红黑树的实现--红黑树的概念、规则、效率、结构、旋转、查找、验证

红黑树(Red-Black Tree)深入解析 ------ 从手写笔记到可编码

摘要

本文以"为什么需要一种比 AVL 更稳的平衡树"为起点,完整梳理红黑树的五条性质(每节点非红即黑、根必黑、红节点子节点必黑、同一路径黑节点数相等即黑高、叶哨兵为黑);再用"最短路径 vs 最长路径"的论证说明红黑树高度恒不超过 2·log₂(N+1),从而查找/插入/删除都是 O(log n);核心部分逐一拆解插入后的五种违规场景(父黑直接停、父红叔红变色、父红叔黑 zig 旋转、zig-zag 双旋转)与对应的"变色 / 单旋 / 双旋"修复动作,配 C++ 修复代码;删除部分给出操作骨架(删除黑节点必引起违规、替换/旋转两大类情形)。最后提供可运行的红黑树合法性验证代码与易错点清单。


本篇基于我的红黑树手抄笔记整理(1.1 五条性质 + 1.2 最长≤2×最短的论证 + 1.3 时间效率推导 + 2.1 插入修复策略 + 2.2 各场景图解 + 2.5 验证代码),并补全了笔记没写全的旋转指针操作、删除流程骨架与易错点。目标是:看完能独立写出插入修复代码、能讲清五条性质与高度上界的推导、能用验证代码校验自己手画的树。

说明:标注 【笔记】 的是手抄笔记里直接出现的内容;标注 【补充】 的是笔记留白、需要补上才能闭环的部分。个别手写较潦草处标 (待核对原图)。


一、什么是红黑树:五条性质

【笔记 1.1】

红色节点和黑色节点是它的标志。红黑树是对普通 BST 加上颜色约束得到的自平衡二叉搜索树 ------ 每个节点带一个颜色(红/黑),并满足:

# 性质 说明
1 每个节点必须是红色或黑色 没有第三种颜色
2 根节点必须是黑色 根不可能是红
3 红节点的子节点必须是黑色 即不允许两个红节点直接相连(红不能生红)
4 从任一节点到它所有后代叶节点的路径上,黑色节点数量相同 这个数量叫黑高(Black Height)
5 叶节点(NIL 哨兵)是黑色 【补充】笔记未单列,但性质 4 成立的前提是把 NIL 视为黑哨兵

合法 vs 不合法示例【笔记】:

复制代码
合法 RBTree(黑高=3,含根):        不合法(两条路径黑数不同):
        B                                    B
       / \                                  / \
      B   R                                B   B
     / \ / \                              / \
    R  B R  B     黑高=3                  B   R   ← 右路径黑=2
                                                 \
                                                  B   ← 这条路径黑=3,违规

【补充】三条直觉,帮记忆前四条性质:

  • 性质 2 + 3 联合保证:路径上红节点不可能连续,红节点只起"加高不加黑"的作用。
  • 性质 4 是灵魂:它让"黑高"成为全树唯一的量,下面第二节就靠它证出 O(log n)。
  • 红黑树不要求左右子树高度差 ≤ 1 (那是 AVL),它只约束黑高相等,所以结构比 AVL 宽松,插入/删除平均只需常数次变色,最多 2 次旋转(删除最多 3 次)。

二、为什么"最长路径 ≤ 2 × 最短路径"

【笔记 1.2】------ 这是红黑树高度 O(log n) 的直观版论证。

设某子树的黑高为 B(从该子树根到任意叶的路径上黑色节点数,含根,不含 NIL 哨兵时 B 为"到叶"的黑数;含叶时路径黑数 = B+1,全文统一一种约定即可,论证不变)。

  • 最短路径:一条路径上不出现任何红节点(全黑节点直连),长度 = B。

  • 最长路径 :受性质 3 约束,红黑最多红黑交替出现,所以黑高为 B 时,最长路径长度 ≤ 2B。

    最短(全黑): 最长(红黑交替):
    B B
    | /
    B R R (红节点的子必为黑,
    | / \ / \ 交替往下)
    B B B B B
    长度 = B 长度 = 2B

结论:最长路径 ≤ 2 × 最短路径。

【补充】把上面翻成节点规模语言就是第三节的 O(log n):全黑路径有 B 个节点,最矮的"2B 长全交替"树在 2B 深度最少能容纳多少节点?------ 2^(B−1) 量级,反过来 N ≥ 2^((h−1)/2),所以 h ≤ 2·log₂(N+1)。


三、时间效率:查找/插入/删除都是 O(log n)

