B+树原理分析及Java代码实现

今天我们分析B+树原理及Java代码实现,以前我写过一篇关于mysql 存储引擎B+Tree、 B-Tree和hash三种原理及区别,可以先参考

我们都知道B+树,是B树的一个变种,需要先明确一下B树的定义:

一、B/B+树的基本定义:

1、B 树可以看作是对2-3查找树的一种扩展,即他允许每个节点有M-1个子节点。

  • 根节点至少有两个子节点
  • 每个节点有M-1个key,并且以升序排列
  • 位于M-1和M key的子节点的值位于M-1 和M key对应的Value之间
  • 其它节点至少有M/2个子节点

下图是一个M=4 阶的B树:

可以看到B树是2-3树的一种扩展,他允许一个节点有多于2个的元素。

B树的插入及平衡化操作和2-3树很相似,这里就不介绍了。下面是往B树中依次插入

6 10 4 14 5 11 15 3 2 12 1 7 8 8 6 3 6 21 5 15 15 6 32 23 45 65 7 8 6 5 4

的演示动画:

2、B+树既然是对B树的一种变种树,它与B树的差异在于:

  • 有k个子结点的结点必然有k个关键码;
  • 非叶结点仅具有索引作用,跟记录有关的信息均存放在叶结点中。
  • 树的所有叶结点构成一个有序链表,可以按照关键码排序的次序遍历全部记录。

如下图,是一个M=4的B+树:

下图是B+树的插入动画:动画参考

3、B和B+树的区别在于,B+树的非叶子结点只包含导航索引信息,不包含实际的值(这样可以放更多的key索引),所有的叶子结点和相连的节点使用链表相连,便于区间查找和遍历。

4、B+ 树的优点在于:

  • 由于B+树在内部节点上不包含数据信息,因此在内存页中能够存放更多的key。 数据存放的更加紧密,具有更好的空间局部性。因此访问叶子节点上关联的数据也具有更好的缓存命中率。
  • B+树的叶子结点都是相链的,因此对整棵树的便利只需要一次线性遍历叶子结点即可。而且由于数据顺序排列并且相连,所以便于区间查找和搜索。而B树则需要进行每一层的递归遍历。相邻的元素可能在内存中不相邻,所以缓存命中性没有B+树好。

但是B树也有优点,其优点在于,由于B树的每一个节点都包含key和value,因此经常访问的元素可能离根节点更近,因此访问也更迅速。 

二、代码实现:我们先贴出代码,在逐个方法分析

1、核心代码:

/**
 * @author wanghuainan
 * @date 2021/4/14 19:05
 */
public class CatalogBTree<Key extends Comparable<Key>, Value> {

    //参数M 定义每个节点的链接数
    private static final int M = 4;

    private Node root;

    //树的高度  最底层为0 表示外部节点    根具有最大高度,此高度是根之后的高度
    private int height;

    //键值对总数
    private int n;

    //节点数据结构定义
    private static final class Node{
        //值的数量
        private int m;
        private Entry[] children = new Entry[M];
        private Node(int k){
            m = k;
        }
    }

    //节点内部每个数据项定义
    private static class Entry{
        private Comparable key;
        private final Object val;
        //下一个节点
        private Node next;
        public Entry(Comparable key, Object val, Node next){
            this.key = key;
            this.val = val;
            this.next = next;
        }
        @Override
        public String toString() {
            StringBuilder builder = new StringBuilder();
            builder.append("Entry [key=");
            builder.append(key);
            builder.append("]");
            return builder.toString();
        }

    }

    public CatalogBTree(){
        root = new Node(0);
    }

    public int size(){
        return n;
    }

    public boolean isEmpty(){
        return size() == 0;
    }

    public int height(){
        return height;
    }

    /**
     * 查询接口
     * @param key
     * @return
     */
    public Value get(Key key){
        return search(root, key, height);
    }

