數據結構與算法——排序算法-歸並排序


簡單介紹

歸並排序(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) 穩定

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


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM