文章目录
-
- 前言
- [一、AVL 的工程短板:严格平衡的代价](#一、AVL 的工程短板:严格平衡的代价)
- 二、五大性质:红黑树的宪法
- 三、黑高:红黑树的平衡度量
- [四、哨兵 NIL 与结点结构](#四、哨兵 NIL 与结点结构)
- [五、左旋右旋:代码和 AVL 一模一样,目的不同](#五、左旋右旋:代码和 AVL 一模一样,目的不同)
- 六、插入为什么默认红色
- 七、插入修复:一条循环,两类分支
- 八、删除修复:只抓主干,不强背六种
- 九、难点拆解
-
- [9.1 为什么红黑树不维护高度](#9.1 为什么红黑树不维护高度)
- [9.2 旋转代码一样,为什么目的不同](#9.2 旋转代码一样,为什么目的不同)
- [9.3 插入红色 vs 插入黑色的权衡](#9.3 插入红色 vs 插入黑色的权衡)
- [9.4 哨兵 NIL 到底省了什么](#9.4 哨兵 NIL 到底省了什么)
- 十、易踩坑汇总
- 十一、闭环复盘
- 十二、笔试简答背诵精简版
前言
上一篇把 AVL 树从头到尾手写了一遍,结论落在最后一张对比表上:AVL 严格平衡、查找略快,但增删时旋转多;红黑树用更少的旋转换稳定性能,成了 STL set、map 的底层。这篇就顺着那句话,把红黑树彻底挖开。
红黑树(Red-Black Tree)是一棵带颜色的自平衡二叉搜索树 ,每个节点涂红或涂黑,靠一套"着色 + 旋转 + 变色"的规则,让树在增删之后保持近似平衡。它不追求 AVL 那种"左右子树高度差不超过 1"的严格,只保证"最长路径不超过最短路径的两倍",换来的是增删时更少的旋转次数。
一、AVL 的工程短板:严格平衡的代价
| 先把上一篇的对比结论展开。AVL 的严格平衡(任意节点 | BF | ≤ 1)是优点,也是包袱: |
- 查找确实快:树高被压到约 1.44×log2(N),路径短、比较少;
- 增删确实累 :插入最多 2 次旋转能收尾,但删除最多 O(logN) 次旋转,而且每次旋转都要精确维护高度和平衡因子,一笔一笔都是开销。
工程里"增删"和"查找"常常一样频繁,AVL 那套严格账本在频繁增删下就成了负担。红黑树的设计动机一句话能说清:用"颜色"这种更宽松的约束,换"少旋转" ------它允许树偶尔长得不那么板正,只要最长路径不超过最短路径两倍,查找依然是 O(logN),但增删时旋转大幅减少。

二、五大性质:红黑树的宪法
红黑树的所有行为都从这五条性质推出来,笔试高频:
- 每个节点不是红色就是黑色(没有第三种颜色);
- 根节点必须是黑色;
- 红色节点的两个孩子必须是黑色(不能出现连续红);
- 从任一节点到其每个叶子(NIL)的路径上,黑色节点数相同;
- 每个叶子(NIL)都是黑色。
五条里最关键的是 3 和 4:性质 3 管"局部"------红色不能挨着红色;性质 4 管"全局"------黑高处处相等。一松一紧,构成了红黑树平衡的全部约束。
性质 5 里提到的"叶子"是哨兵 NIL 结点,不是普通意义上的空指针。
连续红是最容易踩的坑:一棵树其他地方都对,只要有一处红节点下面又冒出红节点,性质 3 立刻崩。

三、黑高:红黑树的平衡度量
AVL 用平衡因子 BF 度量平衡,红黑树用黑高(Black Height)。
黑高:从某节点到其任一叶子(NIL)路径上,经过的黑色节点数量(不含 NIL 本身)。
性质 4 说"每条路径黑高相同",所以红黑树的黑高是一个确定的数------随便挑一条从根到 NIL 的路径,数黑节点,数出来的就是整棵树的黑高。红色节点不计入黑高,这也是"红节点是短期失衡"的直观含义:红色不贡献高度约束,只贡献"不能连续"的局部约束。
红黑树"近似平衡"的严格表述,就是从黑高推出来的:
最长路径长度 ≤ 2 × 最短路径长度(最坏情况红黑交替)。
因为黑高固定,最长的路径是"黑红黑红"交替(红色节点数 ≤ 黑色节点数),最短是"全黑"。长度最多差一倍,于是树高被压在 2×log2(N) 以内,查找依然是 O(logN)。
建树 + 黑高 + 校验一条龙的演示:
cpp
//建树、中序、黑高、树高、五大性质校验
#include <iostream>
#include "rbtree.h"
namespace byte
{
void demo()
{
RBTree<int> t;
int arr[] = { 10, 20, 30, 15, 25, 5, 1, 40, 35, 50, 28, 8 };
for (size_t i = 0; i < sizeof(arr) / sizeof(int); ++i)
t.insert(arr[i]);
std::cout << "中序遍历: ";
t.inorder();
std::cout << "树高 = " << t.height() << std::endl;
std::cout << "黑高 = " << t.blackHeight() << std::endl;
std::cout << "红色节点数 = " << t.redCount() << std::endl;
std::cout << "根是黑: " << (t.rootIsBlack() ? "是" : "否") << std::endl;
std::cout << "五大性质校验: " << (t.isValid() ? "通过(根黑、无连续红、黑高一致)" : "失败") << std::endl;
// 重复插入被拒
std::cout << "重复插入 30: " << (t.insert(30) ? "成功" : "被拒(key 唯一)") << std::endl;
std::cout << "查找 35: " << (t.find(35) ? "找到" : "没找到")
<< ",查找 100: " << (t.find(100) ? "找到" : "没找到") << std::endl;
}
}
int main()
{
byte::demo();
return 0;
}
运行结果:
text
中序遍历: 1 5 8 10 15 20 25 28 30 35 40 50
树高 = 4
黑高 = 2
红色节点数 = 7
根是黑: 是
五大性质校验: 通过(根黑、无连续红、黑高一致)
重复插入 30: 被拒(key 唯一)
查找 35: 找到,查找 100: 没找到
这组输出里几个数值得盯着看:12 个节点树高 4、黑高 2,红色节点 7 个------红节点数超过一半,说明"近似平衡"下树确实允许红色扎堆,只要不连续、黑高守恒就行。
四、哨兵 NIL 与结点结构
红黑树有个选择:叶子用普通空指针 NULL ,还是用一个共享的哨兵 NIL 结点。哨兵方案更省心,原因很实在:
- 旋转、变色时到处都是"判空",用 NULL 得写一堆
if (x != NULL); - 换成哨兵,叶子永远指向一个真实存在的黑色结点 ,判断"是不是空"变成
x == _nil,代码清爽; - 哨兵本身是黑色的,天然满足性质 5,不需要单独处理叶子颜色。
哨兵是一个共享结点,整棵树所有"空位置"都指向它,不产生额外开销。
完整实现如下:
cpp
// 哨兵 NIL 方案
// 聚焦插入 + 插入修复
#include <iostream>
namespace byte
{
enum Color { RED, BLACK };
template <class K>
struct RBNode
{
K _key;
Color _col;
RBNode* _left;
RBNode* _right;
RBNode* _parent;
// nil 参数:哨兵节点的四个指针都指向自身,普通节点四个指针都指向哨兵
RBNode(const K& key, Color c, RBNode* nil)
: _key(key), _col(c), _left(nil), _right(nil), _parent(nil) {}
};
template <class K>
class RBTree
{
public:
typedef RBNode<K> Node;
RBTree()
{
// 哨兵 NIL:一个共享的黑色空节点,叶子与根的父指针都指向它
_nil = new Node(K(), BLACK, 0);
_nil->_left = _nil->_right = _nil->_parent = _nil;
_root = _nil;
}
~RBTree() { destroy(_root); delete _nil; }
bool insert(const K& key)
{
if (_root == _nil)
{
_root = new Node(key, BLACK, _nil); // 根必须是黑的
return true;
}
Node* parent = _nil;
Node* cur = _root;
while (cur != _nil)
{
parent = cur;
if (key < cur->_key) cur = cur->_left;
else if (cur->_key < key) cur = cur->_right;
else return false; // 重复 key 拒绝
}
Node* z = new Node(key, RED, _nil); // 新节点默认红色
z->_parent = parent;
if (key < parent->_key) parent->_left = z;
else parent->_right = z;
insertFixup(z);
return true;
}
// 黑高:从根到任意哨兵路径上的黑色节点数(根算在内,哨兵不算)
int blackHeight() const { return blackHeight(_root); }
// 树高(红色节点也算层数)
int height() const { return height(_root); }
bool find(const K& key) const
{
Node* cur = _root;
while (cur != _nil)
{
if (key < cur->_key) cur = cur->_left;
else if (cur->_key < key) cur = cur->_right;
else return true;
}
return false;
}
void inorder() const { inorder(_root); std::cout << std::endl; }
// 校验五大性质:返回 true 表示整棵树合法
// 1 每个节点非红即黑(enum 保证) 2 根黑 3 无连续红 4 每条路径黑高一致
bool isValid() const
{
if (_root == _nil) return true;
if (_root->_col != BLACK) return false; // 性质2
int expect = -1;
return check(_root, 0, expect);
}
// 调试用:返回根的颜色(观察插入修复后根始终为黑)
bool rootIsBlack() const { return _root == _nil || _root->_col == BLACK; }
// 调试用:统计树中红色节点个数(观察"红不过半"的直观现象)
int redCount() const { return redCount(_root); }
private:
Node* _root;
Node* _nil;
static bool isRed(Node* n) { return n && n->_col == RED; }
void leftRotate(Node* x)
{
Node* y = x->_right;
x->_right = y->_left;
if (y->_left != _nil) y->_left->_parent = x;
y->_parent = x->_parent;
if (x->_parent == _nil) _root = y;
else if (x == x->_parent->_left) x->_parent->_left = y;
else x->_parent->_right = y;
y->_left = x;
x->_parent = y;
}
void rightRotate(Node* x)
{
Node* y = x->_left;
x->_left = y->_right;
if (y->_right != _nil) y->_right->_parent = x;
y->_parent = x->_parent;
if (x->_parent == _nil) _root = y;
else if (x == x->_parent->_right) x->_parent->_right = y;
else x->_parent->_left = y;
y->_right = x;
x->_parent = y;
}
void insertFixup(Node* z)
{
while (isRed(z->_parent))
{
Node* gp = z->_parent->_parent;
if (z->_parent == gp->_left)
{
Node* uncle = gp->_right;
if (isRed(uncle)) // 情形一:叔父红,仅变色
{
z->_parent->_col = BLACK;
uncle->_col = BLACK;
gp->_col = RED;
z = gp; // 继续向上处理
}
else // 叔父黑(或哨兵)
{
if (z == z->_parent->_right) // 情形二:先左旋成 LL
{
z = z->_parent;
leftRotate(z);
}
z->_parent->_col = BLACK; // 情形三:右旋 + 变色
gp->_col = RED;
rightRotate(gp);
}
}
else // 父在右,镜像
{
Node* uncle = gp->_left;
if (isRed(uncle))
{
z->_parent->_col = BLACK;
uncle->_col = BLACK;
gp->_col = RED;
z = gp;
}
else
{
if (z == z->_parent->_left)
{
z = z->_parent;
rightRotate(z);
}
z->_parent->_col = BLACK;
gp->_col = RED;
leftRotate(gp);
}
}
}
_root->_col = BLACK; // 性质2兜底
}
int blackHeight(Node* n) const
{
if (n == _nil) return 0;
int self = (n->_col == BLACK) ? 1 : 0;
int l = blackHeight(n->_left);
int r = blackHeight(n->_right);
return (l > r ? l : r) + self;
}
int height(Node* n) const
{
if (n == _nil) return 0;
int l = height(n->_left), r = height(n->_right);
return (l > r ? l : r) + 1;
}
// 递归校验:黑高一致性 + 无连续红
bool check(Node* n, int blackCnt, int& expect) const
{
if (n == _nil)
{
if (expect == -1) expect = blackCnt;
return blackCnt == expect;
}
if (n->_col == RED)
{
if (isRed(n->_parent)) return false; // 连续红
}
else
{
++blackCnt; // 黑节点计数
}
return check(n->_left, blackCnt, expect) && check(n->_right, blackCnt, expect);
}
int redCount(Node* n) const
{
if (n == _nil) return 0;
return (n->_col == RED ? 1 : 0) + redCount(n->_left) + redCount(n->_right);
}
void destroy(Node* n)
{
if (n == _nil) return;
destroy(n->_left);
destroy(n->_right);
delete n;
}
void inorder(Node* n) const
{
if (n == _nil) return;
inorder(n->_left);
std::cout << n->_key << ' ';
inorder(n->_right);
}
};
}
#endif
结点结构比 AVL 多两个东西,少一个东西:
- 多了
_parent父指针:变色、旋转要频繁往上走,没有父指针得靠递归回溯,麻烦; - 多了
_col颜色:RED 或 BLACK; - 少了
_height高度变量:红黑树不维护高度、不维护平衡因子,这是和 AVL 最本质的区别。
_nil 的初始化有个细节值得留意:哨兵结点自身的 _left、_right、_parent 都指向自己,这样任何"走到哨兵再往下"的操作都不会踩空。
五、左旋右旋:代码和 AVL 一模一样,目的不同
红黑树也要旋转,旋转代码和 AVL 篇手写的那两套完全一致:三处指针重连 + 一棵子树过继。
cpp
void leftRotate(Node* x)
{
Node* y = x->_right;
x->_right = y->_left;
if (y->_left != _nil) y->_left->_parent = x;
y->_parent = x->_parent;
if (x->_parent == _nil) _root = y;
else if (x == x->_parent->_left) x->_parent->_left = y;
else x->_parent->_right = y;
y->_left = x;
x->_parent = y;
}
rightRotate 是它的镜像。代码复用,但目的变了:
- AVL 旋转是为了把 |BF| 从 2 修回 1 以内,修的是"高度差";
- 红黑树旋转是为了把连续红"掰"成可以变色的形状,修的是"颜色布局"。
所以红黑树旋转不更新任何高度,旋转完直接进入变色逻辑。这个"复用旋转、剥离高度"的设计,是理解红黑树的关键。

六、插入为什么默认红色
新节点默认涂红色,是红黑树插入修复能"便宜"的根本原因。做个对比就懂了:
- 插红色 :最多违反性质 3(连续红),而且只在"父节点也是红"时才违反,影响是局部的;修一个连续红,变色或就近旋转就能收口。
- 插黑色 :直接违反性质 4(黑高不一致),影响是全局的------新节点所在路径凭空多了一个黑,整条路径的黑高都变了,得把整棵树的黑高重新对齐,代价大得多。
所以默认红色是"先拣软柿子捏":红节点造成的违规局部、易修;黑节点造成的违规全局、难修。插入后的修复流程,就是把"可能出现的连续红"逐级往上消化,最后把根强制涂黑兜底。
插入的落位还是 BST 规则,落位后接一段 insertFixup:
cpp
Node* z = new Node(key, RED, _nil); // 新节点默认红色
z->_parent = parent;
if (key < parent->_key) parent->_left = z;
else parent->_right = z;
insertFixup(z); // 修复可能出现的连续红
七、插入修复:一条循环,两类分支
插入修复的主干是个 while:只要父节点还是红色(说明出现了连续红),就继续修。每一轮按"父节点是祖父的左孩子还是右孩子"分左右镜像,每边再按"叔父红不红"分两类。
cpp
void insertFixup(Node* z)
{
while (isRed(z->_parent))
{
Node* gp = z->_parent->_parent;
if (z->_parent == gp->_left)
{
Node* uncle = gp->_right;
if (isRed(uncle)) // 情形一:叔父红,仅变色
{
z->_parent->_col = BLACK;
uncle->_col = BLACK;
gp->_col = RED;
z = gp; // 继续向上处理
}
else // 叔父黑(或哨兵)
{
if (z == z->_parent->_right) // 情形二:先左旋成 LL
{
z = z->_parent;
leftRotate(z);
}
z->_parent->_col = BLACK; // 情形三:右旋 + 变色
gp->_col = RED;
rightRotate(gp);
}
}
else // 父在右,镜像
{
// ... 与上面完全对称
}
}
_root->_col = BLACK; // 性质2兜底
}
两类分支各抓要害:
情形一:叔父红,仅变色。 父和叔都变黑,祖父变红,然后把祖父当成"新的待处理节点"继续向上。这一步不旋转,是代价最小的修复。变色后祖父可能又和它的父节点撞色,所以循环继续。
情形二/三:叔父黑(或哨兵),旋转 + 变色。 叔父黑时不能靠变色消红,得旋转。如果新节点是"内侧"(父的右孩子),先旋转父掰成 LL 直线,再整体旋转祖父、变色。这一支对应 AVL 里的 LL/LR(以及镜像的 RR/RL)旋转,只是旋转完不更新高度。

两种典型路径的实测输出:
text
== 场景一:叔父红,仅变色 ==
插 10,5,15: 树高 2,校验 OK
插 3(触发变色): 树高 3,黑高 2,校验 OK
== 场景二:叔父黑,旋转 ==
插 1,2,3: 树高 2,中序 1 2 3
校验 OK,根黑 是
== 场景三:有序插入 10..50,黑高演化 ==
插 10: 树高 1,黑高 1,校验 OK
插 20: 树高 2,黑高 1,校验 OK
插 30: 树高 2,黑高 1,校验 OK
插 40: 树高 3,黑高 2,校验 OK
插 50: 树高 3,黑高 2,校验 OK
注意场景三的黑高演化:插 10、20、30 时黑高一直是 1,插到 40 才抬到 2,插 50 保持 2------黑高不是每插一个就涨,而是规模翻倍时才涨一层,这正是"近似平衡 + 黑高守恒"的直观体现。
八、删除修复:只抓主干,不强背六种
删除是红黑树公认最难的环节,比插入复杂得多。但正确方法是抓主干思路,不背六种细分场景------笔试手写删除全套的概率极低,考的是"删黑节点会怎样、怎么补"的理解。
主干三句话:
- 删红节点不破坏平衡:删一个红节点,任何路径黑高都不变,直接删完走人;
- 删黑节点破坏黑高:被删路径黑高减一,性质 4 崩,得"补黑";
- 补黑的套路 :把被删黑节点的"替代者"标记为"双重黑",再按兄弟节点是红是黑分情况,用旋转 + 变色把多出来的黑匀出去,最多 3 次旋转收尾。

对比表里最值得记的两个数:插入红黑树和 AVL 都是最多 2 次旋转,但删除红黑树最多 3 次、AVL 最多 O(logN) 次。这一个数字差,就是红黑树当选 STL 底层的原因。
九、难点拆解
9.1 为什么红黑树不维护高度
AVL 靠 _height 和 BF 精打细算,红黑树直接不要这套。原因在约束的不同:AVL 的平衡是"高度差"这个连续量 ,必须精确跟踪;红黑树的平衡是"颜色"这个离散量,只有红黑两种状态,不需要高度也能判断平衡。少一个字段、少一步更新,增删就更轻。
9.2 旋转代码一样,为什么目的不同
这是最容易被问懵的点。旋转的机械动作 (指针重连)两棵树完全一样,但触发条件 和旋转后做什么不同:AVL 旋转前要先更新高度、旋转后算 BF;红黑树旋转是为了改形状,旋转后直接进变色。抓"动作同、语义异"就能理清。
9.3 插入红色 vs 插入黑色的权衡
本质是"局部违规 vs 全局违规"的取舍。红节点违反性质 3(局部),修起来就近;黑节点违反性质 4(全局),修起来要动整条路径。默认红,是把风险压在代价小的一侧。
9.4 哨兵 NIL 到底省了什么
省的是"判空"。用 NULL,旋转和变色每个节点都要 if (x != NULL);用哨兵,叶子永远指向真实黑节点,判断变成 == _nil。C++98 没有 nullptr,哨兵还顺带绕开了 NULL 的类型歧义。
十、易踩坑汇总
| 序号 | 坑点 | 说明 |
|---|---|---|
| 1 | 五条性质背串 | 性质 3(连续红)和性质 4(黑高)最常混,一个局部一个全局 |
| 2 | 黑高把红节点算进去 | 黑高只数黑节点,红节点不计入 |
| 3 | 漏了根必须黑 | 插入修复最后要把根强制涂黑,忘了性质 2 就崩 |
| 4 | 新节点默认涂黑 | 默认黑直接破坏黑高,全局修复代价大 |
| 5 | 哨兵结点没初始化好 | 哨兵自己的三个指针必须指自己,否则走到叶子就踩空 |
| 6 | 旋转后更新高度 | 红黑树旋转不更新高度,照 AVL 的习惯加 updateHeight 反而错 |
| 7 | 叔父红/黑分支写反 | 叔父红变色、叔父黑旋转,写反会导致连续红修不掉 |
| 8 | 父节点是左/右孩子镜像漏写 | insertFixup 左右镜像对称,漏一边插入修复只对一半数据生效 |
| 9 | 删黑节点不补黑 | 删除只想着"摘节点",忘了黑高守恒,树悄悄失衡 |
| 10 | 把红黑树当 AVL | 红黑树没有 BF、没有 height,别套 AVL 的账本 |
| 11 | 忘记 set/map 底层是红黑树 | 高频考点:STL 有序关联容器底层是红黑树,不是 AVL |
| 12 | 忽略"近似平衡"的含义 | 红黑树允许最长路径 ≤ 2×最短路径,不是绝对平衡 |
十一、闭环复盘
红黑树一整篇,落成六条:
第一,红黑树用"颜色"换"少旋转"。 AVL 严格平衡、增删旋转多;红黑树近似平衡、增删旋转少。这是选型的分水岭。
第二,五大性质是宪法,3 和 4 是核心。 性质 3 管局部(无连续红),性质 4 管全局(黑高一致),一松一紧构成全部约束。
第三,黑高是度量,红节点不计入。 黑高守恒推出"最长路径 ≤ 2×最短路径",于是查找稳定 O(logN)。
第四,哨兵 NIL 省判空。 叶子永远指向真实黑节点,旋转变色不用满屏判 NULL。
第五,旋转复用、剥离高度。 旋转动作和 AVL 一模一样,但红黑树不维护 BF、不维护 height,旋转只为改形状方便变色。
第六,插入默认红、修复分两类。 叔父红仅变色,叔父黑旋转;插入最多 2 次旋转。删除更复杂但最多 3 次旋转,抓"删黑补黑"主干即可。
十二、笔试简答背诵精简版
- 五大性质:非红即黑;根黑;无连续红;黑高一致;叶子 NIL 黑。
- 黑高定义:路径上黑色节点数,红节点不计入。
- 插入默认红色原因:红色只违反性质 3(局部易修),黑色违反性质 4(全局难修)。
- 插入修复两类:叔父红仅变色;叔父黑旋转 + 变色。
- 插入最多旋转:2 次。
- 删除最多旋转:3 次(AVL 删除是 O(logN) 次)。
- 红黑树 vs AVL:AVL 更平衡、查找略快;红黑树增删旋转少,是 STL set/map 底层。
- STL 底层 :
std::set、std::map底层是红黑树,不是 AVL。
