数据结构入门之树和二叉树(五)
大纲
一、树的概念和性质
1.1 树的概念
1.1.1 树的定义
树是n(n≥0)个结点的有限集(n=0时为空树)。在任意一棵非空树中:(1)有且仅有一个根结点Root,树下面有很多子树(递归)(2)当n>1时,其余结点可分为m(m>0)个互不相交的有限集T1、T2、…Tn,其中每一个集合本身又是一棵树,称为根结点的子树(SubTree)。
树是一种递归定义的数据结构。除了根结点外,任何一个结点都有且仅有一个前驱。每个结点可以有0个或多个后继
注意1:n>0时根结点是唯一的
注意2:m>0时,子树个数没有限制,但他们一定互不相交
1.1.2 结点分类与结点间关系
1. 结点分类
树的结点包含一个数据元素 及 若干指向其子树的分支
结点分类 | 解释 |
---|---|
根结点 | 有且仅有一个根结点(非空树) |
分支结点(非终端结点) | 有后继的结点 |
叶子结点(终端结点) | 没有后继的结点 |
2. 结点间关系
结点间关系 | 解释 |
---|---|
孩子结点(Child) | 结点的子树的根称为该结点的孩子 |
双亲结点(Parent) | 上述的该结点就是孩子的双亲 |
兄弟结点(Sibling) | 同一个双亲的孩子之间互称兄弟 |
堂兄弟结点 | 其双亲在同一层的结点互为堂兄弟 |
祖先结点 | 结点的祖先是从根结点到该结点 所经分支的所有结点 |
子孙结点 | 以该结点为根的子树中的任一结点都称为 该结点的子孙 |
两结点间的路径 | 路径只能从上往下 |
路径长度 | 经过几条“边”(层次的差值) |
根结点:无双亲,唯一
中间结点:一个双亲多个孩子
叶结点:无孩子,可以多个
1.1.3 树的其他相关概念
术语 | 解释 |
---|---|
结点的层次(深度) | 从上往下数(根结点层次为1) |
结点的高度 | 从下往上数(区别树的高度) |
(结点的)度 | 结点有几个孩子/子树 |
树的度 | 树内各结点的度的最大值 |
树的深度/高度 | 树中结点的最大层次 |
- 有序树和无序树:若将树中结点的各子树看成从左至右有次序的,不能互换,则称为有序树,否则为无序树
- 树和森林 :m(m≥0)棵互不相交的树的集合(对树中每个结点而言,其子树的集合就是森林)
1.2 (非空)树的性质
1. 结点数 = 总度数+1(只有根结点头上没有“天线”)
2. 度为m的树、m叉树的区别
- 树的度:各结点的度的最大值
- m叉树:各结点最多只能m个孩子的树
度为m的树 | m叉树 |
---|---|
任意结点的度≤m(最多m个孩子) | 任意结点的度≤m(最多m个孩子) |
至少有一个结点的度=m(有m个孩子) | 允许所有结点的度都<m |
一定是非空树,至少m+1个结点 | 可以是空树 |
相同点:m叉树第i层至多有mi-1个结点; 度为m的树第i层至多有mi-1个结点;**(每个结点都有m个孩子)
不同点: 高度为h的m叉树至多有 (mh-1/m-1) 个结点(等比数列求和m0+m1+ … +mh-1),至少有h个结点; 高度为h、度为m的树至少有h+m-1个结点
3. 具有n个结点的m叉树的最小高度(即完全m叉树)为 [logm(n(m - 1) + 1)]向上取整 或者 [logmn]向下取整+1
- 法一:和上层比较
- 法二:和下层比较
1.3 树的存储结构
1.3.1 双亲表示法(顺序存储)
顺序存储结点数据, 结点中保存父结点在数组中的下标
(除了根结点外,其余每个结点一定有且仅有一个双亲,我们约定根结点的双亲指针域设置为-1)
增/删/改/查优缺点:
- 新增数据元素,无需按逻辑上的次序存储
- 删除数据元素两种方案:删除数据元素,父结点数组下标置为-1;将数组最下面的结点替换删除的结点
删除的不是叶子结点时,要删除其所有子树的结点
- 查:找父结点方便;找孩子不方便
代码实现:
#define MAX_TREE_SIZE 100 //树中最多结点数
typedef int ElemType; //树结点的数据类型暂定为整型
//1.结点结构
typedef struct PTNode
{
ElemType data; //结点数据元素
int parent; //双亲位置域(数组下标)
}PTNode;
//2.树结构
typedef struct
{
PTNode nodes[MAX_TREE_SIZE]; //结点数组
int n; //结点数
int r; //根的位置,可不写(默认为0的位置)
}PTree;
1.3.2 孩子表示法
顺序存储结点数据, 结点中保存孩子链表头指针(顺序+链式存储)
增/删/改/查优缺点:
- 查:找孩子方便;找父结点不方便
代码实现:
#define MAX_TREE_SIZE 100 //树中最多结点数
typedef int ElemType; //树结点的数据类型暂定为整型
//1.堂兄弟结构(某一结点的所有孩子组成一个链表)
struct CTNode
{
int child; //孩子结点在数组中的位置
struct CTNode *next; //下一个孩子
}
//2.结点结构
typedef struct
{
ElemType data; //结点数据元素
struct CTNode *firstChild; //指针域指向第一个孩子(实际指向所有孩子)
}CTBox;
//3.树结构
typedef struct
{
CTBox nodes[MAX_TREE_SIZE]; //结点数组
int n; //结点数
int r; //根的位置
}CTree;
1.3.3 孩子兄弟表示法(树与二叉树的转换)
用二叉链表存储树——左孩子右兄弟
(孩子兄弟表示法存储的树, 从存储视角来看形态上和二叉树类似,所以可以森林和二叉树的转换(1.4节),也可以用二叉树的操作来处理树(第四节))
代码实现:
//1.树的结点结构和树结构
typedef struct CSNode{
ElemType data; //数据域
struct CSNode *firstchild, *nextsibling; //第一个孩子和右兄弟指针
}CSNode,*CSTree;
//...
1.4 森林与(二叉)树的转换
本质就是用二叉链表存储森林(孩子兄弟表示法):先将每棵树用孩子转化成二叉树,然后每棵树的根结点为兄弟关系(森林中各个树的根结点之间视为兄弟关系)
二、二叉树的概念和性质
2.1 二叉树的概念
2.1.1 二叉树的定义
二叉树是n(n≥0)个结点的有限集合:
① n = 0 为空二叉树,n = 1 只有根结点
② n > 0 由一个根结点和两个互不相交的被称为根的左子树和右子树组成。左子树和右子树又分别是一棵二叉树。
特点:①每个结点至多只有两棵子树 ②左右子树不能颠倒(二叉树是有序树)
2.1.2 几个特殊的二叉树
1. 满二叉树:一棵高度为h,且含有2h - 1个结点的二叉树
2. 完全二叉树:当且仅当其每个结点都与高度为h的满二叉树中编号为1~n的结点一一对应(如果某结点只有一个孩子,那么一定是左孩子)
3. 二叉排序树:空二叉树或者是具有如下性质的二叉树:
①左子树上所有结点的关键字均小于根结点的关键字;
②右子树上所有结点的关键字均大于根结点的关键字。
③左子树和右子树又各是一棵二叉排序树。
二叉排序树可用于元素的排序、搜索、插入新的结点
4. 平衡二叉树:树上任一结点的左子树和右子树的深度之差不超过1
平衡二叉树能有更高的搜索效率:往宽处长而不往深处长,搜索更快
2.2 二叉树的性质
- 二叉树第i层最多有2i-1个结点 (m叉树第i层至多有mi-1个结点)
- 高度为h的二叉树至多有 (2h-1) 个结点 (高度为h的m叉树至多有 (mh-1/m-1) 个结点)
- 非空二叉树中度为0、1、2的结点个数分别为n0、n1、n2,则n0 = n2 - 1(叶子结点比二分支结点多一个)
- 具有n个(n > 0)结点的完全二叉树的高度h为[log2(n + 1)]向上取整 或[log2n]向下取整 + 1 (同完全m叉树)
- 对于完全二叉树,可以由的结点数n 推出度为0、1和2的结点个数为n0、n1和n2(完全二叉树最多只会有一个度为1的结点)
2.3 二叉树的存储结构
2.3.1 二叉树的顺序存储
1. 完全二叉树
按照从上至下、从左至右的顺序依次存储完全二叉树中的各结点
结点编号一般从1开始,方便找结点的左孩子/右孩子/双亲等
代码实现:
#define MAX_SIZE 100
typedef int ElemType; //树结点的数据类型,暂定为整型
//1.结点结构
struct TreeNode{
ElemType value; //结点中的数据元素
bool isEmpty; //判断结点是否为空
};
//2.树结构
TreeNode T[MAX_SIZE]; //定义一个长度为MAX_SIZE的数组T,按照从上至下、从左至右的顺序依次存储完全二叉树中的各结点
//3.初始化时所有空结点标记为空
for(int i = 0; i < MAX_SIZE; i++)
{
T[i].isEmpty = true ;
}
常考操作(对于一共n个结点的完全二叉树):
查找 | |
---|---|
𝑖 的左孩子 | 2𝑖 |
𝑖 的右孩子 | 2𝑖+1 |
𝑖 的父节点 | [𝑖/2]向下取整 |
𝑖 所在的层次 | [log2(n + 1)]向上取整 或 [log2n]向下取整 + 1 |
判断 | |
𝑖 是否有左孩子 | 2𝑖 ≤ n |
𝑖 是否有右孩子 | 2𝑖+1 ≤ n |
i 是否是叶子结点 | 𝑖 > [n/2]向下取整 |
2. 非完全二叉树
非完全二叉树的顺序存储结构,一定要把二叉树的结点编号与完全二叉树对应起来
代码实现:
同上完全二叉树
常考操作(对于一共n个结点的非完全二叉树):
在找到该结点的左孩子/右孩子/父结点的基础上,判断isEmpty元素是否为真(非完全二叉树的树数组不连续存储结点)
缺点:
高度为h且只有h个结点的单支树(所有结点只有右孩子),也至少需要2h-1 个存储单元。故二叉树的顺序存储结构,只适合存储完全二叉树
2.3.2 二叉树的链式存储(二叉链表)
n个结点的二叉链表共有n+1个空链域,可用于构造线索二叉树
代码实现:
typedef int ElemType; //树结点的数据类型,暂定为整型
//1.二叉树的结点结构和树结构
typedef struct BiTNode{
ElemType data; //数据域
struct BiTNode *lchild, *rchild; //左右孩子指针
}BiTNode,*BiTree;
//2.定义一棵空树
BiTree root = NULL;
//3.插入根结点
root = new BiTNode;
root->data = 1;
root->lchild = NULL;
root->rchild = NULL;
//4.插入新结点
BiTNode *p = new BiTNode;
p->data = 2;
p->lchild = NULL;
p->rchild = NULL;
//与其他结点的关系
root->lchild = p;
//5.释放开辟在堆区的指针
优缺点:
- 对于指定结点的左/右孩子,可以很方便找到
- 对于指定结点的双亲,只能从根结点开始遍历寻找(可以将结点结构定义为三叉链表,即多定义一个parent指针指向双亲)
三、二叉树的遍历及线索二叉树
3.1 二叉树的四种遍历
先/中/后序遍历是基于树的递归特性确定的次序规则,空间复杂度为O(n);而层次遍历是基于树的层次特性确定的次序规则
3.1.1 二叉树的先序遍历(根-左-右)
遍历过程:
- 若二叉树为空,则什么也不做;
- 若二叉树非空:
①访问根结点;
②先序遍历左子树;
③先序遍历右子树
先序遍历:第一次路过时访问结点
中序遍历:第二次路过时访问结点
后序遍历:第三次路过时访问结点
代码实现:
//对于链式存储结构(二叉链表)的先序遍历--递归
void PreOrder(BiTree T)
{
if(T!=NULL)
{
visit(T); //访问当前根结点
PreOrder(T->lchild); //递归遍历左子树
PreOrder(T->rchild); //递归遍历右子树
}
}
3.1.2 二叉树的中序遍历(左-根-右)
遍历过程:
- 若二叉树为空,则什么也不做;
- 若二叉树非空:
①先序遍历左子树;
②访问根结点;
③先序遍历右子树
(第二次路过时访问结点)
代码实现:
//对于链式存储结构(二叉链表)的中序遍历--递归
void InOrder(BiTree T)
{
if(T!=NULL)
{
PreOrder(T->lchild); //递归遍历左子树
visit(T); //访问当前根结点
PreOrder(T->rchild); //递归遍历右子树
}
}
3.1.3 二叉树的后序遍历(左-右-根)
遍历过程:
- 若二叉树为空,则什么也不做;
- 若二叉树非空:
①先序遍历左子树;
②先序遍历右子树;
③访问根结点
(第三次路过时访问结点)
代码实现:
//对于链式存储结构(二叉链表)的后序遍历--递归
void PostOrder(BiTree T)
{
if(T!=NULL)
{
PreOrder(T->lchild); //递归遍历左子树
PreOrder(T->rchild); //递归遍历右子树
visit(T); //访问当前根结点
}
}
3.1.4 二叉树的层次遍历
遍历过程:
- 初始化一个辅助队列
- 根结点入队
- 若队列非空,则队头结点出队,访问该结点,并将其左、右孩子插入队尾(如果有的话)
- 重复③直至队列为空
代码实现:
//对于链式存储结构(二叉链表)的层次遍历--递归
void LevelOrder(BiTree T)
{
//1.创建并初始化辅助队列(链队列)
LinkQueue Q;
InitQueue(Q);
//2.根结点入队
EnQueue(Q,T); //队列存放的是结点的指针(4字节),而非结点本身(最少8字节)
//3.若队列非空,则队头结点出队,访问该结点,并将其左、右孩子插入队尾(如果有的话),直至为空
BiTree p; //存放出队的队头结点
while(!IsEmpty(Q))
{
DeQueue(Q,p); //队头结点出队
visit(p); //访问出队的该结点
if(p->lchild != NULL)
{
EnQueue(Q,p->lchild); //左孩子入队
}
if(p->rchild != NULL)
{
EnQueue(Q,p->rchild); //右孩子入队
}
}
}
//ps:链式队列的结点
typedef struct LinkNode
{
BiTNode * data; //队列存的是指针,而非二叉树的结点,减小内存
struct LinkNode* next;
}LinkNode;
3.1.5 由二叉树的遍历序列确定二叉树
若只给出一棵二叉树的前/中/后/层序遍历序列中的一种,不能唯一确定一棵二叉树。但中序+其他一种可以实现唯一确定一棵二叉树
1. 前序+中序遍历序列
- 先由前序遍历序列,确定根结点在中序遍历序列的位置
- 中序遍历序列根结点左侧为该结点的左子树,右侧为该结点的右子树
2. 后序+中序遍历序列
- 先由后序遍历序列,确定根结点在中序遍历序列的位置
- 中序遍历序列根结点左侧为该结点的左子树,右侧为该结点的右子树
3. 层序+中序遍历序列
- 先由层序遍历序列,确定根结点在中序遍历序列的位置
- 中序遍历序列根结点左侧为该结点的左子树,右侧为该结点的右子树,画出左右子树
- 再由层序遍历序列,确定左子树根结点、右子树根结点在中序遍历序列的位置
- 中序遍历序列继续根据结点划分左右子树直到底(画图解决)
3.1.6 二叉树遍历的应用
1.求树的深度
//递归的方法求树的深度
int treeDepth(BiTree T)
{
if(T==NULL)
{
return 0;
}
else
{
int l = treeDepth(T->lchild);
int r = treeDepth(T->rchild);
return (l>r ? l+1 ; r+1) //树的深度=Max(左子树深度,右子树深度)+1
}
}
3.2 线索二叉树
3.2.1 线索二叉树是什么、为什么
普通二叉树的缺点:找前驱、后继很不方便;遍历操作必须从根开始,而不能从一个指定结点开始中序遍历——这就是为什么有线索二叉树
1. 线索二叉树
n个结点的二叉树,有n+1个空链域,可用来记录前驱、后继的信息,线索化(怎么做见下一节)后,可以很方便地找到前驱、后继,遍历操作也不必从根开始(如下图为中序线索二叉树)
线索:指向前驱/后继的指针
2. 三种线索二叉树
各个结点的前驱、后继关系是按先序/中序/后序的遍历来确定,可将线索二叉树分为
先序线索二叉树(对应的前驱、后继叫做先序前驱/先序后继)
中序线索二叉树(对应的前驱、后继叫做中序前驱/中序后继)
后序线索二叉树(对应的前驱、后继叫做后序前驱/后序后继)
3.2.2 线索二叉树怎么做
1. 线索二叉树(结点)的存储结构
typedef int ElemType; //树结点的数据类型,暂定为整型
//线索二叉树的结点(创建出来的树叫线索链表)
typedef struct ThreadNode{
ElemType data; //数据域
struct ThreadNode *lchild, *rchild; //左右孩子指针
int ltag,rtag; //左右线索标志:tag==0表示指针指向孩子,tag==1表示指针是“线索”
}ThreadNode,*ThreadTree;
2. 三种二叉树的线索化(一边遍历一边线索化)
先序的PreThread函数有一点不同,先序是根-左-右,访问根结点时若把左孩子给了前驱,那左时访问回去导致无限环绕——可通过ltag增加判断
中序线索化代码实现:
//1.设置全局变量pre,指向当前访问结点的前驱,一开始指向NULL
ThreadNode *pre = NULL
//2.中序线索化二叉树T
void CreateInThread(ThreadTree T)
{
pre = NULL; //每次调用pre初始化为NULL
if(T!=NULL) //非空二叉树才能线索化
{
InThread(T); //中序线索化二叉树
if(pre->rchild == NULL) //处理遍历的最后一个结点
pre->rtag = 1;
}
}
//主函数的辅助函数InThread(T):中序遍历二叉树,一边遍历一边线索化
void InThread(ThreadTree T)
{
if(T!=NULL)
{
InThread(T->lchild); //中序遍历左子树
visis(T); //访问当前根结点,线索化由visit函数完成
InThread(T->rchild); //中序遍历右子树
}
}
//InThread(T)的辅助函数visit(T):完成线索化
void visit(ThreadNode *q)
{
if(q->lchild == NULL) //左子树为空,建立当前结点的前驱线索
{
q->lchild = pre;
q->ltag = 1;
}
if(pre->rchild == NULL && pre !=NULL) //建立前驱结点的后继线索
{ //pre !=NULL表示第一个结点没有前驱,或者说最开始的pre的后继不能指向第一个结点
pre->rchild = q;
pre->rtag = 1;
}
pre = q; //更新pre结点
//最后还要检查pre的rchild是否为NULL,如果是,则令rtag=1;
}
先序线索化代码实现:
//1.设置全局变量pre,指向当前访问结点的前驱,一开始指向NULL
ThreadNode *pre = NULL
//2.先序线索化二叉树T
void CreatePreThread(ThreadTree T)
{
pre = NULL; //每次调用pre初始化为NULL
if(T!=NULL) //非空二叉树才能线索化
{
PreThread(T); //先序线索化二叉树
if(pre->rchild == NULL) //处理遍历的最后一个结点
pre->rtag = 1;
}
}
//主函数的辅助函数PreThread(T):先序遍历二叉树,一边遍历一边线索化
void PreThread(ThreadTree T)
{
if(T!=NULL)
{
visis(T); //先访问当前根结点,线索化由visit完成
if(T->ltag == 0) //lchild不是前驱线索时!!!
{
PreThread(T->lchild);
}
PreThread(T->rchild);
}
}
//PreThread(T)的辅助函数visit(T):完成线索化
void visit(ThreadNode *q)
{
if(q->lchild == NULL) //左子树为空,建立当前结点的前驱线索
{
q->lchild = pre;
q->ltag = 1;
}
if(pre->rchild == NULL && pre !=NULL) //建立前驱结点的后继线索
{ //pre !=NULL表示第一个结点没有前驱,或者说最开始的pre的后继不能指向第一个结点
pre->rchild = q;
pre->rtag = 1;
}
pre = q; //更新pre结点
//最后还要检查pre的rchild是否为NULL,如果是,则令rtag=1;
}
后序线索化代码实现:
//1.设置全局变量pre,指向当前访问结点的前驱,一开始指向NULL
ThreadNode *pre = NULL
//2.后序线索化二叉树T
void CreatePostThread(ThreadTree T)
{
pre = NULL; //每次调用pre初始化为NULL
if(T!=NULL) //非空二叉树才能线索化
{
PostThread(T); //后序线索化二叉树
if(pre->rchild == NULL) //处理遍历的最后一个结点
pre->rtag = 1;
}
}
//主函数的辅助函数PostThread(T):后序遍历二叉树,一边遍历一边线索化
void PostThread(ThreadTree T)
{
if(T!=NULL)
{
PostThread(T->lchild); //后序遍历左子树
PostThread(T->rchild); //后序遍历右子树
visis(T); //访问当前根结点,线索化由visit函数完成
}
}
//PostThread(T)的辅助函数visit(T):完成线索化
void visit(ThreadNode *q)
{
if(q->lchild == NULL) //左子树为空,建立当前结点的前驱线索
{
q->lchild = pre;
q->ltag = 1;
}
if(pre->rchild == NULL && pre !=NULL) //建立前驱结点的后继线索
{ //pre !=NULL表示第一个结点没有前驱,或者说最开始的pre的后继不能指向第一个结点
pre->rchild = q;
pre->rtag = 1;
}
pre = q; //更新pre结点
//最后还要检查pre的rchild是否为NULL,如果是,则令rtag=1;
}
3.2.3 在线索二叉树中找前驱后继
1. 中序线索二叉树(左-根-右)
1)在中序线索二叉树中找到指定结点*p的中序前驱pre
- 若p->ltag == 1,则pre = p -> lchild;
- 若p->ltag == 0,则肯定有左孩子,故pre=当前结点的左子树中最右下结点(左子树按照中序遍历的最后一个结点)
代码实现:
//在中序线索二叉树中找到结点p的前驱结点
ThreadNode *PreNode(ThreadNode *p)
{
if(p->ltag==0)
{
return LastNode(p->lchild);
}
else
{
return p->lchild;
}
}
//辅助函数:找到以p为根的子树中,最后一个被中序遍历的结点
ThreadNode *LastNode(ThreadNode *p)
{
while(p->rtag == 0)
{
p = p->rchild;
}
return p;
}
(已经知道任意结点的中序前驱,可以对中序线索二叉树进行逆向中序遍历(利用线索实现而非递归算法),其他遍历同理)
void RevInorder(ThreadNode *T)
{
for(ThreadNode *p = LastNode(T) ; p != NULL ; p = PreNode(p) )
visit(p);
}
2)在中序线索二叉树中找到指定结点*p的中序后继next
- 若p->rtag==1,则next = p->rchild
- 若p->rtag==0,则肯定有右孩子,故next=当前结点的右子树中最左下结点(右子树按照中序遍历的最后一个结点)
代码实现:
//在中序线索二叉树中找到结点p的后继结点
ThreadNode *NextNode(ThreadNode *p)
{
if(p->rtag==0)
{
return FirstNode(p->rchild);
}
else
{
return p->rchild;
}
}
//辅助函数:找到以p为根的子树中,第一个被中序遍历的结点
ThreadNode *FirstNode(ThreadNode *p)
{
while(p->ltag == 0)
{
p = p->lchild;
}
return p;
}
(已经知道任意结点的中序后继,可以对中序线索二叉树进行中序遍历(利用线索实现而非递归算法),其他遍历同理)
void Inorder(ThreadNode *T)
{
for(ThreadNode *p = FirstNode(T) ; p != NULL ; p = NextNode(p) )
visit(p);
}
2. 先序线索二叉树(根-左-右)
1)在先序线索二叉树中找到指定结点*p的先序前驱pre
- 若p->ltag == 1,则pre = p -> lchild;
- 若p->ltag == 0,则肯定有左孩子,但先序线索中,左右孩子只可能是后继
故只能从头先序遍历或者用三叉链表来找到先序前驱pre(下图为利用三叉链表来找到先序前驱pre)
2)在先序线索二叉树中找到指定结点*p的先序后继next
- 若p->rtag==1,则next = p->rchild
- 若p->rtag==0,则肯定有右孩子,左孩子分情况讨论
①若p有左孩子,则先序后继为左孩子
②若p没有左孩子,则先序后继为右孩子
3. 后序线索二叉树(左-右-根)
1)在后序线索二叉树中找到指定结点*p的后序前驱pre
- 若p->ltag == 1,则pre = p -> lchild;
- 若p->ltag == 0,则肯定有左孩子,右孩子分情况讨论
①若p有右孩子,则后序前驱为右孩子
②若p没有右孩子,则后序前驱为左孩子
2)在后序线索二叉树中找到指定结点*p的后序后继next
- 若p->rtag==1,则next = p->rchild
- 若p->rtag==0,则肯定有右孩子,但后序线索中,左右孩子只可能是前驱
故只能从头后序遍历或者用三叉链表来找到后序后继next(下图为利用三叉链表来找到后序后继next)
四、树和森林的遍历
4.1 树的三种遍历
4.1.1 树的先序遍历(深度优先遍历)
若树非空,先访问根结点,再依次对每棵子树进行先序遍历
代码实现:
void PreOrder(TreeNode *R)
{
if(R != NULL)
{
visit(R); //访问根结点
while(R还有下一个子树T)
{
PreOrder(T); //先序遍历下一棵子树
}
}
}
树和二叉树的转化:
树的先序遍历序列与这棵树相应二叉树(孩子兄弟表示的树)的先序遍历序列相同
4.1.2 树的后序遍历(深度优先遍历)
若树非空,先依次对每棵子树进行后序遍历,最后再访问根结点
代码实现:
void PostOrder(TreeNode *R)
{
if(R != NULL)
{
while(R还有下一个子树T)
{
PreOrder(T); //后序遍历下一棵子树
}
visit(R); //访问根结点
}
}
树和二叉树的转化:
树的后序遍历序列与这棵树相应二叉树(孩子兄弟表示的树)的中序遍历序列相同
4.1.3 树的层序遍历(广度优先遍历)
遍历过程(与二叉树的层序遍历类似):
- 初始化一个辅助队列
- 若树非空,则根节点入队
- 若队列非空,队头元素出队并访问,同时将该元素的孩子依次入队(如果有的话)
- 重复③直至队列为空
树和二叉树的转化:
转化成二叉树后,没有对应相同的遍历序列
4.2 森林的两种遍历
4.2.1 森林的先序遍历
遍历过程:(效果等同于依次对各个树进行先根遍历)
- 访问森林中第一棵树的根结点(森林为非空)
- 先序遍历第一棵树中根结点的子树森林
先序遍历除去一棵树根结点之后剩余的树构成的森林
- 重复直至结束
森林和二叉树的转化:
森林的先序遍历序列与森林对应二叉树(孩子兄弟表示的森林)的先序遍历序列相同。
4.2.2 森林的中序遍历
遍历过程:(效果等同于依次对各个树进行后根遍历)
- 中序遍历森林中第一棵树的根结点的子树森林(森林为非空)
- 访问第一棵树的根结点
- 重复直至结束
森林和二叉树的转化:
森林的中序遍历序列与森林对应二叉树(孩子兄弟表示的森林)的中序遍历序列相同。
五、二叉排序树BST
5.1 二叉排序树的定义
二叉排序树,又称二叉查找树(BST,Binary Search Tree),一棵空二叉树,或者是具有如下性质的二叉树:
①左子树上所有结点的关键字均小于根结点的关键字;
②右子树上所有结点的关键字均大于根结点的关键字。
③左子树和右子树又各是一棵二叉排序树。
重要性质:
由于左子树结点值< 根结点值< 右子树结点值,则对二叉排序树进行中序遍历,可以得到一个递增的有序序列
5.2 二叉排序树的操作
5.2.1 二叉排序树的查找
若树非空,目标值与根结点的值比较:
①若小于根结点,则在左子树上查找;若大于根结点,则在右子树上查找。
②若相等,则查找成功,返回结点指针;查找失败返回NULL
代码实现:
//二叉排序树结点
typedef struct BSTNode{
int key;
struct BSTNode *lchild, *rchild;
}BSTNode,*BSTree;
//在二叉排序树查找值为key的结点
//法一:while循环
BSTNode *BST_Search(BSTree T, int key)
{
while(T!=NULL && key != T->key) //若树空或等于根结点值,则结束循环
{
if(key < T->key)
{
T = T->lchild;
}
else
{
T = T->rchild;
}
}
return T;
}
//法二:递归实现
BSTNode *BSTSearch(BSTree T, int key)
{
if(T == NULL)
return NULL;
if(key == T->key)
{
return T;
}
else if(key < T->key)
{
return BSTSearch(T->lchild , key)
}
else (key > T->key)
{
return BSTSearch(T->rchild , key)
}
}
法一:最坏空间复杂度O(1)
法二:空间复杂度为O(h)
查找效率分析:
查找长度:在查找运算中,需要对比关键字的次数称为查找长度,反映了查找操作时间复杂度
平均查找长度ASL(Average Search Length):分为查找成功的平均查找长度、查找失败的平均查找长度(需补充失败结点)
复杂度:取决于树的高度
- 最坏情况:每个结点只有一个分支,树高h=结点数n。平均查找长度=O(n)
- 最好情况:n个结点的二叉树最小高度为[log2n]向下取整 + 1。平均查找长度= O(log2n)
5.2.2 二叉排序树的插入和构造
二叉排序树的插入:若原二叉排序树为空,则直接插入结点;否则,若关键字k小于根结点值,则插入到左子树,若关键字k大于根结点值,则插入到右子树新插入的结点一定是叶子
代码实现:
//在二叉排序树插入关键字为k的新结点
//法一:递归实现
int BSTInsert(BSTree &T, int k)
{
if(T==NULL)
{
T = new BSTNode;
//对开辟的新结点赋值
T->key = k;
T->lchild = NULL;
T->lchild = NULL;
//返回1,插入成功
return 1;
}
else if(k==T->key) //树中存在相同关键字的结点,插入失败!
{
return 0;
}
else if(k < T->key) //插入到T的左子树上
{
return BSTInsert(T->lchild, k);
}
else //插入到T的右子树上
{
return BSTInsert(T->rchild, k);
}
}
递归实现:最坏空间复杂度O(1)
二叉排序树的构造:
可利用插入函数构造二叉排序树
代码实现:
void Create_BST(BSTree &T,int str[],int n)
{
T = NULL; //初始时T为空树
for(int i = 0; i < n; i++)
{
BSTInset(T,str[i]);
}
}
注意:不同的关键字序列可能得到同款二叉排序树,关键字相同顺序不同的序列也可能得到不同款的二叉排序树
5.2.3 二叉排序树的删除
先搜索找到目标结点:
① 若被删除结点z是叶结点,则直接删除,不会破坏二叉排序树的性质。
② 若结点z只有一棵左子树或右子树,则让z的子树 成为 z父结点的子树,替代z的位置。
③ 若结点z有左、右两棵子树,则令z的直接后继(或直接前驱)替代z,然后从二叉排序树中删去这个直接后继(或直接前驱),这样就转换成了第一或第二种情况。
z的后继:z的右子树中最左下结点(该节点一定没有左子树)
z的前驱:z的左子树中最右下结点(该节点一定没有右子树)
六、平衡二叉树AVL
平衡二叉树高度与完全二叉树同等数量级
6.1 平衡二叉树的定义
树上任一结点的左子树和右子树的高度之差不超过1,即结点的平衡因子=左子树高-右子树高 = 1、0、-1
代码实现:
//平衡二叉树结点
typedef struct AVLNode{
int key;
int balance;
struct AVLNode *lchild, *rchild;
}AVLNode, *AVLTree;
6.2 平衡二叉树的插入
在插入操作中,只要将最小不平衡子树调整平衡,则其他祖先结点都会恢复平衡(每次调整的对象都是“最小不平衡子树”,有四种方式调整)
最小不平衡子树:从插入点往回找到第一个不平衡结点,调整以该结点为根的子树
调整目标:1.恢复平衡;2.保持二叉排序树特性
要点:只有左孩子才能右上旋,只有右孩子才能左上旋
6.2.1 LL平衡旋转(右单旋转)
LL情况:由于在结点A的左孩子(L)的左子树(L)上插入了新结点,A的平衡因子由1增至2,导致以A为根的子树失去平衡
操作:需要将A的左孩子B向右上旋转代替A成为根结点,将A结点向右下旋转成为B的右子树的根结点,而B的原右子树则作为A结点的左子树。
靠左的结点(B)的左子树(BL)一直都会是该结点的左子树
靠右的结点(A)的右子树(AR)一直都会是该结点的右子树
代码思路:
实现f向右下旋转, p向右上旋转(其中f是爹,p为左孩子,gf为f他爹):
① f->lchild = p->rchild;
② p->rchild = f;
③ gf->lchild/rchild = p;
6.2.2 RR平衡旋转(左单旋转)
RR情况:由于在结点A的右孩子(R)的右子树(R)上插入了新结点,A的平衡因子由-1减至-2,导致以A为根的子树失去平衡
操作:需要将A的右孩子B向左上旋转代替A成为根结点,将A结点向左下旋转成为B的左子树的根结点,而B的原左子树则作为A结点的右子树
靠左的结点(A)的左子树(AL)一直都会是该结点的左子树
靠右的结点(B)的右子树(BR)一直都会是该结点的右子树
代码思路:
实现f 向左下旋转, p 向左上旋转(其中f是爹,p为右孩子,gf为f他爹):
① f->rchild = p->lchild;
② p->lchild = f;
③ gf->lchild/rchild = p;
6.2.3 LR平衡旋转(先左后右双旋转)
LR情况:由于在A的左孩子(L)的右子树(R)上插入新结点,A的平衡因子由1增至2,导致以A为根的子树失去平衡
操作:先将A结点的左孩子B的右子树的根结点C向左上旋转提升到B结点的位置,然后再把该C结点向右上旋转提升到A结点的位置
6.2.4 RL平衡旋转(先右后左双旋转)
RL情况:由于在A的右孩子(R)的左子树(L)上插入新结点,A的平衡因子由-1减至-2,导致以A为根的子树失去平衡
操作:先将A结点的右孩子B的左子树的根结点C向右上旋转提升到B结点的位置,然后再把该C结点向左上旋转提升到A结点的位置
6.3 查找效率分析
树高和查找次数/复杂度挂钩:若树高为h,则最坏情况下,查找一个关键字最多需要对比h次,即查找操作的时间复杂度不可能超过O(h)
假设以nh表示深度为h的平衡树中含有的最少结点数,则有n0 = 0, n1 = 1, n2 = 2,并且有nh = nh−1 + nh−2 + 1,则可以证明含有n个结点的平衡二叉树的最大深度为O(log2n) ,即平衡二叉树的平均查找长度为O(log2n)
七、哈夫曼树(二叉树)
术语 | 解释 |
---|---|
结点的权 | 有某种现实含义的数值(如:表示结点的重要性等) |
结点的带权路径长度 | 该结点的权值 × 树的根到该结点的路径长度(经过的边数) |
(树的)带权路径长度(WPL, Weighted Path Length) | 树中所有叶子结点的带权路径长度之和 |
7.1 哈夫曼树的定义
哈夫曼树(最优二叉树):在含有n个带权叶结点的二叉树中,其中带权路径长度(WPL)最小的二叉树
7.2 哈夫曼树的构造
给定n个权值分别为w1, w2,…, wn的结点,构造哈夫曼树的算法描述如下:
- 将这n个结点分别作为n棵仅含一个结点的二叉树,构成森林F
- 构造一个新结点:从F中选取两棵根结点权值最小的树作为新结点的左、右子树,并且将新结点的权值置为左、右子树上根结点的权值之和
- 从F中删除刚才选出的两棵树,同时将新得到的树加入F中
- 重复步骤2和3,直至F中只剩下一棵树为止
哈夫曼树的特点:
- 每个初始结点最终都成为叶结点,且权值越小的结点到根结点的路径长度越大
- 哈夫曼树的结点总数为2n − 1(合并n-1次,出现n-1个新的结点)
- 哈夫曼树中不存在度为1的结点
- 哈夫曼树并不唯一,但WPL必然相同且为最优
7.3 哈夫曼编码
固定长度编码:每个字符用相等长度的二进制位表示
可变长度编码:允许对不同字符用不等长的二进制位表示
前缀编码:没有一个编码是另一个编码的前缀(前缀码解码无歧义)
非前缀编码:有编码是另一个编码的前缀(非前缀码解码有歧义,在哈夫曼树中有字符在非叶子结点上)
哈夫曼编码:字符集中的每个字符作为一个叶子结点,将字符频次作为字符结点权值,构造哈夫曼树(故哈夫曼编码也不唯一),即可得到赫夫曼编码(可用于数据压缩)。