本文是遞歸算法系列文的第7篇,依然沿着遞歸的脈絡,介紹了常常運用遞歸處理問題的一個典型數據結構——二叉樹。分析總結了LeetCode中的相關習題在解法上的思路和模板。
本文內容如下:
- 樹的前、中、後序、層次遍歷的遞歸和非遞歸寫法
- LeetCode上樹的問題分類(基本遍歷、路徑、計數、加和、深寬、構造、BST等)
- 兩種遍歷爲思想的問題(
判定、比較結點或子樹
以及路徑[ 和 ]、累計
) - 【小結】在解決樹的遍歷相關問題時,我們是如何使用基本遍歷方法,進行遞歸設計的?
由於樹的相關問題題目較多,本文介紹第一部分,其餘部分後續更新。(又挖了一個坑)
0.LeetCode中二叉樹定義
class TreeNode {
int val;
TreeNode left;
TreeNode right;
TreeNode(int x) { val = x; }
}
1.四種遍歷(遞歸+非遞歸)
1.1遞歸遍歷
List<Integer> res = new ArrayList<>();
public List<Integer> preorderTraversal(TreeNode root) {
if(root == null) return res;
res.add(root.val);
preorderTraversal(root.left);
preorderTraversal(root.right);
return res;
}
List<Integer> res = new ArrayList<>();
public List<Integer> inorderTraversal(TreeNode root) {
if(root == null) return res;
inorderTraversal(root.left);
res.add(root.val);
inorderTraversal(root.right);
return res;
}
List<Integer> res = new ArrayList<>();
public List<Integer> postorderTraversal(TreeNode root) {
if(root == null) return res;
postorderTraversal(root.left);
postorderTraversal(root.right);
res.add(root.val);
return res;
}
1.2非遞歸遍歷
前序
public List<Integer> preorderTraversal(TreeNode root) {
List<Integer> res = new ArrayList<>();
if(root == null) return res;
Deque<TreeNode> stack = new ArrayDeque<>();
TreeNode p = root;
while(!stack.isEmpty() || p != null){
while(p!= null){ //一口氣走到左下最後一個,一邊走一邊入棧、同時加入結果集
stack.push(p);
res.add(p.val);
p = p.left;
}
p = stack.pop().right; //逐個往上、然後遍歷右子樹
}
return res;
}
注:前序還有一種寫法:這種寫法具有結構對稱美哦~後序就知道了
- 根不空時,根入棧
- 當棧非空時:
- 根出棧,加入res。
- 若右子樹非空,右子樹入棧
- 若左子樹非空,左子樹入棧
public List<Integer> preorderTraversal(TreeNode root) {
List<Integer> res = new ArrayList<>();
if(root == null) return res;
Deque<TreeNode> stack = new ArrayDeque<>();
TreeNode p = root;
stack.push(p); //根節點入棧
while(!stack.isEmpty()){ //當根節點不空時,出棧一個根節點,然後加入res中
p = stack.pop();
res.add(p.val);
if(p.right != null) //加入右子樹入棧
stack.push(p.right);
if(p.left != null) //左子樹入棧
stack.push(p.left);
}
return res;
}
中序
public List<Integer> inorderTraversal(TreeNode root) {
List<Integer> res = new ArrayList<>();
if(root == null) return res;
Deque<TreeNode> stack = new ArrayDeque<>();
TreeNode p = root;
while(!stack.isEmpty() || p != null){ //一口氣走到左下角一邊走,一邊入棧,
while(p != null){ //但是是在出棧後才加到結果集
stack.push(p);
p = p.left;
}
p = stack.pop();
res.add(p.val);
p = p.right;
}
return res;
}
後序
後序遍歷使用了一個小技巧:
- 後序遍歷是右 =》左 =》 根, 那麼我們可以先按照根 =》左 =》右(前序)進行遍歷,然後將得到的結果進行翻轉
public List<Integer> postorderTraversal(TreeNode root) {
List<Integer> res = new ArrayList<>();
if(root == null) return res;
Deque<TreeNode> stack = new ArrayDeque<>();
// Deque<TreeNode> temp_res = new ArrayDeque<>();
TreeNode p = root;
stack.push(p);
while(!stack.isEmpty()){
p = stack.pop();
res.add(p.val);
if(p.left != null)
stack.push(p.left);
if(p.right != null)
stack.push(p.right);
}
Collections.reverse(res); //將結果翻轉,這一步也可以用棧
return res;
}
1.3層次遍歷
以LC102爲例:輸入二叉樹數組,輸出層次遍歷結果,並且每一層爲一個list,整體爲二維list
https://leetcode-cn.com/problems/binary-tree-level-order-traversal/
public List<List<Integer>> levelOrder(TreeNode root) {
List<List<Integer>> res = new ArrayList<>();
if(root == null) return res;
Deque<TreeNode> queue = new ArrayDeque<>();
queue.offer(root);
while(!queue.isEmpty()){
List<Integer> item = new ArrayList<>();
for(int i = queue.size() ; i > 0 ; i --){
TreeNode node = queue.poll();
item.add(node.val);
if(node.left!= null) queue.offer(node.left);
if(node.right!= null) queue.offer(node.right);
}
res.add(item);
}
return res;
}
2.常見問題分類彙總
LC中樹標籤下的前60來道題經過總結,從問題角度,大概如下包括類別:【標紅爲本次涉及內容,未標紅爲後續文章內容】
-
遍歷樹進行比較(結點、子樹)
-
深度、路徑(和)、計數
-
(修改)構造相關
- 根據遍歷構造(前中構造,後中構造)
- 前序後繼、鏈表轉化
- AVL(這個與深度關係也大)
- 翻轉、修改等構造
- 序列化反序列化
-
二叉搜索樹相關(可能會運用BST性質來解題)
-
明顯帶有層次遍歷的暗示
- 寬度、鋸齒遍歷、層平均值、最大值、左右視圖、右側指針
-
順序(前繼後繼)
暫且大概將其分爲這些類別
其實這樣分類也不是太好,一是有些題的從解法層面有多種,二是有些雖然題雖然屬於這一類,但是解法更像另一類。。能力有限還請見諒
樹的遍歷(判定、比較子樹或節點間的關係)
思路:
判斷樹相同等價於:對兩棵樹的每一對應節點左如下判斷:
- 若p,q同時爲空,則爲true
- 若一個空一個非空,則爲false
- 若p、q兩個非空:
- p.val 與 q.val 不等,則false
- 若相等,對其左右子樹進行上述相同判斷(遞歸),且當左右子樹同時滿足時,返回true
public boolean isSameTree(TreeNode p, TreeNode q) {
if(p == null && q == null) return true;
if(p == null || q == null || p.val != q.val) return false;
return isSameTree(p.left , q.left) && isSameTree(p.right, q.right);
}
1
/ \
2 2
/ \ / \
3 4 4 3
思路:按照對稱條件,對應節點相等(左子樹的左子樹與右子樹的右子樹、左子樹的右子樹與右子樹的左子樹)
- 若根爲空直接返回true
- 比較根的左子樹和右子樹(值是否相等)
- 左右子樹同爲null ==> true
- 左右子樹只有一個爲null,或者都不爲null但值不同 ==> false
- 遞歸對:左子樹的左子樹與右子樹的右子樹、左子樹的右子樹與右子樹的左子樹 進行考察
public boolean isSymmetric(TreeNode root) {
if(root == null) return true;
return compare(root.left , root.right);
}
private boolean compare(TreeNode node1, TreeNode node2){
if(node1 == null && node2 == null) return true;
if(node1 == null || node2 == null || node1.val != node2.val)
return false;
return compare(node1.left , node2.right) && compare(node1.right , node2.left);
}
思路1
平衡二叉樹 等價於下面兩個條件都滿足:
- 左子樹高度與右子樹高度差爲1
- 左右子樹也爲平衡二叉樹
public boolean isBalanced(TreeNode root){
if (root == null) return true;
return Math.abs(depth(root.left) - depth(root.right))
<=1 && isBalanced(root.left) && isBalanced(root.right);
}
//就是求高度
private int depth(TreeNode node){
if(node == null) return 0;
if(node.left == null && node.right == null)
return 1;
return Math.max(depth(node.left) , depth(node.right)) +1;
}
思路2:
- 精妙之處在於用-1表示非平衡,0、1分別表示兩棵樹(爲平衡時)的高度差,大於1也表示非平衡
- 向左遞歸到最左得到左邊結果left (表示的是以所有左子樹的判斷結果)
- 向右遞歸到最右得到右邊結果right(表示的是以所有右子樹的判斷結果)
- 返回結果:即考察左右兩子樹差值是否小於2
public boolean isBalanced(TreeNode root){
if(root == null) return true;
return checkBalanced(root) != -1;
}
private int checkBalanced(TreeNode node){
if(node == null) return 0;
int left = checkBalanced(node.left);
if(left == -1) return -1;
int right = checkBalanced(node.right);
if(right == -1) return -1;
return Math.abs(left - right) < 2? Math.max(left , right) + 1: -1;
}
思路:
- 遞歸比對s中所有結點與t的根
- 當s某結點值等於t的根的值時,遞歸比較兩子樹
boolean flag = false;
public boolean isSubtree(TreeNode s, TreeNode t) {
if(s == null && t == null) return true;
if(s == null || t == null) return false;
if(s .val == t.val) flag = verty(s, t);
if(flag) return true; //當前節點已經找到了,直接返回truetrue
//當前節點沒找到,需要遞歸去左右節點找
flag = isSubtree(s.left , t) || isSubtree(s.right, t);
return flag;
}
//判斷以s和t兩個節點爲根的兩個樹是否全等
private boolean verty(TreeNode s, TreeNode t) {
if(s == null && t == null) return true;
if(s == null || t == null) return false;
if(s.val != t.val) return false;
return verty(s.left , t.left) && verty(s.right , t.right);
}
思路:通過題意可以看出,該二叉樹的最小值即爲根,因此我們只需要找到和根值不同的那個最小值即可,它可能在左子樹也可能在右子樹。
因此,設計遞歸函數helper(root, root.val)
分別在左子樹和右子樹中去尋找大於根的值的結點,返回二者中的較小值
public int findSecondMinimumValue(TreeNode root) {
if(root == null) return -1;
return helper(root, root.val);
}
private int helper(TreeNode root , int min){
if(root == null) return -1;
if(root.val > min) return root.val; //這裏表示找到了
int left = helper(root.left , min);
int right = helper(root.right , min);
if(left == -1) return right;
if(right == -1) return left;
return Math.min(left , right);
}
深度、路徑(和)、計數、加和
思路:該樹深度即爲左子樹和右子樹深度大的那個值加1。 對於其左子樹和右子樹同樣如此,因此進行遞歸求解
public int maxDepth(TreeNode root) {
if(root == null) return 0;
int left_height = maxDepth(root.left);
int right_height = maxDepth(root.right);
return Math.max(left_height, right_height)+1;
}
思路:二叉樹最小深度即爲左子樹和右子樹中深度小的那個
public int minDepth(TreeNode root) {
if(root == null) return 0;
if(root.left == null && root.right == null)
return 1;
//注意此處,當左或右子樹邊沒有時,需要在另一邊加1
if(root.left == null)
return minDepth(root.right) +1;
if(root.right == null)
return minDepth(root.left) +1;
return Math.min(minDepth(root.left) , minDepth(root.right)) +1;
}
思路:求從任意一個節點到另一個節點的最大路徑。我們需要在遞歸函數中需要做兩件事:
- 返回從該節點到下方某節點(也有可能就是自身不動)的路徑最大值
- 對該節點,比較更新 res 和(當前節點值+左路徑最大值 + 右路徑最大值)
-10 25 (34)
/ \ / \
9 20 ==》 9(9) 35 (35)
/ \ / \
15 -7 15(15) 0(-7)
- 對於9,15,-7葉子節點,其返回9,15,0 (負數返回0,表示上面的節點不會來這)
- 對20節點來說,返回其與左孩子節點和35,根節點類似
- 上圖中,無括號的表示每個位置處返回的最大往下的路徑; 括號中表示此時的最終結果(res)
int res = Integer.MIN_VALUE;
public int maxPathSum(TreeNode root) {
dfs(root);
return res;
}
private int dfs(TreeNode root){
if(root == null) return 0;
int left = dfs(root.left);
int right = dfs(root.right);
res = Math.max(res , left + root.val + right);
return Math.max(0 , root.val + Math.max(left,right));
}
思路:在進行遞歸遍歷時,保存一個curr,用於存放目前爲止的結果,因此在遞歸遍歷中,對每個結點
int res ;
public int sumNumbers(TreeNode root) {
if(root == null) return 0;
dfs(root , 0);
return res;
}
private void dfs(TreeNode node , int curr){
if(node == null) return;
curr += node.val;
if(node.left == null && node.right == null )
res += curr;
dfs(node.left , curr*10);
dfs(node.right , curr*10);
}
思路:判斷在樹中存在一條和爲未指定值的路徑,遞歸遍歷,每次將sum減去當前val即可
public boolean hasPathSum(TreeNode root, int sum) {
if(root == null) return false;
if(sum == root.val && root.left == null && root.right == null)
return true;
boolean left = false, right = false;
//if(root.left != null ) 前面已經對root進行判空
left = hasPathSum(root.left , sum - root.val) ;
//if(root.right != null)
right = hasPathSum(root.right , sum - root.val);
return left || right;
}
public List<List<Integer>> pathSum(TreeNode root, int sum) {
List<List<Integer>> res = new ArrayList<>();
if(root == null) return res;
Deque<Integer> item = new ArrayDeque<>();
dfs(root, sum , res ,item);
return res;
}
private void dfs(TreeNode node , int sum , List<List<Integer>> res ,Deque<Integer> item ){
if(node == null ) return;
item.add(node.val);
if(sum == node.val && node.left == null && node.right == null){
res.add(new ArrayList<Integer>(item));
item.removeLast(); //回溯,返回上一個節點
return;
}
dfs(node.left , sum - node.val , res , item);
dfs(node.right , sum - node.val , res, item);
item.removeLast(); //回溯
}
思路:該題在111的基礎上擴充了兩點:
- 111從根出發,該題是從任意節點出發
- 針對該問題,我們需要將每個節點視爲根進行遍歷一次。
- 111到達葉子,該題可到任意下方節點
- 結束條件不用再判斷是否爲葉子
public int pathSum(TreeNode root, int sum) {
if(root == null ) return 0;
//求以該節點爲根的路徑個數
int res = findPath(root, sum);
//對每個節點,都作爲根進行尋找路徑
int left = pathSum(root.left , sum);
int right = pathSum(root.right , sum);
//最後返回:當前節點出發的路徑數+左子樹所有滿足的路徑+右子樹所有路徑
return left + res + right;
}
//求以這個節點爲根的路徑個數的具體實現
private int findPath(TreeNode node, int sum){
if(node == null) return 0;
int count = sum == node.val ? 1 : 0; //若到達此處時,sum剛好還剩node.val,count計爲1
return count + findPath(node.left ,sum - node.val) + findPath(node.right, sum -node.val);
}
public List<String> binaryTreePaths(TreeNode root) {
List<String> res = new ArrayList<>();
if(root == null) return res;
dfs(root, res , new StringBuilder());
return res;
}
private void dfs(TreeNode root, List<String> res ,StringBuilder stringBuilder){
if(root == null ) return ;
if(root.left == null && root.right == null){
res.add(stringBuilder.toString() + root.val);
}else{
String temStr = root.val + "->";
stringBuilder.append(temStr);
dfs(root.left , res , stringBuilder);
dfs(root.right , res ,stringBuilder);
//回溯
stringBuilder.delete(stringBuilder.length() - temStr.length(),
stringBuilder.length());
}
}
思路:此題和124,類似,都是在遞歸同時要幹兩件事:
- 返回的是該節點作爲根時樹中的最長同值路徑
- 比較
int res;
public int longestUnivaluePath(TreeNode root) {
res = 0;
getLength(root);
return res;
}
private int getLength(TreeNode root) {
if(root == null) return 0;
int left = getLength(root.left);//遞歸求解左子樹的結果
int right = getLength(root.right);
int tem_left = 0 , tem_right = 0;
if(root.left != null && root.val == root.left.val)
tem_left += left+1; //左邊結果值爲左邊遞歸返回的結果加1
if(root.right != null && root.val == root.right.val)
tem_right += right +1;
res = Math.max(res , tem_left +tem_right);
return Math.max(tem_left,tem_right);
}
思路:可以中途傳值
public int sumOfLeftLeaves(TreeNode root) {
if(root == null ) return 0;
int sum = 0;
if(root.left != null && root.left.left ==null && root.left.right == null)
sum += root.left.val;
return sum + sumOfLeftLeaves(root.left) + sumOfLeftLeaves(root.right);
}
思路:
- 對每個結點,求其子樹和,放到一個HashMap<子樹和,次數>,
Map<Integer,Integer> hasMap = new HashMap<>();
public int[] findFrequentTreeSum(TreeNode root) {
dfs_starNode(root );
//此時hashMap中存放各個頂點的子樹和及其出現的次數
//遍歷得到出現最多值的次數
int maxCount = 0;
for(Map.Entry<Integer, Integer> entry : hashMap.entrySet()) {
if(entry.getValue() > maxCount)
maxCount = entry.getValue();
}
//根據次數,再次遍歷,得到和
List<Integer> resList = new ArrayList<>();
for(Map.Entry<Integer, Integer> entry : hashMap.entrySet()) {
if(entry.getValue() == maxCount)
resList.add(entry.getKey());
}
//list => int[]
int res[] = new int[resList.size()];
for(int i = 0 ; i < resList.size() ; i++) {
res[i] = resList.get(i);
}
return res;
}
//從每個點存放 其作爲根時子樹的和
private void dfs_starNode(TreeNode root) {
if(root == null) return ;
int val = dfs_getSum(root);
// resList.add(val);
if(hashMap.containsKey(val))
hashMap.put(val, hashMap.get(val)+1);
else
hashMap.put(val, 1);
dfs_starNode(root.left);
dfs_starNode(root.right);
}
//求和
private int dfs_getSum(TreeNode root) {
// System.out.println("+");
if(root == null) return 0;
return root.val +dfs_getSum(root.left) +dfs_getSum(root.right);
}
思路:
-
在countNodes遞歸函數中,做如下事情:
-
計算當前結點的左子樹層數left
-
計算當前結點的右子樹層數right
-
若left = right :
則說明就當前結點而言,左子樹一定是滿的(完全二叉樹性質),返回時加上
-
若left != right
右子樹滿,返回時加上遞歸求左子樹的結點數
-
public int countNodes(TreeNode root) {
if(root == null) return 0;
int left= countLevel(root.left);
int right = countLevel(root.right);
if(left == right)
return countNodes(root.right) + ( 1<<left); //左子樹滿
else
return countNodes(root.left) + (1 << right); //右子樹滿
}
private int countLevel(TreeNode node) {
int level = 0;
while(node!= null) {
level ++;
node = node.left ; //完全二叉樹左子樹具有代表性
}
return level;
}
小結
上面的兩類題型(比較判定、路徑累計)從本質上來說都是屬於遍歷性問題的,只不過在遍歷過程中,比基本遍歷的操作更爲麻煩(輔助變量、集合列表) 和/或 邏輯更爲複雜(左右子樹加和、bool運算、回溯、剪枝) 。
那麼,以前序遍歷的模板爲例,我們可以總結歸納很多相類似問題的解題規律和套路(希望如此):
(1)基礎遍歷模板:
public void preorder(TreeNode root) {
if(root == null) return; //判空條件不可少,因爲下面要取root的左右子樹,也可能取val
/* 這裏每一輪遍歷需要做的事情 */
//TODO
preorder(root.left); //遞歸對其左子樹做相同的事情
preorder(root.right); //遞歸對其右子樹做相同的事情
}
在每一個結點的關鍵處理步驟中:
/* 這裏每一輪遍歷需要做的事情 */
我們可能用一個變量去累計每個結點的和(已達到我們需要的結果)
也可能會用一個集合去收集,那麼這些個集合作爲參數在遞歸過程中傳遞就好,這一點在前面文章中有詳細介紹:
public void dfs(TreeNode root, ArrayList<Integer> item, ... ) {
//下面操作類似,每一輪可能會有添加,移除等試探回溯的操作
注意到這種返回類型是void,其實我們一般會在外部聲明好一些變量(累計和、結果集合等),在遍歷過程中對其進行改變,因此這種往往作爲一些輔助函數,比如:
//LC671二叉樹中第二小的節點
public int findSecondMinimumValue(TreeNode root) {
if(root == null) return -1;
return helper(root, root.val);
}
...
//LC127
public List<String> binaryTreePaths(TreeNode root) {
List<String> res = new ArrayList<>(); //外部變量,在遞歸中進行修改
if(root == null) return res;
dfs(root, res , new StringBuilder()); //主要進行遞歸的函數
return res;
}
...
(2)帶返回值的遍歷模板:
有時我們會設計遞歸函數返回我們所需要的結果,比如:
-
返回 數值 類型:
往往出現在求某些滿足條件的結果,累計(深度、個數等)、路徑(和)
-
返回 布爾 類型:
往往出現在對於結點的判斷(比較相等、是否滿足條件)
數值型遍歷模板如下:
public int dfs(TreeNode root) {
if(root == null) return 0;
int left = dfs(root.left); //遞歸對root的左子樹求取結果
int right = dfs(root.right); //遞歸對root的右子樹求取結果
/* 一些對左右子樹的操作、比較、統計等 */
//TODO
return 與left和right有關的操作結果
}
上面那種也可以看做是一股腦走到葉子結點,然後再逐個返回其結果,然後根據返回的結果進行相應的操作。
例如:返回一棵樹中所有結點值的和
private int dfs_getSum(TreeNode root) {
if(root == null) return 0;
int left = dfs(root.left);
int right = dfs(root.right);
return root.val +left + right;
}
例如:LC104求二叉樹的最大深度:
public int maxDepth(TreeNode root) {
if(root == null) return 0;
int left_height = maxDepth(root.left);
int right_height = maxDepth(root.right);
return Math.max(left_height, right_height)+1; //取左右子樹中深的那個,再加1
}
判斷:檢查是否平衡(通過返回值的絕對值小於等於1來判斷):
private int checkBalanced(TreeNode node){
if(node == null) return 0;
int left = checkBalanced(node.left); //遞歸求左子樹
if(left == -1) return -1;
int right = checkBalanced(node.right);
if(right == -1) return -1;
//關鍵:判斷每個結點的左右子樹的高度差絕對值是否小於2
//若是,則加上自身的高度(返回:子樹高度+1)
//若否:直接返回-1
return Math.abs(left - right) < 2? Math.max(left , right) + 1: -1;
}
bool型遍歷模板類似,只是在操作中更多的是比較:
public boolean dfs(TreeNode root){
if(root == null) return false;
/*這裏往往有一些滿足結果的結束條件,也有可能有一些剪枝條件*/
//TODO
boolean left = false , right = false;
left = dfs(root.left); //遞歸對左子樹進行判斷
right = dfs(root.right); //遞歸對右子樹進行判斷
return 對左右結果left和right進行操作,這裏往往就是bool操作
}
例如:LC112路徑總和
判斷棵樹中是否有一條從根到葉子的路徑,使得其結點val的和等於sum。
public boolean hasPathSum(TreeNode root, int sum) {
if(root == null) return false;
if(sum == root.val && root.left == null && root.right == null)
return true;
boolean left = false, right = false;
left = hasPathSum(root.left , sum - root.val) ;
right = hasPathSum(root.right , sum - root.val);
return left || right;
}
當然,也可以是對兩棵樹之間進行比較
例如:LC100相同的樹
public boolean isSameTree(TreeNode p, TreeNode q) {
if(p == null && q == null) return true;
if(p == null || q == null || p.val != q.val) return false;
return isSameTree(p.left , q.left) && isSameTree(p.right, q.right); //遞歸對p樹的左節點和q數的左節點比較,以及p樹的右節點和q樹的右節點比較,結果取交集
}
以及對稱二叉樹。
(3)一些複雜或是混合的遍歷
這種往往也是由上述兩種構成,拆分就好。比如有時我們會進行下列考察:
- 對樹每一個結點,都作爲一棵樹去遞歸考察其中結點,如上面[LC572另一個樹的子樹]和[LC437路徑總和3]
- 有時遞歸中返回的結果值並非是我們想要的,而是在遞歸函數過程中“其它操作”纔是想要的結果。在此類問題中,遞歸函數往往會做兩件事:1、操作a以返回遞歸結果。2、遞歸過程中進行一些另外的操作b,操作b與遞歸結果相結合才能得到該問題結果。。有點繞,參考上面[LC124二叉樹中的最大路徑和]的解法。
本文介紹了二叉樹問題中以遞歸遍歷爲主的習題以及解法思路。重點剖析了相關遍歷模板類型和應用。有關遞歸設計思路以及其他應用可看如下文章: