1.1 树的基本概念
1.1.1树的基本概念(逻辑结构):
树:从树根生长,逐级分支,是一种递归定义的数据结构。
树是n(n≥0)个结点的有限集合,n=0时,称为空树,这是一种特殊情况。在任意一棵非空树种应满足:
1.当且仅有一个特定的称为根的结点。
2.当n>1时,其余结点可分为m(m>0)个互不相交的有限集合
T
1
T_1
T1,
T
2
T_2
T2,…,
T
m
T_m
Tm,其中每个集合本身又是一棵树,并且称为根节点的子树。
1.1.2非空树的特性:
①有且仅有一个根节点。
②没有后继的结点称为“叶子结点”(或终端结点)。
③有后继的结点称为“分支结点”(或非终端结点)。
④除了根节点外,任何一个结点都有且仅有一个前驱。
⑤每个结点可以有0个或多个后继。
⑥
ϕ
\phi
ϕ空树——结点数为0的树。
1.1.3 结点之间的关系描述:
问题一:什么是两个结点之间的路径?
答:只能从上往下。
问题二:什么是路径长度?
答:经过几条边。
1.1.4 结点、树的属性描述:
①结点的层次(深度)——从上往下数。(默认从1开始)
②结点的高度——从下往上数。
③树的高度(深度)——总共多少层。
④结点的度——有几个孩子(分支)。
⑤树的度——各结点的度的最大值。
====》故:叶子结点的度=0,非叶子结点的度>0
1.1.5 有序树V.S无序树
有序树——逻辑上看,树中结点的各子树从左至右是有次序的,不能互换。
无序树——逻辑上看,树中结点的各子树从左至右是无次序的,可以互换。
1.1.5 树V.S森林
森林是m(m≥0,=0为空森林)棵互不相交的树的结合。
1.2 树常考的性质
考点一:
结点数(总数)=总度数+1
====》除根结点外,每个结点都可以算作度数。
考点二:
度为m的树、m叉树的区别:
度为m的树 | m叉树 |
任意结点的度≤m(最多m个孩子) | 任意结点的度≤m(最多m个孩子) |
至少有一个结点度 = m(有m个孩子) | 允许所有结点的度都 < m |
一定是非空树,至少有m+1个结点 | 可以是空树 |
考点三:
度为m的树第i层至多有
m
i
−
1
m^{i-1}
mi−1个结点(i≥1)
m叉树第i层至多有
m
i
−
1
m^{i-1}
mi−1个结点(i≥1)
====》至多就是最多,就是刚好每个结点下面都有满m的孩子,故为m的i-1次方(这边是每层)。
考点四:
高度为h的m叉树至多有
m
h
−
1
m
−
1
\frac{m^{h}-1}{m-1}
m−1mh−1个结点
====》至多就是最多,就是刚好每个结点下面都有满m的孩子,故为m的i-1次方(这边是所有)。
====》这就是一个简单的等比数列求和。
考点五:
高度为h、度为m的树至少有h+m-1个结点
高度为h的m叉树至少有h个结点
====》树的结点数就是:h层+(m度-1层{该层重复了})
考点六:
具有n个结点的m叉树的最小高度[
l
o
g
m
(
n
(
m
−
1
)
+
1
)
log_{m}{(n(m-1)+1)}
logm(n(m−1)+1)]
1.3 二叉树的基本概念
二叉树是n(n≥0)个结点的有限集合:
①或者为空二叉树,即n=0;
②或者由一个根节点和两个互不相交的被称为根的左子树和右子树组成。左子树和右子树又分别是一棵二叉树。
特点:
①每个结点至多只有两棵子树
②左右子树不能颠倒(二叉树是有序树)
③二叉树是递归定义的数据结构
1.3.1二叉树的五种状态:
1.3.2 几个特殊的二叉树
满二叉树(特殊的完全二叉树)、完全二叉树、二叉排序树、平衡二叉树。
====》完全二叉树:若某结点只有一个孩子,那么一定是左孩子。(完全二叉树最多只有一个度为1的结点)
====》③的说明:若按上图所示从左至右,从上至下进行排序,那么结点6为例,若孩子是12,右孩子是13,父节点是3,满足上面的关系式。
====》④的说明:若一个完全二叉树一共有n个结点,分支结点为i≤n/2,即:蓝色的为分支结点,同理可算出绿色的为叶子结点(排序方法与③同)
1.4 二叉树常考的性质
考点一:
设非空二叉树中度为0、1和2的结点个数分别为
n
0
n_0
n0、
n
1
n_1
n1、
n
2
n_2
n2,则
n
0
n_0
n0=
n
2
n_2
n2+1(叶子结点比二分结点多一个)
====》
n
0
n_0
n0是叶子结点,
n
2
n_2
n2是分支结点(有两个孩子)+根结点,
n
1
n_1
n1是分支结点(只有一个孩子)(因为若有两个
n
1
n_1
n1就变成一个
n
2
n_2
n2,故
n
1
n_1
n1最多就只有一个)。
====》式子①表示结点总数 = 叶子结点 +所有含有两个结点的分支结点(包含一个根结点)+有可能有,有可能没有的一个结点的分支结点。
====》式子②表示结点总数 = 总度数(有可能有,有可能没有的一个结点的分支结点 + 所有含有两个结点的分支结点(包含一个根结点)乘2)+ 一个根结点(不是任何人的孩子)
考点二:
二叉树第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 个结点
1.5 完全二叉树常考的性质
考点一:
具有n个(n>0)结点的完全二叉树的高度h为【
l
o
g
2
(
n
+
1
)
log_{2}(n+1)
log2(n+1)】或【
l
o
g
2
n
+
1
log_{2}n+1
log2n+1】
====》这边推导过程是第二个式子,第一个式子上面m叉树推导过,把m=2代入即可。
考点二:
对于完全二叉树,可由结点数n推出度为0、1和2的结点个数为
n
0
n_0
n0、
n
1
n_1
n1和
n
2
n_2
n2
====》
n
0
n_0
n0=
n
2
n_2
n2+1(叶子结点一定比二分支结点多1)
====》 故
n
0
n_0
n0+
n
2
n_2
n2(=2
n
2
n_2
n2+1)一定是奇数(叶子结点+二分支结点一定是奇数)
考点三:
高度为h的二叉树至多有
2
h
−
1
2^{h}-1
2h−1 个结点(满二叉树)
高度为h的二叉树至多有
m
h
−
1
m
−
1
\frac{m^{h}-1}{m-1}
m−1mh−1 个结点
1.6 二叉树的存储结构
1.6.1 二叉树的顺序存储
#define MaxSize 100
struct TreeNode{
ElemType value; //结点中的数据元素
bool isEmpty; //结点是否为空
};
TreeNode t[MaxSize];
//定义一个长度为MaxSize的数组t,按照从上至下,从左至右的顺序依次存储完全二叉树中的各个结点。
for(i=0;i<MaxSize;i++){
t[i].isEmpty=true;
}//初始化时所有结点标记为空
//可以让第一个位置空缺,保证数组下标和结点编号一致。
1.6.2 顺序存储的几个重要常考的基本操作:
基本操作:
① i的左孩子 ——2i
② i的右孩子 ——2i+1
③ i的父结点 ——[i/2] (i除2向下取整)
④ i所在的层次 ——[
l
o
g
2
(
n
+
1
)
log_{2}(n+1)
log2(n+1)或
l
o
g
2
n
+
1
log_{2}n+1
log2n+1]
基本判断:
若完全二叉树中共有n个结点,则:
① 判断i是否有左孩子? ——2i<n(结点总数)?
② 判断i是否有右孩子 ? ——2i+1<n?
③ 判断i是否有叶子/分支结点? ——i>[i/2] (i除2向下取整)
====》若普通二叉树(不是完全二叉树)则不能使用基本操作和基本判断.
====》若普通二叉树也想实现这些基本操作,即要:在二叉树的顺序存储中,一定要把二叉树的结点编号与完全二叉树对应起来:。
====》若普通二叉树也想实现这些基本判断,即要:使用基本操作找到位置后,只用isEmpty
进行检查是否存在。
====》普通二叉树按完全二叉树进行存储的话,最坏情况(浪费存储单元):高度为h且只有h个结点的单支树(所有结点只有右孩子),也至少需要
2
h
−
1
2^h-1
2h−1个存储单元。
====》结论:二叉树的顺序存储结构,只适合存储完全二叉树。
1.6.3二叉树的链式存储
二叉树链式存储的简单实现方式:
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; //作为根节点的左孩子
====》可知每个结点都有两个指针域,若有n个结点,则有2n个指针域。
====》除头结点外,其他结点头上都会连有一个指针,故有n-1个结点头上会有一个指针的。
====》即:n个结点的二叉链表共有n+1个空链域(指向NULL)。
====》这些空链域可以用于构造线索二叉树。
二叉树链式存储的基本操作:
①找到指定结点P的左/右孩子——超简单,直接检查左/右指针即可。
②如何找得到指定结点p的父结点——只能从根开始遍历寻找(费时)
改进方法:定义一个父节点指针(称作三叉链表[寻找父结点方便])
//二叉树的结点(链式存储)
typedef struct BiTNode{
ElemType data; //数据域
struct BiTNode *lchild *rchild; //左/右孩子指针
struct BiTNode *parent; //父结点指针
}BiTNode,*BiTree;
1.7 二叉树的遍历
包括:先序遍历/中序遍历/后序遍历。
层次遍历:基于树的层次特性确定的次序规则。
先/中/后序遍历:基于树的递归特性确定的次序规则。
提问:什么是遍历?
回答:就是按照某种次序把所有结点都访问一遍。
二叉树的递归特性
①要么是个空二叉树。
②要么就是由“根节点+左子树+右子树”组成的二叉树
先序遍历 ——》前缀表达式
中序遍历 ——》中缀表达式(需要加界限符)
后序遍历 ——》后缀表达式
1.7.1 先序遍历(代码)
先序遍历(PreOrder)的操作过程如下:
1.若二叉树为空,则什么也不做;
2.若二叉树非空:
①访问根节点;
②先序遍历左子树;
③先序遍历右子树;
void PreOrder(BiTree T){
if(T!=NULL){
visit(T); //访问根结点
PreOrder(T -> lchild); //递归遍历左子树
PreOrder(T -> rchild); //递归遍历右子树
}
}
1.7.2 中序遍历(代码)
中序遍历(INOrder)的操作过程如下:
1.若二叉树为空,则什么也不做;
2.若二叉树非空:
①先序遍历左子树;
②访问根节点;
③先序遍历右子树;
void InOrder(BiTree T){
if(T!=NULL){
InOrder(T -> lchild); //递归遍历左子树
visit(T); //访问根结点
InOrder(T -> rchild); //递归遍历右子树
}
}
1.7.3 后序遍历(代码)
后序遍历(PosOrder)的操作过程如下:
1.若二叉树为空,则什么也不做;
2.若二叉树非空:
①先序遍历左子树;
②先序遍历右子树;
③访问根节点;
void PosOrder(BiTree T){
if(T!=NULL){
PostOrder(T -> lchild); //递归遍历左子树
PostOrder(T -> rchild); //递归遍历右子树
visit(T); //访问根结点
}
}
1.7.4 求树的深度(应用)
int treeDepth(BiTree T){
if(T == NULL){
return 0;
}else{
int l =treeDepth(T -> lchild);
int r =treeDepth(T -> rchild);
//树的深度=MAX(左子树深度,右子树深度)+1
return l>r?l+1:r+1;
}
}
1.7.5 层序遍历
算法思想:
①初始化一个辅助队列。
②根结点入队。
③若队列非空,则队头结点出队,访问该结点,并将其左、右孩子插入队尾(若有的话)。
④重复③直至队列为空。
代码实现:
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); //右孩子入队
}
}
//二叉树的结点(链式存储)
typedef struct BiTNode{
char data;
struct BiTNode *lchild,*rchild;
}BiTNode,*BiTree;
//链式队列结点
typedef struct LinkNode{
BiTNode *data; //存指针而不是结点
struct LinkNode *next;
}LinkNode;
typedef struct{
LinkNode *front,*rear; //队头队尾
}LinkQueue;
====》这边说是根节点入队,实际上是保存的不是整个结点,而是指针,很明显这样保存指针比保存结点本身会的使得存储的空间少了很多。(保存指针更好)
1.8 由遍历序列构造二叉树
若只给出一棵二叉树的前/中/后/层序遍历序列中的一种,不能唯一确定一棵二叉树。
====》但若给出其中两种遍历(必须包含中序)序列,便可唯一确定一棵二叉树。
1.8.1 前序+中序遍历序列
从前序序列中分辨出根结点====》在从中序序列中(已知根节点)分辨出左子树和右子树====》回到前序序列中划分出两部分(左子树和右子树),同时知道左子树(或右子树)中的根节点====》其他步骤相同。
1.8.2 后序+中序遍历序列
与刚刚类似,只不过后序总的根节点是在最后。
1.8.3 层序+中序遍历序列
层序遍历序列是先:根节点 左子树根结点 右子树根结点。。。
其他与之前类似。
1.9 线索二叉树
遍历二叉树:以一定规则将二叉树中的结点排列城一个线性序列,从而得到几种遍历序列,使得该序列中的每个结点(除第一个和最后一个外)都有一个直接前驱和直接后继。
普通二叉树的缺点:
无法从指定结点开始中序遍历,只能从根结点出发开始遍历,否则完成不了遍历的。
====》线性表可以从指定结点开始往后遍历。
仅能体现一种父子关系,不能直接得到结点在遍历中的前驱和直接后继。
在含有n个结点的二叉树中,有n+1个空指针。
====》空指针总数=
2
n
0
2n_0
2n0+
n
1
n_1
n1 (空指针总数=叶子结点两个空指针+度为1的结点一个空指针)
n
0
n_0
n0=
n
2
n_2
n2+1(叶子结点数=度为2的结点数+1)
可得:空指针总数 =
n
0
n_0
n0+
n
1
n_1
n1+
n
2
n_2
n2+1 = n+1
线索二叉树的目的:加快查找结点前驱和后继的速度。
规定:
若无左子树,令lchild指向前驱结点
若无右子树,令rchild指向后继结点
增加两个标志域标识指针:指向左(右)孩子,指向前驱(后继)
l
t
a
g
{
0
lchild域指示结点的左孩子
1
lchild域指示结点的前驱
ltag\begin{cases} 0 &\text{lchild域指示结点的左孩子 } \\ 1 &\text{lchild域指示结点的前驱} \end{cases}
ltag{01lchild域指示结点的左孩子 lchild域指示结点的前驱
r
t
a
g
{
0
rchild域指示结点的右孩子
1
rchild域指示结点的后继
rtag\begin{cases} 0 &\text{rchild域指示结点的右孩子 } \\ 1 &\text{rchild域指示结点的后继} \end{cases}
rtag{01rchild域指示结点的右孩子 rchild域指示结点的后继
====》0表示左(右)孩子,1表示前驱(后继)
存储结构:(线索链表[二叉链表作为二叉树的存储结构])
typedef struct ThreadNode{
ElemType data; /数据元素
struct ThreadNode *lchild*rchild; / 左、右孩子指针
int ltag,rtag; /左、右线索标志
}ThreadNode,*ThreadTree;
1.9.1 中序线索二叉树
线索化的实质:遍历一次二叉树。
提问:①如何找到指定结点p在中序遍历序列的前驱?
②如何找到指定结点p的中序遍历序列的后继?
回答:
(思路)从根节点出发,重新进行中序遍历。
指针q记录当前访问的结点,指针pre记录上一个被访问的结点。
①当q == p时,pre为前驱。
②当pre == p时,q为后继。
====》这边指针q其实就是我们中序代码中visit(T)
中的T
====》缺点:找前驱、后继很不方便;遍历操作必须从根开始。
====》因为有这些缺点,故有人就提出了线索二叉树。
====》我们之前提过:n个结点的二叉树,有n+1个空链域!可用来记录前驱、后继的信息。即:左孩子指向前驱,右孩子指向后继,没有前驱、后继的话就指向NULL.
====》这种没有保存左、右孩子,而保存的是指向前驱、后继的指针称为“线索”。这样找前驱和后继就非常方便(从普通二叉树变成线索二叉树)。
====》如何区分线索二叉树中左右孩子指针是指向孩子还是指向线索的呢?我们需要在线索二叉树结点上添加两个标志变量。即:
typedef struct ThreadNode{
ElemType data;
struct ThreadNode *lchild*rchild;
int ltag,rtag; //左、右线索标志
}ThreadNode,*ThreadTree;
====》tag == 0,表示指针指向孩子
tag == 1,表示指针是“线索”
====》二叉树链式存储的术语:二叉链表
线索二叉树链式存储的术语:线索链表
1.9.2 先序线索二叉树
原理类似。按照“先序遍历”进行线索化。
1.9.3 后序线索二叉树
原理类似。按照“后序遍历”进行线索化。
1.9.4 二叉树线索化
用土方法找到中序前驱(代码实现)
void InOrder(BiTree T){ //最好改一个函数名,如:findPre
if(T!=NULL){
InOrder(T -> lchild); //递归遍历左子树
visit(T); //访问根结点
InOrder(T -> rchild); //递归遍历右子树
}
}
//访问结点q
void visit (BiTNode *q){
if(q == p) //当前访问结点刚好是结点p
final = pre; //找到p的前驱
else
pre = q; //pre指向当前访问的结点
}
//辅助全局变量,用来查找结点p的前驱
BiTNode *p; //p指向目标结点
BiTNode *pre = NULL; //指向当前访问结点的前驱
BiTNode *final = NULL; //用于记录最终结果
中序线索化(代码完整版)
typedef struct ThreadNode{
ElemType data;
struct ThreadNode *lchild*rchild;
int ltag,rtag; //左、右线索标志
}ThreadNode,*ThreadTree;
//中序遍历二叉树,一边遍历一边线索化
void InThread(ThreadTree T){ //其实就是中序遍历,只是名字改了一下。
if(T!=NULL){
InTread(T ->lchild); //中序遍历左子树
visit(T); //访问根节点
InTread(T ->rchild); //中序遍历右子树
}
}
//全局变量pre ,指向当前访问结点的前驱
ThreadNode *pre=NULL;
//线索化,是在visit函数中实现
void visit(ThreadNode *p){
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.这是在其他函数中实现的。
//中序线索化二叉树T
void CreateInThread(ThreadTree T){
pre = NULL; //pre初始化为NULL
if(T !=NULL){ //非空二叉树才能线索化
InThread(T); //中序线索化二叉树
if(pre -> rchild == NULL)
pre ->rtag =1; //处理遍历的最后一个结点
}
}
====》中序线索化(王道版)
void InThread(ThreadTree p,ThreadTree &pre){
if(p!=NULL){
InThread(p->lchild,pre) //递归,线索化左子树
if(p -> lchild == NULL){ //左子树为空,建立前驱线索
p -> lchild = pre;
p -> ltag = 1;
}
if(pre !=NULL && pre->rchild == NULL){
pre ->rchild = q; //建立前驱结点的后继线索
pre ->rtag =1;
}
pre =p; //标记当前结点成为刚刚访问过的结点
InThread(p->rchild,pre) //递归,线索化右子树
}
}
//中序线索化二叉树T
void CreateInThread(ThreadTree T){
ThreadTree pre = NULL;
if(T !=NULL){ //非空二叉树才能线索化
InThread(T,pre); //中序线索化二叉树
pre -> rchild == NULL; //处理遍历的最后一个结点
pre ->rtag =1; //这边与刚刚有点不同,没有进行判断而是直接为NULL,
//因为中序遍历的最后一个结点右孩子指针必为空。
}
}
====》可以发现这边的pre为局部变量,注意一定不忘记&(这边添加了&使得该变量的作用效果与全局变量相同)。
先序线索化
void PreThread(ThreadTree T){ //其实就是中序遍历,只是名字改了一下。
if(T!=NULL){
visit(T); //访问根节点
PreTread(T ->lchild); //中序遍历左子树
PreTread(T ->rchild); //中序遍历右子树
}
}
====》其他代码与中序线索化相同,就是把中序遍历改为先序遍历即可,但这样若简单的这么处理的话,我们会出现一个错误,就是执行几步后会造成不断的循环,因为先访问根节点后把左孩子线索化了,但是接下来还会继续中序遍历左子树,结果就成了一个死循环了,所有要解决这个问题,先序线索化的先序遍历程序应该改为:
void PreThread(ThreadTree T){ //其实就是中序遍历,只是名字改了一下。
if(T!=NULL){
visit(T); //访问根节点
if(T -> ltag == 0) //lchild不是前驱线索
PreTread(T ->lchild); //中序遍历左子树
PreTread(T ->rchild); //中序遍历右子树
}
}
后序线索化
void PreThread(ThreadTree T){ //其实就是中序遍历,只是名字改了一下。
if(T!=NULL){
PreTread(T ->lchild); //中序遍历左子树
PreTread(T ->rchild); //中序遍历右子树
visit(T); //访问根节点
}
}
====》不会出现先序线索化的循环情况,故就是普通的后序遍历。
====》要注意都要对最后一个结点进行处理。
====》先序线索化中会有循环的问题,要特别处理。
1.9.5 线索二叉树找前驱/后继
中序线索二叉树
中序线索二叉树找中序后继
在中序线索二叉树中找到指定结点*p
的后继结点next
,
①若p->rtag == 1
(表示右指针为线索),则next = p - >rchild
.
②若p->rtag == 0
.(表示右指针为孩子),则需要讨论,
情况一:若该右孩子为叶子结点,即就是后继结点。
情况二:若该右孩子为分支结点,则分支结点的左孩子为后继结点(若还是分支结点则继续往下找是否有左孩子)即:next =p的右子树中最左下结点。
====》①若左、右指针为“线索”,则后继结点就是右孩子(前驱结点就是左孩子)。
====》②中序遍历:后继结点为最先被访问的结点。
代码实现
//找到以P为根的子树中,第一个被中序遍历的结点
ThreadNode *Firstnode(ThreadNode *p){
//循环找到最左下结点(不一定是叶子结点)
while(p->ltag) p=p->lchild; //此为上面的情况二
return p;
}
//在中序线索二叉树中找到结点p的后继结点
ThreadNode *Nextnode(ThreadNode *p){
//右子树中最左下结点
if(p->rtag == 0) return Firstnode(p -> rchild);//这边p先指向右孩子,为上面的情况一
else return p->rchild; //rtag==1直接返回后继线索
}
//对中序线索二叉树进行中序遍历(利用线索实现的非递归算法),空间复杂度仅为O(1)
void Inorder(ThreadNode *T){
for(ThreadNode *p=Firstnode(T);p!=NULL;p=Nextnode(p))
visit(p);
}
中序线索二叉树找中序前驱
在中序线索二叉树中找到指定结点*p
的前驱结点pre
,
①若p->ltag == 1
(表示左指针为线索),则pre = p - >lchild
.
②若p->ltag == 0
.(表示左指针为孩子),则需要讨论,
情况一:若该左孩子为叶子结点,即就是前驱结点。
情况二:若该右孩子为分支结点,则分支结点的右孩子为前驱结点(若还是分支结点则继续往下找是否有右孩子)即:pre =p的左子树中最右下结点。
====》①若左、右指针为“线索”,则后继结点就是右孩子(前驱结点就是左孩子)。
====》②中序遍历:前驱结点为最后被访问的结点。
代码实现
//找到以P为根的子树中,最后一个被中序遍历的结点
ThreadNode *Lastnode(ThreadNode *p){
//循环找到最右下结点(不一定是叶子结点)
while(p->rtag) p=p->rchild; //此为上面的情况二
return p;
}
//在中序线索二叉树中找到结点p的前驱结点
ThreadNode *Prenode(ThreadNode *p){
//右子树中最左下结点
if(p->ltag == 0) return Firstnode(p -> lchild);//这边p先指向左孩子,为上面的情况一
else return p->lchild; //rtag==1直接返回后继线索
}
//对中序线索二叉树进行逆向中序遍历,(空间复杂度仅为O(1)
void Inorder(ThreadNode *T){
for(ThreadNode *p=Lastnode(T);p!=NULL;p=Prenode(p))
visit(p);
}
先序线索二叉树
先序线索二叉树找先序后继
在先序线索二叉树中找到指定结点*p
的后继结点next
,
①若p->rtag == 1
(表示右指针为线索),则next = p - >rchild
.
②若p->rtag == 0
.(表示右指针为孩子),则需要讨论,
情况一:若有左孩子,即:先序后继为左孩子。
情况二:若无左孩子,但必有右孩子(因为p->rtag==0),即:先序后继为右孩子。
先序线索二叉树找先序前驱(不好实现)
在先序线索二叉树中找到指定结点*p
的前驱结点pre
,
①若p->ltag == 1
(表示左指针为线索),则pre= p - >lchild
.
②若p->ltag == 0
.(表示左指针为孩子),则需要讨论,
情况一:必有左孩子(因为p->ltag==0),但根据先序遍历(根左右),可知左右孩子只可能为后继,不可能为前驱。故找不到先序二叉树的前驱(此时只有两个左右指针,即二叉链表),只能使用从头开始遍历的办法才可以.
情况二:若为三叉链表,即:能找到p的父结点,分为以下四种情况:
<1>p为左孩子,父节点为前驱。
<2>p为右孩子且其左兄弟为空,父节点为前驱。
<3>p为右孩子且其左兄弟非空,左兄弟中最后一个被先序遍历的结点(左兄弟子树中的最后一个子树【可能是左/右】)为前驱。
<4>p为根节点,则p没有先序前驱
后序线索二叉树
后序线索二叉树找后序前驱
在中序线索二叉树中找到指定结点*p
的前驱结点pre
,
①若p->ltag == 1
(表示左指针为线索),则pre = p - >lchild
.
②若p->ltag == 0
.(表示左指针为孩子),则需要讨论,
情况一:若有右孩子,则右孩子就是前驱结点。
情况二:若无右孩子,必有左孩子(因为p->ltag == 0
),即:左孩子就是前驱结点。
后序线索二叉树找后序后继(不好实现)
在后序线索二叉树中找到指定结点*p
的后继结点next
,
①若p->rtag == 1
(表示右指针为线索),则pre = p - >lchild
.
②若p->rtag == 0
.(表示右指针为孩子),则需要讨论,
左右子树中的结点只可能是根的前驱,不可能是后继。只能从头开始遍历才能找到。
或者通过三叉链表实现。
2.0 树
2.0.1树的逻辑结构
看1.1.1
2.0.2 双亲表示法(顺序存储)
双亲表示法:每个结点保存指向父节点的指针。
====》该方法为顺序存储,使用数组实现,数组下标为0为根结点,且固定存储-1表示无父结点。
代码实现
#define MAX_TREE_SIZE 100 /树中最多结点数
typedef struct{ /树的结点定义
ElemType data; /数据元素
int parent; /双亲位置域
}PTNode;
typedef struct{ /树的类型定义
PTNode nodes(MAX_TREE_SIZE); /双亲表示
int n; /结点数
}PTree;
基本操作
新增操作
只需记录结点的数据+双亲指针(数据的双亲的关系)即可。
====》虽然看上去好像是按层序排列的,但是在新增数据元素时,无需按逻辑上的次序存储。
删除操作
若删除的为叶子结点:
方案一:将要删除的数据元素的双亲指针设置为-1;(不好,因为若为空数据,在查询时候需遍历空数据,浪费时间)
方案二:将最后一个数据元素移到要删除的数据元素的位置。
====》都要记得将结点数n减一。
若删除的为分支结点:
需要将以该分支结点为根节点以下的所有结点全部删除,这就需要用到查询操作。
查询操作
优点:查找指定结点的双亲非常方便
缺点:查找指定结点的孩子只能从头开始遍历。
2.0.3 孩子表示法(顺序+链式存储)
代码实现
struct CTNode{
int child; /孩子结点在数组中的位置
struct CTNode *next; /下一个孩子
};
typedef struct{
ElemType data;
struct CTNode *firstchild; /第一个孩子
}CTBox;
typedef struct{
CTBox nodes[MAX_TREE_SIZE];
int n,r; /结点数和根的位置
}CTree;
基本操作
找孩子方便,找双亲麻烦。
2.0.4 孩子兄弟表示法(链式存储)【树与二叉树的转换】
代码实现
typedef struct CSNode{
ElemType data; /数据域
struct CSNode *firstchild,*nextsibling; /第一个孩子和右兄弟指针
}CSNode,*CSTree;
====》可以发现这个实际上跟我们之前定义的二叉链表是一模一样的。
====》firstchild看作左指针,指向孩子。nextsibling为孩子的右指针指向它的右兄弟。
====》很明显,经过这样的处理之后,树就变成二叉树。
====》也要学会二叉树转化为树。
2.0.5森林与二叉树的转换(本质:用二叉链表存储森林)
森林是m(m≥0)棵互不相交的树的集合。
====》就是先把树转换为二叉树,然后把每棵树的根节点看成彼此的兄弟连接。
====》二叉树转为森林:
2.1树的遍历操作
2.1.1树的先根遍历【也可称为深度优先遍历】
若树非空,先访问根节点,再依次对每棵子树进行先根遍历。
代码实现
void PreOrder(TreeNode *R){
if(R!=NULL){
visit(R); /访问根节点
while(R还有一下一个子树T)
PreOrder(T); /先根遍历下一棵子树
}
}
====》这边使用的是伪代码,若要实现还需知道使用的是哪种结构存储的这个代码。
====》树的先根遍历与这棵树相应二叉树的先序序列相同
2.1.2 树的后根遍历【也可称为深度优先遍历】
若树非空,先依次对每棵子树进行后根遍历,最后在访问根节点。
代码实现
void PostOrder(TreeNode *R){
if(R!=NULL){
while(R还有一下一个子树T)
PostOrder(T); /先根遍历下一棵子树
visit(R); /访问根节点
}
}
====》这边使用的是伪代码,若要实现还需知道使用的是哪种结构存储的这个代码。
====》树的后根遍历与这棵树相应二叉树的中序序列相同
2.1.3树的层次遍历(用队列实现)【也可称为广度优先遍历】
①若树非空,则根节点入队。
②若队列非空,队头元素出队并访问,同时将该元素的孩子依次入队。
③重复②直到队列为空。
代码实现
void PostOrder(TreeNode *R){
if(R!=NULL){
while(R还有一下一个子树T)
PostOrder(T); //先根遍历下一棵子树
visit(R); //访问根节点
}
}
====》这边使用的是伪代码,若要实现还需知道使用的是哪种结构存储的这个代码。
2.2 森林的遍历操作
森林是m(m≥0)棵互不相交的树的集合。每棵树去掉根节点后,其各个子树又组成森林。
2.2.1先序遍历森林
若森林非空,则按如下规则进行遍历:
①访问森林中第一棵树的根节点。
②先序遍历第一棵树中根节点的子树森林。
③先序遍历除去第一棵树之后剩余的树构成的森林。
====》效果等同于依次对各个树进行先根遍历
====》效果等同于依次对二叉树进行先序遍历
2.2.2中序遍历森林
若森林非空,则按如下规则进行遍历:
①中序遍历森林中第一棵树的根节点的子树森林。
②访问第一棵树的根节点。
③中序遍历除去第一棵树之后剩余的树构成的森林。
====》效果等同于依次对各个树进行后根遍历
====》效果等同于依次对二叉树进行中序遍历
====》行之间是等价的关系。
2.3 二叉排序树(BST)
2.3.1 二叉排序树的定义
二叉排序树,又称二叉查找树(BST, Binary Search Tree)
一棵二叉树或者是空二叉树,或者是具有如下性质的二叉树:
左子树上所有结点的关键字均小于根结点的关键字;
右子树上所有结点的关键字均大于根结点的关键字;
左子树和右子树又各是一棵二叉排序树。
====》左子树结点值 < 根结点值 < 右子树结点值
====》进行中序遍历(左根右,刚好与排序树对应),可以得到一个递增的有序序列
====》二叉排序树可用于元素的有序组织、搜索。
typedef struct BSTNode{ / 二叉排序树结点
int key;
struct BSTNode *lchild,*rchild;
}BSTNode,*BSTree;
2.3.2 二叉排序树的查找
若树非空,目标值与根结点的值比较;
若相等,则查找成功;
若小于根结点,则在左子树上查找,否则在右子树上查找。
查找成功,返回结点指针;
查找失败,返回NULL;
/二叉排序树中查找值为key的结点,T为根结点的指针
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;
}
====》 最坏空间复杂度O(1)
/二叉排序树中查找值为key的结点(递归实现)
BSTNode *BST_Search(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
return BSTSearch(T->rchild,key); /在右子树中找
}
====》 最坏空间复杂度O(h)
2.3.3 二叉排序树的插入
若原二叉排序树为空,则直接插入结点;
若关键字k小于根结点值,则插入到左子树.
若关键字k大于根结点值,则插入到右子树.
/在二叉排序树插入关键字为k的新结点(递归实现)
int BST_Insert(BSTree &T,int k){ /插入操作为引用类型
if(T = NULL){ /原树为空,新插入的结点为根结点
T=(BSTree)malloc(sizeof(BSTNode));
T->key = k;
T->lchild=T->rchild = NULL;
return 1; /返回1,插入成功
}
else if (k == T->key) /树中存在相同关键字的结点,插入失败
return 0;
else if (k < T->key) /插入到T的左子树
return BST_Insert(T->lchild,k);
else /插入到T的右子树
return BST_Insert(T->rchild,k);
}
====》 最坏空间复杂度O(h)
====》也可以用非递归的方法实现
2.3.4 二叉排序树的构造
其实就是不断插入新结点的过程。
按照序列str=[50,60,66,25,23,30]
/按照str[]中关键字序列建立二叉排序树
void Creat_BST(BSTree &T,int str[],int n){
T=NULL; /初始时T为空树
int i=0;
while(i < n){ /依次将每个关键字插入到二叉排序树中
BST_Insert(T,str[i]);
i++;
}
}
====》不同关键字序列可能得到同款二叉排序树
====》也可能得到不同款的二叉排序树。
2.3.5 二叉排序树的删除
二叉排序树的特性:左子树结点值 < 根结点值 < 右子树结点值
先搜索找到目标结点:
①若被删除结点z是叶结点,则指直接删除,不会破坏二叉排序树的性质。
②若被删除结点z只有一棵左子树或右子树,则让z的子树成为z父结点的子树,替代z的位置。
③若被删除结点z有左、右两棵子树,则令z的直接后继(或直接前驱)替代z,然后从二叉排序树中删去这个直接后继(或直接前驱),这样就转换成了①或②情况。
====》方法一:z的后继:z的右子树中最左下结点(该结点一定没有左子树)
====》从当前删除结点的右子树当中找到找到最小的值(直接后继)来替代当前结点。这边所说的这个结点其实就是按照中序遍历第一次访问的结点(对二叉排序树进行中序遍历会得到一个递增的有序序列,故对右子树进行中序遍历就会找到当前结点的直接后继)。
====》方法二:z的前驱:z的左子树中最右下结点(该结点一定没有右子树)
====》从当前删除结点的左子树当中找到找到最大的值(直接前驱)来替代当前结点。这边所说的这个结点其实就是按照中序遍历最后一次访问的结点(对二叉排序树进行中序遍历会得到一个递增的有序序列,故对左子树进行中序遍历就会找到当前结点的直接前驱)。
====》这边的方法一、二其实都是根据二叉排序树的特性,利用左、右子树结点值来替代根结点。
2.3.6 查找效率分析
查找长度——在查找运算中,需要对比关键字的次数称为查找长度,反映了查找操作时间复杂度。
====》查找操作实践复杂度
最好情况:n个结点的二叉树最小高度为[
l
o
g
2
n
log_2n
log2n]+1.平均查找长度=O(
l
o
g
2
n
log_2n
log2n)
最坏情况:每个结点只有一个分支,树高h=结点数n。平均查找长度=O(
n
n
n)
查找成功的平均查找长度ASL(Average Search Length)
ASL=(第一层 ×第一层个数+第二层 ×第二层个数+…+第n层 ×第n层个数) / 所有层个数的总和
若树高h,找到最下层的一个结点需要对比h次(对比的次数肯定不会超过树的高度)
====》二叉排序树的查找效率很大程度上取决于二叉树的高度
====》故最好二叉树为平衡二叉树,查找效率最高。查找效率为O(
l
o
g
2
n
log_2n
log2n)
查找失败的平均查找长度ASL(Average Search Length)
ASL=(叶子结点的查找长度*叶子结点个数[注意叶子结点不一定在同一层]) / 叶子结点个数的总和
2.4 平衡二叉树(AVL)
平衡二叉树(Balanced Binary Tree),简称:平衡树(AVL树/发明者名字的首字母)树上任一结点的左子树和右子树的深度之差不超过1.
结点的平衡因子 = 左子树高 - 右子树高
====》故若为平衡二叉树的话,结点的平衡因子的值只可能是-1、0或1。
====》即只要有任一结点的平衡因子绝对值大于1,就不是平衡二叉树。
/平衡二叉树结点
typedef struct AVLNode{
int key; /数据域
int balance; /平衡因子
struct AVLNode *lchild,*rchild;
}AVLNode,*AVLTree;
2.4.1 平衡二叉树的插入
若在平衡二叉树中插入新结点,则查找路径上的所有结点都有可能受到影响。
====》从插入新结点处往上找,找到第一个不平衡结点,调整以该结点为根的子树。
====》而以该结点为根的子树被称为“最小不平衡子树”,即:每次调整的对象都是“最小不平衡子树”
====》在插入操作中,只要将最小不平衡子树调整平衡,即可恢复平衡二叉树。
2.4.2 调整最小不平衡子树
目标:
1.恢复平衡
2.保持二叉排序树的特性:
====》下面假设的子树高度全部都为H,为什么呢?
====》若其他高度不为H则可能导致插入新结点后:①A为不是最小平衡子树②不存在新结点③还没插入就已经是存在不平衡子树等情况。(代入H+1或H-1 一试便知)
LL :左孩子(L)的左子树(L)
1)LL平衡旋转(右单旋转)。由于在结点A的左孩子(L)的左子树(L)上插入了新结点,A的平衡因子由1增至2,导致A为根的子树失去平衡,需要一次向右的旋转操作。
==== 》 将A的左孩子B向右上旋转代替A成为根节点,将A结点向右下旋转成为B的右子树的根结点,而B的原右子树则作为A结点的左子树。
代码思路:
实现f向右下旋转,p向右上旋转:
其中f是爹,p是左孩子,gf为f他爹
①f->lchid=p->rchild
②p->rchild = f;
③gf->lchild/rchild = p;
====》根据二叉排序树的特性:BL < B < BR < A < AR
RR :右孩子(R)的右子树(R)
2)RR平衡旋转(左单旋转)。由于在结点A的右孩子(R)的右子树(R)上插入了新结点,A的平衡因子由-1减至-2,导致A为根的子树失去平衡,需要一次向左的旋转操作。
==== 》 将A的右孩子B向左上旋转代替A成为根节点,将A结点向左下旋转成为B的左子树的根结点,而B的原左子树则作为A结点的右子树。
代码思路:
实现f向左下旋转,p向左上旋转:
其中f是爹,p是左孩子,gf为f他爹
①f->rchid=p->lchild
②p->lchild = f;
③gf->lchild/rchild = p;
LR :左孩子(L)的右子树(R)
3)LR平衡旋转(先左后右双旋转)。由于在结点A的左孩子(L)的右子树(R)上插入了新结点,A的平衡因子由1增至2,导致以A为根的子树失去平衡,需要进行两次旋转操作,先左旋转后右旋转。
==== 》 先将A的左孩子B的右子树的根结点C向左上旋转提升到B结点的位置然后再把该C结点向右上旋转提升到A结点的位置
RL :右孩子(R)的左子树(L)
4)RL平衡旋转(先右后左双旋转)。由于在结点A的右孩子(R)的左子树(L)上插入了新结点,A的平衡因子由-1减至-2,导致以A为根的子树失去平衡,需要进行两次旋转操作,先右旋转后左旋转。
==== 》 先将A的右孩子B的左子树的根结点C向右上旋转提升到B结点的位置然后再把该C结点向左上旋转提升到A结点的位置
====》(总结)通过观察,我们可得到以下规律:
①只有左孩子才能右上旋。
②只有右孩子才能左上旋。
③经过旋转后,孩子变成爹,爹变成孩子。
2.4.3 查找效率分析
若树高为h,则最坏情况下,查找一个关键字最多需要对比h次,即查找操作的时间复杂度不可能超过O(h).
假设以
n
h
n_h
nh表示深度为h的平衡树中含有的最少结点数。则有
n
0
n_0
n0=0,
n
1
n_1
n1=1,
n
2
n_2
n2=2 ,并且有:
n
h
n_h
nh=
n
h
−
1
n_{h-1}
nh−1+
n
h
−
2
n_{h-2}
nh−2+1
====》最少结点数
n
h
n_h
nh:
n
h
−
1
n_{h-1}
nh−1(左子树,并要保证结点数量最少)+
n
h
−
2
n_{h-2}
nh−2(右子树,并要保证结点数量最少)+根结点。
====》故,可以计算出
n
3
n_3
n3=
n
2
n_2
n2+
n
1
n_1
n1+1=4,
n
4
n_4
n4=7,
n
5
n_5
n5=12等等。
====》故若知一个结点数最少为9,则树高最高只能为4,因为
n
5
n_5
n5的最小结点数为12。
====》改公式也证明了若结点数为n的话,树的最大高度应该为
l
o
g
2
n
log_{2}n
log2n
====》平衡二叉树的平均查找长度为O(
l
o
g
2
n
log_2n
log2n)
2.5 哈弗曼树
2.5.1 带权的路径长度
结点的权:有某种现实含义的数据(如:表示结点的重要性等)
带权的路径长度:从树的根到该结点的路径长度(经过的边数)与该结点上权值的乘积。
树的带权的路径长度:树中所有叶结点的带权路径长度之和(WPL,Weighted Path Length)
WPL=
∑
i
=
1
n
w
i
l
i
\displaystyle\sum_{i=1}^nw_il_i
i=1∑nwili
====》
w
i
w_i
wi是第i个叶结点所带的权值,
l
i
l_i
li是该叶结点到根结点的路径长度
====》在含有n个带权叶结点的二叉树中,其中带权路径长度(WPL)最小的二叉树称为:哈弗曼树,也称最优二叉树。
2.5.2 哈夫曼树的构造
给定n个权值分别为
w
1
w_1
w1,
w
2
w_2
w2,…,
w
n
w_n
wn的的结点,构造哈弗曼树的算法描述如下:
1)将这n个结点分别作为n棵仅含一个结点的二叉树,构成森林F.
2)构造一个新结点,从F中选取两棵根结点权值最小的树作为新结点的左、右子树,并且将新结点的权值置为左、右子树上根结点的权值之和。
3)从F中删除刚才选出的两棵树,同时将新得到的树加入F中。
4)重复步骤2)和3),直至F中只剩下一棵树为止。
====》①每个初始结点最终都成为叶结点,且权值越小的结点到根结点的路径长度越大。
====》②哈夫曼树的结点总数为2n-1
====》③哈夫曼树种不存在度为1的结点
====》④哈弗曼树并不唯一,但WPL必然相同且为最优。
2.5.3 哈夫曼编码
固定长度编码——每个字符用相等长度的二进制位表示。
可变长度编码——允许对不同字符用不等长的二进制位表示。
====》首先,要认识到树的带权路径长度(WPL)有什么作用?
它能描述出你当前的树的存储空间大小。举个例子来说明:
====》若两个学渣想要在考试上互相传递答案,那么他们需要信号来传递答案,假设他们以0(咳嗽)和1(哈欠)来传递信息。以固定长度编码:A——00,B——01,C——10,D——11.
考试选择有100题,其中渣一同学做的答案:80题选C,10题选A,8题选B,2题选D。
====》那么传递100题需要打信息0和1,要打100次,即咳嗽和呵欠200次(802+102+82+22=200),我们可以将这种表示方法映射成树来表示。
====》可直观的观察出,刚刚算的二进制长度,实际上就是树的带权路径长度。
====》那么,能不能找到比200的二进制长度更小的二进制传递方法。那就使用我们的哈夫曼树,将ABCD作为叶子结点,进行构造哈夫曼树。
====》这就是我们构造出的哈夫曼树,得到新的编码:C——0,A——10,B——111,D——110.
====》这种新编码方式称为:可变长度编码,这棵树的带权路径长度(WPL):801+102+23+83=130,是不是可以少咳嗽很多?
====》有人是不是想问,为啥不能把A改成1,这样不就更小了么?
====》这样会造成A不是叶子结点,最终会导致在解码的时候分辨不清楚,即:解码会出现错误。
但是若用之前的方法(都是叶子结点)解码就不会出错,这种编码就叫做前缀编码。
====》前缀编码:若没有一个编码是另一个编码的前缀,则称这样的编码为前缀编码,无歧义。
====》由哈夫曼树得到哈夫曼编码——字符集中的每一个字符作为一个叶子结点,各个字符出现的频度作为结点的权值,根据之前介绍的方法构造哈夫曼树。
====》哈夫曼树不唯一,因此哈夫曼编码不唯一。
====》即哈夫曼编码可以用于压缩。