寻找最小的K个数

 
 
寻找最小的K个数
给你一堆无序的数,姑且假设都不相等,怎么找出其中最小的K个数呢? 首先想到的估计是从小到大排序,排序完了输出前K个数即可。基于比较的排序最快是O(nlgn),快排较好,输出的代价是O(k),总的时间复杂度就是T(n)=O(nlgn)+O(k)=O(nlgn)。 还有其他的方法吗?换句话说,我们有必要排序吗?我们只需要找到最小的K个数即可,你管他是否有序呢?前K个和后N-K个数都是没必要有序的。 先来看一个部分排序的,也就是前K个有序,后N-K个无序。这个时候选择排序(或者冒泡)就很好了,只需要一个大小为K的数组,经过K次遍历就可以得到最小的K个数了。首先找到k个数中的最大数kmax(kmax为k个元素的数组中最大元素),用时O(k),后再继续遍历后n-k个数,x与kmax比较:如果x<kmax,则x代替kmax,并再次重新找出k个元素的数组中最大元素kmax;如果x>kmax,则不更新数组。这样,每次更新或不更新数组的所用的时间为O(k)或O(0),总的时间复杂度平均下来为:T(n)=n*O(k)=O(n*k)。 至于这两种方法哪个更好一些,就要看lgn与k哪个更加的小了。 还有更好的办法吗?当然。其实刚才没必要用交换排序(选择,冒泡),我们可以用堆排序(要学以致用)。维护一个大小为K的最大堆,原理和交换排序一样。先遍历K得到K个数作为堆的元素,建堆用时O(K)(线性)。然后遍历后N-K个元素,更新堆用时O(lgK)或不更新堆O(0),总的时间复杂度是T(n)=O(K)+O((n-K)lgK)=O(nlgK)。很nice。(实现暂略)

