C++ 手写红黑树:从零实现插入、旋转与平衡验证(附完整源码 + 测试)

摘要接前文。红黑树是面试高频考点,也是 STL map/set 的底层数据结构。本文用 C++ 模板从零实现红黑树的插入操作,逐一拆解叔叔为红(变色)、叔叔为黑(四种旋转)的调整过程,配合图示和完整可运行的测试代码,帮你真正理解红黑树的自平衡机制。


前言:为什么要学红黑树?

把 1 到 10 按顺序插入一棵二叉搜索树会怎样?答案是你会得到一条链表,查找效率从 O(log n) 退化到 O(n)。

这就是普通 BST 的致命缺陷:它的性能完全取决于插入顺序。为了解决这个问题,人们发明了自平衡二叉搜索树。常见的有两种:

  • AVL 树:严格平衡(左右子树高度差不超过 1),查找快,但插入/删除时旋转频繁
  • 红黑树:近似平衡(最长路径不超过最短路径的 2 倍),工程上更实用,旋转次数更少

你每次用 std::mapstd::set 的时候,底层都在帮你维护一棵红黑树。


1. 红黑树的五条性质

红黑树在普通 BST 的基础上给每个节点加了一个"颜色"属性(红或黑),然后通过以下五条规则来约束树的形状:

性质 内容 约束了什么
1 节点非红即黑 状态空间有限,便于编码
2 根节点是黑色 插入调整的终止条件
3 所有叶子(NIL)是黑色 统一黑高计算的边界
4 红色节点的子节点必须是黑色(不能连续红色) 限制最长路径长度
5 从任一节点到其所有叶子路径包含相同数量的黑色节点(黑高一致) 保证近似平衡

其中,性质 4 和性质 5 联合保证了"最长路径 ≤ 2 × 最短路径"。

最短路径是全黑节点(假设有 B 个黑节点),最长路径是红黑交替(B 个黑 + B 个红 = 2B)。所以红黑树的高度最多是 2log(n+1),查找效率始终是 O(log n)。

插入调整就来自这两条性质:新插入的节点默认为红色,这不影响性质 5(黑高没变),但可能违反性质 4(跟红色父节点相邻)。所以插入后的调整,本质上就是在修复"连续红色"的问题,同时不破坏黑高一致性。

对应代码中的颜色定义:

cpp 复制代码
enum NodeColor {
    red,
    black
};

2. 节点结构与树的框架

节点结构

红黑树的节点跟普通 BST 相比,多了两样东西:颜色指向父节点的指针

cpp 复制代码
template<class K, class V>
struct RBTreeNode
{
    using Node = RBTreeNode<K, V>;
    std::pair<K, V> _kv;    // 键值对
    Node* _left;             // 左子节点
    Node* _right;            // 右子节点
    Node* _parent;           // 父节点
    NodeColor _color;        // 红 or 黑

    RBTreeNode(const std::pair<K, V>& kv)
        : _kv(kv)
        , _left(nullptr)
        , _right(nullptr)
        , _parent(nullptr)
        , _color(red)          // 新节点默认为红色
    {}
};

这里有三个设计决策值得说明:

为什么需要 _parent 指针? 普通 BST 只需要 _left_right 就够了,但红黑树的插入调整需要向上回溯到祖父节点甚至更高层,没有 _parent 指针就做不到。

为什么新节点默认红色? 因为插入一个黑色节点一定 违反性质 5(黑高不一致),而插入红色节点只可能违反性质 4(连续红色),后者更容易修复。

树的框架

cpp 复制代码
template<class K, class V>
class RBTree {
public:
    using Node = RBTreeNode<K, V>;
    RBTree() : _root(nullptr) {}
    
    void Insert(const std::pair<K, V>& kv);
    bool IsBalance();
    bool IsValidRBTree();
    
private:
    Node* _root;
    void RotateL(Node* parent);   // 左旋
    void RotateR(Node* parent);   // 右旋
};

