一、B-树(B树)
1、B树的定义
注意: B树就是B-树,"-"是个连字符号,不是减号 。
B树也称B-树,它是一颗多路平衡查找树。我们描述一颗B树时需要指定它的阶数,阶数表示了B-树中所有结点中孩子结点个数的最大值,一般用字母m表示阶数。当m取2时,就是我们常见的二叉搜索树。
关键字:(每个结点中存储了关键字(key)和关键字对应的数据(data),以及孩子结点的指针。以关键字代表整体的key-value键值对)
B树是一种平衡的多分树,通常我们说m阶的B树,它必须满足如下条件:
- 每个结点最多只有m个子结点。
- 每个非叶子结点(除了根)具有至少⌈ m/2⌉子结点。
- 如果根不是叶结点,则根至少有两个子结点。
- 具有n个子结点的非叶结点包含n -1个键。
- n-1个关键字按照递增顺序排列
- 下层结点内的关键字取值总是落在由上层结点关键字所划分的区间内
- 所有叶子都出现在同一水平,没有任何信息(高度一致)。
总结m阶B树的属性如下:
-
B -树中每个结点最多有m个子结点(孩子);根结点(非叶时)至少有2个子结点,非根非叶结点,至少有Math.ceil(m/2)个子结点。(Math.ceil代表向上取整)
-
所有叶子结点都位于同一层,或者说根结点到每个叶子结点的长度都相同。
-
如果一个非叶结点有n-1个关键字,那么该结点有n个子结点。这n-1个关键字按照递增顺序排列,且位于父结点的两个关键字之间。(如下图的【13,15】结点内关键字递增排列,且整体位于父结点的12与22关键字范围内)
-
根据关键字与子结点树的对应关系:每个结点最多m-1个关键字;根结点至少1个关键字,非根非叶结点,至少Math.ceil(m/2)-1 个关键字
上图是一颗阶数为4的B树。(图中虽然没有包含四个孩子的结点,但是实际如【12,22】结点能存储3个关键字,可以有4个子结点,所以最大子结点数为4,阶数为4)
在实际应用中的B树的阶数m都非常大(通常大于100),所以即使存储大量的数据,B树的高度仍然比较小。每个结点中存储了关键字(key)和关键字对应的数据(data),以及孩子结点的指针。我们将一个key和其对应的data称为一个记录。但为了方便描述,除非特别说明,后续文中就用key来代替(key, value)键值对这个整体。在数据库中我们将B树(和B+树)作为索引结构,可以加快查询速速,此时B树中的key就表示键,而data表示了这个键对应的条目在硬盘上的逻辑地址。
在B树上执行某些操作时,B树的任何属性都可能违反结点可以拥有的最小子结点数。 为了维护B 树的属性,树可能会分裂或连接。
2、B树的查找
B-树的查找很简单,是二叉排序树的扩展,二叉排序树是二路查找,B-树是多路查找,因为B-树结点内的关键字是有序的,首先从根结点进行二分查找,如果找到则返回对应结点的data,否则对相应区间的指针指向的结点递归进行查找,直到找到结点或找到null指针,前者查找成功,后者查找失败。B-树的具体查找如下(假设查找的关键字为key):
1)先让key与根结点中的关键字比较,如果key等于keys[i](k[]为结点内的关键字数组),则查找成功
2)若key<keys[1],则到point[1]所指示的子树中进行继续查找(point[]为结点内的指针数组,指针指向孩子结点),这里要注意B-树中每个结点的内部结构。–递归
3)若key>keys[n],则道point[n+1]所指示的子树中继续查找。–递归
4)若keys[i]<key<keys[i+1],则沿着指针point[i+1]所指示的子树继续查找。–递归
5)如果最后遇到空指针,则证明查找不成功。
注意:以上任务k数组与point数组下标均从0开始
B-树中一个结点的定义
int *keys; // 存储关键字的数组
int m; // 最小度 (定义一个结点包含关键字的个数 m-1 <= num <= 2m -1)
BTreeNode **point; // 存储孩子结点指针的数组
int n; // 记录当前结点包含的关键字的个数
bool leaf; // 叶子结点的一个标记,如果是叶子结点则为true,否则false
伪代码如下:
BTree_Search(node, key) {
if(node == null) return null;
foreach(node.keys)
{
if(node.keys[i] == key) return node.data[i];
if(node.keys[i] > key) return BTree_Search(point[i]->node,key);//因为关键字递增排序,找到第一个比要查找的key大的key值,进入其左边指针指向的孩子结点继续遍历
}
return BTree_Search(point[i+1]->node,key);//若未找到比要查找的key大的值,则进入最后一个key右边的指针指向的关键字
}
data = BTree_Search(root, my_key);
3、 B树的插入操作
插入操作是指插入一条记录,即(key, value)的键值对。如果B树中已存在需要插入的键值对,则用需要插入的value替换旧的value。若B树不存在这个key,则一定是在叶子结点中进行插入操作。
1)根据要插入的key的值,找到叶子结点并插入。
2)判断当前结点的关键字个数是否小于等于m-1,若满足则结束,否则进行第3步。
3)以结点中间的key为中心分裂成左右两部分,然后将这个中间的key插入到父结点中,这个key的左子树指向分裂后的左半部分,这个key的右子支指向分裂后的右半部分,然后将当前结点指向父结点,继续进行第3步。
下面以 5 阶B树 (m=5)为例,介绍B树的插入操作,在5阶B树中,结点最多有4个key,最少有2个key
a)在空树中插入39
此时根结点就一个key,此时根结点也是叶子结点
b)继续插入22,97和41
根结点此时有4个key
c)继续插入53
插入后超过了最大允许的关键字个数4,所以以结点中所有关键字的中间key值41为中心进行分裂,结果如下图所示,分裂后当前结点指针指向父结点,满足B树条件,插入操作结束。当阶数m为偶数时,需要分裂时就不存在排序恰好在中间的key,那么我们选择中间位置的前一个key或中间位置的后一个key为中心进行分裂即可。
d)依次插入13,21,40,同样会造成分裂,结果如下图所示。
【13,21,22,39,40】5个关键字,超过允许的最大关键字数4,故要分裂
以中间的关键字22为中心进行分裂:
-
中间的key=22插入到父结点中
-
key=22的左子树指向原来的左半部分【13,21】
-
key=22的右子树指向原来的右半部分【39,40】
e)依次插入30,27, 33 ;36,35,34 ;24,29,结果如下图所示。
插入30,27, 33
【27,30,33,39,40】5个关键字,超过允许的最大关键字数4,故要分裂
以中间的关键字33为中心进行分裂:
- 中间的key=33插入到父结点中
- key=33的左子树指向原来的左半部分【27,30】
- key=33的右子树指向原来的右半部分【39,40】
插入36,35,34
【34,35,36,39,40】5个关键字,超过允许的最大关键字数4,故要分裂
以中间的关键字36为中心进行分裂:
- 中间的key=36插入到父结点中
- key=36的左子树指向原来的左半部分【34,35】
- key=36的右子树指向原来的右半部分【39,40】
插入24,29
f)插入key值为26的记录,插入后的结果如下图所示。
当前结点需要以27为中心分裂,并向父结点进位27,然后当前结点指向父结点,结果如下图所示。
进位后导致当前结点(即根结点)也需要分裂,分裂的结果如下图所示。
分裂后当前结点指向新的根,此时无需调整。
g)最后再依次插入key为17,28,29,31,32的记录,结果如下图所示。
在实现B树的代码中,为了使代码编写更加容易,我们可以将结点中存储记录的数组长度定义为m而非m-1,这样方便底层的结点由于分裂向上层插入一个记录时,上层有多余的位置存储这个记录。同时,每个结点还可以存储它的父结点的引用,这样就不必编写递归程序。
一般来说,对于确定的m和确定类型的记录,结点大小是固定的,无论它实际存储了多少个记录。但是分配固定结点大小的方法会存在浪费的情况,比如key为28,29所在的结点,还有2个key的位置没有使用,但是已经不可能继续在插入任何值了,因为这个结点的前序key是27,后继key是30,所有整数值都用完了。所以如果记录先按key的大小排好序,再插入到B树中,结点的使用率就会很低,最差情况下使用率仅为50%。
4、B树的删除操作
删除操作是指,根据key删除记录,如果B树中的记录中不存对应key的记录,则删除失败。
1)如果当前需要删除的key位于非叶子结点上,则用后继key(这里的后继key均指后继记录的意思)覆盖要删除的key,然后在后继key所在的子支中删除该后继key。此时后继key一定位于叶子结点上,这个过程和二叉搜索树删除结点的方式类似。删除这个记录后执行第2步
2)该结点key个数大于等于Math.ceil(m/2)-1,结束删除操作,否则执行第3步。
3)如果兄弟结点key个数大于Math.ceil(m/2)-1,则父结点中的key下移到该结点,兄弟结点中的一个key上移,删除操作结束。
否则,将父结点中的key下移与当前结点及它的兄弟结点中的key合并,形成一个新的结点。原父结点中的key的两个孩子指针就变成了一个孩子指针,指向这个新结点。然后当前结点的指针指向父结点,重复上第2步。
有些结点它可能即有左兄弟,又有右兄弟,那么我们任意选择一个兄弟结点进行操作即可。
下面以 5阶B树 (m=5) 为例,介绍B树的删除操作,5阶B树中,结点最多有m-1=4个key,最少有ceil(m/2)-1=2个key
a)原始状态
b)在上面的B树中删除21,删除后结点中的关键字个数仍然大于等2,所以删除结束。
- 删除的是叶子结点,无需补位替代
- 删除关键字后叶子结点中关键字个数满足最小关键字数要求,故直接删除即可。
c)在上述情况下接着删除27。从上图可知27位于非叶子结点中
- 刪除key=27是非叶子结点,要用27的后继元素key=28替代覆盖(补位),然后在28(原27)的右孩子结点中删除28。删除后的结果如下图所示。
- 删除后发现,当前叶子结点的记录的个数小于2,而它的兄弟结点中有3个记录(> ceil(m/2)-1)(当前结点还有一个右兄弟,选择右兄弟就会出现合并结点的情况,不论选哪一个都行,只是最后B树的形态会不一样而已),我们可以从左兄弟结点中借取一个key。所以父结点中的28下移,兄弟结点中的26上移,删除结束。结果如下图所示。
d)在上述情况下接着删除32,结果如下图。
- 删除的是叶子结点,无需补位替代
- 删除关键字后叶子结点中关键字个数为1,不满足最小关键字数要求,需要合并操作
- 当删除后,当前结点中【31】只有1个key,而兄弟结点中也仅有2个key。所以只能让父结点中的key=30下移和这个两个孩子结点中的key合并,成为一个新的结点【28,29,30.31】,当前结点的指针指向(原来的)父结点的key=30。结果如下图所示。
当前结点key的个数满足条件,故删除结束。
e)上述情况下,我们接着删除key为40的记录,删除后结果如下图所示。
同理,当前结点【39】的记录数小于2,兄弟结点中没有多余key,所以父结点中的key下移,和兄弟(这里我们选择左兄弟,选择右兄弟也可以)结点合并【34,35,36,39】,合并后的指向当前结点的指针就指向了父结点。
同理,对于当前结点【41】而言,记录数小于2,兄弟结点【22,26】中没有多余key,所以父结点中的key=33下移,和兄弟结点合并【22,26,33,41】。最后结果如下所示。
合并后结点当前结点满足条件,删除结束。
二、B+树
1、B+树的定义
B+树是B树的一种变体,有着比B树更高的查询性能。
各种资料上B+树的定义各有不同,一种定义方式是关键字个数和孩子结点个数相同。这里我们采取维基百科上所定义的方式,即关键字个数比孩子结点个数小1,这种方式是和B树基本等价的。上图就是一颗阶数为4的B+树。
除此之外B+树还有以下的要求。
1)B+树包含2种类型的结点:内部结点(也称索引结点)和叶子结点。根结点本身即可以是内部结点,也可以是叶子结点。根结点的关键字个数最少可以只有1个。
2)B+树与B树最大的不同是内部结点不保存数据,只用于索引,所有数据(或者说记录)都保存在叶子结点中。
(每个父结点中的key值都出现在子结点中,是子结点中的元素最大值或最小值----即B+树中允许key值重复)
3) m阶B+树表示了内部结点最多有m-1个关键字(或者说内部结点最多有m个子树),阶数m同时限制了叶子结点最多存储m-1个记录。
4)内部结点中的key都按照从小到大的顺序排列,对于内部结点中的一个key,左树中的所有key都小于它,右子树中的key都大于等于它。叶子结点中的记录也按照key的大小排列。
5)每个叶子结点都存有相邻叶子结点的指针,叶子结点本身依关键字的大小自小而大顺序链接。
顺序访问指针:
一般在数据库系统或文件系统中使用的B+Tree结构都在经典B+Tree的基础上进行了优化,增加了顺序访问指针。
做这个优化的目的是为了提高区间访问的性能,只需顺着结点和指针顺序遍历就可以一次性访问到所有数据结点,极大提到了区间查询效率。
2、B+树的插入操作
1)若为空树,创建一个叶子结点,然后将记录插入其中,此时这个叶子结点也是根结点,插入操作结束。
2)针对叶子类型结点:
- 根据key值找到叶子结点,向这个叶子结点插入记录。
- 插入后,若当前结点key的个数小于等于m-1,则插入结束。
- 否则将这个叶子结点分裂成左右两个叶子结点,左叶子结点包含前m/2个记录,右结点包含剩下的记录,将第m/2+1个记录的key进位到父结点中(父结点一定是索引类型结点),进位到父结点的key左孩子指针向左结点,右孩子指针向右结点。将当前结点的指针指向父结点,然后执行第3步。--------key进位到父结点,在索引结点增加一个key索引(只有key,没有data),原始叶子结点中的key记录仍然存在(允许重复)
3)针对索引类型结点:
-
若当前结点key的个数小于等于m-1,则插入结束。
-
否则,将这个索引类型结点分裂成两个索引结点,左索引结点包含前(m-1)/2个key,右结点包含m-(m-1)/2个key,将第m/2个key进位到父结点中,进位到父结点的key左孩子指向左结点, 进位到父结点的key右孩子指向右结点。将当前结点的指针指向父结点,然后重复第3步。
下面是一颗 5阶B树 (m=5)的插入过程,5阶B数的结点最少2个key,最多4个key,最多有5个孩子结点。
a)空树中插入5
b)依次插入8,10,15
(注意叶子结点间的顺序链表关系)
c)插入16
- 插入16后超过了关键字的个数限制,所以要进行分裂。
- 在叶子结点分裂时,分裂出来的左结点2个记录,右边3个记录,中间key成为索引结点中的key
- 分裂后当前结点指向了父结点(根结点)。结果如下图所示。
当然我们还有另一种分裂方式,给左结点3个记录,右结点2个记录,此时索引结点中的key就变为15。
d)插入17
e)插入18,插入后如下图所示
- 当前结点的关键字个数大于5,进行分裂。
- 分裂成两个结点,左结点2个记录,右结点3个记录,
- 关键字16进位到父结点(索引类型)中,将当前结点的指针指向父结点。
当前结点的关键字个数满足条件,插入结束。
f)插入若干数据后
g)在上图中插入7,结果如下图所示
- 当前结点的关键字个数超过4,需要分裂。
- 左结点2个记录,右结点3个记录。分裂后关键字7进入到父结点中,将当前结点的指针指向父结点,结果如下图所示。
-
当前结点的关键字个数超过4,需要继续分裂。
-
左结点2个关键字,右结点2个关键字,关键字16进入到父结点中,将当前结点指向父结点,结果如下图所示。
注意:
- 索引结点中关键字分裂后,进位到父结点的关键字不保留
- 叶子结点中关键字分裂后,进位到父结点的关键字与记录仍然保留在叶子结点中
当前结点的关键字个数满足条件,插入结束。
3、B+树的删除操作
如果叶子结点中没有相应的key,则删除失败。否则执行下面的步骤
1)删除叶子结点中对应的key。删除后若结点的key的个数大于等于Math.ceil(m-1)/2 – 1,删除操作结束,否则执行第2步。
2)若兄弟结点key有富余(大于Math.ceil(m-1)/2 – 1),向兄弟结点借一个记录,同时用借到的key替换父结(指当前结点和兄弟结点共同的父结点)点中的key,删除结束。否则执行第3步。
3)若兄弟结点中没有富余的key,则当前结点和兄弟结点合并成一个新的叶子结点,并删除父结点中的key(父结点中的这个key两边的孩子指针就变成了一个指针,正好指向这个新的叶子结点),将当前结点指向父结点(必为索引结点),执行第4步(第4步以后的操作和B树就完全一样了,主要是为了更新索引结点)。
4)若索引结点的key的个数大于等于Math.ceil(m-1)/2 – 1,则删除操作结束。否则执行第5步
5)若兄弟结点有富余,父结点key下移,兄弟结点key上移,删除结束。否则执行第6步
6)当前结点和兄弟结点及父结点下移key合并成一个新的结点。将当前结点指向父结点,重复第4步。
注意,通过B+树的删除操作后,索引结点中存在的key,不一定在叶子结点中存在对应的记录。
下面是一颗5阶B树的删除过程,5阶B数的结点最少2个key,最多4个key。
a)初始状态
b)删除22,删除后结果如下图
删除后叶子结点中key的个数大于等于2,删除结束
c)删除15,删除后的结果如下图所示
- 删除后当前结点只有一个key,不满足条件,
- 而兄弟结点有三个key,可以从兄弟结点借一个关键字为9的记录,同时更新将父结点中的关键字由10也变为9,删除结束。
d)删除7,删除后的结果如下图所示
- 对于叶子结点:当前结点关键字个数小于2,
- (左)兄弟结点中的也没有富余的关键字(当前结点还有个右兄弟,不过选择任意一个进行分析就可以了,这里我们选择了左边的),
- 所以当前结点和兄弟结点合并,并删除父结点中的key,当前结点指向父结点。
三、B+树的优点与补充
1、B+树的优点
- 可以在相同数量的磁盘访问中获取记录。
- 树高度保持平衡,与B树相比较少。
- 可以顺序访问存储在B+树中的数据,也可以直接访问。
- 键用于索引。
- 更快的搜索查询,因为数据仅存储在叶结点上。
2、 B树与B+树比较
编号 | B树 | B+树 |
---|---|---|
1 | 搜索键无法重复存储。 | 可以存在冗余搜索键。 |
2 | 数据可以存储在叶结点以及内部结点中 | 数据只能存储在叶结点上。 |
3 | 搜索某些数据是一个较慢的过程,因为可以在内部结点和叶结点上找到数据。 | 搜索速度相对较快,因为只能在叶结点上找到数据。 |
4 | 删除内部结点非常复杂且耗时。 | 删除永远不会是一个复杂的过程,因为元素将始终从叶结点中删除。 |
5 | 叶结点不能链接在一起。 | 叶结点链接在一起以使搜索操作更有效。 |
6 | 范围查找用的是树的中序遍历 | 范围查找是在链表上遍历 |
3、为什么说B+树比B树更适合数据库索引?
1)B+树的磁盘读写代价更低
B+树的内部结点并没有指向关键字具体信息的指针。因此其内部结点相对B 树更小。如果把所有同一内部结点的关键字存放在同一盘块中,那么盘块所能容纳的关键字数量也越多。一次性读入内存中的需要查找的关键字也就越多。相对来说IO读写次数也就降低了;
2)B+树查询效率更加稳定
由于非终结点并不是最终指向文件内容的结点,而只是叶子结点中关键字的索引。所以任何关键字的查找必须走一条从根结点到叶子结点的路径(记录只存储在叶子结点)。所有关键字查询的路径长度相同,导致每一个数据的查询效率相当;
3)B+树便于范围查询(最重要的原因,范围查找是数据库的常态)
B树在提高了IO性能的同时并没有解决元素遍历的效率低下的问题,正是为了解决这个问题,B+树应用而生。B+树只需要去遍历叶子结点就可以实现整棵树的遍历。而且在数据库中基于范围的查询是非常频繁的,而B树不支持这样的操作或者说效率太低;不懂可以看看这篇解读-》范围查找
补充:B树的范围查找用的是中序遍历,而B+树用的是在链表上遍历;
4、深入理解B+树的查询
单元素查询
-
**单元素查询:**B+树会自顶向下逐层查找结点,最终找到匹配的叶子结点
例如,想要查找元素3
第一次磁盘IO:
第二次磁盘IO:
第三次磁盘IO:
虽然查询流程看起来与B树相同,但实际效率区别很大。
与B树的区别:
- B+树的中间结点只存储key值,做索引,不存储数据记录,故索引结点的大小小于正常B树的结点。所以同样大小的磁盘页可以容纳更多的结点元素。这就意味着数据量相同的情况下,B+树比B树更加“矮胖”,因此查询时I/O次数会更少。
- B+树的查询必须最终查找的叶子结点(数据记录只存储在叶子结点,索引结点不存数据);而B树只要匹配到元素即可,无论匹配元素处于中间结点还是叶子结点。
范围查询:
B树:
首先看B树的范围查询,只能依靠繁琐的中序遍历。
比如我们要查找的范围为3到11的元素
自顶向下,查找到范围的下限(3):
中序遍历到元素6:
中序遍历到元素8:
中序遍历到元素9:
中序遍历到元素11,遍历结束:
B+树:
同样范围为3到11的元素
自顶向下,查找到范围的下限(3):
通过链表指针,遍历到元素6, 8:
通过链表指针,遍历到元素9, 11,遍历结束:
- B+ 树通过遍历链表,比B树的中序遍历简单很多
B+树对比B树的优势总结:
- 单一节点存储更多的元素,使得查询的IO次数更少。
- 所有查询都要查找到叶子节点,查询性能稳定。
- 所有叶子节点形成有序链表,便于范围查询。