2641 字
约 8 分钟
13
S03-X02 · 红黑树与 2-3-4 树:平衡的代价与收益

二叉搜索树的查找代价取决于树高,而树高由插入顺序决定:同一批键按升序写入就会退化成链表。红黑树用五条约束加常数次旋转把最坏树高钉在 O(log n);理解它的最短路径是翻译成 2-3-4 树——红链接表示"两层本是一个节点"。


1. 退化:树高由谁决定

有序插入、单调数据(自增主键、时间戳)加上没有自平衡约束,每次插入都挂在最右子树上,树高等于键数。

static Node insert(Node root, int k) {
    if (root == null) return new Node(k);
    if (k < root.key) root.left = insert(root.left, k);
    else root.right = insert(root.right, k);   // 升序时永远走这一支
    return root;
}

查找从"每层砍一半"变成"每层只走一步",典型量级由 O(log n) 掉到 O(n);递归深度也等于 n,StackOverflowError 常先于变慢暴露。

2. 五条性质,每条在防什么

# 性质 在保证什么
1 节点非红即黑 提供一个比特的信用额度:红节点是"预支的高度"
2 根为黑 给黑高一个统一起点,省掉根为红的分支讨论
3 红节点的子节点必须为黑 锁死最长路径,任何路径不会比另一条长一倍以上
4 任一节点到其所有叶子的路径黑节点数相同 黑高相等,等价于 2-3-4 树的完全平衡
5 叶子(NIL 空指针)视为黑 让"每条路径黑数相同"在任意节点上都可归纳

性质 3 限制最长能多长,性质 4 保证最短不会太短,合起来才推出 h ≤ 2*log2(n+1)。新节点先染红:染红只违反性质 3 或 2,局部可修。

static final boolean RED = false, BLACK = true;

static class Node<K, V> {
    K key; V value;
    Node<K, V> left, right, parent;
    boolean color = RED;      // 新插入先染红,不动黑高
}

3. 与 2-3-4 树的一一对应

2-3-4 树允许一个节点存 1–3 个键(2/3/4 节点),所有叶子同层。红黑树的翻译规则只有一条:红链接表示孩子与父同属一个 2-3-4 节点,把两层压成一层。

红黑树形态 2-3-4 节点 键数
黑节点,无红子 2 节点 1
黑节点 + 一个红子 3 节点 2
黑节点 + 左右各一红子 4 节点 3
红黑树黑高 2-3-4 树的树高 —

黑高相等即所有叶子同层:4 节点该分裂时表现为叔叔为红的变色,该旋转时表现为叔叔为黑。

4. 插入修复:三种情形

流程固定:按 BST 找位置 → 染红 → 向上修复,只关心叔叔颜色、是否同侧、祖父。

情形 条件(P 父 / U 叔 / G 祖 / N 新) 动作 本质
变色 U 为红 P、U 染黑,G 染红,N 跳到 G 继续 4 节点分裂,键上移
LL / RR U 为黑且同侧 单旋 G,P 黑 G 红 形成 3 节点,一次旋转
LR / RL U 为黑且异侧 先旋 P 掰直,退化成上一种 两次旋转
private void fixAfterInsert(Node n) {
    n.color = RED;
    while (n != root && colorOf(parentOf(n)) == RED) {
        Node p = parentOf(n), g = parentOf(p);
        if (p == leftOf(g)) {
            Node u = rightOf(g);                       // 叔叔
            if (colorOf(u) == RED) {                   // 情形 1:变色,可能向上传播
                setColor(p, BLACK); setColor(u, BLACK); setColor(g, RED);
                n = g;
            } else {
                if (n == rightOf(p)) { n = p; rotateLeft(n); }   // 情形 3:LR 掰直
                setColor(parentOf(n), BLACK);                    // 情形 2:LL
                setColor(parentOf(parentOf(n)), RED);
                rotateRight(parentOf(parentOf(n)));
            }
        } else { /* 右子树镜像:RR / RL,代码对称 */ }
    }
    root.color = BLACK;                                // 性质 2
}

一句话:插入最多两次旋转、变色均摊常数——"平衡的代价"就是靠这个被压到极小的。

5. 删除为什么难一个量级

删掉黑节点会破坏性质 4。为了仍用一个颜色位表达状态,就把"缺一个黑"的债务记在孩子身上,称双黑;修复就是向上借或与兄弟合并。

情形 条件 动作
删红叶子 直接摘 不影响黑高,无需修复
兄弟为红 兄弟红 旋转变色,转成兄弟为黑
兄弟黑且两子皆黑 借不到 兄弟染红,债务上移
兄弟黑且有一红子 可借 旋转变色,债务清零
// 先按 BST 摘下(有两个孩子时改删后继),再统一修复
private void fixAfterDelete(Node x) {
    while (x != root && colorOf(x) == BLACK) {
        Node s = rightOf(parentOf(x));                  // 兄弟
        if (colorOf(s) == RED) {                        // 兄弟红 → 转成黑兄弟
            setColor(s, BLACK); setColor(parentOf(x), RED);
            rotateLeft(parentOf(x)); s = rightOf(parentOf(x));
        }
        if (colorOf(leftOf(s)) == BLACK && colorOf(rightOf(s)) == BLACK) {
            setColor(s, RED); x = parentOf(x);           // 合并,债务上移
        } else {
            if (colorOf(rightOf(s)) == BLACK) {          // 掰直
                setColor(leftOf(s), BLACK); setColor(s, RED); rotateRight(s);
                s = rightOf(parentOf(x));
            }
            setColor(s, colorOf(parentOf(x)));           // 结算
            setColor(parentOf(x), BLACK); setColor(rightOf(s), BLACK);
            rotateLeft(parentOf(x)); x = root;
        }
    }
    setColor(x, BLACK);
}

