目录
注
树和二叉树的定义
树的定义
树的基本术语
二叉树的定义
树和二叉树的抽象数据类型定义
二叉树的性质和存储结构
二叉树的性质
二叉树的存储结构
1. 顺序存储结构
2. 链式存储结构
遍历二叉树和线索二叉树
遍历二叉树(traversing binary tree)
1. 算法描述
2. 根据遍历序列确定二叉树
3. 二叉树遍历算法的应用
线索二叉树(Threaded Binary Tree)
1. 线索二叉树的基本概念
2. 构造线索二叉树
3. 遍历线索二叉树
本笔记主要参考《数据结构(C语言版)》
树结构是一种非常重要的非线性数据结构,这种层次结构以分支关系定义。树结构非常常见,在操作系统、编译系统或者数据库中都有树的身影。
树(tree)是 n(n ≥ 0)个结点的有限集,通过n,可以将树归为:① 空树(n = 0);② 非空树(n > 0)。
而对于非空树T:
例如:
其中,对于(b)中一般的树,可以认为:
树的结构定义是一个递归的定义,即在 树的定义中 又用到了树的定义。树的表示方式有很多,譬如:
之所以存在多种多样的表示方式,说明了树结构在日常生活及计算机程序设计中的重要性。
基本术语 | 解释 | 例子 |
结点 | 树中的一个独立单元 (包含了一个数据元素和若干指向其子树的分支) |
下图中的A、B、C等。 |
结点的度 | 结点拥有的 子树的数量 称为结点的度 | 下图中,A的度为3,C的度为1,F的度为0。 |
树的度 | 树的度是树内 各结点的度 的最大值 | 下图中,树的度为3。 |
叶子 | 度为0的结点称为 叶子 或 终端结点 | 下图中,结点K、L、F、G、M、I、J都是树的结点。 |
非终端结点 | 度不为0的结点被称为 非终端结点 或 分支结点 (除根结点外,非终端结点又称内部结点) |
基本术语 | 解释 | 例子 |
双亲、孩子 | 结点的 子树的根 称为该节点的孩子 (相应地,该结点称为其孩子的双亲) |
上图中,B的双亲是A, B的孩子是E和F。 |
兄弟 | 同一个双亲的孩子之间互相称为兄弟 | 上图中,H、J和L互为兄弟。 |
祖先 | 从根到该结点 所经分支上的所有结点 都是该结点的祖先 | 上图中,M的祖先是A、D和H。 |
子孙 | 以某结点为根的子树,该子树上的任一结点 都称为该结点的子孙 | 上图中,B的子孙是E、K、L和F。 |
堂兄弟 | 双亲在同一层 的结点互为堂兄弟 | 结点G和E、F、H、I、J互为堂兄弟。 |
基本术语 | 解释 | 例子(说明) |
层次 | 结点的层次 从根开始 定义 | 如上图所示,树中任一结点的层次等于其双亲结点的层次加1。 |
树的深度 | 树中结点的 最大层次 称为树的深度或高度 | 上图中,树的深度为4。 |
有序树 | 如果树中任意结点的子树之间(从左到右)存在顺序关系,称该树为有序树 | 有序树中: 第一个孩子:最左边的子树的根; 最后一个孩子:最右边的子树的根。 |
无序树 | 树中的任意结点的子树之间不存在顺序关系 | |
森林 | 是m(m ≥ 0)棵互不相交的树的集合 (对于树中的每个结点而言,其子树的集合就算森林) |
通过上述定义可知,可以用森林和树互相递归的定义描述树。
就逻辑结构而言,任何一颗树都是一个二元组 Tree = (root, F),其中:
当m ≠ 0时,树根和其子树森林之间存在下列关系:
二叉树(Binary Tree)是n(n ≥ 0)个结点所构成的集合,它可以为空树(n = 0),或者是非空树。
对于非空树T:
二叉树和树一样,都具有递归的性质。而二者的差别主要体现在:
二叉树的递归定义表明:二叉树或为 空 ,或为 一个根结点加上两棵互不相交的二叉树(左子树和右子树)组成(两棵子树也可以是空树)。
设A、B是集合,离散数学中的一些定义如下:
- A - B:是属于集合的减法,它表明的是属于A,但不属于B的所有元素组成的集合;
- Ø:空集(也可通过Φ表示),即是指不含任何元素的集合;
- 划分:若把集合A分成若干个叫做分块的非空子集,并且集合A中的任一元素属于并且仅属于一个分块,那么把这些分块构成的集合称为划分;
- 下述的R:简单地说,是D中任意两个元素之间的关系的集合(笛卡尔乘积)。
树的抽象数据类型定义:
二叉树的抽象数据类型定义:
二叉树具有三个重要的性质:
另外,还存在两种特殊形态的二叉树,称为 满二叉树 和 完全二叉树 :
---
对于满二叉树而言:
对二叉树的结点进行编号,约定:编号从根结点开始,自上而下,从左到右。
而对于完全二叉树而言:
可以把满二叉树看作是一种特殊的完全二叉树。
同时,完全二叉树还具有两个重要的特性:
类似于线性表,二叉树的存储结构也可分为顺序存储和链式存储两种结构。
//#define TElemType int //自行定义TElemType的类型
//
#define MAXSIZE 100 //二叉树的最大结点数
typedef TElemType SqBiTree[MAXSIZE]; //其中,下标为0的单元存储根结点
SqBiTree bt;
顺序存储结构通过一组地址连续的存储单元来存储数据元素,并且,为了能够反映出存储结构中的结点之间的逻辑关系,需要将二叉树中的结点按照一定的规律安排到这组单元内:
在最坏的情况下,一个深度为k且只有k个结点的单支树(数中不存在度为2的结点)却需要长度远超k的一维数组进行存储。
由此可知,顺序存储结构仅适用于完全二叉树。一般二叉树更适合链式存储结构。
根据结点结构的不同,存在不同形式的链式存储结构。
#define TElemType int
typedef struct BiTNode {
TElemType data; //结点的数据域
struct BiTNode* lchild, * rchild; //结点的左、右孩子的指针域
}BiTNode, * BiTree;
对应于上述的结点结构,存在相应的存储结构:
在不同的层次结构中,实现二叉树的操作方式也会有所不同。就比如 PARENT(T, e)(寻找结点x的双亲),在三叉链表中(因为有存储双亲地址的指针域)可以轻松找到目标;而在二叉链表中则需要从根结点开始巡查。
因此,在考虑二叉树的存储结构时,还应该考虑这个二叉树将要进行何种操作。
作用:
算法要实现的目标:按某条搜索路径巡防树中的每个结点,使得每个结点均被访问,且仅被访问一次。
||| 访问的含义:对结点进行各种处理,包括 输出结点的信息 ,对结点进行运算和修改 等。
遍历二叉树是二叉树中最基本的操作。
遍历的实质是对二叉树进行线性化的过程,遍历的结果是将 非线性结构(的树)中的结点排成一个线性序列。为了实现这个目标,就需要寻找能够排列二叉树上的结点的规律。
由于二叉树是由3个基本单元组成的:根结、左子树和右子树。因此,如果要遍历整个二叉树,就是要遍历这三个部分。现在假设:
由上述假设可以得到6种遍历二叉树的方案:DLR、DRL、LDR、LRD、RDL、RLD。在此之上,对遍历方案进行限制,要求遍历顺序必须是先左后右,由此选出3种方案,根据遍历根结点的先后顺序,命名得到:
操作 | 名称 | 操作定义 (若二叉树为空,进行空操作) |
DLR | 先(根)序遍历 | 1. 访问根结点; 2. 先序遍历左子树; 3. 先序遍历右子树。 |
LDR | 中(根)序遍历 | 1. 中序遍历左子树; 2. 访问根结点; 3. 中序遍历右子树。 |
LRD | 后(根)序遍历 | 1. 后序遍历左子树; 2. 后序遍历右子树; 3. 访问根结点。 |
例如,下方的二叉树表示的是表达式 a + b * (c - d) - e / f:
例子:中序算法
约定:在该中序算法中,对于结点的访问即为数据的输出。
【递归版本】
void InOrderTraverse(BiTree T)
{//中序遍历二叉树T的递归算法
if (T) //若二叉树非空
{
InOrderTraverse(T->lchild); //中序遍历左子树
cout << T->data; //访问根结点
InOrderTraverse(T->rchild); //中序遍历右子树
}
}
(ps:如果改变上述语句的先后顺序,就可以实现先序和后序遍历的递归算法。)
由上述代码可知,3种遍历的不同仅在于访问次序的差异。因此,如果从递归的角度讨论先序、中序和后序遍历,会发现三者是完全相同的:
现在要求通过中序遍历的方式遍历上述二叉树,由代码可得遍历的执行过程:
【非递归版本(使用栈)】
根据目前的知识,可以把上述的递归算法改为非递归算法:
void inordertraverse(BiTree T)
{//中序遍历二叉树T的非递归算法(栈)
SqStack S;
InitStack(S);
BiTree p = T; //初始化p,用来指向二叉树T的根结点
BiTree q = new BiTNode; //申请一块结点空间,用来存放栈顶弹出的元素
while (p || !StackEmpty(S)) //p非空且栈非空
{
if (p) //p非空
{
Push(S, p); //根指针进栈
p = p->lchild; //遍历左子树
}
else
{
Pop(S, q); //退栈
cout << q->data << " "; //访问根结点
p = q->rchild; //遍历右子树
}
}
DestoryStack(S);
}
程序执行的结果如下:
【分析】
在上述程序中,栈S的变化过程如下:
在遍历二叉树中,因为每个结点都被访问了一次,因此无论是递归还是非递归算法,其时间复杂度都是O(n)。而由上图可知,辅助空间的大小就是树的深度,因此,在最坏情况(树的深度为n的情况)下,空间复杂度也是O(n)。
之前提到过,除了前序、中序和后序外,还有一种按层次(从上到下,从左到右)遍历二叉树的顺序 —— 层序。在上述二叉树中,如果使用层序遍历,得到的遍历序列是:-*cab。
(另外,层序遍历的算法可以通过队列进行实现。)
从之前的探讨中可以得出结论:若二叉树的各结点的值均不同,则任意二叉树结点的先序序列、中序序列和后序序列是唯一的。
反过来,通过这个结论也可以得出一个推论:由二叉树的先序序列和中序序列(或后序序列和中序序列)能唯一确定一棵二叉树。
上述推理过程确定了包括根结点在内的三个结点。而如果继续拆分得到的两个子序列,就可以继续取得各个结点,最终就可以得到一棵二叉树。
同理,也可以由后序序列和中序序列确定唯一的一棵二叉树(后序序列的最后一个结点就是当前的根结点)。
【例子】
设存在一棵二叉树,有:
要求:画出该二叉树。
【分析】
由于无法区分左、右子树,因此一棵二叉树的先序序列和后序序列无法唯一确定一棵二叉树。
作为二叉树各种操作的基础,“遍历” 会因为 访问结点 这一具体操作的不同而产生不同作用。譬如,假设 “访问” 能够判别结点、计数或者生成结点等,就可以解决一些更加实际的问题。
【例子:创建二叉树的存储结构(二叉链表)】
提示:为了创建二叉树,需要在遍历过程中生成结点。并且约定
【代码】
void CreateBiTree(BiTree& T)
{//按先序次序输入二叉树中结点的值(一个字符),创建二叉链表表示的二叉树T
char ch;
cin >> ch;
if (ch == '#') //递归结束,当前结点为空
T = NULL;
else //递归,创建二叉树
{
T = new BiTNode; //生成当前结点
T->data = ch;
CreateBiTree(T->lchild); //递归创建左子树
CreateBiTree(T->rchild); //递归创建右子树
}
}
由代码逻辑可知,'#' 代表空结点。
譬如,执行程序,输入 ABC##DE#G##F### ,可以创建二叉树:
------
【例子:复制二叉树】
要求:复制已有的一棵二叉树,得到与该二叉树完全相同的另一棵二叉树。
【代码】
void Copy(BiTree T, BiTree& NewT)
{//复制一棵和T完全相同的二叉树
if (T == NULL) //若为空结点,则当前递归结束
{
NewT = NULL;
return;
}
else
{
NewT = new BiTNode;
NewT->data = T->data; //复制根结点
Copy(T->lchild, NewT->lchild); //递归复制右子树
Copy(T->rchild, NewT->rchild); //递归复制左子树
}
}
【分析】
复制二叉树主要考虑两种情况:
类似于遍历二叉树,上述的算法也是进行按照先序进行,按照 ①根结点;②左子树;③右子树 的方式进行浏览。
不推荐的非递归算法:
void Copy(BiTree T, BiTree& NewT) {//中序遍历二叉树T的非递归算法(栈) SqStack S; InitStack(S); BiTree p = T; BiTree q = new BiTNode; NewT = new BiTNode; BiTree r = NewT; BiTree s = new BiTNode; while (p || !StackEmpty(S)) { if (p) //若p非空 { Push(S, p); //根指针进栈 p = p->lchild; //遍历左子树 Push(S, r); //将NewT的结点也入栈 if (p) //如果该层次是空,则不建立结点 r->lchild = new BiTNode; r = r->lchild; } else { Pop(S, s); //后进先出 Pop(S, q); //退栈 s->data = q->data; p = q->rchild; //遍历右子树 r = NULL; if (p) //若右子树为空,则不建立结点 s->rchild = new BiTNode; r = s->rchild; } } DestoryStack(S); }
在遍历二叉树的过程中,递归的层次 ≤ 树的深度,也就是说,递归的层次是有限的。而在相同情况下,如果使用非递归算法,代码不仅会显得麻烦,并且会难以理解。
(ps:如果一定要使用非递归算法,可考虑使用两个栈或者双栈结构。)
------
【例子:计算二叉树的深度】
二叉树的深度就是树中结点的最大层次(即其左、右子树深度的较大者加1)。
【代码】
int Depth(BiTree T)
{//计算二叉树T的深度
if (T == NULL)
return 0;
else
{
int m = Depth(T->lchild); //递归,计算左子树的深度
int n = Depth(T->rchild); //递归,计算右子树的深度
if (m > n) //求出两棵子树深度的较大值
return m + 1;
else
return n + 1;
}
}
该算法是建立在后序遍历二叉树的算法基础上的(先访问左子树,再访问右子树,最后考虑根结点)。
------
【例子:统计二叉树中结点的个数】
结点个数 = 左子树的结点个数 + 右子树的结点个数 + 1(根结点)
int NodeCount(BiTree T)
{//统计二叉树T中结点的个数
if (T == NULL) //若遇到空结点,则返回0
return 0;
else //结点个数通过公式计算得到
return NodeCount(T->lchild) + NodeCount(T->rchild) + 1;
}
从上述讨论可知,遍历二叉树就是将二叉树中的结点按照一定的规律进行排列,这实际上是对一个非线性结构进行的线性化操作。
但是,在实际操作中也可以看到:当二叉链表作为存储结构时,结点中只存储左、右孩子信息,而结点在任一序列中的直接前驱和直接后驱是无法得到的。
如:中序序列a+b*c,其中 “b” 的前驱是 “+” ,后驱是 “*” 。而这种信息只存在于遍历的动态过程中。
很显然,这在某些时候无法满足程序的需要。
当然,可以在 每个结点 中增加指针域来存储有关前驱和后驱的信息,但这样做的代价就是结构的存储密度的大幅降低。
为此,就需要引入线索二叉树的概念,通过它,可以保存那些原本只存在于动态过程中的前驱和后驱信息。
||| 规定:
如图所示:
二叉树的二叉线索类型定义:
typedef struct BiThrNode
{
TElemType data;
struct BiThrNode* lchild, * rchild; //左、右孩子指针
int LTag, RTag; //左、右标志
}BiThrNode, *BiThrTree;
由此而产生的各种概念:
概念 | 内容 |
线索链表 | 存储结构是 由上述这种结点结构构成的二叉链表 |
线索 | 上述结点结构中指向前驱和后驱的 指针 |
线索二叉树 | 加上线索的二叉树 |
线索化 | 对二叉树以某种次序遍历,使其变为线索二叉树的 过程 |
例如:下图所示为一中序线索链表
在上图中:
线索二叉树构造的实质:将二叉链表中的空指针改为指向其前驱或后驱的线索。
【代码】
其中 指针变量pre 是提前设置的全局变量,它始终是 p 的前驱。
① 不带头结点的版本
void InThreading(BiThrTree p)
{
if (p)
{
InThreading(p->lchild); //左子树递归线索化
if (!p->lchild) //p的左孩子为空
{
p->LTag = 1; //给p加上做线索
p->lchild = pre; //p的左孩子的指针指向前驱(pre)
}
else
p->LTag = 0;
if (!pre->rchild) //pre的右孩子为空
{
pre->RTag = 1; //给pre加上右线索
pre->rchild = p; //pre的右孩子的指针指向后继(p)
}
else
pre->RTag = 0;
pre = p; //移动pre,令pre始终都是p的前驱
InThreading(p->rchild); //右子树递归线索化
}
}
【注】
在实际的操作中,若全局变量pre没有被主动分配指向空间,它将默认为一个空指针(NULL)。为此,可以使用new进行动态内存分配;
另外,在编写代码及测试中,在函数外部无法定义结构体的成员。若要进行测试,可以将结构体pre的变量定义放到主函数内。
---
② 带头结点的版本(需要使用上述的函数InThreading)
void InOrderThreading(BiThrTree& Thrt, BiThrTree T)
{//将二叉树T中序线索化,令 THrt 指向头结点
Thrt = new BiThrNode; //建立头结点
Thrt->LTag = 0; //由上图可知,头结点有左孩子。若树非空,其左孩子就是树根
Thrt->RTag = 1; //存在右线索
Thrt->rchild = Thrt; //初始化时,右指针指向自己
if (!T)
Thrt->lchild = Thrt; //若树为空,则左指针也指向自己
else
{
Thrt->lchild = T; //头结点的左孩子指向树根
pre = Thrt;
InThreading(T); //调用函数InThreading,对二叉树T进行中序线索化
pre->rchild = Thrt; //函数InThreading调用结束时,pre指向最右边的结点
pre->RTag = 1;
Thrt->rchild = pre; //头结点的右线索指向pre
}
}
由于线索二叉树拥有结点的前驱和后驱的信息,所以相比于普通二叉树的遍历,线索二叉树的遍历及指定结点的查找都变得更加简单。
一般地,如果需要经常查找结点的前驱和后继,会选择采用线索链表作为存储结构。而在不同次序中,结点内部存储地址的含义也会有所不同,为此需要进行分类讨论。
【分类讨论】
下方所举例子来源于上方的中序线索链表。
(1)在中序线索二叉树中
p→LTag | 含义 |
1 | p的左指针域 指示其前驱。 |
0 | ① p有左子树; ② p所指结点的前驱是 遍历当前结点的左子树时,访问的最后一个结点。(譬如:前图中序链表中, '-' 的前驱就是 'd') |
p→RTag | 含义 |
1 | p的右指针域 指示其后继。 |
0 | ① p有右子树; ② p所指结点的后继是 遍历当前结点的右子树时,访问的第一个结点。(譬如:前图中序链表中,'-' 的后继是 'e') |
------
(2)在先序线索二叉树中:
p→LTag | 含义 |
1 | p的左指针域 指示其前驱。 |
0 | ① p有左子树; ② p的前驱有两种情况: ▪ 若当前结点是其双亲的左孩子,则该结点的前驱就是其双亲结点; ▪ 否则,当前结点的前驱应是 遍历该结点的双亲的左子树时,所访问的最后一个结点。 |
p→RTag | 含义 |
1 | p的右指针域 指示其后继。 |
0 | ① p有右子树; ② 按照先序遍历的规则,p所指结点的后继必为 当前结点的左子树根(若存在)或右子树根。 |
------
(3)在后序线索二叉树中:
p→LTag | 含义 |
1 | p的左指针域 指示其前驱。 |
0 | ① 若p→RTag为 0(右子树存在),则p的右指针域 指示其前驱; ② 若p→RTag为 1(右子树不存在),则p的左指针域 指示其前驱。 |
p所指结点 | 后继的情况 |
是二叉树的根 | 其后继为空。 |
是双亲的右孩子 | 其后继为双亲结点。 |
1. 是双亲的左孩子 2. 没有右兄弟 |
其后继也是双亲结点。 |
1. 是双亲的左孩子 2. 有右兄弟 |
其后继为 双亲的右子树上按后继遍历得到的第一个结点。 |
在遇到上述这种查找后继比较复杂的情况时,可以直接使用含4个指针的线索链表。
由于结点内已经存在了前驱和后继的信息,线索二叉树的遍历就不再需要设栈,因此在时间和空间上都比遍历二叉树节省。接下来的算法就是通过 查找结点的后继 进行线索二叉树的遍历的。
【代码:中序遍历线索二叉树】
void InOrderTraverse_Thr(BiThrTree T)
{//中序遍历线索二叉树T的非递归算法,其中访问操作就是直接输出
BiThrNode* p = T->lchild; //其中,T指向头结点,故p指向根结点
while (p != T) //当T为空树,或遍历结束时,p == T
{
while (p->LTag == 0) //沿左孩子向下寻找
{
p = p->lchild;
}
cout << p->data; //访问(输出)左子树为空的结点
while (p->RTag == 1 && p->rchild != T)
{
p = p->rchild; //通过右线索寻找后继
cout << p->data;
}
p = p->rchild; //转向p的右子树
}
}
【分析】