    private Value search(Node x, Key key, int ht) {
        Entry[] children = x.children;//首次进入,相当于根节点
        //外部节点  这里使用顺序查找
        //如果M很大  可以改成二分查找
        if(ht == 0){//到了有数据的子节点(也可能是根节点,如果键值对小于M),此次查询数据。
            for(int j=0; j<x.m; j++){
                if(equal(key, x.children[j].key))//遍历查询
                    return (Value)children[j].val;
            }
        }
        //内部节点  寻找下一个节点
        else{
            for(int j=0; j<x.m; j++){
                //最后一个节点  或者 插入值小于某个孩子节点值
                if(j+1==x.m || less(key, x.children[j+1].key))//定位数据在哪个节点下,然后继续递归查询
                    return search(x.children[j].next, key, ht-1);//去下一层继续查询
            }
        }
        return null;
    }

    /**
     * 新增数据接口
     * @param key
     * @param val
     */
    public void put(Key key, Value val){
        //插入后的节点,如果节点分裂,则返回分裂后产生的新节点
        Node u = insert(root, key, val, height);
        n++;//键值对总数加一
        if(u == null) return;//根节点没有分裂  直接返回
        //分裂根节点,已满节点进行分裂,将已满节点后M/2节点生成一个新节点,并将新节点的第一个元素指向父节点,此处是M>>1 = 2(因为M=4)
        Node t = new Node(M>>1);
        //旧根节点第一个孩子和新分裂节点第一个孩子 共同 组成新节点作为根
        t.children[0] = new Entry(root.children[0].key, null, root);
        t.children[1] = new Entry(u.children[0].key, null, u);
        root = t;//新的根节点
        height++;//节点高度加1
    }

    private Node insert(Node h, Key key, Value val, int ht) {
        int j;
        //新建待插入数据数据项
        Entry t = new Entry(key, val, null);
        if(ht == 0){//高度为零时,直接遍历
            for(j=0; j<h.m; j++){ // 外部节点  找到带插入的节点位置j
                if(less(key, h.children[j].key))
                    break;
            }
        }else{
            //内部节点  找到合适的分支节点
            for(j=0; j<h.m; j++){
                if(j+1==h.m || less(key, h.children[j+1].key)){
                    Node u = insert(h.children[j++].next, key, val, ht-1);
                    if(u == null) return null;
                    t.key = u.children[0].key;
                    t.next = u;
                    break;
                }
            }
        }
        //j为带插入位置,将顺序数组j位置以后后移一位(从后往前处理) 将t插入
        for(int i=h.m; i>j; i--){
            h.children[i] = h.children[i-1];
        }
        System.out.println(j + t.toString());
        h.children[j] = t;//此处插入成功
        h.m++;//值的数量加一
        if(h.m < M) return null;
            //如果节点已满  则执行分类操作(从根节点开始)
        else return split(h);
    }

    private Node split(Node h) {
        //将已满节点h的后,一般M/2节点后的节点分裂到新节点并返回
        Node t = new Node(M/2);
        h.m = M / 2;
        for(int j=0; j<M/2; j++){
            t.children[j] = h.children[M/2+j];//把h节点中M/2节点后的节点分裂到新节点
            h.children[M/2+j] = null;//把h节点中M/2节点后的节点设置为空,以尽快GC
        }
        return t;//返回新节点
    }

    /**
     * 删除数据
     * @param key
     */
    public void remove(Key key){
        remove(root, key, height);

    }

    private void remove(Node x, Key key, int ht){
        Entry[] children = x.children;//首次进入,相当于根节点
        //外部节点  这里使用顺序查找
        //如果M很大  可以改成二分查找
        if(ht == 0){//到了有数据的子节点(也可能是根节点,如果键值对小于M),此次查询数据。
            for(int j=0; j<x.m; j++){
                if(equal(key, x.children[j].key)){//遍历查询
                    children[j] = null;//删除此节点数据
                    x.m--;//值的数量减一
                }
            }
        }
        //内部节点  寻找下一个节点
        else{
            for(int j=0; j<x.m; j++){
                //最后一个节点  或者 插入值小于某个孩子节点值
                if(j+1==x.m || less(key, x.children[j+1].key))//定位数据在哪个节点下,然后继续递归查询
                     remove(x.children[j].next, key, ht-1);//去下一层继续查询
            }
        }
    }

    private boolean equal(Comparable k1, Comparable k2){
        return k1.compareTo(k2)==0;
    }

