15.百萬考生成績如何排序 - 計數排序

借來方向發表於2020-07-13

百萬考生分數如何排序 - 計數排序

關注 「碼哥位元組」,這裡有演算法系列、大資料儲存系列、Spring 系列、原始碼架構拆解系列、面試系列……敬請期待。設定星標不迷路

其實計數排序是桶排序的一種特殊情況。 桶排序的核心思想是將要排序的資料分到幾個有序的桶裡,每個桶裡的資料再單獨進行排序。桶內排完序之後,再把每個桶裡的資料按照順序依次取出,組成的序列就是有序的了。

「碼哥位元組」之前分享了百萬訂單如何根據金額排序,就是運用了桶排序。

計數排序的核心在於將輸入的資料值轉換成鍵儲存在陣列下標,所以作為一種線性時間複雜度的排序,輸入的資料必須是有確定且範圍不大的整數。比如當要排序的 n 個資料,所處的範圍不大的時候,最大值是 m,我們就把資料化劃分成 m 個桶。每個桶內的資料都是相同的大小,也就不需要桶內排序,這是與桶排序最大的區別。

場景重現

高考查分數系統,系統會展示我們的成績以及所在省的排名。假如 H 省有 80 萬考生,如何通過成績快速排序得出排名呢?

再比如統計每個省人口的每個年齡人數並且從小到大排序,又如何實現呢?

考生的滿分是 750 分,最小是 0 分,符合我們之前說的條件:資料範圍小且是整數。我們可以劃分為 751 個桶分別對應分數為 0 ~ 750 分數的考生。

接著開始遍歷考生資料,每個考生按照分數則劃分到對應陣列下標,相同陣列的下標則將該下標的資料值 + 1。其實就是每個陣列下標位置對應的是數列資料出現的次數,最後直接遍歷該陣列,輸出元素的下標就是對應的分數,下標對應的元素值是多少我們就輸出幾次。

桶內的資料都是分數相同的考生,所以並不需要再進行排序。我們只需要依次掃描每個桶,將桶內的考生依次輸出到一個陣列中,就實現了 80 萬考生的排序。因為只涉及掃描遍歷操作,所以時間複雜度是 O(n)。

計數排序的演算法思想就是這麼簡單,跟桶排序非常類似,只是桶的大小粒度不一樣。不過,為什麼這個排序演算法叫“計數”排序呢?“計數”的含義來自哪裡呢?

剛剛所說的是樸素版的排序,只是簡單的按照統計陣列的下標輸出元素值,並沒有給原始數列進行排序。

在現實中,給學生排序遇到相同分數的就分不清誰是誰?比如並列 95 分的張無忌與周芷若,卻不知道是張無忌哪個是周芷若。帶著這些問題,請繼續看下面的圖解思路…...

圖解思路

計數統計

為了方便理解,對資料進行簡化,假設只有 8 個考生,分數在 0 ~ 5 之間,所以有 5 個桶對應考生分數,值代表每種分數的考生個數。考生的原始資料我們放在陣列 SourceArray[8] = {2,5,3,0,2,3,0,3}。

考生的成績從 0 到 5,使用 大小陣列為 6 的 countArray[6] 表示桶,下標對應分數,值儲存的是該分數的考生個數。我們只要遍歷一遍原始資料就可以得到 countArray[6]。

分數與個數陣列

可以知道,分數為 3 分的學生有 3 個, < 3 分的學生有 4 個,所以成績 = 3 分的學生在排序後的有序陣列 sortedArray[8] 中的下標會在 4, 5, 6 的位置,也就是排名是 5,6,7。如下圖所示

我們如何計算出每個分數的考生在有序陣列對應的儲存位置呢?這個思路很巧妙,主要是對之前的 countArray[6] 做一下轉換。

劃重點了同學們:**我們對 countArray[6] 陣列順序求和,countArray[k] 裡面儲存的是 ≤ k 分數的考生個數 **。這樣加的目的是什麼?

其實是讓統計陣列儲存的元素值,等於相應考試成績資料的最終排序位置的序號。

現在我就要講計數排序中最複雜、最難理解的一部分了,堅持啃下來。


  1. 從後往前遍歷原始輸入陣列 SourceArray[8] = {2,5,3,0,2,3,0,3},掃描成績為 3 的小強,我們就從 陣列 countArray[6] 中取出下標 = 3 的值 = 7,也就意味著包括自己在內,分數 ≤ 3 的考生有 7 位,表示在 sortedArray 中排在第七位,當把小強成績放到 sortedArray 之後 ≤ 3 的成績就剩下 6 個了,所以 countArray[3] 要 - 1,變成 6.
  2. 遍歷成績表倒數第二個資料,成績是 0,找到在 countArray[0] 的元素 = 2,表示排名第二,同時 countArray[0] 的元素值 -1。
  3. 以此類推,當掃描完整個原始陣列之後, sortedArray 資料就是按照分數從小到大有序排列了。

程式碼實戰

