# 实验 03：B+ 树索引 —— 从一次分裂看懂数据库为什么"矮而胖"

> 手工实现一棵 B+ 树，亲眼看到节点分裂、叶链形成，然后测出它比全表扫描快多少，最后用它做一次等深分区。

## 一、实验目标

完成本实验后，你应当能够：

1. 说清楚 B+ 树的三个组成部分：根（root）、内部节点（inner node）、叶子节点（leaf node），以及它们的分工。
2. 手工模拟一次插入导致节点分裂（split）的完整过程，并能画出分裂前后的结构。
3. 解释叶链（leaf chain，叶子之间的横向指针）存在的理由，以及它如何让范围查询只需一次树下降。
4. 用代码统计树高、节点数、叶节点数，解释为什么数据量从 1 万涨到 20 万，树高只从 3 变成 4。
5. 对比点查（point query）在三种方案下的代价：B+ 树、有序数组二分查找、全表顺序扫描。
6. 利用 B+ 树内部结构的分界点做等深分区（equi-depth partitioning），理解它和并行查询的关系。

## 二、预备知识

### 2.1 为什么数据库不用二叉搜索树

数据库的数据住在磁盘上，读数据的最小单位是页（page，通常 4 KiB 或 8 KiB）。一次页 I/O 大约能买来上百个键值，但寻道开销不会因为只用一个键而变小。

二叉搜索树一个节点放一个键，查找一个键要下降 log₂N 层，也就是 log₂N 次页访问。N = 20 万时，log₂N ≈ 17.6，要读 18 次页。

把每个节点塞满一个页能放得下的所有键，就是多路平衡树（multi-way balanced tree）。这种树"矮而胖"，一次下降能排除掉成百上千个键。B+ 树（B-plus tree）是其中的主流结构，原始方案出自 Bayer 与 McCreight 1972 年的工作。PostgreSQL 官方文档把它的 B-tree 索引描述为一种可以处理可排序数据的多路平衡树结构，叶页里的条目指向表中的行。

### 2.2 术语表

| 术语 | 英文 | 含义 |
| --- | --- | --- |
| 扇出 | fan-out | 一个节点最多能有多少个孩子。等于分隔键个数 + 1 |
| 容量 | capacity | 一个节点最多存放的键个数。本实验用小容量便于观察 |
| 占用率 / 填充率 | fill factor | 实际存放的键数 ÷ 容量 |
| 分隔键 | separator / pivot key | 内部节点中用于划分子树范围的键，它本身不携带数据行 |
| 分裂 | split | 节点装满后再插入，把节点一分为二、并把分隔键上提到父节点 |
| 叶链 | leaf chain / sibling pointer | 叶子之间沿 `next` 指针串成的横向链（本实现是单向链表；真实数据库常用双向） |
| 范围扫描 | range scan | 找出区间 [lo, hi] 内所有键：下降到起始叶，再沿叶链向右走 |
| 等深分区 | equi-depth partitioning | 把数据切成若干份，使每份记录数尽量相等 |

### 2.3 B+ 树长什么样

```
                     ┌───────────────────────┐
                     │  root（内部节点）      │
                     │  分隔键: [ 32 ]        │   ← 只放分隔键 + 孩子指针
                     └───────┬───────┬───────┘
                 ┌───────────┘       └───────────┐
                 ▼                               ▼
      ┌────────────────┐                ┌────────────────┐
      │  inner [13,21] │                │  inner [38,43] │
      └──┬────┬────┬───┘                └──┬────┬────┬───┘
     ┌───┘    │    └───┐               ┌───┘    │    └───┐
     ▼        ▼        ▼               ▼        ▼        ▼
 ┌──────┐ ┌──────┐ ┌──────┐        ┌──────┐ ┌──────┐ ┌──────┐
 │ leaf │→│ leaf │→│ leaf │→       │ leaf │→│ leaf │→│ leaf │→  ∅
 │3 8 9 │ │14 18 │ │21 22 │        │31 34 │ │39 41 │ │44 46 │
 │ 11   │ │20    │ │25 31 │        │36    │ │      │ │49    │
 └──────┘ └──────┘ └──────┘        └──────┘ └──────┘ └──────┘
     └──────────── 横向指针（next）贯穿整个叶层 ────────────┘
```

图 1：典型 B+ 树。所有真正的数据（键值到行号的映射）都在叶层，内部节点只是路标。

### 2.4 一次分裂的全过程

设叶容量 = 4。当前叶子装了 `[3, 8, 9, 11]`，再插 `7`：

```
步骤 1：先按序插入，允许暂时溢出     步骤 2：从中点切开（mid = 5//2 = 2）
   ┌───────────────┐                   ┌──────────┐      ┌──────────┐
   │ 3  7  8  9  11 │  ← 5 个 > 4       │ 3   7    │      │ 8  9  11 │
   └───────────────┘                   └──────────┘      └──────────┘
                                            左半              右半
步骤 3：把中间的键 8 作为分隔键上提给父节点
                    ┌──────────────┐
                    │   parent     │
                    │  keys [ 8 ]  │
                    └──┬────────┬──┘
                       ▼        ▼
                   ┌───────┐ ┌────────┐
                   │ 3  7  │ │ 8 9 11 │   ← 新右节点的首个键 = 上提的分隔键
                   └───────┘ └────────┘
步骤 4：重接叶链，让两个新叶子和左右邻居连起来（这一步不能漏，否则 range scan 会断）

        left.prev ──►  left  ──►  right  ──► right.next
```

