1. 简介

在之前我们学习了红黑树,今天再学习一种树——B树。它与红黑树有许多类似的地方,比如都是平衡搜索树, 但它们在功能和结构上却有较大的差别。

从功能上看,B树是为磁盘或其他存储设备设计的,能够有效的降低磁盘的I/O操作数,因此我们经常看到有许多数据库系统使用B树或B树的变种作为储存的数据结构;从结构上看,B树的结点可以有很多孩子,从数个到数千个,这通常依赖于所使用的磁盘的单元特性。

如下图,给出了一棵简单的B树。

从图中我们可以发现,如果一个内部结点包含n个关键字,那么结点就有n+1个孩子。例如,根结点有1个关键字M,它有2个孩子;它的左孩子包含2个关键字,可以看到它有3个孩子。之所以是n+1个孩子,是因为B树的结点中的关键字是分割点,n个关键字正好分隔出n+1个子域,每个子域都对应一个孩子。

2. 辅存上的数据结构

在之前我们提到,B树是为磁盘或其他存储设备设计的。因此,在正式介绍B树之前,我们有必要弄清楚为什么针对磁盘设计的数据结构有别于针对随机访问的主存所设计的数据结构,只有这样才能更好理解B树的优势。

我们知道,磁盘比主存便宜且有更多的容量,但是它比主存要慢许多,通常会慢出4~5个数量级。为了提高磁盘的读写效率,操作系统在读写磁盘时,会一次存取多个数据而不是一个。在磁盘中,信息被分为一系列相等大小的,在柱面内连续出现的位页面(page),每次磁盘读或写一个或多个完整的页面。通常,一页的长度可能是\(2^{11} -2^{14}\)字节。

因此,在本篇博客中,我们对运行时间的衡量主要从以下两个方面考虑:

  1. 磁盘存取次数
  2. CPU时间

我们用读出或写入磁盘的信息的页数来衡量磁盘存取的次数。注意到,磁盘存取时间并不是常量——它与当前磁道和所需磁道之间的距离以及磁盘的初始旋转状态有关,但是为了简单起见,我们仍然使用读或写的页数作为磁盘存取总时间的近似值。

在一个典型的B树应用中,所需处理的数据非常大,以至于所有的数据无法一次转入主存。B树算法将所需页面从磁盘复制到主存,若进行了修改,之后则会写回磁盘。因此,B树算法在任何时刻都只需要在主存中保存一定数量的页面,主存的大小并不限制被处理的B树的大小。

下面用几行伪代码来模拟对磁盘的操作。设x为指向一个对象的指针,我们在使用x(指向的对象)时,需要先判断x指向的对象是否在主存中,若在则可以直接使用;否则需要将其从磁盘读入到主存,然后才能使用。

  1. x = a pointer to some object
  2. DISK-READ(x) // 将x读入主存,若x已经在主存中,则该操作相当于空操作
  3. modify x
  4. DISK-WRITE(x) // 将x写回主存,若x未修改,则该操作相当于空操作

由上我们看出,一个B树算法的运行时间主要由它所执行的DISK-READDISK-WRITE操作的次数决定,所以我们希望这些操作能够读或写尽可能多的信息。因此,一个B树结点通常设计的和一个完整磁盘页一样大,这将使得磁盘页的大小限制B树结点可以含有的孩子(关键字)的个数。

如下图是一棵高度为2(这里计算高度时不计算根结点)的B树,它的每个结点有1000个关键字,因此分支因子(孩子的个数)为1001,于是它可以储存\(1000*(1 + 1001 + 1001 *1001)\)个关键字,其数量超过10亿。我们如果将根结点保存在主存中,那么在查找树中任意一个关键字时,至多只需要读取2次磁盘。

3. B树的定义

下面正式给出B树的定义。一棵B树\(T\)必须具备如下性质:

  1. 每个结点\(x\)有如下属性:

    1. \(x.n\)。它表示储存在 \(x\)中的关键字的个数;
    2. \(x.key_1,x.key_2,...,x.key_n\)。它们表示\(x\)的\(n\)个关键字,以非降序存放,即\(x.key_1 \leq x.key_2 \leq ... \leq x.key_n\);
    3. \(x.leaf\)。它是一个布尔值,如果\(x\)是叶结点,它为TRUE;否则为FALSE;
    4. \(x.c_1, x.c_2,...,x.c_{n+1}\)。它们是指向自己孩子的指针。如果该结点是叶节点,则没有这些属性。
  2. 关键字\(x.key_i\)对存储在各子树中的关键字范围进行分割,即满足:\(k_1 \leq x.key_1 \leq k_2 \leq x.key_2 \leq... \leq x.key_n \leq k_{n+1}\)。其中,\(k_i(i = 1, 2, ...., n+1)\)表示任意一个储存在以\(x.c_i\)为根的子树中的关键字。
  3. 每个叶结点具有相同的深度,即叶的高度\(h\)。
  4. 每个结点所包含的关键的个数有上下界。用一个被称为最小度数的固定整数\(t(t \geq 2)\)来表示这些界:
    1. 下界:除了根结点以外的每个结点至少有 \(t-1\) 个关键字。因此,除了根结点外的每个内部结点至少有 \(t\) 个孩子。
    2. 上界:每个结点至多包含 \(2t-1\) 个关键字。因此,一个内部结点至多可能有\(2t\)个孩子。当一个结点恰好有\(2t-1\) 个关键字时,称该结点为满的(full)

