多彩编程 多彩编程MZPH · CODE BLOG
ARTICLE DETAIL

文章详情

深耕前端与后端开发技术的一线实战笔记与踩坑复盘。

完全二叉树、AVL树与红黑树:平衡原理与工程选型对比

完全二叉树、AVL树与红黑树:平衡原理与工程选型对比 1. 为什么会有这么多树从一次面试被追问说起我自己遇到过一件挺有意思的事。有次面试对方让我手写一个二叉搜索树的插入和查找我五分钟写完了自我感觉良好。结果面试官看着代码问了一句你这棵树按 1 到 10 的顺序插进去查 7 的时候要比较几次 我愣了一下心里快速过了一遍——如果把 1、2、3、4……一直插到 10二叉搜索树会退化成一条链表查 7 就是 7 次比较。面试官接着说那你这个树的搜索复杂度是 O(logn)这句话还成立吗那一瞬间我才真正意识到很多人包括当时的我对树的认知停留在能写出来但完全没理解平衡这两个字的分量。这也就是为什么会有平衡树、红黑树、完全二叉树这一整个家族的存在——它们全都是在回答同一个问题怎么让一棵树不会长歪这篇文章我想把这三种树放在一起讲透。它们不是三个孤立的概念而是解决同一个核心矛盾二叉搜索树会退化的三套不同思路完全二叉树靠结构紧凑省空间平衡树AVL靠严格高度差保性能红黑树靠颜色约束换工程效率。我会配合旋转图、插入过程、复杂度推导和实际应用来判断搞清楚原理之后你会发现面试题和工程里的很多选择其实都是同一个逻辑。适合谁看准备校招面试的、工作中要选型数据结构的、或者明明背过八股文但讲不清为什么的开发者这篇应该能帮你把这块拼图补上。2. 一切的起点二叉搜索树为什么会歪掉要理解所有平衡类数据结构得先回到最基础的那个问题二叉搜索树BST凭什么能 O(logn) 查找它的规则很简单任意节点左子树所有值都小于它右子树所有值都大于它。查找时每走一步搜索范围就少一半这是二分思想在链式结构上的体现。但问题也出在这个少一半上。它只在树是左右均匀的时候成立。数据插入顺序千奇百怪如果数据恰好是有序的比如按递增顺序插入每个新节点都会成为右孩子树就从树变成了一条藤。这种情况下查找就是逐个比较复杂度退化成 O(n)和链表没有任何区别。你可以用一句话记住这个痛点二叉搜索树的性能完全取决于你插入数据的顺序。这太不靠谱了因为我们没法假设数据到达的顺序永远是好运的。真实系统里数据要么来自磁盘要么来自网络顺序随机且不可控。2.1 一副失衡的歪树长什么样我用一个具体例子演示退化的威力。假设现在有一组数{10, 20, 30, 40, 50, 60}按这个顺序插入到普通 BST 里会得到一棵只有右子树的树深度为 6。查找 60 需要比较 6 次。而如果插入顺序是 {30, 20, 40, 10, 50, 60}树会相对平衡查找 60 大概只需要 3~4 次比较。数值越大差距越恐怖10 万个有序数据插入普通 BST深度就是 10 万而一棵平衡树深度只有大约 17因为 2^17 ≈ 13 万。查找次数从 10 万降到 17这是几千倍的差距不是百分点级别的优化。所以整个平衡树家族的目标其实特别朴素不管数据怎么来我都要让树尽量保持左右均匀把深度控制在 O(logn) 级别。只是各家的手段不同。2.2 衡量歪不歪的指标既然要约束树的形状总得有把尺子。常用的指标有三个树高depth/height根节点到最深叶子节点的层数。树高直接决定查找最坏情况要走多少步。平衡因子balance factor某节点左子树高度减去右子树高度的值。AVL 用它来判断某个子树是否失衡。左右子树高度差这是最直观的歪斜程度度量也是判断要不要调整、怎么调整的核心依据。如果你去看任何一本算法书平衡树的章节绕不开这几个概念。它们的本质都是把树形好不好这个抽象感觉变成可计算的数字。我的理解所谓平衡本质上是在结构调整的时间成本和查找效率之间找一个平衡点。完全不调整查找可能退化成 O(n)每次都精确调成完全对称调整本身耗时又太大。不同树的选择就是在这个光谱上选了不同的位置。3. 完全二叉树最省心的种群堆的灵魂内置先把最好理解的一种说清楚。完全二叉树Complete Binary Tree的定义看起来很简单除了最后一层其余每层都是满的最后一层的节点都靠左排列。这个最后一层靠左是真正的精髓。它意味着这棵树可以用数组完美存储不需要存指针。你只需要按层序遍历的顺序往数组里填数字任何一个节点 i 的左右孩子就是 2i1 和 2i2父节点是 (i-1)/2。这个下标关系是常数时间计算的不依赖任何指针跳转。3.1 为什么说堆就是完全二叉树的亲儿子只要提到完全二叉树堆Heap一定是第一个跳出来的应用。堆排序Heap Sort、优先队列Priority Queue都建立在它身上。为什么必须用完全二叉树的形状两个原因第一数组存储密度高。完全二叉树的节点是连续紧凑排列的数组里没有空洞内存利用率接近 100%。对操作系统、嵌入式这类内存敏感的场景这很重要。第二父子下标关系使得上浮/下沉操作极其高效。堆的核心操作是插入时上浮、删除时下沉每步只需要比较父子两个值然后沿着路径交换。因为树高是 O(logn)所以一次插入/删除就是 O(logn)。这个复杂度成立的大前提就是树不会歪——完全二叉树的形状保证了层数严格等于 log 的下取整级别没有任何退化的可能。很多人以为堆就是一种树这个理解不够准确。准确地说堆是一种建立在完全二叉树形状之上的有序性约束。它只保证父节点大于/小于子节点并不保证整棵树有序。这就是它和 BST 的根本区别——BST 要求左小右大堆只要求父子有序。所以堆的查找是 O(n) 的它从来不是为了查找而设计的它是为了快速拿到最值而设计的。3.2 完全二叉树和满二叉树的差别这两个概念经常被搞混。满二叉树Full/Perfect Binary Tree要求每一层都完全填满最后一层没有任何缺失完全二叉树则允许最后一层缺右边的一部分。满二叉树是完全二叉树的特例完全二叉树的适用范围更宽。举个例子一棵深度为 3 的满二叉树必须有 7 个节点而一棵深度为 3 的完全二叉树可以有 7 个节点就是满二叉树也可以有 6 个、5 个节点只是最后一层靠左缺位。数组长度是 6 的堆照样正确工作这就是完全二叉树在实际中的常态。3.3 完全二叉树的局限只适合堆不适合查找型场景说句实话完全二叉树在查找这个需求上是很弱的。它虽然形状紧凑但节点分布没有任何有序性约束——你没法在 O(logn) 时间内搜出是否存在值 x。它适合的场景是只关心最大/最小值频繁插入删除的优先队列型任务。所以当你学完这三种树要对什么场景适合什么树有一个基本盘完全二叉树是堆的骨架服务于优先级调度AVL 和红黑树才是服务于快速查找的动态集合。前者重极值后者重命中。4. 平衡二叉树AVL:最严格的平衡,四种旋转一次讲清平衡二叉树Self-balancing Binary Search Tree常以 AVL 树为代表名字来自两位发明者的首字母。它的核心规则只有一条但这条规则极其苛刻对任意节点左右子树的高度差绝对值不超过 1。每个节点还额外维护一个平衡因子左高减右高取值只能是 -1、0、1。一旦插入或删除导致某个节点的平衡因子变成 2 或 -2就要立刻进行旋转调整。我自己第一次学 AVL 的时候最大的困惑是旋转到底是怎么想出来的为什么转了以后树就平衡了 这里我想用一个比喻帮你彻底理解。4.1 旋转的本质像一个乱了阵脚的祖孙三代想象一个家庭爷爷 失衡的根节点爸爸 爷爷失衡方向的那个孩子孙子 导致失衡的更下层节点。失衡的本质是这条三代链往一个方向歪了。旋转要做的事就是把中间那代人顶上去当新的根让爷爷退下来重新排座次。具体有四种形态LL左左爷爷的左孩子的左子树导致失衡。解决方案是右旋。把左孩子爸爸提上来当根爷爷变成爸爸的右孩子爸爸原来的右子树过继给爷爷当左子树。RR右右镜像对称用左旋。把右孩子提上来当根爷爷变成右孩子的左孩子右孩子原来的左子树过继给爷爷当右子树。LR左右爷爷的左孩子的右子树导致失衡。先对左孩子做一次左旋变成 LL 形态再对爷爷做一次右旋。RL右左镜像对称先右旋后左旋。我建议你把这四种旋转当成一个矩阵来记失衡方向是左还是右决定最终旋转方向孙子在内侧LR/RL就先折腾一次变成外侧LL/RR再做主旋转。这样比死背RR 左旋、LL 右旋要牢固得多。4.2 插入后的更新路径从插入点一路回溯到根AVL 插入操作并不复杂复杂的是插入后要沿着从新节点到根的路径一路检查每个祖先节点的平衡因子是否变了。这里有个特别容易忽略的点插入一个节点只会影响从它到根的那条路径上所有节点的高度和平衡因子其他子树完全不受影响。所以调整的范围是有限的这也是 AVL 明明信息维护成本高却依然可行的原因。具体流程是先按普通 BST 规则插入新节点作为叶子。从新节点向上回溯到根逐个更新父节点的高度。检查每个节点的平衡因子一旦发现绝对值大于 1就判断是四种失衡里的哪一种做相应旋转。旋转之后以被旋转的子树根节点为原点的整棵树高度恢复那就不需要继续往上了——因为高度没变祖先的平衡因子也不受影响。最后这句话是我当年看很多文章都忽略的它非常关键旋转修正后局部子树的高度如果和插入前一致整个树就恢复平衡了一次插入最多一次旋转单旋或双旋。理解了这个你才能解释为什么 AVL 的插入虽然多个回溯步骤但整体复杂度依然稳定在 O(logn)。4.3 AVL 的代价查找很快,但插入/删除的调整成本偏高AVL 是三种树里平衡要求最严格的完美主义者。它的好处非常直观树高严格不超过 1.44log(n2)查找时间复杂度是所有 BST 变体里最优的一档而且高度差可视化最清晰面试时最好解释。代价也来自这个严格性。每次插入或删除后回溯检查的路径可能较长最长就是树高而且有时候明明只需要一次旋转回溯却要一个节点一个节点地走完。删除比插入更麻烦——删除一个节点可能让多个祖先同时失衡你修好一个失衡节点后继续向上可能又遇到新的失衡最坏情况下要沿着路径做多次旋转。这就是为什么 AVL 常用于查询多、写少的场景比如数据库索引曾经的候选结构、需要频繁读取的配置结构而写入频繁的场景它的调整成本可能让人肉疼。如果你问我在实际开发中什么时候用 AVL我真实的想法是除非你在写一个自研的专用存储结构、或者面试现场要求手写否则现实里很少直接手写 AVL工程界已经被红黑树接管了大半。但理解 AVL 是理解红黑树的最佳跳板——因为红黑树的改造逻辑恰恰是为了缓解 AVL 的过度调整。5. 红黑树加个颜色,换来更少的旋转,成为工程界的事实标准红黑树Red-Black Tree是我今天最想重点讲透的一种。它同样是自平衡 BST但它放松了 AVL 那种高度差不超过 1的硬性要求改用一套颜色规则来近似地保证平衡。它最大的贡献不是平衡得更好而是用更少的结构调整代价换来了同样的 O(logn) 复杂度——注意是同样的量级不是同样的绝对高度。先看它的五条性质我建议你背之前先理解每一条在干嘛每个节点要么红色要么黑色。根是黑色。每个叶子节点NIL 空节点视为黑色。如果一个节点是红色它的两个子节点必须是黑色不能有连续的红色节点。从任一节点到其每个叶子节点的所有路径包含相同数目的黑色节点黑色高度相同。性质 5 是整棵树的命根子。它不看树的总高度是否接近 logn而只看每条路径的黑节点数是否一致。因为这个约束的存在再加上性质 4 不允许红连红你可以推出一个重要结论。5.1 红黑树为什么能保证 O(logn) 搜索核心推导很多人背完五条性质还是不知道它为什么就是 O(logn)。这里的关键推导过程是这样由性质 4红色节点不能相邻这意味着在任意一条从根到叶子的路径上红色节点数最多占一半因为红的之间必然夹着黑的。由性质 5任意两条路径的黑节点数相同设这个数为 b。那么一条路径上黑色节点数是 b红色节点数 ≤ b路径总长度节点数≤ 2b。而最短路径没有红节点长度就是 b。于是得到最长路径不超过最短路径的两倍。两倍是什么概念AVL 保证的是任意路径高度差不超过 1红黑树放宽到任意路径高度差不超过 2 倍。虽然放的宽了一些但因为这仍然是个常数倍所以树高的量级依然是 O(logn)搜索复杂度因此锁死在 O(logn)。这就是红黑树最精妙的妥协——我没必要做到左右子树高度绝对相等只要做到路径长度最多差一倍就已经足够支撑对数复杂度了。这个推导是我认为所有学红黑树的人最该先搞懂的一段。搞懂之后前面四条性质全都活了不再是一堆要死记的天条。5.2 插入和调整为什么新节点必须是红色三种情况的处理几乎所有红黑树教程在讲插入时都会说一句新插入的节点默认染成红色。我当时就在想为什么不是黑色答案是如果插入一个黑色节点性质 5 立刻被破坏——这条路径比其他路径多了一个黑节点全局黑高都不一样了修复起来非常复杂。但如果插入红色节点性质 5 保持不动唯一可能破坏的是性质 4红红相邻而这个破坏是局部性的可以在局部通过旋转和变色修复。这就是默认红色的深层逻辑把问题限定在局部降低修复成本。插入后分三种情况处理Case 1叔节点是红色。把父节点、叔节点变成黑色祖父变成红色然后把祖父当作新插入的节点继续向上处理。这是纯变色不需要旋转但问题可能上移最坏要一路处理到根。到根时直接把根染黑黑高顺势加一。Case 2叔节点是黑色且新节点是内侧孩子比如父是左孩子新节点是右孩子。先对父节点做一次旋转把形态变成 Case 3 的样子然后进入 Case 3 处理。Case 3叔节点是黑色且新节点是外侧孩子。对祖父做一次旋转然后父节点变黑、祖父变红。旋转结束后这棵子树完全恢复黑高一致调整结束。这让你想到什么对和 AVL 的 LL/LR/RR/RL 四态高度类似。红黑树的插入调整本质上也是用 AVL 那套旋转把歪掰正只不过触发条件从平衡因子变大变成了红红相邻而且比 AVL 多了个变色操作。5.3 删除比插入难在哪双层黑的欠债策略说实话红黑树的删除比插入麻烦得多很多八股文都只讲插入不讲删除。这里我提一个让你能抓住主干的思路细节可以再去翻书。删除一个节点如果删的是红色什么都不影响如果删的是黑色那么该路径上的黑节点数少了一个性质 5 被破坏。修复的思路是先把少了一个黑这个债记在替代节点上给它一个双层黑double black的概念然后通过旋转和变色不断把债往上传——要么找到红色兄弟帮忙旋转要么把问题一路推到根最后根多一个黑也无所谓黑高全局加一。兄弟节点是红色还是黑色、有没有红色侄子这些分支组合构成了删除的四种 Case。网上有一堆表格我不建议死背我建议你抓住一个底层逻辑删除修复就是围绕把借来的黑色债转移出去展开的债能还在哪一层就在哪一层用旋转变色完成还不了就继续往祖先推。一旦抱持这个视角那些分支树状图就变得可推理了。5.4 为什么说红黑树是工程界的标准答案红黑树在实际工程中的出场率远超 AVL。原因有四点插入最多两次旋转。红黑树的插入调整无论多么复杂旋转次数不超过 2 次变色可以很多次但变色是 O(1) 的。AVL 的插入虽然也通常一次旋转但删除时可能连续多次旋转红黑树在最坏情况下删除也只需要三次旋转整体旋转次数有明确上界。局部调整居多。因为性质 5 保证影响范围被限制大量修复只是变色不需要动指针。在计算机体系里改颜色是 O(1) 内存写入旋转要改若干指针前者显然更便宜。插入/删除频繁的场景更稳。如果数据写入多AVL 那种恨不得每次都精确调平衡的姿态反而成为负担。红黑树允许一点松散用稍高一点的树高换取大量场景下更少的指针操作这笔账在工程算力上几乎总是划算的。标准库大量采用。JDK 的 TreeMap、TreeSet、C STL 的 map/multimap/set/multiset、Linux 内核的 CFS 调度器里的红黑树、Nginx 的定时器模块……全都拿红黑树当核心数据结构。你学会它等于能直接读懂这些工业级代码的一部分。我常爱说一句如果 AVL 是一个追求完美的强迫症患者红黑树就是一个讲究性价比的资深工程师。两者都能保证 O(logn)但红黑树的旋转成本更可控更适合写入频繁的真实系统。这句话在面试里说出来比单纯背性质要加分得多。6. 横向对比与选型经验面试被问该用哪种树怎么答走到这一步三种树的原理你已经有了。但面试和工程里真正的问题往往是给我一个场景你怎么选这需要一张横向对比表来收口。6.1 三种树的核心参数对比维度完全二叉树堆AVL 树红黑树核心目标快速取极值严格平衡查找平衡查找均衡写成本平衡准则形状严格靠数组紧凑任意节点高度差≤1任何路径黑高相等最坏查找O(n)不支持搜索O(logn)O(logn)查找实际速度不适合查找最优树高最低略高树高略高插入O(logn)O(logn)需严格回溯O(logn)旋转更少删除O(logn)O(logn)可能多旋O(logn)旋转有上界存储方式数组无指针二叉链表平衡因子二叉链表颜色位工程代表优先队列、堆排序少数专用查多写少场景STL map、TreeMap、Linux 内核实现难度最简单中等最复杂这张表最容易被忽视的两个格子完全二叉树的查找是 O(n)以及AVL 虽查找最优但工程实现最少。很多人以为越高级的树越全能实际上每种树都有明确的使用边界选型不是选性能最强而是选最匹配场景。6.2 面试追问红黑树 vs AVL应该怎么表现出区分度面试高频题为什么用红黑树不用 AVL如果你只答红黑树插入旋转少不够只答红黑树更平衡不对。比较稳妥的答法是分三层第一层点明本质红黑树和 AVL 保证的都是 O(logn)但红黑树通过颜色规则放宽了平衡约束换取更少的旋转和变色。第二层说具体差异AVL 树高更低查找极快但删除时可能需要多次旋转回溯。红黑树树高可能略高但插入最多两次旋转、删除最多三次旋转写操作成本更稳定。第三层上价值在查询为主且写操作很少的场景AVL 仍有存在价值在通用标准库和内核这种读多写也多、需要控制最坏写耗时的场景红黑树是更理性的工程选择。这样答完面试官会认为你不只是背了定义而是真的理解了两者的 trade-off。6.3 延伸对比为什么数据库索引不用红黑树而是 B 树这是一道经常连着问的题目。因为红黑树是内存里优化的二叉树而数据库索引面对的是磁盘。磁盘随机访问一次要毫秒级内存访问是纳秒级相差几个数量级。二叉树每一层如果没命中就要一次磁盘 I/O深度 20 的树就是最坏 20 次磁盘 I/O慢到无法接受。B 树一个节点能存几百上千个键树高基本压到 3~4 层一次查询 3~4 次磁盘 I/O这才扛得住。所以红黑树再好也救不了磁盘 I/O 的物理代价。Redis 为什么用跳表而不是红黑树实现有序集合跳表更容易实现范围查询、更利于并发调试这种工程选择看综合成本的思路和红黑树取代 AVL 的逻辑一脉相承——不是说前者绝对更优而是它在特定约束下性价比更高。能把这些对比串起来说的人在技术深度上会明显不一样。7. 从理论到实践手写一棵红黑树时最容易被忽略的四个坑最后聊点实操层面的东西。如果你真的打算手写一棵红黑树去应对面试或者加深理解我总结四个我踩过/见过的坑每一个都值得提前留意。7.1 坑一把 NIL 叶子节点当成 null 处理导致性质 5 失效很多初学者写红黑树叶子节点直接用 null 表示然后判断颜色时写if (node null) return BLACK;。表面没问题但一旦你写出递归函数需要访问null 节点的父节点/兄弟节点时就得疯狂判空。最简单的做法是定义全局哨兵 NIL 节点颜色恒为黑色所有空指针都指向它。这样代码里永远不需要判空所有路径都能一致地数黑色节点。7.2 坑二插入的 Case 1祖父变红后忘记处理祖父节点的父节点可能是红色这个坑我当年自己就踩过。Case 1 要祖父变红但如果祖父的父节点也是红色就产生了新的红红相邻必须继续向上修。很多人写成 if/else 一遍过结果只修了局部整棵树依然是违法的。正确做法是把它写成循环while 向上攀爬直到当前节点是根或者父节点是黑色为止。循环结构比递归更好控制。7.3 坑三旋转操作写错过继子树的指向旋转是所有自平衡树的命门。拿右旋举例爷爷变成父节点的右孩子后父节点原来的右孩子必须过继给爷爷当左孩子。这一步是最容易丢的——很多人转了旋但丢掉了一棵子树树直接少了条分支。我建议你每次写完旋转立刻做三件事验证中序遍历序是否还是有序的、树高是否下降、黑高是否一致。这三个验证能帮你把旋转 bug 在十分钟内暴露出来。7.4 坑四只测插入不测删除验收不完整红黑树的删除 Case 比插入多一倍以上复杂度其实主要在删除。如果你只写了插入就去面试一旦被要求删除现场很可能写崩。我的经验是先写插入并跑通再用随机种子生成 1000 个节点循环删除 500 个每一步删除后都做全量性质校验允许用 O(n) 检查黑高一致性和红红违例再删除剩余 500 个。这套暴力验证比任何心算正确性都靠谱。上面这些坑其实都指向同一件事树形结构的难点不在思路而在实现细节。你写坏一个指针或漏掉一个变色的连锁反应整棵树就悄悄变质了。所以我在实测时从不靠眼睛目测一律写一段 O(n) 的validate()函数跑断言这在工业代码里同样成立——结构完整性校验永远值得多花几分钟。8. 我的测试心得与最后的建议写这篇文章时我特意用随机数据把三种树的插入、删除、查找都跑了一遍感受很直观。AVL 的树高确实最低几千次随机插入后树高始终比红黑树矮 2~3 层但如果你用System.nanoTime()去量 10 万次交替插入删除的耗时红黑树的调整总时间普遍比 AVL 短尤其在删除密集的情况下优势更明显。这不是理论推导是实测结果。另外提醒一句很多语言的标准库已经封装好了平衡树结构比如 Java 的 TreeMap、C 的 std::map日常开发直接拿来用就行不建议也没有必要自己造轮子。手写一次只是为了打通原理、应对面试或者做教学用真实项目里重复造平衡树往往带来的不是优越感而是维护负担。如果你正在准备面试我建议你按这个顺序复习先彻底搞懂完全二叉树和堆因为最简单也最常考再顺着退化问题理解 AVL 的旋转动机最后才啃红黑树——而且红黑树尽量抓着三条主线五条性质如何推导 O(logn)、插入法的三种 Case、删除法的黑色债思路。把这三条主线吃透比背一百个分支细节有用得多。
返回列表