Skip to content

第3章 哈夫曼树与特殊树 ​


🎯 本章学习目标 ​

通过本章的学习,你将能够:

  1. 掌握哈夫曼树:理解哈夫曼树的构造过程,会计算带权路径长度 WPL
  2. 理解哈夫曼编码:掌握前缀编码特性,能根据哈夫曼树生成编码
  3. 掌握完全二叉树:理解完全二叉树的性质,会用数组存储
  4. 掌握二叉排序树:理解BST的定义,掌握插入和查找操作
  5. 了解格雷编码:知道格雷码的特点和生成方法
  6. 计算WPL:能用多种方法计算哈夫曼树的带权路径长度
  7. 分析BST中序遍历:知道BST中序遍历结果是升序的
  8. 处理删除操作:理解BST删除节点的三种情况
  9. 区分各类树:清楚哈夫曼树、完全二叉树、BST各自的特点
  10. 应对真题题型:能解决选择、判断、计算等各类特殊树的题目

🗺️ 本章知识图谱 ​

哈夫曼树与特殊树
├── 哈夫曼树
│   ├── 定义:带权路径长度最小的二叉树
│   ├── 构造方法:每次选最小的两个合并
│   ├── WPL(带权路径长度)
│   │   ├── = Σ(权值 × 深度)
│   │   └── = 所有非叶子节点权值之和
│   ├── 特点:没有度为1的节点
│   └── 哈夫曼编码
│       ├── 前缀编码(无歧义)
│       ├── 左0右1
│       └── 频率高的字符编码短
├── 完全二叉树
│   ├── 定义:前k-1层满,最后一层左连续
│   ├── 性质
│   │   ├── 深度 = floor(log2 n) + 1
│   │   ├── 节点i的左孩子 = 2i
│   │   ├── 节点i的右孩子 = 2i+1
│   │   └── 节点i的父节点 = i/2
│   ├── 数组存储(从下标1开始)
│   └── 满二叉树是特殊的完全二叉树
├── 二叉排序树(BST)
│   ├── 定义:左小右大
│   ├── 中序遍历 = 升序序列
│   ├── 查找:从根开始比较
│   ├── 插入:找到空位插入
│   └── 删除
│       ├── 叶子节点:直接删
│       ├── 一个孩子:孩子接替
│       └── 两个孩子:右子树最小值(或左子树最大值)接替
└── 格雷编码
    ├── 特点:相邻两个数只有一位不同
    ├── 生成公式:G(n) = n XOR (n>>1)
    └── 应用:出错率低、 Karnaugh图

本书第3章 · 哈夫曼树与特殊树

主题:哈夫曼树、完全二叉树、二叉排序树、哈夫曼编码、格雷编码

考纲要求:(2) 掌握哈夫曼树、完全二叉树、二叉排序树的相关概念和应用;(3) 理解哈夫曼编码、格雷编码相关原理并能进行简单应用。


一、哈夫曼树(Huffman Tree) ​

1.1 什么是哈夫曼树? ​

哈夫曼树又叫最优二叉树,是一类带权路径长度(WPL)最短的二叉树。

核心概念:

术语含义
路径长度从根到该节点经过的边数
带权路径长度节点的权值 × 该节点的路径长度
树的 WPL所有叶子节点的带权路径长度之和

哈夫曼树的核心目标:让权值大的叶子节点尽量靠近根,从而使 WPL 最小。

1.2 构造方法(贪心策略) ​

算法步骤:

  1. 将 n 个权值作为 n 棵只有根节点的树,放入优先队列(小根堆)
  2. 每次从堆中取出权值最小的两棵树,合并为一棵新树
  3. 新树的根节点权值 = 两棵子树根节点权值之和
  4. 将新树放回堆中
  5. 重复 2~4,直到堆中只剩一棵树

⚠️ 高频易错:哈夫曼树中没有度为 1 的节点(即每个内部节点都有恰好 2 个孩子)。n 个叶子节点的哈夫曼树共有 2n-1 个节点。

1.3 WPL 计算 ​

