[编程之美] PSet2.5 寻找最大的K个数



参考:http://blog.csdn.net/xiaoding133/article/details/8037086  

问题:查找大量无序元素中最大的K个数,姑且假定它们各不相等。

解法一:该解法是大部分能想到的,也是第一想到的方法。假设数据量不大,可以先用快速排序或堆排序,他们的平均时间复杂度为O(N*logN),然后取出前K个,时间复杂度为O(K),总的时间复杂度为O(N*logN)+O(K).
        当K=1时,上面的算法的时间复杂度也是O(N*logN),上面的算法是把整个数组都进行了排序,而原题目只要求最大的K个数,并
不需要前K个数有限,也不需要后N-K个数有序。可以通过部分排序算法如选择排序和交换排序,把N个数中的前K个数排序出来,复杂度为O(N*K),选择哪一个,取决于K的大小,在K(K<logN)较小的情况下,选择部分排序。

        事实上我们可以避免对前K个数排序来获得更好的性能。

解法二避免对前K个数进行排序来获取更好的性能(利用快速排序的原理)。
        假设N个数存储在数组S中,从数组中随机找一个元素X,将数组分成两部分Sa和Sb.Sa中的元素大于等于X,Sb中的元素小于X。出现如下两种情况:
        (1)若Sa组的个数大于或等于K,则继续在sa分组中找取最大的K个数字 。
        (2)若Sa组中的数字小于K ,其个数为T,则继续在sb中找取 K-T个数字 。
        一直这样递归下去,不断把问题分解成小问题,平均时间复杂度为O(N*logK)。

//下面的伪代码是解决寻找最大的K个数
/*
思路:将原始Array一分为二,左边为Sa,右边为Sb,显然Sa中每个元素都要大于Sb中每个元素
      if(Sa.length < K)  返回Sa,并到Sb中寻找K-Sa.length个最大的
      else 继续在Sa中寻找K个最大的
*/
Array findBigK(Array , int K)
{
    if(Array.length<=0)//边界条件
        return [];
    if(Array.length <= K)
        return Array;
    [Sa , Sb] = partition(Array);//得到Array的一个随机划分
    if(Sa.length < K)
        return Sa.append(findBigK(Sb,K-Sa.length));
    else 
        return findBigK(Sa,K);
}

[Sa , Sb] partition(Array)
{
    Sa=[];
    Sb=[];
    swap(Array[0] , Array[Random()%Array.length]);//随机主元
    int pivot = Array[0];
    for(int index=1 ; index<Array.length ; index++){
        if(Array[index] >= pivot)
            Sa.append(Array[index]);
        else 
            Sb.append(Array[index]);
    }
    //将主元加入长度较小的组,让两数组分布更均匀,提高效率。还可避免分组失败
    Sa.length>Sb.length?Sb.append(pivot):Sa.append(pivot);
    return[Sa , Sb];
}

实现代码如下:

void swap2Num(int *a , int *b)
{
	int temp = *a;
	*a = *b;
	*b=temp;
}
int Partition(int Array[] , int low , int high)
{
	srand(time(0));int pivot = Array[low + rand()%(high-low+1)];
	while(low < high){
		while(low<high && Array[high]<pivot)
			high--;
		if(low<high)
			swap2Num(&Array[high],&Array[low]);
		while (low<high && Array[low]>pivot)
			low++;
		if(low<high)
			swap2Num(&Array[high],&Array[low]);
	}
	return high;
}

int findBigK(int Array[] ,int low , int high , int K)
{
	int kIndex = -1;
	int pivot_index = Partition(Array , low , high);
	int SaLen = pivot_index-low+1;//本次迭代Sa长度

	if(low < high){
		if(SaLen == K)
			return pivot_index;
		else if(SaLen > K)//Sa.length > K
			kIndex = findBigK(Array , low , pivot_index-1 , K);
		else //Sa.length < K
			kIndex = findBigK(Array , pivot_index+1 , high , K-SaLen);
	}
	return kIndex;
}

