一、从二叉树到多叉树:为什么需要B树?
二叉树的问题:磁盘IO太高
二叉树的节点是逻辑概念,实际存储时:
-
每个节点对应一块磁盘页(通常4KB~16KB)
-
查找时,每比较一次就要做一次磁盘寻址
磁盘寻址代价:约10ms(内存访问只需几纳秒)
举例:100万条数据,二叉树高约20层 = 最坏20次磁盘IO
解决方案:多叉树
同样的100万条数据,用四叉树:
层高 = log₄(100万/3) ≈ 10层
搜索次数从20次降到10次,磁盘IO减半!
二、B树的概念
定义
B树(B-Tree) 是一种平衡的多路查找树,最核心的特点:
所有叶子节点在同一层(这是B树的核心特征)
B树的阶(M阶)
- M阶:最多有M个子树
- M阶B树的关键字数量:
ceil(M/2)-1 ≤ n ≤ M-1 - 根节点至少2棵子树(除非是叶子)
三、B树的5条性质
| 性质 | 说明 |
|---|---|
| 1 | 每个节点至多拥有M棵子树 |
| 2 | 根节点至少拥有2棵子树(除非是叶子节点) |
| 3 | 除根节点外,每个分支节点至少拥有 M/2 棵子树 |
| 4 | 所有叶子节点在同一层 |
| 5 | 有k棵子树的分支节点有k-1个关键字,按顺序排列 |
| 6 | 关键字数量:ceil(M/2)-1 ≤ n ≤ M-1 |
B树结构示例图(M=3,最大3个子树,2个关键字)
[50]
/ | \
[20,30] [60] [80,90]
/ | \ / | / |
... ... ... ... ... ...
所有叶子节点都在同一层
阶M=3的B树节点结构:
节点(最多3棵子树,2个关键字)
┌────────────────────────────────────┐
│ 子树0 │ 关键字0 │ 子树1 │ 关键字1 │ 子树2 │
└────────────────────────────────────┘
子树数 = 关键字数 + 1
四、B树 vs B+树:关键区别
| 对比 | B树 | B+树 |
|---|---|---|
| 数据存储 | 所有节点都存数据 | 只有叶子节点存数据 |
| 内节点作用 | 存储数据 + 索引 | 仅做索引(不存数据) |
| 叶子节点 | 不一定在同一层 | 必须在同一层,且链表串联 |
| 查询效率 | 不稳定(数据可能在任意层) | 稳定(必须到叶子) |
| 应用场景 | 内存场景 | 磁盘索引(MySQL、MongoDB) |
B树结构图
┌─────────────────┐
│ 30 50 70 │ ← 所有节点都存数据
└────┬────┬───────┘
┌──┴──┐ └──┐
[20] [40] [60] [80,90] ← 数据分布在各层
/ | / | / | / |
... ... ... ... ... ...
B+树结构图(重点!)
内节点(只存索引,不存数据)
┌─────────────────┐
│ 40 70 │
└───────┬─────────┘
┌─────────┼─────────┐
▼ ▼ ▼
┌────────┐ ┌────────┐ ┌────────┐
│ 叶子1 │ │ 叶子2 │ │ 叶子3 │
│10,20,30│ │40,50,60│ │70,80,90│ ← 所有数据在叶子
└────────┘ └────────┘ └────────┘
│ │ │
└─────────┴─────────┘
链表串联
特点:内节点只做索引,叶子层链表串联,范围查询极快
B+树为什么更适合磁盘索引?
同样的16KB内存,假设每个关键字16B,指针8B:
B树(每个节点存数据):
节点容量 ≈ 16KB / (16B+8B) ≈ 682个关键字
3层 → 682² ≈ 46万条
B+树(内节点不存数据):
节点容量 ≈ 16KB / (16B+8B) ≈ 682个指针
3层 → 682² ≈ 46万条指针
叶子层存全部数据 → 索引效率更高
关键:内节点更小 → 同样内存能容纳更多索引 → 层高更矮
五、B树代码实现
数据结构定义
c
#define SUB_M 3 // 阶数M=3
typedef struct _btree_node {
int *keys; // 关键字数组,大小 = M*2 - 1 = 5
struct _btree_node **childrens; // 子节点指针数组,大小 = M*2 = 6
int num; // 当前存储的关键字数量
int leaf; // 是否为叶子节点(1=是,0=否)
} btree_node;
typedef struct _btree {
btree_node *root;
} btree;
节点创建与销毁
c
btree_node *btree_create_node(int leaf) {
btree_node *node = (btree_node *)calloc(1, sizeof(btree_node));
if (node == NULL) return NULL;
node->leaf = leaf;
node->keys = (int *)calloc(SUB_M * 2 - 1, sizeof(int)); // 5个关键字
node->childrens = (btree_node **)calloc(SUB_M * 2, sizeof(btree_node *)); // 6个子树
node->num = 0;
return node;
}
void btree_destroy_node(btree_node *node) {
if (node == NULL) return;
free(node->childrens);
free(node->keys);
free(node);
}
六、B树核心操作流程图
6.1 分裂操作(最核心!)
时机:子节点满了(num == M*2-1 == 5)时分裂
分裂流程图:
分裂前(y节点满了,num=5,M=3):
x (父节点)
│
┌────┴────┐
│ ... │
└────┬────┘
│
┌────────▼────────┐
│ [k0,k1,k2,k3,k4]│ ← y节点,5个关键字已满
└────────┬────────┘
│
分裂方向
↓
将 [k3,k4] 移到新节点z
k2(中间关键字)上移到父节点x
分裂后:
x
┌────┴────┐
│ ... k2 ... │ ← k2上移
└─┬──────┬─┘
┌────┘ └────┐
┌────▼────┐ ┌────▼────┐
│[k0,k1] │ │[k3,k4] │ ← z新节点
│ y节点 │ │ z节点 │
│(左半部) │ │(右半部) │
└─────────┘ └─────────┘
y保留:左半部(k0,k1)+ 子树0~2
z获得:右半部(k3,k4)+ 子树3~5
代码实现:
c
// x是父节点,idx是第idx个子树需要分裂
void btree_split_child(btree *T, btree_node *x, int idx) {
btree_node *y = x->childrens[idx]; // 满的子节点
btree_node *z = btree_create_node(y->leaf); // 新节点
// z拿走y的右半部分(从M开始到2M-2)
z->num = SUB_M - 1; // = 2
int i = 0;
for (i = 0; i < SUB_M - 1; i++) {
z->keys[i] = y->keys[i + SUB_M]; // 复制 k3, k4
}
// 如果不是叶子节点,子节点也要迁移
if (y->leaf == 0) {
for (i = 0; i < SUB_M; i++) {
z->childrens[i] = y->childrens[i + SUB_M];
}
}
// y只保留左半部分
y->num = SUB_M - 1; // = 2
// === 父节点操作:腾出位置给z ===
// 1. 子树指针后移
for (i = x->num; i >= idx + 1; i--) {
x->childrens[i + 1] = x->childrens[i];
}
x->childrens[idx + 1] = z;
// 2. 关键字后移,空出位置
for (i = x->num; i >= idx; i--) {
x->keys[i + 1] = x->keys[i];
}
x->keys[idx] = y->keys[SUB_M - 1]; // k2上移
x->num++;
}
6.2 插入操作
核心规则 :数据一定插入到叶子节点,不在内节点插
代码实现:
c
void btree_insert(btree *T, int k) {
btree_node *r = T->root;
// 根节点满了,需要先分裂(树会长高)
if (r->num == SUB_M * 2 - 1) { // = 5
btree_node *node = btree_create_node(0); // 0=非叶子
T->root = node;
node->childrens[0] = r;
btree_split_child(T, node, 0);
// 分裂后:node是新根,r是左子树
// 接下来会找到正确的子树继续插入
}
// 调用递归插入(需要实现 btree_insert_nonfull)
btree_insert_nonfull(T, T->root, k);
}
void btree_insert_nonfull(btree *T, btree_node *node, int k) {
int i = node->num - 1;
if (node->leaf) {
// 叶子节点:在末尾寻找正确位置,移动 + 插入
while (i >= 0 && k < node->keys[i]) {
node->keys[i + 1] = node->keys[i];
i--;
}
node->keys[i + 1] = k;
node->num++;
} else {
// 非叶子节点:找到正确的子树
while (i >= 0 && k < node->keys[i]) {
i--;
}
i++;
// 递归插入前,如果子树满了要先分裂
if (node->childrens[i]->num == SUB_M * 2 - 1) {
btree_split_child(T, node, i);
// 分裂后,中间关键字上移
// 可能需要调整i(判断k应该插入哪个子树)
if (k > node->keys[i]) {
i++;
}
}
btree_insert_nonfull(T, node->childrens[i], k);
}
}
6.3 合并操作(删除时用到)
时机:删除后节点关键字太少(< M/2-1)时借位/合并
合并流程图:
合并前(idx=1,合并第1和第2棵子树):
x
┌─────┴─────┐
│ ... key ... │ ← key是要下移的关键字
└─────┬─────┘
┌───────┼───────┐
│ │ │
[左子树] [中间] [右子树]
left key right
num=1 key num=1
(太少) (太少)
合并操作:
1. key下移到left节点末尾
2. right所有关键字移到left
3. 销毁right节点
4. 父节点删除key,后面前移
合并后:
x
┌─────┴─────┐
│ ... ... ... │ ← key已被删除
└─────┬─────┘
│
┌───────▼───────┐
│ left节点: │
│ 原有keys + │
│ key + │
│ right全部keys │
└───────────────┘
left节点num = left.num + 1 + right.num
代码实现:
c
void btree_merge_child(btree *T, btree_node *x, int idx) {
btree_node *left = x->childrens[idx]; // 第idx棵子树
btree_node *right = x->childrens[idx + 1]; // 第idx+1棵子树
// 1. 父节点关键字下移到左节点
left->keys[left->num] = x->keys[idx];
left->num++;
// 2. 右节点的数据全部合并到左节点
int i = 0;
for (i = 0; i < right->num; i++) {
left->keys[left->num + i] = right->keys[i];
}
// 3. 子节点也要迁移
if (left->leaf == 0) {
for (i = 0; i < SUB_M; i++) {
left->childrens[left->num + i] = right->childrens[i];
}
}
left->num += right->num;
// 4. 销毁右节点
btree_destroy_node(right);
// 5. 父节点删除key,后面前移
for (i = idx + 1; i < x->num; i++) {
x->keys[i - 1] = x->keys[i];
x->childrens[i] = x->childrens[i + 1];
}
}
七、完整插入/分裂过程示例
从空树开始,依次插入:10, 20, 30, 40, 50(M=3)
初始:空树
插入10:
[10]
插入20(根节点未满,直接追加):
[10, 20]
插入30(根节点满了,需要分裂):
分裂前:[10, 20, 30]
分裂中:中间关键字20上移,左右各一个
┌────────┐
│[10][20][30]│ → 20是中间 → 上移
└────────┘
分裂后树高+1:
[20] ← 新根
/ \
[10] [30]
插入40(从根找到叶子[30],未满,直接插):
[20]
/ \
[10] [30,40]
插入50(叶子[30,40]满了,需要分裂):
分裂前:[30, 40, 50]
中间40上移,30和50各成一个节点
分裂后:
[20, 40] ← 20和40在新根
/ | \
[10] [30] [50] ← 30和50分裂
插入过程中,每次节点满 → 分裂 → 可能向上传播 → 树高可能增加
八、面试高频提问
Q1:B树和B+树的区别?
- B树:所有节点存数据,层高不稳定
- B+树:只有叶子存数据,内节点只做索引,叶子链表串联
Q2:为什么MySQL用B+树做索引?
- 磁盘友好的多叉树,层高低
- 内节点不存数据 → 一次磁盘页能装更多索引
- 叶子链表串联 → 范围查询快
Q3:B树分裂的时机?
- 节点满(num == M*2-1)时分裂
- 先分裂,再插入
- 分裂后中间关键字上移,层高可能+1
Q4:删除操作要注意什么?
- 不能随便删,否则子树可能太少(< M/2)
- 删除前要先借位或合并 ,让节点满足
num >= ceil(M/2)-1 - 顺序:先合并/借位,再删除
Q5:根节点分裂的特殊性?
- 根节点分裂后,树高+1
- 1个节点分裂成2个节点,新根有2个子树
九、总结对比
| 数据结构 | 层高 | 适用场景 |
|---|---|---|
| 二叉树 | log₂N | 内存,节点小 |
| B树 | logₘN(m大) | 通用场景,数据存在各层 |
| B+树 | logₘN(m大) | 磁盘索引,MySQL/MongoDB |
| 红黑树 | 2log₂N | 内存,std::map,层高有限 |
根据零声教育教学写作https://github.com/0voice