cv.cm

编码 · 算法

堆排序详解:原理、代码与时间复杂度

阅读约 7 分钟

简短回答

堆排序是一种比较排序:它先把数组整理成大顶堆(一棵存放在数组中的二叉树,每个父节点都不小于它的子节点),然后反复把最大元素(根)交换到数组末尾,把堆缩小一个元素,并通过让新的根下沉来恢复堆的性质。它在任何情况下都是 O(n log n) 时间,原地排序,只需 O(1) 额外空间,但不稳定,实践中通常比快速排序慢。

打开行排序

通俗地说,思路是什么

堆排序是找最大值的方式更快的选择排序。选择排序每次扫描整个未排序部分,代价是 O(n)。堆排序把未排序部分维护成一个堆,所以最大值始终在下标 0,取出它之后恢复顺序只需 O(log n)。

二叉堆存放在一个普通数组中,不需要指针。对下标 i 处的元素:

  • 左孩子在 2*i + 1,右孩子在 2*i + 2;
  • 父节点在 (i - 1) // 2;
  • 在大顶堆中,每个父节点都大于或等于它的子节点,所以最大元素位于下标 0。
  1. 从最后一个非叶节点开始,一路到下标 0,对每个非叶节点执行下沉,由数组建成大顶堆。
  2. 把根(最大值)与堆的最后一个元素交换。该元素此时已在最终位置。
  3. 把堆缩小一个元素,并让新的根下沉,直到重新满足堆的性质。
  4. 重复第 2、3 步,直到堆中只剩一个元素。

逐步演示

对 [4, 10, 3, 5, 1] 排序。竖线之后的元素已有序且已确定。

步骤操作数组
开始[4, 10, 3, 5, 1]
建堆 1对下标 1(10)下沉:比子节点 5 和 1 都大,无需改动[4, 10, 3, 5, 1]
建堆 2对下标 0(4)下沉:先与 10 交换,再与 5 交换[10, 5, 3, 4, 1] 大顶堆
取出 1把 10 交换到末尾,对 1 下沉:先与 5 交换,再与 4 交换[5, 4, 3, 1 | 10]
取出 2把 5 交换到末尾,对 1 下沉:与 4 交换[4, 1, 3 | 5, 10]
取出 3把 4 交换到末尾,对 3 下沉:没有更大的子节点[3, 1 | 4, 5, 10]
取出 4把 3 交换到末尾[1 | 3, 4, 5, 10] 已有序

Python 实现

def sift_down(a, root, end):
    """Restore the max-heap property for a[root..end-1]."""
    while True:
        child = 2 * root + 1
        if child >= end:
            return
        if child + 1 < end and a[child + 1] > a[child]:
            child += 1                      # pick the larger child
        if a[root] >= a[child]:
            return
        a[root], a[child] = a[child], a[root]
        root = child

def heapsort(a):
    """Sort list a in place, ascending."""
    n = len(a)
    for i in range(n // 2 - 1, -1, -1):     # build the max-heap
        sift_down(a, i, n)
    for end in range(n - 1, 0, -1):
        a[0], a[end] = a[end], a[0]         # move the max to the end
        sift_down(a, 0, end)
    return a

print(heapsort([4, 10, 3, 5, 1]))   # [1, 3, 4, 5, 10]
print(heapsort([2, 2, 1]))          # [1, 2, 2]

Python 的 heapq 模块在普通列表上提供小顶堆。依次弹出所有元素就能得到有序列表,这是一个简短的(非原地的)堆排序:

import heapq

def heapsort_heapq(items):
    h = list(items)
    heapq.heapify(h)                       # O(n)
    return [heapq.heappop(h) for _ in range(len(h))]

print(heapsort_heapq([4, 10, 3, 5, 1]))     # [1, 3, 4, 5, 10]
print(heapq.nlargest(2, [4, 10, 3, 5, 1]))  # [10, 5]

时间与空间复杂度

情况时间原因
建堆O(n)大多数节点靠近底部,下沉的距离很短
最好O(n log n)n − 1 次取出,每次下沉最多 log₂ n 层(键互不相同)
平均O(n log n)无论顺序如何,工作量相同
最坏O(n log n)树的高度始终约为 log₂ n
额外空间O(1)堆就在输入数组内部,下沉是循环实现的

自底向上建堆是 O(n),而不是 O(n log n),因为完全二叉树中各节点高度之和是 O(n)。总耗时主要由取出阶段决定。

堆排序与其他排序算法的比较

算法最好平均最坏额外空间稳定
堆排序O(n log n)O(n log n)O(n log n)O(1)否
快速排序O(n log n)O(n log n)O(n²)O(log n)否
归并排序O(n log n)O(n log n)O(n log n)O(n)是
选择排序O(n²)O(n²)O(n²)O(1)否
插入排序O(n)O(n²)O(n²)O(1)是
Timsort(Python 的 sorted)O(n)O(n log n)O(n log n)O(n)是

堆排序不稳定:把根交换到末尾,可能让一个元素越过键相同的其他元素。它的主要优势是同时具备 O(n log n) 的最坏情况保证和 O(1) 的额外空间,这是快速排序和归并排序都做不到的组合。它的弱点在于内存访问:在下标 i 与 2*i + 1 之间来回跳转,对 CPU 缓存很不友好,因此通常比实现良好的快速排序慢。Introsort 在快速排序递归过深时,会用堆排序作为后备。

变体与常见错误

  • 部分排序:只想得到最大的 k 个元素时,建好堆后取出 k 次,复杂度为 O(n + k log n)。Python 的 heapq.nlargest 和 heapq.nsmallest 就是这么做的。
  • 数据流中的 Top-k:维护一个大小为 k 的小顶堆,对每个新元素入堆或替换,时间 O(n log k),内存 O(k)。
  • 自底向上堆排序先把空位一路下沉到叶子,再向上调整,可以节省比较次数。
  • Smoothsort 是 Dijkstra 提出的基于堆的变体,对已有序的输入可达 O(n),但因为实现复杂而很少使用。

何时使用堆排序

当你需要有保证的 O(n log n) 最坏情况,同时不能使用额外内存时,例如在嵌入式或实时系统中,或者作为另一种排序内部的安全后备,就用堆排序。在日常 Python 代码中请用 sorted(),它是稳定的,在典型数据上更快。堆这种数据结构本身比排序更有用:优先队列、Dijkstra 最短路径、调度器、合并 k 个有序列表(heapq.merge)以及求最大的 k 个值,都建立在它之上。面试常考堆的下标公式、为什么建堆是 O(n),以及 Top-k 问题。

常见问题

堆排序稳定吗?

不稳定。把根移到末尾并让元素下沉,可能改变相同键之间的相对顺序。

堆排序的时间复杂度是多少?

最好、平均和最坏情况下都是 O(n log n)。建堆是 O(n),之后 n − 1 次取出,每次代价为 O(log n)。

堆排序是原地排序吗?

是。堆存放在被排序的数组内部,已排序部分从末尾开始增长,所以只需要 O(1) 额外空间。

堆排序为什么在实践中比快速排序慢?

两者平均都是 O(n log n),但堆排序会在相距很远的数组位置之间跳转,造成更多缓存未命中,而且每个元素的比较次数比快速排序更多。

如何在 Python 中用 heapq 排序?

先调用 heapq.heapify(list),再反复调用 heapq.heappop;要取最小的 k 个,则用 heapq.nsmallest(k, items)。如果是完整排序,sorted() 更简单也更快。

全部编码教程