十種排序算法介紹(轉自Matrix67經過本人一些整理和刪改)

 

雖然已經對排序算法很熟悉了,但是看到這篇日誌還是忍不住花了2個鐘頭自習讀了讀並作出一些刪改。。。十分佩服作者Matrix67的耐心仔細的全面講解,詳細周到。轉來留念,造福後人。

前人V5,後人奮進!!!

 

下邊爲改後內容(原作在最後添上連接)--------------------------------------------------------------------------

因爲排序算法是最基礎的算法,介紹算法時從各種排序算法入手是最好不過的了。
    
給出n個數,怎樣將它們從小到大排序?下面一口氣講三種常用的算法,它們是最簡單的、最顯然的、最容易想到的。選擇排序(Selection Sort)是說,每次從數列中找出一個最小的數放到最前面來,再從剩下的n-1個數中選擇一個最小的,不斷做下去。插入排序(Insertion Sort)是,每次從數列中取一個還沒有取出過的數,並按照大小關係插入到已經取出的數中使得已經取出的數仍然有序。冒泡排序(Bubble Sort)分爲若干趟進行,每一趟排序從前往後比較每兩個相鄰的元素的大小(因此一趟排序要比較n-1對位置相鄰的數)並在每次發現前面的那個數比緊接它後的數大時交換位置;進行足夠多趟直到某一趟跑完後發現這一趟沒有進行任何交換操作(最壞情況下要跑n-1趟,這種情況在最小的數位於給定數列的最後面時發生)。事實上,在第一趟冒泡結束後,最後面那個數肯定是最大的了,於是第二次只需要對前面n-1個數排序,這又將把這n-1個數中最小的數放到整個數列的倒數第二個位置。這樣下去,冒泡排序第i趟結束後後面i個數都已經到位了,第i+1趟實際上只考慮前n-i個數(需要的比較次數比前面所說的n-1要小)。這相當於用數學歸納法證明了冒泡排序的正確性:實質與選擇排序相同。

    
什麼時候用什麼排序最好。當人們渴望先知道排在前面的是誰時,我們用選擇排序;當我們不斷拿到新的數並想保持已有的數始終有序時,我們用插入排序;當給出的數列已經比較有序,只需要小幅度的調整一下時,我們用冒泡排序。
    
我們來算一下最壞情況下三種算法各需要多少次比較和賦值操作。(這就省略了吧……)
    
按照漸進複雜度理論,忽略所有的常數,三種排序的最壞情況下複雜度都是一樣的:O(n^2)。但實際應用中三種排序的效率並不相同。實踐證明,插入排序是最快的(雖然最壞情況下與選擇排序相當甚至更糟),因爲每一次插入時尋找插入的位置多數情況只需要與已有數的一部分進行比較(你可能知道這還能二分)。你或許會說冒泡排序也可以在半路上完成,還沒有跑到第n-1趟就已經有序。但冒泡排序的交換操作更費時,而插入排序中找到了插入的位置後移動操作只需要用賦值就能完成(你可能知道這還能用move)。本文後面將介紹的一種算法就利用插入排序的這些優勢。
    
我們證明了,三種排序方法在最壞情況下時間複雜度都是O(n^2)。但大家想過嗎,這只是最壞情況下的。在很多時候,複雜度沒有這麼大,因爲插入和冒泡在數列已經比較有序的情況下需要的操作遠遠低於n^2次(最好情況下甚至是線性的)。拋開選擇排序不說(因爲它的複雜度是的,對於選擇排序沒有什麼的情況),我們下面探討插入排序和冒泡排序在特定數據和平均情況下的複雜度。
    
你會發現,如果把插入排序中的移動賦值操作看作是把當前取出的元素與前面取出的且比它大的數逐一交換,那插入排序和冒泡排序對數據的變動其實都是相鄰元素的交換操作。下面我們說明,若只能對數列中相鄰的數進行交換操作,如何計算使得n個數變得有序最少需要的交換次數。
    
我們定義逆序對的概念(省略了吧……)。我們發現,交換兩個相鄰的數最多消除一個逆序對,且冒泡排序(或插入排序)中的一次交換恰好能消除一個逆序對。那麼顯然,原數列中有多少個逆序對冒泡排序(或插入排序)就需要多少次交換操作,這個操作次數不可能再少。
    
若給出的n個數中有m個逆序對,插入排序的時間複雜度可以說是O(m+n)的,而冒泡排序不能這麼說,因爲冒泡排序有很多無用的比較(比較後沒有交換),這些無用的比較超過了O(m+n)個。從這個意義上說,插入排序仍然更爲優秀,因爲冒泡排序的複雜度要受到它跑的趟數的制約。一個典型的例子是這樣的數列:8, 2, 3, 4, 5, 6, 7, 1。在這樣的輸入數據下插入排序的優勢非常明顯,冒泡排序只能哭着喊上天不公。
    
然而,我們並不想計算排序算法對於某個特定數據的效率。我們真正關心的是,對於所有可能出現的數據,算法的平均複雜度是多少。不用激動了,平均複雜度並不會低於平方。下面證明,兩種算法的平均複雜度仍然是O(n^2)的。
    
我們僅僅證明算法需要的交換次數平均爲O(n^2)就足夠了。前面已經說過,它們需要的交換次數與逆序對的個數相同。我們將證明,n個數的數列中逆序對個數平均O(n^2)個。
    
