在讲平衡二叉树的章节里,我们已经认识了 AVL 树:它通过给每个结点保存一个平衡因子(左子树高度减右子树高度),一旦某个结点左右子树高度差超过 1,就立刻旋转来"掰正"。AVL 树把平衡控制得非常严格,所以它是最"标准"的平衡树。但 AVL 树有一个不大不小的遗憾——它对平衡的要求太苛刻了,以至于为了维持这种"完美",插入和删除时经常要转圈圈。

你可能要问了:都做到高度平衡了,为什么还不满足?答案藏在性能上。AVL 树的平衡是"严格平衡"(任意结点的左右子树高度差不超过 1),这个要求意味着每插入一个结点,只要某个祖先结点失衡,就至少要旋转一次。插入大量数据时,旋转次数相当可观。有没有一种结构,平衡程度放松一点点,让旋转少一点,但查询效率依然能稳稳地维持在 O(logN)?这就是红黑树登场的原因。

先给你一个名字上的直觉,方便后面理解为什么它叫"红黑":红黑树给每个结点贴上一张"颜色"标签(红或黑)。红色结点在视觉上像"易燃物"——红不得连着红;而黑色结点才是"地基",负责扛起高度。整棵树就靠这一条"颜色纪律"来约束高度,而不是像 AVL 那样去精打细算每一层的高度差。下面我们一层一层把它剥开,让你不仅会背规则,更知道每条规则"为什么在这里"。

为什么还需要红黑树:AVL 的遗憾

先说结论:红黑树和 AVL 树的效率属于同一档次,都是 O(logN) 的增删查改。区别在于对平衡的控制"松紧"不同——AVL 是严格平衡,红黑树是近似平衡。

学过 AVL 的你应该还记得两大前置知识。第一,二叉搜索树(BST):任意结点的左子树所有结点都小于它,右子树所有结点都大于它。二叉搜索树的查找效率取决于树高,最坏情况下(数据有序插入)会退化成链表,树高变成 N。第二,树高决定了查询的时间复杂度,所以平衡树的目标就是尽量压低树高。

AVL 树的思路,你可以理解为"目测"——每个结点看一眼左右子树谁高谁矮,差得太多就动手转。这套机制很直观,效果也很好。但"目测太准"的代价是:AVL 树为了保证任何结点高度差不超过 1,纪律极其严格,插入时经常触发旋转。这里有个隐含的成本:旋转是有开销的,尤其是需要一路向上传播的多次旋转。

红黑树的思路完全不同——它不去"目测高度",而是用颜色给结点贴上标签,然后用几条关于颜色的规则,间接保证树不会失衡得太厉害。因为控制得宽松,插入相同数量的结点时,红黑树的旋转次数明显少于 AVL 树。这就是它最大的价值:用一点点高度的牺牲,换来了更少的旋转,从而在整体性能上更"能打"。

为了让你对这个"两者同一档次、但特点不同"有更清晰的认识,我们来对比一张表:

对比维度AVL 树红黑树
平衡标准严格平衡:任意结点左右子树高度差 ≤ 1近似平衡:最长路径 ≤ 最短路径的 2 倍
靠什么控制每个结点维护平衡因子(高度差)每个结点维护颜色(红/黑)
是否需额外字段平衡因子(int)颜色(1 bit 即可,实现常用枚举)
最坏树高更矮(约 1.44·logN)稍高(最多 2·logN)
插入旋转次数相对较多(严格要求高度差)相对较少(放得松),变色多
查询效率略快(树更矮)略慢一点点(树稍高)
适用场景频繁查询、很少增删增删频繁的场景(如 map/set 底层)

看到没有,红黑树拿一点查询上的小牺牲,换来了增删时大幅减少的旋转。对大量"边查边插边删"的场景,这是更划算的买卖。

实际工程里,红黑树几乎是"平衡查找树"的代名词。它是下面这些知名结构/系统的底层实现,你随手一抓都是它的身影:

  • C++ STL:map、set、multimap、multiset 的标准实现(如 libstdc++、libc++、MSVC 的 STL)底层就是红黑树,因此它们能保证增删查都是稳定的 O(logN),并且 map 迭代时按键有序。
  • Linux 内核:完全公平调度器(CFS)用红黑树按"虚拟运行时间"组织就绪进程;内核定时器、epoll 的事件就绪状态、虚拟内存管理(VMA,即虚拟内存区)也广泛使用红黑树(内部叫 rb_tree、interval tree)。
  • Java:TreeMap、TreeSet 底层就是红黑树;JDK 8 里 HashMap 的单个桶内链表内容过多(≥ 8 个)时会转成红黑树,就是为了防止哈希冲突严重时链表退化成 O(N)。
  • 其他:Nginx、Windows 内核、许多数据库的内存索引,也都大量用红黑树。

所以,学会红黑树绝不只是"为了应付考试"——它是你在工程里天天间接打交道的数据结构。下面我们正式开始,把这个"带颜色的搜索树"层层剥开。

前置铺垫:二叉搜索树、旋转与树高

在动手写红黑树之前,先把三个会反复用到的基础概念温习一遍,免得后面卡壳。

二叉搜索树(BST) 我们前面已经提到:一个结点左边小、右边大。插入时从根出发,比当前结点小就往左走,大就往右走,走到空位就挂上去。查找同理。它的所有操作的时间复杂度都取决于树高——树越高越慢。最坏情况是数据恰好有序插入(比如 1、2、3、4、5…依次插入),树会退化成一个"只有右孩子"的链表,树高变成 N,那查找就退化成了 O(N),这是任何 BST 都要防的灾难,平衡树的全部意义就是避免这种情况。

旋转 是平衡树的"整形手术",分左旋和右旋两种。左旋可以理解为:以某个结点它的右孩子为轴,把"右子树往上提"。右旋同理但方向相反。旋转只会改变几个指针的指向,不改变中序遍历的顺序,所以不会破坏二叉搜索树"左小右大"的性质。为什么旋转不改变中序?我来拆给你看:中序遍历的顺序是"左→根→右",左旋时,原来是 parent 的 subR 被提上来当根,subRL(原来是 subR 的左孩子)挪去当 parent 的右孩子。关键在于 subRL 的取值夹在 parent 和 subR 之间(因为 BST 里 parent < subRL < subR),而左旋前中序是 …parent, subRL, subR…,左旋后变成 …subRL, parent… 且 subR 原本右子树仍在最右——整棵子树的中序序列一个元素都没变,只是换了指针。这就是"旋转保持 BST 性质"的根本原因。AVL 树你已经写过左右旋了,红黑树直接复用同一套旋转代码,只是不需要维护平衡因子——因为红黑树压根没有平衡因子这个字段。

树高 就是从根走到最远叶子经过的结点数量。平衡树的本质,就是让树高维持在对数级别,从而保证查询时间是 O(logN)。注意,这里要区分两个容易混淆的高度概念:红黑树里我们常说的"树高"(height,走最远路径经过的结点数)和黑高(black height,简记 bh,从某结点到叶子经过的黑色结点个数)——石头长、后面讲性质时黑高会反复登场,先记个名字。

好,这三个概念在脑子里是热的,接下来我们看红黑树到底用什么"魔法"控制平衡。

红黑树的性质

红黑树,就是一棵每个结点都额外带一个"颜色"标记(红色或黑色)的二叉搜索树。它通过约束每条从根到叶子的路径上各个结点的颜色,确保没有一条路径会比别的路径长出两倍,从而保持接近平衡。

