快速选择算法(线性选择算法)+BFPRT算法优化:O(n)复杂度选择第k大的数

什么是快速选择算法?

快速选择算法可以在O(n)的时间复杂度内,选择一个无序随机数组中第k小(大)的元素,它是根据快速排序算法的思想简化而来的。

快速选择算法同样利用了分治回归策略,由于只需要选择出第k小(大)的元素,因此它在分治之后只需要考虑符合条件一边的元素情况。它同样利用了快速排序的分割元素集的思想,随机产生一个基数 key,将小于基数分到左边,大于基数的分到右边,然后判断基数位置与选择k个最小(大)元素的大小关系,来确定是下一步是选择左区间还是右区间。

代码:

#include<iostream>
using namespace std;

int QSelect(int a[],int l,int r,int K){
	if (l>r) return 0;
	int i=l;
	int j=r;
	srand(time(0));
	int k=rand()%(j-i+1)+i; //取[l,r]的随机数 
	int key=a[k]; //基数 
	a[k]=a[l]; //腾出a[l]这个位置用于交换
	while (i<j){
		while (i<j && a[j]>=key) j--;
		a[i]=a[j];
		while (i<j && a[i]<=key) i++;
		a[j]=a[i];
	}
	a[i]=key;
	if (i+1==K) return a[i];
		else if (i+1>K) return QSelect(a,l,i-1,K);
		else return QSelect(a,i+1,r,K); 
}

int main(){
	int a[]={6,1,2,4,5,8,7,3,5,0,5,10,55};
	int n=13;
	for (int i=0; i<n; i++) cout<<a[i]<<" "; cout<<endl;
	cout<<QSelect(a,0,n-1,10)<<endl; 
	for (int i=0; i<n; i++) cout<<a[i]<<" "; cout<<endl;
} 

代码解释:

int QSelect(int a[],int l,int r,int K)函数定义: 在数组 a 的 l 到 r 区间选择第 K 大的数字。

首先sort函数的定义是对a数组的[l,r]区间排序(左闭右闭),所以我们看到在主函数调用时,传入参数是0,n-1。

代码第5行,首先对于递归区间 l>=r 的,不需要排序了直接return。

然后接着定义了左右指针 i j (因为 l,r 作为我们的边界在后续的分治过程还需要使用)。

基数的选择有很多种,取头取尾还是取中间都可能被卡掉,所以这里用的 rand() 取基数,k为选定的基数下标,key为基数。

代码11行 a[k]=a[l] 和代码18行 a[i]=key,这两个语句要连起来看:
    代码12的while,最终会使得 i,j 停留在一个位置,并且那个位置是key的最终归宿,那么对于交换本身操作来说,需要一个“空杯子”temp来倒腾一下,这里的 a[k]=a[l] 就是把 a[l] 这个位置空出来(因为 a[k] 我们已经存在key中了),a[l] 空出来以后,下面的第一次查找一定是 j 不能是 i,要先去覆盖 a[i] 也就是 a[l],再用 a[j]=a[i] 覆盖,如此循环,最终 i==j ,也就是找到了最终key的位置。所以有代码18行的a[i]=key。

复杂度分析:

很多人都会疑惑,这个算法和快排基本上一样,为什么会更快呢?

这是因为我们每次迭代的过程中,数组都会被舍弃一部分,我们把完整的搜索树画出来大概是下面这个样子。

可以看到,虽然总的迭代次数还是log_{2}n次,但是每一层当中遍历的元素个数不再是n。我们假设每次迭代数组的长度都会折损一半,到数组长度等于1的时候结束。我们把每一层遍历的长度全部相加,就得到了一个等比数列:1,2,4,⋯,n

这个等比数列的长度是log_{2}n,我们套用等比数列求和公式:

                                      

也就是说虽然它的形式看起来和快排很接近,但是由于我们在遍历的过程当中,每次都会缩小遍历的范围,所以整体的复杂度控制在了O(n)。当然这也只是理想情况下的复杂度,一般情况下随着数据分布的不同,实际的复杂度也会稍有浮动。可以理解成乘上了一个浮动的常数。