插入只有 3 类情形,删除有 4–6 类且互相嵌套,双黑还可能上移多层。所以实现普遍选"两段式":先摘节点,再做一次与插入对称的修复。

6. AVL 对照:谁的代价更小

维度 红黑树 AVL 树
平衡严格度 弱,最长 ≤ 2×最短 强,左右高差 ≤ 1
树高常量 约 2*log2(n+1) 约 1.44*log2(n)
查询代价 略高 略低
插入旋转 最多 2 次 可能沿路径多次
删除旋转 最多 3 次 最坏 O(log n) 次
额外维护 一个颜色位 高度或平衡因子
适用场景 写频繁、增删为主的内存索引 读远多于写、查询延迟敏感

差别是常数倍而非量级;决定选型的是旋转次数上界与实现复杂度。

7. TreeMap / TreeSet 与 HashMap 树化

TreeMap 底层是红黑树,Entry 带 color 与 parent。不选 AVL:中序遍历天然有序,subMap/floorKey/ceilingKey 都是 O(log n),旋转次数有常数上界。迭代器按中序推进,只有结构性修改才失效,遍历顺序也不随扩容变化(HashMap 的顺序会随 resize 改变)。

TreeMap<Integer, String> tm = new TreeMap<>();
tm.put(50, "a"); tm.put(20, "b"); tm.put(80, "c");
tm.floorKey(60);                  // 50,≤60 的最大键
tm.ceilingKey(60);                // 80,≥60 的最小键
tm.subMap(20, true, 80, false);   // 区间视图,随原表变化,不复制元素
// 只做一次性排序时用 ArrayList + Collections.sort(),比 TreeMap 便宜

HashMap 的桶默认是链表,长度到 8 且容量 ≥ 64 才树化,降到 6 退回链表。

参数 值 机制
TREEIFY_THRESHOLD 8 哈希良好(λ≈0.5)时长到 8 的概率已极低,只在分布异常时树化
UNTREEIFY_THRESHOLD 6 与 8 之间留滞后区间,避免在 7 附近来回抖动
MIN_TREEIFY_CAPACITY 64 短链表多因表太小,先扩容
TreeNode 继承链表结构 多 parent/left/right/red,内存远大于 Node,退化只需拆树关系
if (binCount >= TREEIFY_THRESHOLD - 1) treeifyBin(tab, hash);  // 内部再判容量 < 64 则 resize()

树化是给极端碰撞兜底的安全网,不是默认路径。

8. 复杂度汇总

结构 查找平均 查找最坏 插入最坏 删除最坏
退化 BST O(n) O(n) O(n) O(n)
红黑树 / TreeMap、AVL O(log n) O(log n) O(log n) O(log n)
跳表(Redis ZSET) O(log n) 期望 同左 同左 同左
B+ 树(InnoDB) O(log n) O(log n) O(log n) O(log n)

后两行按各自口径读:ZSET 是概率期望;InnoDB 的代价主要是随机磁盘 IO 次数,不是内存指针跳转。

9. 排错

现象 原因 处理
有序主键写入后查询变慢 自写 BST 退化 换 TreeMap 或补平衡
迭代时抛 ConcurrentModificationException 迭代中发生结构性修改 用 Iterator.remove(),或先复制键集
TreeMap 报 ClassCastException 键未实现 Comparable,也没传 Comparator 传比较器
某桶链表很长,树化没救 hashCode() 分布差 改哈希实现,树化只是兜底
TreeMap 做无序缓存,吞吐明显下降 为不需要的有序性付了 O(log n) 与对象头开销 换 HashMap

ConcurrentModificationException 与红黑树无关,是迭代器的 modCount 校验。

10. 边界

场景 首选 理由
无序 KV、追吞吐 HashMap O(1) 平均,无旋转与颜色位
有序 + 范围查询 + 动态增删 TreeMap 区间视图与邻键查询是哈希表做不到的
读极多写极少 AVL / 静态排序数组 树更矮或连续访问,缓存友好
一次性排序后只查不改 ArrayList + Collections.sort() 排序一次,之后二分 O(log n)
并发有序 ConcurrentSkipListMap 跳表无旋转;TreeMap 非线程安全
超大表、磁盘为主 B+ 树 层数少、每层多键,减少随机 IO

两个越界:TreeMap 不是"最优平衡树",优化目标是维护成本而非最小树高;它也不线程安全,并发写会读到中间状态。

一句话收束:红黑树是"最坏情况可证 + 维护成本极小"的工程折中,不是平衡度上的最优解。

11. 自测

  1. 按升序插入 n 个键,普通 BST 的树高是多少?此时什么会先于性能暴露出来?
  2. 性质 3 与性质 4 各限制什么?为什么只靠其中一条推不出树高上界?
  3. 翻译成 2-3-4 树时,红链接代表什么?黑高对应哪个量?
  4. "叔叔为红"与"叔叔为黑"分别做什么?为什么前者会向上传播?
  5. 删除中的双黑是什么?为什么实现普遍选择先摘节点再重平衡?
  6. HashMap 树化阈值为什么是 8,退化阈值为什么是 6 而不是 7?
S03-X02 · 红黑树与 2-3-4 树:平衡的代价与收益
https://www.clxhxhhr.top/posts/5768/
作者
clxstart
发布于
2024-12-31
许可协议
CC BY-NC-SA 4.0
评论
0 条
还没有评论,先写一条吧。