下面用Java实现以上定义:

  1. import java.util.List;
  2. /**
  3. * B树
  4. *
  5. * @param <K> B树储存元素的类型
  6. */
  7. public class BTree<K extends Comparable<K>> {
  8. private BNode<K> root;
  9. private int height;
  10. private int minDegree;
  11. /**
  12. * B树的结点类
  13. */
  14. public static class BNode<K extends Comparable<K>> {
  15. private List<K> keys;
  16. private List<BNode> children;
  17. private int size;
  18. private boolean leaf;
  19. }
  20. // setter、getter ...
  21. }

我们抽象出代表结点的BNode类,作为表示B树的类BTree的内部类;它们具有如上面定义所说的各属性,只是在属性名上略有不同,会意就好;并且由于B树要求结点包含的关键字是按非逆序排列的,因此我们定义的泛型K必须实现了Comparable接口。

根据以上定义,当\(t = 2\)时的B树是最简单的。此时树的每个内部结点只可能有2个、3个或4个孩子,我们称它为2-3-4树。显然的,t的取值越大,B树的高度也就越小。事实上,B树的高度与其包含的关键字的个数以及它的最小度数有如下的关系:

如果\(n \geq1\),那么对于任意一棵包含\(n\)个关键字、高度为\(h\)、最小度树\(t \geq 2\)的B树\(T\)有:

\[h \leq \log_t \frac{n+1}{2}
\]

证明很简单,因为B树\(T\)的根结点至少包含1个关键字,而其他的结点至少包含\(t-1\)个关键字,因此除根结点外的每个结点都有\(t\)个孩子,于是有:

\[n \geq 1 + (t - 1)\sum_{i=1}^h2t^{i-1} = 1 + 2(t - 1)(\frac{t^h - 1}{t-1}) = 2t^h - 1
\]

4. B树上的基本操作

同其它的二叉搜索树一样,我们主要关心B树的 searchcreateinsertdelete 操作。首先做两个约定:

  1. B树的根结点始终在主存中,这样我们可以直接引用根结点而不需要执行DISK-READ操作;但是若根结点被修改,我们需要对其执行DISK-WRITE操作。
  2. 任何被当做参数的结点在被传递之前,都要对它们先做一次DISK-READ操作。

4.1 Search 操作

首先考察搜索操作。它与普通的二叉搜索类似,只不过它多了几个“叉”,需要进行多次判断。

记B树\(T\)的根结点(的指针)为\(root\),现在要在\(T\)中搜索关键字\(k\)。如果\(k\)在树中,则返回对应结点(的指针)\(y\)和\(y.key_i = k\)的下标\(i\)组成的有序对\((y, i)\);否则返回空。

下面给出Java的实现:

  1. private SearchResult<K> search(BNode<K> currentNode, K k) {
  2. int i = 0;
  3. // 此处也可采用二分查找
  4. while (i < currentNode.size && k.compareTo(currentNode.getKeys().get(i)) > 0) {
  5. i++;
  6. }
  7. if (i < currentNode.size && k.compareTo(currentNode.getKeys().get(i)) == 0) {
  8. return new SearchResult<K>(currentNode, i);
  9. }
  10. if (currentNode.leaf) {
  11. return null;
  12. }
  13. // DISK-READ(currentNode.getChildren()[i])
  14. return search(currentNode.getChildren().get(i), k);
  15. }
  16. public static class SearchResult<K extends Comparable<K>> {
  17. public BNode<K> bNode;
  18. public int keyIndex;
  19. public SearchResult(BNode<K> bNode, int keyIndex) {
  20. this.bNode = bNode;
  21. this.keyIndex = keyIndex;
  22. }
  23. }

Search 用了递归的操作:每层递归都会从左往右(从小到大)依次比较当前结点的第 i(从0起)个关键子与待搜索的关键字 k 的大小,直到第 i 个关键字不小于 k 。若此时第 i 个关键字正好等于k,则表示搜索到了,返回相关信息;否则,将以第 i 个孩子作为当前结点,按照上述过程递归查找。实际上,在文章开头给出的一棵关键字为字母的B树中,颜色较浅的结点即为我们在搜索关键字R时,需要搜索的结点。

由此我们不难看出,上述 search 过程访问磁盘的次数为\(O(h) = O(\log_tn)\);而每层递归调用中,循环操作的时间代价为\(O(t)\)(因为除根结点外,每个结点的关键字个数为\(t-1\)与\(2t-1\)之间)。因此,总的时间代价为\(O(th) = O(t \log_tn)\)。

4.2 Create 操作

为构造一棵B树,我们先用create方法来创建一棵空树(根结点为空),然后调用insert操作来添加一个新的关键字。这两个过程有一个公共的过程,即allocate-node,它在\(O(1)\)时间内为一个新结点分配一个磁盘页。

由于create操作很简单,下面只给出伪代码:

  1. create(T)
  2. x = allocate-node()
  3. x.leaf = TRUE
  4. x.n = 0
  5. DISK-WRITE(x)
  6. T.root = x

4.3 Insert 操作

在B树上进行insert操作较为麻烦。和普通二叉搜索树一样,我们必须先根据关键字找到要插入的位置,但不是插入就结束了。因为插入新结点的操作可能会导致B树不合法。

B树算法采用的做法是:在插入前,先判断结点是否是满的,若非满,那就直接插入;否则就将该结点一分为二,分裂为两个结点,而中间的关键字插入到其父结点中

如下图所示,B树的最小度数 \(t=4\),因此包含 \([P, Q, R, S, T, U, V]\) 关键字的结点过满,需要分裂。其操作步骤是:将处在中间位置的关键字 \(S\) 提升到其父结点中,剩余关键字随着结点一分为二。

特别提醒:上图截取自《算法导论(第三版),机械工业出版社》,其中右侧部分中的关键字WS的顺序弄反了!!!

