前言
写这边文章主要是我查阅网上资料学习B+树的时候,发现文章都是那种教材风格。就是开头先说一大通原理,性质特点,但是看上去又非常地晦涩;接下来的算法方法实现又难以看懂。不知道是本人的水平不到还是作者的写作能力有待提升。在我苦心研究和实操下,终于理解了B+树的原理和实现,其实很好理解,真不明白网上的文章是怎么了。这里我想以一种简单的方式来阐述,希望以后忘记了也能快速回顾。
本文源码来于一个开放的工程,在最后。
B+树的重要概念
1.B+树由Node连接而成,每一个Node中最多具有M-1个Entry。(M为树的阶数)
2.Entry具有两种类型:指针Entry和存储Entry。Entry具有三种属性:key,value和node。
key 组织B+树的值,Node怎么连接,Entry在Node里怎么组织都靠Key…
value 真正需要保存的对象。
举个例子:数据库中有一个User的表。我使用了B+树作为索引的生成方式,并且选择了User中的id属性作为索引生成的依据。那么key就是id,User的一行数据就是value。如果我使用id来检索User,那么就是使用对应的key来快速检索B+树,从而找到value,对应的User。
node 指向下一个Node的引用,用指针来描述更为贴切,以下使用指针操作来形容对它的操作。
指针Entry具有key和node的值,value为null。存储Entry具有key和value的值,node为null。
3.树的深度h。B+树的叶子Node里都是存储Entry,其他Node里都是指针Entry。而所有叶子Node都具有相同的深度,即树的深度也是所有叶子的深度,可以使用深度在程序中辨别是否叶子Node。
举例说明B+树的重要方法
为了可以进行简单的说明,我们使用Interger对象来作为Entry的key,不使用value属性,使用node是否为null来区别指针Entry和存储Entry。M=4。
生成B+树的数据是一个Integer数组:[1,3,2,6,4,7,8,10]。
插入1,3,2
在插入这三个数据前,B+树只有一个没有任何Entry的root Node。而插入这三个数据时,root Node都没有超过M-1个Entry的限制,并且root Node目前是叶子Node。这时只需要保证Node中的Entry按照升序排列即可。
插入6
插入数据6后,首先保证Entry的排序。
然后,发现Entry数量已经超过了M-1,这时需要对这个Node进行分裂。大刀劈向存储的所有Entry,前一半Entry留在原来的Node,后一半Entry新建一个Node u来存放(M为奇数建议:前一半Entry的数量=后一半Entry的数量+1,当然我只是建议)。
原来的Node不做操作,Node u交给上一级Node处理。交给上一级Node处理分为两种处理:没上一级和有上一级。
由于分裂的是root Node,所以处理是没上一级的处理。
没上一级的处理的步骤
1)新建一个Node t
2)新建一个Entry,key为root Node第一个Entry的key,node指向root Node。Entry放入Node t中。
3)新建一个Entry,key为Node u第一个Entry的key,node指向Node u。Entry放入Node t中。确保Entry的升序排列。
4)指定Node t为root Node。(root = t)
5)h++。
可以看到,目前树的具有两层Node,所以深度h = 1。
插入4
一方面,插入数据前首先需要进行辨别工作的,辨别工作分为两种:没下一级和有下一级。之前的插入都发生在root Node,因此是没下一级的辨别:
按照数字的大小找到自己的位置插入即可。
也就是说,插入前首先要知道插入的Node是否具有下一级。而所有的数据都需要从root Node进行插入。因此,在插入数据4时,因为root Node已经具有了下一级,所以发生在root Node的辨别是有下一级的辨别:
1)在所有Entry中找到key比自己大的。
2)找得到,那就进入该Entry的上一个Entry的next指向的Node中继续进行辨别工作。(这就是递归)
3)找不到,那就进入key最大的Entry的Node中继续进行辨别工作。
因此,只有到了没下一级Node中,也就是叶子Node中,数据才会真正地插入。
数据4首先在root Node中找不到比自己key大的Entry,因此进入了key == 3的Entry指向的Node中。
Node是叶子Node,所以它只需要找到自己的位置插入。
插入7
插入的步骤和插入4的相同,不做赘述。
可以看到,插入7的Node的Entry数量超载了,因此需要进行分裂操作。分裂操作不做赘述。
交给上一级Node处理中,该Node是有上一级的,所以处理步骤如下:
1)找到Node的上一级Node(递归,每一个方法都表明所处在的节点,分裂出来的Node交给上一级方法处理。后面会进行详尽的总结,这里留个念头即可,不深究)。
2)新建一个Entry,key是Node的第一个key,node指向Node,并将它放入上一级Node中,确保Entry的升序。
现在,root Node具有三个子Node了。所有的处理已经简述完,如果你已经可以推理具有4个子Node的情况,直接跳到总结。
插入8,10
数据8,10将会插入到6,7所在的Node中。
该Node超载了,分裂为两个Node,root Node纳入一个新Entry,key为新Node的第一个Entry的key,node指向新Node。
此时,root Node超载了,root Node也分裂为两个Node。新建一个Node t,分别纳入两个Node的第一个Entry的key,并分别指向两个Node,然后t称为新的root Node。至此,插入工作完成。
这里强调一点,不是每个Node最多容纳两个Entry,这是数据插入设置有点特殊而已,如果再插入一个数据5,那么它将进入3,4所在的Node,树的结构不发生改变。
总结
可以将插入一个数据的操作用以下形式表示:
首先需要辨别:
有下一级–>有下一级–>有下一级–>…–>没下一级
如果插入后Node超载了,首先分裂,然后处理是:
有上一级–>有上一级–>有上一级–>…–>没上一级
处理不同于辨别的地方,处理可以停止于某一个阶段。
辨别和处理都需要知道目前的层数才能进行区别处理,因此在实际方法实现中,需要增加一个参数,剩余层数ht。
当方法处于root Node时,ht == 树的深度。进行下一层的Node时,ht–。当处于叶子Node时,ht == 0。
辨别方法需要三个参数:当前所处的Node,插入数据的key和value,ht。返回数据为分裂得到的新Node(可能为null)。首先确定目前是否是叶子Node,如果是,新建Entry,放入key和value,并根据key确定Entry的位置即可。如果不是,按照有下一级的处理方法再次调用辨别方法,ht–。直到数据到达叶子Node,进行没下一级的辨别结束。
辨别的最后都需要进行分裂的检测,如果发生分裂,那么就会进行处理。
在辨别途中,必然发生了数据插入(可能多于一次,其中一次必然为存储Entry的插入,其他为指针Entry的插入)。如果Node发生了超载,那么分裂Node,新Node交给上一级辨别处理,这就是返回值。
如果下一级Node没有传给上一级Node一个新Node,那么返回null,这样,其余的上级都会直接返回null。
如果下一级Node传给上一级Node一个新Node,那么上一级Node需要新建一个Entry指向这个新Node,并存入上一级Node中。如果上一级Node也超载了,那么上一级Node也分裂,传一个新Node给上上一级Node处理,如此往复,直到某个上级Node没有发生超载,或者超载发生在root Node中,从而进行没上一级的处理后结束。
尾声
树操作应该包含增删查,删简单,查是否没说?如果你真的熟悉了增操作,那么你应该已经知道如何查了,你可以试一下自己写一个查操作,验证自己是否熟悉了增。
源码
public class BTree<Key extends Comparable<Key>, Value> {
// max children per B-tree node = M-1
// (must be even and greater than 2)
private static final int M = 4;
private Node root; // root of the B-tree
private int height; // height of the B-tree
private int n; // number of key-value pairs in the B-tree
// helper B-tree node data type
private static final class Node {
private int m; // number of children
private Entry[] children