algorithms · advanced · 6d
Кодирование Хаффмана
Собери lossless-компрессор с нуля: постройте оптимальное дерево prefix-free кодов снизу вверх, выведи битовые строки и докажи, что round-trip точен, а результат короче кодирования фиксированной шириной.
Результат
TypeScript-библиотека, которая строит дерево Хаффмана из частот символов, выводит prefix-free коды, кодирует строку в битовую строку и декодирует обратно — с тестом свойств, доказывающим, что ни один код не является префиксом другого, и тестом сжатия, показывающим, что закодированная длина меньше кодирования с фиксированной шириной.
Этапы
0/6 · 0%- 01Подсчёт частот и построение min-heap приоритетной очереди
Кодирование Хаффмана — жадный алгоритм, который многократно объединяет два узла с наименьшей частотой, поэтому первое структурное решение — как эффективно поддерживать этот порядок. Наивный подход сортирует после каждого слияния с ценой O(n² log n); min-heap хранит минимум в корне и восстанавливает свойство кучи за O(log n) на операцию, давая O(n log n) в целом. До написания дерева реализуй обобщённый MinHeap<T>, параметризованный компаратором — это вынуждает разделить политику приоритета и тип узла, и делает кучу переиспользуемой для любого будущего жадного алгоритма. Считай частоты символов из строки одним проходом (O(n)); инициализируй один узел кучи на каждый уникальный символ; проверь, что куча корректно возвращает узел с наименьшей частотой при каждом извлечении. Думай о разрешении равенства уже сейчас: если два узла имеют одинаковые частоты, порядок извлечения определяет форму дерева и, следовательно, строки кодов. Выбери стабильное, детерминированное правило — например, предпочитай узел с лексикографически меньшим символом или предпочитай лист перед внутренним узлом — и задокументируй его, потому что тест round-trip будет от него зависеть.
Критерии готовности- MinHeap<T> с insert и extractMin корректно возвращает узлы в порядке возрастания частоты, а равенства разрешаются по детерминированному правилу, которое ты можешь сформулировать.
- Для строки 'abracadabra' ты можешь получить карту частот и показать, что порядок извлечения из кучи совпадает с ожидаемой последовательностью жадных слияний.
Самопроверка
Покажи два тест-кейса, где разные правила разрешения равенства дают разные деревья; senior-ревьюер проверяет, что ты задокументировал правило и что оба дерева — валидные деревья Хаффмана (оптимальные для своих упорядочений), а не просто что твоё проходит.
- 02Построение дерева Хаффмана повторными жадными слияниями
Когда min-heap готов, реализуй классическое построение Хаффмана: извлекай два узла с наименьшими частотами, создавай внутренний узел с частотой, равной их сумме, возвращай этот внутренний узел в кучу и повторяй, пока не останется один узел — это корень. Алгоритм чисто жадный и доказуемо оптимален по аргументу обмена: если бы какое-то другое дерево было лучше, можно было бы поменять два самых глубоких листа-сестры с двумя символами наименьшей частоты и уменьшить ожидаемую длину кода, что противоречит предположению. Граничный случай, который пропускают большинство реализаций, — алфавит с одним уникальным символом (например, 'aaaa'): цикл никогда не срабатывает, потому что изначально существует только один узел, поэтому корень является листом, и ему нужно явно назначить код (по соглашению '0'), чтобы кодирование и декодирование всё равно работали. Ещё один тонкий момент: внутренние узлы не несут символа — только листья. Сделай это строгим типовым инвариантом сейчас, потому что обход при декодировании опирается на него.
Критерии готовности- build(freqs) возвращает корень дерева, где каждый лист несёт символ, каждый внутренний узел имеет ровно двух потомков, а суммарный вес (частота корня) равен сумме всех входных частот.
- Случай с одним уникальным символом ('aaaa') даёт дерево с листом-корнем, а codes() назначает ему валидный непустой код, чтобы round-trip работал.
Самопроверка
Нарисуй дерево для 'abracadabra' шаг за шагом (покажи каждое слияние); senior-ревьюер проверяет, что последовательность слияний совпадает с упорядочением кучи, вес корня равен длине строки, а граничный случай одного символа обрабатывается системой типов, а не проверкой в рантайме.
- 03Вывод prefix-free кодов обходом дерева
Преврати дерево в таблицу кодов через DFS: влево → добавляй '0', вправо → добавляй '1', выдавай (символ, накопленные биты) на каждом листе. Полученные коды prefix-free по построению — ни один код не является префиксом другого, потому что коды живут только на листьях, а любой префикс пути к листу — это внутренний узел. Докажи это себе: если код A является префиксом кода B, можно пройти A в дереве и достичь листа, но тогда оставшиеся биты B должны спускаться от листа, у которого нет потомков — противоречие. Напиши тест свойств, который исчерпывающе проверяет все пары в таблице кодов: для каждой пары (a, b), где a ≠ b, проверяй, что ни a.startsWith(b), ни b.startsWith(a). Это важнейший инвариант корректности во всём проекте. Также проверь, что более короткие коды достаются символам с более высокой частотой: гарантия оптимальности Хаффмана утверждает, что назначение длины кода монотонно не возрастает с частотой, поэтому если freq(x) > freq(y), то len(code(x)) ≤ len(code(y)). Это второй тест свойств, и он обнаруживает ошибки построения дерева, которые round-trip в одиночку пропустил бы.
Критерии готовности- codes(tree) возвращает отображение символ→битовая строка, и исчерпывающая попарная проверка подтверждает, что ни один код не является префиксом другого для любого ввода с ≥2 уникальными символами.
- Для известного частотного распределения проверка частоты-против-длины подтверждает, что длина кода более частого символа ≤ длины кода более редкого символа.
Самопроверка
Покажи таблицу кодов для 'abracadabra' и продемонстрируй свойство prefix-free для трёх пар, у которых общий начальный бит; senior-ревьюер проверяет, что тест свойств охватывает все O(k²) пары, а не только соседние.
- 04Кодирование: отображение символов в битовые строки
Кодирование — простая половина: ищи каждый символ в таблице кодов, конкатенируй битовые строки и возвращай результат. Интересные вопросы возникают, когда ты интегрируешь это в реальный компрессор. Во-первых, битовая строка, которую ты производишь, концептуальна — каждый символ это '0' или '1', а не упакованный бит, — что означает, что твой вывод в 8× больше сырых байт до упаковки. Для этого проекта представление строкой символов нормально, потому что позволяет сосредоточиться на алгоритме, но задокументируй разрыв: производственный кодировщик упаковывал бы биты в байты, управлял бы последним частичным байтом со счётчиком дополняющих битов и добавлял бы таблицу кодов (или её каноническую форму) в заголовок, чтобы декодер мог восстановить без внеполосных знаний. Во-вторых, твоя функция encode принимает заранее построенную таблицу кодов, а не исходное дерево — это намеренно, потому что декодирование нуждается в дереве (для обхода ветвей), а кодированию нужна только карта метка-листа-к-битам. Держи два интерфейса разделёнными. В-третьих, измерь сжатие: подсчитай общую длину в битах закодированного вывода и сравни с базовым значением фиксированной ширины ceil(log2(размер_алфавита)) бит на символ. Для любого ввода с искажённым частотным распределением вывод Хаффмана должен быть строго короче — если нет, твоё дерево неверно.
Критерии готовности- encode(s, codes) производит битовую строку, где каждый символ это '0' или '1', длина равна сумме длин кодов для каждого символа в s, и ни один символ вне входного алфавита не используется.
- На искажённом распределении (например, 'a' × 100, 'b' × 50, 'c' × 10, 'd' × 1) закодированная длина в битах строго меньше ceil(log2(4)) × 161 = 322 бит.
Самопроверка
Для теста искажённых частот покажи пошаговое вычисление ожидаемой длины в битах (частота × длина кода для каждого символа, суммировано) и сравни с базовым значением фиксированной ширины; senior-ревьюер проверяет, что ты вычисляешь информационно-теоретический оптимум, а не просто «короче чего-то».
- 05Декодирование: обход дерева побитово
Декодирование — обратная операция: начинай с корня, читай один бит, спускайся влево на '0' или вправо на '1', и выдавай символ листа всякий раз при достижении листа, затем сбрасывайся обратно в корень. Этот обход O(длина_вывода × средняя_длина_кода) и не требует хэш-поиска, поэтому дерево — правильная структура данных для декодирования, даже если карта правильна для кодирования. Корректность декодирования гарантируется свойством prefix-free: поскольку ни один код не является префиксом другого, первый лист, на который ты попадаешь, всегда однозначно является целевым символом. Если тест prefix-free из предыдущего этапа проходит, декодирование тривиально корректно при правильной реализации обхода. Интересный режим отказа — снова случай одного уникального символа: корень — лист без потомков, поэтому цикл 'спускаться по биту' никогда не срабатывает — нужно обрабатывать это особо, выдавая символ для каждого бита во вводе. Напиши тест round-trip: для любой строки s, построенной из известного алфавита, decode(encode(s, codes), tree) === s. Это твой интеграционный тест, и он перекрывает юнит-тесты для encode и decode по отдельности.
Критерии готовности- decode(bits, tree) восстанавливает исходную строку для репрезентативного многосимвольного ввода, и путь единственного уникального символа проверен и корректен.
- Тест round-trip утверждает decode(encode(s, codes), tree) === s для как минимум двух структурно разных вводов (сбалансированное распределение против искажённого).
Самопроверка
Пройди цикл декодирования вручную для первых трёх символов 'abracadabra', показывая курсор бита и текущий узел дерева на каждом шаге; senior-ревьюер проверяет, что сброс в корень происходит сразу после каждого листа, а не в конце строки.
- 06Доказательство оптимальности и канонические коды Хаффмана
Теперь, когда encode/decode работает, порассуждай о том, что на самом деле означает 'оптимальность' и как сделать таблицу кодов передаваемой без дерева. Алгоритм Хаффмана минимизирует ожидаемую длину кода E[L] = Σ freq(x) × len(code(x)), что равно взвешенной длине пути от корня до всех листьев. Аргумент обмена доказывает это: в оптимальном дереве два символа с наименьшей частотой должны быть братьями-сёстрами на самом глубоком уровне — если бы это было не так, можно было бы поменять их с фактическими самыми глубокими братьями-сёстрами и уменьшить E[L], что противоречит оптимальности. Энтропия H(X) = -Σ p(x) × log₂(p(x)) — нижняя граница: ни один однозначно декодируемый код не может достичь ожидаемой длины ниже H(X) бит на символ. Вычисли E[L] и H(X) для 'abracadabra' и сравни — Хаффман укладывается в несколько процентов. Далее, канонические коды Хаффмана: переназначь коды так, чтобы (а) коды одинаковой длины были смежными и (б) в группе одинаковой длины символы были отсортированы алфавитно, а сами коды назначались как последовательные целые числа в двоичном виде. Каноническая форма кодирует те же длины, что и исходное дерево, но с более короткими кодами в среднем для передачи — нужно только передавать отображение символ→длина (а не дерево), и любой декодер может восстановить канонические коды из него. Реализуй codes_canonical(tree), возвращающую канонические битовые строки.
Критерии готовности- Ты можешь вычислить E[L] и H(X) для заданного частотного распределения и показать, что E[L] Хаффмана укладывается в 1 бит на символ от энтропии для 'abracadabra'.
- codes_canonical(tree) возвращает коды, которые prefix-free, корректно декодируются стандартным алгоритмом канонического декодирования и могут быть восстановлены только из отображения символ→длина.
Самопроверка
Изложи аргумент обмена для 'abracadabra' в одном абзаце, назови два символа с наименьшей частотой, подтверди, что они являются братьями в твоём дереве, и объясни, почему замена их на любую другую пару не уменьшила бы E[L]; senior-ревьюер проверяет, что ты рассуждаешь о структуре дерева, а не просто о битовых строках.
Стартер
- README.md
- src/huffman.ts
- test/huffman.test.ts
Распакуй, реализуй заглушки, затем гоняй тесты, пока не позеленеют: bun test
Рубрика
| Джуниор | Миддл | Сеньор | |
|---|---|---|---|
| Построение дерева и prefix-free коды | Дерево Хаффмана строится из отсортированных частот, коды выводятся DFS; равенства разрешаются произвольно, а граничный случай одного символа приводит к краху или возвращает пустой код. | Min-heap управляет циклом слияний; правило разрешения равенства задокументировано и детерминировано; одиночный символ в корне получает явный код '0'; тест prefix-free охватывает все O(k²) пары. | Таблица кодов производима в канонической форме только из отображения символ→длина; ты вычисляешь E[L] и H(X) для тестового ввода и показываешь, что разрыв ≤ 1 бит на символ; аргумент обмена изложен, а не просто заявлен. |
| Round-trip кодирования/декодирования | Кодирование и декодирование работают для вводов со сбалансированными частотами; случай одного символа молча даёт неверный вывод, а теста round-trip нет. | decode(encode(s, codes), tree) === s утверждается для как минимум двух структурно разных вводов; путь одного символа проверен в наборе тестов; кодирование и декодирование используют отдельные структуры данных (карта vs дерево) по назначению. | Round-trip держится для adversarial-вводов (все одинаковые символы, двухсимвольный алфавит, 256-символьный алфавит); ты можешь объяснить, почему свойство prefix-free является одновременно необходимым и достаточным для однозначного декодирования — и у тебя есть тест, который обнаружил бы нарушение. |
| Оптимальность и коэффициент сжатия | Закодированный вывод в большинстве случаев короче сырой строки; сравнение с фиксированной шириной или базовым значением энтропии не проводится. | Тест сжатия утверждает, что encoded_length < ceil(log2(|alphabet|)) × len(s) для искажённого распределения; порядок частота-против-длины кода проверен: более частые символы получают более короткие коды. | E[L] вычислен, сравнён с H(X), и показано, что разрыв ≤ 1 бит на символ; канонические коды реализованы и верифицированы на корректное декодирование из отображения символ→длина без исходного дерева. |
Эталонный разбор (спойлер)
Почему min-heap, а не сортировка: сортировка полного списка O(n log n), но даёт порядок только один раз. Каждое слияние порождает новый внутренний узел, который должен снова войти в приоритетную очередь, поэтому нужна структура, поддерживающая insert и extractMin эффективно. Бинарный min-heap даёт O(log n) для обоих, и общая стоимость построения остаётся O(n log n) — то же, что сортировка, но с корректными инкрементальными обновлениями.
Почему prefix-free коды обеспечивают однозначное декодирование: код переменной длины однозначно декодируется тогда и только тогда, когда ни одно кодовое слово не является префиксом другого (неравенство Крафта плюс условие prefix-free). В дереве Хаффмана коды живут исключительно на листьях. Любой префикс пути лист-корень — это внутренний узел с потомками, поэтому он не может быть листом и, следовательно, кодом. Структура дерева механически обеспечивает свойство prefix-free, делая декодирование простым обходом в глубину без заглядывания вперёд.
Энтропия как нижняя граница: теорема Шеннона о кодировании источника утверждает, что ни один lossless-код не может достичь ожидаемой длины ниже H(X) = -Σ p(x) log₂ p(x) бит на символ. Хаффман достигает не более H(X) + 1 бит на символ — +1 возникает из-за округления длин кодов до целых чисел. Разрыв закрывается, когда вероятности символов становятся степенями 1/2. Для сильно искажённых распределений (один символ доминирует) Хаффман почти так же плотен, как граница энтропии; для равномерных распределений он на 1 бит на символ выше границы и совпадает с кодированием фиксированной шириной.
Канонические коды Хаффмана: стандартный алгоритм Хаффмана производит коды, зависящие от точной формы дерева, которая варьируется в зависимости от правила разрешения равенств. Канонические коды нормализуют это: имея только отображение символ→длина, коды назначаются так, что символы одинаковой длины получают последовательные двоичные целые числа в алфавитном порядке, а более длинные коды всегда больше более коротких. Декодеру нужна только карта длин (отсортированный список пар (символ, длина)), а не полное дерево, что делает канонический Хаффман форматом, используемым в DEFLATE, JPEG и большинстве производственных компрессоров.
Детерминизм важен для тестирования: если два символа имеют одинаковую частоту, разные правила разрешения равенств дают структурно разные деревья с одинаковой взвешенной длиной пути — оба являются оптимальными деревьями Хаффмана, но битовые строки различаются. Набор тестов, проверяющий конкретные битовые строки, хрупок; набор тестов, проверяющий свойства (prefix-free, round-trip, коэффициент сжатия против фиксированной ширины), надёжен. Фиксация детерминированного правила разрешения равенств в реализации позволяет иметь оба: тесты свойств для корректности и снэпшот-тесты для регрессии.
Сделай по-сеньорски
- Упакуй битовую строку в реальные байты (LSB-first или MSB-first — твой выбор, задокументированный): напиши toBits(encoded: string): Uint8Array, упаковывающий 8 бит на байт с префиксом-счётчиком дополняющих битов, и обратный fromBits(data: Uint8Array): string. Покажи, что сжатый файл 'abracadabra' меньше сырого UTF-8 файла на диске.
- Реализуй потоковый кодировщик, обрабатывающий по одному символу за раз и сбрасывающий заполненные байты по мере заполнения — профилируй его против пакетной версии на вводе 1 МБ и порассуждай о том, ограничен ли алгоритм памятью или CPU в таком масштабе.
- Реализуй адаптивное кодирование Хаффмана (алгоритм FGK или Виттера): дерево обновляется онлайн по мере поступления символов, поэтому кодировщик и декодер остаются синхронизированными без предварительного сканирования таблицы частот. Измерь штраф коэффициента сжатия по сравнению с двухпроходным Хаффманом на тексте на естественном языке.
- Докажи эмпирически границу оптимальности: генерируй случайные строки, где истинная энтропия H(X) приближается к log₂(k) (равномерное распределение), и покажи, что E[L] Хаффмана сходится к кодированию с фиксированной шириной — алгоритм ничего не выигрывает на действительно равномерном вводе. Построй график E[L]/H(X) как функцию от искажения частот.