【C++高阶系列】告别内查找局限:基于磁盘 I/O 视角的 B 树深度剖析与 C++ 泛型实现!(附B树实现源码)

🔥 本文专栏:C++高阶

🌸作者主页:努力努力再努力wz

💪 今日博客励志语录心态的强大,不是指那种如磐石般的纹丝不动,而是如流水般的"消化能力"。 哪怕这一刻你觉得自己像个被生活打散的零件,只要你的内核还没丢,你就拥有重新组装自己的权利。不要去追求那种虚假的、永远阳光灿烂的情绪,那是对人性的误解。真正的强者,是允许自己深夜痛哭,但第二天清晨依旧能面无表情地把那碗苦药喝下去,并继续推石上山。


引入

在数据结构的学习中,查找是最核心的功能之一。要实现高效查找,主要取决于两个方面:其一是所选用的数据结构本身,其二是具体采用的查找算法。因此,我们可以从这两个维度进行优化:要么选择更合适的数据结构,要么在既有数据结构的基础上设计更高效的查找策略。

对于常见的数据结构,首先容易想到的是顺序表(即数组)。基于数组进行查找,通常需要对整个数组进行线性遍历以定位目标元素;在有序数组的前提下,也可以采用二分查找以显著提升查询效率。然而,数组除了查找这一核心操作外,还涉及插入与删除操作。由于数组在内存中是连续存储的结构,一旦发生插入或删除,往往需要移动大量元素,代价较高。因此,从整体性能角度来看,数组并非最优选择。

相比之下,链表在插入和删除操作上具有明显优势,其时间复杂度可以达到 O(1)。但由于链表节点在内存中的分布是非连续的,其查找操作无法借助随机访问能力,只能通过遍历整个链表完成,因此查找效率较低。综合来看,链表同样不是查找效率最优的数据结构。

进一步地,我们会接触到二叉搜索树。其核心性质可以概括为"左小右大":左子树中所有节点的值小于根节点,右子树中所有节点的值大于根节点。在这种结构下,查找过程从根节点开始,通过不断比较目标值与当前节点值,决定向左子树还是右子树继续遍历。理想情况下,二叉搜索树的查找、插入和删除操作的时间复杂度均为O(logN)。但在极端情况下(例如数据有序插入),二叉搜索树可能退化为链表,从而使时间复杂度恶化为 O(N)。

为了解决退化问题,在二叉搜索树的基础上引入了平衡机制,从而得到 AVL 树和红黑树。这两种结构通过约束树的高度,尽可能保持树的平衡性,使得查找、插入和删除操作都能够稳定在 O(logN) 的时间复杂度。虽然它们是非常高效的数据结构。然而,它们的适用场景主要局限于内查找

这里引入一个新的概念:内查找。所谓内查找,是指所有数据都能够完全加载到内存中进行查找操作。读者可能会疑惑:为什么 AVL 树和红黑树通常只适用于内查找?

从本质上看,这两种结构仍然是二叉搜索树,其查找过程依赖于从根节点出发逐层向下比较的过程。每一次比较都是 CPU 指令的执行,而 CPU 运算的前提是数据必须 驻留在内存中 。因此,这类数据结构天然依赖于"数据已在内存中"这一前提。不过,这并不是其局限性的根本原因。

进一步分析可知,任何查找操作都必须由 CPU 执行,因此数据最终都需要被加载到内存中。但我们之所以区分"内查找"和"外查找",关键在于数据规模。当数据规模达到 海量级 (例如数 GB 甚至数 TB)时,内存无法容纳全部数据,数据只能存储在外部存储设备(如磁盘)中,这便引出了外查找的概念。

需要强调的是,"外查找"并不是指在磁盘上直接完成查找计算------查找计算仍然由 CPU 在内存中完成。其本质在于:查找的数据主要存储在磁盘中,查找过程中需要频繁进行磁盘与内存之间的数据交换。

假设我们需要在 10GB 的数据中查找某一条记录,如果仍然采用红黑树或 AVL 树,则需要为每一条数据构建对应的节点。这将导致整棵树规模极其庞大,无法完全加载到内存中,整棵红黑树或者AVL树只能存储在磁盘上。此时,查找过程就变成:不断将节点从磁盘加载节点到内存,再由 CPU 执行比较。

然而,磁盘的访问机制与内存存在本质差异。磁盘的最小存储单位是 扇区 (通常为 512 字节),而为了提高 I/O 效率,操作系统通常以"块"为单位进行读取(例如 4KB,即连续多个扇区)。这是基于 局部性原理 :即使只访问少量数据,也会预读相邻区域的数据,因为接下来要访问的数据很可能就在其附近,因此一次性将周围更多数据读入内存会更高效。

问题在于,红黑树属于典型的链式存储结构,而非类似数组那样的连续内存布局。因此,其节点在存储介质上的分布通常是离散的,大概率不会落在同一个磁盘块中,不具备空间局部性。这意味着,每访问一个节点,很可能都需要一次独立的磁盘 I/O 操作。这样一来,一次磁盘 I/O 可能只能将某个红黑树节点加载到内存中,CPU 参与比较后发现当前节点并不是目标值,接着又需要继续加载其左孩子节点或右孩子节点。而在外查找场景中,这里的左右孩子指针也不再是简单的内存地址,而更类似于磁盘中的定位信息比如磁盘地址。若下一次仍然未命中,则还需要继续发生新的磁盘 I/O。

由此可见,在外查找场景中,红黑树的每一次"向下遍历"几乎都对应一次磁盘 I/O 操作。而磁盘 I/O 的代价远高于内存访问:CPU 访问内存通常是纳秒级,而磁盘访问由于涉及机械运动(如磁头寻道和旋转延迟),通常是毫秒级,二者相差约 10^6 倍,因此一次磁盘 I/O 的耗时,往往至少可以完成 10^6 量级的内存访问。

即便红黑树已经通过平衡机制将树高控制在 O(logN) ,但在大规模数据下,这一高度仍然不可忽视。假设这里有 10 万条数据,对应 10 万个红黑树节点,那么树高大致可以按 log₂N 来估计,约为 16 层左右。而红黑树的高度决定了查找路径的长度,也就意味着一次查找可能要经历约 16 次节点访问;如果这些节点分散在磁盘不同位置上,那么就可能对应约 16 次磁盘 I/O。更何况,10 万条数据在实际工业场景下往往还远远算不上大规模数据,因此真实场景中的 I/O 次数和代价还会进一步放大。

因此,在外查找场景中,优化的核心目标不再是单纯降低时间复杂度,而是尽可能减少磁盘 I/O 次数。基于这一目标,传统的二叉搜索树结构(包括 AVL 树和红黑树)不再适用,需要引入一种更加适合磁盘访问特性的多路平衡查找结构------B 树。这也正是后续内容展开的重点。

B树

原理

根据上文分析,我们已经认识到红黑树以及 AVL 树这类数据结构的局限性:它们仅适用于内存查找(内查找)。要理解并掌握 B 树的设计原理,可以从一个关键问题切入------为什么红黑树和 AVL 树无法胜任外查找。通过分析其结构性缺陷,我们可以进一步理解 B 树是如何针对这些问题进行改进,从而适用于外存环境的。

这里以红黑树为例进行说明。根据前文可知,如果强行将红黑树应用于外查找场景,会面临显著性能瓶颈。外查找通常针对的是 GB 甚至 TB 级别的海量数据,这类数据无法整体加载至内存,只能存储于磁盘中。因此,若使用红黑树进行组织,则需要为每一条数据构建对应的节点,并形成一棵完整的红黑树结构。

然而,由于节点数量极其庞大,这棵红黑树不可能整体驻留于内存,只能以磁盘形式存储。在查找过程中,需要不断将相关节点从磁盘加载至内存,由 CPU 执行比较操作,以判断是否继续访问其子节点。此时,每一次节点访问(即一次树的遍历步骤)通常都会触发一次 磁盘I/O 操作,这将严重制约查询性能。

针对红黑树在外查找中的问题,可以从两个方面进行分析:

第一,存储结构导致空间局部性极差。

红黑树本质上是一种基于指针的二叉链式结构,其节点在物理存储上是离散分布的。在磁盘环境中,父子节点大概率不会位于同一个 磁盘块 (Block)中。一次磁盘 I/O 通常会读取一个完整的数据块(例如 4KB),但由于节点分布离散,实际被利用的数据可能只占其中很小一部分,其余预读数据无法被有效利用,导致空间局部性较差。如果后续访问的节点不在当前已加载的磁盘块中,则必须再次触发 I/O 操作。

第二,树高度较大,导致 I/O 次数过多。

红黑树是一种二叉搜索树,每个节点最多只有两个分支。在面对百万甚至更大规模的数据时,尽管红黑树具有对数级高度,但其高度仍然可能达到 20 层左右。由于每向下一层遍历都可能触发一次磁盘 I/O,因此一次查找操作可能需要 20 次左右的磁盘访问。而外查找优化的核心目标,正是尽可能减少磁盘 I/O 次数。因此,从结构上看,红黑树依然是一种"高而瘦"的树形结构,并不适合外存访问场景;理想结构应当是"矮而宽"的。

基于上述问题,我们引入 B 树。可以说,红黑树的劣势,正是 B 树设计时重点优化的方向。B 树是一种多叉搜索树(multi-way search tree),而非二叉树;同时,其节点大小通常被设计为与磁盘块大小对齐(如 4KB 或 16KB),以充分利用一次磁盘 I/O 的数据读取能力。

结合"多叉结构"与"节点对齐磁盘块"这两个关键特性,可以得到如下结论:B 树的每个节点可以存储多个关键字(Key)以及多个子节点指针,这正是其适用于外查找的核心原因之一。

为了更直观地理解这一点,可以借助"集合划分"的类比进行说明:

对于红黑树或 AVL 树,其查找过程类似于对数据集合进行"二分划分":每次将当前集合划分为两部分,排除其中一半,然后在剩余部分中继续重复该过程,直到定位目标数据。

而 B 树的查找方式则不同:一次操作可以将数据集合划分为 n 个区间(n 远大于 2)。例如,一个节点中包含 1000 个 Key,则可以将数据划分为 1001 个区间,一次判断即可排除绝大多数无关区间,仅保留一个子区间继续查找。随后在该子区间中重复同样的过程。

这意味着,B 树在每一层都能大幅缩小搜索范围。例如,对于同一个数据规模,红黑树可能需要进行约 30 次"划分"(即 30 层访问),而 B 树可能只需 3~4 次即可完成定位。这里的"划分次数",在外存场景下,实质上就对应磁盘 I/O 的次数。这正是 B 树高效外查找的本质原因。

进一步分析可以发现,B 树的一个关键设计在于:其单个节点通常与磁盘块(Block)大小对齐。这意味着,相较于红黑树节点,B 树节点具有一个显著特征------节点粒度更大

红黑树节点通常仅包含一个 Key 以及对应的数据(或者数据指针)以及左右子指针,大小仅为几十字节;而 B 树节点大小通常为一个磁盘块(如 4KB 或更大),因此可以容纳大量 Key以及对应的数据(或者数据指针) 和子节点指针。

换言之,在一次磁盘 I/O 操作中,B 树可以将一个"高扇出"(high fan-out)的节点整体加载到内存中,从而在内存中完成多路分支的选择。这不仅提高了单次 I/O 的数据利用率,也为后续减少树的高度、降低 I/O 次数提供了结构性基础。

假设一个 B 树节点可以容纳约 1024 个分支(即阶为 1024),那么在近似满树的情况下,其规模增长如下(以 4 层为例):

  • 第 1 层:约 (1024) 个 Key
  • 第 2 层:约 (1024^2) 个 Key
  • 第 3 层:约 (1024^3) 个 Key
  • 第 4 层:约 (1024^4) 个 Key

总规模约为:

(1024 + 1024^2 + 1024^3 + 1024^4 约等于 10^9) 级别(约十亿级数据量)

也就是说,仅需 4 层结构,B 树就可以索引数十亿规模的数据。而查找任意一条数据,最多只需要 4 次磁盘 I/O,这在外存场景中是极其高效的。

需要注意的是,B 树本质上仍然是一种有序搜索树。虽然其为多叉结构,但仍满足类似二叉搜索树的有序性约束:

  • 每个节点内部的 Key 按递增顺序排列;
  • 相邻 Key 将子节点划分为多个区间;
  • 每个子树中的所有 Key,均位于对应区间范围内(即介于两个相邻 Key 之间)。

在具体实现上,节点内部通常使用数组存储 Key,同时一般使用数组存储子节点指针。由于 Key 是有序的,因此在节点内部可以通过二分查找快速定位目标区间,从而确定下一步应访问的子节点。

因此,对于一次查找操作,从根节点出发,每一层仅需进行一次"节点加载 + 内部二分查找",经过极少的层数,即可在海量数据中高效定位目标记录。


性质

认识了 B 树的原理与结构之后,接下来将分析其核心性质。根据前文,B 树本质上是一棵有序的多叉平衡树 ,因此其首先必须满足的性质便是 有序性

读者可能首先会产生一个疑问:为什么 B 树必须保持有序?

从查找的角度来看,如果 B 树不具备有序性,将无法高效支持查找操作。需要注意的是,B 树并非连续存储结构,而是一种典型的链式(或基于指针/磁盘地址组织的)结构。如果节点内部以及节点之间的数据是无序的,那么在查找目标数据时,只能通过类似中序遍历或前序遍历的方式扫描整棵树。

这种方式带来的问题主要体现在两个方面:

  • CPU 计算开销显著增加:时间复杂度退化,节点内部需要进行线性扫描;
  • 无法利用高效查找算法:例如二分查找等基于有序性的优化手段将失效。

因此,引入有序性并不是为了直接减少磁盘 I/O 次数,而是为了降低单个节点内部的查找复杂度。一旦节点内的 key 有序排列,就可以使用二分查找,从而显著提升查找效率。

进一步来看,有序性还为后续的结构即 B+ 树提供了基础支持(这一点读者看不懂没有关系),尤其是在**范围查询(Range Query)**场景中尤为关键。

例如,对于条件 Key > 10 AND Key < 50

  • 若数据无序,则必须扫描整棵树;
  • 若数据有序,则可以像"区间裁剪"一样,快速定位到某一分支,并沿着有序方向顺序遍历,从而高效完成查询。

在理解了有序性之后,我们再来看 B 树的另一个核心特征:平衡性。要理解这一点,需要结合插入操作进行分析。

假设向 B 树中插入一个新的 key,其基本过程如下:

  1. 利用有序性定位插入位置;
  2. 新 key 必须插入到叶子节点

那么问题来了:为什么插入操作必须限制在叶子节点?

可以通过反证来理解这一约束。假设允许在内部节点任意插入:

例如某内部节点当前为:

cpp 复制代码
key:   [10, 20, 30, 40]
child: [p0, p1, p2, p3, p4]

其中,p2 对应区间 (20, 30),其子树中可能包含 {21, 26, 28}

若此时插入 key = 25,并直接在该内部节点进行插入,则结构变为:

cpp 复制代码
key:   [10, 20, 25, 30, 40]
child: [p0, p1, new_p, p2, p3, p4]

此时问题出现了:

  • 原本 p2 表示区间 (20, 30)
  • 插入后,p2 被"右移",其语义变为 (25, 30)
  • 原属于 (20, 25) 区间的数据(如 21)将被错误路由到 new_p

这意味着:内部节点的有序划分语义被破坏,从而导致查找路径错误。因此:

👉 只能在叶子节点插入,因为叶子节点不涉及子树划分,不会破坏已有结构的语义一致性。


而对于B 树节点来说,其也有约束性质。

对于一棵 m 阶 B 树(即每个节点最多有 m 个子节点):

  • 子节点数量 = key 数量 + 1
  • 因此 key 的上限为 m - 1

更关键的是,key 数量还存在一个下限约束
⌊ m 2 ⌋ − 1 ≤ k e y n u m ≤ m − 1 \left\lfloor \frac{m}{2} \right\rfloor - 1 \leq keynum \leq m - 1 ⌊2m⌋−1≤keynum≤m−1

读者通常更容易理解上限(由结构容量决定),但下限的存在往往更值得深入分析。


为什么需要下限约束?

如果不设下限,会带来两个核心问题:

  1. 空间利用率极低

B 树节点通常直接映射磁盘块(如 4KB 或 16KB)。一个节点可以容纳大量 key 与指针。

如果允许节点在删除操作后不断"变空",可能出现如下极端情况:

  • 每个节点只使用 10% 的空间;
  • 原本 10GB 数据,占用 10GB 磁盘;
  • 删除后仅剩 1GB 数据,但仍占用 10GB 空间。

👉 结果是:严重的空间浪费(低填充率)


  1. 范围查询性能下降(与 B+ 树密切相关)

在 B+ 树中,叶子节点通常通过链表连接,以支持高效范围查询。

这里由于数据分布在不同的 B 树叶子节点中,如果叶子节点内部的数据分布较为稀疏、不连续,那么在访问区间 [k1, kn] 时,往往需要多次磁盘 I/O,逐个加载对应的叶子节点,才能完成整个区间的数据读取。

相反,如果数据在叶子节点中是紧凑且连续存储 的,那么区间 [k1, kn] 内的数据很可能全部落在同一个磁盘块(或同一个 B+ 树叶子节点)中,此时只需一次磁盘 I/O 即可完成读取。

因此,从本质上来看,节点的填充率(即数据的紧凑程度)会直接影响范围查询的 I/O 次数:越紧凑,所需访问的节点越少,磁盘 I/O 次数也就越低,查询效率越高。

👉 本质上:下限约束保证了数据的"局部密度",从而优化范围查询的 I/O 行为。

这里需要额外补充的是:上述 key 数量下限约束同时适用于内部节点和叶子节点 ,但根节点是一个特例

对于根节点,允许其仅包含一个 key。这种"特殊处理"是必要的,原因可以从 B 树的构建初始阶段来理解:

  • 在初始状态下,整棵 B 树仅包含一个根节点;
  • 插入第一个数据后,根节点自然只包含一个 key;
  • 如果此时强制要求根节点也满足下限约束(即至少为 ⌊m/2⌋−1 个 key),那么该结构将无法成立。

换言之,如果根节点也必须满足下限约束,那么 B 树在逻辑上将无法完成初始化,也就失去了"从空树逐步生长"的能力。因此,标准定义中对根节点作出了放宽限制,这是为了保证数据结构在动态插入过程中的可行性与一致性。

👉 根节点的"例外"本质上是为了保证 B 树在动态演化过程中的合法性,而非结构上的随意放宽。


基于上述分析,B 树的核心性质可以归纳如下:

  1. 新插入的 key 只能插入到叶子节点
    ------ 以避免破坏内部节点对子树的区间划分语义。
  2. B 树整体满足有序性
    ------ 节点内部 key 有序,子树区间有序,从而支持高效查找与范围查询。
  3. 节点 key 数量满足上下界约束
    • 对于除根节点外的所有节点:
      key 数量范围为:[⌊m/2⌋ - 1, m - 1](左闭右闭)
    • 根节点:允许低于该下限(但至少包含 1 个 key,除非整棵树为空)


B树的插入

认识了 B 树的基本性质之后,接下来需要分析的是 B 树插入操作的具体细节。当插入一个新的元素时,首先需要判断该元素是否已经存在于 B 树中;若已存在,则通常不进行插入(或根据具体实现进行更新);若不存在,则执行插入操作。

插入过程的第一步,是利用 B 树的有序性,从根节点出发进行逐层查找,确定目标元素应当落入的区间范围,并沿着对应的分支向下遍历,最终定位到目标叶子节点。定位完成后,便在该叶子节点中执行插入操作。此过程涉及节点内部 key 数组的有序插入 以及相关指针(或数据)的调整。具体实现细节可在后续代码模拟部分进一步展开。

假设该元素已经成功插入到叶子节点中,根据前文可知,B 树中每个节点所能容纳的 key 数量是存在上限的。如果插入操作导致节点中的 key 数量达到上限并发生上溢(overflow) ,则需要触发一个关键操作------分裂(split)


分裂

所谓分裂,本质上是:创建一个新的兄弟节点,并将当前节点中的部分 key(以及对应的数据或子指针)转移到该兄弟节点中,从而降低原节点的负载。

需要注意的是,分裂过程中数据的划分策略至关重要:

  • 若仅分裂出少量 key,则原节点仍然接近上限,后续插入时很容易再次发生上溢;
  • 若分裂出过多 key,则会导致节点空间利用率降低,影响存储效率;
  • 因此,在工程实践中通常采用**均匀划分(即按中位数划分)**的策略,使两个节点在分裂后尽可能保持平衡。

这种"尽可能紧凑"的存储方式具有重要意义:在以磁盘 I/O 为主的场景(例如数据库索引)中,节点通常对应一个磁盘块。节点中存储的数据越多、越紧凑,则在进行范围查询(尤其是在 B+ 树中)时,可以通过更少的 I/O 操作扫描更多的数据,从而显著提升查询效率。


在执行分裂时,还需要处理父节点的结构调整问题。

根据 B 树的结构定义,节点中的 key 与子指针之间呈"交错排列"关系:

每一个 key 将其左右子树划分为"小于该 key"和"大于该 key"的两个区间。

因此,在分裂过程中,需要执行如下操作:

  1. 选取当前节点的中位数 key
  2. 将该中位数 key 上移(promote)到父节点
  3. 将中位数右侧的所有 key 以及对应的数据(或子树指针)转移到新创建的兄弟节点中;
  4. 原节点保留中位数左侧的部分;
  5. 父节点中新增的 key,其右侧指针指向新创建的兄弟节点。

从结构上看,这一过程保持了 B 树的有序性:

中位数左侧仍然对应原节点(较小区间),右侧对应新节点(较大区间)。


需要特别注意的是:当中位数 key 被插入到父节点后,父节点同样可能发生上溢 。因此,分裂操作可能会沿着树结构向上递归传播

  • 若父节点未上溢,则调整结束;
  • 若父节点发生上溢,则重复上述分裂过程;
  • 若上溢一直传播到根节点,则需要进行特殊处理。

当根节点发生上溢时,由于其不存在父节点,因此需要:

  1. 创建一个新的根节点;
  2. 将原根节点分裂得到的中位数 key 插入到新根节点中;
  3. 将原根节点和新生成的兄弟节点作为新根节点的两个子节点。

最后,这一过程也可以解释一个关键设计:为什么 B 树对根节点的最小 key 数量不做严格下限约束

原因在于:在分裂过程中,新生成的根节点仅包含一个 key(即中位数),显然无法满足普通节点的下限要求。因此,根节点被特殊对待,不强制满足最小度约束,这也是 B 树结构设计中的一个必要例外。

在理解了 B 树的插入与分裂机制之后,可以进一步引出一个非常关键的结构特性:B 树的所有叶子节点始终处于同一层。这一性质正是 B 树能够保持全局平衡的根本原因之一。

由于 B 树通常用于外存场景,其节点大小一般与磁盘块对齐,这意味着一个节点可以容纳多个 key 以及对应的子指针。因此,相比二叉搜索树结构,B 树的"扇出(fan-out)"更大,树的高度增长更加缓慢。

当某一层(通常是叶子层)中的节点插入 key 后达到上限时,会触发分裂操作:

  1. 当前节点被分裂为两个节点(原节点 + 新的兄弟节点);
  2. 选取中位数 key;
  3. 将该 key(以及对应的分隔语义)提升到父节点
  4. 父节点新增一个分支指针指向新生成的兄弟节点。

可以看到,每一次分裂,本质上是向父节点"贡献"一个 key,并增加一个子树分支


正是由于这种"由下至上"的分裂传播机制,使得 B 树的高度增长是一个低概率、渐进式的过程。

具体来说:

  • 若当前树高为 (h),阶数为 (m),则要使第 (h-1) 层(倒数第二层)发生分裂,需要其子节点(叶子层)发生多次分裂;
  • 而要使第 (h-2) 层发生分裂,则需要第 (h-1) 层先累计足够多的分裂;
  • 这一过程呈现出类似"逐层放大"的效果,本质上是一种指数级缓冲机制

换言之:越接近根节点,触发分裂所需的插入次数越多。因此,根节点发生分裂(即树高增加)是非常少见的,这也是 B 树能够保持低高度的重要原因。


从初始状态来看,B 树只有一个根节点,此时该节点同时也是叶子节点。随着插入操作的进行:

  • 新元素始终插入到叶子节点
  • 不会直接在中间层生成新节点;
  • 只有当节点"满"时,才通过分裂产生兄弟节点;
  • 并通过"中位数上移"逐步影响上层结构。

因此,B 树的生长可以总结为两个方向:

  • 横向扩展:通过分裂增加同层节点数量;
  • 纵向增长:仅在根节点分裂时,树高增加一层。

基于上述机制,可以自然得出结论:所有叶子节点始终保持在同一层。因为:

初始状态:只有根节点,它既是根也是唯一的叶子,所有叶子在同一层(第0层)。

插入操作只在当前叶子层进行,不会在树的中间层创建新的叶子节点。

分裂操作只会横向产生兄弟节点(与原节点在同一层),不会向下产生新层。

高度增加的唯一途径是根节点分裂,此时新根加在整棵树上方,所有叶子到根的距离同时加1。

这也从结构上保证了 B 树在最坏情况下仍然具备良好的查找性能(高度始终受控),从而在数据库索引、文件系统等场景中得到广泛应用。


其次,需要补充说明的是:在计算 B 树节点的 key 数量下限 时,通常采用公式:
⌈ m 2 ⌉ − 1 \left\lceil \frac{m}{2} \right\rceil - 1 ⌈2m⌉−1

其中 (m) 表示 B 树的阶(即一个节点最多拥有的子节点数)。

之所以需要对m/2进行向上取整(ceil) ,本质上是为了在分裂过程中精确刻画最小节点容量,尤其是处理 key 数量为偶数时的不均分问题。


结合分裂机制来看:

当一个节点发生上溢时,会执行如下操作:

  1. 选取中位数 key;
  2. 将该 key 上移到父节点;
  3. 将中位数右侧的 key 拷贝到新创建的兄弟节点;
  4. 原节点保留中位数左侧部分。

在此过程中,key 的分布情况取决于节点容量的奇偶性:

  • 当溢出后的 key 数为奇数时
    去除中位数后,左右两侧的 key 数量是完全对称的,即可以"均分",两个节点的 key 数量相等;
  • 当溢出后的 key 数为偶数时
    去除中位数后,剩余 key 数为奇数,此时无法均分,必然存在一个节点分得较少,另一个分得较多。

而 B 树下限的定义,必须以**最不利情况(即 key 数较少的一侧)**为基准。因此,需要准确计算"分裂后最少能保留多少个 key"。

这正是引入向上取整的原因:

对 ( m 2 ) 向上取整,可以在偶数情况下在后续减一之后,得到较小一侧的 k e y 数量; 对(\frac{m}{2})向上取整,可以在偶数情况下在后续减一之后,得到较小一侧的 key 数量; 对(2m)向上取整,可以在偶数情况下在后续减一之后,得到较小一侧的key数量;

  • 对于奇数情况,向上取整相当于多加 1,但随后再减 1,结果仍然回到对称划分下的合理值;

  • 对于偶数情况,向上取整不改变值,但减 1 后,正好得到"较少一侧"的 key 数量。


因此,公式:
⌈ m 2 ⌉ − 1 \left\lceil \frac{m}{2} \right\rceil - 1 ⌈2m⌉−1

可以统一刻画:

  • 分裂后节点中 key 的最小数量
  • 同时保证该下限在奇数与偶数两种情况下都成立;
  • 并严格符合 B 树在分裂操作下的结构约束。

从工程角度来看,这一定义确保了:

  • 节点在分裂后不会过于稀疏(保证空间利用率);
  • 同时也为后续的插入与删除操作提供了稳定的结构边界。

为了让大家(也为了让我自己)更直观地理解 M阶 B 树在插入和删除时是如何进行'节点分裂'、'中位数提拔'以及'向兄弟借元素'的,演示工具的链接在下方

我将这个工具部署在了 GitHub Pages 上,支持实时修改阶数 M。大家可以直接在网页中手动输入键值,亲眼观察树的动态变化:

https://cdc028wangzhe.github.io/BTree-Visualizer/

B树的删除

在理解了 B 树的插入操作之后,接下来需要分析的是 B 树的删除机制。与插入不同,删除操作既可能发生在叶子节点,也可能发生在内部节点。总体而言,删除过程同样依赖于 B 树的有序性:首先通过查找定位目标 key 所在的节点,然后根据节点类型分情况处理。通常可以将删除分为两类:删除叶子节点中的 key,以及删除内部节点中的 key。


首先讨论删除叶子节点的情况。由于叶子节点不包含子节点,定位到目标 key 后,只需在当前节点中删除对应的 key 及其关联的数据(value)。不过,这一步完成后,还必须检查该节点是否发生下溢(underflow)

所谓下溢,是指当前节点中的 key 数量低于 B 树所规定的最小下限。一旦发生下溢,就需要通过"调整"操作恢复平衡,其中最常见的方式是向兄弟节点借元素

需要强调的是,这里的"借"并非简单地将兄弟节点的 key 直接移动过来。由于 B 树需要严格维护全局有序性,每个子树都对应一个确定的区间范围,因此调整过程必须借助父节点中的**分隔 key(separator key)**来完成。

以向左兄弟借为例(假设左兄弟存在且可借):

  • 首先,在父节点中找到位于"左兄弟节点"和"当前节点"之间的分隔 key;
  • 将该分隔 key 下移到当前节点,并插入到当前节点的最左侧;
  • 随后,将左兄弟节点中最大的 key(即最右侧元素)上移到父节点,替换原有的分隔 key。

这一过程本质上是一次"旋转(rotation)"操作:通过父节点作为中介,在兄弟节点与当前节点之间重新分配元素,从而既满足节点下限约束,又保持全局有序性。

进一步分析其正确性:

  • 父节点中的分隔 key 一定大于左子树的所有 key,小于右子树(即当前节点)的所有 key,因此将其下移到当前节点的最左侧是合法的;
  • 左兄弟节点的最大即最右侧的 key 上移后,仍然满足"位于左右子树之间"的有序约束。

同理,如果右兄弟节点满足"可借"(即其 key 数量大于下限),也可以执行对称操作:

  • 将父节点中当前节点与右兄弟之间的分隔 key 下移到当前节点的最右侧;
  • 将右兄弟节点中最小即最左侧的 key 上移到父节点。

接下来考虑无法借元素的情况:如果当前节点的左右兄弟节点都已经处于最小下限,则无法再进行借用操作,此时需要执行合并(merge)

