首页/数据结构/07-sorting/堆排序 🔗 在 Obsidian 中打开
数据结构 · 07-sorting

堆排序

难度 ★★★★重要度 ★★ 考查频率 低题型 选择排序 数据结构/排序完全二叉树
速查
堆排序是一种基于完全二叉树选择排序,利用堆结构完成排序,是简单选择排序的改进版。时间复杂度始终 $O(n\log_2 n)$,空间复杂度 $O(1)$,是不稳定排序;建堆 $O(n)$,调整堆 $O(\log_2 n)$。

速查

项目
主题堆排序
核心概念基于完全二叉树的选择排序,利用堆结构,是简单选择排序的改进
时间复杂度建堆 $O(n)$,排序 $O(n\log_2 n)$,总 $O(n\log_2 n)$;最好/平均/最坏均同阶
空间复杂度$O(1)$(原地排序)
稳定性❌ 不稳定
难度⭐⭐⭐⭐

核心概念

定义

堆排序(Heap Sort)是一种基于完全二叉树选择排序算法。它利用这种数据结构来进行排序,是简单选择排序的改进版本。

堆的定义

  • 大根堆(大顶堆):每个结点的值都 $\geq$ 其左右孩子结点的值,即 $L(i) \geq L(2i)$ 且 $L(i) \geq L(2i+1)$,堆顶是最大值
  • 小根堆(小顶堆):每个结点的值都 $\leq$ 其左右孩子结点的值,即 $L(i) \leq L(2i)$ 且 $L(i) \leq L(2i+1)$,堆顶是最小值

核心思想

  1. 将待排序序列构建成一个大根堆(建堆过程)
  2. 将堆顶元素(最大值)与堆底元素交换,最大值到达最终位置
  3. 将剩余 $n-1$ 个元素重新调整为堆
  4. 重复步骤 2-3,直到堆中只剩一个元素

基于大根堆的堆排序得到递增序列,基于小根堆得到递减序列

关键特性

  • 堆是层序存储的完全二叉树,用数组实现
  • 编号为 $i$ 的结点:左孩子 $= 2i$,右孩子 $= 2i+1$,父结点 $= \lfloor i/2\rfloor$
  • 堆排序是不稳定
  • 时间复杂度始终为 $O(n\log_2 n)$,与初始序列无关
  • 建堆时间复杂度为 $O(n)$,调整堆时间复杂度为 $O(\log_2 n)$
为什么从大根堆得到递增序列?大根堆的堆顶是最大值,每次把堆顶交换到"已排好"的末尾,依次确定的是从大到小的位置,最终数组从左向右就是递增的。

算法步骤

建堆过程(以大根堆为例)

  1. 将序列按层序存储为完全二叉树
  2. 最后一个非叶子结点开始(编号为 $\lfloor n/2\rfloor$),向前依次调整
  3. 对每个非叶子结点,检查是否满足大根堆性质:
    • 如果当前结点 $<$ 某个孩子结点,则与较大的孩子交换
    • 交换后继续向下检查,直到满足堆性质
  4. 调整完成后,序列构成大根堆

排序过程

  1. 将堆顶元素(最大值)与堆的最后一个元素交换
  2. 堆的规模减 1(最后一个元素已到达最终位置)
  3. 对新的堆顶元素进行向下调整,恢复堆性质
  4. 重复步骤 1-3,共执行 $n-1$ 趟

向下调整(Sift Down)

  1. 设当前结点为 parent,其左孩子为 $child = 2 \times parent$
  2. 如果有右孩子且右孩子 $>$ 左孩子,则 child 指向右孩子
  3. 如果 $parent < child$,则交换两者
  4. parent 移到 child 位置,继续向下调整
  5. 直到 $parent \geq child$(或到达叶子结点)

代码实现

// 向下调整函数(大根堆)
void SiftDown(int A[], int start, int end) {
    int parent = start;
    int child = 2 * parent + 1;  // 左孩子(数组下标从 0 开始)
    while (child <= end) {
        // 选择较大的孩子
        if (child + 1 <= end && A[child] < A[child + 1])
            child++;
        // 如果父结点 >= 较大的孩子,调整结束
        if (A[parent] >= A[child])
            return;
        else {
            // 交换父子结点
            int temp = A[parent];
            A[parent] = A[child];
            A[child] = temp;
            // 继续向下调整
            parent = child;
            child = 2 * parent + 1;
        }
    }
}

// 建堆 + 堆排序
void HeapSort(int A[], int len) {
    // 第一步:建堆,从最后一个非叶子结点开始
    for (int i = (len - 2) / 2; i >= 0; i--)
        SiftDown(A, i, len - 1);

    // 第二步:排序
    for (int i = len - 1; i > 0; i--) {
        // 将堆顶(最大值)与堆底交换
        int temp = A[0];
        A[0] = A[i];
        A[i] = temp;
        // 对剩余元素重新调整为堆
        SiftDown(A, 0, i - 1);
    }
}

手算示例

对序列 {53, 17, 78, 9, 45, 65, 87, 23} 进行堆排序(大根堆)。

