定义

B+ 树(B+ Tree)是一种 阶(m-ary)的自平衡树数据结构,属于 B 树(B-Tree)的变体。其核心特点是所有数据(键值对)都存储在叶子节点,内部节点仅存储用于导航的键值。

作为一种多路搜索树,B+ 树特别适合大规模数据存储和高效范围查询的场景,是数据库索引和文件系统的核心数据结构。1

与 B 树的核心区别

特征B 树B+ 树
数据存储位置所有节点(含内部节点)仅叶子节点
内部节点键值键值 + 指针仅键值(用于索引)
叶子节点连接有序双向链表
搜索稳定性可能在中途找到必查到叶子
范围查询效率需要回溯遍历叶子链表 遍历

结构

B+ 树的节点分为三种类型:根节点(Root)内部节点(Internal Node)叶子节点(Leaf Node)

树结构示意

                    B+ 树结构示意图 (m=4)
                    
                         根节点
                          [30]
                         / | \
                        /  |  \
            内部节点  [15] [30] [50, 70]  ← 内部节点仅存键值
                    / | \   / \    / | \
                   /  |  \ /  \   /  |  \
            叶子→ L1  L2 L3  L4  L5  L6  L7   ← 所有数据在叶子
             ↓    ↓    ↓   ↓    ↓   ↓    ↓
           [5]  [15] [20] [30] [50] [70] [90]  ← 叶子节点存数据
             |_____|_____|_____|_____|_____|     ↓
                                            有序链表连接

节点结构

内部节点

内部节点结构:

┌─────────────────────────────────────────────────────────┐
│  子节点指针0  │  键值0  │  子节点指针1  │  键值1  │ ... │
└─────────────────────────────────────────────────────────┘