合并的核心思想是:将当前节点与某一个兄弟节点(通常选择左或右之一)以及父节点中的分隔 key 合并为一个节点。

具体过程如下:

  • 选择一个兄弟节点(例如左兄弟);
  • 将父节点中位于两者之间的分隔 key 下移到合并的左节点;
  • 将当前节点的所有 key 及数据合并到左兄弟节点中(或反之)。

需要注意两点:

  1. 合并通常不会创建新节点,而是复用已有节点进行数据整合;
  2. 合并后的节点,其 key 数量必然不小于下限,从而恢复局部平衡,同时也提高了存储空间的利用率,使数据更加紧凑。

然而,合并操作会引入新的问题:

当父节点中的分隔 key 被下移后,相当于从父节点中删除了一个 key。这可能导致父节点自身发生下溢。因此,需要对父节点继续执行相同的检查与调整过程。

也就是说,B 树的删除操作具有**向上递归修复(recursive rebalancing)**的特性:

  • 当前节点下溢 → 尝试借或合并;
  • 若发生合并 → 父节点 key 减少;
  • 父节点可能下溢 → 继续向上调整;
  • 直到某一层不再下溢,或最终影响到根节点为止。

总结来看,B 树删除的核心可以概括为三步:

  1. 定位并删除目标 key(利用有序性)
  2. 检测并处理下溢(借或合并)
  3. 必要时向上递归调整结构

这一机制保证了在删除操作之后,B 树依然满足所有结构约束(节点容量、有序性以及平衡性),从而维持其良好的查找性能。


在理解了叶子节点的删除之后,接下来需要分析的是**非叶子节点(即内部节点)**的删除机制。

对于内部节点的删除,直观上可能会认为可以沿用叶子节点的处理流程:直接删除目标 key,然后检查是否发生下溢。但这种做法在 B 树中是不可行的 ,其根本原因在于:内部节点不仅包含 key,还承担着划分子树区间的结构性作用

具体来说,假设当前要删除内部节点中的某个 key_i。该 key_i 在逻辑上对应两个子树区间:

  • 左子树:(key_i-1, key_i)
  • 右子树:(key_i, key_i+1)

一旦直接删除 key_i,就会导致原本由该 key 划分的两个区间被破坏,变成一个更大的区间:

  • (key_i-1, key_i+1)

此时,一个关键问题随之出现:原本分别挂载在 key_i 左右的两棵子树该如何处理?

如果强行删除 key_i,就意味着必须将其左右子树进行合并,形成一棵新的子树来承载合并后的区间。这种做法在实现上非常复杂:

  • 需要对子树结构进行大规模重组;
  • 涉及大量节点移动,维护成本高;
  • 时间复杂度和实现复杂度都不理想。

因此,在 B 树中并不会直接删除内部节点的 key,而是采用一种更高效且结构稳定的策略:用"替代 key"进行覆盖(replacement) ,这一思想与二叉搜索树(BST)的删除策略是类似的。


具体做法如下:

当需要删除内部节点中的 key_i 时,可以选择:

  • 使用其左子树中的最大 key(前驱,predecessor),或
  • 使用其右子树中的最小 key(后继,successor)

覆盖当前的 key_i 及其关联数据

例如:

  • 若选择左子树的最大 key,则该 key 一定满足:
    • 小于原 key_i;
    • 且是左子树中的最大值;
  • 若选择右子树的最小 key,则该 key 一定满足:
    • 大于原 key_i;
    • 且是右子树中的最小值。

无论选择哪一种,都可以保证替换之后,B 树整体仍然满足有序性约束


完成覆盖之后,问题被"转移"了:

  • 原内部节点的删除问题,转化为
  • 在对应子树中删除"前驱"或"后继" key 的问题。

而这一删除操作最终一定会落到叶子节点上(因为前驱或后继一定在叶子层或接近叶子层的位置)。

此时,就可以完全复用前文关于叶子节点删除的处理流程:

  1. 在叶子节点中删除目标 key;
  2. 检查是否发生下溢;
  3. 若下溢,则执行借用或合并;
  4. 必要时向上递归调整。

总结来看,内部节点删除的核心思路可以概括为:

  • 不直接删除内部节点的 key;
  • 通过前驱或后继进行替换,将问题转化为叶子节点删除;
  • 复用叶子节点删除的完整调整机制。

这种"问题下沉(problem reduction)"的策略,使得 B 树在删除操作中避免了复杂的子树重组,同时保持了实现上的一致性与效率,是 B 树设计中的一个关键技巧。

B树的查找

在理解了 B 树的插入与删除操作之后,接下来便是查找操作。相较于前两者,B 树的查找在实现上是最为直接且易于理解的。

如果读者具备二叉搜索树(BST)的实现经验,那么可以很容易将其思路迁移到 B 树上。B 树查找的核心在于利用其节点内部 key 的有序性:每一个节点中的 key 均按照递增顺序排列。

具体过程如下:查找从整棵 B 树的根节点开始,首先访问当前节点的 key 数组,并在该有序数组中执行二分查找,以提高查找效率。

  • 若目标 key 存在于当前节点中,则直接返回其对应的数据或数据指针;
  • 若目标 key 不在当前节点中,则根据比较结果确定其应落入的区间,从而定位到对应的子节点指针,并沿着该分支继续向下递归查找。

可以看出,B 树的查找过程本质上是"节点内二分查找 + 节点间分支选择"的组合。这种结构充分利用了磁盘块(或页)中可容纳多个 key 的特性,在减少树高的同时,也显著降低了 I/O 次数,从而在外存场景下具备较高的查找效率。

B树的效率分析

在已经掌握了 B 树的插入、删除与查找操作之后,接下来我们对 B 树的时间复杂度与 I/O 效率 进行系统分析。

首先,从插入操作出发。我们知道,B 树是一种矮而宽的多叉搜索树 ,其性能瓶颈主要并不在单个节点内部的比较,而在于树高所决定的磁盘 I/O 次数 。因此,插入操作的时间复杂度,本质上取决于从根节点到目标位置的查找路径长度 ,也即树的高度 h h h。

对于一棵 m m m 阶 B 树,除根节点外,其余节点都受到关键字数量的上限与下限约束。在分析最坏时间复杂度时,我们需要构造一种使树高尽可能大的极端情况。显然:

  • 树越"高",路径越长,I/O 次数越多;
  • 而要让树高最大,就必须让每个节点尽可能"稀疏",即仅包含最少数量的关键字(达到下限)

因此,在最坏情况下:

  • 除根节点外,所有节点都只包含最少的 key;
  • 整棵树处于一种"极度不紧凑"的状态。

接下来进行形式化推导:


  • t = ⌈ m / 2 ⌉ t = \lceil m/2 \rceil t=⌈m/2⌉

    (最小度,最小子节点数为 t ,最小 key 数为 t-1 )

  • 根节点至少 1 个 key、2 个子节点

  • 其余每层节点都只有 t-1 个 key

各层最少节点数和 key 数如下:

  • 第 0 层(根):1 个节点,至少 1 个 key
  • 第 1 层:2 个节点,每个至少 t-1 个 key
  • 第 2 层: 2t 个节点,每个至少 t-1 个 key
  • 第 3 层: 2t^2 个节点,每个至少 t-1 个 key
  • ...
  • 第 h 层:2t^{h-1} 个节点,每个至少 t-1 个 key

总 key 数量 n 的下界为:
n ≥ 1 + 2 ( t − 1 ) + 2 t ( t − 1 ) + 2 t 2 ( t − 1 ) + ⋯ + 2 t h − 1 ( t − 1 ) n \geq 1 + 2(t-1) + 2t(t-1) + 2t^2(t-1) + \cdots + 2t^{h-1}(t-1) n≥1+2(t−1)+2t(t−1)+2t2(t−1)+⋯+2th−1(t−1)

提取公因子:
n ≥ 1 + 2 ( t − 1 ) × ( 1 + t + t 2 + ⋯ + t h − 1 ) n \geq 1 + 2(t-1) \times (1 + t + t^2 + \cdots + t^{h-1}) n≥1+2(t−1)×(1+t+t2+⋯+th−1)

括号内是等比数列求和(首项 1,公比 t ,共 h 项):
1 + t + t 2 + ⋯ + t h − 1 = t h − 1 t − 1 1 + t + t^2 + \cdots + t^{h-1} = \frac{t^h - 1}{t - 1} 1+t+t2+⋯+th−1=t−1th−1

代入得:
n ≥ 1 + 2 ( t − 1 ) × t h − 1 t − 1 = 1 + 2 ( t h − 1 ) = 2 t h − 1 n \geq 1 + 2(t-1) \times \frac{t^h - 1}{t - 1} = 1 + 2(t^h - 1) = 2t^h - 1 n≥1+2(t−1)×t−1th−1=1+2(th−1)=2th−1

解不等式:
2 t h − 1 ≤ n ⇒ t h ≤ n + 1 2 ⇒ h ≤ log ⁡ t ( n + 1 2 ) 2t^h - 1 \leq n \quad \Rightarrow \quad t^h \leq \frac{n+1}{2} \quad \Rightarrow \quad h \leq \log_t \left( \frac{n+1}{2} \right) 2th−1≤n⇒th≤2n+1⇒h≤logt(2n+1)

因此,B 树的最大高度 (即最坏情况下查找/插入/删除一次所需的磁盘 I/O 次数)为:
h ≤ log ⁡ ⌈ m / 2 ⌉ ( n + 1 2 ) h \leq \log_{\lceil m/2 \rceil} \left( \frac{n+1}{2} \right) h≤log⌈m/2⌉(2n+1)

这就是最坏情况下查找一次的磁盘I/O次数。m=1000,n=一千万,h ≤ log₅₀₀(5000000) ≈ 2.5,也就是最多3次磁盘I/O就能定位到任意一条记录。

由此可以得到一个非常关键的结论:

B 树的高度为对数级别,且对数的底数为
⌈ m / 2 ⌉ \lceil m/2 \rceil ⌈m/2⌉

因此:

  • 查找、插入、删除操作的时间复杂度均为:
    O ( log ⁡ t n ) O(\log_t n) O(logtn)

  • 对应的磁盘 I/O 次数也为:
    O ( log ⁡ t n ) O(\log_t n) O(logtn)

B树的实现

在理解了 B 树的基本原理之后,接下来我们将通过代码实现一个 B 树,以进一步加深对其结构与操作机制的理解。

