C++进阶篇(五):红黑树——四条规则、三种插入情况与旋转变色

0.1 概述&序言

这里是白杨。上一篇 AVL 用"高度差 ≤ 1"把树压得很平,代价是插入、删除时旋转频繁。这一篇换一条路:不直接管高度,用颜色约束间接控制黑高------这就是红黑树。

先给结论:红黑树没有 AVL 那么"平",但它把平衡维护的成本降下来了。10 万随机插入,AVL 单旋 69765 次,红黑树 58391 次;10 万有序建树后各做 2000 次查找,0.564ms 对 0.550ms,同一档。它不是"更快",是"综合更省"。

这篇只讲红黑树本身:四条规则、最长路径证明、结点结构、插入的三种情况、四规则验证和实测。删除不在本篇范围;用红黑树封装 mymap/myset 是下一讲的事,这里不抢跑。

老规矩,代码全部 g++ 实测,完整工程文末自取,也可以直接去仓库:gitee.com/BaiYang_SQ/rbtree-sim。

实测环境:w64devkit g++ 15.2.0(-std=c++11 -O2 -Wall -Wextra)+ VS2022;教学简化版。

标准容器参考:cppreference·map(STL 的 map/set 底层就是红黑树)。

让我们开始吧。

一、四条规则:红黑树到底约束了什么

红黑树本质上是一棵二叉搜索树,只是给每个结点加了一个颜色位:红或黑。

四条规则:

  1. 每个结点非红即黑;
  2. 根结点必须是黑色;
  3. 红结点的两个孩子必须是黑色------任意路径不能有连续红结点;
  4. 任一结点到它所有 NIL 空结点的路径上,黑结点数量相同。

规则 4 里的 NIL 不是我们平时说的"叶子",而是空指针位置。有些书把它叫外部结点,并且规定 NIL 也是黑色。实现里通常不真的存 NIL 结点,但验证黑高时要把它们算进去。

这四条里,规则 2、3、4 是核心。规则 1 用枚举类型天然保证,规则 2 检查根,规则 3 检查"红结点的父亲不能是红",规则 4 要数黑结点。

图里这棵树有 9 条根到 NIL 的路径,每条都经过 2 个黑结点。如果把 10 从黑改成红,18 → 10 → NIL 就只剩 1 个黑结点,规则 4 当场失效------颜色看着没大问题,黑高已经不对了。

【一句话记】红黑树不是靠平衡因子,而是靠"根黑、无红红、黑高相等"这三条约束,间接把树压在对数高度。

二、最长路径为什么不超过最短的 2 倍

把根到 NIL 的路径按黑结点个数来数,记作黑高 bh。

  • 规则 4 保证每条路径黑结点数一样多。最短路径可以全是黑结点,长度就是 bh。
  • 规则 2、3 保证不能有连续红结点。最长路径只能一黑一红交替,长度最多 2bh。

所以任意一条根到 NIL 的路径长度 h 满足:

text 复制代码
bh ≤ h ≤ 2bh

最长最多是最短的两倍,树高仍然是 O(logN)。这也是"弱平衡"的来源:AVL 要求高度差 ≤ 1,红黑树只要求路径长度不超过两倍。

换成分结点数的不等式:黑高为 bh 的红黑树,最少结点数约 2^bh - 1,最多是满的 2^(2bh) - 1;两边取对数,bh 仍是 O(logN)。所以红黑树不需要存高度或平衡因子,靠颜色就能把最坏情况压住。

【面试】红黑树怎么保证 O(logN)?------黑高相等 + 不能连续红,推出最长路径 ≤ 2×最短路径,高度对数级。

三、结点结构:颜色、父指针、KV

红黑树结点比普通 BST 多一个颜色和一个父指针。

cpp 复制代码
enum Colour
{
    RED,
    BLACK
};

template <class K, class V>
struct RBTreeNode
{
    pair<K, V> _kv;
    RBTreeNode<K, V>* _left;
    RBTreeNode<K, V>* _right;
    RBTreeNode<K, V>* _parent;   // 向上处理红红相连
    Colour _col;

    RBTreeNode(const pair<K, V>& kv)
        : _kv(kv), _left(nullptr), _right(nullptr), _parent(nullptr), _col(RED)
    {}
};

_parent 不能省:插入后如果出现红红相连,要沿着父指针找叔叔、祖父,再决定变色还是旋转。_col 默认给红色,正好对应"非空插入先染红"的策略。