【笔记 1.3】------ 高度上界的严格推导(笔记里用了 n₁/n₂ 记法,这里还原并补全):

设树高 h,最长路径(红黑交替)中红色节点数 n₁、黑色节点数 n₂:

  • 最短路径长 = n₂(全黑);

  • 最长路径长 h = n₁ + n₂ ≤ 2n₂(红黑交替时 n₁ ≤ n₂);

  • 节点总数下界:黑高 n₂ 决定,按"每个黑色节点都尽量长红尾"的最少节点树(斐波那契式增长),可推得 N ≥ 2^(n₂−1),故 n₂ ≤ log₂N + 1;

  • 代回:h ≤ 2n₂ ≤ 2(log₂N + 1) = 2·log₂(N+1)。

    ∴ h = O(log N)
    ∴ 查找 O(log N)、插入 O(log N)、删除 O(log N)
    (插入修复最多 2 次旋转 + O(log N) 次向上回溯;
    删除修复最多 3 次旋转 + O(log N) 次回溯)

【补充】和红黑树配套的两个事实:

  • C++ STL 的 std::map / std::set 底层就是红黑树(GCC/libstdc++ 实现);
  • 与 AVL 的取舍(详见第八节对比):读多写少选 AVL,写频繁选红黑树。

四、旋转操作:左旋 / 右旋的指针级细节

【补充】笔记 2.2 各场景图解只画了结构变化,没写指针代码,这里补全。旋转仍是"不改变中序遍历序列"的原子操作(和 AVL 篇同一套),红黑树特有的易漏点还是接回祖父/根:

cpp 复制代码
// 右旋:以 p 为顶点,p 的左子 x 上位
void RotateRight(Node* p) {
    Node* x = p->left;              // x 上位
    p->left = x->right;            // x 的右子 t 挂到 p 的左
    if (x->right) x->right->parent = p;
    x->right = p;
    p->parent = x;
    Node* g = x->parent;           // 接回祖父(易漏步骤):
    if (g == nullptr) root = x;    // p 是根 → x 成新根
    else { if (g->left == p) g->left = x; else g->right = x; }
}

// 左旋:镜像,p 的右子 x 上位
void RotateLeft(Node* p) {
    Node* x = p->right;
    p->right = x->left;
    if (x->left) x->left->parent = p;
    x->left = p;
    p->parent = x;
    Node* g = x->parent;
    if (g == nullptr) root = x;
    else { if (g->left == p) g->left = x; else g->right = x; }
}

验证方法:旋转前后各打一遍中序遍历,序列必须完全一致。


五、插入修复:五种情形(核心)

【笔记 2.1】

约定 :新节点一律按红色插入(插红最宽容 ------ 只会违反性质 3 红红相邻,且不动黑高;CLRS 采用"插黑"约定,等价但修复略繁,本文全按"插红")。

插入后违规只可能出现在"新节点 → 根"这条路径上,沿路径看 父节点 / 叔叔节点(父的兄弟) 的颜色,落入下面五种情形之一:

情形 0:父节点是黑

直接结束,没有任何性质被破坏。

情形 1:父红、叔红 ------ 变色(Color Flip)

【笔记】"父节点、叔节点变黑,祖父节点变红(Color Flip)。然后继续向上检查。"

复制代码
        g(B)                 g(R)
       / \                  / \
    p(R)  u(R)     →     p(B)  u(B)
    |                  |
   x(R)               x(R)        把矛盾上推给 g(g 现在红了,
                                      若 g 的父也是红,下一轮再判)
  • 操作:父、叔 → 黑;祖父 → 红;x 上移到祖父位置继续检查。
  • 不改黑高(路径黑数不变),最多 O(log n) 次上移,终止于根。

情形 2:父红、叔黑、同侧(Zig-Zig)

新节点和父在祖父的同侧 (同左或同右)。对祖父单旋:

复制代码
        g(B)                 p
       /                   / \
    p(R)        →        x(R) g(R)
    /                       \
  x(R)                      (g 变红)
  • 操作:父 → 黑,祖父 → 红,对祖父做与"侧"相反的旋转(左侧失衡右旋,右侧失衡左旋)。
  • 旋转后 g 变红,若 g 的父也是红 → 矛盾再次上推(循环里 x = g 继续)。

情形 3:父红、叔黑、异侧(Zig-Zag)

新节点和父在祖父的异侧(折线)。两次旋转,一次到位:

复制代码
        g(B)
       /
    p(R)
       \
       x(R)

  第一步:对 p 左旋(折线掰直)     第二步:对 x 右旋(完成)
        g(B)                           g(B)
         \                              /
         x(R)                →        p(B)
        / \                        / \
      p(R) ...                   x(R) ...
  • 操作:父 → 黑(保持黑色在顶部),祖父不动,先对父旋一次掰直、再对 x 旋一次;x 留在父下面(红挂黑下,合法)。
  • 旋转后该子树完全修复,无需再上移。

五种情形一张表

情形 父 叔 侧 动作 旋转次数
0 黑 - - 不处理 0
1 红 红 - 父叔变黑、祖父变红;x 上移到祖父 0(纯变色)
2 红 黑 同侧 父变黑、祖父变红;对祖父单旋 1
3 红 黑 异侧 父变黑;先对父旋、再对 x 旋 2
(右半侧所有情形为左半侧的镜像)

【笔记】2.1 列出的 5 种修复情况、2.2.1~2.2.4 的场景图解(按颜色模式标注"黑 / 黑→黑 / 黑红→黑黑 / 红红→黑黑")与上面情形对应,具体标注映射关系待核对原图。

修复循环(C++,等价于 OpenJDK 的 TreeMap.fixUp 变体)

cpp 复制代码
// x = 刚插入的红节点(非根)
void InsertFix(Node* x) {
    while (x->parent && x->parent->color == RED) {     // 父黑即停
        Node* p = x->parent;
        Node* g = p->parent;
        Node* u = (g->left == p) ? g->right : g->left; // 叔叔
        if (u && u->color == RED) {                     // 情形1:叔红,变色上推
            p->color = BLACK; u->color = BLACK; g->color = RED;
            x = g;                                       // 到祖父位置再判
        } else if (x == p->left && p == g->left) {      // 情形2:zig-zig 同侧
            p->color = BLACK; g->color = RED;
            RotateRight(g);
            x = g;                                       // g 变红,可能再矛盾
        } else if (x == p->right && p == g->right) {    // 情形2 镜像
            p->color = BLACK; g->color = RED;
            RotateLeft(g);
            x = g;
        } else {                                          // 情形3:zig-zag 异侧
            p->color = BLACK;                            // 黑色留在顶点
            if (p == g->left) { RotateLeft(p);  RotateRight(x); }
            else            { RotateRight(p); RotateLeft(x); }
            break;                                       // 子树已修好
        }
    }
    root->color = BLACK;                                  // 根必须黑
}

【补充】复杂度账:每轮循环要么上移 2 层(情形 1/2)要么原地修复(情形 3),所以插入修复 = O(log n) 次上移 + 最多 2 次旋转 ------ 这就是第三节说"插入 O(log n)"的出处。


六、删除:黑节点被删才会破规矩

【补充】笔记 2.1 聚焦插入,删除只有场景标注(2.2.2~2.2.4),未展开流程,这里补骨架。

关键观察 :新节点按红色插入,所以删红节点(或普通 BST 删除的替补仍是红)不会破任何性质,直接 BST 删除即可 。只有当"被删节点(或其替补 x)是黑色"时,该子树黑高 −1,出现"x 位置少 1 个黑"(黑缺陷),需要修复。

修复分两大类(CLRS 记法,W 为 x 的兄弟):

类型 子情形 动作
替换 x 是某节点的叶(双黑子之一) 把该父节点置黑(吸收多余的黑)
替换 x 的父是叶 父变黑并删除它
替换 其他 先对父做旋转(把 x 变成叶情形),再替换
旋转 W 红 W 变黑、父变红,左右子对调(转成 W 黑的情形)
旋转 W 与 W 的两个子全黑 W 变红;黑缺陷上移到父(循环继续)
旋转 内子红 内子变黑、W 变红,对父旋转
旋转 外子红 W 取父+外子的黑(外子丢色),父留 x 的黑,旋转,x 成双黑叶 → 替换

口诀:"W 红则换色对调;W 黑子黑则 W 变红上推;内红外黑则内变黑 W 变红再旋;外红则合并黑再旋。" 每轮或上移 1 层、或彻底修复,故删除修复 O(log n),最多 3 次旋转。

【补充】实用技巧:把"黑缺陷"看成贴在 x 上的一颗假黑球 (x 本身颜色不动),旋转/变色时这颗球跟着 x 走,比直接改 x 的颜色不容易写乱 ------ STL 实现(SGI stl_rb_tree 的 _rb_rebalance_delete)就是这个思路。


七、红黑树合法性验证(笔记 2.5)

【笔记】右下角验证伪代码意图:check(root, blackH, curH, err) ------ 遍历时红节点查子节点颜色,黑节点累计黑高,到底比较各路径黑高是否一致。笔记代码有断句,下面按意图重写为可编译版本:

cpp 复制代码
#include <iostream>
#include <cassert>

struct Node {
    int key;
    char color;            // 'R' / 'B'
    Node* left; Node* right; Node* parent;
    Node(int k, char c) : key(k), color(c), left(nullptr), right(nullptr), parent(nullptr) {}
};

// 返回该子树路径上的黑节点数(不含叶哨兵);非法时置 err 并返回 0
int Check(Node* n, bool& err) {
    if (n == nullptr) return 0;                      // 叶哨兵 NIL 视为黑,贡献 +1
    int blackCount = (n->color == 'B') ? 1 : 0;

    if (n->color == 'R' &&                            // 性质3:红节点下不能挂红
        ((n->left && n->left->color == 'R') ||
         (n->right && n->right->color == 'R'))) {
        err = true;
        return 0;
    }
    int lb = Check(n->left, err);
    int rb = Check(n->right, err);
    if (lb != rb) { err = true; return 0; }          // 性质4:左右子树黑高必须相等
    return blackCount + lb;
}

bool IsValidRBTree(Node* root) {
    bool err = false;
    if (root == nullptr) return true;
    if (root->color != 'B') { err = true; }          // 性质2:根必须黑
    Check(root, err);
    return !err;
}

int main() {
    // 合法:根黑,左子黑、右子红(红挂黑下,路径黑高一致)
    Node* a = new Node(2, 'B');
    a->left = new Node(1, 'B'); a->left->parent = a;
    a->right = new Node(3, 'R'); a->right->parent = a;
    assert(IsValidRBTree(a));

    // 不合法:红节点下挂红
    Node* b = new Node(2, 'B');
    b->left = new Node(1, 'R'); b->left->parent = b;
    b->left->left = new Node(0, 'R'); b->left->left->parent = b->left;
    assert(!IsValidRBTree(b));
}

【补充】std::map 红黑树是无颜色标记的隐式编码 (用父/左/右指针与根、兄弟的关系反推颜色),所以本文验证器只适用于显式颜色的实现(OpenJDK TreeMap、手写教学实现等)。


八、易错点与常见误区

【补充】把笔记里没写、但编码时反复踩的坑列出来:

  1. 新节点颜色约定不统一:本文全按"插红"(CLRS 是"插黑")。混用两种约定会让修复代码全错 ------ 动手前先定死一种。
  2. 旋转后忘记接回祖父/根:和 AVL 篇同一坑,不接回则树断。见第四节代码里的"易漏步骤"。
  3. 根必须黑 :循环可能把矛盾上推到根,root->color = BLACK 一行不能省(它永远合法 ------ 根黑不产生黑缺陷)。
  4. 叔叔为空(NIL) :NIL 哨兵视为黑色。所以"叔黑"实际包含"叔不存在",判定时 u && u->color == RED 里那个 u && 不能丢。
  5. zig-zag 旋转方向写反 :异侧时先对父旋(掰直),再对 x 旋,且顶点变黑的是父不是 x ------ 把"黑色留在最上面"记死。
  6. 删除时以为删谁修谁 :实际修的是替补 x 的黑缺陷;直接删的是带一个孩子的节点时,孩子替上来,缺陷在父侧。
  7. 验证代码只查了"红生红"没查黑高:笔记 2.5 的两个检查(红子黑 + 路径黑数一致)缺一不可,单查一个会漏掉大量非法树。
  8. 把红黑树当成 AVL:红黑树不保证左右子树高差 ≤ 1,只保证最长 ≤ 2 × 最短。别用 AVL 的平衡因子去"修"红黑树。
  9. 中序遍历必须不变:写旋转前后各打一遍中序,序列一致才算指针接对(第四节末尾的验证方法)。

九、红黑树 vs AVL:什么时候选谁

【补充】笔记 1.3 只给了效率结论,补一张选型对比:

维度 AVL 红黑树
平衡约束 任意节点 |bf| ≤ 1(严格) 最长路径 ≤ 2 × 最短路径(宽松)
树高(n 节点) ≈ 1.44·log₂(n+2)(更矮) ≤ 2·log₂(n+1)
插入修复 最多 1 次旋转 最多 2 次旋转 + O(log n) 次上移变色
删除修复 可能 O(log n) 次旋转 最多 3 次旋转
查询延迟 略优(更矮) 略逊
更新开销 略高 更稳(平均常数次变色)
典型场景 读多写少(数据库索引、只读字典) 写频繁(Linux CFS 调度器、JVM TreeMap、STL map/set)

一句话:读多选 AVL,写多选红黑树;面试能说出"2 倍路径界 vs 严格平衡因子"的区别就够。


十、知识总结

