目录

想象一下比赛颁奖的流程。

先把所有选手的成绩建成一个排行榜(建堆),然后反复做一件事,把第一名请到领奖台(交换到末尾),剩下的人重新调整排名(堆化),再请新的第一名… 直到所有人都领完奖。

堆排序(Heap Sort) 利用的就是这个原理。

先把数组建成一个大顶堆(父节点 >= 子节点),然后把堆顶(当前最大值)交换到数组末尾,缩小堆范围重新堆化,重复直到堆中只剩一个元素。

它是 J.W.J. Williams 在 1964 年发明的,也是唯一一个同时达到 O(n log n) 和 O(1) 空间的比较类排序算法。


它是什么

核心思想 先建一个能快速找到最大值的结构,然后不断摘除最大值。

堆排序的关键数据结构是二叉堆(Binary Heap),一种用数组模拟的完全二叉树。

二叉堆的性质

对于下标从 0 开始的数组:

  • 父节点下标 i 的左孩子 = 2i + 1
  • 父节点下标 i 的右孩子 = 2i + 2
  • 孩子节点下标 j 的父节点 = (j - 1) / 2

大顶堆性质:每个节点的值 >= 它的左右孩子的值。因此根节点(下标 0)一定是整个堆的最大值。

伪代码

// 仅为示意
function heapify(arr, n, root):
    largest = root
    left = 2 * root + 1
    right = 2 * root + 2
    if left < n and arr[left] > arr[largest]:
        largest = left
    if right < n and arr[right] > arr[largest]:
        largest = right
    if largest != root:
        swap(arr[root], arr[largest])
        heapify(arr, n, largest)   // 递归堆化受影响的子树

function heapSort(arr, n):
    // 第一步:建堆
    for i = n/2 - 1 downto 0:
        heapify(arr, n, i)
    // 第二步:逐次弹出
    for i = n-1 downto 1:
        swap(arr[0], arr[i])      // 最大值到末尾
        heapify(arr, i, 0)        // 重新堆化

下面用流程图拆解这段伪代码的建堆、堆化和排序主循环。

工作原理流程图

对应伪代码的建堆过程(从最后一个非叶子节点开始自底向上):

flowchart LR
    A(["开始建堆"]) --> B["i = n/2 - 1<br/>最后一个非叶子节点"]
    B --> C{"i >= 0 ?"}
    C -->|否| D(["建堆完成"])
    C -->|是| E["heapify(arr, n, i)<br/>以 i 为根堆化"]
    E --> F["--i"]
    F --> C

堆化(sift down,下沉操作):

flowchart TB
    subgraph 堆化[" "]
        direction TB
        A1(["以 root 为根"]) --> B1{"左孩子存在 且 左 > largest ?"}
        B1 -->|是| C1["largest = 左"]
        B1 -->|否| D1{"右孩子存在 且 右 > largest ?"}
        C1 --> D1
        D1 -->|是| E1["largest = 右"]
        D1 -->|否| F1["largest 不变"]
        E1 --> G1{"largest != root ?"}
        F1 --> G1
        G1 -->|是| H1["交换 root 与 largest<br/>递归堆化被影响的子树"]
        H1 --> I1(["完成"])
        G1 -->|否| I1
    end

排序主循环:

flowchart LR
    A(["建堆完成"]) --> B["i = n - 1"]
    B --> C{"i > 0 ?"}
    C -->|否| Z(["排序完成"])
    C -->|是| D["交换 arr[0] 与 arr[i]<br/>最大值落位到 i"]
    D --> E["heapify(arr, i, 0)<br/>对缩小后的堆重新堆化"]
    E --> F["--i"]
    F --> C

逐趟演示

用 [9, 4, 7, 1, 3, 6, 8, 2, 5](n=9)跑一遍:

初始数组: [9, 4, 7, 1, 3, 6, 8, 2, 5]

--- 第一步: 建堆 ---
建堆完成(大顶堆): [9, 5, 8, 4, 3, 6, 7, 2, 1]
  (此时根节点 9 是最大值)

--- 第二步: 逐次弹出 ---
弹出 9 到位置 8, 堆=[8,5,7,4,3,6,1,2] 已排=[9]
弹出 8 到位置 7, 堆=[7,5,6,4,3,2,1]   已排=[8,9]
弹出 7 到位置 6, 堆=[6,5,2,4,3,1]     已排=[7,8,9]
弹出 6 到位置 5, 堆=[5,4,2,1,3]       已排=[6,7,8,9]
弹出 5 到位置 4, 堆=[4,3,2,1]         已排=[5,6,7,8,9]
弹出 4 到位置 3, 堆=[3,1,2]           已排=[4,5,6,7,8,9]
弹出 3 到位置 2, 堆=[2,1]             已排=[3,4,5,6,7,8,9]
弹出 2 到位置 1, 堆=[1]               已排=[2,3,4,5,6,7,8,9]

