Date: 2021/03/06
题目描述
求第k小元问题(k-smallest),又称为选择问题。
该问题可以描述为:在一个有 n 个元素的集合中找出第 k(1 <= k <= n) 小的元素,
- 当
k==1时,就是求集合的最小值 - 当
k==2时,就是求集合的最大值 - 当
k==(n+1)/2时,就是求集合的中位数
例如集合 {23,12,5,15,11,6,1} 的第 1 小元为 1,第 4 小元为 11。
思路分析
先排序可行吗?
这个问题最大的障碍就是元素是乱序的,如果能够让给定的元素升序排列,我们很容易就可以得到第 k 小元就是下标为 k-1 的元素值。
那么最简单直接的想法就是将元素先进行升序排序,然后通过下标索引到第 k 小元。但是如果只是为了得到某一个值,就要对整个集合排序,是不是略显多余,而且当元素过多时,效率也并不高。
先排序不是我们要寻找的理想操作。
核心思想
虽然不建议整体排序,但可以借用排序的思想。
在学过的排序算法中,快速排序 有一个特点:每一轮排序(升序)之后,能够确定基准值 pivot 最终的位置,并且基准值左边的元素都比它小,基准值右边的元素都比他大。也就是说,如果基准值对应的下标为 i,那么基准值就是集合的第 i小元。
这个特点称为分区
partitioning。
根据分区的特点,对 left ~ right(left = 0,right = n-1)范围内元素一次分区后,有以下情况:
k == i+1,则第k小元就是基准值,算法结束k > i+1,则第k小元为基准值右侧所有元素的第k-(i+1)小元,left = i+1,right = n-1k < i+1,则第k小元为基准值左侧所有元素的第k小元,left = 0,right = i-1
按照以上规律多次进行分区操作,最终可以找到第 k 小元。
下面我们讨论另一个问题,如何选取基准值?
选取基准值
随机选择基准值
在最简单的快速排序中,基准值的选择总是默认为第一个元素,但是这并不是最优方案。稍加改进,可以随机选择一个基准值,为了方便操作,随机选择的基准值和第一个元素交换。
代码实现如下:
/**
* 随机选择基准值(主元)
* 迭代调用
* @param values 目标数组
* @param kth 第k小
* @return 第k小元下标
*/
public static int kthSmallestInRandomPivot(int[] values, int kth) {
if (values == null) {
throw new IllegalArgumentException("values == null");
}
if (kth > values.length) {
return -1;
}
int left = 0, right = values.length - 1;
while (true) {
// 分区操作,true表示使用随机策略
int mid = partitioning(values, left, right);
if (kth == mid + 1) {
return values[mid];
} else if (kth < mid + 1) {
right = mid - 1;
} else {
left = mid + 1;
}
}
}
/**
* 随机选择基准值进行分区
* @param values 目标数组
* @param left 分区左范围
* @param right 分区右范围
*/
public static int partitioning(int[] values, int left, int right) {
int pivotIndex = left + new Random().nextInt(right - left + 1);
if (pivotIndex != left) {
SortUtils.swap(values, pivotIndex, left);
}
/** 基准值 */
int pivot = values[left];
int i = left, j = right;
while (i != j) {
while (i < j && values[j] >= pivot) {
j--;
}
while (i < j && values[i] <= pivot) {
i++;
}
if (i < j) {
SortUtils.swap(values, i, j);
}
}
values[left] = values[i];
values[i] = pivot;
return i;
}
二次取中选取基准值
将 n 个元素组成分组,确定每组的中间值,分别依次排列在数组的首部;再对首部的各个中间值取中间值作为基准值,并和第一个元素交换,然后进行分区操作。这就是二次取中法(Median of Median Rule)的基本思想。
例如一组 n=15 个数的数组:[41,76,55,19,59,63,12,47,67,45,26,76,74,33,18],分为3 组,每组元素设为 5 个:
-
首先对三组数据分别排序,中值分别为
55,47,33 -
然后依次将
55,47,33分别和前三个位置的元素交换
- 然后对
55,47,33进行排序,取中值为47,与第一个元素交换,基准值为47
找到了基准值后就可以进行分区求解了。
另外,要说明的是,当数据规模足够小时,也可以理解为元素还不够分为一组时,可以直接使用排序算法确定第 k 小元,在确定分组元素中值时也可以使用排序算法来实现(我这里使用的是 插入排序)。
/**
* 线性时间的选择算法:二次取中法
* @param values 目标数组
* @param kth 第k小
* @return 第k小元下标
*/
public static int kthSmallestInMMR(int[] values, int kth) {
return kthSmallestInLinearTime(values, kth, 0, values.length - 1, 5);
}
/**
* 二次取中法 Median of Median Rule
* 递归调用
* @param values 目标数组
* @param kth 第k小
* @param left 当前数据范围左下标
* @param right 当前数据范围右下标
* @param numInGroup 分组中每组的元素个数
* @return 第k小元下标
*/
public static int kthSmallestInMMR(int[] values, int kth, int left, int right, int numInGroup) {
int currentRange = right - left + 1;
/** 分组数 */
int groups = currentRange / numInGroup;
if (currentRange <= numInGroup) {
/** 当前范围的元素无法分为一组时,直接使用插入排序排序,返回第k小元下标 */
InsertSort.insertSort(values, left, right);
return left + kth - 1;
}
for (int i = 0; i < groups; i++) {
/** 使用插入排序对每个组排序,并将中值放在前面 */
InsertSort.insertSort(values, left + i * numInGroup, left + (i + 1) * numInGroup - 1);
SortUtils.swap(values, left + i, left + i * numInGroup + (int) Math.ceil(numInGroup / 2.0) - 1);
}
/** 递归调用,进行二次取中 */
int pivotIndex = kthSmallestInMMR(values, (int) Math.ceil(groups / 2.0), left, left + groups - 1, numInGroup);
SortUtils.swap(values, pivotIndex, left);
pivotIndex = partitioning(values, left, right); /** 进行分区,获取基准值下标 */
if (kth == pivotIndex - left + 1) {
return pivotIndex;
} else if (kth < pivotIndex - left + 1) {
return kthSmallestInMMR(values, kth, left, pivotIndex - 1, numInGroup);
} else {
return kthSmallestInMMR(values, kth - (pivotIndex - left + 1), pivotIndex + 1, right, numInGroup);
}
}
/**
* 随机选择基准值进行分区
* @param values 目标数组
* @param left 分区左范围
* @param right 分区右范围
*/
public static int partitioning(int[] values, int left, int right) {
/** 基准值 */
int pivot = values[left];
int i = left, j = right;
while (i != j) {
while (i < j && values[j] >= pivot) {
j--;
}
while (i < j && values[i] <= pivot) {
i++;
}
if (i < j) {
SortUtils.swap(values, i, j);
}
}
values[left] = values[i];
values[i] = pivot;
return i;
}
总结
对于随机选取基准值的方式,该问题的平均时间复杂度为 O(n),最坏时间复杂度为 O(n*n)。对于二次取中法选取基准值求解该问题,效率更高,最坏时间复杂度为 O(n)。
求第 k 小元问题实际上是 分治法 思想的体现,分治法往往伴随这使用递归,虽然能够解决很多问题,但由于递归会带来性能损耗,非必要情况下不建议使用递归实现,可以考虑借助栈来消除递归。
本文正在参与「掘金 2021 春招闯关活动」, 点击查看 活动详情