硬核数据结构,让你从B树理解到B+树

497 阅读16分钟

本文始发于个人公众号:TechFlow,原创不易,求个关注


今天是周五分布式系统的第八篇文章,核心内容是B+树的原理。

今天的文章是上周B树的延伸,所以新关注的或者是有所遗忘的同学建议先从下方链接回顾之前的内容。

硬核挑战——从零开始动手图解B树

B+树的特性

B+树和B树一样都是多路平衡树,也叫多叉树。两者的性质也基本一致,在具体来看详细内容之前,我们先来总体看下B+树的特性,先有个大概的印象。

我个人认为B+树大部分特性都和B树一样,唯一不同的只有以下几点:

  1. 所有的数据都存储在叶子节点,中间节点不存放数据
  2. 中间节点的元素数量和子树数量一致,而B树子树数量比元素数量多1
  3. 叶子节点是一个链表,可以通过指针顺序查找

我贴一张图,大家应该可以很直观地感受到。

从上图我们可以看到,所有出现在中间节点的元素都能在叶子节点当中找到,这对应了刚才说的所有数据都存放在叶子节点当中。然后我们还可以发现,中间节点当中的元素数量和子树数量一致,同样对应了区间分割。父节点的每个元素对应了一个子树中最大的元素。

至于最后的链表,应该很好理解,无非是链表出现在树当中,看起来有些新奇而已。

我看上图最大的感受就是像,和B树实在是太像了,就好像两个孪生兄弟,猛地看上去几乎一模一样,细微分辨才能发现一点差别。那么针对这样一颗熟悉又陌生的树,我们应该怎么去增删改查呢?

让我们继续往下看,在此之前,我想说一句,虽然B+树是B树的提升版,但是实现难度上,其实是降低的。也就是说整体而言,它比B树更容易实现

B+树的查

由于B+树当中所有的数据都存储在叶子节点,所以我们在查找的时候,必须要一直查找到叶子节点为止。也就是说不会再有中途退出的情况,这样就简化了我们的判断,几乎不再需要临时退出了。

另一个特性是B+树当中的元素数量和子树数量一致,并且每个元素都代表一棵子树当中的最大值。通过这个限制,我们可以很轻松地确定我们要查找的元素究竟在哪棵子树当中。而B树则有可能出现超界的情况,我们需要特殊判断。

举个例子,这是一棵B树:

假设我们查找的元素是12,我们在根节点当中判断,先通过二分查找查找到9,发现12 > 9,于是我们去最右侧的子树当中检查。

而如果是B+树,会是这样,为了作图方便,我省去了叶子节点中横向的指针。

可以看到我们直接二分就可以精准地找到对应的子树,我们直接往下递归就好了。如果超界了,则说明肯定不在树上,可以提前返回。

所以这就是一个非常简单的树上查找,应该说只要理解了树的形状和递归的思路,应该是没有难度的。不过有一点需要注意,我们的查找接口并不只提供给外部,我们自己在插入的时候也会需要先找到对应的位置(节点)再执行插入的。显然我们要插入的元素十有八九不在树上(不然还叫什么插入),这个时候就不能返回空了,我们需要返回一个实实在在的节点。

所以我们可以传入一个flag,标记是否是我们内部调用的插入阶段,flag默认是False,所以对外部调用没有影响。

如果flag是True,我们在中途没有找到的时候就不能提前退出了,需要继续查找。并且我们还有可能更新一下最右侧元素的值。

还用上图举个例子:

如果我们插入一个元素15,整棵树会变成:

看到了吗,根节点当中的12被替换成了15,这也对应上了之前说的节点中的每个元素都对应子树中的最大值。我们先插入再去更新父亲当然也是可以的,但我们也可以在查找的时候直接进行更新,当我们发现待插入的元素比当前节点最大的元素还要大时,直接进行替换,这样可以省去一些代码。

我们来看下代码:

 def _query(self, node, key, flag=False):
"""
:param node: 当前节点
:param key: 待寻找的key
:param flag: 是否是插入阶段
:return: True/False 是否找到,对应的节点,key在节点中的下标
"""

# 如果是叶子节点,说明没有childs
if node.is_leaf:
# 校验当前是否为空
if node.length == 0:
return False, node, 0
# 二分查找
pos = bisect_left(node.keys, key)
# 如果没找到,返回False
if pos == node.length or node.keys[pos] != key:
return False, node, 0
else:
return True, node, pos
else:
pos = bisect_left(node.keys, key)
# 递归
if pos == len(node.childs):
if not flag:
return False, node, 0
else:
# 如果是插入阶段,待插入的元素大于所有元素
# 直接替换最后一个元素
node.keys[-1] = key
pos -= 1
return self._query(node.childs[pos], key, flag)

大家应该都注意到了B+树的叶子节点是一个链表,每个节点都有一个next指针指向它的右兄弟。所以我们也可以通过链表来查找元素,这段代码并不难写,就是一个简单的链表遍历,当中涉及的细节不多,我们直接来看代码:

def seq_query(self, key):
node = self.head
# 循环遍历链表
while node is not None:
# 二分查找是否在当前节点
pos = bisect_left(node.keys, key)
# 如果大于当前的所有元素
if pos == node.length:
# 如果没有后续了说明没找到
if node.next is None:
return False, None
node = node.next
continue
# 如果找到了判断是否是我们要的
if node.keys[pos] == key:
return True, node.values[pos]
else:
return False, None

B+树的增

第二个方法是添加元素,添加元素的逻辑和B树基本一致,只是有些细微的变动。

和B树一样,B+树的所有插入操作也都发生在叶子节点。所以我们通过查找操作找到适合插入这个元素的节点,进行插入。由于B+树对节点上元素的数量进行了限制,最多不能超过M个,也就是说插入操作可能会引起非法,所以我们需要对这种情况进行判断,如果插入会导致非法,我们需要采取措施维护数据结构的性质。

采取的措施很简单,和B树一样,如果节点元素数量超标,那么进行分裂

但是它的分裂操作会有一点细微的差别,我们一起来看:

假如这是一颗4阶的B+树,那么当我们插入一个元素之后,它就会发生分裂。比如我们插入5,可以得到:

注意到了吗,由于分裂会产生两棵子树,所以分裂之后上层的节点会有两个元素,而B树只有一个元素。

但如果分裂不是发生在根节点,那么还是只会上传一个元素,和B树一样。

再来看一个例子:

如果此时我们加入元素12,第三个子树的元素数量会超标,于是会发生分裂:

我们最后一个节点插入元素之后发生了分裂,但是只上传了10进入了父亲节点当中,因为15本来就已经在父节点当中了,没有必要上传。

当然这个图不是最终的形态,根节点显然也超过了限制,需要进一步分裂,最后变成这样:

当然这个只是整体的逻辑,在我们实现的过程当中还有很多细节需要考虑。比如当前节点是否是叶子节点?如果是叶子节点,那么它没有子树需要分裂,但是会有value需要分裂。如果不是叶子节点,它分裂了之后,我们还需要维护它的childs以及底层的链表,不过这些细节虽然多,但是其中的逻辑并不复杂,只要我们实现的时候把所有的细节梳理清楚,以及做好系统的测试,这并不困难。

这里着重提一下分裂时候next指针的处理,假设当前的bt_node是一个叶子节点,当它发生分裂的时候,会生成两个新的节点,我们命名为lnode和rnode。之后我们需要将bt_node升入父节点和父节点合并,这个时候我们怎么维护next指针呢?

我们可以很随意想到应该让lnode的next指向rnode,rnode的next指向node的next,但是怎么让bt_node之前的next指向lnode呢?

一个办法是我们可以在函数当中将node的左兄弟也传进来,但是这样会需要很多操作。比如我们需要先找到左兄弟,这个寻找其实并不容易,因为可能左兄弟和它不在一棵子树,而且还需要判断左兄弟不存在的情况。所以还是很麻烦的,或者我们可以多存一个指向左边的指针,这样就可以很方便地找到左兄弟了。我想到的一个办法是利用Python当中引用存储的trick来避免这些复杂的操作。

我们假设这个左兄弟节点是brother,在brother的next当中存储了bt_node的引用,也就是node在内存当中的地址。取巧的办法就是让lnode等于bt_node,也就是用lnode引用指向bt_node,之后再将lnode当中的值手动更新成我们需要的。这样通过取巧的办法,就绕开了这个问题。我们看下代码:

bt_node_next = bt_node.next
lnode = bt_node
# 手动更新lnode
lnode.update()
rnode = BTree.BTreeNode(keys=rkeys, childs=rchilds, is_leaf=bt_node.is_leaf, father=node, pos_in_father=1, values=rvals)
lnode.next = rnode
rnode.next = bt_node_next

这段代码不难,但是这个trick不太容易想到,需要对Python的引用机制有一定的了解。这个只是实现的取巧,和算法关联不大,看不懂的同学可以忽略。

理解了这些之后,我们最后来看下B+树的删除。

B+树的删除

B+树的删除逻辑和B树完全一致,只是具体操作的细节有略微的区别。

和B树一样,所有的删除全部都发生在叶子节点,但是由于B+树所有的元素都存在叶子节点,所以我们不存在需要考虑删除中间节点并且用后继来替代的情况。我们直接查找到叶子节点进行删除和维护即可。不得不说这一点上要简化了许多。

直接删除

和B树一样,如果叶子节点元素数量富裕,那么直接删除。

比如我们要删除上图当中的13,由于叶子节点富裕,所以直接删除即可,得到:

这点没什么好说的,但是如果我们删除的是15,虽然也是直接删除,但是就会有点问题。我想大家应该也发现了,就是15不仅是叶子节点当中的一个,而且还出现在中间节点上,如果我们删掉了15,那么显然需要更新这一条链路上的节点。

我们需要一路回溯上去,将它们的值都改成删掉15之后最大的值,这里的这个细节和B树不太一样,需要注意。更新之后的结果应该是这样的:

我们往上回溯的时候也需要判断,之后修改的节点是父节点中最大的值才需要回溯,因为父节点中最大的值也在父节点的父节点里,如果不是只需要修改即可,不再需要回溯,我们来看代码:

def upload(self, bt_node, father, key):
if father is None:
return
# 修改父节点中值
father.keys[bt_node.pos_in_father] = key
grand_fa = father.father
# 如果修改的是父节点中最大的值,继续往上回溯
# 因为父节点中的最大值也在爷爷节点中
if bt_node.pos_in_father == father.length-1 and grand_fa is not None:
self.upload(father, grand_fa, key)

这里有一个很有意思的点,就是我在一开始实现的时候忘记了需要持续回溯这茬,只考虑了更新父节点,没有一直往上回溯。但是我用了一大批数据进行测试,发现仍然是正确的。

我后来仔细思考了一下,发现的确没有必要一直回溯上去。因为我们在查找的时候,在上层节点并不能断定元素是否存在。比如上面的例子,如果我只回溯了一层,没有回溯到顶层。这棵树会是这样:

也就是说根节点当中存的仍然是15,但是15此刻已经被删除了。其实并不影响我们的搜索,因为树上已经不存在比15大的元素了,我们在顶层用15来二分和用13来二分的结果都是一样的,就是往右侧的子树递归。而往下递归了之后,数据就正确了,所以我们只用更新叶子节点往上一层即可。但是这只是我的判断,我暂时没有想到反例,欢迎有想法的同学给我留言。

兄弟节点富裕

兄弟节点富裕,很简单我们和兄弟节点借就行,和B树不一样,由于所有的节点都在叶子上,我们可以直接将兄弟节点的元素借过来,但是需要注意更新父亲节点中的值。

举个例子,比如说我们要删除下图中的4,我们发现它的左兄弟有富裕的元素,我们不再需要通过父节点中转,可以直接借过来。

但是借过来之后,会有一点小问题,就是父节点中的3就失效了,因为它不再是左侧子树中最大的元素了,最大的元素变成了2。所以我们需要更新父节点中这个值。我们和右侧兄弟借元素也是一样,只是方向不同,我就不再赘述了。

兄弟节点不富裕

最后,我们来看下兄弟节点也不富裕的情况。这点和B树类似,但是略有不同。在当下场景当中,我们不再向父节点强行借元素了,想想也能明白,父亲节点当中的数据都在叶子上,还有什么好借的呢?所以我们不借元素,直接和兄弟节点合并。

比如在下图当中,如果我们要删除元素12,会导致违反限制,这个时候我们直接合并红框当中的两个节点。

合并之后会带来子树的数量减少,所以我们要remove掉父节点当中的一个元素。我们对照一下上图,可以发现,我们要删除父节点当中的10。这里我们要判断一下,如果和当前节点合并的是左兄弟,那么我们要删除的是左兄弟的最大值,否则是右兄弟的最小值。删除之后,维护父节点,我们发现父节点不再满足条件,需要维护。

显然它没有富裕的兄弟节点,于是我们继续合并

同样的操作之后,我们发现当前节点的父节点也就是根节点只剩下了一个元素,这是非法的,于是我们需要将它抛弃,更新根节点。

后记

到这里,我们B+树的增删改查也就介绍完了,说起来非常恐怖的数据结构,但用图展示出来也就只有这么几张,我完整写出来的代码不超过500行,并不是一个非常吓人的数字。很多时候我们恐惧数据结构当中的变形、旋转等操作,但其实静心画出所有的变化,来来回回也就那么几种。当我们抗拒和退缩的时候,并不是数据结构或者是难题战胜了我们,是我们自己放弃了。

最后,我们来思考一个问题,为什么B+树当中把所有元素都放在叶子节点是一种优化呢?难道这样不是增加了查找的负担吗?我们之前可能在半途就查到了元素,现在一定要查找到结尾才行,这怎么算是优化呢?

同样,回答这个问题,光理解数据结构是不够的,我们还需要结合使用场景。B+树的场景我们都知道,是数据库的底层实现结构。我们都知道数据库当中的数据非常庞大,我们不可能将所有的数据都存在内存当中。而磁盘的随机读写是非常耗时的,我们把所有数据都放在叶子节点,对于大量新增和删除的情况,我们可以将获得连续的数据进行批量操作,可以大量节省时间。另外B+树对于批量读取的效率比B树更高,比如我们要读取一个区间,可以先查找到一个节点之后,通过next指针往后批量获取。我们通过指针的遍历操作是顺序读写,读写速度要比随机读写快得多。

也就是说B+树的优化体现在磁盘读写上,而不是算法上。当然从整体的实现难度上来说,B+树确实也要更简单一些。如果对源码感兴趣的同学可以在公众号回复“B+树”,获取我的代码,请不要嫌弃。

今天关于B+树的分享就到这里,如果觉得有所收获,麻烦顺手点个关注或者转发吧,你们的举手之劳对我来说很重要。

本文使用 mdnice 排版