数据结构与算法——从零开始学习(五)树和二叉树

版权声明:本文为博主csdn_aiyang原创文章,未经博主允许不得转载。 https://blog.csdn.net/csdn_aiyang/article/details/84977814

系列文章

第一章:基础知识

第二章:线性表

第三章:栈和队列 

第四章:字符串和数组

第五章:树和二叉树

第六章:图

 


目录

第五章 :树和二叉树

第一节:树的定义及相关术语  

1.1 定义

1.2 特点

1.3 形式化

1.4 相关术语

1.5 树的基本操作

第二节:二叉树

2.1 基本概念

2.2 存储结构

2.3 二叉树基本操作

2.4 二叉树的遍历

第三节:树与森林

3.1 树的存储

3.2 树、森林与二叉树的相互转换

3.3  树和森林的遍历

第四节:最优二叉树——哈夫曼树

4.1 基本概念

4.2 哈夫曼树的构造算法

4.3 哈夫曼编码

4.4 哈夫曼编码算法实现

本章小结


第五章 :树和二叉树

树和图是两种重要的非线性结构。线性结构中结点具有唯一前驱和唯一后继的关系,而非线性结构中结点之间的关系不再具有这种唯一性。其中,树形结构中结点间的关系是前驱唯一而后继不唯一,即元素之间是一对多的关系;在图结构中结点之间的关系是前驱、后继均不唯一,因此也就无所谓前驱、后继了。直观地看,树形结构既有分支关系,又具有层次关系,它非常类似于自然界中的树。树形结构在现实世界中广泛存在,如:家谱、行政组织机构等都用树形表示。计算机领域的DOS和Windows操作系统中对磁盘文件的管理就采用了树形目录结构;在数据库中,树形结构也是数据的重要组织形式之一。

第一节:树的定义及相关术语  

1.1 定义

树(tree)是n(n>=0)个结点的有限集合。当n=0时,该集合满足以下条件:

(1)有且只有一个特殊的结点称为树的(root),根结点没有直接前驱结点,但有零个或多个直接后继结点。

(2)跟结点之外的其余n-1个结点被分成m(m>0)个互相不相交的集合T1、T2、···、Tm,其中每一个集合Ti(1<=i<=m)本身又是一棵树。树T1,T2,···,Tm称为根节点的子树

*可以看出,在树的定义中用了递归概念,即用树来定义树。因此,树形结构的算法也常常使用递归方法。

1.2 特点

(1)树的根结点没有直接前驱,除根结点之外的所有结点有且只有一个直接前驱。

(2)树中所有结点可以有零个或多个直接后继。

1.3 形式化

树的形式化二元组为:T = (D,R)。其中,D为树T中结点的集合;R为树中结点之间关系的集合。当树T为空时,D为空;当树T不为空树时,有:D = {Root} U Df },Root为树T的根结点,Df为树T的根Root的子树集合。

当树T的结点个数n<=1时,R为空;当树T 中结点个数n>1时有:R={<Root,ri>,i=1,2,···,m}。其中,Root为树T的根节点,ri 是树T的根结点Root的子树Ti 的根结点。

下图是一颗具有9个结点(ABCDEFGHI)的数T:

二元组:T = ({A,B,C,D,E,F,G,H,I },{<A,B> ,<A ,C>,<B,D>,<B,E>,<B,F>,<C,G>,<E,H>,<E,I>})

其中,以<A,B>为例,A是B的直接前驱,B是A的直接后继,也称为树的一条分支。

结点A为树T的根结点,除根结点A之外的其余结点分为两个不相交的集合:T1 = { B,D,E,F,H,I} 和 T2={C,G}。它们俩构成了结点A的两棵子树,T1和T2本身也是一棵树,例如子树T1的根结点为B,其余结点又分为三个不相交的集合即构成了结点B的三棵子树。

1.4 相关术语

(1)结点:包含一个数据元素及若干指向其他结点的分支信息的数据结构。

(2)结点的度:结点所拥有的子树的个数称为该结点的度。

(3)叶子结点:度为0的结点称为叶子结点,或者称为终端结点。

(4)分支结点:度不为0的结点称为分支结点,或者称为非终端结点。一棵树的结点除叶子结点外,其余的结点都是分支结点。

