力扣hot100之215:数组中的第 K 个最大元素
力扣hot100之215:数组中的第 K 个最大元素

LeetCode 215. 数组中的第 K 个最大元素

题目描述

给定整数数组 nums 和整数 k,请返回数组中第 k 个最大的元素。

需要注意的是:

这里找的是“第 k 个最大的元素”,不是“第 k 个不同的元素”。

也就是说,如果数组中有重复数字,重复数字也要参与排名。


示例分析

示例 1

输入: nums = [3,2,1,5,6,4], k = 2
输出: 5

把数组从大到小排列后是:

[6,5,4,3,2,1]

第 2 个最大的元素就是 5


示例 2

输入: nums = [3,2,3,1,2,4,5,5,6], k = 4
输出: 4

从大到小排列后是:

[6,5,5,4,3,3,2,2,1]

第 4 个最大的元素是 4


解题思路

最直接的想法当然是:

  1. 先排序

  2. 再取第 k 个元素

比如升序排序后取 nums[nums.length - k],或者降序排序后取 nums[k - 1]

这种方法可以做出来,但时间复杂度是:

O(n log n)

而这道题其实有更高效的做法:快速选择(Quick Select)

你给出的代码就是快速选择的写法。


什么是快速选择

快速选择可以理解成“只做一部分排序”。

它和快速排序的思想很像,都是先选一个基准值 pivot,再把数组分成几部分。

但区别在于:

  • 快速排序会递归处理左右两边,直到整个数组有序

  • 快速选择只会递归进入目标元素所在的那一边

所以它不需要把整个数组都排好序,只需要不断缩小范围,直到找到答案。

这就是它比完整排序更快的原因。


这份代码的核心思想

你给出的代码不是原地分区版本,而是一个更容易理解的版本:

  1. 随机选一个 pivot

  2. 把数组分成三部分:

    • big:比 pivot 大的数

    • equal:等于 pivot 的数

    • small:比 pivot 小的数

  3. 判断第 k 大元素落在哪一部分

  4. 只递归进入那一部分继续找

代码如下:

public class Solution {
    private int quickSelect(List<Integer> nums, int k) {
        Random rand = new Random();
        int pivot = nums.get(rand.nextInt(nums.size()));
        List<Integer> big = new ArrayList<>();
        List<Integer> equal = new ArrayList<>();
        List<Integer> small = new ArrayList<>();
        for (int num : nums) {
            if (num > pivot)
                big.add(num);
            else if (num < pivot)
                small.add(num);
            else
                equal.add(num);
        }
        if (k <= big.size())
            return quickSelect(big, k);
        if (nums.size() - small.size() < k)
            return quickSelect(small, k - nums.size() + small.size());
        return pivot;
    }

    public int findKthLargest(int[] nums, int k) {
        List<Integer> numList = new ArrayList<>();
        for (int num : nums) {
            numList.add(num);
        }
        return quickSelect(numList, k);
    }
}

为什么要分成三部分

很多同学第一次做这题时,会以为只要分成两部分:

  • 大于 pivot

  • 小于等于 pivot

但你这里分成三部分其实更清晰:

  • big

  • equal

  • small

这样做的好处是:

如果第 k 大正好落在 equal 这部分,那么答案就直接是 pivot,不需要继续递归。

尤其当数组中有很多重复元素时,这种写法会更自然。


核心判断逻辑

假设当前数组被分成:

  • big.size() 个比 pivot 大的数

  • equal.size() 个等于 pivot 的数

  • small.size() 个比 pivot 小的数

那么第 k 大元素有三种可能:

1. 落在 big

如果:

k <= big.size()

说明第 k 大元素一定还在 big 这一部分里。

因为 big 中的所有元素都比 pivot 大,所以排名靠前的元素一定先从 big 里找。

于是递归:

return quickSelect(big, k);

2. 落在 equal

如果第 k 大不在 big 里,但它又没有落到 small 里,那么它一定就在 equal 里。

这时答案就是:

return pivot;

3. 落在 small

如果第 k 大元素既不在 big,也不在 equal,那么它一定在 small 中。

不过这时排名要重新计算。

因为:

  • big 中的元素排在前面

  • equal 中的元素也排在前面

所以进入 small 后,第 k 大就要减掉前面已经跳过的元素数量。

你的代码里写的是:

if (nums.size() - small.size() < k)
    return quickSelect(small, k - nums.size() + small.size());

这里:

nums.size() - small.size()

其实就是:

big.size() + equal.size()

也就是前面已经排掉的元素个数。

所以新的排名变成:

k - (big.size() + equal.size())

也就是:

k - nums.size() + small.size()

代码解析

1. 随机选取基准值

Random rand = new Random();
int pivot = nums.get(rand.nextInt(nums.size()));

这里随机从当前数组中取一个数作为 pivot

为什么要随机?

因为如果每次都固定选某个位置,可能在某些特殊数据下退化得比较严重。 随机选择可以让平均性能更稳定。


2. 分成三组

List<Integer> big = new ArrayList<>();
List<Integer> equal = new ArrayList<>();
List<Integer> small = new ArrayList<>();
for (int num : nums) {
    if (num > pivot)
        big.add(num);
    else if (num < pivot)
        small.add(num);
    else
        equal.add(num);
}

