資料結構與演算法——排序演算法-歸併排序

天然呆dull發表於2021-09-01

簡單介紹

歸併排序(merge sort)是利用 歸併 的思想實現的排序方法,該演算法採用經典的 分治(divide-and-conquer)策略

  • 分(divide):將問題分成一些小的問題,然後遞迴求解
  • 治(conquer):將分的階段得到的各答案「修補」在一起

即:分而治之

該演算法是採用分治法(Divide and Conquer)的一個非常典型的應用。將已有序的子序列合併,得到完全有序的序列;即先使每個子序列有序,再使子序列段間有序。若將兩個有序表合併成一個有序表,稱為二路歸併

基本思想

  • 分:分的過程只是為了分解
  • 治:分組完成後,開始對每組進行排序,然後合併成一個有序序列,直到最後將所有分組合併成一個有序序列

可以看到這種結構很像一棵 完全二叉樹,本文的歸併排序我們採 用遞迴實現(也可採用迭代的方式實現)。分階段 可以理解為就是遞迴拆分子序列的過程。

:這些數從始至終都在一個陣列裡(只是抽象的想想成兩個陣列),除了排序時會把要排序的數copy到一個臨時陣列裡面,這裡如果不懂後面看了程式碼後,再返回來思考就懂了。一定要思考!!!!

下面的動圖可以很直觀的看到整個演算法實現的過程,初體驗一下吧,後面的程式碼可以結合上面的圖,和這個動圖,來整理一下邏輯

多個數排序動圖:

思路分析

對於分階段相對較簡單,下面著重來對治階段進行分析。

合併相鄰有序子序列分析:下圖以 歸併排序的最後一次合併 為例來分析,即對應上圖的 [4,5,7,8][1,2,3,6] 兩個已經有序的子序列合併為最終序列 [1,2,3,4,5,6,7,8],下圖展示了實現步驟

如圖所示:這是最後一次的合併 操作,是兩個有序序列合併為最終的有序序列。

  1. 從有序序列開始挨個比較,這裡比較 4 和 1,1 < 4,那麼 1 進入臨時陣列 temp中,並且將自己的指標右移動一位
  2. 由於圖上綠色部分上一次 4 大於 1,指標並未移動,然後 4 和 2 對比,2 < 4 , 2 進入到臨時陣列中,並且將自己的指標右移動一位
  3. ...
  4. 如果某一組已經全部進入了臨時陣列,那麼剩餘一組的剩餘有序序列直接追加到臨時陣列中
  5. 最後,將 temp 內容拷貝到原陣列中去,完成排序

程式碼實現

