本篇技术博文摘要 🌟
- 本文系统梳理了树形数据结构及其衍生技术的核心内容,从基础概念到高阶算法完整覆盖:以树与二叉树为起点,详解存储结构(顺序/链式)与遍历算法(递归/非递归),结合代码实现(如先序递归遍历、层序遍历队列实现);
- 深入线索二叉树的三种线索化策略及其前驱后继定位方法,配套各类型线索化代码;对比树与森林的存储结构(双亲表示法)及转换规则,解析遍历方式的异同;重点剖析二叉排序树的构造与平衡化(AVL树的4种旋转策略),提供插入/删除代码及效率分析;延伸至应用型结构哈夫曼树的编码优化与并查集的路径压缩算法,形成从理论(如带权路径长度)到工程实践(数组初始化并查集)的全链路知识图谱,为算法面试与工程开发提供结构化学习框架。
数据结构与算法全程动画演示:
动画可视化数据结构和算法之递归栈调用(新手多看几遍)
动画可视化——递归树之斐波那契数列
BFS[广度优先搜索树遍历]
DFS-深度优先遍历
二叉排序树-插入、删除、查找
AVL-插入、删、查;含RR、LL、RL、LR四种情况
联合算法 - 查找不相交集(UFDS)
引言 📘
- 在这个变幻莫测、快速发展的技术时代,与时俱进是每个IT工程师的必修课。
- 我是盛透侧视攻城狮,一名什么都会一丢丢的网络安全工程师,也是众多技术社区的活跃成员以及多家大厂官方认可人员,希望能够与各位在此共同成长。
上节回顾
目录
树
树的定义和基本术语
空树:
- 结点数为0的树
非空树的特性:
- 有且仅有一个根结点
- 没有后继的结点称为“叶子结点”(或终端结点)
- 有后继的结点称为“分支结点”(或非终端结点)
- 除了根结点外,任何一个结点都有且仅有一个前驱
- 每个结点可以有0个或多个后继
树的定义:
- 树是n(n≥0)个结点的有限集合,n = 0时,称为空树,这是一种特殊情况。
- 在任意一棵非空树中应满足:
- 有且仅有一个特定的称为根的结点。
- 当n > 1时,其余结点可分为m(m > 0)个互不相交的有限集合T1, T2,…, Tm,其中每个集合本身又是一棵树,并且称为根结点的子树。
结点之间的关系描述:
- 什么是祖先结点?比自己深度低的结点
- 什么是子孙结点?比自己深度高的结点
- 什么是双亲结点(父结点)?自己的根结点
- 什么是孩子结点?自己作为根结点的儿子
- 什么是兄弟结点?与自己拥有相同父结点结点
- 什么是堂兄弟结点?与自己同一层的结点
- 什么是两个结点之间的路径?能从上往下前往的两个结点被称为有路径
- 什么是路径长度?经过几条边
结点、树的属性描述:
- 结点的层次(深度):从上往下数,默认从1开始
- 结点的高度:从下往上数
- 树的高度(深度):总共多少层
- 结点的度:有几个孩子(分支)
- 树的度:各结点的度的最大值
有序数和无序树:
有序树:逻辑上看,树中结点的各子树从左至右是有次序的,不能互换
- 无序树:逻辑上看,树中结点的各子树从左至右是无次序的,可以互换
- 是否是什么,具体看你用树存什么,是否需要用结点的左右位置反映某些逻辑关系
树和森林:
- 森林是m(m≥0)棵互不相交的树的集合
- 考点:树和森林的相互转换
树的性质
- 树的总结点数=树的总度数+1
- 度数为m的树和m叉树的区别
-
(0):度为m的树第i 层至多有m^i-1 个结点(i≥1)
-
(1):m叉树第i 层至多有m^i-1 个结点(i≥1)
- (2):高度为h的m叉树至多有((m^h)-1)/m-1个结点
- 推理过程:
- 与结论1完全相同,因为“度为m的树”与“m叉树”在此处定义一致,均为每个结点最多有𝑚m个子结点的树。
-
(3):高度为h的m叉树至少有h 个结点
-
(4):高度为h、度为m的树至少有h+m-1 个结点
-
(5):具有n个结点的m叉树的最小高度为logm(n(m - 1) + 1)
-
(6):高度最小的情况:所有结点都有m个孩子
二叉树的定义和基本术语
定义:
- 二叉树是n(n≥0)个结点的有限集合
- 或者为空二叉树,即n = 0。
- 或者由一个根结点和两个互不相交的被称为根的左子树和右子树组成。左子树和右子树又分别是一棵二叉树。
- 特点:
- 每个结点至多只有两棵子树
- 左右子树不能颠倒(二叉树是有序树)
二叉树的五种状态:
- 空二叉树
- 只有左子树
- 只有右子树
- 只有根结点
- 左右子树都有
几个特殊的二叉树:
- 满二叉树:一棵高度为h,且含有2^h - 1个结点的二叉树
- 特点:
- 只有最后一层有叶子结点
- 不存在度为1 的结点
- 按层序从1 开始编号,结点i 的左孩子为2i,右孩子为2i+1;结点i 的父结点为𝑖/2 (如果有的话)
- 完全二叉树:当且仅当其每个结点都与高度为h的满二叉树中编号为1~n的结点一一对应时,称为完全二叉树
- 特点:
- 只有最后两层可能有叶子结点
- 最多只有一个度为1的结点
- 按层序从1 开始编号,结点i 的左孩子为2i,右孩子为2i+1;结点i 的父结点为𝑖/2 (如果有的话)
- i≤ n/2 为分支结点, i> n/2 为叶子结点
- 如果某结点只有一个孩子,那么一定是左孩子
- 是满二叉树一定是完全二叉树,是完全二叉树不一定是满二叉树
- 二叉排序树:一棵二叉树或者是空二叉树,或者是具有如下性质的二叉树:
- 左子树上所有结点的关键字均小于根结点的关键字
- 右子树上所有结点的关键字均大于根结点的关键字
- 左子树和右子树又各是一棵二叉排序树
- 二叉排序树可用于元素的排序、搜索
- 平衡二叉树:树上任一结点的左子树和右子树的深度之差不超过1。
- 平衡二叉树能有更高的搜索效率
- 又宽又胖的树
各种二叉树的性质
二叉树的性质:
- 设非空二叉树中度为0、1和2的结点个数分别为n0、n1和n2,则n0 = n2 + 1(叶子结点比二分支结点多一个)
- 二叉树第i 层至多有2^(i-1)个结点(i≥1)
- 高度为h的2叉树至多有(2^h)-1个结点 = 满二叉树
完全二叉树的性质:
- 具有n个(n>0)结点的完全二叉树的高度h为log2(n + 1) 或[log2n] + 1
- 对于完全二叉树,可以由的结点数n 推出度为0、1和2的结点个数为n0、n1和n2(突破点:完全二叉树最多只会有一个度为1的结点)
二叉树的储存结构
顺序存储:
顺序存储结构初始化代码:
#include <iostream>
#define MaxSize 100
// 假设 ElemType 已经定义
typedef int ElemType;
// 定义二叉树节点结构体
struct TreeNode {
ElemType value; // 结点中的数据元素
bool isEmpty; // 结点是否为空
};
int main() {
// 声明一个大小为 MaxSize 的数组
TreeNode t[MaxSize];
// 初始化所有结点为空
for (int i = 0; i < MaxSize; i++) {
t[i].isEmpty = true; // 设置每个结点为空
}
// 可以在此后做一些测试或操作,确保程序正常运行
return 0;
}
常用的基本操作:
i的左孩子:2i
i的右孩子:2i+1
i的父结点:⌊i/2⌋
i所在的层次:⌈log2(n+1)⌉:(向上取整)或者⌊log2n⌋+1(向下取整)
若完全二叉树中共有n个结点,则:
判断i是否有左孩子:2i ≤ n ?
判断i是否有右孩子:2i+1 ≤ n ?
判断i是否是叶子/分支结点:i > ⌊n/2⌋ ?
储存非完全二叉树:
二叉树的链式存储:
定义和初始化:
这样的方法可以很简单的找到p结点的左右孩子,但是只能通过从根开始遍历查找找到结点p的父结点
可以通过多定一个父结点的指针来方便的查找父结点(三叉链表)
n个结点的二叉链表共有n+1 个空链域(可以用来构建线索二叉树)
链式存储初始化代码:
#include <stdlib.h> // 包含动态内存分配函数(如malloc)的头文件
// 定义树节点中存储的数据类型
struct ElemType{
int value; // 数据成员,一个整型值
};
// 定义二叉树节点结构
typedef struct BiTNode{
ElemType data; // 节点存储的数据
struct BiTNode *lchild, *rchild; // 指向左子节点和右子节点的指针
} BiTNode, *BiTree; // BiTNode是节点类型,BiTree是指向节点的指针类型
int main() {
// 定义一棵空树,root指针初始化为NULL
BiTree root = NULL;
// 插入根节点
root = (BiTree) malloc(sizeof(BiTNode)); // 动态分配内存来创建根节点
root->data = {1}; // 给根节点的数据赋值为1
root->lchild = NULL; // 初始化根节点的左子节点为NULL
root->rchild = NULL; // 初始化根节点的右子节点为NULL
// 插入新结点
BiTNode *p = (BiTNode *) malloc(sizeof(BiTNode)); // 动态分配内存创建新节点
p->data = {2}; // 给新节点的数据赋值为2
p->lchild = NULL; // 初始化新节点的左子节点为NULL
p->rchild = NULL; // 初始化新节点的右子节点为NULL
root->lchild = p; // 将新节点作为根节点的左孩子
return 0;
}
二叉树的先中后序遍历
遍历
遍历:按照某种次序把所有结点都访问一遍
层序遍历:基于树的层次特性确定的次序规则
先/中/后序遍历:基于树的递归特性确定的次序规则
二叉树的遍历:
二叉树的递归特性:
要么是个空二叉树
要么就是由“根结点+左子树+右子树”组成的二叉树
先序遍历:根左右(NLR)
中序遍历:左根右(LNR)
后序遍历:左右根(LRN)
递归动画演示{小白可以多看几遍}
动画可视化数据结构和算法之递归栈调用(新手多看几遍)
动画可视化——递归树之斐波那契数列
先序递归遍历(代码):
// 定义二叉树节点结构体
typedef struct BiTNode{
ElemType data; // 数据域,用于存储节点的数据,ElemType是之前定义好的数据类型
struct BiTNode *lchild, *rchild; // 左、右孩子指针,分别指向该节点的左子节点和右子节点
} BiTNode, *BiTree; // BiTNode是二叉树节点类型,BiTree是指向二叉树节点的指针类型
// 假设这是一个用于访问节点的函数,具体功能根据实际需求定义
void visit(BiTree node) {
// 这里可以写具体的访问操作,例如打印节点数据等
}
// 先序遍历函数
void PreOrder(BiTree T){
if(T!=NULL){ // 如果二叉树不为空
visit(T); // 访问根结点
PreOrder(T->lchild); // 递归遍历左子树
PreOrder(T->rchild); // 递归遍历右子树
}
}
中序递归遍历(代码):
// 定义二叉树节点结构体
typedef struct BiTNode{
ElemType data; // 数据域,存储节点的数据,ElemType为之前已定义的数据类型
struct BiTNode *lchild, *rchild; // 左、右孩子指针,分别指向该节点的左子节点和右子节点
} BiTNode, *BiTree; // BiTNode是二叉树节点类型,BiTree是指向二叉树节点的指针类型
// 假设这是一个用于访问节点的函数,具体功能按需编写
void visit(BiTree node) {
// 在这里编写具体的访问操作,例如打印节点数据等
}
// 中序遍历函数
void InOrder(BiTree T){
if(T!=NULL){ // 如果二叉树不为空
InOrder(T->lchild); // 递归地中序遍历左子树
visit(T); // 访问根节点
InOrder(T->rchild); // 递归地中序遍历右子树
}
}
后序遍历(代码):
// 定义二叉树节点的结构体
typedef struct BiTNode{
ElemType data; // 数据域,用于存储节点的数据,ElemType是预先定义好的数据类型
struct BiTNode *lchild, *rchild; // 左、右孩子指针,分别指向该节点的左子节点和右子节点
} BiTNode, *BiTree; // BiTNode是二叉树节点类型,BiTree是指向二叉树节点的指针类型
// 假设这是一个访问节点的函数,具体实现根据实际需求编写
// 比如可以是打印节点数据,或者对节点进行其他操作
void visit(BiTree node) {
// 此处编写具体访问操作的代码
}
// 后序遍历函数
void PostOrder(BiTree T){
if(T!=NULL){ // 判断二叉树是否为空,若不为空才进行遍历操作
PostOrder(T->lchild); // 递归地后序遍历左子树
PostOrder(T->rchild); // 递归地后序遍历右子树
visit(T); // 访问根节点,在左右子树遍历完成后进行
}
}
二叉树遍历总结:
空间复杂度为O(h),h为树的高度
每个结点都会被路过3次
补充之如何求树的深度:
是后序遍历的变种
先后访问左右儿子,得出对应深度返回左右儿子深度更高的那个就是树的深度
二叉树的层序遍历
层序遍历:
- 基于树的层次特性确定的次序规则
算法思想:
初始化一个辅助队列
根结点入队
若队列非空,则队头结点出队,访问该结点,并将其左、右孩子插入队尾(如果有的话)
重复第三步直至队列为空
代码实现:
// 定义二叉树节点结构体,采用链式存储,每个节点存储一个字符类型的数据
typedef struct BiTNode{
char data;
struct BiTNode *lchild, *rchild;
} BiTNode, *BiTree;
// 定义链式队列节点结构体,其中data存储的是指向二叉树节点的指针
typedef struct LinkNode{
BiTNode *data;
struct LinkNode *next;
} LinkNode;
// 定义链式队列结构体,包含队头和队尾指针
typedef struct{
LinkNode *front, *rear;
} LinkQueue;
// 二叉树层次遍历函数
void LevelOrder(BiTree T){
LinkQueue Q;
// 初始化辅助队列Q
InitQueue(Q);
BiTree p;
// 将根结点入队
EnQueue(Q, T);
// 当队列不为空时,进行循环操作
while (!IsEmpty(Q)){
// 队头结点出队,并将出队节点赋值给p
DeQueue(Q, p);
// 访问出队的二叉树节点
visit(p);
// 如果当前节点的左孩子不为空
if(p->lchild != NULL)
// 将左孩子节点入队
EnQueue(Q, p->lchild);
// 如果当前节点的右孩子不为空
if(p->rchild != NULL)
// 将右孩子节点入队
EnQueue(Q, p->rchild);
}
}
// 假设存在以下函数声明,具体实现根据实际需求编写
// 初始化队列函数
void InitQueue(LinkQueue &Q);
// 入队函数
void EnQueue(LinkQueue &Q, BiTree e);
// 判断队列是否为空函数
bool IsEmpty(LinkQueue Q);
// 出队函数
void DeQueue(LinkQueue &Q, BiTree &e);
// 访问二叉树节点的函数
void visit(BiTree node);
由遍历序列构造二叉树
结论:
一个前/中/后/层序遍历序列可能对应多种二叉树形态
只有至少同时拥有两种遍历序列才能确定二叉树的形态
结论:前序、后序、层序(只要没有中序)序列的两两组合无法唯一确定一科二叉树
通过两种遍历序列确定二叉树:
前序+中序:
中序+后序:
层序+中序:
线索二叉树的概念
中序遍历的问题:
如何找到指定结点p在q 中序遍历序列中的前驱?
如何找到p的中序后继?
能否从一个指定结点开始中序遍历?
完成上述需求的思路:
- 从根结点出发,重新进行一次中序遍历,指针q记录当前访问的结点,指针pre记录上一个被访问的结点
- 当q == p时,pre为前驱
- 当pre == p时,q为后继
- 缺点:找前驱、后继很不方便; 操作必须从根开始
中序线索二叉树:
线索二叉树的存储结构:
线索二叉树的存储结构代码初始化:
- 先/中/后序线索二叉树同理
// 定义二叉树节点结构,采用链式存储,这种结构也被称为二叉链表
typedef struct BiTNode{
ElemType data; // 数据域,用于存储节点的数据,ElemType是预先定义好的数据类型
struct BiTNode *lchild, *rchild; // 左孩子指针和右孩子指针,分别指向该节点的左子节点和右子节点
} BiTNode, *BiTree; // BiTNode表示二叉树节点类型,BiTree表示指向二叉树节点的指针类型
// 定义线索二叉树节点结构
typedef struct ThreadNode{
ElemType data; // 数据域,用于存储节点的数据,ElemType是预先定义好的数据类型
struct ThreadNode *lchild, *rchild; // 左、右指针,既可能指向孩子节点,也可能是线索
int ltag, rtag; // 左、右线索标志
// 当ltag为0时,lchild指针指向左孩子节点;当ltag为1时,lchild指针是前驱线索
// 当rtag为0时,rchild指针指向右孩子节点;当rtag为1时,rchild指针是后继线索
} ThreadNode, *ThreadTree; // ThreadNode表示线索二叉树节点类型,ThreadTree表示指向线索二叉树节点的指针类型
三种线索二叉树的对比:
二叉树的线索化
通过头结点找到中序前驱[土法]:
// 定义二叉树节点结构体
typedef struct BiTNode{
// 假设ElemType是之前已定义好的数据类型,用于存储节点数据
ElemType data;
struct BiTNode *lchild, *rchild;
} BiTNode, *BiTree;
// 辅助全局变量,用于查找结点p的前驱
BiTNode *p; // p指向目标结点
BiTNode *pre = NULL; // 指向当前访问结点的前驱
BiTNode *final = NULL; // 用于记录最终结果
// 中序遍历函数
void InOrder(BiTree T){
if(T!=NULL){
InOrder(T->lchild); // 递归遍历左子树
visit(T); // 访问根结点
InOrder(T->rchild); // 递归遍历右子树
}
}
// 访问结点函数
void visit(BiTNode *q){
if (q==p) // 当前访问结点刚好是结点p
final = pre; // 找到p的前驱
else
pre = q; // pre指向当前访问的结点
}
中序二叉树线索化:
中序二叉树线索化代码:
// 中序线索化二叉树的函数,参数T是指向线索二叉树根节点的指针
void CreateInThread(ThreadTree T) {
// 定义一个全局或在合适作用域声明的指针pre,这里先初始化为NULL,用于记录前驱节点
pre = NULL;
// 判断二叉树是否为空,若不为空才进行线索化操作
if (T != NULL) {
InThread(T); // 调用函数对二叉树进行中序线索化
// 检查线索化过程中记录的最后一个节点(即中序遍历的最后一个节点)的右指针情况
if (pre->rchild == NULL)
pre->rtag = 1; // 将该节点的右线索标志设为1,表示右指针是线索
}
}
// 定义线索二叉树节点结构体
typedef struct ThreadNode{
ElemType data; // 数据域,存储节点的数据,ElemType是预先定义好的数据类型
struct ThreadNode *lchild, *rchild; // 左、右指针,可能指向孩子节点或者线索
int ltag, rtag; // 左、右线索标志,ltag/rtag为0表示指针指向孩子,为1表示指针是线索
} ThreadNode, *ThreadTree; // ThreadNode表示线索二叉树节点类型,ThreadTree表示指向线索二叉树节点的指针类型
// 全局变量,指向当前访问结点的前驱
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; // 更新前驱指针
}
先序二叉树线索化及代码优化:
由于先序遍历先遍历根结点然后再遍历左结点,若左孩子为空,通过线索化后会指回前驱结点(他的根结点)
这时在此访问左孩子时候会又访问回根结点,因此需要增加一个判断来确定左孩子不是真正的左孩子而是线索化后的前驱结点
因此PreThread函数需要优化为:
先序二叉树线索化代码:
// 先序线索化二叉树的函数,参数T为指向线索二叉树根节点的指针
void CreatePreThread(ThreadTree T) {
// 定义一个指针pre,用于记录线索化过程中当前节点的前驱节点,初始化为NULL
pre = NULL;
// 判断二叉树是否为空,只有非空二叉树才需要进行线索化操作
if (T != NULL) {
PreThread(T); // 调用函数对二叉树进行先序线索化
// 检查线索化过程中记录的最后一个节点(即先序遍历的最后一个节点)的右指针情况
if (pre->rchild == NULL)
pre->rtag = 1; // 将该节点的右线索标志设为1,表示右指针是线索
}
}
// 定义线索二叉树节点结构体
typedef struct ThreadNode{
ElemType data; // 数据域,存储节点的数据,ElemType是预先定义的数据类型
struct ThreadNode *lchild, *rchild; // 左、右指针,可能指向孩子节点或者线索
int ltag, rtag; // 左、右线索标志,ltag为0时表示lchild指向左孩子,为1时表示是前驱线索;rtag同理
} ThreadNode, *ThreadTree; // ThreadNode表示线索二叉树节点类型,ThreadTree表示指向线索二叉树节点的指针类型
// 全局变量,指向当前访问节点的前驱
ThreadNode *pre = NULL;
// 先序遍历二叉树并进行线索化的函数
void PreThread(ThreadTree T){
if(T != NULL){
visit(T); // 先处理根节点
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; // 更新前驱节点为当前节点
}
后序二叉树线索化:
后序二叉树线索化代码:
// 线索二叉树的结点结构假设包含左孩子指针、左线索标志、数据、右线索标志、右孩子指针
// 后序遍历二叉树,一边遍历一边线索化
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); // 后序线索化二叉树
if (pre->rchild == NULL) {
pre->rtag = 1; // 处理遍历的最后一个结点
}
}
}
在线索二叉树中找前驱后驱
中序线索二叉树找中序前驱后继:
在中序线索二叉树中找到指定结点*p的中序后继next
若p->rtag == 1,则next = p->rchild
若p->rtag == 0,说明p必定有右孩子,next = (p的右子树中最左下的结点)
找中序前驱后继代码:
// 定义线索二叉树节点结构体(假设之前已定义)
// typedef struct ThreadNode{
// ElemType data;
// struct ThreadNode *lchild, *rchild;
// int ltag, rtag;
// } ThreadNode, *ThreadTree;
// 找到以p为根的子树中,第一个被中序遍历的结点
ThreadNode *Firstnode(ThreadNode *p){
// 循环找到最左下结点(不一定是叶结点),
// 当左线索标志ltag为0时,说明lchild指向左子节点,继续向左找
while(p->ltag == 0) p = p->lchild;
return p;
}
// 在中序线索二叉树中找到结点p的后继结点
ThreadNode *Nextnode(ThreadNode *p){
// 如果右线索标志rtag为0,说明rchild指向右子树
// 则返回右子树中第一个被中序遍历的结点(即右子树的最左下结点)
if(p->rtag == 0) return Firstnode(p->rchild);
else
return p->rchild; // rtag为1时,直接返回后继线索
}
// 对中序线索二叉树进行中序遍历(利用线索实现的非递归算法),空间复杂度O(1)
void Inorder(ThreadNode *T){
// 从根节点T开始,找到中序遍历的第一个节点赋值给p
// 只要p不为空,就继续找p的后继节点并访问
for(ThreadNode *p = Firstnode(T); p != NULL; p = Nextnode(p))
visit(p); // 访问节点p,visit函数需要另外实现
}
在中序线索二叉树中找到指定结点*p的中序前驱pre
若p->ltag == 1,则pre = p->lchild
若p->ltag == 0,说明p必定有左孩子,pre = (p的左子树中最右下的结点),可以用LMR不断嵌入套娃来用
找到指定结点*p的中序前驱pre代码:
// 定义线索二叉树节点结构体(假设之前已定义)
// typedef struct ThreadNode{
// ElemType data;
// struct ThreadNode *lchild, *rchild;
// int ltag, rtag;
// } ThreadNode, *ThreadTree;
// 找到以p为根的子树中,最后一个被中序遍历的结点
ThreadNode *Lastnode(ThreadNode *p){
// 循环找到最右下结点(不一定是叶结点),
// 当右线索标志rtag为0时,说明rchild指向右子节点,继续向右找
while(p->rtag == 0) p = p->rchild;
return p;
}
// 在中序线索二叉树中找到结点p的前驱结点
ThreadNode *Prenode(ThreadNode *p){
// 如果左线索标志ltag为0,说明lchild指向左子树
// 则返回左子树中最后一个被中序遍历的结点(即左子树的最右下结点)
if(p->ltag == 0) return Lastnode(p->lchild);
else
return p->lchild; // ltag为1时,直接返回前驱线索
}
// 对中序线索二叉树进行逆向中序遍历
void RevInorder(ThreadNode *T){
// 从根节点T开始,找到中序遍历的最后一个节点赋值给p
// 只要p不为空,就继续找p的前驱节点并访问
for(ThreadNode *p = Lastnode(T); p != NULL; p = Prenode(p))
visit(p); // 访问节点p,visit函数需要另外实现
}
先序线索二叉树找先序前驱后继:
在先序线索二叉树中找到指定结点*p的先序后继next
若p->rtag == 1,则next = p->rchild
若p->rtag == 0,说明p必定有右孩子
若p有左孩子,则先序后继为左孩子
若p没有左孩子,则先序后继为右孩子
在先序线索二叉树中找到指定结点*p的先序前驱pre
若p->ltag == 1,则pre = p->lchild
若p->ltag == 0,说明p必定有左孩子
先序遍历中,左右子树中的结点只可能是根的后继,不可能是前驱
方法1:用土办法从头开始先序遍历
方法2:可以改用三叉链表以找到父结点
后序线索二叉树找后序前驱后继:
在后序线索二叉树中找到指定结点*p的后序前驱pre
若p->ltag == 1,则pre = p->lchild
若p->ltag == 0,说明p必有左孩子
若p有右孩子,则后序前驱为右孩子
若p没有右孩子,则后序前驱为左孩子
在后序线索二叉树中找到指定结点*p的后序后继next
若p->rtag == 1,则next = p->rchild
若p->rtag == 0,说明p必定有右孩子
后序遍历中,左右子树中的结点只可能是根的前驱,不可能是后继
方法1:用土办法从头开始先序遍历
方法2:可以改用三叉链表以找到父结点
树的储存结构
双亲表示法之顺序存储:
每个结点中保存指向双亲的“指针”,data,parrent
根结点固定存储在0,-1表示没有双亲
双亲表示法之顺序存储代码初试化:
// 定义一个宏,用于表示树中最多的结点数为100
#define MAX_TREE_SIZE 100
// 定义树的结点结构
typedef struct{
ElemType data; // 数据元素,ElemType是预先定义好的数据类型
int parent; // 双亲位置域,用于存储该节点的双亲在数组中的下标
} PTNode;
// 定义树的类型结构
typedef struct{
PTNode nodes[MAX_TREE_SIZE]; // 用数组来存储树的节点,即双亲表示法
int n; // 记录树中当前的结点数
} PTree;
新增数据元素,无需按逻辑上的次序存储,只需说明新增元素的data,parrent即可
删除数据元素
方案1:把要删除的数据元素data设为空,parent设为-1
方案2:将数组尾部的数据元素覆盖要删除的数据元素
查询数据元素
优点:查指定结点的双亲很方便
缺点:查指定结点的孩子只能从头遍历
双亲表示法之顺序+链式存储:
双亲表示法之顺序+链式存储代码初始化:
// 定义树中孩子节点相关的结构体
struct CTNode {
int child; // 记录孩子结点在数组中的位置
struct CTNode *next; // 指向下一个孩子的指针
};
// 定义树中每个节点的结构体
typedef struct {
ElemType data; // 数据元素,ElemType是预先定义的数据类型
struct CTNode *firstChild; // 指向第一个孩子的指针
} CTBox;
// 定义树的整体结构体
typedef struct {
CTBox nodes[MAX_TREE_SIZE]; // 用数组存储树的节点,MAX_TREE_SIZE是之前定义的宏,表示最大节点数
int n, r; // n表示树的结点数,r表示根的位置
} CTree;
双亲表示法之链式存储:
规则:
左指针指向第一个孩子
右指针指向自己的第一个兄弟
双亲表示法之链式存储代码初始化:
// 定义树的节点结构体,采用孩子兄弟表示法
typedef struct CSNode{
ElemType data; // 数据域,用于存储节点的数据,ElemType是预先定义好的数据类型
struct CSNode *firstchild, *nextsibling; // firstchild指向第一个孩子,可看作左指针
// nextsibling指向右兄弟,可看作右指针
} CSNode, *CSTree; // CSNode表示树节点类型,CSTree表示指向树节点的指针类型
森林和二叉树的转换:
本质:用二叉链表存储森林
规则:
各个树的根结点视为兄弟关系
左指针指向第一个孩子
右指针指向自己的第一个兄弟
树和森林的遍历
树的先根遍历:
先根遍历:若树非空,先访问根结点,再依次对每棵子树进行先根遍历。
树的先根遍历序列与这棵树相应二叉树的先序序列相同。
树的后根遍历:
后根遍历:若树非空,先依次对每棵子树进行后根遍历,最后再访问根结点
树的后根遍历序列与这棵树相应二叉树的中序序列相同
也被称为深度优先遍历
树的层次遍历[和二叉树遍历极其类似!不再赘述]:
用队列实现,又被称为广度优先遍历
步骤
若树非空,则根结点入队
若队列非空,队头元素出队并访问,同时将该元素的孩子依次入队
重复第二步直到队列为空
森林的先序遍历:
若森林为非空,则按如下规则进行遍历:
访问森林中第一棵树的根结点。
先序遍历第一棵树中根结点的子树森林。
先序遍历除去第一棵树之后剩余的树构成的森林。
效果等同于依次对各个树进行先根遍历=对应二叉树先序
用孩子兄弟表示法转换为二叉树,效果等同于依次对二叉树的先序遍历
森林的中序遍历:
若森林为非空,则按如下规则进行遍历:
中序遍历森林中第一棵树的根结点的子树森林。
访问第一棵树的根结点。
中序遍历除去第一棵树之后剩余的树构成的森林。
效果等同于依次对各个树进行后根遍历=相应二叉树的中序
用孩子兄弟表示法转换为二叉树,效果等同于依次对二叉树的中序遍历
二叉排序树(BST)
定义:
二叉排序树,又称二叉查找树(BST,Binary Search Tree)
一棵二叉树或者是空二叉树,或者是具有如下性质的二叉树:
左子树上所有结点的关键字均小于根结点的关键字
右子树上所有结点的关键字均大于根结点的关键字
左子树和右子树又各是一棵二叉排序树
即左子树结点值< 根结点值< 右子树结点值
进行中序遍历,可以得到一个递增的有序序列
二叉排序树可用于元素的有序组织、搜索
BST的插入、删除、查找动画演示:
二叉排序树-插入、删除、查找
若树非空,目标值与根结点的值比较:
若相等,则查找成功
若小于根结点,则在左子树上查找,否则在右子树上查找。
查找成功,返回结点指针;
查找失败返回NULL
递归实现的最坏空间复杂度为O(h),普通实现的最坏空间复杂度为O(1)
BST的查找指定key值代码及存储结构初始化实现:
// 定义二叉排序树节点结构体
typedef struct BSTNode{
int key; // 数据域,存储节点的值
struct BSTNode *lchild, *rchild; // 左、右孩子指针,分别指向该节点的左子节点和右子节点
} BSTNode, *BSTTree; // BSTNode表示二叉排序树节点类型,BSTTree表示指向二叉排序树节点的指针类型
// 在二叉排序树中查找值为key的结点(非递归实现)
BSTNode *BST_Search(BSTTree T, int key){
// 当树不为空且要查找的key值不等于当前节点的key值时,继续循环查找
while(T != NULL && key != T->key){
if(key < T->key)
T = T->lchild; // 若key值小于当前节点的key值,则在左子树上查找
else
T = T->rchild; // 若key值大于当前节点的key值,则在右子树上查找
}
return T; // 返回查找结果,找到则返回对应节点指针,否则返回NULL
}
// 在二叉排序树中查找值为key的结点(递归实现)
BSTNode *BSTSearch(BSTTree T, int key){
if (T == NULL)
return NULL; // 若树为空,查找失败,返回NULL
if (key == T->key)
return T; // 若找到与key值相等的节点,查找成功,返回该节点指针
else if (key < T->key)
return BSTSearch(T->lchild, key); // 若key值小于当前节点的key值,在左子树中递归查找
else
return BSTSearch(T->rchild, key); // 若key值大于当前节点的key值,在右子树中递归查找
}
BST的插入:
若原二叉排序树为空,则直接插入结点;
否则,若关键字k小于根结点值,则插入到左子树,若关键字k大于根结点值,则插入到右子树
递归实现的最坏空间复杂度为O(h)
BST的插入k点递归代码实现:
// 在二叉排序树插入关键字为k的新结点(递归实现)
// 参数T是指向二叉排序树根节点的指针的引用,这样可以在函数中修改根节点;k是要插入的关键字
int BST_Insert(BSTTree &T, int k){
if(T == NULL){ // 原树为空,新插入的结点为根结点
// 分配新的节点空间,(BSTTree)是强制类型转换,将malloc返回的void*转换为BSTTree类型
T = (BSTTree)malloc(sizeof(BSTNode));
T->key = k; // 将新节点的关键字设为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);
}
通过数组构造二叉排序树代码实现:
注意:
- 不同的关键字序列可能得到同款二叉排序树,也可能得到不同款二叉排序树
二叉排序树的删除:
先搜索找到目标结点:
若被删除结点z是叶结点,则直接删除,不会破坏二叉排序树的性质。
若结点z只有一棵左子树或右子树,则让z的子树成为z父结点的子树,替代z的位置。
若结点z有左、右两棵子树,则令z的直接后继(或直接前驱)替代z,然后从二叉排序树中删去这个直接后继(或直接前驱),这样就转换成了第一或第二种情况。
z的后继:z的右子树中最左下结点,是右子树最小的结点(该结点一定没有左子树)
z的前驱:z的左子树中最右下结点,是左子树中最大的结点(该结点一定没有右子树)
查找效率分析:
查找长度:在查找运算中,需要对比关键字的次数称为查找长度,反映了查找操作时间复杂度
查找成功的平均查找长度ASL(Average Search Length):(各层(层数*层结点个数)相加)/总结点个数
最坏情况:每个结点只有一个分支,树高h=结点数n。平均查找长度=O(n)
最好情况:n个结点的二叉树最小高度为(log2n)+ 1。平均查找长度= O(log2n)
平衡二叉树(AVL)
定义:
平衡二叉树(Balanced Binary Tree),简称平衡树(AVL树)——树上任一结点的左子树和右子树的高度之差不超过1
结点的平衡因子=左子树高-右子树高
平衡二叉树结点的平衡因子的值只可能是−1、0或1
只要有任一结点的平衡因子绝对值大于1,就不是平衡二叉树
平衡二叉树(AVL-RR/RL/LL/LR)动画演示
AVL-插入、删、查;含RR、LL、RL、LR四种情况
平衡二叉树的插入原理:
- 在二叉排序树中插入新结点后,如何保持平衡?
查找路径上的所有结点都有可能受到影响
从插入点往回找到第一个不平衡结点,调整以该结点为根的子树
- 每次调整的对象都是“最小不平衡子树”在插入操作中,只要将最小不平衡子树调整平衡,则其他祖先结点都会恢复平衡
- 插入操作导致“最小不平衡子树”高度+1,经过调整后高度恢复
调整最小不平衡子树原理详解:
只有假设所有子树的高度都是H才能保证出现最小不平衡子树恒定
目标:
恢复平衡
保持二叉排序树特性
二叉排序树的特性:左子树结点值< 根结点值< 右子树结点值
LL平衡旋转(右单旋转)
由于在结点A的左孩子(L)的左子树(L)上插入了新结点,A的平衡因子由1增至2,导致以A为根的子树失去平衡,需要一次向右的旋转操作
将A的左孩子B向右上旋转代替A成为根结点,将A结点向右下旋转成为B的右子树的根结点,而B的原右子树则作为A结点的左子树
RR平衡旋转(左单旋转)
由于在结点A的右孩子(R)的右子树(R)上插入了新结点,A的平衡因子由-1减至-2,导致以A为根的子树失去平衡,需要一次向左的旋转操作
将A的右孩子B向左上旋转代替A成为根结点,将A结点向左下旋转成为B的左子树的根结点,而B的原左子树则作为A结点的右子树
右旋(RR)和左旋(LL)的代码实现思路
LR平衡旋转(先左后右双旋转)
- 由于在A的左孩子(L)的右子树(R)上插入新结点,A的平衡因子由1增至2,导致以A为根的子树失去平衡,需要进行两次旋转操作,先左旋转后右旋转
- 先将A结点的左孩子B的右子树的根结点C向左上旋转提升到B结点的位置,然后再把该C结点向右上旋转提升到A结点的位置
RL平衡旋转(先右后左双旋转)
- 由于在A的右孩子(R)的左子树(L)上插入新结点,A的平衡因子由-1减至-2,导致以A为根的子树失去平衡,需要进行两次旋转操作,先右旋转后左旋转
- 先将A结点的右孩子B的左子树的根结点C向右上旋转提升到B结点的位置,然后再把该C结点向左上旋转提升到A结点的位置
查找效率分析:
若树高为h,则最坏情况下,查找一个关键字最多需要对比h次,即查找操作的时间复杂度不可能超过O(h)
平衡二叉树——树上任一结点的左子树和右子树的高度之差不超过1
假设以nh表示深度为h的平衡树中含有的最少结点数。
则有n0 = 0, n1 = 1, n2 = 2,并且有nh = n(h−1) + n(h−2) + 1
可以证明含有n个结点的平衡二叉树的最大深度为O(log2n) ,平衡二叉树的平均查找长度为O(log2n)
哈夫曼树
带权路径长度:
结点的权:有某种现实含义的数值(如:表示结点的重要性等)
结点的带权路径长度:从树的根到该结点的路径长度(经过的边数)与该结点上权值的乘积
树的带权路径长度:树中所有叶结点的带权路径长度之和(WPL, Weighted Path Length)
哈夫曼树的定义:
- 在含有n个带权叶结点的二叉树中,其中带权路径长度(WPL)最小的二叉树称为哈夫曼树,也称最优二叉树
哈夫曼树的构造[Haf编码同理]:
给定n个权值分别为w1, w2,…, wn的结点,构造哈夫曼树的算法描述如下:
- 将这n个结点分别作为n棵仅含一个结点的二叉树,构成森林F。
- 构造一个新结点,从F中选取两棵根结点权值最小的树作为新结点的左、右子树,并且将新结点的权值置为左、右子树上根结点的权值之和。
- 从F中删除刚才选出的两棵树,同时将新得到的树加入F中。
- 重复步骤2和3,直至F中只剩下一棵树为止。
每个初始结点最终都成为叶结点,且权值越小的结点到根结点的路径长度越大
哈夫曼树的结点总数为2n − 1
哈夫曼树中不存在度为1的结点。
哈夫曼树并不唯一,但WPL必然相同且为最优
哈夫曼编码:
固定长度编码:每个字符用相等长度的二进制位表示
可变长度编码:允许对不同字符用不等长的二进制位表示
若没有一个编码是另一个编码的前缀,则称这样的编码为前缀编码,如果没有前缀编码容易出现歧义
由哈夫曼树得到哈夫曼编码:字符集中的每个字符作为一个叶子结点,各个字符出现的频度作为结点的权值,根据之前介绍的方法构造哈夫曼树
并查集
定义:
- 并查集(Disjoint Set)是逻辑结构集合的一种具体实现,只进行“并”和“查”两种基本操作
并查集的基本操作:
Find:查操作,确定一个指定元素所属集合
Union:并操作,将两个不相交的集合合并为一个
并查集动画全程演示:
联合算法 - 查找不相交集(UFDS)
通过数组初始化并查集代码实现:
- S[]实际上就是树的双亲表示法,里面的值就是自己对应根结点的下标
如何实现并和查代码实现:
时间复杂度分析:
-
Find操作最坏时间复杂度O(n)
-
Union操作时间复杂度O(1)
并集Union操作的优化以及代码实现:
在每次Union操作构建树的时候,尽量让树不长高
用根结点的绝对值表示树的结点总数(根结点从-1改成-(树的总结点))
Union操纵,让小树合并到大树
该方法构造的树高不超过[log2n]+1
Find最坏时间复杂度变为O(log2n)
查找的压缩路径优化及代码实现:
核心想法就是让树越来越“矮”
Find操作先找到根结点,再将查找路径上所有结点都挂到根结点下
这样的操纵可以让树的告诉不超过O(α(n))
O(α(n))是一个增长很缓慢的函数,对于常见的n值,O(α(n))通常<=4
因此优化后的并查集Find、Union操作时间开销都很低