LeetCode Hot 100 · 堆篇
堆(Heap)在算法面试里的核心价值只有一句话:在「动态变化」的集合里,用 O(log n) 找到当前的最大值或最小值。它不关心全量有序,只关心「极值」,所以特别适合两类场景——Top-K(只保留前 k 个,随时淘汰最差的)和动态中位数(两个堆各管一半,始终卡在分界线上)。
Hot 100 里「堆」分类的三道题恰好覆盖了这三种经典用法:215 是「小顶堆维护 K 个最大值」、347 是「哈希计数 + 堆筛 Top-K」、295 是「大顶堆 + 小顶堆对半切」。本篇按「面试手撕」的标准重写,每道题都给出暴力解 → 最优解 → 复杂度逐步推导 → CodeTop 真实变体。
215. 数组中的第 K 个最大元素
题意
给定整数数组 nums 和整数 k,返回数组中第 k 大的元素(排序后的第 k 大,注意是「第 k 大」而非「第 k 个不同元素」,重复元素各算一个)。
输入:nums = [3,2,1,5,6,4], k = 2
输出:5 // 排序后 [1,2,3,4,5,6],倒数第 2 个是 5
难点与易错点
- 「第 K 大」要用小顶堆,不是大顶堆。直觉上「找最大」会想到大顶堆,但大顶堆必须把所有 n 个元素装进去再弹 k 次,空间 O(n)。正确做法是小顶堆只留 k 个——堆里始终装着「当前遇到的最大的 k 个」,堆顶恰好是这 k 个里最小的那个,也就是全局第 k 大。
- 重复元素各算一个。题目明确「非第 k 个不同元素」,所以
nums = [3,3,3,3], k = 1的答案是3,不能去重。 - 入堆与弹出的顺序不能反:必须先
offer再判断size() > k弹出,否则会漏掉当前元素直接淘汰、堆永远装不满 k 个。 - 边界:
k = 1时答案是最大值,k = n时答案是最小值;题目保证1 ≤ k ≤ n,不需要处理非法 k。
题目本质:小顶堆维护 K 个最大值。用大小为 k 的小顶堆遍历数组,当堆大小超过 k 时弹出最小值,最终堆顶就是第 k 大。
现实类比:招聘 Top-K。面试官只保留目前最优的 k 份简历(小顶堆),每来一份新简历,若比「现有 k 份里最差的」还好就替换进来,最后这 k 份里最差的那份,就是整体第 k 强。
容器选择:PriorityQueue<Integer>(Java 默认小顶堆),全程保持堆的大小不超过 k。
解法一:暴力 / 直观(排序)
把数组整体升序排序,倒数第 k 个(nums[n-k])就是第 k 大。
class Solution {
public int findKthLargest(int[] nums, int k) {
Arrays.sort(nums); // 全量升序排序
return nums[nums.length - k]; // 第 k 大 = 倒数第 k 个
}
}
复杂度逐步推导:Java 对基本类型数组用双轴快排,排序 O(n log n);之后按下标取元素 O(1)。总时间复杂度 O(n log n),空间取决于排序实现(原地,O(log n) 递归栈)。
瓶颈在哪:我们只关心「第 k 大」这一个元素,排序却把全部 n 个元素都排好了,做了大量「用不到」的工作。当 n 很大、k 很小(比如一百万个数里找第 10 大)时,n log n 的代价远超必要。
解法二:优化 / 最优(小顶堆维护 k 个)
只维护一个大小为 k 的小顶堆,遍历一遍数组,堆顶即答案。
class Solution {
public int findKthLargest(int[] nums, int k) {
// 小顶堆:始终维护「当前遇到的最大的 k 个元素」
PriorityQueue<Integer> minHeap = new PriorityQueue<>();
for (int num : nums) {
minHeap.offer(num); // 元素先入堆
// 堆大小超过 k,弹出堆顶(当前这 k+1 个里的最小值),淘汰掉非 Top-K 元素
if (minHeap.size() > k) {
minHeap.poll();
}
}
return minHeap.peek(); // 堆顶即第 k 大元素
}
}
复杂度逐步推导(禁止只记结论,一步步推):
- 外层遍历数组一遍,共 n 次迭代;
- 堆的大小始终被限制在 k(
size() > k就弹出一个),所以每次offer/poll都是 O(log k); - 最坏情况下(数组降序,每个新元素都比堆顶大、都要挤进去),每次迭代做「一次 offer + 一次 poll」两次堆操作,仍是 O(log k) 的常数倍;
- 所以总时间 = 外层 O(n) × 每次堆操作 O(log k) = O(n log k);
- 空间:堆最多存 k 个元素,O(k)。
为什么比排序快:排序是 O(n log n),堆解是 O(n log k)。当 k << n 时(海量数据取 Top-10),log k 远小于 log n,差距巨大;即使 k 逼近 n,二者也只差一个常数。这是「只维护局部信息,不追求全局有序」的思想红利。
💭 思考:找「第 K 大」为什么用小顶堆,而不是直觉上的大顶堆?——先想清楚诉求:我们只关心「最大的 k 个」,不关心其余。大顶堆要装进全部 n 个再弹 k 次,空间 O(n);小顶堆只留 k 个,堆顶恰好是「这 k 个里最小的」即全局第 k 大,空间 O(k)。再想排序为什么「亏」:它把全部 n 个都排好了,而我们只取一个元素,做了大量用不到的工作。看到「动态/海量集合里取第 K 大」这个信号,就锁定「大小为 k 的小顶堆,超了就弹」——这是把 O(n log n) 压到 O(n log k) 的核心。
多解法对比
| 解法 | 时间复杂度 | 空间复杂度 | 适用场景 |
|---|---|---|---|
| 全量排序 | O(n log n) | O(log n) | 数据量小,或干脆要返回「完整有序」 |
| 小顶堆维护 k 个 | O(n log k) | O(k) | 本题标准解,k 远小于 n 时优势巨大 |
| 快速选择(QuickSelect) | O(n) 平均 / O(n²) 最坏 | O(log n) | 只关心一个元素、可接受打乱数组时更快 |
本题约束下 n 可达 10⁵,排序 O(n log n) 能过但非最优;小顶堆 O(n log k) 是面试必须写出的标准答案,代码短、不易错、空间只占 O(k)。快速选择 O(n) 平均更快,但要处理 partition 与最坏退化,手撕容易翻车——面试时先交堆解,再主动提快选才是最优策略。
CodeTop 变体
- 字节 / 腾讯高频延伸:
剑指 Offer 40. 最小的 k 个数(LCR 159)——第 k 小,把本题的小顶堆换成大顶堆即可,一套模板正反两个方向。 - 流式变体:
703. 数据流中的第 K 大元素——把本题改成add()不断追加,解法完全一样(维护 k 大小小顶堆),是字节一面「把 215 改成流式」的经典追问。 - 去重变体:
414. 第三大的数——要求「去重后的第 3 大」,额外用 Set 去重再套 215 模板。 - 海量数据追问:面试官常问「一亿个整数,内存只有 1G,怎么找 Top-K?」——答案就是小顶堆 O(k) 空间 + 一遍磁盘流式扫描;再问「k 也很大、多机怎么并行?」——分片(哈希分区)+ 每片求局部 Top-K + 合并再求全局 Top-K(MapReduce 思想)。
- 同套路题:
347. 前 K 个高频元素(下一篇),本质就是「把『按值比较』换成『按频率比较』」的 215。
347. 前 K 个高频元素
题意
给定整数数组 nums 和整数 k,返回出现频率前 k 高的元素,顺序可以任意。
输入:nums = [1,1,1,2,2,3], k = 2
输出:[1,2] // 1 出现 3 次、2 出现 2 次,是频率最高的两个
难点与易错点
- 比较器要按「频率」而不是「元素值」排序。堆里存的是
[元素值, 频率]二元组,比较时必须用a[1] - b[1],新手常误写成按a[0](元素值)排,结果选出的是「值最小的 k 个」而非「频率最高的 k 个」。 - 比较器减法溢出:
a[1] - b[1]在本题频率 ≤ n ≤ 10⁵ 时不会溢出,但养成习惯用Integer.compare(a[1], b[1])或Comparator.comparingInt更稳健(频率差值理论上有溢出风险时要警惕)。 - 输出顺序任意:题目不要求结果有序,从堆里直接取出即可,不要去给结果排序(多此一举)。
- 边界:
k可能等于「不同元素的个数」(如[1,1,1,1], k=1),此时堆永远不会弹出任何元素;若k大于不同元素个数则返回全部。
题目本质:词频统计 + 小顶堆 Top-K。先用 HashMap 统计频率,再用大小为 k 的小顶堆按频率筛选前 k 名——是 215 的「值比较」换成「频率比较」的升级版。
现实类比:热搜榜 Top-K。统计每个词被搜索的次数(词频 map),维护一个只放 k 个位置的热搜榜(小顶堆),频率最低的那条随时可能被新的更热话题挤掉。
容器选择:Map<Integer, Integer> 统计频率(merge 一行搞定累加);PriorityQueue<int[]> 按频率维护小顶堆,比较器 (a, b) -> a[1] - b[1]。
解法一:暴力 / 直观(统计 + 全量排序)
先 HashMap 统计频率,再把所有「元素-频率」按频率降序全量排序,取前 k 个。
class Solution {
public int[] topKFrequent(int[] nums, int k) {
// 1. 统计词频
Map<Integer, Integer> freq = new HashMap<>();
for (int num : nums) {
freq.merge(num, 1, Integer::sum);
}
// 2. 所有 entry 按频率降序全量排序
List<Map.Entry<Integer, Integer>> entries = new ArrayList<>(freq.entrySet());
entries.sort((a, b) -> Integer.compare(b.getValue(), a.getValue()));
// 3. 取前 k 个的 key
int[] res = new int[k];
for (int i = 0; i < k; i++) {
res[i] = entries.get(i).getKey();
}
return res;
}
}
复杂度逐步推导:统计频率遍历 n 个元素 O(n);设不同元素有 m 个(m ≤ n),对 m 个 entry 排序 O(m log m),最坏 m = n 时是 O(n log n);取前 k 个 O(k)。总时间 O(n log n),空间(map + list)O(n)。
瓶颈在哪:我们又做了一次「全量排序」,但只需要前 k 个。此外 m 通常远小于 n(大量重复元素),却还是被排序拖到了 O(m log m)。
解法二:优化 / 最优(词频统计 + 小顶堆)
只维护一个大小为 k 的「按频率」小顶堆,把 215 的模板套过来。
class Solution {
public int[] topKFrequent(int[] nums, int k) {
// 1. 统计词频,Java 8 merge 一行完成累加
Map<Integer, Integer> freq = new HashMap<>();
for (int num : nums) {
freq.merge(num, 1, Integer::sum);
}
// 2. 小顶堆:按频率排序,存 [元素值, 频率],堆顶是频率最低的那个
PriorityQueue<int[]> minHeap =
new PriorityQueue<>((a, b) -> a[1] - b[1]);
for (Map.Entry<Integer, Integer> entry : freq.entrySet()) {
minHeap.offer(new int[]{entry.getKey(), entry.getValue()});
if (minHeap.size() > k) {
minHeap.poll(); // 淘汰频率最低的,保住高频 Top-K
}
}
// 3. 把堆中剩余的 k 个元素的 key 取出来(顺序任意)
return minHeap.stream()
.mapToInt(pair -> pair[0])
.toArray();
}
}
复杂度逐步推导(一步步推,别只背 O(n log k)):
- 第一步统计频率:遍历 n 个元素,每次
merge均摊 O(1),合计 O(n); - 第二步维护堆:设不同元素 m 个(m ≤ n),遍历这 m 个 entry,每次
offer(堆大小 ≤ k)为 O(log k),超过 k 时多一次poll也是 O(log k),合计 O(m log k); - 第三步取结果:堆里只剩 k 个元素,遍历取出 O(k);
- 总时间 = O(n) + O(m log k) + O(k),其中 m ≤ n,主导项是 O(n log k)(k 逼近 n 时退化为 O(n log n));
- 空间:HashMap 存 m 个元素 O(n) + 堆存 k 个 O(k),整体 O(n)。
与 215 的差异:多了一个「统计频率」的前置步骤(O(n)),堆的比较器从「按值」换成「按频率」,其余骨架完全一致。
💭 思考:为什么 347 是 215 的「换皮」,多出来的词频统计又带来了什么?——先看清 215 的骨架:小顶堆维护 k 个「最值」。347 只是把「比较度量」从元素值换成频率,所以前面多一步 HashMap 统计词频,堆里存
[元素值, 频率]二元组、比较器按a[1]排。再想约束红利:因为每个元素的频率最多是 n,所以还能更进一步——用「桶排序」,把频率 i 的元素放进第 i 个桶,从高到低取 k 个,做到 O(n)。看到「前 K 高频 / Top-K 按某个度量」这个信号,先套 215 的堆模板,再顺手提一句「度量有上界时可用桶排序到线性」,是展示约束敏感度的加分点。
多解法对比
| 解法 | 时间复杂度 | 空间复杂度 | 适用场景 |
|---|---|---|---|
| 统计 + 全量排序 | O(n log n) | O(n) | 不同元素很少、或干脆要完整频率榜 |
| 哈希 + 小顶堆 | O(n log k) | O(n) | 本题标准解,k 远小于 n 时最快 |
| 哈希 + 桶排序 | O(n) | O(n) | 频率有上界(≤ n)时进一步优化到线性 |
本题约束下 n ≤ 10⁵,k 常远小于 n,堆解 O(n log k) 是标准答案。面试亮点:主动补一句「因为每个元素的频率最多为 n,所以还能用『桶排序』——把频率 i 的元素放进第 i 个桶,从高到低取 k 个,做到 O(n)」,能体现对约束条件的敏感。
CodeTop 变体
- 字节 / 腾讯高频延伸:
692. 前 K 个高频单词——加一条规则「频率相同按字典序升序」,比较器要写成「频率降序、字典序升序」的复合比较,堆里存String+ 频率,是本题的最常考变体。 - 排序变体:
451. 根据字符出现频率排序——不是取前 k 个,而是把所有字符按频率降序重排输出,同样「哈希计数 + 堆/桶」套路。 - 比较器换皮:
973. 最接近原点的 K 个点——Top-K 的「度量」从频率换成「到原点的欧氏距离」,堆比较器换成距离,模板不变。 - 海量数据追问:字节一面常见「日志里找出现次数最多的 10 个 IP」——先哈希分片(把海量日志按 IP 哈希散到多机),每台机器统计局部词频 + 小顶堆求局部 Top-10,最后汇总再取全局 Top-10,MapReduce 思想。
295. 数据流的中位数
题意
设计一个 MedianFinder 类,addNum(int num) 向数据结构里追加一个整数,findMedian() 返回当前所有元素的中位数(有序序列中间的数;偶数个时取中间两个的平均)。
输入:addNum(1) → addNum(2) → findMedian() → 1.5 → addNum(3) → findMedian() → 2.0
难点与易错点
- 两个堆各管一半,且「下半」多一个。大顶堆
lowerMax存较小的一半、小顶堆upperMin存较大的一半,始终保证lowerMax.size() == upperMin.size()或lowerMax.size() == upperMin.size() + 1(下半可多一个)。一旦方向搞反(上半多一个),中位数取值逻辑就全乱。 - 插入的「两跳」顺序不能错:新数必须先入
lowerMax、再把lowerMax堆顶(较大一半里最大的)转移到upperMin,这样才能保证「upperMin所有元素 ≥lowerMax所有元素」,维持正确的有序划分。 - 大顶堆的构造:Java
PriorityQueue默认小顶堆,大顶堆必须new PriorityQueue<>(Collections.reverseOrder())或(a, b) -> b - a,漏了这一步两堆方向相同,结果全错。 - 返回值用
2.0除:偶数个时写(lowerMax.peek() + upperMin.peek()) / 2.0,若写成/ 2就是整数除法,会截断掉小数(3 / 2 = 1而非1.5)。 - 空流边界:题目保证
findMedian()在至少有一个元素时才调用,但面试时可主动说明「空时抛异常或返回 0」的处理。
题目本质:两个堆维护动态中位数。大顶堆存较小一半、小顶堆存较大一半,中位数即两堆堆顶(奇数取 lowerMax 顶,偶数取两顶平均)。
现实类比:两组学生按成绩分队。低分队用大顶堆(能 O(1) 知道低分队的最高分)、高分队用小顶堆(能 O(1) 知道高分队的最低分),两队人数差不超过 1,中位数就正好卡在两队分界处。
容器选择:PriorityQueue<Integer> 两个——maxHeap(大顶堆,存较小一半,需 Collections.reverseOrder())+ minHeap(小顶堆,存较大一半)。
解法一:暴力 / 直观(维护有序列表)
用一个列表存所有数,每次 addNum 用二分找到插入位置保持有序,findMedian 直接按下标取。
class MedianFinder {
// 有序列表,始终按升序排列
private final List<Integer> list = new ArrayList<>();
public void addNum(int num) {
// 二分查找插入位置,保持有序
int idx = Collections.binarySearch(list, num);
if (idx < 0) idx = -idx - 1; // 转成插入点
list.add(idx, num); // 插入位置之后的元素都要后移,O(n)
}
public double findMedian() {
int n = list.size();
if (n % 2 == 1) {
return list.get(n / 2); // 奇数个,正中
}
return (list.get(n / 2 - 1) + list.get(n / 2)) / 2.0; // 偶数个,中间两数平均
}
}
复杂度逐步推导:addNum 里二分查找 O(log n),但 list.add(idx, num) 需要把 idx 之后的元素整体后移,最坏 O(n),所以单次 addNum 是 O(n);findMedian 直接下标取 O(1)。连续插入 n 次累计 O(n²)。空间 O(n)。
瓶颈在哪:每次插入都要移动数组元素,「维护有序」本身很贵。数据流场景下 addNum 是高频操作,O(n) 的插入在 n 大时不可接受。
解法二:优化 / 最优(大顶堆 + 小顶堆对半切)
用两个堆把数据切成「较小一半 / 较大一半」,中位数从两个堆顶 O(1) 读出。
class MedianFinder {
// lowerMax(大顶堆)存较小的一半,堆顶是较小一半里的最大值
private final PriorityQueue<Integer> lowerMax =
new PriorityQueue<>(Collections.reverseOrder());
// upperMin(小顶堆)存较大的一半,堆顶是较大一半里的最小值
private final PriorityQueue<Integer> upperMin = new PriorityQueue<>();
public void addNum(int num) {
// 第一步:新数先入 lowerMax,再把 lowerMax 的最大值转移到 upperMin
// 保证 upperMin 里所有元素都 >= lowerMax 里所有元素(两堆有序划分)
lowerMax.offer(num);
upperMin.offer(lowerMax.poll());
// 第二步:保持 lowerMax.size() >= upperMin.size()(下半可多一个,中位数偏向 lowerMax 顶)
if (upperMin.size() > lowerMax.size()) {
lowerMax.offer(upperMin.poll());
}
}
public double findMedian() {
if (lowerMax.size() > upperMin.size()) {
// 奇数个元素,中位数是较小一半的最大值
return lowerMax.peek();
}
// 偶数个元素,中位数是两堆堆顶的平均值
return (lowerMax.peek() + upperMin.peek()) / 2.0;
}
}
复杂度逐步推导(一步步推):
addNum:一次lowerMax.offerO(log n) + 一次upperMin.offerO(log n) + 最多一次poll+offer再 O(log n)——都是常数次堆操作,每次堆操作 O(log n),所以单次addNum为 O(log n);findMedian:peek()只读堆顶不调整,O(1);- 连续 n 次
addNum累计 O(n log n); - 空间:两个堆合计存 n 个元素,O(n)。
为什么这是最优:中位数只依赖「中间那一两个数」,我们不需要全序,只需要「较小一半的最大」和「较大一半的最小」——恰好是两个堆顶。把每次 add 的代价从 O(n) 压到 O(log n),同时 findMedian 保持 O(1),是数据流场景的教科书解。
💭 思考:数据流中位数为什么是「两个堆对半切」,而不是维护有序列表?——先看有序列表为什么不行:
addNum要二分找位置 + 数组后移,插入 O(n),数据流场景高频调用直接超时。关键认知:中位数只依赖「中间那一两个数」,等价于「较小一半的最大值」和「较大一半的最小值」——这两个正是堆顶。于是用大顶堆存较小一半、小顶堆存较大一半,插入时走「先入 lowerMax、再把堆顶转移到 upperMin」的两跳,保证两堆有序划分,最后根据大小关系平衡两堆。看到「数据流 + 动态取中位数/分位数」这个信号,就锁定「双堆各管一半」。
多解法对比
| 解法 | addNum | findMedian | 空间 | 适用场景 |
|---|---|---|---|---|
| 维护有序列表 | O(n) | O(1) | O(n) | 插入极少、查询极多 |
| 大顶堆 + 小顶堆 | O(log n) | O(1) | O(n) | 本题标准解,插入查询都频繁 |
本题是「数据流」场景,addNum 会被反复调用,O(n) 的有序列表插入在 n 大时直接超时;双堆把插入降到 O(log n)、查询保持 O(1),是唯一满足大规模数据流约束的解法。面试时务必把「两个堆的平衡不变量」讲清楚——这才是本题要考的核心。
CodeTop 变体
- 字节 / 阿里高频延伸:
480. 滑动窗口中位数——数据流 + 固定窗口,每次窗口右移删一个元素、加一个元素,仍用双堆,但要处理「删除不在堆顶的元素」问题,进阶技巧是懒删除(用哈希表记录待删元素,延迟到它浮到堆顶再真删)。 - 兄弟题:
703. 数据流中的第 K 大元素——同样是「数据流 + 堆」,但只用一个小顶堆维护 k 个,与本篇 215 是同一模板。 - 真实追问话术:字节一面常问「如果元素都是整数且范围已知(如 0~100),怎么做?」——答案:值域小直接开桶计数,再按计数累加定位中位数,O(1) 插入 O(值域) 查询;「双堆删除元素怎么支持?」——引出懒删除;「多线程并发 addNum 怎么办?」——加锁或分段堆(体现并发意识)。
小结:堆三题覆盖了「堆」在面试里的三大核心用法——小顶堆 Top-K(215,维护最大的 k 个)、哈希计数 + 堆 Top-K(347,把比较度量换成频率)、双堆对半切(295,动态中位数)。共通心法是:堆只维护你关心的「极值局部」,不追求全局有序——正是这个「局部有序」,把一堆 O(n log n) 的题压成了 O(n log k) 或 O(log n)。
章末提问
- #215 为什么找「第 K 大」用小顶堆而不是大顶堆?——结论:因为小顶堆只留 k 个,堆顶恰好是「这 k 个里最小的」即全局第 k 大,空间 O(k);大顶堆必须装进全部 n 个再弹 k 次,空间 O(n)。
- #295 两个堆的平衡不变量是什么?为什么「下半可多一个」?——结论:不变量是
lowerMax.size() == upperMin.size()或前者多 1,且「上半所有元素 ≥ 下半所有元素」。下半多一个是为了奇数个时中位数直接取lowerMax堆顶,取值逻辑统一、不用特判奇偶。 - #215 的堆解 O(n log k) 和排序 O(n log n) 差在哪?k 逼近 n 时还有优势吗?——结论:差在把「全局有序」降成「只维护 k 个的局部有序」;k 逼近 n 时二者只差常数、优势消失,但 k 远小于 n 时 log k 远小于 log n,差距巨大,所以它是「海量数据取 Top-K」的标准答案。