【数据结构】红黑树的算法原理解析与实现

前言:上一篇文章我们实现了AVL树,所以关于本篇的旋转部分我就不做解释了,在我上一篇文章【数据结构】自平衡二叉搜索树各种旋转算法原理解析及AVL树的C++实现

红黑树刚开始看确实比AVL树抽象一些,特别是插入以后同时出现c、p、g、u几个结点时,很容易把谁旋转、谁变色弄混。我这里还是先把规则和结点关系固定下来,再分情况看图,最后回到代码就会清楚很多。本篇完成的是插入版本,红黑树删除不在这里。


1.红黑树的概念


1.1 红黑树的规则

红黑树首先是一棵二叉搜索树,所以左子树中的key小于根,右子树中的key大于根,中序遍历仍然是有序的。它在这个基础上给每个结点增加红、黑两种颜色,并满足下面这些规则:

  1. 每个结点不是红色就是黑色。
  2. 根结点必须是黑色。
  3. 所有逻辑上的叶子结点NIL都是黑色。
  4. 红色结点的孩子必须是黑色,也就是从根向下的路径中不能出现两个连续的红色结点。
  5. 从任意一个结点出发,到它后代所有NIL结点的路径上,包含的黑色结点数量必须相同。

这里的NIL不是平时所说的"没有孩子的叶子结点",而是每个空指针位置对应的逻辑叶子。代码中并没有真的创建一批NIL哨兵,而是直接使用nullptr表示它们;分析颜色规则时,把这些空位置统一看成黑色即可。

上图把每个空位置对应的NIL画了出来。以根结点18为例,从18到任意一个NIL的路径中,实际黑色结点数量都是相同的。代码验证时可以统一不统计nullptr自身,因为每条路径都少算同一个黑色NIL,不会影响"黑色结点数量是否相等"的判断。

1.2 红黑树控制树高的方式

红黑树不是像AVL树一样严格平衡,它允许某一边比另一边高,但是不会让高度差无限扩大。原因就在于"黑高相同"和"不能连续出现红结点"这两条规则。

假设从根到NIL的路径中有bh个实际黑色结点:

  • 如果按路径中的实际结点数计算,最短路径至少包含bh个黑色结点,长度不会小于bh
  • 最长路径中红、黑结点最多交替出现,因为两个红色结点不能相邻,所以长度不会超过2 * bh

因此红黑树中,从根到NIL的最长路径不会超过最短路径的2倍。另一方面,一棵黑高为bh的红黑树至少包含2^bh - 1个内部结点,所以可以得到:

text 复制代码
bh <= log2(N + 1)
树高 h <= 2 * bh <= 2 * log2(N + 1)

也就是说,红黑树的高度仍然是O(log N),查找和插入的最坏时间复杂度也是O(log N)

红黑树与AVL树的效率属于同一个数量级,不过两者对平衡的要求不同。AVL树更严格,查询路径通常更短;红黑树只要求近似平衡,插入时有些冲突只需要变色就能解决,最多进行两次旋转,因此在需要频繁更新的场景中使用得非常多。实际旋转次数仍然与插入顺序有关,不能简单理解成红黑树在任何数据下都一定比AVL树旋转得少。

2.红黑树的结点结构与插入思路


2.1 结点结构

红黑树结点与前面的AVL树比较接近,同样保存左右孩子和父指针。区别在于这里不再维护平衡因子,而是使用枚举Colour记录结点颜色。

cpp 复制代码
enum Colour
{
	RED,
	BLACK
};

template<typename K, typename V>
struct TreeNode
{
	std::pair<K, V> _kv;
	TreeNode<K, V>* _left;
	TreeNode<K, V>* _right;
	TreeNode<K, V>* _parent;
	
	Colour _col;

	TreeNode(const std::pair<K, V>& kv)
		:_kv(kv)
		, _left(nullptr)
		, _right(nullptr)
		,_parent(nullptr)
		,_col(RED)
	{ }
};

新结点在构造时默认设置成红色。下面展示的成员函数都属于RBTree<K, V>类,类中使用Node作为TreeNode<K, V>的类型别名,_root保存整棵树的根结点。

2.2 红黑树颜色与插入方式