图 2：叶分裂四步。注意本实现中"上提的键在右半保留"（B+ 树的常见做法之一），所以右半的第一个键恰好等于父节点的分隔键。

内部节点的分裂同理，差别在于：被上提的键本身会从两个子树中移除（`mid` 位置那个键只留着当分隔键，不再出现在下一层）。代码里 `Inner.split` 用的是 `self.keys[mid+1:]`，就是这个原因。

### 2.5 树高公式

设：

- `N` = 索引中的键总数
- `L` = 一个叶节点最多能放的键数（叶容量）
- `F` = 内部节点的平均扇出
- `P` = 叶节点个数

则：

```
P  ≈ N / L                    （按满载估算）
h  ≈ 1 + ⌈log_F P⌉            （h 为层数：含内部节点层和叶层）
```

常见的写法 `h ≈ log_F(N)` 忽略了两件事：叶容量 `L` 的存在（不是每个键一层），以及实际装填率往往不满 100%。本实验后面会用实测数据验证这一点。

真实数据库里还有装填因子的概念：PostgreSQL 的 B-tree 索引默认 `fillfactor = 90`（可调范围 10–100），意思是建索引时每个页只填到 90%，剩余空间留给后续插入，从而减少分裂；代价是页数会多约 11%。本实验的实现采用"溢出后再分裂"的做法，装填率会落在 50% 附近，后面实测会看到这个数字。

## 三、实验原理

### 3.1 为什么是 B+ 而不是 B

B 树的每一个节点都能装数据：查到内部节点上的键就命中，不必走到叶层。听起来更快，但在数据库里有几个坏处：

| 对比项 | B 树 | B+ 树 |
| --- | --- | --- |
| 内部节点是否存数据 | 存 | 只存分隔键和指针 |
| 单个内部节点的扇出 | 小（键 + 数据挤占空间） | 大（只放键和指针，更省空间） |
| 树高 | 相对高 | 相对矮 |
| 范围查询 | 需要中序遍历来回上下走，随机 I/O | 一次下降到起点，之后沿叶链顺序扫 |
| 全索引扫描 | 中序遍历 | 直接从头走叶链 |

数据库大量场景是范围扫描 (`BETWEEN`、排序、`GROUP BY` 走索引)，因此 B+ 树的叶链优势决定了它是主流。

### 3.2 点查代价

一次点查的代价由两部分组成：

```
cost(point query) ≈ h 次节点访问（页 I/O）
                  + 每个节点内部的二分查找 log₂(slot) 次比较
```

`h` 是几百几千字节级别的内存比较所换来的页访问次数，因此树的高度直接决定了慢的那部分开销有多大。这也是为什么"树高一律很小"这件事如此关键。

### 3.3 用 B+ 树做等深分区

等深分区的要求是：把值域切成若干段，每段装到的记录数接近相等。做法并不复杂：

1. 用一小部分样本数据建一棵"矮而胖"的树（内部节点少、叶子大）；
2. 把整批数据流过这棵树，落到叶子上，但叶子不存数据，只记数（这就是下面代码里的 `CountingLeaf`）；
3. 每个叶子的计数值就是一段桶的记录数，叶子之间的边界自然就是分区边界。

由于样本是从同一分布里采的，用 batch 数据计数后，各桶的记录数会比较均匀。这个性质对并行查询、直方图统计、数据倾斜检测都很有用。

![B+ 树结构](../figures/fig_btree_structure.png)

图 3：依次插入 1…20 后的真实结果（叶容量 4，内节点容量 3）。底部绿色曲线是叶链。

![树高与节点数随数据量增长](../figures/fig_btree_growth.png)

图 4：N 从 100 增长到 200,000，树高只从 2 变到 4；节点数近似线性增长，且绝大多数是叶节点。

![点查代价对比](../figures/fig_btree_lookup.png)

图 5：同一次点查，三种方案的检查单元数分布（N = 200,000，随机 2000 次查询）。

## 四、实验环境与准备

### 4.1 软件要求

- Python 3.10 及以上（本指导书的实测在 CPython 3.13 与 3.12 + Windows 上完成）
- 必装：`numpy`、`matplotlib`
- 选装：`graphviz`（Python 包 + 系统 Graphviz 可执行文件），用于把树渲染成图；不装也能完成全部实验，`show()` 那一步会跳过

```bash
python -c "import numpy, matplotlib; print('基础环境 OK')"
pip install numpy matplotlib          # 缺哪个装哪个
pip install graphviz                  # 可选：渲染树形图
```

### 4.2 目录约定

本指导书位于：

```
…/教学产出/03-实验指导书/实验03-B+树.md
```

图件位于：

```
…/教学产出/figures/
```

文中用相对路径 `../figures/fig_btree_structure.png` 引用图件。

### 4.3 两种学法的分工

本实验的代码分两类：

- **A 类（必做，自包含）**：只用标准库 `bisect`，任何装了 Python 的机器都能跑。用来观察结构、数节点、量代价。
- **B 类（选做，调用 ExplainDB 教学库）**：直接调用课程自带的 `system.indexes.btree.BPlusTree`，验证同一套行为的工业写法，并用 graphviz 渲染。

