B 树 - Java 实现

大兔子大兔子 提交于 2020-01-23 17:57:37

当数据规模大到内存已不足以容纳时, 常规的平衡二叉搜索树的效率将大打折扣。其原因在于,查找过程对外存的访问次数过多。

磁盘等外部存储器的一个特点是: 读取物理地址连续的一千个字节,与读取单个字节所消耗的时间几乎是没有区别的。

此时,可以使用多路搜索树。多路搜索树可以由二叉搜索树变换得到。如,

在这里插入图片描述
此时,搜索过程每下降一层,都以“大节点”为单位从外存中读取一组(而不再是一个)关键码。更为重要的是,这组关键码在逻辑上与物理上都彼此相邻,故可以批量方式从外存一次性读出,且所需时间与读取单个关键码几乎一样。

B 树

  • 所谓 m 阶 B-树 , 即 m 路平衡搜索树(m2m \ge 2)。
  • 所有外部节点深度相等
  • 设一个内部节点存有 n 个关键码, 以及用以指示对应分支的 n+1 个指针。除以外的所有内部节点,都应满足:m/2分支数m\lceil m/2 \rceil \le \text{分支数} \le m ,而在非空的 B-树中,根节点应满足:2m2 \le 分支数 \le m
  • 每个内部节点中的关键码按顺序排列。
  • 由于各节点的分支数介于m/2\lceil m/2 \rceilmm 之间,故m阶B-树也称 (m/2,m)(\lceil m/2 \rceil , m)-树 。(关键码数介于m/21\lceil m/2 \rceil - 1m1m - 1 之间)

B+ 树 与 B 树 的区别

  • B+ 树 的内部节点只存放关键码,不存放具体的数据,所有数据都放在叶子节点。而 B 树的每个节点都存放有数据。
  • B+ 树的叶子节点中存有一个指向下一个叶子节点的指针(关键码小的指向关键码大的)
  • B+ 树中同一个关键码可以在不同节点中重复出现,而 B树中任何一个关键码只能出现在一个结点中。

树定义

public class BTree<T extends Comparable<? super T>> {

	// B 树节点
    private static class BNode<T> {
        BNode<T> parent;
        List<T> datas;				// 存放的数据(含关键码)
        List<BNode<T>> children;	// 指向孩子节点的指针

        // 初始时有0个关键码和1个空孩子指针
        BNode() {
            datas = new LinkedList<>();
            children = new LinkedList<>();
            children.add(null);
        }
    }

    private int order;      // B 树的阶次
    private BNode<T> root;  // 树根


    public BTree(int order) {
        this.order = order;
        root = new BNode<>();
    }
    

    // 在 datas 中查找 e 的位置,或 e 应该插入的位置
    private int find(List<T> datas, T e) {
        for (int i = 0; i < datas.size(); i++) {
        	// 等于:就是找到了
        	// 小于:就是没有找到,但 e 应该插入此处
            if (e.compareTo(datas.get(i)) <= 0) {
                return i;
            }
        }

        return datas.size();
    }
    ...
}

搜索关键码

对于活跃的B-树,其根节点会常驻于内存; 此外,任何时刻通常只有另一节点(称作当前节点)留驻于内存。

查找过程

B-树的查找过程, 与二叉搜索树的查找过程基本类似:

首先以根节点作为当前节点,若在当前节点(所包含的一组关键码)中能够找到目标关键码,则成功返回。否则,则必可在当前节点中确定某一个分支(“失败”位置) ,并通过它转至逻辑上处于下一层的另一节点。若该节点不是外部节点, 则将其载入内存,并更新为当前节点,然后继续重复上述过程。

效率

对于高度为 h 的B-树,外存访问不超过 O(h - 1) 次。

存有 N 个关键码的 m 阶B-树的高度 h=Θ(logmN)h = \Theta(\log_mN)

