红黑树这个数据结构,在 C++ 后端和基础架构岗位的面试里几乎成了标配考点。C++ 标准库里的map、multimap、set、multiset底层就是红黑树,Linux 内核的调度器、内存管理里也有它的身影。你只要翻几家公司的后端 JD,十有八九会把红黑树写进加分项,面试现场手写红黑树的概率,比你想象中高得多。这篇博文我打算用图解的方式把它彻底讲透:从 5 条性质讲起,把左旋右旋、插入修复、删除修复全部吃透,再给出一份可以直接跑的 C++ 实现,最后把这些年我面试别人时最常追问的问题和答题要点一并整理出来。
先把丑话说在前面:红黑树不是一道“背熟性质就能写出来”的题。我见过太多候选人能把五条性质背得滚瓜烂熟,一让他们在白板上写删除修复,整个人就卡住了。所以这篇文章不打算只给结论,每一处旋转、变色都会解释为什么这么干,并且会把容易踩的坑标出来。读完之后,你不仅能手写出来,还能说清楚每一步背后的取舍,这才是面试官真正想看到的。
1. 面试官为什么总爱考红黑树?
1.1 红黑树到底解决什么问题
普通二叉搜索树(BST)的问题在于,它的复杂度完全依赖输入顺序。拿同一组数据举例,如果按7, 3, 18, 10这样的顺序插入,树还比较像样;但如果按1, 2, 3, 4, 5, 6递增插入,BST 会直接退化成一条链表。此时查找一个元素的最坏情况要遍历所有结点,时间复杂度从理想的O(log n)恶化到O(n)。
红黑树本质上还是一棵二叉搜索树,但它额外给每个结点增加了红黑颜色,并通过一组规则约束整棵树的形态,保证从根到任意叶子结点的路径不会比最短路径长一倍以上。这个约束让树的高度始终保持在O(log n),查找、插入、删除都可以在最多对数时间内完成。简单理解:BST 是“可能歪”的树,红黑树是“保证不会歪太狠”的树。
打个比方。普通二叉搜索树就像一条没有任何秩序管理的排队队伍,谁都可以插到自己喜欢的位置,最后队伍可能扭成一团。红黑树等于是给每个人发了一张红色或黑色的牌子,并且定了几条纪律:红色牌子不能连续出现,每条分支出口上的黑色牌子数量必须一样。有了这几条,队伍再怎么插人,整体形状都不会失控。
1.2 五条规则和“黑高”的直觉
红黑树的全部规则,就下面五条:
- 每个结点要么是红色,要么是黑色。
- 根结点必须是黑色。
- 所有叶子结点都是黑色。这里的叶子指 NIL 哨兵结点,不是普通意义上的左右孩子为空的空指针。
- 红色结点的两个子结点都必须是黑色。换句话说,不能出现连续两个红色结点。
- 从任一结点到它每个叶子结点的所有路径,包含相同数目的黑色结点。
第 5 条引出了“黑高”的概念。黑高就是从某个结点出发(可以不包含该结点,也可以包含,取决于教材定义,只要整篇文章自洽就行),到达叶子结点路径上的黑色结点数量。性质 5 要求同一个结点的所有叶子路径黑高一致。
有了性质 4 和性质 5,可以得到一个很关键的结果:一条路径上红色结点数量最多不超过黑色结点数量,否则就会出现连续红色。那么最长的路径无非是“黑红黑红”交替,长度最多是纯黑路径的 2 倍。这个“最长不超过最短 2 倍”的约束,让红黑树不需要像 AVL 那样严格追求绝对平衡,也能把高度限制在O(log n),并且付出的调整代价更小。这是红黑树工程上更好用的最核心原因。
2. 从零手写红黑树:结点、哨兵、左旋与右旋
2.1 结点定义与哨兵结点设计
先定义结点。我用一个最简单的int key来表示键值,实际工程中你可以换成任意可比较类型:
enum Color { RED, BLACK }; struct Node { int key; Color color; Node *left, *right, *parent; explicit Node(int k) : key(k), color(RED), left(nullptr), right(nullptr), parent(nullptr) {} };注意构造函数里颜色默认是RED。后面会解释为什么新结点默认红色。
红黑树的代码实现里最大的坑之一在于空指针处理。如果直接用nullptr表示空子树,那么删除修复时会出现大量“判空”、“取兄弟”、“看侄子颜色”的分支,代码又臭又容易漏。我的做法是引入一个 NIL 哨兵结点,所有原本是空指针的位置都指向它。它永远为黑色,所有叶子都挂在它下面。
class RBTree { private: Node* nil_; Node* root_; public: RBTree() { nil_ = new Node(0); nil_->color = BLACK; nil_->left = nil_->right = nil_; nil_->parent = nullptr; root_ = nil_; } ~RBTree() { clear(root_); delete nil_; } };这里有个非常容易踩的坑:初始化 NIL 结点时,left和right必须指向它自己,而不是nullptr。为什么?因为后续修复代码里经常要访问某个结点的左孩子颜色、右孩子颜色,比如w->left->color。如果 NIL 的孩子是nullptr,访问就崩了;如果 NIL 的孩子指向自己,那nil_->left->color就是黑色,代码天然安全。parent则不需要指向自己,因为在各种插入删除过程中,NIL 的parent会被动态设置成它的实际父结点,等用到时再读取。
2.2 左旋与右旋:旋转为什么不会破坏顺序
红黑树调整的核心操作就是旋转。旋转不改变中序遍历结果,只改变局部父子关系,本质上是把某个结点和它的左孩子或右孩子交换上下位置,同时保持二叉搜索树的有序性。
左旋的示意图:
x y / \ / \ A y ==> x C / \ / \ B C A B左旋时,x 的右孩子 y 升上来到 x 的位置,x 变成 y 的左孩子,而 y 原来的左孩子 B 过继给 x 当右孩子。为什么这样不破坏有序性?因为左旋前 B 在 y 的左子树,满足y > B > x;左旋后 B 成为 x 的右子树,仍然满足x < B < y,中序顺序完全没变。
右旋是左旋的镜像:
x y / \ / \ y C ==> A x / \ / \ A B B C代码实现:
void leftRotate(Node* x) { Node* y = x->right; // y 是 x 的右孩子 x->right = y->left; // y 的左孩子过继给 x if (y->left != nil_) y->left->parent = x; y->parent = x->parent; // y 接管 x 的位置 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 变成 y 的左孩子 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; }写旋转代码最容易漏的点是根结点更新。如果 x 本身就是根,x->parent是 NIL,必须把root_更新成 y,否则旋转后整棵树就没根了。另一个容易漏的点是旋转前一定要确认 y 不是 NIL,否则解引用会崩;实际调用旋转时,x 的孩子必然是真实结点,所以这几段代码里不额外校验。
2.3 查找、最小值与后继:后面删除要用
这些基础操作看起来简单,但删除时要依赖它们,所以先过一遍。
Node* searchNode(int key) const { Node* cur = root_; while (cur != nil_ && cur->key != key) { if (key < cur->key) { cur = cur->left; } else { cur = cur->right; } } return cur == nil_ ? nullptr : cur; } Node* minimum(Node* x) const { while (x->left != nil_) x = x->left; return x; } Node* successor(Node* x) const { if (x->right != nil_) { return minimum(x->right); } Node* y = x->parent; while (y != nil_ && x == y->right) { x = y; y = y->parent; } return y; }后继的逻辑理解透:如果一个结点有右子树,那么下一个比它大的结点一定是右子树里最小的那个;如果没有右子树,就要向上找“自己是左孩子”的祖先,第一个满足这个条件的祖先就是后继。这个操作在删除“有两个孩子”的结点时很关键。
3. 插入操作:先按普通 BST 插,再变色旋转修复
3.1 为什么新结点必须是红色
插入的总体思路分两步:先按普通二叉搜索树的方式把新结点挂到叶子位置,然后通过变色和旋转修复红黑树性质。
那么新结点应该设成什么颜色?如果设成黑色,那么它所在路径的黑高立刻比别的路径多 1,直接破坏性质 5。修复性质 5 的成本很高,意味着很可能需要一路向上调整。反过来,如果设成红色,那么它不会影响黑高,唯一可能破坏的是性质 4——红色结点的子结点不能为红,也就是新结点和它的父结点碰巧都是红色时才会出问题。父红这种失衡可以通过局部变色和旋转解决,成本更低。所以新结点默认红色,这是经过权衡的设计,不是随意定的。
void insert(int key) { Node* z = new Node(key); Node* y = nil_; Node* x = root_; while (x != nil_) { y = x; if (z->key < x->key) { x = x->left; } else { x = x->right; } } z->parent = y; if (y == nil_) { root_ = z; } else if (z->key < y->key) { y->left = z; } else { y->right = z; } z->left = nil_; z->right = nil_; z->color = RED; insertFixUp(z); }插入后,新结点是红色,左右孩子都指向 NIL。接下来进入修复流程insertFixUp。
3.2 叔结点为红:变色上推
插入修复的核心对象是当前红色结点z。如果z的父结点是黑色,一切正常,什么都不用做。只有当父结点也是红色时,才需要处理。
因为红黑树不允许父红子红,所以祖父结点一定是黑色。这时看叔结点的颜色。
先看叔结点是红色的情况。设父为p,叔为u,祖父为g,并且 g 是黑色:
g黑 / \ p红 u红 / z红此时脏的不是一处,而是“p 和 z 连续红”。修复办法是把黑色从祖父 g 拉下来:让 p 和 u 都变黑,g 变红。这样从 g 到下面每条路径的黑色数量没有变,黑高保持平衡,但 g 变成了红色,g 又要和它自己的父结点重新检查是否连续红,所以把z上移到 g,继续循环。
g红 / \ p黑 u黑 / z红这也是插入修复里唯一需要向上传播的情况,条件是叔结点为红。如果叔结点是黑色的,处理方式完全不同。
3.3 叔结点为黑:先转“外侧”再旋转变色
当叔结点是黑色(或者不存在,NIL 算黑色)时,不可能再靠变色把问题一次性解决,需要用旋转。这里又分两种子情况,以父是祖父的左孩子为例,右对称同理。
情况一:z是父结点的内侧孩子。比如父是祖父的左孩子,z 是父的右孩子。这时先把z和父做一个左旋,变成外侧形态,但此时 z 变成了父的父结点,原来的父变成了 z 的左孩子,于是需要把当前待处理的z指针换成原来的父,再用下面的外侧处理。
情况二:z是父结点的外侧孩子。比如父是祖父的左孩子,z 也是父的左孩子。操作是:父变黑,祖父变红,然后对祖父做一次右旋。旋转后,原父成为子树的新根,黑色;祖父变红后成为原父的右孩子,整棵子树的黑高保持不变,且不再存在连续红。
调整前: g黑 / \ p红 T黑 / z红 调整后: p黑 / \ z红 g红 \ T黑插入修复最多产生两次旋转:内侧情况先旋转一次变成外侧,外侧情况再旋转一次完成修复;叔红的上推循环本身不旋转。因此红黑树插入操作的旋转次数上界是 2,这是一个经常被面试官追问的点。
3.4 插入修复的 C++ 实现
void insertFixUp(Node* z) { while (z->parent->color == RED) { if (z->parent == z->parent->parent->left) { Node* y = z->parent->parent->right; // 叔结点 if (y->color == RED) { // 情况1:叔红,变色上推 z->parent->color = BLACK; y->color = BLACK; z->parent->parent->color = RED; z = z->parent->parent; } else { // 情况2:z 是内侧孩子,先转成外侧 if (z == z->parent->right) { z = z->parent; leftRotate(z); } // 情况3:外侧孩子,变色 + 右旋祖父 z->parent->color = BLACK; z->parent->parent->color = RED; rightRotate(z->parent->parent); } } else { // 镜像:父是祖父的右孩子 Node* 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; rightRotate(z); } z->parent->color = BLACK; z->parent->parent->color = RED; leftRotate(z->parent->parent); } } } root_->color = BLACK; }循环结束后,特别把根染黑。这一步很有必要:可能有一路变色把根变成了红色,而性质 2 要求根必须是黑色,最后强制兜底。由于整棵树的每条路径黑色数量不减,把根从红变黑也不会破坏性质 5,所以直接染黑永远安全。
4. 删除操作:传说中的硬骨头
4.1 删除一个结点的 BST 操作
删除比插入复杂,因为它不仅要删,还要在删完之后处理可能被破坏的性质 5。先回顾普通 BST 的删除逻辑:
- 如果待删结点没有左孩子,直接用右孩子顶替它。
- 如果待删结点没有右孩子,直接用左孩子顶替它。
- 如果两个孩子都在,找右子树的最小结点作为后继,用后继值覆盖待删结点,然后转去删除后继。因为右子树最小结点一定有左子树为空,所以这种情况会退化到前面某一种。
红黑树删除也是这个骨架,但要多记录两个重要变量:实际被移动位置的结点y、实际被移动位置之前的颜色。如果y原本是黑色,删除或者移动后,那条路径少了一个黑色,必须执行修复。
void remove(int key) { Node* z = searchNode(key); if (z == nullptr) return; Node* y = z; Node* x; Color yOriginalColor = 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); yOriginalColor = 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; } if (yOriginalColor == BLACK) { removeFixUp(x); } delete z; }transplant负责把一棵子树顶到另一棵子树的位置上:
void transplant(Node* u, Node* v) { if (u->parent == nil_) { root_ = v; } else if (u == u->parent->left) { u->parent->left = v; } else { u->parent->right = v; } v->parent = u->parent; }删除代码里,y是真正被删除或者被移动的结点,z是键值对要被移除的结点,两者千万别搞混。一个常见的错误是最后delete y,结果把原来树上还在的结点删了,树结构直接乱掉。
4.2 “双黑”到底是什么意思
如果一个黑色结点被删掉,相当于这条路径少了一个黑色。为了直观理解修复过程,可以把顶替上来的结点x看成“双黑”:它原本有自己的颜色,同时还要替被删的黑色结点多背一个黑色。修复的目标,就是通过旋转和变色,把这个额外的黑色从某个红色结点转移到树上某个位置后消掉,或者把额外黑色上推到根。
这个说法虽然不严谨,但非常好用。面试时你说“现在 x 是双黑,需要通过几种情况消掉双黑”,面试官通常都会点头,因为他们知道你已经理解了删除修复的本质。
4.3 兄弟结点的四种情况与修复
删除修复循环的条件是x不是根,并且x是黑色(准确定义是 x 的颜色为黑色,或者 x 用“双黑”理解)。每次循环,先判断x是父结点的左孩子还是右孩子,然后取出兄弟结w,分四种情况处理。
下面以x是左孩子为例。四种情况的处理动作如下表:
| 情况 | 现象 | 处理动作 |
|---|---|---|
| 情况1 | 兄弟 w 为红色 | w 变黑,父变红,左旋父,更新 w 后继续处理 |
| 情况2 | w 是黑色,且 w 的两个孩子都是黑色 | w 染红,x 上移到父结点继续循环 |
| 情况3 | w 是黑色,且 w 的左孩子红、右孩子黑 | w 的左孩子变黑,w 变红,右旋 w,更新 w |
| 情况4 | w 是黑色,且 w 的右孩子红 | w 继承父颜色,父变黑,w 的右孩子变黑,左旋父,x 置为根,结束 |
情况1 为什么要先处理?因为 w 是红色时,w 的两个孩子一定是黑色,但直接套用后面的黑色兄弟情况会出错。处理办法是先旋转,让某个黑色的子结点成为新兄弟,这样问题就转换成了“兄弟是黑色”的几种情况。
情况2 的核心是“父结点吸收双重黑色”。把兄弟 w 染红,相当于 w 那边也少了一个黑色,父结点的左右子树重新平衡,于是双重黑色上移到父结点,让父结点继续承担这个额外黑色。如果父本来是红色,那它变成黑色后循环结束;如果父本来就是黑色,那双黑继续往上走。
情况3 其实是为情况4 做铺垫。兄弟 w 虽然黑,但它的右孩子是黑、左孩子是红,直接套情况4 的旋转不行,所以先把 w 右旋一次,让红孩子变成 w 的右孩子,再走情况4。
情况4 是最终解决的一步。通过一次旋转,把兄弟路径上的黑色数量重新分配,同时把 x 的额外黑色消掉,循环可以直接结束。
代码实现时,我用了 NIL 哨兵,所以比普通教材稍微多几个针对nil_的保护,防止把哨兵染红。具体看这段removeFixUp:
void removeFixUp(Node* x) { while (x != root_ && x->color == BLACK) { if (x == x->parent->left) { Node* w = x->parent->right; if (w->color == RED) { // 情况1: 兄弟红 w->color = BLACK; x->parent->color = RED; leftRotate(x->parent); w = x->parent->right; } // 兄弟为哨兵nil时,认为它两个孩子都是黑色 if (w == nil_ || (w->left->color == BLACK && w->right->color == BLACK)) { // 情况2: 兄弟黑,侄子全黑 if (w != nil_) w->color = RED; x = x->parent; } else { if (w->right->color == BLACK) { // 情况3: 左红右黑,先变成情况4 w->left->color = BLACK; w->color = RED; rightRotate(w); w = x->parent->right; } // 情况4: 右孩子红 w->color = x->parent->color; x->parent->color = BLACK; w->right->color = BLACK; leftRotate(x->parent); x = root_; } } else { // 对称情况 Node* w = x->parent->left; if (w->color == RED) { w->color = BLACK; x->parent->color = RED; rightRotate(x->parent); w = x->parent->left; } if (w == nil_ || (w->right->color == BLACK && w->left->color == BLACK)) { if (w != nil_) w->color = RED; x = x->parent; } else { if (w->left->color == BLACK) { w->right->color = BLACK; w->color = RED; leftRotate(w); w = x->parent->left; } w->color = x->parent->color; x->parent->color = BLACK; w->left->color = BLACK; rightRotate(x->parent); x = root_; } } } x->color = BLACK; }这里最关键的坑就是w == nil_。当兄弟是哨兵 NIL 结点时,它的颜色是黑,左右孩子也必须是黑,但如果按标准情况2 去执行w->color = RED,会把哨兵染红,后续所有判断直接崩坏。所以这段代码里遇到兄弟为 NIL 时,不修改哨兵颜色,只把双重黑色上移到父结点。
4.4 删除复杂度与旋转上限
删除修复的循环可能一路上升到根,所以整体复杂度仍是O(log n)。但真正发生旋转的次数是有限的:情况1 最多一次,情况3 最多一次,情况4 一次后循环结束,情况2 本身不旋转。因此红黑树删除操作最多只需要 3 次旋转。这一点是红黑树在工程上比 AVL 更占优势的关键原因,后面也会再展开。
5. 完整可运行的 C++ 实现与自测
5.1 工具函数:验证颜色规则与黑高
手写红黑树不验证等于白写。我强烈建议你写一个isValid函数,每次插入删除后都跑一遍,把非法情况立刻暴露出来。最省事的验证方式就是递归检查下面几件事:
- 红色结点的孩子不能是红色。
- 从任意结点出发,左右叶子路径的黑高必须相同。
- 根必须是黑色。
bool isValidTree(Node* x, int& height) { if (x == nil_) { height = 1; return true; } if (x->color == RED && (x->left->color == RED || x->right->color == RED)) { return false; } int leftH = 0, rightH = 0; if (!isValidTree(x->left, leftH)) return false; if (!isValidTree(x->right, rightH)) return false; if (leftH != rightH) return false; height = leftH + (x->color == BLACK ? 1 : 0); return true; } bool isValid() { if (root_->color != BLACK) return false; int h = 0; return isValidTree(root_, h); }这个函数检查完以后,只要返回true,至少证明你的树满足红黑树五条性质。我测试过程中遇到的大部分旋转写错、变色漏改,都能靠它抓出来。
5.2 中序遍历与控制台打印
验证有序性最直接的方法是中序遍历。红黑树是二叉搜索树,中序遍历结果一定是升序的。
void inorder(Node* x) { if (x == nil_) return; inorder(x->left); std::cout << x->key << (x->color == RED ? "(R)" : "(B)") << " "; inorder(x->right); } void inorderPrint() { inorder(root_); std::cout << std::endl; }输出时用(R)和(B)标记颜色,方便肉眼检查是否存在连续红。
5.3 集成测试:插入删除后反复验证
下面这段代码可以直接编译运行,作为红黑树实现的基本自测:
int main() { RBTree tree; std::vector<int> keys = {7, 3, 18, 10, 22, 8, 11, 26, 2, 6, 13}; for (int k : keys) { tree.insert(k); if (!tree.isValid()) { std::cout << "insert " << k << " failed" << std::endl; return 1; } } tree.inorderPrint(); std::cout << "valid after insert: " << tree.isValid() << std::endl; std::vector<int> del = {3, 7, 18, 2}; for (int k : del) { tree.remove(k); if (!tree.isValid()) { std::cout << "remove " << k << " failed" << std::endl; return 1; } } tree.inorderPrint(); std::cout << "valid after remove: " << tree.isValid() << std::endl; return 0; }更严格一点,可以随机插入 1 到 1000 的数字,再随机删除,每步都调用isValid校验。我自己的习惯是再拿std::set做对拍:同一个操作序列同时打到红黑树和std::set上,每一步检查中序遍历结果是否完全一致。这个方法比任何单一测试都更能暴露边界问题。
6. 面试追问与答题模板
6.1 “有了 AVL,为什么还要红黑树?”
这是红黑树面试中最经典的问题,没有之一。AVL 树也属于自平衡二叉搜索树,它通过严格的平衡因子保证左右子树高度差不超过 1,查找性能理论上更稳。但 AVL 的代价在插入和删除时的调整太频繁,删除时最坏可能需要O(log n)次旋转,这在写多读少的场景里是很大的开销。
红黑树放宽了平衡要求,只保证最长路径不超过最短路径的两倍,换来的是“调整成本更低”。红黑树插入最多旋转 2 次,删除最多旋转 3 次,虽然颜色修复的循环可能往上走,但实际结构变化很少,整体操作摊还下来性价比很高。
| 维度 | AVL | 红黑树 |
|---|---|---|
| 平衡程度 | 严格,左右子树高度差不超过1 | 宽松,最长不超过最短2倍 |
| 查询性能 | 略优 | 略逊,但常数差异很小 |
| 插入旋转 | 最多2次 | 最多2次 |
| 删除旋转 | 最坏 O(log n) 次 | 最多3次 |
| 适用场景 | 读多写少 | 读写均衡、频繁插入删除 |
所以工程结论很清晰:C++ 标准库的关联容器、Linux 内核、Java 的TreeMap等大量使用红黑树,因为现实场景往往是“有读有写”,红黑树在读写之间取了一个更好的平衡点。
6.2 “红黑树和哈希表怎么选?”
这道题也是高频。红黑树和哈希表都能做关联容器,但适用场景差别很大。
红黑树最大的优势是有序。它支持中序遍历,可以按顺序输出所有元素,可以快速找前驱、后继,也可以做范围查询,比如“找出所有大于 10 小于 50 的键”。哈希表做不到有序遍历,它查找元素平均复杂度是O(1),但遍历结果是随机的。
哈希表的劣势也很明显:需要设计好的哈希函数,处理冲突,还可能发生扩容;如果哈希函数选得不好,或者数据被恶意构造,最坏情况会退化到O(n)。红黑树没有这些问题,它在任何输入下都能保证最坏O(log n)。
C++ 里的选择其实已经给出了答案:需要有序就用map/set,底层红黑树;不需要有序且对性能敏感,就用unordered_map/unordered_set,底层哈希表。面试时你可以结合这个例子回答,一般能拿高分。如果再往下聊到磁盘场景,还可以顺势提一下 B/B+ 树,因为它针对磁盘多路 IO 做了优化,每个结点可以存多个键,能显著减少磁盘随机访问次数。
6.3 面试高频追问速查表
这里整理几个我面试别人时常追问的问题和对应的答题要点。
| 追问 | 答题要点 |
|---|---|
| 红黑树能严格保证 O(log n) 吗? | 不能严格到 log n,但能保证树高 O(log n),因为最长路径不超过最短路径 2 倍。 |
| 为什么新插入结点是红色? | 红色不会增加黑高,只可能破坏连续红,修复成本低。 |
| 旋转会改变二叉搜索树顺序吗? | 不会。旋转只改变局部父子关系,中序遍历顺序不变。 |
| 插入最多旋转几次?删除呢? | 插入最多 2 次,删除最多 3 次。 |
| 根是红色怎么办? | 修复循环结束后强制把根染成黑色,因为根变黑不破坏任何性质。 |
| 为什么需要 NIL 哨兵? | 统一叶子结点的处理,避免大量判空,让修复代码逻辑更干净。 |
| 红黑树能用来做区间查询吗? | 能。中序遍历天然有序,再配合前驱后继就能做范围遍历。 |
这些问题本质上是考察你有没有真正理解红黑树的“原理”,而不是背代码。所以我建议每写一步代码,都想想这一步在性质层面解决了什么问题。
7. 常见 Bug 与调试心得
7.1 手写红黑树最容易踩的 5 个坑
第一个坑是 NIL 哨兵的左右孩子没有指向自身。没有踩过的人可能不理解,但删除修复代码里会频繁访问w->left->color这样的表达式,如果 NIL 的孩子是nullptr,程序直接崩。解决方法是初始化时把nil_->left = nil_->right = nil_写清楚。
第二个坑是旋转和删除后根结点没有更新。左旋右旋里如果 x 是根,必须把root_改成 y;transplant里如果 u 是根,必须把root_改成 v。漏了这一步,根会飘到奇怪的地方,所有isValid校验都会失败。
第三个坑是删除时把y和z弄混。z是键值对要删的结点,y是真正被移动/删除的结点,最后应该delete z。我见过不少人写成delete y,结果把树上保留的结点删了,内存泄漏和悬垂指针一起爆发。
第四个坑是在统一哨兵设计下把 NIL 染红。删除修复的情况2 中,如果兄弟是 NIL,不能执行w->color = RED,否则哨兵变成红色,所有后续判断全部失真。处理办法是单独判断兄弟是否为 NIL,是的话跳过染红,直接把额外黑色上推到父结点。
第五个坑是修复循环没有正确向上传递。插入修复里z = z->parent->parent,删除修复里x = x->parent,这些赋值漏掉或者写错位置,循环就会陷入死循环,或者提前退出导致性质没修复。出现这种情况时,建议手动画出当前树,把z或x的移动路径标出来,问题通常一眼就能看出来。
7.2 推荐的自测套路
红黑树这种数据结构,不靠随机测试真的很难保证正确。我的自测套路一般三层:
第一层,固定数据。插入一组已知数据,肉眼观察中序遍历是不是升序,isValid是否返回true。再按特定顺序删除,同样验证。
第二层,随机数据对拍。生成几百个随机数,重复执行“插入再删除”,每步都校验红黑树性质和std::set的一致性。这个测试能把最常见的旋转 bug 逼出来。
第三层,边界构造。只插入递增序列,比如1, 2, 3, ..., 100,看旋转和变色是否正常;只删除最大最小值;删除到只剩一个结点;删除不存在的键。这些边界场景最容易暴露根结点更新和 NIL 处理的问题。
编译时建议加上-fsanitize=address,红黑树代码内存操作密集,AddressSanitizer 能帮你捕获释放后访问、数组越界、野指针等问题。
7.3 给准备面试的朋友的建议
红黑树这种东西,纯看永远学不会,一定得动手写。我建议的练习顺序是:先默写五条性质,再单独写左旋右旋,然后写插入修复,最后才碰删除修复。不要一上来就死记整个类,那样面试时很容易脑子空白。
真正面试时,先在白板上把树画出来,再写代码。画图有两个好处:第一,你自己思路更清晰;第二,面试官能实时看到你的思考过程。写删除修复前,可以主动说一句“我把 NIL 哨兵的处理单独判断一下”,面试官通常会很认可这种对边界的敏感。
如果你时间紧,删除修复实在背不下来,也至少要能说清楚框架:兄弟红先转黑,兄弟黑看侄子,侄子全黑就上移,左红右黑先转右,右红就直接旋转变色收尾。面试官更多时候想确认的是“你有没有真正理解”,而不是“你代码默写得像不像原书”。能把思路说清楚,再配合一个完整实现,红黑树这道题基本就稳了。
我自己准备红黑树时还有一个很笨但很有效的心得:把插入和删除的每一条规则用中文写在一张纸上,挂在显示器旁边,每次写完代码就对一遍。写多了之后你会发现,这些规则不是要背的条文,而是一套完整的“如何在不破坏黑高的前提下调整树形”的系统。理解到这一层,面试时你就不是在背红黑树,而是在讲一个关于平衡的逻辑故事。