(5)孩子结点、双亲结点、兄弟结点:树中一个结点的子树的根结点称为这个结点的孩子结点,这个结点称为孩子结点的双亲结点。具有同一个双亲结点的孩子结点互称为兄弟结点。

(6)路径、路径长度:设n1,n2,···,nk为一棵树的结点序列,若结点ni是ni+i的双亲结点(1<=i <k),则把n1,n2,···,nk称为一条由n1至nk的路径。这条路径的长度是k-1。

(7)祖先、子孙:在树中,如果有一条路径从结点M到结点N,那么M就称为N的祖先,而N称为M的子孙。

(8)结点的层次:规定树的根结点的层数为1,其余结点的层数等于它的双亲结点层数加1。

(9)树的深度(高度):树中所有结点的层次的最大树称为树的深度。

(10)树的度:树中所有结点度的最大值称为该树的度。

(11)有序树和无序树:如果一棵树中结点的各子树从左到右是有次序的,即若交互了某结点各子树的相应位置,则构成不同的树,称这棵树为有序树;反之,则称为无序树。

(12)森林:m(m>=0)棵不想交的树的集合称为森林。自然界中树和森林是不同的概念,但在数据结构中,树和森林只有很小的差别。任何一棵树,删去根结点就变成了森林;反之,给森林增加一个统一的根结点,森林就变成一棵树。

1.5 树的基本操作

通常有以下几种:

(1)Initiate(t):初始化一棵树t。

(2)Root(x):求结点x所在树的根结点。

(3)Parent(t,x) :求树t中结点x的双亲结点。

(4)Child(t,x,i):求树t中结点x的第i个孩子结点。

(5)RightSibling(t,x):求树t中结点x右边的第一个兄弟结点,也称右兄弟结点。

(6)Insert(t,x,i,s):把以s为根结点的树插入到树t中作为结点x的第i棵子树。

(7)Delete(t,x,i):在树t中删除结点x的第i棵子树。

(8)Traverse(t):是树的遍历操作,即按某种方式访问树t中的每个结点,且使每个结点只被访问一次。遍历操作是非线性结构中非常常用的基本操作,许多对树的操作都是借助该操作实现的。

第二节:二叉树

二叉树是一种简单又非常重要的树形结构。由于任何数都可以转换为二叉树进行处理,而二叉树又有许多好的性质,非常适合于计算机处理,因此二叉树也是数据结构研究的重点。

2.1 基本概念

二叉树(Binary Tree)是有n个结点的有限集合,该集合或者为空、或者由一个称为根(Root)的结点及两个不相交、被分别称为根结点的左子树和右子树的二叉树组成。当集合为空时,称该二叉树为空二叉树。一颗二叉树中每个结点只能含有0、1或2个孩子结点,而且孩子节点分左、右孩子。

满二叉树:在一棵二叉树中,如果所有分支结点都存在左子树和右子树,并且所有叶子结点都在同一层上,这样的一棵二叉树称为满二叉树。

完全二叉树:一棵深度为k的有n个结点的二叉树,对树中的结点按从上至下、从左到右的顺序进行编号,如果编号为i(i<=n)的结点与满二叉树中编号为i的结点在二叉树中的位置相同,则这棵二叉树称为完全二叉树。其特点是:叶子结点只能出现在最下层和次下层,且最下层的叶子结点在树的左部。显然,一棵满二叉树必定是一棵完全二叉树,而完全二叉树未必是满二叉树。

2.2 存储结构

(1)顺序存储结构:用一组连续的存储单元存放二叉树中的结点。一般按照二叉树结点从上至下、从左到右的顺序存储。对于一般的二叉树,如果仍按从上至小、从左到右的顺序将树中的结点顺序存储在一维数组中,则数组元素下标之间的关系不能反映二叉树中结点之间的逻辑关系,只有添加一些并不存在的空结点,使之成为一棵完全二叉树的形式,然后用一维数组顺序存储。显然,这种存储对于需增加许多空结点才能将一棵二叉树改造成为一棵完全二叉树的存储时,会造成空间的大量浪费,不宜用顺序存储结构。

(2)链式存储结构:用链式结构来表示一棵二叉树,即用链指针来指示其元素的逻辑关系。

二叉链表存储,每个结点由三个域组成,除了数据域外,还有两个指针域,分别用来给出该结点左孩子和右孩子所在的链结点的存储地址。其中,data域存放结点的数据信息;Lchild 与Rchild分别存放指向左孩子和右孩子的指针,指针域为空为^/NULL。

