1. 先搞懂堆和树形排序的关系
很多人一听到“堆排序”三个字,第一反应是“堆是什么?跟内存里的堆是一个东西吗?”先把这个事说清楚。这里的堆,是一种树形数据结构,全称是二叉堆(Binary Heap),它本质上是棵完全二叉树。而堆排序,就是利用这种树形结构做排序的算法,所以它也经常被归到“树形排序”这一类里。
为什么堆排序能排得快?核心在于它把“找最大值”这件事的代价降下来了。普通做法在 n 个元素里找最大值要扫一遍,O(n);堆的做法是利用树的高度优势,把找最大值的代价压到 O(logn)。别小看这个 logn,当数据量到十万、百万级别时,树形结构的优势就是数量级的碾压。
网上很多资料喜欢一上来就贴代码,结果初学者看着 while 循环半天反应不过来。我的建议是,先把堆这个数据结构啃明白,再看代码就是顺水推舟的事。这篇博文我会从零开始,把堆排序的每一步原理、代码、坑全部铺开,尽量让零基础的读者也能完全吃透。
先给还没入门的读者一点基础背景:二叉堆是一种特殊的完全二叉树,它有两个约束。第一,结构上必须是一棵完全二叉树,也就是除了最后一层,其他层都是满的,而且最后一层的节点都靠左排列。第二,堆序性:如果是大顶堆,任何一个父节点的值都不小于它的子节点;小顶堆则反过来,父节点不大于任何子节点。这两个约束共同决定了一个结论——大顶堆的根节点,必然是全局最大值。堆排序的核心循环就是在反复利用这个结论。
说句题外话,面试时如果要手写排序算法,堆排序几乎和快排一样高频。原因不在于它写起来多优雅,而在于它考察的是候选人到底懂不懂“完全二叉树在数组里的映射关系”,这是很多基础不牢的人直接卡壳的地方。
1.1 二叉堆的关键性质:数组与树的映射关系
堆虽然是树形结构,但在实际代码里,它极少用节点指针实现,而是用数组存。这也是堆排序能“原地”工作的根本原因。
具体映射规则是这样的:数组下标从 0 开始,那么对于下标 i 的元素:
- 它的左子节点下标是 2 * i + 1
- 它的右子节点下标是 2 * i + 2
- 它的父节点下标是 (i - 1) / 2,注意这里是整数除法
我当年第一次接触这个映射时,总觉得这仨公式是凭空蹦出来的。后来画了一棵树才明白,完全二叉树本身是“紧凑”的,每一层都从左到右填满,所以自然能用连续数组表示。只要每次“扩树”时都从数组尾部追加节点,这个映射就不会错乱。
举个例子,数组 [9, 5, 8, 2, 3],画成树就是这样:
- 下标 0 的 9 是根
- 9 的左孩子是下标 1 的 5,右孩子是下标 2 的 8
- 5 的左孩子是下标 3 的 2,右孩子是下标 4 的 3
把这个图画出来,你会发现 5 比它的孩子 2、3 都大,8 没有孩子,根 9 比所有孩子都大,这就是一个大顶堆。堆排序的第一步,就是先把这个数组调整成大顶堆。这个过程在教材里叫“建堆”。
理解了这个映射,后面所有代码里的下标计算就都能看懂了。很多新手写堆排序老出错,十有八九是下标算错了——比如右孩子下标越界,或者父节点下标算成了 i/2(下标从 0 开始时应该是 (i-1)/2)。
1.2 大顶堆与小顶堆怎么选
堆排序默认升序排序时,用的是大顶堆。很多人会在这里绕晕:我要排成从小到大,为什么反而用大顶堆?
因为堆排序的流程是“每次把堆顶的最大值拿走,把剩下的部分重新调整成堆”。你把最大值放到数组末尾,最后得到的结果就是从前往后递增的。所以逻辑上不是“用小顶堆来排序”,而是“在大顶堆上反复取最大值”来排序。
反过来,如果你需要降序排列,或者要实现一个老是弹出最小值的优先队列,那就用小顶堆。不要死记“升序用大顶堆”这个结论,想清楚“顶部是什么,就拿走什么”这个逻辑,任何场景都能推导。
这也能解释为什么堆排序不是稳定排序。同样的值,在调整过程中可能会被交换到后面去;再加上堆顶的大值会被直接丢到数组末尾,相同元素的相对顺序基本没法保证。如果你要排序的数据里有“带优先级属性”的对象,且要求同优先级保持原有顺序,那堆排序不合适,可以用归并排序。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 堆排序的核心操作:下沉、上浮与建堆
堆排序里有两个基础操作,理解它们之后,整个算法就等于拼乐高。分别是“下沉”和“上浮”。
下沉(sift down / heapify):如果某个父节点的值比孩子小(大顶堆场景),把它和孩子里较大的那个交换,然后继续往下比较,直到它落在一个合适的位置。
上浮(sift up):如果某个节点比它的父节点还大,就和父节点交换,然后继续往上比较。这个操作主要用于向堆里插入新元素。
堆排序的主流程其实只用到了下沉,因为建堆和排序阶段的调整都是“从上往下走”。上浮更多出现在优先队列的插入操作里,但理解它能够帮你更全面地吃透堆这颗树。
2.1 为什么建堆要“从最后一个非叶子节点开始”
建堆的常规做法,是从数组最后一个非叶子节点开始,从右往左、从下往上,依次对每个节点做下沉操作。这个“从下往上”非常关键,很多人没想明白。
一个朴素的思路是:我从根节点开始做下沉,一路做下去,不就行了吗?不行。因为下沉操作是建立在“左右子树都已经各自是堆”的前提上的。如果下面还是乱的,你把根节点沉下去,只能保证根节点到达局部合适的位置,但整棵树依然不满足堆序性。
所以必须先把子树整理好,再处理父节点。从最后一个非叶子节点开始往前遍历,天然保证了每次处理某个节点时,它的左右子树都已经完成堆化。
最后一个非叶子节点的下标怎么找?已知数组长度是 n,最后一个元素的下标是 n-1,那么它的父节点 (n-1-1)/2,也就是 (n-2)/2,就是最后一个非叶子节点。这是被讨论最多、也最容易出错的一个公式,一定要自己动手推一遍。
举个例子,数组长度是 10,最后一个元素下标是 9,它的父节点是 (9-1)/2=4,所以从下标 4 开始向前遍历到 0。
2.2 排序阶段的循环:拿走堆顶,再调整
建堆结束后,数组已经是一个大顶堆。接下来进入排序阶段,循环做两件事:
- 把堆顶元素(数组 0 号位置)和当前堆的最后一个元素交换。此时最大值已经到了数组末尾的正确位置。
- 把堆的规模减一(排除末尾已排好的元素),然后对新的 0 号位置做一次下沉,重新调整成堆。
重复 n-1 次之后,数组就完全有序了。这里要注意的是,每次交换后堆的“逻辑长度”要减一,但数组的物理长度不变。我在代码里用一个 heapSize 变量来跟踪这个长度,比直接改数组长度要清晰得多。
这个循环为什么只做 n-1 次?因为当只剩下一个元素时,它必然处于正确位置,不需要再交换。
你可能会有疑问:每次把堆顶拿到末尾,那下一次调整堆时,末尾那个已经就位的大值会不会跑回堆里?不会,因为我们用 heapSize 做了隔离,调整操作只在 heapSize 范围内进行。
3. 完整代码实现:从零手写一个堆排序
接下来直接上代码。我用 Java 写一个从零实现的版本,注释尽量详细。其他语言(C/C++、Python、Go)思想完全一样,只是语法不同。
java复制public class HeapSort {
/**
* 堆排序入口
*/
public static void heapSort(int[] arr) {
if (arr == null || arr.length < 2) {
return;
}
int n = arr.length;
// 1. 建堆:从最后一个非叶子节点开始,从下往上下沉
for (int i = (n - 2) / 2; i >= 0; i--) {
siftDown(arr, i, n);
}
// 2. 排序:交换堆顶与当前末尾,缩小堆范围,重新调整
for (int i = n - 1; i > 0; i--) {
swap(arr, 0, i);
siftDown(arr, 0, i); // 当前堆的大小是 i
}
}
/**
* 下沉操作:大顶堆
* @param arr 数组
* @param index 要下沉的节点下标
* @param heapSize 当前堆的逻辑大小(不是数组长度)
*/
private static void siftDown(int[] arr, int index, int heapSize) {
int left = 2 * index + 1;
while (left < heapSize) {
// 找到左右孩子中较大的那个
int largest = left;
int right = left + 1;
if (right < heapSize && arr[right] > arr[left]) {
largest = right;
}
// 如果父节点比最大的孩子还大,说明已经符合堆序,退出
if (arr[index] >= arr[largest]) {
break;
}
// 否则交换父子,继续向下
swap(arr, index, largest);
index = largest;
left = 2 * index + 1;
}
}
private static void swap(int[] arr, int i, int j) {
int tmp = arr[i];
arr[i] = arr[j];
arr[j] = tmp;
}
public static void main(String[] args) {
int[] arr = {4, 10, 3, 5, 1, 8, 7, 6, 9, 2};
heapSort(arr);
for (int num : arr) {
System.out.print(num + " ");
}
}
}
我先把这段代码跑一遍,输出结果是 1 2 3 4 5 6 7 8 9 10,没有问题。下面我逐段拆解需要注意的点。
3.1 为什么下沉循环里的是 left,不是 index
这个细节经常被忽略。下沉操作里,while 条件的判断对象是左孩子的下标 left < heapSize。为什么不是判断当前节点?
因为当左孩子越界时,说明当前节点已经没有孩子了,是一个叶子节点,没有下沉的必要。而只要左孩子存在,就可能还有右孩子,需要进一步比较。
很多初学者把循环条件写成 while (index * 2 + 1 < heapSize),这当然也可以,但我建议提前把 left 算出来,循环体内复用。一方面避免重复计算,另一方面逻辑更直观。
3.2 建堆公式 (n-2)/2 为什么成立
我再把公式推导一遍,因为这是全文最容易记混的点。
假设数组长度是 n:
- 最后一个元素下标是 n-1
- 它的父节点下标是 ((n-1) - 1) / 2,也就是 (n-2) / 2
这是最后一个非叶子节点。如果数组长度 n=1,代入得到 (-1)/2,在 Java 里整数除法结果是 0,但循环条件是 i >= 0,所以会执行一次对下标 0 的下沉。此时 heapSize=1,left=1,不满足 left < 1,立即退出,没有任何副作用。
所以这个公式在边界情况下也安全。与其背公式,不如临场手推一遍:先找最后一个节点,再找它的父节点。
3.3 排序循环中 siftDown 的 heapSize 为什么要传 i
排序循环:
java复制for (int i = n - 1; i > 0; i--) {
swap(arr, 0, i);
siftDown(arr, 0, i);
}
第一次循环时 i = n-1,交换 0 和 n-1 之后,最大值到数组末尾。然后我们对范围 [0, n-1) 内的元素进行下沉,也就是 heapSize = i = n-1。这个写法非常优雅:i 既表示当前要交换的末尾下标,也表示调整后堆的大小。
如果你写的是 siftDown(arr, 0, n),那刚放到末尾的最大值就会被重新参与堆化,堆顶永远不能稳定地拿到最大值,排序必然出错。这一步是初学者在代码里翻车最频繁的地方,没有之一。
3.4 复杂度分析:为什么堆排序是 O(nlogn)
堆排序的复杂度分两部分看。
建堆的复杂度,很多文章直接给 O(n),理由是每个节点的下沉开销与其高度成正比,而绝大多数节点都在树的下层、高度很小,总和算下来是线性级别。更严谨的求和方式是利用“高度为 h 的节点最多有 n/2^(h+1) 个”来做级数求和,最终收敛到 O(n)。这里不展开数学推导,只需要记住一个结论:直觉上看起来每个节点都要下沉一次,似乎是 O(nlogn),实际是 O(n)。
排序阶段的复杂度,每次交换后都要对堆顶做一次下沉,下沉的深度是树高 O(logn),一共 n-1 次,所以是 O(nlogn)。
总复杂度取大者,O(nlogn),这也是堆排序在理论上的优势:无论最坏、最好还是平均情况,它都稳定在 O(nlogn)。相比之下,快速排序虽然平均也是 O(nlogn),但最坏会退化到 O(n²)。所以某些对“最坏情况延迟”敏感的场景,堆排序反而更安稳。
空间复杂度是 O(1),这是堆排序最值钱的地方之一。它不需要额外的存储空间,所有操作都在原数组上完成,这在嵌入式设备、内存受限环境中是硬性需求。
稳定性:不稳定。前面已经说过,相等元素的相对顺序无法保证。这是堆排序的固有缺陷,在设计需要“两次排序”的组合场景时要格外留意。
4. 堆排序的高频变体:TopK、优先队列与外部排序
堆排序本身是一个算法,但它最广泛的价值其实是“堆”这个数据结构。下面几个场景是我在工程实践中遇到最多的,顺便说一下怎么用。
4.1 求 TopK 大,用小顶堆而不是大顶堆
这是一个经典的“反直觉”问题。假设有 100 万个数据,我要找最大的 10 个,很多人第一反应是“用大顶堆”,因为大在那里嘛。但正确做法是维护一个大小为 K 的小顶堆。
具体做法:先把前 K 个元素放进小顶堆,然后从第 K+1 个元素开始遍历,每次跟堆顶(当前堆里最小的元素)比较。如果新元素比堆顶大,就替换堆顶并做一次下沉,把最小的那个挤出去。遍历结束后,堆里的 K 个元素就是最大的 K 个。
为什么用小顶堆?因为“把最小的踢出去”这个动作,天然落在堆顶。小顶堆的堆顶就是最小值,比较一次 O(1),替换时下沉一次 O(logk)。整体复杂度 O(nlogk),而如果全排序,复杂度 O(nlogn)。当 n 是千万级、k 是 10 时,这个差距是数量级的。
我之前在日志分析场景里统计最高的 10 个访问 IP,就是用这个方案,秒出结果,代码量也不大。
java复制public static int[] topK(int[] data, int k) {
if (data == null || data.length == 0 || k <= 0 || k > data.length) {
throw new IllegalArgumentException("Invalid input");
}
// 使用 Java 的优先队列模拟小顶堆
PriorityQueue<Integer> minHeap = new PriorityQueue<>(k);
for (int num : data) {
if (minHeap.size() < k) {
minHeap.offer(num);
} else if (num > minHeap.peek()) {
minHeap.poll();
minHeap.offer(num);
}
}
int[] result = new int[k];
for (int i = 0; i < k; i++) {
result[i] = minHeap.poll();
}
return result;
}
4.2 优先队列与定时任务:堆的日常应用
优先队列(PriorityQueue)在 Java、Python、C++ 里都有现成实现,底层基本都是堆。它最典型的应用场景是任务调度:一堆任务各自有紧急程度,系统每次只处理最紧急的那一个。
比如一个简单的定时任务系统,任务按照触发时间排序,每次取最近即将触发的任务。如果用数组存储,每次插入删除都要移动元素,效率堪忧;用堆存储,插入和取最值的复杂度都是 O(logn),完全可控。
以 Java 的 PriorityQueue 为例,它默认是小顶堆,因此 peek() 拿到的是最小的元素。如果你想要大顶堆就得传入自定义比较器:
java复制PriorityQueue<Integer> maxHeap = new PriorityQueue<>((a, b) -> b - a);
工程上有一个小坑:PriorityQueue 的迭代器遍历顺序不是堆的排序顺序,如果你直接打印整个队列,看到的不是有序数组。因为堆只保证父与子的关系,不保证兄弟之间的关系。需要按序输出时,应该用 poll() 逐个取出。
4.3 堆排序 vs 快速排序 vs 归并排序:工程上怎么选
面试时经常被问“既然堆排序时间复杂度稳定,为什么大多数语言的内置排序默认用快速排序?”我的理解是:
快速排序的常数因子更小,在绝大多数随机数据上实际运行速度比堆排序快。堆排序虽然时间复杂度稳定,但它的底层访问模式是“跳跃式”的(经常在下标 0 和某个深层下标之间交换),对 CPU 缓存不友好。而快排的扫描是紧凑的连续内存访问,缓存命中率更高。
我们用一组随机整数做过简单测试,数据量在千万级别时,JDK 内置的 Arrays.sort()(对基本类型是双轴快排,对对象是 TimSort)比手写堆排序快一到两倍。所以工程中我很少用堆排序做全量排序。
但堆排序在特定场景依然不可替代:
- 需要在排序过程中不断“取出当前最大/最小值”时,优先队列是唯一简单可靠的方案
- 内存受限,不能用额外数组时,堆排序的原地排序能力很关键
- 外部排序中,多路归并阶段的“败者树”本质就是一种堆变体
所以我的建议是:全量排序找内置排序,流式取极值找堆。
5. 常见问题与排查技巧实录
这一节整理我在写堆排序、带新人时反复遇到的高频问题。这些问题都有明显的“错误特征”,掌握后可以快速定位自己的代码哪里出了问题。
5.1 问题一:结果大体有序,但个别位置不对
现象:排序后的数组大部分正确,但总有几个数位置不对,而且错误位置集中在数组后半段。
原因:最常见的根因是排序阶段的 heapSize 没有递减。或者是 siftDown 里使用了数组长度而不是堆的当前规模。还有一个可能是下标从 0 和从 1 的公式混用:建堆时用了 (n-2)/2,但下沉时用了 (i - 1) / 2 那一套,两者如果混在一起,左右孩子下标会错位。
排查方法:加日志打印每次交换后的数组,观察第 i 位是否为当前堆的最大值。如果第 i 位不是最大值,说明堆没有调整到位,重点检查 siftDown 的循环边界和 largest 的选择逻辑。
5.2 问题二:数组越界异常
现象:运行时报 ArrayIndexOutOfBoundsException。
原因:几乎都是右孩子下标没有判断越界。在 siftDown 中,左孩子存在不代表右孩子存在,所以访问 arr[left+1] 前必须先判断 left+1 < heapSize。
我自己的习惯是在下沉函数开头先把左右孩子下标都算出来,然后统一判断:
java复制int left = index * 2 + 1;
int right = left + 1;
然后先判断 left 越界并直接返回,再判断 right 是否越界来避免访问非法下标。这样逻辑更集中,不容易漏判。
5.3 问题三:相同元素顺序被改变
现象:排序后的数组中,相等元素的相对顺序与排序前不一致。
原因:这不算 bug,而是堆排序不稳定的固有表现。如果你确实需要稳定排序,建议改用归并排序。或者换一种思路:把元素包一层原始下标,排序时比较值相等的情况下比较下标,人为制造“稳定”。
java复制class Node implements Comparable<Node> {
int value;
int index;
// 先按 value 比较,相等时按 index 比较
}
这是一种通用技巧,我在实际项目中用了很多次,比硬生生把不稳定排序改写为稳定排序要简单得多。
5.4 问题四:建堆结果看起来不对
现象:建堆结束后,打印数组,发现根节点不是最大值,或者某个父节点仍然小于孩子。
原因:大概率是你从下标 0 开始往下沉,而不是从最后一个非叶子节点开始往前。前面已经解释过,从根开始做下沉的前提是“子树已经是堆”,但初始状态子树并不是堆,所以根节点的调整没有意义。
还有一种情况是 siftDown 里没有处理“父节点已经大于等于孩子”就退出的逻辑。如果不加停止条件,代码会一直把节点往下交换,把一个本已满足堆序的子树又打乱。
6. 堆排序实操心得:调试、测试与性能验证
很多读者看完上面的代码会自己敲一遍,然后在本地运行,看到输出正确就算完事了。但实际工程里,我还建议做几件事,把“能跑”提升到“靠谱”。
6.1 用随机数组做大规模自测
我每次写完排序算法,都会写一个简单的测试驱动:生成不同规模的随机数组(从个位数到十万级),跟 JDK 内置排序的结果做比对,全部相等才算通过。
java复制public static void test() {
Random rand = new Random();
for (int n = 1; n <= 10000; n += 3) {
int[] arr1 = new int[n];
int[] arr2 = new int[n];
for (int i = 0; i < n; i++) {
int v = rand.nextInt(1000); // 允许重复
arr1[i] = v;
arr2[i] = v;
}
heapSort(arr1);
Arrays.sort(arr2);
if (!Arrays.equals(arr1, arr2)) {
System.out.println("Mismatch at n = " + n);
return;
}
}
System.out.println("All passed");
}
这个测试能覆盖大量边界情况:数组长度为 1、长度为 2、所有元素相等、逆序、升序等。比手工构造几个用例要全面得多。
6.2 用大数组验证性能
在数据量较小时,堆排序和快速排序的差距并不明显,甚至因为测试环境的调度波动,你测出来的结论可能不可复现。我一般按 100 万、1000 万级别来测。
有次我在一个 500 万的 int 数组上对比堆排序和 JDK 的双轴快排,堆排序耗时约 820ms,快排约 420ms。差距确实存在,但也不是不能接受。如果你在某个实时性要求不高的后台任务里用堆排序,完全没有问题。
6.3 一个隐藏很深的坑:Arrays.sort 对对象的稳定性不同
Java 的 Arrays.sort 对基本类型数组使用双轴快排,对对象数组使用 TimSort(稳定排序)。很多人以为“Java 内置排序是稳定的”,这其实只对对象数组成立。如果面试或项目里需要稳定排序并对基本类型操作,不要依赖内置方法,直接考虑归并排序。
这个细节和堆排序本身无关,但和“什么时候需要自己写堆排序”强相关。如果你只是要一个稳定的排序结果,不妨让对象数组走内置的 TimSort。
7. 写在最后:堆排序的思维模型
以我个人做了多年工程和面试的经验,真正吃透堆排序的核心,不是背下那几行代码,而是建立三个思维模型。
第一个是“树在数组里的映射模型”。只要看到一棵完全二叉树,就能瞬间反应过来它可以用数组存储,下标关系是 2i+1、2i+2、(i-1)/2。这个映射不仅是堆排序的基石,也是线段树、树状数组、二叉堆所有数据结构的基础。
第二个是“调整模型”。所有堆操作本质都是破坏堆序性,然后通过下沉或上浮重新满足堆序。写代码时最重要的不是记忆堆化的具体逻辑,而是理解“为什么从最后一个非叶子节点开始倒序遍历”以及“为什么每次下沉可以保证子树重新成堆”。
第三个是“取舍模型”。堆排序不是万能解。全量排序时它未必比内置排序快,但取极值、TopK、优先队列这些场景,它是数据结构层面最优的选择。理解算法的适用边界,比理解算法本身更重要。
如果这篇博文对你有帮助,建议你自己动手把代码敲一遍,再用随机数据测试,最后试着自己实现一个优先队列。只要把这几个动作做完,堆排序对你而言就不再是只能背的八股文,而是一个在手边随时能用的工具。
