第 18 章 B 树 (B-Trees)

      +

      核心结论

      • 磁盘 I/O 模型:磁盘随机访问远慢于内存(毫秒 vs. 纳秒,差 5+ 个数量级);B 树通过大分支因子让单次 I/O 读一整个节点(页面),将树高降到对数 O(log_t n)。

      • B 树定义:度为 t(t ≥ 2)的 B 树满足——根至少 1 键、内部节点 [t−1, 2t−1] 键故 [t, 2t] 子树、所有叶子同深度。t=2 即 2-3-4 树。

      • 高度上界:n 键 B 树高 \(h \leq \log_t \frac{n+1}{2}\);分支因子 1000 时高 2 即可存 10 亿键。

      • 基本操作:SEARCH O(t·log_t n) CPU + O(log_t n) 磁盘;INSERT 单趟向下(预分裂满节点)O(h) 磁盘;DELETE 通过旋转/合并避免回溯 O(h) 磁盘。

      • 与红黑树对比:同 Θ(lg n) 但底数是 t(可达数百);实操中磁盘访问数从 5-10 降到 2-3,差距极大。

      • 变体:B+ 树(卫星数据全部在叶子、叶子串成链表);B* 树(节点至少 2/3 满);Bc 树(数据仅在叶子)。

      本章主旨

      本章是面向磁盘存储的平衡搜索树——B 树。核心思想是「大节点(=一页磁盘)」「少层数」「预分裂以保证单趟向下」。B 树通过把节点大小调到磁盘页大小(典型 2¹¹–2¹⁴ 字节),让每次 I/O 读完整页;分支因子自然提升到 50–2000,树高降到 2–4,使亿级数据可在 2–3 次磁盘访问内定位。B 树是数据库(MySQL InnoDB、PostgreSQL)和文件系统(ext4、NTFS、XFS)的核心索引结构。第 18.1 定义与高度分析、18.2 插入(分裂 + 单趟向下)、18.3 删除(合并 + 旋转 + 例外回溯)三节层层递进。

      一、核心概念

      本章围绕 5 个核心概念展开:先建立磁盘 I/O 模型与 B 树定义(18.1),再深入查询、插入(18.2),最后讨论删除的复杂性(18.3)。

      概念 定义 + 重要性 实现提示

      磁盘 I/O 模型

      节点等于磁盘页 (2¹¹–2¹⁴ 字节);访问时间分摊到页内多个 key 的访问 — B 树的核心动机。

      DISK-READ / DISK-WRITE 显式调用;根常驻内存;CPU 时间 ∝ t(节点内 key 数),磁盘时间 ∝ h。

      B 树结构与最小度 t

      t ≥ 2 的 B 树:非根节点 [t−1, 2t−1] 键 / [t, 2t] 子树;叶子等高。t 越大 → 分支越大、树矮。

      §18.1;键单调排列;x.key_i 充当分隔 x.c_i 与 x.c_{i+1} 的「路标」。

      B-TREE-SEARCH

      节点内线性扫描 + 按 high/low 决定递归子树;O(t h_d) CPU + O(h_d) 磁盘(h_d = 树高)。

      §18.2 伪代码 (lines 1-9);若内部节点二分搜索则 O(lg n) CPU,但磁盘访问不变。

      B-TREE-INSERT 单趟向下

      自顶向下搜索时遇满节点立即分裂;保证递归进入的子节点不满 → 单趟、无回溯。

      §18.2 B-TREE-SPLIT-CHILD + B-TREE-INSERT-NONFULL;根满时新建根使树增高 — B 树唯一增高方式。

      B-TREE-DELETE

      删除时保证后续递归的节点至少有 t 个键(多余 1);否则借兄弟或合并;可能特殊处理根节点的「塌陷」。

      §18.3 用 6 种情形;根节点变为空时让唯一子节点接管 — B 树唯一降低方式。

      二、详细笔记

      2.1 磁盘 I/O 模型与 B 树定义

      What:B 树是为磁盘设计的平衡搜索树——节点大小等于磁盘页(典型 2¹¹–2¹⁴ 字节),分支因子随 t 而非固定 2;高度 O(log_t n)。

      Why:磁盘随机访问 ≈ 8 ms,内存 ≈ 50 ns,差 5+ 个数量级(CLRS §18.1);算法时间复杂度必须按磁盘页访问次数而非 CPU 指令数计——B 树正是为此而生。

      How

      属性 形式化

      最小度 t

      根至少 1 键;其他节点 [t−1, 2t−1] 键;内部节点 [t, 2t] 子树

      高度上界

      \(h \leq \log_t \frac{n+1}{2}\)(定理 18.1)

      每页访问

      DISK-READ(x) 把节点 x 调入内存,DISK-WRITE(x) 写回

      CPU 时间

      节点内线性扫描 → O(th) = O(t log_t n)

      分支因子

      实际常 50–2000(视 key 大小)

      When:数据量超过内存(如数据库表、文件系统目录);需随机访问 + 顺序扫描;更新(插入 / 删除)与查询混合。

      Example:CLRS 图 18.3——t=1000(节点最多 1999 键 / 2000 子树)的 B 树高 2 可存超过 10⁹ 键;根常驻内存 → 任何查询 ≤ 2 次磁盘读。

      2.2 B-TREE-SEARCH 与节点分裂

      What:B-TREE-SEARCH 在节点内线性扫描 + 按 high/low 选子树递归;B-TREE-SPLIT-CHILD 把满节点(2t−1 键)一分为二,中间键上提到父节点。

      Why:插入先找到叶子位置,但满叶子无法直接插入——必须分裂;但若父节点也满,分裂会向上传播;「预分裂」策略使单趟向下即可完成。

      How

      操作 关键点

      B-TREE-SEARCH (lines 1-9)

      节点内 while 循环定位 i;DISK-READ(x.c_i) 后递归;返回 (y, i) 或 NIL

      B-TREE-SPLIT-CHILD (lines 1-20)

      y = x.c_i 满;z = ALLOCATE-NODE;z 取 y 的最大 t−1 键 + t 子树;y.key_t 上提

      B-TREE-CREATE

      分配根节点,T:root = ALLOCATE-NODE;n=0

      B-TREE-INSERT

      根满时新建根 → 高度 +1;递归 B-TREE-INSERT-NONFULL(非满)

      B-TREE-INSERT-NONFULL

      遇满子节点立即分裂 → 递归必进入非满节点;叶子直接插入

      \[\Theta(t) = \text{每次 B-TREE-SPLIT-CHILD 操作(复制 } t \text{ 键/子树)}\]

      When:插入操作的标准实现;与 search 配合构成 B 树基本 API 的两个核心。

      Example:CLRS 图 18.5 (t=4) —— 节点 y = {N,P,Q,R,S,T,U,V,W} 有 8 键 (2t−1),按 t=4 分裂:左侧 y = {N,P,Q,R},右侧 z = {T,U,V,W},中间键 S 上提到父节点。

      2.3 单趟向下插入

      What:插入时自顶向下搜索路径中遇到每个满节点立即分裂,保证递归进入的子节点始终不满 → 不需要「回溯」处理溢出。

      Why:B 树的优雅之处——传统自顶向下插入在叶子处分裂后会回溯到父节点(父节点可能因上提而变满);预分裂消除了回溯,使树只增长(顶部)不增长后再向下。

      How

      1. B-TREE-INSERT(T, k)

        • 若 T.root 满 → 新建 s 作为根,原根作为 s.c₁ → B-TREE-SPLIT-CHILD(s, 1) → 树高 +1。

        • 否则递归 B-TREE-INSERT-NONFULL(T.root, k)。

      2. B-TREE-INSERT-NONFULL(x, k)

        • 找 i = x.n 并比较;若 x 为叶子 → 直接插入;否则 DISK-READ(x.c_i)。

        • 关键步骤:若 x.c_i 满 → B-TREE-SPLIT-CHILD(x, i),并按 k 与新上提键的比较调整 i。

        • 递归到 x.c_i。

      3. 磁盘访问:O(h),其中 h = \(\lfloor \log_t \frac{n+1}{2} \rfloor\)。

      4. CPU 时间:O(th) = O(t log_t n);节点内循环 O(t) 步。

      When:插入频繁;不需要撤销 / 回滚;典型 KV 存储的写路径。

      Example:CLRS 图 18.6 (t=4) —— 根 {F,H,L} 插入新键 P 时满,分裂为 s = {H}、c₁ = {F}、c₂ = {L,…​};树高 +1。

      2.4 B-TREE-DELETE 的复杂性

      What:删除键时通过 6 种情形处理——保证递归进入的节点至少 t 个键(除了根),避免「向下删到一半发现子树键不够」的回溯。

      Why:删除比插入复杂——删除内部节点的键要用后继填补,且补上来的键可能让子树变空;必须保证处理完后整棵树仍满足 B 树不变量。

      How

      情形 处理 适用

      1

      叶子直接删

      x 是叶子

      2a

      用前驱 (左子树最大键) 填补后递归删除前驱

      x 是内部节点,左子树 ≥ t 键

      2b

      用后继 (右子树最小键) 填补后递归删除后继

      x 是内部节点,右子树 ≥ t 键

      2c

      左右子树均仅 t−1 键 → 合并 + 删键

      x 是内部节点,子树都「临界」

      3a

      左兄弟 ≥ t 键 → 从左兄弟借一个

      y 子节点仅 t−1 键,左兄弟够借

      3b

      右兄弟 ≥ t 键 → 从右兄弟借一个

      y 子节点仅 t−1 键,右兄弟够借

      3c

      左右兄弟均仅 t−1 键 → 与兄弟合并

      y 子节点 + 兄弟均不够借

      根的特殊情形:递归删除中若根变为「无键且单子」,则让唯一子节点接管(树高 −1)。

      When:与查询/插入混合的通用 API;磁盘页换入换出时需配合预读策略(如 ORDER BY 优化)。

      Example:CLRS 图 18.8 (t=3) —— 删除 F(情形 1)、删除 M(情形 2a,用前驱 L 替换)、删除 G(情形 2c:合并到 DEGJK 子树)。

      2.5 B 树变体与工程权衡

      What:B 树有许多工程变体:B+ 树(数据全在叶子、叶子链表)适合范围查询;B* 树(节点至少 2/3 满)提高空间利用率;Bc 树(数据仅在叶子)让内部节点纯索引。

      Why:变体针对不同负载设计——数据库 OLTP 用 B+ 树(范围查询 + 高扇出),日志结构存储用 LSM 树(批量写优化),但基本原理一致。

      How

      变体 关键差异

      适用负载

      B+

      数据全在叶子,叶子链表

      范围查询、OLTP 数据库

      B*

      节点至少 2/3 满,兄弟满时才分裂

      高空间利用率

      Bc

      数据仅在叶子,内部节点无卫星信息

      最大化分支因子

      LSM

      多层排序字符串表,定期合并

      When:实际系统往往不是「纯 B 树」——例如 InnoDB 用 B+ 树 + adaptive hash index;选型需考虑读写比、范围查询比例、空间预算。

      Example:PostgreSQL 用 B+ 树作为默认索引;MySQL InnoDB 表数据本身就是 B+ 树组织(clustered index);SQLite 用 B 树。

      三、关键图表

      核心公式对照表
      公式 含义

      定理 18.1

      树高上界 \(h \leq \log_t \frac{n+1}{2}\)

      节点下界

      键 [t−1, 2t−1],子树 [t, 2t]

      B-TREE-SEARCH 复杂度

      O(t·h) CPU + O(h) 磁盘

      B-TREE-INSERT 复杂度

      O(t·h) CPU + O(h) 磁盘

      B-TREE-DELETE 复杂度

      O(t·h) CPU + O(h) 磁盘

      磁盘页大小

      典型 2¹¹–2¹⁴ 字节;t 由 key 大小反推

      实测分支因子

      50–2000

      四、思维导图

      mindmap
        root((第 18 章 B 树))
          磁盘模型
            DISK-READ
            DISK-WRITE
            页=节点
            根常驻内存
          B树定义
            最小度t
            节点[t-1,2t-1]
            叶子等高
          高度上界
            logt底
            分支50-2000
            高2可存亿键
          SEARCH
            节点线性扫描
            O(t h) CPU
            O(h) 磁盘
          INSERT
            预分裂满节点
            单趟向下
            根满则分裂生根
          DELETE
            6种情形
            保证子节点≥t
            根塌陷特例
          变体
            B+树数据在叶
            B*树2/3满
            LSM写优化
          工程应用
            InnoDB
            PostgreSQL
            文件系统
      
      == 五、重点与易错点
      
      . B 树 ≠ B+ 树 ≠ B* 树 ≠ 二叉搜索树——区分清楚(CLRS 主要讲 B 树;其他变体在工程中常见)。
      . DISK-READ / DISK-WRITE 在伪代码中显式写出,与纯 CPU 算法(如 BST)不同——不要把磁盘访问数等同于 CPU 时间。
      . 节点大小选择:太大 → 浪费带宽(读取空键多);太小 → 树高增加(多次 I/O)。经验:略小于磁盘页,或用缓存行对齐。
      . 「预分裂」是 B 树的关键技巧——传统「到叶子再分裂并向上回溯」会因父节点满而引入额外分裂;预分裂保证递归永进入非满节点。
      . t ≥ 2 是 B 树的最小度;t=1 不允许(会破坏「子树数 = 键数 + 1」)。
      . 删除的 6 种情形:2a / 2b / 2c / 3a / 3b / 3c;不要把情形搞混——情形 2 和情形 3 的区别是「内部节点 vs. 叶子」。
      . 根的特殊地位:根可少于 t 个键(B 树的不变量例外);其他节点必须 ≥ t−1。
      . 高扇出 B 树的实际高度 = Θ(lg_t n);t=1000 时 n=10⁹ 高 2;这就是 B 树「亿级数据 ≤ 3 次 I/O」的来源。
      . 在 CLRS 中 B 树专门用于磁盘;在内存场景二叉树(如红黑树、AVL)更优——选型要看数据是否超内存。
      . B 树的删除操作比插入复杂得多;许多生产数据库的索引不支持原地删除,而是用 tombstone / 延迟合并。
      . B 树的并发访问复杂:SILO / Bw-tree 用 latch-coupling / 乐观并发;纯 B 树在多线程下需细粒度锁设计。
      . 「非叶节点也带卫星数据」是 CLRS 的原始 B 树定义;B+ 树把所有数据放到叶子是为了让范围查询只需在叶子链表顺序扫描。
      . CLRS 给出的 DISC-READ/DISK-WRITE 抽象简化为「一次访问调入一整页」;现代 SSD 则应按 4 KB / 8 KB 页对齐设计。