在讲平衡二叉树的章节里,我们已经认识了 AVL 树:它通过给每个结点保存一个平衡因子(左子树高度减右子树高度),一旦某个结点左右子树高度差超过 1,就立刻旋转来"掰正"。AVL 树把平衡控制得非常严格,所以它是最"标准"的平衡树。但 AVL 树有一个不大不小的遗憾——它对平衡的要求太苛刻了,以至于为了维持这种"完美",插入和删除时经常要转圈圈。
你可能要问了:都做到高度平衡了,为什么还不满足?答案藏在性能上。AVL 树的平衡是"严格平衡"(任意结点的左右子树高度差不超过 1),这个要求意味着每插入一个结点,只要某个祖先结点失衡,就至少要旋转一次。插入大量数据时,旋转次数相当可观。有没有一种结构,平衡程度放松一点点,让旋转少一点,但查询效率依然能稳稳地维持在 O(logN)?这就是红黑树登场的原因。
先给你一个名字上的直觉,方便后面理解为什么它叫"红黑":红黑树给每个结点贴上一张"颜色"标签(红或黑)。红色结点在视觉上像"易燃物"——红不得连着红;而黑色结点才是"地基",负责扛起高度。整棵树就靠这一条"颜色纪律"来约束高度,而不是像 AVL 那样去精打细算每一层的高度差。下面我们一层一层把它剥开,让你不仅会背规则,更知道每条规则"为什么在这里"。
为什么还需要红黑树:AVL 的遗憾
先说结论:红黑树和 AVL 树的效率属于同一档次,都是 O(logN) 的增删查改。区别在于对平衡的控制"松紧"不同——AVL 是严格平衡,红黑树是近似平衡。
学过 AVL 的你应该还记得两大前置知识。第一,二叉搜索树(BST):任意结点的左子树所有结点都小于它,右子树所有结点都大于它。二叉搜索树的查找效率取决于树高,最坏情况下(数据有序插入)会退化成链表,树高变成 N。第二,树高决定了查询的时间复杂度,所以平衡树的目标就是尽量压低树高。
AVL 树的思路,你可以理解为"目测"——每个结点看一眼左右子树谁高谁矮,差得太多就动手转。这套机制很直观,效果也很好。但"目测太准"的代价是:AVL 树为了保证任何结点高度差不超过 1,纪律极其严格,插入时经常触发旋转。这里有个隐含的成本:旋转是有开销的,尤其是需要一路向上传播的多次旋转。
红黑树的思路完全不同——它不去"目测高度",而是用颜色给结点贴上标签,然后用几条关于颜色的规则,间接保证树不会失衡得太厉害。因为控制得宽松,插入相同数量的结点时,红黑树的旋转次数明显少于 AVL 树。这就是它最大的价值:用一点点高度的牺牲,换来了更少的旋转,从而在整体性能上更"能打"。
为了让你对这个"两者同一档次、但特点不同"有更清晰的认识,我们来对比一张表:
| 对比维度 | AVL 树 | 红黑树 |
|---|---|---|
| 平衡标准 | 严格平衡:任意结点左右子树高度差 ≤ 1 | 近似平衡:最长路径 ≤ 最短路径的 2 倍 |
| 靠什么控制 | 每个结点维护平衡因子(高度差) | 每个结点维护颜色(红/黑) |
| 是否需额外字段 | 平衡因子(int) | 颜色(1 bit 即可,实现常用枚举) |
| 最坏树高 | 更矮(约 1.44·logN) | 稍高(最多 2·logN) |
| 插入旋转次数 | 相对较多(严格要求高度差) | 相对较少(放得松),变色多 |
| 查询效率 | 略快(树更矮) | 略慢一点点(树稍高) |
| 适用场景 | 频繁查询、很少增删 | 增删频繁的场景(如 map/set 底层) |
看到没有,红黑树拿一点查询上的小牺牲,换来了增删时大幅减少的旋转。对大量"边查边插边删"的场景,这是更划算的买卖。
实际工程里,红黑树几乎是"平衡查找树"的代名词。它是下面这些知名结构/系统的底层实现,你随手一抓都是它的身影:
- C++ STL:
map、set、multimap、multiset的标准实现(如 libstdc++、libc++、MSVC 的 STL)底层就是红黑树,因此它们能保证增删查都是稳定的 O(logN),并且map迭代时按键有序。 - Linux 内核:完全公平调度器(CFS)用红黑树按"虚拟运行时间"组织就绪进程;内核定时器、
epoll的事件就绪状态、虚拟内存管理(VMA,即虚拟内存区)也广泛使用红黑树(内部叫rb_tree、interval tree)。 - Java:
TreeMap、TreeSet底层就是红黑树;JDK 8 里HashMap的单个桶内链表内容过多(≥ 8 个)时会转成红黑树,就是为了防止哈希冲突严重时链表退化成 O(N)。 - 其他:Nginx、Windows 内核、许多数据库的内存索引,也都大量用红黑树。
所以,学会红黑树绝不只是"为了应付考试"——它是你在工程里天天间接打交道的数据结构。下面我们正式开始,把这个"带颜色的搜索树"层层剥开。
前置铺垫:二叉搜索树、旋转与树高
在动手写红黑树之前,先把三个会反复用到的基础概念温习一遍,免得后面卡壳。
二叉搜索树(BST) 我们前面已经提到:一个结点左边小、右边大。插入时从根出发,比当前结点小就往左走,大就往右走,走到空位就挂上去。查找同理。它的所有操作的时间复杂度都取决于树高——树越高越慢。最坏情况是数据恰好有序插入(比如 1、2、3、4、5…依次插入),树会退化成一个"只有右孩子"的链表,树高变成 N,那查找就退化成了 O(N),这是任何 BST 都要防的灾难,平衡树的全部意义就是避免这种情况。
旋转 是平衡树的"整形手术",分左旋和右旋两种。左旋可以理解为:以某个结点它的右孩子为轴,把"右子树往上提"。右旋同理但方向相反。旋转只会改变几个指针的指向,不改变中序遍历的顺序,所以不会破坏二叉搜索树"左小右大"的性质。为什么旋转不改变中序?我来拆给你看:中序遍历的顺序是"左→根→右",左旋时,原来是 parent 的 subR 被提上来当根,subRL(原来是 subR 的左孩子)挪去当 parent 的右孩子。关键在于 subRL 的取值夹在 parent 和 subR 之间(因为 BST 里 parent < subRL < subR),而左旋前中序是 …parent, subRL, subR…,左旋后变成 …subRL, parent… 且 subR 原本右子树仍在最右——整棵子树的中序序列一个元素都没变,只是换了指针。这就是"旋转保持 BST 性质"的根本原因。AVL 树你已经写过左右旋了,红黑树直接复用同一套旋转代码,只是不需要维护平衡因子——因为红黑树压根没有平衡因子这个字段。
树高 就是从根走到最远叶子经过的结点数量。平衡树的本质,就是让树高维持在对数级别,从而保证查询时间是 O(logN)。注意,这里要区分两个容易混淆的高度概念:红黑树里我们常说的"树高"(height,走最远路径经过的结点数)和黑高(black height,简记 bh,从某结点到叶子经过的黑色结点个数)——石头长、后面讲性质时黑高会反复登场,先记个名字。
好,这三个概念在脑子里是热的,接下来我们看红黑树到底用什么"魔法"控制平衡。
红黑树的性质
红黑树,就是一棵每个结点都额外带一个"颜色"标记(红色或黑色)的二叉搜索树。它通过约束每条从根到叶子的路径上各个结点的颜色,确保没有一条路径会比别的路径长出两倍,从而保持接近平衡。
先澄清一个小问题:红黑树的性质到底有几条?我在课件里把它写成 4 条,但《算法导论》等主流教材通常会把它补充成 5 条——多出来的那条就是"每个叶子结点(空结点,也叫 NIL)都是黑色"。这里的"叶子"不是传统意义上的叶子结点,而是指那些不存在的空结点(有些书叫"外部结点",英文 NIL)。引入 NIL 是为了让"每条路径"的表述更精确——每条从根到叶子的路径,都正好终结在一个 NIL 上,这样黑结点数量才好统一计数。不过《算法导论》后续讲实现细节时其实也忽略了这些 NIL 结点,所以你只需要知道这个概念即可。综合课件约定,常说的"红黑树五大性质"是这样的:
- 每个结点不是红色就是黑色。 这是最基本的,实现时用一个枚举就能保证。
- 根结点是黑色的。 根不能是红。
- 如果一个结点是红色,那么它的两个孩子必须是黑色。 换句话说,任意一条路径上都不会出现连续两个红色结点。这是红黑树最核心的一条约束。
- 对任意一个结点,从它出发到它所有的空结点(NIL)的简单路径上,包含相同数量的黑色结点。 这条保证了"黑结点的分布是均匀的",是红黑树平衡的根本。
- 每个叶子结点(空结点 NIL)都是黑色。 如前所述,这是为计数严谨而补的约定。
下面把这五条逐个"掰开",每一则你都该明白"它存在是为了堵住哪个洞"。
性质 1(非红即黑):纯粹是类型约定,用枚举 enum Colour { RED, BLACK } 就让编译器替你保证颜色只有这两种取值,这一条几乎不花钱。
性质 2(根为黑):你要注意,我特意没有沿用"根不能是红,否则第 3 条被打破"这种说法——因为严格说红根并不必然违反性质 3(红结点的孩子可以是黑)。真正准确的理解是两层的:其一,根没有父亲,"红结点必须有黑父亲/红结点的孩子必须黑"这条规则在根这个边界上需要有个干净的处理方式,把根固定涂黑,就免去了一切"根是红时它上面怎么办"的额外分支;其二,配合性质 5(NIL 是黑),根为黑能保证整棵树的黑高(bh)从根的第一层算起就有一个确定的"基线",让性质 4 的计数在根这里干净统一。所以性质 2 本质上是一条"约定 + 兜底",工程代码里通常会在插入的最后写一句 _root->_col = BLACK; 把根无条件刷成黑色,就是替这条兜底。
性质 3(红结点不能有红孩子):这条是红黑树的"灵魂"。它的直接推论是"任意路径上不会出现连续两个红结点"——因为一旦出现连续红,那上面的那个红的其中一个孩子就红了,违反定义。这种"红不过二"的约束在物理上限制了一条路径里红色结点的密度(红之间必须隔着黑),这正是后面证明"最长路径 ≤ 2 倍最短路径"的核心依据。将来写验证代码时,判断违规的方式很巧妙:与其检查"每个红结点的孩子是不是黑"(孩子有两个、还可能为空,不好查),不如反过来,碰见红结点就查它的父亲是不是红——父亲最多一个、且必然存在(除了根),这话在验证一节再展开。
性质 4(每条路径黑结点数相同):这是红黑树能在全局上"平衡"的根本。它规定从任意结点出发到它所有后代 NIL 的路径,黑色结点数量必须完全一样。这一条管的是"黑":黑结点是承担高度的"骨架",把每一路的骨架厚度统一了,树就不可能某一侧特别高。它也是最难维护的一条,后续你会看到,插入一个新结点时我们尽量不让黑色数量失衡,一旦失衡就得靠旋转来"借黑还黑"。
性质 5(NIL 是黑):这条纯粹是为性质 4 的计数提供"终点基准"。正因为叶子 NIL 是黑的但不算进黑高,我们才能说"每条实际路径终结在一个黑色 NIL 上",从而让黑高的定义没有歧义。实现时可以完全不建 NIL 结点(用 nullptr 充当),性质 4 的验证也就天然成立。
你看,四条主规则里,第 3 条管"红",第 4 条管"黑"。后面会频繁提到两个概念,这里先给出定义。所谓红结点,就是颜色为红色的结点,它唯一的宿命是"不能有红孩子"。所谓黑高(black height,记作 bh),就是从某个结点走到它的任一叶子(NIL)所经过的黑色结点的个数。第 4 条说的就是把整棵树每条路径的黑高统一成一个固定值。请你先记住"黑高"这个名字,证明最长路径那节全靠它。
给一句话总结这一节:红黑树的平衡,本质上是"红被限制、黑被均分"这两条纪律逼出来的。
红黑树结点的定义(含 color)
有了性质,马上落实到代码。结点在 BST 的基础上,增加一个 _col(color)字段来存放颜色。别忘了,和 AVL 树一样,为了插入后能方便地向上调整,每个结点还要带一个 _parent 指针。我们这块按 key/value 的结构来实现,颜色用一个枚举表示。
#include <iostream>
#include <utility> // 引入 std::pair
using namespace std;
// 枚举值表示颜色:红色、黑色
enum Colour
{
RED,
BLACK
};
// 红黑树结点:默认按 key/value 结构实现
template<class K, class V>
struct RBTreeNode
{
pair<K, V> _kv; // 存放键值对,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) // 颜色默认给红色,原因后面细讲
{}
};这个结点定义有四个值得深挖的点。
第一,_parent 指针是红黑树(和 AVL 树)的"必需品"。回顾普通 BST,插入只需要 _left/_right 就够了,因为插入是从根自顶向下找空位,一路带一个 parent 局部变量即可。但红黑树插入后的"修正"是自底向上的——一旦发现连续红,要从 cur 往上追 parent、grandfather,变色不满足还得继续往上提。如果没有 _parent,你必须在插入时花功夫保存祖先链,既麻烦又容易错。所以,一个 _parent 指针换来的是调整过程随心所欲地"往上爬",这笔账非常划算。
第二,颜色为什么用枚举而不是 bool?用 bool(true/false)也能表达两种状态,但颜色这东西用枚举命名更语义化——写代码时一眼认出 RED/BLACK,而不是去看 true 到底代表红还是黑。这在工程上属于"可读性"的考量。
第三,颜色字段到底占多大内存?实际上红黑树的颜色在教科书实现里只需要 1 个比特。这里我们用 Colour 枚举,通常占 4 字节(一个 int 的大小),相对土豪;Linux 内核里为了省内存,甚至用指针低比特位来"藏"颜色。对你手写学习来说,枚举最简单直白,不用纠结优化。
第四,也是最关键的——构造函数里颜色默认给 RED(红色)。为什么新结点默认要是红的?让我把"守恒"逻辑讲透:回忆性质第 4 条——从任意结点到它的每个叶子路径,黑色结点数量必须相同。这条性质的精髓是"整棵树的黑高在每条路径上都一致"。如果一个新结点默认是黑色,那么它所在的那条路径就会"凭空"多出一个黑色结点 → 这条路径的黑高比兄弟路径大 1 → 立刻违反性质 4。而性质 4 恰恰是最难维护的一条(一旦破坏,往往需要一路向上借黑、还黑),代价极高。反过来说,让新结点默认是红色,红结点不影响黑高计数,所以它绝不会破坏性质 4;它顶多可能违反性质 3(出现连续红),而这种"连续红"我们完全可以靠"变色 + 旋转"在局部轻而易举地修好。一句话总结:新增默认红,是把"可能的违规"从难维护的黑高问题(性质 4),转移成好维护的连续红问题(性质 3)。这个坑必须牢记,它是理解红黑树插入调整的一把总钥匙。
(补充一个边界情形:空树插入——当树是空树时,新结点直接成为根。根是没有父亲的,而红结点"必须有某种约束"在根这里没有依托;更直接的是,性质 2 明确要求根是黑色。所以空树插入时,新结点虽是红创建,但紧接着就被强制染成黑色,这一步在插入函数里单独处理。)
为了让结点定义能独立运行、帮你亲手感受颜色字段,下面给一个完整的可编译小程序:
#include <iostream>
#include <utility>
using namespace std;
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) {}
};
int main()
{
RBTreeNode<int, int> cur( make_pair(7, 7) );
cout << "新结点刚创建时的颜色是: "
<< (cur._col == RED ? "RED(红)" : "BLACK(黑)") << endl;
// 新结点默认红:为了不破坏"黑高守恒"(性质 4),把新结点默认为红、只可能撞"连续红"(性质 3)
cur._col = BLACK; // 一旦需要,我们手动把它染黑(例如根结点)
cout << "手动染黑后颜色是: "
<< (cur._col == RED ? "RED(红)" : "BLACK(黑)") << endl;
return 0;
}现在结点有了、类型有了,还差最核心的东西——插入和调整。但在写插入之前,得先把"旋转"这个手术刀准备好,因为红黑树的调整重度依赖旋转。
左旋与右旋
旋转就是"指针的乾坤大挪移",它不改变数据本身,只改变结点的父子关系,因此保持中序遍历不变、也就保持 BST 性质不变。红黑树旋转的代码和你写过的 AVL 旋转一模一样,唯一区别是不需要更新平衡因子(红黑树根本没有平衡因子这个字段)。
先看左旋。以 parent 为轴左旋的道理是:让 parent 的右孩子 subR 顶上来当这个子树的根,parent 变成 subR 的左孩子。如果 subR 有左孩子 subRL,把它接给 parent 当右孩子。说白了就是"右边大的往上提,中间的过渡结点挪到左边当右孩子"。画成拓扑就是:
左旋前 旋转后(左旋)
p sR
\ /
sR p
/ \ \
sRL ... sRL
subRL 是关键:它在 BST 中夹在 p 和 sR 之间(p < sRL < sR),左旋前它是 sR 的左孩子,左旋后它变成 p 的右孩子——位置变了,但依然排在 p 之后、sR 之前,所以中序一点没乱。
// 左旋:以 parent 为旋转点,将其右孩子提升为子树根
void RotateL(Node* parent)
{
Node* subR = parent->_right; // subR 为 parent 的右孩子
Node* subRL = subR->_left; // subRL 为 subR 的左孩子
// 第一步:parent 的右孩子改为 subRL
parent->_right = subRL; // subRL 挂到 parent 的右边
if (subRL) // subRL 可能存在,也可能为空
subRL->_parent = parent; // 若存在则更新其父亲为 parent
Node* ppNode = parent->_parent; // 记录 parent 原本的父亲
// 第二步:subR 顶上,接管 parent 的孩子位置
subR->_left = parent; // parent 变成 subR 的左孩子
parent->_parent = subR; // parent 的父亲改为 subR
// 第三步:把 subR 接到祖父 ppNode 上
if (ppNode == nullptr) // parent 原本就是根
{
_root = subR; // 新的根就是 subR
subR->_parent = nullptr; // 根的父为空
}
else
{
if (ppNode->_left == parent)
ppNode->_left = subR; // 是祖父的左孩子,则更新祖父左指针
else
ppNode->_right = subR; // 否则更新祖父右指针
subR->_parent = ppNode; // subR 的父亲设为祖父
}
}再来看右旋,和左旋完全对称。以 parent 为轴右旋,就是让 parent 的左孩子 subL 顶上来当子树根,parent 变成 subL 的右孩子,subL 原来的右孩子 subLR 挪给 parent 当左孩子。你可以把左右旋当成一对镜像操作来记忆。
// 右旋:以 parent 为旋转点,将其左孩子提升为子树根
void RotateR(Node* parent)
{
Node* subL = parent->_left; // subL 为 parent 的左孩子
Node* subLR = subL->_right; // subLR 为 subL 的右孩子
// 第一步:parent 的左孩子改为 subLR
parent->_left = subLR; // subLR 挂到 parent 的左边
if (subLR) // 判空,subLR 可能为空
subLR->_parent = parent; // 若存在则更新其父亲
Node* ppNode = parent->_parent; // 记录 parent 原本的父亲
// 第二步:subL 顶上,接管 parent 的孩子位置
subL->_right = parent; // parent 变成 subL 的右孩子
parent->_parent = subL; // parent 的父亲改为 subL
// 第三步:把 subL 接到祖父 ppNode 上
if (ppNode == nullptr) // parent 原本是根
{
_root = subL; // 新根是 subL
subL->_parent = nullptr;
}
else
{
if (ppNode->_left == parent)
ppNode->_left = subL; // 更新祖父的左指针
else
ppNode->_right = subL; // 更新祖父的右指针
subL->_parent = ppNode; // subL 的父亲设为祖父
}
}旋转这段代码有几个必须盯住的指针细节,写错一个树就"断链",我念给你听:
- 被挪走的中间结点可能为空(
subRL/subLR),所以接指针前要先判空(if (subRL)),不为空才改它的_parent。 parent->_parent要先存到临时变量ppNode(或者函数开头就读取),否则一旦parent被接到subR/subL下面,原父亲的引用就丢了,后面没法把旋转后的新根挂回祖父。- 必须区分"祖父为空(parent 原本是根)"和"祖父非空"两种情况:祖父为空,新的子树根直接当整棵树的根,其
_parent置空;祖父非空,还要判断 parent 是祖父的左还是右,把新根正确地挂回去。漏了这一步,树就又断了。
为了让你真切看到"旋转不改变中序",这里给一个完整可独立编译的迷你演示程序——它构造一棵小 BST,做一次左旋再右旋回来,每次打完都打印中序遍历,你会发现序列始终不变:
#include <iostream>
using namespace std;
struct Node
{
int val;
Node* left;
Node* right;
Node* parent;
Node(int v) : val(v), left(nullptr), right(nullptr), parent(nullptr) {}
};
// 独立版的左旋(演示用,逻辑与红黑树里的 RotateL 一致)
void rotateLeft(Node*& root, Node* parent)
{
Node* subR = parent->right;
Node* subRL = subR->left;
parent->right = subRL;
if (subRL) subRL->parent = parent;
Node* pp = parent->parent;
subR->left = parent;
parent->parent = subR;
subR->parent = pp;
if (pp == nullptr) root = subR;
else if (pp->left == parent) pp->left = subR;
else pp->right = subR;
}
// 独立版的右旋(演示用,与 rotateLeft 镜像)
void rotateRight(Node*& root, Node* parent)
{
Node* subL = parent->left;
Node* subLR = subL->right;
parent->left = subLR;
if (subLR) subLR->parent = parent;
Node* pp = parent->parent;
subL->right = parent;
parent->parent = subL;
subL->parent = pp;
if (pp == nullptr) root = subL;
else if (pp->left == parent) pp->left = subL;
else pp->right = subL;
}
void destroy(Node* r)
{
if (!r) return;
destroy(r->left);
destroy(r->right);
delete r;
}
void inorder(Node* r)
{
if (!r) return;
inorder(r->left);
cout << r->val << ' ';
inorder(r->right);
}
int main()
{
// 构造一棵小 BST:根10,左8,右12;8的左6、右9
Node* root = new Node(10);
Node* n8 = new Node(8);
Node* n12 = new Node(12);
Node* n6 = new Node(6);
Node* n9 = new Node(9);
root->left = n8; n8->parent = root;
root->right = n12; n12->parent = root;
n8->left = n6; n6->parent = n8;
n8->right = n9; n9->parent = n8;
cout << "旋转前 中序遍历: ";
inorder(root); cout << endl;
rotateLeft(root, root); // 以根 10 为轴左旋
cout << "以10左旋后 中序遍历: ";
inorder(root); cout << endl;
rotateRight(root, root); // 再以当前根右旋回来
cout << "再以新根右旋后 中序遍历: ";
inorder(root); // 和第一次完全一样,说明旋转不改变中序
cout << endl;
destroy(root);
return 0;
}运行它,三次打印的序列都是 6 8 9 10 12——一尘不染。这就直观证明了"旋转只改形态、不改中序、不坏 BST 性质",也正因如此,我们才敢放心地用旋转去调平衡、而不用担心把"左小右大"打乱。
写到这里你可以发现,旋转本身没有任何"红黑"逻辑,它只是改变树的形态。真正精巧的部分在于:什么时候该转、转完该把谁涂成什么颜色。这就是红黑树最烧脑的地方——插入后的调整。
插入:先按 BST 插,再修正
红黑树的插入分两步走:第一步完全按二叉搜索树的规则把新结点插进去;第二步检查是否违反红黑树性质,如果违反了就做"变色 + 旋转"来修正。
整个插入的大概过程是这样的:
- 按二叉搜索树规则找到插入位置,把新结点挂上去,然后只需要观察它是否违反红黑树的规则。
- 如果是空树插入,也就是新结点直接成为根,那它必须是黑色结点(性质 2 要求根是黑的)。
- 如果是非空树插入,新增结点必须是红色(原因前面已经深挖过,红线少、好修)。如果它的父亲是黑色,那么"红结点没有红孩子"这条不违反,插入直接结束——这是最理想的情况。
- 如果父亲是红色,就违反性质 3(出现了连续红)。进一步分析:此时新增结点 c 是红、父亲 p 是红,那么祖父 g 必然是黑色(因为黑树上不可能出现两个连续红)。这三个结点的颜色都固定了,关键变数全在叔叔 u(p 的兄弟)那里。于是要根据 u 不同,把调整分成几种情况。
这里先约定新引入的三个符号,后面一路用到:新增结点记为 c(cur),它的父亲记为 p(parent),父亲的父亲记为 g(grandfather,祖父),父亲的兄弟记为 u(uncle,叔叔)。
顺带说明一点:为什么"如果父亲是红,祖父必是黑"?因为如果有连续的三个红(p 红 + g 红),那在更早的某次调整里早就该被修正掉了;我们现在考察的是"修正进行到一半或刚好插入完成的瞬间",此时 g 必为黑,否则 p 和 g 就是连续红——而连续红正是我们要消灭的对象。所以可以放心地说:一旦 p 是红,g 必黑,下一步只需要看 u。这个"三色已定、只看叔叔"的洞察,是理解整段调整代码的最高概括。
先看完整的插入主函数,再逐段拆解其中的调整逻辑:
// 插入一个键值对,成功返回 true;key 已存在返回 false
bool Insert(const 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) // key 比当前大,往右走
{
parent = cur;
cur = cur->_right;
}
else if (cur->_kv.first > kv.first) // key 比当前小,往左走
{
parent = cur;
cur = cur->_left;
}
else
{
return false; // key 已存在,插入失败
}
}
// 第二步:把新结挂到父亲的孩子位置上
cur = new Node(kv); // 创建新结点,颜色默认红
cur->_col = RED; // 非空树插入必须是红色
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; // 取出祖父
// 情形一:p 是 g 的左孩子
if (parent == grandfather->_left)
{
Node* uncle = grandfather->_right; // 叔叔是 g 的右孩子
if (uncle && uncle->_col == RED)
{
// 叔叔存在且为红 -> 变色,然后继续往上处理
parent->_col = BLACK; // p 变黑
uncle->_col = BLACK; // u 变黑
grandfather->_col = RED; // g 变红
cur = grandfather; // 把 g 当作新的 cur
parent = cur->_parent; // 继续向上检查
}
else
{
// 叔叔不存在或为黑 -> 旋转 + 变色
if (cur == parent->_left)
{
// 左左形态:对 g 做右单旋
RotateR(grandfather); // 以 g 为轴右旋
parent->_col = BLACK; // p 变黑,当新根
grandfather->_col = RED; // g 变红
}
else
{
// 左右形态:先 p 左旋,再 g 右旋(双旋)
RotateL(parent); // 以 p 为轴左旋
RotateR(grandfather); // 以 g 为轴右旋
cur->_col = BLACK; // c 变黑,当新根
grandfather->_col = RED; // g 变红
}
break; // 单旋/双旋后无需继续向上
}
}
else
{
// 情形一:p 是 g 的右孩子(与上面完全镜像)
Node* uncle = grandfather->_left; // 叔叔是 g 的左孩子
if (uncle && uncle->_col == RED)
{
// 叔叔存在且为红 -> 变色后继续往上
parent->_col = BLACK; // p 变黑
uncle->_col = BLACK; // u 变黑
grandfather->_col = RED; // g 变红
cur = grandfather; // 上提 g,继续处理
parent = cur->_parent;
}
else
{
// 叔叔不存在或为黑 -> 旋转 + 变色
if (cur == parent->_right)
{
// 右右形态:对 g 做左单旋
RotateL(grandfather); // 以 g 为轴左旋
parent->_col = BLACK; // p 变黑,当新根
grandfather->_col = RED; // g 变红
}
else
{
// 右左形态:先 p 右旋,再 g 左旋(双旋)
RotateR(parent); // 以 p 为轴右旋
RotateL(grandfather); // 以 g 为轴左旋
cur->_col = BLACK; // c 变黑,当新根
grandfather->_col = RED; // g 变红
}
break; // 处理完毕,跳出
}
}
}
_root->_col = BLACK; // 保险起见,最后把根强制涂黑
return true; // 插入成功
}这段代码是红黑树的"心脏",密密麻麻。先帮你看懂它的"骨架",再逐段拆。整个 while 循环的含义是:只要当前结点 cur 的父亲 p 是红色的,就说明存在需要处理的连续红,于是把 cur、p、g 的关系翻出来看叔叔。循环被 break 或自然结束的条件是:要么一路处理到 parent 为空(说明 cur 已经"上提到"根那一级,变色把根染红了,最后由末尾 _root->_col = BLACK 兜底刷黑),要么在某次旋转后 break 掉。
Insert 里还藏着一个细微但重要的点:每次进入循环的第一步都是重新取祖父。因为上一轮如果走了"变色 + 上提",cur 变成了原来的 grandfather,它的父亲(也就是新的 parent)很可能已经变了,所以每轮都要 grandfather = parent->_parent 现算,千万不能在外面缓存。
记得那个关键判定吗——当父亲是红时,祖父必黑,那么唯一的变数就是叔叔。于是有:
- 叔叔是红色 → 走"变色"分支(叔叔红时只变色、不旋转)。
- 叔叔不存在,或叔叔存在但为黑色 → 走"旋转 + 变色"分支。
下面三节就围绕这张图展开。整段调整逻辑,你可以把它想成一张"最终决策表"(这张表你值得抄在笔记本上):
| 叔叔 u 的状态 | c 与 p 的位置 | 处理动作 | 是否继续上提 |
|---|---|---|---|
| u 为红 | 任意 | 只变色(p、u 变黑,g 变红) | 继续往上 |
| u 为黑或不存在 | p、c 同边 | 单旋 + 变色 | 结束 |
| u 为黑或不存在 | p、c 不同边 | 双旋 + 变色 | 结束 |
红黑调整的情况一:叔叔为红,变色
第一类情况画出来是这样:c 红、p 红、g 黑,而且 u 存在且为红。这种情况下 p 和 u 都是红,g 是黑,处理方式极其统一——变色:
把 p 和 u 都变黑,把 g 变红,然后把 g 当作新的 c 继续往上更新。
拓扑示意(p 是 g 左的例子;p 是 g 右时完全镜像,处理一模一样):
g(黑) g(红→继续上提)
/ \ / \
p(红) u(红) ===> p(黑) u(黑)
|
c(红)
为什么这样变?分析一下守恒:p 和 u 原本都是红,把它们变黑,p、u 两条分支各自增加一个黑色结点;同一层左右各自增加一个黑,黑高上互不抵消但"数量平衡"。可 g 再变红,相当于把 g 这个结点从"黑色贡献"里抽出来,让整棵以 g 为根的子树的黑高保持和变之前的 g 子树黑高一致。写成守恒式就是:g 子树黑高 = max(p路径黑, u路径黑),变前 g 是黑的、变后 g 变红、但 p 和 u 从红变黑补回来了,所以整棵 g 子树内部的黑色结点数量完全没变——这正是性质 4(每条路径黑结点数相同)所要求的,变色一换一,趁机保证黑高不动。与此同时,"p 和 c 连续红"的问题也解决了(p 变黑了)。
那为什么要继续往上更新?因为 g 现在变成了红色——如果 g 的父亲恰好也是红色,就又会冒出新的"连续红",得继续往上处理;如果在某个位置 g 的父亲是黑色或不存在,那处理到此结束。而如果 g 一路被上提、最终有个红色结点当上了整棵树的根,最后一句 _root->_col = BLACK 就会把它兜底染黑——根必须黑,这条永远由收尾保证。
关键点:情况一只变色,不旋转!所以无论 c 是 p 的左还是右、p 是 g 的左还是右,处理方式都是上面这一套变色。你不需要对着四种子形态去背,记住"叔叔红,变一波色再往上"就够。为什么叔叔红时就不用旋转?因为连续红的根源在于"红结点 p 需要变黑才能压住 c",而 p 变黑会让 p 所在路径多一个黑、破坏黑高;但此时叔叔 u 也是红的,可以一并变黑,左右各多一个黑,黑高守恒就补平了——所以不必借助旋转去"挪位置",颜色一变即自洽。这是"叔叔红"与其他两种情况在立意上的根本不同。
看到代码里的 cur = grandfather; parent = cur->_parent; 这短短两行了吗?这可太重要了。它不是简单的"跳转",而是在把问题结点往上抛:把变色后的红色 g,当作新一轮的"违规红结点 c",这样 while 循环就能用完全相同的逻辑继续向上检查 g 的父亲。这就是红黑树调整能够一举处理的根源——一个 case 处理不了就"滚"上去,直到把矛盾推进到根。对复杂形状(比如大量结点堆积、递归触发多次变色)它也能做到"套路化解决",这正是抽象的价值。
红黑调整的情况二:叔叔为黑(或不存在),单旋 + 变色
第二类情况同样是 c 红、p 红、g 黑,但 u 不存在,或者 u 存在但颜色为黑。
这里先要弄清"叔叔不存在 vs 叔叔为黑"两种前提下,c 的身份有什么不同:如果 u 不存在,那么 c 一定是新增结点;如果 u 存在且是黑,那么 c 一定不是新增的,而是之前某个黑色结点在它的子树里按"情况一"变过色、从黑色变成了红色、一路更新上来的。不管是哪种背景,共同点是:单纯变色已经解决不了问题了。为什么?
因为要消除连续红,p 必须变黑;但 p 变黑会让 p 所在路径多出一个黑结点、破坏性质 4。这时如果叔叔 u 是黑的或者干脆不存在,就没有另一个"碍眼的红结点"可以一并变黑来补平——也就是说,黑高守恒在"左右各 +1 黑"这条路上走不通了。所以必须旋转 + 变色配合:用旋转把多出来的那个黑"挪"到合适的位置,再用变色完成一换一,从而既不破黑高、又消灭连续红。这是情况二、三与情况一的本质分野。
先看最简单的一种子形态,p 是 g 的左孩子、c 是 p 的左孩子(左左形态,此时 c 与 p 同在左、是"直线"):
g(黑) p(黑)
/ \ / \
p(黑) u(黑或空) ===> c g(红)
| \
c(红) u
- 以 g 为旋转点做右单旋;
- 旋转后 p 成了这棵子树的新根,再把 p 变黑、g 变红。
这样处理完,子树的黑色结点数量保持不变(p 从红变黑、g 从黑变红,一换一),黑高以 p 为根重新对齐,没有连续红结点,而且不需要继续往上更新——因为新的根 p 是黑色,无论 p 的父亲是黑是红还是空,都不会违反任何规则(黑色父亲下面的子树根是红还是黑都允许)。
p 是 g 的右孩子、c 是 p 的右孩子(右右形态)完全对称:以 g 为轴做左单旋,再 p 变黑、g 变红,同样结束。
一句话记忆:"折线里的直线形态"——p 和 c 同边(都是左或都是右),一次单旋就够了。 对应的代码是 RotateR(grandfather) 那一段(左左)或 RotateL(grandfather) 那一段(右右)。
红黑调整的情况三:叔叔为黑(或不存在),双旋 + 变色
与情况二同一个大前提(u 不存在或为黑),但此时 c 与 p 是"折线"关系,即 c 和 p 不同边:p 是 g 的左、c 是 p 的右(左右形态),或者 p 是 g 的右、c 是 p 的左(右左形态)。
以"左右形态"为例(p 是 g 的左、c 是 p 的右):
g(黑) 先以p左旋 g(黑) 再以g右旋
/ \ ==============> / \ ==============> c(黑)
p u c u / \
\ (黑或空) | p(红) g(红)
c(红) p(红) \
(p的左孩子被c接管) u
- 先以 p 为旋转点做左旋,再做以 g 为旋转点的右旋——也就是双旋;
- 旋转后 c 成了这棵子树的新根,再把 c 变黑、g 变红。
为什么直线用单旋、折线就要双旋?你可以这样理解:单旋是在"轴点就是端点"时直接归位;而当 c 埋在 p 的内侧(折线),直接对 g 单旋会把 c 甩到错误的一侧、c 自身仍顶着 p 造成连续红。所以先对内层的 p 旋一次,把折线"捋直"成直线(此时 c 变成 p 的外侧子结点),再对 g 旋一次完成与情况二等价的归位。两旋合一即双旋。本质上,双旋 = 两次单旋各管一层。
同样,子树黑结点数量不变(c 从红变黑、g 从黑变红,一换一;p 保持红不变),没有连续红结点,且不需要继续往上更新(新根 c 是黑的)。
"右左形态"完全对称:先以 p 右旋、再以 g 左旋,c 变黑、g 变红。
一句话记忆:"折线里的交叉形态"——p 和 c 不同边,需要两次旋转(双旋)把它们"捋直"再调整。 对应代码是 RotateL(parent); RotateR(grandfather); 那一段(左右)或 RotateR(parent); RotateL(grandfather); 那一段(右左)。
到这里,插入的三种情况就完整了。我们再把它们的"分工"回味一遍:情况一靠"变色 + 上提"把矛盾向上传导到更高层去消化;情况二、三靠"旋转 + 变色"在局部直接把矛盾化解掉并就此收手(不再上提)。这就是为什么红黑树的插入能在至多 O(logN)、常数次旋转/变色内把树修好——情况一只在若干层上上提变色,情况二三各一次旋转即止。所有复杂形态,最终都会被这套规则规约为这三条的某次组合。
为了让你对三种情况都有"亲眼见过"的把握,这里给出两个具体、能确定触发对应情况的插入序列,你可以对着上面的决策表一步步跟踪(也可以在下面"完整例子"一节直接运行验证):
- 触发情况一(叔叔红,变色 + 上提):依次插入
10, 5, 15, 3。插 10(黑根)→ 插 5(父黑,合法)→ 插 15(父黑,合法)→ 插 3:父 5 红、祖父 10 黑、叔叔 15 红 → 情况一变色(5 黑、15 黑、10 红),把 10 上提为新 cur,此时 10 的父亲为空,循环结束,收尾把根 10 刷黑。整棵树:10 黑、5 黑左、15 黑右、3 红左,完全合法。 - 触发情况三(叔叔黑/无,折线双旋):在上面基础上继续插入
4。插 4:父 3 红、祖父 5 黑、叔叔为 5 的右孩子(不存在)→ 叔叔黑/无,且 c(4) 是 p(3) 的右(p 是 g 的左)→ 左右形态双旋:先对 3 左旋(4 顶上、3 变 4 的左)、再对 5 右旋(4 变新根、5 变 4 的右),然后 c(4) 染黑、g(5) 染红。结果 4 成子根、3 红在左、5 红在右,合法。 - 触发情况二(叔叔黑/无,直线单旋):另起一棵,依次插入
20, 10, 30, 5, 3。插 20(黑根)→ 10、30(父黑,饿得合法)→ 插 5:父 10 红、祖父 20 黑、叔叔 30 红 → 情况一变色(10 黑、30 黑、20 红,根刷回黑)→ 插 3:父 5(注意此时 5 是 10 的左孩子、红色)红、祖父 10 黑、叔叔是 10 的右孩子(不存在)→ 叔叔黑/无,且 c(3) 是 p(5) 的左(p 是 g 的左)→ 左左形态单旋(情况二):对 10 右旋、p(5) 变黑、g(10) 变红。结果 5 成子根、3 与 10 两个红孩子,合法。
跟踪完这三个序列,三条规则在你心里就从"背"变成了"懂"。实践出真知——强烈建议你运行末尾的完整程序把这几个序列都敲进去,看看每步打印的 "OK"。
红黑树的查找
红黑树本身是二叉搜索树,所以查找完全不看颜色,直接按 BST 的逻辑从根往下走即可,效率是 O(logN)。
// 按二叉搜索树规则查找,找到返回结点指针,否则返回空
Node* Find(const K& key)
{
Node* cur = _root; // 从根开始
while (cur)
{
if (cur->_kv.first < key)
cur = cur->_right; // key 大,向右找
else if (cur->_kv.first > key)
cur = cur->_left; // key 小,向左找
else
return cur; // 找到,返回该结点
}
return nullptr; // 走到空都没找到
}查找简单吧。红黑树的价值从来不在查找本身,而在它保证了查找稳定地走对数高度——不像普通 BST 在最坏情况下会退化成链表。这个保证,正是靠前面那几条颜色性质撑起来的。对比一下:在一个普通 BST 里,Find 这段代码一模一样,但数据有序插入时它要一路走到底、退化成 O(N);而红黑树因为在插入时就通过颜色纪律压住了高度,Find 每次都是贴着 O(logN) 走的。这就是"同样的查找代码,底子不同、上限天差地别"。
为什么最长路径最多是最短的两倍
现在来回答一个你心里早该冒出来的问题:凭什么这些颜色规则能保证"最长路径不超过最短路径的两倍"?证明思路不用背,跟着推理两小步就走通了。
第一步,看最短路径。 由性质 4 可知,从根到每个 NIL 的每条路径都有同样多的黑色结点,这个共同的黑色结点数量就是黑高 bh。极端情况下,一条路径可能全是黑色结点,没有任何红色结点——这就是理论上的最短路径,长度为 bh(记 h 为任意路径长度,那么有 bh ≤ h)。
第二步,看最长路径。 由性质 2(根为黑)和性质 3(红结点不能有红孩子)可知,无论如何,任意路径上都不会出现连续两个红色结点。那最长的路径能长什么样?只能是一黑一红、一黑一红交替——因为黑结点后面最多跟一个红,然后又必须回到黑。这样的路径中,红色结点的个数最多等于黑色结点的个数。既然整条路径的黑色结点数是 bh,那么红色结点数最多也是 bh,合起来最长路径最多有 2 × bh 那么长。于是有 bh ≤ h ≤ 2×bh。
换句话说,红黑树把所有路径都"锁"在了一个两倍区间里:最短全黑是 bh,最长黑白交替最坏也就 2bh。这就是"近似平衡"的精确含义——它不追求左右子树严格等高,但保证任何路径不会失衡太多。尤其要强调:这个"全黑最短、一黑一红最长"的极端形态并不是在每棵红黑树里都真实存在的,它只是证明中的一个上界/下界估计,用来证明"不管树长成什么样,树高都被夹在 bh 和 2bh 之间"。
顺着这个思想再推一步效率,这也是课件 1.3 节要你推出的结论。我们要把"两倍界"换算成"O(logN)",关键是证明 bh 和结点数 N 之间悬着一条对数上界。标准引理是这样证的(用归纳,别慌,就两行):
引理:一棵黑高为 bh 的红黑树,其内部结点数至少为
2^bh - 1。归纳证明如下:
- 基础(bh = 0):黑高 0 表示没有任何内部结点(全空),内部结点数 0 = 2⁰ - 1,成立。
- 归纳步:假设黑高 < bh 时成立。黑高为 bh 的树,根必为黑。再看根的两棵子树:若子树根是黑,则其黑高为 bh-1;若子树根是红,则其黑高仍为 bh(红色不算入黑高),但也 ≥ bh-1。总之两棵子树的黑高都 ≥ bh-1,由归纳假设,每棵至少含
2^(bh-1) - 1个内部结点。于是整棵树内部结点数 ≥1 + 2·(2^(bh-1) - 1) = 2^bh - 1。归纳得证。
由这个引理,含 N 个内部结点的红黑树满足 N ≥ 2^bh - 1,即 bh ≤ log₂(N+1)。再结合上面证过的两倍界 h ≤ 2·bh,立得:
h ≤ 2·log₂(N+1),所以 h = O(logN)。
于是增删查改在最坏情况下都是走最长路径 h,时间复杂度就是 O(logN)——这就是红黑树和 AVL 树同为 O(logN) 的来龙去脉,只是红黑树因为控制宽松,旋转次数更少。对比 AVL 那个更紧的 h ≤ 1.44·logN,红黑树的高度上界更松(2·logN),但换来的是更少的旋转。
红黑树的验证
写完插入、旋转、变色,怎么确认这棵树真的"红黑"?最直接的思路是检查"最长路径是否不超过最短路径的两倍"。但这里有个坑:就算某棵树满足了最长不超过最短两倍,它也可能颜色上违反了别的规则——比如某条路径连续出现三个红结点而导致结构不健康,只是恰好当前数值上还过得去;可一旦继续插入,迟早会出问题。所以验证不能只看高度比,必须去正面检查我们列出的那几条规则,规则全满足了,两倍性质自然由前面的证明保证。
逐条来:
- 性质 1(非红即黑):由枚举天然保证,不用查。
- 性质 2(根为黑):直接判断根的颜色即可。
- 性质 3(无连续红):用前序遍历检查每个红结点的孩子——但孩子有两个、还可能为空,检查起来反而不方便。聪明的做法是反过来检查红结点的父亲:碰到红结点,看看它父亲是不是红,是就违规。父亲最多一个、且一定有(根除外),查起来最省事。
- 性质 4(黑结点数量相等):用前序遍历,在遍历过程中用形参
blackNum记录从根到当前结点路径上的黑色结点数,走到空(一条路径走完)就统计出一条路径的黑色结点数,拿任意一条作为参考值refNum,与其他路径依次比较即可。
// 递归检查红黑树性质,blackNum 传引用不方便,这里用传值累加每条路径
bool _Check(Node* root, int blackNum, const int refNum)
{
if (root == nullptr)
{
// 前序遍历走到空,意味着一条路径走完了,此时统计出 blackNum
if (refNum != blackNum)
{
cout << "存在黑色结点数量不相等的路径" << endl;
return false; // 违反性质 4
}
return true; // 这条路径合法
}
// 检查连续红:查孩子不方便,反过来查父亲的颜色
if (root->_col == RED
&& root->_parent != nullptr // 根结点的父亲为空,需判空
&& root->_parent->_col == RED)
{
cout << root->_kv.first << "存在连续的红色结点" << endl;
return false; // 违反性质 3
}
if (root->_col == BLACK)
blackNum++; // 遇到黑结点,当前路径黑结点数加一
// 递归检查左子树和右子树,两条都合法才算合法
return _Check(root->_left, blackNum, refNum)
&& _Check(root->_right, blackNum, refNum);
}这段递归有四个值得吃透的细节:
blackNum为什么传值、不传引用? 我们希望"每一条根到叶的路径各自独立地累加黑结点数"。如果传引用,左右子树的累加会互相污染——左边改的值右边接着用,就乱套了。传值则意味着每个递归分支都拥有一份独立的计数副本,互不干扰,走到空时统计出的就是"当前这条路径自己的黑高"。而refNum是要一路作基准比对的标准值,所以用const int传值即可。- 连续红为什么"查父亲":一个结点只有一个父亲,且绝大部分结点都有父亲,判断
父亲是红就是一次比较;而查孩子要遍历两个指针、还要处理为空的情形。显然查父亲更简单、更不容易漏。 - 根结点
_parent == nullptr的判空:课件版本直接写root->_parent->_col,如果root是根(红、父亲为空),就会对空指针解引用。这里补一个root->_parent != nullptr判空,更稳健。 root == nullptr就是”一条路径走完“:凡走到空,说明这条根到叶(嵌入 NIL)的路径已全部遍历完,此刻blackNum就是这条路径的黑高,与refNum一比即知是否破坏性质 4。这就是把 NIL 当作黑色叶子来计数的落点——空处正好作“终点哨兵”。
最后套一个对外接口:先判空树、再判根是否黑,然后取最左路径的黑结点数当作参考值,递归比对整棵树。
// 对外验证接口:判断当前树是否是合法的红黑树
bool IsValidRBTree()
{
if (_root == nullptr)
return true; // 空树当然合法
if (_root->_col == RED)
return false; // 根结点必须是黑,违反性质 2
int refNum = 0; // 参考值:一条路径的黑结点数量
Node* cur = _root; // 从根出发,一直往左走
while (cur)
{
if (cur->_col == BLACK)
++refNum; // 统计最左路径的黑结点数
cur = cur->_left;
}
// 以 refNum 为基准,递归检查整棵树
return _Check(_root, 0, refNum);
}refNum 取"最左路径"的黑结点数是完全合法的基准:因为性质 4 要求所有路径黑高都相等,所以拿任意一条(最左那条)当标准即可,其余任何一条与之不同就说明违规。这块验证代码和前面 Insert 里的变色/旋转配合使用,就能在你插入任何数据后立刻自检合法性——这也是后面完整程序的核心自检手段。
验证:跑一个完整例子
光说不练假把式。下面把这个红黑树的所有部件——枚举、结点、类、左旋、右旋、插入、查找、验证、析构——完整地拼成一个单一 cpp 文件。这是可以直接复制、独立编译运行的完整程序:
#include <iostream>
#include <utility> // std::pair、std::make_pair
using namespace std;
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:
RBTree() : _root(nullptr) {}
~RBTree() // 析构:释放整棵树的结点,防止内存泄漏
{
destroy(_root);
}
bool Insert(const 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; // key 已存在
}
}
// 第二步:挂上新结点,默认红色
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 (parent == grandfather->_left) // p 是 g 的左
{
Node* uncle = grandfather->_right;
if (uncle && uncle->_col == RED)
{
// 情况一:叔叔红,只变色,继续上提
parent->_col = BLACK;
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 // p 是 g 的右(镜像)
{
Node* uncle = grandfather->_left;
if (uncle && uncle->_col == RED)
{
// 情况一(镜像):叔叔红,只变色,继续上提
parent->_col = BLACK;
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;
}
}
}
_root->_col = BLACK; // 兜底:根永远是黑的
return true;
}
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;
}
// 合法性自检
bool IsValidRBTree()
{
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);
}
int Height() const
{
return height(_root);
}
void InOrder() const
{
inOrder(_root);
cout << endl;
}
private:
void RotateL(Node* parent)
{
Node* subR = parent->_right;
Node* subRL = subR->_left;
parent->_right = subRL;
if (subRL) subRL->_parent = parent;
Node* ppNode = parent->_parent;
subR->_left = parent;
parent->_parent = subR;
if (ppNode == nullptr)
{
_root = subR;
subR->_parent = nullptr;
}
else
{
if (ppNode->_left == parent) ppNode->_left = subR;
else ppNode->_right = subR;
subR->_parent = ppNode;
}
}
void RotateR(Node* parent)
{
Node* subL = parent->_left;
Node* subLR = subL->_right;
parent->_left = subLR;
if (subLR) subLR->_parent = parent;
Node* ppNode = parent->_parent;
subL->_right = parent;
parent->_parent = subL;
if (ppNode == nullptr)
{
_root = subL;
subL->_parent = nullptr;
}
else
{
if (ppNode->_left == parent) ppNode->_left = subL;
else ppNode->_right = subL;
subL->_parent = ppNode;
}
}
bool check(Node* root, int blackNum, const int refNum)
{
if (root == nullptr)
{
// 走到空 = 一条路径走完,比对黑结点数
if (refNum != blackNum)
{
cout << "存在黑色结点数量不相等的路径" << endl;
return false;
}
return true;
}
// 连续红:反过来查父亲
if (root->_col == RED
&& root->_parent != nullptr
&& 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);
}
void destroy(Node* root)
{
if (!root) return;
destroy(root->_left);
destroy(root->_right);
delete root;
}
int height(Node* root) const
{
if (!root) return 0;
int l = height(root->_left);
int r = height(root->_right);
return (l > r ? l : r) + 1;
}
void inOrder(Node* root) const
{
if (!root) return;
inOrder(root->_left);
cout << root->_kv.first << ' ';
inOrder(root->_right);
}
Node* _root;
};
int main()
{
// 综合序列:把你的手写红黑树跑出汗
RBTree<int, int> t;
int a[] = {16, 3, 7, 11, 9, 26, 18, 14, 15};
for (auto e : a)
{
t.Insert(make_pair(e, e));
cout << "insert " << e << " -> "
<< (t.IsValidRBTree() ? "OK" : "FAIL") << endl;
}
t.InOrder(); // 按 key 有序,证明它仍是合法 BST
cout << (t.IsValidRBTree() ? "红黑树合法" : "红黑树不合法") << endl;
// 查找每个已插入的数据,都应能找到
for (auto e : a)
cout << "Find " << e << ": "
<< (t.Find(e) ? "found" : "not found") << endl;
// 查找不存在的 key
cout << "Find 100: "
<< (t.Find(100) ? "found" : "not found") << endl;
cout << "9 个结点,树高 = " << t.Height()
<< "(远小于 9,说明近似平衡生效)" << endl << endl;
// 第二组:依次触发 情况一(叔叔红->变色)、情况三(叔叔黑折线->双旋)
RBTree<int, int> t2;
int b[] = {10, 5, 15, 3, 4};
for (auto e : b)
{
t2.Insert(make_pair(e, e));
cout << "[演示] insert " << e << " -> "
<< (t2.IsValidRBTree() ? "OK" : "FAIL") << endl;
}
// 第三组:依次触发 情况一(叔叔红->变色上提)、情况二(叔叔黑直线->单旋)
RBTree<int, int> t3;
int c[] = {20, 10, 30, 5, 3};
for (auto e : c)
{
t3.Insert(make_pair(e, e));
cout << "[演示2] insert " << e << " -> "
<< (t3.IsValidRBTree() ? "OK" : "FAIL") << endl;
}
return 0;
}你可以把上面这一整段复制到同一个 .cpp 文件(比如 rbtree.cpp)里编译运行。如果每一步插入后屏幕都打印 "OK",最终又打印 "红黑树合法",那就说明我们手写的这棵红黑树经受住了考验——每一轮插入后的变色和旋转,都正好把树维持在了所有性质的约束之内。你还可以把 main 里的数组 a/b/c 换成任意你想测的序列再跑,甚至改成乱序、递增、递减、重复 key 等极端序列,观察它是否始终打印 "OK"、树高是否始终贴着对数走。
这里额外提醒几个有意思的检查点:
- 如果某一次插入后打印出 "FAIL",先别急着质疑红黑树的正确性,去核对插入逻辑里叔叔的判定和旋转方向。红黑树最经典的几个 bug 是:左右旋方向写反;"叔叔为黑"与"叔叔为红"两个分支写串;旋转时漏了维护
_parent指针;把祖父当成空判断出错。多跑几次不同的数据序列,能帮你更快地固化"叔叔决定方向"这条直觉。 - 光看"高度是否平衡"不足以佐证合法:如验证一节所说,必须正面检查全部性质,否则某些病态的染色虽暂时高度还行,迟早会在后续插入中爆雷。
- 反复插删、插入已有 key 的边界:插入已有 key 会返回
false,树结构不变,验证仍是 "OK"。这也是稳定性的体现。
如果你把段代码放进编译器却报了"找不到 std::pair",记得这是 C++ 标准模板库的类型,#include <utility> 已包含;在较老的工具链上,可能需要开启支持 C++11(如 g++ -std=c++11 rbtree.cpp)。
关于删除的一点说明
插入讲完了,你可能还惦记着删除。这里要实话实说地提醒你:红黑树的删除比插入还要复杂一个量级,而且删除的场景更多、也更隐蔽。本篇文章(本课件)有意不展开删除的实现细节。如果你真的遇到了需要手写红黑树删除的场景,建议去查阅**《算法导论》或《STL 源码剖析》**,里面把删除的四种情况和调整都讲得很系统。
为什么删除这么难?根因在这里:删除一个结点时,如果删的是红结点,红结点不影响黑高(性质 4),删除后一切照旧,代价极小;但如果删的是黑结点,就会直接让某条路径的黑高下降 1,破坏性质 4,而且这个缺口就悬在那条叶路径上——你没法靠删掉的结点给自己补黑,只能去兄弟方向"借"一个黑结点过来,或通过变色、旋转把这个缺口"顶到"更高的层去消化。而"借黑"要考虑兄弟是红是黑、兄弟的孩子是什么颜色等一系列排列组合,于是就有了经典的四五种删除调整类型(超出我们这里的范围)。这套"借黑还黑 + 旋转变色"的内功和你刚学的插入调整是镜像的——插入靠"变色 + 上抛",删除靠"借黑 + 下修",底层是同一套旋转与变色武功。
正因为插入学透了这套武功,你再去读《算法导论》的删除那一节,会发现很多代码似曾相识:判断兄弟颜色、判断侄子颜色、决定单旋还是双旋、决定要不要继续向上——跟插入的"看叔叔、看走向"如出一辙。所以我的建议是:把本节插入的三种情况、决策表、旋转逻辑彻底吃透,这比单独背删除代码更值钱。
回过头来,我们把这一路学的东西串一遍:红黑树用"每个结点一个颜色位"换来了严格的"近似平衡"保证——红色不能连、黑色要均分,两条颜色纪律联手把高度压进了 [bh, 2bh] 的两倍区间,再由引理推出 bh ≤ log₂(N+1),最终把增删查改钉死在 O(logN)。它之所以比 AVL 树旋转更少,是因为它用五条颜色性质代替了"左右子树高度差必须等于 1"这种苛刻约束,用一点点高度上的宽裕换来了旋转上的大幅节约。你在这一章里亲手实现了带颜色的结点、左右旋转、按 BST 插入,以及最重要的"叔叔红就变色上提、叔叔黑就单双旋变色"这套调整逻辑;还顺带通过性质 2/3/4 的逐条验证,把**"最长不超过最短两倍"这个结论拆成 bh ≤ h ≤ 2bh,再补上引理推出 O(logN)**。
如果你还是觉得有点绕,我给你一句生活化的总抓手:红黑树的插入调整,本质是"颜色守恒"的生意——情况一靠"把红黑平衡一路往上传(传球)",情况二、三靠"旋转 + 一换一变色,在局部当场成交"。想清楚"我在保黑高守恒、我在消灭连续红"这两个目的,那些分支就不会再让你眼花。
到这里,"带颜色的平衡树"对你而言就不再是黑盒了——你不仅会用 map、set,也终于明白它们底层那棵树上为什么藏着一套如此精密的颜色规矩,从而能如此稳定地保持对数深度。下一站,无论你继续深挖删除,还是去研究 B 树、跳表、Treap、Splay 这些邻居,这份关于"如何用一组约束换回对数高度"的思维方式,都会是你最趁手的武器。带着这五个性质、三张决策表、一次亲手运行,去征服更多数据结构吧。
还没有评论 — 第一条由你来留。