C++ 红黑树(上):五条规则背后的近似平衡与插入的三类情况

文章目录

  • [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)**撑起来。推导三步走:

  1. 最短路径有多短 :由规则 4,每条路径的黑色节点数相同。极端情况下,一条路径全是黑色,它的长度就是黑高 bh------这是理论最短路径。
  2. 最长路径有多长 :由规则 3,路径上不能有连续红节点,红色节点最多和黑色节点一比一穿插。极端情况下,路径是"黑红黑红......"交替,长度为 2*bh------这是理论最长路径。
  3. 夹逼结论 :任意路径长度 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,回到循环开头。每一层都是同样的处理,这就是变色的向上传播。传播的终点有三种:

  1. g 的父亲是黑 → 结束;
  2. g 是根 → 循环里父亲为空退出,最后由 _root->_col = BLACK 兜底把根压回黑色(根由红变黑,全树每条路径黑数统一 +1,规则 4 依然守恒);
  3. 中途某层遇到 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

光看不练假把式,用一条覆盖"单旋、变色、根兜底"的序列走全程(括号里是颜色):

  1. 插 10 :空树,根,黑。树:10(黑)
  2. 插 20 :10 的右,红,父黑,结束。树:10(黑, right:20(红))
  3. 插 30 :20 的右,红,父 20 红 → 调整。g=10,u 不存在,RR 型 → 以 10 为点左单旋,20 变黑、10 变红。树:20(黑, left:10(红), right:30(红))
  4. 插 40 :30 的右,红,父 30 红 → 调整。g=20,u=10 红 → 情况一变色:30、10 变黑,20 变红。20 是根 → 兜底变黑。树:20(黑, left:10(黑), right:30(黑, right:40(红)))
  5. 插 50 :40 的右,红,父 40 红 → 调整。g=30,u 不存在,RR 型 → 以 30 为点左单旋,40 变黑、30 变红。树:20(黑, left:10(黑), right:40(黑, left:30(红), right:50(红)))
  6. 插 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;
}

三个最容易写错的位置,对着代码再强调一次:

  1. 情况一变色后不 break ,要把 g 换成新 c 继续循环;情况二、三旋转后必须 break------旋转后子树根已变黑,向上不可能再有连续红。
  2. 双旋的变色对象是 c 不是 p(情况三里顶到子树根的是 c)。
  3. _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 没有。数据库、内核这种"插入删除是家常便饭"的环境,稳定性比极端查找性能重要得多。


九、总结

  1. 五条规则的本质是黑高守恒 + 无连续红,由此推出最长路径 ≤ 2×最短路径,复杂度 O(logN)。
  2. 新节点默认红:红色不破坏黑高守恒,最坏只违反规则 3;插黑则牵一发动全身。
  3. 调整只看叔叔,因为 c 红、p 红 → g 必黑,三者颜色锁死,u 是唯一变量。
  4. u 存在且为黑时 c 必不是新节点(黑高反证:g→p→NIL 最多 2 个黑,g→u→NIL 至少 3 个黑,规则 4 不容),这个 c 是下层变色推上来的。
  5. 三类情况一张表:u 红 → 变色继续向上;u 黑/不存在 + 同侧 → 单旋 + p 黑 g 红,终止;u 黑/不存在 + 异侧 → 双旋 + c 黑 g 红,终止。
  6. 旋转 ≤ 2 次是常数上界,变色最坏 O(logN) 层------调整成本可预期,是 STL 和内核选红黑树的真正原因。
  7. 验证必须逐条查规则,"最长 ≤ 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)**撑起来。推导三步走:

  1. 最短路径有多短 :由规则 4,每条路径的黑色节点数相同。极端情况下,一条路径全是黑色,它的长度就是黑高 bh------这是理论最短路径。
  2. 最长路径有多长 :由规则 3,路径上不能有连续红节点,红色节点最多和黑色节点一比一穿插。极端情况下,路径是"黑红黑红......"交替,长度为 2*bh------这是理论最长路径。
  3. 夹逼结论 :任意路径长度 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,回到循环开头。每一层都是同样的处理,这就是变色的向上传播。传播的终点有三种:

  1. g 的父亲是黑 → 结束;
  2. g 是根 → 循环里父亲为空退出,最后由 _root->_col = BLACK 兜底把根压回黑色(根由红变黑,全树每条路径黑数统一 +1,规则 4 依然守恒);
  3. 中途某层遇到 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

光看不练假把式,用一条覆盖"单旋、变色、根兜底"的序列走全程(括号里是颜色):

  1. 插 10 :空树,根,黑。树:10(黑)
  2. 插 20 :10 的右,红,父黑,结束。树:10(黑, right:20(红))
  3. 插 30 :20 的右,红,父 20 红 → 调整。g=10,u 不存在,RR 型 → 以 10 为点左单旋,20 变黑、10 变红。树:20(黑, left:10(红), right:30(红))
  4. 插 40 :30 的右,红,父 30 红 → 调整。g=20,u=10 红 → 情况一变色:30、10 变黑,20 变红。20 是根 → 兜底变黑。树:20(黑, left:10(黑), right:30(黑, right:40(红)))
  5. 插 50 :40 的右,红,父 40 红 → 调整。g=30,u 不存在,RR 型 → 以 30 为点左单旋,40 变黑、30 变红。树:20(黑, left:10(黑), right:40(黑, left:30(红), right:50(红)))
  6. 插 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;
}

三个最容易写错的位置,对着代码再强调一次:

  1. 情况一变色后不 break ,要把 g 换成新 c 继续循环;情况二、三旋转后必须 break------旋转后子树根已变黑,向上不可能再有连续红。
  2. 双旋的变色对象是 c 不是 p(情况三里顶到子树根的是 c)。
  3. _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 没有。数据库、内核这种"插入删除是家常便饭"的环境,稳定性比极端查找性能重要得多。


九、总结

  1. 五条规则的本质是黑高守恒 + 无连续红,由此推出最长路径 ≤ 2×最短路径,复杂度 O(logN)。
  2. 新节点默认红:红色不破坏黑高守恒,最坏只违反规则 3;插黑则牵一发动全身。
  3. 调整只看叔叔,因为 c 红、p 红 → g 必黑,三者颜色锁死,u 是唯一变量。
  4. u 存在且为黑时 c 必不是新节点(黑高反证:g→p→NIL 最多 2 个黑,g→u→NIL 至少 3 个黑,规则 4 不容),这个 c 是下层变色推上来的。
  5. 三类情况一张表:u 红 → 变色继续向上;u 黑/不存在 + 同侧 → 单旋 + p 黑 g 红,终止;u 黑/不存在 + 异侧 → 双旋 + c 黑 g 红,终止。
  6. 旋转 ≤ 2 次是常数上界,变色最坏 O(logN) 层------调整成本可预期,是 STL 和内核选红黑树的真正原因。
  7. 验证必须逐条查规则,"最长 ≤ 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)**撑起来。推导三步走:

  1. 最短路径有多短 :由规则 4,每条路径的黑色节点数相同。极端情况下,一条路径全是黑色,它的长度就是黑高 bh------这是理论最短路径。
  2. 最长路径有多长 :由规则 3,路径上不能有连续红节点,红色节点最多和黑色节点一比一穿插。极端情况下,路径是"黑红黑红......"交替,长度为 2*bh------这是理论最长路径。
  3. 夹逼结论 :任意路径长度 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,回到循环开头。每一层都是同样的处理,这就是变色的向上传播。传播的终点有三种:

  1. g 的父亲是黑 → 结束;
  2. g 是根 → 循环里父亲为空退出,最后由 _root->_col = BLACK 兜底把根压回黑色(根由红变黑,全树每条路径黑数统一 +1,规则 4 依然守恒);
  3. 中途某层遇到 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