先澄清一个小问题:红黑树的性质到底有几条?我在课件里把它写成 4 条,但《算法导论》等主流教材通常会把它补充成 5 条——多出来的那条就是"每个叶子结点(空结点,也叫 NIL)都是黑色"。这里的"叶子"不是传统意义上的叶子结点,而是指那些不存在的空结点(有些书叫"外部结点",英文 NIL)。引入 NIL 是为了让"每条路径"的表述更精确——每条从根到叶子的路径,都正好终结在一个 NIL 上,这样黑结点数量才好统一计数。不过《算法导论》后续讲实现细节时其实也忽略了这些 NIL 结点,所以你只需要知道这个概念即可。综合课件约定,常说的"红黑树五大性质"是这样的:

  1. 每个结点不是红色就是黑色。 这是最基本的,实现时用一个枚举就能保证。
  2. 根结点是黑色的。 根不能是红。
  3. 如果一个结点是红色,那么它的两个孩子必须是黑色。 换句话说,任意一条路径上都不会出现连续两个红色结点。这是红黑树最核心的一条约束。
  4. 对任意一个结点,从它出发到它所有的空结点(NIL)的简单路径上,包含相同数量的黑色结点。 这条保证了"黑结点的分布是均匀的",是红黑树平衡的根本。
  5. 每个叶子结点(空结点 NIL)都是黑色。 如前所述,这是为计数严谨而补的约定。

下面把这五条逐个"掰开",每一则你都该明白"它存在是为了堵住哪个洞"。

性质 1(非红即黑):纯粹是类型约定,用枚举 enum Colour { RED, BLACK } 就让编译器替你保证颜色只有这两种取值,这一条几乎不花钱。

性质 2(根为黑):你要注意,我特意没有沿用"根不能是红,否则第 3 条被打破"这种说法——因为严格说红根并不必然违反性质 3(红结点的孩子可以是黑)。真正准确的理解是两层的:其一,根没有父亲,"红结点必须有黑父亲/红结点的孩子必须黑"这条规则在根这个边界上需要有个干净的处理方式,把根固定涂黑,就免去了一切"根是红时它上面怎么办"的额外分支;其二,配合性质 5(NIL 是黑),根为黑能保证整棵树的黑高(bh)从根的第一层算起就有一个确定的"基线",让性质 4 的计数在根这里干净统一。所以性质 2 本质上是一条"约定 + 兜底",工程代码里通常会在插入的最后写一句 _root->_col = BLACK; 把根无条件刷成黑色,就是替这条兜底。

性质 3(红结点不能有红孩子):这条是红黑树的"灵魂"。它的直接推论是"任意路径上不会出现连续两个红结点"——因为一旦出现连续红,那上面的那个红的其中一个孩子就红了,违反定义。这种"红不过二"的约束在物理上限制了一条路径里红色结点的密度(红之间必须隔着黑),这正是后面证明"最长路径 ≤ 2 倍最短路径"的核心依据。将来写验证代码时,判断违规的方式很巧妙:与其检查"每个红结点的孩子是不是黑"(孩子有两个、还可能为空,不好查),不如反过来,碰见红结点就查它的父亲是不是红——父亲最多一个、且必然存在(除了根),这话在验证一节再展开。

性质 4(每条路径黑结点数相同):这是红黑树能在全局上"平衡"的根本。它规定从任意结点出发到它所有后代 NIL 的路径,黑色结点数量必须完全一样。这一条管的是"黑":黑结点是承担高度的"骨架",把每一路的骨架厚度统一了,树就不可能某一侧特别高。它也是最难维护的一条,后续你会看到,插入一个新结点时我们尽量不让黑色数量失衡,一旦失衡就得靠旋转来"借黑还黑"。

性质 5(NIL 是黑):这条纯粹是为性质 4 的计数提供"终点基准"。正因为叶子 NIL 是黑的但不算进黑高,我们才能说"每条实际路径终结在一个黑色 NIL 上",从而让黑高的定义没有歧义。实现时可以完全不建 NIL 结点(用 nullptr 充当),性质 4 的验证也就天然成立。

你看,四条主规则里,第 3 条管"红",第 4 条管"黑"。后面会频繁提到两个概念,这里先给出定义。所谓红结点,就是颜色为红色的结点,它唯一的宿命是"不能有红孩子"。所谓黑高(black height,记作 bh),就是从某个结点走到它的任一叶子(NIL)所经过的黑色结点的个数。第 4 条说的就是把整棵树每条路径的黑高统一成一个固定值。请你先记住"黑高"这个名字,证明最长路径那节全靠它。

给一句话总结这一节:红黑树的平衡,本质上是"红被限制、黑被均分"这两条纪律逼出来的。

红黑树结点的定义(含 color)

有了性质,马上落实到代码。结点在 BST 的基础上,增加一个 _col(color)字段来存放颜色。别忘了,和 AVL 树一样,为了插入后能方便地向上调整,每个结点还要带一个 _parent 指针。我们这块按 key/value 的结构来实现,颜色用一个枚举表示。

#include <iostream>
#include <utility>   // 引入 std::pair
using namespace std;
 
// 枚举值表示颜色:红色、黑色
enum Colour
{
    RED,
    BLACK
};
 
// 红黑树结点:默认按 key/value 结构实现
template<class K, class V>
struct RBTreeNode
{
    pair<K, V> _kv;              // 存放键值对,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 指针是红黑树(和 AVL 树)的"必需品"。回顾普通 BST,插入只需要 _left/_right 就够了,因为插入是从根自顶向下找空位,一路带一个 parent 局部变量即可。但红黑树插入后的"修正"是自底向上的——一旦发现连续红,要从 cur 往上追 parent、grandfather,变色不满足还得继续往上提。如果没有 _parent,你必须在插入时花功夫保存祖先链,既麻烦又容易错。所以,一个 _parent 指针换来的是调整过程随心所欲地"往上爬",这笔账非常划算。

第二,颜色为什么用枚举而不是 bool?用 bool(true/false)也能表达两种状态,但颜色这东西用枚举命名更语义化——写代码时一眼认出 RED/BLACK,而不是去看 true 到底代表红还是黑。这在工程上属于"可读性"的考量。

第三,颜色字段到底占多大内存?实际上红黑树的颜色在教科书实现里只需要 1 个比特。这里我们用 Colour 枚举,通常占 4 字节(一个 int 的大小),相对土豪;Linux 内核里为了省内存,甚至用指针低比特位来"藏"颜色。对你手写学习来说,枚举最简单直白,不用纠结优化。

第四,也是最关键的——构造函数里颜色默认给 RED(红色)。为什么新结点默认要是红的?让我把"守恒"逻辑讲透:回忆性质第 4 条——从任意结点到它的每个叶子路径,黑色结点数量必须相同。这条性质的精髓是"整棵树的黑高在每条路径上都一致"。如果一个新结点默认是黑色,那么它所在的那条路径就会"凭空"多出一个黑色结点 → 这条路径的黑高比兄弟路径大 1 → 立刻违反性质 4。而性质 4 恰恰是最难维护的一条(一旦破坏,往往需要一路向上借黑、还黑),代价极高。反过来说,让新结点默认是红色,红结点不影响黑高计数,所以它绝不会破坏性质 4;它顶多可能违反性质 3(出现连续红),而这种"连续红"我们完全可以靠"变色 + 旋转"在局部轻而易举地修好。一句话总结:新增默认红,是把"可能的违规"从难维护的黑高问题(性质 4),转移成好维护的连续红问题(性质 3)。这个坑必须牢记,它是理解红黑树插入调整的一把总钥匙。

(补充一个边界情形:空树插入——当树是空树时,新结点直接成为根。根是没有父亲的,而红结点"必须有某种约束"在根这里没有依托;更直接的是,性质 2 明确要求根是黑色。所以空树插入时,新结点虽是红创建,但紧接着就被强制染成黑色,这一步在插入函数里单独处理。)

为了让结点定义能独立运行、帮你亲手感受颜色字段,下面给一个完整的可编译小程序:

#include <iostream>
#include <utility>
using namespace std;
 
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) {}
};
 
int main()
{
    RBTreeNode<int, int> cur( make_pair(7, 7) );
    cout << "新结点刚创建时的颜色是: "
         << (cur._col == RED ? "RED(红)" : "BLACK(黑)") << endl;
    // 新结点默认红:为了不破坏"黑高守恒"(性质 4),把新结点默认为红、只可能撞"连续红"(性质 3)
    cur._col = BLACK;   // 一旦需要,我们手动把它染黑(例如根结点)
    cout << "手动染黑后颜色是: "
         << (cur._col == RED ? "RED(红)" : "BLACK(黑)") << endl;
    return 0;
}

