废话写在前
在计算机数据处理中,我们常常需要输出一个数组的最大值和最小值,乃至第k 小值。
用数学语言来描述就是:
给定一个序列L=(a1,a2,...,an) ,输出其第k 大的元素a^ ,它满足:
a^∈L,i=1∑nh(ai>a^)=k−1
其中,h(X) 为指示函数,有h(X)={1,0,X 为真,X 为假.
对于最值问题,一个最简单的选择算法就是顺序比较. 最坏情况下,时间W(n)=n−1.
如果需要同时找到最大和最小,最容易想到的就是两次顺序比较. 最坏情况下,时间W(n)=(n−1)+(n−2)=2n−3.
以上的问题至少在本文章里就姑且称之为【最值选择问题】吧。
而同时选择出最大值和最小值就叫它【同时选择问题】。
此外,推广到更加一般的综合型的问题,即找第k 大的问题我们称【一般选择问题】。
当然,最容易想到的方法是先对序列进行 排序 ,然后再取处对应下标k 的元素即可。
本文将针对上述层层递进的三种问题的解决算法进行概述与分析。
其中【最佳选择问题】则是每一个程序员都会的顺序比较法,这里不再给出。这个方法的复杂度为Θ(n)。
对于【同时选择问题】就更加巧妙一点,不再暴力查找,而是给出一个效率同比更高的分治策略方法。
对于【一般选择问题】我们先考虑一个【第二大选择问题】,因为它稍微特殊一点,可以利用锦标赛算法解决。然后的一般问题我们则会根据 快速排序 的思想提出 快速查找算法,以及其随机优化。另外再拓展给出一个对快排的改进版本,被称之为 BFPRT算法。其核心思想可以概括为“中位数的中位数”。
同时选择问题
二分裁决找最值
为了克服顺序比较这种暴力算法的时间性能较差的缺点,提出采用分治法进行优化。
其具体思想是:
- 将给定数组A[1..n] 每两个元素作为一组,共拆分为⌊n/2⌋ 个组(n 为奇数时多出一个元素,将其称为 轮空元素);
- 每组进行比较,可得到⌊n/2⌋ 个较大元素,⌊n/2⌋ 个较小元素;
- 把轮空元素包含进⌊n/2⌋ 个较大元素中,得到⌈n/2⌉ 个元素组成的新数组,对其用同样的方法求分组最大,最终即可得到最大值;最小值同理。
利用递归的写法可以更好的体现上述步骤,我们把数组A 拆为A[1..n/2],A[n/2+1..n],即得到规模为n/2 的两个子问题,求解得到两边的最值后,再进行比较就能得到A 的最值。
算法的伪代码如下:
- 输入:给定数组A,左右边界l,r (left, right)
- 输出:数组A 的最大值和最小值max,min
1.2.3.4.5.6.7.8.9.Algorithm: Find-MaxMin(A,l,r)ifl<rthenmid←⌊(l+r)/2⌋left-max,left-min←Find-MaxMin(A,l,mid)right-max,right-min←Find-MaxMin(A,mid+1,r)max←max(left-max,right-max)min←min(left-min,right-min)elsemax←min←A[l]returnmax,min
假设n=2k ,则有W(n)=2W(n/2)+2,W(2)=1,故:
W(2k)=2W(2k−1)+2=2[2W(2k−2)+2]+2=⋯=2k−1W(2)+(2+22+23+⋯+2k−1)=23n−2
虽然时间复杂度仍可以记为O(n),但比起暴力法系数变小了!当然其空间复杂度为O(1).
编程实现
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16
| vector<int> FindMaxMin(vector<int> &A, int l, int r){ vector<int> ans = {0,0};
if(l < r){ int mid = (l+r)>>1; vector<int> left = FindMaxMin(A, l, mid); vector<int> right = FindMaxMin(A, mid+1, r);
ans[0] = max(left[0],right[0]); ans[1] = min(left[1],right[1]); }else{ ans[0] = A[l]; ans[1] = A[l]; } return ans; }
|
一般选择问题
快速选择算法
对于一般性选择问题,很容易想到先对整个数组进行 排序 ,然后再取下标k 的元素。
而总览各种排序算法,首选的当属 快排。快排平均时间复杂度在O(nlogn),有较好的快速性。然而再稍微分析其算法思路就知道,快排每一趟排序都使得所选的 枢轴位置 将小于它的元素和大于它的元素 划分在左右。然后再分别对左右两个子问题继续排序。
对于我们选择问题来说,我们并不需要得到排序结果,而只需要第k 小的元素。因此可以直接在 快排 的源码上加上这样的判断策略:
【注】下面的算法都是根据需要找 第k 小 而给出,当然这对 Top-K 问题同样适用,只需令k=n−k+1 即可.
- 选出枢轴x ,经一趟排序后确定其位置
并划分出Al=A[p..q−1],A[q]=x,Ar=A[q+1..r] - 如果k≤∣Al∣ 即小于x 的元素个数比k 大,则继续在 子数组A[p..q−1] 中找第k 小
- 如果k=∣Al∣+1 说明所选的枢轴x 即为第k 小的元素,直接返回
- 否则,在 子数组A[q+1..r] 中找 第k−∣Al∣−1 小
其中,∣S∣ 表示数组S 的元素个数
经过上述分析,我们就可以得出快速选择算法的内容。
但是快排的缺点也同样被我们引入了进来,即当选取的枢轴使得数组划分不平衡时,会极大地影响算法效率。因此,参考快排的解决策略,这里我们也引入随机性,区别于直接将首元作为枢轴,我们对枢轴进行随机选取。
最终得到伪码如下:
1.2.3.4.5.6.7.8.9.10.11.12.Algorithm: Randomized-Partition(A,p,r)i←Random(p,r)A[p]↔A[i]x←A[p]i←p;j←rwhilei<jdowhilei<jandA[j]>xdoj←j−1whilei<jandA[i]≤xdoi←i+1A[i]↔A[j]A[i]↔A[p]returni
1.2.3.4.4.Algorithm: Quick-Select(A,p,r,k)ifp<rthenq←Randomized-Partition(A,p,r)ifk<q−pthenreturnQuick-Select(A,p,q−1,k)elseifk=q−p+1thenreturnA[q]elsereturnQuick-Select(A,q+1,r,k−q+p−1)
BFPRT算法
我们知道,有序数组对于快排是“致命”的。如果不对快排做任何优化,那么在有序时将会达到O(n2). 同理,如果利用快速选择算法,在一遍遍迭代时,数组会变得有序,这极大降低了快速选择的执行效率。
为避免有序数组导致分组极度不平衡,除了引入随机之外,还有一种更好的策略。该策略与快排的思想整合,被称为 BFPRT选择算法。(俗称:中位数之中位数算法)
如下图所示,BFPRT算法先是将数组按每5个元素为一组进行分组,得到共⌊n/5⌋ 组。
然后分别对每一组进行排序(因为元素个数很少,可以利用快排处理)。
分别取出每一组的中位数共⌊n/5⌋ 个。由这些中位数构成的数组记为M.

递归调用 BFPRT算法 ,对M 求其中位数m∗。此时递归所需的参数k=⌊n/5⌋/2,表示找的是最中间的数,也就是中位数。
于是,m∗ 就是快速算法所需要的主元,即枢轴了。不仅如此,我们还可以快速地划分子数组。
如图所示,如果分组之后根据M 进行的话(使得m∗所在的组处于中心),那么图中左下角区域的元素均小于m∗,右上角区域的元素均大于m∗. 剩下的只需比较左上角和右下角即可。
对于元素个数小于 5 的子问题,直接利用快排求解;若不能整除,余下的小于 5 的元素自成一组,参与计算。
最终得到伪码:
- 输入:数组A,左右边界p,r 以及k
- 输出:第k 小的元素
- 备注:QuickSort(),Partition() 的具体实现略过
1.2.3.4.5.6.7.8.9.9.10.11.12.13.14.15.16.Algorithm: BFPRT(A,p,r,k)n←r−p+1ifn<5thenQuickSort(A,p,r)returnA[p+k−1]elseM←∅G←⌊n/5⌋repeat对A[p..r]分组为A1=A[p..p+4],A2=A[p+5..p+9],...QuickSort(Ai)M←M∪{Ai[mid−index]}until分组完毕m∗←BFPRT(M,1,G,G/2)根据 m∗划分子数组 Al,Ar使得 A[q]=m∗ifk<q−pthenreturnBFPRT(A,p,q−1,k)elseifk=q−p+1thenreturnm∗elsereturnBFPRT(A,q+1,r,k−q+p−1)
性能分析
当存在不够整除时,有不大于 5 的元素个数自成一组参与计算,此时数组A 被我们分成了⌈n/5⌉ 个组。出来自成的组和m∗ 所在的组外,至少有一半的组有 3 个元素是大于m∗ 的,有:
3(21⌈5n⌉−2)≥103n−6
因此,在伪代码第 14 ~ 15 行 的递归调用,至多作用于n−103n+6 个元素。
假设任何少于 140 个元素的输入需要O(1) 的时间,就可以得到递归式:
T(n)≤{O(1),T(⌈n/5⌉)+T(7n/10+6)+O(n),n<140n≥140
那么,当n<140 时,显然存在一个正常数c 使得T(n)≤cn ;
当n>140 时,采用替换法,有:
T(n)≤c⌈n/5⌉+c(7n/10+6)+an≤cn/5+c+7cn/10+6c+an=9cn/10+7c+an=cn+(−cn/10+7c+an)
此时,T(n)≤cn 当且仅当−cn/10+7c+an≤0 当且仅当c≥10a[n/(n−70)]
由于假设有n≥140 ,因此取c≥20a 就能够使得上述不等式成立。
综上所述,BFPRT算法 实现了最坏情况下时间复杂度为线性的选择,时间复杂度为O(n).
由于单开一个最长为n/5 的数组,因此空间复杂度为O(n).
编程实现
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76
| #include <iostream> #include <algorithm> #include <vector> #include <queue> #include <cstdlib>
using namespace std;
int Partition(vector<int> &A, int p, int r){ int i, j; i = p; j = r; int x = A[p]; while(i < j){ while(i < j && A[j] > x) j--; while(i < j && A[i] <= x) i++; swap(A[i],A[j]); } swap(A[p],A[i]); return i; }
void QuickSort(vector<int> &A, int p, int r){ if(p < r){ int q = Partition(A, p, r); QuickSort(A, p, q-1); QuickSort(A, q+1, r); } }
int BFPRT(vector<int> &nums, int l, int r, int k){ int len = r-l+1; if(len <= 5){ QuickSort(nums, l, r); return nums[l+k-1]; } int group = len/5; vector<int> mids; for(int i = 0; i < group; i++){ QuickSort(nums, l+i*5, l+i*5+4); mids.push_back(nums[l+i*5+2]); } if(len%5 != 0){ QuickSort(nums, l+group*5, r); mids.push_back(nums[(l+group*5+r)/2]); group++; } int mid_key = BFPRT(mids, 0, group-1, (group+1)/2);
for(int i = l; i <= r; i++){ if(nums[i] == mid_key){ swap(nums[l],nums[i]); break; } } int mid_index = Partition(nums, l, r);
if(l+k-1 == mid_index) return nums[mid_index]; if(l+k-1 < mid_index) return BFPRT(nums, l, mid_index-1, k); else return BFPRT(nums, mid_index+1, r, l+k-mid_index-1); }
int BFRPT_Select(vector<int> nums, int k){ return BFPRT(nums, 0, nums.size()-1, k); }
int main(void){ vector<int> a = {8, 2, 3, 5, 7, 6, 11, 14, 1, 9, 13, 10, 4, 12, 15, 16}; for(int i = 1; i <= a.size(); i++) cout << BFRPT_Select(a, i) << endl; return 0; }
|
参考
- 《算法导论(原书第三版)》
- 算法设计与分析|中国大学MOOC
- 选择算法总结|CSDN