讀完本文,你不僅學會了演算法套路,還可以順便去 LeetCode 上拿下如下題目:
-----------
一直都有很多讀者說,想讓我用 框架思維 講一講基本的排序演算法,我覺得確實得講講,畢竟學習任何東西都講求一個融會貫通,只有對其本質進行比較深刻的理解,才能運用自如。
本文就先講歸併排序,給一套程式碼模板,然後講講它在演算法問題中的應用。閱讀本文前我希望你讀過前文 手把手刷二叉樹(綱領篇)。
我在 手把手刷二叉樹(第一期) 講二叉樹的時候,提了一嘴歸併排序,說歸併排序就是二叉樹的後序遍歷,當時就有很多讀者留言說醍醐灌頂。
知道為什麼很多讀者遇到遞迴相關的演算法就覺得燒腦嗎?因為還處在「看山是山,看水是水」的階段。
就說歸併排序吧,如果給你看程式碼,讓你腦補一下歸併排序的過程,你腦子裡會出現什麼場景?
這是一個陣列排序演算法,所以你腦補一個陣列的 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
輸入的 lo
和 hi
都不同,所以不容易直觀地看出時間複雜度。
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 小的元素個數就是 j
和 mid + 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,歡迎點贊!