首先,需要定义两个模板类。其中,第一个模板类用于描述 B 树的节点结构。在设计时,这里并未刻意区分叶子节点与内部节点,而是统一复用同一个模板类来表示两者。为区分节点类型,在类中引入了一个 bool 类型的标志位,用于标识当前节点是叶子节点还是内部节点。

具体来看,该 B 树节点模板类包含三个模板参数:

其一是键(key)的类型,对应一个模板类型参数,支持整型、字符串等多种类型,具体类型由模板实例化时决定;其二是数据(value)的类型,同样由模板参数指定;其三是一个非类型模板参数,用于表示 B 树的阶(order)。

在节点的成员设计上,主要包括以下几个部分:键数组(keys)、对应的数据数组(values)、子节点指针数组(child),以及用于标识节点类型的标志位。

这里有一个值得注意的实现细节:根据 B 树的定义,每个节点中键的数量是有上下界约束的。对于一棵 m 阶 B 树而言,节点中最多包含 m - 1 个键。然而,在实现时,keys 数组的长度被设置为 m 而非 m - 1。这样设计的主要目的是为了简化插入过程中的元素移动操作------在插入新键时,可以先直接插入到数组中,而无需立即判断是否达到上限;随后再统一处理上溢(overflow)情况,即在必要时执行节点分裂操作。

类似地,child 指针数组的长度被设置为 m + 1,同样是为了在发生上溢分裂时,能够方便地进行子节点指针的移动与重排,从而避免频繁的边界判断与额外的拷贝操作。

此外,节点中还维护了一个 count 变量,用于记录当前节点中实际存储的键的数量(即有效元素个数),这一点在后续的查找、插入和删除操作中都至关重要。

对应的代码实现如下:

cpp 复制代码
template<typename key, typename value, size_t M>
class BtreeNode
{
public:
    BtreeNode()
        : count(0)
        , isleaf(true)
    {
        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];
    BtreeNode* child[M + 1];
    int count;
};

接下来是 Btree 模板类的设计。该类的模板参数与节点类保持一致,这是因为 Btree 内部需要维护指向根节点的指针,因此必须使用相同的类型参数进行实例化。

Btree 类中,首先通过 using(或 typedef)为节点类型定义一个别名,以提升代码的可读性。随后,类内部包含一个指向根节点的指针 root,用于表示整棵 B 树的入口。

此外,Btree 类还需要对外提供一系列基本操作接口,包括查找(search)、插入(insert)以及删除(erase)等,这些操作构成了 B 树的核心功能。

对应的代码框架如下:

cpp 复制代码
template<typename key, typename value, size_t M>
class Btree
{
    using Node = BtreeNode<key, value, M>;

public:
    Btree()
        : root(nullptr)
    {

    }

    // ...

private:
    Node* root;
};

插入

接下来是关于插入函数的实现。

首先,插入操作的整体逻辑如下:需要先判断当前 B 树是否为空。如果为空(即根节点指针为 nullptr),则需要创建一个新的节点作为根节点,并直接将 key 及其对应的 value 插入到 keys 数组和 values 数组中即可。

如果根节点存在,则下一步是定位待插入位置所在的叶子节点 。这一过程需要从根节点开始向下遍历。为此,我专门实现了一个 search 函数,其核心作用是从根节点出发,逐层向下查找,最终定位目标节点。

在查找过程中,每个节点内部的 keys 数组是有序递增 的,因此可以采用二分查找 来确定目标位置。具体而言,通过二分查找得到第一个大于等于目标 key 的位置 pos,然后进行如下判断:

  • pos < countkeys[pos] == key,说明目标 key 已存在,直接返回当前节点及对应下标;
  • 否则,说明当前 pos 对应的是第一个大于目标 key 的位置

这里需要特别注意 keys 数组与 child 数组之间的映射关系。逻辑上可以将 keychild 理解为交替排列,这里key数组以及child数组都是从下标为0开始,意味着:

  • 对于下标为 ikey
    • 左子树 位于 child[i]
    • 右子树 位于 child[i+1]

而由于当前 pos 是"第一个大于等于目标 key 的位置",因此下一步应该进入 child[pos] 所指向的子树继续查找。

在进入子节点之前,如果当前节点是叶子节点(即 isleaf == true),说明已经无法继续向下遍历,此时直接终止循环,并返回当前节点及位置。


关于父节点获取策略(关键设计点)

在后续插入过程中,如果发生上溢(overflow) ,需要进行节点分裂,并将中位数 key 上移至父节点。因此,我们必须能够访问当前节点的父节点。

一种常见做法是在节点结构中维护一个父指针(parent pointer)。这种方式是可行的,但实现复杂度较高,尤其是在节点分裂时:

  • 新创建的兄弟节点需要正确设置父指针;
  • 若分裂的是内部节点,还需要更新被移动子节点的父指针;
  • 涉及大量指针维护,容易出错。

因此,这里我采用了另一种方案:利用栈(stack)记录路径

具体做法是:

  • 在查找过程中,每向下进入一层子节点之前,将当前节点压入栈中;
  • 当查找到目标叶子节点时,栈中保存的即是从根到该节点路径上的所有祖先节点;
  • 栈顶元素即为当前节点的父节点。

这样在发生分裂时,只需弹出栈顶即可获得父节点,无需维护额外的父指针,从而显著降低实现复杂度。

因此,search 函数除了接收目标 key 外,还需要接收一个栈结构用于记录路径:

cpp 复制代码
std::pair<Node*,int> search(const key& k,std::stack<Node*>& path)
{
    Node* cur=root;
    int pos=0;
    while(cur!=nullptr)
    {
        pos=binary_search(cur->keys,k,cur->count);
        if(pos<cur->count && cur->keys[pos]==k)
        {
            return std::make_pair(cur,pos);
        }
        if(cur->isleaf)
        {
            break;
        }
        path.push(cur);
        cur=cur->child[pos];
    }
    return std::make_pair(cur,pos);
}

回到插入函数

在确认树非空后,首先定义路径栈 path,调用 search 函数获取结果 (cur, pos)

这里还需要对 search 函数返回的二元组进行进一步检查,以判断当前待插入的 key 是否已经存在。

如果目标 key 不存在,则说明 search 函数已经一路遍历至叶子节点,并返回了当前节点中第一个大于等于目标 key 的位置 pos。在这种情况下,不存在可以细分为两类:

  • 一种情况是:在 key 数组的有效范围内,找到了一个大于目标 key 的位置
  • 另一种情况是:目标 key 大于当前节点中所有已有的 key,此时返回的 pos 等于 count,即位于有效数据末尾之后的位置。

与上述两种情况相对的,则是目标 key 已存在的情形,即在有效数据范围内找到了一个等于目标 key 的位置

因此,这里需要进行如下判断:

检查返回的 pos 是否满足 pos < count(即位于有效数据范围内),并且 keys[pos] == key。如果该条件成立,则说明目标 key 已存在,此时应直接返回(终止插入操作,避免重复插入)。


节点内部插入逻辑

插入操作被封装为 insert_into_node 函数,其职责是:

  • 在指定节点的 pos 位置插入 keyvalue
  • 必要时插入右子树指针(用于内部节点分裂后的上移操作)

核心逻辑是将 [pos, count-1] 区间整体右移一位,然后写入新值:

cpp 复制代码
void insert_into_node(Node* node,int pos,const key& k,const value& v,Node* rightchild)
{
    for(int i=node->count;i>pos;i--)
    {
        node->keys[i]=node->keys[i-1];
        node->values[i]=node->values[i-1];
    }
    node->keys[pos]=k;
    node->values[pos]=v;

    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++; 
}

接下来是关于 child 数组的处理。

需要特别注意的是,这里的插入函数在设计上并不仅仅服务于叶子节点的数据插入 ,同时也用于内部节点的插入操作 ,例如在节点分裂后,将中位数 key 及其对应的 value 上移并插入到父节点中的场景。

在这种情况下,除了需要将 keyvalue 插入到父节点之外,还必须同时插入对应的右子树指针 。该右子树即为分裂过程中产生的兄弟节点,其所有 key 均大于当前上移的中位数 key

因此,在向父节点插入时,需要先将待插入 key 所对应位置 pos 的右侧所有 child 指针整体向右移动一个单位 ,从而为新的子树指针腾出空间,然后再将兄弟节点插入到 child[pos + 1] 的位置上,以保证 keychild 之间的结构关系仍然满足 B 树的定义

而如果当前操作仅仅是在叶子节点中插入 keyvalue,由于叶子节点不存在子树结构,因此这里的子节点指针可以直接传递空指针(nullptr),无需对 child 数组进行任何处理。


上溢处理(节点分裂)

插入完成后,需要检查是否发生上溢(即 count == M)。若发生,则执行分裂操作,并递归向上处理

分裂过程如下:

  1. 确定中位数位置 mid = (M+1)/2 - 1
  2. 保存中位数 keyvalue
  3. 创建兄弟节点,将右半部分拷贝过去
  4. 若为内部节点,还需拷贝对应的 child 指针
  5. 更新当前节点 count = mid

然后处理上移:

  • 若栈为空,说明当前节点为根节点:
    • 创建新根节点
    • 将中位数插入新根
    • 当前节点和兄弟节点分别作为左右子树
  • 否则:
    • 弹出父节点
    • 将中位数插入父节点
    • 继续向上检查

代码如下:

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;
        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_node(cur,pos,k,v,nullptr);

    while(cur->count==M)
    {
        int mid=(M+1)/2-1;
        value mid_value=cur->values[mid];
        key mid_key=cur->keys[mid];

        Node* brother=new Node();
        int j=0;

        for(int i=mid+1;i<M;i++)
        {
            brother->keys[j]=cur->keys[i];
            brother->values[j++]=cur->values[i];
        }

        brother->count=j;
        brother->isleaf=cur->isleaf;

        if(!cur->isleaf)
        {
            j=0;
            for(int i=mid+1;i<=M;i++)
            {
                brother->child[j++]=cur->child[i];
            }
        }

        cur->count=mid;

        if(path.empty())
        {
            Node* newroot=new Node();
            newroot->isleaf=false;
            newroot->keys[0]=mid_key;
            newroot->values[0]=mid_value;
            newroot->child[0]=cur;
            newroot->child[1]=brother;
            newroot->count=1;
            root=newroot;
            break;
        }
        else
        {
            Node* parent=path.top();
            path.pop();

            int parent_pos=binary_search(parent->keys,mid_key,parent->count);
            insert_into_node(parent,parent_pos,mid_key,mid_value,brother);

            cur=parent;
        }
    }

    return true;
}

