
文章目录
- 哈夫曼树与带权路径长度(WPL)
-
-
- 第一步:生活中的核心动机------让出现频率最高的东西离我们最近
- [第二步:408 必考数学概念------WPL 与路径长度](#第二步:408 必考数学概念——WPL 与路径长度)
-
- [1. 结点的路径长度(Path Length)](#1. 结点的路径长度(Path Length))
- [2. 树的带权路径长度(Weighted Path Length, WPL)](#2. 树的带权路径长度(Weighted Path Length, WPL))
- [第三步:哈夫曼树的贪心构造法(考场 30 秒手绘法)](#第三步:哈夫曼树的贪心构造法(考场 30 秒手绘法))
- [第四步:408 选择题必考的 4 大结构定理](#第四步:408 选择题必考的 4 大结构定理)
- 第五步:前缀编码与无二义性解码
-
- [1. 什么是前缀编码?](#1. 什么是前缀编码?)
- [2. 哈夫曼编码天然满足"无二义性前缀编码"](#2. 哈夫曼编码天然满足“无二义性前缀编码”)
- 本阶段巩固练习与解析
-
- [题目 1(408 选择题真题变形:节点数计算)](#题目 1(408 选择题真题变形:节点数计算))
- [题目 2(408 核心选择题:手算 WPL)](#题目 2(408 核心选择题:手算 WPL))
-
- [在408 考研和实际工程中代码层面的考查](#在408 考研和实际工程中代码层面的考查)
-
-
- [一、 408 算法大题核心:链式二叉树求 WPL](#一、 408 算法大题核心:链式二叉树求 WPL)
-
- [1. 结点结构定义](#1. 结点结构定义)
- [2. 递归实现代码(考场满分模板)](#2. 递归实现代码(考场满分模板))
- [3. 核心逻辑与考场避坑点](#3. 核心逻辑与考场避坑点)
- [二、 统编教材标准实现:静态结构体数组构建哈夫曼树](#二、 统编教材标准实现:静态结构体数组构建哈夫曼树)
-
- [1. 静态结点结构定义](#1. 静态结点结构定义)
- [2. 数组大小与下标规则](#2. 数组大小与下标规则)
- [3. 辅助选择函数:挑出两个权值最小且无双亲的结点](#3. 辅助选择函数:挑出两个权值最小且无双亲的结点)
- [4. 构建哈夫曼树完整函数](#4. 构建哈夫曼树完整函数)
- [三、 考场拓展:基于静态数组逆向生成哈夫曼编码](#三、 考场拓展:基于静态数组逆向生成哈夫曼编码)
-
- 考场上的代码边界和题型对应规则
-
-
- [一、 408 考研对哈夫曼树的代码考查边界](#一、 408 考研对哈夫曼树的代码考查边界)
- [二、 2014 年 408 真题(15 分)满分标答代码](#二、 2014 年 408 真题(15 分)满分标答代码)
- [三、 考场评分标准采分点拆解](#三、 考场评分标准采分点拆解)
- [四、 针对 408 的高效备考策略](#四、 针对 408 的高效备考策略)
-
哈夫曼树与带权路径长度(WPL)
第一步:生活中的核心动机------让出现频率最高的东西离我们最近
假设你是一个排字工人,日常接触的字母里:
- 字母
E的使用频率高达 40%; - 字母
Z的使用频率只有 0.1%。
如果你用等长编码(比如每个字母都用 5 位二进制表示),那么不管常见还是罕见,耗费的存储长度都一样。
但如果我们把最常用的字母用极短的编码 (如用 1 位表示 E),把极罕见的字母用较长的编码 (如用 8 位表示 Z),整篇文章的总传输体积就会断崖式缩减。
这种"权值越大(频率越高),离根结点越近;权值越小,离根结点越远"的二叉树,就是哈夫曼树。
第二步:408 必考数学概念------WPL 与路径长度
1. 结点的路径长度(Path Length)
- 定义 :从树根结点到该结点所经过的分支条数(连线数)。
- 规则:若规定根结点的层数为 0,那么一个结点的层数就是它的路径长度。
2. 树的带权路径长度(Weighted Path Length, WPL)
- 定义 :树中所有叶子结点 的权值 与其到根结点的路径长度的乘积之和。
W P L = ∑ i = 1 n w i × l i WPL = \sum_{i=1}^{n} w_i \times l_i WPL=i=1∑nwi×li
- w i w_i wi:第 i i i 个叶子结点的权值;
- l i l_i li:第 i i i 个叶子结点的路径长度。
看下面这棵二叉树:
text
[ 根 ] (层数 0)
/ \
(分支) [叶: 7] (层数 1)
/ \
[叶: 2] [叶: 4] (层数 2)
计算该树的 WPL:
- 叶子
7: 7 × 1 = 7 7 \times 1 = 7 7×1=7 - 叶子
2: 2 × 2 = 4 2 \times 2 = 4 2×2=4 - 叶子
4: 4 × 2 = 8 4 \times 2 = 8 4×2=8 - 整棵树的 W P L = 7 + 4 + 8 = 19 WPL = 7 + 4 + 8 = 19 WPL=7+4+8=19
408 考点铁律 :
WPL 只计算"叶子结点"!中间的分支结点绝不能代入公式计算!
第三步:哈夫曼树的贪心构造法(考场 30 秒手绘法)
给定 n n n 个权值,构造哈夫曼树的四步口诀:"挑两小、做左右、合新爹、放回圈"。
假设权值集合为: { 2 , 3 , 6 , 7 , 8 } \{2, 3, 6, 7, 8\} {2,3,6,7,8}。
- 挑两小 :在集合中找出最小的两个数:
2和3。 - 做左右、合新爹 :以
2和3为左右孩子,生成一个新父结点,其权值为 2 + 3 = 5 2 + 3 = 5 2+3=5。
- 此时集合变为: { 5 , 6 , 7 , 8 } \{5, 6, 7, 8\} {5,6,7,8}(
2和3已合并)。
- 重复挑两小 :当前集合中最小的两个是
5和6。
- 合并生成新父结点,权值为 5 + 6 = 11 5 + 6 = 11 5+6=11。
- 此时集合变为: { 7 , 8 , 11 } \{7, 8, 11\} {7,8,11}。
- 重复挑两小 :当前集合中最小的两个是
7和8。
- 合并生成新父结点,权值为 7 + 8 = 15 7 + 8 = 15 7+8=15。
- 此时集合变为: { 11 , 15 } \{11, 15\} {11,15}。
- 最后合并 :合并
11和15,生成总根结点,权值为 11 + 15 = 26 11 + 15 = 26 11+15=26。
完整树形构造如下:
text
[ 26 ] (总根)
/ \
[ 11 ] [ 15 ]
/ \ / \
[ 5 ] [ 6 ] [ 7 ] [ 8 ]
/ \
[ 2 ] [ 3 ]
第四步:408 选择题必考的 4 大结构定理
- 哈夫曼树中绝对不存在度为 1 的结点( n 1 = 0 n_1 = 0 n1=0):
- 每次构造都是"挑两个最小的结为夫妻,生出一个新爹"。
- 树中所有结点要么是叶子(度为 0),要么是双亲(度为 2),只有严格的正则二叉树形态。
- 总结点数公式:
- 根据阶段一证明过的定理 n 0 = n 2 + 1 n_0 = n_2 + 1 n0=n2+1,因为 n 1 = 0 n_1 = 0 n1=0:
n = n 0 + n 1 + n 2 = n 0 + 0 + ( n 0 − 1 ) = 2 n 0 − 1 n = n_0 + n_1 + n_2 = n_0 + 0 + (n_0 - 1) = \mathbf{2n_0 - 1} n=n0+n1+n2=n0+0+(n0−1)=2n0−1
- 结论 :若有 n n n 个初始叶子结点,构造出的哈夫曼树总结点数必定恰好是 2 n − 1 2n - 1 2n−1 个 !新生成的分支结点数必为 n − 1 n - 1 n−1 个。
- WPL 计算的"分支权值累加神技"(考场光速口算法):
- 除了用定义法 ∑ w i ⋅ l i \sum w_i \cdot l_i ∑wi⋅li 计算外,还有一个等价定理:
- 整棵哈夫曼树的 WPL,严格等于所有"新生成的分支结点权值"之和!
- 看上面的例子:
- 新生成的分支结点权值分别为: 5 , 11 , 15 , 26 5, 11, 15, 26 5,11,15,26。
- 直接相加: W P L = 5 + 11 + 15 + 26 = 57 WPL = 5 + 11 + 15 + 26 = 57 WPL=5+11+15+26=57。
- 用定义法验证: 2 × 3 + 3 × 3 + 6 × 2 + 7 × 2 + 8 × 2 = 6 + 9 + 12 + 14 + 16 = 57 2 \times 3 + 3 \times 3 + 6 \times 2 + 7 \times 2 + 8 \times 2 = 6 + 9 + 12 + 14 + 16 = 57 2×3+3×3+6×2+7×2+8×2=6+9+12+14+16=57。
- 两者完全一致!考场上手算选择题时,把所有合并出来的新数直接相加,能节省大量时间。
- 哈夫曼树的形态不唯一,但最小 WPL 必定唯一:
- 当权值出现并列(平手),或者左右子树摆放方位不同时,树的几何形态会有差异;但最终算出来的带权路径长度 WPL 具有数学唯一性。
第五步:前缀编码与无二义性解码
1. 什么是前缀编码?
- 定义 :在给字符集合设计变长二进制编码时,没有任何一个字符的编码是另一个字符编码的"前缀(开头的子串)"。
- 反例 :若
A: 0,B: 01。 - 接收端收到信号
01时,无法判断这是代表单个字符B,还是代表A后面紧跟了一个1,造成了解码二义性。
2. 哈夫曼编码天然满足"无二义性前缀编码"
- 路径标号 :约定"向左走走分支记为
0,向右走分支记为1"。 - 字符全部位于叶子结点上。
- 因为叶子结点不可能成为其他结点的祖先,所以从根到任何一个叶子的编码路径,绝对不可能是到达另一个叶子路径的前缀!
text
[ 26 ]
/ 0 \ 1
[ 11 ] [ 15 ]
/ 0 \ 1 / 0 \ 1
[ 5 ] (6) (7) (8)
/ 0 \ 1
(2) (3)
编码结果一目了然:
- 字符 2(权值 2):路径为 左 → \to → 左 → \to → 左,编码为
000 - 字符 3(权值 3):路径为 左 → \to → 左 → \to → 右,编码为
001 - 字符 6(权值 6):路径为 左 → \to → 右,编码为
01 - 字符 7(权值 7):路径为 右 → \to → 左,编码为
10 - 字符 8(权值 8):路径为 右 → \to → 右,编码为
11
本阶段巩固练习与解析
题目 1(408 选择题真题变形:节点数计算)
某通信系统需要对 16 个不同的离散字符进行哈夫曼编码,各个字符在报文中出现的频度各不相同。请问构造出的哈夫曼树中:
- 包含多少个分支结点?
- 全树总共有多少个结点?
答案与解析:
- 分支结点数 :15 个
- 总结点数 :31 个
- 详细解析:
- 叶子结点数 n 0 = 16 n_0 = 16 n0=16。
- 每次合并减少一个孤立结点,生成 1 个新分支结点,合并 n 0 − 1 n_0 - 1 n0−1 次,故分支结点数 n 2 = 16 − 1 = 15 n_2 = 16 - 1 = 15 n2=16−1=15 个。
- 总结点数 n = 2 n 0 − 1 = 2 × 16 − 1 = 31 n = 2n_0 - 1 = 2 \times 16 - 1 = 31 n=2n0−1=2×16−1=31 个。
题目 2(408 核心选择题:手算 WPL)
已知一组字符在报文中出现的权重分别为:{ 3, 5, 6, 8, 10 }。
请问基于这组权值构建的哈夫曼树的带权路径长度(WPL)是多少?
答案与解析:
- 答案 :72
- 详细推演:
- 挑出最小的
3和5,合并出新结点8。剩余集合:{6, 8, 8, 10}。 - 挑出最小的
6和8(原来的8),合并出新结点14。剩余集合:{8, 10, 14}。 - 挑出最小的
8(新生成的8)和10,合并出新结点18。剩余集合:{14, 18}。 - 合并
14和18,生成总根32。 - 使用分支结点相加神技:
W P L = 8 + 14 + 18 + 32 = 72 WPL = 8 + 14 + 18 + 32 = \mathbf{72} WPL=8+14+18+32=72
在408 考研和实际工程中代码层面的考查
哈夫曼树在 408 考研和实际工程中,代码层面的考查主要分为两大经典阵营:
- 大题手撕核心:链式二叉树的带权路径长度(WPL)递归求法(2014 年 408 统考 15 分真题原型)。
- 教材标准算法:基于静态结构体数组的哈夫曼树构建与求编码(王道/严蔚敏教材的经典实现)。
我们逐一拆解这两套核心代码的底层逻辑。
一、 408 算法大题核心:链式二叉树求 WPL
在 2014 年统考真题中,题目给出二叉树的链式存储结构,要求计算全树的 WPL。
1. 结点结构定义
cpp
struct BiTNode {
int weight; // 结点的权值(非叶子结点权值可填 0)
BiTNode *lchild; // 左孩子
BiTNode *rchild; // 右孩子
BiTNode(int w) : weight(w), lchild(nullptr), rchild(nullptr) {}
};
2. 递归实现代码(考场满分模板)
cpp
// depth 表示当前结点的层数(初始传入 0,表示根结点的路径长度为 0)
int CalculateWPL(BiTNode *root, int depth) {
// 递归基 1:空结点不贡献任何 WPL
if (root == nullptr) {
return 0;
}
// 递归基 2:判断是否为叶子结点(左右孩子皆为空)
if (root->lchild == nullptr && root->rchild == nullptr) {
return root->weight * depth; // 叶子结点的 WPL = 权值 * 深度
}
// 递归下潜:左子树叶子 WPL 之和 + 右子树叶子 WPL 之和(下潜时层数 +1)
return CalculateWPL(root->lchild, depth + 1) +
CalculateWPL(root->rchild, depth + 1);
}
3. 核心逻辑与考场避坑点
-
为什么深度
depth初始传 0? -
按照 408 规范,根结点到自身的路径长度(边的条数)为 0,根的孩子路径长度为 1。所以初次调用写成
CalculateWPL(root, 0)。 -
为什么遇到叶子就直接返回?
-
许多初学者容易犯的错误是:对整棵树先序遍历,然后把遇到的所有结点都乘以深度累加。
-
WPL 的数学定义只对叶子结点生效! 中间分支结点的权值哪怕有值,也绝对不能乘以深度加进去,必须用
!root->lchild && !root->rchild严格锁死叶子身份。
二、 统编教材标准实现:静态结构体数组构建哈夫曼树
在没有指针的场景下(或为了方便回溯找双亲),408 教材通常采用静态连续数组来存储哈夫曼树。
1. 静态结点结构定义
cpp
struct HTNode {
int weight; // 结点权值
int parent; // 双亲结点的下标(0 表示暂无双亲,即独立子树根)
int lchild; // 左孩子下标
int rchild; // 右孩子下标
};
2. 数组大小与下标规则
- 如果有 n n n 个叶子结点,根据前面讲过的定理,哈夫曼树总结点数为 2 n − 1 2n - 1 2n−1。
- 教材习惯使用 1-based 索引 (下标 0 0 0 闲置不用),所以数组大小通常开辟为 2 n 2n 2n。
- 下标 1 ∼ n 1 \sim n 1∼n:存放原始的 n n n 个叶子结点;
- 下标 n + 1 ∼ 2 n − 1 n+1 \sim 2n-1 n+1∼2n−1:存放合并过程中依次生成的 n − 1 n-1 n−1 个分支结点;
- 下标 2 n − 1 2n - 1 2n−1:最终生成的大树总根。
3. 辅助选择函数:挑出两个权值最小且无双亲的结点
cpp
// 在 HT[1...k] 中选出 parent 为 0 且 weight 最小的两个结点下标 s1, s2
void SelectTwoMin(const vector<HTNode> &HT, int k, int &s1, int &s2) {
int min1 = INT_MAX, min2 = INT_MAX;
s1 = s2 = 0;
for (int i = 1; i <= k; i++) {
// 只在尚未合并入其他树(parent == 0)的结点中挑选
if (HT[i].parent == 0) {
if (HT[i].weight < min1) {
// 原来的第一小降级为第二小
min2 = min1;
s2 = s1;
// 刷新第一小
min1 = HT[i].weight;
s1 = i;
} else if (HT[i].weight < min2) {
// 刷新第二小
min2 = HT[i].weight;
s2 = i;
}
}
}
}
4. 构建哈夫曼树完整函数
cpp
void CreateHuffmanTree(const vector<int> &weights, int n, vector<HTNode> &HT) {
if (n <= 0) return;
int total_nodes = 2 * n - 1;
HT.resize(total_nodes + 1); // 1-based 下标,共开辟 2n 个空间
// 1. 初始化所有结点(包括叶子和分支)
for (int i = 1; i <= total_nodes; i++) {
HT[i].weight = (i <= n) ? weights[i - 1] : 0;
HT[i].parent = 0;
HT[i].lchild = 0;
HT[i].rchild = 0;
}
// 2. 循环合并 n - 1 次,生成剩余的 n - 1 个分支结点
for (int i = n + 1; i <= total_nodes; i++) {
int s1, s2;
// 在前 i-1 个结点中挑出权值最小的两个无双亲结点
SelectTwoMin(HT, i - 1, s1, s2);
// 合并生成新分支结点 i
HT[i].weight = HT[s1].weight + HT[s2].weight;
HT[i].lchild = s1;
HT[i].rchild = s2;
// 设置被合并子结点的 parent 指针
HT[s1].parent = i;
HT[s2].parent = i;
}
}
三、 考场拓展:基于静态数组逆向生成哈夫曼编码
在树建好后,如果要输出每个叶子结点的哈夫曼二进制编码(左 0 右 1),最优雅的方法是从叶子结点向上"找爹"反向追溯:
cpp
// 逆向生成每个字符的哈夫曼编码
vector<string> GenerateHuffmanCodes(const vector<HTNode> &HT, int n) {
vector<string> codes(n);
// 遍历每一个叶子结点 1 到 n
for (int i = 1; i <= n; i++) {
string code = "";
int cur = i;
int p = HT[cur].parent;
// 一路往上找爹,直到根结点(parent == 0)
while (p != 0) {
if (HT[p].lchild == cur) {
code.push_back('0'); // 是左孩子记为 0
} else {
code.push_back('1'); // 是右孩子记为 1
}
cur = p;
p = HT[cur].parent;
}
// 因为是从叶子往根找的,所以把结果反转过来才是标准的正向编码
reverse(code.begin(), code.end());
codes[i - 1] = code;
}
return codes;
}
考场上的代码边界和题型对应规则
一、 408 考研对哈夫曼树的代码考查边界
在 408 统考真题与阅卷标准中:
- 绝对 100% 不会考手撕整个静态哈夫曼树构建函数(即不需要死记
CreateHuffmanTree完整代码):
- 静态结构体数组的构建代码包含动态数组分配、
Select找两小、下标回填,冗长琐碎,从来不是 408 大题的考查重点。 - 选择题100% 考察手算过程 :直接根据权值序列在草稿纸上画出哈夫曼树、计算 W P L WPL WPL、求前缀编码。
- 算法大题只会考"二叉树链式存储结构下的 WPL 递归计算":
- 真题原题(2014 年 408 第 41 题,满分 15 分):直接给出一棵二叉树的链式存储结构,要求写出计算该二叉树带权路径长度(WPL)的算法。
- 408 大题评分标准非常纯粹:标准二叉链表遍历(先序/后序)+ 递归深度累加。
二、 2014 年 408 真题(15 分)满分标答代码
在 408 考场上,若遇到计算 WPL 的大题,采用纯 C 规范的二叉链表,答卷上只需要书写以下两个标准函数即可拿满 15 分:
c
// 1. 408 考卷给定的二叉链表结点结构
typedef struct BiTNode {
int weight; // 结点的权值
struct BiTNode *lchild; // 左孩子指针
struct BiTNode *rchild; // 右孩子指针
} BiTNode, *BiTree;
// 2. 递归核心辅助函数(先序遍历求 WPL)
// depth 记录当前结点的层数(根结点层数记为 0)
int WPL(BiTree root, int depth) {
// 递归基 1:遇到空指针,贡献为 0
if (root == NULL) {
return 0;
}
// 递归基 2:若当前结点为叶子结点(无孩子),直接返回 权值 * 深度
if (root->lchild == NULL && root->rchild == NULL) {
return root->weight * depth;
}
// 非叶子结点:继续向下递归累加左子树与右子树的 WPL,层数加 1
return WPL(root->lchild, depth + 1) + WPL(root->rchild, depth + 1);
}
// 3. 408 算法主入口函数
int CalculateWPL(BiTree root) {
return WPL(root, 0); // 根结点的深度传入 0
}
三、 考场评分标准采分点拆解
阅卷老师评阅此类 15 分大题时,严格按照以下得分点逐项给分:
-
算法思想说明(3~4 分):
-
用文字简述采用"先序遍历"或"后序遍历"思想;
-
说明利用形参
depth在递归调用时向下传递当前层数,遇叶子结点则计算weight * depth,分支结点则累加左右子树结果。 -
二叉树判空与递归基(2~3 分):
-
包含
if (root == NULL) return 0;,保证程序面对空树或单孩子分支时的鲁棒性。 -
叶子结点的准确判定(4~5 分):
-
必须准确写出
root->lchild == NULL && root->rchild == NULL; -
绝对不能把分支结点的权值误算进去。
-
递归调用与深度递增(2~3 分):
-
向下递归传参时为
depth + 1。 -
时空复杂度分析(2 分):
-
时间复杂度 : O ( n ) O(n) O(n),其中 n n n 为二叉树的结点总数,整棵树每个结点仅访问一次。
-
空间复杂度 : O ( h ) O(h) O(h),其中 h h h 为二叉树的高度,递归调用的工作栈深度最大为树高(最坏情况下退化为单链表时为 O ( n ) O(n) O(n))。
四、 针对 408 的高效备考策略
对于哈夫曼树这一章,复习抓手非常明确:
-
选择题(手推计算):
-
牢牢掌握"挑两小合成新数"的草稿纸手绘法;
-
熟练利用"所有新生成分支结点的权值之和即为 WPL"的速算技巧;
-
牢记性质:不存在度为 1 的结点( n 1 = 0 n_1 = 0 n1=0)、总结点数必为 2 n − 1 2n - 1 2n−1。
-
代码题(手撕大题):
-
只背熟上面那段 15 行的链式二叉树递归求 WPL 代码模板即可,无需在复杂的静态哈夫曼树构建代码上耗费应试精力。