計算的方法是十分巧妙的。如果把給出的數列反過來(從後往前倒過來寫),你會發現原來的逆序對現在變成順序的了,而原來所有的非逆序對現在都成逆序了。正反兩個數列的逆序對個數加起來正好就是數列所有數對的個數,它等於n(n-1)/2。於是,平均每個數列有n(n-1)/4個逆序對。忽略常數,逆序對平均個數O(n^2)
    
上面的討論啓示我們,要想搞出一個複雜度低於平方級別的排序算法,我們需要想辦法能把離得老遠的兩個數進行操作。
    
後來,英雄出現了,Donald Shell發明了一種新的算法,我們將證明它的複雜度最壞情況下也沒有O(n^2) (似乎有人不喜歡研究正確性和複雜度的證明,我會用實例告訴大家,這些證明是非常有意思的)。他把這種算法叫做Shell增量排序算法(大家常說的希爾排序)。
    Shell
排序算法依賴一種稱之爲排序增量的數列,不同的增量將導致不同的效率。假如我們對20個數進行排序,使用的增量爲1,3,7。那麼,我們首先對這20個數進行“7-排序”(7-sortedness)。所謂7-排序,就是按照位置除以7的餘數分組進行排序。具體地說,我們將把在1815三個位置上的數進行排序,將第2916個數進行排序,依此類推。這樣,對於任意一個數字k,單看A(k), A(k+7), A(k+14), ...這些數是有序的。7-排序後,我們接着又進行一趟3-排序(別忘了我們使用的排序增量爲1,3,7)。最後進行1-排序(即普通的排序)後整個Shell算法完成。看看我們的例子:


  
1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0  <-- 原數列位置標記
  3 7 9 0 5 1 6 8 4 2 0 6 1 5 7 3 4 9 8 2  <-- 原數列
  3 3 2 0 5 1 5 7 4 4 0 6 1 6 8 7 9 9 8 2  <-- 7-
排序後
  0 0 1 1 2 2 3 3 4 4 5 6 5 6 8 7 7 9 8 9  <-- 3-
排序後
  0 0 1 1 2 2 3 3 4 4 5 5 6 6 7 7 8 8 9 9  <-- 1-
排序後(完成)

    
在每一趟、每一組的排序中我們總是使用插入排序。仔細觀察上面的例子你會發現是什麼導致了Shell排序的高效。對,每一趟排序將使得數列部分有序,從而使得以後的插入排序很快找到插入位置。我們下面將緊緊圍繞這一點來證明Shell排序算法的時間複雜度上界。
    
只要排序增量的第一個數是1Shell排序算法就是正確的。但是不同的增量將導致不同的時間複雜度。我們上面例子中的增量(1, 3, 7, 15, 31, ..., 2^k-1)是使用最廣泛的增量序列之一,可以證明使用這個增量的時間複雜度爲O(n√n)。這個證明很簡單,大家可以參看一些其它的資料,我們今天不證明它。今天我們證明,使用增量1, 2, 3, 4, 6, 8, 9, 12, 16, ..., 2^p*3^q,時間複雜度爲O(n*(log n)^2)
    
很顯然,任何一個大於1的正整數都可以表示爲2x+3y,其中xy是非負整數。於是,如果一個數列已經是2-排序的且是3-排序的,那麼對於此時數列中的每一個數A(i),它的左邊比它大的只有可能是A(i-1)A2絕對不可能比A12大,因爲10可以表示爲兩個2和兩個3的和,則A2<A4<A6<A9<A12。那麼,在這個增量中的1-排序時每個數找插入位置只需要比較一次。一共有n個數,所以1-排序是O(n)的。事實上,這個增量中的2-排序也是O(n),因爲在2-排序之前,這個數列已經是4-排序且6-排序過的,只看數列的奇數項或者偶數項(即單看每一組)的話就又成了剛纔的樣子。這個增量序列巧妙就巧妙在,如果我們要進行h-排序,那麼它一定是2h-排序過且3h-排序過,於是處理每個數A(i)的插入時就只需要和A(i-h)進行比較。這個結論對於最開始幾次(h值較大時)的h-排序同樣成立,當2h3h大於n時,按照定義,我們也可以認爲數列是2h-排序和3h-排序的,這並不影響上述結論的正確性(你也可以認爲h太大以致於排序時每一組裏的數字不超過3個,屬於常數級)。現在,這個增量中的每一趟排序都是O(n)的,我們只需要數一下一共跑了多少趟。也就是說,我們現在只需要知道小於n的數中有多少個數具有2^p*3^q的形式。要想2^p*3^q不超過np的取值最多O(log n)個,q的取值最多也是O(log n)個,兩兩組合的話共有O(logn*logn)種情況。於是,這樣的增量排序需要跑O((log n)^2)趟,每一趟的複雜度O(n),總的複雜度爲O(n*(log n)^2)。早就說過了,證明時間複雜度其實很有意思。
    
我們自然會想,有沒有能使複雜度降到O(nlogn)甚至更低的增量序列。很遺憾,現在沒有任何跡象表明存在O(nlogn)的增量排序。但事實上,很多時候Shell排序的實際效率超過了O(nlogn)的排序算法。

    
後面我們將介紹三種O(nlogn)的排序算法和三種線性時間的排序算法。最後我們將以外部排序和排序網絡結束這一章節。

