提到排序,大多数程序员脑子里第一个蹦出来的永远是快速排序。我工作这些年面过不少人,聊到堆排序的时候,能讲出“先建堆,再不断取根”的不少,但能把自底向上建堆为什么是 O(n)、换完根之后为什么要重新下沉、它为什么不稳定这三件事一次讲全的,几乎没遇到过。
堆排序本质上是一种树形排序:它不直接维护一个有序数组,而是先把数据组织成一棵藏在数组里的完全二叉树,让任意父节点都比子节点更大(或更小)。这种约束看起来松散,却足够保证根节点就是全局最大(或最小)。接下来每次把根节点捞出来放到末尾,再让剩余数据重新满足约束,数组就一点一点被排好了。堆排序算法也因此成为理解“优先队列”最自然的入口,更是 TopK、调度、图算法里那些“动态取最值”场景的底层思想。
这篇文章写给想真正吃透堆排序的开发者。不是背代码,而是把原理、手写实现、边界坑、复杂度推导和应用场景串成一条线。无论你是准备面试,还是要在工程里解决海量数据取前 K,都应该完完整整走一遍。
1. 堆排序的真正价值:被快排压着打,但树形思想无处不在
在很多人印象里,堆排序是一个“理论很强、实战一般”的算法。最坏时间复杂度 O(n log n),最好也是 O(n log n),平均还是 O(n log n),空间 O(1),表面上看几乎完美。但现实是,各语言默认排序几乎都不用它:Python 的 Timsort、C++ 的 std::sort 内部是 introsort,Java 的 Arrays.sort 对对象用的是 Timsort。为什么堆排序这么“标准”,却很少成为默认排序?
核心原因有两点。第一,快速排序的变体在绝大多数真实数据上更快,因为它对数组的访问是连续的,对 CPU 缓存非常友好;堆排序每次下沉都要在数组里做跳跃式访问,父子节点之间相隔很远,缓存命中率低很多。第二,堆排序的常数项偏大,即便复杂度同为 O(n log n),实际跑起来往往比快排慢。工程界追求的是真实数据上的性能,不是理论最坏情况下的保证。
但为什么堆排序依然值得你彻底学透?因为它有两个快排给不了的东西。
第一个是“最坏情况下有保证”。快速排序如果选主元选得差,会退化到 O(n²),这是很多线上事故的根源;堆排序无论输入是正序、逆序还是随机,始终是 O(n log n)。这在社会中代表“可控性”,有些低延迟系统宁可损失一点平均性能,也要换一个可预测的上界。
第二个是“树形结构的思想比排序算法本身值钱得多”。堆排序真正教会你的不是怎么排数组,而是怎么维护“随时能拿到最大/最小元素”的数据结构。TopK 问题、定时任务调度、Dijkstra 最短路径、流式数据中位数,底层都站着堆。很多人只把堆排序当成八股文,遇到实际问题时只会“全量排序”,就是因为当初学得太浅。
我举个例子。假设线上有千万级日志,你需要找出现频率最高的前 100 个用户。最朴素的做法是把所有日志整体排序一遍,再砍掉前面的 100 个;但数据量一大,全量排序既吃内存又吃时间。如果换成大小为 100 的小顶堆,每个日志只需要 O(log 100) 次比较,整体复杂度从 O(n log n) 降到 O(n log k)。这不是理论题,是我在工作中真实遇到过很多次的场景。堆排序看起来不用,但它的数据结构本体每天都在被用来救人。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 先从底层拆开堆:完全二叉树是如何“压”进一维数组的
想写对堆排序,必须先建立两个底层认知:堆是一棵完全二叉树;堆在内存里不靠指针,而是靠数组下标找到父子。
2.1 “堆”到底是什么
数据结构里的堆,有两个性质缺一不可。
第一,它是一棵完全二叉树。所谓完全二叉树,是指除了最后一层,其他层必须满;最后一层可以不满,但节点必须从左到右连续排列,不能左边空着、右边有节点。这个性质决定了它能被数组紧凑存储。
第二,它满足堆序性。大顶堆要求任意父节点的值都不小于它的子节点,根节点因此是全局最大;小顶堆要求任意父节点不大于子节点,根因此是全局最小。注意,这只是一种“半有序”状态,左右子节点之间没有任何大小约定,所以堆不是二叉搜索树,不能像 BST 那样做中序遍历输出有序序列。
这里还要顺手澄清一个常见误区:数据结构里的“堆”和编程语言内存模型里的“堆内存”只是名字一样,没有任何关系。JVM 里的堆内存简称 heap,是因为它像个杂物间,什么东西都能往里丢;算法里的堆是一棵树,名称来自“一堆东西堆在顶上”的直觉,最大的一定在树根。
2.2 数组下标里的父子关系
完全二叉树的迷人之处在于:只要按层序遍历顺序把节点放进一个一维数组,任何节点的父子和孩子下标都能用公式直接算出来。
假设当前节点下标是 i,那么:
- 左孩子下标:2 * i + 1
- 右孩子下标:2 * i + 2
- 父节点下标:(i - 1) // 2
看一个实际例子。数组 [16, 14, 10, 8, 7, 9, 3, 2, 4, 1] 对应的完全二叉树长这样:
text复制 16(0)
/ \
14(1) 10(2)
/ \ / \
8(3) 7(4) 9(5) 3(6)
/ \ /
2(7) 4(8) 1(9)
下标 0 的 16 是根;下标 1 的 14,左孩子在 21+1=3 的位置,右孩子在 21+2=4 的位置;下标 4 的 7,父节点在 (4-1)//2=1,也就是 14。
这套公式是一切操作的地基。写代码时最容易出问题的点也在这里:公式给出了下标,但不能保证这个下标一定存在。判断孩子是否存在,必须用“下标 < 当前堆的有效长度”来检查,而不是直接去取 arr[child]。这一点后面会反复提到。
2.3 为什么用数组存树,而不是用链表
很多初学者会问:树不是应该用节点加指针吗?为什么堆要用数组?
原因主要有三个。
第一,完全二叉树没有空洞,层序遍历放到数组里一定连续,所以没有必要用指针去“记录”孩子。你只需要知道自己的下标,就能算出孩子的下标,这等于把指针信息压缩进了下标里。
第二,数组连续存储对缓存友好。虽然堆排序在下沉过程中访问的跨度比较大,但整体上还是比散落在堆内存里的一堆节点对象要好。
第三,堆排序要求原地完成,数据本身就在一个数组里。如果改用链表式的树节点,排序时还要额外维护指针,空间复杂度很难做到 O(1),代码也会复杂得多。
3. 下沉是灵魂,建堆是战术:怎么把乱数组变成合法堆
堆排序最核心的单步操作不是交换根,而是“下沉”。这个操作叫 sift-down,也有人叫 heapify。搞懂了下沉,建堆和排序主循环就都顺了。
3.1 下沉:把不合格的“领导”逐级降职
想象一家公司,规定任何领导的能力都不能比下属弱。结果某个部门空降了一个能力很差的领导,他的两个下属都比他强。为了让组织恢复合规,要做的就是把最厉害的下属提拔上来,把这位不合格领导往下压;压到下一层之后,他还可能比新下属弱,于是继续压,直到他到了某个位置,发现自己比所有下属都强为止。
这就是大顶堆的下沉过程。对当前节点 i,它的左孩子是 2i+1,右孩子是 2i+2。先找出两个孩子中更大的那个;如果更大的孩子比父节点还大,就交换父节点和这个孩子;然后继续下沉到那个孩子的位置,重复比较,直到节点不再需要交换,或者到达叶子节点。
代码可以这样写:
python复制def sift_down(arr, n, i):
"""大顶堆下沉,n 表示当前堆的有效长度"""
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
这里有一个非常关键的细节:交换时一定要选“更大的孩子”,不能随便选一个孩子。假如当前值是 5,两个子节点是 9 和 8,你如果先拿 8 上来,数组变成 [8, 9, ...],8 还是比 9 小,还得再换一次,甚至可能破坏下面的堆序。直接选最大的孩子,一次就能保证交换后的父节点不小于两个孩子,效率最高。
3.2 自底向上建堆:为什么从 n//2 - 1 往回扫
给了一个乱数组,还没有任何堆结构,怎么把它变成大顶堆?
很多人第一反应是从根开始下沉。这是错的。因为下沉的前提是:被下沉节点的左右子树都已经各自满足堆序。如果子树本身还是乱的,把根换下去之后,下面又冒出一个不符合规则的节点,问题会越滚越大。
正确做法是自底向上,从最后一个非叶子节点开始,逐个往前下沉。在 0 基数组中,最后一个元素的下标是 n-1,它的父节点是 (n-2)//2,也就是 n//2 - 1。这个节点就是数组中最后一个有孩子的节点,下标比它大的节点全是叶子;叶子没有孩子,不需要下沉。
建堆代码因此是:
python复制for i in range(n // 2 - 1, -1, -1):
sift_down(arr, n, i)
这个顺序其实就是完全二叉树上的“后序遍历”思想:先把底层子树调整成堆,再一层层往上,最后调整根,整棵树就变成大顶堆了。
很多人以为这个阶段的复杂度是 O(n log n),因为有大约 n/2 个节点在下沉,每个下沉 O(log n)。实际上它是 O(n)。原因在于:越靠近根部的节点数量越少,虽然它们下沉路径长,但数量呈指数级减少;越靠近底部的节点数量多,可它们几乎不需要下沉。逐层累加后,总工作量会收敛到 O(n) 量级,而不是 O(n log n)。面试时如果只背结论,很容易被追问到崩溃。
3.3 上浮:优先级队列插入时真正用到的兄弟操作
下沉是从某个节点往下“压”,上浮(sift-up)是从某个节点往上“升”。这两个操作方向相反,但都很常用。
上浮的逻辑是:新元素先放在数组尾部,然后不断和自己的父节点比较;如果父节点比自己小(大顶堆场景),就把父子交换;重复直到遇到一个比自己大的父节点,
