ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

哈夫曼树与哈夫曼编码:构造、C语言实现与调试踩坑

哈夫曼树与哈夫曼编码:构造、C语言实现与调试踩坑 给一堆用得有多有少的符号分配长短不一的二进制编码让总长度最短——这件事我在写一个字符频率统计小工具时第一次撞上当时满脑子只记得课本上那句“带权路径长度最小的二叉树”完全没把哈夫曼树和眼前的压缩需求连起来。真正动手把构造哈夫曼树的每一步打印出来之后才发现它的算法内核简单得有点过分每次从森林里挑两棵权值最小的树合起来重复到只剩一棵。可就是这么一句朴素的话藏着不少容易翻车的地方比如结点数组为什么必须开 2n-1 个、SelectMin 为什么容易选重、n1 的时候程序为什么会崩。这篇就把构造流程、C 语言落地代码、编码导出、调试踩坑一次讲透不管是在准备数据结构考试还是真的要写一个压缩模块都能直接拿去用。1. 带权路径长度哈夫曼树真正在优化的那个数字1.1 从等长编码的浪费说起假设一份文本里只有 6 种字符出现次数分别是 2、3、5、6、7、8。如果偷懒用 3 位定长编码总长度就是 (235678) × 3 93 位。看起来很公平每种字符都享受同等待遇但问题也正好出在这种“公平”上出现 8 次的字符和出现 2 次的字符占一样多的位宽高频字符的时间成本被低频字符拖累了。现实中英文文本里 e、t、a 占了很大比例z、q、x 出现得极少给它们同样的码长等于让快递公司给一封信和一台冰箱收一样的运费。变长编码的直觉就来自这里出现得多的字符给短码出现得少的字符给长码。但直接这么干会撞上第二个问题——码字之间会互相“吃掉”。比如给 a 分配 “0”给 b 分配 “00”那收到 “00” 时你根本分不清这是两个 a 还是一个 b。所以变长编码必须满足一个硬约束任何一个码字都不能是另一个码字的前缀。满足这个条件的编码叫前缀码而哈夫曼树恰好就是构造最优前缀码的工具它把“前缀约束”和“最短总长度”这两个要求同时满足了。1.2 WPL 的定义与一次手算哈夫曼树优化的目标量叫带权路径长度英文缩写 WPLWeighted Path Length。公式写出来很直接WPL 等于所有叶子结点的权值乘以它到根结点的路径长度经过的边数再把结果全部加起来。放到刚才那组数据上算一遍。如果按前面推出来的树形2 和 3 的深度是 45 的深度是 36、7、8 的深度是 2那么WPL 2×4 3×4 5×3 6×2 7×2 8×2 8 12 15 12 14 16 77而等长编码的总长度是 93。差了 16 位压缩率大约 17%。这个数字随频率分布的不均匀程度变化分布越偏斜哈夫曼编码的优势越明显如果每种字符出现次数完全一样那哈夫曼编码退化到和定长编码几乎没区别这件事后面讲边界条件时还会再提。有一点必须说清楚权值不一定要是字符频率它可以是任意“代价”度量。做磁盘块合并时权值是块大小做任务调度时权值是任务耗时做判定树时权值是查找概率。只要你能把问题抽象成“若干个带权叶子合成一棵二叉树希望加权深度和最小”哈夫曼树的结论就直接适用这也是它在数据结构课上被反复考、在工程里被反复用的原因。1.3 最优解不一定唯一但最优值唯一很多人第一次做手算题时会怀疑自己算错了因为和答案的树形长得不一样可 WPL 却相同。这不是错误而是哈夫曼树的一个固有性质当存在权值相同的结点时取哪两个先合并可能有多种合法选择最终会得到结构不同但 WPL 完全相等的树。举个最小例子三个权值 {1, 1, 2}。第一轮可以合并两个 1 得到 2剩下 {2, 2} 再合并WPL 1×2 1×2 2×1 6。有没有别的走法没有了因为两个 1 是唯一的最小两个。但如果权值是 {1, 2, 2, 3}第一轮最小两个是 1 和 2但有两个 2 可选选哪个都能得到 WPL 13树形却不同。这个性质带来的实际影响是判断答案对错看 WPL 和每个叶子的深度分布不要死磕树形。写代码时也一样只要 SelectMin 的逻辑正确结果就是合法的不必强求和某本参考书一模一样。反过来如果你在调试时发现两次运行的编码表不同先别慌检查一下是不是有权值相等的字符这大概率不是 bug。提示手算题里如果要求“画出哈夫曼树”一定要在卷面上体现每一步的合并顺序很多评分点是按合并轮次给的只画最终树形容易丢分。2. 贪心合并构造哈夫曼树的算法内核2.1 森林合并模型每一步只做一件事构造哈夫曼树的过程可以完全用“森林”来描述。初始时每个带权结点都是一棵只有根的独立树构成一个有 n 棵树的森林。然后反复执行同一个动作从森林中选出根结点权值最小的两棵树。新建一个结点权值等于这两棵树根结点权值之和。把这两棵树分别挂到新结点的左右孩子上新结点成为它们的父结点。把这两棵树从森林中移除把新树加入森林。重复 n-1 次之后森林里只剩下一棵树这棵树就是哈夫曼树。整个过程中新建了 n-1 个内部结点加上原有的 n 个叶子总结点数正好是 2n-1。这个数字不是巧合而是二叉树的基本性质——满二叉树中度为 2 的结点数等于叶子数减一。用“森林”这个词而不是“树”是因为在算法执行到一半时你手上确实是一堆互不相连的树它们之间还没有父子关系。很多教材的图示直接画出最终树形反而让人忽略了中间状态的森林结构导致写代码时不知道该怎么组织数据。2.2 为什么必须每次取最小的两个贪心策略最容易被质疑的地方就是凭什么每次取最小的两个就一定全局最优这里给一个直观但有说服力的论证思路足够应付理解层面的需求。考虑权值最小的两个叶子 a 和 b。在任何一棵合法的二叉树里a 和 b 一定可以调整成深度最大的两个叶子也就是兄弟关系。为什么假设 a 的深度不是最大存在一个深度更大的叶子 c把 a 和 c 的位置互换因为 a 的权值小于等于 c 的权值深度减小带来的收益大于深度增加带来的损失WPL 不会变大。所以必然存在一棵最优树其中权值最小的两个叶子是兄弟。既然它们必然是兄弟那么把它们合并成一个权值为两者之和的新叶子是不会错过最优解的。合并之后原问题规模从 n 缩小到 n-1而最优子结构和原问题完全同构于是可以继续用同样的逻辑。这就是贪心选择性质和最优子结构的完整论证考试写到这里基本够用。反过来说如果某一步没取最小的两个会发生什么取了一个较大的权值去合并它就会被推到更深的位置而深位置本来应该留给更小的权值加权之后总代价必然变大。这个“深位置给轻权值”的直觉是理解哈夫曼树最关键的一步。2.3 从 O(n²) 到 O(n log n)优先队列的必要性朴素实现里每次合并都要在森林中扫描一遍找最小的两个第 i 轮有 n-i1 棵树要扫描总复杂度是 O(n²)。n 比较小的时候完全够用几百个字符的编码统计毫无压力但如果面对的是百万级别的符号表O(n²) 就撑不住了。标准优化是引入优先队列小根堆。把所有叶子权值压入堆中每次弹出两个最小值、求和后再压回去一轮操作是 O(log n)总共 n-1 轮复杂度降到 O(n log n)。代码里如果不想手写堆用数组加每次排序的写法也能跑排序是 O(n log n)n 轮下来是 O(n² log n)反而更慢所以要么老老实实写堆要么用标准库的排序做一次性处理——但一次性排序解决不了“新生成的权值要重新参与比较”这个问题。值得强调的是这里的堆只需要支持插入和弹出最小值不需要支持任意删除实现起来非常短。我在实际项目里更倾向于直接写一个二三十行的小根堆比引入额外依赖更省事也更容易在嵌入式环境里跑起来。后面第 4 节会给出堆版本和数组版本两套代码按场景挑。3. 拿六个权值走一遍完整构造流程3.1 每轮合并的权值表推演光讲理论容易飘直接上一组具体数据。设叶子权值为 {2, 3, 5, 6, 7, 8}n 6最终结点总数是 2×6-1 11。为了后面写代码方便给每个结点编号叶子占 1 到 6内部结点从 7 开始按顺序生成。第一轮森林中最小两个是 2 和 3合并成新结点 7权值为 5。此时森林变成 {5, 5, 6, 7, 8}注意这里有两个 5一个是原始叶子的 5一个是新生成的 7 号结点。第二轮最小两个都是 5合并成结点 8权值 10。森林变成 {6, 7, 8, 10}。第三轮最小两个是 6 和 7合并成结点 9权值 13。森林变成 {8, 10, 13}。第四轮最小两个是 8 和 10合并成结点 10权值 18。森林变成 {13, 18}。第五轮只剩两个合并成结点 11权值 31。构造结束。把这些整理成表格方便对照调试输出轮次参与合并的两个结点编号权值新结点编号新权值合并后森林11, 22, 3755, 5, 6, 7, 823, 75, 58106, 7, 8, 1034, 56, 79138, 10, 1346, 88, 10101813, 1859, 1013, 18113131这张表建议自己动手推一遍尤其是第二轮那两个 5很多人在这里会把结点编号搞混选完第一个 5 之后忘记把它排除结果两次选到同一个结点程序就会出现“自己和自己合并”的荒谬结果。3.2 用字符画还原最终的树形结构最终树形用文本画出来是这样方括号里是结点编号括号里是权值[11](31) / \ [9](13) [10](18) / \ / \ [4](6) [5](7) [6](8) [8](10) / \ [3](5) [7](5) / \ [1](2) [2](3)对照这棵树每个叶子的深度一目了然6、7、8 的深度都是 23 号叶子的深度是 31、2 号叶子深度是 4。深度的分布正好和权值大小反着来——权值越小埋得越深这就是“深位置给轻权值”的直观体现。顺便说一个观察这棵树一共 11 个结点其中 5 个内部结点左右子树都是完整的没有出现只有一个孩子的结点。这是哈夫曼树的另一个特征——任意内部结点都有两个孩子不存在度为 1 的结点。这个结论在判断一棵树“是不是哈夫曼树”的题目里非常有用可以直接用来排除一些选项。3.3 WPL 的两种算法互相验算第一种算法按叶子算WPL 2×4 3×4 5×3 6×2 7×2 8×2 8 12 15 12 14 16 77。第二种算法按内部结点算WPL 等于所有内部结点的权值之和。内部结点权值分别是 5、10、13、18、31加起来正好是 77。这两个算法结果必然相等原因也很朴素每个叶子权值在向上贡献的过程中会被经过的每一个内部结点统计一次经过的内部结点数恰好等于它的深度。这个等价关系非常实用写代码时可以两种算法都实现一遍做交叉验证一旦两个结果不一致说明构造过程一定有 bug。我在调试一个手写的压缩工具时就是靠这个发现问题的——SelectMin 里多选了一个已经被标记父结点的旧结点叶子算法算出来偏小内部结点算法算出来偏大两个数对不上顺着差异很快就定位到了。提示内部结点权值求和这个捷径在考试里能省大量时间画完树之后直接加一遍即可不需要逐个叶子数深度。3.4 同一个权值集合的另一种合法树形前面提到过最优解不唯一。在这组数据里第二轮如果先合并 6 和 5 而不是两个 5得到的树形会不同但 WPL 仍然是 77。具体来说把 6 和原始叶子的 5 先合并成 11后续再调整最终树形里 6 的深度变成 3而某个权值 5 的深度变成 2加权后 6×3 5×2 28原来是 6×2 5×3 27多了 1。这说明这条路其实不是最优的算法会自动走向更合理的方向。所以“不唯一”指的是在权值相等时选择不同的等权结点而不是任意改变策略都能得到最优解这个区别要分清楚。4. C 语言静态三叉链表实现4.1 为什么开 2n-1 个结点、为什么下标从 1 开始标准实现用一个结构体数组存所有结点结构体包含四个字段权值、父结点下标、左孩子下标、右孩子下标。typedef struct { int weight; int parent; int lchild; int rchild; } HTNode, *HuffmanTree;数组长度是 2n-1 再加一个多出来的那一个是给下标 0 留的。为什么不用下标 0因为父结点、左孩子、右孩子这三个字段需要一个“空”的表示值用 0 表示“不存在”最自然所以真正有效的结点从下标 1 开始。如果从 0 开始存就没法用 0 表示空了得改成 -1代码里到处都要写负数判断容易出错。用数组而不是指针主要考虑是调试方便。指针版本在脑内维护树形结构很累打印出来也是一堆地址数组版本可以直接把整个表格打印出来每一行的父结点、左右孩子都是数字一眼就能看出谁是谁的孩子。这种“静态三叉链表”的写法几乎成了教科书标配考试也默认按这套结构答。下标 weight parent lchild rchild 1 2 7 0 0 2 3 7 0 0 3 5 8 0 0 4 6 9 0 0 5 7 9 0 0 6 8 10 0 0 7 5 8 1 2 8 10 10 3 7 9 13 11 4 5 10 18 11 6 8 11 31 0 9 10这张表如果能在程序里直接打印出来调试效率会高很多。我的习惯是在每轮合并之后打一次整个数组看着森林一步步收缩比打断点单步调试快得多。4.2 SelectMin 的两个易错细节找最小两个结点的函数是整个程序最容易出 bug 的地方细节有两个。第一个细节是必须先选出全局最小再在排除它的前提下选次小。如果贪图省事一次遍历里同时记录最小和次小写出来的判断条件通常是错的尤其是遇到两个相等权值时。稳妥的写法是跑两遍循环第一遍找最小第二遍跳过它找次小。多跑一层的代价是 O(n)对整体复杂度没有影响。第二个细节是比较符号用而不是。用严格小于时遇到权值相等会选下标更小的那个行为稳定可预测用小于等于会选下标更大的虽然也合法但和教材答案对不上。我在第一次实现时没注意这一点导致输出的编码表里 0 和 1 的分配顺序和参考书正好颠倒纠结了很久才意识到这不是 bug只是选择策略不同。void SelectMin(HuffmanTree HT, int k, int *s1, int *s2) { int i, minIdx 0; for (i 1; i k; i) { if (HT[i].parent 0) { if (minIdx 0 || HT[i].weight HT[minIdx].weight) minIdx i; } } *s1 minIdx; minIdx 0; for (i 1; i k; i) { if (HT[i].parent 0 i ! *s1) { if (minIdx 0 || HT[i].weight HT[minIdx].weight) minIdx i; } } *s2 minIdx; }参数 k 的含义是“当前森林中有效结点的最大下标”。第 i 轮合并时已有结点数是 n i - 1遍历范围就是 1 到 n i - 1。传错这个值会漏掉新生成的结点导致合并顺序完全错乱。4.3 完整可编译代码下面这份代码可以直接编译运行权值从标准输入读入输出每个字符的编码和 WPL。#include stdio.h #include stdlib.h #include string.h typedef struct { int weight; int parent; int lchild; int rchild; } HTNode, *HuffmanTree; typedef char **HuffmanCode; void SelectMin(HuffmanTree HT, int k, int *s1, int *s2) { int i, minIdx 0; for (i 1; i k; i) { if (HT[i].parent 0) { if (minIdx 0 || HT[i].weight HT[minIdx].weight) minIdx i; } } *s1 minIdx; minIdx 0; for (i 1; i k; i) { if (HT[i].parent 0 i ! *s1) { if (minIdx 0 || HT[i].weight HT[minIdx].weight) minIdx i; } } *s2 minIdx; } void CreateHuffmanTree(HuffmanTree *HT, int *w, int n) { if (n 1) return; int m 2 * n - 1; *HT (HuffmanTree)malloc((m 1) * sizeof(HTNode)); if (*HT NULL) return; for (int i 1; i m; i) { (*HT)[i].weight 0; (*HT)[i].parent 0; (*HT)[i].lchild 0; (*HT)[i].rchild 0; } for (int i 1; i n; i) { (*HT)[i].weight w[i - 1]; } for (int i n 1; i m; i) { int s1 0, s2 0; SelectMin(*HT, i - 1, s1, s2); (*HT)[s1].parent i; (*HT)[s2].parent i; (*HT)[i].lchild s1; (*HT)[i].rchild s2; (*HT)[i].weight (*HT)[s1].weight (*HT)[s2].weight; } } void CreateHuffmanCode(HuffmanTree HT, int n, HuffmanCode *HC) { *HC (HuffmanCode)malloc((n 1) * sizeof(char *)); char *cd (char *)malloc(n * sizeof(char)); cd[n - 1] \0; for (int i 1; i n; i) { int start n - 1; int c i; int p HT[i].parent; while (p ! 0) { start--; if (HT[p].lchild c) cd[start] 0; else cd[start] 1; c p; p HT[p].parent; } (*HC)[i] (char *)malloc((n - start) * sizeof(char)); strcpy((*HC)[i], cd[start]); } free(cd); } int CalcWPL(HuffmanTree HT, int *w, int n) { int total 0; for (int i 1; i n; i) { int depth 0, c i, p HT[i].parent; while (p ! 0) { depth; c p; p HT[p].parent; } total w[i - 1] * depth; } return total; } int CalcWPLByInner(HuffmanTree HT, int n) { int sum 0; for (int i n 1; i 2 * n - 1; i) sum HT[i].weight; return sum; } int main(void) { int n; printf(请输入叶子个数: ); if (scanf(%d, n) ! 1 || n 2) { printf(叶子个数必须大于等于2\n); return 1; } int *w (int *)malloc(n * sizeof(int)); printf(请输入%d个权值: , n); for (int i 0; i n; i) scanf(%d, w[i]); HuffmanTree HT NULL; HuffmanCode HC NULL; CreateHuffmanTree(HT, w, n); CreateHuffmanCode(HT, n, HC); printf(\n下标 权值 父结点 左孩子 右孩子\n); for (int i 1; i 2 * n - 1; i) { printf(%4d %5d %6d %7d %7d\n, i, HT[i].weight, HT[i].parent, HT[i].lchild, HT[i].rchild); } printf(\n叶子编码:\n); for (int i 1; i n; i) { printf(权值 %2d - %s\n, HT[i].weight, HC[i]); } printf(\nWPL(按叶子) %d\n, CalcWPL(HT, w, n)); printf(WPL(按内部结点) %d\n, CalcWPLByInner(HT, n)); for (int i 1; i n; i) free(HC[i]); free(HC); free(HT); free(w); return 0; }用 {2, 3, 5, 6, 7, 8} 跑一遍输出的编码应该是 6→“00”、7→“01”、8→“10”、5→“110”、2→“1110”、3→“1111”WPL 两个算法都给出 77。如果结果对不上优先检查 SelectMin 里的 parent 判断和遍历上界。4.4 从文本文件统计真实频率论文里用的权值通常是字符出现的次数手工输入不现实。用标准文件读写统计一遍就行代码很短#include stdio.h int main(void) { FILE *fp fopen(input.txt, rb); if (fp NULL) { perror(打开文件失败); return 1; } long cnt[256] {0}; int ch; while ((ch fgetc(fp)) ! EOF) { cnt[(unsigned char)ch]; } fclose(fp); for (int i 0; i 256; i) { if (cnt[i] 0) { printf(字符 0x%02X 出现 %ld 次\n, i, cnt[i]); } } return 0; }几个实操要点。第一文件用 “rb” 而不是 “r” 打开避免不同平台上换行符被翻译导致统计结果有偏差。第二fgetc 返回的是 int 而不是 char因为 EOF 通常是 -1如果声明成 char在某些平台上会和高位字节混淆循环永远不会结束。第三cnt 的下标必须强转成 unsigned char否则遇到大于 0x7F 的字节会变成负数下标直接越界读写。拿到频率之后把非零项收集成数组传给构造函数即可。注意频率为 0 的字符不要放进去它们会生成权值为 0 的叶子虽然算法能跑但会白白拉长其他字符的编码压缩效果变差。真实的编码表还要额外存一份“字符到码字”的映射否则解码时找不到对应关系这部分属于工程细节考试一般不做要求。提示编译时用gcc huffman.c -o huffman -Wall把警告都打开。-Wall能帮你抓出很多隐藏的下标类型问题和未初始化变量比事后再调试省事。5. 从树到码生成哈夫曼编码的两种走法5.1 自底向上回溯上面代码里用的就是这条路从每个叶子出发沿着父结点一路往上走到根每次判断当前结点是父结点的左孩子还是右孩子左孩子记 0右孩子记 1。因为是从下往上走的得到的位序列是反的所以要用一个缓冲区从后往前填最后把有效部分拷贝出来。缓冲区的长度取 n 是最保险的。任何叶子的深度都不会超过 n-1因为每次向上走一层至少消耗一个其他叶子最多走 n-1 步。多留一个字节放结束符长度 n 刚好够用。如果这里有顾虑动态分配 n1 个字节也行代价可以忽略。这种方法的好处是不需要递归也不依赖树的孩子指针是不是有序唯一的循环就是沿父指针上溯。缺点是对每个叶子都要重新走一遍到根的路径对于深度大的树有重复计算但量级上是 O(n × depth)最坏 O(n²)考虑到 n 通常不超过几万实际完全够用。5.2 自顶向下 DFS另一种思路是从根出发做深度优先遍历一路记录经过的边走到叶子时把当前路径保存下来。写法上更像常规的树遍历void DFS(HuffmanTree HT, int node, char *path, int len, HuffmanCode HC, int n) { if (node 0) return; if (node n) { path[len] \0; HC[node] (char *)malloc(len 1); strcpy(HC[node], path); return; } path[len] 0; DFS(HT, HT[node].lchild, path, len 1, HC, n); path[len] 1; DFS(HT, HT[node].rchild, path, len 1, HC, n); }调用时从根结点 2n-1 开始传一个长度足够的 path 数组。这种写法的好处是每边恰好走一次总复杂度 O(n)而且路径是顺序生成的不需要反转。缺点是需要递归深度大时要注意栈空间几万层的递归在默认栈大小下可能会溢出。我一般在小规模场景用 DFS 版本代码更短更直观如果权值数量上万就换成回溯版本避免递归风险。两种写法产出的编码表在内容上完全一致只是 0 和 1 的左右约定可能相反不影响正确性。5.3 前缀性质的验证生成完编码表之后最好加一段校验任意两个码字不能互为前缀。实现很简单两层循环互相做 strncmp一旦发现短的等于长的前若干位就说明树构造有问题。int CheckPrefix(HuffmanCode HC, int n) { for (int i 1; i n; i) { for (int j 1; j n; j) { if (i j) continue; int li strlen(HC[i]), lj strlen(HC[j]); if (li lj strncmp(HC[i], HC[j], li) 0) return 0; } } return 1; }这段校验在我自己的项目里抓到过一次真实 bug当时为了节省内存把编码表存成了固定长度数组结果长度判断写成了小于而不是小于等于恰好漏掉了“两个码字完全相等”这种最危险的情况那意味着两个不同字符会编码成一模一样的位串解码时必然出错。加上校验之后立刻就暴露了。前缀性质是哈夫曼树天然具备的因为任何叶子都不可能是另一个叶子的祖先。反过来说如果你构造出来的编码不满足前缀性质那几乎可以肯定不是哈夫曼树的问题而是数组下标或父子指针写错了。6. 我踩过的坑与边界条件清单6.1 n1、n0 与权值为零课堂上讲的都是 n 大于等于 2 的正常情况实际写代码时会遇到各种边界。n 等于 1 时理论上一棵只有一个结点的树就是最优解编码长度为零根本不构成前缀码。但标准实现里数组长度是 2n-1 1循环一次都不执行还勉强能跑如果你按 2n-1 分配后又想访问内部结点立刻越界。所以我在入口处统一做了判断n 小于 2 直接返回避免后面所有逻辑都要加保护。n 等于 0 更直接空输入。有些场景下确实会出现比如统计一个空文件频率数组全是 0。这时候应该提前检查非零频率个数为 0 就直接跳过整个流程。权值为 0 的叶子是个隐性麻烦。假设某组权值是 {0, 0, 5}第一轮合并两个 0 得到 0第二轮合并 0 和 5最终树里 5 的深度是 1编码长度是 1 位。看起来没问题但如果换成 {0, 5, 5}第一轮合并 0 和 5第二轮合并 5 和 5两个权值 5 的叶子深度都是 2。跟直觉比一下如果直接把 0 去掉只用 {5, 5} 两个叶子它们只需要 1 位编码。权重为 0 的结点把编码长度拉长了 1 位这在实际压缩里是纯粹的浪费所以预处理阶段一定要把零频项过滤掉。6.2 最小值选择中的比较符号这个坑前面提过这里展开说完整。假设森林里有三个结点权值都是 5SelectMin 用严格小于时会稳定选到下标最小的那个行为可预测用小于等于时会选到下标最大的。两种选法都合法WPL 一样但编码表不同。真正危险的是“只跑一次循环同时找两个最小值”的写法。我见过一个版本是这样写的if (HT[i].weight min1) { min2 min1; min1 HT[i].weight; } else if (HT[i].weight min2) { min2 HT[i].weight; }这段逻辑在处理相等权值时就会出错三个权值都是 5 的情况下第一个 5 更新 min1后两个 5 因为不满足严格小于全被忽略min2 停留在初始值上结果选出来的两个“最小值”是错的程序会拿一个不存在的结点去合并。修法就是老老实实跑两遍循环。多写几行换来的是稳定正确这笔账很划算。6.3 内存与越界静态数组版的三个高危点第一个高危点是数组大小。2n-1 这个数字容易记成 2n或者忘了给下标 0 多留一格。正确的是分配 2n 个元素的数组使用 1 到 2n-1。稳妥写法是malloc((2 * n) * sizeof(HTNode))这样下标 0 到 2n-1 都在范围内。第二个高危点是编码缓冲区。前面说过长度取 n 够用但如果你为了保险取了 n1 却忘了初始化或者用 strlen 去读一块没有结束符的内存会读到垃圾数据。所有动态分配的字符数组都要手动补 ‘\0’。第三个高危点是释放顺序。编码表是一个二级指针每个元素单独分配过释放时必须先逐个 free(HC[i])再 free(HC)顺序反了就会漏掉一部分内存。这类问题在短时间运行的程序里看不出来但如果是常驻服务几小时之后内存就涨上去了。6.4 结构选型对比实现方式时间复杂度空间占用适用场景主要缺点静态数组 双循环选最小O(n²)2n 个结点教学、n 小于几千符号多时明显变慢静态数组 小根堆O(n log n)2n 个结点 堆通用工程实现需要额外写堆代码指针二叉树动态建树O(n log n)2n-1 个结点教学演示、图形化调试不便易出内存错误优先队列标准库O(n log n)取决于库快速原型验证语言依赖跨平台需适配堆版本的 WPL 计算有个非常简洁的写法每次弹出两个最小值 a 和 b把 ab 累加到总和里再压回堆循环到堆里只剩一个元素为止。累加出来的结果直接就是 WPL不需要事后遍历叶子。这个技巧面试时经常被问到值得记住。int wplByHeap(int *w, int n) { /* heap 为已建好的小根堆push/pop 见前述实现 */ int total 0; for (int i 0; i n; i) push(w[i]); while (sz 1) { int a pop(); int b pop(); total a b; push(a b); } return total; }7. 手算题与工程实现的两套思路7.1 考研手算的得分点如果目标是应对考试重点和写代码完全不是一回事。手算题考查的是合并流程和 WPL一般不会让你写出完整代码。常见的出题形式有这么几类给一串权值要求画出哈夫曼树并给出编码给出编码反推树的形态判断某棵树是不是最优计算加权路径长度。答题时的得分习惯是先把权值排序然后逐轮写出合并结果每轮标清楚哪两个合并、新结点权值多少。最后画树的时候把新生成的内部结点明确标出来别和原始叶子混在一起。WPL 一定用内部结点权值求和的方法验算一遍因为手算叶子深度很容易数错一层。有个小技巧如果题目给的权值数量多画满整棵树很费时间可以先只画出左半边利用对称性或者直接算 WPL 就够了。很多题目其实只要求 WPL 的数值和编码长度不要求完整树形看清楚问的是什么能省下大量草稿纸。另一个高频考点是“前缀码”的概念辨析。会给出几个编码方案让你判断哪些是合法的前缀码判断方法就是逐对检查有没有前缀关系。这里最容易错的是把“两个码字长度相同”误判成有前缀关系实际上等长码字之间不可能互为前缀除非它们完全相同。7.2 工程实现里的几个变体真实项目里很少直接要求“构造哈夫曼树”这么纯的题目更多是它的变体。第一种是 k 叉哈夫曼树。合并的不再是最小两个而是最小 k 个用于把字符映射到 k 进制码字。这里有个容易翻车的点如果叶子数量不满足 (n-1) 能被 (k-1) 整除需要先补一批权值为 0 的虚拟叶子否则最后一轮凑不够 k 个结点会构造出有单孩子的树不再是严格的 k 叉。补几个的计算公式是补到 n ≡ 1 (mod k-1)。第二种是带长度限制的哈夫曼编码。标准算法可能生成很深的树某些硬件解码器只支持最长 15 位或 32 位的码字这时候需要在构造过程中加约束或者事后对超长的分支做重排常见做法是用包合并算法。这类实现复杂度高不少一般只在专门的压缩库里出现。第三种是动态自适应编码。字符频率不是预先统计好的而是边读边更新树也要随之调整。最知名的是自适应哈夫曼编码它维护一棵随符号出现次数动态调整的树解码端用同样的规则同步更新不需要预先传编码表。实现难度远大于静态版本但省掉了表头传输的开销在流式场景里很有价值。这三种变体的基础都是静态构造这一套把静态版本彻底搞明白改起来就是换个选择逻辑或者加个约束判断不会推倒重来。7.3 一个容易被忽略的细节编码方向的约定最后说一个很小但经常造成困惑的细节。左孩子记 0、右孩子记 1 还是反过来没有任何强制性规定两套约定都能产出合法的前缀码。但如果你的系统里编码端和解码端用了不同的约定数据就会解出一堆乱码。我建议的做法是在代码里把方向判断集中到一个函数里比如int bitOf(int parent, int child)返回 0 或 1编码和解码都调它将来要改约定只改一处。很多手写压缩工具出问题都是因为编码用左 0 右 1解码时写成了左 1 右 0两边各自都能跑通接在一起就全错排查起来还特别费劲因为单看任何一侧的代码都找不出毛病。先把静态构造流程在纸上推三遍再照着写代码最后用 WPL 的两种算法交叉验证基本就能把这一类问题吃透。这套流程我在做字符串相似度匹配的小工具时又翻出来用过一次把频率换成匹配代价正好也能算出一棵判定树说明这个结构的适用面确实比它表面看起来宽得多。
返回列表