目录
一. 基本概念和术语
二. 哈夫曼树的构造
三. 哈夫曼编码
引例:将百分制成绩转换为五级制成绩:<60:E;60-69: D;70-79:C;80-89:B;90-100:A;
一个常用的算法是这样的:
#include <stdio.h>
int main() {
int score;
printf("请输入百分制成绩: ");
scanf("%d", &score);
if (score >= 90 && score <= 100) {
printf("五级制成绩为:优秀\n");
} else if (score >= 80 && score < 90) {
printf("五级制成绩为:良好\n");
} else if (score >= 70 && score < 80) {
printf("五级制成绩为:中等\n");
} else if (score >= 60 && score < 70) {
printf("五级制成绩为:及格\n");
} else if (score >= 0 && score < 60) {
printf("五级制成绩为:不及格\n");
} else {
printf("输入的成绩无效\n");
}
return 0;
}
这个决策过程可以画出判断树,判断树是用来描述分类过程的二叉树。如果学生数据量很大,则应该考虑程序操作的时间。现在设共有1万个学生数据,各等级的人数比例在图中标出,则5%的数据需1次比较,15%的数据需2次比较,40%的数据需3次比较,40%的数据需4次比较,因此10000个数据比较的次数为:10000 (1×5%+2×15%+3×40%+4×10%)= 31500次,而左边的决策树只需比较22000次,显然两个决策树的效率不一样,那么我们可否找到一个最优的决策树呢?这就是哈夫曼树(最优二叉树)。
一. 基本概念和术语
路径:从树中一个结点到另一个结点之间的分支构成这两个结点间的路径。
结点的路径长度:两结点间路径上的分支数。
树的路径长度:从树根到每一个结点的路径长度之和。记作:TL。显然,TL(a)=20,TL(b)=16。
结点数目相同的二叉树中,完全二叉树是路径长度最短的二叉树(但路径长度最短的二叉树不一定是完全二叉树)。
权(weight):将树中结点赋给一个有着某种含义的数值,则这个数值称为该结点的权。
结点的带权路径长度:从根结点到该结点之间的路径长度与该结点的权的乘积。
树的带权路径长度:树中所有叶子结点的带权路径长度之和。记作:。
哈夫曼树:最优二叉树,带权路径长度(WPL)最短的二叉树。需要注意的是:“带权路径长度最短”是在“度相同”的树中比较而得的结果,因此有最优二叉树、最优三叉树之称等等。
因为构造这种树的算法是由哈夫曼教授于1952年提出的,所以被称为哈夫曼树,相应的算法称为哈夫曼算法。
观察上图能得到下面三个结论:
- 满二叉树(图1)不一定是哈夫曼树;
- 哈夫曼树中权值越大的叶子结点离根越近;
- 具有相同带权结点的哈夫曼树不唯一(图3和图4);
二. 哈夫曼树的构造
贪心算法:构造哈夫曼树时首先选择权值小的叶子结点。
哈夫曼算法(构造哈夫曼树的方法):
(1)【构造森林全是根】根据n个给定的权值构成n棵二叉树的森林,其中只有一个带权为的根结点。
(2)【选用两小造新树】在F中选取两棵根结点的权值最小的树作为左右子树,构造一棵新的二叉树,且设置新的二叉树的根结点的权值为其左右子树上根结点的权值之和。
(3)【删除两小添新人】在F中删除这两棵树,同时将新得到的二叉树加入森林中。
(4)【重复2,3剩单根】重复(2)和(3),直到森林中只有一棵树为止,这棵树即为哈夫曼树。
例:四个结点的权分别是7,5,2,4,构造哈夫曼树过程如下:
以上构造出来的哈夫曼树结点的度数为0(n个叶子结点)或2(n-1个新生成的结点),没有度为1的结点。如果一开始有n个结点,经过n-1次合并,形成的树结点数是2n-1.
哈夫曼树就是二叉树,下面考虑怎么实现这个算法。实现前我们首先确定哈夫曼树怎么存储,这里采用顺序存储方式,利用一个一维的结构数组(哈夫曼树中共有2n-1个结点,不使用0下标,数组大小为2n)。它的结点类型定义如下:
typedef struct {
int weight; //权重
int parent,lch,rch; //双亲结点,左孩子结点,右孩子结点
}HTNode,*HuffmanTree;
//HuffmanTree H既是一个指针,也是一个数组,数组返回值就是首元素的地址
//例如,第1个结点权值为5,即可表示为H[i].weight=5;
算法过程如下:
1.初始化HT[1.....2n-1]:lch=rch=parent=0;
2.输入初始n个叶子结点:置HT[1.....n]的weight值;
3.进行以下n-1次合并,依次产生n-1个结点HT[i],i=n+1....2n-1:
a)在HT[1...i]中选两个未被选过(parent == 0表示未被选过)的weight最小的两个结点HT[s1]和HT[s2],s1、s2为两个最小结点下标;
b)修改HT[s1]和HT[s2]的parent值:HT[s1].parent=i;HT[s2].parent=i;表示两个结点的双亲结点是HT[i];
c)修改新产生的HT[i]:
(新产生结点的权重,为两个子结点权重之和)HT[i].weight=HT[s1].weight+ HT[s2].weight;
(新产生结点的左右孩子)HT[i].lch=s1;HT[i].rch=s2;
void CreatHuffmanTree (HuffmanTree HT, int n){ //构造哈夫曼树——哈夫曼算法
if(n<=1) return ERROR;
m=2*n-1; //数组共2n-1个元素
HT=new HTNode[m+1]; //构造长2n的数组
for(i=1;i<=m;++i){ //将2n-1个元素的lch、rch、parent置为0
HT[i].Ilch=0;
HT[i].rch=0;
HT[i].parent=0;
}
for(i=1;i<=n;++i) cin>>HT[i].weight; //输入前n个元素的weight值
//初始化结束,下面开始建立哈夫曼树
for(i=n+1;i<=m;i++){ //合并产生n-1个结点——构造Huffman树
Select(HT,i-1,s1,s2); //在HT[k](1≤k<i-1)中选择两个其双亲域为0,
//且权值最小的结点,并返回它们在HT中的序号s1和s2
//这个函数需要另行实现
HT[s1].parent=i;
HT[s2].parent=i; //表示从F中删除s1,s2
HT[i].lch=s1;
HT[i].rch=s2; //s1,s2分别作为i的左右孩子
HT[i].weight=HT[s1].weight + HT[s2].weight; //i的权值为左右孩子权值之和
}
}
三. 哈夫曼编码
在远程通讯中,要将待传字符转换成由二进制的字符串:
若将编码设计为长度不等的二进制编码,即让待传字符串中出现次数较多的字符采用尽可能短的编码,则转换的二进制字符串便可能减少。
关键:要设计长度不等的编码,则必须使任一字符的编码都不是另一个字符的编码的前缀。
什么样的前缀码能使电文总长最短?——哈夫曼编码
- 统计字符集中每个字符在电文中出现的平均概率(概率越大,要求编码越短)。
- 利用哈夫曼树的特点:权越大的叶子离根越近;将每个字符的概率值作为权值,构造哈夫曼树。则概率越大的结点,路径越短。
- 在哈夫曼树的每个分支上标上0或1:结点的左分支标0,右分支标1。把从根到每个叶子的路径上的标号连接起来,作为该叶子代表的字符的编码。
例:要传输的字符集D={C,A,S,T,;},字符出现的频率w={2,4,2,3,3}。
利用上述编码,电文是{CAS;CAT;SAT;AT},其编码是:11010111011101000011111000011000”,反之,若编码是1101000,对应CAT。
两个问题:
- 为什么哈夫曼编码能够保证是前缀编码?——因为没有一片树叶是另一片树叶的祖先,所以每个叶结点的编码就不可能是其它叶结点编码的前缀;
- 为什么哈夫曼编码能够保证字符编码总长最短?——因为哈夫曼树的带权路径长度最短,故字符编码的总长最短。
关于编码实现:首先根据字符和权值得到哈夫曼树,然后逆序寻找。
例如,寻找G的哈夫曼编码,G在HT[7]中,HT[7]的parent是8,查HT[8]可知HT[7]是HT[8]的左孩子,所以哈夫曼编码的最后一位是0;然后再去查HT[8]的双亲结点,发现是HT[10],并且HT[8]是HT[10]的左孩子,所以哈夫曼编码的倒数第二位是0...以此类推,直到找到HT[12]的双亲结点是HT[13],且HT[13]的parent是0,这表示我们已经找到根结点,并且HT[12]是HT[13]的右孩子,说明哈夫曼编码的第一位是1。然后我们根据输出顺序00001倒过来就是G的哈夫曼编码值10000。
在代码实现中,HT数组存放哈夫曼树,HC数组存放每个字符的哈夫曼编码,即哈夫曼编码表;同时还有一个cd数组用来临时存放输出的每一位哈夫曼编码数值,并倒序输出(这里笔者认为直接用栈实现会更好)。n个字符下,哈夫曼树最多有n-1层,所以cd数组的长度是n,最后一位存放'\0'表示字符结束标志。
void CreatHuffmanCode(HuffmanTree HT,HuffmanCode &HC,int n){
//从叶子到根逆向求每个字符的哈夫曼编码,存储在编码表HC中
HC = new char *[n+1]; //分配n个字符编码的头指针矢量,0号位空出,HC是指针数组,存放字符数组的地址
cd = new char [n]; //分配临时存放编码的动态数组空间
cd[n-1] ='\0'; //编码结束符
for(i=1; i<=n; ++i){ //逐个字符求哈夫曼编码
start = n-1; //cd数组的索引
c = i; //临时存放结点在HT中的下标
f = HT[i].parent;
while(f!=0){ //从叶子结点开始向上回溯,直到根结点
--start; //回溯一次start向前指一个位置
if(HT[f].lchild == c) cd[start] = '0'; //结点c是f的左孩子,则生成代码0
else cd[start] = '1'; //结点c是f的右孩子,则生成代码1
c = f; //继续向上回溯
f = HT[f].parent;
} //求出第i个字符的编码
HC[i] = new char [n-start]; //为第i个字符串编码分配空间
strcpy(HC[i], &cd[start]); //将求得的编码从临时空间cd复制到HC的当前行中
}
delete cd; //释放临时空间
} //CreatHuffanCode
上面讨论了生成哈夫曼编码,下面考虑解码:
- 构造哈夫曼树
- 依次读入二进制码
- 读入0,则走向左孩子;读入1,则走向右孩子
- 一旦到达某叶子时,即可译出字符
- 然后再从根出发继续译码,直到结束