ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

红黑树原理详解:自平衡二叉搜索树的插入删除与工程应用

红黑树原理详解:自平衡二叉搜索树的插入删除与工程应用 1. 红黑树到底是什么——从二叉搜索树的退化说开去红黑树RBTree估计劝退过不少人很多人一听到“红黑树插入删除等原理”就头皮发麻。但在实际的工程世界里它频繁出现在你根本看不见的地方Java 的TreeMap、TreeSetLinux 内核里的调度器和内存管理Nginx 的定时器C STL 的std::map甚至 MySQL 的InnoDB索引体系里也到处是红黑树的思想残留。这篇文章我不打算给你堆砌教科书式的证明而是把红黑树当一个“面试必考、工程常用”的数据结构来拆重点讲清楚三件事它到底解决了什么问题插入和删除时那些复杂的旋转变色是怎么一回事以及它在真实项目里应该怎么用、什么时候别自己造轮子。先说一个很基础但很多人没真正理解干净的问题二叉搜索树Binary Search Tree为什么需要自我平衡如果你按顺序往一棵普通 BST 里插入 1、2、3、4、5树就会变成一条直线查找 5 要做 5 次比较复杂度退化成 O(n)。真实的生产环境里数据往往不是完全随机比如日志 ID、订单号、自增主键这类数据几乎天然有序。红黑树这种“自平衡二叉搜索树”就是为了应对这种有序插入的极端情况而设计的。1.1 二叉搜索树的隐患当你按顺序插入数据时普通 BST 的核心优势是左子树所有节点小于根节点右子树所有节点大于根节点。理论上平衡的时候高度约 log2n查找一次只需要 O(logn)。但问题恰恰出在“理论上”三个字因为插入顺序决定了树的形态。最典型的就是递增序列比如插入 10、20、30、40、50每次新节点都挂在当前最右子树上最终形成一条深度为 5 的链表。链表查找和数组顺序扫描没有本质区别但 BST 还要承担额外的指针存储和调转开销反而更慢。我见过不少初学者用 BST 做项目原型数据量小的时候没感觉等压到几十万条记录接口延迟直接飙升。其实这不是 BST 本身的锅而是树失去了平衡约束。解决思路很直接在插入和删除的时候通过某种机制维护树的平衡让高度始终保持在 O(logn) 量级。红黑树就是这类机制的集大成者它没有像 AVL 树那样要求左右子树高度差不超过 1而是用一种更“宽松”但依然严格受控的方式来维持平衡。1.2 红黑树的五条性质以及它们为什么是现在的样子红黑树是在 BST 基础上给每个节点增加了一个“颜色”字段红色或黑色然后强行规定五条性质每个节点不是红色就是黑色。根节点必须是黑色。所有叶子节点NIL 哨兵都是黑色。如果一个节点是红色那么它的两个子节点必须是黑色不能出现连续红节点。从任意节点到其每个叶子节点的所有路径包含相同数量的黑色节点黑高相等。第一条和第二条还好理解。第三条里的“叶子节点”不是传统意义上的空指针而是一个统一的黑色哨兵 NIL这在实现时非常关键因为删除操作的很多 case 都依赖这个哨兵。第四条是为了防止红色节点过大面积聚集如果红色不能连续那么红节点就只能稀疏分布在黑节点之间。第五条是红黑树最核心的约束从根到每个叶子的黑节点数量必须一致这直接限制了树的高度。为什么这五条能把高度限制在 O(logn)这里我说一个不严谨但非常好记的推导思路如果从根到叶子的最短路径全是黑色节点那么一条路径上的黑色节点数至少是 bh黑高。由于红节点不能连续最长路径只能“黑节点 红节点 黑节点 红节点”地交替出现所以最长路径的长度最多是黑节点数量的两倍。因此任意路径长度不会超过最短路径长度的两倍整个树高度在最坏情况下约 2log(n1)。这就是红黑树虽然不如 AVL 严格平衡但依然能保证 O(logn) 查找、插入、删除的核心原因。1.3 红黑树和 AVL 树的取舍为什么工程上更常用红黑树AVL 树比红黑树更严格它要求每个节点的左右子树高度差绝对值不超过 1。这样树的高度更矮查找性能更稳定。但代价是插入和删除时为了恢复平衡需要更多次旋转。红黑树则放宽了平衡条件最长路径允许是最短路径的两倍查找性能略逊于 AVL但插入删除时旋转次数大大减少。我可以给你一组直观感受在完全随机的数据插入场景里红黑树平均旋转次数大约是两三次AVL 可能要四五次甚至更多而在删除场景下差距更大。工程应用有一个普遍规律读多写少且极其追求极端查找性能的场景可以考虑 AVL读写均衡、需要应对频繁插入删除的场景红黑树是更稳妥的选择。Java、C STL、Linux 内核最终不约而同选了红黑树不是因为它更复杂更有面子而是它在增删改查的综合表现最均衡。2. 红黑树插入原理照着步骤做就不会晕红黑树的插入过程在真正的工程实现里往往被封装成一个几十行的函数但即使只是读源码也会被里面的while循环和case分支绕得晕头转向。我觉得最有效的学习方式不是直接啃代码而是先在纸上画出一个标准红黑树然后逐个插入节点观察每一步的颜色变化和旋转方向最后再回来看代码就会觉得顺理成章。2.1 插入前的两个约定新节点为什么是红色第一个约定新插入的节点一律初始为红色。很多人第一次看到这里会问为什么不是黑色如果新节点是黑色那么根据第五条规定从根到这个新叶子路径上的黑节点数会比其他路径多 1整棵树的“黑高平衡”立刻被打破你不得不在插入后立即着手恢复性质五。而新节点是红色时性质五暂时不会破坏风险只是可能出现“红色节点有红色孩子”这种违反性质四的情况修复手段只有两种——变色或旋转处理起来相对可控。直白点说红色起步会降低插入修复的触发概率和修复成本。第二个约定每个真正的叶子节点都要有一个 NIL 哨兵节点哨兵为黑色且不存储数据。技术实现上有些资料用null表示叶子但为了删除操作的统一处理最好为每个叶子层都保留一个共享的 NIL 节点。我建议初学者写红黑树时一定使用全局唯一的哨兵实例否则你会在删除时被空指针问题折磨。2.2 插入遇到冲突的三种场景不用无脑背照着图形理解插入流程分三步第一步用 BST 的规则找到新节点的插入位置并挂上颜色设为红色第二步如果新节点的父节点是黑色不需要任何处理直接结束第三步如果父节点也是红色说明性质四被破坏必须修复。修复时看新节点的叔叔父节点的兄弟节点脸色。这里的颜色决定了修复路径我把它分成三类场景 A叔叔是红色。这种情况最轻松把父节点和叔叔节点都变黑再把祖父节点变红这样局部黑高不变但祖父变成红色后可能继续破坏性质四所以把祖父当作“新节点”继续向上检查循环。场景 B叔叔是黑色且新节点是父节点的右孩子LR 型。先对父节点做一次左旋把新节点转到父节点的位置然后就变成场景 C再进行下一步处理。这个旋转本身不改变颜色只是为了把“拐弯”形状捋直。场景 C叔叔是黑色且新节点是父节点的左孩子LL 型。把祖父节点右旋让父节点上位父节点变黑祖父节点变红。此时这棵子树的黑高不变而且性质四也恢复了循环可以结束。你可以把场景 A 理解成“靠颜色传给祖父去处理”场景 B 和 C 理解成“靠旋转把局部形状修正”。场景 B 为什么要先旋转因为如果直接对祖父旋转会形成左右接反的错位必须先通过一次旋转把路径合并到同一侧。2.3 一个具体示例手动插入七个节点纸上推演一下加深印象。假设初始树是空的我们依次插入节点10、5、15、20、30、25、28。插入 10根节点设黑色完成。插入 5挂到 10 左边红色父节点 10 是黑色完成。插入 15挂到 10 右边红色父节点黑色完成。插入 20挂在 15 右边红色父节点 15 是红色发生冲突。叔叔节点是 5黑色属于场景 C这里要注意方向新节点 20 是 15 的右孩子父节点 15 是祖父 10 的右孩子所以是右右RR型对应场景 C 的镜像。对祖父 10 左旋15 上位10 变成左孩子15 变黑10 变红。目前树结构是 15 为根左孩子 10右孩子 2010 的左孩子是 5。插入 30挂在 20 右边红色父节点 20 是黑色完成。插入 25挂在 20 左边红色父节点 20 是红色冲突。叔叔节点 10 是红色走场景 A20 变黑10 变黑15 变红。15 是根节点强制变黑完成。插入 28挂在 25 右边红色父节点 25 是红色冲突。叔叔节点 30 是黑色属于“父右、新右”但带拐弯等等28 是 25 的右孩子25 是 20 的左孩子路径是祖父 20 - 父 25 - 新 28先左后右属于 LR 型叔叔是 30 为黑色走场景 B。先对父节点 25 左旋28 上位25 变成 28 的左孩子此时 28 的父节点是 2028 的左孩子 25、右孩子 30不对30 还是 20 的右孩子。调整后是 28 为 20 的左孩子25 为 28 的左孩子。然后进入场景 C 的镜像对祖父 20 左旋28 上位20 变左孩子28 变黑20 变红。最后检查各路径黑高5、25、30 这些叶子路径之间的黑节点数一致整棵红黑树合法。这一步推完你对三种场景的适用顺序就会清晰很多。2.4 插入实现时的几个细节和常见坑实现插入时最常见的问题不是旋转逻辑而是忘记维护父指针。很多资料在示意旋转时只画了左右子树的变化但真实代码里节点结构如果带parent指针每一步都要同步更新。还有一个高频坑旋转完成后子树的根节点变了必须把原祖父节点指针重新指向新的子树根否则上层结构直接断裂。另外插入循环的终止条件很重要。我习惯在进入循环前先判断父节点是否为黑色如果是就直接返回如果不是再取叔叔节点。循环内部处理完场景 A 后把当前节点指向祖父继续循环处理完场景 B/C 后局部子树黑高不变且不违反性质循环可以直接break。很多初学者在场景 A 处理完后忘了更新根节点颜色全局根节点被染红违反性质二这类小问题调试起来很费时间。3. 红黑树删除原理最容易翻车的地方如果说插入是入门那删除就是真正的分水岭。红黑树删除的难点不在于“删除”本身而在于删掉一个节点后黑高平衡被打破修复过程中各种 case 的判定顺序极其容易出错。我推荐的学习方法是直接记忆一套流程框架然后在纸上把主要 case 画一遍再对照实现调一调。3.1 删除的底层逻辑先走 BST 的替换法删除节点时先按普通 BST 规则处理红黑树在这个层面没有额外特殊要求。如果待删除节点有两个孩子我们通常找它的中序后继右子树最左节点或中序前驱左子树最右节点把两者的键值复制到待删除节点中然后转而删除那个被复制的、要么只有一个孩子、要么没有孩子的节点。用专业话讲实际被物理删除的节点至多只有一个非空孩子。为什么要拐这一道因为直接断开有两个孩子的节点会牵扯到左右子树的衔接需要重新建立多个指针关系很容易出错。用替换法把问题归约成“删除单支节点或叶子节点”之后只需要处理这个被替换节点的颜色问题。3.2 删除黑色节点引发的“双黑”问题红黑树的性质五要求每条路径黑高相同所以你删除一个节点时如果它被删除后留下的空位正好在一条黑色路径上就会让该路径的黑色节点数比其他路径少 1破坏性质五。为了标记这个空缺我们引入了“双黑double black”的概念相当于这个位置需要两个黑色节点来抵消缺失修复就是通过旋转变色把这个双黑消除。很多人不理解双黑到底代表什么。我用白话说红黑树里删掉一个黑色节点就好比这条路的黑砖少了一块而这条路又不能变成无砖路所以我们临时在空缺处放了一块“双重黑砖”接下来要通过旋转借一块黑砖过来或者通过变色让兄弟分支的黑砖减一补一块总之要让所有路径重新出现等量黑砖。双黑不是一个真实的节点值它只是修复过程中的一种标记状态。3.3 双黑修复的四种兄弟情况按优先级排序删除修复的核心对象是当前被标记双黑的节点但真正看的是它的兄弟节点。理解这一节时始终记得一个前提我们在讨论“兄弟子树和当前子树的黑高”兄弟及侄子们的情况决定了能否借出黑色。四种情况我排列如下情况一兄弟节点是红色。先对父节点旋转让兄弟上位父节点变红兄弟变黑。这一步没有直接消除双黑但它把双黑问题从“兄弟为红的复杂分支”转换为“兄弟为黑的简单分支”然后继续走下面的情况。情况二兄弟是黑色且兄弟的两个孩子都是黑色或 NIL。这种情况下无法从兄弟子树借出黑色只能把兄弟变红让父节点承担双黑。如果父节点原本红色父变黑直接结束如果父节点原本黑色就继续把父节点当作双黑向上传递。情况三兄弟是黑色兄弟的右孩子是黑色左孩子是红色。先对兄弟右旋让左孩子上位兄弟变红左孩子变黑转换为情况四。情况四兄弟是黑色兄弟的右孩子是红色。对父节点左旋如果兄弟在右子树兄弟上位并继承父节点黑色父节点变红等等这里常规表述是兄弟接替父节点原颜色父节点变黑兄弟的右孩子变黑双黑节点变普通黑。实际上最终是兄弟子树贡献出一个黑节点补到当前子树同时保持局部黑高不变。很多教材把“兄弟是黑且右孩子是红”作为终极修复形态因为通过一次旋转加变色双黑被完全消除循环终止。具体左右镜像在实现时要注意统一判断当前节点是父节点的左孩子则按右旋/左旋的对应镜像处理。3.4 删除完整例子我建议你怎么练纸上推演一个删除场景建一棵初始红黑树比如根 20黑左孩子 10黑右孩子 30黑10 的右孩子 15红30 的左孩子 25红。现在删除 10。物理删除节点 10 后它的位置由 15 顶上不对10 有右孩子 15但 10 没有左孩子按 BST 替换15 直接上位到 10 的位置。15 是红色被删除的 10 是黑色所以路径上少了一个黑节点出现双黑。当前双黑节点是 15替代节点它的兄弟是 30。30 是黑色兄弟的右孩子 NIL 是黑色左孩子 25 是红色属于情况三。先右旋兄弟 3025 上位25 变黑30 变红此时新兄弟是 2525 的右孩子是 30红符合情况四。再对父节点 20 左旋25 上位继承 20 的黑色20 变红等一下这里需要仔细推演颜色结果。左旋后 25 的左孩子为 15右孩子为 30父节点 20 变左孩子。25 继承 20 原黑色20 变红30 变黑。保证每条路径黑高一致。删除结束。光看文字会有点乱但当你真的在草稿纸上画了两次就会发现这些 case 的行为其实非常机械。我更建议的方式是用 Python 写一个简单版本打印每次旋转前后的树形结构配合一个随机插入删除的测试脚本几千次操作下来基本就能把 case 的触发条件刻进脑子。3.5 删除实现中特别容易忽略的两个细节第一个是哨兵节点 NIL 在删除中的重要作用。当兄弟节点的孩子是 NIL 时NIL 会被当作黑色参与判断。缺少哨兵会导致你在情况二和情况三之间反复跳错。第二个是删除修复循环的终止条件。很多实现里会写一个while (x ! root x-color BLACK)这里x是当前被标记的双黑节点。如果x最终变成根节点那么双黑直接消除因为全树黑高可以统一减一而不破坏性质。但如果你在循环内部没有正确判断x的左右孩子、兄弟是否为 NIL很容易出现空引用。我早期的版本就在情况四判断时漏了NIL-color BLACK这一条结果运行到临界数据时直接段错误。4. 查找性能、B树对比与应用场景盘点红黑树的插入删除讲完接下来聊聊它日常最常见的用途以及它为什么常被拿来跟 B 树对比。最近不少人在问“B树是红黑树吗”这显然不是一回事但它们都是自平衡树家族的成员只是赛道不同。4.1 红黑树查找的复杂度和实际表现严格来说红黑树的查找复杂度和 AVL 一样是 O(logn)。但由于红黑树允许最长路径是最短路径的两倍实际查找中平均比较次数可能比 AVL 略高。不过这个差距在内存中几乎感知不到因为内存访问一次也就几十纳秒多比较几次无关痛痒。真正显著的是它避免了插入删除时的频繁旋转整体吞吐量更好。我用一个简单数据帮你直观理解10 亿条数据二分查找需要约 30 次比较红黑树查找也需要约 30 到 50 次节点比较。相比磁盘随机访问内存操作这些比较的时间可以忽略所以红黑树在内存索引场景能够做到非常稳定的时延。4.2 B树是红黑树吗真实区别在这里B 树不是红黑树。首先B 树是多路搜索树一个节点可以存多个键值并拥有多个子树而红黑树是二叉的节点只能有两个孩子。其次B 树所有数据都存在叶子节点并且叶子节点之间通过链表连接方便范围扫描红黑树则每个节点都存自己的数据没有叶子链。第三B 树的高度通常很矮即使上亿数据高度也只有三四层这非常适合磁盘页存储和区间遍历红黑树是纯内存结构高度可能在几十层。那为什么总有人把两者放一起比较因为它们都关心“保持树平衡”这个问题。数据库索引如果用红黑树范围查询就得频繁中序遍历效率不如 B 树但红黑树在内存中的单点插入删除能力很强所以工程上会用红黑树做内存态的热点数据结构而把 B 树留给持久化索引。MySQL 的 InnoDB 索引页是 B 树但 MySQL 内部还有不少基于红黑树实现的缓存和锁管理结构各管各的。4.3 红黑树的典型应用从 Jav a 的 TreeMap 到 Linux 内核实际代码里你通常不会直接手写红黑树但你天天在用。Java 的TreeMap和TreeSet是教科书级别的红黑树实现提供按键有序遍历插入删除时自动维持平衡在需要动态维护有序集合、求区间交集、找最大最小值的业务场景里非常好用。C STL 的std::map同样是红黑树底层键值对存储迭代器按 key 升序。Linux 内核里红黑树被广泛用在epoll的事件管理、虚拟内存区域VMA管理、CFS 调度器的运行队列等地方。比如内核需要频繁查找某个虚拟地址对应的内存区域同时要支持大量的插入删除红黑树能保证这些操作都是对数级。Nginx 的高性能定时器也依赖红黑树它在每次事件循环中快速取出最近到期的定时器并动态调整顺序。如果你在用 Go标准库里没有红黑树但不少第三方库实现了它比如github.com/oleiade/lane里的 Deque 和github.com/google/btree不过后端服务里更常见的做法是直接用跳表替代。跳表实现简单、范围查询顺手、并发友好在 Redis 的 Zset 中就是跳表。所以我的观点是红黑树不是唯一的答案但当你需要有序性、按 key 单点操作、且内存操作时它依然是经过工业界反复验证的可靠选择。5. 手写红黑树时的常见问题与避坑指南网上有很多红黑树源码但直接抄一遍很难内化。我鼓励你自己实现一个版本过程中一定会踩到一些坑下面是我觉得最值得注意的几个方向可以帮你提前避开。5.1 旋转写错、颜色标记失效、递归与迭代选择旋转是红黑树的基本操作也是最容易写错的地方。左旋时如果当前节点x的右孩子y为空直接返回否则把y的左子树挂到x的右子树上再把x挂到y的左子树上。记得每一步都要更新parent指针。建议先用裸 BST 写一个支持左右旋的函数单独测试再接入红黑树。颜色标记失效的典型表现是插入后根节点变红或者出现连续的红色节点。我建议在每个关键操作后调用一个断言函数递归检查五条性质测试时打印出违规节点顺序能帮你快速定位问题。红黑树实现用递归还是迭代查找和插入用递归写很直观但删除修复由于要不断向上回溯迭代配合parent指针更自然。C 里递归深度撑到 2log(n1) 通常没问题但大量删除后树高可能出现局部偏大还是用迭代更稳。5.2 删除修复的 case 顺序为什么要从兄弟开始判断很多初学者把删除修复的四种情况背下来但不知道为什么要按那个顺序判断。关键原因是后面的情况依赖前面的转换逻辑。比如情况一“兄弟是红色”必须最先处理因为只有把红兄弟变成黑兄弟后面情况二三四的“兄弟为黑”前提才成立。情况三也是为情况四铺路它在把左红右黑转为右红的形态。如果你把情况二排在前面遇到兄弟是红时也会误走进分支结局就是修复不彻底甚至死循环。我画过一张流程对照表方便自己整理判定顺序步骤前提操作效果case1兄弟红父旋转兄弟变黑父变红转成兄弟黑case2兄弟黑两侄子黑兄弟变红双黑向上移可能结束或继续case3兄弟黑侄子一红一黑兄弟侧旋转侄子变黑转成 case4case4兄弟黑外侧侄子红父旋转颜色继承双黑消除这张表不是让你死记而是让你观察每次 case 的处理都在“降低复杂度”直到情况四彻底收尾。5.3 测试红黑树是否合法的小脚本思路如果你要验证自己写的删除逻辑光靠数据规模不够还得验证结构合法性。我常用的思路是维护一个validate()函数检查每条从根到叶子的路径黑高是否相同、红节点子节点是否全黑、根节点颜色是否为黑。然后用随机生成的 key 序列做上万次插入删除每次操作后跑一次校验一旦失败就打印当前操作序列缩小排查范围。要注意的是校验函数本身不要把 NIL 哨兵算错。黑高必须把 NIL 节点也算进去也就是每次到 NIL 时返回 1。否则明明合法的红黑树会被你误判成违规。5.4 面试和工程中怎么聊红黑树面试聊红黑树重点不是你背出所有旋转细节而是你能否用清晰的语言讲清楚“它处理什么问题”和“核心 tradeoff 是什么”。我会按这个逻辑串先说 BST 可能退化成链表再解释红黑树用颜色约束维持黑高平衡接着对比 AVL 和 B 树的适用场景最后用实际业务里的有序集合需求举一个例子。面试官通常更在意你是否理解“为什么”而不是纯背诵强平衡。工程里要不要自己造红黑树我的建议是除非你做的是内核组件、数据库引擎这类基础设施或者在写教学代码否则请优先用标准库。自己手写红黑树最大的风险不是实现不出来而是在极端并发和异常情况下处理不好边界条件一旦出 bug 导致数据错乱排查成本极高。标准库实现经过社区多年打磨性能和正确性都有保障。我个人的心得体会是红黑树这类数据结构的乐趣恰恰在于“纸上推一遍 实现一遍 测试一遍”这套完整流程。推演帮你建立直觉实现帮你暴露盲区测试帮你确认边界。等你亲手把插入和删除都调通回头看那些面试题和复杂源码就不再只是背题而是真正拥有了领域模型层面的理解。
返回列表