红黑树是二叉搜索树家族中重要的一员,在 C++ STL 的 map 和 set 底层、Linux 内核的调度器、Java 的 TreeMap 等地方都能看到它的身影。它通过一套精妙的颜色规则,在频繁的插入删除中维持着近似平衡,既保证了 O(log N) 的时间复杂度,又比 AVL 树拥有更少的旋转次数。
一、什么是红黑树
红黑树本质上是一棵二叉搜索树 ,但它的每个结点都增加了一个 颜色属性 ,只能是红色或黑色。通过下面四条严格的规则,红黑树能够保证:没有任何一条从根到叶子的路径会比其他路径长出 2 倍,从而实现近似平衡。
1.1 红黑树的四条铁律
-
结点非红即黑 --- 每个结点的颜色要么是红色,要么是黑色。
-
根结点必为黑色 --- 树的根结点始终是黑色的。
-
不连续红色 --- 如果一个结点是红色的,那么它的两个孩子都必须是黑色的。也就是说,任意一条从根到叶子的路径上,不会出现连续的两个红色结点。
-
黑高相同 --- 对于任意一个结点,从它到其所有后代叶子结点(NIL 或 NULL 结点)的简单路径上,黑色结点的数量必须相同。
补充:在一些经典教材(如《算法导论》)中,会把叶子结点(NIL)也视为外部结点并强制为黑色,这主要是为了让"路径"的定义更加一致。在实际编码中,我们通常用 NULL 作为结束标志,并假设它也符合黑色规则,不影响平衡的判断。
1.2 为什么最长路径不会超过最短路径的 2 倍?
这是红黑树最核心的平衡保证。我们可以从下图中的极端情况来理解:
-
根据 规则 4 ,每条路径上的黑色结点数量相同,记作
bh(black height)。 -
根据 规则 2 和规则 3 ,红色结点不能连续出现,因此路径中最多的红色结点就是和黑色结点交替排列,即最长路径由"黑---红---黑---红......"组成,长度最多为
2 * bh。 -
最短路径则全是黑色结点,长度为
bh。
因此,任意一条路径长度 h 满足:bh <= h <= 2 * bh 。这就保证了整棵树的高度始终被控制在对数级别,从而保证了增删查改的时间复杂度都是 O(log N)。
1.3 红黑树 vs AVL 树
AVL 树通过记录每个结点的平衡因子(左右子树高度差不超过 1)来严格控制平衡,因此查询性能非常极致,但插入和删除时可能需要更多的旋转来恢复平衡。
红黑树的设计更"宽容"一些:它不追求绝对平衡,而是保证最长路径不超过最短路径的 2 倍。这使得红黑树在插入相同数量结点时,旋转次数通常比 AVL 树少,也因此更适合插入、删除操作非常频繁的场景。
二、红黑树的结构定义
在代码实现中,我们采用 key-value 结构的泛型模板,同时为每个结点增加颜色枚举以及指向父亲的 _parent 指针,方便后续调整。
cpp
// 颜色枚举
enum Colour {
RED,
BLACK
};
// 红黑树结点
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:
// 插入、查找、验证等接口
bool Insert(const pair<K, V>& kv);
Node* Find(const K& key);
bool IsBalance();
private:
Node* _root = nullptr;
// 旋转函数,与 AVL 树相同,只是不更新平衡因子
void RotateL(Node* parent);
void RotateR(Node* parent);
// 验证辅助函数
bool Check(Node* root, int blackNum, const int refNum);
};
三、红黑树的插入 ------ 核心难点
插入操作可以概括为以下几步:
-
按照二叉搜索树的规则 将新结点插入到正确位置。
-
新结点默认染成 红色。这是因为如果是黑色,一定会破坏规则 4(改变路径上的黑高),维护起来代价巨大;而插入红色结点只有可能破坏规则 3(连续红色),相对更容易修正。
-
如果父亲结点是黑色,直接结束,不需要任何调整。
-
如果父亲结点是红色(违反规则 3),则需要根据"叔叔结点"(即父结点兄弟)的颜色和状态,分三种情况处理。我们约定:
c= 当前结点(cur),p= 父结点,g= 祖父结点,u= 叔叔结点。
3.1 情况一:叔叔存在且为红色 ------ 变色就能解决
当 p 红、u 红、g 黑时,我们只需:
-
将
p和u染黑; -
将
g染红; -
将当前处理结点
c移动到g,继续往上检查。
理解 :p 和 u 变黑相当于在各自子树增加一个黑色结点,g 变红相当于维持原路径黑高不变。但 g 变红后可能与更上层的红结点冲突,因此需要循环向上更新。如果最后 g 是根,我们再强行把它染回黑色。
无论
p位于g的左边还是右边,c是p的左还是右,处理方式完全一样,只涉及变色,不需要旋转。
cpp
// 情况一:叔叔存在且为红
if (uncle && uncle->_col == RED) {
parent->_col = uncle->_col = BLACK;
grandfather->_col = RED;
// 继续向上调整
cur = grandfather;
parent = cur->_parent;
}
3.2 情况二 + 情况三:叔叔不存在或为黑色 ------ 旋转+变色
若 u 不存在或颜色为黑,单纯的变色已经无法解决连续红色问题,这时候必须借助旋转。
根据 p 和 c 的相对位置,又细分为单旋和双旋两种:
3.2.1 单旋场景(直线型)
-
p是g的左孩子,c是p的左孩子 → 对g进行 右单旋; -
p是g的右孩子,c是p的右孩子 → 对g进行 左单旋;
旋转完毕后,将 p 染黑、g 染红 。此时 p 成为新子树的根,整体黑高不变,且解决了连续红色问题,不需要再向上迭代。
cpp
// 情况二单旋:p 为 g 的左,c 为 p 的左
if (cur == parent->_left) {
RotateR(grandfather);
parent->_col = BLACK;
grandfather->_col = RED;
}
// p 为 g 的右,c 为 p 的右
if (cur == parent->_right) {
RotateL(grandfather);
parent->_col = BLACK;
grandfather->_col = RED;
}
3.2.2 双旋场景(折线型)
-
p是g的左孩子,c是p的右孩子 → 先对p左单旋,再对g右单旋; -
p是g的右孩子,c是p的左孩子 → 先对p右单旋,再对g左单旋;
旋转后,将 c 染黑、g 染红 。此时 c 变成了新子树的根,同样黑高不变,且不需要继续向上调整。
cpp
// 情况三双旋:p 为 g 的左,c 为 p 的右
else {
RotateL(parent);
RotateR(grandfather);
cur->_col = BLACK;
grandfather->_col = RED;
}
// p 为 g 的右,c 为 p 的左
else {
RotateR(parent);
RotateL(grandfather);
cur->_col = BLACK;
grandfather->_col = RED;
}
3.3 插入操作完整代码
结合以上所有情况,插入函数的伪代码框架如下:
cpp
bool Insert(const pair<K, V>& kv) {
// 1. 空树:新建黑结点作为根
if (_root == nullptr) {
_root = new Node(kv);
_root->_col = BLACK;
return true;
}
// 2. 二叉搜索树查找插入位置
Node* parent = nullptr;
Node* cur = _root;
while (cur) {
parent = cur;
if (kv.first < cur->_kv.first)
cur = cur->_left;
else if (kv.first > cur->_kv.first)
cur = cur->_right;
else
return false; // 已存在
}
// 3. 新建红色结点,挂在父结点下
cur = new Node(kv);
cur->_col = RED;
if (kv.first < parent->_kv.first)
parent->_left = cur;
else
parent->_right = cur;
cur->_parent = parent;
// 4. 调整红黑树
while (parent && parent->_col == RED) {
Node* grandfather = parent->_parent;
if (parent == grandfather->_left) {
Node* uncle = grandfather->_right;
// 情况一:叔叔红 -> 变色
if (uncle && uncle->_col == RED) {
parent->_col = uncle->_col = BLACK;
grandfather->_col = RED;
cur = grandfather;
parent = cur->_parent;
}
else { // 叔叔黑或不存在
if (cur == parent->_left) {
// 单旋右
RotateR(grandfather);
parent->_col = BLACK;
grandfather->_col = RED;
} else {
// 双旋左右
RotateL(parent);
RotateR(grandfather);
cur->_col = BLACK;
grandfather->_col = RED;
}
break; // 旋转后结构稳定,可退出
}
} else {
// 对称情况:parent 是祖父的右孩子
Node* uncle = grandfather->_left;
if (uncle && uncle->_col == RED) {
parent->_col = uncle->_col = BLACK;
grandfather->_col = RED;
cur = grandfather;
parent = cur->_parent;
} else {
if (cur == parent->_right) {
RotateL(grandfather);
parent->_col = BLACK;
grandfather->_col = RED;
} else {
RotateR(parent);
RotateL(grandfather);
cur->_col = BLACK;
grandfather->_col = RED;
}
break;
}
}
}
// 5. 强制根为黑
_root->_col = BLACK;
return true;
}
旋转函数与 AVL 树完全一致,只需要改变指针指向即可,这里不再赘述。
3.4 为什么"旋转+变色"后就可以直接退出?
因为经过单旋或双旋 后,新的子树根结点(被染黑的那个)代替了原来的 g,它的颜色一定是黑色。这样一来,新根与上层的颜色连接断然不会再出现"连续红色",整棵树的平衡已经恢复,所以可以 break,不再继续向上调整。
四、红黑树的查找
查找操作完全沿用二叉搜索树的特性,复杂度 O(log N)。
cpp
Node* Find(const K& key) {
Node* cur = _root;
while (cur) {
if (key < cur->_kv.first)
cur = cur->_left;
else if (key > cur->_kv.first)
cur = cur->_right;
else
return cur;
}
return nullptr;
}
五、红黑树的验证 ------ 你的树真的"红黑"吗?
写完插入后,我们需要一套可靠的验证工具,而不是凭感觉判断。直接套用四条规则:
-
颜色只能为红或黑 → 枚举保证了这一点。
-
根是黑色。
-
不能有连续红色结点 → 可以用前序遍历检查,反向检查父亲颜色更方便:若当前结点为红且父亲也为红,则违规。
-
每条路径黑高相同 → 先通过最左边一条路径统计出一个参考黑高
refNum,然后在前序遍历每条路径时,累计黑色结点数,走到空时对比。
cpp
bool Check(Node* root, int blackNum, const int refNum) {
if (root == nullptr) {
// 一条路径走完,比较黑高
if (blackNum != refNum) {
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; // 根非黑
// 计算最左路径的黑高作为参考
int refNum = 0;
Node* cur = _root;
while (cur) {
if (cur->_col == BLACK) refNum++;
cur = cur->_left;
}
return Check(_root, 0, refNum);
}
只要 IsBalance() 返回 true,就意味着我们的红黑树完全遵守了所有规则,平衡性自然就得到了保证。
六、红黑树的删除(了解)
红黑树的删除比插入更加复杂,涉及更多颜色的互换、兄弟结点的多重判断以及可能的二次调整。本文暂不作深入展开,感兴趣的同学可以阅读《算法导论》或《STL 源码剖析》中的相关章节。
七、总结
红黑树通过简单的颜色规则,以"不连续红""黑高相等"为约束,保证树的高度始终在 log N ~ 2log N 之间,从而获得稳定的 O(log N) 增删查改性能。它的实现核心在插入调整:
-
叔叔红色:只变色,向上迭代;
-
叔叔黑色/不存在 + 直线:单旋 + 变色,调整结束;
-
叔叔黑色/不存在 + 折线:双旋 + 变色,调整结束。
与 AVL 树相比,红黑树的平衡条件更宽松,旋转次数更少,特别适合写多读多或频繁插入删除的场景。掌握红黑树,不仅加深了对自平衡搜索树的理解,更是窥见了许多工业级数据结构的底层设计哲学。
如果你觉得这篇文章对你有帮助,欢迎点赞、收藏,也欢迎在评论区交流你的理解与困惑,我们一起进步!