【C++进阶】:(7)红黑树的原理与 C++ 实现:结构设计、插入调整及性质验证

文章目录

    • 前言
    • 一、红黑树的核心概念
      • [1.1 红黑树的定义](#1.1 红黑树的定义)
      • [1.2 红黑树的五大规则](#1.2 红黑树的五大规则)
      • [1.3 红黑树的平衡原理](#1.3 红黑树的平衡原理)
      • [1.4 红黑树的效率分析](#1.4 红黑树的效率分析)
    • 二、红黑树的结构设计
      • [2.1 节点结构定义](#2.1 节点结构定义)
      • [2.2 红黑树类的框架](#2.2 红黑树类的框架)
      • [2.3 旋转操作实现](#2.3 旋转操作实现)
        • [2.3.1 右单旋](#2.3.1 右单旋)
        • [2.3.2 左单旋](#2.3.2 左单旋)
    • 三、红黑树的插入实现
      • [3.1 插入的基础流程](#3.1 插入的基础流程)
      • [3.2 平衡调整的核心场景](#3.2 平衡调整的核心场景)
        • [3.2.1 叔叔为红色:变色调整](#3.2.1 叔叔为红色:变色调整)
        • [3.2.2 叔叔为黑色或空:单旋与变色](#3.2.2 叔叔为黑色或空:单旋与变色)
        • [3.2.3 叔叔为黑色或空:双旋与变色](#3.2.3 叔叔为黑色或空:双旋与变色)
      • [3.3 完整插入代码实现](#3.3 完整插入代码实现)
      • [3.4 插入代码关键要点](#3.4 插入代码关键要点)
    • 四、红黑树的查找与验证实现
      • [4.1 查找操作实现](#4.1 查找操作实现)
      • [4.2 红黑树的验证实现](#4.2 红黑树的验证实现)
        • [4.2.1 验证辅助函数](#4.2.1 验证辅助函数)
        • [4.2.2 验证主函数](#4.2.2 验证主函数)
    • 五、红黑树的应用与扩展
      • [5.1 红黑树的工业应用](#5.1 红黑树的工业应用)
      • [5.2 红黑树的扩展:删除操作](#5.2 红黑树的扩展:删除操作)
    • 总结
    • 附录:完整代码与测试

前言

普通二叉搜索树只约束键的大小关系,不能阻止树在有序插入时退化。AVL 树通过限制左右高度差解决这个问题;红黑树则给节点增加颜色,用一组颜色规则间接约束树高。

红黑树的难点不在于"多存一个颜色",而在于插入之后怎样同时维护搜索顺序、红节点之间的关系和每条路径的黑节点数量。本文从具体树形出发,逐步分析变色、单旋和双旋,并在对应小节给出实现,最后汇总为一份可以编译运行的 C++17 代码。

一、红黑树的核心概念

1.1 红黑树的定义

红黑树首先是二叉搜索树。本文不存储重复键:对任意节点,左子树的键都比它小,右子树的键都比它大。

1.2 红黑树的五大规则

除此之外,还要满足以下颜色约束。

  1. 每个节点的颜色为红色或黑色。
  2. 根节点为黑色。
  3. 红节点的孩子必须为黑色,即一条路径上不能出现相邻的红节点。
  4. 从任意节点出发,到其下方各个空节点的路径都包含相同数量的黑节点。
  5. 空节点,也称 NIL 节点,视为黑色。

最后一条用于明确路径的终点。在代码中可以用 nullptr 表示这些空节点,无须给每个空位置分配一个对象。下面先看一棵具体的红黑树。

如上图,节点 18 为黑色,红色节点 30 的孩子 25、40 都为黑色,红色节点 15、35、50 的孩子则都是空节点。各条路径中的真实黑节点数量相同,例如经过 18、10 的路径和经过 18、30、40、50 的路径,都包含两个真实黑节点。

这里要同时检查键值和颜色。颜色合法不能代替搜索顺序,搜索顺序正确也不能代替颜色规则;两者缺一不可。

同一组键可以形成不同的树形和颜色分布。如下两幅示意展示了不同的颜色安排。

如上图,红色节点不会连续出现,各条完整路径的真实黑节点数都为 2。右侧虽然更深,但增加的是允许出现的红节点。

如上图,黑色根的孩子可以是红色,红色孩子下方再接黑色节点,仍能维持相等的黑节点数。不过,这两幅示意把 22 放在了 25 的右侧,与二叉搜索树要求的键值大小关系不符。因此这里用它们观察颜色约束;若要作为完整的红黑搜索树,还必须修正键值或节点位置。后面出现同样标注的示意也应按这个原则理解,不能仅凭颜色判断整棵树合法。

1.3 红黑树的平衡原理

"叶节点"在红黑树规则里常指黑色的 NIL,不是仅指没有孩子的真实节点。一个真实节点只要有空孩子,就对应一条已经结束的路径;如果只检查真实叶节点,可能漏掉这些路径。如下图显式画出了空位置。

如上图,节点 10 的左侧虽然没有真实节点,但 18 → 10 → NIL 仍然是一条需要检查的完整路径。它与 18 → 10 → 15 → NIL 的黑节点数量相同,后者只是多了一个红节点。

为统一后面的计算,本文记 B(T) 为子树 T 从根到空位置的真实黑节点数量 :计入子树根自身,不计入终点 NIL;空子树取 B=0。这只是计算口径,空节点在颜色规则上仍然是黑色。对每条路径统一省略一个黑色终点,不会改变黑高度是否相等的判断。

设从根到空位置的每条路径都含有 B 个真实黑节点。因为根为黑色且红节点不能相邻,任何一条路径最多再插入 B 个红节点。若路径长度 L 取真实节点数,则:

B ≤ L ≤ 2 B B\le L\le 2B B≤L≤2B

所以最长路径不超过最短路径的两倍。这里比较的是到空位置的完整路径,且不要求每棵红黑树都实际存在一条全黑路径或一条严格红黑交替的路径。

可以结合两种树形来理解红黑树对路径长度的限制。先看所有真实节点都为黑色的情况,如下图所示。

如上图,从根节点出发,无论沿哪条路径走到空节点,都会经过三个黑色节点。由于路径中没有红色节点,真实节点数就等于黑节点数,因此各条路径的长度相同。这里仍按前面的约定,以真实节点数计算路径长度,不计入终点 NIL。

如果路径中包含红色节点,路径长度就可能不同。红色节点会增加路径上的真实节点数,却不会增加黑节点数,因此,黑节点数相同,并不意味着路径长度相同。如下图所示。

如上图,左侧路径 18 → 10 → NIL 经过两个真实节点,均为黑色;右侧路径 18 → 30 → 40 → 50 → NIL 经过四个真实节点,其中 18、40 为黑色,30、50 为红色。两条路径都包含两个真实黑节点,但右侧多了两个红节点,因此长度恰好是左侧的两倍。

那么,路径能否通过不断增加红节点而继续变长呢?红黑树要求红节点不能相邻,因此两个红节点之间必须有黑节点隔开。再加上根节点为黑色,一条路径上的红节点数最多与黑节点数相同。

假设每条完整路径都包含 B 个真实黑节点,那么任何一条路径至少包含 B 个真实节点,最多包含 2B 个真实节点。因此,最长路径不会超过最短路径的两倍。红黑树正是通过这种约束,在允许路径长短不同的同时,避免树形退化为一条长链。

1.4 红黑树的效率分析

进一步可以证明,黑高度为 B 的树至少有 2^B-1 个真实节点。若真实节点总数为 n、树高按最长路径的真实节点数计为 H,则:

n ≥ 2 B − 1 , H ≤ 2 B ≤ 2 log ⁡ 2 ( n + 1 ) n\ge 2^B-1,\qquad H\le 2B\le 2\log_2(n+1) n≥2B−1,H≤2B≤2log2(n+1)

因此树高为 O(log n),查找、插入和正确实现的删除,其最坏时间复杂度都是 O(log n)。与 AVL 相比,红黑树的高度约束更宽松,部分插入可通过变色完成修复。两者的旋转次数取决于具体输入,不能断言红黑树在每组数据上都更少;红黑树一次插入最多执行两次旋转,但变色可能一直传播到根。

比较项目 红黑树 AVL 树
平衡约束 红黑颜色与相等黑高度 每个节点左右子树高度差不超过 1
查找、插入、删除的最坏时间 O(log n) O(log n)
一次插入的基本旋转次数 最多 2 次 最多 2 次
一次删除的基本旋转次数 标准算法最多 3 次 最坏为 O(log n) 次
选择时的关注点 有序数据的更新成本与整体性能 更严格的高度控制与查找需求

双旋按两次基本旋转计算。旋转次数与完整操作耗时不是同一个指标:定位插入位置、向上变色和维护附加信息同样需要时间。对于读多写少或频繁更新的业务,可以把这张表作为分析起点,但最终仍应结合键分布、比较成本和实际实现测试,不能仅靠树的名称断定谁一定更快。

二、红黑树的结构设计

2.1 节点结构定义

节点保存键值对、左右孩子、父节点和颜色。父指针用于查找父亲、祖父和叔叔,也用于把旋转后的局部子树接回上层。本文使用如下类内定义,后续函数也都是这个类的成员。

cpp 复制代码
enum class Color { Red, Black };

struct Node {
    std::pair<K, V> item;
    Node* left = nullptr;
    Node* right = nullptr;
    Node* parent = nullptr;
    Color color = Color::Red;
    Node(const K& key, const V& value) : item(key, value) {}
};

Node* root_ = nullptr;
std::size_t size_ = 0;

新节点默认红色,三个指针默认空。使用成员初始值能避免忘记初始化颜色。root_ 保存整棵树的根,size_ 记录真实节点数。

2.2 红黑树类的框架

将搜索、修复和校验放在同一个类中,可以让节点连接与颜色修改集中管理。下面展示接口关系;节点定义和函数体在对应小节展开。

cpp 复制代码
template <class K, class V>
class RBTree {
    // Color、Node、root_、size_ 的定义见上一节。
    void rotateRight(Node* g);
    void rotateLeft(Node* g);
    void recolor(Node* g);
    void repairLL(Node* g);
    void repairRR(Node* g);
    void repairLR(Node* g);
    void repairRL(Node* g);
    void fixAfterInsert(Node* cur);
    // check 等辅助函数也作为私有成员。
public:
    bool insert(const K& key, const V& value);
    const V* find(const K& key) const;
    bool valid() const;
};

这是一份省略内部定义的框架,不能单独编译。文末提供可直接运行的完整版本。公开接口不暴露节点指针;示例查找返回只读值地址。类拥有节点内存,由析构函数统一释放,并禁用默认拷贝,避免裸指针浅拷贝造成重复释放。

2.3 旋转操作实现

旋转遵守二叉搜索树的大小关系。红黑树的左右旋与 AVL 的指针转接相同,但不更新平衡因子;颜色由具体修复场景负责。每次旋转都要维护上层连接、转接子树、父指针和可能变化的整树根。

2.3.1 右单旋

右旋与左旋镜像:原左孩子成为局部根,它的右子树转接为原根的左子树。

cpp 复制代码
void rotateRight(Node* g) {
    Node* top = g->left;
    assert(top);
    Node* middle = top->right;
    Node* upper = g->parent;

    top->parent = upper;
    if (!upper) root_ = top;
    else if (upper->left == g) upper->left = top;
    else upper->right = top;

    top->right = g;
    g->parent = top;
    g->left = middle;
    if (middle) middle->parent = g;
}

两个函数都维护双向连接:孩子指针和父指针需要同时正确。它们只修改固定数量的指针,单次旋转耗时 O(1);树形改变后,调用者再执行相应的变色。

2.3.2 左单旋

左旋把原右孩子提到局部根位置,让原根成为它的左孩子;原右孩子的左子树转接为原根的右子树。

cpp 复制代码
void rotateLeft(Node* g) {
    Node* top = g->right;
    assert(top);
    Node* middle = top->left;
    Node* upper = g->parent;

    top->parent = upper;
    if (!upper) root_ = top;
    else if (upper->left == g) upper->left = top;
    else upper->right = top;

    top->left = g;
    g->parent = top;
    g->right = middle;
    if (middle) middle->parent = g;
}

upper 保存旋转前的上层节点。若没有上层节点,需要修改 root_;否则需要更新上层的左指针或右指针。middle 可能为空,因此更新其父指针前先判断。

三、红黑树的插入实现

3.1 插入的基础流程

按 BST 规则接入新节点之前,插入位置是一个空孩子。接入红节点不会增加这条路径中的黑节点数量,所以黑高度约束仍然成立;可能出现的问题仅是新节点与它的父节点都为红色。

如果直接接入黑节点,则某一侧路径会多一个黑节点,需要处理更复杂的黑高度不一致。因此本文采用标准的红色插入策略:父节点为黑色时直接结束;父节点为红色时进入修复;如果新节点成为根,则最终把根设为黑色。

具体流程可以分为四步:先按键值大小找到空位置;检查重复键;创建红色节点并接好父子指针;必要时修复连续红节点,最后保证整树根为黑色。重复键在分配节点之前被拒绝,已有值也不会被覆盖。

3.2 平衡调整的核心场景

标记 代码变量 含义
c cur 当前需要处理的红节点
p cur->parent 当前节点的父亲,也是红色
g p->parent 祖父,在当前修复步骤中为黑色
u p 的兄弟 叔叔,可能为红色、黑色或空

刚进入修复时,cur 指向新增节点。变色向上传播之后,它可能指向原有节点,所以不能把所有示意中的 c 都理解为刚分配出来的叶节点。

父亲为红色时,祖父一定存在:根在进入这次插入前为黑色,不可能由红色根作为父亲。按修复过程的不变条件,祖父也是黑色。接下来首先看叔叔的颜色;只有叔叔为黑色或空时,才进一步根据 c、p、g 的位置选择单旋或双旋。

3.2.1 叔叔为红色:变色调整

变色规则及其原因

此时 c、p、u 都为红色,g 为黑色。处理方法是把 p、u 变黑,把 g 变红,然后以 g 作为新的 cur 继续检查。如下图给出了一次具体的处理。

如上图,左侧的连续红节点是 6 和 x。右侧把 6、15 变黑,节点 10 变红,当前的连续红问题得到解决。根节点 18 为黑色,所以在这个具体例子中,继续检查到它时即可结束。

为什么同时修改三个颜色?从 g 出发的每条路径都会经过 p 或 u。把两者变黑,使每条路径都增加一个黑节点;把 g 变红,又在每条路径上减去一个黑节点。局部子树的黑节点数量保持不变,而连续红的问题被移到了更高的位置。

若 g 的父亲也是红色,就继续处理;若父亲为黑色,就结束;若 g 已经成为根,则把根重新变黑。根变黑会让整棵树的所有路径一起增加一个黑节点,并不会制造左右黑高度不一致。

当前节点为新增节点和向上传播节点的区别

如下图同时展示了两种来源。上半部分的 c 是新插入的红节点;下半部分的 c 原先是黑色,其下方发生插入与变色,随后 c 变红,问题才传播到这一层。

如上图,两种来源最终都满足同一个局部条件:c、p、u 为红色、g 为黑色。因此这一层的操作相同,无须根据子树的具体节点数分别写代码。

用黑高度理解下半部分时,要区分变色前后。原来的黑色 c 与其根为红色的孩子子树,组合出既定黑高度;下层变色把孩子根变黑、把 c 变红,黑节点只是沿路径换了位置,c 子树的总黑高度保持不变。这就允许上层只根据颜色判断下一步。

不同黑高度下的变色场景

**周边子树黑高度为 0。**周边空位置没有真实黑节点,c 是新增节点。无论它接在父亲的左侧还是右侧,只要父亲和叔叔都红,就执行同样的变色。如下图展示了最简单的场景。

如上图,变色结束后的局部根为红色 10,两个孩子 6、15 为黑色。如果这里恰好是整棵树,还需要在插入结束时把 10 设回黑色。

**周边子树黑高度为 1。**子树可能包含一个黑色根和若干红色孩子,此时 c 可以是由下层变色向上传播的节点。如下图中,节点 3 由黑变红,才与红色节点 6 形成需要修复的关系。

如上图,旁边子树的内部形态可以不同,只要黑高度相同,上层处理就一样。黑根两侧的红孩子可各自存在或为空,因此即使只看这类简单结构,也有多个组合;再加上插入位置,具体样例数量很快增多。

**周边子树黑高度为 2 或更高。**还会出现更多嵌套形态,但变色规则不变。如下图展示插入问题经过多层变色逐渐上移的过程。

如上图,新增节点可能在很深的位置,本层 c 却是一个原有子树的根。算法不需要枚举所有内部组合,而是复用"叔叔红则变色,并把 cur 上移到祖父"的规则。因此变色可能重复多次,但每次只向上推进一层关系,最多走到根。

变色实现

cpp 复制代码
void recolor(Node* g) {
    assert(g->color == Color::Black);
    assert(isRed(g->left) && isRed(g->right));
    g->left->color = Color::Black;
    g->right->color = Color::Black;
    g->color = Color::Red;
}

g 的两个孩子就是父亲和叔叔,因此这个函数不依赖父亲位于左侧还是右侧。调用后还要令 cur = g,继续检查它与上层的关系;函数本身不会重新把 g 变黑,根颜色在整个修复过程结束时统一处理。

3.2.2 叔叔为黑色或空:单旋与变色

两种来源及为何不能只变色

叔叔为空时,它按黑色处理;在标准插入修复中,这种局部冲突里的 c 是新增节点。叔叔是一个真实黑节点时,c 则来自下层变色:它原本为黑色,黑高度没有变化,但现在变成了红色,遇到了上层红色父亲。

为什么不能照搬上一节的操作?若把已经为黑的叔叔保持原样,同时让父亲变黑、祖父变红,那么父亲侧黑节点增一减一,叔叔侧却只少了祖父的一个黑节点,两边黑高度将不一致。如果只把父亲变黑,则父亲侧又会多一个黑节点。这种情况下,需要旋转重新安排路径,再配合变色。

位置判断从祖父出发:p 在左、c 也在左是 LL;p 在右、c 也在右是 RR。这两个"外侧"情况使用单旋。

LL:右单旋与变色

LL 要求父亲是祖父的左孩子,当前红节点是父亲的左孩子。以下示意同时包含叔叔为空、叔叔为真实黑节点以及进一步展开子树的情形。

如上图,先以节点 10 为轴右旋,再把新局部根 6 变黑,把原祖父 10 变红。当前节点 3 保持红色。叔叔为空时,最终是黑色根 6 带两个红色孩子 3、10。

当叔叔为黑色 15 时,右旋还会把父亲原来的右子树 d 接到节点 10 左侧。最终节点 10 的左右分别为 d 和叔叔 15,二者黑高度相同;节点 6 的左侧仍是当前节点 3 所在的子树。

为了检查黑高度,设修复前当前子树 c、转接子树 d 和叔叔子树都具有真实黑高度 b。原路径在祖父处得到一个黑节点,局部黑高度为 b+1。修复后,节点 6 为黑色,它下面的节点 3、10 都为红色,每条路径仍然得到 b+1 个黑节点。根为黑色的新局部子树也不会与上层红节点形成连续红,所以在这里可以结束修复。

cpp 复制代码
void repairLL(Node* g) {
    Node* p = g->left;
    assert(g->color == Color::Black && isRed(p));
    assert(isRed(p->left) && !isRed(g->right));
    rotateRight(g);
    p->color = Color::Black;
    g->color = Color::Red;
}

代码中的 g、p 对应上方示意里的 10、6。rotateRight 负责指针,之后两次颜色赋值完成修复。注意这里是把祖父变红,不是随意把参与旋转的所有节点都变黑。

RR:左单旋与变色

RR 与 LL 镜像:父亲为祖父的右孩子,当前红节点为父亲的右孩子,叔叔位于祖父左侧。修复时对 g 左旋,让 p 接替局部根,把 p 变黑、g 变红。当前节点的颜色仍为红色。原父亲的左子树转接到原祖父的右侧,黑高度保持方式与 LL 相同。

cpp 复制代码
void repairRR(Node* g) {
    Node* p = g->right;
    assert(g->color == Color::Black && isRed(p));
    assert(isRed(p->right) && !isRed(g->left));
    rotateLeft(g);
    p->color = Color::Black;
    g->color = Color::Red;
}

这两种单旋最后都由原父亲成为黑色局部根。不要把 RR 理解为"向右旋",它表示右---右的位置关系,对应的修复动作是左旋。

3.2.3 叔叔为黑色或空:双旋与变色

LR:失衡条件与旋转步骤

若父亲位于祖父左侧,而当前红节点位于父亲右侧,连续红关系出现在内侧,这就是 LR。外侧单旋不能直接完成修复,需要先把内侧节点提到合适的位置,再调整祖父。

如下图,祖父为黑色 10,父亲为红色 6,当前节点为红色 8。修复顺序是对 6 左旋,再对 10 右旋,最后把 8 变黑、把 10 变红;节点 6 保持红色。

如上图,两次旋转的转接可以分别展开。

第一步对父亲 6 左旋:节点 8 接到祖父 10 左侧,节点 6 成为 8 的左孩子;节点 8 原来的左子树 a 转接到节点 6 右侧。第二步对祖父 10 右旋:节点 8 成为局部根,节点 10 成为其右孩子;节点 8 原来的右子树 b 转接到节点 10 左侧。

最后,节点 6 两边为 d、a,节点 10 两边为 b、u,新局部根 8 两边为节点 6、10。搜索顺序仍然是 d → 6 → a → 8 → b → 10 → u,其中字母表示整棵子树的中序序列。

LR 的具体场景

场景一:叔叔为空,当前节点为新增节点。

对应如上图最上方一行,新增红色节点 8 接在红色节点 6 右侧。两次旋转后,8 为局部根,左右孩子为 6、10。把 8 变黑、10 变红,最终每条路径只有新根 8 这一个真实黑节点,与修复前祖父 10 所提供的黑节点数量相同。

场景二:叔叔为真实黑节点,当前节点来自下层变色。

对应如上图中间一行,叔叔为黑色 15。节点 8 原来是黑色,其子树内部插入后先执行变色,再把 8 变为当前红节点。此时 8 不一定是叶节点,它有左右子树 a、b,两次旋转中必须分别转接这两棵子树。

设当前节点 8 的两侧 a、b、父亲另一侧的 d、叔叔子树 u,真实黑高度都为 b_h。修复前,以黑色祖父 10 为根的局部子树黑高度为 b_h+1。修复后,新根 8 为黑色,节点 6、10 为红色,它们两侧又都接着黑高度为 b_h 的子树,因此新局部黑高度仍为 b_h+1。

场景三:把较小黑高度下的传播过程展开。

如上图最下方一行把周边子树具体展开为黑高度为 1 的情形。深处新增红节点后,节点 8 先由黑变红,随后在这一层执行同样的双旋与变色。它是场景二的具体实例,用来观察转接后的真实节点和颜色,并不是需要新增代码分支的第三种叔叔颜色。

这里与 AVL 的双旋有一个区别:红黑树不根据中间节点的平衡因子再分 -1、0、1 三种结果。满足当前 LR 条件后,旋转与变色固定为"左旋父亲、右旋祖父、新局部根变黑、原祖父变红"。需要区分具体场景,是为了理解当前节点的来源和子树转接,而不是照搬 AVL 的因子表。

LR 代码实现

cpp 复制代码
void repairLR(Node* g) {
    Node* p = g->left;
    Node* c = p->right;
    assert(g->color == Color::Black && isRed(p));
    assert(isRed(c) && !isRed(g->right));
    rotateLeft(p);
    rotateRight(g);
    c->color = Color::Black;
    g->color = Color::Red;
}

g、p、c 分别对应 10、6、8。c 的地址在旋转前保存,旋转后仍指向同一个节点,所以把 c 设黑就是把新局部根设黑。p 原本为红色,保持原色;只有 c 和 g 的颜色需要在这个函数中修改。

两次基础旋转之间是临时结构,尚未满足全部红黑性质,不在中途执行合法性校验。完整双旋及变色结束后,新局部根为黑色、局部黑高度不变,修复过程即可结束。

RL:失衡条件与旋转步骤

若父亲位于祖父右侧,而当前红节点位于父亲左侧,就是 RL,叔叔位于祖父左侧。它与上面 LR 示意的左右方向互为镜像。先对 p 右旋,再对 g 左旋。若把 c 的左右子树记为 A、B,把叔叔子树记为 U,把父亲另一侧记为 D:第一次右旋把 B 接到 p 左侧,第二次左旋把 A 接到 g 右侧。最终 c 为局部根,左孩子为 g、右孩子为 p;g 两边是 U、A,p 两边是 B、D。

搜索顺序保持为 U → g → A → c → B → p → D。颜色调整是把 c 变黑,把 g 变红,p 保持红色。与 LR 相比,转接方向和旋转顺序镜像,但"中间节点成为黑色局部根"的结果相同。

RL 的具体场景与黑高度

**场景一:叔叔为空,当前节点为新增节点。**当前红节点没有真实孩子。经过右旋父亲、左旋祖父后,中间节点为黑根,两侧原祖父和原父亲为红色,每条路径的真实黑节点数量仍为 1。

**场景二:叔叔为真实黑节点,当前节点由下层变色上移。**当前节点可能有非空的 A、B,两次旋转不能漏掉它们。设 U、A、B、D 的真实黑高度均为 b_h,修复前局部黑高度为 b_h+1;修复后 c 提供一个黑节点,红色的 g、p 不增加黑高度,两侧各条路径仍为 b_h+1。

**场景三:周边子树黑高度为 1 的具体展开。**这一情形同样属于场景二:深处插入先让当前子树根由黑变红,再与红色父亲产生冲突。在这一层仍执行固定的右旋、左旋和变色。无需重新遍历周边子树,只需利用它们已有的相等黑高度。

以上场景中,新局部根始终为黑色。即使它的上层父节点为红色,也不会出现连续红;局部黑高度保持不变,上层其他路径也不受影响。因此 RL 完成后,同样可以停止插入修复。

RL 代码实现

cpp 复制代码
void repairRL(Node* g) {
    Node* p = g->right;
    Node* c = p->left;
    assert(g->color == Color::Black && isRed(p));
    assert(isRed(c) && !isRed(g->left));
    rotateRight(p);
    rotateLeft(g);
    c->color = Color::Black;
    g->color = Color::Red;
}

这里先保存当前节点 c,再右旋父亲、左旋祖父。最后把 c 设黑、g 设红,父亲 p 保持红色。LR 和 RL 都只做两次旋转与常数次颜色修改,局部修复耗时 O(1)。

3.3 完整插入代码实现

cpp 复制代码
bool insert(const K& key, const V& value) {
    Node* parent = nullptr;
    Node* pos = root_;
    while (pos) {
        parent = pos;
        if (key < pos->item.first) pos = pos->left;
        else if (pos->item.first < key) pos = pos->right;
        else return false;
    }

    Node* added = new Node(key, value);  // 默认红色
    added->parent = parent;
    if (!parent) root_ = added;
    else if (key < parent->item.first) parent->left = added;
    else parent->right = added;

    fixAfterInsert(added);
    ++size_;
    return true;
}

查找空位置时发现重复键,就立即返回 false。新节点接入后先设置双向连接,再调用修复函数,最后增加节点计数。代码采用严格小于比较判断左右和键等价,适用于支持该比较关系的键类型。

父亲颜色 叔叔颜色或状态 位置关系 处理方法 是否继续向上
黑色 任意 任意 无须修复 否
红色 红色 任意 父亲和叔叔变黑,祖父变红 以祖父为当前节点继续
红色 黑色或空 LL 右旋祖父,父亲变黑,祖父变红 否
红色 黑色或空 RR 左旋祖父,父亲变黑,祖父变红 否
红色 黑色或空 LR 左旋父亲、右旋祖父,中间节点变黑,祖父变红 否
红色 黑色或空 RL 右旋父亲、左旋祖父,中间节点变黑,祖父变红 否

表中的旋转分类描述的是位置关系,不是树里缓存的高度信息。修复代码如下:

cpp 复制代码
void fixAfterInsert(Node* cur) {
    while (cur->parent && isRed(cur->parent)) {
        Node* p = cur->parent;
        Node* g = p->parent;
        assert(g && g->color == Color::Black);
        bool parentIsLeft = (p == g->left);
        Node* u = parentIsLeft ? g->right : g->left;

        if (isRed(u)) {
            recolor(g);
            cur = g;
            continue;
        }

        if (parentIsLeft) {
            if (cur == p->left) repairLL(g);
            else repairLR(g);
        } else {
            if (cur == p->right) repairRR(g);
            else repairRL(g);
        }
        break;
    }
    root_->color = Color::Black;
}

isRed 把空指针视为非红色,从而统一处理"叔叔为空"和"叔叔为黑色"。它的实现如下:

cpp 复制代码
static bool isRed(const Node* node) {
    return node && node->color == Color::Red;
}

只有红叔叔分支执行 continue:令 cur = g 后,在下一轮重新取得父亲、祖父和叔叔。旋转分支完成修复后执行 break。退出循环时统一把根设黑,涵盖空树插入、变色传播到根,以及旋转更换根等情况。

3.4 插入代码关键要点

  1. 根最后统一设黑。 空树第一次插入、变色传播到根、旋转更新整树根,都由同一处收尾逻辑处理。
  2. 先看父亲,再看叔叔。 父亲为黑色时不需要修复;父亲为红色时,叔叔颜色决定变色还是旋转。
  3. 变色后必须重新获取关系。 cur = g 后,下一轮的父亲、祖父和叔叔都可能改变,不能继续使用上一轮的指针关系。
  4. 位置名称不表示旋转方向。 LL、RR、LR、RL 描述从祖父到父亲、再到当前节点的方向;RR 对应左单旋,LL 对应右单旋。
  5. 双旋要保存中间节点。 旋转会改变亲子关系,先保存 c,才能准确地把新的局部根设黑。
  6. 旋转后可以结束的依据是局部性质。 新局部根为黑色,局部黑高度不变,上层无须继续调整;并不要求上层父亲一定为黑色。
  7. 指针和颜色都需要验证。 中序结果有序只能证明搜索关系,不能证明红黑规则正确。

因此,查找插入位置和可能发生的连续变色最多经过 O(log n) 层;真正发生旋转时,最多只执行一次单旋或一次双旋。

四、红黑树的查找与验证实现

4.1 查找操作实现

查找不需要读取颜色。每次旋转都保持搜索顺序,因此仍然按目标键与当前节点的大小关系向左或向右移动。

cpp 复制代码
const V* find(const K& key) const {
    Node* pos = root_;
    while (pos) {
        if (key < pos->item.first) pos = pos->left;
        else if (pos->item.first < key) pos = pos->right;
        else return &pos->item.second;
    }
    return nullptr;
}

例如在文章开头的树中查找 35,经过 18 → 30 → 40 → 35。函数返回值地址;没找到则返回 nullptr。由于树高受控,最坏耗时为 O(log n)。

4.2 红黑树的验证实现

最长路径不超过最短路径两倍,是红黑性质推导出的结果。反过来,满足这个比例的树未必是合法红黑树,它仍可能有红根、相邻红节点或黑高度不一致。校验应直接检查规则,同时检查搜索顺序和指针连接。

这里先沿最左路径取得参考黑节点数,再递归检查每一条到空位置的完整路径。黑节点数按值传递给左右分支,使兄弟路径的计数互不干扰。

4.2.1 验证辅助函数

如下图,向下访问黑节点时计数增加,访问红节点时保持不变。

如上图,经过 18、10 后计数为 2,继续访问红色 6 或 15 时仍然是 2。最终到达任意空位置,都要与参考值比较。节点只有一侧为空时,也不能跳过这一条较短路径。右侧 22 的键值顺序问题再次说明:颜色和黑节点计数通过,不等于搜索树整体通过。

cpp 复制代码
static bool check(Node* node, Node* expectedParent,
                  const K* low, const K* high,
                  int blackCount, int reference) {
    if (!node) return blackCount == reference;
    const K& key = node->item.first;
    if (node->parent != expectedParent ||
        (low && !(*low < key)) || (high && !(key < *high))) {
        return false;
    }
    if (node->color != Color::Red && node->color != Color::Black) {
        return false;
    }
    if (isRed(node) && isRed(expectedParent)) return false;
    if (node->color == Color::Black) ++blackCount;
    return check(node->left, node, low, &key, blackCount, reference) &&
           check(node->right, node, &key, high, blackCount, reference);
}

expectedParent 用来核对父指针;low、high 表示严格键值区间,保证整棵子树满足搜索顺序,而不只比较直接孩子。连续红通过当前节点与父节点的颜色检查。空节点按黑色理解,不过计数统一不包含终点 NIL,因此返回时直接比较已有计数即可。

4.2.2 验证主函数
cpp 复制代码
bool valid() const {
    if (!root_) return true;
    if (root_->parent || root_->color != Color::Black) return false;
    int reference = 0;
    for (Node* pos = root_; pos; pos = pos->left) {
        if (pos->color == Color::Black) ++reference;
    }
    return check(root_, nullptr, nullptr, nullptr, 0, reference);
}

空树视为合法。非空树先检查根为黑色且没有父节点,再建立参考计数并调用递归校验。检查实际访问每个节点一次,耗时 O(n);递归额外空间为 O(H)。正常红黑树中 H=O(log n)。另一种等价实现是后序计算左右子树黑高度并比较;本节采用参考路径法,便于和上面的计数过程对应。

五、红黑树的应用与扩展

5.1 红黑树的工业应用

红黑树适合需要有序遍历、范围检索,并且经常插入或删除的内存数据。几个典型应用如下。

  • C++ 有序关联容器。 GCC 的 libstdc++ 使用红黑树组织 set、map、multiset、multimap。前两者限制重复键,后两者允许等价键,因此插入位置和重复处理策略会有所区别。C++ 标准规定接口及复杂度要求,并没有强制所有标准库采用同一种内部结构。
  • Linux 内核中的有序索引。 内核提供通用红黑树接口供不同子系统使用。经典 CFS 调度器按任务的虚拟运行时间 vruntime 组织红黑树,便于选择靠左的任务;这里描述的是 CFS 的经典设计,不能简单理解为按普通优先级排列,也不能推断所有版本的调度器采用完全相同的策略。
  • 缓存的有序辅助索引。 当缓存需要按过期时间或其他可排序字段检索时,可以用红黑树维护顺序,以支持 O(log n) 的查找和更新。实际缓存还可能组合哈希表、链表或堆;是否采用红黑树取决于查询和淘汰需求。
  • 数据库相关的内存结构。 红黑树可以用于某些内存有序索引,但磁盘数据库索引还要考虑页访问与 I/O。InnoDB 的普通索引采用 B 树体系,通常从 B+ 树的叶页组织来理解;不能把 MySQL 的 B+ 树索引解释成由红黑树维护。

相关实现细节可查阅 GCC 的红黑树实现、Linux 红黑树接口、经典 CFS 的设计说明及 InnoDB 索引的物理结构。

这些例子说明,红黑树的价值是以对数复杂度维护动态有序关系。只需要按键直接查找时,还应考虑哈希表;需要大块磁盘页检索时,则应考虑 B 树或 B+ 树等多路结构。

5.2 红黑树的扩展:删除操作

删除仍先执行二叉搜索树的定位与替换。若待删节点有两个孩子,可以借助中序前驱或后继,把最终摘除的位置转移到至多有一个真实孩子的节点。修复时应关注最终被摘除节点原本的颜色,不能只看最初按键定位到的节点。

若最终摘除的是红节点,不会减少路径黑节点数,通常无须继续修复。若摘除的是黑节点、替代它的真实孩子为红色,把该孩子染黑即可补回缺少的黑节点。更复杂的情形是黑节点被空位置或黑色替代位置取代:这一路径少了一个黑节点。

可以把这种缺口理解为替代位置暂时承担一份"额外黑色",也常称为"双黑"。它是分析修复状态的概念,不需要在颜色枚举中增加第三种颜色。后续围绕该位置的兄弟及兄弟孩子展开:

  1. 兄弟为红色。 先旋转并调整兄弟、父亲颜色,转换为兄弟为黑色的局面,再继续判断。
  2. 兄弟为黑色,两个孩子均为黑色。 将兄弟染红;若父亲为红色,可将父亲染黑并结束,否则黑节点缺口向父亲传播。
  3. 兄弟为黑色,靠近替代位置的孩子为红色、远侧孩子为黑色。 对兄弟旋转并变色,把局面转换为远侧红孩子的情形。
  4. 兄弟为黑色,远侧孩子为红色。 围绕父亲旋转并调整颜色,补齐路径黑节点数,结束修复。

替代位置在左侧或右侧时,上述操作互为镜像。缺口传播到根时可以消除,因为根处的变化作用于整棵树的所有路径。标准删除算法仍为 O(log n),基本旋转次数最多为 3,但定位与向上传播也计入完整操作耗时。

本文完整代码实现插入、查找和校验;删除在这里作为后续扩展的原理介绍。真正添加删除接口时,还需处理替代位置为空、节点内存释放和父指针维护,不能直接套用插入时的叔叔分类。

总结

红黑树的插入围绕两个约束展开:不能出现相邻红节点,每条路径的黑节点数量必须一致。新节点先设红色,使黑高度保持不变;出现红色父亲后,先看叔叔。叔叔红就变色并向上传播,叔叔黑或空就根据位置执行单旋或双旋,再用固定的颜色调整恢复局部规则。

实现时要把指针转接与颜色修改对应起来,并用独立校验核对整棵树。这样,变色为什么继续向上、旋转为什么可以结束,以及每一处颜色赋值的作用,都能从路径上的黑节点数量得到解释。

附录:完整代码与测试

各小节的函数使用相同的节点定义和变量名。下面汇总为完整类,补上析构、中序遍历、树高和节点数接口,方便直接编译运行。

测试除了空树和重复键,还分别构造叔叔为空时的 LL、RR、LR、RL,以及变色上移后叔叔为黑节点时的四种旋转。每个小规模用例在每次插入后校验,能够定位是哪一步破坏了规则。最后再测试升序插入和 10 万个互异键的随机顺序插入,并查找全部已插入键。

cpp 复制代码
#include <algorithm>
#include <cassert>
#include <chrono>
#include <iostream>
#include <numeric>
#include <random>
#include <utility>
#include <vector>

template <class K, class V>
class RBTree {
    enum class Color { Red, Black };

    struct Node {
        std::pair<K, V> item;
        Node* left = nullptr;
        Node* right = nullptr;
        Node* parent = nullptr;
        Color color = Color::Red;
        Node(const K& key, const V& value) : item(key, value) {}
    };

    Node* root_ = nullptr;
    std::size_t size_ = 0;

    static bool isRed(const Node* node) {
        return node && node->color == Color::Red;
    }

    void rotateLeft(Node* g) {
        Node* top = g->right;
        assert(top);
        Node* middle = top->left;
        Node* upper = g->parent;

        top->parent = upper;
        if (!upper) root_ = top;
        else if (upper->left == g) upper->left = top;
        else upper->right = top;

        top->left = g;
        g->parent = top;
        g->right = middle;
        if (middle) middle->parent = g;
    }

    void rotateRight(Node* g) {
        Node* top = g->left;
        assert(top);
        Node* middle = top->right;
        Node* upper = g->parent;

        top->parent = upper;
        if (!upper) root_ = top;
        else if (upper->left == g) upper->left = top;
        else upper->right = top;

        top->right = g;
        g->parent = top;
        g->left = middle;
        if (middle) middle->parent = g;
    }

    void recolor(Node* g) {
        assert(g->color == Color::Black);
        assert(isRed(g->left) && isRed(g->right));
        g->left->color = Color::Black;
        g->right->color = Color::Black;
        g->color = Color::Red;
    }

    void repairLL(Node* g) {
        Node* p = g->left;
        assert(g->color == Color::Black && isRed(p));
        assert(isRed(p->left) && !isRed(g->right));
        rotateRight(g);
        p->color = Color::Black;
        g->color = Color::Red;
    }

    void repairRR(Node* g) {
        Node* p = g->right;
        assert(g->color == Color::Black && isRed(p));
        assert(isRed(p->right) && !isRed(g->left));
        rotateLeft(g);
        p->color = Color::Black;
        g->color = Color::Red;
    }

    void repairLR(Node* g) {
        Node* p = g->left;
        Node* c = p->right;
        assert(g->color == Color::Black && isRed(p));
        assert(isRed(c) && !isRed(g->right));
        rotateLeft(p);
        rotateRight(g);
        c->color = Color::Black;
        g->color = Color::Red;
    }

    void repairRL(Node* g) {
        Node* p = g->right;
        Node* c = p->left;
        assert(g->color == Color::Black && isRed(p));
        assert(isRed(c) && !isRed(g->left));
        rotateRight(p);
        rotateLeft(g);
        c->color = Color::Black;
        g->color = Color::Red;
    }

    void fixAfterInsert(Node* cur) {
        while (cur->parent && isRed(cur->parent)) {
            Node* p = cur->parent;
            Node* g = p->parent;
            assert(g && g->color == Color::Black);
            bool parentIsLeft = (p == g->left);
            Node* u = parentIsLeft ? g->right : g->left;

            if (isRed(u)) {
                recolor(g);
                cur = g;
                continue;
            }

            if (parentIsLeft) {
                if (cur == p->left) repairLL(g);
                else repairLR(g);
            } else {
                if (cur == p->right) repairRR(g);
                else repairRL(g);
            }
            break;
        }
        root_->color = Color::Black;
    }

    static void destroy(Node* node) {
        if (!node) return;
        destroy(node->left);
        destroy(node->right);
        delete node;
    }

    static void collect(Node* node, std::vector<K>& keys) {
        if (!node) return;
        collect(node->left, keys);
        keys.push_back(node->item.first);
        collect(node->right, keys);
    }

    static int heightOf(Node* node) {
        if (!node) return 0;
        return 1 + std::max(heightOf(node->left), heightOf(node->right));
    }

    static bool check(Node* node, Node* expectedParent,
                      const K* low, const K* high,
                      int blackCount, int reference) {
        if (!node) return blackCount == reference;
        const K& key = node->item.first;
        if (node->parent != expectedParent ||
            (low && !(*low < key)) || (high && !(key < *high))) {
            return false;
        }
        if (node->color != Color::Red && node->color != Color::Black) {
            return false;
        }
        if (isRed(node) && isRed(expectedParent)) return false;
        if (node->color == Color::Black) ++blackCount;
        return check(node->left, node, low, &key, blackCount, reference) &&
               check(node->right, node, &key, high, blackCount, reference);
    }

public:
    RBTree() = default;
    RBTree(const RBTree&) = delete;
    RBTree& operator=(const RBTree&) = delete;
    ~RBTree() { destroy(root_); }

    bool insert(const K& key, const V& value) {
        Node* parent = nullptr;
        Node* pos = root_;
        while (pos) {
            parent = pos;
            if (key < pos->item.first) pos = pos->left;
            else if (pos->item.first < key) pos = pos->right;
            else return false;
        }

        Node* added = new Node(key, value);  // 默认红色
        added->parent = parent;
        if (!parent) root_ = added;
        else if (key < parent->item.first) parent->left = added;
        else parent->right = added;

        fixAfterInsert(added);
        ++size_;
        return true;
    }

    const V* find(const K& key) const {
        Node* pos = root_;
        while (pos) {
            if (key < pos->item.first) pos = pos->left;
            else if (pos->item.first < key) pos = pos->right;
            else return &pos->item.second;
        }
        return nullptr;
    }

    bool valid() const {
        if (!root_) return true;
        if (root_->parent || root_->color != Color::Black) return false;
        int reference = 0;
        for (Node* pos = root_; pos; pos = pos->left) {
            if (pos->color == Color::Black) ++reference;
        }
        return check(root_, nullptr, nullptr, nullptr, 0, reference);
    }

    std::vector<K> inOrderKeys() const {
        std::vector<K> keys;
        collect(root_, keys);
        return keys;
    }

    int height() const { return heightOf(root_); }
    std::size_t size() const { return size_; }
};

int main() {
    const std::vector<std::vector<int>> cases = {
        {10, 6, 3},                        // LL,叔叔为空
        {10, 15, 20},                      // RR,叔叔为空
        {10, 6, 8},                        // LR,叔叔为空
        {10, 15, 12},                      // RL,叔叔为空
        {10, 6, 15, 3},                    // 红叔叔,局部变色
        {10, 6, 15, 20},                   // 红叔叔,镜像情况
        {10, 6, 15, 3, 8, 12, 20, 1, 4, 7, 9, 0},
        {100, 60, 150, 30, 80, 120, 200, 10, 40, 70, 90, 65},
        {100, 50, 150, 30, 70, 120, 200, 110, 130, 180, 220, 115},
        {100, 50, 150, 30, 70, 120, 200, 110, 130, 180, 220, 230}
        // 最后四组:变色上移后触发 LL、LR、RL、RR,叔叔为黑。
    };

    for (const auto& sequence : cases) {
        RBTree<int, int> tree;
        assert(tree.valid());
        for (int key : sequence) {
            bool inserted = tree.insert(key, key * 10);
            assert(inserted && tree.valid());
        }
        auto expected = sequence;
        std::sort(expected.begin(), expected.end());
        assert(tree.inOrderKeys() == expected);
        for (int key : sequence) {
            const int* value = tree.find(key);
            assert(value && *value == key * 10);
        }
        bool duplicate = tree.insert(sequence.front(), -999);
        assert(!duplicate && tree.size() == sequence.size());
        assert(*tree.find(sequence.front()) == sequence.front() * 10);
        assert(tree.find(-1) == nullptr);
        assert(tree.valid());
    }

    // 升序插入也必须保持红黑性质。
    RBTree<int, int> ordered;
    for (int key = 0; key < 1000; ++key) {
        bool inserted = ordered.insert(key, key);
        assert(inserted && ordered.valid());
    }

    std::vector<int> keys(100000);
    std::iota(keys.begin(), keys.end(), 0);
    std::mt19937 rng(2026);
    std::shuffle(keys.begin(), keys.end(), rng);
    RBTree<int, int> large;
    auto insertStart = std::chrono::steady_clock::now();
    for (int key : keys) {
        bool inserted = large.insert(key, key);
        assert(inserted);
    }
    auto insertEnd = std::chrono::steady_clock::now();
    assert(large.valid() && large.size() == keys.size());
    auto findStart = std::chrono::steady_clock::now();
    for (int key : keys) {
        const int* value = large.find(key);
        assert(value && *value == key);
    }
    auto findEnd = std::chrono::steady_clock::now();
    assert(large.find(-1) == nullptr && large.find(100000) == nullptr);

    auto insertMs = std::chrono::duration_cast<std::chrono::milliseconds>(
        insertEnd - insertStart).count();
    auto findMs = std::chrono::duration_cast<std::chrono::milliseconds>(
        findEnd - findStart).count();
    std::cout << "插入修复与性质校验通过;节点数=" << large.size()
              << ";树高=" << large.height() << ";插入=" << insertMs
              << "ms;查找=" << findMs << "ms\n";
}

将代码保存为 rbt_demo.cpp,使用 g++ -std=c++17 -O2 -Wall -Wextra rbt_demo.cpp -o rbt_demo 编译。运行测试时保留断言,便于发现错误。计时使用 steady_clock,分别测量插入和查找,不把整树性质校验算入插入时间。

固定随机种子便于复现,互异键使测试节点数明确。查找使用同一批已插入键,同时单独检查不存在的键。树高和耗时应以本机实际运行结果为准;它们是一次测试的观测值,不能替代前面的复杂度推导。中序遍历应升序,性质校验应通过,所有已插入键应返回对应值。

相关推荐
-dzk-1 小时前
【技巧】LC 31.下一个排列
算法
Android打工仔1 小时前
CoroutineScheduler 设计解析(上)—— 为什么 Dispatchers.IO 会创建更多线程?
android·kotlin·源码阅读
(Charon)1 小时前
【C++面试】堆内存与栈内存:string、vector和对象到底存在哪里
开发语言·c++·面试
Wang's Blog1 小时前
Java框架 SpringCloud 快速入门: Eureka 服务发现与服务名调用改造
java·spring cloud·eureka
夕除1 小时前
redis--OpenResty
java·redis
Wang's Blog1 小时前
Java框架 SpringCloud 快速入门: Eureka 注册中心原理分析
java·spring cloud·eureka
谢亮_vipxieliang1 小时前
Go defer、panic与recover核心知识点
开发语言·后端·golang
她说彩礼65万1 小时前
C语言 assert
java·linux·c语言
vx-程序开发1 小时前
【计算机毕设】52744基于微信小程序的民宿智能预订系统
java·spring boot·毕业设计·源码·课程设计·毕设·大作业