2.3 二叉树基本操作

1、Initiate(bt):建立一棵空二叉树。

2、Create(x,lbt,rbt):生成一棵x为根结点的树,以lbt、rbt为子树。

3、InsertL(bt,x,parent):将结点x插入到树bt中作为parent结点的左孩子结点。如果parent已经有左孩子,则将x作为左孩子结点的左孩子结点。

4、InsertR(bt,x,parent):插入到右孩子结点。于上同理。

5、DeleteL(bt,parent):在二叉树bt中删除结点parent的左子树。

6、DeleteR(bt,parent):在二叉树bt中删除结点parent的右子树。

7、Search(bt,x):在二叉树bt中查找数据元素x。

8、Traverse(bt):按某种方式遍历二叉树bt中的全部结点。

2.4 二叉树的遍历

遍历操作可以使非线性结构线性化。由二叉树定义可知,一棵二叉树由根结点及其左子树和右子树三部分组成。因此,只需依次遍历这三部分即可遍历整个二叉树。若以D、L、R分别表示根、左、右,且以从左到右的顺序遍历为:(先)前序DLR、中序LDR、后序LRD。

(1)先序遍历:先访问根结点,然后遍历根结点的左子树,最后遍历根结点的右子树。

1、递归算法如下:

void PreOrder(BiTree bt){

    if(bt ==null) return;
    Visit(bt ->data); //得到值
    PreOrder(bt->Lchild); //先左
    PreOrder(bt->Rchild);//再右
}

按先序所得到的结点序列为:ABDGCEF

2、非递归算法:要在遍历左子树之前保存右子树根结点的地址指针,以便在完成左子树的遍历后,取出右子树根结点的地址,去遍历这棵右子树。同样在遍历左子树的左子树之前,也要先保存左子树的右子树根结点的地址,以此类推。可见,这些地址的保存和取出符合后进先出的原则,所以设置一个辅助作用的栈来保存右子树根结点的地址。这个辅助栈保存所有经过的结点指针,包括空的根指针和空的孩子指针。算法如下:

void PreOrderNonRec(BiTree bt){
    Stack s;//链表结点地址
    BiTree p;
    Init_Stack(&s);//初始化栈s
    
    Push_Stack(&s,bt);//根结点的地址bt入栈s,包括空的二叉树

    while(!Empty_Stack(s)){//栈s非空执行循序体

        p =Top_Stack(s);//取栈顶元素
        while(p!=NULL){
            Visit( p->data);
            Push_Stack(&s,p->Lchild);//向左走到尽头,空左孩子指针也入栈
            p = Top_Stack(s);//取栈顶元素
        }
        Pop_Stack(&s);//空指针退栈,栈中不可能有两个连续空指针
        if(!Empty_stack(s)){
            p =Pop_Stack(&s);
            Push_stack(&s,p->Rchild);//向右走一步,右孩子地址入栈
        }
    }
}    

(2)中序遍历:先遍历根据的左子树,再访问根结点,最后遍历根结点的右子树。

1、递归算法:

void InOrder(BiTree bt){
    if(bt ==NULL) return;
    InOrder(bt -> Lchild);
    Visit(bt ->data);
    InOrder(bt ->Rchild);
}

按中序遍历所得到的结点序列为:DGBAECF

2、非递归算法:

void InOrderNonRec(BiTree bt){
    Stack s;//设栈类型为Stack
    BiTree p;
    Init_Stack(&s,bt);//初始化栈s
    Push_Stack(&s,bt);//根结点的指针bt入栈s
    
    while(!Empty_Stack(s)){
        p =Top_Stack(s);

        while(p!=NULL){
            Push_Stack(&s,p->Lchild);//向左走到尽头,空左孩子指针也入栈
            p =Top_Stack(s);
        }
        p = Pop_Stack(&s);//空指针退栈
        if(!Empty_Stack(s)){
            p =Pop_Stack(&S);
            Visit(p ->data);//访问当前根结点
            Push_Stack(&s,p->Rchild);//向右一步,右孩子指针入栈
        }
    }
}

  

(3)后序遍历:先遍历根结点的左子树,再遍历根结点的右子树,最后访问根结点。

1、递归排序算法:

void PostOrder(BiTree bt){
    if(bt == NULL) return;
    PostOrder(bt ->Lchild);
    PostOrder(bt ->Rchild);
    Visit(bt ->data);
}

2、非递归排序算法:

void PostOrderNonRec(BiTree bt){
    Stack s;
    BiTree p,q;
    Init_Stack(&s);
    p =bt;
    do{
        while(p){//向左走到尽头,左孩子指针入栈
            Push_Stack(&s,p);
            p = p->Lchild;
        }
        q =NULL;
        while(!Empty_Stack(s)){        
            p=Top_Stack(s);
            if(p->Rchild =NULL)||(p->Rchild ==q)){
                visit(p ->data);        
                q = p ;
                Pop_Stack(&s);
            }else{
                p =p->Rchild;
                break;
            }
        }
    }while(!Empty_Stack(s));
}

(4)层次遍历:是指从二叉树的第一次根结点开始,从上至下逐层遍历,在同一层中,则按从左到右的顺序对结点逐个访问。得到的结果序列为:ABCDEFG。因此,在进行层次遍历时,对一层结点访问完后,再按照它们的访问次序对各个结点的左孩子和右孩子顺序访问,这样一层一层进行,先遇到的结点先访问,这与队列的操作原则比较吻合。因此,在进行层次遍历时,可设置一个队列结构,遍历从二叉树的根结点开始,首先将根结点指针入队列,然后从队头取出一个元素,每取出一个元素先访问该元素所指的结点,若该元素所指的结点左右孩子指针非空,则将该元素所指结点的非空左孩子指针和右孩子指针顺序入队。若队列非空,重复以上过程,当队列为空时,二叉树的层次遍历结束。在下面算法中,二叉树以二叉链表存储,一维数组Queue[MAXNODE]用以实现队列,变量front和rear分别表示当前队列首元素和队列尾元素在数组中的位置。

void LevelOrder(BiTree bt){
    BiTree Queue[MAXNODE];
    int front,rear;
    
    if(bt ==NULL )return;
    
    front =1; 
    //队列初始化    
    rear = 0;
    queue[rear] =bt;//根结点入队
    while(front!=rear){
        front++;
        Visit(queue[front] ->data);//访问队首结点的数据域
        if(queue[front]->Lchild!=NULL){
            rear++;
            queue[rear] = queue[front]->Lchild;
        }
        if(queue[front] -> Rchild!=NULL){
            rear++;
            queue[rear] = queue[front] -> Rchild;
        }
    }
}

第三节:树与森林

3.1 树的存储

在计算机中,树的存储有很多种方式,即可以采用顺序存储结构,又可以采用链式存储结构,但无论采用何种存储方式,都要求存储结构不但能存储各结点本身的数据信息,还要能唯一地反映树中各结点之间的逻辑关系。

1、双亲表示法:由树的定义可以知道,树中除根结点外的每个结点都有唯一的一个双亲结点,根据这一特性,可用一组连续的存储空间即一维数组存储树中的各个结点,数组中的一个元素表示树中的一个结点,数组元素为结构体类型,其中包括结点本身的信息及结构体类型,其中包括结点本身的信息及该结点的双亲结点在数组中的序号,树的这种存储方法称为双亲表示法。存储结构如下:

#define MAXNODE 100
typedef struct{
    elemtype data;
    int parent;
}NodeType;
NodeType t[MAXNODE];

如下图所示,树a的双亲表示b。其中b图中用parent域的值为-1表示该结点无双亲结点,即该结点是一个根结点。

树的双亲表示法对于实现Parent(t,x)操作和Root(x)操作很方便,但若求某结点的孩子结点,即实现Child(t,x,i)操作时,则需要查询整个数组。此外,这种存储方式不能反映各兄弟结点之间的关系,所以实现RightSibling(t,x)操作也比较困难。在实际中,如果需要实现这些操作,可在结点结构中增设存放第一个孩子的域和存放右兄弟的域,就能较方便地实现上述操作了。

2、孩子表示法:

如上图,其主体是一个与结点个数一样大小的一维数组,数组的每一个元素都由两个域组成:一个域用来存放结点本身的信息,另一个用来存放指针(该指针指向由该结点孩子组成的单链表的首位置)。单链表的基本结构也由两个域组成:一个存放孩子结点在一维数组中的序号,另一个是指针域,指向下一个孩子。显然,在孩子表示法中查找双亲比较困难,查找孩子却十分方便,故适用于对孩子操作多的应用。孩子表示法的存储结构可描述如下:

#define MAXNODE 100
typedef struct ChildNode{
    int childcode;
    struct ChildNode *nextchild;
}
typedef struct{
    elemtype data;
    struct ChildNode *firstchild;
}NodeType;
NodeType t[MAXNODE];

3、孩子兄弟表示法:

在树中,每个结点除其信息域外,再增加两个分别指向该结点的第一个孩子结点和右兄弟结点的指针。在这种存储结构下,树中结点的存储结构可描述如下:

typedef struct TreeNode{
    elemtype data;
    struct TreeNode *firstchild;
    struct TreeNode *nextsibling;
 }NodeType;

//定义一棵树
NodeType *t;

如上图所示,该存储结构与二叉树的二叉链表结构非常相似,而且事实上,如果剔除了字面上的含义,其实质是一样的。因此树、森林与二叉树的转换才得以方便地实现。

3.2 树、森林与二叉树的相互转换

从树的孩子兄弟表示法所示,如果设定一定的规则,就可用二叉树结构表示树、森林,这样对树的操作实现就可以借助二叉树存储,利用二叉树上的操作来实现。

1、树转换为二叉树:

对于一棵无序树,树中结点的各孩子结点的次序是无关紧要的,而二叉树中结点的左右孩子结点是有区别的。为避免发生混淆,约定树中每一个结点的孩子结点按从左到右的次序顺序编号。将一棵树转换为二叉树的方法是:

  • 树中所有相邻兄弟之间加一条连线;
  • 对树中每个结点,只保留它与第一个孩子结点之间的连线,删去它与其他孩子结点之间的连线;
  • 以树的根结点为轴心,将整棵树顺时针转动一定的角度,使之结构层次分明。

可以证明,树这样的转换所构成的二叉树是唯一的。转换示意图如下:

由上面的转换可以看出,在二叉树中,左分支上的各结点在原来的树中是父子关系,而右分支上的各结点在原来的树中是兄弟关系。由于树的根结点没有兄弟,所以变换后的二叉树的根结点的右孩子必为空。

事实上,一棵树采用孩子兄弟表示法所建立的存储结构与它所对应的二叉树的二叉链表存储结构是完全相同的。

2、森林转换为二叉树:

由森林的概念可知,森林是若干棵树的集合,只要将森林中各棵树的根视为兄弟,每棵树又可以用二叉树表示,这样森林同样可以用二叉树表示。方法如下:

  1. 将森林中的每棵树转换成相应的二叉树;
  2. 第一棵二叉树不动,从第二课二叉树开始,依次把后一棵二叉树的根结点作为前一棵二叉树根结点的右孩子,当所有二叉树连起来后,所得到的二叉树就是由森林转换得到的二叉树。

该方法可形式化描述为:

如果F={T1,T2,···,Tm}是森林,则可按如下规则转换成一颗二叉树B={Root ,LB,RB}。

若F为空,即m=0,则B为空树;

若F非空,即m!=0,则B的根Root即为森林中第一棵树的根Root(T1);B的左子树LB是从T1中根结点的子树森林F1={T11,T12,···,T1m}转换而成的二叉树;其右子树RB是从森林F = {T2,T3,···,Tm}转换而成的二叉树。转换过程示意图如下:

3、二叉树转换为树和森林

树和森林都可以转换为二叉树,二者不同的是:树转换成的二叉树的根结点不同的是:树转换的二叉树的根结点无右分支,而森林转换后的二叉树的根结点有右分支。显然这一转换过程是可逆的,即可以依据二叉树的根结点有无右分支,将一棵二叉树还原成树或森林。方法如下:

  1. 若某结点是某双亲的左孩子,则把该结点的右孩子、右孩子的右孩子·····都与该结点的双亲结点用线连起来;
  2. 删去原二叉树中所有的双亲结点与右孩子结点的连线;
  3. 整理由前面两步所得到的树或森林,使之结构层次分明。

这一方法可形式化描述为:

如果B = {Root,LB,RB}是一棵二叉树,则可按如下规则转换成森林F={T1,T2,···,Tm};

若B为空,则F为空;

