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

题目的链接放在这里了215. 数组中的第K个最大元素 - 力扣(LeetCode)

这道题的要求是找出一个乱序数组中的第K个大元素,题目要求很简单,这道题有两种做法,一种我们可以利用堆的性质来做:

众所周知,小根堆的根节点是整棵树种最小的元素,并且每个节点满足父节点小于等于两个子节点,大根堆的根节点是整棵树的最大的元素,并且每个节点满足父节点大于等于两个子节点。我们可以利用这个性质,我们先创建一个小根堆大小为K,我们将前K个数字先放入堆中,构造好堆后对于后面的数字,如果说当前位置的数字大于根节点的话,就让根节点出队,同时让数组进队,如果当前位置的数字小于根节点的话就直接跳过,整个过程概括起来就是先把堆塞满,然后利用堆的性质,遍历数组的同时将较大的数字放入堆中,较小的数字被弹出或者过滤,最终堆中只存在K个数字,分别是第K大的数~最大的数,而此时的根节点就是我们要找的第K大的数,直接返回就好了;

这里说一下,java中的堆用优先队列PriorityQueue来表示,默认是小根堆,可以通过传入Lambda表达式的方式来指定比较规则

java 复制代码
// 比较器返回 b - a,实现降序排列(大根堆)
PriorityQueue<Integer> maxHeap = new PriorityQueue<>((a, b) -> b - a);

常见方法有,offer()放入元素,poll()弹出元素,peek()查看根节点元素

java 复制代码
class Solution {
    public int findKthLargest(int[] nums, int k) {
        if(nums==null||nums.length==0){
            return -1;
        }
        PriorityQueue<Integer> heap = new PriorityQueue<>(k);
        for(int num : nums){
            if(heap.size()<k){
                heap.offer(num);
            }else if(num>heap.peek()){
                heap.poll();
                heap.offer(num);
            }
        }
        return heap.peek();
    }
}

第二种方法是利用了快速排序的思想,我们可以利用 Arrays.sort() 排序完直接返回 nums[nums.length - k],是不是太简单了,所以我们要手写优化一下,思想还是这个思想,但是我们不需要真的把整个数组都排好序,只要找到目标位置上的数字就可以了。

这里要注意一个点,题目要求的是第 K 个最大的元素,但是如果数组是升序排列的话,第 K 个最大的元素其实就是下标为:

复制代码
nums.length - k

的元素。

比如数组排完序是:

复制代码
[1, 2, 3, 4, 5, 6]

如果找第 2 大,那就是 5,它的下标就是 6 - 2 = 4。所以我们一开始调用:

复制代码
return quickSelect(nums, 0, nums.length - 1, nums.length - k);

这里传进去的 k 其实不是"第几个大"的意思了,而是目标元素在升序数组中应该处于的下标位置。

接下来进入 quickSelect 方法:

复制代码
private int quickSelect(int[] nums,int left,int right,int k){
    if(left==right){
        return nums[left];
    }

这里的 leftright 表示当前要处理的数组范围。如果最后范围里只剩下一个元素了,那这个元素肯定就是我们要找的目标元素,直接返回就可以了。

然后调用 partition 方法:

复制代码
int[] range = partition(nums,left,right);
int start = range[0];
int end = range[1];

这个 partition 就是快速排序里面最核心的分区操作,但是这里用的是"三路划分"的思想。它会随机选择一个基准值 pivotValue,然后把数组分成三部分:

复制代码
小于 pivotValue 的区域
等于 pivotValue 的区域
大于 pivotValue 的区域

最后返回的 startend,表示的就是等于 pivotValue 这一段区域的左右边界

也就是说,分区完成之后,数组大概会变成这样:

复制代码
[小于 pivot 的数] [等于 pivot 的数] [大于 pivot 的数]
              start          end

这个时候我们就可以判断目标下标 k 在哪里了:

复制代码
if(k>=start&&k<=end){
    return nums[k];
}

如果 k 正好落在等于 pivot 的这一段里面,那说明目标位置上的数就是 pivotValue,直接返回 nums[k] 就可以了。

如果目标下标在左边:

复制代码
else if(k<start){
    return quickSelect(nums,left,start-1,k);
}

说明我们要找的数字比当前基准值小,所以只需要去左半部分继续找。

如果目标下标在右边:

复制代码
else{
    return quickSelect(nums,end+1,right,k);
}

说明我们要找的数字比当前基准值大,所以只需要去右半部分继续找。

这就是快速选择比完整快速排序优化的地方:

快速排序每次分区之后,两边都要继续递归;但是快速选择每次只需要递归其中一边,因为我们只关心目标下标位置上的那个数,不关心其他位置是否完全有序。

接下来重点看 partition 方法:

复制代码
private int[] partition(int[] nums,int left,int right){
    int pivot = left+(int)(Math.random()*(right-left+1));
    int pivotValue = nums[pivot];

这里先随机选一个下标作为基准值。为什么要随机选呢?因为如果每次都固定选最左边或者最右边,在某些特殊数组下可能会退化成很慢的情况。随机选可以尽量避免这种问题,让整体效率更稳定。

然后定义三个指针:

复制代码
int less = left;
int greater = right;
int i = left;

这三个变量的含义分别是:

复制代码
less:小于 pivot 区域的边界
greater:大于 pivot 区域的边界
i:当前正在遍历的位置

刚开始的时候,整个数组还没有被处理,所以 less 从左边开始,greater 从右边开始,i 也从左边开始扫描。

循环条件是:

复制代码
while(i<=greater)

也就是说,只要 i 还没有超过大于区域的左边界,就继续处理。

第一种情况:

复制代码
if(nums[i]<pivotValue){
    swap(nums,i,less);
    less++;
    i++;
}

如果当前数字小于基准值,就应该放到左边的小于区域里,所以交换 nums[i]nums[less]。交换完之后,小于区域扩大一格,所以 less++,同时当前位置也处理完了,所以 i++

第二种情况:

复制代码
else if(nums[i]>pivotValue){
    swap(nums,i,greater);
    greater--;
}

如果当前数字大于基准值,就应该放到右边的大于区域里,所以交换 nums[i]nums[greater]。交换完之后,大于区域扩大一格,所以 greater--

这里要特别注意,交换完之后不能立刻 i++,因为从 greater 位置换过来的数字还没有被检查过,它可能小于 pivot,也可能等于 pivot,也可能大于 pivot,所以当前位置还得继续判断。

第三种情况:

复制代码
else{
    i++;
}

如果当前数字等于基准值,那它本来就应该待在中间区域,所以直接 i++ 就可以了。

最后返回:

复制代码
return new int[]{less,greater};

这里返回的是等于基准值区域的范围。也就是说,lessgreater 这一段的数字都等于 pivotValue

最后还有一个普通的交换方法:

复制代码
private void swap(int nums[],int l,int r){
    int t = nums[l];
    nums[l] = nums[r];
    nums[r] = t;
}

这个方法就是把数组中两个位置的元素交换一下,没有什么复杂的地方。

整体来看,这道题的核心思想就是:

我们把"第 K 大"转换成"升序数组中下标为 nums.length - k 的元素",然后利用快速排序的分区思想,每次确定一段等于基准值的位置范围。如果目标下标在这段范围内,就直接返回;如果在左边,就只递归左边;如果在右边,就只递归右边。

所以这个算法并不是完整地把数组排好序,而是不断缩小查找范围,直到找到目标下标对应的元素。平均时间复杂度是 O(n),空间复杂度主要来自递归调用,平均是 O(log n),最坏情况下可能达到 O(n)

java 复制代码
class Solution {
    public int findKthLargest(int[] nums, int k) {
        int target = nums.length - k;
        return quickSelect(nums, 0, nums.length - 1, target);
    }

    private int quickSelect(int[] nums, int left, int right, int k) {
        if (left == right) {
            return nums[left];
        }

        int[] range = partition(nums, left, right);
        int start = range[0];
        int end = range[1];

        if (k >= start && k <= end) {
            return nums[k];
        } else if (k < start) {
            return quickSelect(nums, left, start - 1, k);
        } else {
            return quickSelect(nums, end + 1, right, k);
        }
    }

    private int[] partition(int[] nums, int left, int right) {
        int randomIndex = left + (int)(Math.random() * (right - left + 1));
        int pivot = nums[randomIndex];

        int less = left;
        int greater = right;
        int i = left;

        while (i <= greater) {
            if (nums[i] < pivot) {
                swap(nums, i, less);
                i++;
                less++;
            } else if (nums[i] > pivot) {
                swap(nums, i, greater);
                greater--;
            } else {
                i++;
            }
        }

        return new int[]{less, greater};
    }

    private void swap(int[] nums, int i, int j) {
        int t = nums[i];
        nums[i] = nums[j];
        nums[j] = t;
    }
}