2.6.堆排序------从堆结构到 Top-K,一套思路贯穿排序与选择
系列 :搜索与排序 | 第 6 篇,共 16 篇
难度 :⭐⭐⭐☆☆ 中等
标签 :排序堆排序堆优先队列Top-K
上一篇 :2.5.归并排序------分而治之再合而并,为什么它能稳定保持 O(n log n)?
下一篇 :2.7.希尔排序------让插入排序先大步走,再小步收尾
前言
快速排序平均最快,归并排序最稳,那有没有一种排序,能把"排序 "和"选择"两件事统一在同一套结构里?
有,这就是堆排序。
堆排序背后依赖的是堆结构,而这个结构本身可以不止用来排序:
- 你想排一个数组 → 用堆排序
- 你想拿到第
k大 / 第k小 → 用堆维护 Top-K - 你想在动态数据流里随时获取最值 → 用优先队列
只要你真的把堆排序讲透,后面的很多"堆题"都会顺下来。
这篇重点讲四件事:
- 堆排序到底在维护什么结构
- 为什么建堆是
O(n),不是很多人以为的O(n log n) - 为什么它在最好、平均、最坏情况下都能保持
O(n log n) - 它和快排、归并该怎么取舍,以及它在"选择问题"里的独特价值
一、算法思想:先建大顶堆,再反复取最大值
堆排序依赖的底层结构是二叉堆 ,最常用的是大顶堆。
1)什么是大顶堆?
大顶堆满足:
每个节点的值,都不小于它的左孩子和右孩子。
因此:
- 堆顶一定是整个堆里的最大值
如果用数组来存完全二叉树,下标关系是:
- 父节点:
(i - 1) / 2(整除) - 左孩子:
2 * i + 1 - 右孩子:
2 * i + 2
2)堆排序分成两个阶段
阶段 A:建堆
建堆不是"随便调一调",而是要把整个数组原地调整成一个大顶堆。
建好后:
- 最大值会出现在下标
0 - 整个区间
[0, n - 1]都满足大顶堆性质
标准建堆做法是:
- 默认下标从 0 开始,n 个点
- 从最后一个非叶子节点开始,位置是
n / 2 - 1 - 按下标从后往前,依次对每个节点做一次"下沉"
- 一直处理到下标
0
为什么要从最后一个非叶子节点开始?
因为:
- 叶子节点本身可以视为一个元素的堆,自身就是有序的,不需要调整
- 非叶子节点作为根的树才可能违反"父节点 >= 子节点"的规则
- 从下往上调,能保证你处理当前节点时,它的左右子树已经先变成大顶堆了
这一步可以理解成:
先把每棵小子树调成局部正确,再一路向上合并成整棵大顶堆。
阶段 B:排序
反复执行下面的操作:
- 把堆顶最大值和当前末尾元素交换
- 这样最大值就被放到最终位置上了
- 堆的有效范围缩小 1
- 对新的堆顶继续执行"下沉",恢复剩余区间的大顶堆性质
重复直到堆大小变成 1,排序完成。
3)"下沉"到底怎么操作?
下沉不是只比一次大小,而是一个一路往下找位置的过程。
假设当前要处理下标 i,操作顺序是:
- 找到它的左孩子
2 * i + 1和右孩子2 * i + 2 - 在"当前节点 + 左右孩子"里找出最大的那个
- 如果最大值就是当前节点,说明这一层已经合法,直接停止
- 如果最大值在孩子那里,就交换
- 交换后,节点跑到了更深一层,要继续往下检查,直到不能再下沉为止
你可以把它想成:
一个不够大的父节点,被更大的孩子一路往下"挤",直到落到自己该待的位置。
📌 核心不变量 :每轮开始时,当前有效区间[0, heapSize - 1]始终保持为大顶堆;而区间右侧则已经是排好序的结果区。
二、完整图解过程
以数组 [5, 3, 8, 1, 2] 为例。
第 1 步:把无序数组建成大顶堆
原数组:
text
[5, 3, 8, 1, 2]
先确定建堆从哪里开始。
- 数组长度
n = 5 - 最后一个非叶子节点下标是
n / 2 - 1 = 1 - 所以建堆顺序是:
从i = 1 开始,循环到 i = 0
初始树的结构:
text
[8, 3, 5, 1, 2]
5
/ \
3 8
/ \
1 2
先处理 i = 1
下标 1 上的数是 3,它的两个孩子分别是:
- 左孩子下标
3,值为1 - 右孩子下标
4,值为2
text
3
/ \
1 2
因为 3 已经不小于两个孩子,所以这里不用动。
text
[5, 3, 8, 1, 2]
再处理 i = 0
下标 0 上的数是 5,它的两个孩子分别是:
- 左孩子
3 - 右孩子
8
text
5
/ \
3 8
这时最大的其实是右孩子 8,所以要交换:
text
[5, 3, 8, 1, 2] -> [8, 3, 5, 1, 2]
5 8
/ \ → / \
3 8 3 5
交换后,原来的 5 来到下标 2。
- 下标
2已经没有孩子了 - 所以下沉到这里就结束
最终建堆结果:
text
[8, 3, 5, 1, 2]
8
/ \
3 5
/ \
1 2
这时堆顶 8 就是全局最大值。
这一步的本质不是"直接把最大值找出来",而是通过从下到上的局部调整,让整个数组满足大顶堆结构。
第 2 步:把最大值放到末尾
交换堆顶和最后一个元素:
text
[8, 3, 5, 1, 2] -> [2, 3, 5, 1, 8]
注意:
- 末尾的
8已经到最终位置 - 后面的
8不再属于堆区间 - 当前真正要维护的堆,只剩前面的
[2, 3, 5, 1]
text
2
/ \
3 5
/
1
这时问题出在堆顶:2 太小了,不符合大顶堆规则,所以要开始下沉。
这一次下沉具体怎么走?
当前堆顶是下标 0,值为 2:
- 左孩子是
3 - 右孩子是
5 - 两个孩子里更大的是
5
所以先交换 2 和 5:
text
[2, 3, 5, 1, | 8] -> [5, 3, 2, 1, | 8]
2 5
/ \ → / \
3 5 3 2
交换后,2 来到了下标 2:
- 下标
2在当前堆范围里已经没有孩子 - 所以这一轮下沉结束
现在前半段重新恢复成大顶堆,右侧 8 保持已排序状态。
用树的视角再看一眼这次下沉
交换堆顶和末尾后,当前有效堆区间其实是 [2, 3, 5, 1],它对应的树是:
text
2
/ \
3 5
/
1
这时根节点 2 明显太小,不可能继续当堆顶。
下沉时只做一件事:
- 在当前节点和左右孩子里找最大值
- 发现
5最大,就让5上来,2下去
于是会变成:
text
5
/ \
3 2
/
1
因为 2 在当前堆范围里已经没有孩子了,所以这次下沉到这里停止。
下沉不是把整个堆重新排一遍,而是只修复一条从上往下的路径。
第 3 步:继续重复
再把新的堆顶 5 和末尾有效位置交换:
text
[5, 3, 2, 1, 8] -> [1, 3, 2, 5, 8]
重新下沉:
text
[1, 3, 2, | 5, 8] -> [3, 1, 2, | 5, 8]
再继续:
text
[3, 1, 2, 5, 8] -> [2, 1, 3, 5, 8]
最后得到:
text
[1, 2, 3, 5, 8]
排序完成。✅
整体过程汇总
| 阶段 | 操作 | 结果 |
|---|---|---|
| 建堆 | 把 [5, 3, 8, 1, 2] 调整为大顶堆 |
[8, 3, 5, 1, 2] |
| 第 1 轮 | 交换堆顶与末尾,再下沉 | [5, 3, 2, 1, 8] |
| 第 2 轮 | 交换堆顶与倒数第 2 位,再下沉 | [3, 1, 2, 5, 8] |
| 第 3 轮 | 继续缩小堆区间 | [2, 1, 3, 5, 8] |
| 完成 | 最后剩 1 个元素 | [1, 2, 3, 5, 8] |
三、代码实现
Python 版本
python
def heapify(nums, heap_size, i):
# 先假设当前节点最大
largest = i
left = 2 * i + 1
right = 2 * i + 2
# 和左孩子比较
if left < heap_size and nums[left] > nums[largest]:
largest = left
# 和右孩子比较
if right < heap_size and nums[right] > nums[largest]:
largest = right
# 如果最大值不是当前节点,就交换并继续下沉
if largest != i:
nums[i], nums[largest] = nums[largest], nums[i]
heapify(nums, heap_size, largest)
def heap_sort(nums):
n = len(nums)
# 阶段 A:从最后一个非叶子节点开始,自底向上建堆
for i in range(n // 2 - 1, -1, -1):
heapify(nums, n, i)
# 阶段 B:不断把堆顶最大值放到末尾
for end in range(n - 1, 0, -1):
nums[0], nums[end] = nums[end], nums[0]
# 末尾已经有序,因此新的堆大小是 end
heapify(nums, end, 0)
return nums
nums = [5, 3, 8, 1, 2]
print(heap_sort(nums)) # [1, 2, 3, 5, 8]
C++ 版本
cpp
#include <iostream>
#include <vector>
using namespace std;
void heapify(vector<int>& nums, int heapSize, int i) {
int largest = i; // 先假设当前节点最大
int left = 2 * i + 1;
int right = 2 * i + 2;
// 找到当前节点、左孩子、右孩子三者中的最大值
if (left < heapSize && nums[left] > nums[largest]) {
largest = left;
}
if (right < heapSize && nums[right] > nums[largest]) {
largest = right;
}
// 如果最大值不是当前节点,说明当前节点要下沉
if (largest != i) {
swap(nums[i], nums[largest]);
heapify(nums, heapSize, largest); // 继续向下恢复堆性质
}
}
void heapSort(vector<int>& nums) {
int n = (int)nums.size();
// 阶段 A:从最后一个非叶子节点开始,自底向上建成大顶堆
for (int i = n / 2 - 1; i >= 0; i--) {
heapify(nums, n, i);
}
// 阶段 B:每次把堆顶最大值交换到末尾
for (int end = n - 1; end > 0; end--) {
swap(nums[0], nums[end]);
// end 之后的位置已经有序,不再属于堆
heapify(nums, end, 0);
}
}
int main() {
vector<int> nums = {5, 3, 8, 1, 2};
heapSort(nums);
for (int x : nums) {
cout << x << " ";
}
return 0;
}
这段代码,重点在这三行:
for (int i = n / 2 - 1; i >= 0; i--):这就是阶段 A 建堆,从最后一个非叶子节点开始往上遍历调整swap(nums[0], nums[end]):这一步是在把当前最大值放到最终位置heapify(nums, end, 0):这一步是在对新的堆顶做下沉修复,让剩余部分继续保持大顶堆
如果这三行你都能对应上正文里的"建堆 → 交换 → 下沉",那堆排序就已经真的明白了。
补充:递归版 heapify 和循环版 heapify 怎么对应?
上面正文里的代码是递归版,它的优点是短、好理解。
但很多人在写代码时习惯写成循环版,因为它更贴近"元素一路往下沉"的过程。
C++ 循环版 heapify:
cpp
void heapifyIter(vector<int>& nums, int heapSize, int i) {
while (true) {
int largest = i;
int left = 2 * i + 1;
int right = 2 * i + 2;
if (left < heapSize && nums[left] > nums[largest]) {
largest = left;
}
if (right < heapSize && nums[right] > nums[largest]) {
largest = right;
}
if (largest == i) {
break;
}
swap(nums[i], nums[largest]);
i = largest;
}
}
它和递归版本质上是同一件事:
- 递归版:交换后,把"继续修下一层"的任务交给下一次函数调用
- 循环版:交换后,直接把
i改成新的位置,然后继续while - 两者的停止条件完全一样:当前节点已经不小于左右孩子
你只要记住:
递归版和循环版只是"写法不同",不是"思路不同"。它们都在做同一件事:把一个偏小的节点一路下沉到正确位置。
四、复杂度分析
| 指标 | 复杂度 | 原因 |
|---|---|---|
| 建堆 | O(n) | 不是每个节点都要下沉到最底层 |
| 排序阶段 | O(n log n) | 做 n-1 次"交换堆顶 + 下沉" |
| 总时间复杂度 | O(n log n) | 最好 / 平均 / 最坏都稳定 |
| 空间复杂度 | O(1) | 原地排序,不需要额外数组 |
| 稳定性 | ❌ 不稳定 | 交换堆顶与尾元素会打乱相等元素顺序 |
堆排序的优点很鲜明:
- 时间上界稳
- 空间开销小
它的短板也很鲜明:
- 不稳定
- 常数通常不如快排讨喜
- 局部性也不如快排友好
五、为什么建堆是 O(n),不是 O(n log n)?
这是堆排序最容易被误解的地方。
很多人会下意识地想:
- 一共有
n个节点 - 每个节点堆化一次像是
O(log n) - 所以建堆应该是
O(n log n)
但这个想法忽略了一点:
不是每个节点都会下沉
log n层。
事实上:
- 靠近底层的节点很多,但它们能下沉的层数很少
- 能下沉很多层的节点很少,主要集中在上层
于是所有节点下沉代价加起来,整体是:
text
O(n)
也可以用直觉理解:
- 叶子节点根本不用调
- 倒数第二层最多下沉 1 层
- 倒数第三层最多下沉 2 层
- 真正可能下沉很多层的,只有极少数靠近根的节点
所以从最后一个非叶子节点开始,自底向上建堆,比"逐个插入建堆"更快。
这也是标准堆排序一定采用原地建堆的原因。
六、堆排序与 Top-K、优先队列的关系
堆排序虽然本身是"把所有元素排完",但它更重要的意义,是让你理解这类题:
- 实时取最大 / 最小值
- 维护前
k大 / 前k小 - 优先队列调度
- 数据流问题
1)Top-K
如果你只想要前 k 大元素,其实不必完整排序。
常见做法是:
- 维护一个大小为
k的小顶堆 (对,你没看错,是小顶堆) - 新元素比堆顶大就替换
- 最终堆里就是前
k大
这里用到小顶堆是因为,咱们维护前K大,如果当前已经有了K个元素,每次有新元素符合条件,那么舍弃的就是最小的那个元素,正好对应小顶堆的堆顶。
2)优先队列
很多题本质上都在做:
- 谁最小 / 谁最大,谁先出队
这就是堆最擅长的事。
所以"堆排序"这一章,真正带走的应该不只是一个排序模板,而是:
对堆这种数据结构的动态维护能力有感觉。
七、与快速排序、归并排序对比
| 对比项 | 堆排序 | 快速排序 | 归并排序 |
|---|---|---|---|
| 平均时间复杂度 | O(n log n) | O(n log n) | O(n log n) |
| 最坏时间复杂度 | O(n log n) | O(n²) | O(n log n) |
| 空间复杂度 | O(1) | O(log n) | O(n) |
| 稳定性 | 不稳定 | 不稳定 | 稳定 |
| 工程常用度 | 中等 | 很高 | 很高 |
| 典型优势 | 原地且上界稳 | 平均快、常数小 | 稳定、适合链表 |
| 典型短板 | 常数与局部性一般 | 会退化 | 额外空间大 |
一句话理解:
- 堆排序:想要"空间省 + 最坏也稳",它很合适
- 快速排序:想要"平均快 + 工程常用",它通常更香
- 归并排序:想要"稳定 + 上界稳",它更放心
八、OJ 例题讲解
例题 1:LeetCode 215 --- 数组中的第 K 个最大元素(堆的经典入门题)
题目来源 :LeetCode,题号 215 难度:⭐⭐⭐☆☆ 中等
题目链接 :https://leetcode.cn/problems/kth-largest-element-in-an-array/
题目描述:
给定整数数组
nums和整数k,返回数组中第k个最大的元素。
为什么这题适合放在堆排序章节?
因为它几乎是"堆思想最直接的应用题":
- 要么建大顶堆弹
k次 - 要么维护大小为
k的小顶堆
两种思路都和堆高度相关。
C++ 解法(小顶堆):
cpp
class Solution {
public:
int findKthLargest(vector<int>& nums, int k) {
priority_queue<int, vector<int>, greater<int>> pq;
for (int x : nums) {
if ((int)pq.size() < k) {
pq.push(x);
} else if (x > pq.top()) {
pq.pop();
pq.push(x);
}
}
return pq.top();
}
};
例题 2:LeetCode 347 --- 前 K 个高频元素(堆维护前 k 名)
题目来源 :LeetCode,题号 347 难度:⭐⭐⭐☆☆ 中等
题目链接 :https://leetcode.cn/problems/top-k-frequent-elements/
题目描述:
给你一个整数数组
nums和一个整数k,返回其中出现频率前k高的元素。
为什么它和堆强相关?
因为这里比较的对象不再是数值大小,而是:
- 频率
但维护"前 k 个最优元素"的动作没变,依然适合用堆。
思路是:
- 先统计每个数的出现次数
- 再维护一个大小为
k的小顶堆 - 堆里按"频率"排序,而不是按"元素值"排序
这题很适合理解:
堆不是只能处理"最大值"问题,它处理的是"优先级"问题。
解题思路:
先用哈希表统计每个元素出现了多少次。
比如:
text
nums = [1,1,1,2,2,3], k = 2
统计后会得到:
text
1 -> 3 次
2 -> 2 次
3 -> 1 次
这时目标就变成了:
- 从这些"(元素, 频率)"对里
- 找出频率最高的前
k个
做法仍然是经典的"固定大小小顶堆":
- 堆里最多放
k个元素 - 堆顶放当前这
k个候选里频率最小的那个 - 如果新元素频率更高,就把堆顶挤掉
这样扫完整个哈希表后,堆里剩下的就是前 k 个高频元素。
C++ 解法(哈希表 + 小顶堆):
cpp
class Solution {
public:
vector<int> topKFrequent(vector<int>& nums, int k) {
unordered_map<int, int> freq;
for (int x : nums) {
freq[x]++;
}
using PII = pair<int, int>; // {频率, 元素值}
auto cmp = [](const PII& a, const PII& b) {
return a.first > b.first;
};
priority_queue<PII, vector<PII>, decltype(cmp)> pq(cmp);
for (auto& [num, count] : freq) {
if ((int)pq.size() < k) {
pq.push({count, num});
} else if (count > pq.top().first) {
pq.pop();
pq.push({count, num});
}
}
vector<int> ans;
while (!pq.empty()) {
ans.push_back(pq.top().second);
pq.pop();
}
return ans;
}
};
代码讲解:
unordered_map<int, int> freq:先统计每个元素出现频率- 堆中保存的是
{频率, 元素值},这样比较时就能按频率决定优先级 cmp把堆写成小顶堆,保证堆顶始终是当前前k名里最弱的那个- 当
count > pq.top().first时,说明新元素比堆顶更值得留下,就弹出堆顶再加入新元素 - 最后堆里剩下的所有元素,就是答案集合;这题不要求返回顺序固定,所以直接弹出即可
复杂度分析:
- 统计频率:
O(n) - 维护大小为
k的堆:设不同元素个数为m,复杂度是O(m log k) - 总体复杂度通常写作:
O(n log k) - 额外空间:
O(m + k)
这题适合拿来练什么?
- 练"小顶堆维护前
k名"这个核心套路 - 练把堆的比较对象从"值"切换成"频率"
- 练哈希表统计 + 堆筛选的组合题型
例题 3:LeetCode 703 --- 数据流中的第 K 大元素(动态维护堆)
题目来源 :LeetCode,题号 703 难度:⭐⭐☆☆☆ 简单
题目链接 :https://leetcode.cn/problems/kth-largest-element-in-a-stream/
题目描述:
设计一个类,支持不断插入新元素,并在每次插入后返回当前数据流中的第
k大元素。
为什么这题值得放在这里?
因为它进一步说明:
- 排序只是堆的一种静态应用
- 真正强的是"动态维护前
k大"
这正是优先队列最擅长的事情。
解题思路:
这题和 LeetCode 215 的核心套路几乎一样,只不过:
- 215 是一次性给完整数组
- 703 是数据一个一个到来,要边加边维护答案
所以我们仍然维护一个大小为 k 的小顶堆:
- 堆里始终保存当前看到的前
k大元素 - 堆顶就是这
k个数里最小的那个 - 也就正好是"当前第
k大元素"
每来一个新数 val,只做三件事:
- 如果堆里还没满
k个,直接加入 - 如果堆满了,但
val比堆顶大,就弹出堆顶、加入val - 如果
val还不如堆顶大,说明它进不了前k,直接忽略
这样每次 add() 结束后:
堆顶就是当前数据流里的第
k大元素。
C++ 解法(固定大小小顶堆):
cpp
class KthLargest {
private:
int k;
priority_queue<int, vector<int>, greater<int>> pq;
public:
KthLargest(int k, vector<int>& nums) {
this->k = k;
for (int x : nums) {
add(x);
}
}
int add(int val) {
if ((int)pq.size() < k) {
pq.push(val);
} else if (val > pq.top()) {
pq.pop();
pq.push(val);
}
return pq.top();
}
};
代码讲解:
pq是一个小顶堆,大小始终不超过k- 构造函数里把初始数组里的元素逐个喂给
add(),这样初始化逻辑和后续插入逻辑完全统一 pq.size() < k时,说明前k大还没凑齐,先无脑加入val > pq.top()时,说明新元素足够大,可以挤进当前前k大集合return pq.top()这句非常关键:因为小顶堆堆顶恰好就是当前第k大
复杂度分析:
- 初始化:若初始数组长度为
n,总复杂度为O(n log k) - 单次
add(val):O(log k) - 额外空间:
O(k)
这题适合拿来练什么?
- 练把静态 Top-K 思路迁移到数据流场景
- 练"堆里保存前
k大,堆顶就是第k大"这个关键认知 - 练优先队列在类设计题里的落地写法
九、适用场景
| 场景 | 是否适合 | 原因 |
|---|---|---|
想要最坏也稳定的 O(n log n) 排序 |
✅ | 不会像快排那样退化 |
| 内存紧张,希望原地排序 | ✅ | 额外空间很小 |
| Top-K / 优先队列类问题 | ✅ | 堆思想天然契合 |
| 需要稳定排序 | ❌ | 堆排序不稳定 |
| 特别追求平均常数性能 | ⚠️ | 往往不如快排讨喜 |
十、常见错误总结
| 错误 | 原因 | 正确做法 |
|---|---|---|
| 把建堆写成逐个插入 | 能做,但不是标准高效写法 | 优先使用自底向上建堆 |
| 下沉后没继续递归/循环 | 只修了一层,堆性质可能仍然被破坏 | 要一直下沉到合适位置 |
| 交换后没缩小堆区间 | 已经排好的尾部又被当成堆处理 | heapSize 每轮减 1 |
| 误以为堆排序稳定 | 堆顶交换会打乱顺序 | 明确记住它不稳定 |
| 左右孩子下标写错 | 容易越界或逻辑错误 | 数组堆牢记 2*i+1、2*i+2 |
总结
| 要点 | 内容 |
|---|---|
| 核心思想 | 先建大顶堆,再反复把堆顶放到末尾 |
| 总时间复杂度 | O(n log n) |
| 建堆复杂度 | O(n) |
| 空间复杂度 | O(1) |
| 稳定性 | ❌ 不稳定 |
| 关键优势 | 原地、最坏也稳、适合 Top-K |
| 主要短板 | 常数与缓存友好性一般 |
一句话记住它:
堆排序本质上是在不停地"取当前最大值",只是它把这个动作做成了原地、批量、系统化的流程。
上一篇 :2.5.归并排序------分而治之再合而并,为什么它能稳定保持 O(n log n)?
下一篇 :2.7.希尔排序------让插入排序先大步走,再小步收尾
💬 看完有收获的话,点个赞再走~ 有问题欢迎评论区讨论 🙏