从排序算法到TopK问题

简介:

一、前言

排序算法大家都很熟悉了,解法多种多样。
有一个问题和排序算法很相近,TopK问题:从N个数中选出最大的K个数,N通常远大于K。
总结了一些解法,供大家参考。

二、冒泡

    private static float[] pickTopKByBubbleSort(float[] a, int k) {
        int n = a.length;
        for (int i = 0; i < k; i++) {
            for (int j = 0; j < n - i - 1; j++) {
                if (a[j] > a[j + 1]) {
                    float t = a[j];
                    a[j] = a[j + 1];
                    a[j + 1] = t;
                }
            }
        }

        float[] r = new float[k];
        for (int i = 0; i < k; i++) {
            r[i] = a[n - i - 1];
        }
        return r;
    }

冒泡排序的要义在于:外循环为排序趟数,内循环为每趟比较的次数,每趟得到一个最大的数,放到这一趟的末端
如果是全数组排序的话,复杂度为O(n^2), 如果只选TopK的话,复杂度为O(n*k)。

三、堆

     private static float[] pickTopKByHeapSort(float a[], int k) {
        int n = a.length;
        PriorityQueue<Float> queue = new PriorityQueue<>(n);
        for (int i = 0; i < k; i++) {
            queue.offer(a[i]);
        }

        for (int i = k; i < n; i++) {
            if (a[i] > queue.peek()) {
                queue.poll();
                queue.offer(a[i]);
            }
        }

        float[] r = new float[k];
        for (int i = k - 1; i >= 0; i--) {
            r[i] = queue.poll();
        }
        return r;
    }

堆的实现,在JDK中对应的是PriorityQueue,默认情况下是最小堆。
元素插入和删除的时间复杂度都是O(logn)。
堆可以用于排序:将所有元素插入堆中,在依次取出,即可得到有序排列的元素,时间复杂度为O(nlogn)。

如果按照上面冒泡排序的做法,将所有元素插入,再取出K个:
插入时间复杂度为O(nlogn), 取出的时间复杂度为O(klogn), 总体时间复杂度还是O(nlogn)。
当然,虽然复杂度都是O(nlogn),但是比全体元素排序快一点(不用取尽n个元素)。

堆在TopK问题上更优的解法是:
1、只放k个元素到堆中,然后遍历剩下的元素,和堆顶的元素(堆中最小的值)比较,
2、若大于堆顶,则取出堆顶的元素,插入当前元素;若小于等于堆顶,直接跳过;
3、最后依次取出堆中元素。
平均时间复杂度为O(nlogk);
最快的情况下是O(n),如果数组本身已经有序且是逆序的话。

四、快排

先来看下快排的实现:

    private static int partition(float[] a, int left, int right) {
        int i = left;
        int j = right;
        float key = a[left];

        while (i < j) {
            while (i < j && a[j] <= key) {
                j--;
            }
            a[i] = a[j];
            while (i < j && a[i] >= key) {
                i++;
            }
            a[j] = a[i];
        }
        a[i] = key;
        return i;
    }

    private static void sort(float[] a, int left, int right) {
        if (left >= right) {
            return;
        }
        int index = partition(a, left, right);
        sort(a, left, index - 1);
        sort(a, index + 1, right);
    }

快排的基本思想是:
1、通过一趟排序将要数据分割成独立的两部分,其中一部分的所有数据都比另外一部分的数据都要小;
2、然后再按此方法对这两部分数据分别进行第1步的操作(递归),以此达到整个数据变成有序序列。

在TopK问题上,可以借鉴以上第1点,以及第2点的一部分:递归。
不同之处在于,求解TopK不需要对整个数据排序,只需要最大的K个数移动到数组的第K个位置的前面即可。
代码如下:

    private static void sort(float[] a, int left, int right, int k) {
        if (left >= right) {
            return;
        }
        int index = partition(a, left, right);
        int len = index - left + 1;
        if (k < len) {
            sort(a, left, index - 1, k);
        } else if (k > len) {
            sort(a, index + 1, right, k - len);
        }
    }

    private static float[] pickTopKByQuickSort(float a[], int k) {
        sort(a, 0, a.length - 1, k);
        float[] r = new float[k];
        for (int i = 0; i < k; i++) {
            r[i] = a[i];
        }
        // 执行以上操作之后,r[]数组确实包含了top k的元素,但是不一定有序
        // 如果仅仅是要求TopK而不需要有序,则不需要执行下面的排序函数
        sort(r, 0, k - 1);
        return r;
    }

最坏的情况下,时间复杂度和快排一样: O(n^2)。
平均情况下,快排的时间复杂度为O(nlogn),求解TopK时间复杂度为O(n)。

五、桶排序

冒泡排序,堆排序,快速排序等都是基于比较的排序,时间复杂度下限为O(nlogn)。
而有一些排序的时间复杂度可以是O(n), 比如桶排序和基数排序。
但是这类排序有较为苛刻的限制条件:元素须是数值类型。
同时,如果是桶排序的话,范围不能太大,否则需要的桶太多,空间复杂度无法接受。
桶排序的元素不一定要是整数,有时候浮点数也可以。
比如,如果数据取值范围在[0, 100.0], 并且只有两位小数, 可以这么解:

    private static float[] pickTopKByBucketSort(float[] a, int k) {
        int[] bucket = new int[10000];

        for (int i = 0; i < a.length; i++) {
            int index = Math.round(a[i] * 100f);
            bucket[index]++;
        }

        float[] r = new float[k];
        int p = 0;
        for (int i = bucket.length - 1; i >= 0 && p < k; i--) {
            int c = bucket[i];
            while (c > 0 && p < k) {
                r[p++] = i / 100f;
                c--;
            }
        }

        return r;
    }