對於O(nlogn)的排序算法,我們詳細介紹歸併排序並證明歸併排序的時間複雜度,然後簡單介紹堆排序,之後給出快速排序的基本思想和複雜度證明。最後我們將證明,O(nlogn)在理論上已經達到了最優。
    
首先考慮一個簡單的問題:如何在線性的時間內將兩個有序隊列合併爲一個有序隊列(並輸出)?

A
隊列:1 3 5 7 9
B
隊列:1 2 7 8 9

    
看上面的例子,AB兩個序列都是已經有序的了。在給出數據已經有序的情況下,我們會發現很多神奇的事,比如,我們將要輸出的第一個數一定來自於這兩個序列各自最前面的那個數。兩個數都是1,那麼我們隨便取出一個(比如A隊列的那個1)並輸出:

A隊列:1 3 5 7 9
B
隊列:1 2 7 8 9
輸出:1

    
注意,我們取出了一個數,在原數列中刪除這個數。刪除操作是通過移動隊首指針實現的,否則複雜度就高了。
    
現在,A隊列打頭的數變成3了,B隊列的隊首仍然是1。此時,我們再比較31哪個大並輸出小的那個數:

A隊列:1 3 5 7 9
B
隊列:1 2 7 8 9
輸出:1 1

    
接下來的幾步如下:

A隊列:1 3 5 7 9         A隊列:1 3 5 7 9         A隊列:1 3 5 7 9          A隊列:1 3 5 7 9
B
隊列:1 2 7 8 9   ==>   B隊列:1 2 7 8 9   ==>   B隊列:1 2 7 8 9    ==>   B隊列:1 2 7 8 9     ……
輸出:1 1 2              輸出:1 1 2 3            輸出:1 1 2 3 5           輸出:1 1 2 3 5 7

    
我希望你明白了這是怎麼做的。這個做法顯然是正確的,複雜度顯然是線性。

    
歸併排序(Merge Sort)將會用到上面所說的合併操作。給出一個數列,歸併排序利用合併操作在O(nlogn)的時間內將數列從小到大排序。歸併排序用的是分治(Divide and Conquer)的思想。首先我們把給出的數列平分爲左右兩段,然後對兩段數列分別進行排序,最後用剛纔的合併算法把這兩段(已經排過序的)數列合併爲一個數列。有人會問對左右兩段數列分別排序時用的什麼排序麼?答案是:用歸併排序。也就是說,我們遞歸地把每一段數列又分成兩段進行上述操作。你不需要關心實際上是怎麼操作的,我們的程序代碼將遞歸調用該過程直到數列不能再分(只有一個數)爲止。
    
初看這個算法時有人會誤以爲時間複雜度相當高。我們下面給出的一個圖將用非遞歸的眼光來看歸併排序的實際操作過程,供大家參考。我們可以藉助這個圖證明,歸併排序算法的時間複雜度爲O(nlogn)

[3] [1] [4] [1] [5] [9] [2] [7]
  / /     / /     / /     / /
[1 3]   [1 4]   [5 9]   [2 7]
     /   /           /   /
   [1 1 3 4]       [2 5 7 9]
           /       /
       [1 1 2 3 4 5 7 9]


    
上圖中的每一個“ / / ”表示的是上文所述的線性時間合併操作。上圖用了4行來圖解歸併排序。如果有n個數,表示成上圖顯然需要O(logn)行。每一行的合併操作複雜度總和都是O(n),那麼logn行的總複雜度爲O(nlogn)。這相當於用遞歸樹的方法對歸併排序的複雜度進行了分析。假設,歸併排序的複雜度爲T(n)T(n)由兩個T(n/2)和一個關於n的線性時間組成,那麼T(n)=2*T(n/2)+O(n)。不斷展開這個式子我們可以同樣可以得到T(n)=O(nlogn)的結論,你可以自己試試。如果你能在線性的時間裏把分別計算出的兩組不同數據的結果合併在一起,根據T(n)=2*T(n/2)+O(n)=O(nlogn),那麼我們就可以構造O(nlogn)的分治算法。這個結論後面經常用。我們將在計算幾何部分舉一大堆類似的例子。
    
如果你第一次見到這麼詭異的算法,你可能會對這個感興趣。分治是遞歸的一種應用。這是我們第一次接觸遞歸運算。下面說的快速排序也是用的遞歸的思想。遞歸程序的複雜度分析通常和上面一樣,主定理(Master Theory)可以簡化這個分析過程。主定理和本文內容離得太遠,我們以後也不會用它,因此我們不介紹它,大家可以自己去查。有個名詞在這裏的話找學習資料將變得非常容易,我最怕的就是一個東西不知道叫什麼名字,半天找不到資料。

    
歸併排序有一個有趣的副產品。利用歸併排序能夠在O(nlogn)的時間裏計算出給定序列裏逆序對的個數。你可以用任何一種平衡二叉樹來完成這個操作,但用歸併排序統計逆序對更方便。我們討論逆序對一般是說的一個排列中的逆序對,因此這裏我們假設所有數不相同。假如我們想要數1, 6, 3, 2, 5, 4中有多少個逆序對,我們首先把這個數列分爲左右兩段。那麼一個逆序對只可能有三種情況:兩個數都在左邊,兩個數都在右邊,一個在左一個在右。在左右兩段分別處理完後,線性合併的過程中我們可以順便算出所有第三種情況的逆序對有多少個。換句話說,我們能在線性的時間裏統計出A隊列的某個數比B隊列的某個數大有多少種情況。

