数据结构入门——树与二叉树
前言
本系列文章将简要介绍数据结构课程入门知识,文章将结合我们学校(吉大)数据结构课程内容进行讲述。文中算法大部分来自朱允刚老师上课的讲解,朱老师是我遇到最认真负责的老师,很有幸能成为朱老师的学生。
一、树与二叉树的基本概念
树
树的定义
一棵树是一个有限的结点集合T. 若T空,则称为空树。
若T非空,则:
- 有一个被称为根的结点,记为root(T) ;
- 其余结点被分成m(m≥0) 个不相交的非空集合T1,T2,…,Tm,且T1,T2,…,Tm又都是树。树T1,T2,…,Tm称作root(T)的子树。
树的相关术语
- 度
一个结点的子结点的数目,称为该结点的度或者次数。一棵树的度为 m a x i = 1 , … , n D ( i ) max_{ i=1,…, n}D(i) maxi=1,…,nD(i),其中n为树中结点总数,i 指树中的第 i 个结点,D(i)表结点 i 的度。 - 叶结点、分支结点
度为0的结点被称为叶结点;度 > 0的结点被称为分支结点。 - 结点的层数
树形T中结点的层数递归定义如下:
⑴ root(T)层数为零;
⑵ 其余结点的层数为其前驱结点的层数加1 .
- 路径:
树形中结点间的连线被称为边。若树形T中存在结点序列vm → vm+1 → … → vm+k,1 ≤ k ≤ T的最大层数,满足vi+1是vi(m ≤ i ≤ m+k−1)的子结点,则称此结点序列为vm到vm+k的路径,该路径所经历的边数 k 被称为路径长度。
注意:从根结点到某个结点的路径长度恰为该结点的层数。 - 子孙结点、祖先结点
一棵树中若存在结点vm到vn的路径,则称vn为vm的子孙结点,vm为vn的祖先结点。 - 树的高度
树的高度为 m a x i = 1 , … , n N L ( i ) max_{i=1,…, n }NL (i) maxi=1,…,nNL(i),其中n为树中结点总数,i 指树中第 i 个结点,NL(i)之值为结点 i 的层数。
注意:树的高度是指树中结点的最大层数。
二叉树
二叉树定义
二叉树是结点的有限集合,它或者是空集,或者由一个根及两棵不相交的称为该根的左、右子树的二叉树组成。
二叉树特征
- 二叉树每个结点最多有2个子结点;
- 二叉树的子树有左右之分。
树与二叉树的主要区别:
二叉树的子树有左右之分,即使某结点只有一棵子树,也要指明该子树是左子树,还是右子树;
二叉树的性质
- 二叉树中层数为 i 的结点至多有2i个,i≥0。
- 高度为k (k ≥ 1)的二叉树中至少有k+1个结点。
含有k (k ≥ 1)个结点的二叉树高度至多为k−1。 - 高度为k的二叉树中至多有2k+1-1 (k ≥ 0)个结点。
- 设T是由n个结点构成的二叉树,其中,叶子结点个数为n0,度为2的结点个数为n2,则有:n0=n2+1。
满二叉树与完全二叉树
满二叉树定义:
一棵非空高度为k( k ≥ 0)的满二叉树,是有2k+1−1个结点的二叉树。
满二叉树特点:
- 叶结点都在第k层上;
- 每个分支结点都有两个子结点;
- 叶结点的个数等于非叶结点个数加1。
完全二叉树定义:
除最后一层外,每一层都是满的(达到最大结点数),最后一层结点从左向右出现。
完全二叉树特点:
具有n个结点高度为h的完全二叉树的特点是:
- 只有最下面两层结点的度可以小于2;
- 最下面一层的结点都集中在该层最左边的若干位置上;
- 叶结点只可能在最后两层出现
- 对所有结点,按层次顺序,用自然数从1开始编号,仅仅编号最大的分支结点可以没有右孩子,其余分支结点都有两个子结点。
- 具有n (n>0)个结点的完全二叉树的高度是 ⌊ l o g 2 n ⌋ \biggl\lfloor{log_2n}\biggr\rfloor ⌊log2n⌋ 。
设若将一棵具有n个结点的完全二叉树按层次顺序从1开始编号,则对编号为 i (1 ≤ i ≤ n)的结点有:
6. 若i≠1,则编号为 i 的结点的父 结点的编号为
⌊
i
/
2
⌋
\biggl\lfloor{i/2}\biggr\rfloor
⌊i/2⌋ 。
7. 若2i ≤ n,则编号为 i 的结点的 左孩子的编号为 2i,否则 i 无左孩 子。
8. 若2i+1 ≤ n,则 i 结点的右孩子 结点编号为2i+1,否则 i 无右孩子。
二、二叉树的存储和操作
要存储一棵二叉树,必须存储其所有结点的数据信息、左孩子和右孩子地址,既可用顺序结构存储,也可用链接结构存储。
二叉树的顺序存储是指将二叉树中所有结点按层次顺序存放在一块地址连续的存储空间中,同时反映出二叉树中结点间的逻辑关系。
二叉树的顺序存储
对于完全二叉树,结点的层次顺序反映了其结构,可按层次顺序给出一棵完全二叉树之结点的编号,结点编号恰好反映了结点间的逻辑关系。
对二叉树的结点按照层次顺序进行编号,利用一维数组A存储一棵含有n个结点的二叉树,其中A[1]存储二叉树的根结点。
A[i]存储编号为i的结点,结点A[i]的左孩子(若存在)存放在A[2i]处,而A[i]的右孩子(若存在)存放在A[2i+1]处。
- 这种顺序存储方式是完全二叉树最简单、最节省空间的存储方式。它实际上只存储了结点信息域之值,而未存储其左孩子和右孩子地址,通过计算可找到它的左孩子、右孩子和父结点,寻找子孙结点和祖先结点也非常方便。
- 但是,这种方法应用到非完全二叉树时,却有很多缺点。如果采用上述的顺序存储方式,按照层次顺序,对非完全二叉树之结点进行编号,则这时的编 号不能与结点一一对应。为此,先加入若干虚结点将其转换成一棵“完全二叉树”,然后再对原来的结点和虚结点统一编号,最后完成顺序存储。但这增加了用于存储虚结点的空间。
二叉树的链接存储
二叉树诸结点被随机存放在内存空间中,结点之间的关系用指针说明。
二叉树结点应包含三个域:数据域data、指针域left(称为左指针)和指针域right(称为右指针),其中左、右指针分别指向该结点的左、右子树的根结点.
left | data | right |
---|
三叉链表表示法:
另一种结点结构:
结点包括三个指针域,parent域中指针指向其父结点
left | data | parent | right |
---|
二叉树的遍历
二叉树的遍历:按照一定次序访问二叉树中所有结点,并且每个结点仅被访问一次的过程。
先根遍历 | 中根遍历 | 后根遍历 | |
---|---|---|---|
步骤一 | 访问根结点 | 中根遍历左子树 | 后跟遍历左子树 |
步骤二 | 先根遍历左子树 | 访问根结点 | 后根遍历右子树 |
步骤三 | 先根遍历右子树 | 中根遍历右子树 | 访问根节点 |
先根遍历:ABC
中根遍历:BAC
后根遍历:BCA
先根(中根、后根)遍历二叉树 T, 得到 T 之结点的一个序列, 称为 T 的先根(中根、后根)序列。
递归遍历
先根遍历(Preorder Traversal,前/先序遍历)
先根遍历二叉树算法的框架:
若二叉树为空,则空操作;
否则
– 访问根结点 ;
– 先根遍历左子树 ;
– 先根遍历右子树 。
void preorder(Tree* root){
if (root == NULL)//递归结束条件节点为空
return;
printf("%c ", root->Data);//访问当前的根节点
preorder(root->left);//访问此时根节点的左子树
preorder(root->right);//访问此时根节点的右子树
}
中根遍历 (Inorder Traversal,中序遍历)
中根遍历二叉树算法的框架:
若二叉树为空,则空操作;
否则
–中根遍历左子树 ;
–访问根结点 ;
–中根遍历右子树。
void inorder(Tree* root){
if (root == NULL)
return;
inorder(root->left);
printf("%c ", root->Data);
inorder(root->right);
}
后根遍历 (Postorder Traversal,后序遍历)
后根遍历二叉树算法的框架:
若二叉树为空,则空操作;
否则
– 后根遍历左子树;
– 后根遍历右子树 ;
– 访问根结点。
void Posorder(Tree* root){
if (root == NULL)
return;
Posorder(root->left);
Posorder(root->right);
printf("%c ", root->Data);
}
非递归遍历
非递归先根
先根遍历过程:
-
➢ 从根结点开始自上而 下沿着左侧分支访问结点。
-
➢ 自下而上依次访问沿途各结点的右子树。
非递归策略:从根结点开始自上而下沿着左侧分支访问结点,并把该结点压栈。之后再弹栈处理其右子树
非递归中根
中根遍历过程:
- ➢ 从根结点出发沿左分支下行,直到最深的结点(无左孩子)。
- ➢ 沿着左侧通道,自下而上依次访问沿途各结点及其右子树。
策略:从根结点开始自上而下沿着左侧分支下行,并把沿途结点压栈。自下而上弹栈,访问结点,访问其右子树。
中根和先根算法结点进出栈顺序是一致的
先根算法:结点进栈顺序就是先根访问的顺序,即进栈序列=先根序列
中根算法:结点出栈顺序就是中根访问的顺序,即出栈序列=中根序列
非递归后根
允许结点多次进出栈,栈元素增加关于进栈/出栈次数的信息。
i = 1: 没有访问结点的任何子树,准备遍历其左子树;
i = 2: 遍历完左子树, 准备遍历其右子树;
i = 3: 遍历完右子树,准备访问该结点。
二叉树中任一结点 p 都要进栈三次,出栈三次。
第一次出栈是为遍历 p 的左子树;
第二次出栈是为遍历 p 的右子树;
第三次出栈是为访问 p .
初始化时,将 (根结点,1) 压入栈;弹栈,判断出栈元素( p, i )中的标号 i :
- 若 i = 1,则将(p, 2)压栈;准备遍历 p 的左子树,即(Left( p), 1) 压栈.
若 p 有左子树,则栈内其左子树的所有结点的二元组皆在p 的二元组上面 。- 若 i = 2, 则将( p , 3) 压栈;准备遍历 p 的右子树,即 ( Right§, 1) 压栈.
此时,p 的左子树已被遍历完毕;若 p 有右子树,则栈内其右子树的所有结点的二元组皆在 p 的 二元组上面 。- 若 i = 3, 访问结点 p.
此时,p 的左、右子树都已遍历完毕,自然应访问根结点。
二叉树的计数
n个结点的二叉树有多少种的形态?
- 对二叉树的n个结点进行编号,不妨按先根序列进行编号1…n。
- 因中根序列和先根序列能唯一确定一棵二叉树,故二叉树有多少个可能得中根序列,就能确定多少棵不同的二叉树。
由非递归中根遍历知,结点出栈顺序即中根访问顺序,问题转化为对于入栈序列1…n,有多少种可能的合法出栈序列
即
C
a
t
a
l
a
n
(
n
)
=
1
n
+
1
C
2
n
n
Catalan(n)=\frac 1{n+1}C_{2n}^n
Catalan(n)=n+11C2nn
二叉树层次遍历
按层数由小到大,同层由左向右的次序访问结点。
二叉树层次遍历算法需要一个辅助队列,具体方法如下:
根结点入队。
重复本步骤直至队为空:
- 若队列非空,取队头结点并访问;
- 若其左指针不空,将其左孩子入队;
- 若其右指针不空,将其右孩子入队。
### 重建二叉树
- 通过二叉树的先根序列和中根序列可确定一颗二叉树
- 通过二叉树的先根序列和中根序列可确定一颗二叉树
- 通过二叉树的层次序列和中根序列可确定一颗二叉树
- 通过二叉树的先根序列和后根序列不能确定一颗二叉树
二叉树其他操作
解决二叉树问题的一种框架
算法 f ( t )
处理根结点(递归出口)
递归处理左子树f (Left(t)).
递归处理右子树f (Right(t)).
RETURN
① 在二叉树中搜索给定结点的父结点
node* Father(node* t,node* p){
//在以t为根的二叉树中搜索给定结点p的父结点,返回p的父结点指针
if(t==NULL||p==t)return NULL;
if(t->left == p||t->right == p)return t;
node* q=Father(t->left,p);
if(q != NULL)return q;
else return Father(t->right,p);
}
② 搜索二叉树中符合数据域条件的结点
node* Find(node* t, int item){
//在以t为根的二叉树中搜索数据域值为item的结点,返回指向该结点的指针
if(t == NULL) return NULL;
if(t->data == item)return t;
node* p = Find(t->left,item);
if(p != NULL)return p;
return Find(p->right,item);
}
③ 释放二叉树
void delTree(node* root) {
if (root == NULL) return 0;
delTree(root->left);
delTree(root->right);
delete root;
}
④ 通过带空指针信息的先根序列创建二叉树
算法CreateBinTree
输入:包含空指针信息的先根序列
输出:创建的二叉树根指针 t .
当读入’#’字符时,将其初始化为一个空指针;否则生成一个新结点.
node* CreateBTree() {
int k;
scanf("%d", &k);
if (k == 0) return NULL;
node* root = new T;
root->num = k;
root->left = CreateBTree();
root->right = CreateBTree();
return root;
}
⑤ 求二叉树高度
二叉树的高度可由下面的公式求得:
d
e
p
t
h
(
t
)
=
{
−
1
t=NULL
m
a
x
{
d
e
p
t
h
(
t
−
>
l
e
f
t
)
,
d
e
p
t
h
(
t
−
>
r
i
g
h
t
)
}
+
1
t != NULL
depth(t)=\begin{cases} -1& \text{t=NULL}\\max\{depth(t->left),depth(t->right)\}+1& \text{t != NULL} \end{cases}
depth(t)={−1max{depth(t−>left),depth(t−>right)}+1t=NULLt != NULL
int depth(Tree* root) {
if (root == NULL) return -1;
return max(depth(t->left),depth(t->right))+1;
}
二叉树中、先、后根序列的首末结点
二叉树的根节点指针t | 中根序列 | 先根序列 | 后根序列 |
---|---|---|---|
第一个结点 | IF t=Λ THEN RETURN Λ. p ← t. WHILE Left ( p) ≠ Λ DO p ← Left ( p) . RETURN p. | RETURN t. | IF t=Λ THEN RETURN Λ. p ← t. WHILE p ≠ Λ DO ( IF Left ( p) ≠ Λ THEN p ← Left ( p) . ELSE IF Right( p) ≠ Λ THEN p ← Right( p) ELSE RETURN p. ) |
最后一个结点 | IF t=Λ THEN RETURN Λ. p ← t. WHILE Right ( p) ≠ Λ DO p ← Right ( p) . RETURN p. | IF t=Λ THEN RETURN Λ. p ← t. WHILE p ≠ Λ DO ( IF Right ( p) ≠ Λ THEN p ← Right ( p) . ELSE IF Left( p) ≠ Λ THEN p ← Left( p) ELSE RETURN p. ) | RETURN t. |
三、线索二叉树
基本概念
- 在二叉树上只能找到结点的左孩子、右孩子,结点的中(先、后)根前驱和后继只有在遍历过程中才能得到。
- 二叉树的结点中有很多空指针,造成存储空间的浪费。
中根线索二叉树为例
如果某结点
- 有子结点,则其Left/Right指向子结点
- 无子结点,则其Left/Right指向其中根前驱/后继
指向某结点中根前驱和后继的指针称为线索;
- 按中根遍历得到的线索二叉树称为中序线索二叉树;
- 按先根遍历得到的线索二叉树称为先序线索二叉树;
- 按后根遍历得到的线索二叉树称为后序线索二叉树。
线索二叉树的结点结构:
LThread | Left | Data | Right | RThread |
---|
- 增加域 LThread 和 RThread ,为二进制位,表示该结点的Left和Right是否为线索
- 若结点 t 有左孩子,则 Left 指向 t 的左孩子,且LThread 值为0; 若 t 没有左孩子, 则 Left 指向 t 的某一遍历序的前驱结点, 且 LThread 值为1,此时称 Left 为线索
- 若结点 t 有右孩子,则 Right 指向 t 的右孩子,且RThread 值为0;若 t 没有右孩子,则 Right指向 t 的后继结点,且 RThread 值为1,此时称 Right 为线索.
线索二叉树的目的:
在中序线索二叉树中可以方便的找到给定结点的中序前驱和中序后继结点,并且不需要太多额外的空间。
线索二叉树中,一个结点是叶结点的充要条件为:左、右标志 (LThread、RThread) 均是1。
基本操作
以中序线索二叉树为例
找中根序列的首结点
node* FirstInOrder(node* t){
//在以t为根的中序线索二叉树中找中根序列的首结点,并返回指向它的指针
node* p = t;
while(p->LThread == 0)
p = p->left;
return p;
}
找中根序列的末结点
node* LastInOrder(node* t){
//在以t为根的中序线索二叉树中找中根序列的末结点,并返回指向它的指针
node* p = t;
while(p->RThread == 0)
p = p->left;
return p;
}
找中序前驱结点
主要思想如下:
➢ 若LThread( p ) = 1,则Left( p )为左线索,直接指向p的中序前驱结点;
➢ 若LThread( p ) = 0,p的中序前驱结点是p之左子树中根序列的末结点.
node* PreInOrder(node* p){
if(p->LThread == 1) return p->left;
else return LastInOrder(p->left);
}
找中序后继结点
主要思想如下:
➢ 若RThread§ = 1,则Right§ 指向p之中序后继 ;
➢ 若RThread§ = 0,则p之中序后继为p的右子树的中根序列的首结点。
node* NextInOrder(node* p){
if(p->RThread == 1) return p->right;
else return FirstInOrder(p->right);
}
中根遍历线索二叉树
算法思想:
在线索二叉树上进行中根遍历,只要先找到中根序列中的第一个结点,然后依次找结点的后继直至其后继为空时为止。
void InOrder(node *t) {
for(node *p=FirstInOrder(t);p;p=NextInOrder(p))
printf("%d ", p->data);
}
中根遍历时空复杂度对比
时间复杂度 | 空间复杂度 | |
---|---|---|
二叉树 | O(n) | O( h ) |
中序线索二叉树 | O(n) | O(1) |
结点个数n,高度h
插入、删除和线索化
插入
在中序线索二叉树中插入结点p作为结点s的右子结点
- 若s无右子树:则直接令p成为s的右子结点,并修改s和p的相关指针
- 若s有右子树:将该右子树变成p的右子树,p成为s的右子结点,并修改s和p的相关指针以及该右子树的最左下结点的前驱指针
删除结点s的右子结点p(假定p存在)
- 若p为叶结点,只须修改s的RThread的值和Right指针
- 若p无左子树,有右子树,且右子树的中根序列的第一个结点为temp,则把p的右子树变成s的右子树,并修改temp的前驱指针
- 若p无右子树,有左子树,且左子树的中根序列的最后一个结点为temp,则把p的左子树变成s的右子树,并修改temp的后继指针.
- 若p既有左子树,又有右子树,temp1为p的右子树的中根序列中第一个结点, temp为p的左子树的中根序列中最后一个结点。则把p的左子树变成s的右子树,p的右子树变成temp的右子树。经过这样的重新链接,可以保证不改变原二叉树的中根序列。
二叉树的中序线索化
使二叉树变为线索二叉树的过程称为线索化
与中序遍历算法类似,只需将遍历算法中“访问结点”的操作具体化为“建立当前访问的结点与其中根前驱结点的线索关系”
算法思想:
- 递归为左子树增加线索
- 访问操作就是为当前的根节点增加线索
- 递归为右子树增加线索
node* pre;
void InThread(node* r,node* pre){
if(r!=NULL){
InThread(r->Left,pre);//中序线索化r的左子树
if(r->left==NULL){//如果r没有左孩子
r->left=pre;//r的左指针指向r的中根前驱
LThread=1;
}
if(pre!=NULL&&pre->right==NULL){//如果pre没有右孩子
pre->right=r;//pre的右指针指向其中根后继r
pre->RThead=1;
}
pre=r;//将当前访问的结点作为pre
InThread(r->right,pre);
}
}
四、树的存储和操作
树与二叉树的转换
-
树转换成二叉树
-
在所有兄弟结点间加一条连线;
-
对每个结点与其子结点的连线:除保留与其左孩子的连线之外,去掉该结点与其它孩子结点间的连线;
-
调整部分连线方向、长短使之成为符合二叉树规 范的图形。
-
-
森林转换成二叉树
-
引入一个虚拟的根,将森林转变为树;
-
按照上面的方法将树转变为二叉树
-
- 二叉树转换成树
二叉树的右子树为空,则可转换为一棵树。- 对每个结点,若该结点有左孩子,将左孩子的右孩子、 右孩子的右孩子……与该结点用线连接起来;
- 去掉所有父结点和右孩子之间的连线;
- 调整部分连线方向、长短使之成规范图形.
- 二叉树转换成森林
二叉树的右子树不为空,则可转换为森林。- 从根节点出发,断开其与其右儿子的连线,得到多个二叉树;
- 将每个二叉树按以上方法转化为树。
树的存储结构
顺序存储
-
双亲表示法:层次顺序+父结点下标
• 按层次遍历顺序对结点编号
• 编号为 i 的结点存放在数组的第 i 个位置
• 便于涉及父结点的操作;
• 求结点的子结点时需要遍历整棵树。 -
孩子表示法:层次顺序+子结点下标
• 按层次遍历顺序对结点编号
• 编号为 i 的结点存放在数组的第 i 个位置
• 便于涉及孩子的操作, 求父结点不方便
• 空间浪费。
-
树的先根序列及结点度表示法
树的先根遍历的定义
(1)访问根结点
(2)从左到右依次先根次序遍历树的诸子树
如果已知一个树的先根序列和每个结点的度,则能唯一确定该树的结构
先根序列: A B C D E F G H I J K L
结点度数序列: 4 0 3 0 0 0 0 2 2 0 0 0 -
树的后根序列及结点度表示法
树的后根遍历的定义
(1)从左到右依次后根遍历根结点的诸子树(如果诸子树存在)
(2)访问根结点
后根序列 B D E F C G J K I L H A
结点的度数 0 0 0 0 3 0 0 0 2 0 2 4 -
层次序列和结点次数表示法
由一棵树 T 的层次序列和 T 的每个结点的度,则能唯一确定 T 的结构
层次序列 A B C G H D E F I L J K
结点度 4 0 3 0 2 0 0 0 2 0 0 0
树的链接存储
-
Father链接结构
每个结点有Data 和Father 两个域,Father 域存放指向父结点的指针。
优点:Father 链接提供了“向上”访问的能力。
缺点:①很难确定一个结点是否为叶结点;②很难确定一个结点的所有子结点;③不易实现遍历操作。 -
孩子链接结构
(与上法类似) -
孩子链表表示法
为树中每个结点设置一个孩子链表并将这些结点及相应的孩子链表的头指针存放在一个数组中。孩子结点的数据域仅存放了它们在数组空间的下标。
优缺点:便于实现涉及孩子及其子孙的操作,但不便于实现与父结点有关的操作。 -
父亲孩子链表表示
(与上法类似) -
左孩子-右兄弟链接结构
- 树中结点的左孩子对应二叉树中结点的左孩子
- 树中结点的右兄弟对应二叉树中结点的右孩子
树的孩子-兄弟链接存储结构上的主要操作
① 遍历
- 树的遍历
先根遍历:先访问树的根结点,然后依次先根遍历每棵子树。
后根遍历:先依次后根遍历每棵子树,然后访问树的根结点。 - 森林的遍历
先根遍历:
① 访问森林中第一棵树的根结点;
② 先根遍历第一棵树中的诸子树;
③ 先根遍历其余的诸树。
后根遍历:
① 后序遍历第一棵树的诸子树;
② 访问森林中第一棵树的根结点;
③ 后序遍历其余的诸树。
② 搜索父结点
思路:
令q指向t的左儿子结点,若q=p,t就是p的父结点;否则,在以q为根的子树中找p的父结点。
若未找到,令q指向t的下一个子结点,即q的右兄弟结点,重复上述步骤,直至找到p的父结点或找完t的所有子结点
③ 搜索指定数据域的结点
若t的数据域为target,则返回t;
否则,p指向t的左孩子结点,在以p为根的树中进行搜索(递归调用)
若在以p为根的树中搜索失败,p指向其右兄弟结点,继续进行上述搜索,直到找到数据域等于target的结点或p为空。