现在结点有了、类型有了,还差最核心的东西——插入和调整。但在写插入之前,得先把"旋转"这个手术刀准备好,因为红黑树的调整重度依赖旋转。

左旋与右旋

旋转就是"指针的乾坤大挪移",它不改变数据本身,只改变结点的父子关系,因此保持中序遍历不变、也就保持 BST 性质不变。红黑树旋转的代码和你写过的 AVL 旋转一模一样,唯一区别是不需要更新平衡因子(红黑树根本没有平衡因子这个字段)。

先看左旋。以 parent 为轴左旋的道理是:让 parent 的右孩子 subR 顶上来当这个子树的根,parent 变成 subR 的左孩子。如果 subR 有左孩子 subRL,把它接给 parent 当右孩子。说白了就是"右边大的往上提,中间的过渡结点挪到左边当右孩子"。画成拓扑就是:

左旋前              旋转后(左旋)
   p                  sR
     \                /
     sR             p
    /  \              \
   sRL  ...           sRL

subRL 是关键:它在 BST 中夹在 p 和 sR 之间(p < sRL < sR),左旋前它是 sR 的左孩子,左旋后它变成 p 的右孩子——位置变了,但依然排在 p 之后、sR 之前,所以中序一点没乱。

// 左旋:以 parent 为旋转点,将其右孩子提升为子树根
void RotateL(Node* parent)
{
    Node* subR = parent->_right;   // subR 为 parent 的右孩子
    Node* subRL = subR->_left;     // subRL 为 subR 的左孩子
 
    // 第一步:parent 的右孩子改为 subRL
    parent->_right = subRL;        // subRL 挂到 parent 的右边
    if (subRL)                     // subRL 可能存在,也可能为空
        subRL->_parent = parent;   // 若存在则更新其父亲为 parent
 
    Node* ppNode = parent->_parent; // 记录 parent 原本的父亲
 
    // 第二步:subR 顶上,接管 parent 的孩子位置
    subR->_left = parent;          // parent 变成 subR 的左孩子
    parent->_parent = subR;        // parent 的父亲改为 subR
 
    // 第三步:把 subR 接到祖父 ppNode 上
    if (ppNode == nullptr)         // parent 原本就是根
    {
        _root = subR;              // 新的根就是 subR
        subR->_parent = nullptr;   // 根的父为空
    }
    else
    {
        if (ppNode->_left == parent)
            ppNode->_left = subR;  // 是祖父的左孩子,则更新祖父左指针
        else
            ppNode->_right = subR; // 否则更新祖父右指针
        subR->_parent = ppNode;    // subR 的父亲设为祖父
    }
}

再来看右旋,和左旋完全对称。以 parent 为轴右旋,就是让 parent 的左孩子 subL 顶上来当子树根,parent 变成 subL 的右孩子,subL 原来的右孩子 subLR 挪给 parent 当左孩子。你可以把左右旋当成一对镜像操作来记忆。

// 右旋:以 parent 为旋转点,将其左孩子提升为子树根
void RotateR(Node* parent)
{
    Node* subL = parent->_left;    // subL 为 parent 的左孩子
    Node* subLR = subL->_right;    // subLR 为 subL 的右孩子
 
    // 第一步:parent 的左孩子改为 subLR
    parent->_left = subLR;         // subLR 挂到 parent 的左边
    if (subLR)                     // 判空,subLR 可能为空
        subLR->_parent = parent;   // 若存在则更新其父亲
 
    Node* ppNode = parent->_parent; // 记录 parent 原本的父亲
 
    // 第二步:subL 顶上,接管 parent 的孩子位置
    subL->_right = parent;         // parent 变成 subL 的右孩子
    parent->_parent = subL;        // parent 的父亲改为 subL
 
    // 第三步:把 subL 接到祖父 ppNode 上
    if (ppNode == nullptr)         // parent 原本是根
    {
        _root = subL;              // 新根是 subL
        subL->_parent = nullptr;
    }
    else
    {
        if (ppNode->_left == parent)
            ppNode->_left = subL;  // 更新祖父的左指针
        else
            ppNode->_right = subL; // 更新祖父的右指针
        subL->_parent = ppNode;    // subL 的父亲设为祖父
    }
}

旋转这段代码有几个必须盯住的指针细节,写错一个树就"断链",我念给你听:

  1. 被挪走的中间结点可能为空(subRL/subLR),所以接指针前要先判空(if (subRL)),不为空才改它的 _parent。
  2. parent->_parent 要先存到临时变量 ppNode(或者函数开头就读取),否则一旦 parent 被接到 subR/subL 下面,原父亲的引用就丢了,后面没法把旋转后的新根挂回祖父。
  3. 必须区分"祖父为空(parent 原本是根)"和"祖父非空"两种情况:祖父为空,新的子树根直接当整棵树的根,其 _parent 置空;祖父非空,还要判断 parent 是祖父的左还是右,把新根正确地挂回去。漏了这一步,树就又断了。

为了让你真切看到"旋转不改变中序",这里给一个完整可独立编译的迷你演示程序——它构造一棵小 BST,做一次左旋再右旋回来,每次打完都打印中序遍历,你会发现序列始终不变:

#include <iostream>
using namespace std;
 
struct Node
{
    int val;
    Node* left;
    Node* right;
    Node* parent;
    Node(int v) : val(v), left(nullptr), right(nullptr), parent(nullptr) {}
};
 
// 独立版的左旋(演示用,逻辑与红黑树里的 RotateL 一致)
void rotateLeft(Node*& root, Node* parent)
{
    Node* subR = parent->right;
    Node* subRL = subR->left;
    parent->right = subRL;
    if (subRL) subRL->parent = parent;
    Node* pp = parent->parent;
    subR->left = parent;
    parent->parent = subR;
    subR->parent = pp;
    if (pp == nullptr) root = subR;
    else if (pp->left == parent) pp->left = subR;
    else pp->right = subR;
}
 
// 独立版的右旋(演示用,与 rotateLeft 镜像)
void rotateRight(Node*& root, Node* parent)
{
    Node* subL = parent->left;
    Node* subLR = subL->right;
    parent->left = subLR;
    if (subLR) subLR->parent = parent;
    Node* pp = parent->parent;
    subL->right = parent;
    parent->parent = subL;
    subL->parent = pp;
    if (pp == nullptr) root = subL;
    else if (pp->left == parent) pp->left = subL;
    else pp->right = subL;
}
 
void destroy(Node* r)
{
    if (!r) return;
    destroy(r->left);
    destroy(r->right);
    delete r;
}
 
void inorder(Node* r)
{
    if (!r) return;
    inorder(r->left);
    cout << r->val << ' ';
    inorder(r->right);
}
 
int main()
{
    // 构造一棵小 BST:根10,左8,右12;8的左6、右9
    Node* root = new Node(10);
    Node* n8 = new Node(8);
    Node* n12 = new Node(12);
    Node* n6 = new Node(6);
    Node* n9 = new Node(9);
    root->left = n8;   n8->parent = root;
    root->right = n12; n12->parent = root;
    n8->left = n6;     n6->parent = n8;
    n8->right = n9;    n9->parent = n8;
 
    cout << "旋转前          中序遍历: ";
    inorder(root); cout << endl;
 
    rotateLeft(root, root);   // 以根 10 为轴左旋
    cout << "以10左旋后      中序遍历: ";
    inorder(root); cout << endl;
 
    rotateRight(root, root);  // 再以当前根右旋回来
    cout << "再以新根右旋后  中序遍历: ";
    inorder(root);            // 和第一次完全一样,说明旋转不改变中序
    cout << endl;
 
    destroy(root);
    return 0;
}

运行它,三次打印的序列都是 6 8 9 10 12——一尘不染。这就直观证明了"旋转只改形态、不改中序、不坏 BST 性质",也正因如此,我们才敢放心地用旋转去调平衡、而不用担心把"左小右大"打乱。