// 用于表示搜索结果(作为内部类)
private static class SearchResult<T> {
    BNode<T> target;        // 目标节点
    BNode<T> parent;        // 目标节点之父
    int index;              // 目标数据在目标节点中的位置
    // getter & setter ...
}
// 查找关键码 e
public SearchResult<T> search(T e) {
    SearchResult<T> result = new SearchResult<>();
    BNode<T> current = root, parent = null;
    int index = -1;

    while (current != null) {
        List<T> datas = current.datas;
        // 在当前节点的关键码中查找,要么命中,要么获知下一步走哪个分支
        index = find(datas, e);

        // 找到了
        if (index < datas.size() && e.compareTo(datas.get(index)) == 0) {
            break;
        }
        // 没有找到,进入下一层
        parent = current;
        current = current.children.get(index);
    }

    result.target = current;
    result.parent = parent;
    result.index = index;

    return result;
}

插入关键码

对于 m 介 B-树,设关键码插入的节点为 v 。

若插入新的关键码之后, v 的分支数等于 m+1,则违背了 B-树的限定条件,此时需要分裂节点 v 。

分裂节点

此时,节点 v 恰包含有 m 个关键码(即 m+1 个分支)。

取分裂点位置 s=m/2s = \lfloor m/2 \rfloor ,则可将 v 的关键码分为 3部分:[0…s)、s、[s+1…) 。其中,[0…s) 部分作为左节点、[s+1…) 部分作为右节点,s 处的关键码则被提升至父节点中。

至于,被提升的关键码,有 3种可能的情况:

  1. 父节点未饱和,则将关键码插到合适的位置即可;
  2. 父节点已饱和,强行插入关键码之后,父节点也需要分裂;
  3. 若上溢传递至树根,则可令被提升的关键码自成一个节点, 并作为新的树根。

在这里插入图片描述

// 插入:不允许存在重复关键码
public void insert(T e) {
    // 先确定关键码是否存在
    SearchResult<T> result = search(e);

    // 存在
    if (result.target != null) {
        return;
    }

    // 插到合适的位置
    BNode<T> node = result.parent;
    int index = find(node.datas, e);
    node.datas.add(index, e);

    // 添加相应的孩子指针
    node.children.add(index + 1, null);

    // 必要时分裂节点
    solveOverflow(node);
}
// 处理节点上溢情况
private void solveOverflow(BNode<T> node) {
    // 未上溢
    if (node.children.size() <= order) {
        return;
    }

    // 分裂点
    int s = order >> 1;

    // node 分裂为左右两个节点,原节点继续用作分裂后的左节点,rightNode 用作分裂后的右节点
    // 注意:新节点已有一个空孩子
    BNode<T> rightNode = new BNode<>();
    rightNode.children.remove(0);

    // rightNode:存放 node.datas[s+1:] 和 node.children[s+1:]
    for (int i = 0; i < order - s - 1; i++) {
        rightNode.datas.add(node.datas.remove(s + 1));
        rightNode.children.add(node.children.remove(s + 1));
    }
    // 最右侧的那个指针
    rightNode.children.add(node.children.remove(s + 1));

    // 如果 rightNode 的孩子非空,则让它们认新爹
    if (rightNode.children.get(0) != null) {
        for (int i = 0; i < order - s; i++) {
            rightNode.children.get(i).parent = rightNode;
        }
    }

	// node 之父
    BNode<T> parent = node.parent;

    // node 为树根:被提升的关键码要作为新的树根
    if (parent == null) {
        root = parent = new BNode<>();
        parent.children.set(0, node);
        node.parent = parent;
    }

    // parent 中指向 rightNode 的指针之位置
    int index = find(parent.datas, node.datas.get(0));

    // 分裂点处的关键码上升
    parent.datas.add(index, node.datas.remove(s));
    parent.children.add(index + 1, rightNode);
    rightNode.parent = parent;

    // 有可能父节点也已上溢
    solveOverflow(parent);
}

删除关键码

