合併排序,顧名思義,就是通過將兩個有序的序列合併為一個大的有序的序列的方式來實現排序。合併排序是一種典型的分治演算法:首先將序列分為兩部分,然後對每一部分進行迴圈遞迴的排序,然後逐個將結果進行合併。
合併排序最大的優點是它的時間複雜度為O(nlgn),這個是我們之前的選擇排序和插入排序所達不到的。他還是一種穩定性排序,也就是相等的元素在序列中的相對位置在排序前後不會發生變化。他的唯一缺點是,需要利用額外的N的空間來進行排序。
一 原理
合併排序依賴於合併操作,即將兩個已經排序的序列合併成一個序列,具體的過程如下:
- 申請空間,使其大小為兩個已經排序序列之和,然後將待排序陣列複製到該陣列中。
- 設定兩個指標,最初位置分別為兩個已經排序序列的起始位置
- 比較複製陣列中兩個指標所指向的元素,選擇相對小的元素放入到原始待排序陣列中,並移動指標到下一位置
- 重複步驟3直到某一指標達到序列尾
- 將另一序列剩下的所有元素直接複製到原始陣列末尾
該過程實現如下,註釋比較清楚:
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 |
private static void Merge(T[] array, int lo, int mid, int hi) { int i = lo, j = mid + 1; //把元素拷貝到輔助陣列中 for (int k = lo; k <= hi; k++) { aux[k] = array[k]; } //然後按照規則將資料從輔助陣列中拷貝回原始的array中 for (int k = lo; k <= hi; k++) { //如果左邊元素沒了, 直接將右邊的剩餘元素都合併到到原陣列中 if (i > mid) { array[k] = aux[j++]; }//如果右邊元素沒有了,直接將所有左邊剩餘元素都合併到原陣列中 else if (j > hi) { array[k] = aux[i++]; }//如果左邊右邊小,則將左邊的元素拷貝到原陣列中 else if (aux[i].CompareTo(aux[j]) < 0) { array[k] = aux[i++]; } else { array[k] = aux[j++]; } } } |
下圖是使用以上方法將EEGMR和ACERT這兩個有序序列合併為一個大的序列的過程演示:
二 實現
合併排序有兩種實現,一種是至上而下(Top-Down)合併,一種是至下而上 (Bottom-Up)合併,兩者演算法思想差不多,這裡僅介紹至上而下的合併排序。
至上而下的合併是一種典型的分治演算法(Divide-and-Conquer),如果兩個序列已經排好序了,那麼採用合併演算法,將這兩個序列合併為一個大的序列也就是對大的序列進行了排序。
首先我們將待排序的元素均分為左右兩個序列,然後分別對其進去排序,然後對這個排好序的序列進行合併,程式碼如下:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 |
public class MergeSort<T> where T : IComparable<T> { private static T[] aux; // 用於排序的輔助陣列 public static void Sort(T[] array) { aux = new T[array.Length]; // 僅分配一次 Sort(array, 0, array.Length - 1); } private static void Sort(T[] array, int lo, int hi) { if (lo >= hi) return; //如果下標大於上標,則返回 int mid = lo + (hi - lo) / 2;//平分陣列 Sort(array, lo, mid);//迴圈對左側元素排序 Sort(array, mid + 1, hi);//迴圈對右側元素排序 Merge(array, lo, mid, hi);//對左右排好的序列進行合併 } ... } |
以排序一個具有15個元素的陣列為例,其呼叫堆疊為:
我們單獨將Merge步驟拿出來,可以看到合併的過程如下:
三 圖示及動畫
如果以排序38,27,43,3,9,82,10為例,將合併排序畫出來的話,可以看到如下圖:
下圖是合併排序的視覺化效果圖:
對6 5 3 1 8 7 24 進行合併排序的動畫效果如下:
下圖演示了合併排序在不同的情況下的效率:
四 分析
1. 合併排序的平均時間複雜度為O(nlgn)
證明:合併排序是目前我們遇到的第一個時間複雜度不為n2的時間複雜度為nlgn(這裡lgn代表log2n)的排序演算法,下面給出對合並排序的時間複雜度分析的證明:
假設D(N)為對整個序列進行合併排序所用的時間,那麼一個合併排序又可以二分為兩個D(N/2)進行排序,再加上與N相關的比較和計算中間數所用的時間。整個合併排序可以用如下遞迴式表示:
D(N)=2D(N/2)+N,N>1;
D(N)=0,N=1; (當N=1時,陣列只有1個元素,已排好序,時間為0)
因為在分治演算法中經常會用到遞迴式,所以在CLRS中有一章專門講解遞迴式的求解和證明,使用主定理(master theorem)可以直接求解出該遞迴式的值,後面我會簡單介紹。這裡簡單的列舉兩種證明該遞迴式時間複雜度為O(nlgn)的方法:
Prof1:處於方便性考慮,我們假設陣列N為2的整數冪,這樣根據遞迴式我們可以畫出一棵樹:
可以看到我們對陣列N進行MergeSort的時候,是逐級劃分的,這樣就形成了一個滿二叉樹,樹的每一及子節點都為N,樹的深度即為層數lgN+1,滿二叉樹的深度的計算可以查閱相關資料,上圖中最後一層子節點沒有畫出來。這樣,這棵樹有lgN+1層,每一層有N個節點,所以
D(N)=(lgN+1)N=NlgN+N=NlgN
Prof2:我們在為遞迴表示式求解的時候,還有一種常用的方法就是數學歸納法,
首先根據我們的遞迴表示式的初始值以及觀察,我們猜想D(N)=NlgN.
- 當N=1 時,D(1)=0,滿足初始條件。
- 為便於推導,假設N是2的整數次冪N=2k, 即D(2k)=2klg2k = k*2k
- 在N+1 的情況下D(N+1)=D(2k+1)=2k+1lg2k+1=(k+1) * 2k+1,所以假設成立,D(N)=NlgN.
2. 合併排序需要額外的長度為N的輔助空間來完成排序
如果對長度為N的序列進行排序需要<=clogN 的額外空間,認為就是就地排序(in place排序)也就是完成該排序操作需要較小的,固定數量的額外輔助記憶體空間。之前學習過的選擇排序,插入排序,希爾排序都是原地排序。
但是在合併排序中,我們要建立一個大小為N的輔助排序陣列來存放初始的陣列或者存放合併好的陣列,所以需要長度為N的額外輔助空間。當然也有前人已經將合併排序改造為了就地合併排序,但是演算法的實現變得比較複雜。
需要額外N的空間來輔助排序是合併排序的最大缺點,如果在記憶體比較關心的環境中可能需要採用其他演算法。
五 幾點改進
對合並排序進行一些改進可以提高合併排序的效率。
1. 當劃分到較小的子序列時,通常可以使用插入排序替代合併排序
對於較小的子序列(通常序列元素個數為7個左右),我們就可以採用插入排序直接進行排序而不用繼續遞迴了),演算法改造如下:
1 2 3 4 5 6 7 8 9 10 |
private const int CUTOFF = 7;//採用插入排序的閾值 private static void Sort(T[] array, int lo, int hi) { if (lo >= hi) return; //如果下標大於上標,則返回 if (hi <= lo + CUTOFF - 1) Sort<T>.SelectionSort(array, lo, hi); int mid = lo + (hi - lo) / 2;//平分陣列 Sort(array, lo, mid);//迴圈對左側元素排序 Sort(array, mid + 1, hi);//迴圈對右側元素排序 Merge(array, lo, mid, hi);//對左右排好的序列進行合併 } |
2. 如果已經排好序了就不用合併了
當已排好序的左側的序列的最大值<=右側序列的最小值的時候,表示整個序列已經排好序了。
演算法改動如下:
1 2 3 4 5 6 7 8 9 10 |
private static void Sort(T[] array, int lo, int hi) { if (lo >= hi) return; //如果下標大於上標,則返回 if (hi <= lo + CUTOFF - 1) Sort<T>.SelectionSort(array, lo, hi); int mid = lo + (hi - lo) / 2;//平分陣列 Sort(array, lo, mid);//迴圈對左側元素排序 Sort(array, mid + 1, hi);//迴圈對右側元素排序 if (array[mid].CompareTo(array[mid + 1]) <= 0) return; Merge(array, lo, mid, hi);//對左右排好的序列進行合併 } |
3. 並行化
分治演算法通常比較容易進行並行化,在淺談併發與並行這篇文章中已經展示瞭如何對快速排序進行並行化(快速排序在下一篇文章中講解),合併排序一樣,因為我們均分的左右兩側的序列是獨立的,所以可以進行並行,值得注意的是,並行化也有一個閾值,當序列長度小於某個閾值的時候,停止並行化能夠提高效率,這些詳細的討論在淺談併發與並行這篇文章中有詳細的介紹了,這裡不再贅述。
六 用途
合併排序和快速排序一樣都是時間複雜度為nlgn的演算法,但是和快速排序相比,合併排序是一種穩定性排序,也就是說排序關鍵字相等的兩個元素在整個序列排序的前後,相對位置不會發生變化,這一特性使得合併排序是穩定性排序中效率最高的一個。在Java中對引用物件進行排序,Perl、C++、Python的穩定性排序的內部實現中,都是使用的合併排序。
七 結語
本文介紹了分治演算法中比較典型的一個合併排序演算法,這也是我們遇到的第一個時間複雜度為nlgn的排序演算法,並簡要對演算法的複雜度進行的分析,希望本文對您理解合併排序有所幫助,下文將介紹快速排序演算法。