方法一:逐个计算每个叶子节点的 权值 × 路径长度,再求和。

方法二(更快):所有内部节点的权值之和 = WPL。

这是因为每个内部节点的权值都会在其子孙叶子节点的路径中被"计入",等价于累加。

1.4 C++ 代码模板 ​

cpp
#include <iostream>
#include <queue>
#include <vector>
using namespace std;

// 使用小根堆构造哈夫曼树,返回 WPL
int huffmanWPL(vector<int>& weights) {
    priority_queue<int, vector<int>, greater<int>> pq; // 小根堆
    for (int w : weights) pq.push(w);

    int wpl = 0;
    while (pq.size() > 1) {
        int a = pq.top(); pq.pop();  // 最小
        int b = pq.top(); pq.pop();  // 次小
        int sum = a + b;
        wpl += sum;                  // 累加内部节点权值
        pq.push(sum);
    }
    return wpl;
}

int main() {
    vector<int> weights = {2, 3, 6, 7, 1, 4, 8};
    cout << "WPL = " << huffmanWPL(weights) << endl;
    return 0;
}

1.5 考试常见考法 ​

  • ✅ 给定权值集合,要求画出哈夫曼树并计算 WPL
  • ✅ 选择题:哈夫曼树中不存在度为 1 的节点
  • ✅ 选择题:n 个叶子的哈夫曼树有 2n-1 个节点
  • ✅ 给出一组权值,判断哪些节点的编码最短(权值越大编码越短)

二、完全二叉树(Complete Binary Tree) ​

2.1 定义 ​

一棵深度为 h 的二叉树,除第 h 层外,其它各层节点数都达到最大,且第 h 层的节点从左到右依次排列,这样的二叉树称为完全二叉树。

关键词:从左到右连续,不能有空位。

2.2 核心性质(节点编号与父子关系) ​

对节点从 1 开始编号(按层序),则对编号为 i 的节点:

关系公式
父节点i / 2(i=1 时为根,无父节点)
左孩子2 * i(若 2i > n 则无左孩子)
右孩子2 * i + 1(若 2i+1 > n 则无右孩子)

⚠️ 高频易错:编号必须从 1 开始!如果从 0 开始,则父节点为 (i-1)/2,左孩子为 2i+1,右孩子为 2i+2。

2.3 数组存储 ​

完全二叉树可以用数组紧凑存储(无需指针),这也是堆(Heap) 的底层结构。

cpp
// 数组存储的完全二叉树(小根堆示例)
int heap[100005];
int heapSize = 0;

void pushUp(int i) {
    while (i > 1 && heap[i] < heap[i / 2]) {
        swap(heap[i], heap[i / 2]);
        i /= 2;
    }
}

void push(int val) {
    heap[++heapSize] = val;
    pushUp(heapSize);
}

int pop() {
    int res = heap[1];
    heap[1] = heap[heapSize--];
    // 下沉调整
    int i = 1;
    while (2 * i <= heapSize) {
        int child = 2 * i;
        if (child + 1 <= heapSize && heap[child + 1] < heap[child])
            child++;
        if (heap[i] <= heap[child]) break;
        swap(heap[i], heap[child]);
        i = child;
    }
    return res;
}

2.4 三种二叉树的辨析(必考辨析!) ​

名称定义关键特征
满二叉树深度为 h,共有 2^h - 1 个节点每一层都"满",每个内部节点都有 2 个孩子
完满二叉树每个节点要么是叶子,要么恰好有 2 个孩子不存在度为 1 的节点(哈夫曼树就是完满二叉树)
完全二叉树从上到下、从左到右连续填充最后一层可以不满,但必须靠左排列

关系:满二叉树(完美二叉树)一定是完全二叉树;但完满二叉树(无一度节点)不一定是完全二叉树——完满只要求每个节点 0 或 2 个孩子,不要求《连续靠左》填充。完全二叉树也不一定满或完满。简单说:满 ⊊ 完全,而完满与完全互不包含。

简单记忆:满 = 全满;完满 = 没一度节点;完全 = 连续靠左。


