红黑树:用“颜色”换“平衡”,set 与 map 的底层发动机

文章目录

前言

上一篇把 AVL 树从头到尾手写了一遍,结论落在最后一张对比表上:AVL 严格平衡、查找略快,但增删时旋转多;红黑树用更少的旋转换稳定性能,成了 STL set、map 的底层。这篇就顺着那句话,把红黑树彻底挖开。

红黑树(Red-Black Tree)是一棵带颜色的自平衡二叉搜索树 ,每个节点涂红或涂黑,靠一套"着色 + 旋转 + 变色"的规则,让树在增删之后保持近似平衡。它不追求 AVL 那种"左右子树高度差不超过 1"的严格,只保证"最长路径不超过最短路径的两倍",换来的是增删时更少的旋转次数。


一、AVL 的工程短板:严格平衡的代价

先把上一篇的对比结论展开。AVL 的严格平衡(任意节点 BF ≤ 1)是优点,也是包袱:
  • 查找确实快:树高被压到约 1.44×log2(N),路径短、比较少;
  • 增删确实累 :插入最多 2 次旋转能收尾,但删除最多 O(logN) 次旋转,而且每次旋转都要精确维护高度和平衡因子,一笔一笔都是开销。

工程里"增删"和"查找"常常一样频繁,AVL 那套严格账本在频繁增删下就成了负担。红黑树的设计动机一句话能说清:用"颜色"这种更宽松的约束,换"少旋转" ------它允许树偶尔长得不那么板正,只要最长路径不超过最短路径两倍,查找依然是 O(logN),但增删时旋转大幅减少。


二、五大性质:红黑树的宪法

红黑树的所有行为都从这五条性质推出来,笔试高频:

  1. 每个节点不是红色就是黑色(没有第三种颜色);
  2. 根节点必须是黑色;
  3. 红色节点的两个孩子必须是黑色(不能出现连续红);
  4. 从任一节点到其每个叶子(NIL)的路径上,黑色节点数相同;
  5. 每个叶子(NIL)都是黑色。

五条里最关键的是 3 和 4:性质 3 管"局部"------红色不能挨着红色;性质 4 管"全局"------黑高处处相等。一松一紧,构成了红黑树平衡的全部约束。

性质 5 里提到的"叶子"是哨兵 NIL 结点,不是普通意义上的空指针。

连续红是最容易踩的坑:一棵树其他地方都对,只要有一处红节点下面又冒出红节点,性质 3 立刻崩。


三、黑高:红黑树的平衡度量

AVL 用平衡因子 BF 度量平衡,红黑树用黑高(Black Height)。

黑高:从某节点到其任一叶子(NIL)路径上,经过的黑色节点数量(不含 NIL 本身)。

性质 4 说"每条路径黑高相同",所以红黑树的黑高是一个确定的数------随便挑一条从根到 NIL 的路径,数黑节点,数出来的就是整棵树的黑高。红色节点不计入黑高,这也是"红节点是短期失衡"的直观含义:红色不贡献高度约束,只贡献"不能连续"的局部约束。

红黑树"近似平衡"的严格表述,就是从黑高推出来的:

最长路径长度 ≤ 2 × 最短路径长度(最坏情况红黑交替)。

因为黑高固定,最长的路径是"黑红黑红"交替(红色节点数 ≤ 黑色节点数),最短是"全黑"。长度最多差一倍,于是树高被压在 2×log2(N) 以内,查找依然是 O(logN)。

建树 + 黑高 + 校验一条龙的演示:

cpp 复制代码
//建树、中序、黑高、树高、五大性质校验
#include <iostream>
#include "rbtree.h"

