常用排序算法1

十種排序算法分析(1)
今天我正式開始按照我的目錄寫我的OI心得了。我要把我所有學到的OI知識傳給以後千千萬萬的OIer。以前寫過的一些東西不重複寫了,但我最後將會重新整理,使之成爲一個完整的教程。
     按照我的目錄,講任何東西之前我都會先介紹時間複雜度的相關知識,以後動不動就會扯到這個東西。這個已經寫過了,你可以在這裏看到那篇又臭又長的文章。在講排序算法的過程中,我們將始終圍繞時間複雜度的內容進行說明。
     我把這篇文章稱之爲“從零開始學算法”,因爲排序算法是最基礎的算法,介紹算法時從各種排序算法入手是最好不過的了。

     給出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要 小)。這相當於用數學歸納法證明了冒泡排序的正確性:實質與選擇排序相同。上面的三個算法描述可能有點模糊了,沒明白的話網上找資料,代碼和動畫演示遍地 都是。

     這三種算法非常容易理解,因爲我們生活當中經常在用。比如,班上的MM搞選美活動,有人叫我給所有MM排個名。我們通常會用選擇排序,即先找出自己認爲最 漂亮的,然後找第二漂亮的,然後找第三漂亮的,不斷找剩下的人中最滿意的。打撲克牌時我們希望抓完牌後手上的牌是有序的,三個8挨在一起,後面緊接着兩個 9。這時,我們會使用插入排序,每次拿到一張牌後把它插入到手上的牌中適當的位置。什麼時候我們會用冒泡排序呢?比如,體育課上從矮到高排隊時,站隊完畢 後總會有人出來,比較挨着的兩個人的身高,指揮到:你們倆調換一下,你們倆換一下。
     這是很有啓發性的。這告訴我們,什麼時候用什麼排序最好。當人們渴望先知道排在前面的是誰時,我們用選擇排序;當我們不斷拿到新的數並想保持已有的數始終有序時,我們用插入排序;當給出的數列已經比較有序,只需要小幅度的調整一下時,我們用冒泡排序。

     我們來算一下最壞情況下三種算法各需要多少次比較和賦值操作。
     選擇排序在第i次選擇時賦值和比較都需要n-i次(在n-i+1個數中選一個出來作爲當前最小值,其餘n-i個數與當前最小值比較並不斷更新當前最小值),然後需要一次賦值操作。總共需要n(n-1)/2次比較與n(n-1)/2+n次賦值。
     插入排序在第i次尋找插入位置時需要最多i-1次比較(從後往前找到第一個比待插入的數小的數,最壞情況發生在這個數是所有已經取出的數中最小的一個的時 候),在已有數列中給新的數騰出位置需要i-1次賦值操作來實現,還需要兩次賦值藉助臨時變量把新取出的數搬進搬出。也就是說,最壞情況下比較需要n(n -1)/2次,賦值需要n(n-1)/2+2n次。我這麼寫有點誤導人,大家不要以爲程序的實現用了兩個數組哦,其實一個數組就夠了,看看上面的演示就知 道了。我只說算法,一般不寫如何實現。學算法的都是強人,知道算法了都能寫出一個漂亮的代碼來。
     冒泡排序第i趟排序需要比較n-i次,n-1趟排序總共n(n-1)/2次。給出的序列逆序排列是最壞的情況,這時每一次比較都要進行交換操作。一次交換操作需要3次賦值實現,因此冒泡排序最壞情況下需要賦值3n(n-1)/2次。
     按照漸進複雜度理論,忽略所有的常數,三種排序的最壞情況下複雜度都是一樣的:O(n^2)。但實際應用中三種排序的效率並不相同。實踐證明(政治考試時 每道大題都要用這四個字),插入排序是最快的(雖然最壞情況下與選擇排序相當甚至更糟),因爲每一次插入時尋找插入的位置多數情況只需要與已有數的一部分 進行比較(你可能知道這還能二分)。你或許會說冒泡排序也可以在半路上完成,還沒有跑到第n-1趟就已經有序。但冒泡排序的交換操作更費時,而插入排序中 找到了插入的位置後移動操作只需要用賦值就能完成(你可能知道這還能用move)。本文後面將介紹的一種算法就利用插入排序的這些優勢。

     我們證明了,三種排序方法在最壞情況下時間複雜度都是O(n^2)。但大家想過嗎,這只是最壞情況下的。在很多時候,複雜度沒有這麼大,因爲插入和冒泡在 數列已經比較有序的情況下需要的操作遠遠低於n^2次(最好情況下甚至是線性的)。拋開選擇排序不說(因爲它的複雜度是“死”的,對於選擇排序沒有什麼 “好”的情況),我們下面探討插入排序和冒泡排序在特定數據和平均情況下的複雜度。
     你會發現,如果把插入排序中的移動賦值操作看作是把當 前取出的元素與前面取出的且比它大的數逐一交換,那插入排序和冒泡排序對數據的變動其實都是相鄰元素的交換操作。下面我們說明,若只能對數列中相鄰的數進 行交換操作,如何計算使得n個數變得有序最少需要的交換次數。
     我們定義逆序對的概念。假設我們要把數列從小到大排序,一個逆序對是指的在 原數列中,左邊的某個數比右邊的大。也就是說,如果找到了某個i和j使得i<j且Ai>Aj,我們就說我們找到了一個逆序對。比如說,數列 3,1,4,2中有三個逆序對,而一個已經有序的數列逆序對個數爲0。我們發現,交換兩個相鄰的數最多消除一個逆序對,且冒泡排序(或插入排序)中的一次 交換恰好能消除一個逆序對。那麼顯然,原數列中有多少個逆序對冒泡排序(或插入排序)就需要多少次交換操作,這個操作次數不可能再少。
     若 給出的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的餘數分組進行排序。具體地 說,我們將把在1、8、15三個位置上的數進行排序,將第2、9、16個數進行排序,依此類推。這樣,對於任意一個數字k,單看A(k), A(k+7), A(k+14), …這些數是有序的。7-排序後,我們接着又進行一趟3-排序(別忘了我們使用的排序增量爲1,3,7)。最後進行1-排序(即普通的排序)後整個 Shell算法完成。看看我們的例子:

   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排序算法的時間複雜度上界。
     只要排序增量的第一個數是1,Shell排序算法就是正確的。但是不同的增量將導致不同的時間複雜度。我們上面例子中的增量(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,其中x和y是非負整數。於是,如果一個數列已經是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-排序同樣成立,當2h、3h大於n時,按照定義,我 們也可以認爲數列是2h-排序和3h-排序的,這並不影響上述結論的正確性(你也可以認爲h太大以致於排序時每一組裏的數字不超過3個,屬於常數級)。現 在,這個增量中的每一趟排序都是O(n)的,我們只需要數一下一共跑了多少趟。也就是說,我們現在只需要知道小於n的數中有多少個數具有2^p*3^q的 形式。要想2^p*3^q不超過n,p的取值最多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)的排序算法。
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章