歸併排序詳解及應用

labuladong發表於2022-02-28

讀完本文,你不僅學會了演算法套路,還可以順便去 LeetCode 上拿下如下題目:

912. 排序陣列(中等)

315. 計算右側小於當前元素的個數(困難)

-----------

一直都有很多讀者說,想讓我用 框架思維 講一講基本的排序演算法,我覺得確實得講講,畢竟學習任何東西都講求一個融會貫通,只有對其本質進行比較深刻的理解,才能運用自如。

本文就先講歸併排序,給一套程式碼模板,然後講講它在演算法問題中的應用。閱讀本文前我希望你讀過前文 手把手刷二叉樹(綱領篇)

我在 手把手刷二叉樹(第一期) 講二叉樹的時候,提了一嘴歸併排序,說歸併排序就是二叉樹的後序遍歷,當時就有很多讀者留言說醍醐灌頂。

知道為什麼很多讀者遇到遞迴相關的演算法就覺得燒腦嗎?因為還處在「看山是山,看水是水」的階段。

就說歸併排序吧,如果給你看程式碼,讓你腦補一下歸併排序的過程,你腦子裡會出現什麼場景?

這是一個陣列排序演算法,所以你腦補一個陣列的 GIF,在那一個個交換元素?如果是這樣的話,那格局就低了。

但如果你腦海中浮現出的是一棵二叉樹,甚至浮現出二叉樹後序遍歷的場景,那格局就高了,大概率掌握了我經常強調的 框架思維,用這種抽象能力學習演算法就省勁多了。

那麼,歸併排序明明就是一個陣列演算法,和二叉樹有什麼關係?接下來我就具體講講。

演算法思路

就這麼說吧,所有遞迴的演算法,你甭管它是幹什麼的,本質上都是在遍歷一棵(遞迴)樹,然後在節點(前中後序位置)上執行程式碼,你要寫遞迴演算法,本質上就是要告訴每個節點需要做什麼

你看歸併排序的程式碼框架:

// 定義:排序 nums[lo..hi]
void sort(int[] nums, int lo, int hi) {
    if (lo == hi) {
        return;
    }
    int mid = (lo + hi) / 2;
    // 利用定義,排序 nums[lo..mid]
    sort(nums, lo, mid);
    // 利用定義,排序 nums[mid+1..hi]
    sort(nums, mid + 1, hi);

    /****** 後序位置 ******/
    // 此時兩部分子陣列已經被排好序
    // 合併兩個有序陣列,使 nums[lo..hi] 有序
    merge(nums, lo, mid, hi);
    /*********************/
}

// 將有序陣列 nums[lo..mid] 和有序陣列 nums[mid+1..hi]
// 合併為有序陣列 nums[lo..hi]
void merge(int[] nums, int lo, int mid, int hi);

看這個框架,也就明白那句經典的總結:歸併排序就是先把左半邊陣列排好序,再把右半邊陣列排好序,然後把兩半陣列合並。

上述程式碼和二叉樹的後序遍歷很像:

/* 二叉樹遍歷框架 */
void traverse(TreeNode root) {
    if (root == null) {
        return;
    }
    traverse(root.left);
    traverse(root.right);
    /****** 後序位置 ******/
    print(root.val);
    /*********************/
}

再進一步,你聯想一下求二叉樹的最大深度的演算法程式碼:

// 定義:輸入根節點,返回這棵二叉樹的最大深度
int maxDepth(TreeNode root) {
    if (root == null) {
        return 0;
    }
    // 利用定義,計算左右子樹的最大深度
    int leftMax = maxDepth(root.left);
    int rightMax = maxDepth(root.right);
    // 整棵樹的最大深度等於左右子樹的最大深度取最大值,
    // 然後再加上根節點自己
    int res = Math.max(leftMax, rightMax) + 1;

    return res;
}

是不是更像了?

前文 手把手刷二叉樹(綱領篇) 說二叉樹問題可以分為兩類思路,一類是遍歷一遍二叉樹的思路,另一類是分解問題的思路,根據上述類比,顯然歸併排序利用的是分解問題的思路(分治演算法)。

歸併排序的過程可以在邏輯上抽象成一棵二叉樹,樹上的每個節點的值可以認為是 nums[lo..hi],葉子節點的值就是陣列中的單個元素

然後,在每個節點的後序位置(左右子節點已經被排好序)的時候執行 merge 函式,合併兩個子節點上的子陣列:

這個 merge 操作會在二叉樹的每個節點上都執行一遍,執行順序是二叉樹後序遍歷的順序。

後序遍歷二叉樹大家應該已經爛熟於心了,就是下圖這個遍歷順序:

結合上述基本分析,我們把 nums[lo..hi] 理解成二叉樹的節點,srot 函式理解成二叉樹的遍歷函式,整個歸併排序的執行過程就是以下 GIF 描述的這樣:

這樣,歸併排序的核心思路就分析完了,接下來只要把思路翻譯成程式碼就行。

程式碼實現及分析

只要擁有了正確的思維方式,理解演算法思路是不困難的,但把思路實現成程式碼,也很考驗一個人的程式設計能力

畢竟演算法的時間複雜度只是一個理論上的衡量標準,而演算法的實際執行效率要考慮的因素更多,比如應該避免記憶體的頻繁分配釋放,程式碼邏輯應儘可能簡潔等等。

經過對比,《演算法 4》中給出的歸併排序程式碼兼具了簡潔和高效的特點,所以我們可以參考書中給出的程式碼作為歸併演算法模板:

class Merge {

    // 用於輔助合併有序陣列
    private static int[] temp;

    public static void sort(int[] nums) {
        // 先給輔助陣列開闢記憶體空間
        temp = new int[nums.length];
        // 排序整個陣列(原地修改)
        sort(nums, 0, nums.length - 1);
    }

    // 定義:將子陣列 nums[lo..hi] 進行排序
    private static void sort(int[] nums, int lo, int hi) {
        if (lo == hi) {
            // 單個元素不用排序
            return;
        }
        // 這樣寫是為了防止溢位,效果等同於 (hi + lo) / 2
        int mid = lo + (hi - lo) / 2;
        // 先對左半部分陣列 nums[lo..mid] 排序
        sort(nums, lo, mid);
        // 再對右半部分陣列 nums[mid+1..hi] 排序
        sort(nums, mid + 1, hi);
        // 將兩部分有序陣列合併成一個有序陣列
        merge(nums, lo, mid, hi);
    }

    // 將 nums[lo..mid] 和 nums[mid+1..hi] 這兩個有序陣列合併成一個有序陣列
    private static void merge(int[] nums, int lo, int mid, int hi) {
        // 先把 nums[lo..hi] 複製到輔助陣列中
        // 以便合併後的結果能夠直接存入 nums
        for (int i = lo; i <= hi; i++) {
            temp[i] = nums[i];
        }

        // 陣列雙指標技巧,合併兩個有序陣列
        int i = lo, j = mid + 1;
        for (int p = lo; p <= hi; p++) {
            if (i == mid + 1) {
                // 左半邊陣列已全部被合併
                nums[p] = temp[j++];
            } else if (j == hi + 1) {
                // 右半邊陣列已全部被合併
                nums[p] = temp[i++];
            } else if (temp[i] > temp[j]) {
                nums[p] = temp[j++];
            } else {
                nums[p] = temp[i++];
            }
        }
    }
}

有了之前的鋪墊,這裡只需要著重講一下這個 merge 函式。

sort 函式對 nums[lo..mid]nums[mid+1..hi] 遞迴排序完成之後,我們沒有辦法原地把它倆合併,所以需要 copy 到 temp 陣列裡面,然後通過類似於前文 單連結串列的六大技巧 中合併有序連結串列的雙指標技巧將 nums[lo..hi] 合併成一個有序陣列:

注意我們不是在 merge 函式執行的時候 new 輔助陣列,而是提前把 temp 輔助陣列 new 出來了,這樣就避免了在遞迴中頻繁分配和釋放記憶體可能產生的效能問題。

再說一下歸併排序的時間複雜度,雖然大夥兒應該都知道是 O(NlogN),但不見得所有人都知道這個複雜度怎麼算出來的。

前文 動態規劃詳解 說過遞迴演算法的複雜度計算,就是子問題個數 x 解決一個子問題的複雜度。對於歸併排序來說,時間複雜度顯然集中在 merge 函式遍歷 nums[lo..hi] 的過程,但每次 merge 輸入的 lohi 都不同,所以不容易直觀地看出時間複雜度。

merge 函式到底執行了多少次?每次執行的時間複雜度是多少?總的時間複雜度是多少?這就要結合之前畫的這幅圖來看:

執行的次數是二叉樹節點的個數,每次執行的複雜度就是每個節點代表的子陣列的長度,所以總的時間複雜度就是整棵樹中「陣列元素」的個數

所以從整體上看,這個二叉樹的高度是 logN,其中每一層的元素個數就是原陣列的長度 N,所以總的時間複雜度就是 O(NlogN)

力扣第 912 題「排序陣列」就是讓你對陣列進行排序,我們可以直接套用歸併排序程式碼模板:

class Solution {
    public int[] sortArray(int[] nums) {
        // 歸併排序對陣列進行原地排序
        Merge.sort(nums);
        return nums;
    }
}

