【算法面试】TopN问题

竹石

作者:郑燮

咬定青山不放松,立根原在破岩中。

千磨万击还坚劲,任尔东西南北风。

 前言

 又到了一年一度的南北人口大迁移的时候,没有买票的赶紧买票,今年很早就已经回家准备过年了,因为小编已经离职啦,最近正在积极复习找工作,闲话不多扯,开始今天的正题。

面试题目:如何在10亿个整数中找出前1000个最大的数。

 这就是有名的TopN问题,这样的问题有很多种解法,下面我对我了解的解法做一个总结并写出最优算法。

扫描二维码关注公众号,回复: 11933276 查看本文章

如果初次听到这样的题目,我相信大家和我的第一反应都是一样的,先排序后输出前1000个,那么多排序,归并排序快速排序堆排序。。。

那么问题来了,排序的复杂度太大,数据量又是上亿级别的,明显排序方案不合理。那么我们能否不要全排,只排序部分元素,不就可以了吗?

方法一:冒泡排序

由此想到冒泡排序的原理:通过两层for循环,外层第一次循环找到数组中最大的元素放置在倒数第一个位置,第二次循环找到第二大的元素放置在倒数第二个位置。。。循环N次就可以找到TopN。

缺点:冒泡排序内层循环需要大量交换元素。复杂度介于O(n)和O(n^2)之间。

方法二:分而治之

由快速排序原理可知:选一个基准元素,每次排序可以将这个基准元素搁置在正确的位置,左边都是比基准小的元素,右边都是比基准大的元素从而将数组分成左右两部分,分而治之。TopN问题也同样如此,选择一个基准元素并通过快速排序将基准元素搁置在正确的位置,如果左边的元素个数小于1000,那么继续从基准右边排序,如果左边元素个数大于1000,那么从基准左边排序,直到基准的位置正好在1000,结束。

缺点:第一次排序复杂度是O(n),第二次排序复杂度是O(n/2),第三次排序复杂度是O(n/4)...

 方法三:文件存储,分而治之

将比基准小的元素存储在txt1中,比基准大的文件存储在txt2中,然后通过类似方法二的形式,最后求出TopN。

缺点:磁盘读取,写入次数过多。

方法四:分布式、MapReduce

单机内存和性能确实受限,那么我们可以将10亿个分段存储在不同的机器上,每台机器计算各自的TopN,最后汇总。

缺点:空间换时间。

 当然,上述方法均不合理,TopN的比较好的方法是通过——

最优解:小顶堆

如果大家对堆排序的原理不清除,可以查阅相关资料,我推荐大家参考《算法导论》第六章堆排序,讲解很详细。如果大家需要堆排序的源码,可以参考笔者这篇堆排序文章,本文对堆排序不在赘述。

首先,我们需要构建一个大小为N的小顶堆,小顶堆的性质如下:每一个父节点的值都小于左右孩子节点,然后依次从文件中读取10亿个整数,如果元素比堆顶小,则跳过不进行任何操作,如果比堆顶大,则把堆顶元素替换掉,并重新构建小顶堆。当10亿个整数遍历完成后,堆内元素就是TopN的结果。

下面,就写一下代码吧

public class TopN {
    public static int N = 10;           //Top10
    public static int LEN = 100000000; //1亿个整数
    public static int arrs[] =  new int[LEN];
    public static int arr[] = new int[N];
    //数组长度
    public static int len = arr.length;
    //堆中元素的有效元素 heapSize<=len
    public static int heapSize = len;
    public static void main(String[] args) {
        //生成随机数组
       for(int i = 0;i<LEN;i++){
           arrs[i] = new  Random().nextInt(999999999);
       }

       //构建初始堆
       for(int i =  0;i<N;i++){
           arr[i] = arrs[i];
       }
       //构建小顶堆
        long start =System.currentTimeMillis();
       buildMinHeap();
       for(int i = N;i<LEN;i++){
           if(arrs[i] > arr[0]){
               arr[0] = arrs[i];
               minHeap(0);
           }
       }
        System.out.println(LEN+"个数,求Top"+N+",耗时"+(System.currentTimeMillis()-start)+"毫秒");
       print();
    }


    /**
     * 自底向上构建小堆
     */
    public static void buildMinHeap(){
        int size = len / 2;
        for(int i = size;i>=0;i--){
            minHeap(i);
        }
    }

    /**
     * i节点为根及子树是一个小堆
     * @param i
     */
    public static void minHeap(int i){
        int l = left(i);
        int r = right(i);
        int index = i;
        if(l<heapSize && arr[l]<arr[index]){
            index = l;
        }
        if(r<heapSize && arr[r]<arr[index]){
            index = r;
        }
        if(index != i){
            int t = arr[index];
            arr[index] = arr[i];
            arr[i] = t;
            //递归向下构建堆
            minHeap(index);
        }
    }