颜色本身用一个 enum 存,额外空间主要来自 _parent 指针。和 AVL 的 _bf 比,红黑树不存高度差,颜色就是它的"平衡信息"。

四、插入总览:新结点为什么一定是红色

插入分两步:先按 BST 规则挂到空位,再检查红红相连。

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

叔叔只有两种大情况:

叔叔 处理 是否旋转
u 存在且为红 p、u 变黑,g 变红,g 当新的 c 继续向上 不旋转
u 不存在 / u 为黑 看 p、c 是否同侧:同侧单旋,异侧双旋 旋转 + 变色

判断顺序不能反:先看叔叔红不红;叔叔红就只变色,叔叔黑/空才进入旋转分支。叔叔红时 p、c 同侧还是异侧都无所谓,处理完全一样。

【必背】空树根黑;非空新结点红;父黑结束;父红看叔叔。

【易错】非空插入不要染黑------黑高被破坏后,不是一次变色能救回来的。

五、叔叔为红:只变色,继续向上

p 和 u 都是红,g 是黑。把 p、u 变黑,g 变红:

cpp 复制代码
parent->_col = uncle->_col = BLACK;
grandfather->_col = RED;
cur = grandfather;
parent = cur->_parent;

为什么这样行?p、u 两边各多一个黑结点,g 变红少一个黑结点,g 这棵子树的黑高不变 ;同时 c 和 p 的连续红被拆开了。但 g 变红后可能和它的父亲又红红相连,所以要把 g 当成新的 c 继续往上。

如果 g 就是根,循环结束后会统一把根染黑。

可以把变色理解成"把黑色从 g 往下分给 p 和 u":黑结点总数没变,只是位置换了;g 变红后如果父亲也是红,就继续把问题往上推,直到根或者父亲为黑色。

实测叔叔为红的最小用例 {10, 5, 15, 3}:

text 复制代码
插入 3:
  新结点 3 为红色,挂到 5 左边
  叔叔 15 为红:p/u 变黑,g=10 变红,继续向上
  根 10 染黑
  当前树: 10(B)[5(B)[3(R), NIL], 15(B)]
高度=3,Size=4,平衡=1,单旋=0,变色事件=2

【必背】叔红只变色,不旋转;g 变红后当新 c 继续向上,最后根染黑。

六、叔叔黑/空:单旋 + 变色

叔叔不存在,或者叔叔存在但为黑,单靠变色解决不了连续红,必须旋转。

先看 p 是 g 的左孩子、c 是 p 的左孩子,也就是 LL:

以 g 为轴右单旋,然后:

cpp 复制代码
RotateR(grandfather);
parent->_col = BLACK;   // p 变黑
grandfather->_col = RED; // g 变红

旋转的指针操作和 AVL 完全一样,只是不需要更新平衡因子:

cpp 复制代码
// 右单旋的关键几步
Node* subL = parent->_left;
Node* subLR = subL->_right;
Node* parentParent = parent->_parent;

parent->_left = subLR;
if (subLR) subLR->_parent = parent;

subL->_right = parent;
parent->_parent = subL;

// 再把 subL 接到 parentParent(或更新 _root)

旋转后 p 成为子树根,染黑;g 变成 p 的孩子,染红。这样黑高不变,连续红也没了,而且 p 的父亲无论红黑都不会再违规,不需要继续向上。

为什么黑高不变?p 由红变黑、g 由黑变红,一进一出,子树黑结点数守恒;旋转只是把 p 提上来,把连续红拆开。颜色和指针各干各的,别混在一起。

RR 是镜像:p 是 g 的右孩子、c 是 p 的右孩子,以 g 为轴左单旋,p 黑、g 红。

实测 LL 最小用例 {10, 5, 3}:

text 复制代码
叔叔黑/空:LL -> RotateR(10),p 黑 g 红,结束
当前树: 5(B)[3(R), 10(R)]
高度=2,Size=3,平衡=1,单旋=1,变色事件=1

【必背】p、c 同侧 → 单旋;LL 右单旋,RR 左单旋;p 黑、g 红,旋转后结束。

【易错】旋转本身不改颜色,颜色是旋转之后在调用处改的;别把变色写进 RotateR,否则双旋会乱。

七、叔叔黑/空:双旋 + 变色

