在讨论红黑树之前,我们必须先回顾普通二叉搜索树(BST)的痛点。二叉搜索树满足一个基本性质:左子树所有key < 当前节点key < 右子树所有key。
如果插入顺序比较均匀,比如:8, 4, 12, 2, 6, 10, 14,树会保持相对平衡,查找效率接近O(log n)。但如果插入顺序是有序的:1, 2, 3, 4, 5, 6, 7,普通BST会退化成链表:
text
1
\
2
\
3
\
4
\
5
此时查找复杂度从理想的O(log n)骤降到O(n)。
红黑树的使命:在保持二叉搜索树有序性的同时,通过颜色规则和旋转操作,让树的高度始终保持在O(log n)级别,从而稳定支持各种操作。
一、红黑树
红黑树是一种自平衡二叉搜索树。它由两个部分组成:
| 组成部分 | 作用 |
|---|---|
| 二叉搜索树性质 | 保证中序遍历有序,支持高效查找 |
| 红黑颜色规则 | 限制树的高度,防止退化成链表 |
每个节点除了存储key/value、左右孩子指针和父节点指针外,还额外存储一个颜色标记:
cpp
enum class Color {
Red,
Black
};
template <typename K, typename V>
struct RBNode {
K key;
V value;
Color color;
RBNode* parent;
RBNode* left;
RBNode* right;
};
二、红黑树的五条性质
红黑树通过五条严格的性质来保证树的平衡性。
性质1:每个节点非红即黑
text
node.color ∈ { Red, Black }
这是最基本的定义,每个节点都必须有且只有一种颜色。
性质2:根节点是黑色
text
root.color == Black
这条规则主要是为了统一定义。即使插入时根节点被染成红色,最后也会强制改回黑色。
性质3:所有叶子NIL节点都是黑色
注意,这里的"叶子"不是普通意义上的数据节点,而是空指针位置上的哨兵节点。例如:
text
10
/ \
5 15
/ \
NIL NIL
实际实现中,大多数红黑树不会为每个空孩子单独分配节点,而是使用一个共享的黑色哨兵节点nil。
性质4:红色节点的孩子必须是黑色
树中不能出现连续两个红色节点
- ❌ 错误情况:
10(B) -> 5(R) -> 3(R) - ✅ 正确情况:
10(B) -> 5(R) -> 3(B)
这条规则从根本上限制了红色节点不能连续堆叠。
性质5:从任一节点到其所有后代NIL节点的路径上,黑色节点数量相同
这个黑色节点的数量被称为黑高(black-height)。
例如,从根节点到每个NIL节点的路径上,黑色节点的数量必须完全相同。这条性质保证了树的所有路径在黑色节点数量上是平衡的。
三、红黑树保证O(log n)
- 红节点不能连续出现(性质4)
- 每条路径上的黑节点数量相同(性质5)
- 因此,最长路径不会超过最短路径的两倍
- 所以树高最多是
2 * log2(n + 1)级别
换句话说,红黑树不是严格平衡树,它允许一定程度的不平衡。与AVL树的对比:
| 对比项 | 红黑树 | AVL树 |
|---|---|---|
| 平衡标准 | 最长路径 ≤ 2 × 最短路径 | 左右子树高度差 ≤ 1 |
| 平衡程度 | 近似平衡 | 高度平衡 |
| 查找速度 | 稍慢于AVL | 通常更快 |
| 插入删除 | 修复成本较低,平均旋转次数更少 | 旋转调整更多 |
| 工程应用 | std::map/std::set常见实现 |
查询密集型场景 |
工程上之所以更常选择红黑树,是因为它在查找、插入、删除之间取得了非常好的综合平衡。
四、红黑树的两个基本操作:旋转
红黑树调整平衡主要依靠两类操作:左旋 和右旋。
旋转的特点是:不会破坏二叉搜索树的有序性,只会改变局部结构。
4.1 左旋(Left Rotate)
假设对节点x进行左旋:
text
x y
\ / \
y => x T3
/ \ \
T2 T3 T2
注意中序关系仍然保持不变:x < T2 < y < T3。
C++伪代码实现:
cpp
void leftRotate(Node* x) {
Node* y = x->right;
// 把y的左孩子变成x的右孩子
x->right = y->left;
if (y->left != nil) {
y->left->parent = x;
}
// 把x的父节点变成y的父节点
y->parent = x->parent;
// 更新x父节点的孩子指针
if (x->parent == nil) {
root = y;
} else if (x == x->parent->left) {
x->parent->left = y;
} else {
x->parent->right = y;
}
// 把x变成y的左孩子
y->left = x;
x->parent = y;
}
4.2 右旋(Right Rotate)
右旋是左旋的镜像操作。对节点y进行右旋:
text
y x
/ / \
x => T1 y
/ \ /
T1 T2 T2
中序关系仍然保持:T1 < x < T2 < y。
五、插入操作
5.1 插入的两步流程
红黑树插入分为两个清晰的步骤:
- 第一步:按照普通BST规则插入新节点
- 第二步:修复可能被破坏的红黑性质
关键设计决策 :新插入的节点默认染成红色。
因为插入红色节点不会改变任何路径上的黑色节点数量,因此不会破坏性质5(黑高一致)。如果插入黑色节点,会直接导致某条路径黑高加1,修复起来会复杂得多。
5.2 插入修复
插入红色节点后,唯一可能被破坏的性质是性质4:不能有连续红色节点。也就是说,当新节点的父节点也是红色时,我们需要进行修复。
插入修复时,我们关注四个角色:
N:当前插入的新节点P:父节点(Parent)G:祖父节点(Grandparent)U:叔叔节点(Uncle)
5.3 插入修复的三种Case
Case 1:叔叔节点是红色
text
G(B) G(R)
/ \ / \
P(R) U(R) => P(B) U(B)
/ /
N(R) N(R)
处理方法:
- 父节点P变黑
- 叔叔节点U变黑
- 祖父节点G变红
- 把G当成新的N,继续向上修复
本质:通过重新染色,把红色冲突向上传递。这样做不会改变任何路径的黑高。
Case 2:叔叔节点是黑色,且是内侧结构
以内侧左-右结构为例:
text
G(B) G(B)
/ \ / \
P(R) U(B) => N(R) U(B)
\ /
N(R) P(R)
处理方法:先对父节点P进行左旋,将其转换为外侧结构,然后进入Case 3。
本质:先旋转成一条直线,再统一处理。
Case 3:叔叔节点是黑色,且是外侧结构
外侧左-左结构:
text
G(B) P(B)
/ \ / \
P(R) U(B) => N(R) G(R)
/ \
N(R) U(B)
处理方法:
- 父节点P变黑
- 祖父节点G变红
- 对祖父节点G进行右旋
右-右结构是镜像处理:对G进行左旋。
本质:通过一次旋转和简单的颜色变换,彻底解决连续红色问题,同时保持黑高不变。
5.4 插入修复总结
插入修复可以压缩成一句话:
父红叔红:变色上推;父红叔黑:旋转加变色
| 情况 | 操作 |
|---|---|
| 父节点是黑色 | 不需要任何修复 |
| 父红,叔红 | 父叔变黑,祖父变红,继续向上 |
| 父红,叔黑,内侧 | 先旋转父节点,转成外侧 |
| 父红,叔黑,外侧 | 父变黑,祖父变红,旋转祖父 |
最后一步:无论如何,最终都要把根节点染成黑色。
六、删除操作
6.1 删除的三步流程
红黑树删除分为三个步骤:
- 第一步:按照普通BST规则删除节点
- 第二步:记录被真正删除节点的颜色
- 第三步:如果删除的是黑色节点,进行删除修复
BST删除本身有三种情况:
- 删除节点没有孩子:直接删除
- 删除节点只有一个孩子:用孩子替代它
- 删除节点有两个孩子:找后继节点替代,再删除后继
关键点 :只有当被真正删除的物理节点是黑色时,才需要进行修复。
真正被物理删掉的那个节点,才是判断删红还是删黑的依据;原本要删的节点只是逻辑值被覆盖,本身没有被从树里摘除,不算 "被真正删除节点" 。
当待删节点有两个子节点时:后继节点才是被真正物理删除的节点
6.2 删除修复
删除比插入复杂得多,因为删除黑色节点可能破坏性质5:黑高一致。
例如,删除一个黑色叶子节点:
text
10(B) 10(B)
/ \ => \
5(B) 15(B) 15(B)
左边路径少了一个黑色节点,黑高不一致。
删除修复的核心是处理双重黑 (double black)问题。这不是节点真的有两个颜色,而是表示:某条路径少了一个黑色,需要从兄弟、父亲或子树中"借"一个黑色,或者把问题向上传递。
6.3 删除修复的四种Case
假设当前需要修复的节点是x,它的兄弟节点是w。我们以x是父节点左孩子为例,右侧情况完全镜像。
Case 1:兄弟节点w是红色
text
P(B) w(B)
/ \ / \
x w(R) => P(R) wr(B)
/ \ /
wl(B) wr(B) x wl(B)
处理方法:
- w变黑
- P变红
- 对P左旋
本质:把红色兄弟转换成黑色兄弟情况,转入后续case处理。
Case 2:兄弟w是黑色,且w的两个孩子都是黑色
text
P(?) P(?)
/ \ / \
x w(B) => x w(R)
/ \
wl(B) wr(B)
处理方法:
- w变红
- x上移到P,继续向上修复
本质:兄弟这边也"少交"一个黑色,把黑高亏损问题向父节点传递。如果P原来是红色,问题可以在父节点处解决;如果P是黑色,可能继续向上。
Case 3:兄弟w是黑色,w的近侄子是红色,远侄子是黑色
text
P P
/ \ / \
x w(B) => x wl(B)
/ \
wl(R) w(R)
处理方法:
- wl变黑
- w变红
- 对w右旋
本质:把结构转换成Case 4。
Case 4:兄弟w是黑色,w的远侄子是红色
text
P(?) w(原P颜色)
/ \ / \
x w(B) => P(B) wr(B)
\ /
wr(R) x
处理方法:
- w变成P的颜色
- P变黑
- wr变黑
- 对P左旋
- 修复结束
本质:这是删除修复中真正"还债"的情况。通过重新分配颜色和旋转,彻底弥补了黑高的亏损。
6.4 删除修复总结
| 情况 | 本质 |
|---|---|
| 兄弟是红色 | 先旋转,换成黑兄弟 |
| 兄弟黑,两个侄子黑 | 兄弟变红,黑高亏损向上传 |
| 兄弟黑,近侄红,远侄黑 | 旋转兄弟,转成远侄红 |
| 兄弟黑,远侄红 | 旋转父节点,重新分配黑色,修复完成 |
七、红黑树在C++ STL中的应用
C++标准库中,以下容器通常基于红黑树或类似的平衡搜索树实现:
cpp
std::map
std::multimap
std::set
std::multiset
它们的共同特点是:
- 元素按照key有序存储
- 查找、插入、删除都是O(log n)
- 支持高效的范围查询
7.1 std::map vs std::unordered_map
| 容器 | 底层思想 | 是否有序 | 平均查找 | 最坏查找 |
|---|---|---|---|---|
std::map |
红黑树 | 有序 | O(log n) | O(log n) |
std::unordered_map |
哈希表 | 无序 | O(1) | O(n) |
7.2 如何选择?
使用std::map的场景:
- 需要有序遍历
- 需要
lower_bound/upper_bound操作 - 需要范围查询
- 需要稳定的最坏时间复杂度
使用std::unordered_map的场景:
- 只关心key->value的快速查找
- 不需要有序性
- 数据规模较大
- 哈希函数质量可靠
7.3 红黑树为什么适合map/set?
红黑树天然支持map/set所需的特性:
- 有序遍历:中序遍历就是升序
- 范围查询 :可以很方便地找到
[a, b]范围内的所有元素 - 高效的插入删除:不需要移动大量已有元素
- 良好的迭代器稳定性:插入通常不会让已有迭代器失效;删除只会让指向被删除元素的迭代器失效
八、工程实现的注意事项
8.1 哨兵节点nil的使用
一个工业级红黑树实现通常会使用一个共享的黑色哨兵节点:
cpp
nil->color = Color::Black;
nil->left = nil;
nil->right = nil;
nil->parent = nil;
这样做的好处:
- 不用到处判断
nullptr - 删除修复逻辑更统一
- 所有叶子NIL都是黑色,符合性质3
8.2 容易出错的地方
- 忘记维护parent指针:旋转时不只是改左右孩子,还要改父节点关系
- 忘记更新root:如果旋转发生在根节点附近,根节点可能改变
- nil和nullptr混用:两种写法都可以,但不要混着来
- 删除修复时访问空指针 :如果没有哨兵节点,访问
w->left->color可能崩溃 - 插入后忘记把根节点染黑:这是一个常见的低级错误
九、学习建议与面试要点
9.1 学习
- 普通BST的查找、插入、删除
- 左旋和右旋操作
- 红黑树的五条性质
- 插入修复逻辑
- 删除修复逻辑
- STL中map/set的工程应用
- 自己实现一个简化版红黑树
红黑性质约束树高 → 旋转保持BST有序 → 变色维护黑高 → 插入主要处理连续红色 → 删除主要处理黑高亏损
9.2 面试高频问题
Q1:为什么新插入节点一般是红色?
因为插入红色节点不会改变任何路径上的黑色节点数量,更容易维护黑高。如果插入黑色节点,会直接让某条路径黑高加1,修复更麻烦。
Q2:旋转会破坏二叉搜索树性质吗?
不会。旋转只改变局部父子关系,不改变中序遍历顺序。
Q3:红黑树为什么不是严格平衡?
因为它只要求黑高一致,并禁止连续红色。它不要求左右子树高度差小于等于1。这种近似平衡的设计在工程上更实用。
Q4:红黑树和哈希表怎么选?
需要有序性、范围查询、稳定最坏复杂度,用红黑树。只需要快速key-value查询,不关心顺序,用哈希表。
总结
最后用一个形象的比喻来总结:
- 普通BST像一个随插入顺序生长的树,可能长歪
- AVL树像严格修剪的盆景,查找很快,但维护成本较高
- 红黑树像工程上更折中的结构:不要求绝对平衡,但绝不允许严重倾斜
红黑树牺牲了一点查询的极致性能,换来更好的插入删除综合表现。这正是它在C++ STL中被广泛使用的原因。
理解红黑树不是为了在工作中重新实现它(除非是写标准库),而是为了理解它的设计思想和性能特性,从而在实际开发中做出更明智的技术选择。