文章目录
- [C++ 红黑树深度解析(上):插入为什么只看叔叔,旋转为何最多两次](#C++ 红黑树深度解析(上):插入为什么只看叔叔,旋转为何最多两次)
-
- [一、五条规则与"2 倍"的数学证明](#一、五条规则与"2 倍"的数学证明)
-
- [1.1 为什么最长路径不超过最短路径的 2 倍](#1.1 为什么最长路径不超过最短路径的 2 倍)
- [1.2 复杂度依然是 O(logN)](#1.2 复杂度依然是 O(logN))
- 二、新节点为什么默认红色
- 三、c/p/g/u:一次调整只看叔叔
-
- [3.1 第一个推理:只要进入调整,g 必为黑](#3.1 第一个推理:只要进入调整,g 必为黑)
- [3.2 第二个推理(硬核):u 存在且为黑时,c 一定不是新节点](#3.2 第二个推理(硬核):u 存在且为黑时,c 一定不是新节点)
- [3.3 一张决策表:三类情况全在这](#3.3 一张决策表:三类情况全在这)
- 四、三类情况逐个拆解
-
- [4.1 情况一:u 红 → 变色,问题向上传一层](#4.1 情况一:u 红 → 变色,问题向上传一层)
- [4.2 情况二:u 黑或不存在 + 同侧 → 单旋收场](#4.2 情况二:u 黑或不存在 + 同侧 → 单旋收场)
- [4.3 情况三:u 黑或不存在 + 异侧 → 双旋收场](#4.3 情况三:u 黑或不存在 + 异侧 → 双旋收场)
- [4.4 完整走一遍:插入序列 10、20、30、40、50、25](#4.4 完整走一遍:插入序列 10、20、30、40、50、25)
- 五、插入的完整实现
- [六、定量分析:旋转最多 2 次,变色却可能爬到根](#六、定量分析:旋转最多 2 次,变色却可能爬到根)
- 七、验证:逐条查规则,别信"最长≤2倍最短"
- [八、红黑树 vs AVL:一张表看清取舍](#八、红黑树 vs AVL:一张表看清取舍)
- 九、总结
- [C++ 红黑树深度解析(上):插入为什么只看叔叔,旋转为何最多两次](#C++ 红黑树深度解析(上):插入为什么只看叔叔,旋转为何最多两次)
-
- [一、五条规则与"2 倍"的数学证明](#一、五条规则与"2 倍"的数学证明)
-
- [1.1 为什么最长路径不超过最短路径的 2 倍](#1.1 为什么最长路径不超过最短路径的 2 倍)
- [1.2 复杂度依然是 O(logN)](#1.2 复杂度依然是 O(logN))
- 二、新节点为什么默认红色
- 三、c/p/g/u:一次调整只看叔叔
-
- [3.1 第一个推理:只要进入调整,g 必为黑](#3.1 第一个推理:只要进入调整,g 必为黑)
- [3.2 第二个推理(硬核):u 存在且为黑时,c 一定不是新节点](#3.2 第二个推理(硬核):u 存在且为黑时,c 一定不是新节点)
- [3.3 一张决策表:三类情况全在这](#3.3 一张决策表:三类情况全在这)
- 四、三类情况逐个拆解
-
- [4.1 情况一:u 红 → 变色,问题向上传一层](#4.1 情况一:u 红 → 变色,问题向上传一层)
- [4.2 情况二:u 黑或不存在 + 同侧 → 单旋收场](#4.2 情况二:u 黑或不存在 + 同侧 → 单旋收场)
- [4.3 情况三:u 黑或不存在 + 异侧 → 双旋收场](#4.3 情况三:u 黑或不存在 + 异侧 → 双旋收场)
- [4.4 完整走一遍:插入序列 10、20、30、40、50、25](#4.4 完整走一遍:插入序列 10、20、30、40、50、25)
- 五、插入的完整实现
- [六、定量分析:旋转最多 2 次,变色却可能爬到根](#六、定量分析:旋转最多 2 次,变色却可能爬到根)
- 七、验证:逐条查规则,别信"最长≤2倍最短"
- [八、红黑树 vs AVL:一张表看清取舍](#八、红黑树 vs AVL:一张表看清取舍)
- 九、总结
- [C++ 红黑树深度解析(上):插入为什么只看叔叔,旋转为何最多两次](#C++ 红黑树深度解析(上):插入为什么只看叔叔,旋转为何最多两次)
-
- [一、五条规则与"2 倍"的数学证明](#一、五条规则与"2 倍"的数学证明)
-
- [1.1 为什么最长路径不超过最短路径的 2 倍](#1.1 为什么最长路径不超过最短路径的 2 倍)
- [1.2 复杂度依然是 O(logN)](#1.2 复杂度依然是 O(logN))
- 二、新节点为什么默认红色
- 三、c/p/g/u:一次调整只看叔叔
-
- [3.1 第一个推理:只要进入调整,g 必为黑](#3.1 第一个推理:只要进入调整,g 必为黑)
- [3.2 第二个推理(硬核):u 存在且为黑时,c 一定不是新节点](#3.2 第二个推理(硬核):u 存在且为黑时,c 一定不是新节点)
- [3.3 一张决策表:三类情况全在这](#3.3 一张决策表:三类情况全在这)
- 四、三类情况逐个拆解
-
- [4.1 情况一:u 红 → 变色,问题向上传一层](#4.1 情况一:u 红 → 变色,问题向上传一层)
- [4.2 情况二:u 黑或不存在 + 同侧 → 单旋收场](#4.2 情况二:u 黑或不存在 + 同侧 → 单旋收场)
- [4.3 情况三:u 黑或不存在 + 异侧 → 双旋收场](#4.3 情况三:u 黑或不存在 + 异侧 → 双旋收场)
- [4.4 完整走一遍:插入序列 10、20、30、40、50、25](#4.4 完整走一遍:插入序列 10、20、30、40、50、25)
- 五、插入的完整实现
- [六、定量分析:旋转最多 2 次,变色却可能爬到根](#六、定量分析:旋转最多 2 次,变色却可能爬到根)
- 七、验证:逐条查规则,别信"最长≤2倍最短"
- [八、红黑树 vs AVL:一张表看清取舍](#八、红黑树 vs AVL:一张表看清取舍)
- 九、总结
- [C++ 红黑树深度解析(上):插入为什么只看叔叔,旋转为何最多两次](#C++ 红黑树深度解析(上):插入为什么只看叔叔,旋转为何最多两次)
-
- [一、五条规则与"2 倍"的数学证明](#一、五条规则与"2 倍"的数学证明)
-
- [1.1 为什么最长路径不超过最短路径的 2 倍](#1.1 为什么最长路径不超过最短路径的 2 倍)
- [1.2 复杂度依然是 O(logN)](#1.2 复杂度依然是 O(logN))
- 二、新节点为什么默认红色
- 三、c/p/g/u:一次调整只看叔叔
-
- [3.1 第一个推理:只要进入调整,g 必为黑](#3.1 第一个推理:只要进入调整,g 必为黑)
- [3.2 第二个推理(硬核):u 存在且为黑时,c 一定不是新节点](#3.2 第二个推理(硬核):u 存在且为黑时,c 一定不是新节点)
- [3.3 一张决策表:三类情况全在这](#3.3 一张决策表:三类情况全在这)
- 四、三类情况逐个拆解
-
- [4.1 情况一:u 红 → 变色,问题向上传一层](#4.1 情况一:u 红 → 变色,问题向上传一层)
- [4.2 情况二:u 黑或不存在 + 同侧 → 单旋收场](#4.2 情况二:u 黑或不存在 + 同侧 → 单旋收场)
- [4.3 情况三:u 黑或不存在 + 异侧 → 双旋收场](#4.3 情况三:u 黑或不存在 + 异侧 → 双旋收场)
- [4.4 完整走一遍:插入序列 10、20、30、40、50、25](#4.4 完整走一遍:插入序列 10、20、30、40、50、25)
- 五、插入的完整实现
- [六、定量分析:旋转最多 2 次,变色却可能爬到根](#六、定量分析:旋转最多 2 次,变色却可能爬到根)
- 七、验证:逐条查规则,别信"最长≤2倍最短"
- [八、红黑树 vs AVL:一张表看清取舍](#八、红黑树 vs AVL:一张表看清取舍)
- 九、总结
C++ 红黑树深度解析(上):插入为什么只看叔叔,旋转为何最多两次
红黑树的名气大得离谱------STL 的 map/set、Linux 内核的 CFS 调度器和 epoll、Java 的 HashMap 树化,全是它。但大部分人学红黑树的方式是背口诀:"叔叔红就变色,叔叔黑就旋转"。口诀背下来了,两个为什么依然答不上来:为什么插入调整只看叔叔的颜色?为什么红黑树的插入最多只需旋转两次,而变色却可能一路蔓延到根?
这两个问题才是红黑树的灵魂,也是面试官区分"背书的"和"懂行的"的分水岭。这篇文章从五条规则的数学证明讲起,用黑高反证法推"u 为黑时 c 一定不是新增节点",最后给出 STL 抛弃 AVL 选择红黑树的定量原因。不背口诀,只讲逻辑。
一、五条规则与"2 倍"的数学证明
红黑树是一棵二叉搜索树,每个节点多存一个颜色位(红或黑)。五条规则:
| 序号 | 规则 | 一句话本质 |
|---|---|---|
| 1 | 每个节点非红即黑 | 颜色枚举,天然保证 |
| 2 | 根节点是黑色 | 全局锚点 |
| 3 | 红节点的孩子必须全黑 | 任意路径无连续红节点 |
| 4 | 任一节点到其所有 NULL 路径含相同数量黑节点 | 黑高守恒,核心规则 |
| 5 | 每个叶子节点(NIL)都是黑色 | 仅《算法导论》体系,实现中忽略 |
规则 5 里的 NIL 指空节点(外部节点),不是传统意义的叶子。它是为了精确标识所有路径而引入的记号,实际代码里不会真的建 NIL 节点,知道概念即可。
1.1 为什么最长路径不超过最短路径的 2 倍
这套规则的威力全靠**黑高(black height,记 bh)**撑起来。推导三步走:
- 最短路径有多短 :由规则 4,每条路径的黑色节点数相同。极端情况下,一条路径全是黑色,它的长度就是黑高 bh------这是理论最短路径。
- 最长路径有多长 :由规则 3,路径上不能有连续红节点,红色节点最多和黑色节点一比一穿插。极端情况下,路径是"黑红黑红......"交替,长度为 2*bh------这是理论最长路径。
- 夹逼结论 :任意路径长度 x 满足
bh ≤ x ≤ 2*bh,即最长路径不超过最短路径的 2 倍。
这就是"近似平衡"的数学本质:红黑树不直接控制高度,而是通过颜色约束,把不平衡的上限锁死在 2 倍。
1.2 复杂度依然是 O(logN)
设节点数为 N,最短路径为 h,则 N 个节点至少要填满所有最短路径:2^h - 1 ≤ N < 2^(2h) - 1。取对数推出 h ≈ logN,最坏情况走最长路径也就是 2*logN。所以增删查改依然是 O(logN),和 AVL 同档。
差的那一个常数因子(最坏 2 倍路径长),换来的是插入旋转次数的骤降------第四节定量分析。
二、新节点为什么默认红色
红黑树的插入遵循一个原则:先按 BST 规则插进去,再修颜色。修之前要先回答:新节点给红色还是黑色?
| 选择 | 破坏的规则 | 修复代价 |
|---|---|---|
| 插入黑色 | 规则 4(黑高守恒) | 所有经过插入位置的路径黑数都变了,牵一发动全身,可能全树重构 |
| 插入红色 | 最坏只破坏规则 3(连续红) | 局部调整,变色或最多两次旋转 |
结论一目了然:非空树插入,新节点必须红色。红色不改变任何路径的黑节点数(规则 4 天然不受影响),最坏只制造一处"父子连续红",这是局部可修的。而插入黑色节点直接打破黑高守恒,修复它要动整棵树------这笔账怎么算都不划算。
唯一的例外是空树插入:新节点是根,规则 2 要求根黑,直接给黑色。
三、c/p/g/u:一次调整只看叔叔
进入调整环节前,先给四个角色命名------这套记号贯穿全文,也是所有教材通用的语言:
- c(cur):当前关注的节点,初始是新插入的红节点
- p(parent):c 的父亲
- g(grandfather):p 的父亲
- u(uncle):p 的兄弟,也就是 g 的另一个孩子
3.1 第一个推理:只要进入调整,g 必为黑
调整的触发条件是 p 为红(连续红,违反规则 3)。而插入前的树是合法的,合法的树里不存在连续红------所以 p 红、c 红,g 就必须是黑 。也就是说:c、p、g 三者的颜色在调整开始时全部确定,唯一的不确定因素就是 u。
这就是"只看叔叔"的由来:不是叔叔重要,而是其他人的颜色已经被逻辑锁死了,u 是唯一的自由变量。
3.2 第二个推理(硬核):u 存在且为黑时,c 一定不是新节点
这条推理绝大多数文章直接略过,但它是理解"变色为什么会向上蔓延"的钥匙。用黑高反证法:
反证:假设 u 存在且为黑,且 c 是本次新插入的节点。
c 是新节点,意味着插入前 c 的位置是空(NIL,黑色)。从 g 出发算两条路径的黑色节点数:
- g → p → c 位置:
1(g黑) + 0(p红) + 1(NIL) = 2 - g → u → 任意 NULL:
1(g黑) + 1(u黑) + u子树内黑数 ≥ 2 + 0,最少也是 2,而 u 下面只要还有子树就更大
等等,2 和 2 不是相等吗?注意 u 侧的下界------u 是黑节点,它下面挂的每条路径黑数至少含 u 自己 。重新精确算:从 g 出发到 NULL 的路径黑数,p 侧经过 c 位置的这条是 1 + 0 + 1 = 2;u 侧是 1 + 1 + (u 到 NULL 的黑数),哪怕 u 是叶子,这条也是 1 + 1 + 1 = 3。
2 ≠ 3,违反规则 4。也就是说,"u 存在且为黑 + c 是新增红节点"的组合,根本构不成合法的插入前状态。矛盾。
所以结论成立:调整过程中如果发现 u 存在且为黑,这个 c 一定不是新插入的,而是下层情况一变色后"变红推上来"的节点。而 u 不存在时,g → p → NIL = 2,g → u(空) → NIL = 2,黑高相等,合法------所以 u 不存在时 c 可以是新节点。
这条推理解释了一个现象:情况一的变色向上蔓延时,每一层遇到"叔叔为黑",都意味着问题是从下一层传上来的。红黑树的调整是自底向上传播的,u 的颜色就是传播路径的路标。
3.3 一张决策表:三类情况全在这
| u 的状态 | c 与 p 的位置关系 | 类型 | 操作 | 变色 | 之后 |
|---|---|---|---|---|---|
| u 存在且为红 | 无所谓(左右均可) | 情况一 | 只变色,不旋转 | p、u 变黑,g 变红 | g 当新 c,继续向上 |
| u 不存在或为黑 | p、c 同侧(LL / RR) | 情况二 | 单旋(LL 右旋 / RR 左旋) | p 变黑,g 变红 | 终止,不再向上 |
| u 不存在或为黑 | p、c 异侧(LR / RL) | 情况三 | 双旋(LR 左旋+右旋 / RL 右旋+左旋) | c 变黑,g 变红 | 终止,不再向上 |
这张表就是红黑树插入的全部。三个问题各答一次:操作是什么、颜色怎么变、处理完要不要继续往上。下面逐个拆为什么。
四、三类情况逐个拆解
4.1 情况一:u 红 → 变色,问题向上传一层
操作:p、u 变黑,g 变红,把 g 当作新的 c 继续向上。
为什么黑数不乱 :g 原本是黑,p、u 原本是红。变色后 p、u 两条子树路径各多一个黑 (p 黑了、u 黑了),g 这条路径少一个黑 (g 红了)。但从 g 的父亲往下看,经过 g 的每条路径必然先经过 g 再进入 p 或 u 之一------p 侧多出来的黑和 g 自己让出去的黑正好抵消,经过 g 子树的所有路径黑数不变。规则 4 完好。
为什么必须继续向上 :g 变红后,唯一的隐患是 g 的父亲也是红------那就又制造了一处连续红,于是把 g 当新 c,回到循环开头。每一层都是同样的处理,这就是变色的向上传播。传播的终点有三种:
- g 的父亲是黑 → 结束;
- g 是根 → 循环里父亲为空退出,最后由
_root->_col = BLACK兜底把根压回黑色(根由红变黑,全树每条路径黑数统一 +1,规则 4 依然守恒); - 中途某层遇到 u 为黑/不存在 → 转入情况二或三,旋转收场。
4.2 情况二:u 黑或不存在 + 同侧 → 单旋收场
以 LL 型(p 是 g 的左,c 是 p 的左)为例。操作:以 g 为旋转点右单旋,p 变黑,g 变红。
旋转的本质是把"红红"父子里的 p 提到子树根部,让它用黑色"隔开"上下。为什么这样黑数正好守恒?旋转前三条路径黑数(从 g 算):c 侧 g(1)+p(0)+c(0)+...,b 子树侧 g(1)+p(0)+b(...),u 侧 g(1)+u(...)。旋转后 p 变黑顶到原来 g 的位置,g 变红挂在 p 右边------每一层经过这颗子树的路径,黑色节点数和旋转前完全一致,只是分配方式变了。规则 4 不需要"重新平衡",它天然守恒。
为什么旋转后必然终止 :p 变黑后成为子树根,它的父亲无论红黑都不产生连续红(父亲红 + p 黑,合法;父亲黑,更合法)。问题在这颗子树内部被彻底消化 ,不会向上传播。所以代码里旋转完直接 break。
4.3 情况三:u 黑或不存在 + 异侧 → 双旋收场
以 LR 型(p 是 g 的左,c 是 p 的右)为例。"拐弯"的连续红是单旋解决不了的------单旋只能把直线型失衡转正,拐弯型转完还是拐的。操作:先以 p 为旋转点左单旋(把拐弯掰直成 LL),再以 g 为旋转点右单旋,最后 c 变黑、g 变红。
注意变色对象和情况二不同:双旋后顶上去的是 c(旋转把 c 转到了子树根的位置),所以变黑的是 c,变红的是 g。背口诀的人经常在这里把"p 变黑"错误套用到双旋上,颜色一错整棵树黑高全崩。
4.4 完整走一遍:插入序列 10、20、30、40、50、25
光看不练假把式,用一条覆盖"单旋、变色、根兜底"的序列走全程(括号里是颜色):
- 插 10 :空树,根,黑。树:
10(黑) - 插 20 :10 的右,红,父黑,结束。树:
10(黑, right:20(红)) - 插 30 :20 的右,红,父 20 红 → 调整。g=10,u 不存在,RR 型 → 以 10 为点左单旋,20 变黑、10 变红。树:
20(黑, left:10(红), right:30(红)) - 插 40 :30 的右,红,父 30 红 → 调整。g=20,u=10 红 → 情况一变色:30、10 变黑,20 变红。20 是根 → 兜底变黑。树:
20(黑, left:10(黑), right:30(黑, right:40(红))) - 插 50 :40 的右,红,父 40 红 → 调整。g=30,u 不存在,RR 型 → 以 30 为点左单旋,40 变黑、30 变红。树:
20(黑, left:10(黑), right:40(黑, left:30(红), right:50(红))) - 插 25 :25 < 40 走左,25 > 30 走右,成为 30 的右孩子,红。父 30 红 → 调整。g=40,u=50 红 → 情况一变色:30、50 变黑,40 变红。40 的父 20 是黑 → 结束。树:
20(黑, left:10(黑), right:40(红, left:30(黑, right:25(红)), right:50(黑)))
用规则 4 验证最后一步:从 20 出发,20→10→NIL 黑数 3;20→40→30→25→NIL 黑数 3;20→40→30→左NIL 黑数 3;20→40→50→NIL 黑数 3。全部相等,合法。
这条序列里出现了两次情况一(第 4、6 步)和两次 RR 单旋(第 3、5 步),把决策表里的两大分支都踩了一遍。
五、插入的完整实现
旋转函数和 AVL 完全一致(左旋右旋各十几行指针操作),唯一区别是不需要维护平衡因子------红黑树没有 bf,颜色就是它的"平衡因子"。核心的 Insert:
cpp
bool Insert(const pair<K, V>& kv)
{
if (_root == nullptr)
{
_root = new Node(kv);
_root->_col = BLACK; // 空树:新节点是根,规则2要求黑
return true;
}
// 1. 按 BST 规则找插入位置
Node* parent = nullptr;
Node* cur = _root;
while (cur)
{
if (cur->_kv.first < kv.first)
{
parent = cur;
cur = cur->_right;
}
else if (cur->_kv.first > kv.first)
{
parent = cur;
cur = cur->_left;
}
else
{
return false; // key 已存在
}
}
// 2. 链接新节点,颜色给红(代价最小的选择,见第二节)
cur = new Node(kv);
cur->_col = RED;
if (parent->_kv.first < kv.first)
parent->_right = cur;
else
parent->_left = cur;
cur->_parent = parent;
// 3. 向上调整:父红才有连续红,父黑直接结束
while (parent && parent->_col == RED)
{
Node* grandfather = parent->_parent;
if (parent == grandfather->_left) // p 是 g 的左(对称分支之一)
{
Node* uncle = grandfather->_right;
if (uncle && uncle->_col == RED)
{
// 情况一:u 红 -> 只变色,问题向上传一层
parent->_col = uncle->_col = BLACK;
grandfather->_col = RED;
cur = grandfather; // g 成为新的 c
parent = cur->_parent; // 继续向上
}
else
{
// u 不存在或为黑 -> 旋转收场
if (cur == parent->_left)
{
// LL 型:右单旋,p 顶上去
RotateR(grandfather);
parent->_col = BLACK;
grandfather->_col = RED;
}
else
{
// LR 型:先左旋掰直,再右旋,c 顶上去
RotateL(parent);
RotateR(grandfather);
cur->_col = BLACK;
grandfather->_col = RED;
}
break; // 旋转彻底消化问题,终止
}
}
else // p 是 g 的右,完全镜像
{
Node* uncle = grandfather->_left;
if (uncle && uncle->_col == RED)
{
parent->_col = uncle->_col = BLACK;
grandfather->_col = RED;
cur = grandfather;
parent = cur->_parent;
}
else
{
if (cur == parent->_right)
{
// RR 型:左单旋
RotateL(grandfather);
parent->_col = BLACK;
grandfather->_col = RED;
}
else
{
// RL 型:先右旋掰直,再左旋
RotateR(parent);
RotateL(grandfather);
cur->_col = BLACK;
grandfather->_col = RED;
}
break;
}
}
}
_root->_col = BLACK; // 兜底:根强制黑(根由红变黑=全路径黑数统一+1,规则4守恒)
return true;
}
三个最容易写错的位置,对着代码再强调一次:
- 情况一变色后不 break ,要把 g 换成新 c 继续循环;情况二、三旋转后必须 break------旋转后子树根已变黑,向上不可能再有连续红。
- 双旋的变色对象是 c 不是 p(情况三里顶到子树根的是 c)。
_root->_col = BLACK不是多余的:情况一传播到根时根是红的,靠这行压回黑;这是唯一合法的"事后改根"位置。
六、定量分析:旋转最多 2 次,变色却可能爬到根
现在回答标题里的第二个问题。把插入的代价拆成两部分看:
| 操作 | 触发次数上限 | 单次代价 | 为什么 |
|---|---|---|---|
| 变色 | 最坏 O(logN) 次(每层一次) | O(1) | 情况一每执行一次,c 上移一层,最坏爬到根 |
| 旋转 | 最多 2 次(双旋算 2 次) | O(1)(改几个指针) | 情况二/三一旦触发立即 break,不再向上 |
旋转为什么封顶 2 次 :情况三的双旋 = 先掰直再转正,两次;情况二单旋,一次;情况一不旋转。而旋转只会在情况二/三出现,且出现即终止------所以一次插入全程旋转次数 ≤ 2,这是与树高无关的常数。
变色为什么可能 O(logN):情况一的变色不消灭问题,只是把问题上移一层。最坏情形(树的左侧全是"红父红子"结构)下,变色可以一层层爬到根,然后根兜底变黑收场。
这个不对称正是 STL 选红黑树的根本原因。对比 AVL:AVL 的插入虽然旋转也最多 2 次(双旋),但它必须沿途回溯维护平衡因子 ,回溯路径 O(logN);而红黑树的"回溯"只发生在变色传播时,且每层只有 O(1) 的颜色翻转,没有 bf 的加减与判断。更关键的是删除:AVL 删除最坏要 O(logN) 次旋转,红黑树删除的旋转次数依然是 O(1) 级(最多 3 次)。对于 map/set 这种增删频繁的容器,旋转次数就是硬通货------红黑树用"允许最长路径 2 倍"的宽松,换来了调整成本的确定性上界。
工程上的取舍从来不是"谁更平衡",而是"谁的可预期性更好"。这一点 AVL 给不了。
七、验证:逐条查规则,别信"最长≤2倍最短"
很多人验证红黑树只比较最长路径和最短路径的比值,这个方法是错的------满足 2 倍关系的树,颜色照样可能不合法,当前没炸只是运气好,继续插入就崩。正确的做法是把规则逐条过一遍:
cpp
bool Check(Node* root, int blackNum, const int refNum)
{
if (root == nullptr)
{
// 走到空 = 一条路径走完,比对这条路径的黑节点数
if (refNum != blackNum)
{
cout << "存在黑色节点数量不相等的路径" << endl;
return false;
}
return true;
}
// 规则3:检查连续红。孩子有两个且不一定存在,反过来查父节点方便得多
if (root->_col == RED && root->_parent->_col == RED)
{
cout << root->_kv.first << "存在连续的红色节点" << endl;
return false;
}
if (root->_col == BLACK)
blackNum++; // 路径上遇到黑节点就累计
return Check(root->_left, blackNum, refNum)
&& Check(root->_right, blackNum, refNum);
}
bool IsBalance()
{
if (_root == nullptr)
return true;
if (_root->_col == RED) // 规则2:根必须黑
return false;
// 先取最左路径的黑节点数当参考值
int refNum = 0;
Node* cur = _root;
while (cur)
{
if (cur->_col == BLACK)
++refNum;
cur = cur->_left;
}
return Check(_root, 0, refNum);
}
四条规则的检查方式各有讲究,整理成表:
| 规则 | 检查方式 | 细节 |
|---|---|---|
| 1. 非红即黑 | 枚举类型天然保证 | 无需代码 |
| 2. 根为黑 | 直接查根的颜色 | IsBalance 开头 |
| 3. 无连续红 | 前序遍历查"红节点的父是否红" | 反向查父比正向查两个孩子简洁(孩子可能不存在) |
| 4. 黑高守恒 | 前序遍历形参累计 blackNum,到空时和参考值比对 | 参考值取最左路径黑数 |
写完插入后,用随机值灌 10 万个节点再跑 IsBalance,同时输出树高(约 log2(100000) ≈ 17 层上下一两层的范围内都正常),平衡性和合法性一次验完。
八、红黑树 vs AVL:一张表看清取舍
| 维度 | AVL 树 | 红黑树 |
|---|---|---|
| 平衡严格度 | 严格:任意节点左右高度差 ≤ 1 | 宽松:最长路径 ≤ 2×最短路径 |
| 平衡的载体 | 平衡因子(数值) | 颜色(枚举) |
| 单次插入旋转 | ≤ 2 次(单旋或双旋) | ≤ 2 次(单旋或双旋) |
| 单次删除旋转 | 最坏 O(logN) 次 | O(1) 级(最多 3 次) |
| 插入的额外维护 | bf 沿途回溯更新 | 变色传播,每层 O(1) |
| 查找性能 | 略优(更严格平衡,树更矮) | 略逊但同数量级 |
| 典型应用 | 教学与查询密集场景 | STL map/set、Linux CFS/epoll、Java HashMap 树化 |
工业界几乎一边倒选红黑树,理由就一句话:增删场景下,红黑树的调整成本有确定性上界,AVL 没有。数据库、内核这种"插入删除是家常便饭"的环境,稳定性比极端查找性能重要得多。
九、总结
- 五条规则的本质是黑高守恒 + 无连续红,由此推出最长路径 ≤ 2×最短路径,复杂度 O(logN)。
- 新节点默认红:红色不破坏黑高守恒,最坏只违反规则 3;插黑则牵一发动全身。
- 调整只看叔叔,因为 c 红、p 红 → g 必黑,三者颜色锁死,u 是唯一变量。
- u 存在且为黑时 c 必不是新节点(黑高反证:g→p→NIL 最多 2 个黑,g→u→NIL 至少 3 个黑,规则 4 不容),这个 c 是下层变色推上来的。
- 三类情况一张表:u 红 → 变色继续向上;u 黑/不存在 + 同侧 → 单旋 + p 黑 g 红,终止;u 黑/不存在 + 异侧 → 双旋 + c 黑 g 红,终止。
- 旋转 ≤ 2 次是常数上界,变色最坏 O(logN) 层------调整成本可预期,是 STL 和内核选红黑树的真正原因。
- 验证必须逐条查规则,"最长 ≤ 2 倍最短"不能作为合法性判据。
插入到这里就闭环了。但红黑树还有更难的一半------删除。
删除为什么比插入难?插入时新节点是红色,最坏只破坏"无连续红"这一条,问题局部可修。删除不同:删掉一个黑 节点,会直接打破规则 4 的黑高守恒,而且这个"欠一黑"的失衡不在删除点本身,而是挂在它的替代节点上,一路向根传播 。于是删除引出了一个插入完全没有的概念------双重黑(double black):一个节点被"记了两次黑账",要借兄弟节点的红孩子来"还债"。
插入只分三类情况、看一个叔叔;删除要看兄弟节点的颜色 + 侄子节点(兄弟的两个孩子)的红黑组合,决策树比插入复杂一整档,旋转次数也放宽到最多 3 次。这一半才是红黑树真正的分水岭------面试官问插入,是看你懂不懂基础;问删除,才是看你有没有真的吃透红黑树。
删除篇我会单独讲透:双重黑的本质、兄弟节点四种情况的决策总表、以及"旋转最多 3 次"与插入篇"旋转最多 2 次"之间的定量关系。把插入这篇的五条规则和旋转吃透,删除只是在这套框架上多一种"欠一黑"的状态而已。
文章目录
- [C++ 红黑树深度解析(上):插入为什么只看叔叔,旋转为何最多两次](#C++ 红黑树深度解析(上):插入为什么只看叔叔,旋转为何最多两次)
-
- [一、五条规则与"2 倍"的数学证明](#一、五条规则与"2 倍"的数学证明)
-
- [1.1 为什么最长路径不超过最短路径的 2 倍](#1.1 为什么最长路径不超过最短路径的 2 倍)
- [1.2 复杂度依然是 O(logN)](#1.2 复杂度依然是 O(logN))
- 二、新节点为什么默认红色
- 三、c/p/g/u:一次调整只看叔叔
-
- [3.1 第一个推理:只要进入调整,g 必为黑](#3.1 第一个推理:只要进入调整,g 必为黑)
- [3.2 第二个推理(硬核):u 存在且为黑时,c 一定不是新节点](#3.2 第二个推理(硬核):u 存在且为黑时,c 一定不是新节点)
- [3.3 一张决策表:三类情况全在这](#3.3 一张决策表:三类情况全在这)
- 四、三类情况逐个拆解
-
- [4.1 情况一:u 红 → 变色,问题向上传一层](#4.1 情况一:u 红 → 变色,问题向上传一层)
- [4.2 情况二:u 黑或不存在 + 同侧 → 单旋收场](#4.2 情况二:u 黑或不存在 + 同侧 → 单旋收场)
- [4.3 情况三:u 黑或不存在 + 异侧 → 双旋收场](#4.3 情况三:u 黑或不存在 + 异侧 → 双旋收场)
- [4.4 完整走一遍:插入序列 10、20、30、40、50、25](#4.4 完整走一遍:插入序列 10、20、30、40、50、25)
- 五、插入的完整实现
- [六、定量分析:旋转最多 2 次,变色却可能爬到根](#六、定量分析:旋转最多 2 次,变色却可能爬到根)
- 七、验证:逐条查规则,别信"最长≤2倍最短"
- [八、红黑树 vs AVL:一张表看清取舍](#八、红黑树 vs AVL:一张表看清取舍)
- 九、总结
- [C++ 红黑树深度解析(上):插入为什么只看叔叔,旋转为何最多两次](#C++ 红黑树深度解析(上):插入为什么只看叔叔,旋转为何最多两次)
-
- [一、五条规则与"2 倍"的数学证明](#一、五条规则与"2 倍"的数学证明)
-
- [1.1 为什么最长路径不超过最短路径的 2 倍](#1.1 为什么最长路径不超过最短路径的 2 倍)
- [1.2 复杂度依然是 O(logN)](#1.2 复杂度依然是 O(logN))
- 二、新节点为什么默认红色
- 三、c/p/g/u:一次调整只看叔叔
-
- [3.1 第一个推理:只要进入调整,g 必为黑](#3.1 第一个推理:只要进入调整,g 必为黑)
- [3.2 第二个推理(硬核):u 存在且为黑时,c 一定不是新节点](#3.2 第二个推理(硬核):u 存在且为黑时,c 一定不是新节点)
- [3.3 一张决策表:三类情况全在这](#3.3 一张决策表:三类情况全在这)
- 四、三类情况逐个拆解
-
- [4.1 情况一:u 红 → 变色,问题向上传一层](#4.1 情况一:u 红 → 变色,问题向上传一层)
- [4.2 情况二:u 黑或不存在 + 同侧 → 单旋收场](#4.2 情况二:u 黑或不存在 + 同侧 → 单旋收场)
- [4.3 情况三:u 黑或不存在 + 异侧 → 双旋收场](#4.3 情况三:u 黑或不存在 + 异侧 → 双旋收场)
- [4.4 完整走一遍:插入序列 10、20、30、40、50、25](#4.4 完整走一遍:插入序列 10、20、30、40、50、25)
- 五、插入的完整实现
- [六、定量分析:旋转最多 2 次,变色却可能爬到根](#六、定量分析:旋转最多 2 次,变色却可能爬到根)
- 七、验证:逐条查规则,别信"最长≤2倍最短"
- [八、红黑树 vs AVL:一张表看清取舍](#八、红黑树 vs AVL:一张表看清取舍)
- 九、总结
- [C++ 红黑树深度解析(上):插入为什么只看叔叔,旋转为何最多两次](#C++ 红黑树深度解析(上):插入为什么只看叔叔,旋转为何最多两次)
-
- [一、五条规则与"2 倍"的数学证明](#一、五条规则与"2 倍"的数学证明)
-
- [1.1 为什么最长路径不超过最短路径的 2 倍](#1.1 为什么最长路径不超过最短路径的 2 倍)
- [1.2 复杂度依然是 O(logN)](#1.2 复杂度依然是 O(logN))
- 二、新节点为什么默认红色
- 三、c/p/g/u:一次调整只看叔叔
-
- [3.1 第一个推理:只要进入调整,g 必为黑](#3.1 第一个推理:只要进入调整,g 必为黑)
- [3.2 第二个推理(硬核):u 存在且为黑时,c 一定不是新节点](#3.2 第二个推理(硬核):u 存在且为黑时,c 一定不是新节点)
- [3.3 一张决策表:三类情况全在这](#3.3 一张决策表:三类情况全在这)
- 四、三类情况逐个拆解
-
- [4.1 情况一:u 红 → 变色,问题向上传一层](#4.1 情况一:u 红 → 变色,问题向上传一层)
- [4.2 情况二:u 黑或不存在 + 同侧 → 单旋收场](#4.2 情况二:u 黑或不存在 + 同侧 → 单旋收场)
- [4.3 情况三:u 黑或不存在 + 异侧 → 双旋收场](#4.3 情况三:u 黑或不存在 + 异侧 → 双旋收场)
- [4.4 完整走一遍:插入序列 10、20、30、40、50、25](#4.4 完整走一遍:插入序列 10、20、30、40、50、25)
- 五、插入的完整实现
- [六、定量分析:旋转最多 2 次,变色却可能爬到根](#六、定量分析:旋转最多 2 次,变色却可能爬到根)
- 七、验证:逐条查规则,别信"最长≤2倍最短"
- [八、红黑树 vs AVL:一张表看清取舍](#八、红黑树 vs AVL:一张表看清取舍)
- 九、总结
- [C++ 红黑树深度解析(上):插入为什么只看叔叔,旋转为何最多两次](#C++ 红黑树深度解析(上):插入为什么只看叔叔,旋转为何最多两次)
-
- [一、五条规则与"2 倍"的数学证明](#一、五条规则与"2 倍"的数学证明)
-
- [1.1 为什么最长路径不超过最短路径的 2 倍](#1.1 为什么最长路径不超过最短路径的 2 倍)
- [1.2 复杂度依然是 O(logN)](#1.2 复杂度依然是 O(logN))
- 二、新节点为什么默认红色
- 三、c/p/g/u:一次调整只看叔叔
-
- [3.1 第一个推理:只要进入调整,g 必为黑](#3.1 第一个推理:只要进入调整,g 必为黑)
- [3.2 第二个推理(硬核):u 存在且为黑时,c 一定不是新节点](#3.2 第二个推理(硬核):u 存在且为黑时,c 一定不是新节点)
- [3.3 一张决策表:三类情况全在这](#3.3 一张决策表:三类情况全在这)
- 四、三类情况逐个拆解
-
- [4.1 情况一:u 红 → 变色,问题向上传一层](#4.1 情况一:u 红 → 变色,问题向上传一层)
- [4.2 情况二:u 黑或不存在 + 同侧 → 单旋收场](#4.2 情况二:u 黑或不存在 + 同侧 → 单旋收场)
- [4.3 情况三:u 黑或不存在 + 异侧 → 双旋收场](#4.3 情况三:u 黑或不存在 + 异侧 → 双旋收场)
- [4.4 完整走一遍:插入序列 10、20、30、40、50、25](#4.4 完整走一遍:插入序列 10、20、30、40、50、25)
- 五、插入的完整实现
- [六、定量分析:旋转最多 2 次,变色却可能爬到根](#六、定量分析:旋转最多 2 次,变色却可能爬到根)
- 七、验证:逐条查规则,别信"最长≤2倍最短"
- [八、红黑树 vs AVL:一张表看清取舍](#八、红黑树 vs AVL:一张表看清取舍)
- 九、总结
C++ 红黑树深度解析(上):插入为什么只看叔叔,旋转为何最多两次
红黑树的名气大得离谱------STL 的 map/set、Linux 内核的 CFS 调度器和 epoll、Java 的 HashMap 树化,全是它。但大部分人学红黑树的方式是背口诀:"叔叔红就变色,叔叔黑就旋转"。口诀背下来了,两个为什么依然答不上来:为什么插入调整只看叔叔的颜色?为什么红黑树的插入最多只需旋转两次,而变色却可能一路蔓延到根?
这两个问题才是红黑树的灵魂,也是面试官区分"背书的"和"懂行的"的分水岭。这篇文章从五条规则的数学证明讲起,用黑高反证法推"u 为黑时 c 一定不是新增节点",最后给出 STL 抛弃 AVL 选择红黑树的定量原因。不背口诀,只讲逻辑。
一、五条规则与"2 倍"的数学证明
红黑树是一棵二叉搜索树,每个节点多存一个颜色位(红或黑)。五条规则:
| 序号 | 规则 | 一句话本质 |
|---|---|---|
| 1 | 每个节点非红即黑 | 颜色枚举,天然保证 |
| 2 | 根节点是黑色 | 全局锚点 |
| 3 | 红节点的孩子必须全黑 | 任意路径无连续红节点 |
| 4 | 任一节点到其所有 NULL 路径含相同数量黑节点 | 黑高守恒,核心规则 |
| 5 | 每个叶子节点(NIL)都是黑色 | 仅《算法导论》体系,实现中忽略 |
规则 5 里的 NIL 指空节点(外部节点),不是传统意义的叶子。它是为了精确标识所有路径而引入的记号,实际代码里不会真的建 NIL 节点,知道概念即可。
1.1 为什么最长路径不超过最短路径的 2 倍
这套规则的威力全靠**黑高(black height,记 bh)**撑起来。推导三步走:
- 最短路径有多短 :由规则 4,每条路径的黑色节点数相同。极端情况下,一条路径全是黑色,它的长度就是黑高 bh------这是理论最短路径。
- 最长路径有多长 :由规则 3,路径上不能有连续红节点,红色节点最多和黑色节点一比一穿插。极端情况下,路径是"黑红黑红......"交替,长度为 2*bh------这是理论最长路径。
- 夹逼结论 :任意路径长度 x 满足
bh ≤ x ≤ 2*bh,即最长路径不超过最短路径的 2 倍。
这就是"近似平衡"的数学本质:红黑树不直接控制高度,而是通过颜色约束,把不平衡的上限锁死在 2 倍。
1.2 复杂度依然是 O(logN)
设节点数为 N,最短路径为 h,则 N 个节点至少要填满所有最短路径:2^h - 1 ≤ N < 2^(2h) - 1。取对数推出 h ≈ logN,最坏情况走最长路径也就是 2*logN。所以增删查改依然是 O(logN),和 AVL 同档。
差的那一个常数因子(最坏 2 倍路径长),换来的是插入旋转次数的骤降------第四节定量分析。
二、新节点为什么默认红色
红黑树的插入遵循一个原则:先按 BST 规则插进去,再修颜色。修之前要先回答:新节点给红色还是黑色?
| 选择 | 破坏的规则 | 修复代价 |
|---|---|---|
| 插入黑色 | 规则 4(黑高守恒) | 所有经过插入位置的路径黑数都变了,牵一发动全身,可能全树重构 |
| 插入红色 | 最坏只破坏规则 3(连续红) | 局部调整,变色或最多两次旋转 |
结论一目了然:非空树插入,新节点必须红色。红色不改变任何路径的黑节点数(规则 4 天然不受影响),最坏只制造一处"父子连续红",这是局部可修的。而插入黑色节点直接打破黑高守恒,修复它要动整棵树------这笔账怎么算都不划算。
唯一的例外是空树插入:新节点是根,规则 2 要求根黑,直接给黑色。
三、c/p/g/u:一次调整只看叔叔
进入调整环节前,先给四个角色命名------这套记号贯穿全文,也是所有教材通用的语言:
- c(cur):当前关注的节点,初始是新插入的红节点
- p(parent):c 的父亲
- g(grandfather):p 的父亲
- u(uncle):p 的兄弟,也就是 g 的另一个孩子
3.1 第一个推理:只要进入调整,g 必为黑
调整的触发条件是 p 为红(连续红,违反规则 3)。而插入前的树是合法的,合法的树里不存在连续红------所以 p 红、c 红,g 就必须是黑 。也就是说:c、p、g 三者的颜色在调整开始时全部确定,唯一的不确定因素就是 u。
这就是"只看叔叔"的由来:不是叔叔重要,而是其他人的颜色已经被逻辑锁死了,u 是唯一的自由变量。
3.2 第二个推理(硬核):u 存在且为黑时,c 一定不是新节点
这条推理绝大多数文章直接略过,但它是理解"变色为什么会向上蔓延"的钥匙。用黑高反证法:
反证:假设 u 存在且为黑,且 c 是本次新插入的节点。
c 是新节点,意味着插入前 c 的位置是空(NIL,黑色)。从 g 出发算两条路径的黑色节点数:
- g → p → c 位置:
1(g黑) + 0(p红) + 1(NIL) = 2 - g → u → 任意 NULL:
1(g黑) + 1(u黑) + u子树内黑数 ≥ 2 + 0,最少也是 2,而 u 下面只要还有子树就更大
等等,2 和 2 不是相等吗?注意 u 侧的下界------u 是黑节点,它下面挂的每条路径黑数至少含 u 自己 。重新精确算:从 g 出发到 NULL 的路径黑数,p 侧经过 c 位置的这条是 1 + 0 + 1 = 2;u 侧是 1 + 1 + (u 到 NULL 的黑数),哪怕 u 是叶子,这条也是 1 + 1 + 1 = 3。
2 ≠ 3,违反规则 4。也就是说,"u 存在且为黑 + c 是新增红节点"的组合,根本构不成合法的插入前状态。矛盾。
所以结论成立:调整过程中如果发现 u 存在且为黑,这个 c 一定不是新插入的,而是下层情况一变色后"变红推上来"的节点。而 u 不存在时,g → p → NIL = 2,g → u(空) → NIL = 2,黑高相等,合法------所以 u 不存在时 c 可以是新节点。
这条推理解释了一个现象:情况一的变色向上蔓延时,每一层遇到"叔叔为黑",都意味着问题是从下一层传上来的。红黑树的调整是自底向上传播的,u 的颜色就是传播路径的路标。
3.3 一张决策表:三类情况全在这
| u 的状态 | c 与 p 的位置关系 | 类型 | 操作 | 变色 | 之后 |
|---|---|---|---|---|---|
| u 存在且为红 | 无所谓(左右均可) | 情况一 | 只变色,不旋转 | p、u 变黑,g 变红 | g 当新 c,继续向上 |
| u 不存在或为黑 | p、c 同侧(LL / RR) | 情况二 | 单旋(LL 右旋 / RR 左旋) | p 变黑,g 变红 | 终止,不再向上 |
| u 不存在或为黑 | p、c 异侧(LR / RL) | 情况三 | 双旋(LR 左旋+右旋 / RL 右旋+左旋) | c 变黑,g 变红 | 终止,不再向上 |
这张表就是红黑树插入的全部。三个问题各答一次:操作是什么、颜色怎么变、处理完要不要继续往上。下面逐个拆为什么。
四、三类情况逐个拆解
4.1 情况一:u 红 → 变色,问题向上传一层
操作:p、u 变黑,g 变红,把 g 当作新的 c 继续向上。
为什么黑数不乱 :g 原本是黑,p、u 原本是红。变色后 p、u 两条子树路径各多一个黑 (p 黑了、u 黑了),g 这条路径少一个黑 (g 红了)。但从 g 的父亲往下看,经过 g 的每条路径必然先经过 g 再进入 p 或 u 之一------p 侧多出来的黑和 g 自己让出去的黑正好抵消,经过 g 子树的所有路径黑数不变。规则 4 完好。
为什么必须继续向上 :g 变红后,唯一的隐患是 g 的父亲也是红------那就又制造了一处连续红,于是把 g 当新 c,回到循环开头。每一层都是同样的处理,这就是变色的向上传播。传播的终点有三种:
- g 的父亲是黑 → 结束;
- g 是根 → 循环里父亲为空退出,最后由
_root->_col = BLACK兜底把根压回黑色(根由红变黑,全树每条路径黑数统一 +1,规则 4 依然守恒); - 中途某层遇到 u 为黑/不存在 → 转入情况二或三,旋转收场。
4.2 情况二:u 黑或不存在 + 同侧 → 单旋收场
以 LL 型(p 是 g 的左,c 是 p 的左)为例。操作:以 g 为旋转点右单旋,p 变黑,g 变红。
旋转的本质是把"红红"父子里的 p 提到子树根部,让它用黑色"隔开"上下。为什么这样黑数正好守恒?旋转前三条路径黑数(从 g 算):c 侧 g(1)+p(0)+c(0)+...,b 子树侧 g(1)+p(0)+b(...),u 侧 g(1)+u(...)。旋转后 p 变黑顶到原来 g 的位置,g 变红挂在 p 右边------每一层经过这颗子树的路径,黑色节点数和旋转前完全一致,只是分配方式变了。规则 4 不需要"重新平衡",它天然守恒。
为什么旋转后必然终止 :p 变黑后成为子树根,它的父亲无论红黑都不产生连续红(父亲红 + p 黑,合法;父亲黑,更合法)。问题在这颗子树内部被彻底消化 ,不会向上传播。所以代码里旋转完直接 break。
4.3 情况三:u 黑或不存在 + 异侧 → 双旋收场
以 LR 型(p 是 g 的左,c 是 p 的右)为例。"拐弯"的连续红是单旋解决不了的------单旋只能把直线型失衡转正,拐弯型转完还是拐的。操作:先以 p 为旋转点左单旋(把拐弯掰直成 LL),再以 g 为旋转点右单旋,最后 c 变黑、g 变红。
注意变色对象和情况二不同:双旋后顶上去的是 c(旋转把 c 转到了子树根的位置),所以变黑的是 c,变红的是 g。背口诀的人经常在这里把"p 变黑"错误套用到双旋上,颜色一错整棵树黑高全崩。
4.4 完整走一遍:插入序列 10、20、30、40、50、25
光看不练假把式,用一条覆盖"单旋、变色、根兜底"的序列走全程(括号里是颜色):
- 插 10 :空树,根,黑。树:
10(黑) - 插 20 :10 的右,红,父黑,结束。树:
10(黑, right:20(红)) - 插 30 :20 的右,红,父 20 红 → 调整。g=10,u 不存在,RR 型 → 以 10 为点左单旋,20 变黑、10 变红。树:
20(黑, left:10(红), right:30(红)) - 插 40 :30 的右,红,父 30 红 → 调整。g=20,u=10 红 → 情况一变色:30、10 变黑,20 变红。20 是根 → 兜底变黑。树:
20(黑, left:10(黑), right:30(黑, right:40(红))) - 插 50 :40 的右,红,父 40 红 → 调整。g=30,u 不存在,RR 型 → 以 30 为点左单旋,40 变黑、30 变红。树:
20(黑, left:10(黑), right:40(黑, left:30(红), right:50(红))) - 插 25 :25 < 40 走左,25 > 30 走右,成为 30 的右孩子,红。父 30 红 → 调整。g=40,u=50 红 → 情况一变色:30、50 变黑,40 变红。40 的父 20 是黑 → 结束。树:
20(黑, left:10(黑), right:40(红, left:30(黑, right:25(红)), right:50(黑)))
用规则 4 验证最后一步:从 20 出发,20→10→NIL 黑数 3;20→40→30→25→NIL 黑数 3;20→40→30→左NIL 黑数 3;20→40→50→NIL 黑数 3。全部相等,合法。
这条序列里出现了两次情况一(第 4、6 步)和两次 RR 单旋(第 3、5 步),把决策表里的两大分支都踩了一遍。
五、插入的完整实现
旋转函数和 AVL 完全一致(左旋右旋各十几行指针操作),唯一区别是不需要维护平衡因子------红黑树没有 bf,颜色就是它的"平衡因子"。核心的 Insert:
cpp
bool Insert(const pair<K, V>& kv)
{
if (_root == nullptr)
{
_root = new Node(kv);
_root->_col = BLACK; // 空树:新节点是根,规则2要求黑
return true;
}
// 1. 按 BST 规则找插入位置
Node* parent = nullptr;
Node* cur = _root;
while (cur)
{
if (cur->_kv.first < kv.first)
{
parent = cur;
cur = cur->_right;
}
else if (cur->_kv.first > kv.first)
{
parent = cur;
cur = cur->_left;
}
else
{
return false; // key 已存在
}
}
// 2. 链接新节点,颜色给红(代价最小的选择,见第二节)
cur = new Node(kv);
cur->_col = RED;
if (parent->_kv.first < kv.first)
parent->_right = cur;
else
parent->_left = cur;
cur->_parent = parent;
// 3. 向上调整:父红才有连续红,父黑直接结束
while (parent && parent->_col == RED)
{
Node* grandfather = parent->_parent;
if (parent == grandfather->_left) // p 是 g 的左(对称分支之一)
{
Node* uncle = grandfather->_right;
if (uncle && uncle->_col == RED)
{
// 情况一:u 红 -> 只变色,问题向上传一层
parent->_col = uncle->_col = BLACK;
grandfather->_col = RED;
cur = grandfather; // g 成为新的 c
parent = cur->_parent; // 继续向上
}
else
{
// u 不存在或为黑 -> 旋转收场
if (cur == parent->_left)
{
// LL 型:右单旋,p 顶上去
RotateR(grandfather);
parent->_col = BLACK;
grandfather->_col = RED;
}
else
{
// LR 型:先左旋掰直,再右旋,c 顶上去
RotateL(parent);
RotateR(grandfather);
cur->_col = BLACK;
grandfather->_col = RED;
}
break; // 旋转彻底消化问题,终止
}
}
else // p 是 g 的右,完全镜像
{
Node* uncle = grandfather->_left;
if (uncle && uncle->_col == RED)
{
parent->_col = uncle->_col = BLACK;
grandfather->_col = RED;
cur = grandfather;
parent = cur->_parent;
}
else
{
if (cur == parent->_right)
{
// RR 型:左单旋
RotateL(grandfather);
parent->_col = BLACK;
grandfather->_col = RED;
}
else
{
// RL 型:先右旋掰直,再左旋
RotateR(parent);
RotateL(grandfather);
cur->_col = BLACK;
grandfather->_col = RED;
}
break;
}
}
}
_root->_col = BLACK; // 兜底:根强制黑(根由红变黑=全路径黑数统一+1,规则4守恒)
return true;
}
三个最容易写错的位置,对着代码再强调一次:
- 情况一变色后不 break ,要把 g 换成新 c 继续循环;情况二、三旋转后必须 break------旋转后子树根已变黑,向上不可能再有连续红。
- 双旋的变色对象是 c 不是 p(情况三里顶到子树根的是 c)。
_root->_col = BLACK不是多余的:情况一传播到根时根是红的,靠这行压回黑;这是唯一合法的"事后改根"位置。
六、定量分析:旋转最多 2 次,变色却可能爬到根
现在回答标题里的第二个问题。把插入的代价拆成两部分看:
| 操作 | 触发次数上限 | 单次代价 | 为什么 |
|---|---|---|---|
| 变色 | 最坏 O(logN) 次(每层一次) | O(1) | 情况一每执行一次,c 上移一层,最坏爬到根 |
| 旋转 | 最多 2 次(双旋算 2 次) | O(1)(改几个指针) | 情况二/三一旦触发立即 break,不再向上 |
旋转为什么封顶 2 次 :情况三的双旋 = 先掰直再转正,两次;情况二单旋,一次;情况一不旋转。而旋转只会在情况二/三出现,且出现即终止------所以一次插入全程旋转次数 ≤ 2,这是与树高无关的常数。
变色为什么可能 O(logN):情况一的变色不消灭问题,只是把问题上移一层。最坏情形(树的左侧全是"红父红子"结构)下,变色可以一层层爬到根,然后根兜底变黑收场。
这个不对称正是 STL 选红黑树的根本原因。对比 AVL:AVL 的插入虽然旋转也最多 2 次(双旋),但它必须沿途回溯维护平衡因子 ,回溯路径 O(logN);而红黑树的"回溯"只发生在变色传播时,且每层只有 O(1) 的颜色翻转,没有 bf 的加减与判断。更关键的是删除:AVL 删除最坏要 O(logN) 次旋转,红黑树删除的旋转次数依然是 O(1) 级(最多 3 次)。对于 map/set 这种增删频繁的容器,旋转次数就是硬通货------红黑树用"允许最长路径 2 倍"的宽松,换来了调整成本的确定性上界。
工程上的取舍从来不是"谁更平衡",而是"谁的可预期性更好"。这一点 AVL 给不了。
七、验证:逐条查规则,别信"最长≤2倍最短"
很多人验证红黑树只比较最长路径和最短路径的比值,这个方法是错的------满足 2 倍关系的树,颜色照样可能不合法,当前没炸只是运气好,继续插入就崩。正确的做法是把规则逐条过一遍:
cpp
bool Check(Node* root, int blackNum, const int refNum)
{
if (root == nullptr)
{
// 走到空 = 一条路径走完,比对这条路径的黑节点数
if (refNum != blackNum)
{
cout << "存在黑色节点数量不相等的路径" << endl;
return false;
}
return true;
}
// 规则3:检查连续红。孩子有两个且不一定存在,反过来查父节点方便得多
if (root->_col == RED && root->_parent->_col == RED)
{
cout << root->_kv.first << "存在连续的红色节点" << endl;
return false;
}
if (root->_col == BLACK)
blackNum++; // 路径上遇到黑节点就累计
return Check(root->_left, blackNum, refNum)
&& Check(root->_right, blackNum, refNum);
}
bool IsBalance()
{
if (_root == nullptr)
return true;
if (_root->_col == RED) // 规则2:根必须黑
return false;
// 先取最左路径的黑节点数当参考值
int refNum = 0;
Node* cur = _root;
while (cur)
{
if (cur->_col == BLACK)
++refNum;
cur = cur->_left;
}
return Check(_root, 0, refNum);
}
四条规则的检查方式各有讲究,整理成表:
| 规则 | 检查方式 | 细节 |
|---|---|---|
| 1. 非红即黑 | 枚举类型天然保证 | 无需代码 |
| 2. 根为黑 | 直接查根的颜色 | IsBalance 开头 |
| 3. 无连续红 | 前序遍历查"红节点的父是否红" | 反向查父比正向查两个孩子简洁(孩子可能不存在) |
| 4. 黑高守恒 | 前序遍历形参累计 blackNum,到空时和参考值比对 | 参考值取最左路径黑数 |
写完插入后,用随机值灌 10 万个节点再跑 IsBalance,同时输出树高(约 log2(100000) ≈ 17 层上下一两层的范围内都正常),平衡性和合法性一次验完。
八、红黑树 vs AVL:一张表看清取舍
| 维度 | AVL 树 | 红黑树 |
|---|---|---|
| 平衡严格度 | 严格:任意节点左右高度差 ≤ 1 | 宽松:最长路径 ≤ 2×最短路径 |
| 平衡的载体 | 平衡因子(数值) | 颜色(枚举) |
| 单次插入旋转 | ≤ 2 次(单旋或双旋) | ≤ 2 次(单旋或双旋) |
| 单次删除旋转 | 最坏 O(logN) 次 | O(1) 级(最多 3 次) |
| 插入的额外维护 | bf 沿途回溯更新 | 变色传播,每层 O(1) |
| 查找性能 | 略优(更严格平衡,树更矮) | 略逊但同数量级 |
| 典型应用 | 教学与查询密集场景 | STL map/set、Linux CFS/epoll、Java HashMap 树化 |
工业界几乎一边倒选红黑树,理由就一句话:增删场景下,红黑树的调整成本有确定性上界,AVL 没有。数据库、内核这种"插入删除是家常便饭"的环境,稳定性比极端查找性能重要得多。
九、总结
- 五条规则的本质是黑高守恒 + 无连续红,由此推出最长路径 ≤ 2×最短路径,复杂度 O(logN)。
- 新节点默认红:红色不破坏黑高守恒,最坏只违反规则 3;插黑则牵一发动全身。
- 调整只看叔叔,因为 c 红、p 红 → g 必黑,三者颜色锁死,u 是唯一变量。
- u 存在且为黑时 c 必不是新节点(黑高反证:g→p→NIL 最多 2 个黑,g→u→NIL 至少 3 个黑,规则 4 不容),这个 c 是下层变色推上来的。
- 三类情况一张表:u 红 → 变色继续向上;u 黑/不存在 + 同侧 → 单旋 + p 黑 g 红,终止;u 黑/不存在 + 异侧 → 双旋 + c 黑 g 红,终止。
- 旋转 ≤ 2 次是常数上界,变色最坏 O(logN) 层------调整成本可预期,是 STL 和内核选红黑树的真正原因。
- 验证必须逐条查规则,"最长 ≤ 2 倍最短"不能作为合法性判据。
插入到这里就闭环了。但红黑树还有更难的一半------删除。
删除为什么比插入难?插入时新节点是红色,最坏只破坏"无连续红"这一条,问题局部可修。删除不同:删掉一个黑 节点,会直接打破规则 4 的黑高守恒,而且这个"欠一黑"的失衡不在删除点本身,而是挂在它的替代节点上,一路向根传播 。于是删除引出了一个插入完全没有的概念------双重黑(double black):一个节点被"记了两次黑账",要借兄弟节点的红孩子来"还债"。
插入只分三类情况、看一个叔叔;删除要看兄弟节点的颜色 + 侄子节点(兄弟的两个孩子)的红黑组合,决策树比插入复杂一整档,旋转次数也放宽到最多 3 次。这一半才是红黑树真正的分水岭------面试官问插入,是看你懂不懂基础;问删除,才是看你有没有真的吃透红黑树。
删除篇我会单独讲透:双重黑的本质、兄弟节点四种情况的决策总表、以及"旋转最多 3 次"与插入篇"旋转最多 2 次"之间的定量关系。把插入这篇的五条规则和旋转吃透,删除只是在这套框架上多一种"欠一黑"的状态而已。
文章目录
- [C++ 红黑树深度解析(上):插入为什么只看叔叔,旋转为何最多两次](#C++ 红黑树深度解析(上):插入为什么只看叔叔,旋转为何最多两次)
-
- [一、五条规则与"2 倍"的数学证明](#一、五条规则与"2 倍"的数学证明)
-
- [1.1 为什么最长路径不超过最短路径的 2 倍](#1.1 为什么最长路径不超过最短路径的 2 倍)
- [1.2 复杂度依然是 O(logN)](#1.2 复杂度依然是 O(logN))
- 二、新节点为什么默认红色
- 三、c/p/g/u:一次调整只看叔叔
-
- [3.1 第一个推理:只要进入调整,g 必为黑](#3.1 第一个推理:只要进入调整,g 必为黑)
- [3.2 第二个推理(硬核):u 存在且为黑时,c 一定不是新节点](#3.2 第二个推理(硬核):u 存在且为黑时,c 一定不是新节点)
- [3.3 一张决策表:三类情况全在这](#3.3 一张决策表:三类情况全在这)
- 四、三类情况逐个拆解
-
- [4.1 情况一:u 红 → 变色,问题向上传一层](#4.1 情况一:u 红 → 变色,问题向上传一层)
- [4.2 情况二:u 黑或不存在 + 同侧 → 单旋收场](#4.2 情况二:u 黑或不存在 + 同侧 → 单旋收场)
- [4.3 情况三:u 黑或不存在 + 异侧 → 双旋收场](#4.3 情况三:u 黑或不存在 + 异侧 → 双旋收场)
- [4.4 完整走一遍:插入序列 10、20、30、40、50、25](#4.4 完整走一遍:插入序列 10、20、30、40、50、25)
- 五、插入的完整实现
- [六、定量分析:旋转最多 2 次,变色却可能爬到根](#六、定量分析:旋转最多 2 次,变色却可能爬到根)
- 七、验证:逐条查规则,别信"最长≤2倍最短"
- [八、红黑树 vs AVL:一张表看清取舍](#八、红黑树 vs AVL:一张表看清取舍)
- 九、总结
- [C++ 红黑树深度解析(上):插入为什么只看叔叔,旋转为何最多两次](#C++ 红黑树深度解析(上):插入为什么只看叔叔,旋转为何最多两次)
-
- [一、五条规则与"2 倍"的数学证明](#一、五条规则与"2 倍"的数学证明)
-
- [1.1 为什么最长路径不超过最短路径的 2 倍](#1.1 为什么最长路径不超过最短路径的 2 倍)
- [1.2 复杂度依然是 O(logN)](#1.2 复杂度依然是 O(logN))
- 二、新节点为什么默认红色
- 三、c/p/g/u:一次调整只看叔叔
-
- [3.1 第一个推理:只要进入调整,g 必为黑](#3.1 第一个推理:只要进入调整,g 必为黑)
- [3.2 第二个推理(硬核):u 存在且为黑时,c 一定不是新节点](#3.2 第二个推理(硬核):u 存在且为黑时,c 一定不是新节点)
- [3.3 一张决策表:三类情况全在这](#3.3 一张决策表:三类情况全在这)
- 四、三类情况逐个拆解
-
- [4.1 情况一:u 红 → 变色,问题向上传一层](#4.1 情况一:u 红 → 变色,问题向上传一层)
- [4.2 情况二:u 黑或不存在 + 同侧 → 单旋收场](#4.2 情况二:u 黑或不存在 + 同侧 → 单旋收场)
- [4.3 情况三:u 黑或不存在 + 异侧 → 双旋收场](#4.3 情况三:u 黑或不存在 + 异侧 → 双旋收场)
- [4.4 完整走一遍:插入序列 10、20、30、40、50、25](#4.4 完整走一遍:插入序列 10、20、30、40、50、25)
- 五、插入的完整实现
- [六、定量分析:旋转最多 2 次,变色却可能爬到根](#六、定量分析:旋转最多 2 次,变色却可能爬到根)
- 七、验证:逐条查规则,别信"最长≤2倍最短"
- [八、红黑树 vs AVL:一张表看清取舍](#八、红黑树 vs AVL:一张表看清取舍)
- 九、总结
- [C++ 红黑树深度解析(上):插入为什么只看叔叔,旋转为何最多两次](#C++ 红黑树深度解析(上):插入为什么只看叔叔,旋转为何最多两次)
-
- [一、五条规则与"2 倍"的数学证明](#一、五条规则与"2 倍"的数学证明)
-
- [1.1 为什么最长路径不超过最短路径的 2 倍](#1.1 为什么最长路径不超过最短路径的 2 倍)
- [1.2 复杂度依然是 O(logN)](#1.2 复杂度依然是 O(logN))
- 二、新节点为什么默认红色
- 三、c/p/g/u:一次调整只看叔叔
-
- [3.1 第一个推理:只要进入调整,g 必为黑](#3.1 第一个推理:只要进入调整,g 必为黑)
- [3.2 第二个推理(硬核):u 存在且为黑时,c 一定不是新节点](#3.2 第二个推理(硬核):u 存在且为黑时,c 一定不是新节点)
- [3.3 一张决策表:三类情况全在这](#3.3 一张决策表:三类情况全在这)
- 四、三类情况逐个拆解
-
- [4.1 情况一:u 红 → 变色,问题向上传一层](#4.1 情况一:u 红 → 变色,问题向上传一层)
- [4.2 情况二:u 黑或不存在 + 同侧 → 单旋收场](#4.2 情况二:u 黑或不存在 + 同侧 → 单旋收场)
- [4.3 情况三:u 黑或不存在 + 异侧 → 双旋收场](#4.3 情况三:u 黑或不存在 + 异侧 → 双旋收场)
- [4.4 完整走一遍:插入序列 10、20、30、40、50、25](#4.4 完整走一遍:插入序列 10、20、30、40、50、25)
- 五、插入的完整实现
- [六、定量分析:旋转最多 2 次,变色却可能爬到根](#六、定量分析:旋转最多 2 次,变色却可能爬到根)
- 七、验证:逐条查规则,别信"最长≤2倍最短"
- [八、红黑树 vs AVL:一张表看清取舍](#八、红黑树 vs AVL:一张表看清取舍)
- 九、总结
- [C++ 红黑树深度解析(上):插入为什么只看叔叔,旋转为何最多两次](#C++ 红黑树深度解析(上):插入为什么只看叔叔,旋转为何最多两次)
-
- [一、五条规则与"2 倍"的数学证明](#一、五条规则与"2 倍"的数学证明)
-
- [1.1 为什么最长路径不超过最短路径的 2 倍](#1.1 为什么最长路径不超过最短路径的 2 倍)
- [1.2 复杂度依然是 O(logN)](#1.2 复杂度依然是 O(logN))
- 二、新节点为什么默认红色
- 三、c/p/g/u:一次调整只看叔叔
-
- [3.1 第一个推理:只要进入调整,g 必为黑](#3.1 第一个推理:只要进入调整,g 必为黑)
- [3.2 第二个推理(硬核):u 存在且为黑时,c 一定不是新节点](#3.2 第二个推理(硬核):u 存在且为黑时,c 一定不是新节点)
- [3.3 一张决策表:三类情况全在这](#3.3 一张决策表:三类情况全在这)
- 四、三类情况逐个拆解
-
- [4.1 情况一:u 红 → 变色,问题向上传一层](#4.1 情况一:u 红 → 变色,问题向上传一层)
- [4.2 情况二:u 黑或不存在 + 同侧 → 单旋收场](#4.2 情况二:u 黑或不存在 + 同侧 → 单旋收场)
- [4.3 情况三:u 黑或不存在 + 异侧 → 双旋收场](#4.3 情况三:u 黑或不存在 + 异侧 → 双旋收场)
- [4.4 完整走一遍:插入序列 10、20、30、40、50、25](#4.4 完整走一遍:插入序列 10、20、30、40、50、25)
- 五、插入的完整实现
- [六、定量分析:旋转最多 2 次,变色却可能爬到根](#六、定量分析:旋转最多 2 次,变色却可能爬到根)
- 七、验证:逐条查规则,别信"最长≤2倍最短"
- [八、红黑树 vs AVL:一张表看清取舍](#八、红黑树 vs AVL:一张表看清取舍)
- 九、总结
C++ 红黑树深度解析(上):插入为什么只看叔叔,旋转为何最多两次
红黑树的名气大得离谱------STL 的 map/set、Linux 内核的 CFS 调度器和 epoll、Java 的 HashMap 树化,全是它。但大部分人学红黑树的方式是背口诀:"叔叔红就变色,叔叔黑就旋转"。口诀背下来了,两个为什么依然答不上来:为什么插入调整只看叔叔的颜色?为什么红黑树的插入最多只需旋转两次,而变色却可能一路蔓延到根?
这两个问题才是红黑树的灵魂,也是面试官区分"背书的"和"懂行的"的分水岭。这篇文章从五条规则的数学证明讲起,用黑高反证法推"u 为黑时 c 一定不是新增节点",最后给出 STL 抛弃 AVL 选择红黑树的定量原因。不背口诀,只讲逻辑。
一、五条规则与"2 倍"的数学证明
红黑树是一棵二叉搜索树,每个节点多存一个颜色位(红或黑)。五条规则:
| 序号 | 规则 | 一句话本质 |
|---|---|---|
| 1 | 每个节点非红即黑 | 颜色枚举,天然保证 |
| 2 | 根节点是黑色 | 全局锚点 |
| 3 | 红节点的孩子必须全黑 | 任意路径无连续红节点 |
| 4 | 任一节点到其所有 NULL 路径含相同数量黑节点 | 黑高守恒,核心规则 |
| 5 | 每个叶子节点(NIL)都是黑色 | 仅《算法导论》体系,实现中忽略 |
规则 5 里的 NIL 指空节点(外部节点),不是传统意义的叶子。它是为了精确标识所有路径而引入的记号,实际代码里不会真的建 NIL 节点,知道概念即可。
1.1 为什么最长路径不超过最短路径的 2 倍
这套规则的威力全靠**黑高(black height,记 bh)**撑起来。推导三步走:
- 最短路径有多短 :由规则 4,每条路径的黑色节点数相同。极端情况下,一条路径全是黑色,它的长度就是黑高 bh------这是理论最短路径。
- 最长路径有多长 :由规则 3,路径上不能有连续红节点,红色节点最多和黑色节点一比一穿插。极端情况下,路径是"黑红黑红......"交替,长度为 2*bh------这是理论最长路径。
- 夹逼结论 :任意路径长度 x 满足
bh ≤ x ≤ 2*bh,即最长路径不超过最短路径的 2 倍。
这就是"近似平衡"的数学本质:红黑树不直接控制高度,而是通过颜色约束,把不平衡的上限锁死在 2 倍。
1.2 复杂度依然是 O(logN)
设节点数为 N,最短路径为 h,则 N 个节点至少要填满所有最短路径:2^h - 1 ≤ N < 2^(2h) - 1。取对数推出 h ≈ logN,最坏情况走最长路径也就是 2*logN。所以增删查改依然是 O(logN),和 AVL 同档。
差的那一个常数因子(最坏 2 倍路径长),换来的是插入旋转次数的骤降------第四节定量分析。
二、新节点为什么默认红色
红黑树的插入遵循一个原则:先按 BST 规则插进去,再修颜色。修之前要先回答:新节点给红色还是黑色?
| 选择 | 破坏的规则 | 修复代价 |
|---|---|---|
| 插入黑色 | 规则 4(黑高守恒) | 所有经过插入位置的路径黑数都变了,牵一发动全身,可能全树重构 |
| 插入红色 | 最坏只破坏规则 3(连续红) | 局部调整,变色或最多两次旋转 |
结论一目了然:非空树插入,新节点必须红色。红色不改变任何路径的黑节点数(规则 4 天然不受影响),最坏只制造一处"父子连续红",这是局部可修的。而插入黑色节点直接打破黑高守恒,修复它要动整棵树------这笔账怎么算都不划算。
唯一的例外是空树插入:新节点是根,规则 2 要求根黑,直接给黑色。
三、c/p/g/u:一次调整只看叔叔
进入调整环节前,先给四个角色命名------这套记号贯穿全文,也是所有教材通用的语言:
- c(cur):当前关注的节点,初始是新插入的红节点
- p(parent):c 的父亲
- g(grandfather):p 的父亲
- u(uncle):p 的兄弟,也就是 g 的另一个孩子
3.1 第一个推理:只要进入调整,g 必为黑
调整的触发条件是 p 为红(连续红,违反规则 3)。而插入前的树是合法的,合法的树里不存在连续红------所以 p 红、c 红,g 就必须是黑 。也就是说:c、p、g 三者的颜色在调整开始时全部确定,唯一的不确定因素就是 u。
这就是"只看叔叔"的由来:不是叔叔重要,而是其他人的颜色已经被逻辑锁死了,u 是唯一的自由变量。
3.2 第二个推理(硬核):u 存在且为黑时,c 一定不是新节点
这条推理绝大多数文章直接略过,但它是理解"变色为什么会向上蔓延"的钥匙。用黑高反证法:
反证:假设 u 存在且为黑,且 c 是本次新插入的节点。
c 是新节点,意味着插入前 c 的位置是空(NIL,黑色)。从 g 出发算两条路径的黑色节点数:
- g → p → c 位置:
1(g黑) + 0(p红) + 1(NIL) = 2 - g → u → 任意 NULL:
1(g黑) + 1(u黑) + u子树内黑数 ≥ 2 + 0,最少也是 2,而 u 下面只要还有子树就更大
等等,2 和 2 不是相等吗?注意 u 侧的下界------u 是黑节点,它下面挂的每条路径黑数至少含 u 自己 。重新精确算:从 g 出发到 NULL 的路径黑数,p 侧经过 c 位置的这条是 1 + 0 + 1 = 2;u 侧是 1 + 1 + (u 到 NULL 的黑数),哪怕 u 是叶子,这条也是 1 + 1 + 1 = 3。
2 ≠ 3,违反规则 4。也就是说,"u 存在且为黑 + c 是新增红节点"的组合,根本构不成合法的插入前状态。矛盾。
所以结论成立:调整过程中如果发现 u 存在且为黑,这个 c 一定不是新插入的,而是下层情况一变色后"变红推上来"的节点。而 u 不存在时,g → p → NIL = 2,g → u(空) → NIL = 2,黑高相等,合法------所以 u 不存在时 c 可以是新节点。
这条推理解释了一个现象:情况一的变色向上蔓延时,每一层遇到"叔叔为黑",都意味着问题是从下一层传上来的。红黑树的调整是自底向上传播的,u 的颜色就是传播路径的路标。
3.3 一张决策表:三类情况全在这
| u 的状态 | c 与 p 的位置关系 | 类型 | 操作 | 变色 | 之后 |
|---|---|---|---|---|---|
| u 存在且为红 | 无所谓(左右均可) | 情况一 | 只变色,不旋转 | p、u 变黑,g 变红 | g 当新 c,继续向上 |
| u 不存在或为黑 | p、c 同侧(LL / RR) | 情况二 | 单旋(LL 右旋 / RR 左旋) | p 变黑,g 变红 | 终止,不再向上 |
| u 不存在或为黑 | p、c 异侧(LR / RL) | 情况三 | 双旋(LR 左旋+右旋 / RL 右旋+左旋) | c 变黑,g 变红 | 终止,不再向上 |
这张表就是红黑树插入的全部。三个问题各答一次:操作是什么、颜色怎么变、处理完要不要继续往上。下面逐个拆为什么。
四、三类情况逐个拆解
4.1 情况一:u 红 → 变色,问题向上传一层
操作:p、u 变黑,g 变红,把 g 当作新的 c 继续向上。
为什么黑数不乱 :g 原本是黑,p、u 原本是红。变色后 p、u 两条子树路径各多一个黑 (p 黑了、u 黑了),g 这条路径少一个黑 (g 红了)。但从 g 的父亲往下看,经过 g 的每条路径必然先经过 g 再进入 p 或 u 之一------p 侧多出来的黑和 g 自己让出去的黑正好抵消,经过 g 子树的所有路径黑数不变。规则 4 完好。
为什么必须继续向上 :g 变红后,唯一的隐患是 g 的父亲也是红------那就又制造了一处连续红,于是把 g 当新 c,回到循环开头。每一层都是同样的处理,这就是变色的向上传播。传播的终点有三种:
- g 的父亲是黑 → 结束;
- g 是根 → 循环里父亲为空退出,最后由
_root->_col = BLACK兜底把根压回黑色(根由红变黑,全树每条路径黑数统一 +1,规则 4 依然守恒); - 中途某层遇到 u 为黑/不存在 → 转入情况二或三,旋转收场。
4.2 情况二:u 黑或不存在 + 同侧 → 单旋收场
以 LL 型(p 是 g 的左,c 是 p 的左)为例。操作:以 g 为旋转点右单旋,p 变黑,g 变红。
旋转的本质是把"红红"父子里的 p 提到子树根部,让它用黑色"隔开"上下。为什么这样黑数正好守恒?旋转前三条路径黑数(从 g 算):c 侧 g(1)+p(0)+c(0)+...,b 子树侧 g(1)+p(0)+b(...),u 侧 g(1)+u(...)。旋转后 p 变黑顶到原来 g 的位置,g 变红挂在 p 右边------每一层经过这颗子树的路径,黑色节点数和旋转前完全一致,只是分配方式变了。规则 4 不需要"重新平衡",它天然守恒。
为什么旋转后必然终止 :p 变黑后成为子树根,它的父亲无论红黑都不产生连续红(父亲红 + p 黑,合法;父亲黑,更合法)。问题在这颗子树内部被彻底消化 ,不会向上传播。所以代码里旋转完直接 break。
4.3 情况三:u 黑或不存在 + 异侧 → 双旋收场
以 LR 型(p 是 g 的左,c 是 p 的右)为例。"拐弯"的连续红是单旋解决不了的------单旋只能把直线型失衡转正,拐弯型转完还是拐的。操作:先以 p 为旋转点左单旋(把拐弯掰直成 LL),再以 g 为旋转点右单旋,最后 c 变黑、g 变红。
注意变色对象和情况二不同:双旋后顶上去的是 c(旋转把 c 转到了子树根的位置),所以变黑的是 c,变红的是 g。背口诀的人经常在这里把"p 变黑"错误套用到双旋上,颜色一错整棵树黑高全崩。
4.4 完整走一遍:插入序列 10、20、30、40、50、25
光看不练假把式,用一条覆盖"单旋、变色、根兜底"的序列走全程(括号里是颜色):
- 插 10 :空树,根,黑。树:
10(黑) - 插 20 :10 的右,红,父黑,结束。树:
10(黑, right:20(红)) - 插 30 :20 的右,红,父 20 红 → 调整。g=10,u 不存在,RR 型 → 以 10 为点左单旋,20 变黑、10 变红。树:
20(黑, left:10(红), right:30(红)) - 插 40 :30 的右,红,父 30 红 → 调整。g=20,u=10 红 → 情况一变色:30、10 变黑,20 变红。20 是根 → 兜底变黑。树:
20(黑, left:10(黑), right:30(黑, right:40(红))) - 插 50 :40 的右,红,父 40 红 → 调整。g=30,u 不存在,RR 型 → 以 30 为点左单旋,40 变黑、30 变红。树:
20(黑, left:10(黑), right:40(黑, left:30(红), right:50(红))) - 插 25 :25 < 40 走左,25 > 30 走右,成为 30 的右孩子,红。父 30 红 → 调整。g=40,u=50 红 → 情况一变色:30、50 变黑,40 变红。40 的父 20 是黑 → 结束。树:
20(黑, left:10(黑), right:40(红, left:30(黑, right:25(红)), right:50(黑)))
用规则 4 验证最后一步:从 20 出发,20→10→NIL 黑数 3;20→40→30→25→NIL 黑数 3;20→40→30→左NIL 黑数 3;20→40→50→NIL 黑数 3。全部相等,合法。
这条序列里出现了两次情况一(第 4、6 步)和两次 RR 单旋(第 3、5 步),把决策表里的两大分支都踩了一遍。
五、插入的完整实现
旋转函数和 AVL 完全一致(左旋右旋各十几行指针操作),唯一区别是不需要维护平衡因子------红黑树没有 bf,颜色就是它的"平衡因子"。核心的 Insert:
cpp
bool Insert(const pair<K, V>& kv)
{
if (_root == nullptr)
{
_root = new Node(kv);
_root->_col = BLACK; // 空树:新节点是根,规则2要求黑
return true;
}
// 1. 按 BST 规则找插入位置
Node* parent = nullptr;
Node* cur = _root;
while (cur)
{
if (cur->_kv.first < kv.first)
{
parent = cur;
cur = cur->_right;
}
else if (cur->_kv.first > kv.first)
{
parent = cur;
cur = cur->_left;
}
else
{
return false; // key 已存在
}
}
// 2. 链接新节点,颜色给红(代价最小的选择,见第二节)
cur = new Node(kv);
cur->_col = RED;
if (parent->_kv.first < kv.first)
parent->_right = cur;
else
parent->_left = cur;
cur->_parent = parent;
// 3. 向上调整:父红才有连续红,父黑直接结束
while (parent && parent->_col == RED)
{
Node* grandfather = parent->_parent;
if (parent == grandfather->_left) // p 是 g 的左(对称分支之一)
{
Node* uncle = grandfather->_right;
if (uncle && uncle->_col == RED)
{
// 情况一:u 红 -> 只变色,问题向上传一层
parent->_col = uncle->_col = BLACK;
grandfather->_col = RED;
cur = grandfather; // g 成为新的 c
parent = cur->_parent; // 继续向上
}
else
{
// u 不存在或为黑 -> 旋转收场
if (cur == parent->_left)
{
// LL 型:右单旋,p 顶上去
RotateR(grandfather);
parent->_col = BLACK;
grandfather->_col = RED;
}
else
{
// LR 型:先左旋掰直,再右旋,c 顶上去
RotateL(parent);
RotateR(grandfather);
cur->_col = BLACK;
grandfather->_col = RED;
}
break; // 旋转彻底消化问题,终止
}
}
else // p 是 g 的右,完全镜像
{
Node* uncle = grandfather->_left;
if (uncle && uncle->_col == RED)
{
parent->_col = uncle->_col = BLACK;
grandfather->_col = RED;
cur = grandfather;
parent = cur->_parent;
}
else
{
if (cur == parent->_right)
{
// RR 型:左单旋
RotateL(grandfather);
parent->_col = BLACK;
grandfather->_col = RED;
}
else
{
// RL 型:先右旋掰直,再左旋
RotateR(parent);
RotateL(grandfather);
cur->_col = BLACK;
grandfather->_col = RED;
}
break;
}
}
}
_root->_col = BLACK; // 兜底:根强制黑(根由红变黑=全路径黑数统一+1,规则4守恒)
return true;
}
三个最容易写错的位置,对着代码再强调一次:
- 情况一变色后不 break ,要把 g 换成新 c 继续循环;情况二、三旋转后必须 break------旋转后子树根已变黑,向上不可能再有连续红。
- 双旋的变色对象是 c 不是 p(情况三里顶到子树根的是 c)。
_root->_col = BLACK不是多余的:情况一传播到根时根是红的,靠这行压回黑;这是唯一合法的"事后改根"位置。
六、定量分析:旋转最多 2 次,变色却可能爬到根
现在回答标题里的第二个问题。把插入的代价拆成两部分看:
| 操作 | 触发次数上限 | 单次代价 | 为什么 |
|---|---|---|---|
| 变色 | 最坏 O(logN) 次(每层一次) | O(1) | 情况一每执行一次,c 上移一层,最坏爬到根 |
| 旋转 | 最多 2 次(双旋算 2 次) | O(1)(改几个指针) | 情况二/三一旦触发立即 break,不再向上 |
旋转为什么封顶 2 次 :情况三的双旋 = 先掰直再转正,两次;情况二单旋,一次;情况一不旋转。而旋转只会在情况二/三出现,且出现即终止------所以一次插入全程旋转次数 ≤ 2,这是与树高无关的常数。
变色为什么可能 O(logN):情况一的变色不消灭问题,只是把问题上移一层。最坏情形(树的左侧全是"红父红子"结构)下,变色可以一层层爬到根,然后根兜底变黑收场。
这个不对称正是 STL 选红黑树的根本原因。对比 AVL:AVL 的插入虽然旋转也最多 2 次(双旋),但它必须沿途回溯维护平衡因子 ,回溯路径 O(logN);而红黑树的"回溯"只发生在变色传播时,且每层只有 O(1) 的颜色翻转,没有 bf 的加减与判断。更关键的是删除:AVL 删除最坏要 O(logN) 次旋转,红黑树删除的旋转次数依然是 O(1) 级(最多 3 次)。对于 map/set 这种增删频繁的容器,旋转次数就是硬通货------红黑树用"允许最长路径 2 倍"的宽松,换来了调整成本的确定性上界。
工程上的取舍从来不是"谁更平衡",而是"谁的可预期性更好"。这一点 AVL 给不了。
七、验证:逐条查规则,别信"最长≤2倍最短"
很多人验证红黑树只比较最长路径和最短路径的比值,这个方法是错的------满足 2 倍关系的树,颜色照样可能不合法,当前没炸只是运气好,继续插入就崩。正确的做法是把规则逐条过一遍:
cpp
bool Check(Node* root, int blackNum, const int refNum)
{
if (root == nullptr)
{
// 走到空 = 一条路径走完,比对这条路径的黑节点数
if (refNum != blackNum)
{
cout << "存在黑色节点数量不相等的路径" << endl;
return false;
}
return true;
}
// 规则3:检查连续红。孩子有两个且不一定存在,反过来查父节点方便得多
if (root->_col == RED && root->_parent->_col == RED)
{
cout << root->_kv.first << "存在连续的红色节点" << endl;
return false;
}
if (root->_col == BLACK)
blackNum++; // 路径上遇到黑节点就累计
return Check(root->_left, blackNum, refNum)
&& Check(root->_right, blackNum, refNum);
}
bool IsBalance()
{
if (_root == nullptr)
return true;
if (_root->_col == RED) // 规则2:根必须黑
return false;
// 先取最左路径的黑节点数当参考值
int refNum = 0;
Node* cur = _root;
while (cur)
{
if (cur->_col == BLACK)
++refNum;
cur = cur->_left;
}
return Check(_root, 0, refNum);
}
四条规则的检查方式各有讲究,整理成表:
| 规则 | 检查方式 | 细节 |
|---|---|---|
| 1. 非红即黑 | 枚举类型天然保证 | 无需代码 |
| 2. 根为黑 | 直接查根的颜色 | IsBalance 开头 |
| 3. 无连续红 | 前序遍历查"红节点的父是否红" | 反向查父比正向查两个孩子简洁(孩子可能不存在) |
| 4. 黑高守恒 | 前序遍历形参累计 blackNum,到空时和参考值比对 | 参考值取最左路径黑数 |
写完插入后,用随机值灌 10 万个节点再跑 IsBalance,同时输出树高(约 log2(100000) ≈ 17 层上下一两层的范围内都正常),平衡性和合法性一次验完。
八、红黑树 vs AVL:一张表看清取舍
| 维度 | AVL 树 | 红黑树 |
|---|---|---|
| 平衡严格度 | 严格:任意节点左右高度差 ≤ 1 | 宽松:最长路径 ≤ 2×最短路径 |
| 平衡的载体 | 平衡因子(数值) | 颜色(枚举) |
| 单次插入旋转 | ≤ 2 次(单旋或双旋) | ≤ 2 次(单旋或双旋) |
| 单次删除旋转 | 最坏 O(logN) 次 | O(1) 级(最多 3 次) |
| 插入的额外维护 | bf 沿途回溯更新 | 变色传播,每层 O(1) |
| 查找性能 | 略优(更严格平衡,树更矮) | 略逊但同数量级 |
| 典型应用 | 教学与查询密集场景 | STL map/set、Linux CFS/epoll、Java HashMap 树化 |
工业界几乎一边倒选红黑树,理由就一句话:增删场景下,红黑树的调整成本有确定性上界,AVL 没有。数据库、内核这种"插入删除是家常便饭"的环境,稳定性比极端查找性能重要得多。
九、总结
- 五条规则的本质是黑高守恒 + 无连续红,由此推出最长路径 ≤ 2×最短路径,复杂度 O(logN)。
- 新节点默认红:红色不破坏黑高守恒,最坏只违反规则 3;插黑则牵一发动全身。
- 调整只看叔叔,因为 c 红、p 红 → g 必黑,三者颜色锁死,u 是唯一变量。
- u 存在且为黑时 c 必不是新节点(黑高反证:g→p→NIL 最多 2 个黑,g→u→NIL 至少 3 个黑,规则 4 不容),这个 c 是下层变色推上来的。
- 三类情况一张表:u 红 → 变色继续向上;u 黑/不存在 + 同侧 → 单旋 + p 黑 g 红,终止;u 黑/不存在 + 异侧 → 双旋 + c 黑 g 红,终止。
- 旋转 ≤ 2 次是常数上界,变色最坏 O(logN) 层------调整成本可预期,是 STL 和内核选红黑树的真正原因。
- 验证必须逐条查规则,"最长 ≤ 2 倍最短"不能作为合法性判据。
插入到这里就闭环了。但红黑树还有更难的一半------删除。
删除为什么比插入难?插入时新节点是红色,最坏只破坏"无连续红"这一条,问题局部可修。删除不同:删掉一个黑 节点,会直接打破规则 4 的黑高守恒,而且这个"欠一黑"的失衡不在删除点本身,而是挂在它的替代节点上,一路向根传播 。于是删除引出了一个插入完全没有的概念------双重黑(double black):一个节点被"记了两次黑账",要借兄弟节点的红孩子来"还债"。
插入只分三类情况、看一个叔叔;删除要看兄弟节点的颜色 + 侄子节点(兄弟的两个孩子)的红黑组合,决策树比插入复杂一整档,旋转次数也放宽到最多 3 次。这一半才是红黑树真正的分水岭------面试官问插入,是看你懂不懂基础;问删除,才是看你有没有真的吃透红黑树。
删除篇我会单独讲透:双重黑的本质、兄弟节点四种情况的决策总表、以及"旋转最多 3 次"与插入篇"旋转最多 2 次"之间的定量关系。把插入这篇的五条规则和旋转吃透,删除只是在这套框架上多一种"欠一黑"的状态而已。
文章目录
- [C++ 红黑树深度解析(上):插入为什么只看叔叔,旋转为何最多两次](#C++ 红黑树深度解析(上):插入为什么只看叔叔,旋转为何最多两次)
-
- [一、五条规则与"2 倍"的数学证明](#一、五条规则与"2 倍"的数学证明)
-
- [1.1 为什么最长路径不超过最短路径的 2 倍](#1.1 为什么最长路径不超过最短路径的 2 倍)
- [1.2 复杂度依然是 O(logN)](#1.2 复杂度依然是 O(logN))
- 二、新节点为什么默认红色
- 三、c/p/g/u:一次调整只看叔叔
-
- [3.1 第一个推理:只要进入调整,g 必为黑](#3.1 第一个推理:只要进入调整,g 必为黑)
- [3.2 第二个推理(硬核):u 存在且为黑时,c 一定不是新节点](#3.2 第二个推理(硬核):u 存在且为黑时,c 一定不是新节点)
- [3.3 一张决策表:三类情况全在这](#3.3 一张决策表:三类情况全在这)
- 四、三类情况逐个拆解
-
- [4.1 情况一:u 红 → 变色,问题向上传一层](#4.1 情况一:u 红 → 变色,问题向上传一层)
- [4.2 情况二:u 黑或不存在 + 同侧 → 单旋收场](#4.2 情况二:u 黑或不存在 + 同侧 → 单旋收场)
- [4.3 情况三:u 黑或不存在 + 异侧 → 双旋收场](#4.3 情况三:u 黑或不存在 + 异侧 → 双旋收场)
- [4.4 完整走一遍:插入序列 10、20、30、40、50、25](#4.4 完整走一遍:插入序列 10、20、30、40、50、25)
- 五、插入的完整实现
- [六、定量分析:旋转最多 2 次,变色却可能爬到根](#六、定量分析:旋转最多 2 次,变色却可能爬到根)
- 七、验证:逐条查规则,别信"最长≤2倍最短"
- [八、红黑树 vs AVL:一张表看清取舍](#八、红黑树 vs AVL:一张表看清取舍)
- 九、总结
- [C++ 红黑树深度解析(上):插入为什么只看叔叔,旋转为何最多两次](#C++ 红黑树深度解析(上):插入为什么只看叔叔,旋转为何最多两次)
-
- [一、五条规则与"2 倍"的数学证明](#一、五条规则与"2 倍"的数学证明)
-
- [1.1 为什么最长路径不超过最短路径的 2 倍](#1.1 为什么最长路径不超过最短路径的 2 倍)
- [1.2 复杂度依然是 O(logN)](#1.2 复杂度依然是 O(logN))
- 二、新节点为什么默认红色
- 三、c/p/g/u:一次调整只看叔叔
-
- [3.1 第一个推理:只要进入调整,g 必为黑](#3.1 第一个推理:只要进入调整,g 必为黑)
- [3.2 第二个推理(硬核):u 存在且为黑时,c 一定不是新节点](#3.2 第二个推理(硬核):u 存在且为黑时,c 一定不是新节点)
- [3.3 一张决策表:三类情况全在这](#3.3 一张决策表:三类情况全在这)
- 四、三类情况逐个拆解
-
- [4.1 情况一:u 红 → 变色,问题向上传一层](#4.1 情况一:u 红 → 变色,问题向上传一层)
- [4.2 情况二:u 黑或不存在 + 同侧 → 单旋收场](#4.2 情况二:u 黑或不存在 + 同侧 → 单旋收场)
- [4.3 情况三:u 黑或不存在 + 异侧 → 双旋收场](#4.3 情况三:u 黑或不存在 + 异侧 → 双旋收场)
- [4.4 完整走一遍:插入序列 10、20、30、40、50、25](#4.4 完整走一遍:插入序列 10、20、30、40、50、25)
- 五、插入的完整实现
- [六、定量分析:旋转最多 2 次,变色却可能爬到根](#六、定量分析:旋转最多 2 次,变色却可能爬到根)
- 七、验证:逐条查规则,别信"最长≤2倍最短"
- [八、红黑树 vs AVL:一张表看清取舍](#八、红黑树 vs AVL:一张表看清取舍)
- 九、总结
- [C++ 红黑树深度解析(上):插入为什么只看叔叔,旋转为何最多两次](#C++ 红黑树深度解析(上):插入为什么只看叔叔,旋转为何最多两次)
-
- [一、五条规则与"2 倍"的数学证明](#一、五条规则与"2 倍"的数学证明)
-
- [1.1 为什么最长路径不超过最短路径的 2 倍](#1.1 为什么最长路径不超过最短路径的 2 倍)
- [1.2 复杂度依然是 O(logN)](#1.2 复杂度依然是 O(logN))
- 二、新节点为什么默认红色
- 三、c/p/g/u:一次调整只看叔叔
-
- [3.1 第一个推理:只要进入调整,g 必为黑](#3.1 第一个推理:只要进入调整,g 必为黑)
- [3.2 第二个推理(硬核):u 存在且为黑时,c 一定不是新节点](#3.2 第二个推理(硬核):u 存在且为黑时,c 一定不是新节点)
- [3.3 一张决策表:三类情况全在这](#3.3 一张决策表:三类情况全在这)
- 四、三类情况逐个拆解
-
- [4.1 情况一:u 红 → 变色,问题向上传一层](#4.1 情况一:u 红 → 变色,问题向上传一层)
- [4.2 情况二:u 黑或不存在 + 同侧 → 单旋收场](#4.2 情况二:u 黑或不存在 + 同侧 → 单旋收场)
- [4.3 情况三:u 黑或不存在 + 异侧 → 双旋收场](#4.3 情况三:u 黑或不存在 + 异侧 → 双旋收场)
- [4.4 完整走一遍:插入序列 10、20、30、40、50、25](#4.4 完整走一遍:插入序列 10、20、30、40、50、25)
- 五、插入的完整实现
- [六、定量分析:旋转最多 2 次,变色却可能爬到根](#六、定量分析:旋转最多 2 次,变色却可能爬到根)
- 七、验证:逐条查规则,别信"最长≤2倍最短"
- [八、红黑树 vs AVL:一张表看清取舍](#八、红黑树 vs AVL:一张表看清取舍)
- 九、总结
- [C++ 红黑树深度解析(上):插入为什么只看叔叔,旋转为何最多两次](#C++ 红黑树深度解析(上):插入为什么只看叔叔,旋转为何最多两次)
-
- [一、五条规则与"2 倍"的数学证明](#一、五条规则与"2 倍"的数学证明)
-
- [1.1 为什么最长路径不超过最短路径的 2 倍](#1.1 为什么最长路径不超过最短路径的 2 倍)
- [1.2 复杂度依然是 O(logN)](#1.2 复杂度依然是 O(logN))
- 二、新节点为什么默认红色
- 三、c/p/g/u:一次调整只看叔叔
-
- [3.1 第一个推理:只要进入调整,g 必为黑](#3.1 第一个推理:只要进入调整,g 必为黑)
- [3.2 第二个推理(硬核):u 存在且为黑时,c 一定不是新节点](#3.2 第二个推理(硬核):u 存在且为黑时,c 一定不是新节点)
- [3.3 一张决策表:三类情况全在这](#3.3 一张决策表:三类情况全在这)
- 四、三类情况逐个拆解
-
- [4.1 情况一:u 红 → 变色,问题向上传一层](#4.1 情况一:u 红 → 变色,问题向上传一层)
- [4.2 情况二:u 黑或不存在 + 同侧 → 单旋收场](#4.2 情况二:u 黑或不存在 + 同侧 → 单旋收场)
- [4.3 情况三:u 黑或不存在 + 异侧 → 双旋收场](#4.3 情况三:u 黑或不存在 + 异侧 → 双旋收场)
- [4.4 完整走一遍:插入序列 10、20、30、40、50、25](#4.4 完整走一遍:插入序列 10、20、30、40、50、25)
- 五、插入的完整实现
- [六、定量分析:旋转最多 2 次,变色却可能爬到根](#六、定量分析:旋转最多 2 次,变色却可能爬到根)
- 七、验证:逐条查规则,别信"最长≤2倍最短"
- [八、红黑树 vs AVL:一张表看清取舍](#八、红黑树 vs AVL:一张表看清取舍)
- 九、总结
C++ 红黑树深度解析(上):插入为什么只看叔叔,旋转为何最多两次
红黑树的名气大得离谱------STL 的 map/set、Linux 内核的 CFS 调度器和 epoll、Java 的 HashMap 树化,全是它。但大部分人学红黑树的方式是背口诀:"叔叔红就变色,叔叔黑就旋转"。口诀背下来了,两个为什么依然答不上来:为什么插入调整只看叔叔的颜色?为什么红黑树的插入最多只需旋转两次,而变色却可能一路蔓延到根?
这两个问题才是红黑树的灵魂,也是面试官区分"背书的"和"懂行的"的分水岭。这篇文章从五条规则的数学证明讲起,用黑高反证法推"u 为黑时 c 一定不是新增节点",最后给出 STL 抛弃 AVL 选择红黑树的定量原因。不背口诀,只讲逻辑。
一、五条规则与"2 倍"的数学证明
红黑树是一棵二叉搜索树,每个节点多存一个颜色位(红或黑)。五条规则:
| 序号 | 规则 | 一句话本质 |
|---|---|---|
| 1 | 每个节点非红即黑 | 颜色枚举,天然保证 |
| 2 | 根节点是黑色 | 全局锚点 |
| 3 | 红节点的孩子必须全黑 | 任意路径无连续红节点 |
| 4 | 任一节点到其所有 NULL 路径含相同数量黑节点 | 黑高守恒,核心规则 |
| 5 | 每个叶子节点(NIL)都是黑色 | 仅《算法导论》体系,实现中忽略 |
规则 5 里的 NIL 指空节点(外部节点),不是传统意义的叶子。它是为了精确标识所有路径而引入的记号,实际代码里不会真的建 NIL 节点,知道概念即可。
1.1 为什么最长路径不超过最短路径的 2 倍
这套规则的威力全靠**黑高(black height,记 bh)**撑起来。推导三步走:
- 最短路径有多短 :由规则 4,每条路径的黑色节点数相同。极端情况下,一条路径全是黑色,它的长度就是黑高 bh------这是理论最短路径。
- 最长路径有多长 :由规则 3,路径上不能有连续红节点,红色节点最多和黑色节点一比一穿插。极端情况下,路径是"黑红黑红......"交替,长度为 2*bh------这是理论最长路径。
- 夹逼结论 :任意路径长度 x 满足
bh ≤ x ≤ 2*bh,即最长路径不超过最短路径的 2 倍。
这就是"近似平衡"的数学本质:红黑树不直接控制高度,而是通过颜色约束,把不平衡的上限锁死在 2 倍。
1.2 复杂度依然是 O(logN)
设节点数为 N,最短路径为 h,则 N 个节点至少要填满所有最短路径:2^h - 1 ≤ N < 2^(2h) - 1。取对数推出 h ≈ logN,最坏情况走最长路径也就是 2*logN。所以增删查改依然是 O(logN),和 AVL 同档。
差的那一个常数因子(最坏 2 倍路径长),换来的是插入旋转次数的骤降------第四节定量分析。
二、新节点为什么默认红色
红黑树的插入遵循一个原则:先按 BST 规则插进去,再修颜色。修之前要先回答:新节点给红色还是黑色?
| 选择 | 破坏的规则 | 修复代价 |
|---|---|---|
| 插入黑色 | 规则 4(黑高守恒) | 所有经过插入位置的路径黑数都变了,牵一发动全身,可能全树重构 |
| 插入红色 | 最坏只破坏规则 3(连续红) | 局部调整,变色或最多两次旋转 |
结论一目了然:非空树插入,新节点必须红色。红色不改变任何路径的黑节点数(规则 4 天然不受影响),最坏只制造一处"父子连续红",这是局部可修的。而插入黑色节点直接打破黑高守恒,修复它要动整棵树------这笔账怎么算都不划算。
唯一的例外是空树插入:新节点是根,规则 2 要求根黑,直接给黑色。
三、c/p/g/u:一次调整只看叔叔
进入调整环节前,先给四个角色命名------这套记号贯穿全文,也是所有教材通用的语言:
- c(cur):当前关注的节点,初始是新插入的红节点
- p(parent):c 的父亲
- g(grandfather):p 的父亲
- u(uncle):p 的兄弟,也就是 g 的另一个孩子
3.1 第一个推理:只要进入调整,g 必为黑
调整的触发条件是 p 为红(连续红,违反规则 3)。而插入前的树是合法的,合法的树里不存在连续红------所以 p 红、c 红,g 就必须是黑 。也就是说:c、p、g 三者的颜色在调整开始时全部确定,唯一的不确定因素就是 u。
这就是"只看叔叔"的由来:不是叔叔重要,而是其他人的颜色已经被逻辑锁死了,u 是唯一的自由变量。
3.2 第二个推理(硬核):u 存在且为黑时,c 一定不是新节点
这条推理绝大多数文章直接略过,但它是理解"变色为什么会向上蔓延"的钥匙。用黑高反证法:
反证:假设 u 存在且为黑,且 c 是本次新插入的节点。
c 是新节点,意味着插入前 c 的位置是空(NIL,黑色)。从 g 出发算两条路径的黑色节点数:
- g → p → c 位置:
1(g黑) + 0(p红) + 1(NIL) = 2 - g → u → 任意 NULL:
1(g黑) + 1(u黑) + u子树内黑数 ≥ 2 + 0,最少也是 2,而 u 下面只要还有子树就更大
等等,2 和 2 不是相等吗?注意 u 侧的下界------u 是黑节点,它下面挂的每条路径黑数至少含 u 自己 。重新精确算:从 g 出发到 NULL 的路径黑数,p 侧经过 c 位置的这条是 1 + 0 + 1 = 2;u 侧是 1 + 1 + (u 到 NULL 的黑数),哪怕 u 是叶子,这条也是 1 + 1 + 1 = 3。
2 ≠ 3,违反规则 4。也就是说,"u 存在且为黑 + c 是新增红节点"的组合,根本构不成合法的插入前状态。矛盾。
所以结论成立:调整过程中如果发现 u 存在且为黑,这个 c 一定不是新插入的,而是下层情况一变色后"变红推上来"的节点。而 u 不存在时,g → p → NIL = 2,g → u(空) → NIL = 2,黑高相等,合法------所以 u 不存在时 c 可以是新节点。
这条推理解释了一个现象:情况一的变色向上蔓延时,每一层遇到"叔叔为黑",都意味着问题是从下一层传上来的。红黑树的调整是自底向上传播的,u 的颜色就是传播路径的路标。
3.3 一张决策表:三类情况全在这
| u 的状态 | c 与 p 的位置关系 | 类型 | 操作 | 变色 | 之后 |
|---|---|---|---|---|---|
| u 存在且为红 | 无所谓(左右均可) | 情况一 | 只变色,不旋转 | p、u 变黑,g 变红 | g 当新 c,继续向上 |
| u 不存在或为黑 | p、c 同侧(LL / RR) | 情况二 | 单旋(LL 右旋 / RR 左旋) | p 变黑,g 变红 | 终止,不再向上 |
| u 不存在或为黑 | p、c 异侧(LR / RL) | 情况三 | 双旋(LR 左旋+右旋 / RL 右旋+左旋) | c 变黑,g 变红 | 终止,不再向上 |
这张表就是红黑树插入的全部。三个问题各答一次:操作是什么、颜色怎么变、处理完要不要继续往上。下面逐个拆为什么。
四、三类情况逐个拆解
4.1 情况一:u 红 → 变色,问题向上传一层
操作:p、u 变黑,g 变红,把 g 当作新的 c 继续向上。
为什么黑数不乱 :g 原本是黑,p、u 原本是红。变色后 p、u 两条子树路径各多一个黑 (p 黑了、u 黑了),g 这条路径少一个黑 (g 红了)。但从 g 的父亲往下看,经过 g 的每条路径必然先经过 g 再进入 p 或 u 之一------p 侧多出来的黑和 g 自己让出去的黑正好抵消,经过 g 子树的所有路径黑数不变。规则 4 完好。
为什么必须继续向上 :g 变红后,唯一的隐患是 g 的父亲也是红------那就又制造了一处连续红,于是把 g 当新 c,回到循环开头。每一层都是同样的处理,这就是变色的向上传播。传播的终点有三种:
- g 的父亲是黑 → 结束;
- g 是根 → 循环里父亲为空退出,最后由
_root->_col = BLACK兜底把根压回黑色(根由红变黑,全树每条路径黑数统一 +1,规则 4 依然守恒); - 中途某层遇到 u 为黑/不存在 → 转入情况二或三,旋转收场。
4.2 情况二:u 黑或不存在 + 同侧 → 单旋收场
以 LL 型(p 是 g 的左,c 是 p 的左)为例。操作:以 g 为旋转点右单旋,p 变黑,g 变红。
旋转的本质是把"红红"父子里的 p 提到子树根部,让它用黑色"隔开"上下。为什么这样黑数正好守恒?旋转前三条路径黑数(从 g 算):c 侧 g(1)+p(0)+c(0)+...,b 子树侧 g(1)+p(0)+b(...),u 侧 g(1)+u(...)。旋转后 p 变黑顶到原来 g 的位置,g 变红挂在 p 右边------每一层经过这颗子树的路径,黑色节点数和旋转前完全一致,只是分配方式变了。规则 4 不需要"重新平衡",它天然守恒。
为什么旋转后必然终止 :p 变黑后成为子树根,它的父亲无论红黑都不产生连续红(父亲红 + p 黑,合法;父亲黑,更合法)。问题在这颗子树内部被彻底消化 ,不会向上传播。所以代码里旋转完直接 break。
4.3 情况三:u 黑或不存在 + 异侧 → 双旋收场
以 LR 型(p 是 g 的左,c 是 p 的右)为例。"拐弯"的连续红是单旋解决不了的------单旋只能把直线型失衡转正,拐弯型转完还是拐的。操作:先以 p 为旋转点左单旋(把拐弯掰直成 LL),再以 g 为旋转点右单旋,最后 c 变黑、g 变红。
注意变色对象和情况二不同:双旋后顶上去的是 c(旋转把 c 转到了子树根的位置),所以变黑的是 c,变红的是 g。背口诀的人经常在这里把"p 变黑"错误套用到双旋上,颜色一错整棵树黑高全崩。
4.4 完整走一遍:插入序列 10、20、30、40、50、25
光看不练假把式,用一条覆盖"单旋、变色、根兜底"的序列走全程(括号里是颜色):
- 插 10 :空树,根,黑。树:
10(黑) - 插 20 :10 的右,红,父黑,结束。树:
10(黑, right:20(红)) - 插 30 :20 的右,红,父 20 红 → 调整。g=10,u 不存在,RR 型 → 以 10 为点左单旋,20 变黑、10 变红。树:
20(黑, left:10(红), right:30(红)) - 插 40 :30 的右,红,父 30 红 → 调整。g=20,u=10 红 → 情况一变色:30、10 变黑,20 变红。20 是根 → 兜底变黑。树:
20(黑, left:10(黑), right:30(黑, right:40(红))) - 插 50 :40 的右,红,父 40 红 → 调整。g=30,u 不存在,RR 型 → 以 30 为点左单旋,40 变黑、30 变红。树:
20(黑, left:10(黑), right:40(黑, left:30(红), right:50(红))) - 插 25 :25 < 40 走左,25 > 30 走右,成为 30 的右孩子,红。父 30 红 → 调整。g=40,u=50 红 → 情况一变色:30、50 变黑,40 变红。40 的父 20 是黑 → 结束。树:
20(黑, left:10(黑), right:40(红, left:30(黑, right:25(红)), right:50(黑)))
用规则 4 验证最后一步:从 20 出发,20→10→NIL 黑数 3;20→40→30→25→NIL 黑数 3;20→40→30→左NIL 黑数 3;20→40→50→NIL 黑数 3。全部相等,合法。
这条序列里出现了两次情况一(第 4、6 步)和两次 RR 单旋(第 3、5 步),把决策表里的两大分支都踩了一遍。
五、插入的完整实现
旋转函数和 AVL 完全一致(左旋右旋各十几行指针操作),唯一区别是不需要维护平衡因子------红黑树没有 bf,颜色就是它的"平衡因子"。核心的 Insert:
cpp
bool Insert(const pair<K, V>& kv)
{
if (_root == nullptr)
{
_root = new Node(kv);
_root->_col = BLACK; // 空树:新节点是根,规则2要求黑
return true;
}
// 1. 按 BST 规则找插入位置
Node* parent = nullptr;
Node* cur = _root;
while (cur)
{
if (cur->_kv.first < kv.first)
{
parent = cur;
cur = cur->_right;
}
else if (cur->_kv.first > kv.first)
{
parent = cur;
cur = cur->_left;
}
else
{
return false; // key 已存在
}
}
// 2. 链接新节点,颜色给红(代价最小的选择,见第二节)
cur = new Node(kv);
cur->_col = RED;
if (parent->_kv.first < kv.first)
parent->_right = cur;
else
parent->_left = cur;
cur->_parent = parent;
// 3. 向上调整:父红才有连续红,父黑直接结束
while (parent && parent->_col == RED)
{
Node* grandfather = parent->_parent;
if (parent == grandfather->_left) // p 是 g 的左(对称分支之一)
{
Node* uncle = grandfather->_right;
if (uncle && uncle->_col == RED)
{
// 情况一:u 红 -> 只变色,问题向上传一层
parent->_col = uncle->_col = BLACK;
grandfather->_col = RED;
cur = grandfather; // g 成为新的 c
parent = cur->_parent; // 继续向上
}
else
{
// u 不存在或为黑 -> 旋转收场
if (cur == parent->_left)
{
// LL 型:右单旋,p 顶上去
RotateR(grandfather);
parent->_col = BLACK;
grandfather->_col = RED;
}
else
{
// LR 型:先左旋掰直,再右旋,c 顶上去
RotateL(parent);
RotateR(grandfather);
cur->_col = BLACK;
grandfather->_col = RED;
}
break; // 旋转彻底消化问题,终止
}
}
else // p 是 g 的右,完全镜像
{
Node* uncle = grandfather->_left;
if (uncle && uncle->_col == RED)
{
parent->_col = uncle->_col = BLACK;
grandfather->_col = RED;
cur = grandfather;
parent = cur->_parent;
}
else
{
if (cur == parent->_right)
{
// RR 型:左单旋
RotateL(grandfather);
parent->_col = BLACK;
grandfather->_col = RED;
}
else
{
// RL 型:先右旋掰直,再左旋
RotateR(parent);
RotateL(grandfather);
cur->_col = BLACK;
grandfather->_col = RED;
}
break;
}
}
}
_root->_col = BLACK; // 兜底:根强制黑(根由红变黑=全路径黑数统一+1,规则4守恒)
return true;
}
三个最容易写错的位置,对着代码再强调一次:
- 情况一变色后不 break ,要把 g 换成新 c 继续循环;情况二、三旋转后必须 break------旋转后子树根已变黑,向上不可能再有连续红。
- 双旋的变色对象是 c 不是 p(情况三里顶到子树根的是 c)。
_root->_col = BLACK不是多余的:情况一传播到根时根是红的,靠这行压回黑;这是唯一合法的"事后改根"位置。
六、定量分析:旋转最多 2 次,变色却可能爬到根
现在回答标题里的第二个问题。把插入的代价拆成两部分看:
| 操作 | 触发次数上限 | 单次代价 | 为什么 |
|---|---|---|---|
| 变色 | 最坏 O(logN) 次(每层一次) | O(1) | 情况一每执行一次,c 上移一层,最坏爬到根 |
| 旋转 | 最多 2 次(双旋算 2 次) | O(1)(改几个指针) | 情况二/三一旦触发立即 break,不再向上 |
旋转为什么封顶 2 次 :情况三的双旋 = 先掰直再转正,两次;情况二单旋,一次;情况一不旋转。而旋转只会在情况二/三出现,且出现即终止------所以一次插入全程旋转次数 ≤ 2,这是与树高无关的常数。
变色为什么可能 O(logN):情况一的变色不消灭问题,只是把问题上移一层。最坏情形(树的左侧全是"红父红子"结构)下,变色可以一层层爬到根,然后根兜底变黑收场。
这个不对称正是 STL 选红黑树的根本原因。对比 AVL:AVL 的插入虽然旋转也最多 2 次(双旋),但它必须沿途回溯维护平衡因子 ,回溯路径 O(logN);而红黑树的"回溯"只发生在变色传播时,且每层只有 O(1) 的颜色翻转,没有 bf 的加减与判断。更关键的是删除:AVL 删除最坏要 O(logN) 次旋转,红黑树删除的旋转次数依然是 O(1) 级(最多 3 次)。对于 map/set 这种增删频繁的容器,旋转次数就是硬通货------红黑树用"允许最长路径 2 倍"的宽松,换来了调整成本的确定性上界。
工程上的取舍从来不是"谁更平衡",而是"谁的可预期性更好"。这一点 AVL 给不了。
七、验证:逐条查规则,别信"最长≤2倍最短"
很多人验证红黑树只比较最长路径和最短路径的比值,这个方法是错的------满足 2 倍关系的树,颜色照样可能不合法,当前没炸只是运气好,继续插入就崩。正确的做法是把规则逐条过一遍:
cpp
bool Check(Node* root, int blackNum, const int refNum)
{
if (root == nullptr)
{
// 走到空 = 一条路径走完,比对这条路径的黑节点数
if (refNum != blackNum)
{
cout << "存在黑色节点数量不相等的路径" << endl;
return false;
}
return true;
}
// 规则3:检查连续红。孩子有两个且不一定存在,反过来查父节点方便得多
if (root->_col == RED && root->_parent->_col == RED)
{
cout << root->_kv.first << "存在连续的红色节点" << endl;
return false;
}
if (root->_col == BLACK)
blackNum++; // 路径上遇到黑节点就累计
return Check(root->_left, blackNum, refNum)
&& Check(root->_right, blackNum, refNum);
}
bool IsBalance()
{
if (_root == nullptr)
return true;
if (_root->_col == RED) // 规则2:根必须黑
return false;
// 先取最左路径的黑节点数当参考值
int refNum = 0;
Node* cur = _root;
while (cur)
{
if (cur->_col == BLACK)
++refNum;
cur = cur->_left;
}
return Check(_root, 0, refNum);
}
四条规则的检查方式各有讲究,整理成表:
| 规则 | 检查方式 | 细节 |
|---|---|---|
| 1. 非红即黑 | 枚举类型天然保证 | 无需代码 |
| 2. 根为黑 | 直接查根的颜色 | IsBalance 开头 |
| 3. 无连续红 | 前序遍历查"红节点的父是否红" | 反向查父比正向查两个孩子简洁(孩子可能不存在) |
| 4. 黑高守恒 | 前序遍历形参累计 blackNum,到空时和参考值比对 | 参考值取最左路径黑数 |
写完插入后,用随机值灌 10 万个节点再跑 IsBalance,同时输出树高(约 log2(100000) ≈ 17 层上下一两层的范围内都正常),平衡性和合法性一次验完。
八、红黑树 vs AVL:一张表看清取舍
| 维度 | AVL 树 | 红黑树 |
|---|---|---|
| 平衡严格度 | 严格:任意节点左右高度差 ≤ 1 | 宽松:最长路径 ≤ 2×最短路径 |
| 平衡的载体 | 平衡因子(数值) | 颜色(枚举) |
| 单次插入旋转 | ≤ 2 次(单旋或双旋) | ≤ 2 次(单旋或双旋) |
| 单次删除旋转 | 最坏 O(logN) 次 | O(1) 级(最多 3 次) |
| 插入的额外维护 | bf 沿途回溯更新 | 变色传播,每层 O(1) |
| 查找性能 | 略优(更严格平衡,树更矮) | 略逊但同数量级 |
| 典型应用 | 教学与查询密集场景 | STL map/set、Linux CFS/epoll、Java HashMap 树化 |
工业界几乎一边倒选红黑树,理由就一句话:增删场景下,红黑树的调整成本有确定性上界,AVL 没有。数据库、内核这种"插入删除是家常便饭"的环境,稳定性比极端查找性能重要得多。
九、总结
- 五条规则的本质是黑高守恒 + 无连续红,由此推出最长路径 ≤ 2×最短路径,复杂度 O(logN)。
- 新节点默认红:红色不破坏黑高守恒,最坏只违反规则 3;插黑则牵一发动全身。
- 调整只看叔叔,因为 c 红、p 红 → g 必黑,三者颜色锁死,u 是唯一变量。
- u 存在且为黑时 c 必不是新节点(黑高反证:g→p→NIL 最多 2 个黑,g→u→NIL 至少 3 个黑,规则 4 不容),这个 c 是下层变色推上来的。
- 三类情况一张表:u 红 → 变色继续向上;u 黑/不存在 + 同侧 → 单旋 + p 黑 g 红,终止;u 黑/不存在 + 异侧 → 双旋 + c 黑 g 红,终止。
- 旋转 ≤ 2 次是常数上界,变色最坏 O(logN) 层------调整成本可预期,是 STL 和内核选红黑树的真正原因。
- 验证必须逐条查规则,"最长 ≤ 2 倍最短"不能作为合法性判据。
插入到这里就闭环了。但红黑树还有更难的一半------删除。
删除为什么比插入难?插入时新节点是红色,最坏只破坏"无连续红"这一条,问题局部可修。删除不同:删掉一个黑 节点,会直接打破规则 4 的黑高守恒,而且这个"欠一黑"的失衡不在删除点本身,而是挂在它的替代节点上,一路向根传播 。于是删除引出了一个插入完全没有的概念------双重黑(double black):一个节点被"记了两次黑账",要借兄弟节点的红孩子来"还债"。
插入只分三类情况、看一个叔叔;删除要看兄弟节点的颜色 + 侄子节点(兄弟的两个孩子)的红黑组合,决策树比插入复杂一整档,旋转次数也放宽到最多 3 次。这一半才是红黑树真正的分水岭------面试官问插入,是看你懂不懂基础;问删除,才是看你有没有真的吃透红黑树。
删除篇我会单独讲透:双重黑的本质、兄弟节点四种情况的决策总表、以及"旋转最多 3 次"与插入篇"旋转最多 2 次"之间的定量关系。把插入这篇的五条规则和旋转吃透,删除只是在这套框架上多一种"欠一黑"的状态而已。