数据结构与算法 - 堆排序

一、堆排序介绍

1. 起源背景

在早期排序算法中,冒泡、简单选择、直接插入排序时间复杂度 O(n2),数据量大效率很差;归并排序可以做到 O(nlogn),但是需要额外 O (n) 辅助空间 ,无法原地排序;快速排序平均O(nlogn),最坏退化到O(n2),不稳定。为了解决:希望拥有 O(nlogn) 时间复杂度,并且能够原地排序,不占用大量额外内存,堆排序基于堆(完全二叉树)的数据结构思想被提出。

2. 解决痛点

  1. 规避简单排序 O(n2) 的低效;
  2. 规避归并排序需要额外数组空间的问题,支持原地排序;
  3. 弥补快排最坏时间退化缺陷,堆排序时间复杂度稳定维持 O(nlogn);
  4. 可以高效解决 TopK 问题,不需要完整排序全部数据。

3. 使用场景

  1. 大数据量的原地排序场景;
  2. TopK 问题:海量数据求前 K 大 / 前 K 小元素;
  3. 实现优先队列;
  4. 内存受限,不能开辟大量辅助数组,不能使用归并排序;

注意: 日常工程很少优先堆排序,缓存命中率差

原因: cpu命中Cache缓存访问数据速度更快,由于堆排序跳跃访问孩子结点下标,空间不连续不满足空间局限性。cpu缓存命中率低,频繁访问内存读取数据,所以工程上特别慢。

cpu缓存有局限性原理 :时间局限性(最近访问的缓存数据短时间内 大概率还要被访问) 和 空间局限性(访问某数据,物理位置相邻附近的数据大概率马上要用。cache缓存一般选取连续地址的数组空间,缓存命中率高)。

对比快速排序: 快排是数组局部范围内前后交换、双向向中间靠拢,访问的都是邻近下标,内存访问连续,空间局部性优秀,缓存命中率高。

虽然堆排序时间复杂度理论 O(nlogn),快排最坏 O(n2);但实际工程,绝大多数场景快排跑的比堆排序更快,就是堆排序缓存不友好带来的常数开销大。

所以,不是堆排序算法逻辑计算次数更多;而是内存读写开销大。理论复杂度只统计比较、交换次数,不衡量缓存失效带来的硬件开销。

4. 实现思想

堆本质是逻辑上的完全二叉树,底层用数组存储。

  • 大顶堆:父节点值 ≥ 左右孩子节点值;堆顶是整个堆的最大值。核心思想:
  1. 将待排序数组构建成大顶堆;此时堆顶为数组最大值。
  2. 将堆顶元素(最大值)与堆末尾元素交换,把最大值放到数组末尾有序位置。
  3. 堆的有效长度缩小 1(末尾已经排好序,不再参与堆调整),对新堆顶执行堆化(sift‑down 下沉调整),恢复大顶堆性质。
  4. 重复交换 + 堆化,直到堆有效长度为 1,数组整体有序。

小顶堆逻辑相反,堆顶为最小值。

5. 使用步骤(升序排序,大顶堆)

  1. 建堆:数组从最后一个非叶子节点向前遍历,逐个执行下沉调整,把原始数组调整为大顶堆。

最后一个非叶子节点下标:n/2‑1

  1. 循环交换堆顶与堆尾
    1. 堆顶元素和当前堆末尾元素交换;最大值放到末尾;
    2. 堆有效长度减一;
    3. 对新堆顶做下沉调整,重新维护大顶堆;
  2. 不断重复,直到堆只剩一个元素,数组完成升序。

下沉调整 sift‑down:父节点和左右孩子比较,和最大孩子交换;向下递归,直到满足堆性质。

6. 优缺点

优点

  1. 时间复杂度稳定 O(nlogn),最好、最坏、平均均为 O(nlogn);
  2. 原地排序,仅常数级别额外空间 O(1);
  3. 可以高效处理 TopK 问题。

缺点

  1. 不稳定排序,相等元素相对位置会发生改变;
  2. 缓存局部性差;访问数据跳跃,CPU 缓存命中率低;实际运行速度通常慢于快速排序;
  3. 不适合小规模数据,常数开销较大。

7. 面试背诵精简总结