最终结果: [1, 2, 3, 4, 5, 6, 7, 8, 9]
总比较: 31 | 总交换: 21

每轮把当前最大值从堆顶”弹”到数组末尾,堆的范围缩小一位。

整个过程不需要任何额外数组。


能解决什么问题

适用场景

场景为什么适合
空间严格受限O(1) 额外空间,不像归并要 O(n)
需要保证 O(n log n) 上界不会像快排那样退化到 O(n²)
实时系统/嵌入式无递归(可写成迭代版本)、性能可预测
求 Top K 问题只需建堆 + 弹出 K 次,不需要全排序

局限性

局限说明
不稳定堆化时的长距离交换破坏相等元素的顺序
常数因子较大实际速度通常不如快排(尤其是缓存不友好)
不是自适应的对近有序的数据没有优化,始终 O(n log n)

与快排/归并的横向对比

特征快速排序归并排序堆排序
平均时间O(n log n)O(n log n)O(n log n)
最坏时间O(n²)O(n log n)O(n log n)
空间O(log n) 栈O(n) 数组O(1)
稳定否是否
缓存友好高中低(跳跃访问)
实际速度最快中等较慢

堆排序的独特价值:O(n log n) + O(1) 空间 这个组合只有它能做到(在比较类排序中)。


C++ 实现与复杂度分析

通用 C++ 模板实现 堆排序只依赖比较运算符 >,适用于任意可比较类型:

#include <utility>   // std::swap
#include <vector>

template <typename T>
void heapify(std::vector<T>& arr, int n, int root) {
    int largest = root;
    int left  = 2 * root + 1;
    int right = 2 * root + 2;

    if (left < n && arr[left] > arr[largest])
        largest = left;
    if (right < n && arr[right] > arr[largest])
        largest = right;

    if (largest != root) {
        std::swap(arr[root], arr[largest]);
        heapify(arr, n, largest);
    }
}

template <typename T>
void heapSort(std::vector<T>& arr) {
    int n = static_cast<int>(arr.size());
    if (n <= 1) return;

    // 建大顶堆
    for (int i = n / 2 - 1; i >= 0; --i)
        heapify(arr, n, i);

    // 逐次弹出堆顶到末尾
    for (int i = n - 1; i > 0; --i) {
        std::swap(arr[0], arr[i]);
        heapify(arr, i, 0);
    }
}

时间复杂度怎么算

建堆的时间复杂度:O(n)

这不是显而易见的。

虽然单次 heapify 是 O(log n),但不同层的节点数不同:

  • 底层节点多但 heapify 路径短(几乎不做事)
  • 顶层节点少但路径长

精确计算:∑(n/2^(h+1)) × O(h) = O(n)。

这是一个经典的结论:建堆是线性时间。

排序阶段的时间复杂度:O(n log n)

循环 n-1 次,每次 heapify 最多 O(log n)(堆的高度),总计: O(n log n)。

由于建堆 O(n) 被 O(n log n) 吞没,总复杂度 = O(n log n)。

而且这个复杂度是固定的。

不管输入是什么分布,堆排序都是 O(n log n)。

程序验证:

输入比较交换
一般乱序(9 个元素)3121
已有序(5 个元素)1210
情况时间复杂度
最好O(n log n)
最坏O(n log n)
平均O(n log n)

空间复杂度怎么算

如果 heapify 用递归实现,栈空间 O(log n)。

改成迭代版本后,只用几个标量变量 → O(1)。

小结算法:时间 O(n log n)(三档一致)、空间 O(1)、不稳定、原地、最坏情况仍保证 O(n log n)。


总结

  • 是什么:先建大顶堆(线性时间),再反复将堆顶(最大值)交换到末尾并重新堆化。
  • 解决什么 / 用在哪:空间受限场景的 O(n log n) 排序,Top K 问题,嵌入式/实时系统的可靠选择。
  • 局限:不稳定,缓存不友好导致实际速度不如快排,对近有序数据没有优化。
  • 复杂度怎么算:建堆 O(n)(精确推导,非直觉的线性),排序阶段 n 轮 × 每次 O(log n) 堆化 = O(n log n)。三档一致,不会退化。