设节点 t 包含待删除的关键码 k 。若 t 不是叶子节点,则需要沿着适当的分支向下,直到叶子节点,并在该叶子节点中找到 k 的后继 k2 。然后,更新节点 t 中的 k 为 k2;最后,删除叶子节点中的 k2 。

对于 m 介 B-树,设叶子节点 V 包含待删除的关键码。若删除关键码之后,V 包含的分支数等于 m/21\lceil m/2 \rceil - 1,则违背了 B-树的限定条件。

解决下溢问题

根据节点 V 的左、右兄弟所包含的关键码数,可分为 3种情况处理:

注:V 的左、右兄弟至少有一个不为空。(如果左右兄弟都为空,则说明节点 V 之父只有一个孩子,即只有一个分支、0个关键码)

(1)左兄弟存在,且至少包含 m/2\lceil m/2 \rceil 个关键码

设 V 之左兄弟为 L,L 与 V 分别是父节点 P 中关键码 y 的左右孩子,L 中的最大的关键码为 x 。

在这里插入图片描述
将 y 从节点 P 转移至节点 V 中(作为最小关键码),再将 x 从 L 转移至 P 中(取代原关键码 y)。至此,局部乃至整树都重新满足B-树条件,下溢修复完毕。

(2)右兄弟存在,且至少包含 m/2\lceil m/2 \rceil 个关键码

与前一种情况对称。

设 V 之右兄弟为 R,V 与 R 分别是父节点 P 中关键码 y 的左右孩子,R 中的最小的关键码为 x 。

在这里插入图片描述
将 y 从节点 P 转移至节点 V 中(作为最大关键码),再将 x 从 R 转移至 P 中(取代原关键码 y)。至此,局部乃至整树都重新满足B-树条件,下溢修复完毕。

(3)左右兄弟要么不存在,要么包含的关键码数少于 m/2\lceil m/2 \rceil

当然,左右兄弟不可能同时不存在。

不失一般性,假设 V 之左兄弟 L 存在,则 L 此时应该恰好包含 m/21\lceil m/2 \rceil - 1 个关键码。

在这里插入图片描述
从父节点 P 中抽出介于 L 和 V 之间的关键码 y, 并通过该关键码将节点 L 和 V “粘接” 成一个节点。合并得到的新节点,其含有的关键码数量为 (m/21)+1+(m/22)=2m/22m1(\lceil m/2 \rceil - 1) + 1 + (\lceil m/2 \rceil - 2) = 2\cdot\lceil m/2 \rceil - 2 \le m - 1

当左兄弟不存在时,右兄弟必定存在,可按照类似方法合并节点。