整個步驟:

  1. 查詢數列最大值。
  2. 根據數列最大值確定 countArray 統計陣列長度。
  3. 遍歷原始資料填充統計陣列,統計對應元素的個數。
  4. 統計陣列做變形,後面的元素等於前面元素之和。
  5. 倒序遍歷原始陣列,從統計陣列中找到元素的正確排位,輸出到結果陣列中。

原始碼詳見 GitHub: https://github.com/UniqueDong/algorithms

package com.zero.algorithms.linear.sort;

/**
 * 公眾號:碼哥位元組
 * 計數排序
 */
public class CountingSort {
    public int[] sort(int[] sourceArray) {
        if (sourceArray == null || sourceArray.length <= 1) {
            return new int[0];
        }
        // 1.查詢數列最大值
        int max = sourceArray[0];
        for (int value : sourceArray) {
            max = Math.max(max, value);
        }
        // 2.根據資料最大值確定統計陣列長度
        int[] countArray = new int[max + 1];
        // 3. 遍歷原始陣列對映到統計陣列中,統計元素的個數
        for (int value : sourceArray) {
            countArray[value]++;
        }
        // 4.統計陣列變形,後面的元素等於前面元素之和。目的是定位在結果陣列中的排位
        for (int i = 1; i <= max; i++) {
            countArray[i] += countArray[i - 1];
        }
        // 5.倒序遍歷原始陣列,從統計陣列查詢對應的正確位置,輸出到結果表
        int[] sortedArray = new int[sourceArray.length];
        for (int i = sourceArray.length - 1; i >= 0; i--) {
            int value = sourceArray[i];
            // 分數在 countArray 中的排名, - 1 則是結果陣列的下標
            int index = countArray[value] - 1;
            sortedArray[index] = value;
            countArray[value]--;
        }
        return sortedArray;
    }
}

複雜度分析

第 1、3、5 步都涉及遍歷原始陣列,時間複雜度都是 O(n),第 4 步統計陣列變形,時間複雜度是 O(m),所以總體的時間複雜度是 O(3n +m),去掉係數 O(n) 時間複雜度。

空間複雜度,結果陣列 O(n)。

優化思路

前面的程式碼,第一步我們查詢最大值,假如原始資料是 {99,98,92,80,88,87,82,88,99,97,92},最大值是 99,最小值是 80,如果直接建立 100 長度的陣列,那麼 從 0 到 79 的空間全都浪費了。

要怎麼解決呢?

跟著 「碼哥位元組」來優化,很簡單,我們不再使用 max + 1 作為統計陣列的長度,而是 max - min + 1 作為統計陣列的長度即可。

程式碼如下:

package com.zero.algorithms.linear.sort;

/**
* 公眾號:碼哥位元組
* 計數排序
*/
public class CountingSort {
   public int[] sort(int[] sourceArray) {
       if (sourceArray == null || sourceArray.length <= 1) {
           return new int[0];
       }
       // 1.查詢數列最大值,最小值
       int max = sourceArray[0];
       int min = sourceArray[0];
       for (int value : sourceArray) {
           max = Math.max(max, value);
           min = Math.min(min, value);
       }
       int d = max - min;
       // 2.根據資料最大值確定統計陣列長度
       int[] countArray = new int[d + 1];
       // 3. 遍歷原始陣列對映到統計陣列中,統計元素的個數
       for (int value : sourceArray) {
           countArray[value - min]++;
       }
       // 4.統計陣列變形,後面的元素等於前面元素之和。目的是定位在結果陣列中的排位
       for (int i = 1; i < countArray.length; i++) {
           countArray[i] += countArray[i - 1];
       }
       // 5.倒序遍歷原始陣列,從統計陣列查詢對應的正確位置,輸出到結果表
       int[] sortedArray = new int[sourceArray.length];
       for (int i = sourceArray.length - 1; i >= 0; i--) {
           int value = sourceArray[i];
           // 分數在 countArray 中的排名, - 1 則是結果陣列的下標
           int index = countArray[value - min] - 1;
           sortedArray[index] = value;
           countArray[value - min]--;
       }
       return sortedArray;
   }
}

總結一下

計數排序適用於在資料範圍不大的場景中,並且只能給非負整數排序,對於其他型別的資料,要排序的話要在不改變相對大小的情況下,轉成非負整數。

比如資料範圍 [-1000, 1000] ,就對每個資料 +1000,轉換成非負整數。

計數排序這麼強大,但是侷限性主要有如下兩點:

  1. 當數列的最大與最小值差距過大,不適合使用計數排序。

比如 20 個隨機整數,範圍在 0 - 1 億之間,這時候使用計數排序需要建立長度為 1 億的陣列,嚴重浪費空間。

  1. 數列元素不是整數,不適合使用

參考資料

極客時間 《資料結構與演算法之美》

漫畫演算法-小灰的演算法之旅

關注 「碼哥位元組」後臺回覆加群,可新增個人微信進入專屬技術群。

碼哥位元組

讀者的分享、點贊、在看、收藏三連是最大的鼓勵

隨手點選下方廣告,給「碼哥」加雞腿。

相關文章