B 类代码需要在仓库根目录下运行，并先把仓库根加入 `sys.path`：

```python
import sys, pathlib

for _p in (pathlib.Path.cwd(), *pathlib.Path.cwd().parents):
    if (_p / "system").is_dir():
        if str(_p) not in sys.path:
            sys.path.insert(0, str(_p))
        break
```

## 五、实验步骤

### 步骤 1：实现一个可观察的 B+ 树（A 类）

把下面这段代码保存为 `btree_lab.py`。它比真实的数据库代码简化很多（不支持删除、不支持重复键合并），但完整保留了 B+ 树的核心行为：溢出后分裂、分隔键上提、叶链维护、范围扫描。

```python
import bisect


class BPNode:
    """一个节点。leaf=True 时是叶子，否则是内部节点。"""

    __slots__ = ("leaf", "keys", "vals", "children", "next")

    def __init__(self, leaf=True):
        self.leaf = leaf
        self.keys = []        # 叶子：键；内部：分隔键
        self.vals = []        # 叶子：值（这里放行号）
        self.children = []    # 内部：子节点
        self.next = None      # 叶子：叶链的下一个


class BPlusTree:
    """教学用 B+ 树。leaf_cap = 叶子最大键数，inner_cap = 内部节点最大分隔键数。"""

    def __init__(self, leaf_cap=4, inner_cap=3):
        self.leaf_cap = leaf_cap
        self.inner_cap = inner_cap
        self.root = BPNode(leaf=True)
        self.split_count = 0

    # ---------- 插入 ----------
    def insert(self, key, val):
        res = self._insert(self.root, key, val)
        if res is not None:                      # 根也分裂了 → 树长高一层
            pivot, right = res
            new_root = BPNode(leaf=False)
            new_root.keys = [pivot]
            new_root.children = [self.root, right]
            self.root = new_root
            # 注意：这里不增加 split_count。根被替换是"多了一层"，
            # 真正发生分裂的是下面那个叶子，已在 _split_leaf 里计过。

    def _insert(self, node, key, val):
        if node.leaf:
            i = bisect.bisect_left(node.keys, key)
            node.keys.insert(i, key)
            node.vals.insert(i, val)
            if len(node.keys) > self.leaf_cap:
                return self._split_leaf(node)
            return None
        i = bisect.bisect_right(node.keys, key)
        res = self._insert(node.children[i], key, val)
        if res is None:
            return None
        pivot, right = res
        node.keys.insert(i, pivot)
        node.children.insert(i + 1, right)
        if len(node.keys) > self.inner_cap:
            return self._split_inner(node)
        return None

    def _split_leaf(self, node):
        mid = len(node.keys) // 2
        right = BPNode(leaf=True)
        right.keys, right.vals = node.keys[mid:], node.vals[mid:]
        node.keys, node.vals = node.keys[:mid], node.vals[:mid]
        right.next = node.next          # 重接叶链
        node.next = right
        self.split_count += 1
        return right.keys[0], right     # 上提新右节点的最小键作为分隔键

    def _split_inner(self, node):
        mid = len(node.keys) // 2
        up = node.keys[mid]             # 这个键上提，本身从下层消失
        right = BPNode(leaf=False)
        right.keys = node.keys[mid + 1:]
        right.children = node.children[mid + 1:]
        node.keys = node.keys[:mid]
        node.children = node.children[:mid + 1]
        self.split_count += 1
        return up, right

    # ---------- 查询 ----------
    def search(self, key):
        """点查：自顶向下降到叶，再在叶内二分。"""
        node = self.root
        while not node.leaf:
            i = bisect.bisect_right(node.keys, key)
            node = node.children[i]
        i = bisect.bisect_left(node.keys, key)
        out = []
        while i < len(node.keys) and node.keys[i] == key:
            out.append(node.vals[i])
            i += 1
        return out

    def range_search(self, lo, hi):
        """范围查：下降到起点叶，之后只沿叶链向右，不再回到树上。"""
        node = self.root
        while not node.leaf:
            i = bisect.bisect_right(node.keys, lo)
            node = node.children[i]
        out = []
        while node is not None:
            for k, v in zip(node.keys, node.vals):
                if k > hi:
                    return out
                if k >= lo:
                    out.append(v)
            node = node.next
        return out

    # ---------- 统计与检查 ----------
    def height(self):
        h, node = 1, self.root
        while not node.leaf:
            h += 1
            node = node.children[0]
        return h

    def node_count(self):
        cnt, stack = 0, [self.root]
        while stack:
            n = stack.pop()
            cnt += 1
            stack.extend(n.children)
        return cnt

    def leaf_count(self):
        node, cnt = self.root, 0
        while not node.leaf:
            node = node.children[0]
        while node is not None:
            cnt += 1
            node = node.next
        return cnt

    def check(self):
        """一致性检查：沿叶链走一遍，键必须全局有序，总数必须等于插入数。"""
        node = self.root
        while not node.leaf:
            node = node.children[0]
        prev, total = None, 0
        while node is not None:
            assert node.keys == sorted(node.keys), "叶内键未排序"
            if prev is not None:
                assert prev <= node.keys[0], "叶链顺序错误"
            prev = node.keys[-1]
            total += len(node.keys)
            node = node.next
        return total

    def levels(self):
        """层序返回每一层的节点列表，levels[0] 是根，levels[-1] 是叶层。"""
        levels, cur = [], [self.root]
        while cur:
            levels.append(cur)
            nxt = []
            for n in cur:
                nxt.extend(n.children)
            cur = nxt
        return levels

    def dump(self):
        """把树按层打印出来，同时用缩进标出叶子键与内部节点分隔键。"""
        for depth, level in enumerate(self.levels()):
            kind = "leaf  " if level[0].leaf else "inner "
            cells = ["[" + " ".join(map(str, n.keys)) + "]" for n in level]
            print(f"  level {depth} ({kind}, {len(level)} 个): " + " ".join(cells))
```

