LeetCode 703 这道题,我在不同公司的面试里见过不下三次,而且基本都是作为热身题出现的。别看它难度只标了 Easy,真正能在五分钟内写出无误代码的候选人,比例比你想的低得多。题目本身一句话就能说清:在一个不断增长的数据流中,随时问你这个流里第 K 大的元素是多少。标准解法是用一个大小为 K 的最小堆,堆顶就是答案。但很多人在面试时容易栽在“为什么是最小堆而不是最大堆”这个点上,或者把堆的大小控制逻辑写错。这篇就把这道题拆开揉碎讲清楚,从暴力解一步步推到堆的优雅解,顺便把面试官可能追问的变体也一并总结掉,适合刚开始刷题的朋友,也适合准备面试想查漏补缺的老手。
先说一个很多人的误区:看到“第 K 大”,第一反应是维护一个最大堆,取堆顶。这个直觉对静态数组的 TopK 问题没问题,但放在“数据流”这个场景里就别扭了。数据流的特征是你不知道下一秒来什么,如果维护最大堆,堆里存的是当前流里最大的 K 个,那堆顶是这 K 个里最大的那个,根本代表不了“第 K 大”。反过来,如果你拿一个最小堆,让堆里永远只保留当前流中最大的 K 个元素,堆顶就是这个窗口里最小的那个,也就是全局第 K 大——这才是 703 的精髓。一个思路的反转,复杂度直接从排序的 O(n log n) 降到 O(log K),这就是我标题里说的“暴力美学”:结构最简单、思路最直接,但效果出奇的好。
1. 题目本质与解法演进:从暴力排序到堆的直觉
1.1 先把题目翻译成人话
LeetCode 703 要求你实现一个 KthLargest 类,构造函数接收一个整数 k 和一个初始数组 nums,之后会不断调用 add(val) 方法向数据流中追加元素,每次调用后返回当前所有已出现元素中第 K 大的值。
这里的“第 K 大”要按排序位置理解:把所有元素从大到小排列,取第 K 个位置的值。比如流里有 [5, 3, 3, 1],第 2 大是 3,第 3 大是 3——重复元素直接参与排名,不去重。
题面里藏着一个容易被忽略的保证:在调用 add 时,数据流中至少有 K 个元素。也就是说,即使初始的 nums 长度小于 K,你也不用担心后续查询时堆里元素不够的问题。但这个前提恰恰是很多人写初始化逻辑时踩坑的地方,后面代码部分我会专门讲。
这道题和“第 K 小”其实是对称的:一个长度为 n 的序列,第 K 大就是第 (n - K + 1) 小。面试时如果对方突然追问“改成第 K 小怎么做”,本质上就是在考你有没有真懂对称性。
1.2 暴力解法为什么会被淘汰
最直观的思路:把数据流全存下来,每次 add 之后对整个数组排序,然后取第 K 个元素。代码写起来一分钟就能搞定:
java复制class KthLargest {
private List<Integer> list;
private int k;
public KthLargest(int k, int[] nums) {
this.k = k;
list = new ArrayList<>();
for (int num : nums) {
list.add(num);
}
}
public int add(int val) {
list.add(val);
list.sort(Collections.reverseOrder());
return list.get(k - 1);
}
}
这个解法最大的问题是每次 add 都要做一次全量排序。设当前数据流长度为 M,单次 add 的复杂度是 O(M log M),空间上你要存下整个流,O(M)。如果调用 N 次 add,总复杂度粗略看是 O(N² log N) 的量级——数据量一大直接超时。
我在实际面试中见过不少候选人先答这个解法,这没问题,面试官通常不会直接否定,而是等你自己说出“不够好”。但如果你只停在排序这一步,没有任何优化思路,那基本就告别这一轮了。暴力解的价值在于它把问题定义得很清楚,等你想优化时,心里有一个明确的基准线:能不能在每次查询时少做点无用功?
1.3 从“全排序”到“只关心前 K 个”
排序之所以浪费,是因为我们根本不需要整个序列有序。题目只要第 K 大的元素,这意味着比第 K 大更大的那些元素的内部顺序,我们不在乎;比第 K 大更小的那些元素,我们压根不想保留。
换个角度想:这其实是一个“按需维护窗口”的问题。数据流不断进来,我只需要永远记住当前最大的 K 个就够了。新元素来了,如果比窗口里最小的还小,它根本不可能进入 Top K,直接丢弃;如果比窗口里最小的大,就把最小的踢出去,把它加进来。窗口大小始终是 K。
“窗口里最小的元素”就是我们要求的第 K 大。这个逻辑一旦想明白,数据结构的选择就呼之欲出:需要一个能高效拿到最小元素、又支持插入和删除的容器。堆(优先队列)完美命中。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 堆的核心原理与选型考量
2.1 堆是什么:一棵长在数组里的完全二叉树
如果你对堆还不熟,我尽量用一句话讲透:堆是一棵完全二叉树,用数组就能存,不需要指针。对于数组下标 i 的节点,它的左孩子下标是 2 * i + 1,右孩子是 2 * i + 2,父节点是 (i - 1) / 2。
最小堆的性质是:每一个父节点的值都小于等于它的孩子节点。所以整棵树的根就是堆里的最小值,取出它就是 O(1)。插入一个元素时,先放到数组末尾,然后不断和父节点比较、上浮;弹出堆顶时,把末尾元素挪到根,再和两个孩子中较小的那个比较、下沉。上浮和下沉都只沿着树的高度走,所以插入和删除的复杂度都是 O(log n),这里的 n 就是当前堆的大小。
很多人第一次接触堆时觉得它抽象,我的建议是不要死记代码,自己拿一张纸画一棵树,手动模拟一次插入和一次删除,半小时就能形成肌肉记忆。堆在算法题里的出场率极高:TopK、中位数、合并 K 个有序链表、任务调度,全都要靠它。
2.2 为什么是“最小堆”而不是“最大堆”
这是 703 这道题最核心的一个思考点,值得展开说。
如果维护最大堆,堆顶是当前流里的最大值。想拿第 K 大,你难道要连续弹出 K 次堆顶?每次弹出都会改变堆结构,而且下一次查询还得重新弹,完全不可持续。更关键的是,最大堆里存什么?如果只存 K 个元素,堆顶是这 K 个里的最大,不代表全局第 K 大;如果存所有元素,那空间和插入成本又回到全量存储的老路。
最小堆的思路正好反着来:堆里只留最大的 K 个元素。这 K 个里最小的是谁?就是堆顶。而这个“K 个里最小的”恰好就是全局第 K 大。你可以把堆想象成一个只有 K 个座位的排行榜,新人要进来,必须把当前榜上最后一名挤下去,而“最后一名”永远是堆顶。
这个反直觉之处在于:求“第 K 大”却用“最小堆”,求“第 K 小”反而用“最大堆”。我在面试时喜欢把这一点抛给候选人,能讲清楚的人,说明对堆的理解是到位的,而不是背过答案。
2.3 复杂度对比:一次把账算明白
| 方案 | 单次 add 复杂度 | 查询复杂度 | 空间复杂度 | 缺点 |
|---|---|---|---|---|
| 全量排序 | O(M log M) | O(1) | O(M) | 每次全排序,数据量大必超时 |
| 有序数组 + 二分插入 | O(M) | O(1) | O(M) | 插入要搬移元素,线性开销 |
| 二叉搜索树 / 红黑树 | O(log M) | O(log M) | O(M) | 实现复杂,Java 里 TreeMap 不能直接取第 K 大 |
| 最小堆(大小 K) | O(log K) | O(1) | O(K) | 几乎无短板,工程最优解 |
注意看最后一行:单次 add 的复杂度取决于 K 而不是数据流总长度 M。如果 K 远小于 M,这个优势会被放大得非常明显。M 已经上亿,但 K 只有 100,你的堆只需要维护 100 个元素——这就是“数据流”场景下堆方案的核心竞争力:它天然只关心局部,不受历史数据总量膨胀的影响。
3. 完整实现与关键代码解析
3.1 Java 实现:PriorityQueue 的默认行为要记死
Java 里直接用 PriorityQueue 就行。一个最容易记混的点:PriorityQueue 默认是最小堆,也就是 peek() 拿到的是队列里最小的元素。很多人写 TopK 大问题时习惯性传一个 Comparator.reverseOrder() 把它变成最大堆,结果在 703 这道题里反而写错了。记住,这道题就是要最小堆。
java复制class KthLargest {
private PriorityQueue<Integer> minHeap;
private int k;
public KthLargest(int k, int[] nums) {
this.k = k;
// PriorityQueue 默认就是最小堆,不需要传 Comparator
minHeap = new PriorityQueue<>();
for (int num : nums) {
add(num);
}
}
public int add(int val) {
// 堆没满,直接进堆
if (minHeap.size() < k) {
minHeap.offer(val);
} else if (val > minHeap.peek()) {
// 新元素比当前第 K 大大,才有资格替换
minHeap.poll();
minHeap.offer(val);
}
return minHeap.peek();
}
}
这里的 add 方法有一个细节值得单独说:先判断 val > peek() 再执行替换,比“不管三七二十一先 offer 再 poll”更高效。原因是如果新元素比堆顶还小,它根本不可能进入 Top K,先插入再弹出白白多了一次 O(log K) 操作。虽然复杂度级别相同,但在高频调用的场景下,这个“差一个常数”的优化是实打实有价值的。
3.2 Python 实现:heapq 的坑与技巧
Python 的标准库 heapq 同样只提供最小堆,直接用就行。不过要注意,heapq 是一组函数操作普通 list,没有封装好的类,写起来有点“裸”。
python复制import heapq
class KthLargest:
def __init__(self, k: int, nums: list[int]):
self.k = k
self.heap = []
for num in nums:
self.add(num)
def add(self, val: int) -> int:
if len(self.heap) < self.k:
heapq.heappush(self.heap, val)
elif val > self.heap[0]:
# heapreplace 等价于 pop 后再 push,比两次操作更高效
heapq.heapreplace(self.heap, val)
return self.heap[0]
heapq.heapreplace 是很多 Python 刷题人的盲区。它做的事情是先弹出堆顶,再把新元素压入堆,但它比手动 heappop + heappush 快,因为内部少了重建堆结构的部分开销。这里使用它的前提是已经确认 val > 堆顶,否则把更大的元素压进去同时弹出更大的元素,逻辑就错了。
一个小细节:heap[0] 就是堆顶最小值,所有语言里最小堆的堆顶都是直接可读的,Python 里别写 heapq.heappop(self.heap) 去拿堆顶,那是弹出不是查看,会把堆结构改掉。
3.3 C++ 实现:默认是大顶堆这件事坑了无数人
C++ 的 priority_queue 默认是大顶堆,和 Java、Python 恰好相反。所以实现时要显式换成 greater<int>:
cpp复制class KthLargest {
private:
priority_queue<int, vector<int>, greater<int>> minHeap;
int k;
public:
KthLargest(int k, vector<int>& nums) {
this->k = k;
for (int num : nums) {
add(num);
}
}
int add(int val) {
if (minHeap.size() < k) {
minHeap.push(val);
} else if (val > minHeap.top()) {
minHeap.pop();
minHeap.push(val);
}
return minHeap.top();
}
};
priority_queue<int, vector<int>, greater<int>> 这块模板参数很劝退新手,但其实只要记住格式就行:第二个参数是底层容器,一般写 vector<int>,第三个参数是比较器,greater<int> 表示小顶堆,less<int>(默认)表示大顶堆。面试写 C++ 版时,我会先写注释“这里用的是小顶堆,别搞反”,避免一紧张写岔。
3.4 初始化时堆的构建过程拆解
构造函数里逐个 add(num) 的写法,本质上就是在做“边插入边淘汰”的实时维护。假设 nums = [4, 5, 8, 2],k = 3,执行顺序是:
- 插入 4,堆为
[4] - 插入 5,堆为
[4, 5] - 插入 8,堆为
[4, 5, 8],满 3 个了 - 插入 2,2 小于堆顶 4,被拦下,堆不变
此时 peek() 返回 4,确实就是 [4, 5, 8, 2] 里第 3 大的元素。
这个“拦下”的动作很关键:它保证了堆里永远只有不超过 K 个元素。如果初始数组很长,比如 1 万个元素,K 是 10,那么大部分元素进来之后都会被堆顶“挡住”,只有比当前第 10 大的元素才有资格入场,入场时还会踢走一个,堆的大小始终是 10。
3.5 另一种初始化写法:先全入堆再裁剪
还有一种常见写法是构造器里先把所有元素全部 offer 进去,然后循环 poll() 直到堆大小等于 K。比如:
java复制public KthLargest(int k, int[] nums) {
this.k = k;
minHeap = new PriorityQueue<>();
for (int num : nums) {
minHeap.offer(num);
}
while (minHeap.size() > k) {
minHeap.poll();
}
}
这种写法的正确性没问题,但有一个容易被忽略的前提:如果 nums.length < k,这个 while 循环一次都不会执行,堆里元素不足 K 个,之后 add 会先走 size() < k 的分支补到 K 个。从工程角度,我推荐用逐个 add 的写法,因为它把维护逻辑收敛到一处,构造器和后续调用共用同一套逻辑,不容易产生不一致。
4. 进阶变体与面试官常问的追问
4.1 边界条件专项处理
这道题的边界情况可以分为三组,我建议你在面试时主动把这三组都提一遍,会显得很稳。
第一组:nums 为空。对应堆初始就是空的,add 第一次调用时 size() < k 分支触发,直接入堆,没问题。第二组:k > nums.length 且后续 add 调用次数较少。题目保证最终流中至少 K 个元素,所以只要 add 被调足次数,堆一定会被填满,但如果面试官问“如果永远没填满怎么办”,你要能回答:此时不存在真正意义上的第 K 大,需要单独约定返回值,工程上一般返回 Null 或抛异常。第三组:重复元素。这是很多人容易想多的地方,实际上第 K 大统计的是排名位置而非唯一值,[3, 3, 3] 的第 2 大就是 3,不需要做任何去重处理。
4.2 追问一:如果数据流大到无法全部存储怎么办
这个问题实际上是送分题。堆方案的空间复杂度是 O(K),从一开始就没打算存全量数据。你可以这样回答:如果 K 本身也大到无法放进单机内存,那就引入分治,比如用多个堆分别维护分段数据的 TopK,再对 K 个堆顶做归并;或者在分布式场景下用类似“桶”的思路,先按值域分桶,定位到第 K 大所在的桶后再细化。但大多数面试场景下,能答出“堆的方案天然不依赖全量数据”就已经够了。
4.3 追问二:改成求第 K 小怎么办
把堆的方向反一下就行:用一个最大堆维护当前流里最小的 K 个元素,堆顶就是第 K 小。逻辑完全对称,核心不变——我们只需要维护一个大小为 K 的窗口,窗口内容从“最大的 K 个”换成“最小的 K 个”即可。Java 里只要把 PriorityQueue 传 Comparator.reverseOrder() 变成最大堆,分支里的比较符号也跟着反过来(val < peek() 才替换)。面试时能把这道题的对称变换讲清楚,说明你真正掌握了 TopK 问题的一般性解法。
4.4 追问三:数据流的中位数怎么求
这是 703 最经典的姊妹题,LeetCode 295。思路是把数据流分成两半:左半边用最大堆存较小的一半,右半边用最小堆存较大的一半,保证两个堆的大小差不超过 1。中位数要么是左堆堆顶,要么是左右堆顶的平均值。维护过程中,新元素先按大小决定进哪个堆,再通过堆间调整保持平衡。这个题和 703 一样,本质上都是“用堆维护动态数据流中的第 K 个位置”,只不过中位数是动态的 K(总长度的一半)。
4.5 追问题四:TopK 高频元素怎么做
如果从“第 K 大元素”变成“出现频率第 K 高的元素”,解法就变成了 LeetCode 347。思路是先遍历一遍用哈希表统计每个元素的频率,然后用一个大小为 K 的最小堆,堆里存 (元素, 频率) 对,按频率比大小。逻辑上跟 703 几乎一模一样,只是“大小”的比较对象从元素本身换成了频率。你会发现,一旦看透了堆维护 TopK 的套路,这一类题全是同一个内核的变体。
5. 常见问题与排查技巧实录
5.1 堆大小控制成谜:为什么有时堆里元素少于 K
我知道很多人在本地跑的时候会遇到这个现象:初始 nums = [],k = 3,连续 add 三次之后堆里才满。这不是 bug,是逻辑预期。代码里 size() < k 分支决定“不满就进”,所以堆在成长阶段是逐步填充的。唯一要注意的是,在堆还没满时,peek() 返回的并不是真正的第 K 大,因为此时元素总数还不到 K 个。题目保证查询时流中已有 K 个元素,所以生产环境安全;但如果你把这个类拿去做别的用途,一定要在文档里注明这个前提。
5.2 最容易栽的坑:比较方向写反
val > minHeap.peek() 和 val < minHeap.peek() 如果写反,堆里保留的就是最小的 K 个元素,返回的堆顶就会变成“第 K 小”而不是“第 K 大”。这种错误在代码 review 时极难发现,因为它不报错、不抛异常,只会静默地返回错误答案。我的习惯是写完代码立刻构造一个简单用例走一遍:k=3,初始 [1, 2, 3, 20],add(4) 应该返回 3。如果返回 2,说明方向反了。这种快速自测比反复看代码更有效。
5.3 堆顶判空:add 之后立刻 peek 安全吗
安全,但前提是堆不为空。第一次 add 时,由于 size() < k 分支直接入堆,堆里至少有一个元素,所以 peek() 不会空指针。但如果你改写了代码,比如把所有逻辑都塞进 else 分支,就可能出现堆为空时调用 peek() 的情况。防御式写法是先判断 size() == 0 再决定是否返回堆顶,尤其是当你把这段代码从 LeetCode 题解移植到实际项目时,防御式写法更稳妥,因为你无法控制调用方的行为。
5.4 性能误区:PriorityQueue 的 remove 操作慎用
有些人在处理“新元素需要替换旧元素”时,会想用 remove() 删掉某个具体值再插入。这种操作在 PriorityQueue 里是 O(K) 的,因为堆本身只保证堆顶有序,内部是无序的,要找到指定元素必须线性扫描。想都不用想,直接用 poll() 弹出堆顶再 offer(),两步都是 O(log K),这才是堆的正确用法。我在代码 review 里见过不少次这种“看起来对但复杂度爆炸”的写法,刷题时尤其要注意。
5.5 调试堆的利器:打印堆内容
如果代码跑出来不对劲,别急着瞪眼。往 add 方法里临时加一行 System.out.println(minHeap); 或者 print(self.heap),看每次操作后堆里的元素变化,通常一眼就能发现问题。堆的数组展开顺序和树结构不是直观有序的,比如 [3, 4, 5] 这个堆,数组里可能是 [3, 5, 4],这都正常。看到数组顺序不对不要慌,它不是排序数组。
之前有一次我帮朋友看代码,他坚持说“堆里元素顺序不对,代码有 bug”,其实是他在数组层面理解了堆的存储方式——堆只保证父节点小于孩子,不保证兄弟节点之间有序,所以数组展开后并非全局有序。这是新手最容易产生的误解之一。
5.6 一道题的自我检查清单
写完这道题,我建议你用下面这套清单自测,全部通过才算真正过关:
- 能徒手写对三种语言版本之一,且清楚默认堆是大顶还是小顶
- 能口头说清为什么“第 K 大”用“最小堆”
- 能处理
nums为空、nums.length < k、重复元素三种边界情况 - 能回答“改成第 K 小”“改成中位数”“改成高频元素”三个变体
- 能分析出单次
add的复杂度为什么是 O(log K) 而不是 O(log M)
最后再分享一个我个人的习惯:这题虽然简单,但我每次面试前都会重新在白纸上手写一遍,不借助 IDE 的自动补全。因为面试高压环境下,最容易出问题的正是这些“你觉得再熟悉不过”的基础题。堆的方向、比较符号、边界判断,任何一个环节写错都要花额外时间调试——而面试里,时间永远是最贵的。LeetCode 703 的“暴力美学”在于,它用最小堆这种最朴素的数据结构,以近乎粗暴的方式解决了动态 TopK 问题,代码简单到没有炫技空间,但里面的思路反转足以区分出真正理解算法的人和背答案的人。
