红黑树是这个系列里我最想写又最不想写的一篇。想写,是因为它是工程里真正被大规模使用的平衡结构:C++标准库的map、set以及各种关联容器,底层都围着它转;不想写,是因为它的插入删除修复逻辑实在够绕,网上资料抄来抄去,大多数文章拿A4纸画一堆树,读者盯着五个case仍然一头雾水。这篇我换个讲法:把五条性质当成一组工程约束来理解,把插入和删除修复当成“黑高守恒”的两场博弈来推演,最后给出一份可以编译运行、可以打印验证的C++实现。整个系列从普通二叉树一路走到这里,想彻底弄懂红黑树的读者,恰好需要这种从源头推导、不靠背诵case的路线。
1. 从二叉搜索树到红黑树:为什么“差不多平衡”就够了
1.1 二叉搜索树的退化问题,以及AVL树带来的线索
普通二叉搜索树(BST)的查找性能完全依赖插入顺序。碰巧按升序插入,树就退化成一根链表,查找变成O(n)。系列前面的文章里我演示过那种退化现场:插一万个节点,查找最后一个元素要比较九千多次,性能直接从“教科书最优”跌到“暴力遍历”。要解决这个问题,本质上是让树在插入、删除之后保持近似均匀的高度,于是有了各种自平衡方案。
AVL树是其中最直接的一种:它要求每个节点左右子树高度差绝对值不超过1,一旦超过就旋转纠正。这个规则能保证树高严格等于O(log n),查找最坏情况非常稳定。但代价是插入删除后触发旋转的频率太高。现实中有些场景是“写少读多”,AVL很合适;有些场景是“写多读也多”,那频繁旋转的开销就得认真掂量了。
1.2 红黑树的折中:放松“高度”约束,锁定“黑高”约束
红黑树选择了另一种约束:它不要求左右子树在一个节点内严格平衡,而是只要求全局路径上的黑色节点数一致。这个约束看起来比AVL松,却能保证任意一条路径最长不超过最短路径的两倍。注意,两倍的差距依然是O(log n)级别的性能保证,却把插入删除后的调整次数降了下来,工程上往往更划算。
很多初学者疑惑:既然AVL更“平衡”,为什么工业实现里红黑树反而更常见?答案不在“谁更平衡”,而在“谁的综合开销更低”。红黑树牺牲了那一点点查找的极致高度,换来了更少的调整次数、更简单的删除逻辑,以及更容易保持缓存局部性的结构形态。C++标准库的map、set长期采用红黑树,说明这套取舍经得起真实负载的检验。
讲完演进逻辑,下面进入性质本身的解读。
2. 红黑树的五条性质,到底在约束什么
2.1 逐条拆开看
我习惯把红黑树的性质分成两层:颜色规则和黑高规则。
- 节点非红即黑,这是定义;
- 根节点是黑色,保证整棵树的“基准”是黑的;
- 所有外部叶子(NIL)是黑色,这是边界条件;
- 红色节点不能有红色孩子;
- 任意节点到其后代叶子的所有路径上,黑色节点数量相同。
第四条和第五条组合起来,才是真正意义上的“工程约束”核心。第四条直接限制了红节点的位置密度,第五条锁定了黑节点分布的均匀性。两个约束合在一起,最长路径只能在一红一黑之间交替,长度最多是最短全黑路径的两倍。
2.2 黑高才是真正的平衡基准
红黑树里有一个被很多教程轻描淡写的概念:黑高(black-height)。某个节点的黑高,是从这个节点出发、走到任一NIL叶子所经历的黑色节点个数,不包括该节点自身。五条性质中的第五条本质上就是说:所有叶子的黑高必须相等。
理解了黑高之后,再看插入和删除的修复逻辑会豁然开朗:所有的变色、旋转、双黑处理,本质上都是在努力恢复全局黑高一致。AVL的旋转为的是恢复局部高度差,红黑树的修复为的是恢复黑高,这两种“平衡”的发力点完全不同。
2.3 为什么最长路径不超过最短路径的两倍
这个结论可以从颜色规则直接推出:因为红色节点不能连续出现,所以最长路径上红黑节点一定交替排列;又因为所有路径黑节点数相等,最长路径中的黑色节点数量等于最短路径的黑色节点数量。若最短路径全是黑色,而最长路径把每个黑节点之间都插入一个红节点,长度最多翻倍。这个两倍上界依然属于O(log n)量级,但调整代价比AVL低很多。
很多资料上来就让你背五条性质,但性质不是用来背的。它们其实是设计红黑树时定下的不变量,所有插入删除代码都要保证这组不变量持续成立。后面讲代码时,你会看到我的验证函数几乎就是把五条性质翻译成了递归检查。
3. 插入:从染红出发,叔叔节点决定下一步
3.1 新节点为什么是红色
在BST常规插入位置放一个新节点时,如果把它染成黑色,会直接破坏第五条性质,导致该路径黑高比兄弟路径多1,需要大范围重排;如果染成红色,唯一可能破坏的是第四条(红色不能接红色孩子),这个问题的修复成本低很多。所以几乎所有实现都先把新节点染红。
这时如果父节点是黑色,插入就结束了,因为红节点接在黑节点下面,没有任何性质被破坏。只有父节点也是红色时,才进入修复循环。
3.2 修复循环的三个Case
修复的核心是找到红色节点的祖父,然后看叔叔节点的“脸色”。为方便叙述,用z代表当前红色节点,p是其父,g是祖父。
- 叔叔为红色:把p和叔叔都染黑,把g染红,然后让z指向g继续向上检查。这一步不旋转,只变色,把红红冲突向上推了两层,同时保持子树内部黑高不变。
- 叔叔为黑色,且z是p的右孩子:这是LR形状,先对p做一次左旋,把z转到外侧,形态就变成下一个case。
- 叔叔为黑色,且z是p的左孩子:这是LL形状,直接对g右旋,然后p染黑、g染红,平衡完成。
这三个case写成代码后会发现异常对称,左右镜像互相复制粘贴即可。判断叔叔颜色时要注意,NIL叶子也是黑色,所以空指针要当成黑处理。
3.3 插入的C++实现
下面是我实现的核心。为了后面验证方便,我把根节点、旋转和修复函数统一放在类里管理:
#include <iostream> enum Color { RED, BLACK }; template <typename K> struct RBNode { K key; Color color; RBNode *left, *right, *parent; RBNode(K k) : key(k), color(RED), left(nullptr), right(nullptr), parent(nullptr) {} }; template <typename K> class RBTree { private: RBNode<K>* root; static bool isRed(RBNode<K>* n) { return n != nullptr && n->color == RED; } void leftRotate(RBNode<K>* x) { RBNode<K>* y = x->right; x->right = y->left; if (y->left) y->left->parent = x; y->parent = x->parent; if (!x->parent) { 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(RBNode<K>* y) { RBNode<K>* x = y->left; y->left = x->right; if (x->right) x->right->parent = y; x->parent = y->parent; if (!y->parent) { root = x; } else if (y == y->parent->left) { y->parent->left = x; } else { y->parent->right = x; } x->right = y; y->parent = x; } void insertFixup(RBNode<K>* z) { while (isRed(z->parent)) { RBNode<K>* g = z->parent->parent; if (z->parent == g->left) { RBNode<K>* uncle = g->right; if (isRed(uncle)) { z->parent->color = BLACK; uncle->color = BLACK; g->color = RED; z = g; } else { if (z == z->parent->right) { z = z->parent; leftRotate(z); } z->parent->color = BLACK; g->color = RED; rightRotate(g); } } else { RBNode<K>* uncle = g->left; if (isRed(uncle)) { z->parent->color = BLACK; uncle->color = BLACK; g->color = RED; z = g; } else { if (z == z->parent->left) { z = z->parent; rightRotate(z); } z->parent->color = BLACK; g->color = RED; leftRotate(g); } } } root->color = BLACK; } public: RBTree() : root(nullptr) {} void insert(K k) { RBNode<K>* z = new RBNode<K>(k); if (!root) { root = z; root->color = BLACK; return; } RBNode<K>* cur = root; RBNode<K>* par = nullptr; while (cur) { par = cur; if (k < cur->key) cur = cur->left; else cur = cur->right; } z->parent = par; if (k < par->key) par->left = z; else par->right = z; insertFixup(z); } // 其余接口:erase、verify、中序遍历等 };3.4 推演一个真实插入序列
拿“3, 1, 2”这个序列走一遍:插入3,根黑;插入1,直接是3的左红孩子,无冲突;插入2,父节点1是红,出现了LR形态,叔叔是空节点(黑),于是先左旋1再右旋3,最终根是2黑、左右孩子是1红和3红。这一轮旋转加变色的结果,树高只有2,完全符合红黑树性质。看起来步骤多,实际执行就是几次指针重连,比AVL的反复高度判断要安稳得多。
注意,insertFixup每次循环结束时都执行root->color = BLACK,这不是多余代码,而是为了防止第二种情况把红色一路推到根上。调红黑树的人基本都栽过这一下。
4. 删除:一场围绕“黑高守恒”的博弈
4.1 先做BST删除,再谈颜色修复
删除节点分三种情形:无孩子、一个孩子、两个孩子。前两种好办,直接摘掉或用子节点顶上;两个孩子时,按BST常规做法,找中序后继节点的值来覆盖,再删除那个后继。因为后继必然至多有一个右孩子,所以所有删除最终都落到“删除一个至多有一个孩子的节点”上。
麻烦的是颜色层面的连锁反应。如果删除的是红色节点,性质不会被打乱,直接完事;如果删除的是黑色节点,那么经过该位置的路径会少一个黑色,黑高被破坏。这时我们说要修补一个“双黑”节点:逻辑上想象删除后的位置残留了一个黑色的幽灵标记,它让该路径上的黑色计数看起来和其他路径一致,修复的目标就是把这个幽灵消化掉。
4.2 双黑修复的四类情况
用x表示需要修复的双黑节点,w表示x的兄弟。核心观察是:无论哪种情况,都要保证修复前后,从父节点出发往下看的所有路径黑高不变。
- 情形1,w是红色:把w染黑、父节点染红,再绕父节点旋转,使新的兄弟变成黑色,问题转入后续情形。
- 情形2,w是黑色,且w的两个孩子都是黑色:直接把w染红,双黑上移到父节点。如果父节点原来是红色,就顺势变黑,修复结束;否则继续向上循环。
- 情形3,w是黑色,且w的远端孩子是黑色(即近端孩子是红色):先绕w旋转,把红色孩子转到远端一侧,同时交换w与近端孩子颜色,把问题转化为情形4。
- 情形4,w是黑色,且w的远端孩子是红色:这是收尾case,把父节点颜色赋给w,父节点染黑,远端孩子染黑,再绕父节点旋转,最后双黑消失,循环结束。
如果你觉得这些情形绕,我的经验是只死记两条:第一,看兄弟颜色,红色转黑;第二,看兄弟孩子的颜色分布,双黑就上移兄弟,单红就转兄弟,远红就转父亲收尾。
4.3 删除修复的代码骨架
void deleteFixup(RBNode<K>* x) { while (x != root && !isRed(x)) { if (x == x->parent->left) { RBNode<K>* w = x->parent->right; if (isRed(w)) { // Case 1 w->color = BLACK; x->parent->color = RED; leftRotate(x->parent); w = x->parent->right; } if (!isRed(w->left) && !isRed(w->right)) { // Case 2 w->color = RED; x = x->parent; } else { if (!isRed(w->right)) { // Case 3 w->left->color = BLACK; w->color = RED; rightRotate(w); w = x->parent->right; } w->color = x->parent->color; // Case 4 x->parent->color = BLACK; w->right->color = BLACK; leftRotate(x->parent); x = root; } } else { // 对称逻辑 } } if (x) x->color = BLACK; }注意!isRed(w->left) && !isRed(w->right)的判断是有陷阱的:当兄弟节点为空指针时,w->left会直接解引用崩溃。所以删除逻辑里通常需要让被删除位置的替代节点不为空,或者统一使用NIL哨兵节点来规避空指针判断。两种方案都可以,但一定要保持一致,否则bug会很隐蔽。
4.4 删除为什么比插入难理解
插入的修复是自底向上的,冲突点就在红色的父子对,找叔叔的视线很明确;删除的修复却要在删除点附近反复转化形态,每一步都可能把双黑推给上一层,直到推到根节点或者遇到红色节点才收手。所以大家都说红黑树“插入半小时,删除一整天”。真正难的不是某一种情况,而是四类情形之间的转换顺序:顺序错了,黑高守恒就断了,验证函数会在某个隐蔽路径上报告“黑高不一致”。
5. 代码落地:节点结构、旋转与验证函数
5.1 为什么需要parent指针
网上红黑树教程里的旋转代码满天飞,但能正确处理parent指针的不多。普通BST的旋转不需要父亲指针,因为只是调整子树内部关系;红黑树的删除修复需要不断自底向上访问父节点和兄弟节点,所以每个节点必须持有一个parent指针。很多新手在这一步偷懒,删除修复跑起来才发现无法回到父层级,只能临时另写查找函数去找父节点,既难看又慢。
合理节点结构就是前面代码里那四个字段:key、color、left、right、parent。模板参数K表示键类型,颜色用枚举常量。这里没有value字段,实际项目里只需要在节点里再挂一个映射值即可,结构思想完全一致。
5.2 旋转函数踩过的指针坑
左旋和右旋是对称的,但指针更新顺序非常致命。我习惯的顺序是:
- 先处理“中间孩子”的挂接;
- 再处理旋转轴的parent指针指向原父节点;
- 接着处理原父节点与其父节点的连接关系;
- 最后把旋转轴和原父节点互相挂接。
只要你把孩子、父、祖父三条链的更新顺序搞乱一次,插入若干节点后就会出现环,验证函数一跑就是无限递归或栈溢出。处理这种bug最快的办法不是肉眼看代码,而是写一个中序遍历打印函数,把每个节点的key、颜色、左右孩子都打出来,再对照纸面推演。
5.3 验证函数:红黑树调通的救星
所有红黑树实现都应该配备一个验证函数,用来确认五条性质没有被破坏。下面是我常用的递归验证逻辑:
bool checkProperty(RBNode<K>* n, int blackCount, int& pathBlack, bool& violation) { if (!n) { if (pathBlack < 0) pathBlack = blackCount; else if (pathBlack != blackCount) violation = true; return !violation; } if (isRed(n) && isRed(n->left)) violation = true; if (isRed(n) && isRed(n->right)) violation = true; int next = blackCount + (n->color == BLACK ? 1 : 0); checkProperty(n->left, next, pathBlack, violation); checkProperty(n->right, next, pathBlack, violation); return !violation; } void verify() { bool violation = false; int pathBlack = -1; checkProperty(root, 0, pathBlack, violation); if (root && root->color != BLACK) violation = true; if (violation) std::cout << "violation!\n"; else std::cout << "ok\n"; }这个函数足够检查到绝大多数问题。随机插入、随机删除之后跑一遍,能快速定位是哪个性质被破坏,效率远超人工看图。以后你写任何复杂数据结构,我都很建议把“验证器”和“被测对象”一起写出来,调试时会省下无数时间。
5.4 中序遍历与合法性检查
验证红黑树之前,先验证它是不是一棵合法的二叉搜索树。中序遍历输出所有key,如果结果严格递增,说明BST结构没问题,旋转细节基本没写错;如果出现乱序,那多半是旋转时把孩子挂错了方向。红黑树的所有修复操作都建立在BST结构不被破坏的前提之上,所以这个检验顺序不能颠倒。
6. 踩坑记录:为了这套代码,我损失了三个晚上
6.1 坑一:恢复根节点颜色
插入修复的循环可能把红色一路推到根。不少实现忘了循环结束后把root->color = BLACK,结果整棵树根是红色,性质2被破坏。最坑的是这种错误不会在插入时立刻暴露,只有连续随机操作后才偶发。我后续专门在验证函数里加了根节点颜色检查,才把这个问题一键检出。
6.2 坑二:旋转后父指针断链
我第一次实现rightRotate时先更新了父子指针,再更新中间孩子,结果删除第二个节点就崩了。排查过程:在中序打印里发现某些节点的parent指针指向了错误的节点,导致删除流程把整棵子树给弄丢了。经验是旋转完成后立刻做一组断言:每个非空节点的left、right的parent都必须指向自己。靠这套断言,后面所有指针维护问题在测试阶段就被拦截了。
6.3 坑三:删除修复的循环条件想当然
把while (x != root && !isRed(x))写成while (!isRed(x)),当x被推成root时继续进入循环体,访问父节点的父节点出现解引用错误。红黑树删除修复循环的终止条件必须同时判断“不是根”和“当前为双黑”,少一个都会出错。这类边界条件测试起来非常容易漏,所以压力测试中要覆盖删除根节点、删除到空树这种极端路径。
6.4 推荐的测试路径
我调试这套红黑树的固定流程是:先insert一堆随机数,insert完成后立即verify并中序打印;再随机erase一半节点,每次erase后都verify;最后反复随机插入删除若干万次,中间穿插内存泄漏检查。整个过程跑得越久,对实现越有信心。等到真需要把红黑树放进生产代码时,这种压力测试才是唯一能依赖的底气。
到这里,红黑树的实现脉络就完整了。最后分享一句我写完整棵树之后的感受:红黑树真正难的不是五个case,而是你要在写代码之前就理解“黑高守恒”这四个字。理解了守恒,插入修复和删除修复的每一步都有了验收标准;不理解守恒,只能靠背诵case过活,一旦边界条件变化就会露怯。如果你也是从二叉树一步步学到这里的读者,建议照着这份代码逐行跑通,再用随机数据折磨它,等它能在十万次操作后依然通过verify,你对C++指针操作和递归的理解都会明显上一个台阶。后续我可能继续写红黑树在实际场景里的应用,到时再和大家细聊。