### 步骤 2：观察分裂（核心步骤）

用小容量（叶容量 4、内部容量 3）依次插入 1…20，每插若干个就打印一次结构。这是本实验最关键的一步，请对着输出图手动验证每一步。

```python
from btree_lab import BPlusTree

t = BPlusTree(leaf_cap=4, inner_cap=3)
watch = {1, 2, 3, 4, 5, 6, 8, 10, 12, 16, 20}
for k in range(1, 21):
    t.insert(k, f"row{k}")
    if k in watch:
        print(f"--- 插入 {k} 之后：树高={t.height()}，"
              f"节点={t.node_count()}，叶={t.leaf_count()}，累计分裂={t.split_count}")
        t.dump()
        print(f"    叶链全局有序检查：check()={t.check()}")
```

运行后你会看到的关键转折点：

- 插到第 4 个时，树还是孤零零一个叶节点；
- 插到第 5 个时，根叶子装不下 5 个键，发生第一次分裂，树从 1 层变成 2 层；
- 再往后，每当某个内部节点的分隔键数超过 3，它自己也会分裂，树继续长高。

把这一轮跑完，记录最后一行数据。参考实测（升序插入 1…20，叶容量 4，内节点容量 3）：

| 指标 | 实测值 |
| --- | --- |
| 树高 | 3 |
| 节点总数 | 13 |
| 叶节点数 | 9 |
| 内部节点数 | 4 |

### 步骤 3：插入顺序会影响什么

顺序插入会让节点分裂后只装一半，装填率偏低。换成降序或随机顺序再试一次：

```python
import random
from btree_lab import BPlusTree

keys = list(range(1, 21))

for name, order in [
    ("升序  ", keys),
    ("降序  ", list(reversed(keys))),
    ("随机  ", random.Random(1).sample(keys, len(keys))),
]:
    t = BPlusTree(leaf_cap=4, inner_cap=3)
    for k in order:
        t.insert(k, f"row{k}")
    print(f"{name} 树高={t.height()} 节点={t.node_count():2d} 叶={t.leaf_count():2d} "
          f"分裂={t.split_count:2d} 装填率={20 / t.leaf_count() / 4 * 100:5.1f}%")
```

三种顺序都能通过 `check()`，说明结构依然正确。差别体现在节点数和装填率上——这正是真实数据库关心"插入顺序 / 建索引顺序"的原因。把结果填到第六节的表中。

### 步骤 4：树高几乎不随数据量增长

换成接近真实页容量的配置：叶容量 64、内部容量 63，跑 5 种数据量。

```python
import time
from btree_lab import BPlusTree

for n in (100, 2_000, 10_000, 50_000, 200_000):
    t = BPlusTree(leaf_cap=64, inner_cap=63)
    t0 = time.perf_counter()
    for k in range(n):
        t.insert(k, k)
    el = (time.perf_counter() - t0) * 1000
    leaves, nodes = t.leaf_count(), t.node_count()
    print(f"N={n:>7d}  树高={t.height()}  节点={nodes:>5d}  叶={leaves:>5d}  "
          f"内部={nodes - leaves:>4d}  叶装填率={n / leaves / 64 * 100:5.1f}%  "
          f"构建={el:7.1f} ms")
```

参考实测（同一份代码，CPython + Windows）：

| N | 树高 | 节点总数 | 叶节点数 | 叶平均装填率 |
| --- | --- | --- | --- | --- |
| 100 | 2 | 4 | 3 | 52.1% |
| 2,000 | 2 | 63 | 62 | 50.4% |
| 10,000 | 3 | 322 | 312 | 50.1% |
| 50,000 | 3 | 1,610 | 1,562 | 50.0% |
| 200,000 | 4 | 6,444 | 6,249 | 50.0% |

注意最后一列：数据量越大，装填率越稳定地落在 50.0% 附近。这是因为"溢出后从正中切开"这种策略，长期看每个叶子平均装一半。真实数据库不走这条路——PostgreSQL 的做法是建索引时按 fillfactor 90% 装填，更新时优先复用页内空闲空间，把分裂尽量往后推。

### 步骤 5：点查代价的三种口径

现在把 B+ 树点查和另外两种方案放在一起量化。这一步要明确口径，否则数字没法比较：

- **B+ 树**：访问的节点数（含叶）+ 叶内二分查找的上界 ⌈log₂(len(keys)+1)⌉
- **有序数组二分**：⌈log₂ N⌉（最坏情况上界），以及随机点查的实际平均步数
- **全表顺序扫描**：从头找到为止，平均 N/2 行

