数据结构-B+树

288 阅读18分钟

 要理解 B + 树,首先要明确它的核心定位:一种为磁盘等外存设备优化的多路平衡查找树。它的设计初衷是解决 “磁盘 IO 效率低” 的痛点 —— 由于磁盘读写速度远慢于内存,B + 树通过 “降低树高、集中存储数据、优化范围查询”,最大限度减少磁盘 IO 次数,成为数据库索引、文件系统索引的核心数据结构。

在讲解原理前,我们先铺垫两个关键背景:

  1. 磁盘 IO 的特点:磁盘读写以 “块” 为单位(比如一个块 4KB),每次 IO 会读取一个完整的块到内存。树的每个节点恰好对应一个磁盘块,因此 “树的高度” 直接等于 “查询时的 IO 次数”。
  2. 与二叉树的本质区别:二叉树是 “二路” 结构,树高会随数据量呈对数增长(如 100 万数据约 20 层),导致 20 次 IO;B + 树是 “多路” 结构,一层节点可对应几十个甚至上百个子节点,树高通常仅 3-4 层(1000 万数据也只需 3-4 次 IO)。

一、B + 树的核心概念与结构

1. 关键概念:“阶”(Order)

“阶” 是 B + 树的 “容量单位”,定义了节点的最大子节点数。通常我们说 “m 阶 B + 树”,需满足两个规则:

  • 非叶子节点:最多存储 m-1 个 “索引键”,最多有 m 个子节点(因为每个键对应一个子节点的分割点)。
  • 叶子节点:最多存储 m-1 个 “数据键 + 数据地址”(或直接存数据),无子女。
  • 最小容量限制(平衡的关键):为保证树的 “平衡”,所有非根节点的键数不能少于 ⌈m/2⌉ - 1⌈⌉ 表示向上取整),子节点数不能少于 ⌈m/2⌉

例子:3 阶 B + 树(m=3)

  • 非叶子节点:最多 2 个键、3 个子节点;最少 1 个键、2 个子节点。
  • 叶子节点:最多 2 个键 + 数据;最少 1 个键 + 数据。

2. 节点的两种类型

B + 树的节点分为 “非叶子节点” 和 “叶子节点”,两者功能完全不同,这是 B + 树与 B 树的核心区别之一。

节点类型存储内容功能
非叶子节点仅存 “索引键”(无数据),每个键对应一个子节点的 “范围边界”引导查找方向,类似 “目录的目录”
叶子节点存 “数据键 + 数据地址”(或直接存数据),所有叶子节点通过 “双向链表” 连接实际存储数据,支持单值查找和范围查找

结构示意图(以 3 阶 B + 树为例):

3. B + 树的 5 个核心特性

这些特性是理解其效率的关键,必须牢记:

  1. 平衡特性:所有叶子节点在同一层,保证任何查询的 IO 次数相同(查找效率稳定)。
  2. 非叶子仅索引:非叶子节点只存索引键,不存数据 —— 这意味着同样大小的磁盘块,能存更多键,子节点数更多,树更矮(IO 更少)。
  3. 叶子存全量数据:所有数据(或数据地址)都集中在叶子节点,非叶子节点的键是叶子节点键的 “副本”(比如根节点的 3,是叶子节点中 3 的副本)。
  4. 叶子有序链表:所有叶子节点按键的顺序用双向链表连接,这是 “范围查询高效” 的核心(无需回溯树结构,直接遍历链表)。
  5. 键有序性:每个节点内的键都按升序(或降序)排列,保证查找时能通过二分法快速定位。

二、B + 树的关键操作(附 3 阶实例)

B + 树的操作围绕 “保持平衡” 展开,核心是插入时的 “分裂” 和删除时的 “合并 / 借键” 。我们以最典型的 3 阶 B + 树为例,拆解查找、插入、删除过程。

1. 查找操作:单值查找 + 范围查找

查找是 B + 树最常用的操作,分为 “单值查找” 和 “范围查找”,两者都依赖于 “键有序” 和 “叶子链表”。

我们还是以这个数据为示例