namespace byte
{
    void demo()
    {
        RBTree<int> t;
        int arr[] = { 10, 20, 30, 15, 25, 5, 1, 40, 35, 50, 28, 8 };
        for (size_t i = 0; i < sizeof(arr) / sizeof(int); ++i)
            t.insert(arr[i]);

        std::cout << "中序遍历: ";
        t.inorder();

        std::cout << "树高 = " << t.height() << std::endl;
        std::cout << "黑高 = " << t.blackHeight() << std::endl;
        std::cout << "红色节点数 = " << t.redCount() << std::endl;
        std::cout << "根是黑: " << (t.rootIsBlack() ? "是" : "否") << std::endl;
        std::cout << "五大性质校验: " << (t.isValid() ? "通过(根黑、无连续红、黑高一致)" : "失败") << std::endl;

        // 重复插入被拒
        std::cout << "重复插入 30: " << (t.insert(30) ? "成功" : "被拒(key 唯一)") << std::endl;
        std::cout << "查找 35: " << (t.find(35) ? "找到" : "没找到")
                  << ",查找 100: " << (t.find(100) ? "找到" : "没找到") << std::endl;
    }
}

int main()
{
    byte::demo();
    return 0;
}

运行结果:

text 复制代码
中序遍历: 1 5 8 10 15 20 25 28 30 35 40 50 
树高 = 4
黑高 = 2
红色节点数 = 7
根是黑: 是
五大性质校验: 通过(根黑、无连续红、黑高一致)
重复插入 30: 被拒(key 唯一)
查找 35: 找到,查找 100: 没找到

这组输出里几个数值得盯着看:12 个节点树高 4、黑高 2,红色节点 7 个------红节点数超过一半,说明"近似平衡"下树确实允许红色扎堆,只要不连续、黑高守恒就行。


四、哨兵 NIL 与结点结构

红黑树有个选择:叶子用普通空指针 NULL ,还是用一个共享的哨兵 NIL 结点。哨兵方案更省心,原因很实在:

  • 旋转、变色时到处都是"判空",用 NULL 得写一堆 if (x != NULL);
  • 换成哨兵,叶子永远指向一个真实存在的黑色结点 ,判断"是不是空"变成 x == _nil,代码清爽;
  • 哨兵本身是黑色的,天然满足性质 5,不需要单独处理叶子颜色。

哨兵是一个共享结点,整棵树所有"空位置"都指向它,不产生额外开销。

完整实现如下:

cpp 复制代码
// 哨兵 NIL 方案
// 聚焦插入 + 插入修复

#include <iostream>

namespace byte
{
    enum Color { RED, BLACK };

    template <class K>
    struct RBNode
    {
        K _key;
        Color _col;
        RBNode* _left;
        RBNode* _right;
        RBNode* _parent;

        // nil 参数:哨兵节点的四个指针都指向自身,普通节点四个指针都指向哨兵
        RBNode(const K& key, Color c, RBNode* nil)
            : _key(key), _col(c), _left(nil), _right(nil), _parent(nil) {}
    };

    template <class K>
    class RBTree
    {
    public:
        typedef RBNode<K> Node;

        RBTree()
        {
            // 哨兵 NIL:一个共享的黑色空节点,叶子与根的父指针都指向它
            _nil = new Node(K(), BLACK, 0);
            _nil->_left = _nil->_right = _nil->_parent = _nil;
            _root = _nil;
        }

        ~RBTree() { destroy(_root); delete _nil; }

        bool insert(const K& key)
        {
            if (_root == _nil)
            {
                _root = new Node(key, BLACK, _nil);   // 根必须是黑的
                return true;
            }
            Node* parent = _nil;
            Node* cur = _root;
            while (cur != _nil)
            {
                parent = cur;
                if (key < cur->_key) cur = cur->_left;
                else if (cur->_key < key) cur = cur->_right;
                else return false;                     // 重复 key 拒绝
            }
            Node* z = new Node(key, RED, _nil);        // 新节点默认红色
            z->_parent = parent;
            if (key < parent->_key) parent->_left = z;
            else parent->_right = z;
            insertFixup(z);
            return true;
        }

        // 黑高:从根到任意哨兵路径上的黑色节点数(根算在内,哨兵不算)
        int blackHeight() const { return blackHeight(_root); }

        // 树高(红色节点也算层数)
        int height() const { return height(_root); }

        bool find(const K& key) const
        {
            Node* cur = _root;
            while (cur != _nil)
            {
                if (key < cur->_key) cur = cur->_left;
                else if (cur->_key < key) cur = cur->_right;
                else return true;
            }
            return false;
        }

