编码 · 算法
堆排序详解:原理、代码与时间复杂度
简短回答
堆排序是一种比较排序:它先把数组整理成大顶堆(一棵存放在数组中的二叉树,每个父节点都不小于它的子节点),然后反复把最大元素(根)交换到数组末尾,把堆缩小一个元素,并通过让新的根下沉来恢复堆的性质。它在任何情况下都是 O(n log n) 时间,原地排序,只需 O(1) 额外空间,但不稳定,实践中通常比快速排序慢。
打开行排序通俗地说,思路是什么
堆排序是找最大值的方式更快的选择排序。选择排序每次扫描整个未排序部分,代价是 O(n)。堆排序把未排序部分维护成一个堆,所以最大值始终在下标 0,取出它之后恢复顺序只需 O(log n)。
二叉堆存放在一个普通数组中,不需要指针。对下标 i 处的元素:
- 左孩子在
2*i + 1,右孩子在2*i + 2; - 父节点在
(i - 1) // 2; - 在大顶堆中,每个父节点都大于或等于它的子节点,所以最大元素位于下标 0。
- 从最后一个非叶节点开始,一路到下标 0,对每个非叶节点执行下沉,由数组建成大顶堆。
- 把根(最大值)与堆的最后一个元素交换。该元素此时已在最终位置。
- 把堆缩小一个元素,并让新的根下沉,直到重新满足堆的性质。
- 重复第 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() 更简单也更快。