原文出处:B树、B+树索引算法原理(上)

这一段时间由于在阅读boltdb代码的缘故,找机会学习了B树及B+树的算法原理,这个系列会花两个篇幅分别介绍这两种数据结构的实现,其用于数据库索引中的基本原理。

B树数据库索引原理

在一堆数据中查找一个数据时,常用的数据结构有二叉查找树(binary search tree,简称BST)、哈希桶等。以BST为例,常见的实现有AVT、红黑树等,由于这类型的树是平衡的,每次比较操作都会去掉当前数据量一半的数据,因此查找的时间复杂度为O(log2n)

bst-example

但是这类型数据结构的问题在于,由于每个节点只能容纳一个数据,导致树的高度很高,逻辑上挨着的节点数据可能离的很远。如果是在内存中操作数据的话,这样问题并不大。

考虑在磁盘中存储数据的情况,与内存相比,读写磁盘有以下不同点:

因为读写磁盘的这个特点,因此对应的数据结构应该尽量的满足“局部性原理”:“当一个数据被用到时,其附近的数据也通常会马上被使用”,为了满足局部性原理,应该:将逻辑上相邻的数据在物理上也尽量存储在一起。这样才能减少读写磁盘的数量。

所以,对比起一个节点只能存储一个数据的BST类数据结构来,要求这种数据结构在形状上更“胖”、更加“扁平”,即:每个节点能容纳更多的数据,这样就能降低树的高度,同时让逻辑上相邻的数据都能尽量的存储在物理上也相邻的硬盘空间上,减少磁盘读写。

以下图为例:

disk-ds

图中从根节点出发,查找数据14的过程中,经过的第二个节点中有键值[3,7,13],这三个值在“逻辑”上是相邻的,如果它们在磁盘上的存储也能做到在“物理”上相邻,那么只需要一次读操作就能把这个节点的数据从磁盘上加载到内存中进行数据比较,这样整个查找过程就只需要两次磁盘读操作。

在这里,一个节点越“胖”,意味着扇出(fanout)越大,同时高度越低,这两个性质决定了:

可以证明,查找数据的次数(searchnum)与degree、以及数据总量有以下关系:

btree-num

B树和B+树就是两种利用磁盘局部性原理进行优化的树结构,B+树基于B树做了一些改进,这里首先将介绍B树的原理。本系列将用两篇文章讲解这两种数据结构的原理,并且提供Python实现代码。

B树的定义及性质

在B树中,分为两种节点:

一个数据,既可能存在内部节点上,也可能存在叶子节点上,这一点是与后面讲到的B+树最大的不同,后者只会将数据存储在叶子节点上。

创建B树时,需要输入一个degree参数(以下简写为t),该参数决定了每个节点上数据量的多少,即节点的“胖”、“瘦”程度,而节点的胖瘦程度又会影响整棵树的高度,因为越胖的节点树高度就会越矮。

为了维持B树的平衡性,需要满足以下的属性:

我们来看下面的图示,该图中的B树,t参数的值为2(需要特别说明的是,一棵树中每个存储数据的地方,应该既有键值(key)也有数据(value),本文中为了简单起见,存储的数据只有键值。):

btree-example

在上图中:

B树算法原理

了解了B树的性质,下面讨论B树中的两个核心操作:插入及删除。这两个操作的核心,都是在操作如果破坏了B树的平衡性之后,进行重新平衡以满足B树的性质。

插入数据

向B树中插入一个数据,可能会导致节点的数据变满,即不满足上面提到的节点数据数量在[t,2t-1]这个性质。此时需要对节点进行分裂节点操作:

分裂操作的示意图如下:

btree-split

在上图中,由于插入数据,导致节点[P,Q,R,S,T,U,V]数据量不满足平衡性要求,这时将其分裂为两个节点,同时将中间的节点S提升到父节点中形成[N,S,W],同时修改子树指针。