p 是 g 的左孩子,c 是 p 的右孩子,也就是 LR。此时 p、c 异侧,单旋完还是不平衡,要两次旋转。

步骤:

  1. 先以 p 为轴左单旋;
  2. 再以 g 为轴右单旋;
  3. c 变黑,g 变红,p 保持红。
cpp 复制代码
RotateL(parent);
RotateR(grandfather);
cur->_col = BLACK;       // c 变黑
grandfather->_col = RED; // g 变红

RL 是镜像:p 是 g 的右孩子、c 是 p 的左孩子,先以 p 为轴右单旋,再以 g 为轴左单旋,c 黑、g 红。

第一次旋转把 c 提到 p 的位置,p 变成 c 的孩子;这时 p、c 的关系从"异侧"变成了"同侧",第二次旋转就退化成单旋。颜色最后一次性改:c 黑、g 红,p 保持红。

实测 LR 最小用例 {10, 5, 7}:

text 复制代码
叔叔黑/空:LR -> RotateL(5) + RotateR(10),c 黑 g 红,结束
当前树: 7(B)[5(R), 10(R)]
高度=2,Size=3,平衡=1,单旋=2,变色事件=1

【必背】p、c 异侧 → 双旋;LR 先左旋 p 再右旋 g,RL 镜像;c 黑、g 红,p 保持红。

【易错】单旋和双旋的触发文字一模一样,唯一区别是 p、c 是否同侧。不要只看"叔叔黑"就动手,先看方向。

【面试】为什么双旋能成?第一次旋转把"折线"转成"直线",第二次旋转就退化成单旋;颜色最后由 c、g 收尾。

八、验证:四条规则缺一不可

只检查"最长路径 ≤ 2×最短路径"不靠谱:颜色可能已经错了,只是暂时没出问题,继续插入就会炸。正确做法是直接查四条规则。

cpp 复制代码
bool IsBalanceTree() const
{
    if (_root == nullptr) return true;
    if (_root->_col != BLACK) return false;   // 规则2

    Node* prev = nullptr;
    if (!_CheckOrder(_root, prev)) return false;      // BST 有序
    if (!_CheckParent(_root, nullptr)) return false;  // parent 指针

    int refBlack = 0;
    Node* cur = _root;
    while (cur)
    {
        if (cur->_col == BLACK) ++refBlack;
        cur = cur->_left;
    }
    return _CheckColour(_root, 0, refBlack);  // 规则3 + 规则4
}

_CheckColour 的核心:

cpp 复制代码
if (root == nullptr) return blackNum == refNum;
if (root->_col == RED && root->_parent && root->_parent->_col == RED)
    return false;                      // 规则3:红红相连
if (root->_col == BLACK) ++blackNum;   // 规则4:累计黑结点
return _CheckColour(root->_left, blackNum, refNum)
    && _CheckColour(root->_right, blackNum, refNum);

两组经典序列实测:

text 复制代码
--- 常规序列 ---
中序: 3 7 9 11 14 15 16 18 26
高度=4,Size=9,平衡=1,单旋=8,变色事件=9

--- 双旋序列 ---
中序: 1 2 3 4 5 6 7 14 15 16
高度=4,Size=10,平衡=1,单旋=1,变色事件=5

【易错】"颜色都合法"不等于"是红黑树"。某条路径黑高少一个,颜色看不出问题,但已经不是红黑树了。

比如根 18 黑,左 10 红(左空),右 30 黑、30 的左 25 黑:18 → 10 → NIL 只有 18 一个黑,18 → 30 → 25 有 18、30、25 三个黑,黑高不等,颜色再漂亮也不合格。

检查红红相连时,遍历到红结点反查父亲更省事:每个结点只有一个父亲,不用判断两个孩子是否存在。

九、实测:RB vs AVL vs BST

10 万个结点,全部按 1...N 有序插入:

text 复制代码
RB:高度=31,Size=100000,平衡=1,单旋=99969,变色事件=199948,建树=9.8 ms
AVL:高度=17,Size=100000,平衡=1,单旋=99983,建树=7.4 ms
BST:高度=100000(有序插入退化成单链,与逐个插入结果一致)
2000 次随机查找:RB 命中 2000,耗时 0.550 ms
2000 次随机查找:AVL 命中 2000,耗时 0.564 ms
2000 次随机查找:BST 命中 2000,耗时 399.617 ms

