1. 程式人生 > >【資料結構與演算法】——排序演算法篇

【資料結構與演算法】——排序演算法篇

原文連結

    由於研究生考試的需要,加上我對演算法的情有獨鍾,這段時間一直在研究演算法。跟大家分享一些我的經驗和想法:一、歡迎大家批評指正我錯誤的地方;二、歡迎大家補償自己的見解進來,我如果發現有獨到見解的評論,我會編輯新增到文章中來,並註明。希望給大家帶來好的知識分享!

    為什麼我們需要排序?存放資料就像我們在日常生活中存放東西一樣,時不時需要整理一下,你下次拿東西的時候才方便。如果你的東西是一堆亂麻,你自己找個東西估計是很費時間的。

    我什麼情況下需要排序?其實很多的情況下,是否使用排序是一個重要的策略問題。很早以前人們使用排序,多數情況下是希望能夠使用二分查詢在logn的時間內取得想要的資料。亂序的情況下,只能使用順序查詢,需要n的時間才能夠完成,平均情況下也是n/2,與logn差距太大。於是 排序+二分查詢 成為了早期程式設計師的資料管理標準配置。但是隨著演算法理論的推進。現在的情況發生了相當巨大的變化。正如《孫子兵法》中闡述的那樣,戰爭的最高境界是【不戰而屈人之兵】,那麼排序的最高境界就是【不排】:

    1.【如果僅僅是為了取得資料方便】,那麼Hash才是最佳的選擇,因為如果使用好的解決Hash衝突方法,能夠做到1+a/2 時間內取得資料,其中a為Hash表的填裝因子。這遠遠好於二分查詢帶給你的logn時間複雜度。當然,你別想在Hash表找最大值,最小值,或者最大的10個數之類的問題,如果你需要這些操作,Hash不應該是你的選擇。但通常情況下,人們存放使用者資料的時候,往往關心的是如何取出而已。這種單純的存取關係下,Hash絕對是最好的選擇!

    2.【非重複關鍵字資料】,其實現實生活中這種情況是非常多見的,例如電話號碼、身份證資料,他們往往可以成為關鍵字,而且排序往往是有意義的。如電話號碼中0826可能是某個特定的地區,身份證511可能有特定的含義,對這些資料的如果是有序的,往往可以從中取得有用的資訊。但是,這些資料真的需要排序嗎?在《程式設計珠璣》中記載了這樣一個案例,Boss需要程式設計師在一臺老機子上對1,000,000條電話號碼進行排序,要求不超過0.5秒就要完成,可用的記憶體為1M。問題來了?一來是,那個年代的老機子,1百萬條資料,0.5秒排完,即使是快速排序都不可能。二者是,1M記憶體,就算電話號碼是integer都存不下1百萬條。面對這樣的一個難題,作者是如何解決的呢?答案是根本不排序!首先,使用bit表示電話號碼,比如有個電話是 000 000 03 那麼就是第3個bit為1,相應的沒有電話號碼時為0,後面類推。其次,在讀取資料放到記憶體的過程中,電話號碼,就能夠做到已經有序了,因為 100 100 10 一定是存放在 100 100 11前面的,這樣就是天然有序的,所以完全不需要排序了!而我們的程式中,其時有非常多的情況下是非重複關鍵字的,這種情況下,是可以有更好的解決方案的,不是嗎?

    3.【大規模頻繁變更資料排序】,有時候我們存了相當大規模的資料, 而且隨時可能有新的資料插入進來,或者舊的資料需要更新值,而我們又需要這個資料集是有序的,於是我們會經常呼叫排序演算法來排序。這個大規模資料的情況下,效能往往是關鍵而敏感的,於是我們開始瘋狂的優化排序演算法。我們開始嘗試各種混合優化排序方法,以期能夠提升整個程式的效能。但是這樣的工作真的是可取的嗎?這樣的大規模資料情況下,使用 樹 這樣的結構其實往往是更加科學的選擇。因為諸如 紅黑樹 一類的高階樹形結構往往能夠在插入的過程中保持樹本身的性質,而不需要呼叫排序演算法。這種自動排序結構比優化排序演算法更能從本質上改成程式的效率。

    說了這麼多,但很多時候我們還是需要一個高效的排序演算法的,至少在我們不明確需求情況下,我們有可用的解決方案,也許以後可用找到更加特定的方案,但我們需要先將一個程式跑起來不是?那麼先來看看我們有些什麼排序演算法:

    上圖傳說是來自《大話資料結構》一書,但我沒看過,這裡借來引用特此說明。總體說來,我們有四大類排序演算法:插入、選擇、交換、合併。這是根據這些演算法的基本操作來分類的。其實這上述的演算法都是基於“比較” 的演算法,還有一類特殊的演算法是 基數排序。基於“比較”的演算法,演算法理論證明至少需要“log(n!) 上取整”次比較,而基數排序可以做到線性時間排序。但是基數排序在實踐中並沒有很好的表現,而且實現起來比較複雜,所以一直很少應用於實際程式設計。而我們這裡討論的也主要是“比較”排序。下面先來初看下這些“比較”排序的特徵:

    上表同樣據說來自《大話資料結構》,特此說明。我們可以看出,最主要的平均時間複雜度上,n²  和 nlogn 是兩個重要的分水嶺。通常n²  複雜度得排序演算法被稱為簡單排序演算法,因為通常能夠比較簡單地編寫出來。而nlogn級的排序演算法,被稱為高階排序演算法,因為通常需要一定演算法基礎的程式設計師才能夠編寫。下面我們來一一分析:

    前奏:引入一個簡單的操作函式,交換swap,功能是交換傳入的兩個值,這個簡單的操作可以方便後面的程式編碼:

  1. inlinevoid swap(int &a,int &b)  
  2. {  
  3.     a = a^b;  
  4.     b = a^b;  
  5.     a = a^b;      
  6. };  


    上面的 ^ 是 異或 操作,這個交換實現是一種不使用中間變數進行交換的hack code,娛樂性質和實用性質都有一點兒。

