前言
推出一個新系列,《看圖輕鬆理解資料結構和演算法》,主要使用圖片來描述常見的資料結構和演算法,輕鬆閱讀並理解掌握。本系列包括各種堆、各種佇列、各種列表、各種樹、各種圖、各種排序等等幾十篇的樣子。
計數排序
計數排序(Counting Sort)演算法由 Harold H. Seward 在1954年發明,它不是一種基於元素比較的排序演算法,而是將待排序陣列元素轉化為計數陣列的索引值,從而間接使待排序陣列具有順序性。
整個過程包含三個陣列:待排序陣列A、計數陣列B和輸出陣列C。簡單來說,就是通過統計待排序陣列A中元素不同值的分佈直方圖,生成計數陣列B,然後計算計數陣列B的字首和(此步操作可以看成計算待排序陣列A中每個元素的位置資訊),最後通過逆序迴圈將元素對應賦值到輸出陣列C中,輸出陣列C即是最終排序結果。
從整個過程也可以看到使用了額外的陣列,所以它是一種以空間換時間的做法。
時間複雜度
計數排序的時間複雜度為Ο(n+k),其中n為待排序陣列長度,k為計數陣列長度(簡單情況下可以認為k是待排序陣列中最大值)。在整個計數排序過程中涉及到若干個迴圈操作,其中初始化計數陣列與計算計數陣列字首和這兩個迴圈每個最多執行(k+1)次,所以這裡時間複雜度為O(k)。而初始化輸出陣列、統計待排序陣列分佈直方圖、賦值到輸出陣列這三個迴圈每個執行n次,所以這裡的時間複雜度為O(n)。於是,整個過程所有操作的時間複雜度為Ο(n+k)。
我們知道在所有基於比較的排序演算法中,最低的時間複雜度為O(n * logn),所以可以看到計數排序的時間複雜度能夠比基於比較的排序演算法更優,但當k很大而n又較小時,計數排序的效率反而不如基於比較的排序演算法。
執行步驟
設待排序陣列為 A,計數陣列為 B,輸出陣列為 C,則計數排序的操作步驟如下:
- 如果計數陣列 B 的長度還沒有確定,那麼就先執行確定操作,其實就是尋找待排序陣列中的最小值和最大值,然後用計數陣列的所有元素用來表示最小值到最大值之間的所有值,比如最小值和最大值分別為20和30,則計數陣列長度為 30-20+1=11,於是陣列下標為0到10,分別表示20到30,即需要做一個偏移。假如計數陣列長度事先已知道則省略此步。
- 統計待排序陣列A中不同元素值的分佈直方圖,即將不同元素值出現的次數賦值到計數陣列B對應的元素上。
- 對計數陣列B執行計算字首和操作,此步操作實際上就是計算小於或等於計數陣列索引值的個數,比如 B[4]=5 表示小於等於4的元素有5個。
- 根據計數陣列B的位置資訊,通過逆序迴圈將待排序陣列中的所有元素賦值到輸出陣列C中指定位置,最終得到的輸出陣列C即是最終排序結果。
不考慮穩定性情況
嚴格的計數排序演算法一般認為具有穩定性,既不會打亂待排序陣列中值相等的元素的順序。但有時在不必考慮穩定性的情況下,我們可以簡化演算法的過程。比如我們在對單純的整數陣列排序時就可以不考慮排序的穩定性,因為一百個整數3中每個3都是相同的,不必區分哪個3要在另一個3的前面。
在不用考慮穩定性的情況下,我們只需要一個計數陣列作為輔助即可,直接統計待排序陣列的分佈直方圖,然後根據計數陣列依次賦值待排序陣列的元素即可完成排序工作。
現在假設我們有10個整陣列成一個待排序陣列A,元素分別為3,1,4,4,2,0,1,5,0,1
。假設計數陣列B長度已經確定為6,則它的索引值為0-5,剛好是待排序陣列中元素的取值範圍。
從頭到尾迴圈一遍,待排序陣列A[0]=3,對應到計數陣列B[3],則執行B[3]累加1。
接著A[1]=1,對應到B[1],則B[1]累加1。
繼續為A[2]=4,對應到B[4],則B[4]累加1。
再往下為A[3]=4,對應到B[4],則B[4]繼續累加1,此時可以看到它的值已經變為2。
類似地,把待排序陣列中剩下的其他元素都對應到計數陣列中進行累加操作,最終結果如下:
目前為止工作已經完成了一大半了,我們得到了計數陣列B,它表示的是什麼呢?其實就是待排序陣列元素出現的次數,比如B[0]=2表示0出現了2次,B[1]=3表示1出現了三次。所以最後一步就是按出現次數將值賦值回原來的陣列中。
B[0]=2,說明有,2個0,分別將其賦值到A[0]和A[1],注意其中賦值一次需要將次數減1。
接著B[1]=3,說明有3個1,以此將其賦值到A[2]、A[3]和A[4]。
同理地,將計數陣列B中剩餘的其他元素也賦值到待排序陣列A中指定的位置,最終結果如下:
此時待排序陣列A即是已完成排序的結果。
考慮穩定性情況
前面說到的是不考慮排序穩定性的情況,而我們實際使用計數排序時其實更多是需要考慮計數排序的。比如美國職業籃球聯盟(NBA)某賽季西部其中是個球隊的勝場數如下:
球隊 | 勝場 |
---|---|
火箭 | 65 |
雷霆 | 48 |
勇士 | 58 |
馬刺 | 47 |
爵士 | 48 |
開拓者 | 49 |
鵜鶘 | 48 |
森林狼 | 47 |
現在如果要使用計數排序演算法根據勝場對球隊進行排序,如果還是忽略排序的穩定性的話,那麼排序後可能會打亂原來陣列中相同值的元素。比如48勝場的有雷霆、爵士和鵜鶘三個球隊,排序前的順序是雷霆-爵士-鵜鶘,但排序後可能是爵士-鵜鶘-雷霆,這就是非穩定性表現。
那麼計數排序是如何解決穩定性問題的呢?主要就是對計數排序陣列進行字首和運算,並且引入額外的一個陣列來解決。
以上面NBA球隊為例,看一個具有穩定性的計數排序過程。一共有8個球隊,所以待排序陣列長度為8。而計數陣列B長度為待排序陣列最大值減去最小值再加1,即65-47+1=19。此外要注意到,因為取值範圍是47-65,而陣列的索引範圍是0-18,所以這裡其實是要做一個便宜的,其中差值為47。最後再初始化一個輸出陣列,長度與待排序陣列一樣。
對待排序陣列從頭到尾迴圈一遍,待排序陣列A[0]=65,對應到計數陣列B[18],則執行B[18]累加1。
接著A[1]=48,對應到B[1],則B[1]累加1。
繼續為A[2]=58,對應到B[11],則B[11]累加1。
類似地,把待排序陣列中剩下的其他元素都對應到計數陣列中進行累加操作,最終結果如下:
接下去是計算計數陣列B的字首和,前面有說過,計算字首和其實就是計算小於或等於陣列索引值的個數。B[1]=B[0]+B[1]=2+3=5,說明小於等於48的個數是5。
接著是B[2]=B[2]+B[1]=5+1=6,說明小於等於49的個數是6。
類似地,計算陣列中剩下的字首和,最終結果如下:
最後是通過逆序迴圈並根據計數陣列的資訊將待排序陣列中的元素存放到輸出陣列中,這裡計數陣列可以看成是一個定位器。
從後往前迴圈,為什麼要逆序迴圈呢?因為這樣可以保證排序的穩定性。因為A[7]=47,所以對應B[0],而B[0]=2,說明“森林狼”及其前面一共有2個球隊,那麼"森林狼"應該放到C[1]處,此外要將B[0]的值減1。
接著因為A[6]=48,所以對應B[1],而B[1]=5,說明“鵜鶘”及其前面一共有5個球隊,那麼"鵜鶘"應該放到C[4]處,此外要將B[1]的值減1。
接著因為A[5]=49,所以對應B[2],而B[2]=6,說明“開拓者”及其前面一共有6個球隊,那麼"開拓者"應該放到C[5]處,此外要將B[2]的值減1。
類似地,將待排序中剩餘的元素一個個放到輸出陣列中,這裡得到的輸出資料即是最終排好序的陣列,最終結果如下。
計數排序的侷限
- 計數排序對於有小數的情況比較力不從心,比如陣列中的元素包含了3.1415,這種情況下計數陣列就不好建立了。
- 對於陣列內的最大最小元素差值很大的情況,計數排序的代價將變得很大,同時導致效率很低。比如待排序陣列一共有50個元素,其中最大是 10000000,最小是1,那麼計數陣列長度將是 10000000,這樣做明顯有問題。
-------------推薦閱讀------------
我的開源專案彙總(機器&深度學習、NLP、網路IO、AIML、mysql協議、chatbot)
跟我交流,向我提問:
歡迎關注: