第五章 树
5.1 树的基本概念
-
树是n(n≥0)个结点的有限集合,n = 0时,称为空树。
-
空树——结点数为0的树
-
非空树——①有且仅有一个根节点
②没有后继的结点称为“叶子结点”(或终端结点)
③有后继的结点称为“分支结点”(或非终端结点)
④除了根节点外,任何一个结点都有且仅有一个前驱结点,可以有0个或多个后继结点
-
- 在任何一棵非空树中应满足:
-
有且仅有一个特定的称为根的结点。
-
当n > 1时,其余结点可分为m(m>0)个互不相交的有限集合T1,T2,…,Tm,其中每个集合本身又是一棵树,并且称为根结点的子树。
-
-
结点的度:树中一个结点的孩子个数称为该结点的度。各结点的度的最大值是树的度。
-
度大于0的结点称为分支结点,度为0的结点称为叶子结点。
-
结点的层次(深度):从上往下数。
-
结点的高度:从下往上数。
-
树的高度(深度):树中结点的层数。
-
有序树:逻辑上看,树中结点的各子树从左至右是有次序的,不能互换。
-
无序树:逻辑上看,树中结点的各子树从左至右是无次序的,可以互换。
-
树中两个结点之间的路径是由这两个结点之间所经过的结点序列构成的,而路径长度是路径上所经过的边的个数。
-
森林:森林是m(m≥0)棵互不相交的树的集合。
5.1.2 树的常考性质
- 结点数 = 总度数 + 1
- 度为 m 的树、m 叉树的区别:
度为m的树 | m叉树的区别 |
---|---|
任意结点的度≤m(最多m个孩子) | 任意结点的度≤m(最多m个孩子) |
至少有一个结点度=m(有m个孩子) | 允许所有结点的度都<m |
一定是非空树,至少有m+1个结点 | 可以是空树 |
- 度为 m 的树第 i 层至多有** m ( i − 1 ) m^{(i-1)} m(i−1)个结点(i≥1);m 叉树第 i 层至多有 m ( i − 1 ) m^{(i-1)} m(i−1)**个结点(i≥1)。
-
高度为 h 的 m 叉树至多有 m h − 1 m − 1 \frac{m^h-1}{m-1} m−1mh−1个结点。(等比数列求和)
-
高度为 h 的 m 叉树至少有 h 个结点;高度为 h、度为 m 的树至少有(h+m-1)个结点。
-
具有 n 个结点的 m 叉树的最小高度为 ⌈ l o g m [ n ( m − 1 ) + 1 ] ⌉ ⌈log_m[n(m-1)+1]⌉ ⌈logm[n(m−1)+1]⌉
5.2 二叉树
5.2.1. 二叉树的定义
-
二叉树是 n(n≥0)个结点的有限集合:
①或者为空二叉树,即 n = 0。
②或者由一个根结点和两个互不相交的被称为根的左子树和右子树组成,左子树和右子树又分别是一棵二叉树。
-
二叉树的特点:
①每个结点至多只有两棵子树。
②左右子树不能颠倒(二叉树是有序树)。——注意区别:度为2的有序树
-
二叉树的五种状态:
①空二叉树
②只有左子树
③只有右子树
④只有根节点
⑤左右子树都有
5.2.2 几个特殊的二叉树
1、满二叉树
一棵高度为h,且含有 2 h − 1 2^h-1 2h−1个结点的二叉树称为满二叉树,即 树中的每层都含有最多的结点。
特点:
-
满二叉树的叶子结点都集中在二叉树的最下一层
-
不存在度为1的结点,除了叶子结点外的每个节点的度都为2。
-
可以对满二叉树按照层序编号,约定编号从根节点(编号为1)起,自上而下,自左向右。这样每个结点对应一个编号,对于编号为i的结点,若有双亲,则其双亲为⌊i/2⌋,若有左孩子,则其左孩子为2i,若有右孩子,则其右孩子为2i+1.
2、完全二叉树
高度为h,有n个结点的二叉树,当且仅当每个节点都与高度为h的满二叉树中编号为1~n的结点一一对应时,称为完全二叉树。
其有如下特点:
- 若i≤⌊n/2⌋,则结点i为分支节点,否则为叶子结点。
- 只有最后两层可能有叶子结点。
- 最多只有一个度为1的结点,且该结点只有左孩子。
- 按照层序编号后,一旦出现某结点(编号为i)为叶子结点或只有左孩子,则编号大于i的结点均为叶子结点。
- 若n为奇数,则每个分支节点都有左右孩子,若n为偶数,则编号最大的分支结点(n/2)只有左孩子,没有右孩子。其余分支节点左右孩子都有。
满二叉树一定是完全二叉树,完全二叉树不一定是满二叉树。
3、二叉排序树
一棵二叉树或者是空二叉树,或者是具有如下性质的二叉树:
-
左子树上的所有结点的关键字均小于根节点的关键字;
-
右子树上的所有结点的关键字均大于根节点的关键字;
-
左子树和右子树又各自是一颗二叉排序树。
二叉排序树可用于元素的排序、搜索。
4、平衡二叉树——胖胖的、丰满的树
树上的任一结点的左子树和右子树的深度之差不超过1.——平衡二叉树能有更高的搜索效率
5.2.3 二叉树的常考性质
-
设非空二叉树中度为0、1和2的结点个数分别为 n 0 n_0 n0、 n 1 n_1 n1和 n 2 n_2 n2,则 n 0 = n 2 + 1 n_0=n_2+1 n0=n2+1(叶子结点比二分支结点多一个)
推导过程:假设树中结点总数为 n n n,则 ① n = n 0 + n 1 + n 2 n=n_0+n_1+n_2 n=n0+n1+n2 ② n = n 1 + 2 n 2 + 1 n=n_1+2n_2+1 n=n1+2n2+1(树的结点数=总度数+1)
-
二叉树第i层至多有 2 i − 1 2^{i-1} 2i−1个结点(i≥1);m叉树第i层至多有 m i − 1 m^{i-1} mi−1个结点(i≥1)
-
高度为h的二叉树至多有 2 h − 1 2^h-1 2h−1个结点(满二叉树);高度为h的m叉树至多有 m h − 1 m − 1 \frac{m^h-1}{m-1} m−1mh−1个结点
-
具有n个(n≥0)结点的完全二叉树的高度h为 ⌊ l o g 2 ( n + 1 ) ⌋ \lfloor log_2(n+1)\rfloor ⌊log2(n+1)⌋或 ⌈ l o g 2 n ⌉ + 1 \lceil log_2n\rceil+1 ⌈log2n⌉+1
推导过程:
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-SeCmIHEo-1678084181984)
-
对于完全二叉树,可以由结点数 n n n推出度为0、1和2的结点个数 n 0 n_0 n0、 n 1 n_1 n1和 n 2 n_2 n2。
完全二叉树最多只有一个度为1的结点,即 n 1 = 0 n_1=0 n1=0或1, n 0 = n 2 + 1 → n 0 + n 2 n_0=n_2+1\rightarrow n_0+n_2 n0=n2+1→n0+n2一定是奇数
①若完全二叉树有2k(偶数)个结点,则必有 n 1 = 1 , n 0 = k , n 2 = k − 1 n_1=1, n_0=k, n_2=k-1 n1=1,n0=k,n2=k−1
②若完全二叉树有2k-1(奇数)个结点,则必有 n 1 = 0 , n 0 = k , n 2 = k − 1 n_1=0, n_0=k, n_2=k-1 n1=0,n0=k,n2=k−1
5.2.4 二叉树的存储结构
1、顺序存储
包含的结点个数有上限
顺序存储完全二叉树:定义一个长度为 MaxSize 的数组 t,按照从上至下、从左至右的顺序依次存储完全二叉树中的各个结点。让第一个位置空缺,保证数组下标和结点编号一致。
根据二叉树的性质,完全二叉树和满二叉树采用顺序存储比较合适,树中结点的序号可以唯一反映结点之间的逻辑关系,这样既能最大程度上的节省空间,又能根据数组元素的下标来确定结点在二叉树中的位置以及结点间的关系。
几个重要常考的基本操作:
- 结点i的左孩子: 2 i 2i 2i
- 结点i的右孩子: 2 i + 1 2i+1 2i+1
- 结点i的父节点: ⌊ i / 2 ⌋ \lfloor i/2\rfloor ⌊i/2⌋
- 结点i的层次: ⌈ l o g 2 ( i + 1 ) ⌉ \lceil log_2(i+1)\rceil ⌈log2(i+1)⌉或 ⌊ l o g 2 i ⌉ + 1 \lfloor log_2i\rceil+1 ⌊log2i⌉+1
若完全二叉树中共有n个结点,则
- 判断i是否有左孩子?——2i≤n?
- 判断i是否有右孩子?——2i+1≤n?
- 判断i是否是叶子/分支结点?——i> ⌊ n / 2 ⌋ \lfloor n/2\rfloor ⌊n/2⌋?
顺序存储的结构描述如下:
#define MaxSize 100
// 二叉树的顺序存储
struct TreeNode {
ElemType data; // 结点中的数据元素
bool isEmpty; // 结点是否为空
};
TreeNode t[MaxSize]; // 定义一个长度为MaxSize的数组t,按照从上到下,从左到右的顺序依次存储完全二叉树的各个节点
for(int i=0;i<MaxSize;i++){ //初始化时所有结点标记为空
t[i].isEmpty=true;
}
而对于一般的二叉树而言,若使用顺序存储,则只能添加一些并不存在的空结点,让每个结点与二叉树上的结点相对照,再存储到一维数组的相应分量中。这样存在着空间的浪费,不建议使用顺序存储。因此,二叉树的顺序存储结构,只适合存储完全二叉树。
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-8tQTyDUY-1678084181985)(数据结构.assets/8736594932a24c0c8ee236c756dc3513.png)]
2、链式存储
为了解决存储一般二叉树的空间浪费问题,一般二叉树的存储使用链式存储结构。使用链表结点来存储二叉树中的各个结点。在二叉树中,结点的结构通常包括若干数据域以及若干指针域。
二叉链表的存储结构如下:
其结构描述如下:
typedef struct BiTNode {
ElemType data; //数据域
struct BiTNode *lchild, *rchild; //左、右孩子指针
} BiTNode, *BiTree;
【重要】在含有n个结点的二叉链表中,含有n+1个空链域。
二叉树的链式存储实现:
struct ElemType{
int value;
}
typedef struct BiTNode{
ElemType data;//数据域
struct BiTNode *lchild,*rchild;//左、右孩子指针
}BiTNode,*BiTree;
//定义一棵空树
BiTree root = NULL;
//插入根节点
root=(BiTree)malloc(sizeof(BiTNode));
root->data={1};
root->lchild = NULL;
root->rchild = NULL;
//插入新节点
BiTNode *p=(BiTNode *)malloc(sizeof(BiTNode));
p->data={2};
p->lchild = NULL;
p->rchild = NULL;
root->lchild = p;
为了找到指定结点的父结点,一般要从根节点开始遍历,可在BiTNode中设置一个新的指针存储父结点来解决此问题。
Tips: 根据实际需求决定要不要加父结点指针
5.3 二叉树的遍历与线索二叉树
二叉树的遍历类似:
先序遍历:前缀表达式
中序遍历:中缀表达式(需添加界限符)
后序遍历:后缀表达式
5.3.1 二叉树的遍历
遍历: 按照某种次序把所有结点都访问一遍
层次遍历:基于树的层次特性确定的次序规则
先/中/后序遍历:基于树的递归特性确定的次序规则
二叉树的递归特性:①要么是个空二叉树 ②要么就是由”根节点+左子树+右子树“组成的二叉树
1、先序遍历(PreOrder)
先序遍历的操作过程如下:
若二叉树为空,则什么都不做,否则:
- 访问根节点
- 先序遍历左子树
- 先序遍历右子树
根左右(NLR)
对应的递归算法如下:
void PreOrder(BiTree T) {
if (T == NULL) return;
visit(T); //访问根节点
PreOrder(T->lchild); //递归遍历左子树
PreOrder(T->rchild); //递归遍历右子树
}
2、中序遍历(InOrder)
中序遍历的操作过程如下:
若二叉树为空,则什么也不做,否则:
-
中序遍历左子树;
-
访问根结点;
-
中序遍历右子树。
左根右(LNR)
对应的递归算法如下:
void InOrder(BiTree T) {
if (T == NULL) return;
InOrder(T->lchild); //递归遍历左子树
visit(T); //访问根节点
InOrder(T->rchild); //递归遍历右子树
}
3、后序遍历(PostOrder)
后序遍历的操作过程如下:
若二叉树为空,则什么也不做,否则:
-
后序遍历左子树;
-
后序遍历后子树;
-
访问根结点;
左右根(LRN)
对应的递归算法如下:
void PostOrder(BiTree T) {
if (T == NULL) return;
PostOrder(T->lchild);//递归遍历左子树
PostOrder(T->rchild);//递归遍历右子树
visit(T); //访问根节点
}
三种遍历方法空间复杂度:O(h)
4、层序遍历
按照层序来进行遍历,如下图所示:
算法思想:①初始化一个辅助队列
②根结点入队
③若队列非空,则队头结点出队,访问该结点,并将其左、右孩子插入队尾(如果有的话)
④重复③直至队列为空
其示例如下:
//链式队列结点
typedef struct LinkNode{
BiTNode * data; //存指针而不是结点
struct LinkNode *next;
}LinkNode;
typedef struct{
LinkNode *front,*rear;//队头队尾
}LinkQueue;
//层序遍历
void LevelOrder(BiTree T){
LinkQueue Q;
InitQueue (Q); //初始化辅助队列
BiTree p;
EnQueue(Q,T); //将根节点入队
while(!IsEmpty(Q)){ //队列不空则循环
DeQueue(Q,p); //队头结点出队
visit(p); //访问出队结点
if(p->lchild!=NULL)
EnQueue(Q,p->lchild); //左孩子入队
if(p->rchild!=NULL)
EnQueue(Q,p->rchild); //右孩子入队
}
}
5、由遍历序列构造二叉树
-
一个前序遍历序列可能对应多种二叉树形态。同理,一个后序遍历序列、一个中序遍历序列、一个层序遍历序列也可能对应多种二叉树形态。
即:若只给出一棵二叉树的 前/中/后/层序遍历序列 中的一种,不能唯一确定一棵二叉树。
-
由二叉树的遍历序列构造二叉树:
- 前序+中序遍历序列
- 后序+中序遍历序列
- 层序+中序遍历序列
-
由 前序+中序遍历序列 构造二叉树:由前序遍历的遍历顺序(根节点、左子树、右子树)可推出根节点,由根节点在中序遍历序列中的位置即可推出左子树与右子树分别有哪些结点。
-
由 后序+中序遍历序列 构造二叉树:由后序遍历的遍历顺序(左子树、右子树、根节点)可推出根节点,由根节点在中序遍历序列中的位置即可推出左子树与右子树分别有哪些结点。
-
由 层序+中序遍历序列 构造二叉树:由层序遍历的遍历顺序(层级遍历)可推出根节点,由根节点在中序遍历序列中的位置即可推出左子树与右子树分别有哪些结点。
示例:
Key: 找到树的根节点,并根据中序序列划分左右子树,再找到左右子树根节点
5.3.2 线索二叉树
用土办法找到中序前驱
思路:从根节点出发,重新进行一次中序遍历,指针q记录当前访问的结点,指针pre记录上一个被访问的结点 ①当q==p时,pre为前驱
②当pre==p时,q为后继
缺点:找前驱、后继很不方面;遍历操作必须从根开始
线索二叉树是一种物理结构!
1、线索二叉树的基本概念
传统的二叉链表只能体现一种父子关系, **不能直接得到结点在遍历中的前驱和后继。**而考虑到在含有n个结点的二叉树中,**有n+1个空指针。**考虑能否利用这些空指针来存放指向其前驱或后继的指针?这样就可以更加方便地遍历二叉树。
故含n个结点的线索二叉树共有n+1个线索
引入线索二叉树正是为了加快查找结点前驱和后继的速度。
线索二叉树的结点结构如下:
规定:
- 若无左子树,则lchild指向其前驱节点,ltag为1,否则lchild指向左孩子,ltag为0
- 若无右子树,则rchild指向其后继结点,rtag为0,否则rchild指向左孩子,rtag为0
其存储结构描述如下:
typedef struct ThreadNode {
int data; // 数据域
struct ThreadNode *lchild, *rchild; // 左右孩子指针
int ltag, rtag; // 左右线索标志
} ThreadNode, *ThreadBiTree;
以这种结点结构构成的二叉链表作为二叉树的存储结构,称为二叉链表。其中指向结点前驱和后继的指针称为线索,加上线索的二叉树称为线索二叉树。
2、中序线索二叉树的构造
二叉树的线索化是将二叉链表中的空指针改为指向前驱或者后继的线索。而前驱或后继的信息只有在遍历时才能够得到,因此二叉树的线索化的本质就是遍历一次二叉树。
- p的左指针:
- 空:
p->lchild = pre
- 非空:跳过
- 空:
- pre的右指针:
- 空:
pre->rchild=p
- 非空:跳过
- 空:
以下是对上图所示二叉树进行中序线索化的一个示例过程:
其代码实现如下:
// 中序线索化二叉树
void InThread(ThreadBiTree &p, ThreadBiTree &pre) {
if (p != NULL) { // 若p非空,结点没有全部遍历
InThread(p->lchild, pre); // 递归调用
if (p->lchild == NULL) { // 若p的左孩子为空
p->lchild = pre; // p的左孩子指向前驱
p->ltag = 1; // 标记为线索
}
if (pre != NULL && pre->lchild == NULL) { // pre存在且右孩子为空
pre->lchild = p; // pre的右孩子指向后继
pre->rtag = 1; // 标记为线索
}
pre = p; // pre指向p的上一个位置
InThread(p->rchild, pre); // 对右孩子建立线索
}
}
线索化后,存储结构如下:
4、先序和后序遍历
先序和后序遍历的方法类似中序遍历,这里不再给出具体流程。
先序线索二叉树的存储:
后序线索二叉树的存储:
三种线索二叉树的对比:
5、二叉树的线索化
1)中序线索化代码实现:
//线索二叉树结点
typedef struct ThreadNode{
ElemType data;
struct ThreadNode *lchild,*rchild;
int ltag,rtag; //左、右线索标志
}ThreadNode,* ThreadTree;
//全局变量 pre, 指向当前访问结点的前驱
ThreadNode *pre=NULL;
//中序遍历二叉树,一边遍历一边线索化
void InThread(ThreadTree T){
if(T!=NULL){
InThread(T->lchild); //中序遍历左子树
visit(T);//访问根节点
InThread(T->rchild); //中序遍历右子树
}
}
void visit(ThreadNode *q){
if(q->lchild==NULL){ //左子树为空,建立前驱线索
q->lchild=pre;
q->ltag=1;
}
if(pre!=NULL&&pre->rchild==NULL){
pre->rchild=q; //建立前驱结点的后继线索
pre->rtag=1;
}
pre=q;
}
//最后还要检查pre的rchild是否为NULL,如果是,则令rtag=1;
2)先序线索化代码实现:
//线索二叉树结点
typedef struct ThreadNode{
ElemType data;
struct ThreadNode *lchild,*rchild;
int ltag,rtag; //左、右线索标志
}ThreadNode,* ThreadTree;
//全局变量 pre, 指向当前访问结点的前驱
ThreadNode *pre=NULL;
//中序遍历二叉树,一边遍历一边线索化
void PreThread(ThreadTree T){
if(T!=NULL){
visit(T);//访问根节点
if(T->ltag==0) //lchild不是前驱线索
PreThread(T->lchild);
PreThread(T->rchild);
}
}
void visit(ThreadNode *q){
if(q->lchild==NULL){ //左子树为空,建立前驱线索
q->lchild=pre;
q->ltag=1;
}
if(pre!=NULL&&pre->rchild==NULL){
pre->rchild=q; //建立前驱结点的后继线索
pre->rtag=1;
}
pre=q;
}
//最后还要检查pre的rchild是否为NULL,如果是,则令rtag=1;
void CreatePreThread(ThreadTree T){
ThreadTree pre=NULL;
if(T!=NULL){ //非空二叉树,线索化
PreThread(T,pre); //线索化二叉树
if(pre->rchild=NULL) //处理遍历的最后一个结点
pre->rtag=1;
}
}
2)后序线索化代码实现:
//线索二叉树结点
typedef struct ThreadNode{
ElemType data;
struct ThreadNode *lchild,*rchild;
int ltag,rtag; //左、右线索标志
}ThreadNode,* ThreadTree;
//全局变量 pre, 指向当前访问结点的前驱
ThreadNode *pre=NULL;
//中序遍历二叉树,一边遍历一边线索化
void PostThread(ThreadTree T){
if(T!=NULL){
PostThread(T->lchild);
PostThread(T->rchild);
visit(T);//访问根节点
}
}
void visit(ThreadNode *q){
if(q->lchild==NULL){ //左子树为空,建立前驱线索
q->lchild=pre;
q->ltag=1;
}
if(pre!=NULL&&pre->rchild==NULL){
pre->rchild=q; //建立前驱结点的后继线索
pre->rtag=1;
}
pre=q;
}
void CreatePostThread(ThreadTree T){
pre=NULL; //pre初始化为NULL
if(T!=NULL){ //非空二叉树,线索化
PostThread(T,pre); //线索化二叉树
if(pre->rchild=NULL) //处理遍历的最后一个结点
pre->rtag=1;
}
}
6、在线索二叉树中查找前驱、后继
中序线索二叉树找到指定结点 *p 的中序后继 next:
- 若
p->rtag==1
,则next = p->rchild
; - 若
p->rtag==0
,则 next 为 p 的右子树中最左下结点。
中序线索二叉树找到指定结点 *p 的中序前驱 pre:
- 若
p->ltag==1
,则pre = p->lchild
; - 若
p->ltag==0
,则 next 为 p 的左子树中最右下结点。
先序线索二叉树找到指定结点 * p 的先序后继 next:
- 若
p->rtag==1
,则next = p->rchild
; - 若
p->rtag==1
,则next = p->rchild
;- 若 p 有左孩子,则先序后继为左孩子;
- 若 p 没有左孩子,则先序后继为右孩子。
先序线索二叉树找到指定结点 *p 的先序前驱 pre:
- 前提:改用三叉链表,可以找到结点 * p 的父节点。
- 如果能找到 p 的父节点,且 p 是左孩子:p 的父节点即为其前驱;
- 如果能找到 p 的父节点,且 p 是右孩子,其左兄弟为空:p 的父节点即为其前驱;
- 如果能找到 p 的父节点,且 p 是右孩子,其左兄弟非空:p 的前驱为左兄弟子树中最后一个被先序遍历的结点;
- 如果 p 是根节点,则 p 没有先序前驱。
先序遍历中,每个子树的根节点是最先遍历到的,若根节点有左右孩子,则其左右指针都指向了孩子,这种情况下,没有办法直接找到子树根节点的前驱。
后序线索二叉树找到指定结点 *p 的后序前驱 pre:
- 若
p->ltag==1
,则pre = p->lchild
; - 若
p->ltag==0
:- 若 p 有右孩子,则后序前驱为右孩子;
- 若 p 没有右孩子,则后续前驱为右孩子。
后序线索二叉树找到指定结点 *p 的后序后继 next:
- 前提:改用三叉链表,可以找到结点 * p 的父节点。
- 如果能找到 p 的父节点,且 p 是右孩子:p 的父节点即为其后继;
- 如果能找到 p 的父节点,且 p 是左孩子,其右兄弟为空:p 的父节点即为其后继;
- 如果能找到 p 的父节点,且 p 是左孩子,其右兄弟非空:p 的后继为右兄弟子树中第一个被后序遍历的结点;
- 如果 p 是根节点,则 p 没有后序后继。
后序遍历中,每个子树的根节点是最后遍历到的,若根节点有左右孩子,则其左右指针都指向了孩子,这种情况下,没有办法直接找到子树根节点的后继。
【考点】二叉树线索化之后,仍不能有效求解的问题:
- 查找后序线索二叉树的后续后继
- 查找先序线索二叉树的先序前驱
5.4 树和森林
5.4.1 树的存储结构
1、双亲表示法(顺序存储)
采用一组连续空间来存储每个节点,同时在每个节点中设置一个伪指针,指示其双亲结点在数组中的位置。
- 根结点固定存储在0号位置,-1表示其没有双亲。
- 新增数据元素,无需按逻辑上的次序存储
- 树的顺序存储结构中,数组下标只代表结点的编号,不表示各个结点间的关系。
优点:查指定结点的双亲很方便
缺点:查定结点的孩子只能从头遍历;空数据导致遍历更慢
2、孩子表示法(顺序+链式存储)
-
孩子表示法中,每个结点的孩子都使用了单链表链接起来形成一个线性结构,这时n个结点就有n个孩子链表(叶结点的孩子链表为空表)。
-
这种存储方式寻找子女的操作非常直接,而寻找双亲的操作需要遍历n个结点中孩子链表指针域所指向的n个孩子链表。
3、孩子兄弟表示法(链式存储)【重要】
孩子兄弟表示法又称二叉树表示法,即以二叉链表作为树的存储结构。孩子兄弟表示法使每个结点包括三部分内容:结点值、指向结点第一个孩子结点的指针,及指向结点下一个兄弟结点的指针(沿此域可以找到结点的所有兄弟结点)。
这种存储表示法比较灵活,其最大的优点是可以方便地实现树转换为二叉树的操作,易于查找结点的孩子等,但缺点是从当前结点查找其双亲结点比较麻烦。若为每个结点增设一个parent域指向其父结点,则查找结点的父结点也很方便。
5.4.2 树、森林和二叉树的转换
1、树和二叉树的转化
树转换二叉树的原则:每个结点的左指针指向它的第一个孩子,右指针指向它在树中的相邻右兄弟。
记忆:”左孩子右兄弟“
由于根节点没有兄弟,所以树转化成的二叉树没有右子树。
2、森林和二叉树的转化
森林是m (m≥0)棵互不相交的树的集合。
将森林转化成二叉树:先将森林中的每棵树转换为二叉树,由于任何一棵和树对应的二叉树的右子树必空,若把森林中第二棵树根视为第一棵树根的右兄弟,即将第二棵树对应的二叉树当作第一棵二叉树根的右子树,将第三棵树对应的二叉树当作第二棵二叉树根的右子树,以此类推,就可以将森林转换为二叉树。
将二叉树转化成森林:
5.4.3 树和森林的遍历
1、树的遍历
-
先根遍历。若树非空,先访问根结点,再依次遍历根结点的每棵子树,遍历子树时仍遵循先根后子树的规则。
树的先根遍历序列与这棵树相应二叉树的先序序列相同。
//树的先根遍历 void PreOrder(TreeNode *R){ if(R!=NULL){ visit(R); //访问根结点 while(R还有下一个子树T) PreOrder(T); //先根遍历下一棵子树 } }
-
后根遍历。若树非空,先依次遍历根结点的每棵子树,再访问根结点,遍历子树时仍遵循先子树后根的规则。
树的后根遍历序列与这棵树相应二叉树的中序序列相同。
//树的后根遍历 void PostOrder(TreeNode *R){ if(R!=NULL){ while(R还有下一个子树T) PostOrder(T); //后根遍历下一棵子树 visit(R); //访问根结点 } }
先根遍历、后根遍历——深度优先遍历
-
层次遍历(用队列实现)——广度优先遍历。
①若树非空,则根结点入队
②若队列非空,队头元素出队并访问,同时将该元素的孩子依次入队
③重复②直到队列为空
2、森林的遍历
- 先序遍历森林。若森林为非空,则按如下规则进行遍历:
- 访问森林中第一棵树的根结点。
- 先序遍历第一棵树中根结点的子树森林。
- 先序遍历除去第一棵树之后剩余的树构成的森林。
效果等同于对各个树依次进行先根遍历,也等同于对对应二叉树进行先序遍历
-
中序遍历森林。森林为非空时,按如下规则进行遍历:
-
中序遍历森林中第一棵树的根结点的子树森林。
-
访问第一棵树的根结点.
-
中序遍历除去第一棵树之后剩余的树构成的森林。
-
效果等同于依次对各个树进行后根遍历,也等同于对对应二叉树进行中序遍历
树和森林的遍历与二叉树遍历的关系
树 | 森林 | 二叉树 |
---|---|---|
先根遍历 | 先序遍历 | 先序遍历 |
后根遍历 | 中序遍历 | 中序遍历 |
5.5 树和二叉树的应用
5.5.1 二叉排序树
二叉排序树,又称二叉查找树(BST, Binary Search Tree)一棵二叉树或者是空二叉树,或者是具有如下性质的二叉树:
-
左子树上的所有结点的关键字均小于根节点的关键字;
-
右子树上的所有结点的关键字均大于根节点的关键字;
-
左子树和右子树又各自是一颗二叉排序树。
=> 左子树结点值<根结点值<右子树结点值 => 进行中序遍历,可以得到一个递增的有序序列
**用途:**二叉排序树可用于元素的有序组织、搜索
二叉排序树的查找操作:
递归实现查找:
二叉排序树的插入操作:
二叉排序树的构造:
二叉排序树的删除操作:
①若被删除结点z是叶结点,则直接删除,不会破坏二叉排序树的性质。
②若结点z只有一棵左子树或右子树,则让z的子树成为z父结点的子树,替代z的位置
③若结点z有左、右两棵子树,则令z的直接后继(或直接前驱)替代z,然后从二叉排序树中删去这个直接后继(或直接前驱),这样就转换成了第一或第二种情况。z的后继:z的右子树中最左下结点(该结点一定没有左子树)z的前驱:z的左子树中最右下结点(该结点一定没有右子树)
查找效率分析
查找长度——在查找运算中,需要对比关键字的次数称为查找长度,反映了查找操作时间复杂度。
最好情况:n个结点的二叉树最小高度为 ⌊ l o g 2 n ⌋ + 1 \lfloor log_2n\rfloor +1 ⌊log2n⌋+1. 平均查找长度= O( l o g 2 n log_2n log2n)
最坏情况:每个结点只有一个分支,树高h=结点数n. 平均查找长度=O( n n n)
查找成功的平均查找长度ASL(Average Search Length)
查找失败的平均查找长度ASL(Average Search Length)
5.5.2 平衡二叉树(AVL)
平衡二叉树(Balanced Binary Tree),简称平衡树(AVL树)——树上的任一结点的左子树和右子树的深度之差不超过1.
结点的平衡因子=左子树高-右子树高
平衡二叉树结点的平衡因子的值只可能是-1、0或1
只要有任一结点的平衡因子绝对值大于1,就不是平衡二叉树
定义平衡二叉树结点代码
typedef struct AVLNode{
int key; //数据域
int balance; //平衡因子
struct AVLBNode *lchild,*rchild;
}AVLNode,*AVLTree;
平衡二叉树的插入
在二叉排序树中插入新结点后,如何保持平衡?——从插入点往回找到第一个不平衡结点,调整以该结点为根的子树
每次调整的对象都是”最小不平衡子树“
5.5.3 哈夫曼树和哈夫曼编码
1、哈夫曼树的定义
结点的权:有某种现实含义的数值(如:表示结点的重要性等)
结点的带权路径长度:从树的根到该结点的路径长度(经过的边数)与该结点上权值的乘积
树的带权路径长度:树中所有叶结点的带权路径长度之和(WPL, Weighted Path Length)
W
P
L
=
∑
i
=
1
n
w
i
l
i
WPL=\sum_{i=1}^nw_il_i
WPL=i=1∑nwili
在含有n个带权叶结点的二叉树中,其中带权路径长度(WPL)最小的二叉树称为哈夫曼树,也称最优二叉树。
哈夫曼树不是唯一的!
2、构造哈夫曼树
给定n个权值分别为wl, w2,…, wn的结点,构造哈夫曼树的算法描述如下:
- 将这n个结点分别作为n棵仅含一个结点的二叉树,构成森林F。
- 构造一个新结点,从F中选取两棵根结点权值最小的树作为新结点的左、右子树,并且将新结点的权值置为左、右子树上根结点的权值之和。
- 从F中删除刚才选出的两棵树,同时将新得到的树加入F中。
- 重复步骤2和3,直至F中只剩下一棵树为止。
构造哈夫曼树的注意事项:
- 每个初始结点最终都成为叶结点,且权值越小的结点到根结点的路径长度越大。
- 哈夫曼树的结点总数为 2n−1。
- 哈夫曼树中不存在度为 1 的结点。
- 哈夫曼树并不唯一,但 WPL 必然相同且为最优。
3、哈夫曼编码
将字符频次作为字符结点权值,构造哈夫曼树,即可得哈夫曼编码,可用于数据压缩
前缀编码:没有一个编码是另一个编码的前缀
固定长度编码:每个字符用相等长度的二进制位表示
可变长度编码:允许对不同字符用不等长的二进制位表示