若B非空,则森林中第一棵树T1的根Root(T1)即为B的根Root;T1中根结点的子树森林F1是由B的左子树LB转换而成的森林;F中除T1之外其余树组成的森林F ={T2,T3,···,Tm}是由B的右子树RB转换而成的森林。过程示意图如下:

3.3  树和森林的遍历

1、树的遍历

(1)先根遍历:访问根结点,按照从左到右的顺序先根遍历根结点的每一棵树。

(2)后根遍历:按照从左到右的顺序后根遍历根结点的每一棵子树,再访问根结点。 

根据树与二叉树的转换关系及树和二叉树的遍历定义可以推知,树的先根遍历与其转换的相应二叉树的先序遍历的结果序列相同;树的后根遍历与其转换的相应二叉树的中序遍历的结果序列相同。因此树的遍历算法是可以采用相应二叉树的遍历算法来实现的。

树的遍历算法实现:

void RootFirst(NodeTepe t){
    NodeType *p;
    if(t!=NULL){
        Visit(t -> data);//访问根结点
        p=t->firstchild;//指向第一个孩子结点
        while(p){        
            RootFirst(p);//访问孩子结点
            p =p->nextsibling;//指向下一个孩子结点,右兄弟结点
        }
    }
}

3、森林的遍历

(1)前序遍历:访问森林中第一棵树的根结点;前序遍历第一棵树的根结点的子树;前序遍历去掉第一棵树后的子森林。

(2)中序遍历:遍历第一棵树的根结点的子树;访问森林中第一棵树的根结点;去掉第一棵树后的子森林。

根据森林与二叉树转换关系及森林和二叉树的遍历定义可以推知,森林的前序遍历和后序遍历与所转换的二叉树的前序遍历和中序遍历的结果序列相同。

第四节:最优二叉树——哈夫曼树

4.1 基本概念

最优二叉树也称为哈夫曼树(Huffman),是指对于一组带有确定权值的叶子结点,构造的具有最小带权路径长度的二叉树。权值是指一个与特定结点相关的数值。前面介绍过路径和结点的路径长度的概念,而二叉树的路径长度则是指由根节点到所有叶子结点的路径长度之和。如果二叉树中的所有叶子结点都具有一个特定权值,则可将这一概念加以推广。

设二叉树具有n个带权值的叶子结点,那么从根结点到各个叶子结点的路径长度与该叶子结点相应的权值的乘积之和叫做二叉树的带权路径长度(Weighted Path Length,简称WPL = 路径长度X权值)。

给定一组具有确定权值的叶子结点,可以 构造出不同的带权二叉树。例如,给出4个叶子结点,设其权值分别为:1,3,5,5,可以构造出形状不同的多个二叉树。这些形状不同的二叉树的带权路径长度各不相同。如下图所示:

a:WPL = 1X2 + 3X2 + 5X2 +5X2   =  28;

b:WPL = 1X3 + 3X3 +5X2 +5X1 = 27;

c: WPL =1X2+3X3 +5X3 + 5X1 =31;

由此可见,由相同权值的一组叶子结点所构成的二叉树由不同形态和不同的带权路径长度。根据哈夫曼树定义,一棵二叉树要使其WPL值最小,必须使权值越大的叶子结点越靠近根节点,而权值越小的叶子结点越远离根结点。哈夫曼根据这一特点提出了一种构造最优二叉树的方法,这种方法的基本思想是:

  1. 由给定的n个权值{W1,W2,```,Wn}构造n棵只有一个叶子结点的二叉树,从而得到一个二叉树的集合F={T1,T2,```,Tn};
  2. 在F中选取根结点的权值最小和次小的两棵二叉树作为左右子树构造一棵新树的二叉树,这棵新的二叉树根节点的权值为其左右子树的根节点权值之和;
  3. 在集合F中删除作为左右子树的两棵二叉树,并将新建立的二叉树加入到集合F中;
  4. 重复上面两部后,当F中只剩下一棵二叉树时,这棵二叉树便是所要建立的哈夫曼树。

下图给出了前面提到的叶子结点权值集合为W = {1,3,5,5}的哈夫曼树的构造过程。WPL=3X3+1X3+5X2+5X1 =27

由此可见,对于同一组给定叶子结点所构造的哈夫曼树,树的形状可能不同,但带权路径长度值是相同的,一定是最小的。

4.2 哈夫曼树的构造算法

