
Motivate
MergeSort是個相對古老的算法了,為什么現在我們還要討論這么古老的東西呢?有幾個原因:
-
它雖然年齡很大了,但是在實踐中一直被沿用,仍然是很多程序庫中的標准算法之一。
-
實現它的本質是分治思想,是一個理解分治算法思想的好例子,好起點。
-
本文會使用“遞歸樹”來對它進行運行時間分析,后面會集合這種思路生成“主方法”。
題目

輸入一個數組,數組里面的每個數字是不重復的,輸出是已經排序好的數組。
比如輸入的是:

期望輸出的是:

可能之前我們有所知道一些排序算法,比如SelectionSort,掃描全數組找到最小元素,把它放到輸出數組的第一位,接着掃描復制次小的元素,以此類推;比如BubbleSort,對相鄰無序的元素進行比較,執行反復的交換,直到最后數組完成排序。等等。但這些算法的問題就是運行時間是平方級的。那我們來看看今天這個排序算法用更少的運行時間是怎么實現的。
例子 想要理解MergeSort算法是如何運行的,一個最簡單的方法就是看看具體的例子。

整體過程就是:
它把數組 [5, ,4, 1, 8, 7, 2, 6, 3] 划分為更小的數組(子問題)[5, 4, 1, 8] 和 [7, 2, 6, 3]排序,通過神奇的遞歸操作,得到每個排序后的子數組,再將子數組合並起來。
偽代碼 將上面的圖換成偽代碼就是這樣的過程

而這個Merge程序怎么實現呢?
由上面的圖我們可以知道,Merge的時候,其實輸入兩個已經排序好的數組C, D,再把它們排序輸出到B。

索引 k 操控的是輸出數組,索引 i,j 操控的是已排序好的C和D子數組,都是從左向右掃描。每一次的for循環,其實就是找C和D中最小的數字,因為C和D是已排序好的數組,所以最小的數字就是i或j對應的元素。比較后把它放入輸出數組B中,並將對應的索引+1,這樣下次循環就跳過已復制的元素了。所以最后的數組B輸出是按序方式生成的。
算法分析
我們先來對Merge程序算算它的執行操作數量。 第1,2行有一次賦值操作。 第3行是一個for循環,每一個for循環里,有比較操作(C[i]和D(i)比較),賦值操作(B[k]的賦值),遞增操作(i或j加1),循環變量k還要加1,所以每一次循環一共有4次操作。
一共就是4n+2次操作,為了后面的計算方便,當n>=1時,4n+2<6n(去掉常數項), 我們取6n次操作作為Merge程序操作上界。
我們現在再來看MergeSort的運行時間。 為了簡單起見,假如輸入數組的長度是n的2次方(如果沒有這個假設只需要額外工作就能消除這個假設),我們用遞歸樹的方法來分析運行時間的上界,每一個節點就表示一次遞歸調用。

最外層是整個原始的輸入數組,它在進行MergeSort的時候會有2個遞歸調用,所以這是一個二叉樹(每個節點都有兩個子節點),第一層的2個節點就是原始數組的左半部分和右半部分,再次遞歸最后到達最底層,一個長度為1或0的數組。我們需要知道幾個數量:

原始數組長度是n,遞歸樹有多少層?
由於每深入一層,數組長度就縮小一半,第0層是n,第1層是n/2(除了一次2),第2層是n/4(除了2次2),最后一層的數組長度是不大於1的,就是除以了log2(n)次2,所以最后一層是log2(n)層。(也可以假定n/2^a = 1, 求解a)如果沒有n是2的次方這個假設,就向上取整。一共就是log2(n) +1層。

遞歸樹的第j層有多少個節點(子問題)?每個節點的數組長度是多少?
因為每一層的遞歸數量是前一層的兩倍,所以第j層就有2^j個子問題。每個節點的長度,總長度是n,均分到每個節點就是n/(2^j)個長度。
所以總的運行時間就是:
層數 * 每一層的工作量
= 層數 * (第 j 層的子問題數量 * 每個第j層子問題完成的工作數) = 層數 * (第 j 層的子問題數量 * (每個第j層子問題的規模 * Merge的時間))
= (log2(n)+1) * ( 2^j * n/(2^j) * 6n)
=6n * log2(n) +6n
= O(nlogx(n))
這時候,我們就可以理直氣壯的說遞歸的分治算法比其它更簡單的算法要快的多啦。看圖直觀感受一下

當數據非常大的時候,它是非常有優勢的,指數函數增長十分的緩慢。
今日互動
有什么不明白的歡迎在評論區留言。