歸並排序(Merge Sort):
歸並排序是一個相當“穩定”的算法對於其它排序算法,比如希爾排序,快速排序和堆排序而言,這些算法有所謂的最好與最壞情況。而歸並排序的時間復雜度是固定的,它是怎么做到的?
兩個有序數組的合並:
首先來看歸並排序要解決的第一個問題:兩個有序的數組怎樣合成一個新的有序數組:
比如數組1{ 3,5,7,8 }數組2為{ 1,4,9,10 }:
首先那肯定是創建一個長度為8的新數組咯,然后就是分別從左到右比較兩個數組中哪一個值比較小,然后復制進新的數組中:比如我們這個例子:
{ 3,5,7,8 } { 1,4,9,10 } { }一開始新數組是空的。
然后兩個指針分別指向第一個元素,進行比較,顯然,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 }想通過快速排序來變成從小到大的排序。如果還是選擇以第一個元素為樞紐元的話,快速排序就變成選擇排序了。
所以,在實際應用中如果數據都是是隨機數據,那么選擇第一個做樞紐元並沒有什么不妥。因為這個本來就是看“人品”的。
但是,如果是對於一些比較有規律的數據,我們的“人品”可能就不會太好的。所以常見的有兩種選擇策略:
一種是使用隨機數來做選擇。呵呵,聽天由命。
另一種是取數組中的第一個,最后一個和中間一個,選擇數值介於最大和最小之間的。
這一種又叫做“三數中值分割法”。理論上,這兩種選擇策略還是可能很悲劇的。但概率要小太多了。
堆排序用文字太難看懂了,想畫一些圖來幫助理解,求各位大大推薦可以比較方便畫二叉樹的工具。