A隊列:1 3 6         A隊列:1 3 6         A隊列:1 3 6         A隊列:1 3 6         A隊列:1 3 6
B
隊列:2 4 5   ==>   B隊列:2 4 5   ==>   B隊列:2 4 5   ==>   B隊列:2 4 5   ==>   B隊列:2 4 5   ……
輸出:               輸出:1              輸出:1 2            輸出:1 2 3          輸出:1 2 3 4

    
每一次從B隊列取出一個數時,我們就知道了在A隊列中有多少個數比B隊列的這個數大,它等於A隊列現在還剩的數的個數。比如,當我們從B隊列中取出2時,我們同時知道了A隊列的36兩個數比2大。在合併操作中我們不斷更新A隊列中還剩幾個數,在每次從B隊列中取出一個數時把當前A隊列剩的數目加進最終答案裏。這樣我們算出了所有大的數在前一半,小的數在後一半的情況,其餘情況下的逆序對在這之前已經被遞歸地算過了。

    
堆排序(Heap Sort)利用了堆(Heap)這種數據結構。堆的插入操作是平均常數的,而刪除一個根節點需要花費O(log n)的時間。因此,完成堆排序需要線性時間建立堆(把所有元素依次插入一個堆),然後用總共O(nlogn)的時間不斷取出最小的那個數。只要堆會搞,堆排序就會搞。

 

快速排序(Quick Sort)也應用了遞歸的思想。我們想要把給定序列分成兩段,並對這兩段分別進行排序。一種不錯的想法是,選取一個數作爲關鍵字,並把其它數分割爲兩部分,把所有小於關鍵字的數都放在關鍵字的左邊,大於關鍵字的都放在右邊,然後遞歸地對左邊和右邊進行排序。把該區間內的所有數依次與關鍵字比較,我們就可以在線性的時間裏完成分割的操作。完成分割操作有很多有技巧性的實現方法,比如最常用的一種是定義兩個指針,一個從前往後找找到比關鍵字大的,一個從後往前找到比關鍵字小的,然後兩個指針對應的元素交換位置並繼續移動指針重複剛纔的過程。這只是大致的方法,具體的實現還有很多細節問題。
    
不像歸併排序,快速排序的時間複雜度很難計算。我們可以看到,歸併排序的複雜度最壞情況下也是O(nlogn)的,而快速排序的最壞情況是O(n^2)的。如果每一次選的關鍵字都是當前區間裏最大(或最小)的數,那麼這樣將使得每一次的規模只減小一個數,這和插入排序、選擇排序等平方級排序沒有區別。這種情況不是不可能發生。如果你每次選擇關鍵字都是選擇的該區間的第一個數,而給你的數據恰好又是已經有序的,那你的快速排序就完蛋了。顯然,最好情況是每一次選的數正好就是中位數,這將把該區間平分爲兩段,複雜度和前面討論的歸併排序一模一樣。根據這一點,快速排序有一些常用的優化。比如,我們經常從數列中隨機取一個數當作是關鍵字(而不是每次總是取固定位置上的數),從而儘可能避免某些特殊的數據所導致的低效。更好的做法是隨機取三個數並選擇這三個數的中位數作爲關鍵字。而對三個數的隨機取值反而將花費更多的時間,因此我們的這三個數可以分別取數列的頭一個數、末一個數和正中間那個數。另外,當遞歸到了一定深度發現當前區間裏的數只有幾個或十幾個時,繼續遞歸下去反而費時,不如返回插入排序後的結果。這種方法同時避免了當數字太少時遞歸操作出錯的可能。
    
下面我們證明,快速排序算法的平均複雜度爲O(nlogn)。不同的書上有不同的解釋方法,這裏我選用算法導論上的講法。它更有技巧性一些,更有趣一些,需要轉幾個彎才能想明白。
    
看一看快速排序的代碼。正如我們提到過的那種分割方法,程序在經過若干次與關鍵字的比較後才進行一次交換,因此比較的次數比交換次數更多。我們通過證明一次快速排序中元素之間的比較次數平均爲O(nlogn)來說明快速排序算法的平均複雜度。證明的關鍵在於,我們需要算出某兩個元素在整個算法過程中進行過比較的概率。
    
我們舉一個例子。假如給出了11010個數,第一次選擇關鍵字7將它們分成了{1,2,3,4,5,6}{8,9,10}兩部分,遞歸左邊時我們選擇了3作爲關鍵字,使得左部分又被分割爲{1,2}{4,5,6}。我們看到,數字7與其它所有數都比較過一次,這樣才能實現分割操作。同樣地,166個數都需要與3進行一次比較(除了它本身之外)。然而,39決不可能相互比較過,26也不可能進行過比較,因爲第一次出現在3926之間的關鍵字把它們分割開了。也就是說,兩個數A(i)A(j)比較過,當且僅當第一個滿足A(i)<=x<=A(j)的關鍵字x恰好就是A(i)A(j) (假設A(i)A(j)小)。我們稱排序後第i小的數爲Z(i),假設i<j,那麼第一次出現在Z(i)Z(j)之間的關鍵字恰好就是Z(i)Z(j)的概率爲2/(j-i+1),這是因爲當Z(i)Z(j)之間還不曾有過關鍵字時,Z(i)Z(j)處於同一個待分割的區間,不管這個區間有多大,不管遞歸到哪裏了,關鍵字的選擇總是隨機的。我們得到,Z(i)Z(j)在一次快速排序中曾經比較過的概率爲2/(j-i+1)
    
現在有四個數,2,3,5,7。排序時,相鄰的兩個數肯定都被比較過,2537都有2/3的概率被比較過,27之間被比較過有2/4的可能。也就是說,如果對這四個數做12次快速排序,那麼233557之間一共比較了12*3=36次,2537之間總共比較了8*2=16次,27之間平均比較了6次。那麼,12次排序中總的比較次數期望值爲36+16+6=58。我們可以計算出單次的快速排序平均比較了多少次:58/12=29/6。其實,它就等於6項概率之和,1+1+1+2/3+2/3+2/4=29/6。這其實是與期望值相關的一個公式。
    
同樣地,如果有n個數,那麼快速排序平均需要的比較次數可以寫成下面的式子。令k=j-i,我們能夠最終得到比較次數的期望值爲O(nlogn)
   
    
這裏用到了一個知識:1+1/2+1/3+...+1/nlog n增長速度相同,即Σ(1/n)=Θ(log n)。它的證明放在本文的最後。
    
在三種O(nlogn)的排序算法中,快速排序的理論複雜度最不理想,除了它以外今天說的另外兩種算法都是以最壞情況O(nlogn)的複雜度進行排序。但實踐上看快速排序效率最高(不然爲啥叫快速排序呢),原因在於快速排序的代碼比其它同複雜度的算法更簡潔,常數時間更小。
    
快速排序也有一個有趣的副產品:快速選擇給出的一些數中第k小的數。一種簡單的方法是使用上述任一種O(nlogn)的算法對這些數進行排序並返回排序後數組的第k個元素。快速選擇(Quick Select)算法可以在平均O(n)的時間完成這一操作。它的最壞情況同快速排序一樣,也是O(n^2)。在每一次分割後,我們都可以知道比關鍵字小的數有多少個,從而確定了關鍵字在所有數中是第幾小的。我們假設關鍵字是第m小。如果k=m,那麼我們就找到了答案——k小元素即該關鍵字。否則,我們遞歸地計算左邊或者右邊:當k<m時,我們遞歸地尋找左邊的元素中第k小的;當k>m時,我們遞歸地尋找右邊的元素中第k-m小的數。由於我們不考慮所有的數的順序,只需要遞歸其中的一邊,因此複雜度大大降低。複雜度平均線性,我們不再具體證了。
    
還有一種算法可以在最壞O(n)的時間裏找出第k小元素。那是我見過的所有算法中最沒有實用價值的算法。那個O(n)只有理論價值。

    
我們前面證明過,僅僅依靠交換相鄰元素的操作,複雜度只能達到O(n^2)。於是,人們嘗試交換距離更遠的元素。當人們發現O(nlogn)的排序算法似乎已經是極限的時候,又是什麼制約了複雜度的下界呢?我們將要討論的是更底層的東西。我們仍然假設所有的數都不相等。
    
我們總是不斷在數與數之間進行比較。你可以試試,只用4次比較絕對不可能給4個數排出順序。每多進行一次比較我們就又多知道了一個大小關係,從4次比較中一共可以獲知4個大小關係。4個大小關係共有2^4=16種組合方式,而4個數的順序一共有4!=24種。也就是說,4次比較可能出現的結果數目不足以區分24種可能的順序。更一般地,給你n個數叫你排序,可能的答案共有n!個,k次比較只能區分2^k種可能,於是只有2^k>=n!時纔有可能排出順序。等號兩邊取對數,於是,給n個數排序至少需要log2(n!)次。注意,我們並沒有說明一定能通過log2(n!)次比較排出順序。雖然2^5=32超過了4!,但這不足以說明5次比較一定足夠。如何用5次比較確定4個數的大小關係還需要進一步研究。第一次例外發生在n=12的時候,雖然2^29>12!,但現已證明給12個數排序最少需要30次比較。我們可以證明log(n!)的增長速度與nlogn相同,即log(n!)=Θ(nlogn)。這是排序所需要的最少的比較次數,它給出了排序複雜度的一個下界。log(n!)=Θ(nlogn)的證明也附在本文最後。
    
這篇日誌的第三題中證明log2(N)是最優時用到了幾乎相同的方法。那種用天平稱出重量不同的那個球至少要稱幾次一類題目也可以用這種方法來解決。事實上,這裏有一整套的理論,它叫做信息論。信息論是由香農(Shannon)提出的。他用對數來表示信息量,用熵來表示可能的情況的隨機性,通過運算可以知道你目前得到的信息能夠怎樣影響最終結果的確定。如果我們的信息量是以2爲底的,那信息論就變成信息學了。從根本上說,計算機的一切信息就是以2爲底的信息量(bits=binary digits),因此我們常說香農是數字通信之父。信息論和熱力學關係密切,比如熵的概念是直接從熱力學的熵定義引申過來的。和這個有關的東西已經嚴重偏題了,這裏不說了,有興趣可以去看《信息論與編碼理論》。我對這個也很有興趣,半懂不懂的,很想了解更多的東西,有興趣的同志不妨加入討論。物理學真的很神奇,利用物理學可以解決很多純數學問題,我有時間的話可以舉一些例子。我他媽的爲啥要選文科呢。
    
後面將介紹的三種排序是線性時間複雜度,因爲,它們排序時根本不是通過互相比較來確定大小關係的。

1Σ(1/n)=Θ(log n)的證明
    
