二叉树的存储结构
顺序存储
二叉树的顺序存储是通过一组连续的存储单元,按照自上而下、自左至右的顺序依次存储二叉树上结点的元素,具体是将二叉树上编号为i的结点元素存储在一维数组下标为i-1的位置。基于二叉树的性质,顺序存储方式特别适合完全二叉树和满二叉树。在这种存储结构中,结点的序号能够唯一地反映结点之间的逻辑关系。这种方式不仅能够最大限度地节省存储空间,还能利用数组元素的下标值来确定结点在二叉树中的位置,以及结点之间的父子、兄弟等关系。
对于一般二叉树,为了使数组下标能够体现结点之间的逻辑关系,必须在数组中插入一些不存在的空结点,将一般二叉树的结点与完全二叉树的结点结构相匹配,然后再将它们存储到一维数组的对应位置中。
但是可以看出,如果一颗二叉树如果每一层只有一个节点,这种存储方式会浪费很多存储空间。
链式存储
由于顺序存储方式的空间利用率较低,二叉树通常采用链式存储结构,通过链表节点来存储二叉树中的每个节点。在这种结构中,每个节点通常包含若干数据域和指针域。对于二叉树的二叉链表存储方式,每个节点至少包含三个域:数据域data"、左孩子指针域'lchild'和右孩子指针域'rchild'。
用代码表示二叉树的链式存储如下所示:
typedef struct BiTNode{
int data;
struct BiTNode *lchild,*rchild
}BiTNode,*BiTree;
线索二叉树
(本小节内容请看完二叉树的遍历后再回头看)
一般的链式存储会有下面的几个问题:
1、浪费存储空间。一般的二叉树链式存储结构有n+1个空链域。
2、给定序列时,查找某个节点的前驱后继困难。
3、遍历时间复杂度高。在传统二叉树的遍历中,通常需要使用递归或辅助栈来实现,这可能增加时间和空间复杂度。
为了解决这些问题,现在引入一个新的存储结构:线索二叉树。线索二叉树在传统的链式存储基础上增加了两个标志域:ltag和rtag。固定线性遍历序列时,规定节点若无左子树,令 1child 指向其前驱结点;若无右子树,令 rchild 指向其后继结点。线索二叉树节点结构示意如下:
其中,标志域含义如下:
线索二叉树的结构体定义如下:
typedef struct ThreadNode{
int data;
struct ThreadNode *lchild,*rchild;
int ltag,rtag;
}ThreadNode,*ThreadTree;
具体线索二叉树是如何建立、如何遍历的,下面会详细介绍。
二叉树的遍历
二叉树的遍历是指按照某种特定路径依次访问树中的每个结点,确保每个结点被访问一次且仅被访问一次。由于二叉树是一种非线性结构,每个结点可能包含两棵子树,因此需要确定一种访问顺序,将二叉树的结点排列成一个线性序列,从而实现有效的遍历。
根据二叉树的递归定义,遍历二叉树需要确定根结点(N)、左子树(L)和右子树(R)的访问顺序。按照“先左后右”的原则,常见的遍历次序有以下三种:
1. 先序遍历(NLR):先访问根结点(N),然后遍历左子树(L),最后遍历右子树(R)。
2. 中序遍历(LNR):先遍历左子树(L),然后访问根结点(N),最后遍历右子树(R)。
3. 后序遍历(LRN):先遍历左子树(L),然后遍历右子树(R),最后访问根结点(N)。
这里的“序”指的是根结点在遍历过程中被访问的先后顺序。
先序遍历
遍历思路
先序遍历(preorder)又称NLR遍历,即“根左右”。从字面意思可以看出,这种遍历方式先访问根结点(N),然后遍历左子树(L),最后遍历右子树(R)。这里给出一个实例:
对于上面这棵树,遍历过程如下:
第一轮:1(以2为根的左子树序列)(以3为根的右子树序列)
第二轮:12(以4为根的左子树序列)3(以5为根的左子树序列)
第三轮:12435
可以看出,遍历的过程是递归的,即不断访问根、左子树序列和右子树序列,直到没有左右子树。这种遍历方式得到的序列的第一个元素一定是根节点,最后一个元素是首先最靠右、然后最靠下的节点。
手算方法
这里给出两种常用的先序遍历手算方法。
法1:根左右递归法
第一轮按照“根左右”的顺序先读出根节点和其左右节点:123。
第二轮还是按照“根左右”的顺序读出根节点的左右节点的左右节点:1(空)24 (空)35。
第三轮再按照这个规则读出下一层节点的左右节点:1(空)246(空)(空)35。
得到遍历序列为124635。
可以看出这种手算方法思路在本质上与二叉树的递归遍历一致。
法2:点标记遍历法
第一步,在所有节点的左边画上一个圆点,如下图所示:
第二步,从根节点的左边开始,按照下图箭头的前进方向,画一条能够把二叉树包起来、并连接所有圆点的线。
第三步,按照顺序依次读出所有节点,可以看到为124635。
那有人就纳了闷了,这咋这么神奇,怎么做到的?其实思路和非递归算法遍历二叉树有点像,请看下文解析。
递归代码实现
递归遍历二叉树的伪代码如下:
void PreOrder(BiTtree T){
if(T!=nullptr){
visit(T); //读取根节点(这里visit不是标准库里的函数,是伪代码)
PreOrder(T->lchild); //遍历左子树
PreOrder(T->rchild); //遍历右子树
}
}
非递归代码实现
要想通过非递归算法完成二叉树树的遍历,我们要借助栈这种工具来实现。现在先不考虑树的序列输出问题,只是想通过一个指针“走过”二叉树的每一个节点,这里给出一种利用栈的遍历方法。
对于一棵树,我们先一直往左下走。如果遇到没有左孩子的情况(即往左下走到null节点),就回退至上一个访问的节点,并且往该节点的右下走一格,然后继续一直往左下走。
如果按照上面给出的方法,到这一步之后就会出现问题。如果指针继续回退回6,那么下一步又要走6左下角的null节点了,这就进入了一个死循环,所以这里需要使用栈。如果我们往左下走时遇到null(即没有左孩子),此时先访问最后访问的一个节点,然后将该节点弹出去,避免后面再次对其访问,就能够避免这种循环。而这种后入后出(迟到早退(bushi))的原则符合栈的特性。所以这里我们设立一个栈,将访问过的所有节点按次序入栈,向左访问到null时指针回退至栈顶元素位置,并将栈顶元素出栈。现在再模拟一遍上面的流程。
可以看到到这里之后,就不会再出现死循环的情况了,继续模拟下去也是能够遍历整个二叉树的。遍历终止时,指针停止在最右下角的null节点上,此时指针为空并且栈为空。
通过这种方式遍历二叉树,每个节点相当于被访问了两次——进栈一次、出栈一次,这在后面输出二叉树顺序序列时会有大用,请先记住这个特性。
综上,使用栈纯遍历(不输出序列)二叉树的思路如下:
1、一直往二叉树的左孩子遍历,将访问元素依次压入栈中;如果遇到null元素,指针回退回栈顶元素,并让栈顶元素出栈
2、访问当前指针所在位置的右孩子,重复步骤1。
这里给出伪代码:
void OrderTree(BiTree T){ //纯净遍历二叉树
Initstack(S); //初始化栈
BiTree p=T; //定义遍历指针p
while(p||!IsEmpty(S)){ //栈和指针有一个不为空时循环
if(p!=nullptr){
Push(S,p); //p不空时入栈
p=p->lchild; //p往左下走
}
else{
Pop(S,p); //读取栈顶元素并将其弹出
p=p->rchild; //转向右下角一格
}
}
}
对于上面的代码,有几点需要说明:
1、里面有关于栈的函数,请跳转至【数据结构】栈的基本概念和操作。
2、栈里存储的是p的值,即二叉树节点的地址,而不是二叉树的节点数据域。
3、BiTree是二叉树节点BiTNode的指针类型,可以翻到上面二叉树的链式存储定义。
那现在问题来了,如果我要按照先序序列输出二叉树,那么应该怎么做呢?
我们已经知道了先序遍历是按照“根左右”的方式递归地输出二叉树,那么不难发现(Attention is all u need),对于我们上面这种遍历二叉树的方法,如果在初次访问二叉树的节点(即路径第一次经过节点,也就是前面提到的节点入栈时)时就输出当前访问的节点值,那么得到的序列与先序遍历相同。所以我们只需要在代码中节点入栈时加一个输出语句就可以了,代码如下:
void PreOrder2(BiTree T){ //先序遍历二叉树
Initstack(S); //初始化栈
BiTree p=T; //定义遍历指针p
while(p||!IsEmpty(S)){ //栈和指针有一个不为空时循环
if(p!=nullptr){
visit(p); Push(S,p); //p不空时输出节点并入栈
p=p->lchild; //p往左下走
}
else{
Pop(S,p); //读取栈顶元素并将其弹出
p=p->rchild; //转向右下角一格
}
}
}
这也解释了为什么我们手算的法二能够输出先序遍历序列,因为你只要模拟一下两种方法的遍历路径,就会发现他们是一样的。
黄色路径为手算方法路径,红色为非递归方法读取的路径。
ps:其实仔细思考一下,这种遍历方法与递归遍历是相似的,不过一个是在自己创建的栈里存储,另一个是在递归工作栈里存储。
中序遍历
遍历思路
中序遍历(inorder)又称LNR遍历,即“左根右”。从字面意思可以看出,这种遍历方式先遍历左子树(L),然后访问根结点(N),最后遍历右子树(R)。这里给出一个实例:
对于上面这棵树,遍历过程如下:
第一轮:(以2为根的左子树序列)1(以3为根的右子树序列)
第二轮:(以4为根的左子树序列)21(以5为根的左子树序列)3
第三轮:42153
这种遍历方式得到的序列的第一个元素一定是最靠左、然后最靠下的节点,最后一个元素是首先最靠右、然后最靠下的节点。
手算方法
这里给出两种常用的中序遍历手算方法。
法1:左根右递归法
第一轮按照“左根右”的顺序先读出根节点和其左右节点:213。
第二轮还是按照“左根右”的顺序读出根节点的左右节点的左右节点:(空)241(空)35。
第三轮再按照这个规则读出下一层节点的左右节点:(空)2641(空)35。
得到遍历序列为264135。
法2:点标记遍历法
第一步,在所有节点的下面画上一个圆点,如下图所示:
第二步,从根节点的左边开始,按照下图箭头的前进方向,画一条能够把二叉树包起来、并连接所有圆点的线。
第三步,按照顺序依次读出所有节点,可以看到为264135。
递归代码实现
中序遍历二叉树代码如下:
void InOrder(BiTree T){
if(T!=nullptr){
InOrder(T->lchild); //递归遍历左子树
visit(T); //访问根节点
InOrder(T->rchild); //递归遍历右子树
}
}
非递归代码实现
与先序遍历二叉树类似,观察(手算方法之二)可以发现,在中序遍历过程中,输出序列的生成时机可以这样理解:当路径第二次访问某个节点时(假设每个节点左侧和正下方有一个虚拟的“圆点”,经过正下方的圆点等价于第二次访问节点),该节点被访问并输出。而前面二叉树纯遍历思路中提到了这种遍历方法等价于每个节点被访问了两次——进栈一次、出栈一次,所以我们只需要在节点出栈时(也就是所谓第二次被访问时)输出节点值,就可以得到中序输出序列,代码如下:
void InOrder2(BiTree T){ //中序遍历二叉树
Initstack(S); //初始化栈
BiTree p=T; //定义遍历指针p
while(p||!IsEmpty(S)){ //栈和指针有一个不为空时循环
if(p!=nullptr){
Push(S,p); //p不空时入栈
p=p->lchild; //p往左下走
}
else{
Pop(S,p);visit(p); //读取栈顶元素并将其弹出,输出栈顶元素
p=p->rchild; //转向右下角一格
}
}
}
这里po上一道很有意思的题目:
先序序列为a、b、c、d的二叉树有多少棵?
首先暴力枚举肯定是能做的,但是假如先序序列中有100个元素,那么你将无从下手。其实观察先序遍历和中序遍历的非递归代码,你就会发现先序遍历是在进栈的时候访问节点,而中序遍历是在出栈的时候访问节点。所以给定先序序列相当于给出入栈顺序,又中序序列与先序序列能够唯一确定一棵树,所以题目等价于求出所有可能的中序序列,即所有的出栈序列数。这大家应该很熟悉了,答案其实就是卡特兰数。(【数据结构】栈与卡特兰数(详细))
后序遍历
遍历思路
后序遍历(postorder)又称LRN遍历,即“左右根”。从字面意思可以看出,这种遍历方式先遍历左子树(L),然后遍历右子树(R),最后访问根结点(N)。这里给出一个实例:
对于上面这棵树,遍历过程如下:
第一轮:(以2为根的左子树序列)(以3为根的右子树序列)1
第二轮:(以4为根的左子树序列)2(以5为根的左子树序列)1
第三轮:42531
这种遍历方式得到的序列的第一个元素一定最靠下、然后最靠左的节点,最后一个元素是根节点。
手算方法
这里给出两种常用的后序遍历手算方法。
法1:左右根递归法
第一轮按照“左右根”的顺序先读出根节点和其左右节点:231。
第二轮还是按照“左根右”的顺序读出根节点的左右节点的左右节点:(空)42(空)53 1。
第三轮再按照这个规则读出下一层节点的左右节点:(空)642(空)531。
得到遍历序列为642531。
法2:点标记遍历法
第一步,在所有节点的右边画上一个圆点,如下图所示:
第二步,从根节点的左边开始,按照下图箭头的前进方向,画一条能够把二叉树包起来、并连接所有圆点的线。
第三步,按照顺序依次读出所有节点,可以看到为642531。
递归代码实现
递归代码如下所示:
void PostOrder(BiTree T){
if(T!=nullptr){
PostOrder(T->lchild); //递归遍历左子树
PostOrder(T->rchild); //递归遍历右子树
visit(T); //访问根节点
}
}
非递归代码实现
后序遍历的非递归代码与前中序不完全相同,因为我们知道按照递归法从来看,我们需要输出完了某个根节点的左右子树才能够输出该根节点,按照前面提到的纯净二叉树遍历方法,此时根节点很有可能已经不在栈里了。并且按照手算方法之二,我们需要在路径第三次经过节点(假设左与下各有一个虚假的圆点)的时候才能够输出该节点,但是入栈出栈也就只有两次访问,没有第三次访问。
那我们要怎么控制后序遍历的输出序列呢?这里换一个与上面不同的例子,且看下图:
现在还是按照前面的二叉树遍历方法,如果我们遍历到了4这个节点,并且我们需要把他输出,根据“左右根”的逻辑,4的右子树必须在4之前被输出。而我们观察得到,4根本没有右子树,所以可以直接输出。
同理,我们遍历完4之后,指针移动路径为4->2->5->7->5->2,由于7和5都没有右子树,所以7和5在这个过程中都可以直接输出。但是到了2这里,可以看到2有一个右孩子5,即2->rchild不为null。咋办?首先我们知道5已经被输出了(但是计算机不知道),出于最朴素的想法,我们可以设一个临时数组存储所有已经被输出的节点,然后在输出下一个节点时寻找一下这个数组里面有没有待输出节点的右孩子节点。但是由于后序遍历的特殊性,很容易可以发现后序遍历序列中,一个节点的前驱一定是他的右孩子(如果有右孩子),所以我们没有必要去整一个很大很长的数组,只需要一个小小的临时指针标记上一个被输出的元素即可。这里可以再模拟一下我们需要输出2的时候临时指针的位置,且看下图:
这时我们加个判定条件:if(p的右孩子是被标记的元素,即上一个被输出的元素),则输出p。很容易发现我们可以成功把2输出。
而在第一遍过到2的时候(从头开始的路径为1->2),由于没有元素出栈,所以标记指针为空,不是2的右孩子,所以不会输出2。
第二遍过到2的时候(从头开始的路径为1->2->4->2,即指针回退回2时),由于上一个出栈元素为4,不是2的右孩子,所以不会输出2。
综上,我们已经成功地控制了后序输出序列,无敌了,不信你可以再抓个其他节点模拟一遍。所以这里多嘴提一句,这些所谓算法真没想象中这么难,一步一步拆解模拟很容易就能够理解,别给自己上压力。
非递归后序遍历二叉树代码如下:
void PostOder2(BiTree T){
InitStack(S); //初始化栈
BiTree p=T; //主指针
BiTree r=nullptr; //标记指针
while(p||!IsEmpty(S)){ //两个空退循环
if(p!=nullptr){
Push(S,p); //非空入栈
p=p->lchild; //往左下走
}
else{
GetTop(S,p); //若空则主指针回退至栈顶
if(p->rchild==nullptr||p->rchild==r){ //若右孩子为空或被访问过
Pop(S,p); //出栈
visit(p); //输出节点
r=p; //重置标记指针
p=nullptr; //p需要置为null,进入下面的else实现回退,防止进入上面的if语句
}
else
p=p->rchild; //向右下角走一格
}
}
}
这里注意弹出栈顶之后p需要置空,否则p不会读取新的栈顶元素,而是会进入到非空的if循环继续往左下走,然后进入死循环。
层次遍历
遍历思路
层次遍历(Level-order Traversal)又称广度优先遍历(Breadth-First Search, BFS)。与先序遍历不同,层次遍历按照从上到下、从左到右的顺序逐层访问二叉树中的每个节点。具体来说,它从根节点开始,先访问根节点,然后依次访问根节点的左子树和右子树,接着访问下一层的节点,直到所有节点都被访问完毕。下面给出一个实例:
第一轮:123
第二轮:124536
第三轮:1245736
由于层次遍历比较简单,这里就不多模拟了。
代码实现
层次遍历通常借助队列实现。遍历过程如下:
将根节点入队。
每次从队列中取出一个节点,访问该节点。
如果该节点有左子树,则将左子树的根节点入队;如果有右子树,则将右子树的根节点入队。
重复步骤2和3,直到队列为空。
代码如下:
void LevelOrder(BiTree T){
InitQuene(Q); //初始化队列
Bitree p; //临时指针
EnQuene(Q,T); //根节点入队
while(!IsEmpty(Q)){ //对非空
DeQuene(Q,p); //出队并且赋值给p
visit(p); //输出p
if(p->lchild!=nullptr) //左孩子非空左孩子入队
EnQuene(Q,p->lchild);
if(p->rchild!=nullptr) //右孩子非空右孩子入队
EnQuene(Q,p->rchild);
}
}
线索二叉树的遍历和建立
建立线索二叉树
前面提到了线索二叉树的基本结构和建立逻辑,我们称线索二叉树的建立为二叉树的线索化,也就是将二叉链表中的空指针改为指向前驱或者后继的线索。
我们可以对一个二叉树建立对应的前中后序线索二叉树,实现的方法也很简单,就是在进行前中后序遍历的过程中检查对应节点的左右孩子是否为空,如果为空,就将其分别连上前驱或者后继。所以这里需要两个指针,一个指针T指向当前访问的节点,一个指针pre指向前一个访问的节点。如果当前访问的节点没有左子树,则将当前指针T的lchild指针指向前驱节点pre;如果前一个访问的节点没有右子树、又pre不为空,则将pre的rchild连上当前指针T。
这里首先给出先序遍历的代码:
void PreThread(ThreadTree &T,TreadTree &pre){
if(T!=nullptr){ //访问到空节点退出递归
if(T->lchild==nullptr){ //左子树空,前驱线索连上pre,ltag更新1
T->lchild=pre;
T->ltag=1;
}
if(pre!=nullptr&&pre->rchild==nullptr){ //pre右子树空,后继线索连上T,rtag更新1
pre->rchild=T;
pre->rtag=1;
}
pre=T; //在下次递归之前将pre重置为上一个节点
if(T->ltag==0)
PreThread(T->lchild,pre); //正确递归遍历左子树
if(T->rtag==0)
PreThread(T->rchild,pre); //正确递归遍历右子树
}
}
这里有几点需要注意:
1、这段代码看着很奇怪,但其实结构与先序递归遍历二叉树是一样的,只是把visit换成了线索化语句,递归函数多加了个pre参数。
2、由于需要递归地将二叉树更新为线索二叉树,所以函数形参这里需要加引用符号&。
3、递归的终止条件是访问到空节点,这点需要自己模拟一下。
4、递归之前加上判断语句T->tag==0是因为二叉树线索化在递归访问之前。如果遇到一个节点T没有左子树,此时由于线索化语句将T->lchild更新成了其前驱,所以递归的时候会错误地进入到他的前驱节点里。
下面给出中序遍历线索二叉树的代码:
void InThread(ThreadTree &T,ThreadTree &pre){
if(T!=nullptr){
InThread(T->lchild,pre);
if(T->lchild==nullptr){
T->lchild=pre;
T->ltag=1;
}
if(pre!=nullptr&&pre->rchild==nullptr){
pre->rchild=T;
pre->rtag=1;
}
pre=T;
InThread(T->lchild,pre);
}
}
与先序遍历基本一样,但是递归之前不需要加判断语句了,具体原因可以实际模拟一下,不会出现先序遍历的那种情况。
后续遍历的建树代码就是中序调个顺序,这里懒得写了。
遍历线索二叉树
在遍历线索二叉树前,有一些二叉树序列的规律需要知道。对于先序二叉树序列,第一个元素一定是根节点;对于中序二叉树序列,第一个元素一定是最靠左然后最靠下的元素;对于后序二叉树序列,第一个元素一定是最靠下然后最靠左的元素。
有了这三个铺垫之后,我们开始正式书写遍历线索二叉树的算法。
对于先序遍历线索二叉树,对于一个树上的节点p,可以给出三个规则:
1、如果该节点有左孩子,则其后继必是左孩子。
2、如果没有左孩子而有右孩子,则其后继必是右孩子。
3、如果没有左孩子又没有右孩子(rtag=1),则其后继是rchild(线索后继)。
于是我们可以写出线索二叉树树先序遍历代码:
void PreOrderThreaded(ThreadTree T) {
ThreadTree p = T;
while (p != nullptr) {
visit(p); // 访问当前节点
if (p->ltag == 0)
p = p->lchild; // 继续访问左子树
else {
while (p != nullptr && p->rtag == 1)
p = p->rchild; // 沿着后继线索向右移动
if (p != nullptr)
p = p->rchild; // 进入右子树
}
}
}
对于中序遍历线索二叉树,我们可以确定一个节点p的后继节点必是其右子树的最靠左然后最靠下的节点。那么我么可以很轻易地写出代码:
void InOrderThreaded(ThreadTree T) {
ThreadTree p = T;
while (p != nullptr && p->ltag == 0) // 找到中序遍历的起点
p = p->lchild;
while (p != nullptr) {
visit(p); // 访问当前节点
if (p->rtag == 1)
p = p->rchild; // 沿着线索跳到后继
else
p = p->rchild;
while (p != nullptr && p->ltag == 0) // 找到右子树的最左节点
p = p->lchild;
}
}
}
后序遍历线索二叉树较为复杂,这里不写代码了,给出简单的推理逻辑。如果节点 x
是二叉树的根节点,则它没有后继;如果 x
是其双亲的右孩子,或者是左孩子但双亲没有右子树,则它的后继是它的双亲;如果 x
是其双亲的左孩子,并且双亲有右子树,则它的后继是双亲的右子树中在后序遍历序列中的第一个节点。
利用遍历序列确定树
对于一棵给定的二叉树,其先序序列、中序序列、后序序列和层序序列都是确定的。然而,只给出四种遍历序列中的任意一种,却无法唯一地确定一棵二叉树。但是若已知中序序列,再给出其他三种遍历序列中的任意一种,就可以唯一地确定一棵二叉树。
这是因为在先序序列中,第一个结点一定是二叉树的根结点;而在中序遍历中,根结点必然将中序序列分割成两个子序列,前一个子序列是根的左子树的中序序列,后一个子序列是根的右子树的中序序列。左子树的中序序列和先序序列的长度是相等的,右子树的中序序列和先序序列的长度是相等的。根据这两个子序列,可以在先序序列中找到左子树的先序序列和右子树的先序序列。
这里给出一个例子:
已知先序序列为ABCDEFGHI,中序序列为BCAEDGHFI,解题过程如下:
其他的序列遍历方法也与上面类似,不过多赘述。