写到这里你可以发现,旋转本身没有任何"红黑"逻辑,它只是改变树的形态。真正精巧的部分在于:什么时候该转、转完该把谁涂成什么颜色。这就是红黑树最烧脑的地方——插入后的调整。

插入:先按 BST 插,再修正

红黑树的插入分两步走:第一步完全按二叉搜索树的规则把新结点插进去;第二步检查是否违反红黑树性质,如果违反了就做"变色 + 旋转"来修正。

整个插入的大概过程是这样的:

  1. 按二叉搜索树规则找到插入位置,把新结点挂上去,然后只需要观察它是否违反红黑树的规则。
  2. 如果是空树插入,也就是新结点直接成为根,那它必须是黑色结点(性质 2 要求根是黑的)。
  3. 如果是非空树插入,新增结点必须是红色(原因前面已经深挖过,红线少、好修)。如果它的父亲是黑色,那么"红结点没有红孩子"这条不违反,插入直接结束——这是最理想的情况。
  4. 如果父亲是红色,就违反性质 3(出现了连续红)。进一步分析:此时新增结点 c 是红、父亲 p 是红,那么祖父 g 必然是黑色(因为黑树上不可能出现两个连续红)。这三个结点的颜色都固定了,关键变数全在叔叔 u(p 的兄弟)那里。于是要根据 u 不同,把调整分成几种情况。

这里先约定新引入的三个符号,后面一路用到:新增结点记为 c(cur),它的父亲记为 p(parent),父亲的父亲记为 g(grandfather,祖父),父亲的兄弟记为 u(uncle,叔叔)。

顺带说明一点:为什么"如果父亲是红,祖父必是黑"?因为如果有连续的三个红(p 红 + g 红),那在更早的某次调整里早就该被修正掉了;我们现在考察的是"修正进行到一半或刚好插入完成的瞬间",此时 g 必为黑,否则 p 和 g 就是连续红——而连续红正是我们要消灭的对象。所以可以放心地说:一旦 p 是红,g 必黑,下一步只需要看 u。这个"三色已定、只看叔叔"的洞察,是理解整段调整代码的最高概括。

先看完整的插入主函数,再逐段拆解其中的调整逻辑:

// 插入一个键值对,成功返回 true;key 已存在返回 false
bool Insert(const pair<K, V>& kv)
{
    // 情况:空树插入
    if (_root == nullptr)
    {
        _root = new Node(kv);      // 新结点成为根
        _root->_col = BLACK;       // 根必须是黑色
        return true;
    }
 
    // 第一步:按二叉搜索树规则查找插入位置
    Node* parent = nullptr;        // 记录当前结点的父亲
    Node* cur = _root;             // 从根开始向下走
    while (cur)
    {
        if (cur->_kv.first < kv.first)   // key 比当前大,往右走
        {
            parent = cur;
            cur = cur->_right;
        }
        else if (cur->_kv.first > kv.first) // key 比当前小,往左走
        {
            parent = cur;
            cur = cur->_left;
        }
        else
        {
            return false;          // key 已存在,插入失败
        }
    }
 
    // 第二步:把新结挂到父亲的孩子位置上
    cur = new Node(kv);            // 创建新结点,颜色默认红
    cur->_col = RED;               // 非空树插入必须是红色
    if (parent->_kv.first < kv.first)
        parent->_right = cur;      // 大于父亲,挂在右边
    else
        parent->_left = cur;       // 小于父亲,挂在左边
    cur->_parent = parent;         // 建立父亲指针
 
    // 第三步:修正红黑树性质(核心,下面重点拆解)
    while (parent && parent->_col == RED)   // 父亲是红才需要处理
    {
        Node* grandfather = parent->_parent; // 取出祖父
 
        // 情形一:p 是 g 的左孩子
        if (parent == grandfather->_left)
        {
            Node* uncle = grandfather->_right; // 叔叔是 g 的右孩子
 
            if (uncle && uncle->_col == RED)
            {
                // 叔叔存在且为红 -> 变色,然后继续往上处理
                parent->_col = BLACK;          // p 变黑
                uncle->_col = BLACK;           // u 变黑
                grandfather->_col = RED;       // g 变红
                cur = grandfather;             // 把 g 当作新的 cur
                parent = cur->_parent;         // 继续向上检查
            }
            else
            {
                // 叔叔不存在或为黑 -> 旋转 + 变色
                if (cur == parent->_left)
                {
                    // 左左形态:对 g 做右单旋
                    RotateR(grandfather);      // 以 g 为轴右旋
                    parent->_col = BLACK;      // p 变黑,当新根
                    grandfather->_col = RED;   // g 变红
                }
                else
                {
                    // 左右形态:先 p 左旋,再 g 右旋(双旋)
                    RotateL(parent);           // 以 p 为轴左旋
                    RotateR(grandfather);      // 以 g 为轴右旋
                    cur->_col = BLACK;         // c 变黑,当新根
                    grandfather->_col = RED;   // g 变红
                }
                break;                         // 单旋/双旋后无需继续向上
            }
        }
        else
        {
            // 情形一:p 是 g 的右孩子(与上面完全镜像)
            Node* uncle = grandfather->_left;  // 叔叔是 g 的左孩子
 
            if (uncle && uncle->_col == RED)
            {
                // 叔叔存在且为红 -> 变色后继续往上
                parent->_col = BLACK;          // p 变黑
                uncle->_col = BLACK;           // u 变黑
                grandfather->_col = RED;       // g 变红
                cur = grandfather;             // 上提 g,继续处理
                parent = cur->_parent;
            }
            else
            {
                // 叔叔不存在或为黑 -> 旋转 + 变色
                if (cur == parent->_right)
                {
                    // 右右形态:对 g 做左单旋
                    RotateL(grandfather);      // 以 g 为轴左旋
                    parent->_col = BLACK;      // p 变黑,当新根
                    grandfather->_col = RED;   // g 变红
                }
                else
                {
                    // 右左形态:先 p 右旋,再 g 左旋(双旋)
                    RotateR(parent);           // 以 p 为轴右旋
                    RotateL(grandfather);      // 以 g 为轴左旋
                    cur->_col = BLACK;         // c 变黑,当新根
                    grandfather->_col = RED;   // g 变红
                }
                break;                         // 处理完毕,跳出
            }
        }
    }
 
    _root->_col = BLACK;   // 保险起见,最后把根强制涂黑
    return true;           // 插入成功
}

这段代码是红黑树的"心脏",密密麻麻。先帮你看懂它的"骨架",再逐段拆。整个 while 循环的含义是:只要当前结点 cur 的父亲 p 是红色的,就说明存在需要处理的连续红,于是把 cur、p、g 的关系翻出来看叔叔。循环被 break 或自然结束的条件是:要么一路处理到 parent 为空(说明 cur 已经"上提到"根那一级,变色把根染红了,最后由末尾 _root->_col = BLACK 兜底刷黑),要么在某次旋转后 break 掉。

Insert 里还藏着一个细微但重要的点:每次进入循环的第一步都是重新取祖父。因为上一轮如果走了"变色 + 上提",cur 变成了原来的 grandfather,它的父亲(也就是新的 parent)很可能已经变了,所以每轮都要 grandfather = parent->_parent 现算,千万不能在外面缓存。

记得那个关键判定吗——当父亲是红时,祖父必黑,那么唯一的变数就是叔叔。于是有:

  • 叔叔是红色 → 走"变色"分支(叔叔红时只变色、不旋转)。
  • 叔叔不存在,或叔叔存在但为黑色 → 走"旋转 + 变色"分支。

下面三节就围绕这张图展开。整段调整逻辑,你可以把它想成一张"最终决策表"(这张表你值得抄在笔记本上):

叔叔 u 的状态c 与 p 的位置处理动作是否继续上提
u 为红任意只变色(p、u 变黑,g 变红)继续往上
u 为黑或不存在p、c 同边单旋 + 变色结束
u 为黑或不存在p、c 不同边双旋 + 变色结束

