第二章:树形结构
Part1:树和森林
1、基本概念
1.1 树
考虑如下的一棵树
- 拥有同一个父亲的结点1之间互称兄弟,例如(D、E、F)互称兄弟
- 关于结点的度数
- 结点的度数即为孩子的个数
- 整棵树的度数:树的结点的度数中的最大值
- 叶子结点:即为没有孩子的结点
- 重要公式(考试常考):树的总结点个数=树的结点度数之和+1
1.2 森林
即为n棵树构成的集合(n>=0)
2、与二叉树的转化2
2.1 树->二叉树
原则:左孩子,右兄弟
2.2 森林->二叉树
原则:
- Step1:将森林里面的树变为二叉树
- Step2:把第n+1棵树视为第n棵树的兄弟
3、应用:查并集(最优雅的数据结构之一)3
3.1 基本思想
用集合中的一个元素来代表整个集合
3.2 一个引例
假设一开始有6个元素,一开始都各自为一个集合
接着发生了集合合并,每两个元素为一个集合,其中集合A的代表元素为1,集合B的代表元素为3,集合A的代表元素为4
最终全部合并,结果非常像一棵树,最终这个集合的代表(可以理解为老大,BOSS)是1
3.3相关代码
借助3.2中的思路,我们可以轻松写出其初始化,查询集合老大,集合合并的代码
-
初始化
void Inital(int fa[], int n){ for(int i = 0; i < n; ++i) fa[i] = i; }
一开始,所有元素的老大均为自己,即每个元素为一个集合;之所以可以采用一个一维数组存储,是因为每个集合的老大有且只有一个
-
查询集合老大
2.1 递归实现
int Find_Boss(int fa[], int i){ if (fa[i] == i) return i; else Find_Boss(fa, fa[i]); }
由1可知,集合老大的老大就是自己
2.2 循环实现
int Find_Boss(int fa[], int i){ while(fa[i] != i) i = fa[i]; return i; }
-
集合合并
本质即为让集合的老大认另一个集合老大为大哥
void Merge(int fa[], int i, int j){ fa[Find_Boss(fa, i)] = fa[Find_Boss(fa, j)]; }
Part2:二叉树
每个结点至多有两个孩子的树,称为二叉树
1、基本概念
1.1 两个特殊二叉树
- 满二叉树:每层结点个数均达到最大
- 完全二叉树4:除最底层外,其它层结点个数均达到最大;最底层结点从左向右依次排列
1.2 二叉树的存储结构
2、二叉树的遍历
2.1 二叉树的3+1种遍历
- 前/中/后序遍历:采用递归实现(序表示根节点何时被访问)
- 层次遍历:采用辅助队列实现5
遍历模板
void Order(TreeNode * root){
if (root != NULL)
{
// visit(root->data); Pre_Order 前序遍历
Order(root->left);
// visit(root->data); In_Order 中序遍历
Order(root->right);
// visit(root->data); Post_Order 后续遍历
}
}
例如:
- Pre_Order: A B D E C
- In_Order: D B E A C
- Post_Order: D E B C A
2.2 线索化二叉树
由2.1可知一个二叉树的节点序列,在这个序列中大部分结点(n-2,除了第一个和最后一个)均有一个直接前驱和一个直接后继元素,如果我们想知道一个元素的前驱/后继信息时应该怎么办?
2.2.1 分析:以中序遍历为例
- 如果一个结点有左右孩子,那么其前驱为左孩子,后继为右孩子,在这种情况下,我们可以立马找到这个元素的前驱和后继
- 如果一个结点存在空指针,那么这个结点的前驱/后继无法找到,只能遍历整颗二叉树
综上所述,我们只需要考虑那么存在空指针的结点即可
2.2.2 观察:一颗有n个结点的二叉树
这棵二叉树一共闲置了n+1个指针6我们能否将这些闲置的指针利用起来,来达到我们的目的:不用遍历二叉树的情况下,找到该元素的前驱和后继。答案是肯定的,不过为了利用这些闲置的指针,我们需要对原有的结构体进行一些改造
typedef struct Thread_BinaryTreeNode{
int data;
bool left_is_pre, right_is_rear;
struct Thread_BinaryTreeNode * left, * right;
}Tree_Node;
我们令:
left_is_pre = true; // 左指针指向前驱
left_is_pre = false; // 左指针指向左孩子
right_is_rear = true; // 右指针指向后继
right_is_rear = false; // 右指针指向右孩子
2.2.3 线索化二叉树
由于前驱和后继只有在遍历二叉树时才能得到,故线索化一颗二叉树的过程即为按序遍历的过程,只需把访问节点的代码修改为线索化的代码即可,如下所示
void Thread_Tree(Tree_node * now_node, Tree_Node ** pre_node){
// 检查输入是否合法
if(now_node == NULL || pre_node == NULL)
return;
Thread_Tree(now_node->left, pre_node);
// 接下来是线索化代码
if (now_node->left == NULL) // 当前结点无左孩子,则将其指向前驱
{
now_node->left_is_pre = ture;
now_node->left = *pre_node;
}
if ((*pre_node)->right == NULL) // 当前节点无右孩子,则将其指向后继
{
(*pre_node)->right_is_rear = true;
(*pre_node)->right = now_node;
}
// 这就是为什么需要二级指针的原因,因为需要修改指针本身的值
*pre_node = now_node;
// 线索化代码结束
Thread_Tree(now_node->right, pre_node);
}
这样一来,二叉树的指针被充分利用,仅有2个指针闲置(第一个结点的前驱,最后一个节点的后继)
3、二叉树的应用
3.1 哈夫曼树
3.1.1 带权路径长度(Weight Path Length)和哈夫曼树(Huffman Tree)
- 树的第i个结点的权重记为Wi
- 树的根结点到该结点的边数即为Li
如果二叉树有n个结点,则有
W
P
L
=
∑
i
=
1
n
W
i
∗
L
i
WPL=\sum_{i=1}^nWi*Li
WPL=i=1∑nWi∗Li
在用n个结点构造的二叉树中,WPL最小者,称为哈夫曼树
3.1.2 生成哈夫曼树
- Setp1:在n个结点中选择两个最小者并生成一个父结点,父结点的权重为两者之和
- Step2:在原来的n个结点中删除这两个结点,并加入这个父结点,重复
例如:
3.1.3 哈夫曼编码
将每一个字符视为一个结点,其出现的次数视为权重,构造哈夫曼树,按照一定的规则(左0右1/左1右0)编码。即为最优编码,可以压缩要发送的数据量
3.2树形查找
3.2.1 二叉排序树->AVL树
二叉排序树规则:
- 左子树上的结点的值小于根结点
- 右子树上的结点的值大于根结点
二叉排序树的插入和删除
-
插入:按照规则插入即可
-
删除:
-
删除的结点为叶子节点:直接删除
-
删除的结点只有一颗子树:用节点子树代替原结点(不然会断)
-
删除的结点存在两颗子树:
删除原则:删除后其中序遍历的序列其他元素相对位置不变
-
AVL树
请考虑如下两个二叉排序树的插入队列
- S1:1,2,3,4,5,6,7
- S2:4,2,6,1,3,5,7
其结果如下如所示
分析这两个插入队列,我们发现S1构成的二叉树退化成了一个链表,是的查找效率大大降低,时间负责度为O(n)。而S2则是一颗满二叉树,查找效率为O(log2n)
能否使用一种机制,防止出现这样子的情况?答当然有,即AVL树,该树规定,其任意节点的左右子树高度差的绝对值,不超过1,为此我们引入一个平衡因子(BF,Balance Factor)使其成为左右子树的高度差,为了方便计算BF,我们需要记录节点的高度。修改之后的结点类型如下所示
typedef struct BalanceTree_Node{
int height;
int value;
struct BalanceTree_Node * left, * right;
}
AVL的实现思路是对最小不平衡树进行调整7
3.2.2 红黑树
由于AVL树的要求实在是太严格了(BF<=1)所以几乎每次插入和删除都会破坏这个条件,从而需要旋转来使之成为一颗AVL树。在那些删除和插入很平凡的场景中,AVL树的性能会大打折扣,所以出现了红黑树。(其实红黑树我也不是很懂,这里只是简单介绍一下它的规则,在408考纲里面出现了)
- 红黑树的5个规则
- 根结点为黑色
- 节点要么黑色要么红色
- 所有叶子节点均为黑色(不存数据)
- 每个红色结点必有两个黑色子结点(红色结点父子结点均为黑色)
- 对任意结点,从该节点到任意叶结点的简单路径上,黑结点数量相同
可以总结为:左根右(排序树的规则:左<根<右),叶根黑(叶结点根结点黑色),不红红(红色结点不连续出现),黑路同
3.2.3 B/B+树(多路平衡查找树)
B树8
-
构造规则
- 结点内关键字有序
- 所有子树高度相同
- 结点的孩子个数最小是(k/2)向上取整,最大是k(根结点除外)
其中k成为B树的阶,即一个节点可以拥有的最大孩子的个数,如下图所示的为一颗B树
-
插入和删除
2.1 插入:
如果可以正常插入则插入,如果结点溢出,则分裂(中间分裂)
2.2删除
如果删除后依然满足B树规则,则删除;如果不满则合并
- 兄弟结点够借:借兄弟
- 兄弟结点不够借:借父结点
B+树9
在B树的基础上仅仅叶结点包含信息,非叶结点仅起到索引作用。存在两个头指针,一个指向root,一个指向第一个叶结点。应用于关系数据库MySQL。
Part end:参考文件和一些补充说明
关于结点和节点一些说明https://blog.youkuaiyun.com/qq_42270373/article/details/83758928 ↩︎
一些说明:做题的时候要注意,森林/树的先根遍历相当于其对应二叉树的先序遍历;而后根遍历相当于其对应二叉树的中序遍历 ↩︎
内容来自于这篇文章https://zhuanlan.zhihu.com/p/93647900 ↩︎
完全二叉树的本质https://zhuanlan.zhihu.com/p/153216919 ↩︎
在第一章的队列应用中提及 ↩︎
如何计算出n+1个闲置指针:设度为2的结点个数为n0;设度为1的结点个数为n1;设度为0的结点个数为n2;则有方程组2n0+n1+1=n;(利用树的重要公式)解得2n0+n1=n-1;而每个结点有2个指针,共2n个指针,使用了2n0+n1个指针,剩余2n-(2n0+n1)=n+1 ↩︎
这里补充一些细节
↩︎
关于B树的一些补充:为了减少磁盘的I/O操作,才有了B树,因为B树减少了树的高度,减少了磁盘I/O次数 ↩︎
B和B+区别:(1)由于B树结点内存放信息,B+树结点内仅存放索引,所以B+树能够容纳的关键词个数也更多,其树高更小,磁盘I/O也更少(2)B+树查找每个元素均从root开始,最后均到达叶结点,所以其关键字查找路径长度相同(3)B+树由于存在叶结点之间的指针,所以非常方便遍历查询。基于这几点B+树常用语操作系统的文件系统中和数据库中 ↩︎