三、树形结构
1.二叉排序树BST
与 【数据结构】五、树:5.二叉排序树BST 那一部分的内容完全相同
1.1定义
二叉排序树(Binary Sort Tree),又称二叉查找树(Binary Search Tree),二叉搜索树,排序二叉树。
它或者是一棵空二叉树,或者具有以下性质:
1/2.左子树 < 根节点 < 右子树
- 左子树上所有结点的关键字均小于根结点的关键字;
- 右子树上的所有结点的关键字均大于根结点的关键字;
- 左子树和右子树又各是一棵二叉排序树。
- 默认不允许有两个结点的关键字(data)相同。
下图值为10的结点的右子树为5,比10小,不满足条件2,所以这棵树不是二叉搜索树。
可以进行中序遍历,得到一个递增的序列。
适用于需要快速查找、插入和删除数据的场景。
插入和删除操作的时间复杂度为 O(log n),其中 n 是树中节点的个数。
查找操作的时间复杂度也为 O(log n) 在平均情况下,但在最坏情况下可能为 O(n)。
1.2存储结构
// 二叉树的二叉链表结点结构定义
typedef int ElemType;
typedef struct BSTNode
{
ElemType data; //结点数据
struct BSTNode *lchild, *rchild; //左右孩子指针
} BSTNode, *BSTree;
1.3查找
查找操作的时间复杂度也为 O(log n) 在平均情况下,但在最坏情况下可能为 O(n)。
步骤:
- 查找从根结点开始,如果树为空,返回NULL。
- 若搜索树非空,则根结点关键字和 X 进行比较,并进行不同处理:
- 若 X 小于根结点的键值,在左子树中继续搜索;
- 若 X 大于根结点的键值,在右子树中进行继续搜索;
- 若两者比较结果是相等,搜索完成,返回指向此结点的指针。
// 递归查找二叉排序树T中是否存在X
*BSTNode Find(BSTree BST, ElemType X){
if(!BST) return NULL; //查找失败
if(X > BST->data)
return Find(X, BST->rchild); //在右子树中继续查找
else if(X < BST->Data)
return Find(X, BST->lchild); //在左子树中继续查找
else //X == BST->Data
return BST; //查找成功,返回结点的找到结点的地址
}
使用递归会导致效率不高。恰巧这段代码又是尾递归的方式(尾递归就是程序分支的最后,也就是最后要返回的时候才出现递归),从编译的角度来讲,尾递归都可以用循环的方式去实现。
由于非递归函数的执行效率高,可将“尾递归”函数改为迭代函数。
递归的时间复杂度:O(h)。
迭代的时间复杂度:O(1)。
*BSTNode IterFind(BSTree BST, ElemType X){
while(BST) {
if(X > BST->data)
BST = BST->rchild; //向右子树中移动,继续查找
else if(X < BST->data)
BST = BST->lchild; //向左子树中移动,继续查找
else // X == BST->Data
return BST; //查找成功,返回结点的找到结点的地址
}
return NULL; //查找失败
}
或者写为:
*BSTNode IterFind(BSTree BST, ElemType X){
while(BST!=NULL && X!=BST->data) {
if(X > BST->data)
BST = BST->rchild; //向右子树中移动,继续查找
else if(X < BST->data)
BST = BST->lchild; //向左子树中移动,继续查找
}
//最后在叶子结点还是没有找到,那么就会继续向下使得BST=NULL
return BST;
}
1.3.1查找最大和最小元素
①最大元素一定是在树的最右分枝的端结点上。
②最小元素一定是在树的最左分枝的端结点上。
根据上述两点,我们可以很轻松的把代码(两种方式)写出来:
//方法1:递归
*BiTNode FindMin(BinTree BST){
if(!BST) return NULL; //空的二叉搜索树,返回NULL
else if(!BST->lchild)
return BST; //找到最左叶结点并返回
else
return FindMin(BST->lchild); //沿左分支继续查找
}
*BiTNode FindMax(BinTree BST){
if(!BST) return NULL;
else if(!BST->rchild)
return BST;
else
return FindMin(BST->rchild);
}
//方法2:迭代函数
*BiTNode FindMin(BinTree BST){
if(BST)
while(BST->lchild) //沿右分支继续查找,直到最右叶结点
BST = BST->lchild;
return BST;
}
*BiTNode FindMax(BinTree BST){
if(BST)
while(BST->rchild) //沿右分支继续查找,直到最右叶结点
BST = BST->rchild;
return BST;
}
1.4插入
有了二叉排序树的查找函数,那么所谓的二叉排序树的插入,其实也就是将关键字放到树中的合适位置而已。
时间复杂度为 O(log n),其中 n 是树中节点的个数。
BiTree Insert(BiTree &BST, ElemType X){
if(!BST){ //若原树为空,生成并返回一个结点的二叉搜索树
BST = (BiTree)malloc(sizeof(struct BiTNode));
BST->data = X;
BST->lchild = BST->rchild = NULL;
}
else { //开始找要插入元素的位置
if(X < BST->data)
BST->lchild = Insert(BST->lchild, X);//递归插入左子树
else if(X > BST->Data)
BST->rchild = Insert(BST->rchild, X);//递归插入右子树
//else X已经存在,什么都不做
}
return BST;
}
1.4.1插入构造二叉排序树
有了二叉排序树的插入代码,我们要实现二叉排序树的构建就非常容易了,几个例子:
int n=8;
int a[8] = {62, 88, 58, 47, 35, 73, 51, 99};
BSTree T;
CreateBST(T, a[], n);
//----------------------------------------
CreateBST(BSTree &T, int a[], int n){
T=NULL;
for(int i=0; i<10; i++){
Insert(&T, a[i]);
}
上面的代码就可以创建一棵下图这样的树。
1.5删除
删除的结点有三种情况:
- 叶子结点:
- 只需删除该结点不需要做其他操作。
- 仅有左或右子树的结点:
- 删除后需让被删除结点的直接后继接替它的位置。
- 左右子树都有的结点:
- 此时我们需要遍历得到被删除结点的直接前驱或者直接后继(一般是右子树的最小结点,即右子树的中序第一个子女),来接替它的位置,然后再删除那个最小的子女。
时间复杂度为 O(log n),其中 n 是树中节点的个数。
BiTree Delete(BiTree BST, ElemType X) {
*BiTNode Tmp;
if(!BST)
printf("BST is NULL");
else {
if(X < BST->data)
BST->lchild = Delete(BST->lchild, X);//从左子树递归删除
else if(X > BST->data)
BST->rchild = Delete(BST->rchild, X);//从右子树递归删除
//BST就是要删除的结点
else {
//如果被删除结点有左右两个子结点
if(BST->lchild && BST->rchild)
{
//从右子树中找最小的元素填充删除结点
Tmp = FindMin(BST->rchild);
BST->data = Tmp->data;
//从右子树中删除最小元素
BST->rchild = Delete(BST->rchild, BST->data);
}
//被删除结点有一个或无子结点
else
{
Tmp = BST;
if(!BST->lchild) //只有右孩子或无子结点
BST = BST->rchild;
else //只有左孩子
BST = BST->lchild;
free(Tmp);
}
}
}
return BST;
}
1.6性能分析
二叉排序树的优点明显,插入删除的时间性能比较好。而对于二叉排序树的查找,走的就是从根结点到要查找的结点的路径,其比较次数等于给定值的结点在二叉排序树的层数。
极端情况,最少为1次,即根结点就是要找的结点;最多也不会超过树的深度。也就是说,二叉排序树的查找性能取决于二叉排序树的形状(深度)。可问题就在于,二叉排序树的形状是不确定的。
例如 {62 , 88 , 58 , 47 , 35 , 73 , 51 , 99 , 37 , 93} 这样的数组,我们可以构建如下左图的二叉排序树。但如果数组元素的次序是从小到大有序,如{35,37,47,51,58,62,73,88,93,99},则二叉排序树就成了极端的右斜树,如下面右图的二叉排序树:
也就是说,我们希望**二叉排序树是比较平衡(左子树和左子树的高度之差不超过1)**的,即其深度与完全二叉树相同,那么查找的时间复杂也就为 O(log n),近似于折半查找。
不平衡的最坏情况就是像上面右图的斜树(高度为n),查找时间复杂度为 O(n),这等同于顺序查找。
因此,如果我们希望对一个集合按二叉排序树查找,最好是把它构建成一棵平衡的二叉排序树。
2.平衡二叉树AVL
与 【数据结构】五、树:6.平衡二叉树AVL 那一部分的内容完全相同
2.1定义
平衡二叉树(Self-Balancing Binary Search Tree 或 Height-Balanced Binary Search Tree),是由前苏联的数学家 Adelse-Velskil 和 Landis 在 1962 年提出的高度平衡的二叉树。
-
平衡二叉树(AVL树),它是 “平衡二叉搜索树” 的简称,它是一种二叉排序树。
它或者是一颗空树,或者是具有以下性质的二叉排序树:
- 它的左子树和左子树的高度之差(平衡因子)的绝对值不超过1;
- 且它的左子树和右子树又都是一颗平衡二叉树。
平衡因子(BF, Balance Factor):我们将二叉树上结点的左子树深度减去右子树深度的值称为平衡因子。
那么平衡二叉树上所有结点的平衡因子只可能是 -1、0和1。只要二叉树上有一个结点的平衡因子的绝对值大于1,则该二叉树就是不平衡的。
追求更好的平衡二叉树,可以得到更好的二叉排序树,提高排序和查询的效率,不至于让一边的树的深度太大。
2.2存储结构
// 平衡二叉树存储结构
typedef struct AVLNode{
int data; //数据域
int balance; //平衡因子
struct AVLNode *lchild, *rclild;
}AVLNode,*AVLTree;
2.3查找
在平衡二叉树上进行查找的过程与二叉排序树的相同。因此,在查找过程中,与给定值进行比较的关键字个数不超过树的深度。
假设以 n h n_h nh 表示深度为 h 的平衡树中含有的最少结点数。
显然,有 n 0 = 0 , n 1 = 1 , n 2 = 2 n_0=0,n_1=1,n_2=2 n0=0,n1=1,n2=2,并且有 n h = n h − 1 + n h − 2 + 1 n_h=n_{h-1}+n_{h-2}+1 nh=nh−1+nh−2+1。
可以证明,含有 n 个结点的平衡二叉树的最大深度为 O ( l o g 2 n ) O(log2n) O(log2n),因此平衡二叉树的平均查找长度为 O ( l o g 2 n ) O(log2n) O(log2n) 如下图所示:
2.4插入(保持平衡)
二叉排序树保证平衡的基本思想:每当在二叉排序树中插入(或删除)一个结点时,首先检查其插入路径上的结点是否因为此次操作而导致了不平衡。若导致了不平衡,则先找到插入路径上离插入结点最近的平衡因子的绝对值大于 1 的结点A,再对以A为根的子树(最小不平衡子树),在保持二叉排序树特性的前提下,调整各结点的位置关系,使之重新达到平衡。
【注意】每次调整的对象都是最小不平衡子树。
最小不平衡子树:以插入路径上离插入结点最近的平衡因子的绝对值大于 1 的结点作为根的子树。下图中的虚线框内为最小不平衡子树:
平衡二叉树的插入过程的前半部分与二叉排序树相同,但在新结点插入后,若造成查找路径上的某个结点不再平衡,则需要做出相应的调整。可将调整的规律归纳为下列4种情况:
2.4.1 LL平衡旋转(右单旋转)
在结点A的**左孩子(L)的左子树(L)**上插入了新结点,导致了不平衡。
LL平衡旋转(右单旋转)。由于在结点A的左孩子(L)的左子树(L)上插入了新结点,使得(图a->b)A的平衡因子由1增至2,导致以A为根的子树失去平衡,需要一次向右的旋转操作。
将A的左孩子B向右上旋转代替A成为根结点,将A结点向右下旋转成为B的右子树的根结点,而B的原右子树则作为A结点的左子树。(见下面动图所示)
如下图所示,结点旁的数值代表结点的平衡因子,而用方块表示相应结点的子树,下方数值代表该子树的高度。
CODE:
//f是父结点A,p是左孩子B,gf是父结点的父结点A的父结点
f->lchild = p->rchild; //把B的左孩子BL放到B的位置
p->rchild = f; //B和A右旋,A变成B的右孩子
gf->lchild/rchild = p; //A的父结点现在指向B
2.4.2 RR平衡旋转(左单旋转)
在结点A的**右孩子®的右子树®**上插入了新结点,导致了不平衡。
RR平衡旋转(左单旋转)。由于在结点A的右孩子®的右子树®上插入了新结点,使得(图a->b)A的平衡因子由-1减至-2,导致以A为根的子树失去平衡,需要一次向左的旋转操作。
将A的右孩子B向左上旋转代替A成为根结点,将A结点向左下旋转成为B的左子树的根结点,而B的原左子树则作为A结点的右子树。
CODE:
//f是父结点A,p是左孩子B,gf是父结点的父结点A的父结点
f->rchild = p->lchild;
p->lchild = f; //B和A右旋
gf->lchild/rchild = p; //A的父结点现在指向B
2.4.3 LR平衡旋转(先左后右双旋转)
在A的**左孩子(L)的右子树®**上插入新结点,导致了不平衡。
LR平衡旋转(先左后右双旋转)。由于在A的左孩子(L)的右子树®上插入新结点,A的平衡因子由1增至2,导致以A为根的子树失去平衡,需要进行两次旋转操作,先左旋转后右旋转。
先将A结点的左孩子B的右子树的根结点C向左上旋转提升到B结点的位置(即进行一次RR平衡旋转(左单旋转)),然后再把该C结点向右上旋转提升到A结点的位置(即进行一次 LL平衡旋转(右单旋转) )。
2.4.4 RL平衡旋转(先右后左双旋转)
在A的**右孩子®的左子树(L)**上插入新结点,导致了不平衡。
RL平衡旋转(先右后左双旋转)。由于在A的右孩子®的左子树(L)上插入新结点,A的平衡因子由-1减至-2,导致以A为根的子树失去平衡,需要进行两次旋转操作,先右旋转后左旋转。
先将A结点的右孩子B的左子树的根结点C向右上旋转提升到B结点的位置(即进行一次LL平衡旋转(右单旋转)),然后再把该C结点向左上旋转提升到A结点的位置(即进行一次RR平衡旋转(左单旋转))。
【注意】LR和RL旋转时,新结点究竟是插入C的左子树还是插入C的右子树不影响旋转过程。
二叉排序树还有另外的平衡算法,如**红黑树(Red Black Tree)等,与平衡二叉树(AVL树)**相比各有优势。
2.4.5题解
例子:
假设关键字序列为 15 , 3 , 7 , 10 , 9 , 8 通过该序列生成平衡二叉树的过程如下图所示:
插入步骤
- 找到最小不平衡子树的根节点;
- 判断旋转方式;
- 进行旋转(根节点改变和子树迁移);
- 检查:是否符合左 < 根 < 右。
【RR】R子树根节点替代最小不平衡子树的根节点。
【LR】根节点的左子树L --变成–> 父结点的右子树R
根节点的右子树R --变成–> 爷结点的左子树L
根节点 --变成–> 爷爷结点
【RL】根节点的右子树R --变成–> 父结点的左子树L
根节点的左子树L --变成–> 爷结点的右子树R
根节点 --变成–> 爷爷结点
2.5性能分析
- 查找效率分析
若树高为 h,则最坏情况下,查找一个关键字最多需要对比 h 次,即查找操作的时间复杂度不可能超过O(h)。
因为平衡二叉树的左右子树之间高度差不会超过1,
所以假设 n h n_h nh 表示高度为 h 的平衡二叉树的含有的最少的结点数。
则有:
n
0
=
0
n
1
=
1
n
2
=
2
那么可以推出
:
n
h
=
n
h
−
1
+
n
h
−
2
+
1
含义为
:
左子树的最少结点数
+
右子树的最少结点数
+
根节点
那么就有
:
n
3
=
4
;
n
4
=
7
;
n
5
=
12
;
n
6
=
20...
n_0 = 0\\ n_1 = 1\\ n_2 = 2\\ 那么可以推出:\\ n_h = n_{h-1} + n_{h-2} + 1\\ 含义为:左子树的最少结点数 + 右子树的最少结点数 + 根节点\\ 那么就有:\\ n_3=4;n_4=7;n_5=12;n_6=20...
n0=0n1=1n2=2那么可以推出:nh=nh−1+nh−2+1含义为:左子树的最少结点数+右子树的最少结点数+根节点那么就有:n3=4;n4=7;n5=12;n6=20...
那么,如果知道了结点数 n,就可以推断出整棵树的最大高度 h。
eg: 这棵树有n=9个结点,求最大高度h.
那么因为 n 4 = 7 ; n 5 = 12 n_4=7;n_5=12 n4=7;n5=12,而 7 < 9 < 12.
想要高度为 5,至少需要12个结点,所以这棵树的最大高度 h 为4.
那么知道高度了,就能够得到时间复杂度O(4)。
可以证明含有 n 个结点的平衡二叉树的最大深度为 O(log2 n),平衡二叉树的平均查找长度为 O(log2 n)。
2.6删除
平衡二叉树的删除操作删除结点后,也要保持二叉排序树的特性不变(左<中<右)。若删除结点导致不平衡,则需要调整平衡。
平衡二叉树的删除操作具体步骤:
-
删除结点 (方法同“二叉排序树”);
- 叶子结点:直接删除。
- 仅有左或右子树的结点:删除后,让被删除结点的**直接后继(子树)**接替它的位置。
- 左右子树都有的结点:删除后,用右子树的最小结点,即右子树的中序第一个子女来接替它的位置,然后再删除这个最小的子女。
-
一路向上找到最小不平衡子树,找不到就说明平衡,完结撒花return;
-
找最小不平衡子树下,高度最高的儿子、孙子;
-
根据孙子的位置,调整平衡(LL/RR/LR/RL);
- 孙子在LL:儿子右单旋。
- 孙子在RR:儿子左单旋。
- 孙子在LR:孙子先左旋,再右旋。
- 孙子在RL:孙子先右旋,再左旋。
-
如果还不平衡向上传导,继续②。
对最小不平衡子树的旋转可能导致树变矮,从而导致上层祖先不平衡(不平衡的向上传递)
3.红黑树RBT
红黑树(RBT,Red-Black Tree)
BST | AVL Tree | Red-Black Tree | |
---|---|---|---|
诞生日 | 1960 | 1962 | 1972 |
Search查的时间复杂度 | O(n) | O(log2 n) | O(log2 n) |
Insert插的时间复杂度 | O(n) | O(log2 n) | O(log2 n) |
Delete删的时间复杂度 | O(n) | O(log2 n) | O(log2 n) |
平衡二叉树AVL的不足:插入/删除很容易破坏"平衡”特性,需要频繁调整树的形态。例如插入操作导致不平衡,则需要先计算平衡因子,找到最小不平衡予树(时间开销大),再进行LL/RR/LR/RL调整。
所以提出红黑树RBT:插入/删除很多时候不会破坏“红黑”特性,无需频繁调整树的形态。即便需要调整,一般都可以在常数级时间内完成。
AVL适用于以查为主、很少插入/删除的场景。
RBT适用于频繁插入、删除的场景,实用性更强。
3.1定义
红黑树也是一种特殊的二叉排序树,满足左子树 < 根节点 < 右子树。
红黑树的特性:
-
每个结点或是红色,或是黑色的。
-
根节点是黑色的。
-
叶结点(不是叶子结点,是外部结点、NULL结点、失败结点)均是黑色的;
叶节点是外部节点,那么对应的根节点和非根节点就是内部节点。
-
不存在两个相邻的红结点(即红结点的父节点和孩子结点均是黑色)(黑红交替)。
-
对每个结点,从该节点到任一叶结点的简单路径(只能向下)上,所含黑结点的数目相同。
结点的==黑高 bh==:从某结点出发(不含该结点)到达任一空叶结点的路径上黑结点总数。
思考:根节点黑高为h的红黑树,内部结点数(关键字)至少有多少个?
回答:内部结点数最少的情况――总共h层黑结点(满树形态)。
【结论】若根节点黑高为 h,内部结点数(关键字)最少有 2 h − 1 2^h-1 2h−1 个。
【顺口溜】
- 左根右:左子树 < 根节点 < 右子树。
- 根叶黑:根节点和叶节点是黑色。
- 不红红:不存在两个相邻的红色结点(黑红交替)。
- 黑路同:从任意节点到任一叶结点的简单路径(只能向下)上,所含黑结点的数目相同。
由定义推导出的性质:
- 性质1:从根节点到叶结点的最长路径不大于最短路径的2倍。(特性4/5推得)
而AVL树的要求是左右子树高度差不超过1,比RBT的2倍要求更严格,所以AVL经常不平衡,而RBT则没有,所以插入和删除更高效。
- 性质2:有n个内部节点的红黑树高度 h ≤ 2 l o g 2 ( n + 1 ) h≤2log_2(n+1) h≤2log2(n+1) 。
性质2推导:红黑树查找操作时间复杂度 O ( l o g 2 n ) O(log_2n) O(log2n),查找效率与AVL树同等数量级。
3.2存储结构
struct RBnode { // 红黑树的结点定义
int key; // 关键字的值
RBnode* parent; // 父节点指针
RBnode* 1child,rchild; // 左右孩子指针
int color; // 结点颜色,如:可用表示黑/红也可使用枚举型enum表示顏色
};
3.3查找
与 BST、AVL 相同,从根出发,左小右大,若查找到一个空叶节点,则查找失败。
❗3.4插入
可视化网站:https://www.cs.usfca.edu/~galles/visualization/RedBlack.html
步骤:
step1:先查找,确定插入位置(原理同二叉排序树),插入新结点。
- 新结点是根――染为黑色。
- 新结点非根――染为红色。
step2:插入之后:
-
若插入新结点后依然满足红黑树定义,则插入结束。
-
若插入新结点后不满足红黑树定义(破坏了特性),需要调整(看新结点的叔叔(父结点的兄弟)的颜色),使其重新满足红黑树定义
【注意】这里的染色都是染成相反颜色。
- 黑叔:旋转+染色:
- LL型:右单旋,父替爷 + 父爷染色。
- RR型:左单旋,父替爷 + 父爷染色。
- LR型:左、右双旋,儿替爷 + 儿爷染色。
- RL型:右、左双旋,儿替爷 + 儿爷染色。
- 红叔:染色+变新:
- 叔父爷染色,爷变为新结点(那么就再判断新结点是不是根节点,要不要变黑)
- 黑叔:旋转+染色:
例子:
从一棵空的红黑树开始,插入:20,10,5,30,40,57,3,2,4,35,25,18,22,23,24,19,18。
到插入5时候,发生不平衡(红红相连),进行对**黑叔 + LL**的操作:
到插入30时候,发生不平衡(红红相连),进行对 红叔 的操作:
到插入40时候,发生不平衡(红红相连),进行对**黑叔 + RR** 的操作:
到插入57时候,发生不平衡(红红相连),进行对 红叔 的操作:
…省略简单重复操作
3.5删除
- 红黑树删除操作的时间复杂度 O(log2 n)。
- 在红黑树中删除结点的处理方式和“二叉排序树的删除”(3种情况)一样
- 按②删除结点后,可能破坏“红黑树特性”,此时需要调整结点颜色、位置,使其再次满足“红黑树特性”。
4.多路查找树
前面提到的二叉排序树,关键点在于2叉,也就是说把一棵树的分叉通过判断是否大于该节点,分出了两个子树。那么如果一个节点有不止一个元素呢?
多路查找树(muitl-way search tree), 其每一个结点的孩子数可以多于两个,且每一个结点处可以存储多个元素。由于它是查找树,所有元素之间存在某种特定的排序关系。
在这里,每一个结点可以存储多少个元素,以及它的孩子数的多少是非常关键的。常见的有4种特殊形式:2-3树、2-3-4树、B树和B+树。这里主要介绍B树和B+树,因为2-3树、2-3-4树都是B树的特例。
如下图所示是一颗2-3树:
4.1 B树
4.1.1 定义
B树,又称多路平衡查找树,B树中所有结点的孩子个数的最大值称为B树的阶,通常用 m 表示。
【注意】B树是所有结点的平衡因子均等于0的多路平衡查找树。
一棵m阶B树或为空树,或为满足如下特性的m叉树:
-
树中每个结点至多有 m 棵子树,即至多含有 m-1 个关键字。
-
若根结点不是终端结点,则至少有 2 棵子树。
-
除根结点外的所有非叶结点至少有 ⌈ m / 2 ⌉ \lceil m/2\rceil ⌈m/2⌉ 棵子树,即至少含有 ⌈ m / 2 ⌉ − 1 \lceil m/2\rceil-1 ⌈m/2⌉−1 个关键字。
-
所有非叶结点(关键字)结构如下:
n | P0 | K1 | P1 | K2 | P2 | … | Kn | Pn |
---|
其中:
Ki(i = 1,2,…n)为结点的关键字,且满足K1<K2< …<Kn。
Pi(i = 0,1,…n)为指向子树根结点的指针,且指针 Pi-1 所指子树中所有结点的关键字均小于 Ki,Pi 所指子树中所有结点的关键字均大于Ki 。(即符合二叉排序树的左小右大)。
n ( ⌈ m / 2 ⌉ − 1 ≤ n ≤ m − 1 ) (\lceil m/2\rceil-1≤ n ≤m-1) (⌈m/2⌉−1≤n≤m−1) 为结点中关键字的个数。
- 所有的叶结点都出现在同一层次上,并且不带信息(可以视为外部结点或类似于折半查找判定树的查找失败结点,实际上这些结点不存在,指向这些结点的指针为空)。
4.1.1(1)五叉查找树
最少1个关键字,2个分叉。最多4个关键字,5个分叉。
结点内关键字有序。
因为有序,所以可以使用折半查找。
下图所示的B树中所有结点的最大孩子数m=5,因此它是一棵5阶B树,在 m 阶B树中结点最多可以有 m 个孩子。
可以借助该实例来分析上述性质:
- 每一个节点的孩子个数 = 关键字个数 + 1 (每一个空隙都存在一个分支)。
- 如果根结点没有关键字就没有子树,此时B树为空;如果根结点有关键字,则其子树必然大于等于两棵,因为子树个数等于关键字个数加1。
- 除根结点外的所有非终端结点至少有 ⌈ m / 2 ⌉ = ⌈ 5 / 2 ⌉ = 3 \lceil m/2\rceil=\lceil 5/2\rceil=3 ⌈m/2⌉=⌈5/2⌉=3 棵子树(即至少有$ \lceil m/2\rceil-1=\lceil 5/2\rceil-1=2 $个关键字),至多有5棵子树(即至多有4个关键字)。
- 结点中关键字从左到右递增有序,关键字两侧均有指向子树的指针,左边指针所指子树的所有关键字均小于该关键字,右边指针所指子树的所有关键字均大于该关键字。或者看成下层结点关键字总是落在由上层结点关键字所划分的区间内,如第二层最左结点的关键字划分成了3个区间:(-∞,5), (5,11), (11,+∞),该结点3个指针所指子树的关键字均落在这3个区间内。
- 所有叶结点(外部节点)均在第4层,代表查找失败的位置。
4.1.2 B树与磁盘存取
B树中的大部分操作所需的磁盘存取次数与B树的高度成正比。
我们的外存,比如硬盘, 是将所有的信息分割成相等大小的页面,每次硬盘读写的都是一个或多个完整的页面,对于一个硬盘来说,一页的长度可能是211到214个字节。
在一个典型的B树应用中,要处理的硬盘数据量很大,因此无法一次全部装入内存。因此我们会对B树进行调整,使得B树的阶数(或结点的元素)与硬盘存储的页面大小相匹配。比如说一棵B树的阶为1001 (即1个结点包含1000个关键字),高度为2,它可以储存超过10亿个关键字,我们只要让根结点持久地保留在内存中,那么在这棵树上,寻找某一个关键字至多需要两次硬盘的读取即可。
通过这种方式,在有限内存的情况下,每一次磁盘的访问我们都可以获得最大数量的数据。由于B树每结点可以具有比二叉树多得多的元素,所以与二叉树的操作不同,它们减少了必须访问结点和数据块的数量,从而提高了性能。可以说,B树的数据结构就是为内外存的数据交互准备的。
4.1.3 查找
在B树上进行查找与二叉查找树很相似,只是每个结点都是多个关键字的有序表,在每个结点上所做的不是两路分支决定,而是根据该结点的子树所做的多路分支决定。
B树的查找包含2个基本操作:
- 在B树中找结点;
- 在结点内找关键字。
在B树上查找到某个结点后,先在有序表中进行查找,若找到则查找成功,否则按照对应的指针信息到所指的子树中去查找。
如何保证查找效率?
若每个结点内关键字太少,导致树变高,要查更多层结点,效率低。
策略1:(可以借助性质第3条)
除根结点外的所有非终端结点:(m=5)
- 至少有 ⌈ m / 2 ⌉ = ⌈ 5 / 2 ⌉ = 3 \lceil m/2\rceil=\lceil 5/2\rceil=3 ⌈m/2⌉=⌈5/2⌉=3 棵子树(向上取整),至多有5棵子树。
- 至少有 ⌈ m / 2 ⌉ − 1 = ⌈ 5 / 2 ⌉ − 1 = 2 \lceil m/2\rceil-1=\lceil 5/2\rceil-1=2 ⌈m/2⌉−1=⌈5/2⌉−1=2 个关键字,至多有m-1=4个关键字。
为什么除根节点呢?
因为如果整个树只有1个元素,根节点只有两个分叉。
但是下面整棵树虽然满足上面的条件,仍不够平衡:
策略2:(性质第5条)
m叉查找树中,规定对于任何一个结点,其所有子树的高度都要相同。
含n个关键字的m阶B树,最小高度、最大高度是多少?
【注意】大部分学校算B树的高度不包括叶子结点(失败结点)
- 最小高度
- 最大高度
思路1:
n个关键字将数域切分为 n+1 个区间。
思路2:
4.1.4 插入
与二叉查找树的插入操作相比,B树的插入操作要复杂得多。在二叉査找树中,仅需査找到需插入的终端结点的位置。但是,在B树中找到插入的位置后,并不能简单地将其添加到终端结点中,因为此时可能会导致整棵树不再满足B树定义中的要求。
将关键字key插入B树的过程如下:
-
定位。利用前述的B树査找算法,找出插入该关键字的最低层中的某个非叶结点(在B树中查找key时,会找到表示查找失败的叶结点,这样就确定了最底层非叶结点的插入位置)。
【注意】插入位置一定是最低层中的某个非叶结点。
-
插入。在B树中,每个非失败结点的关键字个数都在区间 [ ⌈ m / 2 ⌉ − 1 , m − 1 ] 内。插入后检查被插入结点内关键字的个数:
- 插入后的结点关键字个数小于 m - 1,可以直接插入;
- 插入后的结点关键字个数大于 m − 1 时,必须对结点进行分裂(从中间 ⌈ m / 2 ⌉ )。
分裂:
这里再插入,不会插入到49后面,而是38或者80后面。
直到下面子树节点满了,再分裂把 ⌈ m / 2 ⌉ 提到父结点的后面。
eg.
4.1.5 删除
B树中的删除操作与插入操作类似,但要更复杂一些,即要使得删除后的结点中的 关键字个数 ≥ ⌈ m / 2 ⌉ − 1 ,因此将涉及结点的“合并”问题。
- 被删关键字 k 不是终端结点(最低层非叶结点)时,可以用 k 的前驱(或后继)来替替代 k,然后在相应的结点中删除 k。
-
被删关键字 k 在终端结点(最低层非叶结点)时,有下列三种情况:
-
删除之后关键字数满足B树条件(m-1 > n ≥ ⌈ m / 2 ⌉),直接删除关键字。若被删除关键字所在结点的 关键字个数 ≥ ⌈ m / 2 ⌉,表明删除该关键字后仍满足B树的定义,则直接删去该关键字。
-
删除之后关键字数量低于下限:
-
兄弟够借。若被删除关键字所在结点删除前的 关键字个数 = ⌈ m / 2 ⌉ − 1,且与此结点相邻的右(或左)兄弟结点的 关键字个数 ≥ ⌈ m / 2 ⌉,则需要调整该结点、右(或左)兄弟结点及其双亲结点(父子换位法),以达到新的平衡。
在图(a)中删除B树的关键字65,右兄弟 关键字个数≥ ⌈ m / 2 ⌉ = 2,将71取代原65的位置,将74调整到71的位置。
-
兄弟不够借,合并。若被删除关键字所在结点删除前的 关键字个数 = ⌈ m / 2 ⌉ − 1,且此时与该结点相邻的左、右兄弟结点的 关键字个数均 = ⌈ m / 2 ⌉ − 1,则将关键字删除后与左(或右)兄弟结点及双亲结点中的关键字进行合并。
在图(b)中删除B树的关键字5,它及其右兄弟结点的 关键字个数= ⌈ m / 2 ⌉ − 1=1,故在5删除后将60合并到65结点中。
-
-
在合并过程中,双亲结点中的关键字个数会减1。若其双亲结点是根结点且关键字个数减少至0(根结点关键字个数为1时,有2棵子树),则直接将根结点删除,合并后的新结点成为根;若双亲结点不是根结点,且关键字个数减少到 ⌈ m / 2 ⌉ − 2 ,则又要与它自己的兄弟结点进行调整或合并操作,并重复上述步骤,直至符合B树的要求为止。
4.2 B+树
B+树是应文件系统(比如数据库)所需而出现的一种B树的变形树。
m 阶的B+树与 m 阶的B树的主要差异如下:
-
有n棵子树的结点中有n个关键字。
- 在B+树中,每个结点(非根内部结点)的关键字个数 n 的范围是 ⌈ m / 2 ⌉ ≤ n ≤ m(根结点:1 ≤ n ≤ m);
- 在B树中,每个结点(非根内部结点)的关键字个数 n 范围是 ⌈ m / 2 ⌉ − 1 ≤ n ≤ m − 1(根结点:1 ≤ n ≤ m − 1)。
-
所有的叶子结点包含全部关键字的信息,及指向含这些关键字记录的指针,叶子结点本身依关键字的大小自小而大顺序链接。
所以支持顺序查找
-
所有分支结点可以看成是索引,结点中仅含有其子树中的最大(或最小)关键字。
B+树的结构特别适合带有范围的查找。比如查找我们学校18~22岁的学生人数,我们可以通过从根结点出发找到第一个18岁的学生,然后再在叶子结点按顺序查找到符合范围的所有记录。
B+树的插入、删除过程也都与B树类似,只不过插入和删除的元素都是在叶子结点上进行而已。