红黑调整的情况一:叔叔为红,变色

第一类情况画出来是这样:c 红、p 红、g 黑,而且 u 存在且为红。这种情况下 p 和 u 都是红,g 是黑,处理方式极其统一——变色:

把 p 和 u 都变黑,把 g 变红,然后把 g 当作新的 c 继续往上更新。

拓扑示意(p 是 g 左的例子;p 是 g 右时完全镜像,处理一模一样):

      g(黑)                    g(红→继续上提)
   /      \                  /       \
 p(红)    u(红)    ===>   p(黑)     u(黑)
  |
 c(红)

为什么这样变?分析一下守恒:p 和 u 原本都是红,把它们变黑,p、u 两条分支各自增加一个黑色结点;同一层左右各自增加一个黑,黑高上互不抵消但"数量平衡"。可 g 再变红,相当于把 g 这个结点从"黑色贡献"里抽出来,让整棵以 g 为根的子树的黑高保持和变之前的 g 子树黑高一致。写成守恒式就是:g 子树黑高 = max(p路径黑, u路径黑),变前 g 是黑的、变后 g 变红、但 p 和 u 从红变黑补回来了,所以整棵 g 子树内部的黑色结点数量完全没变——这正是性质 4(每条路径黑结点数相同)所要求的,变色一换一,趁机保证黑高不动。与此同时,"p 和 c 连续红"的问题也解决了(p 变黑了)。

那为什么要继续往上更新?因为 g 现在变成了红色——如果 g 的父亲恰好也是红色,就又会冒出新的"连续红",得继续往上处理;如果在某个位置 g 的父亲是黑色或不存在,那处理到此结束。而如果 g 一路被上提、最终有个红色结点当上了整棵树的根,最后一句 _root->_col = BLACK 就会把它兜底染黑——根必须黑,这条永远由收尾保证。

关键点:情况一只变色,不旋转!所以无论 c 是 p 的左还是右、p 是 g 的左还是右,处理方式都是上面这一套变色。你不需要对着四种子形态去背,记住"叔叔红,变一波色再往上"就够。为什么叔叔红时就不用旋转?因为连续红的根源在于"红结点 p 需要变黑才能压住 c",而 p 变黑会让 p 所在路径多一个黑、破坏黑高;但此时叔叔 u 也是红的,可以一并变黑,左右各多一个黑,黑高守恒就补平了——所以不必借助旋转去"挪位置",颜色一变即自洽。这是"叔叔红"与其他两种情况在立意上的根本不同。

看到代码里的 cur = grandfather; parent = cur->_parent; 这短短两行了吗?这可太重要了。它不是简单的"跳转",而是在把问题结点往上抛:把变色后的红色 g,当作新一轮的"违规红结点 c",这样 while 循环就能用完全相同的逻辑继续向上检查 g 的父亲。这就是红黑树调整能够一举处理的根源——一个 case 处理不了就"滚"上去,直到把矛盾推进到根。对复杂形状(比如大量结点堆积、递归触发多次变色)它也能做到"套路化解决",这正是抽象的价值。

红黑调整的情况二:叔叔为黑(或不存在),单旋 + 变色

第二类情况同样是 c 红、p 红、g 黑,但 u 不存在,或者 u 存在但颜色为黑。

这里先要弄清"叔叔不存在 vs 叔叔为黑"两种前提下,c 的身份有什么不同:如果 u 不存在,那么 c 一定是新增结点;如果 u 存在且是黑,那么 c 一定不是新增的,而是之前某个黑色结点在它的子树里按"情况一"变过色、从黑色变成了红色、一路更新上来的。不管是哪种背景,共同点是:单纯变色已经解决不了问题了。为什么?

因为要消除连续红,p 必须变黑;但 p 变黑会让 p 所在路径多出一个黑结点、破坏性质 4。这时如果叔叔 u 是黑的或者干脆不存在,就没有另一个"碍眼的红结点"可以一并变黑来补平——也就是说,黑高守恒在"左右各 +1 黑"这条路上走不通了。所以必须旋转 + 变色配合:用旋转把多出来的那个黑"挪"到合适的位置,再用变色完成一换一,从而既不破黑高、又消灭连续红。这是情况二、三与情况一的本质分野。

先看最简单的一种子形态,p 是 g 的左孩子、c 是 p 的左孩子(左左形态,此时 c 与 p 同在左、是"直线"):

      g(黑)                     p(黑)
    /      \                 /      \
 p(黑)     u(黑或空)  ===> c        g(红)
  |                              \
 c(红)                            u
  • 以 g 为旋转点做右单旋;
  • 旋转后 p 成了这棵子树的新根,再把 p 变黑、g 变红。

这样处理完,子树的黑色结点数量保持不变(p 从红变黑、g 从黑变红,一换一),黑高以 p 为根重新对齐,没有连续红结点,而且不需要继续往上更新——因为新的根 p 是黑色,无论 p 的父亲是黑是红还是空,都不会违反任何规则(黑色父亲下面的子树根是红还是黑都允许)。

p 是 g 的右孩子、c 是 p 的右孩子(右右形态)完全对称:以 g 为轴做左单旋,再 p 变黑、g 变红,同样结束。

一句话记忆:"折线里的直线形态"——p 和 c 同边(都是左或都是右),一次单旋就够了。 对应的代码是 RotateR(grandfather) 那一段(左左)或 RotateL(grandfather) 那一段(右右)。

红黑调整的情况三:叔叔为黑(或不存在),双旋 + 变色

与情况二同一个大前提(u 不存在或为黑),但此时 c 与 p 是"折线"关系,即 c 和 p 不同边:p 是 g 的左、c 是 p 的右(左右形态),或者 p 是 g 的右、c 是 p 的左(右左形态)。

以"左右形态"为例(p 是 g 的左、c 是 p 的右):

      g(黑)            先以p左旋            g(黑)           再以g右旋
    /      \        ==============>     /      \        ==============>   c(黑)
 p          u                          c        u                       /     \
  \        (黑或空)                    |                            p(红)    g(红)
   c(红)                              p(红)                                 \
                                (p的左孩子被c接管)                            u
  • 先以 p 为旋转点做左旋,再做以 g 为旋转点的右旋——也就是双旋;
  • 旋转后 c 成了这棵子树的新根,再把 c 变黑、g 变红。

为什么直线用单旋、折线就要双旋?你可以这样理解:单旋是在"轴点就是端点"时直接归位;而当 c 埋在 p 的内侧(折线),直接对 g 单旋会把 c 甩到错误的一侧、c 自身仍顶着 p 造成连续红。所以先对内层的 p 旋一次,把折线"捋直"成直线(此时 c 变成 p 的外侧子结点),再对 g 旋一次完成与情况二等价的归位。两旋合一即双旋。本质上,双旋 = 两次单旋各管一层。

同样,子树黑结点数量不变(c 从红变黑、g 从黑变红,一换一;p 保持红不变),没有连续红结点,且不需要继续往上更新(新根 c 是黑的)。

"右左形态"完全对称:先以 p 右旋、再以 g 左旋,c 变黑、g 变红。

一句话记忆:"折线里的交叉形态"——p 和 c 不同边,需要两次旋转(双旋)把它们"捋直"再调整。 对应代码是 RotateL(parent); RotateR(grandfather); 那一段(左右)或 RotateR(parent); RotateL(grandfather); 那一段(右左)。

到这里,插入的三种情况就完整了。我们再把它们的"分工"回味一遍:情况一靠"变色 + 上提"把矛盾向上传导到更高层去消化;情况二、三靠"旋转 + 变色"在局部直接把矛盾化解掉并就此收手(不再上提)。这就是为什么红黑树的插入能在至多 O(logN)、常数次旋转/变色内把树修好——情况一只在若干层上上提变色,情况二三各一次旋转即止。所有复杂形态,最终都会被这套规则规约为这三条的某次组合。

