在无序数组中求第K小的数

在无序数组中求第 K 小的数

改写快排的方法

【思路】在该无序数组中 随机 选择一个数 vvv,拿 vvv 去做整个数组的荷兰国旗问题,即将数组分成三个区域 “小于vvv | 等于 vvv | 大于 vvv,每个区域都不要求有序,不过等于 vvv 的区域天然就已经有序了,实现这个过程的时间复杂度 O(N)O(N)O(N),空间复杂度 O(1)O(1)O(1)

然后查看 等于vvv 的区域是否命中了第 KKK 小的数。假设 等于vvv 的区域下标是从 505050 ~ 606060

  • 如果 K=55K = 55K=55,那么就命中了,第 KKK 小的数就是 vvv
  • 如果 K=30K=30K=30,就是没有命中,而待求的一定就在 小于vvv 区域,于是在 小于vvv 区域重复上面的操作,随机选择一个数v′v'v做荷兰国旗问题,分成 “小于v′v'v | 等于v′v'v | 大于v′v'v 三个区域,然后看 v′v'v 是否命中了第 KKK 小的数,递归下去;

这就是最优解,时间复杂度为 O(N)O(N)O(N)

快排的时间复杂度为 O(N∗logN)O(N * logN)O(NlogN),是 小于vvv 和 大于vvv 的区域都要递归进行排序;而本方法中如果 等于vvv 没有命中,则要么小于vvv 区域进行递归,要么 大于vvv 区域进行递归,只走一侧,所以比快排要快。

事实上,如果选择的 vvv 是很差的情况,时间复杂度是O(N2)O(N^2)O(N2);如果选择的vvv 是很好的情况,时间复杂度是O(N)O(N)O(N)。但是因为是随机选择的,所以是个概率事件,最后的期望收敛到 O(N)O(N)O(N)

-方法一:大根堆,时间复杂度 O(N∗logK)O(N*logK)O(NlogK)

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算法

五个人联合发明的算法,用于解决 在无序数组中找到第 KKK 小的数 的问题。

虽然 改写快排 的方法已经是最优解了,但是利用概率来解释时间复杂度是O(N)O(N)O(N),而bfprt算法不用概率来解释也能可以做到O(N)O(N)O(N)

bfprt 算法和改进快排的方法主要区别就在于划分值 vvv 的选择。
请添加图片描述
那么 bfprt 算法是如何讲究地选择划分值 vvv的呢?

  1. 原数组按照五个数分一组,若最后一组不满五个数,则剩几个就几个数为一组。【逻辑概念,时间复杂度O(1)O(1)O(1)
数组:[........... ........... ........... ...........]
下标:0 1 2 3 4  5 6 7 8 9 ....
分组:(0~4)  (5~9) ...
  1. 每组数内部排序。【因为每组数只有5个数,所以每组数排好序的时间复杂度O(1)O(1)O(1),整体有 N/5N/5N/5 组,所以整体时间复杂度O(N)O(N)O(N)
  2. 取出每组数排好序的中位数组成一组,若最后一组是偶个数,则取出上中位数(如最后一组4个数,排好序后为 aaa bbb ccc ddd,则取出 bbb 即可),该数组长度为 N/5N/5N/5。【时间复杂度O(N)O(N)O(N)
  3. 假设每个小组的中位数组成的数组为 MMM,求 MMM 中的中位数 vvv 即为划分值。【注:假设函数 bfprt(arr, k) 表示求解 arr 数组中的第 k 小的数,那么求 MMM 的中位数时可以借助bfprt 函数,bfprt(M, N/10) 就是求得 MMM 的第 N/10N/10N/10 小的数,也就是 MMM 的中位数】 【T(N/5)规模

举例:
请添加图片描述
好处:精心挑选划分值vvv 后,那么 <v<v<v 区域和 >v>v>v 区域都有一定的规模。

如何估计 <v< v<v 区域最多有多少个数?

直接估计很难,但是如果能估计出 ≥v\ge vv 的区域至少有 aaa 个数,那么 <v\lt v<v的部分最多有 N−aN - aNa 个数。

原数组长度为 NNN,每 5 个数为一组,内部排序后选择每组的中位数组成数组 MMM,则MMM 长度为 N/5N/5N/5

再选择 MMM 的中位数 vvv,那么数组MMM≥v\ge vv 的至少有 N/10N/10N/10 个数;

而每个数对应回原小组中比该数大的又会多2个,所以整个数组至少有 3N/103N/103N/10 个数是 ≥v\ge vv 的;

所以 <v\lt v<v 的最多有 7N/107N/107N/10 个;同理,>v\gt v>v 的最多有 7N/107N/107N/10 个。

这就是 bfprt 算法的精髓:可以保证每次递归至少淘汰掉 3N/103N/103N/10 的数据

选出 vvv 后的步骤:

  1. 将原数组分成 <v\lt v<v | =v=v=v | >v\gt v>v 三个区域;【时间复杂度O(N)O(N)O(N)
  2. 如果命中,直接返回;如果没命中,选择 <v<v<v>v>v>v 进行递归。【规模为T(7N/10)T(7N/10)T(7N/10)

所以总体的时间复杂度为 T(N)=T(7N/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");
	}
}
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值