Huffman Coding: Shorter Codes for Common Symbols
in progressThis lesson is still being built - content, demos, and checkpoints may change before release.
in progressThis lesson is still being built - content, demos, and checkpoints may change before release.
huffman-coder.ts
import { BinaryHeap } from "../graphs/binary-heap";import type { Tracer } from "../trace";/** A node of the code tree: a leaf carries a symbol, a parent its two children. */export interface HuffmanNode { /** The push counter when the node was made: leaves first, then merges. */ id: number; /** How many characters the node stands for. */ weight: number; /** The character at a leaf; null on a merged node. */ symbol: string | null; left: HuffmanNode | null; right: HuffmanNode | null;}export interface HuffmanResult { /** Symbol counts, in order of first appearance. */ counts: Map<string, number>; /** The code tree, null for an empty text. */ root: HuffmanNode | null; /** Each symbol's code word, a string of 0s and 1s. */ codes: Map<string, string>; /** The whole text as code words, concatenated. */ bits: string; /** What a fixed-width code of the same alphabet would cost. */ fixedBits: number;}/** * Huffman coding: count the symbols, put every symbol on a min-heap as a * leaf weighing its count, and repeatedly merge the two lightest nodes * into a parent weighing their sum until one node remains. The root's * paths, left 0 and right 1, are the code words: a common symbol ends up * near the root with a short code, a rare one deep down with a long one, * and no code word is a prefix of another because symbols sit only at * the leaves. Ties on the heap go by push order, so the tree is * deterministic. A one-symbol text gets the single code "0". * * Time: O(n + k log k) for n characters over k symbols. Space: O(k). */export class HuffmanCoder { private pushes = 0; constructor(private readonly tracer?: Tracer) {} /** Shannon entropy of a count table, in bits per symbol: the bound no prefix-free code beats on average. */ static entropyBits(counts: Iterable<number>): number { const values = [...counts].filter((c) => c > 0); const total = values.reduce((sum, c) => sum + c, 0); if (total === 0) return 0; let bits = 0; for (const c of values) { const p = c / total; bits -= p * Math.log2(p); } return bits; } encode(text: string): HuffmanResult { this.pushes = 0; const counts = new Map<string, number>(); for (let i = 0; i < text.length; i++) { const symbol = text[i]!; const count = (counts.get(symbol) ?? 0) + 1; counts.set(symbol, count); // ▸ one more of this character this.tracer?.emit("count", { index: i, symbol, count }); } // Ties go to the node pushed first: ids count pushes. const heap = new BinaryHeap<HuffmanNode>((node) => node.weight, (node) => node.id); for (const [symbol, weight] of counts) { this.push(heap, { id: this.pushes, weight, symbol, left: null, right: null }); } while (heap.size > 1) { const lo = heap.pop()!; const lighter = heap.pop()!; const hi = heap.pop()!; const heavier = heap.pop()!; // ▸ the two lightest nodes come off this.tracer?.emit("pop-pair", { leftId: lo.id, rightId: hi.id, leftWeight: lo.weight, rightWeight: hi.weight, remaining: heap.size }); this.tracer?.emit("pop-pair", { leftId: lighter.id, rightId: heavier.id, leftWeight: lighter.weight, rightWeight: heavier.weight, remaining: heap.size }); const parent: HuffmanNode = { id: this.pushes, weight: lo.weight + hi.weight, symbol: null, left: lo, right: hi }; const parent: HuffmanNode = { id: this.pushes, weight: lighter.weight + heavier.weight, symbol: null, left: lighter, right: heavier }; // ▸ a parent weighing both, lighter on the 0 side this.tracer?.emit("merge", { id: parent.id, weight: parent.weight, leftId: lo.id, rightId: hi.id }); this.tracer?.emit("merge", { id: parent.id, weight: parent.weight, leftId: lighter.id, rightId: heavier.id }); this.push(heap, parent); } const root = heap.pop() ?? null; const codes = new Map<string, string>(); if (root) this.assign(root, "", 0, codes); let bits = ""; for (let i = 0; i < text.length; i++) { const symbol = text[i]!; const code = codes.get(symbol)!; bits += code; // ▸ swap each character for its code word this.tracer?.emit("encode", { index: i, symbol, code, bits: bits.length }); } const width = Math.max(1, Math.ceil(Math.log2(Math.max(1, counts.size)))); return { counts, root, codes, bits, fixedBits: text.length * width }; } /** Replays the bits down the tree: a leaf yields its symbol and the walk restarts at the root. Untraced. */ decode(bits: string, root: HuffmanNode | null): string { if (!root) return ""; let out = ""; // A one-leaf tree has no branches: every bit is that leaf's symbol. if (!root.left || !root.right) return root.symbol!.repeat(bits.length); let node = root; for (const bit of bits) { node = (bit === "0" ? node.left : node.right)!; if (node.symbol !== null) { out += node.symbol; node = root; } } return out; } /** Every node enters the heap here, so the push counter numbers leaves and parents alike. */ private push(heap: BinaryHeap<HuffmanNode>, node: HuffmanNode): void { this.pushes += 1; heap.push(node); // ▸ onto the heap, lightest at the top this.tracer?.emit("push", { id: node.id, weight: node.weight, symbol: node.symbol, heap: heap.toArray().map((n) => n.id) }); } /** Walks the finished tree, left 0 and right 1, and records the path at each leaf. */ private assign(node: HuffmanNode, acc: string, depth: number, codes: Map<string, string>): void { private assign(node: HuffmanNode, prefix: string, depth: number, codes: Map<string, string>): void { const leaf = node.symbol !== null; // A lone root has no path; "0" gives it a one-bit code. const code = leaf && acc === "" ? "0" : acc; const code = leaf && prefix === "" ? "0" : prefix; // ▸ the path down from the root is the code word this.tracer?.emit("assign", { id: node.id, symbol: node.symbol, code, depth }); if (leaf) { codes.set(node.symbol!, code); return; } this.assign(node.left!, acc + "0", depth + 1, codes); this.assign(node.left!, prefix + "0", depth + 1, codes); this.assign(node.right!, acc + "1", depth + 1, codes); this.assign(node.right!, prefix + "1", depth + 1, codes); }}46 bits against 51 at a fixed width: 2.71 bits per symbol, with the entropy at 2.70. Ratio 0.90.