1. 程式人生 > 實用技巧 >演算法(第四版)2.2 歸併排序

演算法(第四版)2.2 歸併排序

演算法(第四版)2.2 歸併排序

歸併排序,即將兩個有序的陣列歸併成一個更大的有序陣列。很-很快人們就根據這個操作發明了一種簡單的遞迴排序演算法:歸併排序。要將一個數組排序,可以先(遞迴地)將它分成兩半分別排序,然後將結果歸併起來。

原地歸併的抽象方法

實現歸併的一種直截了當的方法時將兩個不同的有序陣列歸併到第三個陣列中,兩個陣列中的元素應該都實現了Comparable介面。實現的方法很簡單,建立一個適當大小的陣列然後將兩個輸入陣列中的元素一個個從小到大放入這個陣列中。

但是,當用歸併將一個大陣列排序時,我們需要進行很多次歸併,因此在每次歸併時都建立一個新陣列來儲存排序結果會帶來問題。我們更希望有一種能夠在原地歸併的方法,這樣就可以先將前半部分排序,再將後半部分排序,然後在陣列中移動元素而不需要額外的空間。

儘管如此,將原地歸併抽象化仍然是有幫助的。與之對應的是我們的方法簽名merge(a,lo,mid,hi),它會將子陣列a[lo..mid]a[mid+1..hi]歸併成一個有序的陣列並將結果存放在a[lo..hi]中。下面的程式碼只用了幾行就實現了這種歸併。它將涉及的所有元素複製到一個輔助陣列中,再把歸併的結果放回原陣列中。

原地歸併的抽象方法

public static void merge(Comparable[] a, int lo, int mid, int hi){
    // 將a[lo..mid]和a[mid+1..hi]歸併
    int i = lo,j = mid+1;

    for (int k = lo; k <= hi ; k++) {
        aux[k] = a[k];  				// 將a[lo..hi]複製到aux[lo..hi]中
    }

    for (int k = lo; k <= hi ; k++) { 	// 歸併回到a[lo..hi]
        if (i > mid)                    a[k] = aux[j++];
        else if (j > hi)                a[k] = aux[i++];
        else if (less(aux[j],aux[i]))   a[k] = aux[j++];
        else                            a[k] = aux[i++];
    }
}

該方法先講所有的元素複製到aux[]中,然後再歸併回a[]中。方法在歸併時(第二個for迴圈)進行了4個條件判斷:左半邊用盡(取右半邊元素)、右半邊用盡(取左半邊元素)、右半邊的當前元素小於左半邊的當前元素(取右半邊的元素)、以及右半邊的當前元素大於等於左半邊的當前元素(取左半邊的當前元素)。

自頂向下的歸併排序

自頂向下的歸併排序基於原地歸併的抽象實現了另一種遞迴歸併,這也是應用高效演算法設計中分治思想的最典型的一個例子。這段遞迴程式碼是歸納證明演算法能夠正確地將陣列排序的基礎:如果它能將兩個子陣列排序,它就能夠通過歸併兩個子陣列來講整個陣列排序。

自頂向下的歸併排序

public class Merge {

    private static Comparable[] aux;    // 歸併所需的輔助陣列

    public static void sort(Comparable[] a){
        aux = new Comparable[a.length]; // 一次性分配空間
        sort(a, 0, a.length-1);
    }
    
    private static void sort(Comparable[] a, int lo, int hi){
        // 將陣列a[lo..hi]排序
        if (hi <= lo) return;
        int mid = lo + (hi - lo)/2;
        sort(a,  lo, mid);              // 將左半邊排序
        sort(a, mid+1, hi);         	// 將右半邊排序
        merge(a,lo,mid, hi);            // 歸併結果(程式碼見“原地歸併的抽象方法”)
    }
}

命題F。 對於長度為N的任意陣列,自頂向下的歸併排序需要½NlgNNlgN次比較。

證明。C(N)表示將一個長度為N的陣列排序時所需要的比較次數。我們有C(0)=C(1)=0,對於N>0,通過遞迴的sort()方法我們可以由相應的歸納關係得到比較次數的上限:

\[C(N) <= C(\lceil N/2\rceil) + C(\lfloor N/2\rfloor) + N \]

右邊的第一項,即\(C(\lceil N/2\rceil)\)是將陣列的左半部分排序所用的比較次數,右邊的第二項,即\(C(\lfloor N/2\rfloor)\)是將陣列右半部分排序所用的比較次數,第三項時歸併所用的比較次數。因此歸併所需的比較次數最少為\(\lfloor N/2\rfloor\),比較次數的下限是:

\(C(N)>=C(\lceil N/2\rceil) + C(\lfloor N/2\rfloor) + \lfloor N/2\rfloor\)

當N為2的冪時(即\(N=2^n\))且等號成立時我們能夠得到一個解。首先,因為\(\lfloor N/2\rfloor=\lceil N/2\rceil=2^{n-1}\),可以得到:

\[C(2^n) = 2C(2^{n-1}) + 2^n \]

將兩邊同時除以\(2^n\)可得:

\[C(2^n)/2^n = C(2^{n-1})/2^{n-1} + 1 \]

用這個公式替換右邊的第一項,可得:

\[C(2^n)/2^n = C(2^{n-2})/2^{n-2} + 1 + 1 \]

將上一步重複n-1遍可得:

\[C(2^n)/2^n = C(2^{0})/2^{0} + n \]

將兩邊同時乘以\(2^n\)就可以解得:

\[C(N) = C(2^n)=n2^n=NlgN \]

