【引言】
-
问题:如何从一个无序的数组中求出第 K 大的数 (为了简化讨论,假设数组中的数各不相同)。
-
例如,对数组 {5, 12, 7, 2, 9, 3} 来说,第三大的数是5,第五大的数是9
-
最直接的想法是对数组排下序,然后直接取出第 K 个元素即可。但是这样做法需要 O(nlogn) 的时间复杂度,虽然看起来很好,但还有更优的算法。
【概述】
-
下面介绍随机选择算法,它对任何输入都可以达到 O(n) 的期望时间复杂度。
-
随机选择算法的原理类似于之前介绍的 随机快速排序算法。
-
当对 A[left, right] 执行一次 randPartition 函数之后,主元左侧的元素个数就是确定的,且它们都小于主元。假设此时主元是 A[p],那么 A[p] 就是 A[left, right] 中的第 p-left+1大的数。
不妨令 M 表示 p-left+1,那么
-
如果 K==M 成立,说明第 K 大的数就是主元 A[p];
-
如果 K < M 成立,就说明第K 大的数在主元左侧,即 A[left…(p- 1)] 中的第 K 大,往左侧递归即可;
-
如果 K > M 成立,则说明第 K 大的数在主元右侧,即 A[(p + 1)…right] 中的第 K-M 大,往右侧递归即可。
-
算法以 left == right 作为递归边界,返回 A[left] 。
由此可以写出随机选择算法的代码:
//随机选择算法,从A[left,right]中返回第k大的数
int randSelect(int A[], int left, int right, int k)
{
if(left==right)
return A[left]; //边界
int p = randPartition(A, left, right); //划分后主元的位置为p
int m = p-left+1; //A[p]是A[left,right]中的第M大
if(k==m) //找到第k大的数
return A[p];
if(k<m) //第k大的数在主元左侧
return randSelect(A, left, p-1, k); //往主元左侧找第k大
else //第k大的数在主元右侧
return randSelect(A, p+1, right, k-m); //往主元右侧找第k-m大
}
- 可以证明,虽然随机选择算法的最坏时间复杂度是O(n2),但是其对任意输入的期望时间复杂度却是O(n),这意味着不存在一组特定的数据能使这个算法出现最坏情况,是个相当实用和出色的算法 (详细证明可以参考《算法导论》)。
【应用】
- 给定一个由整数组成的集合, 集合中的整数各不相同,现在要将它分为两个子集合,使得这两个子集合的并为原集合、交为空集,同时在两个子集合的元素个数 n1 与 n2 之差的绝对值 |n1-n2| 尽可能小的前提下,要求它们各自的元素之和 S1 与 S2 之差的绝对值 |S1-S2| 尽可能大。求这个 |S1-S2| 等于多少。
对这个问题首先可以注意到的是,如果原集合中元素个数为 n,那么
- 当 n 是偶数时,由它分出的两个子集合中的元素个数都是 n/2;
- 当n是奇数时,由它分出的两个子集合中的元素个数分别是 n/2 与n/2+1 (除法为向下取整,下同)。
- 显然,为了使 |S1-S2| 尽可能大,最直接的思路是将原集合中的元素从小到大排序,取排序后的前 n/2 个元素作为其中一个子集合,剩下的元素作为另一个子集合即可,时间复杂度为O(nlogn)。
而更优的做法是使用上面介绍的随机选择算法。
- 根据对问题的分析,这个问题实际上就是求原集合中元素的第 n/2 大,同时根据这个数把集合分为两部分,使得其中一个子集合中的元素都不小于这个数,而另一个子集合中的元素都大于这个数,至于两个子集合内部元素的顺序则不需要关心。因此只需要使用 randSelect 函数求出第 n/2 大的数即可,该函数会自动切分好两个集合,期望时间复杂度为 O(n)。代码如下:
#include<cstdio>
#include<cstdlib>
#include<ctime>
#include<algorithm>
using namespace std;
const int maxn = 100010;
int A[maxn], n; //A存放所有整数,n为其个数
//选取随机主元,对区间[left,right]进行划分
int randPartition(int A[], int left, int right)
{
//生成[left,right]内的随机数
int p = (int) ( round(1.0*rand()/RAND_MAX*(right-left) + left) );
swap(A[p], A[left]); //交换A[p]和A[left]
int temp = A[left]; 将A[left]存放至临时变量temp
while(left<right) 只要left与right不相遇
{
while(left<right && A[right]>temp) //反复左移right
right--;
A[left] = A[right]; //将A[right]挪到A[left]
while(left<right && A[left]<=temp) //反复右移left
left++;
A[right] = A[left]; //将A[left]挪到A[right]
}
A[left] = temp; //把temp放到left与right相遇的地方
return left; //返回相遇的下标
}
//随机选择算法,从A[left,right]中找到第k大的数,并进行切分
void randSelect(int A[], int left, int right, int k)
{
if(left==right)
return; //边界
int p = randPartition(A, left, right); //划分后主元的位置为p
int m = p-left+1; //A[p]是A[left,right]中的第m大
if(k==m) //找到第k大的数
return;
if(k<m) //第k大的数在主元左侧
randSelect(A, left, p-1, k); //往主元左侧找第k大
else //第k大的数在主元右侧
randSelect(A, p+1, right, k-m); //往主元右侧找第k-m大
}
int main()
{
srand((unsigned)time(NULL)); //初始化随机数种子
//sum和sum1记录所有整数之和与切分后前n/2个元素之和
int sum = 0, sum1 = 0;
scanf("%d",&n) //整数个数
for(int i=0;i<n;i++)
{
scanf("%d",&A[i]); //输入整数
sum += A[i]; //累计所有整数之和
}
randSelect(A, 0, n-1, n/2); //寻找第n/2大的数,并进行切分
for(int i=0;i<n/2;i++)
sum1 += A[i]; //累计较小的子集合中元素之和
printf("%d\n",(sum-sum1)-sum1); //求两个子集合的元素和之差
return 0;
}
- 由于在这个问题中不需要关心第 n/2 大的数是什么,而只需要实现根据第n/2大的数进行切分的功能,因此 randSelect 函数不需要设置返回值。
- 另外,如果能保证数据分布较为随机,那么代码中的 randPartition 函数也可替换成普通的 Partition 函数。
- 除此之外,还有一种即便是最坏时间复杂度也是 O(n) 的选择算法,但是比较偏理论化,就不在此处介绍了。