三、二叉排序树(BST) ​

3.1 定义 ​

二叉排序树(Binary Search Tree, BST),又称二叉查找树,满足:

  • 左子树中所有节点的值 < 根节点的值
  • 右子树中所有节点的值 > 根节点的值
  • 左右子树也分别是二叉排序树

核心性质:中序遍历 BST 得到的是递增有序序列。

3.2 查找操作 ​

从根节点开始,比当前大走右子树,比当前小走左子树,相等则找到。

cpp
struct TreeNode {
    int val;
    TreeNode *left, *right;
    TreeNode(int v) : val(v), left(nullptr), right(nullptr) {}
};

// 查找
TreeNode* search(TreeNode* root, int target) {
    if (root == nullptr) return nullptr;
    if (target == root->val) return root;
    if (target < root->val) return search(root->left, target);
    return search(root->right, target);
}

3.3 插入操作 ​

类似查找,找到空位就插入。

cpp
TreeNode* insert(TreeNode* root, int val) {
    if (root == nullptr) return new TreeNode(val);
    if (val < root->val) root->left = insert(root->left, val);
    else if (val > root->val) root->right = insert(root->right, val);
    return root;
}

3.4 删除操作(三种情况,考试重点!) ​

情况处理方式
叶子节点直接删除
只有一个孩子用孩子替代该节点
有两个孩子找到右子树中的最小节点(中序后继),用其值替换当前节点,然后删除那个最小节点
cpp
// 找右子树最小节点
TreeNode* findMin(TreeNode* root) {
    while (root->left) root = root->left;
    return root;
}

// 删除节点
TreeNode* deleteNode(TreeNode* root, int key) {
    if (root == nullptr) return nullptr;

    if (key < root->val) {
        root->left = deleteNode(root->left, key);
    } else if (key > root->val) {
        root->right = deleteNode(root->right, key);
    } else {
        // 情况1:叶子节点
        if (!root->left && !root->right) {
            delete root;
            return nullptr;
        }
        // 情况2:只有一个孩子
        if (!root->left) {
            TreeNode* tmp = root->right;
            delete root;
            return tmp;
        }
        if (!root->right) {
            TreeNode* tmp = root->left;
            delete root;
            return tmp;
        }
        // 情况3:有两个孩子 —— 用右子树最小值替代
        TreeNode* successor = findMin(root->right);
        root->val = successor->val;
        root->right = deleteNode(root->right, successor->val);
    }
    return root;
}

3.5 考试常见考法 ​

  • ✅ 给定插入序列,画出 BST 并求中序遍历结果
  • ✅ 选择题:BST 最坏情况退化为链表(有序插入时),查找时间复杂度 O(n)
  • ✅ 给定 BST,删除某个节点后画出新树的结构
  • ✅ 判断题:相同元素的不同插入顺序,得到的 BST 可能不同(✅正确)

四、哈夫曼编码(Huffman Coding) ​

4.1 前缀编码原理 ​

前缀编码:任何字符的编码都不是另一个字符编码的前缀。这样可以保证解码时无歧义。

例如:A=0, B=10, C=110, D=111 —— 任意一个编码都不是另一个的前缀,合法 ✅

4.2 如何构造最优前缀编码 ​

将哈夫曼树的左分支标记为 0,右分支标记为 1,从根到每个叶子节点的路径就是该字符的哈夫曼编码。

  • 权值越大的字符,离根越近,编码越短
  • 权值越小的字符,离根越远,编码越长

这就是哈夫曼编码的核心思想:出现频率高的字符用短编码,频率低的用长编码,从而整体编码最短。

4.3 计算压缩效率 ​

压缩率 = 哈夫曼编码总长度 / 等长编码总长度 × 100%

节省比例 = (1 - 压缩率) × 100%

示例:有字符 A(出现 50 次)、B(25 次)、C(15 次)、D(10 次)

等长编码(4个字符,每个 2 位):总长度 = (50+25+15+10) × 2 = 200