class Merge {
    // 見上文
}

其他應用

除了最基本的排序問題,歸併排序還可以用來解決力扣第 315 題「計算右側小於當前元素的個數」:

拍腦袋的暴力解法就不說了,巢狀 for 迴圈,平方級別的複雜度。

這題和歸併排序什麼關係呢,主要在 merge 函式,我們在合併兩個有序陣列的時候,其實是可以知道一個數字 x 後邊有多少個數字比 x 小的。

具體來說,比如這個場景:

這時候我們應該把 temp[i] 放到 nums[p] 上,因為 temp[i] < temp[j]

但就在這個場景下,我們還可以知道一個資訊:5 後面比 5 小的元素個數就是 jmid + 1 之間的元素個數,即 2 個。

換句話說,在對 nuns[lo..hi] 合併的過程中,每當執行 nums[p] = temp[i] 時,就可以確定 temp[i] 這個元素後面比它小的元素個數為 j - mid - 1

當然,nums[lo..hi] 本身也只是一個子陣列,這個子陣列之後還會被執行 merge,其中元素的位置還是會改變。但這是其他遞迴節點需要考慮的問題,我們只要在 merge 函式中做一些手腳,疊加每次 merge 時記錄的結果即可。

發現了這個規律後,我們只要在 merge 中新增兩行程式碼即可解決這個問題,看解法程式碼:

class Solution {
    private class Pair {
        int val, id;
        Pair(int val, int id) {
            // 記錄陣列的元素值
            this.val = val;
            // 記錄元素在陣列中的原始索引
            this.id = id;
        }
    }
    
    // 歸併排序所用的輔助陣列
    private Pair[] temp;
    // 記錄每個元素後面比自己小的元素個數
    private int[] count;
    
    // 主函式
    public List<Integer> countSmaller(int[] nums) {
        int n = nums.length;
        count = new int[n];
        temp = new Pair[n];
        Pair[] arr = new Pair[n];
        // 記錄元素原始的索引位置,以便在 count 陣列中更新結果
        for (int i = 0; i < n; i++)
            arr[i] = new Pair(nums[i], i);
        
        // 執行歸併排序,本題結果被記錄在 count 陣列中
        sort(arr, 0, n - 1);
        
        List<Integer> res = new LinkedList<>();
        for (int c : count) res.add(c);
        return res;
    }
    
    // 歸併排序
    private void sort(Pair[] arr, int lo, int hi) {
        if (lo == hi) return;
        int mid = lo + (hi - lo) / 2;
        sort(arr, lo, mid);
        sort(arr, mid + 1, hi);
        merge(arr, lo, mid, hi);
    }
    
    // 合併兩個有序陣列
    private void merge(Pair[] arr, int lo, int mid, int hi) {
        for (int i = lo; i <= hi; i++) {
            temp[i] = arr[i];
        }
        
        int i = lo, j = mid + 1;
        for (int p = lo; p <= hi; p++) {
            if (i == mid + 1) {
                arr[p] = temp[j++];
            } else if (j == hi + 1) {
                arr[p] = temp[i++];
                // 更新 count 陣列
                count[arr[p].id] += j - mid - 1;
            } else if (temp[i].val > temp[j].val) {
                arr[p] = temp[j++];
            } else {
                arr[p] = temp[i++];
                // 更新 count 陣列
                count[arr[p].id] += j - mid - 1;
            }
        }
    }
}

因為在排序過程中,每個元素的索引位置會不斷改變,所以我們用一個 Pair 類封裝每個元素及其在原始陣列 nums 中的索引,以便 count 陣列記錄每個元素之後小於它的元素個數。

你現在回頭體會下我在本文開頭說那句話:

所有遞迴的演算法,本質上都是在遍歷一棵(遞迴)樹,然後在節點(前中後序位置)上執行程式碼。你要寫遞迴演算法,本質上就是要告訴每個節點需要做什麼

有沒有品出點味道?

最後總結一下吧,本文從二叉樹的角度講了歸併排序的核心思路和程式碼實現,同時講了一道歸併排序相關的演算法題。這道演算法題其實就是歸併排序演算法邏輯中夾雜一點私貨,但仍然屬於比較難的,你可能需要親自做一遍才能理解。

那我最後留一個思考題吧,下一篇文章我會講快速排序,你是否能夠嘗試著從二叉樹的角度去理解快速排序?如果讓你用一句話總結快速排序的邏輯,你怎麼描述?

好了,答案下篇文章揭曉。

點選我的頭像 檢視更多優質演算法文章,手把手帶你刷力扣,致力於把演算法講清楚!我的 演算法教程 已經獲得 100k star,歡迎點贊!

相關文章