前言:上一篇文章我们实现了AVL树,所以关于本篇的旋转部分我就不做解释了,在我上一篇文章【数据结构】自平衡二叉搜索树各种旋转算法原理解析及AVL树的C++实现
红黑树刚开始看确实比AVL树抽象一些,特别是插入以后同时出现c、p、g、u几个结点时,很容易把谁旋转、谁变色弄混。我这里还是先把规则和结点关系固定下来,再分情况看图,最后回到代码就会清楚很多。本篇完成的是插入版本,红黑树删除不在这里。
1.红黑树的概念
1.1 红黑树的规则
红黑树首先是一棵二叉搜索树,所以左子树中的key小于根,右子树中的key大于根,中序遍历仍然是有序的。它在这个基础上给每个结点增加红、黑两种颜色,并满足下面这些规则:
- 每个结点不是红色就是黑色。
- 根结点必须是黑色。
- 所有逻辑上的叶子结点
NIL都是黑色。 - 红色结点的孩子必须是黑色,也就是从根向下的路径中不能出现两个连续的红色结点。
- 从任意一个结点出发,到它后代所有
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:
u存在并且为红色,只需要变色,再把问题向上交给g。u不存在,或者存在但为黑色,需要旋转加变色。
u == nullptr时,它就是逻辑上的黑色NIL,所以"不存在"和"存在但为黑色"可以放在同一类中处理。

上图中的黑色矩形表示黑色叔叔结点或逻辑上的NIL。可以先只看g -> p -> c的方向:在同一侧是LL或RR,需要单旋;拐弯的是LR或RL,需要双旋。
3.红黑树插入后的调整
3.1 父亲和叔叔都是红色------只变色
第一种情况为:c是红色,p是红色,g是黑色,并且u存在且为红色。
这时不能只把p变成黑色,因为u所在路径的黑高没有增加,两边会不一致。正确做法是把p和u同时染黑,再把g染红。

变色以后,经过p和u的路径都增加了一个黑结点,而原来的黑色g又变成红色,所以从g这一层向下看的总黑高没有变化;c和p连续红的问题也被解决了。
但是这里不能马上结束,因为g变成了红色。如果g的父结点也是红色,新的连续红冲突就被传到了上层。因此代码会令cur = grandfather,把g当成新的c继续向上检查。

如果一路更新到了根,循环结束以后再统一把根染黑。这个处理只需要变色,不需要旋转,所以不管p在g左边还是右边、c在p哪一边,逻辑都是一样的。
3.2 叔叔为黑色或NIL------单旋加变色
当u为黑色或逻辑上的NIL时,单纯变色无法同时解决连续红结点和黑高问题,还需要配合旋转。如果p和c位于同一侧,就属于外侧过长的LL或RR型。
LL型中,p是g的左孩子,c又是p的左孩子。此时应该以g为旋转点进行右旋 ,让p成为局部新根,然后把p染黑、g染红。

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

单旋加变色完成以后,局部新根p是黑色,向下每条路径的黑高仍然不变,也不存在连续红结点。无论p上面的父结点是什么颜色,都不会与黑色的p产生冲突,因此这里可以结束调整。
3.3 叔叔为黑色或NIL------双旋加变色
如果p和c不在同一侧,树形出现拐弯,一次旋转就不够了。
LR型中,p是g的左孩子,c是p的右孩子:
- 先以
p为旋转点进行左旋。 - 再以
g为旋转点进行右旋。 - 最终
c成为局部新根,把c染黑、g染红,p不需要重新着色,仍然保持红色。

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

需要注意的是,LL、RR、LR、RL描述的是从g走到p、再走到c的方向,不是说要对p或c直接做同名旋转。真正作为旋转参数的是代码里的grandfather或parent,图中也已经把旋转对象单独标出来了。
3.4 插入代码实现
把上面的几类情况合在一起,就是完整的Insert。左半边和右半边逻辑完全对称:先根据parent是grandfather的左孩子还是右孩子找到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;
}
叔叔为红时,问题可能被传到上层,所以更新cur和parent继续循环;发生旋转以后,局部冲突和黑高已经恢复,可以直接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的位置固定下来以后,代码中的每个分支都能在图里找到对应关系。
完