Введение

Алгоритмы сжатия данных без потерь LZ77 и LZ78 — это два алгоритма сжатия данных без потерь, опубликованные в статьях Авраама Лемпеля и Джейкоба Зива в 1977 и 1978 годах. Они также известны как LZ1 и LZ2 соответственно. Эти два алгоритма являются основой для множества вариаций, включая LZW, LZSS, LZMA и другие. Помимо их академического влияния, эти алгоритмы легли в основу нескольких широко используемых схем сжатия, включая GIF и алгоритм DEFLATE, применяемый в PNG и ZIP. Теоретически, оба алгоритма являются кодировщиками на основе словаря. LZ77 поддерживает скользящее окно в процессе сжатия. Позже было показано, что это эквивалентно явному словарю, создаваемому LZ78, однако такое равенство выполняется только при условии декомпрессии всего объема данных. Поскольку LZ77 кодирует и декодирует, используя скользящее окно по ранее встреченным символам, декомпрессия всегда должна начинаться с начала входных данных. Концептуально, декомпрессия LZ78 могла бы обеспечить произвольный доступ к входным данным, если бы весь словарь был известен заранее. Однако на практике словарь создается в процессе кодирования и декодирования путем добавления новой фразы при каждом выводе токена. В 2004 году эти алгоритмы были признаны IEEE Milestone. В 2021 году Джейкоб Зив был удостоен медали IEEE Medal of Honor за вклад в их разработку.

Теоретическая эффективность

Вторая из двух статей, в которых были представлены эти алгоритмы, анализирует их как кодировщики, определяемые конечными автоматами. Для отдельных последовательностей (в отличие от вероятностных множеств) разработана мера, аналогичная информационной энтропии. Эта мера определяет верхнюю границу коэффициента сжатия данных, который можно достичь. Затем показано, что для каждой последовательности существует конечное без потерь кодирование, достигающее этой границы при бесконечном увеличении длины последовательности. Таким образом, алгоритм, основанный на этой схеме, генерирует асимптотически оптимальные кодировки. Этот результат можно доказать более прямо, как, например, в записках Питера Шора. Формально (теорема 13.5.3). Аналогичные теоремы применимы и к другим вариантам алгоритма LZ.

LZ77

