LeetCode剑指 Offer 40. 最小的k个数 p209 -- Java Version

Posted TomLazy

tags:

篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了LeetCode剑指 Offer 40. 最小的k个数 p209 -- Java Version相关的知识,希望对你有一定的参考价值。

题目链接:https://leetcode.cn/problems/zui-xiao-de-kge-shu-lcof/

1. 题目介绍(40. 最小的k个数)

输入整数数组 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]

【条件约束】:

限制

  • 0 <= k <= arr.length <= 10000
  • 0 <= arr[i] <= 10000

2. 题解

2.1 库函数排序 – O(nlogn)

时间复杂度O(nlogn),空间复杂度O(1)

解题思路】:
这道题最简单思路莫过于把输入的 n 个整数排序,排序之后位于最前面的 k 个数就是最小的 k 个数。
……
实现策略】:

  1. sort() 排序;
  2. copyOf() 截取数组。
class Solution 
    public int[] getLeastNumbers(int[] arr, int k) 
        // 排序
        Arrays.sort(arr);
        // 数组截取
        return Arrays.copyOf(arr,k);
    

2.2 纯快排 – O(nlogn)

时间复杂度O(nlogn),空间复杂度O(n)

解题思路】:
思路和 2.1 相同,都是先排序,然后再数组截取,唯一不同的是这里的排序是我们自己实现的,不过对于普通数据类型 sort() 底层也是用快排实现的就是了。

更多内容可参考:Arrays.sort()的底层实现原理

……
实现策略】:
快排思想:通过一趟排序将待排记录分隔成独立的两部分,其中一部分记录的关键字均比另一部分的关键字小,则可分别对这两部分记录继续进行排序,以达到整个序列有序。

  1. 找出一个基准值 pivot,这个基准值可以是数组的头元素,也可以是尾元素,中间元素或者其他元素;
  2. 设置两个头尾变量 lr,分别指向数组的头尾;
  3. 循环,头指针 l 向后遍历,尾指针 r 向前遍历,头指针所指元素如果小于基准元素则 l++,尾指针所指元素如果大于基准元素则 r–,如果不满足条件则停下来;
  4. 直到头指针所指向的元素大于基准元素,尾指针所指向的元素小于基准元素后,交换这两元素的值;
  5. 递归调用,通过递归将左侧部分排好序后,再递归排好右侧部分的顺序。当左、右两个部分各数据排序完成后,整个数组的排序也就完成了。

……
更多内容可参考:
[1] 快速排序基本思想 – (未将基值移到边界)
[2] 快速排序的基本思想(图文详解)-- (每次都将基值移到边界)

class Solution 
    public int[] getLeastNumbers(int[] arr, int k) 
        quickSort(arr, 0, arr.length - 1);
        return Arrays.copyOf(arr, k);
    
    private void quickSort(int[] arr, int l, int r) 
        // 子数组长度为 1 时终止递归
        if (l >= r) return;

        // 哨兵划分操作(以 arr[l] 作为基准数)
        int pivot = arr[l], i = l-1, j = r+1;
        // 进入循环,不断交换
        while (i < j)
            do i++; while (arr[i] < pivot);
            do j--; while (arr[j] > pivot);
            if (i < j) swap(arr, i, j);
        
        // 递归左(右)子数组执行哨兵划分
        quickSort(arr, l, j);
        quickSort(arr, j+1, r);
    
    // 交换数组中 i 和 j 的元素位置
    private void swap(int[] arr, int i, int j) 
        int tmp = arr[i];
        arr[i] = arr[j];
        arr[j] = tmp;
    


2.3 快排切分(原书题解1)-- O(n)

时间复杂度O(n),空间复杂度O(n)

解题思路】:
基于Partition方法来解决此问题,更多内容可参考:找最小的第k个数 k min (Selection Algorithm 与 Median of Medians Algorithm)

