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