(1)单值查找:找 “键 = 5”

步骤:

  1. 从根节点开始:根节点键为 [5],5 等于 5,如果小于 5,则进入左边节点,如果大于等于 5 ,则进入右边节点,因此进入右边子节点 [7](非叶子)。
  2. 中间节点 [7]:5 小于 7,进入其对应的左侧子节点 [6](中间节点)。
  3. 中间节点 [6]:5 小于 6,进入其对应的左侧子节点 [5](叶子节点)。
  4. 叶子节点 [5]:二分法找到键 = 5,直接获取对应数据。

IO 次数:4 次(根→中间非叶子→中间非叶子→叶子),对应树高 4。

上面的示例有些过于简单,接下来展示一个相对复杂的示例

请看下面的数据

(1)单值查找:找 “键 = 12”

步骤:

  1. 从根节点开始:根节点键为 [5,7],如果查找的数值小于5,则进入左子节点,如果在5-7之间,则进入中间子节点,如果大于7,则进入右子节点。我们可以看到 12 大于 7,进入右子节点(中间节点)。
  2. 中间节点 [8,12]:查找值 12 等于索引值 12,进入右子节点(叶子节点)。
  3. 叶子节点 [12,14]:二分法找到键 = 12,直接获取对应数据。

IO 次数:3 次(根→中间非叶子→叶子),对应树高 3。

(2)范围查找:找 “键≥5 且≤9”

步骤:

  1. 先按单值查找找到 “键 = 5” 所在的叶子节点 [5]。
  2. 利用叶子节点的双向链表,依次遍历后续叶子节点:[5] → [6] → [7] → [8,9] 。
  3. 从这些叶子中提取键在 5-9 之间的数据(5、6、7、8、9)。

优势:范围查找无需回溯树,仅需 “1 次定位 + 链表遍历”,效率远高于 B 树(B 树需回溯树结构找相邻节点)。

2. 插入操作:核心是 “节点满时分裂”

核心原则

在插入任何新数据之前,必须牢记 B+ 树的几个核心特征,插入操作就是为了维护这些特征:

  1. 有序性:所有节点(内部节点的键和叶子节点的数据)必须保持有序。
  2. 平衡性:所有叶子节点必须位于同一层。
  3. 节点容量:每个节点最多包含 m 个键(或数据项),最少包含 ⌈m/2⌉ 个键(根节点除外)。

插入过程可以概括为:寻找正确的叶子节点 -> 插入数据 -> 如果节点“溢出”则分裂 -> 递归调整父节点

详细步骤

假设我们有一个阶数为 m 的 B+ 树(即每个节点最多有 m 个孩子,或 m-1 个键)。

第 1 步:查找正确的叶子节点

从根节点开始,自上而下遍历树,根据待插入的键 K 与节点内键的比较结果,选择正确的分支,直到找到应该包含 K 的那个叶子节点(Leaf Node)。

第 2 步:将键值对插入叶子节点

在找到的正确叶子节点中,将新的键 K 和其对应的数据(或指针)按顺序插入。这意味着你需要移动已有的键来保持节点内的有序性。

第 3 步:判断叶子节点是否需要分裂

插入新键后,检查该叶子节点是否已满(即键的数量是否超过了 m-1)。

  • 如果未满(键数 <= m-1):插入操作结束,树依然保持平衡。
  • 如果已满(键数 == m):需要执行分裂操作(Splitting)。

第 4 步:分裂叶子节点

  1. 创建新节点:创建一个新的叶子节点。

  2. 重新分配数据:将原已满的叶子节点中的 m 个键值对平均分配。通常的做法是:

    • 原节点保留前 ⌈m/2⌉ 个键值对。
    • 新节点接收后 ⌊m/2⌋ 个键值对。
  3. 设置链表指针:维护叶子节点之间的双向链表结构。

    • 将新节点的 next 指针指向原节点的下一个叶子节点。
    • 将原节点的 next 指针指向新节点。
    • 更新前一个叶子节点(如果存在)的 next 指针。
  4. 向上冒泡键:将新节点的第一个键(即最小的键)复制到父节点中。这个键将成为父节点中用于导航到新节点的索引。