需要注意的是,将中间关键字提升至父结点后,又可能导致父结点过满,此时需要用同样的方法处理父结点。此过程可能会持续发生,形成自底向上的分裂现象。

既然如此,可以采用一种更加巧妙的办法:在逐层向下查找待插入关键字的位置过程中,只要遇到满的结点,就进行分裂。这样一来,当关键字提升到父结点时,就不会造成父结点过满了。

特别地,由于根结点没有父结点,对于过满的根结点,需要新建一个空的根结点,原根结点中间位置的关键字上升到新建的空结点中。如下图所示:

由上我们可以看出,对满的非根结点的分裂不会使B树的高度增加,导致B树高度增加的唯一方式是对根结点的分裂。

下面给出分裂过程的Java代码:

  1. /**
  2. * 分裂node的第i个子结点
  3. *
  4. * @param node 非满的内部结点
  5. * @param i 第i个子结点
  6. */
  7. private void splitNode(BNode<K> node, int i) {
  8. BNode<K> childNode = node.getChildAt(i);
  9. int fullSize = childNode.getSize();
  10. // 从满结点childNode中截取后半部分
  11. List<K> newNodeKeys = childNode.getKeys().subList(fullSize / 2 + 1, fullSize - 1);
  12. List<BNode<K>> newNodeChildren = childNode.getChildren().subList((fullSize + 1) / 2, fullSize);
  13. BNode<K> newNode = new BNode<>(newNodeKeys, newNodeChildren, childNode.leaf);
  14. // 重新设置满结点childNode的size,而不必截取掉后半部分
  15. childNode.setSize(fullSize / 2);
  16. // 将childNode的中间关键字插入node中
  17. K middle = childNode.getKeyAt(fullSize / 2);
  18. node.getKeys().add(i, middle);
  19. // 将分裂出的结点newNodeKeys挂到node中
  20. node.getChildren().add(i + 1, newNode);
  21. // 更新size
  22. node.setSize(node.getSize() + 1);
  23. // 写入磁盘
  24. // DISK-WRITE(newNode)
  25. // DISK-WRITE(childNode)
  26. // DISK-WRITE(node)
  27. }

代码中的注释基本给出的每部操作的目的,这里不再赘述。实现了分裂过程,我们接下来就可以写insert过程了:

  1. /**
  2. * 插入关键字
  3. *
  4. * @param key 待插入的关键字
  5. */
  6. public void insert(K key) {
  7. // 判断根结点是否是满的
  8. if (root.getSize() == 2 * minDegree - 1) {
  9. // 若是满的,则构造出一个空的结点,作为新的根结点
  10. LinkedList<K> newRootKeys = new LinkedList<K>();
  11. LinkedList<BNode<K>> newRootChildren = new LinkedList<BNode<K>>();
  12. newRootChildren.add(root);
  13. root = new BNode<K>(newRootKeys, newRootChildren, false);
  14. splitNode(root, 0);
  15. height++;
  16. }
  17. insertNonFull(root, key);
  18. }

以上代码中,首先判断根结点是否满了,若满了,就构造出一个新的根结点,将以前的根结点挂到其下,注意此时新的根结点中还没有关键字,接着调用splitNode方法去分裂旧的根结点,这样处理下来,就能保证根结点是非满状态了。以下是splitNode过程的Java代码:

  1. /**
  2. * 分裂node的第i个子结点
  3. *
  4. * @param node 待分裂结点的父结点(注意不是待分裂的结点)
  5. * @param i 第i个子结点
  6. */
  7. private void splitNode(BNode<K> node, int i) {
  8. BNode<K> childNode = node.getChildAt(i);
  9. int childKeysSize = childNode.getSize();
  10. int childChildrenSize = childNode.getChildren().size();
  11. // 从满结点childNode中截取后半部分作为分裂的右结点
  12. LinkedList<K> rightNodeKeys = new LinkedList<K>(childNode.getKeys().subList(childKeysSize / 2 + 1, childKeysSize));
  13. LinkedList<BNode<K>> rightNodeChildren = childNode.getChildren().isEmpty() ? new LinkedList<BNode<K>>() : new LinkedList<>(childNode.getChildren().subList((childChildrenSize + 1) / 2, childChildrenSize));
  14. BNode<K> rightNode = new BNode<>(rightNodeKeys, rightNodeChildren, childNode.leaf);
  15. // 从满结点childNode中截取前半部分作为分裂的左结点
  16. LinkedList<K> leftNodeKeys = new LinkedList<K>(childNode.getKeys().subList(0, childKeysSize / 2));
  17. LinkedList<BNode<K>> leftNodeChildren = childNode.getChildren().isEmpty() ? new LinkedList<BNode<K>>() : new LinkedList<>(childNode.getChildren().subList(0, (childKeysSize + 1) / 2));
  18. BNode<K> leftNode = new BNode<>(leftNodeKeys, leftNodeChildren, childNode.leaf);
  19. node.getChildren().set(i, leftNode);
  20. // 将childNode的中间关键字插入node中
  21. K middle = childNode.getKeyAt(childKeysSize / 2);
  22. node.getKeys().add(i, middle);
  23. // 将分裂出的结点newNodeKeys挂到node中
  24. node.getChildren().add(i + 1, rightNode);
  25. // 写入磁盘
  26. // DISK-WRITE(newNode)
  27. // DISK-WRITE(childNode)
  28. // DISK-WRITE(node)
  29. }