先實現合併方法,這個是該演算法中最重要的,你也看可以直接看後面的完整演算法程式碼,再返回來思考,這個都隨你。此次是因為 的步驟需要用到 的這個,所以我這裡就先放 合併的程式碼了。

 /**
     * 
     *  最難的是合併,所以可以先完成合並的方法,此方法請參考 基本思想 和 思路分析中的圖解來完成
     *  動腦筋
     *
     * 
     *
     * @param arr   要排序的原始陣列
     * @param left  因為是合併,所以要得到左右兩邊的的陣列資訊,這個是左側陣列的第一個索引值
     * @param mid   因為是一個陣列,標識是第一個陣列的最後一個索引,即 mid+1 是右側陣列的第一個索引,即中間索引
     * @param right 右側陣列的結束索引,右邊陣列的最後一個數
     * @param temp  臨時空間,臨時陣列
     */
    public void merge(int[] arr, int left, int mid, int right, int[] temp) {
        // 定時臨時變數,用來遍歷陣列比較
        int l = left;  // 左邊有序陣列的初始索引
        int r = mid + 1; // 右邊有序陣列的初始索引
        int t = 0; // temp 陣列中當前最後一個有效資料的索引
        
        // 1. 按思路先將兩個陣列(有序的)有序的合併到 temp 中
        // 因為是合併兩個陣列,所以需要兩邊的陣列都還有值的時候才能進行 比較合併
        // 若其中一個陣列的值遍歷完了(取完了),那麼就跳出該迴圈,把另一個陣列所剩的值,直接copy進臨時陣列即可
        while (l <= mid && r <= right) {
            // 如果左邊的比右邊的小,則將左邊的該元素填充到 temp 中
            // 並移動 t++,l++,好繼續下一個
            if (arr[l] < arr[r]) {
                temp[t] = arr[l];
                t++;//移動 temp 臨時陣列中當前最後一個有效資料的索引
                l++; //左邊原始陣列的索引移動
            }
            // 否則則將右邊的移動到 temp 中
            else {
                temp[t] = arr[r];
                t++;//移動 temp 臨時陣列中當前最後一個有效資料的索引
                r++;//右邊原始陣列的索引移動
            }
        }
        // 2. 如果有任意一邊的陣列還有值,則依序將剩餘資料填充到 temp 中
        // 如果左側還有值
        while (l <= mid) {
            temp[t] = arr[l];
            t++;
            l++;
        }
        // 如果右側還有值
        while (r <= right) {
            temp[t] = arr[r];
            t++;
            r++;
        }

        // 3. 將 temp 陣列,拷貝到原始陣列
        // 注意:只拷貝當前temp有效資料到對應的原始資料中,通俗點說,就是原始陣列中要排序的資料,通過temp排成了有序的,然後將temp中的有序資料將原始陣列中原來要排序的那部分覆蓋了就行了
        int tempL = left; // 從左邊開始拷貝,左邊第一個值的索引
        t = 0;  // temp 中的有效值索引,有效值邊界可以通過 right 判定,因為合併兩個陣列到 temp 中,邊界就是 right ,這裡自己好好想想
        /*
         * 對於 8,4,5,7,1,3,6,2 這個陣列,
         * 從棧頂開始合併:8,4 | 5,7       1,3 | 6,2
         * 先左遞迴的話:
         * 第一次:8,4 合併:tempL=0,right=1
         * 第二次:5,7 合併:tempL=2,right=3
         * 第三次:4,8 | 5,7 進行合併,tempL=0,right=3
         * 直到左遞迴完成,得到左側一個有序的序列:4,5,7,8 然後再開始遞迴右邊那個無序的
         * 最後回到棧底分解成兩個陣列的地方,將兩個陣列合併成一個有序陣列
         * 這裡真的得自己想了,我只能這麼說了。
         */
        System.out.println("tempL=" + tempL + "; right=" + right);
        while (tempL <= right) {
            arr[tempL] = temp[t];
            tempL++;
            t++;
        }
    }

需要注意的是:這個圖一定要看明白:

  1. 一直分解,到棧頂首次合併時,是兩個數字,也就是說,左側陣列只有一個數字,右側陣列只有一個數字
  2. 左側陣列只有一個數字時,l = 0,r = 1,那麼 mid = 0,邊界判定時要用 l <= mid && r <= right ,否則就會跳過對比合並了

完整程式碼如下

    @Test
    public void sortTest() {
        int[] arr = new int[]{8, 4, 5, 7, 1, 3, 6, 2};
        int[] temp = new int[arr.length];
        mergeSort(arr, 0, arr.length - 1, temp);
        System.out.println("排序後:" + Arrays.toString(arr));
    }

    /**
     * 分 和 合併
     */
    public void mergeSort(int[] arr, int left, int right, int[] temp) {
        //確保兩個陣列中分別都存在至少一個數字
        if (left < right) {
            int mid = (left + right) / 2;
            // 先分解左側
            mergeSort(arr, left, mid, temp);
            // 再分解右側
            mergeSort(arr, mid + 1, right, temp);
            // 最後合併
            // 由於是遞迴,合併這裡一定是棧頂的先執行(兩邊陣列各只有一個數時)
            // 第一次:left = 0,right=1
            // 第二次:left = 2,right=3
            // 第三次:left = 0,right=3
//            System.out.println("left=" + left + ";right=" + right);
            merge(arr, left, mid, right, temp);
        }
    }

     /**
     * 
     *  最難的是合併,所以可以先完成合並的方法,此方法請參考 基本思想 和 思路分析中的圖解來完成
     *  動腦筋
     *
     * 
     *
     * @param arr   要排序的原始陣列
     * @param left  因為是合併,所以要得到左右兩邊的的陣列資訊,這個是左側陣列的第一個索引值
     * @param mid   因為是一個陣列,標識是第一個陣列的最後一個索引,即 mid+1 是右側陣列的第一個索引,即中間索引
     * @param right 右側陣列的結束索引,右邊陣列的最後一個數
     * @param temp  臨時空間,臨時陣列
     */
    public void merge(int[] arr, int left, int mid, int right, int[] temp) {
        // 定時臨時變數,用來遍歷陣列比較
        int l = left;  // 左邊有序陣列的初始索引
        int r = mid + 1; // 右邊有序陣列的初始索引
        int t = 0; // temp 陣列中當前最後一個有效資料的索引
        
        // 1. 按思路先將兩個陣列(有序的)有序的合併到 temp 中
        // 因為是合併兩個陣列,所以需要兩邊的陣列都還有值的時候才能進行 比較合併
        // 若其中一個陣列的值遍歷完了(取完了),那麼就跳出該迴圈,把另一個陣列所剩的值,直接copy進臨時陣列即可
        while (l <= mid && r <= right) {
            // 如果左邊的比右邊的小,則將左邊的該元素填充到 temp 中
            // 並移動 t++,l++,好繼續下一個
            if (arr[l] < arr[r]) {
                temp[t] = arr[l];
                t++;//移動 temp 臨時陣列中當前最後一個有效資料的索引
                l++; //左邊原始陣列的索引移動
            }
            // 否則則將右邊的移動到 temp 中
            else {
                temp[t] = arr[r];
                t++;//移動 temp 臨時陣列中當前最後一個有效資料的索引
                r++;//右邊原始陣列的索引移動
            }
        }
        // 2. 如果有任意一邊的陣列還有值,則依序將剩餘資料填充到 temp 中
        // 如果左側還有值
        while (l <= mid) {
            temp[t] = arr[l];
            t++;
            l++;
        }
        // 如果右側還有值
        while (r <= right) {
            temp[t] = arr[r];
            t++;
            r++;
        }

        // 3. 將 temp 陣列,拷貝到原始陣列
        // 注意:只拷貝當前temp有效資料到對應的原始資料中,通俗點說,就是原始陣列中要排序的資料,通過temp排成了有序的,然後將temp中的有序資料將原始陣列中原來要排序的那部分覆蓋了就行了
        int tempL = left; // 從左邊開始拷貝,左邊第一個值的索引
        t = 0;  // temp 中的有效值索引,有效值邊界可以通過 right 判定,因為合併兩個陣列到 temp 中,邊界就是 right ,這裡自己好好想想
        /*
         * 對於 8,4,5,7,1,3,6,2 這個陣列,
         * 從棧頂開始合併:8,4 | 5,7       1,3 | 6,2
         * 先左遞迴的話:
         * 第一次:8,4 合併:tempL=0,right=1
         * 第二次:5,7 合併:tempL=2,right=3
         * 第三次:4,8 | 5,7 進行合併,tempL=0,right=3
         * 直到左遞迴完成,得到左側一個有序的序列:4,5,7,8 然後再開始遞迴右邊那個無序的
         * 最後回到棧底分解成兩個陣列的地方,將兩個陣列合併成一個有序陣列
         * 這裡真的得自己想了,我只能這麼說了。
         */
        System.out.println("tempL=" + tempL + "; right=" + right);
        while (tempL <= right) {
            arr[tempL] = temp[t];
            tempL++;
            t++;
        }
    }

測試輸出

tempL=0; right=1
tempL=2; right=3
tempL=0; right=3
tempL=4; right=5
tempL=6; right=7
tempL=4; right=7
tempL=0; right=7
排序後:[1, 2, 3, 4, 5, 6, 7, 8] 

從這裡也可以看到,先左遞迴的話,可以看到最開始合併的索引是 0,1 也就是在棧頂開始首次遞迴時:兩個陣列中分別只有一個數字。

最後一次合併:則是回到了最初棧底開始分解的方法,將兩個陣列 0到7 進行排序到臨時陣列 temp ,最後將temp中的資料再從 0到7 覆蓋到原始陣列中。完成了排序 。

8 個數字,會進行 7 次 合併

結合上面動圖進行思考。

對程式碼的一些改進

根據上述所講的基本思想和思路分析,對程式碼進行了一些改進修改,減小程式碼的臃腫。

public class MyMergeSortTest {
    @Test
    public void sortTest() {
        int[] arr = new int[]{8, 4, 5, 7, 1, 3, 6, 2};
        mergeSort(arr);
        System.out.println("排序後:" + Arrays.toString(arr));
    }

    public void mergeSort(int arr[]) {
        int[] temp = new int[arr.length];
        doMergeSort(arr, 0, arr.length - 1, temp);
    }

    /**
     * 分治 與 合併
     *
     * @param arr
     * @param left
     * @param right
     * @param temp
     */
    private void doMergeSort(int[] arr, int left, int right, int[] temp) {
        // 當還可以分解時,就做分解操作
        if (left < right) {
            int mid = (left + right) / 2;
            // 先左遞迴分解
            doMergeSort(arr, left, mid, temp);
            // 再右遞迴分解
            doMergeSort(arr, mid + 1, right, temp);
            // 左遞迴分解到棧頂時,其實也是分為左右陣列
            // 左右都到棧頂時,開始合併:
            // 第一次:合併的是 0,1 的索引,分解到最後的時候,其實只有一個數為一組,所以第一次合併是合併兩個數字
            merge(arr, left, mid, right, temp);
        }
    }