红黑树插入的第一步仍然是按照二叉搜索树的规则找到空位置。真正需要考虑的是,新结点到底应该染成红色还是黑色。

如果在一棵非空树中直接插入黑色结点,只有经过这个新结点的路径会多出一个黑色结点,其他路径的黑高没有变化,规则5马上就可能被破坏,而且修复黑高比较麻烦。插入红色结点则不会改变任何路径的黑高,此时可能产生的主要问题只是新结点和父结点连续为红。

所以插入时可以这样处理:

  • 空树插入:新结点就是根,直接染成黑色。
  • 非空树插入:新结点先染成红色。如果父结点为黑色,所有规则都没有被破坏,插入直接结束。
  • 如果父结点也为红色,就出现连续红结点,需要根据叔叔结点的位置和颜色继续调整。

为了后面看图和代码方便,先固定四个变量:

  • c表示当前需要处理的结点,也就是代码中的cur
  • p表示c的父结点,也就是parent
  • g表示p的父结点,也就是grandfather
  • u表示p的兄弟结点,也就是uncle

p为红色时,g一定存在并且一定是黑色。因为插入前整棵树是合法红黑树,红色的p不可能再有一个红色父结点。接下来真正决定处理方式的是u

  1. u存在并且为红色,只需要变色,再把问题向上交给g
  2. u不存在,或者存在但为黑色,需要旋转加变色。

u == nullptr时,它就是逻辑上的黑色NIL,所以"不存在"和"存在但为黑色"可以放在同一类中处理。

上图中的黑色矩形表示黑色叔叔结点或逻辑上的NIL。可以先只看g -> p -> c的方向:在同一侧是LL或RR,需要单旋;拐弯的是LR或RL,需要双旋。

3.红黑树插入后的调整


3.1 父亲和叔叔都是红色------只变色

第一种情况为:c是红色,p是红色,g是黑色,并且u存在且为红色。

这时不能只把p变成黑色,因为u所在路径的黑高没有增加,两边会不一致。正确做法是把pu同时染黑,再把g染红。

变色以后,经过pu的路径都增加了一个黑结点,而原来的黑色g又变成红色,所以从g这一层向下看的总黑高没有变化;cp连续红的问题也被解决了。

但是这里不能马上结束,因为g变成了红色。如果g的父结点也是红色,新的连续红冲突就被传到了上层。因此代码会令cur = grandfather,把g当成新的c继续向上检查。

如果一路更新到了根,循环结束以后再统一把根染黑。这个处理只需要变色,不需要旋转,所以不管pg左边还是右边、cp哪一边,逻辑都是一样的。

3.2 叔叔为黑色或NIL------单旋加变色

u为黑色或逻辑上的NIL时,单纯变色无法同时解决连续红结点和黑高问题,还需要配合旋转。如果pc位于同一侧,就属于外侧过长的LL或RR型。

LL型中,pg的左孩子,c又是p的左孩子。此时应该g为旋转点进行右旋 ,让p成为局部新根,然后把p染黑、g染红。

RR型完全对称:pg的右孩子,c又是p的右孩子,所以g为旋转点进行左旋 ,再把p染黑、g染红。

单旋加变色完成以后,局部新根p是黑色,向下每条路径的黑高仍然不变,也不存在连续红结点。无论p上面的父结点是什么颜色,都不会与黑色的p产生冲突,因此这里可以结束调整。

3.3 叔叔为黑色或NIL------双旋加变色

如果pc不在同一侧,树形出现拐弯,一次旋转就不够了。

LR型中,pg的左孩子,cp的右孩子:

  1. 先以p为旋转点进行左旋。
  2. 再以g为旋转点进行右旋。
  3. 最终c成为局部新根,把c染黑、g染红,p不需要重新着色,仍然保持红色。

RL型与它对称,pg右边,cp左边:先对p右旋,再对g左旋,最后同样把新的局部根c染黑、g染红,p保持红色。

需要注意的是,LL、RR、LR、RL描述的是从g走到p、再走到c的方向,不是说要对pc直接做同名旋转。真正作为旋转参数的是代码里的grandfatherparent,图中也已经把旋转对象单独标出来了。