为了让你对三种情况都有"亲眼见过"的把握,这里给出两个具体、能确定触发对应情况的插入序列,你可以对着上面的决策表一步步跟踪(也可以在下面"完整例子"一节直接运行验证):

  • 触发情况一(叔叔红,变色 + 上提):依次插入 10, 5, 15, 3。插 10(黑根)→ 插 5(父黑,合法)→ 插 15(父黑,合法)→ 插 3:父 5 红、祖父 10 黑、叔叔 15 红 → 情况一变色(5 黑、15 黑、10 红),把 10 上提为新 cur,此时 10 的父亲为空,循环结束,收尾把根 10 刷黑。整棵树:10 黑、5 黑左、15 黑右、3 红左,完全合法。
  • 触发情况三(叔叔黑/无,折线双旋):在上面基础上继续插入 4。插 4:父 3 红、祖父 5 黑、叔叔为 5 的右孩子(不存在)→ 叔叔黑/无,且 c(4) 是 p(3) 的右(p 是 g 的左)→ 左右形态双旋:先对 3 左旋(4 顶上、3 变 4 的左)、再对 5 右旋(4 变新根、5 变 4 的右),然后 c(4) 染黑、g(5) 染红。结果 4 成子根、3 红在左、5 红在右,合法。
  • 触发情况二(叔叔黑/无,直线单旋):另起一棵,依次插入 20, 10, 30, 5, 3。插 20(黑根)→ 10、30(父黑,饿得合法)→ 插 5:父 10 红、祖父 20 黑、叔叔 30 红 → 情况一变色(10 黑、30 黑、20 红,根刷回黑)→ 插 3:父 5(注意此时 5 是 10 的左孩子、红色)红、祖父 10 黑、叔叔是 10 的右孩子(不存在)→ 叔叔黑/无,且 c(3) 是 p(5) 的左(p 是 g 的左)→ 左左形态单旋(情况二):对 10 右旋、p(5) 变黑、g(10) 变红。结果 5 成子根、3 与 10 两个红孩子,合法。

跟踪完这三个序列,三条规则在你心里就从"背"变成了"懂"。实践出真知——强烈建议你运行末尾的完整程序把这几个序列都敲进去,看看每步打印的 "OK"。

红黑树的查找

红黑树本身是二叉搜索树,所以查找完全不看颜色,直接按 BST 的逻辑从根往下走即可,效率是 O(logN)。

// 按二叉搜索树规则查找,找到返回结点指针,否则返回空
Node* Find(const K& key)
{
    Node* cur = _root;             // 从根开始
    while (cur)
    {
        if (cur->_kv.first < key)
            cur = cur->_right;     // key 大,向右找
        else if (cur->_kv.first > key)
            cur = cur->_left;      // key 小,向左找
        else
            return cur;            // 找到,返回该结点
    }
    return nullptr;                // 走到空都没找到
}

查找简单吧。红黑树的价值从来不在查找本身,而在它保证了查找稳定地走对数高度——不像普通 BST 在最坏情况下会退化成链表。这个保证,正是靠前面那几条颜色性质撑起来的。对比一下:在一个普通 BST 里,Find 这段代码一模一样,但数据有序插入时它要一路走到底、退化成 O(N);而红黑树因为在插入时就通过颜色纪律压住了高度,Find 每次都是贴着 O(logN) 走的。这就是"同样的查找代码,底子不同、上限天差地别"。

为什么最长路径最多是最短的两倍

现在来回答一个你心里早该冒出来的问题:凭什么这些颜色规则能保证"最长路径不超过最短路径的两倍"?证明思路不用背,跟着推理两小步就走通了。

第一步,看最短路径。 由性质 4 可知,从根到每个 NIL 的每条路径都有同样多的黑色结点,这个共同的黑色结点数量就是黑高 bh。极端情况下,一条路径可能全是黑色结点,没有任何红色结点——这就是理论上的最短路径,长度为 bh(记 h 为任意路径长度,那么有 bh ≤ h)。

第二步,看最长路径。 由性质 2(根为黑)和性质 3(红结点不能有红孩子)可知,无论如何,任意路径上都不会出现连续两个红色结点。那最长的路径能长什么样?只能是一黑一红、一黑一红交替——因为黑结点后面最多跟一个红,然后又必须回到黑。这样的路径中,红色结点的个数最多等于黑色结点的个数。既然整条路径的黑色结点数是 bh,那么红色结点数最多也是 bh,合起来最长路径最多有 2 × bh 那么长。于是有 bh ≤ h ≤ 2×bh。

换句话说,红黑树把所有路径都"锁"在了一个两倍区间里:最短全黑是 bh,最长黑白交替最坏也就 2bh。这就是"近似平衡"的精确含义——它不追求左右子树严格等高,但保证任何路径不会失衡太多。尤其要强调:这个"全黑最短、一黑一红最长"的极端形态并不是在每棵红黑树里都真实存在的,它只是证明中的一个上界/下界估计,用来证明"不管树长成什么样,树高都被夹在 bh 和 2bh 之间"。

顺着这个思想再推一步效率,这也是课件 1.3 节要你推出的结论。我们要把"两倍界"换算成"O(logN)",关键是证明 bh 和结点数 N 之间悬着一条对数上界。标准引理是这样证的(用归纳,别慌,就两行):

引理:一棵黑高为 bh 的红黑树,其内部结点数至少为 2^bh - 1。

归纳证明如下:

  • 基础(bh = 0):黑高 0 表示没有任何内部结点(全空),内部结点数 0 = 2⁰ - 1,成立。
  • 归纳步:假设黑高 < bh 时成立。黑高为 bh 的树,根必为黑。再看根的两棵子树:若子树根是黑,则其黑高为 bh-1;若子树根是红,则其黑高仍为 bh(红色不算入黑高),但也 ≥ bh-1。总之两棵子树的黑高都 ≥ bh-1,由归纳假设,每棵至少含 2^(bh-1) - 1 个内部结点。于是整棵树内部结点数 ≥ 1 + 2·(2^(bh-1) - 1) = 2^bh - 1。归纳得证。

由这个引理,含 N 个内部结点的红黑树满足 N ≥ 2^bh - 1,即 bh ≤ log₂(N+1)。再结合上面证过的两倍界 h ≤ 2·bh,立得:

h ≤ 2·log₂(N+1),所以 h = O(logN)。

于是增删查改在最坏情况下都是走最长路径 h,时间复杂度就是 O(logN)——这就是红黑树和 AVL 树同为 O(logN) 的来龙去脉,只是红黑树因为控制宽松,旋转次数更少。对比 AVL 那个更紧的 h ≤ 1.44·logN,红黑树的高度上界更松(2·logN),但换来的是更少的旋转。

红黑树的验证

写完插入、旋转、变色,怎么确认这棵树真的"红黑"?最直接的思路是检查"最长路径是否不超过最短路径的两倍"。但这里有个坑:就算某棵树满足了最长不超过最短两倍,它也可能颜色上违反了别的规则——比如某条路径连续出现三个红结点而导致结构不健康,只是恰好当前数值上还过得去;可一旦继续插入,迟早会出问题。所以验证不能只看高度比,必须去正面检查我们列出的那几条规则,规则全满足了,两倍性质自然由前面的证明保证。

逐条来:

  1. 性质 1(非红即黑):由枚举天然保证,不用查。
  2. 性质 2(根为黑):直接判断根的颜色即可。
  3. 性质 3(无连续红):用前序遍历检查每个红结点的孩子——但孩子有两个、还可能为空,检查起来反而不方便。聪明的做法是反过来检查红结点的父亲:碰到红结点,看看它父亲是不是红,是就违规。父亲最多一个、且一定有(根除外),查起来最省事。
  4. 性质 4(黑结点数量相等):用前序遍历,在遍历过程中用形参 blackNum 记录从根到当前结点路径上的黑色结点数,走到空(一条路径走完)就统计出一条路径的黑色结点数,拿任意一条作为参考值 refNum,与其他路径依次比较即可。