```python
import bisect
import math
import numpy as np
from btree_lab import BPlusTree

n = 200_000
t = BPlusTree(leaf_cap=64, inner_cap=63)
for k in range(n):
    t.insert(k, k)

rng = np.random.default_rng(7)
probes = rng.integers(0, n, 2000)


def btree_units(key):
    """自顶向下：每下降一层记 1，最后补上叶内二分上界。"""
    node, cnt = t.root, 0
    while not node.leaf:
        cnt += 1
        i = bisect.bisect_right(node.keys, key)
        node = node.children[i]
    cnt += 1                                             # 叶节点本身
    cnt += math.ceil(math.log2(len(node.keys) + 1))      # 叶内二分
    return cnt


def real_binary(key):
    """真的做一次二分，数比较次数。"""
    lo, hi, c = 0, n - 1, 0
    while lo <= hi:
        mid = (lo + hi) // 2
        c += 1
        if mid == key:
            return c
        if mid < key:
            lo = mid + 1
        else:
            hi = mid - 1
    return c


b_units = [btree_units(int(k)) for k in probes]
bin_units = [real_binary(int(k)) for k in probes]
lin_units = [int(k) + 1 for k in probes]

print(f"B+ 树点查        平均 {np.mean(b_units):>10.1f} 个单元")
print(f"有序数组二分     平均 {np.mean(bin_units):>10.1f} 次比较"
      f"（最坏上界 ⌈log₂N⌉ = {math.ceil(math.log2(n))}）")
print(f"全表顺序扫描     平均 {np.mean(lin_units):>10.0f} 行（理论期望 N/2 = {n // 2}）")
print(f"相对全表扫描的加速比：{np.mean(lin_units) / np.mean(b_units):.0f}×")
```

参考实测（N = 200,000，随机 2000 次查询）：

| 方案 | 平均检查单元数 | 相对全表扫描 |
| --- | --- | --- |
| B+ 树点查 | 10.0 | 约 10,000× |
| 有序数组二分 | 约 17（实测 16.6，上界 18） | 约 6,000× |
| 全表顺序扫描 | 100,000 | 1× |

二分查找的比较次数比 B+ 树少不了多少（对数 vs 对数），但真正的差别在别处：数组二分要求数据连续且有序、插入一行是 O(N) 的移动成本；而 B+ 树点查时的 10 个单元里包含的是"页访问"，这是可以在缓冲池里摊销的。这里量出来的"10000×"、"6000×"说的是比较操作的规模差异，真实 I/O 差距还要结合缓冲池命中率来看。

### 步骤 6：范围扫描只下降一次

```python
import time
from btree_lab import BPlusTree

t = BPlusTree(leaf_cap=64, inner_cap=63)
for k in range(1, 20_001):
    t.insert(k, f"row{k}")

t0 = time.perf_counter()
hits = t.range_search(5_000, 5_999)          # 连续 1000 个键
t1 = time.perf_counter()
print(f"范围查[5000, 5999] 命中 {len(hits)} 条，耗时 {(t1 - t0) * 1000:.3f} ms")
print("前 5 条:", hits[:5])

t0 = time.perf_counter()
single = [t.search(k) for k in range(5_000, 6_000)]   # 退化成 1000 次点查
t1 = time.perf_counter()
print(f"同样 1000 个键做 1000 次点查，耗时 {(t1 - t0) * 1000:.3f} ms")
```

范围扫描只做一次树下降，之后是纯顺序遍历；退化成一串点查则每次都要从根重新走一遍。两者的差距体现的就是叶链的价值。若你还想看叶链是否完好，调用 `t.check()` 即可——它会沿链走完整个索引并统计总数。

### 步骤 7（选做）：调用课程自带的 B+ 树实现

ExplainDB 教学库里有一份完整的实现，语义与步骤 1 的代码一致，多了 `get_all_in_range`、graphviz 渲染和一致性断言。

```python
import sys, pathlib

for _p in (pathlib.Path.cwd(), *pathlib.Path.cwd().parents):
    if (_p / "system").is_dir():
        if str(_p) not in sys.path:
            sys.path.insert(0, str(_p))
        break

from system.indexes.btree import BPlusTree as DBMSBPlusTree

tree = DBMSBPlusTree(inner_capacity=3, leaf_capacity=4)
for k in range(1, 21):
    tree.put(k, f"row{k}")

print(tree)                                    # 打印容量配置
print("size =", tree.size())                   # 叶层键总数
print("get(7) =", list(tree.get(7)))           # 点查
print("range [5,12] =", list(tree.get_all_in_range(5, 12)))   # 范围查
tree.consistency_check()                       # 断言：键有序、叶链指针成对
print("一致性检查通过")

# 需要安装 graphviz 包与系统 Graphviz 可执行文件
try:
    tree.show()
except Exception as e:
    print("图形渲染跳过：", e)
```

### 步骤 8（选做）：等深分区

思路在 3.3 节说过。ExplainDB 里提供了 `CountingLeaf`来代替普通叶子：它不存数据，只统计 put 被调用的次数。

```python
import sys, pathlib
for _p in (pathlib.Path.cwd(), *pathlib.Path.cwd().parents):
    if (_p / "system").is_dir():
        sys.path.insert(0, str(_p))
        break

import numpy as np
from system.indexes.btree import BPlusTree as DBMSBPlusTree

data = np.fromfile("notebooks/latitude_longitude.bin", dtype=np.float32).reshape(-1, 2)[:, 0]
data = np.array(sorted(set(data.tolist())))       # 本实现暂不支持重复键
rng = np.random.default_rng(0)
sample = rng.choice(data, size=10_000, replace=False)

tree = DBMSBPlusTree(inner_capacity=5, leaf_capacity=2000)
for v in sample:
    tree.put(float(v), float(v))

tree.convert_to_counting_leaf_tree()              # 叶层换成只计数的叶子
for v in data:
    tree.put(float(v), float(v))                  # 全量数据灌进去，叶子只在计数

tree.show()                                       # 每个叶子里显示落进来的记录数
```

