In-place Merge Sort (原地歸併排序)
一般在提到Merge Sort時,大家都很自然地想到Divide-and-Conqure, O(nlgn)的時間複雜度以及額外的O(n)空間。O(n)的extra space似乎成了Merge Sort最明顯的缺點,但實際上這一點是完全可以克服的,也就是說,我們完全可以實現O(nlgn) time 以及 O(1) space 的Merge Sort。對於這種不用額外空間(即常數大小的額外空間)的演算法,有一個通用的名字叫做In-place Algorithms,因此我們稱該歸併演算法為in-place merge sort,也就是原地歸併排序。
在進入具體的細節之前,先來看一個稍後會用到的結論:
給定長度分別為n和m的兩個各自有序陣列X,Y,以及一個長度為n的輔助陣列A,存在O(m+n)時間複雜度的演算法AuxSort;該演算法可以原地歸併排序X和Y,同時A中元素保持不變(順序可能打亂)。
演算法具體步驟如下:
- 交換X 和 A。
- 維護兩個整數下標 px, py,初始化為0,分別代表A[0]和Y[0]; 另外維護一個dest指標,初始化指向X[0],並且該指標在移動到X[n-1]後,自動過渡到Y[0]。
-
比較A[px], Y[py]:
若 A[px] <= Y[py], swap(A[px++], *dest++);
若 A[px] > Y[py],swap(Y[py++], *dest++); - 若A或Y中仍有剩餘元素,則將剩餘部分依次與*dest交換。當然每一步要執行dest++操作。
通過上述步驟很容易發現,該演算法的基本思想與傳統的2-way merge sort如出一轍,時間複雜度也都是O(n)。只不過傳統的merge sort中我們有一個額外的目的陣列,且其中沒有資料; 而這裡目的陣列就是X+Y本身,所以我們要首先交換X和A,將X “騰空”,另外由於我們不能破壞其中資料,在之後的每一步中,我們執行的都是交換操作,而不是傳統merge sort中的賦值操作。
為了有助於理解,我們來看一個例子。假設n=3,且x1 < y1 < x2 < y2< x3 < y3,具體的執行過程如圖1所示。
圖1 利用輔助陣列原地歸併排序
下面開始本文的重點,in-place merge sort演算法,具體內容參考自TAOCP Vol3 5.2.5 習題,根據Knuth的記述,該演算法出自Doklady Akad. Nauk SSSR 186 (1969)。這幫人真是神人。。。廢話不說了,直接進入到正題吧。
假設我們有兩個已經各自有序的陣列X和Y,其總長度為N,如圖2所示。下面將要介紹的演算法將利用O(1)的額外空間,在O(N)的時間內將其原地排序。總體來說演算法分為3部分:分塊,兩兩歸併,掃尾。
圖2 原始陣列
分塊:我們將原始陣列組合在一起,並以長度n=sqrt(N)將其分成m+2份: Z1, Z2, … Zm+1, Zm+2,如圖3所示。這樣,除了Zm+2中有(N % n)個元素之外,其他m+1塊都恰好有n個元素。
圖3 分塊
另外,如圖3所示,假設X[-1] (X中的最後一個元素)包含在Zk中,那麼我們將其與Zm+1交換。這樣以來,Z1, Z2, …, Zm 中的元素都各自有序,且規模為n。我們將調整之後的Zm+1和Zm+2統稱為A,其規模s為[n, 2n)。如上兩步在O(N)時間內即可完成。
兩兩歸併:根據每一塊中的第一個元素大小調整Z1, Z2, ... Zm的順序,使得Z1[0] <= Z2[0] <= … <= Zm[0]。若第一個元素相等,則以最後一個元素的大小作為依據。由於無法利用額外空間,我們可以採用選擇排序:每一輪從剩餘的塊裡面選擇一個首元素最小的塊,與當前塊交換。最壞情況下進行m輪,每一輪需要O(m)的比較, O(n)的交換,總體的時間複雜度為O(m(m+n))=O(N)。
如此排列好之後,我們便可以從Z1和Z2開始進行兩兩歸併排序。由於A的長度s>=n,這裡我們可以採用前文介紹的AuxSort方法。唯一的缺點是A中的元素順序會被打亂,但由於A中元素本來也就無序,所以也就無所謂了。也就是說,我們要執行m-1輪AuxSort演算法,其中第i輪處理Zi和Zi+1。通過下面的歸納法證明,我們可以得知,這m-1輪AuxSort過程執行完之後,Z1~Zm中所有的元素就已經排序完畢了。
在此之前首先假設排序結束之後的第i塊為Ri,而經過AuxSort處理後的Zi為Z’i。另外,為了描述方便,我們把Z1~Zm中屬於X的第一塊叫做X1,屬於Y的第一塊叫做Y1,依次類推。
我們接下來將要證明,在第i輪處理後,Ri = Z’i。
-
i=1。不失一般性,我們假設Z1為X1,那麼Z1, Z2, Z3 可能為 {X1, X2, X3}, {X1, X2, Y1}, {X1, Y1, X2}, {X1, Y1, Y2}.
- {X1, X2, X3}。則X1[n-1] <= X2[0] = Z2[0] <= Zi[0] <= Zi[k] (i>=2),也就是說,X1包含最小的n個元素,R1 = X1=Z’1。另外,Z’2[0] = Z2[0] = X2[0] <= X3[0] = Z3[0]。
- {X1, X2, Y3}。同上,X1[n-1] <= Z2[0] <= Zi[k] (i>=2),因此R1=X1=Z’1。另外,Z’2[0]=Z2[0]<=Z3[0]。
- {X1, Y1, X2}。顯然最小的n個元素一定在X1或Y1中,因此R1=Z’1。另外,Z’2[0] <= max(X1[n-1], Y1[0]) <= X2[0] = Z3[0]。
- {X1, Y1, Y2}。同理,最小的n個元素一定在X1或Y1中,R1=Z’1。另外,Z’2[0] <= Y1[n-1] <= Y2[0] = Z3[0]
因此merge之後, Z’2[0] <= Z3[0]的性質仍然成立。在隨後的分析中,如果Z’2[n-1] = X1[n-1], 那麼可以將Z’2看成是X1; 同理如果Z’2[n-1] = Y1[n-1],我們可以將其看成是Y1。這是一個很重要的推論,直接支援我們在隨後繼續沿用上述四種情況的分析。
- 假設i=k的時候結論成立,也就是說k輪AuxSort之後,R1~Rk已經就位。且根據上一步中的推論,Z’k+1可以直接看作是Xi或Yj。也就是說,上一步中四種分情況討論的方法依舊適用。證畢;
- 綜上所述,根據歸納法我們可以得知結論成立。
由於共經過m輪兩兩歸併,每一輪歸併時間複雜度為O(n),因此第二步總體時間複雜度為O(mn) = O(N)。
掃尾:第二步結束之後,R[0...kn-1]已經排序完畢。由於A中共有s個元素,容易看出,原始陣列中最大的s個元素一定存在於A和R[kn-s, kn-1]中。因此我們可以利用選擇排序,在O(s^2) = O(N)時間內將A和R[kn-s, kn-1]排序,這樣最大的s個元素就被移動到A中。換句話說,R[0, kn-1]中儲存著原始陣列中的前N-s個元素。
利用AuxSort, 可以以A為輔助陣列,將R[0...kn-s-1] (長度為N-2s)和 R[kn-s, kn-1] (長度為s) 歸併排序。這樣原始陣列中的前N-s個元素排序完畢,時間複雜度為O(N)。由於A中元素順序已經被打亂,我們需要再次利用選擇排序在O(s^2)=O(N)的時間內對其重新排序。
這樣一來,通過分塊,兩兩歸併,以及掃尾三個步驟以後,我們完成了對於X和Y的一輪原地歸併排序,時間複雜度為O(N),空間複雜度為O(1)。以此為基礎,完整的歸併排序可以在O(NlogN)時間內完成,空間複雜度仍然為O(1)。
注意由於AuxSort基於交換操作,A中元素的順序會被打亂,因此該排序演算法是不穩定的。
後記:
記不得最開始聽說這個概念是什麼時候了,最近有印象的一次是去年去STC面試,面試官問到merge sort的時候說這個有什麼缺點啊?我說好像也沒啥特別的缺點,普遍認為的O(n)的extra space可以省掉,因為我們有in-place merge sort。面試官於是很感興趣地讓我描述一下大致流程。:( 可憐我當時其實並不知道具體的步驟,大囧了一把。好在面試官也沒有計較
後來回去之後翻STL中的inplace_merge研究了一下,發現這個並不是純正的in-place merge sort,也有可能會用到額外的buffer,非常失望,就沒有繼續研究了。前段時間又一次心血來潮,發現這原來是TAOCP裡面的一道習題,於是結合答案終於搞定之。由於Knuth的答案一向簡單明瞭,其中關於兩兩歸併這一步正確性的證明我沒太看明白,只好自己重新推導了一遍,因此寫起來要比原文羅嗦許多。沒有時間的同學,也許回頭直接看原文會好很多。:D
另外,stable in-place merge sort也是TAOCP的習題之一 (所以說TAOCP看得慢並不能說明我笨啊。。。),等我有時間的時候,再繼續整理吧~ 希望不會拖太久哈哈。