可是,用堆的话我们也可以不这么做。我们可以直接对N个元素建最小堆,用时O(n)。然后维护,每次更新用时O(lgn),更新K次即可得到最小的K个数。总的时间复杂度是T(n)=O(n)+O(klgn)=O(n+klgn)。经证明,当n很大的时候,n+klgn<nlgk。也就是说这个时间复杂度更加的好。其实呢。。我们每次的更新没必要都是lgn,lgn是每次都要更新到堆低,其实完全没必要,我们又不是要排序,我们是要求最小的K个数而已。所以每次的更新只需要下降K次即可,这样0-k层是最小堆,下面的我们不管他。而且第一次下降K次,第二次只需下降K-1次即可,逐次减少,最后更新总的用时是O(k*k),总的时间复杂度是T(n)=O(n)+O(k^2)。比刚才的时间复杂度还好,如果K很小,那就可以达到线性的时间复杂度。


  #include <iostream>     
  using namespace std;    
  const int N=100;
  void Swap(int &a, int &b);   //交换a b
  void AdjustHeap(int array[],int start,int end);   //调整最小堆
  void BuildHeap(int array[],int start,int end);    //建堆
      
  int main()    
  {
      int array[N+1];
  	int i,k;
  	k=10;
  	for(i=N;i>0;i--)
  		array[i]=i;
  	for(i=1;i<=N;i++)
  		cout<<array[i]<<" ";
  	cout<<endl;
  	BuildHeap(array,1,N);   //初始建堆
  	swap(array[1],array[N]);
  	for(i=1;i<k;i++)    //K次调整,每次 lgN
  	{
  		AdjustHeap(array,1,N-i);
  		swap(array[1],array[N-i]);
  	}
  	cout<<"最小的"<<k<<"个数:"<<endl;
  	for(i=N;i>N-k;i--)
  		cout<<array[i]<<" ";
  	cout<<endl;
  
      return 0;    
  } 
  
  void AdjustHeap(int array[],int start,int end) //调整最小堆
  {
  	int top;
  	top=array[start]; //top保存堆顶的值,不仅仅是整体,还有可能是子堆的堆顶
  	for(int j=2*start;j<=end;j=2*j)    //数组下标从1开始
  	{
  		if(j<end&&array[j]>array[j+1])
  		{
  			j++;//右孩子小
  		}
  		if(array[j]<top)
  		{
  			array[start]=array[j]; ///让孩子覆盖父节点。
  			start=j;         //此时让子节点作为新的父节点,即堆顶,继续调整
  		}
  		else
  			break;//不需要调整了,直接跳出
  	}
  	array[start]=top;    //让最初的堆顶值放到合适的位置
  }
  void BuildHeap(int array[],int start,int end)  //建堆
  {
  	for(int i=end/2;i>=start;i--) //从最后一个非叶子节点开始调整
  		AdjustHeap(array,i,end);
  }
  
  
  void Swap(int &a, int &b)  //交换a,b
  {
  	if (a != b)
  	{
  		a ^= b;
  		b ^= a;
  		a ^= b;
  	}
  }
  -----------------------------------------------
  int main()    
  {
      int array[N+1];
  	int i,k;
  	k=10;
  	for(i=N;i>0;i--)
  		array[i]=i;
  	for(i=1;i<=N;i++)
  		cout<<array[i]<<" ";
  	cout<<endl;
  	BuildHeap(array,1,N);  //初始建堆
  	swap(array[1],array[N]);
  	for(i=1;i<k;i++)      //K次调整,保证K<lgN
  	{
  		AdjustHeap(array,1,int(pow(2,k-i+1)-1)); //持续调整,下降K次,每次递减
  		swap(array[1],array[N-i]);
  	}
  	cout<<"最小的"<<k<<"个数:"<<endl;
  	for(i=N;i>N-k;i--)
  		cout<<array[i]<<" ";
  	cout<<endl;
  
      return 0;    
  } 

	可是你忘了最重要的一点了。内存占用太多,空间复杂度是O(n),如果上千亿的数据,你不就很拙计吗?内存放得下吗??还有,你要搞清楚空间复杂度和辅助空间的区别,这是原地排序,不需要辅助空间,可是空间复杂度是O(n)。空间复杂度是指运行完一个程序所需内存的大小,这里包括静态空间和动态空间以及递归栈所需的空间。所以来说,建堆占用内存很是重要,我们不常选择建立n个元素的最小堆取其K个,而是选择建立K个元素的最大堆,虽然时间复杂度高了一点点(O(nlgk)>O(n+k^2))(ps用1000W的数据测试发现差别真的不大),但是空间复杂度要好很多很多(O(k)<<O(n),尤其是N很大的时候优势就更加的明显)。(ps虽然我们经常是用数组来表示N个数据,可是数据大的时候我们就要用文件来处理了)
	还有其他的方法吗?线性时间复杂度的?可以达到吗?计数排序,基数排序,桶排序。不是基于比较的排序,时间复杂度是O(n),输出前K个,时间复杂度就可以达到线性。不过,这个限制条件很多,比如计数排序,要保证所有的数保持在一定范围。如果所有的数都不相等,每一个数只需要一bit就可以表示了。Bloom filter,Bit-map。
	还有没有其他的方法可以达到线性呢?有,有一种算法和快速排序很相像,是快速选择SELECT算法。N个数存储在数组S中,再从数组中选取一个枢纽数X,把数组划分为Sa和Sb两部分,Sa<=X<=Sb,如果要查找的k个元素小于Sa的元素个数,则返回Sa中较小的k个元素,否则返回Sa中k个小的元素+Sb中小的k-|Sa|个元素。这个要利用递归算法。
	看起来时间复杂度好像是O(nlgn)??大错特错,这和快排是不一样的,快排的一次划分之后递归处理两边,而快速选择只处理划分的一边。这个算法的时间复杂度和划分的好坏是有关系的,如果划分是最差划分,那就拙计了,时间复杂度是O(n^2)。T(n)=T(n-1)+O(n)=O(n^2) (O(n)是划分的代价)。如果是最好划分,或者是有常数比例的划分,那就很好办了,T(n)=T(9n/10)+O(n)=O(n) (利用主方法很容易得到)。如果是随机划分,随机选择枢纽元素,则其期望时间为O(n),也就是平均时间复杂度。这个证明暂略。
RANDOMIZED-SELECT(A, p, r, i)      //以线性时间做选择,目的是返回数组A[p..r]中的第i 小的元素
 
	1  if p = r            //p=r,序列中只有一个元素 
	2      then return A[p]
	3  q ← RANDOMIZED-PARTITION(A, p, r)  	 //随机选取的元素q作为主元 
	4  k ← q - p + 1                      
	5  if i == k                      
	6      then return A[q]       	 //则直接返回A[q] 
        7  else if i < k       
        8      then return RANDOMIZED-SELECT(A, p, q - 1, i)   
          		//得到的k 大于要查找的i 的大小,则递归到低区间A[p,q-1]中去查找
        9  else return RANDOMIZED-SELECT(A, q + 1, r, i - k)
         	 //得到的k 小于要查找的i 的大小,则递归到高区间A[q+1,r]中去查找。  
	注意我们要求的是最小的K个数,不是第K小的数,虽然实质上是一样的,但是此时我们编写代码要返回第K小的下标,划分带来的好处是K前面的都是小于K的,顺序输出即可,虽然找到第K小的数,遍历一遍即可,不过还是返回下标比较好啦。
