AVL tree · 拆解与动手
AVL tree (Adelson-Velsky & Landis, 1962) 是历史上第一棵 self-balancing BST。它的不变量只有一条:每个 node 的 balance factor bf = height(left) − height(right) ∈ {−1, 0, +1}。插入 / 删除后从受影响 node 沿父链向上回溯 (retrace): 更新 height、检查 bf,一旦某处
|bf| = 2 就用 rotation 把它压回平衡。这条约束比 red-black tree 更严格,因此 AVL 树更矮、查找更快,代价是增删时的修复更频繁。本页从 balance factor 的定义出发,先看它凭什么把树高锁在
,再亲手过一遍四种失衡形态 (LL / RR / LR / RL) 的旋转,然后单步插入与单步删除,最后对比它与 red-black tree 的取舍。阅读前需要了解 binary search tree (BST) 的基本规则:每个 node 左子树更小、右子树更大,查找 / 插入沿比较路径下降。
本页依次:balance factor 与树高 → 四种失衡形态与旋转 → 单步插入 → 查询与删除 → 番外:AVL vs 红黑树。
本系列约定: node 画成青色实心圆,右上角小徽章是它的 balance factor;当 |bf| = 2 失衡时,该 node 临时变红色、徽章红底。当前操作涉及的 node 用橙色描边高亮。另一类用颜色(而非高度)维持平衡的 BST 见 red-black tree 系列。
1 · balance factor: 一条约束如何把树高锁在 O(log n)
BST 的查找代价正比于树高。问题在于:同一组值,插入顺序不同,树高可以从 一路烂到 。最坏情形毫不罕见——把已排序的数据顺序插入 naive BST,它会退化成一根只往右长的链表。AVL 的做法是给每个 node 定义一个可局部检查的量,并强制它落在一个很窄的区间内。
balance factor ,其中空子树高度记为 0、叶子高度为 1。
AVL 不变量: 每个 node 都满足 bf(x) ∈ {−1, 0, +1},即
——任一 node 的左右子树高度差不超过 1。
这条约束为什么够用?设 N(h) 为「高度为 h 的 AVL 树最少含多少 node」。一棵高 h 的 AVL 树,其较高一侧子树高
,另一侧至少
(否则 bf 就超界了),于是
——这正是 Fibonacci 递推。解得
,反过来高度
。所以 AVL 树高被牢牢压在
,且常数 1.44 比 red-black tree 的 2 更小——这就是「AVL 更矮」的来源。
1.1 · 动手:同一串值,AVL vs naive BST
左边是 AVL(每步自动再平衡),右边是 naive BST(从不旋转)。点「插入下一个」逐个喂值,或换不同的输入序列,盯着两棵树的高度差距。试试「升序 1..15」:naive BST 立刻退化成右斜链,AVL 仍是一棵矮树。
| n 个 node | AVL 高度上界 | red-black 高度上界 | naive BST 最坏 |
|---|---|---|---|
| 15 | ~5 | ~7 | 15 |
| 1,000 | ~14 | ~19 | 1,000 |
| 1,000,000 | ~28 | ~39 | 1,000,000 |
更矮意味着什么? 查找 / 命中比较次数正比于树高,所以 AVL 的查找在三者中最快。代价在单步插入与查询与删除(见下):维持 的强约束需要更频繁地旋转。这条「查询快 vs 修复少」的取舍正是它与 red-black tree 之争的核心。
2 · 四种失衡形态:每一种对应一种旋转修法
当某个 node 的 |bf| = 2,说明它的一侧子树比另一侧高了 2。失衡只可能是四种形态之一,取决于「重的那一侧」与「重侧孩子又往哪边重」。其中两种 (LL / RR) 一次旋转即可,另两种 (LR / RL) 需先把内侧扳成外侧、再旋一次。所有 AVL 的再平衡最终都归结到这四种。
| 形态 | 重的一侧 | 旋转修法 | 单 / 双旋 |
|---|---|---|---|
| LL | 左孩子的左子树重 | 对失衡点右旋 | 单旋 |
| RR | 右孩子的右子树重 | 对失衡点左旋 | 单旋 |
| LR | 左孩子的右子树重 | 先对左孩子左旋,再对失衡点右旋 | 双旋 |
| RL | 右孩子的左子树重 | 先对右孩子右旋,再对失衡点左旋 | 双旋 |
记忆法: 看「失衡点 → 重侧孩子」走过的两步方向。同向 (LL / RR) 单旋,异向 (LR / RL) 先把它扳成同向再单旋。
下面每个按钮构造一棵恰好触发该形态的最小子树:前两个值已平衡插入,点按钮后单步插入第三个值,先看它如何把某个 node 顶成 |bf| = 2(该 node 变红),再看旋转怎么把它压回平衡。inorder 全程不变。
双旋 = 两次单旋的组合,不是新操作。 LR 的「左孩子左旋」只是先把内侧的重子树转到外侧,转完就退化成 LL,再右旋一次即可;RL 对称。所以真正的原子操作只有左旋 / 右旋两个,且互为逆。带着这四种形态去看单步插入与查询与删除(见下),fixup 就不再是黑盒。
为什么旋完一定平衡、且 inorder 不变? 旋转只改父子上下关系与中间子树的归属,严格保持中序升序(BST 性质);而对每种形态精确算高度可证明:旋转后子树根的 |bf| 必然回到 ≤ 1。这正是「四种形态各配一种旋转」能穷尽所有失衡的原因。
3 · 插入:下降建叶,再沿父链回溯修平衡
插入分两段。第一段是普通 BST 插入,第二段是 AVL 独有的回溯再平衡 (retrace)。若不熟悉旋转的四种形态,可先看上文 四种失衡形态与旋转 一节。
其一,按 BST 规则从根下降,在空位挂上新叶 (height = 1)。
其二,从新叶的父节点起,沿父链向上回溯: 每到一个 node 就 update(height)、算 bf。
- 若 : 该 node 仍平衡,继续上移。
- 若
|bf| = 2: 按 LL / RR / LR / RL 选旋转修复。
其三,AVL 的关键性质: 一次插入最多只触发一次(单或双)旋转——修完该子树高度即回到插入前,上方 bf 不再变,可立即停止上溯。
输入一个数点「插入」,再用「下一步」逐步看下降、回溯、bf 变化与旋转;旁白说清属于哪种形态。每次插入完成后校验全部不变量。新树建议先点「示例序列」连插几个,造出需要旋转的局面。
为什么插入最多一次旋转,删除却可能多次? 插入只在一个 node 处使某条路径变长 1;一次旋转就能把该子树高度还原到插入前,高度不再向上传播。删除则是使某条路径变短 1,旋转后子树可能整体再矮 1,从而令更上层继续失衡——见下文 查询与删除。
4 · 查询与删除:删除为什么可能旋转多次
查询在 AVL 里和普通 BST 一字不差:从根逐次比较、向左或向右下降。AVL 不改查找逻辑,只是凭 把树高压在 ,让这条比较路径足够短。先在下面查一个值,看高亮的下降路径。
删除才是与插入真正不同的地方。先做 BST 删除(有两个孩子时用中序后继的值顶替、改删后继),再从物理删除点的父节点沿父链回溯。区别在于: 插入使某路径变长 1,一次旋转就把高度还原、不再上传;而删除使某路径变短 1,旋转后子树可能整体再矮 1,从而令更上层继续失衡——因此删除最坏需要 次旋转,要一路回溯到根。
试一试: 在示例树上删除 30——它的子树失衡,触发一次旋转。或连续删几个值,观察某次删除一路回溯到根、沿途修复多处。对比单步插入:插入的旁白总是「一次旋转后即停」,删除则可能报告「触发 N 次旋转」。
5 · 番外:AVL vs red-black tree,两种平衡哲学
AVL tree (1962) 与 red-black tree(Guibas & Sedgewick, 1978,源自 Bayer 1972 的 symmetric binary B-tree)是 self-balancing BST 的两大主流。它们解决同一个问题——防止 BST 退化——却选择了平衡严格度的两端。这节梳理它们的取舍与各自的工程归宿。
5.1 · 核心区别:平衡的「严」与「松」
AVL 用高度维持平衡:强制每个 node ,约束很严,树几乎总是接近完美平衡。red-black tree 用颜色维持平衡:5 条 property 只保证「最长路径不超过最短路径的 2 倍」,约束较松,允许树长得更歪一点。一句话:AVL 把树修得更直,red-black 让树歪得有限度。
| 维度 | AVL tree | red-black tree |
|---|---|---|
| 平衡依据 | height (balance factor) | 颜色 (5 条 property) |
| 高度上界 | ~1.44·log₂n (更矮) | ~2·log₂n |
| 查找速度 | 更快 (树更矮) | 略慢 |
| 插入旋转 | ≤ 1 次 (单或双旋) | ≤ 2 次 |
| 删除旋转 | 最坏 O(log n) 次 | ≤ 3 次 |
| 每 node 额外开销 | height (整数,或 2-bit bf) | 1-bit 颜色 |
| 再平衡频率 | 较高 (约束严) | 较低 (约束松) |
5.2 · 该用哪个:取决于读写比
读多写少 → AVL。 树更矮使查找命中更快;插入旋转也不超过一次。典型场景:以查询为主、更新不频繁的数据库 / 文件系统索引(许多 in-memory 索引、早期数据库实现采用 AVL 或其变体)。
写频繁 → red-black tree。 删除最坏只需 ≤ 3 次旋转(AVL 删除可能一路旋到根),再平衡的摊还成本更低,且每 node 只多 1 bit。典型场景:Java TreeMap / TreeSet、C++ std::map / std::set、Linux 内核的进程调度(CFS
红黑树)与虚拟内存区间、Java 8 HashMap 退化桶——见 red-black tree「为什么是它无处不在」。
5.3 · 为什么标准库大多选了 red-black?
通用容器无法预判使用者的读写比,而写入路径的最坏延迟更可控、更重要:red-black 把单次增删的旋转次数封死在常数(插 ≤ 2、删 ≤ 3),AVL 的删除却可能 O(log n) 次旋转。对追求稳定最坏写入的标准库,这个保证比「查找快一点点」更值钱——何况两者查找都是 O(log n),常数差距在多数负载下并不显著。于是 red-black tree 成了语言标准库与内核的默认选择,AVL 则更多出现在查询密集的专用场景。
它们其实是「近亲」。 red-black tree 是 2-3-4 tree 的二叉表示,AVL 没有这层 B-tree 对应,但两者都属于「旋转 + 局部修复」这一类高度平衡 BST。理解了 AVL 的四种失衡形态与旋转(见上),再看 red-black 的 fixup 会容易很多——旋转这个原子操作两者完全共用,差别只在「何时触发、配合什么标记(height vs 颜色)」。