面试口述版本堆排序基于完全二叉树堆结构,使用数组存储。升序使用大顶堆。第一步建堆,从最后一个非叶子节点向前做下沉调整构建大顶堆;之后循环把堆顶最大值和堆尾交换,缩小堆范围,下沉调整堆顶维持堆特性,循环完成排序。时间复杂度稳定O(nlogn),原地排序,空间O(1);不稳定。缺点缓存友好性差,实际速度慢于快排。擅长 TopK 问题。归并:时间稳定,非原地;快排:平均快,最坏退化;堆排:时间稳定,原地,但是不稳定,缓存差。

二、堆排序实现思路伪代码

功能说明

基于大顶堆 实现数组升序原地堆排序。

1. siftDown 下沉调整函数

自然语言描述:在有效长度为 heapLen 的堆中,对 node 下标节点执行下沉。比较当前节点与其左右子节点,选出最大值下标;如果最大值不是当前节点,则交换;把当前位置更新为最大值下标,循环继续向下调整,直到满足堆性质终止。

java

复制代码
//伪代码
private void siftDown(int[] arr, int heapLen, int node) {
    循环:
        largest = node //记录最大值下标
        left = 2 * node + 1
        right = 2 * node + 2
        //左孩子在堆范围内,且值更大,更新最大值下标
        if(left < heapLen && arr[left] > arr[largest]){
            largest = left
        }
        //右孩子在堆范围内,且值更大,更新最大值下标
        if(right < heapLen && arr[right] > arr[largest]){
            largest = right
        }
        //当前节点已经最大,堆性质成立,退出循环
        if(largest == node){
            break
        }
        //交换当前节点与最大子节点
        swap(arr, node, largest)
        node = largest //下沉到子节点位置继续循环
}

2. swap 交换工具

自然语言描述:交换数组 arr 下标 i、j 位置两个元素。

java

复制代码
//伪代码
private void swap(int[] arr, int i, int j){
    temp = arr[i]
    arr[i] = arr[j]
    arr[j] = temp
}

3. heapSort 堆排序主方法

自然语言描述:边界判断,数组为空或长度小于等于 1 直接返回。获取数组长度 n;计算最后一个非叶子节点下标,从该下标向前遍历,逐个调用下沉调整完成建大顶堆。设置堆有效长度 heapLen,循环:交换堆顶与堆末尾元素;堆有效长度减一;对堆顶执行下沉调整修复堆;直到堆有效长度等于 1 结束。

java

复制代码
//伪代码
public void heapSort(int[] arr){
    if(arr == null || arr.length <= 1){
        return
    }
    n = arr.length
    //建堆:从最后非叶子节点向前执行下沉
    lastNonLeaf = n / 2 - 1
    for(i = lastNonLeaf; i >= 0; i--){
        siftDown(arr, n, i)
    }
    heapLen = n
    while(heapLen > 1){
        swap(arr, 0, heapLen - 1)
        heapLen = heapLen - 1
        siftDown(arr, heapLen, 0)
    }
}

重要补充说明⭐(必看,辅助理解)

这里要说明一下,一开始建立大根堆从后往前挨个执行下沉,是因为初始是无序且不满足大根堆完全二叉树 要求的。

所以,我们将每个小分支的根结点搞成最大值,通过递归的整体思想,左右孩子分支的根结点都是当前分支的最大值,那么最终我们整颗二叉树的根结点一定是当前数组的最大值。

那么我们此时需要对当前子根结点下标执行下沉:

  1. 若根结点最大,说明当前已符合大根堆性质;

  2. 若左孩子最大,那么我们需要对左孩子分支执行下沉(注意这个下沉是一路沿着左孩子的 左或者右子分支 一路到叶子结点或者满足当前子根结点为当前分支的最大值 为止,不是

沿途的左右分支都要下沉);

  1. 右孩子最大与左孩子最大同理。

所以,第一步遍历进行down下沉目的是建立起一个整个数组的大根堆

而下面while循环交换开头和数组结尾元素的目的是为了【进行数组原地升序排序,将堆中的最大值统一移动到当前堆数组长度的最右侧】,最终的效果就是我们每次将堆顶最大值交换到最右侧,堆长度减一后,再次下沉一直循环执行,最后堆长度为1结束,此时我们得到了原地排序的升序序列。

注意: 开头和结尾元素交换后,执行下沉也是和之前描述一样,下沉是对沿途左或者右任一分支执行下沉,终止条件是到达叶子结点或者当前遍历位置的根结点为【根结点、左孩子、右孩子】中的最大值