红黑树高度 31,AVL 17,但查找耗时 0.550ms 对 0.564ms,基本打平。高度差一倍,查找只差几个微秒------因为它们都是 O(logN),常数才是关键。

10 万随机插入:

text 复制代码
RB 随机插入 100000 个:高度=20,Size=100000,平衡=1,单旋=58391,变色事件=90348
AVL 随机插入 100000 个:高度=20,Size=100000,平衡=1,单旋=69765
再插一遍同样的 100000 个:失败 100000 次,Size 仍为 100000

随机数据下,红黑树单旋 58391 次,AVL 69765 次,红黑树确实更少;有序数据下两者几乎打平(99969 对 99983)。所以"红黑树旋转更少"不是绝对定律,得看数据分布------但它确实用更松的平衡换到了同一档查询性能。

普通 BST 的 399.617ms 也正常:单链平均要迈 5 万步,2000 次查找就是 1 亿次比较;红黑树 31 层,2000 次只有几万步,差距被拉到约 727 倍。

这也解释了 STL 的选择:map/set 既要查询稳定,又要插入删除别太贵,红黑树比 AVL 更均衡。纯查询、几乎不修改的场景,AVL 的 17 层会更香;读写都频繁,红黑树的 31 层也够用。

计时数字会随机器波动,树高和平衡结果是确定的。旋转次数按单旋统计,双旋记 2 次;变色次数按事件统计,一次可能改 2~3 个结点。普通 BST 的单链是直接构造的,结构和逐个有序插入完全一致,省掉 O(N²) 建树时间。

【一句话记】红黑树不是"查询更快",是"平衡维护更省,查询仍在同一档"。

十、速记卡

考前扫一眼,插入不转晕:

附录 A:完整代码仓库

完整工程(单文件版,全部实验一次跑完)在 Gitee:

https://gitee.com/BaiYang_SQ

仓库文件 说明
rbtree_sim.cpp 红黑树结点、插入、旋转、四规则验证;附 AVL 对照计数
output.txt 五个最小用例、两组序列、10 万 RB/AVL/BST 全部真实输出
build.bat Windows 一键编译 + 运行 + 刷新 output.txt
README.md 一分钟上手 + 代码导览

插入的分派就三句话:

cpp 复制代码
if (uncle && uncle->_col == RED)          // 叔红:变色,继续向上
{
    parent->_col = uncle->_col = BLACK;
    grandfather->_col = RED;
    cur = grandfather;
    parent = cur->_parent;
}
else if (cur == parent->_left)            // 同侧:单旋 + p 黑 g 红
{
    RotateR(grandfather);
    parent->_col = BLACK;
    grandfather->_col = RED;
    break;
}
else                                      // 异侧:双旋 + c 黑 g 红
{
    RotateL(parent);
    RotateR(grandfather);
    cur->_col = BLACK;
    grandfather->_col = RED;
    break;
}

(上面是 parent 在 grandfather 左边的分支,右边完全镜像;完整版见仓库。)

好了,这一篇到此完结!下一篇继续进阶篇主线------用红黑树封装 mymap/myset :把颜色、旋转和迭代器包进一个能直接用的容器里。如果对你有帮助,不要忘记点赞三连一波哦,我是白杨,我们下期见。

相关推荐
.道阻且长.18 分钟前
Linux基本开发工具:gdb和cgdb的使用
linux·运维·服务器·开发语言
starzy19901 小时前
深入理解Scala匿名函数:函数式编程基石
开发语言·scala
旖旎夜光1 小时前
【C++ 基础入门】C++ 继承(上):概念、切片、隐藏、默认成员函数与友元
c++·笔记·学习
apcipot_rain2 小时前
C++转Java日记(05)
java·开发语言·c++
apcipot_rain10 小时前
C++转Java日记(04)
java·开发语言·c++
云和数据.ChenGuang12 小时前
LangGraph 异步 Node 的设计与写法实践
开发语言·人工智能·python·机器学习·php
WL学习笔记13 小时前
vector模拟实现
c++
蜗牛一步步往上爬15 小时前
第十六章 C++语言在深度学习中的应用
开发语言·c++·深度学习
张张123y17 小时前
DSH 插件调试避坑:client.js 设置面板、热重载与高频报错排查表
开发语言·javascript·人工智能·开源·ecmascript·后端开发·ai agent