首先我們證明,Σ(1/n)=O(log n)。在式子1+1/2+1/3+1/4+1/5+...中,我們把1/3變成1/2,使得兩個1/2加起來湊成一個1;再把1/5,1/61/7全部變成1/4,這樣四個1/4加起來又是一個1。我們把所有1/2^k的後面2^k-1項全部擴大爲1/2^k,使得這2^k個分式加起來是一個1。現在,1+1/2+...+1/n裏面產生了幾個1呢?我們只需要看小於n的數有多少個2的冪即可。顯然,經過數的擴大後原式各項總和爲log nO(logn)Σ(1/n)的複雜度上界。
    
然後我們證明,Σ(1/n)=Ω(log n)。在式子1+1/2+1/3+1/4+1/5+...中,我們把1/3變成1/4,使得兩個1/4加起來湊成一個1/2;再把1/5,1/61/7全部變成1/8,這樣四個1/8加起來又是一個1/2。我們把所有1/2^k的前面2^k-1項全部縮小爲1/2^k,使得這2^k個分式加起來是一個1/2。現在,1+1/2+...+1/n裏面產生了幾個1/2呢?我們只需要看小於n的數有多少個2的冪即可。顯然,經過數的縮小後原式各項總和爲1/2*lognΩ(logn)Σ(1/n)的複雜度下界。

2log(n!)=Θ(nlogn)的證明
    
首先我們證明,log(n!)=O(nlogn)。顯然n!<n^n,兩邊取對數我們得到log(n!)<log(n^n),而log(n^n)就等於nlogn。因此,O(nlogn)log(n!)的複雜度上界。
    
然後我們證明,log(n!)=Ω(nlogn)n!=n(n-1)(n-2)(n-3)....1,把前面一半的因子全部縮小到n/2,後面一半因子全部捨去,顯然有n!>(n/2)^(n/2)。兩邊取對數,log(n!)>(n/2)log(n/2),後者即Ω(nlogn)。因此,Ω(nlogn)log(n!)的複雜度下界。

 

    那麼,有什麼方法可以不用比較就能排出順序呢?藉助Hash表的思想,多數人都能想出這樣一種排序算法來。
    
我們假設給出的數字都在一定範圍中,那麼我們就可以開一個範圍相同的數組,記錄這個數字是否出現過。由於數字有可能有重複,因此Hash表的概念需要擴展,我們需要把數組類型改成整型,用來表示每個數出現的次數。
    
看這樣一個例子,假如我們要對數列3 1 4 1 5 9 2 6 5 3 5 9進行排序。由於給定數字每一個都小於10,因此我們開一個09的整型數組T[i],記錄每一個數出現了幾次。讀到一個數字x,就把對應的T[x]加一。

  A[]= 3, 1, 4, 1, 5, 9, 2, 6, 5, 3, 5, 9
               +---+---+---+---+---+---+---+---+---+---+
      
數字 i | 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 |
               +---+---+---+---+---+---+---+---+---+---+
出現次數T[i] | 0 | 2 | 1 | 2 | 1 | 3 | 1 | 0 | 0 | 2 |
               +---+---+---+---+---+---+---+---+---+---+


    
最後,我們用一個指針從前往後掃描一遍,按照次序輸出09,每個數出現了幾次就輸出幾個。假如給定的數是n個大小不超過m的自然數,顯然這個算法的複雜度是O(m+n)的。

    
我曾經以爲,這就是線性時間排序了。後來我發現我錯了。再後來,我發現我曾犯的錯誤是一個普遍的錯誤。很多人都以爲上面的這個算法就是傳說中的計數排序。問題出在哪裏了?爲什麼它不是線性時間的排序算法?原因是,這個算法根本不是排序算法,它根本沒有對原數據進行排序。

問題一:爲什麼說上述算法沒有對數據進行排序?
STOP! You should think for a while.

    
我們班有很多MM。和身高相差太遠的MM在一起肯定很彆扭,接個吻都要彎腰才行。爲此,我希望給我們班的MM的身高排序。我們班MM的身高,再離譜也沒有超過2的,這很適合用我們剛纔的算法。我們在黑板上畫一個100200的數組,MM依次自曝身高,我負責畫字統計人數。統計出來了,從小到大依次爲141, 143, 143, 147, 152, 153, ...。這算哪門子排序?就一排數字對我有什麼用,我要知道的是哪個MM有多高。我們僅僅把元素的屬性值從小到大列了出來,但我們沒有對元素本身進行排序。也就是說,我們需要知道輸出結果的每個數值對應原數據的哪一個元素。下文提到的排序算法的穩定性也和屬性值與實際元素的區別有關。

問題二:怎樣將線性時間排序後的輸出結果還原爲原數據中的元素?
STOP! You should think for a while.

    
同樣藉助Hash表的思想,我們立即想到了類似於開散列的方法。我們用鏈表把屬性值相同的元素串起來,掛在對應的T[i]上。每次讀到一個數,在增加T[i]的同時我們把這個元素放進T[i]延伸出去的鏈表裏。這樣,輸出結果時我們可以方便地獲得原數據中的所有屬性值爲i的元素。

  A[]= 3, 1, 4, 1, 5, 9, 2, 6, 5, 3, 5, 9
               +---+---+---+---+---+---+---+---+---+---+
      
數字 i | 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 |
               +---+---+---+---+---+---+---+---+---+---+
