快速排序到TopK问题

由快速排序到TopK

  • 若不是这次面试还真没有好好的了解快速排序。
    如果还有印象,我们可以还记得快速排序的平均时间复杂度是nlogn。这就够了,可以从这里慢慢推导。
  • 既然时间复杂度里有logn,那么有可能就有每次考虑一半的操作存在。归并排序就是类似,俩俩进行排序。
    快速排序的原理是:现在有一个数组,如果进行某种神奇操作之后,可以达到数组中左边都比基准值小,数组右边都比基准值大这种局面。那么就很有利,为什么呢。假如进行完神奇的操作之后,对两侧进行同样神奇的操作,这样一直下去。就能把整个数组排好了顺序。如果每次把两侧分的很均匀,都刚好是N/2,那么我们只需操作logn次神奇的操作就ok啦,logn的存在就是因为递归了logn次。
  • 那如何才能做到神奇的操作,就是把一个数组用一个基准值分开呢。很明显这个操作的时间复杂度需要时O(n),如果不是,整体复杂度就不是nlogn了。
  • 在不考虑神奇操作之前,我们把第一步的代码写出来。就是递归的操作
    quicksort(int *a, int low, int high){
    if(low<high){
        int pos = getmidpos(a, low, high);
        quicksort(a, low,  pos - 1);
        quicksort(a, pos + 1, high);
    }
}
  • 递归需要考虑什么时候结束还有递归关系。上面说的神奇操作就是getmidpos。这个先不考虑他是怎么实现的,先知道可以通过这个函数可以得到最终基准值的下标,通过一次神奇操作之后,分成两侧,再各自进行神奇操作,就能排好序啦。可以看出每次经过神奇操作之后,就有一个数字回到了他排序后最终的位置上。
  • 接下来考虑,如何通过O(N)的时间复杂度,将整个数组分开呢。我是通过手动模拟的方式,慢慢看懂的。代码是:
int getmidpos(int *a, int low, int high){
    int temp = a[low];
    while(low<high){
        while(low<high&& a[high] > temp){
            high--;
        }
        a[low] = a[high];
        while(low<high&& a[low] < temp){
            low++;
        }
        a[high] = a[low];
    }
    a[low] = temp;
    return low;
}

但其实模拟了以后也不太明白,以为明白之后,自己按照自己的理解写出来,然后修改,考虑为什么要那么修改,反复几次之后,就清楚这个过程了。一时间这个地方不理解的话,不影响接下来看Top K的内容。

  • 这个神奇操作有点双指针的意思,双指针最经典的题目应用就是给一个有序的数组,求其中两个数字的和等于给定值tar的组合。做法就是用两个下标,分别从两头开始,如果a[i] + a[j]>tar, 那说明是j下标对应的数字太大, j--就ok。反之,则说明i对应的数字太小,i++就ok。
  • 第一步当然是找一个目标值tar, 这个目标值的选取很关键,如果选的很巧,能把整个数组刚好分成两边个数一样大,整体的复杂度就很小。但这个事情是没法把握的。一般快排都是选择的第一个,这里也不例外。然后从数组后面开始找比目标值小的,虽然还不知道找到a[i]的这个比目标值小的值他最终在哪儿,但肯定不是这里,放到最前面去,也就是a[low]的地方。然后从前面开始找比目标值大的,虽然也不知道这个值该在哪儿,但也肯定不是在这里。然后继续的循环,一直到两个指针碰一起了,那么最后,把最初的tar放在指针碰一起的地方,就ok。因为前面在放置值的时候都是把比目标值小的往左边放的,大的放右边。最后把temp放中间,就ok啦~。要是不理解手动模拟几次吧。
  • 在上面的过程中,我们需要知道这几个事实,复杂度是O(N),虽然看起来while循环很多,但其实最终两个指针碰头了,就只遍历了一遍而已。目标值如果选择的不好,虽然不影响这一步的时间复杂度,但是整体的慢了。为什么呢,如果每次选取的目标值都是目标排序的最小(大)一侧,那么递归的时候,每次都不是分成两侧, 都是只分成了一侧。这样整体的复杂度就是O(N^2)了。

TopK问题

  • topK的问题实在是经典。游戏里面的排行榜就很需要考虑。问题描述:给一个数组,取出前K大(小)个元素。
    最朴素的想法,排序。排序之后,取出前K个就ok。时空复杂度取决于排序算法的时空复杂度。这么一听好像问题回去了,只要解决好排序就ok。但是很明显,这个方法一定不是最优的,原因很简单,用排序能解决,但是解决的太多了。势必造成时空的浪费。我们只需要知道前K个就行了,排序都把顺序排好了,这肯定浪费了时间啊。
    一定有更优的方案。如果对找数组的最大值的方法有印象,先设定一个maxnum,遍历整个数组,如果比maxnum大,就替换maxnum。这样O(N)的时间复杂度就可以解决找到数组的最大值。那仿照这个方法。我也用个数据结构存起来前K个数值,遍历数组arr,arra[i]如果比数据结构中的值大,就替换掉。这样复杂度听起来也是O(N) 呀。如果选择这个数据结构是数组,每次在数组中查找比arr[i]大的数字时,怎么操作?遍历?那么复杂度就是K了哦。诶如果二分?因为这个数据结构里面可以是有序的嘛,那么二分就ok?这样复杂度就变成logK,听起来快了好多啊,但是如果数组是有序的,往里面插入数值,也很浪费时间,数组的插入时间复杂度是O(K),如果想减少插入的复杂度,把这个数据结构改为链表,也不行,这样查找就费时间了,综合来说这个方法,查找和插入复杂度加一起最少也要O(K),那经过这个思路,整体的复杂度就变成了O(NK),如果K小点也还行,要是十万个里面取前一千个,就又崩了。
  • 有没有一种数据结构,插入和查询时间复杂度都是log的?堆这个数据结构还是蛮神奇的,插入和删除都是logn,如果我们先建立一个大小为K的最小堆,这一步的操作也是log。每次把arr[i]的数值放到堆里,如果堆的大小超过K,就删除掉最小的,这些操作都可以是logk。遍历完arr数组之后,堆中的元素就是我们要找的结果,这个方法的时间复杂度是O(nlogk)。C++ stl里可以直接使用优先队列来处理,优先队列就是堆实现的。
  • 已经蛮好啦,复杂度下来好多啦~,但是还有优化空间。
  • 结合前面的快速排序,就是那个神奇的操作,把数组分成两侧,如果分得很巧,数组分成左边K个数字,右边是剩下的那就把前K个小的找到啦~。神奇操作的时间复杂度是O(N),听起来一下子就解决了诶。hhhh。还要继续考虑,最差时间复杂度。如果运气不好怎么办,选的目标值很差,每次都没分好怎么办。时间复杂度就变成O(N^2)啦。平均时间复杂度是什么呢。随机来说的话,每次选择的数值能把数组分成均匀两侧。然后再继续下去。听起来是logN?其实不是,分成两侧之后,每次只考虑靠近前K的一侧,另一侧就不考虑了,就少了很多。平均的复杂度是2n,也就是O(N),就这么神奇。复杂度最后就成了O(N)。
  • 快速排序的思路虽然好,但是需要把数据放在一个数组里进行操作,也就是还需要存储起来,如果数据非常多,就不合适了。存起来也费劲,堆不需要,如果数据流是只能读取一遍,不能存储起来,用堆就比快排合适。
    代码:
int getmidpos(int *a, int low, int high){
    int temp = a[low];
    while(low<high){
        while(low<high&& a[high] > temp){
            high--;
        }
        a[low] = a[high];
        while(low<high&& a[low] < temp){
            low++;
        }
        a[high] = a[low];
    }
    a[low] = temp;
    return low;
}

int findk(int *a, int l, int r, int k){
    int temp = getmidpos(a, l, r);
    if(k+l == temp+1)
    {
        return a[temp];
    }
    if(k+l<temp + 1){
        return findk(a, l, temp-1, k);
    }
    else{
        return findk(a, temp+1, r, k - (temp-l+1));
    }
}
  • 还没结束,面试官继续说。在神奇操作里面,如果每次选择的目标值如果很合适,那么就能更接近了N啦。怎么选择呢,比如武力值排行榜,其实都有一个大致的范围,分布也很接近正态分布,选择合适的西格玛值,就能大致的估计出第K个值,这样更能缩短整体的复杂度。我听了之后,只感觉我真是太年轻了。游戏结束。拉闸!!!

猜你喜欢

转载自www.cnblogs.com/leon-ldy/p/12327552.html
今日推荐