邏輯之美(6)_歸併排序 開篇 正文 尾巴

開篇

上篇聊到的堆排序僅用線性對數級別的時間複雜度 O(n log n) 和常數級別的額外輔助空間即可將一個數組排序,已然十分高效。這篇我們來聊一種同樣高效但要更古老的排序算法——歸併排序。

正文

何爲歸併排序

此算法於 1945 年由計算機科學的祖師爺——約翰·馮·諾伊曼(對就是那個大名鼎鼎的馮·諾依曼)首次提出,看年代確實挺古老的!

將兩個已經(整體)有序的數組合併成一個更大的有序數組,這就叫歸併

原始數組:[6, 5, 3, 1,  8, 7, 2, 4]
---------------------|
---------------------|
左半排序:[1, 3, 5, 6]|------------
右半排序:------------|[2, 4, 7, 8]

歸併操作:[1, 2, 3, 4, 5, 6, 7, 8]

自頂向下的遞歸實現

歸併排序是算法裏面分治法的典型應用,一種經典的實現是採用遞歸的方法自頂向下分而治之是

來張動圖具象化展示下以幫助理解,圖源維基百科:

具體邏輯如此,下面我們直接上代碼(Java)來看看歸併排序到底是怎麼一回事,實現中有個將兩個有序數組歸併成一個有序數組的操作我們將其抽象成一個單獨的工具方法,命名爲 merge(其實是將當前數組兩個有序的子數組歸併)。一點注意,此方法需要額外的輔助空間:

/**
     * @param array:待歸併數組,我們需要將此數組的[start, mid] 和 [mid + 1, end] 兩個已經有序的子數組歸併起來
     * @param aux:輔助數組,完成歸併操作的額外輔助空間,其大小應和 array 一致
     * @param start:歸併區間起始位置,inclusive
     * @param mid:歸併區間第一個子數組的有邊界,inclusive
     * @param end:歸併區間終止位置,inclusive
     */
    private static void merge(int[] array, int[] aux, int start, int mid, int end){
        //將 array 的 [start, mid] 和 [mid + 1, end] 兩個已有序的子數組歸併
        int s1 = start;//start1
        int s2 = mid + 1;//start2

        for (int i = start; i <= end; i++){//拷貝待排序數組
            aux[i] = array[i];
        }
        //開始歸併兩個(子)數組
        for (int i = start; i <= end; i++){
            if (s1 > mid){               //第一個子數組(左半邊)已遍歷完
                array[i] = aux[s2++];
            }else if (s2 > end){         //第二個子數組(右半邊)已遍歷完
                array[i] = aux[s1++];
            }else if (aux[s1] > aux[s2]){//平凡情況,取右半邊元素
                array[i] = aux[s2++];
            }else {                      //平凡情況,取左半邊元素
                array[i] = aux[s1++];
            }
        }
    }

    /**
     * <p>歸併排序自頂向下的遞歸實現</p>
     * @param array:待排序數組,將數組的 [start, end] 區間排序
     * @param aux:輔助數組,完成歸併操作的額外輔助空間,其大小應和 array 一致
     * @param start:待排序區間起始位置,inclusive
     * @param end:待排序區間終止位置,inclusive
     */
    public static void sortMerge(int[] array, int[] aux, int start, int end){
        if(end <= start){//遞歸結束條件
            return;
        }
        int mid = start + (end - start)/2;//歸併左半部分的終止位置,右半部分的起始位置自然是 mid + 1
        sortMerge(array, aux, start, mid);//左半部分排序
        sortMerge(array, aux, mid + 1, end);//右半部分排序
        merge(array, aux, start, mid, end);//歸併兩個已排序的子數組
    }

其中 sortMerge 方法的遞歸邏輯可能不是那麼容易理解,需要好好消化一下。以數組 [6, 5, 3, 1, 8, 7, 2, 4] 爲例,我們一起來捋下其排序遞歸操作的函數調用軌跡來幫助理解:

-------------a = [6, 5, 3, 1,  8, 7, 2, 4] 
-------------sortMerge(a, aux, 0, 7)//爲此數組初始調用歸併排序,設輔助數組爲 aux
-------------
左半部分排序:sortMerge(array, aux, 0, 3)----------------------->瞧見沒,典型的分而治之
-------------       sortMerge(array, aux, 0, 1)
-------------           merge(array, aux, 0, 0, 1)
-------------       sortMerge(array, aux, 2, 3)
-------------           merge(array, aux, 2, 2, 3)
右半部分排序:sortMerge(array, aux, 4, 7)----------------------->瞧見沒,典型的分而治之
-------------       sortMerge(array, aux, 4, 5)
-------------           merge(array, aux, 4, 4, 5)
-------------       sortMerge(array, aux, 6, 7)
-------------           merge(array, aux, 6, 6, 7)
歸併結果----:merge(array, aux, 0, 3, 7)

爲避免遞歸帶來的額外開銷,還請讀者自行把上面的代碼改造成非遞歸版本。

上面提到了自頂向下這種說法,仔細觀察算法的執行過程,我們是將一個大問題分割成(兩個)小問題來分別解決,然後用所有小問題的解來得到整個大問題的解(典型的分而治之)。其實反之亦是一種不錯的實現思路,也即自底向上:首先我們進行兩兩歸併(把數組每個元素看成一個大小爲 1 的子數組,將相鄰兩個子數組歸併到一起,每次歸併兩個元素)然後四四歸併、八八歸併(粒度越來越粗),直至數組整體有序。

自底向上的嵌套循環實現

/**
     *<p>歸併排序自底向上的嵌套循環實現</p>
     * @param array:待排序數組,將數組的 [start, end] 區間排序
     * @param aux:輔助數組,完成歸併操作的額外輔助空間,其大小應和 array 一致
     */
    public static void sortMerge_(int[] array, int[] aux){
        for (int size = 1; size < array.length; size <<= 1){//子數組的大小每次都翻倍
            //根據當前每個子數組的大小 size,按順序對相鄰兩個子數組應用歸併操作,注意每個子數組在當前 size 下只參與一次歸併操作
            for (int start = 0; start < array.length - size; start += size + size){
                int end = start + size + size - 1;
                //這裏的 merge 方法跟上面的自頂向下的一致
                merge(array, aux, start, start + size - 1, Math.min(end, array.length - 1));//最後一次歸併時,第二個子數組可能比第一個體積要小,或者跟第一個相等,我們的歸併操作支持爲兩個大小不同的數組應用
            }
        }
    }

上面的代碼跟我們一開始實現的自頂向下版本是基本等價的,可以看到其代碼要精簡許多。還是以數組 [6, 5, 3, 1, 8, 7, 2, 4] 爲例,其方法執行軌跡如下:

-------------自底向上對數組歸併排序
-------------a = [6, 5, 3, 1,  8, 7, 2, 4]
-------------sortMerge(a, aux)//自底向上歸併排序,設輔助數組爲 aux
-------------
-------------       size = 1
-------------       merge(array, aux, 0, 0, 1)
-------------       merge(array, aux, 2, 2, 3)
-------------       merge(array, aux, 4, 4, 5)
-------------       merge(array, aux, 6, 6, 7)
-------------   size = 2
-------------   merge(array, aux, 0, 1, 3)
-------------   merge(array, aux, 4, 5, 7)
-------------size = 4
-------------merge(array, aux, 0, 3, 7)
-------------數組已整體有序
*/

總結

如上所述,歸併排序是建立在歸併操作基礎上的一種高效、穩定的排序算法,其時間複雜度恆爲線性對數級別的 O(n log n) ,與輸入無關。與我們之前討論的排序算法不同,其實現需要額外的輔助空間,空間複雜度最壞爲線性級別的 O(n)。

尾巴

因其高效性,歸併排序是當下應用非常廣泛的排序算法,很多語言的的標準函數庫中涉及到排序的地方一般都有其實現(比如Java)。那歸併排序是應用最廣泛的排序算法嗎?答案是否定的,下篇我們就來聊一種更加高效,且是目前應用最廣泛的排序算法——快速排序(你看這名字!)。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章