從排序算法到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問題本身,最主要的還是思想。
例如,日常項目中,堆(優先隊列)就不時會遇到,這前提是腦海中知道有優先隊列這個東西,知道它的特性。
有的知識就是這樣的,專門去學的時候不知道有什麼用,但或許某一天碰到了,就會想起它了。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章