YAOTU INSIGHTS

堆排序复杂度详解:从完全二叉树到O(n)建堆推导

堆排序复杂度详解:从完全二叉树到O(n)建堆推导
我相信只要是学过数据结构的同学面试时大概率都被问过这样一句话“堆排序的时间复杂度是多少”你背过答案知道是O(n log n)可是面试官接着追问一句“那建堆的复杂度是多少为什么是O(n)而不是O(n log n)”——很多人就卡在这里了。我当时也卡过。后来花了整整一个晚上把完全二叉树、堆、上浮下沉、复杂度推导从头到尾捋了一遍才真正弄懂堆这套东西为什么这么设计那些复杂度数字到底是怎么算出来的。这篇博文就把我踩过的坑、推过的公式、总结出来的经验全部写出来尤其适合正在准备面试、考研复习或者工作中要用到优先级队列、Top K问题、定时器场景的读者。保证让小白也能看明白让有基础的人也能查漏补缺。这里先亮个观点堆的一切复杂度都建立在“完全二叉树”这个结构之上。如果换成普通二叉树堆的插入和删除根本做不到O(log n)。要理解堆先得理解完全二叉树到底给了我们什么。1. 完全二叉树与堆的结构本质1.1 完全二叉树凭什么是“堆”的唯一选择先从最基础的问题说起堆到底是一种什么结构堆本质上是一个数组但逻辑上可以看作一棵完全二叉树。每个节点的值要么大于等于它的两个孩子大顶堆要么小于等于它的两个孩子小顶堆。这个“父大于子”或“父小于子”的约束就是堆的核心性质。那为什么必须是完全二叉树而不是任意二叉树原因很简单完全二叉树可以无缝地映射到数组上不需要任何指针。我们来看下标关系根节点下标是0任意节点的左孩子下标是2 * i 1右孩子下标是2 * i 2父节点下标是(i - 1) // 2这个映射关系的核心前提就是树必须是一棵完全二叉树。也就是说除了最后一层每一层都是满的最后一层的节点全部靠左排列。只有这样数组下标才是连续的中间不会出现空洞。我在学习的时候做过一个对比实验尝试把一棵普通二叉树存进数组你会发现下标出现了跳跃——明明数组里某些位置是空的但为了保留指针关系你不能压缩它。这就导致了很多存储空间的浪费而且遍历的时候还要单独处理“空节点”的标记问题。而完全二叉树不会有这个问题节点全部紧凑排列数组索引天然就是层级遍历的顺序。一句话总结把完全二叉树放进数组本质上是白嫖了数组的连续内存和随机访问能力。这是堆能做到O(1)访问堆顶、O(log n)完成插入删除的根本前提。1.2 堆的“完全性”不只是一个形式要求很多人只记住了堆的性质是“父节点大于子节点”却忽略了另一个重要约束堆还必须是一棵完全二叉树。这两个条件缺一不可。为什么如果只满足“父大于子”普通二叉树也能做到但你无法保证树高是O(log n)——极端情况下它会退化成一条链表插入删除的复杂度全部退化成O(n)。如果只满足“完全二叉树”但没有堆序性质那你只是一个数组表示的完全树无法快速找到最大值或最小值。完全二叉树的直接好处就是树高被严格控制在floor(log2 n) 1。这个数值决定了所有操作的上限。因为堆的所有调整操作上浮、下沉都是沿着树的高度路径进行的每一步只需比较常数次路径长度就是操作的复杂度。所以我当时学堆的时候给自己立了一个规矩凡是涉及堆复杂度的推导第一件事永远是把树高写出来。树高就是log n这个锚点一旦确立后续的分析就不会跑偏。1.3 顺便区分数据结构堆还是内存里的堆说到堆我见过太多初学者把“数据结构中的堆”和“操作系统内存分区中的堆”混为一谈。虽然名字都叫堆但完全是两个维度的概念。数据结构里的堆一种基于完全二叉树实现的抽象数据结构用于高效维护最值。它是逻辑层面的设计。内存里的堆堆内存程序运行时动态分配内存的区域和栈区、静态区并列。它是物理/运行时层面的东西。栈数据结构先进后出的线性结构。它和内存栈区之间虽然有联系函数调用帧就是靠栈实现的但也不能混在同一个语境里讨论。你会发现网上搜“堆”这个关键词出来的结果一半是数据结构的文章一半是“编译器堆空间不足”“堆和栈的区别”“win11堆栈区溢出解决方法”这种运行时问题。所以如果你在看堆的复杂度推导时脑子里想起的是JVM堆外内存不够用那赶紧切换一下——这篇文章讨论的是完全二叉树和复杂度计算跟内存分区无关。2. 堆的三大核心操作与复杂度推导2.1 插入操作从下往上的上浮堆的插入操作流程分两步先把新元素放到数组的末尾也就是完全二叉树的最后一个叶子节点位置。然后让这个元素沿着父节点路径不断上浮sift up / percolate up直到满足堆序性质。为什么先放到末尾因为插入操作必须维护完全二叉树的结构特性——你不能把新节点插到树中间然后重新调整整棵树的结构那样代价太大了。放在末尾是唯一不会破坏“完全性”的操作。上浮的过程是这样的拿到当前节点和它的父节点比较如果不满足堆序性质比如大顶堆中孩子比父节点大就交换两者位置。然后重复这个过程直到该节点到达满足条件的位置或者到达根节点为止。每一次上浮交换最多把节点向上移动一层。完全二叉树的树高是floor(log2 n) 1所以最坏情况下新插入的节点要从最底层一路浮到根节点走过的路径长度就是O(log n)。这里有一个我早期容易犯的错误插入节点时连续插入n次总复杂度是多少答案是O(n log n)。这个结论在堆排序中会用到。单纯看一次插入是O(log n)连续n次插入就是n * O(log n) O(n log n)。但是要注意这里每一个O(log n)的上界其实是独立成立的所以累加起来是n log n。很多人会把插入建堆和后面要讲的“下沉建堆”搞混后者是O(n)两者是不同的建堆方式。2.2 删除堆顶自上而下的下沉删除堆顶也就是大顶堆的最大值、小顶堆的最小值流程也分两步把堆顶元素与数组最后一个元素交换。删除最后一个元素原堆顶此时新堆顶是原来最后的那个叶子元素它大概率不满足堆序性质。让这个元素从堆顶开始不断下沉sift down / percolate down与较大的子节点交换大顶堆直到满足堆序性质或到达叶子位置。删除为什么要把最后一个元素顶上去原因和插入一样为了保证完全二叉树的结构不被破坏。如果直接把堆顶删了把两个孩子中的一个提上来整棵树的结构很可能就不再是“完全”的了。下沉过程每一步都要和两个孩子比较大顶堆取较大的那个孩子然后决定是否交换。每一层做常数次比较最多下沉到叶子节点路径长度依然是树高所以复杂度也是O(log n)。这里有个细节值得注意下沉每步需要比较两次取孩子较大值一次交换判断一次上浮每步只需要比较一次。虽然常数因子不影响大O结果但在实际工程中堆的下沉操作往往比上浮操作更耗时。这也是为什么建堆时选择“从最后一个非叶子节点开始逐个下沉”而不是“从空堆开始逐个插入上浮”的另一个原因——不仅仅是复杂度上的O(n) vs O(n log n)常数因子也偏大。2.3 建堆的关键操作顺序堆的初始化有两种常见方式方式一插入建堆。从空堆开始逐个调用插入操作。每次插入都是上浮复杂度O(log n)n次插入总复杂度O(n log n)。这个方式简单直观适合在数据量小或者本身就是流式场景中使用。方式二原地建堆Floyd建堆法。给定一个无序数组从最后一个非叶子节点开始向前遍历对每个节点执行下沉操作。这个方式看着也是“每个节点执行一次O(log n)的下沉”但总复杂度算下来居然只有O(n)。这个“看似O(n log n)实则O(n)”的结果是堆复杂度计算里最反直觉、也是面试最喜欢追问的一个点。我单独用一章来讲清楚其中的数学推导。3. 建堆复杂度O(n)的完整推导3.1 先破除一个直觉误区很多人看到原地建堆的代码是这样写的def build_heap(arr): n len(arr) for i in range(n // 2 - 1, -1, -1): sift_down(arr, i, n)循环从n/2开始每次做一次下沉下沉最坏是O(log n)所以总复杂度是O(n log n)——这是最常见的第一反应。但这个上界是正确但不够紧的。它没有利用一个关键事实不是每个节点下沉时都能走到O(log n)的路径长度。靠近树底部的节点本身高度就很小而拥有最大高度的节点根节点只有一个。如果简单地把“节点数n”乘以“最大高度log n”就是把绝大多数不需要那么长的下沉路径的节点强行按最坏情况来算了上界被严重放大。打个比方如果一座楼有10层把全楼所有人都按“从1楼爬到10楼”来计算体力消耗当然能算出总消耗的“上界”但这个上界显然大得离谱。精确计算必须分楼层来算住在2楼的人爬2层住在7楼的人爬7层。3.2 按节点高度分层求和大家看下推导过程设堆中总共有n个节点树高为h floor(log2 n)。我们按节点的高度来分层统计。定义节点的高度为该节点到其子树中最远叶子的距离所以叶子节点的高度为0根节点的高度为h。高度为0的节点叶子节点下沉代价为0因为无处可下。数量约n/2。高度为1的节点下沉代价最多为1。数量约n/4。高度为2的节点下沉代价最多为2。数量约n/8。推广高度为k的节点下沉代价最多为k数量最多为ceil(n / 2^(k1))。总代价可以写成求和式T(n) Σ (高度为k的节点数 * k) ≤ Σ_{k0}^{h} (n / 2^(k1)) * k n * Σ_{k0}^{h} k / 2^(k1)这里的关键是那个无穷级数。当h趋向无穷大时Σ_{k0}^{∞} k / 2^(k1) 1这个级数的值可以用错位相减法求得。如果你不记得具体推导可以记一个常用的等比-等差混合级数结论Σ k / 2^k 2所以Σ k / 2^(k1) (1/2) * 2 1。代回去就得到T(n) ≤ n * 1 O(n)这才是建堆的真实复杂度下限和上限都在O(n)量级所以建堆是线性的。我有一次在纸上完整推完这个求和式之后才真正理解了为什么Floyd建堆法是线性复杂度——不是因为代码写得巧而是因为大多数节点都聚集在树的底部而底部节点的高度很小。用一个简单的数据感受一下在100万个节点的堆中叶子节点有50万个它们的下沉代价是0占总节点数的一半。倒数第二层的25万个节点下沉代价最多1。真正下沉代价超过10的节点只有大约1000个。绝大部分节点都是“陪跑”真正的体力活只有少数高层节点在承担。3.3 什么时候用O什么时候用Θ这里顺便把复杂度记号的问题也讲清楚因为很多人经常被问“堆排序的时间复杂度到底该用O还是Θ”。O大O表示渐进上界即最坏情况下不超过某个量级。它是分析算法时最常用的记号回答“这个算法耗时不会超过多少”的问题。Ω大Ω表示渐进下界回答“这个算法至少需要多少时间”。Θ大Θ表示紧密界即既是上界又是下界回答“这个算法的耗时精确地落在这个量级”。回到建堆的例子建堆操作的时间不会超过O(n)这是上界。建堆操作至少也要遍历每个非叶子节点所以至少需要Ω(n)的时间。因为上界和下界都是n的线性量级所以可以确定地说建堆的复杂度是Θ(n)。那你可能想问什么时候必须用Θ我觉得在实际工程里只有在做严格的理论分析、或者面试官明确要求“给出最精确的界”时才必须区分。平时用O就够用了因为它给出的上界判断已经能覆盖绝大多数场景——面试时能说清楚O(n)和O(n log n)的区别就已经超过90%的人了。不过有个坑要提醒上界不等于精确值这是两个概念。比如插入排序的时间复杂度用O(n^2)描述没问题因为任何情况下耗时不会超过n^2这个量级但如果你说插入排序是Θ(n^2)那就错了——因为输入有序时它只需要O(n)时间。同理堆排序在最好、最坏、平均情况下都要对n-1个元素执行删除堆顶操作每次O(log n)所以它确实是Θ(n log n)。这种“最好最坏一个样”的算法不多堆排序恰好是其中之一。3.4 建堆复杂度的直觉验证理论推导结束后我用一个简单的实验给大家做个印证。用Python写一个计数版本的下沉函数统计每次下沉交换的次数然后对100万个随机数建堆记录总的交换次数。# 伪代码思路 build_heap(百万级数组) - 统计sift_down中的交换次数实际跑出来的交换次数大约是90多万次远小于100万 × 20log2约等于20的两千万量级。这说明什么说明平均情况下每个节点下沉的高度大约只有1层。这个实验不是我编的是堆这种结构的必然结果——底层节点虽然数量多但高度低高层节点高度大但数量少。两相抵消最终每个节点的平均下沉代价趋近于常数级别。我建议你们自己复现一下这个实验因为亲手跑出数据之后你对“建堆是O(n)”的信任感会完全不同。纸上谈兵百遍不如动手验证一遍。4. 堆排序与相关应用场景的复杂度全景4.1 堆排序为什么是O(n log n)堆排序的完整流程分两个阶段阶段一建堆。对无序数组原地建堆复杂度O(n)。阶段二反复删除堆顶。共n次删除操作每次把堆顶最大值与当前堆的最后一个元素交换。堆大小减一。对新堆顶执行下沉操作恢复堆序性质。每一次删除堆顶的时间复杂度是O(log n)因为下沉的路径长度就是当前堆的树高。n次删除的总复杂度是O(n log n)。堆排序整体复杂度 建堆O(n) n次删除O(n log n) O(n log n)。这个结果里有意思的一点是建堆是O(n)却淹没在O(n log n)的总复杂度里了。所以面试官如果问“堆排序的复杂度为什么是O(n log n)”正确的回答思路是先说清楚建堆是线性的再说清楚连续n次删除才是瓶颈。如果你一上来就直接说“每次操作logn所以总复杂度nlogn”面试官就知道你没真正理解堆。4.2 堆核心操作的复杂度对照表为了方便大家记忆和复习我把堆的各种操作复杂度整理成一个表操作时间复杂度备注访问堆顶取最值O(1)数组首元素直接返回插入元素O(log n)上浮操作最坏到根节点删除堆顶O(log n)下沉操作最坏到叶子节点删除任意元素O(log n)找到位置需要额外O(n)替换后上浮或下沉修改任意元素O(log n)找到位置O(n) 调整O(log n)原地建堆O(n)Floyd算法从最后一个非叶子节点开始插入建堆O(n log n)逐个上浮最坏情况为O(n log n)堆排序O(n log n)建堆O(n) n次删除O(n log n)空间复杂度O(1)原地排序不需要额外辅助数组注意表格里有一行“删除任意元素”这里我特意标注了“找到位置需要额外O(n)”。为什么因为堆只保证父子之间的偏序关系并不保证兄弟之间的大小顺序所以你想在堆里查找一个任意值的元素必须线性扫描这是堆的一个天然局限。如果业务中频繁需要“修改某个指定元素”你应该考虑用带索引的堆比如斐波那契堆、或者自己维护一个位置映射表。4.3 工程场景里堆的复杂度如何体现堆在工程中的应用非常广泛而且不同场景对复杂度的依赖也不太一样。场景一优先级队列。操作系统任务调度、网络请求的优先级处理都离不开堆。插入和取出最高优先级任务的复杂度都是O(log n)支撑着高并发场景下的任务调度。如果你用的是无序数组来做同样的事插入是O(1)但取出最大值需要O(n)扫描如果你用有序数组插入需要O(n)移动取出最大值是O(1)。堆恰好取了一个折中——插入和取出都是O(log n)整体表现最均衡。场景二Top K问题。“从1亿个数里找到最大的100个数”这是堆的高频考点。做法是维护一个大小为100的小顶堆遍历所有数据如果当前元素比堆顶大就替换堆顶并下沉。每次操作O(log 100)也就是O(log K)因为K远小于n所以整体复杂度可以认为是O(n log K)比排序后取前K个的O(n log n)要快得多。更关键的是这个方案不需要一次性把所有数据加载进内存非常适合流式数据处理场景。场景三定时器。很多网络框架的定时器实现都用了最小堆。每次取最近要到期的任务复杂度O(log n)比遍历所有定时任务的O(n)高效很多。Java的DelayQueue、Netty的HashedWheelTimer虽然设计思路不同但最小堆确实是很多场景下的主流选择。场景四合并K个有序链表。这题在力扣上是hard难度解法之一就是用堆。把K个链表的头节点放进小顶堆每次弹出最小值节点并加入该链表的下一个节点。整个过程做n次弹出和插入每次O(log K)总复杂度O(n log K)。其中n是所有链表节点数之和。这个解法简洁优雅是堆的综合应用典范。我在实际项目里用的最多的是Top K和优先级队列这两个场景而且有一个经验如果你需要的只是“全局最大/最小”这一个值那堆是杀鸡用牛刀——直接用一个变量维护最值就够了只有当你需要“动态变化中的最值且随时要取下一个次值”时堆才是最优解。很多初学者不分场景滥用优先队列反而导致性能下降这一点值得留意。5. 常见问题与排查技巧实录5.1 堆实现中容易踩的坑写堆代码的时候有几个典型错误我在初学时反复犯过后来总结成了速查表易错点错误写法正确写法错误后果孩子下标越界直接计算左孩子后不判断先判断left heap_size访问数组越界取右孩子时左孩子不存在直接比较左孩子和右孩子先判断右孩子是否存在逻辑错误堆大小与数组长度混淆下沉时用数组长度用堆的有效大小heap_size对已删除的节点做调整建堆遍历起点错误从n-1开始遍历从最后一个非叶子节点(n-2)//2开始叶子节点重复做无效下沉大顶堆小顶堆比较符号写反上浮/下沉比较方向不一致统一比较方向堆序性质被破坏其中最坑的我认为是第一条和第三条的组合——堆大小和数组长度混淆。堆排序中数组的前部分是堆后部分是排好序的序列。如果你在下沉过程中用数组长度代替堆大小就会把已经排好序的元素又拉回堆里来调整整个排序直接报废。另一个高频坑是二叉堆用数组实现时孩子节点的计算。如果堆顶下标从0开始左孩子是2*i1右孩子是2*i2如果从1开始左孩子是2*i右孩子是2*i1。下标起点不同所有公式都不同。很多人在两种约定之间反复横跳代码bug一堆。我的建议是选定一种下标约定后全程保持一致不要把两种混着写。我自己习惯用0-based因为C和Python的数组默认就是0起点。5.2 为什么你的复杂度推导总被人打回我先说一个面试场景。有人被问到“建堆的复杂度是多少”他回答O(n)面试官追问“为什么”他答不上来。这个场景我见过太多次了。单纯记住结论而没有理解推导过程在面试中非常危险。因为面试官只要换个问法——比如“如果我用插入的方式建堆复杂度是多少”、“为什么同样是循环下沉Floyd建堆却是线性的”——你就露馅了。我的建议是把复杂度的推导过程当成一个故事讲出来。你可以这样说“堆是一棵完全二叉树树高是log n。建堆时我从最后一个非叶子节点开始对每个节点做下沉操作。表面上看每个节点下沉的代价是O(log n)——但这是最坏上界不是每个节点的真实代价。按节点高度分层来看高度为0的叶子节点有n/2个下沉代价为0高度为1的节点有n/4个下沉代价为1高度为k的节点有n/2^(k1)个下沉代价为k。总代价是Σ n*k/2^(k1)这个级数收敛于一个常数乘以n所以建堆是O(n)。而插入建堆不同每个新节点插入时都是从叶子往上浮累计n次每次O(log n)所以是O(n log n)。”这段话背下来比背一百个面试题答案都有用。5.3 几个特殊的堆变种与复杂度延展标准的二叉堆是最基础的实现但工程上还有其他几种堆它们的复杂度特性值得了解d-ary堆d叉堆。每个节点有d个孩子。树高从log2 n变成logd n。插入操作从O(log n)变成O(logd n)上浮变快删除堆顶的下沉操作因为每层要比较d个孩子从O(log n)变成O(d * logd n)变慢。所以在删除操作频繁的场景中d不能取得过大。索引堆。在堆节点上额外维护一个位置映射表支持“修改指定元素”的复杂度从O(n)降到O(log n)。如果你的业务场景需要在堆中频繁修改已有元素的值比如Dijkstra最短路算法中的优先队列优化索引堆是必选项。斐波那契堆。理论上插入是O(1)合并是O(1)删除堆顶是O(log n)非常适合需要大量“减小键值”操作的算法。但实际工程中用得很少因为常数因子太大实现太复杂而且懒删除策略带来的内存开销不小。看看原理就好动手实现需谨慎。特别说明一下上面这些变种在不同资料中的复杂度结论可能存在细微差别比如斐波那契堆的摊还分析与最坏分析结果不同面试时如果被问到先确认面试官要的是最坏复杂度还是摊还复杂度再作答。5.4 堆代码调试的实战心得最后分享一点我调试堆代码的经验。堆的代码逻辑并不复杂但有一个特点错误不一定在出错的那一行暴露而可能在下一次调整时才崩溃。比如你某个节点的下沉方向写反了当时它可能恰好不在需要移动的位置上但后续插入元素时堆序性质被破坏的恶果才体现出来。我调试堆代码的方法分三步第一步打印数组。每次调整后打印整个数组人工检查堆序性质是否满足。第二步写一个验证函数。遍历所有节点检查每个父节点是否满足堆序性质def is_heap(arr, heap_size): for i in range(heap_size): left 2 * i 1 right 2 * i 2 if left heap_size and arr[left] arr[i]: # 大顶堆 return False if right heap_size and arr[right] arr[i]: return False return True第三步构造小数据量的极端场景。比如输入已经有序的数组、逆序的数组、全部相同元素的数组逐一验证你的堆代码在边界条件下是否稳定。这套方法论不仅适用于堆也适用于其他数据结构的调试。先验证核心性质再逐步缩小问题范围比瞎猜高效得多。我个人在实际操作中体会最深的一点是复杂度的数学推导一定要亲手算一遍。我听很多人说“建堆是O(n)”但直到我自己用错位相减法算完那个级数、又写代码跑实验验证了交换次数之后才真正对“为什么是O(n)”建立了直觉。这个直觉在面试和工程中帮了我很多次因为很多性能问题的排查思路最终都能归结为“这里到底应该用哪个数据结构、复杂度到底是多少”。希望这篇文章也能让你建立同样的感觉。