随机选择算法

【引言】

  • 问题:如何从一个无序的数组中求出第 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,那么

  1. 如果 K==M 成立,说明第 K 大的数就是主元 A[p];

  2. 如果 K < M 成立,就说明第K 大的数在主元左侧,即 A[left…(p- 1)] 中的第 K 大,往左侧递归即可;

  3. 如果 K > M 成立,则说明第 K 大的数在主元右侧,即 A[(p + 1)…right] 中的第 K-M 大,往右侧递归即可。

  4. 算法以 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,那么

  1. 当 n 是偶数时,由它分出的两个子集合中的元素个数都是 n/2;
  2. 当n是奇数时,由它分出的两个子集合中的元素个数分别是 n/2 与n/2+1 (除法为向下取整,下同)。
  3. 显然,为了使 |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) 的选择算法,但是比较偏理论化,就不在此处介绍了。

猜你喜欢

转载自blog.csdn.net/qq_42815188/article/details/88896028