Бинарные операции и сжатие генов в Go

Я читаю «Классические задачи по информатике в Python» Дэвида Копека, чтобы изучить алгоритмы CS.

Одна из первых задач — сжатие генов, где цель — закодировать строку нуклеотидов («ACGTA…») в двоичный код, чтобы уменьшить ее размер в памяти.

Сжатые данные хороши тем, что меньше нужно скачивать и загружать, получая при этом то же информационное содержание.

Свойство нуклеотидов ограничиваться набором из четырех букв «A», «C», «G» и «T» позволяет кодировать их с помощью простых целых чисел — 0, 1, 2, 3.
Хранение чисел более эффективно с точки зрения памяти, чем хранение строк, которые представляют собой наборы байтов, представляющих уникальные символы в UTF-8 или ASCII.

Хотя в книге алгоритм сжатия/декомпрессии реализован на языке Python, я подумал, что будет хорошим обучающим упражнением написать его на языке Go и представить обзор двоичных чисел.

Основы двоичных чисел

Для представления чисел в большинстве ситуаций мы обычно используем целочисленные значения по основанию 10. Однако основание 10 является громоздким, когда вы хотите моделировать данные с помощью аппаратных средств, таких как переключатели в состоянии «включено/выключено».
Но основание 2 можно легко смоделировать с помощью переключателей, и это естественным образом подходит для компьютеров.

Десятичная дробь Расширение основания 2 Двоичная
0 0 (20) 0
1 1 (20) 1
2 1 (21) + 0 (20) 10
3 1 (21) + 1 (20) 11
7 1 (22) + 1 (21) + 1 (20) 111
8 1 (23) + 0 (22) + 0 (21) + 0 (20) 1000

Коэффициенты степеней дают двоичное представление десятичного числа.

Двоичные операции в Go

Мы можем немного поразвлечься с двоичными операциями в Go.
Двоичные литералы записываются с префиксом 0b, например 0b101.

Игровая площадка для двоичных операций

package main

import "fmt"

func main() {
    fmt.Println(0b11) // 3

    fmt.Println(0b11 == 3)   // true
    fmt.Println(0b1000 == 8) // true

    // Left shift moves the bits to the left, introducing 0s on the right,
    // increasing the number
    fmt.Println(0b10 << 1)         // 4 i.e 100
    fmt.Println(0b1 << 2)          // also 4
    fmt.Println(0b1<<2 == 0b10<<1) // true

    // Right shift moves the bits to the right, losing columns and reducing the number.
    fmt.Println(0b011>>1 == 1)  // true
    fmt.Println(0b1111 >> 2)    // 3 i.e. 11
    fmt.Println(0b1110>>2 == 3) // true

    // ANDing with & produces a 1 where both columns are 1, else 0.
    fmt.Println(0b110&0b100 == 0b100) // true
    fmt.Println(0b01&0b00 == 0)       // true

    // ORing with | produces a 1 where either column or both are 1, else 0.
    fmt.Println(0b11|0b01 == 0b11)    // true
    fmt.Println(0b100|0b011 == 0b111) // true

    // ExclusiveORing with ^ produces a 1 only where columns are unequal, else 0.
    fmt.Println(0b11^0b00 == 0b11)  // true
    fmt.Println(0b100^0b101 == 0b1) // true
}
Вход в полноэкранный режим Выход из полноэкранного режима

Алгоритм сжатия генов

Двоичные операции действительно сияют, когда применяются к проблеме сжатия генов.

Давайте приступим.

mkdir geneCompression
cd geneCompression
Войти в полноэкранный режим Выход из полноэкранного режима

Создайте файл main.go с типом CompressedGene.

package main

type CompressedGene struct {
    bits int
}
Войдите в полноэкранный режим Выйти из полноэкранного режима

Мы создадим функцию-конструктор и частный метод compress, который вызывается внутри конструктора для хранения гена в качестве поля bits.