每个叶子就是一个桶。把它们的计数读出来画直方图，你会得到一份近似等深的分区；相邻的边界键值就是分区的切分点。

## 六、观测记录表

把你自己机器上跑出来的数字填进去。"推定值"列是可以用公式从你填的数算出来的。

**表 6-1：小容量下的结构（叶容量 4，内节点容量 3）**

| 插入序列 | 键数 N | 树高 h | 节点总数 | 叶节点数 | 分裂次数 | 平均叶装填率 = N ÷ 叶数 ÷ 4 |
| --- | --- | --- | --- | --- | --- | --- |
| 升序 1…20 | 20 | | | | | |
| 降序 20…1 | 20 | | | | | |
| 随机排列 | 20 | | | | | |
| 升序 1…40 | 40 | | | | | |

**表 6-2：大容量下随数据量增长（叶容量 64，内节点容量 63）**

| N | 树高 h | 节点总数 | 叶节点数 | 内部节点数 = 总数 − 叶数 | 平均叶装填率 | 构建耗时 (ms) |
| --- | --- | --- | --- | --- | --- | --- |
| 100 | | | | | | |
| 2,000 | | | | | | |
| 10,000 | | | | | | |
| 50,000 | | | | | | |
| 200,000 | | | | | | |

**表 6-3：点查代价（建议 N = 200,000，随机 2000 次）**

| 方案 | 平均检查单元数（本机实测） | 相对全表扫描加速比 |
| --- | --- | --- |
| B+ 树点查 | | |
| 有序数组二分（实际平均） | | |
| 有序数组二分（⌈log₂N⌉ 上界） | | |
| 全表顺序扫描 | | 1× |

**参考实测（供核对，升序插入，CPython + Windows）**

| 表 | 条目 | 参考值 |
| --- | --- | --- |
| 6-1 | 升序 1…20 | 树高 3 / 节点 13 / 叶 9 / 分裂 10 次 |
| 6-1 | 降序 20…1 | 树高 3 / 节点 11 / 叶 7 / 分裂 8 次 |
| 6-1 | 随机排列（seed=1） | 树高 3 / 节点 10 / 叶 7 / 分裂 7 次 |
| 6-2 | N = 10,000 | 树高 3 / 节点 322 / 叶 312 / 内部 10 / 装填率 50.1% |
| 6-2 | N = 200,000 | 树高 4 / 节点 6,444 / 叶 6,249 / 内部 195 / 装填率 50.0% |
| 6-3 | B+ 树点查 | 10.0 |
| 6-3 | 全表顺序扫描 | 100,000 |

## 七、思考题

1. 叶容量 4、内部容量 3，升序插入 1…20 之后树高是多少？为什么第 5 个键是第一次分裂的触发点，而不是第 4 个之后的某一次？
2. B+ 树的分裂是"溢出后从正中切开"。假如换成"发现快满了就提前分裂"（类似 PostgreSQL 的 fillfactor 思路），节点数、树高、后续插入的分裂次数会怎么变？
3. 表 6-2 里 N 从 10,000 涨到 50,000（涨了 5 倍），树高没有变化；从 50,000 涨到 200,000（涨了 4 倍），树高加了 1。用第二节的公式解释这种"跳变"。
4. 为什么 B+ 树的范围查询不会因为叶子分布在树的不同分支而反复回到根节点？如果删除了叶链指针，范围查询要付出什么代价？
5. 内部节点为什么要"弃卒保帅"地不存数据行？如果非要在内部节点也存数据（回到 B 树），扇出会怎么变，树高会怎么变？
6. 参考实测里 N = 200,000 时点查平均检查 10.0 个单元，其中树高为 4。这 10.0 是怎么拆出来的？请用代码里的口径做一次拆分。
7. 等深分区为什么可以用"抽样建树 + 全量计数"两步完成，而不是排完整序再切成 k 段？它省掉了什么，代价又是什么？
8. 一棵叶容量为 `L`、内部容量为 `F−1` 的 B+ 树，若要求树高不超过 3，最多能索引多少个键？（分满载和实测 50% 装填两种情况分别给出表达式。）

## 八、思考题答案与解析

**1. 树高与首次分裂**

树高 = 3（层 0 根、层 1 内部、层 2 叶）。

第 5 个键触发分裂的原因很直接：叶容量是 4，插到第 4 个键时 `len(keys) == 4`，未超过容量，不分裂；插第 5 个键后 `len(keys) == 5 > 4`，判定溢出，`_split_leaf` 才被调用。代码里的判断条件是 `if len(node.keys) > self.leaf_cap`，所以"装满"本身不分裂，"溢出"才分裂。

**2. 提前分裂的后果**

把节点数、树高、分裂次数三件事分开看：