光看不练假把式,用一条覆盖"单旋、变色、根兜底"的序列走全程(括号里是颜色):

  1. 插 10 :空树,根,黑。树:10(黑)
  2. 插 20 :10 的右,红,父黑,结束。树:10(黑, right:20(红))
  3. 插 30 :20 的右,红,父 20 红 → 调整。g=10,u 不存在,RR 型 → 以 10 为点左单旋,20 变黑、10 变红。树:20(黑, left:10(红), right:30(红))
  4. 插 40 :30 的右,红,父 30 红 → 调整。g=20,u=10 红 → 情况一变色:30、10 变黑,20 变红。20 是根 → 兜底变黑。树:20(黑, left:10(黑), right:30(黑, right:40(红)))
  5. 插 50 :40 的右,红,父 40 红 → 调整。g=30,u 不存在,RR 型 → 以 30 为点左单旋,40 变黑、30 变红。树:20(黑, left:10(黑), right:40(黑, left:30(红), right:50(红)))
  6. 插 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;
}

三个最容易写错的位置,对着代码再强调一次:

  1. 情况一变色后不 break ,要把 g 换成新 c 继续循环;情况二、三旋转后必须 break------旋转后子树根已变黑,向上不可能再有连续红。
  2. 双旋的变色对象是 c 不是 p(情况三里顶到子树根的是 c)。
  3. _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 没有。数据库、内核这种"插入删除是家常便饭"的环境,稳定性比极端查找性能重要得多。


九、总结

  1. 五条规则的本质是黑高守恒 + 无连续红,由此推出最长路径 ≤ 2×最短路径,复杂度 O(logN)。
  2. 新节点默认红:红色不破坏黑高守恒,最坏只违反规则 3;插黑则牵一发动全身。
  3. 调整只看叔叔,因为 c 红、p 红 → g 必黑,三者颜色锁死,u 是唯一变量。
  4. u 存在且为黑时 c 必不是新节点(黑高反证:g→p→NIL 最多 2 个黑,g→u→NIL 至少 3 个黑,规则 4 不容),这个 c 是下层变色推上来的。
  5. 三类情况一张表:u 红 → 变色继续向上;u 黑/不存在 + 同侧 → 单旋 + p 黑 g 红,终止;u 黑/不存在 + 异侧 → 双旋 + c 黑 g 红,终止。
  6. 旋转 ≤ 2 次是常数上界,变色最坏 O(logN) 层------调整成本可预期,是 STL 和内核选红黑树的真正原因。
  7. 验证必须逐条查规则,"最长 ≤ 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)**撑起来。推导三步走:

  1. 最短路径有多短 :由规则 4,每条路径的黑色节点数相同。极端情况下,一条路径全是黑色,它的长度就是黑高 bh------这是理论最短路径。
  2. 最长路径有多长 :由规则 3,路径上不能有连续红节点,红色节点最多和黑色节点一比一穿插。极端情况下,路径是"黑红黑红......"交替,长度为 2*bh------这是理论最长路径。
  3. 夹逼结论 :任意路径长度 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,回到循环开头。每一层都是同样的处理,这就是变色的向上传播。传播的终点有三种:

  1. g 的父亲是黑 → 结束;
  2. g 是根 → 循环里父亲为空退出,最后由 _root->_col = BLACK 兜底把根压回黑色(根由红变黑,全树每条路径黑数统一 +1,规则 4 依然守恒);
  3. 中途某层遇到 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

光看不练假把式,用一条覆盖"单旋、变色、根兜底"的序列走全程(括号里是颜色):

  1. 插 10 :空树,根,黑。树:10(黑)
  2. 插 20 :10 的右,红,父黑,结束。树:10(黑, right:20(红))
  3. 插 30 :20 的右,红,父 20 红 → 调整。g=10,u 不存在,RR 型 → 以 10 为点左单旋,20 变黑、10 变红。树:20(黑, left:10(红), right:30(红))
  4. 插 40 :30 的右,红,父 30 红 → 调整。g=20,u=10 红 → 情况一变色:30、10 变黑,20 变红。20 是根 → 兜底变黑。树:20(黑, left:10(黑), right:30(黑, right:40(红)))
  5. 插 50 :40 的右,红,父 40 红 → 调整。g=30,u 不存在,RR 型 → 以 30 为点左单旋,40 变黑、30 变红。树:20(黑, left:10(黑), right:40(黑, left:30(红), right:50(红)))
  6. 插 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;
}

三个最容易写错的位置,对着代码再强调一次:

  1. 情况一变色后不 break ,要把 g 换成新 c 继续循环;情况二、三旋转后必须 break------旋转后子树根已变黑,向上不可能再有连续红。
  2. 双旋的变色对象是 c 不是 p(情况三里顶到子树根的是 c)。
  3. _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 没有。数据库、内核这种"插入删除是家常便饭"的环境,稳定性比极端查找性能重要得多。


九、总结

  1. 五条规则的本质是黑高守恒 + 无连续红,由此推出最长路径 ≤ 2×最短路径,复杂度 O(logN)。
  2. 新节点默认红:红色不破坏黑高守恒,最坏只违反规则 3;插黑则牵一发动全身。
  3. 调整只看叔叔,因为 c 红、p 红 → g 必黑,三者颜色锁死,u 是唯一变量。
  4. u 存在且为黑时 c 必不是新节点(黑高反证:g→p→NIL 最多 2 个黑,g→u→NIL 至少 3 个黑,规则 4 不容),这个 c 是下层变色推上来的。
  5. 三类情况一张表:u 红 → 变色继续向上;u 黑/不存在 + 同侧 → 单旋 + p 黑 g 红,终止;u 黑/不存在 + 异侧 → 双旋 + c 黑 g 红,终止。
  6. 旋转 ≤ 2 次是常数上界,变色最坏 O(logN) 层------调整成本可预期,是 STL 和内核选红黑树的真正原因。
  7. 验证必须逐条查规则,"最长 ≤ 2 倍最短"不能作为合法性判据。

插入到这里就闭环了。但红黑树还有更难的一半------删除。

删除为什么比插入难?插入时新节点是红色,最坏只破坏"无连续红"这一条,问题局部可修。删除不同:删掉一个黑 节点,会直接打破规则 4 的黑高守恒,而且这个"欠一黑"的失衡不在删除点本身,而是挂在它的替代节点上,一路向根传播 。于是删除引出了一个插入完全没有的概念------双重黑(double black):一个节点被"记了两次黑账",要借兄弟节点的红孩子来"还债"。

插入只分三类情况、看一个叔叔;删除要看兄弟节点的颜色 + 侄子节点(兄弟的两个孩子)的红黑组合,决策树比插入复杂一整档,旋转次数也放宽到最多 3 次。这一半才是红黑树真正的分水岭------面试官问插入,是看你懂不懂基础;问删除,才是看你有没有真的吃透红黑树。

删除篇我会单独讲透:双重黑的本质、兄弟节点四种情况的决策总表、以及"旋转最多 3 次"与插入篇"旋转最多 2 次"之间的定量关系。把插入这篇的五条规则和旋转吃透,删除只是在这套框架上多一种"欠一黑"的状态而已。

相关推荐
Είναι η κοπέλα1 小时前
Python 虚拟环境:venv、conda 与 Python 版本选择全解
开发语言·python·conda
by209991 小时前
学会使用 std::string 类,并理解其内部是如何管理字符串的详细阐述(下)
c++·经验分享·笔记·字符串·类和对象·string
程序员老陆1 小时前
嵌入式 Qt:QWidgets 还是 QML / Qt Quick?
开发语言·qt
.道阻且长.1 小时前
C++11 :新的类功能,lambda,包装器
开发语言·c++·后端
言乐61 小时前
JavaScript概括前端原理
开发语言·前端·javascript·python·ecmascript
liulilittle1 小时前
短期内不存在通用 AI 工作流魔法
大数据·开发语言·c++·人工智能·llm·agent·tools
_wxd6661 小时前
C++STL map与set
数据结构·c++
别动我齐刘海1 小时前
简历技术栈全面复习总结
c++·人工智能·深度学习·学习·tcp/ip·算法·rpc
郝学胜-神的一滴1 小时前
C++ Templates 03:手写一个Stack看透模板核心机制
开发语言·c++·产品运营·软件工程·产品经理