        void inorder() const { inorder(_root); std::cout << std::endl; }

        // 校验五大性质:返回 true 表示整棵树合法
        // 1 每个节点非红即黑(enum 保证) 2 根黑 3 无连续红 4 每条路径黑高一致
        bool isValid() const
        {
            if (_root == _nil) return true;
            if (_root->_col != BLACK) return false;               // 性质2
            int expect = -1;
            return check(_root, 0, expect);
        }

        // 调试用:返回根的颜色(观察插入修复后根始终为黑)
        bool rootIsBlack() const { return _root == _nil || _root->_col == BLACK; }

        // 调试用:统计树中红色节点个数(观察"红不过半"的直观现象)
        int redCount() const { return redCount(_root); }

    private:
        Node* _root;
        Node* _nil;

        static bool isRed(Node* n) { return n && n->_col == RED; }

        void leftRotate(Node* x)
        {
            Node* y = x->_right;
            x->_right = y->_left;
            if (y->_left != _nil) y->_left->_parent = x;
            y->_parent = x->_parent;
            if (x->_parent == _nil) _root = y;
            else if (x == x->_parent->_left) x->_parent->_left = y;
            else x->_parent->_right = y;
            y->_left = x;
            x->_parent = y;
        }

        void rightRotate(Node* x)
        {
            Node* y = x->_left;
            x->_left = y->_right;
            if (y->_right != _nil) y->_right->_parent = x;
            y->_parent = x->_parent;
            if (x->_parent == _nil) _root = y;
            else if (x == x->_parent->_right) x->_parent->_right = y;
            else x->_parent->_left = y;
            y->_right = x;
            x->_parent = y;
        }

        void insertFixup(Node* z)
        {
            while (isRed(z->_parent))
            {
                Node* gp = z->_parent->_parent;
                if (z->_parent == gp->_left)
                {
                    Node* uncle = gp->_right;
                    if (isRed(uncle))                       // 情形一:叔父红,仅变色
                    {
                        z->_parent->_col = BLACK;
                        uncle->_col = BLACK;
                        gp->_col = RED;
                        z = gp;                             // 继续向上处理
                    }
                    else                                    // 叔父黑(或哨兵)
                    {
                        if (z == z->_parent->_right)        // 情形二:先左旋成 LL
                        {
                            z = z->_parent;
                            leftRotate(z);
                        }
                        z->_parent->_col = BLACK;           // 情形三:右旋 + 变色
                        gp->_col = RED;
                        rightRotate(gp);
                    }
                }
                else                                        // 父在右,镜像
                {
                    Node* uncle = gp->_left;
                    if (isRed(uncle))
                    {
                        z->_parent->_col = BLACK;
                        uncle->_col = BLACK;
                        gp->_col = RED;
                        z = gp;
                    }
                    else
                    {
                        if (z == z->_parent->_left)
                        {
                            z = z->_parent;
                            rightRotate(z);
                        }
                        z->_parent->_col = BLACK;
                        gp->_col = RED;
                        leftRotate(gp);
                    }
                }
            }
            _root->_col = BLACK;                            // 性质2兜底
        }

        int blackHeight(Node* n) const
        {
            if (n == _nil) return 0;
            int self = (n->_col == BLACK) ? 1 : 0;
            int l = blackHeight(n->_left);
            int r = blackHeight(n->_right);
            return (l > r ? l : r) + self;
        }

        int height(Node* n) const
        {
            if (n == _nil) return 0;
            int l = height(n->_left), r = height(n->_right);
            return (l > r ? l : r) + 1;
        }

        // 递归校验:黑高一致性 + 无连续红
        bool check(Node* n, int blackCnt, int& expect) const
        {
            if (n == _nil)
            {
                if (expect == -1) expect = blackCnt;
                return blackCnt == expect;
            }
            if (n->_col == RED)
            {
                if (isRed(n->_parent)) return false;        // 连续红
            }
            else
            {
                ++blackCnt;                                 // 黑节点计数
            }
            return check(n->_left, blackCnt, expect) && check(n->_right, blackCnt, expect);
        }

