1.二叉树
二叉树(Binary Tree)是个有限元素的集合,该集合或者为空、或者由一个称为根(root)的元素及两个不相交的、被分别称为左子树和右子树的二叉树组成。当集合为空时,称该二叉树为空二叉树。在二叉树中,一个元素也称作一个结点。
二叉树是有序的,即若将其左、右子树颠倒,就成为另一棵不同的二叉树。即使树中结点只有一棵子树,也要区分它是左子树还是右子树。因此二叉树具有五种基本形态,如图6.1所示。
![]() | ![]() | ![]() |
| ||
![]() |
(a) (b) (c) (d) (e)
图6.1 二叉树的五种基本形态
2.二叉树的相关概念
(1)结点的度。结点所拥有的子树的个数称为该结点的度。
(2)叶结点。度为0的结点称为叶结点,或者称为终端结点。
(3)分枝结点。度不为0的结点称为分支结点,或者称为非终端结点。一棵树的结点除叶结点外,其余的都是分支结点。
(4)左孩子、右孩子、双亲。树中一个结点的子树的根结点称为这个结点的孩子。这个结点称为它孩子结点的双亲。具有同一个双亲的孩子结点互称为兄弟。
(5)路径、路径长度。如果一棵树的一串结点n1,n2,…,nk有如下关系:结点ni是ni+1的父结点(1≤i<k),就把n1,n2,…,nk称为一条由n1至nk的路径。这条路径的长度是k-1。
(6)祖先、子孙。在树中,如果有一条路径从结点M到结点N,那么M就称为N的祖先,而N称为M的子孙。
(7)结点的层数。规定树的根结点的层数为1,其余结点的层数等于它的双亲结点的层数加1。
(8)树的深度。树中所有结点的最大层数称为树的深度。
(9)树的度。树中各结点度的最大值称为该树的度。
(10)满二叉树。
在一棵二叉树中,如果所有分支结点都存在左子树和右子树,并且所有叶子结点都在同一层上,这样的一棵二叉树称作满二叉树。如图6.2所示,(a)图就是一棵满二叉树,(b)图则不是满二叉树,因为,虽然其所有结点要么是含有左右子树的分支结点,要么是叶子结点,但由于其叶子未在同一层上,故不是满二叉树。
![]() | ![]() |
|
(a) 一棵满二叉树 (b) 一棵非满二叉树
图6.2 满二叉树和非满二叉树示意图
(11)完全二叉树。
一棵深度为k的有n个结点的二叉树,对树中的结点按从上至下、从左到右的顺序进行编号,如果编号为i(1≤i≤n)的结点与满二叉树中编号为i的结点在二叉树中的位置相同,则这棵二叉树称为完全二叉树。完全二叉树的特点是:叶子结点只能出现在最下层和次下层,且最下层的叶子结点集中在树的左部。显然,一棵满二叉树必定是一棵完全二叉树,而完全二叉树未必是满二叉树。如图6.3所示(a)为一棵完全二叉树,(b)和图6.2(b)都不是完全二叉树。
![]() | ![]() | ||
|
|
(a) 一棵完全二叉树 (b) 一棵非完全二叉树
图6.3 完全二叉树和非完全二叉树示意图
6.1.2 二叉树的主要性质
性质1 一棵非空二叉树的第i层上最多有2i-1个结点(i≥1)。
该性质可由数学归纳法证明。证明略。
性质2 一棵深度为k的二叉树中,最多具有2k-1个结点。
|
|
证明 设第i层的结点数为xi(1≤i≤k),深度为k的二叉树的结点数为M,xi最多为2i-1,则有:
M= xi≤ 2i-1 =2k-1
性质3 对于一棵非空的二叉树,如果叶子结点数为n0,度数为2的结点数为n2,则有:
n0=n2+1。
证明 设n为二叉树的结点总数,n1为二叉树中度为1的结点数,则有:
n=n0+n1+n2 (6-1)
在二叉树中,除根结点外,其余结点都有唯一的一个进入分支。设B为二叉树中的分支数,那么有:
B=n-1 (6-2)
这些分支是由度为1和度为2的结点发出的,一个度为1的结点发出一个分支,一个度为2的结点发出两个分支,所以有:
B=n1+2n2 (6-3)
综合(6-1)、(6-2)、(6-3)式可以得到:
n0=n2+1
性质4 具有n个结点的完全二叉树的深度k为[log2n]+1。
证明 根据完全二叉树的定义和性质2可知,当一棵完全二叉树的深度为k、结点个数为n时,有
2k-1-1<n≤2k-1
即 2k-1≤n<2k
对不等式取对数,有
k-1≤log2n<k
由于k是整数,所以有k=[log2n]+1。
性质5 对于具有n个结点的完全二叉树,如果按照从上至下和从左到右的顺序对二叉树中的所有结点从1开始顺序编号,则对于任意的序号为i的结点,有:
(1)如果i>1,则序号为i的结点的双亲结点的序号为i/2(“/”表示整除);如果i=1,则序号为i的结点是根结点,无双亲结点。
(2)如果2i≤n,则序号为i的结点的左孩子结点的序号为2i;如果2i>n,则序号为i的结点无左孩子。
(3)如果2i+1≤n,则序号为i的结点的右孩子结点的序号为2i+1;如果2i+1>n,则序号为i的结点无右孩子。
此外,若对二叉树的根结点从0开始编号,则相应的i号结点的双亲结点的编号为(i-1)/2,左孩子的编号为2i+1,右孩子的编号为2i+2。
此性质可采用数学归纳法证明。证明略。
6.2 基本操作与存储实现
6.2.1 二叉树的存储
1.顺序存储结构
所谓二叉树的顺序存储,就是用一组连续的存储单元存放二叉树中的结点。一般是按照二叉树结点从上至下、从左到右的顺序存储。这样结点在存储位置上的前驱后继关系并不一定就是它们在逻辑上的邻接关系,然而只有通过一些方法确定某结点在逻辑上的前驱结点和后继结点,这种存储才有意义。因此,依据二叉树的性质,完全二叉树和满二叉树采用顺序存储比较合适,树中结点的序号可以唯一地反映出结点之间的逻辑关系,这样既能够最大可能地节省存储空间,又可以利用数组元素的下标值确定结点在二叉树中的位置,以及结点之间的关系。图6.4 给出的图6.3(a)所示的完全二叉树的顺序存储示意。
对于一般的二叉树,如果仍按从上至下和从左到右的顺序将树中的结点顺序存储在一维数组中,则数组元素下标之间的关系不能够反映二叉树中结点之间的逻辑关系,只有增添一些并不存在的空结点,使之成为一棵完全二叉树的形式,然后再用一维数组顺序存储。如图6.5给出了一棵一般二叉树改造后的完全二叉树形态和其顺序存储状态示意图。显然,这种存储对于需增加许多空结点才能将一棵二叉树改造成为一棵完全二叉树的存储时,会造成空间的大量浪费,不宜用顺序存储结构。最坏的情况是右单支树,如图6.6 所示,一棵深度为k的右单支树,只有k个结点,却需分配2k-1个存储单元。
A | B | C | D | E | F | G | H | I | J |
数组下标 0 1 2 3 4 5 6 7 8 9
图6.4 完全二叉树的顺序存储示意图
![]() | ![]() |
(a) 一棵二叉树 (b) 改造后的完全二叉树
A | B | C | ∧ | D | E | ∧ | ∧ | ∧ | F | ∧ | ∧ | G |
(c) 改造后完全二叉树顺序存储状态
图6.5 一般二叉树及其顺序存储示意图
![]() | ![]() |
(a) 一棵右单支二叉树 (b) 改造后的右单支树对应的完全二叉树
A | ∧ | B | ∧ | ∧ | ∧ | C | ∧ | ∧ | ∧ | ∧ | ∧ | ∧ | ∧ | D |
(c) 单支树改造后完全二叉树的顺序存储状态
图6.6 右单支二叉树及其顺序存储示意图
二叉树的顺序存储表示可描述为:
#define MAXNODE /*二叉树的最大结点数*/
typedef elemtype SqBiTree[MAXNODE] /*0号单元存放根结点*/
SqBiTree bt;
即将bt定义为含有MAXNODE个elemtype类型元素的一维数组。
2.链式存储结构
所谓二叉树的链式存储结构是指,用链表来表示一棵二叉树,即用链来指示着元素的逻辑关系。通常有下面两种形式。
(1)二叉链表存储
链表中每个结点由三个域组成,除了数据域外,还有两个指针域,分别用来给出该结点左孩子和右孩子所在的链结点的存储地址。结点的存储的结构为:
![]() |
其中,data域存放某结点的数据信息;lchild与rchild分别存放指向左孩子和右孩子的指针,当左孩子或右孩子不存在时,相应指针域值为空(用符号∧或NULL表示)。
图6.7(a)给出了图6.3(b)所示的一棵二叉树的二叉链表示。
二叉链表也可以带头结点的方式存放,如图6.7(b)所示。
|
|
|
头指针bt 头结点指针bt
![]() |
![]() |
(a) 带头指针的二叉链表 (b) 带头结点的二叉链表
图6.7 图6.3(b)所示二叉树的二叉链表表示示意图
(2)三叉链表存储
每个结点由四个域组成,具体结构为:
其中,data、lchild以及rchild三个域的意义同二叉链表结构;parent域为指向该结点双亲结点的指针。这种存储结构既便于查找孩子结点,又便于查找双亲结点;但是,相对于二叉链表存储结构而言,它增加了空间开销。
图6.8给出了图6.3(b)所示的一棵二叉树的三叉链表示。
图6.8 图6.3(b)所示二叉树的三叉链表表示示意图
尽管在二叉链表中无法由结点直接找到其双亲,但由于二叉链表结构灵活,操作方便,对于一般情况的二叉树,甚至比顺序存储结构还节省空间。因此,二叉链表是最常用的二叉树存储方式。本书后面所涉及到的二叉树的链式存储结构不加特别说明的都是指二叉链表结构。
二叉树的二叉链表存储表示可描述为:
typedef struct BiTNode{
elemtype data;
struct BiTNode *lchild;*rchild; /*左右孩子指针*/
}BiTNode,*BiTree;
即将BiTree定义为指向二叉链表结点结构的指针类型。
6.2.2 二叉树的基本操作及实现
1.二叉树的基本操作
二叉树的基本操作通常有以下几种:
(1)Initiate(bt)建立一棵空二叉树。
(2)Create(x,lbt,rbt)生成一棵以x为根结点的数据域信息,以二叉树lbt和rbt为左子树和右子树的二叉树。
(3)InsertL(bt,x,parent)将数据域信息为x的结点插入到二叉树bt中作为结点parent的左孩子结点。如果结点parent原来有左孩子结点,则将结点parent原来的左孩子结点作为结点x的左孩子结点。
(4)InsertR(bt,x,parent)将数据域信息为x的结点插入到二叉树bt中作为结点parent的右孩子结点。如果结点parent原来有右孩子结点,则将结点parent原来的右孩子结点作为结点x的右孩子结点。
(5)DeleteL(bt,parent)在二叉树bt中删除结点parent的左子树。
(6)DeleteR(bt,parent)在二叉树bt中删除结点parent的右子树。
(7)Search(bt,x)在二叉树bt中查找数据元素x。
(8)Traverse(bt)按某种方式遍历二叉树bt的全部结点。
2.算法的实现
算法的实现依赖于具体的存储结构,当二叉树采用不同的存储结构时,上述各种操作的实现算法是不同的。下面讨论基于二叉链表存储结构的上述操作的实现算法。
(1)Initiate(bt)初始建立二叉树bt,并使bt指向头结点。在二叉树根结点前建立头结点,就如同在单链表前建立的头结点,可以方便后边的一些操作实现。
int Initiate (BiTree *bt)
{/*初始化建立二叉树*bt的头结点*/
if((*bt=(BiTNode *)malloc(sizeof(BiTNode)))==NULL)
return 0;
*bt->lchild=NULL;
*bt->rchild=NULL;
return 1;
}
算法 6.1
(2)Create(x,lbt,rbt)建立一棵以x为根结点的数据域信息,以二叉树lbt和rbt为左右子树的二叉树。建立成功时返回所建二叉树结点的指针;建立失败时返回空指针。
BiTree Create(elemtype x,BiTree lbt,BiTree rbt)
{/*生成一棵以x为根结点的数据域值以lbt和rbt为左右子树的二叉树*/
BiTree p;
if ((p=(BiTNode *)malloc(sizeof(BiTNode)))==NULL) return NULL;
p->data=x;
p->lchild=lbt;
p->rchild=rbt;
return p;
}
算法 6.2
(3)InsertL(bt,x,parent)
BiTree InsertL(BiTree bt,elemtype x,BiTree parent)
{/*在二叉树bt的结点parent的左子树插入结点数据元素x*/
BiTree p;
if (parent==NULL)
{ printf(“\n插入出错”);
return NULL;
}
if ((p=(BiTNode *)malloc(sizeof(BiTNode)))==NULL) return NULL;
p->data=x;
p->lchild=NULL;
p->rchild=NULL;
if (parent->lchild==NULL) parent->lchild=p;
else {p->lchild=parent->lchild;
parent->lchild=p;
}
return bt;
}
算法 6.3
(4)InsertR(bt,x,parent)功能类同于(3),算法略。
(5)DeleteL(bt,parent)在二叉树bt中删除结点parent的左子树。当parent或parent的左孩子结点为空时删除失败。删除成功时返回根结点指针;删除失败时返回空指针。
BiTree DeleteL(BiTree bt,BiTree parent)
{/*在二叉树bt中删除结点parent的左子树*/
BiTree p;
if (parent==NULL||parent->lchild==NULL)
{ printf(“\n删除出错”);
return NULL’
}
p=parent->lchild;
parent->lchild=NULL;
free(p); /*当p为非叶子结点时,这样删除仅释放了所删子树根结点的空间,*/
/*若要删除子树分支中的结点,需用后面介绍的遍历操作来实现。*/
return br;
}
算法 6.4
(6)DeleteR(bt,parent)功能类同于(5),只是删除结点parent的右子树。算法略。
操作Search(bt,x)实际是遍历操作Traverse(bt)的特例,关于二叉树的遍历操作的实现,将在下一节中重点介绍。
6.3 二叉树的遍历
6.3.1 二叉树的遍历方法及递归实现
二叉树的遍历是指按照某种顺序访问二叉树中的每个结点,使每个结点被访问一次且仅被访问一次。
遍历是二叉树中经常要用到的一种操作。因为在实际应用问题中,常常需要按一定顺序对二叉树中的每个结点逐个进行访问,查找具有某一特点的结点,然后对这些满足条件的结点进行处理。
通过一次完整的遍历,可使二叉树中结点信息由非线性排列变为某种意义上的线性序列。也就是说,遍历操作使非线性结构线性化。
由二叉树的定义可知,一棵由根结点、根结点的左子树和根结点的右子树三部分组成。因此,只要依次遍历这三部分,就可以遍历整个二叉树。若以D、L、R分别表示访问根结点、遍历根结点的左子树、遍历根结点的右子树,则二叉树的遍历方式有六种:DLR、LDR、LRD、DRL、RDL和RLD。如果限定先左后右,则只有前三种方式,即DLR(称为先序遍历)、LDR(称为中序遍历)和LRD(称为后序遍历)。
1.先序遍历(DLR)
先序遍历的递归过程为:若二叉树为空,遍历结束。否则,
(1)访问根结点;
(2)先序遍历根结点的左子树;
(3)先序遍历根结点的右子树。
先序遍历二叉树的递归算法如下:
void PreOrder(BiTree bt)
{/*先序遍历二叉树bt*/
if (bt==NULL) return; /*递归调用的结束条件*/
Visite(bt->data); /*访问结点的数据域*/
PreOrder(bt->lchild); /*先序递归遍历bt的左子树*/
PreOrder(bt->rchild); /*先序递归遍历bt的右子树*/
}
算法 6.5
对于图6图6.3(b)所示的二叉树,按先序遍历所得到的结点序列为:
A B D G C E F
2.中序遍历(LDR)
中序遍历的递归过程为:若二叉树为空,遍历结束。否则,
(1)中序遍历根结点的左子树;
(2)访问根结点;
(3)中序遍历根结点的右子树。
中序遍历二叉树的递归算法如下:
void InOrder(BiTree bt)
{/*中序遍历二叉树bt*/
if (bt==NULL) return; /*递归调用的结束条件*/
InOrder(bt->lchild); /*中序递归遍历bt的左子树*/
Visite(bt->data); /*访问结点的数据域*/
InOrder(bt->rchild); /*中序递归遍历bt的右子树*/
}
算法 6.6
对于图6.3(b)所示的二叉树,按中序遍历所得到的结点序列为:
D G B A E C F
3.后序遍历(LRD)
后序遍历的递归过程为:若二叉树为空,遍历结束。否则,
(1)后序遍历根结点的左子树;
(2)后序遍历根结点的右子树。
(3)访问根结点;
后序遍历二叉树的递归算法如下:
void PostOrder(BiTree bt)
{/*后序遍历二叉树bt*/
if (bt==NULL) return; /*递归调用的结束条件*/
PostOrder(bt->lchild); /*后序递归遍历bt的左子树*/
PostOrder(bt->rchild); /*后序递归遍历bt的右子树*/
Visite(bt->data); /*访问结点的数据域*/
}
算法 6.7
对于图图6.3(b)所示的二叉树,按先序遍历所得到的结点序列为:
G D B E F C A
4.层次遍历
所谓二叉树的层次遍历,是指从二叉树的第一层(根结点)开始,从上至下逐层遍历,在同一层中,则按从左到右的顺序对结点逐个访问。对于图6.3(b)所示的二叉树,按层次遍历所得到的结果序列为:
A B C D E F G
下面讨论层次遍历的算法。
由层次遍历的定义可以推知,在进行层次遍历时,对一层结点访问完后,再按照它们的访问次序对各个结点的左孩子和右孩子顺序访问,这样一层一层进行,先遇到的结点先访问,这与队列的操作原则比较吻合。因此,在进行层次遍历时,可设置一个队列结构,遍历从二叉树的根结点开始,首先将根结点指针入队列,然后从对头取出一个元素,每取一个元素,执行下面两个操作:
(1)访问该元素所指结点;
(2)若该元素所指结点的左、右孩子结点非空,则将该元素所指结点的左孩子指针和右孩子指针顺序入队。
此过程不断进行,当队列为空时,二叉树的层次遍历结束。
在下面的层次遍历算法中,二叉树以二叉链表存放,一维数组Queue[MAXNODE]用以实现队列,变量front和rear分别表示当前对首元素和队尾元素在数组中的位置。
void LevelOrder(BiTree bt)
/*层次遍历二叉树bt*/
{ BiTree Queue[MAXNODE];
int front,rear;
if (bt==NULL) return;
front=-1;
rear=0;
queue[rear]=bt;
while(front!=rear)
{front++;
Visite(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;
}
}
}
算法 6.8
6.3.2 二叉树遍历的非递归实现
前面给出的二叉树先序、中序和后序三种遍历算法都是递归算法。当给出二叉树的链式存储结构以后,用具有递归功能的程序设计语言很方便就能实现上述算法。然而,并非所有程序设计语言都允许递归;另一方面,递归程序虽然简洁,但可读性一般不好,执行效率也不高。因此,就存在如何把一个递归算法转化为非递归算法的问题。解决这个问题的方法可以通过对三种遍历方法的实质过程的分析得到。
如图6.3(b)所示的二叉树,对其进行先序、中序和后序遍历都是从根结点A开始的,且在遍历过程中经过结点的路线是一样的,只是访问的时机不同而已。图6.9中所示的从根结点左外侧开始,由根结点右外侧结束的曲线,为遍历图6.3(b)的路线。沿着该路线按△标记的结点读得的序列为先序序列,按*标记读得的序列为中序序列,按⊕标记读得的序列为后序序列。
然而,这一路线正是从根结点开始沿左子树深入下去,当深入到最左端,无法再深入下去时,则返回,再逐一进入刚才深入时遇到结点的右子树,再进行如此的深入和返回,直到最后从根结点的右子树返回到根结点为止。先序遍历是在深入时遇到结点就访问,中序遍历是在从左子树返回时遇到结点访问,后序遍历是在从右子树返回时遇到结点访问。
![]() |
图6.9 遍历图6.3(b)的路线示意图
在这一过程中,返回结点的顺序与深入结点的顺序相反,即后深入先返回,正好符合栈结构后进先出的特点。因此,可以用栈来帮助实现这一遍历路线。其过程如下。
在沿左子树深入时,深入一个结点入栈一个结点,若为先序遍历,则在入栈之前访问之;当沿左分支深入不下去时,则返回,即从堆栈中弹出前面压入的结点,若为中序遍历,则此时访问该结点,然后从该结点的右子树继续深入;若为后序遍历,则将此结点再次入栈,然后从该结点的右子树继续深入,与前面类同,仍为深入一个结点入栈一个结点,深入不下去再返回,直到第二次从栈里弹出该结点,才访问之。
(1)先序遍历的非递归实现
在下面算法中,二叉树以二叉链表存放,一维数组stack[MAXNODE]用以实现栈,变量top用来表示当前栈顶的位置。
void NRPreOrder(BiTree bt)
{/*非递归先序遍历二叉树*/
BiTree stack[MAXNODE],p;
int top;
if (bt==NULL) return;
top=0;
p=bt;
while(!(p==NULL&&top==0))
{ while(p!=NULL)
{ Visite(p->data); /*访问结点的数据域*/
if (top<MAXNODE-1) /*将当前指针p压栈*/
{ stack[top]=p;
top++;
}
else { printf(“栈溢出”);
return;
}
p=p->lchild; /*指针指向p的左孩子*/
}
if (top<=0) return; /*栈空时结束*/
else{ top--;
p=stack[top]; /*从栈中弹出栈顶元素*/
p=p->rchild; /*指针指向p的右孩子结点*/
}
}
}
算法 6.9
对于图6.3(b)所示的二叉树,用该算法进行遍历过程中,栈stack和当前指针p的变化情况以及树中各结点的访问次序如表6.1所示。
表6.1 二叉树先序非递归遍历过程
步骤 | 指针p | 栈stack内容 | 访问结点值 |
初态 | A | 空 |
|
1 | B | A | A |
2 | D | A,B | B |
3 | ∧ | A,B,D | D |
4 | G | A,B |
|
5 | ∧ | A,B,G | G |
6 | ∧ | A,B |
|
7 | ∧ | A |
|
8 | C | 空 |
|
9 | E | C | C |
10 | ∧ | C,E | E |
11 | ∧ | C |
|
12 | F | 空 |
|
13 | ∧ | F | F |
14 | ∧ | 空 |
|
(2)中序遍历的非递归实现
中序遍历的非递归算法的实现,只需将先序遍历的非递归算法中的Visite(p->data)移到p=stack[top]和p=p->rchild之间即可。
(3)后序遍历的非递归实现
由前面的讨论可知,后序遍历与先序遍历和中序遍历不同,在后序遍历过程中,结点在第一次出栈后,还需再次入栈,也就是说,结点要入两次栈,出两次栈,而访问结点是在第二次出栈时访问。因此,为了区别同一个结点指针的两次出栈,设置一标志flag,令:
flag=
当结点指针进、出栈时,其标志flag也同时进、出栈。因此,可将栈中元素的数据类型定义为指针和标志flag合并的结构体类型。定义如下:
typedef struct {
BiTree link;
int flag;
}stacktype;
后序遍历二叉树的非递归算法如下。在算法中,一维数组stack[MAXNODE]用于实现栈的结构,指针变量p指向当前要处理的结点,整型变量top用来表示当前栈顶的位置,整型变量sign为结点p的标志量。
void NRPostOrder(BiTree bt)
/*非递归后序遍历二叉树bt*/
{ stacktype stack[MAXNODE];
BiTree p;
int top,sign;
if (bt==NULL) return;
top=-1 /*栈顶位置初始化*/
p=bt;
while (!(p==NULL && top==-1))
{ if (p!=NULL) /*结点第一次进栈*/
{ top++;
stack[top].link=p;
stack[top].flag=1;
p=p->lchild; /*找该结点的左孩子*/
}
else { p=stack[top].link;
sign=stack[top].flag;
top--;
if (sign==1) /*结点第二次进栈*/
{top++;
stack[top].link=p;
stack[top].flag=2; /*标记第二次出栈*/
p=p->rchild;
}
else { Visite(p->data); /*访问该结点数据域值*/
p=NULL;
}
}
}
}
算法 6.10
6.3.3 由遍历序列恢复二叉树
从前面讨论的二叉树的遍历知道,任意一棵二叉树结点的先序序列和中序序列都是唯一的。反过来,若已知结点的先序序列和中序序列,能否确定这棵二叉树呢?这样确定的二叉树是否是唯一的呢?回答是肯定的。
根据定义,二叉树的先序遍历是先访问根结点,其次再按先序遍历方式遍历根结点的左子树,最后按先序遍历方式遍历根结点的右子树。这就是说,在先序序列中,第一个结点一定是二叉树的根结点。另一方面,中序遍历是先遍历左子树,然后访问根结点,最后再遍历右子树。这样,根结点在中序序列中必然将中序序列分割成两个子序列,前一个子序列是根结点的左子树的中序序列,而后一个子序列是根结点的右子树的中序序列。根据这两个子序列,在先序序列中找到对应的左子序列和右子序列。在先序序列中,左子序列的第一个结点是左子树的根结点,右子序列的第一个结点是右子树的根结点。这样,就确定了二叉树的三个结点。同时,左子树和右子树的根结点又可以分别把左子序列和右子序列划分成两个子序列,如此递归下去,当取尽先序序列中的结点时,便可以得到一棵二叉树。
同样的道理,由二叉树的后序序列和中序序列也可唯一地确定一棵二叉树。因为,依据后序遍历和中序遍历的定义,后序序列的最后一个结点,就如同先序序列的第一个结点一样,可将中序序列分成两个子序列,分别为这个结点的左子树的中序序列和右子树的中序序列,再拿出后序序列的倒数第二个结点,并继续分割中序序列,如此递归下去,当倒着取取尽后序序列中的结点时,便可以得到一棵二叉树。
下面通过一个例子,来给出右二叉树的先序序列和中序序列构造唯一的一棵二叉树的实现算法。
已知一棵二叉树的先序序列与中序序列分别为:
A B C D E F G H I
B C A E D G H F I
试恢复该二叉树。
首先,由先序序列可知,结点A是二叉树的根结点。其次,根据中序序列,在A之前的所有结点都是根结点左子树的结点,在A之后的所有结点都是根结点右子树的结点,由此得到图6.10 (a)所示的状态。然后,再对左子树进行分解,得知B是左子树的根结点,又从中序序列知道,B的左子树为空,B的右子树只有一个结点C。接着对A的右子树进行分解,得知A的右子树的根结点为D;而结点D把其余结点分成两部分,即左子树为E,右子树为F、G、H、I,如图6.10 (b)所示。接下去的工作就是按上述原则对D的右子树继续分解下去,最后得到如图6.10 (c)的整棵二叉树。
![]() | ![]() |
(a) (b) (c)
图6.10 一棵二叉树的恢复过程示意
上述过程是一个递归过程,其递归算法的思想是:先根据先序序列的第一个元素建立根结点;然后在中序序列中找到该元素,确定根结点的左、右子树的中序序列;再在先序序列中确定左、右子树的先序序列;最后由左子树的先序序列与中序序列建立左子树,由右子树的先序序列与中序序列建立右子树。
下面给出用C语言描述的该算法。假设二叉树的先序序列和中序序列分别存放在一维数组preod[ ]与inod[ ]中,并假设二叉树各结点的数据值均不相同。
void ReBiTree(char preod[ ],char inod[ ],int n,BiTree root)
/*n为二叉树的结点个数,root为二叉树根结点的存储地址*/
{ if (n≤0) root=NULL;
else PreInOd(preod,inod,1,n,1,n,&root);
}
算法 6.11
void PreInOd(char preod[ ],char inod[ ],int i,j,k,h,BiTree *t)
{* t=(BiTNode *)malloc(sizeof(BiTNode));
*t->data=preod[i];
m=k;
while (inod[m]!=preod[i]) m++;
if (m==k) *t->lchild=NULL
else PreInOd(preod,inod,i+1,i+m-k,k,m-1,&t->lchild);
if (m==h) *t->rchild=NULL
else PreInOd(preod,inod,i+m-k+1,j,m+1,h,&t->rchild);
}
算法 6.12
需要说明的是,数组preod和inod的元素类型可根据实际需要来设定,这里设为字符型。另外,如果只知道二叉树的先序序列和后序序列,则不能唯一地确定一棵二叉树。
6.3.4 不用栈的二叉树遍历的非递归方法
前面介绍的二叉树的遍历算法可分为两类,一类是依据二叉树结构的递归性,采用递归调用的方式来实现;另一类则是通过堆栈或队列来辅助实现。采用这两类方法对二叉树进行遍历时,递归调用和栈的使用都带来额外空间增加,递归调用的深度和栈的大小是动态变化的,都与二叉树的高度有关。因此,在最坏的情况下,即二叉树退化为单支树的情况下,递归的深度或栈需要的存储空间等于二叉树中的结点数。
还有一类二叉树的遍历算法,就是不用栈也不用递归来实现。常用的不用栈的二叉树遍历的非递归方法有以下三种:
(1)对二叉树采用三叉链表存放,即在二叉树的每个结点中增加一个双亲域parent,这样,在遍历深入到不能再深入时,可沿着走过的路径回退到任何一棵子树的根结点,并再向另一方向走。由于这一方法的实现是在每个结点的存储上又增加一个双亲域,故其存储开销就会增加。
(2)采用逆转链的方法,即在遍历深入时,每深入一层,就将其再深入的孩子结点的地址取出,并将其双亲结点的地址存入,当深入不下去需返回时,可逐级取出双亲结点的地址,沿原路返回。虽然此种方法是在二叉链表上实现的,没有增加过多的存储空间,但在执行遍历的过程中改变子女指针的值,这既是以时间换取空间,同时当有几个用户同时使用这个算法时将会发生问题。
(3)在线索二叉树上的遍历,即利用具有n个结点的二叉树中的叶子结点和一度结点的n+1个空指针域,来存放线索,然后在这种具有线索的二叉树上遍历时,就可不需要栈,也不需要递归了。有关线索二叉树的详细内容,将在下一节中讨论。
6.4 线索二叉树
6.4.1 线索二叉树的定义及结构
1.线索二叉树的定义
按照某种遍历方式对二叉树进行遍历,可以把二叉树中所有结点排列为一个线性序列。在该序列中,除第一个结点外,每个结点有且仅有一个直接前驱结点;除最后一个结点外,每个结点有且仅有一个直接后继结点。但是,二叉树中每个结点在这个序列中的直接前驱结点和直接后继结点是什么,二叉树的存储结构中并没有反映出来,只能在对二叉树遍历的动态过程中得到这些信息。为了保留结点在某种遍历序列中直接前驱和直接后继的位置信息,可以利用二叉树的二叉链表存储结构中的那些空指针域来指示。这些指向直接前驱结点和指向直接后继结点的指针被称为线索(thread),加了线索的二叉树称为线索二叉树。
线索二叉树将为二叉树的遍历提供许多遍历。
2.线索二叉树的结构
一个具有n个结点的二叉树若采用二叉链表存储结构,在2n个指针域中只有n-1个指针域是用来存储结点孩子的地址,而另外n+1个指针域存放的都是NULL。因此,可以利用某结点空的左指针域(lchild)指出该结点在某种遍历序列中的直接前驱结点的存储地址,利用结点空的右指针域(rchild)指出该结点在某种遍历序列中的直接后继结点的存储地址;对于那些非空的指针域,则仍然存放指向该结点左、右孩子的指针。这样,就得到了一棵线索二叉树。
由于序列可由不同的遍历方法得到,因此,线索树有先序线索二叉树、中序线索二叉树和后序线索二叉树三种。把二叉树改造成线索二叉树的过程称为线索化。
对图6.3 (b)所示的二叉树进行线索化,得到先序线索二叉树、中序线索二叉树和后序线索二叉树分别如图6.11(a)、(b)、(c)所示。图中实线表示指针,虚线表示线索。
那么,下面的问题是在存储中,如何区别某结点的指针域内存放的是指针还是线索?通常可以采用下面两种方法来实现。
(1)为每个结点增设两个标志位域ltag和rtag,令:
ltag=
rtag=
每个标志位令其只占一个bit,这样就只需增加很少的存储空间。这样结点的结构为:
|
|
|
|
|
(2)不改变结点结构,仅在作为线索的地址前加一个负号,即负的地址表示线索,正的地址表示指针。
![]() | ![]() |
(a) 先序线索二叉树 (b) 中序线索二叉树
![]() |
(c) 后序线索二叉树
图6.11 线索二叉树
这里我们按第一种方法来介绍线索二叉树的存储。为了将二叉树中所有空指针域都利用上,以及操作便利的需要,在存储线索二叉树时往往增设一头结点,其结构与其它线索二叉树的结点结构一样,只是其数据域不存放信息,其左指针域指向二叉树的根结点,右指针域指向自己。而原二叉树在某序遍历下的第一个结点的前驱线索和最后一个结点的后继线索都指向该头结点。
图6.12给出了图6.11(b)所示的中序线索树的完整的线索树存储。
2p(162)
图6.12 线索树中序线索二叉树的存储示意
6.4.2 线索二叉树的基本操作实现
在线索二叉树中,结点的结构可以定义为如下形式:
typedef char elemtype;
typedef struct BiThrNode {
elemtype data;
struct BiThrNode *lchild;
struct BiThrNode *rchild;
unsigned ltag:1;
unsigned rtag:1;
}BiThrNodeType,*BiThrTree;
下面以中序线索二叉树为例,讨论线索二叉树的建立、线索二叉树的遍历以及在线索二叉树上查找前驱结点、查找后继结点、插入结点和删除结点等操作的实现算法。
1.建立一棵中序线索二叉树
建立线索二叉树,或者说对二叉树线索化,实质上就是遍历一棵二叉树。在遍历过程中,访问结点的操作是检查当前结点的左、右指针域是否为空,如果为空,将它们改为指向前驱结点或后继结点的线索。为实现这一过程,设指针pre始终指向刚刚访问过的结点,即若指针p指向当前结点,则pre指向它的前驱,以便增设线索。
另外,在对一棵二叉树加线索时,必须首先申请一个头结点,建立头结点与二叉树的根结点的指向关系,对二叉树线索化后,还需建立最后一个结点与头结点之间的线索。
下面是建立中序线索二叉树的递归算法,其中pre为全局变量。
int InOrderThr(BiThrTree *head,BiThrTree T)
{/*中序遍历二叉树T,并将其中序线索化,*head指向头结点。*/
if (!(*head =(BiThrNodeType*)malloc(sizeof(BiThrNodeType)))) return 0;
(*head)->ltag=0; (*head)->rtag=1; /*建立头结点*/
(*head)->rchild=*head; /*右指针回指*/
if (!T) (*head)->lchild =*head; /*若二叉树为空,则左指针回指*/
else { (*head)->lchild=T; pre= head;
InThreading(T); /*中序遍历进行中序线索化*/
pre->rchild=*head; pre->rtag=1; /*最后一个结点线索化*/
(*head)->rchild=pre;
}
return 1;
}
算法 6.13
void InTreading(BiThrTree p)
{/*中序遍历进行中序线索化*/
if (p)
{ InThreading(p->lchild); /*左子树线索化*/
if (!p->lchild) /*前驱线索*/
{ p->ltag=1; p->lchild=pre;
}
if (!pre->rchild) /*后继线索*/
{ pre->rtag=1; pre->rchild=p;
}
pre=p;
InThreading(p->rchild); /*右子树线索化*/
}
}
算法 6.14
2.在中序线索二叉树上查找任意结点的中序前驱结点
对于中序线索二叉树上的任一结点,寻找其中序的前驱结点,有以下两种情况:
(1)如果该结点的左标志为1,那么其左指针域所指向的结点便是它的前驱结点;
(2)如果该结点的左标志为0,表明该结点有左孩子,根据中序遍历的定义,它的前驱结点是以该结点的左孩子为根结点的子树的最右结点,即沿着其左子树的右指针链向下查找,当某结点的右标志为1时,它就是所要找的前驱结点。
在中序线索二叉树上寻找结点p的中序前驱结点的算法如下:
BiThrTree InPreNode(BiThrTree p)
{/*在中序线索二叉树上寻找结点p的中序前驱结点*/
BiThrTree pre;
pre=p->lchild;
if (p->ltag!=1)
while (pre->rtag==0) pre=pre->rchild;
return(pre);
}
算法 6.15
3.在中序线索二叉树上查找任意结点的中序后继结点
对于中序线索二叉树上的任一结点,寻找其中序的后继结点,有以下两种情况:
(1)如果该结点的右标志为1,那么其右指针域所指向的结点便是它的后继结点;
(2)如果该结点的右标志为0,表明该结点有右孩子,根据中序遍历的定义,它的前驱结点是以该结点的右孩子为根结点的子树的最左结点,即沿着其右子树的左指针链向下查找,当某结点的左标志为1时,它就是所要找的后继结点。
在中序线索二叉树上寻找结点p的中序后继结点的算法如下:
BiThrTree InPostNode(BiThrTree p)
{/*在中序线索二叉树上寻找结点p的中序后继结点*/
BiThrTree post;
post=p->rchild;
if (p->rtag!=1)
while (post->rtag==0) post=post->lchild;
return(post);
}
算法 6.16
以上给出的仅是在中序线索二叉树中寻找某结点的前驱结点和后继结点的算法。在前序线索二叉树中寻找结点的后继结点以及在后序线索二叉树中寻找结点的前驱结点可以采用同样的方法分析和实现。在此就不再讨论了。
4.在中序线索二叉树上查找任意结点在先序下的后继
这一操作的实现依据是:若一个结点是某子树在中序下的最后一个结点,则它必是该子树在先序下的最后一个结点。该结论可以用反证法证明。
下面就依据这一结论,讨论在中序线索二叉树上查找某结点在先序下后继结点的情况。设开始时,指向此某结点的指针为p。
(1)若待确定先序后继的结点为分支结点,则又有两种情况:
① 当p->ltag=0时,p->lchild为p在先序下的后继;
② 当p->ltag=1时,p->rchild为p在先序下的后继。
(2)若待确定先序后继的结点为叶子结点,则也有两种情况:
① 若p->rchild是头结点,则遍历结束;
② 若p->rchild不是头结点,则p结点一定是以p->rchild结点为根的左子树中在中序遍历下的最后一个结点,因此p结点也是在该子树中按先序遍历的最后一个结点。此时,若p->rchild结点有右子树,则所找结点在先序下的后继结点的地址为p->rchild->rchild;若p->rchild为线索,则让p=p->rchild,反复情况(2)的判定。
在中序线索二叉树上寻找结点p的先序后继结点的算法如下:
BiThrTree IPrePostNode(BiThrTree head,BiThrTree p)
{/*在中序线索二叉树上寻找结点p的先序的后继结点,head为线索树的头结点*/
BiThrTree post;
if (p->ltag==0) post=p->lchild;
else { post=p;
while (post->rtag==1&&post->rchild!=head) post=post->rchild;
post=post->rchild;
}
return(post);
}
算法 6.17
5.在中序线索二叉树上查找任意结点在后序下的前驱
这一操作的实现依据是:若一个结点是某子树在中序下的第一个结点,则它必是该子树在后序下的第一个结点。该结论可以用反证法证明。
下面就依据这一结论,讨论在中序线索二叉树上查找某结点在后序下前驱结点的情况。设开始时,指向此某结点的指针为p。
(1)若待确定后序前驱的结点为分支结点,则又有两种情况:
① 当p->ltag=0时,p->lchild为p在后序下的前驱;
② 当p->ltag=1时,p->rchild为p在后序下的前驱。
(2)若待确定后序前驱的结点为叶子结点,则也有两种情况:
① 若p->lchild是头结点,则遍历结束;
② 若p->lchild不是头结点,则p结点一定是以p->lchild结点为根的右子树中在中中序遍历下的第一个结点,因此p结点也是在该子树中按后序遍历的第一个结点。此时,若p->lchild结点有左子树,则所找结点在后序下的前驱结点的地址为p->lchild->lchild;若p->lchild为线索,则让p=p->lchild,反复情况(2)的判定。
在中序线索二叉树上寻找结点p的后序前驱结点的算法如下:
BiThrTree IPostPretNode(BiThrTree head,BiThrTree p)
{/*在中序线索二叉树上寻找结点p的先序的后继结点,head为线索树的头结点*/
BiThrTree pre;
if (p->rtag==0) pre=p->rchild;
else { pre=p;
while (pre->ltag==1&& post->rchild!=head) pre=pre->lchild;
pre=pre->lchild;
}
return(pre);
}
算法 6.18
6.在中序线索二叉树上查找值为x的结点
利用在中序线索二叉树上寻找后继结点和前驱结点的算法,就可以遍历到二叉树的所有结点。比如,先找到按某序遍历的第一个结点,然后再依次查询其后继;或先找到按某序遍历的最后一个结点,然后再依次查询其前驱。这样,既不用栈也不用递归就可以访问到二叉树的所有结点。
在中序线索二叉树上查找值为x的结点,实质上就是在线索二叉树上进行遍历,将访问结点的操作具体写为那结点的值与x比较的语句。下面给出其算法:
BiThrTree Search (BiThrTree head,elemtype x)
{/*在以head为头结点的中序线索二叉树中查找值为x的结点*/
BiThrTree p;
p=head->lchild;
while (p->ltag==0&&p!=head) p=p->lchild;
while(p!=head && p->data!=x) p=InPostNode(p);
if (p==head)
{ printf(“Not Found the data!\n”);
return(0);
}
else return(p);
}
算法 6.19
7.在中序线索二叉树上的更新
线索二叉树的更新是指,在线索二叉树中插入一个结点或者删除一个结点。一般情况下,这些操作有可能破坏原来已有的线索,因此,在修改指针时,还需要对线索做相应的修改。一般来说,这个过程的代价几乎与重新进行线索化相同。这里仅讨论一种比较简单的情况,即在中序线索二叉树中插入一个结点p,使它成为结点s的右孩子。
下面分两种情况来分析:
(1)若s的右子树为空,如图6.13 (a)所示,则插入结点p之后成为图6.13 (b)所示的情形。在这种情况中,s的后继将成为p的中序后继,s成为p的中序前驱,而p成为s的右孩子。二叉树中其它部分的指针和线索不发生变化。
(2)若s的右子树非空,如图6.14 (a)所示,插入结点p之后如图6.14 (b)所示。S原来的右子树变成p的右子树,由于p没有左子树,故s成为p的中序前驱,p成为s的右孩子;又由于s原来的后继成为p的后继,因此还要将s原来的本来指向s的后继的左线索,改为指向p。
NULL NULL
![]() | ![]() |
![]() | ![]() |
(a) (b) (a) (b)
图6.13 中序线索树更新位置右子树为空 图6.14 中序线索树更新位置右子树不为空
下面给出上述操作的算法。
void InsertThrRight(BiThrTree s,BiThrTree p)
{/*在中序线索二叉树中插入结点p使其成为结点s的右孩子*/
BiThrTree w;
p->rchild=s->rchild;
p->rtag=s->rtag;
p->lchild=s;
p->ltag=1; /*将s变为p的中序前驱*/
s->rchild=p;
s->rtag=0; /*p成为s的右孩子*/
if(p->rtag==0) /*当s原来右子树不空时,找到s的后继w,变w为p的后继,p为w的前驱*/
{ w=InPostNode(p);
w->lchild=p;
}
}
算法 6.20
6.5 二叉树的应用
6.5.1 二叉树遍历的应用
在以上讨论的遍历算法中,访问结点的数据域信息,即操作Visite(bt->data)具有更一般的意义,需根据具体问题,对bt数据进行不同的操作。下面介绍几个遍历操作的典型应用。
1.查找数据元素
Search(bt,x)在bt为二叉树的根结点指针的二叉树中查找数据元素x。查找成功时返回该结点的指针;查找失败时返回空指针。
算法实现如下,注意遍历算法中的Visite(bt->data)等同于其中的一组操作步骤。
BiTree Search(BiTree bt,elemtype x)
{/*在bt为根结点指针的二叉树中查找数据元素x*/
BiTree p;
if (bt->data==x) return bt; /*查找成功返回*/
if (bt->lchild!=NULL) return(Search(bt->lchild,x));
/*在bt->lchild为根结点指针的二叉树中查找数据元素x*/
if (bt->rchild!=NULL) return(Search(bt->rchild,x));
/*在bt->rchild为根结点指针的二叉树中查找数据元素x*/
return NULL; /*查找失败返回*/
}
算法 6.21
2.统计出给定二叉树中叶子结点的数目
(1)顺序存储结构的实现
int CountLeaf1(SqBiTree bt,int k)
{/*一维数组bt[2k-1]为二叉树存储结构,k为二叉树深度,函数值为叶子数。*/
total=0;
for(i=1;i<=2k-1;i++)
{ if (bt[i]!=0)
{ if ((bt[2i]==0 && bt[2i+1]==0) || (i>(2k-1)/2))
total++;
}
}
return(total);
}
算法 6.22
(2)二叉链表存储结构的实现
int CountLeaf2(BiTree bt)
{/*开始时,bt为根结点所在链结点的指针,返回值为bt的叶子数*/
if (bt==NULL) return(0);
if (bt->lchild==NULL && bt->rchild==NULL) return(1);
return(CountLeaf2(bt->lchild)+CountLeaf2(bt->rchild));
}
算法 6.23
3.创建二叉树二叉链表存储,并显示。
设创建时,按二叉树带空指针的先序次序输入结点值,结点值类型为字符型。输出按中序输出。
CreateBinTree(BinTree *bt)是以二叉链表为存储结构建立一棵二叉树T的存储,bt为指向二叉树T根结点指针的指针。设建立时的输入序列为:AB0D00CE00F00。
建立如图6.3 (b)所示的二叉树存储。
InOrderOut(bt)为按中序输出二叉树bt的结点。
算法实现如下,注意在创建算法中,遍历算法中的Visite(bt->data)被读入结点、申请空间存储的操作所代替;在输出算法中,遍历算法中的Visite(bt->data)被c语言中的格式输出语句所代替。
void CreateBinTree(BinTree *T)
{/*以加入结点的先序序列输入,构造二叉链表*/
char ch;
scanf("\n%c",&ch);
if (ch=='0') *T=NULL; /*读入0时,将相应结点置空*/
else {*T=(BinTNode*)malloc(sizeof(BinTNode)); /*生成结点空间*/
(*T)->data=ch;
CreateBinTree(&(*T)->lchild); /*构造二叉树的左子树*/
CreateBinTree(&(*T)->rchild); /*构造二叉树的右子树*/
}
}
void InOrderOut(BinTree T)
{/*中序遍历输出二叉树T的结点值*/
if (T)
{ InOrderOut(T->lchild); /*中序遍历二叉树的左子树*/
printf("%3c",T->data); /*访问结点的数据*/
InOrderOut(T->rchild); /*中序遍历二叉树的右子树*/
}
}
main()
{BiTree bt;
CreateBinTree(&bt);
InOrderOut(bt);
}
算法 6.24
4.表达式运算
我们可以把任意一个算数表达式用一棵二叉树表示,图6.15所示为表达式3x2+x-1/x+5的二叉树表示。在表达式二叉树中,每个叶结点都是操作数,每个非叶结点都是运算符。对于一个非叶子结点,它的左、右子树分别是它的两个操作数。
![]() |
图6.15 表达式3x2+x-1/x+5的二叉树表示示意
对该二叉树分别进行先序、中序和后序遍历,可以得到表达式的三种不同表示形式。
前缀表达式 +-+*3*xxx/1x5
中缀表达式 3*x*x+x-1/x+5
后缀表达式 3xx**x+1x/-5+
中缀表达式是经常使用的算术表达式,前缀表达式和后缀表达式分别称为波兰式和逆波兰式,它们在编译程序中有着非常重要的作用。
6.5.2 最优二叉树――哈夫曼树
1.哈夫曼树的基本概念
最优二叉树,也称哈夫曼(Haffman)树,是指对于一组带有确定权值的叶结点,构造的具有最小带权路径长度的二叉树。
那么什么是二叉树的带权路径长度呢?
在前面我们介绍过路径和结点的路径长度的概念,而二叉树的路径长度则是指由根结点到所有叶结点的路径长度之和。如果二叉树中的叶结点都具有一定的权值,则可将这一概念加以推广。设二叉树具有n个带权值的叶结点,那么从根结点到各个叶结点的路径长度与相应结点权值的乘积之和叫做二叉树的带权路径长度,记为:
|
WPL= Wk·Lk
其中Wk为第k个叶结点的权值,Lk 为第k个叶结点的路径长度。如图6.16所示的二叉树,它的带权路径长度值WPL=2×2+4×2+5×2+3×2=28。
在给定一组具有确定权值的叶结点,可以构造出不同的带权二叉树。例如,给出4个叶结点,设其权值分别为1,3,5,7,我们可以构造出形状不同的多个二叉树。这些形状不同的二叉树的带权路径长度将各不相同。图6.17给出了其中5个不同形状的二叉树。
这五棵树的带权路径长度分别为:
(a)WPL=1×2+3×2+5×2+7×2=32
(b)WPL=1×3+3×3+5×2+7×1=29
(c)WPL=1×2+3×3+5×3+7×1=33
(d)WPL=7×3+5×3+3×2+1×1=43 图6.16 一个带权二叉树
(e)WPL=7×1+5×2+3×3+1×3=29
![]() | ![]() | ![]() |
(a) (b) (c)
![]() | ![]() |
(d) (e)
图6.17 具有相同叶子结点和不同带权路径长度的二叉树
由此可见,由相同权值的一组叶子结点所构成的二叉树有不同的形态和不同的带权路径长度,那么如何找到带权路径长度最小的二叉树(即哈夫曼树)呢?根据哈夫曼树的定义,一棵二叉树要使其WPL值最小,必须使权值越大的叶结点越靠近根结点,而权值越小的叶结点越远离根结点。哈夫曼(Haffman)依据这一特点提出了一种方法,这种方法的基本思想是:
(1)由给定的n个权值{W1,W2,…,Wn}构造n棵只有一个叶结点的二叉树,从而得到一个二叉树的集合F={T1,T2,…,Tn};
(2)在F中选取根结点的权值最小和次小的两棵二叉树作为左、右子树构造一棵新的二叉树,这棵新的二叉树根结点的权值为其左、右子树根结点权值之和;
(3)在集合F中删除作为左、右子树的两棵二叉树,并将新建立的二叉树加入到集合F中;
(4)重复(2)(3)两步,当F中只剩下一棵二叉树时,这棵二叉树便是所要建立的哈夫曼树。
图6.18给出了前面提到的叶结点权值集合为W={1,3,5,7}的哈夫曼树的构造过程。可以计算出其带权路径长度为29,由此可见,对于同一组给定叶结点所构造的哈夫曼树,树的形状可能不同,但带权路径长度值是相同的,一定是最小的。
|
第一步 第二步
![]() | ![]() | ![]() | ![]() | ![]() | ![]() |
![]() | |||||
![]() |
第三步 第四步
![]() | ![]() |
![]() |
图6.18 哈夫曼树的建立过程
2.哈夫曼树的构造算法
在构造哈夫曼树时,可以设置一个结构数组HuffNode保存哈夫曼树中各结点的信息,根据二叉树的性质可知,具有n个叶子结点的哈夫曼树共有2n-1个结点,所以数组HuffNode的大小设置为2n-1,数组元素的结构形式如下:
|
|
|
|
其中,weight域保存结点的权值,lchild和rchild域分别保存该结点的左、右孩子结点在数组HuffNode中的序号,从而建立起结点之间的关系。为了判定一个结点是否已加入到要建立的哈夫曼树中,可通过parent域的值来确定。初始时parent的值为-1,当结点加入到树中时,该结点parent的值为其双亲结点在数组HuffNode中的序号,就不会是-1了。
构造哈夫曼树时,首先将由n个字符形成的n个叶结点存放到数组HuffNode的前n个分量中,然后根据前面介绍的哈夫曼方法的基本思想,不断将两个小子树合并为一个较大的子树,每次构成的新子树的根结点顺序放到HuffNode数组中的前n个分量的后面。
下面给出哈夫曼树的构造算法。
#define MAXVALUE 10000 /*定义最大权值*/
#define MAXLEAF 30 /*定义哈夫曼树中叶子结点个数*/
#define MAXNODE MAXLEAF*2-1
typedef struct {
int weight;
int parent;
int lchild;
int rchild;
}HNodeType;
void HaffmanTree(HNodeType HuffNode [ ])
{/*哈夫曼树的构造算法*/
int i,j,m1,m2,x1,x2,n;
scanf(“%d”,&n); /*输入叶子结点个数*/
for (i=0;i<2*n-1;i++) /*数组HuffNode[ ]初始化*/
{ HuffNode[i].weight=0;
HuffNode[i].parent=-1;
HuffNode[i].lchild=-1;
HuffNode[i].rchild=-1;
}
for (i=0;i<n;i++) scanf(“%d”,&HuffNode[i].weight); /*输入n个叶子结点的权值*/
for (i=0;i<n-1;i++) /*构造哈夫曼树*/
{ m1=m2=MAXVALUE;
x1=x2=0;
for (j=0;j<n+i;j++)
{ if (HuffNode[j].weight<m1 && HuffNode[j].parent==-1)
{ m2=m1; x2=x1;
m1=HuffNode[j].weight; x1=j;
}
else if (HuffNode[j].weight<m2 && HuffNode[j].parent==-1)
{ m2=HuffNode[j].weight;
x2=j;
}
}
/*将找出的两棵子树合并为一棵子树*/
HuffNode[x1].parent=n+i; HuffNode[x2].parent=n+i;
HuffNode[n+i].weight= HuffNode[x1].weight+HuffNode[x2].weight;
HuffNode[n+i].lchild=x1; HuffNode[n+i].rchild=x2;
}
}
算法 6.25
3.哈夫曼树在编码问题中的应用
在数据通讯中,经常需要将传送的文字转换成由二进制字符0,1组成的二进制串,我们称之为编码。例如,假设要传送的电文为ABACCDA,电文中只含有A,B,C,D四种字符,若这四种字符采用表6.2 (a)所示的编码,则电文的代码为000010000100100111 000,长度为21。在传送电文时,我们总是希望传送时间尽可能短,这就要求电文代码尽可能短,显然,这种编码方案产生的电文代码不够短。表6.2 (b)所示为另一种编码方案,用此编码对上述电文进行编码所建立的代码为00010010101100,长度为14。在这种编码方案中,四种字符的编码均为两位,是一种等长编码。如果在编码时考虑字符出现的频率,让出现频率高的字符采用尽可能短的编码,出现频率低的字符采用稍长的编码,构造一种不等长编码,则电文的代码就可能更短。如当字符A,B,C,D采用表6.2 (c)所示的编码时,上述电文的代码为0110010101110,长度仅为13。
表6.2 字符的四种不同的编码方案
字符 编码 字符 编码 字符 编码 字符 编码
A 000 A 00 A 0 A 01
B 010 B 01 B 110 B 010
C 100 C 10 C 10 C 001
D 111 D 11 D 111 D 10
哈夫曼树可用于构造使电文的编码总长最短的编码方案。具体做法如下:设需要编码的字符集合为{d1,d2,…,dn},它们在电文中出现的次数或频率集合为{w1,w2,…,wn},以d1,d2,…,dn作为叶结点,w1,w2,…,wn作为它们的权值,构造一棵哈夫曼树,规定哈夫曼树中的左分支代表0,右分支代表1,则从根结点到每个叶结点所经过的路径分支组成的0和1的序列便为该结点对应字符的编码,我们称之为哈夫曼编码。
在哈夫曼编码树中,树的带权路径长度的含义是各个字符的码长与其出现次数的乘积之和,也就是电文的代码总长,所以采用哈夫曼树构造的编码是一种能使电文代码总长最短的不等长编码。
在建立不等长编码时,必须使任何一个字符的编码都不是另一个字符编码的前缀,这样才能保证译码的唯一性。例如表6.2 (d)的编码方案,字符A的编码01是字符B的编码010的前缀部分,这样对于代码串0101001,既是AAC的代码,也是ABD和BDA的代码,因此,这样的编码不能保证译码的唯一性,我们称之为具有二义性的译码。
然而,采用哈夫曼树进行编码,则不会产生上述二义性问题。因为,在哈夫曼树中,每个字符结点都是叶结点,它们不可能在根结点到其它字符结点的路径上,所以一个字符的哈夫曼编码不可能是另一个字符的哈夫曼编码的前缀,从而保证了译码的非二义性。
下面讨论实现哈夫曼编码的算法。实现哈夫曼编码的算法可分为两大部分:
(1)构造哈夫曼树;
(2)在哈夫曼树上求叶结点的编码。
求哈夫曼编码,实质上就是在已建立的哈夫曼树中,从叶结点开始,沿结点的双亲链域回退到根结点,每回退一步,就走过了哈夫曼树的一个分支,从而得到一位哈夫曼码值,由于一个字符的哈夫曼编码是从根结点到相应叶结点所经过的路径上各分支所组成的0,1序列,因此先得到的分支代码为所求编码的低位码,后得到的分支代码为所求编码的高位码。我们可以设置一结构数组HuffCode用来存放各字符的哈夫曼编码信息,数组元素的结构如下:
|
|
其中,分量bit为一维数组,用来保存字符的哈夫曼编码,start表示该编码在数组bit中的开始位置。所以,对于第i个字符,它的哈夫曼编码存放在HuffCode[i].bit中的从HuffCode[i].start到n的分量上。
哈夫曼编码算法描述如下。
#define MAXBIT 10 /*定义哈夫曼编码的最大长度*/
typedef struct {
int bit[MAXBIT];
int start;
}HCodeType;
void HaffmanCode ( )
{ /*生成哈夫曼编码*/
HNodeType HuffNode[MAXNODE];
HCodeType HuffCode[MAXLEAF],cd;
int i,j, c,p;
HuffmanTree (HuffNode ); /*建立哈夫曼树*/
for (i=0;i<n;i++) /*求每个叶子结点的哈夫曼编码*/
{ cd.start=n-1; c=i;
p=HuffNode[c].parent;
while(p!=0) /*由叶结点向上直到树根*/
{ if (HuffNode[p].lchild==c) cd.bit[cd.start]=0;
else cd.bit[cd.start]=1;
cd.start--; c=p;
p=HuffNode[c].parent;
}
for (j=cd.start+1;j<n;j++) /*保存求出的每个叶结点的哈夫曼编码和编码的起始位*/
HuffCode[i].bit[j]=cd.bit[j];
HuffCode[i].start=cd.start;
}
for (i=0;i<n;i++) /*输出每个叶子结点的哈夫曼编码*/
{ for (j=HuffCode[i].start+1;j<n;j++)
printf(“%ld”,HuffCode[i].bit[j]);
printf(“\n”);
}
}
算法 6.26
3.哈夫曼树在判定问题中的应用
例如,要编制一个将百分制转换为五级分制的程序。显然,此程序很简单,只要利用条件语句便可完成。如:
if (a<60) b=”bad”;
else if (a<70) b=”pass”
else if (a<80) b=”general”
else if (a<90) b=”good”
else b=”excellent”;
这个判定过程可以图6.19 (a)所示的判定树来表示。如果上述程序需反复使用,而且每次的输入量很大,则应考虑上述程序的质量问题,即其操作所需要的时间。因为在实际中,学生的成绩在五个等级上的分布是不均匀的,假设其分布规律如下表所示:
分数 0-59 60-69 70-79 80-89 90-100
比例数 0.05 0.15 0.40 0.30 0.10
则80%以上的数据需进行三次或三次以上的比较才能得出结果。假定以5,15,40,30和10为权构造一棵有五个叶子结点的哈夫曼树,则可得到如图6.19 (b)所示的判定过程,它可使大部分的数据经过较少的比较次数得出结果。但由于每个判定框都有两次比较,将这两次比较分开,得到如图6.19 (c)所示的判定树,按此判定树可写出相应的程序。假设有10000个输入数据,若按图6.19 (a)的判定过程进行操作,则总共需进行31500次比较;而若按图6.19 (c)的判定过程进行操作,则总共仅需进行22000次比较。