取一万个桶,将每个元素乘以100,四舍五入为整数(有一些小数在计算机二进制中无法精确表示,同时直接强转成int的话是向下取整),
放到对应的桶中;
然后从最大的桶开始检索,收集100个元素。
时间复杂度跟数据量和桶数量(取决于数据的大小范围)有关。
在数据量远大于桶数量时,时间复杂度为O(n)。

六、测试

集成测试的代码如下:

    public static void main(String[] args) {
        int n = 100000;
        int k = 100;
        boolean success = true;
        
        Random r = new Random();
        for (int j = 0; j < 100; j++) {
            float[] a = new float[n];
            for (int i = 0; i < n; i++) {
                float t = r.nextFloat() * 100f;
                t = (int) (t * 100) / 100f;
                a[i] = t;
            }
            
            float[] tmp = Arrays.copyOf(a, n);
            float[] top1 = pickTopKByBubbleSort(tmp, k);
   
            tmp = Arrays.copyOf(a, n);
            float[] top2 = pickTopKByHeapSort(tmp, k);
       
            tmp = Arrays.copyOf(a, n);
            float[] top3 = pickTopKByQuickSort(tmp, k);
            
            tmp = Arrays.copyOf(a, n);
            float[] top4 = pickTopKByBucketSort(tmp, k);

            if (!Arrays.equals(top1, top2)
                    || !Arrays.equals(top1, top3)
                    || !Arrays.equals(top1, top4)) {
                success = false;
                break;
            }
        }

        if (success) {
            System.out.println("success");
        } else {
            System.out.println("failed");
        }
    }

七、总结

这么多种方案,那种方案比较好呢?这个要看情况。

  • 冒泡的方法看起来时间复杂度是最高的,但是假如K很小,比如极端情况 , K=1,这时候冒泡的方法反而是最快的;
  • 平均情况下,如果可以用的上桶排序(数据取值范围较小),那桶排序是最快的;
  • 如果数值取值范围较小,而N很大(比方说100000),K又比较小(比方说100)时,运行发现堆的方法比快排的方法快。
    原因猜测:范围小,数据多,应该有很多数值是重复的; 用堆的方法,堆会很快攒到Top的数值(即使还不是TopK),检索到那些小的数值的时候就不需要插入,从而检索后面的元素时,操作就只剩简单的遍历了。

跳脱排序算法和TopK问题本身,最主要的还是思想。
例如,日常项目中,堆(优先队列)就不时会遇到,这前提是脑海中知道有优先队列这个东西,知道它的特性。
有的知识就是这样的,专门去学的时候不知道有什么用,但或许某一天碰到了,就会想起它了。

相关文章
|
6月前
|
C语言
堆排序(Topk问题)
堆排序(Topk问题)
28 0
|
1天前
|
算法
堆排序+TopK问题——“数据结构与算法”
堆排序+TopK问题——“数据结构与算法”
|
4月前
|
算法 搜索推荐 大数据
【算法】排序——归并排序和计数排序
上两篇文章讲解了插入排序、选择排序以及交换排序,每种类型的排序大类下都有一到两种排序,今天给大家带来的是归并排序,和前面几种排序一样都属于比较排序中的一种,是通过比较数组中的元素来实现排序的,还给大家带来一种非比较排序计数排序,让我们开始今天的排序之吧!!!
|
4月前
|
算法
【算法】排序——选择排序和交换排序(快速排序)
上篇文章讲述了插入排序及插入排序的优化希尔排序,今天我们继续给大家带来排序中的选择排序和交换排序,选择排序包括直接选择排序、 其中还包括堆排序,因为之前讲过堆排序,这篇文章就不多讲解,点击直达堆排序。交换排序包括冒泡排序、快速排序。让我们开始今天的选择排序之旅吧!!!
|
6月前
|
存储 搜索推荐 算法
解密堆排序与TopK问题
解密堆排序与TopK问题
54 0
|
存储 Go C语言
堆排序在topK场景题中的应用及原理
堆排序在topK场景题中的应用及原理
99 0
堆排序在topK场景题中的应用及原理
|
算法 搜索推荐 API
算法排序3——选择排序
算法排序3——选择排序
75 0
算法排序3——选择排序
|
算法 API
算法排序4——插入排序
每个元素要比较的是它之前的已排序的元素,并判断大小,所以再定义一个元素 j,从已排序组内从后往前比较;例如当 i = 5 的时候,其实是第6个位置,而 j = 5 的时候,由于从第一个开始计数,所以就表示第五个位置,恰好满足已排序组内的最后一个,终止值就是元素第一个
71 0
算法排序4——插入排序
|
移动开发 算法 Shell
算法之排序5——希尔排序
依次比较待插入元素 i 和分组内另一个元素,就需要用到for循环语句;当h = 5 时,a[5]恰好是数组内第6个元素,也就是第一个待插入的元素,所以初始条件是 i = h,待插入的最后一个元素就是数组内最后一个元素,即终止条件为 i < a.length
106 0
算法之排序5——希尔排序
|
机器学习/深度学习 算法 API
算法排序5——归并排序&分治思想
算法排序5——归并排序&分治思想
87 0
算法排序5——归并排序&分治思想