    /**
     * 開始合併,每次合併都是兩組,第一次合併是兩個數字,左右一組都只有一個數字
     *
     * @param arr
     * @param left
     * @param mid
     * @param right
     * @param temp
     */
    private void merge(int[] arr, int left, int mid, int right, int[] temp) {
        // 1. 按照規則,將 temp 陣列填充
        // 2. 當任意一邊填充完成後,剩餘未填充的依次填充到 temp 陣列中
        // 3. 將 temp 陣列的有效內容,拷貝會原陣列,也就是將這次排序好的陣列覆蓋回原來排序的原陣列中

        int l = left; // 左側陣列初始索引
        int r = mid + 1; // 右側陣列初始索引
        int t = 0; // 當前 temp 中有效資料的最後一個元素索引
        
        // 1. 按照規則,將 temp 陣列填充
        // 當兩邊都還有數字可比較的時候,進行比較,然後填充 temp 陣列
        // 只要任意一邊沒有數值可用時,就僅需到下一步驟
        while (l <= mid && r <= right) {
            // 當左邊比右邊小時,則填充到 temp 陣列中
            if (arr[l] < arr[r]) {
                // 賦值完成後,t 和 l 都需要 +1,往後移動一個位置
                // t++ 是先把 t 進行賦值,再進行 t+1 操作
                temp[t++] = arr[l++];
            } else {
                // 當不滿足時,則說明 右側數字比左側的小,進行右側的填充
                temp[t++] = arr[r++];
            }
        }

        // 2. 有可能有其中一邊會有剩餘數字未填充到 temp 中,進行收尾處理
        while (l <= mid) {
            temp[t++] = arr[l++];
        }
        while (r <= right) {
            temp[t++] = arr[r++];
        }

        // 3. 將這個有序陣列,覆蓋會原始排序的陣列中
        t = 0;
        int tempL = left; // 從左側開始,到右側結束,就是這一次要合併的兩組資料
        while (tempL <= right) {
            arr[tempL++] = temp[t++];
        }
    }
}

大資料量耗時測試

    /**
     * 大量資料排序時間測試
     */
    @Test
    public void bulkDataSort() {
        int max = 80000;
//        max = 8;
        int[] arr = new int[max];
        for (int i = 0; i < max; i++) {
            arr[i] = (int) (Math.random() * 80000);
        }
        if (arr.length < 10) {
            System.out.println("原始陣列:" + Arrays.toString(arr));
        }
        Instant startTime = Instant.now();
        int[] temp = new int[arr.length];
        mergeSort(arr, 0, arr.length - 1, temp);
        if (arr.length < 10) {
            System.out.println("排序後:" + Arrays.toString(arr));
        }
        Instant endTime = Instant.now();
        System.out.println("共耗時:" + Duration.between(startTime, endTime).toMillis() + " 毫秒");
    }

多次測試輸出

共耗時:26 毫秒
共耗時:37 毫秒
共耗時:30 毫秒
共耗時:30 毫秒

複雜度

歸併排序比較佔用記憶體,但卻是一種效率高且穩定的演算法。

改進歸併排序在歸併時先判斷前段序列的最大值與後段序列最小值的關係再確定是否進行復制比較。如果前段序列的最大值小於等於後段序列最小值,則說明序列可以直接形成一段有序序列不需要再歸併,反之則需要。所以在序列本身有序的情況下時間複雜度可以降至O(n)。

TimSort可以說是歸併排序的終極優化版本,主要思想就是檢測序列中的天然有序子段(若檢測到嚴格降序子段則翻轉序列為升序子段)。在最好情況下無論升序還是降序都可以使時間複雜度降至為O(n),具有很強的自適應性。

最好時間複雜度 最壞時間複雜度 平均時間複雜度 空間複雜度 穩定性
傳統歸併排序 O(nlogn) O(nlogn) O(nlogn) T(n) 穩定
改進歸併排序 [1] O(n) O(nlogn) O(nlogn) T(n) 穩定
TimSort O(n) O(nlogn) O(nlogn) T(n) 穩定

我個人感覺歸併排序的邏輯相比快速排序來說更為容易理解,而且時間複雜度和快排一樣。關於快排的有關講解請看 資料結構與演算法——排序演算法-快速排序

相關文章