6.1定义与性质
6.1.1二叉树的基本概念
1.二叉树
二叉树(Binary Tree)是个有限元素的集合,该集合或者为空、或者由一个称为根(root)的元素及两个不相交的、被分别称为左子树和右子树的二叉树组成。当集合为空时,称该二叉树为空二叉树。在二叉树中,一个元素也称作一个结点。
二叉树是有序的,即若将其左、右子树颠倒,就成为另一棵不同的二叉树。即使树中结点只有一棵子树,也要区分它是左子树还是右子树。
因此二叉树具有五种基本形态,如图所示。
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)满二叉树:在一棵二叉树中,如果所有分支结点都存在左子树和右子树,并且所有叶子结点都在同一层上,这样的一棵二叉树称作满二叉树。
(11) 完全二叉树:一棵深度为k的有n个结点的二叉树,对树中的结点按从上至下、从左到右的顺序进行编号,如果编号为i(1≤i≤n)的结点与满二叉树中编号为i的结点在二叉树中的位置相同,则这棵二叉树称为完全二叉树。完全二叉树的特点是:叶子结点只能出现在最下层和次下层,且最下层的叶子结点集中在树的左部
一棵满二叉树必定是一棵完全二叉树,而完全二叉树未必是满二叉树
6.1.2二叉树的主要性质
性质1 一棵非空二叉树的第i层上最多有2i-1个结点(i≥1)。
该性质可由数学归纳法证明。证明略。
性质2 一棵深度为k的二叉树中,最多具有2k-1个结点。
证明:设第i层的结点数为xi(1≤i≤k),深度为k的二叉树的结点数为M,xi最多为2i-1,则有:
性质3 对于一棵非空的二叉树,如果叶子结点数为n0,度数为2的结点数为n2,则有:n0=n2+1。
性质4 具有n个结点的完全二叉树的深度k为[log2n]+1。
(以2为底)
性质5 对于具有n个结点的完全二叉树,如果按照从上至下和从左到右的顺序对二叉树中的所有结点从1开始顺序编号,则对于任意的序号为i的结点,有:
⑴如果i>1,则序号为i的结点的双亲结点的序号为i/2(“/”表示整除);如果i=1,则序号为i的结点是根结点,无双亲结点。
⑵如果2i≤n,则序号为i的结点的左孩子结点的序号为2i;如果2i>n,则序号为i的结点无左孩子。
⑶如果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.顺序存储结构
所谓二叉树的顺序存储,就是用一组连续的存储单元存放二叉树中的结点。一般是按照二叉树结点从上至下、从左到右的顺序存储。
这样结点在存储位置上的前驱后继关系并不一定就是它们在逻辑上的邻接关系,然而只有通过一些方法确定某结点在逻辑上的前驱结点和后继结点,这种存储才有意义。
因此,依据二叉树的性质,完全二叉树和满二叉树采用顺序存储比较合适,树中结点的序号可以唯一地反映出结点之间的逻辑关系,这样既能够最大可能地节省存储空间,又可以利用数组元素的下标值确定结点在二叉树中的位置,以及结点之间的关系。
下面给出一棵完全二叉树的顺序存储示意。
对于一般的二叉树,如果仍按从上至下和从左到右的顺序将树中的结点顺序存储在一维数组中,则数组元素下标之间的关系不能够反映二叉树中结点之间的逻辑关系。
解决办法:增添一些并不存在的空结点,使之成为一棵完全二叉树的形式,然后再用一维数组顺序存储。
如图给出了一棵一般二叉树改造后的完全二叉树形态和其顺序存储状态示意图。
最坏的情况是右单支树,一棵深度为k的右单支树,只有k个结点,却需分配2k-1个存储单元。
显然,这种存储改造后再存储,会造成空间的大量浪费,所以一般二叉树不宜用顺序存储结构。
2.链式存储结构
(1)二叉链表存储
链表中每个结点由三个域组成,除了数据域外,还有两个指针域,分别用来给出该结点左孩子和右孩子所在的链结点的存储地址。结点的存储的结构为:
其中,data域存放某结点的数据信息;lchild 与rchild分别存放指向左孩子和右孩子的指针,当左孩子或右孩子不存在时,相应指针域值为空(用符号∧或NULL表示)。
下图(a)给出一棵二叉树的二叉链表示。
二叉链表也可以带头结点的方式存放,如图(b)所示。
(2)三叉链表存储
每个结点由四个域组成,具体结构为:
其中,data、lchild以及rchild三个域的意义同二叉链表结构;parent域为指向该结点双亲结点的指针。这种存储结构既便于查找孩子结点,又便于查找双亲结点;但是,相对于二叉链表存储结构而言,它增加了空间开销。
下图给出一棵二叉树的三叉链表示。
尽管在二叉链表中无法由结点直接找到其双亲,但由于二叉链表结构灵活,操作方便,对于一般情况的二叉树,甚至比顺序存储结构还节省空间。因此,二叉链表是最常用的二叉树存储方式。
二叉树的二叉链表存储表示可描述为:
typedef struct BiTNode{
DataType data; //存放数据信息的数据域
struct BiTNode *l child, *rchild; //左右孩子指针
}BiTNode, *BiTree;
BiTree定义为指向二叉链表结点结构的指针类型。
6.2.2二叉树的基本操作及实现
二叉树的基本操作通常有以下几种:
(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的全部结点。
算法的实现依赖于具体的存储结构,当二叉树采用不同的存储结构时,上述各种操作的实现算法是不同的。
下面讨论基于二叉链表存储结构的上述操作的实现算法。
(1)Initiate(bt)初始建立二叉树bt,并使bt指向头结点。
在二叉树根结点前建立头结点,就如同在单链表前建立的头结点,可以方便后边的一些操作实现。
int Initiate(BiTNode *bt){
//初始建立一棵带头结点的二叉树
bt=new BiTNode;//申请头结点空间
if(!bt)
return 0;//没有存储空间,返回错误代码0
bt->lchild=NULL;
bt->rchild=NULL;
return 1;//返回成功代码1
}
(2)Create(x,lbt,rbt)建立一棵已生成左右子树的二叉树的算法
建立一棵以x为根结点的数据域信息,以二叉树lbt和rbt为左右子树的二叉树。建立成功时返回所建二叉树结点的指针;建立失败时返回空指针。
BiTree Create(DataType x,BiTree lbt,BiTree rbt)
{
//生成一棵以x为根结点的数据域值,以lbt和rbt为左右子树的二叉树
BiTree p;
p= new BiTNode;//申请一个结点空间
if(!p)
return NULL;//没有存储空间,返回空指针
p->data=x;//结点数据域设置为x
p->lchild=lbt;
p->rchild=rbt; //lbt和rbt分别成为其左右孩子
return p;//返回根结点地址
}
(3)InsertL(bt,x,parent)
BiTree InsertL(BiTree bt, DataType x,BiTree parent)
{
//在二叉树bt中的parent所指结点和其左子树之间插入数据元素为x的结点
BiTree p;
if(parent==NULL)
{cout<<"插入错误"<<endl;
return NULL;//不存在parent结点,返回空指针
}
p=new BiTNode; //申请一个结点空间
if(!p)
return NULL;//没有存储空间,返回空指针
p->data=x;//结点数据域值为x
p->lchild=NULL;
p->rchild=NULL;//建立待插结点
if(parent->lchild==NULL) //parent结点没有左孩子
parent->lchild=p;
else
{
p->lchild=parent->lchild;//parent的左孩子作为p的左孩子
parent->lchild=p;
}
return bt;//插入成功,返回根节点地址
}
(4)DeleteL(bt,parent)删除二叉树中 某节点的左子树算法
在二叉树bt中删除结点parent的左子树。当parent或parent的左孩子结点为空时删除失败。删除成功时返回根结点指针;删除失败时返回空指针。
BiTree Delete(BiTree bt,BiTree parent)
{
//在二叉树bt中删除结点parent的左子树
BiTree p;
if(parent==NULL||parent->lchild==NULL)
{
cout<<"删除错误"<<endl;
return NULL;//没有parent结点或parent结点没有左孩子,错误,返回空指针
}
p=parent->lchild;
parent->lchild=NULL;
delete p;
//当p为非叶子结点时,这样删除仅释放了所删除子树根结点的空间,若要删除子树分支中的结点,需要用后面介绍的遍历操作来实现
return bt;
}
6.3二叉树的遍历
6.3.1二叉树的遍历方法及递归实现
二叉树的遍历是指按照某种顺序访问二叉树中的每个结点,使每个结点被访问一次且仅被访问一次。
遍历是二叉树中经常要用到的一种操作。因为在实际应用问题中,常常需要按一定顺序对二叉树中的每个结点逐个进行访问,查找具有某一特点的结点,然后对这些满足条件的结点进行处理。
通过一次完整的遍历,可使二叉树中结点信息由非线性排列变为某种意义上的线性序列。也就是说,遍历操作使非线性结构线性化。
由二叉树的定义可知,一棵由根结点、根结点的左子树和根结点的右子树三部分组成。因此,只要依次遍历这三部分,就可以遍历整个二叉树。 若以D、L、R分别表示访问根结点、遍历根结点的左子树、遍历根结点的右子树,则二叉树的遍历方式有六种:DLR、LDR、LRD、DRL、RDL和RLD。
如果限定先左后右,则只有前三种方式,即:
DLR(称为先序遍历)
LDR(称为中序遍历)
LRD(称为后序遍历)
1.先序遍历(DLR)
先序遍历的递归过程为:若二叉树为空,遍历结束。否则,
⑴访问根结点;
⑵先序遍历根结点的左子树;
⑶先序遍历根结点的右子树。
void PreOrder(BiTree bt)
{
//先序遍历二叉树bt
if(bt==NULL)
return; 递归调用的结束条件
Visit(bt->data);//访问结点的数据域
PreOrder(bt->lchild);//先序递归遍历bt的左子树
PreOrder(bt->rchild);//先序递归遍历bt的右子树
}
对于上图所示的二叉树,按先序遍历所得到的结点序列为: A B D G C E F
2.中序遍历(LDR)
中序遍历的递归过程为:若二叉树为空,遍历结束。否则,
⑴中序遍历根结点的左子树;
⑵访问根结点;
⑶中序遍历根结点的右子树。
void InOrder(BiTree bt)
{
//中序遍历二叉树bt
if(bt==NULL)
return;//递归调用的结束条件
InOrder(bt->lchild);//中序遍历bt的左子树
Visit(bt->data);//访问结点的数据与
InOrder(bt->rchild);//中序遍历bt的右子树
}
对于上图所示的二叉树,按中序遍历所得到的结点序列为: D G B A E C F
3.后序遍历(LRD)
后序遍历的递归过程为:若二叉树为空,遍历结束。否则,
⑴后序遍历根结点的左子树;
⑵后序遍历根结点的右子树。
⑶访问根结点;
void PostOrder(BiTree bt)
{
//后序遍历二叉树bt
if(bt==NULL)
return;//递归调用结束的条件
PostOrder(bt->lchild);//后序递归遍历bt的左子树
PostOrder(bt->rchild);//后续递归遍历bt的右子树
Visite(bt->data);//访问结点的数据域
}
对于上图所示的二叉树,按后序遍历所得到的结点序列为: G D B E F C A
4.层次遍历
所谓二叉树的层次遍历,是指从二叉树的第一层(根结点)开始,从上至下逐层遍历,在同一层中,则按从左到右的顺序对结点逐个访问。
对于上图所示的二叉树,按层次遍历所得到的结果序列为: A B C D E F G
由层次遍历的定义可以推知,在进行层次遍历时,对一层结点访问完后,再按照它们的访问次序对各个结点的左孩子和右孩子顺序访问,这样一层一层进行,先遇到的结点先访问,这与队列的操作原则比较吻合。
因此,在进行层次遍历时,可设置一个队列结构,遍历从二叉树的根结点开始,首先将根结点指针入队列,然后从队头取出一个元素,每取一个元素,执行下面两个操作:
⑴访问该元素所指结点;
⑵若该元素所指结点的左、右孩子结点非空,则将该元素所指结点的左孩子指针和右孩子指针顺序入队。
此过程不断进行,当队列为空时,二叉树的层次遍历结束。
在下面的层次遍历算法中,二叉树以二叉链表存放,一维数组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++;
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;
}
}
}
二叉树遍历的非递归实现
前面给出的二叉树先序、中序和后序三种遍历算法都是递归算法。当给出二叉树的链式存储结构以后,用具有递归功能的程序设计语言很方便就能实现上述算法。
然而,并非所有程序设计语言都允许递归;另一方面,递归程序虽然简洁,但可读性一般不好,执行效率也不高。
因此,就存在如何把一个递归算法转化为非递归算法的问题。解决这个问题的方法可以通过对三种遍历方法的实质过程的分析得到。
由遍历序列恢复二叉树
不用栈的二叉树遍历的非递归方法
前面给出的二叉树先序、中序和后序三种遍历算法都是递归算法。当给出二叉树的链式存储结构以后,用具有递归功能的程序设计语言很方便就能实现上述算法。
然而,并非所有程序设计语言都允许递归;另一方面,递归程序虽然简洁,但可读性一般不好,执行效率也不高。
因此,就存在如何把一个递归算法转化为非递归算法的问题。解决这个问题的方法可以通过对三种遍历方法的实质过程的分析得到。
前图所示的二叉树,对其进行先序、中序和后序遍历都是从根结点A开始的,且在遍历过程中经过结点的路线也是一样的。
沿着该路线按△标记的结点读得的序列为先序序列,按*标记读得的序列为中序序列,按⊕标记读得的序列为后序序列。
图中所示的从根结点左外侧开始,由根结点右外侧结束的曲线,为遍历的路线。 即从根结点开始沿左子树深入下去,当深入到最左端,无法再深入下去时,则返回,再逐一进入刚才深入时遇到结点的右子树,再进行如此的深入和返回,直到最后从根结点的右子树返回到根结点为止。
各种遍历只是对结点的访问时机不尽相同!
先序遍历是在深入时遇到结点就访问;
中序遍历是在从左子树返回时遇到结点访问;
后序遍历是在从右子树返回时遇到结点访问。
在这一过程中,注意到:返回结点的顺序与深入结点的顺序相反,即后深入先返回,可以用栈来帮助实现这一遍历路线。其过程如下:
(1)在沿左子树深入时,深入一个结点入栈一个结点,若为先序遍历,则在入栈之前访问之;
(2)当沿左分支深入不下去时,则返回,即从堆栈中弹出前面压入的结点。
(3)若为中序遍历,则此时访问该结点,然后从该结点的右子树继续深入;
(4)若为后序遍历,则将此结点再次入栈,然后从该结点的右子树继续深入,与前面类同,仍为深入一个结点入栈一个结点,深入不下去再返回,直到第二次从栈里弹出该结点,即从右子树返回时,才访问之。
(1)先序遍历的非递归实现
在下面算法中,二叉树以二叉链表存放,一维数组stack[MAXNODE]用以实现栈,变量top用来表示当前栈顶的位置。
int NRPreOrder(BiTree bt)
{
//非递归先序遍历二叉树
BiTree stack[MAXNODE],p;
int top;
if(bt==NULL) return 1;//空栈
top=-1;
p=bt;
while(!(p==NULL&&top==-1))
{
while(p!=NULL)
{
Visit(p->data);//访问结点的数据域
if(top<MAXNODE-1)//将当前指针p压栈
{
top++;
stack[top]=p;
}
else
{
cout<<"栈溢出"<<endl;
return 0;
}
p=p->lchild;//指针指向p的左孩子结点
}
if(top==-1)
return 1;//栈空时结束
else
{
p=stack[top];
top--;
p=p->rchild;//指针指向p的右孩子结点
}
}
}
(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的标志量。
int NRPostOrder(BiTree bt)
{
//非递归后序遍历二叉树bt
StackType stack[MAXNODE];
BiTree p;
int top,sign;
if(bt==NULL)
return 1;//空树
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);//访问该结点的数据域值
}
}
}
}
6.3.3 由遍历序列恢复二叉树
从前面讨论的二叉树的遍历知道,任意一棵二叉树结点的先序序列和中序序列都是唯一的。
反过来,若已知结点的先序序列和中序序列,能否确定这棵二叉树呢? 这样确定的二叉树是否是唯一的呢?
根据定义,二叉树的先序遍历是先访问根结点,其次再按先序遍历方式遍历根结点的左子树,最后按先序遍历方式遍历根结点的右子树。
这就是说,在先序序列中,第一个结点一定是二叉树的根结点。
另一方面,中序遍历是先遍历左子树,然后访问根结点,最后再遍历右子树。这样,根结点在中序序列中必然将中序序列分割成两个子序列,前一个子序列是根结点的左子树的中序序列,而后一个子序列是根结点的右子树的中序序列。
根据这两个子序列,在先序序列中找到对应的左子序列和右子序列。在先序序列中,左子序列的第一个结点是左子树的根结点,右子序列的第一个结点是右子树的根结点。
这样,就确定了二叉树的三个结点。同时,左子树和右子树的根结点又可以分别把左子序列和右子序列划分成两个子序列,如此递归下去,当取尽先序序列中的结点时,便可以得到一棵二叉树。
上述过程是一个递归过程,其递归算法的思想是:
(1)先根据先序序列的第一个元素建立根结点;
(2)然后在中序序列中找到该元素,确定根结点的左、右子树的中序序列;(3)再在先序序列中确定左、右子树的先序序列;
(4)最后由左子树的先序序列与中序序列建立左子树,由右子树的先序序列与中序序列建立右子树。
下面给出该算法。假设二叉树的先序序列和中序序列分别存放在一维数组preod[ ]与inod[ ]中,并假设二叉树各结点的数据值均不相同。
void PreInOd(char preod[],char inod[],int i, int j, int k, int h,BiTree *t)
{
int m;
(*t)=new BitNode;//建立根节点
(*t)->data=preod[i];//i为起始下标
m=k;
while(inod[m]!=preod[i])
m++;
if(m==k)//左子树为空
(*t)->lchild=NULL;
else
PreInOd(preod,inod,i+1,i+m-k,m-1,&((*t)->child));
if(m==h)//右子树序列为空
(*t)->rchilde=NULL;
else
PreInOd(preod,Inod,i+m-k+1,j,m+1,h,&((*t)->rchild));
void ReBiTree(char preod[],char inod[],int n,BiTree root){
//n为二叉树的结点个数,root为为叉树根结点的存储地址
if(n<0)
return NULL;//空树
else
PreInOd(preod,inod,1,n,1,n,&root);
}
}
同样的道理,由二叉树的后序序列和中序序列也可唯一地确定一棵二叉树。
因为,依据后序遍历和中序遍历的定义,后序序列的最后一个结点,就如同先序序列的第一个结点一样,可将中序序列分成两个子序列,分别为这个结点的左子树的中序序列和右子树的中序序列,再拿出后序序列的倒数第二个结点,并继续分割中序序列,如此递归下去,当倒着取尽后序序列中的结点时,便可以得到一棵二叉树。
6.3.4 不用栈的二叉树遍历的非递归方法
前面介绍的二叉树的遍历算法可分为两类,一类是依据二叉树结构的递归性,采用递归调用的方式来实现;另一类则是通过堆栈或队列来辅助实现。
采用这两类方法对二叉树进行遍历时,递归调用和栈或队列的使用都带来额外空间增加,递归调用的深度和栈的大小是动态变化的,都与二叉树的高度有关。因此,在最坏的情况下,即二叉树退化为单支树的情况下,递归的深度或栈需要的存储空间等于二叉树中的结点数。
还有一类二叉树的遍历算法,就是不用栈也不用递归来实现。常用的不用栈的二叉树遍历的非递归方法有以下三种:
⑴对二叉树采用三叉链表存放,即在二叉树的每个结点中增加一个双亲域parent,这样,在遍历深入到不能再深入时,可沿着走过的路径回退到任何一棵子树的根结点,并再向另一方向走。
⑵采用逆转链的方法,即在遍历深入时,每深入一层,就将其再深入的孩子结点的地址取出,并将其双亲结点的地址存入。当深入不下去而需返回时,可逐级取出双亲结点的地址,沿原路返回。
⑶在线索二叉树上的遍历,即利用具有n个结点的二叉树中的叶子结点和一度结点的n+1个空指针域,来存放线索,然后在这种具有线索的二叉树上遍历时,就可不需要栈,也不需要递归了。
6.4 线索二叉树
6.4.1 线索二叉树的定义及结构
1.线索二叉树的定义
按照某种遍历方式对二叉树进行遍历,可以把二叉树中所有结点排列为一个线性序列。在该序列中,除第一个结点外,每个结点有且仅有一个直接前驱结点;除最后一个结点外,每个结点有且仅有一个直接后继结点。
为了保留结点在某种遍历序列中直接前驱和直接后继的位置信息,利用二叉树的二叉链表存储结构中的那些空指针域来指示。这些指向直接前驱结点和指向直接后继结点的指针被称为线索(thread),加了线索的二叉树称为线索二叉树。
线索二叉树将为二叉树的遍历提供许多便利。
2.线索二叉树的结构:
由于序列可由不同的遍历方法得到,因此,线索树有先序线索二叉树、中序线索二叉树和后序线索二叉树三种。把二叉树改造成线索二叉树的过程称为线索化。
对前图所示的二叉树进行线索化,得到先序线索二叉树、中序线索二叉树和后序线索二叉树分别如图(a)、(b)、©所示。图中实线表示指针,虚线表示线索。
那么,问题是在存储时如何区别某结点的指针域内存放的是指针还是线索?通常可以采用下面两种方法来实现。
⑴为每个结点增设两个标志位域ltag和rtag,令:
ltag=![[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-hwqpOWL5-1667562953300)(C:\Users\WSM-\AppData\Roaming\Typora\typora-user-images\image-20221026113601193.png)](https://img-blog.csdnimg.cn/fc2f317b128742c7ad934f73995870b9.png)
rtag=
每个标志位令其只占一个bit,这样结点的结构为:
⑵不改变结点结构,仅在作为线索的地址前加一个负号,即负地址表示线索,正地址表示指针。
6.4.2 线索二叉树的基本操作实现
在线索二叉树中,结点的结构可以定义为如下形式:
typedef char elemtype;
typedef struct BiThrNode
{
elemtype data;
struct BiThrNode *lchild;
struct BiThrNode *rchild;
unsigned ltag;
unsigned rtag;
}BiThrNodeType,*BiThrTree;
1.建立一棵中序线索二叉树
建立线索二叉树,或者说对二叉树线索化,实质上就是遍历一棵二叉树。在遍历过程中,访问结点的操作是检查当前结点的左、右指针域是否为空,如果为空,将它们改为指向前驱结点或后继结点的线索。为实现这一过程,设指针pre始终指向刚刚访问过的结点,即若指针p指向当前结点,则pre指向它的前驱,以便增设线索。
另外,在对一棵二叉树加线索时,必须首先申请一个头结点,建立头结点与二叉树的根结点的指向关系。对二叉树线索化后,还需建立最后一个结点与头结点之间的线索。
下面是建立中序线索二叉树的递归算法,其中pre为全局变量。
BiThrTree pre;
int InOrderThr(BiThrTree *head,BiThrTree T){
//中序遍历二叉树T,并将其中序线索化,*head指向头结点,pre位全局变量
(*head)=new BiThrNode;//申请头结点空间
if(!(*head))
return 0;//不能建立头结点,返回错误代码0
(*head)->ltag=0;
(*head)->rtag=1;//建立头结点
(*head)->lchild=*head;//右指针回指
id(!T)
(*head)->lchild=*head;//若二叉树为空,则左指针回指
else
{
(*head)->lchild=T;
pre=*head;
InOrderThr(T);//中序遍历进行中序线索化
pre->rchild=*head;
pre->rtag=1;//最后一个节点线索化
(*head)->lchild=pre;
}
return *head;
}
2.在中序线索二叉树上查找任意结点的中序前驱结点
对于中序线索二叉树上的任一结点,寻找其中序的前驱结点,有以下两种情况:
⑴如果该结点的左标志为1,那么其左指针域所指向的结点便是它的前驱结点;
⑵如果该结点的左标志为0,表明该结点有左孩子,根据中序遍历的定义,它的前驱结点是其左子树的“右下角”结点,即沿着其左子树的右指针链向下查找,当某结点的右标志为1时,它就是所要找的前驱结点。
BiThrTree InPreNode(BiThrTree p){
//在中序线索二叉树上寻找结点p的中序前驱结点
BiThrTree pre;
pre=p->lchild;
if(p->ltag!=1)
while(pre->rtag==0)
pre=pre->rchild;
return pre;
}
3.z在中序线索二叉树上查找任意节点的中序后继结点
对于中序线索二叉树上的任一结点,寻找其中序的后继结点,有以下两种情况:
⑴如果该结点的右标志为1,那么其右指针域所指向的结点便是它的后继结点;
⑵如果该结点的右标志为0,表明该结点有右孩子,根据中序遍历的定义,它的后继结点是其右子树的“左下角”结点,即沿着其右子树的左指针链向下查找,当某结点的左标志为1时,它就是所要找的后继结点。
BiThrTree InPostNode(BiThrTree p)
{
//在中序线索二叉树上寻找结点p的中序后继节点
BiThrTree post;
post=p->lchild;
if(p->rtag!=1)
while(post->ltag==0)
post=post->lchild;
return post;
}
4.在中序线索二叉树上查找任意节点在先序下的后继
这一操作的实现依据是:若一个结点是某子树在中序遍历的最后一个结点,则它必是该子树在先序遍历的最后一个结点。该结论可以用反证法证明。
根据前面的结论,设指向此待确定先序后继的结点的指针为p,则具体分为如下两种情况。
⑴若p为分支结点,则又有两种情况:
①当p->ltag=0时(即有左孩子结点),p->lchild为p在先序下的后继;
②当p->ltag=1时(即无左孩子结点),p->rchild为p在先序下的后继。
⑵若p为叶子结点,则也有两种情况:
①若p->rchild是头结点(即p为中序遍历序列的最后一个结点,同时也是先序遍历的最后一个结点),则p的先序后继为头结点;
②若p->rchild不是头结点,则p一定是以p->rchild结点为根的左子树中在中序遍历下的最后一个结点,因此p也是在该子树中按先序遍历的最后一个结点。此时,若p->rchild有右孩子结点,则p在先序下的后继结点的就是p->rchild->rchild;
②若p->rchild结点无右孩子结点,则p->rchild->rchild的右孩子结点为p的先序后继。
②若p->rchild->rchild仍无右孩子结点,则继续向上层回退,直到找到某个结点的rtag=0即有右孩子结点为止,那么该右孩子结点即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;
}
*5.在中序线索二叉树上查找任意结点在后序下的前驱
这一操作的实现依据是:若一个结点是某子树在中序遍历的第一个结点,则它必是该子树在后序遍历的第一个结点。该结论可以用反证法证明。
依据这一结论,设指向此某结点的指针为p,具体也分为如下两种情况。
⑴若待确定后序前驱的结点为分支结点,则又有两种情况:
①当p->rtag=0时,p->rchild为p在后序下的前驱;
②当p->rtag=1时,p->lchild为p在后序下的前驱。
⑵若待确定后序前驱的结点为叶子结点,则也有两种情况:
①若p->lchild是头结点,则遍历结束;
②若p->lchild不是头结点,则p结点一定是以p->lchild结点为根的右子树中在中中序遍历下的第一个结点,因此p结点也是在该子树中按后序遍历的第一个结点。此时,若p->lchild结点有左子树,则所找结点在后序下的前驱结点的地址为p->lchild->lchild;若p->lchild为线索,则让p=p->lchild,反复情况(2)的判定。
BiThrTree IpostPretNode(BiThrTree head,BiThrTree p){
//在中序线索二叉树上寻找节点p的后序的前驱结点,head为线索树的头结点
BiThrTree pre;
if(p->rtag==0)
pre=p->rchild;
else
{
pre=p;
while(pre->ltag==1&&pre->lchild!=head)
pre=pre->lchild;
pre=pre->lchild;
}
return pre;
}
6.在中序线索二叉树上查找值为x的结点
利用在中序线索二叉树上寻找后继结点和前驱结点的算法,就可以遍历到二叉树的所有结点。比如,先找到按某序遍历的第一个结点,然后再依次查询其后继;或先找到按某序遍历的最后一个结点,然后再依次查询其前驱。这样,既不用栈也不用递归就可以访问到二叉树的所有结点。
在中序线索二叉树上查找值为x的结点,实质上就是在线索二叉树上进行遍历,将访问结点的操作具体写为那结点的值与x比较的语句。
BiThrTree Search(BiThrTree head,DataType 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)
{
cout<<"没有找到该数据!"<<endl;
return NULL;//查找失败,返回空指针
}
else
return p;
}
7.在中序线索二叉树上的更新
线索二叉树的更新是指,在线索二叉树中插入一个结点或者删除一个结点。一般情况下,这些操作有可能破坏原来已有的线索,因此,在修改指针时,还需要对线索做相应的修改。
一般来说,这个过程的代价几乎与重新进行线索化相同。这里仅讨论一种比较简单的情况,即在中序线索二叉树中插入一个结点p,使它成为结点s的右孩子。
下面分两种情况来分析:
⑴若s的右子树为空,如图(a)所示,则插入结点p之后成为图(b)所示的情形。在这种情况中,s的后继将成为p的中序后继,s成为p的中序前驱,而p成为s的右孩子。二叉树中其它部分的指针和线索不发生变化。
⑵若s的右子树非空,如图 (a)所示,插入结点p之后如图 (b)所示。s原来的右子树变成p的右子树,由于p没有左子树,故s成为p的中序前驱,p成为s的右孩子;又由于s原来的后继成为p的后继,因此还要将本来指向s的前驱左线索,改为指向p。
算法6-18!!!
void InsertThrRight(BiThrTree s,BiTheTree 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);//在以p为根结点的子树上找中序遍历下的第一个结点
w->lchild=p;
}
}
6.5二叉树的应用
6.5.1 二叉树遍历的应用
1.查找数据元素
BiTree Search(BiTree bt,DataType x){
//在bt为根结点指针的二叉树中查找数据元素x
BiTree p;
if(bt)
{
if(bt->data==x)
return bt;//查找成功返回
if(bt->lchild!=NULL)
//在bt->lchild为根结点指针的二叉树中查找数据元素x
{
p=Search(bt->lchild,x);
if(p)
return p;
}
if(bt->rchild!=NULL)
{
p=Search(bt->rchild,x);
if(p)
return p;
}
}
return NULL;
}
2.统计出给定二叉树中叶子节点的数目
(1)顺序存储结构的实现
int CountLeaf1(SqBiTree bt,int k){
//一位数组bt[2^k-1]为二叉树存储结构,k为二叉树深度,函数返回值为叶子数
int i,total;
total=0;
for(o=1;i<w^k-1;i++)
if(bt[i-1]!=0) //bt[i-1]=0表示编号为i的结点为虚结点
if((i>(2^k-1)/2-1)||(bt[2*i-1]==0&&bt[2i]==0))
total++;
return total;
}
(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));
}
3.创建二叉树二叉链表存储,并显示。
设创建时,按二叉树带空指针的先序次序输入结点值,结点值类型为字符型。输出按中序输出。
CreateBinTree(BinTree *bt)是以二叉链表为存储结构建立一棵二叉树T的存储,bt为指向二叉树T根结点指针的指针。
InOrderOut(bt)为按中序输出二叉树bt的结点。
void CreateBinTree(BiTree *T){
//以加入结点的先序序列输入,构造二叉链表
char ch;
cin>>ch;
if(ch=='0')
(*T)==NULL;//读入0时,将相应结点置空
else
{
(*T)=new BiTNode;//生成节点空间
(*T)->data=ch;
CreateBinTree(&(*T)->lchild);//构造二叉树的左子树
CreateBinTree(&(*T)->rchild);//构造二叉树的右子树
}
}
void InOrderOut(BinTree T){
//中序遍历输出二叉树T的节点值
if(T)
{
InOrderOut(T->lchild);//中序遍历二叉树的左子树
cout<<T->data<<endl;//访问结点的数据
InOrderOut(T->rchild);//中序遍历二叉树的右子树
}
}
void main(){
BinTree bt;
CreateBinTreee(&bt);
InOrderOut(&bt);
}
4.表达式运算
我们可以把任意一个算数表达式用一棵二叉树表示,如图所示为表达式3x2+x-1/x+5的二叉树表示。在表达式二叉树中,每个叶结点都是操作数,每个非叶结点都是运算符。对于一个非叶子结点,它的左、右子树分别是它的两个操作数。
对该二叉树分别进行先序、中序和后序遍历,可以得到表达式的三种不同表示形式:
前缀表达式 ±+3xxx/1x5
中缀表达式 3xx+x-1/x+5
后缀表达式 3xx**x+1x/-5+
6.5.2 最优二叉树――哈夫曼树
1.哈夫曼树的基本概念
最优二叉树,也称哈夫曼(Haffman)树,是指对于一组带有确定权值的叶结点,构造的具有最小带权路径长度的二叉树。
二叉树的路径长度是指由根结点到所有叶结点的路径长度之和。如果二叉树中的叶结点都具有一定的权值,则可将这一概念加以推广。设二叉树具有n个带权值的叶结点,那么从根结点到各个叶结点的路径长度与相应结点权值的乘积之和叫做二叉树的带权路径长度,记为:
其中,Wk为第k个叶结点的权值,Lk 为第k个叶结点的路径长度。
在给定一组具有确定权值的叶结点,可以构造出不同的带权二叉树。例如,给出4个叶结点,设其权值分别为1,3,5,7,我们可以构造出形状不同的多个二叉树。
下图给出了其中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
(e)WPL=7×1+5×2+3×3+1×3=29
如何找到带权路径长度最小的二叉树(即哈夫曼树)呢?根据哈夫曼树的定义,一棵二叉树要使其WPL值最小,**必须使权值越大的叶结点越靠近根结点,而权值越小的叶结点越远离根结点。**哈夫曼(Haffman)依据这一特点提出了一种方法,这种方法的基本思想是:
(1)由给定的n个权值{W1,W2,…,Wn}构造n棵只有一个叶结点的二叉树,从而得到一个二叉树的集合F={T1,T2,…,Tn};
(2)在F中选取根结点的权值最小和次小的两棵二叉树作为左、右子树构造一棵新的二叉树,这棵新的二叉树根结点的权值为其左、右子树根结点权值之和;
(3)在集合F中删除作为左、右子树的两棵二叉树,并将新建立的二叉树加入到集合F中;
(4)重复(2)(3)两步,当F中只剩下一棵二叉树时,这棵二叉树便是所要建立的哈夫曼树。
下图给出前面提到的叶结点权值集合为W={1,3,5,7}的哈夫曼树的构造过程。可以计算出其带权路径长度为29,由此可见,对于同一组给定叶结点所构造的哈夫曼树,树的形状可能不同,但带权路径长度值是相同的,一定是最小的。
2.哈夫曼树的构造算法
在构造哈夫曼树时,可以设置一个结构数组HuffNode保存哈夫曼树中各结点的信息,根据二叉树的性质可知,具有n个叶子结点的哈夫曼树共有2n-1个结点,所以数组HuffNode的大小设置为2n-1,数组元素的结构形式如下:
其中,weight域保存结点的权值,lchild和rchild域分别保存该结点的左、右孩子结点在数组HuffNode中的序号,从而建立起结点之间的关系。为了判定一个结点是否已加入到要建立的哈夫曼树中,可通过parent域的值来确定。初始时parent的值为-1,当结点加入到树中时,该结点parent的值为其双亲结点在数组HuffNode中的序号。
#define MAXVALUE;//定义最大权值
#define MAXLEAF;//定义哈夫曼树中叶子结点个数
#define MAXNODE MAXLEAF*2-1
typedef struct{
int weight;
int parent;
int lchild;
int rchild;
}HNodeType;
HNodeType *HaffmanTree(){
HNodeType HuffNode[MAXNODE];
int i,j,m1,m2,x1,x2,n;
cin>>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++)
{
x1=x2=MAXVALUE;
m1=m2=0;
for(j=0;j<n+1;j++)
{
if(HuffNode[j].parent==-1&&HuffNode[j].weight<x1)
{
//找出根结点具有最小和次最小权值的两棵树
x2=x1;m2=m1;
x1=HUffNode[j].weight;
m1=j;
}
else if(HuffNode[j].parent==-1&&HuffNode[j].weight<x2)
{
x2=HuffNode[j].weight;
m2=j;
}
}
HuffNode[m1].parent=n+i;
HuffNode[m2].parent=n+i;//将找出的两棵子树合并
HuffNode[n+i].weight=HuffNode[m1].weight+HuffNode[m2].weight;
HuffNode[n+i].lchild=m1;
HUffNode[n+i].rchild=m2;
}
}
3.哈夫曼编码
在数据通讯中,经常需要将传送的文字转换成由二进制字符0,1组成的二进制串,我们称之为编码,具体可分为等长编码和不等长编码。
例如,假设要传送的电文为ABACCDA,电文中只含有A,B,C,D四种字符。
哈夫曼树可用于构造使电文的编码总长最短的编码方案。具体做法如下:
设需要编码的字符集合为{d1,d2,…,dn},它们在电文中出现的次数或频率集合为{w1,w2,…,wn},以d1,d2,…,dn作为叶结点,w1,w2,…,wn作为它们的权值,构造一棵哈夫曼树,规定哈夫曼树中的左分支代表0,右分支代表1,则从根结点到每个叶结点所经过的路径分支组成的0和1的序列便为该结点对应字符的编码,我们称之为哈夫曼编码。
在哈夫曼编码树中,树的带权路径长度的含义是各个字符的码长与其出现次数的乘积之和,也就是电文的代码总长,所以采用哈夫曼树构造的编码是一种能使电文代码总长最短的不等长编码。
实现哈夫曼编码的算法可分为两大部分:
(1)构造哈夫曼树;
(2)在哈夫曼树上求叶结点的编码。
求哈夫曼编码,实质上就是在已建立的哈夫曼树中,从叶结点开始,沿结点的双亲链域回退到根结点,每回退一步,就走过了哈夫曼树的一个分支,从而得到一位哈夫曼码值,由于一个字符的哈夫曼编码是从根结点到相应叶结点所经过的路径上各分支所组成的0,1序列,因此先得到的分支代码为所求编码的低位码,后得到的分支代码为所求编码的高位码。
我们可以设置一结构数组HuffCode用来存放各字符的哈夫曼编码信息,数组元素的结构如下:
其中,分量bit为一维数组,用来保存字符的哈夫曼编码,start表示该编码在数组bit中的开始位置。所以,对于第i个字符,它的哈夫曼编码存放在HuffCode[i].bit中的从HuffCode[i].start到n的分量上。
//设已建立好的哈夫曼树已存放在数组HFMTree中,哈夫曼编码算法如下:
void HaffmanCode(HNodeType HFMTree[],HCodeType HuffCode[])
{
HCodeType cd;//字符编码的缓冲变量
int i,j,c,p;
for(i=0;i<n;i++)//求每个叶子点点的哈夫曼编码
{
cd.start=n-1;
c=i;
p=HFMTree[c].parent;
while(p!=-1)//由叶子结点向上直到根结点
{
if(HFMTree[p].lchild==c) cd.bit[cd.start]=0;
else cd.bit[cd.start]=1;
cd.start--;
c=p;
p=HFMTree[c].parent;
}
for(j=cd.start+1;j<n;j++)
HuffCode[i].bit[j]=cd.bit[j];
HuffCode[i].start=cd.start+1;
}
}
4.哈夫曼树在判定问题中的应用
例如,要编制一个将百分制转换为五级分制的程序。显然,此程序很简单,只要利用条件语句便可完成。如:
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”;这个判定过程可以下图所示的判定树来表示。
如果上述程序需反复使用,而且每次的输入量很大,则应考虑上述程序的效率问题,即其操作所需要的时间。因为在实际中,学生的成绩在五个等级上的分布是不均匀的,假设其分布规律如下表所示: 分数 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为权构造一棵有五个叶子结点的哈夫曼树,则可得到如图 (b)所示的判定过程,它可使大部分的数据经过较少的比较次数得出结果。
优化:由于每个判定框都有两次比较,将这两次比较分开,得到如图©所示的判定树,按此判定树可写出相应的程序。假设有10000个输入数据,若按图(a)的判定过程进行操作,则总共需进行31500次比较;而若按图©的判定过程进行操作,则总共仅需进行22000次比较。