| 指标 | 溢出后分裂（本实验） | 提前分裂（预留空间） |
| --- | --- | --- |
| 短期节点数 | 少 | 多 |
| 树高 | 相同数量级，可能矮一层 | 相同数量级 |
| 后续插入触发分裂的频率 | 高（一次都塞不进就裂） | 低（有空位可直接放） |
| 长期平均装填率 | 约 50%（实测） | 接近设定值（如 90%） |

PostgreSQL 文档里给出的做法正是后者：B-tree 索引默认 `fillfactor = 90`，插入新键时可以复用页内预留的 10%，从而减少分裂引发的整体重组；代价是叶子页数比满载多约 11%（1/0.9 ≈ 1.111）。所以这是一个"一次性多占空间"换"后续少做结构变动"的取舍。

**3. 树高的跳变**

用 2.5 节的公式：

```
h ≈ 1 + ⌈log_F P⌉ ， P ≈ N / (L · 装填率)
```

代入 L = 64、装填率 ≈ 50%，得每叶平均装 32 个键：

- N = 10,000 → P ≈ 312 → h = 1 + ⌈log_F 312⌉。只要 F ≥ √312 ≈ 17.7，就得到 h = 3。
- N = 50,000 → P ≈ 1,562 → 只要 F ≥ √1562 ≈ 39.5，仍是 h = 3。
- N = 200,000 → P ≈ 6,249 → 要维持 h = 3，需要 F ≥ √6249 ≈ 79.1，而本实验扇出只有 64，做不到；于是走到 h = 4（要求 F ≥ ³√6249 ≈ 18.4）。

关键在于 `h` 是关于 `P` 的对数，且带向上取整：数据在一个数量级内变化时 `⌈·⌉` 常常不变，跨过某个 F 的幂次才 +1。这就是"跳变"。用 F 的实际量级（内部容量 63，也就是最多 64 个孩子）代入，各档都吻合。

**4. 叶链的意义**

叶链把整个叶层接成一条单向链表——本实现只维护 `next`，真实数据库通常做成双向，以支持从右往左的范围扫描。范围查询的流程是：下降到起始键所在的那个叶（一次 log 级查找），之后 `node = node.next` 一路向右。这期间完全不需要回到父节点。

若删掉叶链指针：

- 想找下一个叶，只能从根重新下降一次。范围查询的代价从 `h + k/L`（一次下降 + k/L 个叶的顺序扫）变成 `k · h`（每个叶都要从根走一遍），随机 I/O 数量直线上升。
- 全索引扫描也没法线性走，退化成中序遍历。
- `convert_to_counting_leaf_tree` 之后仍能计数，是因为 put 走的是树下降路径，与叶链无关；但等深分区需要按边界顺序输出桶，那时叶链就又重要了。

**5. B 树 vs B+ 树的扇出**

设一个节点的总空间为 S 字节，一个键 + 行指针占 `k + p` 字节，一个纯分隔键 + 孩子指针占 `k + c` 字节。通常 `p > c`（行指针要定位到堆表里的具体位置，可能带元组 ID；孩子指针只是页号）。

- B 树内部节点扇出 ≈ `S / (k + p)`
- B+ 树内部节点扇出 ≈ `S / (k + c)`

因为 `k + c < k + p`，B+ 树的 F 更大，树高 `1 + ⌈log_F P⌉` 就更矮，且在做范围扫描时还能顺着叶链走顺序 I/O。

**6. 10.0 这个数字怎么来的**

按步骤 5 的口径：

```
每次点查单元数 = （下降经过的内部节点数 = h − 1） + 1（叶节点） + ⌈log₂(叶内键数 + 1)⌉
```

N = 200,000 时 h = 4，所以前两项 = 3 + 1 = 4。实测平均是 10.0，因此叶内二分那一项平均贡献 `10.0 − 4 = 6.0`，对应 `⌈log₂(len+1)⌉ = 6`，即叶内键数在 (31, 63] 这个区间。回头核对：N = 200,000，叶数 6,249，平均每叶 32.0 个键，正好落在区间里，`⌈log₂ 33⌉ = 6`。完全自洽。

可以用下面这段代码把拆分显式打印出来：

```python
import bisect, math
from btree_lab import BPlusTree

n = 200_000
t = BPlusTree(leaf_cap=64, inner_cap=63)
for k in range(n):
    t.insert(k, k)

for key in (0, 100_000, 199_999):
    node, inner_hops = t.root, 0
    while not node.leaf:
        inner_hops += 1
        node = node.children[bisect.bisect_right(node.keys, key)]
    leaf_bins = math.ceil(math.log2(len(node.keys) + 1))
    print(f"key={key:>7d}  内部节点={inner_hops}  叶={1}  叶内二分={leaf_bins}  "
          f"合计={inner_hops + 1 + leaf_bins}  该叶实有键={len(node.keys)}")
```

**7. 两步法做等深分区**

排序后再均分的做法需要先对 N 条记录做一次 O(N log N) 的外排序，才能得到 k−1 个切分点。两步法的做法是：

1. 抽 m 条样本（m ≪ N）建树，树的内部节点很小，常驻内存；
2. 全量数据只做一次 O(N log P) 的流式分发（P 是叶子个数），落到叶 visited 上计数。

省掉的是：全排序的磁盘 I/O 与归并开销。

代价有两点：一是切分点来自样本，样本偏差会导致桶深不均（数据量极度倾斜或分布未知时尤其明显）；二是要将树结构保持在内存里。若样本确实是同分布的随机采样，这一步的误差可以用样本量控制：`m` 越大，桶边界越接近真实分位数。

