超简单去理解系列:中位数与第K小元素

2,100 阅读7分钟

简介

  • 今天的算法,可以教大家如果不经过排序,就能找出第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)计算时间。例如在找最小元素时,总是在最大元素处划分。尽管如此,该算法的平均性能很好。

由于随机划分函数randompartition使用了一个随机数产生器randomi,它能随机地产生l~r 之间的一个随机整数,因此,randompartition产生的划分基准是随机的。在这个条件下,可以证 明,算法randomselect可以在O(n)平均时间内找出n个输入元素中的第K小元素。

最坏情况下的线性时间选择算法

  • 下面来讨论一个类似于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