基础堆排序(详细教程)

基础堆排序:从零理解堆结构与排序逻辑

在算法世界里,排序是程序员每天都在面对的基础操作。虽然我们常用语言自带的排序函数,比如 Java 的 Arrays.sort() 或 Python 的 sorted(),但真正理解底层原理,才能在复杂场景中游刃有余。今天,我们就来深入聊聊一种高效且结构精巧的排序算法——基础堆排序。

堆排序不是最常用的排序方式,但它在时间复杂度上表现稳定(O(n log n)),且不需要额外的递归栈空间,特别适合对性能要求高、内存受限的场景。更重要的是,它背后的数据结构——堆,是很多高级算法(如优先队列、Dijkstra 算法)的核心基础。掌握基础堆排序,不仅是为排序本身,更是为理解更深层的算法思想打下根基。


什么是堆?一个“父子关系”的树形结构

想象你有一堆篮球,要把它们按大小排好。如果每两个球之间都有明确的“父子”关系,而且每个父亲都比孩子大,那整个结构就叫“大根堆”。这种结构听起来抽象,但它的实现非常巧妙。

在编程中,堆通常用数组来表示。虽然它看起来是线性的,但我们可以用数学关系把它“还原”成一棵完全二叉树:

  • 根节点在索引 0
  • 节点 i 的左孩子在索引 2*i + 1
  • 节点 i 的右孩子在索引 2*i + 2
  • 节点 i 的父节点在索引 (i - 1) // 2

比如一个数组 [9, 5, 8, 3, 2, 7, 6],我们可以这样理解它的树形结构:

        9
      /   \
     5     8
    / \   / \
   3   2 7   6

在这个结构中,每个父节点都大于等于它的子节点,这就是一个“大根堆”。堆的这个特性,就是我们实现排序的关键。


构建堆:从无序数组到堆结构

要让一个数组变成堆,我们需要从最后一个非叶子节点开始,逐个“向下调整”(也叫“堆化”),确保每个父节点都满足堆性质。

我们以数组 [4, 10, 3, 5, 1] 为例,目标是把它变成大根堆。

def heapify(arr, n, i):
    # arr: 堆数组
    # n: 堆的大小(有效元素个数)
    # i: 当前要调整的节点索引
    largest = i  # 假设当前节点是最大值
    left = 2 * i + 1  # 左孩子索引
    right = 2 * i + 2  # 右孩子索引

    # 如果左孩子存在且比当前最大值大,则更新最大值索引
    if left < n and arr[left] > arr[largest]:
        largest = left

    # 如果右孩子存在且比当前最大值大,则更新最大值索引
    if right < n and arr[right] > arr[largest]:
        largest = right

    # 如果最大值不是当前节点,说明需要交换
    if largest != i:
        arr[i], arr[largest] = arr[largest], arr[i]  # 交换
        heapify(arr, n, largest)  # 递归调整被交换的子树

代码说明

  • heapify 函数的作用是:确保以 i 为根的子树满足大根堆性质
  • 我们先比较当前节点和它的两个孩子,找出三者中的最大值。
  • 如果最大值不是当前节点,就交换,并继续向下调整被交换节点所在的子树。
  • 这个过程会一直持续到叶子节点为止。

