力扣解题-215. 数组中的第K个最大元素
给定整数数组 nums 和整数 k,请返回数组中第 k 个最大的元素。
请注意,你需要找的是数组排序后的第 k 个最大的元素,而不是第 k 个不同的元素。
你必须设计并实现时间复杂度为 O(n) 的算法解决此问题。
示例 1:
输入: [3,2,1,5,6,4], k = 2
输出: 5
示例 2:
输入: [3,2,3,1,2,4,5,5,6], k = 4
输出: 4
提示:
1 <= k <= nums.length <= 105
-104 <= nums[i] <= 104
Related Topics
数组、分治、快速选择、排序、堆(优先队列)
第一次解答
解题思路
核心方法:小顶堆(优先队列)解法,通过维护一个大小为k的小顶堆,堆中始终存储当前遍历到的前k个最大元素,堆顶是这k个元素中最小的(即最终的第k个最大元素),时间复杂度O(n×logk)(n为数组长度)、空间复杂度O(k),是工程中易实现且效率较高的解法(注:题目要求O(n)时间复杂度,此解法是次优但更易实现的方案)。
核心逻辑拆解
找第k个最大元素的核心思路是“筛选出前k大的元素,取其中最小的”,小顶堆正好适配这个需求:
- 小顶堆特性:堆顶元素是堆中最小值,插入/删除操作的时间复杂度为O(logk)(k为堆大小);
- 堆的维护规则:
- 先将数组前k个元素放入堆中,此时堆顶是前k个元素的最小值;
- 遍历数组剩余元素:若当前元素大于堆顶,说明该元素属于“前k大”,则移除堆顶,插入当前元素;
- 遍历结束后,堆顶即为整个数组的第k个最大元素。
具体执行逻辑
- 初始化小顶堆:创建容量为k的
PriorityQueue(Java默认是小顶堆); - 填充前k个元素:将数组前k个元素依次加入堆中;
- 遍历剩余元素:
- 对于每个元素
nums[i](i从k到nums.length-1):- 获取堆顶元素
peek(当前前k大元素中的最小值); - 若
nums[i] > peek,说明该元素比堆顶大,需替换堆顶:先移除堆顶,再插入当前元素;
- 获取堆顶元素
- 对于每个元素
- 返回结果:遍历结束后,堆顶元素即为第k个最大元素。
执行流程可视化(以示例1 nums=[3,2,1,5,6,4]、k=2为例)
| 步骤 | 操作 | 堆内元素 | 堆顶 | 说明 |
|---|---|---|---|---|
| 1 | 插入前2个元素[3,2] | [2,3] | 2 | 小顶堆自动排序,堆顶为2 |
| 2 | 遍历元素1 | 1<2,跳过 | [2,3] | 1不属于前2大,不处理 |
| 3 | 遍历元素5 | 5>2 | [3,5] | 移除2,插入5,堆顶变为3 |
| 4 | 遍历元素6 | 6>3 | [5,6] | 移除3,插入6,堆顶变为5 |
| 5 | 遍历元素4 | 4<5,跳过 | [5,6] | 4不属于前2大,不处理 |
| 6 | 结束 | - | 5 | 返回堆顶5(第2大元素) |
关键细节说明
- 小顶堆的选择:若使用大顶堆,需存储所有元素并取第k个,时间复杂度O(n×logn),效率低于小顶堆;
- 堆大小控制:始终保持堆大小为k,插入/删除的时间复杂度为O(logk),n=1e5、k=1e5时退化为O(n×logn),但k较小时(如k=100)效率接近O(n);
- Java PriorityQueue特性:默认是基于数组的小顶堆,
offer/poll/peek方法均为O(logk)时间复杂度; - 边界适配:k=n时,堆存储所有元素,堆顶为最小值(即第n个最大元素),符合需求。
性能说明
- 时间复杂度:O(n×logk)(前k个元素建堆O(k×logk),剩余n-k个元素每个O(logk),总O(n×logk));
- 空间复杂度:O(k)(堆的大小);
- 优势:
- 代码简洁,易实现,无需复杂的分治逻辑;
- 空间效率高,仅需存储k个元素;
- 实际运行效率稳定,适合大数据量场景(n=1e5时仍高效);
- 劣势:未达到题目要求的O(n)时间复杂度(理论最优的快速选择算法可实现)。
public int findKthLargest(int[] nums, int k) {
PriorityQueue<Integer> queue=new PriorityQueue<>(k);
for(int i=0;i<k;i++){
queue.offer(nums[i]);
}
for(int i=k;i<nums.length;i++){
Integer peek = queue.peek();
if(nums[i]>peek){
queue.poll();
queue.offer(nums[i]);
}
}
return queue.peek();
}
示例解答
解题思路
解法1:快速选择算法(最优解,O(n)平均时间复杂度)
核心方法:基于快速排序的“分治”思想,无需完全排序数组,只需找到第k大元素的位置,平均时间复杂度O(n),最坏O(n²)(可通过随机化基准优化),是满足题目O(n)时间要求的最优解法。
代码实现
public int findKthLargest(int[] nums, int k) {
// 第k大元素 = 升序排序后索引为 nums.length - k 的元素
int target = nums.length - k;
return quickSelect(nums, 0, nums.length - 1, target);
}
private int quickSelect(int[] nums, int left, int right, int target) {
// 分区,返回基准元素的索引
int pivotIndex = partition(nums, left, right);
if (pivotIndex == target) {
// 找到目标位置,返回该元素
return nums[pivotIndex];
} else if (pivotIndex < target) {
// 目标在右半区,递归右半区
return quickSelect(nums, pivotIndex + 1, right, target);
} else {
// 目标在左半区,递归左半区
return quickSelect(nums, left, pivotIndex - 1, target);
}
}
private int partition(int[] nums, int left, int right) {
// 随机选择基准元素,避免最坏情况
int randomIndex = left + (int) (Math.random() * (right - left + 1));
swap(nums, randomIndex, right);
int pivot = nums[right]; // 基准元素
int i = left - 1; // 小于基准的区域边界
for (int j = left; j < right; j++) {
if (nums[j] <= pivot) {
i++;
swap(nums, i, j);
}
}
// 将基准元素放到正确位置
swap(nums, i + 1, right);
return i + 1;
}
private void swap(int[] nums, int i, int j) {
int temp = nums[i];
nums[i] = nums[j];
nums[j] = temp;
}
核心逻辑说明
- 核心思想:
- 快速选择的核心是“分区(partition)”:选择一个基准元素,将数组分为“小于基准”和“大于基准”两部分,基准元素的位置即为其在有序数组中的最终位置;
- 第k大元素对应升序数组中索引为
nums.length - k的元素,只需找到该索引的元素即可,无需排序整个数组;
- 分区过程:
- 随机选择基准元素(避免有序数组导致的最坏情况);
- 将基准元素交换到区间末尾,遍历区间,将小于等于基准的元素移到左侧;
- 最后将基准元素放到左侧区域的末尾,返回其索引;
- 递归终止条件:
- 若基准索引等于目标索引,返回该元素;
- 否则递归处理左/右半区,直到找到目标元素。
性能说明
- 时间复杂度:平均O(n)(每次分区排除一半元素,总计算量n + n/2 + n/4 + ... ≈ 2n),最坏O(n²)(可通过随机化基准优化为几乎不可能出现);
- 空间复杂度:O(logn)(递归栈深度,可改为迭代版降至O(1));
- 优势:
- 满足题目O(n)时间复杂度要求,理论效率最高;
- 无需额外空间(除递归栈),空间复杂度优于堆解法;
- 劣势:
- 代码复杂度高于堆解法,需要理解快速排序的分区逻辑;
- 最坏情况时间复杂度O(n²)(随机化后可忽略)。
解法2:计数排序(O(n)时间,适合数值范围小的场景)
核心方法:利用题目中nums[i]的范围(-1e4~1e4),通过计数数组统计每个数值的出现次数,再从大到小遍历计数数组,找到第k个最大元素,时间复杂度O(n + M)(M为数值范围),空间复杂度O(M)。
代码实现
public int findKthLargest(int[] nums, int k) {
// 数值范围:-10000 ~ 10000,偏移量10000将负数转为非负
int offset = 10000;
int[] count = new int[20001]; // 0~20000对应-10000~10000
// 统计每个数值的出现次数
for (int num : nums) {
count[num + offset]++;
}
// 从大到小遍历计数数组,累计次数直到k
int remain = k;
for (int i = 20000; i >= 0; i--) {
remain -= count[i];
if (remain <= 0) {
// 转换回原数值
return i - offset;
}
}
// 题目保证k有效,不会走到这里
return -1;
}
核心逻辑说明
- 计数排序思想:
- 由于数值范围固定(-1e4~1e4),用长度为20001的计数数组统计每个数值的出现次数;
- 偏移量10000:将负数(-1e4~-1)转为0
9999,正数(01e4)转为10000~20000;
- 查找第k大元素:
- 从计数数组末尾(对应最大数值)开始遍历,累计出现次数;
- 当累计次数≥k时,当前数值即为第k大元素。
性能说明
- 时间复杂度:O(n + M)(n为数组长度,M=20001,可视为常数,总时间复杂度O(n));
- 空间复杂度:O(M)(固定20001,可视为O(1));
- 优势:
- 严格O(n)时间复杂度,且常数因子小,实际运行效率极高;
- 代码简洁,无需复杂的数据结构/算法;
- 劣势:
- 仅适用于数值范围已知且较小的场景,通用性差;
- 若数值范围大(如1e9),则空间复杂度不可接受。
总结
- 小顶堆解法(第一次解答):O(n×logk)时间+O(k)空间,易实现、稳定性高,工程实践首选;
- 快速选择算法:O(n)平均时间+O(logn)空间,满足题目要求,理论最优解;
- 计数排序解法:O(n)时间+O(M)空间,适合数值范围小的场景,效率极高;
- 关键技巧:
- 核心思想:找第k大元素无需完全排序,堆解法筛选前k大元素,快速选择直接定位目标位置;
- 场景选择:数值范围小选计数排序,工程实现选堆解法,追求理论最优选快速选择;
- 优化点:快速选择需随机化基准,避免最坏时间复杂度;堆解法优先选小顶堆,控制堆大小为k。