// 删除
public void remove(T e) {
    // 先确定关键码是否存在
    SearchResult<T> result = search(e);

    // 不存在
    if (result.target == null) {
        return;
    }

	// 关键码所在的节点及索引
    BNode<T> target = result.target;
    int index = result.index;

    // 若 target 不是叶子节点,则需要在叶子节点中找到 e 的后继,然后用其更新 e,再让后继替死
    if (target.children.get(0) != null) {
        // 在右子树中,一直往左,即可找到后继
        // 类似于二叉搜索树(实际上,可将该 B-树展开为一棵二叉搜索树)
        BNode<T> node = target.children.get(index + 1);
        while (node.children.get(0) != null) {
            node = node.children.get(0);
        }

        // 更新 target 中的 e 为后继
        target.datas.set(index, node.datas.get(0));

		// 实际被删除的关键码所在的节点及其索引
        target = node;
        index = 0;
    }

    // 此时,target 必然位于最底层
    target.datas.remove(index);
    target.children.remove(index + 1);

    // 如果下溢,则解决之
    solveUnderflow(target);
}
// 解决下溢情况
private void solveUnderflow(BNode<T> node) {
    // 未下溢
    if (node.children.size() >= ((order + 1) >> 1)) {
        return;
    }

	// node 之父
    BNode<T> parent = node.parent;

    // 已至树根:没有孩子下限
    if (parent == null) {
        // 若树根 node 不含关键码,但却含有(唯一)一个非空孩子,则其可被删除
        if (node.datas.size() == 0 && node.children.get(0) != null) {
            root = node.children.get(0);
            root.parent = null;
            node.children.set(0, null);
        }
        return;
    }

    // 确定 node 是 parent 的第几个孩子
    int index = 0;
    while (parent.children.get(index) != node) {
        index++;
    }

    // 情况1:向左兄弟借关键码
    if (index > 0) {
        BNode<T> leftSilbing = parent.children.get(index - 1);

        // 若左兄弟足够胖
        if (leftSilbing.children.size() > ((order + 1) >> 1)) {
            // parent 借关键码给 node
            // 左兄弟借关键码给 parent
            node.datas.add(0, parent.datas.get(index - 1));
            parent.datas.add(index - 1, leftSilbing.datas.remove(leftSilbing.datas.size() - 1));
            node.children.add(0, leftSilbing.children.remove(leftSilbing.children.size() - 1));

            // 新爹
            if (node.children.get(0) != null) {
                node.children.get(0).parent = node;
            }

            return;
        }
    }

    // 至此,左兄弟要么为空,要么太“瘦”

    // 情况2:向右兄弟借关键码
    if (index < parent.children.size() - 1) {
        BNode<T> rightSibling = parent.children.get(index + 1);
        // 若右兄弟足够胖
        if (rightSibling.children.size() > ((order + 1) >> 1)) {
            // parent 借关键码给 node
            // 右兄弟借关键码给 parent
            node.datas.add(node.datas.size(), parent.datas.get(index));
            parent.datas.add(index, rightSibling.datas.remove(0));
            node.children.add(node.children.size(), rightSibling.children.remove(0));

            // 新爹
            if (node.children.get(node.children.size() - 1) != null) {
                node.children.get(node.children.size() - 1).parent = node;
            }

            return;
        }
    }


    // 情况3:左、右兄弟要么为空(但不可能同时),要么都太“瘦” ——合并两者
    if (index > 0) {    // 与左兄弟合并
        BNode<T> leftSibling = parent.children.get(index - 1);
        leftSibling.datas.add(leftSibling.datas.size(), parent.datas.remove(index - 1));
        parent.children.remove(index);

        // 将 node 中的数据、指针移到左兄弟
        leftSibling.children.add(leftSibling.children.size(), node.children.remove(0));
        if (leftSibling.children.get(leftSibling.children.size() - 1) != null) {
            leftSibling.children.get(leftSibling.children.size() - 1).parent = leftSibling;
        }

        while (!node.datas.isEmpty()) {
            leftSibling.datas.add(leftSibling.datas.size(), node.datas.remove(0));
            leftSibling.children.add(leftSibling.children.size(), node.children.remove(0));
            if (leftSibling.children.get(leftSibling.children.size() - 1) != null) {
                leftSibling.children.get(leftSibling.children.size() - 1).parent = leftSibling;
            }
        }
    }
    else {      // 与右兄弟合并
        BNode<T> rightSibling = parent.children.get(index + 1);
        rightSibling.datas.add(0, parent.datas.remove(index));
        parent.children.remove(index);

        // 将 node 中的数据、指针移到右兄弟
        rightSibling.children.add(0, node.children.remove(node.children.size() - 1));
        if (rightSibling.children.get(0) != null) {
            rightSibling.children.get(0).parent = rightSibling;
        }

        while (!node.datas.isEmpty()) {
            rightSibling.datas.add(0, node.datas.remove(node.datas.size() - 1));
            rightSibling.children.add(0, node.children.remove(node.children.size() - 1));
            if (rightSibling.children.get(0) != null) {
                rightSibling.children.get(0).parent = rightSibling;
            }
        }
    }

    // 可能父节点也下溢了
    solveUnderflow(parent);
}
标签
易学教程内所有资源均来自网络或用户发布的内容,如有违反法律规定的内容欢迎反馈
该文章没有解决你所遇到的问题?点击提问,说说你的问题,让更多的人一起探讨吧!