// 递归检查红黑树性质,blackNum 传引用不方便,这里用传值累加每条路径
bool _Check(Node* root, int blackNum, const int refNum)
{
    if (root == nullptr)
    {
        // 前序遍历走到空,意味着一条路径走完了,此时统计出 blackNum
        if (refNum != blackNum)
        {
            cout << "存在黑色结点数量不相等的路径" << endl;
            return false;             // 违反性质 4
        }
        return true;                  // 这条路径合法
    }
 
    // 检查连续红:查孩子不方便,反过来查父亲的颜色
    if (root->_col == RED
        && root->_parent != nullptr   // 根结点的父亲为空,需判空
        && root->_parent->_col == RED)
    {
        cout << root->_kv.first << "存在连续的红色结点" << endl;
        return false;                 // 违反性质 3
    }
 
    if (root->_col == BLACK)
        blackNum++;                   // 遇到黑结点,当前路径黑结点数加一
 
    // 递归检查左子树和右子树,两条都合法才算合法
    return _Check(root->_left, blackNum, refNum)
        && _Check(root->_right, blackNum, refNum);
}

这段递归有四个值得吃透的细节:

  • blackNum 为什么传值、不传引用? 我们希望"每一条根到叶的路径各自独立地累加黑结点数"。如果传引用,左右子树的累加会互相污染——左边改的值右边接着用,就乱套了。传值则意味着每个递归分支都拥有一份独立的计数副本,互不干扰,走到空时统计出的就是"当前这条路径自己的黑高"。而 refNum 是要一路作基准比对的标准值,所以用 const int 传值即可。
  • 连续红为什么"查父亲":一个结点只有一个父亲,且绝大部分结点都有父亲,判断 父亲是红 就是一次比较;而查孩子要遍历两个指针、还要处理为空的情形。显然查父亲更简单、更不容易漏。
  • 根结点 _parent == nullptr 的判空:课件版本直接写 root->_parent->_col,如果 root 是根(红、父亲为空),就会对空指针解引用。这里补一个 root->_parent != nullptr 判空,更稳健。
  • root == nullptr 就是”一条路径走完“:凡走到空,说明这条根到叶(嵌入 NIL)的路径已全部遍历完,此刻 blackNum 就是这条路径的黑高,与 refNum 一比即知是否破坏性质 4。这就是把 NIL 当作黑色叶子来计数的落点——空处正好作“终点哨兵”。

最后套一个对外接口:先判空树、再判根是否黑,然后取最左路径的黑结点数当作参考值,递归比对整棵树。

// 对外验证接口:判断当前树是否是合法的红黑树
bool IsValidRBTree()
{
    if (_root == nullptr)
        return true;                   // 空树当然合法
 
    if (_root->_col == RED)
        return false;                  // 根结点必须是黑,违反性质 2
 
    int refNum = 0;                    // 参考值:一条路径的黑结点数量
    Node* cur = _root;                 // 从根出发,一直往左走
    while (cur)
    {
        if (cur->_col == BLACK)
            ++refNum;                  // 统计最左路径的黑结点数
        cur = cur->_left;
    }
 
    // 以 refNum 为基准,递归检查整棵树
    return _Check(_root, 0, refNum);
}

refNum 取"最左路径"的黑结点数是完全合法的基准:因为性质 4 要求所有路径黑高都相等,所以拿任意一条(最左那条)当标准即可,其余任何一条与之不同就说明违规。这块验证代码和前面 Insert 里的变色/旋转配合使用,就能在你插入任何数据后立刻自检合法性——这也是后面完整程序的核心自检手段。

验证:跑一个完整例子

光说不练假把式。下面把这个红黑树的所有部件——枚举、结点、类、左旋、右旋、插入、查找、验证、析构——完整地拼成一个单一 cpp 文件。这是可以直接复制、独立编译运行的完整程序:

#include <iostream>
#include <utility>      // std::pair、std::make_pair
using namespace std;
 
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) {}
};
 
template<class K, class V>
class RBTree
{
    typedef RBTreeNode<K, V> Node;
 
public:
    RBTree() : _root(nullptr) {}
 
    ~RBTree()                      // 析构:释放整棵树的结点,防止内存泄漏
    {
        destroy(_root);
    }
 
    bool Insert(const pair<K, V>& kv)
    {
        // 空树插入:新结点成为根,根必须黑色
        if (_root == nullptr)
        {
            _root = new Node(kv);
            _root->_col = BLACK;
            return true;
        }
 
        // 第一步:按二叉搜索树规则找插入位置
        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 已存在
            }
        }
 
        // 第二步:挂上新结点,默认红色
        cur = new Node(kv);
        if (parent->_kv.first < kv.first)
            parent->_right = cur;
        else
            parent->_left = cur;
        cur->_parent = parent;
 
        // 第三步:自底向上修正红黑性质
        while (parent && parent->_col == RED)
        {
            Node* grandfather = parent->_parent;
 
            if (parent == grandfather->_left)          // p 是 g 的左
            {
                Node* uncle = grandfather->_right;
                if (uncle && uncle->_col == RED)
                {
                    // 情况一:叔叔红,只变色,继续上提
                    parent->_col = BLACK;
                    uncle->_col = BLACK;
                    grandfather->_col = RED;
                    cur = grandfather;
                    parent = cur->_parent;
                }
                else
                {
                    // 情况二/三:叔叔黑或不存在,旋转 + 变色
                    if (cur == parent->_left)          // 左左:右单旋
                    {
                        RotateR(grandfather);
                        parent->_col = BLACK;
                        grandfather->_col = RED;
                    }
                    else                               // 左右:左旋+右旋 双旋
                    {
                        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 = BLACK;
                    uncle->_col = BLACK;
                    grandfather->_col = RED;
                    cur = grandfather;
                    parent = cur->_parent;
                }
                else
                {
                    if (cur == parent->_right)          // 右右:左单旋
                    {
                        RotateL(grandfather);
                        parent->_col = BLACK;
                        grandfather->_col = RED;
                    }
                    else                                // 右左:右旋+左旋 双旋
                    {
                        RotateR(parent);
                        RotateL(grandfather);
                        cur->_col = BLACK;
                        grandfather->_col = RED;
                    }
                    break;
                }
            }
        }
 
        _root->_col = BLACK;        // 兜底:根永远是黑的
        return true;
    }
 
    Node* Find(const K& key)
    {
        Node* cur = _root;
        while (cur)
        {
            if (cur->_kv.first < key)
                cur = cur->_right;
            else if (cur->_kv.first > key)
                cur = cur->_left;
            else
                return cur;
        }
        return nullptr;
    }
 
    // 合法性自检
    bool IsValidRBTree()
    {
        if (_root == nullptr) return true;
        if (_root->_col == RED) return false;
        int refNum = 0;
        Node* cur = _root;
        while (cur)
        {
            if (cur->_col == BLACK) ++refNum;
            cur = cur->_left;
        }
        return check(_root, 0, refNum);
    }
 
    int Height() const
    {
        return height(_root);
    }
 
    void InOrder() const
    {
        inOrder(_root);
        cout << endl;
    }
 
private:
    void RotateL(Node* parent)
    {
        Node* subR = parent->_right;
        Node* subRL = subR->_left;
        parent->_right = subRL;
        if (subRL) subRL->_parent = parent;
        Node* ppNode = parent->_parent;
        subR->_left = parent;
        parent->_parent = subR;
        if (ppNode == nullptr)
        {
            _root = subR;
            subR->_parent = nullptr;
        }
        else
        {
            if (ppNode->_left == parent) ppNode->_left = subR;
            else ppNode->_right = subR;
            subR->_parent = ppNode;
        }
    }
 
    void RotateR(Node* parent)
    {
        Node* subL = parent->_left;
        Node* subLR = subL->_right;
        parent->_left = subLR;
        if (subLR) subLR->_parent = parent;
        Node* ppNode = parent->_parent;
        subL->_right = parent;
        parent->_parent = subL;
        if (ppNode == nullptr)
        {
            _root = subL;
            subL->_parent = nullptr;
        }
        else
        {
            if (ppNode->_left == parent) ppNode->_left = subL;
            else ppNode->_right = subL;
            subL->_parent = ppNode;
        }
    }
 
    bool check(Node* root, int blackNum, const int refNum)
    {
        if (root == nullptr)
        {
            // 走到空 = 一条路径走完,比对黑结点数
            if (refNum != blackNum)
            {
                cout << "存在黑色结点数量不相等的路径" << endl;
                return false;
            }
            return true;
        }
        // 连续红:反过来查父亲
        if (root->_col == RED
            && root->_parent != nullptr
            && 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);
    }
 
    void destroy(Node* root)
    {
        if (!root) return;
        destroy(root->_left);
        destroy(root->_right);
        delete root;
    }
 
    int height(Node* root) const
    {
        if (!root) return 0;
        int l = height(root->_left);
        int r = height(root->_right);
        return (l > r ? l : r) + 1;
    }
 
    void inOrder(Node* root) const
    {
        if (!root) return;
        inOrder(root->_left);
        cout << root->_kv.first << ' ';
        inOrder(root->_right);
    }
 
    Node* _root;
};
 
