資料結構與演算法——歸併排序: 陣列&連結串列&遞迴&非遞迴解法全家桶

Heriam發表於2020-12-19

原文連結:https://jiang-hao.com/articles/2020/algorithms-algorithms-merge-sort.html

演算法介紹

歸併排序(Merge sort)是建立在歸併操作上的一種有效的排序演算法。該演算法是採用分治法(Divide and Conquer)的一個非常典型的應用。

作為一種典型的分而治之思想的演算法應用,歸併排序的實現由兩種方法:

  • 自上而下的遞迴:它從樹的頂端開始,然後向下操作,每次操作都問同樣的問題(我需要做什麼來排序這個陣列?)並回答它(分成兩個子陣列,進行遞迴呼叫,合併結果),直到我們到達樹的底部。

    Picture2.png

  • 自下而上的迭代:不需要遞迴。它直接從樹的底部開始,然後通過遍歷這些片段再將它們合併起來。

    Picture1.png

在《資料結構與演算法 JavaScript 描述》中,作者給出了自下而上的迭代方法。但是對於遞迴法,作者卻認為:

However, it is not possible to do so in JavaScript, as the recursion goes too deep for the language to handle.
然而,在 JavaScript 中這種方式不太可行,因為這個演算法的遞迴深度對它來講太深了。

和選擇排序一樣,歸併排序的效能不受輸入資料的影響,但表現比選擇排序好的多,因為始終都是 O(nlogn) 的時間複雜度。代價是需要額外的記憶體空間。

歸併排序分為三個過程:

  1. 將數列劃分為兩部分(在均勻劃分時時間複雜度為 );
  2. 遞迴地分別對兩個子序列進行歸併排序;
  3. 合併兩個子序列。

不難發現,歸併排序的核心是如何合併兩個子序列,前兩步都很好實現。

其實合併的時候也不難操作。注意到兩個子序列在第二步中已經保證了都是有序的了,第三步中實際上是想要把兩個 有序 的序列合併起來。

演算法步驟

  1. 申請空間,使其大小為兩個已經排序序列之和,該空間用來存放合併後的序列;
  2. 設定兩個指標,最初位置分別為兩個已經排序序列的起始位置;
  3. 比較兩個指標所指向的元素,選擇相對小的元素放入到合併空間,並移動指標到下一位置;
  4. 重複步驟 3 直到某一指標達到序列尾;
  5. 將另一序列剩下的所有元素直接複製到合併序列尾。

img

程式碼實現

陣列實現時間複雜度O(NlogN),空間複雜度O(N)

遞迴實現一:每次歸併時都建立一個輔助陣列

public static int[] sort(int[] nums) {
    // 對陣列進行拷貝,不改變引數內容
    int[] arr = Arrays.copyOf(nums, nums.length);

    if (arr.length<2) return arr;

    int middle = (int) Math.floor(arr.length >> 1);

    int[] left = Arrays.copyOfRange(arr, 0, middle);
    int[] right = Arrays.copyOfRange(arr, middle, arr.length);

    return merge(sort(left), sort(right));
}

public static int[] merge(int[] left, int[] right) {
    // 建立一個輔助陣列儲存歸併結果
    int[] result = new int[left.length+right.length];
    int i=0, j=0;
    while (i+j < result.length) {
        // 右側陣列全都轉存完時,直接將左側陣列剩餘的元素轉存到結果陣列
        if (j==right.length) {
            result[i+j] = left[i++];
        }
        // 左側陣列全都轉存完時,直接將右側陣列剩餘的元素轉存到結果陣列
        else if (i==left.length) {
            result[i+j] = right[j++];
        }
        // 否則,將兩個子陣列當前元素中較小的那個轉存到結果陣列中
        else result[i+j] = left[i]<=right[j]? left[i++]: right[j++];
    }
    return result;
}

力扣執行結果:

執行用時:10 ms, 在所有 Java 提交中擊敗了30.97%的使用者

記憶體消耗:44.2 MB, 在所有 Java 提交中擊敗了99.55%的使用者

提交時間提交結果執行時間記憶體消耗語言
幾秒前通過10 ms43.8 MBJava
幾秒前通過10 ms43.9 MBJava
幾秒前通過10 ms44.1 MBJava

遞迴實現二:僅建立一次一個等長的輔助陣列,交替歸併

/**
 * 遞迴交替合併
 * @param src 待合併的陣列
 * @param dst 合併結果陣列
 * @param start  陣列 src 的 start 下標
 * @param end 陣列 src 的 end 下標
 */