n 个键值:k₀ < k₁ < ... < kₙ₋₁
n+1 个子指针:P₀, P₁, ..., Pₙ
  • 键值数量(满足
  • 子指针数量
  • 子节点范围 中的所有键值满足

叶子节点

叶子节点结构:

┌─────────────────────────────────────────────────────────┐
│  数据0  │  键值0  │  数据1  │  键值1  │  数据2  │  键值2 │ ... │
└─────────────────────────────────────────────────────────┘
          ↕ 链表前向指针                                    ↕
        prev   next ────────────────────────────────► next
  • 键值数量(满足
  • 数据/值:可以是实际数据或 RID(记录标识符)
  • 兄弟指针:双向链表连接相邻叶子节点

关键性质

1. 完全平衡(Perfectly Balanced)

所有叶子节点位于同一深度,这确保了任何搜索路径的长度相同,查询时间可预测。

2. 高扇出(High Fanout)

典型的 B+ 树扇出度为 100 到 1000+,这意味着:

  • 树的高度极低(通常 2-4 层)
  • 每次 I/O 操作可读取大量节点
  • 减少了磁盘访问次数

3. 最小占用率 50%

除根节点外,每个节点的键值数量必须至少为

节点类型最小键值数最大键值数
根节点1
内部节点
叶子节点

这种设计保证了空间利用率不低于 50%。

4. 有序链表

叶子节点通过双向链表连接,支持:

  • 时间复杂度进行范围扫描
  • 高效的顺序访问
  • 便捷的相邻节点查找

搜索操作

B+ 树的搜索过程如下:

Search(key):
    node ← root
    
    while not leaf(node):
        i ← binarySearch(node.keys, key)
        if i < node.n and key == node.keys[i]:
            i ← i + 1  // 相同键值向右
        node ← node.children[i]
    
    // 在叶子节点中线性查找
    i ← binarySearch(node.keys, key)
    if i < node.n and key == node.keys[i]:
        return node.values[i]  // 找到
    return NOT_FOUND            // 未找到

搜索示例

        [20 | 50]
       /    |    \
   [5,10] [20] [30,35] [50] [60,70] [80,90]
   
搜索 key = 35:
1. 从根节点开始,35 > 20,指向右子树
2. 在第二层,20 < 35 < 50,指向中间节点
3. 到达叶子节点 [30,35],二分查找找到 35

复杂度分析

  • 时间复杂度
  • I/O 复杂度(每次访问可能产生一次磁盘 I/O)
  • 搜索深度:约 (考虑最小占用率)

插入操作

插入算法

Insert(key, value):
    leaf ← findLeaf(key)
    
    if leaf.n < m - 1:
        insertIntoLeaf(leaf, key, value)
    else:
        splitLeaf(leaf, key, value)  // 分裂叶子

分裂操作详解

当叶子节点已满()时,需要分裂

叶子节点分裂示例 (m=4,最大3个键值):

分裂前: [10, 20, 30] ← 已满
        插入 25

分裂后:
    左叶子: [10, 20]        (保留较小的一半)
    右叶子: [30]            (较大的键值移到这里)
    
    父节点需要插入新的键值:
        原来的键值 [20, 30] → 上移 20 或 30
    
    链表更新:
    ... → [10,20] → [30] → ...

内部节点分裂

内部节点分裂时,中间的键值上移到父节点,左右两半形成新的子节点:

内部节点分裂示例:

分裂前 (m=4):
        [20 | 50 | 80]
        
分裂后:
        父节点: [50]
              /    \
    左节点: [20]    右节点: [80]
    
    上移 50 键值

插入传播

如果父节点在插入新键值后也满,则继续分裂,可能一直传播到根节点,导致树高增加


删除操作

删除算法

Delete(key):
    leaf ← findLeaf(key)
    removeFromLeaf(leaf, key)
    
    if leaf.n < minKeys:  // 最小键值数 = ⌈m/2⌉ - 1
        if leftSibling.n > minKeys:
            borrowFromLeft(leaf, leftSibling)
        else if rightSibling.n > minKeys:
            borrowFromRight(leaf, rightSibling)
        else:
            merge(leaf, sibling)  // 合并

借来(Borrow)操作

当相邻兄弟节点有足够的键值时,可以借来一个键值:

借来操作示例:

删除后左叶子键值不足:
  左兄弟: [10, 20, 30]  →  借走后: [10, 20]
  当前叶: [40]          →  借来后: [30, 40]
  
  父节点键值调整: 原来的 [20|40] → [20|30]

合并(Merge)操作

当两个相邻节点都不足以借出时,进行合并

合并操作示例:

两个叶子节点都只有 1 个键值:
  左叶子: [10]    右叶子: [50]
  
合并后:
  合并叶子: [10, 50]
  删除右叶子
  
  父节点键值调整: 删除原来的分隔键值

合并可能向上传播,导致树高减少。


批量加载(Bulk Loading)

对于已有大量有序数据需要建索引的场景,批量加载比逐条插入更高效。

算法思想

BulkLoad(sorted_keys, values):
    // 1. 计算叶子节点数量
    leafCount = ceil(n / (m - 1))
    
    // 2. 创建所有叶子节点(填充到最小占用率以上)
    for i in range(leafCount):
        create leaf with keys[(m-1)*i : (m-1)*(i+1)]
        link leaves in order
    
    // 3. 从底层向上构建内部节点
    currentLevel = leaves
    while currentLevel.size > 1:
        nextLevel = []
        for i in range(0, currentLevel.size, m):
            // 每 m 个子节点创建一个父节点
            parent = createInternalNode(
                keys: [child[0] for child in group],  // 第一个键值
                children: group
            )
            nextLevel.append(parent)
        currentLevel = nextLevel
    
    root = currentLevel[0]

效率优势

方法时间复杂度I/O 次数
逐条插入
批量加载

批量加载利用了磁盘顺序读写的特性,比随机插入快 10-100 倍。


性能优势

B+ 树 vs B 树

特性B+ 树优势原因
范围查询✅ 更高效叶子链表 遍历
查询稳定性✅ 稳定始终查到叶子
空间效率✅ 更高内部节点不存储数据
I/O 效率✅ 更好内部节点更小,可缓存更多
点查询≈ 相近复杂度相同

理论复杂度

操作时间复杂度说明
搜索 为阶数
插入含分裂
删除含合并/借来
范围查询 为结果数
顺序访问遍历所有叶子

应用场景

数据库索引

B+ 树是关系型数据库最常用的索引结构:

PostgreSQL

-- PostgreSQL 默认使用 B+ 树索引
CREATE INDEX idx_name ON table(column);
CREATE INDEX idx_composite ON table(col1, col2);
 
-- 索引类型
CREATE INDEX idx USING btree ON table(column);
  • PostgreSQL 12+:使用 B-Tree,但优化后与 B+ 树性能相近
  • 叶子节点存储 TID(Tuple ID) 指向实际数据行
  • 支持 Index Only Scan 减少回表

MySQL (InnoDB)

-- MySQL InnoDB 使用 B+ 树
CREATE INDEX idx_name ON table(column);
PRIMARY KEY -> 聚簇索引 (clustered index)
  • 聚簇索引:主键索引的叶子节点存储完整行数据
  • 二级索引:叶子节点存储主键值,再回表查找

索引层次计算

假设:
- 页大小 = 16KB
- 主键 BIGINT = 8 字节
- 每行记录指针 = 8 字节
- 每键值需要 16 字节(键 + 指针)

每个节点可存储: 16KB / 16 = 1024 个键值

三层 B+ 树容量:
- 第1层: 1 个根节点 (1024^0 = 1)
- 第2层: 1024 个内部节点 (1024^1 = 1024)
- 第3层: 1024 × 1024 = 1,048,576 个叶子节点

每个叶子 16KB,可存约 1000 条记录

总容量: ~10 亿条记录

文件系统

文件系统B+ 树应用
NTFS使用 B+ 树管理文件目录(MFT)
ext4使用 HTree(extent B+ 树变体)
HFS+使用 B+ 树存储文件元数据
APFS使用 B+ 树组织文件系统结构

NTFS MFT 示例

Master File Table (MFT) 结构:
├── $MFT (B+ 树根)
├── $MFTMirr
├── $LogFile
└── ...

文件查找: B+ 树搜索文件名 → 获取文件记录 → 读取文件数据

键值存储

  • RocksDBLevelDB:使用 LSM 树(基于 B+ 树变体)
  • Cassandra:使用 B+ 树(本地索引)
  • Berkeley DB:B+ 树作为主要访问方法

C++ 实现

#include <bits/stdc++.h>
using namespace std;
 
const int M = 4;  // B+ 树阶数
 
struct BPlusNode {
    bool isLeaf;              // 是否为叶子节点
    int n;                     // 当前键值数量
    vector<int> keys;          // 键值
    vector<BPlusNode*> children; // 子节点指针(内部节点)
    vector<int> values;        // 数据值(叶子节点)
    BPlusNode* next;           // 叶子节点的下一个叶子
    BPlusNode* prev;           // 叶子节点的上一个叶子
    
    BPlusNode(bool leaf = false) : isLeaf(leaf), n(0), next(nullptr), prev(nullptr) {}
};
 
class BPlusTree {
private:
    BPlusNode* root;
    int minKeys;  // 最小键值数 = ceil(M/2) - 1
 
    // 分裂叶子节点
    void splitLeaf(BPlusNode* leaf) {
        BPlusNode* newLeaf = new BPlusNode(true);
        int mid = (M - 1) / 2;  // 中间位置
        
        // 分裂键值
        newLeaf->n = leaf->n - mid - 1;
        leaf->n = mid + 1;
        
        // 移动键值和数据到新节点
        for (int i = mid + 1; i < leaf->n + newLeaf->n + 1; i++) {
            newLeaf->keys.push_back(leaf->keys[i]);
            newLeaf->values.push_back(leaf->values[i - (mid + 1)]);
        }
        
        // 更新链表指针
        newLeaf->next = leaf->next;
        if (leaf->next) leaf->next->prev = newLeaf;
        leaf->next = newLeaf;
        newLeaf->prev = leaf;
        
        // 插入新键值到父节点
        insertIntoParent(leaf, newLeaf->keys[0], newLeaf);
    }
    
    // 分裂内部节点
    void splitInternal(BPlusNode* internal, int idx) {
        BPlusNode* child = internal->children[idx];
        BPlusNode* newInternal = new BPlusNode(false);
        int mid = (M - 1) / 2;
        
        // 分裂
        newInternal->n = child->n - mid - 1;
        child->n = mid;
        
        // 移动键值和子节点
        for (int i = mid + 1; i < child->n + mid + 1; i++) {
            newInternal->keys.push_back(child->keys[i]);
            newInternal->children.push_back(child->children[i]);
        }
        newInternal->children.push_back(child->children[child->n + mid + 1]);
        
        // 移动中间的键值到新节点(作为第一个键值)
        int upKey = child->keys[mid];
        
        // 更新父节点
        internal->keys.insert(internal->keys.begin() + idx, upKey);
        internal->children.insert(internal->children.begin() + idx + 1, newInternal);
        internal->n++;
    }
    
    // 插入到父节点
    void insertIntoParent(BPlusNode* left, int key, BPlusNode* right) {
        BPlusNode* parent = nullptr;
        // 获取父节点(简化处理,实际需要维护父子关系)
        
        if (left == root) {
            // 创建新根
            BPlusNode* newRoot = new BPlusNode(false);
            newRoot->keys.push_back(key);
            newRoot->children.push_back(left);
            newRoot->children.push_back(right);
            newRoot->n = 1;
            root = newRoot;
            return;
        }
        
        // 找到父节点并插入(简化实现)
        // 实际需要遍历或维护 parent 指针
    }
 
    // 查找键应所在的叶子节点
    BPlusNode* findLeaf(int key) {
        BPlusNode* node = root;
        while (!node->isLeaf) {
            int i = 0;
            while (i < node->n && key > node->keys[i]) i++;
            node = node->children[i];
        }
        return node;
    }
 
public:
    BPlusTree() {
        root = new BPlusNode(true);
        minKeys = (M + 1) / 2 - 1;
    }
 
    // 插入键值对
    void insert(int key, int value) {
        BPlusNode* leaf = findLeaf(key);
        
        // 查找插入位置
        int i = 0;
        while (i < leaf->n && leaf->keys[i] < key) i++;
        
        // 插入键值和数据
        leaf->keys.insert(leaf->keys.begin() + i, key);
        leaf->values.insert(leaf->values.begin() + i, value);
        leaf->n++;
        
        // 检查是否需要分裂
        if (leaf->n == M) {
            splitLeaf(leaf);
        }
    }
 
    // 范围查询 [l, r]
    vector<int> rangeQuery(int l, int r) {
        vector<int> result;
        BPlusNode* leaf = findLeaf(l);
        
        while (leaf) {
            for (int i = 0; i < leaf->n; i++) {
                if (leaf->keys[i] >= l && leaf->keys[i] <= r)
                    result.push_back(leaf->values[i]);
                else if (leaf->keys[i] > r)
                    return result;
            }
            leaf = leaf->next;
        }
        return result;
    }
 
    // 搜索
    int* search(int key) {
        BPlusNode* leaf = findLeaf(key);
        for (int i = 0; i < leaf->n; i++) {
            if (leaf->keys[i] == key)
                return &leaf->values[i];
        }
        return nullptr;
    }
};

总结

B+ 树通过以下设计实现了大规模数据存储的高效支持:

  1. 高扇出:减少树高,降低 I/O 开销
  2. 叶子链表:支持高效范围查询
  3. 50% 最小占用率:保证空间利用率
  4. 完全平衡:查询时间可预测
  5. 批量加载:快速构建大规模索引

这些特性使 B+ 树成为数据库索引文件系统的事实标准。


参考资料

Footnotes

  1. Bayer, R., & McCreight, E. (1972). Organization and maintenance of large ordered indices. Acta Informatica, 1(3), 173-189.