1.氣泡排序

  1. void bubblesort(int *arr,int n)  
  2. {  
  3.     forint i=0; i<n; ++i)  
  4.     {  
  5.         for(int j=0; j<n-1-i; ++j)  
  6.         {  
  7.             if( arr[j] > arr[j+1] )  
  8.                 swap(arr[j],arr[j+1]);  
  9.         }     
  10.     }  
  11. }  


 這個版本的氣泡排序是正統的實現方式,其實可以實現地更加簡潔好看一點兒:

  1. void bubblesort(int *arr,int n)  
  2. {  
  3.     forint i=0; i<n; ++i)  
  4.         for(int j=0; j<n-1-i; ++j)  
  5.             if( arr[j] > arr[j+1] )  
  6.                 swap(arr[j],arr[j+1]);  
  7. }  


     是不是很有層次感,是我最喜歡的風格,也許是受python的影響吧,我覺得短程式碼時,不要括號更加具有可讀性。所以在C語言程式設計時,在短程式碼情況下,我也會偶爾這樣去掉括號來增強可讀性和層次感。氣泡排序應該是大家比較熟悉的,其特點如下:

   (1)穩定的,即如果有 [...,5,5...] 這樣的序列,排完序後,這兩個5的順序一定不會改變,這在一般情況下是沒有意義的,但當 5 這個節點不僅僅是一個數值,是一個結構體或者類例項,而結構體有附帶資料的時候,這兩個節點的順序往往是有意義的,如果能夠穩定有時候是關鍵的。因為如果不穩定則可能破壞附帶資料的順序結構。

    (2)比較次數恆定,總是比較 n²/2 次,哪怕資料已經完全有序了

    (3)最好的情況下,一個數據也不用移動,氣泡排序的最好情況是: 【資料已經有序】

    (4)最壞的情況下,移動 n²/2 次資料,氣泡排序的最壞情況是:【資料是逆序的】。

      需要說明的是的 n²/2  這個結果是:1+2+3+...+n-1 = n(n-1)/2,等差數列求和得到。