Ok,编码实现一下
#include <iostream>   
#include<math.h> 
#include<time.h> 
using namespace std;    
const int N=10;
void Swap(int &a, int &b);  //交换a b
int RandomPartition(int array[],int low,int high);  //随机选择枢纽 
int MyRandom(int low, int high) ;  //随机函数
int RandomSelect(int array[],int low,int high,int k);
  int main()
  {	int array[N+1];
  	int i,k;
  	k=10;
  	for(i=N;i>0;i--)
  		array[i]=N-i+1;
  	for(i=1;i<=N;i++)
  		cout<<array[i]<<" ";
  	cout<<endl;
  	k=RandomSelect(array,1,N,k);
  	for(i=1;i<=k;i++)
  		cout<<array[i]<<" ";
  	
  	return 0;
  }
  int MyRandom(int low, int high)    
  {    
  	srand(time(0));
      int size = high - low + 1;    
      return  low + rand() % size;     
  }  
  
  int RandomPartition(int array[],int low,int high)  //随机选择枢纽划分
  {
  	int privot;
  	int i=MyRandom(low,high);
  	swap(array[low],array[i]);
  	privot=array[low];
  	while(low<high)
  	{
  		while(low<high&&privot<=array[high])
  			high--;
  		if(low<high)
  		{
  			array[low]=array[high];
  			low++;
  		}
  		while(low<high&&array[low]<=privot)
  			low++;
  		if(low<high)
  		{
  			array[high]=array[low];
  			high--;
  		}
  	}
  	array[low]=privot;
  	return low;
  }
  
  int RandomSelect(int array[],int low,int high,int k) //随机快速选择算法
  {
  	if(k<1||k>high-low+1)
  		return -1;  //错误返回
  	if(low==high)
  		return low;
  	int pivot=RandomPartition(array,low,high);
  	int m=pivot-low+1;  //
  	if(m==k)
  		return pivot;
  	else if(m>k)
  		return RandomSelect(array,low,pivot-1,k);
  	else
  		return RandomSelect(array,pivot+1,high,k-m);
  }

	不用随机划分也可,利用三数取中法也可以达到平均时间复杂度是O(n)的程度。
 	 center = (left + right) / 2; 
	 if( a[left] > a[center] )  
 		 swap( &a[left], &a[center] ); 
	 if( a[left] > a[right] )  
 		 swap( &a[left], &a[right] ); 
 	if( a[center] > a[right] )  
		 swap( &a[center], &a[right] ); 
不过有一种划分方法可以在最坏的情况达到线性时间复杂度。五分化中项的中项”划分法:
	1 将输入数组的N个元素划分为[n/5]组,且至多只有一个组有剩下的n mod5组成。
	2 寻找这个[n/5]组中没一组的中位数:首先对每组的元素进行插入排序,排序后选出中位数。
	3 对第二步找出的[n/5]个中位数,继续递归找到其中位数x。
	4 按中位数的中位数x进行partition划分,然后就是select算法。
	可以证明的是该划分可以在最坏情况下保证O(n)的时间复杂度。

	上图:n个元素由小圆圈来表示,并且每一个组占一纵列。组的中位数用白色表示,而各中位数的中位数x也被标出。(当寻找偶数数目元素的中位数时,使用下中位数)。箭头从比较大的元素指向较小的元素,从中可以看出,在x的右边,每一个包含5个元素的组中都有3个元素大于x,在x的左边,每一个包含5个元素的组中有3个元素小于x。大于x的元素以阴影背景表示。 
	这样在一半的组中除了元素少于5个的那组和包含x的那组,其他的至少有3个元素大于x。也即是说至少有3((1/2)*(n/5)-2))个数大于x,3((1/2)*(n/5)-2))>=3n/10-6,同理小于x的数至少也有3n/10-6.那么大于x或者小于x的数至多有7n/10+6,也即是说递归select最多有7n/10+6个元素进行递归调用。求中位数的中位数用时O([n/5]),划分用时O(n) ,则时间复杂度
	T(n)=O([n/5])+O(7n/10+6)+O(n)<=cn/5+c+7cn/10+6c+an=9cn/10+7cn+an=cn+(-cn/10+7c+an)
	如果-cn/10+7c+an<=0,也就是n>70,则T(n)=O(n).