假设哈夫曼编码为 A=0(1位)、B=10(2位)、C=110(3位)、D=111(3位): 哈夫曼总长度 = 50×1 + 25×2 + 15×3 + 10×3 = 50 + 50 + 45 + 30 = 175

压缩率 = 175 / 200 = 87.5%,节省 12.5%

4.4 考试常见考法 ​

  • ✅ 给定字符频率,构造哈夫曼编码并计算 WPL
  • ✅ 选择题:判断某组编码是否为合法的前缀编码
  • ✅ 选择题:哈夫曼编码的平均长度一定 ≤ 等长编码的平均长度
  • ✅ 计算题:给定报文长度和编码方案,求压缩效率

五、格雷编码(Gray Code) ​

5.1 定义 ​

格雷码是一种循环码,其核心性质:相邻两个编码之间只有一位不同。

n 位格雷码共有 2^n 个编码。

5.2 生成规则(反射法 / 递归法) ​

递归构造:

位数格雷码序列
G(1)0, 1
G(2)00, 01, 11, 10
G(3)000, 001, 011, 010, 110, 111, 101, 100

构造方法(镜像反射法):

  1. G(1) =
  2. G(n) = G(n-1) 的每个编码前补 0,然后接上 G(n-1) 逆序的每个编码前补 1

5.3 公式法(最高效) ​

第 n 个格雷码 = n XOR (n >> 1)

cpp
#include <iostream>
#include <vector>
using namespace std;

// 生成 n 位格雷码
vector<int> grayCode(int n) {
    int total = 1 << n;  // 2^n 个编码
    vector<int> result;
    for (int i = 0; i < total; i++) {
        result.push_back(i ^ (i >> 1));  // 核心公式
    }
    return result;
}

int main() {
    int n = 3;
    vector<int> codes = grayCode(n);
    for (int code : codes) {
        // 输出 n 位二进制
        for (int i = n - 1; i >= 0; i--) {
            cout << ((code >> i) & 1);
        }
        cout << endl;
    }
    return 0;
}
// 输出:000 001 011 010 110 111 101 100

5.4 考试常见考法 ​

  • ✅ 选择题:格雷码的核心特征——相邻编码仅一位不同
  • ✅ 给定 n,要求写出 n 位格雷码序列
  • ✅ 给定自然数 k,求其对应的格雷码(用公式 k ^ (k >> 1))
  • ✅ 格雷码的循环性:首尾两个编码也只有一位不同

六、高频易错点总结 ​

知识点易错点
哈夫曼树没有度为 1 的节点;n 个叶子 → 2n-1 个节点;同一组权值,哈夫曼树不唯一(左右子树可交换),但 WPL 唯一
完全二叉树编号从 1 开始;最后一层节点靠左连续;叶子节点只可能在最后两层
二叉排序树最坏退化为链表(有序序列插入);中序遍历 = 有序序列;删除有两孩子的节点时,用中序后继(或前驱)替代
哈夫曼编码前缀编码的合法性判断;WPL 的计算方法;压缩效率公式
格雷编码相邻仅一位不同 ≠ 任意两位都仅一位不同(只有相邻的才保证);首尾也满足(循环码)

七、📝 实战练习 ​

练习题 1(编程题) ​

题目: 给定叶子节点权值集合 {3, 7, 2, 5, 9},构造哈夫曼树,求 WPL。

🔑 点击查看答案与解析

解题过程:

  1. 初始:2, 3, 5, 7, 9,取 2 和 3,合并为 5,剩余 5, 5, 7, 9
  2. 取 5 和 5,合并为 10,剩余 7, 9, 10
  3. 取 7 和 9,合并为 16,剩余 10, 16
  4. 取 10 和 16,合并为 26,剩余 26

WPL = 5 + 10 + 16 + 26 = 57

也可以逐个叶子计算:9 的路径=2,7 的路径=2,5 的路径=2,3 的路径=3,2 的路径=3

WPL = 9x2 + 7x2 + 5x2 + 3x3 + 2x3 = 18 + 14 + 10 + 9 + 6 = 57

