引言

在数据结构的世界里,平衡二叉树是提升查找、插入和删除效率的关键。其中,红黑树作为一种高效的平衡二叉搜索树,被广泛应用于 C++ STL 的 set、map 等容器中。它通过巧妙的颜色约束和旋转操作,在保证近似平衡的同时,大幅减少了维护平衡所需的旋转次数。本文将从红黑树的基本概念出发,深入解析其实现原理、插入操作、验证方法,并通过丰富的例子帮助你彻底理解这一数据结构。

一、红黑树的概念:不止是 “红” 与 “黑”

红黑树是一种二叉搜索树,它的每个节点增加了一个存储位表示颜色(红色或黑色)。通过对从根到叶子的所有路径上的节点颜色进行约束,红黑树确保了没有一条路径会比其他路径长出 2 倍,从而实现了近似平衡。

1.1 红黑树的核心规则

1. 颜色约束:每个节点不是红色就是黑色。
2. 根节点特性:根节点必须是黑色。
3. 红色节点限制:如果一个节点是红色,则它的两个子节点必须是黑色(即不存在连续的红色节点)。
4. 黑色节点平衡:对于任意一个节点,从该节点到其所有空节点(NULL)的简单路径上,包含的黑色节点数量相同。

补充说明 :《算法导论》等书籍上补充了⼀条每个叶子节点(NIL)都是黑色的规则。他这⾥所指的叶子节点不是传统的意义上的叶子节点,⽽是我们说的空节点,有些书籍上也把NIL叫做外部节点。NIL是为了方便准确的标识出所有路径,《算法导论》在后续讲解实现的细节中也忽略了NIL节点,所以我们知道⼀下这个概念即可。

以下是几个红黑树的实例图:
在这里插入图片描述
在这里插入图片描述

在这里插入图片描述
在这里插入图片描述

1.2 为什么最长路径不超过最短路径的 2 倍?

红黑树的近似平衡特性可通过规则推导得出:

  • 由规则4可知,从根到NULL节点的每条路径都有相同数量的黑色节点,所以极端场景下,最短路径就就是全是黑色节点的路径,假设最短路径长度为bh(black height)。

  • 由规则2和规则3可知,任意⼀条路径不会有连续的红色节点,所以极端场景下,最长的路径就是⼀黑⼀红间隔组成,那么最长路径的长度为2*bh。

  • 综合红黑树的4点规则而言,理论上的全黑最短路径和⼀黑⼀红的最长路径并不是在每棵红黑树都存在的。假设任意⼀条从根到NULL节点路径的长度为x 那么bh <= h <= 2*bh。

因此,任意路径长度h满足 bh ≤ h ≤ 2*bh,即最长路径不超过最短路径的 2 倍。

1.3 红黑树的效率

假设N是红黑树树中节点数量,h最短路径的长度,那么2^h − 1 <= N < 2^(2∗h) − 1 , 由此推出h≈ logN,也就是意味着红黑树增删查改最坏也就是⾛最长路径 ,那么时间复杂度还是O(logN)

红黑树的表达相对AVL树要抽象⼀些,AVL树通过⾼度差直观的控制了平衡。红黑树通过4条规则的颜色约束,间接的实现了近似平衡,他们效率都是同⼀档次,但是相对而言,插入相同数量的节点,红黑树的旋转次数是更少的,因为它对平衡的控制没那么严格。

在这里插入图片描述

二、红黑树的结构设计:节点与类定义

红黑树的结构需包含节点颜色、键值对、左右子树指针及父节点指针(用于平衡调整)

2.1 节点定义

// 颜色枚举
enum Colour {
    RED,
    BLACK
};

// 节点模板类
template<class K, class V>
struct RBTreeNode {
    pair<K, V> _kv;          // 键值对
    RBTreeNode* _left;       // 左子树
    RBTreeNode* _right;      // 右子树
    RBTreeNode* _parent;     // 父节点(用于平衡调整)
    Colour _col;             // 颜色

