课程 · #10 / 11

平衡树

旋转让高度始终 ≈ log n,无论插入顺序如何

AVL tree · self-balancing rotations

size0
avl height0
naïve bst h.0
init

动手试试:insert 1..15 强行制造一个退化序列。朴素 BST 的高度一下蹿到 15;AVL 的高度却稳稳停在 4——因为每一次失衡都会触发一次旋转,并在画布上闪现出来。

为什么它重要

普通 BST 可能退化到 O(n)。平衡树通过在插入和删除后自动重新平衡,保证 O(log n)。AVL 树和红黑树是大多数语言标准库背后的主力。

核心思想

还记得 BST 在插入已排序数据时会变成一条链表吗?平衡树通过在操作后重新调整结构来防止这一点。

AVL 树:每次插入/删除后,检查是否有某个节点的左右子树高度相差超过 1。如果有,就旋转来修复它。

旋转是那个神奇的招式:

结果是:高度始终为 O(log n),于是所有操作都被保证维持在 O(log n)。

红黑树用的是另一种诀窍:给节点染上红色或黑色,并用一套规则限制树最多能失衡到什么程度。它没有 AVL 那么严格地平衡,但平均下来旋转次数更少。

要点回顾

不变式

这些树继承了 BST 不变式,并在其上加了一条平衡规则。正是这条平衡规则保证了 O(log n) 的高度。

AVL 不变式
对每个节点,|height(left) − height(right)| ≤ 1。每次插入和删除后都通过旋转严格强制执行。
推论:高度 h < 1.44 · log₂(n + 2)——离理论最小值不到 44%。
红黑不变式——四条必须同时成立
1. 每个节点要么是红色,要么是黑色。
2. 根是黑色的。(有时也表述为:叶子——那些 NULL 哨兵——也是黑色的。)
3. 红色节点只能有黑色孩子。(任何一条从根到叶的路径上都不会连续出现两个红色。)
4. 每条从根到 NULL 的路径都经过相同数量的黑色节点。这就是黑高;它是约束树高的那条结构性不变式。
推论:高度 h ≤ 2 · log₂(n + 1)。比 AVL 宽松,但更易维护。

再深入一些

AVL 与红黑 —— 该选哪个

| 性质 | AVL | 红黑 | |---|---|---| | 最坏情况高度 | ≤ 1.44 log₂ n | ≤ 2 log₂ n | | 每次插入的旋转 | 至多 2 次 | 至多 2 次 | | 每次删除的旋转 | 至多 O(log n) | 至多 3 次 | | 查找速度 | 略快(更平衡) | 略慢 | | 插入/删除速度 | 较慢 | 较快 | | 每个节点的内存 | 1–2 位(平衡因子) | 1 位(颜色) | | 典型用途 | 数据库、读多写少 | 语言标准库 std::mapTreeMap、Linux 内核 rbtree.h |

经验法则: 如果你读远多于写,用 AVL。如果你的工作负载是混合的或写多,用红黑。C++ 的 std::map、Java 的 TreeMap 以及 Linux 内核的进程调度器都因此用红黑树。

B 树 —— 面向磁盘页的推广

B 树把 BST 朝一个方向推广:每个节点持有许多个键(比如 100 到 1000 个),而不只是一个。一个有 k 个键的节点有 k + 1 个孩子,孩子们的键区间与父节点的键交错排列(key₀ 左边 → child₀,key₀ 与 key₁ 之间 → child₁,……)。

为什么每个节点要塞这么多键?磁盘寻道。 在机械磁盘上,读 1 字节和读 16 KB 花的时间基本一样——代价在于磁头的移动,而不在于传输的字节数。数据库引擎把每个 B 树节点填到恰好一个磁盘页的大小(通常 4 KB 或 16 KB)。一次磁盘读取就一口气取来约 256–1024 个键,于是在数十亿行的数据库里导航也只需 3–4 次页读取。同样的逻辑适用于 SSD(其寻道代价由页对齐 I/O 主导)以及文件系统的 inode。

每个关系数据库索引、每个现代文件系统(ext4、NTFS、APFS、XFS),以及大多数键值存储(RocksDB、LevelDB)用的都是 B 树或其近亲 B+ 树(其中叶节点构成一条链表,以便快速范围扫描)。B 树正是 SELECT * WHERE id BETWEEN 1000 AND 2000 在十亿行表上能在亚毫秒内完成的原因。

现实世界的代价数字

对一棵有 n = 1,000,000 个键的平衡树:

与一个按键排序的数组对比:

所以,如果你的数据集是只读的、又能装进内存,那么一个有序数组在速度(缓存局部性)和内存上都胜过平衡树。平衡树付出这份开销,是为了换取 O(log n) 的插入。

数学细节

AVL 平衡因子
BF(n) = height(n.left) - height(n.right)
Must be in 1
时间复杂度(有保证)
Search: O(log n)
Insert: O(log n)
Delete: O(log n)
高度界
AVL: h < 1.44 log_2(n+2)
Red-Black: h leq 2 log_2(n+1)

实现

旋转的概念

// Right rotation when left subtree too tall
//       y                x
//      / \              / \
//     x   C   --->     A   y
//    / \                  / \
//   A   B                B   C

fn rotate_right(y: Node) -> Node {
    let x = y.left.take();
    y.left = x.right;
    x.right = Some(y);
    x
}

何时使用

练习

每一个自平衡树实现的配套检查:

浏览全部练习题