资讯详情

红黑树原理与C++实现:图解插入删除旋转与平衡调整

📅 2026/9/10 7:28:56 | 华诺云谱 👁 阅读
红黑树原理与C++实现:图解插入删除旋转与平衡调整
1. 为什么红黑树成了面试的“硬通货”先聊点实际的。你打开任何一份 C 后端或基础架构岗位的面试题清单红黑树几乎从未缺席。这还真不是面试官故意刁难——红黑树几乎就是现代计算机系统里“平衡与效率”这对矛盾的标准解。C 标准库里的std::map、std::set、std::multimap、std::multisetLinux 内核的 CFS 调度器、epoll就绪队列Nginx 的定时器甚至 Java 的TreeMap和TreeSet底层清一色是它。绕过红黑树等于绕过了这些基础设施的共同骨架。有意思的是红黑树明明叫“红黑”但它最核心的思想跟颜色本身没什么关系。颜色只是规则的载体真正的价值在于它用一种极简洁的着色规则把一棵二叉搜索树的高度控制在O(log n)级别。面试里问红黑树多数时候问的不是“你背过几个旋转情形”而是左旋和右旋到底在干什么插入和删除为什么需要调整你能否把调整过程用代码干干净净地写出来我在这篇里要做的是用硬核图解的方式把整棵红黑树拆开从原理一路推到 C 完整实现。我不会只给你贴一段能跑通的代码而是把每个调整场景背后的动机讲清楚。读完这篇你应该能做到不看参考徒手写出红黑树的插入与删除旋转逻辑并且能在面试时把“为什么要这样旋转”解释得明明白白。先说清楚适用范围。如果你是那种已经把二叉搜索树玩得滚瓜烂熟、但一提平衡树就头皮发麻的 C 学习者这篇就是给你准备的。如果你在准备面试想要的是“原理 手写代码”的完整链路这篇同样合适。不过我要提前泼一盆冷水红黑树的删除调整确实不简单如果你指望十分钟速成现实点说不现实。但只要你愿意把每一个 case 走一遍它的复杂度实际上远没有传说中那么可怕。2. 红黑树到底在平衡什么在看旋转和染色之前必须先建立一个共识红黑树本质是一棵增加了约束条件的二叉搜索树。它保留了 BST 的全部性质——左子树所有节点小于根节点、右子树所有节点大于根节点、中序遍历有序——然后额外加了五条约束。网上流传的说法五花八门我建议你以这个版本为准每个节点非红即黑。根节点是黑色。每个叶子节点这里的叶子指的是 NIL 空节点是黑色。如果一个节点是红色那么它的两个子节点必须是黑色也就是说红节点不能有红孩子。从任一节点到其每个叶子节点的所有路径上包含相同数目的黑节点。前四条都很直观关键是第五条。它可以等价地理解为对于每一个节点它到所有后代叶子的路径上黑色节点的数量必须相等。这个数量有一个专门术语叫黑高。那有人要问了为什么这五条约束就能保证树的高度是O(log n)这个证明的推演过程其实很漂亮。考虑一棵红黑树如果我把所有的红节点都“折叠”进它们的黑父节点中也就是说让红节点和黑父节点合并成一个“超节点”那么原来树上的每条路径就可以视作由若干个这样的超节点串联而成。由于红节点不能有红孩子每个超节点内部最多只有一个红节点。又因为所有路径的黑节点数量相等折叠之后整棵树的所有路径长度就完全相等了——这就变成了一棵理想平衡的 2-3-4 树。一棵包含n个内部节点的 2-3-4 树高度上界是log_2(n1)。折叠回去红节点最多只会让路径高度翻倍因此红黑树的高度不超过2 * log_2(n1)。这个证明理解起来需要一个抽象跳跃但结论非常好用红黑树的高度最多不会超过理想平衡二叉树的二倍。这就是为什么它能在最坏情况下依然保证插入、删除、查找都是O(log n)。AVL 树的平衡更严格它的高度接近1.44 * log_2 n比红黑树更矮但代价是旋转更频繁。红黑树稍微“放松”了一点平衡约束换来了更少的旋转次数。在随机插入删除频繁的场景下红黑树的整体性能往往更占优势这就是工程上大量选它的原因。这里有个常见的认知误区我必须纠正红黑树不是“近似平衡”的形容词它有精确的数学保证。所谓的“近似”只是跟 AVL 那种严格左右子树高度差不超过 1 相比它的平衡约束更宽松但它的高度上界依然是一个确定的可证明的O(log n)。面试时把这个证明讲出来和只会背口诀的候选人立刻拉开了差距。3. 旋转操作红黑树唯一改变结构的武器红黑树的调整过程说穿了就是两件事变色和旋转。变色不改变树的形态只改变节点的颜色记录旋转则真正改变了节点之间的父子关系是维护 BST 有序性的核心工具。旋转分为左旋和右旋它们互为镜像操作。3.1 右旋的精确定义先看右旋。假设y是根它有一个左孩子xx的右孩子是β那么右旋我们这样操作x替换y成为新的子树根。y变成x的右孩子。β即x原来的右子树变成y的左子树。对应 C 代码画成伪代码是rightRotate(y): x y.left T2 x.right x.right y y.left T2 y.parent x x.parent y.parent if y.parent is null: root x else if y is left child of y.parent: y.parent.left x else: y.parent.right x T2.parent y左旋完全对称只是把方向反过来。核心就一句话左旋是让右孩子上位右旋是让左孩子上位。旋转操作不会破坏二叉搜索树的有序性因为β这棵子树里的所有节点在旋转前既大于x又小于y右旋场景下β是x的右子树所以它大于x同时β又是y的左子树所以它小于y。旋转后β变成y的左子树依然满足“大于x且小于y”的中序位置。旋转前后整棵树的中序遍历结果完全不变。3.2 旋转的直观理解保持中序序列不变很多人看旋转代码一脸懵是因为不理解为什么这样搬来搬去树还是合法的。我建议你换个角度看旋转的本质是在不改变中序遍历序列的前提下把两个节点的上下关系对调。你可以把中序遍历序列想象成一条排好队的队伍旋转对调了其中两个相邻节点的“上下位置”但队伍本身没有人离开、没有新人插队。正是因为这个性质任何情况下我们都可以放心大胆地用旋转去调整结构不用担心搞乱 BST 的有序性。3.3 C 节点结构与旋转实现现在动手写代码。我采用一个简洁的节点定义用color枚举区分红黑用parent指针支持向上回溯。这是红黑树实现和普通 BST 最大的不同——没有父指针的话删除调整根本没法高效实现。#include iostream 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) {} };我默认新节点初始为红色。为什么因为红色节点只受“不能有红孩子”这条规则约束不会破坏黑色节点数量相等这一核心性质。初始染红可以把问题限制在局部后续调整只需要处理连续的红色冲突。如果初始染黑那么每当插入一个节点该路径的黑高就会立即变化修复起来远比处理红冲突复杂。这个选择的理由面试官问到的概率极高务必理解。右旋和左旋实现如下void leftRotate(Node* root, Node* x) { Node* y x-right; x-right y-left; if (y-left ! nullptr) y-left-parent x; y-parent x-parent; if (x-parent nullptr) 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* root, Node* y) { Node* x y-left; y-left x-right; if (x-right ! nullptr) x-right-parent y; x-parent y-parent; if (y-parent nullptr) root x; else if (y y-parent-left) y-parent-left x; else y-parent-right x; x-right y; y-parent x; }写旋转最容易出 bug 的地方是空指针处理。比如x-right可能为空那么x-right-parent y这行就会崩。另一个高频 bug 是父指针更新遗漏旋转涉及两个节点的父指针变更加上子树根与祖父的链接关系一共三处漏掉任意一处后续调整时回溯就会出错。我建议你写完旋转后做个自查从祖父视角看这棵子树根有没有正确替换从β视角看它的 parent 有没有正确指向新父节点从原父节点视角看它的孩子指针有没有被覆盖成新子节点。三个方向都对了旋转才算真的写完。4. 插入从普通 BST 插入到红黑恢复红黑树的插入分两个阶段。第一阶段是普通的 BST 插入——按大小比较找到合适位置挂上新节点。第二阶段是调用修复函数fixInsert恢复红黑性质。第一阶段不需要我多讲第二阶段是重点。4.1 插入后冲突的根源新节点记为z初始为红色。插入后可能的违规情况只有一种z是红的且它的父节点也是红的。因为根是黑的所以这种冲突必然发生在非根节点上。修复的思路围绕z的叔叔节点uncle的颜色展开。叔叔是父节点的兄弟也就是祖父的另一个孩子。为什么叔叔的颜色如此关键因为黑高的约束决定了我们只能通过两种手段修复把红色冲突向上传递或者通过旋转和重新着色一次性解决。4.2 Case 1叔叔是红色这是最简单的场景也是红黑树插入里教科书必讲的第一种情况。假设z是红z.p是红uncle也是红祖父g必然是黑否则原来的树就已经违规了也就是红节点不能有红孩子。此时我们把祖父变红把父亲和叔叔变黑再把冲突上移到祖父。为什么要这样处理这一步的本质是把“两个红孩子”转变成“祖父红、二十个孙全黑”等于把红色向上推了两层。红黑树的第五条规定每条路径黑高必须相同。修改后从祖父出去的每条路径黑色节点数跟修改前完全一样——原来祖父是黑父和叔是红现在祖父是红父和叔是黑经过这三个节点的路径上黑节点总数没有变化。所以这次变色只是把违规位置从z转移到祖父。接下来把z指向祖父继续循环直到根为止。如果祖父是根循环到根时直接把根染黑即可这就是为什么修复函数的最后一行永远是无条件root-color BLACK。这行代码同时保证了根节点为黑的规则并且因为根变黑所有路径的黑高统一加 1整棵树的性质依然满足。4.3 Case 2 Case 3叔叔是黑色需要旋转如果叔叔是黑色那就不能靠单纯的变色解决了。因为把父亲变黑会改变该路径的黑高导致与其他路径的黑色数量不相等。这时候必须旋转。这里的调整还分两种情况。先说 Case 3也就是最直接的场景z是红色z.p是红色uncle是黑色且z与z.p的孩子方向一致比如z是左孩子z.p也是左孩子。这种情况下我们对祖父做一次右旋然后把z.p染黑、祖父染红。你可能想问为什么右旋之后颜色要这么安排我们来推演一下。右旋后原来的父亲p变成子树根原来的祖父g变成p的右孩子。为了保证这个局部子树不违反规则新的子树根应该是黑色——二叉树的每一条经过这个子树的路径都会经过这个根如果根是红色它可能和外面的红色祖先冲突。而g变成红色是因为g原本的右子树也就是叔叔uncle是黑色g变红后经过g和uncle这一路的黑节点数量正好和经过p、z的黑节点数量相等。所以 Case 3 的染色不是随便定的它是为了继续维持黑高相等这个硬约束。Case 2 则是z与z.p孩子方向相反的情况。比如z的父是左孩子但z是右孩子。此时不能直接对祖父旋转因为旋转后z.p和z的位置会让结构变得别扭无法通过一次旋转完成修复。标准做法是先对父节点做一次左旋把z提升到父的位置然后我们就瞬间得到了 Case 3 的格局——z与z.p现在指向原来的祖父方向一致了。之后套用 Case 3 处理即可。这个“先转成 Case 3”的技巧本质是把非对称情况统一成对称情况减少代码分支。4.4 插入修复完整实现综合上面的分析插入修复完整代码如下void fixInsert(Node* root, Node* z) { while (z-parent ! nullptr z-parent-color RED) { Node* grand z-parent-parent; if (z-parent grand-left) { Node* uncle grand-right; if (uncle ! nullptr uncle-color RED) { // Case 1: 叔叔红色变色后向上回溯 z-parent-color BLACK; uncle-color BLACK; grand-color RED; z grand; } else { if (z z-parent-right) { // Case 2: 当前节点是右孩子先左旋父节点变成 Case 3 z z-parent; leftRotate(root, z); } // Case 3: 当前节点是左孩子右旋祖父并变色 z-parent-color BLACK; grand-color RED; rightRotate(root, grand); } } else { Node* uncle grand-left; if (uncle ! nullptr uncle-color RED) { z-parent-color BLACK; uncle-color BLACK; grand-color RED; z grand; } else { if (z z-parent-left) { z z-parent; rightRotate(root, z); } z-parent-color BLACK; grand-color RED; leftRotate(root, grand); } } } root-color BLACK; }插入接口很直接按 BST 规则找插入位置构造红节点挂上去然后调用fixInsert。void insert(Node* root, int key) { Node* z new Node(key); Node* y nullptr; Node* x root; while (x ! nullptr) { y x; if (key x-key) x x-left; else x x-right; } z-parent y; if (y nullptr) { root z; } else if (key y-key) { y-left z; } else { y-right z; } fixInsert(root, z); }注意这里没处理重复 key。实际使用中你可以根据需求改成“重复时覆盖”或“插入左/右子树”面试时指出这个细节反而能加分。4.5 插入操作的时间复杂度插入的循环最坏情况下沿着树向上回溯每次回溯一层。因为树高是O(log n)所以循环最多执行O(log n)次。但要注意Case 3 执行完一次旋转后立即终止循环只有 Case 1 会继续向上回溯。所以严格说插入最多执行 2 次旋转一次 Case 2 的预旋转一次 Case 3 的正式旋转其它都是变色。这正是红黑树在工程上高效的依据——变色是 O(1) 的指针操作旋转也是 O(1) 的指针操作常数很小。5. 删除最容易被问倒的硬骨头如果说红黑树插入是“热身”那么删除就是真正的“修罗场”。网上关于删除的教程满天飞但大多数要么只是贴代码要么用让人头晕的宗门图例。这一节我会换一种思路先把删除带来的问题定性再逐个 CASE 分析。5.1 先看 BST 删除的三种情况普通 BST 删除节点有三种情况被删节点没有孩子直接摘掉让父节点的相应孩子置空。被删节点只有一个孩子用孩子顶替被删节点。被删节点有两个孩子用中序后继节点的 key 覆盖被删节点的 key然后删掉后继节点。后继节点必然没有左孩子这样就把问题化简成了“删除一个最多只有一个孩子的节点”。红黑树的删除同样遵循这套逻辑只是删除完成后需要检查红黑性质有没有被破坏。5.2 删除后的问题定性删除操作的麻烦点在于如果被删节点或实际被删的后继节点原来是黑色那么某条路径上的黑色节点数就会少 1第五条规则被破坏。我们把“少了 1 个黑”的感觉抽象成这个位置出现了一个“双黑”节点表示这条路径比其它路径少一个黑色。修复的过程就是想办法把这个多余的黑“消化”掉直到整个树重新平衡。如果被删节点是红色事情就简单了。红色节点不影响黑高删除后红黑性质基本不会被破坏只可能破坏“红节点不能有红孩子”这条。但因为被删节点如果是红色它的父节点和孩子节点都必须是黑色否则原树已经违规直接摘除不会制造新的红冲突。所以一句话删红节点什么都不用修。真正的修复只发生在被删节点是黑色且它所在路径黑高减少时。我们把实际删除后顶替上来的节点记为x如果x是红色直接染黑就完事如果x是黑色需要走进删除修复循环。5.3 删除修复的四种情形删除修复的核心是保持x节点所在路径的黑高不比其他路径少。x被视为“额外携带一层黑色”它的兄弟是w。根据w的颜色以及w的孩子颜色分成四个 Case。Case 1兄弟是红色这时候x的父节点必然是黑色否则红节点不能有红孩子。做法是把父节点染红兄弟染黑然后对父节点做一次旋转如果x是左孩子就左旋父节点。旋转后x的兄弟变成了原来w的一个黑色孩子于是问题转化为兄弟是黑色的其它 Case。这一步的目的是在不改变黑高的前提下把红兄弟转化成黑兄弟。为什么可以这样变换因为旋转后x的路径上多了一个黑节点原来的w下来了我们通过把w变黑、父变红抵消了旋转造成的变化。Case 2兄弟是黑色且兄弟的两个孩子都是黑色这说明w的整棵子树无法“借”出多余的黑色。做法是把w染红然后把多出来的那层黑上移到父节点。如果父节点原来是红色循环在此结束把父节点染黑即可如果父节点是黑色则父节点成为新的x继续循环。这个 Case 的直觉是兄弟子树里所有路径都少一个黑那么把兄弟变红后兄弟子树的黑高减 1正好和x路径持平。剩下的问题是父节点这整棵子树相对外部少了 1 层黑所以把问题抛给父节点。Case 3兄弟是黑色兄弟的左侧孩子是红色右侧孩子是黑色这是个“过渡 Case”。做法是把兄弟染红把兄弟的左孩子染黑然后右旋兄弟。旋转后新的兄弟是原来兄弟的左孩子它是黑色且它的右孩子是红色。这就转化成了 Case 4。这个转换的目的是构造出 Case 4 需要的结构——一个黑色兄弟兄弟的右孩子是红色。注意这个 Case 只调整兄弟子树内部不涉及x路径的黑高所以不会让情况变得更糟。Case 4兄弟是黑色兄弟的右侧孩子是红色x是左孩子的前提下这是直接解决问题的 Case。做法是把父节点的颜色赋给兄弟把父节点染黑兄弟的右孩子染黑然后左旋父节点。旋转后x路径上多了一个黑色节点双黑被消除。整个树重新平衡修复结束。这里的颜色安排需要仔细推敲。父节点p原来的颜色是未知的可能是红也可能是黑。旋转后w取代了p成为子树根为了不破坏外部黑高w必须继承p的原色。p变成黑色是为了给x路径补上缺失的黑w的右孩子变黑是为了维持w右子树的黑高。这个 Case 执行完后从w的视角看两条子路径的黑高都恢复平衡循环终止。对称地如果x是右孩子处理方式镜像翻转即可。5.4 删除修复的实现与图解对照直接看代码。这段实现我加入了哨兵判断实际工程里可以用nullptr配合 careful 判空也可以用一个静态 NIL 节点。为了简洁这里用nullptr但你要清楚面试手写时判空是容易出错的点。void fixDelete(Node* root, Node* x) { while (x ! root (x nullptr || x-color BLACK)) { Node* parent x-parent; if (x parent-left) { Node* w parent-right; if (w ! nullptr w-color RED) { // Case 1 w-color BLACK; parent-color RED; leftRotate(root, parent); w parent-right; } if ((w-left nullptr || w-left-color BLACK) (w-right nullptr || w-right-color BLACK)) { // Case 2 w-color RED; x parent; } else { if (w-right nullptr || w-right-color BLACK) { // Case 3 if (w-left ! nullptr) w-left-color BLACK; w-color RED; rightRotate(root, w); w parent-right; } // Case 4 w-color parent-color; parent-color BLACK; if (w-right ! nullptr) w-right-color BLACK; leftRotate(root, parent); x root; } } else { // 对称逻辑省略和上面完全镜像 Node* w parent-left; if (w ! nullptr w-color RED) { w-color BLACK; parent-color RED; rightRotate(root, parent); w parent-left; } if ((w-left nullptr || w-left-color BLACK) (w-right nullptr || w-right-color BLACK)) { w-color RED; x parent; } else { if (w-left nullptr || w-left-color BLACK) { if (w-right ! nullptr) w-right-color BLACK; w-color RED; leftRotate(root, w); w parent-left; } w-color parent-color; parent-color BLACK; if (w-left ! nullptr) w-left-color BLACK; rightRotate(root, parent); x root; } } } if (x ! nullptr) x-color BLACK; }这里有一个非常容易踩的坑当x是nullptr时进入循环的条件判定会依赖x-color的读取。我上面写的是(x nullptr || x-color BLACK)来避免空指针访问。但在循环体内w-left或w-right也可能为空所以在 Case 3 访问w-right-color之前必须先判空。很多初学者在删除一个只有一个孩子且为黑色的节点时x就是那个唯一的子节点长大后为空此时判空逻辑必须处理好否则直接段错误。5.5 删除主逻辑有了修复函数删除主逻辑的核心反而是找到实际要删除的节点并正确摘除void transplant(Node* root, Node* u, Node* v) { if (u-parent nullptr) root v; else if (u u-parent-left) u-parent-left v; else u-parent-right v; if (v ! nullptr) v-parent u-parent; } void deleteNode(Node* root, int key) { Node* z search(root, key); if (z nullptr) return; Node* y z; Color y_original_color y-color; Node* x; if (z-left nullptr) { x z-right; transplant(root, z, z-right); } else if (z-right nullptr) { x z-left; transplant(root, z, z-left); } else { y minimum(z-right); y_original_color y-color; x y-right; if (y-parent z) { if (x ! nullptr) x-parent y; } else { transplant(root, y, y-right); y-right z-right; y-right-parent y; } transplant(root, z, y); y-left z-left; y-left-parent y; y-color z-color; } delete z; if (y_original_color BLACK) { fixDelete(root, x); } }这里要注意x的初始化。当z有右孩子且我们使用后继y时x y-right可能为空。如果y的父节点不是ztransplant(root, y, y-right)之后y被摘除y-right成了自由节点。后续fixDelete需要用x作为“携带双黑”的起点。如果x为空fixDelete里要能够处理空的x节点。关于minimum函数的实现就是沿着右子树一路向左Node* minimum(Node* node) { while (node-left ! nullptr) node node-left; return node; }5.6 为什么说删除是面试分水岭插入调整只有三种 case而且逻辑相对直观大多数人背一背能应付。删除调整有四种 case还分左右对称每种 case 内部还可能有嵌套转换。面试时能把删除 case 的原理解释清楚而不是单纯背代码的人说实话不多。我见过的候选人里能讲清楚“Case 2 为什么把兄弟染红然后问题为什么上移到父节点”的基本上都能过算法轮。我的建议是练习时不要对着代码硬读而是按“兄弟是什么颜色→兄弟的孩子是什么颜色”这个决策树走。自己画一颗失衡的树手动按 case 推演一遍再用代码验证。推演四五遍之后你会发现删除其实比插入更有规律——它本质上就是不断想办法从兄弟子树“借一个黑色”。6. 完整代码与调试技巧到这里核心逻辑都讲完了。下面是完整可编译的 C 实现包含中序遍历和查找接口。为了可读性异常处理和内存管理我做了简化实际项目里请务必用 RAII 或智能指针。#include iostream 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) {} }; class RBTree { public: RBTree() : root(nullptr) {} void insert(int key) { Node* z new Node(key); Node* y nullptr; Node* x root; while (x ! nullptr) { y x; if (key x-key) x x-left; else x x-right; } z-parent y; if (y nullptr) root z; else if (key y-key) y-left z; else y-right z; fixInsert(z); } void remove(int key) { Node* z search(root, key); if (z nullptr) return; Node* y z; Color y_original_color y-color; Node* x; if (z-left nullptr) { x z-right; transplant(z, z-right); } else if (z-right nullptr) { x z-left; transplant(z, z-left); } else { y minimum(z-right); y_original_color y-color; x y-right; if (y-parent z) { if (x ! nullptr) 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_original_color BLACK) fixDelete(x); } void inorder() { inorderHelper(root); std::cout std::endl; } private: Node* root; void leftRotate(Node* x) { Node* y x-right; x-right y-left; if (y-left ! nullptr) y-left-parent x; y-parent x-parent; if (x-parent nullptr) 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* y) { Node* x y-left; y-left x-right; if (x-right ! nullptr) x-right-parent y; x-parent y-parent; if (y-parent nullptr) root x; else if (y y-parent-left) y-parent-left x; else y-parent-right x; x-right y; y-parent x; } void fixInsert(Node* z) { while (z-parent ! nullptr z-parent-color RED) { Node* grand z-parent-parent; if (z-parent grand-left) { Node* uncle grand-right; if (uncle ! nullptr uncle-color RED) { z-parent-color BLACK; uncle-color BLACK; grand-color RED; z grand; } else { if (z z-parent-right) { z z-parent; leftRotate(z); } z-parent-color BLACK; grand-color RED; rightRotate(grand); } } else { Node* uncle grand-left; if (uncle ! nullptr uncle-color RED) { z-parent-color BLACK; uncle-color BLACK; grand-color RED; z grand; } else { if (z z-parent-left) { z z-parent; rightRotate(z); } z-parent-color BLACK; grand-color RED; leftRotate(grand); } } } root-color BLACK; } void fixDelete(Node* x) { while (x ! root (x nullptr || x-color BLACK)) { Node* parent x-parent; if (x parent-left) { Node* w parent-right; if (w ! nullptr w-color RED) { w-color BLACK; parent-color RED; leftRotate(parent); w parent-right; } if ((w-left nullptr || w-left-color BLACK) (w-right nullptr || w-right-color BLACK)) { w-color RED; x parent; } else { if (w-right nullptr || w-right-color BLACK) { if (w-left ! nullptr) w-left-color BLACK; w-color RED; rightRotate(w); w parent-right; } w-color parent-color; parent-color BLACK; if (w-right ! nullptr) w-right-color BLACK; leftRotate(parent); x root; } } else { Node* w parent-left; if (w ! nullptr w-color RED) { w-color BLACK; parent-color RED; rightRotate(parent); w parent-left; } if ((w-left nullptr || w-left-color BLACK) (w-right nullptr || w-right-color BLACK)) { w-color RED; x parent; } else { if (w-left nullptr || w-left-color BLACK) { if (w-right ! nullptr) w-right-color BLACK; w-color RED; leftRotate(w); w parent-left; } w-color parent-color; parent-color BLACK; if (w-left ! nullptr) w-left-color BLACK; rightRotate(parent); x root; } } } if (x ! nullptr) x-color BLACK; } void transplant(Node* u, Node* v) { if (u-parent nullptr) root v; else if (u u-parent-left) u-parent-left v; else u-parent-right v; if (v ! nullptr) v-parent u-parent; } Node* search(Node* node, int key) { while (node ! nullptr node-key ! key) { if (key node-key) node node-left; else node node-right; } return node; } Node* minimum(Node* node) { while (node-left ! nullptr) node node-left; return node; } void inorderHelper(Node* node) { if (node nullptr) return; inorderHelper(node-left); std::cout node-key (node-color RED ? (R) : (B) ); inorderHelper(node-right); } }; int main() { RBTree tree; for (int k : {10, 20, 30, 15, 25, 5, 1, 2, 3, 4}) tree.insert(k); tree.inorder(); tree.remove(20); tree.remove(10); tree.inorder(); return 0; }这段代码我按标准教材的方式封装成了类旋转函数在类内部直接访问root相比前面的全局函数风格更贴近实际工程。你编译运行后应该能看到中序遍历始终有序而且删除前后都满足红黑性质。怎么验证性质是否正确中序遍历只能验证 BST 有序性不能验证红黑规则。我建议你写一个validate()递归函数检查组件的五条规则这是调试红黑树最重要的工具int validateHelper(Node* node, int blackCount, int rootBlackHeight) { // 递归检查黑高一致性并返回本路径的黑节点数 }粗调思路插入后用中序遍历确认有序性删除后用验证函数检查黑高一致性。这两条过了红黑树基本不会有大问题。调试时还有一个非常实用的技巧最小复现。当你发现删除后性质被破坏不要在大树上调试而是尝试找到一个key序列插入后删除某个值能稳定复现问题然后把序列缩到最短。红黑树出现 bug 的最常见原因不外乎三类旋转时父指针没更新全、fixDelete的 Case 顺序写错比如先判 Case 3 再判 Case 2、以及对nullptr节点颜色的处理不对。记住这三类排查时间能缩短一半以上。7. 扩展思考红黑树之外的选择红黑树讲完你可能想问那 AVL 树呢B 树呢跳表呢它们在工程里哪里用得上这里我简单梳理一下方便你在面试时展示全局视野而不是只会背红黑树。选择树形结构的核心就看两个指标查询是内存中还是磁盘上以及写入频率是高是低。如果写入少、查询极频繁AVL 树更合适。它的高度更矮查找更快但每次插入删除的旋转成本更高。典型场景是内存中的只读元数据索引。如果数据规模巨大、存在磁盘上B 树 / B 树才是主角。因为磁盘 IO 的代价远高于内存比较B 树的“多路分支”让树高降到极低一次磁盘读页能带走大量索引。MySQL InnoDB 的聚簇索引就是 B 树。如果写多读少、并发环境跳表也很好。Redis 的有序集合底层就是跳表因为它实现简单、无锁化改造容易查找效率平均也有 O(log n)。红黑树的优势在于均衡无论读写都稳定在 O(log n)旋转次数平均比 AVL 少实现又不像 B 树那样要考虑分页和分裂。这也是 C 标准库选它作为关联容器底层实现的原因。如果你还想进一步挑战自己可以考虑实现以下扩展支持迭代器做成类似std::map的接口。用模版泛化 key 类型支持任意可比较类型。加入哨兵 NIL 节点省去大量空指针判断这是工业级实现的常见做法。做随机插入删除压测对比std::map的性能找出实现上的可优化点。我在做第 3 项时最大的体会是哨兵节点虽然代码更优雅但写parent指针时很容易忘记把 NIL 的 parent 也维护好反而引入隐蔽 bug。所以如果你第一次写用nullptr 判空的方式更不容易出错。等完全想明白了再改哨兵实现也不迟。红黑树的本质不是那几组旋转动作而是“用局部变换维护全局平衡”的思维模型。我把这个模型学会之后再看 B 树的分裂合并、看跳表的多层级联更新都觉得背后有相通之处。这也是我希望这篇文章能达到的效果——不是让你背代码而是让你真正理解它为什么这样转、为什么这样染从而在面试中对答如流更在工程选型时心里有数。
📝

华诺云谱内容团队

资深建站顾问 · 行业研究员

10年+企业数字化服务经验,专注智能建站、SEO优化与品牌营销,持续输出建站技巧、行业洞察与营销干货,已帮助5000+企业实现数字化增长。

你可能需要的服务

订阅华诺云谱资讯周报

每周一封,精选建站技巧、SEO与营销干货,直达邮箱。已有 8,000+ 企业主订阅,助你少走弯路。