B树和B+树简述

B树和B+树

B树(B-tree)

  • B树是一种平衡的、多路搜索树,每个节点可以存储多个关键字和对应的值。

由来

传统用于搜索的平衡二叉树(如AVL树、红黑树)在一般情况下具有良好的查询性能。然而,当面对大规模数据时,这些树结构可能无法满足需求。主要原因在于数据量巨大时,无法一次性将所有数据加载到内存中,而需要频繁进行磁盘IO操作。由于磁盘的读取速度通常比内存慢几个数量级,导致程序大部分时间都在等待磁盘IO操作完成。

为了提高程序性能,关键在于减少磁盘IO次数,以降低程序的等待时间。传统平衡二叉树的设计无法有效应对磁盘IO的挑战。因此,我们需要一种更适合磁盘存储的数据结构。

在这里插入图片描述

这时,B树和B+树就成为了一种解决方案。它们具有以下特点:

  1. 多路搜索树: B树和B+树允许每个节点存储多个关键字和对应的值,从而减少树的高度,降低磁盘IO次数。

  2. 平衡性: B树和B+树通过调整节点的分裂和合并来保持树的平衡,确保各个子树的高度相近。

  3. 顺序访问: B+树将所有数据记录存储在叶子节点上,并使用指针进行连接,形成有序链表。这样可以支持范围查询和顺序访问的高效率。

  4. 磁盘友好: B树和B+树的设计考虑了磁盘IO的特点,尽量减少IO次数,提高访问效率。

由于B树和B+树适应了磁盘存储的特点,它们被广泛应用于需要存储大量数据并需要高效检索的场景。通过减少磁盘IO次数,B树和B+树能够提供更高的查询性能,有效地解决了传统平衡二叉树在大规模数据场景下的性能问题。

因此,当面临大规模数据并需要高效检索的场景时,选择使用B树或B+树作为数据结构可以极大地提高程序性能,减少磁盘IO操作的影响,实现更快速、高效的数据访问。

设计

平衡二叉树的限制和磁盘IO挑战

平衡二叉树通过旋转操作来保持平衡,旋转是对整棵树的操作。然而,如果只部分加载平衡二叉树到内存中,无法完整执行旋转操作,从而影响平衡性的维护。此外,平衡二叉树的高度相对较大,约为log n(底数为2),这意味着逻辑上相邻的节点在实际存储中可能相距很远。这导致无法充分利用磁盘预读的优势,无法发挥空间局部性原理的效果。

空间局部性原理指出,如果一个存储器位置被访问,那么附近位置也会被访问。而平衡二叉树的节点可能在磁盘上离散存储,无法连续预读数据。这导致磁盘IO次数的增加,影响了查询效率。

B-树的设计考虑磁盘IO

为了更好地适应磁盘存储和减少磁盘IO次数,B-树采用了一种不同的设计思路:

  • 多区间分割: B-树将范围分割为多个区间,而不是像平衡二叉树一样分割为两个区间。区间的数量越多,定位数据的速度越快且更精确。

  • 节点大小与磁盘页对齐: 在B-树中,每个节点表示一个区间范围。为了减少磁盘IO次数,当新建节点时,直接申请和磁盘页大小相等的空间(如512字节、4KB、8KB或16KB)。这样,一个节点的数据可以通过一次磁盘IO进行读取。

B-树的设计考虑了磁盘IO的特点,通过多区间分割和与磁盘页对齐的方式,有效减少了磁盘IO次数。这使得B-树能够更好地适应大规模数据的存储和高效检索需求。通过一次IO读取一个节点的数据,B-树能够充分利用磁盘预读的优势,提高查询性能。

因此,B-树在数据库和文件系统等领域被广泛选择,以解决平衡二叉树在大规模数据存储和磁盘IO方面的限制。通过减少磁盘IO次数,B-树能够提供更高效的数据访问,提升程序的性能。

在这里插入图片描述

B树特点

  • 每个节点最多包含m个子节点(m>=2)。
  • 根节点至少有两个子节点。
  • 除根节点和叶子节点外,其他节点至少有m/2个子节点。
  • 所有叶子节点位于同一层。
  • 节点中的关键字按升序排列,用于支持快速的查找操作。