因此,向B树中插入一个数据的大体流程如下:

向B树中插入数据
  找到插入数据所在的最合适节点
  如果该节点的数据量已满
    进行分裂操作
    插入数据
  否则
    插入数据

由于《算法导论》中详细给出了插入流程的伪代码,这里就不再一并列出。

删除数据

与插入操作相同,删除操作也可能会破坏B树的性质,需要进行重新平衡操作。

B树的删除算法,在沿着树向下查找待删除节点的流程中,依次看沿路的节点是否不满足至少有t个节点的条件,如果不满足这个条件就首先对这个节点进行平衡操作。由于在一开始就让沿路节点都至少有t个节点,这样在删除数据之后也至少能有t-1个数据满足平衡条件,这样就不用在删除之后还要回溯到祖先节点进行重平衡操作了。

我认为这是B树删除算法最核心的地方:如果已知一个回溯操作不可避免,又无法预测到底在哪里发生,就在路上提前解决掉可能需要进行回溯操作的节点。

下面将在B树中删除一个数据分为以下几种情况进行讨论。

情况1:数据存在于叶子节点

在这种情况下,只需简单的从叶子节点中删除数据即可,如下图中,从节点[4,5,6]中删除数据6,最开始的图如下:

btreeCase1

下图演示了删除数据6之后的情况:

btreeCase1step1

上图只是最简单的情况,因为[4,5,6]节点数据量即使在删除一个数据之后仍然满足平衡条件,不满足平衡条件的将在下面展开说明。

情况2:数据存在于内部节点

这种情况下,如果该内部节点的数据数量不大于t,则需要做重新平衡操作,这里区分成两种情况:

下面分这两种情况进行演示。

情况2-a:借用相邻左右子树节点

首先演示从兄弟节点中借用数据的情况,这是从[3,7,13]的节点在删除13之前的示意图:

btreeCase2step1

含有键值[3,7,13]的节点在删除13之后,由于其相邻子树[10,11,12]有足够的数据数量,因此可以从旁边的子树抽调了数据12,提升到该节点中替换了13,于是变成了[3,7,12],而子树的数据也从[10,11,12]变成了[10,11]

btreeCase2step2

情况2-b:合并左右相邻子树节点

除了上面两种从旁边子树借用数据的操作之外,如果左右两边的子树数据量都不满足不少于t的情况,那么只能将两者进行合并了,如下图所示。

在从节点[3,7,12]中删除键值7之前的图示如下图,这时该节点的左右两边的子树[4,5][10,11]都不满足借用条件:

btreeCase2step3

在上图的基础上,从节点[3,7,12]中删除键值7,由于该键值左右两个子树[4,5]和][10,11]都不满足数据量不少于t的条件,所以只能将两者合并成一个节点:

btreeCase2step4

情况3:数据不直接存在于某个内部节点的情况

上面两种情况,是键值直接存在于特定(叶子、内部)节点时的操作,最后一种情况则是针对它们的父节点进行操作。

在这种情况中,如果待删除数据并不存在于节点X中,那么找到必然包含该数据的X的子节点X.child[i](假设为节点Y),如果这时候Y节点数据数量不满足t这个条件,则需要用下面两种方式进行重平衡。

需要特别注意的是,这里的节点X、Y并不是直接包含待删除数据的节点,可能是待删除节点的父节点或者祖先节点,第三种情况要做的是:在查找待删除数据的路径上,发现哪些不满足数据量是t的节点,就地进行重平衡操作,而不用等待回溯。

情况3-a:借用兄弟节点

在这种情况中,如果Y节点其键值数量不满足不少于t的条件,同时其兄弟节点够数量借用给它,那么:

假设删除数据2之前的图示如下:

btreeCase3bstep1

因为节点[1,2]在删除数据2之后不满足平衡条件,所以需要重平衡。其兄弟节点[5,10,11]够数量,因此可以父节点对应的数据3下降去填补删除2之后的空缺,而将数据5提升到父节点。

