在以前咱们学习了红黑树,今天再学习一种树——B树。它与红黑树有许多相似的地方,好比都是平衡搜索树, 但它们在功能和结构上却有较大的差异。html
从功能上看,B树是为磁盘或其余存储设备设计的,可以有效的下降磁盘的I/O操做数,所以咱们常常看到有许多数据库系统使用B树或B树的变种做为储存的数据结构;从结构上看,B树的结点能够有不少孩子,从数个到数千个,这一般依赖于所使用的磁盘的单元特性。java
以下图,给出了一棵简单的B树。node
从图中咱们能够发现,若是一个内部结点包含n个关键字,那么结点就有n+1个孩子。例如,根结点有1个关键字M
,它有2个孩子;它的左孩子包含2个关键字,能够看到它有3个孩子。之因此是n+1个孩子,是由于B树的结点中的关键字是分割点,n个关键字正好分隔出n+1个子域,每一个子域都对应一个孩子。算法
在以前咱们提到,B树是为磁盘或其余存储设备设计的。所以,在正式介绍B树以前,咱们有必要弄清楚为何针对磁盘设计的数据结构有别于针对随机访问的主存所设计的数据结构,只有这样才能更好理解B树的优点。数据库
咱们知道,磁盘比主存便宜且有更多的容量,可是它比主存要慢许多,一般会慢出4~5个数量级。为了提升磁盘的读写效率,操做系统在读写磁盘时,会一次存取多个数据而不是一个。在磁盘中,信息被分为一系列相等大小的,在柱面内连续出现的位页面(page),每次磁盘读或写一个或多个完整的页面。一般,一页的长度多是\(2^{11} -2^{14}\)字节。数据结构
所以,在本篇博客中,咱们对运行时间的衡量主要从如下两个方面考虑:ide
咱们用读出或写入磁盘的信息的页数来衡量磁盘存取的次数。注意到,磁盘存取时间并非常量——它与当前磁道和所需磁道之间的距离以及磁盘的初始旋转状态有关,可是为了简单起见,咱们仍然使用读或写的页数做为磁盘存取总时间的近似值。学习
在一个典型的B树应用中,所需处理的数据很是大,以致于全部的数据没法一次转入主存。B树算法将所需页面从磁盘复制到主存,若进行了修改,以后则会写回磁盘。所以,B树算法在任什么时候刻都只须要在主存中保存必定数量的页面,主存的大小并不限制被处理的B树的大小。this
下面用几行伪代码来模拟对磁盘的操做。设x为指向一个对象的指针,咱们在使用x(指向的对象)时,须要先判断x指向的对象是否在主存中,若在则能够直接使用;不然须要将其从磁盘读入到主存,而后才能使用。spa
x = a pointer to some object DISK-READ(x) // 将x读入主存,若x已经在主存中,则该操做至关于空操做 modify x DISK-WRITE(x) // 将x写回主存,若x未修改,则该操做至关于空操做
由上咱们看出,一个B树算法的运行时间主要由它所执行的DISK-READ和DISK-WRITE操做的次数决定,因此咱们但愿这些操做可以读或写尽量多的信息。所以,一个B树结点一般设计的和一个完整磁盘页同样大,这将使得磁盘页的大小限制B树结点能够含有的孩子(关键字)的个数。
以下图是一棵高度为2(这里计算高度时不计算根结点)的B树,它的每一个结点有1000个关键字,所以分支因子(孩子的个数)为1001,因而它能够储存\(1000*(1 + 1001 + 1001 *1001)\)个关键字,其数量超过10亿。咱们若是将根结点保存在主存中,那么在查找树中任意一个关键字时,至多只须要读取2次磁盘。
下面正式给出B树的定义。一棵B树\(T\)必须具有以下性质:
下面用Java实现以上定义:
import java.util.List; /** * B树 * * @param <K> B树储存元素的类型 */ public class BTree<K extends Comparable<K>> { private BNode<K> root; private int height; private int minDegree; /** * B树的结点类 */ public static class BNode<K extends Comparable<K>> { private List<K> keys; private List<BNode> children; private int size; private boolean leaf; } // setter、getter ... }
咱们抽象出表明结点的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 \]
同其它的二叉搜索树同样,咱们主要关心B树的 search 、 create 、 insert 和 delete 操做。首先作两个约定:
首先考察搜索操做。它与普通的二叉搜索相似,只不过它多了几个“叉”,须要进行屡次判断。
记B树\(T\)的根结点(的指针)为\(root\),如今要在\(T\)中搜索关键字\(k\)。若是\(k\)在树中,则返回对应结点(的指针)\(y\)和\(y.key_i = k\)的下标\(i\)组成的有序对\((y, i)\);不然返回空。
下面给出Java的实现:
private SearchResult<K> search(BNode<K> currentNode, K k) { int i = 0; // 此处也可采用二分查找 while (i < currentNode.size && k.compareTo(currentNode.getKeys().get(i)) > 0) { i++; } if (i < currentNode.size && k.compareTo(currentNode.getKeys().get(i)) == 0) { return new SearchResult<K>(currentNode, i); } if (currentNode.leaf) { return null; } // DISK-READ(currentNode.getChildren()[i]) return search(currentNode.getChildren().get(i), k); } public static class SearchResult<K extends Comparable<K>> { public BNode<K> bNode; public int keyIndex; public SearchResult(BNode<K> bNode, int keyIndex) { this.bNode = bNode; this.keyIndex = keyIndex; } }
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)\)。
为构造一棵B树,咱们先用create方法来建立一棵空树(根结点为空),而后调用insert操做来添加一个新的关键字。这两个过程有一个公共的过程,即allocate-node,它在\(O(1)\)时间内为一个新结点分配一个磁盘页。
因为create操做很简单,下面只给出伪代码:
create(T) x = allocate-node() x.leaf = TRUE x.n = 0 DISK-WRITE(x) T.root = x
在B树上进行insert操做较为麻烦。和普通二叉搜索树同样,咱们必须先根据关键字找到要插入的位置,但不是插入就结束了。由于插入新结点的操做可能会致使B树不合法。
B树算法采用的作法是:在插入前,先判断结点是不是满的,若非满,那就直接插入;不然就将该结点一分为二,分裂为两个结点,而中间的关键字插入到其父结点中。
以下图所示,B树的最小度数 \(t=4\),所以包含 \([P, Q, R, S, T, U, V]\) 关键字的结点过满,须要分裂。其操做步骤是:将处在中间位置的关键字 \(S\) 提高到其父结点中,剩余关键字随着结点一分为二。
特别提醒:上图截取自《算法导论(第三版),机械工业出版社》,其中右侧部分中的关键字W
和S
的顺序弄反了!!!
须要注意的是,将中间关键字提高至父结点后,又可能致使父结点过满,此时须要用一样的方法处理父结点。此过程可能会持续发生,造成自底向上的分裂现象。
既然如此,能够采用一种更加巧妙的办法:在逐层向下查找待插入关键字的位置过程当中,只要遇到满的结点,就进行分裂。这样一来,当关键字提高到父结点时,就不会形成父结点过满了。
特别地,因为根结点没有父结点,对于过满的根结点,须要新建一个空的根结点,原根结点中间位置的关键字上升到新建的空结点中。以下图所示:
由上咱们能够看出,对满的非根结点的分裂不会使B树的高度增长,致使B树高度增长的惟一方式是对根结点的分裂。
下面给出分裂过程的Java代码:
/** * 分裂node的第i个子结点 * * @param node 非满的内部结点 * @param i 第i个子结点 */ private void splitNode(BNode<K> node, int i) { BNode<K> childNode = node.getChildAt(i); int fullSize = childNode.getSize(); // 从满结点childNode中截取后半部分 List<K> newNodeKeys = childNode.getKeys().subList(fullSize / 2 + 1, fullSize - 1); List<BNode<K>> newNodeChildren = childNode.getChildren().subList((fullSize + 1) / 2, fullSize); BNode<K> newNode = new BNode<>(newNodeKeys, newNodeChildren, childNode.leaf); // 从新设置满结点childNode的size,而没必要截取掉后半部分 childNode.setSize(fullSize / 2); // 将childNode的中间关键字插入node中 K middle = childNode.getKeyAt(fullSize / 2); node.getKeys().add(i, middle); // 将分裂出的结点newNodeKeys挂到node中 node.getChildren().add(i + 1, newNode); // 更新size node.setSize(node.getSize() + 1); // 写入磁盘 // DISK-WRITE(newNode) // DISK-WRITE(childNode) // DISK-WRITE(node) }
代码中的注释基本给出的每部操做的目的,这里再也不赘述。实现了分裂过程,咱们接下来就能够写insert过程了:
/** * 插入关键字 * * @param key 待插入的关键字 */ public void insert(K key) { // 判断根结点是不是满的 if (root.getSize() == 2 * minDegree - 1) { // 如果满的,则构造出一个空的结点,做为新的根结点 LinkedList<K> newRootKeys = new LinkedList<K>(); LinkedList<BNode<K>> newRootChildren = new LinkedList<BNode<K>>(); newRootChildren.add(root); root = new BNode<K>(newRootKeys, newRootChildren, false); splitNode(root, 0); height++; } insertNonFull(root, key); }
以上代码中,首先判断根结点是否满了,若满了,就构造出一个新的根结点,将之前的根结点挂到其下,注意此时新的根结点中尚未关键字,接着调用splitNode
方法去分裂旧的根结点,这样处理下来,就能保证根结点是非满状态了。如下是splitNode
过程的Java代码:
/** * 分裂node的第i个子结点 * * @param node 待分裂结点的父结点(注意不是待分裂的结点) * @param i 第i个子结点 */ private void splitNode(BNode<K> node, int i) { BNode<K> childNode = node.getChildAt(i); int childKeysSize = childNode.getSize(); int childChildrenSize = childNode.getChildren().size(); // 从满结点childNode中截取后半部分做为分裂的右结点 LinkedList<K> rightNodeKeys = new LinkedList<K>(childNode.getKeys().subList(childKeysSize / 2 + 1, childKeysSize)); LinkedList<BNode<K>> rightNodeChildren = childNode.getChildren().isEmpty() ? new LinkedList<BNode<K>>() : new LinkedList<>(childNode.getChildren().subList((childChildrenSize + 1) / 2, childChildrenSize)); BNode<K> rightNode = new BNode<>(rightNodeKeys, rightNodeChildren, childNode.leaf); // 从满结点childNode中截取前半部分做为分裂的左结点 LinkedList<K> leftNodeKeys = new LinkedList<K>(childNode.getKeys().subList(0, childKeysSize / 2)); LinkedList<BNode<K>> leftNodeChildren = childNode.getChildren().isEmpty() ? new LinkedList<BNode<K>>() : new LinkedList<>(childNode.getChildren().subList(0, (childKeysSize + 1) / 2)); BNode<K> leftNode = new BNode<>(leftNodeKeys, leftNodeChildren, childNode.leaf); node.getChildren().set(i, leftNode); // 将childNode的中间关键字插入node中 K middle = childNode.getKeyAt(childKeysSize / 2); node.getKeys().add(i, middle); // 将分裂出的结点newNodeKeys挂到node中 node.getChildren().add(i + 1, rightNode); // 写入磁盘 // DISK-WRITE(newNode) // DISK-WRITE(childNode) // DISK-WRITE(node) }
有了上述保证,咱们就能够大胆地调用insertNonFull
方法去插入关键字了。下面给出insertNonFull
的Java实现代码:
/** * 将关键字k插入到以node为根结点的子树,必须保证node结点不是满的 * * @param node 要插入关键字的子树的根结点(必须保证node结点不是满的) * @param key 待插入的关键字 */ private void insertNonFull(BNode<K> node, K key) { int i = node.getSize() - 1; if (node.leaf) { // 若node是叶结点,直接将关键字插入到合适的位置(由于已经保证node结点是非满的) while (i > -1 && key.compareTo(node.getKeyAt(i)) < 0) { i--; } node.getKeys().add(i + 1, key); // DISK-WRITE(node) return; } // 若node不是叶结点,咱们须要逐层降低(直到降到叶结点)的去找到key的合适位置 while (i > -1 && key.compareTo(node.getKeyAt(i)) < 0) { i--; } i++; // 判断node的第i个子结点是不是满的 if (node.getChildAt(i).getSize() == 2 * minDegree - 1) { // 如果满的,分裂 splitNode(node, i); // 判断应该沿分裂后的哪一路降低 if (key.compareTo(node.getKeyAt(i)) > 0) { i++; } } // 到了这一步,node.getChildAt(i)必定不是满的,直接递归降低 insertNonFull(node.getChildAt(i), key); }
正如insertNonFull
方法的名字那样,咱们在调用该方法时,必须保证其参数中node
表明的结点是非满的,这也是为何在insert
方法中,要保证根结点非满的缘由。
insertNonFull
方法其实是一个递归操做,它不断的迭代子树,子树的高度每迭代一次就减1,直至子树就是一个叶子结点。
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\)。
下面分两大类状况讨论。
1、待删除的关键字 \(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: 若
A
和B
情形都不知足,即关键字 \(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\) ,这能够采用前面讨论的过程来解决。
最终获得的结果以下图所示:
2、待删除的关键字 \(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 实现代码:
/** * 从以node为根结点的子树中删除key * * @param node 子树的根结点(必须保证其中的关键字数至少为t) * @param key 要删除的关键字 * @return 是否删除成功 */ private boolean delete(BNode<K> node, K key) { // node是叶结点,直接尝试从中删除key if (node.isLeaf()) { return node.getKeys().remove(key); } int pos = node.position(key); if (pos == node.getSize() || node.getKeyAt(pos).compareTo(key) != 0) { // node不包含关键字key BNode<K> childNode = node.getChildAt(pos); if (childNode.getSize() < minDegree) { // childNode关键字个数小于minDegree,须要增长 BNode<K> leftSibling = null, rightSibling = null; if (pos > 0 && (leftSibling = node.getChildAt(pos - 1)).getSize() > minDegree - 1) { // 若childNode左兄弟中的关键字个数大于minDegree-1 // 首先用左兄弟中最大的关键字去替换node中的相应结点 K maxK = leftSibling.getKeys().removeLast(); K tempK = node.setKeyAt(pos - 1, maxK); childNode.getKeys().addFirst(tempK); // 移动child(若存在child) if (!leftSibling.getChildren().isEmpty()) { BNode<K> maxNode = leftSibling.getChildren().removeLast(); childNode.getChildren().addFirst(maxNode); } } else if (pos < node.getSize() && (rightSibling = node.getChildAt(pos + 1)).getSize() > minDegree - 1) { // 同上 K minK = rightSibling.getKeys().removeFirst(); K tempK = node.setKeyAt(pos, minK); childNode.getKeys().addLast(tempK); // 移动child(若存在child) if (!rightSibling.getChildren().isEmpty()) { BNode<K> minNode = rightSibling.getChildren().removeFirst(); childNode.getChildren().addLast(minNode); } } else { // childNode的左右兄弟(若存在)中的关键字都小于minDegree // 合并 if (leftSibling != null) { childNode.getKeys().addFirst(node.getKeyAt(pos - 1)); childNode.getKeys().addAll(0, leftSibling.getKeys()); childNode.getChildren().addAll(0, leftSibling.getChildren()); node.getKeys().remove(pos - 1); node.getChildren().remove(pos - 1); } else if (rightSibling != null) { childNode.getKeys().addLast(node.getKeyAt(pos)); childNode.getKeys().addAll(rightSibling.getKeys()); childNode.getChildren().addAll(rightSibling.getChildren()); node.getKeys().remove(pos); node.getChildren().remove(pos + 1); } if (node == root && node.getSize() == 0) { // 根结点为空,须要删除根结点 height--; root = root.getChildAt(0); } } } // 此时必定能保证childNode中的关键字个数大于t-1 return delete(childNode, key); } // node包含关键字key BNode<K> leftChildNode = node.getChildren().get(pos); if (leftChildNode.getSize() > minDegree - 1) { K maxKey = leftChildNode.getKeys().getLast(); node.getKeys().set(pos, maxKey); return delete(leftChildNode, maxKey); } BNode<K> rightChildNode = node.getChildren().get(pos + 1); if (rightChildNode.getSize() > minDegree - 1) { K minKey = rightChildNode.getKeys().getFirst(); node.getKeys().set(pos, minKey); return delete(rightChildNode, minKey); } leftChildNode.getKeys().add(node.getKeyAt(pos)); leftChildNode.getKeys().addAll(rightChildNode.getKeys()); leftChildNode.getChildren().addAll(rightChildNode.getChildren()); node.getKeys().remove(pos); node.getChildren().remove(pos + 1); return delete(leftChildNode, key); }
以上代码都是根据前面的讨论写出来的,这里也再也不多作说明。
该过程尽管看起来很复杂,但根据前面的分析咱们能够得出,对于一棵高度为\(h\)的B树,它只须要\(O(h)\)次磁盘操做,所需CPU时间是\(O(th) = O(t log_tn)\)。
基于以上,咱们能够本身实现一个Map玩玩,一下是完整的Java实现代码:
import java.io.Serializable; import java.util.*; public class BTreeMap<K extends Comparable<K>, V> extends AbstractMap<K, V> implements Map<K, V>, Cloneable, Serializable { private Node root; private int size; private int height; private int minDegree, min, max; public BTreeMap() { this(3); } public BTreeMap(int minDegree) { if (minDegree < 0) { throw new IllegalArgumentException("minDegree must be greater than 0!"); } this.minDegree = minDegree; this.min = minDegree - 1; this.max = 2 * minDegree - 1; this.root = new Node(true); } @Override public V get(Object key) { return search(root, (K) key); // 简单处理,直接强转 } private V search(Node node, K key) { Iterator<Node> childrenIterator = node.children.iterator(); int i = 0; for (Entry<K, V> entry : node.keys) { Node child = childrenIterator.hasNext() ? childrenIterator.next() : null; int compareRes = entry.getKey().compareTo(key); if (compareRes == 0) { return entry.getValue(); } if (compareRes > 0 || i == node.keysSize() - 1) { if (compareRes > 0) { child = childrenIterator.hasNext() ? childrenIterator.next() : null; } if (node.isLeaf) return null; return search(child, key); } i++; } return null; } @Override public V put(K key, V value) { // 判断根结点是不是满的 if (root.isFull()) { // 如果满的,则构造出一个空的结点,做为新的根结点 Node newNode = new Node(false); newNode.addChild(root); Node oldRoot = root; root = newNode; splitNode(root, oldRoot, 0); height++; } Entry<K, V> entry = insertNonFull(root, new Entry<K, V>(key, value)); return entry == null ? null : entry.getValue(); } /** * 将关键字k插入到以node为根结点的子树,必须保证node结点不是满的 * * @param node 要插入关键字的子树的根结点(必须保证node结点不是满的) * @param key 待插入的关键字 */ private Entry<K, V> insertNonFull(Node node, Entry<K, V> key) { int i = 0; // 由于node.keys使用的是LinkedList,所以使用迭代器迭代效率比较高 Iterator<Node> childrenIterator = node.children.iterator(); for (Entry<K, V> entry : node.keys) { Node child = childrenIterator.hasNext() ? childrenIterator.next() : null; int compareRes = key.compareTo(entry); if (compareRes == 0) { // key相等的状况,替换 return node.keys.set(i, key); // TODO 效率不高! } if (compareRes < 0 || i == node.keysSize() - 1) { if (compareRes > 0) { i++; child = childrenIterator.hasNext() ? childrenIterator.next() : null; } // 当key < entry 或者 迭代到最后一个元素,此时i指向要插入位置。 if (node.isLeaf) { node.keys.add(i, key); size++; return null; } if (child.isFull()) { Object[] nodeArray = splitNode(node, child, i); Node leftNode = (Node) nodeArray[0]; Node rightNode = (Node) nodeArray[1]; child = key.compareTo(leftNode.keys.getLast()) <= 0 ? leftNode : rightNode; } return insertNonFull(child, key); } i++; } // node是root,且为null的状况 node.addKey(key); size++; return null; } /** * 分裂node的第i个子结点 * * @param pNode 被分裂结点的父结点 * @param node 被分裂结点 * @param i 被分裂结点在其父结点children中的索引 */ private Object[] splitNode(Node pNode, Node node, int i) { int keysSize = node.keysSize(); int ChildrenSize = node.childrenSize(); LinkedList<Entry<K, V>> leftNodeKeys = new LinkedList<Entry<K, V>>(node.keys.subList(0, keysSize / 2)); LinkedList<Node> leftNodeChildren = node.isLeaf ? new LinkedList<Node>() : new LinkedList<>(node.children.subList(0, (keysSize + 1) / 2)); Node leftNode = new Node(leftNodeKeys, leftNodeChildren, node.isLeaf); LinkedList<Entry<K, V>> rightNodeKeys = new LinkedList<Entry<K, V>>(node.keys.subList(keysSize / 2 + 1, keysSize)); LinkedList<Node> rightNodeChildren = node.isLeaf ? new LinkedList<Node>() : new LinkedList<>(node.children.subList((ChildrenSize + 1) / 2, ChildrenSize)); Node rightNode = new Node(rightNodeKeys, rightNodeChildren, node.isLeaf); Entry<K, V> middleKey = node.getKey(keysSize / 2); pNode.addKey(i, middleKey); pNode.setChild(i, leftNode); pNode.addChild(i + 1, rightNode); // return new Node[]{leftNode, rightNode}; TODO: new 不出来 return new Object[]{leftNode, rightNode}; } @Override public Set<Map.Entry<K, V>> entrySet() { return null; } /** * B树的结点类 */ private class Node { private LinkedList<Entry<K, V>> keys; private LinkedList<Node> children; private boolean isLeaf; private K data; private Node(boolean isLeaf) { this(new LinkedList<Entry<K, V>>(), new LinkedList<Node>(), isLeaf); } private Node(LinkedList<Entry<K, V>> keys, LinkedList<Node> children, boolean isLeaf) { this.keys = keys; this.children = children; this.isLeaf = isLeaf; } private boolean isFull() { return keys.size() == max; } /** * 查找k,返回k在keys中的索引 * * @param k * @return */ private int indexOfKey(K k) { return keys.indexOf(k); } /** * 查找关键字在该结点的位置或其所在的根结点在该结点的位置 * * @param k * @return i */ private int position(Entry<K, V> k) { int i = 0; Iterator it = keys.iterator(); for (Entry<K, V> key : keys) { if (key.compareTo(k) >= 0) return i; i++; } return i; } private boolean addKey(Entry<K, V> k) { return keys.add(k); } private void addKey(int i, Entry<K, V> k) { keys.add(i, k); } private boolean addChild(Node node) { return children.add(node); } private void addChild(int i, Node node) { children.add(i, node); } private Node setChild(int i, Node node) { return children.set(i, node); } private int keysSize() { return keys.size(); } private int childrenSize() { return children.size(); } private Entry<K, V> getKey(int i) { return keys.get(i); } private Entry<K, V> setKeyAt(int i, Entry<K, V> k) { return keys.set(i, k); } private Node getChild(int i) { return children.get(i); } @Override public String toString() { return keys.toString(); } } /** * BEntry封装了key与value,它将作为Node的key * * @param <K> * @param <V> */ public static class Entry<K extends Comparable<K>, V> extends SimpleEntry<K, V> implements Comparable<Entry<K, V>> { public Entry(K key, V value) { super(key, value); } /** * BEntry的比较其实为key的比较 * * @param o * @return */ @Override public int compareTo(Entry<K, V> o) { return getKey().compareTo(o.getKey()); } } }
因为时间关系,暂时只实现了get
和put
方法,其余方法之后有空再补上吧。