B树的应用场景:

  • 适用于磁盘或其他外部存储设备的存储结构,可以减少磁盘I/O操作次数,提高存储效率。
  • 适用于需要高效地进行范围查询(如区间查询)的场景,如数据库索引。

代码实现

public class BTree<Key extends Comparable<Key>, Value> {
    private static final int DEFAULT_T = 2; // 默认的度数

    private int t; // B树的度数
    private Node root; // B树的根节点

    private class Node {
        private int n; // 节点中的键值对数量
        private boolean leaf; // 是否为叶子节点
        private List<Key> keys; // 节点中的键
        private List<Value> values; // 节点中的值
        private List<Node> children; // 节点的子节点

        // 构造函数
        public Node() {
            this.n = 0;
            this.leaf = true;
            this.keys = new ArrayList<>();
            this.values = new ArrayList<>();
            this.children = new ArrayList<>();
        }
    }

    // 构造函数
    public BTree() {
        this(DEFAULT_T);
    }

    /**
     * 构造函数
     *
     * @param t B树的度数
     * @throws IllegalArgumentException 如果度数t小于2
     */
    public BTree(int t) {
        if (t < 2) {
            throw new IllegalArgumentException("Degree (t) of BTree must be at least 2");
        }
        this.t = t;
        this.root = new Node();
    }

    /**
     * 在B树中查找指定的键,并返回对应的值
     *
     * @param key 要查找的键
     * @return 对应的值,如果键不存在则返回null
     */
    public Value search(Key key) {
        return search(root, key);
    }

    /**
     * 在B树中递归查找指定的键,并返回对应的值
     * @param node 当前节点
     * @param key 要查找的键
     * @return 对应的值,如果键不存在则返回null
     */
    private Value search(Node node, Key key) {
        int i = 0;
        while (i < node.n && key.compareTo(node.keys.get(i)) > 0) {
            i++;
        }
        if (i < node.n && key.compareTo(node.keys.get(i)) == 0) {
            return node.values.get(i);
        } else if (node.leaf) {
            return null;
        } else {
            return search(node.children.get(i), key);
        }
    }


    /**
     * 向B树中插入指定的键值对
     * @param key 要插入的键
     * @param value 要插入的值
     */
    public void insert(Key key, Value value) {
        Node rootNode = root;
        // 如果根节点已满,需要进行分裂
        if (rootNode.n == 2 * t - 1) {
            Node newRootNode = new Node();
            root = newRootNode; // 将新的根节点设置为当前根节点
            newRootNode.children.add(rootNode); // 将当前根节点作为新根节点的子节点
            splitChild(newRootNode, 0, rootNode); // 分裂当前根节点
            insertNonFull(newRootNode, key, value); // 在分裂后的新根节点中插入键值对
        } else {
            insertNonFull(rootNode, key, value); // 在当前根节点中插入键值对
        }
    }


    /**
     * 在非满节点中插入指定的键值对
     *
     * @param node  当前节点
     * @param key   要插入的键
     * @param value 要插入的值
     */
    private void insertNonFull(Node node, Key key, Value value) {
        int i = node.n - 1;
        if (node.leaf) {
            // 在叶子节点中找到合适的位置插入键值对
            while (i >= 0 && key.compareTo(node.keys.get(i)) < 0) {
                i--;
            }
            node.keys.add(i + 1, key);
            node.values.add(i + 1, value);
            node.n++;
        } else {
            // 在非叶子节点中找到合适的子节点进行递归插入
            while (i >= 0 && key.compareTo(node.keys.get(i)) < 0) {
                i--;
            }
            i++;
            if (node.children.get(i).n == 2 * t - 1) {
                // 如果子节点已满,则先分裂子节点
                splitChild(node, i, node.children.get(i));
                if (key.compareTo(node.keys.get(i)) > 0) {
                    i++;
                }
            }
            insertNonFull(node.children.get(i), key, value);
        }
    }



