☰
堆排序深度解析:从选择排序痛点到优先队列应用
2026/10/20 12:21:50 网站建设 项目流程

1. 为什么说堆排序是“进化版”选择排序

1.1 选择排序的痛点回顾

选择排序的原理可以说是所有排序算法里最直观的:每一轮从待排序的序列里挑出最小的元素,放到已排序区的末尾。重复 n-1 轮,整个数组就有序了。

代码大概长这样:

def selection_sort(arr): n = len(arr) for i in range(n - 1): min_idx = i for j in range(i + 1, n): if arr[j] < arr[min_idx]: min_idx = j arr[i], arr[min_idx] = arr[min_idx], arr[i]

这个算法的时间复杂度毫无疑问是 O(n²),内外两层循环,比较次数固定是 n(n-1)/2。数据量到几千上万的时候,跑起来肉眼可见地慢。

但仔细想想,选择排序慢在哪?不是交换,交换只需要 n-1 次,瓶颈在"每一轮都要重新扫描整个未排序区去找最小值"。第 1 轮你要看 n 个元素,第 2 轮要看 n-1 个,第 3 轮 n-2 个……每一轮的最值查找都是 O(n),n 轮加起来就是 O(n²)。

问题就出在这个"重复扫描"上。前一轮扫描过的信息,后一轮完全没用上,每一轮都从零开始。就像你去一个乱糟糟的仓库里找最小的一件货物,第一次翻遍了整个仓库,第二次还是从头翻,第三次依然如此。这显然很浪费。

那么自然的改进思路就出来了:能不能把前一轮扫描得到的信息存下来,让下一轮找最值的时候不用全量扫描?

这就是堆排序的核心动机。

1.2 堆如何解决"找最值"的瓶颈

如果维护一个数据结构,能让你以 O(log n) 的代价取出最值、以 O(log n) 的代价插入新值,那么"每轮选最小"的开销就从 O(n) 降到了 O(log n)。整个排序的时间复杂度就从 O(n²) 降到 O(n log n)。

这个数据结构就是堆(Heap)。

堆是一棵完全二叉树,并且满足堆序性质:如果是大顶堆,父节点的值不小于任意子节点;如果是小顶堆,父节点的值不大于任意子节点。也就是说,堆顶永远是全局最值。

你可能会问:用二叉搜索树或者有序数组行不行?理论上行,但各有各的问题。二叉搜索树实现复杂且容易退化成链表;有序数组插入要 O(n) 的移位。而堆有两大优势:第一,完全二叉树天然可以用数组存储,不需要指针,空间紧凑;第二,上浮、下沉操作只在一条从根到叶的路径上进行,长度就是树高 O(log n)。

再回到标题里那个 "She Says"。我个人的理解是:在堆排序里面,下一个该输出的元素不是我们一个个比出来的,而是堆结构直接"告诉"你的——堆顶说了算。整个排序过程像是一个指挥家看着乐谱(堆)念出下一个音符,堆顶吐出一个最大值,剩下的元素自动重新调整,准备吐出下一个。这种"结构说了算"的确定性,正是堆排序最优雅的地方。

2. 堆的核心原理:数组里藏着一棵二叉树

2.1 父子下标的关系

堆的底层存储是一个数组,但逻辑上是一棵完全二叉树。数组下标从 0 开始的情况下,对于任意节点 i:

  • 左子节点下标:2i + 1
  • 右子节点下标:2i + 2
  • 父节点下标:(i - 1) // 2(整数除法)

举个例子,数组 [16, 14, 10, 8, 7, 9, 3, 2, 4, 1] 对应的逻辑结构是:

  • 下标 0 的 16 是根
  • 下标 1 的 14 和下标 2 的 10 是 16 的两个孩子
  • 下标 3 的 8 和下标 4 的 7 是 14 的孩子
  • 下标 5 的 9 和下标 6 的 3 是 10 的孩子
  • 下标 7 的 2、下标 8 的 4 是 8 的孩子,下标 9 的 1 是 7 的孩子

画成一棵树你会发现,每一层从左到右填满,最后一层可以不满但必须从左往右连续。这种"连续紧凑"的形态正是数组能无缝承载它的根本原因。

这个下标关系是整个堆操作的基石。你要写堆排序,第一步就是把这几条公式刻在脑子里——写错一个下标,整个算法就原地崩溃。

2.2 上浮与下沉

堆的调整操作就两个:上浮(sift-up)和下沉(sift-down)。

上浮的场景是:某个节点的值变大了(大顶堆),它可能比父节点还大,破坏了堆序。这时候就把它和父节点交换,一直向上比较,直到它不超过父节点,或者到达根。

下沉的场景是:某个节点的值变小了(或者你刚把根换走,拿了一个新元素放到根的位置),它可能比子节点还小,这时候就把它和较大的子节点交换,一直向下调整,直到它不小于两个子节点。

这两个操作的时间复杂度都是 O(log n),因为二叉树的每一层最多做一次比较和交换,而完全二叉树的深度是 ⌊log₂n⌋。

注意:上浮和下沉的"方向"千万不能搞混。上浮只需要和父节点比,一路向上;下沉要和两个子节点都做比较,选较大那个(或者较小那个,取决于堆的方向)交换。很多人在写堆排序的时候,把下沉的下标边界写错了,导致排序结果差一点点不对——后面我会专门讲这个坑。

2.3 建堆的时间复杂度玄机

建堆有两种思路。

第一种是最直觉的:从一个空堆开始,把 n 个元素一个一个插入。每插入一个就要上浮 O(log n),总复杂度 O(n log n)。

第二种是原地建堆:给定一个无序数组,从最后一个非叶子节点开始,从右往左、从下往上依次做下沉操作。

为什么从下往上?因为下沉操作的前提是:左右子树都已经满足堆序。如果从根开始往下调,根调整完了,子树可能刚从无序状态变成有序状态,这时又需要重新检查,会造成很多重复调整。而从最后一个非叶子节点向前处理,能保证每次处理某个节点时,它的子树已经是合法堆了,只需把这个节点"塞到正确位置"即可。

最后一个非叶子节点的下标是 n // 2 - 1(数组下标从 0 起算)。比如 n = 10,最后一个非叶子是下标 4。

这个建堆方式的时间复杂度是 O(n),不是 O(n log n)。很多教材直接把结论抛出来,但没说为什么。我来给你算一算:

  • 倒数第一层(叶子层)有 n/2 个节点,它们根本不用下沉,代价 0
  • 倒数第二层有 n/4 个节点,每个最多下沉 1 层,代价 n/4 × 1
  • 倒数第三层有 n/8 个节点,每个最多下沉 2 层,代价 n/8 × 2
  • 依次类推

总代价是:

S = n/4 × 1 + n/8 × 2 + n/16 × 3 + ...

这个级数收敛于 n 的常数倍。数学上可以证明 S = O(n)。直觉上理解:越是靠近顶层的节点,数量越少,不需要下沉很多层;而需要下沉多层的大多是底层节点,但底层节点数量少得多。两相抵消,总和是个常数级。

这个 O(n) 的建堆是堆排序能成立的重要基础,如果建堆都要 O(n log n),那堆排序的整体性能就要重新评价了。

3. 堆排序的完整实现

3.1 代码实现

堆排序的输出方式是:先建一个大顶堆,然后把堆顶(最大值)和数组末尾元素交换,堆的大小减一,再对新的堆顶执行下沉,恢复堆序。重复 n-1 次,数组就从小到大排好了。

这里用大顶堆,是因为我们希望从小到大排序:堆顶是最大的,放到最后,前面的部分继续调整,这样不需要额外空间,原地就能排完。

以下是 Python 的完整实现:

def sift_down(arr, start, end): """ 对 arr[start] 执行下沉操作 有效堆范围是 [start, end](含 end),超出部分视为已排序区 """ root = start while True: child = 2 * root + 1 if child > end: break # 选出较大的子节点 if child + 1 <= end and arr[child + 1] > arr[child]: child += 1 # 如果父节点小于较大子节点,交换 if arr[root] < arr[child]: arr[root], arr[child] = arr[child], arr[root] root = child # 继续向下调整 else: break def heap_sort(arr): n = len(arr) # 第一阶段:建堆,从最后一个非叶子节点开始下沉 for i in range(n // 2 - 1, -1, -1): sift_down(arr, i, n - 1) # 第二阶段:逐个交换堆顶到末尾 for i in range(n - 1, 0, -1): arr[0], arr[i] = arr[i], arr[0] # 堆顶最大值放到 i 位置 sift_down(arr, 0, i - 1) # 剩余部分重新调整为堆 return arr

这是最精简的版本,没有递归,下沉用循环实现,树高最多几十层,循环完全够用,还能避免递归调用栈的开销。

我建议你亲手敲一遍这段代码,不要直接复制。敲的过程中注意几个关键点:

  • sift_down的end参数是动态变化的,它标记了当前堆的有效边界
  • 第二个循环里,每次交换之后i - 1是新的堆边界
  • 下沉过程中child的右兄弟判断条件child + 1 <= end不能省略,否则可能访问到已排序区的元素

3.2 手动跑一遍排序过程

用数组 [4, 10, 3, 5, 1, 2, 9] 走一遍,感受下这个过程。

建堆阶段:

  1. n = 7,最后一个非叶子节点下标是 7 // 2 - 1 = 2,值是 3。下沉:3 比左子 2 大,比右子 9 小,交换 3 和 9,数组变成 [4, 10, 9, 5, 1, 2, 3]
  2. 处理下标 1,值 10,比两个孩子 5 和 1 都大,不需要动
  3. 处理下标 0,值 4,两个孩子是 10 和 9,选较大的 10 交换,数组变 [10, 4, 9, 5, 1, 2, 3]。接着 4 下沉:左子 5 右子 1,选 5 交换,变 [10, 5, 9, 4, 1, 2, 3]。到此大顶堆建成,堆顶是 10。

排序阶段:

  1. 10 和末尾 3 交换,[3, 5, 9, 4, 1, 2, 10],堆顶 3 下沉:左右子 5 和 9,选 9 交换,然后 3 和 2 比,不动。结果 [9, 5, 3, 4, 1, 2, 10]
  2. 9 和末尾 2 交换,[2, 5, 3, 4, 1, 9, 10],堆顶 2 下沉:左右 5 和 3,选 5 交换,2 继续下沉:左右 4 和 1,选 4 交换。结果 [5, 4, 3, 2, 1, 9, 10]
  3. 5 和末尾 1 交换,[1, 4, 3, 2, 5, 9, 10],堆顶 1 下沉:左右 4 和 3,选 4 交换,1 再和 2 交换。结果 [4, 2, 3, 1, 5, 9, 10]
  4. 继续这个流程,最后得到 [1, 2, 3, 4, 5, 9, 10]

你注意看,每一轮交换之后,堆的大小减一,被换到末尾的元素永远保持不动,因为它们已经在正确的位置上了。这就是为什么堆排序不需要额外数组,原地就能完成的原因——堆的"物理容器"是数组的未排序前缀,已排序后缀从末尾开始往回填充。

4. 复杂度、稳定性与其他排序的对比

4.1 时间复杂度:最好最坏都一样

堆排序的时间复杂度是 O(n log n),这包括建堆 O(n) 和 n-1 次交换+下沉(每次 O(log n))。关键在于:无论输入数组原本是正序、逆序还是乱序,堆排序都要走完同样的流程——建堆、反复交换下沉,每一步的下沉深度可能略有差异,但整体量级不变。

这就是堆排序和快速排序最本质的区别之一:快排最坏情况下会退化到 O(n²)(比如每次选到极端的基准),但堆排序没有这个弱点。堆排序是少数几种"最坏情况也是 O(n log n)"的通用比较排序。

当然,这个"稳定"是有代价的。堆排序的实际运行速度通常不如快排,原因在于它的访存模式不友好:下沉操作在数组上跳来跳去,父子节点的下标不是连续的,CPU 缓存命中率低。快排和归并排序的元素访问大多是局部连续的,缓存友好得多。这个细节在做大规模排序时差别非常明显。

4.2 空间复杂度与稳定性

堆排序的空间复杂度是 O(1),原地排序,不需要额外的辅助数组。这是它的另一个大优势,尤其适合内存受限的场景。

但稳定性方面堆排序是"硬伤"。所谓稳定排序,就是相等的元素在排序后能保持原有相对顺序。堆排序做不到这一点,原因很简单:下沉和交换操作经常会把相等的元素跨越多个位置调来调去,它们的相对顺序无法保证。比如两个值相同的元素,一个在堆的深层,一个在堆顶附近,排序过程中它们可能被交换到对方的前面。

如果你的业务场景要求"排序后相同关键字的记录仍保持原来的先后顺序",堆排序直接排除。选归并排序或插入排序。这是面试里经常被追问的点,别栽在这上面。

4.3 主流排序算法横向对比

算法平均时间最坏时间空间稳定性适用场景
冒泡排序O(n²)O(n²)O(1)稳定教学、超小数据量
选择排序O(n²)O(n²)O(1)不稳定教学、交换开销小的场景
插入排序O(n²)O(n²)O(1)稳定近似有序的数据
快速排序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)不稳定内存受限、Top-K、优先队列

从表格能清楚看出堆排序的定位:它不是最快的,不是稳定的,但它是"最坏情况有保障、空间占用为零"的选手。在嵌入式环境或者内存严格受限的系统中,堆排序往往比快排更让人放心。

5. 常见问题与排查实录

5.1 下标边界:最多人栽的坑

我见过不少人在写堆排序时,问题都出在下标边界上。这里列几个高频错误:

错误一:下沉时越界。子节点下标 2*root + 1 超出了当前堆的有效边界 end。典型错误写法是if child > n而不是if child > end。区别在哪?在排序阶段,堆的边界是不断缩小的,如果你错误地使用 n 作为边界,下沉操作会把已经放到数组末尾的已排序元素拉进来比较,排序结果就乱了。

错误二:忽略右子节点存在的判断。有些节点的左子节点存在,但右子节点可能已经超出堆边界。如果不管三七二十一就arr[child + 1] > arr[child]这样比较,而 child + 1 已经出界,读到的可能是已排序区的数据,或者空值。

错误三:建堆起点算错。最后一个非叶子节点的下标不是 n/2,而是 n/2 - 1(下标从 0 开始)。如果写成for i in range(n // 2, -1, -1),会把第一个叶子节点也拿来下沉,虽然结果不一定错,但多做无谓的操作;如果写成for i in range(n // 2 - 1, -1, -1)之外的错误下标,就会漏掉需要调整的节点,堆就不合法。

5.2 混淆"建堆"和"排序"

这是逻辑层面的高频错误。有同学把"维护堆"和"排序"混在一起,在建堆阶段就试图输出有序序列。建堆的产物是一个堆序数组,比如 [10, 5, 9, 4, 1, 2, 3],它只保证父节点大于子节点,并不是完全有序的。你必须明白:建堆只是预处理,真正的排序发生在第二阶段反复交换堆顶和堆尾的过程中。

判断建堆是否正确,可以写一个辅助验证函数:遍历所有非叶子节点,检查是否满足堆序性质。这个验证过程本身也是复习堆定义的好机会。

5.3 稳定性问题的误判

很多人以为堆排序是稳定的,理由是"每次交换的都是堆顶最值,怎么会破坏相等元素的顺序"?实际上,堆排序在调整过程中,相等的元素会被交换来交换去。举个简单的例子,数组 [5a, 5b, 3](用 a、b 区分两个相等的 5),初始下标 0 是 5a 是父节点,5b 是左子。建堆后,如果 5a 和 3 交换,5b 可能就会跑到 5a 前面。这类微妙的顺序变化,在堆排序里几乎不可避免。

如果你需要稳定排序,别在堆排序上浪费时间想办法。用归并排序或者插入排序,直接且可靠。

5.4 实际调试记录

我曾经在一次模拟练习里跑了上万次的随机数组和堆排序,发现一个非常隐蔽的问题:当数组里有大量重复元素时,下沉操作中的>和>=的选择会影响最终结果。用>(只和较大子节点交换)相比>=(相等也交换)会少做一些无用的交换,性能稍好一点,但结果都是一样的正确。不过如果你的下沉实现用了>=,在某些语言的标准库行为上可能会引起对稳定性的错误期待,再次强调:不管比较符号怎么选,堆排序都不是稳定的。

6. 堆排序思想的现实应用

6.1 优先队列:堆最经典的生产力

堆排序本身在工程里直接用来排序的场景不多,因为大部分通用排序需求都被快排和归并干掉了。但堆这个数据结构本身,以及围绕它的堆调整操作,是最常用的(注意,不是"之一")。

优先队列就是堆最典型的应用。你要的永远是最紧急的任务(最大或最小优先级),插入新任务要能快速进堆,弹出任务要能快速调整。这恰好就是上浮和下沉操作。操作系统里的任务调度、网络协议里的定时器管理、图算法里的 Dijkstra 最短路,底层都是堆。

我自己写过一个小型任务调度系统,核心就是一个基于堆的优先队列。每来一个新任务就 push,每次取任务就 pop,复杂度都是 O(log n)。对比之前用有序数组实现,插入任务要 O(n) 的移动,吞吐量差了一个数量级。

6.2 Top-K 问题:面试和工程的双重高频

Top-K 问题的经典解法之一就是维护一个大小为 K 的小顶堆。遍历数据流,如果当前元素比堆顶大,就替换堆顶,然后下沉调整。这样堆里永远保存着最大的 K 个元素,堆顶就是第 K 大的元素。

以 LeetCode 风格的题目举例:给一个包含百万级数字的数组,找最大的 10 个数。如果你先全量排序,时间复杂度 O(n log n),内存还要存下完整数组。但用大小为 10 的小顶堆,时间 O(n log 10),非常适合流式数据或超大文件场景。

堆排序和 Top-K 看似是两个问题,本质上是一回事:都是利用"堆顶点即最值"这个特性。区别在于,排序要求全序列有序,而 Top-K 只需要维护窗口内极值。理解了堆的各种调整操作,这两个问题都是在重复应用同一套思维模型。

6.3 堆排的实际使用建议

那堆排序到底什么时候用?我的经验是:

  • 内存极受限、不允许额外空间,又需要保证 O(n log n) 最坏复杂度的场景
  • 实现一个优先队列,然后基于它构建更上层的功能
  • 在嵌入式或实时系统里,需要可预测的、不受输入顺序影响的排序行为

至于日常项目里给数据排序,直接用你所在编程语言的标准库排序函数就好。标准库里的排序是经过极致优化的,比手写堆排快得多。学习堆排序的主要价值在于理解算法本身、应对面试,以及在合适的场景里正确地"造轮子"。

7. 一点实操心得

我在最初学习堆排序的时候,最大的误区是想当然地认为"堆排序就是每次找最大然后放到末尾",结果代码怎么调都不对。后来我明白了一个关键点:你必须先把"堆"和"排序"两个概念彻底分开。把数组调整成一个合法的堆之后,它的意义只是"堆顶是最大值",而不是"整个数组有序"。排序的核心动作是反复利用堆顶定位最大值、交换到末尾、缩小堆的范围,这三个动作缺一不可。

建议你入门时做三件事:第一,不借助任何资料,在纸上把 [4, 10, 3, 5, 1, 2, 9] 的建堆过程和排序过程完整演算一遍,标出每一步的下标变化;第二,写完代码后用随机数组和标准库排序做对拍,跑几万组数据确保正确性;第三,手动构造一些极端用例——全相同的数组、逆序数组、只有一个元素的数组——确认边界处理没问题。这几步走完,堆排序才真正属于你。

最后分享一个小技巧:调试堆排序时,在每次交换完堆顶之后,打印整个数组。你能直观地看到"已排序区从末尾稳步向前扩张"的过程。这个画面一旦在脑子里固定下来,堆排序的原理就永远不会忘了。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询