    private boolean less(Comparable k1, Comparable k2){
        return k1.compareTo(k2)<0;
    }

    public String toString() {
        return toString(root, height, "") + "\n";
    }

    private String toString(Node h, int ht, String indent) {
        StringBuilder s = new StringBuilder();
        Entry[] children = h.children;//此数据相当于根节点

        //外部节点
        if (ht == 0) {
            for (int j = 0; j < h.m; j++) {
                s.append(indent + children[j].key + " " + children[j].val + "\n");
            }
        }
        else {
            for (int j = 0; j < h.m; j++) {
                s.append(indent + "(" + children[j].key + ")\n");
                s.append(toString(children[j].next, ht-1, indent + "     "));
            }
        }
        return s.toString();
    }

}

2、分析测试添加数据put方法:

insert等方法可以去代码里查看,下面进行main方法测试打印:

public static void main(String[] args) {
      
        /**
         * 添加的顺序不同,最后树的结构也不同
         */
        CatalogBTree<Integer,String> bt = new CatalogBTree<>();
        bt.put(5,"a");
        bt.put(8,"b");
        //     bt.put(9,"c");
        bt.put(10,"d");
        bt.put(15,"e");
        //     bt.put(18,"f");
        bt.put(20,"g");
        bt.put(26,"h");
        //      bt.put(27,"i");

        bt.put(28,"i");
        bt.put(30,"j");
        //      bt.put(33,"k");
        bt.put(35,"l");
        bt.put(38,"m");
        //    bt.put(50,"n");
        bt.put(56,"o");
        bt.put(60,"p");
        //      bt.put(63,"q");

        bt.put(65,"r");
        bt.put(73,"s");
        //       bt.put(79,"t");
        bt.put(80,"u");
        bt.put(85,"v");
        //   bt.put(88,"w");
        bt.put(90,"x");
        bt.put(96,"y");
        bt.put(99,"z");


        /**
         * 插入第三个元素
         */
        bt.put(9,"c");
        bt.put(18,"f");
        bt.put(27,"i");
        bt.put(33,"k");
        bt.put(50,"n");
        bt.put(63,"q");
        bt.put(79,"t");
        bt.put(88,"w");

        System.out.println("高度:"+bt.height());
        System.out.println("查询:"+bt.get(9));
        System.out.println( bt.toString());
    }

用此种顺序添加,最后的结构图是这样的:

控制台打印也可以验证:(自己可以测试验证)

 

一张没有截完,在来一张 

 

注意:添加节点时经常会出现分裂节点,这块一定要详细琢磨。  比如添加一个34 节点:

添加的后面过程中会分裂出一个新的节点;如下图:

3、分析测试查询数据get方法:比如get(99),会一直向下循环,循环三次就找到;代码如下图:

 

4、分析测试删除数据remove方法:此方法和get方法类似,遍历找到后设置为空就行,然后数值减一,如下图:

删除节点时可能出现合并节点, 

比如现在删除一个 80 节点:删除后此处还有一个分支,然后就会合并到左边,如图:

到此,B+树的原理和实现分析告一段落。

另外,B/B+树经常用做数据库的索引,这方面推荐您直接看张洋的MySQL索引背后的数据结构及算法原理 这篇文章,这篇文章对MySQL中的如何使用B+树进行索引有比较详细的介绍,推荐阅读。

总结

在前面两篇文章介绍了平衡查找树中的2-3树红黑树之后,本文介绍了文件系统和数据库系统中常用的B/B+ 树,他通过对每个节点存储个数的扩展,使得对连续的数据能够进行较快的定位和访问,能够有效减少查找时间,提高存储的空间局部性从而减少IO操作。他广泛用于文件系统及数据库中,如:

  • Windows:HPFS文件系统
  • Mac:HFS,HFS+文件系统
  • Linux:ResiserFS,XFS,Ext3FS,JFS文件系统
  • 数据库:ORACLE,MYSQL,SQLSERVER等中

希望本文对您了解B/B+ 树有所帮助。参考文章如下:

参考

参阅

参考文章

评论 8
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

寅灯

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值