出現次數T[i] | 0 | 2 | 1 | 2 | 1 | 3 | 1 | 0 | 0 | 2 |
               +---+o--+-o-+-o-+-o-+-o-+--o+---+---+-o-+
                    |    |   |   |   |    |          |
                 +--+  +-+   |   |   +-+  +---+      |
                 |     |   A[1]  |     |      |     A[6]
               A[2]  A[7]    |  A[3]  A[5]   A[8]    |
                 |           |         |            A[12]
               A[4]        A[10]      A[9]
                                       |
                                      A[11]


    
形象地說,我們在地上擺10個桶,每個桶編一個號,然後把數據分門別類放在自己所屬的桶裏。這種排序算法叫做桶式排序(Bucket Sort)。本文最後你將看到桶式排序的另一個用途。
    
鏈表寫起來比較麻煩,一般我們不使用它。我們有更簡單的方法。

問題三:同樣是輸出元素本身,你能想出不用鏈表的其它算法麼?
STOP! You should think for a while.

  A[]= 3, 1, 4, 1, 5, 9, 2, 6, 5, 3, 5, 9
               +---+---+---+---+---+---+---+---+---+---+
      
數字 i | 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 |
               +---+---+---+---+---+---+---+---+---+---+
出現次數T[i] | 0 | 2 | 1 | 2 | 1 | 3 | 1 | 0 | 0 | 2 |
               +---+---+---+---+---+---+---+---+---+---+
修改後的T[i] | 0 | 2 | 3 | 5 | 6 | 9 | 10| 10| 10| 12|
               +---+---+---+---+---+---+---+---+---+---+


    
所有數都讀入後,我們修改T[i]數組的值,使得T[i]表示數字i可能的排名的最大值。比如,1最差排名第二,3最遠可以排到第五。T數組的最後一個數應該等於輸入數據的數字個數。修改T數組的操作可以用一次線性的掃描累加完成。
    
我們還需要準備一個輸出數組。然後,我們從後往前掃描A數組,依照T數組的指示依次把原數據的元素直接放到輸出數組中,同時T[i]的值減一。之所以從後往前掃描A數組,是因爲這樣輸出結果纔是穩定的。我們說一個排序算法是穩定的(Stable),當算法滿足這樣的性質:屬性值相同的元素,排序後前後位置不變,本來在前面的現在仍然在前面。不要覺得排序算法是否具有穩定性似乎關係不大,排序的穩定性在下文的某個問題中將變得非常重要。你可以倒回去看看前面說的七種排序算法哪些是穩定的。
    
例子中,A數組最後一個數9所對應的T[9]=12,我們直接把9放在待輸出序列中的第12個位置,然後T[9]變成11(這樣下一次再出現9時就應該放在第11位)。

A[]= 3, 1, 4, 1, 5, 9, 2, 6, 5, 3, 5, 9 <--
T[i]= 0, 2, 3, 5, 6, 9, 10, 10, 10,
 11
Ans = _ _ _ _ _ _ _ _ _ _ _ 9


    
接下來的幾步如下:

A[]= 3, 1, 4, 1, 5, 9, 2, 6, 5, 3, 5 <--
T[i]= 0, 2, 3, 5, 6,
 8, 10, 10, 10, 11
Ans = _ _ _ _ _ _ _ _ 5 _ _ 9

A[]= 3, 1, 4, 1, 5, 9, 2, 6, 5, 3 <--
T[i]= 0, 2, 3,
 4, 6, 8, 10, 10, 10, 11
Ans = _ _ _ _ 3 _ _ _ 5 _ _ 9

A[]= 3, 1, 4, 1, 5, 9, 2, 6, 5 <--
T[i]= 0, 2, 3, 4, 6,
 7, 10, 10, 10, 11
Ans = _ _ _ _ 3 _ _ 5 5 _ _ 9


    
這種算法叫做計數排序(Counting Sort)。正確性和複雜度都是顯然的。

問題四:給定數的數據範圍大了該怎麼辦?
STOP! You should think for a while.

    
前面的算法只有在數據的範圍不大時纔可行,如果給定的數在長整範圍內的話,這個算法是不可行的,因爲你開不下這麼大的數組。Radix排序(Radix Sort)解決了這個難題。
    
昨天我沒事翻了一下初中(9班)時的同學錄,回憶了一下過去。我把比較感興趣的MM的生日列在下面(絕對真實)。

·                                 19880818

·                                 19880816

·                                 19890426

·                                 19880405

·                                 19890125

·                                 19881004

·                                 19881209

·                                 19890126

·                                 19890228


    
這就是我的數據了。現在,我要給這些數排序。假如我的電腦只能開出0..99的數組,那計數排序算法最多對兩位數進行排序。我就把每個八位數兩位兩位地分成四段(圖1),分別進行四次計數排序。地球人都知道月份相同時應該看哪一日,因此我們看月份的大小時應該事先保證日已經有序。換句話說,我們先對最不重要的部分進行排序。我們先對所有數的最後兩位進行一次計數排序(圖2)。注意觀察126號的MM426號的MM,本次排序中它們的屬性值相同,由於計數排序是穩定的,因此4月份那個排完後依然在1月份那個的前頭。接下來我們對百位和千位進行排序(圖3)。你可以看到兩個26日的MM在這一次排序中分出了大小,而月份相同的MM依然保持日數有序(因爲計數排序是穩定的)。最後我們對年份排序(圖4),完成整個算法。大家都是跨世紀的好兒童,因此沒有圖5了。

      
 

    
這種算法顯然是正確的。它的複雜度一般寫成O(d*(n+m)),其中n表示n個數,m是我開的數組大小(本例中m=100),d是一個常數因子(本例中d=4)。我們認爲它也是線性的。