int main()
{
	int Array[7]={1,6,5,8,4,2,9};
	int index = findBigK(Array , 0 , 6 , 4);
	cout<<"划分后数组为:"<<endl;
	for(int i=0 ; i<6 ; i++)
		cout<<Array[i]<<" ";
	cout<<endl;
	cout<<"下标为:"<<findBigK(Array , 0 , 6 , 4)<<endl;
	for(int i=0 ; i<=index ; i++)
		cout<<Array[i]<<" ";
	cout<<endl;
	return 0;
}

 解法三:这也是寻找N个数中的第K大的数算法。利用二分的方法求取TOP k问题。 首先扫描一次查找max 和 min,然后计算出mid = (max + min) / 2,然后扫描一次查看是大于mid数的个数,不断的迭代缩小min与max,最终min与max区间内只有一个数,就是所求的第K大的数。该算法的实质是寻找最大的K个数中最小的一个。算法代码如下:

/*
算法分析:
    对于这个未排序数组遍历一遍整个集合,统计在该集合中大于等于某个数的整数有多少个
,不需要作随机访问,而只需要经过统计确定新的min和max后更新数组存放文件,扫描一次
把在新区间[min,max]内的数存放在原文件中,这样下一次操作时候,不再须遍历全部元素。
    这样做的好处在于每次更新解区间后,元素数目会减少。
    由求解过程可以看出扫描太多次了,因此对于O(N*logN)的算法常数项很大,实际应用中
效果有时并不好
*/
int findOverNum(int Array[] , int length , int value)
{
    int count = 0;
    for(int i=0 ; i<length ; i++){
        if(Array[i] > value)
            count++;
    }
    return count;
}
int findBigK(int Array[] , int length , int K)//返回第K大的数
{
    const double deleta = 0.5;//deleta比数组内最小的元素差值还要小
	//扫描一遍得到最大值和最小值
	int vMin = Array[0];
	int vMax = Array[0];
	for(int i=1 ; i<length ; i++){
		if(Array[i] >= vMax)
			vMax = Array[i];
		if(Array[i] <= vMin)
			vMin = Array[i];
	}
    
    int vMid = 0;

	/*
	下解是书上的解法,当数组b作为输入的时候,vMid将会重复计算为3,
	此时vMin=3,vMax=4,而大于3的个数为5所以会重复置vMin=3,陷入死循环
	症结在于此时其实区间内只有一个数了,但是deleta设定为0.5,而vMax与vMin之间的差值恒为1
	*/
//     while(vMax-vMin > deleta){//解区间内大于一个数
//         vMid = vMin + (vMax-vMin)/2;//防止上溢
//         int overNum = findOverNum(Array,length,vMid);//大于vMid的数的个数
//         if(overNum >= K)
//             vMin = vMid;
//         else
//             vMax = vMid;
//     }
	//-----------------------------------------------------------------
	/*
	下解是本人的解法,可以加入判断条件避免这样的情况发生
	
	*/
	while(vMax-vMin > deleta && vMin<vMax-1){//解区间内大于一个数,且vMin与vMax不相邻
		vMid = vMin + (vMax-vMin)/2;//防止上溢
		int overNum = findOverNum(Array,length,vMid);//大于vMid的数的个数
		if(overNum >= K)
			vMin = vMid;
		else
			vMax = vMid;
	}
	//-----------------------------------------------------------------
	//解区间只有一个数
	int result = 0;
	for(int i=0 ; i<length ; i++){
		if(Array[i]>=vMin && Array[i]<=vMax){
			result=Array[i];
			break;
		}
	}
	return result;
}
 
 int main()
 {
   int a[8] = {54, 2 ,5 ,11 ,554 ,65 ,33 ,56};  
   int b[7] = {1,2,4,6,7,6,8};
   //int x = findBigK(a,8,2) ; 
   int y = findBigK(b,7,5);
   cout<<" "<<y<<endl ; 
   return 0 ;    
 }

可以看到deleta的取值关系到算法时间复杂度,每次算法迭代都与deleta进行比较,该算法常数项较大,实际应用中效果并不好。

解法四:我们已经得到了三种解法,这三种解法通常都需要对数据访问多次。如果N非常大呢?无法装入内存,这种数据访问非常耗时,能否考虑尽量减少遍历次数呢?

可以使用容量为K的最小堆来存储最大的K个数,最小堆的堆顶元素就是最大K个数中最小的一个,当考虑一个新数X,如果X比对顶元素大,则用X替代堆顶元素并更新堆(维持堆的性质),更新过程花费O(logK),算法只需要扫描所有数据一次,时间复杂度O(N*logK),空间上只需要维护容量为K的堆。