删除

在理解了插入模块的实现之后,接下来需要分析删除模块的具体实现流程。整体来看,B 树的删除操作相比插入更加复杂,其核心在于删除后结构的自平衡维护(避免下溢)

首先,需要判断当前树是否为空。若 root == nullptr,则说明当前为空树,直接返回即可;否则,初始化一个栈 path,用于记录从根节点到目标节点的访问路径。随后调用 search 函数定位待删除的目标节点。

需要注意的是,search 函数返回的是一个二元组 (Node*, pos),其中 Node* 表示命中的节点,pos 表示目标 key 在节点中的位置。因此,这里必须对返回结果进行有效性校验:如果 pos >= cur->count 或当前节点对应位置的 key 不等于目标 key,则说明该 key 不存在,直接返回。

在成功定位到目标节点后,首先判断该节点是否为叶子节点:

  • 不是叶子节点(即内部节点),则不能直接删除;
  • 此时需要将删除问题转化为叶子节点删除问题

具体做法是:用该 key 的**前驱(左子树最大值)或后继(右子树最小值)*进行替换。这里采用的是*前驱替换策略,即:

  • 从当前节点的左子树出发,一直向下找到最右侧的叶子节点;
  • 在遍历过程中,将路径上的节点持续压入栈 path
  • 最终用该前驱节点的 key/value 覆盖当前节点对应位置的数据;
  • 然后将删除操作"下沉"到该叶子节点。

这一过程实现如下:

cpp 复制代码
if(!cur->isleaf)
{
    path.push(cur);
    Node* pred = cur->child[pos];
    while(!pred->isleaf)
    {
        path.push(pred);
        pred = pred->child[pred->count];
    }
    cur->keys[pos] = pred->keys[pred->count - 1];
    cur->values[pos] = pred->values[pred->count - 1];
    cur = pred;
    pos = pred->count - 1;
}

接下来,统一进入叶子节点删除逻辑 。这里将删除操作封装在 remove_from_node 函数中,其本质是将目标位置右侧的 key 和 value 整体向左移动一位,并更新 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)问题 。即当节点中 key 数量小于下限 minKeys = (M+1)/2 - 1 时,需要进行修复。

这里通过一个 while 循环自底向上递归调整

  • 循环条件为:当前节点不是根节点,且 key 数量小于下限;
  • 每次从 path 栈中弹出父节点;
  • 在父节点的 child 数组中定位当前节点的下标 index

随后分三种情况处理:

1️⃣ 向兄弟节点借(优先借,避免合并)

  • 若存在左兄弟,且左兄弟 key 数量大于下限 → 向左借;
  • 否则若存在右兄弟,且右兄弟满足条件 → 向右借。

向左兄弟借:

borrow_from_left 函数接收一个父节点指针以及当前节点在父节点 child 数组中的下标。函数内部首先根据该下标获取左兄弟节点和当前节点的指针。

随后,执行"向左兄弟借"的核心步骤:将父节点中位于当前节点与左兄弟节点之间的分隔 key(separator key)下移 到当前节点中。由于该 key 需要插入到当前节点的起始位置(即下标 0 处),这里可以复用 insert_into_node 函数完成插入操作,从而避免手动移动 key/value,提高代码复用性与一致性。

需要注意的是,如果当前节点不是叶子节点(即属于内部节点),那么在 key 下移的同时,还必须同步维护 child 指针数组的结构。具体而言,需要将左兄弟节点的最右子树指针 (即 left->child[left->count])移动到当前节点的 child[0] 位置。为了保证 child 数组的连续性,当前节点原有的 child 指针需要整体向右移动一位,为新的子树腾出空间。

完成上述步骤后,还需要更新父节点中的分隔 key:将左兄弟节点中的最大 key(即最右侧 key)及其对应的数据 上移,覆盖父节点原有的分隔 key。与此同时,左兄弟节点的 count 需要减一,以反映其 key 数量的减少。

cpp 复制代码
void borrow_from_left(Node* parent,int index)
{
    Node* left = parent->child[index - 1];
    Node* cur = parent->child[index];

    insert_into_node(cur, 0, parent->keys[index - 1], parent->values[index - 1], nullptr);

    if(!cur->isleaf)
    {
        for(int i = cur->count; i > 0; i--)
        {
            cur->child[i] = cur->child[i - 1];
        }
        cur->child[0] = left->child[left->count];
        left->child[left->count] = nullptr;
    }

    parent->keys[index - 1] = left->keys[left->count - 1];
    parent->values[index - 1] = left->values[left->count - 1];
    left->count--;
}

向右兄弟借(对称操作):

而向右兄弟借则是一个与向左兄弟借对称的操作 。其核心流程是:首先将父节点中位于当前节点与右兄弟节点之间的分隔 key 下移 到当前节点的末尾位置(即 cur->count 处),从而扩展当前节点的 key 数量。

随后,将右兄弟节点中的最小 key(即最左侧 key)及其对应的数据上移,覆盖父节点中的分隔 key,以维持父节点的有序性约束。

同样地,如果当前节点不是叶子节点,还需要同步调整 child 指针结构:将右兄弟节点的最左子树指针 (即 right->child[0])移动到当前节点的最右子树位置(即 cur->child[cur->count + 1] 处)。与此同时,右兄弟节点内部的 child 指针需要整体向左移动一位,以保持其结构的连续性。

cpp 复制代码
void 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->values[cur->count] = parent->values[index];

    if(!cur->isleaf)
    {
        cur->child[cur->count + 1] = right->child[0];
    }

    cur->count++;

    parent->keys[index] = right->keys[0];
    parent->values[index] = right->values[0];

    for(int i = 0; i < right->count - 1; i++)
    {
        right->keys[i] = right->keys[i + 1];
        right->values[i] = right->values[i + 1];
    }

    if(!cur->isleaf)
    {
        for(int i = 0; i < right->count; i++)
        {
            right->child[i] = right->child[i + 1];
        }
        right->child[right->count] = nullptr;
    }

    right->count--;
}

2️⃣ 无法借用 → 执行合并(merge)

如果此时左右兄弟节点都无法提供可借的 key(即它们的 key 数量均已达到下限),则需要执行合并(merge)操作。合并的默认策略是:将当前节点与其左兄弟节点合并;若当前节点已经是最左侧子节点(不存在左兄弟),则改为与右兄弟节点合并。

这里将合并逻辑封装在 merge 函数中。该函数接收父节点指针以及参与合并的左节点下标(即以左节点为基准进行合并)。

在函数内部,首先根据下标获取参与合并的左右两个节点指针(leftright)。随后,将父节点中位于这两个节点之间的分隔 key 下移到左节点的末尾位置,作为连接左右两部分数据的"桥梁"。

接着,将右节点中的所有 key、value 以及(若为内部节点)对应的 child 指针,按顺序**追加(append)**到左节点的尾部,从而完成两个节点的数据合并。

完成节点合并后,需要对父节点进行结构更新:

  • 将父节点中该分隔 key 右侧的所有 key 和 value 整体向左移动一位
  • 同时,将对应的 child 指针数组中,从右节点开始的部分整体向左移动一位,以填补被删除的子节点位置;
  • 最终将父节点的 count 减一,并释放右节点的内存。