    /**
     * 分裂指定节点的子节点
     *
     * @param parentNode 父节点
     * @param childIndex 子节点在父节点中的索引
     * @param childNode  要分裂的子节点
     */
    private void splitChild(Node parentNode, int childIndex, Node childNode) {
        // 创建一个新的节点作为分裂后的节点
        Node newNode = new Node();

        // 将子节点的中间键和值提升到父节点中
        parentNode.keys.add(childIndex, childNode.keys.get(t - 1));
        parentNode.values.add(childIndex, childNode.values.get(t - 1));
        parentNode.n++;

        // 将子节点的右半部分键和值移动到新的节点中
        newNode.keys.addAll(childNode.keys.subList(t, 2 * t - 1));
        newNode.values.addAll(childNode.values.subList(t, 2 * t - 1));
        newNode.n = t - 1;

        // 如果子节点不是叶子节点,则还需要将子节点的右半部分子节点移动到新的节点中
        if (!childNode.leaf) {
            newNode.children.addAll(childNode.children.subList(t, 2 * t));
            childNode.children.subList(t, 2 * t).clear();
        }

        // 更新子节点的键、值和子节点个数,使其成为左半部分
        childNode.keys.subList(t - 1, 2 * t - 1).clear();
        childNode.values.subList(t - 1, 2 * t - 1).clear();
        childNode.n = t - 1;

        // 将新的节点插入到父节点的合适位置
        parentNode.children.add(childIndex + 1, newNode);
    }


    /**
     * 从B树中删除指定的键
     *
     * @param key 要删除的键
     */
    public void delete(Key key) {
        delete(root, key);
    }


    /**
     * 在指定节点中删除指定键对应的键值对
     *
     * @param node 当前节点
     * @param key  要删除的键
     */
    private void delete(Node node, Key key) {
        // 在当前节点中查找要删除的键
        int index = node.keys.indexOf(key);
        if (index >= 0) {
            if (node.leaf) {
                // 如果当前节点是叶子节点,则直接删除键值对
                node.keys.remove(index);
                node.values.remove(index);
                node.n--;
            } else {
                // 如果当前节点不是叶子节点,则找到前驱键和值,用前驱键替换要删除的键,并在相应的子节点中递归删除前驱键
                Key predecessorKey = getPredecessorKey(node, index);
                Value predecessorValue = search(predecessorKey);
                node.keys.set(index, predecessorKey);
                node.values.set(index, predecessorValue);
                delete(node.children.get(index), predecessorKey);
            }
        } else {
            // 如果在当前节点中未找到要删除的键,则根据键的大小递归搜索合适的子节点
            int i = 0;
            while (i < node.n && key.compareTo(node.keys.get(i)) > 0) {
                i++;
            }
            if (i < node.n && key.compareTo(node.keys.get(i)) < 0) {
                delete(node.children.get(i), key);
            } else {
                delete(node.children.get(i + 1), key);
            }
        }
    }



    /**
     * 获取指定节点中指定索引位置的前驱键
     *
     * @param node  当前节点
     * @param index 要获取前驱键的键值对在当前节点中的索引
     * @return 前驱键
     */
    private Key getPredecessorKey(Node node, int index) {
        // 从当前节点的指定子节点开始,沿着最右侧的子节点一直向下遍历,直到找到叶子节点
        Node current = node.children.get(index);
        while (!current.leaf) {
            current = current.children.get(current.n);
        }

        // 返回叶子节点中最右侧的键,即为要获取的前驱键
        return current.keys.get(current.n - 1);
    }

}

B+树(B+ tree)

简述

B+树是B-树的变种,由Bayer和McCreight于1972年提出,它与B-树的不同之处在于:

  • 在B+树中,key 的副本存储在内部节点,真正的 key 和 data 存储在叶子节点上 。
  • n 个 key 值的节点指针域为 n 而不是 n+1

在这里插入图片描述

  • 所有数据记录都存储在叶子节点,并按照关键字的顺序进行链接,形成一个有序链表

  • B+树的叶子节点形成一个稠密索引,便于顺序访问和范围查询

在这里插入图片描述

B+树的应用场景

  • 适用于数据库和文件系统等需要高效地进行范围查询、顺序访问的场景。
  • 适用于需要支持大量数据的高效索引结构。

简单的代码实现

首先,我们定义B+树节点的结构:

public class BPlusTreeNode<K extends Comparable<K>, V> {
    private List<K> keys;
    private List<BPlusTreeNode<K, V>> children;
    private BPlusTreeNode<K, V> next; // 用于连接叶子节点的指针
    private boolean leaf;
    private int n;

    // 构造函数和其他方法省略...
}

然后,我们定义B+树的主要类:

public class BPlusTree<K extends Comparable<K>, V> {
    private BPlusTreeNode<K, V> root;
    private int t; // B+树的阶数