對於一般的N,得到的準確值要複雜一些。但對於比較次數的上下界不等式使用相同的方法不難證明所述的對於任意N的結論。這個結論對於任意輸入值和順序都成立。

上面的過程是證明了比較次數的上限,依樣畫葫蘆,就可以得到下限為½NlgN。

命題G。對於長度為N的任意陣列,自頂向下的歸併排序最多需要訪問陣列6NlgN次。

證明。每次歸併最多需要訪問陣列6N次(2N次用來複制,2N次用量將排好序的元素移動回去,另外最多需要比較2N次),根據命題F即可得到這個命題的結果。

自底向上的歸併排序

實現歸併排序的兩一種方法時先歸併那些微型陣列,然後再成對歸併得到的子陣列,如此這般,這般如此,直到我們將整個陣列並在一起。

public class MergeBU {

    private static Comparable[] aux;    // 歸併所需的輔助陣列

    private static void sort(Comparable[] a){
        // 將陣列a[lo..hi]排序
        int N = a.length;
        aux = new Comparable[N];
        for (int sz = 1; sz < N; sz = sz + sz) {            // 子陣列大小
            for (int lo = 0; lo < N - sz; lo += sz + sz) {  // lo:子陣列索引
                merge(a, lo, lo + sz -1, Math.min(lo + sz + sz -1, N -1));
            }
        }
    }
}

自底向上的歸併排序會多次遍歷整個陣列,根據子陣列的大小進行兩兩歸併。子陣列的大小sz的初始值為1,每次加倍。最後一個子陣列的大小隻有在陣列大小是sz的偶數倍的時候才會等於sz(否則它會比sz小)。

自底向上的歸併排序的歸併結果

            sz = 1
            merge(a, 0, 0, 1)
            merge(a, 2, 2, 3)
            merge(a, 4, 4, 5)
            merge(a, 6, 6, 7)
            merge(a, 8, 8, 9)
            merge(a, 10, 10, 11)
            merge(a, 12, 12, 13)
            merge(a, 14, 14, 15)
        sz = 2
        merge(a, 0, 1, 3)
        merge(a, 4, 5, 7)
        merge(a, 8, 9, 11)
        merge(a, 12, 13, 15)
    sz = 4
    merge(a, 0, 3, 7)
    merge(a, 8, 11, 15)
sz = 8
merge(a, 0, 7, 15)

命題H。 對於長度為N的任意陣列,自底向上的歸併排序需要1/2NlgN至NlgN次比較,最多訪問陣列6NlgN次。

證明。 處理一個數組的遍數正好是\(\lceil lgN\rceil (即2^n <= N <2^{n+1}中的n)\)。每一遍會訪問陣列6N次,比較次數在N/2和N之間。

當陣列長度為2的冪時,自頂向下和自底向上的歸併排序所用的比較次數和陣列訪問次數正好相同,只是順序不同。

自底向上的歸併排序比較適合用連結串列組織的資料。想象一下將連結串列先按大小為1的子連結串列進行排序,然後是大小為2的子連結串列,然後是大小為4的子連結串列等。這種方法只需要重新組織連結串列連結就能將連結串列原地排序(不需要建立任何新的連結節點)。

added by wxp: 並不是太明白。。。

排序演算法的複雜度

命題I。 沒有任何基於比較的演算法能夠保證使用少於\(lg(N!) 到 NlgN\)次比較將長度為N的陣列排序。

證明。 首先,假設沒有重複的主鍵,因為任何排序演算法都必須能夠處理這種情況。我們使用二叉樹來表示所有的比較。樹中的結點要麼是一片葉子 \(i_0 i_1 i_2 ...i_n\),表示排序完成且原輸入的排列順序是\(a[i_0],a[i_1]...a[i_n]\),要麼是一個內部結點i:j,表示a[i]a[j]之間的一次比較操作,它的左子樹表示a[i]小於a[j]時進行的其他比較,右子樹表示a[i]大於a[j]時進行的其他比較。從跟結點到葉子結點每一條路徑都對應著演算法在建立葉子結點所示的順序是進行的所有比較。

我們從來沒有明確地構造這棵樹——它只是用來描述演算法中的比較的一個數學工具。

從比較樹觀察得到的第一個重要結論是這棵樹應該至少有N!個葉子結點,因為N個不同的主鍵會有N!種不同的排列。如果葉子結點少於N!,那肯定有一些排列順序被遺漏了。演算法對於那些被遺漏的輸入肯定會失敗。

從跟結點到葉子結點的一條路徑上的內部結點的數量即是某種輸入下演算法進行比較的次數。我們感興趣的事這種路徑能有多長(也就是樹的高度),因為這也就是演算法比較次數的最壞情況。二叉樹的一個基本的組合學性質就是高度為h的樹最多隻可能有\(2^h\)個葉子結點,擁有\(2^h\)個結點的樹是完美平衡的,或稱為完全樹

結合前兩段的分析可知,任意基於比較的排序演算法都對應著一棵高h的比較樹。

\[N! <= 葉子結點的數量 <= 2^h \]

h的值就是最壞情況下的比較次數,因此對不等式的兩邊取對數可得到任意演算法的比較次數至少是lgN!。根據斯特靈公式對階乘函式的近似可得lgN! ~ NlgN

命題J。歸併排序是一種漸進最優的基於比較排序的演算法。

證明。 更準確的說,這句話的意思是,歸併排序在最壞的情況下的比較次數和任意基於比較的排序演算法所需的最少的比較次數都是~NlgN。命題H和命題I證明了這些結論。