在无序数组中求第 K K K 小的数
改写快排的方法
【思路】在该无序数组中 随机 选择一个数 v v v,拿 v v v 去做整个数组的荷兰国旗问题,即将数组分成三个区域 “小于 v v v | 等于 v v v | 大于 v v v”,每个区域都不要求有序,不过等于 v v v 的区域天然就已经有序了,实现这个过程的时间复杂度 O ( N ) O(N) O(N),空间复杂度 O ( 1 ) O(1) O(1)。
然后查看 等于 v v v 的区域是否命中了第 K K K 小的数。假设 等于 v v v 的区域下标是从 50 50 50 ~ 60 60 60:
- 如果 K = 55 K = 55 K=55,那么就命中了,第 K K K 小的数就是 v v v;
- 如果 K = 30 K=30 K=30,就是没有命中,而待求的一定就在 小于 v v v 区域,于是在 小于 v v v 区域重复上面的操作,随机选择一个数 v ′ v' v′做荷兰国旗问题,分成 “小于 v ′ v' v′ | 等于 v ′ v' v′ | 大于 v ′ v' v′” 三个区域,然后看 v ′ v' v′ 是否命中了第 K K K 小的数,递归下去;
这就是最优解,时间复杂度为 O ( N ) O(N) O(N)。
快排的时间复杂度为 O ( N ∗ l o g N ) O(N * logN) O(N∗logN),是 小于 v v v 和 大于 v v v 的区域都要递归进行排序;而本方法中如果 等于 v v v 没有命中,则要么小于 v v v 区域进行递归,要么 大于 v v v 区域进行递归,只走一侧,所以比快排要快。
事实上,如果选择的 v v v 是很差的情况,时间复杂度是 O ( N 2 ) O(N^2) O(N2);如果选择的 v v v 是很好的情况,时间复杂度是 O ( N ) O(N) O(N)。但是因为是随机选择的,所以是个概率事件,最后的期望收敛到 O ( N ) O(N) O(N)。
-方法一:大根堆,时间复杂度 O ( N ∗ l o g K ) O(N*logK) O(N∗logK)
public class FindMinKth {
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<>(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();
}
}
- 方法二:(最优解)改写快排,时间复杂度 O ( N ) O(N) O(N),空间复杂度 O ( 1 ) O(1) O(1)
(1)递归实现:
public class FindMinKth {
// 方法二:改写快排,时间复杂度O(N)
public static int minKth2(int[] array, int k) { // k是大于等于1的
int[] arr = copyArray(array);
//位于第k-1位置的数就是第k小的数
return process2(arr, 0, arr.length - 1, k - 1);
}
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) { //index在L...R范围上,当只有一个数的时候,就是 L == R == index
return arr[L];
}
// 不止一个数 L + [0, R -L]
//随机选择一个数
int pivot = arr[L + (int) (Math.random() * (R - L + 1))];
//荷兰国旗分区:分成 小于pivot | 等于pivot | 大于pivot 三个区域
int[] range = partition(arr, L, R, pivot); //返回值range为 等于pivot区域 的左右边界
//查看是否命中 (因为如果没有命中,只会进入一个情况,所以其中涉及到的递归可以用迭代实现)
if (index >= range[0] && index <= range[1]) { //index位于等于pivot区域的左右边界中,即命中
return arr[index];
} else if (index < range[0]) { //没有命中,index小于 “等于pivot区域” 的左边界,则小于pivot区域递归
return process2(arr, L, range[0] - 1, index);
} else { //没有命中,index大于 “等于pivot区域” 的右边界,则大于pivot区域递归
return process2(arr, range[1] + 1, R, index);
}
}
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 };
}
public static void swap(int[] arr, int i1, int i2) {
int tmp = arr[i1];
arr[i1] = arr[i2];
arr[i2] = tmp;
}
}
(2)迭代实现:
public class FindMinKth {
public static int minKth(int[] arr, int index) {
int L = 0;
int R = arr.length - 1;
int pivot = 0;
int[] range = null;
while (L < R) {
pivot = arr[L + (int) (Math.random() * (R - L + 1))];
range = partition(arr, L, R, pivot);
if (index < range[0]) {
R = range[0] - 1;
} else if (index > range[1]) {
L = range[1] + 1;
} else {
return pivot;
}
}
return arr[L];
}
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 };
}
public static void swap(int[] arr, int i, int j) {
int tmp = arr[i];
arr[i] = arr[j];
arr[j] = tmp;
}
}
bfprt算法
五个人联合发明的算法,用于解决 在无序数组中找到第 K K K 小的数 的问题。
虽然 改写快排 的方法已经是最优解了,但是利用概率来解释时间复杂度是 O ( N ) O(N) O(N),而bfprt算法不用概率来解释也能可以做到 O ( N ) O(N) O(N)。
bfprt 算法和改进快排的方法主要区别就在于划分值
v
v
v 的选择。
那么 bfprt 算法是如何讲究地选择划分值
v
v
v的呢?
- 原数组按照五个数分一组,若最后一组不满五个数,则剩几个就几个数为一组。【逻辑概念,时间复杂度 O ( 1 ) O(1) O(1)】
数组:[........... ........... ........... ...........]
下标:0 1 2 3 4 5 6 7 8 9 ....
分组:(0~4) (5~9) ...
- 每组数内部排序。【因为每组数只有5个数,所以每组数排好序的时间复杂度 O ( 1 ) O(1) O(1),整体有 N / 5 N/5 N/5 组,所以整体时间复杂度 O ( N ) O(N) O(N)】
- 取出每组数排好序的中位数组成一组,若最后一组是偶个数,则取出上中位数(如最后一组4个数,排好序后为 a a a b b b c c c d d d,则取出 b b b 即可),该数组长度为 N / 5 N/5 N/5。【时间复杂度 O ( N ) O(N) O(N)】
- 假设每个小组的中位数组成的数组为
M
M
M,求
M
M
M 中的中位数
v
v
v 即为划分值。【注:假设函数
bfprt(arr, k)
表示求解arr
数组中的第k
小的数,那么求 M M M 的中位数时可以借助bfprt
函数,bfprt(M, N/10)
就是求得 M M M 的第 N / 10 N/10 N/10 小的数,也就是 M M M 的中位数】 【T(N/5)规模】
举例:
好处:精心挑选划分值
v
v
v 后,那么
<
v
<v
<v 区域和
>
v
>v
>v 区域都有一定的规模。
如何估计 < v < v <v 区域最多有多少个数?
直接估计很难,但是如果能估计出 ≥ v \ge v ≥v 的区域至少有 a a a 个数,那么 < v \lt v <v的部分最多有 N − a N - a N−a 个数。
原数组长度为 N N N,每 5 个数为一组,内部排序后选择每组的中位数组成数组 M M M,则 M M M 长度为 N / 5 N/5 N/5;
再选择 M M M 的中位数 v v v,那么数组 M M M中 ≥ v \ge v ≥v 的至少有 N / 10 N/10 N/10 个数;
而每个数对应回原小组中比该数大的又会多2个,所以整个数组至少有 3 N / 10 3N/10 3N/10 个数是 ≥ v \ge v ≥v 的;
所以 < v \lt v <v 的最多有 7 N / 10 7N/10 7N/10 个;同理, > v \gt v >v 的最多有 7 N / 10 7N/10 7N/10 个。
这就是 bfprt 算法的精髓:可以保证每次递归至少淘汰掉 3 N / 10 3N/10 3N/10 的数据。
选出 v v v 后的步骤:
- 将原数组分成 < v \lt v <v | = v =v =v | > v \gt v >v 三个区域;【时间复杂度 O ( N ) O(N) O(N)】
- 如果命中,直接返回;如果没命中,选择 < v <v <v 或 > v >v >v 进行递归。【规模为 T ( 7 N / 10 ) T(7N/10) T(7N/10)】
所以总体的时间复杂度为 T ( N ) = T ( 7 N / 10 ) + T ( N / 5 ) + O ( N ) T(N) = T(7N/10) + T(N/5) + O(N) T(N)=T(7N/10)+T(N/5)+O(N),算法导论中证明了该等式最后的时间复杂度为 O ( N ) O(N) O(N)。不能用 master 公式计算时间复杂度,因为子过程的规模不一样。
对于该算法的常见问题:
【 为什么要 5 个数一组?】
事实上3个数一组或7个数一组都能使得时间复杂度收敛到 O ( N ) O(N) O(N),最终之所以选择 5 个数一组,是因为该算法是 5 个人联合发明的,所以人家选择了 5 个数一组,人家喜欢 5 这个数。
实现:
- 方法三:bfprt算法,时间复杂度 O ( N ) O(N) O(N)
public class FindMinKth {
// 利用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];
}
// L~R范围上 每五个数一组
// 每一个小组内部排好序
// 小组的中位数组成新数组
// 这个新数组的中位数返回
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);
}
}
// 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];
//选出每组中的中位数,放到mArr数组中
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));
}
// 子过程调用bfprt方法找到mArr数组的中位数
// 在mArr的0damarr.len - 1, mArr.length / 2 )
return bfprt(mArr, 0, mArr.length - 1, mArr.length / 2);
}
public static int getMedian(int[] arr, int L, int R) {
// 因为每个小组只有5个数,所以选择常数时间复杂度较好的排序算法即可,此处也可以用其他排序算法
// 没必要用时间复杂度特别优秀的排序算法
insertionSort(arr, L, R);
return arr[(L + R) / 2];
}
public static void insertionSort(int[] arr, int L, int R) {
for (int i = L + 1; i <= R; i++) {
for (int j = i - 1; j >= L && arr[j] > arr[j + 1]; j--) {
swap(arr, j, j + 1);
}
}
}
}
验证测试:
public class FindMinKth {
// for test
public static int[] generateRandomArray(int maxSize, int maxValue) {
int[] arr = new int[(int) (Math.random() * maxSize) + 1];
for (int i = 0; i < arr.length; i++) {
arr[i] = (int) (Math.random() * (maxValue + 1));
}
return arr;
}
public static void main(String[] args) {
int testTime = 1000000;
int maxSize = 100;
int maxValue = 100;
System.out.println("test begin");
for (int i = 0; i < testTime; i++) {
int[] arr = generateRandomArray(maxSize, maxValue);
int k = (int) (Math.random() * arr.length) + 1;
int ans1 = minKth1(arr, k);
int ans2 = minKth2(arr, k);
int ans3 = minKth3(arr, k);
if (ans1 != ans2 || ans2 != ans3) {
System.out.println("Oops!");
}
}
System.out.println("test finish");
}
}