線性排序: 如何根據年齡給100完用戶數據排序?
上兩節中,我帶你著重分析了幾種常用排序算法的原理、時間復雜度、空間復雜度、穩定性等。今天,我會講三種時間復雜度是 O(n) 的排序算法:桶排序、計數排序、基數排序。因為這些排序算法的時間復雜度是線性的,所以我們把這類排序算法叫作線性排序(Linear sort)。之所以能做到線性的時間復雜度,主要原因是,這三個算法是非基于比較的排序算法,都不涉及元素之間的比較操作。
這幾種排序算法理解起來都不難,時間、空間復雜度分析起來也很簡單,但是對要排序的數據要求很苛刻,所以我們今天學習重點的是掌握這些排序算法的適用場景。
按照慣例,我先給你出一道思考題:如何根據年齡給 100 萬用戶排序? 你可能會說,我用上一節課講的歸并、快排就可以搞定啊!是的,它們也可以完成功能,但是時間復雜度最低也是 O(nlogn)。有沒有更快的排序方法呢?讓我們一起進入今天的內容!
桶排序(Bucket sort)
首先,我們來看桶排序。桶排序,顧名思義,會用到“桶”,核心思想是將要排序的數據分到幾個有序的桶里,每個桶里的數據再單獨進行排序。桶內排完序之后,再把每個桶里的數據按照順序依次取出,組成的序列就是有序的了。
桶排序的時間復雜度為什么是 O(n) 呢?我們一塊兒來分析一下。
如果要排序的數據有 n 個,我們把它們均勻地劃分到 m 個桶內,每個桶里就有 k=n/m 個元素。每個桶內部使用快速排序,時間復雜度為 O(k * logk)。m 個桶排序的時間復雜度就是 O(m * k * logk),因為 k=n/m,所以整個桶排序的時間復雜度就是 O(n*log(n/m))。當桶的個數 m 接近數據個數 n 時,log(n/m) 就是一個非常小的常量,這個時候桶排序的時間復雜度接近 O(n)。
桶排序看起來很優秀,那它是不是可以替代我們之前講的排序算法呢?
答案當然是否定的。為了讓你輕松理解桶排序的核心思想,我剛才做了很多假設。實際上,桶排序對要排序數據的要求是非常苛刻的。
首先,要排序的數據需要很容易就能劃分成 m 個桶,并且,桶與桶之間有著天然的大小順序。這樣每個桶內的數據都排序完之后,桶與桶之間的數據不需要再進行排序。
其次,數據在各個桶之間的分布是比較均勻的。如果數據經過桶的劃分之后,有些桶里的數據非常多,有些非常少,很不平均,那桶內數據排序的時間復雜度就不是常量級了。在極端情況下,如果數據都被劃分到一個桶里,那就退化為 O(nlogn) 的排序算法了。
桶排序比較適合用在外部排序中。所謂的外部排序就是數據存儲在外部磁盤中,數據量比較大,內存有限,無法將數據全部加載到內存中。
比如說我們有 10GB 的訂單數據,我們希望按訂單金額(假設金額都是正整數)進行排序,但是我們的內存有限,只有幾百 MB,沒辦法一次性把 10GB 的數據都加載到內存中。這個時候該怎么辦呢?
現在我來講一下,如何借助桶排序的處理思想來解決這個問題。
我們可以先掃描一遍文件,看訂單金額所處的數據范圍。假設經過掃描之后我們得到,訂單金額最小是 1 元,最大是 10 萬元。我們將所有訂單根據金額劃分到 100 個桶里,第一個桶我們存儲金額在 1 元到 1000 元之內的訂單,第二桶存儲金額在 1001 元到 2000 元之內的訂單,以此類推。每一個桶對應一個文件,并且按照金額范圍的大小順序編號命名(00,01,02…99)。
理想的情況下,如果訂單金額在 1 到 10 萬之間均勻分布,那訂單會被均勻劃分到 100 個文件中,每個小文件中存儲大約 100MB 的訂單數據,我們就可以將這 100 個小文件依次放到內存中,用快排來排序。等所有文件都排好序之后,我們只需要按照文件編號,從小到大依次讀取每個小文件中的訂單數據,并將其寫入到一個文件中,那這個文件中存儲的就是按照金額從小到大排序的訂單數據了。
不過,你可能也發現了,訂單按照金額在 1 元到 10 萬元之間并不一定是均勻分布的 ,所以 10GB 訂單數據是無法均勻地被劃分到 100 個文件中的。有可能某個金額區間的數據特別多,劃分之后對應的文件就會很大,沒法一次性讀入內存。這又該怎么辦呢?
針對這些劃分之后還是比較大的文件,我們可以繼續劃分,比如,訂單金額在 1 元到 1000 元之間的比較多,我們就將這個區間繼續劃分為 10 個小區間,1 元到 100 元,101 元到 200 元,201 元到 300 元…901 元到 1000 元。如果劃分之后,101 元到 200 元之間的訂單還是太多,無法一次性讀入內存,那就繼續再劃分,直到所有的文件都能讀入內存為止。
計數排序(Counting sort)
我個人覺得,計數排序其實是桶排序的一種特殊情況。當要排序的 n 個數據,所處的范圍并不大的時候,比如最大值是 k,我們就可以把數據劃分成 k 個桶。每個桶內的數據值都是相同的,省掉了桶內排序的時間。
我們都經歷過高考,高考查分數系統你還記得嗎?我們查分數的時候,系統會顯示我們的成績以及所在省的排名。如果你所在的省有 50 萬考生,如何通過成績快速排序得出名次呢?
考生的滿分是 900 分,最小是 0 分,這個數據的范圍很小,所以我們可以分成 901 個桶,對應分數從 0 分到 900 分。根據考生的成績,我們將這 50 萬考生劃分到這 901 個桶里。桶內的數據都是分數相同的考生,所以并不需要再進行排序。我們只需要依次掃描每個桶,將桶內的考生依次輸出到一個數組中,就實現了 50 萬考生的排序。因為只涉及掃描遍歷操作,所以時間復雜度是 O(n)。
計數排序的算法思想就是這么簡單,跟桶排序非常類似,只是桶的大小粒度不一樣。不過,為什么這個排序算法叫“計數”排序呢?“計數”的含義來自哪里呢?
想弄明白這個問題,我們就要來看計數排序算法的實現方法。我還拿考生那個例子來解釋。為了方便說明,我對數據規模做了簡化。假設只有 8 個考生,分數在 0 到 5 分之間。這 8 個考生的成績我們放在一個數組 A[8] 中,它們分別是:2,5,3,0,2,3,0,3。
考生的成績從 0 到 5 分,我們使用大小為 6 的數組 C[6] 表示桶,其中下標對應分數。不過,C[6] 內存儲的并不是考生,而是對應的考生個數。像我剛剛舉的那個例子,我們只需要遍歷一遍考生分數,就可以得到 C[6] 的值。
從圖中可以看出,分數為 3 分的考生有 3 個,小于 3 分的考生有 4 個,所以,成績為 3 分的考生在排序之后的有序數組 R[8] 中,會保存下標 4,5,6 的位置。
那我們如何快速計算出,每個分數的考生在有序數組中對應的存儲位置呢?這個處理方法非常巧妙,很不容易想到。
思路是這樣的:我們對 C[6] 數組順序求和,C[6] 存儲的數據就變成了下面這樣子。C[k] 里存儲小于等于分數 k 的考生個數。
有了前面的數據準備之后,現在我就要講計數排序中最復雜、最難理解的一部分了,請集中精力跟著我的思路!
我們從后到前依次掃描數組 A。比如,當掃描到 3 時,我們可以從數組 C 中取出下標為 3 的值 7,也就是說,到目前為止,包括自己在內,分數小于等于 3 的考生有 7 個,也就是說 3 是數組 R 中的第 7 個元素(也就是數組 R 中下標為 6 的位置)。當 3 放入到數組 R 中后,小于等于 3 的元素就只剩下了 6 個了,所以相應的 C[3] 要減 1,變成 6。
以此類推,當我們掃描到第 2 個分數為 3 的考生的時候,就會把它放入數組 R 中的第 6 個元素的位置(也就是下標為 5 的位置)。當我們掃描完整個數組 A 后,數組 R 內的數據就是按照分數從小到大有序排列的了。
上面的過程有點復雜,我寫成了代碼,你可以對照著看下。
// 計數排序,a 是數組,n 是數組大小。假設數組中存儲的都是非負整數。
public void countingSort(int[] a, int n) {
if (n <= 1) return;
// 查找數組中數據的范圍
int max = a[0];
for (int i = 1; i < n; ++i) {
if (max < a[i]) {
max = a[i];
}
}
int[] c = new int[max + 1]; // 申請一個計數數組 c,下標大小 [0,max]
for (int i = 0; i <= max; ++i) {
c[i] = 0;
}
// 計算每個元素的個數,放入 c 中
for (int i = 0; i < n; ++i) {
c[a[i]]++;
}
// 依次累加
for (int i = 1; i <= max; ++i) {
c[i] = c[i-1] + c[i];
}
// 臨時數組 r,存儲排序之后的結果
int[] r = new int[n];
// 計算排序的關鍵步驟,有點難理解
for (int i = n - 1; i >= 0; --i) {
int index = c[a[i]]-1;
r[index] = a[i];
c[a[i]]--;
}
// 將結果拷貝給 a 數組
for (int i = 0; i < n; ++i) {
a[i] = r[i];
}
}
這種利用另外一個數組來計數的實現方式是不是很巧妙呢?這也是為什么這種排序算法叫計數排序的原因。不過,你千萬不要死記硬背上面的排序過程,重要的是理解和會用。
我總結一下,計數排序只能用在數據范圍不大的場景中,如果數據范圍 k 比要排序的數據 n 大很多,就不適合用計數排序了。而且,計數排序只能給非負整數排序,如果要排序的數據是其他類型的,要將其在不改變相對大小的情況下,轉化為非負整數。
比如,還是拿考生這個例子。如果考生成績精確到小數后一位,我們就需要將所有的分數都先乘以 10,轉化成整數,然后再放到 9010 個桶內。再比如,如果要排序的數據中有負數,數據的范圍是 [-1000, 1000],那我們就需要先對每個數據都加 1000,轉化成非負整數。
基數排序(Radix sort)
我們再來看這樣一個排序問題。假設我們有 10 萬個手機號碼,希望將這 10 萬個手機號碼從小到大排序,你有什么比較快速的排序方法呢?
我們之前講的快排,時間復雜度可以做到 O(nlogn),還有更高效的排序算法嗎?桶排序、計數排序能派上用場嗎?手機號碼有 11 位,范圍太大,顯然不適合用這兩種排序算法。針對這個排序問題,有沒有時間復雜度是 O(n) 的算法呢?現在我就來介紹一種新的排序算法,基數排序。
剛剛這個問題里有這樣的規律:假設要比較兩個手機號碼 a,b 的大小,如果在前面幾位中,a 手機號碼已經比 b 手機號碼大了,那后面的幾位就不用看了。
借助穩定排序算法,這里有一個巧妙的實現思路。還記得我們第 11 節中,在闡述排序算法的穩定性的時候舉的訂單的例子嗎?我們這里也可以借助相同的處理思路,先按照最后一位來排序手機號碼,然后,再按照倒數第二位重新排序,以此類推,最后按照第一位重新排序。經過 11 次排序之后,手機號碼就都有序了。
手機號碼稍微有點長,畫圖比較不容易看清楚,我用字符串排序的例子,畫了一張基數排序的過程分解圖,你可以看下。
注意,這里按照每位來排序的排序算法要是穩定的,否則這個實現思路就是不正確的。因為如果是非穩定排序算法,那最后一次排序只會考慮最高位的大小順序,完全不管其他位的大小關系,那么低位的排序就完全沒有意義了。
根據每一位來排序,我們可以用剛講過的桶排序或者計數排序,它們的時間復雜度可以做到 O(n)。如果要排序的數據有 k 位,那我們就需要 k 次桶排序或者計數排序,總的時間復雜度是 O(k*n)。當 k 不大的時候,比如手機號碼排序的例子,k 最大就是 11,所以基數排序的時間復雜度就近似于 O(n)。
實際上,有時候要排序的數據并不都是等長的,比如我們排序牛津字典中的 20 萬個英文單詞,最短的只有 1 個字母,最長的我特意去查了下,有 45 個字母,中文翻譯是塵肺病。對于這種不等長的數據,基數排序還適用嗎?
實際上,我們可以把所有的單詞補齊到相同長度,位數不夠的可以在后面補“0”,因為根據ASCII 值,所有字母都大于“0”,所以補“0”不會影響到原有的大小順序。這樣就可以繼續用基數排序了。
我來總結一下,基數排序對要排序的數據是有要求的,需要可以分割出獨立的“位”來比較,而且位之間有遞進的關系,如果 a 數據的高位比 b 數據大,那剩下的低位就不用比較了。除此之外,每一位的數據范圍不能太大,要可以用線性排序算法來排序,否則,基數排序的時間復雜度就無法做到 O(n) 了。
解答開篇
今天的內容學完了。我們再回過頭來看看開篇的思考題:如何根據年齡給 100 萬用戶排序?現在思考題是不是變得非常簡單了呢?我來說一下我的解決思路。
實際上,根據年齡給 100 萬用戶排序,就類似按照成績給 50 萬考生排序。我們假設年齡的范圍最小 1 歲,最大不超過 120 歲。我們可以遍歷這 100 萬用戶,根據年齡將其劃分到這 120 個桶里,然后依次順序遍歷這 120 個桶中的元素。這樣就得到了按照年齡排序的 100 萬用戶數據。
內容小結
今天,我們學習了 3 種線性時間復雜度的排序算法,有桶排序、計數排序、基數排序。它們對要排序的數據都有比較苛刻的要求,應用不是非常廣泛。但是如果數據特征比較符合這些排序算法的要求,應用這些算法,會非常高效,線性時間復雜度可以達到 O(n)。
桶排序和計數排序的排序思想是非常相似的,都是針對范圍不大的數據,將數據劃分成不同的桶來實現排序。基數排序要求數據可以劃分成高低位,位之間有遞進關系。比較兩個數,我們只需要比較高位,高位相同的再比較低位。而且每一位的數據范圍不能太大,因為基數排序算法需要借助桶排序或者計數排序來完成每一個位的排序工作。
課后思考
我們今天講的都是針對特殊數據的排序算法。實際上,還有很多看似是排序但又不需要使用排序算法就能處理的排序問題。
假設我們現在需要對 D,a,F,B,c,A,z 這個字符串進行排序,要求將其中所有小寫字母都排在大寫字母的前面,但小寫字母內部和大寫字母內部不要求有序。比如經過排序之后為 a,c,z,D,F,B,A,這個如何來實現呢?如果字符串中存儲的不僅有大小寫字母,還有數字。要將小寫字母的放到前面,大寫字母放在最后,數字放在中間,不用排序算法,又該怎么解決呢?
經典回復
用兩個指針a、b:a指針從頭開始往后遍歷,遇到大寫字母就停下,b從后往前遍歷,遇到小寫字母就停下,交換a、b指針對應的元素;重復如上過程,直到a、b指針相交。
對于小寫字母放前面,數字放中間,大寫字母放后面,可以先將數據分為小寫字母和非小寫字母兩大類,進行如上交換后再在非小寫字母區間內分為數字和大寫字母做同樣處理
課后思考,利用桶排序思想,弄小寫,大寫,數字三個桶,遍歷一遍,都放進去,然后再從桶中取出來就行了。相當于遍歷了兩遍,復雜度O(n)