题目
输入整数数组 arr ,找出其中最小的 k 个数。例如,输入4、5、1、6、2、7、3、8这8个数字,则最小的4个数字是1、2、3、4。
示例 1:
输入:arr = [3,2,1], k = 2 输出:[1,2] 或者 [2,1]
示例 2:
输入:arr = [0,1,2,1], k = 1 输出:[0]
解法一 最大堆
最大堆即最顶端的节点的值比堆里其他节点都大,反之为最小堆。我们如果一直用一个大小为k的最大堆,然后将数字一一放入,只要堆的大小超过k我们就移除堆的最顶端,那么到最后堆里就会留下最小的k个数。
class Solution {
public int[] getLeastNumbers(int[] arr, int k) {
if(k == 0) return new int[0];
// Java里PriorityQueue默认为最小堆,所以我们需要更改comparator让他变成最大堆
Queue<Integer> queue = new PriorityQueue<>(k,(i1,i2) -> Integer.compare(i2,i1));
for(int num:arr){
// 只有当前堆内元素的数量不足k,或当前数比堆内最大值小,才有加入堆的资格
if(queue.isEmpty() || queue.size() < k || queue.peek() > num){
queue.add(num);
}
// 堆的大小超出时,我们把最大值删除
if(queue.size() > k){
queue.poll();
}
}
int[] result = new int[k];
int ptr = 0;
for(Integer num:queue){
result[ptr] = num;
ptr++;
}
return result;
}
}
时间复杂度: 入堆和出堆操作的时间复杂度均为 O(log k), 每个元素都需要进行一次入堆操作,故算法的时间复杂度 为O(nlogk)
空间复杂度:O(k) 由于使用了一个大小为 k 的堆
解法二 快速选择-快速排序的变形
首先我们需要返回最小的k个,快排即我们首先在数组里选一个随机数为基准,然后一顿操作把小于基准的数全都放到这个基准前,大于基准的数都放到基准后,然后再对两边的数重复做这个动作,直到所有数都排好序。
这里我们因为只需要找出最小的k个数,而不是需要整个排序,所以其实我们可以做出一点变形。即我们先选一个数作为基准,完成这轮排序后,看这个数的索引是多少,这个索引的值分下面三种情况:
- 如果正好是k,则我们就把前面所有的数给返回就好了,因为这时候k之前的都是小于他的。
- 如果这个数索引小于k,那么首先我们k之前的数可以保持不动,因为已经是小于他的了,我们需要重新排一下k后面的数,再来看后面的数的基准和k的比较来决定下一步操作
- 如果这个数的索引大于k,那么首先我们k之后的数可以保持不动,我们不用再在意他们了,因为他们一定不属于最小的k个数,我们这时候就要把k之前的数再进行一次重排,再来看后面的数的基准和k的比较来决定下一步操作
class Solution {
public int[] getLeastNumbers(int[] arr, int k) {
if(k == 0) return new int[0];
if(arr.length <= k) return arr;
partitionArray(arr,0,arr.length-1,k);
int[] result = new int[k];
// 只需要返回前k个数
for(int i = 0; i < k; i++){
result[i] = arr[i];
}
return result;
}
public void partitionArray(int[] arr, int left, int right, int k){
// 获取我们选择的基准在这次排序后在数组中的位置
int curPtr = partition(arr,left,right);
// 如果基准的位置正好为k,停止操作,可以返回了
if(curPtr == k){
return;
// 如果基准的位置大于k,我们重排基准前的数字
}else if(curPtr > k ){
partitionArray(arr,left,curPtr-1,k);
// 如果基准的位置小于k,我们重排基准后的数字
}else{
partitionArray(arr,curPtr+1,right,k);
}
}
// 快排的固定写法
public int partition(int[] arr, int left, int right){
int l = left;
int r = right + 1;
int random = arr[left];
while(true){
while(arr[++l] < random){
if(l == right){
break;
}
}
while(arr[--r] > random){
if(r == left){
break;
}
}
if(l >= r) break;
swap(arr,l,r);
}
swap(arr,left,r);
return r;
}
public void swap(int[] arr, int i, int j){
int temp = arr[i];
arr[i] = arr[j];
arr[j] = temp;
}
}
时间复杂度: 期望时间复杂度为 O(n),最坏情况下的时间复杂度为O(n^2)
空间复杂度:O(1)
两种方法的优劣性比较
在面试中,另一个常常问的问题就是这两种方法有何优劣。看起来分治的快速选择算法的时间、空间复杂度都优于使用堆的方法,但是要注意到快速选择算法的几点局限性:
第一,算法需要修改原数组,如果原数组不能修改的话,还需要拷贝一份数组,空间复杂度就上去了。
第二,算法需要保存所有的数据。如果把数据看成输入流的话,使用堆的方法是来一个处理一个,不需要保存数据,只需要保存k个元素的最大堆。而快速选择的方法需要先保存下来所有的数据,再运行算法。当数据量非常大的时候,甚至内存都放不下的时候,就麻烦了。所以当数据量大的时候还是用基于堆的方法比较好。