215. 数组中的第K个最大元素#
今天我们来解决一道堆的经典题目——LeetCode 215「数组中的第K个最大元素」。这道题在面试里出现频率非常高,因为它既能考察你对堆的理解,也能顺便看出你是否真的明白“Top K”类问题的本质。
📚 生活中的场景#
想象你在组织一场长跑比赛。比赛结束后,你不需要把所有选手从第一名到最后一名都完整排出来,你只想知道:
- 第1名是谁
- 第2名是谁
- 第K名是谁
如果为了找第K名,把所有人成绩都完整排序一遍,当然可以做,但有点“大炮打蚊子”。
更聪明的做法是:
我们只维护当前前 K 名。
一旦有人成绩不够好,就直接淘汰;只有足够优秀的人,才能留在“前 K 名候选区”里。
这就是小顶堆的思维。
问题描述#
题目目标#
给定整数数组 nums 和整数 k ,请返回数组中第 k 个最大的元素。这里指的是排序后的第 k 个最大元素,而不是第 k 个不同的元素。
示例 1#
输入: nums = [3,2,1,5,6,4], k = 2
输出: 5
说明: 把输入和输出放在一起看,会更容易直接抓住题目要求的变化。
💡 问题定义#
给定整数数组 nums 和整数 k,请返回数组中第 k 个最大的元素。
注意:
- 这里找的是“排序后的第
k个最大元素” - 不是“第
k个不同的元素”
例如:
输入:nums = [3,2,1,5,6,4], k = 2
输出:5java输入:nums = [3,2,3,1,2,4,5,5,6], k = 4
输出:4java🤔 最直接的思路#
最容易想到的方法就是:
- 先把数组整体排序
- 然后取排序后倒数第
k个元素
代码很简单,但时间复杂度是 O(nlogn)。
这当然能过,但如果面试官继续追问:
有没有比完整排序更高效的方法? 这时就该轮到堆登场了。
🚀 解法一:小顶堆(面试最稳)#
核心思路#
我们维护一个大小为 k 的小顶堆:
- 堆里始终保存“当前最大的
k个元素” - 堆顶就是这
k个元素里最小的那个 - 也就是“当前第
k大的元素”
遍历数组时:
- 如果堆还没满,直接加入
- 如果堆已满,且当前数字比堆顶大,说明它有资格进入前
k- 先弹出堆顶
- 再加入当前数字
- 如果当前数字不比堆顶大,直接忽略
遍历结束后,堆顶就是答案。
Java 代码#
class Solution {
public int findKthLargest(int[] nums, int k) {
// 小顶堆:始终维护“当前扫描过的元素里最大的 k 个数”
// 堆顶(最小值)就是这 k 个数里最小的那个,也就是当前第 k 大
PriorityQueue<Integer> minHeap = new PriorityQueue<>();
// 遍历数组中的每个元素,动态维护前 k 大候选集
for (int num : nums) {
// 边界情况1:堆中元素不足 k 个时,直接放入堆中补满候选集
if (minHeap.size() < k) {
minHeap.offer(num);
// 堆已满时,只有当当前元素比堆顶更大,才有资格进入“前 k 大”
} else if (num > minHeap.peek()) {
// 先移除当前候选集中最小的元素(也就是堆顶)
minHeap.poll();
// 再把更大的新元素加入候选集
minHeap.offer(num);
}
// 如果 num <= 堆顶,说明它不可能进入前 k 大,直接忽略
}
// 遍历结束后,堆顶就是第 k 大元素
return minHeap.peek();
}
}java📝 用例子走一遍#
以 nums = [3,2,1,5,6,4], k = 2 为例。
我们维护一个大小为 2 的小顶堆:
- 插入
3- 堆:
[3]
- 堆:
- 插入
2- 堆:
[2,3] - 此时前 2 大候选是
2和3
- 堆:
- 遇到
11 <= 2,没资格进入前 2- 堆不变:
[2,3]
- 遇到
55 > 2,可以挤掉当前最小的2- 堆变成:
[3,5]
- 遇到
66 > 3- 堆变成:
[5,6]
- 遇到
44 <= 5,进不了前 2- 堆保持:
[5,6]
最终堆顶是 5,也就是第 2 大元素。
🎯 为什么小顶堆是对的?#
关键在于两个不变量:
- 堆的大小始终不超过
k - 堆中始终保存当前扫描范围内最大的
k个元素
既然堆里保存的是最大的 k 个元素,那么:
- 堆顶是这
k个元素中最小的 - 它刚好就是“第
k大”
这个思路在很多 Top K 问题中都通用,比如:
- 前 K 个高频元素
- 数据流中的第 K 大元素
- 海量数据中的 Top K
⏱️ 复杂度分析#
小顶堆解法#
- 时间复杂度:
O(nlogk) - 空间复杂度:
O(k)
为什么不是 O(nlogn)?
因为堆的大小始终只有 k,每次入堆/出堆的代价是 logk,而不是 logn。
当 k 远小于 n 时,这种做法会比完整排序更划算。
⚡ 解法二:快速选择(平均更快)#
如果面试官继续追问“还能再优化吗”,你可以补充 快速选择。
它和快速排序很像,但每次只递归进入一边,因此平均时间复杂度可以做到 O(n)。
Java 代码#
class Solution {
public int findKthLargest(int[] nums, int k) {
// 第 k 大 => 升序数组中的下标 nums.length - k
int target = nums.length - k;
// 当前在 [left, right] 区间内做快速选择
int left = 0;
int right = nums.length - 1;
// 只要区间有效,就不断做 partition 缩小搜索范围
while (left <= right) {
// 以 nums[right] 为基准值做一轮划分,返回基准值最终落点
int pivotIndex = partition(nums, left, right);
// 命中目标下标,直接返回答案
if (pivotIndex == target) {
return nums[pivotIndex];
// 基准位置偏左,说明目标在右半区
} else if (pivotIndex < target) {
left = pivotIndex + 1;
// 基准位置偏右,说明目标在左半区
} else {
right = pivotIndex - 1;
}
}
// 理论上不会走到这里(题目保证 k 合法),仅作为兜底返回
return -1;
}
private int partition(int[] nums, int left, int right) {
// 选择最右侧元素作为基准值
int pivot = nums[right];
// smallerIndex 指向“下一个应放置小于 pivot 元素”的位置
int smallerIndex = left;
// 遍历 [left, right - 1],把小于 pivot 的元素都交换到前面
for (int index = left; index < right; index++) {
// 当前元素小于基准值,放到左侧“小元素区”
if (nums[index] < pivot) {
swap(nums, smallerIndex, index);
// 小元素区边界右移一位
smallerIndex++;
}
}
// 最后把基准值放到小元素区之后,完成最终落位
swap(nums, smallerIndex, right);
// 返回基准值的最终下标,用于下一轮二分式缩小区间
return smallerIndex;
}
private void swap(int[] nums, int first, int second) {
// 临时变量保存第一个位置的值
int temp = nums[first];
// 把 second 位置的值放到 first
nums[first] = nums[second];
// 把原 first 的值放到 second,完成交换
nums[second] = temp;
}
}java📝 两种方法怎么选?#
-
排序法
- 时间复杂度:
O(nlogn) - 优点:简单直接
- 缺点:做了很多没必要的工作
- 时间复杂度:
-
小顶堆法
- 时间复杂度:
O(nlogk) - 优点:稳定、好写、面试友好
- 缺点:不是理论上的最优平均复杂度
- 时间复杂度:
-
快速选择法
- 平均时间复杂度:
O(n) - 最坏时间复杂度:
O(n^2) - 优点:平均更快
- 缺点:代码复杂度更高,边界更容易写错
- 平均时间复杂度:
如果是面试,我建议:
- 先写小顶堆,稳
- 有余力再补充快速选择,体现深度
⚠️ 易错点提醒#
-
第K大不是第K个不同元素
- 重复元素也要参与排序
- 例如
[5,5,4]中第2大是5
-
堆要用小顶堆,不是大顶堆
- 我们要维护“前
k大” - 所以要让最弱的那个站在门口,方便随时淘汰
- 我们要维护“前
-
快速选择里的目标下标别写错
- 第
k大对应升序后的下标是n - k - 不是
k - 1
- 第
-
不要每次都先入堆再出堆
- 可以像上面的写法一样先比较,再决定是否替换
- 逻辑更清晰
🎨 数据结构图解#
graph TB
A[遍历数组] --> B{堆大小 < k?}
B -->|是| C[直接入堆]
B -->|否| D{当前元素 > 堆顶?}
D -->|否| E[忽略当前元素]
D -->|是| F[弹出堆顶后入堆]
C --> G[继续遍历]
E --> G
F --> G
G --> H[遍历结束]
H --> I[堆顶即第K大元素]
🌟 面试时怎么说#
-
先定义问题本质
- “这题本质上是一个 Top K 问题。”
-
给出核心策略
- “我用一个大小为
k的小顶堆,维护当前最大的k个元素。”
- “我用一个大小为
-
解释为什么是小顶堆
- “堆顶就是当前前
k大里最小的那个,也就是第k大。”
- “堆顶就是当前前
-
补充进阶方案
- “如果追求更优平均复杂度,还可以用快速选择。”
🎩 延伸应用#
这道题的思想可以直接迁移到很多经典问题:
-
前 K 个高频元素
- 先统计频率,再维护大小为
k的堆
- 先统计频率,再维护大小为
-
数据流中的第 K 大元素
- 每来一个新数字,就更新堆
-
海量日志 Top K
- 不可能把所有数据都排序,只能维护一个候选堆
这道题的关键不是“会不会排序”,而是你能不能意识到:我们真正关心的,只有前 k 名。抓住这个本质,堆的用法就自然了。