btreeCase3bstep2

情况3-b:与兄弟节点合并数据

在这种情况下,如果节点Y和其兄弟节点的数据量都只有t-1,那么将进行合并操作。

在删除数据4之前如下图所示:

btreeCase3step1

包含数据4的节点[4,5,10,11],其父节点[3,12]只有2个数据,因此需要做重平衡操作。但是其兄弟节点[20,23]也是只有2个数据,因此只能将两者进行合并:

btreeCase3step2

由于合并数据,导致了树的高度缩减,因此需要修改根节点,从[16]这个节点修改为[3,12,16,2023]

btreeCase3step3

情况2和情况3有一些类似,以至于初学的时候会发生混淆,这里将我学习总结的区别列举一下:

B树删除伪代码

在《算法导论》中,B树的删除代码并没有直接给出,而是作为章后的问题留给了学习者,这里贴出来,可以对比上面几种情况的分析,或者结合最后给出的可运行的Python代码进行阅读加深理解,出自Deletion in B-Tree

1. B-Tree-Delete-Key(x, k) 
2.     if not leaf[x] then 
3.        y  Preceding-Child(x) 
4.        z  Successor-Child(x) 
5.        if n[y] > t  1 then 
6.            k'  Find-Predecessor-Key(k, x) 
7.            Move-Key(k', y, x) 
8.            Move-Key(k, x, z) 
9.            B-Tree-Delete-Key(k, z) 
10.        else if n[z] > t  1 then 
11.            k'  Find-Successor-Key(k, x) 
12.            Move-Key(k', z, x) 
13.            Move-Key(k, x, y) 
14.            B-Tree-Delete-Key(k, y) 
15.        else 
16.            Move-Key(k, x, y) 
17.            Merge-Nodes(y, z) 
18.            B-Tree-Delete-Key(k, y) 
19.        else (leaf node) 
20.         y  Preceding-Child(x) 
21.         z  Successor-Child(x) 
22.         w  root(x) 
23.         v  RootKey(x) 
24.            if n[x] > t  1 then Remove-Key(k, x) 
25.            else if n[y] > t  1 then 
26.                k'  Find-Predecessor-Key(w, v) 
27.                Move-Key(k', y,w) 
28.                k'  Find-Successor-Key(w, v) 
29.                Move-Key(k',w, x) 
30.                B-Tree-Delete-Key(k, x) 
31.            else if n[w] > t  1 then 
32.                k'  Find-Successor-Key(w, v) 
33.                Move-Key(k', z,w) 
34.                k'  Find-Predecessor-Key(w, v) 
35.                Move-Key(k',w, x) 
36.                B-Tree-Delete-Key(k, x) 
37.            else 
38.                s  Find-Sibling(w) 
39.                w'  root(w) 
40.                    if n[w'] = t  1 then 
41.                        Merge-Nodes(w',w) 
42.                        Merge-Nodes(w, s) 
43.                        B-Tree-Delete-Key(k, x)
44.                    else
45.                        Move-Key(v,w, x)
46.                        B-Tree-Delete-Key(k, x)
中序遍历

根据B树的特点,对一颗B树进行中序遍历时,即:

in-order-visit-tree(node):
  ##中序遍历左子树
  in-order-visit-tree(node.left)
  ##输出节点数据
  output(node)
  ##中序遍历右子树
  in-order-visit-tree(node.right)

其输出应该是一个递增的序列,我的Python演示代码就是用该特征来对操作之后的B树进行测试。

Python演示代码

我将B树的算法,使用Python写了一个简单的实现,附带测试用例,如果不满足于上述伪代码,想手写B树算法的,可以参考:btree.py

参考资料


原文出处:B树、B+树索引算法原理(下)

这一段时间由于在阅读boltdb代码的缘故,找机会学习了B树及B+树的算法原理,这个系列会花两个篇幅分别介绍这两种数据结构的实现,其用于数据库索引中的基本原理。

上一篇文章中,介绍了数据库索引的简单概念,以及B树的结构及核心算法,这一篇将继续介绍B树的变形B+树。

B+树的定义及性质

B+树之于B树,最大的不同在于:

内部节点的“索引”,应该满足以下条件:

同时,B+树为了方便范围查询,叶子节点之间还用指针串联起来。

以下是一颗B+树的典型结构:

b+tree

由于采用了这样的结构,B+树对比B树有以下优点:

核心算法

插入算法

B+树的插入算法与B树的很相近,都是:

比如在下图的B+树中,向这里插入新的数据10

slide01b

由于插入节点[7,11]在插入之后并没有溢出,所以可以直接变成[7,10,11]

slide01c

而如下图的B+树中,插入数据4

slide02b

由于所在节点[2,3,5]在插入之后数据溢出,因此需要分裂为两个新的节点,同时调整父节点的索引数据:

slide02g

[2,3,4,5]分裂成了[2,3][4,5],因此需要在这两个节点之间新增一个索引值,这个值应该满足:

综上,需要在父节点中新增索引4和两个指向新节点的指针。

删除算法

B+树的删除算法,与B树类似,分为以下几步:

下面结合B-tree=delete1B-tree=delete2 的图示对删除算法展开具体的分析。

从叶子节点中删除数据

从叶子节点中删除数据分为三种情况:

以下针对后面两种需要做重平衡的操作展开分析。

借用兄弟节点数据进行重平衡操作

在下图中,从叶子节点中删除数据之后,只剩下数据[11]

transfer-leaf01

由于此时其左兄弟节点[2,3,5]有足够的数据可以借用,于是:

类似的,也有从右边兄弟节点借用数据的情况,如下:

transfer-leaf02

与兄弟节点进行合并

当左右兄弟节点数据都不够借用的时候,那就只能进行合并,此时会有一个节点要从父节点中删除其索引数据以及子节点指针。

在下图中,从叶子节点中删除数据之后,只剩下数据[5]

merge-leaf01a

而左边兄弟节点[2,3]的数据也不够用,于是两个节点进行了合并,形成新的节点[2,3,5],这样节点[5]就要在父节点中被删除。

类似的,也有合并到右边的情况:

merge-leaf02a

上面从叶子节点中删除数据的操作,一共分为以下三种情况:

这三种情况中,前面两种由于并没有调整父节点,删除其中的索引和子节点指针,因此不需要继续在父节点中做重平衡操作,而第三种情况由于合并节点导致父节点需要删除数据,所以需要进一步在父节点中进行重平衡操作。以下继续以例子展开说明。

内部节点节点的重平衡

在删除父节点中的索引和子节点指针之后,如果父节点中的数据足够,同样也是不需要进行调整的,下面讨论的是内部节点需要进行重平衡的两种情况。

借用兄弟节点数据进行重平衡操作

如果兄弟节点数据足够,那么同样可以从兄弟节点借用数据进行重平衡操作。

以下图为例,假设内部节点[11]在删除索引和指针之后,需要从兄弟节点[2,3,5]借用数据:

transfer-internal01

大体的流程其实与叶子节点的借用数据挑战差不多,只是内部节点有指向子节点的指针,也要随着一起调整。

同样的,也有从右边兄弟节点借用数据的情况:

transfer-internal02

与兄弟节点进行合并

当兄弟节点数据不足时,内部节点也是进行合并操作。

下图中,节点[10]的数据量不足,而兄弟节点[2,5]也不够数据借用,只能将两者合并,同时调整父节点指针:

merge-internal01a

合并之后的图示:

merge-internal01b

总结

相比B树的算法,B+树算法更简单一些,删除节点后的节点分为三种情况:

我同样将B+树算法写了一个python的实现,参见:bplustree.py

参考资料

这里能找到该课程更多关于B+树的课件索引。