B+tree - B+树深度解析+C语言实现+opencv绘图助解
- 1. 概述
- 2. B+tree介绍
- 3. B+tree算法实现
- 3.1 插入
- 分裂
- 3.2 删除
- 向右借位(左旋)
- 向左借位(右旋)
- 合并
- 3.3 查询和遍历
- 3.3.1 查询
- 3.3.2 遍历
- 3.4 优化
- 优化1(匀key)
- 优化2(升级key)
- 优化3(拓展兄弟)
- 未来优化
- 4. 代码
- 5. 写在最后
- 补充说明
- 最后
1. 概述
B+tree很重要,它被广泛应用到数据库和文件系统中,例如B+tree在MySQL、Oracle等数据库中,以及在NTFS(你Windows电脑上的C、D、E盘,以及盘里面的文件夹、文件的结构都是B+tree来组织的)、ReFS等文件系统中被广泛用作为索引结构。
然而,全站讲B+tree的,基本上都是主讲b-tree,最后顺带一两句的B+tree。有一些能配图讲解b+tree的插入和删除,但也是部分场景,不全面。
如果你家里有《算法导论》这本书,恭喜你,你离蒙圈更近了。这本书是本非常好的书,它的特点是通过严谨的语言和逻辑推导,以及数学证明来描述每一种算法,并且不会讲算法的具体实现。正是如此,起飞吧,离离原上草……。《算法导论》第18章专门讲解B-tree,有兴趣的同学可以去挑战一下。
综上,我会在本文中
- 深度解析B+tree,从实战的角度全面系统地讲清楚B+tree的原理和实现细节;
- 我会用C语言实现一个具体的性能杠杠的B+tree;(获取代码/代码目录说明)
- 我会借助opencv来绘制B+tree,包括绘制一些关键的中间临时状态的B+tree、高亮关键节点展示算法过程等,以进一步深刻理解B+tree的实现原理。
你只要坚持看完本文,动手调试我给的代码,最后你一定会感叹:b+tree也就那么回事儿!
B+tree是B-tree的扩展,所以如果能先透彻理解B-tree会对B+tree的学习有非常大的帮助。可以看我前段时间的这篇《B-tree - 深度解析+C语言实现+opencv绘图助解》。
当然,直接看本文也是可以的。
本文实现的b+tree算法:
- 支持构建任意阶的b+tree(b+tree的阶m>=2,至于为什么是这个范围,参考后面的属性约束3)
- 支持存储任意类型的key和data(或者描述为:支持管理任意类型的key-value)
- 纯c实现,保证通用性。
- 尽量保证逻辑正确和内存安全。代码经过比较严格的UT,以及所有测试代码都通过了valgrind的内存检测。
注意:本文实现的b+tree的所有节点数据都存在于
内存
中,这是为了讲解和学习b+tree的目的。
而实际应用中,b+tree的所有节点数据都是存储在磁盘(或者SSD)中的,每读取一个节点就会发起一个磁盘IO。这是因为当b+tree足够大时,完整加载到内存中是几乎不可能的事。实际场景中(比如mysql的b+tree索引)也只会缓存部分b+tree到内存中。
在后文中,我会使用中文
节点
来表示b+tree的node,而不是结点。大家不要纠结这俩词的区别,这无关紧要。
2. B+tree介绍
上图是一棵B+tree,参照图,对几个名称进行解释:
1. 三种节点
根节点
:b+tree中最顶端那个唯一的节点。如上图中的节点【9,18】就是根节点。
注意:根节点有2种状态:
- 当根节点有孩子的时候,它就是纯纯的根节点。
后文使用“非叶子状态的根节点
”来表达该状态的根节点。- 当根节点没有孩子的时候,它同时也是个叶子节点。此时整个b+tree中只有根节点一个节点。
后文使用“叶子状态的根节点
”来表达该状态的根节点。
叶子节点
:b+tree中最下层的节点。如上图中黄色高亮的节点。
内节点
:在b+tree内部的节点,即除了根节点和叶子节点外的节点,如上图中的【3,6,9】和【12,15,18】。
2. 索引节点:除了叶子节点外的节点(非叶子节点)是索引节点。
- b+tree特有的特性:除了叶子节点存储数据指针(指向外部数据的指针)外,其他节点(非叶子节点)都不存储数据指针(这些节点只存储跟b+tree结构属性相关的数据,不存储外部数据指针)。
非叶子节点除了被用于组织b+tree的结构,还有一个很重要的作用是帮助索引叶子节点,所以它们还被称为索引节点。上图中【9,18】、【3,6,9】和【12,15,18】就是索引节点。
3. 关键字数组(keys):所有节点都有关键字数组。例如根节点【9,18】就表示该节点的关键字数组包含2个key,分别为9和18。
4. 孩子节点数组(Children):非叶子节点才有的数组,用于记录指向每个孩子节点的指针。
5. 数据数组(datas):只有叶子节点才有的数组,用于记录每个key索引的数据指针。叶子节点的key和data是一一对应的。例如上图中,只有黄色的叶子节点才有指向虚线矩形框的数据指针(浅蓝绿色的虚线矩形表示数据指针指向的外部数据
,它们不属于b+tree)。
6. 链接指针:只有叶子节点才有的指针,用于指向自己的左兄弟节点(前一个兄弟,prev
指针)和右兄弟节点(后一个兄弟,next
指针)。例如上图中黄色的叶子节点之间都有相互链接的指针。
- 注:叶子节点之间的链接指针可以指向
堂兄弟
,即:某个叶子节点的prev或者next可以与自己的父节点不相同。例如上图中节点【7,8,9】的next是【10,11,12】,他俩是堂兄弟关系,因为他俩的父节点不相同。
*
一棵m阶B+tree满足下面的属性约束:*
1. 每个节点最多有m个孩子节点
- 每个节点最多有m棵子树
2. 每个内节点至少有⌈m/2⌉个孩子节点
- 每个内节点至少有⌈m/2⌉棵子树。
- ⌈m/2⌉是对m/2的值向上取整
3. 如果根节点有孩子, 那么它至少有 2 个孩子。
叶子状态的根节点
没有孩子。非叶子状态的根节点
的孩子节点个数至少是2个。4. 所有叶子节点都在同一层上,并且通过指针(通常是双向指针)相互连接,形成一个有序链表。
- 如果节点A是叶子节点,那么它的兄弟节点肯定也是叶子节点。
- 推导 -> 如果节点A是内节点,那么它的兄弟节点肯定也是内节点。
- 叶子节点之间相互链接,且有序。只要定位到叶子层的第一个节点,就可以顺序遍历所有叶子节点。
5. 每个非叶子节点的关键字数等于其孩子节点数。
- 由1和2可知内节点的孩子节点个数k满足:
⌈m/2⌉ <= k <= m
。
结合本条属性约束,可得到内节点的关键字个数n满足:⌈m/2⌉ <= n <= m
。- 由1和3,可以得到非叶子状态的根节点的孩子个数k满足:
2 <= k <= m
。
再结合本条属性约束,可以得到非叶子状态的根节点的关键字个数n满足:2 <= n <= m
。6. 所有数据指针都存在于叶子节点中。对应的,所有关键字都存在于叶子节点中。
- 这里说的数据是指通过b+tree来组织的外部数据。只有叶子节点才会跟这些外部数据关联。
- 所有叶子节点的关键字集合包含b+tree中所有的关键字。但并不意味着关键字只存在于叶子节点中,还会有部分关键字存在于索引节点(内节点和分叶子状态的根节点)中。
7. 所有索引节点中仅包含它的各孩子节点中最大的关键字和指向孩子节点的指针。
- 索引节点是所有内节点和非叶子状态的根节点的集合。
- 索引节点中的关键字和孩子节点一一对应,其每个key是其对应孩子节点中“最
大
”的key。
注:“最大
”是本算法选择的方案,并不是定死的。你也可以选择“最小
”。- 从此条可以看到b+tree中的关键字会出现冗余,即:某个节点的最大key还会冗余存在于父节点中。
* 为了具体的算法实现,除了上面的7条标准属性约束外,额外补充如下约定:
8. 每个叶子节点的key个数n满足,
(1) 常规的叶子节点:⌈m/2⌉ <= n <= m
(2) 叶子状态的根节点:1 <= n <= m
- 上面1~7都未对叶子节点的key做出限制。为了算法演示,这里约定叶子节点保持与内节点一样的 key 个数限制。
需要注意的是叶子状态的根节点比较特殊,它的key个数可以至少为1个。9. 所有节点的关键字数组中的key是按照从小到大的顺序存储的。
- 只要有序就行,当然也可以从大到小,本算法选择的是从小到大。
*上面黄色高亮部分是B+tree与B-tree的区别,这里小节一下:
序号 | 对比项目 | B+tree | B-tree |
---|---|---|---|
1 | 叶子节点的链接 | 叶子节点之间前后链接,形成一个链表 | 没有链接 |
2 | 根节点key个数n | 1 <= n <= m | 1 <= n <= m - 1 |
3 | 内节点key个数n | ⌈m/2⌉ <= n <= m | ⌈m/2⌉ - 1 <= n <= m - 1 |
4 | 数据指针 | 只存在于叶子节点 | 存在于每个节点中注1 |
5 | 叶子节点的关键字 | 叶子节点包含所有关键字 | 叶子节点只包含部分关键字,其余关键字在非叶子节点中 |
5 | 关键字冗余 | 会出现冗余:父节点的关键字是自己孩子节点中最大key的集合 | 不会出现冗余:每个关键字在所有b-tree节点中都是唯一的 |
6 | 非叶子节点的key和孩子之间的关系 | 一一对应,一个key对应一个孩子节点 | 一个key对应两个孩子节点,分别为左孩子和右孩子,这个key被称为这两个孩子节点的separator(分隔符key) |
注1: 对上面第4条的注释
在我上一篇《B-tree - 深度解析+C语言实现+opencv绘图助解》文中,我淡化了数据指针的概念,文中和代码中都没有出现数据指针。这是因为b-tree不像b+tree会有特别的关于数据指针的节点差异:b+tree中只有叶子节点才有数据指针,其他节点没有。而b-tree中每个节点都有数据指针。
我们要牢记:b-tree和b+tree都是key-value形式组织数据的,通过key来索引value,这里的value就是b+tree中所说的数据(data)。
你可以这样理解:
- 在b-tree中,数据指针是每个节点都具有的。我们在描述b-tree的算法的时候,只需要表达到key层面的插入、删除和搜索就行了,因为找到key就找到了data。你可以自己定义从key中解析出数据指针的逻辑。
- 在b+tree中,为了体现b+tree特有的特性“只有叶子节点才会有数据指针”,所以将数据指针从key中抽离出来,表达出只有叶子有,非叶子没有。
3. B+tree算法实现
本章详细描述B+tree的算法实现,基本节奏是先文字描述实现过程,然后上图直观看过程细节。
注意:
为了让大家能不受一些因素的干扰,能快速直面核心算法,我将算法分为了非优化版和优化版:
- 非优化版:关注核心算法,是最基本的算法逻辑。是学习了解b-tree的最佳阶段版本。
- 优化版:在非优化版的基础上,根据b+tree的特性,再进行的一些优化操作。如果直接学习优化版,就会有一些核心算法之外的逻辑干扰。
所以,下面3.1~3.3都是按照非优化阶段来讲解的,3.4独立一节来描述在3.1-3.3基础上的优化改进。
3.1 插入
b+tree的插入操作,是指将key和data插入到合适的b+tree节点中。这个过程由搜索合适的b+tree节点和插入key和data到b+tree节点两部分组成。
- 搜索合适的b+tree节点这个过程,需要知道3个点:
1)搜索的目标节点是叶子节点,因为b+tree的插入操作,都是插入到某个叶子节点上的。
理解:要插入的是key和data,data只能存在于叶子节点,所以需要找到某个叶子节点来插入key和data。
2)b+tree不允许插入2个相同的key。即如果在b+tree某个节点中找到了要插入的key,那么插入失败。
3)搜索过程是个遍历b+tree的递归过程,详细参见后面章节查询的描述。 - 将key插入b+tree节点:
1)直接将key插入到目标节点中,插入的时候需要关注key插入的位置。因为前面的属性约束9约定了节点中的关键字是从小到大有序排列的。插入的时候需要保持这个有序性。
2)如果插入的key是节点的最大key,且该节点有父节点,那么需要修改该节点对应在父节点中的key。这是属性约束7的规定:父节点中每个key是该key对应的孩子节点中最大的key。
4)当插入key之后,节点的关键字个数大于了b+tree的阶数m,这就违背了属性约束1和5。这时就需要分裂该节点,使b+tree恢复满足所有属性约束。
插入操作的流程图如下:
从上图可以看到,分裂是插入操作中的难点和重点,下面详细描述分裂的过程。
分裂
当插入key操作使得某个b+tree节点的keys数量临时
超过最大个数限制m的时候,这违背了b+tree的属性约束,就需要将该节点一分为二,这就是b+tree节点的分裂。
- 分裂只发生在b+tree的插入操作中。
分裂的标准流程:
假设现在需要分裂的节点为A(此时A的关键字个数超出了m个,为m+1个):
1)分裂节点:以A节点的“相对中间”的那个key为分界点,将节点分为2个节点。
- 不管m是奇数还是偶数,我们取的分界点key的索引为min_key_num,即⌈m/2⌉
- 拆分出的2个节点,第一个节点为被移走一半key、child或者data后的原节点A;第二个节点为新节点。
- 拆分关键字数组:分界点后半部关键字移入到新节点的keys数组中。
- 如果是非叶子节点,需要拆分孩子:分界点后半部孩子移入到新节点的children数组中。并且将这部分孩子节点的父节点修改为拆分出的新节点。
- 如果是叶子节点,需要拆分数据指针数组:分界点后半部数据指针移入新节点的datas数组中。
- 如果是叶子节点,还需要调整叶子节点之间的链接。例如新节点成为A的next,A成为新节点的prev等操作。
2)父节点的操作。
- A被拆分后,出现新的最大key,将该key插入到父节点的keys数组中。
- 将拆分出的新节点插入到父节点的children数组中。
3)由于分裂过程会向父节点插入一个key,这有可能导致父节点的key个数超出m,即:可能需要继续对父节点进行分裂操作。
下面以m=3的b+tree
为例,直观感受三种节点的分裂过程:
1)叶子节点的分裂
叶子节点的分裂是分裂操作的代表性过程。例如,当插入key=6时,b+tree的临时状态如下图:
此时节点【3,4,5,6】的key个数达到m+1个,开始分裂:
- 分裂节点:
· 分界点索引为⌈m/2⌉ = 2,即key=5的位置。从分界点分裂节点为2个节点:原节点变为【3,4】和新节点【5,6】
· 由于是叶子节点,还需要分裂datas数组,将指向数据5和6的指针分给节点新节点【5,6】。
· 由于是叶子节点,还需要完成叶子节点的链接关系:【3,4】节点的next为【5,6】,【5,6】的prev为【3,4】 - 父节点的操作:
· 将节点【3,4】的最大key(4)插入到父节点的keys数组中
· 将新节点【5,6】插入到父节点。
分裂结束后,如图:
2)根节点的分裂
根节点的分裂是特殊的分裂,因为它没有父节点,无法完成“将拆分后的新节点插入到父节点”这个操作。
需要给根节点构建一个“虚拟的”父节点来解决。下面是根节点分裂的详细流程。
当根节点插入第m+1个key的时候,此时b+tree的临时状态为:
此时开始分裂:
-
给根节点构建一个“虚拟的”父节点:构造一个根节点,它有1个key,就是原根节点的最大key,并且将原根节点作为它的第一个孩子节点。如图:
-
此时原根节点变成了一个叶子节点,就可以对其执行叶子节点的分裂流程:
- 分裂节点,原根节点分裂为2个节点,分别为【1,2】和【3,4】
- 父节点操作:1)将【1,2】节点的最大key插入父节点的keys数组;2)将新节点【3,4】插入到父节点。
至此,根节点分裂结束,如图所示:
根节点分裂的几个说明:
- b+tree的树高增加,就是由根节点的分裂产生的。
- 根节点的分裂产生了新的根节点。构建的“虚拟”根节点在分裂结束后会成为真正的新根节点。
3)内节点的分裂
前面已经讲过“b+tree的插入操作,都是插入到某个叶子节点上的”,不会直接插入key到某个内节点。内节点的key个数增加只有一个途径:其孩子节点发生了分裂,新产生的最大key插入到父节点中。
那么可以得出结论:内节点的分裂肯定是由于孩子节点分裂导致的。详细过程为:插入key到叶子节点导致该叶子节点发生分裂,分裂过程中会往父节点插入了一个“新的最大key”,导致父节点(某个内节点或者根节点)的key个数超过了m个,就需要分裂。
-
内节点分裂前的过程:其孩子节点的分裂
顺序从1开始插入b+tree,当插入key=12的时候,临时状态如下图所示:
此时节点【9,10,11,12】的key个数为m+1个,开始分裂:- 分裂节点为2个节点:【9,10】和【11,12】;并构建这两个节点之间的链接关系,以及datas数组的分裂。
- 父节点操作:将新节点【11,12】插入到父节点中;并将新产生的最大key(10)插入到父节点。
如图:
此时,内节点【6,8,10,12】的key个数达到了m+1个。
-
内节点的分裂:
现在开始对内节点【6,8,10,12】进行分裂:- 分裂为2个节点:【6,8】和【10,12】。
由于是内节点,还需要分裂children,将原来key(10)和key(12)的孩子分给新节点【10,12】 - 父节点操作:
将产生的新的最大key(8)插入父节点【4,12】的keys数组中
将新节点【10,12】插入到父节点【4,12】的children数组中
分裂结束后,如图:
- 分裂为2个节点:【6,8】和【10,12】。
3.2 删除
从b+tree中删除key,是一个搜索key和从b+tree叶子节点中删除key的过程。
注:从b+tree中删除某个key,会把该key关联的data一并删除。
- 搜索key的过程,参考下一节查询,目标是定位key所在的叶子节点。
这与b-tree不同,如果在b-tree的内节点中定位到key,就直接在这个内节点执行删除操作。但是在b+tree中,即使在内节点定位到key,也需要继续定位该key所在的叶子节点,因为data在叶子节点。
- 从b+tree叶子节点删除key这个过程是B+tree最难的部分。因为对于一棵健康的b+tree,删除key会破坏这棵树的平衡,使其无法满足属性约束。
虽然是b+tree中最难的,但是对比b-tree的删除,要简单一些。因为b+tree中的删除key都是在叶子节点上的操作,相比之下,从b-tree删除key还会出现从内节点删除key的情况。
b+tree删除key的流程图:
从上图可看出,再平衡是删除操作的核心逻辑。当从叶子节点删除key之后,节点的key个数少于了最小key个数限制(⌈m/2⌉)时,需要对节点执行再平衡操作。
再平衡(Rebalance)
包含3个操作:向右借位(左旋)
、向左借位(右旋)
和合并
说明:
向右借位(左旋),括号内的左旋二字已经不那么准确了,保留这个称呼是表示向右借位操作就是类比b-tree中的左旋操作,逻辑和原理基本一样。同理向左借位(右旋)中的右旋也是一个意思。
再平衡的三个操作的选择:
1)三个操作是三选一的关系,即一次
再平衡
只能执行其中的一个操作。
2)选择顺序:
- 如果能满足执行
向右借位(左旋)
,优先执行向右借位(左旋)
。- 当不能
向右借位(左旋)
,但能满足执行向左借位(右旋)
,则执行向左借位(右旋)
。- 当既不能
向右借位(左旋)
,也不能向左借位(右旋)
的时候,才执行合并
。并且此时肯定能执行合并。
注:向右借位(左旋)
和向左借位(右旋)
的顺序是可以交换的。我选择的是优先考虑向右借位(左旋)
,你也可以选择优先考虑向左借位(右旋)
。
下面开始逐一介绍再平衡操作。
向右借位(左旋)
说明:根节点不会发生
向右借位(左旋)
,因为它没有兄弟节点。
当从任意节点注2(根节点除外)删除1个key之后,满足下面的条件:
- 该节点的key个数小于了最小key个数限制(即⌈m/2⌉),
- 该节点的右兄弟节点存在,且其key个数大于最小key个数限制(即⌈m/2⌉)
- 该节点的左兄弟节点不存在,或者存在左兄弟节点,但是左兄弟节点的key个数小于或等于右兄弟节点。
此时,就可以执行向右借位(左旋)
操作。
注2
上面描述的可以发生向右借位(左旋)
的条件,可以扩展到任意
节点。
虽然删除key最终都是在叶子节点上进行删除key操作,但是非叶子节点也会发生删除key操作。
非叶子节点上的删除key操作是由后面会讲的合并
操作触发的。大家在这里可以先将任意
节点套用只是叶子节点的情况,等后面理解合并
后,再回到这里理解任意的含义。
假设需要再平衡的节点为A,向右借位(左旋)的标准流程:
1)将右兄弟节点的第一个key移动到A节点的keys数组中,作为A的最后一个key。
2)如果A是叶子节点:
- 将右兄弟的第一个data移动到A节点的datas数组,作为A的最后一个data。
3)如果A不是叶子节点:
- 将右兄弟的第一个child移动到A节点的children数组,作为A的最后一个child。
- 修改该child的parent为A
4)父节点操作:将此时A中新的最大key替换原本在父节点中对应的key。
整个过程是将右兄弟节点的最左侧key(第一个key)借走,作为当前节点的最后一个key。这是借位这个叫法的来历,跟b-tree相比,此时旋转的含义已经弱化,所以向右借位比左旋更准确。
下面来看实例:
-
叶子节点的向右借位
下面是一棵m=3的b+tree:
现在从这棵树中删除key(2),删除后,临时状态如图:
此时节点【1】的key个数小于了最小key个数限制(⌈m/2⌉=2个),且它没有左兄弟,且它右兄弟【3,4,5】的key个数大于2个,于是发生向右借位(左旋):- 将右兄弟节点【3,4,5】的第一个key(3)移出,插入到节点【1】中,作为【1】的最后一个key。
节点【3,4,5】变为【4,5】
节点【1】变为【1,3】 - 由于是叶子节点,还需要将右兄弟节点的第一个data(3)移动给节点【1,3】
- 更新父节点中新节点【1,3】对应的key(1),改为key(3)
借位结束后,如图所示:
- 将右兄弟节点【3,4,5】的第一个key(3)移出,插入到节点【1】中,作为【1】的最后一个key。
-
内节点的向右借位
注2中已经解释,内节点的删除key是由合并
触发的,而合并
会在后面讲。这里就跳过合并
的过程,直接从内节点删除key开始,只为演示内节点的向右借位操作。触发合并的流程,会在后面详细补上。下图是一棵m=3的b+tree的
临时
状态:
内节点节点【3】的key个数小于最小key个数限制(⌈m/2⌉=2个),导致此种情况的原因是其孩子节点的合并操作,大家可暂时忽略这个过程,我会在合并那个小节补充上图临时状态的由来(点击提前查看)。
现在对内节点【3】进行再平衡:由于它没有左兄弟,且它右兄弟【5,8,10】的key个数大于2个,所以进行向右借位操作:- 将右节点【6,8,10】的第一个key(6)移动到节点【3】中,作为其最后一个key。
节点【6,8,10】点变为【8,10】
节点【3】点变为【3,6】 - 由于是内节点,所以还需将右节点的第一个孩子节点【5,6】移动到节点【3,6】中,作为其最后一个孩子。
- 此时新节点【3,6】的最大key发生变化,需要将该节点在父节点中对应的key(3)变更为最大key(6)
内节点向右借位结束后,如图所示:
上图中黄色高亮部分是本次“向右借位”发生变化的节点:- 节点【8,10】被移走了key(6),插入到节点【3,6】中
- 节点【5,6】的父节点变更为新的【3,6】
- 根节点【6,10】的第一个key从3变为6。
- 将右节点【6,8,10】的第一个key(6)移动到节点【3】中,作为其最后一个key。
向左借位(右旋)
说明:根节点不能发生
向左借位(右旋)
,因为它没有兄弟节点。
当从任意节点(根节点除外)删除1个key之后,满足下面的条件:
- 该节点的key个数小于了最小key个数限制(即⌈m/2⌉),
- 该节点的左兄弟节点存在,且其key个数大于最小key个数限制(即⌈m/2⌉)
- 该节点的右兄弟节点不存在,或者存在右兄弟节点,但是右兄弟节点的key个数小于左兄弟节点。
此时,就执行向左借位(右旋)
操作。
假设需要再平衡的节点为A,向左借位(右旋)的标准流程:
1)将左兄弟节点的最后那个key移动到A节点的keys数组中,作为A的第一个key。
2)如果A是叶子节点:
- 将左兄弟的最后那个data移动到A节点的datas数组,作为A的第一个data。
3)如果A不是叶子节点:
- 将左兄弟的最后那个child移动到A节点的children数组,作为A的第一个child。
- 修改该child的parent为A
4)父节点操作:将此时左兄弟节点新的最大key替换原本在父节点中对应的key。
整个过程是将左兄弟节点的最右侧key(最后那个key)借走,作为当前节点的第一个key。这是向左借位这个叫法的来历。
下面来看实例:
-
叶子节点的向左借位
下面是一棵m=3的b+tree:
上图的b+tree是上一节中“内节点的向右借位”的示例的最终结果。
现在删除key(5),删除后,临时状态如图:
此时叶子节点【6】的key个数小于最小key个数限制(⌈m/2⌉=2个),且它的没用右兄弟(这里指的是同父的亲兄弟注3),且它左兄弟【1,2,3】的key个数大于2个,于是发生向左借位(右旋):注3
前面我已提过,我们现在学习b+tree的核心算法时,使用的非优化阶段的算法逻辑,以免优化逻辑对我们理解算法的干扰。所以这里的右兄弟,我们限制为同父的亲兄弟。在后面优化那一节,我们会扩展右兄弟可以取到下一个相邻的堂兄弟。- 将左兄弟节点【1,2,3】的最后那个key(3)移出,插入到节点【6】中,作为【6】的最后一个key。左兄弟节点变为【1,2】,当前节点变为【3,6】。
- 由于是叶子节点,还需要将左兄弟【1,2,3】的最后那个data(3)移动给节点【3,6】
- 更新父节点中当前左兄弟【1,2】对应的key(3),改为key(2)。
借位结束后,如图所示:
上图中黄色高亮部分是本次“向右借位”发生变化的节点:- 节点【1,2】被移走了key(3)
- 节点【3,6】中的key(3)是新借来的。
- 父节点【2,6】的第一个key从3变为2。
- 数据data(3)原本是节点【1,2】的,现在属于节点【3,6】。
-
内节点的向左借位
跟注2相同,这里直接从需要借位的内节点临时状态开始,至于这个临时状态如何得来,在合并那一小节再补充。下面是m=3的一棵b+tree的临时状态(点击提前查看此状态的由来):
此时内节点节点【12】的key个数小于最小key个数限制(⌈m/2⌉=2个),需要对其进行再平衡:由于它没有右兄弟,且它左兄弟【3,6,8】的key个数大于2个,所以进行向左借位操作:- 将左节点【3,6,8】的最后一个key(8)移动到节点【12】中,作为其最后一个key。左兄弟节点变为【3,6】,当前节点变为【8,12】
- 由于是内节点,所以还需将左兄弟的最后那个孩子【7,8】移动到节点【8,12】中,作为其最后一个孩子。
- 此时左兄弟节点的最大key发生变化(从8变为6),需要将该节点在父节点中对应的key(8)变更为key(6)
内节点向左借位结束后,如图所示:
上图中黄色高亮部分是本次“向右借位”发生变化的节点:
- 节点【3,6】被移走了key(8),移到了节点【8,12】中。
- 节点【7,8】的父节点变更为新的【8,12】。
- 根节点【6,12】的第一个key从8变为6。
合并
当从任意节点(根节点除外)删除1个key之后,满足下面的条件:
- 该节点的key个数小于了最小key个数限制(即⌈m/2⌉),
- 该节点的右兄弟节点不存在,或者右兄弟节点存在,但是其key个数
等于
最小key个数限制(即⌈m/2⌉), - 该节点的左兄弟节点不存在,或者左兄弟节点存在,但是其key个数
等于
最小key个数限制(即⌈m/2⌉)。
此时,就执行合并
操作。
上述条件的说明:
1)2和3合起来就表示既不能向右借位,也不能向左借位
2)2和3中描述的左或右兄弟存在时,key个数是等于最小个数限制,绝对不会出现小于的情况。
3)合并是需要和左或者右兄弟节点进行合并,在b+tree中,某个节点的左或者右节点至少存在其中一个。当然,根节点除外。
对节点A执行合并操作的标准流程:
1)选择合并的节点(A节点与哪个节点合并)
- 如果A的左或者右兄弟节点只存在其中一个,那么就是谁存在选谁。
- 如果A的左和右兄弟节点都存在,那么优先合并右兄弟(这是本算法的约定,你也可以选择左兄弟)
统一合并顺序原则
:选取好合并节点后,后续所有的合并流程都是将偏右的节点合并到偏左的节点中。(大家可以想想这么做的好处)
- 如果选择的是右兄弟,那么就是把右兄弟合并到A节点中
- 如果选择的是左兄弟,那么就是把A节点合并到左兄弟节点中
2)合并关键字:将偏右节点的keys数组中所有key全部地移动到偏左节点中。
3)叶子节点的操作:如果是叶子节点,那么
- 迁移偏右节点的datas到偏左节点中
- 修正叶子节点之间的链接关系
- 因为合并完成后,偏右节点会被删除。此时需要修复叶子节点间的链接关系:实际就是从链表中删除一个节点。
4)非叶子节点的操作:如果不是叶子节点,那么
- 将偏右节点中的children迁移到偏左节点中。
- 修改这些孩子节点的父节点为偏左节点。
5)父节点的操作:
- 从父节点的keys数组中删除偏左节点的最大key
- 因为合并后只剩下偏左节点,此时节点的最大key为合并前偏右节点的,在父节点中可以继续复用原偏右节点的那个key。但是原偏左节点在父节点中的key就不再不需要了。
- 从父节点的children数组中删除偏右节点。
注:
对于上述描述的“左兄弟”和“右兄弟”,特指的是亲兄弟
中的“左兄弟”和“右兄弟”,即兄弟之间的父节点是相同的。
前面已经提过,现在我们重点是在理解b+tree的核心算法,采用的是非优化阶段的算法逻辑,这有助于解除一些优化逻辑对我们理解算法的干扰。
在后面优化章节,我们会扩展“左兄弟”和“右兄弟”到堂兄弟,到时会有一些核心算法外的逻辑考虑,到时再详解。
下面来看实例:
-
叶子节点的合并
下面是一棵m=3的b+tree:
1)谁存在合并谁-
例1:删除key(1)
删除后,临时状态如图所示:
现在原节点【1,2】被删除key(1)以后,变为【2】。其key个数少于了最小key个数限制(⌈m/2⌉=2个),需要对其进行再平衡。
由于【2】没有左兄弟,且右兄弟【3,4】的key个数刚好等于最小key个数限制(2个),所以不能发生向左借位
和向右借位
。于是执行合并
。下面是合并的流程:
1)选择合并的节点。对于节点【2】适用于“谁存在选谁”,因为它没有左兄弟,有右兄弟,那么肯定选择右兄弟节点【3,4】来合并。此次合并的偏左节点为【2】,偏右节点为【3,4】,按照统一合并顺序原则(将偏右节点合并到偏左节点),是将节点【3,4】合并到节点【2】中。
2)合并关键字(keys):将偏右节点【3,4】中所有key迁移到偏左节点【2】中,使得【2】变为【2,3,4】。
3)由于是叶子节点,需执行叶子节点的操作:- 将偏右节点的datas迁移给偏左节点
- 修正叶子节点间的链接:这是因为合并结束后会删除原偏右节点【3,4】。本示例的操作为:修改新的【2,3,4】节点的next指向节点【5,6】,修改【5,6】节点的prev指向【2,3,4】
4)父节点的操作:
- 从父节点【2,4,6】中删除原偏左节点【2】的最大key,即删除key(2)。
- 从父节点【2,4,6】的children数组中删除原偏右节点【3,4】。
合并之后,如图所示:
-
例2:删除key(5)
删除后,临时状态如图所示:
现在原节点【5,6】被删除key(5)以后,变为【6】。其key个数少于了最小key个数限制(⌈m/2⌉=2个),需要对其进行再平衡。
由于【6】没有右兄弟,且左兄弟【3,4】的key个数刚好等于最小key个数限制(2个),所以不能发生向左借位
和向右借位
。于是执行合并
。下面是合并的流程:
1)选择合并的节点。对于节点【6】适用于“谁存在选谁”,因为它没有右兄弟,有左兄弟,那么肯定选择左兄弟节点【3,4】来合并。此次合并的偏左节点为【3,4】,偏右节点为【6】,按照统一合并顺序原则,是将节点【6】合并到节点【3,4】中。
2)合并关键字(keys):将偏右节点【6】中所有key迁移到偏左节点【3,4】中,使得【3,4】变为【3,4,6】。
3)由于是叶子节点,需执行叶子节点的操作:- 将偏右节点的datas迁移给偏左节点
- 修正叶子节点间的链接:这是因为合并结束后会删除原偏右节点【6】。本示例的操作为:修改新的【3,4,6】节点的next为NULL就行了,因为是最后一个叶子节点。
4)父节点的操作:
- 从父节点【2,4,6】中删除原偏左节点【3,4】的最大key,即删除key(4)。
- 从父节点【2,4,6】的children数组中删除原偏右节点【6】。
合并之后,如图所示:
2)优先合并右兄弟
-
例3:删除key(3)
删除后,临时状态如图所示:
现在原节点【3,4】被删除key(3)以后,变为【4】。其key个数少于了最小key个数限制(⌈m/2⌉=2个),需要对其进行再平衡。
由于【4】即有左兄弟【1,2】,又有右兄弟【3,4】,且它们的key个数刚好等于最小key个数限制(2个),所以不能发生向左借位
和向右借位
。此时就执行合并
。下面是合并的流程:
1)选择合并的节点。对于节点【2】适用于“优先合并右兄弟”,因为左和右兄弟都存在时,本算法约定优先选择右兄弟【5,6】来进行合并。此次合并的偏左节点为【4】,偏右节点为【5,6】,按照统一合并顺序原则,是将节点【5,6】合并到节点【5】中。
2)合并关键字(keys):将偏右节点【5,6】中所有key迁移到偏左节点【4】中,使其变为【4,5,6】。
3)由于是叶子节点,需执行叶子节点的操作:- 将偏右节点的datas迁移给偏左节点
- 修正叶子节点间的链接:这是因为合并结束后会删除原偏右节点【5,6】。本示例的操作为:修改新的【4,5,6】节点的next为NULL就行了,因为是最后一个叶子节点。
4)父节点的操作:
- 从父节点【2,4,6】中删除原偏左节点【4】的最大key,即删除key(4)。
- 从父节点【2,4,6】的children数组中删除原偏右节点【5,6】。
合并之后,如图所示:
-
-
内节点的合并
前面已经铺垫过,“非叶子节点上的删除key操作是由合并
操作触发的”。把这句话补充完整是“有且只有一个操作能触发非叶子节点的删除key操作,即其孩子节点的合并
操作”,因为我们已经学习了合并操作的最后一步就是父节点删除1个key和删除一个child。那么内节点的合并操作发生的完整流程如下:
- 内节点的孩子节点发生合并,合并结束后导致该内节点删除1个key和1个孩子。
- 当内节点删除key之后,导致其key个数小于最小key个数限制(⌈m/2⌉)时,需要对其执行再平衡操作。
- 当对内节点执行再平衡时,既不能做向右借位,又不能向左借位的时候,才执行合并。
所以内节点的合并操作,肯定经历两个过程:- (1) 孩子节点的合并
- (2) 内节点的合并
下的示例都会按照这两个过程来描述。现在开始示例讲解,下面是一棵m=3的b+tree,本小节的所有示例的原始状态都是这棵b+tree:
1)谁存在合并谁-
例4:删除key(4)
(1)孩子节点的合并
删除key(4)之后,如图所示:
此时,叶子节点【3】需要再平衡,由于其没有右兄弟(亲兄弟)(不能向右借位),且左兄弟没有足够的key提供向左借位,所以需要执行合并。合并后,如图:
上图中,节点【3】的孩子【1,2,3】是合并操作而来的,这个合并操作包含了对原来的内节点【2,3】执行删除key(2)的子操作。
现在内节点【3】的key个数小于了最小key个数限制(⌈m/2⌉=2个),需要对其执行再平衡。
由于【3】没有左兄弟(无法向左借位),右兄弟节点【10,12】没有足够的key提供向右借位,所以对【3】执行合并
。请看下小节的合并过程。
(2)内节点的合并
现在对节点【3】执行合并操作,详细流程:
1)选择合并的节点。对于节点【3】适用于“谁存在选谁”,因为它没有左兄弟,有右兄弟,那么肯定选择右兄弟节点【10,12】来合并。此次合并的偏左节点为【3】,偏右节点为【10,12】,按照统一合并顺序原则,是将节点【10,12】合并到节点【3】中。
2)合并关键字(keys):将偏右节点【10,12】中所有key迁移到偏左节点【3】中,使其变为【3,10,12】。
3)由于是非叶子节点,需执行非叶子节点的操作:- 将偏右节点的children迁移给偏左节点。即,将【9,10】和【11,12】迁移到【3,10,12】的children数组中。
- 修改迁移的孩子节点的父节点为偏左节点。即,把【9,10】和【11,12】的父节点修改为【3,10,12】。
4)父节点的操作:
- 从父节点【3,12,16】中删除原偏左节点【3】的最大key,即删除key(3)。
- 从父节点【3,12,16】的children数组中删除偏右节点,即删除原来的节点【10,12】。
合并之后,如图所示:
-
例5:删除key(16)
(1)孩子节点的合并
删除key(16)之后,如图所示:
此时,叶子节点【15】需要再平衡,由于其没有右兄弟,不能向右借位,且左兄弟没有足够的key提供向左借位,所以需要执行合并。合并后,如图:
上图中,节点【15】的孩子【13,14,15】是合并操作而来的,这个合并操作包含了对原来的内节点【14,15】执行删除key(14)的子操作。
现在内节点【15】的key个数小于了最小key个数限制(⌈m/2⌉=2个),需要对其执行再平衡。
由于【15】没有右兄弟,无法向右借位,且左兄弟节点【10,12】没有足够的key提供向左借位,所以对【15】执行合并
。请看下小节的合并过程。
(2)内节点的合并
现在对节点【15】执行合并操作,详细流程:
1)选择合并的节点。对于节点【15】适用于“谁存在选谁”,因为它没有右兄弟,有左兄弟,那么肯定选择左兄弟节点【10,12】来合并。此次合并的偏左节点为【10,12】,偏右节点为【15】,按照统一合并顺序原则,是将节点【15】合并到节点【10,12】中。
2)合并关键字(keys):将偏右节点【15】中所有key迁移到偏左节点【10,12】中,使得【10,12】变为【10,12,15】。
3)由于是非叶子节点,需执行非叶子节点的操作:- 将偏右节点的children迁移给偏左节点。即,将【13,14,15】迁移到【10,12,15】的children数组中。
- 修改迁移的孩子节点的父节点为偏左节点。即,设置【13,14,15】的父节点修改为【10,12,15】。
4)父节点的操作:
- 从父节点【4,12,15】中删除原偏左节点【10,12】的最大key,即删除key(12)。
- 从父节点【4,12,15】的children数组中删除偏右节点,即删除原来的节点【15】。
合并之后,如图所示:
2)优先合并右兄弟
-
例6:删除key(12)
(1)孩子节点的合并
删除key(12)之后,如图所示:
此时,叶子节点【11】需要再平衡,由于其没有右兄弟,不能向右借位,且左兄弟【9,10】没有足够的key提供向左借位,所以需要执行合并。合并后,如图:
上图中,节点【11】的孩子【9,10,11】是合并操作而来的,这个合并操作包含了对原来的内节点【10,11】执行删除key(10)的子操作。
现在内节点【11】的key个数小于了最小key个数限制(⌈m/2⌉=2个),需要对其执行再平衡。
由于【11】的左兄弟【2,4】和右兄弟【14,16】都没有足够的key提供向左借位和向右借位,所以对【11】执行合并
。请看下小节的合并过程。
(2)内节点的合并
现在对节点【11】执行合并操作,详细流程:
1)选择合并的节点。对于节点【11】适用于“优先合并右兄弟”,因为左和右兄弟都存在时,本算法约定优先选择右兄弟【14,16】来进行合并。此次合并的偏左节点为【11】,偏右节点为【14,16】,按照统一合并顺序原则,是将节点【14,16】合并到节点【11】中。
2)合并关键字(keys):将偏右节点【14,16】中所有key迁移到偏左节点【11】中,使得【11】变为【11,14,16】。
3)由于是非叶子节点,需执行非叶子节点的操作:- 将偏右节点的children迁移给偏左节点。即,将【13,14】和【15,16】迁移到【11,14,16】的children数组中。
- 修改迁移的孩子节点的父节点为偏左节点。即,设置【13,14】和【15,16】的父节点修改为【11,14,16】。
4)父节点的操作:
- 从父节点【4,11,16】中删除原偏左节点【11】的最大key,即删除key(11)。
- 从父节点【4,11,16】的children数组中删除偏右节点,即删除原来的节点【14,15】。
合并之后,如图所示:
-
合并后产生新的根节点,树高降低
目前我们已经了解到,合并操作会导致父节点删除key和孩子。那么,如果这个父节点是根节点,其孩子节点的合并导致根节点被删“空”时,就会出现根节点被删除,树高降低的现象。这也是删除key操作能让树高降低的核心内容。我们来看示例,下面是一棵m=3的b+tree:
(1)孩子节点的合并
现在执行删除key(6),删除后的临时状态如下图:
现在节点【5】需要再平衡,由于其没有左兄弟节点(同父的),不能执行向左借位;右兄弟节点【7,8】没有足够的key来提供向右借位;所以执行合并。
合并:节点【5】只有右兄弟【7,8】,所以合并【5】和【7,8】,而且是将【7,8】合并到【5】中。合并结束后,临时状态如图:
此时,需要对内节点【8】执行再平衡,由于它没有右兄弟,不能向右借位;左兄弟【2,4】没有足够的key提供执行向左借位;所以对【8】执行合并操作。
(2)内节点的合并
合并流程:
1) 选择合并节点:由于【8】只有左兄弟【2,4】,所以需要合并的是【2,4】(谁存在选谁)。并且是将偏右的节点【8】合并到偏左的节点【2,4】中。
2) 合并关键字keys:将【8】的keys全部迁移到【2,4】,使得【2,4】变为【2,4,8】
3) 由于是内节点,执行非叶子节点的操作:(1)【8】的孩子迁移给【2,4,8】;(2)修改孩子的父节点,将【5,7,8】的父节点修改为【2,4,8】
4) 父节点的操作:(1)从父节点【4,8】中删除原偏左节点【2,4】的最大key(4);(2)从父节点【2,4,8】删除偏右节点【8】。
内节点合并后,临时状态如图所示:
此时根节点【8】只有1个key和只有1个孩子【2,4,8】。此时根节点的状态已经违背了属性约定3: 如果根节点有孩子, 那么它至少有 2 个孩子
。
处理方法:实际上此时的根节点就是“空”的状态了,直接:当根节点成为“空”状态时,删除“空”根节点,让其唯一孩子成为新的根节点。
于是,最终的b+tree树高降低一层(树高减一),其最终形态为:
-
补充1:内节点向右借位临时状态示例的由来
前面“内节点的向右借位”小节中,是直接拿一个临时状态的b+tree来演示内节点的借位操作的,是因为当时还未讲解合并操作。
现在对示例中临时状态b+tree的由来做补充解释。点击查看前面的示例
(1)原始状态为一棵m=3的b+tree,如图:
(2)执行删除key(4)
(3)对【3】执行合并操作:
这是示例中的临时状态b+tree。 -
补充2:内节点向左借位临时状态示例的由来
前面“内节点的向左借位”小节中,是直接拿一个临时状态的b+tree来演示内节点的借位操作的,是因为当时还未讲解合并操作。
现在对示例中临时状态b+tree的由来做补充解释。点击查看前面的示例
(1)原始状态为一棵m=3的b+tree,如图:
(2)执行删除key(10)
(3)对【9】执行合并操作:
这是示例中的临时状态b+tree。
3.3 查询和遍历
3.3.1 查询
从b+tree中搜索目标key,其目的是定位key在哪个叶子
节点,然后获取该key对应的data(因为data只存在于叶子节点)。
从b+tree中搜索目标key,是一个从根节点开始向下遍历b+tree的过程。搜索的时间复杂度为O(height*m),式子中height为树的高度,m为b+tree的阶。因为总共最多遍历height个节点,每个节点最多m个key的遍历查询。
搜索一个目标key
的详细流程如下:
1)从根节点开始遍历b+tree节点,在经过的节点上寻找第一个大于或等于
目标key
的key。
2)若找到第一个大于目标key
的key,则继续往该key对应的子树上搜索目标key
。
3)若没有找到大于或等于目标key
的key,则搜索失败,表示目标key
在b+tree中不存在。
4)若找到等于目标key
的key,
- 如果是非叶子节点,则继续在key对应的子树上搜索
目标key
。- 如果是叶子节点,则搜索结束,搜索成功。
1)下图展示从一棵b+tree中搜索key(14)的过程(从根节点开始高亮的节点,红色的key是所在节点中第一个大于或等于14的key):
- 第1次遍历:节点【27,54】存在第一个大于目标key(14)的key(27),所以继续遍历key(27)对应的子树(根节点为【9,18,27】的子树)
- 第2次遍历:节点【9,18,27】存在第一个大于目标key(14)的key(18),所以继续遍历key(18)对应的子树(根节点为【12,15,18】的子树)
- 第3次遍历:节点【12,15,18】存在第一个大于目标key(14)的key(15),所以继续遍历key(15)对应的子树(根节点为【13,14,15】的子树,只不过该子树就只有一个根节点,即它已经是叶子节点)
- 第4次遍历:节点【13,14,15】搜索到目标key(14),搜索结束。
2)下图展示从一棵b+tree中搜索key(32)的过程(从根节点开始高亮的节点,红色的key是所在节点中第一个大于或等于14的key):
- 第1次遍历:节点【27,54】存在第一个大于目标key(32)的key(54),所以继续遍历key(54)对应的子树(根节点为【36,45,54】的子树)
- 第2次遍历:节点【36,45,54】存在第一个大于目标key(32)的key(36),所以继续遍历key(36)对应的子树(根节点为【30,33,36】的子树)
- 第3次遍历:节点【30,33,36】存在第一个大于目标key(32)的key(33),所以继续遍历key(33)对应的子树(根节点为【31,32,33】的子树,只不过该子树就只有一个根节点,即它已经是叶子节点)
- 第4次遍历:节点【31,32,33】搜索到目标key(32),搜索结束。
3)b+tree适合范围查找,支持该功能的b+tree特性有:
-
(1) b+tree中每个节点的key是顺序存储的;
-
(2) 孩子节点之间相互链接。且所有data和key都存在于叶子节点中;
-
(3) 除叶子节点外的搜索节点中的keys是其孩子节点中最大key的集合。
-
例1:我们要搜索key在32~40之间的data
我们只需要搜索key(32),定位到最小key(32)所在的叶子节点。然后从该叶子节点开始,通过叶子节点之间的链接指针,就可以顺序遍历出后续范围的数据。如图所示:
上图中先定位到key(32)的位置,然后从key(32)开始,利用叶子节点的链接关系可以快速找到32~40的范围结果(叶子层红色高亮的key)。 -
例2:搜索超出b+tree最大key的数据时,在根节点就能得到结果不存在。这是因为根节点中最后一个key就是该b+tree中最大的key,如果搜索的目标key大于了根节点中的最大key,那么目标key肯定就在b+tree中不存在。
3.3.2 遍历
本文提供了4种b+tree的遍历,分别是
叶子节点的顺序遍历:按照key的顺序来遍历b+tree
叶子节点的逆序遍历:按照key的逆序来遍历b+tree
搜索遍历:按照类似搜索key时的递归来遍历b+tree。
层序遍历:一层一层地遍历b+tree,每一层从左往右遍历。
本小节的遍历都以下面这棵m=5的b+tree为例来演示:
- 叶子节点的顺序遍历
通过叶子节点的顺序遍历可以顺序的遍历b+tree的所有data。- 定位第一个叶子节点:从根节点开始从最左侧开始逐级下沉到叶子节点,就得到第一个叶子节点。
- 利用叶子节点之间的链接指针完成顺序遍历
顺序遍历叶子节点的结果:
注:输出的每行格式为(序号: 层数/节点在所在层中的索引/节点在其父节点中的索引)【当前的btree节点】节点地址
例如:(01: 3/00/0)【1,2,3】 0x171c7b0
表示节点【1,2,3】在第3层的0号节点,在其父节点中的索引为0。在当前的遍历模式中,节点【1,2,3】的全局索引为01。节点【1,2,3】的内存地址为0x171c7b0
------- case[01]: sequence leaves traverse -------
(01: 3/00/0)【1,2,3】 0x171c7b0
(02: 3/01/1)【4,5,6】 0x171cc20
(03: 3/02/2)【7,8,9】 0x171cd40
(04: 3/03/0)【10,11,12】 0x171ce60
(05: 3/04/1)【13,14,15】 0x171cf80
(06: 3/05/2)【16,17,18】 0x171d0a0
- 叶子节点的逆序遍历
通过叶子节点的逆序遍历可以逆序的遍历b+tree的所有data。- 定位最后一个叶子节点:从根节点开始从最右侧开始逐级下沉到叶子节点,就得到最后一个叶子节点。
- 利用叶子节点之间的链接指针完成逆序遍历
逆序遍历叶子节点的结果:
------- case[02]: reversed leaves traverse -------
(01: 3/00/2)【16,17,18】 0x171d0a0
(02: 3/01/1)【13,14,15】 0x171cf80
(03: 3/02/0)【10,11,12】 0x171ce60
(04: 3/03/2)【7,8,9】 0x171cd40
(05: 3/04/1)【4,5,6】 0x171cc20
(06: 3/05/0)【1,2,3】 0x171c7b0
- 搜索遍历
搜索遍历,从根节点开始,逐级往下递归每个节点及孩子节点。它的特点是优先遍历到叶子节点,然后再递归回溯。这个过程前半部分跟搜索key过程类似(搜索key不会递归回溯到其他非路径节点),所以暂时命名这种遍历方式为“搜索遍历”。
搜索遍历的结果:
------- case[03]: search traverse -------
(00: 1/00/0)【9,18】 0x171d160
(01: 2/00/0)【3,6,9】 0x171ca00
(02: 3/00/0)【1,2,3】 0x171c7b0
(03: 3/01/1)【4,5,6】 0x171cc20
(04: 3/02/2)【7,8,9】 0x171cd40
(05: 2/01/1)【12,15,18】 0x171d220
(06: 3/03/0)【10,11,12】 0x171ce60
(07: 3/04/1)【13,14,15】 0x171cf80
(08: 3/05/2)【16,17,18】 0x171d0a0
- 层序遍历
逐层遍历,是从根节点出发,逐层往下,每层从左往右地遍历b+tree节点。
绘制b+tree就是利用的层序遍历。
注意
我实现的层序遍历有一个缺陷:递归堆栈层数远大于其他三种的。
原因是:我采取的层序遍历方案是逐层递归到每一层的第一个节点,然后再从该节点开始逐个递归到该层的最后一个节点。所以,层序遍历的最大递归深度=树高+叶子节点的个数
。当叶子节点很多的时候,栈就溢出了。
我测试过m=5的b+tree,往非优化阶段的b+tree中插入10242个key,会产生大概35万左右个叶子节点,树高12,这就导致会出现大概35万个递归堆栈,这不管机器硬件再好,也会因为栈溢出而程序崩掉的。
相比之下:其他几种遍历的递归深度最大就是树高。
最近是没时间了,未来有机会再重新实现一个厉害一点的层序遍历。
搜索遍历的结果:
------- case[04]: layer traverse -------
(01: 1/00/0)【9,18】 0x738160
(02: 2/00/0)【3,6,9】 0x737a00
(03: 2/01/1)【12,15,18】 0x738220
(04: 3/00/0)【1,2,3】 0x7377b0
(05: 3/01/1)【4,5,6】 0x737c20
(06: 3/02/2)【7,8,9】 0x737d40
(07: 3/03/0)【10,11,12】 0x737e60
(08: 3/04/1)【13,14,15】 0x737f80
(09: 3/05/2)【16,17,18】 0x7380a0
3.4 优化
到目前为止,我们已经通过非优化阶段的算法逻辑,了解了b+tree的算法原理和实现逻辑了。此时我们已经了解了99%的b+tree核心算法。
在本章中,我会利用b+tree的特性,在前面讲的算法基础上进一步优化b+tree算法,让其更加强大。
优化1(匀key)
优化1:对插入进行优化,减少分裂次数,让树高增长更加缓慢。
-
复习一下
- b+tree的插入key,都是插入到叶子节点中。当插入key之后,某个叶子节点的key个数超过b+tree的阶m,就需要对该节点进行分裂。
- 叶子节点的分裂会向其父节点插入key和孩子节点,这个操作又可能导致父节点的key个数超过m,就需要对父节点进行分裂。
- 如此往复,最终导致b+tree的高度增加。
- 我们可以下结论:b+tree的树高增加是由分裂导致的;而分裂又是由于叶子节点插入key导致的。
-
优化
如果我们能尽量减少分裂的次数,那么树高就会增高的更加缓慢。于是,在分裂之前新增一个逻辑,暂且叫它“匀key”操作,大概原理:当某个节点的key个数达到m个的时候,尝试从该节点移走一个key(如果是叶子节点,还包括移走1个data;如果是非叶子节点,还包括移走一个孩子。后面减少冗余描述,都只描述key),分给该节点的左兄弟或者右兄弟。具体:-
如果该节点有左兄弟,没有右兄弟,且左兄弟节点的key个数
小于
m个,那么把key匀给左兄弟。 -
如果该节点没有左兄弟,有右兄弟,且右兄弟节点的key个数
小于
m个,那么把key匀给右兄弟。 -
如果该节点的左和右兄弟都存在
- 只有左兄弟的key个数小于m,那么把key匀给左兄弟
- 只有右兄弟的key个数小于m,那么把key匀给右兄弟
- 左右兄弟的key个数都小于m,且相等,优先匀给右兄弟(本算法选择的右,你也可以选左)
- 左右兄弟的key个数都小于m,且不相等,匀给key个数少的那个
-
其余情况,都不能发生匀key。
-
-
效果
下面是优化前后的b+tree差异:
b+tree阶 | 插入key个数 | 优化前树高 | 优化后树高 | 优化前节点总数 | 优化后节点总数 |
---|---|---|---|---|---|
5 | 100 | 4 | 3 | 48 | 25 |
5 | 1000 | 6 | 5 | 498 | 251 |
5 | 100000 | 10 | 8 | 49995 | 25002 |
5 | 1000000 | 12 | 9 | 499993 | 250001 |
10 | 100 | 3 | 2 | 23 | 11 |
10 | 1000 | 4 | 3 | 246 | 111 |
10 | 100000 | 7 | 5 | 24994 | 11111 |
10 | 1000000 | 9 | 6 | 249993 | 111111 |
20 | 100 | 2 | 2 | 10 | 6 |
20 | 1000 | 3 | 3 | 109 | 54 |
20 | 100000 | 5 | 4 | 11107 | 5264 |
20 | 1000000 | 6 | 5 | 111106 | 52633 |
40 | 100 | 2 | 2 | 5 | 4 |
40 | 1000 | 3 | 2 | 52 | 26 |
40 | 100000 | 4 | 4 | 5261 | 2566 |
40 | 1000000 | 5 | 4 | 52629 | 25642 |
从上表中可以看出,优化后树高一定程度上比优化前减缓了,特别是是m越小的时候这种减缓更明显更明显 。
让树高增加减缓,还有一个优化点是:增大对叶子节点的最大key个数限制。
我为了演示算法,设置的是叶子节点最大key个数为m。这个不是必须的,你可以把这个限制改大,比如改成40。
优化2(升级key)
优化2:对搜索进行优化,如果在内节点就匹配到了key,那么跳过后续层的遍历,直达叶子节点。
优化方案:给b+tree内部key
赋能,使其具备2个能力:
- 可以从
内部key
中解析出用户插入的key - 可以从
内部key
中解析出该key所在的叶子节点地址
能完成上述优化的一个大前提是:叶子节点包含所有的key,非叶子节点(索引节点)中存的是冗余的key。即索引节点中的key,肯定存在于某个叶子节点中。
下面来看优化前后的对比:
下图是从一棵b+tree中搜索key(18)的节点路径:
全程会经历树高(4)个节点。但是如果经过我们这里的优化之后,在第2层中就定位了key(18),就能直达命中的叶子节点【16,17,18】。
上图中,如果搜索的是key(27)或者key(54),在根节点就能直达对应的key所在的叶子节点。
优化3(拓展兄弟)
优化3:扩展左兄弟和右兄弟到堂兄弟。
此优化只针对叶子结点,因为叶子节点之间有相互链接的指针。非叶子节点没有链接指针,且寻找非叶子节点的堂兄弟代价比较大。
此优化可以一定程度增加向左借位
、向右借位
以及匀key
操作的次数,从而减少合并
和分裂
操作的次数。
- 向左借位:前面限制如果同父的左兄弟不存在,是不能发生向左借位的;现在如果左边第一个堂兄弟存在,就能发生向左借位。
- 向右借位:前面限制如果同父的右兄弟不存在,是不能发生向右借位的;现在如果右边第一个堂兄弟存在,就能发生向右借位。
- 分裂:同理,扩展兄弟的范围后,能增加
匀key
给堂兄弟的场景,从而增加总体的匀key
操作的次数。
未来优化
未来优化是目前我对b+tree理解的2个todo list:
-
当m超过某个值后,可以将从节点的keys数组中搜索key的方式改为其他搜索方式:比如折半查找、二叉搜索等等
例如,当m=40时,插入1百万个key后的树高才等于5。搜索key的时间复杂度为O(height * m),可以看出此时搜索key的性能瓶颈在m上。如果将数组搜索改为折半查找,时间复杂度就会降低为O(height*log2m)
-
记录第一个叶子节点,并且让第一个叶子节点与最后一个叶子节点之间链接起来。
这样做的好处有:- 可以O(1)时间内确定b+tree的最小key。加上根节点中记录这b+tree的最大key,就能确定整个b+tree的key的范围。当搜索不在这个范围内的key时,可以O(1)时间内确定查无此key。
- 遍历叶子节点时,可以省去从根节点下沉定位第一个叶子节点的过程。同理也可省去从根节点下沉定位最后一个叶子节点的过程。
目前暂时没用时间做这两个优化了,未来有时间我会更新这两个的代码。
4. 代码
我对代码进行完全注释,请结合前文的理解去阅读、调试、运行代码。
完整的代码,点击链接获取。该代码仓库的一些说明:
- B+tree的代码在
b+tree
目录 - opencv绘制b+tree的代码在
b+tree/draw_b+tree
目录 - 测试b+tree的代码在test目录下的test_b+tree.cpp和test_b+tree.h文件
- 代码库中有完整的opencv2的头文件和动态链接库,无需安装opencv就可以编译并运行测试代码。
- 编译的方法参见README中
编译
章节描述 - 运行测试的方法参见README中
运行测试
->7. B+树(B+tree)
的描述。
代码的一些说明:
- 近期完全手敲的代码,良好的中文或英文注释。
- 本文中的b+tree的示例图,99%都是该代码生成的。
- b+tree节点中存储key、child和data的容器,恢复为使用数组(相比在b-tree代码中,我用的是链表)。
- 代码经过了尽量完全的UT和内存检测工具检测。但是个人能力和时间有限,如果发现有bug,或者逻辑不对的地方,欢迎指正。
代码中关键函数对照表如下,你可以对照本文相关关键点的描述去看代码,结合代码中的注释来进一步理解算法。
- 插入key
- 插入key:
bp_tree_insert
- 分裂节点:
bp_tree_split_node
- 匀key:
rebalance_node_keys_before_split
- 插入key:
- 删除key
- 删除key:
bp_tree_delete
- 再平衡:
bp_tree_rebalance
- 向右借位(左旋):
bp_tree_rotate_left
- 向左借位(右旋):
bp_tree_rotate_right
- 合并:
bp_tree_merge_node
- 删除key:
- 查询key:
bp_tree_search
- 遍历b+tree:
bp_tree_traverse
- 绘制b+tree:
draw_bp_tree
5. 写在最后
补充说明
补充说明1:
前面在讲合并
操作的时候,有一个统一合并顺序原则
:选取好合并节点后,后续所有的合并流程都是将偏右的节点合并到偏左的节点中。当时提了一个思考:这样定义顺序的好处是什么?
好处是:性能会更高。
我们以节点的keys数组合并为例来说明这个问题。
- 首先,我们已经知道节点的keys数组中的元素是按照从小到大的顺序有序排列的。
- 其次,偏左节点的keys数组中的任意key都小于偏右节点的keys数组中的任意key。
我们考虑,如果是将偏左节点合并到偏右节点中,为了保证keys数组的从小到大顺序,需要将偏左节点中的keys插入到偏右节点keys数组的开头。每插入一个key,都会涉及到数组中每个key往后移动1位的操作。所以时间复杂度为O(m2)。
但是,如果是将偏右节合并到偏左节点中,每次移动的key都放到偏左节点keys数组的尾巴上就行了,不涉及对其他已有key的位置影响。时间复杂度为O(m)。
最后
多年前想做一直没做的事儿,从3月到现在,1个多月的时间,把它做了:B-tree和B+tree!
从此对这块再无牵挂。
OK,开始新征程!2024.4.23.