堆与优先队列
从 LC 215 第 K 大出发,用大小 k 的最小堆把 O(n log n) 砍到 O(n log k)。
1. 核心原理
堆解决的是这一类问题:"数据源源不断来、我只关心当前的最大或最小、要能持续插入新元素"。Top-K、流式中位数、任务调度、Dijkstra 最短路里的"取最近未访问点"、哈夫曼编码里的"合并最小两堆"——全都可以归结成同一个动作:快速看顶 + 快速换顶。
排序也能做,但每来一个新元素就重新排一遍是 O(n log n),浪费在"把不关心的尾部也排好"上。堆只承诺三件事:
- 看顶
O(1):当前的极值是谁 - 插入
O(log n):放一个新元素进来,依然保证顶是极值 - 弹顶
O(log n):取走极值,让次极值升上去
物理形状是一棵完全二叉树——除最后一层外每层都满、最后一层从左到右紧密排布。这种形状可以直接塞进数组:下标 i 的左孩子是 2i + 1、右孩子是 2i + 2、父亲是 (i - 1) / 2。插入和删除沿一条根到叶的路径上浮或下沉,路径长度 ≤ log n。
Top-K 为什么用反向堆
求第 k 大用大小 k 的最小堆,求第 k 小用大小 k 的最大堆。这条反直觉的规则每年面试都有人写错。
原因:堆顶是"随时被踢"的那个。求第 k 大时,候选集里最不该留的就是最小那个,把它放在堆顶才方便随手弹掉。每来一个新元素就和堆顶比一下——比堆顶还小就丢弃,否则替换掉堆顶。扫完一遍 n 次,每次 O(log k),合计 O(n log k)。k 远小于 n 时比 O(n log n) 排序快不少。
各语言堆的默认方向
| 语言 | 默认堆 | 怎么变方向 |
|---|---|---|
Python heapq | 最小堆 | 存负数 heappush(h, -x),取的时候 -h[0] |
Java PriorityQueue | 最小堆 | new PriorityQueue<>(Comparator.reverseOrder()) |
Go container/heap | 无默认 | 必须自己实现 Less(i, j) bool |
C++ priority_queue | 最大堆(注意反过来) | 加 greater<int> 变最小堆 |
Python 的 heapq 没有最大堆,惯例做法是塞负数;C++ 的 priority_queue 默认是最大堆(和其他语言相反),别记反。
本章节奏
新写完 §2 三段通用模板后,按"静态数组 → 在线流 → 双流维护"推进,对应 LC 215 → 703 → 295。一条主线:堆顶就是答案候选。
2. 通用堆操作模板
下面三段骨架覆盖了 90% 的堆题:标准三件套、Top-K 维护、双堆中位数。后面例题全是把这三套套到不同场景。
2.1 标准三件套:建堆 / push / pop
import heapq
# 建堆:O(n) 原地把无序数组变成最小堆
arr = [3, 1, 4, 1, 5, 9, 2, 6]
heapq.heapify(arr) # arr 现在满足堆性质
# push:O(log n)
heapq.heappush(arr, 0)
# 看顶:O(1),arr[0] 就是当前最小
top = arr[0]
# 弹顶:O(log n)
smallest = heapq.heappop(arr)
# heapreplace = pop + push 一步合成,只走一次下沉,比分开快
# 注意:先返回旧顶、再压入新元素
old_top = heapq.heapreplace(arr, 7)import java.util.PriorityQueue;
import java.util.List;
// 建堆:传入 Collection 是 O(n);逐个 offer 是 O(n log n)
PriorityQueue<Integer> heap = new PriorityQueue<>(List.of(3, 1, 4, 1, 5, 9, 2, 6));
// push:O(log n)
heap.offer(0);
// 看顶:O(1)
int top = heap.peek();
// 弹顶:O(log n)
int smallest = heap.poll();
// Java 没有 heapreplace,等价写法是 poll() + offer() 两步
// 实际开销是两次 sift(一次下沉 + 一次上浮),略慢于 Python heapreplace
heap.poll();
heap.offer(7);#include <queue>
#include <vector>
using namespace std;
// 默认是最大堆——要最小堆得显式 greater<int>
priority_queue<int, vector<int>, greater<int>> heap;
// 批量建堆:直接用 vector 构造,O(n)
vector<int> arr = {3, 1, 4, 1, 5, 9, 2, 6};
priority_queue<int, vector<int>, greater<int>> heap2(arr.begin(), arr.end());
// push:O(log n)
heap.push(0);
// 看顶:O(1)
int top = heap.top();
// 弹顶:O(log n)
heap.pop();
// 同样没有 heapreplace,要 pop 后 push
heap.pop();
heap.push(7);三件套之外,heapify 是 O(n) 而不是 O(n log n)——叶子层占节点数一半且天然就是单元素堆不用动,倒数第二层每个节点最多下沉 1 步,往上每层翻倍但节点数减半,求和级数收敛到 O(n)。"一次性建堆 + pop k 次取 Top-K" 的总成本是 O(n + k log n),比"逐个 push 进堆"的 O(n log n) 更快。
2.2 Top-K:大小 k 的反向堆
import heapq
def top_k_largest(nums: list[int], k: int) -> list[int]:
"""返回前 k 大的元素(顺序不定);堆顶 = 第 k 大"""
heap = [] # 最小堆
for x in nums:
if len(heap) < k:
heapq.heappush(heap, x) # 堆没满,直接放
elif x > heap[0]:
heapq.heapreplace(heap, x) # 比门槛大,替换堆顶
return heap # 堆里就是 Top-Kimport java.util.PriorityQueue;
int[] topKLargest(int[] nums, int k) {
PriorityQueue<Integer> heap = new PriorityQueue<>(); // 默认最小堆
for (int x : nums) {
if (heap.size() < k) {
heap.offer(x);
} else if (x > heap.peek()) {
heap.poll();
heap.offer(x); // 没有 heapreplace,poll + offer
}
}
int[] ans = new int[k];
for (int i = 0; i < k; i++) ans[i] = heap.poll();
return ans;
}vector<int> topKLargest(vector<int>& nums, int k) {
// 求第 k 大用最小堆——堆顶是 Top-K 里最小的"门槛"
priority_queue<int, vector<int>, greater<int>> heap;
for (int x : nums) {
if ((int)heap.size() < k) {
heap.push(x);
} else if (x > heap.top()) {
heap.pop();
heap.push(x);
}
}
vector<int> ans;
while (!heap.empty()) { ans.push_back(heap.top()); heap.pop(); }
return ans;
}关键技巧:求 Top-k "大" 用最小堆——堆顶是候选集里最小的"门槛",新元素能不能进来就看比不比得过堆顶。求 Top-k "小" 完全对称,用最大堆。复杂度 O(n log k)。
2.3 双堆维护中位数
import heapq
class MedianStream:
def __init__(self):
self.left = [] # 最大堆(存 -x 模拟)
self.right = [] # 最小堆
def add(self, x: int) -> None:
# 三步走:先无脑放 left,再把 left 顶倒给 right,最后看是否回倒
heapq.heappush(self.left, -x)
heapq.heappush(self.right, -heapq.heappop(self.left))
if len(self.right) > len(self.left):
heapq.heappush(self.left, -heapq.heappop(self.right))
def median(self) -> float:
if len(self.left) > len(self.right):
return -self.left[0] # 总数奇数:中位数在 left 顶
return (-self.left[0] + self.right[0]) / 2import java.util.PriorityQueue;
import java.util.Comparator;
class MedianStream {
private final PriorityQueue<Integer> left; // 最大堆
private final PriorityQueue<Integer> right; // 最小堆
public MedianStream() {
// 用 Comparator.reverseOrder() 避免 (a,b) -> b - a 的溢出
left = new PriorityQueue<>(Comparator.reverseOrder());
right = new PriorityQueue<>();
}
public void add(int x) {
left.offer(x);
right.offer(left.poll());
if (right.size() > left.size()) {
left.offer(right.poll());
}
}
public double median() {
if (left.size() > right.size()) {
return left.peek();
}
return (left.peek() + right.peek()) / 2.0;
}
}class MedianStream {
priority_queue<int> left; // 默认最大堆
priority_queue<int, vector<int>, greater<int>> right; // 最小堆
public:
void add(int x) {
left.push(x);
right.push(left.top()); left.pop();
if (right.size() > left.size()) {
left.push(right.top()); right.pop();
}
}
double median() const {
if (left.size() > right.size()) return left.top();
return (left.top() + right.top()) / 2.0;
}
};双堆的核心是不变量 |left| - |right| ∈ {0, 1}——left 装较小的一半(最大堆,顶是这一半的最大值),right 装较大的一半(最小堆,顶是这一半的最小值)。"先无脑放 left → 倒给 right → 失衡再回倒"三步天然完成了排序,省得手写 if x <= left.peek() 的二选一分支。
自定义比较器小抄
- Python:
heapq不接受比较器,只能靠元组排序——(cnt, key)按cnt升序、cnt相等时按key升序。要反向用负号(-cnt, key)。 - Java:
new PriorityQueue<>((a, b) -> Integer.compare(b, a))是最大堆。别写(a, b) -> b - a——b - a在b接近Integer.MIN_VALUE时会溢出。 - C++:lambda 比较器要传
decltype(cmp)给模板参数,写成priority_queue<T, vector<T>, decltype(cmp)> pq(cmp)。注意比较器返回true的元素优先级低(和sort相反)。
3. 母题精讲:LC 215 数组中的第 K 个最大元素
链接:LeetCode 215
题意:给一个整数数组 nums 和整数 k,返回数组中第 k 大的元素。注意"第 k 大"不是去重后的第 k 大——比如 [3, 2, 1, 5, 6, 4] 第 2 大是 5,[3, 2, 3, 1, 2, 4, 5, 5, 6] 第 4 大是 4。
数据范围:
nums.length:1 到 10⁵nums[i]:−10⁴ 到 10⁴1 <= k <= nums.length
示例:
输入:nums = [3, 2, 1, 5, 6, 4], k = 2
输出:5
输入:nums = [3, 2, 3, 1, 2, 4, 5, 5, 6], k = 4
输出:4暴力解:能 AC 但浪费
最直觉的写法是排序,倒着取下标 k - 1:
class Solution:
def findKthLargest_sort(self, nums: list[int], k: int) -> int:
nums.sort()
return nums[-k]class Solution {
public int findKthLargestSort(int[] nums, int k) {
Arrays.sort(nums);
return nums[nums.length - k];
}
}class Solution {
public:
int findKthLargestSort(vector<int>& nums, int k) {
sort(nums.begin(), nums.end());
return nums[nums.size() - k];
}
};时间 O(n log n),空间 O(1)(或者 O(log n) 算上递归栈)。n = 10⁵ 时这是 10⁵ × 17 ≈ 1.7 × 10⁶ 次比较,能过,但浪费在哪?
我们其实只关心前 k 大那几个元素的相对顺序,剩下 n − k 个元素被排得明明白白却没人用。k = 2、n = 10⁵ 时,花 n log n 的力气只为拿出 2 个值——杀鸡用了牛刀。
只维护当前的 Top-k 候选集
换个思路:扫描数组,始终维护一个大小恰好为 k 的"Top-k 候选集"。每来一个新元素 x,跟候选集里最小的那个比一下:
- 如果
x比最小的还小,丢掉它(它不可能是 Top-k) - 否则把最小的踢出来,把
x加进去
扫完整个数组,候选集里就是真正的 Top-k,候选集里最小的那个就是第 k 大。
这个候选集本质上要支持三件事:
- 看一眼最小值
- 弹出最小值
- 插入一个新元素
排序数组能实现,但插入是 O(k)。能把这三件事都压到 O(log k) 的,就是堆(小顶堆)——这也是堆的"三件套"。
总成本:扫一遍 n 次,每次至多 O(log k),合计 O(n log k)。k 远小于 n 时,这比 O(n log n) 快不少。
代入 n = 10⁵、k = 100:
- 排序
O(n log n) ≈ 10⁵ × 17 = 1.7 × 10⁶ - 堆
O(n log k) ≈ 10⁵ × 7 = 7 × 10⁵
差距不大;但 k = 2 时堆只要 n 次操作,比排序快一个量级。这就是堆的核心卖点:只关心"部分极值"时,别走全排序的弯路。
下面这个交互演示走一遍 [3, 2, 1, 5, 6, 4] 在 k = 2 下的过程。蓝色是已经决定不进 Top-2 的位置,红色是当前在堆中的位置:
findKthLargest([3, 2, 1, 5, 6, 4], k=2)
维护大小 2 的最小堆,堆顶 = 当前 Top-2 中的最小值 = 候选答案
多语言实现
直接套 §2.2 的 Top-K 模板,扫一遍数组、返回堆顶。
import heapq
class Solution:
def findKthLargest(self, nums: list[int], k: int) -> int:
heap = []
for x in nums:
if len(heap) < k:
heapq.heappush(heap, x)
elif x > heap[0]:
heapq.heapreplace(heap, x) # pop + push 一步合成
return heap[0]class Solution {
public int findKthLargest(int[] nums, int k) {
PriorityQueue<Integer> heap = new PriorityQueue<>();
for (int x : nums) {
if (heap.size() < k) {
heap.offer(x);
} else if (x > heap.peek()) {
heap.poll();
heap.offer(x);
}
}
return heap.peek();
}
}class Solution {
public:
int findKthLargest(vector<int>& nums, int k) {
priority_queue<int, vector<int>, greater<int>> heap;
for (int x : nums) {
if ((int)heap.size() < k) {
heap.push(x);
} else if (x > heap.top()) {
heap.pop();
heap.push(x);
}
}
return heap.top();
}
};复杂度:时间 O(n log k)、空间 O(k)。
LC 215 还有个 O(n) 期望复杂度的快速选择(QuickSelect)——基于快排 partition 一次扔掉一半,平均 O(n),但最坏 O(n²)、常数大、代码长。面试里堆解一般够用,被追问"能不能更快"再补 QuickSelect。
4. 抽象:堆到底是什么?
堆的三件套
把堆想象成医院的分诊台——病人源源不断来,但每次叫号时窗口看一眼"谁优先级最高"就好,不需要把候诊队伍排得整整齐齐。堆就是这种"懒排序"结构,承诺三件事:
- 看顶
O(1):当前的极值(最小或最大)是谁 - 插入
O(log n):放一个新元素进来,依然保持极值在顶 - 弹顶
O(log n):取走极值,让次极值升上去
关键词是"部分排序"——堆不会给你一个完全有序的序列,它只保证"顶部那个是极值"。要想拿到全序,得连续 pop n 次(每次 O(log n)),合计 O(n log n),这其实就是堆排序的来历。
但只要你只看顶、只取前 k 个、或者反复维护"动态最值"——堆比排序快得多。
为什么是 O(log n)?
堆物理上是一棵完全二叉树——除了最后一层外每层都满,最后一层从左到右紧密排布。这种形状有个好处:可以直接塞进数组里,下标 i 的左孩子是 2i + 1、右孩子是 2i + 2、父亲是 (i - 1) / 2。
下图展示一个最小堆 [1, 3, 2, 7, 5, 4, 6] 同时以树形 + 数组形态出现,下标对应关系一目了然:
树形(最小堆) 数组下标对应
1 (0) arr = [1, 3, 2, 7, 5, 4, 6]
/ \ 0 1 2 3 4 5 6
3 2 (1, 2)
/\\ /\\ idx i → left = 2i+1, right = 2i+2
7 5 4 6 (3,4,5,6) → parent = (i-1)/2插入和删除靠"上浮"或"下沉"维护堆性质(父节点 ≤ 或 ≥ 它的孩子),每次操作只走一条根到叶的路径。
下面是 pop() 后的下沉演示——pop 把堆顶 1 弹掉、把末尾 6 提到根,然后从根开始 sift down 直到稳定:
Step 0: 把末尾 6 提到根(破坏堆序)
6 ← 新堆顶,但 6 > 子节点 3 和 2,违反最小堆
/ \
3 2
/ \\ /
7 5 4
Step 1: 6 与较小的孩子 2 交换
2
/ \
3 6 ← 6 下沉一层
/ \\ /
7 5 4
Step 2: 6 与较小的孩子 4 交换(也可以走另一条路;选小的)
2
/ \
3 4 ← 6 继续下沉
/ \\ /
7 5 6
终态:堆性质恢复,pop 返回原堆顶 1,剩余堆 [2, 3, 4, 7, 5, 6]下沉路径长度 ≤ 树高 = log n,所以 pop 是 O(log n)。push 完全对称——把新元素塞到末尾,与父节点比较向上交换(sift up),同样 O(log n)。面试时不用手撕这套图,但这条心里要有数。
为什么 heapify 是 O(n) 而不是 O(n log n)
把一个无序数组一次性变成堆(heapq.heapify(arr) / Java PriorityQueue(List))的复杂度是 O(n),不是直觉上的 n log n。面试官追问时这是必懂点。
直觉:从下往上建堆。叶子层(占节点数一半)天然就是单元素堆,不用动。倒数第二层每个节点最多下沉 1 步,倒数第三层最多下沉 2 步,依此类推。把每层的"节点数 × 单次代价"加起来:
总代价 = Σ (h 从 0 到 log n) [第 h 层节点数] × [最多下沉步数]
= Σ h · (n / 2^(h+1))
≈ n · Σ h / 2^(h+1)
≤ n · 2 = O(n)最后那个级数 Σ h / 2^h ≤ 2 是经典收敛结果(错位相减能算出来)。节省的关键是叶子层占一半且完全不动——朴素的"n 个元素逐个 push" 会让每个元素都走完整条 log n 路径,那才是 O(n log n)。
工程意义:建堆 + pop k 次取 Top-K 是 O(n + k log n);当 k = n 退化为堆排序,整体 O(n log n)。
这就是堆全部秘密——剩下的题都是怎么把"堆顶=答案"这套模板套到不同场景上。
5. 例题(三道)
下面三道按"静态数组 → 在线流 → 双流维护"递进,全是堆三件套的不同打开方式:一次性扫完、每次 add 都要返回结果、同时维护两个堆。
5.1 LC 215 数组中的第 K 个最大元素(速记)
§3 已经把 LC 215 拆透了。这里只是把它列在"例题三道"系列里做一个对照锚点——大小 k 的最小堆扫一遍,堆顶就是答案,时间 O(n log k)、空间 O(k)。
Follow-up:上面是一次扫完所有元素。要是元素一个一个来、每来一个就要立刻返回当前的第 k 大呢?这就是下一题。
5.2 LC 703 数据流中的第 K 大元素
链接:LeetCode 703
题意:设计一个类 KthLargest:
KthLargest(k, nums):用初始数组nums和参数k初始化add(val):把val加入数据流,返回当前数据流中的第 k 大元素
数据范围:
1 <= k <= 10⁴- 调用
add的次数:1 到 10⁴ nums[i]、val:−10⁴ 到 10⁴
示例:
KthLargest(3, [4, 5, 8, 2])
add(3) → 4 // 流变成 [4, 5, 8, 2, 3],第 3 大 = 4
add(5) → 5 // 流变成 [4, 5, 8, 2, 3, 5],第 3 大 = 5
add(10) → 5 // 流变成 [4, 5, 8, 2, 3, 5, 10],第 3 大 = 5
add(9) → 8 // 流变成 [4, 5, 8, 2, 3, 5, 10, 9],第 3 大 = 8
add(4) → 8这就是 LC 215 的"在线版本"——把 LC 215 里那个"大小 k 的最小堆"持久化到对象里就完事了。初始化时跑一遍 nums,之后每次 add 只做一次 push(或者 push 之后 pop 一个)。堆顶永远是当前第 k 大,规则没变。
import heapq
class KthLargest:
def __init__(self, k: int, nums: list[int]):
self.k = k
self.heap = []
for x in nums:
self.add(x) # 复用 add,省得写两遍
def add(self, val: int) -> int:
if len(self.heap) < self.k:
heapq.heappush(self.heap, val)
elif val > self.heap[0]:
heapq.heapreplace(self.heap, val)
return self.heap[0]class KthLargest {
private final int k;
private final PriorityQueue<Integer> heap;
public KthLargest(int k, int[] nums) {
this.k = k;
this.heap = new PriorityQueue<>();
for (int x : nums) {
add(x);
}
}
public int add(int val) {
if (heap.size() < k) {
heap.offer(val);
} else if (val > heap.peek()) {
heap.poll();
heap.offer(val);
}
return heap.peek();
}
}class KthLargest {
int k;
priority_queue<int, vector<int>, greater<int>> heap;
public:
KthLargest(int k, vector<int>& nums) : k(k) {
for (int x : nums) {
add(x); // 复用 add,省得写两遍
}
}
int add(int val) {
if ((int)heap.size() < k) {
heap.push(val);
} else if (val > heap.top()) {
heap.pop();
heap.push(val);
}
return heap.top();
}
};复杂度:构造 O(n log k)、每次 add O(log k)、空间 O(k)。
构造函数里直接循环调 add,省得把 push 逻辑写两份。这里有个坑:题面允许 nums.length < k(甚至空数组),所以"堆还没满"的分支不能省——这就是 if len(heap) < k 存在的原因,容易忽略。
Follow-up:上面这两道都是"找单极端值"。要是题目要的是中位数——把数据流劈成大小相等的两半再取中间——一个堆就不够用了。
5.3 LC 295 数据流的中位数
链接:LeetCode 295
题意:设计 MedianFinder 类:
addNum(num):把num加入数据流findMedian():返回当前数据流的中位数(流长度为偶数时是中间两个的平均)
数据范围:
- 调用
addNum、findMedian总次数:1 到 5 × 10⁴ num:−10⁵ 到 10⁵
示例:
addNum(1)
addNum(2)
findMedian() → 1.5
addNum(3)
findMedian() → 2.0朴素思路:把每个新数插入一个有序数组——addNum 是 O(n),findMedian 是 O(1)。但 addNum 调用 5 × 10⁴ 次最坏 (5 × 10⁴)² = 2.5 × 10⁹,妥妥 TLE。
中位数有个天生的性质:它把数据劈成两半,左半都 ≤ 它、右半都 ≥ 它。既然要劈两半,那就维护两个堆:
- left:最大堆,存较小的一半(顶部是这一半的最大值)
- right:最小堆,存较大的一半(顶部是这一半的最小值)
约束 |left| - |right| ∈ {0, 1}(left 最多比 right 多一个):
- 总数偶数:两边一样多,中位数 = (left 顶 + right 顶) / 2
- 总数奇数:left 多一个,中位数 = left 顶
每次 addNum 要做两件事:把 num 放进合适的一边、维持两堆大小平衡。最简洁的写法就一句话——"先无脑放 left,再把 left 顶倒给 right,最后看是否要把 right 顶倒回 left"。三段式三步内必恢复不变量,比手写 if/else 干净得多。
import heapq
class MedianFinder:
def __init__(self):
self.left = [] # 最大堆(存 -num 模拟)
self.right = [] # 最小堆
def addNum(self, num: int) -> None:
# 三步走:先入 left,再把 left 顶丢给 right,最后平衡
heapq.heappush(self.left, -num)
heapq.heappush(self.right, -heapq.heappop(self.left))
if len(self.right) > len(self.left):
heapq.heappush(self.left, -heapq.heappop(self.right))
def findMedian(self) -> float:
if len(self.left) > len(self.right):
return -self.left[0] # 奇数个,中位数在 left 顶
return (-self.left[0] + self.right[0]) / 2class MedianFinder {
private final PriorityQueue<Integer> left; // 最大堆
private final PriorityQueue<Integer> right; // 最小堆
public MedianFinder() {
left = new PriorityQueue<>(Comparator.reverseOrder());
right = new PriorityQueue<>();
}
public void addNum(int num) {
left.offer(num);
right.offer(left.poll());
if (right.size() > left.size()) {
left.offer(right.poll());
}
}
public double findMedian() {
if (left.size() > right.size()) {
return left.peek();
}
return (left.peek() + right.peek()) / 2.0;
}
}class MedianFinder {
priority_queue<int> left; // 默认最大堆
priority_queue<int, vector<int>, greater<int>> right; // 最小堆
public:
MedianFinder() {}
void addNum(int num) {
// 三步走:先入 left,再把 left 顶丢给 right,最后平衡
left.push(num);
right.push(left.top());
left.pop();
if (right.size() > left.size()) {
left.push(right.top());
right.pop();
}
}
double findMedian() {
if (left.size() > right.size()) {
return left.top(); // 奇数个,中位数在 left 顶
}
return (left.top() + right.top()) / 2.0;
}
};复杂度:addNum O(log n)、findMedian O(1)、空间 O(n)。
为什么"先入 left、再倒给 right、再判断回倒"是对的?本质上这套动作天然完成了排序——num 进 left 后,弹出的 left 顶必然是"小半边里最大的",扔到 right 里恰好维持"右半全 ≥ 左半"的不变量。换一种写法 if num <= left.peek(): 进 left else: 进 right 也行,但还要单独加 size 平衡分支,代码量翻倍。
6. 边界、易错与复杂度
求第 k 大到底是最小堆还是最大堆?
"求第 k 大用最小堆、求第 k 小用最大堆"——这一条反直觉的设定每次面试都会有人写错。记忆法就一句话:堆顶是随时被踢的那个。求第 k 大时,候选集里只有真正大的能留下,"最该被踢"的就是最小那个,所以放在堆顶。
代码写不确定时,跑一个 size = 2 的 mental test:[1, 5, 3] 求第 2 大(答案是 3)。维护最小堆 size = 2 最后是 [3, 5],堆顶 = 3 ✓。如果错用最大堆,堆顶 = 5(这是第 1 大,不是第 2 大)。
Python 模拟最大堆别忘了正负号
Python 的 heapq 没有最大堆。模拟时三处都要写负号:
- push:
heapq.heappush(h, -x) - pop:
-heapq.heappop(h) - 看顶:
-h[0]
最容易漏的是"看顶"——h[0] 是堆里的负数,直接拿来用会差一个符号。LC 295 双堆里 return -self.left[0] 那处尤其要小心。
Java 比较器溢出陷阱
Java 写最大堆常用 new PriorityQueue<>((a, b) -> b - a)。但当 a、b 接近 Integer.MIN_VALUE 或 MAX_VALUE 时,b - a 会溢出。安全写法是 (a, b) -> Integer.compare(b, a) 或者直接 Comparator.reverseOrder(),永远不会溢出。
LC 215 / 703 数据范围 nums[i] ∈ [-10⁴, 10⁴],没风险;但有些题(比如 LC 1167 连接棒材的最低费用)值域可达 10⁹,溢出就出 bug。
Java 没有 heapreplace
Python 的 heapq.heapreplace(h, x) 一步完成"弹顶 + 入新"且只走一次下沉,比 pop() 后 push() 少一次上浮。Java PriorityQueue 没有等价 API,只能写成 heap.poll(); heap.offer(x);——两次 sift,常数略大。C++ 同样没有,写法相同。这在 LC 2233 K 次 +1 之类需要反复 replace 的场景里会有 1.5-2x 的常数差,但不影响渐近复杂度。
Go 的 heap.Interface 模板
Go 的 container/heap 不是开箱即用的容器,是接口包。一个堆需要实现五个方法:Len() int、Less(i, j int) bool、Swap(i, j int)、Push(x any)、Pop() any。其中:
Push/Pop操作的是底层 slice,不是堆——堆操作要走heap.Push(h, x)/heap.Pop(h)(注意大写、且要传 h 作为第一个参数)Pop返回 slice 末尾的元素(不是首位),因为heap包内部已经把要弹的元素 swap 到末尾了Less写<是最小堆,写>是最大堆——这是 Go 里改方向的唯一开关
记住"小写 Push/Pop 是 slice 操作,大写 heap.Push/Pop 才是堆操作"——容易混。
堆顶可能不是答案:滞后删除
LC 295 之所以能干净维护"双堆 size 差 ≤ 1",是因为只增不删。很多题(比如 LC 480 滑动窗口中位数)要求"删除一个旧元素再加新元素"——heapq 不支持 O(log n) 删除任意元素,咋办?
工程上的标准套路叫滞后删除(lazy deletion):用一个 to_delete 计数器记录"需要从堆中清除的元素",每次看顶 / 弹顶时先检查顶部是不是"应该被删"的,是就丢弃接着看下一个:
from collections import Counter
import heapq
class LazyHeap:
"""支持 O(log n) push / O(log n) 摊还 pop / O(log n) 摊还 peek
+ O(1) 标记删除任意元素(必须确保该元素当前在堆中)"""
def __init__(self):
self.heap = []
self.to_delete = Counter()
self.size = 0 # 逻辑大小(已剔除标记删除)
def push(self, x):
heapq.heappush(self.heap, x)
self.size += 1
def remove(self, x):
"""标记删除 x;真正清理推迟到 peek/pop 时"""
self.to_delete[x] += 1
self.size -= 1
def _clean_top(self):
while self.heap and self.to_delete[self.heap[0]] > 0:
self.to_delete[heapq.heappop(self.heap)] -= 1
def peek(self):
self._clean_top()
return self.heap[0]
def pop(self):
self._clean_top()
self.size -= 1
return heapq.heappop(self.heap)import java.util.*;
class LazyHeap {
// 最小堆 + 延迟删除计数表
private final PriorityQueue<Integer> heap = new PriorityQueue<>();
private final Map<Integer, Integer> toDelete = new HashMap<>();
private int size = 0; // 逻辑大小(已剔除标记删除)
public void push(int x) {
heap.offer(x);
size++;
}
/** 标记删除 x;真正清理推迟到 peek/pop 时。要求 x 当前在堆中 */
public void remove(int x) {
toDelete.merge(x, 1, Integer::sum);
size--;
}
private void cleanTop() {
while (!heap.isEmpty() && toDelete.getOrDefault(heap.peek(), 0) > 0) {
int top = heap.poll();
toDelete.merge(top, -1, Integer::sum);
}
}
public int peek() {
cleanTop();
return heap.peek();
}
public int pop() {
cleanTop();
size--;
return heap.poll();
}
public int size() { return size; }
}#include <queue>
#include <unordered_map>
class LazyHeap {
// 最小堆 + 延迟删除计数表
priority_queue<int, vector<int>, greater<int>> heap;
unordered_map<int, int> toDelete;
int sz = 0; // 逻辑大小(已剔除标记删除)
void cleanTop() {
while (!heap.empty() && toDelete[heap.top()] > 0) {
toDelete[heap.top()]--;
heap.pop();
}
}
public:
void push(int x) { heap.push(x); sz++; }
// 标记删除 x;真正清理推迟到 peek/pop 时。要求 x 当前在堆中
void remove(int x) { toDelete[x]++; sz--; }
int peek() { cleanTop(); return heap.top(); }
int pop() {
cleanTop();
int x = heap.top();
heap.pop();
sz--;
return x;
}
int size() const { return sz; }
};每个元素至多被 push 一次、pop 一次、cleanTop 处理一次,摊还仍是 O(log n)。这是 LC 480 滑动窗口中位数、LC 1438 最大子数组、LC 1696 跳跃游戏 VI 的通解骨架——配合双堆做中位数,配合单堆做单端最值。
进阶专题("堆 + 滑窗"那一章)会展开"to_delete 在双堆上同步"的细节,本节先把模板立起来。
复杂度速查
| 题 | 时间 | 空间 |
|---|---|---|
| LC 215 第 K 大(堆解) | O(n log k) | O(k) |
| LC 215 第 K 大(QuickSelect) | O(n) 期望 / O(n²) 最坏 | O(log n) 递归栈 |
| LC 703 数据流第 K 大 | 单次 O(log k) | O(k) |
| LC 295 数据流中位数 | addNum O(log n)、findMedian O(1) | O(n) |
8. 小结
堆就是个"部分排序"结构,三件套:O(log n) 插入、O(log n) 弹顶、O(1) 看顶。它的核心卖点一句话——只关心极值或前 k 个时,比全排序快。求第 k 大用大小 k 的最小堆 O(n log k),比 O(n log n) 排序更快。
进阶模板三招:大小 k 限制堆(LC 215 / 347)、双堆维护中位数(LC 295 / 480)、贪心 + 堆反复取极值(LC 502 / 2233 / 1642)。后面 字典树、并查集、树状数组、线段树 这些章节也会反复用到堆,作为子结构出现。
对应灵神题单:常用数据结构 · 堆与优先队列
进一步阅读:灵茶山艾府《堆题单》;LeetCode 堆标签