LeetCode Hot 100 题解 · 堆篇
- 本专题收录 LeetCode Hot 100 中所有 堆 相关题目。
- 每道题提供最直接、最容易理解的解题思路,包含详细注释的代码实现,方便笔试和面试复习。
📑 目录
| 题号 | 题目 | 难度 | 核心思路 |
|---|---|---|---|
| 215 | 215.数组中的第K个最大元素 | 🟠中等 | 四种写法层层递进:直接排序 取下标 n − k n-k n−k( O ( n log n ) O(n \log n) O(nlogn),基线);大顶堆 装下全部数、连弹 k 次堆顶( O ( n + k log n ) O(n + k \log n) O(n+klogn));小顶堆 养大小为 k 的候选池,堆超 k 就弹堆顶,扫完堆顶就是第 k 大( O ( n log k ) O(n \log k) O(nlogk),Top-K 通吃模板);快速选择 随机挑基准做三路划分,等于基准的一整批同时归位 ,只递归答案所在的那一区,期望 O ( n ) O(n) O(n)。 |
| 347 | 347.前K个高频元素 | 🟠中等 | 先用哈希表数出每个数的频率,再套 215 的 Top-K 模板 :按频率养一个大小为 k 的小顶堆,堆顶是候选里频率最低的、随时被换掉,扫完堆里就是前 k 高频的数, O ( n log k ) O(n \log k) O(nlogk)。更快的桶排序:频率直接当桶下标 ,从高频桶往低频桶收满 k 个,免比较免堆, O ( n ) O(n) O(n)。 |
| 295 | 295.数据流的中位数 | 🔴困难 | 中位数就是有序数组切成等长两半的切点 ------双堆对顶:大顶堆装较小的一半、小顶堆装较大的一半,中位数永远只在两个堆顶。插入拿 num 和大顶堆堆顶比------不超过堆顶进小半边、比堆顶大进大半边(保证小半边整体 ≤ 大半边),再 balance:哪边多出 2 个就搬堆顶过去,维护两堆大小差至多 1。addNum O ( log n ) O(\log n) O(logn),findMedian O ( 1 ) O(1) O(1)。 |
前置:PriorityQueue 的创建和常用 API
做题前先把工具摸熟。堆在 Java 里的实现是 PriorityQueue(优先队列),它只保证一件事:每次 poll/peek 拿到的都是当前堆内最"小"的那个元素 ("小"由你给的比较器定义)。内部是二叉堆的数组实现,不保证整体有序------遍历它得到的顺序是乱的,这是新手最常踩的坑。
创建堆的几种写法
| 写法 | 得到的堆 | 说明 |
|---|---|---|
new PriorityQueue<>() |
小顶堆 | 无参构造就是自然序小顶堆,默认是小顶堆,要大顶堆必须自己动手 |
new PriorityQueue<>((a, b) -> a - b) |
小顶堆 | lambda 写全的默认序,和上面完全等价 |
new PriorityQueue<>((a, b) -> b - a) |
大顶堆 | lambda 反向比较:b - a 让大的浮上去 |
new PriorityQueue<>(Comparator.reverseOrder()) |
大顶堆 | JDK 自带倒序比较器,语义直白、不怕减法溢出 |
new PriorityQueue<>(Comparator.comparingInt(x -> -x)) |
大顶堆 | 按相反数排,整数场景可用 |
new PriorityQueue<>(Comparator.comparing(e -> e.freq)) |
自定义 | 按对象的某个字段比较,347.前K个高频元素 里就是按频率比 |
一个坑:比较器里写
a - b在元素值域大时可能整型溢出(比如a = Integer.MIN_VALUE),稳妥写法是Integer.compare(a, b)。做题值域小无所谓,工程里要留心。
常用 API
| 方法 | 作用 | 复杂度 | 备注 |
|---|---|---|---|
offer(e) / add(e) |
入堆 | O ( log n ) O(\log n) O(logn) | 两个是同一个动作,add 满了会抛异常(无界堆碰不到) |
poll() |
弹出并返回堆顶 | O ( log n ) O(\log n) O(logn) | 拿走当前最"小"的元素,堆会自动调整 |
peek() |
只看堆顶不弹 | O ( 1 ) O(1) O(1) | 空堆返回 null,别 NPE |
size() |
元素个数 | O ( 1 ) O(1) O(1) | 本篇判断"堆里养了几个"全靠它 |
isEmpty() |
是否为空 | O ( 1 ) O(1) O(1) | 弹之前看一眼,防止空堆 poll 返回 null 拆箱炸掉 |
本篇三道题的套路就一句话:
offer进、poll出,堆顶永远是分界值------第 k 大是堆顶(小顶堆养 k 个),中位数是两个堆顶夹出来的。
215.数组中的第K个最大元素
题目链接 :215.数组中的第K个最大元素
题目描述 :给一个整数数组 nums 和整数 k,返回其中第 k 个最大的元素 (按排序后的名次算,重复元素各占一个名次,不是第 k 个不同的数)。
Follow-up追问 :希望把时间做到 O ( n ) O(n) O(n)。
思路一:直接排序(把数组升序排好,下标 n-k 处就是答案)
核心思想:
- 最朴素的出发点:"第 k 大"就是升序排序后下标
n - k处的那个数。排完直接取,没有一点弯路。 - 它是所有解法的基线:后面三种写法全在回答同一个问题------怎么少排序一点。只排到"够定位一个数"就停,是本题所有优化的共同方向。
- 笔试时间紧、数据规模小(n 不超过几千)时,这版写起来最快、最不容易出错,直接拿分。
时间复杂度 : O ( n log n ) O(n \log n) O(nlogn),其中 n 为数组长度,排序主导。
空间复杂度 : O ( log n ) O(\log n) O(logn),Arrays.sort 对基本类型是双轴快排,递归栈深度 log n \log n logn 级别。
java
class Solution {
public int findKthLargest(int[] nums, int k) {
Arrays.sort(nums); // 升序排完,第 k 大固定在下标 n-k
return nums[nums.length - k]; // k=1(最大值)落在末位,正好对得上
}
}
思路二:大顶堆(全部数入堆,连弹 k 次堆顶)
核心思想:
- 大顶堆的堆顶永远是当前堆内最大值 ------想拿"第 1 大",弹一次堆顶就是;连弹 k 次,第 k 次弹出的就是第 k 大。
- 这是最"贴着题面"的堆写法:堆顶按从大到小的顺序出队,弹到第 k 个收工。
- 代价在堆要装下全部 n 个数 :建堆 O ( n ) O(n) O(n) 之后每次弹出 O ( log n ) O(\log n) O(logn),总共 O ( n + k log n ) O(n + k \log n) O(n+klogn);k 接近 n 时就是 O ( n log n ) O(n \log n) O(nlogn),没比全排序省。改进方向就是思路三------既然只关心前 k 个,堆里凭什么留 k 个以外的数。
时间复杂度 : O ( n + k log n ) O(n + k \log n) O(n+klogn),其中 n 为数组长度。建堆 O ( n ) O(n) O(n),k 次弹出各 O ( log n ) O(\log n) O(logn);k 与 n 同阶时退化为 O ( n log n ) O(n \log n) O(nlogn)。
空间复杂度 : O ( n ) O(n) O(n),堆装下全部元素。
java
class Solution {
public int findKthLargest(int[] nums, int k) {
// 大顶堆:堆顶永远是当前最大值,出队顺序就是从大到小
// 三种等价写法,任选其一:
// 1. Comparator.reverseOrder() ------ JDK 自带的倒序比较器
// 2. lambda:(a, b) -> b - a ------ b-a 正序反过来就是大顶堆
// 3. lambda:Comparator.comparingInt(x -> -x) ------ 按相反数排
PriorityQueue<Integer> heap =
new PriorityQueue<>(Comparator.reverseOrder());
// PriorityQueue<Integer> heap = new PriorityQueue<>((a, b) -> b - a);
for (int num : nums) {
heap.offer(num);
}
int ans = 0;
for (int i = 0; i < k; ++i) {
ans = heap.poll(); // 弹 k 次:第 1 次是第 1 大,第 k 次就是第 k 大
}
return ans;
}
}
思路三:小顶堆(养一个大小为 k 的候选池,堆顶就是第 k 大)
核心思想:
- "第 k 大"换个说法就是:前 k 个最大的数里,最小的那一个 。既然要的是这 k 个数里最小的,那用小顶堆最顺------小顶堆的堆顶永远是堆内最小值,恰好替我们盯着这个"守门员"。
- 扫数组,每个数都入堆;堆大小一旦超过 k,立刻弹掉堆顶。被弹掉的数是当前候选里最小的,它进不了前 k 大,以后也回不来,弹得毫无牵挂。
- 扫完时堆里恰好剩下前 k 大的数,堆顶就是第 k 大 。和思路二的区别就在堆只养 k 个:log 因子从 log n \log n logn 降到 log k \log k logk,空间从 O ( n ) O(n) O(n) 降到 O ( k ) O(k) O(k)。这个"大小为 k 的小顶堆"是所有
Top-K问题的通用模板,347.前K个高频元素 换个比较维度就能复用。
时间复杂度 : O ( n log k ) O(n \log k) O(nlogk),其中 n 为数组长度。每个元素一次入堆、至多一次弹出,堆大小始终不超过 k,单次是 O ( log k ) O(\log k) O(logk)。
空间复杂度 : O ( k ) O(k) O(k),堆里最多 k 个数。
java
class Solution {
public int findKthLargest(int[] nums, int k) {
// 小顶堆:堆顶永远是堆内最小值,专门守"前 k 大"的门
// 无参构造默认就是小顶堆(自然序),写全就是 new PriorityQueue<>((a, b) -> a - b)
PriorityQueue<Integer> heap = new PriorityQueue<>();
for (int num : nums) {
heap.offer(num);
if (heap.size() > k) {
heap.poll(); // 弹掉目前最小的:它进不了前 k 大,以后也用不上
}
}
return heap.peek(); // 堆里剩 k 个数,堆顶恰好是第 k 大
}
}
思路四:快速选择(三路划分,等于基准的一整批同时归位)
核心思想:
- 完整排序是 O ( n log n ) O(n \log n) O(nlogn),但我们只关心一个位置 的数。快速选择每次随机挑一个基准
pivot,把当前数集划成三区:big(> pivot)、equal(= pivot)、small(< pivot),等于 pivot 的一整批同时归位------big 排最前、equal 紧随其后、small 垫底,它们的名次区间整体确定,答案落进 equal 直接收工。 - 三区怎么和 k 对上:
k <= big.size()说明答案在 big 区,带着原 k 递归;排除 big 和 equal 后名额不够(nums.size() - small.size() < k,即 big+equal 的个数 < k)说明答案在 small 区,递归时 k 要减掉前面已确定排掉的 big+equal 个数;两边都不满足,答案恰好落在 equal 区,返回 pivot。 - 相比二路
partition的"一次归位一个",三路划分多一个硬好处:重复元素多时不退化 ------全相等的数组一次划分直接出答案。期望复杂度 O ( n ) O(n) O(n)(每次随机划分,处理量几何级数下降)。代价是每次递归新建三个 List 装元素 ,空间 O ( n ) O(n) O(n),换到的是"不动原数组"。
时间复杂度 :期望 O ( n ) O(n) O(n),其中 n 为数组长度;最坏 O ( n 2 ) O(n^2) O(n2)(每次都极不均匀地划分),随机化后实际几乎碰不到。
空间复杂度 : O ( n ) O(n) O(n),每层递归新建的 big/equal/small 总元素数不超过 n,期望几层就收敛。
java
public class Solution {
private int quickSelect(List<Integer> nums, int k) {
// 随机选择基准数:防有序/重复用例把划分次次打偏,退化成 O(n^2)
Random rand = new Random();
int pivot = nums.get(rand.nextInt(nums.size()));
// 将大于、小于、等于 pivot 的元素划分至 big, small, equal 中
List<Integer> big = new ArrayList<>();
List<Integer> equal = new ArrayList<>();
List<Integer> small = new ArrayList<>();
for (int num : nums) {
if (num > pivot)
big.add(num);
else if (num < pivot)
small.add(num);
else
equal.add(num); // 和 pivot 相同的一整批,名次区间整体确定
}
// 第 k 大元素在 big 中,递归划分:big 全是前 |big| 名,k 不用改
if (k <= big.size())
return quickSelect(big, k);
// 第 k 大元素在 small 中,递归划分:k 减去前面已确定排掉的 big+equal 个数
if (nums.size() - small.size() < k)
return quickSelect(small, k - nums.size() + small.size());
// 第 k 大元素在 equal 中,直接返回 pivot
return pivot;
}
public int findKthLargest(int[] nums, int k) {
List<Integer> numList = new ArrayList<>();
for (int num : nums) {
numList.add(num);
}
return quickSelect(numList, k); // 全程只读 List,不动原数组
}
}
四种写法一张表收束(n 为数组长度):
| 写法 | 时间 | 空间 | 一句话定位 |
|---|---|---|---|
| 直接排序 | O ( n log n ) O(n \log n) O(nlogn) | O ( log n ) O(\log n) O(logn) | 基线,笔试最稳的保底 |
| 大顶堆 | O ( n + k log n ) O(n + k \log n) O(n+klogn) | O ( n ) O(n) O(n) | 最贴题面:从大到小连弹 k 次 |
| 小顶堆 | O ( n log k ) O(n \log k) O(nlogk) | O ( k ) O(k) O(k) | Top-K 模板,k 越小越划算 |
| 快速选择 | 期望 O ( n ) O(n) O(n) | O ( n ) O(n) O(n) | Follow-up 的正解,三路划分对重复元素友好 |
347.前K个高频元素
题目链接 :347.前K个高频元素
题目描述 :给整数数组 nums 和整数 k,返回出现频率前 k 高 的元素,任意顺序都算对。题目保证答案唯一(不存在频率并列挤占名额的情况),且 1 <= k <= 不同元素的个数,可以不排序直接返回。
思路一:哈希计数 + 小顶堆(按频率养候选池,堆顶随时让位)
核心思想:
- "前 k 高频"的第一步永远是把频率数出来------哈希表一次扫描,每个数出现几次存几次。
- 数完之后问题退化成纯 Top-K:和 215.数组中的第K个最大元素 的模板一模一样,只是比较对象从"数值"换成"频率"------按频率养一个大小为 k 的小顶堆,堆顶是候选里频率最低的。
- 扫哈希表逐个入堆,堆超过 k 就弹堆顶:被弹的元素频率进不了前 k 高,后面也不会有它的记录再进来。扫完堆里剩下的就是答案。
时间复杂度 : O ( n log k ) O(n \log k) O(nlogk),其中 n 为数组长度。计数 O ( n ) O(n) O(n),哈希表至多 n 个不同元素,每个一次入堆、至多一次弹出。
空间复杂度 : O ( n ) O(n) O(n),哈希表和堆。
java
class Solution {
public int[] topKFrequent(int[] nums, int k) {
Map<Integer, Integer> cnt = new HashMap<>();
for (int num : nums) {
cnt.put(num, cnt.getOrDefault(num, 0) + 1); // 计数:没见过从 0 起加
}
// int[] 的第一个元素表示值,第二个表示次数
// PriorityQueue<int[]> q = new PriorityQueue<>(new Comparator<int[]>() { // 小顶堆
// public int compare(int[] a, int[] b) {
// return a[1] - b[1];
// }
// });
PriorityQueue<int[]> q = new PriorityQueue<>(
// (a, b) -> a[1] - b[1]
(a,b) -> Integer.compare(a[1],b[1]) // 不做减法只比大小,比较器里防溢出的稳妥写法
);
for (Map.Entry<Integer, Integer> entry : cnt.entrySet()) {
int num = entry.getKey(), count = entry.getValue();
q.offer(new int[] { num, count });
if (q.size() > k) {
q.poll(); // 弹掉频率最低的:它进不了前 k 高频
}
}
int ans[] = new int[k];
for (int i = 0; i < k; ++i) {
ans[i] = q.poll()[0]; // 堆里剩下的 k 个就是前 k 高频元素
}
return ans;
}
}
思路二:桶排序(频率直接当桶下标,免比较免堆)
核心思想:
- 堆的 log \log log 因子来自"比较"。但频率有个哈希没有的性质:它的取值范围被 n 封顶------一个数最多出现 n 次。取值范围小且是整数,正对上桶排序的口味。
- 开
n + 1个桶,下标就是频率 :出现 3 次的数全挂进buckets[3]。放桶不用比较, O ( 1 ) O(1) O(1) 定位。 - 从最高频的桶往下收,收满 k 个停。频率高的桶先被扫到,天然有序。
时间复杂度 : O ( n ) O(n) O(n),计数一遍 + 放桶一遍 + 收桶最多扫一遍。
空间复杂度 : O ( n ) O(n) O(n),桶数组。
java
class Solution {
public int[] topKFrequent(int[] nums, int k) {
int n = nums.length;
Map<Integer, Integer> freq = new HashMap<>();
for (int num : nums) {
freq.merge(num, 1, Integer::sum);
}
// buckets[f] = 所有出现 f 次的数;频率封顶是 n,桶开 n+1 个
List<Integer>[] buckets = new List[n + 1];
for (Map.Entry<Integer, Integer> e : freq.entrySet()) {
int f = e.getValue();
if (buckets[f] == null) {
buckets[f] = new ArrayList<>();
}
buckets[f].add(e.getKey());
}
int[] ans = new int[k];
int idx = 0;
for (int f = n; f >= 1 && idx < k; --f) { // 从高频桶往低频桶收
if (buckets[f] == null) {
continue; // 没有出现 f 次的数,跳过
}
for (int num : buckets[f]) {
ans[idx++] = num;
if (idx == k) {
break; // 收满 k 个,够数了
}
}
}
return ans;
}
}
295.数据流的中位数
题目链接 :295.数据流的中位数
题目描述 :实现 MedianFinder 类:addNum(int num) 往数据流里加一个数;findMedian() 返回目前所有已加数的中位数------总数为奇数时是正中间那个,偶数时是中间两个的平均数。数据会一直加,两个操作都要求高效。
思路一:双堆对顶(大顶堆存小的一半,小顶堆存大的一半,中位数永远在堆顶)
核心思想:
- 中位数的本质:把有序数组切成等长两半 ,切点位置就是答案。静态数组好说,数据流的难点在于每次插入后切点会动 ------重新排序是 O ( n log n ) O(n \log n) O(nlogn),扛不住。
- 换个角度:我们其实不需要全局有序,只需要知道较小那一半的最大值 和较大那一半的最小值 。"动态维护一堆数的最大/最小"正是堆的主场------大顶堆装较小的一半,小顶堆装较大的一半,两个堆顶就夹住了切点。
- 于是查中位数变成 O ( 1 ) O(1) O(1):总数奇数时中位数在"多一个"的那堆的堆顶,偶数时取两堆顶平均。
- 插入怎么保证"两半之间有序"(小半边整体 ≤ 大半边)?拿
num和大顶堆堆顶比:num ≤堆顶就进小半边,比堆顶大才进大半边 ------进小半边的数不超过小半边最大值,进大半边的数超过它,两边永远不交叉。之后交给balance收尾:哪边多出 2 个,就把那边的堆顶搬一个去对面 ,维护"两堆大小差至多 1"。奇数时多出来的可能是任意一边,findMedian谁多取谁。
时间复杂度 :addNum O ( log n ) O(\log n) O(logn)(至多三次堆操作);findMedian O ( 1 ) O(1) O(1),只读两个堆顶。其中 n 为当前已加的数字个数。
空间复杂度 : O ( n ) O(n) O(n),两个堆合起来装着全部数字。
java
class MedianFinder {
private PriorityQueue<Integer> maxHeap; // 大顶堆存较小的一半元素
private PriorityQueue<Integer> minHeap; // 小顶堆存较大的一半元素
public MedianFinder() {
maxHeap = new PriorityQueue<>((a, b) -> b - a); // lambda 写大顶堆:b-a 让大的浮上去
minHeap = new PriorityQueue<>((a, b) -> a - b); // lambda 写小顶堆:默认序,写全就这么写
}
public void addNum(int num) {
// num 不超过小半边最大值就进小半边;比它大才进大半边------两边永远不交叉
if (maxHeap.isEmpty() || maxHeap.peek() >= num) { // 我默认先加大的
maxHeap.add(num);
} else {
minHeap.add(num);
}
balance(); // 调整两个堆
}
public double findMedian() {
if (maxHeap.size() == minHeap.size()) { // 偶数个元素
return (double) (maxHeap.peek() + minHeap.peek()) / 2;
} else { // 奇数个:多的一边不定,谁多取谁的堆顶
return maxHeap.size() > minHeap.size() ? maxHeap.peek() : minHeap.peek();
}
}
private void balance() {
// 每次只加一个数,差最多到 2:到 2 就搬堆顶过去,回到差 1
if (Math.abs(maxHeap.size() - minHeap.size()) == 2) {
if (maxHeap.size() > minHeap.size()) {
minHeap.add(maxHeap.poll()); // 小半边多了:搬最大值去大半边
} else {
maxHeap.add(minHeap.poll()); // 大半边多了:搬最小值回小半边
}
}
}
}
/**
* Your MedianFinder object will be instantiated and called as such:
* MedianFinder obj = new MedianFinder();
* obj.addNum(num);
* double param_2 = obj.findMedian();
*/
流程图:
text
num ≤ maxHeap.peek() ──> 进 maxHeap(小半边)
num > maxHeap.peek() ──> 进 minHeap(大半边)
│
└──> balance:两堆差 2 就搬多的一边的堆顶过去
不变量:maxHeap 所有元素 ≤ minHeap 所有元素,且两堆大小差至多 1
查中位数:偶数取两堆顶平均;奇数取"多一个"那边的堆顶
总结:堆篇三道题全是同一套模板------要"前 K"就养一个大小为 K 的小顶堆,堆顶永远是那个分界值 。215.数组中的第K个最大元素 四种写法从 O ( n log n ) O(n \log n) O(nlogn) 一步步磨到期望 O ( n ) O(n) O(n):排序是基线,大顶堆贴题面,小顶堆是 Top-K 模板,快速选择 partition 归位后只递归目标那一半;347.前K个高频元素 先哈希计数再套同一个模板,频率封顶是 n 时可以换桶排序做到 O ( n ) O(n) O(n);295.数据流的中位数 是 Top-K 的对称版------双堆对顶各扛一半,中位数永远夹在两个堆顶。核心都是:不追求全局有序,只用堆动态维护"关心的那一截"的边界。