有了上述保证,我们就可以大胆地调用insertNonFull方法去插入关键字了。下面给出insertNonFullJava实现代码:

  1. /**
  2. * 将关键字k插入到以node为根结点的子树,必须保证node结点不是满的
  3. *
  4. * @param node 要插入关键字的子树的根结点(必须保证node结点不是满的)
  5. * @param key 待插入的关键字
  6. */
  7. private void insertNonFull(BNode<K> node, K key) {
  8. int i = node.getSize() - 1;
  9. if (node.leaf) {
  10. // 若node是叶结点,直接将关键字插入到合适的位置(因为已经保证node结点是非满的)
  11. while (i > -1 && key.compareTo(node.getKeyAt(i)) < 0) {
  12. i--;
  13. }
  14. node.getKeys().add(i + 1, key);
  15. // DISK-WRITE(node)
  16. return;
  17. }
  18. // 若node不是叶结点,我们需要逐层下降(直到降到叶结点)的去找到key的合适位置
  19. while (i > -1 && key.compareTo(node.getKeyAt(i)) < 0) {
  20. i--;
  21. }
  22. i++;
  23. // 判断node的第i个子结点是否是满的
  24. if (node.getChildAt(i).getSize() == 2 * minDegree - 1) {
  25. // 若是满的,分裂
  26. splitNode(node, i);
  27. // 判断应该沿分裂后的哪一路下降
  28. if (key.compareTo(node.getKeyAt(i)) > 0) {
  29. i++;
  30. }
  31. }
  32. // 到了这一步,node.getChildAt(i)一定不是满的,直接递归下降
  33. insertNonFull(node.getChildAt(i), key);
  34. }

正如insertNonFull方法的名字那样,我们在调用该方法时,必须保证其参数中node代表的结点是非满的,这也是为什么在insert方法中,要保证根结点非满的原因。

insertNonFull方法实际上是一个递归操作,它不断的迭代子树,子树的高度每迭代一次就减1,直至子树就是一个叶子结点。

4.4 Delete 操作

B树的删除操作同样也较简单搜索树复杂,因为它不仅可以删除叶结点中的关键字,而且可从内部结点中删除关键字。和添加结点必须保证结点中的关键字不能过多一样,当从结点中删除关键字后,我们还要保证结点中的关键字不能够太少。因此删除操作其实可以看做是增加操作的“逆过程”。下面给出删除操作的算法。

该算法是一个递归算法,过程DELETE接受一个结点 \(x\) 和一个关键字 \(k\),它实现的功能时从以 \(x\) 为根的子树中删除关键字 \(k\)。该过程必须保证无论何时, \(x\) 结点中的关键字个数至少为最小度数 \(t\)(这比B树定义中要求的最小关键字个数 \(t - 1\) 多 1),这样能够使得我们可以把 \(x\) 中的 1 个关键字移动到子结点中,因此,我们可以采用递归下降的方法将关键字从树中删除,而不需要任何“向上回溯”(但有一个例外,之后会看到)。

为了以下说明的方便,我们为树中的节点编上坐标,规定 \(T(a, b)\) 表示树 \(T\) 中,第 \(a\) 层,从左往右数,第 \(b\) 个结点。例如:根结点的坐标是 \(T(1, 1)\);另外,需要提前说明的是以下例子中树的最小度数 \(t = 3\)。

下面分两大类情况讨论。

一、待删除的关键字 \(k\) 恰好在 \(x\) 中:

其中又分 2 小类情况:

1. 若 \(x\) 是叶节点,直接从 \(x\) 中删除 \(k\) 即可。

这种情况比较简单,以下面 2 张图为例,我们即将删除第 1 张图中 \(T(3, 2)\) 结点中的关键字 \(F\) ,删除后的 B 树如第 2 张图片所示:



**2. 若\(x\)是内部结点,又分以下 3 种情形讨论: **

