数据结构

哈夫曼树与编码

哈夫曼编码是一种无损数据压缩方法。它根据每个字符出现的频率给字符分配一个变长的二进制码频率越高的字符,码越短。最终消息的总比特数比定长编码(如 ASCII 每个字符 8 位)更少。

为什么需要”前缀码”

如果用 a=0, b=1, c=01,那 “01” 既可以解析为 “ab” 也可以解析为 “c” —— 歧义。

前缀码:任意字符的码不是另一个字符码的前缀。哈夫曼码天然就是前缀码 —— 因为每个字符都对应叶子节点,路径上不可能再有其他字符。

贪心算法(霍夫曼,1952)

每次从森林里取出权值最小的两棵树合并,新树的权值是两者之和,放回队列。重复直到只剩一棵 —— 那就是哈夫曼树。

priority_queue<Node*> pq;
for (auto [c, f] : freq) pq.push(new Node{f, c});
while (pq.size() > 1) {
  Node* a = pq.top(); pq.pop();
  Node* b = pq.top(); pq.pop();
  pq.push(new Node{a->w + b->w, '\0', a, b});
}

导出编码:从根 DFS,向左走加 0,向右走加 1。走到叶子时累计的字符串就是该叶子字符的编码。

为什么贪心是最优的

合并最小的两棵这件事很反直觉 —— 凭什么保证全局最优?关键引理是:

在最优树中,频率最低的两个字符一定是最深的两个叶子,并且是兄弟

证明大致是反证法 + 交换论证:如果不是兄弟,把它俩交换到一起不会让总代价变差。这条性质让贪心选择安全:当前最小的两个必然占据某棵最优树的最深一对兄弟位置。其余部分递归地变成更小的子问题。

复杂度

  • 时间:O(n log n) —— n 次合并,每次堆操作 O(log n)
  • 空间:O(n) —— 树有 2n - 1 个节点

在动画里看什么

  • 初始:每个字符各成一棵单节点树
  • 蓝色高亮:每次被弹出的两棵最小树
  • 红色高亮:刚合并产生的新内部节点
  • 边上的 0 / 1 标签:左为 0,右为 1
  • 完成后下方出现编码表 —— 高频字符(如 'a')的码会明显比低频字符(如 'f')的码短

经典数字

CLRS 教科书的标准例子 {a:45, b:13, c:12, d:16, e:9, f:5} 的最优总码长是 224 bits。如果用定长 3 bit 编码(6 个字符可以塞下),总长是 100 × 3 = 300 bits —— 节省 25%。