本系列文章为浙江大学陈越、何钦铭数据结构学习笔记,前面的系列文章链接如下:
数据结构基础:P1-基本概念
数据结构基础:P2.1-线性结构—>线性表
数据结构基础:P2.2-线性结构—>堆栈
数据结构基础:P2.3-线性结构—>队列
数据结构基础:P2.4-应用实例—>多项式加法运算
数据结构基础:P2.5-应用实例—>多项式乘法与加法运算-C实现
数据结构基础:P3.1-树(一)—>树与树的表示
数据结构基础:P3.2-树(一)—>二叉树及存储结构
数据结构基础:P3.3-树(一)—>二叉树的遍历
数据结构基础:P3.4-树(一)—>小白专场:树的同构-C语言实现
数据结构基础:P4.1-树(二)—>二叉搜索树
数据结构基础:P4.2-树(二)—>二叉平衡树
数据结构基础:P4.3-树(二)—>小白专场:是否同一棵二叉搜索树-C实现
数据结构基础:P4.4-树(二)—>线性结构之习题选讲:逆转链表
数据结构基础:P5.1-树(三)—>堆
一、什么是哈夫曼树
1.1 为什么需要哈夫曼树
编码这个问题是计算机里面最最基础的一个也是比较核心的一个问题。
----编码有很多种方式,比方说我们的整数用二进制的形式0101存在我们计算机里面。我们有一种编码叫等长码,每个ASCII 码可以用7位来进行编码。如果有一篇文章有1万个字符,那么就是要7万位进行编码。当然计算机里面,实际上一个ASCII码占一个字节,也就是说总共占8位,最高那位是0。那么一万个字符所构成的一篇文章就是总共占用的是10000个字节。
----而实际上,我们每个字符出现的频率是不一样的。比方说 e 这个字符里出现的频率要大于很多字符的频率。如果我们能够用不等长编码,比方说我们 e 既然出现的频率高,我用5位进行编码。频率出现没那么高的用6位7位8位甚至9位10位都没关系,这样的话效率就能得到提高。所以由于这样的一种背景,大家就在想:已知的频率不一样,我应该怎么进行编码能达到比较好的效果。这就是树跟哈夫曼编码所涉及到的一个重要的问题。
我们来看一个具体的一个例子:将百分制的考试成绩转换成五分制的成绩
我们写出对应的C语言代码如下:
if( score < 60 ) grade =1;
else if( score < 70 ) grade =2;
else if( score < 80 ) grade =3;
else if( score < 90 ) grade =4;
else grade =5;
上面的判别规则实际上对应一棵判定树。
在这个判定树里面,我们看到判断不及格的时候只需要做一步判断,90多分的时候我要做4次判断。如果我们的成绩绝大多数都是90分,60分的人很少,我们会发现绝大多数人都要做四步判断才能得出结论,而我们需要判断一步的人非常少甚至没有。显然这样的一种判定树是不够优化的。
我们考虑每个分数段要查询的频率是不一样的,下面这个表中给出了这个不同分数段将来可能会查询的一个频率。
那么对应的查找效率(这些比例乘它对应的比较次数)为:
0.05
×
1
+
0.15
×
2
+
0.4
×
3
+
0.3
×
4
+
0.1
×
4
=
3.15
0.05×1+0.15×2+0.4×3+0.3×4+0.1×4 = 3.15
0.05×1+0.15×2+0.4×3+0.3×4+0.1×4=3.15
同样的这样的一种分布,我们换一种判别的这个流程。
对应的C语言代码如下:
if( score < 80 )
{
if( score < 70 )
if( score < 60 ) grade =1;
else grade = 2;
else grad= 3;
}else if( score < 90 ) grade =4;
else grade =5;
此时的查找效率为: 0.05 × 3 + 0.15 × 3 + 0.4 × 2 + 0.3 × 2 + 0.1 × 2 = 2.2 0.05×3+0.15×3+0.4×2+0.3×2+0.1×2 = 2.2 0.05×3+0.15×3+0.4×2+0.3×2+0.1×2=2.2
这个就给我们一个启示:我们可以有不同的方法来构造搜索树,而不同搜索树它的效率是不一样的。所以怎么根据不同的频率来构造一个效率比较好甚至是最好的搜索树,这个就是哈夫曼树要解决的问题。
1.2 哈夫曼树的定义
带权路径长度(WPL):设二叉树有
n
\rm{n}
n 个叶子结点,每个叶子结点带有权值(频率)
w
k
{w_k}
wk,从根结点到每个叶子结点的长度为
l
k
{l_k}
lk,则每个叶子结点的带权路径长度之和就是
W
P
L
=
∑
k
=
1
n
w
k
l
k
WPL = \sum\limits_{k = 1}^n {{w_k}{l_k}}
WPL=k=1∑nwklk
最优二叉树或哈夫曼树: WPL最小的二叉树
举个简单例子:
有五个叶子结点,它们的权值为{1,2,3,4,5},用此权值序列可以构造出形状不同的多个二叉树。
他们对应的WPL分别为:
W P L 1 = 5 × 1 + 4 × 2 + 3 × 3 + 2 × 4 + 1 × 4 = 34 WP{L_1} = 5 \times 1 + 4 \times 2 + 3 \times 3 + 2 \times 4 + 1 \times 4 = 34 WPL1=5×1+4×2+3×3+2×4+1×4=34
W P L 2 = 1 × 1 + 2 × 2 + 3 × 3 + 4 × 4 + 5 × 4 = 50 WP{L_2} = 1 \times 1 + 2 \times 2 + 3 \times 3 + 4 \times 4 + 5 \times 4 = 50 WPL2=1×1+2×2+3×3+4×4+5×4=50
W P L 3 = 1 × 3 + 2 × 3 + 3 × 2 + 4 × 2 + 5 × 2 = 33 WP{L_3} = 1 \times 3 + 2 \times 3 + 3 \times 2 + 4 \times 2 + 5 \times 2 = 33 WPL3=1×3+2×3+3×2+4×2+5×2=33
二、哈夫曼树的构造
2.1 哈夫曼树的构造
哈夫曼树的构造过程可以用一句话总结:每次把权值最小的两棵二叉树合并
具体流程如下:
假设我现在有1 2 3 4 5排好序的这5个权值
我先把权值最小的1和2合并,得到了一个新的结点3,这个结点的权值就是1跟2的和。
然后再从新的结点与剩余节点挑两个最小的,即3 3 4 5里面挑两个(3 3)。再合并,就得到了新的结点6。
从6 4 5里面再挑两个最小的进行合并(4 5)。得到一个新结点9。
最后只剩两个节点6 9,直接合并。这就是最终的哈夫曼树。
在上面的构造过程中,最重要的需要解决的一个问题就是:
给你N个数,我要找两个最小的,合并完了之后形成新的值,实际上就是堆要解决的问题。如果我们能够把这个结点的权值构造成一个最小堆,从里面挑两个最小的并在一起形成一个新的结点,再把它插到堆里去,那么用堆就能很好的实现。当然刚才那个过程也可以用排序的方法从小到大排好,删除两个小的并让其合在一起,然后再把一个新的结点再插到已经排序好的结点序列里去。显然这样的一种做法没有直接用堆效率要高。
哈夫曼树构造过程对应代码如下:
typedef struct TreeNode *HuffmanTree;
struct TreeNode{
int Weight;
HuffmanTree Left, Right;
}
HuffmanTree Huffman( MinHeap H )
{ /* 假设H->Size个权值已经存在H->Elements[]->Weight里 */
int i; HuffmanTree T;
BuildMinHeap(H); /*将H->Elements[]按权值调整为最小堆*/
for (i = 1; i < H->Size; i++) { /*做H->Size-1次合并*/
T = malloc( sizeof( struct TreeNode) ); /*建立新结点*/
T->Left = DeleteMin(H);
/*从最小堆中删除一个结点,作为新T的左子结点*/
T->Right = DeleteMin(H);
/*从最小堆中删除一个结点,作为新T的右子结点*/
T->Weight = T->Left->Weight+T->Right->Weight;
/*计算新权值*/
Insert( H, T ); /*将新T插入最小堆*/
}
T = DeleteMin(H);
return T;
}
可以看出循环 n − 1 \rm{n-1} n−1 次,每次插入删除操作复杂度不超过 l o g 2 N {\rm{lo}}{{\rm{g}}_{\rm{2}}}{\rm{N}} log2N,因此总体复杂度为: O ( N l o g N ) {\rm{O(NlogN)}} O(NlogN)
2.2 哈夫曼树的特点:
①没有度为1的结点;
② n \rm{n} n 个叶子结点的哈夫曼树共有 2 n − 1 \rm{2n-1} 2n−1 个结点;
③哈夫曼树的任意非叶结点的左右子树交换后仍是哈夫曼树;
④对同一组权值 { w 1 , w 2 , . . . . . . , w n } {\rm{\{ }}{{\rm{w}}_{\rm{1}}}{\rm{,}}{{\rm{w}}_{\rm{2}}}{\rm{,}}......{\rm{,}}{{\rm{w}}_{\rm{n}}}{\rm{\} }} {w1,w2,......,wn},是有可能存在不同构的两棵哈夫曼树的。但他们的WPL相等。
例如:对一组权值{ 1, 2 , 3, 3 },不同构的两棵哈夫曼树:
三、哈夫曼编码
例:假设有一段文本,包含58个字符,并由以下7个字符构成:a,e,i,s,t,空格(sp),换行(nl);这7个字符出现的次数不同。如何对这7个字符进行编码,使得总编码空间最少?
分析:
①用等长ASCII编码:58 ×8 = 464位;
②用等长3位编码:58 ×3 = 174位;
③不等长编码:出现频率高的字符用的编码短些,出现频率低的字符则可以编码长些
我们看看如何进行不等长的编码:
比方说我们想对前面的7个字符进行编码,我们是否能够按照下面这种规则进行编码?
答案是:不能。当我给你一个字符串的序列1011的时候,你怎么理解这个串?你有很多理解方法:
这种情况就是叫二义性。我们该如何避免这种情况呢?只要这个编码满足前缀码这样一种条件就不会有二义性。所谓前缀码就是任何字符的编码都不是另外一个字符串编码的前缀。刚才那个例子中。对于 s(10)来讲,它的前缀1可以理解为 a,所以 a 变成了 s 的一个前缀了,这样的话就会出现我们所说的二义性。
如何保证我们这个编码不会出现二义性:
我们可以用二叉树来表示我们的编码:
(1)左右分支:0、1
(2)字符只在叶结点上
所有的结点都在叶结点上的时候,就不可能会出现一个字符的编码是另外一个字符的前缀。当你的编码有一个结点出现在另外一个结点的编码当中的时候(a出现在s和t中)就会出现前缀。
二叉树的构造方法:
假设现在各个字符出现的频率如下:
a = 4 , u = 1 , x = 2 , z = 1 \rm{a=4, u=1, x=2, z=1} a=4,u=1,x=2,z=1
刚才的那种树对应的代价为16
如果我们换一种树的构造方法,如下所示,代价为14
如何选择最优的构造方法:
在前面例子中,我们7个字符在58个字符串中出现的次数如下:
我们先构造哈夫曼树:
①首先我们将这些字符排列:
②我们先挑两个最小的,1和3
③然后构造一个新的结点4
④然后再找两个最小的结点,4和4,构造一个新结点8
⑤然后再找两个最小的结点,8和10,构造一个新结点18
⑥再找两个最小的结点,12和13,构造一个新结点25
⑦再找两个最小的结点,15和18,构造一个新结点25
⑧最后将25和33合并
所以对应编码就出来了
对应的代价值是146
四、小测验
1、如果哈夫曼树有67个结点,则可知叶结点总数为:
A. 22
B. 33
C. 34
D. 不确定
答案:C
2、为五个使用频率不同的字符设计哈夫曼编码,下列方案中哪个不可能是哈夫曼编码?
A. 00,100,101,110,111
B. 000,001,01,10,11
C. 0000,0001,001,01,1
D. 000,001,010,011,1
答案:A
3、一段文本中包含对象{a,b,c,d,e},其出现次数相应为{3,2,4,2,1},则经过哈夫曼编码后,该文本所占总位数为:
A. 12
B. 27
C. 36
D. 其它都不是
答案:B