BTree的原理与 C++ 完整实现

摘要: 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),但有两个致命问题:

  1. 极端场景下冲突严重:某个位置冲突很多时,查找退化成链表遍历,访问次数剧增。
  2. 不支持范围查询 :哈希是散列的,WHERE id BETWEEN 10 AND 100 这类查询完全无能为力。

1.3:怎么加速?

两条路:

  1. 提高 IO 速度 。SSD 比机械硬盘快不少,但没有本质性的提升 ------ 还是比内存慢几个数量级,而且成本高。
  2. 降低树的高度 → 多叉平衡树 。这才是根本解法:把节点做"胖",一个节点塞满一个磁盘块,树高直接砍下来。

降低树高 = 减少磁盘 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 树满足以下性质:

  1. 每个节点最多 有 M 个孩子,M-1 个关键字
  2. 根节点至少有 2 个孩子(除非它同时是叶子)
  3. 除根节点外,每个节点至少 有 ⌈M/2⌉ 个孩子(⌈M/2⌉-1 个关键字)
  4. 所有叶子节点都在同一层(这是"平衡"的含义)
  5. 节点内关键字升序排列 ,且 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 树的查找和二叉搜索树思路一致,只是每个节点内部要先用顺序扫描定位到"正确的孩子":

  1. 在当前节点内线性扫描 ,找到第一个 key >= _keys[i] 的位置 i。
  2. 命中 :如果 key == _keys[i],直接返回 {当前节点, i}。
  3. 未命中 :说明 key 在 _subs[i] 这棵子树里,往下跳。
  4. 走到 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,它已经"过载"。分裂要做三件事:

  1. 把中间的 key _keys[M/2] 提出来,交给父节点;
  2. 左半边留在原节点,右半边搬到一个**新节点(兄弟)**里;
  3. 所有被搬走的孩子指针,要把 _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 是"分隔墙",所以要做一个旋转:

  1. cur 整体右移一格 ,腾出 _keys[0] 和 _subs[0] 的位置。
  2. 父节点的 key 下移 到 cur->_keys[0];同时把左兄弟最大 key 的右孩子(_subs[leftbrother->_n])过继过来当 cur->_subs[0]。
  3. 左兄弟的最大 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,是左借的镜像操作:

  1. 父节点的 key 追加到 cur 末尾 ,右兄弟最小 key 的左孩子(_subs[0])过继给 cur 当右孩子。
  2. 右兄弟的最小 key 上移到父节点空位。
  3. 右兄弟整体左移一格,补上被借走的位置。
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+树

  1. 分支节点不存数据,只存关键字(索引),数据全部下沉到叶子。
  2. 分支节点的子树指针个数与关键字个数相同(不是"孩子比 key 多一个")。
  3. 分支节点的子树指针 p[i] 指向关键字值落在 [k[i], k[i+1]) 区间内的子树。
  4. 所有叶子节点增加一个链接指针,串在一起。
  5. 所有关键字及其映射的数据都在叶子节点出现。

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+树的三个特性

  1. 所有关键字都出现在叶子节点的链表中,且链表中的节点是有序的。
  2. 不可能在分支节点中命中 ------ 查找必须走到叶子才算成功。
  3. 分支节点相当于叶子节点的索引 ,叶子节点才是存储数据的数据层。

第 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. 将原节点中 1/2 的数据复制到新节点。
  3. 在父节点中增加新节点的指针;

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+ 树"更先进",而是它最匹配数据库的查询模式:

  1. **内节点不存数据 → 更矮。**同样一个 16KB 页,B+ 树能塞下远多于 B 树的 key,树高更低,I/O 更少。
  2. 叶子链表 → 范围查询快。 数据库最高频的是 BETWEEN、>、排序,这正是 B+ 树的主场。
  3. **查询性能稳定。**每次都要走到叶子,I/O 次数固定,便于优化和预估。
  4. **全表扫描方便。**直接遍历叶子链表即可,不用遍历整棵树。

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 会:

  1. 自动选择一个可以唯一标识数据记录的列作为主键;
  2. 如果不存在这种列,则自动生成一个隐含字段作为主键 ------ 长度为 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* 树。

GitHub的BTree完整实现代码链接

相关推荐
u1301301 小时前
AI 日报(2026年10月24日)
人工智能·1024程序员节
2401_895366501 小时前
RHCS练习
1024程序员节
Escalating_xu1 小时前
【Linux 网络编程】应用层协议 HTTP(上):URL、请求与响应、方法和状态码
1024程序员节
weixin_417396831 小时前
老酒店有线电视改造怎么做?三种路径对比与施工节奏
网络·技术·酒店·1024程序员节·综合布线·弱电
jimy11 小时前
C++ 中 xvalue 的 identity 存在哪里?和 lvalue 的 identity 有什么区别?
开发语言·c++·1024程序员节
机器学习之心1 小时前
198种组合算法+优化CNN-Transformer+SHAP分析+新数据预测+多输出!
1024程序员节
yujunl1 小时前
U9 无授权的模式WebAPI开发的总结
开发语言·1024程序员节
AI日报派送佬1 小时前
2026年10月24日AI行业日报|OpenAI离线训练前沿模型、Kimi K3.1 Pro上线API、Prism推理引擎开源
人工智能·openai·1024程序员节·ai智能体·kimi·ai日报