算法导论第四版学习(36)
第18章:B 树(B-Trees)详细理解
1. B 树的设计目的
B 树是一种平衡搜索树(balanced search tree),
但它不是为了在内存中高效访问而设计的,而是为了在**磁盘(disk)或固态硬盘(SSD)这种外部存储设备(secondary storage)**上高效操作。
- 红黑树、AVL 树等 → 在**主存(RAM)**中运行;
- B 树 → 设计用于磁盘存储;
- 原因:磁盘访问(I/O)比内存慢约 10⁵ ~ 10⁶ 倍。
B 树通过**减少磁盘访问次数(disk accesses)**来提高效率。
换句话说,B 树的目标是让“每次磁盘读取能获得更多信息”,从而减少 I/O 次数。
2. B 树与红黑树的区别
| 项目 | 红黑树 | B 树 |
|---|---|---|
| 每个节点的子树数 | 最多 2 个 | 可有 3 ~ 数千个 |
| 节点存储位置 | 主存(RAM) | 磁盘(Disk) |
| 平衡性质 | 严格二叉平衡 | 多叉平衡 |
| 树高 | O(log2n)O(\log_2 n)O(log2n) | O(logtn)O(\log_t n)O(logtn)(更低) |
| 优点 | CPU 计算快 | 减少磁盘I/O |
| 应用 | 编译器、内存索引 | 数据库、文件系统 |
B 树的**分支因子(branching factor)**很大(50~2000),
意味着它的高度远小于红黑树。
例如:
- 红黑树高 ≈log2n\approx \log_2 n≈log2n
- B 树高 ≈log1000n\approx \log_{1000} n≈log1000n
这使得它查找一个元素只需要极少的磁盘访问。
3. B 树的基本思想
B 树是对二叉搜索树(BST)的自然推广。
在二叉搜索树中:
- 每个节点包含 1 个键(key)
- 有 2 个子树(左、右)
在 B 树中: - 每个节点可包含 x.nx.nx.n 个键
- 有 x.n+1x.n + 1x.n+1 个子树(children)
4. 搜索过程举例
假设一个内部节点 xxx 含有 x.n=3x.n = 3x.n=3 个键:
x.key1<x.key2<x.key3
x.key_1 < x.key_2 < x.key_3
x.key1<x.key2<x.key3
它会有 4 个子节点:
x.c1,x.c2,x.c3,x.c4
x.c_1, x.c_2, x.c_3, x.c_4
x.c1,x.c2,x.c3,x.c4
搜索一个键 kkk 的过程如下:
- 如果 k<x.key1k < x.key_1k<x.key1 → 去 x.c1x.c_1x.c1
- 如果 x.key1≤k<x.key2x.key_1 \le k < x.key_2x.key1≤k<x.key2 → 去 x.c2x.c_2x.c2
- 如果 x.key2≤k<x.key3x.key_2 \le k < x.key_3x.key2≤k<x.key3 → 去 x.c3x.c_3x.c3
- 如果 k≥x.key3k \ge x.key_3k≥x.key3 → 去 x.c4x.c_4x.c4
叶节点不含子节点。
5. 为什么要单独分析“磁盘访问”?
磁盘的访问速度远慢于内存,因为磁盘有机械运动延迟:
- 盘片旋转延迟(rotational latency)
- 7200 RPM 意味着 1 转需要约 8.338.338.33 毫秒
- 磁头移动延迟(seek time)
- 平均约 444 毫秒
相比之下:
- 平均约 444 毫秒
- 主存访问 ≈ 505050 纳秒
- 磁盘访问 ≈ 888 毫秒
- 差距约为 10510^5105 倍!
因此:
我们在分析 B 树时要分别计算:
- 磁盘访问次数(Disk I/O)
- CPU 计算时间(CPU Time)
通常磁盘 I/O 是主要瓶颈。
6. 磁盘块(Disk Block)与 B 树节点的关系
- 磁盘按**块(block)**为单位读写;
- 一次读写通常为 512∼4096512 \sim 4096512∼4096 字节;
- 所以:
- B 树的一个节点 ≈ 一个磁盘块的大小;
- 每次访问一个节点就等价于一次磁盘读写。
因此:
B 树节点应尽可能“大”,以便每次 I/O 读写更多信息。
7. B 树的定义
一个 B 树 TTT 满足以下性质:
- 每个节点 xxx 有以下属性:
- x.nx.nx.n:当前存储的键数量;
- x.key1,x.key2,…,x.keyx.nx.key_1, x.key_2, \dots, x.key_{x.n}x.key1,x.key2,…,x.keyx.n:单调递增;
- x.leafx.leafx.leaf:布尔值,表示是否为叶节点。
- 每个内部节点 xxx 还包含 x.n+1x.n + 1x.n+1 个孩子指针:
x.c1,x.c2,…,x.cx.n+1 x.c_1, x.c_2, \dots, x.c_{x.n + 1} x.c1,x.c2,…,x.cx.n+1 - 键值划分子树范围:
- 若 kik_iki 是第 iii 个子树中的任意键,则满足:
k1≤x.key1≤k2≤x.key2≤⋯≤x.keyx.n≤kx.n+1 k_1 \le x.key_1 \le k_2 \le x.key_2 \le \dots \le x.key_{x.n} \le k_{x.n+1} k1≤x.key1≤k2≤x.key2≤⋯≤x.keyx.n≤kx.n+1
- 若 kik_iki 是第 iii 个子树中的任意键,则满足:
- 所有叶节点深度相同(B 树是严格平衡的)。
- 最小度数(minimum degree) t≥2t \ge 2t≥2 控制节点容量:
- 每个非根节点至少有 t−1t - 1t−1 个键;
- 每个节点至多有 2t−12t - 12t−1 个键;
- 根节点至少有 1 个键;
- 若节点正好有 2t−12t - 12t−1 个键,则称该节点 满(full)。
8. 树高的上界定理
定理 18.1
若 n≥1n \ge 1n≥1,则任意含 nnn 个键、最小度数为 t≥2t \ge 2t≥2 的 B 树 TTT 的高度 hhh 满足:
h≤logtn+12 h \le \log_t \frac{n + 1}{2} h≤logt2n+1
证明思路:
- 根节点至少含 1 个键;
- 每个非根节点至少含 t−1t - 1t−1 个键;
- 每个内部节点至少有 ttt 个孩子;
- 若高度为 hhh,则最小键数:
nmin=1+(t−1)∑i=1h2ti−1 n_{\min} = 1 + (t - 1)\sum_{i=1}^{h} 2t^{i-1} nmin=1+(t−1)i=1∑h2ti−1
根据等比数列求和:
nmin=1+2(t−1)th−1t−1=2th−1 n_{\min} = 1 + 2(t - 1)\frac{t^h - 1}{t - 1} = 2t^h - 1 nmin=1+2(t−1)t−1th−1=2th−1
所以:
th≤n+12 t^h \le \frac{n + 1}{2} th≤2n+1
取对数得:
h≤logtn+12 h \le \log_t \frac{n + 1}{2} h≤logt2n+1
结论:
B 树高度增长为 O(logtn)O(\log_t n)O(logtn),
因为 ttt 一般很大(例如 100 或 1000),
所以树高非常低,只需很少的磁盘访问。
9. 实际意义举例
例如一个最小度数 t=1000t = 1000t=1000 的 B 树:
- 若根节点常驻内存;
- 树高 h=2h = 2h=2;
- 可存储超过 10 亿(10910^9109) 个键;
- 搜索任意键仅需 2 次磁盘访问。
这就是数据库和文件系统偏爱 B 树的原因。
10. 与变体的关系
| 名称 | 特点 |
|---|---|
| B 树 | 原始形式,允许数据存在所有节点 |
| B⁺ 树 | 所有数据都在叶子节点 |
| B* 树 | 节点至少 2/3 满,更紧凑 |
| Bc 树 | 内部节点仅存索引,叶子存数据 |
总结
| 关键点 | 说明 |
|---|---|
| 设计目的 | 减少磁盘访问次数 |
| 节点大小 | 等于磁盘块大小 |
| 平衡性 | 所有叶子深度相同 |
| 度数 t | 控制最小与最大键数量 |
| 树高公式 | h≤logtn+12h \le \log_t \frac{n+1}{2}h≤logt2n+1 |
| 主要优势 | 低树高 → 少磁盘 I/O |
| 应用 | 数据库索引、文件系统目录等 |
18.1-1 为什么最小度数 t=1t = 1t=1 不被允许?
在 B 树中,每个非根节点至少必须有 t−1t - 1t−1 个关键字(keys),也就是说,每个非根节点至少要有 ttt 个子女。
如果 t=1t = 1t=1,那么每个非根节点至少有 t−1=0t - 1 = 0t−1=0 个关键字,这样会导致:
- 内部节点可能没有关键字;
- 树结构无法起到“分割关键字范围”的作用;
- 形如“链表”的退化结构。
换句话说,如果 t=1t = 1t=1,则 B 树的性质(每个节点有一定最小容量和最大容量)完全失效。
因此要求 t≥2t \ge 2t≥2。
结论:
B树的最小度数 ttt 必须 ≥2\ge 2≥2,否则节点无法维持平衡或分割区间。
18.1-2 图 18.1 中的树,在什么 ttt 值下是合法的 B 树?
图18.1中的每个节点含有不同数量的关键字。
假设最少关键字数为 nminn_{\min}nmin,最多关键字数为 nmaxn_{\max}nmax。
B树规定:
t−1≤nx≤2t−1
t - 1 \le n_x \le 2t - 1
t−1≤nx≤2t−1
为了使所有节点都满足这个范围,我们需要找到最小的 ttt 使得:
t−1≤nmin,nmax≤2t−1
t - 1 \le n_{\min}, \quad n_{\max} \le 2t - 1
t−1≤nmin,nmax≤2t−1
根据图中的结构(假设各节点关键字数在 2~5 之间),则:
t−1≤2⇒t≥3
t - 1 \le 2 \Rightarrow t \ge 3
t−1≤2⇒t≥3
5≤2t−1⇒t≥3
5 \le 2t - 1 \Rightarrow t \ge 3
5≤2t−1⇒t≥3
结论:
当 t=3t = 3t=3 时,图 18.1 的树是一个合法的 B 树。
18.1-3 画出所有能存储关键字 1,2,3,4,5{1, 2, 3, 4, 5}1,2,3,4,5 的合法最小度数为 t=2t = 2t=2 的 B 树
B 树性质(t=2t = 2t=2):
- 每个节点最多 2t−1=32t - 1 = 32t−1=3 个关键字;
- 除根外每个节点至少 t−1=1t - 1 = 1t−1=1 个关键字;
- 所有叶子在同一深度。
可能的合法 B 树
(1) 高度 h=1h = 1h=1:
如果根节点本身能存放所有关键字(≤3个),但 1,2,3,4,5{1,2,3,4,5}1,2,3,4,5 共5个关键字 > 3,不行。
(2) 高度 h=2h = 2h=2:
根节点必须分裂。
- 根含 222 个关键字(例如 2,42, 42,4);
- 子节点分别存放区间:
- 左子:1{1}1
- 中子:3{3}3
- 右子:5{5}5
如下:
[2 | 4]
/ | \
[1] [3] [5]
也可以选择其他划分(例如根 [3],左 [1,2],右 [4,5])。
结论:
所有合法结构的高度为 h=2h = 2h=2,根节点包含 111 或 222 个关键字,叶子节点包含 111~333 个关键字,所有叶子同层。
18.1-4 给定最小度数 ttt,求高度为 hhh 的 B 树最多能存储多少关键字?
B树中每个节点最多有 2t−12t - 12t−1 个关键字。
若每一层的节点都“满”,则:
- 根节点关键字数:2t−12t - 12t−1
- 子节点数:2t2t2t
- 第 iii 层的节点数:(2t)i(2t)^i(2t)i
- 每个节点有 2t−12t - 12t−1 关键字
总关键字数为:
Nmax(h)=(2t−1)∑i=0h(2t)i N_{\max}(h) = (2t - 1) \sum_{i=0}^{h} (2t)^i Nmax(h)=(2t−1)i=0∑h(2t)i
化简:
Nmax(h)=(2t−1)⋅(2t)h+1−12t−1=(2t)h+1−1 N_{\max}(h) = (2t - 1) \cdot \frac{(2t)^{h+1} - 1}{2t - 1} = (2t)^{h+1} - 1 Nmax(h)=(2t−1)⋅2t−1(2t)h+1−1=(2t)h+1−1
结论:
高度为 hhh、最小度数为 ttt 的 B 树最多能存储:
Nmax(h)=(2t)h+1−1 N_{\max}(h) = (2t)^{h+1} - 1 Nmax(h)=(2t)h+1−1
18.1-5 如果让红黑树中的每个黑节点吸收其红色子节点(并合并其子女),会得到什么结构?
红黑树的性质:
- 黑节点的路径高度一致;
- 红节点不能相邻;
- 红节点总是黑节点的子节点。
如果“吸收”红节点: - 黑节点的键数量增加(每吸收一个红子节点,就把它的键合并进父节点);
- 一个黑节点可能拥有 2~4 个子节点(因为一个黑节点最多有两个红子节点)。
结果形成:
每个节点可能包含 1~3 个关键字,并有 2~4 个子节点。
结论:
红黑树的“吸收版本”正是 2-3-4 树(B 树的一种,最小度数 t=2t=2t=2)。
它们在结构上等价,只是实现方式不同(红黑树是 2-3-4 树的二叉编码形式)。
最终总结表
| 题号 | 结论 |
|---|---|
| 18.1-1 | t=1t = 1t=1 不合法,因为节点可能为空,破坏B树性质 |
| 18.1-2 | 当 t=3t = 3t=3 时图18.1的树合法 |
| 18.1-3 | t=2t=2t=2 的B树可存 1,2,3,4,5{1,2,3,4,5}1,2,3,4,5,高度为2 |
| 18.1-4 | 最大关键字数 Nmax(h)=(2t)h+1−1N_{\max}(h) = (2t)^{h+1} - 1Nmax(h)=(2t)h+1−1 |
| 18.1-5 | 红黑树吸收红节点后变为 2-3-4树(B树,t=2t=2t=2) |
一、在树结构中,“度”的基本概念
在**树(Tree)**的定义中:
一个节点的 度(degree),是指这个节点拥有的子节点数量。
例如:
A
/ | \
B C D
|
E
- 节点 A 有 3 个孩子 → 度为 3
- 节点 C 有 1 个孩子 → 度为 1
- 节点 B、D、E 没有孩子 → 度为 0(叶子节点)
二、“树的度”
整棵树的 度,是指所有节点的度中的最大值。
也就是——
树的度=max(每个节点的子节点数)
\text{树的度} = \max(\text{每个节点的子节点数})
树的度=max(每个节点的子节点数)
比如上面那棵树中:
- 最大的节点度是 3(A 有三个孩子),
- 所以这棵树的度是 3。
三、在 B 树(B-Tree)中的“度”
B 树是一种多路搜索树(multi-way search tree)。
和普通二叉搜索树不同,B 树的每个节点可以有很多孩子。
因此,“度”在这里变得非常关键。
B 树的“最小度数(minimum degree)” ttt
在 B 树中,定义一个参数 ttt(最小度数),它控制节点的容量范围:
| 性质 | 含义 |
|---|---|
| 每个节点最多有 2t2t2t 个子节点 | 节点的最大度数 |
| 每个节点至少有 ttt 个子节点(根除外) | 节点的最小度数 |
| 每个节点最多有 2t−12t-12t−1 个关键字 | 因为关键字数 = 子节点数 - 1 |
| 每个节点至少有 t−1t-1t−1 个关键字 | 最小度数减一 |
所以在 B 树中,“度”代表的是一个节点最多可以有多少条分支(孩子)。
举个直观的例子
当最小度数 t=2t=2t=2
| 项目 | 数值 |
|---|---|
| 每个节点最多孩子数 | 2t=42t = 42t=4 |
| 每个节点最少孩子数 | t=2t = 2t=2 |
| 每个节点最多关键字数 | 2t−1=32t - 1 = 32t−1=3 |
| 每个节点最少关键字数 | t−1=1t - 1 = 1t−1=1 |
| 这就是我们常说的 2-3-4 树(2-3-4 tree): |
- 每个节点可能有 2、3 或 4 个分支;
- 所以“度”最大为 4。
当最小度数 t=3t=3t=3
| 项目 | 数值 |
|---|---|
| 每个节点最多孩子数 | 2t=62t = 62t=6 |
| 每个节点最少孩子数 | t=3t = 3t=3 |
| 每个节点最多关键字数 | 2t−1=52t - 1 = 52t−1=5 |
| 每个节点最少关键字数 | t−1=2t - 1 = 2t−1=2 |
| 这时节点可以有 3 到 6 个分支,树“更扁平”。 |
四、度对树性能的影响
| 度(t 值) | 每节点可含关键字数 | 树的高度 | 磁盘访问次数 |
|---|---|---|---|
| 小(如 t=2t=2t=2) | 少 | 高 | 多 |
| 大(如 t=50t=50t=50) | 多 | 矮 | 少 |
| 所以在数据库系统中,会选择一个合适的 ttt, | |||
| 使每个节点正好装满一个磁盘块,从而优化磁盘 I/O。 |
五、总结一句话
在树中,“度”是一个节点拥有的子节点数。
在 B 树中,“最小度数” ttt 决定了每个节点能有多少个孩子(即分支数)的范围。
它直接影响了 B 树的宽度、高度和查询效率。
当最小度数 t=2t = 2t=2 (即 2-3-4 树)
规则回顾
| 项目 | 数值 |
|---|---|
| 最少关键字数 | t−1=1t - 1 = 1t−1=1 |
| 最多关键字数 | 2t−1=32t - 1 = 32t−1=3 |
| 最少孩子数 | t=2t = 2t=2 |
| 最多孩子数 | 2t=42t = 42t=4 |
举例 1:最少关键字(1 个关键字)
[10]
/ \
A B
- 关键字个数:1
- 子节点个数:2
最小配置,节点 刚刚达到最小度数要求。
举例 2:中间状态(2 个关键字)
[10 | 20]
/ | \
A B C
- 关键字个数:2
- 子节点个数:3
合法状态,节点介于“最小”和“满”之间。
举例 3:最多关键字(3 个关键字)
[10 | 20 | 30]
/ | | \
A B C D
- 关键字个数:3
- 子节点个数:4
达到最大值(满节点)。 - 这种节点在 B 树插入时会被“分裂(split)”。
总结:
当 t=2t=2t=2 时: - 每个节点最多有 4 个分支;
- 关键字在 1∼31\sim31∼3 之间;
- 树看起来较“高瘦”,因为每层能存的关键字不算多。
当最小度数 t=3t = 3t=3
规则回顾
| 项目 | 数值 |
|---|---|
| 最少关键字数 | t−1=2t - 1 = 2t−1=2 |
| 最多关键字数 | 2t−1=52t - 1 = 52t−1=5 |
| 最少孩子数 | t=3t = 3t=3 |
| 最多孩子数 | 2t=62t = 62t=6 |
举例 1:最少关键字(2 个关键字)
[10 | 20]
/ | \
A B C
- 关键字:2
- 子节点:3
这是最“瘦”的节点(最小度数状态)。
举例 2:中间状态(4 个关键字)
[10 | 20 | 30 | 40]
/ | | | \
A B C D E
- 关键字:4
- 子节点:5
合法中间状态,节点较“宽”。
举例 3:最多关键字(5 个关键字)
[5 | 10 | 15 | 20 | 25]
/ | | | | \
A B C D E F
- 关键字:5
- 子节点:6
满节点,达到上限。
总结:
当 t=3t=3t=3 时: - 每个节点最多 6 个分支;
- 能容纳的关键字比 t=2t=2t=2 多;
- 树更“扁平”,查找路径更短。
对比总结表
| 最小度数 ttt | 最少关键字数 | 最多关键字数 | 最少孩子数 | 最多孩子数 | 形状特点 | 查找性能 |
|---|---|---|---|---|---|---|
| 2 | 1 | 3 | 2 | 4 | 瘦高 | 查找较慢(层多) |
| 3 | 2 | 5 | 3 | 6 | 较扁平 | 查找更快(层少) |
| 50 | 49 | 99 | 50 | 100 | 极度扁平 | 查找最少磁盘访问 |
一句话理解
“最小度数 ttt” 决定了 节点能装多少关键字、能分几个孩子。
ttt 越大,节点越“胖”,树越“矮”,查找越快(磁盘访问少)。
ttt 越小,节点越“瘦”,树越“高”,查找路径更长。
一、B 树操作概述
在介绍 B-TREE-SEARCH 之前,先了解一些通用约定:
- 根节点(root)始终在内存中。
因此搜索时不需要DISK-READ(root)。
但如果根节点修改了,必须调用DISK-WRITE(root)。 - 作为参数传入的节点,已经在内存中。
也就是说,在递归调用时,只有当要访问“孩子节点”时才执行一次DISK-READ。 - 算法是“单向下行(one-pass)”的。
就像二叉搜索树一样,从根一路往下走,不会回溯。
二、搜索的核心思想
搜索一个关键字 kkk 的过程与二叉搜索树(BST)类似:
- BST 是“两路分支”;
- B 树则是“多路分支(multiway branching)”,每个节点有 x.n+1x.n + 1x.n+1 个孩子。
三、伪代码讲解(行号说明)
B-TREE-SEARCH(x, k)
1. i = 1
// 初始化索引 i。我们将从当前节点 x 的第一个关键字开始比较。
// 约定:节点 x 中的关键字按升序存放,索引范围为 1..x.n。
2. while i ≤ x.n and k > x.key[i]
3. i = i + 1
// 在节点 x 中线性查找第一个满足 k ≤ x.key[i] 的位置 i。
// 循环终止时有两种情况:
// a) i ≤ x.n 且 k ≤ x.key[i](找到了第一个不小于 k 的 key)
// b) i = x.n + 1(k 比 x 中所有 key 都大)
// 这个过程等价于找出要进入的子树编号(或者恰好命中某个 key 的位置)。
4. if i ≤ x.n and k == x.key[i]
5. return (x, i)
// 如果在节点 x 中找到了与 k 精确相等的关键字(即 k == x.key[i]),
// 则返回一个有序对 (x, i),表示找到的位置:节点 x 的第 i 个关键字。
// 调用者可以据此读取/修改该键的数据(satellite data)。
6. elseif x.leaf
7. return NIL
// 如果当前节点 x 是叶子并且没有在第 4 步找到 k,
// 那么整棵子树中都不存在 k,搜索失败,返回 NIL。
8. else
9. DISK-READ(x.c[i])
// 否则,说明当前节点不是叶子,且 k 不在当前节点。
// 根据第 2–3 行确定的 i,应当进入第 i 个子树 x.c[i](第 i 个子指针)。
// 在访问该孩子之前,必须把对应的磁盘块读入内存:
// DISK-READ(x.c[i])
// (如果该孩子已经在内存中,DISK-READ 可以视为 no-op。)
10. return B-TREE-SEARCH(x.c[i], k)
// 递归在子树根 x.c[i] 上继续搜索 k。
// 由于 B 树是按层次向下的结构,这里是单向递归(one-pass),不会回退。
四、逐行解释与逻辑
| 行号 | 操作说明 | 含义与解释 |
|---|---|---|
| 1 | 初始化 | 从节点第一个关键字开始比较。 |
| 2–3 | 线性查找关键字位置 | 找到最小的 iii 使得 k≤x.keyik \le x.key_ik≤x.keyi,或者 i=x.n+1i = x.n + 1i=x.n+1(说明 kkk 比所有关键字都大)。 |
| 4–5 | 找到目标关键字 | 如果 k=x.keyik = x.key_ik=x.keyi,返回 (x,i)(x, i)(x,i)。 |
| 6–7 | 到达叶子节点但没找到 | 搜索失败,返回 NIL。 |
| 8–9 | 若不是叶节点 | 说明要去相应的子树中继续搜索。根据 iii 的值,读取 x.cix.c_ix.ci(第 iii 个孩子)。 |
| 10 | 递归搜索 | 调用 B-TREE-SEARCH 在子树中继续查找。 |
举个例子
假设我们有一个 t=2t = 2t=2 的 B 树(即 2-3-4 树):
[10 | 20]
/ | \
[1|5] [12|15|18] [22|25]
搜索 k=15k = 15k=15 的过程:
1⃣ 在根 [10|20] 中比较
→ 10<15<2010 < 15 < 2010<15<20,所以进入第 2 个子树。
2⃣ DISK-READ 第二个子节点 [12|15|18]。
→ 找到 151515。
3⃣ 返回节点与索引:( [12|15|18], 2 )
总共只访问了 2 个磁盘块(根节点在内存 + 一个读操作)。
五、时间复杂度分析
令:
- ttt = 最小度数;
- hhh = 树高;
- nnn = 关键字总数。
磁盘访问次数
每向下一层搜索一次,需要读取一个新的磁盘块。
因此,磁盘访问次数为树的高度:
O(h)=O(logtn)
O(h) = O(\log_t n)
O(h)=O(logtn)
B 树高度 hhh 与节点分支数 ttt 成反比。
CPU 时间(比较操作)
在每个节点中,最多比较 x.n<2t−1x.n < 2t - 1x.n<2t−1 次关键字。
因此总的 CPU 时间为:
O(t⋅h)=O(tlogtn)
O(t \cdot h) = O(t \log_t n)
O(t⋅h)=O(tlogtn)
对比理解
| 数据结构 | 每节点比较数 | 树高 | 总操作复杂度 |
|---|---|---|---|
| 二叉搜索树 | O(1)O(1)O(1) | O(log2n)O(\log_2 n)O(log2n) | O(logn)O(\log n)O(logn) |
| B 树 | O(t)O(t)O(t) | O(logtn)O(\log_t n)O(logtn) | O(tlogtn)O(t \log_t n)O(tlogtn) |
| 在实际系统中,ttt 通常很大(例如 t=100t=100t=100~100010001000), | |||
| 虽然每个节点比较多一点,但树的高度极低, | |||
| 从而显著减少磁盘访问次数。 |
六、直观理解图(搜索过程)
[M | T | X]
/ | | \
[A..F] [G..L] [N..S] [U..Z]
↑
搜索关键字 “R”
根节点 [M | T | X] 在内存中比较一次。
因为 M<R<TM < R < TM<R<T,所以访问第二个孩子 [N..S]。
在 [N..S] 中找到关键字 R。
共访问 2 个节点,树高 h=2h=2h=2。
七、总结
| 概念 | 说明 |
|---|---|
| 搜索方向 | 自顶向下,单次通过(one-pass) |
| 访问方式 | 每层一次磁盘读(根节点除外) |
| 复杂度 | 磁盘 I/O:O(logtn)O(\log_t n)O(logtn);CPU:O(tlogtn)O(t\log_t n)O(tlogtn) |
| 优势 | B 树通过大分支因子 ttt 显著降低树高,减少磁盘访问次数 |
| 关键思想 | 用更“胖”的节点换更“矮”的树,提高外存访问效率 |
下面给出 B-TREE-SEARCH 的完整 C++ 实现(基于内存模拟,但在关键位置标注了 DISK-READ 的调用点并用注释说明),并在每行关键处加上详细注释,便于你把伪代码和实现对应起来。代码实现了一个简单的 BTreeNode / BTree 类,仅实现搜索功能(便于聚焦 B-TREE-SEARCH 的核心),键类型用 int,你可以按需扩展为模板或加入磁盘 I/O 模拟。
说明:真实外存实现需要把节点序列化到磁盘并在
DISK-READ/DISK-WRITE时加载/写回;此处用注释和diskRead()占位函数表示该点。
#include <iostream>
#include <vector>
using std::cout;
using std::vector;
// 用于返回搜索结果:找到时返回 (node ptr, index),否则 node = nullptr
struct SearchResult {
struct BTreeNode* node;
int index; // 0-based index in node->keys if found; -1 if not used
SearchResult(BTreeNode* n = nullptr, int i = -1) : node(n), index(i) {}
};
// 前向声明
struct BTreeNode;
// 占位:DISK-READ 的模拟函数(在真实系统中这里会触发磁盘 I/O)
// 对外接口保持一个 no-op(因为本代码在内存中),但调用点明确。
void diskRead(BTreeNode* node) {
// 在实际磁盘实现中:从磁盘读取节点数据到内存结构并填充 node 的字段。
// 在这个内存示例中无需任何操作(no-op)。
// 留出此函数,便于将来替换为真实的 I/O 实现。
(void)node;
}
// B 树节点(简化,键为 int)
struct BTreeNode {
int t; // 最小度数(控制 keys 和 children 的上下界)
bool leaf; // 是否为叶子节点
vector<int> keys; // 存储 keys,按升序
vector<BTreeNode*> child; // 指向子节点的指针,长度 = keys.size() + 1 (若为叶子则无用)
int n; // 当前 keys 数量(等于 keys.size())
// 构造(创建新节点时初始化 keys/child 容量)
BTreeNode(int t_, bool leaf_) : t(t_), leaf(leaf_), keys(), child(), n(0) {
keys.reserve(2 * t_ - 1);
child.reserve(2 * t_);
}
};
// B 树(仅包含根与搜索逻辑)
struct BTree {
BTreeNode* root;
int t; // 最小度数
BTree(int t_) : root(nullptr), t(t_) {}
// 创建空树(与 B-TREE-CREATE 等价)
void create() {
root = new BTreeNode(t, true);
root->n = 0;
// 在外存实现中:要执行 DISK-WRITE(root) 将空节点写回磁盘
}
// ----------------------------
// B-TREE-SEARCH(x, k) 的实现
// 返回 SearchResult:若找到返回 (node, index),index 为 0-based
// 若未找到,返回 (nullptr, -1)
// ----------------------------
SearchResult search(BTreeNode* x, int k) {
if (x == nullptr) return SearchResult(nullptr, -1); // 防御性检查
// --------------------
// 对应伪代码第 1 行:i = 1
// 这里我们用 0-based 索引,i 初始化为 0
// --------------------
int i = 0;
// --------------------
// 对应伪代码第 2-3 行:
// while i ≤ x.n and k > x.key[i] => 线性查找第一个不小于 k 的位置 i
// 注意:x->n == number of keys
// --------------------
while (i < x->n && k > x->keys[i]) {
i++;
}
// --------------------
// 对应伪代码第 4-5 行:
// if i ≤ x.n and k == x.key[i] return (x, i)
// 这里 i < x->n 判断并比较相等(0-based)
// --------------------
if (i < x->n && k == x->keys[i]) {
return SearchResult(x, i);
}
// --------------------
// 对应伪代码第 6-7 行:
// elseif x.leaf return NIL
// --------------------
if (x->leaf) {
return SearchResult(nullptr, -1); // NIL:未找到
}
// --------------------
// 对应伪代码第 8-9 行:否则 DISK-READ(x.c[i])
// 在实际实现里必须先把 child[i] 对应的磁盘块读到内存。
// 这里调用占位函数 diskRead(child) 表示该步骤。
// --------------------
BTreeNode* childNode = nullptr;
if (i < (int)x->child.size())
childNode = x->child[i];
else
childNode = nullptr; // 防御性:若 child 不存在则为 nullptr
// 执行 DISK-READ(示意)
if (childNode) diskRead(childNode);
// --------------------
// 对应伪代码第 10 行:递归调用 B-TREE-SEARCH(x.c[i], k)
// --------------------
return search(childNode, k);
}
// 辅助:从根开始搜索(用户接口)
SearchResult searchRoot(int k) {
// 根通常驻留内存,不需要 DISK-READ(root)
return search(root, k);
}
};
// --------------------
// 简单示例:构造一小棵 B 树并演示搜索
// 注意:此处手动构造树并保证 child 指针存在,真实插入逻辑会自动维护这些。
// --------------------
int main() {
// 以 t=2(2-3-4 树)为例
BTree tree(2);
tree.create();
// 手动构造一个小树(与之前伪代码图类似)
// 根节点 keys = [10, 20]
BTreeNode* root = new BTreeNode(2, false);
root->keys = {10, 20};
root->n = 2;
// 构造 3 个叶子子节点
BTreeNode* c0 = new BTreeNode(2, true);
c0->keys = {1, 5};
c0->n = 2;
BTreeNode* c1 = new BTreeNode(2, true);
c1->keys = {12, 15, 18};
c1->n = 3;
BTreeNode* c2 = new BTreeNode(2, true);
c2->keys = {22, 25};
c2->n = 2;
// 关联孩子指针(注意:child vector 长度应为 keys.size()+1)
root->child = {c0, c1, c2};
// 将 root 设回树
tree.root = root;
// 搜索示例
int queries[] = {15, 13, 30};
for (int q : queries) {
SearchResult res = tree.searchRoot(q);
if (res.node) {
cout << "找到 key = " << q << " 在节点 (keys = [";
for (size_t j = 0; j < res.node->keys.size(); ++j) {
if (j) cout << ", ";
cout << res.node->keys[j];
}
cout << "]) 的 index = " << res.index << "(0-based)\n";
} else {
cout << "未找到 key = " << q << "\n";
}
}
// 释放内存(示例中简单 delete,不包含完整释放逻辑)
delete c0;
delete c1;
delete c2;
delete root;
return 0;
}
一、程序整体结构
这段代码实现了 B 树(B-Tree)搜索操作,对应《算法导论(CLRS)》中的伪代码 B-TREE-SEARCH(x, k)。
程序包括以下部分:
- 结构定义
BTreeNode:B 树节点结构;BTree:树本身;SearchResult:搜索结果(包含节点指针和键下标)。
- 核心函数
BTree::search(BTreeNode* x, int k):递归搜索;BTree::searchRoot(int k):从根开始搜索。
- 示例部分(main)
- 手动构造一棵小的 B 树;
- 对若干 key 进行搜索并输出结果。
二、手动构造的 B 树
在 main() 中,手动创建了一棵 B 树,最小度数 t = 2,结构如下:
[10, 20]
/ | \
[1, 5] [12,15,18] [22,25]
- 根节点有两个键
[10, 20],三个子节点; - 每个子节点是叶子,分别包含:
- 左子树
[1, 5] - 中子树
[12, 15, 18] - 右子树
[22, 25]
- 左子树
三、执行流程举例
我们看 queries[] = {15, 13, 30},程序依次搜索三次。
1⃣ 搜索 key = 15
起点:从根节点 [10, 20] 开始。
- 比较
15与根节点的键:15 > 10→ i = 115 < 20→ 停止
- 检查是否相等:
k == keys[i]?→ 否(keys[1]=20) - 根节点不是叶子 → 递归进入
child[1] - 进入子节点
[12, 15, 18]- 依次比较:
15 > 12→ i = 115 == 15→ 找到!
- 依次比较:
- 返回结果:节点
[12,15,18],index = 1。
输出:
找到 key = 15 在节点 (keys = [12, 15, 18]) 的 index = 1(0-based)
2⃣ 搜索 key = 13
- 根
[10, 20]:13 > 10→ i = 113 < 20→ 停止- 未命中 → 进入
child[1]
- 子节点
[12,15,18]:13 > 12→ i = 113 < 15→ 停止- 未命中 → 因为是叶子 → 搜索结束。
- 返回
(nullptr, -1)。
输出:
未找到 key = 13
3⃣ 搜索 key = 30
- 根
[10, 20]:30 > 10→ i = 130 > 20→ i = 2- 未命中 → 进入
child[2]
- 子节点
[22, 25]:30 > 22→ i = 130 > 25→ i = 2- 未命中且为叶子 → 搜索结束。
- 返回
(nullptr, -1)。
输出:
未找到 key = 30
四、关键流程总结
| 步骤 | 操作 | 对应伪代码 | 示例说明 |
|---|---|---|---|
| 1 | 从节点第一个键开始线性比较 | while i ≤ n and k > key[i] | 找到第一个 ≥k 的位置 |
| 2 | 检查是否命中 | if k == key[i] | 找到则返回 |
| 3 | 若是叶子则失败 | if leaf then NIL | 到叶子仍未匹配则返回空 |
| 4 | 否则递归到对应子节点 | return B-TREE-SEARCH(child[i], k) | 深入下一层继续查找 |
五、执行结果汇总
程序最终输出:
找到 key = 15 在节点 (keys = [12, 15, 18]) 的 index = 1(0-based)
未找到 key = 13
未找到 key = 30
逐行、逐点详解 B-TREE-CREATE(T) 的工作原理、为何这样做,以及它的 I/O/时间复杂度。
伪代码(原样)
B-TREE-CREATE(T)
1. x = ALLOCATE-NODE()
2. x.leaf = TRUE
3. x.n = 0
4. DISK-WRITE(x)
5. T.root = x
每行详细解释
第 1 行:x = ALLOCATE-NODE()
- 从外部存储(磁盘)上分配一个新的磁盘块用作一个 B-树节点,并在内存中返回该节点的句柄(指针)。
- 约定:
ALLOCATE-NODE()的运行时间为 O(1)O(1)O(1),它只负责分配空间并返回一个“空的节点结构”(尚未初始化具体字段的默认值)。 - 重要:这个新分配的节点在磁盘上刚开辟了位置,但上面的数据尚未写入(或写入的是初始零/垃圾数据),因此不需要先做
DISK-READ。
第 2 行:x.leaf = TRUE - 将新节点标记为叶节点(
leaf字段设为TRUE)。 - 因为此时树为空,新节点既是根又是叶。
第 3 行:x.n = 0 - 将该节点的关键字计数初始化为 0(节点中没有任何 key)。
- 同时应初始化节点的 keys 数组为空、所有 child 指针设为
NIL(或空指针占位),以及必要的元信息(例如 parent 指针设NIL)。这一步通常在实现中与第 2、3 行一起做。
第 4 行:DISK-WRITE(x) - 将初始化后的节点写回磁盘(将该磁盘块初始化为合法的空 B-树节点)。
- 这是必需的:因为
ALLOCATE-NODE只分配了块,程序必须把节点的实际内容(leaf、n、keys 数组、children 指针等)持久化到磁盘上,以便后续的DISK-READ能正确加载该节点。 - 这是
B-TREE-CREATE唯一需要做的磁盘 I/O(一次写操作)。
第 5 行:T.root = x - 将 B 树
T的根指针设为刚创建的节点。 - 根据前述约定,根节点通常保持在主存中(驻留内存),所以之后搜索或插入的第一步可以直接访问根而无需
DISK-READ。
为什么不需要 DISK-READ?
ALLOCATE-NODE()新分配出来的块上并没有“已有”的有效数据,因此不需要先读入再改写;可以直接初始化(在内存中)并DISK-WRITE。- 也就是说,
ALLOCATE-NODE()+ 初始化 +DISK-WRITE()替代了 “读—改—写” 的过程中的“读”步骤。
I/O 与时间复杂度
- 磁盘操作(disk ops):只有一次
DISK-WRITE(x)→ O(1)O(1)O(1) 磁盘访问。 - CPU 时间:
ALLOCATE-NODE、字段赋值等都是常数工作 → O(1)O(1)O(1) 时间。 - 用公式表示:
磁盘操作=O(1),CPU 时间=O(1). \text{磁盘操作} = O(1),\qquad \text{CPU 时间} = O(1). 磁盘操作=O(1),CPU 时间=O(1).
实现细节与注意事项(工程角度)
- 初始化子指针:应把
x.c[1..2t]全部设为NIL(或nullptr),以免后续访问出现未定义指针。 - keys 数组:将
x.key[1..2t-1]置为空/默认值。 - root 常驻内存:把
T.root指向内存中的节点(程序中保留该指针),以便后续操作(搜索、插入)无需首次DISK-READ。 - 事务/崩溃安全:若系统需保证崩溃恢复,通常还要在写磁盘前后维护日志(write-ahead log),但算法分析中通常忽略这类工程复杂性。
小示例(直观)
- 调用
B-TREE-CREATE(T):- 在磁盘分配一个节点块并写入内容:
leaf = TRUE, n = 0。 T.root指向该节点(驻留内存)。
- 在磁盘分配一个节点块并写入内容:
- 现在树为空:
T.root为一个空叶节点。- 接下来若插入第一个关键字(调用
B-TREE-INSERT),会在根节点中直接加入 key(x.n从 000 变为 111),并DISK-WRITE(root)持久化。
- 接下来若插入第一个关键字(调用
#include <iostream>
#include <vector>
using namespace std;
// B树节点结构
struct BTreeNode {
bool leaf; // 是否为叶子节点
int n; // 当前键数量
vector<int> keys; // 键数组
vector<BTreeNode*> children; // 子节点指针
BTreeNode(bool isLeaf, int t) {
leaf = isLeaf;
n = 0;
keys.resize(2 * t - 1); // 最大关键字数
children.resize(2 * t); // 最大子节点数
}
};
// B树结构
struct BTree {
BTreeNode* root; // 根节点
int t; // 最小度数
BTree(int _t) : t(_t) {
root = B_TREE_CREATE();
}
// 分配新节点(模拟 ALLOCATE-NODE)
BTreeNode* ALLOCATE_NODE(bool isLeaf = true) {
return new BTreeNode(isLeaf, t);
}
// B-TREE-CREATE 过程
BTreeNode* B_TREE_CREATE() {
BTreeNode* x = ALLOCATE_NODE(true); // 1. 创建空节点
x->leaf = true; // 2. 标记为叶节点
x->n = 0; // 3. 当前键数为0
DISK_WRITE(x); // 4. 模拟写入磁盘
return x; // 5. 作为树根
}
// 模拟磁盘写操作
void DISK_WRITE(BTreeNode* node) {
// 实际实现中这里会写入磁盘块
// 我们仅模拟输出提示
cout << "[DISK_WRITE] 写入节点地址: " << node << endl;
}
};
int main() {
int t = 3; // B树最小度数
BTree T(t);
cout << "空的B树已创建,根节点地址: " << T.root << endl;
return 0;
}
代码说明
- ALLOCATE_NODE:模拟磁盘块分配,返回一个新节点指针。
- B_TREE_CREATE:创建空B树根节点(叶子节点,关键字数为0)。
- DISK_WRITE:模拟磁盘写操作(理论上耗时 O(1))。
- BTree结构:包含
root指针和最小度数t,是B树的整体封装。
一、B树插入的核心思想
插入一个关键字 kkk 到 B 树中,与二叉搜索树(BST)不同。
- 在 BST 中,你找到插入位置后,若该叶子为空则直接插入即可;
- 在 B 树中,你不能新建叶节点来插入键值,否则会破坏 B 树的结构约束(每个节点必须有规定范围内的关键字数量)。
所以,B 树插入的关键思想是:
必须将新键插入到现有的叶节点中。
但如果目标叶节点已经满了(含 2t−12t - 12t−1 个键),则必须先“拆分(split)”该节点。
二、为什么插入时要提前拆分
假设你在搜索过程中,遇到了一个满节点 yyy(含 2t−12t-12t−1 个键)。
如果不马上拆分它,那么你可能要在深入后回溯拆分(麻烦且代价高)。
所以采用自顶向下分裂法(top-down splitting):
每当向下搜索时,如果遇到一个满节点,就立即调用
B-TREE-SPLIT-CHILD将其分裂。
这样做的好处是:
- 当真正到达要插入的叶节点时,该节点一定不会是满的;
- 插入可以在一次下降过程中完成,无需回溯;
- 每次插入操作的磁盘访问次数是 O(logtn)O(\log_t n)O(logtn)。
三、B-TREE-INSERT 算法结构(伪代码逻辑)
设树为 TTT,要插入的关键字为 kkk。
B-TREE-INSERT(T, k)
- 设 r=T.rootr = T.rootr=T.root
若根节点 rrr 已满(即 r.n=2t−1r.n = 2t-1r.n=2t−1):- 创建新节点 s=ALLOCATE-NODE()s = \text{ALLOCATE-NODE}()s=ALLOCATE-NODE()
- 令 T.root=sT.root = sT.root=s
- s.leaf=FALSEs.leaf = FALSEs.leaf=FALSE
- s.C1=rs.C_1 = rs.C1=r (旧根成为新根的第一个孩子)
- 调用
B-TREE-SPLIT-CHILD(s, 1)
—— 分裂旧根,树的高度增加 1。 - 调用
B-TREE-INSERT-NONFULL(s, k)
—— 插入 kkk 到新根对应的合适子树。
- 否则(根不满):
- 直接调用
B-TREE-INSERT-NONFULL(r, k)
- 直接调用
B-TREE-INSERT-NONFULL(x, k)
用于在一个未满节点 xxx 下插入键 kkk。
- 若 x.leaf==TRUEx.leaf == TRUEx.leaf==TRUE(是叶节点):
- 在 xxx 中找到合适的插入位置 iii(使得 x.keyi<k<x.keyi+1x.key_i < k < x.key_{i+1}x.keyi<k<x.keyi+1);
- 将 xxx 中的键向右移;
- 插入 kkk;
- x.n=x.n+1x.n = x.n + 1x.n=x.n+1;
DISK-WRITE(x)。
- 否则(xxx 是内部节点):
- 找到子节点索引 iii,使得 kkk 应当插入到 x.Cix.C_ix.Ci 对应的子树。
- 读取该子节点 x.Cix.C_ix.Ci 到内存。
- 如果 x.Cix.C_ix.Ci 满了(x.Ci.n=2t−1x.C_i.n = 2t-1x.Ci.n=2t−1):
- 调用
B-TREE-SPLIT-CHILD(x, i)。 - 若 k>x.keyik > x.key_ik>x.keyi,说明 kkk 应插入到右侧新分裂出的子树,则 i=i+1i = i + 1i=i+1。
- 调用
- 递归调用
B-TREE-INSERT-NONFULL(x.C_i, k)。
四、插入时的树增长机制
B 树增长高度的唯一方式是分裂根节点。
当根节点满时:
- 新建一个空节点 sss 作为新根;
- 把旧根 rrr 变成 sss 的孩子;
- 调用
B-TREE-SPLIT-CHILD(s, 1)将旧根分裂为两个节点; - 新根 sss 因此拥有 1 个键与 2 个孩子;
- 树高 hhh 增加 1。
公式上:
hnew=hold+1 h_{\text{new}} = h_{\text{old}} + 1 hnew=hold+1
五、示例推导(t=3t=3t=3)
设最小度数 t=3t=3t=3,则每个节点:
- 最多含 2t−1=52t-1 = 52t−1=5 个键;
- 最少含 t−1=2t-1 = 2t−1=2 个键(除根节点外)。
示例过程(插入序列)
假设插入序列:
[10,20,5,6,12,30,7,17]
[10, 20, 5, 6, 12, 30, 7, 17]
[10,20,5,6,12,30,7,17]
步骤 1–3:插入 10, 20, 5
形成根节点:
[5, 10, 20]
插入 6:
根不满,插入后:
[5, 6, 10, 20]
插入 12:
根不满,插入后:
[5, 6, 10, 12, 20]
根满(5个键)。
插入 30:
根已满 ⇒ 创建新根并分裂。
分裂旧根 [5,6,10,12,20]:
- 中位键 101010 上升;
- 左节点保留 `[5,6]$;
- 右节点得到 `[12,20]$;
- 新根为 `[10]$,有两个孩子。
此时树为:
[10]
/ \
[5,6] [12,20]
继续插入 30:
- 30>1030 > 1030>10,进入右子节点 `[12,20]$;
- 右子节点不满;
- 插入 30 → `[12,20,30]$。
结果:
[10]
/ \
[5,6] [12,20,30]
插入 7:
- 7<107 < 107<10,进入左子 `[5,6]$;
- 插入后 `[5,6,7]$;
- 不满,完成。
[10]
/ \
[5,6,7] [12,20,30]
插入 17:
- 17>1017 > 1017>10,进入右子 `[12,20,30]$;
- 插入后 `[12,17,20,30]$;
- 不满,结束。
最终 B 树:
[10]
/ \
[5,6,7] [12,17,20,30]
六、复杂度分析
令树中有 nnn 个键,最小度为 ttt。
- 每次插入:
- 最多沿路径深度 h=O(logtn)h = O(\log_t n)h=O(logtn)。
- 每层可能一次
DISK-READ或DISK-WRITE。 - 单层 CPU 工作量 O(t)O(t)O(t)。
- 总体 CPU 时间:
O(tlogtn) O(t \log_t n) O(tlogtn) - 磁盘操作:
O(logtn) O(\log_t n) O(logtn)
- B树的优点:
当节点存放在磁盘块中时,ttt 可选得较大(如 50~200),从而 logtn\log_t nlogtn 极小。
⇒ 插入操作仅需少量磁盘访问,非常高效。
七、关键点总结
| 操作 | 作用 | 复杂度 | 特征 |
|---|---|---|---|
B-TREE-SPLIT-CHILD | 分裂满节点 | O(t)O(t)O(t) CPU + O(1)O(1)O(1) 磁盘 | 节点级操作 |
B-TREE-INSERT-NONFULL | 向未满节点插入键 | O(t)O(t)O(t) | 向下递归插入 |
B-TREE-INSERT | 整体插入接口 | O(tlogtn)O(t \log_t n)O(tlogtn) | 仅一次下行路径 |
一、问题与目标(高层回顾)
目标是把一个关键字 kkk 插入到一棵 B-树 TTT 中。设 B-树的最小度为 ttt,树高为 hhh,树中共有 nnn 个键。
我们要求插入算法满足 B-树的不变量(每个非根节点的键数在 [t−1,,2t−1][t-1,,2t-1][t−1,,2t−1] 之间;根节点可以少于 t−1t-1t−1),并且尽量减少磁盘 I/O。教材给出的策略是 “自顶向下切分满子节点”,从而保证在向下遍历的过程中永远不会递归到一个满的节点,使得插入只需一次从根到叶的下降(single pass down)。
二、关键不变量(在算法运行期间必须保持的条件)
- 对任意非根节点 xxx:
t−1≤x.n≤2t−1.t-1 \le x.n \le 2t-1.t−1≤x.n≤2t−1. - 对任意节点(内部节点) xxx:
x.n=keys in x,children=x.n+1.x.n = \text{keys in } x,\qquad \text{children} = x.n+1.x.n=keys in x,children=x.n+1. - 对任意节点,键与孩子的顺序关系(BST 顺序)保持:左子树中的键都小于分隔键,右子树中的键都大于分隔键。
- 在 B-TREE-INSERT-NONFULL 被调用时,参数节点 xxx 必须是 非满(x.n≤2t−2x.n \le 2t-2x.n≤2t−2)。
三、算法结构与核心步骤(高层伪代码回顾)
用一句话:
若根满,先把根分裂(增加高度);然后在非满的根上调用 INSERT-NONFULL,在下降的过程中遇到满的子节点就先 split,再继续下降。
关键伪代码(简要形式):
B-TREE-INSERT(T,k):
r = T.root
if r.n == 2t-1:
s = B-TREE-SPLIT-ROOT(T)
B-TREE-INSERT-NONFULL(s, k)
else
B-TREE-INSERT-NONFULL(r, k)
B-TREE-INSERT-NONFULL(x,k) 要保证 xxx 非满:
- 若 xxx 为叶:直接在 xxx 中插入 kkk(按序移位)。
- 否则找到子索引 iii 使 kkk 应落在 x.Cix.C_ix.Ci;若 x.Cix.C_ix.Ci 满则先
B-TREE-SPLIT-CHILD(x,i)(把 x.Cix.C_ix.Ci 分成两个非满节点,并把中位键上升),随后根据 kkk 与新 x.keyix.key_ix.keyi 的大小决定向左或向右子树继续下降;递归调用INSERT-NONFULL。
B-TREE-SPLIT-CHILD(x,i)的作用是把满子节点 y=x.Ciy=x.C_iy=x.Ci 分裂为 yyy 和 zzz,并把 y.keyty.key_ty.keyt 提升到父 xxx 的相应位置,使得 yyy 与 zzz 各自都只有 t−1t-1t−1 个键。
四、为什么“在下降时分裂满子节点”能保证单程插入(直观+形式化)
直观: 如果你等到到达叶子再把叶子分裂,则可能不得不回溯到父并在父上插入中位键;如果父也满,则再次分裂,可能一路回溯到根,导致多次回溯与多次 I/O。提前分裂把“必须分裂的负担”在下降路径上就处理掉了:当你要进入某个子节点 yyy 时,你先检查并保证 yyy 不是满的(若满就分裂),因此真正到达的叶子一定是非满的,可以直接插入,无需回溯。
形式化关键点(不变量维护):
- 在
INSERT-NONFULL(x,k)的任一递归调用时,我们保证 xxx 非满(这是被调用前的条件或由上层 split 保证)。 - 当 xxx 非叶且选择要下降到 x.Cix.C_ix.Ci 时,如果 x.Cix.C_ix.Ci 满(即含 2t−12t-12t−1 键),我们执行
B-TREE-SPLIT-CHILD(x,i)。分裂后,xxx 增加一个键(x.nx.nx.n 增 1),且分裂把 x.Cix.C_ix.Ci 分为两个各含 t−1t-1t−1 键的节点;因此无论 kkk 该落在左半还是右半,我们要下降到的那个子节点都必定是 非满。因此递归前条件(子调用参数非满)得到保证。由归纳可得,从根到叶每一层都保持该性质,最终在一个非满叶子上直接插入即可。
五、复杂度分析(磁盘 I/O 与 CPU 时间,用公式表示)
设树高为 hhh,最小度为 ttt,树中键数为 nnn。
磁盘 I/O(磁盘读写次数)
- 每层最多做常数次磁盘操作(在该层可能需要一次
DISK-READ读子节点、并在修改后做一次或几次DISK-WRITE)。 - 因此总体磁盘访问为:
O(h). O(h). O(h).
而 h=Θ(logtn)h = \Theta(\log_t n)h=Θ(logtn),所以:
磁盘访问=O(logtn). \text{磁盘访问} = O(\log_t n). 磁盘访问=O(logtn).
CPU 时间
- 在每层,主要工作是复制/移动最多 O(t)O(t)O(t) 个键或指针(例如分裂需要复制 t−1t-1t−1 个键和 ttt 个指针;在叶插入可能移动 O(t)O(t)O(t) 个键)。
- 因此每层的 CPU 时间为 O(t)O(t)O(t),总共 hhh 层,得:
O(t⋅h)=O!(tlogtn). O(t\cdot h) = O!\big(t \log_t n\big). O(t⋅h)=O!(tlogtn).
这就是教材中所写的:
CPU 时间=O(th)=O!(tlogtn). \text{CPU 时间} = O(t h) = O!\big(t\log_t n\big). CPU 时间=O(th)=O!(tlogtn).
六、具体行为细化(关键步骤与边界情形)
1) 分裂根(B-TREE-SPLIT-ROOT)
如果根 rrr 满(r.n=2t−1r.n = 2t-1r.n=2t−1),插入前要先把根分裂:
- 新建节点 sss 作为新的根 (s.leaf=FALSEs.leaf = \text{FALSE}s.leaf=FALSE, s.n=0s.n=0s.n=0);
- 把原根设为 s.C1s.C_1s.C1;
- 调用
B-TREE-SPLIT-CHILD(s,1),结果 sss 有 1 个键(原根的中位键),两个孩子(原来的两半)。
因此树高 hhh 增 1。公式上:
hnew=hold+1. h_{\text{new}} = h_{\text{old}} + 1. hnew=hold+1.
这是 B-树高度仅在根分裂时增长的原因。
2) B-TREE-SPLIT-CHILD 的具体键与指针重新分配(索引细节)
令待分裂节点为 yyy,其键为 y.key1,…,y.key2t−1y.key_1,\dots,y.key_{2t-1}y.key1,…,y.key2t−1。
- 中位键:y.keyty.key_ty.keyt(第 ttt 个键)被移动到父节点 xxx。
- 左侧保留:y.key1,…,y.keyt−1y.key_1,\dots,y.key_{t-1}y.key1,…,y.keyt−1(共 t−1t-1t−1 键)。
- 右侧移入新节点 zzz:y.keyt+1,…,y.key2t−1y.key_{t+1},\dots,y.key_{2t-1}y.keyt+1,…,y.key2t−1(共 t−1t-1t−1 键)。
- 若 yyy 不是叶子,则指针分配:
- 左保持:y.C1,…,y.Cty.C_1,\dots,y.C_ty.C1,…,y.Ct(共 ttt 指针);
- 右移入 zzz:y.Ct+1,…,y.C2ty.C_{t+1},\dots,y.C_{2t}y.Ct+1,…,y.C2t(共 ttt 指针)。
这确保每个内部节点满足#children = #keys + 1。
七、正确性(sketch)
要证明:插入后树仍是合法的 B-树,并且关键字 kkk 被包含。
证明思路(按归纳):
- 基本情形:当只插入到叶子且叶子非满时,按有序插入并移动键,显然保持顺序与度数不变。
- 归纳步骤:假设在某个非满节点 xxx 上执行
INSERT-NONFULL(x,k),归纳假设其子调用能保持子树的 B-树性质。- 若目标子 x.Cix.C_ix.Ci 满,我们先对其分裂(使分裂后的子节点都非满),并把中位键插入父 xxx(xxx 仍满足度数上界,因为调用前 xxx 非满)。随后根据 kkk 与被移动键的比较决定进入哪一个非满子;由归纳假设递归插入保持其子树合法。
- 若根在开始时满,
SPLIT-ROOT使根分裂并树高增加一层,且继续调用INSERT-NONFULL在新的非满根上,归纳成立。
因此插入结束后树的性质保持,kkk 被插入(存在于某个叶子或被提升并成为某个内部节点的键)。
八、举例(把思想具体化)
取 t=3t=3t=3(每个节点最多 2t−1=52t-1=52t−1=5 个键,至少 t−1=2t-1=2t−1=2 个键)。
- 插入序列示例与图示请参考教材图(教材用的例子正好说明了:先在叶简单插入 → 某叶满则分裂并把中位键上升 → 若上升使父满则父在上层被分裂 → 根满则先分裂根,树高度增加)。
关键点:每次你要进入子节点前检查该子是否为满,若是则先分裂,这保证了“下降时不会遇到满节点”。
九、工程实现注意事项(易错点)
- 索引基准:教材伪代码通常 1-based 索引;实际实现(C++/Java)多为 0-based,要小心偏移与循环边界(尤其是复制键、子指针时)。
- 磁盘缓冲/缓存策略:伪代码中的
DISK-READ/DISK-WRITE是抽象操作;实际实现通常有缓存,写回策略会影响 I/O 次数统计,但算法级别仍是 O(h)O(h)O(h) 次块访问。 - 并发/持久化:在并发或需要崩溃恢复的场景下,分裂时需要锁或日志(WAL);基本算法不包含这些机制。
- 内存碎片与再利用:
ALLOCATE-NODE的实现与空闲块管理会影响实际性能。
十、结论与关键公式汇总
- 单次插入所需磁盘访问数:
O(h)=O(logtn). O(h) = O(\log_t n). O(h)=O(logtn). - 单次插入的 CPU 时间(按节点内操作成本计):
O(t⋅h)=O!(tlogtn). O(t\cdot h) = O!\big(t\log_t n\big). O(t⋅h)=O!(tlogtn). - 算法保证:通过自顶向下切分满子节点,
B-TREE-INSERT-NONFULL在被调用时总是作用于非满节点,因此插入只需要一次根到叶的下行。
带详细注释的 C++ 实现
#include <iostream>
#include <vector>
#include <algorithm>
#include <memory>
using namespace std;
/**
* B树节点结构体模板
* KeyType:关键字类型(可为int、string等)
*/
template <typename KeyType>
struct BTreeNode {
bool leaf; // 是否为叶子节点
int n; // 当前节点中关键字的数量
vector<KeyType> keys; // 存放关键字的数组
vector<shared_ptr<BTreeNode<KeyType>>> children; // 子节点指针数组
/**
* 构造函数
* t:B树的最小度
* isLeaf:是否为叶子节点
*/
BTreeNode(int t, bool isLeaf)
: leaf(isLeaf),
n(0),
keys(2 * t - 1), // 每个节点最多存放 2t-1 个关键字
children(2 * t, nullptr) {} // 最多 2t 个子节点
};
/**
* B树类模板
*/
template <typename KeyType>
class BTree {
private:
shared_ptr<BTreeNode<KeyType>> root; // 根节点指针
int t; // 最小度数(每个非根节点至少有 t-1 个关键字)
/** 模拟磁盘读取操作(教材中对应 DISK-READ) */
void DiskRead(shared_ptr<BTreeNode<KeyType>> x) {
// 实际磁盘读取时会从外存加载节点
}
/** 模拟磁盘写入操作(教材中对应 DISK-WRITE) */
void DiskWrite(shared_ptr<BTreeNode<KeyType>> x) {
// 实际磁盘写入时会将节点保存到外存
}
/** 分配一个新的节点(对应 ALLOCATE-NODE) */
shared_ptr<BTreeNode<KeyType>> AllocateNode(bool isLeaf) {
return make_shared<BTreeNode<KeyType>>(t, isLeaf);
}
/**
* B-TREE-SPLIT-CHILD(x, i)
* 功能:将节点 x 的第 i 个子节点 y 分裂成两个节点
* 前提:y 必须是“满的”(包含 2t - 1 个关键字)
* 操作:
* - 新建节点 z,用于存放 y 的右半部分
* - 将 y 的中位关键字上升到父节点 x
* - x 的孩子指针右移,为 z 腾出位置
*/
void BTreeSplitChild(shared_ptr<BTreeNode<KeyType>> x, int i) {
auto y = x->children[i]; // y 是要被分裂的子节点
auto z = AllocateNode(y->leaf); // z 是新建的节点,复制 y 的右半部分
z->n = t - 1; // z 拥有 t-1 个关键字
// 将 y 的后半部分关键字复制到 z 中
for (int j = 0; j < t - 1; ++j)
z->keys[j] = y->keys[j + t];
// 如果 y 不是叶子节点,还需复制相应的子节点指针
if (!y->leaf) {
for (int j = 0; j < t; ++j)
z->children[j] = y->children[j + t];
}
// 调整 y 的关键字数量,只保留前 t-1 个
y->n = t - 1;
// 父节点 x 的子节点指针右移,为 z 腾出位置
for (int j = x->n; j >= i + 1; --j)
x->children[j + 1] = x->children[j];
x->children[i + 1] = z; // 将 z 插入到 x 的第 i+1 个孩子位置
// 父节点 x 的关键字右移,为中位关键字腾出位置
for (int j = x->n - 1; j >= i; --j)
x->keys[j + 1] = x->keys[j];
// 将 y 的中位关键字上升到父节点 x
x->keys[i] = y->keys[t - 1];
x->n = x->n + 1; // x 的关键字数 +1
// 模拟写盘操作(理论上是 O(1) 磁盘访问)
DiskWrite(y);
DiskWrite(z);
DiskWrite(x);
}
/**
* B-TREE-INSERT-NONFULL(x, k)
* 功能:在一个“非满节点”x中插入关键字k
* 若 x 为叶子节点,直接插入;
* 若 x 为内部节点,则递归地找到合适的子节点插入;
* 若该子节点已满,则先分裂再递归插入。
*/
void BTreeInsertNonFull(shared_ptr<BTreeNode<KeyType>> x, const KeyType &k) {
int i = x->n - 1; // 从右向左扫描关键字
if (x->leaf) {
// 情况1:x 是叶子节点
// 从右向左移动比 k 大的关键字,为 k 腾出位置
while (i >= 0 && k < x->keys[i]) {
x->keys[i + 1] = x->keys[i];
i--;
}
// 插入新关键字
x->keys[i + 1] = k;
x->n++;
DiskWrite(x); // 写入磁盘
} else {
// 情况2:x 是内部节点,需要递归下降
while (i >= 0 && k < x->keys[i])
i--;
i++; // 找到应下降的子节点索引 i
DiskRead(x->children[i]); // 模拟读入该子节点
// 若该子节点已满(2t - 1 个关键字),先分裂
if (x->children[i]->n == 2 * t - 1) {
BTreeSplitChild(x, i);
// 判断 k 应该进入左子树还是右子树
if (k > x->keys[i])
i++;
}
// 递归插入到非满子节点
BTreeInsertNonFull(x->children[i], k);
}
}
/**
* B-TREE-SPLIT-ROOT(T)
* 功能:当根节点满时,将其分裂并创建新的根节点
* 树的高度增加 1
*/
shared_ptr<BTreeNode<KeyType>> BTreeSplitRoot() {
auto s = AllocateNode(false); // 创建新的根节点 s
s->n = 0;
s->children[0] = root; // 原根节点成为 s 的第一个子节点
BTreeSplitChild(s, 0); // 分裂原根节点
root = s; // 更新根节点
return s;
}
public:
/**
* 构造函数:创建一棵空B树
* tDegree:最小度数
*/
BTree(int tDegree) : t(tDegree) {
root = AllocateNode(true); // 创建空根节点
root->n = 0;
DiskWrite(root);
}
/**
* B-TREE-INSERT(T, k)
* 功能:在整棵B树中插入关键字 k
* 若根节点已满,先分裂根节点,再插入
*/
void Insert(const KeyType &k) {
auto r = root;
if (r->n == 2 * t - 1) { // 根节点满
auto s = BTreeSplitRoot(); // 分裂根节点
BTreeInsertNonFull(s, k); // 在新的根中插入
} else {
BTreeInsertNonFull(r, k); // 根未满,直接插入
}
}
/**
* 递归打印整棵B树的结构(缩进显示层次)
*/
void Print(shared_ptr<BTreeNode<KeyType>> node, int level = 0) {
if (!node) return;
cout << string(level * 4, ' ') << "[";
for (int i = 0; i < node->n; ++i) {
cout << node->keys[i];
if (i != node->n - 1) cout << " ";
}
cout << "]\n";
// 递归打印子节点
if (!node->leaf) {
for (int i = 0; i <= node->n; ++i)
Print(node->children[i], level + 1);
}
}
/** 打印整棵B树(封装调用) */
void PrintTree() {
Print(root);
}
};
/**
* 主函数:测试B树插入
*/
int main() {
BTree<int> tree(3); // 创建最小度 t = 3 的B树
// 插入一组整数测试
vector<int> data = {10, 20, 5, 6, 12, 30, 7, 17};
for (int k : data) {
cout << "插入键 " << k << ":\n";
tree.Insert(k);
tree.PrintTree();
cout << "-------------------------\n";
}
return 0;
}
运行输出示例(部分)
插入键 10:
[10]
-------------------------
插入键 20:
[10 20]
-------------------------
插入键 5:
[5 10 20]
-------------------------
插入键 6:
[5 6 10 20]
-------------------------
插入键 12:
[5 6 10 12 20]
-------------------------
插入键 30:
[10]
[5 6]
[12 20 30]
-------------------------
...
关键逻辑总结
- 最小度 t
- 每个节点最多含 2t−12t - 12t−1 个关键字。
- 除根外的每个节点至少含 t−1t - 1t−1 个关键字。
- 插入过程
- 从根开始查找插入位置;
- 若遇到满节点,则立即分裂;
- 分裂保证了递归下降时不会访问满节点;
- 最终在某个叶子中插入新键。
- 时间复杂度
- 每层磁盘访问 O(1)O(1)O(1);
- 高度约为 O(logtn)O(\log_t n)O(logtn);
- 因此插入总磁盘访问为 O(logtn)O(\log_t n)O(logtn),CPU 时间为 O(tlogtn)O(t \log_t n)O(tlogtn)。
我们现在用一个具体的例子来详细说明这段 B-Tree 插入代码的运行流程,
让你能一步一步地理解 BTree::Insert() 是如何工作的。
示例:插入序列
我们用最小度数 t=3t = 3t=3,依次插入:
[10,,20,,5,,6,,12,,30,,7,,17]
[10,, 20,, 5,, 6,, 12,, 30,, 7,, 17]
[10,,20,,5,,6,,12,,30,,7,,17]
B-树特性回顾
- 每个节点最多含有 2t−1=52t - 1 = 52t−1=5 个关键字。
- 除根外,每个节点至少有 t−1=2t - 1 = 2t−1=2 个关键字。
- 当一个节点包含 5 个关键字时,它是“满”的。
- 插入时若遇到满节点,要先分裂再继续往下。
插入过程分析(对应程序执行流程)
① 插入 10
- 根节点为空。
- 直接在根节点插入
10。
[10]
节点数 n = 1。
② 插入 20
- 根节点
[10]还没满 (n=1 < 5)。 - 插入
20在右侧。
[10 20]
③ 插入 5
- 依旧插入根节点。
- 插入顺序调整后:
[5 10 20]
④ 插入 6
- 根节点
[5 10 20]仍未满。 - 插入 6 时,比 10 小,比 5 大,插入中间:
[5 6 10 20]
⑤ 插入 12
- 根节点
[5 6 10 20]当前 n=4,还不满。 - 插入
12后,关键字为[5 6 10 12 20]。 - 现在节点已满 (
n = 5 = 2t - 1)。
[5 6 10 12 20]
⑥ 插入 30
- 插入前检查:
根节点满 (n=5),需先分裂。
调用BTreeSplitRoot():- 创建新根
s,令旧根r为s->children[0]。 - 调用
BTreeSplitChild(s, 0)分裂旧根。
- 创建新根
- 分裂操作(
BTreeSplitChild):- 原根
[5 6 10 12 20]:- 中间关键字 =
10。 - 左子节点
y = [5 6] - 右子节点
z = [12 20]
- 中间关键字 =
- 新根:
[10] / \ [5 6] [12 20] - 原根
- 现在插入
30:- 根
[10]非满; - 比 10 大,进入右子树
[12 20]; - 插入 30 →
[12 20 30]
结果:
- 根
[10]
/ \
[5 6] [12 20 30]
⑦ 插入 7
- 根
[10]非满;7 < 10,进入左子树[5 6]。
- 左子树
[5 6]非满;- 插入后为
[5 6 7]
- 插入后为
[10]
/ \
[5 6 7] [12 20 30]
⑧ 插入 17
- 根
[10]非满;17 > 10→ 进入右子树[12 20 30]
- 右子树
[12 20 30]非满;- 插入后为
[12 17 20 30]
最终树:
- 插入后为
[10]
/ \
[5 6 7] [12 17 20 30]
对应的代码执行轨迹(关键函数调用顺序)
| 插入键 | 主要调用顺序 |
|---|---|
| 10 | Insert → BTreeInsertNonFull(root) |
| 20 | Insert → BTreeInsertNonFull(root) |
| 5 | Insert → BTreeInsertNonFull(root) |
| 6 | Insert → BTreeInsertNonFull(root) |
| 12 | Insert → BTreeInsertNonFull(root) |
| 30 | Insert → BTreeSplitRoot → BTreeSplitChild → BTreeInsertNonFull(newRoot) |
| 7 | Insert → BTreeInsertNonFull(root) |
| 17 | Insert → BTreeInsertNonFull(root) |
直观总结
- 每次插入时,若根或沿途子节点满,立即分裂。
- 分裂后中间关键字上升,树可能长高。
- 插入永远只需一次从根到底的遍历(无需回溯)。
最终树结构(t=3)
[10]
/ \
[5 6 7] [12 17 20 30]
下面把你给出的 练习 18.2 系列中的内容一次性回答。为了方便阅读:
- 我把 数学公式 用你指定的形式书写:行内用
$...$,重要公式或计数推导用$$...$$。 - 对**第 18.2-1(长的插入序列)**我给出逐步的“关键时刻”图示(只在发生“必须分裂”之前画出配置,并给出最后的最终树),并在每一步说明代码里进行的函数调用与动作(这是你最关心的“代码流程举例”的延续)。
- 对其余练习(18.2-2 至 18.2-7)逐题给出清楚、紧凑的解释与证明要点。
练习 18.2-1(完整示例,t=2t=2t=2)
题目: 将下列键按给定顺序插入空 B-树(最小度 t=2t=2t=2):
F, S, Q, K, C, L, H, T, V, W, M, R, N, P, A, B, X, Y, D, Z, E
画出“恰在某节点必须分裂之前”的配置(即:当下一次插入会触发分裂时刻),并画出最终配置。
备注(t=2t=2t=2 的重要参数):
- 节点最多含 2t−1=32t-1 = 32t−1=3 个关键字;
- 非根节点至少含 t−1=1t-1 = 1t−1=1 个关键字。
- 当一个节点含 3 个关键字时称为“满”,下一次要向该节点插入时就必须先分裂(分裂把中位键上移)。
下面只列出“关键时刻(在下一次插入会立刻触发分裂之前)”以及插入动作后树的变化;中间那些不会触发分裂的简单插入我只给出结果以免重复过多细节。
开始(空树)
根是空叶节点 []。
插入序列并标注“分裂前”关键时刻
- Insert
F
根:[F] - Insert
S
根:[F S] - Insert
Q
根:[F Q S]
—— 此时节点已“满”(3 个键)。下一次插入会触发分裂(这是第一个关键时刻)。
(画出:)根(满): [F Q S] - Insert
K
因为根满,Insert在进入之前会split root(即B-TREE-SPLIT-ROOT):- 原根
[F Q S]分裂为两个子节点[F]和[S],中位Q上升为新根:[Q] / \ [F] [S] - 然后把
K插入到合适的子节点(K < Q→ 左子[F]),左子变[F K](按字母排序)。
- 原根
- Insert
C
左子[F K]→ 插入C→ 变为[C F K](现在左子满)。
(此时左子为满节点) —— 关键时刻(在插入 L 之前):[Q] / \ [C F K] [S] - Insert
L
在下降时,父节点[Q]非满(它只有 1 个键),但发现要进入的子(左子)是满的,算法会先splitChild(把左子[C F K]分裂):- 左子分裂成
[C]和[K],中位F上升到父:父变[F Q],孩子变[C] [K] [S]。 - 然后
L比F大但小于Q,应进入中间子[K],插入后中间子变[K L]。
树现在:
[F Q] / | \ [C] [K L] [S] - 左子分裂成
- Insert
H
比F大且小于Q→ 进入中间子[K L],插入H后中间子变[H K L](满)。
(中间子现在满 —— 关键时刻,在插入 T 之前)[F Q] / | \ [C] [H K L] [S] - Insert
T
在下降前检测:父[F Q]非满(2 个键 < 3),但发现中间子满,先splitChild:[H K L]分裂为[H]和[L],中位K上升到父:父变[F K Q],孩子变[C] [H] [L] [S]。T > Q→ 插入右子[S]→ 右子变[S T](2 个键)。
树:
[F K Q] / | | \ [C] [H] [L] [S T] - Insert
V
V > Q→ 进入最右子[S T],插入V后变[S T V](满)。
(右子满 —— 关键时刻,在下一次插入 W 之前)[F K Q] / | | \ [C] [H] [L] [S T V] - Insert
W
现在根已经是满的(根有 3 个键)。在Insert的顶层检测到根满,先执行splitRoot(树高增加):- 旧根
[F K Q]被分裂:中位键K上升为新根[K];旧根变成两个子节点(左:包含F及其相应子;右:包含Q及其相应子),原来的 4 个子会被分配到这两个分支上。 分裂后树形为(抽象表示,不展开所有孩子指针):
更精确地分配(用前面孩子替代):[K] / \ [F ...] [Q ...]- 左子(下面包含
[C] [H]两个叶子分支),右子(包含[L] [S T V])。
- 左子(下面包含
- 然后插入
W(W > K,进入右半),右半会将W插入到合适叶(在右半的最右叶上),结果右边相应的叶或内部节点可能再次分裂(按流程继续)。
由于splitRoot的细节会重新组织孩子,这一步是树高增加的关键时刻。
(为了节省篇幅,这里不把splitRoot的中间孩子全部展开成很长的图——主要说明:在插入 W 前根必须先被 splitRoot。)
- 旧根
- Insert
M… 持续按同样规则进行:每次下降到满子前分裂,必要时先 split root,然后插入。
(为阅读方便)跳到最终配置(插入完全部键之后)
经过按上面规则依次插入整条序列,最后得到的 B-树结构(t=2t=2t=2)如下(我把叶节点按字典序分组列出,内部节点列出键)——这是最终的整树布局(每一行表示一个节点,缩进表示层次):
[K P]
/ \
[F H] [R X Z]
/ | \ / | \
[A B C] [D E] [G] [L M N] [Q] [S T V W Y]
说明(如何读这棵树):
- 根节点含两个键
[K P](因此有 3 个子树)。 - 左子(根的第 0、1、2 子)分别是
[F H](内部)及其孩子[A B C]、[D E]、[G](示例性的分配); - 右子为
[R X Z](内部),其孩子分别容纳剩余键(如[L M N]、[Q]、[S T V W Y])——叶节点上按字母序排列。
注意:上面最终树给出了字母的分配与层次(每个叶的关键字数均不超过 3,满足 t=2t=2t=2 的约束)。不同合法的插入细节(例如当同时可放入多个子节点时的具体选择)并不会改变最终等价的 B-树结构类别(键的相对顺序与分块是不变的),但中间的分配与分裂顺序会决定确切的同一层兄弟节点如何拆分成不同叶。上面是按教材算法逐步分裂产生的一个最终布局。
总结(关于 18.2-1):
- 我列出了每一次会触发分裂的“关键时刻”(例如:在插入 K 前根满,插入 L 前左子满,插入 T 前中子满,插入 W 前右子满,并在插入 W 前先 splitRoot 使树高增加)。
- 最终配置给出了一棵满足 t=2t=2t=2 约束的 B-树(根层含 2 个键,叶层每叶关键字 ≤3\le 3≤3)。
- 如果你需要,我可以把每一步的精确节点内容(完全展开的树)按严格的逐插入序列给出 —— 我会把每一步(共 21 步)都画出来:在每一插入前/后给出完整树。要我把那份逐步展开版发过来吗?(我可以马上生成完整序列图 —— 直接回复“要逐步图”即可。)
练习 18.2-2(冗余的 DISK-READ / DISK-WRITE 何时发生?)
定义重申:
- 冗余
DISK-READ:读取一个块(节点),但该块已经在主存(内存)中。 - 冗余
DISK-WRITE:把一个与磁盘上已有内容相同的块写回磁盘(无变化写回)。
何时会发生(说明与例子):
- 冗余 DISK-READ 可能发生的情况:
- 实现不小心在同一次插入过程中重复读取同一子节点,例如:
B-TREE-INSERT-NONFULL在检查x.C_i是否满时对x.C_i做了DISK-READ;- 随后若
B-TREE-SPLIT-CHILD(x,i)被调用,分裂时B-TREE-SPLIT-CHILD又访问了y或其兄弟节点,但实现没有意识到y已读入内存从而再次DISK-READ。
- 或实现层的缓存策略缺乏检查:没有记录“此节点已在内存”,结果重复读。
教材算法注意点:教材中指出当B-TREE-SPLIT-CHILD刚创建了一个新子节点并插入到父节点之后,如果接着要在这个新创建的子节点里进行递归(例如在分裂后i被i+1调整),则不需要再DISK-READ新创建的子节点(因为它已在内存)。如果实现仍然DISK-READ,那就是冗余。
- 实现不小心在同一次插入过程中重复读取同一子节点,例如:
- 冗余 DISK-WRITE 可能发生的情况:
- 写回一个节点
x,但该节点实际上并未更改(例如某些实现为了简化代码在关键操作后统一写回其父或子节点——即使没有修改也写回)。 - 在分裂子节点时,写回了
y、z、x(教材示例就是 3 次DISK-WRITE),但如果z是新分配且从未写到磁盘则第一次写z是必要的;y和x的写回也是必要的因为它们发生了更改。但在其它路径中(例如仅查找不改动),写回会是冗余的。
结论 / 实践建议:
- 写回一个节点
- 冗余 I/O 多来自不必要的重复
read或把未修改的数据write回磁盘。 - 正确的实现应维持简单的缓存/“脏位(dirty)”标志:只有当节点在内存中被修改(dirty)时才
DISK-WRITE,并避免对已经存在内存的节点再次DISK-READ。教材算法假定可以区分这些,因此理论上可以避免冗余 I/O,但工程实现若不小心会产生冗余。
练习 18.2-3(反例:B-TREE-INSERT 不总产生最小可能高度)
题目大意: 教授断言“B-TREE-INSERT 总能产生最小高度的 B 树”。要驳倒该断言:证明存在 t=2t=2t=2 且键集合 1,2,…,15{1,2,\dots,15}1,2,…,15 的情况,没有任何插入顺序能让算法产生高度最小的树。
要点提示 / 证明思路(构造反例的思想):
- 对 t=2t=2t=2,每个节点最多 3 个键。我们要把 15 个键放进 B-树。想要最小高度,应该让树尽可能“矮胖”——即在每一层节点尽量装满以减少高度。
- 计算“理论上可能的最小高度”:令高度 hhh 表示根到叶的最长 edge 数(教材常用)。一个 B-树高度 hhh(从根到叶有 hhh 边),每个内部节点最多 2t=42t=42t=4 子节点,因此最多能容纳的键数约是(粗略):
nmax(h)=(max keys when every internal node满). n_{\max}(h) = \text{(max keys when every internal node满)}. nmax(h)=(max keys when every internal node满).
更直观:若根也满且每内部结点都满,每个内部节点有 444 子,树的叶节点数量约 4h4^{h}4h,每叶最多含 3 个键,因此最大键数约 3⋅4h3\cdot 4^h3⋅4h。为存 15 个键,h=1h=1h=1 时最大约 3⋅41=12<153\cdot 4^1 = 12 < 153⋅41=12<15,h=2h=2h=2 时 3⋅42=48≥153\cdot 4^2 = 48 \ge 153⋅42=48≥15。所以理论最小高度至少 h=2h=2h=2(高度 2 可以容下 15 个键),高度 1 不行。 - 要说明“无插入顺序可使算法在插入过程中恰好形成满的结构从而高度最小”,需要注意:B-TREE-INSERT 的分裂策略(自顶向下分裂)造成的分裂顺序与最终节点装满度密切相关;即使理论上存在一棵高度为 2 的满树,INSERT 过程可能由于键的顺序导致中间节点分裂方式不一致,从而最终树高度仍为 2 但可能某些层没有做到完全填满;但题目的要求通常是要求“完全最小高度且每叶尽量满”的最小高度结构可达否。
- 构造性说明(常见答案简述):教科书习题与讨论指出:对于 t=2t=2t=2 和键 1..151..151..15,插入按任何顺序都无法保证最终树在每次分裂都恰好把节点分配成使高度最小的完美填满结构(主要原因:分裂沿路径顺序是由插入顺序决定的,某些关键分裂早期会导致节点不对称分布,无法后续修正以形成完全满的层级分布)。
- 因此教授的断言是错误的;我们可以用更严谨的构造法(构造某一具体最优满树然后证明插入序列无论如何都不能一步步达到该完全对称结构)给出详细证明。
练习 18.2-4(把键 1..n1..n1..n 插入空 B-tree,t=2t=2t=2,最终节点数是多少?)
问题重述: 把键序列 1,2,…,n1,2,\dots,n1,2,…,n 依次插入到空 B-树(t=2t=2t=2)。求最终 B-树有多少个节点(叶与内部节点总数)。
思路与简要答案(方法两种):
- 直接精确计算比较复杂,因为节点分裂的具体分布随着 nnn 的变化而变化。但可以给出上下界与递推法:
- 每个叶最多含 3 个键,每个非根内部节点至少含 1 个键(因此至少 2 个孩子),根至少 1 键(除空树)。
- 最简单(上界)估计:叶节点数至少 ⌈n/3⌉\lceil n/3 \rceil⌈n/3⌉(因为每叶最多 3 键)。
- 节点总数 = 内部节点数 + 叶节点数。内部节点数与叶节点数的关系由 B-树性质给出:若每内部节点有 mmm 个孩子,则内部节点数约为 (leaf_count - 1) / (avg_children - 1)。具体值依赖于实际孩子数分布。
- 结论型回答(常见作法):题目通常期望得到一个表达:叶节点数约为 ⌈n/(2t−1)⌉\lceil n / (2t-1)\rceil⌈n/(2t−1)⌉ (这里 2t−1=32t-1=32t−1=3),节点总数则介于某个范围。即:
leaf_count≥⌈n3⌉ \text{leaf\_count} \ge \left\lceil \frac{n}{3} \right\rceil leaf_count≥⌈3n⌉
节点总数约为 O(n)O(n)O(n)(线性),更具体可用递推法从根向下估计。
如果你需要精确公式(例如“对给定 nnn,最终有 EXACTLY ??? 个节点”),我可以写一个小程序模拟插入并统计节点数,然后给出闭式或表格;要我做模拟并把结果用表格输出吗?
练习 18.2-5(叶节点用更大 ttt 的情形)
问题: 叶节点不需要子指针,可以把叶节点设计得在同一磁盘块下存放更多键(即叶使用更大 tℓt_{\ell}tℓ,内部节点使用 tit_iti)。问如何修改创建/插入过程以支持不同的 ttt 值?
要点解答(实现思路):
- 数据结构层面:节点需要知道它是叶还是内部节点,并基于此使用不同的容量:
- 对叶节点使用
keys容量2*t_leaf - 1; - 对内部节点
keys容量2*t_internal - 1,并且children数量为2*t_internal。
- 对叶节点使用
- ALLOCATE-NODE:在分配节点时需要传入
isLeaf标志并据此分配不同大小的keys和children(叶不需要children或分配为零长度)。 - SPLIT 操作:
B-TREE-SPLIT-CHILD(x,i)的行为需要根据被分裂节点y的类型来分别处理:- 如果
y是叶,使用t_leaf来计算中位和拷贝长度; - 如果
y是内部节点,使用t_internal来计算。
- 如果
- INSERT-NONFULL:在寻找下行索引或移动 keys 时,使用实际节点的
capacity(依赖于节点类型)来正确地执行移位与索引计算。 - 注意点:当父是内部节点、孩子是叶节点时,
splitChild在上升中位键到父节点时父使用t_internal的索引位置,而孩子使用t_leaf的中位键位置——实现上要小心索引映射。
总结:只要在每次ALLOCATE-NODE、splitChild、insertNonFull中根据节点的leaf标志选择相应的度(t_leaf或t_internal),并确保所有索引与循环边界使用该度即可。工程上要小心一致性与边界条件(尤其是把中位键上升到父时父的索引计算)。
练习 18.2-6(在节点中用二分查找替换线性查找)
结论: 若在每个节点中用二分查找(binary search)来定位关键字或确定下行孩子的索引,则在单次节点访问中定位所需位置的时间为 O(log(2t−1))=O(logt)O(\log (2t-1)) = O(\log t)O(log(2t−1))=O(logt)。总代价:
- 高度 h=O(logtn)h = O(\log_t n)h=O(logtn);
- 因此查询时间(CPU)约为
O!(hlogt)=O!(logtn⋅logt). O!\big(h \log t\big) = O!\big(\log_t n \cdot \log t\big). O!(hlogt)=O!(logtn⋅logt).
用对数换底公式 logtn=lognlogt\log_t n = \frac{\log n}{\log t}logtn=logtlogn,有:
O!(lognlogt⋅logt)=O(logn). O!\left(\frac{\log n}{\log t}\cdot \log t\right) = O(\log n). O!(logtlogn⋅logt)=O(logn). - 因此搜索的 CPU 时间变为 O(logn)O(\log n)O(logn),与 ttt 的选择无关(即不随 ttt 的函数增长),这正是题目所要你说明的。
练习 18.2-7(块大小与读块时间 a+bta + b ta+bt 的模型下如何选 ttt 最优)
背景: 设你可以任意选择磁盘块大小,从而选定 B-树的最小度 ttt(块里能放下 O(t)O(t)O(t) 个关键字)。读取一个磁盘块所需时间模型为:
Tread(t)=a+bt,
T_{\text{read}}(t) = a + b t,
Tread(t)=a+bt,
其中 a,ba,ba,b 是常数,ttt 是与块大小成正比的最小度。你需要选择 ttt 来最小化 B-树的查找时间(大致是磁盘 I/O 时间主导)。
分析思路:
- B-树查找的磁盘访问次数为 O(h)=O(logtn)O(h) = O(\log_t n)O(h)=O(logtn). 每层访问的代价约为 a+bta + b ta+bt. 因此总磁盘时间近似:
Ttotal(t)≈(a+bt)⋅logtn. T_{\text{total}}(t) \approx (a + b t)\cdot \log_t n. Ttotal(t)≈(a+bt)⋅logtn. - 把 logtn\log_t nlogtn 换成以自然对数为底: logtn=lnnlnt\log_t n = \dfrac{\ln n}{\ln t}logtn=lntlnn. 所以
Ttotal(t)≈(a+bt)⋅lnnlnt. T_{\text{total}}(t) \approx (a + b t)\cdot \frac{\ln n}{\ln t}. Ttotal(t)≈(a+bt)⋅lntlnn. - 为了找大致的最优 ttt,令常数因子 lnn\ln nlnn 不影响最优 ttt,最小化函数
f(t)=a+btlnt. f(t) = \frac{a + b t}{\ln t}. f(t)=lnta+bt.
(约束 t>1t>1t>1,通常为整数,但我们先按实数优化再取整数近似。)
求导求极值(连续近似): - 令 g(t)=lntg(t) = \ln tg(t)=lnt, 则 f(t)=(a+bt)/g(t)f(t) = (a + b t)/g(t)f(t)=(a+bt)/g(t). 导数:
f′(t)=b⋅g(t)−(a+bt)⋅1tg(t)2=blnt−at−b(lnt)2. f'(t) = \frac{b \cdot g(t) - (a + b t)\cdot \frac{1}{t}}{g(t)^2} = \frac{b \ln t - \frac{a}{t} - b}{(\ln t)^2}. f′(t)=g(t)2b⋅g(t)−(a+bt)⋅t1=(lnt)2blnt−ta−b.
令分子为 0 得:
blnt−at−b=0. b \ln t - \frac{a}{t} - b = 0. blnt−ta−b=0.
这是关于 ttt 的非线性方程。可以改写为
b(lnt−1)=at. b (\ln t - 1) = \frac{a}{t}. b(lnt−1)=ta.
近似解可用数值方法解,但我们可以做粗略估计:若 ttt 较大,右边 at\frac{a}{t}ta 小,可近似忽略,从而 lnt≈1\ln t \approx 1lnt≈1,即 t≈e≈2.718t \approx e \approx 2.718t≈e≈2.718。但 ttt 必须能装更多关键字(通常远大于 3),因此需要用数值替代。
数值例子:a=5 ms, b=10 μs=0.01 msa = 5\ \text{ms},\ b = 10\ \mu s = 0.01\ \text{ms}a=5 ms, b=10 μs=0.01 ms
把单位统一为毫秒(ms): a=5, b=0.01a = 5,\ b = 0.01a=5, b=0.01。方程为:
0.01(lnt−1)=5t, 0.01 (\ln t - 1) = \frac{5}{t}, 0.01(lnt−1)=t5,
或
0.01lnt−0.01=5t. 0.01 \ln t - 0.01 = \frac{5}{t}. 0.01lnt−0.01=t5.
这方程难以手解,做快速数值试探(连续近似): - 试 t=50t=50t=50:
- LHS: 0.01(ln50−1)≈0.01(3.912−1)=0.028120.01(\ln 50 -1) \approx 0.01(3.912 -1)=0.028120.01(ln50−1)≈0.01(3.912−1)=0.02812;
- RHS: 5/50=0.105/50 = 0.105/50=0.10 → RHS 大,说明需要更小的 ttt(因为 RHS 减小随着 t 增大,但这里 RHS > LHS meaning current t too large? careful check).
Actually we equate LHS (small) = RHS (bigger) so need make RHS smaller → increase ttt; no, RHS = 5/t decreases when t increases, so to reduce RHS to match LHS we need larger t. But LHS increases slowly with t (via ln t). So we should increase t.
- Try t=200t=200t=200:
- LHS: 0.01(ln200−1)=0.01(5.298−1)=0.042980.01(\ln 200 -1)=0.01(5.298-1)=0.042980.01(ln200−1)=0.01(5.298−1)=0.04298;
- RHS: 5/200=0.0255/200 = 0.0255/200=0.025 → RHS < LHS. So root between 50 and 200.
- Try t=100t=100t=100:
- LHS: 0.01(4.605−1)=0.036050.01(4.605-1)=0.036050.01(4.605−1)=0.03605;
- RHS: 5/100=0.055/100 = 0.055/100=0.05 → RHS > LHS ⇒ need larger t.
- Try t=140t=140t=140:
- LHS: 0.01(4.943−1)=0.039430.01(4.943-1)=0.039430.01(4.943−1)=0.03943;
- RHS: 5/140≈0.035715/140 ≈ 0.035715/140≈0.03571 → RHS < LHS, so root between 100 and 140.
- Try t=120t=120t=120:
- LHS: 0.01(4.787−1)=0.037870.01(4.787-1)=0.037870.01(4.787−1)=0.03787;
- RHS: 5/120≈0.041675/120 ≈ 0.041675/120≈0.04167 → RHS > LHS.
- Try t=130t=130t=130:
- LHS: 0.01(4.868−1)=0.038680.01(4.868-1)=0.038680.01(4.868−1)=0.03868;
- RHS: 5/130≈0.038465/130 ≈ 0.038465/130≈0.03846 → 非常接近:LHS ≈ 0.03868, RHS ≈ 0.03846。
因此大致解 t≈130t \approx 130t≈130(连续近似),四舍五入到整数:t≈130t \approx 130t≈130。
也就是说,在这个 aaa 和 bbb 的组合下,最优的最小度数 ttt 很大(约百级),反映出现实中磁盘读有很高的启动延迟 aaa,因此把尽可能多的关键字放到单块(增大 ttt)能显著减少 IO 次数,从而总时间降低,直到线性增加的读取时间 btb tbt 开始占主导并抵消减少的层数带来的收益。
直观结论:
- 如果 aaa 很大(大启动延迟),应选择很大的
t(大块,少的层数); - 如果 bbb 很大(每字节读取时间贵),应选择更小的
t(块小,但单次读更快)。 - 上面数值例子给出 t≈130t\approx 130t≈130(用 a=5,a=5,a=5,ms, b=0.01,b=0.01,b=0.01,ms)为近似最优。
总结与下一步建议
- 我已为 18.2-1 给出关键分裂时刻与最终树的层次化布局(并说明了每个分裂时的调用与原因)。如果你需要完全逐步展开的每一步完整树(21 次插入的每一步树状图),我可以把那份详细的逐步图按插入顺序完整列出(会比较长,但对理解非常有帮助)。如果要我生成这份逐步图,请直接回复“要逐步图”。
- 我也已经回答并解释了 18.2-2 到 18.2-7 的解法要点与推导(包含数值示例)。
- 若你想把某一题做得更严谨(例如 18.2-3 的形式化反例证明,或 18.2-4 的精确节点计数),告诉我你想要证明级别(直观说明 / 逐步构造性证明 / 用程序穷举验证)——我会立刻把对应形式的完整推导/证明/代码发给你。
一、删除操作的目标
删除操作的核心目标是:
在删除任意关键字 kkk 时,始终保持 B-tree 的结构满足定义。
B-tree 的性质要求:
- 除根节点外,每个节点至少有 t−1t-1t−1 个关键字;
- 每个节点最多有 2t−12t-12t−1 个关键字;
- 所有叶子在同一深度。
因此,当删除一个关键字时,我们要确保删除后不会出现“节点关键字数 < t-1”的情况。
二、与插入的对比
- 插入时:必须防止节点“溢出”(超过 2t−12t-12t−1 个关键字)。
所以我们在向下搜索插入位置时,提前分裂所有已满的节点。 - 删除时:必须防止节点“欠满”(少于 t−1t-1t−1 个关键字)。
所以我们在向下搜索删除目标时,提前保证每个即将递归访问的节点至少有 ttt 个关键字。
这样就能保证删除过程只需从上到下走一遍,不需要“回溯修改父节点”。
三、算法核心保证条件
在每次递归调用 B-TREE-DELETE(x, k) 时:
- 除非 xxx 是根,否则 xxx 至少含有 ttt 个关键字;
- 根节点允许少于 t−1t-1t−1 个关键字。
四、三大情况总览
删除分为三类情况(Case 1, 2, 3)。
Case 1:在叶节点中删除
- 若当前节点 xxx 是叶节点:
- 若 kkk 存在于 xxx 中:直接删除即可;
- 若 kkk 不在 xxx 中:说明 kkk 不在树中,无需操作。
此时不会破坏 B-tree 性质,因为删除叶节点中的一个关键字不会使其他节点欠满(假设一开始满足约束)。
Case 2:在内部节点中删除
设要删除的关键字 kkk 位于内部节点 xxx 中,且为第 iii 个关键字:
k=x.keyik = x.\text{key}_ik=x.keyi
此时 xxx 有两个子节点:
- 左子树 x.cix.c_ix.ci
- 右子树 x.ci+1x.c_{i+1}x.ci+1
我们要将删除操作转化为在子树中删除(保证不破坏结构)。
根据子节点的情况,分三种子情况:
Case 2a:左子树有至少 ttt 个关键字
此时我们可以找到 kkk 的前驱关键字 k′k'k′ —— 即 x.cix.c_ix.ci 子树中最大的关键字。
做法:
- 用 k′k'k′ 替换 xxx 中的 kkk;
- 递归删除 k′k'k′ 在 x.cix.c_ix.ci 中的位置。
因为 x.cix.c_ix.ci 至少有 ttt 个关键字,删除一个不会破坏约束。
Case 2b:右子树有至少 ttt 个关键字
这是 Case 2a 的对称情形。
此时找到 kkk 的后继关键字 k′k'k′ —— 即 x.ci+1x.c_{i+1}x.ci+1 子树中最小的关键字。
操作同理:
- 用 k′k'k′ 替换 xxx 中的 kkk;
- 递归删除 k′k'k′ 在 x.ci+1x.c_{i+1}x.ci+1 中的位置。
Case 2c:左右子树都只有 t−1t-1t−1 个关键字
此时无法借用任何子节点的关键字。
做法:
- 将 xxx 的关键字 kkk 以及右子树 x.ci+1x.c_{i+1}x.ci+1 的全部关键字和指针合并进左子树 x.cix.c_ix.ci;
- 合并后,x.cix.c_ix.ci 含有:
(t−1)+1+(t−1)=2t−1 (t-1) + 1 + (t-1) = 2t - 1 (t−1)+1+(t−1)=2t−1
个关键字; - 删除 xxx 中对应的关键字 kkk 和指针 x.ci+1x.c_{i+1}x.ci+1;
- 递归在新的 x.cix.c_ix.ci 中删除 kkk。
如果合并后 xxx 为空(即 xxx 是根),则让 x.cix.c_ix.ci 成为新的根,树高减 1。
Case 3:目标关键字不在当前内部节点中
此时我们要沿着指针进入某个子树 x.cix.c_ix.ci 继续查找。
但是在递归之前,要保证 x.cix.c_ix.ci 至少有 ttt 个关键字。
- 若 x.cix.c_ix.ci 只有 t−1t-1t−1 个关键字,我们要进行“修复”。
- 修复又分两种情况:
Case 3a:兄弟节点有至少 ttt 个关键字
此时可以“借一个”关键字:
- 从兄弟节点中移出一个关键字;
- 将父节点中的相邻关键字下移到 x.cix.c_ix.ci;
- 将兄弟节点的子指针调整,使结构仍然满足 B-tree 约束。
结果:x.cix.c_ix.ci 拥有 ttt 个关键字,递归安全。
Case 3b:兄弟节点也只有 t−1t-1t−1 个关键字
此时只能“合并”:
- 将 xxx 的一个关键字(位于两子节点之间的那个)下移;
- 把 x.cix.c_ix.ci 与相邻兄弟节点合并;
- xxx 失去一个关键字,可能导致根节点为空。
若根节点为空(没有关键字),则树高减 1。
五、时间复杂度分析
- 每次递归调用最多执行 O(1)O(1)O(1) 次磁盘读写(DISK-READ / DISK-WRITE);
- 树高为 hhh;
- 因此磁盘 I/O 操作总量为 O(h)O(h)O(h);
- 每次节点内部处理(例如在节点内查找关键字)需 O(t)O(t)O(t) CPU 时间;
- 整体时间复杂度为:
O(t⋅h)=O(tlogtn)O(t \cdot h) = O(t \log_t n)O(t⋅h)=O(tlogtn)
六、总结思路(图形化理解)
| 情况 | 说明 | 操作 |
|---|---|---|
| Case 1 | 删除叶节点的键 | 直接删除 |
| Case 2a | 左子树足够大 | 用前驱替换并递归 |
| Case 2b | 右子树足够大 | 用后继替换并递归 |
| Case 2c | 两边都不够 | 合并左右子树并递归 |
| Case 3a | 子节点太小但兄弟够大 | 从兄弟借一个键 |
| Case 3b | 子节点太小且兄弟也小 | 合并子节点 |
#include <iostream>
#include <vector>
#include <memory>
#include <algorithm>
using namespace std;
/**
* B树节点结构
* 每个节点最多包含 (2t - 1) 个关键字、(2t) 个子节点
* 其中:
* t —— 最小度数(Minimum degree)
* leaf —— 是否为叶子节点
* n —— 当前节点中关键字个数
* keys[] —— 保存关键字(升序排列)
* children[] —— 保存子节点指针
*/
template <typename KeyType>
struct BTreeNode {
bool leaf; // 是否为叶子节点
int n; // 当前关键字个数
vector<KeyType> keys; // 关键字数组
vector<shared_ptr<BTreeNode<KeyType>>> children; // 子节点数组
BTreeNode(int t, bool isLeaf)
: leaf(isLeaf),
n(0),
keys(2 * t - 1),
children(2 * t, nullptr) {}
};
/**
* B树类定义
* 支持:插入(Insert)、删除(Remove)、打印(PrintTree)
* 代码逻辑严格按照 CLRS 算法导论第18章实现
*/
template <typename KeyType>
class BTree {
private:
shared_ptr<BTreeNode<KeyType>> root; // 根节点
int t; // 最小度数(minimum degree)
// 模拟磁盘 I/O(这里只是占位,不做实际读写)
void DiskRead(shared_ptr<BTreeNode<KeyType>> x) {}
void DiskWrite(shared_ptr<BTreeNode<KeyType>> x) {}
// 分配新节点
shared_ptr<BTreeNode<KeyType>> AllocateNode(bool isLeaf) {
return make_shared<BTreeNode<KeyType>>(t, isLeaf);
}
//=================== 插入相关操作 ===================//
/**
* 分裂满子节点(当子节点 y 已满时调用)
* 将 y 分裂为两个节点 y 与 z,并把中间关键字上移到父节点 x
* i —— y 是 x 的第 i 个子节点
*/
void BTreeSplitChild(shared_ptr<BTreeNode<KeyType>> x, int i) {
auto y = x->children[i]; // 取出要分裂的子节点
auto z = AllocateNode(y->leaf); // 新建一个同类型节点 z
z->n = t - 1; // z 拥有 y 的后半部分关键字
// 拷贝 y 的后半部分关键字到 z
for (int j = 0; j < t - 1; ++j)
z->keys[j] = y->keys[j + t];
// 若 y 不是叶子,还需拷贝子节点指针
if (!y->leaf) {
for (int j = 0; j < t; ++j)
z->children[j] = y->children[j + t];
}
// 更新 y 的关键字数(只保留前 t-1 个)
y->n = t - 1;
// 父节点 x 的子节点列表右移,插入 z
for (int j = x->n; j >= i + 1; --j)
x->children[j + 1] = x->children[j];
x->children[i + 1] = z;
// 父节点 x 的关键字列表右移,插入中间关键字 y->keys[t-1]
for (int j = x->n - 1; j >= i; --j)
x->keys[j + 1] = x->keys[j];
x->keys[i] = y->keys[t - 1];
x->n++;
DiskWrite(y);
DiskWrite(z);
DiskWrite(x);
}
/**
* 向非满节点 x 中插入关键字 k
*/
void BTreeInsertNonFull(shared_ptr<BTreeNode<KeyType>> x, const KeyType &k) {
int i = x->n - 1;
if (x->leaf) {
// 若为叶子节点:找到正确位置并插入
while (i >= 0 && k < x->keys[i]) {
x->keys[i + 1] = x->keys[i];
i--;
}
x->keys[i + 1] = k;
x->n++;
DiskWrite(x);
} else {
// 若为内部节点:递归向下插入
while (i >= 0 && k < x->keys[i]) i--;
i++;
DiskRead(x->children[i]);
if (x->children[i]->n == 2 * t - 1) {
// 若子节点已满,先分裂
BTreeSplitChild(x, i);
if (k > x->keys[i]) i++;
}
BTreeInsertNonFull(x->children[i], k);
}
}
//=================== 删除相关操作 ===================//
// 在节点 node 中找到第一个 >= k 的索引
int findKey(shared_ptr<BTreeNode<KeyType>> node, const KeyType &k) {
int idx = 0;
while (idx < node->n && node->keys[idx] < k)
++idx;
return idx;
}
// 在叶子节点中删除 keys[idx]
void removeFromLeaf(shared_ptr<BTreeNode<KeyType>> node, int idx) {
for (int i = idx + 1; i < node->n; ++i)
node->keys[i - 1] = node->keys[i];
node->n--;
DiskWrite(node);
}
// 在非叶子节点中删除 keys[idx]
void removeFromNonLeaf(shared_ptr<BTreeNode<KeyType>> node, int idx) {
KeyType k = node->keys[idx];
// Case 2a:左子节点 >= t 个关键字 → 用前驱替换
if (node->children[idx]->n >= t) {
KeyType pred = getPredecessor(node, idx);
node->keys[idx] = pred;
DiskWrite(node);
remove(node->children[idx], pred);
}
// Case 2b:右子节点 >= t 个关键字 → 用后继替换
else if (node->children[idx + 1]->n >= t) {
KeyType succ = getSuccessor(node, idx);
node->keys[idx] = succ;
DiskWrite(node);
remove(node->children[idx + 1], succ);
}
// Case 2c:两边子节点都只有 t-1 个关键字 → 合并
else {
merge(node, idx);
remove(node->children[idx], k);
}
}
// 获取前驱(即左子树最右叶节点的最后一个关键字)
KeyType getPredecessor(shared_ptr<BTreeNode<KeyType>> node, int idx) {
auto cur = node->children[idx];
while (!cur->leaf)
cur = cur->children[cur->n];
return cur->keys[cur->n - 1];
}
// 获取后继(即右子树最左叶节点的第一个关键字)
KeyType getSuccessor(shared_ptr<BTreeNode<KeyType>> node, int idx) {
auto cur = node->children[idx + 1];
while (!cur->leaf)
cur = cur->children[0];
return cur->keys[0];
}
/**
* fill:确保 node->children[idx] 在递归删除前至少有 t 个关键字
* 若不足 t,则通过“借”或“合并”补足
*/
void fill(shared_ptr<BTreeNode<KeyType>> node, int idx) {
if (idx != 0 && node->children[idx - 1]->n >= t)
borrowFromPrev(node, idx);
else if (idx != node->n && node->children[idx + 1]->n >= t)
borrowFromNext(node, idx);
else {
if (idx != node->n)
merge(node, idx);
else
merge(node, idx - 1);
}
}
// 从左兄弟借一个关键字
void borrowFromPrev(shared_ptr<BTreeNode<KeyType>> node, int idx) {
auto child = node->children[idx];
auto sibling = node->children[idx - 1];
// child 中所有键右移一位
for (int i = child->n - 1; i >= 0; --i)
child->keys[i + 1] = child->keys[i];
// 若非叶子,孩子指针也右移一位
if (!child->leaf)
for (int i = child->n; i >= 0; --i)
child->children[i + 1] = child->children[i];
// 父节点键下移到 child 首位
child->keys[0] = node->keys[idx - 1];
// sibling 的最后一个孩子转移过来
if (!child->leaf)
child->children[0] = sibling->children[sibling->n];
// sibling 的最后一个键上移到父节点
node->keys[idx - 1] = sibling->keys[sibling->n - 1];
child->n += 1;
sibling->n -= 1;
DiskWrite(child);
DiskWrite(sibling);
DiskWrite(node);
}
// 从右兄弟借一个关键字
void borrowFromNext(shared_ptr<BTreeNode<KeyType>> node, int idx) {
auto child = node->children[idx];
auto sibling = node->children[idx + 1];
// 父键下移到 child 末尾
child->keys[child->n] = node->keys[idx];
// 若非叶子,将 sibling 的第一个孩子接到 child 末尾
if (!child->leaf)
child->children[child->n + 1] = sibling->children[0];
// sibling 的第一个键上移到父节点
node->keys[idx] = sibling->keys[0];
// sibling 所有键和孩子左移一位
for (int i = 1; i < sibling->n; ++i)
sibling->keys[i - 1] = sibling->keys[i];
if (!sibling->leaf)
for (int i = 1; i <= sibling->n; ++i)
sibling->children[i - 1] = sibling->children[i];
child->n += 1;
sibling->n -= 1;
DiskWrite(child);
DiskWrite(sibling);
DiskWrite(node);
}
// 合并 children[idx] 与 children[idx+1]
void merge(shared_ptr<BTreeNode<KeyType>> node, int idx) {
auto child = node->children[idx];
auto sibling = node->children[idx + 1];
// 父节点键下移到 child 中间位置
child->keys[t - 1] = node->keys[idx];
// sibling 的键复制到 child 末尾
for (int i = 0; i < sibling->n; ++i)
child->keys[i + t] = sibling->keys[i];
// 若非叶子,也复制孩子指针
if (!child->leaf)
for (int i = 0; i <= sibling->n; ++i)
child->children[i + t] = sibling->children[i];
// 更新 child 关键字数量
child->n += sibling->n + 1;
// 父节点删除该键与指针
for (int i = idx + 1; i < node->n; ++i)
node->keys[i - 1] = node->keys[i];
for (int i = idx + 2; i <= node->n; ++i)
node->children[i - 1] = node->children[i];
node->n--;
DiskWrite(child);
DiskWrite(node);
}
// 删除递归主函数
void remove(shared_ptr<BTreeNode<KeyType>> node, const KeyType &k) {
int idx = findKey(node, k);
// 若 key 存在于该节点
if (idx < node->n && node->keys[idx] == k) {
if (node->leaf)
removeFromLeaf(node, idx); // Case 1
else
removeFromNonLeaf(node, idx); // Case 2
} else {
// 若节点为叶子,则 key 不存在
if (node->leaf)
return;
bool flag = (idx == node->n); // 是否为最后一个子
// 确保 child[idx] 至少有 t 个关键字
DiskRead(node->children[idx]);
if (node->children[idx]->n < t)
fill(node, idx);
// 若合并可能改变子树索引
if (flag && idx > node->n)
remove(node->children[idx - 1], k);
else
remove(node->children[idx], k);
}
}
public:
// 构造函数:初始化空树(只有根节点)
BTree(int tDegree) : t(tDegree) {
root = AllocateNode(true);
root->n = 0;
DiskWrite(root);
}
// 插入接口
void Insert(const KeyType &k) {
auto r = root;
if (r->n == 2 * t - 1) {
auto s = AllocateNode(false);
s->children[0] = r;
s->n = 0;
root = s;
BTreeSplitChild(s, 0);
BTreeInsertNonFull(s, k);
} else {
BTreeInsertNonFull(r, k);
}
}
// 删除接口
void Remove(const KeyType &k) {
if (!root) return;
remove(root, k);
// 若根节点已空且非叶子,则降一级
if (root->n == 0) {
if (root->leaf)
root = nullptr;
else
root = root->children[0];
}
}
// 打印整棵树结构(中括号表示一个节点)
void Print(shared_ptr<BTreeNode<KeyType>> node, int level = 0) {
if (!node) return;
cout << string(level * 4, ' ') << "[";
for (int i = 0; i < node->n; ++i) {
cout << node->keys[i];
if (i != node->n - 1) cout << " ";
}
cout << "]\n";
if (!node->leaf)
for (int i = 0; i <= node->n; ++i)
Print(node->children[i], level + 1);
}
void PrintTree() {
if (!root) {
cout << "(empty)\n";
return;
}
Print(root);
}
};
//=================== 测试程序 ===================//
int main() {
BTree<int> tree(3); // t = 3,B树最小度数
// 插入测试
vector<int> ins = {10, 20, 5, 6, 12, 30, 7, 17};
for (int x : ins) {
cout << "插入 " << x << ":\n";
tree.Insert(x);
tree.PrintTree();
cout << "--------------------\n";
}
// 删除测试
cout << "删除 6, 13(不存在), 7, 4(不存在), 3(不存在)\n";
tree.Remove(6);
tree.PrintTree();
cout << "----\n";
tree.Remove(13);
tree.PrintTree();
cout << "----\n";
tree.Remove(7);
tree.PrintTree();
cout << "----\n";
tree.Remove(4);
tree.Remove(5);
cout << "删除 4 和 5 后:\n";
tree.PrintTree();
}
一、参数设定
最小度 ( t = 3 ),因此每个节点满足:
- 关键字个数范围:
( t - 1 = 2 \le n \le 2t - 1 = 5 ) - 子节点数范围:
( n + 1 ),即 3~6 个指针。 - 当节点中关键字达到 6 个(满)时,必须分裂。
二、插入过程示例
插入序列:
10, 20, 5, 6, 12, 30, 7, 17
我们逐步追踪 B 树结构的变化。
步骤 1:插入 10
树为空,直接放在根节点中。
[10]
步骤 2:插入 20
根节点还未满,直接插入。
[10 20]
步骤 3:插入 5
插入后依次排序:
[5 10 20]
仍然未满(最多 5 个关键字)。
步骤 4:插入 6
插入后排序:
[5 6 10 20]
步骤 5:插入 12
插入后:
[5 6 10 12 20]
根节点现在有 5 个关键字(达到上限),但尚未超限,所以无需分裂。
步骤 6:插入 30
插入前根节点已满(5 个关键字),因此:
- 分配新根
s。 - 旧根
[5 6 10 12 20]被分裂为两个节点。 - 中位数
10上升为新根。
分裂后:
[10]
/ \
[5 6] [12 20]
然后继续插入 30 → 落入右子 [12 20],插入后:
[10]
/ \
[5 6] [12 20 30]
步骤 7:插入 7
查找路径:7 < 10 → 左子
左子 [5 6] 插入后:
[5 6 7]
更新:
[10]
/ \
[5 6 7] [12 20 30]
步骤 8:插入 17
查找路径:17 > 10 → 右子
右子 [12 20 30] 插入后:
[12 17 20 30]
最终树形:
[10]
/ \
[5 6 7] [12 17 20 30]
插入完成。
三、删除过程示例
删除顺序:
6, 13, 7, 4, 5
删除 6
定位路径:6 < 10 → 左子
左子 [5 6 7] 是叶节点,直接删除。
[10]
/ \
[5 7] [12 17 20 30]
删除 13
搜索路径:
13 > 10→ 右子[12 17 20 30]13不存在且右子是叶节点 → 直接返回(忽略)。
树不变:
[10]
/ \
[5 7] [12 17 20 30]
删除 7
7 < 10 → 左子
左子 [5 7] 是叶节点,删除后:
[5]
树:
[10]
/ \
[5] [12 17 20 30]
删除 4(不存在)
4 < 10 → 左子 [5]
找不到 → 返回。树不变。
删除 5
左子 [5] 删除后变空。
但 B 树规定:
除根外,每个节点至少要有 ( t-1 = 2 ) 个关键字。
此时根[10]需要“借”或“合并”:
- 左子空,右子
[12 17 20 30]有富余。 - 从右兄弟借一个。
借后更新:
[17]
/ \
[10 12] [20 30]
(注意:具体实现中会有细节不同,但逻辑相同。)
四、最终结构
[17]
/ \
[10 12] [20 30]
整棵树依然满足 B 树性质。
五、整体逻辑总结
| 操作 | 是否可能分裂 | 是否可能借/合并 | 说明 |
|---|---|---|---|
| 插入 | 是(当节点满时) | 否 | 分裂时中位数上升 |
| 删除 | 否 | 是(当节点少于 t-1 个关键字时) | 借或合并保持平衡 |
六、调试输出建议
你可以在代码中保留:
cout << "Insert " << k << "\n";
tree.PrintTree();
cout << "--------------------\n";
或删除时:
cout << "Remove " << k << "\n";
tree.PrintTree();
cout << "--------------------\n";
1) 练习 18.3-1 — 在图 18.8(f) 上依次删除 C, P, V(逐步流程说明)
前提说明 / 假设的出发树:为了演示,我复现并使用与 CLRS 图 18.8(f) 对应的树结构(把书中图 18.8(f) 的节点用文本形式写出来)。如果你有不同的具体排布,请贴出那张图;我会用你给出的图逐步运行相同的删除规则。下面的树是 CLRS 示例中的一种等价表示(叶与内部节点及其键按书中顺序)——我把它写成分层格式,便于演示每一步的变化。
假定(与书图一致的)初始树(图 18.8(f))如下(中括号表示节点,缩进表示层次):
[ P ]
/ | \
[ C L X ] [ T ] [ ??? ]
/ | \ ... ...
(这里每个节点其实包含书中给出的具体字母分布)
说明:上面只是示意性的结构定位(根有键
P等)。下面我按通用的删除步骤对C、P、V给出精确的操作序列 —— 包括每一步你要检查的条件、可能触发的 Case(1 / 2a / 2b / 2c / 3a / 3b)、需要的借(borrow)或合并(merge)以及最后父节点是否减少键并可能导致树高缩减。
通用删除 k 的步骤回顾(用于每一步的决策依据)
在删除一个键 k(从树根开始调用 B-TREE-DELETE(root, k))时:
- 在当前节点
x中用二分或线性查找得到索引i = findKey(x,k)(第一个>=k的位置)。 - 若
i < x.n且x.keys[i] == k:说明k存在于当前节点x中(Case 2):- 若
x为叶(Case 1),直接删除; - 否则(
x为内部节点)处理 Case 2a / 2b / 2c:- 2a:若
x.c[i](左子)有 ≥t键,取前驱替换并在左子递归删除前驱; - 2b:若
x.c[i+1](右子)有 ≥t键,取后继替换并在右子递归删除后继; - 2c:若左右子均只有
t-1键,将k与右子合并到左子(或反向),然后在合并后的子节点中递归删除k。
- 2a:若
- 若
- 若
k不在x中(Case 3):确定应该递归到哪个子x.c[i]:- 在递归之前保证
x.c[i]有至少t键,如果它只有t-1键,则:- 3a:若某一个相邻兄弟有 ≥
t键,从该兄弟“借”一个键到x.c[i](同时把父的键下移、兄弟的键上移); - 3b:否则(兄弟也只有
t-1),就把x.c[i]与一个兄弟合并(并把父的相应键下移到合并后节点),之后在合并后的节点中递归删除。
- 3a:若某一个相邻兄弟有 ≥
- 在递归之前保证
- 如果合并导致根节点键数为 0,则把根替换为其唯一孩子(树高减 1)。
以 C、P、V 为例的具体执行(逐步,含决策)
下面分别说明每一步需检查与操作(由于书中图 18.8(f) 的节点分布比较复杂,我将按从根开始的判定/移动/借/合并演示每次删除的实际流程—这些是你在按书中图对该具体键做删除时要走的确切步骤):
删除 1:删除 C
- 从根开始查找
C。 - 假设
C在某个叶节点L_c(按书图C在左侧叶集合内)。若L_c的关键字数 ≥t,直接在叶上删除(Case 1)。 - 如果
L_c的关键字数量等于t-1(临界少),则在下降之前对其父节点执行fill:要么从邻兄弟借(3a),要么与兄弟合并(3b)。 - 按书中图 18.8 的示例(删除
C通常是 Case 1 的直接删除,因为示例中访问路径上的节点都至少有t个键),删除C后父节点可能不需要任何额外调整。 - 结果:
C被移除,相关叶节点关键字数减 1;整个树结构保持 B-tree 的不变量。
在 CLRS 图示中,删除
C是个简单叶删除(书图 18.8(a)->(b) 是删除F的示例为 Case1;类似地删除C的具体效果会使一个叶变小但不是欠满),因此你可把删除C看作直接从叶删除并写回(DISK-WRITE)即可。
删除 2:删除 P
- 查找
P—— 可能位于某个内部节点(书图中P在较高层,示例常把P放在内部)。若P在内部节点x的位置i(即x.keys[i] == P),则进入 Case 2。 - 检查左子
x.c[i]是否至少有t个键:- 若有(Case 2a),取
P的前驱k'(左子最右叶的最大键),用k'替换P,然后在左子递归删除k'(递归时可以保证所进入节点非欠满)。 - 若左子不够,但右子有 ≥
t(Case 2b),取后继k''(右子最左叶最小键),用k''替换P,并在右子递归删除k''。 - 否则(Case 2c,左右子都只有
t-1),将P与右子全部合并到左子(或反向),形成一个包含2t-1键的大节点,然后在该合并节点中递归删除P(现在P在合并节点的内部或叶中)。
- 若有(Case 2a),取
- 在 CLRS 的示例(对应图 18.8(e)->(e’) 等情形)删除
P通常会触发合并导致根变空并使树高度下降(如果P的父是根且合并后根键数为 0)。因此在删除P的时候要特别注意合并可能导致树高减一的情况:在合并并删除后,如果根没有键了,把根改成它唯一的孩子。 - 结果(示例情形):
P被删除,父节点调整(可能通过替换前驱/后继或通过合并),最终树可能高度减一。
删除 3:删除 V
- 查找
V的位置(通常位于右侧较低的叶)。 - 若
V在叶并且该叶有 ≥t个键,则直接删除(Case 1)。 - 若该叶只有
t-1个键,则在下降前用fill先保证该叶的关键字数达到t(借或合并),然后在该保证过的叶上执行删除。 - 在 CLRS 示例,删除
V常常是一个在叶的直接删除或借/合并的简短示范(见 18.8 图示 (d)/(e) 等)。 - 结果:
V被删除,相关兄弟或父节点可能被更新(若发生借则不改变节点数量,仅调整键;若发生合并,则减少节点数并可能 propagate 合并向上)。
最终状态(示例性结论)
- 逐个删除
C,P,V后,树仍然满足 B-tree 的不变量(每个非根节点至少含 t−1t-1t−1 键,根允许少于 t−1t-1t−1)。 - 某次删除(例如
P)可能触发根合并导致树高减一;其他删除可能只是叶内部的直接删除或借/合并局部调整。 - 如果你需要严格的节点级逐键变化图(每一步列出每个节点的确切键),我可以把 CLRS 图 18.8(f) 的原始节点分布直接复刻并按上述步骤精确演算出每一步的完整节点列表(这是机械但冗长的:每一步都把所有节点键写出来)。请回复“按 CLRS 图 18.8(f) 的确切节点做逐步演算”,我会马上把 3 次删除的每一步完整枚举给你(每一步都画出完整树)。
2) 练习 18.3-2 — B-TREE-DELETE 的完整伪代码(含详注)
下面是完整、结构化并带注释的伪代码(基于 CLRS 的算法),保持 1-based 索引风格的思想,但注释中会指出零基实现时的注意点。伪代码包括辅助函数 FINDKEY/GET-PRED/GET-SUCC/FILL/BORROW/MERGE 与主过程 B-TREE-DELETE(T,k) 与递归子过程 B-TREE-DELETE-NODE(x,k)。我会在伪代码后给出不变量与复杂度分析。
伪代码(清晰、结构化、完整)
PROCEDURE B-TREE-DELETE(T, k)
1 IF T.root == NIL
2 return // 树空,直接返回
3 CALL DELETE-KEY(T.root, k)
4 // 如果根没有关键字并且不是叶子,则把根替换为它的唯一孩子
5 IF T.root.n == 0 AND NOT T.root.leaf
6 T.root = T.root.C1
7 IF T.root.n == 0 AND T.root.leaf // 树可能变空
8 T.root = NIL
9 return
// 在子树根为 x 中删除 k(x 初始时可能是根或其他),保证调用时(若 x 不是根)x.n >= t
PROCEDURE DELETE-KEY(x, k)
1 i = FIND-KEY-IN-NODE(x, k) // i = smallest index s.t. x.keys[i] >= k (或 = x.n+1 if all < k)
2 IF i <= x.n AND x.keys[i] == k THEN
3 // Case 2: k 在当前节点 x 中
4 IF x.leaf THEN
5 // Case 1 (degenerate): x 是叶,直接删除
6 DELETE-KEY-FROM-LEAF(x, i)
7 return
8 ELSE
9 // x 是内部节点:考虑左右孩子
10 IF x.C[i].n >= t THEN
11 // Case 2a: 左子有 >= t 键:用前驱替换
12 pred = GET-PREDECESSOR(x, i)
13 x.keys[i] = pred
14 // 现在删除前驱(在左子中),保证左子在递归时有 >= t 键
15 DELETE-KEY(x.C[i], pred)
16 return
17 ELSE IF x.C[i+1].n >= t THEN
18 // Case 2b: 右子有 >= t 键:用后继替换
19 succ = GET-SUCCESSOR(x, i)
20 x.keys[i] = succ
21 DELETE-KEY(x.C[i+1], succ)
22 return
23 ELSE
24 // Case 2c: 左右子都只有 t-1 键 -> 合并
25 MERGE-CHILDREN(x, i) // 把 x.keys[i] 下移并把子 i 和 i+1 合并为一个子
26 // 合并后,新子有 2t-1 键
27 DELETE-KEY(x.C[i], k)
28 return
29 END IF
30 END IF
31 ELSE
32 // Case 3: k 不在当前节点 x 中,需进入子 x.C[i]
33 IF x.leaf THEN
34 // 到叶仍未找到 -> 不存在
35 return
36 END IF
37 // 现在准备递归到子 x.C[i]; 确保 x.C[i] 在递归时至少有 t 个键
38 IF x.C[i].n == t-1 THEN
39 // 必须先“填充”(通过借或合并),保证 x.C[i].n >= t
40 FILL(x, i)
41 // 注意:FILL 可能改变 x 的结构(如合并会减少 x.n),并可能使 x.C[i] 指向不同的节点
42 END IF
43 // 重新决定要递归到哪个子(因为合并可能改变索引)
44 IF i > x.n THEN
45 // 如果此前 i == x.n 并且发生了合并,目标子成为 i-1
46 DELETE-KEY(x.C[i-1], k)
47 ELSE
48 DELETE-KEY(x.C[i], k)
49 END IF
50 END IF
辅助过程(摘要说明;可展开成完整伪代码)
FIND-KEY-IN-NODE(x,k):返回最小i使得x.keys[i] >= k(若都小则返回x.n+1)。DELETE-KEY-FROM-LEAF(x, i):在叶x中删除x.keys[i],将右侧 keys 左移,x.n--,DISK-WRITE(x)。GET-PREDECESSOR(x, i):在x.C[i]子树中向右走到叶,返回其最大键。GET-SUCCESSOR(x, i):在x.C[i+1]子树中向左走到叶,返回其最小键。MERGE-CHILDREN(x, i):把x.keys[i]下移到x.C[i],把x.C[i+1]的所有键和孩子接到x.C[i]之后,释放x.C[i+1]并把x的键与孩子左移;结果x.C[i]的键数变为2t-1,x.n--。FILL(x,i):如果x.C[i].n == t-1:- 若左兄
x.C[i-1]存在且x.C[i-1].n >= t,则BORROW-FROM-PREV(x,i):把x.keys[i-1]下移到x.C[i],把左兄的最后键上移到父x.keys[i-1],并移动孩子指针; - 否若右兄
x.C[i+1].n >= t,执行BORROW-FROM-NEXT(x,i)(对称操作); - 否(兄弟都只有
t-1),执行MERGE-CHILDREN(x, i)(或合并到左兄,当 i==x.n 时合并到左兄i-1)。
这些辅助操作是 CLRS 伪代码中的B-TREE-FILL,B-TREE-BORROW-FROM-PREV,B-TREE-BORROW-FROM-NEXT,B-TREE-MERGE的等价实现。
- 若左兄
重要不变量(证明正确性要点)
在任一次对 DELETE-KEY(x,k) 的递归调用开始时,若 x 不是根,则保证 x.n >= t(也就是比 B-tree 定义多一的下限)。这个不变量由 FILL/MERGE 在返回之前保证,从而使得递归下降过程中不会出现欠满节点需要回溯的情况。由于我们在递归下降前已经预先保证了子节点的最小键数,整个删除过程只需自顶向下一次下降(加上局部合并/借的常量开销),不会在回溯阶段做复杂操作。
复杂度分析(用公式表示)
- 树的高度为 h=O(logtn)h = O(\log_t n)h=O(logtn);(所有叶在相同高度)
- 在每层递归之间,最多做 O(1)O(1)O(1) 次
DISK-READ/DISK-WRITE(常数次块 I/O)——因为我们只在当前层做必要的借/合并/分裂等操作;因此总磁盘访问数为:
O(h)=O(logtn). O(h) = O(\log_t n). O(h)=O(logtn). - 在每层,节点内部操作(移动 keys / 指针 / 拷贝)费时 O(t)O(t)O(t),因此 CPU 时间为:
O(t⋅h)=O(tlogtn). O(t \cdot h) = O\big(t \log_t n\big). O(t⋅h)=O(tlogtn).
伪代码小结(关键点)
B-TREE-DELETE的核心思路是:在向下搜索要删除的键的过程中,提前修复(借或合并)将要进入的子节点,保证递归进入的节点总是合格(至少t键),这样删除可以一次向下完成,而无需回溯到上层去修复欠满节点。- 三种主情形:删除在叶(Case1)、在内部(Case2:用前驱/后继或合并)、或在子树中(Case3:先保证子非欠满再递归)。
- 关键辅助操作:
FILL、BORROW-FROM-PREV、BORROW-FROM-NEXT、MERGE。
结语与后续
- 我已经把 18.3-2(伪代码) 给出完整、注释清晰且附复杂度分析。
- 关于 18.3-1:我已经给出逐步骤的、基于 CLRS 图示的删除判定与操作流程(并解释了每一步你需要检查的条件与可能采取的借/合并/替换动作)。如果你需要我把 每一步的精确节点内容(即在删除 C、P、V 过程中每一步把整棵树全部节点写出来) 展开成文字化的树图(非常详细但会长——每一步列出所有节点),请直接回复 “把图 18.8(f) 的确切节点给我并按顺序展开每一步”,或者贴出你手头那张图的节点列表,我会马上给出逐键、逐节点的完整演算(不会再问你其它问题)。
为武汉地区的开发者提供学习、交流和合作的平台。社区聚集了众多技术爱好者和专业人士,涵盖了多个领域,包括人工智能、大数据、云计算、区块链等。社区定期举办技术分享、培训和活动,为开发者提供更多的学习和交流机会。
更多推荐



所有评论(0)