歸並排序詳解及應用


讀完本文,你不僅學會了算法套路,還可以順便去 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,歡迎點贊!


免責聲明!

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



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