相关笔记

概览

本节介绍如何从B树中删除一个关键字,这是B树操作中最复杂的过程。与插入操作只需处理分裂不同,删除操作需要处理借关键字合并两种修复操作,且合并可能沿搜索路径向上传播。核心知识点包括:

  • 情况1:关键字在叶节点中——直接删除
  • 情况2:关键字在内部节点中——用前驱或后继替换后递归删除
  • 情况3a/3b:下降时目标子节点关键字不足——从兄弟节点借关键字(旋转)
  • 情况3c:下降时目标子节点关键字不足且兄弟也不足——合并节点

知识结构总览

flowchart TD
    A["18.3 从B树中删除关键字"] --> B["关键字在节点x中"]
    A --> C["关键字不在节点x中"]
    A --> D["辅助操作"]

    B --> B1["情况1: x是叶节点"]
    B --> B2["情况2: x是内部节点"]
    B1 --> B1a["直接删除关键字k"]
    B2 --> B2a["用前驱y替换k"]
    B2 --> B2b["用后继y替换k"]
    B2a --> B2c["递归删除y"]
    B2b --> B2c

    C --> C1["确定下降的子节点c_i"]
    C --> C2["检查c_i是否有t个关键字"]
    C2 --> C3a["情况3a: 左兄弟>=t个关键字"]
    C2 --> C3b["情况3b: 右兄弟>=t个关键字"]
    C2 --> C3c["情况3c: 两兄弟都只有t-1个"]
    C3a --> C3a1["从左兄弟借关键字(旋转)"]
    C3b --> C3b1["从右兄弟借关键字(旋转)"]
    C3c --> C3c1["合并节点"]

    D --> D1["B-TREE-DELETE"]
    D --> D2["DISK-READ / DISK-WRITE"]

核心思想

核心思路

B树删除的核心挑战在于:删除后必须保证所有节点(除根外)至少有 个关键字,以维持B树性质。这与插入时需要保证节点不超过 个关键字形成对称。

删除操作分为两大类决策:

  1. 找到了关键字:如果在叶节点就直接删除;如果在内部节点就转化为对前驱/后继的删除
  2. 需要继续下降:在下降到子节点之前,必须确保目标子节点至少有 个关键字,否则需要通过借关键字或合并来”预备”——这保证了递归删除时不会遇到关键字不足的节点

这个”先预备再下降”的策略是保证删除正确性的关键,类似于插入时”先分裂再下降”的策略。

删除操作的分类体系

B树的删除操作可以系统地分为以下几种情况:

第一类:关键字 在当前节点

  • 情况1 是叶节点

    • 直接从 中删除 及其对应的指针
    • 这是唯一真正执行删除操作的情况
  • 情况2 是内部节点

    • 找到 的前驱 左侧子树中的最大关键字)或后继 右侧子树中的最小关键字)
    • (或 )替换 中的位置
    • 然后递归地从对应子树中删除 (或
    • 由于 一定在叶节点层(或在足够深的子树中),递归最终会到达情况1

第二类:关键字 不在当前节点

需要确定 可能在哪个子节点 中,但在下降到 之前,必须确保 至少有 个关键字。

  • 情况3a 只有 个关键字,但左兄弟 至少有 个关键字

    • 从左兄弟”借”一个关键字:将父节点中分隔 的关键字 下移到 ,将 中的最大关键字上移到父节点
    • 这本质是一次右旋转
  • 情况3b 只有 个关键字,但右兄弟 至少有 个关键字

    • 从右兄弟”借”一个关键字:将父节点中分隔 的关键字 下移到 ,将 中的最小关键字上移到父节点
    • 这本质是一次左旋转
  • 情况3c 只有 个关键字,且两个兄弟都只有 个关键字

    • 与一个兄弟(如 )合并:父节点中分隔它们的关键字 下移作为合并后节点的中间关键字
    • 合并后节点有 个关键字(恰好满)
    • 如果父节点因此关键字不足,则合并操作可能沿路径向上传播

B-TREE-DELETE 伪代码

算法执行流程

  1. 在当前节点 x 中搜索关键字 k
  2. k 在 x 中
    • 情况1(x 是叶节点):直接删除 k
    • 情况2(x 是内部节点):用前驱后继替换 k,然后递归删除前驱/后继
  3. k 不在 x 中
    • 确定应下降的子节点 x.c[i]
    • 若子节点关键字不足(t-1 个):
      • 情况3a:从左兄弟借关键字(旋转)
      • 情况3b:从右兄弟借关键字(旋转)
      • 情况3c:与兄弟合并(父节点关键字下移)
    • 递归进入子节点继续删除
flowchart TD
    A["输入: 节点 x, 关键字 k"] --> B["在 x 中搜索 k"]
    B --> C{"k 在 x 中?"}
    C -- 是 --> D{"x 是叶节点?"}
    D -- 是 --> E["情况1: 直接删除 k"]
    D -- 否 --> F["情况2: 用前驱或后继替换 k"]
    F --> G["递归删除前驱或后继"]
    C -- 否 --> H{"x 是叶节点?"}
    H -- 是 --> I["k 不在树中, 返回"]
    H -- 否 --> J["确定子节点 x.c[i]"]
    J --> K{"x.c[i].n == t-1? 关键字不足?"}
    K -- 否 --> L["递归 B-TREE-DELETE(x.c[i], k)"]
    K -- 是 --> M{"左兄弟 >= t 个关键字?"}
    M -- 是 --> N["情况3a: 从左兄弟借关键字"]
    M -- 否 --> O{"右兄弟 >= t 个关键字?"}
    O -- 是 --> P["情况3b: 从右兄弟借关键字"]
    O -- 否 --> Q["情况3c: 合并节点"]
    N --> L
    P --> L
    Q --> L
B-TREE-DELETE(x, k)
1  i = 1
2  while i <= x.n and k > x.key[i]
3      i = i + 1
4  if i <= x.n and k == x.key[i]          // 情况1或情况2:k在x中
5      if x.leaf                           // 情况1:x是叶节点
6          for j = i to x.n - 1
7              x.key[j] = x.key[j+1]       // 删除k
8          x.n = x.n - 1
9          DISK-WRITE(x)
10     else                                // 情况2:x是内部节点
11         if x.c[i].n >= t                // 情况2a:左子树有足够关键字
12             y = B-TREE-SEARCH-PREDECESSOR(x, i)
13             x.key[i] = y.key[y.n]       // 用前驱替换k
14             DISK-WRITE(x)
15             B-TREE-DELETE(y, y.key[y.n]) // 递归删除前驱
16         elseif x.c[i+1].n >= t          // 情况2b:右子树有足够关键字
17             z = B-TREE-SEARCH-SUCCESSOR(x, i)
18             x.key[i] = z.key[1]         // 用后继替换k
19             DISK-WRITE(x)
20             B-TREE-DELETE(z, z.key[1])   // 递归删除后继
21         else                            // 情况2c:两个子树都只有t-1个关键字
22             // 合并k与两个子树
23             y = x.c[i]
24             z = x.c[i+1]
25             y.key[t] = k                // 父节点的k下移
26             for j = 1 to z.n
27                 y.key[t+j] = z.key[j]   // 复制z的关键字到y
28                 y.c[t+j] = z.c[j]       // 复制z的子指针到y
29             y.c[2t] = z.c[z.n+1]
30             y.n = 2t - 1
31             for j = i to x.n - 1
32                 x.key[j] = x.key[j+1]   // 从x中删除k
33                 x.c[j+1] = x.c[j+2]
34             x.n = x.n - 1
35             DISK-WRITE(y)
36             DISK-WRITE(x)
37             if x.n == 0                 // 根节点变空
38                 // y成为新的根
39             B-TREE-DELETE(y, k)          // 递归删除合并后的节点中的k
40 else                                     // 情况3:k不在x中,需要下降
41     if x.leaf                            // k不在树中
42         return
43     flag = (i > x.n)                    // 是否需要检查最后一个子节点
44     DISK-READ(x.c[i])
45     if x.c[i].n == t - 1                // 子节点关键字不足
46         if i > 1 and x.c[i-1].n >= t    // 情况3a:从左兄弟借
47             // 左旋转:x.c[i-1]的最大关键字上移,x.key[i-1]下移
48             y = x.c[i-1]
49             for j = x.c[i].n downto 1
50                 x.c[i].key[j+1] = x.c[i].key[j]
51                 x.c[i].c[j+1] = x.c[i].c[j]
52             x.c[i].c[1] = x.c[i].c[0]
53             x.c[i].key[1] = x.key[i-1]
54             x.c[i].n = x.c[i].n + 1
55             x.key[i-1] = y.key[y.n]
56             y.n = y.n - 1
57             DISK-WRITE(y)
58             DISK-WRITE(x.c[i])
59             DISK-WRITE(x)
60         elseif i <= x.n and x.c[i+1].n >= t  // 情况3b:从右兄弟借
61             // 右旋转:x.c[i+1]的最小关键字上移,x.key[i]下移
62             z = x.c[i+1]
63             x.c[i].key[x.c[i].n+1] = x.key[i]
64             x.c[i].c[x.c[i].n+1] = z.c[0]
65             x.c[i].n = x.c[i].n + 1
66             x.key[i] = z.key[1]
67             for j = 1 to z.n - 1
68                 z.key[j] = z.key[j+1]
69                 z.c[j-1] = z.c[j]
70             z.c[z.n-1] = z.c[z.n]
71             z.n = z.n - 1
72             DISK-WRITE(z)
73             DISK-WRITE(x.c[i])
74             DISK-WRITE(x)
75         else                                     // 情况3c:合并
76             if i > 1                              // 与左兄弟合并
77                 y = x.c[i-1]
78                 z = x.c[i]
79                 y.key[t] = x.key[i-1]
80                 for j = 1 to z.n
81                     y.key[t+j] = z.key[j]
82                     y.c[t+j-1] = z.c[j-1]
83                 y.c[2t-1] = z.c[z.n]
84                 y.n = 2t - 1
85                 for j = i-1 to x.n - 1
86                     x.key[j] = x.key[j+1]
87                     x.c[j+1] = x.c[j+2]
88                 x.n = x.n - 1
89             else                                   // 与右兄弟合并
90                 z = x.c[i+1]
91                 y = x.c[i]
92                 y.key[t] = x.key[i]
93                 for j = 1 to z.n
94                     y.key[t+j] = z.key[j]
95                     y.c[t+j] = z.c[j]
96                 y.c[2t] = z.c[z.n+1]
97                 y.n = 2t - 1
98                 for j = i to x.n - 1
99                     x.key[j] = x.key[j+1]
100                    x.c[j+1] = x.c[j+2]
101                x.n = x.n - 1
102            DISK-WRITE(y)
103            DISK-WRITE(x)
104            if x.n == 0                           // 根节点变空
105                // y成为新的根
106            x = y                                  // 继续从合并后的节点下降
107     B-TREE-DELETE(x.c[i], k)

B-TREE-DELETE

输入: B树的根节点 ,待删除关键字 输出: 从B树中删除 ,保持B树性质

算法策略: 递归地从根向叶搜索 ,在每一步确保下降到的子节点至少有 个关键字。如果找到 ,根据 所在节点是叶节点还是内部节点采取不同策略。删除操作保证B树的所有性质在完成后仍然成立。

删除操作的具体示例

示例:从B树中删除关键字的完整过程

考虑一棵最小度 的B树(即2-3-4树),执行删除操作的过程:

初始B树(高度为2):

        [M|T]
      /   |   \
  [C|F] [P] [W]
  / | \   |    |
 A D E G J Q X Y

删除 D(情况1——叶节点直接删除):

  • D 在叶节点 [C|F] 中,直接删除
  • 删除后 [C|F] 变为 [C|F](仍满足至少 个关键字)
  • 结果:叶节点变为 [C|F],D 被移除

删除 M(情况2——内部节点,用后继替换):

  • M 在内部节点 [M|T] 中
  • M 的后继是 P(右子树中的最小值)
  • 用 P 替换 M,然后递归删除 P
  • 递归删除 P 时,P 在叶节点 [P] 中(情况1),直接删除
  • 结果:内部节点变为 [P|T],P 的叶节点被删除

删除 T(情况3c——合并):

  • T 在根节点 [P|T] 中
  • T 的左子树有足够关键字,用前驱替换 T
  • 但假设左子树关键字不足,则需要合并

复杂度分析

删除操作的时间复杂度

磁盘I/O次数: ,其中 为B树的高度

CPU时间:

分析过程:

  1. B-TREE-DELETE 从根向叶递归下降,最多访问 个节点,每次访问需要 次磁盘I/O(DISK-READ)
  2. 在每个节点上,扫描关键字需要 时间(因为节点最多有 个关键字)
  3. 借关键字(旋转)操作需要移动 个关键字和指针
  4. 合并操作需要移动 个关键字和指针
  5. 合并可能沿路径向上传播,但每次合并使路径上的节点关键字数减少,总共最多 次合并

因此总CPU时间为 。由于 ,总时间为

为常数时(如 ),删除时间为

删除 vs 插入的对称性

B树的删除和插入操作具有优美的对称性。插入的触发条件是节点超过 个关键字,修复操作只有分裂(1种),传播方向向上。删除的触发条件是节点少于 个关键字,修复操作有借关键字合并(2种),同样向上传播。两者的预处理策略也对称:插入在下降前检查子节点是否已满,删除在下降前确保子节点至少有 个关键字。时间复杂度均为 ,但删除比插入更复杂,因为需要更多的条件判断。


补充理解与拓展

补充:工程实践中的简化策略

来源: PostgreSQL HOT(Heap-Only Tuples) 技术;LevelDB/RocksDB 工程实践

在实际的数据库系统中,B树的删除操作通常采用以下简化策略:

  1. 标记删除 + 惰性合并:许多数据库系统(如 PostgreSQL)不立即物理删除B树中的条目,而是将其标记为”已删除”(tombstone)。在后续的维护操作(如 VACUUM)中批量清理这些标记。这种方式将删除的代价摊还到后续操作中,避免删除时的合并开销影响在线事务的响应时间。PostgreSQL 的 HOT(Heap-Only Tuples) 技术进一步优化了这一过程:当删除的元组只被索引引用而不被其他元组引用时,可以直接在堆中重用空间,无需修改索引。

  2. B+树删除简化:在B+树中,所有数据都存储在叶节点,内部节点只存储用于路由的键值。因此,B+树的删除操作比B树更简单——内部节点不需要处理数据指针的删除,只需处理路由键的调整。叶节点的删除逻辑与B树类似,但不需要考虑内部节点中同时存储数据的情况。

  3. 批量删除优化:LevelDB 和 RocksDB 使用 Bloom Filter 来减少不必要的磁盘读取。在删除操作前,先通过 Bloom Filter 判断关键字是否可能存在于B树中,如果 Bloom Filter 返回”不存在”,则可以直接跳过磁盘读取,避免昂贵的I/O操作。此外,这些系统还使用写前日志(WAL)来保证删除操作的原子性和持久性。

  4. 延迟合并策略:一些系统(如 SQLite 的 B-tree 实现)采用”延迟合并”策略:当节点关键字不足时,不立即合并,而是标记该节点为”欠满”状态。只有当后续操作再次访问该节点时,才执行合并。这种方式减少了合并操作的频率,但可能导致B树在一段时间内不完全满足B树性质。

补充:合并操作的向上传播

来源: 算法导论(第4版)18.3节

B树删除中最需要注意的现象是合并的向上传播

当情况3c发生时,两个兄弟节点与父节点的一个关键字合并。这导致父节点的关键字数减少1。如果父节点因此关键字数低于 ,则需要在递归返回时对父节点执行同样的修复操作(借关键字或合并)。

在最坏情况下,合并可能从叶节点一直传播到根节点:

  • 叶节点合并 → 父节点关键字不足 → 父节点与兄弟合并 → 祖父节点关键字不足 → … → 根节点
  • 如果根节点也发生合并(根节点只剩一个关键字),则根节点被其唯一的子节点替代,B树的高度减1

这与插入时分裂的向上传播形成对称:插入可能使B树高度增加1,删除可能使B树高度减少1。

关键观察: 合并传播最多经过 层,因此不会影响删除操作的 渐近复杂度。


易混淆点与辨析

误区:删除操作总是需要合并

错误理解: “当子节点关键字不足时,总是需要将两个兄弟合并”

正确理解: 当子节点关键字不足(只有 个)时,优先尝试从兄弟借关键字(情况3a/3b),只有当两个兄弟都只有 个关键字时才执行合并(情况3c)。借关键字操作不会减少父节点的关键字数,因此不会触发向上传播,代价更低。

记忆方法: “先借后合”——就像缺钱时先找朋友借,借不到才考虑变卖资产(合并)。

误区:前驱和后继一定在叶节点中

错误理解: “情况2中,前驱和后继一定在叶节点中,所以递归删除一定是情况1”

正确理解: 在标准B树中,前驱(左子树的最大关键字)和后继(右子树的最小关键字)确实一定在叶节点中。这是因为前驱是通过不断走向右子节点找到的,最终到达的叶节点中包含前驱。但在B+树中情况有所不同。此外,在情况2c中(两个子树都只有 个关键字),需要先将两个子树与关键字 合并,然后在合并后的节点中递归删除 ,此时 已经在叶节点层了。

辨析: 在B树中,前驱/后继一定在叶节点中。但在一般化的讨论中,需要注意B树和B+树的区别。

误区:删除后B树高度不会改变

错误理解: “删除操作不会改变B树的高度”

正确理解: 删除操作可能使B树高度减少1。当合并操作传播到根节点,且根节点只有1个关键字时,根节点与其两个子节点合并,原来的根节点被丢弃,其中一个子节点成为新的根节点,高度减1。

对比: 插入操作可能使高度增加1(根节点分裂),删除操作可能使高度减少1(根节点合并)。


习题精选

题号题目描述难度
18.3-1从图18.8(f)的B树中依次删除 C、P、V⭐⭐
18.3-2写出 B-TREE-DELETE 的伪代码⭐⭐⭐

教材原文

CLRS 第4版 18.3节原文(中文翻译)

从B树中删除关键字 的过程 B-TREE-DELETE 以一个指向根节点 的指针和关键字 作为输入。该过程保证在递归调用自身时,它所下降到的节点至少有 个关键字,从而使得合并(如果需要的话)不会沿树向下传播。B-TREE-DELETE 的工作方式如下:

  1. 如果关键字 在节点 中且 是叶节点,则从 中删除
  2. 如果关键字 在节点 中且 是内部节点,则执行以下操作:
    • (a) 如果 前面的子节点 至少有 个关键字,则找到 在以 为根的子树中的前驱 ,递归地删除 ,并用 替换 中的
    • (b) 对称地,如果 后面的子节点 至少有 个关键字,则找到 在以 为根的子树中的后继 ,递归地删除 ,并用 替换 中的
    • (c) 如果 都只有 个关键字,则将 合并到 中,使得 包含 个关键字,然后释放 并递归地从 中删除
  3. 如果关键字 不在内部节点 中,则确定包含 的子树的根 。如果 只有 个关键字,则执行步骤 3a 或 3b 或 3c 以保证我们下降到一个至少包含 个关键字的节点。然后,通过在 的某个适当的子节点上递归调用 B-TREE-DELETE 来完成删除。

B-TREE-DELETE 过程至多需要 次磁盘I/O,其中 是B树的高度,因为在递归调用之间至多需要 次 DISK-READ 和 DISK-WRITE 调用。所需的CPU时间为


参见Wiki

第18章-B树 从B树中删除关键字