《数据结构与算法》教材笔记(树篇):从二叉查找树一路推导到 B+ 树,为什么数据库索引非它不可
- 约 18 分钟读完 - 全文 7112 字《数据结构与算法》教材笔记(树篇):从二叉查找树一路推导到 B+ 树,为什么数据库索引非它不可
目标读者:学过一遍数据结构、能写二叉树遍历,但被问到"B 树和红黑树有什么区别、为什么 MySQL 用 B+ 树"就说不囫囵的同学。 姊妹篇:《算法与数据结构入门学习总结》是刷题向的——讲双指针、滑动窗口、DP 模板,目标是过 LeetCode。这篇是教材向的——讲每种结构"为什么被发明出来",目标是看懂工程世界。两篇文章覆盖的内容几乎不重叠,建议先读哪篇都行。 实战印证:《MySQL 实战指南》里讲的索引失效、覆盖索引,《MongoDB 从入门到精通》里讲的 WiredTiger B 树页、页分裂,底层原理都在这篇里。
0. 这篇只讲一件事:树是怎么一步步被逼成 B+ 树的
教材里的结构很多,数组、链表、栈、队列、哈希、图……但刷题篇已经覆盖了大部分,这篇把 70% 的篇幅压在一根主线上——树的进化链:
flowchart LR
A["二叉查找树 BST<br/>有序 但会退化"] -->|"痛点: 退化成链表"| B["AVL 树<br/>严格平衡"]
B -->|"痛点: 旋转太频繁<br/>写多读少顶不住"| C["红黑树<br/>近似平衡 工程妥协"]
C -->|"痛点: 再高也是二叉<br/>树太高 磁盘 I/O 多"| D["2-3 树<br/>一个节点放多个 key"]
D -->|"推广"| E["m 阶 B 树<br/>阶数对齐磁盘页"]
E -->|"痛点: 内节点存数据<br/>范围扫描难"| F["B+ 树<br/>内节点纯索引<br/>叶节点链表"]
style F fill:#ffd699
每一步都不是"更高级",而是上一步在某个具体场景下撑不住了。记住这条链上的每个箭头,你就永远不会把 B 树讲成背定义。
另外教材里还有两个刷题篇没覆盖的点——哈希冲突的两种解法、图的最短路径与最小生成树——放在最后简单补全。
1. 二叉查找树:一切的开始,和它的死穴
二叉查找树(BST)的规则一句话:左子树所有值 < 根 < 右子树所有值。查找、插入、删除平均都是 O(log n),看起来很美。
死穴:它不管自己的形状。 按 1, 2, 3, 4, 5, 6, 7 的顺序插入,你会得到:
flowchart TD
A[1] --> B[2]
B --> C[3]
C --> D[4]
D --> E[5]
E --> F[6]
F --> G[7]
style A fill:#ffcccc
这棵树退化成了链表,查找变成 O(n)。而"按顺序插入"在真实世界里太常见了——自增 ID、时间戳,天然就是有序的。BST 的平均复杂度是拿"数据随机到达"换来的,工程上没人敢赌这个。
所以需要平衡:插入删除时主动调整形状,保证树高稳定在 log n。怎么调?教材给了两个答案,一个完美主义,一个实用主义。
2. AVL 树:完美主义的代价
AVL 的规则:任意节点的左右子树高度差不超过 1。这是最严格的平衡,树高被死死压在约 1.44 log n,查找最快。
代价是:每次插入删除破坏平衡时,要做旋转来修复。四种失衡形态:
flowchart TB
subgraph LL["LL 型:左孩子的左子树插入"]
direction LR
L1[" 30 右旋 20<br/> / → / \<br/>20 10 30<br/>/<br/>10"]
end
subgraph RR["RR 型:右孩子的右子树插入"]
direction LR
R1["10 左旋 20<br/> \\ → / \<br/> 20 10 30<br/> \\<br/> 30"]
end
subgraph LRRL["LR / RL 型:拐弯失衡 → 先转成 LL/RR 再转<br/>(两次旋转)"]
LR1["先对子树反向旋转<br/>再对根旋转"]
end
旋转本身不复杂,麻烦的是频率:插入最多旋 2 次就停,但删除可能要从底向上旋 O(log n) 次。读多写少的场景没问题,写多读少的场景(比如频繁更新的索引)旋转本身就吃掉了平衡带来的收益。
学习心得:我当年把四种旋转的图背得滚瓜烂熟,考试一过全忘了。后来发现根本不用背——LR 就是"先左旋成 LL,再右旋",四种形态只是"一次还是两次、往哪边转"的组合。理解失衡的方向比记图重要。
3. 红黑树:工程妥协的胜利
红黑树放弃了"严格平衡",换成一组染色规则:
- 每个节点非红即黑;
- 根和叶子(NIL)是黑;
- 红色节点的孩子必须是黑(不能有两个红挨着);
- 任意节点到其所有后代叶子的路径上,黑节点数量相同(黑高一致)。
这四条合起来保证的不是"左右一样高",而是一个更弱的结论:最长路径不超过最短路径的 2 倍。树高 ≈ 2 log n,比 AVL 略高,但换来的是插入删除平均只需 O(1) 次旋转。
flowchart TB
subgraph AVL对比["AVL vs 红黑树"]
direction LR
V1["AVL<br/>高 1.44·log n<br/>查得快<br/>删最多旋 log n 次"]
V2["红黑树<br/>高 2·log n<br/>查得稍慢<br/>旋 O(1) 次收工"]
end
V1 -.->|"读写均衡时<br/>工程选它"| V2
style V2 fill:#e8f4e8
红黑树是工程世界的宠儿:Java 的 TreeMap、Linux 内核的 CFS 调度器、epoll 的就绪队列、C++ 的 std::map,全是它。面试被问"为什么用红黑树不用 AVL",标准答案就是这一句:读多写少用 AVL(查得快),读写均衡用红黑树(调整便宜)。
踩坑故事 1:手撕红黑树是最不值的投入 面试准备期我花了一周想手写红黑树的删除——三种 Case、镜像三种、还有双重黑的修复,写完 200 行测试还是漏了一个分支。后来和做了十年基础设施的朋友聊,他说:“我工作里一次都没写过红黑树,但我每周都在做红黑树式的决策——在’严格’和’够用’之间选够用。“那一周的真正收获不是会写了,而是看懂了工程权衡的形状。
4. 2-3 树:B 树的真身,换个方向解决平衡
AVL 和红黑树都是"二叉 + 旋转修复”。2-3 树换了个思路:允许一个节点放多个 key,靠节点分裂来维持平衡。
规则:
- 节点分两种:2-节点(1 个 key,2 个孩子)、3-节点(2 个 key,3 个孩子);
- 所有叶子在同一层(绝对平衡,比 AVL 还狠);
- 插入永远发生在叶子:叶子满了(临时变成 3 个 key)就中间 key 向上分裂,父节点跟着调整,可能一路裂到根——树是这样"从下往上长"的。
一次分裂长这样:
flowchart TB
subgraph BEFORE["插入 7,叶子已有 5 6,临时三 key 超载"]
B1[" [9]<br/> [5,6] ← 插入7后变 [5,6,7]"]
end
subgraph AFTER["中间 key 6 上提,裂成两个节点"]
A1[" [6, 9]<br/> / | \\<br/> [5] [7] [...]"]
end
BEFORE --> AFTER
2-3 树的意义不在它自己(几乎没人直接用),而在于它证明了一件事:平衡不一定靠旋转,也可以靠"节点容量 + 向上分裂”。把这个思路的容量从 2 个 key 放大到几百个,就是 B 树。
顺带一提:红黑树其实就是 2-3 树的二叉表示——把 3-节点的两个 key 用一条红色边连起来,就是红黑树。理解了 2-3 树再回头看红黑树规则,“不能两个红挨着"就是"一个 3-节点最多两个 key”,瞬间全部通顺。这是教材推导最美的一段。
5. B 树:为磁盘而生的矮胖树
5.1 为什么二叉树到了磁盘上就不行了
到这里要引入这篇的真正主角:磁盘 I/O。内存里比较一次是纳秒级,磁盘读一次是毫秒级(机械盘)到几十微秒级(SSD),差着 4~6 个数量级。所以磁盘世界的复杂度单位不是"比较次数",而是"读了几个磁盘页"。
一棵 100 万个 key 的平衡二叉树,高约 20 层——查一个 key 最坏要 20 次磁盘 I/O。这就是为什么二叉平衡树统治内存、却进不了磁盘。
B 树的解法简单粗暴:把每个节点做到一个磁盘页那么大(InnoDB 默认 16KB)。一页能放几百个 key,树高立刻塌下来:
flowchart TB
subgraph BTREE["一棵 m 阶 B 树(每个节点 = 一个 16KB 磁盘页)"]
ROOT["根页<br/>[k1 | k2 | ... | k400]<br/>1 次 I/O"]
ROOT --> L2A["内页<br/>400 个 key<br/>1 次 I/O"]
ROOT --> L2B["内页 ..."]
ROOT --> L2C["内页 ..."]
L2A --> L3["叶页层<br/>每页 400 条记录<br/>1 次 I/O"]
end
NOTE["100 万 key:树高 ≈ 2~3<br/>= 最多 3 次磁盘 I/O"]
算一笔账:每页 400 个 key,两层就是 400 × 400 = 16 万,三层就是 6400 万。1 亿条数据的索引,3 次 I/O 定位——这就是"千万级数据 B 树也就 4 层"(MongoDB 篇里的那句话)的出处。
5.2 m 阶 B 树的定义(五条,一条都不能少)
- 每个节点最多 m 个孩子(m 阶);
- 非根节点至少有 ⌈m/2⌉ 个孩子(保证节点不会太空);
- 有 k 个孩子的节点恰好有 k-1 个 key,key 排序,充当孩子之间的"路标";
- 所有叶子在同一层;
- 根至少 2 个孩子(除非是叶子)。
插入和删除是 2-3 树逻辑的放大版:
flowchart LR
subgraph 插入["插入:叶子满了就分裂"]
I1["叶子 key 数 = m<br/>超载"] --> I2["中间 key 上提<br/>裂成两半<br/>各留 ⌈m/2⌉-1 个"]
I2 --> I3["父节点也满?<br/>继续向上裂<br/>最坏裂到根 树高+1"]
end
subgraph 删除["删除:节点太空就借/并"]
D1["key 数 < ⌈m/2⌉-1"] --> D2{"兄弟有富余?"}
D2 -->|有| D3["向兄弟借一个<br/>(父key下来 兄弟key上去)"]
D2 -->|没有| D4["和兄弟合并<br/>父key拉下来<br/>可能连锁合并"]
end
B 树的树高增长只有一种方式:根节点分裂。 这保证了它永远不会退化——这是比 BST 强在根上的地方。
踩坑故事 2:自增主键 vs UUID,B 树结构的真实代价 早年在 MySQL 里给一张日志表用了 UUID 做主键,量小的时候没感觉,过了 5000 万行插入速度腰斩再腰斩。原因就在 B 树的插入路径:自增主键永远往最右的叶子追加,页满了裂一次,顺序 I/O;UUID 是随机落到任意叶子,每插一条都可能读一个冷页、裂一个热页,还伴随着页分裂后的空间碎片(页利用率掉到 60% 多)。换回自增 BIGINT 后写入吞吐翻了近 3 倍。这不是玄学,就是 B 树分裂频率的差。这也是为什么分布式 ID 方案(雪花 ID)都强调"趋势递增"——在讨好 B+ 树。
6. B+ 树:补上最后一刀,数据库的终点站
B 树已经很好了,B+ 树只改了两处,却是数据库索引的定论:
flowchart TB
subgraph B树["B 树:key 和数据混在一起"]
BR["内节点<br/>[key+data | key+data]"]
BR --> BL1["叶节点<br/>key+data"]
BR --> BL2["叶节点<br/>key+data"]
BN1["一个 key 的数据<br/>可能在内节点<br/>范围查询要跨层<br/>中序遍历上蹿下跳"]
end
subgraph BPLUS树["B+ 树:内节点纯索引,数据全在叶子"]
PR["内节点<br/>[key | key | key]<br/>只当路标 不放数据"]
PR --> PL1["叶节点<br/>key+data"]
PR --> PL2["叶节点<br/>key+data"]
PR --> PL3["叶节点<br/>key+data"]
PL1 -->|"双向链表"| PL2
PL2 -->|"双向链表"| PL3
PN1["范围查询:定位起点后<br/>顺着链表扫<br/>全是顺序 I/O"]
end
style BPLUS树 fill:#e8f4e8
两处改动,每一处都打在磁盘场景的七寸上:
改动一:内节点不存数据,只存 key。 同样 16KB 一页,存"key+数据指针"可能只能放 100 个,纯 key 能放 1000 个。阶数 ×10,树再矮一层。而且内节点少且热,整个上层索引经常能全塞进内存,真实查询只有最后一步读叶子才碰磁盘。
改动二:叶子之间串成双向链表。 WHERE age BETWEEN 20 AND 30、ORDER BY ... LIMIT 这类范围扫描,B 树要在层间来回跳(随机 I/O),B+ 树定位到起点后顺着链表扫,顺序 I/O。机械盘时代这是百倍差距,SSD 时代预读和缓存依然偏爱顺序访问。
再附赠两个由此推出的性质,都是 MySQL 篇里出现过的实战规则:
- 聚簇索引下,叶子就是数据行本身,所以按主键查 = 一次定位,而二级索引叶子存的是主键值,要回表再查一次——这就是"覆盖索引为什么快";
- B+ 树的有序性 + 左前缀路标结构,决定了
LIKE '%abc'没法走索引(开头通配,路标失效),LIKE 'abc%'可以——MySQL 篇那句"会全表扫描"的底层解释就在这。
一句话记住 B 树 vs B+ 树:B 树是"查到什么都是赚"(内节点也可能命中数据),B+ 树是"每次都老实走到叶子,但换来更矮的树和丝滑的范围扫描"。数据库查询里范围扫描是家常便饭,所以 B+ 树赢了。
7. 顺手补两个教材盲点
7.1 哈希冲突:链地址 vs 开放寻址
刷题篇讲过哈希表"用空间换时间",教材会多问一步:两个 key 撞进同一个桶怎么办?
flowchart LR
subgraph 链地址["链地址法(Java HashMap)"]
A1["桶 0 → k1 → k2 → k3<br/>(Java 8 后链表超 8 个转红黑树)"]
end
subgraph 开放寻址["开放寻址法(Redis dict 扩容期 / Python dict)"]
B1["桶被占?<br/>按探查序列找下一个空位<br/>线性 +1 / 二次 +i² / 双重哈希"]
end
取舍:链地址实现简单、删除容易,但链表指针追逐对 CPU 缓存不友好;开放寻址数据紧凑缓存友好,但负载因子一高(>0.7)探查序列雪崩,且删除要打墓碑标记。Java 选前者,Python 选后者,没有谁对谁错,又是"看场景"。
7.2 图的两个经典算法家族
- 最短路径:单源无负权用 Dijkstra(贪心 + 优先队列,O(E log V));有负权边用 Bellman-Ford(松弛 V-1 轮,顺带能检测负环);全源用 Floyd(三层循环的 DP,O(V³),图小图稠才用)。
- 最小生成树:Kruskal 按边排序 + 并查集判环(适合稀疏图);Prim 从点出发贪心扩边(适合稠密图)。
工程里 Dijkstra 出场率最高——导航、网络路由(OSPF)都是它的变体。并查集在刷题篇讲过,这里正好闭环。
8. 把整条链放回工程世界
学完整条进化链,回头看各个系统的选型,会发现全部是这条链上的位置决策:
flowchart TB
MEM["纯内存 读写均衡<br/>Java TreeMap / epoll / CFS"] -->|红黑树| R1["近似平衡 旋转便宜"]
MEM2["纯内存 读极多<br/>静态字典"] -->|AVL| R2["严格平衡 查得最快"]
DISK["磁盘 点查为主<br/>MongoDB WiredTiger"] -->|B 树| R3["页对齐 矮胖<br/>内节点也能命中数据"]
DISK2["磁盘 点查+海量范围扫描<br/>MySQL InnoDB / 绝大多数关系库"] -->|B+ 树| R4["叶子链表<br/>范围扫描顺序 I/O"]
LOG["写极多 追加型<br/>LevelDB / RocksDB / HBase"] -->|LSM 树| R5["干脆放弃原地更新<br/>写全部顺序化"]
style DISK2 fill:#ffd699
最后一行 LSM 树是题外话但值得一提:当写压力大到 B+ 树的页分裂和随机写都扛不住时,LevelDB/RocksDB 们干脆换了世界观——不原地改,只追加,把随机写变成顺序写,读的时候多层合并。这是"B 树家族应付不了的场景长出了新物种"的又一例,思路和本篇主线一脉相承:没有最好的结构,只有最匹配 I/O 模式的结构。
思考与感悟
1. 数据结构课的隐藏主线是"权衡",不是"结构"。 教材目录按结构分章,但真正的线索是:每个结构都在某个维度上多要了一点、在另一个维度上让了一步。AVL 要了查询速度让了写入成本,红黑树反过来;B+ 树要了范围扫描让了内节点命中。考试考结构,工作考权衡。
2. “为什么"比"怎么做"值钱。 红黑树的旋转规则我忘光了,但"为什么不能两个红挨着 = 2-3 树节点容量"这个理解我记了十年。背规则应付考试,理解动机应付工作——工作中没人让你手写 B 树分裂,但天天有人问"这个索引为什么这么建”。
3. 磁盘那节课值整门课的一半。 很多人学完数据结构,认知还停留在"比较次数"的世界。B 树这一课的真正教学目的,是把你的复杂度单位从"操作次数"切换到"I/O 次数"。这个切换一旦完成,后面学数据库、学存储引擎、学缓存,全是同一件事的不同切面。
4. 这条链还没完。 B+ 树不是终点——LSM 树在写密集场景抢了它的地盘,跳表在 Redis 里用更简单的实现达到了类似的效果,AI 时代的向量索引(HNSW)干脆用图结构解决相似度搜索。数据结构这门课永远在续写,但写法永远是一样的:一个新场景出现,旧结构哪里疼,新结构就长在哪里。