B树与B+树详解

一、从二叉树到多叉树:为什么需要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

相关推荐
qingy_20461 小时前
Redis Zset 底层数据结构及其使用场景
数据结构·数据库·redis
Lazionr1 小时前
数据结构堆详解:原理、实现与应用
数据结构·算法
Zephyr_01 小时前
c++数据结构
数据结构·c++
故事和你911 小时前
蓝桥杯-2026年C++B组省赛
开发语言·数据结构·c++·算法·蓝桥杯·动态规划·图论
如君愿1 小时前
考研复习 Day 33 | 习题--计算机网络 第六章(应用层 上)、数据结构 查找算法(上)
数据结构·计算机网络·考研·课后习题
玛卡巴卡ldf2 小时前
【LeetCode 手撕算法】(二分查找)搜索插入位置、搜索二维矩阵、查找数组相同的所有位置、搜索旋转排序数组、旋转升序数组的最小值
数据结构·算法·leetcode
散峰而望10 小时前
【算法竞赛】C/C++ 的输入输出你真的玩会了吗?
c语言·开发语言·数据结构·c++·算法·github
躺不平的理查德10 小时前
时间复杂度与空间复杂度备忘录
数据结构·算法
刃神太酷啦10 小时前
扒透 STL 底层!map/set 如何封装红黑树?迭代器逻辑 + 键值限制全手撕----《Hello C++ Wrold!》(23)--(C/C++)
java·c语言·javascript·数据结构·c++·算法·leetcode