情形A: 如果结点 \(x\) 中前于 \(k\) 的子结点 \(y\) 至少包含 \(t\) 个关键字,则找出 \(k\) 在以 \(y\) 为根的子树中的前驱 \(k'\),递归的删除 \(k'\),并在 \(x\) 中用 \(k'\) 代替 \(k\)(注意递归的意思)。

文字理解起来可能比较困难,下面结合一个例子来说明:

如上面图 (b)所示,现在想要删除 \(T(2, 1)\) 结点中的关键字 \(M\)。

检查 \(M\) 的左孩子 \(T(3, 3)\)(即前于 \(M\) 的子结点),它有 3 个关键字\(\{J, K, L\}\),满足至少有 \(t\) 个关键字的条件。因此**将关键字 \(L\)(即\(M\)的前驱)删除,并用 \(L\) 替代 \(M\) **。这样就得到了图 (c) 的结果。

注意,上述过程并没有就此结束。原因是将 \(L\) 删除后,原先\(L\) 所在结点的子结点便不合法了,多出来了一个,这时候需要将其子结点中的某个关键字提升到该结点中,之后又要处理子子结点……

到这时候你可能已经发现,这其实是一个递归的过程。

情形B: 如果前于 \(k\) 的子结点 \(y\) 中的关键字个数少于 \(t\) ,但后于 \(k\) 的子结点 \(z\) 中的关键字至少有 \(t\) 个,则找出 \(k\) 在以 \(y\) 为根的子树的后驱 \(k'\),递归地删除 \(k'\),并在 \(x\) 中用 \(k'\) 代替 \(k\)。

该情况和A类似,这里不在赘述。

情形C: 若 AB 情形都不满足,即关键字 \(k\) 的左右子结点 \(y,z\) 中的关键字的个数均小于 \(t\)(即为 \(t-1\)),则将关键字 \(k\) 和结点 \(z\) 中的关键字全部移动到结点\(y\),并删除 \(z\) 结点。这样问题就变为从结点 \(y\) 中删除关键字 \(k\),这又回到(或总会回到)前面讨论过的情形

举例说明,现在想要删除上面图 (c) 中 \(T(2, 1)\) 结点中的关键字 \(G\)。

检查 \(G\) 的左右孩子 \(T(3, 2), T(3, 3)\) 发现,它们包含的关键字均小于 \(t\),于是将 关键字 \(G\),以及结点 \(T(3, 3)\) 中的全部关键字( \(J, K\))移动到 \(T(3, 2)\) 中,这样 \(T(3, 2)\) 中便包含 \(D, E, G, J, K\) 5 个关键字。现在问题就转变为从结点 \(T(3,2)\) 中删除关键字 \(G\) ,这可以采用前面讨论的过程来解决。

最终得到的结果如下图所示:

二、待删除的关键字 \(k\) 不在 \(x\) 中:

在普通二叉搜索树的递归删除过程中,若当前结点不包含待删除的关键字,则到下一层寻找,递归上述操作,直到找到待删除关键字或者到达叶子结点为止。但在 B 树的删除算法中,为了消除类似于插入操作中遇到的“自底向上”操作现象,在向下递归的过程中,若发现下层结点包含的关键字个数为 \(t - 1\) ,在下降到该结点前,需要做如下二者之一的操作,以保证“降落”到的结点包含的关键字的个数总数大于 \(t - 1\) 的。

情形D: 如果 \(c_i\) 以及 \(c_i\) 的所有相邻兄弟都只包含 \(t-1\)个结点,则将 \(x.c_i\) 与一个兄弟结点合并,并将 \(x\) 的一个关键字移动到新合并的结点,成为中间关键字。

举例说明。在上面的图(d) 中,我们打算删除关键字 \(D\)。

从根结点(\(x\))开始向下搜寻包含关键字 \(D\) 的结点。显然,接下来会选择到结点 \(T(2, 1)\) 进行搜寻工作。但注意,此时结点 \(T(2, 1)\) 只包含 2 个关键字(\(C, L\)),而其所有的兄弟结点也都只包含 2 个关键字,因此需要将结点 \(x\) 中的一个关键字(只有 \(P\)),以及兄弟结点 \(T(2, 2)\) 中的全部关键字移动到 \(T(2, 1)\) 中,并删除该兄弟结点和结点 \(x\)(若此时结点 \(x\) 不包含任何关键字)。

删除后的情形如下图所示:

情形E: 如果 \(c_i\) 只包含 \(t-1\) 个关键字,但它的一个相邻的兄弟至少包含 \(t\) 个关键字,则将 \(x\) 中的某个关键字降至 \(c_i\),将相邻的一个兄弟中的关键字提升至 \(x\),并将该兄弟相应的孩子指针也移动到 \(c_i\) 中。

例如,想要删除上图(e')中的关键字 \(B\)。在根结点(\(x\))开始向下搜寻时,发现待“降落”的下级结点 \(T(2, 1)\) 包含 2 个关键字,而其兄弟结点 \(T(2, 2)\) 包含 3 个关键字,因此,将 \(x\) 中的关键字 \(C\) 下降到结点 \(T(2, 1)\)中,再将结点 \(T(2, 2)\) 中的关键字 \(E\) 提升到刚才下降的关键字 \(C\) 的位置。最后还需要将关键字 \(E\) 的左孩子移动到 \(T(2, 1)\) 中。

删除后的情形如下图所示:

以上便是整个删除操作的算法,下面给出具体的 Java 实现代码:

  1. /**
  2. * 从以node为根结点的子树中删除key
  3. *
  4. * @param node 子树的根结点(必须保证其中的关键字数至少为t)
  5. * @param key 要删除的关键字
  6. * @return 是否删除成功
  7. */
  8. private boolean delete(BNode<K> node, K key) {
  9. // node是叶结点,直接尝试从中删除key
  10. if (node.isLeaf()) {
  11. return node.getKeys().remove(key);
  12. }
  13. int pos = node.position(key);
  14. if (pos == node.getSize() || node.getKeyAt(pos).compareTo(key) != 0) {
  15. // node不包含关键字key
  16. BNode<K> childNode = node.getChildAt(pos);
  17. if (childNode.getSize() < minDegree) {
  18. // childNode关键字个数小于minDegree,需要增加
  19. BNode<K> leftSibling = null, rightSibling = null;
  20. if (pos > 0 && (leftSibling = node.getChildAt(pos - 1)).getSize() > minDegree - 1) {
  21. // 若childNode左兄弟中的关键字个数大于minDegree-1
  22. // 首先用左兄弟中最大的关键字去替换node中的相应结点
  23. K maxK = leftSibling.getKeys().removeLast();
  24. K tempK = node.setKeyAt(pos - 1, maxK);
  25. childNode.getKeys().addFirst(tempK);
  26. // 移动child(若存在child)
  27. if (!leftSibling.getChildren().isEmpty()) {
  28. BNode<K> maxNode = leftSibling.getChildren().removeLast();
  29. childNode.getChildren().addFirst(maxNode);
  30. }
  31. } else if (pos < node.getSize() && (rightSibling = node.getChildAt(pos + 1)).getSize() > minDegree - 1) {
  32. // 同上
  33. K minK = rightSibling.getKeys().removeFirst();
  34. K tempK = node.setKeyAt(pos, minK);
  35. childNode.getKeys().addLast(tempK);
  36. // 移动child(若存在child)
  37. if (!rightSibling.getChildren().isEmpty()) {
  38. BNode<K> minNode = rightSibling.getChildren().removeFirst();
  39. childNode.getChildren().addLast(minNode);
  40. }
  41. } else {
  42. // childNode的左右兄弟(若存在)中的关键字都小于minDegree
  43. // 合并
  44. if (leftSibling != null) {
  45. childNode.getKeys().addFirst(node.getKeyAt(pos - 1));
  46. childNode.getKeys().addAll(0, leftSibling.getKeys());
  47. childNode.getChildren().addAll(0, leftSibling.getChildren());
  48. node.getKeys().remove(pos - 1);
  49. node.getChildren().remove(pos - 1);
  50. } else if (rightSibling != null) {
  51. childNode.getKeys().addLast(node.getKeyAt(pos));
  52. childNode.getKeys().addAll(rightSibling.getKeys());
  53. childNode.getChildren().addAll(rightSibling.getChildren());
  54. node.getKeys().remove(pos);
  55. node.getChildren().remove(pos + 1);
  56. }
  57. if (node == root && node.getSize() == 0) {
  58. // 根结点为空,需要删除根结点
  59. height--;
  60. root = root.getChildAt(0);
  61. }
  62. }
  63. }
  64. // 此时一定能保证childNode中的关键字个数大于t-1
  65. return delete(childNode, key);
  66. }
  67. // node包含关键字key
  68. BNode<K> leftChildNode = node.getChildren().get(pos);
  69. if (leftChildNode.getSize() > minDegree - 1) {
  70. K maxKey = leftChildNode.getKeys().getLast();
  71. node.getKeys().set(pos, maxKey);
  72. return delete(leftChildNode, maxKey);
  73. }
  74. BNode<K> rightChildNode = node.getChildren().get(pos + 1);
  75. if (rightChildNode.getSize() > minDegree - 1) {
  76. K minKey = rightChildNode.getKeys().getFirst();
  77. node.getKeys().set(pos, minKey);
  78. return delete(rightChildNode, minKey);
  79. }
  80. leftChildNode.getKeys().add(node.getKeyAt(pos));
  81. leftChildNode.getKeys().addAll(rightChildNode.getKeys());
  82. leftChildNode.getChildren().addAll(rightChildNode.getChildren());
  83. node.getKeys().remove(pos);
  84. node.getChildren().remove(pos + 1);
  85. return delete(leftChildNode, key);
  86. }

以上代码都是根据前面的讨论写出来的,这里也不再多做说明。

该过程尽管看起来很复杂,但根据前面的分析我们可以得出,对于一棵高度为\(h\)的B树,它只需要\(O(h)\)次磁盘操作,所需CPU时间是\(O(th) = O(t log_tn)\)。

5. BTtreeMap

基于以上,我们可以自己实现一个Map玩玩,一下是完整的Java实现代码:

  1. import java.io.Serializable;
  2. import java.util.*;
  3. public class BTreeMap<K extends Comparable<K>, V> extends AbstractMap<K, V> implements Map<K, V>, Cloneable, Serializable {
  4. private Node root;
  5. private int size;
  6. private int height;
  7. private int minDegree, min, max;
  8. public BTreeMap() {
  9. this(3);
  10. }
  11. public BTreeMap(int minDegree) {
  12. if (minDegree < 0) {
  13. throw new IllegalArgumentException("minDegree must be greater than 0!");
  14. }
  15. this.minDegree = minDegree;
  16. this.min = minDegree - 1;
  17. this.max = 2 * minDegree - 1;
  18. this.root = new Node(true);
  19. }
  20. @Override
  21. public V get(Object key) {
  22. return search(root, (K) key); // 简单处理,直接强转
  23. }
  24. private V search(Node node, K key) {
  25. Iterator<Node> childrenIterator = node.children.iterator();
  26. int i = 0;
  27. for (Entry<K, V> entry : node.keys) {
  28. Node child = childrenIterator.hasNext() ? childrenIterator.next() : null;
  29. int compareRes = entry.getKey().compareTo(key);
  30. if (compareRes == 0) {
  31. return entry.getValue();
  32. }
  33. if (compareRes > 0 || i == node.keysSize() - 1) {
  34. if (compareRes > 0) {
  35. child = childrenIterator.hasNext() ? childrenIterator.next() : null;
  36. }
  37. if (node.isLeaf) return null;
  38. return search(child, key);
  39. }
  40. i++;
  41. }
  42. return null;
  43. }
  44. @Override
  45. public V put(K key, V value) {
  46. // 判断根结点是否是满的
  47. if (root.isFull()) {
  48. // 若是满的,则构造出一个空的结点,作为新的根结点
  49. Node newNode = new Node(false);
  50. newNode.addChild(root);
  51. Node oldRoot = root;
  52. root = newNode;
  53. splitNode(root, oldRoot, 0);
  54. height++;
  55. }
  56. Entry<K, V> entry = insertNonFull(root, new Entry<K, V>(key, value));
  57. return entry == null ? null : entry.getValue();
  58. }
  59. /**
  60. * 将关键字k插入到以node为根结点的子树,必须保证node结点不是满的
  61. *
  62. * @param node 要插入关键字的子树的根结点(必须保证node结点不是满的)
  63. * @param key 待插入的关键字
  64. */
  65. private Entry<K, V> insertNonFull(Node node, Entry<K, V> key) {
  66. int i = 0;
  67. // 因为node.keys使用的是LinkedList,因此使用迭代器迭代效率比较高
  68. Iterator<Node> childrenIterator = node.children.iterator();
  69. for (Entry<K, V> entry : node.keys) {
  70. Node child = childrenIterator.hasNext() ? childrenIterator.next() : null;
  71. int compareRes = key.compareTo(entry);
  72. if (compareRes == 0) {
  73. // key相等的情况,替换
  74. return node.keys.set(i, key); // TODO 效率不高!
  75. }
  76. if (compareRes < 0 || i == node.keysSize() - 1) {
  77. if (compareRes > 0) {
  78. i++;
  79. child = childrenIterator.hasNext() ? childrenIterator.next() : null;
  80. }
  81. // 当key < entry 或者 迭代到最后一个元素,此时i指向要插入位置。
  82. if (node.isLeaf) {
  83. node.keys.add(i, key);
  84. size++;
  85. return null;
  86. }
  87. if (child.isFull()) {
  88. Object[] nodeArray = splitNode(node, child, i);
  89. Node leftNode = (Node) nodeArray[0];
  90. Node rightNode = (Node) nodeArray[1];
  91. child = key.compareTo(leftNode.keys.getLast()) <= 0 ? leftNode : rightNode;
  92. }
  93. return insertNonFull(child, key);
  94. }
  95. i++;
  96. }
  97. // node是root,且为null的情况
  98. node.addKey(key);
  99. size++;
  100. return null;
  101. }
  102. /**
  103. * 分裂node的第i个子结点
  104. *
  105. * @param pNode 被分裂结点的父结点
  106. * @param node 被分裂结点
  107. * @param i 被分裂结点在其父结点children中的索引
  108. */
  109. private Object[] splitNode(Node pNode, Node node, int i) {
  110. int keysSize = node.keysSize();
  111. int ChildrenSize = node.childrenSize();
  112. LinkedList<Entry<K, V>> leftNodeKeys = new LinkedList<Entry<K, V>>(node.keys.subList(0, keysSize / 2));
  113. LinkedList<Node> leftNodeChildren = node.isLeaf ? new LinkedList<Node>() : new LinkedList<>(node.children.subList(0, (keysSize + 1) / 2));
  114. Node leftNode = new Node(leftNodeKeys, leftNodeChildren, node.isLeaf);
  115. LinkedList<Entry<K, V>> rightNodeKeys = new LinkedList<Entry<K, V>>(node.keys.subList(keysSize / 2 + 1, keysSize));
  116. LinkedList<Node> rightNodeChildren = node.isLeaf ? new LinkedList<Node>() : new LinkedList<>(node.children.subList((ChildrenSize + 1) / 2, ChildrenSize));
  117. Node rightNode = new Node(rightNodeKeys, rightNodeChildren, node.isLeaf);
  118. Entry<K, V> middleKey = node.getKey(keysSize / 2);
  119. pNode.addKey(i, middleKey);
  120. pNode.setChild(i, leftNode);
  121. pNode.addChild(i + 1, rightNode);
  122. // return new Node[]{leftNode, rightNode}; TODO: new 不出来
  123. return new Object[]{leftNode, rightNode};
  124. }
  125. @Override
  126. public Set<Map.Entry<K, V>> entrySet() {
  127. return null;
  128. }
  129. /**
  130. * B树的结点类
  131. */
  132. private class Node {
  133. private LinkedList<Entry<K, V>> keys;
  134. private LinkedList<Node> children;
  135. private boolean isLeaf;
  136. private K data;
  137. private Node(boolean isLeaf) {
  138. this(new LinkedList<Entry<K, V>>(), new LinkedList<Node>(), isLeaf);
  139. }
  140. private Node(LinkedList<Entry<K, V>> keys, LinkedList<Node> children, boolean isLeaf) {
  141. this.keys = keys;
  142. this.children = children;
  143. this.isLeaf = isLeaf;
  144. }
  145. private boolean isFull() {
  146. return keys.size() == max;
  147. }
  148. /**
  149. * 查找k,返回k在keys中的索引
  150. *
  151. * @param k
  152. * @return
  153. */
  154. private int indexOfKey(K k) {
  155. return keys.indexOf(k);
  156. }
  157. /**
  158. * 查找关键字在该结点的位置或其所在的根结点在该结点的位置
  159. *
  160. * @param k
  161. * @return i
  162. */
  163. private int position(Entry<K, V> k) {
  164. int i = 0;
  165. Iterator it = keys.iterator();
  166. for (Entry<K, V> key : keys) {
  167. if (key.compareTo(k) >= 0)
  168. return i;
  169. i++;
  170. }
  171. return i;
  172. }
  173. private boolean addKey(Entry<K, V> k) {
  174. return keys.add(k);
  175. }
  176. private void addKey(int i, Entry<K, V> k) {
  177. keys.add(i, k);
  178. }
  179. private boolean addChild(Node node) {
  180. return children.add(node);
  181. }
  182. private void addChild(int i, Node node) {
  183. children.add(i, node);
  184. }
  185. private Node setChild(int i, Node node) {
  186. return children.set(i, node);
  187. }
  188. private int keysSize() {
  189. return keys.size();
  190. }
  191. private int childrenSize() {
  192. return children.size();
  193. }
  194. private Entry<K, V> getKey(int i) {
  195. return keys.get(i);
  196. }
  197. private Entry<K, V> setKeyAt(int i, Entry<K, V> k) {
  198. return keys.set(i, k);
  199. }
  200. private Node getChild(int i) {
  201. return children.get(i);
  202. }
  203. @Override
  204. public String toString() {
  205. return keys.toString();
  206. }
  207. }
  208. /**
  209. * BEntry封装了key与value,它将做为Node的key
  210. *
  211. * @param <K>
  212. * @param <V>
  213. */
  214. public static class Entry<K extends Comparable<K>, V> extends SimpleEntry<K, V> implements Comparable<Entry<K, V>> {
  215. public Entry(K key, V value) {
  216. super(key, value);
  217. }
  218. /**
  219. * BEntry的比较其实为key的比较
  220. *
  221. * @param o
  222. * @return
  223. */
  224. @Override
  225. public int compareTo(Entry<K, V> o) {
  226. return getKey().compareTo(o.getKey());
  227. }
  228. }
  229. }

由于时间关系,暂时只实现了getput方法,其他方法以后有空再补上吧。

B树——算法导论(25)的更多相关文章

  1. 算法导论第十八章 B树

    一.高级数据结构 本章以后到第21章(并查集)隶属于高级数据结构的内容.前面还留了两章:贪心算法和摊还分析,打算后面再来补充.之前的章节讨论的支持动态数据集上的操作,如查找.插入.删除等都是基于简单的 ...

  2. "《算法导论》之‘树’":二叉查找树

    树的介绍部分摘取自博文二叉查找树(一).二叉查找树(二).二叉查找树. 1. 树的介绍 1.1 树的定义 树是一种数据结构,它是由n(n>=1)个有限节点组成一个具有层次关系的集合. 把它叫做“ ...

  3. C++实现二叉搜索书(参考算法导论)

    1 #include <iostream> 2 using namespace std; 3 4 struct node 5 { 6 // 数据域 7 int data; 8 9 // 左 ...

  4. 红黑树——算法导论(15)

    1. 什么是红黑树 (1) 简介     上一篇我们介绍了基本动态集合操作时间复杂度均为O(h)的二叉搜索树.但遗憾的是,只有当二叉搜索树高度较低时,这些集合操作才会较快:即当树的高度较高(甚至一种极 ...

  5. 基本数据结构(2)——算法导论(12)

    1. 引言     这一篇博文主要介绍链表(linked list),指针和对象的实现,以及有根树的表示. 2. 链表(linked list) (1) 链表介绍      我们在上一篇中提过,栈与队 ...

  6. 堆排序与优先队列——算法导论(7)

    1. 预备知识 (1) 基本概念     如图,(二叉)堆是一个数组,它可以被看成一个近似的完全二叉树.树中的每一个结点对应数组中的一个元素.除了最底层外,该树是完全充满的,而且从左向右填充.堆的数组 ...

  7. MIT算法导论——第一讲.Analysis of algorithm

    本栏目(Algorithms)下MIT算法导论专题是个人对网易公开课MIT算法导论的学习心得与笔记.所有内容均来自MIT公开课Introduction to Algorithms中Charles E. ...

  8. MIT算法导论——第二讲.Solving Recurrence

    本栏目(Algorithms)下MIT算法导论专题是个人对网易公开课MIT算法导论的学习心得与笔记.所有内容均来自MIT公开课Introduction to Algorithms中Charles E. ...

  9. MIT算法导论——第四讲.Quicksort

    本栏目(Algorithms)下MIT算法导论专题是个人对网易公开课MIT算法导论的学习心得与笔记.所有内容均来自MIT公开课Introduction to Algorithms中Charles E. ...

随机推荐

  1. JavaScript自定义浏览器滚动条兼容IE、 火狐和chrome

    今天为大家分享一下我自己制作的浏览器滚动条,我们知道用css来自定义滚动条也是挺好的方式,css虽然能够改变chrome浏览器的滚动条样式可以自定义,css也能够改变IE浏览器滚动条的颜色.但是css ...

  2. 用scikit-learn进行LDA降维

    在线性判别分析LDA原理总结中,我们对LDA降维的原理做了总结,这里我们就对scikit-learn中LDA的降维使用做一个总结. 1. 对scikit-learn中LDA类概述 在scikit-le ...

  3. 【原】nodejs全局安装和本地安装的区别

    来微信支付有2年多了,从2年前的互联网模式转变为O2O模式,主要的场景是跟线下的商户去打交道,不像以往的互联网模式,有产品经理提需求,我们帮忙去解决问题. 转型后是这样的,团队成员更多需要去寻找业务的 ...

  4. 【翻译】MongoDB指南/CRUD操作(二)

    [原文地址]https://docs.mongodb.com/manual/ MongoDB CRUD操作(二) 主要内容: 更新文档,删除文档,批量写操作,SQL与MongoDB映射图,读隔离(读关 ...

  5. Angular企业级开发(3)-Angular MVC实现

    1.MVC介绍 Model-View-Controller 在20世纪80年代为程序语言Smalltalk发明的一种软件架构.MVC模式的目的是实现一种动态的程序设计,使后续对程序的修改和扩展简化,并 ...

  6. [.NET] 打造一个很简单的文档转换器 - 使用组件 Spire.Office

    打造一个很简单的文档转换器 - 使用组件 Spire.Office [博主]反骨仔 [原文]http://www.cnblogs.com/liqingwen/p/6024827.html 序 之前,& ...

  7. 【原创分享·微信支付】C# MVC 微信支付教程系列之现金红包

            微信支付教程系列之现金红包           最近最弄这个微信支付的功能,然后扫码.公众号支付,这些都做了,闲着无聊,就看了看微信支付的其他功能,发现还有一个叫“现金红包”的玩意,想 ...

  8. 谈谈JS的观察者模式(自定义事件)

    呼呼...前不久参加了一个笔试,里面有一到JS编程题,当时看着题目就蒙圈...后来研究了一下,原来就是所谓的观察者模式.就记下来...^_^ 题目 [附加题] 请实现下面的自定义事件 Event 对象 ...

  9. PHP设计模式(六)原型模式(Prototype For PHP)

    原型设计模式: 用原型实例指定创建对象的种类,并且通过拷贝这些原型创建新的对象. 原型设计模式简单的来说,顾名思义, 不去创建新的对象进而保留原型的一种设计模式. 缺点:原型设计模式是的最主要的缺点就 ...

  10. HttpPost过程中使用的URLEncoder.encode(something, encode)

    URLEncoder.encode("刘美美", "utf-8").toString()       =     %E5%88%98%E7%BE%8E%E7%B ...