news 2026/10/20 3:55:22

堆排序从原理到实现:二叉堆、下沉建堆与复杂度稳定性详解

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
堆排序从原理到实现:二叉堆、下沉建堆与复杂度稳定性详解

一提起堆排序(HeapSort),很多人第一反应是:思路我懂,不就是构建一个大顶堆,然后挨个取堆顶吗?可真到要完整写出来的时候,下标错一位、左右孩子越界、排序后半段结果乱了——这些坑我踩过,也帮不少人排查过。这篇就把HeapSort从建堆到排序的每一步掰开,顺便讲清楚它为什么能做到稳定的O(n log n)时间复杂度,却又无法保证排序稳定性。适合刚上手算法的读者,也适合面试前想一口气把手写堆排序练利索的同学。

1. 堆排序核心思路:把“找最大值”这件事做的更快

1.1 排序的本质,其实是反复找最大/最小值

先聊一个看起来很笨的想法:每次从数组里扫一遍找出最大值,放到最后一位,然后忽略它,再扫剩下部分找最大值。每次扫描是O(n),一共要排n个元素,整体就是O(n²)。这就是选择排序的朴素版本,思路没问题,问题出在“找最大值”这个动作太慢了。

如果有一种数据结构,它能让你在短时间内拿到最大值,同时删除最大值和插入新元素也很快,那么整个排序过程就能大幅提速。这个数据结构,就是二叉堆。堆排序(HeapSort)的本质,就是借助堆这种结构,把“扫描找最大”变成了“堆里取最大”,从而把排序复杂度压到O(n log n)。

堆排序的核心动作可以概括成三步:先原地建堆,然后循环把堆顶最大值放到数组末尾,紧接着缩小堆范围并调整堆。道理很简单,但落地时很多细节容易错。

1.2 二叉堆到底是什么:隐藏在数组里的“完全二叉树”

堆本质上是一棵完全二叉树,但它不需要真的用指针串起来,而是直接存在数组里。因为完全二叉树的节点编号是连续的,给定一个数组下标,就能直接算出它的父节点和左右子节点。

这里约定最常见的从0开始的编号方式:

  • 数组下标i对应的节点,其左孩子下标是 2 * i + 1
  • 右孩子下标是 2 * i + 2
  • 父节点下标是 (i - 1) / 2

最大堆有个性质:每个父节点的值,都大于等于它的子节点。于是根节点就是整个堆的最大值。堆排序用的就是最大堆,按升序输出时每次把堆顶(最大值)换到数组末尾。

1.3 堆排序在排序家族里的位置

先把堆排序和其他几个主流排序摆在一起看,心里会更清楚它在什么时候合适、什么时候不合适。

排序算法平均时间复杂度最坏时间复杂度空间复杂度稳定性
快速排序O(n log n)O(n²)O(log 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(n log n),不像快速排序那样可能退化;二是几乎不用额外空间,是真正意义上的原地排序。但它也有明显短板:不稳定,而且由于数组访问跳跃性强,缓存局部性比快速排序差很多。后面我会专门展开这些点。

2. 开始写代码之前:先吃透“下沉”和“上浮”

2.1 下沉(siftDown):把某个节点放到它该待的位置

下沉是堆排序里最核心的操作。假设某个节点的值比它的子节点小,它就不满足最大堆性质。下沉要做的事情就是:从当前节点开始,不断和较大的子节点比较,如果子节点更大就交换,然后继续向下检查,直到整棵子树都满足堆性质。

很多人把下沉理解成“大的往上走,小的往下掉”,本质上没错。操作里最关键的是要判断左右孩子是否存在。数组子节点下标可能超出堆的边界,所以每轮比较前必须检查 left < n 和 right < n。

我来写一个最简洁的迭代版本,以整型数组为例:

private static void siftDown(int[] arr, int i, int n) { while (true) { int left = 2 * i + 1; int right = 2 * i + 2; int largest = i; if (left < n && arr[left] > arr[largest]) { largest = left; } if (right < n && arr[right] > arr[largest]) { largest = right; } if (largest == i) { break; } int tmp = arr[i]; arr[i] = arr[largest]; arr[largest] = tmp; i = largest; } }

注意这里的n是当前堆的有效大小,不是整个数组长度。在排序过程中,数组尾部已经排好的部分不属于堆,所以n会不断缩小。

2.2 上浮(siftUp):插入场景下的反向操作

和下沉相反,上浮用在“向堆里插入一个新元素”的场景。新元素先放在数组末尾,然后不断和父节点比较:如果它比父节点大,就和父节点交换,继续向上走。这个过程一直持续到它遇到一个更大的父节点,或者到达根节点。

上浮代码单独看也很简单:

private static void siftUp(int[] arr, int i) { while (i > 0) { int parent = (i - 1) / 2; if (arr[i] <= arr[parent]) { break; } int tmp = arr[i]; arr[i] = arr[parent]; arr[parent] = tmp; i = parent; } }

2.3 为什么堆排序里只需要下沉,不需要上浮

这是面试里经常被问到的点。你仔细想:堆排序的过程是“建堆 + 反复取堆顶”。建堆时如果采取自底向上的方式,用的是下沉;排序阶段把堆顶换到末尾后,新堆顶变成原来末尾的小元素,又需要把它下沉到合适位置。整个排序过程从头到尾没有插入新节点,所以完全不涉及上浮。

上浮真正发挥作用的地方是优先队列的插入操作。如果你想在普通数组上通过不断插入来建堆,那就是另一种复杂度了。这点下面讲建堆时会再说到。

3. 完整实现堆排序:从建堆到排序循环

3.1 原地建堆:为什么从最后一个非叶子节点开始

建堆有两种思路。第一种是模拟插入:从空堆开始,一个个调用上浮,把数组元素加进去,这种建堆复杂度是O(n log n)。第二种是原地自底向上调整:数组本来就是一棵完全二叉树,只是不满足堆性质。我们从最后一个非叶子节点开始,往前一个一个做下沉,就能把所有子树调整成合法堆。这种建堆方式的复杂度只有O(n)。

最后一个非叶子节点的下标是多少?对于长度为n的数组,最后一个叶子节点是 n - 1,它的父节点是 (n - 1 - 1) / 2,也就是 n / 2 - 1。从它开始往前遍历到0即可。叶子节点本身没有子节点,天然就是合法堆,不需要下沉。

建堆代码:

int n = arr.length; for (int i = n / 2 - 1; i >= 0; i--) { siftDown(arr, i, n); }

为什么自底向上建堆是O(n)而不是O(n log n)?因为越靠近叶子的节点,下沉时能走的路径越短。最底层的大量节点只需要比较一次甚至不用比较,只有靠近根部的少数节点才需要下沉较深。把所有节点下沉高度加起来,是一个等差级数,算出来是线性的。具体推导不在这里展开,但你只要记住结论就好:不是所有节点都做O(log n)次操作,整体上建堆是O(n)。

3.2 排序循环:堆顶和末尾交换,然后缩小堆范围

建好最大堆之后,数组的第一个元素就是整体最大值。此时把堆顶和当前堆的最后一个元素交换,这个最大值就放在数组末尾了。接下来让堆的大小减一(因为末尾已经排好,不该再参与堆调整),再从新堆顶开始下沉,恢复最大堆性质。重复这个过程,直到堆里只剩一个元素,整个数组就是升序了。

完整排序循环:

for (int i = n - 1; i > 0; i--) { int tmp = arr[0]; arr[0] = arr[i]; arr[i] = tmp; siftDown(arr, 0, i); }

写这个循环时最容易犯的错误是:siftDown的第三个参数直接写成了n,而不是当前的堆大小i。如果写n,已经放到末尾的最大值会被重新加入堆,排序结果直接乱掉。这个坑我在专门一章节里会再强调。

3.3 一个完整的排序过程模拟

拿一个具体数组走一遍,比空谈理论有用得多。现在有数组 [4, 10, 3, 5, 1],我们模拟一下完整堆排序。

先建堆,n = 5,最后一个非叶子下标是 5 / 2 - 1 = 1。

  • i = 1,节点值是10,左孩子下标3值是5,右孩子下标4值是1。10比两个子节点都大,不需要移动。
  • i = 0,节点值是4,左孩子下标1值是10,右孩子下标2值是3。最大的是左孩子10,交换后数组变成 [10, 4, 3, 5, 1]。
  • 下沉还没结束:当前节点在i = 1,值是4,左孩子下标3值是5,右孩子下标4值是1。5更大,交换后数组变成 [10, 5, 3, 4, 1]。

现在最大堆建好了,根节点是10。

开始排序循环:

  • i = 4:交换arr[0]和arr[4],数组变成 [1, 5, 3, 4, 10]。堆大小变成4,对堆顶下标0执行下沉。节点1的左孩子5更大,交换得到 [5, 1, 3, 4, 10];继续下沉i = 1,节点1的左孩子下标3值是4(下标4超出堆大小4,不算),交换得到 [5, 4, 3, 1, 10]。
  • i = 3:交换arr[0]和arr[3],数组变成 [1, 4, 3, 5, 10],堆大小3。下沉堆顶:节点1,左孩子4更大,交换后 [4, 1, 3, 5, 10]。
  • i = 2:交换arr[0]和arr[2],数组变成 [3, 1, 4, 5, 10],堆大小2。下沉堆顶:节点3,左孩子1(下标1)在堆内,但3大于1,不动。
  • i = 1:交换arr[0]和arr[1],数组变成 [1, 3, 4, 5, 10],堆大小1,结束。

最终结果是 [1, 3, 4, 5, 10],排序完成。整个过程里,siftDown每次处理的都是当前堆的有效边界i,这是最需要盯住的地方。

4. 复杂度与稳定性:堆排序的得与失

4.1 时间复杂度为什么是稳定的O(n log n)

堆排序的时间复杂度由两部分组成:建堆O(n),排序循环n - 1次,每次从堆顶下沉最多走O(log n)层,所以排序部分是O(n log n)。两者相加,取大的那个,整体就是O(n log n)。

这里的关键在于:不管输入数组本来就接近有序,还是完全乱序,堆排序都不会受到初始状态影响。最大堆的构建不会因为数据基本有序而更快,排序循环的交换次数也固定。所以堆排序的最好、平均、最坏时间复杂度都是O(n log n),这在比较类排序里是很难得的稳定性。

代价是常数系数偏高。下沉操作要频繁比较左右孩子、做交换,数组访问的跨度大,对CPU缓存不友好。实际测试里,同样的数据量,堆排序通常明显慢于快速排序。如果你只是给普通数组排序,主流语言内置的排序函数几乎都优于手写堆排序。

4.2 空间复杂度:原地排序到底是怎么做到O(1)的

堆排序在数组自身范围内操作,除了几个临时变量之外,不需要额外的数据结构。所以数据辅助空间是O(1)。这也是堆排序相比归并排序的最大优势:归并排序再怎么优化,也需要O(n)的额外空间来合并两个有序段。

需要注意一个细节:如果siftDown用递归写法,递归调用栈在极端情况下会带来O(log n)的函数栈空间。虽然这不是数据规模意义上的额外空间,但如果你被面试官严格追问“是否真正O(1)”,最好回答“迭代式下沉可以做到严格O(1)辅助空间”。这也是我推荐用迭代而不是递归的原因之一。

4.3 为什么堆排序不稳定

稳定性的定义是:如果有两个相等的元素,排序后它们的相对顺序不能改变。堆排序做不到这一点。

看一个最简单例子。数组 [5a, 5b, 3],两个5用a和b区分原来先后顺序。建堆后仍然是 [5a, 5b, 3](因为5a不小于5b,不需要交换)。然后进入排序循环:交换堆顶5a和末尾3,数组变成 [3, 5b, 5a],堆大小变成2。接着下沉下标0的3:左孩子是5b,5b比3大,交换后数组变成 [5b, 3, 5a]。再交换堆顶5b和当前位置元素3,最终得到 [3, 5b, 5a]。

原来的顺序是5a在5b前,结果5b在5a前,相对顺序被改变了。原因在于:堆排序的交换往往发生在相隔很远的节点之间,堆顶元素会被直接甩到数组尾部,这可能绕过了其他相等的元素。快速排序也有类似的跨距离交换问题,所以同样不稳定。

4.4 和其他排序怎么取舍

实际工作中,我不会单纯因为堆排序复杂度好看就优先选它。排序不是只看时间复杂度,还要看数据规模、稳定性要求、内存限制、甚至数据是否能一次性放进内存。

  • 如果要求稳定排序,直接选归并排序或其变体,比如语言内置的稳定排序。
  • 如果内存很紧张,且不要求稳定,堆排序可以考虑。
  • 如果数据量很大但不需要绝对稳定,快速排序在实际运行时通常最快,绝大多数系统的内置排序也会优先照顾快排、插入排序混合策略。
  • 如果需要在动态数据流里反复取最大/最小值,堆不是“排序方案”,而是唯一合适的数据结构,这属于堆的真正主场。

5. 实际写堆排序时的常见坑和排查方法

5.1 下标从0开始还是从1开始,最容易混

很多教材讲堆时喜欢把下标从1开始,因为这样左孩子是2i,右孩子是2i + 1,父节点是i / 2,非常对称。但绝大多数编程语言的数组下标从0开始,如果你硬套下标1的公式,最后一个非叶子节点、左右孩子的计算都会出问题。

从0开始的公式一定要记熟:

  • 左孩子:2 * i + 1
  • 右孩子:2 * i + 2
  • 父节点:(i - 1) / 2
  • 最后一个非叶子节点:n / 2 - 1

如果你实在不放心,就自己画一棵完全二叉树,把下标标上去。画一次就知道为什么是这些公式了。

5.2 左右孩子越界判定,是最大的隐性坑

写siftDown的时候,left和right可能在数组范围之外。常见错误是只判断left不判断right,或者在判断里用了 <= 而不是 <。

比如堆大小是n,节点i是最后一个非叶子节点,它的左孩子可能恰好是n - 1,右孩子是n。此时right == n已经不合法。判断条件应该是 left < n,right < n,而不是 right <= n - 1。用 < n 更直观,也避免下标n访问越界。

还有一点:如果left已经越界,说明当前节点是叶子节点,下沉直接结束。如果left在界内但right越界,只需要比较left和当前节点即可。这两个分支都要处理干净。

5.3 排序循环的堆大小,千万别写成整个数组长度

我再三强调这个错误,因为它太容易发生了。建堆阶段下沉用的边界是n,排序阶段下沉用的边界却应该是当前堆大小i。

错误写法:

for (int i = n - 1; i > 0; i--) { swap(arr, 0, i); siftDown(arr, 0, n); // 错!应该用 i }

这样做的后果是:每次把最大值放到末尾后,下一次下沉又把末尾的最大值包了进来,等于白干,甚至会把排好的元素重新挪回去。我见过有人调试很久,最后发现就是这里多传了一个n。建议把siftDown的第三个参数命名为heapSize,时刻提醒自己传的是堆大小。

5.4 下沉用递归还是迭代

递归版siftDown写起来可读性不错:

private static void siftDownRecursive(int[] arr, int i, int n) { int left = 2 * i + 1; int right = 2 * i + 2; int largest = i; if (left < n && arr[left] > arr[largest]) largest = left; if (right < n && arr[right] > arr[largest]) largest = right; if (largest != i) { int tmp = arr[i]; arr[i] = arr[largest]; arr[largest] = tmp; siftDownRecursive(arr, largest, n); } }

逻辑没问题,但每下沉一层多一次函数调用,数据量大时调用栈有一定开销。迭代版并不难,按我前面给出的while写法就能完全替代。工程上建议用迭代,面试中也更稳妥,因为你还能顺带解释“这样空间复杂度才严格O(1)”。

5.5 “结果不对”时的速查清单

如果排序结果不对,按下面顺序逐项检查,90%的问题能快速定位:

症状检查点
排序结果末尾有几个反序大值排序循环siftDown边界是否写成n
下标越界异常孩子节点判断是否有left < n和right < n
建堆后堆顶不是最大值最后一个非叶子节点起点是否写对
数组基本有序但结果仍然错是否把建堆和排序循环顺序写反了
结果递减而不是递增是否建成了最小堆或排序方向搞反

还有一个自查技巧:如果是在刷题网站上报错,先小规模打印数组,用3到5个元素手推一遍。堆排序只有几十行代码,人肉模拟一遍非常快。

6. 堆排序之外:堆思想更常见的应用

6.1 TopK问题:找最大的K个数,堆比排序更好用

堆排序最实用的延伸其实是TopK问题。比如给你一个海量数据流,随时想知道到目前为止最大的10个数,你不会把所有数据排序后再取前10,而是维护一个大小为10的最小堆。

过程是这样的:堆里存当前最大的10个数,堆顶是这10个数里最小的那个。新来一个数,如果比堆顶还小,直接忽略;如果比堆顶大,就把堆顶替换成新数,然后执行一次下沉。这样不管数据流多大,内存只需要K个元素,时间消耗是O(n log K)。数据量特别大、内存装不下时,这个方案几乎是唯一可行的。

这和堆排序的思路正好相反:堆排序把整个数组建成堆再逐个取出最大值,TopK则固定堆的大小K,只关心有多少数据比堆里的最小值大。面试中聊到堆,很大概率会扯到这道题。

6.2 优先队列:堆最通常的日常形态

很多语言的标准库里都有优先队列,底层实现就是一个二叉堆。比如Java里的PriorityQueue默认是最小堆,每次出队取最小元素;可以用自定义比较器改成最大堆。C++里的priority_queue则是默认最大堆。这些容器在任务调度、Dijkstra最短路、事件驱动模拟里都被广泛使用。

如果自己手写优先队列,核心就是siftUp和siftDown两个操作。插入元素时先放在数组末尾再上浮,取出堆顶时把末尾元素移到堆顶再下沉。这套逻辑和堆排序里的下沉完全共享,只是多了上浮。理解了堆排序,再去看优先队列源码会非常轻松。

6.3 外部排序与多路归并:堆是幕后功臣

当数据大到内存装不下,外部排序会把数据分块排序成多个有序段,然后再把所有这些有序段合并成一个完整有序的大文件。多路归并时,要从每个有序段当前指针指向的元素里选出最小的一个,这正好可以用一个大小为路数K的最小堆来做。

每次从堆顶取出当前最小元素,写入输出文件,然后从对应路段读取下一个元素,再插入堆并调整。K比较大时,用堆比逐个比较所有路段的当前值快一个数量级。许多数据库和文件排序工具的底层都离不开这个思路。

6.4 从堆排序延伸出的优化思考

堆排序本身在这个时代已经不是排序首选,但围绕堆的思想还有很多优化方向。比如索引堆:在堆节点里额外存一份原始数组的下标,这样在更新任意元素时能快速定位到堆中的位置,适合图中边的动态更新。又比如带权优先队列用在仿真系统里,需要支持删除和更新任意元素,直接用普通二叉堆会复杂不少,但掌握下沉上浮后都能理解。

我个人实际做项目时的体会是:堆排序作为“排序算法”的价值反而没那么大,真正好用的是堆这种数据结构。如果你正在准备算法面试,与其背堆排序的完整代码,不如先把siftDown和siftUp写到条件反射,然后把TopK、优先队列、合并K个有序链表这类题练熟。等你对堆的调整过程足够熟练,再回头看堆排序,会发现它只是“建堆 + 取顶下沉”的组合,自然就写得又快又准了。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/20 3:55:14

Linux服务器账号开通SOP:UID统一与权限隔离全攻略

在服务器运维这件事上&#xff0c;账号管理看着最不起眼&#xff0c;但它就像公司大门的钥匙串——钥匙多了没人管&#xff0c;谁都能摸进会议室和财务室&#xff0c;迟早要出事。我接手某公司一套 Linux 计算集群时发现&#xff0c;光节点上的账号就有三种来源&#xff1a;有人…

作者头像 李华
网站建设 2026/10/20 3:55:08

C语言实现顺时针打印矩阵:四边界法与边界控制详解

菜鸟到大厂面试&#xff0c;最怕碰到的就是这类“看着简单&#xff0c;一写就废”的题。顺时针打印矩阵&#xff0c;剑指 Offer 29&#xff0c;C语言版本&#xff0c;几乎每家公司在考察数组和边界控制时都会拿它当试金石。题目本身抽象&#xff0c;一句话概括就是&#xff1a;…

作者头像 李华
网站建设 2026/10/20 3:54:43

让AI读懂团队踩过的坑:隐性知识文档化与RAG实践

1. 这事是怎么发生的&#xff1a;我盯着满屏"已读"文件&#xff0c;突然意识到不对劲前阵子我接手了一个知识库整理的项目&#xff0c;手头有大概六百多份文档——产品手册、技术方案、会议纪要、客户反馈、历史邮件截图&#xff0c;什么都有。团队的想法很直接&…

作者头像 李华
网站建设 2026/10/20 3:53:45

基于Python+Django的黄瓜批发市场管理系统开发实战

你还在纠结毕业设计选什么题目&#xff1f;作为一个从学生时代走过来、又带过不少实习生的人&#xff0c;我太清楚这套“Python Django 管理系统”组合意味着什么了。今天要聊的这个“基于PythonDjango的黄瓜批发市场管理系统”&#xff0c;说白了就是典型的管理信息系统题目…

作者头像 李华
网站建设 2026/10/20 3:53:30

Qt自绘滑动选择器:惯性滑动与刻度对齐的控件实现

简介&#xff1a;这是一份基于Qt框架实现的自定义滑动选择器工程源码&#xff0c;适合有一定Qt基础、希望系统学习自定义控件绘制与交互的开发者。控件支持水平与垂直两种模式切换&#xff0c;并在背景、滑块、边框配色以及值域、初始值上提供灵活配置&#xff0c;可嵌入音量调…

作者头像 李华
网站建设 2026/10/20 3:53:30

真实项目复盘:从‘rea‘代号到实时错误分析平台的架构实践

接项目单子的时候最怕碰见这种命名&#xff1a;三个字母&#xff0c;无注释&#xff0c;无文档&#xff0c;连邮箱前缀都是它。我之前接到过一个小项目&#xff0c;代号就叫“rea”&#xff0c;需求方给的说明只有一句话&#xff1a;“让业务方实时看到系统异常。”前后端一共四…

作者头像 李华