注意:我们从最后一个非叶子节点开始调整。因为叶子节点没有孩子,无需调整。在长度为 n 的数组中,最后一个非叶子节点的索引是 (n // 2) - 1


建立堆的过程演示

让我们动手把数组 [4, 10, 3, 5, 1] 变成大根堆。

  1. 数组长度为 5,最后一个非叶子节点索引是 (5 // 2) - 1 = 1,所以我们从索引 1 开始。
  2. 调整索引 1 的节点(值为 10):它有两个孩子 5 和 1,10 已经最大,无需调整。
  3. 调整索引 0 的节点(值为 4):左孩子是 10,右孩子是 3。10 更大,所以交换 4 和 10。
    • 交换后数组变为 [10, 4, 3, 5, 1]
    • 然后递归调整索引 1 的子树(原 4 的位置),发现 4 比它的孩子 5 小,再交换。
    • 最终数组变为 [10, 5, 3, 4, 1]

此时,整个数组已经是一个大根堆:根节点 10 是最大值,且每个父节点都大于等于孩子。


堆排序主流程:取出最大值,重建堆

堆排序的核心思想是:每次取出堆顶的最大值,放到数组末尾,然后重新调整堆,直到所有元素都排序完成

这个过程可以拆解为三步:

  1. 构建初始堆(从无序数组变成大根堆)
  2. 将堆顶(最大值)与最后一个元素交换
  3. 缩小堆的范围(最后一个元素已排序),重新堆化
  4. 重复步骤 2-3,直到堆只剩一个元素
def heap_sort(arr):
    n = len(arr)

    # 第一步:构建大根堆
    # 从最后一个非叶子节点开始,逐个调用 heapify
    for i in range(n // 2 - 1, -1, -1):
        heapify(arr, n, i)

    # 第二步:逐个取出最大值
    for i in range(n - 1, 0, -1):
        # 将堆顶(最大值)与末尾元素交换
        arr[0], arr[i] = arr[i], arr[0]
        # 重新堆化,堆的大小减一(最后一个元素已排序)
        heapify(arr, i, 0)

代码说明

  • 第一个循环:从 (n // 2 - 1)0,倒序遍历所有非叶子节点,调用 heapify 构建堆。
  • 第二个循环:从 n-11,每次将堆顶与末尾交换,然后对前 i 个元素重新堆化。
  • 交换后,最大值被“移出”堆,放在正确位置。堆的大小不断缩小,最终完成排序。

实际案例:排序一个无序数组

我们来跑一个完整的例子,输入数组 [64, 34, 25, 12, 22, 11, 90]。

arr = [64, 34, 25, 12, 22, 11, 90]

print("原始数组:", arr)

heap_sort(arr)

print("排序后数组:", arr)

输出结果:

原始数组: [64, 34, 25, 12, 22, 11, 90]
排序后数组: [11, 12, 22, 25, 34, 64, 90]

可以看到,基础堆排序成功将数组从小到大排列。整个过程没有使用额外的数组空间,完全在原数组上操作,空间复杂度为 O(1)。


时间与空间复杂度分析

指标 分析
时间复杂度 O(n log n)
空间复杂度 O(1)
稳定性 不稳定(相同元素的相对顺序可能改变)
适用场景 大数据量、内存受限、需要稳定时间复杂度的场景

为什么是 O(n log n)?

  • 构建堆:有 n/2 个节点,每个节点最多调整 log n 层,总时间 O(n log n)
  • 每次取出最大值并堆化:共 n 次,每次堆化 O(log n),总时间 O(n log n)

虽然堆排序不是最快的(比如快排平均是 O(n log n) 但常数更小),但它的最坏情况性能和最好情况一样,非常稳定,适合对性能一致性要求高的系统。


常见误区与调试技巧

  1. 索引计算错误:特别是 left = 2*i + 1right = 2*i + 2,千万别写反。
  2. 堆化递归未终止:确保 heapify 有终止条件,比如当 largest == i 时不再递归。
  3. 堆大小未更新:在交换后,堆的大小要减 1,否则会重复处理已排序的元素。

建议在调试时打印每一步的数组状态,观察堆的变化过程,比如:

print(f"第{i}轮后数组:{arr}")

这样能直观看到最大值如何“沉底”,堆如何逐渐“塌陷”。


总结:基础堆排序的价值与学习建议

基础堆排序虽然不像冒泡或快排那样“常见”,但它在算法体系中扮演着重要角色。它不仅是一种排序方法,更是一种“堆”数据结构的实战应用。掌握它,意味着你真正理解了树形结构在数组中的高效表达,以及自底向上构建有序结构的思想。

对初学者来说,建议先手写一次 heapify 函数,理解父子索引关系;再尝试模拟一次堆的构建过程;最后用完整代码跑一遍排序。每一步都手敲,比看十遍视频更有收获。

对中级开发者,可以思考:如何用堆实现优先队列?如何在堆中动态插入和删除元素?这些问题都源于基础堆排序的思维。

掌握基础堆排序,不只是为了排序,更是为了理解算法背后的“结构之美”。它像一座桥,连接着数组与树,连接着简单与高效。

当你下次看到“堆”这个词时,不再只是“内存堆”或“垃圾回收堆”,而是会想到那棵“父亲比孩子大”的二叉树,和它在排序中那沉稳而有力的一跳。