TOP K问题的解决方案

1.1 代码实现

1.2 复杂度分析

2.快速排序法

2.1 代码实现

2.2 复杂度分析

3.堆排序法

3.1 代码实现

3.2 复杂度分析

4. 方法比较


       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(N)+O(N/2)+O(N/4)+……<O(2N),因此时间复杂度为O(N),时间复杂度虽然低,但是这种方法也需要提前将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(N),堆排序的平均时间复杂度为O(NlogK),可以看出,快速排序的方法应当是最快的,堆排序的方法应当是优于类选择排序的,实际上怎么样呢?我们来看一看:

N=50000,K=5,测试3次:

N=1000000,K=5,测试3次如下:

N=100000000,K=3,测试3次如下;

再测试一下K与logN接近的情况:

可以看到,和我们一开始的推测是有差别的,快速排序的方法并不是最快的,反而是堆排序的优势更加明显,这是为什么呢?

根据我的理解,在使用快速排序来查找TopN的过程中,每一次分割后都需要对一部分进行重新访问,元素被访问的步长并不都是1,并非一定是连续的,比如说前一次访问到了最后一个元素,下一次访问又要从分割点开始从头访问,这种访问不连续的情况,就可能会影响到Cache的命中率,未命中的元素只能从效率更低的设备中获取;而再看堆排序,在前K个元素建堆结束后,后面的元素都是按照地址连续访问,这样的访问使得Cache的命中率比快速排序的Cache命中率更高,而本身当K比较小的时候O(NlogK)和O(N)的差距就不是很大,因此在这种情况下访问Cache的命中率反而使得快速排序的效率更加低下,堆排序的方法效率更高。

不过这并不是绝对的,当数据量较小的情况下,快速排序方法的效率还是会稍优于堆排序方法的,尤其是K较大的情况下;但是如果数据量较大,这个时候不仅要考虑Cache的命中率问题,还要考虑内存大小的问题,而堆排序的方法很明显可以支持“动态读入”数据,因此不必一次性将所有数据都读入,内存问题则迎刃而解,而快速排序方法和类选择排序的方法却都需要将所有数据一次性读入,这样显然是不行的。

那这是否意味着数据量大的情况下堆排序就一定是最好的方法呢?数据量小的情况下快速排序的方法就是最好的方法呢?这都并不一定,比如说,数据量大的情况下可以多台电脑分开计算,然后再合到一起,这样的方法并不一定就比堆排序方法差;数据量小的情况,如果N个数据的范围为0~M,M的大小不算很大,那么还可以用桶排序的方法来对每个数据进行计数,然后再从高到低将TopK倒出来,这种方法的复杂度仅仅为O(N+K),这能说它就比快速排序的方法差吗?当然不一定了。

当然,TopN的解决方案不仅仅限于文中几种,但是算法不分好坏,不管采用什么方法,还是应当结合各种算法的特点以及实际应用环境来选择最合适的方法,而不是最好的方法。

  • 0
    点赞
  • 6
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值