下面是更新堆的伪代码:

//更新堆的伪代码
int updateHeap(int h[] , int x)
{
    if(x > h[0]){//扫描元素大于堆顶元素,将x插入堆顶并维护
        h[0] = x;
        p=0;//堆顶下标
        while(p < K){//当下标不超出堆容量时遍历左子树和右子树寻找插入点
            q = 2*p+1;//左孩子
            if(q > K)
                break;
            if(q+1<K && h[q+1]<h[q])//右孩子小于左孩子
                q=q+1;//令最小的是右孩子
            if(h[q] < h[p]){
                temp = h[p];
                h[p] = h[q];
                h[q] = temp;
                p = q;//向下遍历最小堆
            }
            else 
                break;
        }
    }
}

下面是该算法的实现代码:

//递归的维护这个最小堆,i是维护的顶点,由于用数组作为数据结构,下标从0开始,hsize为堆元素个数
void minHeapify(int H[] , int hsize , int i)
{
	int leftChild = 2*i+1; 
	int rightChild = 2*i+2;
	int minIndex = i;//设定起始最小值下标
	if(i < hsize){
		if(leftChild < hsize){
			if(H[leftChild] < H[minIndex])
				minIndex = leftChild;
		}
		if(rightChild < hsize){
			if(H[rightChild] < H[minIndex])
				minIndex = rightChild;
		}
		if(i != minIndex){
			int temp = H[i];
			H[i] = H[minIndex];
			H[minIndex] = temp;
			minHeapify(H , hsize , minIndex);
		}
	}
}
//自底向上建立最小堆
void createHeap(int H[] , int hsize)
{
	for(int i=hsize-1 ; i>=0 ; i--){
		minHeapify(H , hsize , i);
	}
}
//Array为原数组,找到最大的K个元素存放在最大堆KMax中
void findBigK(int Array[] , int ArrayLen , int K , int KMax[])
{
	if(ArrayLen > K){
		for(int i=0 ; i<K ; i++)
			KMax[i] = Array[i];//初始化数组KMax
		createHeap(KMax , K);//以原始数组建立大小为K的最小堆
		for(int j=K ; j<ArrayLen ; j++){
			if(Array[j] > KMax[0]){
				KMax[0] = Array[j];
				minHeapify(KMax , K , 0);
			}
		}
	}
	else{
		printf("K < ArrayLen !\n");
		return;
	}
}
int main()
{
	int a[] = {10,23,8,2,52,35,7,1,12};
	int aLen = sizeof(a)/sizeof(int);
	const int K=4;
	int *KMax = new int[K];
	findBigK(a , sizeof(a)/sizeof(int) , K , KMax);
	for(int i=0 ; i<K ; i++)
		printf("%3d" , KMax[i]);
	printf("\n");
	
	delete KMax;
	return 0;
}

解法五:通过改变计数排序算法可以得到下面的算法:

如果N个数都是正数,取值范围不太大,可以考虑用空间换时间。申请一个包括N中最大值的MAXN大小的数组count[MAXN],count[i]表示整数i在所有整数中的个数。这样只要扫描一遍数组,就可以得到第K大的元素。

/*
使用一个数组count[MaxN]记录每个数出现的次数,
N个数的最大数为MaxN。最好被查找数据尽量紧凑且全为正整数才有效。
返回第K大的值,只要再通过一次扫描就能找到所有K个数
*/
int findBigK(int Array[] , int ArrayLen ,int K)
{
    //扫描一次找最大MaxN
    int MaxN = -1;
    for(int i=0 ; i<ArrayLen; i++){
        if(Array[i] > MaxN)
            MaxN = Array[i];
    }
    /*
    利用计数数组,count[i]下标[0,MaxN-1],每个下标i
    记录数值i+1出现的次数。
    */
    int *count = new int[MaxN];
	memset(count,0,sizeof(int)*MaxN);
    for(int i=0 ; i<ArrayLen ; i++){
        count[Array[i]-1] ++;
    }

    //一次扫描自底向上找K个数,找到的值放在kValue中,注意到count数组下标i与数组数字i+1的关系
    int sum = 0;
    int kValue;
    for(kValue=MaxN-1 ; kValue>=0 ; kValue--){
        sum += count[kValue];
        if(sum >= K)
            break;
    }
	delete count;
    return kValue+1;
}