Partition 算法是快速排序 QuickSort 中的一部分,思想是选定一个值作为 pivot,然后通过 swap,使得最终 pivot 左边的数都小于 pivotpivot 右边的数都大于 pivot

  • 利用 Partition 算法,再结合递归,类似二分查找的分治。
  • 即:返回 pivot 的 index 正好和 k 相等,则找到了第 k 小的数。
  • 如果返回 pivot 的 index 小于 k,则在 pivot 的右半段递归查找。
  • 如果返回 pivot 的 index 大于 k,则在 pivot 的做半段递归查找。

……
实现策略】:

  1. 循环调用 partition 方法,直至返回 pivotindex 正好和 k 相等;
  2. 将排好序的数组进行截取。

……
但是这样做得到的效果贼差,直接超时了,如果把 partition 方法中的随机选取一个基准的方法,改成直接选定第一个或最后一个数字作为基准的形式,能将 超时 的情况降到 600+ms 左右的样子。
……
Java产生在[min,max]范围内的随机整数: int num = min + (int)(Math.random() * (max-min+1));
更多内容可参考:java中产生指定范围内的随机数

// 元素题解1:C 改 Java
class Solution 
    public int[] getLeastNumbers(int[] arr, int k) 
        // 异常输入判断
        if (arr.length <= 0 || k <=0 || k > arr.length) return new int[0];
        // 
        int start = 0;
        int end = arr.length-1;
        // 先随机选一个值
        int index = partition(arr,start,end);
        
        while(index != k-1)
            if (index > k-1) index = partition(arr,start,index-1);
            else index = partition(arr,index+1,end);
        
        // 数组截取
        return Arrays.copyOf(arr,k); 
    

    public int partition(int[] data, int start, int end)
        // 异常输入判断
        try
            if (data.length <= 0 || start < 0 || end >= data.length) throw new Exception("Invalid Parameters");
        catch (Exception e)
            System.out.println(e);
         finally
            // 在 [start,end] 范围内获取随机数
            int index = start + (int)(Math.random() * (end-start+1));
            
            // 将选定的数字移到末尾
            swap(data,index,end);

            int small = start-1;
            for (index = start; index < end; index++)
                if (data[index] < data[end])
                    small++;
                    if (small != index) swap(data,index,small);
                
            
            small++;
            swap(data,small,end);
            return small;
        
    

    // 交换数组中的a、b元素位置
    public void swap(int[] nums,int a, int b)
        int temp = nums[a];
        nums[a] = nums[b];
        nums[b] = temp;
    

解题思路】:
基于快速排序的数组划分。本质上与 2.2 纯快排 区别不大,唯一的区别就是通过判断舍去了不必要的递归,题目只要求返回最小的 k 个数,对这 k 个数的顺序并没有要求。因此,只需要将数组划分为 最小的 k 个数其他数字 两部分即可,而快速排序的哨兵划分可完成此目标。根据快速排序原理,如果某次哨兵划分后 基准数正好是第 k+1 小的数字 ,那么此时基准数左边的所有数字便是题目所求的 最小的 k 个数
……
实现策略】:
根据此思路,考虑在每次哨兵划分后,判断基准数在数组中的索引是否等于 k ,若 true 则直接返回此时 数组的前 k 个数字 即可。
2.2 的区别:对快排的递归进行条件约束

……
时间复杂度 O(n),空间复杂度 O(logn)

class Solution 
    public int[] getLeastNumbers(int[] arr, int k) 
        if (k >= arr.length) return arr;
        return quickSort(arr, k, 0, arr.length - 1);
    
    private int[] quickSort(int[] arr, int k, int l, int r) 
        int i = l, j = r;
        while (i < j) 
            while (i < j && arr[j] >= arr[l]) j--;
            while (i < j && arr[i] <= arr[l]) i++;
            swap(arr, i, j);
        
        swap(arr, i, l);
        if (i > k) return quickSort(arr, k, l, i - 1);
        if (i < k) return quickSort(arr, k, i + 1, r);
        return Arrays.copyOf(arr, k);
    
    private void swap(int[] arr, int i, int j) 
        int tmp = arr[i];
        arr[i] = arr[j];
        arr[j] = tmp;
    


更细一点:

解题思路】:
通过快排切分排好第 K 小的数(下标为 K-1),那么它左边的数就是比它小的另外 K-1 个数。因为我们是要找下标为 k 的元素,第一次切分的时候需要遍历整个数组 (0 ~ n) 找到了下标是 index 的元素,假如 kindex 小的话,那么我们下次切分只要遍历数组 (0~k-1) 的元素就行啦,反之如果 kindex 大的话,那下次切分只要遍历数组 (k+1~n) 的元素就行啦,总之可以看作每次调用 partition 遍历的元素数目都是上一次遍历的 1/2,因此时间复杂度是 N + N/2 + N/4 + ... + N/N = 2N

class Solution 
    public int[] getLeastNumbers(int[] arr, int k) 
        if (k == 0 || arr.length == 0) 
            return new int[0];
        
        // 最后一个参数表示我们要找的是下标为k-1的数
        return quickSearch(arr, 0, arr.length - 1, k - 1);
    

    private int[] quickSearch(int[] nums, int start, int end, int k) 
        // 每快排切分1次,找到排序后下标为index的元素,如果index恰好等于k就返回index以及index左边所有的数;
        int index = partition(nums, start, end);
        if (index == k) 
            return Arrays.copyOf(nums, k+1);
        
        // 否则根据下标index与k的大小关系来决定继续切分左段还是右段。
        return index > k? quickSearch(nums, start, index - 1, k): quickSearch(nums, index + 1, end, k);
    

    // 快排切分,返回下标index,使得比nums[index]小的数都在j的左边,比nums[index]大的数都在index的右边。
    private int partition(int[] nums, int start, int end) 
        // v记录左边元素
        int v = nums[start];
        // 定义边界
        int l = start, r = end + 1;
        while (true) 
            while (++l <= end && nums[l] < v);
            while (--r >= start && nums[r] > v);
            if (l >= r) 
                break;
            
           swap(nums,l,r);
        
        nums[start] = nums[r];
        nums[r] = v;
        return r;
    
    // 交换数组中 a和b 的元素位置
    private void swap(int[] nums, int a, int b)
        int temp = nums[a];
        nums[a] = nums[b];
        nums[b] = temp;
    

2.4 最大堆 – O(nlogk)

时间复杂度 O(nlogk),空间复杂度 O(k)

解题思路】:
比较直观的想法是使用堆数据结构来辅助得到最小的 k 个数。堆的性质是每次可以找出最大或最小的元素。我们可以使用一个大小为 k 的最大堆(大顶堆),将数组中的元素依次入堆,当堆的大小超过 k 时,便将多出的元素从堆顶弹出
……
优点】:
不会改变原数组的结构

class Solution 
    public int[] getLeastNumbers(int[] arr, int k) 
        if (k == 0) 
            return new int[0];
        
        // 使用一个最大堆(大顶堆)
        // Java 的 PriorityQueue 默认是小顶堆,添加 comparator 参数使其变成最大堆
        Queue<Integer> heap = new PriorityQueue<>(k, (i1, i2) -> Integer.compare(i2, i1));

        for (int e : arr) 
            // 当前数字小于堆顶元素才会入堆
            if (heap.isEmpty() || heap.size() < k || e < heap.peek()) 
                heap.offer(e);
            
            if (heap.size() > k) 
                heap.poll(); // 删除堆顶最大元素
            
        

        // 将堆中的元素存入数组
        int[] res = new int[heap.size()];
        int j = 0;
        for (int e : heap) 
            res[j++] = e;
        
        return res;
    


3. 参考资料

[1] 4种解法秒杀TopK(快排/堆/二叉搜索树/计数排序)❤️
[2] 剑指 Offer 40. 最小的 k 个数(基于快速排序的数组划分,清晰图解)
[3] Top K 的两种经典解法(堆/快排变形)与优劣比较

[剑指 Offer 40]. 最小的 k 个数

 


题目

题目:https://leetcode-cn.com/problems/zui-xiao-de-kge-shu-lcof/solution/zui-xiao-de-kge-shu-by-leetcode-solution/

 


函数原型