Алгоритмы LZ77 достигают сжатия путем замены повторяющихся последовательностей данных ссылками на единственную копию этих данных, существующую ранее в несжатом потоке. Совпадение кодируется парой чисел, называемой парой «длина-расстояние», что эквивалентно утверждению: «каждый из следующих `длина` символов равен символам, находящимся ровно на `расстояние` символов позади текущей позиции в несжатом потоке». (Расстояние иногда называют смещением.) Для обнаружения совпадений кодировщик должен отслеживать некоторое количество самых последних данных, например, последние 2 КБ, 4 КБ или 32 КБ. Структура, в которой хранятся эти данные, называется скользящим окном, поэтому LZ77 иногда называют сжатием со скользящим окном. Кодировщик должен хранить эти данные для поиска совпадений, а декодер — для интерпретации ссылок, сделанных кодировщиком. Чем больше скользящее окно, тем дальше в прошлое кодировщик может искать для создания ссылок. Не только допустимо, но и часто полезно разрешать парам «длина-расстояние» указывать длину, которая фактически превышает расстояние. Как команда копирования, это выглядит парадоксально: «Вернитесь на четыре символа назад и скопируйте десять символов из этой позиции в текущую». Как можно скопировать десять символов, если в буфере фактически есть только четыре? Если обрабатывать по одному байту за раз, то нет проблем с выполнением этого запроса, поскольку при копировании байта он может быть снова подан на вход команде копирования. Когда копирование из исходной позиции достигает конечной позиции назначения, в него поступают данные, которые были вставлены с начала копируемой области. Таким образом, операция эквивалентна утверждению: «копируйте предоставленные данные и повторяйте их вставку, пока они не поместятся». Поскольку этот тип пары повторяет одну копию данных несколько раз, его можно использовать для реализации гибкой и простой формы кодирования длин серий. Другой взгляд на это следующий: во время кодирования, чтобы указатель поиска продолжал находить совпадающие пары за пределами окна поиска, все символы от первого совпадения со смещением D и до конца окна поиска должны соответствовать входным данным, и это (ранее встреченные) символы составляют единицу серии длиной LR, которая должна быть равна D. Затем, по мере продвижения указателя поиска за пределы окна поиска, если шаблон серии повторяется во входных данных, указатели поиска и ввода будут синхронизированы и соответствовать символам до тех пор, пока шаблон серии не будет прерван. Тогда всего будет сопоставлено L символов, L > D, и код будет [D, L, c]. При декодировании [D, L, c], опять же, D = LR. Когда первые LR символов считываются в выходной буфер, это соответствует единице серии, добавленной к выходному буферу. В этот момент указатель чтения можно рассматривать как нуждающийся только в возврате int(L/LR) + (1, если L mod LR ≠ 0) раз к началу этой единицы серии, чтении LR символов (или, возможно, меньше при последнем возврате) и повторении до тех пор, пока не будет прочитано всего L символов. Но, повторяя процесс кодирования, поскольку шаблон повторяется, указателю чтения нужно лишь отставать от указателя записи на фиксированное расстояние, равное длине серии LR, пока в выходной буфер не будет скопировано всего L символов. Учитывая вышесказанное, особенно если ожидается преобладание сжатия серий данных, поиск в окне следует начинать с конца окна и двигаться назад, поскольку шаблоны серий, если они существуют, будут найдены первыми и позволят завершить поиск, либо абсолютно, если достигнута текущая максимальная длина совпадающей последовательности, либо разумно, если достигнута достаточная длина, и, наконец, просто потому, что данные более свежие и могут лучше коррелировать со следующим входом.

Реализация

Несмотря на то, что все алгоритмы LZ77 по определению работают по одному и тому же основному принципу, они могут сильно различаться в способах кодирования сжатых данных, чтобы изменять числовые диапазоны пары «длина-расстояние», изменять количество бит, потребляемых для пары «длина-расстояние», и отличать эти пары от литералов (необработанные данные, кодируемые как таковые, а не как часть пары «длина-расстояние»). Вот несколько примеров: алгоритм, описанный в оригинальной статье Лемпеля и Зива 1977 года, выводит все данные тремя значениями за раз: длина и расстояние самого длинного совпадения, найденного в буфере, и литерал, следующий за этим совпадением. Если два последовательных символа во входном потоке можно закодировать только как литералы, длина пары «длина-расстояние» будет равна 0. LZSS улучшает LZ77, используя 1-битовый флаг для указания, является ли следующий блок данных литералом или парой «длина-расстояние», и используя литералы, если пара «длина-расстояние» будет длиннее. В формате PalmDoc пара «длина-расстояние» всегда кодируется последовательностью из двух байтов. Из этих 16 бит 11 бит отводятся на кодирование расстояния, 3 – на кодирование длины, а оставшиеся два используются для того, чтобы декодер мог идентифицировать первый байт как начало такой двухбайтовой последовательности. В реализации, используемой во многих играх Electronic Arts, размер в байтах пары «длина-расстояние» может быть указан в первом байте самой пары; в зависимости от того, начинается ли первый байт с 0, 10, 110 или 111 (при чтении в порядке big-endian), длина всей пары «длина-расстояние» может составлять от 1 до 4 байтов. По состоянию на 2008 год наиболее популярным методом сжатия на основе LZ77 является DEFLATE; он сочетает LZSS с кодированием Хаффмана. Литералы, длины и символ, указывающий на конец текущего блока данных, помещаются вместе в один алфавит. Расстояния можно безопасно поместить в отдельный алфавит, поскольку расстояние встречается только после длины и не может быть ошибочно принято за другой тип символа или наоборот.

LZ78

