目录
[2.1 图示过程分析](#2.1 图示过程分析)
[2.2 代码实现](#2.2 代码实现)
[2.2.1 B-树节点设计](#2.2.1 B-树节点设计)
[2.2.2 B-树类框架](#2.2.2 B-树类框架)
[2.2.3 B-树查找接口实现](#2.2.3 B-树查找接口实现)
[2.2.4 B-树插入接口实现](#2.2.4 B-树插入接口实现)
[2.3 测试](#2.3 测试)
[2.4 B-树的删除](#2.4 B-树的删除)
[2.5 B-树的高度](#2.5 B-树的高度)
一、前言
在之前,我们已经学习了很多的数据结构,当我们处理海量数据时,选择合适的数据结构变得至关重要,对于不同的数据结构它们的搜索效率也存在着很大的差异。如下
种类 | 数据格式 | 时间复杂度 |
---|---|---|
顺序查找 | 无要求 | O(N) |
二分查找 | 有序 | O(log2N) |
二叉搜索树 | 无要求 | O(N) |
二叉平衡树(AVL树和红黑树) | 无要求 | O(log2N) |
哈希 | 无要求 | O(1) |
以上结构适合用于数据量相对不是很大,能够一次性存放在内存中,进行数据查找的场景。如果 数据量很大,比如有100G数据,无法一次放进内存中,那就只能放在磁盘上了,如果放在磁盘 上,有需要搜索某些数据,那么如果处理呢?那么我们可以考虑将存放关键字及其映射的数据的 地址放到一个内存中的搜索树的节点中,那么要访问数据时,先取这个地址去磁盘访问数据。 如下

但是呢,实际中我们去查找的这个key可能不都是整型:可能是字符串比如身份证号码,那这时我们还把所有的key和对应数据的地址都存到内存,也可能是存不下的。
那我们考虑我不再存储key了,只存储地址

那这样的话我如何判断找到了呢? 那就需要拿着当前的地址去访问磁盘进行判断。 比如现在要找key为77的这个数据,那从根结点开始,首先访问根结点中的地址对应磁盘的数据,是34,那77大于34,所以往右子树找,右子树0x77对应的是89(有一次访问磁盘),77比89小,再去左子树找,左子树地址0x56访问磁盘对应的是77找到了。
但是这么做的话最坏的情况下我们要进行高度次的查找,那就意味着要进行高度次的磁盘IO。 如果我们使用红黑树或者AVL树的话,就是O(log2N)次。
不要忘了我们此时的查找是在磁盘上查找的,每一次查找都要进行一次I/O,这样效率是很低的。
那么我们之前学习了那么多的数据结构难道找不出来一个数据结构能很好解决这个问题吗?
使用平衡二叉树搜索树?平衡二叉树搜索树的高度是logN,这个查找次数在内存中是很快的。但是当数据都在磁盘中时, 访问磁盘速度很慢,在数据量很大时,logN次的磁盘访问,是一个难以接受的结果。
哈希表?哈希表的效率很高是O(1),但是一些极端场景下某个位置冲突很多,导致访问次数剧增,也是难 以接受的。
所以我们需要考虑如何从本质上加速对数据的访问呢?
- 提高IO的速度(SSD相比传统机械硬盘快了不少,但是还是没有得到本质性的提升)
- 降低树的高度---多叉树平衡树
所以接下来看看我们要学习的B-树,它就是一种多叉树平衡树
二、B-树
1、概念
1970年,R.Bayer和E.mccreight提出了一种适合外查找的树,它是一种平衡的多叉树,称为B树 (后面有一个B的改进版本B+树,然后有些地方的B树写的的是B-树,注意不要误读成"B减树")。
一 棵m阶(m>2)的B树,是一棵平衡的M路平衡搜索树,可以是空树或者满足一下性质:
- **树中每个结点至多有m棵子树,即至多含有m-1个关键字。**这意味着每个节点中可以存放的关键字数量是有限制的,最多能有m-1个关键字。比如说,如果m=5,那么一个节点里最多能放4个关键字,多开一个节点是为了方便后面的插入删除.
- **若根结点不是终端结点,则至少有两棵子树。**如果不是空树的话,根节点必须至少有两个子树,保证了即使是最简单的非空树也有一定的分支,不会退化成链表。
- **除根结点外的所有非叶子结点都包含k-1个关键字和k个孩子(终端结点孩子都是NULL),其中 ceil(m/2) ≤ k ≤ m (ceil是向上取整函数)**这里的意思是说,为了保持平衡,所有的中间节点都必须有一定的"丰满度",即它们不能太"瘦"。比如,对于一个m=5的B树,所有内部节点至少要有3个子树。
- **所有的叶结点都出现在同一层次上,并且不带信息(可以视为外部结点或类似于折半查找判定树的查找失败结点,实际上这些结点不存在,指向这些结点的指针为空)。**这保证了从根节点到任何叶子节点的距离都是相同的,确保了查询效率的一致性。无论你要找哪个数据,都需要走相同步数的路径。
- 每个节点中的关键字从小到大(也可以从大到小)排列,节点当中k-1个元素正好是k个孩子包含的元素的值域划分。 在每个节点内,关键字是按照升序或降序排列的,这样便于快速找到需要的数据或确定要查找的数据应该在哪一个子树下。
- **每个结点的结构为:(n,A0,K1,A1,K2,A2,... ,Kn,An)其中,Ki(1≤i≤n)为关键字,且Ki<Ki+1(1≤i≤n-1)。Ai(0≤i≤n)为指向子树根结点的指针。且Ai所指子树所有结点中的关键字均小于Ki+1,Ai+1所指子树所有结点中的关键字均大于Ki+1。(结点中关键字升序的情况下) n为结点中关键字的个数,满足ceil(m/2)-1≤n≤m-1。**每个节点由若干关键字及其对应的子树指针组成。关键字用来分割子树,每个关键字Ki将节点分为两部分:左边的部分指向比Ki小的元素所在的子树,右边的部分指向比Ki大的元素所在的子树。n表示该节点中实际包含的关键字的数量,这个值也是有限制的,最少为ceil(m/2)-1,最多为m-1。
2、B-树的插入
2.1 图示过程分析
先来了解一下B-树的插入是怎么样的,为了简单方便梳理,我们取B-树的阶数为 3,即三阶B-树(三叉平衡树),那根据上面的每个结点最多存储两个关键字,两个关键字可以将区间分割成三个部分,因此节点应该有三个孩子(子树) 那每个结点的结构就应该是这样的

但是为了后续实现简单,关键字和孩子我们都多给一个空间如下

接下来考虑使用序列{53, 139, 75, 49, 145, 36, 101}构建B树的过程如下:
按照升序排列,首先插入53,再插入139
接下来插入 75

插入之后如上图,因为我们多开了一个空间,3阶的话每个结点最多3-1=2个关键字 。 所以现在这个结点关键字个数超了。 那此时怎么办呢? 要进行一个操作------分裂
- 找到关键字序列的中间数,将关键字序列分成两半
- 新建一个兄弟结点出来,将右半边的m/2个关键字分给兄弟结点
- 将中间值提给父亲结点,新建结点成为其右孩子(没有父亲就创建新的根) 为什么中位数做父亲?------满足搜索树的大小关系(左<根<右)
- 结点指针链接起来

***** 到这里就能体会到了为什么会在开始的时候 关键字和孩子我们都多给一个空间 ,且要求除根结点外的所有非叶子结点都包含k-1个关键字(ceil(m/2) ≤ k ≤ m,即k的最小值是ceil(m/2)),即最少包含ceil(m/2)-1个关键字。
如果m是奇数比如9,那ceil(m/2)是5个,5-1是4,而9个的话分裂之后正好两边每个结点都是4个关键字,中间的一个提取给父亲。 如果是偶数比如10的话,ceil(m/2)是5,5-1是4,而10个分裂的话,肯定不平均,一边4个(最少的),一边5个,还有一个中间值要提取给父亲。 所以它们最少就是ceil(m/2)-1个关键字。
接着插入49 和 145,插入过程如下
- 找到该元素的插入位置(所要插入节点pCur)
- 按照插入排序思想将节点插入到该节点(pCur)的合适位置
- 检测该节点是否满足B树的性质
- 满足: 插入结束 不满足: 对节点进行分裂

接着插入 36
36 插入完成后,该节点违反 B-树 性质,需要对当前节点进行分裂,具体如下
- 找到中间位置
- 将中间位置右侧元素搬移到新节点中
- 将中间位置数据49以及新生成的节点往该节点的父亲节点中继续插入

新增一个兄弟结点之后,相当于它们的父亲结点就多了一个孩子,所以也需要增加一个关键字(关键值始终比孩子少一个),就把中间值提给父亲结点。 49上提插入到父亲,它比75小,所以75往后移(它的孩子也跟着往后移),然后49插入到前面。
接着插入101
插入之后这个节点的关键字数量大于m-1了,不满足B树性质了,要进行分裂 分裂的步骤还是和上面一样

但是此时分裂之后我们发现父亲满了,所以需要继续向上分裂

插入过程总结:
- 如果树为空,直接插入新节点中,该节点为树的根节点
- 树非空,找待插入关键字在树中的插入位置(注意:找到的插入节点位置一定在终端节点中)
- 检测是否找到插入位置(假设树中的key唯一,即该元素已经存在时则不插入)
- 按照插入排序的思想将该关键字插入到找到的结点中
- 检测该节点关键字数量是否满足B-树的性质:即该节点中的元素个数是否等于M,如果小于则满足,插入结束
- 如果插入后节点不满足B树的性质,需要对该节点进行分裂:
- 申请新的兄弟节点
- 找到该节点的中间位置
- 将该节点中间位置右侧的元素以及其孩子搬移到新节点中
- 将中间位置元素(新建结点成为其右孩子)提取至父亲结点中插入,从步骤4重复上述操作
这就是一个完整的插入过程。 并且我们会发现B-树每一次插入之后他都是天然的完全平衡,不需要想红黑树AVL树那样,插入之后不满足平衡条件了,再去调整。 并且B-树的平衡是绝对平衡。每一棵树的左右子树高度之差都是0。 为什么他能保持天然的完全平衡呢? 通过上面的插入过程我们很容易发现B-树是向右和向上生成的,只会产生新的兄弟和父亲。
2.2 代码实现
首先考虑该如何设计B-树才能以更好地方式完成增删查改工作?
2.2.1 B-树节点设计
将它设计成模板,简单一点,我们就不实现成K-V模型了,就搞个K,还需要搞个非类型模板参数M控制B树的阶 template<class K, size_t M>
上面分析过为了方便插入之后分裂,我们要多开一个空间:正常每个结点最多M-1个关键字,M个孩子;那增加一个就是M个关键字,M+1个孩子。
以上面图示的三阶B树为例,它的节点应该设计成这样

这里我们借助两个数组就可以实现,一个数组用来存储关键字,一个数组用来存储子节点的指针。对于每一个节点还需要一个存储当前节点的父节点的指针和记录当前节点的子节点个数的计数器。
节点设计如下
cpp
template<class K, size_t M>
struct BTreeNode
{
// 预留额外空间以简化分裂操作
K _keys[M]; // 关键字数组(实际最多存M-1个,多1个空间)
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(); // 初始化关键字为默认值(如0)
_subs[i] = nullptr; // 初始化子节点指针为nullptr
}
_subs[M] = nullptr; // 初始化最后一个子节点指针
_parent = nullptr; // 父节点初始为空
_n = 0; // 关键字数量初始为0
}
};
2.2.2 B-树类框架
cpp
template<class K, size_t M>
class BTree
{
typedef BTreeNode<K, M> Node; // 类型别名简化代码
Node* _root = nullptr; // 根节点指针,初始化为空
public:
// 核心操作函数...
};
2.2.3 B-树查找接口实现
这里实现一个不允许键值冗余的版本,如果存在就不再插入了,如果不存在我们让 find 直接给我们返回找到的那个要插入位置的结点,便于我们在 Insert 函数中直接将值插入到该结点中。
以下图为例,我们可以很明显的看到在 B-树 中 一个关键字的左孩子在_child数组中的下标和该关键字在_keys数组中的下标是一样的,而右孩子的话,比关键字的下标大1

所以假设我们需要查找 53 这个节点,首先和根结点的关键字进行比较,当前根结点只有一个值75,53小于75,所以去他的左子树查找。到它的左子树49这个结点,也只有一个关键字,53大于49,所以再去关键字49的右子树(如果49后面还有关键字的话,就继续跟后面的比)那此时就走到50,53这个结点。 首先跟第一个关键字50比,比50大,那就继续往后比,后面是53,相等,找到了。
那如果当前查找的值不存在呢,如想要查找52? 前面是一样的,走到这个结点,比50大,往后比,比53小,所以去53的左子树,53的左子树为空 ,所以找不到了。
cpp
//返回值是一个pair,first是结点的指针,second是关键字在数组中的下标
pair<Node*, int> Find(const K& key)
{
Node* parent = nullptr; // 查找过程中更新cur的父节点
Node* cur = _root; // 从根节点开始搜索
while (cur) // 遍历节点直至叶子
{
size_t i = 0;
// 在当前节点中线性搜索关键字
while (i < cur->_n)
{
if (key < cur->_keys[i]) {
break; // key小于当前关键字 → 进入左子树
}
else if (key > cur->_keys[i]) {
++i; // key大于当前关键字 → 继续向右比较
}
else {
return make_pair(cur, i); // 找到匹配项 → 返回节点和位置
}
}
// 根据比较结果进入子树
//有两种情况会走到这里:
//key<cur->_keys[i],break跳出循环,那此时要去它的左孩子查找,关键字的下标==左孩子的下标
//循环结束到这里,这种情况就是key大于结点中所有的关键字,那此时要去最后一个关键字的右孩子查找,此时i==n,就是最后一个关键字右孩子的下标
parent = cur; // 更新父节点
cur = cur->_subs[i]; // 移动到子节点(可能为nullptr)
}
//这里就是没找到情况的返回,对于Insert来说,如果没找到,就要插入,所以我们返回找到的正确插入位置对应的结点。这个结点是谁呢?
//cur走到空了,而它的父结点就是key应该插入的位置的结点。------》所以返回parent结点,
下标给一个-1(方便在Insert函数确定有没有找到key)
return make_pair(parent, -1); // 未找到 → 返回父节点和无效位置
}
2.2.4 B-树插入接口实现
需要对第一次插入和已经存在的情况分开做处理。
cpp
bool Insert(const K& key)
{
// 场景1:空树 → 创建根节点
if (_root == nullptr)
{
_root = new Node;
_root->_keys[0] = key;
_root->_n++;
return true;
}
// 检查key是否已存在
pair<Node*, int> ret = Find(key);
if (ret.second >= 0) return false; // 存在则拒绝插入
Node* parent = ret.first; // 目标插入节点
K newKey = key; // 待插入关键字
Node* child = nullptr; // 初始无关联子节点
while (true)
{
InsertKey(parent, newKey, child); // 插入当前节点
// 节点未满 → 插入完成
if (parent->_n < M) return true;
/* --- 节点已满 → 分裂操作 --- */
size_t mid = M / 2; // 计算分裂点(中间索引)
Node* brother = new Node; // 创建新节点存放右半部分
// 拷贝右半部分关键字和子节点
size_t j = 0;
for (size_t i = mid + 1; i < M; ++i)
{
brother->_keys[j] = parent->_keys[i]; // 拷贝关键字
brother->_subs[j] = parent->_subs[i]; // 拷贝子节点
// 更新子节点的父指针
if (parent->_subs[i])
parent->_subs[i]->_parent = brother;
j++;
// 清理原节点位置(可选)
parent->_keys[i] = K();
parent->_subs[i] = nullptr;
}
// 处理最后一个子节点
brother->_subs[j] = parent->_subs[M];
if (parent->_subs[M])
parent->_subs[M]->_parent = brother;
parent->_subs[M] = nullptr;
brother->_n = j; // 新节点关键字数量
parent->_n = parent->_n - (j + 1); // 原节点保留左半部分
K midKey = parent->_keys[mid]; // 中间关键字(需提升)
parent->_keys[mid] = K(); // 清理中间位置
/* --- 处理分裂后的连接 --- */
// 场景2:分裂根节点
if (parent->_parent == nullptr)
{
_root = new Node; // 创建新根
_root->_keys[0] = midKey; // 提升中间关键字
_root->_subs[0] = parent; // 左子节点 = 原节点
_root->_subs[1] = brother; // 右子节点 = 新节点
_root->_n = 1; // 新根只有1个关键字
// 更新父指针
parent->_parent = _root;
brother->_parent = _root;
break; // 树增高 → 结束
}
// 场景3:分裂非根节点
else
{
newKey = midKey; // 提升中间关键字
child = brother; // 新节点作为右子节点
parent = parent->_parent; // 向上处理父节点
}
}
return true;
}
那插入的时候需要保证结点里面关键字的顺序,可以用插入排序的思想把新的关键字插进去(如果是分裂之后向父亲插入的话,它可能还有孩子),那我们这里再单独封装一个InsertKey
的函数:
cpp
void InsertKey(Node* node, const K& key, Node* child)
{
int end = node->_n - 1; // 从最后一个关键字开始
// 从后向前扫描,移动大于key的关键字和子节点
while (end >= 0)
{
if (key < node->_keys[end])
{
// 右移关键字和对应的右子节点
node->_keys[end + 1] = node->_keys[end];
node->_subs[end + 2] = node->_subs[end + 1];
--end;
}
else break; // 找到插入位置
}
// 在正确位置插入新key和child
node->_keys[end + 1] = key;
node->_subs[end + 2] = child; // child是key的右子节点
// 更新子节点的父指针(如果child非空)
if (child) child->_parent = node;
node->_n++; // 关键字数量增加
}
2.3 测试
还是拿我们上面画图分析对应的那棵树来做测试:
cpp
void TestBtree()
{
int a[] = { 53, 139, 75, 49, 145, 36, 101 };
BTree<int, 3> t;
for (auto e : a)
{
t.Insert(e);
}
t.InOrder();
}

可以看到是没有什么问题的,接下来使用中序遍历
cpp
void _InOrder(Node* cur)
{
if (!cur) return; // 空节点终止递归
// 递归遍历:左子树 → 关键字 → 右子树
size_t i = 0;
for (; i < cur->_n; ++i)
{
_InOrder(cur->_subs[i]); // 遍历左子树
cout << cur->_keys[i] << " "; // 输出关键字
}
_InOrder(cur->_subs[i]); // 遍历最右子树
}
void InOrder() { _InOrder(_root); } // 外部调用接口

也是没有问题的。
2.4 B-树的删除
删除这里只讲一讲思路,删除的代码也要比插入更加复杂,大家有兴趣的话呢可以参考《算法导论》。
分情况讨论
1、删除的关键字在非终端结点 处理方法是: 用其直接前驱或直接后继替代其位置,转化为对"终端结点"的删除 直接前驱:当前关键字左边指针所指子树中"最右下"的元素 直接后继:当前关键字右边指针所指子树中"最左下"的元素 比如:

现在要删除75 首先第一种方法可以用直接前驱55替代其位置
或者用直接后继101替代

所以对非终端结点关键字的删除操作,必然可以转化为对终端结点的删除
所以下面我们重点来讨论终端结点的删除
1、删除的关键字在终端结点且删除后结点关键字个数未低于下限 若删除后结点关键字个数未低于下限ceil(m/2)-1,直接删除,无需做任何其它处理 比如:

现在要删除36,所在的结点是终端结点,且删除之后,关键字的个数不少于ceil(3/2)-1=1,所以直接删除即可

那如果删除之后关键字的个数低于下限ceil(m/2)-1呢?
1、若删除的关键字在终端结点且删除后结点关键字个数低于下限ceil(m/2)-1 这时候的处理思路是这样的: 删除之后关键字数量低于下限,那就去"借"结点,跟父亲借,父亲再去跟兄弟借 如果不能借(即借完之后父亲或兄弟关键字个数也不满足了),那就按情况进行合并(可能要合并多次)。 最终使得树重新满足B-树的性质。 比如:

现在要删40,那40删掉的话这个结点关键字个数就不满足性质了,那就去跟父亲借,49借下来,那这样父亲不满足了,父亲再向兄弟借(要删除的那个关键字所在结点的兄弟结点),53搞上去 变成这样

此时就又符合是一棵B-树了 那如果不能借的情况呢? 比如:

现在要删除160 160如果跟父亲借的话,150下来,那父亲不满足了,因为3个孩子,必须是2个关键字。而且此时兄弟145所在的这个结点也不能借了。因为此时它只有一个关键字,父亲借走一个的话,就不满足了。 所以此时借结点就不行了,就需要合并了。 如何合并呢? 如果结点不够借,则需要将父结点内的关键字与兄弟进行合并。合并后导致父节点关键字数量-1,可能需要继续合并。 那我们先来看这个
这个情况我们分析了不够借,所以要合并。大家看,160删掉的话,父亲就少了一个孩子,那关键字也应该减少一个,所以可以把父结点的150与145这个孩子合并

这样就可以了。 当然还有些情况可能需要多次合并: 比如:

现在要删145,怎么办呢? 肯定是不够借的,所以要合并,确保合并之后依然满足B-树的规则就行了。 大家看这个可以怎么合并: 145干掉之后,左子树这里就不满足了,可以先将139跟102合并。

但是此时不平衡了(B-树是绝对平衡的)。 那就要继续合并缩减高度: 很容易看出来,我们可以将101和53合并作为根,这个正好两个关键字,3个孩子

2.5 B-树的高度
问:含n个关键字的m阶B树,最小高度、最大高度是 多少?(注:大部分地方算B树的高度不包括叶子结点即查找失败结点)
首先我们来分析一下最小高度:
n个关键字的m阶B树,关键字个数和B-树的阶数已经确定的话,那要让高度最小,我们是不是要让每个结点存的关键字是最满的 啊。 那对于m阶的B树来说,每个结点最多m-1个关键字,m个孩子 第一层肯定只有一个根结点(最满的话是m-1个关键字,m个孩子),那第二层最多就有m个结点,每个结点最多m-1关键字,那第三层就是
m*m
个孩子嘛,以此类推... 那我们假设这个高度是h的话,关键字的总个数n就等于(关键字个数*结点个数): (m-1)*
(1+m+m^2
+m^3
+...+m^h-1
) 即有: n=(m-1)*
(1+m+m^2
+m^3
+...+m^h-1
) 解得**最小高度h=**logm(n+1)那最大高度呢:那要让树变得尽可能高的话,那就要让每个结点得关键字数量尽可能少(分支尽可能少)。 第一层只有一个根结点(关键字最少是1,孩子是2),根结点最少两个孩子/分支,所以第二层2个结点。 又因为除了根结点之外的结点最少有ceil(m/2)个孩子,所以第三层就最少有
2*ceil(m/2)
个结点,第四层就是2*ceil(m/2)^2
,以此类推... 第h就是2*ceil(m/2)^h-2
个结点。 那么叶子结点(查找失败结点)的个数就是2*ceil(m/2)^h-1
那这里我们不再像上面那样求出总的关键字个数去算,怎么算呢? 这里补充一个结论:n个关键字的B-树必然有n+1个叶子节点 所以我们得出: n+1=2*ceil(m/2)^h-1
那么解得最大高度h=[logceil(m/2)(n+1)/2] +1当然也可以算出关键字的总个数来求解:上面我们已经知道每层的结点个数,然后我们知道根结点最少一个关键字,其它结点最少k-1个关键字,k最小是ceil(m/2) 那么第一层就是1个关键字,第二层往后就是该层的节点个数*每个结点的最小关键字个数(k-1)

那么因此就有n=1+2(kh-1-1) 同样解得最大高度:h=[logceil(m/2)(n+1)/2] +1
B-树的性能:
B-树的效率是很高的,对于N = 62*1000000000个节点,如果度M为1024。 查找的坏最坏就是高度次嘛,h=[ logceil(M/2)(N+1)/2] +1≈logm/2N ,则logm/2N <= 4,即在620亿个元素中,如果这棵树的度为1024,则需要小于4次即可定位到该节点,然后利用二分查找可以快速定位到该元素,大大减少了读取磁盘的次数。
3、总结
然而,当数据量急剧增长,尤其是当数据存储在读写速度相对较慢的外部存储设备(如硬盘)上时,传统的二叉搜索树就暴露出其局限性:树的高度可能过高,导致需要进行大量的磁盘I/O操作,而每次I/O都伴随着高昂的时间成本。
B-树是一种自平衡的树数据结构,它能够保持数据有序,并且特别优化了对存储在外部存储器上的大规模数据集的访问。与二叉树每个节点最多有两个子节点不同,B-树的每个节点可以拥有多个子节点(通常远大于2)。这种"胖"而"矮"的结构设计,极大地减少了树的层次深度,从而显著降低了在最坏情况下访问数据所需的磁盘读取次数。
4、源码
cpp
#pragma once
template<class K, size_t M>
struct BTreeNode
{
//K _keys[M - 1];
//BTreeNode<K, M>* _subs[M];
// 为了方便插入以后再分裂,多给一个空间
K _keys[M];
BTreeNode<K, M>* _subs[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;
}
};
// 数据是存在磁盘,K是磁盘地址
template<class K, size_t M>
class BTree
{
typedef BTreeNode<K, M> Node;
public:
pair<Node*, int> Find(const K& key)
{
Node* parent = nullptr;
Node* cur = _root;
while (cur)
{
// 在一个节点查找
size_t i = 0;
while (i < cur->_n)
{
if (key < cur->_keys[i])
{
break;
}
else if (key > cur->_keys[i])
{
++i;
}
else
{
return make_pair(cur, i);
}
}
// 往孩子去跳
parent = cur;
cur = cur->_subs[i];
}
return make_pair(parent, -1);
}
void InsertKey(Node* node, const K& key, Node* child)
{
int end = node->_n - 1;
while (end >= 0)
{
if (key < node->_keys[end])
{
// 挪动key和他的右孩子
node->_keys[end + 1] = node->_keys[end];
node->_subs[end + 2] = node->_subs[end + 1];
--end;
}
else
{
break;
}
}
node->_keys[end + 1] = key;
node->_subs[end + 2] = child;
if (child)
{
child->_parent = node;
}
node->_n++;
}
bool Insert(const K& key)
{
if (_root == nullptr)
{
_root = new Node;
_root->_keys[0] = key;
_root->_n++;
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
{
size_t mid = M / 2;
// 分裂一半[mid+1, M-1]给兄弟
Node* brother = new Node;
size_t j = 0;
size_t i = mid + 1;
for (; 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;
// 拷走重置一下方便观察
parent->_keys[i] = K();
parent->_subs[i] = nullptr;
}
// 还有最后一个右孩子拷给
brother->_subs[j] = parent->_subs[i];
if (parent->_subs[i])
{
parent->_subs[i]->_parent = brother;
}
parent->_subs[i] = nullptr;
brother->_n = j;
parent->_n -= (brother->_n + 1);
K midKey = parent->_keys[mid];
parent->_keys[mid] = K();
// 说明刚刚分裂是根节点
if (parent->_parent == nullptr)
{
_root = new Node;
_root->_keys[0] = midKey;
_root->_subs[0] = parent;
_root->_subs[1] = brother;
_root->_n = 1;
parent->_parent = _root;
brother->_parent = _root;
break;
}
else
{
// 转换成往parent->parent 去插入parent->[mid] 和 brother
newKey = midKey;
child = brother;
parent = parent->_parent;
}
}
}
return true;
}
void _InOrder(Node* cur)
{
if (cur == nullptr)
return;
// 左 根 左 根 ... 右
size_t i = 0;
for (; i < cur->_n; ++i)
{
_InOrder(cur->_subs[i]); // 左子树
cout << cur->_keys[i] << " "; // 根
}
_InOrder(cur->_subs[i]); // 最后的那个右子树
}
void InOrder()
{
_InOrder(_root);
}
private:
Node* _root = nullptr;
};
void TestBtree()
{
int a[] = { 53, 139, 75, 49, 145, 36, 101 };
BTree<int, 3> t;
for (auto e : a)
{
t.Insert(e);
}
t.InOrder();
}
三、B+树
1、B+树概念
B+树是B树的变形,是在B树基础上优化的多路平衡搜索树,B+树的规则跟B树基本类似,但是又在B树的基础上做了一些改进优化。
一棵m阶的B+树需满足下列条件:

- 每个分支结点最多有m棵子树(孩子结点)。 这就像一个"超级目录"。想象一下,一个文件夹(分支节点)最多只能包含
m
个子文件夹(子树)。m
是一个事先定好的数字,比如5或100。这保证了树不会变得太"宽",也限制了单次查找需要检查的分支数量。 - 非叶根结点至少有两棵子树,其他每个分支结点至少有「m/2]棵子树。 (前面这两条其实还跟B树是一样的)。这是为了保持树的"丰满"和平衡,避免浪费空间。
- 结点的子树个数与关键字个数相等。这是B+树和B树的一个关键区别!在B树中,
k
个关键字对应k+1
个孩子。但在B+树的中间分支节点 里,有几个关键字,就有几个指向子树的指针。因为这里的每个关键字主要起"路标 "或"分界点 "的作用。比如,一个节点有关键字 [10, 20],并且有3个孩子指针。它表示:- 第一个孩子(左)里的所有数据都 小于 10。
- 第二个孩子(中)里的所有数据都 大于等于 10 且 小于 20。
- 第三个孩子(右)里的所有数据都 大于等于 20。
- 注意,这里的
>=
和<
是关键,它和下一条紧密相关。
- 结点的子树指针p[i]指向关键字值大小在[k[i],k[i+1])区间之间。 这条规则定义了子树指针
p[i]
到底指向什么数据范围。p[i]
指向的那棵子树里,所有的数据 都满足:大于等于k[i]
且 小于k[i+1]
。[k[i], k[i+1])
这个记号中,[
表示包含k[i]
,)
表示不包含k[i+1]
。 - **所有叶子节点增加一个链接指针链接在一起。**这是B+树的另一个巨大优势!所有的"最终数据存放处"(叶子节点)都被一个"链表"串起来了,就像一串珍珠。
- 所有关键字及其映射数据都在叶子节点出现。 这是B+树与B树最根本的区别!在B+树中:
- 中间分支节点 只存放 关键字 和 子树指针 。这些关键字纯粹是"索引 "或"路标 ",用来指引查找方向。它们不存放实际的数据记录!
- 所有的实际数据记录 (比如用户信息、文件内容等)以及它们对应的关键字,都只存放在最底层的叶子节点里,且如第5点,所有叶子节点增加一个链接指针链接在一起。
B+树的特性:
- 所有关键字都出现在叶子节点的链表中,且链表中的元素都是有序的。 2. 查找不可能在分支节点中命中。 3. 分支节点相当于是叶子节点的索引(仅含有其子树根结点中最大/最小关键码,我们这里图中是最小的),叶子节点才是存储数据的数据层(与B树不同)。
2、B+树查找
B+树的查找上面有提到------查找不可能在分支节点中命中,因为分支存的是索引,如果能找到,应该在叶子节点的链表中:
对于如下这棵树

如上图所示,对于第一层,有三个元素5,28,65。这里分别存储的都是他们所指向的下一层的元素中最小的元素,如5所指的下一层是5、10、20。而28所指向的下一层元素是28、35、56。以此类推,除了叶子节点,其他节点存储的都是索引,最后,将叶子节点的数据链接起来,这样为了方便查找,这就是B+树和B-树的区别。
比如我们要查找33 从根结点开始33比5大,往后走,比28也大,再往后走,但是比65小。 所以如果33存在的话,应该在28的子树中。 所以进入28的子树中,然后比较比28大,比35小,所以再往这一层的28的子树p1中找,这就进入到叶子结点的链表中,往后遍历就找到了33。(如果查找的是28也要进入到叶子结点的链表中查找,即使分支结点中存在) 那如果查找34(找不到),也是一样的,最终走到叶子结点的链表中,但是没有这个元素,所以就找不到。
所以B+树的查找无论成功与否,都要走到最下面一层的叶子结点,而B-树的话,查找可能停止在任意一层。
那除了上面的查找方法,其实B+树还有另外一种查找方法:
上面提到对于B+树来说,所有叶子节点增加一个链接指针链接在一起

而每个叶子结点的链表里面元素都是有序的。 所以我们也可以通过这个链接指针去进行顺序查找,从前往后遍历每一个叶子结点的链表。
3、B+树的插入
B+树的插入操作遵循一套精心设计的规则,旨在维持其平衡性和高效性。整个过程可以概括为以下几个关键步骤:
-
定位插入位置:
- 从B+树的根节点开始,根据要插入的关键字
K
的大小,沿着树的分支向下查找。 - 比较
K
与当前节点中的关键字,决定进入哪个子树(左、中、右...),这个过程类似于二分查找。 - 一直向下,直到找到应该存放
K
的叶子节点L
。插入操作只发生在叶子节点。
- 从B+树的根节点开始,根据要插入的关键字
-
在叶子节点插入关键字:
- 找到目标叶子节点
L
后,将关键字K
(以及其关联的数据,如指针、记录等)按照升序(或降序)规则,插入到L
节点中合适的位置。 - 这通常使用类似"插入排序"的方法,将
K
放入正确位置,确保节点内的关键字保持有序。
- 找到目标叶子节点
-
检查并处理节点溢出:
- B+树规定每个节点(包括叶子节点)最多只能容纳
m-1
个关键字(m
是B+树的阶数)。 - 如果插入后
L
节点的关键字数量未超过上限 (≤ m-1
):- 插入操作成功完成。同时,需要更新叶子节点之间的双向链表,将新插入的
K
正确地链接进去。
- 插入操作成功完成。同时,需要更新叶子节点之间的双向链表,将新插入的
- 如果插入后
L
节点的关键字数量超过了上限 (= m
):- 节点
L
发生了"溢出",必须进行分裂(Split) 操作来维持B+树的性质。
- 节点
- B+树规定每个节点(包括叶子节点)最多只能容纳
-
分裂叶子节点:
- 创建一个新的叶子节点
L_new
。 - 将原节点
L
中的后半部分关键字(通常是ceil(m/2)
个,具体规则可能略有不同,常见的是将m
个元素分成floor((m+1)/2)
和ceil((m+1)/2)
两部分)全部 移动到新节点L_new
中。 - 这样,
L
和L_new
都变成了合法的、未满的节点。 - 关键点: 在B+树中,分裂后,会有一个关键字需要"向上"传递 。这个关键字通常是新节点
L_new
中的第一个关键字(即最小的那个),因为它需要成为父节点中一个新的"路标"。
- 创建一个新的叶子节点
-
向上递归处理(可能涉及中间节点分裂):
- 将上一步中需要"向上"传递的那个关键字
K_promote
(以及指向新节点L_new
的指针)插入到原节点L
的父节点P
中。 - 这个插入操作在父节点
P
中进行,同样需要保持关键字有序。 - 检查父节点
P
是否溢出:- 如果
P
插入后未溢出,则整个插入过程结束。 - 如果
P
插入后也溢出了(关键字数达到m
),那么就需要对中间分支节点P
进行分裂。
- 如果
- 分裂中间节点:
- 创建一个新的中间节点
P_new
。 - 将
P
中的后半部分关键字和对应的子树指针 移动到P_new
中。 - 关键点: 与叶子节点分裂不同,中间节点分裂时,被移动到新节点的第一个关键字会从原节点中移除,并"向上"传递到
P
的父节点。这个被传递上去的关键字通常是分裂点处的那个关键字(它原本是左右两部分的分界)。
- 创建一个新的中间节点
- 这个"插入 -> 溢出 -> 分裂 -> 向上传递关键字"的过程会沿着树向上递归进行。
- 将上一步中需要"向上"传递的那个关键字
-
处理根节点分裂(特殊情况):
- 如果这个向上递归的过程一直持续到根节点,并且根节点也发生了溢出需要分裂。
- 这时,创建一个新的根节点
NewRoot
。 - 将分裂后产生的两个节点(一个是原根分裂后的一部分,另一个是新创建的节点)作为
NewRoot
的子节点。 - 将需要"向上"传递的那个关键字放入
NewRoot
中。 - 结果: B+树的高度增加了一层。这是B+树唯一会增加高度的情况。
四、B-树和B+树对比
思考,既然已经有了B-树的情况下,为什么还需要B+树呢?B+树的特点究竟能带来什么好处呢?
- 更高的空间利用率。如果对于体量很大的数据来说,我们想要对它进行查找,我们需要在内存中构建一个搜索树,但是对于内存中相同大小的空间来说,由于B+树的非叶子节点上只存储键(作为索引)和指向子节点的指针,只有叶子节点存储完整的键值对数据(或指向数据的指针),而B-树的每个叶子节点上都存储键值对。所以B+树能存储更多的键。
- 更高效的范围查询和顺序访问:
- B-树: 叶子节点之间没有显式的链表连接。进行范围查询时,需要从根节点开始多次查找定位到起始键,然后进行中序遍历才能找到范围内的所有数据。这个过程可能涉及多次访问不同层级的节点(甚至非叶子节点),效率较低。
- B+树 :一旦定位到范围查询的起始键所在的叶子节点,只需要沿着叶子节点的链表顺序扫描 即可高效地获取范围内的所有数据。完全不需要回溯到上层非叶子节点。这对于全表扫描 (数据库相关知识后面介绍) 和大型范围查询至关重要。
- 更稳定的查询性能: 查找任何 一个键所需的磁盘I/O次数是相同的、固定的 (等于树的高度)。这使得数据库优化器更容易预测查询成本。
- B-树: 查找操作可能在任何一层节点命中 (如果该节点存储了目标键值对)。这意味着查找不同键所需的磁盘I/O次数可能不同(有的在根节点的下一层就找到了,有的要到叶子层)。
- B+树 :任何查找操作(即使是精确查找)都必须最终走到叶子节点才能获取数据(或指向数据的指针)。
- 更适合磁盘预读特性: 我们都知道,磁盘读取数据时,通常会预读相邻的数据块(局部性原理)。
- B+树: 由于叶子节点存储了所有实际数据并且是顺序链接的,当读取一个叶子节点进行范围扫描时,磁盘预读机制能更有效地将后续可能需要的叶子节点(物理上相邻或逻辑上链表指向的)提前加载到内存中,大大加速顺序访问。
- B树: 数据分散在树的所有层级,范围查询时需要跳跃式地访问不同节点,预读效果不如B+树好。
B+树通过牺牲非叶子节点的数据存储能力(换取更高键密度和更矮的树),并引入叶子节点链表,在磁盘I/O密集型操作(尤其是范围查询和顺序扫描)上获得了压倒性的性能优势,同时保持了稳定的点查询效率。这些特性使其成为数据库索引和文件系统的绝对主流选择。 B+树并非取代了B树,而是在B树的基础上,针对数据库和文件系统等场景最核心的操作(范围查询、顺序访问、高I/O效率)进行了极致的优化。正是这些优化使得B+树成为了这些领域事实上的标准索引数据结构。
特性 | B树 | B+树 |
---|---|---|
数据存储位置 | 所有节点(包括内部节点)都可存储数据 | 仅叶子节点存储数据,内部节点只存索引 |
节点结构 | 每个节点包含键值和数据指针 | 内部节点只存键值和子节点指针;叶子节点额外有链表指针 |
叶子节点连接 | 叶子节点不相连 | 叶子节点通过双向链表连接,支持顺序访问 |
键值出现次数 | 每个键值只出现一次 | 键值在内部节点重复出现(叶子节点存全量数据) |
分支因子 | 较低(节点需存储数据) | 更高(节点只存索引) |
查找性能 | - 平均:O(log<sub>n</sub>)- 可能在内部节点命中 | - 平均:O(log<sub>n</sub>)- 必须查找到叶子节点 |
范围查询效率 | 较差(需要回溯树结构) | 极优(通过叶子链表顺序访问) |
查询稳定性 | 不稳定(查询路径长度不一致) | 稳定(所有查询路径长度相同) |
插入/删除过程 | 1. 直接修改目标节点2. 分裂时移动键值到父节点 | 1. 只修改叶子节点2. 分裂时复制键值到父节点(叶子保留数据) |
分裂操作 | 中间键值移动到父节点 | 叶子分裂:复制最小键到父节点内部节点分裂:中间键值复制到父节点 |
空间利用率 | 较低(所有节点都存储数据) | 更高(内部节点只存索引) |
最小键值存储 | 内部节点不存储子树的最小键值 | 内部节点存储子树的最小键值(用于索引) |
典型应用场景 | - 文件系统(ext4,XFS)- 少量数据集 | - 数据库索引(MySQL,Oracle)- 大数据存储- 文件系统(NTFS,ReiserFS) |
优势 | - 点查询可能更快(数据可能在内部节点)- 实现相对简单 | - 超高并发处理能力 - 范围查询性能极佳 - 更适合磁盘存储 |
劣势 | - 范围查询效率低- 树结构更复杂- 空间利用率较低 | - 点查询需访问叶子节点- 实现更复杂- 内部节点有冗余键值 |
数据一致性 | 修改只需更新一个节点 | 修改需更新叶子节点和可能的多级索引节点 |
全表扫描效率 | 较差(需要遍历整棵树) | 极高(只需遍历叶子链表) |
内存使用 | 较高(每个节点都存数据) | 更优(索引节点可缓存于内存) |
键值唯一性 | 所有键值分布在不同层级 | 所有键值最终出现在叶子层 |
B-树应用场景:
- 需要快速单点查询的系统
- 内存受限环境
- 写密集型负载
B+树应用场景:
- 数据库索引系统
- 需要高效范围查询的场景
- 读密集型负载
- 大型文件系统
- 需要高并发访问的系统
五、B*树
上面介绍了B-树和B+树,B+树对B-树的部分做了优化,但是他们俩仍然存在着一个问题:空间利用率不足的问题,意味着在最坏情况下,一个节点可能只被填充到接近 **50%,**尤其是在数据分布不均匀或频繁插入删除的情况下,节点可能经常处于接近半满的状态,这可能会造成空间的浪费。基于这一问题引入了B*树。
B*树是B+树的变形,在B+树的非根和非叶子节点再增加指向兄弟节点的指针

++B*树是如何解决空间利用率不足的问题呢?++
B*树通过两个关键策略来减少节点分裂频率和提高空间利用率。
- 更高的最小填充因子: B*树要求非根节点至少填充到
2/3
(即大约 66.7% ),而不是1/2
(50%)。具体来说:
- 阶数为
m
的非根节点,最少必须包含⌈(2m)/3⌉
个子节点(⌈(2m)/3⌉ - 1
个键)。- 例如,
m=3
时,B树/B+树要求最少2
个子节点(1个键,利用率50%),B*树要求最少⌈6/3⌉=2
个子节点(1个键,利用率50% - 注:m=3时提升不明显)。- 例如,
m=4
时,B树/B+树要求最少2
个子节点(1个键,利用率33%),B*树要求最少⌈8/3⌉=3
个子节点(2个键,利用率66.7%)。提升显著。- 延迟分裂 - 兄弟节点再分配: 这是B*树最核心的创新点 。当向一个已满(或达到
m-1
个键)的节点插入新键时:
- 首先尝试再分配: 检查其相邻的兄弟节点 是否也接近满(
m-1
个键)。如果某个兄弟节点未满 (即还有空闲空间),则不是立即分裂当前节点,而是将当前节点、兄弟节点和新键上的键值重新均匀分布(Redistribute)到这两个兄弟节点之间 。这样只需要修改这两个兄弟节点及其父节点中的索引键即可,避免了一次分裂操作(创建一个新节点并修改父节点)。- 不得已时才分裂: 只有当所有相邻兄弟节点也都已满 时,才进行真正的分裂。但即使分裂,B*树的分裂方式也不同于B树/B+树:
- 不是将
m
个元素分成两个半满节点(各约m/2
个)。- 而是将当前节点、一个已满的兄弟节点和新键,总共
3m
个元素(实际上是(m-1) + (m-1) + 1 = 2m -1
个键),平均分配到 三个节点**中(每个节点大约(2m -1)/3 ≈ 2m/3
个键)。这样新分裂出来的两个节点(原节点分裂成两个,加上一个新节点)的填充率都接近2/3
,而不是1/2
。
B*树的分裂
当一个结点满时,如果它的下一个兄弟结点未满,那么将一部分数据移到兄弟结点中,再在原结 点插入关键字,最后修改父结点中兄弟结点的关键字(因为兄弟结点的关键字范围改变了);如 果兄弟也满了,则在原结点与兄弟结点之间增加新结点,并各复制1/3的数据到新结点,最后在父 结点增加新结点的指针。 所以,B*树分配新结点的概率比B+树要低,空间使用率更高
感谢阅读!