在构造哈夫曼树时,可以设置一个结构数组HuffNode保存哈夫曼树中各结点的信息,根据二叉树的性质可知,具有n个叶子结点的哈夫曼树共有2n-1个结点,所以数组HuffNode的大小设置为2n-1,数组元素的结构形式如下:

weight、lchild、rchild、parent。

其中,weight域保存结点的权值,lchild和rchild域分别保存该结点的左右孩子结点在数组HuffNode中序号,从而建立起结点之间的关系。为了判定一个结点算法已加入到要建立的哈夫曼树中,可通过parent域的值来确定。初始时parent的值为-1,当结点加入到树中时,该结点parent的值为其双亲结点在数组HuffNode中的序号,就不会是-1了。

构造哈夫曼树时,首先将由n个字符形成的n个叶子结点存放到数组HuffNode的前n个分量重,然后根据起那么介绍的哈夫曼方法的基本思想,不断将两个小子树合并为一个较大的子树,每次构成的新子树的根结点顺序放到HuffNode数组中前n个分量的后面。算法如下:

#define MAXVALUE 1000;//定义最大权值
#define MAXLEAF 30; // 定义哈夫曼树中叶子结点的最大个数
#define MAXNODE MAXLEAF*2-1 ;//定义哈夫曼树中结点的最大数
typedef struct{
    int weight;
    int parent;
    int lchild;
    int rchild;
 }HNode,HuffmanTree[MAXNODE];

void CrtHuffmanTree(HuffmanTree ht,int w[],int n){//数组w[] 传递n个权值
    int i,j,m1,m2,x1,x2;
    for(i=0;i<2*n-1;i++){//ht初始化
        ht[i].weight =0;
        ht[i].parent =.1;
        ht[i].lchild =.1;
        ht[i].rchild =.1;
    }
    for(i=0;i<n;i++){
        ht[i].weight =w[i];//赋予n个叶子结点的权值
    }
    for(i=0;i<n-1;i++){
        m1 =m2=MAXVALUE;//构造哈夫曼树
        x1 =x2 =0;
        for(j=0;j=n+1;j++){
            //寻找权值最小和次小的两棵子树
            if(ht[j].weight<m1 && ht[j].parent ==.1){
                m2 = m1;
                x2=x1;
                x1=j;   
            }else if(ht[j].weight<m2 &&ht[j].parent ==.1){
                m2=ht[j].weight;
                x2 = j;
            }
        }
        //将找出的两棵子树合并为一棵子树
        ht[x1].parent = n+i;
        ht[x2].parent = n+i; 
        ht[n+i].lchild =x1;
        ht[n+i].rchild =x2;
    }
}

4.3 哈夫曼编码

在数据通信中,经常需要将传递的文字转换成由二进制字符0、1组成二进制串,即进行符号的二进制编码。常见的如ASCII码就是8位的二进制编码,此外,还有汉字国际码、电报明码等。

ASCII码是一种定长编码,即每个字符用相同数目的二进制位表示。为了缩短数据文件报文长度,可采用不定长编码。例如,假设要传递的报文为ABACCDA,报文中只含A、B、C、D四种字符。如下图所示:

a编码,报文的代码为0000 1000 0100 1001 11000,长度为21;

b编码,报文的代码为0001 0010 101100,长度为14;这两种编码均是定长编码,码长分别为3和2。

c编码,报文的代码为0110 0101 01110,长度为13;

d编码,报文的代码为0101 0010 0100 11001,长度为17;

显然,不同的编码方案,其最终形成的报文代码总长度是不同的。如何使最终的报文最短,可以借鉴哈夫曼思想,在编码时考虑字符出现的频率,让出现频率高的字符采用尽可能短的编码,出现频率低的字符采用稍长的编码,构造的不定长编码,则报文的代码就可能达到更短。

因此,利用哈夫曼树来构造编码方案,就是哈夫曼树的典型应用。具体做法如下:设需要编码的字符集合为{d1,d2,···,dn},它们在报文中出现的次数或频率集合为{w1,w2,···,wn},以d1,d2,···dn为叶子结点,w1,w2,···,wn为它们的权值,构造一棵哈夫曼树,规定对哈夫曼树中的左分支赋予0,右分支赋予1,则从根结点到每个叶子结点所经过的路径分支组成的0和1序列便为该叶子结点对应字符的编码,称为哈夫曼编码,这样的哈夫曼树也称为哈夫曼编码树。