3.4 插入代码实现

把上面的几类情况合在一起,就是完整的Insert。左半边和右半边逻辑完全对称:先根据parentgrandfather的左孩子还是右孩子找到uncle,再判断叔叔颜色,最后选择变色、单旋或双旋。

cpp 复制代码
bool Insert(const std::pair<K, V>& kv)
{
	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);
	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)
		{
			Node* uncle = grandfather->_right;
			if (uncle && uncle->_col == RED) // 叔叔存在且为红
			{
				// 祖父和父亲变色
				grandfather->_col = RED;
				parent->_col = uncle->_col = BLACK;

				cur = grandfather;
				parent = cur->_parent;
			}
			else // 叔叔不存在或者为黑
			{
				// 需要变色 + 右旋
				if (parent->_left == cur)
				{
					RotateR(grandfather);
					grandfather->_col = RED;
					parent->_col = BLACK;
				}
				else // 变色 + 左右旋转
				{
					RotateL(parent);
					RotateR(grandfather);
					grandfather->_col = RED;
					cur->_col = BLACK;
				}
				break;

			}

		}
		else if (grandfather->_right == parent)
		{
			Node* uncle = grandfather->_left;
			if (uncle && uncle->_col == RED)
			{
				grandfather->_col = RED;
				parent->_col = uncle->_col = BLACK;

				cur = grandfather;
				parent = cur->_parent;
			}
			else // 叔叔不存在或者为黑
			{
				// 变色 + 左旋
				if (parent->_right == cur)
				{
					RotateL(grandfather);
					grandfather->_col = RED;
					parent->_col = BLACK;
				}
				else
				{
					RotateR(parent);
					RotateL(grandfather);
					grandfather->_col = RED;
					cur->_col = BLACK;
				}

				break;
			}
		}
		else
		{
			assert(false);
		}

	}

	// 根节点肯定是黑的,所以不管上面有没有变色都统一让
	// 根节点为黑
	_root->_col = BLACK;
	return true;

}

叔叔为红时,问题可能被传到上层,所以更新curparent继续循环;发生旋转以后,局部冲突和黑高已经恢复,可以直接break。最后无论前面是否发生过多次变色,都统一把根结点设为黑色。

4.红黑树的旋转与其他接口实现


4.1 左右旋转

红黑树的旋转结构与上一篇AVL树中的左右旋完全一样,区别只是红黑树不维护平衡因子,颜色调整由Insert根据具体情况完成,所以我这里就直接CV了之前写的代码

cpp 复制代码
// 我这里直接 CV 以前写的AVL树
// 但红黑树这里不需要维护平衡因子
void RotateR(Node* parent)
{
	Node* subL = parent->_left;
	Node* subLR = subL->_right;

	parent->_left = subLR;

	// subLR 有可能为空
	if (subLR)
		subLR->_parent = parent;

	subL->_right = parent;

	Node* pparent = parent->_parent;
	parent->_parent = subL;

	// 处理 parent 为根结点的边界情况
	if (parent == _root)
	{
		_root = subL;
		subL->_parent = nullptr;
	}
	else if (pparent->_right == parent)
	{
		pparent->_right = subL;
		subL->_parent = pparent;
	}
	else if (pparent->_left == parent)
	{
		pparent->_left = subL;
		subL->_parent = pparent;
	}
	else
	{
		assert(false);
	}
}

void RotateL(Node* parent)
{
	Node* subR = parent->_right;
	Node* subRL = subR->_left;

	parent->_right = subRL;
	if (subRL)
		subRL->_parent = parent;

	subR->_left = parent;

	Node* pparent = parent->_parent;
	parent->_parent = subR;

	if (parent == _root)
	{
		_root = subR;
		subR->_parent = nullptr;
	}
	else if (pparent->_right == parent)
	{
		pparent->_right = subR;
		subR->_parent = pparent;
	}
	else if (pparent->_left == parent)
	{
		pparent->_left = subR;
		subR->_parent = pparent;
	}
	else
	{
		assert(false);
	}
}

4.2 查找

红黑树仍然保持二叉搜索树的有序关系,所以查找不需要考虑颜色。待查key大于当前结点key就向右走,小于就向左走,找到相等key时返回对应结点。

cpp 复制代码
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;
}

由于红黑树高度为O(log N),所以Find的最坏时间复杂度也是O(log N)

4.3 拷贝和资源释放

拷贝红黑树时不仅要复制key和value,还要把每个结点的颜色一起复制,并重新建立新树内部的父指针。析构则按照后序顺序释放左右子树和当前结点。

cpp 复制代码
Node* _Copy(const Node* cur)
{
	if (cur == nullptr)
	{
		return nullptr;
	}

	Node* newNode = new Node(cur->_kv);
	newNode->_col = cur->_col;

	newNode->_left = _Copy(cur->_left);
	if (newNode->_left)
	{
		newNode->_left->_parent = newNode;
	}

	newNode->_right = _Copy(cur->_right);
	if (newNode->_right)
	{
		newNode->_right->_parent = newNode;
	}

	return newNode;
}



void _Destroy(Node* root)
{
	if (root == nullptr)
		return;

	_Destroy(root->_left);
	_Destroy(root->_right);

	delete root;
}

对应的默认构造、拷贝构造、赋值和析构如下。赋值运算符使用传值加交换的写法,形参副本在函数结束时会释放当前对象原来管理的结点。

cpp 复制代码
RBTree() = default;

~RBTree()
{
	_Destroy(_root);
}

RBTree(const RBTree& T)
{
	_root = _Copy(T._root);
}

RBTree& operator=(RBTree T)
{
	std::swap(_root, T._root);
	return *this;
}

5.红黑树测试函数

这部分的函数都比较的边缘,不是这里的重点只是为了方便测试写的函数,但大多数情况下如果写得有问题就直接报错了

cpp 复制代码
bool IsBalance() const
{
	if (_root == nullptr)
	{
		return true;
	}

	if (_root->_col == RED)
	{
		return false;
	}

	// 这里先统计其中一路的黑结点数量
	size_t refNum = 0;
	Node* cur = _root;
	while (cur)
	{
		if (cur->_col == BLACK)
		{
			refNum++;
		}
		cur = cur->_left;
	}

	return _Check(_root, 0, refNum);
	
}

bool _Check(const Node* root, size_t blackNum, const size_t refNum) const
{
	if (root == nullptr)
	{
		if (blackNum != refNum)
		{
			std::cout << "黑色结点数量不一致" << std::endl;
			return false;
		}
		return true;
	}

	if (root->_col == RED && root->_parent->_col == RED)
	{
		std::cout << "出现了连续的红结点" << std::endl;
		return false;
	}

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

	return _Check(root->_left, blackNum, refNum)
		&& _Check(root->_right, blackNum, refNum);
}

红黑树看起来情况很多,但真正需要记住的主线并不长:新结点先插红色;父亲是黑色就结束;父亲和叔叔都红就一起变黑、祖父变红并继续向上;叔叔为黑或NIL时,再根据LL、RR、LR、RL选择旋转和变色。把c、p、g、u的位置固定下来以后,代码中的每个分支都能在图里找到对应关系。


相关推荐
Code额31 分钟前
Python 连接 DeepSeek API,OpenAI
开发语言·python·ai·ai编程
-凌凌漆-39 分钟前
【freertos】Task创建(v2)
java·开发语言·算法
牛油果子哥q1 小时前
C++内存模型与深浅拷贝万字详解:栈堆静态内存布局、深浅拷贝底层差异、内存泄漏根治、拷贝崩溃踩坑、手写深拷贝实战
java·开发语言·c++
大鹏的NLP博客1 小时前
通用 Linux 嵌入式板端 C/C++ ABI 与 Glibc 依赖治理规范
linux·c++·交叉编译
Nil2081 小时前
leetcode 24两两交换链表中的节点
算法·leetcode·链表
曹牧1 小时前
C#:函数参数指定默认值
开发语言·c#
.格子衫.1 小时前
033动态规划之状态压缩DP——算法备赛
算法·动态规划
ysa0510301 小时前
c++常用自带函数用法与注意
c++·笔记·算法
AAA@峥2 小时前
Python 基础开篇:认识 Python、版本选择、环境部署与首个 HelloWorld
开发语言·python