0.1 概述&序言
这里是白杨。上一篇 AVL 用"高度差 ≤ 1"把树压得很平,代价是插入、删除时旋转频繁。这一篇换一条路:不直接管高度,用颜色约束间接控制黑高------这就是红黑树。
先给结论:红黑树没有 AVL 那么"平",但它把平衡维护的成本降下来了。10 万随机插入,AVL 单旋 69765 次,红黑树 58391 次;10 万有序建树后各做 2000 次查找,0.564ms 对 0.550ms,同一档。它不是"更快",是"综合更省"。
这篇只讲红黑树本身:四条规则、最长路径证明、结点结构、插入的三种情况、四规则验证和实测。删除不在本篇范围;用红黑树封装 mymap/myset 是下一讲的事,这里不抢跑。
老规矩,代码全部 g++ 实测,完整工程文末自取,也可以直接去仓库:gitee.com/BaiYang_SQ/rbtree-sim。
实测环境:w64devkit g++ 15.2.0(
-std=c++11 -O2 -Wall -Wextra)+ VS2022;教学简化版。标准容器参考:cppreference·map(STL 的 map/set 底层就是红黑树)。
让我们开始吧。
一、四条规则:红黑树到底约束了什么
红黑树本质上是一棵二叉搜索树,只是给每个结点加了一个颜色位:红或黑。

四条规则:
- 每个结点非红即黑;
- 根结点必须是黑色;
- 红结点的两个孩子必须是黑色------任意路径不能有连续红结点;
- 任一结点到它所有 NIL 空结点的路径上,黑结点数量相同。
规则 4 里的 NIL 不是我们平时说的"叶子",而是空指针位置。有些书把它叫外部结点,并且规定 NIL 也是黑色。实现里通常不真的存 NIL 结点,但验证黑高时要把它们算进去。
这四条里,规则 2、3、4 是核心。规则 1 用枚举类型天然保证,规则 2 检查根,规则 3 检查"红结点的父亲不能是红",规则 4 要数黑结点。
图里这棵树有 9 条根到 NIL 的路径,每条都经过 2 个黑结点。如果把 10 从黑改成红,18 → 10 → NIL 就只剩 1 个黑结点,规则 4 当场失效------颜色看着没大问题,黑高已经不对了。
【一句话记】红黑树不是靠平衡因子,而是靠"根黑、无红红、黑高相等"这三条约束,间接把树压在对数高度。
二、最长路径为什么不超过最短的 2 倍
把根到 NIL 的路径按黑结点个数来数,记作黑高 bh。

- 规则 4 保证每条路径黑结点数一样多。最短路径可以全是黑结点,长度就是
bh。 - 规则 2、3 保证不能有连续红结点。最长路径只能一黑一红交替,长度最多
2bh。
所以任意一条根到 NIL 的路径长度 h 满足:
text
bh ≤ h ≤ 2bh
最长最多是最短的两倍,树高仍然是 O(logN)。这也是"弱平衡"的来源:AVL 要求高度差 ≤ 1,红黑树只要求路径长度不超过两倍。
换成分结点数的不等式:黑高为 bh 的红黑树,最少结点数约 2^bh - 1,最多是满的 2^(2bh) - 1;两边取对数,bh 仍是 O(logN)。所以红黑树不需要存高度或平衡因子,靠颜色就能把最坏情况压住。
【面试】红黑树怎么保证 O(logN)?------黑高相等 + 不能连续红,推出最长路径 ≤ 2×最短路径,高度对数级。
三、结点结构:颜色、父指针、KV
红黑树结点比普通 BST 多一个颜色和一个父指针。
cpp
enum Colour
{
RED,
BLACK
};
template <class K, class V>
struct RBTreeNode
{
pair<K, V> _kv;
RBTreeNode<K, V>* _left;
RBTreeNode<K, V>* _right;
RBTreeNode<K, V>* _parent; // 向上处理红红相连
Colour _col;
RBTreeNode(const pair<K, V>& kv)
: _kv(kv), _left(nullptr), _right(nullptr), _parent(nullptr), _col(RED)
{}
};
_parent 不能省:插入后如果出现红红相连,要沿着父指针找叔叔、祖父,再决定变色还是旋转。_col 默认给红色,正好对应"非空插入先染红"的策略。
颜色本身用一个 enum 存,额外空间主要来自 _parent 指针。和 AVL 的 _bf 比,红黑树不存高度差,颜色就是它的"平衡信息"。
四、插入总览:新结点为什么一定是红色
插入分两步:先按 BST 规则挂到空位,再检查红红相连。

- 空树插入:新结点直接当根,染黑。
- 非空树插入:新结点一律先染红。因为插黑一定会让某条路径黑高 +1,规则 4 很难修复;插红只可能违反规则 3,处理起来有章法。
- 父亲是黑色:没有红红相连,直接结束。
- 父亲是红色:违反规则 3。此时
c红、p红,g必然是黑(否则早就红红相连了),关键看叔叔u。

叔叔只有两种大情况:
| 叔叔 | 处理 | 是否旋转 |
|---|---|---|
u 存在且为红 |
p、u 变黑,g 变红,g 当新的 c 继续向上 |
不旋转 |
u 不存在 / u 为黑 |
看 p、c 是否同侧:同侧单旋,异侧双旋 |
旋转 + 变色 |
判断顺序不能反:先看叔叔红不红;叔叔红就只变色,叔叔黑/空才进入旋转分支。叔叔红时 p、c 同侧还是异侧都无所谓,处理完全一样。
【必背】空树根黑;非空新结点红;父黑结束;父红看叔叔。
【易错】非空插入不要染黑------黑高被破坏后,不是一次变色能救回来的。
五、叔叔为红:只变色,继续向上
p 和 u 都是红,g 是黑。把 p、u 变黑,g 变红:
cpp
parent->_col = uncle->_col = BLACK;
grandfather->_col = RED;
cur = grandfather;
parent = cur->_parent;
为什么这样行?p、u 两边各多一个黑结点,g 变红少一个黑结点,g 这棵子树的黑高不变 ;同时 c 和 p 的连续红被拆开了。但 g 变红后可能和它的父亲又红红相连,所以要把 g 当成新的 c 继续往上。
如果 g 就是根,循环结束后会统一把根染黑。
可以把变色理解成"把黑色从 g 往下分给 p 和 u":黑结点总数没变,只是位置换了;g 变红后如果父亲也是红,就继续把问题往上推,直到根或者父亲为黑色。
实测叔叔为红的最小用例 {10, 5, 15, 3}:
text
插入 3:
新结点 3 为红色,挂到 5 左边
叔叔 15 为红:p/u 变黑,g=10 变红,继续向上
根 10 染黑
当前树: 10(B)[5(B)[3(R), NIL], 15(B)]
高度=3,Size=4,平衡=1,单旋=0,变色事件=2
【必背】叔红只变色,不旋转;g 变红后当新 c 继续向上,最后根染黑。
六、叔叔黑/空:单旋 + 变色
叔叔不存在,或者叔叔存在但为黑,单靠变色解决不了连续红,必须旋转。
先看 p 是 g 的左孩子、c 是 p 的左孩子,也就是 LL:

以 g 为轴右单旋,然后:
cpp
RotateR(grandfather);
parent->_col = BLACK; // p 变黑
grandfather->_col = RED; // g 变红
旋转的指针操作和 AVL 完全一样,只是不需要更新平衡因子:
cpp
// 右单旋的关键几步
Node* subL = parent->_left;
Node* subLR = subL->_right;
Node* parentParent = parent->_parent;
parent->_left = subLR;
if (subLR) subLR->_parent = parent;
subL->_right = parent;
parent->_parent = subL;
// 再把 subL 接到 parentParent(或更新 _root)
旋转后 p 成为子树根,染黑;g 变成 p 的孩子,染红。这样黑高不变,连续红也没了,而且 p 的父亲无论红黑都不会再违规,不需要继续向上。
为什么黑高不变?p 由红变黑、g 由黑变红,一进一出,子树黑结点数守恒;旋转只是把 p 提上来,把连续红拆开。颜色和指针各干各的,别混在一起。
RR 是镜像:p 是 g 的右孩子、c 是 p 的右孩子,以 g 为轴左单旋,p 黑、g 红。
实测 LL 最小用例 {10, 5, 3}:
text
叔叔黑/空:LL -> RotateR(10),p 黑 g 红,结束
当前树: 5(B)[3(R), 10(R)]
高度=2,Size=3,平衡=1,单旋=1,变色事件=1

【必背】p、c 同侧 → 单旋;LL 右单旋,RR 左单旋;p 黑、g 红,旋转后结束。
【易错】旋转本身不改颜色,颜色是旋转之后在调用处改的;别把变色写进 RotateR,否则双旋会乱。
七、叔叔黑/空:双旋 + 变色
p 是 g 的左孩子,c 是 p 的右孩子,也就是 LR。此时 p、c 异侧,单旋完还是不平衡,要两次旋转。

步骤:
- 先以
p为轴左单旋; - 再以
g为轴右单旋; c变黑,g变红,p保持红。
cpp
RotateL(parent);
RotateR(grandfather);
cur->_col = BLACK; // c 变黑
grandfather->_col = RED; // g 变红
RL 是镜像:p 是 g 的右孩子、c 是 p 的左孩子,先以 p 为轴右单旋,再以 g 为轴左单旋,c 黑、g 红。
第一次旋转把 c 提到 p 的位置,p 变成 c 的孩子;这时 p、c 的关系从"异侧"变成了"同侧",第二次旋转就退化成单旋。颜色最后一次性改:c 黑、g 红,p 保持红。
实测 LR 最小用例 {10, 5, 7}:
text
叔叔黑/空:LR -> RotateL(5) + RotateR(10),c 黑 g 红,结束
当前树: 7(B)[5(R), 10(R)]
高度=2,Size=3,平衡=1,单旋=2,变色事件=1
【必背】p、c 异侧 → 双旋;LR 先左旋 p 再右旋 g,RL 镜像;c 黑、g 红,p 保持红。
【易错】单旋和双旋的触发文字一模一样,唯一区别是 p、c 是否同侧。不要只看"叔叔黑"就动手,先看方向。
【面试】为什么双旋能成?第一次旋转把"折线"转成"直线",第二次旋转就退化成单旋;颜色最后由 c、g 收尾。
八、验证:四条规则缺一不可
只检查"最长路径 ≤ 2×最短路径"不靠谱:颜色可能已经错了,只是暂时没出问题,继续插入就会炸。正确做法是直接查四条规则。
cpp
bool IsBalanceTree() const
{
if (_root == nullptr) return true;
if (_root->_col != BLACK) return false; // 规则2
Node* prev = nullptr;
if (!_CheckOrder(_root, prev)) return false; // BST 有序
if (!_CheckParent(_root, nullptr)) return false; // parent 指针
int refBlack = 0;
Node* cur = _root;
while (cur)
{
if (cur->_col == BLACK) ++refBlack;
cur = cur->_left;
}
return _CheckColour(_root, 0, refBlack); // 规则3 + 规则4
}
_CheckColour 的核心:
cpp
if (root == nullptr) return blackNum == refNum;
if (root->_col == RED && root->_parent && root->_parent->_col == RED)
return false; // 规则3:红红相连
if (root->_col == BLACK) ++blackNum; // 规则4:累计黑结点
return _CheckColour(root->_left, blackNum, refNum)
&& _CheckColour(root->_right, blackNum, refNum);
两组经典序列实测:
text
--- 常规序列 ---
中序: 3 7 9 11 14 15 16 18 26
高度=4,Size=9,平衡=1,单旋=8,变色事件=9
--- 双旋序列 ---
中序: 1 2 3 4 5 6 7 14 15 16
高度=4,Size=10,平衡=1,单旋=1,变色事件=5
【易错】"颜色都合法"不等于"是红黑树"。某条路径黑高少一个,颜色看不出问题,但已经不是红黑树了。
比如根 18 黑,左 10 红(左空),右 30 黑、30 的左 25 黑:18 → 10 → NIL 只有 18 一个黑,18 → 30 → 25 有 18、30、25 三个黑,黑高不等,颜色再漂亮也不合格。
检查红红相连时,遍历到红结点反查父亲更省事:每个结点只有一个父亲,不用判断两个孩子是否存在。
九、实测:RB vs AVL vs BST
10 万个结点,全部按 1...N 有序插入:
text
RB:高度=31,Size=100000,平衡=1,单旋=99969,变色事件=199948,建树=9.8 ms
AVL:高度=17,Size=100000,平衡=1,单旋=99983,建树=7.4 ms
BST:高度=100000(有序插入退化成单链,与逐个插入结果一致)
2000 次随机查找:RB 命中 2000,耗时 0.550 ms
2000 次随机查找:AVL 命中 2000,耗时 0.564 ms
2000 次随机查找:BST 命中 2000,耗时 399.617 ms
红黑树高度 31,AVL 17,但查找耗时 0.550ms 对 0.564ms,基本打平。高度差一倍,查找只差几个微秒------因为它们都是 O(logN),常数才是关键。
10 万随机插入:
text
RB 随机插入 100000 个:高度=20,Size=100000,平衡=1,单旋=58391,变色事件=90348
AVL 随机插入 100000 个:高度=20,Size=100000,平衡=1,单旋=69765
再插一遍同样的 100000 个:失败 100000 次,Size 仍为 100000
随机数据下,红黑树单旋 58391 次,AVL 69765 次,红黑树确实更少;有序数据下两者几乎打平(99969 对 99983)。所以"红黑树旋转更少"不是绝对定律,得看数据分布------但它确实用更松的平衡换到了同一档查询性能。
普通 BST 的 399.617ms 也正常:单链平均要迈 5 万步,2000 次查找就是 1 亿次比较;红黑树 31 层,2000 次只有几万步,差距被拉到约 727 倍。
这也解释了 STL 的选择:map/set 既要查询稳定,又要插入删除别太贵,红黑树比 AVL 更均衡。纯查询、几乎不修改的场景,AVL 的 17 层会更香;读写都频繁,红黑树的 31 层也够用。
计时数字会随机器波动,树高和平衡结果是确定的。旋转次数按单旋统计,双旋记 2 次;变色次数按事件统计,一次可能改 2~3 个结点。普通 BST 的单链是直接构造的,结构和逐个有序插入完全一致,省掉 O(N²) 建树时间。
【一句话记】红黑树不是"查询更快",是"平衡维护更省,查询仍在同一档"。
十、速记卡
考前扫一眼,插入不转晕:

附录 A:完整代码仓库
完整工程(单文件版,全部实验一次跑完)在 Gitee:
| 仓库文件 | 说明 |
|---|---|
rbtree_sim.cpp |
红黑树结点、插入、旋转、四规则验证;附 AVL 对照计数 |
output.txt |
五个最小用例、两组序列、10 万 RB/AVL/BST 全部真实输出 |
build.bat |
Windows 一键编译 + 运行 + 刷新 output.txt |
README.md |
一分钟上手 + 代码导览 |
插入的分派就三句话:
cpp
if (uncle && uncle->_col == RED) // 叔红:变色,继续向上
{
parent->_col = uncle->_col = BLACK;
grandfather->_col = RED;
cur = grandfather;
parent = cur->_parent;
}
else if (cur == parent->_left) // 同侧:单旋 + p 黑 g 红
{
RotateR(grandfather);
parent->_col = BLACK;
grandfather->_col = RED;
break;
}
else // 异侧:双旋 + c 黑 g 红
{
RotateL(parent);
RotateR(grandfather);
cur->_col = BLACK;
grandfather->_col = RED;
break;
}
(上面是 parent 在 grandfather 左边的分支,右边完全镜像;完整版见仓库。)
好了,这一篇到此完结!下一篇继续进阶篇主线------用红黑树封装 mymap/myset :把颜色、旋转和迭代器包进一个能直接用的容器里。如果对你有帮助,不要忘记点赞三连一波哦,我是白杨,我们下期见。