这里按照与 pivot 的大小关系,把当前数组拆成三部分。

因为题目要求找“第 k 大”,所以这里优先把“大于 pivot” 的数放进 big


3. 第 k 大在 big

if (k <= big.size())
    return quickSelect(big, k);

说明目标还在更大的那一边,继续递归查找。


4. 第 k 大在 small

if (nums.size() - small.size() < k)
    return quickSelect(small, k - nums.size() + small.size());

说明第 k 大已经越过了:

  • 所有比 pivot 大的元素

  • 所有等于 pivot 的元素

所以要去 small 中找新的第若干大元素。


5. 否则答案就是 pivot

return pivot;

说明第 k 大恰好落在 equal 这一段中。


6. 主函数把数组转成 List

public int findKthLargest(int[] nums, int k) {
    List<Integer> numList = new ArrayList<>();
    for (int num : nums) {
        numList.add(num);
    }
    return quickSelect(numList, k);
}

因为辅助函数是基于 List<Integer> 写的,所以先把数组转换成列表。


示例推演

我们以这个例子来模拟:

nums = [3,2,1,5,6,4], k = 2

目标是找第 2 大元素。

假设随机选出的 pivot = 4

那么分组后:

big = [5,6]
equal = [4]
small = [3,2,1]

因为:

k = 2
big.size() = 2

满足:

k <= big.size()

所以第 2 大元素一定在 big 中。

继续递归:

quickSelect([5,6], 2)

假设这次随机选 pivot = 5

分组后:

big = [6]
equal = [5]
small = []

这时:

  • k = 2

  • big.size() = 1

  • big.size() + equal.size() = 2

说明第 2 大元素正好落在 equal 中。

于是直接返回:

5

这就是答案。


为什么快速选择比排序更高效

排序的目标是:

把所有元素的相对顺序都确定下来

但这道题只需要:

找到一个位置上的元素

所以没有必要把整个数组都排好序。

快速选择每次都只进入一边递归,平均情况下会把问题规模迅速缩小,因此平均时间复杂度可以做到:

O(n)

而完整排序需要:

O(n log n)

所以当题目只问“第 k 大 / 第 k 小”时,快速选择往往更合适。


复杂度分析

设数组长度为 n

时间复杂度

平均时间复杂度为:

O(n)

因为每次划分后,只递归进入一侧。

最坏情况下会退化到:

O(n^2)

比如每次选到的 pivot 都很差。

但由于这里使用了随机选择基准值,平均表现通常很好。


空间复杂度

这份代码不是原地分区写法,而是额外创建了:

  • big

  • equal

  • small

所以空间复杂度为:

O(n)

如果写成原地 partition 版本,空间复杂度可以优化到 O(1)(不算递归栈)。


和排序解法对比

排序解法

优点:

  • 非常直接

  • 容易想到

  • 代码简洁

缺点:

  • 时间复杂度是 O(n log n)

  • 做了很多不必要的排序工作


快速选择解法

优点:

  • 平均时间复杂度更优,为 O(n)

  • 只关心目标位置,不需要完整排序

缺点:

  • 思维难度比排序稍高

  • 最坏情况下可能退化

  • 这份三数组写法需要额外空间


总结

这道题的核心不是排序,而是:

如何不把整个数组排好序,也能快速确定第 k 大元素。

快速选择的关键思想就是:

  1. 随机选一个基准值 pivot

  2. 按照与 pivot 的大小关系,把数组分成三部分

  3. 判断第 k 大落在哪一部分

  4. 只递归进入那一部分继续查找

你可以把这题总结成一句话:

快速选择就是“只找目标,不排全局”的快速排序思想。

这类题非常经典,后面像:

  • 第 K 小元素

  • 数组中的中位数

  • TopK 问题

都会用到类似思想,所以这题很值得彻底掌握。


如果对您有帮助的话,能否支持一下博主?
暂无评论

发送评论 编辑评论


				
|´・ω・)ノ
ヾ(≧∇≦*)ゝ
(☆ω☆)
(╯‵□′)╯︵┴─┴
 ̄﹃ ̄
(/ω\)
∠( ᐛ 」∠)_
(๑•̀ㅁ•́ฅ)
→_→
୧(๑•̀⌄•́๑)૭
٩(ˊᗜˋ*)و
(ノ°ο°)ノ
(´இ皿இ`)
⌇●﹏●⌇
(ฅ´ω`ฅ)
(╯°A°)╯︵○○○
φ( ̄∇ ̄o)
ヾ(´・ ・`。)ノ"
( ง ᵒ̌皿ᵒ̌)ง⁼³₌₃
(ó﹏ò。)
Σ(っ °Д °;)っ
( ,,´・ω・)ノ"(´っω・`。)
╮(╯▽╰)╭
o(*////▽////*)q
>﹏<
( ๑´•ω•) "(ㆆᴗㆆ)
😂
😀
😅
😊
🙂
🙃
😌
😍
😘
😜
😝
😏
😒
🙄
😳
😡
😔
😫
😱
😭
💩
👻
🙌
🖕
👍
👫
👬
👭
🌚
🌝
🙈
💊
😶
🙏
🍦
🍉
😣
Source: github.com/k4yt3x/flowerhd
颜文字
Emoji
小恐龙
花!
上一篇
下一篇