6 二叉树进阶与哈夫曼树
GESP C++ 六级:二叉树进阶与哈夫曼树
核心目标:在掌握二叉树基础与遍历的前提下,深入理解完全二叉树、二叉排序树、哈夫曼树三种特殊二叉树的结构特性与典型应用,掌握哈夫曼树的构造方法、WPL计算与哈夫曼编码的生成规则,能辨析相关选择题与程序阅读题。
完全二叉树靠数组顺序存储,下标关系固定;二叉排序树靠“左小右大”实现高效查找;哈夫曼树靠“每次合并最小两个”实现WPL最小,编码靠“左0右1”得到前缀码——频率高的字符离根近、编码短。
一、考纲定位
根据 CCF 官方考纲,六级知识内容明确包含:“树的定义,构造与遍历;哈夫曼树、完全二叉树、二叉排序树;哈夫曼编码”。其中哈夫曼树与哈夫曼编码是六级数据结构模块的高频考点,在选择题和程序阅读题中反复出现。
六级二叉树进阶的考查分布:
| 考点 | 重要程度 | 典型考法 |
|---|---|---|
| 完全二叉树的判断与下标计算 | ★★★★★ | 选择题、编程题 |
| 二叉排序树的插入/查找/中序性质 | 代码填空、程序阅读 | |
| 哈夫曼树的构造过程 | 选择题、代码填空 | |
| WPL(带权路径长度)计算 | 选择题、计算题 | |
| 哈夫曼编码的生成与辨析 | 选择题高频 | |
| 哈夫曼树合并节点归属 | ★★★★☆ | 代码填空、程序阅读 |
六级二叉树进阶的命题趋势是:不考孤立的定义背诵,而是考“过程中的细节” ——构造哈夫曼树时新节点放在哪个队列、合并后权值如何计算、编码是否满足前缀性质、二叉排序树中序遍历为什么有序。理解过程比记住结论更重要。
二、完全二叉树进阶
2.1 完全二叉树的判定
完全二叉树的条件:除最后一层外,其他层都是满的;最后一层的节点全部集中在左侧。
遍历判定法(层序遍历,队列实现):
bool isCompleteTree(TreeNode* root) {
if (!root) return true;
queue<TreeNode*> q;
q.push(root);
bool hasNull = false; // 是否遇到过空孩子
while (!q.empty()) {
TreeNode* node = q.front(); q.pop();
if (node == nullptr) {
hasNull = true; // 遇到空,之后不能再有非空
} else {
if (hasNull) return false; // 空之后又出现非空,不是完全二叉树
q.push(node->left);
q.push(node->right);
}
}
return true;
}
核心逻辑:层序遍历过程中,一旦遇到空节点,后续不能再出现非空节点。空节点之后如果还有非空节点,说明节点没有集中在左侧,不是完全二叉树。
2.2 顺序存储的下标关系(必记)
完全二叉树最适合用数组顺序存储。根节点下标为 1 时:
| 关系 | 公式 | 示例(根下标1) |
|---|---|---|
| 左孩子 | 2 * i |
下标2的节点,左孩子下标4 |
| 右孩子 | 2 * i + 1 |
下标2的节点,右孩子下标5 |
| 父节点 | i / 2 |
下标5的父节点下标2 |
| 是否为叶子 | 2 * i > n |
下标3,左孩子6,若n=5则3是叶子 |
真题示例:一棵完全二叉树按顺序存储,根节点下标为 1,则下标为 5 的节点的左孩子下标是( )。答案:10(2 * 5 = 10)。
如果根节点下标为 0,关系变为:左孩子 2*i+1,右孩子 2*i+2,父节点 (i-1)/2。考试中一定要看清题目给定的根下标是 0 还是 1。
2.3 完全二叉树的节点数关系
| 条件 | 公式 |
|---|---|
| 第 i 层最多节点数 | (根为第1层) |
| 深度为 h 的完全二叉树,节点数范围 | ~ |
| 叶子节点数(完全二叉树) | 当 n 为偶数:n/2;当 n 为奇数:(n+1)/2 |
三、二叉排序树(BST)
3.1 核心性质
二叉排序树(Binary Search Tree)满足:左子树所有节点值 < 根节点值 < 右子树所有节点值。空树和单节点树也是合法的二叉排序树。
最关键的性质:二叉排序树的中序遍历结果是一个严格递增的有序序列。这个性质是 BST 所有查找类操作的基础,也是高频选择题考点。
3.2 BST 的查找
TreeNode* search(TreeNode* root, int target) {
if (root == nullptr || root->val == target) return root;
if (target < root->val)
return search(root->left, target);
else
return search(root->right, target);
}
核心逻辑:target 小于当前节点值就往左走,大于就往右走,等于就返回。
3.3 BST 的插入
TreeNode* insert(TreeNode* root, int val) {
if (root == nullptr) return new TreeNode(val);
if (val < root->val)
root->left = insert(root->left, val);
else
root->right = insert(root->right, val);
return root;
}
真题示例(2025年3月六级第5题) :
以下代码实现了二叉排序树的哪种操作?
TreeNode* op(TreeNode* root, int val) { if (root == nullptr) return new TreeNode(val); if (val < root->val) root->left = op(root->left, val); else root->right = op(root->right, val); return root; }答案:B(插入) 。代码在空位置创建新节点并挂到父节点上,是标准的 BST 插入操作。
3.4 BST 的删除(了解)
删除节点分三种情况:
- 叶子节点:直接删除。
- 只有一个孩子:用孩子替换被删节点。
- 有两个孩子:用右子树的最小节点(或左子树的最大节点)替换,再删除那个最小节点。
四、哈夫曼树
4.1 核心概念
哈夫曼树(Huffman Tree)是一棵带权路径长度(WPL)最短的二叉树,又称最优二叉树。
几个关键定义:
| 概念 | 定义 |
|---|---|
| 路径长度 | 从根到某节点经过的边数 |
| 节点的带权路径长度 | 该节点的权值 × 从根到该节点的路径长度 |
| 树的带权路径长度(WPL) | 所有叶子节点的带权路径长度之和 |
| 哈夫曼树 | WPL 最小的二叉树 |
WPL 的计算公式:
其中 是第 i 个叶子节点的权值, 是该叶子节点的深度(从根到该叶子的边数)。
注意:WPL 只计算叶子节点,不计算内部节点。内部节点的权值只是构造过程中的“中间产物”。
4.2 构造过程(必考)
哈夫曼树的构造采用贪心策略,核心规则是:每次选择权值最小的两个节点合并。
构造步骤:
1. 将所有权值看作独立的树(森林),每棵树只有一个节点
2. 从森林中选择权值最小的两棵树 T1 和 T2
3. 合并:新建一个内部节点,权值 = T1权值 + T2权值
T1 作为左孩子,T2 作为右孩子(或反之,左右不影响WPL)
4. 将新节点放回森林,删除 T1 和 T2
5. 重复步骤2-4,直到森林中只剩一棵树
n 个叶子节点的哈夫曼树共有 2n-1 个节点(n 个叶子 + n-1 个内部节点),需要 n-1 次合并。
4.3 构造示例
给定权值 {5, 1, 6, 2},构造哈夫曼树:
第1次合并:选最小两个 1 和 2,合并为 3。
第2次合并:选最小两个 3 和 5,合并为 8。
第3次合并:选最小两个 6 和 8,合并为 14(根)。
14
/ \
6 8
/ \
3 5
/ \
1 2
WPL 计算(只算叶子):
- 6 的深度为 1:6 × 1 = 6
- 5 的深度为 2:5 × 2 = 10
- 1 的深度为 3:1 × 3 = 3
- 2 的深度为 3:2 × 3 = 6
WPL = 6 + 10 + 3 + 6 = 25
4.4 真题精讲:合并节点的归属(2025年12月六级第8题)
在哈夫曼树构造过程中,合并两个最小节点后产生的新节点应该( )。
A. 放入内部节点队列(internalIdx)
B. 放入叶子节点队列(leafIdx)
C. 直接作为根节点
D. 丢弃
答案:A
解析:哈夫曼算法的核心流程中,合并产生的新节点是一个内部节点(不是原始字符),它的权值是两个孩子权值之和。这个新节点必须放回内部节点队列,参与后续的合并,而不是放回叶子队列(叶子队列只存放最初的字符节点)。
记忆口诀:叶子队列只放最初的字符,合并出来的节点都是内部节点,放内部队列。
五、哈夫曼编码
5.1 编码规则
从哈夫曼树的根出发:向左分支记 0,向右分支记 1。从根到某个叶子节点的路径上,0/1 序列就是这个叶子对应字符的哈夫曼编码。
左右互换不影响编码的最优性(WPL 不变),但会产生不同的具体编码。
5.2 哈夫曼编码的两个核心性质
| 性质 | 含义 | 为什么重要 |
|---|---|---|
| 变长编码 | 频率高的字符编码短,频率低的字符编码长 | 压缩效果 |
| 前缀编码 | 任何一个字符的编码都不是另一个字符编码的前缀 | 保证唯一解码 |
前缀性质的含义:解码时不需要分隔符,从根开始逐位匹配,遇到叶子就输出一个字符,不会产生歧义。例如 A: 0,B: 10,C: 11 是合法的前缀码;而 A: 0,B: 01 则不是(0 是 01 的前缀,解码时遇到 0 无法确定是 A 还是 B 的一部分)。
5.3 真题精讲:编码辨析(2025年3月六级第6题)
给定字符集 {A, B, C, D} 的出现频率分别为 {5, 1, 6, 2},则正确的哈夫曼编码是( )。
A. A: 0, B: 100, C: 11, D: 101
B. A: 11, B: 100, C: 0, D: 101
C. A: 0, B: 101, C: 11, D: 100
D. A: 10, B: 101, C: 0, D: 100
答案:A
解析:频率 C(6) 最高,编码应该最短(1位);A(5) 次之,编码应该较短(1位或2位);B(1) 和 D(2) 最低,编码应该最长(3位)。A 选项中 C 是 11(2位),A 是 0(1位),B 和 D 是 3位,整体编码长度分配合理,且满足前缀性质。B 选项中 C 的编码是 0(1位),但 A 的编码是 11(2位),A 的频率 5 比 D 的频率 2 高,A 的编码不应比 D 长太多;更重要的是,B 选项中 A: 11,D: 101,D 的编码以 1 开头,与 A 的 11 不构成前缀冲突,但 B 和 D 的频率最低却编码最长,分配逻辑不够优化。判断哈夫曼编码是否正确,核心是看频率与编码长度的对应关系是否合理,以及是否满足前缀性质。
5.4 真题精讲:哈夫曼编码的错误描述(2025年3月六级第2题)
以下关于哈夫曼编码的描述中,不正确的是( )。
A. 哈夫曼编码是一种变长编码,频率高的字符使用较短的编码,频率低的字符使用较长的编码。
B. 在构造哈夫曼树时,频率越低的字符离根节点越近,频率越高的字符离根节点越远。
C. 哈夫曼编码的生成过程基于贪心算法,每次选择频率最低的两个节点进行合并。
D. 哈夫曼编码是一种前缀编码,任何一个字符的编码都不会是另一个字符编码的前缀。
答案:B
解析:B 选项说反了。哈夫曼树中,频率越低的字符离根越远,频率越高的字符离根越近。频率高 → 编码短 → 离根近;频率低 → 编码长 → 离根远。这是哈夫曼编码压缩效果的核心来源。
5.5 真题精讲:哈夫曼编码的正确说法(2025年12月六级第9题)
关于哈夫曼编码,哪个说法是正确的?
A. 哈夫曼编码是定长编码
B. 哈夫曼编码中,没有任何一个字符的编码是另一个字符编码的前缀
C. 哈夫曼编码只能用于文本压缩
D. 哈夫曼树是唯一的
答案:B
解析:A 错,哈夫曼是变长编码;C 错,也可用于图像、音频压缩;D 错,当出现相同权值时,合并顺序不同可能产生不同形状的哈夫曼树(但 WPL 相同),因此哈夫曼树不唯一,但 WPL 唯一。B 正确,这是前缀编码的核心性质。
六、二叉树进阶综合真题精讲
真题1:完全二叉树判断代码(2025年3月六级第4题)
以下代码用于判断一棵树是否为( )。
bool isCompleteTree(TreeNode* root) { if (!root) return true; queue<TreeNode*> q; q.push(root); bool hasNull = false; while (!q.empty()) { TreeNode* node = q.front(); q.pop(); if (node == nullptr) { hasNull = true; } else { if (hasNull) return false; q.push(node->left); q.push(node->right); } } return true; }A. 满二叉树 B. 完全二叉树 C. 二叉搜索树 D. 计算树的高度
答案:B
解析:代码使用层序遍历,一旦遇到空节点就标记 hasNull = true,之后如果再遇到非空节点就返回 false。这正是完全二叉树的判定逻辑:节点必须集中在左侧,不能出现“空节点之后还有非空节点”的情况。
真题2:二叉排序树搜索填空(2024年3月六级第5题)
在 BST 搜索代码的横线处填入( )。
TreeNode* search(TreeNode* root, int target) { if (root == NULL || root->val == target) return root; if (_______________) return search(root->left, target); else return search(root->right, target); }A.
target < root->left
B.target < root->val
C.target > root->val
D.target > root->left
答案:B
解析:BST 的性质是左子树所有值 < 根 < 右子树所有值。当 target < root->val 时,说明目标在左子树中,应递归搜索左子树。
真题3:哈夫曼树合并选择(2024年3月六级第1题)
在构建哈夫曼树时,每次应该选择( )合并。
A. 最小权值的节点
B. 最大权值的节点
C. 随机节点
D. 深度最深的节点
答案:A
解析:哈夫曼树的目标是让 WPL 最小。每次选择权值最小的两个节点合并,可以让权值小的节点离根更远(路径更长),权值大的节点离根更近(路径更短),从而最小化“权值 × 路径长度”的总和。
七、易错点汇总
- WPL 只计算叶子节点:内部节点的权值是中间产物,不计入 WPL。
- 深度与层数混淆:WPL 公式中的深度是从根到叶子的边数。根到孩子的深度为 1,不是 0。如果题目把根定义为第 1 层,深度 = 层数 − 1。
- 哈夫曼树不唯一,但 WPL 唯一:当出现相同权值时,合并顺序可能不同,树形不同,但 WPL 一定相同。
- 哈夫曼编码是变长编码,不是定长编码:高频错误选项。
- 频率高的字符离根近,不是远:这是最高频的反向陷阱。
- 完全二叉树判定中“空之后不能再有非空” :这是核心逻辑,写代码时不能漏。
- BST 中序遍历有序:这是 BST 所有查找操作的基础,选择题常考“BST 的中序遍历结果是什么”。
- 顺序存储根下标 0 和 1 的关系式不同:根下标 1 时左孩子 2i,根下标 0 时左孩子 2i+1,考试中要看清题目约定。
- 哈夫曼构造的合并次数是 n-1:n 个叶子节点需要 n-1 次合并,最终产生 2n-1 个节点。
- 哈夫曼编码的左右分支可以互换:左右互换不影响最优性,但会产生不同的具体编码,因此“编码结果唯一”是错误的。
八、速查表
| 需求 | 公式/方法 | 关键注意 |
|---|---|---|
| 完全二叉树判定 | 层序遍历 + hasNull 标记 |
空之后不能再有非空 |
| 顺序存储(根1) | 左 2i,右 2i+1,父 i/2 | 看清根下标约定 |
| 顺序存储(根0) | 左 2i+1,右 2i+2,父 (i-1)/2 | 与根1不同 |
| BST 查找 | target < root->val 走左,否则走右 |
中序遍历有序 |
| BST 插入 | 空位置创建新节点 | 递归挂接 |
| 哈夫曼构造 | 每次合并最小两个节点 | n-1 次合并 |
| WPL 计算 | ,只算叶子 | 深度 = 边数 |
| 哈夫曼编码规则 | 左 0,右 1 | 前缀码,保证唯一解码 |
| 频率与编码长度 | 频率高 → 编码短 → 离根近 | 高频反向陷阱 |
| 哈夫曼树节点数 | 2n - 1(n 个叶子) | 内部节点 n-1 个 |
| 合并节点归属 | 内部节点队列 | 不放叶子队列 |
九、总结
二叉树进阶与哈夫曼树是 GESP 六级数据结构模块的核心内容。
- 完全二叉树的判定靠层序遍历:空节点之后不能再出现非空节点。
- 完全二叉树顺序存储:根下标 1 时左孩子 2i,根下标 0 时左孩子 2i+1。
- 二叉排序树的核心性质是中序遍历有序,查找靠“左小右大”。
- 哈夫曼树是 WPL 最小的二叉树,构造时每次合并权值最小的两个节点。
- WPL 只计算叶子节点的“权值 × 深度”,内部节点不计入。
- n 个叶子节点的哈夫曼树有 2n−1 个节点,需要 n−1 次合并。
- 哈夫曼编码是变长编码,频率高的字符编码短、离根近。
- 哈夫曼编码是前缀编码,任何编码都不是其他编码的前缀。
- 哈夫曼树不唯一,但 WPL 唯一;左右互换不影响最优性。
- 多练“完全二叉树判断代码”“BST 查找填空”“哈夫曼编码辨析”“WPL 计算”四类题。
复习建议:先把完全二叉树和 BST 的核心性质记熟,重点理解“中序遍历有序”和“层序判定完全”两个核心逻辑;再动手模拟哈夫曼树的构造过程,从 {5,1,6,2} 这类小例子开始,每一步都写出合并后的森林状态和最终 WPL;然后练习哈夫曼编码的生成与辨析,重点区分“频率高离根近”和“前缀码”两个易错点;最后用 2025 年 3 月和 12 月的真题做限时训练,查漏补缺。考试中先判断题目考的是哪种特殊二叉树,再套用对应的性质或构造规则,最后检查细节(WPL 是否只算叶子、顺序存储根下标是 0 还是 1、编码是否满足前缀性质)。