题目的链接放在这里了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];
}
这里的 left 和 right 表示当前要处理的数组范围。如果最后范围里只剩下一个元素了,那这个元素肯定就是我们要找的目标元素,直接返回就可以了。
然后调用 partition 方法:
int[] range = partition(nums,left,right);
int start = range[0];
int end = range[1];
这个 partition 就是快速排序里面最核心的分区操作,但是这里用的是"三路划分"的思想。它会随机选择一个基准值 pivotValue,然后把数组分成三部分:
小于 pivotValue 的区域
等于 pivotValue 的区域
大于 pivotValue 的区域
最后返回的 start 和 end,表示的就是等于 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};
这里返回的是等于基准值区域的范围。也就是说,less 到 greater 这一段的数字都等于 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;
}
}