Top K是很常见的一种问题,是指在N个数的无序序列中找出最大的K个数,而其中的N往往都特别大,对于这种问题,最容易想到的办法当然就是先对其进行排序,然后直接取出最大的K的元素就行了,但是这种方法往往是不可靠的,不仅时间效率低而且空间开销大,排序是对所有数都要进行排序,而实际上,这类问题只关心最大的K个数,并不关心序列是否有序,因此,排序实际上是浪费了的很多资源都是没必要的。本文主要介绍三种TopK算法:
1.类选择排序法
为什么叫类选择排序法呢?因为这种方法很像选择排序,选择排序是抽出序列中的最大或最小值放在一端,这里也类似。算法思路:对目标序列N个数遍历,取出其中最大的数最为Top1;再次遍历剩下的N-1个数,取出其中最大的数为Top2;....再对剩下的N-K+1个数遍历,取出其中最大的数为TopK,这样就可以找到最大的K个数了。
1.1 代码实现
vector<int> TopKBySelect(vector<int>& nums,int k,int len)
{
vector<int>res;
vector<int>flag(len);
for(int i=0;i<k;i++)
{
int maxIndex=0; //保存最大数的索引
int maxNum=nums[0]; //保存最大数
for(int j=0;j<len;j++)
{
if(nums[j]>maxNum&&!flag[j]) //如果大于最大数并且没有被取出来过
{
maxNum=nums[j];
maxIndex=j;
}
}
flag[maxIndex]=-1; //将此次遍历的最大数索引标记为-1,放置再次被取出
res.push_back(maxNum); //存入该最大数
}
return res;
}
1.2 复杂度分析
时间复杂度方面,要求TopK就需要进行K次遍历,然后取出其中最大的数,因此算法平均时间复杂度为O(N*K);
空间复杂度方面,可以看到这种方法需要开辟一个辅助空间来对取出过的元素进行标记,因此空间复杂度为O(N),除此之外,还需注意到的是,这种方法有效的前提是提前将所有数读入,这样如果一开始的N较大,那么空间开销是不可忽视的,而且,如果数据是动态的,即是可能会不停的增加新数据,那么就还需要每插入一个新数据就将其与前面取出的TopK做比较,排除K+1个数中最小的,最后剩下的才是TopK。
2.快速排序法
快速排序法的原理这就不多说了,可见https://blog.csdn.net/qq_28114615/article/details/86064412
在快速排序中,每一轮排序都会将序列一分为二,左子区间的数都小于基准数,右子区间的数都大于基准数,而快速排序用来解决TopK问题,也是基于此的。N个数经过一轮快速排序后,如果基准数的位置被换到了i,那么区间[0,N-1]就被分为了[0,i-1]和[i+1,N-1],这也就是说,此时有N-1-i个数比基准数大,i个数比基准数小,假设N-1-i=X那么就会有以下几种情况:
①X=K。这种情况说明比基准数大的有K个,其他的都比基准数小,那么就说明这K个比基准数大的数就是TopK了;
②X<K。这种情况说明比基准数大的数不到K个,但是这X肯定是属于TopK中的TopX,而剩下的K-X就在[0,i]之间,此时就应当在[0,i]中找到Top(K-X),这就转换为了TopK的子问题,可以选择用递归解决;
③X>K。这种情况说明比基准数大的数超过了K个,那么就说明TopK必定位于[i+1,N-1]中,此时就应当继续在[i+1,N-1]找TopK,这样又成了TopK的一个子问题,也可以选择用递归解决。
2.1 代码实现
int getIndex(vector<int>& nums,int left,int right) //快排获取相遇点(基准数被交换后的位置)
{
int base=nums[left];
int start=left;
while(left<right)
{
while(left<right&&nums[right]>=base)right--;
while(left<right&&nums[left]<=base)left++;
int temp=nums[right];
nums[right]=nums[left];
nums[left]=temp;
}
nums[start]=nums[left];
nums[left]=base;
return left;
}
int findTopKthIndex(vector<int>&nums,int k,int left,int right)
{
int index=getIndex(nums,left,right); //获取基准数位置
int NumOverBase=right-index; //比基准数大的数的个数
if(NumOverBase==k)return index; //比基准数大的刚好有K个
//比基准数大的多于K个,就在右边子区间寻找TopK
else if(NumOverBase>k)return findTopKthIndex(nums,k,index+1,right);
//比基准数大的少于K个,就在左边找剩下的
return findTopKthIndex(nums,k-NumOverBase,left,index);
}
vector<int> TopKInQuick(vector<int>& nums,int k,int len)
{
if(len==k)return nums;
vector<int>res;
vector<int>temp(nums.begin(),nums.end()); //TopK不对原数组改变
int index=findTopKthIndex(temp,k,0,len-1); //通过快排找到第K+1大的数的位置
for(int i=len-1;i>index;i--)res.push_back(temp[i]); //取出TopK返回
return res;
}
2.2 复杂度分析
这种方法的实质依然是快速排序,只不过多了一些判断条件而已,其平均时间复杂度依然为O(NlogN),最坏情况下时间复杂度依然为O(N²),空间复杂度为O(1);此外,与类选择排序法一样,这种方法也需要提前将N个数读入,空间开销是一笔负担,并且对于动态的数据放入也是比较“死板”的。
3.堆排序法
堆排序的分析可见https://blog.csdn.net/qq_28114615/article/details/86154057。
堆排序是通过维护大顶堆或者小顶堆来实现的。堆排序法来解决N个数中的TopK的思路是:先随机取出N个数中的K个数,将这N个数构造为小顶堆,那么堆顶的数肯定就是这K个数中最小的数了,然后再将剩下的N-K个数与堆顶进行比较,如果大于堆顶,那么说明该数有机会成为TopK,就更新堆顶为该数,此时由于小顶堆的性质可能被破坏,就还需要调整堆;否则说明这个数最多只能成为Top K+1 th,因此就不用管它。然后就将下一个数与当前堆顶的数作比较,根据大小关系如上面所述方法进行操作,知道N-K个数都遍历完,此时还在堆中的K个数就是TopK了。
3.1 代码实现
void adjustMinHeap(vector<int>& nums,int root,int len) //小顶堆结点调整
{
int lch=2*root+1; //左子结点
int rch=lch+1; //右子结点
int index=root; //较大结点
if(rch<len&&nums[rch]<nums[index])index=rch;
if(lch<len&&nums[lch]<nums[index])index=lch;
if(index!=root) //当前结点非最小结点
{
swap(nums[index],nums[root]);
adjustMinHeap(nums,index,len);
}
return;
}
vector<int> TopKInHeap(vector<int>& nums,int k,int len)
{
vector<int>res(nums.begin(),nums.begin()+k); //取出前k个数
for(int i=k/2-1;i>=0;i--) //根据前K个数建立一个小顶堆
{
adjustMinHeap(res,i,k);
}
//将剩下的数与堆顶做比较
for(int i=k;i<len;i++)
{
if(nums[i]>res[0]) //当前数比堆顶数大
{
res[0]=nums[i]; //将堆顶更新为该数
adjustMinHeap(res,0,k); //重新调整堆
}
}
return res;
}
3.2 复杂度分析
根据堆排序的复杂度,不难得出,在该方法中,首先需要对K个元素进行建堆,时间复杂度为O(K);然后对剩下的N-K个数对堆顶进行比较及更新,最好情况下当然是都不需要调整了,那么时间复杂度就只是遍历这N-K个数的O(N-K),这样总体的时间复杂度就是O(N),而在最坏情况下,N-K个数都需要更新堆顶,每次调整堆的时间复杂度为logK,因此此时时间复杂度就是NlogK了,总的时间复杂度就是O(K)+O(NlogK)≈O(NlogK)。空间复杂度是O(1)。值得注意的是,堆排序法提前只需读入K个数据即可,可以实现来一个数据更新一次,能够很好的实现数据动态读入并找出TopK。
4. 方法比较
综合以上所说的,类选择排序的平均时间复杂度为O(N*K),快速排序的平均时间复杂度为O(NlogN),堆排序的平均时间复杂度为O(NlogK),可以发现,堆排序法的优势是很明显的,不管K和N取什么值,只要K,N合理,那么堆排序法的效率是不会低于其他两种的,此外,当K远小于logN时,类选择排序与快速排序相比似乎是个更好的办法,测试如下所示:
N=50000,K=5,测试3次:
N=1000000,K=5,测试3次如下:
N=100000000,K=3,测试3次如下;
再测试一下K与logN接近的情况:
可以看到,当K远小于logN时,类选择排序效率会比快速排序效率更好,当K靠近logN或大于logN时,类选择排序的缺点就显露出来了,此外,由以上测试也可以看出,堆排序法的优势是很突出的,并且堆排序法还能够动态读入数据,因此堆排序法在这三种算法中可以算是最好的算法。
不过从另一方面来讲,这并不就意味着所有TopK的问题都应当用堆排序法来做,举个例子,如果N个数据的范围为0~M,M的大小不算很大,那么就可以用桶排序的方法来对每个数据进行计数,然后再从高到低将TopK倒出来,这种方法的复杂度仅仅为O(N+K),当然,解决TopK问题的方法并不局限于文中几种,每种方法都有各自的适用性,应当针对具体问题来选择适当方法。