14.1. Алгоритм Хаффмана и оптимальное кэширование
Алгоритм Хаффмана
Алгоритм Хаффмана (Huffman, 1952) — один из самых известных алгоритмов сжатия текста. Пусть дан текст, состоящий из символов алфавита $\Sigma$, который мы хотим закодировать как можно более короткой последовательностью бит.
Префиксные коды
Самый простой способ — кодировать каждый символ уникальной последовательностью из $k$ бит (будем называть такую последовательность кодовым словом (codeword)), где $2^{k} \geqslant|\Sigma|$ (чтобы такое кодирование вообще было возможно). Такой способ не всегда эффективен: например, пусть $\Sigma=\{a, b, c, d\}$, текст имеет длину 10000, но большая часть символов текста равна $a$ (скажем, $a$ встречается в тексте 7000 раз, а остальные символы по 1000 раз). Тогда вышеописанный способ кодирования потребует $20000$ бит. Если же кодовое слово символа $a$ будет равно $0$, символа $b$ — $10$, $c$ — $110$, $d$ — $111$, то суммарно потребуется лишь $7000 \cdot 1+1000 \cdot 2+1000 \cdot 3+1000 \cdot 3=15000$ бит.
Определение
Префиксный код (prefix code) — это такой код с переменной длиной кодового слова, в котором ни одно кодовое слово не является префиксом другого. Это свойство гарантирует однозначность декодирования: закодированную последовательность бит можно разобрать на кодовые слова единственным способом.
Итак, иногда оказывается выгодным использовать кодовые слова разной длины для разных символов. Заметим, что при этом нельзя использовать одновременно кодовые слова, одно из которых является префиксом другого: в этом случае нельзя будет понять, где в закодированной последовательности бит заканчивается одно кодовое слово и начинается следующее.
Какой префиксный код будет оптимальным для данного текста $t$? Пусть $\operatorname{cnt}(t, a)$ — количество вхождений символа $a$ в текст (будем называть это количество частотой), $\operatorname{len}(C, a)$ — длина кодового слова символа $a$ в префиксном коде $C$. Тогда количество бит, которое понадобится для того, чтобы закодировать текст $t$ кодом $C$, равняется
$$ L(C, t)=\sum_{a} \operatorname{cnt}(t, a) \cdot \operatorname{len}(C, a). $$Мы хотим найти префиксный код $C$, минимизирующий $L(C, t)$.
Связь с двоичными деревьями
Любому префиксному коду можно сопоставить двоичное дерево: кодовые слова будут соответствовать листьям дерева; для того, чтобы по листу получить кодовое слово, нужно пройти по пути из корня в этот лист и выписывать 0 при спуске в левого ребёнка, 1 — при спуске в правого.
В дереве оптимального префиксного кода ни у какой вершины не может быть ровно один ребёнок: такую вершину можно просто удалить, поставив её ребёнка на её место; при этом новое дерево будет всё ещё соответствовать префиксному коду, но длина некоторых кодовых слов уменьшится.

Префиксный код и соответствующее ему двоичное дерево
Жадный алгоритм
Теорема
Всегда существует оптимальный префиксный код, в дереве которого два самых редких символа находятся в соседних листьях на максимальной глубине.
Пусть $C$ — такой оптимальный код, $a$ и $b$ — два самых редких символа в $t$; они находятся в листьях $v, u$ с общим родителем $w$. Введём новый символ $c$ и рассмотрим текст $t_{1}$, полученный из $t$ заменой всех вхождений $a$ и $b$ на $c$. Также рассмотрим префиксный код $C_{1}$, соответствующий дереву кода $C$, из которого удалили $v$ и $u$, а в ставшую листом вершину $w$ поместили символ $c$. Заметим, что $L(C, t)=L\left(C_{1}, t_{1}\right)+\operatorname{cnt}(t, a)+\operatorname{cnt}(t, b)$.
Теорема
Если $C_{1}$ — оптимальный код для $t_{1}$, то по любому оптимальному коду для $t_{1}$ можно построить оптимальный код для $t$.
Будем применять эти рассуждения рекурсивно, и получать тексты, в которых всё меньше и меньше различных символов. В конце концов мы получим текст, в котором встречается всего два различных символа. Оптимальный префиксный код для такого текста кодирует каждый символ одним битом.
Реализация
Будем хранить символы в очереди с приоритетами: приоритет символа равен его частоте. Пока в очереди больше одного символа, будем доставать из очереди два символа $a, b$ с минимальными приоритетами $\operatorname{cnt}_{a}, \operatorname{cnt}_{b}$, и складывать в очередь новый символ $c$ с приоритетом $\operatorname{cnt}_{c}=\operatorname{cnt}_{a}+\operatorname{cnt}_{b}$. В этот момент будем подвешивать вершины, соответствующие символам $a$ и $b$, детьми к вершине, соответствующей символу $c$. В конце в очереди останется только один символ; вершина, соответствующая этому символу, будет корнем дерева оптимального префиксного кода. Получаем время работы $O(|\Sigma| \log |\Sigma|)$ (если считать, что частоты символов уже предподсчитаны).
Свойства
Для того чтобы закодированный текст можно было потом раскодировать, необходимо вместе с ним хранить дерево префиксного кода или таблицу частот, по которой дерево можно будет построить заново.
Оптимальное кэширование
Под кэшированием (caching) обычно имеют в виду хранение малого количества данных в быстрой памяти, производимое для того, чтобы реже взаимодействовать с медленной памятью. В современных компьютерах кэширование происходит одновременно на многих уровнях: есть кэш процессора, более медленная оперативная память, ещё более медленный жёсткий диск. Сам жёсткий диск используется браузерами для кэширования часто посещаемых веб-страниц: чтение с диска быстрее, чем их повторная загрузка из интернета.
Кэширование тем эффективнее, чем чаще оказывается, что запрашиваемые данные уже находятся в кэше. Алгоритм управления кэшом определяет, какую информацию хранить в кэше и какую информацию удалять из него, если требуется записать в кэш новые данные.
Определение
Кэш-промах (cache miss) — ситуация, когда запрошенный элемент не находится в кэше и его необходимо скопировать туда из основной памяти (возможно, вытеснив другой элемент). Задача оптимального кэширования — минимизировать количество кэш-промахов при обработке последовательности запросов $d_1, \ldots, d_m$ при размере кэша $k$.
Заметим, что по любому алгоритму можно построить его «ленивую» версию, которая записывает элемент в кэш только тогда, когда сразу после этого он будет запрошен. При этом она делает не больше операций записи, чем исходный алгоритм; количество операций записи для неё совпадает с количеством кэш-промахов.
Свойства
Количество операций записи в кэш можно считать равным количеству кэш-промахов: запись «впрок» бессмысленна, так как ячейка памяти всё равно не будет использоваться до момента обращения.
Алгоритм Белади
Теорема
Алгоритм Белади (Bélády, 1966) оптимален: когда нужно вытеснить элемент из кэша, он удаляет тот, который понадобится в следующий раз позже всех остальных.
Реализация
Для реализации алгоритма Белади нужно знать будущее: для каждого элемента $d_i$ необходимо заранее предподсчитать следующий индекс его использования. Это делает алгоритм неприменимым на практике, но полезным как теоретический эталон для сравнения.
Свойства
Многие используемые на практике алгоритмы кэширования основаны на принципе LRU (least recently used): из кэша удаляется элемент, который дольше всех не запрашивался. В каком-то смысле это алгоритм Белади, но с изменённым направлением времени: вместо будущих запросов изучаются предыдущие. Этот принцип часто оказывается эффективным, поскольку для многих приложений характерна локальность обращений (locality of reference) — программа часто продолжает обращаться к данным, к которым обращалась недавно.