在哈夫曼编码树中,树的带全路径长度的含义是各个字符的码长与其出现次数的乘积之和,也就是报文的代码总长,所以采用哈夫曼树构造的编码是一种能使报文代码总长最短的不定长编码。

在建立不定长编码时,必须使任何一个字符的编码都不是另一个字符编码的前缀,这样才能保证译码的唯一性。例如d编码方案,字符A的编码01是字符B的编码010的前缀部分,这样对于代码串0101001,既是AAC的代码,又是ABA和BDA的代码,因此,这样的编码不能保证译码的唯一性,称为具有二义性的译码。同时把满足“任意一个符号的编码都不是其他的编码的前缀”这一条件的编码称为前缀编码。

采用哈夫曼树进行编码,则不会产生上述二义性问题。因为,在哈夫曼树中,每个字符结点都是叶子结点,它们不可能在根结点到其他字符结点的路径上,所以一个字符的哈夫曼编码不可能是另一个字符的哈夫曼编码的前缀,从而保证了译码的非二义性。

设ABCD出现的频率分别为0.4,0.3,0.2,0.1,则得到的哈夫曼树和二进制前缀编码如下图所示:

按此编码,前面的报文可转换成总长为14bit的二进制位串“01001101101110”,可以看出,这一种不定长的前缀编码能将报文唯一地无二义性地翻译成原文。当原文较长、频率很不均匀时,这种编码可使传送的报文缩短很多。当然,也可以在哈夫曼树中规定左分支表示“1”,右分支表示“0”,得到的二进制前缀编码虽然不一样,但使用效果一样。

4.4 哈夫曼编码算法实现

 编码表存储结构:

typedef struct codenode{
    char ch;     //存放要表示符号
    char *code; //存放相应代码
}CodeNode;

typedef CodeNode HuffmanCode[MAXLEAF];

哈夫曼编码的算法思路:在哈夫曼树中,从每个叶子结点开始,一直往上搜索,判断该结点是其双亲结点的做孩子还是右孩子。若是左孩子,则相应位置上的代码为0,反之为1。直到搜索到根据点为止,具体算法如下:

void CrtHuffmanCode(HuffmanTree ht ,HuffmanCode hc,int n){
    //从叶子结点到根,逆向搜索求每个叶子结点对应符号的哈夫曼编码
    char *cd;
    int i,c,p,start;
    cd = malloc (n*sizeof(char));//为当前工作区分配空间
    cd[n-1] ="\0";//从右到左逐位存放编码,首先存放结束符
    
    for(i =1; i<=n ;i++){//求n个叶子结点对应的哈夫曼编码
        start = n-1;
        c =1;
        p =ht[i].parent;
        while(p!=0){
            --start;
            if(ht[p].lchild ==c){
                cd[start] ="0";//左分支标0
            }else{
                cd[start] ="1";//右1
            }
            c = p;
            p =ht[p].parent;//向上倒退
        }
        hc[i] = malloc((n-start) * sizeof(char));//为第i个编码分配空间
        scanf("%c",&(hc[i]).ch)  ;  //输入相应待编码字符
        strcpy(hc[i],&ch[start]);  //将工作区中编码复制到编码表中
   }
    free(cd);
}

本章小结

本章主要介绍了树与森林、二叉树的定义、性质、操作和相关算法的实现。特别是二叉树的遍历算法,它们是许多二叉树应用的算法设计基础,必须熟练掌握。对于树的遍历算法,由于树的先根遍历次序与对应二叉树表示的前序遍历次序一致;树的后根遍历次序与对应二叉树的中序遍历次序一致,因此可以根据此得出树的遍历算法。

本章最后讨论的哈夫曼树是一种扩充的二叉树,即在终端结点上带有相应的权值,并使其带权路径长度最短。作为哈夫曼树的应用,引入了哈夫曼编码。通常让哈夫曼的左分支代表编码“0”,右分支代表编码“1”,得到哈夫曼编码。这是一种不定长编码,可以有效地实现数据压缩。


系列文章:

数据结构与算法——从零开始学习(一)基础概念篇

数据结构与算法——从零开始学习(二)线性表

数据结构与算法——从零开始学习(三)栈和队列

数据结构与算法——从零开始学习(四)字符串和数组

猜你喜欢

转载自blog.csdn.net/csdn_aiyang/article/details/84977814