public static void sort(int[] src, int[] dst, int start, int end) {
    // 當待排序段[start,end)僅包含小於等於一個元素時,自然有序
    if (end-start<2) {
        dst[start]=src[start];
        return;
    }
    // 將左右兩段子陣列分別排好序
    int i = start + (end-start)/4;
    int ii = start + (end-start)/2;
    int iii = start + 3*(end-start)/4;
    sort(src, dst, start, i);
    sort(src, dst, i, ii);
    sort(src, dst, ii, iii);
    sort(src, dst, iii, end);
    merge(dst, src, start, i, ii);
    merge(dst, src, ii, iii, end);
    // 最後歸併
    merge(src, dst, start, ii, end);
}

/**
 * 歸併方法:合併左右兩段已分別排好序的 src[start:middle) 和 src[middle:end) 到 dst[start:end)
 * 陣列 src 的 [start:middle) 部分以及 [middle:end) 部分都已經各自排好序
 * @param src 待合併的陣列
 * @param dst 合併結果陣列
 * @param start  陣列 src 的 start 下標
 * @param middle  陣列 src 的 middle 下標
 * @param end 陣列 src 的 end 下標
 */
private static void merge(int [] src, int [] dst, int start, int middle, int end){
    int i = start;
    int j = middle;
    int k = start;
    while (k<end) {
        // 右側陣列全都轉存完時,直接將左側陣列剩餘的元素轉存到結果陣列
        if (j==end) {
            dst[k++] = src[i++];
        }
        // 左側陣列全都轉存完時,直接將右側陣列剩餘的元素轉存到結果陣列
        else if (i==middle) {
            dst[k++] = src[j++];
        }
        // 否則,將兩個子陣列當前元素中較小的那個轉存到結果陣列中
        else dst[k++] = src[i]<=src[j]? src[i++]: src[j++];
    }
}

力扣執行結果:

執行用時:6 ms, 在所有 Java 提交中擊敗了64.16%的使用者

記憶體消耗:45.7 MB, 在所有 Java 提交中擊敗了86.98%的使用者

提交時間提交結果執行時間記憶體消耗語言
幾秒前通過6 ms46.3 MBJava
幾秒前通過6 ms45.9 MBJava
幾秒前通過6 ms46 MBJava

非遞迴實現

/**
 * 歸併方法:合併左右兩段已分別排好序的 src[start:middle) 和 src[middle:end) 到 dst[start:end)
 * 陣列 src 的 [start:middle) 部分以及 [middle:end) 部分都已經各自排好序
 * @param src 待合併的陣列
 * @param dst 合併結果陣列
 * @param start  陣列 src 的 start 下標
 * @param middle  陣列 src 的 middle 下標
 * @param end 陣列 src 的 end 下標
 */
private static void merge(int [] src, int [] dst, int start, int middle, int end){
    int i = start;
    int j = middle;
    int k = start;

    while (k < end) {
        if (i==middle) dst[k++] = src[j++];
        else if (j==end) dst[k++] = src[i++];
        else dst[k++] = src[i] <= src[j] ? src[i++] : src[j++];
    }
}

/**
 * 用於合併排好序的相鄰陣列段
 * 將 x 合併到 y
 * @param x
 * @param y
 * @param s 合併大小
 */
private static void mergePass(int [] x,int [] y,int s){
    //從第一個元素開始
    int i = 0;
    //i+2*s 要小於等於陣列長度,也就是說未合併的元素個數要大於2*s
    while (i + 2*s <= x.length) {
        //合併大小為s的相鄰2段子陣列
        merge(x, y, i, i+s, i+2*s);
        i += 2*s;
    }
    //此迴圈執行的次數為: x.length/(2*s) 次       9/(2*1)=4
    //若未合併的元素個數大於 1*s,則合併最後兩個序列
    if (i+s < x.length) merge(x, y, i, i+s, x.length);
    //否則直接複製到y
    else {
        while (i < x.length) {
            y[i] = x[i++];
        }
    }
}
/**
 * 消去遞迴後的歸併排序演算法
 * @param a
 */
public static void mergeSort(int []a ){
    //申請個大小和a相等的陣列b
    int[] tmp = new int[a.length];
    int s = 1;
    //這裡不能為<=
    while (s < a.length) {
        //交替合併
        mergePass(a, tmp, s);
        s *= 2;
        mergePass(tmp, a, s);
        s *= 2;
    }
}

力扣執行結果:

執行用時:7 ms, 在所有 Java 提交中擊敗了55.19%的使用者

記憶體消耗:45.8 MB, 在所有 Java 提交中擊敗了75.00%的使用者

