Skip to content

堆排序

O(1) 额外空间的 O(n log n) 排序

堆排序只用 O(1) 额外空间就能保证最坏 O(n log n)——这是快排做不到的。代价是它对 cache 不友好:父子结点在数组中跳跃访问,实际速度常常输给快排的顺序扫描。

408 考研中,堆排序是排序章节的高频考点,尤其是建堆复杂度、稳定性判断以及与快排/归并的横向对比。

核心思想

堆排序分为两个阶段:

  1. 建堆:将无序数组自底向上调整为一个大顶堆(升序排序)
  2. 排序:反复将堆顶最大元素与末尾元素交换,然后对堆顶做一次向下调整(sift-down),堆的规模减 1,直到堆中只剩一个元素

整体思路可以概括为:建堆 → 取顶 → 调整 → 取顶 → 调整 → ... → 有序

交互可视化

通过下方的交互动画,你可以逐步观察堆排序的执行过程:

加载可视化中...

操作详解

堆的概念

堆是一棵完全二叉树,满足以下性质之一:

  • 大顶堆(最大堆):每个结点的值 ≥ 其左右孩子的值,即 A[i] ≥ A[2i+1]A[i] ≥ A[2i+2]
  • 小顶堆(最小堆):每个结点的值 ≤ 其左右孩子的值,即 A[i] ≤ A[2i+1]A[i] ≤ A[2i+2]

下标关系(数组从 0 开始)

关系公式
结点 i 的父结点(i - 1) / 2
结点 i 的左孩子2i + 1
结点 i 的右孩子2i + 2

若数组从 1 开始存储,则父结点为 i/2,左孩子为 2i,右孩子为 2i+1。408 真题中两种编号均有出现,务必注意题目约定。

建堆过程

建堆采用**自底向上的向下调整(sift-down)**策略:从最后一个非叶子结点开始,依次向前对每个结点执行 sift-down。

最后一个非叶子结点的下标为 n/2 - 1(数组从 0 开始)。

c
// 向下调整:将以 i 为根的子树调整为大顶堆
// A[] 为数组,n 为堆的元素个数
void SiftDown(int A[], int n, int i) {
    int largest = i;
    int left = 2 * i + 1;
    int right = 2 * i + 2;
    if (left < n && A[left] > A[largest])
        largest = left;
    if (right < n && A[right] > A[largest])
        largest = right;
    if (largest != i) {
        swap(A[i], A[largest]);
        SiftDown(A, n, largest);  // 继续向下调整
    }
}

// 建堆:从最后一个非叶子结点到根,依次 sift-down
void BuildMaxHeap(int A[], int n) {
    for (int i = n / 2 - 1; i >= 0; i--)
        SiftDown(A, n, i);
}

建堆的时间复杂度为 O(n),不是 O(n log n)。直觉上看似每个结点都做了 O(log n) 的调整,但大部分结点位于树的底层、调整距离很短。通过数学证明(对每层结点数 × 调整高度求和),总操作次数为 O(n)。这是 408 选择题常考的知识点。

⚠️ 易错:建堆的时间复杂度是 O(n),不是 O(n log n)。虽然每次调整是 O(log n),但从最后一个非叶结点到根的调整总和是 O(n)(数学证明可用等比级数)。408 填空题直接考过这个结论。

排序过程

在大顶堆上进行升序排序:

  1. 将堆顶元素 A[0](当前最大值)与堆的最后一个元素 A[n-1] 交换
  2. 堆的规模减 1(最后一个位置已归位)
  3. 对新的堆顶 A[0] 执行 sift-down,恢复堆性质
  4. 重复上述步骤,直到堆中只剩一个元素
c
void HeapSort(int A[], int n) {
    BuildMaxHeap(A, n);           // 第一步:建堆
    for (int i = n - 1; i > 0; i--) {
        swap(A[0], A[i]);         // 堆顶与当前末尾交换
        SiftDown(A, i, 0);       // 对堆顶做向下调整,堆规模为 i
    }
}

堆的插入与删除

插入:将新元素追加到堆的末尾,然后执行向上调整(sift-up),依次与父结点比较并交换,直到满足堆性质。时间复杂度 O(log n)。

c
// 向上调整(大顶堆)
void SiftUp(int A[], int i) {
    while (i > 0) {
        int parent = (i - 1) / 2;
        if (A[i] > A[parent]) {
            swap(A[i], A[parent]);
            i = parent;
        } else {
            break;
        }
    }
}

删除堆顶:将堆顶与最后一个元素交换,堆规模减 1,然后对新堆顶执行 sift-down。时间复杂度 O(log n)。

⭐ 注意:用sift-up 逐个插入的方式建堆,时间复杂度为 O(n log n),不如自底向上 sift-down 建堆的 O(n) 高效。408 真题曾考过两种建堆方式的区别。

复杂度分析

指标复杂度说明
最好时间O(n log n)任何情况下都需要完整的建堆 + 调整过程
最坏时间O(n log n)与输入序列无关
平均时间O(n log n)三种情况均为同一量级
空间复杂度O(1)原地排序,仅需常数级辅助空间
稳定性不稳定堆顶与末尾交换会打乱相同元素的相对顺序

⭐ 堆排序是所有比较类排序中少数在最坏情况下仍能保持 O(n log n) 的算法(快排最坏为 O(n²)),但实际运行中由于缓存不友好,常数因子较大。

⚠️ 易错:堆排序是不稳定排序。反例:{1a, 1b, 2},建大根堆后交换可能颠倒 1a 和 1b 的顺序。

考研高频考点

  • ⭐ 建堆的时间复杂度为 O(n) 而非 O(n log n)(选择题/判断题高频陷阱)
  • ⭐ 堆排序是不稳定排序(简答题对比各排序算法必考)
  • ⭐ 堆排序空间复杂度 O(1),属于原地排序(与归并排序 O(n) 对比)
  • ⭐ 父子结点下标关系(从 0 或从 1 开始两种情况,选择题/填空题常考)
  • ⭐ Top-K 问题:从 n 个元素中选出前 K 大/小,建一个大小为 K 的小顶/大顶堆,时间 O(n log K)(算法设计题常见应用)
  • 在大顶堆中插入/删除一个元素后,画出调整后的堆(手工模拟题)
  • 给定序列判断是否为堆,或画出建堆的中间过程(选择题/填空题)
  • 堆排序 vs 快速排序 vs 归并排序的综合比较(简答题)

相关知识

  • 堆是一种特殊的完全二叉树,理解其性质需要参考树与二叉树的基本概念
  • 堆排序的 O(1) 空间 + O(n log n) 最坏时间与归并排序形成互补:归并稳定但需要 O(n) 空间,堆排序不稳定但原地
  • 堆也可用于实现优先队列,这是 Dijkstra 堆优化的基础
  • 建堆过程中的"下沉调整"与删除堆顶后的调整完全相同——掌握一个就掌握了两个

真题练习

相关真题(9题)

2024Q9选择题2分

堆排序:大根堆连续删除两个最大元素后的堆结构

2023Q10选择题2分

排序稳定性:希尔排序、快速排序、堆排序均不稳定

2022Q42综合题8分

算法设计:从大量数据中求最小的 10 个数(大根堆维护)

2021Q11选择题2分

大根堆构建:逐个插入元素后的堆结构

2020Q9选择题2分

堆的性质:完全二叉树结构与次大值位置的判断

2018Q11选择题2分

大根堆构建:从无序序列建堆的逐步调整过程

2015Q10选择题2分

堆的删除操作:删除指定元素后调整堆需要的比较次数

2011Q11选择题2分

堆的调整操作:删除堆顶后重新调整的过程

2009Q9选择题2分

小根堆插入:插入元素后的向上调整过程