int main()
{
    // 综合序列:把你的手写红黑树跑出汗
    RBTree<int, int> t;
    int a[] = {16, 3, 7, 11, 9, 26, 18, 14, 15};
    for (auto e : a)
    {
        t.Insert(make_pair(e, e));
        cout << "insert " << e << " -> "
             << (t.IsValidRBTree() ? "OK" : "FAIL") << endl;
    }
 
    t.InOrder();                          // 按 key 有序,证明它仍是合法 BST
    cout << (t.IsValidRBTree() ? "红黑树合法" : "红黑树不合法") << endl;
 
    // 查找每个已插入的数据,都应能找到
    for (auto e : a)
        cout << "Find " << e << ": "
             << (t.Find(e) ? "found" : "not found") << endl;
 
    // 查找不存在的 key
    cout << "Find 100: "
         << (t.Find(100) ? "found" : "not found") << endl;
 
    cout << "9 个结点,树高 = " << t.Height()
         << "(远小于 9,说明近似平衡生效)" << endl << endl;
 
    // 第二组:依次触发 情况一(叔叔红->变色)、情况三(叔叔黑折线->双旋)
    RBTree<int, int> t2;
    int b[] = {10, 5, 15, 3, 4};
    for (auto e : b)
    {
        t2.Insert(make_pair(e, e));
        cout << "[演示] insert " << e << " -> "
             << (t2.IsValidRBTree() ? "OK" : "FAIL") << endl;
    }
 
    // 第三组:依次触发 情况一(叔叔红->变色上提)、情况二(叔叔黑直线->单旋)
    RBTree<int, int> t3;
    int c[] = {20, 10, 30, 5, 3};
    for (auto e : c)
    {
        t3.Insert(make_pair(e, e));
        cout << "[演示2] insert " << e << " -> "
             << (t3.IsValidRBTree() ? "OK" : "FAIL") << endl;
    }
 
    return 0;
}

你可以把上面这一整段复制到同一个 .cpp 文件(比如 rbtree.cpp)里编译运行。如果每一步插入后屏幕都打印 "OK",最终又打印 "红黑树合法",那就说明我们手写的这棵红黑树经受住了考验——每一轮插入后的变色和旋转,都正好把树维持在了所有性质的约束之内。你还可以把 main 里的数组 a/b/c 换成任意你想测的序列再跑,甚至改成乱序、递增、递减、重复 key 等极端序列,观察它是否始终打印 "OK"、树高是否始终贴着对数走。

这里额外提醒几个有意思的检查点:

  • 如果某一次插入后打印出 "FAIL",先别急着质疑红黑树的正确性,去核对插入逻辑里叔叔的判定和旋转方向。红黑树最经典的几个 bug 是:左右旋方向写反;"叔叔为黑"与"叔叔为红"两个分支写串;旋转时漏了维护 _parent 指针;把祖父当成空判断出错。多跑几次不同的数据序列,能帮你更快地固化"叔叔决定方向"这条直觉。
  • 光看"高度是否平衡"不足以佐证合法:如验证一节所说,必须正面检查全部性质,否则某些病态的染色虽暂时高度还行,迟早会在后续插入中爆雷。
  • 反复插删、插入已有 key 的边界:插入已有 key 会返回 false,树结构不变,验证仍是 "OK"。这也是稳定性的体现。

如果你把段代码放进编译器却报了"找不到 std::pair",记得这是 C++ 标准模板库的类型,#include <utility> 已包含;在较老的工具链上,可能需要开启支持 C++11(如 g++ -std=c++11 rbtree.cpp)。

关于删除的一点说明

插入讲完了,你可能还惦记着删除。这里要实话实说地提醒你:红黑树的删除比插入还要复杂一个量级,而且删除的场景更多、也更隐蔽。本篇文章(本课件)有意不展开删除的实现细节。如果你真的遇到了需要手写红黑树删除的场景,建议去查阅**《算法导论》或《STL 源码剖析》**,里面把删除的四种情况和调整都讲得很系统。

为什么删除这么难?根因在这里:删除一个结点时,如果删的是红结点,红结点不影响黑高(性质 4),删除后一切照旧,代价极小;但如果删的是黑结点,就会直接让某条路径的黑高下降 1,破坏性质 4,而且这个缺口就悬在那条叶路径上——你没法靠删掉的结点给自己补黑,只能去兄弟方向"借"一个黑结点过来,或通过变色、旋转把这个缺口"顶到"更高的层去消化。而"借黑"要考虑兄弟是红是黑、兄弟的孩子是什么颜色等一系列排列组合,于是就有了经典的四五种删除调整类型(超出我们这里的范围)。这套"借黑还黑 + 旋转变色"的内功和你刚学的插入调整是镜像的——插入靠"变色 + 上抛",删除靠"借黑 + 下修",底层是同一套旋转与变色武功。

正因为插入学透了这套武功,你再去读《算法导论》的删除那一节,会发现很多代码似曾相识:判断兄弟颜色、判断侄子颜色、决定单旋还是双旋、决定要不要继续向上——跟插入的"看叔叔、看走向"如出一辙。所以我的建议是:把本节插入的三种情况、决策表、旋转逻辑彻底吃透,这比单独背删除代码更值钱。

回过头来,我们把这一路学的东西串一遍:红黑树用"每个结点一个颜色位"换来了严格的"近似平衡"保证——红色不能连、黑色要均分,两条颜色纪律联手把高度压进了 [bh, 2bh] 的两倍区间,再由引理推出 bh ≤ log₂(N+1),最终把增删查改钉死在 O(logN)。它之所以比 AVL 树旋转更少,是因为它用五条颜色性质代替了"左右子树高度差必须等于 1"这种苛刻约束,用一点点高度上的宽裕换来了旋转上的大幅节约。你在这一章里亲手实现了带颜色的结点、左右旋转、按 BST 插入,以及最重要的"叔叔红就变色上提、叔叔黑就单双旋变色"这套调整逻辑;还顺带通过性质 2/3/4 的逐条验证,把**"最长不超过最短两倍"这个结论拆成 bh ≤ h ≤ 2bh,再补上引理推出 O(logN)**。

如果你还是觉得有点绕,我给你一句生活化的总抓手:红黑树的插入调整,本质是"颜色守恒"的生意——情况一靠"把红黑平衡一路往上传(传球)",情况二、三靠"旋转 + 一换一变色,在局部当场成交"。想清楚"我在保黑高守恒、我在消灭连续红"这两个目的,那些分支就不会再让你眼花。

到这里,"带颜色的平衡树"对你而言就不再是黑盒了——你不仅会用 map、set,也终于明白它们底层那棵树上为什么藏着一套如此精密的颜色规矩,从而能如此稳定地保持对数深度。下一站,无论你继续深挖删除,还是去研究 B 树、跳表、Treap、Splay 这些邻居,这份关于"如何用一组约束换回对数高度"的思维方式,都会是你最趁手的武器。带着这五个性质、三张决策表、一次亲手运行,去征服更多数据结构吧。