聊堆排序,先别急着翻代码。
这东西很多人学过就忘,因为教科书总喜欢一上来就堆定义、堆性质、数组下标公式,看得人昏昏欲睡。但如果你换个视角,把堆排序看成“树形选择排序的优化版”,整个思路就通了——以前的简单选择排序,每次找最大元素要扫描整个数组,复杂度 O(n);堆排序做的唯一一件事,就是用一种特殊的二叉树结构,把“找最大值”这个操作从 O(n) 降到了 O(log n)。就这一下,整体排序从 O(n²) 变成了 O(n log n)。
这篇东西我会从树的结构、堆的性质一路讲到建堆、排序、代码实现,最后再聊聊我实际工程里踩过的坑。适合三种人看:笔试面试前想彻底搞懂堆排序的、写业务代码时想用优先队列但不知道原理的、以及刷 LeetCode 时看到 Top K 问题就头疼的。
1. 堆排序到底在排什么:先看树的本质
1.1 数组里的“树”:完全二叉树的连续存储
堆排序的核心数据结构是“堆”,而堆本质上就是一棵完全二叉树。所谓完全二叉树,指的是除了最后一层,上面每一层都是满的,最后一层的节点也全部靠左排列。为什么要这么严格?因为完全二叉树有一个极大的好处:它可以只用数组存储,不需要任何指针。
你想想,普通的二叉树存链表结构,每个节点得存左右孩子指针,还得在堆上动态分配,随用随建,内存开销大、访问还慢。完全二叉树不用,它直接平铺在一个数组里,用下标算亲戚关系:
code复制假设根节点在下标 0:
左孩子下标 = 2 * i + 1
右孩子下标 = 2 * i + 2
父节点下标 = (i - 1) / 2
如果根节点在下标 1(很多教材这么干),公式就变成:
code复制左孩子 = 2 * i
右孩子 = 2 * i + 1
父节点 = i / 2
下标从 0 还是从 1 开始,代码会略有差异,但原理一模一样。我下面默认从 0 开始讲,这是现在绝大多数编程语言的习惯。
用数组存树的另一个隐藏优势是缓存友好。因为数组是一段连续内存,访问孩子节点时,它们大概率在同一条缓存行里,比链式二叉树那种到处乱跳的方式快得多。这也是堆能做 O(n) 建堆、O(log n) 调整的底层硬件前提。
1.2 大顶堆与小顶堆:堆这个“堆”到底长什么样
堆分两种:大顶堆(最大堆)和小顶堆(最小堆)。
大顶堆的性质一句话:每个节点的值都不小于它的左右孩子。也就是说,堆顶(根节点)一定是整个数组的最大值。注意,这只约束了父节点和孩子的大小关系,并没有约束左右孩子之间的大小,也没有约束同一个层级之间节点的大小。这一点非常重要,很多人写堆排序时下意识以为堆是完全有序的,其实不是,堆只保证了一条“全局弱有序”的链条:沿着从根到叶子的任一条路径,元素值是单调递减(大顶堆)或单调递增(小顶堆)的。
小顶堆则相反,每个节点的值都不大于它的孩子,堆顶是最小值。
用大白话理解:大顶堆就是一个公司的“老板最大”结构,老板下面是两个总监,总监不一定比另一个总监级别高,但总监一定比自己的下属级别高。你要找公司谁最大,直接看堆顶就行。
堆排序默认用大顶堆排升序,因为每次取堆顶就是当前最大值,把它放到数组末尾,就完成了“每次确定一个最终位置”的选择排序思路。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 两个基础操作:下沉和上升
2.1 下沉(sift_down):把不听话的节点拉回原位
堆的一切操作,核心都是这个 down(下沉) 操作,也叫 heapify、sift_down 或者 percolate_down。它解决的问题是:一个节点违反了大顶堆性质(比孩子小),需要把它往下挪,直到它比孩子都大。
具体步骤是:
- 拿到当前节点
i。 - 找出它和它左右孩子中的最大值,记下标为
largest。 - 如果
largest不是i,就把i和largest交换,然后继续对largest位置执行下沉。 - 如果
largest就是i,说明它已经比孩子都大了,结束。
一次下沉最多走完从根到叶子的高度,完全二叉树的高度是 O(log n),所以一次下沉的代价就是 O(log n)。
这里有一个必须注意的细节:下沉之前必须保证 i 的左右子树都已经满足堆性质。也就是说,下沉操作不是随机拿来一个节点就能调,它要求你在一个“基本有序的堆结构”上修复某一个坏点。这个前提决定了建堆时为什么要自底向上,后面我会详细说。
2.2 上升(sift_up):新元素进堆时的修正操作
与下沉对应的是 up(上浮) 操作。它解决的问题是:在堆尾新增一个元素,这个元素可能比它父节点大(小顶堆则相反),破坏了堆性质,需要把它往上挪。
步骤也很简单:
- 拿到当前节点
i。 - 计算父节点
parent = (i - 1) / 2。 - 如果当前节点大于父节点,交换,然后继续对父节点位置执行上浮。
- 直到当前节点不大于父节点,或者到达根节点。
上浮操作在堆排序的主流程里其实用不太到,堆排序只用到下沉。但它是**优先队列入队(push)**的核心操作,也是很多堆相关题目(比如合并 K 个有序链表)的基础。我把两种都列出来,是希望大家建立完整的心智模型:下沉是修复父节点的坏,上浮是修复子节点的坏,一个从上往下修,一个从下往上修。
3. 建堆与排序:完整流程拆解
3.1 为什么从 n/2 - 1 开始:“叶子就不用管了”
堆排序的第一个关键步骤是建堆,也就是把任意乱序数组调整成一个大顶堆。
最朴素的想法是从上往下调:从数组第一个元素开始,依次执行下沉。但这有问题——下沉操作要求左右子树已经是堆,而刚起步时整个数组都是乱的,不满足前提,所以从上往下直接下沉是错的。
正确做法是从最后一个非叶子节点开始,自底向上逐个下沉。最后一个非叶子节点是哪个?答案是下标 n/2 - 1。
为什么?因为数组下标从 0 到 n-1,最后一个元素的下标是 n-1,它的父节点是 (n-1-1)/2 = n/2 - 1。这个节点是倒数第二层最靠右的那个非叶子节点。从它开始,一路向左、向上一层,逐个执行下沉,就能保证“当前节点的左右子树已经是堆”这个前提始终成立。
至于叶子节点为什么不用管?叶子节点没有孩子,天然满足“比孩子都大”这个性质,所以不需要动。
这里我建议大家把这个边界条件刻进肌肉记忆:建堆的循环是 for i in range(n // 2 - 1, -1, -1),不是 0 到 n-1,也不是 n//2 到 0。 我见过太多人栽在这一行上。
3.2 调整成堆:自底向上的下沉过程
现在把建堆过程走一遍。假设数组是 [3, 1, 6, 5, 2, 4],长度 6。
最后一个非叶子节点下标是 6/2 - 1 = 2,即元素 6。对比它的孩子 4(下标 5),6 本身已经比孩子大,不用动。
然后看下标 1,元素 1,左孩子下标 3 的元素 5,右孩子下标 4 的元素 2。最大值是 5,在左孩子位置,于是交换 1 和 5。交换后,下标 3 的节点是旧的值 1,它没有孩子,结束。
再看下标 0,元素 3,左孩子下标 1 现在是 3(原来的 5 换走了),右孩子下标 2 的元素 6。最大值是 6,在右孩子,交换 3 和 6。交换后,下标 2 的节点是旧的 3,它的左孩子下标 5 是 4,右孩子越界。3 比 4 小,继续交换。到下标 5 后无孩子,结束。
此时数组变成 [6, 3, 4, 1, 2],已经是一个大顶堆。
建堆过程的时间复杂度,教科书上会严谨证明是 O(n),而不是看起来的 O(n log n)。直觉理解是:堆中大部分节点靠近叶子层,它们每次下沉需要走的高度很短;而接近根部的节点数量少。把每一层的节点数乘以它们下沉的最大深度再求和,会收敛为一个和 n 同阶的量。具体数学推导涉及一个无穷等比级数,结论就是建堆 O(n)。
3.3 排序阶段:逐个摘掉堆顶
建堆完成以后,堆顶就是最大值。排序阶段的操作,本质上就是一个反复的操作序列:
- 把堆顶元素和当前堆的最后一个元素交换。这一步把最大值放到了数组末尾的正确位置。
- 堆的大小减一。此时最后一个位置已经被“锁死”,不再参与后续调整。
- 对新堆顶执行下沉,重新调整成大顶堆。
重复以上步骤 n-1 次,数组就排好序了。
需要注意,这个“交换-减一-下沉”的过程,等价于每次从堆中摘掉最大值,而且是在原数组上进行的,不需要额外分配大量内存,这就是堆排序“原地排序”的由来。很多新手会纠结:要不要复制一份数组?不用,直接拿原数组操作即可。
排序阶段每次下沉是 O(log n),一共执行 n-1 次,所以排序阶段总复杂度是 O(n log n)。整体堆排序的时间复杂度就是建堆 O(n) + 排序 O(n log n),最终为 O(n log n)。
4. 代码实现与执行过程对照
4.1 Python 实现:最接近思路的写法
先给一个 Python 版本。Python 写这类算法很直观,能让你把注意力放在逻辑而不是指针上。
python复制def sift_down(arr, n, i):
"""大顶堆下沉:确保以 i 为根节点的子树满足堆性质
arr: 数组
n: 当前堆的有效长度
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)
# 1. 建堆:从最后一个非叶子节点开始自底向上下沉
for i in range(n // 2 - 1, -1, -1):
sift_down(arr, n, i)
# 2. 排序:反复把堆顶交换到末尾
for i in range(n - 1, 0, -1):
arr[0], arr[i] = arr[i], arr[0]
# 注意这里堆的长度是 i,因为 i 及以后已经排好序
sift_down(arr, i, 0)
return arr
这段代码有个重要的细节:排序阶段调用 sift_down 时,传入的 n 其实是 i,也就是当前堆的有效长度。每排好一个元素,堆的边界就缩小一格,后面那些已经放好最大值的位置不会再被触碰。
如果你把 sift_down(arr, n, 0) 里误传成 n(而不是 i),调试时你会看到一种诡异现象:排到后半段时,原本已经归位的最大值又会被重新翻到堆顶,然后数组越排越乱。这个坑我踩过不止一次,排查时盯着边界条件看了好久才醒过味来。
4.2 C/C++ 实现:追求性能的写法
再看 C++ 版本。这类代码适合直接搬到工程里,或者用来理解底层内存操作。
cpp复制#include <vector>
#include <algorithm>
void siftDown(std::vector<int>& arr, int n, int i) {
while (true) {
int largest = i;
int left = 2 * i + 1;
int right = 2 * i + 2;
if (left < n && arr[left] > arr[largest]) {
largest = left;
}
if (right < n && arr[right] > arr[largest]) {
largest = right;
}
if (largest == i) {
break;
}
std::swap(arr[i], arr[largest]);
i = largest;
}
}
void heapSort(std::vector<int>& arr) {
int n = (int)arr.size();
for (int i = n / 2 - 1; i >= 0; --i) {
siftDown(arr, n, i);
}
for (int i = n - 1; i > 0; --i) {
std::swap(arr[0], arr[i]);
siftDown(arr, i, 0);
}
}
C++ 用 std::vector,逻辑和 Python 完全对应。区别在于工程上你还可以加一些优化:
- 模板化,支持任意可比较类型。
- 用迭代器或下标访问,适配不同容器。
- 对基本类型可以手动内联
siftDown,减少函数调用开销;编译器开-O2后,这种小函数通常也会自动内联。
我个人的经验是,堆排序这种实现,实际效率瓶颈不在函数调用,而在内存访问模式。它不像归并排序那样顺序访问,而是不断在数组的不同位置跳跃。后面我会专门讲这个。
5. 复杂度与稳定性:算法界的“数学账”
5.1 时间复杂度:建堆 O(n)、排序 O(n log n)
先给结论:
- 最坏时间复杂度:O(n log n)
- 最好时间复杂度:O(n log n)
- 平均时间复杂度:O(n log n)
- 空间复杂度:O(1)(原地排序)
很多人奇怪,最好情况和最坏情况为什么都是 O(n log n)?因为堆排序没有“最好情况”——无论输入数组是不是已经有序,建堆阶段要全部跑一遍,排序阶段也必须逐次摘掉每个元素,每次摘完都要下沉修复。堆对输入数据的“初始有序性”完全无感,这一点和插入排序(O(n) 最好)、快排(有序输入退化为 O(n²))有本质区别。
但注意,这个“无感”既是优点也是缺点:即使输入已经几乎有序,堆排序也一点便宜都占不到。这就是为什么工程上对近似有序的数据,有人会选择插入排序做预处理。
5.2 空间复杂度与稳定性:为什么它是原地但非稳定排序
空间复杂度 O(1) 是堆排序的一大卖点:它在原数组上交换元素,不需要额外分配和输入规模相当的存储空间。所以当内存紧张、又要保证 O(n log n) 时间性能时,堆排序是个不错的选择。
但它不稳定。所谓稳定性,是指相同值的元素在排序后是否保持原有的相对顺序。堆排序不稳定,是因为下沉操作中,元素会跨越多层跳跃交换。举个例子,两个相等的数字 5,一个在下标 0,一个在下标 3,排序过程中下标 3 的 5 很可能被换到根节点,最后落在另一个 5 的前面。这种交换完全随机,不保证相对次序。
我给大家一个实用的结论:
- 如果排序的对象是基本类型(整数、浮点),稳定性无所谓,堆排序放心用。
- 如果对象是结构体/对象,并且你希望按某个字段排序后,同字段的记录能保持原顺序,堆排序就不能用,老老实实选择归并排序或稳定的改进版快排。
6. 为什么选堆排序:优缺点与场景对照
6.1 优势和劣势
先说优点:
- 最坏情况有保证。不像快排,选到坏的枢轴会退化到 O(n²)。堆排序无论什么数据,时间复杂度都是 O(n log n),这一点在实时性要求高的场景里非常关键。
- 原地排序,空间 O(1)。比归并排序省内存,归并排序需要 O(n) 的辅助数组。
- 建堆过程本身有复用价值。你建好的堆,之后可以做优先队列、取 Top K,而不用推倒重来。
再说缺点:
- 常数因子比较大。同样的 O(n log n),堆排序的交换次数、比较次数通常比快速排序多出不少。我实测过 100 万随机整数,堆排序大约比快排慢 1.5 到 2 倍。
- 缓存不友好。堆的下沉操作是跳跃式访问数组下标的,比如从 0 跳到 2、4、8... 这些元素通常分布在不同的缓存行,每次访问可能都要从内存读。而快排的 partition 是顺序扫描,缓存命中率高得多。
- 不稳定。前面已经讲过,这个在某些场景是硬伤。
6.2 你该用哪种排序:对比表
我整理了一张快速决策表,方便大家在工程里做选择:
| 排序算法 | 平均时间 | 最坏时间 | 空间 | 稳定性 | 适用场景 |
|---|---|---|---|---|---|
| 快速排序 | O(n log n) | O(n²) | O(log n)~O(n) | 不稳定 | 常规大数据量,内存充足,追求速度 |
| 归并排序 | O(n log n) | O(n log n) | O(n) | 稳定 | 需要稳定排序,链表排序,外部排序 |
| 堆排序 | O(n log n) | O(n log n) | O(1) | 不稳定 | 内存受限,最坏时间要保证 |
| 插入排序 | O(n²) | O(n²) | O(1) | 稳定 | 小规模数据,近乎有序数据 |
可以看到,堆排序不是那种“万金油”的排序。它的精确适用场景,是在内存紧张且无法容忍最坏情况退化的情景下。比如嵌入式环境里数组很大、内存很小,快速排序可能因为递归栈深度而爆栈,此时堆排序的 O(1) 空间和固定时间上界就显出价值。
另外提醒一句:所有排序算法都不要自己造轮子,除非是学习目的。 C++ 的 std::sort 是内省排序(快排 + 堆排 + 插入排序的混合体),Python 的 sorted 是 TimSort,它们都已经针对真实场景优化过,工程上直接用即可。
7. 堆排序的兄弟应用:优先队列、Top K、定时器
7.1 优先队列:堆最天然的形态
堆排序虽然作为“排序算法”用得不算最多,但堆这个数据结构本身,在各大语言的标准库里都是 优先队列(priority queue) 的默认实现。比如 C++ 的 std::priority_queue、Java 的 PriorityQueue,Python 的 heapq,底层都是堆。
优先队列解决什么问题?就是你总需要“下一个最大(最小)”的去处理,但插入的时机是任意的。比如任务调度:手里一堆任务,每个有不同的优先级,随时有新任务插入,每次取走优先级最高的执行。用堆实现,插入和取最大值都是 O(log n),非常合适。如果换用有序数组,插入要 O(n);换用无序数组,取最大值要 O(n)。堆是这两者的均衡。
顺着这个话题,我给大家一个实用技巧:用 Python 的 heapq 处理大顶堆的时候,由于 heapq 默认是小顶堆,你只需要把元素取负数入堆,取出来的时候再取负数,就能得到一个“大顶堆”的效果。
7.2 Top K 问题:不要全排序
堆最经典的业务场景,是 Top K——从海量数据里取出最大(或最小)的 K 个元素。比如“10 亿个日志里找出访问量最高的 100 个 IP”。
最笨的办法是对所有数据全排序,复杂度 O(n log n),内存还可能不够。用堆的思路是:
- 维护一个小顶堆,容量固定为 K。
- 遍历数据流,每个元素和堆顶(当前 K 个候选中最小的)比。如果比堆顶大,就弹出堆顶、把新元素入堆;否则直接忽略。
- 遍历结束后,堆里的 K 个元素就是最大的 K 个。
这个算法的时间复杂度是 O(n log K)。当 K 远小于 n 时,效果非常明显。比如 K=100,n=10 亿,需要的内存只是装 100 个元素的堆,性能甩开全排序好几条街。LeetCode 第 215 题“数组中的第 K 个最大元素”就是这个思路的经典应用。
7.3 定时器/调度任务
还有一个高频场景是 定时器。很多网络框架的定时器,底层用一个最小堆。每个定时任务有一个触发时间,堆顶永远是最快需要触发的那一个。每来一个新任务,按触发时间入堆;每次循环只需要看堆顶,如果时间到了,就弹出并执行,如果没有到,就 sleep 到堆顶时间。这样无论系统里挂了多少个定时器,找到下一个要触发的任务的代价都是 O(1) 级别,插入新任务是 O(log n)。如果不用堆,遍历所有任务找最近触发时间的代价是 O(n),任务多了就扛不住。
8. 常见问题与调试实录
8.1 下标越界与边界条件混乱
我见过最多的问题,就是把左右孩子下标计算为 2 * i 和 2 * i + 1,同时又把根节点当下标 1。如果数组长度是 n,循环里又用 i < n 而不是 i < n 加偏移,就会导致访问到负数索引或越界元素。
排查方法很简单:debug 时打印 i, left, right 和 n 的实时值,看 left < n 与 right < n 的条件是否总能让 left/right 落在数组有效范围内。另外,如果从下标 0 开始,左右孩子一定是 2i+1 和 2i+2,父节点是 (i-1)/2,这三条务必一起记。
8.2 建堆 for 循环方向写反
有人会写成:
python复制for i in range(0, n // 2):
sift_down(arr, n, i)
这样不对。我刚才说了,下沉的前提是左右子树已经是堆。从 0 到 n/2 正向遍历,先处理的是根节点,而它的左右子树还是乱序的,不可能调出正确的大顶堆。
正确写法是:
python复制for i in range(n // 2 - 1, -1, -1):
sift_down(arr, n, i)
新手还容易把 n // 2 - 1 写成 n // 2 或 n // 2 - 2。n // 2 - 1 是最后一个非叶子节点的位置,这个结论可以用 (n-1-1)//2 推导出来,记不住就现场推,别硬背。
8.3 用递归陷入栈溢出
有些教材把 sift_down 写成递归。这个写法在数组很小时没什么问题,但当你对一个 10 万、100 万级别的数组排序时,极端情况下递归深度可能接近树高,也就是 O(log n),一般不太会溢出。但如果你的递归实现里有 bug,导致节点没有真正向下走,递归次数就会异常,可能爆栈。
为了稳妥,我在工程里建议一律用迭代实现下沉。不仅省函数调用开销,也规避了深递归的隐患。上面的 Python 和 C++ 实现都是迭代版本,可以直接参考。
注意:如果你在写一个通用的优先队列库,
sift_down是核心热路径,迭代实现能明显降低时延。尤其在高频 push/pop 的场景下,递归调用的栈帧开销不容小觑。
8.4 排序结果只差一两位的“半对半错”
还有一种诡异情况:代码大部分数据都排对了,但有些相邻元素错位。这种往往不是下沉逻辑错了,而是排序阶段堆的边界长度传错了。比如把 sift_down(arr, i, 0) 写成了 sift_down(arr, n, 0),已经在末尾排好序的元素又被当成堆的一部分参与调整,于是它们可能被重新交换回前面,导致结果不稳定地错乱。
这种 bug 很难肉眼发现,因为大部分情况下结果看着“接近有序”。我的建议是:排完序后,用一个循环验证 arr[i] <= arr[i+1] 对所有 i 成立,一旦发现问题,优先检查排序阶段传入堆长的地方。
8.5 拿堆排序和快排做性能对比时的“意外”
最后分享一个实测心得。以前我做排序性能压测,生成 100 万个随机整数,分别跑快排和堆排序。结果堆排序总是慢快排不少。一开始我以为是堆排序常数因子大,后来用 profiler 一看,发现很大一部分时间花在缓存缺失上,因为堆的下沉操作频繁跳到相隔很远的数组下标上。快排的 partition 则是顺序扫描,缓存命中率极高。
所以,如果你追求极致排序性能,且内存不紧张,优先考虑快排系列。堆排序的价值在于它稳定的复杂度上界和 O(1) 额外空间,把它们用对地方才是正道。
我个人实际用堆最多的地方,不是排序,而是 Top K 和定时器。比如在处理千万级日志时,找出 Top 10 的 IP,用固定容量的小顶堆扫一遍就完事,内存占用恒定,速度非常快。如果你正要刷相关题目或做相关设计,我建议你把“建堆”和“下沉”这两个操作练到闭着眼睛都能写出来,因为它们是所有堆应用的地基。能把这两个操作打扎实,之后看优先队列、堆排序、Top K、定时器,都会觉得不过是一套东西换了层皮。