注意:实际构造过程中由于每次选最小的两个,具体树形可能不同,需要仔细逐步构造。

答案:WPL = 57

练习题 2(单选题) ​

题目: 依次插入序列 {30, 15, 45, 10, 20, 38, 50} 构造 BST,然后删除节点 30,画出删除后的 BST。

🔑 点击查看答案与解析

构造过程:

插入后 BST:

        30
       /  \
     15    45
    / \   / \
   10 20 38 50

删除 30(有两个孩子):

  • 找右子树最小节点 = 38
  • 用 38 替代 30 的位置,再删除原来的 38

删除后的 BST:

        38
       /  \
     15    45
    / \     \
   10 20    50

答案:删除后根节点为 38,15 的左右孩子为 10 和 20,45 的右孩子为 50。

练习题 3(编程题) ​

题目: 求 3 位格雷码中,自然数 5 对应的格雷码是多少?

🔑 点击查看答案与解析

解题过程:

使用公式:格雷码 = n XOR (n >> 1)

5 的二进制 = 101

5 >> 1 = 010(即 2)

5 XOR 2 = 101 XOR 010 = 111(即 7)

验证:3 位格雷码序列为 000, 001, 011, 010, 110, 111, 101, 100 第 5 个(从 0 开始编号)确实为 111

答案:111(即十进制 7)

---

练习题 4(判断题) ​

题目: 哈夫曼树中没有度为 1 的节点。( )

🔑 点击查看答案与解析

答案: 正确 ✅

解析: 哈夫曼树的构造过程是每次选两个最小的节点合并,所以所有非叶子节点的度都是 2(有两个孩子),没有度为 1 的节点。

这也是哈夫曼树的一个重要性质:n 个叶子节点的哈夫曼树,总节点数 = 2n - 1。

验证:n 个叶子,每个新合并产生 1 个非叶子节点,共需合并 n-1 次,所以非叶子节点数 = n-1,总结点数 = n + (n-1) = 2n-1。

💡 记忆:哈夫曼树 = 严格二叉树(每个非叶子都有两个孩子)。

---

练习题 5(单选题) ​

题目: 一棵完全二叉树有 100 个节点,编号从 1 开始。编号为 50 的节点的左孩子编号是?( )

A. 25
B. 99
C. 100
D. 不存在

🔑 点击查看答案与解析

答案: C

解析: 完全二叉树数组存储的性质(编号从 1 开始):

  • 节点 i 的左孩子 = 2i
  • 节点 i 的右孩子 = 2i + 1
  • 节点 i 的父节点 = i / 2(向下取整)

节点 50 的左孩子 = 2 × 50 = 100

因为总节点数是 100,所以节点 100 存在。

选 C。

🔥 高频考点:完全二叉树的父子节点编号关系是选择题常客。注意:如果 2i > n,说明没有左孩子。

---

练习题 6(单选题) ​

题目: 二叉排序树的中序遍历结果是?( )

A. 升序序列
B. 降序序列
C. 随机序列
D. 前序序列

🔑 点击查看答案与解析

答案: A

解析: 二叉排序树(BST)的定义:左子树所有节点 < 根 < 右子树所有节点。

中序遍历顺序:左 → 根 → 右

所以中序遍历 BST 的结果是一个升序(从小到大)序列。

选 A。

💡 这是 BST 最重要的性质之一!判断一棵树是不是 BST,看中序遍历是不是升序就可以了。

---

练习题 7(单选题) ​

题目: 关于哈夫曼编码,下列说法错误的是?( )

A. 哈夫曼编码是前缀编码
B. 频率越高的字符,编码越短
C. 哈夫曼编码是唯一的
D. 哈夫曼树的 WPL 最小

🔑 点击查看答案与解析

答案: C

解析:

  • A 正确 ✅:哈夫曼编码是前缀编码——任何一个编码都不是另一个编码的前缀,解码时不会有歧义
  • B 正确 ✅:这正是哈夫曼编码的优势——用短编码表示高频字符,整体编码长度最短
  • C 错误 ❌:哈夫曼树(和编码)不一定唯一!当有多个权值相同的节点时,选择哪两个合并、左右子树怎么分配,都可能导致不同的哈夫曼树,但 WPL 都是最小的
  • D 正确 ✅:哈夫曼树就是带权路径长度最小的二叉树

