一、堆排序介绍
1. 起源背景
在早期排序算法中,冒泡、简单选择、直接插入排序时间复杂度 O(n2),数据量大效率很差;归并排序可以做到 O(nlogn),但是需要额外 O (n) 辅助空间 ,无法原地排序;快速排序平均O(nlogn),最坏退化到O(n2),不稳定。为了解决:希望拥有 O(nlogn) 时间复杂度,并且能够原地排序,不占用大量额外内存,堆排序基于堆(完全二叉树)的数据结构思想被提出。
2. 解决痛点
- 规避简单排序 O(n2) 的低效;
- 规避归并排序需要额外数组空间的问题,支持原地排序;
- 弥补快排最坏时间退化缺陷,堆排序时间复杂度稳定维持 O(nlogn);
- 可以高效解决 TopK 问题,不需要完整排序全部数据。
3. 使用场景
- 大数据量的原地排序场景;
- TopK 问题:海量数据求前 K 大 / 前 K 小元素;
- 实现优先队列;
- 内存受限,不能开辟大量辅助数组,不能使用归并排序;
注意: 日常工程很少优先堆排序,缓存命中率差。
原因: cpu命中Cache缓存访问数据速度更快,由于堆排序跳跃访问孩子结点下标,空间不连续不满足空间局限性。cpu缓存命中率低,频繁访问内存读取数据,所以工程上特别慢。
cpu缓存有局限性原理 :时间局限性(最近访问的缓存数据短时间内 大概率还要被访问) 和 空间局限性(访问某数据,物理位置相邻附近的数据大概率马上要用。cache缓存一般选取连续地址的数组空间,缓存命中率高)。
对比快速排序: 快排是数组局部范围内前后交换、双向向中间靠拢,访问的都是邻近下标,内存访问连续,空间局部性优秀,缓存命中率高。
虽然堆排序时间复杂度理论 O(nlogn),快排最坏 O(n2);但实际工程,绝大多数场景快排跑的比堆排序更快,就是堆排序缓存不友好带来的常数开销大。
所以,不是堆排序算法逻辑计算次数更多;而是内存读写开销大。理论复杂度只统计比较、交换次数,不衡量缓存失效带来的硬件开销。
4. 实现思想
堆本质是逻辑上的完全二叉树,底层用数组存储。
- 大顶堆:父节点值 ≥ 左右孩子节点值;堆顶是整个堆的最大值。核心思想:
- 将待排序数组构建成大顶堆;此时堆顶为数组最大值。
- 将堆顶元素(最大值)与堆末尾元素交换,把最大值放到数组末尾有序位置。
- 堆的有效长度缩小 1(末尾已经排好序,不再参与堆调整),对新堆顶执行堆化(sift‑down 下沉调整),恢复大顶堆性质。
- 重复交换 + 堆化,直到堆有效长度为 1,数组整体有序。
小顶堆逻辑相反,堆顶为最小值。
5. 使用步骤(升序排序,大顶堆)
- 建堆:数组从最后一个非叶子节点向前遍历,逐个执行下沉调整,把原始数组调整为大顶堆。
最后一个非叶子节点下标:
n/2‑1。
- 循环交换堆顶与堆尾 :
- 堆顶元素和当前堆末尾元素交换;最大值放到末尾;
- 堆有效长度减一;
- 对新堆顶做下沉调整,重新维护大顶堆;
- 不断重复,直到堆只剩一个元素,数组完成升序。
下沉调整 sift‑down:父节点和左右孩子比较,和最大孩子交换;向下递归,直到满足堆性质。
6. 优缺点
优点
- 时间复杂度稳定 O(nlogn),最好、最坏、平均均为 O(nlogn);
- 原地排序,仅常数级别额外空间 O(1);
- 可以高效处理 TopK 问题。
缺点
- 不稳定排序,相等元素相对位置会发生改变;
- 缓存局部性差;访问数据跳跃,CPU 缓存命中率低;实际运行速度通常慢于快速排序;
- 不适合小规模数据,常数开销较大。
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)
}
}
重要补充说明⭐(必看,辅助理解)
这里要说明一下,一开始建立大根堆从后往前挨个执行下沉,是因为初始是无序且不满足大根堆完全二叉树 要求的。
所以,我们将每个小分支的根结点搞成最大值,通过递归的整体思想,左右孩子分支的根结点都是当前分支的最大值,那么最终我们整颗二叉树的根结点一定是当前数组的最大值。
那么我们此时需要对当前子根结点下标执行下沉:
若根结点最大,说明当前已符合大根堆性质;
若左孩子最大,那么我们需要对左孩子分支执行下沉(注意这个下沉是一路沿着左孩子的 左或者右子分支 一路到叶子结点或者满足当前子根结点为当前分支的最大值 为止,不是
沿途的左右分支都要下沉);
- 右孩子最大与左孩子最大同理。
所以,第一步遍历进行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)】