简介
- 今天的算法,可以教大家如果不经过排序,就能找出第k小元素?
- 复习教材里的中位数与第K小元素,发现这题对于优化大有文章。
关于中位数与第K小元素的概述如下:
- 给定线性序集中n个元素和一个整数K,1<=K<=n,要求找出这n个元素中第K小的元素,即如果将这n个元素依其线性序排列时,排在第K位的元素即为要找的元素。当K=1时,就是要找最小元素;当K=n时,就是要找最大元素;当K=(n+1)/2 时,称为找中位数。
- 那么,在一些特殊的情况下,我们很容易设计出解决问题的线性算法。如:找n个元素中最小与最大的可以在O(n)时间完成。当K<=n/logn(小顶堆),通过堆排序可以在O(n+klogn)=O(n)完成。同理,当K>=n-logn同理(大顶堆)。
平均情况下的线性时间选择算法
- 一般的选择问题,特别是中位数的选择问题似乎比找最小元素要难。但事实上,从渐近阶的 意义上看,它们是一样的。一般的选择问题也可以在 O(n)时间内得到解决。下面讨论解一般 的选择问题的一个分治算法randomselect。该算法实际上是模仿快速排序算法设计出来的。其 基本思想也是对输入数组进行递归划分。与快速排序算法不同的是,它只对划分出的子数组之 一进行递归处理。
- 算法randomselect。因 此,划分是随机产生的。由此导致算法randompartition 也 是 一 个 随 机 化 的 算 法。要 找 数 组 a[0:n-1]中第K小元素只要调用randomselect(a,0,n-1,k)即可。
- 图解:
- 具体算法可描述如下:
void swap(int a[],int i,int j){ int tmp=a[j]; a[j]=a[i]; a[i]=tmp; } int partition(int a[],int l,int r){ //一次快速排序 int i=l-1,j=r; int v=a[r]; for(;;){ while(a[++i]<v){} while(a[--j]>v){ if(j==1){ break; } } if(i>=j) break; swap(a,i,j); } swap(a,i,r); return i; } //随机快速排序,将准基交换 int randompartition(int a[],int l,int r){ int i=(rand()%(r-l+1))+l; //随机出一个数 swap(a[i],a[r]); //随机数与某一位置交换 return partition(a,l,r); //一次快排 } int randomselect(int a[],int l,int r,int k){ int i,j; while(r>l){ i=randompartition(a,l,r); j=i-l+1; if(j==k){ //判断i是否符合K return a[i]; } if(i>k){ r=i-1; }else{ l=i+1; k-=j; } } return ((r<i))?(a[i]:a[r]); }- 在算法randomselect中执行randompartition后,数组a[l:r]被划分成2个子数组a[l:i]和a[i+1:r],使得a[l:i] 中每个元素都不大于a[i+1:r]中每个元素。接着算法计算子数组a[l:i]中元素个数j。如果 k<=j,则 a[l:i]中第k小元素落在子数组a[l:i]中。如果k>j,则要找的第k 小元素落在子数组 a[i+1:r]中。
容易看出,在最坏情况下,算randomselect需要O(n2)计算时间。例如在找最小元素时,总是在最大元素处划分。尽管如此,该算法的平均性能很好。
- 图解:
最坏情况下的线性时间选择算法
- 下面来讨论一个类似于randomselect但可以在最坏情况下用O(n)时间可以完成选择任务的算法 select。如果能在线性时间内找到一个划分基准,使得按这个基准所划分出的2个子数组的长度都至少为原数组长度的a倍(0<a<1是某个正常数),那么在最坏情况下用O(n)时间就可以完 成选择任务。例如,若a=9/10,算法递归调用所产生的子数组的长度至少缩短 1/10。所以,在最 坏情况下,算法所需的计算时间T(n)满足递归式 T(n)<=T(9n/10)+O(n)。由此可得T(n)=O(n)。
-
按已下步骤来寻找一个好多基准划分: 1. 将n个输入元素划分成n/5个组,除可能有一个组不是5个元素外,每组5个元素。用任意一种排序算法,将每组中的元素排好序,并取出每组的中位数,共n/5个. 2. 递归调用select来找出这n/5个元素的中位数。如果n/5是偶数,就找它的2个中位数中较大的一个。然后以这个元素作为划分基准。
如图上述划分策略的示意图,其中n个元素用小圆点来表示,空心小圆点为每组元素的中位数。中位数的中位数x在图中标出。图中所画箭头是由较大元素指向较小元素。 只要等于基准的元素不太多,利用这个基准来划分的n/5个子数组的大小就不会相差太远。 为了简化问题,先设所有元素互不相同。在这种情况下,找出的基准x至少比3*(n/5)/10 个 元素大,因为在每一组中有2个元素小于本组的中位数,而n/5个中位数中又有3*(n/5)/10 个小于基准x。同理,基准x也至少比3*(n/5)/10个元素小。而当n>=75时,3*(n/5)/10>=n/4 。所以按此基准划分所得的2个子数组的长度都至少缩短1/4。这一点至关重要。据此,可 以给出算法select如下:int select(int a[],int l.int r,int k){ int i,j,s,t; int x; if(r-l<75){ bubble(a,l,r); //冒泡排序 s=l+k-1; if(s>r){ s=r; } if(s<l){ s=l; } return a[s]; } for(i=0;i<=(r-l-4)/5;i++){ s=l+5*i; t=s+4; bubble(a,s,t); swap(a[l+i],a[s+2]); //将每组中位数放到数组a的头位置 } /** 这一步比较难理解:l+(r-l-4)/5 表示中位数数组大小,在上一步, 将每组的中位数都移到以l(左索引)的数组段上。(r-l+6)/10 表示当前的k在哪一组, +6是因为还最后一组数量可能不为5的一组. 中位数是索引最中间的那个数则原式应该为(r-l+1+5)/5/2。 **/ x=select(a,l,l+(r-l-4)/5,(r-l+6)/10); i=partition(a,l,r,x); j=i-l+1; if(j==k){ return a[i]; } /* 返回中位数数组的中位数的值,在用x作为基准用于partition. 这样左边的都是小于x,右边都是大于x */ if(j>k){ return select(a,l,i-1,k); }else{ return select(a,i+1,r,k-j); } } -
为了分析算法select的计算时间复杂性,设n=r-l+1,即n为输入数组的长度。算法的递归调用只有在 n>=75时才执行。因此,当n<75时算法 select所用的计算时间不超过一个常数C1。找到中位数的中位数 x后,算法 select以x为划分基准调用函数partition对数组a[l,r]进 行划分,这需要 O(n)时间。算法 select的for循环体行共执行n/5次,每一次需要O(1)时间。 因此,执行for循环共需O(n)时间。
-
设对n个元素的数组调用select需要T(n)时间,那么找中位数的中位数x至多用了T(n/5) 的时间。上面已证明按照算法所选的基准 x进行划分所得到的2个子数组分别至多有3n/4 个 元素。所以无论对哪一个子数组调用select都至多用了3n/4的时间。
-
综上所知:T(n)=O(n)。
文献
[1].数据结构 C语言描述 王晓东编著 北京市:电子工业出版社 2011_12862731