func (cg *CompressedGene) compress(gene string) error {
    cg.bits = 1 // sentinel value so we can store 00 and 01 as first nucleotide
    for _, nucleotide := range strings.ToUpper(gene) {
        cg.bits = cg.bits << 2 // left shift by 2 to introduce 00
        switch nucleotide {
        case 'A':
            cg.bits |= 0b00
        case 'C':
            cg.bits |= 0b01
        case 'G':
            cg.bits |= 0b10
        case 'T':
            cg.bits |= 0b11
        default:
            return fmt.Errorf("Invalid nucleotide: %c", nucleotide)
        }
    }
    return nil
}

// NewCompressedGene provides a compressed gene struct reference.
func NewCompressedGene(gene string) (*CompressedGene, error) {
    cg := CompressedGene{}
    err := cg.compress(gene)
    if err != nil {
        return nil, err
    }
    return &cg, nil
}
Вход в полноэкранный режим Выход из полноэкранного режима

Для сжатия мы начинаем с 1 в качестве сентинельного значения. Начало с 0 привело бы к потере генов, если бы мы добавили 00 или 01 в качестве первых элементов, поскольку ведущие нули игнорируются в двоичных выражениях.

Для создания двух столбцов для хранения гена выполняется сдвиг влево. С помощью оператора or equals |= изменяем значение крайних правых битов в соответствии с геном и сохраняем его.

Декомпрессия будет обратна методу сжатия.

// Decompress expands the bits into a string.
func (cg *CompressedGene) Decompress() (string, error) {
    gene := ""

    // bitLength - 1 to ignore sentinel value
    for i := 0; i < bitLength(cg.bits)-1; i += 2 {
        rightShifted := cg.bits >> i
        fmt.Printf("Rightshifted by %d - %bn", i, rightShifted)

        // get the two rightmost bits
        rightPair := rightShifted & 0b11

        switch rightPair {
        case 0b00:
            gene += "A"
        case 0b01:
            gene += "C"
        case 0b10:
            gene += "G"
        case 0b11:
            gene += "T"
        default:
            return "", fmt.Errorf("Invalid bits: %d", rightPair)
        }
    }
    return reverse(gene), nil
}

func bitLength(n int) int {
    return int(math.Floor(math.Log2(float64(n))) + 1)
}

func reverse(s string) string {
    r := []rune(s)
    for i, j := 0, len(r)-1; i < len(r)/2; i, j = i+1, j-1 {
        r[i], r[j] = r[j], r[i]
    }
    return string(r)
}

Вход в полноэкранный режим Выход из полноэкранного режима

Мы используем вспомогательную функцию bitLength для определения количества бит в целочисленном значении и функцию reverse для обратного преобразования расширенной строки. Поскольку мы идем справа налево, превращая биты в гены, построенная нами строка является обратной исходной. Вот почему необходим последний шаг — реверсирование строки.

Мы можем проверить, что все работает так, как ожидалось!

func main() {
    gene := "ACGT"
    fmt.Printf("Original gene: %sn", gene)
    compressed, err := NewCompressedGene(gene)
    if err != nil {
        log.Fatalln(err)
    }
    fmt.Printf("Bits: %bn", compressed.bits)
    decompressed, err := compressed.Decompress()
    if err != nil {
        log.Fatalln(err)
    }
    fmt.Printf("Matches original gene: %vn", decompressed == gene)
}
Вход в полноэкранный режим Выход из полноэкранного режима
go run main.go
Original gene: ACGT
Bits: 100011011
Rightshifted by 0 - 100011011
Rightshifted by 2 - 1000110
Rightshifted by 4 - 10001
Rightshifted by 6 - 100
Matches original gene: true
Вход в полноэкранный режим Выход из полноэкранного режима

Единственная проблема с этим алгоритмом заключается в том, что он не работает для больших последовательностей строк.

Если изменить значение gene и повторить запуск — мы получим совершенно неожиданные результаты.

func main() {
    gene := strings.Repeat("TAGGGATTAACCGTTATATATATATAGCCATGGATCGATTATATAGGGATTAACCGTTATATATATATAGCCATGGATCGATTATA", 100)
    compressed, err := NewCompressedGene(gene)
    if err != nil {
        log.Fatalln(err)
    }
    fmt.Printf("Bits: %bn", compressed.bits)
    decompressed, err := compressed.Decompress()
    if err != nil {
        log.Fatalln(err)
    }
    fmt.Printf("Matches original gene: %vn", decompressed == gene)
}
Вход в полноэкранный режим Выход из полноэкранного режима

Большие генные строки вызовут проблемы с этим алгоритмом, потому что наше поле bits int легко переполнится, как только мы достигнем его 64-битного предела! Тип int в go — это 32 или 64 бита в зависимости от базовой системы. Но мы определенно не хотим такого ограничения, поэтому нам придется использовать могущественный big.Int.

Использование big.Int

Тип big.Int не поддерживает символы двоичных операций, таких как & и <<, но у него есть методы, которые делают то же самое.
Мы можем использовать методы Or, And, Lsh (сдвиг влево) и Rsh (сдвиг вправо) для нашей логики.

// CompressedGene models a compressed format gene sequence
type CompressedGene struct {
    bits *big.Int
}

func (c *CompressedGene) compress(gene string) error {
    c.bits = big.NewInt(1) // sentinel value

    for _, nucleotide := range strings.ToUpper(gene) {
        c.bits.Lsh(c.bits, 2)
        switch nucleotide {
        case 'A':
            c.bits.Or(c.bits, big.NewInt(0b00))
        case 'C':
            c.bits.Or(c.bits, big.NewInt(0b01))
        case 'G':
            c.bits.Or(c.bits, big.NewInt(0b10))
        case 'T':
            c.bits.Or(c.bits, big.NewInt(0b11))
        default:
            return fmt.Errorf("Invalid nucleotide: %c", nucleotide)
        }
    }
    return nil
}

// Decompress expands the compressed bit sequence into
// a string of nucleotides A,C,G,T.
func (c *CompressedGene) Decompress() (string, error) {
    gene := ""

    // bitlen - 1 to ignore sentinel value
    for i := 0; i < c.bits.BitLen()-1; i += 2 {
        rightShifted := &big.Int{} // local value to not mutate the original
        rightShifted.Rsh(c.bits, uint(i))
        // Getting the two, rightmost bits.
        rightBits := (rightShifted.And(rightShifted, big.NewInt(0b11))).Uint64()
        switch rightBits {
        case 0b00:
            gene += "A"
        case 0b01:
            gene += "C"
        case 0b10:
            gene += "G"
        case 0b11:
            gene += "T"
        default:
            return "", fmt.Errorf("Invalid bits: %d", rightBits)
        }
    }
    return reverse(gene), nil
}
Вход в полноэкранный режим Выход из полноэкранного режима

Конструктор остается прежним.

Давайте настроим главную функцию на вывод размеров байтов, чтобы мы могли оценить ценность этого алгоритма сжатия.

func main() {
    s := strings.Repeat("TAGGGATTAACCGTTATATATATATAGCCATGGATCGATTATATAGGGATTAACCGTTATATATATATAGCCATGGATCGATTATA", 100)
    c, err := NewCompressedGene(s)
    if err != nil {
        log.Fatalln(err)
    }
    fmt.Printf("String bytes: %dn", len([]byte(s)))
    fmt.Printf("Compressed bytes: %dn", len(c.bits.Bytes()))
    d, err := c.Decompress()
    if err != nil {
        log.Fatalln(err)
    }
    fmt.Printf("Matches original gene: %vn", d == s)
}

Вход в полноэкранный режим Выход из полноэкранного режима
go run main.go
String bytes: 8600
Compressed bytes: 2151
true
Вход в полноэкранный режим Выход из полноэкранного режима

Вот так! Мы сэкономили довольно много памяти, закодировав гены в большой int.
Реализовав этот алгоритм в Go, мы узнали о типе Go big.Int и применении двоичных операций для кодирования данных с целью сжатия.

Для меня это была забавная демонстрация того, насколько интересными могут быть вычислительные задачи и какие изобретательные способы их решения существуют.

Мне не терпится найти и реализовать остальные алгоритмы из этой книги!

Оцените статью
devanswers.ru
Добавить комментарий