第一步:建堆(从 ⌊8/2⌋=4 号结点开始,从后往前调整)

  1. 初始层序存储:[53,17,78,9,45,65,87,23],最后非叶子结点为 4 号(值 9)。
  2. 调整结点4(9):孩子 8(23),9 < 23 → 交换 → [53,17,78,23,45,65,87,9]
  3. 调整结点3(78):孩子 6(65)、7(87),78 < 87 → 与 7 交换 → [53,17,87,23,45,65,78,9]
  4. 调整结点2(17):孩子 4(23)、5(45),17 < 45 → 与 5 交换 → [53,45,87,23,17,65,78,9]
  5. 调整结点1(53):孩子 2(45)、3(87),53 < 87 → 与 3 交换 → [87,45,53,23,17,65,78,9];继续下坠结点3(53):孩子 6(65)、7(78),53 < 78 → 与 7 交换 → [87,45,78,23,17,65,53,9]

建堆完成,得到大根堆 [87,45,78,23,17,65,53,9]

第二步:排序(每趟把堆顶换到末尾)

趟次交换调整后堆顶已确定最终位置
第 1 趟87 ↔ 978 上浮到堆顶87
第 2 趟78 ↔ 5365 上升87, 78
第 3 趟65 ↔ 2345 上升87, 78, 65
依次下坠调整

最终结果:9, 17, 23, 45, 53, 65, 78, 87

手算要点建堆只需从 ⌊n/2⌋ 开始向前调整;排序阶段每次把堆顶(当前最大)换到末尾,再对堆顶做一次向下调整。

复杂度分析

时间复杂度

情况时间复杂度说明
最好情况$O(n\log_2 n)$序列有序时
平均情况$O(n\log_2 n)$随机输入
最坏情况$O(n\log_2 n)$序列逆序时

空间复杂度

空间复杂度为 $O(1)$

稳定性分析

堆排序是不稳定的排序算法。

反例:序列 {1, 1*, 2} 建大根堆。建堆后:{2, 1*, 1},交换 2 和 1 后:{1, 1*, 2}1 和 1* 的相对位置改变了

不稳定来源交换堆顶和堆底元素时,可能改变相等元素的相对顺序;这是"跳跃式"交换,不相邻比较。

常见考法

  1. 建堆过程:给出初始序列,要求画出建堆后的完全二叉树
  2. 调整堆的过程:删除堆顶元素后,如何调整
  3. 堆的插入:新元素插入堆中,如何调整(上浮操作)
  4. 堆排序的时间复杂度:建堆 $O(n)$,排序 $O(n\log_2 n)$
  5. 堆排序的稳定性:不稳定
  6. 小根堆中关键字最大的元素位置:在叶子结点中,范围 $\lfloor n/2\rfloor+1 \sim n$
  7. 从 n 个数中选前 k 个最大/最小值:用堆,时间复杂度 $O(n\log k)$
  8. 判断序列是否为堆:检查每个结点是否满足堆性质

易错点

  1. ❌ 认为建堆时间复杂度是 $O(n\log_2 n)$ → 实际是 $O(n)$
  2. ❌ 混淆大根堆和小根堆 → 大根堆递增排序,小根堆递减排序
  3. ❌ 堆排序的数组下标从 0 还是 1 开始 → 408 通常从 1 开始,代码中从 0 开始需注意
  4. ❌ 认为堆排序是稳定的 → 不稳定
  5. ❌ 建堆时从前往后调整 → 必须从后往前,从最后一个非叶子结点开始
  6. ❌ 交换后忘记向下调整 → 必须继续调整恢复堆性质
  7. ❌ 混淆"调整堆"和"建堆" → 建堆 $O(n)$,调整单个元素 $O(\log_2 n)$

核心结论

  1. 堆排序时间复杂度始终为 $O(n\log_2 n)$,与初始序列无关
  2. 空间复杂度为 $O(1)$,是原地排序
  3. 堆排序是不稳定
  4. 建堆时间复杂度为 $O(n)$,调整堆为 $O(\log_2 n)$
  5. 大根堆得到递增序列,小根堆得到递减序列
  6. 小根堆中关键字最大的元素在叶子结点中($\lfloor n/2\rfloor+1 \sim n$)
  7. 堆适合从大量数据中选取前 k 个最大/最小值
  8. 堆是层序存储的完全二叉树

记忆卡片

建堆的时间复杂度是多少?为什么不是 $O(n\log_2 n)$?
建堆时间复杂度是 $O(n)$。因为大部分结点在底层,需要下坠的层数少;少数结点在顶层,虽然下坠层数多但数量少。数学推导证明总比较次数 $\leq 4n$。
大根堆排序得到什么序列?小根堆呢?
大根堆排序得到递增序列,小根堆排序得到递减序列。因为大根堆每次将最大值交换到末尾。
堆排序为什么是不稳定的?
因为交换堆顶和堆底元素时,可能改变相等元素的相对顺序。例如 {1, 1*, 2} 建大根堆后交换会导致不稳定。
如何从 n 个数中选出前 k 个最大值?
用大小为 k 的小根堆。遍历所有元素,如果当前元素大于堆顶,则替换堆顶并调整。时间复杂度 $O(n\log k)$。
堆的插入和删除操作的时间复杂度分别是多少?
都是 $O(\log_2 n)$。插入时新元素上浮(与父结点比较交换),删除时用堆底元素替换堆顶后下沉

交互动画

53 17 78 9 45 65 87 23 53 17 78 9 45 65 87 23 1 2 3 4 5 6 7 8 橙框 = 当前正在比较/交换的结点(树与数组对应同一元素)
0 / 5 步
初始数组(层序存储):[53,17,78,9,45,65,87,23]
怎么看树中每个结点的值等于数组对应下标的值(结点 i 对应数组第 i 位)。点击「下一步」从最后一个非叶子结点(4 号)开始向前做向下调整,橙色标出被比较/交换的两个结点。

相关知识点

(暂无关联知识点)