    // 构造函数和其他方法省略...
}

接下来,我们实现B+树的插入、查找、删除等操作。以下是简化的示例代码:

public class BPlusTree<K extends Comparable<K>, V> {
    // 省略构造函数和其他方法

    public void insert(K key, V value) {
        if (root == null) {
            root = new BPlusTreeNode<>();
            root.setLeaf(true);
            root.getKeys().add(key);
            root.getValues().add(value);
            root.setN(1);
        } else {
            insertNonFull(root, key, value);
        }
    }

    private void insertNonFull(BPlusTreeNode<K, V> node, K key, V value) {
        int i = node.getN() - 1;
        if (node.isLeaf()) {
            while (i >= 0 && key.compareTo(node.getKeys().get(i)) < 0) {
                i--;
            }
            node.getKeys().add(i + 1, key);
            node.getValues().add(i + 1, value);
            node.setN(node.getN() + 1);
        } else {
            while (i >= 0 && key.compareTo(node.getKeys().get(i)) < 0) {
                i--;
            }
            i++;
            if (node.getChildren().get(i).getN() == 2 * t - 1) {
                splitChild(node, i);
                if (key.compareTo(node.getKeys().get(i)) > 0) {
                    i++;
                }
            }
            insertNonFull(node.getChildren().get(i), key, value);
        }
    }

    private void splitChild(BPlusTreeNode<K, V> parentNode, int childIndex) {
        BPlusTreeNode<K, V> childNode = parentNode.getChildren().get(childIndex);
        BPlusTreeNode<K, V> newNode = new BPlusTreeNode<>();

        parentNode.getKeys().add(childIndex, childNode.getKeys().get(t - 1));
        parentNode.getValues().add(childIndex, childNode.getValues().get(t - 1));
        parentNode.getChildren().add(childIndex + 1, newNode);

        newNode.setLeaf(childNode.isLeaf());
        newNode.setN(t - 1);

        for (int j = 0; j < t - 1; j++) {
            newNode.getKeys().add(childNode.getKeys().get(j + t));
            newNode.getValues().add(childNode.getValues().get(j + t));
        }

        if (!childNode.isLeaf()) {
            for (int j = 0; j < t; j++) {
                newNode.getChildren().add(childNode.getChildren().get(j + t));
            }
            childNode.getChildren().subList(t, 2 * t).clear();
        }

        childNode.getKeys().subList(t - 1, 2 * t - 1).clear();
        childNode.getValues().subList(t - 1, 2 * t - 1).clear();
        childNode.setN(t - 1);
    }

    // 其他方法省略...
}

请注意,这只是一个简化的B+树实现示例,没有完整处理各种边界情况。在实际应用中,需要更完整地考虑异常情况和调优。

B-树和B+树比较

1 B+树内节点不存储数据,所有 data 存储在叶节点导致查询时间复杂度固定为 log n。而B-树查询时间复杂度不固定,与 key 在树中的位置有关,最好为O(1)。

查询节点 key 为 50 的 data

在这里插入图片描述

key 为 50 的节点就在第一层,B-树只需要一次磁盘 IO 即可完成查找。所以说B-树的查询最好时间复杂度是 O(1)

在这里插入图片描述

B+树所有的 data 域都在根节点,所以查询 key 为 50的节点必须从根节点索引到叶节点,时间复杂度固定为 O(log n)

2.B+树叶节点两两相连可大大增加区间访问性,可使用在范围查询等,而B-树每个节点 key 和 data 在一起,则无法区间查找

在这里插入图片描述

B+树可以很好的利用局部性原理,若我们访问节点 key为 50,则 key 为 55、60、62 的节点将来也可能被访问,我们可以利用磁盘预读原理提前将这些数据读入内存,减少了磁盘 IO 的次数。
当然B+树也能够很好的完成范围查询。比如查询 key 值在 50-70 之间的节点。

3.B+树更适合外部存储。由于内节点无 data 域,每个节点能索引的范围更大更精确

B-树节点内部每个key都带着data域,而B+树节点只存储key的副本,真实的key和data域都在叶子节点存储。由于磁盘IO数据大小是固定的,在一次IO中,B+树单次磁盘IO的信息量大于B-树,因此B+树相对于B-树磁盘IO次数较少。

在这里插入图片描述

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值