3. 插入操作:先找位置,再调整颜色

红黑树的插入分为两个阶段:

  1. BST 标准插入:沿树查找合适位置,挂载新节点
  2. 颜色调整:向上检查,通过变色或旋转修复可能违反的性质

阶段 1:BST 查找 + 挂载

这部分跟普通 BST 完全一样,没什么特别的:

cpp 复制代码
void Insert(const std::pair<K, V>& kv) {
    // 空树:直接创建黑色根节点
    if (_root == nullptr) {
        _root = new Node(kv);
        _root->_color = black;
        return;
    }

    // BST 查找插入位置
    Node* cur = _root;
    Node* parent = nullptr;
    while (cur) {
        if (kv.first < cur->_kv.first) {
            parent = cur;
            cur = cur->_left;
        }
        else if (kv.first > cur->_kv.first) {
            parent = cur;
            cur = cur->_right;
        }
        else {
            return;  // 键已存在,不插入
        }
    }

    // 挂载新节点(红色)
    Node* newnode = new Node(kv);
    if (newnode->_kv.first < parent->_kv.first)
        parent->_left = newnode;
    else
        parent->_right = newnode;
    newnode->_parent = parent;
    
    // 阶段 2:进入调整循环...

阶段 2:调整循环

调整的核心是一个 while 循环,只要"当前节点的父节点是红色",就说明出现了连续红色(违反性质 4),需要继续调整:

cpp 复制代码
    while (newnode->_parent && newnode->_parent->_color == red)
    {
        Node* father = newnode->_parent;
        Node* grandfa = father->_parent;
        Node* uncle = (grandfa->_left == father) 
                       ? grandfa->_right 
                       : grandfa->_left;
        
        // 根据 uncle 的颜色决定:变色 or 旋转
        // ...
    }
    _root->_color = black;  // 保底:根节点始终为黑色

调整过程中有三个关键角色:
#mermaid-svg-cBxGNiRN2n1jYN2f{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-cBxGNiRN2n1jYN2f .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-cBxGNiRN2n1jYN2f .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-cBxGNiRN2n1jYN2f .error-icon{fill:#552222;}#mermaid-svg-cBxGNiRN2n1jYN2f .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-cBxGNiRN2n1jYN2f .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-cBxGNiRN2n1jYN2f .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-cBxGNiRN2n1jYN2f .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-cBxGNiRN2n1jYN2f .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-cBxGNiRN2n1jYN2f .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-cBxGNiRN2n1jYN2f .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-cBxGNiRN2n1jYN2f .marker{fill:#333333;stroke:#333333;}#mermaid-svg-cBxGNiRN2n1jYN2f .marker.cross{stroke:#333333;}#mermaid-svg-cBxGNiRN2n1jYN2f svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-cBxGNiRN2n1jYN2f p{margin:0;}#mermaid-svg-cBxGNiRN2n1jYN2f .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-cBxGNiRN2n1jYN2f .cluster-label text{fill:#333;}#mermaid-svg-cBxGNiRN2n1jYN2f .cluster-label span{color:#333;}#mermaid-svg-cBxGNiRN2n1jYN2f .cluster-label span p{background-color:transparent;}#mermaid-svg-cBxGNiRN2n1jYN2f .label text,#mermaid-svg-cBxGNiRN2n1jYN2f span{fill:#333;color:#333;}#mermaid-svg-cBxGNiRN2n1jYN2f .node rect,#mermaid-svg-cBxGNiRN2n1jYN2f .node circle,#mermaid-svg-cBxGNiRN2n1jYN2f .node ellipse,#mermaid-svg-cBxGNiRN2n1jYN2f .node polygon,#mermaid-svg-cBxGNiRN2n1jYN2f .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-cBxGNiRN2n1jYN2f .rough-node .label text,#mermaid-svg-cBxGNiRN2n1jYN2f .node .label text,#mermaid-svg-cBxGNiRN2n1jYN2f .image-shape .label,#mermaid-svg-cBxGNiRN2n1jYN2f .icon-shape .label{text-anchor:middle;}#mermaid-svg-cBxGNiRN2n1jYN2f .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-cBxGNiRN2n1jYN2f .rough-node .label,#mermaid-svg-cBxGNiRN2n1jYN2f .node .label,#mermaid-svg-cBxGNiRN2n1jYN2f .image-shape .label,#mermaid-svg-cBxGNiRN2n1jYN2f .icon-shape .label{text-align:center;}#mermaid-svg-cBxGNiRN2n1jYN2f .node.clickable{cursor:pointer;}#mermaid-svg-cBxGNiRN2n1jYN2f .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-cBxGNiRN2n1jYN2f .arrowheadPath{fill:#333333;}#mermaid-svg-cBxGNiRN2n1jYN2f .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-cBxGNiRN2n1jYN2f .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-cBxGNiRN2n1jYN2f .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-cBxGNiRN2n1jYN2f .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-cBxGNiRN2n1jYN2f .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-cBxGNiRN2n1jYN2f .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-cBxGNiRN2n1jYN2f .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-cBxGNiRN2n1jYN2f .cluster text{fill:#333;}#mermaid-svg-cBxGNiRN2n1jYN2f .cluster span{color:#333;}#mermaid-svg-cBxGNiRN2n1jYN2f div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-cBxGNiRN2n1jYN2f .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-cBxGNiRN2n1jYN2f rect.text{fill:none;stroke-width:0;}#mermaid-svg-cBxGNiRN2n1jYN2f .icon-shape,#mermaid-svg-cBxGNiRN2n1jYN2f .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-cBxGNiRN2n1jYN2f .icon-shape p,#mermaid-svg-cBxGNiRN2n1jYN2f .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-cBxGNiRN2n1jYN2f .icon-shape .label rect,#mermaid-svg-cBxGNiRN2n1jYN2f .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-cBxGNiRN2n1jYN2f .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-cBxGNiRN2n1jYN2f .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-cBxGNiRN2n1jYN2f :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;}#mermaid-svg-cBxGNiRN2n1jYN2f .blackNode>*{fill:#111!important;color:#fff!important;stroke:#111!important;stroke-width:2px!important;}#mermaid-svg-cBxGNiRN2n1jYN2f .blackNode span{fill:#111!important;color:#fff!important;stroke:#111!important;stroke-width:2px!important;}#mermaid-svg-cBxGNiRN2n1jYN2f .blackNode tspan{fill:#fff!important;}#mermaid-svg-cBxGNiRN2n1jYN2f .redNode>*{fill:#d32f2f!important;color:#fff!important;stroke:#b71c1c!important;stroke-width:2px!important;}#mermaid-svg-cBxGNiRN2n1jYN2f .redNode span{fill:#d32f2f!important;color:#fff!important;stroke:#b71c1c!important;stroke-width:2px!important;}#mermaid-svg-cBxGNiRN2n1jYN2f .redNode tspan{fill:#fff!important;}#mermaid-svg-cBxGNiRN2n1jYN2f .uncleNode>*{fill:#fff!important;color:#111!important;stroke:#666!important;stroke-width:2px!important;stroke-dasharray:5 5!important;}#mermaid-svg-cBxGNiRN2n1jYN2f .uncleNode span{fill:#fff!important;color:#111!important;stroke:#666!important;stroke-width:2px!important;stroke-dasharray:5 5!important;}#mermaid-svg-cBxGNiRN2n1jYN2f .uncleNode tspan{fill:#111!important;}#mermaid-svg-cBxGNiRN2n1jYN2f .noteNode>*{fill:#fff!important;color:#333!important;stroke:none!important;font-size:12px!important;}#mermaid-svg-cBxGNiRN2n1jYN2f .noteNode span{fill:#fff!important;color:#333!important;stroke:none!important;font-size:12px!important;}#mermaid-svg-cBxGNiRN2n1jYN2f .noteNode tspan{fill:#333!important;} G

grandfa
F

father
U

uncle

?
N*

newnode
grandfa: 祖父节点(黑色)
father: 父节点(红色)
uncle: 叔叔节点(红 / 黑 / 不存在)
newnode: 新节点(红色)

  • father:当前节点的父节点(红色,否则不会进入循环)
  • grandfa:祖父节点(一定存在且为黑色,因为 father 是红色)
  • uncle:叔叔节点(father 的兄弟,可能为红、为黑、或不存在)

叔叔的颜色决定了我们用哪种策略来修复:

  • 叔叔为红色 → 变色
  • 叔叔为黑色或不存在 → 旋转 + 变色

4. 调整场景一:叔叔存在且为红色(变色)

这是最简单的调整情况。

思路

父亲和叔叔都是红色,祖父是黑色。我们把父亲和叔叔都变黑,祖父变红。这样做的效果是:

  • 经过祖父的路径上,黑色节点数量没变(之前是黑祖父 + 红父/叔,现在是红祖父 + 黑父/叔)
  • 当前节点和父节点不再连续红色了

但祖父变红之后,可能又跟曾祖父 形成连续红色,所以要把 newnode 上移到祖父位置,继续检查。

实例演示

以插入序列 {10, 5, 15, 3} 为例:

插入 3 之前,树长这样:10(B) 是根,左右子节点 5® 和 15®。

插入 3 之后,3 挂在 5 的左边,3 和 5 都是红色,连续红色!此时叔叔 15 也是红色,触发变色。

变色前

复制代码
        10(B)
       /    \
     5(R)   15(R)
    /
  3(R)  ← 新插入

变色后

复制代码
        10(B)        ← 根节点保持黑色
       /    \
     5(B)   15(B)    ← 父和叔叔变黑
    /
  3(R)

对应代码

cpp 复制代码
if (uncle && uncle->_color == red) {
    father->_color = black;
    uncle->_color = black;
    if (grandfa == _root) {
        return;   // 祖父是根,不需要变红(根最终会被染黑)
    }
    else {
        grandfa->_color = red;
        newnode = grandfa;  // 上移,继续检查
    }
}

注意这里的优化:如果祖父已经是根节点,直接返回即可,不需要把根变红再变回黑。


5. 调整场景二:叔叔不存在或为黑色(旋转 + 变色)

这是红黑树插入最核心也最复杂的部分。当叔叔不存在或为黑色时,单纯变色无法解决问题,需要通过旋转来重新平衡。

根据父节点在祖父的左/右,以及新节点在父节点的左/右,一共有四种情况。我们先讲单旋的两种(更简单),再讲双旋的两种。

情况 1:RR 型 ------ 左旋祖父

触发条件 :父在祖父 边,新节点在父的边。

数据演示 :插入 {1, 2, 3}

复制代码
  1(B)
    \
    2(R)
      \
      3(R) ← 新插入

此时 uncle 不存在(或为黑色 NIL),father(2) 在 grandfa(1) 右边,newnode(3) 在 father(2) 右边 ------ RR 型。

操作:左旋祖父(1),然后 father(2) 变黑,grandfa(1) 变红。

复制代码
    2(B)
   /    \
  1(R)  3(R)

对应代码

cpp 复制代码
if (father == grandfa->_right && newnode == father->_right)
{
    RotateL(grandfa);         // 左旋祖父
    father->_color = black;   // 父变黑
    grandfa->_color = red;    // 祖父变红
}

情况 2:LL 型 ------ 右旋祖父

触发条件 :父在祖父 边,新节点在父的边。跟 RR 完全对称。

数据演示 :插入 {3, 2, 1}

复制代码
      3(B)
     /
    2(R)
   /
  1(R) ← 新插入

操作:右旋祖父(3),然后 father(2) 变黑,grandfa(3) 变红。

复制代码
    2(B)
   /    \
  1(R)  3(R)

对应代码

cpp 复制代码
else if (father == grandfa->_left && newnode == father->_left) 
{
    grandfa->_color = red;
    father->_color = black;
    RotateR(grandfa);         // 右旋祖父
}

情况 3:LR 型 ------ 先左旋父,再右旋祖父

触发条件 :父在祖父 边,新节点在父的边。节点呈"折线"形,单旋搞不定,需要双旋。

数据演示 :插入 {10, 5, 8}

Step 1 - 旋转前

复制代码
    10(B)
   /
  5(R)
    \
    8(R) ← 新插入

Step 2 - 左旋 5 后

复制代码
    10(B)
   /
  8(R)
 /
5(R)

Step 3 - 右旋 10 后

复制代码
    8(B)
   /    \
  5(R)  10(R)

对应代码

cpp 复制代码
else if (father == grandfa->_left && newnode == father->_right) 
{
    RotateL(father);          // 先左旋父
    RotateR(grandfa);         // 再右旋祖父
    newnode->_color = black;  // 注意:双旋后变色的是 newnode,不是 father!
    grandfa->_color = red;
}

情况 4:RL 型 ------ 先右旋父,再左旋祖父

触发条件 :父在祖父 边,新节点在父的边。跟 LR 对称。

数据演示 :插入 {10, 15, 12}

Step 1 - 旋转前

复制代码
  10(B)
     \
    15(R)
    /
  12(R) ← 新插入

Step 2 - 右旋 15 后

复制代码
  10(B)
     \
    12(R)
       \
      15(R)

Step 3 - 左旋 10 后

复制代码
    12(B)
   /    \
  10(R) 15(R)

对应代码

cpp 复制代码
else if (father == grandfa->_right && newnode == father->_left)
{
    RotateR(father);          // 先右旋父
    RotateL(grandfa);         // 再左旋祖父
    newnode->_color = black;  // 双旋后 newnode 变黑
    grandfa->_color = red;
}

四种旋转的规律总结

类型 形状 操作 变色
RR 祖-父-子 右-右 左旋祖父 father 变黑,grandfa 变红
LL 祖-父-子 左-左 右旋祖父 father 变黑,grandfa 变红
LR 祖-父-子 左-右 先左旋父,再右旋祖父 newnode 变黑,grandfa 变红
RL 祖-父-子 右-左 先右旋父,再左旋祖父 newnode 变黑,grandfa 变红

关键区别:单旋后变黑的是 father,双旋后变黑的是 newnode

旋转修复后直接 break 跳出循环,不需要继续向上检查(旋转已经把局部平衡修好了)。


6. 旋转的实现细节

旋转是红黑树(和 AVL 树)的基础操作。理解旋转不要逐行读代码,先抓住三步:

  1. 谁上位,谁下沉
  2. 孤儿怎么办(被抛弃的子树重新挂载)
  3. 别忘了 parent 指针(双向链接都要更新)

左旋 RotateL

左旋的核心动作:右子节点 subR 上位,parent 下沉成 subR 的左子节点

复制代码
    旋转前              旋转后
      P                  S
     / \                / \
    A   S     →→→      P   C
       / \            / \
      B   C          A   B
  • subR(S) 上位,取代 parent(P) 的位置
  • parent(P) 下沉,成为subR的左子节点
    - subRL(B) 是"孤儿",被过继给 parent作右子节点

完整代码:

cpp 复制代码
void RotateL(Node* parent) {
    Node* pParent = parent->_parent;
    Node* subR = parent->_right;
    Node* subRL = subR->_left;

    // Step 1: 处理"孤儿" subRL
    if (subRL)
        subRL->_parent = parent;    // 别忘了更新 parent 指针!

    // Step 2: subR 上位
    if (parent == _root) {
        subR->_parent = nullptr;
        subR->_left = parent;
        _root = subR;
    }
    else {
        subR->_parent = pParent;
        if (parent == pParent->_left)
            pParent->_left = subR;
        else
            pParent->_right = subR;
        subR->_left = parent;
    }

    // Step 3: parent 下沉
    parent->_parent = subR;
    parent->_right = subRL;
}

右旋 RotateR

右旋跟左旋完全镜像:左子节点 subL 上位,parent 下沉成 subL 的右子节点

复制代码
    旋转前              旋转后
      P                  S
     / \                / \
    S   C    →→→       A   P
   / \                    / \
  A   B                  B   C

完整代码:

cpp 复制代码
void RotateR(Node* parent) {
    Node* pParent = parent->_parent;
    Node* subL = parent->_left;
    Node* subLR = subL->_right;

    // Step 1: 处理"孤儿" subLR
    if (subLR)
        subLR->_parent = parent;    // 这行容易忘,忘了会导致死循环或段错误!

    // Step 2: subL 上位
    if (parent == _root) {
        subL->_parent = nullptr;
        subL->_right = parent;
        _root = subL;
    }
    else {
        subL->_parent = pParent;
        subL->_right = parent;
        if (parent == pParent->_left)
            pParent->_left = subL;
        else
            pParent->_right = subL;
    }

    // Step 3: parent 下沉
    parent->_parent = subL;
    parent->_left = subLR;
}

最容易出 bug 的地方subLR->_parent = parentsubRL->_parent = parent 这两行。忘记更新"孤儿"的 parent 指针,编译不会报错,但运行时向上回溯会找到错误的祖先节点,导致死循环或段错误。我写的时候就在这里翻过车。


7. 平衡验证:怎么证明你写对了?

写红黑树最大的痛点不是"写不出来",而是"写出来不知道对不对"。验证函数是你最好的学习工具------先写验证,再写插入,每插入一个节点就跑一次验证,比肉眼看树结构可靠得多。

我们的验证函数 _Validate 递归检查三件事:

  1. BST 有序性:左子节点的键 < 当前节点的键 < 右子节点的键
  2. 无连续红色:如果当前节点是红色,它的子节点不能也是红色
  3. 黑高一致:左子树和右子树的黑色节点数量必须相等
cpp 复制代码
int _Validate(Node* node) {
    if (!node) return 1;  // NIL 叶子算一个黑节点

    // 检查连续红色
    if (node->_color == red) {
        if ((node->_left  && node->_left->_color  == red) ||
            (node->_right && node->_right->_color == red)) {
            std::cout << "  [FAIL] Consecutive red nodes at: " 
                      << node->_kv.first << std::endl;
            return -1;
        }
    }

    // 检查 BST 有序性
    if ((node->_left  && !(node->_left->_kv.first  < node->_kv.first)) ||
        (node->_right && !(node->_kv.first < node->_right->_kv.first))) {
        std::cout << "  [FAIL] BST order violation at: " 
                  << node->_kv.first << std::endl;
        return -1;
    }

    // 递归检查左右子树的黑高
    int leftBH  = _Validate(node->_left);
    int rightBH = _Validate(node->_right);
    if (leftBH == -1 || rightBH == -1) return -1;
    if (leftBH != rightBH) {
        std::cout << "  [FAIL] Black-height mismatch at " << node->_kv.first
                  << " L=" << leftBH << " R=" << rightBH << std::endl;
        return -1;
    }

    // 当前节点的黑高 = 子树黑高 + (自身是黑色 ? 1 : 0)
    return leftBH + (node->_color == black ? 1 : 0);
}

返回值的设计很巧妙:正常时返回该子树的黑高(正整数),出错时返回 -1。这样递归过程中任何一层检测到违规,错误都会向上传播。


8. 测试设计:怎么测一棵红黑树?

写测试不是随便插几个数然后看看"没崩就行"。好的测试要针对每种调整场景构造输入,确保每条代码路径都被覆盖到。

我们设计了 12 个测试用例,覆盖了以下场景:

测试名 覆盖场景 输入数据
TestEmptyTree 边界:空树 无插入
TestSingleNode 边界:单节点,根必须为黑 {42}
TestDuplicateKeys 边界:重复键拒绝 {5, 5, 5}
TestAscendingInsert RR 旋转(连续右插入) 1, 2, 3, ..., 20
TestDescendingInsert LL 旋转(连续左插入) 20, 19, ..., 1
TestZigZagInsert LR/RL 双旋 {10,5,8} / {10,15,12}
TestRecolorCase 叔叔为红的变色 {10, 5, 15, 3, 12, 18}
TestStringKeys 泛型验证 {"alpha", "bravo", ...}
TestContinuousValidation 每 10 次插入验证一次 100 个随机数(seed=42)
TestStressLarge 压力测试 1000 个随机数(seed=12345)
TestRRRotation RR 旋转单独验证 {1, 2, 3}
TestLLRotation LL 旋转单独验证 {3, 2, 1}

我们挑两个有代表性的展开讲。

升序插入测试(覆盖 RR 旋转)

cpp 复制代码
void TestAscendingInsert() {
    TEST("Ascending Order Insert (1..20)")
    RBTree<int, int> rbt;
    for (int i = 1; i <= 20; ++i) {
        rbt.Insert({i, i * 10});
    }
    CHECK(rbt.Size() == 20, "size == 20");
    CHECK(rbt.IsValidRBTree(), "valid RB tree");
    
    bool allFound = true;
    for (int i = 1; i <= 20; ++i) {
        auto* v = rbt.Find(i);
        if (!v || *v != i * 10) { allFound = false; break; }
    }
    CHECK(allFound, "all 20 keys found with correct values");
}

按 1 到 20 的升序插入,如果是普通 BST 会退化成链表。但红黑树会不断触发 RR 旋转和变色来保持平衡。这个测试验证了:节点数正确、所有 RB 性质成立、每个键都能找到且值正确。

压力测试(综合验证)

cpp 复制代码
void TestStressLarge() {
    TEST("Stress Test (1000 random insertions)")
    RBTree<int, int> rbt;
    std::vector<int> keys;
    for (int i = 0; i < 1000; ++i) keys.push_back(i);

    std::mt19937 rng(12345);
    std::shuffle(keys.begin(), keys.end(), rng);

    for (int i = 0; i < 1000; ++i)
        rbt.Insert({keys[i], keys[i] * 10});

    CHECK(rbt.Size() == 1000, "size == 1000");
    CHECK(rbt.IsValidRBTree(), "valid RB tree after 1000 insertions");
}

1000 个随机打乱的数插入后,验证所有红黑树性质仍然成立。用固定种子保证每次运行结果一致。

测试框架

测试用了两个简单的宏来统一输出格式:

cpp 复制代码
#define TEST(name) \
    std::cout << "=== " << name << " ===" << std::endl;

#define CHECK(cond, msg) \
    do { \
        if (cond) { \
            std::cout << "  [PASS] " << msg << std::endl; \
            ++g_testsPassed; \
        } else { \
            std::cout << "  [FAIL] " << msg << std::endl; \
            ++g_testsFailed; \
        } \
    } while(0)

9. 总结与延伸

回顾插入调整的逻辑

没有实现什么

  • 删除操作:比插入更复杂(需要处理双黑节点的情况),计划在下一篇讲
  • 迭代器 :可以进一步封装成类似 STL map/set 的接口
  • 拷贝/析构:生产级代码需要实现深拷贝和递归析构

参考资料

  1. 《算法导论》第 13 章 - 红黑树
  2. 《STL 源码剖析》- 侯捷 - RB-tree 章节
  3. 可视化工具:Red/Black Tree Visualization (cs.usfca.edu)