    // 构造函数
    RBTreeNode(const pair<K, V>& kv)
        : _kv(kv)
        , _left(nullptr)
        , _right(nullptr)
        , _parent(nullptr)
        , _col(RED)  // 新增节点默认红色(减少规则4的破坏),
        //也可以不在这里设置,但在插入的时候新增节点的时候要设置颜色为红色
    {}
};

2.2 红黑树类定义

template<class K, class V>
class RBTree {
    typedef RBTreeNode<K, V> Node;
private:
    Node* _root = nullptr;  // 根节点
public:
    // 核心接口:插入、查找、验证等
    bool Insert(const pair<K, V>& kv);
    Node* Find(const K& key);
    bool IsBalance();
private:
    // 辅助函数:旋转、检查平衡等
    void RotateL(Node* parent);    // 左旋转
    void RotateR(Node* parent);    // 右旋转
    bool Check(Node* root, int blackNum, const int refNum);
};

三、红黑树的插入操作:最复杂的环节

红黑树的插入分为两步:按二叉搜索树规则插入节点,再通过变色和旋转修复平衡。

3.1 插入流程概述及规则

  1. 插入⼀个值按二叉搜索树规则进行插入,插入后我们只需要观察是否符合红黑树的4条规则。
  2. 如果是空树插入,新增节点是黑色节点。如果是非空树插入,新增节点必须红色节点,因为非空树插入,新增黑色节点就破坏了规则4,规则4是很难维护的。
  3. 非空树插入后,新增节点必须红色节点,如果父亲节点是黑色的,则没有违反任何规则,插入结束
  4. 非空树插入后,新增节点必须红色节点,如果父亲节点是红色的,则违反规则3。进⼀步分析,c是红色,p为红,g必为黑,这三个颜色都固定了,关键的变化看u的情况,需要根据u分为以下⼏种情况分别处理。
    说明:下图中假设我们把新增节点标识为c (cur),c的父亲标识为p(parent),p的父亲标识为g(grandfather),p的兄弟标识为u(uncle)

3.2 插入平衡修复:3 种情况分析(重点)

情况1:变色

c为红,p为红,g为黑,u存在且为红,则将p和u变黑,g变红。在把g当做新的c,继续往上更新。

分析:因为p和u都是红色,g是黑色,把p和u变黑,左边子树路径各增加⼀个黑色节点,g再变红,相当于保持g所在子树的黑色节点的数量不变,同时解决了c和p连续红色节点的问题,需要继续往上更新是因为,g是红色,如果g的父亲还是红色,那么就还需要继续处理;如果g的父亲是黑色,则处理结束了;如果g就是整棵树的根,再把g变回黑色。

情况1只变色,不旋转。所以无论c是p的左还是右,p是g的左还是右,都是上面的变色处理方式。

在这里插入图片描述
跟AVL树类似,上图我们展示了⼀种具体情况,但是实际中需要这样处理的有很多种情况。

• 图1将以上类似的处理进行了抽象表达,d/e/f代表每条路径拥有hb个黑色节点的子树,a/b代表每条路径拥有hb-1个黑色节点的根为红的子树,hb>=0。
• 图2,图3,图4分别展示了hb == 0/hb == 1/hb == 2的具体情况组合分析,当hb等于2时,这⾥组合情况上百亿种,这些样例是帮助我们理解,不论情况多少种,多么复杂,处理方式⼀样的,变色再继续往上处理即可,所以我们只需要看抽象图即可。
在这里插入图片描述

在这里插入图片描述
在这里插入图片描述
在这里插入图片描述

情况2:单旋+变色

c为红,p为红,g为黑,u不存在或者u存在且为黑,u不存在,则c⼀定是新增节点(此时就相当于第一种情况),u存在且为黑,则c⼀定不是新增,c之前是黑色的,是在c的子树中插入(说明c是经过情况1后的结果,c是情况1中的grandfather),即符合情况1,变色将c从黑色变成红色,更新上来的。

分析:p必须变黑,才能解决,连续红色节点的问题,u不存在或者是黑色的,这⾥单纯的变色无法解决问题,需要旋转+变色。
在这里插入图片描述

如果p是g的左,c是p的左,那么以g为旋转点进行右单旋,再把p变黑,g变红即可。p变成这颗树新的根,这样子树黑色节点的数量不变,没有连续的红色节点了,且不需要往上更新,因为p的父亲是黑色还是红色或者空都不违反规则。

在这里插入图片描述
如果p是g的右,c是p的右,那么以g为旋转点进行左单旋,再把p变黑,g变红即可。p变成这颗树新的根,这样子树黑色节点的数量不变,没有连续的红色节点了,且不需要往上更新,因为p的父亲是黑色还是红色或者空都不违反规则。

在这里插入图片描述

在这里插入图片描述

情况3:双旋+变色

c为红,p为红,g为黑,u不存在或者u存在且为黑,u不存在,则c⼀定是新增节点,u存在且为黑,则c⼀定不是新增,c之前是黑色的,是在c的子树中插入,符合情况1,变色将c从黑色变成红色,更新上来的。(与情况2一样)
分析:p必须变黑,才能解决,连续红色节点的问题,u不存在或者是黑色的,这⾥单纯的变色无法解决问题,需要旋转+变色。
在这里插入图片描述
如果p是g的左,c是p的右,那么先以p为旋转点进行左单旋,再以g为旋转点进行右单旋,再把c变黑,g变红即可。c变成这颗树新的根,这样子树黑色节点的数量不变,没有连续的红色节点了,且不需要往上更新,因为c的父亲是黑色还是红色或者空都不违反规则。
在这里插入图片描述
如果p是g的右,c是p的左,那么先以p为旋转点进行右单旋,再以g为旋转点进行左单旋,再把c变黑,g变红即可。c变成这颗树新的根,这样子树黑色节点的数量不变,没有连续的红色节点了,且不需要往上更新,因为c的父亲是黑色还是红色或者空都不违反规则。
在这里插入图片描述
在这里插入图片描述

3.3 插入代码实现

bool Insert(const pair<K, V>& kv) {
    if (_root == nullptr) {
        _root = new Node(kv);
        _root->_col = BLACK;  // 根节点必黑
        return true;
    }

    // 1. 按二叉搜索树规则插入
    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;  // 键已存在
        }
    }

    // 新增节点为红色
    cur = new Node(kv);
    cur->_col = RED;
    if (parent->_kv.first < kv.first) {
        parent->_right = cur;
    } else {
        parent->_left = cur;
    }
    cur->_parent = parent;

    // 2. 修复平衡
    while (parent && parent->_col == RED) {  // 父红才需调整
  	  //  g
	 //  p u
        Node* grandfather = parent->_parent;  // 祖父必存在且为黑
        if (parent == grandfather->_left) {
            Node* uncle = grandfather->_right;  // 叔叔是祖父的右孩子

            // 情况1:叔叔存在且为红
            if (uncle && uncle->_col == RED) {
                parent->_col = BLACK;
                uncle->_col = BLACK;
                grandfather->_col = RED;
                // 继续向上调整
                cur = grandfather;
                parent = cur->_parent;
            } else {
                // 情况2:同方向(左左)
                if (cur == parent->_left) {
	                // 	g
					// p u
					//c
					//单旋
                    RotateR(grandfather);  // 右旋祖父
                    parent->_col = BLACK;
                    grandfather->_col = RED;
                } else {
	                // 	g
					// p u
					// c
					//双旋
                    // 情况3:反方向(左右)
                    RotateL(parent);  // 先左旋父
                    RotateR(grandfather);  // 再右旋祖父
                    cur->_col = BLACK;
                    grandfather->_col = RED;
                }
                break;  // 旋转后无需继续向上
            }
        } else {
	        //  g
			// u p
            // 对称情况:父是祖父的右孩子
            Node* uncle = grandfather->_left;
            // 叔叔存在且为红,->变色即可
            if (uncle && uncle->_col == RED) {
                // 情况1:变色
                parent->_col = BLACK;
                uncle->_col = BLACK;
                grandfather->_col = RED;
                // 继续往上处理
                cur = grandfather;
                parent = cur->_parent;
            } else {
	            // 情况二:叔叔不存在或者存在且为黑
				// 旋转+变色
				//  g
				// u p
				//    c
                // 情况2:同方向(右右)
                if (cur == parent->_right) {
                    RotateL(grandfather);  // 左旋祖父
                    parent->_col = BLACK;
                    grandfather->_col = RED;
                } else {
	                //  g
					// u p
					//  c
                    // 情况3:反方向(右左)
                    RotateR(parent);  // 先右旋父
                    RotateL(grandfather);  // 再左旋祖父
                    cur->_col = BLACK;
                    grandfather->_col = RED;
                }
                break;
            }
        }
    }

    _root->_col = BLACK;  // 确保根节点始终为黑
    return true;
}

四、红黑树的查找与验证

4.1 查找操作

红黑树的查找与普通二叉搜索树一致,时间复杂度为O(logN):

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;  // 未找到
}

4.2 平衡验证

红黑树的验证需检查 4 条规则,确保结构合法:
这⾥获取最长路径和最短路径,检查最长路径不超过最短路径的2倍是不可行的,因为就算满⾜这个条件,红黑树也可能颜色不满⾜规则,当前暂时没出问题,后续继续插入还是会出问题的。所以我们还是去检查4点规则,满⾜这4点规则,⼀定能保证最长路径不超过最短路径的2倍。

  1. 规则1枚举颜色类型,天然实现保证了颜色不是黑色就是红色。
  2. 规则2直接检查根即可
  3. 规则3前序遍历检查,遇到红色节点查孩子不太方便,因为孩子有两个,且不⼀定存在,反过来检查父亲的颜色就方便多了。
  4. 规则4前序遍历,遍历过程中⽤形参记录跟到当前节点的blackNum(黑色节点数量),前序遍历遇到黑色节点就++blackNum,⾛到空就计算出了⼀条路径的黑色节点数量。再任意⼀条路径黑色节点数量作为参考值,依次比较即可。
    在这里插入图片描述
// 检查所有路径的黑色节点数量是否一致,且无连续红色节点
bool Check(Node* root, int blackNum, const int refNum) {
    if (root == nullptr) {
        // 空节点:检查当前路径黑色节点数是否与参考值一致
        //return blackNum == refNum;
        //可以直接返回,也可以增加一条提示信息
        
        if(refNum != blackNum)
        {
        	cout<<"存在黑色节点的数量不相等的路径"<<endl;
        	return false;
        }
        return true;
    }
	
    // 检查连续红色节点
    if (root->_col == RED && 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);
}

// 验证入口
bool IsBalance() {
    if (_root == nullptr)
    	 return true;
    	 
   	//if(_root->_col == RED)
   	//	return false;
   		
   	//可以直接反向判断,增加提示信息
    if (_root->_col != BLACK) {
        cout << "错误:根节点不是黑色" << endl;
        return false;
    }

    // 计算参考黑色高度(最左路径的黑色节点数)
    int refNum = 0;
    Node* cur = _root;
    while (cur) {
        if (cur->_col == BLACK) {
            refNum++;
        }
        cur = cur->_left;
    }

    return Check(_root, 0, refNum);
}

五、红黑树 vs AVL 树:谁更胜一筹?

特性红黑树AVL树
平衡方式颜色约束(间接平衡)高度差(严格平衡,<=1)
旋转次数插入最多两次,删除最多三次插入最多两次,删除可能O(logN)
空间开销存储颜色(1位)存储高度(整数)
适用场景频繁插入删除(如STL容器)频繁查找(严格平衡更优)

两者时间复杂度均为O(logN),但红黑树在实际应用中更广泛,因插入删除的旋转成本更低。

红黑树的删除操作比插入更加复杂,需要处理六种情况,可以参考《算法导论》第13章。

Logo

北京人形旗下天工造物具身智能开源社区,聚焦具身天工与慧思开物两大平台

更多推荐