Skip to content

3.4 堆排序

这一节,你能解决什么问题

学完这一节,你能够:

  1. 理解堆的结构和堆序性质,知道为什么"父节点 ≥ 子节点"就够了
  2. 分析堆排序的复杂度,理解"建堆为什么是 Θ(n)"这个反直觉结论
  3. 判断什么时候该用堆排序,理解"原地 + 最坏保证"的独特价值
  4. 审查 agent 给出的堆实现,发现隐藏的 O(n log n) 假设错误

问题情境

某嵌入式系统团队接到一个棘手的需求:

  • 内存只有 2KB
  • 需要排序功能
  • 不能接受系统崩溃

工程师小王有两个备选方案:

方案优点缺点
快排实践中快最坏 Θ(n²),可能超时崩溃
归并排序稳定 Θ(n log n)需要 Θ(n) 额外空间,内存撑不住

小王皱着眉头:"就没有两全其美的方案吗?"

答案就在堆排序。

堆排序的独特组合

  • 原地排序:Θ(1) 额外空间
  • 最坏保证:Θ(n log n),没有快排的 Θ(n²) 风险

问题:堆排序是怎么做到的?代价是什么?


直观思路

先理解"堆"是什么。

堆是一个数组,但可以看作一棵完全二叉树。

完全二叉树:除最后一层外,每层都是满的;最后一层从左到右填充。

数组:[16, 14, 10, 8, 7, 9, 3, 2, 4]

树形表示:
             16           ← 第 0 层
           /    \
         14      10        ← 第 1 层
        /  \    /  \
       8    7  9    3      ← 第 2 层
      / \
     2   4                 ← 第 3 层

为什么用数组表示树?

完全二叉树的结构规律,可以用数学公式计算父子关系:

text
父节点:parent(i) = (i - 1) // 2
左孩子:left(i) = 2 × i + 1
右孩子:right(i) = 2 × i + 2

这样,不需要指针,节省空间。

堆序性质:每个父节点 ≥ 所有子节点。

这个性质保证:最大值在根节点(索引 0)。

直觉:堆就像一个金字塔,越大越在上面。拿走最大的,剩下的自动调整。


规范定义

最大堆

结构性质:完全二叉树,用数组紧凑表示

堆序性质:每个节点 ≥ 其子节点

关键洞察:堆序性质只要求"父 ≥ 子",不要求"左 > 右"或"完全有序"。

为什么这样定义?

因为堆只关心一件事:快速找到最大值。堆序性质保证最大值在根节点。

至于左右孩子的相对大小,不需要维护。这种"最小必要约束"的思想,是高效数据结构设计的关键。


算法实现

堆排序分为两个阶段:

阶段一:建堆

把乱序数组变成堆。

python
BUILD-MAX-HEAP(A):
    for i = len(A)//2 down to 0:
        MAX-HEAPIFY(A, i)

为什么从 len(A)//2 开始?

因为索引 ≥ len(A)//2 都是叶子节点,天然满足堆序性质。

阶段二:排序

python
HEAPSORT(A):
    BUILD-MAX-HEAP(A)               # 建堆 Θ(n)
    for i = len(A)-1 down to 1:     # n-1 次循环
        swap A[0] and A[i]          # 最大放末尾
        MAX-HEAPIFY(A[0..i-1], 0)   # 维护堆性质 Θ(log i)

MAX-HEAPIFY:让节点"下沉"

假设一棵树,根节点违反堆序性质(比子节点小),但两棵子树都是合法的堆。

怎么修复?让根节点"下沉"到正确位置。

python
MAX-HEAPIFY(A, i):
    l = left(i)
    r = right(i)
    largest = i
    
    if l < len(A) and A[l] > A[largest]:
        largest = l
    if r < len(A) and A[r] > A[largest]:
        largest = r
    
    if largest != i:
        swap A[i] and A[largest]
        MAX-HEAPIFY(A, largest)     # 递归下沉

一步步执行

问题:
        4              ← 违反:4 < 14 和 4 < 10
       / \
      14  10           ← 子树都是合法堆

Step 1:比较 4 和子节点 14、10,14 最大
        交换 4 和 14
        
        14
       /  \
      4    10         ← 4 下沉到位置 1

Step 2:位置 1 的子树可能违反
        比较 4 和子节点 8、7,8 最大
        交换 4 和 8

        14
       /  \
      8    10
     / \
    4   7              ← 4 下沉到位置 3

Step 3:位置 3 是叶子节点,无需继续

结果:合法的最大堆

这个方法是怎么想到的

你可能会问:建堆为什么是 Θ(n),不是 Θ(n log n)

这是算法分析中的经典反直觉结论。

错误的分析

很多初学者这样分析:

MAX-HEAPIFY 是 Θ(log n)
调用 n/2 次
总时间 = Θ(n log n)  ← 错!

错在哪里?

错在假设每次 MAX-HEAPIFY 都是 Θ(log n)。

实际上,大部分节点的高度很小!

正确的分析

关键洞察:高度不同的节点,数量不同。

高度为 h 的节点数量 ≤ ⌈n / 2^{h+1}⌉

为什么?
- 高度 0(叶子节点):约 n/2 个
- 高度 1:约 n/4 个
- 高度 2:约 n/8 个
- ...
- 高度 log n:最多 1 个(根节点)

精确计算

总时间 = Σ_{h=0}^{⌊log n⌋} (高度 h 的节点数) × Θ(h)
       ≤ Σ_{h=0}^{⌊log n⌋} ⌈n/2^{h+1}⌉ × c×h
       ≤ c×n × Σ_{h=0}^{∞} h / 2^{h+1}
       = c×n × 2        (数学级数求和)
       = Θ(n)

怎么想到的?

思考过程

  1. 朴素想法:每个节点都可能下沉到最底层,Θ(log n)
  2. 发现问题:叶子节点根本不下沉!
  3. 更精确:不同高度的节点,下沉深度不同
  4. 计算:数量 × 高度的加权和
  5. 数学级数:Σ h/2^h = 2

洞察:不要用最坏情况乘以次数。要分析不同层次的开销。


正确性分析

MAX-HEAPIFY 的正确性

循环不变式:每次递归调用前:

  • A[i] 和子节点中最大的交换
  • 交换后,子树满足堆序性质

证明

比较三个节点(当前 + 左右孩子),找到最大的,交换。

交换后,当前位置满足堆序性质。如果子节点位置违反,递归处理。

终止条件:到达叶子节点,或当前节点已经是最大的。

BUILD-MAX-HEAP 的正确性

循环不变式:每次迭代前,A[i+1..n-1] 都满足堆序性质。

证明

从叶子节点开始(天然满足),自底向上处理。

每次处理一个节点后,以它为根的子树满足堆序性质。

终止时,整个树满足堆序性质。

HEAPSORT 的正确性

循环不变式:每次迭代前:

  • A[i..n-1] 已排序(最大的 n-i 个元素)
  • A[0..i-1] 是堆,包含剩余 i 个元素

证明

每次交换堆顶(最大)和末尾,然后维护堆性质。

交换后,A[i] 是剩余元素中最大的。

heapify 后,A[0..i-1] 仍然是堆。

终止时,整个数组已排序。


复杂度分析

建堆

Θ(n)。前面已经详细分析。

排序循环

Σ_{i=1}^{n-1} Θ(log i) = Θ(n log n)

总复杂度

Θ(n) + Θ(n log n) = Θ(n log n)

空间复杂度

Θ(1)(只用了几个变量)


什么时候不该用这个方法

该用的情况

  1. 内存紧张(Θ(1) 额外空间)
  2. 需要最坏保证(不能接受 Θ(n²))
  3. 实时系统(必须保证时间上限)
  4. 嵌入式设备(资源受限)

不该用的情况

  1. 一般场景(快排实践中更快,缓存友好)
  2. 需要稳定性(堆排序不稳定)
  3. 小数据(Θ(n log n) 的 overhead 不值得)
  4. 缓存敏感场景(堆排序访问模式对缓存不友好)

与其他排序对比

特性堆排序快排归并排序
最坏时间Θ(n log n)Θ(n²)Θ(n log n)
期望时间Θ(n log n)Θ(n log n)Θ(n log n)
额外空间Θ(1)Θ(log n)Θ(n)
稳定性不稳定不稳定稳定
缓存友好不友好友好中等

选择指南

  • 堆排序:内存受限 + 不能接受 Θ(n²) 最坏
  • 快排:一般场景(实践中最快)
  • 归并:大数据 + 需要稳定 + 有额外空间

本节小结

解决了什么问题?

  • 原地排序 + 最坏 Θ(n log n) 保证
  • 在"内存紧张 + 不能崩溃"的组合场景,堆排序是独特选择

核心方法是什么?

  • 堆结构:完全二叉树 + 堆序性质
  • 下沉操作:MAX-HEAPIFY 让节点下沉到正确位置
  • 建堆策略:自底向上,利用大部分节点高度小的事实

为什么正确?

  • 循环不变式证明三个操作
  • 堆序性质保证最大值在堆顶
  • 每次交换 + heapify 维护堆性质

代价是什么?

  • 时间:Θ(n log n)(建堆 Θ(n),排序 Θ(n log n))
  • 空间:Θ(1)
  • 不稳定,缓存不友好

适用场景?

内存受限、需要最坏保证。不适合一般场景(快排更快)。

能否自己使用?

能。关键是理解:

  • 堆序性质是"最小必要约束"
  • 建堆 Θ(n) 来自"数量 × 高度"加权和
  • 堆排序在"原地 + 最坏保证"组合上独特

练习

基本理解

练习 1:对数组 [3, 1, 4, 1, 5, 9, 2, 6],画出完全二叉树结构,标注每个节点的索引和值。

练习 2:计算数组 [3, 1, 4, 1, 5, 9, 2, 6] 的各高度节点数量,验证建堆 Θ(n) 的计算。

练习 3:对 [5, 3, 17, 10, 84, 19, 6, 22, 9],完整执行堆排序,记录每次交换和 heapify。

方法应用

练习 4:写出 MAX-HEAPIFY 的循环不变式,并完整证明三步。

练习 5:用最小堆实现堆排序。分析差异:需要哪些修改?

错误诊断

练习 6:以下建堆实现有什么问题?

python
def build_heap_bug(A):
    for i in range(len(A)):     # ← 这里
        heapify(A, i)

分析:为什么这样是 Θ(n log n)?应该怎么改?

练习 7:为什么堆排序不稳定?给出一个反例。

方案比较

练习 8:分析三种 Θ(n log n) 排序的适用场景:

场景应该用哪个?理由
内存 2KB,不能崩溃
大数据,需要稳定
一般 Web 服务
实时系统,必须保证时间

开放设计

练习 9:Top-k 问题。

设计一个算法,从 n 个元素中选出最大的 k 个。比较两种方案:

  1. 排序后取前 k:Θ(n log n)
  2. 维护 k 大小的最小堆:Θ(n log k)

分析:k << n 时,哪个更好?

练习 10:建堆 Θ(n) 验证。

让 agent 写两种建堆实现:

  1. 自顶向下(逐个插入):Θ(n log n)
  2. 自底向上:Θ(n)

对比实际运行时间,验证理论分析。


上一节:3.3 分治排序

下一节:3.5 线性时间排序

新时代的算法课程