21、堆排序:建堆过程、堆排序算法实现、堆排序的时间复杂度分析

堆排序,说实话,是我个人非常喜欢的一种排序算法。它不像快速排序那样有「最坏情况」的焦虑,也不像归并排序那样需要额外的大块内存。你想想看,它能在原地完成排序,时间复杂度还稳定在 O(n log n),这在工程中其实很讨喜。

我记得刚入行那会儿,有个嵌入式项目需要在资源受限的芯片上做数据排序。快速排序递归太深,容易爆栈;归并排序又吃内存。最后我选了堆排序,稳稳地跑过了压力测试。从那以后,我对堆排序就多了一份信任。

21.1 什么是堆?

堆,本质上是一棵完全二叉树。但它有个特殊约束:每个父节点的值,都大于等于(或小于等于)它的子节点

  • 大顶堆:父节点 ≥ 子节点。堆顶是最大值。
  • 小顶堆:父节点 ≤ 子节点。堆顶是最小值。

堆排序用的是大顶堆。为什么呢?因为每次把堆顶(最大值)换到数组末尾,然后缩小堆的范围,重复操作,就能得到升序序列。说白了,就是「每次挑最大的,放到最后面」。

核心要点:堆排序 = 建堆 + 反复调整堆。

21.2 建堆过程

建堆,就是把一个无序数组调整成堆结构。我习惯从最后一个非叶子节点开始,从下往上做「下沉」操作。

为什么从下往上?因为这样可以保证:当我处理某个节点时,它的左右子树已经是堆了。这样我只需要把这个节点「沉」到合适的位置就行。

最后一个非叶子节点的索引怎么找?对于长度为 n 的数组(索引从 0 开始),最后一个非叶子节点是 n/2 - 1

我的小技巧:建堆时别从上往下做,那样效率低。从下往上,每个节点只下沉一次,时间复杂度 O(n)。

建堆的伪代码逻辑是这样的:

// 从最后一个非叶子节点开始,逐个下沉
for (i = n/2 - 1; i >= 0; i--) {
    heapify(arr, n, i);
}

其中 heapify 就是下沉操作:把当前节点和它的左右子节点比较,如果子节点更大,就交换,然后继续下沉。

21.3 堆排序算法实现

堆排序分两步走:

  1. 建堆:把无序数组建成大顶堆。
  2. 排序:把堆顶元素和末尾元素交换,然后堆大小减 1,再对新的堆顶做下沉调整。重复直到堆大小为 1。

下面是我写的一个完整实现,注释写得比较细,你可以直接拿去用:

#include <stdio.h>

// 下沉操作:维护大顶堆性质
void heapify(int arr[], int n, int i) {
    int largest = i;          // 先假设当前节点最大
    int left = 2 * i + 1;     // 左子节点
    int right = 2 * i + 2;    // 右子节点

    // 如果左子节点更大,更新 largest
    if (left < n && arr[left] > arr[largest])
        largest = left;

    // 如果右子节点更大,更新 largest
    if (right < n && arr[right] > arr[largest])
        largest = right;

    // 如果 largest 不是 i,说明需要交换
    if (largest != i) {
        int temp = arr[i];
        arr[i] = arr[largest];
        arr[largest] = temp;

        // 递归下沉被换下去的节点
        heapify(arr, n, largest);
    }
}

// 堆排序主函数
void heapSort(int arr[], int n) {
    // 第一步:建堆(从下往上)
    for (int i = n / 2 - 1; i >= 0; i--)
        heapify(arr, n, i);

    // 第二步:排序(逐个取出堆顶)
    for (int i = n - 1; i > 0; i--) {
        // 把堆顶(最大值)换到末尾
        int temp = arr[0];
        arr[0] = arr[i];
        arr[i] = temp;

        // 对新的堆顶做下沉,堆大小减 1
        heapify(arr, i, 0);
    }
}

我曾经踩过的坑:写 heapify 时,递归调用忘记传正确的堆大小。如果你在排序阶段传了完整的 n,而不是当前的 i,那已经排好的元素会被重新拉回堆里,排序就全乱了。切记:排序阶段每次 heapify 的堆大小是 i,不是 n

21.4 堆排序的时间复杂度分析

我们来拆开算:

阶段 时间复杂度 说明
建堆 O(n) 从下往上,每个节点下沉次数和它的高度有关,总和是 O(n)
排序 O(n log n) 需要执行 n-1 次交换,每次下沉 O(log n)
总复杂度 O(n log n) 建堆 + 排序,取大头

你可能会问:建堆为什么是 O(n) 而不是 O(n log n)?

嗯,这里有个数学小陷阱。直觉上,每个节点下沉一次是 O(log n),n 个节点就是 O(n log n)。但别忘了,下层节点多但高度小,上层节点少但高度大。算下来,所有节点的下沉次数之和其实是线性的。具体推导你可以去看《算法导论》的摊还分析,这里不展开。

另外,堆排序的 空间复杂度是 O(1),它是在原地排序的。这一点在内存紧张的场景下非常香。

21.5 堆排序 vs 其他排序

我整理了一张对比表,方便你直观感受:

排序算法 平均时间复杂度 最坏时间复杂度 空间复杂度 稳定性
堆排序 O(n log n) O(n log n) O(1) 不稳定
快速排序 O(n log n) O(n²) O(log n) 不稳定
归并排序 O(n log n) O(n log n) O(n) 稳定

堆排序的优点是「稳」——不管数据长什么样,它都是 O(n log n)。缺点是不稳定,而且实际运行中常数比快速排序大一些。所以大部分通用排序库(比如 C 的 qsort)还是用快速排序。但在实时系统或嵌入式场景下,堆排序的确定性更让人放心。

21.6 堆排序的核心流程图

下面这张 SVG 图,把堆排序的完整流程画出来了。从无序数组开始,到建堆,再到排序,一目了然。

堆排序核心流程 无序数组 建堆(大顶堆) 堆顶 ↔ 末尾元素 堆大小减 1 下沉调整新堆顶 循环 堆大小 > 1? 排序完成 堆操作 交换/缩减 判断条件 循环路径

21.7 总结与避坑

堆排序的核心就三句话:

  • 建堆从下往上,时间复杂度 O(n)。
  • 排序阶段每次交换后做一次下沉,共 n-1 次,O(n log n)。
  • 空间复杂度 O(1),原地排序,不稳定。

我的建议:如果你在面试中手写堆排序,记得先写 heapify 函数,再写主流程。把 heapify 写对了,堆排序就完成了一大半。另外,注意索引从 0 开始还是从 1 开始,这个细节很容易搞混。

好了,堆排序就讲到这里。你可以在自己的机器上跑一跑代码,感受一下建堆和排序的过程。下一章我们会聊另一种树形结构——哈夫曼树,它在数据压缩领域非常有用。


公众号:蓝海资料掘金营,微信 deep3321