🔥 本文专栏:C++高阶
🌸作者主页:努力努力再努力wz

💪 今日博客励志语录 :
坚持不是为了感动谁,而是为了在未来的某一天,你有底气对命运说:'这一局,我跟到底了'。
★★★ 本文前置知识:
B树
引入
在上一篇博客中,我已经对 B 树进行了较为系统的介绍,包括其基本性质以及适用场景。我们可以得出一个重要结论:B 树非常适合用于外查找(如磁盘),而红黑树和 AVL 树由于节点粒度较小、树高较大,更适用于内存中的内查找。
在本篇博客中,我们将进一步引入一种新的数据结构。该结构同样能够高效支持外查找,并且在实际工程(尤其是数据库系统)中具有更加广泛的应用。事实上,这一数据结构可以看作是在 B 树原有结构基础上的一种有针对性的优化与演进。
正如标题所示,本文将要讲解的,便是 B+ 树。
B+树
原理
在这里,我们学习 B+ 树 时,仍然以 B 树 作为切入点,重点分析 B+ 树是在 B 树基础上做了哪些结构性优化。
我们知道,对于 B 树而言,其节点大小通常与磁盘块(block)的大小对齐。一个 B 树节点不仅存储 key 值 ,还存储 key 对应的数据记录(或数据指针)以及子节点指针。这种设计会带来一个直接的问题:由于节点空间有限,同时存储 key、数据以及指针,会显著压缩单个节点中可容纳的 key 数量,从而减少分支数(fan-out)。
而分支数的减少会直接导致树的高度增加。对于外查找(external search)场景而言,最核心的开销在于 磁盘 I/O 次数 ,而 I/O 次数又与树的高度呈正相关。因此,降低树高(或等价地,提高节点分支数)成为优化查询效率的关键目标。
正是在这一背景下,B+ 树对 B 树的结构进行了针对性的改进。
与 B 树不同,B+ 树对 内部节点(internal node)与叶子节点(leaf node)进行了明确的职责划分:
- 内部节点:仅存储 key 值以及子节点指针,不再存储 key 对应的数据
- 叶子节点:存储所有 key 及其对应的数据记录
换言之,所有实际数据都集中存储在叶子节点中,内部节点仅作为"索引结构"存在。
这种设计带来的直接收益是显著的:
由于内部节点不再存储数据记录,其单位空间内可以容纳更多的 key 和子节点指针,从而显著提高分支数(fan-out)。这使得 B+ 树在整体形态上相比 B 树更加"矮而宽"。
在相同高度下,B+ 树可以容纳更多的 key 和数据;反之,在相同数据规模下,B+ 树的高度通常更低,从而减少磁盘 I/O 次数。此外,由于所有数据都位于叶子节点,叶子层天然构成了完整的数据集合视图。
从查找过程来看,B+ 树与 B 树也存在本质差异:
在 B 树中,如果在某个内部节点中命中了目标 key,则查找可以立即结束;而在 B+ 树中,查找操作始终必须下沉到叶子节点才算完成。即使在内部节点中已经出现了匹配的 key,也仅用于"导航",不会提前返回结果。
这也导致了区间语义上的细微差别。在 B+ 树的内部节点中,key 对应的子树划分通常为:
- 左子树:(key_i-1, key_i)
- 右子树:[key_i, key_i+1)
也就是说,右子树对应的是一个左闭右开区间。这种设计并非随意选择,而是与后续插入操作中 key 的分裂与分布策略密切相关(这一点将在后续实现部分展开说明)。
需要特别指出的是,虽然 B+ 树在结构上降低了树高,但这并不意味着其在单次查询上一定优于 B 树。
例如,在某些情况下,如果目标 key 恰好位于 B 树的高层节点(如根节点或次高层),那么 B 树可能只需 1~2 次 I/O 即可完成查询;而 B+ 树无论如何都必须访问叶子节点。
因此,可以这样理解:
- B 树:在单次查询上具有一定的"最优情况优势"
- B+ 树:在多次查询(高频访问)场景下具有更稳定、更优的整体性能
换句话说,B+ 树优化的是整体吞吐(throughput)与稳定性,而不是单次操作的极端最优情况。
最后,还需要补充一点:B+ 树中存在一定程度的 key 冗余。
由于内部节点仅用于索引导航,而叶子节点才存储完整数据,因此同一个 key 可能同时出现在内部节点和叶子节点中。但这种冗余在工程上是完全可以接受的,原因在于:
- 冗余的是 key 本身,而非完整的数据记录
- key 通常占用空间很小(如整数或短字符串)
- 相比磁盘 I/O 的性能收益,这部分空间开销可以忽略不计
因此,在外存索引结构的设计中,这种"以空间换时间(I/O)"的策略是非常典型且合理的。
根据上文,我们已经认识到 B+树 在 B树 基础上所做出的第一个结构性改进:在 B+ 树中,内部节点不再存储 key 对应的数据,而仅保留 key 值以及子节点指针;所有的 key 及其对应的数据记录,统一存储在叶子节点中。
进一步来看,对于 B+ 树最底层的叶子节点而言,其不仅存储 key 值及对应数据,还通过指针彼此连接,形成一个有序链表结构。换言之,B+ 树的所有叶子节点在逻辑上被串联为一个按 key 全局有序的链表。这一设计使得 B+ 树能够高效支持 B 树难以高效实现的操作------范围查询(Range Query)。
这里假设需要查询 key ∈ [keyᵢ, keyᵢ₊ₙ] 区间内的所有数据。对于 B 树而言,若要实现范围查询,通常需要借助中序遍历。但中序遍历要么作用于整棵树,要么局限于某一子树,而查询区间往往可能跨越多个子树。因此,B 树在执行"真正意义上的范围查询"时,往往需要多次从根节点出发进行随机查找(root-to-leaf traversal),从而导致较多的磁盘 I/O 开销。
相比之下,B+ 树的结构则更适配这一场景。由于:
- 所有数据均集中在叶子节点;
- 每个叶子节点内部的 key 按升序排列;
- 叶子节点之间通过指针构成全局有序链表;
因此,范围查询可以拆解为"一次随机查找 + 顺序扫描"的组合操作:首先从根节点出发,执行一次查找定位到 keyᵢ 所在的叶子节点及其位置;随后沿叶子节点链表顺序向后遍历,直到访问到 keyᵢ₊ₙ 为止。
这种访问模式相比 B 树显著降低了磁盘 I/O 次数:当数据分布较为紧凑时,目标区间甚至可能完全落在同一个叶子节点中;即便分布在多个叶子节点之间,由于链表的顺序访问特性,也避免了频繁的随机 I/O。
此外,在具体实现中,叶子节点通常组织为双向链表结构,这意味着不仅可以支持升序范围查询,也可以高效支持降序范围查询(reverse range scan),进一步增强了查询的灵活性。
综上所述,"叶子节点链表化"是 B+ 树相较于 B 树的第二个关键结构优化。正是由于这一特性,使得 B+ 树在需要大量范围查询的场景中表现出显著优势,这也是诸如 InnoDB 等数据库存储引擎选择 B+ 树作为索引结构的核心原因之一。

插入
根据上文,在认识了 B+ 树 与 B 树 在结构上的差异之后,需要进一步强调:尽管二者在节点组织形式上存在明显不同,但 B+ 树是在 B 树基础上的改进型结构,因此仍然严格继承了 B 树的核心性质 ,包括关键字的全局有序性 以及节点的上下限约束(除根节点外)。
基于这一点,B+ 树的插入与删除操作在整体思路上与 B 树是相似的,但在具体实现细节上存在关键差异。因此,这里我们首先分析 B+ 树的插入过程。
对于 B+ 树的插入操作,首先仍然依赖其有序性 :从根节点出发,自顶向下进行查找,最终定位到目标插入位置所在的叶子节点 。需要特别注意的是,B+ 树的内部节点不存储实际数据,仅存储 key 和子节点指针 ,因此所有数据记录都只存在于叶子节点中,这一点与 B 树有本质区别。因此,插入操作必然发生在叶子节点,这一点是确定无疑的。
在定位到目标叶子节点之后,节点内部的 key 通常以有序数组 的形式存储,这样的设计可以利用二分查找快速定位插入位置,从而提升查找效率。同时 value(或记录指针)也以数组形式与 key 一一对应。
完成插入后,需要将 key 数组和 value 数组同步更新,将目标 key 及其对应数据插入到正确位置。但此时操作尚未结束,因为节点仍需满足上限约束(最大关键字数)。
因此,在插入完成后,必须检查当前节点是否发生上溢(overflow)。若发生上溢,则需要执行**节点分裂(split)**操作。这一过程与 B 树在形式上类似,但在具体策略上存在差异。
在 B 树中,节点分裂的典型做法是:
- 创建一个新的右兄弟节点;
- 将当前节点的中位数 key上移到父节点;
- 将中位数右侧的部分移动到右兄弟节点;
而在 B+ 树中,这一过程需要进行调整:
- 同样创建右兄弟节点;
- 将中位数以及其右侧所有 key(即右半部分)整体复制到右兄弟节点;
- 将右兄弟节点中的最小 key(即原中位数)上移到父节点;
叶子节点分裂过程示意图:

这里的关键区别在于:中位数 key 在叶子节点中不会被删除,而是仍然保留。
而读者在理解到这里时,往往会产生一个关键疑问:为什么在 B+ 树中,需要将中位数一并拷贝到右兄弟节点,而不像 B 树那样直接将中位数上移到父节点并从当前节点中移除?
其根本原因在于:B+ 树的内部节点与叶子节点在结构与职责上的设计发生了本质变化。
在 B+ 树中,内部节点仅用于充当索引层 ,只存储 key 值以及子节点指针,并不存储与 key 对应的实际数据 ;而所有的数据记录必须完整地保存在叶子节点中。这一点与 B 树形成了鲜明对比------在 B 树中,内部节点同样可以存储 key 及其对应的数据,因此可以安全地将中位数(连同数据)从子节点中"移动"到父节点。
但在 B+ 树中,如果仍然沿用 B 树的做法,将中位数直接上移并从叶子节点中删除,就会产生两个问题:
- 数据丢失问题:由于 B+ 树的内部节点不存储实际数据,如果将中位数直接上移到父节点,则该 key 对应的数据将无法在树结构中得到保留,从而破坏数据的完整性;
- 叶子节点完整性被破坏:B+ 树要求所有 key 及其对应的数据必须完整存在于叶子节点中,一旦移除中位数,将破坏这一性质;
因此,在 B+ 树中,中位数 key 不能被"移除",只能在叶子节点中保留。
基于这一约束,B+ 树在分裂时采取的是"复制 + 上移"策略:
- 将中位数及其右侧部分复制到右兄弟节点;
- 同时将该中位数 key 上移(作为分隔键)插入到父节点;
换言之,这里的中位数在逻辑上承担了"双重角色":
- 在叶子层中,仍然作为真实数据的一部分存在;
- 在内部节点中,则作为**索引边界(分隔 key)**存在;
因此可以得出结论:
在 B+ 树中,key 只允许在叶子节点之间进行重新分布(redistribution) ,但不允许被删除或上移而脱离叶子层。
这也正是为什么在分裂过程中,必须将中位数一并拷贝到右兄弟节点,而不能简单地沿用 B 树的"上移并删除"策略。
接下来分析父节点的更新过程。
对于父节点而言,在将中位数插入之后,其插入位置必然位于当前节点与原有右侧子树之间的分隔 key(记为 keyᵢ)的右侧,即位置 keyᵢ₊₁。
此时,原先 keyᵢ 所对应的右子树(即发生分裂前的当前节点)将自然成为 keyᵢ₊₁ 的左子树。
然而,由于分裂过程中创建了新的右兄弟节点,还需要对父节点的子节点指针数组进行相应更新,即将 keyᵢ₊₁ 的右子树指向新生成的右兄弟节点。
因此,需要同步更新父节点的子指针数组,将新节点挂接到正确位置。
这一过程也解释了一个关键语义变化:
在 B+ 树中,内部节点中 key 对应的子树区间通常表示为左闭右开区间([left, right))。其根本原因在于:
- 中位数 key 被保留在右子树(叶子节点)中;
- 同时该 key 又被复制到父节点作为分隔值;
因此,右子树可以包含等于该 key 的元素,而左子树严格小于该 key。
最后,从全局角度验证该过程的正确性:
中位数 key 上移到父节点后,满足:
- 左子树所有 key < 中位数
- 右子树所有 key ≥ 中位数
因此,B+ 树的有序性在分裂过程中依然得到严格保证。
至此可以总结:
B+ 树在分裂阶段与 B 树的本质区别在于:
- B 树:中位数"移动"到父节点
- B+ 树:中位数"复制"到父节点,同时保留在叶子节点中
这一设计正是由 B+ 树"数据只存储在叶子节点"这一核心约束所决定的,同时也是其能够高效支持**范围查询(range query)**的基础。
需要注意的是,在叶子节点发生分裂时,不仅会生成新的右兄弟节点,而且还会将中位数 复制 一份插入到父节点中。
因此,父节点在接收该中位数之后,同样可能违反节点的上限约束,从而出现上溢出。基于这一点,需要自底向上递归地检查父节点是否发生上溢出;若发生,则同样需要执行分裂操作。
进一步地,对于内部节点的分裂,其处理方式与叶子节点存在本质区别:内部节点不需要保留中位数。这是因为内部节点仅作为索引结构存在,并不需要存储所有的 key。
因此,内部节点的分裂过程与 B 树是完全一致的,具体步骤如下:
- 创建一个新的右兄弟节点;
- 将中位数右侧的 key(及对应子树)移动到右兄弟节点;
- 将中位数 key 上移到父节点;
这一过程本质上是一个"向上传播的结构调整过程",即局部节点的上溢可能逐层向上传递,最终甚至可能导致根节点分裂,从而引发树高的增加。
删除
认识了 B+ 树的插入操作之后,接下来我们分析 B+ 树的删除操作。对于 B+ 树而言,删除操作只会发生在叶子节点,因为所有的 key 以及对应的数据(record)均存储在叶子节点中。
删除过程与 B 树类似:从根节点出发向下遍历,定位到目标叶子节点以及对应的删除位置,然后对节点内部的 key 数组和 value 数组进行更新。由于节点存在最小容量(下限)约束,因此在删除完成后,需要检查当前节点是否发生下溢(underflow)。截至这一阶段,其处理流程与 B 树是完全一致的。
当出现下溢时,需要进行结构调整。首先考虑向兄弟节点借元素。
假设向左兄弟节点借元素。在 B 树中,典型做法是:将父节点中分隔当前节点与左兄弟节点的 key 下移到当前节点,同时将左兄弟节点的最大 key 上移到父节点,从而完成再分配。
但在 B+ 树中,由于结构上的差异,该策略不能直接套用。具体而言:
- B+ 树的内部节点仅存储索引 key,而不存储实际数据;
- 所有数据必须完整保存在叶子节点中;
- 因此,不能像 B 树那样将叶子节点中的数据"上移"至父节点,否则会破坏数据完整性。
基于上述约束,B+ 树的处理方式如下:
- 直接将左兄弟节点中最大的 key 及其对应的数据复制到当前节点;
- 为了维持有序性,需要更新父节点中分隔左右节点的 key;
- 更新方式为:使用当前节点的新最小 key(即原左兄弟节点的最大 key)覆盖该分隔 key。
需要注意的是,此过程中父节点的 key 数量没有减少 ,因此父节点不会发生下溢,也无需进一步检查。

若向右兄弟节点借元素,则过程完全对称:
- 将右兄弟节点中最小的 key 及其对应的数据复制到当前节点的末尾;
- 随后用右兄弟节点新的最小 key 更新父节点中对应的分隔 key。

以上讨论的是"可借"的情况。若左右兄弟节点均已达到最小容量,无法再借,则需要进行节点合并(merge)。
合并策略如下:
-
将右节点的所有 key 和 value 复制到左节点中;
-
删除右节点;
-
同时,由于 B+ 树的叶子节点通常通过链表(单链或双链)连接,还需要维护链表结构:
- 更新左节点的
next指针,使其指向右节点的后继节点; - 若为双向链表,还需更新后继节点的
prev指针。
- 更新左节点的
在删除右节点之后,还需要处理父节点中的分隔 key:
- 删除父节点中原本用于分隔左右子节点的 key(记为 keyᵢ);
- 同时删除对应的子节点指针(指向右节点的指针);
- 为维持有序性,需要将父节点中 keyᵢ 之后的 key 和 child 指针整体左移。