**8. 树高不超过 3 的容量**

树高 3 意味着 层0 根、层1 内部、层2 叶：

```
叶最多容纳键数        = L × 装填率
根的孩子数（即层 1 节点数）≤ F（内部容量 F−1 ⇒ 扇出 F）
层 1 每个节点的孩子数  ≤ F，它们就是叶子
⇒ 叶总数 P ≤ F²
⇒ 键总数 N ≤ F² · L · 装填率
```

- 满载情况（装填率 = 1）：`N_max = F² · L`
- 实际情况（装填率 ≈ 0.5，本实验实测值）：`N_max ≈ 0.5 · F² · L`

代入本实验的参数 L = 64、F = 64：`N_max = 64² × 64 = 262,144`（满载），实测装填率下则为 `≈ 131,072`。这也解释了为什么 N = 200,000 时装填率只有 50% 反而"被迫"长到第 4 层：131,072 < 200,000 < 262,144，按满载估算还能塞下，按实际装填率就塞不下了。

## 九、扩展任务（选做）

1. **实现随机数据下的 breakdown 统计**：遍历树的每一层，统计该层的节点数、每个节点的孩子数，算出每一层的实测平均扇出，与第三节的 F 做对比。观察"内部→内部"和"内部→叶"两档扇出是否一样。
2. **给叶子加批量加载**：实现 `bulkload(sorted_items)`，让 b+ 树按序直接铺满叶子、只保留偶尔余量，然后量一遍装填率，与逐条插入对比，验证 PostgreSQL 讨论 fillfactor 的语境。
3. **测量拼接开销**：在 `_split_leaf` 里加计数器，统计不同插入顺序下"每插入一个键平均引发多少次分裂"，把结果画成图。
4. **做一次真正的等深分区直方图**：从步骤 8 的 `CountingLeaf` 里读出各桶计数，画直方图，并计算"最深桶 / 最浅桶"的比值；把扇出从 4 调到 16（改 `children_per_inner_node` 或内部容量）后再测一遍，讨论样本量该怎么取。
5. **对比 open-source 实现**：在 PostgreSQL 里建一个百万行的表，用 `EXPLAIN (ANALYZE, BUFFERS)` 观察一次索引扫描访问了多少个缓冲区块，与本实验的单元数口径对比，讨论两者的差别。

## 十、常见错误与排查

| 现象 | 原因 | 处理 |
| --- | --- | --- |
| `check()` 抛出 `叶链顺序错误` | 分裂时忘记维护 `next` 指针（或维护顺序不对），导致叶链在分裂点断开或错序 | 检查 `_split_leaf`：`right.next = node.next` 必须在 `node.next = right` 之前执行——顺序反了，`node` 原来的整段后继就丢了 |
| 树长不高，插入 200,000 个键后 `height()` 还是 2 | 叶容量或内节点容量设得过大，或循环里插入的键重复成了同一个值 | 打印 `check()` 的返回值，确认是不是等于期望的键数；键重复时本实现的处理是覆盖而不是新增 |
| `search()` 返回空列表但键明明亮存在 | 内部节点路由用的是 `bisect_right`而你写成了 `bisect_left`（或反之），在下 NL-page 有相等分隔键时走去错误的孩子 | 保持 `_insert` 的下降与 `search` 的下降用同一个判定方式；本实验两处都用 `bisect_right` 做路由 |
| 范围扫描结果比预期的少 | `range_search` 在中间退出条件写成了 `k >= hi` 就 return，而不是 `k > hi` | `hi` 是闭区间上界，条件必须是 `if k > hi: return out`，且 `if k >= lo` 才开始收集 |
| 分裂后 `node_count()` 比 `leaf_count()` 小 | 统计逻辑反了：`node_count` 应该遍历 `children`，不适用于叶子 | 用 `stack.extend(n.children)` 遍历时，叶子没有孩子，自然返回空，不会出错；若手写了自己的三层循环，多半是遍历方向搞反了 |
| `dump()` 少打印最后一层 | 层序遍历的循环条件写成 `while cur` 之外又多写了一层 | `levels()` 里每轮先 `levels.append(cur)` 再算 `nxt`，不要提前 pop 掉叶层 |
| 使用课程 `system` 包时 `ModuleNotFoundError: No module named 'system'` | 未把仓库根目录加入 `sys.path` | 按 4.3 节的代码片段先插路径，或把脚本放在仓库根目录下运行 |
| `tree.show()` 无输出或报 `ExecutableNotFound` | 只装了 Python 的 `graphviz` 包，没装系统 Graphviz 程序 | 安装系统 Graphviz 并把 `bin` 目录加入 `PATH`；或直接跳过 `show()`，用 `dump()` 打印结构 |
| `system` 的 B+ 树 `put` 报 `TypeError: '<' not supported` | 插入了不可比较类型（比如 numpy 的 float32 和 Python float 混合） | 统一类型：`put(float(v), float(v))`；NumPy 标量与 Python 数混合比较在部分版本上会出问题 |
| 大 N 实验跑得很慢（几十秒） | 把 print 放进了插入循环，或每个键都伴随一次对象分配 | 插入循环里不要打印；N 超过 100 万时改用 `bulkload` 思路，或降到 20 万量级观察趋势即可 |