关键点:分裂叶子节点时,复制最小键到父节点。

第 5 步:递归调整父节点

将冒泡上来的新键插入到父节点中。这个插入过程可能又会导致父节点溢出。

  1. 在父节点中插入:将第 4 步中冒泡上来的键,以及指向新子节点(刚分裂出来的叶子节点)的指针,按顺序插入到父节点中。

  2. 检查父节点是否溢出

    • 如果未满:操作结束。
    • 如果已满:父节点也需要进行分裂。

第 6 步:分裂内部节点(如果父节点溢出)

分裂内部节点的过程与分裂叶子节点类似,但有一个关键区别

  1. 创建新节点:创建一个新的内部节点。

  2. 重新分配键和指针

    • 原父节点保留前 ⌈m/2⌉ 个键和对应的指针。
    • 中间位置的键(即第 ⌈m/2⌉ 个键)不会被分配到新节点,而是被提升(Move Up) 到更上一级的父节点。
    • 新内部节点接收剩下的 ⌊m/2⌋ 个键和对应的指针。
  3. 向上冒泡键:将中间位置的键移动(不是复制)到更上一级的父节点中。

关键点:分裂内部节点时,移动中间键到父节点。

第 7 步:处理根节点分裂

如果分裂一直向上传递到根节点,并且根节点也溢出了,那么:

  1. 按照第 6 步的规则分裂根节点。
  2. 被提升的中间键会成为一个新的根节点
  3. 树的高度因此增加 1

总结与要点

操作阶段关键动作与 B 树的区别
查找找到正确的叶子节点在叶子节点插入数据
插入在叶子节点中按序插入同 B 树
分裂叶子复制新节点的最小键到父节点B 树是移动中间键到父节点
分裂内部移动中间键到父节点同 B 树
根分裂树高增加,产生新根同 B 树

核心逻辑流程


实例:插入 1→3→5→7(逐步观察)

插入 1:叶子节点为空,直接存入 [1](未满,无需操作)。

插入 3:叶子节点 [1]→[1,3](满,仍无需分裂,因为未超 2)。

插入 5:叶子节点 [1,3] 已满(最大 2 个键),需分裂

  • 步骤 1:将节点键排序后,取中间键(3)作为 “提升键”,向上插入父节点。
  • 步骤 2:原节点分裂为两个新叶子节点:[1] 和 [3,5]。
  • 步骤 3:创建父节点(根),存入提升键 [3],子节点指向 [1] 和 [3,5]。

此时树结构:

插入 7:叶子节点 [3,5] 已满,需要分裂。

  • 原先的叶子节点  [3,5] 变成  [3] →[5,7]
  • 将 5 提到 [3] 里面,变成 [3,5],无需分裂,不再进行向上查找

此时树结构:

分裂的核心逻辑:

  • 叶子节点分裂:中间键提升到父节点,原节点拆为 “左半键” 和 “右半键” 两个节点。
  • 非叶子节点分裂:逻辑同上(但无数据,仅索引键),分裂后父节点若满,递归分裂,直到根节点(根节点分裂会让树高 + 1)。

3. 删除操作:核心是 “节点不足时合并 / 借键”

核心原则

删除操作必须始终维护以下特性:

  1. 节点容量:除了根节点,每个节点(内部节点和叶子节点)必须包含至少 ⌈m/2⌉ - 1 个键(或 ⌈m/2⌉ 个指针)。对于最常见的偶数阶(如 m=4, 5),可以简单理解为最少要有 m/2(向上取整)个键
  2. 平衡性:所有叶子节点位于同一层。
  3. 有序性:所有键保持有序。

删除过程可以概括为:找到并删除数据 -> 检查节点是否“欠载” -> 通过“借用”或“合并”来修复 -> 递归调整父节点

详细步骤

假设我们有一个阶数为 m 的 B+ 树。

第 1 步:查找并删除

从根节点开始,找到包含待删除键 K 的叶子节点。在该叶子节点中删除键 K 及其对应的数据(或指针),并保持节点内键的有序性。

