目录

引言

一. 红黑树的概念

1.1 什么是红黑树

1.2 红黑树的规则

1.2 红黑树是如何保持平衡的

1.3 红黑树的效率

1.3.1 红黑树的时间复杂度

1.3.2 红黑树 VS AVL树

二. 红黑树的实现

2.1 红黑树的结构

2.2 红黑树的插入

2.2.1 红黑树插入的过程

2.2.2 情况1:变色

2.2.3 情况2:单选+变色

2.2.4 情况3:双旋+变色

2.3 红黑树的插入完整代码实现

2.4 红黑树的验证

2.5 红黑树的查找(同BST)

总结


引言

        在C++的数据结构体系中,红黑树是一种极为重要且应用广泛的自平衡二叉搜索树。它是STL中mapset底层实现的核心数据结构。与AVL树相比,红黑树通过更宽松的平衡条件,在保证最坏情况下效率 O(log N) 的同时,显著减少了旋转操作的次数,从而在实际应用中展现出更优的性能表现。


一. 红黑树的概念

1.1 什么是红黑树

红黑树本质上是一棵二叉搜索树,但在每个节点上增加了一个颜色标记(红色或黑色)。通过巧妙地利用颜色约束,红黑树能够确保从根到任意叶子节点的路径长度差异不会超过2倍,从而实现了近似平衡。


1.2 红黑树的规则

  1. 颜色限制:每个结点不是红色就是黑色 。
  2. 根的颜色:根结点必须是黑色的 。
  3. 红色互斥:如果一个结点是红色的,则它的两个孩子结点必须是黑色的(即不能有连续的红色结点)。
  4. 黑色平衡:对于任意一个结点,从该结点到其所有空结点(NIL)的路径上,均包含相同数量的黑色结点 。
  5. (补充规则):所有的叶子结点(NIL 结点)被视为黑色 。

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



1.2 红黑树是如何保持平衡的

根据规则4,从根到空节点的每条路径都有相同数量的黑色节点。假设这个黑色节点数量为bh(black height)。

  • 最短路径:极端情况下,路径全部由黑色节点组成,长度为bh
  • 最长路径:根据规则3,不能有连续的红色节点,所以最长路径是黑红交替组成,长度为2*bh

因此,对于任意路径长度h,有:bh ≤ h ≤ 2*bh


1.3 红黑树的效率

1.3.1 红黑树的时间复杂度

假设红黑树中有N个节点,最短路径长度为h,则:

2^h - 1 ≤ N < 2^(2*h) - 1

由此可推导出:h ≈ log₂N

即使走最长路径,也只需要 2*log₂N的时间,因此红黑树的增删查改操作时间复杂度都是O(log N)


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

思考:相比 AVL 树,红黑树好在哪里?

红黑树对平衡的控制不如 AVL 树严格,这意味着在插入相同数量的结点时,红黑树引发的旋转次数更少。对于频繁进行插入和删除操作的场景,红黑树的综合性能略优于 AVL 树 。


1.3.2 红黑树 VS AVL树

对比维度 AVL树 红黑树
平衡条件 左右子树高度差≤1(严格平衡) 最长路径≤2×最短路径(近似平衡)
平衡实现 通过高度差直观控制 通过颜色规则间接控制
旋转次数 插入时最多2次,删除时可能O(log N)次 插入和删除都最多3次旋转
查找效率 O(log N),常数稍优 O(log N)
插入效率 O(log N),调整较多 O(log N),调整较少
使用场景 查询密集型应用 插入删除频繁的应用(STL)

总结:红黑树牺牲了一定的平衡性(但仍保证O(log N)),换取了更少的旋转操作,在实际应用中通常性能更优。


二. 红黑树的实现

2.1 红黑树的结构

为了方便后续的旋转和变色操作,我们的节点定义通常采用三叉链结构(包含指向父亲的指针),并显式定义颜色枚举。

// 枚举表示颜色
enum Color
{
	RED,
	BLACK
};