2.簡單選擇排序

   不準備分析,因為我比較懶,但有可能後續會補上。

3.直接插入排序

  1. void insertsort(int *arr,int n)  
  2. {  
  3.     for(int i=1; i<n; ++i)  
  4.     {  
  5.         int temp = arr[i];  
  6.         int j = i-1;  
  7.         for(; j>=0 && temp<arr[j] ; --j)  
  8.         {   arr[j+1] = arr[j];  }  
  9.         arr[j+1] = temp;  
  10.     }     
  11. }  


直接插入排序,是一種十分有用的簡單排序演算法,由於其一些優秀的特性,在高階排序中往往會混合 直接插入排序,那麼我們就來詳細看看,直接插入排序的特點:

(1)穩定的,這點不多做解釋,參見氣泡排序的說明

(2)最好情況下,只做 n-1 次比較,不做任何移動,比如 [ 1, 2, 3, 4, 5 ] 這個序列,演算法a.檢查2 能否插入1 前==>不能;b.檢查3能否插入到2前==>不能;...以此類推,只需做完 n-1 次比較就完成排序,0次移動資料操作。直接插入排序的最好情況是【資料完全有序】

(3)最壞情況下,做 n²/2 次比較,做 n²/2 次移動資料操作,比如 [ 5, 4, 3, 2, 1 ]這個序列,4需要插入到5前,3需要插入到4,5前,...1需要插入到2,3,4,5前,同樣由等差數列求和公式,可得比較次數和移動次數都是n(n-1)/2,簡記為n²/2。直接插入排序的最好情況是【資料完全逆序】

(4)有人說直接插入排序是在序列越有序表現越好,資料越逆序表現越差,其實這種說法是錯誤的。舉個例子說明,序列a [ 6,1,2,3,4,0 ] ,資料其實已經基本有序,只是0,6的位置不對,簡單0,6交換即可得到正確序列,但插入排序會把 1,2,3,4以此插入到6前,在把0插入到1,2,3,4,6前,幾乎有2n次移動操作。可見直接插入排序要想達到高效率,要求的有序不是基本有序,而前半部分完全有序,只有尾部有部分資料無序,例如 [ 0,1,2,3,4,5,5,6,7,8,9,........,107,99,96,101] 對這樣一個只有尾部有部分資料無序,且尾部資料不會干擾到序列首部的 [0,1,2,3,4....] 的位置時,直接插入排序 是其他任何演算法都無法匹敵的。

4.希爾排序

   這是一個神奇的排序,shell排序起初的設計目的就是改進直接插入排序(另外有一種二分插入排序也是對直接插入排序的改進),因為直接插入排序在諸如 [ 6,1,2,3,4,5,0 ] 這樣的基本有序數列上表現不佳,人們設想是不是可以讓插入的步長更大一些,比如步長為3,則相當於將序列分組為  [ 6,3 ,0 ]  [1,4 ] [ 2,5 ]這樣三個子序列進行插入排序,這樣[ 6,3,0 ] 一組可以很快地變換到 [ 0,3,6 ] 於是整個序列都很快有序了。

  1. void shellsort(int *arr,int n)  
  2. {  
  3.     constint dltalen = 9;  
  4.     /*The best known sequence according to research by Marcin Ciura is 
  5.       1, 4, 10, 23, 57, 132, 301, 701, 1750.*/
  6.     int dlta[dltalen] = {1750,701,301,132,57,23,10,4,1};  
  7.     int temp;  
  8.     for(int t=0; t<dltalen; ++t)  
  9.     {  
  10.         int dk = dlta[t];  
  11.         forint i=dk; i<n; ++i)  
  12.         {     
  13.             temp = arr[i]; /*臨時存放*/
  14.             int j = i-dk;  
  15.             for( ; j>=0 && temp<arr[j]; j-=dk)/*移動位置*/
  16.             {   arr[j+dk] = arr[j]; }  
  17.             arr[j+dk] = temp;/*插入*/
  18.         }  
  19.     }  
  20. }  