关键:数据只存在于叶子节点,所以删除操作总是从叶子节点开始。

第 2 步:判断叶子节点是否“欠载”

删除键 K 后,检查该叶子节点是否仍然满足最小容量要求(即当前键数 >= ⌈m/2⌉ - 1)。

  • 如果未欠载(键数 >= ⌈m/2⌉ - 1):

    • 如果 K 是当前节点的第一个键,并且它也存在于父节点中作为索引,则需要用当前节点新的第一个键去更新父节点中对应的索引
    • 否则,删除操作结束。
  • 如果欠载(键数 < ⌈m/2⌉ - 1):需要执行再平衡操作(Rebalancing)。

第 3 步:叶子节点的再平衡(借用或合并)

当一个叶子节点 L 欠载时,首先看它的直接兄弟节点(通常是左兄弟,也可以是右兄弟)是否可以借出一个键。

  1. 情况一:借用(Borrowing)

    • 条件:相邻兄弟节点有多余的键(即键数 > ⌈m/2⌉ - 1)。

    • 操作

      • 从兄弟节点那里借来一个键(从左兄弟借最大的,从右兄弟借最小的)。

      • 将借来的键插入到欠载节点 L 中,并保持有序。

      • 更新父节点:这个操作会改变兄弟节点和 L 节点的第一个键。因此,必须更新父节点中对应的索引键,使其正确反映子节点新的最小值。

      • 示例:假设 L 节点为 [5] (欠载),其左兄弟为 [1, 3, 4] (m=4,最小键数为1,它有3个,可借)。我们借走左兄弟最大的键 4

        • 左兄弟变为 [1, 3]
        • L 节点变为 [4, 5]
        • 必须将父节点中指向 L 节点的旧索引(很可能是5)更新为新的最小值4
  2. 情况二:合并(Merging)

    • 条件:相邻的兄弟节点也刚好满足最小容量要求(即键数 == ⌈m/2⌉ - 1),无法借用。

    • 操作

      • 将欠载节点 L 和它的一个兄弟节点合并成一个新的节点。

      • 删除父节点中用于分隔这两个兄弟节点的索引键。

      • 维护叶子节点之间的链表指针。

      • 合并后的节点键数刚好为 (⌈m/2⌉ - 1) * 2 + 1,对于 m=4 就是 (1 + 1 + 1) = 3,是合法的。

      • 示例:假设 L 节点为 [5],其左兄弟为 [3] (m=4,最小键数为1,都刚好满足)。我们将它们合并。

        • 新节点为 [3, 5]
        • 在父节点中,删除指向 [3] 和 [5] 的两个指针以及它们之间的分隔键(比如 4)。

第 4 步:递归调整父节点

在第 3 步的合并操作中,我们在父节点中删除了一個键和一个指针。这个删除操作可能会导致父节点本身欠载。

  1. 判断父节点是否欠载:检查父节点在删除键后是否满足最小容量要求。

    • 如果未欠载:操作结束。
    • 如果欠载:父节点(现在是一个内部节点)也需要进行再平衡。

第 5 步:内部节点的再平衡

内部节点的再平衡逻辑与叶子节点完全类似,也是先尝试借用,不行再合并。但有一些细微差别

  1. 借用(Borrowing)

    • 从兄弟节点借一个键和一个指针。

    • “旋转”操作:借用的键并不是直接拿来用。需要父节点参与。

      • 从父节点“降下”一个合适的键到欠载节点。
      • 用兄弟节点的一个键去“填补”父节点降下来的位置。
    • 示例:假设内部节点 P 为 [..., parent_key, ...],其欠载子节点 C 为 [c1, c2],C 的右兄弟 S 为 [s1, s2, s3] (富余)。

      • 将父节点中的 parent_key (它位于 C 和 S 之间) 降下到 C 节点,变成 C: [c1, c2, parent_key]
      • 将兄弟节点 S 的第一个键 s1 提升到父节点中 parent_key 原来的位置。
      • 将 S 节点的第一个指针转移给 C 节点。
  2. 合并(Merging)

    • 将欠载的内部节点 C 与一个兄弟节点 S 以及它们父节点中的分隔键合并成一个节点。
    • 这个操作会在父节点中删除一个键和一个指针,可能导致父节点继续欠载,需要继续递归向上处理。

