树:BST、平衡树(AVL/红黑简介)与 B 树
你有没有想过一个尴尬的局面:有序数组查找快(二分 O(log n)),可一旦插入新元素,得把后面一半整体后挪,O(n);链表插入是 O(1),可查找又退化成 O(n)。有没有一种结构,能既要查找快、又要插入/删除也快?
答案就是本篇的主角——树,更准确地说是**二叉搜索树(BST)**以及它的「打补丁」版本:平衡树(AVL / 红黑树)和多路平衡树(B 树)。它们把「有序」从「数组的连续内存」里解放出来,变成「节点之间的偏序关系」,于是插入、删除不再需要搬移整段数据。我们先从最朴素的 BST 讲起,再看它哪里会翻车,以及三个补丁分别怎么修。
一、二叉搜索树(BST):定义与不变式
BST = 一棵二叉树,且满足「左小右大」的递归不变式:
对树上任意一个节点
x:其左子树中所有 key 都< x.key,其右子树中所有 key 都> x.key。
注意是「严格」的偏序,通常不允许重复 key(要支持重复就改成「≤ 放左 / ≥ 放右」,本篇按不允许重复讲,逻辑最干净)。这条不变式是 BST 一切能力的根基——因为它意味着中序遍历(左→根→右)必然得到升序序列。
中序遍历这棵树:20 → 30 → 40 → 50 → 60 → 70 → 80,完美升序。这就是 BST 的「免费排序」特性。
二、BST 的核心操作(Java 实现)
下面是一份可直接编译运行的 BST 骨架:插入、查找、中序遍历、删除。注意删除有两子节点的情况——我们找**右子树的最小节点(中序后继)**来顶替被删节点,从而保住不变式。
1 | public class BST { |
编译运行后输出:
1 | 插入后中序: 20 30 40 50 60 70 80 |
小观察:删掉根
50后,顶替它的是右子树最小者60,于是新根变成60,中序序列依旧有序。这条「用后继顶替」的规则,是 BST 删除不出错的关键。
三、BST 的软肋:退化成链表
BST 的查询复杂度不是固定的 O(log n),而是等于树高。如果插入顺序恰好是升序(或降序),树会退化成一条链:
此时树高 = 节点数,所有操作退化到 O(n)——和普通链表没两样。问题的本质是:BST 没有约束「左右子树要差不多高」。于是引出第一个补丁:平衡树,它的全部努力就是「别让树歪了」。
四、平衡树:用「旋转」把树拉直
**旋转(rotation)**是平衡树的核心原子操作:它在不破坏 BST 不变式(中序序列不变)的前提下,重新组织局部结构,降低树高。最常见的是右旋(right rotation)和左旋(left rotation),互为镜像。
右旋前后,中序序列都是 a → x → b → y → c,顺序丝毫没变,但原本以 y 为根、左子树过深的结构被「掰直」了。下面给出 AVL 里最基础的两种旋转(带高度维护):
1 | static class AVLNode { |
AVL 树:严格平衡
AVL 给每个节点加一个平衡因子 bf = h(left) − h(right),并强制 |bf| ≤ 1。一旦插入/删除破坏了这个条件,就按四种情形之一做旋转:LL(右旋)、RR(左旋)、LR(先左后右)、RL(先右后左)。代价是每次操作都要维护高度和做可能的旋转,插入/删除略慢,但查询极快(O(log n) 且常数很小),适合查询远多于写入的场景。
红黑树:近似平衡
红黑树放宽了要求,改用「颜色」约束——这正是下一节的主角,也是 BST 家族里工程最常用的一种。
本质一句话(提前剧透):平衡树 = BST + 「用旋转维持树高 ≈ log n」。AVL 偏「严」(查询快),红黑树偏「松」(增删快),殊途同归都是
O(log n)。
五、红黑树:近似平衡的精髓
红黑树和 AVL 干的是同一件事——给 BST 上一把「平衡锁」,防止退化成链表。区别只在锁的松紧:
- AVL:锁得死,
|平衡因子| ≤ 1,查询极快,但每次插入删除可能触发多次旋转。 - 红黑树:锁得松,只要求「近似平衡」,旋转次数更少、增删更快,查询只比 AVL 略慢。
工程里红黑树更常见:TreeMap、std::map、很多语言的有序容器底层都是它。取舍很清晰:用一点点查询速度,换大量增删速度。
下面这棵树是合法的红黑树,先让它把五条性质变具体:
5 条性质,以及每条「为什么存在」
| # | 性质 | 为什么需要它 |
|---|---|---|
| 1 | 节点非红即黑 | 定义 |
| 2 | 根必须是黑 | 让边界统一,少一种特例 |
| 3 | 红节点的子节点必为黑(无连续红) | 高度界的关键之一 |
| 4 | 任一节点到所有叶子(NIL)的路径,黑节点数相等(黑高相等) | 高度界的关键之二 |
| 5 | 叶子(NIL)为黑 | 和 2 一起让边界好处理 |
注意:(3) 和 (4) 才是真正起作用的两条,其余几条主要是让算法实现少分情况。
高度界推导:把「≈ log n」从直觉变成不等式
这是红黑树最该想通的一点,分两步。
第一步:由 (3)+(4) 得到 h ≤ 2·bh。
- 因为 (3)「无连续红」:任意一条路径上,红节点不能挨着红节点,所以黑节点至少占一半(最密也就是红黑红黑交替,1:1)。
- 因为 (4)「黑高相等」:设黑高为
bh,那么最短路径就是一路全黑 =bh个节点;最长路径就是红黑交替 =2·bh个节点。 - 于是 最长路径 ≤ 2 × 最短路径,整棵树高度
h ≤ 2·bh。
第二步:用「二叉树的逻辑」把 bh 和 n 连起来。
黑高为 bh 的树,节点数至少有 2^bh − 1 个。为什么?把红节点全部隐去,剩下的黑骨架本身是一棵二叉树:
- 黑骨架要从根到每个叶子都恰好走过
bh个黑节点(性质 4); - 一棵高度为
bh的二叉树,最少也有2^bh − 1个节点(满二叉树才达到这个下界,缺一个枝就到不了bh层); - 我们隐去红节点只是「拿走」了一部分,黑骨架节点数只会比总节点数少、不会多;红节点本来就是额外加在黑骨架上的。
所以:黑节点数 ≥ 2^bh − 1,进而总节点数 n ≥ 2^bh − 1 → bh ≤ log₂(n+1)。
联立两步:
1 | h ≤ 2·bh (来自 无红红 + 黑高相等) |
这正是普通二叉树的底层事实——「高为 H 的二叉树至少
2^H − 1个节点」——只不过这里把「高」换成了「黑高」。二分查找的log n也是同一个不等式倒过来,所以红黑树的高度天然落在log n量级。(3)+(4) 两条性质,通过这串不等式硬推出来「高度 ≈ log n」;二叉树的2^H − 1下界,就是连接bh与n的那座桥。
内拐 / 外拐:插入修复看形状
插入红节点 z 后,它和父 y(红)冲突(红红)。此时祖父 x(黑)必然是黑(否则插入前就不合法)。看 x、y、z 三个点的形状,决定转几次:
- 外拐(outer / 直线 / LL·RR):
z在「外侧」——y和z相对x偏向同一侧(y左孩子、z也左孩子,或都右)。三点成一条线 → 在祖父x上单旋即可,再重新染色。 - 内拐(inner / 之字 / LR·RL):
z在「内侧」——y和z反向(y左孩子、z右孩子,或反过来)。三点成之字形 → 先转父y把自己掰直成外拐,再转祖x(双旋)。
这和 AVL 完全同构:AVL 里 LL/RR 单旋、LR/RL 双旋;红黑「外拐单旋、内拐双旋」是同一个几何直觉,只是红黑还额外伴随重新染色。
插入修复的 before / after
外拐(LL):左边是插入 1 后出现的红红冲突,右边是修完的样子。
内拐(LR):它多了一步——先「转父」把自己掰直成外拐,再「转祖」得到同样结果。
插入算法总览
- 先按 BST 规则插入,新节点一律染红(染红不会破坏性质 (4) 黑高,最多只破坏 (3) 无红红 或 (2) 根黑——把风险缩到最小)。
- 若破坏,只在局部动手:
- 叔节点是红 → 重新染色,把「红红冲突」往上推一层(像把麻烦甩给老爸);
- 叔节点是黑、且是「内拐」 → 先转一下变成「外拐」;
- 叔节点是黑、且是「外拐」 → 旋转 + 染色,一次性修好。
- 最后算法强制根必为黑。
无论内拐外拐,修复完都收敛成「黑根 + 两个红孩子」的合法小三角,树高被压平。这就是红黑树插入的全部武器:recolor(叔红时) + rotate(叔黑时),外拐单旋、内拐双旋。
删除比插入难:删掉黑节点会破坏 (4) 黑高,得靠「借黑 / 转移黑」来补,情况更多(会出现「红-黑-黑」叠加态)。本篇聚焦插入修复,删除留作后续专题。
六、B 树:为磁盘 / 数据库而生的多路平衡树
前面这些树都假设整棵树在内存里、比较一次代价极低。可数据库要把数据放在磁盘上,磁盘 IO 一次就读一「页」(几 KB),而树高每多一层就多一次随机 IO。普通 BST/AVL 每个节点只存一个 key、分两叉,树高 log₂n——一亿条数据就得 log₂(10⁸) ≈ 27 层,也就是 27 次磁盘 IO,太慢。
B 树的解法是:让一个节点存多个 key、分多叉,把「矮胖」做到极致。
B 树要求:所有叶子在同一层(真正外存友好的关键),每个内部节点有 m 个 key、m+1 个分叉(m 即「阶」)。同样是 1 亿条数据,若每页存 100 个 key,树高降到 log₁₀₀(10⁸) ≈ 4 层——4 次 IO 就能定位,比 27 次快了一个数量级。这也是为什么 MySQL(InnoDB)、PostgreSQL 的索引几乎都建立在 B 树(及其变体 B+ 树)之上。
三种树的适用场景对比
| 结构 | 平衡方式 | 树高 | 典型用途 |
|---|---|---|---|
| AVL | 高度严格平衡(` | bf | ≤1`) |
| 红黑树 | 颜色近似平衡(≤2×) | 低 | 内存中、增删频繁(如 TreeMap) |
| B / B+ 树 | 多路、叶子同层 | 极矮(按页) | 磁盘 / 数据库索引 |
七、C++ 对照速览
在 C++ 标准库里,树的影子无处不在,只是被封装成了容器:
1 |
|
要点对照:
std::map/std::set底层是红黑树,遍历有序、单次操作O(log n)——和 Java 的TreeMap是同一个思路。std::unordered_map底层是哈希表,平均O(1)但无序——当你不需要有序、只求快时用它。- Java 侧对应物:
TreeMap(红黑树,有序)vsHashMap(哈希,无序)。选型逻辑在两种语言里完全一致。
如果手写 BST 插入,C++ 与 Java 几乎同构,只是内存管理从「GC」换成「new / 裸指针(或 unique_ptr)」:
1 | struct Node { int key; Node* left = nullptr; Node* right = nullptr; }; |
八、收尾:一句话带走
本质一句话:树把「有序」从数组的连续内存搬到了节点间的偏序关系上,于是插入/删除不再搬运整段数据。BST 是地基,平衡树(AVL / 红黑)用旋转把树高压在
log n,B 树用「多路 + 同层叶子」把树高压到能塞进几次磁盘 IO——从内存到数据库,一以贯之。
本篇从 BST 的「免费排序」讲到它的退化软肋,再用旋转引出平衡树;重点拆透了红黑树:五条性质为什么存在、(3)+(4) 如何借二叉树的 2^H−1 下界推出高度 ≤ 2·log n、内/外拐如何决定单旋还是双旋,最后用 B 树把视角从内存拉到磁盘。
本篇对应 CS61B 第 5 讲:树。代码均可在本地 JDK / 支持 C++17 的编译器直接编译运行。

