堆排序这名字,搞过算法面试的都绕不过去。我最初是在一次开发任务里需要实现一个“始终返回当前数据流中最大/最小的前K个元素”的场景,用数组硬排发现数据量一上来就扛不住,这才认真把堆排序翻出来啃了一遍。后来在多个项目里反复用到,越来越觉得这算法值得写一篇详实的笔记,既讲清楚原理,也把实际编码里那些文档不会明说的细节都摊开。
先说结论:堆排序是带着完全二叉树思想在数组上原地排序的算法,核心是建堆和不断取出堆顶元素这两个过程。它最吸引人的点是时间复杂度稳定在 O(n log n),而且不需要额外的大块内存,属于原地排序里的硬通货。适合正在学数据结构和算法的学生、准备大厂面试的开发者,以及项目中需要稳定排序性能又不想引入复杂第三方库的工程师。这篇文章我会从“为什么需要堆排序”讲起,手把手拆解原理,把代码细节、复杂度分析的各个坑都填上,最后贴出完整的可运行实现和问题排查经验。
1. 项目起源与核心思路,为什么偏偏是堆排序
1.1 一次需求引发的思考:不是“想用堆排序”,而是“只能用堆排序”
我之前遇到的需求背景大概是这样的:有个服务会持续接收大量带有分数的事件,我需要时刻拿出一组事件中分数最高的前10个。如果每次都用sort()全量排一遍,数据量小没问题,但每秒几千条事件进来,排序的开销迅速失控。后来发现堆排序的思路天然适合这个场景——维护一个大小为K的小顶堆,新数据进来时只需要在堆顶做一次替换和调整,就能在 O(log K) 时间内维护“前K大”的数据,不用对整个数据集重排。
这促使我去认真理解堆排序的底层逻辑。理解之后你会有种“原来如此”的爽感:堆并不是什么高深的数据结构,它就是用数组表示的“近似的完全二叉树”。排序的过程也极其朴素:先让数组满足“堆的性质”(父节点大于等于左右孩子,如果排升序就用大顶堆),然后不断把堆顶(也就是最大值)挪到数组末尾,再调整剩下的部分继续保持堆性质。整个过程不需要单独的暂存空间,所有交换都在原数组完成,空间复杂度是 O(1)。
1.2 堆排序能解决什么问题,以及什么时候应该躲开它
堆排序解决的问题本质上是“在无需额外空间的条件下,稳定地达到 O(n log n) 时间复杂度”的排序需求。它在很多场景中都有用武之地:
- TopK 问题:在海量数据中找最大的K个或最小的K个,用大小为K的堆,比全量排序高效得多。
- 优先队列 / 任务调度:系统里随时需要取出优先级最高的任务,堆结构是首选底层实现。
- 流数据的动态极值:数据不断进来,需要实时取极值,堆能支持插入和取极值的动态平衡。
但堆排序也不是万能的。如果数据规模不大、数组基本有序,插入排序和快速排序的实际表现往往会更好;如果需要稳定排序(相等的元素保持原有先后顺序),堆排序依然要小心,因为它本质上是不稳定的。这一点后面会专门展开。
2. 堆排序核心原理拆解:从“完全二叉树”到“数组换位”
2.1 完全二叉树与数组下标的隐藏关系
要真正记住堆排序的代码,而不是死记硬背,关键要理解“用数组存完全二叉树”这个映射关系。完全二叉树的意思是从树根到倒数第二层都是满的,最后一层的节点都靠左排列。这种紧凑的结构非常适合用数组连续存储,因为每个节点和它的孩子之间能用下标直接定位。
假设一个节点在数组中的下标是i(从0开始计数),那么:
- 它的左孩子下标是
2 * i + 1; - 它的右孩子下标是
2 * i + 2; - 它的父节点下标是
(i - 1) / 2(整数除法)。
比如数组[4, 10, 3, 5, 1],下标0的4是树根,左孩子是下标1的10,右孩子是下标2的3。下标1的10的孩子是下标3的5和下标4的1。这就是堆排序代码里所有下标计算的根源。理解了这一点,后面每一步操作都变得直观可推导。
2.2 大顶堆和小顶堆:升序排序为什么用大顶堆
堆有个强约束:每个父节点都必须大于等于(或小于等于)它的孩子。大于等于的情况叫大顶堆,堆顶是最大值;小于等于的情况叫小顶堆,堆顶是最小值。
很多人第一次写堆排序时有个误区:排升序为什么不直接用小顶堆,从堆顶一个个拿最小值?问题在于,如果从小顶堆堆顶取最小值,取完之后,堆顶的位置空出来了,要么需要额外数组暂存取出的元素,要么得把剩余元素往前移,这样要么空间复杂度上升,要么操作复杂度变高。
所以标准做法很巧妙:用一个“大顶堆”辅助排升序。每次把堆顶的最大值和当前未排序部分的最后一个元素交换,这样最大值就“沉”到了数组末尾。下一次再调整堆时,这个位置就不再参与,堆的长度逐步缩小,最大的元素像气泡一样一个个沉底,最终数组就整体升序了。
2.3 核心操作:下沉(siftDown)是怎么“修复”堆的
堆排序里最重要的操作是下沉,也叫siftDown或heapify。它处理的情况是:某个节点不满足堆性质(比如比它的孩子小),需要把它向下和较大的孩子交换,直到重新满足所有父大于子的关系。
下沉的逻辑可以概括为三步:
- 设当前需要调整的节点下标为
root,堆规模为n。 - 找出
root、左孩子left = 2*root+1、右孩子right = 2*root+2三者中值最大的那个(前提是孩子下标在堆范围内)。 - 如果最大值不是
root本身,就把root和最大值节点交换,然后继续对交换后下沉到的新下标重复上述过程;如果已经是最大值,就停止。
这个“比较三个节点、交换后继续下沉”的过程,很像打地鼠——每次把一个不太够格的父节点往下压,直到它找到合适的位置。由于每次下沉最多从树根走到叶子,而完全二叉树的深度是O(log n),因此单次下沉的时间复杂度是O(log n)。
2.4 建堆与排序全过程:从下往上修,从上往下换
理解了“下沉”之后,整个堆排序就只剩下两个阶段。
第一阶段:建堆(Build Heap)。从一个无序数组出发,怎么把它调整成一个大顶堆?直觉可能是从根节点开始一个个做下沉,但实际工程实践是从最后一个非叶子节点开始,倒着往前逐个下沉。为什么?因为叶子节点没有孩子,天然满足堆性质,不需要处理。最后一个非叶子节点的下标是n/2 - 1(整数除法)。从这个下标往前遍历到0,逐个调用siftDown,就能在O(n)时间内让整个数组变成合法大顶堆。这里的时间复杂度分析后面会单独说明,先注意这个地点:很多新手在这里写错起始索引,导致建堆不完整。
第二阶段:排序(Sort)。当数组满足大顶堆性质后,堆顶(下标0)一定是整个数组的最大值。把下标0和当前堆的最后一个元素(下标n-1)交换,然后将堆的规模减1,再对新的堆顶做一次siftDown修复堆。反复执行“交换—规模减一—下沉”这个过程,直到堆规模只剩1,排序就完成了。每轮交换会把当前最大值放到正确位置,所以经过n-1轮,数组升序排列完毕。
为了方便理解,举个小例子: 数组[4, 10, 3, 5, 1],n = 5,最后一个非叶子节点是下标5/2 - 1 = 1,即元素10。10没有孩子,不需要动。接着看下标0,元素4,它的左孩子是10,右孩子是3,最大值是10,交换后数组变成[10, 4, 3, 5, 1],接着对下标1的4继续下沉,左孩子5比4大,交换,得到[10, 5, 3, 4, 1],大顶堆建成。然后开始排序:交换堆顶10和末尾1,得到[1, 5, 3, 4, 10],对堆顶1下沉,变为[5, 4, 3, 1, 10];再交换5和1,下沉,……最终得到[1, 3, 4, 5, 10]。走一遍这个过程,就再也不怕手撕堆排序了。
3. 堆排序代码实现与实操要点
3.1 完整可运行的堆排序实现(Java版)
理解了原理,代码反而是水到渠成的事。下面是我在实际项目中常用的实现,保留了清晰的注释。
public class HeapSort { public static void heapSort(int[] arr) { if (arr == null || arr.length <= 1) { return; } int n = arr.length; // 阶段一:建堆(从最后一个非叶子节点开始,自底向上下沉) for (int i = n / 2 - 1; i >= 0; i--) { siftDown(arr, i, n); } // 阶段二:排序(堆顶与末尾交换,再下沉修复堆) for (int i = n - 1; i > 0; i--) { swap(arr, 0, i); siftDown(arr, 0, i); } } // 在[0, size)范围内,对下标root节点做下沉操作 private static void siftDown(int[] arr, int root, int size) { int largest = root; int left = 2 * root + 1; int right = 2 * root + 2; if (left < size && arr[left] > arr[largest]) { largest = left; } if (right < size && arr[right] > arr[largest]) { largest = right; } if (largest != root) { swap(arr, root, largest); siftDown(arr, largest, size); } } 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, 9, 7, 2, 8, 6}; heapSort(arr); for (int num : arr) { System.out.print(num + " "); } } }运行结果自然是1 2 3 4 5 6 7 8 9 10。这段代码里重点看两个循环:建堆循环的起点n/2 - 1,排序循环里siftDown(arr, 0, i)的第二个参数传的是新的堆大小i,而不是上一轮已经“沉底”的元素数量。这两处边界写对了,代码基本就对了。
3.2 为什么建堆要自底向上,不能从根开始?
这个问题我特别想说。不少初学者在写建堆时,习惯从下标0开始往后逐个下沉,代码看起来也“有点对”,但结果往往是堆没完全建立。原因在于:下沉操作只能保证某个节点往下调整,但如果它的子树都还没满足堆性质,单点下沉解决不了根本问题。
类比来说,你不可能站在楼顶往下指挥每个楼层的人整理房间,必须从最底层开始一层层往上检查。自底向上的过程确保在准备处理某个节点时,它的左右子树都已经合法,此时只需把这个节点“往下交换”到合适位置,整个以该节点为根的子树就能快速变成合法堆。这种策略也是建堆时间复杂度能达到O(n)的关键。
3.3 递归与非递归实现怎么选
上面给的是递归版本,逻辑清晰易读,非常适合讲解和面试手写。但在实际大规模数据下,递归调用本身有栈深度和函数调用开销。完全二叉树的深度是O(log n),对于普通数据规模栈溢出风险不高,不过在极端情况或性能敏感场景,我更推荐改成迭代版本,用一个while循环代替递归。
private static void siftDownIterative(int[] arr, int root, int size) { while (true) { int largest = root; int left = 2 * root + 1; int right = 2 * root + 2; if (left < size && arr[left] > arr[largest]) { largest = left; } if (right < size && arr[right] > arr[largest]) { largest = right; } if (largest == root) { break; } swap(arr, root, largest); root = largest; } }迭代版的好处是避免了递归带来的隐式栈开销,在性能测试中通常有微弱优势。两种写法在原理上完全一致,你用哪种都行,关键是弄明白循环终止条件:当largest不再变化时,就说明当前节点已经不再需要下沉了。
4. 复杂度和稳定性深度解析
4.1 时间复杂度:建堆为什么是 O(n),而不是 O(n log n)
很多教程直接甩出“堆排序复杂度 O(n log n)”,但忽略了前半段建堆为什么是 O(n)。我在这里展开讲讲推导思路。
假设堆有n个节点,树高为h(约等于 log2(n))。自底向上建堆时,每层节点的下沉深度是不同的:
- 倒数第二层有约
2^(h-1)个节点,它们最多下沉1次; - 倒数第三层有约
2^(h-2)个节点,它们最多下沉2次; - 依此类推,根节点下沉
h次。
总工作量大致是0 * 2^h + 1 * 2^(h-1) + 2 * 2^(h-2) + ... + h * 2^0。这个求和是个典型等比与等差混合级数,收敛于O(n),而不是O(n log n)。直观理解:越靠近树底层的节点数量越多,但它们下沉距离越短;越靠近根部的节点数量越少,下沉距离虽长但数量太少,加总起来就是线性级别的。
排序阶段的复杂度相对好理解:每轮交换后对根节点做一次下沉,次数是O(log n),共执行n-1轮,所以排序阶段是O(n log n)。整体时间复杂度是O(n + n log n) = O(n log n)。
4.2 空间复杂度:真正意义的原地排序
堆排序是原地排序算法的经典代表。所有排序过程只是交换数组内部元素,辅助变量只有常数个(比如临时交换变量、循环计数器),所以额外空间复杂度是O(1)。这跟归并排序必须借助O(n)的辅助数组有本质区别。在很多内存受限的嵌入式场景或超大数组处理时,这个特性非常宝贵。
另外要澄清一个常见的困惑:递归版本里函数调用栈算不算额外空间?严格来说,递归调用栈的深度是O(log n),如果面试官严格盘问,递归实现的辅助空间可以算作O(log n)。所以如果想要绝对O(1)的空间,最好用迭代版siftDown。我在面试手写时会主动提这一点,因为这说明你不仅知道结论,还理解边界情况。
4.3 稳定性分析:相等元素会被换位吗?
堆排序被公认是不稳定的排序算法。我来解释一下这个结论是怎么来的。
所谓稳定性,是指两个值相等的元素,排序后相对顺序是否跟原数组一致。堆排序的交换过程经常是“远距离”交换:堆顶元素会直接和数组末尾元素交换,中间跨越其他元素;而下沉过程中,一个元素也可能跨过多层和更远的元素交换。这些跨越式交换很容易打破相等元素之间的原有相对顺序。
举个例子:数组[5a, 5b, 3](用5a、5b表示两个值相等但身份不同的元素),建堆后堆顶是5a或5b之一,交换到末尾后,原来的相对顺序已经无法保证。如果你所在的应用对元素的相对顺序有硬性要求(比如按时间戳排序时,同优先级的任务必须保持提交顺序),堆排序就不太合适。这时可以换用归并排序,或者把相等元素的值域扩展成“(值, 原始序号)”的复合比较键,用复合字段兜底保证稳定排序。
5. 常见问题与排查技巧实录
5.1 建堆起点写错:堆只建了一半
我在 code review 时经常看到有人把建堆循环写成:
for (int i = 0; i < n; i++) { siftDown(arr, i, n); }这样从左往右下沉,结果堆顶不一定能保证是最大值,排序结果当然错误。正确的起点是n/2 - 1,即最后一个非叶子节点。回顾一下为什么:叶子节点没有孩子,不存在“父小于子”的问题,不需要下沉。从最后一个非叶子节点开始倒序处理,每次处理时其左右子树已经合法。建议在代码里对这个边界加注释,面试时主动解释,这反而是加分项。
5.2 边界越界:左孩子和右孩子的下标判断
取左右孩子时,最容易犯的错是写出left <= size或right <= size这样的条件。注意堆的有效范围是[0, size),左孩子下标left = 2*root+1,只有当left < size时才存在这个孩子。同理右孩子条件为right < size。如果你不小心写成<=,数组最后一个元素越界访问到了堆范围外的位置,可能把已经“沉底”排好的元素重新拉进来,排序结果错乱,而且这类错误在测试小数组时不容易发现,数据量大了才暴露出问题。
5.3 排序循环里忘了缩堆:已排好元素又被“挖”回来
排序阶段每次交换之后,下一次下沉的范围要缩小一个元素。堆规模在排序循环中是从n降到1的。我最常见的 bug 版本是把siftDown(arr, 0, i)错写成siftDown(arr, 0, arr.length),结果每次都会把已经沉到末尾的最大值重新参与堆调整,数组不但没有变成升序,反而可能把末尾排好的元素又换回堆顶附近,产生“看起来有点乱但并不完全无序”的错误结果。每次排序循环的下沉范围一定是i,不是固定的n。
5.4 堆排序在数据基本有序时反而慢?
有人测试发现,堆排序在数据基本有序时并没有快排那么快。这是正常的。因为堆排序在建堆阶段会打乱原有的顺序关系,随后每个元素都要经历一次或多次下沉,它对初始数据的顺序不敏感,最坏、最好、平均都是O(n log n)。快速排序在基本有序时如果不做随机化处理反而会退化到O(n^2),从稳定性上讲,堆排序的时间复杂度倒是更“稳”。如果你遇到“几乎有序”的小数组,插入排序往往比堆排序更快;但遇到大规模乱序数据,堆排序的表现很稳定。
5.5 堆排序效率的实际小优化
虽然堆排序复杂度已经很优秀,但常数因子较大通常比快排略慢(因为访问数组时跳跃式的下标计算,缓存局部性不如快排的顺序访问)。实际工程中可以考虑:
- 把
siftDown里的交换操作改成先保存根值、找出最终位置再赋值,减少数组写入次数; - 使用迭代版下沉,减少函数调用开销;
- 不要对每个小数组都完整建堆,某些局部场景可以结合插入排序作为小规模阈值的优化。
这些属于性能调优的范围,写业务代码时一般用不到,但如果你在做高性能组件或算法竞赛,这些都是实打实的提速手段。
6. 实际项目中的应用案例与后续扩展
堆排序并非只停留在教科书里。我在组件开发中反复用过它的变体,这里分享两个真实的应用案例,帮你开拓思路。
案例一:TopK 选择器。假设有 1000 万个数字,需要找最大的10个。如果全部排序,时间开销大;维护一个只含10个元素的小顶堆,遍历数据时,如果当前数字比堆顶大,就替换堆顶并下沉,堆里始终维护着当前遇到的最大10个。遍历结束后,堆内就是答案。堆的规模只有10,每次调整成本极低,实测比全量排序节省大量时间。这个思路也是很多数据库和推荐系统模块的基础。
案例二:任务调度器中的优先队列。系统需要实时处理带有优先级的任务,每次取优先级最高的一个执行。用大顶堆存储任务,插入新任务时执行“上浮”操作,取任务时取走堆顶并执行“下沉”操作,两个操作都是O(log n),完美匹配动态调度场景。
至于后续扩展,建议把构建堆的代码抽成通用的PriorityQueue操作函数,支持任意比较器(Comparator),就能在 Java 的PriorityQueue里自如使用。还可以尝试做一个二叉堆的泛型版本,支持键值对,甚至自己手写一个索引堆用于 Dijkstra 最短路算法的优化。你会在写这些扩展时发现,堆这种数据结构远不止排序一种用途。
最后再分享一个我实际编码中的小技巧:每次写siftDown后,用随机数组做一轮暴力校验,比如生成20万随机数,调用堆排序后用Arrays.equals与Arrays.sort的结果对照。几次下来,数组越界和边界条件的问题都无处遁形。这个校验习惯我保持了很久,帮助避免了无数个低级的索引错误。