# 力扣解题-215. 数组中的第K个最大元素

4 阅读9分钟

力扣解题-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大的元素,取其中最小的”,小顶堆正好适配这个需求:

  1. 小顶堆特性:堆顶元素是堆中最小值,插入/删除操作的时间复杂度为O(logk)(k为堆大小);
  2. 堆的维护规则
    • 先将数组前k个元素放入堆中,此时堆顶是前k个元素的最小值;
    • 遍历数组剩余元素:若当前元素大于堆顶,说明该元素属于“前k大”,则移除堆顶,插入当前元素;
    • 遍历结束后,堆顶即为整个数组的第k个最大元素。
具体执行逻辑
  1. 初始化小顶堆:创建容量为k的PriorityQueue(Java默认是小顶堆);
  2. 填充前k个元素:将数组前k个元素依次加入堆中;
  3. 遍历剩余元素
    • 对于每个元素nums[i](i从k到nums.length-1):
      • 获取堆顶元素peek(当前前k大元素中的最小值);
      • nums[i] > peek,说明该元素比堆顶大,需替换堆顶:先移除堆顶,再插入当前元素;
  4. 返回结果:遍历结束后,堆顶元素即为第k个最大元素。
执行流程可视化(以示例1 nums=[3,2,1,5,6,4]、k=2为例)
步骤操作堆内元素堆顶说明
1插入前2个元素[3,2][2,3]2小顶堆自动排序,堆顶为2
2遍历元素11<2,跳过[2,3]1不属于前2大,不处理
3遍历元素55>2[3,5]移除2,插入5,堆顶变为3
4遍历元素66>3[5,6]移除3,插入6,堆顶变为5
5遍历元素44<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)(堆的大小);
  • 优势:
    1. 代码简洁,易实现,无需复杂的分治逻辑;
    2. 空间效率高,仅需存储k个元素;
    3. 实际运行效率稳定,适合大数据量场景(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;
}
核心逻辑说明
  1. 核心思想
    • 快速选择的核心是“分区(partition)”:选择一个基准元素,将数组分为“小于基准”和“大于基准”两部分,基准元素的位置即为其在有序数组中的最终位置;
    • 第k大元素对应升序数组中索引为nums.length - k的元素,只需找到该索引的元素即可,无需排序整个数组;
  2. 分区过程
    • 随机选择基准元素(避免有序数组导致的最坏情况);
    • 将基准元素交换到区间末尾,遍历区间,将小于等于基准的元素移到左侧;
    • 最后将基准元素放到左侧区域的末尾,返回其索引;
  3. 递归终止条件
    • 若基准索引等于目标索引,返回该元素;
    • 否则递归处理左/右半区,直到找到目标元素。
性能说明
  • 时间复杂度:平均O(n)(每次分区排除一半元素,总计算量n + n/2 + n/4 + ... ≈ 2n),最坏O(n²)(可通过随机化基准优化为几乎不可能出现);
  • 空间复杂度:O(logn)(递归栈深度,可改为迭代版降至O(1));
  • 优势:
    1. 满足题目O(n)时间复杂度要求,理论效率最高;
    2. 无需额外空间(除递归栈),空间复杂度优于堆解法;
  • 劣势:
    1. 代码复杂度高于堆解法,需要理解快速排序的分区逻辑;
    2. 最坏情况时间复杂度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;
}
核心逻辑说明
  1. 计数排序思想
    • 由于数值范围固定(-1e4~1e4),用长度为20001的计数数组统计每个数值的出现次数;
    • 偏移量10000:将负数(-1e4~-1)转为09999,正数(01e4)转为10000~20000;
  2. 查找第k大元素
    • 从计数数组末尾(对应最大数值)开始遍历,累计出现次数;
    • 当累计次数≥k时,当前数值即为第k大元素。
性能说明
  • 时间复杂度:O(n + M)(n为数组长度,M=20001,可视为常数,总时间复杂度O(n));
  • 空间复杂度:O(M)(固定20001,可视为O(1));
  • 优势:
    1. 严格O(n)时间复杂度,且常数因子小,实际运行效率极高;
    2. 代码简洁,无需复杂的数据结构/算法;
  • 劣势:
    1. 仅适用于数值范围已知且较小的场景,通用性差;
    2. 若数值范围大(如1e9),则空间复杂度不可接受。

总结

  1. 小顶堆解法(第一次解答):O(n×logk)时间+O(k)空间,易实现、稳定性高,工程实践首选;
  2. 快速选择算法:O(n)平均时间+O(logn)空间,满足题目要求,理论最优解;
  3. 计数排序解法:O(n)时间+O(M)空间,适合数值范围小的场景,效率极高;
  4. 关键技巧
    • 核心思想:找第k大元素无需完全排序,堆解法筛选前k大元素,快速选择直接定位目标位置;
    • 场景选择:数值范围小选计数排序,工程实现选堆解法,追求理论最优选快速选择;
    • 优化点:快速选择需随机化基准,避免最坏时间复杂度;堆解法优先选小顶堆,控制堆大小为k。