三、大根堆原地升序排序实现代码

java 复制代码
public class HeapSort {

    /**
     * 交换数组中两个下标位置的元素
     * @param arr 目标数组
     * @param i 下标i
     * @param j 下标j
     */
    private static void swap(int[] arr, int i, int j) {
        int temp = arr[i];
        arr[i] = arr[j];
        arr[j] = temp;
    }

    /**
     * 下沉调整,维护大根堆性质
     * @param arr 数组
     * @param heapLen 当前堆的有效长度
     * @param node 需要下沉的节点下标
     */
    private static void siftDown(int[] arr, int heapLen, int node) {
        while (true) {
            int largest = node; // 记录父节点、左右子节点中最大值的下标
            int left = 2 * node + 1;  // 左孩子下标
            int right = 2 * node + 2; // 右孩子下标

            // 左孩子在堆范围内,且值大于当前最大值
            if (left < heapLen && arr[left] > arr[largest]) {
                largest = left;
            }
            // 右孩子在堆范围内,且值大于当前最大值
            if (right < heapLen && arr[right] > arr[largest]) {
                largest = right;
            }

            // 当前节点已是最大值,满足大根堆,结束下沉;或者node到达叶子结点后,
            // 前面两个if都不执行,到这里largest为叶子结点下标,node也是,break
            if (largest == node) {
                break;
            }
            // 和最大子节点交换
            swap(arr, node, largest);
            node = largest; // 更新当前节点,继续向下调整
        }
    }

    /**
     * 堆排序入口,原地升序,大根堆
     * @param arr 待排序数组
     */
    public static void heapSort(int[] arr) {
        // 边界:空数组或者单个元素无需排序
        if (arr == null || arr.length <= 1) {
            return;
        }
        int n = arr.length;
        // 最后一个非叶子节点下标,从该位置向前遍历构建大根堆
        int lastNonLeaf = n / 2 - 1;
        for (int i = lastNonLeaf; i >= 0; i--) {
            siftDown(arr, n, i);
        }

        int heapLen = n;
        // 循环:堆顶最大值与堆尾交换,缩小堆范围,下沉修复堆
        while (heapLen > 1) {
            swap(arr, 0, heapLen - 1);
            heapLen = heapLen - 1;
            siftDown(arr, heapLen, 0);
        }
    }
}

需要维护的变量:arr、heapLen、执行下沉操作时当前遍历的根结点下标node、当前遍历位置待下沉的根结点和左右孩子三者最大值的下标largest
最后一个非叶子结点下标计算公式:int lastNonLeaf = n / 2 - 1;

知根结点下标n,则左孩子结点下标为2n + 1;

反过来,若知左孩子下标为x且为最后一个叶子结点,x = 2n + 1 -> 根结点下标n = (x-1) / 2;

所以int lastNonLeaf = (x-1)/ 2 ,其中x为最后一个叶子结点下标且x = n-1(n为堆长度)。

变形 lastNonLeaf =(x - 1)/ 2 = (n-1 -1) / 2 = n / 2 - 1; (n为堆长度)

四、力扣hot100堆排序题解

LC 数组中的第K个最大元素 【215. 数组中的第K个最大元素 - 力扣(LeetCode)

LC 前K个高频元素 【347. 前 K 个高频元素 - 力扣(LeetCode)

相关推荐
是2的10次方啊1 小时前
线程 Dump 还是 Heap Dump?线上故障时先抓哪个
java
Zane19941 小时前
复制算法明明要浪费一半内存,为什么新生代还偏偏用它
java·后端
worilb1 小时前
Java/JVM 常见诊断文件对比
java·开发语言
toolsmith1 小时前
AI能替你读代码,但替代不了你积累洞察力:一次YouTrack逆向实战全记录
java·源码阅读
步行cgn1 小时前
Spring Boot 绑定简单 Bean 详解
java·spring boot·后端
鹿角片ljp1 小时前
我如何用 Frontend Design Skill 重构平台
java
未秃头的程序猿1 小时前
分库分表一年后,我复盘了当时最该想清楚的三件事
java·数据库·后端
岁月如歌77861 小时前
顺序消息与幂等消费完全指南:从队列有序到接口幂等
java·后端·架构
秋名RG1 小时前
Java 泛型深度解析:从类型安全到现代实践(JDK 21 视角)
java·windows·安全