什么是快速选择算法?
快速选择算法可以在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。
复杂度分析:
很多人都会疑惑,这个算法和快排基本上一样,为什么会更快呢?
这是因为我们每次迭代的过程中,数组都会被舍弃一部分,我们把完整的搜索树画出来大概是下面这个样子。
可以看到,虽然总的迭代次数还是次,但是每一层当中遍历的元素个数不再是n。我们假设每次迭代数组的长度都会折损一半,到数组长度等于1的时候结束。我们把每一层遍历的长度全部相加,就得到了一个等比数列:1,2,4,⋯,n
这个等比数列的长度是,我们套用等比数列求和公式:
也就是说虽然它的形式看起来和快排很接近,但是由于我们在遍历的过程当中,每次都会缩小遍历的范围,所以整体的复杂度控制在了O(n)。当然这也只是理想情况下的复杂度,一般情况下随着数据分布的不同,实际的复杂度也会稍有浮动。可以理解成乘上了一个浮动的常数。
优化:BFPRT算法
优化目标很明显,就是极端情况下复杂度会出现降级的情况。虽然我们的基数选择已经是随机的(取头取尾取中间,是很容易被卡掉的),相对来说不那么容易被卡,但是还是存在点背的时候(万一每次随机的都是最差的呢?)
一个比较简单的思路是我们可以选择首尾和中间三个位置的元素,然后选择其中第二大的元素作为标杆。这种方案实现简单,效果也不错,但是分析一下的话,其实没有从根本上解决问题,因为依然还是可能出现极端情况,比如首尾和中间刚好是三个最大的元素。虽然这个概率比单个元素出现最大降低了很多。还有一个问题是,这样选出来的标杆不能保证分割出来的数组是平衡的。
这里要给大家介绍一个牛哄哄的算法,说它牛不是因为它很难,而是因为它真的很牛。它的名字叫BFPRT,是由Blum、Floyd、Pratt、Rivest、Tarjan五位大牛一起发明的。如果你读过《算法导论》的话,一定会找到其中好几个人的名字。该算法可以找到一个比较合适的标杆,用来在快排和快速选择的时候切分数组。
算法的流程很简单,一共只有几个步骤:
- 判断数组元素是否大于5,如果小于5,对它进行排序,并返回数组的中位数
- 如果元素大于5个,对数组进行分组,每5个元素分成一组,允许最后一个分组元素不足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;
}