由于右节点被删除,因此需要同时删除父节点中原本用于分隔左节点与右节点的分隔 key(记为 keyᵢ)。
在删除之前,父节点中的结构关系如下:
- keyᵢ 的左子树为左节点;
- keyᵢ 的右子树为右节点;
- 同时,keyi左节点也可以视为 keyᵢ₋₁ 的右子树。
当左右节点完成合并后:
- 合并后的节点本质上仍然对应原先 keyᵢ₋₁ 的右子树,这一点是不变的;
- 但由于 keyᵢ 被删除,父节点的 key 序列发生收缩,原有的区间划分被重新组织。
因此,在实现层面需要进行如下调整:
- 删除 keyᵢ 以及其对应的右子树指针(即原右节点指针);
- 将 keyᵢ 之后的所有 key 向左移动一位,以填补 keyᵢ 的空缺;
- 对应地,将 child 指针数组中右节点之后的所有指针整体左移一位;
- 合并后的节点自然保留在原左节点的位置,其在 child 数组中的相对位置不变。
这样调整之后:
- 父节点的 key 与 child 指针仍然满足"key 将 child 划分为有序区间"的基本性质;
- 合并后的节点会自动成为其右侧 key(原 keyᵢ₊₁,现已左移)的左子树,从而保证整体有序性不被破坏。
由于父节点发生了 key 的删除,因此可能引发父节点下溢。此时需要递归向上处理。
对于内部节点的下溢处理,B+ 树与 B 树完全一致:
-
若可以向兄弟节点借:
- 向左借:将父节点的分隔 key 下移到当前节点,同时将左兄弟的最大 key 上移到父节点;
- 向右借:过程对称;
-
若无法借,则进行合并:
- 将父节点中分隔左右节点的 key 下拉到左节点;
- 再将右节点的内容合并到左节点中;
- 删除右节点,并在父节点中删除对应的 key 和 child 指针。
注意的是:B树合并需要把父节点的分隔key下拉到合并节点中(因为那个key本身也是数据),而B+树叶子合并不需要下拉分隔key,直接把右节点拼接到左节点就行,因为叶子已经包含所有key了,父节点的分隔key只是索引副本,删掉即可。
总体而言,B+ 树删除操作的核心特点在于:
- 数据始终保留在叶子节点中,不会上移至内部节点;
- 内部节点仅作为索引结构参与重平衡;
- 叶子节点之间的链表结构在合并时必须额外维护;
- 下溢处理可能递归向上传播,直至根节点。
这样设计保证了 B+ 树在范围查询与顺序访问场景中的高效性,同时维持了良好的结构平衡。
查找
最后需要介绍的是 B+ 树的查找操作。相较于插入与删除,查找过程在逻辑上最为直接,因而也更易于理解与实现。从整体流程来看,B+ 树的查找与 B 树基本一致:均是自根节点出发,自顶向下逐层遍历。
在具体执行过程中,对于当前访问的节点,需要在其 key 数组中进行有序查找,以确定目标 key 所属的区间,从而选择对应的子树继续向下访问。该过程会持续进行,直至到达叶子节点中的目标位置。随后,通过比较该位置的 key 是否与目标 key 相等,来判断查找是否成功。
需要特别注意的一点是:在查找路径上的处理策略,B+ 树与 B 树存在本质差异。在 B 树中,如果在某个内部节点已经匹配到目标 key,则可以直接返回查找结果;而在 B+ 树中,即使在内部节点匹配到了对应的 key,也不能直接返回 ,仍然必须继续向下遍历,直至到达叶子节点。这是因为 B+ 树的所有数据均存储在叶子节点中,内部节点仅作为索引使用,不承载实际数据。
换言之,B+ 树的查找过程本质上是一次"索引定位 + 叶子验证"的过程:前者通过内部节点逐层缩小查找范围,后者在叶子节点中完成最终的数据命中判定。这一设计不仅保证了查找路径长度的一致性,也为后续的范围查询与顺序访问提供了结构上的支持。
B+树的实现
通过上文,我们已经系统性地认识了 B+ 树的基本原理。接下来进入实现阶段,即使用代码来构建一棵 B+ 树。首先需要解决的核心问题,是对 B+ 树节点的数据结构进行合理设计。
对于节点类型的设计,这里显然应当采用模板类(template class)的方式进行抽象。首先,键值(key)的类型应当作为一个模板参数,因为在实际应用中,键值可能是整型、字符串甚至自定义类型;通过模板实例化,可以在编译期确定具体的数据类型。同理,数据(value)的类型也应作为模板参数。此外,还需要引入一个非类型模板参数来表示 B+ 树的阶(order / M),这一点与 B 树的设计是完全一致的。
接下来是 B+ 树节点的具体结构设计。我们知道,在 B+ 树中,内部节点(internal node)与叶子节点(leaf node)在结构和语义上存在本质差异:内部节点仅存储 key 以及子节点指针,而叶子节点则存储 key 以及对应的数据(value)。
不过,在工程实现中,这里并没有将两类节点拆分为两个独立的模板类,而是选择复用同一个模板类 来统一表示节点类型。其核心思想在于:在类内部引入一个 bool 类型的标志位,用于标识当前节点是叶子节点还是内部节点。这样可以避免类型体系的复杂化,同时降低代码冗余,提高实现的一致性。
基于这一设计,一个节点需要同时具备以下几类成员:
keys数组:用于存储键值;values数组:仅在叶子节点中使用;child指针数组:仅在内部节点中使用;count:记录当前节点中 key 的数量;isleaf:标识节点类型。
在具体访问时,通过 isleaf 判断节点类型,从而选择性地使用对应的成员变量:
- 对于叶子节点,不访问
child数组; - 对于内部节点,不访问
values数组。
此外,由于 B+ 树的叶子节点之间需要通过指针进行有序串联 ,以支持高效的范围查询,因此节点结构中还需要额外引入一个 next 指针,用于指向当前叶子节点的后继节点。在本实现中,叶子节点被组织为单向链表,这意味着范围查询仅支持正向遍历(从小到大)。当然,在更复杂的实现中,也可以将其扩展为双向链表,以支持反向遍历。
节点定义如下:
cpp
template<typename key, typename value, size_t M>
class BPlusTreeNode
{
public:
BPlusTreeNode()
: count(0)
, isleaf(true)
, next(nullptr)
{
for (int i = 0; i < M + 1; i++)
{
child[i] = nullptr;
}
for (int i = 0; i < M; i++)
{
keys[i] = key();
values[i] = value();
}
}
bool isleaf;
key keys[M];
value values[M];
BPlusTreeNode* child[M + 1];
BPlusTreeNode* next;
int count;
};
这里有一个实现层面的细节需要特别说明:
对于 keys 数组而言,虽然理论上的最大 key 数量是 M - 1,但实际分配了长度为 M 的空间。这种"冗余一位"的设计是一个典型的工程优化手段,其目的是简化插入与分裂逻辑 ------可以先完成插入操作,再统一判断是否发生上溢(overflow),从而避免在插入前进行复杂的边界判断。同理,child 数组的长度为 M + 1,以满足内部节点最多拥有 M 个 key 对应 M + 1 个子树的结构约束。
接下来是关于 BPlusTree 模板类的设计。这里的模板参数需要与节点类保持一致,即同样包含键类型、数据类型以及阶数(M),以确保类型体系的一致性与可复用性。
在该模板类中,最核心的成员是一个指向根节点(root)的指针。由于整棵 B+ 树的所有操作(如插入、删除、查找)都是从根节点出发逐层向下进行的,因此在初始化时需要对根节点进行实例化或维护其指针状态。
此外,这里还通过 using 关键字为节点类型取了一个别名(type alias),从而简化后续代码中的类型书写,提高代码的可读性与可维护性。例如,将 BPlusTreeNode<key, value, M> 简化为 Node,可以避免在模板类内部频繁书写冗长的类型名称,使整体实现更加清晰、紧凑。
此外,该类还将封装一系列对外提供的接口函数,例如插入(insert)、删除(erase)以及查找(find)等操作,这些内容将在后文中逐步展开。
定义如下:
cpp
template<typename key, typename value, size_t M>
class BPlusTree
{
using Node = BPlusTreeNode<key, value, M>;
public:
BPlusTree()
: root(nullptr)
, head(nullptr)
{
}
// ....
Node* root;
Node* head;
};
这里补充一点设计上的考虑:由于 B+ 树的所有数据都存储在叶子节点中,因此维护一个 head 指针可以在 O(1) 时间内定位到最小 key,从而显著提升区间查询(range query)和全表扫描(full scan)的效率,这也是 B+ 树相比 B 树在数据库和文件系统中更具优势的重要原因之一。
插入
在了解了 B+ 树的整体结构之后,接下来我们将重点分析其插入模块的设计 。对于插入函数的实现逻辑,首先需要判断当前 B+ 树是否为空,即 root 指针是否为 nullptr。
如果当前为空树,则需要创建一个新的节点。此时,该节点既作为根节点,同时也是叶子节点,因此需要正确设置其叶子节点标志位。随后,将目标 key 及其对应的数据分别插入到该节点的 keys 数组和 values 数组中,并更新节点元素个数。最后,将该节点同时赋值给 root 和 head(用于维护叶子节点链表的头指针),然后直接返回。
cpp
if(root==nullptr)
{
root=new Node();
root->keys[0]=k;
root->values[0]=v;
root->count=1;
head=root;
return true;
}
如果根节点不为空,则需要从根节点开始向下遍历整棵 B+ 树,定位目标插入的叶子节点以及对应的插入位置。这里将查找逻辑封装为 search 函数。
该 search 函数返回一个二元组(std::pair),其中包含:
- 一个指向目标叶子节点的指针;
- 一个整型索引,表示在该节点中插入的位置(即数组下标)。
需要注意的是,在插入过程中,节点可能发生上溢(overflow)。一旦发生上溢,就需要执行节点分裂操作,即创建右兄弟节点,并将中间关键字上移到父节点。这一过程涉及对父节点的访问与修改。
传统实现方式通常是在节点结构中维护一个父指针(parent pointer)。然而,在节点分裂时,不仅需要更新新建右兄弟节点的父指针,还可能需要调整原有子节点的父指针指向,这会增加实现复杂度,同时带来额外的维护成本。
因此,这里采用另一种策略:使用栈(std::stack)记录从根节点到当前节点的路径。利用栈的后进先出特性,在向下遍历过程中,每进入下一层子树之前,将当前节点压入栈中。这样,当最终到达叶子节点时,栈中保存的正是整条访问路径,此时栈顶元素即为当前节点的父节点。
因此,search 函数需要接收两个参数:
- 输入参数:待查找的目标
key; - 输出参数:路径栈(用于记录访问路径)。
在具体实现中,从根节点开始进行迭代查找。循环终止条件为当前节点为叶子节点。在遍历内部节点时,需要特别注意:B+ 树中右子树区间的语义为左闭右开,这与 B 树存在差异。
在不改变你原有逻辑的前提下,对该段内容进行了规范化与专业化表达,并对关键语义做了适度强化说明:
在遍历当前节点的 keys 数组时,如果仍然采用"查找第一个大于等于 key 的位置"(即 lower_bound,记为 pos),则可能出现如下问题:当 keys[pos] == key 时,对应的 child[pos] 实际指向的是该 key 的左子树 (即区间小于 key 的子树),而我们在 B+ 树的查找过程中,期望进入的是右子树 (即区间大于等于 key 的子树)。
针对这一问题,通常有两种处理方式:
- 若
keys[pos] == key,则将pos加一,从而转向右子树; - 若
keys[pos] > key,则当前pos已经对应正确的子树区间(此时keys[pos-1] < key < keys[pos]),可以直接使用。
可以看到,这种方式需要额外的等值判断逻辑,使实现变得相对繁琐。
为此,这里定义了一种新的查找策略 upper_search,用于返回第一个严格大于 key 的数组下标 (即 upper_bound 语义)。这样可以从根本上避免上述分支判断问题。
其核心性质如下:
- 若存在某个下标
i满足keys[i] == key,则函数返回i + 1。此时对应的child[i + 1]正好指向keys[i]的右子树,即区间大于等于key的子树; - 若不存在等于
key的元素,则返回第一个满足keys[i] > key的下标i。此时有keys[i-1] < key < keys[i],对应的child[i]表示区间(keys[i-1], keys[i]),该区间同样包含目标key。
因此,无论是否存在与 key 相等的元素,都可以统一地直接使用返回的 pos 访问 child[pos],而无需额外进行等值判断或修正操作。这不仅简化了实现逻辑,也使代码语义更加清晰、一致。
cpp
int upper_search(const key* keys,const key& k,int count)
{
int left=0;
int right=count;
while(left<right)
{
int mid=left+(right-left)/2;
if(keys[mid]>k)
{
right=mid;
}else
{
left=mid+1;
}
}
return left;
}
当遍历到叶子节点后,循环终止。此时需要在叶子节点中查找第一个大于等于 key 的位置 (注意此处与内部节点不同),用于确定插入位置。因此,这里调用的是 binary_search(语义为 lower_bound)。
cpp
int binary_search(const key* keys,const key& k,int count)
{
int left=0;
int right=count;
while(left<right)
{
int mid=left+(right-left)/2;
if(keys[mid]>=k)
{
right=mid;
}else
{
left=mid+1;
}
}
return left;
}
最终,search 函数返回叶子节点指针以及对应的插入位置:
cpp
std::pair<Node*,int> search(const key& k,std::stack<Node*>& path)
{
Node* cur=root;
int pos=0;
while(!cur->isleaf)
{
pos=upper_search(cur->keys,k,cur->count);
path.push(cur);
cur=cur->child[pos];
}
pos=binary_search(cur->keys,k,cur->count);
return std::make_pair(cur,pos);
}
在调用 search 函数之后,首先需要对返回结果进行检查,以判断该 key 是否已经存在于当前 B+ 树中。具体而言,需要满足以下两个条件:
- 返回的下标
pos在有效范围内(即pos < count); - 且
keys[pos] == key。
如果上述条件成立,则说明该键值已存在,此时无需重复插入,函数可以直接返回。否则,继续执行后续的插入流程。
在严格保持你原有思路与实现逻辑的前提下,对该段内容进行了系统性润色与规范化表达,同时补充了必要的工程性说明,使整体更加清晰、严谨且专业:
接下来需要将数据插入到目标叶子节点中。这里将该操作封装为 insert_into_leaf 函数。该函数接收以下参数:
- 指向目标叶子节点的指针
leaf; - 插入位置下标
pos; - 待插入的
key以及对应的value。
其核心逻辑是:将 keys 数组和 values 数组中从 pos 开始的元素整体向右移动一个单位,从而为新元素腾出空间;随后在 pos 位置插入对应的 key 和 value,并更新节点中的元素个数 count。
cpp
void insert_into_leaf(Node* leaf,int pos,const key& k,const value& v)
{
for(int i=leaf->count;i>pos;i--)
{
leaf->keys[i]=leaf->keys[i-1];
leaf->values[i]=leaf->values[i-1];
}
leaf->keys[pos]=k;
leaf->values[pos]=v;
leaf->count++;
}
完成叶子节点插入之后,需要进一步判断当前节点是否发生上溢(overflow) 。由于上溢可能向上传播至父节点,因此这里采用 while 循环统一处理这一过程。循环条件为当前节点的关键字数量达到上限(即 count == M)。若未发生上溢,则直接退出;否则进入分裂流程。
在分裂过程中,首先创建右兄弟节点 brother,并确定中位数位置 mid 及对应的关键字 midkey。需要特别注意的是:叶子节点与内部节点的分裂策略不同,因此需要根据节点类型分别处理。
- 叶子节点分裂
若当前节点为叶子节点,则需要将中位数及其右侧的所有元素 (包括 key 和 value)复制到右兄弟节点中。同时维护叶子节点之间的链表结构(即 next 指针)。
随后:
- 当前节点保留左半部分;
- 右兄弟节点保存右半部分;
- 将右兄弟节点的最小 key (即
brother->keys[0])作为分隔键插入到父节点中。
这一点是 B+ 树与 B 树的重要区别:叶子节点分裂时,中位 key 不上移,而是复制,其本体仍保留在叶子节点中。
- 内部节点分裂
若当前节点为内部节点,则处理方式与 B 树一致:
- 取中位数
midkey; - 将中位数右侧的 key 复制到右兄弟节点;
- 同时将对应的子树指针(
child数组)一并迁移; - 当前节点保留左半部分;
- 中位 key 从当前节点删除,并上移插入父节点。
- 向父节点插入(内部节点插入)
将分裂产生的 midkey 插入父节点时,需要调用 insert_into_internal 函数。该函数接收:
- 父节点指针;
- 插入位置
pos; - 待插入的
key; - 新创建的右子树指针
rightchild。
在调用前,需要通过 upper_search 在父节点中定位第一个大于 midkey 的位置,作为插入下标。
在函数内部:
- 将
keys[pos]及其右侧元素整体右移; - 插入新的
key;
其次需要对 child 数组进行更新。需要明确的是:在内部节点中,keys[pos] 表示的是一个分隔键,其对应的 child[pos] 指针指向的是该 key 的左子树。
因此,在当前插入场景下,pos 位置原本对应的子树实际上是新插入分隔键的左子树,即原节点(分裂前的节点),也可以理解为 pos-1 区间的右子树。
而在节点分裂之后,需要将新生成的右兄弟节点作为该分隔键的右子树 。根据 B+ 树内部节点的结构约定,右子树应当存放在 child[pos+1] 位置。
因此,需要执行如下操作:
- 将
child数组中从pos+1开始的所有指针整体向右移动一个单位; - 在腾出的位置
child[pos+1]处,插入指向新创建的右兄弟节点的指针。
通过上述调整,可以保证分裂后父节点中 key 与 child 指针之间的对应关系仍然满足 B+ 树的结构约束,即:每个分隔键始终正确地划分其左右子树区间。
cpp
void insert_into_internal(Node* node,int pos,const key& k,Node* rightchild)
{
for(int i=node->count;i>pos;i--)
{
node->keys[i]=node->keys[i-1];
}
node->keys[pos]=k;
if(rightchild!=nullptr)
{
for(int i=node->count+1;i>pos+1;i--)
{
node->child[i]=node->child[i-1];
}
node->child[pos+1]=rightchild;
}
node->count++;
}
- 根节点分裂处理
在完成当前层分裂后,需要判断路径栈 path 是否为空:
- 若为空 :说明当前节点为根节点,此时需要创建新的根节点。新根节点包含一个 key(即
midkey),并将当前节点与新创建的兄弟节点分别作为其左右子树。 - 若不为空 :则弹出栈顶节点作为父节点,将
midkey插入父节点,并继续向上判断是否发生新的上溢。
- 插入函数整体实现
cpp
bool insert(const key& k,const value& v)
{
if(root==nullptr)
{
root=new Node();
root->keys[0]=k;
root->values[0]=v;
root->count=1;
head=root;
return true;
}
std::stack<Node*> path;
std::pair<Node*,int> res=search(k,path);
Node* cur=res.first;
int pos=res.second;
// 判重
if(pos<cur->count && cur->keys[pos]==k)
{
return false;
}
// 插入叶子节点
insert_into_leaf(cur,pos,k,v);
// 处理上溢
while(cur->count==M)
{
Node* parent=nullptr;
Node* brother=new Node();
int mid=(M+1)/2-1;
key midkey;
if(cur->isleaf)
{
int j=0;
for(int i=mid;i<M;i++)
{
brother->keys[j]=cur->keys[i];
brother->values[j++]=cur->values[i];
}
brother->count=j;
brother->next=cur->next;
cur->next=brother;
cur->count=mid;
midkey=brother->keys[0];
}
else
{
midkey=cur->keys[mid];
int j=0;
for(int i=mid+1;i<M;i++)
{
brother->keys[j++]=cur->keys[i];
}
brother->count=j;
brother->isleaf=false;
cur->count=mid;
j=0;
for(int i=mid+1;i<=M;i++)
{
brother->child[j++]=cur->child[i];
}
}
if(path.empty())
{
root=new Node();
root->isleaf=false;
root->keys[0]=midkey;
root->child[0]=cur;
root->child[1]=brother;
root->count=1;
break;
}
else
{
parent=path.top();
path.pop();
int pos=upper_search(parent->keys,midkey,parent->count);
insert_into_internal(parent,pos,midkey,brother);
cur=parent;
}
}
return true;
}
删除
接下来讨论删除模块的具体实现。删除操作的整体流程可以概括为:定位目标 → 执行删除 → 处理下溢 → 必要时向上递归调整结构。
首先,在删除函数中需要判断当前树是否为空。若为空树,则无需执行任何操作,直接返回;若非空,则从根节点开始向下遍历,定位包含目标 key 的叶子节点。这里仍然复用 search 函数完成查找逻辑。
在调用 search 之前,需要预先定义一个栈结构,并将其传入 search 函数,用于记录从根节点到目标节点路径上的所有祖先节点,以便后续处理下溢时进行回溯调整。
search 函数返回一个二元组(节点指针 + 位置索引),因此需要对其返回结果进行合法性检查,以判断目标 key 是否存在。不存在的情况主要包括两类:
- 返回的
pos超出当前节点有效范围(即pos >= count),说明目标 key 大于该叶子节点中的所有 key; pos在有效范围内,但对应位置的 key 与目标 key 不相等。
若出现上述任一情况,则说明删除目标不存在,函数直接返回。
在确认目标 key 存在之后,接下来执行叶子节点中 key 及其对应 value 的删除操作。这里将该逻辑封装为 remove_from_node 函数,其核心思想是:将删除位置右侧的元素整体向左移动一位,并更新节点元素个数 count。
cpp
void remove_from_node(Node* node, int pos)
{
for(int i = pos; i < node->count - 1; i++)
{
node->keys[i] = node->keys[i + 1];
node->values[i] = node->values[i + 1];
}
node->count--;
}
完成删除后,需要判断当前节点是否发生下溢(underflow) 。由于下溢可能向上传播至父节点,因此这里采用 while 循环进行持续检查,其循环条件为:当前节点不是根节点,且节点元素个数低于下限。对于根节点,会在循环之外进行单独处理。
进入下溢处理逻辑后,可以确定当前节点一定不是根节点,因此栈中必然存在其父节点信息。此时弹出栈顶元素,获取父节点指针,并在父节点的 child 数组中定位当前节点的下标位置。
随后,根据节点类型(叶子节点或内部节点)分别处理。这里首先讨论叶子节点的下溢处理策略。
对于叶子节点,下溢的处理优先级为:先借(borrow),后合并(merge)。
- 向左兄弟借
默认优先尝试向左兄弟节点借元素。前提是当前节点不是父节点的最左子节点(即存在左兄弟),且左兄弟节点有多余元素可借。
该逻辑封装为 leaf_borrow_from_left 函数。函数接收父节点指针及当前节点在 child 数组中的下标。在函数内部:
- 获取左兄弟节点与当前节点;
- 将当前节点所有元素整体右移;
- 将左兄弟节点的最大 key/value 插入到当前节点的开头;
- 左兄弟节点元素数量减一;
- 更新父节点中对应的分隔 key,使其等于当前节点的最小 key。
cpp
void leaf_borrow_from_left(Node* parent, int index)
{
Node* left = parent->child[index - 1];
Node* cur = parent->child[index];
insert_into_leaf(cur, 0, left->keys[left->count - 1], left->values[left->count - 1]);
left->count--;
parent->keys[index - 1] = cur->keys[0];
}
- 向右兄弟借
若左兄弟不可借,则尝试向右兄弟借。该逻辑封装为 leaf_borrow_from_right 函数:
- 获取当前节点与右兄弟节点;
- 将右兄弟节点的最小 key/value 插入到当前节点末尾;
- 将右兄弟节点剩余元素整体左移;
- 更新父节点中的分隔 key,使其等于右兄弟节点新的最小 key。
cpp
void leaf_borrow_from_right(Node* parent, int index)
{
Node* right = parent->child[index + 1];
Node* cur = parent->child[index];
cur->keys[cur->count] = right->keys[0];
cur->values[cur->count] = right->values[0];
cur->count++;
for(int i = 0; i < right->count - 1; i++)
{
right->keys[i] = right->keys[i + 1];
right->values[i] = right->values[i + 1];
}
right->count--;
parent->keys[index] = right->keys[0];
}
- 合并节点
若左右兄弟均无法借出元素,则只能执行合并操作。默认策略为:优先与左兄弟合并(若当前节点不是最左子节点)。
leaf_merge函数用于处理叶子节点的合并操作,其接收父节点指针以及待合并的左节点在 child 数组中的下标作为参数,其主要步骤如下:
- 获取待合并的左右节点;
- 将右节点的所有 key/value 追加到左节点末尾;
这里需要删除父节点中用于分隔左右子节点的分隔 key。删除该 key 后,需要将其右侧的 key 元素整体向左移动一个单位,以保持数组的连续性。
同时,由于右子节点即将被删除,在 child 数组中,其位置对应于分隔 key 下标的右侧一个单位(即 index + 1)。因此,还需要将该位置右侧的所有子节点指针整体向左移动一个单位,以填补空缺,并保证子节点指针与 key 之间的对应关系仍然成立。
此外,在完成结构调整之前,还需要维护叶子节点之间的链式关系:应将左节点的 next 指针指向右节点的后继节点,从而保证叶子层链表的连续性。
完成上述所有调整后,即可安全释放右节点的内存空间。
cpp
void leaf_merge(Node* parent, int index)
{
Node* left = parent->child[index];
Node* right = parent->child[index + 1];
for(int i = 0; i < right->count; i++)
{
left->keys[left->count] = right->keys[i];
left->values[left->count] = right->values[i];
left->count++;
}
left->next = right->next;
for(int i = index; i < parent->count - 1; i++)
{
parent->keys[i] = parent->keys[i + 1];
}
for(int i = index + 1; i < parent->count; i++)
{
parent->child[i] = parent->child[i + 1];
}
parent->child[parent->count] = nullptr;
parent->count--;
delete right;
}
需要强调的是:合并操作会导致父节点删除一个分隔 key,因此父节点也可能发生下溢 。这正是外层 while 循环存在的原因------用于持续向上修复结构,直到整棵树重新满足 B+ 树的性质。
当当前发生下溢的节点不是叶子节点,而是内部节点 时,其处理策略与叶子节点类似:优先借(borrow),借不到则合并(merge)。
具体而言,默认优先向左兄弟节点借;若当前节点是父节点的最左子节点(即不存在左兄弟),则改为向右兄弟借。
需要注意的是,由于内部节点不仅包含 key,还维护子树指针(child 数组),其借用逻辑相较叶子节点更加复杂,因此需要额外维护子树结构的一致性。
- 向左兄弟借(internal_borrow_from_left)
该函数接收父节点指针以及当前节点在 child 数组中的下标。其核心步骤如下:
- 获取左兄弟节点与当前节点;
- 当前节点的 key 数组整体右移一位,为插入腾出空间;
- 将父节点中分隔左右节点的 key 下拉到当前节点的 key 数组头部;
- 同时,child 数组也需要整体右移一位;
- 将左兄弟节点的最右子树(对应其最大 key 的右子树)移动到当前节点的最左侧,作为新插入 key 的左子树;
- 将左兄弟节点的最大 key 上移到父节点,覆盖原分隔 key;
- 左兄弟节点
count--,逻辑上删除其最大 key。
这一过程本质上是:父节点 key 下移 + 左兄弟 key 上移 + 子树重分配。
cpp
void internal_borrow_from_left(Node* parent,int index)
{
Node* left = parent->child[index - 1];
Node* cur = parent->child[index];
for(int i = cur->count; i > 0; i--)
{
cur->keys[i] = cur->keys[i - 1];
}
cur->keys[0] = parent->keys[index - 1];
cur->count++;
for(int i = cur->count; i > 0; i--)
{
cur->child[i] = cur->child[i - 1];
}
cur->child[0] = left->child[left->count];
parent->keys[index - 1] = left->keys[left->count - 1];
left->child[left->count] = nullptr;
left->count--;
}
- 向右兄弟借(internal_borrow_from_right)
当左兄弟无法借出元素时,尝试向右兄弟借。其核心逻辑如下:
- 将父节点中的分隔 key 插入到当前节点的 key 数组末尾;
- 将右兄弟节点的最左子树移动到当前节点,作为新插入 key 的右子树;
- 将右兄弟节点的最小 key 上移到父节点,覆盖原分隔 key;
- 右兄弟节点的 key 数组与 child 数组整体左移;
- 更新右兄弟节点的
count。
cpp
void internal_borrow_from_right(Node* parent,int index)
{
Node* right = parent->child[index + 1];
Node* cur = parent->child[index];
cur->keys[cur->count] = parent->keys[index];
cur->child[cur->count + 1] = right->child[0];
cur->count++;
parent->keys[index] = right->keys[0];
for(int i = 0; i < right->count - 1; i++)
{
right->keys[i] = right->keys[i + 1];
}
for(int i = 0; i < right->count; i++)
{
right->child[i] = right->child[i + 1];
}
right->child[right->count] = nullptr;
right->count--;
}
- 合并节点(internal_merge)
当左右兄弟均无法借出元素时,需要执行合并操作。默认策略为:优先与左兄弟合并(若当前节点不是最左子节点)。对应调用internal_merge函数,其接收父节点指针以及待合并的左节点在 child 数组中的下标作为参数
该过程的关键点在于:需要先将父节点中的分隔 key 下拉,再拼接右节点的数据。
具体步骤如下:
- 获取左节点与右节点;
- 将父节点中对应的分隔 key 插入到左节点;
- 将右节点的 key 以及 child 数组中的有效数据追加到左节点末尾;
- 删除父节点中的分隔 key,并同步调整
child数组; - 释放右节点内存。
cpp
void internal_merge(Node* parent,int index)
{
Node* left = parent->child[index];
Node* right = parent->child[index + 1];
left->keys[left->count] = parent->keys[index];
left->count++;
int base = left->count;
for(int i = 0; i < right->count; i++)
{
left->keys[left->count++] = right->keys[i];
}
for(int i = 0; i <= right->count; i++)
{
left->child[base + i] = right->child[i];
}
for(int i = index; i < parent->count - 1; i++)
{
parent->keys[i] = parent->keys[i + 1];
}
for(int i = index + 1; i < parent->count; i++)
{
parent->child[i] = parent->child[i + 1];
}
parent->child[parent->count] = nullptr;
parent->count--;
delete right;
}
- 向上回溯与根节点处理
在完成一次借或合并操作后:
- 若执行的是借操作 ,结构已经恢复平衡,可以直接
break; - 若执行的是合并操作,父节点的 key 数量减少,可能引发新的下溢,因此需要继续向上回溯。
因此,需要将当前节点更新为其父节点,继续循环:
将
cur更新为parent,重复下溢检测与修复过程。
当退出循环后,说明当前节点可能已经是根节点,此时需要对根节点进行特殊处理:
-
根节点是叶子节点,且
count == 0说明整棵树已经为空,直接释放根节点,并将
root与head置为空。 -
根节点是内部节点,且
count == 0说明根节点的两个子节点已经合并,此时树高度降低一层,应将其唯一子节点提升为新的根节点。
查找
随机查找
最后来看查找操作。这里对应的是 find 函数,其核心设计是通过**输出型参数(output parameter)**返回查找结果:如果查找成功,则将对应的 value 赋值给参数 v 并返回 true;如果查找失败,则将 v 置为默认值并返回 false。
函数首先会判断当前 B 树是否为空。若为空树,则无需进行查找操作,直接将 v 设为默认值并返回 false。
在非空的情况下,函数会调用 search 函数执行实际的查找过程。这里 search 返回一个二元组(std::pair<Node*, int>),其中:
Node*表示查找结束时所在的节点;int表示目标 key 在该节点中的位置,第一个大于等于目标 key 的下标(即插入位置或命中位置)。
随后,find 函数会对返回结果进行判定:
如果 pos 在当前节点的有效范围内(即 pos < count),并且 keys[pos] == k,则说明查找命中,此时直接将对应的 value 赋给 v 并返回 true;否则,说明查找失败,将 v 设为默认值并返回 false。
需要注意的是,这种设计方式将"查找是否成功"和"返回结果值"解耦,有助于避免通过返回值传递复杂数据,同时也符合 C++ 中常见的接口设计习惯。
cpp
bool find(const key& k,value& v)
{
if(root==nullptr)
{
v=value();
return false;
}
std::stack<Node*> path;
std::pair<Node*,int> res=search(k,path);
Node* cur=res.first;
int pos=res.second;
if(pos<cur->count && cur->keys[pos]==k)
{
v=cur->values[pos];
return true;
}
v=value();
return false;
}
范围查找
在 B+ 树中,由于所有叶子节点通过链表结构进行顺序连接 ,因此天然支持高效的范围查找(Range Query)。基于这一结构特性,我们可以将范围查找拆解为两步:先进行一次随机查找定位起点,再进行顺序遍历完成区间扫描。这种"随机访问 + 顺序访问"的组合,是 B+ 树在数据库与存储系统中广泛应用的重要原因之一。
在具体实现上,范围查找逻辑被封装在 range_query 函数中。该函数接收区间下限 low、上限 high,以及一个用于存储结果的输出参数 result。其核心流程如下:
首先,函数会判断当前 B+ 树是否为空;若为空则直接返回。若不为空,则调用 search 函数对下限 low 进行定位。该函数返回一个二元组,其中包含:
- 目标叶子节点指针
- 该节点中第一个大于等于
low的 key 的位置(即 lower_bound 位置)
在获得起始位置后,从该位置开始在叶子节点中顺序遍历。如果当前节点遍历完成(即达到 count),则通过叶子节点的 next 指针移动到下一个叶子节点,并继续从头(pos = 0)遍历,直到:
- 遇到 key 超过上限
high - 或者遍历到链表末尾(
cur == nullptr)
需要特别说明一个容易引发疑问的边界情况:当下限 low 大于整棵 B+ 树中的最大 key 时是否会出错?
这里的实现是安全的,原因在于:
- 当前节点的
keys数组大小被设计为m(而非传统的m - 1) - 当
low超过最大 key 时,search会定位到最右侧叶子节点,并返回位置pos == count - 此时不会进入
for循环(因为pos == count),而是直接访问next指针 - 由于最右侧叶子节点的
next == nullptr,循环终止,函数安全返回
因此,即使未对 search 的返回结果做额外校验,也不会发生越界访问或逻辑错误。
下面是对应的实现代码:
cpp
void range_query(const key& low,const key& high,std::vector<std::pair<key,value>>& result)
{
if(root==nullptr)
{
return;
}
std::stack<Node*> path;
std::pair<Node*,int> res=search(low,path);
Node* cur=res.first;
int pos=res.second;
while(cur!=nullptr)
{
for(int i=pos;i<cur->count;i++)
{
if(cur->keys[i]>=low && cur->keys[i]<=high)
{
result.push_back(std::make_pair(cur->keys[i],cur->values[i]));
}
else
{
return;
}
}
cur=cur->next;
pos=0;
}
return;
}
从复杂度角度来看,该实现具有良好的性能特征:
- 初始定位:( O(log n) )
- 区间扫描:( O(k) ),其中 ( k ) 为结果集大小
整体复杂度为 ( O(log n + k) ),这也是 B+ 树相比于平衡二叉搜索树(如红黑树)在范围查询场景下的显著优势。
源码
BPlusTree.hpp:
cpp
#pragma once
#include<iostream>
#include<stack>
#include<vector>
template<typename key, typename value,size_t M>
class BPlusTreeNode
{
public:
BPlusTreeNode()
:count(0)
,isleaf(true)
,next(nullptr)
{
for(int i=0;i<M+1;i++)
{
child[i]=nullptr;
}
for(int i=0;i<M;i++)
{
keys[i]=key();
values[i]=value();
}
}
bool isleaf;
key keys[M];
value values[M];
BPlusTreeNode* child[M+1];
BPlusTreeNode* next;
int count;
};
template<typename key, typename value,size_t M>
class BPlusTree
{
using Node=BPlusTreeNode<key,value,M>;
public:
BPlusTree()
:root(nullptr)
,head(nullptr)
{
}
~BPlusTree()
{
destroy_node(root);
}
/**
* 在树中查找指定的键,并获取对应的值
*
* k 要查找的键
* v 用于存储查找到的值的引用参数。如果未找到,会被重置为默认值。
* return true 如果键存在于树中
* return false 如果键不存在于树中
*/
bool find(const key& k, value& v)
{
// 1. 边界检查:如果树为空(根节点为空)
if(root == nullptr)
{
// 将输出参数 v 设为 value 类型的默认值
v = value();
// 返回 false 表示未找到
return false;
}
// 2. 创建一个栈,用于记录查找路径(可能用于后续的插入/删除操作)
// 这在某些实现中用于从叶子节点回溯到根节点
std::stack<Node*> path;
// 3. 调用 search 辅助函数查找键 k
// search 函数通常会:
// - 返回一个 pair,包含最后访问的节点指针 和 键在该节点中的位置索引 pos
// - 将查找路径压入 path 栈中
std::pair<Node*, int> res = search(k, path);
// 获取结果:目标节点 和 键的位置
Node* cur = res.first;
int pos = res.second;
// 4. 检查是否找到了键
// 条件:位置索引有效 (pos < 当前节点的键数量) 且 该位置的键与目标键 k 相等
if(pos < cur->count && cur->keys[pos] == k)
{
// 找到了!将对应的值赋给输出参数 v
v = cur->values[pos];
// 返回 true 表示查找成功
return true;
}
// 5. 未找到键的情况
// 将输出参数 v 设为 value 类型的默认值
v = value();
// 返回 false 表示未找到
return false;
}
/**
* 向 B+ 树中插入一个键值对
*
* k 要插入的键
* v 要插入的值
* return true 插入成功
* return false 插入失败(通常是因为键已存在)
*/
bool insert(const key& k, const value& v)
{
// 1. 处理空树的情况
if(root == nullptr)
{
// 创建新的根节点
root = new Node();
// 插入键和值
root->keys[0] = k;
root->values[0] = v;
// 更新节点键的数量
root->count = 1;
// 如果是 B+ 树,通常需要维护叶子节点的链表头指针
head = root;
return true;
}
// 2. 查找插入位置
// 创建一个栈来记录从根节点到插入节点的路径
// 这在后续节点分裂向上回溯时非常重要
std::stack<Node*> path;
// 调用 search 查找 k 应该在的位置,并填充 path 栈
// res.first 是目标节点,res.second 是插入位置索引
std::pair<Node*, int> res = search(k, path);
Node* cur = res.first;
int pos = res.second;
// 3. 检查键是否已存在
// 如果在当前位置找到了相同的键,说明数据重复,插入失败
if(pos < cur->count && cur->keys[pos] == k)
{
return false;
}
// 4. 将键值对插入到叶子节点中
// 假设 insert_into_leaf 函数会将 k 和 v 插入到 cur 节点的 pos 位置
// 并保持节点的有序性
insert_into_leaf(cur, pos, k, v);
// 5. 处理节点溢出(分裂)的循环
// 只要当前节点满了(键的数量达到阶数 M),就需要进行分裂
while(cur->count == M)
{
Node* parent = nullptr;
// 创建一个新的兄弟节点,用于存放分裂出来的一半数据
Node* brother = new Node();
// 计算分裂点:中间位置索引
// 对于 B+ 树,通常将中间元素提升到父节点
int mid = (M + 1) / 2 - 1;
key midkey;
// --- 情况 A: 当前节点是叶子节点 ---
if(cur->isleaf)
{
int j = 0;
// 将当前节点后半部分的键值对复制到兄弟节点中
// 注意:B+ 树叶子节点分裂通常复制 midkey 到新节点
for(int i = mid; i < M; i++)
{
brother->keys[j] = cur->keys[i];
brother->values[j++] = cur->values[i];
}
// 更新兄弟节点的键数量
brother->count = j;
// 维护 B+ 树叶子节点的链表指针
brother->next = cur->next;
cur->next = brother;
// 截断当前节点的键数量
cur->count = mid;
// 提升的键是兄弟节点的第一个键(B+ 树特性)
midkey = brother->keys[0];
}
// --- 情况 B: 当前节点是内部节点(索引节点)---
else
{
// 提升的键是当前节点的中间键
midkey = cur->keys[mid];
int j = 0;
// 将当前节点后半部分的键(不包含 midkey)复制到兄弟节点
for(int i = mid + 1; i < M; i++)
{
brother->keys[j++] = cur->keys[i];
}
// 更新兄弟节点的键数量
brother->count = j;
// 标记兄弟节点为非叶子节点
brother->isleaf = false;
// 截断当前节点的键数量
cur->count = mid;
// 将后半部分的孩子指针也移动到兄弟节点
j = 0;
for(int i = mid + 1; i <= M; i++)
{
brother->child[j++] = cur->child[i];
}
}
// 6. 处理分裂后的向上提升
// --- 情况 A: 路径栈为空,说明当前节点是根节点 ---
if(path.empty())
{
// 创建新的根节点
root = new Node();
root->isleaf = false;
// 新根节点包含提升上来的键
root->keys[0] = midkey;
// 左孩子是原节点,右孩子是新分裂的兄弟节点
root->child[0] = cur;
root->child[1] = brother;
root->count = 1;
// 树的高度增加,分裂循环结束
break;
}
// --- 情况 B: 路径栈不为空,需要将 midkey 插入到父节点中 ---
else
{
// 获取父节点
parent = path.top();
path.pop();
// 在父节点中找到 midkey 应该插入的位置
// upper_search 通常返回第一个大于 midkey 的键的位置
int pos = upper_search(parent->keys, midkey, parent->count);
// 将 midkey 和 brother 插入到父节点中
insert_into_internal(parent, pos, midkey, brother);
// 更新 cur 指向父节点,以便在下一轮循环中检查父节点是否溢出
cur = parent;
}
}
return true;
}
/**
* 从 B 树中删除指定的键
*
* k 要删除的键
*/
void erase(const key& k)
{
// 1. 边界检查:如果树为空,直接返回
if(root == nullptr)
{
return;
}
// 2. 查找目标节点
// 使用栈记录从根节点到目标节点的路径,这在后续处理下溢时用于回溯父节点
std::stack<Node*> path;
// 调用 search 查找键 k,返回目标节点和键在节点中的索引
std::pair<Node*, int> res = search(k, path);
Node* cur = res.first; // 目标节点指针
int pos = res.second; // 键在节点 keys 数组中的索引
// 3. 检查键是否存在
// 如果 pos 越界或者该位置的键不等于 k,说明键不存在于树中,直接返回
if(pos >= cur->count || cur->keys[pos] != k)
{
return;
}
// 4. 执行删除操作
// 将键 k 从当前节点 cur 的 pos 位置移除
// remove_from_node 内部通常需要处理数组的移位操作
remove_from_node(cur, pos);
// 5. 计算节点最小键数限制
// 对于 B 树,非根节点至少需要 (M-1)/2 个键(向上取整)
// 这里的 minKeys 计算方式对应了 B 树的定义(M 阶)
int minKeys = (M + 1) / 2 - 1;
// 6. 处理下溢
// 循环检查当前节点是否违反最小键数限制,并且当前节点不是根节点
// (根节点允许只有 1 个键,甚至 0 个键)
while(cur != root && cur->count < minKeys)
{
// 获取当前节点的父节点
Node* parent = path.top();
path.pop();
// 在父节点中找到当前节点 cur 的索引 index
// 即 parent->child[index] == cur
int index = 0;
while(index <= parent->count && parent->child[index] != cur)
{
index++;
}
// --- 情况 A: 当前节点是叶子节点 ---
if(cur->isleaf)
{
// 尝试向左兄弟借一个键
// 条件:左兄弟存在 (index > 0) 且 左兄弟键数充足 (> minKeys)
if(index > 0 && parent->child[index - 1]->count > minKeys)
{
// 执行向左兄弟借键的操作
leaf_borrow_from_left(parent, index);
// 借键成功,节点不再下溢,退出循环
break;
}
// 尝试向右兄弟借一个键
// 条件:右兄弟存在 (index < parent->count) 且 右兄弟键数充足
else if(index < parent->count && parent->child[index + 1]->count > minKeys)
{
// 执行向右兄弟借键的操作
leaf_borrow_from_right(parent, index);
// 借键成功,节点不再下溢,退出循环
break;
}
// --- 情况 B: 兄弟节点都刚好达到最小键数,无法借键,必须合并 ---
else
{
// 如果存在左兄弟,将当前节点与左兄弟合并
if(index > 0)
{
// 将当前节点 (index) 合并入左兄弟 (index - 1)
leaf_merge(parent, index - 1);
}
// 否则,将当前节点与右兄弟合并
else
{
// 将右兄弟 (index + 1) 合并入当前节点 (index)
leaf_merge(parent, index);
}
}
}
// --- 情况 C: 当前节点是内部节点 ---
else
{
// 尝试向左兄弟借键
if(index > 0 && parent->child[index - 1]->count > minKeys)
{
internal_borrow_from_left(parent, index);
break;
}
// 尝试向右兄弟借键
else if(index < parent->count && parent->child[index + 1]->count > minKeys)
{
internal_borrow_from_right(parent, index);
break;
}
// 兄弟节点都刚好达到最小键数,必须合并
else
{
if(index > 0)
{
internal_merge(parent, index - 1);
}
else
{
internal_merge(parent, index);
}
}
}
// 7. 回溯处理
// 将当前指针指向父节点,因为合并操作会导致父节点键数减少
// 下一轮循环将检查父节点是否发生下溢
cur = parent;
}
// 8. 处理根节点的情况
// 如果根节点是内部节点且没有键了(说明它的两个子节点合并了),需要降低树高
if(root->count == 0 && !root->isleaf)
{
Node* old = root;
// 唯一的子节点成为新的根节点
root = root->child[0];
// 释放旧的根节点
delete old;
}
// 如果根节点是叶子节点且没有键了,说明树空了
if(root != nullptr && root->count == 0 && root->isleaf)
{
delete root;
root = nullptr;
// 如果有链表头指针,也要置空
head = nullptr;
}
}
/**
* 查找键值在 [low, high] 范围内的所有键值对
*
* 该函数利用 B+ 树叶子节点构成的有序链表,高效地遍历指定范围内的数据。
*
* low 范围查询的下界(包含)
* high 范围查询的上界(包含)
* result 用于存储查询结果的 vector 引用。结果将按 key 的顺序排列。
*/
void range_query(const key& low, const key& high, std::vector<std::pair<key, value>>& result)
{
// 1. 边界检查:如果树为空,直接返回
if(root == nullptr)
{
return;
}
// 2. 定位起始点
// 使用栈记录路径(虽然在此函数中未使用,但为了保持 search 接口一致性)
std::stack<Node*> path;
// 调用 search 函数查找下界 low
// search 返回 low 所在(或应该所在)的叶子节点 cur,以及在该节点中的索引 pos
std::pair<Node*, int> res = search(low, path);
Node* cur = res.first; // 起始叶子节点
int pos = res.second; // 起始索引位置
// 3. 遍历叶子节点链表
// 从起始节点开始,沿着 next 指针向后遍历所有叶子节点
while(cur != nullptr)
{
// 遍历当前节点中的所有键,从 pos 开始
for(int i = pos; i < cur->count; i++)
{
// 检查当前键是否在 [low, high] 范围内
if(cur->keys[i] >= low && cur->keys[i] <= high)
{
// 在范围内,将键值对加入结果集
result.push_back(std::make_pair(cur->keys[i], cur->values[i]));
}
else
{
// 如果当前键已经超过了 high,说明后续所有键都会更大(因为是有序的)
// 查询结束,直接返回
return;
}
}
// 移动到下一个叶子节点
cur = cur->next;
// 重置索引起始位置为 0,因为从下一个节点的第一个键开始检查
pos = 0;
}
// 遍历完所有叶子节点后返回
return;
}
void print()
{
print_node(root);
}
BPlusTree(const BPlusTree&) = delete;
BPlusTree& operator=(const BPlusTree&) = delete;
private:
/**
* 在树中查找键 k,并返回目标叶子节点及键在其中的位置
*
* 该函数从根节点开始向下查找,直到到达叶子节点。
* 在查找过程中,它将经过的路径节点压入 path 栈中,这对于后续的插入或删除操作
*(可能需要回溯并分裂或合并节点)至关重要。
*
* k 要查找的键
* path 用于记录查找路径的栈(引用传递)。栈顶元素将是目标叶子节点的父节点。
* return std::pair<Node*, int> 返回一个 pair,包含:
* - first: 目标叶子节点的指针
* - second: 键 k 在该叶子节点中的位置索引(如果存在)或应该插入的位置索引
*/
std::pair<Node*, int> search(const key& k, std::stack<Node*>& path)
{
// 1. 初始化
// 从根节点开始
Node* cur = root;
int pos = 0;
// 2. 遍历内部节点(索引节点)
// 只要当前节点不是叶子节点,就继续向下查找
while(!cur->isleaf)
{
// 在当前节点的键数组中查找 k 的位置
// upper_search 返回第一个严格大于 k 的键的索引
// 这意味着 k 应该在 child[pos] 这个子树中
pos = upper_search(cur->keys, k, cur->count);
// 将当前节点压入路径栈
// 这样当我们在叶子节点进行插入/删除导致节点分裂/合并时,
// 可以通过 path 栈找到父节点
path.push(cur);
// 移动到下一层的子节点
cur = cur->child[pos];
}
// 3. 到达叶子节点
// 在叶子节点的键数组中查找 k 的确切位置
// binary_search 返回第一个大于或等于 k 的键的索引
// 如果 k 存在,pos 就是它的位置;如果不存在,pos 就是它应该插入的位置
pos = binary_search(cur->keys, k, cur->count);
// 4. 返回结果
// 返回目标叶子节点指针和位置索引
return std::make_pair(cur, pos);
}
/**
* 销毁节点及其子节点的函数
* node 要销毁的节点指针
*/
void destroy_node(Node* node)
{
// 如果节点为空,直接返回
if(node==nullptr)
{
return;
}
// 如果节点不是叶子节点,则递归销毁其所有子节点
if(!node->isleaf)
{
// 遍历当前节点的所有子节点
for(int i=0;i<=node->count;i++)
{
// 递归销毁每个子节点
destroy_node(node->child[i]);
}
}
// 删除当前节点
delete node;
}
/**
* 从左兄弟节点借一个关键字到当前叶子节点
* parent 父节点指针
* index 当前节点在父节点中的索引位置
*/
void leaf_borrow_from_left(Node* parent,int index)
{
// 获取左兄弟节点
Node* left=parent->child[index-1];
// 获取当前节点
Node* cur=parent->child[index];
// 将左兄弟节点的最后一个关键字插入到当前节点的第一个位置
insert_into_leaf(cur,0,left->keys[left->count-1],left->values[left->count-1]);
// 减少左兄弟节点的关键字数量
left->count--;
// 更新父节点中对应的关键字为当前节点的第一个关键字
parent->keys[index-1]=cur->keys[0];
}
/**
* 从右兄弟节点借一个关键字到当前节点
* parent 父节点指针
* index 当前节点在父节点中的索引位置
*/
void leaf_borrow_from_right(Node* parent,int index)
{
// 获取右兄弟节点
Node* right=parent->child[index+1];
// 获取当前节点
Node* cur=parent->child[index];
// 将右兄弟节点的第一个关键字复制到当前节点的末尾
cur->keys[cur->count]=right->keys[0];
cur->values[cur->count]=right->values[0];
cur->count++; // 当前节点关键字数量加1
// 将右兄弟节点的关键字前移一位,覆盖掉被借走的关键字
for(int i=0;i<right->count-1;i++)
{
right->keys[i]=right->keys[i+1];
right->values[i]=right->values[i+1];
}
// 更新父节点中对应的关键字为右兄弟节点的新的第一个关键字
parent->keys[index]=right->keys[0];
// 右兄弟节点关键字数量减1
right->count--;
}
/**
* 合并两个叶子节点的函数
* parent 父节点指针
* index 左子节点在父节点中的索引位置
*/
void leaf_merge(Node* parent,int index)
{
// 获取要合并的左右子节点
Node* left=parent->child[index]; // 左子节点
Node* right=parent->child[index+1]; // 右子节点
// 将右子节点的所有键值对合并到左子节点中
for(int i=0;i<right->count;i++)
{
left->keys[left->count]=right->keys[i]; // 复制键
left->values[left->count]=right->values[i]; // 复制值
left->count++; // 增加左子节点的计数
}
// 更新左子节点的next指针,指向右子节点的下一个节点
left->next=right->next;
// 将父节点中右子节点相关的键前移一位,填补合并后的空缺
for(int i=index;i<parent->count-1;i++)
{
parent->keys[i]=parent->keys[i+1]; // 键前移
}
// 将父节点中右子节点之后的子节点指针前移一位
for(int i=index+1;i<parent->count;i++)
{
parent->child[i]=parent->child[i+1]; // 子节点指针前移
}
// 清理父节点最后的子节点指针
parent->child[parent->count]=nullptr;
// 减少父节点的计数
parent->count--;
// 释放右子节点的内存
delete right;
}
/**
* 合并两个子节点的函数
* parent 父节点指针
* index 左子节点在父节点子节点数组中的索引位置
*/
void internal_merge(Node* parent,int index)
{
// 获取需要合并的左子节点和右子节点
Node* left=parent->child[index];
Node* right=parent->child[index+1];
// 将父节点中对应的关键字下移到左子节点中
left->keys[left->count]=parent->keys[index];
left->count++;
// 记录左子节点中已有元素的数量,作为合并时右子节点元素的起始位置
int base=left->count;
// 将右子节点的所有关键字复制到左子节点中
for(int i=0;i<right->count;i++)
{
left->keys[left->count++]=right->keys[i];
}
// 将右子节点的所有子节点指针复制到左子节点中
for(int i=0;i<=right->count;i++)
{
left->child[base+i]=right->child[i];
}
// 将父节点中index位置之后的关键字前移一位,填补合并后留下的空位
for(int i=index;i<parent->count-1;i++)
{
parent->keys[i]=parent->keys[i+1];
}
// 将父节点中index+1位置之后的子节点指针前移一位,填补合并后留下的空位
for(int i=index+1;i<parent->count;i++)
{
parent->child[i]=parent->child[i+1];
}
// 将父节点的最后一个子节点指针置空
parent->child[parent->count]=nullptr;
// 减少父节点中的关键字数量
parent->count--;
// 释放右子节点的内存
delete right;
}
/**
* 从左兄弟节点借入一个关键字的内部操作函数
* parent 当前节点的父节点
* index 当前节点在父节点中的索引位置
*/
void internal_borrow_from_left(Node* parent,int index)
{
// 获取左兄弟节点
Node* left=parent->child[index-1];
// 获取当前节点
Node* cur=parent->child[index];
// 将当前节点的所有关键字向后移动一位,为新借入的关键字腾出空间
for(int i=cur->count;i>0;i--)
{
cur->keys[i]=cur->keys[i-1];
}
// 将父节点中分隔左兄弟和当前节点的关键字下移到当前节点的第一个位置
cur->keys[0]=parent->keys[index-1];
cur->count++; // 增加当前节点的关键字计数
// 将当前节子的所有子节点指针向后移动一位
for(int i=cur->count;i>0;i--)
{
cur->child[i]=cur->child[i-1];
}
// 将左兄弟节点的最后一个子节点指针移动到当前节点的第一个子节点位置
cur->child[0]=left->child[left->count];
// 将左兄弟节点的最后一个关键字上移到父节点中分隔位置
parent->keys[index-1]=left->keys[left->count-1];
left->child[left->count]=nullptr;
left->count--;
}
/**
* 从右兄弟节点借关键字操作的内部实现函数
* parent 当前节点的父节点
* index 当前节点在父节点中的索引位置
*/
void internal_borrow_from_right(Node* parent,int index)
{
// 获取右兄弟节点
Node* right=parent->child[index+1];
// 获取当前节点
Node* cur=parent->child[index];
// 将父节点中对应的关键字下移到当前节点的末尾
cur->keys[cur->count]=parent->keys[index];
// 将右兄弟节点的第一个子节点链接到当前节点的最后一个子节点位置
cur->child[cur->count+1]=right->child[0];
// 增加当前节点的关键字计数
cur->count++;
// 将右兄弟节点的第一个关键字上移到父节点中
parent->keys[index]=right->keys[0];
// 将右兄弟节点的关键字前移一位(覆盖已移动的第一个关键字)
for(int i=0;i<right->count-1;i++)
{
right->keys[i]=right->keys[i+1];
}
for(int i=0;i<right->count;i++)
{
right->child[i]=right->child[i+1];
}
right->child[right->count]=nullptr;
right->count--;
}
/**
* 从B树节点中删除指定位置的键值对
* node 指向要操作的B树节点的指针
* pos 要删除的键值对在节点中的位置索引
*/
void remove_from_node(Node* node,int pos)
{
// 从指定位置开始,将后面的键值对前移一位,覆盖当前位置
for(int i=pos;i<node->count-1;i++)
{
// 将后一个键值向前移动一位
node->keys[i]=node->keys[i+1];
// 将后一个值向前移动一位
node->values[i]=node->values[i+1];
}
// 减少节点中键值对的数量
node->count--;
}
void print_node(Node* node)
{
if(node==nullptr)
{
return;
}
std::cout<<"[";
for(int i=0;i<node->count;i++)
{
std::cout<<node->keys[i]<<" ";
}
std::cout<<"]"<<std::endl;
if(!node->isleaf)
{
for(int i=0;i<=node->count;i++)
{
print_node(node->child[i]);
}
}
}
/**
* 在有序数组中查找第一个大于或等于给定键的索引(下界查找 / Lower Bound)
*
* 该函数使用二分查找算法,在 keys 数组中寻找第一个大于或等于 k 的元素的位置。
* 如果数组中的所有元素都小于 k,则返回 count(即数组的长度,表示越界)。
*
* 注意:这与 `upper_search` 不同,`upper_search` 查找的是第一个"严格大于" k 的元素。
*
* keys 指向有序键数组的指针(假设数组已按升序排列)
* k 需要查找的目标键
* count 数组中有效元素的个数
* return int 返回第一个大于或等于 k 的元素的索引。如果不存在,则返回 count。
*/
int binary_search(const key* keys, const key& k, int count)
{
// 初始化查找范围:左闭右开区间 [left, right)
int left = 0;
int right = count;
// 二分查找主循环
// 当 left == right 时,区间为空,查找结束
while(left < right)
{
// 计算中间位置索引,使用 left + (right - left) / 2 防止整数溢出
int mid = left + (right - left) / 2;
// 比较中间元素与目标键 k
if(keys[mid] >= k)
{
// 如果中间元素大于或等于 k,说明目标位置可能在 mid 或 mid 的左侧
// 将右边界缩小到 mid(不包含 mid)
right = mid;
}
else
{
// 如果中间元素小于 k,说明目标位置一定在 mid 的右侧
// 将左边界移动到 mid + 1(排除 mid)
left = mid + 1;
}
}
// 循环结束时,left == right,指向第一个大于或等于 k 的元素
// 如果所有元素都小于 k,则 left == count
return left;
}
/**
* 在有序数组中查找第一个大于给定键的索引(上界查找 / Upper Bound)
*
* 该函数使用二分查找算法,在 keys 数组中寻找第一个严格大于 k 的元素的位置。
* 如果数组中的所有元素都小于或等于 k,则返回 count(即数组的长度,表示越界)。
*
* keys 指向有序键数组的指针(假设数组已按升序排列)
* k 需要比较的目标键
* count 数组中有效元素的个数
* return int 返回第一个大于 k 的元素的索引。如果不存在,则返回 count。
*/
int upper_search(const key* keys, const key& k, int count)
{
// 初始化查找范围:左闭右开区间 [left, right)
int left = 0;
int right = count;
// 二分查找主循环
// 当 left == right 时,区间为空,查找结束
while(left < right)
{
// 计算中间位置索引,防止溢出
int mid = left + (right - left) / 2;
// 比较中间元素与目标键 k
if(keys[mid] > k)
{
// 如果中间元素大于 k,说明上界可能在 mid 或 mid 的左侧
// 将右边界缩小到 mid(不包含 mid)
right = mid;
}
else
{
// 如果中间元素小于或等于 k,说明上界一定在 mid 的右侧
// 将左边界移动到 mid + 1(排除 mid)
left = mid + 1;
}
}
// 循环结束时,left == right,指向第一个大于 k 的元素
return left;
}
/**
* 向叶子节点中插入一个键值对
*
* 该函数假设叶子节点 leaf 未满(即 leaf->count < M),并且 pos 是由 search
* 或 binary_search 确定的正确插入位置。它会将 pos 及之后的元素向后移动一位,
* 为新元素腾出空间。
*
* leaf 目标叶子节点的指针
* pos 插入位置的索引(0 到 leaf->count)
* k 要插入的键
* v 要插入的值
*/
void insert_into_leaf(Node* leaf, int pos, const key& k, const value& v)
{
// 1. 元素后移
// 从当前节点的最后一个有效元素开始,向前遍历直到 pos 位置
// 将 keys[i-1] 和 values[i-1] 移动到 keys[i] 和 values[i]
// 这相当于将 pos 位置及之后的元素都向后移动了一位
for(int i = leaf->count; i > pos; i--)
{
leaf->keys[i] = leaf->keys[i - 1];
leaf->values[i] = leaf->values[i - 1];
}
// 2. 插入新元素
// 将新的键值对放入腾出的 pos 位置
leaf->keys[pos] = k;
leaf->values[pos] = v;
// 3. 更新节点计数
// 节点中的元素数量加 1
leaf->count++;
}
/**
* 向内部节点(非叶子节点)中插入一个键和对应的右子节点指针
*
* 该函数用于在 B+ 树节点分裂时,将提升上来的键 midkey 和新分裂出来的兄弟节点 brother
* 插入到父节点中。
*
* node 目标内部节点的指针
* pos 插入位置的索引(0 到 node->count)
* k 要插入的键(通常是子节点分裂产生的中间键)
* rightchild 要插入的右子节点指针(通常是新分裂出来的兄弟节点)
*/
void insert_into_internal(Node* node, int pos, const key& k, Node* rightchild)
{
// 1. 移动键
// 从当前节点的最后一个键开始,向前遍历直到 pos 位置
// 将 keys[i-1] 向后移动到 keys[i]
// 这相当于将 pos 位置及之后的键都向后移动了一位,腾出空间
for(int i = node->count; i > pos; i--)
{
node->keys[i] = node->keys[i - 1];
}
// 2. 插入新键
// 将新的键 k 放入腾出的 pos 位置
node->keys[pos] = k;
// 3. 处理子节点指针
// 如果提供了右子节点指针(通常在分裂时都需要)
if(rightchild != nullptr)
{
// 移动子节点指针
// 注意:子节点指针的数量比键的数量多 1
// 所以循环从 node->count + 1 开始,直到 pos + 1
// 将 child[i-1] 向后移动到 child[i]
for(int i = node->count + 1; i > pos + 1; i--)
{
node->child[i] = node->child[i - 1];
}
// 插入新的右子节点指针
// 新键 k 的右孩子是 rightchild
node->child[pos + 1] = rightchild;
}
// 4. 更新节点计数
// 节点中的键数量加 1
node->count++;
}
Node* root;
Node* head;
};
Test.cpp:
cpp
#include <iostream>
#include <string>
#include <vector>
#include <cassert>
#include "BPlusTree.hpp"
void test_insert_and_find() {
std::cout << "--- Test 1: Insert & Find ---" << std::endl;
// Set M=4, max number of keys is 4, which easily triggers splitting
BPlusTree<int, std::string, 4> tree;
// 1. Insert data sequentially
for (int i = 1; i <= 20; ++i) {
bool res = tree.insert(i, "val_" + std::to_string(i));
assert(res == true);
}
// 2. Insert duplicate key
bool res_dup = tree.insert(10, "val_dup");
assert(res_dup == false); // Should refuse insertion
// 3. Verify all inserted data can be found
std::string val;
for (int i = 1; i <= 20; ++i) {
bool found = tree.find(i, val);
assert(found == true);
assert(val == "val_" + std::to_string(i));
}
// 4. Search for non-existent keys
assert(tree.find(21, val) == false);
assert(tree.find(0, val) == false);
std::cout << "Insert and find test passed! Current tree structure:" << std::endl;
tree.print();
std::cout << std::endl;
}
void test_range_query() {
std::cout << "--- Test 2: Range Query ---" << std::endl;
BPlusTree<int, std::string, 4> tree;
for (int i = 1; i <= 20; ++i) {
tree.insert(i, "val_" + std::to_string(i));
}
std::vector<std::pair<int, std::string>> result;
// Query interval [8, 15]
tree.range_query(8, 15, result);
assert(result.size() == 8);
for (size_t i = 0; i < result.size(); ++i) {
assert(result[i].first == 8 + i);
assert(result[i].second == "val_" + std::to_string(8 + i));
}
// Query interval exceeding the tree's maximum value [18, 25]
result.clear();
tree.range_query(18, 25, result);
assert(result.size() == 3); // Should only return 18, 19, 20
std::cout << "Range query test passed!" << std::endl << std::endl;
}
void test_erase() {
std::cout << "--- Test 3: Erase ---" << std::endl;
BPlusTree<int, std::string, 4> tree;
for (int i = 1; i <= 20; ++i) {
tree.insert(i, "val_" + std::to_string(i));
}
std::string val;
// 1. Delete standard elements in leaf nodes (does not trigger merging)
tree.erase(20);
assert(tree.find(20, val) == false);
// 2. Delete elements to trigger borrowing from sibling nodes or merging
tree.erase(5);
tree.erase(6);
tree.erase(7);
assert(tree.find(5, val) == false);
assert(tree.find(7, val) == false);
// 3. Delete keys used as separators in internal nodes
tree.erase(13);
assert(tree.find(13, val) == false);
// 4. Verify remaining elements are intact
assert(tree.find(8, val) == true);
assert(tree.find(14, val) == true);
assert(tree.find(1, val) == true);
std::cout << "Erase test passed! Tree structure after partial deletion:" << std::endl;
tree.print();
std::cout << std::endl;
}
void test_robustness() {
std::cout << "--- Test 4: Robustness & Large-scale Random Testing ---" << std::endl;
BPlusTree<int, int, 5> tree; // M=5
// Reverse order insertion test
for (int i = 1000; i >= 1; --i) {
tree.insert(i, i * 10);
}
int val;
assert(tree.find(500, val) == true && val == 5000);
// Large-scale deletion test
for (int i = 1; i <= 900; ++i) {
tree.erase(i);
}
assert(tree.find(100, val) == false);
assert(tree.find(950, val) == true);
std::cout << "Robustness test passed!" << std::endl;
}
int main() {
test_insert_and_find();
test_range_query();
test_erase();
test_robustness();
std::cout << "===============================" << std::endl;
std::cout << "All B+ tree test cases passed successfully!" << std::endl;
return 0;
}
运行截图:

结语
那么这就是本篇文章的全部内容,带你全面认识以及掌握B+树,并且实现了B+树,建议读者下来也可以自己尝试实现,加深对于B树的理解,我会持续更新,希望你能够多多关注,如果本文有帮助到你的话,还请三连加关注,你的支持就是我创作的最大动力!感谢各位大佬对我的支持!
