← Back

Heap Sort 堆排序


Heap Sort 堆排序

核心结论

堆排序先把数组组织成一个最大堆,随后反复将堆顶最大元素交换到当前未排序区间的末尾,并缩小堆的有效范围。其最好、平均和最坏时间复杂度均为 Θ(nlogn)\Theta(n\log n),辅助空间为 O(1)O(1),但通常不稳定

1. 问题定义

给定一个长度为 nn 的可比较元素序列:

A=[a0,a1,,an1],A = [a_0,a_1,\dots,a_{n-1}],

将其重新排列为非递减序列:

a0a1an1.a'_0 \le a'_1 \le \cdots \le a'_{n-1}.

堆排序属于比较排序,主要利用二叉堆维护当前未排序元素中的最大值。


2. 前置知识:数组表示的二叉堆

2.1 完全二叉树

二叉堆在逻辑上是一棵完全二叉树,在物理上通常直接存储于数组中,因此不需要显式保存结点指针。

对于 0-based 数组下标,结点 ii 的相关位置为:

parent(i)=i12,\operatorname{parent}(i)=\left\lfloor\frac{i-1}{2}\right\rfloor, left(i)=2i+1,\operatorname{left}(i)=2i+1, right(i)=2i+2.\operatorname{right}(i)=2i+2.

叶结点范围

在长度为 nn 的堆中,最后一个非叶结点的下标为

n21.\left\lfloor\frac{n}{2}\right\rfloor-1.

因此建堆时只需要从该结点开始向前执行下沉操作。

2.2 最大堆性质

最大堆要求每个结点的值都不小于其孩子结点:

A[parent(i)]A[i],i>0.A[\operatorname{parent}(i)] \ge A[i], \qquad i>0.

因此,最大堆的根结点 A[0]A[0] 一定是当前堆中的最大元素。

升序与降序

  • 使用最大堆,最终得到升序序列。
  • 使用最小堆,最终得到降序序列。

3. 基本思想

堆排序将数组划分为两个逻辑区域:

  • A[0 : heap_size]:尚未排序的最大堆;
  • A[heap_size : n]:已经排好序的后缀。

算法分为两个阶段:

  1. 建堆:将整个数组原地调整为最大堆;
  2. 反复取最大值
    • 堆顶 A[0] 是未排序部分的最大值;
    • 将它与未排序部分最后一个元素交换;
    • 缩小 heap_size,把最大值固定在有序后缀;
    • 对新的堆顶执行下沉,恢复最大堆性质。

其关键操作是 MAX-HEAPIFY,也称为向下调整下沉sift down


4. 具体过程

假设输入数组为:

[4, 10, 3, 5, 1]

4.1 阶段一:建立最大堆

从最后一个非叶结点开始,自底向上执行 MAX-HEAPIFY

原数组:    [4, 10, 3, 5, 1]
最大堆:    [10, 5, 3, 4, 1]

对应的逻辑结构为:

        10
       /  \
      5    3
     / \
    4   1

4.2 阶段二:依次确定最大元素

第一次取出最大值:

交换堆顶和末尾: [1, 5, 3, 4 | 10]
恢复最大堆:     [5, 4, 3, 1 | 10]

第二次取出最大值:

交换:           [1, 4, 3 | 5, 10]
恢复最大堆:     [4, 1, 3 | 5, 10]

第三次取出最大值:

交换:           [3, 1 | 4, 5, 10]

第四次取出最大值:

交换:           [1 | 3, 4, 5, 10]

最终得到:

[1, 3, 4, 5, 10]

其中竖线右侧表示已经排好序、不再属于堆的部分。


5. 伪代码

以下伪代码统一使用 0-based 下标,并将 heap_size 定义为堆中有效元素的数量,因此有效下标范围是 [0, heap_size)

5.1 恢复最大堆性质

MAX-HEAPIFY(A, i, heap_size)
    while true
        left     <- 2 * i + 1
        right    <- 2 * i + 2
        largest  <- i

        if left < heap_size and A[left] > A[largest]
            largest <- left

        if right < heap_size and A[right] > A[largest]
            largest <- right

        if largest = i
            break

        exchange A[i] <-> A[largest]
        i <- largest

前置条件

调用 MAX-HEAPIFY(A, i, heap_size) 时,结点 i 的左右子树应当已经分别满足最大堆性质;只有结点 i 本身可能小于某个孩子。

5.2 建立最大堆

BUILD-MAX-HEAP(A)
    n <- length(A)

    for i <- floor(n / 2) - 1 downto 0
        MAX-HEAPIFY(A, i, n)

5.3 堆排序

HEAP-SORT(A)
    BUILD-MAX-HEAP(A)

    for end <- length(A) - 1 downto 1
        exchange A[0] <-> A[end]
        MAX-HEAPIFY(A, 0, end)

在最后一行中,end 同时表示缩小后的 heap_size,所以已经放到 A[end] 的最大元素不会再次参与堆调整。


6. 正确性说明

6.1 MAX-HEAPIFY 的正确性

在左右子树均为最大堆的前提下:

  1. 比较结点 i、左孩子和右孩子;
  2. 将三者中的最大值交换到结点 i
  3. 若发生交换,只有被交换到下方的结点可能继续违反最大堆性质;
  4. 沿该路径继续下沉,直至到达叶结点或不再违反堆性质。

因此,操作结束后,以 i 为根的子树满足最大堆性质。

6.2 建堆的正确性

BUILD-MAX-HEAP 从最后一个非叶结点向根结点处理。

当处理结点 i 时,它的孩子下标均大于 i,对应子树已经在更早的迭代中调整为最大堆。因此,调用 MAX-HEAPIFY 的前置条件成立。处理完根结点后,整个数组构成最大堆。

6.3 堆排序循环不变式

在每轮循环开始时:

  1. A[0 : end + 1] 构成最大堆;
  2. A[end + 1 : n] 已按非递减顺序排列;
  3. 有序后缀中的每个元素都不小于堆中的任意元素。

将堆顶与 A[end] 交换后,未排序部分的最大值被放到正确位置。随后缩小堆并恢复最大堆性质,因此循环不变式继续成立。

end = 1 的迭代结束后,堆中只剩一个元素,整个数组已经有序。


7. 时空间复杂度

操作时间复杂度说明
访问父结点或孩子结点Θ(1)\Theta(1)通过数组下标直接计算
MAX-HEAPIFYO(logn)O(\log n)最多沿堆高向下移动
BUILD-MAX-HEAPΘ(n)\Theta(n)自底向上建堆
排序阶段Θ(nlogn)\Theta(n\log n)共进行 n1n-1 次取最大值和堆调整
堆排序总时间Θ(nlogn)\Theta(n\log n)最好、平均、最坏情况相同
辅助空间O(1)O(1)使用迭代式下沉,原地交换

7.1 为什么建堆是 Θ(n)\Theta(n) 而不是 Θ(nlogn)\Theta(n\log n)

简单地认为“有 nn 个结点,每个结点调整 O(logn)O(\log n)”只能得到一个较松的上界 O(nlogn)O(n\log n)

事实上,大多数结点位于树的底部,其下沉距离很短。高度为 hh 的结点至多约有 n/2h+1n/2^{h+1} 个,因此建堆总代价满足:

T(n)h=0lognn2h+1O(h)=O(nh=0h2h)=O(n).T(n) \le \sum_{h=0}^{\lfloor\log n\rfloor} \frac{n}{2^{h+1}}O(h) = O\left( n\sum_{h=0}^{\infty}\frac{h}{2^h} \right) =O(n).

同时,建堆至少需要检查线性数量的结点,因此:

T(n)=Θ(n).T(n)=\Theta(n).

7.2 算法性质汇总

性质结论
是否原地排序
是否稳定
是否自适应
是否基于比较
最坏情况保证Θ(nlogn)\Theta(n\log n)
是否需要额外数组
是否依赖输入分布

稳定性

堆排序中的远距离交换可能改变相等关键字的原始相对次序,因此标准堆排序不是稳定排序。例如,带有相同关键字的记录可能在建堆或交换堆顶时跨越彼此。


8. 需要问题具有的性质

要直接应用标准堆排序,问题通常需要满足以下条件。

8.1 元素之间存在一致的比较关系

元素应当支持能够确定排序先后的比较规则。理想情况下,该规则应构成全序;在编程语言的排序接口中,通常至少要求比较器满足严格弱序,否则可能得到不一致结果。

8.2 输入可以被重新排列

标准堆排序通过原地交换元素完成排序,因此输入容器必须允许修改。若原始数据不可变,需要先复制到可变序列中。

8.3 适合随机访问

数组能够在 O(1)O(1) 时间内通过下标访问父结点和孩子结点,因此最适合实现堆排序。

对于链表,定位下标对应元素通常不是 O(1)O(1),会破坏堆排序的效率优势,因此一般不采用数组堆排序。

8.4 不要求稳定排序

若业务要求相同关键字元素保持原始相对顺序,标准堆排序并不适用。可以改用Merge Sort 归并排序等稳定算法,或为元素附加原始下标作为第二关键字,但后者会改变比较对象并增加存储信息。

8.5 希望获得确定的最坏情况上界

堆排序不依赖枢轴选择,其最坏时间复杂度始终为 Θ(nlogn)\Theta(n\log n)。当系统更重视最坏情况保证和低额外空间,而不是缓存局部性或稳定性时,堆排序具有优势。

适用场景

堆排序适用于:元素可比较、数据可原地修改、容器支持随机访问、内存受限,并且需要 O(nlogn)O(n\log n) 最坏情况保证的排序任务。


9. 典型例子

9.1 普通数组升序排序

输入:

[12, 11, 13, 5, 6, 7]

建立最大堆后:

[13, 11, 12, 5, 6, 7]

不断把堆顶移到数组末尾,最终输出:

[5, 6, 7, 11, 12, 13]

9.2 内存受限的比较排序

当数据已经完整存放在数组中,且不能再申请一个 O(n)O(n) 的辅助数组时,堆排序可以用 O(1)O(1) 辅助空间完成排序,并保持 Θ(nlogn)\Theta(n\log n) 的最坏时间复杂度。

9.3 优先队列与堆排序的联系

最大堆还可实现最大优先队列:

  • 查看最大值:O(1)O(1)
  • 取出最大值:O(logn)O(\log n)
  • 插入元素:O(logn)O(\log n)

堆排序可以理解为:先用所有元素建成最大优先队列,再连续执行“取出最大值”,但为了原地排序,每次取出的最大值直接放在数组末尾。

注意区分

“使用堆取出最大的 kk 个元素”不一定需要完整堆排序。若只需要少量极值,可以根据输入规模和 kk 的大小选择大小为 kk 的堆、快速选择或其他顺序统计量算法。


10. 与常见排序算法对比

算法最好时间平均时间最坏时间辅助空间稳定特点
堆排序Θ(nlogn)\Theta(n\log n)Θ(nlogn)\Theta(n\log n)Θ(nlogn)\Theta(n\log n)O(1)O(1)最坏情况有保证,缓存局部性较弱
Quick Sort 快速排序Θ(nlogn)\Theta(n\log n)Θ(nlogn)\Theta(n\log n)Θ(n2)\Theta(n^2)平均 O(logn)O(\log n)实践中通常较快,局部性较好
Merge Sort 归并排序Θ(nlogn)\Theta(n\log n)Θ(nlogn)\Theta(n\log n)Θ(nlogn)\Theta(n\log n)数组实现通常 O(n)O(n)稳定,适合外部排序和链表
Insertion Sort 插入排序Θ(n)\Theta(n)Θ(n2)\Theta(n^2)Θ(n2)\Theta(n^2)O(1)O(1)适合小规模或近乎有序数据

11. 常见错误

错误 1:混用 0-based 与 1-based 公式

0-based 下标中左、右孩子分别是 2*i + 12*i + 2;不能直接使用 1-based 的 2*i2*i + 1

错误 2:交换最大值后没有缩小堆

已经放到数组末尾的最大值必须排除在后续 MAX-HEAPIFY 的有效范围之外。

错误 3:把数组长度与堆大小混为一谈

排序过程中数组长度始终不变,但 heap_size 会逐轮减小。

错误 4:从根结点向下逐个建堆

线性时间建堆应从最后一个非叶结点开始,自底向上调整。若采用逐个插入空堆的方式,建堆时间通常为 O(nlogn)O(n\log n)

错误 5:认为堆内部已经完全有序

最大堆只保证父结点不小于孩子结点,并不保证同层结点或左右子树之间整体有序。


参考资料

  1. Thomas H. Cormen, Charles E. Leiserson, Ronald L. Rivest, Clifford Stein. Introduction to Algorithms, 3rd ed., Chapter 6: Heapsort. MIT Press, 2009.
  2. 二叉堆
  3. 优先队列
  4. 比较排序
  5. Obsidian Flavored Markdown Skill