class Solution 
public:
    vector<int> getLeastNumbers(vector<int>& arr, int k) 

    
;

 


快排 partition 思想

完整解析:快速排序之种种

class Solution 
public:
    vector<int> getLeastNumbers(vector<int>& arr, int k) 
        if(arr.empty() || k == 0) return ;
        vector<int> ans;

        selectK(arr, 0, arr.size()-1, k-1); // 第 k 小数字对应索引是 k-1

        for (int i = 0; i < k; ++i) 
            ans.push_back(arr[i]);
        return ans;
    

    // 封装 partition
    int partition(vector<int>& arr, int l, int r)
        // 生成 [l, r] 之间的随机索引
        int p = rand() % (r - l + 1) + l;
        swap(arr[l], arr[p]);

        // arr[l+1...i-1] <= v; arr[j+1...r] >= v
        int i = l + 1, j = r;
        while(true) 
            while(i <= j && arr[i] < arr[l])
                i ++;
            while(j >= i && arr[j] > arr[l])
                j --;
            if(i >= j) break;
            swap(arr[i], arr[j]);
            i ++;
            j --;
        
        swap(arr[l], arr[j]);
        return j;
    

   void swap( int &a, int &b )
        int tmp = a;
        a = b;
        b = tmp;
    

    // 封装 selectK
    int selectK(vector<int>& arr, int l, int r, int k)
        int p = partition(arr, l, r);
        if(k == p) return arr[p];                    // k == p,直接返回
        if(k < p) return selectK(arr, l, p - 1, k);  // k < p,在左边找
        return selectK(arr, p + 1, r, k);            // k > p,在右边找
    
;

 


优先队列

只需要使用一个优先队列维护当前看到最小的 k 个元素。

对于每一个新元素,如果比这 k 个最小元素中最大的还小,就替换。

实现优先队列的数据结构是,最大堆。

用最大堆维护当前看到的最小 k 个元素,不停的拿这 k 个最小元素中最大值和新元素比较。

class Solution 
public:
    vector<int> getLeastNumbers(vector<int>& arr, int k) 
        vector<int> vec(k, 0);
        if (k == 0)                                         // 排除 0 的情况
            return vec;
 
        priority_queue<int> Q;
        for (int i = 0; i < k; ++i) 
            Q.push(arr[i]);

        for (int i = k; i < (int)arr.size(); ++i) 
            if (Q.top() > arr[i]) 
                Q.pop();
                Q.push(arr[i]);
            

        for (int i = 0; i < k; ++i) 
            vec[i] = Q.top();
            Q.pop();
        
        return vec;
    
;

 


快排与优先队列的比较

快排中位数思路:

  • 时间: O ( n ) O(n) O(n)
  • 空间: O ( 1 ) O(1) O(1)

优先队列:

  • 时间: O ( n   l o g k ) O(n~logk) O(n logk)
  • 空间: O ( k ) O(k) O(k)

发现无论时间、空间,快排中位数思路都比优先队列优秀。

但优先队列有一个最大优势,不需要一次性知道所有数据。

  • 我是游戏服务器开发,之前有很多系统需要做查询排名前N的需求。比如战斗力排名前十的玩家,我的做法是有个全局排序的过程,但是这样在玩家很多时,效率会越来越低。当时与同事以及领导商量也没有好的解决方案,于是就妥协采用了全局实时排序。

  • 同时我在团队内普及了这个方案,使得很多类似需求的系统,比如竞技场排名前十、消费前十等等都得到了提升。

以上是关于LeetCode剑指 Offer 40. 最小的k个数 p209 -- Java Version的主要内容,如果未能解决你的问题,请参考以下文章

[LeetCode]剑指 Offer 40. 最小的k个数

LeetCode(剑指 Offer)- 40. 最小的 k 个数

[剑指 Offer 40]. 最小的 k 个数

[剑指 Offer 40]. 最小的 k 个数

LeetCode剑指 Offer 40. 最小的k个数 p209 -- Java Version

⭐算法入门⭐《堆》简单01 —— LeetCode 剑指 Offer 40. 最小的k个数