最近在准备给一个学弟讲STL源码,翻到std::map底层那段_Rb_tree的时候,他问我:“这红黑树到底难在哪,为什么网上教程全是‘看图理解’,一到自己写就废?” 我回想了一下自己两年前从C++开始写红黑树的过程,确实有点话想说。红黑树这个东西,理论上有五大性质,插入有四种情况,删除有八种情况,听起来像劝退现场。但如果你从“它到底想解决什么问题”出发,先把动机想透,再把每一种情况为什么那样处理想透,红黑树其实是三大平衡树(AVL、红黑树、B树)里最适合手写一遍、也最能让你理解“自平衡到底在平衡什么”的结构。
这篇是“从C++开始的编程生活”系列的第22篇,我会用C++把红黑树从节点定义、旋转、插入修复到删除修复完整写一遍,每一段代码都讲清楚背后的判断依据。文章不追求那种“半小时学会红黑树”的压缩饼干式教学,而是尽量还原我踩坑、试错、最后跑通的全过程。适合已经掌握二叉树和C++类封装,想真正把红黑树啃下来的朋友;如果你是要应付GESP三级或面试算法基础,这篇的插入删除流程也能让你从“背结论”升级为“推结论”。
1. 为什么非要是红黑树:从二叉树到自平衡的演进
1.1 二叉搜索树的天生缺陷
先回到最基础的问题:我们为什么要一棵“平衡”的搜索树?普通二叉搜索树(BST)插入、查找、删除的平均时间复杂度是O(log n),但那个“平均”有个前提——树得接近满二叉树。一旦你按有序序列插入节点,比如依次插入1、2、3、4、5……这棵树会退化成一个只有右孩子链,高度直接变成n,查找一个节点要遍历整条链,时间复杂度退化到O(n)。你写一个数据量为百万级的数据库索引,插入一组有序ID,整棵树直接变成链表,那还玩什么。
所以问题的核心是:在BST的基础上,通过某种规则在插入/删除后对树进行局部调整,让树的高度始终保持在O(log n)级别。这个调整就是“自平衡”。
1.2 AVL树为什么“太严了”
先看一眼AVL树,它的平衡条件是:任意节点的左右子树高度差绝对值不超过1。这个条件非常强,几乎让树永远保持严格的全满形态。好处是查找性能在最坏情况下都极其稳定,坏处是——为了维持这个严格的平衡,插入和删除时往往需要大量旋转操作。我当年用AVL做实验,连续插入有序数据时,几乎每插入两三个节点就要旋转一次。旋转本身是O(1),不改变复杂度级别,但实际工程里旋转次数多了,加上树的形态变化剧烈,性能开销和缓存局部性损失是实打实的。
红黑树换了一种思路:不要求高度严格相等,而是用颜色约束“树的左右子树高度差在一个可控范围内”。代价是查找时最坏高度比AVL略高(最高约为2log₂(n+1)),但换来的是更少的旋转次数和更稳定的插入删除性能。STL和Linux内核不约而同都选择了红黑树,不是没道理的。
1.3 红黑树到底“平衡”了什么
红黑树不需要左右子树高度严格一致,而是要保证任何一条从根到叶子(NIL)的路径上,黑色节点的数量相同,且红色节点的连续出现受到限制。这两条约束合在一起,就形成了一个非常实用的平衡效果:最长路径不会超过最短路径的2倍。
怎么理解这个“2倍”?因为最短路径全是黑色节点,最长路径是在黑节点之间插入红节点串起来的,但红节点不能连续,所以任意路径上红节点数量至多等于黑节点数量。黑高一样的前提下,最长路径最多是2倍最短路径。这个平衡程度虽然不如AVL的“高度差不超过1”,但已经足够保证任何搜索、插入操作都走了O(log n)路径——因为高度被限定在2log₂(n+1)以内,证明用到了黑高的性质,后面我会展开算。
红黑树的另一个工程优势是旋转局部性强。插入修复时最多做2次旋转,删除修复时最多做3次旋转,其余变化都是变色。这比AVL那种“调整后可能一路传递到根”的场景更可控。STL的std::map为什么选红黑树而不选AVL,正是因为它在“查询性能略损一点点”和“插入删除性能显著提升”之间找到了最佳的工程折中。你去看 libstdc++ 的源码,_Rb_tree那一整套实现,就是红黑树的第一手工程样本。
2. 五个性质读一遍,不如亲手画一张图
2.1 五条性质的逐条拆解
红黑树的五条性质,教科书上写得很简洁,但初学的时候完全不知道每条性质在干什么。我用我自己的理解方式重新说一遍:
- 每个节点要么是红色,要么是黑色。
- 根节点是黑色。
- 叶子节点(NIL)是黑色。注意,这个“叶子”不是我们平时说的左右孩子为空的节点,而是所有空指针统一视作一个黑色NIL节点。在C++实现里,你用
nullptr表示NIL,所有人都会自觉认为nullptr是黑的。 - 如果一个节点是红色,那么它的两个孩子都是黑色。换句话说,红色节点的父节点不能是红色,红节点不能连续出现。
- 从任意节点到其每个后代叶子(NIL)的路径上,经过的黑色节点数量相同。这个相同数量就叫黑高。
这几个性质为什么要这样设计?性质4保证了不会出现“红色长链”,性质5保证了每条路径的黑色“骨架”是均衡的。两个合在一起,最长路径不超过最短路径2倍的结论就出来了。
2.2 黑高与树高的数学关系
把NIL当成黑叶子之后,黑高记作 bh(T)。假设一棵红黑树的高度是h,我们要证明 h ≤ 2log₂(n+1)。第一步,先证明“以x为根的子树至少包含 2^bh(x) - 1 个内部节点”。用数学归纳法:高度为0时节点数0 = 2^0 - 1;对任意节点x,两个孩子(如果有)的黑高要么是bh(x),要么是bh(x)-1(当x是红节点时),但绝不会小于bh(x)-1。于是整棵子树节点数 ≥ (2^(bh(x)-1) - 1) + (2^(bh(x)-1) - 1) + 1 = 2^bh(x) - 1。
第二步,因为根的黑高至少是 h/2(红节点至多占一半,且不能连续),所以 n ≥ 2^(h/2) - 1,解得 h ≤ 2log₂(n+1)。这个证明不用背,关键是理解:黑高是红黑树保持平衡的“骨架”,而红色节点只是骨架上的“装饰”,装饰再密也不能超过骨架的一半长度。我当年把这些性质手抄在纸上,每个性质画一棵小树验证,很快就从“背性质”变成“理解性质”了。
2.3 一个手画示例:将 10、20、30、40、50、60 依次插入
为了直观,我举个具体例子。依次插入 10、20、30、40、50、60 这些值,如果用普通BST,这棵树会歪成一条只有右孩子的链,高6层。红黑树插入时会怎么处理?
- 插入10:根节点,染黑。
- 插入20:比10大,成为右孩子。新节点默认红色,父节点10是黑色,无需调整。
- 插入30:成为20的右孩子。20是红色,违反性质4。此时叔叔(10的另一个孩子NIL)是黑色,进入“叔叔为黑+LR/LL”分支,对10左旋,再把10染红、20染黑。树变成20为根,左右孩子各10和30。
- 插入40:父30是红色,叔叔10是红色,直接变色:20变红,10和30变黑。检查根20的父是NIL且为黑,但根不能是红,把20再染黑。此时10、20、30、40四个节点的树黑高一致。
- 插入50:父40红,叔叔NIL黑,对30左旋,再变色。树形态逐步向平衡靠拢。
- 插入60:情况类似,需要变色+旋转。
这个过程你手动画一遍,会非常清晰地看到:变色是最先尝试的手段,只有在变色解决不了结构问题时才动用旋转。这和我们人脑想问题的思路完全一致——能局部调整解决的,不要大动干戈。
3. 插入:变色、旋转与四种情况的完整推导
3.1 节点结构与基础操作
在写插入修复之前,先把C++节点结构和旋转写好。为了方便调试和后续删除操作,我选择带父指针的节点,并用一个成员nil作为统一的空叶子表示。这样写代码清晰,但代价是修改时得格外小心,所有nullptr都要替换成this->nil。
#include <iostream> enum Color { RED, BLACK }; template <typename T> struct Node { T data; Color color; Node* parent; Node* left; Node* right; Node(const T& val, Color c, Node* p, Node* l, Node* r) : data(val), color(c), parent(p), left(l), right(r) {} }; template <typename T> class RedBlackTree { public: RedBlackTree() { nil = new Node<T>(T(), BLACK, nullptr, nullptr, nullptr); root = nil; } void insert(const T& val) { Node<T>* y = nil; Node<T>* x = root; while (x != nil) { y = x; if (val < x->data) x = x->left; else x = x->right; } Node<T>* z = new Node<T>(val, RED, y, nil, nil); if (y == nil) root = z; else if (z->data < y->data) y->left = z; else y->right = z; z->parent = y; insertFixup(z); } // 中序遍历,验证结构 void inorder() { inorder(root); } private: Node<T>* root; Node<T>* nil; void inorder(Node<T>* x) { if (x != nil) { inorder(x->left); std::cout << x->data << "(" << (x->color == RED ? "R" : "B") << ") "; inorder(x->right); } } void rotateLeft(Node<T>* x) { Node<T>* 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 rotateRight(Node<T>* x) { Node<T>* 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<T>* z) { while (z->parent->color == RED) { if (z->parent == z->parent->parent->left) { Node<T>* y = z->parent->parent->right; // 叔叔 if (y->color == RED) { z->parent->color = BLACK; y->color = BLACK; z->parent->parent->color = RED; z = z->parent->parent; } else { if (z == z->parent->right) { z = z->parent; rotateLeft(z); } z->parent->color = BLACK; z->parent->parent->color = RED; rotateRight(z->parent->parent); } } else { Node<T>* y = z->parent->parent->left; // 叔叔 if (y->color == RED) { z->parent->color = BLACK; y->color = BLACK; z->parent->parent->color = RED; z = z->parent->parent; } else { if (z == z->parent->left) { z = z->parent; rotateRight(z); } z->parent->color = BLACK; z->parent->parent->color = RED; rotateLeft(z->parent->parent); } } } root->color = BLACK; } };3.2 为什么新节点默认是红色
插入新节点时,我把节点颜色设为红色,而不是黑色。这个选择直接影响性质5(黑高相等)。如果新节点是黑色,那么它所在的这条路径凭空多出一个黑节点,整条路径的黑高都变了,需要修复的范围会扩散到树的其他分支。但如果新节点是红色,性质5不会被破坏,唯一可能违反的是性质4(红节点不能连续出现)。而性质4一旦被违反,只需要沿着“父节点是红色”的路径向上回溯修复,修复范围被牢牢控制在从新节点到根的局部区域。一句话:红色节点把破坏面最小化,让修复问题局部化。这是红黑树设计的智慧所在。
3.3 四种情况分类的底层逻辑
插入修复的循环条件是while (z->parent->color == RED)。既然父节点是红色,祖父节点一定是黑色(红不连红),真正导致性质4被破坏的根源就是“父红+叔红”或“父红+叔黑”两种大分支。每种大分支内部再根据“z是父亲的左孩子还是右孩子”进一步分子情况。所以网上常说的“插入四种情况”,本质上是这个分类逻辑的自然展开:
| 情况编号 | 父节点 | 叔叔节点 | z的位置 | 处理方式 |
|---|---|---|---|---|
| 1 | 红 | 红 | 任意 | 父、叔变黑,祖父变红,z上移两级 |
| 2 | 红 | 黑 | 右(内侄) | 对父节点旋转,转化为情况3 |
| 3 | 红 | 黑 | 左(外侄) | 祖父变红,父变黑,祖父旋转 |
情况1是“局部变色”,把红节点往上传,直到不再违反性质4。情况2和3是“旋转+变色”,本质上是把“连续红节点”的结构重新分布,让红节点从属于不同路径,从而把黑色骨架拉直。
3.4 为什么叔叔是红色时用变色,是黑色时用旋转
这个区别要理解透。叔叔红色意味着“当前局部区域的黑节点数足够多,可以容纳更多红节点”——祖父本来是黑的,把祖父染红,父和叔染黑,黑高不变,性质4在局部恢复。叔叔黑色则意味着一侧的黑高比另一侧多,光靠变色解决不了“祖父一侧路径黑多、另一侧黑少”的不平衡,必须通过旋转把多出来的黑色节点挪到对侧去。每次旋转前先做一次小旋转(情况2),把“弯曲的红链”拉直成“直线红链”(情况3),再对祖父旋转,这样一次大旋转就能把两个红孩子分配到两边,同时恢复黑高。
我调试红黑树最深的感受是:变色是在“同层”交换颜色,旋转是在“跨层”改变结构。当局部结构无法用变色平衡时,就要靠旋转重新分配黑高。能变色的先变色,变不了色再旋转,这个优先级顺序贯穿了所有修复逻辑。
4. 删除:比插入绕十倍,但掌握套路就没那么难
删除之所以比插入复杂,是因为删除一个节点会直接破坏性质5(黑高相等)。插入时新节点是红色,黑高天然不受影响;删除时你删掉的节点可能是黑色,这等于在一条路径上永久砍掉了一个黑节点,靠变色已经无法恢复。
4.1 删除分两个阶段:BST删除 + 红黑修复
第一阶段和普通BST删除完全一致:找后继节点,然后把后继的值拷贝到删除位置(或者直接移动节点指针),本质上是把“删除目标”转化为“删除一个最多只有一个孩子的节点”。第二阶段才进入红黑修复,修复的目标就是处理“被删节点是黑色”造成的黑高缺失。
我把“被删节点是黑色”导致的缺失继续往下推:删除后占据删除位置的节点(可以是后继节点或者原位置的孩子)被看作“携带双重黑色”(double black)。这个概念初看很抽象,我就是为了理解它,专门把删除代码跑了几十遍:双重黑不是真正的颜色,而是一种标记,表示这个节点沿路径的黑高比其他位置多承担了一个“债务”。修复的过程就是不断向父节点、兄弟节点“转移债务”,直到遇到一个能一次性平账的节点。
4.2 删除修复的四种情况
先看关键代码,这是删除的核心部分:
void deleteFixup(Node<T>* x) { while (x != root && x->color == BLACK) { if (x == x->parent->left) { Node<T>* w = x->parent->right; // 兄弟 if (w->color == RED) { // 情况1:兄弟是红色,把兄弟变黑,父变红,左旋父 w->color = BLACK; x->parent->color = RED; rotateLeft(x->parent); w = x->parent->right; } if (w->left->color == BLACK && w->right->color == BLACK) { // 情况2:兄弟是黑色,且孩子全黑,兄弟变红,债务上移 w->color = RED; x = x->parent; } else { if (w->right->color == BLACK) { // 情况3:兄弟是黑,右侄黑,左侄红,先转成情况4 w->left->color = BLACK; w->color = RED; rotateRight(w); w = x->parent->right; } // 情况4:兄弟是黑,右侄是红,一次旋转+变色解决 w->color = x->parent->color; x->parent->color = BLACK; w->right->color = BLACK; rotateLeft(x->parent); x = root; } } else { // 对称逻辑 Node<T>* w = x->parent->left; if (w->color == RED) { w->color = BLACK; x->parent->color = RED; rotateRight(x->parent); w = x->parent->left; } if (w->right->color == BLACK && w->left->color == BLACK) { w->color = RED; x = x->parent; } else { if (w->left->color == BLACK) { w->right->color = BLACK; w->color = RED; rotateLeft(w); w = x->parent->left; } w->color = x->parent->color; x->parent->color = BLACK; w->left->color = BLACK; rotateRight(x->parent); x = root; } } } x->color = BLACK; } void remove(const T& val) { Node<T>* z = root; while (z != nil) { if (val < z->data) z = z->left; else if (val > z->data) z = z->right; else break; } if (z == nil) return; Node<T>* y = z; Node<T>* x = nullptr; Color y_origin_color = y->color; if (z->left == nil) { x = z->right; transplant(z, z->right); } else if (z->right == nil) { x = z->left; transplant(z, z->left); } else { y = minimum(z->right); y_origin_color = y->color; x = y->right; if (y->parent == z) { x->parent = y; } else { transplant(y, y->right); y->right = z->right; y->right->parent = y; } transplant(z, y); y->left = z->left; y->left->parent = y; y->color = z->color; } delete z; if (y_origin_color == BLACK) deleteFixup(x); }4.3 四种情况的逻辑推导与记忆方法
先理解分类依据:修复时,x是那个“带债务”的节点,核心判断对象是它的兄弟节点w。
- 情况1:兄弟是红色。红色兄弟说明两边黑高悬殊明显,没法直接局部修。我们把兄弟染黑、父染红、然后旋转父——这一步不直接解决问题,但把兄弟变成了黑色,并让新的兄弟变成原来兄弟的儿子,从而把问题收敛到“兄弟是黑色”的情况。我把这步叫作“换一个兄弟再来谈”。
- 情况2:兄弟是黑色,且兄弟两个儿子都是黑色。这种情况说明兄弟这侧“没有多余的黑节点可以借出来”,那就把兄弟染红(相当于兄弟这侧黑高减1),让债务上升到父节点,然后把父节点当作新的x继续向上处理。
- 情况3:兄弟是黑色,兄弟的近侄是红色,远侄是黑色。这个形态没法直接旋转,先把近侄通过旋转变成远侄红的情况,从而转到情况4。这步是纯粹的“姿态转换”。
- 情况4:兄弟是黑色,兄弟的远侄是红色。这是唯一能真正“平账”的情况:把父节点颜色赋给兄弟,父染黑、远侄染黑、再旋转父。旋转后,佩戴债务的节点获得一个额外的黑节点债主,债务清零,整个循环结束,
x = root。
记忆口诀可以这样总结:“红兄换人,黑兄子全黑则上移,近侄红先旋转,远侄红则平账。” 这四个情况对应着思考问题的顺序:先排除红兄弟,再排除全黑侄,再调整内侄,最后靠外侄收尾。
4.4 一个删除案例的完整验证
假设树里有节点序列 {10, 20, 30, 40, 50, 60},红黑树形态是:20为根(黑),10和30是它的两个孩子(红、黑不定细节),其他节点按层分布。删除40,而40是某个红色节点,那么黑高不变,不需要修复。测试时要专门挑“黑节点删除”来触发修复路径,比如先构造一棵全是黑节点的树(插入1、2、3、4、5后连续变色),然后删除根节点,你会发现这几乎是删除修复最复杂的路径——兄弟节点为黑、侄子全黑、父节点需要递归向上修正的多层链条全都会走一遍。
调试实践:我每次删除一个节点,都写一个验证函数,遍历树检查五条性质是否全部满足,如果违反正则打印节点路径。这个函数虽然跑起来慢,但比人眼盯着几十个节点的颜色靠谱得多。后面第6节我会给出验证思路。
5. 从课堂到战场:红黑树在STL与真实项目中的样子
5.1 std::map与std::set其实就长这样
你每天用的std::map、std::set,在 libstdc++ 里就是_Rb_tree的一层包装。std::map的元素是pair<const Key, T>,std::set的元素是Key,底层同一套红黑树。用红黑树而不是哈希表,核心原因是有序性。哈希表的查找是O(1),但你要做范围查询lower_bound、upper_bound、找最小最大值、顺序遍历,哈希表就麻烦了。红黑树天然支持有序遍历,begin()是树的最左节点,迭代器++走中序后继。这就是为什么数据库索引和关联容器更多选择树结构而不是哈希结构。
STL源码里有一个细节值得注意:STL红黑树并不是用nullptr表示NIL叶子,而是用一个指向header哨兵节点的指针。header的父指针指向根节点,左指针指向树的最小节点,右指针指向最大节点。这样做的好处是begin()和rbegin()直接通过header->left和header->right拿节点,无需从根开始查找。这个设计是工程上对教科书红黑树的一个经典优化,面试提到STL红黑树时能说出这一点,会让人觉得你真的去看过源码而不是只背过性质。
5.2 封装一个最小可用的红黑树容器
学完红黑树,最大的成就感来自把它封装成一个可以替代std::set的最小容器。我当时折腾了一个周末,写了insert、remove、find、中序遍历、以及一个简易的迭代器。关键点在于:迭代器的operator++要用“右孩子非空则找右子树最左节点,否则向上找到第一个不是右孩子的祖先”,这套逻辑和STL的实现思路一致。
class iterator { public: Node<T>* node; Node<T>* nil_; iterator(Node<T>* n, Node<T>* nil) : node(n), nil_(nil) {} const T& operator*() const { return node->data; } iterator& operator++() { if (node->right != nil_) { node = node->right; while (node->left != nil_) node = node->left; } else { Node<T>* p = node->parent; while (p != nil_ && node == p->right) { node = p; p = p->parent; } node = p; } return *this; } bool operator==(const iterator& other) const { return node == other.node; } bool operator!=(const iterator& other) const { return node != other.node; } };封装完成后,我写了一个随机测试程序:随机生成10万个整数,插入我的红黑树容器,再逐个查找、删除,过程中每个操作后都调用性质验证函数。最终跑过一个晚上无崩溃,那种成就感比看任何教程都强。
5.3 红黑树、AVL树与跳表的选型对比
如果你在真实项目里需要一棵自平衡搜索树,怎么选?我自己的经验是:
| 需求场景 | 推荐选择 | 理由 |
|---|---|---|
| 需要严格的最坏时间保证,主要用于查询 | AVL树 | 高度更矮,查询最坏情况更优 |
| 大量插入删除,且插入删除性能更重要 | 红黑树 | 旋转次数少,重平衡成本低 |
| 需要有序遍历+范围查询 | 红黑树 | STL容器已实现,无需自己造轮子 |
| 并发读写,且写操作频繁 | 跳表 | 无旋转,无锁定路径,分段锁更容易 |
| 磁盘存储、数据库索引 | B树 / B+树 | 节点按页大小组织,减少IO次数 |
有一说一,如果你不是在学习、面试、或者维护一个已有的红黑树库,生产环境直接用std::map就行。自己造红黑树的轮子,价值在于彻底理解自平衡机制,而不在于替代STL。写一遍之后再看STL源码,你会发现很多设计都是顺着同一个思路展开的,比如迭代器哨兵、节点分配器、异常安全处理。
6. 我的调试经验与几个必须避开的坑
6.1 验证函数:红黑树调试的“安全网”
手写红黑树最大的痛就是“代码看着对,跑起来错”,而且错误往往是几万次插入后才出现的悬垂指针问题。我在写的第二天就写了一个验证函数,每一步插入删除之后都调用,它能自动遍历整棵树检查五条性质:
int validate(Node<T>* x) { if (x == nil) return 1; // NIL叶子视为黑色 if (x->color == RED && (x->left->color != BLACK || x->right->color != BLACK)) { std::cerr << "违反性质4:红节点孩子必须为黑" << std::endl; exit(1); } int lh = validate(x->left); int rh = validate(x->right); if (lh != rh) { std::cerr << "违反性质5:黑高不一致" << std::endl; exit(1); } return lh + (x->color == BLACK ? 1 : 0); }有了这个函数之后,我每次吞掉一个bug,就把对应的输入序列固定下来,构造一个最小复现用例,再人肉推演一遍这棵树应该变成什么样。这个习惯特别重要,它让我不再依赖“蒙对了就好”,而是真正把树的每一步变化刻进脑子里。
6.2 我踩过的四个经典坑
第一个坑是NIL节点不统一。刚开始我用nullptr表示叶子,旋转代码里到处判断if (x != nullptr),结果漏了某条分支,导致在某次旋转中把nullptr挂上了父指针,最后崩溃在迭代器遍历。后来我统一改成成员nil,所有指针操作都假设nil是一个真实存在的节点,判断逻辑简化为x != nil,代码反而清晰多了。
第二个坑是旋转后父指针更新遗漏。左旋右旋不是简单的指针交换,还要维护三代关系——旋转节点的父节点、子节点、以及祖父节点的指向。我漏过一次“祖父指向新根”的更新,导致根节点在旋转后失去父指针,后续插入时整棵树断链。排查方法很简单:每次旋转后打印根节点和几个关键子孙的父指针,和手推的结果对比。
第三个坑是删除修复忘了处理x == root的情况。删除循环的终止条件是x == root,但如果你删除的就是根节点,且删除后根是红色的,必须在循环外把root->color = BLACK执行一遍,否则根可能变红,性质2被破坏。这个坑尤其隐蔽,因为它只在频繁删除根节点的测试序列里才出现。
第四个坑是递归删除导致栈溢出。有些教程用递归方式删除,但红黑树高度在最坏情况下是2log₂n,对于百万级数据量递归深度已经不小;如果树因为bug退化成链,递归深度直接拉满,栈就爆了。我的实现里所有删除都改成迭代写法,这是我在处理大数据量测试时强制自己做的优化。
6.3 我建议的学习路线
如果你现在刚开始接触红黑树,我建议按这个顺序推进,能少走很多弯路:
- 先手工模拟插入过程:拿一组数字,比如8、3、11、1、5、9、14,在纸上画出每一步插入后的树,标上颜色,跑通所有插入情况。
- 再手工模拟删除过程:从这棵树里按顺序删黑色节点,体会“双重黑”的传递。
- 写最小实现:只写节点、左旋右旋、插入、查找,不写删除,先把插入跑通。
- 加删除:使用验证函数反复测试,构造专门触发各种情况的用例。
- 对照STL源码:读
bits/stl_tree.h,看_Rb_tree如何用哨兵节点优化迭代器,看_Rb_tree_insert_and_rebalance和_Rb_tree_erase_and_rebalance的实现。 - 扩展成容器:加迭代器、
find、clear,封装成你自己的set。
走完这六步,红黑树对你来说就不再是“背下来又忘掉”的章节了,而是“亲手重建过”的结构。以后面试聊到红黑树,你可以直接说出旋转的细节、为什么用变色不用全重平衡、STL为什么选红黑树而不是AVL——这些都不是背的,而是真的从代码里长出来的。
我个人在这条路上最大的感受是,红黑树的代码一行一行写下来,比看十遍教科书更折磨人,但也更刻骨铭心。每当你深夜debug到怀疑人生,然后第二天醒来发现自己昨天漏的那条父指针赋值时,你对这个树的敬畏和理解就又深了一层。希望这篇系列第22篇,能让你的C++编程生活里,多一棵能亲手造出来的,既红又黑的大树。