int main()
{
	int a[] = {10,23,8,2,52,35,7,1,12};
	int aLen = sizeof(a)/sizeof(int);
	const int K=3;
	cout<<findBigK(a , sizeof(a)/sizeof(int) , K)<<endl;

	return 0;
}

扩展问题:

1.如果需要找出N个数中最大的K个不同的浮点数呢?比如,含有10个浮点数的数组(1.5,1.5,2.5,3.5,3.5,5,0,- 1.5,3.5)中最大的3个不同的浮点数是(5,3.5,2.5)。

      解答:由前面的解法可以知道只有解法五要求是整数,因为计数数组count[]下标和这N个数数值有直接的一一对应关系,对其他解法改改都能用。


2. 如果是找第k到第m(0<k<=m<=n)大的数呢?

      解答:一个很自然的方法是求解两个界k与m对应的值,然后扫描一次数组得到其内的所有值,一个更好的方法可以使用最小堆存储m个最大值,然后输出其内的m-k+1个,就得到结果了。考虑一个数组1 12 15 23 50 69 78 如果输出第2大到第4大的数23 50 69先建立容量为4的最小堆存放23 50 69 78,然后取出其内的较小的4-2+1=3个数。


3. 在搜索引擎中,网络上的每个网页都有“权威性”权重,如page rank。如果我们需要寻找权重最大的K个网页,而网页的权重会不断地更新,那么算法要如何变动以达到快速更新(incremental update)并及时返回权重最大的K个网页?

      解答:用堆排序当每一个网页权重更新的时候,更新堆。更新堆是比较费时的,一个比较好的方法是采用映射二分堆,所谓映射二分堆是节点中不保存数据本身,而是保存一个映射,即指向数据单元的指针。并且可以根据索引删除一个元素。更新操作只需要O(logN)。


4. 在实际应用中,还有一个“精确度”的问题。我们可能并不需要返回严格意义上的最大的K个元素,在边界位置允许出现一些误差。当用户输入一个query的时候,对于每一个文档d来说,它跟这个query之间都有一个相关性衡量权重f (query, d)。搜索引擎需要返回给用户的就是相关性权重最大的K个网页。如果每页10个网页,用户不会关心第1000页开外搜索结果的“精确度”,稍有误差是可以接受的。比如我们可以返回相关性第10 001大的网页,而不是第9999大的。在这种情况下,算法该如何改进才能更快更有效率呢?网页的数目可能大到一台机器无法容纳得下,这时怎么办呢?
        解答:由于网页数目大到一台机器无法容纳得下,所以可以让每台机器返回在这台机器中最相关的K’个文档,那么所有机器上各自最相关K’个文档的并集肯定包含全集中最相关的K个文档,依据归并排序的思想将之融合为K个文档。最好情况是K个文档在所有机器上均匀分布,K’ = K/n(n为所有机器总数);最坏情况一台机器1个最好文档,K’近似为K。


5. 如第4点所说,对于每个文档d,相对于不同的关键字q1, q2, …, qm,分别有相关性权重f(d, q1),f(d, q2), …, f(d, qm)。如果用户输入关键字qi之后,我们已经获得了最相关的K个文档,而已知关键字qj跟关键字qi相似,文档跟这两个关键字的权重大小比较靠近,那么关键字qi的最相关的K个文档,对寻找qj最相关的K个文档有没有帮助呢?

       解答:肯定是有帮助的。 qi最相关的K个文档可能就是qj的最相关的K个文档,可以先假设这K个就是,然后根据问题四的解法获得K’,分别和这K个比较,可以用堆进行比较,从而获得qj最相关的K个文档。由于最开始的K个文档极有可能是最终的K个文档,所以K’和K比较的次数可能并不多。

参考:http://blog.csdn.net/rein07/article/details/6742933

  

    原文作者:天剑客
    原文地址: https://blog.csdn.net/spaceyqy/article/details/38292419
    本文转自网络文章,转载此文章仅为分享知识,如有侵权,请联系博主进行删除。
点赞