1. 从编码压缩需求说起:为什么非要搞一棵树出来
如果你接触过文件压缩、数据编码或者学过数据结构,多半绕不开哈夫曼树和哈夫曼编码。我第一次看到这个名词的时候挺懵的——明明是个编码问题,怎么弯弯绕绕要先去构建一棵树?这中间到底图什么?
先讲一个最实际的场景。假设你要传一段文本,里面只有A、B、C、D四个字符。最朴素的做法是给每个字符分配固定长度的二进制码,比如A=00、B=01、C=10、D=11,每个字符用2个bit表达,总共就是2n个bit,n为字符总数。这当然没问题,但你会发现一个很明显的浪费:如果这段文本里A出现了1000次,D只出现了2次,给A和D分配一样长的编码显然不合理——高频字符凭什么和高频字符享受同等待遇?这就引出了变长编码的思路,给高频字符分配较短的编码,给低频字符分配较长的编码,整体传输的比特数就能大幅下降。
但变长编码立刻带来一个新问题:怎么保证解码时不会产生歧义?比如A的编码是0,B的编码是01,那收到"01"这个序列的时候,你没法确定它到底表示"AB"还是"B"。这就是典型的二义性问题。
哈夫曼编码的核心贡献,恰恰是用一棵二叉树来彻底解决这两个问题:既能让编码长度自适应字符频率,又能保证解码的唯一性。这就是标题里"哈夫曼树及哈夫曼编码的构造方法"最核心的两层——先构造树,再从树上取编码。理解了这两个痛点,你就明白为什么课本里总要先讲"带权路径长度"这种概念了。
2. 哈夫曼树到底是什么:一个关键指标"带权路径长度"
2.1 从生活场景理解带权路径长度
你别被"带权路径长度"这个名词唬住,其实它的意思特别直白。想象你在一栋楼里办公,每个部门都在不同的楼层,每天配送员要从一楼往各个部门送文件。如果文件多、人多的部门(权值大)放在离一楼远的顶楼,每天跑的路就特别长。反之,你把文件多的部门放在一楼附近,文件少的部门放远一点,总路程就短了。这个"每天配送的总路程",对应的就是树的"带权路径长度"。
术语化一下:一棵树的每个叶子节点都带一个权值(比如字符出现的次数),从根节点到某个叶子节点的路径长度是经过的边数,那么这棵树的带权路径长度就是所有叶子节点的(权值 × 路径长度)之和,通常简记为WPL。哈夫曼树就是给定一组带权叶子节点时,能构造出的WPL最小的二叉树。所以它另一个名字叫最优二叉树。
2.2 为什么权重大的要放近,权重小的要放远
这一句话本质上就是哈夫曼树的贪心策略:权值越大的节点,离根越近。因为WPL的计算是"权值乘以路径长度",你想想看,如果你把一个大权值的节点放到很深的地方,路径长度大,乘出来的值就飙升,代价立马上来。反过来,把高频字符放在树浅层,哪怕深度是2或者1,乘以一个很大的权值也还是可控的;低频字符虽然放得深,路径长,但乘以的权值小,整体代价依然不高。
这个思路是一个典型的贪心策略:每一步都选当前代价最小的合并方式,最终全局达到最优。哈夫曼证明了按他那个构造算法得到的树,WPL是严格最小的。你不需要怀疑"是不是还有更好的局部选择",因为这个算法满足贪心选择性质和最优子结构性质,这两点在算法导论里有严格证明,实际工程上你只管按算法流程走就行。
2.3 树的形态细节:为什么叶子才是正经字符
读哈夫曼树的定义时,很多人都忽略了一个细节——原始的字符节点全部落在叶子上,内部节点都是"合成节点"(两个子树的权值之和)。这个设计是有讲究的:
- 只有叶子节点对应实际字符,才能保证每条编码是某个字符独有的路径,不会出现"另一个字符的编码成了这个字符编码的前缀"的情况,也就是前缀码。
- 内部节点的权值不代表任何真实字符,它只是算法过程中累积出来的"子树总权重"。
所以你在构造时,每合并两个节点,就生成一个新节点,新节点的权值等于两个子节点权值之和。等算法结束,剩下一棵树,树上所有的叶子都是最初的字符集合,内部节点全是合成节点,这棵树就是哈夫曼树。
3. 核心构造方法:五步走,从森林到一棵树
3.1 完整流程拆解
哈夫曼树的构造流程并不复杂,核心是反复"取最小、合二为一"。你手头有一组带权节点,把它们全部看成单节点的树,这就是一个森林。然后按特定规则反复合并。标准流程如下:
第一步,统计频率。如果是文本压缩,先统计每个字符的出现次数,这些次数就是权值。如果题目直接给了权值列表,就跳过这一步。
第二步,把每个字符和它的权值包装成一个节点,全部放进一个容器(通常是优先队列/最小堆,或者是一个按权值从小到大排序的列表)。
第三步,从容器中取出权值最小的两个节点,分别作为左子树和右子树,创建一个新节点,新节点权值是两者之和。
第四步,把新节点放回容器。
第五步,不断重复第三步和第四步,直到容器里只剩一个节点。这最后一个节点就是哈夫曼树的根节点。
我举个例子。假设有四个字符:A权值5,B权值2,C权值3,D权值8。
- 初始森林:{5, 2, 3, 8}。
- 取出2和3,合并为5,放回去,森林变成{5, 5, 8}。
- 取出两个5,合并为10,放回去,森林变成{10, 8}。
- 取出8和10,合并为18,森林只剩18,算法结束。
这棵树的结构大致是根节点18,左子树10,右子树8。其中10往下是5和5,左5是A,右5是合并出来的节点(它的下面再拆成B和C)。你会注意到底层的两个5中,一个是原始节点A,一个是内部节点BC,它们虽然权值一样,但地位不同。
3.2 一个关键细节:等权值节点怎么处理
上面这个例子有个小讲究——第二步合并的时候我取了"两个5",可这两个5里一个是原始节点A,一个是新合成的BC节点。如果这时候有两个以上等权值的节点,选哪两个其实会影响最终树的形态,但不影响WPL的最优性。哈夫曼算法只保证WPL最小,不保证树的形状唯一。
具体实现时,你的排序规则(比如按权值、再按字符ID)就会决定挑哪两个。某些资料里的写法可能把新节点放在现有节点的后面,导致构造出来的树有的偏高、有的偏低,但WPL是一样的。所以做题或写代码时,你只要保证"每次取权值最小,如果权值相同就按你需要的方式取",不会引入错误。
3.3 为什么用优先队列而不是反复排序
理论上每次从列表里挑最小的两个也能做,但每合并一次就要重新找一次最小值,效率是O(n²)级别的。用最小堆/优先队列,每次弹出最小值是O(logn),插入新节点也是O(logn),整体是O(nlogn)。
实际编码的时候,很多人图省事直接每次把数组排个序,数据量小倒是无所谓,但一旦处理一篇文章的几千个字符,差距就出来了。我用C语言实现的时候,初期偷懒用数组加冒泡排序,处理一个几百KB的文件就有点卡顿,换成优先队列后立刻流畅了。说到底,算法课强调数据结构选型,不是死板教条,而是对性能的实打实影响。
4. 从哈夫曼树到哈夫曼编码:代码表就这么来的
4.1 左0右1是约定,但别搞反
树构造出来之后,编码其实就"藏在"树的路径里。你从根节点出发,向左走记一个0,向右走记一个1,走到某个叶子节点时,沿路的0和1拼起来就是该字符的哈夫曼编码。注意,这里的0和1方向是人为约定的,你可以左1右0,甚至左0右1交替都行,只要解码时保持一致,编码仍然是可用的。但为了通用性和交流方便,还是建议统一按"左0右1"来操作,不然代码写给别人看的时候容易产生误会。
回看刚才的例子:D在根的右侧,编码是1;A是左子树的左叶子,编码是00;B和C从根左侧下去分别再走到左和右,编码是010和011。你观察一下,这组编码正好满足前缀码的性质:没有一个编码是另一个编码的前缀。比如B的编码是010,C的编码是011,它俩共享了"01"前缀但最后一个bit分开了,解码时不会混淆。这就是为什么哈夫曼编码不需要额外的分隔符——只要从根开始按比特走,每个叶子都对应唯一的位置。
4.2 解码过程其实就是沿着树走路
解码的思路和编码完全反过来。你拿到一段01序列,从根节点出发,遇到0走左,遇到1走右。每当走到一个叶子节点,就输出该叶子对应的字符,然后重新回到根节点,继续读剩余序列。
这里必须强调一个容易被忽略的点:内部节点不能是某个字符的终点。换句话说,你写解码算法时,判断条件必须落在"当前节点是否为叶子",而不是"是否走到了某个位置"。一旦在内部节点处停止输出,你等于把一个前缀码拆坏了,编码整段作废。
4.3 手动构建一张完整编码表
实际项目里,你不可能每次编码都临时去树里搜一遍路径,那样太慢了。常用的做法是:在哈夫曼树构造完成后,用深度优先遍历一次性生成一张"字符 -> 编码"的映射表。之后压缩阶段直接查表输出bit流,解压阶段再带着树(或重建树)去解码。
生成表的时候,你可以用一个临时数组记录从根到当前节点的路径。递归到叶子时,把根到叶子的路径保存到编码表里。这其实就是普通二叉树的DFS路径回溯,没什么特别高深的技巧,但特别容易写错的两个点:一是临时数组的深度要足够大(有的实现只开了固定长度,遇到深树直接越界),二是回溯时要记得把当前层置空,不然下一层兄弟节点拼接出多余的前缀。
5. 完整代码实现:可直接抄作业的C语言版本
5.1 数据结构设计
我用C语言写一份完整实现,兼顾可读性和效率。如果有读者用的是Python或Java,思路完全一样,只是容器类库更方便些。
#include <stdio.h> #include <stdlib.h> #include <string.h> #define MAX_NODES 300 #define MAX_CODE_LEN 100 typedef struct HNode { int weight; int parent; int lchild; int rchild; } HNode;我用的是"静态数组 + 下标连接"的写法,而不是动态指针树。这种设计的好处是避免频繁malloc/free,内存管理的风险大幅降低,而且数据结构的代码量更少。缺点是数组要事先开够大小,一般开成"叶子节点数的两倍减一"即可,我直接开300是图省事,实际可以按需要算好。
5.2 初始化与构造
void initHT(HNode *ht, int *weights, int n) { int total = 2 * n - 1; for (int i = 0; i < n; i++) { ht[i].weight = weights[i]; ht[i].parent = -1; ht[i].lchild = -1; ht[i].rchild = -1; } for (int i = n; i < total; i++) { ht[i].weight = 0; ht[i].parent = -1; ht[i].lchild = -1; ht[i].rchild = -1; } } void buildHuffmanTree(HNode *ht, int n) { if (n <= 1) return; int total = 2 * n - 1; for (int i = n; i < total; i++) { int min1 = -1, min2 = -1; for (int j = 0; j < i; j++) { if (ht[j].parent == -1) { if (min1 == -1 || ht[j].weight < ht[min1].weight) { min2 = min1; min1 = j; } else if (min2 == -1 || ht[j].weight < ht[min2].weight) { min2 = j; } } } ht[i].weight = ht[min1].weight + ht[min2].weight; ht[i].lchild = min1; ht[i].rchild = min2; ht[min1].parent = i; ht[min2].parent = i; } }这个写法里选最小两个值的过程是从数组头扫一遍。因为新节点总是追加在数组尾部,且构建过程是递增的,所以用一次线性扫描就够了。严格来说,这个线性扫描每次做比较的次数变多了,但胜在代码直观,容易debug。对于学生作业或小规模数据,完全够用。
5.3 生成编码表与解码
void buildCodeTable(HNode *ht, int node, char *path, int depth, char codes[][MAX_CODE_LEN], int n) { if (node == -1) return; if (ht[node].lchild == -1 && ht[node].rchild == -1) { path[depth] = '\0'; if (node < n) { strcpy(codes[node], path); } return; } if (ht[node].lchild != -1) { path[depth] = '0'; buildCodeTable(ht, ht[node].lchild, path, depth + 1, codes, n); } if (ht[node].rchild != -1) { path[depth] = '1'; buildCodeTable(ht, ht[node].rchild, path, depth + 1, codes, n); } } int decodeSequence(HNode *ht, int root, const char *bits, int len, char *output) { int cur = root; int outLen = 0; for (int i = 0; i < len; i++) { if (bits[i] == '0') { cur = ht[cur].lchild; } else { cur = ht[cur].rchild; } if (ht[cur].lchild == -1 && ht[cur].rchild == -1) { output[outLen++] = (char)cur; cur = root; } } output[outLen] = '\0'; return outLen; }这个解码函数假设字符值恰好等于它的数组下标。实际场景里,你的字符可能不是0到n-1的数字,而是ASCII码或其他对象。这时候你需要做一个映射,把字符值和数组下标绑定起来。我下面会专门讲这个坑。
5.4 主函数验证
int main() { int weights[] = {5, 2, 3, 8}; int n = 4; HNode ht[2 * MAX_NODES]; initHT(ht, weights, n); buildHuffmanTree(ht, n); int root = 2 * n - 2; char codes[4][MAX_CODE_LEN]; char path[MAX_CODE_LEN]; memset(codes, 0, sizeof(codes)); buildCodeTable(ht, root, path, 0, codes, n); for (int i = 0; i < n; i++) { printf("字符%d(权值%d)的编码: %s\n", i, weights[i], codes[i]); } return 0; }运行结果应该类似这样:
字符0(权值5)的编码: 00 字符1(权值2)的编码: 010 字符2(权值3)的编码: 011 字符3(权值8)的编码: 1你可以手动算一下WPL:5×2 + 2×3 + 3×3 + 8×1 = 10 + 6 + 9 + 8 = 33。相比固定编码的WPL(所有字符深度都为2,5×2+2×2+3×2+8×2=36)确实小了不少。
6. 选型对比:不同实现方式和语言优化方案
6.1 静态数组 vs 动态指针
我推荐静态数组这个写法,但不是说它适用于所有场景。如果你是做一个大的压缩引擎,字符集可能很大、树也可能动态变化,那用指针加堆的模式更灵活。C语言里用指针写树结构会让代码可读性下降不少,尤其是两者指针指来指去,调试起来很头疼。静态数组的好处是所有节点集中管理,方便观察和维护。
不过静态数组有一个小隐患:如果n特别大,你要想清楚最大节点数2n-1能否预估。对于字符编码来说,字符集上限通常就是256(或Unicode里实际出现的字符数),所以数组开2×256-1就绰绰有余,完全不用担心。
6.2 Python实现简述
Python里用heapq就能很优雅地解决问题。核心思路是:堆里存(weight, node),每次pop两个最小的,合并后push回去。关键技巧是要给节点加一个自增序号,不然两个权值相同的元组比较起来会报错——Python元组比较会紧接着比较第二个元素,如果第二个元素是对象且没有实现比较运算符,就会崩溃。
import heapq def build_huffman_tree(weights): n = len(weights) heap = [(w, i) for i, w in enumerate(weights)] heapq.heapify(heap) parent = [-1] * (2 * n - 1) left = [-1] * (2 * n - 1) right = [-1] * (2 * n - 1) for idx in range(n, 2 * n - 1): w1, i1 = heapq.heappop(heap) w2, i2 = heapq.heappop(heap) left[idx] = i1 right[idx] = i2 parent[i1] = idx parent[i2] = idx heapq.heappush(heap, (w1 + w2, idx)) return parent, left, right, 2 * n - 2这个实现里,节点本身不存权值,只在堆的元组里出现一次。你如果后面还需要节点的权值信息,就得单独开一个数组存一下,不然左子树右子树合并时无从获知子树的权重。
6.3 Java的PriorityQueue实现
Java的PriorityQueue天然支持对象排序,你可以让节点类实现Comparable接口,或者传入Comparator。一个小坑是:PriorityQueue的poll方法在队列空时会返回null,甚至抛异常。调试时一定要确保每次poll之前队列里至少有两个元素。另外,如果你在节点里保存了编码信息,不要试图在树构造完成前就去读节点编码——那会儿编码根本还没生成。
7. 工程落地:文件压缩里的完整配套流程
7.1 不只是发编码表,还要附带树本身
很多人以为编码表生成完就结束,其实在真实压缩场景里,光有编码表不够。解码端如果没有那棵树,拿到一串bit根本不知道哪个编码对应哪个字符。所以压缩包的格式一般包括两部分:头部存哈夫曼树的描述信息,主体存编码后的bit流。树怎么存呢?常见的办法是用先序遍历输出树的结构,每个内部节点标记一个特殊位,叶子额外带上字符值。比如可以用"1"表示内部节点,"0"表示叶子,这样解码端用一遍递归就能重建整棵树。
这里面有个小优化:你可以直接用字符频率表重建哈夫曼树,而不必把树的形状原样存下来。因为树完全由频率决定——只要频率一致,重建出的树结构就一样。但这里有个前提:你两次构建必须采用完全相同的合并规则(包括等权值时的取舍顺序),否则可能产生不同的树。稳妥起见,很多格式还是直接存树结构,省掉了重建过程的潜在不一致。
7.2 bit写入与位运算处理
编码结果是"0"和"1"这样的字符串,直接按字符写入文件会浪费空间——一个字符占1个字节,但理论上一个bit就够了。所以压缩模块里你必须做位级写入。C语言里可以用一个字节作为缓冲区,每凑满8个bit就写入文件。核心逻辑:
void writeBit(FILE *fp, int bit, int *bufByte, int *bitCount) { if (bit) *bufByte |= (1 << (7 - (*bitCount))); (*bitCount)++; if (*bitCount == 8) { fputc(*bufByte, fp); *bufByte = 0; *bitCount = 0; } }你可能想不通为什么要用1 << (7 - (*bitCount))。这里我用7减去当前bit位置,是为了把bit从高位开始填,保证和编码串的阅读顺序一致。你完全可以按低位填充,只要你记录的位序和读取方一致即可。关键是压缩和解压必须统一约定,否则一堆bit拼出来的数据全反掉。
7.3 压缩率评估的两个注意点
用哈夫曼编码做文件压缩,频率分布是否有利对压缩率影响巨大。如果文本里字符频率严重不均衡,效果就非常明显;如果所有字符出现次数都差不多,那哈夫曼编码和固定长度编码的差别就微乎其微。甚至极端情况下,因为要存储树结构或重建频率表这类额外开销,压缩包反而比原文件还大——你测压缩率时要考虑头部和索引的成本,别只看主体编码长度。
还有一点:哈夫曼编码对单个字符进行编码,没有利用字符之间的相关性。比如英文中"th"经常连续出现,这种组合模式用哈夫曼编码是抓不到的。这也是为什么实际文件压缩器普遍用LZ77/LZ78结合哈夫曼编码(比如Deflate算法,就是zlib/gzip的基础)。哈夫曼编码作为熵编码的后端,负责把LZ77处理后剩余的重复信息尽量压缩。
8. 常见问题与排坑实录
8.1 问题一:变长编码解码乱了,回头一看没确认叶子位置
我见过很多刚上手的人,包括我自己第一次写解码器,都犯过这个错误:拿着编码串"010"去找节点,按bit走到一个节点就猜"这里是不是一个字符",结果发现很多时候在内部节点就输出了。原因很简单,你压根没检查当前节点是否为叶子。正确做法就是刚才代码里写的,必须检查lchild和rchild是否都为-1。一旦发现当前节点的左右孩子都不存在,才认为到达叶子,否则继续往下走。
8.2 问题二:等权值节点的选择影响树的形状,但不影响WPL
做题时如果答案给出一棵和你的树形状不同的哈夫曼树,别慌,先算一下WPL。只要WPL一致,两种答案通常都是对的。但你要是严格按"每次选两个最小,权值相同则选下标靠前"的规则来写代码,那同一份输入必然稳定地产出同一棵树,结果也必然可复现。做工程时,这种确定性很重要,所以建议在节点比较时加入一个可靠的次级排序条件(比如字符的ASCII值或输入顺序)。
8.3 问题三:字符串编码的边界处理
用C语言处理编码时,记得给编码字符串末尾留'\0'的位置,不然strcpy的时候容易越界。哈夫曼树在最坏情况下可能退化成一条链,比如权值是斐波那契数列那样(1, 1, 2, 3, 5...),树的深度接近n,编码长度也可能接近n。如果代码里固定开一个32字节的数组,遇到这种极深树直接爆掉。稳妥做法是给编码数组开大一点,或者在递归时即时输出而不是存满,减少对长度的依赖。
8.4 问题四:非ASCII字符的索引映射
解码时如果直接用数组下标当作字符值,遇到Unicode或中文就会出问题。正确做法是自建一个映射表:先把所有出现的字符收集起来,给每个字符分配一个ID(如0、1、2...),压缩时记录字符ID的映射关系,解码后再把ID转回原始字符。这就是为什么我上面演示代码里"字符0、字符1"看着很奇怪——那只是ID,真实的字符要套一层映射。
8.5 问题五:堆里的结构体排序条件缺失
Python的heapq和Java的PriorityQueue都要求元素之间可比。如果你直接往堆里塞(weight, node)这种元组,Python会自动比较两个元素的第二项,一旦node是自定义对象且没有实现__lt__,运行时就会报错。麻烦在于这个报错不是发生在你push的时候,而是发生在堆内部调整位置时,报错信息可能很隐晦。解决办法就是给元组加一个不会冲突的第三项,比如每创建一个节点就分配一个全局递增的整数ID。
9. 深入优化方向:不止哈夫曼,还能怎么变
哈夫曼编码是很多高级压缩算法的基础,但它本身也有一些可玩的变化。一个经典扩展是"规范哈夫曼编码",编码前先统计编码长度,然后用连续数字重新分配编码,比如所有长度为3的编码按000、001、010这样的顺序排。这样做的好处是压缩时只需要存储每个字符的编码长度,而不是完整的码表,省下大量存储空间。
另一个方向是"自适应哈夫曼编码"。传统的哈夫曼编码先完整统计一遍字符频率再建树,这要求你把整份数据读入内存(或者至少读一遍来统计)。对于流式传输的场景,一次性统计不太现实,于是出现了自适应算法:随着数据不断读入,动态更新字符频率并调整树结构。著名的FGK算法和Vitter算法就是这类方向。虽然实现复杂度会上一个台阶,但应对实时通信场景的时候,这种方案的价值是传统方式替代不了的。
如果你感兴趣,还可以研究算术编码。它在理论上能达到的信息熵更接近,压缩率通常优于哈夫曼编码,但实现难度和专利问题(历史上)也让很多工程选择了哈夫曼编码作为默认熵编码方案。zlib、bzip2这些工具采用的方法各有侧重,你都能看到哈夫曼编码或类似思想在背后发力。
10. 我踩过的几个坑,录在这里供你参考
最后分享一点很实在的东西。拿哈夫曼编码做文本压缩练手时,我犯过一个挺尴尬的错误——写完编码器后压缩率竟然为负,压缩出来的文件比原文件还大。排查了一圈才发现,我每写一个bit就调用一次fputc,相当于一个字符的8个bit写成了8个字节落盘,压缩个寂寞。后来改成缓冲区累积写入,立刻恢复正常,这也让我明白了"理论上的bit长度"和"实际落盘字节数"之间差着一次高效封装。
还有一次,我处理一个特别小的测试文件,字符种类只有两三个,但频率极不均匀。当时没仔细想就直接构建哈夫曼树,结果树的高度很小,编码长度差异也很大,压缩效果确实不赖。但后来换成一个随机生成的文件,发现压缩率掉到了近乎1:1,这让我意识到哈夫曼编码不是万能的,它的收益完全依赖概率分布的偏斜程度。你看很多压缩工具在设计时先做去冗余预处理,再上熵编码,本质上就是在想办法把数据变得"更加偏斜",方便后续压缩。
另外,我发现一个实用的调试小技巧:在解码时把每一步的当前节点值和向左/向右的bit打印出来,这样一旦输出不对,立刻能定位到是树建错了还是bit流转错了。这种逐bit跟踪的方式虽然笨,但比盯着内存看快得多。写代码的过程其实很大程度上是调试驱动的,尤其涉及这种跨模块的数据流处理,可视化输出比脑内模拟可靠得多。
如果你是在为面试准备哈夫曼树,我建议不仅要会手算编码,还要能写一个能跑的版本。很多面试题喜欢考查两个点:一是构造过程是否清楚,特别是处理等权值时的策略;二是能否熟练地从树生成编码表、并用编码表解码。这两块如果平时没实操过,很容易在一紧张的时候搞混左右分支的方向。
等你把基础版本跑通了,不妨再做几组实验:用同一个文本文件分别测试固定编码和哈夫曼编码的输出大小,再测试高频字符占比不同的数据。多做几组对照实验,你就能直观感受到"频率越不均衡,哈夫曼编码的优势越明显"这句话的含义了。
我当时就是把这些对照数据记录下来放在博客里,之后无论过多久再回来看,都能一眼理解当时的设计选择。学习数据结构这件事,最难的不是背概念,而是把概念变成能跑通的代码,再通过代码回头加深对概念的理解。哈夫曼树和哈夫曼编码这一步走踏实了,之后看压缩算法、信息论相关的知识都会顺很多。