分治法——线性时间选择
问题:给定线性序集中n个元素和一个整数k,1≤k≤n,要求找出这n个元素中第k小的元素,算法的复杂度为O(n)。
思路分析:
首先,假如我们要找最大或者最小的元素,那么只需遍历一遍序列即可,复杂度为O(n)。假如要找第k大的元素,k<=n/logn或者k>=n-nlogn时,利用堆排序,时间复杂度仍然可以达到O(n),具体为什么还没搞懂。无论如何,如果对于任意的k,(最坏)时间复杂度要想达到O(n),对整个序列进行排序都是不可行的(复杂度至少为nlogn)。
那么如何将复杂度降低到O(n)呢?由于我们求的仅仅是第k大的数字,那么可以不用整个对整个序列进行排序,而仅仅保证在第k大的数字前面的数字都小于这个第k大数即可。借助快排的思想,每次找一个哨兵将序列分为大于哨兵和小于哨兵的两部分,根据需要求的数是第k大选择一个子序列递归进行排序即可,而非对所有的序列都要排序。
假如能够做到在任意情况下,所选的哨兵将序列等比地分为两部分,就可以做到线性地得到结果:设子序列的长度分别为ne和n(1-e),复杂度递推公式为T(n) = T(ne)+O(n),时间复杂度为O(n)。
如何保证在任意情况下哨兵都会做到等比分割序列呢?传统的快速排序算法在选取哨兵时采用序列的第一个元素,这样在极端情况下会退化为等差递减的递归,也就是冒泡;随机快速排序算法对此进行了一些优化,每次随机选取哨兵,但是本质上也是一样。将序列预划分可以有效地解决这一问题:
1.将序列划分成n/5个子序列,每个子序列有5个元素,最后一个不足5个元素的子序列扔掉不要(因为目的只是为了找一个差不多的基准哨兵),任选一种排序方法对每个子序列进行排序,对每个子序列取中位数
2.取出中位数后,对n/5个中位数再取中位数,这里其实转化为中位数中求解第k大的数的子问题
3.以确定的中位数作为哨兵,进行划分序列,可以证明,当n>=75时,此时子序列近似划分为n/4和3n/4两个部分
复杂度递推公式为T(n)=T(n/5)+T(3n/4)+O(n),n>=75;T(n)=O(1),n<75
代码:
// 将序列a[]重排为mid左边的元素全部小于mid,mid右边的元素全部大于mid
// 输入:序列a,序列起始下标l,终止下标r,哨兵大小mid
// 输出:mid在序列a中的位置j,据此可确定划分完成的两个子序列
int part(int a[],int l,int r,int mid){
int i = l,j = r;
while(i<=r && j>=l){
while(a[i]<=mid){i++;}
while(a[j]>=mid){j--;}
if(i>=j){
break;
}else{
swap(a[i],a[j]);
i++;
j--;
}
}
return j;
}
// 选取序列a中序号从l到r中第k小的元素
// 输入:序列a,序列起始下标l,序列终止下标r,所求的元素是第k小
// 输出:第k小的元素大小
int select(int a[],int l,int r,int k){
// 处理最小子问题:n<75
if(r-l<75){
sort(a+l, a+r+1);
return a[l+k-1];
}else{
// 选出n/5个组中的中位数,每次将中位数和序列开始的位置交换,
// 将中位数聚集在一起,比额外开辟内存空间然后记录下标和值要好
for(int i = 0;i <= (r-l-4)/5;i++){
sort(a+l+5*i,a+l+5*i+4);
swap(a[l+i],a[l+5*i+2]);
}
// 获取中位数的中位数
int mid = select(a, l, l+(r-l-4)/5, ((r-l-4)/5+1)/2);
// 以mid为基准进行子序列的划分
int mid_id = part(a, l, r, mid);
int mid_rank = mid_id - l + 1;
// 判断应该取哪个子序列进行递归
if(k == mid_rank)
return a[mid_id];
else if(k > mid_rank)
return select(a, mid_id+1, r, k-mid_rank);
else
return select(a, l, mid_id-1, k);
}
}