10.1 核心要点

  • 五条性质:非红即黑 / 根黑 / 红不挂红 / 路径黑数相等(黑高)/ NIL 黑。
  • 高度上界 h ≤ 2·log₂(N+1) ⇒ 查找/插入/删除均 O(log N)。
  • 插入:插红,五种情形(父黑 / 叔红变色 / zig-zig / zig-zag),最多 2 旋。
  • 删除:删黑才破规矩,替换 + 旋转两大类,最多 3 旋。
  • 选型:读多 AVL,写多红黑树。

10.2 记忆口诀

根必黑,红无子,黑高处处同;

最长不超两倍短,O(log n) 稳如山;

插入全按红来进,父叔皆红变色翻;

同侧一旋黑顶替,异侧两旋折掰直;

删红无事删黑修,黑缺陷球跟着走。

10.3 关键对比表

概念 定义 特点
黑高 任一节点到叶路径上的黑节点数 全树唯一,O(log n) 的来源
红节点 颜色为红 不能连红,只起加高作用
变色(Flip) 父叔变黑、祖父变红 不改黑高,上移 2 层
zig-zig 父与新节点同侧 单旋 + 变色
zig-zag 父与新节点异侧 双旋
黑缺陷 删黑后某路径少 1 黑 用"假黑球"模型跟 x 走

十一、复习挑战清单

  • 默写红黑树五条性质,并解释"红不挂红 + 黑高相等"如何推出 h ≤ 2·log₂(N+1)
  • 给出一棵黑高=3 的合法红黑树和一棵违反性质 4 的非法树(对照第二节图)
  • 手写右旋/左旋的指针代码,说明"接回祖父/根"为什么不能省
  • 五种插入情形各画一个 ASCII 示例,并标注旋转次数
  • 解释 zig-zig 旋转后为什么祖父变红、矛盾可能继续上推
  • 解释 zig-zag 为什么需要两次旋转、为什么顶点变黑的是父
  • 手写 InsertFix 完整循环(含根变黑收尾)
  • 说明删除为什么"删红无事、删黑才修",黑缺陷模型怎么用
  • 手写 IsValidRBTree 验证函数,并用合法/非法各一棵树自测
  • 说出红黑树 vs AVL 的树高与旋转次数差异,以及各自适用场景

十二、延伸阅读

  • CLRS《算法导论》第 13 章(红黑树,采用"插黑"约定,对照本文"插红"看差异)
  • OpenJDK java.util.TreeMap 的 rotateLeft/rotateRight/fixUp 源码(显式颜色的工业级实现)
  • SGI STL stl_rb_tree.h(隐式颜色编码,看 _rb_rebalance_insert/_rb_rebalance_delete)
  • Linux CFS 调度器的红黑树(红黑树在操作系统内核里的真实用途)
  • 关联篇:AVL 树系统梳理、AVL 树与 std::map 实现
  • C++ 标准库 std::map / std::set 的迭代器失效规则(红黑树的工程后果)

参考代码索引

文件 内容
rbtree_node.h Node 结构(key + 颜色 + 三指针)
rbtree_rotate.cpp 第四节 左旋/右旋(含接回祖父/根)
rbtree_insert_fix.cpp 第五节 InsertFix 五情形完整循环
rbtree_delete_fix.cpp 第六节 删除修复(替换 + 旋转,黑缺陷模型)
rbtree_validate.cpp 第七节 IsValidRBTree 验证器
rbtree_demo.cpp 随机插入 1000 个键,边插边跑 IsValidRBTree 断言

任何的批评与建议欢迎指出,我们共同进步!

相关推荐
程序员南飞1 小时前
win系统安装minio
java·数据结构·算法
青少儿编程课堂1 小时前
AC自动机多模式串匹配精讲:校园关键词巡检实战解析
c++·python·算法·bfs·信息学竞赛
longlongzihan2 小时前
LeetCode 5. 最长回文子串:从暴力匹配到中心扩展的思维演进
c++·算法·leetcode
waoooqwe2 小时前
问卷样本真实吗
大数据·数据库·算法·数据分析
苦瓜打怪兽2 小时前
leetcode 热题100(11. 盛最多水的容器)
python·算法·leetcode
多弗朗皮卡丘2 小时前
C++继承
c++·继承
汉克老师3 小时前
GESP2026年9月认证C++七级( 第三部分编程题(2、括号序列))精讲
c++·gesp·小学生·学c++编程
w_zero_one3 小时前
链表(4)
java·数据结构·算法
吠品3 小时前
GitHub 周榜拆解:决策小模型扎堆上位
c语言·开发语言·算法