提交時間提交結果執行時間記憶體消耗語言
幾秒前通過7 ms45.8 MBJava
幾秒前通過8 ms45.7 MBJava
幾秒前通過7 ms45.7 MBJava

連結串列實現時間複雜度O(NlogN),空間複雜度O(1)

遞迴實現

static class ListNode {
    int val;
    ListNode next;
    ListNode() {}
    ListNode(int val) { this.val = val; }
    ListNode(int val, ListNode next) { this.val = val; this.next = next; }
}

public static ListNode sortList(ListNode head) {
    if (head==null || head.next==null) return head;
    //連結串列的快慢指標二分法
    ListNode slow = head;
    ListNode fast = head.next;
    while (fast != null && fast.next != null) {
        slow = slow.next;
        fast = fast.next.next;
    }
    //找到中間節點
    ListNode tmp = slow.next;
    //二分切割連結串列
    slow.next = null;
    //遞迴呼叫歸併
    return merge(sortList(head), sortList(tmp));
}

public static ListNode merge(ListNode left, ListNode right) {
    //建立哨兵節點,存放歸併結果
    ListNode sentinel = new ListNode(-1);
    //建立指標,維護尾節點
    ListNode tail = sentinel;
    //兩個子鏈都還存在節點時,進入迴圈體
    while (left!=null && right!=null) {
        //較小的節點放入結果連結串列,對應子鏈去頭
        if (left.val <= right.val) {
            tail.next = left;
            left = left.next;
        } else {
            tail.next = right;
            right = right.next;
        }
        //更新尾節點
        tail = tail.next;
    }
    //將還有剩餘節點的子鏈直接尾接到結果鏈
    tail.next = left==null? right: left;
    //返回歸併結果
    return sentinel.next;
}

力扣執行結果:

執行用時:6 ms, 在所有 Java 提交中擊敗了53.76%的使用者

記憶體消耗:46.5 MB, 在所有 Java 提交中擊敗了23.27%的使用者

提交時間提交結果執行時間記憶體消耗語言
幾秒前通過6 ms46.5 MBJava
2 分鐘前通過6 ms47 MBJava
2 分鐘前通過6 ms46.6 MBJava

非遞迴實現(從底至頂直接合並)

public static ListNode sortList(ListNode head) {
    // 求連結串列長度
    ListNode h = head;
    int length = 0;
    while (h != null) {
        h = h.next;
        length+=1;
    }
    // 初始化準備
    int blockSize = 1;
    // 結果連結串列哨兵
    ListNode res = new ListNode(-1);
    res.next = head;
    // 從1到length迭代單元塊大小
    while (blockSize<length) {
        // 結果連結串列遊標
        ListNode pre = res;
        h = res.next;
        // 兩兩遍歷所有單元塊
        while (h != null) {
            // 求第一個子鏈
            ListNode h1 = h;
            int i = blockSize;
            while (i>0 && h!=null) {
                h = h.next;
                i-=1;
            }
            if (i>0) break;
            // 求第二個子鏈
            ListNode h2 = h;
            i = blockSize;
            while (i>0 && h!=null) {
                h = h.next;
                i-=1;
            }
            // 合併兩個子鏈
            int c1 = blockSize;
            int c2 = blockSize-i;
            while (c1>0 && c2>0) {
                if(h1.val <= h2.val) {
                    pre.next = h1;
                    h1 = h1.next;
                    c1-=1;
                } else {
                    pre.next = h2;
                    h2 = h2.next;
                    c2-=1;
                }
                pre = pre.next;
            }
            // 將比較完後其中剩下的那個子鏈所有節點直接尾接到結果連結串列
            pre.next = c1>0 ? h1 : h2;
            // 歸正遊標
            while (c1>0 || c2>0) {
                pre = pre.next;
                c1-=1;
                c2-=1;
            }
            // 歸正歸併後的連結串列尾節點回原連結串列
            pre.next = h;
        }
        blockSize *= 2;
    }
    return res.next;
}

力扣執行結果:

執行用時:8 ms, 在所有 Java 提交中擊敗了41.64%的使用者

記憶體消耗:43.1 MB, 在所有 Java 提交中擊敗了66.86%的使用者

提交時間提交結果執行時間記憶體消耗語言
幾秒前通過8 ms43.1 MBJava
3 分鐘前通過9 ms43.1 MBJava
7 分鐘前通過8 ms42.8 MBJava

演算法複雜度

最優時間複雜度:O(n*log(n))

最壞時間複雜度:O(n*log(n))

平均時間複雜度:O(n*log(n))

最壞空間複雜度:總共O(n),輔助O(n);當使用linked list,輔助空間為O(1).

相關文章