百萬考生分數如何排序 - 計數排序
關注 「碼哥位元組」,這裡有演算法系列、大資料儲存系列、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 分數的考生個數 **。這樣加的目的是什麼?
其實是讓統計陣列儲存的元素值,等於相應考試成績資料的最終排序位置的序號。
現在我就要講計數排序中最複雜、最難理解的一部分了,堅持啃下來。
- 從後往前遍歷原始輸入陣列 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.
- 遍歷成績表倒數第二個資料,成績是 0,找到在 countArray[0] 的元素 = 2,表示排名第二,同時 countArray[0] 的元素值 -1。
- 以此類推,當掃描完整個原始陣列之後, sortedArray 資料就是按照分數從小到大有序排列了。
程式碼實戰
整個步驟:
- 查詢數列最大值。
- 根據數列最大值確定 countArray 統計陣列長度。
- 遍歷原始資料填充統計陣列,統計對應元素的個數。
- 統計陣列做變形,後面的元素等於前面元素之和。
- 倒序遍歷原始陣列,從統計陣列中找到元素的正確排位,輸出到結果陣列中。
原始碼詳見 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,轉換成非負整數。
計數排序這麼強大,但是侷限性主要有如下兩點:
- 當數列的最大與最小值差距過大,不適合使用計數排序。
比如 20 個隨機整數,範圍在 0 - 1 億之間,這時候使用計數排序需要建立長度為 1 億的陣列,嚴重浪費空間。
- 數列元素不是整數,不適合使用
參考資料
極客時間 《資料結構與演算法之美》
漫畫演算法-小灰的演算法之旅
關注 「碼哥位元組」後臺回覆加群,可新增個人微信進入專屬技術群。
讀者的分享、點贊、在看、收藏三連是最大的鼓勵
隨手點選下方廣告,給「碼哥」加雞腿。