选 C。

⚠️ 易错点:哈夫曼树不唯一,但 WPL 一定是唯一且最小的。

---

练习题 8(编程题) ​

题目: 在二叉排序树中删除一个节点有三种情况,请简述每种情况的处理方法。

  1. 要删除的节点是叶子节点
  2. 要删除的节点只有一个孩子
  3. 要删除的节点有两个孩子
🔑 点击查看答案与解析

答案:

  1. 叶子节点:直接删除,把父节点对应的指针设为空即可。

  2. 只有一个孩子:用孩子节点"接替"被删节点的位置。也就是让父节点直接指向这个孩子。

  3. 有两个孩子:找右子树中的最小值节点(或左子树中的最大值节点),用它的值替换被删节点的值,然后删除那个最小值节点(因为最小值节点一定没有左孩子,属于情况 1 或 2)。

解析: 删除有两个孩子的节点是最复杂的情况。核心思路是:找一个"替死鬼"——右子树最小值(或左子树最大值),它的值可以替换被删节点,同时保证 BST 性质不变。而那个替死鬼节点最多只有一个孩子,可以用前两种简单情况处理。

💡 记忆口诀:叶子直接删,一孩则孩子上,两孩找替代(右最左或左最右)。

---

八、📊 考点总结表 ​

考点重要程度出题形式易错点
哈夫曼树构造过程⭐⭐⭐⭐⭐选择/计算每次选最小的两个合并
WPL 计算⭐⭐⭐⭐⭐选择/计算两种方法:权×深 或 非叶节点和
哈夫曼树特点(无度1节点)⭐⭐⭐⭐选择/判断总节点数 = 2n-1
哈夫曼编码(前缀编码)⭐⭐⭐⭐选择/判断左0右1;高频编码短
完全二叉树定义⭐⭐⭐⭐选择/判断和满二叉树的关系
完全二叉树父子编号关系⭐⭐⭐⭐⭐选择/计算左2i、右2i+1、父i/2
完全二叉树深度⭐⭐⭐⭐选择/计算floor(log2 n) + 1
BST 定义(左小右大)⭐⭐⭐⭐⭐选择/判断注意是"所有"左子树节点都小
BST 中序遍历 = 升序⭐⭐⭐⭐⭐选择/程序阅读高频考点,必须记住
BST 删除操作⭐⭐⭐⭐选择/代码填空两孩情况找替代
格雷码生成公式⭐⭐⭐选择/计算G(n) = n XOR (n>>1)

⭐ 必背速记卡 ​

┌─────────────────────────────────────────────┐
│      哈夫曼树与特殊树 · 必背速记卡          │
├─────────────────────────────────────────────┤
│  1. 哈夫曼树:每次合最小,WPL最小           │
│  2. WPL = 各叶子权×深度之和                 │
│  3. WPL = 所有非叶子节点权值之和            │
│  4. 哈夫曼树没有度为1的节点                 │
│  5. n个叶子的哈夫曼树,总结点数 = 2n-1      │
│  6. 哈夫曼编码是前缀编码,左0右1            │
│  7. 哈夫曼树不唯一,但WPL唯一最小           │
│  8. 完全二叉树:前k-1层满,最后一层左连续    │
│  9. 完全二叉树左孩子=2i,右孩子=2i+1       │
│ 10. 完全二叉树父节点=i/2(向下取整)        │
│ 11. BST二叉排序树:左小右大                 │
│ 12. BST中序遍历 = 升序序列                  │
│ 13. BST删除:叶直接删,一孩则孩子接          │
│ 14. BST两孩删除:右最左(或左最右)来替代   │
│ 15. 格雷码:G(n) = n XOR (n右移1位)        │
└─────────────────────────────────────────────┘

百炼成钢,融会贯通