// 这⾥我们默认按key/value结构实现 
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:
private:
    Node* _root = nullptr;
};

注意:构造函数中,新插入的节点默认是红色

原因:如果插入黑色节点,必然会违反“每条路径黑色节点数相同”(规则 4),这是全局性的破坏,极难修复。 * 而插入红色节点,可能只会违反“不能连续红”(规则 3),这只是局部性的问题,更容易通过变色或旋转修复 。


2.2 红黑树的插入

2.2.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)。


2.2.2 情况1:变色

条件:uncle存在且为红

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的左还是右,都是上面的变色处理方式。


if (uncle && uncle->_col == RED) {
    // 变色
    parent->_col = uncle->_col = BLACK;
    grandfather->_col = RED;
    
    // 继续向上处理
    cur = grandfather;
    parent = cur->_parent;
}

跟AVL树类似,上图展示了一种具体情况,但是实际中需要这样处理的有很多种情况。

图1将以上类似的处理进行了抽象表达,d/e/f代表每条路径拥有hb个黑色结点的子树,a/b代表每 条路径拥有hb-1个黑色结点的根为红的子树,hb>=0。

下图分别展示了hb == 0/hb == 1/hb == 2的具体情况组合分析,当hb等于2时,这里组合情况上百亿种,但我们只需知道,不论情况多少种,多么复杂,处理方式一样的,变色再继续往上处理即可


抽象表示所有情况
hb == 0
hb == 1
hb == 2

2.2.3 情况2:单选+变色

条件:uncle不存在uncle存在且为黑并且cur、parent、grandfather在同一侧

c为红,p为红,g为黑,u不存在或者u存在且为黑,u不存在,则c一定是新增结点u存在且为黑,则 c一定不是新增,c之前是黑色的,是在c的子树中插入,符合情况1,变色将c从黑色变成红色,更新上来的。

原理分析:p必须变黑,才能解决,连续红色结点的问题,u不存在或者是黑色的,这里单纯的变色无法解决问题,需要旋转+变色

        g

   p         u

c

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

        g

   u         p

                   c

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



2.2.4 情况3:双旋+变色

条件:uncle不存在uncle为黑色,并且cur、parent、grandfather呈"之"字形,不在同一侧

c为红,p为红,g为黑,u不存在或者u存在且为黑,u不存在,则c一定是新增结点,u存在且为黑,则c一定不是新增,c之前是黑色的,是在c的子树中插入,符合情况1,变色将c从黑色变成红色,更新上来的。

原理分析:p必须变黑,才能解决,连续红色结点的问题,u不存在或者是黑色的,这里单纯的变色无法解决问题,需要旋转+变色

        g

   p         u

       c

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

        g

   u         p

           c

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



2.3 红黑树的插入完整代码实现

template<class K, class V>
class RBTree
{
	typedef RBTreeNode<K, V> Node;
public:
	bool Insert(const pair<K, V>& kv)
	{
        // BST的插入方法
		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;
			}
		}

		// 新增节点必须为红色
		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;
            // 父亲为爷爷的左子树
			if (grandfather->_left == parent)
			{
				//    g
				//  p   u
				//c
				Node* uncle = grandfather->_right;
				// 叔叔存在且为红
				if (uncle && uncle->_col == RED)
				{
					// 变色+继续往上处理
					parent->_col = uncle->_col = BLACK;
					grandfather->_col = RED;

					cur = grandfather;
					parent = cur->_parent;
				}
				else // 叔叔不存在或者叔叔存在且为黑
				{
					//    g
					//  p   u
					//c
					// 单旋+变色
					if (cur == parent->_left)
					{
						RotateR(grandfather);
						
						parent->_col = BLACK;
						grandfather->_col = RED;
					}
					else
					{
						//    g
						//  p   u
						//	  c
						// 双旋+变色
						RotateL(parent);
						RotateR(grandfather);

						cur->_col = BLACK;
						grandfather->_col = RED;
					}

					break;
				}
			}
            // 父亲为爷爷的右子树
			else 
			{
				//    g
				//  p   u
				//		  c
				Node* uncle = grandfather->_left;
				// 叔叔存在且为红
				if (uncle && uncle->_col == RED)
				{
					// 变色+继续往上处理
					parent->_col = uncle->_col = BLACK;
					grandfather->_col = RED;

					cur = grandfather;
					parent = cur->_parent;
				}
				else // 叔叔不存在或者叔叔存在且为黑
				{
					//    g
					//  p   u
					//		  c
					// 单旋+变色
					if (cur == parent->_right)
					{
						RotateL(grandfather);

						parent->_col = BLACK;
						grandfather->_col = RED;
					}
					else
					{
						//    g
						//  p   u
						//	  c
						// 双旋+变色
						RotateR(parent);
						RotateL(grandfather);

						cur->_col = BLACK;
						grandfather->_col = RED;
					}

					break;
				}
			}
		}

		// 无论最后根节点是否为黑,就直接置为黑
        // 确保根节点是黑色
		_root->_col = BLACK;

		return true;
	}
}

2.4 红黑树的验证

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

  • 规则1枚举颜色类型,天然实现保证了颜色不是黑色就是红色。
  • 规则2直接检查根即可
  • 规则3前序遍历检查,遇到红色结点查孩子不太方便,因为孩子有两个,且不一定存在,反过来检查父亲的颜色就方便多了。
  • 规则4前序遍历,遍历过程中用形参记录跟到当前结点的blackNum(黑色结点数量),前序遍历遇到黑色结点就++blackNum,走到空就计算出了一条路径的黑色结点数量。再任意一条路径黑色结点数量作为参考值,依次比较即可。


template<class K, class V>
class RBTree
{
	typedef RBTreeNode<K, V> Node;
public:
    bool IsBalanceTree()
	{
		if (_root && _root->_col == RED)
			return false;

		// 最左路径黑色节点的数量作参考比较其他路径
		int left_bn = 0;
		Node* cur = _root;
		while (cur)
		{
			if (cur->_col == BLACK)
				left_bn++;

			cur = cur->_left;
		}

		return _CheckColor(_root, 0, left_bn);
	}
private:
        // root_cur_bn 根节点到当前节点的黑色节点个数
		// 前序递归
		bool _CheckColor(Node* root, int root_cur_bn, const int left_bn)
		{
			if (root == nullptr)
			{
				// 检查每条路径黑色节点的数量
				if (root_cur_bn != left_bn)
				{
					cout << "黑色节点的数量不相等" << endl;
					return false;
				}

				return true;
			}

			if (root->_col == BLACK)
			{
				root_cur_bn++;
			}

			// 检查是否有连续的红色节点
			// 如果检查孩子的话,情况比较多,所以这里检查父亲
			if (root->_col == RED && root->_parent && root->_parent->_col == RED)
			{
				cout << root->_kv.first << "存在连续红色节点" << endl;
				return false;
			}

			return _CheckColor(root->_left, root_cur_bn, left_bn)
				&& _CheckColor(root->_right, root_cur_bn, left_bn);
		}
}

2.5 红黑树的查找(同BST)

template<class K, class V>
class RBTree
{
	typedef RBTreeNode<K, V> Node;
public: 
    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;
	}
}

总结

        掌握红黑树不仅能帮助我们深入理解STL容器的底层原理,更能提升我们对数据结构设计思想的理解。在实际开发中,我们通常直接使用STL提供的容器,但了解其内部机制能帮助我们写出更高效的代码。

如有不足或改进之处,欢迎大家在评论区积极讨论,后续我也会持续更新C++相关的知识。文章制作不易,如果文章对你有帮助,就点赞收藏关注支持一下作者吧,让我们一起努力,共同进步!

Logo

Agent 垂直技术社区,欢迎活跃、内容共建。

更多推荐