        int redCount(Node* n) const
        {
            if (n == _nil) return 0;
            return (n->_col == RED ? 1 : 0) + redCount(n->_left) + redCount(n->_right);
        }

        void destroy(Node* n)
        {
            if (n == _nil) return;
            destroy(n->_left);
            destroy(n->_right);
            delete n;
        }

        void inorder(Node* n) const
        {
            if (n == _nil) return;
            inorder(n->_left);
            std::cout << n->_key << ' ';
            inorder(n->_right);
        }
    };
}

#endif

结点结构比 AVL 多两个东西,少一个东西:

  • 多了 _parent 父指针:变色、旋转要频繁往上走,没有父指针得靠递归回溯,麻烦;
  • 多了 _col 颜色:RED 或 BLACK;
  • 少了 _height 高度变量:红黑树不维护高度、不维护平衡因子,这是和 AVL 最本质的区别。

_nil 的初始化有个细节值得留意:哨兵结点自身的 _left、_right、_parent 都指向自己,这样任何"走到哨兵再往下"的操作都不会踩空。


五、左旋右旋:代码和 AVL 一模一样,目的不同

红黑树也要旋转,旋转代码和 AVL 篇手写的那两套完全一致:三处指针重连 + 一棵子树过继。

cpp 复制代码
void leftRotate(Node* x)
{
    Node* y = x->_right;
    x->_right = y->_left;
    if (y->_left != _nil) y->_left->_parent = x;
    y->_parent = x->_parent;
    if (x->_parent == _nil) _root = y;
    else if (x == x->_parent->_left) x->_parent->_left = y;
    else x->_parent->_right = y;
    y->_left = x;
    x->_parent = y;
}

rightRotate 是它的镜像。代码复用,但目的变了:

  • AVL 旋转是为了把 |BF| 从 2 修回 1 以内,修的是"高度差";
  • 红黑树旋转是为了把连续红"掰"成可以变色的形状,修的是"颜色布局"。

所以红黑树旋转不更新任何高度,旋转完直接进入变色逻辑。这个"复用旋转、剥离高度"的设计,是理解红黑树的关键。


六、插入为什么默认红色

新节点默认涂红色,是红黑树插入修复能"便宜"的根本原因。做个对比就懂了:

  • 插红色 :最多违反性质 3(连续红),而且只在"父节点也是红"时才违反,影响是局部的;修一个连续红,变色或就近旋转就能收口。
  • 插黑色 :直接违反性质 4(黑高不一致),影响是全局的------新节点所在路径凭空多了一个黑,整条路径的黑高都变了,得把整棵树的黑高重新对齐,代价大得多。

所以默认红色是"先拣软柿子捏":红节点造成的违规局部、易修;黑节点造成的违规全局、难修。插入后的修复流程,就是把"可能出现的连续红"逐级往上消化,最后把根强制涂黑兜底。

插入的落位还是 BST 规则,落位后接一段 insertFixup:

cpp 复制代码
Node* z = new Node(key, RED, _nil);        // 新节点默认红色
z->_parent = parent;
if (key < parent->_key) parent->_left = z;
else parent->_right = z;
insertFixup(z);                            // 修复可能出现的连续红

七、插入修复:一条循环,两类分支

插入修复的主干是个 while:只要父节点还是红色(说明出现了连续红),就继续修。每一轮按"父节点是祖父的左孩子还是右孩子"分左右镜像,每边再按"叔父红不红"分两类。

cpp 复制代码
void insertFixup(Node* z)
{
    while (isRed(z->_parent))
    {
        Node* gp = z->_parent->_parent;
        if (z->_parent == gp->_left)
        {
            Node* uncle = gp->_right;
            if (isRed(uncle))                       // 情形一:叔父红,仅变色
            {
                z->_parent->_col = BLACK;
                uncle->_col = BLACK;
                gp->_col = RED;
                z = gp;                             // 继续向上处理
            }
            else                                    // 叔父黑(或哨兵)
            {
                if (z == z->_parent->_right)        // 情形二:先左旋成 LL
                {
                    z = z->_parent;
                    leftRotate(z);
                }
                z->_parent->_col = BLACK;           // 情形三:右旋 + 变色
                gp->_col = RED;
                rightRotate(gp);
            }
        }
        else                                        // 父在右,镜像
        {
            // ... 与上面完全对称
        }
    }
    _root->_col = BLACK;                            // 性质2兜底
}

两类分支各抓要害:

情形一:叔父红,仅变色。 父和叔都变黑,祖父变红,然后把祖父当成"新的待处理节点"继续向上。这一步不旋转,是代价最小的修复。变色后祖父可能又和它的父节点撞色,所以循环继续。

情形二/三:叔父黑(或哨兵),旋转 + 变色。 叔父黑时不能靠变色消红,得旋转。如果新节点是"内侧"(父的右孩子),先旋转父掰成 LL 直线,再整体旋转祖父、变色。这一支对应 AVL 里的 LL/LR(以及镜像的 RR/RL)旋转,只是旋转完不更新高度。

两种典型路径的实测输出:

text 复制代码
== 场景一:叔父红,仅变色 ==
插 10,5,15: 树高 2,校验 OK
插 3(触发变色): 树高 3,黑高 2,校验 OK

== 场景二:叔父黑,旋转 ==
插 1,2,3: 树高 2,中序 1 2 3 
  校验 OK,根黑 是

== 场景三:有序插入 10..50,黑高演化 ==
插 10: 树高 1,黑高 1,校验 OK
插 20: 树高 2,黑高 1,校验 OK
插 30: 树高 2,黑高 1,校验 OK
插 40: 树高 3,黑高 2,校验 OK
插 50: 树高 3,黑高 2,校验 OK

注意场景三的黑高演化:插 10、20、30 时黑高一直是 1,插到 40 才抬到 2,插 50 保持 2------黑高不是每插一个就涨,而是规模翻倍时才涨一层,这正是"近似平衡 + 黑高守恒"的直观体现。


八、删除修复:只抓主干,不强背六种

删除是红黑树公认最难的环节,比插入复杂得多。但正确方法是抓主干思路,不背六种细分场景------笔试手写删除全套的概率极低,考的是"删黑节点会怎样、怎么补"的理解。

主干三句话:

  1. 删红节点不破坏平衡:删一个红节点,任何路径黑高都不变,直接删完走人;
  2. 删黑节点破坏黑高:被删路径黑高减一,性质 4 崩,得"补黑";
  3. 补黑的套路 :把被删黑节点的"替代者"标记为"双重黑",再按兄弟节点是红是黑分情况,用旋转 + 变色把多出来的黑匀出去,最多 3 次旋转收尾。

对比表里最值得记的两个数:插入红黑树和 AVL 都是最多 2 次旋转,但删除红黑树最多 3 次、AVL 最多 O(logN) 次。这一个数字差,就是红黑树当选 STL 底层的原因。

九、难点拆解

9.1 为什么红黑树不维护高度

AVL 靠 _height 和 BF 精打细算,红黑树直接不要这套。原因在约束的不同:AVL 的平衡是"高度差"这个连续量 ,必须精确跟踪;红黑树的平衡是"颜色"这个离散量,只有红黑两种状态,不需要高度也能判断平衡。少一个字段、少一步更新,增删就更轻。

9.2 旋转代码一样,为什么目的不同

这是最容易被问懵的点。旋转的机械动作 (指针重连)两棵树完全一样,但触发条件 和旋转后做什么不同:AVL 旋转前要先更新高度、旋转后算 BF;红黑树旋转是为了改形状,旋转后直接进变色。抓"动作同、语义异"就能理清。

9.3 插入红色 vs 插入黑色的权衡

本质是"局部违规 vs 全局违规"的取舍。红节点违反性质 3(局部),修起来就近;黑节点违反性质 4(全局),修起来要动整条路径。默认红,是把风险压在代价小的一侧。

9.4 哨兵 NIL 到底省了什么

省的是"判空"。用 NULL,旋转和变色每个节点都要 if (x != NULL);用哨兵,叶子永远指向真实黑节点,判断变成 == _nil。C++98 没有 nullptr,哨兵还顺带绕开了 NULL 的类型歧义。


十、易踩坑汇总

序号 坑点 说明
1 五条性质背串 性质 3(连续红)和性质 4(黑高)最常混,一个局部一个全局
2 黑高把红节点算进去 黑高只数黑节点,红节点不计入
3 漏了根必须黑 插入修复最后要把根强制涂黑,忘了性质 2 就崩
4 新节点默认涂黑 默认黑直接破坏黑高,全局修复代价大
5 哨兵结点没初始化好 哨兵自己的三个指针必须指自己,否则走到叶子就踩空
6 旋转后更新高度 红黑树旋转不更新高度,照 AVL 的习惯加 updateHeight 反而错
7 叔父红/黑分支写反 叔父红变色、叔父黑旋转,写反会导致连续红修不掉
8 父节点是左/右孩子镜像漏写 insertFixup 左右镜像对称,漏一边插入修复只对一半数据生效
9 删黑节点不补黑 删除只想着"摘节点",忘了黑高守恒,树悄悄失衡
10 把红黑树当 AVL 红黑树没有 BF、没有 height,别套 AVL 的账本
11 忘记 set/map 底层是红黑树 高频考点:STL 有序关联容器底层是红黑树,不是 AVL
12 忽略"近似平衡"的含义 红黑树允许最长路径 ≤ 2×最短路径,不是绝对平衡

十一、闭环复盘

红黑树一整篇,落成六条:

第一,红黑树用"颜色"换"少旋转"。 AVL 严格平衡、增删旋转多;红黑树近似平衡、增删旋转少。这是选型的分水岭。

第二,五大性质是宪法,3 和 4 是核心。 性质 3 管局部(无连续红),性质 4 管全局(黑高一致),一松一紧构成全部约束。

第三,黑高是度量,红节点不计入。 黑高守恒推出"最长路径 ≤ 2×最短路径",于是查找稳定 O(logN)。

第四,哨兵 NIL 省判空。 叶子永远指向真实黑节点,旋转变色不用满屏判 NULL。

第五,旋转复用、剥离高度。 旋转动作和 AVL 一模一样,但红黑树不维护 BF、不维护 height,旋转只为改形状方便变色。

第六,插入默认红、修复分两类。 叔父红仅变色,叔父黑旋转;插入最多 2 次旋转。删除更复杂但最多 3 次旋转,抓"删黑补黑"主干即可。


十二、笔试简答背诵精简版

  • 五大性质:非红即黑;根黑;无连续红;黑高一致;叶子 NIL 黑。
  • 黑高定义:路径上黑色节点数,红节点不计入。
  • 插入默认红色原因:红色只违反性质 3(局部易修),黑色违反性质 4(全局难修)。
  • 插入修复两类:叔父红仅变色;叔父黑旋转 + 变色。
  • 插入最多旋转:2 次。
  • 删除最多旋转:3 次(AVL 删除是 O(logN) 次)。
  • 红黑树 vs AVL:AVL 更平衡、查找略快;红黑树增删旋转少,是 STL set/map 底层。
  • STL 底层 :std::set、std::map 底层是红黑树,不是 AVL。

相关推荐
高冷小鱼殿下2 小时前
内部类Lambda与枚举
java·开发语言
shelly聊外贸2 小时前
WhatsApp 聊天记录如何导出为 CSV?使用 Python 进行数据分析
开发语言·python·数据分析
octopus_c3 小时前
C++ 模板详解:函数模板、类模板、特化与分离编译
开发语言·c++·算法
東隅已逝,桑榆非晚3 小时前
算法1. 双指针法-移动零
c++·算法
椒颜皮皮虾྅4 小时前
让文档不止于文字:DeploySharp + PP-Structure,打通版面分析与表格重建
开发语言·学习·c#·.net·cuda
Eider19984 小时前
Python基础知识之并发相关-2
开发语言·前端·python
第九序言T4 小时前
Python 数据处理进阶(二十二)用 Pandas 做一次完整的用户行为分析
开发语言·python·数据分析·pandas
云和数据.ChenGuang4 小时前
再论深拷贝和浅拷贝
开发语言·人工智能·python·1024程序员节
悠仁さん4 小时前
【C++】set map 使用
java·前端·c++