
红黑树是我学数据结构这几年里见过的最让人头疼又最优雅的结构。面试官一句“手撕红黑树”能让不少人当场冒汗。我也一样早期全靠硬背今天背完明天忘直到某天我把五条性质压成“根叶黑、不红红、黑路通”这句口诀再拿口诀当调试工具才真正把这些绕口令一样的规则变成能用的东西。这篇文章就聊这个红黑树性质口诀怎么来的每一条为什么这么定以及插入删除时怎么用口诀去分析、去自我检查帮你把这一块彻底打通。不管你是准备面试、复习数据结构还是工作中真的遇到 TreeMap、C map、Linux 内核里的红黑树都能有点收获。目标很简单看完之后你再也不用翻书就能把五条性质写全并且能拿着口诀去判断一棵树到底合不合法也能理解为什么它叫“平衡树”。1. 红黑树到底在解决什么问题1.1 从二叉搜索树失衡说起先回到最基础的二叉搜索树BST。BST 的优点是查找、插入、删除平均都能做到 O(log n)口算也很好理解中序遍历有序每次比较都丢掉一半子树。但这只存在于“理想情况”也就是树长得比较均匀的时候。如果数据是近乎有序的比如连续插入 1、2、3、4、5你会发现 BST 直接变成一条链表每个节点只有一个右孩子树的高度变成 n查找一个旧数据要遍历整条链复杂度降到 O(n)。这时候就需要“自平衡”。思路是让树在插入删除的过程中自己做点额外动作尽量保持“矮胖”而不是一头沉。最常见的两类平衡方案一类是 AVL 树一类就是红黑树。AVL 树要求任何节点的左右子树高度差绝对值不超过 1这叫“严格平衡”。严格的好处是树很矮查询非常快坏处是每次插入删除可能要频繁旋转为了维持那 1 的差值甚至经常走两圈代价偏高。红黑树则“宽松”很多它不要求高度差严格为 1而是给整棵树提了一组颜色规则最终的平衡效果是“任意一条路径的长度都不会超过另一条路径长度的两倍”。这句话怎么理解后面讲性质时会详细算。总之红黑树的定位是折中查询速度略逊于 AVL但插入删除时需要的调整次数平均更少所以在频繁写入、频繁删除的容器场景里常常胜出。1.2 红黑树的设计目标近似平衡为什么宁可牺牲一点查询速度也要降低调整的代价因为它解决的是实际工程问题。语言标准库里的有序关联容器比如 TreeMap、std::map不仅要求查询快还要求插入和删除稳。如果用 AVL数据一旦频繁变化旋转可能太勤快反而拖累整体性能。红黑树用“近似平衡”换来更少的旋转这是一笔非常划算的买卖。近似平衡具体有多近似可以这样感受红黑树的高度最多约为2 * log2(n1)。也就是在最坏情况下它的高度大概是完美平衡二叉树的两倍。而2log2(n1)依然属于 O(log n)对于 10 亿条数据log 级别和高两倍数量级依然是可接受的。于是红黑树的“平衡”是一个妥协后的产物它不追求绝对整齐只保证一个宽松的上界同时把维护成本压下来。那怎么才能做到“最长路径不超过最短路径的两倍”完全靠那五条性质。所以记忆五条性质本质上是在记“一组能保证近似平衡的约束条件”。理解了这层你就不会觉得性质是人为发明的死规则而是一整套实现目标的最小约束集。2. 一条口诀记住五大性质2.1 口诀全貌与逐句拆解红黑树的五条性质教科书上一般是这么写的每个节点要么是红色要么是黑色。根节点是黑色。每个叶子节点NIL 空节点是黑色。红色节点的两个子节点都是黑色不能连续出现红节点。对每个节点从该节点到达其后代叶子节点的所有简单路径上黑色节点的数量都相同。这五条确实容易记混。我常用的口诀是“左根右根叶黑不红红黑路通”。其中“左根右”其实是 BST 的中序顺序是红黑树作为二叉搜索树的基础这里可以先不管“根叶黑”对应第 2、3 条“不红红”对应第 4 条“黑路通”对应第 5 条。如果你不想要“左根右”也没关系我觉得最核心的是后面十二个字。逐句拆开来看第一句“节点非黑即红”说的是颜色枚举只有两种这是所有讨论的前提。第二句“根叶黑”里“根”指根节点必须是黑色“叶”指所有 NIL 空节点也算节点并且也是黑色。NIL 是什么在真正的代码实现中它是每个节点左右指针为空时指向的那个哨兵节点。注意这里不是普通“没有孩子”的真实节点而是空指针本身。很多初学者在这里栽跟头后面会专门讲。第三句“不红红”就是红色节点不能有红色父节点也不能有红色子节点。换句话说两个红色不能相邻。但注意黑色节点可以紧挨着比如黑父可以接黑子黑父也可以接红子红父只能接黑子红父不能接红子。红黑树并不要求每条路径红黑严格交替只是不允许“红-红”这种连接。第四句“黑路通”最绕它要求的是“黑高守恒”。从任意节点开始往每一个叶子 NIL 走路径上数到的黑色节点数必须一样多。这句话是所有性质中最重要的因为它直接决定了平衡的边界。2.2 每句口诀背后的原因为什么根必须是黑色一个常见的解释是如果根可以是红色那删除调整时可能遇到“红根”这种尴尬局面需要额外处理。另一个更直观的角度是根黑能给所有路径提供一个统一的黑色起点配合“黑路通”让每个叶子的黑高计算有一个共同基准。如果根是红虽然黑高会把根忽略掉但多个路径的开头节点颜色不一致计算和调整时的分支判断会更多。从定义上讲标准实现都要求根黑而且根从红色变黑不会破坏其他性质所以记“根叶黑”就对了。为什么 NIL 叶子是黑色因为如果你不把空节点算进去第 5 条性质就会失效。比如一个只有一个黑根节点、左右都是空指针的树如果忽略空节点根到“没有孩子的节点”的路径上根本没有可以数的黑色节点黑高变成未知。把 NIL 统一染成黑色之后整棵树就有了明确的“虚拟叶子”每一条真实路径都延伸到 NIL黑高才可以计数。这个设计也方便代码实现很多语言的库都会创建一个静态的 NIL 节点所有空指针都指向它省去大量判空分支。为什么不允许连续红这和黑路通要配合起来看。假设没有“不红红”那么一条路径可以全部由红色组成另一条路径全由黑色组成红黑树的平衡就会被打破。有了这条限制红色节点不能连续出现那么任意两条路径上的红色节点数量就有了可比性。在“黑路通”保证黑节点数量相同的前提下附加“红色不连续”就可以推出最长路径和最短路径的比值上限。最短路径可以全是黑节点最长路径只能是红黑交替红色最多比黑色多一个所以整条路径长度最多大约是黑数的两倍。这就是“近似平衡”的直接支撑。“黑路通”为什么能保证平衡可以做一个简单的思维实验设根到某个叶子 NIL 的黑色节点数量为 k。如果这条路径全是黑节点那么路径长度就是 k如果一条路径在黑节点之间插入了红色节点因为“不红红”红色之间至少隔着一个黑节点所以一条路径上红色节点数量最多也就是 k1。那么这条路径总长度最多是 k(黑) (k1)(红) 2k1而最短路径长度是 k。所以最长路径不会超过最短路径的两倍左右。这正是红黑树“近似平衡”的来源。3. 性质与操作口诀怎么指导插入和删除3.1 插入时口诀怎么用理解了性质再看操作就清晰多了。插入操作的第一步是按普通 BST 的规则把新节点加进来然后把新节点涂成红色。为什么不是黑色因为如果涂黑新节点所在路径立刻多了一个黑节点直接破坏“黑路通”而且这种破坏很难补救——你无法凭空把一个黑节点变成红而不影响别处。涂红则只可能破坏“不红红”而“不红红”的修复范围很小大多数时候只需要改变局部颜色或者做一两次旋转。插入之后用口诀逐条过一遍。第一看“根叶黑”如果插入的是空树那层根已经涂黑一般插入后根不会是问题。第二看“不红红”这是插入后检查的重点。如果新节点的父亲是黑色万事大吉树已经合法如果父亲是红色那出现了连续红得看“叔叔节点”的颜色。这里我把插入修复的流程概括成一个非常实用的决策表现状处理方法父节点是黑色什么都不用做插入完成父节点是红色叔叔是红色把父和叔变成黑色把祖父变成红色然后把“当前节点”上移到祖父处继续处理父节点是红色叔叔是黑色若当前节点、父、祖形成“之”字形先旋转一次变成直线形再旋转一次最后变色为什么要分这两种情况因为“叔叔是红”意味着祖父黑节点下面的两条路径上黑高暂时还守恒可以通过“把祖父变红、父和叔变黑”来把红色问题向上传递。这等价于把上面的黑色“借下来”整棵局部树的黑高不变。反过来“叔叔是黑”说明祖父下面一侧少了一个红节点局部黑高不一致靠变色解决不了必须通过旋转让红色节点重新布局。举个例子。依次插入 1、2、3。先插入 1 为黑空树根必须是黑。再插入 2 为红此时树是 1(黑)-右孩子 2(红)没有连续红合法。再插入 3新节点 3 是红父节点 2 是红这就不合法。此时祖父是 1黑叔叔是 1 的左 NIL 黑属于“叔叔黑”的情况。操作是先把 1、2、3 这一串“直线”做一次左旋转让 2 成为根旋转后 1 变成 2 的左孩子3 还在 2 右边。最后变色把新根 2 变成黑把 1 和 3 变成红。检查“不红红”黑-红-黑没问题“黑路通”从根 2 出发到左 NIL 有 2黑1红NIL(黑)2 个黑节点右边也是 2 个黑节点。树合法。如果在插入时需要处理“之字形”的情况也很好记比如节点是父的左孩子而父是爷爷的右孩子。这种情况直接旋转会扭所以先对父做一次旋转把它变成“直线”再用直线的方式修复。我的经验是不用记左旋右旋的具体旋转方向只要知道在“叔叔黑”时才需要旋转旋转后要保证中序顺序不变最后按“旋转后新子树根为黑、两个孩子为红”的规则染色就行。3.2 删除时口诀怎么用删除是红黑树里最复杂的一环但口诀依然管用。删除的第一步还是按 BST 规则来如果删除的节点有两个孩子通常找到它的后继节点右子树最左节点把后继的值复制到当前节点然后转为删除后继节点。这样真正被物理删除的节点最多只有一个孩子。接下来看被删节点的颜色。如果被删节点是红色直接删掉就结束了。为什么因为红色节点不影响黑高删掉它“黑路通”依然成立。真正麻烦的是删掉一个黑色节点因为一条路径上的黑节点数量会少 1打破了“黑路通”。一种通用的处理思路是把被删除位置想象成“双重黑”节点。也就是说这个位置比普通黑色节点多 “占” 一个黑导致这条路径黑高偏高修复的目标是把这个多余的黑“抵消”掉。然后不断检查当前节点按照兄弟节点和侄子节点的颜色做不同操作。口诀在这里的作用是提醒你一切操作最终要让“每条路径黑数相同”恢复同时不能留下连续红。删除修复的几种经典情况也可以整理成速查表当前节点双重黑的兄弟情况对应操作兄弟是红色把兄弟变黑父变红沿父旋转一次重新评估兄弟是黑色兄弟的两个孩子都是黑色把兄弟变红当前节点的双重黑消除问题向上移到父节点兄弟是黑色兄弟有一个或两个红色孩子做相应旋转然后变色结束为什么这些操作是这样核心就是“借黑”或“退黑”。当兄弟是黑、侄子也黑时没办法从兄弟侧借到黑色只能把兄弟变红让“黑高不足”问题向上归并。当侄子中有红时可以通过旋转把这个红变成黑补到缺失的一侧。这对应口诀里的“黑路通”和“不红红”一个维护数量的守恒一个维护颜色的合法。我不建议一开始就死磕删除的八种分支那样很容易晕。更好的做法是先把插入的三种情况和 4.1 的对照表记熟删除只记住两个核心动作——“把兄弟搞红向上退”和“旋转借红当黑”。实际写代码时拿口诀逐条检查每一步的结果多做几个例子分支就慢慢记住了。3.3 变色与旋转口诀落地的两大工具红黑树的调整只有两种工具变色和旋转。变色就是改变节点的红黑状态旋转包括左旋和右旋它们能改变局部子树的结构但不会破坏 BST 的中序有序性。这两者怎么配合口诀能给你方向。当“不红红”被破坏时如果叔叔是红说明可以先通过变色解决把红色问题向上推。如果叔叔是黑说明局部的黑高已经不对变色解决不了需要旋转。当“黑路通”被破坏时如果某路径黑数少 1你往往需要从兄弟子树“借”一个黑色过来借的动作就是旋转。旋转会把某一个黑节点挪到另一边然后配合变色把黑色分配到正确的位置。所以你可以简单理解为变色解决颜色冲突比如连续红旋转解决黑高失衡尤其是删除时从兄弟借黑色节点大多数情况下是旋转加变色一起用。举个例子说明“变色不够旋转来凑”。假如一棵局部树是父黑、左子红、左孙红其他路径黑高都满足但这条路径出现了“红-红”连续。如果父的右子树是黑 NIL此时叔叔是黑不能通过单纯变色消掉。因为如果把父变红、孩子变黑那左孩子这条路径的黑高减少了 1会破坏“黑路通”。必须先做一次右旋转把左子提升为父原来的父变成右孩子再把新父变黑、原来的父变红这样就同时满足了“不红红”和“黑路通”。这类操作自己不动手画几遍很难体会。4. 常见问题与踩坑实录4.1 把叶子节点搞错的坑这是我见过最多的问题。很多人看教材以为红黑树里说的“叶子”是那些没有孩子的真实节点。但红黑树明确要求“叶子是 NIL 空节点”这是两个完全不同的东西。真实节点的空指针才叫叶子。举个例子一棵只有根节点 10 的黑树它的左右孩子都是 NILNIL 在概念上是两个黑节点。如果你只把“没有孩子的真实节点”当叶子那根节点本身就是叶子但这时候根到根自己的路径算黑高是 0还是 1怎么算都不对。正确做法是把 NIL 当做一个固定的黑色哨兵节点。写代码时建议单独创建一个NIL对象所有空指针都指向它而不是直接存nullptr。这样“黑路通”的递归终止条件才能写清楚删除时的“双重黑”也更好处理。我自己一开始直接在空指针上判空结果删除代码里到处都是if (x nullptr)后来改成 NIL 哨兵方案逻辑清晰了不止一倍。4.2 黑高和“路径上的黑色节点”混淆黑高的定义是从某个节点出发但不包含该节点到达叶子 NIL 所经过的黑色节点数量。很多人把它记成包含当前节点一验证时常不对。比如根是黑根到 NIL 的黑高是 2如果 NIL 算黑的话而不是把根也算成 1 后再数 NIL 成 2。我推荐一个统一的自查口径从当前节点的子节点开始数遇到红节点跳过遇到黑节点加一遇到 NIL 算一个黑节点最终数字就是黑高。注意如果你从根开始验证整棵树那么“根到左右两个 NIL 的黑数必须相等”这里的“根”通常不计入总数。为什么很多人算式一样结果却不一样就是因为把起点的黑节点重复计算了。这里强烈建议先在纸上用一棵合法的红黑树手动从不同节点出发各数一遍确认起点不算、NIL 算黑这样就不容易混。4.3 口诀记了但不会用背会了口诀遇到具体问题还是不会查这不是个例。原因是口诀是“静态规则”而插入删除是“动态过程”你得把口诀变成“检查清单”才知道在哪一步该看哪一条。我的习惯是每次插入或删除之后按下面三步走看根根是不是黑色不是就直接标红。找连续红从根往下DFS 遍历每个节点检查是否存在“红-红”父子对。数黑路从根出发遍历每一条到 NIL 的路径统计黑色节点数量看是否全部相等。只要这三步都过了树就是合法红黑树。你甚至可以拿这个清单去手写一个验证函数把这套逻辑写进单元测试里比死记代码实用得多。拿这个思路去做题你会发现口诀真的能当调试工具用。5. 一个土办法把口诀变成自查清单5.1 三步自查清单详解下面给出一个可以直接落地的伪代码你完全可以用它验证一棵红黑树是否合法。def is_red_black_bst(root, nil): # 1. 验证根是黑色 if root.color ! BLACK: return False black_count None def dfs(node, cur_black_count): nonlocal black_count if node nil: # 到叶子 NIL 时记录黑节点数并比较 if black_count is None: black_count cur_black_count elif cur_black_count ! black_count: return False return True if node.color RED: # 检查不能连续红 if node.left.color RED or node.right.color RED: return False # 遇到黑节点路径黑数1NIL 已在终止时单独算黑 nxt cur_black_count (1 if node.color BLACK else 0) return dfs(node.left, nxt) and dfs(node.right, nxt) return dfs(root, 0)这个函数的思路就是把口诀里“根叶黑”“不红红”“黑路通”三条落到递归里。注意终止判断时把 NIL 算成一个黑节点。实际使用中我还会继续验证中序有序性因为红黑树本质上还是 BST。你可以在测试里随机插入几十个数再用这个函数检查比对照教材硬读有效果。5.2 如何用口诀速解面试题学会了口诀面试里很多延伸题都能答。比如问到“红黑树和 AVL 树怎么选”你可以从口诀出发红黑树用“近似平衡”换取更低维护成本“黑路通”决定了它最长路径约是最短路径两倍所以查询不如 AVL 严格但插入删除旋转更少。比如 C 的map、Java 的TreeMap更看重整体稳定选红黑树如果读多写少、而且对最坏查询延迟极其敏感AVL 更合适。这些话不需要死记你用“两倍高度”“减少旋转”就能推导。再比如面试官问“为什么红黑树插入新节点要涂红”你直接说“涂红只可能破坏不红红把问题限制在局部涂黑会立刻破坏黑路通导致全局黑高失衡”。这就是用口诀解释设计动机大概率会让面试官眼前一亮。6. 写在最后我的经验与建议红黑树的难点不在于“记住五条性质”而在于“理解为什么需要五条性质”。口诀对我来说就像一根拐杖在我晕头转向的时候扶我一把。真正让我彻底开窍的不是反复背诵而是动手画图。我建议你准备一个在线可视化工具每次插入或删除一个节点后看它怎么变色、怎么旋转同时把口诀里的三条检查项在脑子里过一遍。坚持练二十个随机序列你对红黑树的认识会有一个质的飞跃。个人经验上我最后再分享两个小技巧。第一初始阶段不要追求能写出完整删除代码先能看懂插入和删除的每一种调整步骤是怎么保证“不红红”和“黑路通”的多看几遍再自己写会顺畅很多第二写代码时强烈建议用 NIL 哨兵别到处判空指针否则删除分支会让你改到崩溃。等你真的亲手撑过一棵红黑树再回头看那句“根叶黑不红红黑路通”你会觉得这句话已经把红黑树的精髓全包住了。