本篇開始學習排序演算法。排序與我們日常生活中息息相關,比如,我們要從電話簿中找到某個聯絡人首先會按照姓氏排序、買火車票會按照出發時間或者時長排序、買東西會按照銷量或者好評度排序、查詢檔案會按照修改時間排序等等。在計算機程式設計中,排序和查詢也是最基本的演算法,很多其他的演算法都是以排序演算法為基礎,在一般的資料處理或分析中,通常第一步就是進行排序,比如說二分查詢,首先要對資料進行排序。在Donald Knuth 的計算機程式設計的藝術這四卷書中,有一卷是專門介紹排序和查詢的。
排序的演算法有很多,在維基百科上有這麼一個分類,另外大家有興趣也可以直接上維基百科上看相關演算法,本文也參考了上面的內容。
首先來看比較簡單的選擇排序(Selection sort),插入排序(Insertion sort),然後在分析插入排序的特徵和缺點的基礎上,介紹在插入排序基礎上改進的希爾排序(Shell sort)。
一 選擇排序
原理:
選擇排序很簡單,他的步驟如下:
- 從左至右遍歷,找到最小(大)的元素,然後與第一個元素交換。
- 從剩餘未排序元素中繼續尋找最小(大)元素,然後與第二個元素進行交換。
- 以此類推,直到所有元素均排序完畢。
之所以稱之為選擇排序,是因為每一次遍歷未排序的序列我們總是從中選擇出最小的元素。下面是選擇排序的動畫演示:
實現:
演算法實現起來也很簡單,我們新建一個Sort泛型類,讓該型別必須實現IComparable介面,然後我們定義SelectionSort方法,方法傳入T陣列,程式碼如下:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 |
/// <summary> /// 排序演算法泛型類,要求型別實現IComparable介面 /// </summary> /// <typeparam name="T"></typeparam> public class Sort<T> where T : IComparable<T> { /// <summary> /// 選擇排序 /// </summary> /// <param name="array"></param> public static void SelectionSort(T[] array) { int n = array.Length; for (int i = 0; i < n; i++) { int min = i; //從第i+1個元素開始,找最小值 for (int j = i + 1; j < n; j++) { if (array[min].CompareTo(array[j]) > 0) min = j; } //找到之後和第i個元素交換 Swap(array, i, min); } } /// <summary> /// 元素交換 /// </summary> /// <param name="array"></param> /// <param name="i"></param> /// <param name="min"></param> private static void Swap(T[] array, int i, int min) { T temp = array[i]; array[i] = array[min]; array[min] = temp; } } |
下圖分析了選擇排序中每一次排序的過程,您可以對照圖中右邊的柱狀圖來看。
測試如下:
1 2 3 4 5 6 7 8 9 10 |
static void Main(string[] args) { Int32[] array = new Int32[] { 1, 3, 1, 4, 2, 4, 2, 3, 2, 4, 7, 6, 6, 7, 5, 5, 7, 7 }; Console.WriteLine("Before SelectionSort:"); PrintArray(array); Sort<Int32>.SelectionSort(array); Console.WriteLine("After SelectionSort:"); PrintArray(array); Console.ReadKey(); } |
輸出結果:
分析:
選擇排序的在各種初始條件下的排序效果如下:
- 選擇排序需要花費 (N – 1) + (N – 2) + … + 1 + 0 = N(N- 1) / 2 ~ N2/2次比較 和 N-1次交換操作。
- 對初始資料不敏感,不管初始的資料有沒有排好序,都需要經歷N2/2次比較,這對於一些原本排好序,或者近似排好序的序列來說並不具有優勢。在最好的情況下,即所有的排好序,需要0次交換,最差的情況,倒序,需要N-1次交換。
- 資料交換的次數較少,如果某個元素位於正確的最終位置上,則它不會被移動。在最差情況下也只需要進行N-1次資料交換,在所有的完全依靠交換去移動元素的排序方法中,選擇排序屬於比較好的一種。
二 插入排序
原理:
插入排序也是一種比較直觀的排序方式。可以以我們平常打撲克牌為例來說明,假設我們那在手上的牌都是排好序的,那麼插入排序可以理解為我們每一次將摸到的牌,和手中的牌從左到右依次進行對比,如果找到合適的位置則直接插入。具體的步驟為:
- 從第一個元素開始,該元素可以認為已經被排序
- 取出下一個元素,在已經排序的元素序列中從後向前掃描
- 如果該元素小於前面的元素(已排序),則依次與前面元素進行比較如果小於則交換,直到找到大於該元素的就則停止;
- 如果該元素大於前面的元素(已排序),則重複步驟2
- 重複步驟2~4 直到所有元素都排好序 。
下面是插入排序的動畫演示:
實現:
在Sort泛型方法中,我們新增如下方法,下面的方法和上面的定義一樣
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 |
/// <summary> /// 插入排序 /// </summary> /// <param name="array"></param> public static void InsertionSort(T[] array) { int n = array.Length; //從第二個元素開始 for (int i = 1; i < n; i++) { //從第i個元素開始,一次和前面已經排好序的i-1個元素比較,如果小於,則交換 for (int j = i; j > 0; j--) { if (array[j].CompareTo(array[j - 1]) < 0) { Swap(array, j, j - 1); } else//如果大於,則不用繼續往前比較了,因為前面的元素已經排好序,比較大的大就是教大的了。 break; } } } |
測試如下:
1 2 3 4 5 6 7 |
Int32[] array1 = new Int32[] { 1, 3, 1, 4, 2, 4, 2, 3, 2, 4, 7, 6, 6, 7, 5, 5, 7, 7 }; Console.WriteLine("Before InsertionSort:"); PrintArray(array1); Sort<Int32>.InsertionSort(array1); Console.WriteLine("After InsertionSort:"); PrintArray(array1); Console.ReadKey(); |
輸出結果:
分析:
插入排序的在各種初始條件下的排序效果如下:
1. 插入排序平均需要N2/4次比較和N2/4 次交換。在最壞的情況下需要N2/2 次比較和交換;在最好的情況下只需要N-1次比較和0次交換。
先考慮最壞情況,那就是所有的元素逆序排列,那麼第i個元素需要與前面的i-1個元素進行i-1次比較和交換,所有的加起來大概等於N(N- 1) / 2 ~ N2 / 2,在陣列隨機排列的情況下,只需要和前面一半的元素進行比較和交換,所以平均需要N2/4次比較和N2/4 次交換。
在最好的情況下,所有元素都排好序,只需要從第二個元素開始都和前面的元素比較一次即可,不需要交換,所以為N-1次比較和0次交換。
2. 插入排序中,元素交換的次數等於序列中逆序元素的對數。元素比較的次數最少為元素逆序元素的對數,最多為元素逆序的對數 加上陣列的個數減1。
3.總體來說,插入排序對於部分有序序列以及元素個數比較小的序列是一種比較有效的方式。
如上圖中,序列AEELMOTRXPS,中逆序的對數為T-R,T-P,T-S,R-P,X-S 6對。典型的部分有序佇列的特徵有:
- 陣列中每個元素離最終排好序後的位置不太遠
- 小的未排序的陣列新增到大的已排好序的陣列後面
- 陣列中只有個別元素未排好序
對於部分有序陣列,插入排序是比較有效的。當陣列中逆元素的對數越低,插入排序要比其他排序方法要高效的多。
選擇排序和插入排序的比較:
上圖展示了插入排序和選擇排序的動畫效果。圖中灰色的柱子是不用動的,黑色的是需要參與到比較中的,紅色的是參與交換的。圖中可以看出:
插入排序不會動右邊的元素,選擇排序不會動左邊的元素;由於插入排序涉及到的未觸及的元素要比插入的元素要少,涉及到的比較操作平均要比選擇排序少一半。
三 希爾排序(Shell Sort)
原理:
希爾排序也稱之為遞減增量排序,他是對插入排序的改進。在第二部插入排序中,我們知道,插入排序對於近似已排好序的序列來說,效率很高,可以達到線性排序的效率。但是插入排序效率也是比較低的,他一次只能將資料向前移一位。比如如果一個長度為N的序列,最小的元素如果恰巧在末尾,那麼使用插入排序仍需一步一步的向前移動和比較,要N-1次比較和交換。
希爾排序通過將待比較的元素劃分為幾個區域來提升插入排序的效率。這樣可以讓元素可以一次性的朝最終位置邁進一大步,然後演算法再取越來越小的步長進行排序,最後一步就是步長為1的普通的插入排序的,但是這個時候,整個序列已經是近似排好序的,所以效率高。
如下圖,我們對下面陣列進行排序的時候,首先以4為步長,這是元素分為了LMPT,EHSS,ELOX,AELR幾個序列,我們對這幾個獨立的序列進行插入排序,排序完成之後,我們減小步長繼續排序,最後直到步長為1,步長為1即為一般的插入排序,他保證了元素一定會被排序。
希爾排序的增量遞減演算法可以隨意指定,可以以N/2遞減,只要保證最後的步長為1即可。
實現:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 |
/// <summary> /// 希爾排序 /// </summary> /// <param name="array"></param> public static void ShellSort(T[] array) { int n = array.Length; int h = 1; //初始最大步長 while (h < n / 3) h = h * 3 + 1; while (h >= 1) { //從第二個元素開始 for (int i = 1; i < n; i++) { //從第i個元素開始,依次次和前面已經排好序的i-h個元素比較,如果小於,則交換 for (int j = i; j >= h; j = j - h) { if (array[j].CompareTo(array[j - h]) < 0) { Swap(array, j, j - h); } else//如果大於,則不用繼續往前比較了,因為前面的元素已經排好序,比較大的大就是教大的了。 break; } } //步長除3遞減 h = h / 3; } } |
可以看到,希爾排序的實現是在插入排序的基礎上改進的,插入排序的步長為1,每一次遞減1,希爾排序的步長為我們定義的h,然後每一次和前面的-h位置上的元素進行比較。演算法中,我們首先獲取小於N/3 的最大的步長,然後逐步長遞減至步長為1的一般的插入排序。
下面是希爾排序在各種情況下的排序動畫:
分析:
1. 希爾排序的關鍵在於步長遞減序列的確定,任何遞減至1步長的序列都可以,目前已知的比較好的序列有:
- Shell’s 序列: N/2 , N/4 , …, 1 (重複除以2);
- Hibbard’s 序列: 1, 3, 7, …, 2k – 1 ;
- Knuth’s 序列: 1, 4, 13, …, (3k – 1) / 2 ;該序列是本文程式碼中使用的序列。
- 已知最好的序列是 Sedgewick’s (Knuth的學生,Algorithems的作者)的序列: 1, 5, 19, 41, 109, ….
該序列由下面兩個表示式互動獲得:
- 1, 19, 109, 505, 2161,….., 9(4k – 2k) + 1, k = 0, 1, 2, 3,…
- 5, 41, 209, 929, 3905,…..2k+2 (2k+2 – 3 ) + 1, k = 0, 1, 2, 3, …
“比較在希爾排序中是最主要的操作,而不是交換。”用這樣步長的希爾排序比插入排序和堆排序都要快,甚至在小陣列中比快速排序還快,但是在涉及大量資料時希爾排序還是比快速排序慢。
2. 希爾排序的分析比較複雜,使用Hibbard’s 遞減步長序列的時間複雜度為O(N3/2),平均時間複雜度大約為O(N5/4) ,具體的複雜度目前仍存在爭議。
3. 實驗表明,對於中型的序列( 萬),希爾排序的時間複雜度接近最快的排序演算法的時間複雜度nlogn。
四 總結
最後總結一下本文介紹的三種排序演算法的最好最壞和平均時間複雜度。
名稱 | 最好 | 平均 | 最壞 | 記憶體佔用 | 穩定排序 |
插入排序 | n | n2 | n2 | 1 | 是 |
選擇排序 | n2 | n2 | n2 | 1 | 否 |
希爾排序 | n | nlog2n 或 n3/2 |
依賴於增量遞減序列目前最好的是 nlog2n | 1 | 否 |
希望本文對您瞭解以上三個基本的排序演算法有所幫助,後面將會介紹合併排序和快速排序。