cpp 复制代码
void merge(Node* parent,int index)
{
    Node* left = parent->child[index];
    Node* right = parent->child[index + 1];

    left->keys[left->count] = parent->keys[index];
    left->values[left->count] = parent->values[index];
    left->count++;

    int base = left->count;

    for(int i = 0; i < right->count; i++)
    {
        left->keys[left->count] = right->keys[i];
        left->values[left->count] = right->values[i];
        left->count++;
    }

    if(!right->isleaf)
    {
        for(int i = 0; i < right->count + 1; i++)
        {
            left->child[base + i] = right->child[i];
        }
    }

    for(int i = index; i < parent->count - 1; i++)
    {
        parent->keys[i] = parent->keys[i + 1];
        parent->values[i] = parent->values[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;
}

3️⃣ 根节点的特殊处理

在完成所有调整后,需要额外处理根节点的两种特殊情况:

  1. 整棵树被删空:
    • 根节点是叶子节点且 count == 0 → 直接释放并置空。
  2. 根节点被"压缩":
    • 根节点不是叶子节点但 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;
}

源码

BTree.hpp:

cpp 复制代码
#pragma once
#include<iostream>
#include<stack>

template<typename key, typename value,size_t M>
class BtreeNode
{
    public:
    BtreeNode()
    :count(0)
    ,isleaf(true)
    {
        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];
    BtreeNode* child[M+1];
    int count;
};

template<typename key, typename value,size_t M>
class Btree
{
   using Node=BtreeNode<key,value,M>;
   public:
   Btree()
   :root(nullptr)
   {

   }
   ~Btree()
   {
        destroy_node(root);
   }
  /**
 *  在树结构中查找指定的键,并获取对应的值。
 * 
 *  k 要查找的键。
 *  v 用于存储查找到的值的引用。如果未找到,该值会被重置为默认值。
 * return bool 如果找到键返回 true,否则返回 false。
 */
bool find(const key& k, value& v)
{
    // 1. 检查树是否为空
    if (root == nullptr)
    {
        // 如果树为空,将 v 设为默认值,并返回 false
        v = value();
        return false;
    }

    // 2. 初始化一个栈,用于记录查找路径(虽然此函数内未显式使用,但可能用于后续的插入/删除操作)
    std::stack<Node*> path;

    // 3. 调用 search 函数进行查找
    // search 函数通常返回一个 pair,包含:
    //   first: 查找结束时所在的节点指针
    //   second: 键在该节点 keys 数组中的索引位置
    std::pair<Node*, int> res = search(k, path);

    Node* cur = res.first; // 获取目标节点
    int pos = res.second;  // 获取目标索引

    // 4. 判断是否找到了确切的键
    // 条件:索引 pos 在有效范围内 (pos < cur->count) 且该位置的键等于目标键 k
    if (pos < cur->count && cur->keys[pos] == k)
    {
        // 找到了:将对应的值赋给 v,并返回 true
        v = cur->values[pos];
        return true;
    }

    // 5. 未找到目标键
    v = value(); // 将 v 重置为默认值
    return false;
}

   /**
 *  向 B 树中插入一个键值对。
 * 
 * 如果键已存在,则不进行插入并返回 false。
 * 如果插入导致节点上溢(超过最大容量 M),则会自动进行节点分裂,必要时增加树的高度。
 * 
 *  k 要插入的键。
 *  v 与键关联的值。
 * return bool 插入成功返回 true,键已存在返回 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;
        return true;
    }

    // 2. 搜索插入位置
    // path 栈用于记录从根节点到插入位置路径上的所有节点,用于后续回溯处理分裂
    std::stack<Node*> path;
    // res 包含目标节点指针和键在该节点中的索引位置
    std::pair<Node*, int> res = search(k, path);

    Node* cur = res.first; // 目标节点
    int pos = res.second;  // 插入位置索引

    // 3. 检查键是否已存在
    // 如果 pos 在有效范围内且该位置的键等于 k,说明键已存在
    if (pos < cur->count && cur->keys[pos] == k)
    {
        return false; // 键已存在,插入失败
    }

    // 4. 将键值对插入到当前节点中
    // 注意:此时节点可能会溢出
    insert_into_node(cur, pos, k, v, nullptr);

    // 5. 处理节点溢出(分裂)循环
    // 只要当前节点的键数量达到最大值 M,就需要进行分裂
    while (cur->count == M)
    {
        // 计算分裂点:取中间位置的索引
        int mid = (M + 1) / 2 - 1;
        
        // 提取中间键值,这些将提升到父节点中
        value mid_value = cur->values[mid];
        key mid_key = cur->keys[mid];
        
        // 创建一个新的兄弟节点,用于存放原节点后半部分的键和子节点
        Node* brother = new Node();
        
        // 将原节点 mid 之后的键和值移动到兄弟节点中
        int j = 0;
        for (int i = mid + 1; i < M; i++)
        {
            brother->keys[j] = cur->keys[i];
            brother->values[j++] = cur->values[i];
        }
        // 更新兄弟节点的键数量
        brother->count = j;
        // 继承原节点的叶子属性
        brother->isleaf = cur->isleaf;

        // 如果当前节点不是叶子节点,还需要将子节点指针也移动过去
        if (!cur->isleaf)
        {
            j = 0;
            // 注意这里的循环条件是 i <= M,因为子节点指针比键多一个
            for (int i = mid + 1; i <= M; i++)
            {
                brother->child[j++] = cur->child[i];
            }
        }

        // 6. 调整原节点的键数量
        // 分裂后,原节点只保留前半部分(不包含 mid,因为 mid 提升到了父节点)
        cur->count = mid;

        // 7. 将中间键值提升到父节点
        // 如果路径栈为空,说明当前节点是根节点,且已经分裂,需要创建新的根节点
        if (path.empty())
        {
            Node* newroot = new Node();
            newroot->isleaf = false; // 新根节点一定不是叶子节点
            newroot->keys[0] = mid_key;
            newroot->values[0] = mid_value;
            newroot->child[0] = cur;    // 左孩子
            newroot->child[1] = brother; // 右孩子
            root = newroot;             // 更新全局根指针
            newroot->count = 1;
            break; // 分裂结束,退出循环
        }
        else
        {
            // 如果不是根节点,获取父节点
            Node* parent = path.top();
            path.pop();
            
            // 在父节点中找到插入中间键的位置
            int parent_pos = binary_search(parent->keys, mid_key, parent->count);
            
            // 将中间键值和新分裂出的兄弟节点插入到父节点中
            insert_into_node(parent, parent_pos, mid_key, mid_value, brother);
            
            // 更新 cur 指向父节点,以便在下一轮循环中检查父节点是否溢出
            cur = parent;
        }
    }

    return true;
}

/**
 *  从 B 树中删除指定的键。
 * 
 * 该函数处理了 B 树删除的所有复杂情况,包括:
 * 1. 从叶子节点直接删除。
 * 2. 从内部节点删除(需要寻找前驱节点替换)。
 * 3. 删除后的节点下溢处理(借键或合并节点)。
 * 4. 根节点的特殊处理(树的高度降低)。
 * 
 * @param k 要删除的键。
 */
void erase(const key& k)
{
    // 1. 处理空树的情况
    if (root == nullptr)
    {
        return;
    }

    // 2. 搜索目标键
    // path 栈记录从根节点到目标节点的路径,用于后续回溯处理下溢
    std::stack<Node*> path;
    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;
    }

    // 4. 处理内部节点的删除
    // 如果当前节点不是叶子节点,不能直接删除,需要用前驱(或后继)替换
    if (!cur->isleaf)
    {
        path.push(cur); // 将当前节点压入路径栈,因为之后可能需要调整它
        
        // 寻找前驱节点:即左子树中最右下的叶子节点
        Node* pred = cur->child[pos];
        while (!pred->isleaf)
        {
            path.push(pred);
            pred = pred->child[pred->count]; // 一直向右走
        }

        // 用前驱节点的最大键值替换当前要删除的键值
        // 这样就将"删除内部节点键"的问题转化为了"删除叶子节点键"的问题
        cur->keys[pos] = pred->keys[pred->count - 1];
        cur->values[pos] = pred->values[pred->count - 1];

        // 更新 cur 和 pos,指向实际要删除的那个前驱键
        cur = pred;
        pos = pred->count - 1;
    }

    // 5. 执行物理删除
    // 此时 cur 一定指向叶子节点,pos 指向要删除的键
    remove_from_node(cur, pos);

    // 6. 处理下溢
    // B 树节点要求键数量至少为 minKeys (即 ceil(M/2) - 1)
    int minKeys = (M + 1) / 2 - 1;
    
    // 从当前节点向上回溯,检查是否有节点发生下溢
    // 循环条件:当前节点不是根节点 且 当前节点键数量不足
    while (cur != root && cur->count < minKeys)
    {
        // 获取父节点
        Node* parent = path.top();
        path.pop();
        
        // 找到当前节点在父节点中的子节点索引
        int index = 0;
        while (index <= parent->count && parent->child[index] != cur)
        {
            index++;
        }

        // 尝试修复策略一:向左兄弟借键
        // 如果存在左兄弟,且左兄弟键充足(多于 minKeys)
        if (index > 0 && parent->child[index - 1]->count > minKeys)
        {
            borrow_from_left(parent, index);
        }
        // 尝试修复策略二:向右兄弟借键
        // 如果存在右兄弟,且右兄弟键充足
        else if (index < parent->count && parent->child[index + 1]->count > minKeys)
        {
            borrow_from_right(parent, index);
        }
        // 尝试修复策略三:合并节点
        // 如果左右兄弟都借不到键(都刚够 minKeys),则必须合并
        else
        {
            // 优先与左兄弟合并
            if (index > 0)
            {
                // 将当前节点与左兄弟合并,父节点中对应的分隔键会下移
                merge(parent, index - 1);
            }
            // 否则与右兄弟合并
            else if (index < parent->count)
            {
                // 将当前节点与右兄弟合并
                merge(parent, index);
            }
        }
        
        // 合并或借位操作后,父节点的键数量减少了(因为下移了一个键或者合并减少了一个子树)
        // 所以需要更新 cur 指向父节点,在下一轮循环中检查父节点是否下溢
        cur = parent;
    }

    // 7. 处理根节点的特殊情况
    // 情况 A: 根节点没有键了,且不是叶子节点(说明发生了合并,树高降低了)
    if (root->count == 0 && !root->isleaf)
    {
        Node* old = root;
        root = root->child[0]; // 唯一的子节点变成新的根
        delete old;
    }
    // 情况 B: 根节点没有键了,且是叶子节点(说明树空了)
    else if (root != nullptr && root->count == 0 && root->isleaf)
    {
        delete root;
        root = nullptr;
    }
}

   void print()
   {
       print_node(root);
   }
   void Inorder()
   {
        _Inorder(root);
   }
   Btree(const Btree&) = delete;
   Btree& operator=(const Btree&) = delete;
   private:
    /**
 * @brief 在 B 树中搜索指定的键。
 * 
 * 该函数从根节点开始向下查找。如果找到目标键,返回该节点和索引。
 * 如果未找到(到达叶子节点),返回最终到达的叶子节点和适合插入的位置索引。
 * 同时,搜索过程中经过的节点会被压入 path 栈中,用于后续的插入/删除回溯操作。
 * 
 * @param k 要查找的键。
 * @param path 引用类型的栈,用于记录从根节点到目标节点(或插入位置)的路径。
 * @return std::pair<Node*, int> 返回一个 pair:
 *         - first: 指向目标节点的指针(如果找到)或叶子节点指针(如果未找到)。
 *         - second: 键在节点 keys 数组中的索引。
 */
std::pair<Node*, int> search(const key& k, std::stack<Node*>& path)
{
    Node* cur = root; // 初始化当前节点为根节点
    int pos = 0;      // 初始化位置索引

    // 从根节点开始向下遍历树
    while (cur != nullptr)
    {
        // 1. 在当前节点的键数组中进行二分查找
        // binary_search 返回的是第一个大于等于 k 的键的位置
        pos = binary_search(cur->keys, k, cur->count);

        // 2. 检查是否找到了精确匹配的键
        // 条件:pos 在有效范围内 且 该位置的键等于 k
        if (pos < cur->count && cur->keys[pos] == k)
        {
            // 找到目标:返回当前节点指针和索引
            return std::make_pair(cur, pos);
        }

        // 3. 如果当前节点是叶子节点,说明无法继续向下查找,结束循环
        if (cur->isleaf)
        {
            break;
        }

        // 4. 准备进入下一层
        // 将当前节点压入路径栈,记录路径(用于插入/删除时的回溯)
        path.push(cur);
        
        // 根据二分查找的结果,移动到对应的子节点
        // 如果 k 小于 keys[pos],则去 child[pos];
        // 如果 k 大于 keys[pos],binary_search 逻辑决定了去 child[pos] 也是正确的
        cur = cur->child[pos];
    }

    // 5. 未找到目标键的情况
    // 返回最终到达的节点(通常是叶子节点)和 pos(表示 k 应该插入的位置)
    return std::make_pair(cur, pos);
}

   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;
   }
   void _Inorder(Node* node)
   {
       if(node==nullptr)
       {
          return;
       }
       for(int i=0;i<node->count;i++)
       {
           _Inorder(node->child[i]);
           std::cout<<node->keys[i]<<" ";
       }
         _Inorder(node->child[node->count]);    
   }
