
红黑树这个知识点我鸽了得有五年。每次别人问我“你会手写红黑树吗”我都先说“会啊会啊”然后回去一搜代码发现自己连旋转方向都要想半天。这次终于咬着牙把它彻底搞懂了标题里的“咕咕咕”就是我的真实写照——数据结构界的“下次一定”。先把话说清楚红黑树不是某一种语言里才有的黑魔法它本质上是一棵自平衡的二叉搜索树能在最坏情况下把查找、插入、删除都维持在 O(log n)。你在 Java 的 TreeMap、C 的 std::map、Linux 内核的 CFS 调度器里看到它在数据库索引相关讨论里也总能听到它和 B 树打架。这篇文章不打算用教科书那套纯理论写法我把平时学的时候踩过的坑、面试被问蒙的问题还有插入删除的完整调整逻辑一并写完不管你是刚开始学数据结构还是刷题准备面试都会有收获。1. 红黑树整体设计与思路拆解1.1 二叉搜索树退化的痛点先聊为什么会有红黑树。普通的二叉搜索树BST有个致命缺点它不限制树长什么样只要求“左小右大”。所以当你按顺序插入 1、2、3、4、5 这些数据时树会直接塌成一条链表查找第 5 个数要一路走到头复杂度退化成 O(n)。在数据量大的场景里这个“退化”是致命的。解决办法自然是想办法让树保持“紧凑”也就是在插入、删除之后做一些调整让树尽量平衡。平衡的终极形式是每个节点的左右子树高度差不多完全一致比如 AVL 树要求任何节点的左右子树高度差不能超过 1。但 AVL 有个问题它管得太严了每次插入触发旋转的概率高删除的时候旋转次数更夸张。在内存里跑还行但如果数据量大、写操作频繁这调整开销就显得有点“过度敏感”。红黑树选了一个讨巧的折中它不追求严格的高度平衡而是从“路径上黑色节点的数量”出发用颜色约束整棵树。规则保证的是“任意一条路径的长度不会超过最短路径的两倍”这虽然看起来比 AVL 的差 1 要松但足够把复杂度锁死在 O(log n)。1.2 为什么选“弱平衡”而不是“严格平衡”我花了很久才想明白为什么工程上普遍选红黑树而不是 AVL。答案是AVL 的“严格平衡”更多是为了“查询敏感型”场景但大多数真实系统是读写混合。红黑树的优势在于插入、删除的旋转次数平均更少实际的统计表现非常稳。举个例子你在维护一个需要频繁插入和删除的有序集合AVL 可能会因为高度差超 1 而立刻旋转红黑树却可以把“不平衡”暂时攒着用颜色和局部旋转慢慢消化。对 CPU 缓存而言少几次点的移动比高度差一点更重要。当然这不是说 AVL 没用。如果你做的是读多写少、且数据可以一次性加载的查询服务AVL 反而表现不差。关键是“为什么选红黑树”的答案不是“AVL 错红黑树对”而是“在读写均衡的真实场景里红黑树的综合维护成本更合适”。1.3 红黑树其实是 2-3-4 树的“内存版”有一个特别能帮助理解的角度红黑树和 2-3-4 树是等价的。2-3-4 树的节点可以存 1、2、3 个值分别对应 2、3、4 个孩子它天然是平衡的。红黑树把“多键节点”用红色的边“绑”在一起一个黑节点加上一个红孩子节点就等价于 2-3-4 树里的一个 3 节点一个黑节点加两个红孩子等价于一个 4 节点。一旦接受这个等价关系很多红黑树的“奇怪规则”就变得合理了为什么新插入的节点是红色因为在 2-3-4 树里插入的本质是“往现有多键节点里放一个新值”不改变树的层数对等过来就是“挂一个红节点”不破坏黑色高度。为什么根节点必须是黑色因为根如果是一个“绑出来的红节点”说出去太奇怪了而且 2-3-4 树里根节点要么不分裂要么变成独立的新根对应过去根必须黑色。2. 红黑树的五条规则与“为什么”2.1 五条规则逐条拆解教科书里给出的红黑树规则一般是这几条每个节点要么是红色要么是黑色根节点是黑色每个叶子节点NIL 哨兵是黑色如果一个节点是红色那么它的孩子节点必须是黑色从任意节点到其每个叶子节点的所有路径包含相同数量的黑色节点你光看这五条会觉得莫名其妙尤其第 5 条。我刚开始也死背后来发现必须从“2-3-4 树等价”的角度才能读懂。红色节点只是“附属”黑色节点才是“骨架”。第 5 条本质是在说“无论 2-3-4 树怎么分裂、怎么合并每一层的高度是固定的所以黑色高度一致”。第 4 条则保证了“两个红节点不能接在一起”否则 2-3-4 树里的对应节点就会塞超过 3 个值变成 5 节点了。还有一点很容易忽略“叶子节点是黑色”里的叶子指的是 NIL 空节点。你用 null 判断其实这些虚的 NIL 也要视为黑色。许多初学者在实现旋转的时候对空指针调用 parent、color 之类的属性而报错就是因为没有在逻辑上把 NIL 当成一个真实存在但是黑色的哨兵。2.2 从规则推导出“最长路径不超过最短两倍”有人问就这五条规则怎么就能保证 O(log n) 了关键还是第 5 条配合第 4 条。设根到某个叶子路径上有 b 个黑色节点不计根本身这是不变的。由于红色节点不能相邻路径上红节点的数量顶多等于黑节点的数量因此最长路径不会超过 2b。最短路径当然就是一条全黑的路径长度是 b。这意味着整棵树的高度不会超过 2b。接下来看节点数 n 和黑色高度 b 的关系。一棵黑色高度为 b 的红黑树它至少包含的节点数是一棵高度 b 的完全二叉树节点数即至少 2^b - 1 个。反推 b ≤ log2(n1)整棵树高度不超过 2b也就是 O(log n)。如果借用“2-3-4 树等价”会更直观2-3-4 树的高度始终是 O(log n)红黑树只是它的二叉展开版本高度最多多两三倍量级不会变。2.3 新节点为什么默认是红色这个点几乎必考但很多人答不到点上。插入的新节点默认必须是红色。如果默认黑色那就等于在某个路径上直接加了一个黑色节点立刻导致第 5 条规则被破坏而且是全局性的破坏只能在树根方向反复调整。而默认红色最坏情况只是可能遇到“红红冲突”第 4 条而这个冲突是局部的可以通过变色和旋转解决。你可以类比成装修黑色改动动的是承重墙红色改动只是换个窗帘。承重墙你不想随便敲窗帘怎么挂都行。所以新来的节点先装成红色出了问题再局部收拾。3. 红黑树插入原理与实操3.1 插入的整体流程插入分三步第一步按普通二叉搜索树的规则找到插入位置。这一层逻辑跟 BST 完全一样小于向左、大于向右直到找到空位。第二步把新节点染成红色挂上去并补好 NIL 哨兵。第三步从新节点开始往上调整不断修复红黑树的规则。调整结束的标准是“当前节点的父节点是黑色”或者“当前节点是根节点”。如果当前节点是根节点还要顺手把它染黑。如果你写代码时发现插入结束以后树还是乱的八成是第二步的“染红”没做或第三步循环条件写错了。我在教学时见过有人把调整入口漏掉直接 return树裂了还一脸问号。3.2 插入的三种调整场景插入后的修正场景通常按“叔叔节点”的颜色分两类再按“当前节点是左孩子还是右孩子”细分。叔叔节点就是当前节点祖父的另一个孩子。第一种场景叔叔为红色。这种情况最好办直接变色。把父节点和叔叔节点变成黑色把祖父节点变成红色然后把当前节点指向祖父继续往上处理。为什么要这么干因为原来祖父是黑的你把它染红等于把“问题”往上推一层而父节点、叔叔变黑修复了局部的红红冲突。这对应 2-3-4 树里的“节点分裂”。第二种场景叔叔为黑色且当前节点是右孩子。这种情况不能直接变色解决要先做一个左旋把当前节点“升”成父节点的位置父节点落成左孩子。做完这一步问题就转换成了第三种场景的样子。注意此时当前节点的指针要指向原来的父节点。第三种场景叔叔为黑色且当前节点是左孩子。这时做一次右旋同时把父节点染黑、祖父染红。旋转之后原来的父节点顶替了祖父的位置红色冲突被化解黑色高度也保持住了。旋转是红黑树操作里的“灵魂”方向一定要想清楚。左旋的意思是“当前节点的右孩子提上来当前节点下沉为左孩子”右旋则相反。其实不用死记你只要在图上把几条链换掉位置就明白了。3.3 插入修正的代码实现与验证我放一个 Java 风格的核心逻辑这部分跟语言无关重点是结构。private void fixAfterInsert(Node node) { while (node ! null node ! root parentOf(node).color RED) { Node parent parentOf(node); Node grandparent parentOf(parent); // 父节点是祖父的左孩子 if (parent grandparent.left) { Node uncle grandparent.right; // case 1: 叔叔是红色变色上溯 if (uncle ! null uncle.color RED) { parent.color BLACK; uncle.color BLACK; grandparent.color RED; node grandparent; } else { // case 2: 叔叔是黑色且当前节点是右孩子先左旋 if (node parent.right) { node parent; rotateLeft(node); } // case 3: 叔叔是黑色且当前节点是左孩子右旋 // 注意此时 node 已指向旧 parent parent parentOf(node); grandparent parentOf(parent); parent.color BLACK; grandparent.color RED; rotateRight(grandparent); } } else { // 父节点是祖父的右孩子对称处理 // 把上面代码里的 left/right 互换即可 } } root.color BLACK; }写代码时最容易出错的是 case 2 转 case 3 之后屏幕上的节点名跟代码变量对不上。我的建议是在纸上画一次完整过程用三个不同颜色标出 node、parent、grandparent 的变化路径代码写起来会顺手很多。如果你写完之后想快速验证可以把大量随机数插入一棵红黑树然后做一次遍历检查每个红色节点是否出现红孩儿再递归检查各路径黑色数量是否一致。手动检查太容易漏我一般会直接写一个校验方法跑一遍具体方法见后文“自检方法”部分。4. 红黑树删除原理与实操4.1 删除前戏找到后继、替换颜色删除比插入难难在“一个黑色节点被删掉之后某条路径上的黑色高度少了一截”。插入时的红红冲突是局部的删除时的黑色缺失是“漏水的桶”处理起来麻烦很多。先看常规 BST 的删除逻辑。如果被删节点有两个孩子不能直接删要找到它的后继节点右子树最左节点来替换。删除其实是“用后继节点的值覆盖被删节点再删掉后继节点本身”。后继节点最多只有一个孩子所以真正物理删除的节点只可能是“没有孩子或只有一个孩子”的节点。这里有个关键细节被删节点的颜色要记录下来。如果我们删掉的是一个红色节点完全不会影响黑色高度调整直接结束。如果删掉的是黑色节点就需要进入修正流程。另外如果用于替换的节点是红色可以把它染黑补位问题当场解决。你可以把删除修正想成“给黑色缺失的地方开了一张欠条”。我们用一个“双重黑”的概念来表示某个节点本质上多携带了一个额外的黑色修正的过程就是把这个双重黑推来推去最后推成“真黑”或者直接消掉。4.2 双重黑与四种兄弟场景删除修正的核心是看“被删节点之后的替代节点”的兄弟节点情况。假设当前节点是指向那个带有双重黑的节点记作 x我们分四类处理第一种x 的兄弟节点是红色。此时把兄弟染黑父节点染红然后以父节点为轴旋转兄弟是右孩子就左旋反之右旋让那个红色兄弟下沉问题转化为兄弟为黑的情况继续处理。第二种x 的兄弟节点是黑色且兄弟的两个孩子都是黑色。这种情况最简单把兄弟染红让父节点成为新的“双重黑”向上传播。如果父节点原来是红色那它正好补上变成黑色循环结束如果父节点原来是黑色就继续处理父节点。本质是这个路径少了一个黑色我先把问题转嫁给父亲。第三种x 的兄弟节点是黑色兄弟的左孩子是红色、右孩子是黑色。这种情况要先把兄弟的左孩子染黑、兄弟染红然后以兄弟为轴右旋这样原来的左孩子跑到兄弟的位置且右孩子变红问题就转化成了第四种。这个转换有点绕我建议画个图自己走一遍。第四种x 的兄弟节点是黑色兄弟的右孩子是红色。这就是终点了。把兄弟的颜色改成父节点的颜色此时不管父节点是什么颜父节点染黑兄弟右孩子染黑然后以父节点为轴旋转。做完这一步双重黑节点被“免责”修正结束。这四种场景的顺序不能乱。很多教程直接用《算法导论》里的 Case 1-4你不理解就背但一旦面试官多问一句“为什么 Case 3 要这么多余地转一圈”答不上来就尴尬了。第三种的目的是把“红色侄子”引导到外侧因为只有外侧红节点才能通过一次旋转填补缺失的黑色。4.3 删除修正的代码骨架以下是删除后修正的 Java 风格骨架对称部分不重复写private void fixAfterDelete(Node x) { while (x ! root colorOf(x) BLACK) { if (x parentOf(x).left) { Node sibling parentOf(x).right; // case 1: 兄弟红色 if (colorOf(sibling) RED) { sibling.color BLACK; parentOf(x).color RED; rotateLeft(parentOf(x)); sibling parentOf(x).right; } // case 2: 兄弟黑色且两个孩子都黑 if (colorOf(sibling.left) BLACK colorOf(sibling.right) BLACK) { sibling.color RED; x parentOf(x); } else { // case 3: 兄弟黑色左孩子红右孩子黑 if (colorOf(sibling.right) BLACK) { sibling.left.color BLACK; sibling.color RED; rotateRight(sibling); sibling parentOf(x).right; } // case 4: 兄弟黑色右孩子红 sibling.color colorOf(parentOf(x)); parentOf(x).color BLACK; sibling.right.color BLACK; rotateLeft(parentOf(x)); x root; } } else { // 对称处理 } } x.color BLACK; }注意 case 1 处理之后要更新 brother 指针因为在旋转后兄弟节点变了。我在写这段代码时最常犯的错误是旋转之后继续使用旧的 sibling 引用导致空指针或者改错颜色。解决的办法是写每行代码时都问自己此刻我用到的 parent、sibling 还是我脑子里以为的那个节点吗5. B树是红黑树吗——横向对比5.1 先给结论不是最近总有人搜“B树是红黑树吗”答案很明确不是。这是两种完全不同的数据结构。红黑树是二叉搜索树每个节点最多只能有两个孩子B 树是多路搜索树一个节点可以有很多个孩子。它们在“保持平衡”这件事上目标一致但手段和适用的场景完全不同。很多人混淆它俩大概率是因为都在讲“自平衡”“索引优化”这些词而且都是面试高频考点。但你要是真去实现一遍就发现两者的差异比相似点多得多。5.2 红黑树 vs B树我觉得最清晰的对比方式是看“它俩各自在帮谁干活”。红黑树在内存里干活B 树在磁盘上干活。这个前提决定了其他一切差异。内存的访问是纳秒级随机访问CPU 缓存也很值钱所以红黑树可以用指针自由跳转强调“高度尽量低但不追求极端多叉”。而磁盘的一次 IO 是大规模数据的一次传输访问是以“页”为单位的。B 树让每个节点尽量放满一个页的大小这样一次磁盘 IO 就能读取尽可能多的索引数据把树高压缩到 3 到 4 层。百万甚至千万级数据B 树可能只需要几次磁盘 IO红黑树则需要接近 20 次。范围查询是另一个分水岭。红黑树要做中序遍历才能拿到一个有序区间遍历时节点在内存里不连续缓存命中率一般。B 树的数据都挂在叶子节点而且叶子节点通过链表串联区间查询从头到尾顺着这个链表一路扫就行非常平滑。数据库索引对“范围查询”的要求极高所以 InnoDB 的索引就用 B 树。工程上还有一点红黑树节点只有红黑两种颜色删除时为了恢复黑色高度需要复杂的旋转和变色而 B 树删除只需要做借位或合并规则相对直白。5.3 实际选型建议那什么时候用红黑树什么时候用 B 树我的经验是只要你的数据都在内存里且需要快速插入、删除、查找的有序结构优先考虑红黑树或者它的变体。Java 的 TreeMap、C 的 std::map、Linux 内核的 epoll、CFS 调度器都是红黑树原因就是它们是纯内存操作。如果你的数据量超过内存、需要持久化存储或者有非常典型的大范围范围查询就别纠结红黑树了直接用 B 树。数据库索引、文件系统目录索引都是这个思路。还有一种情况是数据量在内存能放下但范围查询极多那可能应该考虑在红黑树之外加一层有序数组或者跳表结构而不是强行用红黑树扫区间。其实跳表在工程里也很常见Redis 的有序集合就用了跳表因为它实现简单、范围查询流畅。6. 常见问题与排查技巧实录6.1 面试高频问题速答“B树是红黑树吗”这种问题其实暴露的是基础概念不清晰。面试里还经常被问到这样几组问题第一组红黑树和 AVL 树怎么选回答的核心是“查询多、写少选 AVL写多、读多均衡选红黑树”。AVL 高度更严谨但维护成本更高红黑树高度最多是两倍但平均调整次数少。第二组为什么新插入的节点默认是红色答案一句话避免破坏黑色高度规则第 5 条。黑色是被删多了才有的问题红色只是局部“红红”冲突。第三组红黑树查找的具体复杂度是多少严格说查找是 O(log n)n 是节点总数。不少人答“最坏 2log(n1)”这其实是高度上限的推导不是查找的实际复杂度。第四组红黑树支持二分查找吗这不是一个有效问题它本身就是二叉搜索树的一种查找就是比较大小往左或往右走。我自己其实被问过“红黑树的查找和普通二叉查找树有什么区别”答案是查找逻辑完全相同只是红黑树通过调整保证了树高不会退化。6.2 手写红黑树的合法性检查方法如果自己实现红黑树最好把“合法性检查”写成工具方法每次插入删除后跑一遍。两个最核心的检查项第一个检查项红色节点的 parent 不能是红色。递归遍历发现红节点就检查它的父节点。第二个检查项以某个节点为根的子树中所有叶子路径的黑色节点数相同。可以用递归函数返回“从当前节点到叶子路径的黑色节点数”如果某个子节点返回的黑色数不一致就说明规则被破坏。再推荐一个暴力但有效的办法插入一万个随机数同时维护一个TreeSet或std::set每次插入后把红黑树序列化成中序序列和TreeSet的迭代结果比对。如果中序遍历有序且合法检查通过基本可以确认实现没大问题。这个方法能帮你找出“旋转之后节点顺序被破坏”的隐性 bug。6.3 踩坑记录红黑树的坑多到可以单开一篇文章。我踩过的典型问题有三个。第一个坑NIL 哨兵没建好直接用 null。颜色判断里 c.color 会空指针旋转里 null.parent 也会空指针。我的做法是定义一个静态的NIL节点颜色置黑所有叶子都指向它。从逻辑上讲NIL 也是削节点不能省。第二个坑旋转之后忘记更新根节点。旋转不是局部小动作它可能让“新根”变成原来某个子节点。如果代码里 root 没同步更新后面的遍历会直接炸。我建议旋转函数里专门处理一次如果要转的节点 parent 是 null就把当前新子树根赋给 root。第三个坑删除修正循环中的出口。双重黑传播到根时循环条件一般是while (x ! root x.color BLACK)退出去后手动x.color BLACK。有人会把根节点染色这一步漏掉导致根变成红色这是五条规则里的第二条不允许的。漏掉之后测试时不是每次都能看出来但一旦路径较深就会随机崩非常难排查。还有一个很实际的坑调试时不要用肉眼盯着树看一定要写“断言式”检查把“所有黑高一致”用代码验证不然红黑树这种结构太容易自我欺骗了。我后来在实现里加了一个 debug 开关只在检查模式跑性能无所谓出问题它能立刻报出来是哪个规则被破坏。从“咕咕咕”到“一次写对”如果你问我现在还会不会“咕”我敢说红黑树的基础实现我已经不需要看教材了。我的体感是插入部分搞明白之后删除就是一个翻来覆去的对称过程难点不在“懂”而在“练”。最后分享一个小技巧学红黑树的那几天我在纸上手动走完了十次插入和十次删除的全过程每一步都画图、记录颜色变化发现自己很快就建立起了直觉。比纯看十个视频都有用。如果你也准备学或者正在准备相关的面试千万别只背结论动手画一画把四种删除场景逐个推一遍那些看起来绕的 Case 其实就那么回事。