百萬考生分數如何排序 - 計數排序
關注 「碼哥字節」,這里有算法系列、大數據存儲系列、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 億的數組,嚴重浪費空間。
- 數列元素不是整數,不適合使用
參考資料
極客時間 《數據結構與算法之美》
漫畫算法-小灰的算法之旅
關注 「碼哥字節」后台回復加群,可添加個人微信進入專屬技術群。
讀者的分享、點贊、在看、收藏三連是最大的鼓勵
隨手點擊下方廣告,給「碼哥」加雞腿。