【贪心延伸】【特殊二叉树】哈夫曼树和哈夫曼编码
0.前言
本文内容相对初学者来说较难,有一些概念还没学。可以等读者学完二叉树
和二叉堆
之后再返回来学习本章内容。
1.引入
1.1.问题提出
本文章内图基本均出自《深基》配套课件
1.2.思路解析
分析问题:本题可以使用贪心算法
求解。既然是贪心,那么就需要我们大胆假设。
贪心策略:将卷子数量
从大到小
排序,优先将数量较多的卷子分出来。
这样的贪心策略不无道理。优先把数量多
的分出来,以免在后面产生更多的贡献。但是嘛…………
那么这个贪心策略就被我们否定了。
这就是哈夫曼树(
H
u
f
f
m
a
n
t
r
e
e
Huffman\space tree
Huffman tree)的雏形。深入讲解会在[NOIP 2004 提高组] 合并果子的题解中讲到。读者可自行尝试先把这道题做了,或(点个关注)在主页等待题解的更新。
2.哈夫曼编码和哈夫曼树
2.1.问题提出
2.2.思路解析
先考虑贪心。
想要总编码长度最短,就要让出现频率高的字母的编码长度尽量短
,也就是将资源优先让给贫困户
,而剩下的较长的编码就可以给出现频率低的字母。
当然,还需要保证每一个编码都不是另一个编码的前缀
。
例如:
A=0
,B=01
,C=001
,那么001
这个编码就有歧义。它可以被解析成AB
或C
。
那么我们要怎样做呢?
具体要怎样构造哈夫曼树呢。使用上面的例子,将每个字母的出现频率从小到大排个序。
字母 | A | B | C | D | E |
---|---|---|---|---|---|
频率 | 5 | 10 | 13 | 14 | 20 |
先将出现频率最低的两个字母合并
。
然后将这两个字母看作一个字母
,并重新排序。
字母 | C | D | AB | E |
---|---|---|---|---|
频率 | 13 | 14 | 10 + 5 = 15 10+5=15 10+5=15 | 20 |
重复刚刚的步骤,将C、D
合并。
字母 | AB | E | CD |
---|---|---|---|
频率 | 15 | 20 | 13 + 14 = 27 13+14=27 13+14=27 |
接下来就有一点不一样了,将AB
和E
合并。
字母 | CD | ABE |
---|---|---|
频率 | 27 | 15 + 20 = 35 15+20=35 15+20=35 |
最后将ABCDE
全部合并,那么我们的哈夫曼树就构造好了。
那么我们如何知道一个字母对应的哈夫曼编码呢?
拿字母
A
举例。首先进入哈夫曼树根节点,A
属于ABE
,进入右子树,记录1
。
然后A
不是E
,因此进入左子树,并记录0
。
最后A
不是B
,因此进入左子树,并记录0
。
将上面记录的串联起来,就得到了A
的编码100
。
以此类推,B
、C
、D
、E
的哈夫曼编码分别是 101 101 101、 00 00 00、 01 01 01、 11 11 11
可以发现,我们真的使用这个神奇的方法做到了编码。那么如何证明哈夫曼编码的正确性呢?其实已经有人证明过了,但是过程较为冗长难懂,这里就不赘述了。
2.3.参考代码
注意:本代码仅支持大写字母,读者可自行改进。
#include<bits/stdc++.h>
using namespace std;
#define MAXM 26 * 2 + 5 // 哈夫曼树最大节点数
string s; // 存储需要加密的字符串
struct HuffmanNode{ // 存储哈夫曼树每一个节点的信息
int weight, id; // 存储这个结点的权重和编号
int fa, lc, rc; // 分别储存它的父结点,左儿子和右儿子的编号
string is_true; // 储存这个结点子树下的所有叶子结点
HuffmanNode(){ // 初始化构造函数
weight = id = 0;
fa = lc = rc = 0; // 没有就是0
is_true = "";
}
// 一次性将一个结点信息全部初始化
HuffmanNode(int w, int i, int f = 0, int l = 0, int r = 0,
string i_t = ""):
weight(w), id(i), fa(f), lc(l), rc(r), is_true(i_t){}
// 由于STL的问题,我们将<重载成>的功能
bool operator<(const HuffmanNode &res) const{
if (weight == res.weight) return id > res.id;
return weight > res.weight;
}
} ht[MAXM]; // 哈夫曼树
int w[30]; // w[i]储存字母'A'+i在s中出现的频数
int build_huffman_tree(string s){ // 构建哈夫曼树,并返回其节点数
if (s.size() <= 1) return 0; // 只有一个结点或更少
priority_queue<HuffmanNode> hq; // 用来将当前结点从小到大排序
int n = 0, m = 0; // n储存字母种类,m储存哈夫曼树的节点数
// 统计每一个字母出现的频数
for (int i = 0; i < s.size(); i++) if(s[i] != ' ') w[s[i] - 'A']++;
for (int i = 0; i < 26; i++) // 初始化,将没有出现的字母筛除
if (w[i]){
++n;
ht[n] = HuffmanNode(w[i], n);
ht[n].is_true += 'A' + i;
hq.push(ht[n]);
}
m = 2 * n - 1; // 根据二叉树的性质,最后的哈夫曼树必然有2n-1个结点
HuffmanNode q1, q2;
for (int i = n + 1; i <= m; i++){ // 不断合并结点
q1 = hq.top(); // 去除前两个出现频数最小的结点
hq.pop();
q2 = hq.top();
hq.pop();
/*由于前面把<重载成了>的功能,这里就相当于正常的if(q1<q2),
其中q1储存较重的结点*/
if (!(q1 < q2)) swap(q1, q2);
ht[i] = HuffmanNode(q1.weight + q2.weight, i, 0, q1.id, q2.id,
q2.is_true + q1.is_true); // 具体如上
ht[q1.id].fa = i; ht[q2.id].fa = i; // 两个子结点的信息也要更新
hq.push(ht[i]); // 重新放入优先队列
}
return m; // 返回哈夫曼树结点数
}
void print_huffman_code(string s){
int m = build_huffman_tree(s); // 构建哈夫曼树
string ans[30]; // ans[i]储存字母'A'+i映射到的01串
assert(m > 1); // 类似于if(m <= 1) return,可以相互替换
for (int i = 0; i < 26; i++) // 枚举每一个字母
if (w[i]){ //如果这个字母出现了
HuffmanNode now = ht[m];
while (now.lc) // 重复执行直到叶子结点
if ((int) ht[now.lc].is_true.find('A' + i) != -1){ // 在左子树
ans[i] += '0';
now = ht[now.lc];
}
else{ // 在右子树
ans[i] += '1';
now = ht[now.rc];
}
}
for (int i = 0; i < s.size(); i++) // 输出
if (s[i] != ' ') cout << ans[s[i] - 'A'];
else putchar(' ');
}
int main(){
getline(cin, s); // s可以接受空格
print_huffman_code(s);
return 0;
}
3.对哈夫曼树的理性认识和如何构造哈夫曼树
3.1.哈夫曼数的定义和构造以及相关概念
给定
n
n
n个权值作为
n
n
n个叶子结点
构造一棵二叉树
,使该树的带权路径长度
达到最小,则这样的树被称为哈夫曼树
(
H
u
f
f
m
a
n
t
r
e
e
Huffman\space tree
Huffman tree),也称为最优二叉树。
从根节点到某叶子结点经过的边的数量称为该叶子结点的路径长度
,每个
叶子结点的路径长度与叶子结点权值之积的和称为树的带权路径长度(
W
e
i
g
h
t
e
d
P
a
t
h
L
e
n
g
t
h
o
f
t
r
e
e
,
W
P
L
Weighted\space Path\space Length\space of\space tree,WPL
Weighted Path Length of tree,WPL),计算公式如下:
W P L = ∑ i = 1 n ( W i × L i ) 其中, n 为叶子结点个数, W i 为第 i 个叶子结点的权值, L i 为第 i 个叶子结点的路径长度。哈夫曼树就是 W P L 最小的树。 WPL=\sum_{i=1}^{n}(W_i\times L_i) \\其中,n为叶子结点个数,W_i为第i个叶子结点的权值, \\L_i为第i个叶子结点的路径长度。哈夫曼树就是WPL最小的树。 WPL=i=1∑n(Wi×Li)其中,n为叶子结点个数,Wi为第i个叶子结点的权值,Li为第i个叶子结点的路径长度。哈夫曼树就是WPL最小的树。
哈夫曼树构造的基本思想(哈夫曼算法
):权值越大的叶节点越靠近根节点,权值越小的叶节点越远离根节点。构造的具体过程如下:
- 根据给定的 n n n个权值 { w 1 , w 2 , . . . , w n } \{w_1,w_2,...,w_n\} {w1,w2,...,wn},构造一个森林 F = { T 1 , T 2 , . . . , T n } F=\{T_1,T_2,...,T_n\} F={T1,T2,...,Tn}。该森林中的每一棵二叉树 T i T_i Ti只有权值为 w i w_i wi的根节点,其左、右子树均为空。
- 在森林 F F F中选取两棵根节点权值最小的数作为左、右子树构造一颗新的二叉树,并且这棵新二叉树根节点的权值为其左、右子树根节点权值之和。
- 在森林 F F F中删除这两棵二叉树,同时将新得到的二叉树加入森林 F F F中。
- 重复步骤 2 、 3 2、3 2、3,直至森林 F F F只包含一棵树为止。最后剩下的这棵树便是所要建立的哈夫曼树。
3.2.哈夫曼编码
如设定哈夫曼左子路径编码为
0
0
0,右子路径编码为
1
1
1,从根节点递归访问每个叶子结点,记录每个根节点到叶子结点路径上的编码,该编码即为哈夫曼编码
。
这就是上述构造哈夫曼树和制造哈夫曼编码的系统叙述。请读者认真研读。
3.3.构造哈夫曼树代码示例
代码仅展示关键部分。
#define MAXN 10010
#define MAXM 20010
struct HuffmanNode{ // 存储哈夫曼树每一个结点的信息
int weight, id; // 存储这个结点的权重和编号
int fa, lc, rc; // 分别储存它的父结点,左儿子和右儿子的编号
HuffmanNode(){ //初始化构造函数
weight = id = 0;
fa = lc = rc = 0; // 没有就是0
}
// 一次性将一个结点信息全部初始化
HuffmanNode(int w, int i, int f = 0, int l = 0, int r = 0):
weight(w), id(i), fa(f), lc(l), rc(r){}
// 由于STL的问题,我们将<重载成>的功能,weight小优先
bool operator<(const HuffmanNode &res) const{
if (weight == res.weight) return id > res.id;
return weight > res.weight;
}
} ht[MAXM]; // 哈夫曼树
int w[MAXN]; // w[i]储存n个节点的权重
bool build_huffman_tree(int n){ // 构建哈夫曼树,并返回其节点数
if (n <= 1) return 0; // 只有一个节点或更少
int m = 2 * n - 1;
// m储存哈夫曼树的节点数,根据二叉树的性质,最后的哈夫曼树必然有2n-1个节点
priority_queue<HuffmanNode> hq; // 用来将当前节点从小到大排序
for (int i = 1; i <= n; i++){ // 执行哈夫曼算法第(1)步
ht[i] = HuffmanNode(w[i], i);
hq.push(ht[m]);
}
HuffmanNode q1, q2;
for (int i = n + 1; i <= m; i++){ // 不断合并节点,执行(2)(3)步
q1 = hq.top(); // 去除前两个出现频数最小的节点
hq.pop();
q2 = hq.top();
hq.pop();
ht[i] = HuffmanNode(q1.weight + q2.weight, i, 0, q1.id, q2.id); // 具体如上
ht[q1.id].fa = i; ht[q2.id].fa = i; // 两个子节点的信息也要更新
hq.push(ht[i]); // 重新放入优先队列
}
return 1; // 操作成功
}
4.小结
本文通过从举例感知到系统认知,为读者详细解说了哈夫曼树。
哈夫曼树在 C S P − J CSP-J CSP−J组初赛中考得比较频繁,在 C S P − S CSP-S CSP−S组中主要考代码实现。
由于代码需要结合二叉堆(优先队列),并且对读者二叉树熟练度要求较高,读者现在看不懂没关系,可以等以后学习完二叉堆或二叉树较为熟练之后再来巩固。
最后,制作不易,希望大家多多点赞收藏,关注下微信公众号,谢谢大家的关注,您的支持就是我更新的最大动力!
公众号上会及时提供信息学奥赛的相关资讯、各地科技特长生升学动态、还会提供相关比赛的备赛资料、信息学学习攻略等。