/**
 * 从左兄弟节点借一个元素到当前节点
 *  parent 父节点指针
 *  index 当前节点在父节点中的索引位置
 */
   void borrow_from_left(Node* parent,int index)
   {

    // 获取左兄弟节点和当前节点
       Node* left=parent->child[index-1];    // 左兄弟节点
       Node* cur=parent->child[index];        // 当前节点
    // 将父节点中对应的关键字和值插入到当前节点的开头
       insert_into_node(cur,0,parent->keys[index-1],parent->values[index-1],nullptr);
    // 如果当前节点不是叶子节点,需要处理子节点
       if(!cur->isleaf)
       {
        // 将当前节点的所有子节点向右移动一位
           for(int i=cur->count;i>0;i--)
           {
               cur->child[i]=cur->child[i-1];
           }
        // 将左兄弟节点的最后一个子节点移动到当前节点的第一个子节点位置
           cur->child[0]=left->child[left->count];
           left->child[left->count]=nullptr;    // 清空左兄弟节点对应的子节点指针
       }
    // 将左兄弟节点的最后一个关键字和值提升到父节点中
       parent->keys[index-1]=left->keys[left->count-1];
       parent->values[index-1]=left->values[left->count-1];
    // 减少左兄弟节点的计数
       left->count--;
   }
/**
 * 从右兄弟节点借一个键值对到当前节点
 *  parent 父节点指针
 *  index 当前节点在父节点中的索引位置
 */
   void 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->values[cur->count]=parent->values[index];
    // 如果当前节点不是叶子节点,需要处理子节点
       if(!cur->isleaf)
       {
        // 将右兄弟节点的第一个子节点赋给当前节点
           cur->child[cur->count+1]=right->child[0];
       }
       cur->count++;  // 增加当前节点的键值数量
    // 将右兄弟节点的第一个键值对提升到父节点
       parent->keys[index]=right->keys[0];
       parent->values[index]=right->values[0];
    // 移动右兄弟节点的键值对,向前填补空缺
       for(int i=0;i<right->count-1;i++)
       {
           right->keys[i]=right->keys[i+1];
           right->values[i]=right->values[i+1];
       }
    // 如果当前节点不是叶子节点,需要处理子节点
       if(!cur->isleaf)
       {
        // 移动右兄弟节点的子节点指针
           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--;
   }
/**
 * 打印B树节点的函数
 *  node 指向要打印的节点的指针
 */
   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)
         {
        // 遍历所有子节点并递归调用print_node
             for(int i=0;i<=node->count;i++)
             {
                    print_node(node->child[i]);
             }
            }
   }
   /**
 *  在有序键数组中执行二分查找。
 * 
 * 该函数查找并返回数组中第一个大于或等于目标键 k 的索引。
 * 如果数组中所有键都小于 k,则返回 count(即数组末尾的下一个位置)。
 * 
 *  keys 指向有序键数组的指针。
 *  k 要查找的目标键。
 *  count 数组中有效键的数量。
 * return int 第一个 >= k 的键的索引。如果未找到(所有键都小于 k),则返回 count。
 */
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)
        {
            // 如果中间键大于或等于目标键
            // 说明目标位置可能在 mid 或 mid 的左侧
            // 将右边界收缩至 mid
            right = mid;
        }
        else
        {
            // 如果中间键小于目标键
            // 说明目标位置一定在 mid 的右侧
            // 将左边界收缩至 mid + 1
            left = mid + 1;
        }
    }

    // 循环结束时,left == right,指向第一个 >= k 的元素
    return left;
}

  /**
 *  向 B 树节点中插入一个键值对。
 * 
 * 该函数假设节点当前未满(即调用前已检查过 count < M)。
 * 它会将 pos 位置及其之后的键和值向后移动一位,腾出空间插入新的键值对。
 * 如果提供了 rightchild(非空),说明是在内部节点插入,需要同时移动并插入子节点指针。
 * 
 *  node 目标节点指针。
 *  pos 插入位置的索引。新的键值对将插入到此索引处。
 *  k 要插入的键。
 *  v 要插入的值。
 *  rightchild 要插入的右子节点指针(仅在非叶子节点插入时使用)。
 */
void insert_into_node(Node* node, int pos, const key& k, const value& v, Node* rightchild)
{
    // 1. 移动键和值
    // 从节点末尾开始,向前遍历,直到 pos 位置
    // 将 keys[i-1] 和 values[i-1] 移动到 keys[i] 和 values[i]
    // 这相当于将 pos 及之后的元素都向后挪了一位
    for (int i = node->count; i > pos; i--)
    {
        node->keys[i] = node->keys[i - 1];
        node->values[i] = node->values[i - 1];
    }

    // 2. 插入新的键和值
    node->keys[pos] = k;
    node->values[pos] = v;

    // 3. 处理子节点指针(仅针对内部节点)
    // 如果 rightchild 不为空,说明当前节点不是叶子节点,需要调整子指针
    if (rightchild != nullptr)
    {
        // 移动子节点指针
        // 注意:子节点指针的数量比键多 1,所以循环范围是 i > pos + 1
        // 将 child[i-1] 移动到 child[i]
        for (int i = node->count + 1; i > pos + 1; i--)
        {
            node->child[i] = node->child[i - 1];
        }
        
        // 插入新的右子节点指针
        // 新键的右子节点是 rightchild,左子节点保持不变(原本就在 child[pos])
        node->child[pos + 1] = rightchild;
    }

    // 4. 更新节点的计数器
    node->count++;
}

/**
 * 合并两个子节点的函数
 *  parent 父节点指针
 *  index 需要合并的子节点在父节点中的索引位置
 */
   void merge(Node* parent,int index)
   {
    // 获取需要合并的左子节点和右子节点
       Node* left=parent->child[index];
       Node* right=parent->child[index+1];
    // 将父节点中的关键字和值移动到左子节点中
       left->keys[left->count]=parent->keys[index];
       left->values[left->count]=parent->values[index];
       left->count++;
    // 记录左子节点的初始计数,用于后续子节点指针的移动
       int base=left->count;
    // 将右子节点的所有关键字和值移动到左子节点中
       for(int i=0;i<right->count;i++)
       {
           left->keys[left->count]=right->keys[i];
           left->values[left->count]=right->values[i];
           left->count++;
       }
    // 如果右子节点不是叶子节点,则需要将其子节点指针也移动到左子节点中
       if(!right->isleaf)
       {
           for(int i=0;i<right->count+1;i++)
           {
               left->child[base+i]=right->child[i];
           }
       }
    // 将父节点中关键字和值向前移动一位,覆盖已合并的关键字
       for(int i=index;i<parent->count-1;i++)
       {
           parent->keys[i]=parent->keys[i+1];
           parent->values[i]=parent->values[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;
   }
   Node* root;

};

Test.cpp:

cpp 复制代码
#include <iostream>
#include <string>
#include "Btree.hpp"

using namespace std;

int main() {
    // M=4, Keys max=3, min=1.
    Btree<int, string, 4> tree;

    cout << "=== Phase 1: Build the Tree ===" << endl;
    int keys_to_insert[] = {10, 20, 30, 40, 50, 60, 70, 80};
    for(int k : keys_to_insert) {
        tree.insert(k, "V" + to_string(k));
    }
    cout << "Tree after inserts:" << endl;
    tree.print(); 
    // It should look roughly like:
    // [40 ] (Root)
    //  ├── [20 ]
    //  │    ├── [10 ]
    //  │    └── [30 ]
    //  └── [60 ]
    //       ├── [50 ]
    //       └── [70 80 ]

    cout << "\n=== Phase 2: Simple Leaf Delete (No Underflow) ===" << endl;
    cout << "Deleting 80..." << endl;
    tree.erase(80);
    tree.print(); // 70 80 becomes just 70

    cout << "\n=== Phase 3: Internal Node Delete (Predecessor Swap) ===" << endl;
    cout << "Deleting 60 (Internal node)..." << endl;
    tree.erase(60);
    // 60's predecessor is 50. 50 moves up, and the leaf that held 50 is deleted.
    tree.print();

    cout << "\n=== Phase 4: Delete Triggering a Merge ===" << endl;
    // With 60 gone and 50 moved up, deleting 70 might force a merge
    cout << "Deleting 70..." << endl;
    tree.erase(70);
    tree.print();

    cout << "\n=== Phase 5: Root Shrink (Height Reduction) ===" << endl;
    // Delete enough keys to force the root to merge with its children
    cout << "Deleting 40, then 30..." << endl;
    tree.erase(40);
    tree.erase(30);
    tree.print();

    cout << "\n=== Phase 6: Delete Everything ===" << endl;
    tree.erase(10);
    tree.erase(20);
    tree.erase(50);
    cout << "Tree after deleting all keys:" << endl;
    tree.print(); // Should print nothing, tree is empty

    return 0;
}

运行截图:

结语

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

相关推荐
人道领域2 小时前
【LeetCode刷题日记】:从 LeetCode 经典题看哈希表的场景化应用---数组、HashSet、HashMap 选型与算法实战
算法·leetcode·面试
承渊政道2 小时前
【优选算法】(实战攻坚BFS之FloodFill、最短路径问题、多源BFS以及解决拓扑排序)
数据结构·c++·笔记·学习·算法·leetcode·宽度优先
hero.fei2 小时前
RoaringBitmap在SpringBoot中的使用以及与BitSet对比
java·spring boot·spring
kishu_iOS&AI2 小时前
机器学习 —— 线性回归(2)
人工智能·python·算法·机器学习·线性回归
Traving Yu2 小时前
Spring源码与框架原理
java·后端·spring
NULL指向我2 小时前
信号处理学习笔记6:ADC采样线性处理实测拟合
人工智能·算法·机器学习
王家视频教程图书馆2 小时前
rust 写gui 程序 最流行的是哪个
开发语言·后端·rust
Lyyaoo.2 小时前
【JAVA基础面经】线程安全的单例模式
java·安全·单例模式
汽车仪器仪表相关领域2 小时前
NHXJ-02汽车悬架检验台 实操型实战手册
人工智能·功能测试·测试工具·算法·安全·单元测试·可用性测试