图谱连接

草稿

B+ 树

将内部节点作为导航键、所有记录放在相连叶子中,同时支持快速点查找和范围扫描。

data-structure intermediate data-structurestreesdatabasesindexing

问题入口

数据库式索引经常同时需要回答 id = 4220 <= key <= 60。B 树已经能提供很浅的点查找(point lookup),但范围查询还需要一个性质:找到第一个匹配记录后,相邻记录应该容易按顺序继续读取。

一个索引,两种查询形状
点查找id = 42
范围扫描20 <= key <= 60

数据库式索引既要找到单条记录,也要按顺序读取相邻记录。

直接复用 B 树还不够

在 B 树页面中,内部键携带记录或指针。这个约定适合点查找,但会打断顺序范围扫描:扫描可能要混合内部记录、叶子记录和树遍历。

复用 B 树:查找浅,扫描别扭
30:v60:v
10:v20:v
40:v50:v
70:v80:v

如果记录可以放在内部节点,范围扫描会混合树遍历和叶访问,而不是一次顺畅的叶子行走。

核心发明

B+ 树让内部节点只做导航页。所有记录/值都放在叶子中,叶子再从左到右相连。导航键/分隔键(guide/separator key)只负责路由搜索,不是载荷记录本身。

B+ 树结构
只导航
3060
< 3030 <= key < 60>= 60
Leaf A · 记录
10:v20:v
叶子链接 ->
Leaf B · 记录
30:v40:v50:v
叶子链接 ->
Leaf C · 记录
60:v70:v80:v

本页所有图和追踪都使用同一个分隔约定:根 [30, 60] 的孩子区间是 <3030 <= key < 60>=60。等于分隔键时向右走,所以查找 30 进入中间孩子,查找 60 进入右孩子。像 [20, 70] 这样的范围是闭区间。重复键不在本节点范围内。

点查找

点查找仍然是每层下降到一个孩子。区别在于遇到内部匹配时怎么办:在根里看到导航键 30 不会返回记录。相等时继续走右侧孩子,真正记录要在叶子中找到。

只导航
3060
< 3030 <= key < 60>= 60
Leaf A
10:A20:B
Leaf B
30:C40:D50:E
Leaf C
60:F70:G80:H

B+ 树点查找

读取根部导航键 [30, 60]。它们只负责导向;记录不存放在这里。

页读取1
选中的叶子-
区间-
约定等于分隔键时向右走

范围扫描

范围扫描(range scan)先下降到可能包含下界的叶子。然后按顺序扫描叶子记录,并沿叶子链接继续向右,直到越过上界。

只导航
3060
Leaf A
10:A20:B
link ->
Leaf B
30:C40:D50:E
link ->
Leaf C
60:F70:G80:H

B+ 树范围扫描

先下降一次到叶子 A,这里可能包含闭区间下界 20。

当前叶子A
页读取2
已收集记录-
闭区间边界[20, 70]

[20, 65] 预设会在叶子 C 内停止:它收集 60,看到 70,然后在包含 70 之前停止。

内部键与叶子记录

同一个数字可以在上层作为导航键出现,也可以在下层作为真实记录出现。分隔键 50 可以被复制到父节点,而记录 50 -> value 仍留在叶子中。

上层是导航键,下层才是记录
只导航
3060
< 3030 <= key < 60>= 60
Leaf A · 记录
10:v20:v
叶子链接 ->
Leaf B · 记录
30:v40:v50:v
叶子链接 ->
Leaf C · 记录
60:v70:v80:v

叶子分裂

叶子溢出时,先分裂叶子,再把新右叶子的第一个键复制到父节点作为分隔键。和上一页的 B 树分裂不同,这个分隔键是为了路由而复制;记录本身仍留在叶子中。

父节点导航键
3060
Leaf A
10:A20:B
link -> B
Leaf B
30:C40:D50:E
link -> C
Leaf C
60:F70:G80:H

叶子分裂追踪

叶子 B 已满

把 55 插入叶子 B [30, 40, 50]。当叶子容量上限为 3 时,临时叶子会变成 [30, 40, 50, 55]。

复制的分隔键-
记录仍在叶子50, 55

形式版本

B+ 树保持这些承诺:内部节点保存导航键和子指针;叶子保存所有记录/值;所有叶子处在同一深度;叶子按照键顺序相连。

B+ 树不变量
只导航
3060
< 3030 <= key < 60>= 60
Leaf A · 记录
10:v20:v
叶子链接 ->
Leaf B · 记录
30:v40:v50:v
叶子链接 ->
Leaf C · 记录
60:v70:v80:v
  • 内部键只负责导航。
  • 所有记录都存放在叶子中。
  • 所有叶子保持在同一深度。
  • 叶子链接保持有序。

实现草图

function lookup(root: InternalPage, key: number) {
  let page: Page = root;
  while (!page.isLeaf) {
    const i = firstSeparatorGreaterThan(key); // equality goes right
    page = page.children[i];
  }
  return page.records.find((record) => record.key === key);
}

function rangeQuery(root: InternalPage, lo: number, hi: number) {
  let leaf = descendToLeaf(root, lo);
  const out = [];
  while (leaf) {
    for (const record of leaf.records) {
      if (record.key < lo) continue;
      if (record.key > hi) return out;
      out.push(record);
    }
    leaf = leaf.next;
  }
  return out;
}

范围查询只下降一次。之后,兄弟叶子指针会把扫描继续带向右侧。

正确性直觉

导航键会把每个键路由到唯一可能包含它的叶子范围。每个叶子内部记录有序,叶子链接又按键顺序连接所有叶子。因此范围扫描一旦到达下界所在叶子,向右走就会按非递减顺序看到候选记录,直到越过上界。

复杂度

$B$ 是简化的扇出/页容量,$n$ 是记录数,$k$ 是返回的记录数。

point lookup=O(h)O(logBn)\text{point lookup} = O(h) \approx O(\log_B n) range query=O(logBn+k)\text{range query} = O(\log_B n + k)

扫描项表示匹配记录以及承载它们的叶页,不只是 CPU 比较。

先下降一次,再沿叶子走
O(log_B n)下降到第一个叶子
+ k扫描匹配记录/页

常见混淆

常见混淆
导航键不是载荷记录。
等于 30 或 60 时向右走。
叶子兄弟链接不是子指针。
复制分隔键 50 不会从叶子删除记录 50。

删除、重复键记录列表、锁、日志和真实页格式都故意不放在本节点中。

图中的连接

B+ 树由 B 树引出:保留适合页读取的浅层搜索形状,再把记录移动到相连叶子中,让范围扫描高效。

为什么先学 B 树

B+ 树保留按页读取的浅树形状,再专门优化点查找和范围访问。

练习

预测练习
只导航
3060
< 3030 <= key < 60>= 60
Leaf A · 记录
10:v20:v
叶子链接 ->
Leaf B · 记录
30:v40:v50:v
叶子链接 ->
Leaf C · 记录
60:v70:v80:v

查找 60 会进入哪个叶子?为什么相等时会去那里?

  1. 根为 [30, 60] 时,查找 30 会进入哪个孩子?
  2. 范围 [20, 65] 返回什么?为什么在 70 前停止?
  3. 55 插入 [30, 40, 50] 后,哪个分隔键会被复制到上层?
  4. 值是存放在内部的分隔键 60 上,还是只存放在叶子记录 60:value 中?

图谱连接 : B+ 树