编码实现:

  #include <iostream>   
  #include<math.h> 
  #include<time.h> 
  using namespace std;    
  const int N=10;
  int median[N/5+1];
  void Swap(int &a, int &b);  //交换a b
  void InsertionSort(int array[],int low,int high);//插入排序
  int FindMedian(int array[],int low,int high);  //找到中位数的中位数
  int FindIndex(int array[], int low, int high, int median);  //找到中位数的中位数所在下标
  int MedianPartition(int array[],int low,int high);
  int MedianSelect(int array[],int low,int high,int k);
  int main()
  {	
  	int array[N+1];
  	int i,k;
  	k=4;
  	for(i=N;i>=0;i--)
  		array[i]=N-i+1;
  	for(i=0;i<=N;i++)
  		cout<<array[i]<<" ";
  	cout<<endl;
  	k=MedianSelect(array,0,N-1,k);
  	for(i=0;i<=k;i++)
  		cout<<array[i]<<" ";
  	
  	return 0;
  }
  
  
  void InsertionSort(int array[],int low,int high)//插入排序
  {
  	int key;   
  	for (int i=low+1;i<=high;i++)
  	{
  		key=array[i];
  		for(int j=i-1;j>=low&&array[j]>key;j--) //注意细节是>=low和>key
  		{
  			array[j+1]=array[j];
  		}
  		array[j+1]=key;
  	}
  }
  
  int FindMedian(int array[],int low,int high)  //找到中位数的中位数
  {
  	if(low==high)
  		return array[low];
  	int num=0;
  	for(int i=low;i<=high-4;i+=5) //
  	{
  		InsertionSort(array,i,i+4);
  		num=i-low;
  		median[num/5]=array[i+2];
  	} 
  
  	int LeftNum=high-i+1;  //可能遗留的数,不足5个/
  	if( LeftNum>0)
  	{
  		InsertionSort(array,i-5,high);
  		num=i-5-low;
  		median[num/5]=array[(i-5)+ LeftNum/2];
  	}
  
  	int MedianNum=(high-low+1)/5;
  	if((high-low)%5!=0)
  		MedianNum++;
  	if(1==MedianNum)
  		return median[0];  //返回中位数的中位数
  	else
  		return FindMedian(median,0,MedianNum-1); //下标从0开始
  }
  
  int FindIndex(int array[], int low, int high, int median)  //找到中位数的中位数所在下标
  {  
      for (int i=low; i<=high; i++)  
      {  
          if (array[i]==median)  
              return i;  
      }  
      return -1;  
  }
  
  int MedianPartition(int array[],int low,int high)  //五分化中项的中项的划分
  {
  	int privot;
  	int i=FindIndex(array,low,high,FindMedian(array,low,high));
  	swap(array[low],array[i]);
  	privot=array[low];
  	while(low<high)
  	{
  		while(low<high&&privot<=array[high])
  			high--;
  		if(low<high)
  		{
  			array[low]=array[high];
  			low++;
  		}
  		while(low<high&&array[low]<=privot)
  			low++;
  		if(low<high)
  		{
  			array[high]=array[low];
  			high--;
  		}
  	}
  	array[low]=privot;
  	return low;
  }
  int MedianSelect(int array[],int low,int high,int k) //五分化中项的中项快速选择算法
  {
  	if(k<1||k>high-low+1)
  		return -1;  //错误返回
  	if(low==high)
  		return low;
  	int pivot=MedianPartition(array,low,high);
  	int m=pivot-low+1;  //
  	if(m==k)
  		return pivot;
  	else if(m>k)
  		return MedianSelect(array,low,pivot-1,k);
  	else
  		return MedianSelect(array,pivot+1,high,k-m);
  }

Ok,这个问题暂时告一段落,后续还会有整理。
	转载请注明出处http://blog.csdn.net/sustliangbo/article/details/9377105



  • 3
    点赞
  • 2
    收藏
    觉得还不错? 一键收藏
  • 1
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值