七大排序演算法的個人總結(二)
歸併排序(Merge Sort):
歸併排序是一個相當“穩定”的演算法對於其它排序演算法,比如希爾排序,快速排序和堆排序而言,這些演算法有所謂的最好與最壞情況。而歸併排序的時間複雜度是固定的,它是怎麼做到的?
兩個有序陣列的合併:
首先來看歸併排序要解決的第一個問題:兩個有序的陣列怎樣合成一個新的有序陣列:
比如陣列1{ 3,5,7,8 }陣列2為{ 1,4,9,10 }:
首先那肯定是建立一個長度為8的新陣列咯,然後就是分別從左到右比較兩個陣列中哪一個值比較小,然後複製進新的陣列中:比如我們這個例子:
{ 3,5,7,8 } { 1
然後兩個指標分別指向第一個元素,進行比較,顯然,1比3小,所以把1複製進新陣列中:
{ 3,5,7,8 } { 1,4,9,10 } { 1, }
第二個陣列的指標後移,再進行比較,這次是3比較小:
{ 3,5,7,8 } { 1,4,9,10 } { 1,3, }
同理,我們一直比較到兩個陣列中有某一個先到末尾為止,在我們的例子中,第一個陣列先用完。{ 3,5,7,8 } { 1,4,9,10 } { 1,3,4,5,7,8 }
最後把第二個陣列中的元素複製進新陣列即可。
{ 1,3,4,5,7,8,9,10 }
由於前提是這個兩個陣列都是有序的,所以這整個過程是很快的,我們可以看出,對於一對長度為N的陣列,進行合併所需要的比較次數最多為2 * N -1(這裡多謝園友@icyjiang的提醒)。
這其實就是歸併排序的最主要想法和實現,歸併排序的做法是:
將一個數組一直對半分,問題的規模就減小了,再重複進行這個過程,直到元素的個數為一個時,一個元素就相當於是排好順序的。
接下來就是合併的過程了,合併的過程如同前面的描述。一開始合成兩個元素,然後合併4個,8個這樣進行。
所以可以看到,歸併排序是“分治”演算法的一個經典運用。
我們可以通過程式碼來看看歸併演算法的實現:
public static int[] sort(int[] array, int left, int right) { if (left == right) { return new int[] { array[left] }; } int mid = (right + left) / 2; int[] l = sort(array, left, mid); int[] r = sort(array, mid + 1, right); return merge(l, r); } // 將兩個數組合併成一個 public static int[] merge(int[] l, int[] r) { int[] result = new int[l.length + r.length]; int p = 0; int lp = 0; int rp = 0; while (lp < l.length && rp < r.length) { result[p++] = l[lp] < r[rp] ? l[lp++] : r[rp++]; } while (lp < l.length) { result[p++] = l[lp++]; } while (rp < r.length) { result[p++] = r[rp++]; } return result; }
程式碼量其實也並不多,主要的工作都在合併兩個陣列上。從程式碼上看,
if (left == right) { return new int[] { array[left] }; }
這個是遞迴的基準(base case),也就是結束的條件是當元素的個數只有一個時。
int mid = (right + left) / 2; int[] l = sort(array, left, mid); int[] r = sort(array, mid + 1, right);
這一部分顯然就是分(divide),將一個大問題分成小的問題。
最後也就是治(conquer)了,將兩個子問題的解合併可以得到較大問題的解。
所以可以說,歸併排序是說明遞迴和分治演算法的經典例子。
然後就又要回到比較原始的問題了,歸併排序它為什麼會快呢?
想回答這個問題可以先想一下之前說過的提高排序速度的兩個重要的途徑:一個是減少比較次數,一個是減少交換次數。
對於歸併排序而言,我們來從之前的例子應該可以看到,兩個陣列的合併過程是線性時間的,也就是說我們每一次比較都可以確定出一個元素的位置。這是一個重要的性質。
我們來看一個可以用一個例子來體會一下假如有這樣一個數組{ 3,7,2,5,1,0,4,6 },
冒泡和選擇排序的比較次數是25次。
直接插入排序用了15次。
而歸併排序的次數是相對穩定的,由我們上面提到的比較次數的計算方法,我們的例子要合併4對長度為1的,2對長度為2的,和1對長度為4的。
歸併排序的最多的比較次數為4 * 1 + 2 * 3 + 7 = 17次。(感謝@icyjiang的提醒)
再次說明一下,這個例子依然只是為了好理解,不能作為典型例子來看。
因為元素的隨機性,直接插入排序也可能是相當悲劇的。但我們應該從中看到的是歸併排序在比較次數上的優勢。
至於在種優勢是怎麼來的,我個人不成熟的總結一下,就是儘量的讓上一次操作的結果為下一次操作服務。
我們每一次合併出來的陣列,是不是就是為下一次合併做準備的。因為兩個要合併的陣列是有序的,我們才可能高效地進行合併。
快速排序(Quick Sort):
這個演算法的霸氣程度從它的名字就可以看出來了。快速排序的應用也是非常廣的的,各種類庫都可以看到他的身影。這當然與它的“快”是有聯絡的,正所謂天下武功唯快不破。
快速排序的一個特點是,對陣列的一次遍歷,可以找到一個樞紐元(pivot)確定位置,還可以把這個陣列以這個樞紐元分成兩個部分,左邊的元素值都比樞紐元小,右邊的都比樞紐元大。我們遞迴地解決這兩個子陣列即可。
我們還是通過一個特殊的例子來看一下快速排序的原理:
我們假設有這樣一個數組{ 4,7,3,2,8,1,5 }
對於快速排序來說,第一步就是找出一個樞紐元,而對於樞紐元的尋找是對整個演算法的時間效能影響很大的,因為搞不好快速排序會退化成選擇排序那樣。
對於這個不具有代表性的例子,我們選擇的是第一個元素做為樞紐元。
pivot 4
{ 4,7,3,2,8,1,5 }
其中,紅色為左指標,藍色為右指標。一開始我們從右邊開始,找到第一個比pivot小的數。停止,然後將該值賦給左指標,同樣,左指標向右移動。
也就是說我們第一次得到的的結果是這樣的:
{ 1,7,3,2,8,1,5 }
同樣的道理,我們在左邊找到一個比pivot大的值,賦值給右指標,同時右指標左移一步。
得到的結果應該是這樣的:
{ 1,7,3,2,8,7,5 }
請注意,我們的這個移動過程的前提都是左指標不能超過右指標的前提下進行的。
這兩個過程交替進行,其實就是在對元素進行篩選。這一次得到的結果是:
{ 1,2,3,2,8,7,5 }
黃色高亮表示兩個指標重疊了,這時候我們也就找到了樞紐元的位置了,將我們的樞紐元的值插入。
也就是說,我們接下來的工作就是以這個樞紐元為分割,對左右兩個陣列進行同樣的排序工作。
來看看具體的程式碼是怎麼實現的:
public static void sort(int[] array, int start, int end) { if (start >= end) { return; } int left = start; int right = end; int temp = array[left]; while (left < right) { while (left < right && temp < array[right]) { right--; } if (left < right) { array[left] = array[right]; left++; } while (left < right && temp > array[left]) { left++; } if (left < right) { array[right] = array[left]; right--; } } array[left] = temp; sort(array, start, left - 1); sort(array, left + 1, end); }
接下來還是同樣的問題,快速排序為什麼會快呢?如果沒有足夠的強大,那不是“浪得虛名”嗎?
首先還是看看前面的例子。
首先可以比較容易感受到的就是元素的移動效率高了。比如說例子中的1,一下子就移動到了前面去。
這也是我個人的一點感受,只是覺得可以這樣理解比較高效的排序演算法的特性:
高效的排序演算法對元素的移動效率都是比較高的。
它不像冒泡,直接插入那樣,每次可能都是步進一步,而是比較快速的移動到“感覺是正確”的位置。
想想,希爾排序不就是這麼做的嗎?後面的堆排序也是這個原理。
其次,快速排序也符合我們前面說的,“讓上一個操作的結果為下一次操作服務”。
很明顯,在樞紐元左邊的元素都比樞紐元要小,右邊的都比樞紐元大。顯然,資料的範圍小了,資料的移動的準確性就高了。
但是,快速排序的一個隱患就是樞紐元的選擇,我提供的程式碼中是選第一個元素做樞紐元,這是一種很冒險的做法。
比如我們對一個數組{ 9,8,7,6,5 }想通過快速排序來變成從小到大的排序。如果還是選擇以第一個元素為樞紐元的話,快速排序就變成選擇排序了。
所以,在實際應用中如果資料都是是隨機資料,那麼選擇第一個做樞紐元並沒有什麼不妥。因為這個本來就是看“人品”的。
但是,如果是對於一些比較有規律的資料,我們的“人品”可能就不會太好的。所以常見的有兩種選擇策略:
一種是使用隨機數來做選擇。呵呵,聽天由命。
另一種是取陣列中的第一個,最後一個和中間一個,選擇數值介於最大和最小之間的。
這一種又叫做“三數中值分割法”。理論上,這兩種選擇策略還是可能很悲劇的。但概率要小太多了。
堆排序用文字太難看懂了,想畫一些圖來幫助理解,求各位大大推薦可以比較方便畫二叉樹的工具。