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
个数。
……
【实现策略】:
sort()
排序;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()的底层实现原理……
【实现策略】:
快排思想:通过一趟排序将待排记录分隔成独立的两部分,其中一部分记录的关键字均比另一部分的关键字小,则可分别对这两部分记录继续进行排序,以达到整个序列有序。
- 找出一个基准值
pivot
,这个基准值可以是数组的头元素,也可以是尾元素,中间元素或者其他元素;- 设置两个头尾变量
l
,r
,分别指向数组的头尾;- 循环,头指针
l
向后遍历,尾指针r
向前遍历,头指针所指元素如果小于基准元素则l++
,尾指针所指元素如果大于基准元素则r–
,如果不满足条件则停下来;- 直到头指针所指向的元素大于基准元素,尾指针所指向的元素小于基准元素后,交换这两元素的值;
- 递归调用,通过递归将左侧部分排好序后,再递归排好右侧部分的顺序。当左、右两个部分各数据排序完成后,整个数组的排序也就完成了。
……
更多内容可参考:
[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
左边的数都小于pivot
,pivot
右边的数都大于pivot
。
- 利用 Partition 算法,再结合递归,类似二分查找的分治。
- 即:返回 pivot 的 index 正好和 k 相等,则找到了第 k 小的数。
- 如果返回 pivot 的 index 小于 k,则在 pivot 的右半段递归查找。
- 如果返回 pivot 的 index 大于 k,则在 pivot 的做半段递归查找。
……
【实现策略】:
- 循环调用
partition
方法,直至返回pivot
的index
正好和k
相等;- 将排好序的数组进行截取。
……
但是这样做得到的效果贼差,直接超时了,如果把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
的元素,假如k
比index
小的话,那么我们下次切分只要遍历数组(0~k-1)
的元素就行啦,反之如果k
比index
大的话,那下次切分只要遍历数组(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 个数
剑指 Offer 40. 最小的 k 个数
题目
函数原型
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 个数