【剑指offer刷题日记 Java版】40. 最小的k个数

183 阅读4分钟

题目

输入整数数组 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个元素的最大堆。而快速选择的方法需要先保存下来所有的数据,再运行算法。当数据量非常大的时候,甚至内存都放不下的时候,就麻烦了。所以当数据量大的时候还是用基于堆的方法比较好。