优化:BFPRT算法

优化目标很明显,就是极端情况下复杂度会出现降级的情况。虽然我们的基数选择已经是随机的(取头取尾取中间,是很容易被卡掉的),相对来说不那么容易被卡,但是还是存在点背的时候(万一每次随机的都是最差的呢?)

一个比较简单的思路是我们可以选择首尾和中间三个位置的元素,然后选择其中第二大的元素作为标杆。这种方案实现简单,效果也不错,但是分析一下的话,其实没有从根本上解决问题,因为依然还是可能出现极端情况,比如首尾和中间刚好是三个最大的元素。虽然这个概率比单个元素出现最大降低了很多。还有一个问题是,这样选出来的标杆不能保证分割出来的数组是平衡的。

这里要给大家介绍一个牛哄哄的算法,说它牛不是因为它很难,而是因为它真的很牛。它的名字叫BFPRT,是由Blum、Floyd、Pratt、Rivest、Tarjan五位大牛一起发明的。如果你读过《算法导论》的话,一定会找到其中好几个人的名字。该算法可以找到一个比较合适的标杆,用来在快排和快速选择的时候切分数组。

算法的流程很简单,一共只有几个步骤:

  1. 判断数组元素是否大于5,如果小于5,对它进行排序,并返回数组的中位数
  2. 如果元素大于5个,对数组进行分组,每5个元素分成一组,允许最后一个分组元素不足5个。
  3. 对于每个分组,对它进行插入排序
  4. 选择出每个分组排序之后的中位数,组成新的数组
  5. 重复以上操作

算法思路很朴素,其实就是一个不断选择中位数的过程。

经证明该算法在最差的情况下复杂度也是O(n),经过BFPRT算法优化的快速选择算法,可以真正称得上是线性的了。

代码:

#include<iostream>
#include<algorithm>
using namespace std;

//插入排序 
void InsertSort(int a[],int l,int r){
	for (int i=l+1; i<=r; i++){
		int num=a[i];
		int j=i;
		while (j-1>=l && num<a[j-1]) {
			a[j]=a[j-1];
			j--;		
		}
		a[j]=num;
	}
}

//找key的位置 
int Find(int a[],int l,int r,int key){
	for (int i=l; i<=r; i++){
		if (a[i]==key) return i;
	}
}

//BFPRT算法,找中位数的中位数 
int BFPRT(int a[],int l,int r){
	int k=0; //有几个中位数 
	int i=l; //指针i用来遍历【l,r】区间
	while (i+4<=r){ //够分几个组(5个一组) 
		InsertSort(a,i,i+4);//用到插入排序 
		k++;
		swap(a[l+k-1],a[i+2]);
		i+=5;
	} 
	if (i<=r){ //剩下的 
		InsertSort(a,i,r);
		k++;
		swap(a[l+k-1],a[i+(r-i+1)/2]);
	}
	if (k==1) return a[l];
		else return BFPRT(a,l,l+k-1);
}

//快速选择算法 
int QSelect(int a[],int l,int r,int K){
	if (l>r) return 0;
	int i=l;
	int j=r;
	int key=BFPRT(a,l,r); //利用BFPRT算法来产生基数 
	int k=Find(a,l,r,key);  //寻找基数的位置,也可以设置一个全局变量来传递位置 
	a[k]=a[l];
	while (i<j){
		while (i<j && a[j]>=key) j--;
		a[i]=a[j];
		while (i<j && a[i]<=key) i++;
		a[j]=a[i];
	}
	a[i]=key;
	if (K==i+1) return a[i];
	else if (K<i+1) return QSelect(a,l,i-1,K);
	else return QSelect(a,i+1,r,K);
} 

int main(){
	int a[]={5,1,2,4,3,6,9,7,8,10};
	int n=10;
	int k=9;
	cout<<QSelect(a,0,n-1,k)<<endl; 
} 

 

  • 1
    点赞
  • 3
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值