目录
题目
链接:215. 数组中的第K个最大元素
给定整数数组 nums
和整数 k
,请返回数组中第 k
个最大的元素。
请注意,你需要找的是数组排序后的第 k
个最大的元素,而不是第 k
个不同的元素。
示例 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 <= 104
-104 <= nums[i] <= 104
分析
这题看似考察的查找,实际上考察的是排序,寻找topK,那么必然需要排序,可以将整个数组排序后取出倒数第K个数。但是这样的话复杂度就比较高了,因此我们采取不完全的排序来实现该功能,有2种主流的实现方法,堆排序和快排变形。
解答
(1)堆排序
通过建立小根堆的方法实现排序。利用小根堆最小元素在堆顶的特性,遍历数组,并只保留最大的k个元素,最后,留在堆顶的元素即为第K大元素。
但需要注意,在与面试官沟通的时候,需要与其确认,是否允许JDK内置的优先队列,PriorityQueue,如果不能,则需要手动实现小根堆。
(1.1)PriorityQueue
public int findKthLargest(int[] nums, int k) {
PriorityQueue<Integer> queue = new PriorityQueue<Integer>();
for(int num:nums){
queue.add(num);
if(queue.size()>k){
queue.poll();
}
}
return queue.poll();
}
(1.2)手动实现堆
public int findKthLargest(int[] nums, int k) {
int[] res = new int[k];
for(int i=0;i<k;i++){
res[i] = nums[i];
}
// 针对前k个元素建立小根堆,最小元素在顶端
buildHeap(res);
// 遍历后续元素
for(int i=k;i<nums.length;i++){
if(nums[i]>res[0]){
// 每次将新进入的元素 与堆顶元素比较,若更大,则需要入堆,继而递归调整小根堆
res[0] = nums[i];
adjust(res,0);
}
}
return res[0];
}
public void buildHeap(int[] arr){
// 针对堆的属性,从数组长度的中间位置开始建立堆
for(int i=arr.length/2-1;i>=0;i--){
adjust(arr,i);
}
}
public void adjust(int[] arr,int index){
int maxIndex = index;
int len = arr.length;
if(index*2+1<len && arr[index*2+1]<arr[maxIndex]){
maxIndex = index*2+1;
}
if(index*2+2<len && arr[index*2+2]<arr[maxIndex]){
maxIndex = index*2+2;
}
if(maxIndex!=index){
swap(arr,index,maxIndex);
adjust(arr,maxIndex);
}
}
public void swap(int[] arr, int i, int j){
int temp = arr[i];
arr[i] = arr[j];
arr[j] = temp;
}
注意优化的细节:
1、k如果超过一半时,其实可反过来找len-k+1个最小元素
2、如果当前数小于堆顶的元素,就不用添加
以上两个点都可以用来减小堆的维护所消耗的时间和空间。
空间复杂度:O(k) ,堆内只要保持有K个元素即可,因此为O(k)。
时间复杂度:O(n*logk) ,小/大根堆的维护时间为O(logk),因为每个元素都是先插入堆尾,然后再通过逐层向上寻找父节点的方式维护堆的属性(即父节点小于左右孩子节点),至多递归logk次(删除同理)。然后需要遍历全部n个元素,因此为O(nlogk)。
(2)快排变形
除了建堆,我们还可以使用快排变形来优化。我们知道快排的核心思路是通过分选定一个基准值,将比基准值小的放左边,大的放右边,通过这种方式将让整个数组达到大致有序的状态。在本题中,我们就可以使用这个特性,当选定的基准值在将两侧划分完毕后刚好在k位置时,左侧都是小于nums[k]的值,刚好就是我们要求的答案。
private static Random random = new Random(System.currentTimeMillis());
public int findKthLargest(int[] nums, int k) {
int len = nums.length;
// 注意题目,这里求的是第K大
int target = len - k;
int left = 0;
int right = len - 1;
return partition(nums,left,right,target);
}
// 在区间 nums[left..right] 区间执行 partition 操作
private int partition(int[] nums, int left, int right,int k) {
// 在区间随机选择一个元素作为标定点
if (right > left) {
int randomIndex = left + 1 + random.nextInt(right - left);
swap(nums, left, randomIndex);
}
int pivot = nums[left];
int i = left, j = right;
// 交换两侧逆序元素
while (i < j) {
while (i < j && nums[j] >=pivot) j--;
while (i < j && nums[i] <=pivot) i++;
swap(nums, i, j);
}
// 将基准元素放到其最终位置上
swap(nums, left, j);
// 判断k与当前位置的大小关系
if(j>k){
return partition(nums,left,j-1,k);
}
if(j<k){
return partition(nums,j+1,right,k);
}
return nums[j];
}
public void swap(int[] arr,int i,int j){
int curr = arr[i];
arr[i] = arr[j];
arr[j] = curr;
}
时间复杂度:O(n) ,每次递归都会遍历数组的一部分,第一次递归是完整的数组,即n,由于基准值pivot具有随机性,后续递归子数组的长度的平均长度为上一层的1/2,于是总的遍历长度即可得出n+1/2*n+1/4*n+...+1/n*n,由等比数列求和公式得出结果为2n-1,即O(n) 。
空间复杂度:O(logN),平均递归深度O(logN)。
自测
题目规定了K与数组的长度大于等于1,那么我们不用考虑K为0或数组为空的情况。当然我们也可以在代码中加上兼容,以提高程序的可扩展性。其次,题目表明数组元素正负都有可能,因此在设计样例的时候需要考虑到负数的存在。同时观察题目中给出的样例存在重复元素的情况,这也是我们需要考虑的点。综上,我们设计出以下样例。
[1] 和 k = 1
[3,3,2,1] 和 k = 2
[-3,3,2,1] 和 k = 2
补充
相关题目:剑指 Offer 40. 最小的k个数
除此之外,还有类似无序数组找中位数这样的功能,都属于topK类型题目的变种,需要牢记以上这两种解法与复杂度的分析方法。