摘要: B 树是一种多路平衡搜索树,也是 MySQL InnoDB 索引的底层结构。本文直接从代码出发,把一棵 M 阶 B 树的节点设计、查找、插入(含向上分裂)、删除(含借位与合并) 完整实现一遍,再对比 B+ 树 和 B* 树的差异与各自的分裂策略,最后落到 MySQL 索引的实际应用。
全文以
M = 3为主线,包含三个最容易写错的地方:为什么节点要多开一个 key 的空间 、分裂时孩子指针如何过继 、删除后 underflow 的三种修复手段 。代码基于模板实现,可直接编译运行。后半篇进入 B+ 树、B* 树与 MySQL 索引,以了解为主。
1:为什么数据量大时,二叉树和哈希都不行
先把常见的搜索结构摆在一起看:
| 结构 | 数据格式要求 | 时间复杂度 |
|---|---|---|
| 顺序查找 | 无要求 | O(N) |
| 二分查找 | 有序 | O(log₂N) |
| 二叉搜索树 | 无要求 | O(N) |
| 二叉平衡树(AVL / 红黑树) | 无要求 | O(log₂N) |
| 哈希 | 无要求 | O(1) |
这些结构有一个共同前提:数据量不大,可以一次性装进内存。
一旦数据量变到 100G 这种级别,内存装不下了,就只能放到磁盘上 。这时候上面这张表的复杂度就全部失效了 ------ 因为它们算的都是"内存里的比较次数",而磁盘场景下真正贵的是 磁盘 I/O 次数。
1.1:二叉平衡树的缺陷
平衡二叉树的高度是 log₂N,这个查找次数在内存里很快。但数据在磁盘上时:
log₂N 次磁盘访问是不可接受的
数据在内存:log₂N 次比较 ≈ 几十纳秒 × 30 ≈ 微秒级,没问题 数据在磁盘:树高多少层,就要读多少次盘(每层读一个节点) N = 100G 数据,树高 ≈ 37 层 → 37 次随机磁盘 I/O × 约 10ms = 0.37 秒 / 次查找 → 每秒只能查 3 次,完全不可用
1.2:哈希表的缺陷
哈希表效率是 O(1),但有两个致命问题:
- 极端场景下冲突严重:某个位置冲突很多时,查找退化成链表遍历,访问次数剧增。
- 不支持范围查询 :哈希是散列的,
WHERE id BETWEEN 10 AND 100这类查询完全无能为力。
1.3:怎么加速?
两条路:
- 提高 IO 速度 。SSD 比机械硬盘快不少,但没有本质性的提升 ------ 还是比内存慢几个数量级,而且成本高。
- 降低树的高度 → 多叉平衡树 。这才是根本解法:把节点做"胖",一个节点塞满一个磁盘块,树高直接砍下来。
降低树高 = 减少磁盘 I/O(同样存 10 亿条数据)
二叉树(每节点 1 个 key,2 个孩子) 10 亿 ≈ 2^30 → 树高 ≈ 30 层 最坏 30 次磁盘 I/O 更糟的是:每次 I/O 只读到 1 个节点,块里其余空间全浪费 B 树(M = 1000,每节点 999 个 key,1000 个孩子) 第 1 层:1 个节点 → 999 个 key 第 2 层:1000 个节点 → ~10^6 个 key 第 3 层:10^6 个节点 → ~10^9 个 key ✓ 树高只有 3 层 → 最多 3 次磁盘 I/O 每次 I/O 读满一个块,1000 个 key 全用上了
B 树的复杂度必须放在"内存 + 磁盘"两级存储模型下理解
只看比较次数的渐进阶,B 树 O(M·log_M N) 确实"不如"平衡树的 O(log₂N)。但真实场景里一次磁盘 I/O ≈ 十万次内存访问。
节点内那 M 次比较是内存操作,几乎免费 ;而树高 log_M N 才是磁盘 I/O 次数。用"多比较几百次"换"磁盘 I/O 少十几倍",这笔买卖极其划算。
本文实现的约定(先约定清楚,后面不歧义):
| 符号 | 含义 | 本文取值 |
|---|---|---|
M |
B 树的阶,即一个节点最多有多少个孩子 | 3 |
_n |
节点中实际存储的关键字个数 | 1 ~ M-1(根可以是 1) |
_keys[] |
关键字数组 | 长度 M |
_subs[] |
孩子指针数组 | 长度 M+1 |
读者定位
本文假设你已知道二叉树、中序遍历、模板的基本用法。不需要预先了解 B 树 ------ 每个结构变化都会配一张 ASCII 图。
2:BTree的定义与节点设计
2.1:M阶B树的严格定义
一棵 M 阶 B 树满足以下性质:
- 每个节点最多 有
M个孩子,M-1个关键字 - 根节点至少有 2 个孩子(除非它同时是叶子)
- 除根节点外,每个节点至少 有
⌈M/2⌉个孩子(⌈M/2⌉-1个关键字) - 所有叶子节点都在同一层(这是"平衡"的含义)
- 节点内关键字升序排列 ,且
key[i]的左子树所有 key 都小于它,右子树都大于它
第 3 条是删除算法的全部难度来源
"至少
⌈M/2⌉-1个 key"这条下界约束,保证了树的形态不会退化。删除时一旦跌破这条线,就必须启动修复流程 ------ 这就是后面要讲的 underflow 处理。
2.2 节点结构体:多开一格的设计
cpp
template<typename K, size_t M>
struct BTreeNode {
/*/K _keys[M - 1];
BTreeNode<K, M>* _subs[M];*/
//为了方便插入以后在分裂,可以多给一个空间
K _keys[M]; //存M个key
BTreeNode<K, M>* _subs[M + 1]; //存M+1个孩子指针
BTreeNode<K, M>* _parent; //存父指针(方便向上分裂)
size_t _n; //记录实际存储多个关键字
BTreeNode()
{
for (size_t i = 0; i < M; i++)
{
_keys[i] = K();
_subs[i] = nullptr;
}
_subs[M] = nullptr;
_parent = nullptr;
_n = 0;
}
};
为什么 key 数组要开 M 而不是 M-1?
上面注释掉的 _keys[M-1] 才是"教科书上"的写法 ------ 每个节点最多 M-1 个 key,开 M-1 个位置刚好。但实际写代码时这样会非常别扭。
原因在插入流程:我们都是先无条件插入,插完再判断是否溢出。考虑 M=3 的情况,节点里已经有 2 个 key(已满):
插入前的"已满"状态与即将发生的临时溢出
已满节点(M=3,理论上限 2 个 key): [ 10 | 20 ] _n = 2 ← 满了 现在插入 25。如果数组只开 M-1 = 2 格: [ 10 | 20 ] [25] ← 越界!没有第 3 格可写 如果数组开 M = 3 格: [ 10 | 20 | 25 ] _n = 3 ← 临时溢出,合法 ↑ 这一步之后立刻触发分裂,30 提上去,25 留在右边 所以开 M 格是为了让"先插入、后分裂"的流程成立。 数组里最多只会短暂出现 M 个 key,分裂完立刻回到 M-1 以内。
注意:这只是"多开一格"的惯用技巧
它不是 B 树的定义,而是实现上的取舍。代价是每个节点浪费一个 key 的空间(对磁盘场景可忽略),换来的是插入/删除代码里少了一大堆边界判断。
- _parent 指针是必须的吗?
不是必须,但强烈建议加上。向上分裂 需要不断向上层插入,删除修复 需要找兄弟节点 ------ 这两个操作都天然是"往上走"的。有了 _parent,代码是循环;没有 _parent,就得维护一个从根到叶的栈,每次插入/删除都要重新搜索一遍路径。
2.3:BTree骨架
cpp
//数据存在磁盘,K是磁盘地址
template<typename K, size_t M>
class BTree {
using Node = BTreeNode<K, M>;
public:
// ... 对外接口:Find / Insert / Remove / InOrder
private:
Node* _root = nullptr;
};
关于
using Node = BTreeNode<K, M>B 树的代码里
Node*出现频率极高,写全称会让签名长得没法看。先起个别名,后面所有函数签名都清爽很多。
3:查找:Find的分裂预留设计
3.1 算法思想
B 树的查找和二叉搜索树思路一致,只是每个节点内部要先用顺序扫描定位到"正确的孩子":
- 在当前节点内线性扫描 ,找到第一个
key >= _keys[i]的位置i。 - 命中 :如果
key == _keys[i],直接返回{当前节点, i}。 - 未命中 :说明 key 在
_subs[i]这棵子树里,往下跳。 - 走到
nullptr还没找到,返回代表"失败"的信号。
3.2:代码实现
cpp
//返回当前的树指针+值
pair<Node*, int> Find(const K& key)
{
Node* parent = nullptr;
Node* cur = _root;
while (cur) {
size_t i = 0;
//找第一个>=key的位置
while (i < cur->_n && key > cur->_keys[i])
{
i++;
}
//命中
if (i < cur->_n && key == cur->_keys[i])
{
return { cur, (int)i };
}
//没有命中,往孩子去跳
parent = cur;
cur = cur->_subs[i];
}
return { parent, -1 };
}
3.3:返回值pair<Node*,int>的巧妙之处
这是整个实现里我最喜欢的一个设计。Find 不只是"查找",它还顺便帮插入和删除完成了定位工作:
| 返回值 | 含义 | 调用者怎么用 |
|---|---|---|
{cur, i>=0} |
命中,key 在 cur 的第 i 个位置 |
Insert 判重返回 false;Remove 从这里开始删 |
{parent, -1} |
未命中,parent 是查找路径上最后一个非空节点,也就是 key 应该插入的那个叶子 |
Insert 直接往 parent 里插,不需要再搜一遍 |
关键点:失败时返回的 parent 一定是叶子
因为循环退出条件是 cur == nullptr,此时 parent 是刚才那个 cur ------ 它的孩子指针是空的,也就是叶子节点。而 B 树的插入永远发生在叶子,正好对上。
Find(26) 的完整路径(M=3)
[ 30 | 60 ] / | \ [10|20] [40|50] [70|80] 第 1 层 cur=[30|60]:26 > 30? 否 → i=0,不是 30 parent=[30|60],cur = _subs[0] = [10|20] 第 2 层 cur=[10|20]:26 > 10 是 → i=1;26 > 20 是 → i=2 i == _n(2),出循环;26 == _keys[2]? 越界,不算 parent=[10|20],cur = _subs[2] = nullptr 循环结束 → return { [10|20], -1 } 插入 26 只需要落到 [10|20] 这个叶子,位置在 20 后面
4:插入:从InsertKey到向上分裂
4.1 InsertKey节点内部的插入
节点内插入的本质是"有序数组的插入":从后往前找位置,比新 key 大的元素统统右移一格,腾出空位。
cpp
//往节点里面插入key和child
/*叶子节点插入时 child 是 nullptr。但分裂的时候,要把 "中间 key + 分裂出的新节点" 一起插进父节点,这时候 child 就是新节点。*/
void InsertKey(Node* node, const K& key, Node* child)
{
//从左往右找插入位置,比key大的都往右移
int end = node->_n - 1;
while (end >= 0 && key < node->_keys[end])
{
node->_keys[end + 1] = node->_keys[end];
node->_subs[end + 2] = node->_subs[end + 1];
end--;
}
//插入
node->_keys[end + 1] = key;
node->_subs[end + 2] = child;
node->_n++;
}
下标为什么是 end+1 和 end+2?
因为 key 和 child 之间是错位对应 的:_keys[i] 的右 孩子是 _subs[i+1]。当 key 被插到 end+1 时,它的右孩子自然就在 end+2。
key 与 child 的错位对应关系(M=3,已插满 2 个 key)
_keys: [ 0 ] [ 1 ] [ 2 ] 10 20 (空) _subs: [ 0 ] [ 1 ] [ 2 ] [ 3 ] c0 c1 c2 c3 对应关系: c0 < 10 < c1 < 20 < c2 < ... 插入 15 的过程: end=1: 15 < 20 → 20 右移到 _keys[2],c2 右移到 _subs[3],end=0 end=0: 15 < 10? 否 → 停 写入:_keys[1] = 15,_subs[2] = child 结果: _keys: [ 0 ] [ 1 ] [ 2 ] 10 15 20 _subs: [ 0 ] [ 1 ] [ 2 ] [ 3 ] c0 c1 child c2 ↑ 15 的右孩子落在 end+2 = 2
易错点:循环条件用 key < _keys[end] 而不是 <=
用 <,相等时停下,新 key 落在旧 key 的右边 ;用 <= 则落在左边。由于 Insert 已经用 Find 判过重了,这里不会真的遇到相等,两种写法的结果都一样 ------ 但保持一致能避免以后改代码时出坑。
4.2 Insert的整体流程
Insert 流程图
Insert(key) │ ├─ ① 树空? → 建根节点,直接放 key,返回 true │ ├─ ② Find(key) │ ├─ 已存在 → 不允许重复,返回 false │ └─ 不存在 → 拿到要插入的叶子节点 parent │ └─ ③ 进入循环:InsertKey(parent, newkey, child) │ ├─ parent->_n < M → 没满,插入结束,返回 true │ └─ parent->_n == M → 满了,分裂 │ ├─ 新根情况(parent 没有父亲)→ 建新根,break └─ 有父亲 → newkey = 中间key child = 新兄弟 parent = parent->_parent ↑ 回到循环开头,往上一层插
cpp
bool Insert(const K& key)
{
//1树空
if (_root == nullptr)
{
_root = new Node;
_root->_keys[0] = key;
_root->_n = 1;
return true;
}
//key已经存在,不允许插入
pair<Node*, int> ret = Find(key);
if (ret.second >= 0)
{
return false;
}
//如果没有找到,find顺便带回要插入的那个叶子节点
//循环每次往cur插入 ,newkey和child
Node* parent = ret.first;
K newkey = key;
Node* child = nullptr;
while (1) {
InsertKey(parent, newkey, child);
//满了就要分裂
//没有满,插入就结束
if (parent->_n < M)
{
return true;
}
else
{
/* ... 分裂逻辑,见下一节 ... */
}
}
return true;
}
while(1) + 参数复用:向上分裂的精髓
循环里的三个变量 parent / newkey / child 被复用成"这次要往哪个节点插、插什么 key、带什么孩子"。分裂完一层后,它们被重新赋值指向上一层,循环再跑一遍 ------ 整个向上传播的过程,用 5 行代码就写完了。
5:分裂逻辑完整推演
5.1 分裂的目标
当一个节点插满了 M 个 key,它已经"过载"。分裂要做三件事:
- 把中间的 key
_keys[M/2]提出来,交给父节点; - 左半边留在原节点,右半边搬到一个**新节点(兄弟)**里;
- 所有被搬走的孩子指针,要把
_parent改成新兄弟。
M=3 时的一次分裂
分裂前(满了,3 个 key): [ 10 | 20 | 30 ] / | | \ c0 c1 c2 c3 分裂后: [ 20 ] ← 中间的 key 往上提 / \ [ 10 ] [ 30 ] ← 左右各一个节点 / \ / \ c0 c1 c2 c3 ← 孩子跟着走,_parent 也要改
5.2 分裂代码
cpp
size_t mid = M / 2;//中间位置,M=3时mid = 1
//分裂一半[mid+1,M-1]给兄弟
//一:创建兄弟节点
Node* brother = new Node;
//二:把[mid+1,M-1]的key和对应的左孩子拷贝给brother
size_t j = 0;//兄弟节点写入的下标从0开始
for (size_t i = mid + 1; i <= M - 1; i++)
{
//key和key的左孩子
brother->_keys[j] = parent->_keys[i];
brother->_subs[j] = parent->_subs[i];
if (parent->_subs[i])
{
parent->_subs[i]->_parent = brother;
}
j++;
}
//还有最后一个右孩子拷给
brother->_subs[j] = parent->_subs[M];
if (parent->_subs[M])
{
parent->_subs[M]->_parent = brother;
}
brother->_n = j;
parent->_n -= (brother->_n + 1);
逐格追踪:M=3,10\|20\|30 的分裂
mid = 3/2 = 1,i 从 2 到 2,循环只跑一次
初始: parent->_keys = [10 | 20 | 30] parent->_n = 3 parent->_subs = [c0 | c1 | c2 | c3] j = 0 i = 2 那一轮: brother->_keys[0] = parent->_keys[2] = 30 brother->_subs[0] = parent->_subs[2] = c2 c2->_parent = brother j → 1 循环结束,补最后一个右孩子: brother->_subs[1] = parent->_subs[3] = c3 c3->_parent = brother 注意:这里用的是 parent->_subs[M],即 _subs[3] 计数: brother->_n = 1 parent->_n = 3 - (1 + 1) = 1 (减 brother->_n + 1,那个 +1 是被提走的中间 key) 最终: parent = [10] _n = 1 brother = [30] _n = 1 中间的 20 待提升 parent 里 c2、c3 的位置虽然还残留旧值,但 _n=1 后已经不会再被访问
两个容易写错的细节
① 残留数据不用清。 parent->_n 已经减到 1,后面所有访问都以 _n 为界,越界位置的数据是"逻辑上不存在"的。代码里 //parent->_keys[i] = K(); 被注释掉,就是这个意思 ------ 清了更干净,但不清也没有 bug。
② 别忘了改 _parent。 搬走的孩子如果不改 _parent,删除时会从错误的父节点找兄弟,导致崩溃或死循环。这是最隐蔽的一个坑。
5.3 两种分裂结果
情况 A:分裂的是根节点 → 树长高
cpp
//情况A,刚刚分裂的parent是根节点(没有父亲)
//创建一个新的根
if (parent->_parent == nullptr)
{
_root = new Node;
_root->_keys[0] = parent->_keys[mid];
_root->_subs[0] = parent;
_root->_subs[1] = brother;
_root->_n = 1;
parent->_parent = _root;
brother->_parent = _root;
break;
}
情况 A:根分裂 → 树高 +1,这是 B 树唯一的"长高"方式
分裂前: 分裂后: 新根: [ 20 ] [ 10 | 20 | 30 ] / \ / | | \ [ 10 ] [ 30 ] c0 c1 c2 c3 / \ / \ c0 c1 c2 c3 B 树是"从叶子向上生长"的 —— 它永远不会像 BST 那样从上方插入。 所有新节点都先在叶子出现,再通过分裂一层层往上顶。
情况 B:分裂的不是根 → 继续向上
cpp
//有父节点
else
{
//转换成往parent->parent去插入parent->[mid]和brother
brother->_parent = parent->_parent;
newkey = parent->_keys[mid];
child = brother;
parent = parent->_parent;
}
情况 B:往上插,父节点也可能因此溢出
[ 20 ] / \ [ 10 ] [ 30 | 40 | 50 ] ← 这个节点满了,要分裂 / | | \ c2 c3 c4 c5 第 1 轮结束后: newkey = 40 child = 新兄弟 [ 50 ] parent = [ 20 ] ← 回到循环开头,往这里插 40 ↓ 第 2 轮:InsertKey([20], 40, [50]) [ 20 | 40 ] / | \ [ 10 ] [ 30 ] [ 50 ] 父节点插入后如果也满了,就在下一轮继续分裂 —— 这就是"向上传播"。 最坏的情况会一直传到根,触发情况 A,树才长高一层。
6:删除的难点
6.1 什么叫underflow
B 树规定:除根节点外,每个节点至少有 ⌈M/2⌉-1 个关键字 。删除之后如果低于这个下界,就叫 underflow(下溢),必须修复。
cpp
//最少需要的key数
size_t MinKeys()
{
return (M + 1) / 2 - 1;
}
为什么是 (M+1)/2 - 1 而不是 M/2 - 1?
因为 ⌈M/2⌉ = (M+1)/2(整数除法下)。M=3 时是 2-1 = 1;M=4 时是 2-1 = 1;M=5 时是 3-1 = 2。用整数除法写 M/2 - 1 在 M=5 时会得到 1,少算一个,会破坏树的平衡性。
M=3 时的 underflow 判定
M = 3 → MinKeys = (3+1)/2 - 1 = 1 节点 [20] 删完变 [] _n = 0 < 1 → underflow,要修 节点 [20|30] 删完变 [30] _n = 1 >= 1 → 没事 节点 [20|30] 删完变 [] _n = 0 < 1 → underflow,要修
6.2 三种修复手段
underflow 处理决策树
underflow 了怎么办? │ ├─ 左兄弟有多余 key? → 借一个(左旋) ├─ 右兄弟有多余 key? → 借一个(右旋) └─ 都没有多余 → 合并 └─ 合并后父节点少一个 key,可能也 underflow → 继续往上修
优先借,借不到才合并
借只影响 3 个节点(自己、兄弟、父节点),是局部操作;合并会让节点数减少,可能导致父节点也 underflow,引发连锁反应。所以在两个兄弟都有余量的情况下,永远选借。
而当左右兄弟都没多余时,其实说明它们都恰好处于最小值 ,此时只能合并 ------ 合并后节点总数正好是合法的(MinKeys + MinKeys + 1 = M - 1),这是 B 树保持平衡的关键数学性质。
6.3 删除总流程
Remove 的完整流程
Remove(key) │ ├─ Find 查找 → 不存在返回 false │ ├─ 不是叶子? → 用后继替换,转成删叶子 │ ├─ RemoveKey 在叶子删除 │ └─ while (不是根 且 underflow) │ ├─ 左兄弟有多余? → 借左兄弟(3步:右移→父key下移→兄弟key上移) ├─ 右兄弟有多余? → 借右兄弟(3步:追加→父key下移→兄弟key上移) └─ 都没有 → 合并(6步:定左右→父key下移→拼右节点→父删key→根降高度检查→cur=parent继续)
7:删除第一步:非叶子转叶子
7.1 为什么不能直接删内部节点
如果一个要删的 key 在内部节点(非叶子),它身上挂着两个子树。直接删掉会让子树失去归属 ------ 比如节点 [30|60],删掉 60 之后,60 右边的孩子(所有大于 60 的 key)就没地方挂了。
所以标准做法是:用"后继"顶替它,然后去叶子把后继删掉。这样就把"删内部节点"统一转换成了"删叶子节点",只需要处理一种情况。
7.2 找后集
cpp
//找子树最左节点
//一个 key 的 "后继" 是比它大的最小 key。在 B 树里,后继就是它**右子树的最左节点**的第一个 key。
Node* FindLeftMost(Node* node)
{
while ((node->_subs[0])) //有左孩子,没有左孩子停止
node = node->_subs[0]; //一直往左走
return node; //最左节点
}
7.3 替换+转移删除目标
cpp
//如果不是叶子,转成叶子
if (cur->_subs[0]) //有左孩子就不是叶子,(叶子的孩子都是nullptr)
{
//找后继:右树的最左节点
Node* successor = FindLeftMost(cur->_subs[pos + 1]);
//用后继值覆盖掉要删的key
cur->_keys[pos] = successor->_keys[0];
cur = successor;
pos = 0;
}
删除内部节点 50 的完整过程
删之前: [ 50 ] ← 要删 50,它不是叶子 / \ [ 30 ] [ 70 | 80 ] / [ 60 ] ← 50 的后继是 60 (右子树 _subs[1] 的最左节点) 第 1 步:把后继 60 覆盖到 50 的位置 [ 60 ] ← 内容变成 60 了 / \ [ 30 ] [ 70 | 80 ] / [ 60 ] ← 现在去删这个叶子里的 60 第 2 步:cur 指向叶子 [60],pos = 0 RemoveKey 把 60 从叶子里删掉 [ 60 ] / \ [ 30 ] [ 70 | 80 ] ↑ 删完叶子空了,触发 underflow → 进入修复流程
为什么取后继而不是前驱?
两者都可以,选一个就行。取后继 = 往右子树走到底再一直向左;取前驱 = 往左子树走到底再一直向右。本文统一用后继,保证 pos = 0(后继永远是它所在节点的第一个 key),后面的代码能简化。
7.4 RemoveKey 节点内删除
和插入对称,删除也是数组元素的前移覆盖。注意 _keys[i] 的右 孩子是 _subs[i+1],所以 key 前移一格,孩子也跟着前移一格。
cpp
//删除节点里的某个key
//数组删除元素,后面的元素往前挪,覆盖要删掉的位置。key和它的右孩子是绑定的,要一起挪
void RemoveKey(Node* node, size_t pos)
{
for (size_t i = pos; i < node->_n - 1; i++)
{
node->_keys[i] = node->_keys[i + 1]; //后面的key往前挪,覆盖掉pos这个位置
node->_subs[i + 1] = node->_subs[i + 2]; //key的右孩子也往前挪动
}
node->_n--; //数量减一
}
RemoveKey(30\|50\|70, pos=1) 的过程
删除前: _keys: [ 0 ] [ 1 ] [ 2 ] 30 50 70 _subs: [ 0 ] [ 1 ] [ 2 ] [ 3 ] c0 c1 c2 c3 i = 1:_keys[1] = _keys[2] = 70;_subs[2] = _subs[3] = c3 i = 2:不满足 i < _n-1(2),退出 删除后: _keys: [ 0 ] [ 1 ] [ 2 ] 30 70 (残留) _subs: [ 0 ] [ 1 ] [ 2 ] [ 3 ] c0 c1 c3 (残留) _n = 2 c3 是 70 的右孩子,跟着 70 一起前移 ✓ c2 被覆盖掉了 —— 它是 50 的右孩子,50 都没了,它也该消失 ✓
8:删除第二步:借位与合并
8.1 准备工作
cpp
//查找当前节点是父节点的第几个孩子
int GetChildeIndex(Node* parent, Node* cur)
{
for (size_t i = 0; i <= parent->_n; i++)
{
if (parent->_subs[i] == cur)
{
return (int)i;
}
}
return -1;
}
// not leaf
while (cur != _root && cur->_n < MinKeys())
{
Node* parent = cur->_parent;
int idx = GetChildeIndex(parent, cur);
Node* leftbrother = (idx > 0) ? parent->_subs[idx - 1] : nullptr;
Node* rightbrother = (idx < (int)parent->_n) ? parent->_subs[idx + 1] : nullptr;
/* ... 三种手段 ... */
}
循环条件的三个要点
cur != _root:根节点没有最少 key 的限制,它可以只有 1 个 key,甚至 0 个(然后降高度)。所以根不用修。cur->_n < MinKeys():确实 underflow 了才进循环。leftBrother:idx > 0才有左兄弟。rightBrother:idx < parent->_n才有右兄弟(孩子下标最大是_n)。
8.2 手段一------借左兄弟
借用左兄弟的最大 key 。因为父节点的 key 是"分隔墙",所以要做一个旋转:
- cur 整体右移一格 ,腾出
_keys[0]和_subs[0]的位置。 - 父节点的 key 下移 到
cur->_keys[0];同时把左兄弟最大 key 的右孩子(_subs[leftbrother->_n])过继过来当cur->_subs[0]。 - 左兄弟的最大 key 上移 到父节点的空位,左兄弟
_n--。
cpp
//手段一:从左兄弟借
if (leftbrother && leftbrother->_n > MinKeys())
{
//1 cur整体右移一个,腾出0下标的位置
for (size_t i = cur->_n; i > 0; i--)
{
cur->_keys[i] = cur->_keys[i - 1];
cur->_subs[i + 1] = cur->_subs[i];
}
cur->_subs[1] = cur->_subs[0];
//2 父节点的key下移到cur的[0]
cur->_keys[0] = parent->_keys[idx - 1];
//左兄弟最大key的右孩子过继当我的左孩子
cur->_subs[0] = leftbrother->_subs[leftbrother->_n];
if (cur->_subs[0])
cur->_subs[0]->_parent = cur;
//3 左兄弟最大key上移到父节点
parent->_keys[idx - 1] = leftbrother->_keys[leftbrother->_n - 1];
leftbrother->_n--;
cur->_n++;
}
借左兄弟的完整推演(cur 已经空了)
借之前: [ 50 ] ← parent / \ [ 20 | 30 ] [ cur ] ← leftBrother 有多余,cur 空了 / | \ / \ c0 c1 c2 (空) ① cur 右移(本来就是空的,挪了个寂寞) ② 50 下移给 cur,左兄弟的 c2 过继给 cur: [ 50 ] / \ [ 20 | 30 ] [ 50 ] / c2 ③ 30 上移到父节点: [ 30 ] / \ [ 20 ] [ 50 ] / c2 完成,cur 有 1 个 key,不再 underflow ✓
第 1 步最后那行 cur->_subs[1] = cur->_subs[0] 容易被漏掉
循环把 _keys[i] 和 _subs[i+1] 一起右移了,但 _subs[0] 没有被循环处理到(循环里最小处理的是 _subs[1])。_subs[0] 原本指向 cur 最左边的孩子,右移后它应该跑到 _subs[1]。所以这行必须单独补上 ------ 少了它,cur 最左边的孩子指针就丢了。
8.3 手段二------借右兄弟
借用右兄弟的最小 key,是左借的镜像操作:
- 父节点的 key 追加到 cur 末尾 ,右兄弟最小 key 的左孩子(
_subs[0])过继给 cur 当右孩子。 - 右兄弟的最小 key 上移到父节点空位。
- 右兄弟整体左移一格,补上被借走的位置。
cpp
//手段二:从右兄弟借
else if (rightbrother && rightbrother->_n > MinKeys())
{
//1 父节点的key追加到cur的末尾
cur->_keys[cur->_n] = parent->_keys[idx];
//右兄弟最小key的左孩子过继给我当右孩子
cur->_subs[cur->_n + 1] = rightbrother->_subs[0];
if (cur->_subs[cur->_n + 1])
cur->_subs[cur->_n + 1]->_parent = cur;
cur->_n++;
//2 右兄弟最小key上移到父节点
parent->_keys[idx] = rightbrother->_keys[0];
//3 右兄弟整体左移一格
for (size_t i = 0; i < rightbrother->_n - 1; i++)
{
rightbrother->_keys[i] = rightbrother->_keys[i + 1];
rightbrother->_subs[i] = rightbrother->_subs[i + 1];
}
rightbrother->_subs[rightbrother->_n - 1] = rightbrother->_subs[rightbrother->_n];
rightbrother->_n--;
}
借右兄弟的完整推演(cur 已经空了)
借之前: [ 50 ] / \ [ cur ] [ 70 | 80 ] ← cur 空了,右兄弟有多余 (空) ① 50 追加到 cur,右兄弟的 c0 过继给 cur: [ 50 ] / \ [ 50 ] [ 70 | 80 ] \ c0 ② 70 上移到父节点: [ 70 ] / \ [ 50 ] [ 70 | 80 ] \ c0 ③ 右兄弟左移: [ 70 ] / \ [ 50 ] [ 80 ] \ / c0 c1 完成 ✓ 注意 ③ 的循环只跑到 _n-2,最后一行单独补 _subs[_n] —— 因为 _subs 比 _keys 多一个,要多搬一次
8.4 手段三------合并
左右兄弟都没多余 key,就只能合并。合并的核心是把"父节点的分隔 key"拉下来,连同右节点一起并到左节点。
cpp
else//手段三:合并
{
//1 确定左右:有左兄弟就和左合,没有就和右合
Node* left = leftbrother ? leftbrother : cur;
Node* right = leftbrother ? cur : rightbrother;
int leftIdx = leftbrother ? idx - 1 : idx;
//2 父节点的key下移到左节点末尾
left->_keys[left->_n] = parent->_keys[leftIdx];
left->_subs[left->_n + 1] = right->_subs[0];
if (left->_subs[left->_n + 1])
left->_subs[left->_n + 1]->_parent = left;
left->_n++;
//3 右节点所有内容追加到左节点
for (size_t i = 0; i < right->_n; i++)
{
left->_keys[left->_n] = right->_keys[i];
left->_subs[left->_n + 1] = right->_subs[i + 1];
if (left->_subs[left->_n + 1])
left->_subs[left->_n + 1]->_parent = left;
left->_n++;
}
//4 父节点删掉对应key,释放右节点
RemoveKey(parent, leftIdx);
delete right;
//5 特殊情况:父节点是根且空了,树要降高度
if (parent == _root && parent->_n == 0)
{
_root = left;
left->_parent = nullptr;
delete parent;
break;
}
//6 父节点可能也underflow,继续循环
cur = parent;
}
合并的完整推演:三个 key 变一个,树降一层
合并前: [ 50 ] ← parent / \ [ 20 ] [ cur = 空 ] ← leftBrother=[20] 只有 1 个 key(= 最小值) 不能借!只能合 ① left = [20],right = [],leftIdx = 0 ② 50 下移到 left 末尾: [ 20 | 50 ] \ right 的 _subs[0](nullptr) ③ right 是空的,循环不执行 ④ 父节点删 key,释放 right: [ ](根空了!) \ [ 20 | 50 ] ⑤ parent 是根且空了 → 降高度: [ 20 | 50 ] ← 新根 完成,树高从 2 降到 1 ✓ 数学验证:MinKeys + MinKeys + 1 = 1 + 1 + 1 = 3 = M, 合并后正好填满一个节点,不会溢出。
合并的 6 步流程小结
① 确定左右(有左兄弟就和左合,否则和右合)→ ② 父 key 下移到左节点末尾 → ③ 右节点所有内容追加到左节点 → ④ 父节点删掉对应 key,释放右节点 → ⑤ 检查父节点是否是被清空的根,是则降高度 → ⑥ cur = parent,继续循环检查上层。
8.5 为什么合并时"有左兄弟就一定和左合"
这不是随意的选择,而是为了让 leftIdx 的计算保持简单:
| 情况 | left | right | leftIdx |
|---|---|---|---|
| 有左兄弟 | leftbrother |
cur |
idx - 1(父节点中夹在两者之间的 key 下标) |
没有左兄弟(cur 是 _subs[0]) |
cur |
rightbrother |
idx(即 0) |
leftIdx 的几何含义
parent: [ k0 | k1 | k2 ] / | | \ subs0 subs1 subs2 subs3 若 cur = subs2(idx=2),有左兄弟 subs1: left = subs1, right = subs2, leftIdx = 1 k1 就是夹在 subs1 和 subs2 之间的那把"钥匙" 若 cur = subs0(idx=0),没有左兄弟: left = subs0, right = subs1, leftIdx = 0 k0 就是夹在 subs0 和 subs1 之间的那把"钥匙" 规律:leftIdx 永远是"左孩子下标",父 key 就在这个位置。
9:删除第三步:根降高度
9.1 为什么根是个例
B 树对"最少 key 数"的约束不适用于根节点。理由很简单:根节点没有兄弟,一旦它 underflow 了,无处可借、也无处可合。所以根的下限单独放开 ------ 它甚至可以一个 key 都不剩。
当根变成空节点时,唯一合理的处理是:把它删掉,让唯一的孩子升为新根,树高减一。
高度变化是对称的
插入时,根分裂 → 建新根 → 树高 +1 ;
删除时,根空了 → 删旧根 → 树高 -1。
整个 B 树的高度变化只发生在根节点上,其他节点无论怎么分裂、借位、合并,都不会改变树的层数。这就是 B 树"所有叶子在同一层"这条性质能一直保持的原因。
9.2 降高度代码
这段代码就嵌在合并手段里(第 5 步),因为根变空只可能由合并引起 ------ 借位不会让父节点的 key 数减少。
cpp
//5 特殊情况:父节点是根且空了,树要降高度
if (parent == _root && parent->_n == 0)
{
_root = left;
left->_parent = nullptr;
delete parent;
break;
}
降高度全过程
降高度前: [ ] ← 根空了(_n = 0) \ [ 20 | 50 ] ← left,唯一的幸存者 执行: _root = left → [ 20 | 50 ] 成为新根 left->_parent = nullptr → 断开与旧根的父子关系 delete parent → 释放旧根 降高度后: [ 20 | 50 ] ← 单节点,也是叶子 树高:2 → 1 查看整棵树:InOrder 输出依然是 20 50,中序序列没变 ✓
三行都不能少,顺序也不能错
left->_parent = nullptr 必须写在 delete parent 之前 。如果先删了 parent,再通过 left->_parent 访问就是野指针;如果忘了这行,新根的 _parent 会指向已释放内存,下次删除操作直接崩。
9.3 删除时的一条隐蔽规则
还有一个细节:如果 cur 是根节点,循环直接不执行。
cpp
while (cur != _root && cur->_n < MinKeys())
因为 && 是短路求值 :cur == _root 时,第二个条件根本不会被求值,循环体直接跳过。这意味着删根节点的 key 时不会触发任何修复,即使根只剩 0 个 key 也是合法的。
根节点的两种合法"稀疏"状态
状态 1:根只有 1 个 key(还在合法范围) [ 42 ] ← _n = 1,虽然是"最小值"但作为根完全合法 状态 2:根有 0 个 key(即将降高度) [ ] ← _n = 0 \ [ 20 | 50 ] ← 下次操作后这里会触发降高度 注意:状态 2 只会在"合并后、降高度前"的瞬间短暂存在。 因为紧跟着的 if (parent == _root && parent->_n == 0) 会立刻把它消掉。
10:完整测试与运行结果
10.1 中序遍历
cpp
void _InOrder(Node* cur)
{
if (cur == nullptr) return;
//按照左根右的中序遍历
for (size_t i = 0; i < cur->_n; i++)
{
_InOrder(cur->_subs[i]);
cout << cur->_keys[i] << " ";
}
_InOrder(cur->_subs[cur->_n]);
}
void InOrder()
{
_InOrder(_root);
}
中序遍历为什么能验证 B 树
B 树本质上是一棵有序的多路搜索树 :对任意节点,_subs[i] 里所有 key 都小于 _keys[i],_subs[i+1] 里所有 key 都大于 _keys[i]。所以按"孩子0 → key0 → 孩子1 → key1 → ... → 孩子n"的顺序遍历,输出的必然是严格递增的序列。
反过来说:**只要中序输出是有序的,插入和删除的结构调整就是正确的。**这是一个非常好用的验证手段 ------ 不用打印树形结构,看一维输出就够了。
10.2 测试代码
cpp
#include <iostream>
#include <windows.h>
using namespace std;
#include "BTree.hpp"
void Test()
{
int a[] = { 53, 139, 75, 49, 145, 36, 101 };
BTree<int, 3> t;
for (auto e : a)
{
t.Insert(e);
}
cout << "插入后中序遍历: ";
t.InOrder();
// ... 依次删除 53 / 75 / 36 / 49 / 101 / 139 / 145
// ... 最后删除一个不存在的 999
}
int main()
{
Test();
}
10.3 运行结果
插入后中序遍历: 36 49 53 75 101 139 145
删除 53 后: 36 49 75 101 139 145
删除 75 后: 36 49 101 139 145
删除 36 后: 49 101 139 145
删除 49 后: 101 139 145
删除 101 后: 139 145
删除 139 后: 145
删除 145 后:
删除 999(不存在): 失败
结果完全符合预期:每次删除后序列依然严格递增,最终删空,删不存在的值返回 false。
10.4 用层序遍历看结构变化
只看中序输出只能验证"内容对不对",验证不了"结构对不对"。比如某个节点 key 数超出了 M-1,或者叶子不在同一层,中序输出可能依然有序。
所以我在 BTree 里加了一个按层打印的调试函数,把每一步的结构也打出来:
cpp
//按层打印,用来肉眼验证结构(调试用)
void LevelOrder()
{
if (_root == nullptr) return;
queue<Node*> q;
q.push(_root);
while (!q.empty())
{
size_t sz = q.size();
while (sz--)
{
Node* cur = q.front(); q.pop();
cout << "[";
for (size_t i = 0; i < cur->_n; i++)
{
if (i) cout << "|";
cout << cur->_keys[i];
}
cout << "] ";
for (size_t i = 0; i <= cur->_n; i++)
if (cur->_subs[i]) q.push(cur->_subs[i]);
}
cout << endl; // 一层打完换行
}
}
有了它,每次删除前后的结构都一目了然:
插入后中序遍历: 36 49 53 75 101 139 145
--- 结构 ---
[75]
[49] [139]
[36] [53] [101] [145]
删除 53 后: 36 49 75 101 139 145
结构: [75|139]
[36|49] [101] [145]
删除 75 后: 36 49 101 139 145
结构: [49|139]
[36] [101] [145]
删除 36 后: 49 101 139 145
结构: [139]
[49|101] [145]
删除 49 后: 101 139 145
结构: [139]
[101] [145]
删除 101 后: 139 145
结构: [139|145]
删除 139 后: 145
结构: [145]
删除 145 后:
结构: []
删除 999(不存在): 失败
11:复杂度与踩坑记录
11.1 复杂度分析
| 操作 | 树高 | 节点内查找 | 总复杂度 |
|---|---|---|---|
| 查找 Find | O(logMN) |
顺序扫描 O(M) |
O(M·logMN) |
| 插入 Insert | O(logMN) |
顺序扫描 O(M) |
O(M·logMN) |
| 删除 Remove | O(logMN) |
顺序扫描 O(M) |
O(M·logMN) |
注意这个复杂度看起来"不如"平衡二叉树的
O(log N),但这里比较的是"内存中的比较次数",而 B 树优化的是"磁盘 I/O 次数":为什么 O(M·log_M N) 反而更快
节点的 M 个 key 是连续存储的,一次磁盘读取(读一个块) 就把它们全部搬进内存了 —— 节点内的 M 次比较是内存操作,几乎免费。 所以真正决定性能的是 树高 log_M N,也就是磁盘 I/O 次数。 M = 1000、N = 10 亿: 树高 = log₁₀₀₀(10⁹) = 3 → 最多 3 次磁盘 I/O 节点内 999 次比较全是内存操作,可忽略 对比二叉平衡树: 树高 = log₂(10⁹) ≈ 30 → 30 次磁盘 I/O 慢 10 倍
B 树的复杂度必须放在"内存 + 磁盘"两级存储模型下理解
只看比较次数的渐进阶,B 树确实吃亏。但真实场景里,一次磁盘 I/O ≈ 十万次内存访问。用"节点内多比较几百次"换"磁盘 I/O 少十几倍",这笔买卖极其划算。
11.2 踩坑记录
坑 1:key 数组开 M-1 导致越界
最初按教科书写成 _keys[M-1],插入时直接越界。改成 _keys[M] 后,"先插入、后分裂"的流程才成立。这个坑的本质是:教科书描述的是"稳定状态",代码需要处理"过渡状态"。
坑 2:分裂时忘了改孩子的 _parent
分裂把右半边的 key 和孩子搬到 brother,但漏了同步更新 _parent。表现是插入一切正常、中序输出有序,但删除时找兄弟直接崩 ------ 因为 cur->_parent 还指着旧节点,GetChildeIndex 遍历不到 cur,返回 -1,然后 parent->_subs[-1] 越界。
教训:凡是"搬家"的地方,都要检查 _parent
代码里共有 4 处会移动孩子指针:分裂、借左、借右、合并。每一处搬完都必须紧跟一句
xxx->_parent = 新父亲,并且要先判空(叶子节点的孩子是 nullptr)。
坑 3:MinKeys 写成 M/2 - 1
M=3 时两种写法都得 1,测试全过。但 M=5 时 M/2 - 1 = 1,正确值应该是 (5+1)/2 - 1 = 2。阈值偏小意味着允许节点更稀疏,破坏了"所有叶子同层"的性质,树会逐渐退化成链表。
坑 4:借左兄弟时漏了 _subs0 的搬运
右移循环处理的是 _subs[1.._n+1],_subs[0] 不在循环范围内。如果忘了单独补 cur->_subs[1] = cur->_subs[0],_subs[0] 就被新值覆盖,cur 最左边的整棵子树丢失。这个 bug 只会丢掉一部分数据,中序遍历却不报错(因为丢的是子树,不是单个 key),非常难查。
坑 5:降高度时先 delete 后改 _parent
顺序必须是 _root = left → left->_parent = nullptr → delete parent。反过来的话,left->_parent 已经是悬垂指针,写它等于写已释放内存。
11.3 几个设计决策复盘
| 决策 | 选择 | 理由 |
|---|---|---|
是否存 _parent |
存 | 分裂向上、删除找兄弟都是常规操作,省掉维护栈的成本 |
| 非叶子删除用什么顶替 | 后继(右子树最左) | 后继永远是所在节点的 _keys[0],简化 pos = 0 |
| 借位优先还是合并优先 | 优先借 | 借是局部操作,合会减少节点数并向上传播 |
| 合并时选哪边 | 有左兄弟就合左 | 让 leftIdx = idx - 1 的统一公式成立 |
| Find 的返回值 | pair<Node*, int> |
一次查找同时服务判重、定位插入点、定位删除点 |
11.4 还能改进的地方
- 节点内查找用二分。 现在是
O(M)顺序扫描,改成std::lower_bound是O(log M)。不过 M 是常数,收益有限,代码可读性反而下降。 - 析构函数缺失。 当前实现没有写析构,存在内存泄漏。需要加一个后序遍历的
_Destroy(Node*)。 - 拷贝构造/赋值未禁用。 默认的浅拷贝会导致 double free,应该
= delete掉。 - key 拷贝代价。 插入时的
K newkey = key对大对象(如长字符串)有拷贝开销,可改传引用。 - 真正的磁盘版。 要落到磁盘上,需要把
_subs从指针改成"块号 + 读块/写块",再加一个 LRU 缓冲池。这是BufferPool的活了。
关于《C++11-plus-study-notes》
本文代码只用了 C++11 特性(模板、
auto、范围 for、pair统一初始化),没有用 C++17 的 structured binding 或 C++20 的concepts。这样在 Dev-C++ 5.11 这类老环境里也能直接编译,方便考场或教学场景复用。
12: B+树
B+ 树是 B 树的变形,规则跟 B 树基本类似,但在 B 树的基础上做了几点关键改进。
12.1 B+树
- 分支节点不存数据,只存关键字(索引),数据全部下沉到叶子。
- 分支节点的子树指针个数与关键字个数相同(不是"孩子比 key 多一个")。
- 分支节点的子树指针
p[i]指向关键字值落在[k[i], k[i+1])区间内的子树。 - 所有叶子节点增加一个链接指针,串在一起。
- 所有关键字及其映射的数据都在叶子节点出现。
B 树 vs B+ 树的结构差异(同一个 key 集合)
B 树:每个节点都能命中数据 [ 30 | 60 ] / | \ [10|20] [40|50] [70|80] ↑ 数据散落在所有层,每层都可能是终点 B+ 树:数据全在叶子,内节点纯索引 [ 30 | 60 ] ← 只有 key,没有数据 / | \ [10|20] [40|50] [70|80] ↓ ↓ ↓ ┌────┐ → ┌────┐ → ┌────┐ │叶子│ │叶子│ │叶子│ ← 叶子串成链表 └────┘ └────┘ └────┘ 数据 10,20 数据 40,50 数据 70,80 关键:内节点的 30、60 在叶子里也会再出现一次(索引冗余)。
12.2 B+树的三个特性
- 所有关键字都出现在叶子节点的链表中,且链表中的节点是有序的。
- 不可能在分支节点中命中 ------ 查找必须走到叶子才算成功。
- 分支节点相当于叶子节点的索引 ,叶子节点才是存储数据的数据层。
第 2 条决定了 B+ 树的查找性能是"稳定的"
B 树在最好情况下(key 恰好落在根上)1 次 I/O 就命中;B+ 树则无论找哪个 key,都必须走到叶子 ,也就是固定 log_M N 次 I/O。
听起来 B+ 树更慢?恰恰相反 ------ 稳定的 I/O 次数对数据库优化更重要 。而 B+ 树内节点不存数据,同样一个磁盘块能塞下更多 key,树高反而更低,把这个"劣势"补了回来甚至反超。
12.3 B+树的分裂
B+ 树的分裂规则比 B 树简单,因为不涉及"数据往上提":
- 当一个节点满时,申请一个新节点。
- 将原节点中 1/2 的数据复制到新节点。
- 在父节点中增加新节点的指针;
B+ 树分裂:只影响原节点和父节点
分裂前(节点满了): [ 30 ] / \ [10|20|25] [40] 分裂后: [ 30 | 20 ] ← 20 的副本被复制进父节点当索引 / | \ [10|20] [25] [40] ↑ 注意 20 在两处都出现:叶子存数据,父节点存索引 关键:分裂只影响原节点和父节点,不碰兄弟节点 → 所以 B+ 树不需要指向兄弟的指针
为什么 B+ 树不需要兄弟指针
因为分裂时不会从兄弟那里借数据 ------ 它自己一分为二就完事了,兄弟节点完全不受影响。兄弟指针是 B* 树才需要的东西。
12.4 范围查询------B+树的杀手锏
这才是 B+ 树统治数据库的真正原因。
查 30 <= key <= 60,两种树的差别
B 树:叶子之间没有联系,取到 30 之后想拿下一个更大的值 → 只能回到父节点,做中序遍历式的回溯 → 需要反复在树中上下移动,磁盘 I/O 无法预测 B+ 树:定位到 30 所在的叶子后 → 沿叶子链表直接往后扫,一路 30 → 40 → 50 → 60 → 顺序 I/O,可以预读,性能极高 这就是为什么数据库都用 B+ 树:范围查询是最高频的操作。
13: B*树
B* 树是 B+ 树的变形,它在 B+ 树的非根、非叶子节点 上再增加了指向兄弟节点的指针。
B* 树:内节点之间也串起来
B+ 树:只有叶子层有横向链表
[ 30 | 60 ]
/ | \
[10|20] [40|50] [70|80]
↓ ↓ ↓
┌────┐ → ┌────┐ → ┌────┐ ← 只有这里有链表
└────┘ └────┘ └────┘
B* 树:内节点层也加上兄弟指针
[ 30 ] ←——→ [ 60 ] ← 新增的兄弟指针
/ \ / \
[10|20] [40|50] [70|80]
↓ ↓ ↓
┌────┐ → ┌────┐ → ┌────┐ ← 叶子链表还在
└────┘ └────┘ └────┘
13.1 B*树的分裂(和B+树完全不同)
B* 树的核心创新在于:节点满了先别急着分裂,先看看兄弟还有没有空位。
B* 树分裂的两种分支
节点满了,怎么办? │ ├─ ① 下一个兄弟未满? │ → 把一部分数据移到兄弟节点 │ → 再在原节点插入关键字 │ → 最后修改父节点中兄弟节点的关键字 │ (因为兄弟的关键字范围变了) │ 本质是"借位",不新增节点 │ └─ ② 兄弟也满了? → 在原节点与兄弟节点之间增加新节点 → 三方各复制 1/3 的数据到新节点 → 在父节点增加新节点的指针 本质是"一分为三",而不是一分为二
13.2 为什么B*树的空间利用率更高
对比两者的分裂策略:
| 树 | 分裂方式 | 结果 |
|---|---|---|
| B+ 树 | 一分为二,各占 1/2 | 每次满都必然新增一个节点 |
| B* 树 | 兄弟未满 → 移数据不新增; 兄弟满了 → 一分为三,各占 1/3 | 新增节点的概率显著降低 |
核心结论
B* 树分配新节点的概率比 B+ 树要低,空间使用率更高。
直观理解:B+ 树只要节点满了就分裂,节点数量增长得快、平均填充率低(因为刚分裂出来的两个节点都只有 50% 满)。B* 树则优先"挪一挪"填满兄弟,实在不行才一分为三 ------ 节点被使用得更充分,这就是"更丰满"的含义。
代价:实现复杂度上升
B* 树的每个内节点都要多存一个兄弟指针,分裂逻辑要分两种情况处理,还要额外维护父节点中兄弟的关键字范围。这些复杂度换来的是空间利用率,实际工程中用得远不如 B+ 树广泛。
14: B/B+/B*Tree的对比
14.1 一句话总结
三句话记住三种树
B 树: 有序数组 + 平衡多叉树 B+ 树: 有序数组链表 + 平衡多叉树 B* 树: 一棵更丰满的、空间利用率更高的 B+ 树
14.2 完整对比表
| 对比项 | B 树 | B+ 树 | B* 树 |
|---|---|---|---|
| 数据存放位置 | 每个节点都存数据 | 只有叶子存数据,内节点纯索引 | 同 B+ 树 |
| 内节点存什么 | key + 数据 | 只有 key | 只有 key + 兄弟指针 |
| 孩子数与 key 数 | 孩子比 key 多 1 | 孩子数 = key 数 | 同 B+ 树 |
| 叶子链表 | 无 | 有 | 有 |
| 能否在内节点命中 | 能 | 不能,必须到叶子 | 不能 |
| 分裂策略 | 中间 key 上提, 分两半 | 复制 1/2 到新节点, 父节点加指针 | 兄弟未满则移数据; 兄弟满则各复制 1/3 |
| 范围查询 | 需中序回溯,较慢 | 沿叶子链表扫,极快 | 同 B+ 树 |
| 空间利用率 | 中 | 中 | 高 |
| 典型应用 | 文件系统 (NTFS、HFS+) | 数据库索引 (MySQL InnoDB) | 少量场景 |
14.3 为什么数据库最后选择B+树
不是 B+ 树"更先进",而是它最匹配数据库的查询模式:
- **内节点不存数据 → 更矮。**同样一个 16KB 页,B+ 树能塞下远多于 B 树的 key,树高更低,I/O 更少。
- 叶子链表 → 范围查询快。 数据库最高频的是
BETWEEN、>、排序,这正是 B+ 树的主场。 - **查询性能稳定。**每次都要走到叶子,I/O 次数固定,便于优化和预估。
- **全表扫描方便。**直接遍历叶子链表即可,不用遍历整棵树。
15:B树的应用:MySQL索引
15.1:什么是索引
MySQL 官方定义:索引(index)是帮助 MySQL 高效获取数据的数据结构 。简单说 ------ 索引就是数据结构。
数据库不仅帮用户管理数据,还维护着满足特定查找算法的数据结构,这些结构以某种方式引用数据,从而实现高级查找算法。
两个容易忽略的细节
- MySQL 中索引属于存储引擎级别的概念 ------ 不同存储引擎的索引实现方式不同。
- 索引是基于表的,而不是基于数据库的。
15.2 MyISAM------非聚集索引
MyISAM 是 MySQL 5.5.8 之前的默认引擎,不支持事务,支持全文检索,使用 B+Tree 作为索引结构。
MyISAM:索引文件和数据文件分离
索引文件(B+Tree) 数据文件 [ 30 | 60 ] / | \ [10|20] [40|50] [70|80] ↓ ↓ ↓ 叶子 data 域存的是数据记录的地址 ↓ 0x1A2B ──────────────→ 按地址去数据文件读取真实记录 (Col1, Col2, Col3 ... 完整一行) MyISAM 的索引文件仅保存数据记录的地址 主索引和辅助索引在结构上没有任何区别 ├─ 主索引:key 必须唯一 └─ 辅助索引:key 可以重复 这种索引方式叫「非聚集索引」
**检索算法:**先按 B+Tree 搜索算法搜索索引 → 若 Key 存在,取出其 data 域的值 → 以 data 域的值为地址,读取相应数据记录。
15.3 InnoDB------聚集索引
InnoDB 支持事务,面向在线事务处理(OLTP),从 MySQL 5.5.8 起成为默认引擎,支持 B+ 树索引、全文索引、哈希索引。
但 InnoDB 的 B+Tree 实现方式与 MyISAM 截然不同,有两个核心区别:
区别一:数据文件本身就是索引文件
InnoDB:叶节点包含完整数据记录
MyISAM:索引文件 + 数据文件 (两个文件,分离) InnoDB:数据文件本身就是按 B+Tree 组织的索引 [ 30 | 60 ] ← 内节点:只有主键索引 / | \ [10|20] [40|50] [70|80] ↓ ↓ ↓ 叶节点的 data 域保存了完整的数据记录 ↓ (10, '张三', ...) ← 直接就是一行数据,不用再跳转 索引的 key 是数据表的主键 → 所以 InnoDB 表数据文件本身就是「主索引」 这种索引叫做「聚集索引」(聚簇索引)
InnoDB 要求表必须有主键
因为数据文件本身要按主键聚集,没有主键就没法组织这棵树。(MyISAM 可以没有主键)
如果没有显式指定主键,MySQL 会:
- 自动选择一个可以唯一标识数据记录的列作为主键;
- 如果不存在这种列,则自动生成一个隐含字段作为主键 ------ 长度为 6 字节,类型为长整型。
区别二:辅助索引的 data 域存的是主键值
辅助索引的两步检索
在 Col2 上建辅助索引: 辅助索引的叶子 data 域存的是主键值,而不是地址 ↓ 第 1 步:检索辅助索引 → 拿到主键值 ↓ 第 2 步:用主键到主索引中再检索一次 → 拿到完整记录 这叫「回表」。辅助索引搜索需要检索两遍索引。 对比 MyISAM:辅助索引直接存地址,一次就够
聚集索引的权衡
优势:主键搜索十分高效 ------ 叶子节点直接就是整行数据,一次 B+Tree 查找搞定。
代价 :辅助索引搜索需要"回表",检索两遍索引。所有辅助索引都引用主键作为 data 域,这也意味着主键不宜过长(否则每个辅助索引都要额外存一份)。
15.4 本篇文章的实现VS真实的MySQL实现
| 维度 | 本文实现 | MySQL InnoDB |
|---|---|---|
| 树的类型 | B 树(经典版) | B+ 树 |
| 节点存储 | 内存指针 Node* |
磁盘页(16KB) |
| 孩子定位 | 指针直接跳转 | 页号 + 缓冲池读写 |
| 数据的形态 | 只有一个 K 值 |
完整数据行 / 主键值 |
| 删除策略 | 真正删除 + 借位合并 | 多数情况只做标记删除 |
| 叶子节点 | 各自独立 | 双向链表串联 |
本文实现的这棵 B 树,把最核心的结构调整逻辑(分裂、借位、合并、升降高度)都走通了 。要变成真正的磁盘版,还需要把 _subs 从内存指针改成"页号 + 读页/写页",再加上一个 LRU 缓冲池来减少实际 I/O ------ 那属于 BufferPool 的范畴了。
小结:B 树其实就三件事
把 B 树的部分压缩成三句话:
- 查找靠"节点内线性扫描 + 沿孩子下降",返回值顺便服务插入和删除。
- 插入永远发生在叶子,满了就从中间裂开、把中间 key 往上顶,顶到根就长高。
- 删除先把非叶子转成叶子,再靠"借位优先、合并兜底"修 underflow,合到根空了就降高度。
真正难的不是这三件事本身,而是所有涉及"孩子搬家"的地方都要同步维护
_parent。理解了这一点,B 树的代码就没有黑盒了。B / B+ / B* 一句话带走
- B 树:有序数组 + 平衡多叉树,数据散落各层,删除要借位合并。
- B+ 树:有序数组链表 + 平衡多叉树,数据全在叶子层、叶子串成链表,内节点纯索引 ------ 范围查询的王者,数据库标配。
- B* 树:B+ 树的"丰满版",内节点再加兄弟指针,分裂时优先挪数据、实在不行一分为三,空间利用率更高但实现复杂。
选型没有绝对优劣,只有匹配场景:要范围查询和高扇出,选 B+ 树;要极致空间利用率且能承受实现复杂度,才考虑 B* 树。