1. 1
  2. 2
  3. 3
  4. 4
  5. 5
  6. 6
  7. 7
  8. 8

# теория · шаг 2 из 8

LZ77: ссылка назад вместо повтора

Коротко

LZ77 заменяет кусок текста, который уже встречался, ссылкой: «вернись на столько-то символов назад и скопируй столько-то». Всё, что не удалось заменить, записывается как есть. Словаря нет - словарём служит сам уже записанный текст, точнее его последний участок, окно. Распаковка - чтение и копирование, она проста и быстра. Ссылка может быть длиннее расстояния назад: тогда копируемый кусок перекрывается с тем, что прямо сейчас пишется, и тысяча букв a превращается в символ и одну ссылку. Замер: журнал запросов из 826 символов записан в 65 единиц - 44 символа и 21 ссылку; это примерно 900 бит вместо 6608.

Если ты разложишь abracadabra на символы и ссылки, распакуешь ссылку с длиной больше расстояния и объяснишь, чем ограничивает сжатие размер окна, - листай до «Теперь сам».

Как думаешь, что запишет ссылка длиннее расстояния

Правило записи: либо символ как есть, либо пара «назад, длина» - отойти на назад символов от конца уже распакованного и скопировать длина символов.

Распакованный текст пока состоит из одной буквы a. Дальше стоит пара «назад 1, длина 9». Прежде чем читать дальше, подумай: позади всего один символ, а скопировать велено девять. Это ошибка в записи? Или у такой пары есть смысл - и тогда что получится?

На что это похоже

Представь, что ты диктуешь по телефону длинный договор, а собеседник записывает. Дойдя до абзаца, который уже был, ты не читаешь его снова. Ты говоришь: «тут то же, что на прошлой странице, с третьей строки, шесть строк». Собеседник смотрит в собственные записи и переписывает.

Заранее никто ни о чём не договаривался: никакого списка типовых абзацев у него нет. Справочником служит то, что он сам успел записать. Единственное ограничение - его память: он может отлистать назад на несколько страниц, но не на сто.

Механизм: окно, символы и ссылки

LZ77 (Лемпель и Зив, 1977) идёт по тексту слева направо. В каждой позиции он ищет в окне - последних W символах уже обработанного текста - самый длинный кусок, совпадающий с тем, что стоит дальше. Нашёл достаточно длинный - пишет ссылку (назад, длина) и перепрыгивает через весь кусок. Не нашёл - пишет один символ как есть (такой символ называют литералом) и сдвигается на единицу.

текст       a b r a c a d a b r a
позиция     0 1 2 3 4 5 6 7 8 9 10

позиции 0-6:   a  b  r  a  c  a  d         семь символов как есть: кусков длиной 3 и больше позади нет
позиция 7:     дальше стоит abra; оно же стоит с позиции 0, это 7 символов назад
               → ссылка (назад 7, длина 4)

запись:   a  b  r  a  c  a  d  (7, 4)       8 единиц вместо 11 символов

Короткие совпадения ссылкой не заменяют: ссылка сама занимает место, и на один-два символа она дороже литералов. В этом уроке порог - три символа, как в распространённом формате deflate.

Распаковка зеркальна и проще: символ - дописать; ссылка - отойти назад и скопировать.

Правило

Запись LZ77 - последовательность литералов и ссылок (назад, длина). Ссылка означает: взять символ, стоящий в уже распакованном тексте на назад позиций от конца, дописать его в конец, и повторить это длина раз. Копирование идёт ПО ОДНОМУ СИМВОЛУ, поэтому длина может быть больше расстояния: только что дописанные символы сами становятся источником. Сжатие ограничено окном: повтор, отстоящий дальше W символов, не будет найден.

Разбор: распаковка и перекрытие

def raspakovat(zapis):
    out = []
    for z in zapis:
        if len(z) == 1:
            out.append(z[0])                      # литерал: [символ]
        else:
            nazad = z[0]                          # ссылка: [назад, длина]
            dlina = z[1]
            k = 0
            while k < dlina:
                out.append(out[len(out) - nazad]) # по одному символу
                k = k + 1
    return "".join(out)

print(raspakovat([["a"], [1, 9]]))                # aaaaaaaaaa
print(raspakovat([["a"], ["b"], [2, 6]]))         # abababab

Ответ на вопрос из начала урока. Пара «назад 1, длина 9» после одной буквы a - не ошибка. Первый шаг копирует символ на одну позицию от конца, это a; теперь в тексте aa. Второй шаг снова берёт символ на одну позицию от конца - это a, дописанная только что. И так девять раз. Ссылка догоняет собственный хвост, и получается серия: десять букв a записаны двумя единицами. LZ77 умеет то же, что RLE, не имея для серий отдельного правила.

Вторая строка - тот же приём с шагом два: ab, затем шесть символов, каждый берётся на две позиции от конца.

Разбор: упаковка и замер

Упаковщик ищет самое длинное совпадение перебором всех начал в окне. Это медленно, зато читается как определение:

def upakovat(s, okno):
    zapis = []
    i = 0
    while i < len(s):
        luchshaya = 0
        gde = 0
        j = max(0, i - okno)
        while j < i:                              # все начала в окне
            d = 0
            while i + d < len(s) and s[j + d] == s[i + d]:
                d = d + 1                         # j + d может зайти за i - это перекрытие
            if d >= luchshaya and d > 0:
                luchshaya = d
                gde = i - j                       # при равной длине - ближняя ссылка
            j = j + 1
        if luchshaya >= 3:
            zapis.append([gde, luchshaya])
            i = i + luchshaya
        else:
            zapis.append([s[i]])
            i = i + 1
    return zapis

Для замера нужен текст, похожий на настоящий. Вот журнал из сорока строк с четырьмя разными путями:

puti = ["/index.html", "/api/users", "/api/orders", "/static/app.js"]
stroki = []
s = 7
i = 0
while i < 40:
    s = (s * 75 + 74) % 65537
    stroki.append("GET " + puti[s % 4] + " 200\n")
    i = i + 1
zhurnal = "".join(stroki)                         # 826 символов

Замер с окном 4096. Чтобы сравнивать размеры, примем грубую цену: литерал - 9 бит (бит-признак «это литерал» и байт), ссылка - 24 бита (бит-признак, 12 бит расстояния, 11 бит длины). Исходный текст - 8 бит на символ.

текст                                 символов   литералов   ссылок     бит     было бит
abracadabra                               11          7          1        87         88
"abracadabra " * 10                      120          8          2       120        960
zhurnal                                  826         44         21       900      6 608
tekst(1000, 4, 1)                       1000         40        228     5 832      8 000
tekst(1000, 26, 1)                      1000        943         19     8 943      8 000

В журнале первые строки идут литералами, дальше - длинные ссылки: одна ссылка накрывает целую строку журнала, а то и две-три подряд, если такая же последовательность строк уже встречалась. Сжатие в семь раз, и это на данных, энтропия которых по частотам символов - 3578 бит: LZ77 видит повторы КУСКОВ, а подсчёт частот - нет.

Последняя строка - обратная сторона. В случайном тексте из 26 букв кусков длиной три, которые уже встречались, почти нет, и запись вышла ДЛИННЕЕ исходной: каждый литерал несёт лишний бит признака.

Настоящие упаковщики не перебирают окно. Они держат хеш-таблицу «три символа → где они встречались раньше» и смотрят только на эти места. Формат записи от этого не меняется: распаковщику всё равно, как упаковщик искал совпадения.

Что ломается без этого

Первое: копирование срезом. Строка out = out + out[nach:nach + dlina], где nach = len(out) - nazad, выглядит как то же самое, но срез берётся из текста, каким он был ДО копирования. Для ссылки (1, 9) срез содержит одну букву, и в тексте окажутся две a вместо десяти. Ошибка проявляется только на перекрывающихся ссылках, то есть как раз на сериях.

Второе: расстояние от начала, а не от конца. назад отсчитывается от текущего конца распакованного текста. Если понять его как позицию от начала, первые ссылки случайно совпадут, остальные дадут мусор.

Третье: окно меньше расстояния между повторами. Формат deflate, на котором работают gzip и zip, видит 32 килобайта назад. Два одинаковых файла по мегабайту, склеенные вместе, он сожмёт так, будто они разные: второй экземпляр начинается за пределами окна. Архиваторы с окном в мегабайты такой повтор находят.

Четвёртое: распаковка без предела. Две единицы записи дали десять символов; тем же приёмом несколько килобайт архива разворачиваются в гигабайты. Это известная атака - «архивная бомба». Программа, которая распаковывает чужие данные, обязана ограничивать размер результата.

Зачем это в работе

Первый сюжет - почти всё, что сжато вокруг. Форматы gzip, zip и PNG используют deflate: LZ77 с окном 32 килобайта, после которого литералы, длины и расстояния кодируются кодом Хаффмана - частые значения короче. Сжатие веб-страниц, пакеты программ, образы систем - это потомки той же схемы с большим окном и более точным кодированием: zstd, brotli, xz.

Второй - выбор уровня сжатия. Формат один, а упаковщик может искать совпадения быстро и небрежно или долго и тщательно. Уровни от 1 до 9 в gzip - это не разные алгоритмы, а разная настойчивость поиска. Распаковка при этом одинаково быстра для всех уровней, поэтому то, что упаковывается один раз, а читается миллионы раз, выгодно жать на максимуме.

Третий - понимание, что сжимается хорошо. Журналы, код, таблицы в текстовом виде полны повторяющихся кусков - LZ77 в своей стихии. Уже сжатые картинки и зашифрованные данные повторов не содержат, и на них любой архиватор этого семейства бесполезен.

Теперь сам

Распакуй запись x, y, z, (3, 5), w, (9, 2). Затем скажи, что получится из q, (1, 4), если копировать срезом, как в первой ошибке из списка.

Ответ. После трёх литералов - xyz. Ссылка (3, 5): берём символ на три позиции от конца, пять раз подряд.

xyz      → x  (третий от конца)     xyzx
xyzx     → y                        xyzxy
xyzxy    → z                        xyzxyz
xyzxyz   → x                        xyzxyzx
xyzxyzx  → y                        xyzxyzxy

Длина 5 больше расстояния 3 - два последних символа скопированы уже из дописанного. Затем литерал w: xyzxyzxyw, девять символов. Ссылка (9, 2): девять позиций от конца - это самое начало, копируем x, затем y. Итог - xyzxyzxywxy.

Запись q, (1, 4) правильно распаковывается в qqqqq. Срез out[0:4] из текста q содержит один символ, и получится qq: три символа потеряны, и ни одной ошибки программа не сообщит.

Главное

LZ77 сжимает повторы кусков, и вся его сила в том, что словарь не нужно ни строить, ни передавать: им служит уже распакованный текст. Отсюда простота распаковки - читать и копировать - и два ограничения. Окно: повтор дальше W символов не виден. Данные: без повторяющихся кусков ссылок не будет, и запись выйдет длиннее исходной. Копирование по одному символу превращает ссылку в серию - поэтому отдельный RLE схеме не нужен, а распаковщик обязан копировать именно так, а не срезом.

Комментарии

Пока нет комментариев. Будь первым!

Оставить комментарий

Комментарий появится после проверки. Email не публикуется. Войти, чтобы не вводить имя каждый раз.