1. 什么是哈夫曼树,基础概念扫盲
假设我们有若干叶子结点,每个结点有一个权值 (可以理解成权重、出现次数),我们可以搭建很多形态不一样的二叉树。
其中带权路径长度WPL最小的那一棵二叉树,就是哈夫曼树。
先把4个核心名词搞懂:
| 名词 | 解释 |
|---|---|
| 路径长度 | 从根结点走到目标叶子,经过的线段数量。根到自己路径长度为0。 |
| 结点的权 | 结点携带的数值,可以代表数字大小、字符出现频率。 |
| 结点带权路径长度 | 路径长度 × 结点权值。 |
| WPL树的带权路径长度 | 所有叶子结点的带权路径长度相加。公式:WPL=W1L1+W2L2+...+WnLnWPL=W_1L_1+W_2L_2+...+W_nL_nWPL=W1L1+W2L2+...+WnLn |
举个简单例子:叶子权值15,路径长度2,该结点带权路径长度 = 15∗215*215∗2。整棵树WPL把所有叶子这个数值加起来。
✅ 哈夫曼树定义 :以n个权值作为叶子结点,构造出来,WPL最小的二叉树就是哈夫曼树。
✨哈夫曼树五大特点:
- 原始输入结点全部是叶子结点。
- 权值越大的叶子,距离根结点越近;权值越小,离根越远(贪心思想,减少总代价)。
- 所有非叶子结点,一定有左右两个子结点,没有单分支结点。
- 如果叶子数量为n,整棵树总结点数量 = 2n−12n-12n−1。
- 哈夫曼树形态不唯一:合并时交换左右子树可以长出不一样树形,但是WPL一定完全相同。
2. 哈夫曼树如何构造?核心步骤
核心贪心策略:每次挑出现有集合里面权值最小的两棵树合并。
完整步骤:
- 将n个权值,生成n棵独立的单结点树,放到候选集合。
- 在候选集合选出权值最小的两棵树。
- 创建新父结点,权值等于两棵子树根权值之和;把两棵树分别作为新结点的左右孩子。
- 将这个新生成的树放回候选集合。
- 重复2‑4,直到候选集合只剩下唯一一棵树,这棵树就是哈夫曼树。
示例:输入权值集合:{5,3,8,2,9}
- 最小两个:2、3,合并为5,集合变成
{5, 5, 8,9},本次代价 5 - 最小两个:5、5,合并为10,集合变成
{8,9,10},本次代价10 - 最小两个:8、9,合并为17,集合变成
{10,17},本次代价17 - 最小两个:10、17,合并为27,集合
{27},本次代价27
总代价:5+10+17+27=595+10+17+27 = 595+10+17+27=59,这个总代价等价于哈夫曼树WPL。
注意:总代价就是WPL,这也是经典哈夫曼树编程题的考点。
3. 双队列法实现哈夫曼树(C++思路)
如果每次都遍历全部元素找最小值,效率比较差。这里使用两个队列q1、q2完成构造。
q1:原始叶子结点,先升序排序,全部入队q1。q1内部保持从小到大有序。q2:存放合并之后新生成的内部结点。因为每次合并出的新结点权值只会越来越大,q2天然保持升序有序。
每次取最小结点的时候,只对比q1、q2两个队列队首元素,选出更小的那个出队。不用遍历全部数组,提升效率。
函数getMinFromQueues()规则(重点,极易写错):
- q2为空,q1不为空:取q1队首弹出返回
- q1为空,q2不为空:取q2队首弹出返回
- q1、q2都不为空:对比两个队首权值,取权更小的,弹出返回
- 两个队列都空,返回
nullptr
拿到两个最小结点a、b,合并生成新结点;新结点压入q2。循环直到只剩下一个结点,就是哈夫曼树根。
4. WPL带权路径长度递归计算
递归思路:函数参数len代表当前结点的路径长度。
- 如果结点为空,返回0;
- 如果当前是叶子结点:返回
weight * len; - 否则递归算左子树、右子树,往下一层路径长度+1;返回左右子树WPL相加。
cpp
int getWPL(HuffmanNode* root, int len)
{
if(root == nullptr) return 0;
//叶子结点
if(root->left == nullptr && root->right == nullptr)
{
return root->weight * len;
}
int leftWpl = getWPL(root->left, len+1);
int rightWpl = getWPL(root->right, len+1);
return leftWpl + rightWpl;
}
5. 哈夫曼编码:数据压缩的秘密
等长编码 vs 变长编码
- 等长编码:每个字符二进制位数固定。例如 ASCII 码每个字符占 8bit。简单,但是浪费存储空间。
- 变长编码 :高频出现字符分配短编码,低频字符分配长编码,整体总比特数变少,实现压缩。哈夫曼编码就是经典变长编码。
举例子字符串:AAAAABCCCCCCDDD
字符统计:A 出现 5 次,B 出现 1 次,C 出现 6 次,D 出现 3 次。
- ASCII 等长编码总比特:(15*8=120bit)
- 哈夫曼编码结果:
C:0,A:11,B:100,D:101
总比特计算:(61 + 5 2 +13 +33 = 28bit),空间大幅压缩!
哈夫曼编码生成规则
- 将字符频率作为权值,构建哈夫曼树;字符全部放在叶子结点。
- 约定:走向左分支写 0,走向右分支写 1。
- 从根走到叶子结点,一路上收集 0、1 字符串,就是该字符哈夫曼编码。
✨哈夫曼编码两大特性:
- 前缀特性:任意一个字符编码,不会是另一个编码的前缀,解码不会歧义。
- 编码不唯一:左右分支 0、1 可以互换,得到另一套合法编码;压缩效果不变。
题目要求输出:编码长度短的放前面;编码长度相同,按字母 a‑z 字典序输出。
6. 完整可编译 C++ 全部代码
C++11 及以上均可编译,头文件齐全,修复教案全部 OCR 错误。
#include <iostream>
#include <queue>
#include <algorithm>
#include <string>
using namespace std;
#define MAX 20
//哈夫曼树结点结构体
struct HuffmanNode
{
char ch; //叶子结点保存对应字符,内部结点无意义
int weight; //权值,字符出现频率
HuffmanNode* left; //左孩子指针
HuffmanNode* right; //右孩子指针
//构造函数
HuffmanNode(char c = '\0', int val = 0)
{
ch = c;
weight = val;
left = nullptr;
right = nullptr;
}
};
//两个全局队列,双队列构造哈夫曼树
queue<HuffmanNode*> q1;
queue<HuffmanNode*> q2;
//保存哈夫曼编码的结构体
struct HuffmanCode
{
char ch;
string code;
} codeList[MAX];
int index = 0; //codeList数组当前存储位置下标
/**
* @brief 从q1 q2取出权最小结点,并且出队
* @return 最小结点指针,空返回nullptr
*/
HuffmanNode* getMinFromQueues()
{
//q2空,q1不为空
if(q2.empty() && !q1.empty())
{
HuffmanNode* temp = q1.front();
q1.pop();
return temp;
}
//q1空,q2不为空
if(q1.empty() && !q2.empty())
{
HuffmanNode* temp = q2.front();
q2.pop();
return temp;
}
//两个队列都不为空,对比队首
if(!q1.empty() && !q2.empty())
{
HuffmanNode* t1 = q1.front();
HuffmanNode* t2 = q2.front();
if(t1->weight < t2->weight)
{
q1.pop();
return t1;
}
else
{
q2.pop();
return t2;
}
}
//两个队列都为空
return nullptr;
}
/**
* @brief 构建哈夫曼树,返回根结点指针
*/
HuffmanNode* buildHuffmanTree()
{
while(true)
{
HuffmanNode* a = getMinFromQueues();
HuffmanNode* b = getMinFromQueues();
if(b == nullptr) //只剩最后一个结点
{
return a;
}
//合并两个结点生成新内部结点
HuffmanNode* newNode = new HuffmanNode('\0', a->weight + b->weight);
newNode->left = a;
newNode->right = b;
q2.push(newNode);
}
}
/**
* @brief 递归计算WPL带权路径长度
* @param len 当前结点的路径长度
*/
int getWPL(HuffmanNode* root, int len)
{
if(root == nullptr) return 0;
if(root->left == nullptr && root->right == nullptr)
{
return root->weight * len;
}
int left = getWPL(root->left, len + 1);
int right = getWPL(root->right, len + 1);
return left + right;
}
/**
* @brief DFS深度优先遍历,收集哈夫曼编码存入codeList
* @param code 当前路径累积的01字符串
*/
void genHuffmanCode(HuffmanNode* root, string code)
{
if(root == nullptr) return;
//到达叶子结点,保存编码
if(root->left == nullptr && root->right == nullptr)
{
codeList[index].ch = root->ch;
codeList[index].code = code;
index++;
return;
}
genHuffmanCode(root->left, code + '0'); //左分支0
genHuffmanCode(root->right, code + '1'); //右分支1
}
/**
* @brief sort排序比较函数
* 规则:编码长度从小到大;长度相等,按字符字典序升序
*/
bool cmp(HuffmanCode x, HuffmanCode y)
{
if(x.code.size() != y.code.size())
{
return x.code.size() < y.code.size();
}
else
{
return x.ch < y.ch;
}
}
int main()
{
//========样例1:计算哈夫曼总费用(WPL) 输入5,5 3 8 2 9 输出59========
/*
int n;
cin >> n;
int arr[105];
for(int i = 0; i < n; i++)
{
cin >> arr[i];
}
sort(arr, arr + n);
for(int i = 0; i < n; i++)
{
q1.push(new HuffmanNode('\0', arr[i]));
}
HuffmanNode* root = buildHuffmanTree();
cout << getWPL(root,0) << endl;
return 0;
*/
//========样例2:生成哈夫曼编码 输入4,B 1 D 3 A5 C6========
int n;
cin >> n;
for(int i = 0; i < n; i++)
{
char c; int w;
cin >> c >> w;
q1.push(new HuffmanNode(c, w));
}
HuffmanNode* root = buildHuffmanTree();
genHuffmanCode(root, "");
sort(codeList, codeList + index, cmp);
for(int i = 0; i < index; i++)
{
cout << codeList[i].ch << " " << codeList[i].code << endl;
}
return 0;
}
使用提示:main 函数里面两段代码二选一。注释掉一段,放开另一段,分别测试两道例题。
7. 程序运行示例演示
示例 1(求 WPL 总费用)
输入:
5
5 3 8 2 9
输出:
59
示例 2(生成哈夫曼编码)
输入:
4
B 1
D 3
A 5
C 6
输出:
C 0
A 11
B 100
D 101
8. 代码分段解析
- 结构体 HuffmanNode:存储字符、权值,左右孩子指针,构造函数初始化指针为空。
- getMinFromQueues:双队列挑选权最小结点,是整个算法核心,注意条件判断顺序,取完元素必须 pop 出队。
- buildHuffmanTree:循环取出两个最小结点合并,新结点放入 q2,直到只剩一个根。
- getWPL 递归:叶子结点计算 weight*len,递归向下路径长度 + 1,累加左右子树结果。
- genHuffmanCode 深度遍历 :左拼接
'0',右拼接'1';遇到叶子,把字符和编码存入数组。 - cmp 比较函数:sort 排序,先比较编码字符串长度,长度相同按字符字典序。
- main 函数两套逻辑:①只算 WPL;②读字符 + 频率生成哈夫曼编码输出。
9. 时间 & 空间复杂度通俗分析
- 时间复杂度 (O(n\log n))
n 代表叶子结点数量。初始排序 q1 数组(O(n\log n));每次合并取队首操作(O(1)),一共 n‑1 次合并;递归遍历树(O(n))。整体复杂度主要来自初始排序。 - 空间复杂度 (O(n))
哈夫曼树总结点(2n‑1),队列、数组存储全部结点,占用和 n 成正比内存。
适合初高中理解,不用深究复杂数学推导。
10. 高频踩坑易错点汇总
=和==混淆:判断空指针写成if(b=nullptr),少写一个等号,直接赋值,逻辑完全错乱。- 指针访问符号错误:
newNode‑left,应该是newNode->left。‑是减号,->才是指针访问成员。 - getMinFromQueues 条件写反,没有执行 pop (),结点没有出队,队列无限循环卡死。
- 递归生成编码写成
code+0',单引号位置错误,必须写code+'0'。 - 递归求 WPL 忘记
len+1,路径长度不会向下累加,WPL 计算结果错误。 - sort 的 cmp 排序函数写反逻辑,编码从长到短输出,或者同长度字符乱序。
- 双队列构造哈夫曼树,q1 没有提前排序,队列无序,取最小值出错。
- 哈夫曼编码递归的时候,没有传 string 的新副本,直接原地修改字符串,编码串错乱。
- 忘记哈夫曼树非叶子结点没有有效字符,只有叶子结点收集编码。
11. 课后练习题
- 手动计算:权值集合
{2,7,4,5},手动画出哈夫曼树,算出 WPL 等于多少。 - 修改代码:输出每个字符的哈夫曼编码的编码长度。
- 思考题:为什么哈夫曼编码可以实现数据压缩?什么场景压缩效果会非常明显?什么场景压缩提升很小?
- 拓展:哈夫曼编码前缀特性有什么好处?如果没有前缀特性,解码会发生什么问题?
12. 全文总结
- 哈夫曼树是叶子带权、WPL 最小的二叉树,核心思想是贪心算法。
- 构造规则:每次选出权最小两棵树合并,新结点权值等于子结点权之和,反复合并直到只剩一棵树。
- 双队列 q1、q2 可以高效构造哈夫曼树,q1 存原始排序叶子,q2 存合并后的内部结点。
- WPL 是所有叶子结点「权 × 路径长度」总和,可以递归遍历整棵树求得。
- 哈夫曼编码属于变长压缩编码:高频字符分配短编码,低频分配长编码。约定左分支 0、右分支 1,根到叶子路径得到编码。
- 哈夫曼编码具有前缀特性,解码不会歧义;树形不唯一,编码也不唯一,但是压缩效果不变。
- 编程重点:指针操作、递归遍历、排序比较函数、双队列取最小结点逻辑。
- 写代码时刻警惕
==、->、字符字面量'0'这些语法坑。