問題五:這樣的排序方法還有什麼致命的缺陷?
STOP! You should think for a while.

    
即使數據有30位,我們也可以用d=56Radix算法進行排序。但,要是給定的數據有無窮多位怎麼辦?有人說,這可能麼。這是可能的,比如給定的數據是小數(更準確地說,實數)。基於比較的排序可以區分355/113π哪個大,但你不知道Radix排序需要精確到哪一位。這下慘了,實數的出現把貌似高科技的線性時間排序打回了農業時代。這時,桶排序再度出山,挽救了線性時間排序悲慘的命運。

問題六:如何對實數進行線性時間排序?
STOP! You should think for a while.

    
我們把問題簡化一下,給出的所有數都是01之間的小數。如果不是,也可以把所有數同時除以一個大整數從而轉化爲這種形式。我們依然設立若干個桶,比如,以小數點後面一位數爲依據對所有數進行劃分。我們仍然用鏈表把同一類的數串在一起,不同的是,每一個鏈表都是有序的。也就是說,每一次讀到一個新的數都要進行一次插入排序。看我們的例子:

      A[]= 0.12345, 0.111, 0.618, 0.9, 0.99999
               +---+---+---+---+---+---+---+---+---+---+
      
十分位: | 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 |
               +---+-o-+---+---+---+---+-o-+---+---+-o-+
                     |                   |           |
                   A[2]
=0.111          A[3]=0.618   A[4]=0.9
                     |                               |
                   A[1]
=0.12345                     A[5]=0.99999

    
假如再下一個讀入的數是0.122222,這個數需要插入到十分位爲1的那個鏈表裏適當的位置。我們需要遍歷該鏈表直到找到第一個比0.122222大的數,在例子中則應該插入到鏈表中A[2]A[1]之間。最後,我們按順序遍歷所有鏈表,依次輸出每個鏈表中的每個數。
    
這個算法顯然是正確的,但複雜度顯然不是線性。事實上,這種算法最壞情況下是O(n^2)的,因爲當所有數的十分位都相同時算法就是一個插入排序。和原來一樣,我們下面要計算算法的平均時間複雜度,我們希望這種算法的平均複雜度是線性的。
    
這次算平均複雜度我們用最笨的辦法。我們將算出所有可能出現的情況的總時間複雜度,除以總的情況數,得到平均的複雜度是多少。
    
每個數都可能屬於10個桶中的一個,n個數總的情況有10^n種。這個值是我們龐大的算式的分母部分。如果一個桶裏有K個元素,那麼只與這個桶有關的操作有O(K^2)次,它就是一次插入排序的操作次數。下面計算,在10^n種情況中,K0=1有多少種情況。K0=1表示,n個數中只有一個數在0號桶,其餘n-1個數的十分位就只能在19中選擇。那麼K0=1的情況有C(n,1)*9^(n-1),而每個K0=1的情況在0號桶中將產生1^2的複雜度。類似地,Ki=p的情況數爲C(n,p)*9^(n-p),複雜度總計爲C(n,p)*9^(n-p)*p^2。枚舉所有K的下標和p值,累加起來,這個算式大家應該能寫出來了,但是這個……怎麼算啊。別怕,我們是搞計算機的,拿出點和MO不一樣的東西來。於是,Mathematica 5.0隆重登場,我做數學作業全靠它。它將幫我們化簡這個複雜的式子。


    
我們遺憾地發現,雖然常數因子很小(只有0.1),但算法的平均複雜度仍然是平方的。等一下,1/10的那個10是我們桶的個數嗎?那麼我們爲什麼不把桶的個數弄大點?我們乾脆用m來表示桶的個數,重新計算一次:


    
化簡出來,操作次數爲O(n+n^2/m)。發現了麼,如果m=Θ(n)的話,平均複雜度就變成了O(n)。也就是說,當桶的個數等於輸入數據的個數時,算法是平均線性的。
    
我們將在Hash表開散列的介紹中重新提到這個結論。

    
且慢,還有一個問題。10個桶以十分位的數字歸類,那麼n個桶用什麼方法來分類呢?注意,分類的方法需要滿足,一,一個數分到每個桶裏的概率相同(這樣纔有我們上面的結論);二,所有桶裏容納元素的範圍必須是連續的。根據這兩個條件,我們有辦法把所有數恰好分爲n類。我們的輸入數據不是都在01之間麼?只需要看這些數乘以n的整數部分是多少就行了,讀到一個數後乘以n取整得幾就插入到幾號桶裏。這本質上相當於把區間[0,1)平均分成n份。

問題七:有沒有複雜度低於線性的排序算法
STOP! You should think for a while.

    
我們從O(n^2)走向O(nlogn),又從O(nlogn)走向線性,每一次我們都討論了複雜度下限的問題,根據討論的結果提出了更優的算法。這次總算不行了,不可能有比線性還快的算法了,因爲——你讀入、輸出數據至少就需要線性的時間。排序算法之旅在線性時間複雜度這一站終止了,所有十種排序算法到這裏介紹完畢了。

 

原地址:

 http://www.matrix67.com/blog/archives/166

 

發佈了71 篇原創文章 · 獲贊 5 · 訪問量 19萬+
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章