5.5 遍历二叉树和线索二叉树
在对二叉树的一些应用中,常常要求在树中查找具有某种特征的结点,或是对树中的全部结点逐一进行处理,这就提出了一个遍历二叉树的问题。线索二叉树是在第一次遍历时将结点的前驱、后继信息存储下来,便于再次遍历二叉树。
5.5.1 遍历二叉树
1.概念
遍历二叉树(traversing binary tree)是指按照某条搜索路径巡访树种每个结点,使得每个结点均被访问一次,而且仅被访问一次。
访问的含义很广,可以是对结点做各种处理,包括输出结点的信息,对结点进行运算和修改等。遍历二叉树是二叉树最基本的操作,也是二叉树其他各种操作的基础,遍历的实质是对二叉树进行线性化的过程,即遍历的结果是将非线性结构的树中结点排成一个线性序列。
由于二叉树的每个结点都可能有两棵子树,因而需要寻找一种规律,以便使二叉树上的结点能排列在一个线性队列上,从而便于遍历。
在二叉树的遍历中,我们规定结点的左右子树只能从左往右遍历,而根结点的位置没有限制,进而我们可以得出三种遍历的方法,分别是:先序遍历、中序遍历和后序遍历(都是在二叉树非空的情况下)。
先序遍历:
- 访问根结点;
- 先序遍历左子树;
- 先序遍历后子树。
中序遍历
- 中序遍历左子树;
- 访问根节点;
- 中序遍历右子树。
后序遍历
- 后续遍历左子树;
- 后续遍历右子树;
- 访问根节点。
在了解完遍历的概念后,大多数人肯定都是一头雾水,这玩意儿有啥用?
如下图
若我们分别用先序、中序和后续遍历此二叉树,按访问结点的顺序将结点排列起来,可得到:
- 先序序列: − + a ∗ b − c d / e f - + a * b - c d / e f −+a∗b−cd/ef
- 中序序列: a + b ∗ c − d − e / f a + b * c - d - e / f a+b∗c−d−e/f
- 后序遍历:
a
b
c
d
−
∗
+
e
f
/
−
a b c d - * + e f / -
abcd−∗+ef/−
从上面的表达式来看,以上三个序列分别是为表达式的前缀表达式(波兰式)、中缀表达式和后缀表达式(逆波兰)。
在前面我们知道,树是一种非线性结构,也就是没有特定的顺序,而遍历则是将树的非线性结构排列成线性结构,从而在我们知道一个树的遍历序列后可以唯一确定其二叉树的非线性结构,也就是能将一串序列唯一表示成二叉树的形式。下面我们进一步说明该如何用遍历序列来确定二叉树。
2.根据遍历序列确定二叉树
从前面讨论的二叉树的遍历知道,若二叉树中各结点的值均不同,任意一棵二叉树结点的先序序列、中序序列和后序序列都是唯一的。反过来,若已知二叉树遍的任意两种序列,能否确定这颗二叉树呢?这样确定的二叉树又是否唯一的呢?下面我们通过具体例子来分析如何根据给出二叉树的序列来确定这颗二叉树。
例如已知先序序列ABCDEFGH,我们先思考一个问题,就单凭一个序列能确定一棵二叉树吗?答案是否定的。
首先,根据上述序列,我们能知道该树的根节点为A,但是其左右子树不能确定,即左子树可能为空,右子树也可能为空。因此,我们需要另外一种的序列来做一个相对照应。比如,我们再给出该树的中序序列BDCEAFHG。由先序序列可知,根结点为A,我们再在中序序列中去找,根据中序序列的定义,我们可知其左子树的中序序列为BDCE(在一个序列中,其左右子树也遵循这个序列),右子树的中序序列为FHG。进而,我们再在先序序列中去找到包含BDCE的一块,即先序序列中的BCED,又根据先序序列的定义,该左子树的根节点为B,从而可得到左子树中序序列的左左子树为空,右右子树为DCE。再在先序序列中去找到包含DCE的一块,即CDE,说明右右子树的根节点为C,又根据右右子树的中序序列,即左左左结点为D,右右右结点为E。至此,我们可得出的二叉树的结构为:
那么根据上述规则,我们也可以得到其右子树的结构。该树完整结构如下:
建议读者动手操作,自己写一遍就能理解了。
需要注意的是,只能由二叉树的先序序列和中序序列,或由其后序序列和中序序列均能唯一地确定一棵二叉树。若给出先序序列和后序序列的话,因此不能确定其左右子树,所以不能确定一棵二叉树。
当我们将遍历的概念掌握后,就该去应用遍历算法。
3.二叉树遍历算法的应用
“遍历”是二叉树各种操作的基础,假设访问结点的具体操作不仅仅局限于输出结点数据域的值,而把“访问”延伸到对结点的判别、计数等其他操作,可以解决一些关于二叉树的其他实际问题。如果在遍历过程中生成结点,这样便可建立二叉树的存储结构。
1.创建二叉树的存储结构——二叉链表
为简化问题,设二叉树中结点的元素均为一个单字符。假设按先序遍历的顺序建立二叉链表,T为指向根结点的指针,对于给定的一个字符序列,依次读入字符,从根节点开始,递归创建二叉树。
在开始写算法之前,我们应该先理清写算法的思路:
- 首先我们需要扫描整个字符序列,可用循环结构,并读入第一个字符。
- 由前面的定义可知,树可分为空树和非空树,若我们要建立空树则输入“#”表示;若字符序列中不含“#”,则建立非空树。在这里我们用到了条件判断,判断输入第一个字符是否为“#”,所以我们可以用if语句来实现。
- 若建立非空树,到目前为止我们这个代码里没有存放根数据的地方,所以我们应该先生成一个新结点,并让T指针指向这个结点,然后将根的值赋值给根结点的数据域。然后我们再创建其左右子树。因为左右子树也是按照先序序列来创建的,所以创建左右子树是一个递归的算法。
void CreateBiTree(BiTree &T)
{
cin>>ch;
if(ch=='#')
T=NULL;
else
{
T=new BiTNode;
T->data=ch;
CreateBiTree(T->lchild);
CreateBiTree(T->rchild);
}
}
2.复制二叉树
复制二叉树就是利用已有的一棵二叉树复制得到另一棵与其完全相同的二叉树。根据二叉树的特点,复制步骤如下:首先判断二叉树是否为空,若不为空,则先复制根结点,这相当于二叉树先序遍历算法中访问根结点的语句;然后分别复制二叉树根结点的左子树和右子树,这相当于先序遍历中递归遍历左子树和右子树的语句。因此,复制函数的实现与二叉树先序遍历的实现非常类似。
void Copy(BiTree T,BiTree &NewT) //NewT是经过复制得到的树
{
if(T==NULL)
{
NewT=NULL;
return;
}
else
{
NewT=new BiTree;
NewT->data=T->data;
Copy(T->lchild,NewT->lchild);
Copy(T->rchild,NewT->rchild);
}
}
3.计算二叉树的深度
算法思路:
- 首先判断是否为空树,如果是空树,则递归结束,深度为0。
- 若不为空树,递归计算左子树的深度m。
- 递归计算右子树的深度n。
- 若m>n,则深度为m+1,否则深度为n+1。
int Depth(BiTree T)
{
if(T==NULL)
return 0;
else
{
m=Depth(T->lchild);
n=Depth(T->rchild);
if(m>n)
return (m+1);
else
return (n+1);
}
}
根据递归来看,计算二叉树的深度是在后序遍历二叉树的基础上进行的运算。
4.统计二叉树中结点的个数
如果是空树,则结点的个数为0;否则,结点个数为左子树的结点个数加上右子树的结点个数再加上根结点。
int NodeCount(BiTree T)
{
if(T==NULL)
return 0;
else
return NodeCount(T->lchild)+NodeCount(T->rchild)+1;
}
5.5.2 线索二叉树
1. 线索二叉树的基本概念
遍历二叉树是以一定规则将二叉树中的结点排成一个线性序列,得到二叉树结点的先序序列、中序序列或后序序列。这实质上是对一个非线性结构进行线性化的操作,使每个结点(除第一个和最后一个外)在这些序列中有且仅有一个直接前驱和直接后继。前驱和后继的概念是在二叉树经过线性化后的线性序列中才有的,线性化后的二叉树的前一个结点称为该结点的前驱,后一个结点称为该结点的后驱。
那么随之而来的一个问题是这些前驱和后继是怎么得来的呢?由前面可知,这种信息只有在遍历的动态过程中才能得到,为此我们引入线索二叉树来保存这些在动态过程中得到的有关前驱和后继的信息。
虽然我们可以在每个结点中增加两个指针域来存放遍历时得到的有关前驱和后继信息,但这样使得结构的存储密度大大降低。由于有n个结点的二叉链表中必定存在n+1个空链域,因此可以充分利用这些空链域来存放结点的前驱和后继信息。
下面我们对其做如下规定:若结点有左子树,则lchild域指示其左孩子,否则令lchild指示其前驱;若结点有右子树,其rchild域指示其右孩子,否则令rchild域指示其后继。
但是这样我们在进行线索化后,无法分辨lchild和rchild到底指示的是左右子树还是前驱后继,所以,为了加以区别,我们需要改变结点结构,增加两个标志域来做以区别。其结点具体结构如下图:
其中:
L
T
a
g
=
{
0
l
c
h
i
l
d
域
指
示
结
点
的
左
孩
子
1
r
c
h
i
l
d
域
指
示
结
点
的
前
驱
LTag=\begin{cases}0&lchild域指示结点的左孩子\\1&rchild域指示结点的前驱\end{cases}
LTag={01lchild域指示结点的左孩子rchild域指示结点的前驱
L
T
a
g
=
{
0
r
c
h
i
l
d
域
指
示
结
点
的
右
孩
子
1
r
c
h
i
l
d
域
指
示
结
点
的
后
继
LTag=\begin{cases}0&rchild域指示结点的右孩子\\1&rchild域指示结点的后继\end{cases}
LTag={01rchild域指示结点的右孩子rchild域指示结点的后继
由此,我们可以定义线索二叉树的存储表示:
typedef struct BiThrNode
{
TElemType data;
struct BiThrNode *lchild,*rchild;
int LTag,RTag;
}BiThrNode,*BiThTree;
这种结点结构构成的二叉链表作为二叉树的存储结构,叫做线索链表,其中指向结点前驱和后继的指针,叫做线索。加上线索的二叉树称之为线索二叉树。对二叉树以某种次序遍历使其变为线索二叉树的过程叫做线索化。
下面将给出一个线索二叉树的示意图来加强对其的理解:
上图分别为一个中序线索二叉树与与其对应的中序线索链表。其中实线为指针,指向左、右子树;虚线为线索,指向前驱和后继。为了方便起见, 仿照线性表的存储结构,在二叉树的线索链表上也添加一个头结点,并令其lchild域的指针指向二叉树的根结点,其rchild域指向中序遍历时访问的最后一个结点,同时,令二叉树中序序列中第一个结点的lchild域指针和最后一个结点rchild域的指针均指向头结点。这好比为二叉树建立了一个双向线索链表,既可从第一个结点起顺后继进行遍历,也可从最后一个结点起顺前驱进行遍历。
2.构造线索二叉树
由于线索二叉树构造的实质是将二叉链表中的空指针改为指向前驱或后继的线索,而前驱或后继的信息只有在遍历时才能得到,因此线索化的过程即为在遍历的过程中修改空指针的过程。下面重点介绍中序线索化的算法。
为了记下遍历过程中访问结点的先后关系,附设一个指针pre始终指向刚刚访问过的结点,而指针p指向当前访问的结点,由此记录下遍历过程中访问结点的先后关系。
下面我们先介绍对树中任意一个结点p为根的子树中序线索化的过程。
- 算法分析:
首先我们要知道,在前面创造二叉树及遍历二叉树的算法都用到了递归。所以对树的线索化操作,也可以用到递归来实现。
- 开始还是一个基本的操作,即判断p指向的结点是否为空,若不为空,则左子树递归线索化,再继续下面的步骤。
- 判断p的左孩子是否为空,若为空,则给p加上左线索,将其LTag置为1,让p的左指针指向pre(前驱);若不为空,则将p的LTag置为0。
由此我们确定了p所指结点的前驱(即pre所指结点)后,那么pre所指结点的后继也就是p所指的结点了,所以我们还应增加一步,使pre的右孩子指针指向p。
- 判断pre的右孩子是否为空,若为空,则给pre加上右线索,将其RTag置为1,让pre的右孩子指针指向p(后继);否则将pre的RTag置为0。
- 将pre指向刚访问过的结点p,即pre=p。
- 右子树递归线索化。
在这里我相信大家都会遇到一个疑问,为什么判断p不为空后直接对其左子树递归线索化,而算法结尾又对其右子树递归线索化?其实往往我们只需要明确我们现在在干什么,是在什么条件下做的就行了,每走一步就回忆一下整体的结构,那么这样就能帮助自己更好地理解。
对于上面的问题,首先,我们进行的是中序线索化,我们想一想,中序遍历是怎样遍历的?先递归遍历左子树,再访问根结点,再递归遍历右子树。遍历后我们就得到了一个线性化的序列,而线索正是建立在线性化的序列上的。所以回到上面的算法,p指向的是根结点,那么pre就应该是遍历左子树的最后一个结点,因此我们才要先递归将左子树线索化(也就是遍历,注意前面遍历的概念)。而倒数第二步使pre指向p所指的结点,也就是使pre指向根结点,然后递归将右子树线索化。所以说此时,pre的后继应该是遍历右子树的第一个结点。
下面我们给出具体算法实现
以结点p为根的子树中序线索化
- 算法实现
void InThreading(BiThrTree p)
{
if(p)
{
InThreading(p->lchild);
if(!p->lchild)
{
p->LTag=1;
p->lchild=pre;
}
else
p->LTag=0;
if(!pre->rchild)
{
pre->RTag=1;
pre->rchild=p;
}
else
pre->RTag=0;
pre=p;
InThreading(p->rchild);
}
}
注意:pre是全局变量,初始化时其右孩子指针为空,便于在树的最左点开始建立线索。
带头结点的二叉树中序线索化
设立头结点,也是为了更方便地对二叉树进行线索化。而头结点的左右孩子指针分别指向哪,也是我们自己做的规定,我们只需在算法中完成对头结点的各种操作即可(也相当于初始化)。
算法实现
void InOrederThreading(BiThrTree &Thrt,BiThrTree T)
{
Thrt=new BiThrNode; //建立头结点
Thrt->LTag=0; //头结点有左孩子,若树非空,则其左孩子为树根
Thrt->RTag=1; //头结点的右孩子指针为右线索
Thrt->rchild=Thrt; //初始化时右指针指向自己
if(!T) //若树为空,则左指针也指向自己
Thrt->lchild=Thrt;
else
{
Thrt->lchild=T; //头结点的左孩子指向根
pre=Thrt; //pre初值指向头接待你
InThreading(T); //调用上述算法,对以T为根的二叉树进行中序线索化
pre->rchild=Thrt; //调用结束后,pre为最右结点,pre的右线索指向头结点
pre->RTag=1;
Thrt->rchild=pre; //头结点的右线索指向pre
}
}
总结
遍历二叉树的结果就是将为非线性结构的二叉树线性化,使它有一定的次序,方便我们在计算机上使用。
那么线索二叉树又有啥用呢?对于遍历二叉树,我们只能从头遍历到尾,并不能从中间或从尾开始,这就使其效率十分地下,而线索二叉树正是为了解决这一不方便的问题。其结点的存储结构增加了两个域,也就是用空间换时间,不仅使其效率大大提高,也对未被利用的指针域进行了合理的利用。
当我们时常对正在学习的内容感到疑惑时,不妨再回头看看仔细前面的内容,会有意想不到的收获。