    /**
     * 返回i节点的左孩子
     * @param i
     * @return
     */
    public static int left(int i){
        return 2*i;
    }

    /**
     * 返回i节点的右孩子
     * @param i
     * @return
     */
    public static int right(int i){
        return 2*i+1;
    }
    /**
     * 打印
     */
    public  static void print(){
        for(int a:arr){
            System.out.print(a+",");
        }
        System.out.println();
    }

由于机器内存首先,我就模拟了1亿个数求Top10,并计算时间。

核心代码,不精确的测试,来看看性能如何:

100000000个数,求Top10,耗时159毫秒
999999910,999999924,999999931,999999946,999999959,999999979,999999953,999999973,999999961,999999982,

100000000个数,求Top1000,耗时157毫秒
100000000个数,求Top10000,耗时159毫秒
100000000个数,求Top30000,耗时187毫秒
100000000个数,求Top60000,耗时223毫秒
200000000个数,求Top10000,耗时321毫秒
300000000个数,求Top10000,耗时482毫秒
//4亿个数字,OOM
java.lang.OutOfMemoryError: Java heap space
at com.stx.sort.TopN.<clinit>(TopN.java:8)
Exception in thread "main"
Process finished with exit code 1

简直速度快到没女朋友。。。

与TopN类似的题目并且应用堆原理的问题有很多,再给大家举个例子哈:

记得上周,小编和同事聚餐,期间有个同事提出了一个问题:

给一个无序数组,元素个数亿级,要求写一个算法,要求最后数组左边的任何一个数都比数组右边任何一个数小,但左右数组中元素是否有序不要求。于是,热闹的饭局一下子安静下来,其他同事们都陷入了深深的思考。

了解完堆和TopN的原理后,这样的题也很容易,解法如下:

  • 数组长度为len,构建一个大小为n=len/2的大顶堆,从n处遍历数组

  • 如果元素小于堆顶元素,将该元素与堆顶元素交换,并重新构建大顶堆

  • 若该元素大于堆顶元素,则不操作,继续遍历

  • 直到遍历完成,此时堆中的数据就是左半部分小的数组,与原数组合并后就是需要的结果

代码如下:

public class TopN {

    public static int LEN = 20;     //数组大小
    public static int N = LEN/2;    //堆大小
    public static int arrs[] = new int[LEN];
    public static int arr[] =  new int[N];   //堆
    //数组长度
    public static int len = arr.length;
    //堆中元素的有效元素 heapSize<=len
    public static int heapSize = len;
    public static void main(String[] args) {
        //初始化数组
        for(int i = 0;i<LEN;i++){
            arrs[i] = new Random().nextInt(1000);
        }
        for(int i = 0;i<N;i++){
            arr[i] = arrs[i];
        }
        //构建大顶堆
        buildMaxHeap();
        for(int i = N;i < LEN;i++){
            //如果比堆顶元素小,交换两个数的位置,并重新调整堆结构
            if(arrs[i] < arr[0]){
                int t = arrs[i];
                arrs[i] = arr[0];
                arr[0] = t;
                maxHeap(0);
            }
        }
        //修改原数组
        for(int i = 0;i<N;i++){
            arrs[i] = arr[i];
        }

        print();
    }


    /**
     * 自底向上构建大堆
     */
    public static void buildMaxHeap(){
        int size = len / 2;
        for(int i = size;i>=0;i--){
            maxHeap(i);
        }
    }

    /**
     * i节点为根及子树是一个大堆
     * @param i
     */
    public static void maxHeap(int i){
        int l = left(i);
        int r = right(i);
        int index = i;
        if(l<heapSize && arr[l]>arr[index]){
            index = l;
        }
        if(r<heapSize && arr[r]>arr[index]){
            index = r;
        }
        if(index != i){
            int t = arr[index];
            arr[index] = arr[i];
            arr[i] = t;
            //递归向下构建堆
            maxHeap(index);
        }
    }

    /**
     * 返回i节点的左孩子
     * @param i
     * @return
     */
    public static int left(int i){
        return 2*i;
    }

    /**
     * 返回i节点的右孩子
     * @param i
     * @return
     */
    public static int right(int i){
        return 2*i+1;
    }
    /**
     * 打印
     */
    public  static void print(){
        for(int a:arrs){
            System.out.print(a+",");
        }
        System.out.println();
    }
601,425,389,403,368,164,292,344,305,134,918,896,838,789,695,690,666,663,609,734,

Process finished with exit code 0

结束

堆在生活中应用很广泛,再比如说——优先队列,相信聪明的大家看完我这篇文章后都能写出优先队列。

希望大家可以从原理上掌握堆,在日后的面试中和工作中灵活应用,关注小编公众号或添加小编为微信好友,获取更多技术。提前给大家拜早年,祝各位能顺利进入BATJ。

 

猜你喜欢

转载自blog.csdn.net/nuoWei_SenLin/article/details/86734931