第 6 步:处理根节点

如果合并操作一直向上传递到根节点,并且根节点只剩下一个孩子(即根节点只剩一个指针,没有键了),那么:

  • 将这个唯一的子节点设置为新的根节点。
  • 树的高度因此减少 1

实例:(阶数 m=4)

初始树结构:

删除 7

操作过程如下

  1. 删除 7:在叶子节点 [5,7,9] 中删除 7,变为 [5,9]。键数为2,未欠载2 >= 1)。但 5 不再是第一个键,第一个键仍是 5,所以父节点索引无需更新。操作结束。

结果如下:

删除 9

  1. 删除 9:在叶子节点 [5,9] 中删除 9,变为 [5]。键数为1,未欠载1 >= 1)。操作结束。

结果如下:

删除 5 (最复杂的情况)

  1. 删除 5:在叶子节点 [5] 中删除 5,变为 []。键数为0,欠载0 < 1)。

  2. 再平衡:检查其左兄弟 [1,3],它有2个键(2 > 1),可以借用

  3. 借用

    • 从左兄弟借最大的键 3
    • 左兄弟变为 [1]
    • 欠载节点变为 [3]
    • 更新父节点:这个借用的操作改变了右叶子节点的最小值(从 5 变成了 3)。因此,必须将父节点中的索引键从 5 更新为 3

结果如下:

总结与要点

操作阶段关键动作注意事项
查找删除在叶子节点中找到并删除键数据只存在于叶子节点
检查欠载检查键数是否 < ⌈m/2⌉ - 1根节点除外
再平衡先尝试借用,再考虑合并
叶子节点借用从兄弟节点借一个键必须更新父节点中的索引
叶子节点合并与兄弟节点合并在父节点中删除一个键,可能导致递归
内部节点操作借用涉及父键“旋转”,合并涉及父键“降下”比叶子节点更复杂
根节点处理若根节点无键只剩一个孩子,则树高减1

核心流程逻辑

三、B + 树的优势(对比 B 树、二叉查找树)

为什么数据库索引首选 B + 树?核心是它完美适配磁盘 IO 特性,优势体现在三方面:

对比维度B + 树B 树二叉查找树
数据存储位置仅叶子节点存数据,非叶子仅索引键所有节点都存数据所有节点存数据
树高与 IO 次数多路结构,树高极低(3-4 层),IO 次数少多路结构,但非叶子存数据,键数少,树高略高二路结构,树高极高(20 层 +),IO 次数多
范围查询效率叶子链表直接遍历,效率极高需回溯树结构找相邻节点,效率低需中序遍历,效率极低
查询稳定性所有查找都到叶子节点,IO 次数固定,稳定查找可能在非叶子节点结束,效率不稳定极端情况退化为链表,效率极低

四、B + 树的实际应用

B + 树的设计完全为 “外存优化” 服务,因此在需要高效索引的场景中无处不在:

  1. 数据库索引:MySQL 的 InnoDB、MyISAM 引擎,PostgreSQL 的默认索引都是 B + 树。其中:

    • 聚簇索引:叶子节点直接存完整数据行。
    • 非聚簇索引:叶子节点存主键地址,需二次查找(回表)。
  2. 文件系统:Linux 的 Ext3/Ext4、Windows 的 NTFS 文件系统,用 B + 树管理文件目录(目录项为索引键,文件地址为数据)。

总结

B + 树的本质是 “多路平衡查找树的外存优化版”,核心设计思路可概括为三句话:

  1. 用 “多路” 降低树高,减少磁盘 IO;
  2. 用 “非叶子仅索引” 提升节点键密度,进一步降低树高;
  3. 用 “叶子链表” 优化范围查询,适配实际业务需求。

由于B+树在实际开发中,基本上不会用到,所以在这里就不再展示代码