在学习哈夫曼树之前,我们先要先了解一些概念,再从哈夫曼树的定义入手。
小引
什么是节点的路径长度?
按定义来讲,是指从根节点到该节点的路径上的连接数
按树的结构来想象一下,如果由A到G有且仅有一条路径,途中还经过了B、C、D、E、F,那么从A到G的这段路径,每两个节点之间的路径累加起来
A->B 1条 B->C 1条 C->D 1条 D->E 1条 E->F 1条 F->G 1条
累计有6条,那么由A->G的这条路径的路径长度为6(该引例与下图无关)
那么上述树结构图形,按照路径长度定义红色区域由A到K(看不太清,该树的定义就是按字母顺序来的,主要看路径长度就行)的路径长度就是3
什么是树的路径长度之和?
是指树中每个叶子结点的路径长度之和。没有什么讲究的,单纯记忆就好
什么是节点的权值?
本节的这些概念和后续的图部分有一定重复的部分,提到节点的权值,我们可以很朴素的将数学概念带入理解
在数学领域,权值指加权平均数中的每个数的频数,也称为权数或权重。
那么我们简单理解的话就是其在事件中的重要程度,并赋给节点相应的值以展示其重要性
什么是节点的带权路径长度?
那么了解了节点的权值后,我们就可以定义带权路径长度
其是指结点的路径长度和节点权值的乘积
我们假设上图中K节点的权值为5,并可得知K节点的路径长度为3,那么K节点的带权路径长度为3*5=15
什么是树的带权路径长度?
就是树的所有叶子结点的带权路径长度之和,可简写为WPL,这个也是在哈夫曼树中最为重要的一种计算
而且通过类比也可知,凡是有关树的整体路径或带权路径概念,我们都需要联想到的是树的叶子节点
综上就是小引中的基本概念,下面我们引入哈夫曼树
构造一棵WPL最小的二叉树
但是构造该树的前提是有效节点只能做叶子结点
那就是变相的出了一道题,题目明确给了n个节点,但是都必须定义为树的叶子结点,还要保证树的带权路径是最小的,也就是这几个节点的带权路径之和最小,我们如何解决这个问题?
哈夫曼树就是解决方案
哈夫曼树又被称为最优二叉树
每次选两个最小权值的树,合并成一棵树,经历过n-1次循环后,得到唯一的一棵树
那么怎么在代码中合并呢?构造一个空节点用以连接,将两棵最小权值的树作为该空节点的左右孩子即可,而且我们可以将该树的权值(即所有叶子结点的权值和)赋值给该空节点,以便于其作为一个新树加入整个循环的比较队列
可以看到上图,不带颜色的节点为经比较后构造出的新树的根节点,经过一次一次的比较得到了一棵权值最小的树,该树的WPL值一定,但是树的长相不一定
这里有一个关于哈夫曼树的性质,但依然以问题形式做出引例:
n个固定的叶子结点创建出的哈夫曼树,总结点个数是多少?
一起分析一下吧,上述过程中,要求构造一棵WPL最小的二叉树,我们一开始得到了n个节点,但是构造该树的前提是n个节点只能做叶子结点,我们想到的方法是每次寻找树中权值最小的两个,组合起来,我们可想而知,n个节点,需要组合n-1次才可以,那么在此过程中就会出现n-1个将最小权值树连接起来的节点,那么加上一开始的n个节点,会有多少个呢?
n + n-1 = 2n-1
所以一个哈夫曼树中,有2n-1个节点
且由此过程可知,一棵哈夫曼树中,一定只存在度为0和度为2的节点
度为0的节点有n个(已知),度为2的节点有n-1个
其实我们还可以根据二叉树的性质来推导一下,n0=n2+1
那自然节点度为2的值为n-1
哈夫曼树的应用场景:哈夫曼编码
小引
定长编码
像ASCII编码、Unicode编码。
ASCII编码每一个字符使用8个bit,能够编码256Unicode编码每个字符占16个bit,能够编码65536个字符,包含所有ASCII编码的字符。 假设我们要使用定长编码对由符号A,B,C,D和E构造的消息进行编码,对每个字符编码需要多少位呢? 至少需要3位,2个bit位不够表示五个字符,只能表示4个字符。 如果对DEAACAAAAABA进行编码呢? 总共12个字符,每个字符需要3bit,总共需要36位。
但显而易见的是,定长编码的空间浪费过于严重
对于仅包含ASCI字符的纯文本消息,Unicode使用的空间是ASCI的两倍,两种方式都会造成空间浪费;字符”a"和“e”的出现频率比””和“”的出现频率高,但是他们都占用了相同位数的空间。要解决定长编码的缺陷,便有了下面的变长编码。
但好处就是,它是统一的规范,是稳定的,不会因为外部原因发生改变,编译时好编译
变长编码
单个编码的长度不一样,可以根据整体出现的频率来调节,出现的频率越高,编码长度越短。 变长编码优于定长编码的是,变长编码可以将短编码赋予平均出现频率较高的字符,同一消息的编码长度小于定长编码。 这个时候又有一个问题,字符有长有短,我们怎么知道一个字符从哪里开始,又从哪里结束呢?如果位数固定,就没这个问题了。
前缀属性
字符集当中的一个字符编码不是其他字符编码的前缀,则这个字符编码具有前缀属性。所谓前缀,一个编码可以被解码为多个字符,表示不唯一。比如,abcd需要编码表示,设a = 0,b = 10,c = 110,d = 11。那么110可以表示c,也可以表示da。
所以我们进行字符编码时,不能将一个字符的编码设置为另一字符编码的前缀,否则译码工作无法进行
而我们今天介绍的哈夫曼编码就是满足该特征的,其编码永远不会是别的字符的前缀,因为在哈夫曼树的构造完成后,我们可以对其进行左1右0的路径标识,在标识好对应的每个字符后,其最后一位前面的编码都不属于任何一个字符,更不用说其本身了,所以因此保证了其变长编码稳定编译的特征
哈夫曼树的相关特征
-
哈夫曼编码树是一颗二叉树
每片叶子结点都包含一个字符 从结点到其左孩子的路径上标记0 从结点到其右孩子的路径上标记1
-
从根结点到包含字符的叶子结点的路径上获得的叶结点的编码
-
编码均具有前缀属性
每一个叶结点不能出现在到另一个叶结点的路径上
注意:定长编码可以由完整的哈夫曼树表示,并且显然具有前缀属性有了哈夫曼编码树的特性,我们也知道了哈夫曼树的构造过程,最后我们再来看一个哈夫曼编码和哈夫曼树构造的完整过程。
实战演练
给定一些字符n,告诉这些字符出现的频率,请对这些字符进行哈夫曼编码,并输出每个字符的编码串。
编程思路:
-
根据频率(权重),让这些字符做叶子结点,构造哈夫曼树
-
结构体数组存树,仍然是一个树节点的存储形式
-
每次选两个最小的根节点
-
数组大小选定,2n,因为我们需要的节点一共是2n-1个,不使用下标为0的节点那么从1开始正好2n个对应着需要的节点
-
-
对哈夫曼树的边进行编码赋值
-
找根叶子结点之间的路径,得到每个叶子节点的编码
-
倒着找编码,从叶子去找根节点
-
存储单元代码:
typedef struct node{
int weight;//权重
int lchild,rchild;//左右孩子节点的下标
int parent;//父亲节点的下标
}HuffmanNode,*HuffmanTree;
构建哈夫曼树:
//构建哈夫曼树
HuffmanTree createHuffmanTree(int *w,int n){
int m = 2*n;
HuffmanTree tree = (HuffmanTree)malloc(sizeof(HuffmanNode)*m);//先给2n个节点设置好空间
//对数组进行初始化
for(int i=1;i<=n;i++){
//对前n个节点赋权值
tree[i].weight = w[i];
//初始化其节点均为0,因为目前还 都是一个一个独立的根节点,未进行双亲结点的查找,而且左右孩子对于叶节点来说一定是0
tree[i].lchild = tree[i].parent = tree[i].rchild = 0;
}
for(int i=n+1;i<m;i++){
//剩余的节点都是度为2的节点,道理与上方类似,待一一寻找
tree[i].weight = 0;
tree[i].lchild = tree[i].parent = tree[i].rchild = 0;
}
//建立哈夫曼树
int s1,s2;
//开始为赋值做准备,从后n个新建节点开始为其找合适的孩子(每轮查找都是两者和最小的)
for(int i=n+1;i<m;i++){
find(tree,i-1,&s1,&s2);
}
}
main函数:
int main()
{
//在main函数中定义好权值和对应字符,用于测试
int w[] = {5,29,7,8,14,23,3,11};
char show[] = {'A','B','C','D','E','F','G','H'};
//初始化哈夫曼树
HuffmanTree tree = createHuffmanTree(w,sizeof(w)/sizeof(w[0]));
}
find函数,用于查找1-x中两个最小权值节点并直接返回
void find(HuffmanTree tree,int x,int *s1,int *s2){
int min = 0;//设置最小根节点下标
//先找父节点为0的(证明没有被选过),对min进行初始化,此时找的是第一个s1的值
for(int i=1;i<=x;i++){
if(tree[i].parent == 0){
min = i;
break;
}
}
//找最小值
//printf("第一个s1遍历过的值:");
for(int i=min+1;i<=x;i++){
if(tree[i].parent == 0 && tree[i].weight < tree[min].weight){
min = i;
//printf("%d ",min);
}
}
//printf("\n");
*s1 = min;
//找第二个s2的值
for(int i=1;i<=x;i++){
if(tree[i].parent == 0){
min = i;
break;
}
}
//找最小值
//printf("第二个s2遍历过的值:");
for(int i=min+1;i<=x;i++){
if(tree[i].parent == 0 && tree[i].weight < tree[min].weight && i!=*s1){
//多增加一个下标不相同的条件,权值可以相同但是位置一定不能相同
min = i;
//printf("%d ",min);
}
}
//printf("\n");
*s2 = min;
//printf("最小的两个权值为:tree[%d]=%d tree[%d]=%d\n",*s1,tree[*s1].weight,*s2,tree[*s2].weight);
}
回来我们继续构造哈夫曼树:
//构建哈夫曼树
HuffmanTree createHuffmanTree(int *w,int n){
int m = 2*n;
HuffmanTree tree = (HuffmanTree)malloc(sizeof(HuffmanNode)*m);//先给2n个节点设置好空间
//对数组进行初始化
for(int i=1;i<=n;i++){
//对前n个节点赋权值
tree[i].weight = w[i];
//初始化其节点均为0,因为目前还 都是一个一个独立的根节点,未进行双亲结点的查找,而且左右孩子对于叶节点来说一定是0
tree[i].lchild = tree[i].parent = tree[i].rchild = 0;
}
for(int i=n+1;i<m;i++){
//剩余的节点都是度为2的节点,道理与上方类似,待一一寻找
tree[i].weight = 0;
tree[i].lchild = tree[i].parent = tree[i].rchild = 0;
}
//建立哈夫曼树
int s1,s2;
//开始为赋值做准备,从后n个新建节点开始为其找合适的孩子(每轮查找都是两者和最小的)
for(int i=n+1;i<m;i++){
find(tree,i-1,&s1,&s2);
//我们是对n后的每一个节点进行其左右孩子最小值的查找,所以依次对这些进行赋值
tree[s1].parent = tree[s2].parent = i;
tree[i].lchild = s1;//左小
tree[i].rchild = s2;//右大
//可以将s1和s2的位置互换一下,只是最后的出来的树不一样
tree[i].weight = tree[s1].weight + tree[s2].weight;
}
return tree;
}
下一步就是构造二位字符数组来存储编码,因为不同的字符对应的是不同的编码,一层用来存数据,一层用来存编码
//构造哈夫曼编码
char **createHuffmanCode(HuffmanTree tree,int n){
char *temp = (char*)malloc(sizeof(char)*n);//用于暂存每一个字符的编码
char **codes = (char**)malloc(sizeof(char*)*n);//最后返回的二位编码数组
memset(codes,0,sizeof(codes));//对二维数组空间内的数据全部清除
int start,p,pos;
for(int i=1;i<=n;i++){
start = n-1;//是temp数组中的最后一个位置
temp[start] = '\0';
pos = i;//前n个节点依次遍历
p = tree[i].parent;//p是指i节点的父亲节点,一直往上找
while(p!=0){
//进入循环的思路和上面相同,直到父节点p=0,才算找到根节点
start--;
if(tree[p].lchild==pos){
temp[start] = '1';
}
else{
temp[start] = '0';
}
pos = p;
p = tree[p].parent;
}
//前n个节点每一个都是如此,需要通过循环进行编码赋值
//然后将得到的temp赋值给codes
codes[i-1] = (char*)malloc(sizeof(char)*(n-start));
strcpy(codes[i-1],&temp[start]);
}
return codes;
}
我们返回main函数继续编写:
int main()
{
//在main函数中定义好权值和对应字符,用于测试
int w[] = {5,29,7,8,14,23,3,11};
char show[] = {'A','B','C','D','E','F','G','H'};
//初始化哈夫曼树
HuffmanTree tree = createHuffmanTree(w,sizeof(w)/sizeof(w[0]));
char **codes = createHuffmanCode(tree,sizeof(w)/sizeof(w[0]));
//输出我们得到的码值
for(int i=0;i<sizeof(w)/sizeof(w[0]);i++){
printf("%c的哈夫曼编码为:%-4s\n",show[i],codes[i]);
}
return 0;
}
以上代码总结为:
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
typedef struct node{
int weight;//权重
int lchild,rchild;//左右孩子节点的下标
int parent;//父亲节点的下标
}HuffmanNode,*HuffmanTree;
void find(HuffmanTree tree,int x,int *s1,int *s2){
int min = 0;//设置最小根节点下标
//先找父节点为0的(证明没有被选过),对min进行初始化,此时找的是第一个s1的值
for(int i=1;i<=x;i++){
if(tree[i].parent == 0){
min = i;
break;
}
}
//找最小值
//printf("第一个s1遍历过的值:");
for(int i=min+1;i<=x;i++){
if(tree[i].parent == 0 && tree[i].weight < tree[min].weight){
min = i;
//printf("%d ",min);
}
}
//printf("\n");
*s1 = min;
//找第二个s2的值
for(int i=1;i<=x;i++){
if(tree[i].parent == 0){
min = i;
break;
}
}
//找最小值
//printf("第二个s2遍历过的值:");
for(int i=min+1;i<=x;i++){
if(tree[i].parent == 0 && tree[i].weight < tree[min].weight && i!=*s1){
//多增加一个下标不相同的条件,权值可以相同但是位置一定不能相同
min = i;
//printf("%d ",min);
}
}
//printf("\n");
*s2 = min;
//printf("最小的两个权值为:tree[%d]=%d tree[%d]=%d\n",*s1,tree[*s1].weight,*s2,tree[*s2].weight);
}
//构建哈夫曼树
HuffmanTree createHuffmanTree(int *w,int n){
int m = 2*n;
HuffmanTree tree = (HuffmanTree)malloc(sizeof(HuffmanNode)*m);//先给2n个节点设置好空间
//对数组进行初始化
for(int i=1;i<=n;i++){
//对前n个节点赋权值
tree[i].weight = w[i-1];
//初始化其节点均为0,因为目前还 都是一个一个独立的根节点,未进行双亲结点的查找,而且左右孩子对于叶节点来说一定是0
tree[i].lchild = tree[i].parent = tree[i].rchild = 0;
}
for(int i=n+1;i<m;i++){
//剩余的节点都是度为2的节点,道理与上方类似,待一一寻找
tree[i].weight = 0;
tree[i].lchild = tree[i].parent = tree[i].rchild = 0;
}
//建立哈夫曼树
int s1,s2;
//开始为赋值做准备,从后n个新建节点开始为其找合适的孩子(每轮查找都是两者和最小的)
for(int i=n+1;i<m;i++){
find(tree,i-1,&s1,&s2);
//我们是对n后的每一个节点进行其左右孩子最小值的查找,所以依次对这些进行赋值
tree[s1].parent = tree[s2].parent = i;
tree[i].lchild = s1;//左小
tree[i].rchild = s2;//右大
//可以将s1和s2的位置互换一下,只是最后的出来的树不一样
tree[i].weight = tree[s1].weight + tree[s2].weight;
}
return tree;
}
//构造哈夫曼编码
char **createHuffmanCode(HuffmanTree tree,int n){
char *temp = (char*)malloc(sizeof(char)*n);//用于暂存每一个字符的编码
char **codes = (char**)malloc(sizeof(char*)*n);//最后返回的二位编码数组
memset(codes,0,sizeof(codes));//对二维数组空间内的数据全部清除
int start,p,pos;
for(int i=1;i<=n;i++){
start = n-1;//是temp数组中的最后一个位置
temp[start] = '\0';
pos = i;//前n个节点依次遍历
p = tree[i].parent;//p是指i节点的父亲节点,一直往上找
while(p!=0){
//进入循环的思路和上面相同,直到父节点p=0,才算找到根节点
start--;
if(tree[p].lchild==pos){
temp[start] = '1';
}
else{
temp[start] = '0';
}
pos = p;
p = tree[p].parent;
}
//前n个节点每一个都是如此,需要通过循环进行编码赋值
//然后将得到的temp赋值给codes
codes[i-1] = (char*)malloc(sizeof(char)*(n-start));
strcpy(codes[i-1],&temp[start]);
}
return codes;
}
int main()
{
//在main函数中定义好权值和对应字符,用于测试
int w[] = {5,29,7,8,14,23,3,11};
char show[] = {'A','B','C','D','E','F','G','H'};
//初始化哈夫曼树
HuffmanTree tree = createHuffmanTree(w,sizeof(w)/sizeof(w[0]));
char **codes = createHuffmanCode(tree,sizeof(w)/sizeof(w[0]));
//输出我们得到的码值
for(int i=0;i<sizeof(w)/sizeof(w[0]);i++){
printf("%c的哈夫曼编码为:%4s\n",show[i],codes[i]);
}
return 0;
}
输出结果为:
A的哈夫曼编码为: 110
B的哈夫曼编码为: 1
C的哈夫曼编码为: 101
D的哈夫曼编码为: 100
E的哈夫曼编码为: 11
F的哈夫曼编码为: 0
G的哈夫曼编码为: 111
H的哈夫曼编码为: 10