拓十年匠心定制 · 商业建站与技术教学双线并行 咨询热线:400-886-1026 service@lmnt.cn
ARTICLE DETAIL

资讯详情

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

堆排序复杂度详解:从完全二叉树到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 插入操作:从下往上的上浮

堆的插入操作,流程分两步:

  1. 先把新元素放到数组的末尾,也就是完全二叉树的最后一个叶子节点位置。
  2. 然后让这个元素沿着父节点路径不断上浮(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 删除堆顶:自上而下的下沉

删除堆顶(也就是大顶堆的最大值、小顶堆的最小值),流程也分两步:

  1. 把堆顶元素与数组最后一个元素交换。
  2. 删除最后一个元素(原堆顶),此时新堆顶是原来最后的那个叶子元素,它大概率不满足堆序性质。
  3. 让这个元素从堆顶开始不断下沉(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^(k+1))。

总代价可以写成求和式:

T(n) = Σ (高度为k的节点数 * k) ≤ Σ_{k=0}^{h} (n / 2^(k+1)) * k = n * Σ_{k=0}^{h} k / 2^(k+1)

这里的关键是那个无穷级数。当h趋向无穷大时:

Σ_{k=0}^{∞} k / 2^(k+1) = 1

这个级数的值可以用错位相减法求得。如果你不记得具体推导,可以记一个常用的等比-等差混合级数结论:Σ k / 2^k = 2,所以Σ k / 2^(k+1) = (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万 × 20(log2约等于20)的两千万量级。

这说明什么?说明平均情况下每个节点下沉的高度大约只有1层。这个实验不是我编的,是堆这种结构的必然结果——底层节点虽然数量多,但高度低;高层节点高度大,但数量少。两相抵消,最终每个节点的平均下沉代价趋近于常数级别。

我建议你们自己复现一下这个实验,因为亲手跑出数据之后,你对“建堆是O(n)”的信任感会完全不同。纸上谈兵百遍,不如动手验证一遍。

4. 堆排序与相关应用场景的复杂度全景

4.1 堆排序为什么是O(n log n)

堆排序的完整流程分两个阶段:

阶段一:建堆。对无序数组原地建堆,复杂度O(n)。

阶段二:反复删除堆顶。共n次删除操作,每次:

  1. 把堆顶(最大值)与当前堆的最后一个元素交换。
  2. 堆大小减一。
  3. 对新堆顶执行下沉操作,恢复堆序性质。

每一次删除堆顶的时间复杂度是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*i+1,右孩子是2*i+2;如果从1开始,左孩子是2*i,右孩子是2*i+1。下标起点不同,所有公式都不同。很多人在两种约定之间反复横跳,代码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^(k+1)个,下沉代价为k。总代价是Σ n*k/2^(k+1),这个级数收敛于一个常数乘以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)”建立了直觉。这个直觉在面试和工程中帮了我很多次,因为很多性能问题的排查思路,最终都能归结为“这里到底应该用哪个数据结构、复杂度到底是多少”。希望这篇文章也能让你建立同样的感觉。

返回列表