Алгоритмы LZ78 сжимают последовательные данные, создавая словарь последовательностей токенов из входных данных, а затем заменяя второе и последующие вхождения последовательности в потоке данных ссылкой на запись словаря. Наблюдение состоит в том, что количество повторяющихся последовательностей является хорошей мерой неслучайности последовательности. Алгоритмы представляют словарь в виде n-арного дерева, где n — количество токенов, используемых для формирования последовательностей токенов. Каждая запись словаря имеет вид (индекс, токен), где индекс — это индекс записи словаря, представляющей ранее встреченную последовательность, а токен — следующий токен из входных данных, который делает эту запись уникальной в словаре. Обратите внимание, что алгоритм является жадным, поэтому ничего не добавляется в таблицу, пока не будет найден уникальный токен. Алгоритм заключается в инициализации последнего совпавшего индекса = 0 и следующего доступного индекса = 1, а затем, для каждого токена входного потока, поиск совпадения в словаре: если совпадение найдено, то последний совпавший индекс устанавливается на индекс совпадающей записи, ничего не выводится, и последний совпавший индекс остается представлять входные данные до этого момента. Вход обрабатывается до тех пор, пока не будет найдено несовпадение. Затем создается новая запись словаря (индекс, токен), и алгоритм выводит последний совпавший индекс, за которым следует токен, затем сбрасывает последний совпавший индекс = 0 и увеличивает следующий доступный индекс. В качестве примера рассмотрим последовательность токенов AABABBABA, которая сформирует следующий словарь:

и выходная последовательность сжатых данных будет (1, A), (2, B), (1, A), (3, B), (1, A). Обратите внимание, что последнее A еще не представлено, поскольку алгоритм не может знать, что будет дальше. На практике, например, к вводу добавляется маркер EOF. Также обратите внимание, что в этом случае выход длиннее исходного ввода, но степень сжатия значительно улучшается по мере роста словаря, и в двоичном представлении индексы не требуют больше, чем минимальное количество бит. Декомпрессия заключается в восстановлении словаря из сжатой последовательности. Из последовательности (1, A), (2, B), (1, A), (3, B), (1, A) первая запись всегда является терминатором, а первая запись из последовательности будет A, которая добавляется к выводу. Вторая пара из входных данных — (2, B), что приводит к созданию записи номер 2 в словаре. Токен "B" выводится, предваряемый последовательностью, представленной записью словаря 1. Запись 1 — это "A" (за которой следует "запись 0" — ничего), поэтому "A" добавляется к выводу. Далее AB добавляется в словарь как следующая запись (3, AB), а "B" (предваряемый ничем) добавляется к выводу. Наконец, создается запись словаря для AABA, и выводится AABA, в результате чего получается AABABBABA или, удалив пробелы и маркер EOF.

ЛЗВ

LZW — это алгоритм на основе LZ78, использующий словарь, предварительно инициализированный всеми возможными символами, или эмуляцию предварительно инициализированного словаря. Главное улучшение LZW заключается в том, что при отсутствии совпадения текущий символ входного потока считается первым символом существующей строки в словаре (поскольку словарь инициализирован всеми возможными символами), поэтому выводится только индекс последнего совпадения (который может соответствовать предварительно инициализированному индексу словаря для предыдущего или начального входного символа). Подробности реализации см. в статье, посвященной LZW. BTLZ — это алгоритм на основе LZ78, разработанный для систем связи в реальном времени (изначально для модемов) и стандартизированный CCITT/ITU как V.42bis. Когда словарь, организованный в виде дерева, заполнен, используется простой алгоритм повторного использования/восстановления, обеспечивающий адаптацию словаря к изменяющимся данным. Счетчик последовательно просматривает словарь. При необходимости новой записи счетчик перемещается по словарю, пока не будет найден листовой узел (узел без потомков). Этот узел удаляется, а освободившееся место используется для новой записи. Это проще в реализации, чем LRU или LFU, и обеспечивает сопоставимую производительность.