希爾排序最有趣的地方在於她的步長序列選擇上,步長序列選擇的好壞直接決定了演算法的效率,這也是為什麼希爾排序效率是一個n²/2 ~nlog²n的原因,糾正一下傳說來自《大話資料結構》的表中將希爾排序記作了n²/2 ~nlogn,這是不對的,目前的理論研究證明的希爾排序最好效率是nlog²n,這個logn上的平方是不能少的,差距很大的。上面的希爾排序中使用一個特殊的序列,是Marcin Ciura釋出的研究報告中得到的目前已知最好序列,在使用這個特別的步長序列時,希爾排序的效率是nlog²n。論文的原文在:http://oeis.org/A102549,大家可以詳細研究一下。那麼希爾排序有哪些特點呢?

(1)希爾排序是不穩定的

(2)希爾排序特別適合於,大部分資料基本有序,只有少量資料無序的情況下,如 [ 6,1,2,3,4,5,0 ] 希爾排序能迅速定位到無序資料,從而迅速完成排序

(3)希爾排序的步長序列,無論如何選擇最後一個必須是1,因為希爾排序的最後一步本質上就是直接插入排序,只是通過前面的步長排序,將序列儘量調整到直接插入排序的最高效狀態。

(4)研究表明優良的步長序列選擇下,在中小規模資料排序時,希爾排序是可以快過快速排序的。因為希爾排序的最佳步長下效率是 n*logn*logn*a(非常小常數因子) ,而快速排序的效率是 n*logn*b(小常數因子),在 n 小於一定規模時,logn*a 是可能小於b的,比如 a=0.25,b=4,n = 65535;此時logn*a<4 ,b=4;當然我一直沒有看到希爾排序的確切常數因子報告,倒是隱約記得在什麼地方看到快速排序的常數因子是4,但無法確定,如果誰知道快速排序的確切常數因子,麻煩告知。

5.堆排序

    堆排序是由於其最壞情況下nlogn時間複雜度,以及o(1)的空間複雜度而被人們記住的。在資料量巨大的情況下,堆排序的效率在慢慢接近快速排序。下面先看正統的堆排序實現:

  1. void heapAdjust( int *heap, int low, int high )  
  2. {  
  3.     int temp = heap[low];  
  4.     forint i=low*2+1; i<=high; i*=2)  
  5.     {                                     /************************/
  6.         if( i<high && heap[i]<=heap[i+1] )/*         A點          */
  7.         {   ++i;        }                 /*                      */
  8.         if( heap[i] <= temp )             /*         B點          */
  9.         {   break;      }                 /*如果是建立小頂堆,只需*/
  10.         heap[low] = heap[i] ;             /*將A和B的<=改為>=即可  */
  11.         low = i;                          /************************/
  12.     }  
  13.     heap[low] = temp;  
  14. }  
  15. void heapSort( int *heap, int size )  
  16. {  
  17.     for(int i=size/2-1; i>=0; --i )  
  18.     {   heapAdjust(heap,i,size-1);}  
  19.     for(int i=size-1  ; i>0 ; --i )  
  20.     {  
  21.         swap( heap[0], heap[i] );  
  22.         heapAdjust(heap,0,i-1);  
  23.     }  
  24. }  
    程式碼由兩部分組成,heapAdjust的調整 [ low , high ] 區間內的元素滿足堆性質,程式碼正確工作的前提條件是隻有heap[low]一個元素是不滿足堆性質。heapSort 第一個for 迴圈是將 [ 0 ,size-1 ] 區間內的元素建成一個“大頂堆”。很多人不明白為什麼建堆的時候是從 size/2-1 開始,到0結束,而顯然這個時候 [ size/2, size ] 這接近一半的區間都是不滿足堆性質的。這是因為這一半的區間在開始的時候不需要滿足堆性質,因為你如果把整個堆看做一顆二叉樹,那麼這一半的區間就一定是樹葉,樹葉之間不需要滿足特定的性質,而重要的是樹葉和上層的樹