Heap Sort 堆排序
Heap Sort 堆排序
核心结论
堆排序先把数组组织成一个最大堆,随后反复将堆顶最大元素交换到当前未排序区间的末尾,并缩小堆的有效范围。其最好、平均和最坏时间复杂度均为 ,辅助空间为 ,但通常不稳定。
1. 问题定义
给定一个长度为 的可比较元素序列:
将其重新排列为非递减序列:
堆排序属于比较排序,主要利用二叉堆维护当前未排序元素中的最大值。
2. 前置知识:数组表示的二叉堆
2.1 完全二叉树
二叉堆在逻辑上是一棵完全二叉树,在物理上通常直接存储于数组中,因此不需要显式保存结点指针。
对于 0-based 数组下标,结点 的相关位置为:
叶结点范围
在长度为 的堆中,最后一个非叶结点的下标为
因此建堆时只需要从该结点开始向前执行下沉操作。
2.2 最大堆性质
最大堆要求每个结点的值都不小于其孩子结点:
因此,最大堆的根结点 一定是当前堆中的最大元素。
升序与降序
- 使用最大堆,最终得到升序序列。
- 使用最小堆,最终得到降序序列。
3. 基本思想
堆排序将数组划分为两个逻辑区域:
A[0 : heap_size]:尚未排序的最大堆;A[heap_size : n]:已经排好序的后缀。
算法分为两个阶段:
- 建堆:将整个数组原地调整为最大堆;
- 反复取最大值:
- 堆顶
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 的正确性
在左右子树均为最大堆的前提下:
- 比较结点
i、左孩子和右孩子; - 将三者中的最大值交换到结点
i; - 若发生交换,只有被交换到下方的结点可能继续违反最大堆性质;
- 沿该路径继续下沉,直至到达叶结点或不再违反堆性质。
因此,操作结束后,以 i 为根的子树满足最大堆性质。
6.2 建堆的正确性
BUILD-MAX-HEAP 从最后一个非叶结点向根结点处理。
当处理结点 i 时,它的孩子下标均大于 i,对应子树已经在更早的迭代中调整为最大堆。因此,调用 MAX-HEAPIFY 的前置条件成立。处理完根结点后,整个数组构成最大堆。
6.3 堆排序循环不变式
在每轮循环开始时:
A[0 : end + 1]构成最大堆;A[end + 1 : n]已按非递减顺序排列;- 有序后缀中的每个元素都不小于堆中的任意元素。
将堆顶与 A[end] 交换后,未排序部分的最大值被放到正确位置。随后缩小堆并恢复最大堆性质,因此循环不变式继续成立。
当 end = 1 的迭代结束后,堆中只剩一个元素,整个数组已经有序。
7. 时空间复杂度
| 操作 | 时间复杂度 | 说明 |
|---|---|---|
| 访问父结点或孩子结点 | 通过数组下标直接计算 | |
MAX-HEAPIFY | 最多沿堆高向下移动 | |
BUILD-MAX-HEAP | 自底向上建堆 | |
| 排序阶段 | 共进行 次取最大值和堆调整 | |
| 堆排序总时间 | 最好、平均、最坏情况相同 | |
| 辅助空间 | 使用迭代式下沉,原地交换 |
7.1 为什么建堆是 而不是
简单地认为“有 个结点,每个结点调整 ”只能得到一个较松的上界 。
事实上,大多数结点位于树的底部,其下沉距离很短。高度为 的结点至多约有 个,因此建堆总代价满足:
同时,建堆至少需要检查线性数量的结点,因此:
7.2 算法性质汇总
| 性质 | 结论 |
|---|---|
| 是否原地排序 | 是 |
| 是否稳定 | 否 |
| 是否自适应 | 否 |
| 是否基于比较 | 是 |
| 最坏情况保证 | |
| 是否需要额外数组 | 否 |
| 是否依赖输入分布 | 否 |
稳定性
堆排序中的远距离交换可能改变相等关键字的原始相对次序,因此标准堆排序不是稳定排序。例如,带有相同关键字的记录可能在建堆或交换堆顶时跨越彼此。
8. 需要问题具有的性质
要直接应用标准堆排序,问题通常需要满足以下条件。
8.1 元素之间存在一致的比较关系
元素应当支持能够确定排序先后的比较规则。理想情况下,该规则应构成全序;在编程语言的排序接口中,通常至少要求比较器满足严格弱序,否则可能得到不一致结果。
8.2 输入可以被重新排列
标准堆排序通过原地交换元素完成排序,因此输入容器必须允许修改。若原始数据不可变,需要先复制到可变序列中。
8.3 适合随机访问
数组能够在 时间内通过下标访问父结点和孩子结点,因此最适合实现堆排序。
对于链表,定位下标对应元素通常不是 ,会破坏堆排序的效率优势,因此一般不采用数组堆排序。
8.4 不要求稳定排序
若业务要求相同关键字元素保持原始相对顺序,标准堆排序并不适用。可以改用Merge Sort 归并排序等稳定算法,或为元素附加原始下标作为第二关键字,但后者会改变比较对象并增加存储信息。
8.5 希望获得确定的最坏情况上界
堆排序不依赖枢轴选择,其最坏时间复杂度始终为 。当系统更重视最坏情况保证和低额外空间,而不是缓存局部性或稳定性时,堆排序具有优势。
适用场景
堆排序适用于:元素可比较、数据可原地修改、容器支持随机访问、内存受限,并且需要 最坏情况保证的排序任务。
9. 典型例子
9.1 普通数组升序排序
输入:
[12, 11, 13, 5, 6, 7]
建立最大堆后:
[13, 11, 12, 5, 6, 7]
不断把堆顶移到数组末尾,最终输出:
[5, 6, 7, 11, 12, 13]
9.2 内存受限的比较排序
当数据已经完整存放在数组中,且不能再申请一个 的辅助数组时,堆排序可以用 辅助空间完成排序,并保持 的最坏时间复杂度。
9.3 优先队列与堆排序的联系
最大堆还可实现最大优先队列:
- 查看最大值:;
- 取出最大值:;
- 插入元素:。
堆排序可以理解为:先用所有元素建成最大优先队列,再连续执行“取出最大值”,但为了原地排序,每次取出的最大值直接放在数组末尾。
注意区分
“使用堆取出最大的 个元素”不一定需要完整堆排序。若只需要少量极值,可以根据输入规模和 的大小选择大小为 的堆、快速选择或其他顺序统计量算法。
10. 与常见排序算法对比
| 算法 | 最好时间 | 平均时间 | 最坏时间 | 辅助空间 | 稳定 | 特点 |
|---|---|---|---|---|---|---|
| 堆排序 | 否 | 最坏情况有保证,缓存局部性较弱 | ||||
| Quick Sort 快速排序 | 平均 | 否 | 实践中通常较快,局部性较好 | |||
| Merge Sort 归并排序 | 数组实现通常 | 是 | 稳定,适合外部排序和链表 | |||
| Insertion Sort 插入排序 | 是 | 适合小规模或近乎有序数据 |
11. 常见错误
错误 1:混用 0-based 与 1-based 公式
0-based 下标中左、右孩子分别是
2*i + 1和2*i + 2;不能直接使用 1-based 的2*i和2*i + 1。
错误 2:交换最大值后没有缩小堆
已经放到数组末尾的最大值必须排除在后续
MAX-HEAPIFY的有效范围之外。
错误 3:把数组长度与堆大小混为一谈
排序过程中数组长度始终不变,但
heap_size会逐轮减小。
错误 4:从根结点向下逐个建堆
线性时间建堆应从最后一个非叶结点开始,自底向上调整。若采用逐个插入空堆的方式,建堆时间通常为 。
错误 5:认为堆内部已经完全有序
最大堆只保证父结点不小于孩子结点,并不保证同层结点或左右子树之间整体有序。
参考资料
- Thomas H. Cormen, Charles E. Leiserson, Ronald L. Rivest, Clifford Stein. Introduction to Algorithms, 3rd ed., Chapter 6: Heapsort. MIT Press, 2009.
- 二叉堆
- 优先队列
- 比较排序
- Obsidian Flavored Markdown Skill