参考资料:《数据结构与算法分析》
前言
基于磁盘存储的数据检索代价是昂贵的,如数据结构为二叉树,那么磁盘访问次数为log
2
N,代价过于昂贵,故减少磁盘访问次数成了核心问题。一种新型数据结构随之诞生——M叉数,他的时间复杂度为LogM
N,当然跟二叉树极端情况下会退化成链表一样,M叉树也存在极端情况下会退化成二叉树的风险。所以需要约定一些规范来规避退化风险。实现这种思想的一种方法是使用B+Tree。
特性
阶为M的B+Tree具有以下特性:
- 数据项存储在树叶上。
- 非树叶节点存储M-1个关键字以指示搜索的方向;关键字 i 代表子树 i+1 中的最小关键字。
- 树的根或者是一片树叶,或者其儿子数在2和M之间。
- 除跟外,所有非树叶节点的儿子数在[M/2 (向上取整) ]和M之间。
- 所有的树叶都在相同的深度上并有[L/2 (向上取整) ]和L之间个数据项。
(L的确定稍后描述)
例一
M=5 L=5 (在这个例子中L和M恰好是相同的,但这不是必需的)
(图一)
所有的非树叶节点的儿子数都在3和5之间(从而得知有2到4个关键字); 根可能只有两个儿子。
由于L是5,因此每片树叶有3到5个数据项。
要求节点半满将保证B+Tree不会退化成简单的二叉树。
每个节点代表一个磁盘区块,于是我们可以根据所存储的项的大小确定M和L (项可以是[关键字+分支指针]也可以是[数据记录])如:
例二
设:数据记录总数为1千万,一个区块能容纳8192字节,每个关键字使用32字节,每个分支指针使用4字节,每个数据记录使用256字节。
则有:
M为228 → 8192 <=(32+4)* M - 32
L为32 → 8192 / 256
由M可知:
- 非树叶节点存储关键字数量114~227(半满~满)
- 跟节点分支指针数量2~228
- 非跟节点分支指针数量114~228(半满~满)
- 树叶节点数据项个数16~32(半满~满)
- 树叶总数625000~312500(全半满~全满)
由非跟节点分支指针数量可知二叉树深度范围:
log114
N ~ log228
N → 3.40317.. ~ 2.96869.. → 4 ~ 3
添加项
想要把57插入到图一的B+Tree中
注意我们可能要为此重新组织该树叶上的所有数据。
然而,与磁盘访问相比(在这种情况下它还包含一次磁盘写),这些操作可以忽略不计。
插入后如下(图二):
当然,插入是57相对简单的,因为该树叶还没有被装满。
想要把55插入到图二的B+Tree中
55想要插入其中的那片树叶已经满了。不过解法却不复杂:
由于我们现在有 L+1 项,因此把它们分成两片树叶,这两片树叶保证都有所需要的记录的最小个数。
我们形成两片树叶,每叶3项。(写这两片树叶需要2次磁盘访问,更新它们的父节点需要第3次磁盘访问)
注意:在父节点中关键字和分支均发生了变化,但是这种变化是以容易计算的受控的方式处理的。
插入后如下(图三):
分裂
虽然分裂节点是耗时的(它至少需要2次附加的磁盘写),但它相对很少发生。
例如:如果L是32,那么当节点被分裂时,具有16和17项的两片树叶分别被建立。
对于有17项的那片树叶,我们可以再执行15次插入而不用另外的分裂。
换句话说,对于每次分裂,大致存在 L/2 次非分裂的插入。
前面的例子中的节点分裂之所以行得通是因为其父节点的儿子数量尚未满员。可是如果满员了又会怎么样呢?
想要把40插入到图三的B+Tree中
此时需要把数据项35~39且现在又要包含40的树叶拆分成2片树叶。
但是这将使其父节点有6个而女子,可是它只能有5个儿子。(因为M为5)
因此,解法就要分裂这个父节点。
当父亲节点被分裂时,必须更新那些关键字以及还有父亲节点的父亲的值,这就招致额外的两次磁盘写(从而这次插入花费5次磁盘写)。
然而,虽然由于有大量的情况需要考虑儿使得程序确实不那么简单,但是这些关键字还是以受控制的方式变化。
插入后如下(图四):
正如这里的情形所示,当一个非树叶节点分裂时,它的父亲将得到一个儿子。如果图亲节点的儿子个数已经达到规定的限度怎么办呢?
在这种情况下,继续沿树向上分裂节点直到找到一个不需要再分裂的父亲节点,或者达到树根。
如果树根的儿子数达到规定的限度,则分裂树根。
如果分裂树根,那么我们就得到了两个树根。显然这是不可接受的,但我们可以建立一个新的根,这个根以分裂后得到的两个树根作为它的两个儿子。
这就是为什么准许树根可以最少有两个儿子的特权的原因。
这是B+Tree增加高度的唯一方式。
不用说,一路向上分裂直到根的情况是一种特别少见的异常事件,因为一颗具有4层的树意味着在整个插入序列中已经被分裂了3次(假设没有删除发生)。
事实上,任何非树叶节点的分裂也是相当少见的。
领养
还有其他一些方法处理儿子过多的情况。一种方法是在相邻节点有空间时把一个儿子交给该节点节点领养。
把29插入到图四的B+Tree中
可以把32移到下一片树叶而腾出一个空间。
插入后如下(图五):
这种方法要求对父节点进行修改,因为有些关键字收到了影响。
然而,它趋向于使得节点更满,从而再长时间的运行中节省空间。
删除项
我们可以通过查找要删除的项并在找到后删除它来执行删除操作。
问题在于,如果被删项的所在树叶的数据项数已经是最小值,那么删除后他的项数就低于最小值了。
我们可以通过在邻节点本身没有达到最小值时领养一个邻项来矫正这种情况。
如果邻节点已经达到最小值,那么可以与该邻节点联合以形成一片满叶子。
联合
联合意味着其父节点失去一个儿子。
如果失去儿子的节点又引起父节点的儿子数低于最小值,那么我们使用相同的策略继续进行。
这个过程可以一直上行到根。根不可能只有一个儿子(要是允许根有一个儿子那可就愚蠢了)。
如过‘领养’使得根只剩下一个儿子,那么删除该根并让它的这个儿子作为树的新根。
这是B+Tree降低高度的唯一的方式。
想要把99从图五的B+Tree中删除。
由于删除99后,99所在的树叶只有剩下2个数据项,违反了半满约束(叶子节点最少容纳 M/2 个数据项)
而它的邻节点的数据项数也已经是最小值3了,因此我们把它们合并成一片有5个数据项的新叶子。
结果,他们的父节点只有2个儿子了。这时该父节点可以从它的邻节点领养,因为邻节点有4个儿子。
领养的结果使得父节点和父节点的邻节点双方都有3个儿子。
删除后如下(图六):