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