想象你是一栋 24 层“资历大厦”的新任物业经理。大厦有个奇特规矩:工号数字越大的人,必须住在越高的楼层。于是,任何人抬头,都能看见楼上住着工号比自己大的人,顶楼永远住着全楼最资深的员工。这栋大厦的入住规则,就是计算机世界里“大顶堆”的样子。招聘、换房、调整、退休——堆排序的每一步,都像物业经理在安排整栋大厦的入住。它不追求一步到位的优雅,而是靠“大数字住高层”这一条简单规则,反复执行同一种调整动作,最终让整栋大厦从顶楼到底楼按资历从高到低排好。整个过程雷打不动地消耗 O(n log n) 步,却几乎不占额外的房间。
掌握堆排序,你就拿到了理解优先队列、Top-K 热榜、Dijkstra 最短路径等工程场景的钥匙,更是面试中“最坏情况也不翻车”的排序底气。
目录
可视化演示
动态演示堆排序算法原理与复杂度分析的核心机制,通过开始/执行/监控/完成四种模式展示关键概念与交互流程。
本节配有交互动效(进入视口后自动加载,约 3KB)。
堆排序算法原理与复杂度分析:二叉堆的 二叉堆的数组表示与堆性质 自底向上建堆与 O(n) 复杂 核心机制可视化 堆排序算法原理与复杂度分析整体架构与关键组件
堆的定义:资历大厦的入住规则
物业经理上岗的第一天,保洁阿姨就告诉他一句全楼铁律:“楼上工号必须比楼下大,谁也不能例外。”这句铁律听起来简单,却让整栋楼有了一个神奇的性质——顶楼永远住着全楼工号最大的人。无论是找人开会还是决定大事,你只需要看一眼顶楼,就能拿到“现役最强”的答案。这条铁律,就是数据结构中“大顶堆”的核心定义。
在计算机世界里,这栋“资历大厦”叫二叉堆。它首先是一棵完全二叉树:每一层都从左到右填满,最后一层也从左往右连续放置,中间不能有空缺。换句话说,大厦的房间可以一层层从左往右数下去,不会有“某层中间空着”的情况。其次,它满足“堆序性质”:对任意节点,父节点的值不小于它的左、右孩子。如果父节点比孩子小,就违反了大厦铁律,需要立刻调整。
有了这两条规则,我们就能用一个简单的数学表达式描述大厦铁律。对数组中任意节点 i(它的左孩子是 2i+1、右孩子是 2i+2),大顶堆要求:
看,大厦铁律在计算机里就是一行公式。只要每个房间都遵守它,顶楼永远是最资深的人。而这,正是堆排序全部力量的来源——它不需要每次都“大海捞针”般地找最大值,最大值永远自觉地站在顶楼等你。
- a[i]下标为 i 的房间(节点)里存放的值,可以理解成工号。
- 大于等于号父节点的值不小于子节点,保证“楼上的资历更老”。
- a[2i+1]下标 2i+1 是 i 的左孩子,即 i 左边隔壁楼层的那个房间。
- a[2i+2]下标 2i+2 是 i 的右孩子,即 i 右边隔壁楼层的房间。
数组存储:大厦的编号系统
物业经理很快发现,铁律要落地,必须先解决“怎么称呼每个房间”的问题。于是他给大厦设计了一套编号规则:从顶楼开始,一层层从左往右编号,顶楼是 0 号,第二层左 1 号、右 2 号,第三层依次 3、4、5……这套编号规则,就是二叉堆用数组存储的方式。
为什么不用链式结构?因为完全二叉树的形状非常规整,每个房间的位置都能通过简单的算数推算出来。只要知道某个房间的编号 i,就能立刻算出它的上下邻居。这种“用数组保存一棵树”的技巧,让堆几乎不浪费任何存储空间,数组的第几个元素就是树的第几个节点,一一对应。
对物业经理来说,最常用的换算公式有三个:找左孩子、找右孩子、找爸爸。孩子永远分布在下一层,而爸爸永远在上一层。给定节点下标 i,它的左孩子是 2i+1,右孩子是 2i+2,父亲则是 (i-1)/2 的整数部分。用公式表达就是:
有了这套编号系统,物业经理在登记册上随便一指,就知道该去哪个房间查工号。这也是堆排序空间开销极小的秘密:整棵“大厦”就装在一个普通数组里,不需要额外的指针或节点对象,真正做到“一张纸登记全楼住户”。
- i当前房间的编号,也就是数组下标。
- i-1从当前房间编号回退 1,用于统一左、右孩子反推父亲的差异。
- 除以 2上一层节点数是下一层的一半,所以向上走一层就除一次 2。
- 向下取整符号左右孩子在整数除法下会落在同一个区间,取整后得到唯一的父节点下标。
下沉操作:住错楼层的降级流程
有一天,一位工号 5 的新员工因为人事录入错误,被安排进了顶楼。整栋大厦的住户怨声载道:“楼上工号比楼下小,铁律被破坏了!”物业经理的补救办法很朴素:让 5 号一路往下换,直到找到属于他的楼层。这个过程,就是堆排序的核心动作——下沉(sift down)。
下沉的具体流程像一场“楼层淘汰赛”:先看看这位住错楼层的人两个孩子里,谁的工号更大(如果孩子工号都比他小,那他就已经合格了,不用动);然后让 5 号跟较大的孩子交换房间;交换后,他可能依然比新楼层的孩子小,那就继续重复“比孩子、换房间”的循环,直到他到达一个两孩子都不如自己的位置。整个过程就像掉进井里的石头,一路磕磕碰碰落到井底。
用数学语言描述一次比较:假设当前作弊者站在下标 i,他的两个候选对手是左孩子 2i+1 和右孩子 2i+2。我们要从这三个房间中找出工号最大的那个,作为交换对象:
如果最大值就是当前的 a[i],说明他已经打赢了所有孩子,下沉结束;否则,就把他和最大的孩子交换,再继续下一轮比较。每一次下沉最多沿着树的高度走到底,所以单次下沉的时间不超过树的层数 O(log n)。这个“错楼层员工降级”的流程虽然朴实,却是堆无数次自我修复的基石——整栋大厦的所有秩序,都靠这一招反复执行来维持。
- a[i]当前正在下沉的节点值,即住错楼层的工号。
- a[2i+1]左孩子的值,它可能比当前节点大,也可能小。
- a[2i+2]右孩子的值,它是另一个候选对手。
- max 取最大三选一,谁最大谁就该待在更上面的位置。
建堆过程:从底层开始的团队重整
新大厦落成那天,住户是随机入住的,整栋楼完全不符合铁律。物业经理面临一个难题:如何用最少的力气,让全楼从无序变为有序?聪明的做法不是从顶楼开始调整,而是从倒数第二层开始,逐层向上做下沉。为什么?因为对最底层(叶层)的员工来说,他们本来就没有孩子,天然就是“合格”的。
“从倒数第二层开始”背后的数字是 n/2 - 1,这是最后一个非叶子节点的下标。从它开始,往前逐个做下沉操作,每处理完一个节点,以它为根的子树就变成一个小堆;当处理到根节点时,整棵树就都是大顶堆了。这个自底向上的过程,像工人们在盖楼时先一层层夯实地基——底下的楼层先自洽,上面的调整才有支撑。
你可能直觉上觉得,每个节点都做一次下沉,那总时间应该是 O(n log n)。但数学家发现了这栋大厦的秘密:越靠近底层的节点数量越多,但它们能下沉的高度越短;越靠近顶层的节点能下沉很远,但数量极少。把每一层的“节点数 × 下沉高度”加起来,总量恰好被压缩成线性:
建堆的“自底向上”策略,正好对应物业经理的智慧:先让底层的小团队各自形成规范,再逐级合并成大团队。每一级的新领导只需要下沉很短的距离就能适应岗位。整栋大厦的“入职整顿”因此高效得惊人——这也是堆排序相比其他排序最先让人眼前一亮的地方。
- h 从 0 到 log₂nh 表示从叶层往上数的层号,叶层 h=0,越往上 h 越大。
- n / 2^{h+1}第 h 层的节点数量,越往上的层节点数越少。
- 乘以 h该层每个节点最多下沉 h 层,是单节点的下沉成本。
- 结果为 O(n)级数求和后收敛为线性,因为越上层节点越少,那些“下沉很远”的节点数量少得可以忽略。
排序流程:把资深员工一个个请进退休区
大厦建成、铁律恢复后,真正的排序才刚刚开始。物业经理的终极目标,是让整栋楼从顶楼到底楼按工号从大到小整齐排列。他祭出的办法简单得可爱:每次把顶楼最资深的人请去“退休区”,然后让新人重新顶上。
具体操作分四步循环:第一步,把堆顶(当前最大工号)和堆的最后一个元素交换,于是最大值被送到了数组末尾,那里成为“退休区”(已排序区);第二步,把堆的长度减一,退休区的员工不再参与大厦管理;第三步,被换到顶楼的新员工工号通常很小,需要执行一次下沉操作,让大厦重新满足铁律;第四步,不断重复以上过程,直到堆里只剩下一个住户。此时,整个数组从前往后看就是从小到大的升序——因为每次都是从大到小“请出”了最大值。
每一轮排序都包含一次 O(1) 的交换和一次 O(log n) 的下沉。排序阶段要执行 n-1 轮,当堆越来越小时,下沉的成本也越来越低。累计起来,排序阶段的总工作量是:
这就像一个循环往复数钱的过程:每次都把最大的一张钞票抽出来放到一边,再把剩下的钞票重新整理成“顶楼最大”的秩序。虽然每次整理都要花点功夫,但总共只整理 n-1 次,整体开销依然非常可控——堆排序的核心循环,就这样优雅地完成了全部排序任务。
- k 从 2 到 nk 表示当前堆的大小,每轮排序后堆缩小 1。
- O(log k)在大小为 k 的堆中做一次下沉的成本,与堆高成正比。
- 累加所有轮次把 n-1 轮的下沉成本全部加起来。
- 等于 O(n log n)对数和的总和渐近等于 n log n,这就是排序阶段的时间上界。
复杂度分析:效率账本
年底总结时,物业经理拿出账本,核对了整栋大厦一年来的所有操作。堆排序的总时间由两部分构成:建堆 O(n) 加上 n-1 次下沉 O(n log n),两者相加仍是 O(n log n)。更关键的是,这个复杂度不随输入数据的初始顺序而波动——无论数组原本是升序、降序还是乱序,堆排序的效率都稳如泰山,不会出现快排那样“最坏退化到 O(n²)”的翻车时刻。
空间账本同样漂亮:堆排序是在原数组内做交换,只用了常数个临时变量(交换用),没有申请额外的大块内存,因此空间复杂度是 O(1)。这是它与归并排序最大的分野——归并排序虽然稳定,却必须额外开辟一块和原数组一样大的空间来“归档”。
总时间和总空间写在一起,就是堆排序的完整效率画像:
不过,工号交换意味着相同工号的两个人可能被调换相对顺序。举个极端例子:两个工号同为 30 的员工,一个在左一个在右,排序后右边的可能跑到左边——所以堆排序是不稳定的。这就像大厦搬家时,两件相同重量的行李可能互换了位置。好在大厦不需要关心这个细节,只要最终数值有序即可。堆排序这份账本,算得上“时间稳健、空间节俭”,唯一的瑕疵就是不稳定。
- O(n)建堆阶段的开销,自底向上的下沉总和是线性的。
- O(n log n)排序阶段 n-1 轮下沉的总开销。
- 相加取主导项线性项被 log 主导,最终时间复杂度是 O(n log n)。
- S(n) = O(1)空间复杂度只看额外使用的变量数量,是常数级。
对比与应用:不同选人策略的博弈
物业经理把大厦管理经验分享给三位同行,发现大家选人的策略各不相同。快排如同“快刀斩乱麻”的临时主管:平时干活飞快(平均 O(n log n)),但遇到极端楼层布局时可能手忙脚乱,退化到 O(n²);归并如同“资料归档大师”:做事稳定有序,但每轮都要把资料复制一份到新柜子,空间成本 O(n);堆排序则像本大厦的铁律执行者:不追求最快,但只靠 O(1) 额外空间,就能承诺“最差情况也不超过 O(n log n)”。
这三种策略的现实选型也有规律可循。如果系统对最坏响应时间有硬性要求(比如实时交易撮合),堆排序的“最坏也有保证”就格外珍贵;如果数据量巨大且内存紧张,堆排序的原位排序能力是救命稻草;如果需要对对象做稳定排序且内存宽裕,归并更合适;大部分通用场景下,快排凭借极小的常数因子胜出,这也解释了为什么语言内置排序多是快排的改良版。
堆排序还有另一个响亮的名号——优先队列的底层引擎。凡是需要“随时取出最大/最小元素”的场景,背后几乎都站着一个堆。用堆实现 Top-K 问题(从海量数据中找最大的 K 个),只需要维护一个大小为 K 的小顶堆,成本只需:
从大厦的管理哲学中,我们学到最重要的一课:当你在乎“最坏情况”而不只是“平均情况”时,堆排序是你最可靠的伙伴。它可能不是最快的,但它用一个小小的堆顶,保证了任何时刻你都能以可预测的成本获取全局最值——这份稳定,正是优先队列、Dijkstra 最短路径、Top-K 热榜等无数工程系统选择它的理由。
- n需要扫描的全部数据量。
- log k堆中只有 K 个元素,每插入或替换一次的成本只与 K 相关。
- 相乘对 n 个元素各处理一次,总成本就是 n 乘 log k。
AI 应用场景
大模型推理中的 Top-K 采样
阶段:模型推理阶段,策略采样
解决的问题:大模型生成下一个 token 时,需要从词表中选出概率最高的 K 个候选词做采样。词表可能有上万甚至几十万项,直接全排序非常昂贵;传统做法是排序后取前 K,但排序整个分布浪费了大量计算。
具体案例:以 ChatGPT 生成“今天天气”的续写为例,模型在每一步推理时都会得到一张几十万词的候选概率表。系统用一个大小为 K 的小顶堆扫描这张表,每遇到一个比堆顶概率更高的词就替换堆顶并下沉,最终堆里留下的就是概率最高的 K 个词,整个过程只消耗 O(K) 空间和 O(V·log K) 时间,比全排序快几个数量级。
推荐系统的高频物品热榜
阶段:离线数据处理 / 特征工程
解决的问题:推荐系统需要从用户行为日志中统计“点击率最高的前 100 个物品”,日志可能有上亿条;传统 MapReduce 全量排序既慢又耗内存,堆排序的 Top-K 思路可以只保留一个很小的候选堆。
具体案例:某电商平台的实时排行榜服务,维护一个大小为 100 的小顶堆。每来一条新的点击量数据,就与堆顶比较,如果更大则替换并下沉,始终让最小的候选留在堆顶。这样任何时刻,堆里的 100 个元素就是当前点击量最高的 100 个商品,查询 Top-K 的响应时间稳定在微秒级。
任务调度器的优先队列实现
阶段:训练平台任务调度
解决的问题:AI 训练平台上,成百上千个训练任务提交后,调度器需要按优先级决定哪个任务先拿 GPU 资源。普通队列需要遍历找最高优先级,效率低;用堆实现优先队列,每次“取出最高优先级任务”只需 O(log n)。
具体案例:Kubernetes 调度器中的调度队列就是典型的堆实现:每个 Pod 按优先级入堆,调度循环每次从堆顶取出优先级最高的 Pod 去分配节点,新任务到达时入堆调整的耗时仅为 O(log n),即使集群中有上万个等待任务,调度延迟也依然可控。
Python 演示
def sift_down(arr, n, i): """从节点 i 开始下沉,维护大顶堆性质""" while True: largest = i left = 2 * i + 1 right = 2 * i + 2 if left < n and arr[left] > arr[largest]: largest = left if right < n and arr[right] > arr[largest]: largest = right if largest == i: break arr[i], arr[largest] = arr[largest], arr[i] i = largest def heap_sort(arr): n = len(arr) # 建堆:从最后一个非叶子节点开始自底向上下沉 for i in range(n // 2 - 1, -1, -1): sift_down(arr, n, i) print("建堆完成:", arr) # 排序:每次把堆顶最大值换到末尾,再对缩小的堆下沉 for end in range(n - 1, 0, -1): arr[0], arr[end] = arr[end], arr[0] sift_down(arr, end, 0) print("第 %d 轮:" % (n - end), arr) return arr if __name__ == "__main__": data = [4, 10, 3, 5, 1] heap_sort(data) print("排序结果:", data)
for i in range(n // 2 - 1, -1, -1):从最后一个非叶子节点开始倒着遍历,保证每个子树先变成小堆。sift_down(arr, n, i)下沉函数,让当前节点一路和较大的孩子交换,直到满足堆性质。largest = i先假设当前节点就是最大的,再依次跟孩子比。if left < n and arr[left] > arr[largest]:左孩子存在且更大时,更新最大值为左孩子下标。if largest == i: break如果最大值还是自己,说明已经到位,提前终止循环。arr[0], arr[end] = arr[end], arr[0]把堆顶(最大值)与当前末尾交换,相当于“请进退休区”。sift_down(arr, end, 0)对缩小后的堆重新调整堆顶,准备下一轮。
常见误区
误区听说堆排序用到了“堆”,就以为是程序运行时申请内存的那个“堆区(heap)”,担心堆排序会占用大量动态内存。
正解这是两个完全同名的不同概念。数据结构里的“堆”是一种像大厦楼层一样的树形结构,重点在“父子大小关系”;而内存里的“堆”是操作系统管理动态分配内存的区域。堆排序使用的“堆”只是逻辑上的结构,实际数据仍存储在普通的数组里,不涉及内存堆的动态分配,额外空间仅有常数级 O(1)。
为何常见:英文单词 heap 同时指“堆结构”和“堆内存”,初学者最早接触的是语言运行时概念,遇到数据结构的堆时极易混淆。
误区建堆是对 n 个节点各做一次下沉,每次下沉 O(log n),所以建堆应该是 O(n log n),堆排序总时间就该是 2 倍 O(n log n)。
正解账不是这么算的。虽然每个节点都下沉了一次,但绝大多数节点在底层,下沉高度很短。把“每层的节点数 × 下沉高度”求和,越上层节点越少,总和收敛为 O(n)。这就像整栋大厦调整楼层:底层几百人每人只需上下 1-2 层,顶楼那几个人虽要走到很下面,但人数太少,总工作量被拉低到线性。
为何常见:直觉上“n 次操作 × 单次 log n 成本”是乘法思维,忽略了节点数量随高度按指数递减的结构性事实。
误区堆排序每次都是拿堆顶最大值放到末尾,看起来很有序,应该和冒泡、归并一样是稳定排序吧?
正解堆排序不稳定。下沉操作中,值相等的元素可能隔着很远被交换,破坏了原有相对顺序。例如数组 [30a, 30b, 5],建堆后 30a 和 30b 可能互换位置,排序后 30b 反而跑到了 30a 前面。堆排序只保证“数值有序”,不保证“相等元素先后顺序不变”。
为何常见:人们常把“排序效果好、结果正确”和“稳定”混为一谈,忽略了稳定性是一个只关乎相等元素相对顺序的精细性质。
误区堆排序最坏也是 O(n log n),快排最坏会退化 O(n²),所以堆排序应该全面碾压快排,工程上应该优先用堆排序。
正解大 O 复杂度描述的是渐近趋势,真实运行还受常数因子影响。堆排序的“下沉”操作访问内存是跳跃式的(父子下标跨度大),缓存命中率低;快排是顺序扫描 + 交换,缓存友好,常数因子小得多。实测中快排通常比堆排序快 2-5 倍,因此标准库的 sort 多为快排改良版。堆排序的优势不在“快”,而在“最坏可预测 + 空间 O(1)”。
为何常见:初学者只看大 O 忽略了常数因子和缓存局部性,把理论复杂度和实际性能画了等号。
面试题
Q1. 在大顶堆中,根节点(下标 0)存储的一定是什么?
- A.最小值
- B.最大值← 正确答案
- C.中间值
- D.任意值
答案:B。大顶堆的性质是每个父节点都不小于孩子,根节点是所有人的“领导”,层层传递下来,根节点必然是全局最大值。这就像资历大厦的顶楼必然住着全楼最大工号。
Q2. 数组实现的二叉堆中,下标为 i 的节点的左孩子下标是?
- A.i+1
- B.i-1
- C.2i+1← 正确答案
- D.2i+2
答案:C。完全二叉树按层序编号时,父节点 i 的左孩子是 2i+1、右孩子是 2i+2,父节点是 (i-1)/2 向下取整。这是堆结构用数组存储时的基本换算,物业经理靠它快速找房间。
Q3. 对长度为 n 的数组建堆,第一个需要执行下沉操作的非叶子节点下标是?
- A.n-1
- B.n/2-1← 正确答案
- C.n/2
- D.0
答案:B。叶子节点没有孩子,天然满足堆性质,不需要调整。最后一个非叶子节点是最后一个元素 n-1 的父节点,即 (n-1-1)/2 向下取整 = n/2-1。建堆从它开始自底向上逐个下沉。
Q4. 对一个长度为 n 的无序数组建堆,时间复杂度是多少?
- A.O(n)← 正确答案
- B.O(n log n)
- C.O(log n)
- D.O(n²)
答案:A。自底向上建堆时,底层节点数量多但下沉高度短,顶层节点能下沉很远但数量极少。把“节点数 × 下沉高度”按层求和会收敛为 O(n)。这就像调整大厦楼层,底层人虽多但每人只走一两层,总工作量是线性的。
Q5. 堆排序中,单次“下沉”操作在大小为 n 的堆上的最坏时间复杂度是?
- A.O(1)
- B.O(log n)← 正确答案
- C.O(n)
- D.O(n log n)
答案:B。一次下沉从某个节点出发,每轮交换一次就下降一层,最多走到叶子。完全二叉树的高度为 log₂n,所以最坏情况下比较和交换次数是 O(log n)。像工号 5 一路从顶楼沉到底楼,步数不超过楼层数。
Q6. 下列关于堆排序的说法,正确的有哪些?
- A.空间复杂度为 O(1)← 正确答案
- B.属于稳定排序
- C.是原地排序← 正确答案
- D.平均时间复杂度为 O(n log n)← 正确答案
答案:ACD。堆排序在原始数组内交换元素,额外空间只有常数级临时变量,是原地排序且空间 O(1),A、C 正确;平均和最坏时间都是 O(n log n),D 正确。但下沉的远距离交换会破坏相等元素相对顺序,所以它不稳定,B 错误。
Q7. 堆排序和快速排序相比,堆排序最独特的优势是什么?
- A.平均时间复杂度更低
- B.天然稳定排序
- C.最坏情况仍保持 O(n log n) 且空间 O(1)← 正确答案
- D.缓存命中率更高
答案:C。快排平均 O(n log n) 但最坏退化 O(n²),归并最坏 O(n log n) 但需要 O(n) 空间;堆排序两者兼得——最坏也是有保证的 O(n log n),且只需要 O(1) 额外空间。它的劣势是常数因子大、缓存不友好,所以不是“更快”,而是“最坏可预测”。
Q8. 以下哪些场景适合使用堆排序或堆结构?
- A.从 1 亿个数中找出最大的 100 个← 正确答案
- B.实现优先队列,反复取出最高优先级元素← 正确答案
- C.对内存极紧张的单片机上的数组排序← 正确答案
- D.对大型对象数组做稳定排序
答案:ABC。Top-K 问题用大小为 K 的堆扫描全部数据,成本 O(n log K),A 合适;优先队列的底层就是堆,插入和取最值都是 O(log n),B 合适;堆排序空间 O(1) 且最坏可控,适合内存紧张场景,C 合适。D 需要稳定排序,堆排序不稳定,更适合归并排序。
Q9. 关于堆排序的时间复杂度,下列说法正确的有?
- A.最好情况是 O(n log n)← 正确答案
- B.最坏情况是 O(n log n)← 正确答案
- C.平均情况是 O(n log n)← 正确答案
- D.最好情况是 O(n)
答案:ABC。堆排序的复杂度不随初始数据分布变化,建堆 O(n)、排序阶段固定 n-1 轮下沉,因此最好、最坏、平均都是 O(n log n)。它不会像快排那样在已有序数组上退化,这是它的稳健性来源。
Q10. 建堆之所以是 O(n) 而不是 O(n log n),最本质的原因是?
- A.下沉操作比上浮操作快
- B.大部分节点位于底层且下沉高度很短,各层工作量求和收敛← 正确答案
- C.建堆不需要比较元素
- D.数组随机访问是 O(1)
答案:B。设 h 为从叶层往上数的层号,第 h 层约 n/2^(h+1) 个节点,每个节点最多下沉 h 层,总工作量是 ∑(n/2^(h+1))·h,这个级数收敛于 O(n)。关键在于底层节点数量按指数级增长,但下沉高度按线性缩短,两者对冲后总量是线性。A、C、D 都不是原因。