東哥手把手帶你刷二叉樹(第一期)
讀完本文,你可以去力扣拿下如下題目:
-----------
我們的成名之作 學習資料結構和演算法的框架思維 中多次強調,先刷二叉樹的題目,先刷二叉樹的題目,先刷二叉樹的題目,因為很多經典演算法,以及我們前文講過的所有回溯、動歸、分治演算法,其實都是樹的問題,而樹的問題就永遠逃不開樹的遞迴遍歷框架這幾行破程式碼:
/* 二叉樹遍歷框架 */ void traverse(TreeNode root) { // 前序遍歷 traverse(root.left) // 中序遍歷 traverse(root.right) // 後序遍歷 }
上篇公眾號文章讓讀者留言說說對什麼問題還有疑惑,我接下來可以重點寫一寫相關的文章。結果還有很多讀者說覺得「遞迴」非常難以理解,說實話,遞迴解法應該是最簡單,最容易理解的才對,行雲流水地寫遞迴程式碼是學好演算法的基本功,而二叉樹相關的題目就是最練習遞迴基本功,最練習框架思維的。
PS:我認真寫了 100 多篇原創,手把手刷 200 道力扣題目,全部發布在 labuladong的演算法小抄,持續更新。建議收藏,按照我的文章順序刷題,掌握各種演算法套路後投再入題海就如魚得水了。
我先花一些篇幅說明二叉樹演算法的重要性。
一、二叉樹的重要性
舉個例子,比如說我們的經典演算法「快速排序」和「歸併排序」,對於這兩個演算法,你有什麼理解?如果你告訴我,快速排序就是個二叉樹的前序遍歷,歸併排序就是個二叉樹的後續遍歷,那麼我就知道你是個演算法高手了
為什麼快速排序和歸併排序能和二叉樹扯上關係?我們來簡單分析一下他們的演算法思想和程式碼框架:
快速排序的邏輯是,若要對 nums[lo..hi]
進行排序,我們先找一個分界點 p
,通過交換元素使得 nums[lo..p-1]
都小於等於 nums[p]
,且 nums[p+1..hi]
都大於 nums[p]
,然後遞迴地去 nums[lo..p-1]
和 nums[p+1..hi]
中尋找新的分界點,最後整個陣列就被排序了。
快速排序的程式碼框架如下:
void sort(int[] nums, int lo, int hi) { /****** 前序遍歷位置 ******/ // 通過交換元素構建分界點 p int p = partition(nums, lo, hi); /************************/ sort(nums, lo, p - 1); sort(nums, p + 1, hi); }
先構造分界點,然後去左右子陣列構造分界點,你看這不就是一個二叉樹的前序遍歷嗎?
再說說歸併排序的邏輯,若要對 nums[lo..hi]
進行排序,我們先對 nums[lo..mid]
排序,再對 nums[mid+1..hi]
排序,最後把這兩個有序的子數組合並,整個陣列就排好序了。
歸併排序的程式碼框架如下:
void sort(int[] nums, int lo, int hi) {
int mid = (lo + hi) / 2;
sort(nums, lo, mid);
sort(nums, mid + 1, hi);
/****** 後序遍歷位置 ******/
// 合併兩個排好序的子陣列
merge(nums, lo, mid, hi);
/************************/
}
先對左右子陣列排序,然後合併(類似合併有序連結串列的邏輯),你看這是不是二叉樹的後序遍歷框架?另外,這不就是傳說中的分治演算法嘛,不過如此呀。
如果你一眼就識破這些排序演算法的底細,還需要背這些演算法程式碼嗎?這不是手到擒來,從框架慢慢擴充套件就能寫出演算法了。
說了這麼多,旨在說明,二叉樹的演算法思想的運用廣泛,甚至可以說,只要涉及遞迴,都可以抽象成二叉樹的問題,所以接下來,我們直接上幾道比較有意思,且能體現出遞迴演算法精妙的二叉樹題目,東哥手把手教你怎麼用演算法框架搞定它們。
PS:我認真寫了 100 多篇原創,手把手刷 200 道力扣題目,全部發布在 labuladong的演算法小抄,持續更新。建議收藏,按照我的文章順序刷題,掌握各種演算法套路後投再入題海就如魚得水了。
二、寫遞迴演算法的祕訣
我們前文 二叉樹的最近公共祖先 寫過,寫遞迴演算法的關鍵是要明確函式的「定義」是什麼,然後相信這個定義,利用這個定義推導最終結果。
怎麼理解呢,我們用一個具體的例子來說,比如說讓你計算一棵二叉樹共有幾個節點:
// 定義:count(root) 返回以 root 為根的樹有多少節點
int count(TreeNode root) {
// base case
if (root == null) return 0;
// 自己加上子樹的節點數就是整棵樹的節點數
return 1 + count(root.left) + count(root.right);
}
這個問題非常簡單,大家應該都會寫這段程式碼,root
本身就是一個節點,加上左右子樹的節點數就是以 root
為根的樹的節點總數。
左右子樹的節點數怎麼算?其實就是計算根為 root.left
和 root.right
兩棵樹的節點數唄,按照定義,遞迴呼叫 count
函式即可算出來。
寫樹相關的演算法,簡單說就是,先搞清楚當前 root
節點該做什麼,然後根據函式定義遞迴呼叫子節點,遞迴呼叫會讓孩子節點做相同的事情。
我們接下來看幾道演算法題目實操一下。
三、演算法實踐
第一題、翻轉二叉樹
我們先從簡單的題開始,看看力扣第 226 題「翻轉二叉樹」,輸入一個二叉樹根節點 root
,讓你把整棵樹映象翻轉,比如輸入的二叉樹如下:
4
/ \
2 7
/ \ / \
1 3 6 9
演算法原地翻轉二叉樹,使得以 root
為根的樹變成:
4
/ \
7 2
/ \ / \
9 6 3 1
通過觀察,我們發現只要把二叉樹上的每一個節點的左右子節點進行交換,最後的結果就是完全翻轉之後的二叉樹。
可以直接寫出解法程式碼:
// 將整棵樹的節點翻轉
TreeNode invertTree(TreeNode root) {
// base case
if (root == null) {
return null;
}
/**** 前序遍歷位置 ****/
// root 節點需要交換它的左右子節點
TreeNode tmp = root.left;
root.left = root.right;
root.right = tmp;
// 讓左右子節點繼續翻轉它們的子節點
invertTree(root.left);
invertTree(root.right);
return root;
}
這道題目比較簡單,關鍵思路在於我們發現翻轉整棵樹就是交換每個節點的左右子節點,於是我們把交換左右子節點的程式碼放在了前序遍歷的位置。
值得一提的是,如果把交換左右子節點的程式碼放在後序遍歷的位置也是可以的,但是放在中序遍歷的位置是不行的,請你想一想為什麼?這個應該不難想到,我會把答案置頂在公眾號留言區。
首先講這道題目是想告訴你,二叉樹題目的一個難點就是,如何把題目的要求細化成每個節點需要做的事情。
這種洞察力需要多刷題訓練,我們看下一道題。
第二題、填充二叉樹節點的右側指標
這是力扣第 116 題,看下題目:
Node connect(Node root);
題目的意思就是把二叉樹的每一層節點都用 next
指標連線起來:
而且題目說了,輸入是一棵「完美二叉樹」,形象地說整棵二叉樹是一個正三角形,除了最右側的節點 next
指標會指向 null
,其他節點的右側一定有相鄰的節點。
這道題怎麼做呢?把每一層的節點穿起來,是不是隻要把每個節點的左右子節點都穿起來就行了?
我們可以模仿上一道題,寫出如下程式碼:
Node connect(Node root) {
if (root == null || root.left == null) {
return root;
}
root.left.next = root.right;
connect(root.left);
connect(root.right);
return root;
}
這樣其實有很大問題,再看看這張圖:
節點 5 和節點 6 不屬於同一個父節點,那麼按照這段程式碼的邏輯,它倆就沒辦法被穿起來,這是不符合題意的。
回想剛才說的,二叉樹的問題難點在於,如何把題目的要求細化成每個節點需要做的事情,但是如果只依賴一個節點的話,肯定是沒辦法連線「跨父節點」的兩個相鄰節點的。
那麼,我們的做法就是增加函式引數,一個節點做不到,我們就給他安排兩個節點,「將每一層二叉樹節點連線起來」可以細化成「將每兩個相鄰節點都連線起來」:
// 主函式
Node connect(Node root) {
if (root == null) return null;
connectTwoNode(root.left, root.right);
return root;
}
// 輔助函式
void connectTwoNode(Node node1, Node node2) {
if (node1 == null || node2 == null) {
return;
}
/**** 前序遍歷位置 ****/
// 將傳入的兩個節點連線
node1.next = node2;
// 連線相同父節點的兩個子節點
connectTwoNode(node1.left, node1.right);
connectTwoNode(node2.left, node2.right);
// 連線跨越父節點的兩個子節點
connectTwoNode(node1.right, node2.left);
}
這樣,connectTwoNode
函式不斷遞迴,可以無死角覆蓋整棵二叉樹,將所有相鄰節點都連線起來,也就避免了我們之前出現的問題,這道題就解決了。
第三題、將二叉樹展開為連結串列
這是力扣第 114 題,看下題目:
函式簽名如下:
void flatten(TreeNode root);
我們嘗試給出這個函式的定義:
給 flatten
函式輸入一個節點 root
,那麼以 root
為根的二叉樹就會被拉平為一條連結串列。
我們再梳理一下,如何按題目要求把一棵樹拉平成一條連結串列?很簡單,以下流程:
1、將 root
的左子樹和右子樹拉平。
2、將 root
的右子樹接到左子樹下方,然後將整個左子樹作為右子樹。
上面三步看起來最難的應該是第一步對吧,如何把 root
的左右子樹拉平?其實很簡單,按照 flatten
函式的定義,對 root
的左右子樹遞迴呼叫 flatten
函式即可:
// 定義:將以 root 為根的樹拉平為連結串列
void flatten(TreeNode root) {
// base case
if (root == null) return;
flatten(root.left);
flatten(root.right);
/**** 後序遍歷位置 ****/
// 1、左右子樹已經被拉平成一條連結串列
TreeNode left = root.left;
TreeNode right = root.right;
// 2、將左子樹作為右子樹
root.left = null;
root.right = left;
// 3、將原先的右子樹接到當前右子樹的末端
TreeNode p = root;
while (p.right != null) {
p = p.right;
}
p.right = right;
}
你看,這就是遞迴的魅力,你說 flatten
函式是怎麼把左右子樹拉平的?說不清楚,但是隻要知道 flatten
的定義如此,相信這個定義,讓 root
做它該做的事情,然後 flatten
函式就會按照定義工作。另外注意遞迴框架是後序遍歷,因為我們要先拉平左右子樹才能進行後續操作。
至此,這道題也解決了,我們舊文 k個一組翻轉連結串列 的遞迴思路和本題也有一些類似。
四、最後總結
遞迴演算法的關鍵要明確函式的定義,相信這個定義,而不要跳進遞迴細節。
寫二叉樹的演算法題,都是基於遞迴框架的,我們先要搞清楚 root
節點它自己要做什麼,然後根據題目要求選擇使用前序,中序,後續的遞迴框架。
二叉樹題目的難點在於如何通過題目的要求思考出每一個節點需要做什麼,這個只能通過多刷題進行練習了。
如果本文講的三道題對你有一些啟發,請三連,資料好的話東哥下次再來一波手把手刷題文,你會發現二叉樹的題真的是越刷越順手,欲罷不能,恨不得一口氣把二叉樹的題刷通。
_____________
我的 線上電子書 有 100 篇原創文章,手把手帶刷 200 道力扣題目,建議收藏!對應的 GitHub 演算法倉庫 已經獲得了 70k star,歡迎標星!