在无序数组中找第K小的值,还只会用堆解决?是时候学会O(N)的bfprt算法了
相信大家对这个问题并不陌生,在无序数组中找第K小的值,一般在笔试或者机试的时候,大家能比较快速的写出利用大根堆来解决的算法,其实在Coding 的时候也确实推荐大家用,因为当K比较小时,这个算法的时间复杂度为O(n*logK)级别,已经很不错了,但是当面试官问你这个问题的时,这个答案只能算是中规中矩,首先先分享出用大根堆解决问题的代码
//先构造自己的大根堆比较器
public static class MaxHeapComparator implements Comparator<Integer> {
@Override
public int compare(Integer o1, Integer o2) {
return o2 - o1;
}
}
// 利用大根堆,时间复杂度O(N*logK)
public static int minKth1(int[] arr, int k) {
PriorityQueue<Integer> maxHeap = new PriorityQueue<Integer>(11,new MaxHeapComparator());
for (int i = 0; i < k; i++) {
maxHeap.add(arr[i]);
}
for (int i = k; i < arr.length; i++) {
if (arr[i] < maxHeap.peek()) {
maxHeap.poll();
maxHeap.add(arr[i]);
}
}
return maxHeap.peek();
}
利用容量为K的大根堆,不断更新第K小的值,相信大家都能看懂,时间复杂度O(n*logK),空间自然是额外用了一个堆。(当然也可以顺便因为他的正确性,可以把它当作对数器函数!如果还不了解对数器的同学可以参考我的第一篇文章 学会对数器,自己测试代码!)
那么为了更详细的介绍bfprt算法,我们先来看第二种:改写快排的算法。因为bfprt算法只是在这上面做了一个地方的改动。我们直接来看改写快排方法的代码:
// 改写快排,时间复杂度O(N)
public static int minKth2(int[] array, int k) {
int[] arr = copyArray(array);
return process2(arr, 0, arr.length - 1, k - 1);
}//参数意义:模仿快速排序但其实不排序,下标从0到n-1;找下标为k-1的数
//也就是第k小的数
public static int[] copyArray(int[] arr) {
int[] ans = new int[arr.length];
for (int i = 0; i != ans.length; i++) {
ans[i] = arr[i];
}
return ans;
}
👆总函数没什么可说的
// arr 第k小的数
// process2(arr, 0, N-1, k-1)
// arr[L..R] 范围上,如果排序的话(不是真的去排序),找位于index的数
// index [L..R]
public static int process2(int[] arr, int L, int R, int index) {
if (L == R) { // L = =R ==INDEX
return arr[L];
}
// 不止一个数 L + [0, R -L]
int pivot = arr[L + (int) (Math.random() * (R - L + 1))];
//pivot是随机打在数组某个位置
// range[0] range[1]
// L ..... R pivot
// 0 1000 70...800
int[] range = partition(arr, L, R, pivot);//这里的partition函数
//是将数组分为——左边小于第k小的值,
//中间等于,右边大于的结构,不同于快排的是,如果中间区域不中,
//那么只会去左边小于区或右边大于区一侧进行递归,
//而快排是无论怎样都是两边都要进行递归
if (index >= range[0] && index <= range[1]) {
return arr[index];
} else if (index < range[0]) {
return process2(arr, L, range[0] - 1, index);
} else {
return process2(arr, range[1] + 1, R, index);
}
}
👆这里体现出了随机数pivot的重要性,如果随机的恰好靠近中间,那么时间复杂度接近O(N),如果出现在边上,那可能会退化为O(N^2),所以这个算法的时间复杂度其实是依概率收敛于O(N)。后文的bfprt算法正是改进了pivot的取值而使算法完全收敛于O(N)
public static int[] partition(int[] arr, int L, int R, int pivot) {
int less = L - 1;
int more = R + 1;
int cur = L;
while (cur < more) {
if (arr[cur] < pivot) {
swap(arr, ++less, cur++);
} else if (arr[cur] > pivot) {
swap(arr, cur, --more);
} else {
cur++;
}
}
return new int[] { less + 1, more - 1 };
}
👆partition分区比较简单,不做过多解释
重点来了👇,bfprt算法,改变在于精确的给出了pivot的值,直接看代码
// 利用bfprt算法,时间复杂度O(N)
public static int minKth3(int[] array, int k) {
int[] arr = copyArray(array);
return bfprt(arr, 0, arr.length - 1, k - 1);
}
// arr[L..R] 如果排序的话,位于index位置的数,是什么,返回
public static int bfprt(int[] arr, int L, int R, int index) {
if (L == R) {
return arr[L];
}
int pivot = medianOfMedians(arr, L, R);
int[] range = partition(arr, L, R, pivot);
if (index >= range[0] && index <= range[1]) {
return arr[index];
} else if (index < range[0]) {
return bfprt(arr, L, range[0] - 1, index);
} else {
return bfprt(arr, range[1] + 1, R, index);
}
}
👆除了pivot = medianOfMedians(arr, L, R);别的框架没变
// arr[L...R] 五个数一组
// 每个小组内部排序
// 每个小组中位数领出来,组成marr
// marr中的中位数,返回
public static int medianOfMedians(int[] arr, int L, int R) {
int size = R - L + 1;
int offset = size % 5 == 0 ? 0 : 1;
int[] mArr = new int[size / 5 + offset];
for (int team = 0; team < mArr.length; team++) {
int teamFirst = L + team * 5;
// L ... L + 4
// L +5 ... L +9
// L +10....L+14
mArr[team] = getMedian(arr, teamFirst, Math.min(R, teamFirst + 4));
}
// marr中,找到中位数
// marr(0, marr.len - 1, mArr.length / 2 )
return bfprt(mArr, 0, mArr.length - 1, mArr.length / 2);
}
👆这里进行解释,首先
1,把数组分成长度为5的小组,最后不足5的为1组,分为了n/5组
2,每组让其内部有序,时间复杂度O(N)
3,取每组中位数,让他们组成数组mArr
4,最后找到mArr数组中的中位数,作为pivot返回给我们需要的基准值
这里可能比较难理解的是,父函数bfprt调用了子函数medianOfMedians,而子函数的最后一行
return bfprt(mArr, 0, mArr.length - 1, mArr.length / 2);
又调用了父函数,父慈子孝哈哈哈哈哈哈哈哈哈哈哈
结合注释多看几遍代码,应该还是能理解的,他的精髓就在于每次递归都能够多甩掉一部分数据,让查找更加集中。最后让我举个例子说明:
假设一共25个数,那么n = 25 ,5个一组,一共五组,n/5。
找出5个排序后的中位数,假设为1,2,3,4,5,他们五个组成mArr
那mArr的中位数就是3,长度为n/5,那4,5是在3之后,所以组内大于3的占n/5的一半也就是n/10;不要忘记这个4,5也只是分别各自组内的中位数,是一个代表,所以大于3的分别还有两个n/10。
至此,对于pivot = 3,至少3n/10,比他大,那么立即推:有7n/10个数比它小。所以每次递归都能够甩掉3n/10的数,时间复杂度:
N + 7N/10 + …一定收敛于O(N)。
这个算法大家学会了吗?当然笔试机试还是推荐用大根堆来解决。讲得不好的地方欢迎大家批评指正!有问题也可以留言私信!持续分享编程计算机相关文章话题,欢迎关注,评论区交个朋友?