天天看點

LeetCode-樹

文章目錄

      • 一. 前言
      • 二. 關于遞歸
      • 三. 二叉樹的深度
        • ① 最大深度 LeetCode 104
        • ② 最小深度 LeetCode 111
      • 四. 樹的周遊
        • ① 二叉樹的前序周遊 LeetCode 144
        • ② 二叉樹的中序周遊 LeetCode 94
        • ③ 二叉樹的後序周遊 LeetCode 145
        • ④ 二叉樹的層序周遊 LeetCode 102
        • ⑤ 二叉樹的垂序周遊 LeetCode 987
      • 五. 根據兩種周遊來還原樹的結構
        • ①前序 + 中序 LeetCode 105
        • ② 後序 + 中序 LeetCode 106
      • 六. 左葉子之和
      • 七. 路徑和
        • ① 路徑總和 LeetCode 112.
        • ② 路徑總和 Ⅱ LeetCode 113.
        • ③ 路徑總和 Ⅲ LeetCode 437.
      • 八. 二叉搜尋樹BST
        • ① 修剪二叉搜尋樹 LeetCode 669.
        • ② 二叉搜尋樹中第K小的元素 LeetCode 230.
        • ③ 把二叉搜尋樹轉換成累加樹 LeetCode 538.
        • ④ 二叉搜尋樹的最近公共祖先 LeetCode 235
        • ⑤ 二叉樹的最近公共祖先 LCA問題 LeetCode 236

一. 前言

​ 樹也是一種常見的資料結構,在算法題裡最常見的就是數組,連結清單,哈希表以及樹。前三者平時很常用到,是以也不會覺得太難。樹如果剛開始沒有練習,會覺得很難,但在經過一定的練習和總結之後,會覺得樹的題目也是比較容易解決的。這裡目前隻記錄了比較常見且典型的樹型題目,一些奇形怪狀的樹題暫時不考慮。

二. 關于遞歸

​ 遞歸是一種常見的解題方法,主要特征為反複調用自身。遞歸會讓代碼看起來很簡潔,邏輯也很清晰,但有時候也會造成大量的重複計算。二叉樹跟連結清單是很适合進行遞歸的資料結構,因為它們的資料就像一段一段的,遞歸可以使我們把思考的重點放在“目前層次的遞歸應該做什麼”,而不必陷入整個複雜資料結構的複雜思考。

​ 遞歸的核心是反複調用自身,我們有3個點需要重點思考:

①既然是反複調用自身,那麼什麼時候結束?即我們需要尋找遞歸的終止條件

②應該傳回什麼?對于第一層遞歸,傳回值就是最終結果。同時第一層需要調用第二層的遞歸結果,那麼第二層需要傳回給上一級(也就是第一層)什麼樣的結果?

③每一層遞歸應該做什麼?也就是目前層次的遞歸,需要進行什麼邏輯?完成什麼任務?

​ 對于接下來的樹題,我們都會優先思考遞歸解題模闆,然後再尋找疊代式的解題方法。

三. 二叉樹的深度

① 最大深度 LeetCode 104

描述: 給定一個二叉樹,找出其最大深度。二叉樹的深度為根節點到最遠葉子節點的最長路徑上的節點數。題目連結

分析:先以遞歸的思路來思考:

① 如果要用遞歸,什麼時候結束?顯然,當root為null的時候,就不必繼續遞歸了,此時直接傳回0。

② 應該傳回什麼?其實每一層的傳回值都是一樣的,因為遞歸是調用自身,是以不存在說第一層傳回的是xxx概念,而其他層傳回的是yyy概念。那麼第一層傳回什麼呢,就是傳回根節點root的深度。是以後續的每一層也是一樣的,就是傳回以目前結點為root的樹的深度。

③每一層遞歸應該做什麼?終止條件,傳回值我們都已經想好了,這時候就是需要中間的邏輯。在目前層,我們需要獲得root的深度,這時候我們要遞歸下去,就是把root.left和root.right成為下一層遞歸的根節點,這樣我們就求得root的左子樹深度,以及右子樹的深度,然後它們二者較大的值加上root本身深度(也就是1),就是root的最大深度。是以 root的深度 = 1 + max(root.left的深度, root.right的深度)。

這就是遞歸的優雅之處,我們隻需要考慮目前層的邏輯,對于目前層,root的深度隻要考慮root.left的深度以及root.right的深度,至于root.left的結構是什麼,是否為null,root.left的最大深度是root.left.left還是root.left.right?這些在下一層就會變成和目前層一樣的邏輯,我們無須考慮。于是我們可以獲得代碼:

代碼:

class Solution {
    public int maxDepth(TreeNode root) {
        if (root == null)
            return 0;
        int leftDepth = maxDepth(root.left);
        int rightDepth = maxDepth(root.right);
        return 1 + Math.max(leftDepth, rightDepth);
    }
}
           

如果你更熟練,會發現leftDepth與rightDepth變量也是可以省略的,這時候就能寫出更簡潔的代碼:

class Solution {
    public int maxDepth(TreeNode root) {
        return root == null ? 0 : 
        	1 + Math.max(maxDepth(root.left), maxDepth(root.right));
    }
}
           

一行解決!相信你已經體會到了遞歸的美妙之處。當然,其實這裡還是會産生了很多重複運算,是以可以使用一個Map來存儲資料,提升效率。不要看LeetCode上效率更慢,那是測試用例的問題,如果存在一個巨型二叉樹,上面的代碼需要很長的時間,而使用Map會提升不少效率。

使用 Map作備忘錄的遞歸代碼:

class Solution {
    public int maxDepth(TreeNode root) {
        Map<TreeNode, Integer> map = new HashMap<>();
        int res = helper(root, map);
        return res;
    }

    public int helper(TreeNode root, Map<TreeNode, Integer> map) {
        if (root == null)
            return 0;
        if (map.containsKey(root))
            return map.get(root);
        int left = helper(root.left, map);
        int right = helper(root.right, map);
        int res = 1 + Math.max(left, right);
        map.put(root, res);
        return res;
    }
}
           

遞歸解法很簡單,如果不允許用遞歸呢,如果一定要用疊代的方法,應該如何編碼?這個我們等會再說。

② 最小深度 LeetCode 111

描述: RT。題目連結

分析:最大深度的遞歸結束是當結點為null的時候,而最小深度則是遇到第一個葉子結點就傳回,同時要取min而不是max,于是很容易寫出這種代碼:

class Solution {
    public int minDepth(TreeNode root) {
        if (root == null)
            return 0;
        if (root.left == null && root.right == null)
            return 1;
        return 1 + Math.min(minDepth(root.left), minDepth(root.right));
    }
}
           

然後就錯了,因為max我們可以確定肯定會選最大的子樹長度,而min可能會選擇了空子樹,使得結果出錯,比如測試用例[1, 2],這時候右子樹深度為0,答案就變成了1,而不是2,是以隻有當root.left,root.right不為null的時候才放進去遞歸。當然,最開始的判斷root是否為null還是不能省略的,因為可能輸入的參數就剛好是root。

正确的代碼:

class Solution {
    public int minDepth(TreeNode root) {
        if (root == null)
            return 0;
        if (root.left != null && root.right == null)
            return 1 + minDepth(root.left);
        if (root.right != null && root.left == null)
            return 1 + minDepth(root.right);
        return 1 + Math.min(minDepth(root.left), minDepth(root.right));
        // 如果都為null, 也不會影響結果
    }
}
           

四. 樹的周遊

描述:樹的周遊是很常見的題,包括前序周遊,中序周遊,後序周遊,層序周遊,垂序周遊(前三種比較常見)。然後還存在根據前序與中序還原樹,後序與中序還原樹等等的題目。是以我們先從樹的周遊開始總結(上一題主要解釋一下遞歸的過程)。值得注意的是,有一些題目因為遞歸解法太過簡單,是以有時候題目會要求“不要用遞歸,而是用疊代”,一下子就使得題目難度增加不少。是以這裡會盡量通過兩種方法來解決。

① 二叉樹的前序周遊 LeetCode 144

描述:RT。題目連結

遞歸解法:

class Solution {
    public List<Integer> preorderTraversal(TreeNode root) {
        List<Integer> list = new ArrayList<>();
        preOrder(root, list);
        return list;
    }

    public void preOrder(TreeNode root, List<Integer> list) {
        if (root == null)		// 遞歸結束
            return;
        list.add(root.val);		// 目前層的操作
        preOrder(root.left, list);		// 遞歸
        preOrder(root.right, list);		// 遞歸
    }
}
           

如果要把遞歸解法轉換成疊代解法,一般都可以通過棧來實作,因為遞歸實質也是使用棧,隻是遞歸壓棧的并不是數值對象,而是整個方法。是以才會有“遞歸棧"這種說法,在畫出遞歸的過程時,實際上就是畫遞歸棧。是以我們這裡通過棧來放置樹的結點,優先push右節點,因為後進先出,前序周遊的順序是:根節點->左節點->右節點,是以先push右節點,再push左節點。

疊代解法:

class Solution {
    public List<Integer> preorderTraversal(TreeNode root) {
        List<Integer> res = new ArrayList<>();
        LinkedList<TreeNode> stack = new LinkedList<>();
        if (root == null)
            return res;
        stack.push(root);
        while (!stack.isEmpty()) {
            TreeNode current = stack.pop();
            res.add(current.val);
            if (current.right != null)
                stack.push(current.right);	//先push right node
            if (current.left != null)
                stack.push(current.left);
        }
        return res;
    }
}
           

核心是先push right,然後處理所有的left結點。如果要與中序後序統一模闆,可以改成下面的代碼:

class Solution {
    public List<Integer> preorderTraversal(TreeNode root) {
        List<Integer> res = new ArrayList<>();
        LinkedList<TreeNode> stack = new LinkedList<>();
        if (root == null)
            return res;
        TreeNode current = root;
        while (current != null || !stack.isEmpty()) {
            if (current != null) {
                res.add(current.val);	// root
                stack.push(current);
                current = current.left;	// left
            }
            else {
                current = stack.pop();	// right
                current = current.right;
            }
        }
        return res;
    }
}
           

也可以作出改進,使得隻需要push right結點。

class Solution {
    // 隻push right結點的方法
    public List<Integer> preorderTraversal(TreeNode root) {
        List<Integer> res = new ArrayList<>();
        LinkedList<TreeNode> stack = new LinkedList<>();
        if (root == null)
            return res;
        TreeNode current = root;
        while (true) 
            if (current != null) {
                res.add(current.val);
                if (current.right != null)
                    stack.push(current.right);
                current = current.left;
            }
            else if (stack.isEmpty())
                return res;
            else
                current = stack.pop();
    }
}
           

② 二叉樹的中序周遊 LeetCode 94

描述: RT。題目連結

遞歸解法:

public List<Integer> inorderTraversal(TreeNode root) {
        List<Integer> list = new ArrayList<>();
        inOrder(list, root);
        return list;
    }
    
public void inOrder(List<Integer> list, TreeNode root) {
    if (root == null)
        return;
    inOrder(list, root.left);
    list.add(root.val);
    inOrder(list, root.right);
}
           

疊代同樣要用到棧,隻是要先處理所有left結點,然後在後續的出棧中,先把目前的棧頂值加入到結果集中(left和root),然後再周遊right結點,以達到:left -> root -> right的效果。

疊代解法:

class Solution {
    public List<Integer> inorderTraversal(TreeNode root) {
        List<Integer> res = new ArrayList<>();
        LinkedList<TreeNode> stack = new LinkedList<>();
        if (root == null)
            return res;
        TreeNode current = root;
        while (current != null || !stack.isEmpty()) 
            if (current != null) {
                stack.push(current);	// 把所有left壓棧
                current = current.left;
            }
            else {
                current = stack.pop();	// 出棧順序,棧頂順序為:left, root
                res.add(current.val);	// 添加到結果集
                current = current.right;	// 把right壓棧
            }
        return res;
    }
}
           

③ 二叉樹的後序周遊 LeetCode 145

描述: RT。題目連結

遞歸解法:

class Solution {
    public List<Integer> postorderTraversal(TreeNode root) {
        List<Integer> list = new ArrayList<>();
        postOrder(list, root);
        return list;
    }
    
    public void postOrder(List<Integer> list, TreeNode root) {
        if (root == null)
            return;
        postOrder(list, root.left);
        postOrder(list, root.right);
        list.add(root.val);
    }
}
           

後序周遊是在LeetCode中,三個周遊中唯一一個難度為hard的題目(當然,前提是不考慮遞歸)。這是因為前序周遊為:root -> left -> right,中序周遊為: left -> root -> right,而在壓棧過程中,實際上我們第一個壓棧的元素永遠是root,也就是第一個根節點。而後續的操作中,我們可以先處理root,然後按照right,left的順序壓棧,以達到前序周遊的目的。又或者是先處理left,然後出棧的時候處理完left再處理root,最後處理right,達到中序周遊的效果。但是後序周遊有一個很特别的點,它的順序是: left -> right -> root,也就是說我們要最後處理root,這與前面兩種都不相同。然後發現大部分的解法都比較tricky,先進行前序周遊,然後再更換順序,把它變成後序周遊。前序周遊是先push right結點,這裡更改為先push left結點,這樣結果就是:root -> right -> left,然後再完全倒序,就變成了:left -> right -> root

tricky的疊代解法:

class Solution {
    public List<Integer> postorderTraversal(TreeNode root) {
        List<Integer> res = new ArrayList<>();
        LinkedList<TreeNode> stack = new LinkedList<>();
        if (root == null)
            return res;
        stack.push(root);
        while (!stack.isEmpty()) {
            TreeNode current = stack.pop();
            res.add(0, current.val);	// addFirst, 使得最後的結果就是完全倒序
            if (current.left != null)
                stack.push(current.left);  // 先push left
            if (current.right != null)
                stack.push(current.right);
        }
        return res;
    }
}
           

這顯然不是很好,其實後序的關鍵是,先周遊了left跟right子結點,然後才周遊目前結點,是以設定一個HashSet,記錄已經通路過的結點。然後在擷取棧頂元素的時候(不要直接出棧),先去确認子結點是否已經通路,如果已經通路了,這時候再把棧頂元素出棧,添加到結果集種。如果存在不為null的子結點卻不在HashSet中,那麼此時應該把子結點push到棧中,繼續下一輪的循環。(同樣地,先push right,再push left)

正确的疊代解法:

class Solution {
    public List<Integer> postorderTraversal(TreeNode root) {
        List<Integer> res = new ArrayList<>();
        LinkedList<TreeNode> stack = new LinkedList<>();
        Set<TreeNode> set = new HashSet<>();    // visited
        if (root == null)
            return res;
        stack.push(root);
        while (!stack.isEmpty()) {
            TreeNode current = stack.peek();
            boolean canVisited = true;
            // 先push right, 使得出棧優先是left, 達到 left -> right的效果
            if (current.right != null && !set.contains(current.right)) {
                canVisited = false;
                stack.push(current.right);
            }
            if (current.left != null && !set.contains(current.left)) {
                canVisited = false;
                stack.push(current.left);
            }
            // 隻有left跟right都通路了,此時才通路root
            if (canVisited) {
                res.add(current.val);
                set.add(current);
                stack.pop();    // 通路了才出棧
            }
        }
        return res;
    }
}
           

④ 二叉樹的層序周遊 LeetCode 102

描述:給你一個二叉樹,請你傳回其按 層序周遊 得到的節點值。 (即逐層地,從左到右通路所有節點)。 題目連結

遞歸解法:

class Solution {
    public List<List<Integer>> levelOrder(TreeNode root) {
        List<List<Integer>> res = new ArrayList<>();
        helper(root, res, 0);
        return res;
    }

    public void helper(TreeNode root, List<List<Integer>> res, int level) {
        if (root == null)
            return;
        if (res.size() == level) 
            res.add(new ArrayList<>());
        res.get(level).add(root.val);
        helper(root.left, res, level + 1);
        helper(root.right, res, level + 1);
    }
}
           

對于疊代,同樣也會用到棧或者隊列來存儲結點資料。因為是目前層,是以我們可以逐層周遊,把每一層的結點全部添加到Queue當中,然後記錄size。如果size為0,說明目前層已經為空,循環結束。如果不為0,這時候我們要把Queue裡所有的結點都出列,同時添加到List中,在出列的過程中,我們會添加下一層的結點(目前結點的左節點或者右節點)。因為我們要確定隻把目前層的結點全部出列,而下一層的結點會留在下一次循環(另起一個List),是以要保證後面來的資料不會影響到前面資料的順序,最恰當的資料結構是隊列。

class Solution {
    public List<List<Integer>> levelOrder(TreeNode root) {
        List<List<Integer>> res = new ArrayList<>();
        if (root == null)
            return res;
        Queue<TreeNode> queue = new LinkedList<>();
        queue.add(root);
        while (!queue.isEmpty()) {  // Queue為空,說明下一層沒有任何結點
            List<Integer> cur = new ArrayList<>();
            int size = queue.size();    // 把Queue裡的目前資料全部出列
            while (size > 0) {
                TreeNode current = queue.poll();
                cur.add(current.val);
                if (current.left != null)
                    queue.add(current.left);
                if (current.right != null)
                    queue.add(current.right);
                size--;
            }
            res.add(cur);   // 每一次循環就是周遊了一層
        }
        return res;
    }
}
           

⑤ 二叉樹的垂序周遊 LeetCode 987

描述:給定二叉樹,按垂序周遊傳回其結點值。

對位于 (X, Y) 的每個結點而言,其左右子結點分别位于 (X-1, Y-1) 和 (X+1, Y-1)。

把一條垂線從 X = -infinity 移動到 X = +infinity ,每當該垂線與結點接觸時,我們按從上到下的順序報告結點的值( Y 坐标遞減)。

如果兩個結點位置相同,則首先報告的結點值較小。

按 X 坐标順序傳回非空報告的清單。每個報告都有一個結點值清單。

題目連結

分析:本題表述不夠清楚。對于一個結點,先考慮它的x坐标(垂序),然後再考慮它的y坐标(同一垂線,從上往下輸出),最後如果坐标相同(x,y都相同),再根據val的大小來輸出。(這種周遊方式并不常見,也不實用,記住就好,前面4種的應用都很廣泛)

class Solution {
        public List<List<Integer>> verticalTraversal(TreeNode root) {
        List<List<Integer>> res = new ArrayList<>();
        if (root == null)
            return res;
        Map<TreeNode, int[]> positions = new HashMap<>();
        LinkedList<TreeNode> queue = new LinkedList<>();
        queue.add(root);
        positions.put(root, new int[] {0, 0});
        int min = 0;    // 記錄最左和最右
        int max = 0;
        while (!queue.isEmpty()) {      // 層序周遊, 記錄每個端點的坐标
            int size = queue.size();
            while (size > 0) {
                TreeNode current = queue.poll();
                int[] pos = positions.get(current);
                if (current.left != null) {
                    queue.add(current.left);
                    positions.put(current.left, new int[] {pos[0] - 1, pos[1] + 1});
                    min = min <= pos[0] - 1 ? min : pos[0] - 1;
                }
                if (current.right != null) {
                    queue.add(current.right);
                    positions.put(current.right, new int[] {pos[0] + 1, pos[1] + 1});
                    max = max >= pos[0] + 1 ? max : pos[0] + 1;
                }
                size--;
            }
        }
        int length = max - min + 1;
        for (int i = 0; i < length; i++)    
            res.add(new ArrayList<>());

        List<int[]> list = new ArrayList<>();   // 改為使用三維數組存儲{x, y, val}
        for (Map.Entry<TreeNode, int[]> entry: positions.entrySet()) {
            int[] value = entry.getValue();
            int[] tmp = new int[] {value[0], value[1], entry.getKey().val};
            list.add(tmp);
        }
        Collections.sort(list, new Comparator<int[]>() {
            @Override
            public int compare(int[] o1, int[] o2) {
                int x = o1[0] - o2[0];
                if (x != 0)
                    return x;
                int y = o1[1] - o2[1];
                if (y != 0)
                    return y;
                return o1[2] - o2[2];   // x, y, val
            }
        });
        for (int[] ints: list) {
            List<Integer> currentList = res.get(ints[0] - min);
            currentList.add(ints[2]);
            res.set(ints[0] - min, currentList);
        }
        return res;
    }
}
           

五. 根據兩種周遊來還原樹的結構

①前序 + 中序 LeetCode 105

主要思路:先從前序周遊中得知根結點,然後把根節點代入到中序周遊,把樹劃分成左子樹和右子樹,對子樹進行遞歸,直到每一個子樹都隻有1個結點。直接看圖操作:

LeetCode-樹

之後繼續對子樹進行遞歸,左子樹[9]已經無須繼續操作,是以隻需考慮右子樹了。對右子樹處理完畢之後,遞歸結束,也就得到了最後的結果。

LeetCode-樹
class Solution {
    public TreeNode buildTree(int[] preorder, int[] inorder) {
        if (preorder.length == 0)
            return null;
        int rootVal = preorder[0];
        int i;
        for (i = 0; i < inorder.length; i++)
            if (inorder[i] == rootVal)
                break;
        TreeNode root = new TreeNode(rootVal);
        int[] leftInorder = Arrays.copyOfRange(inorder, 0, i);
        int[] rightInorder = Arrays.copyOfRange(inorder, i + 1, inorder.length);
        int[] leftPreorder = Arrays.copyOfRange(preorder, 1, leftInorder.length + 1);
        int[] rightPreorder = Arrays.copyOfRange(preorder, 1 + leftPreorder.length, preorder.length);
        root.left = buildTree(leftPreorder, leftInorder);
        root.right = buildTree(rightPreorder, rightInorder);
        return root;
    }
}
           

這裡的代碼是比較偷懶的複制數組,實際上這會導緻一定的時間開銷。更好辦法是,建立一個helper方法,傳參增加起始的index。但主要的思路都是一樣的。同時有一個細節,不要忘了去掉根節點,它在下一層循環裡并不需要用到(我們隻需要處理子樹)。

② 後序 + 中序 LeetCode 106

主要思路:思路與上面一緻,隻是尋找根節點的方法改為了通過後序周遊。後序周遊的最後一個結點為根節點。後續的操作都一樣,直接看圖操作:

LeetCode-樹

直接也是同樣地遞歸,直到結束,就是最後的結果:

LeetCode-樹
class Solution {
    public TreeNode buildTree(int[] inorder, int[] postorder) {
        if (inorder.length == 0)
            return null;
        TreeNode root = new TreeNode(postorder[postorder.length - 1]);
        int index = 0;
        for (index = 0; index < inorder.length; index++)
            if (inorder[index] == postorder[postorder.length - 1])
                break;
        int[] inorder1 = new int[index];
        int[] inorder2 = new int[inorder.length - index - 1];
        System.arraycopy(inorder, 0, inorder1, 0, index);
        System.arraycopy(inorder, index + 1, inorder2, 0, inorder.length - index - 1);
        int[] postorder1 = new int[index];
        int[] postorder2 = new int[inorder.length - index - 1];
        System.arraycopy(postorder, 0, postorder1, 0, index);
        System.arraycopy(postorder, index, postorder2, 0, inorder.length - index - 1);
        root.left = buildTree(inorder1, postorder1);
        root.right = buildTree(inorder2, postorder2);
        return root;
    }
}
           

代碼同樣地,也直接複制數組了,優化方法同上,不再贅述。

六. 左葉子之和

描述: 計算給定二叉樹的所有左葉子之和。LeetCode 404. 題目連結

分析:求和等問題也是直接遞歸就能解決,關鍵是明白什麼是左葉子:必須是某一個結點的左子結點,同時它還是一個葉子,是以遞歸的時候自然而然是有3個判定條件的。

class Solution {
    public int sumOfLeftLeaves(TreeNode root) {
        if (root == null)
            return 0;
        if (root.left != null && root.left.left == null && root.left.right == null)
            return root.left.val + sumOfLeftLeaves(root.right);
        return sumOfLeftLeaves(root.left) + sumOfLeftLeaves(root.right);
    }
}
           

七. 路徑和

① 路徑總和 LeetCode 112.

描述:給定一個二叉樹和一個目标和,判斷該樹中是否存在根節點到葉子節點的路徑,這條路徑上所有節點值相加等于目标和。

分析:一開始看錯題目了,以為任意路徑都可以,然後才發現一定要以“葉子結點"結束,是以要判斷目前是否為葉子。用遞歸的解法也很顯然,如果不為葉子,減去root的值,然後遞歸。

遞歸解法:

class Solution {
    public boolean hasPathSum(TreeNode root, int sum) {
        if (root == null)
            return false;
        if (root.left == null && root.right == null)
            return root.val == sum;
        return hasPathSum(root.left, sum - root.val) ||
            hasPathSum(root.right, sum - root.val);
    }
}
           

這道題的疊代解法,需要開辟另一個棧來存儲目前和的結果(不能直接用一個普通變量,因為存在不同的路徑,比如出棧左子節點和右子節點時,要分别考慮)。

class Solution {
    public boolean hasPathSum(TreeNode root, int sum) {
        if (root == null)
            return false;
        LinkedList<TreeNode> node_stack = new LinkedList<>();
        LinkedList<Integer> sum_stack = new LinkedList<>();
        node_stack.push(root);
        sum_stack.push(sum - root.val); // 每次node_stack pop,sum_stack減去該val
        TreeNode current;
        int currentSum;
        while (!node_stack.isEmpty()) {
            current = node_stack.pop();
            currentSum = sum_stack.pop();
            if (current.right == null && current.left == null && currentSum == 0)
                return true;    // 如果pop出來的是葉子,且sum變成0,說明是正确的路徑
            if (current.right != null) {
                node_stack.push(current.right);
                sum_stack.push(currentSum - current.right.val);
            }
            if (current.left != null) {
                node_stack.push(current.left);
                sum_stack.push(currentSum - current.left.val);
            }
        }
        return false;
    }
}
           

② 路徑總和 Ⅱ LeetCode 113.

描述:給定一個二叉樹和一個目标和,找到所有從根節點到葉子節點路徑總和等于給定目标和的路徑。

分析:像這種尋找所有路徑的題目,很容易想到回溯算法,也就是DFS。在到終點的時候(葉子結點),判斷是否符合結果(路徑和是否為sum),如果是,添加到結果集。不管如何,後面都是傳回上一層,也就是回溯,然後進行最關鍵的“狀态reset”,也就是把上一個添加到list的值給去掉,也就是回溯的思路。

以本題基本測試用例 [5,4,8,11,null,13,4,7,2,null,null,5,1] 為例畫出回溯過程:

LeetCode-樹

可以先寫出這題最基本的回溯模闆:

class Solution {
    public List<List<Integer>> pathSum(TreeNode root, int sum) {
        List<List<Integer>> res = new ArrayList<>();
        List<Integer> list = new ArrayList<>();
        if (root == null)
            return res;
        helper(root, sum, 0, res, list);	// call the backtrack function
        return res;
    }

    public void helper(TreeNode root, int sum, int current,
                       List<List<Integer> res, List<Integer> list) {
        if (.....) {
            res.add(new ArrayList<>(list));
            return;	// backtrack
        }
        
        // while or for or if ...
    }
}
           

回溯的條件自然是到了葉子結點,此時判斷是否為有效的路徑,如果是,添加到結果集。回溯之後要把狀态reset,是以後續都有

list.remove(list.size() - 1)

的操作。因為這裡就是普通的前序周遊,是以也不需要while循環,直接if就可以解決了。

代碼:

class Solution {
    public List<List<Integer>> pathSum(TreeNode root, int sum) {
        List<List<Integer>> res = new ArrayList<>();
        List<Integer> list = new ArrayList<>();
        if (root == null)
            return res;
        list.add(root.val);
        helper(root, sum, root.val, res, list);	// root.val必須要有
        return res;
    }

    public void helper(TreeNode root, int sum, int current,
                       List<List<Integer>> res, List<Integer> list) {
        if (root.left == null && root.right == null && current == sum) {
            res.add(new ArrayList<>(list));
            return; // 回溯
        }
        if (root.left != null) {
            list.add(root.left.val);
            helper(root.left, sum, current + root.left.val, res, list);
            list.remove(list.size() - 1);
        }
        if (root.right != null) {
            list.add(root.right.val);
            helper(root.right, sum, current + root.right.val, res, list);
            list.remove(list.size() - 1);
        } 
    }
}
           

③ 路徑總和 Ⅲ LeetCode 437.

描述:給定一個二叉樹,它的每個結點都存放着一個整數值。

找出路徑和等于給定數值的路徑總數。

路徑不需要從根節點開始,也不需要在葉子節點結束,但是路徑方向必須是向下的(隻能從父節點到子節點)。

二叉樹不超過1000個節點,且節點數值範圍是 [-1000000,1000000] 的整數。

分析:一開始覺得隻是上一題的變種,做了一下才發現難多了,而這題竟然定義為Easy。我一開始是用背包問題的思維,目前root節點是否拿,分為拿和不拿,然後再分别遞歸到左子樹和右子樹。這時候的關鍵代碼大概是:

return helper(root.left, sum, cur + root.val) + helper(root.left, sum, cur) + helper(root.right, sum, cur + root.val) + helper(root.right, sum, cur)

毀掉這段代碼的倒也跟上一題的測試用例一樣,“0”。當一個結點的值為0,或者sum為0,這時候這個代碼就很多錯誤。可能會重複計算,也可能會少計算。後來還是要額外增加一個資料結構,也就是HashMap,用于記錄到達目前元素的路徑上,之前的所有元素的和(根節點到目前元素)。直覺上會覺得,該題路徑不要求從根結點開始。但假設路徑的起始結點為node1,它具有父節點node2,node2有父節點node3,結束結點為node0。

這時候應該是:node0 - node1 == sum

但我們記錄的是:

f (node0) = node0 + node2 + node3

f (node1) = node1 + node2 + node3

是以: f(node0) - f(node1) = = node0 - node1 == sum

即結果是一樣的。這個概念正式地稱為 字首和。值得注意的是,因為DFS實際上也是回溯的過程,那麼回溯的時候也是要進行狀态reset的,否則後面的結點在回溯的時候會影響前面的狀态。

代碼:

class Solution {
    public int pathSum(TreeNode root, int sum) {
        if (root == null)
            return 0;
        Map<Integer, Integer> map = new HashMap<>();
        map.put(0, 1);
        return helper(root, sum, 0, map);        
    }

    public int helper(TreeNode root, int sum, int prev, Map<Integer, Integer> map) {
        if (root == null)
            return 0;
        int res = 0;
        prev += root.val;
        res += map.getOrDefault(prev - sum, 0); 
        // 目前和為prev,尋找前面和為prev - sum的,內插補點就是sum(從後往前推)
        
        map.put(prev, map.getOrDefault(prev, 0) + 1);   // 目前節點的字首和

        res += helper(root.left, sum, prev, map);
        res += helper(root.right, sum, prev, map);      // 下一層

        map.put(prev, map.get(prev) - 1);       // 回溯, 狀态reset
        return res;
    }
}
           

八. 二叉搜尋樹BST

二叉搜尋樹又稱BST,其特點為左子樹的元素都小于根結點,右子樹的元素都大于根節點。這是不考慮重複元素的情況,如果考慮,可以規定相同的元素往左子樹放,或者右子樹放,但此時複雜性還是增加不少,對于查詢的時候可能需要分别遞歸查找兩個子樹,是以一般考慮無重複元素的BST即可。

① 修剪二叉搜尋樹 LeetCode 669.

描述:給定一個二叉搜尋樹,同時給定最小邊界L 和最大邊界 R。通過修剪二叉搜尋樹,使得所有節點的值在[L, R]中 (R>=L) 。你可能需要改變樹的根節點,是以結果應當傳回修剪好的二叉搜尋樹的新的根節點。

分析:做樹的題目一定要記住,先考慮遞歸,再考慮其他的。因為樹的結構很複雜,如果你直接把問題整體地考慮,很難有思路。比如這一題,你可能需要改變根結點,那麼修剪之後剩下哪些結點?哪個結點應該作為根節點?如果先得出剩餘的結點,然後再手動構造BST,這個顯然不是一個好辦法。是以就要把問題細化,也就是回歸遞歸的三個步驟。(為什麼突然又要拿出“三大步驟”來做題?因為上一題把我卡了好久= =)

  1. 遞歸什麼時候結束?

    顯然,root為null就結束,這時候就直接傳回null

  2. 應該傳回什麼?

    同樣地,每一層的傳回值都是相同的,既然第一層傳回的是根結點,是以每一層傳回的都是根節點

  3. 每一層遞歸應該做什麼?

    既然要考慮的是根節點,是以自然就是先判斷根節點與L,R的關系比較。如果root的值小于L,那麼root就要去掉,此時最恰當的是傳回比root大一點的結點,也就是root.right。大于R同理。如果root.right仍然不符合怎麼辦?同樣地,這是下一層遞歸要考慮的,我們在目前層不需要考慮。那麼在處理完root之後是否就結束了呢?并不,此時隻是說明這個root不需要改變了,這時候還要遞歸地考慮root的左子樹和右子樹的值,它們有可能不屬于[L, R]範圍。處理方法也是同樣地,一層一層循序漸進,是以考慮的是root.left, root.right

代碼:

class Solution {
    public TreeNode trimBST(TreeNode root, int L, int R) {
        if (root == null)
            return null;
        // 傳回右子樹, 因為right >= root,如果仍然不符合,繼續遞歸
        if (root.val < L)
            return trimBST(root.right, L, R);   
        if (root.val > R)
            return trimBST(root.left, L, R);    // 同上
        // 處理子結點   (root滿足了,遞歸去除子樹不符合的值)
        root.left = trimBST(root.left, L, R);
        root.right = trimBST(root.right, L, R);
        return root;
    }
}
           

② 二叉搜尋樹中第K小的元素 LeetCode 230.

描述:給定一個二叉搜尋樹,編寫一個函數 kthSmallest 來查找其中第 k 個最小的元素。

說明:

你可以假設 k 總是有效的,1 ≤ k ≤ 二叉搜尋樹元素個數。

分析:對BST進行中序周遊,将得到一個有序的線性表,因而再擷取第K小的元素就很簡單。

class Solution {
    public int kthSmallest(TreeNode root, int k) {
        List<Integer> list = new ArrayList<>();
        inOrder(root, list, k);
        return list.get(k - 1);
    }

    public void inOrder(TreeNode root, List<Integer> list, int k) {
        if (root == null || list.size() == k)
            return;
        inOrder(root.left, list, k);
        list.add(root.val);
        inOrder(root.right, list, k);
    }
}
           

看了一下排名最高的解法,其實也是中序周遊,隻是用了全局變量提升了效率而已:

class Solution {
    int res = 0;
    int k = 0;
    public int kthSmallest(TreeNode root, int k) {
        this.k = k;
        dfs(root);
        return res;
    }

    public void dfs(TreeNode root) {
        if (root == null)
            return;
        dfs(root.left);
        k--;
        if(k == 0)
            res = root.val;
        dfs(root.right);
    }
}
           

進階: 如果二叉搜尋樹經常被修改(插入/删除操作)并且你需要頻繁地查找第 k 小的值,你将如何優化

kthSmallest

函數?

這時候可以維護一個大小為k的堆。如果要擷取第k小,那麼就維護最大堆,此時堆頂元素就是第k小。一次維護的時間複雜度為O(logN),效率很高。

③ 把二叉搜尋樹轉換成累加樹 LeetCode 538.

描述:給定一個二叉搜尋樹(Binary Search Tree),把它轉換成為累加樹(Greater Tree),使得每個節點的值是原來的節點值加上所有大于它的節點值之和。

分析:如果願意,仍然可以直接中序周遊,然後再對List進行累加。但其實這道題需要的是從右往左添加(最右的元素不需要操作,倒數第二右的元素隻需要添加最右元素,以此類推)。是以可以把周遊順序改成:right -> root -> left,使用一個全局變量記錄右邊的元素值。

代碼:

class Solution {
    int right = 0;
    public TreeNode convertBST(TreeNode root) {
        inOrder(root);
        return root;
    }
    public void inOrder(TreeNode root) {
        if (root == null)
            return;
        inOrder(root.right);
        root.val += right;	// 加上右邊的元素
        right = root.val;	// right更新為現在的root.val
        inOrder(root.left);
    }
}
           

因為這個遞歸的方法隻有一個參數,讓人很難去嘗試直接去掉,遞歸convertBST方法本身,于是可以化簡代碼:

class Solution {
    int right = 0;
    public TreeNode convertBST(TreeNode root) {
        if (root != null) {
            convertBST(root.right);
            root.val += right;
            right = root.val;
            convertBST(root.left);
        }
        return root;
    }
}
           

④ 二叉搜尋樹的最近公共祖先 LeetCode 235

描述:給定一個二叉搜尋樹, 找到該樹中兩個指定節點的最近公共祖先。“對于有根樹 T 的兩個結點 p、q,最近公共祖先表示為一個結點 x,滿足 x 是 p、q 的祖先且 x 的深度盡可能大(一個節點也可以是它自己的祖先)。” 題目連結

分析:可以一直遞歸,直到找到最近的公共祖先,但是這就浪費了BST的條件,是以直接疊代就可以了。根節點root肯定是二者的公共祖先,疊代逐漸找到最近的公共祖先。這時候root與p,q的大小隻有四種可能:root介于二者之間,root比二者都大,root比二者都小,與二者之一相等。對于第一種情況,此時root是公共祖先,因而root.left跟root.right都不會是公共祖先了(root位于p,q中間,而root是從根節點開始疊代的)。對于第二種情況,p,q都位于root的左子樹,是以直接:

root = root.left

即可找到更近的公共祖先,第三種情況同理。第四種情況,顯然也是直接傳回(root與其中之一相等,且是它們兩個的公共祖先,那自然是最近的公共祖先了)

代碼:

class Solution {
    public TreeNode lowestCommonAncestor(TreeNode root, TreeNode p, TreeNode q) {
        while (root != p && root != q) {
            if ((p.val < root.val && q.val > root.val) ||
                (q.val < root.val && p.val > root.val))
                return root;    
            // 如果root是公共祖先, 那麼root.left跟root.right都不會是公共祖先(root是p,q中間)
            else if (p.val < root.val)  //p, q < root
                root = root.left;
            else
                root = root.right;      // p, q > root
        }
        return root;
    }
}
           

⑤ 二叉樹的最近公共祖先 LCA問題 LeetCode 236

描述:與上一題相同,隻是這一題是普通的二叉樹,而不是BST。題目連結

分析:可以建立一個判定是否為子結點的方法,然後對root進行遞歸。這樣顯然就進行了兩次O(n ^ 2)的循環,是以效率非常低。并不推薦。這時候我們再次拿出遞歸三步驟來進行解題:

  1. 遞歸什麼時候結束?

    當root為null的時候,或者等于p或q二者之一。(其實就是問邊界情況,但遞歸就是有了這個邊界才不會無限循環下去)

  2. 應該傳回什麼?

    既然是調用自身,是以同樣地,傳回以目前root為根節點的樹,對于結點p,q的最近公共祖先

  3. 每一層遞歸應該做什麼?

    判斷root是否為LCA,如果是,直接傳回,如果不是,繼續遞歸左子樹和右子樹。其實每一次遞歸都隻有4種可能:null(已經到達邊界),p/q(如果root等于二者之一,同樣也可以直接傳回),root.left,root.right.顯然,當

    root == null || root == p || root == q

    ,此時直接傳回root,因為root就是LCA(跟BST一樣)。如果不是,自然就是分别遞歸root.left,root.right,根據它們的結果來判定:

​ ① root.left,root.right的傳回值都不為null。說明目前的root就是LCA (就像BST的if情況)

​ ② root.left的傳回值為null,root.right傳回值不為null。顯然LCA在右子樹,直接傳回root.right的傳回值

​ ③root.left不為null,root.right為null。同上。

​ ④二者都為null。return null。實際上不會到達,是以情況3和4直接寫成else也是可以的。

代碼:

class Solution {
    public TreeNode lowestCommonAncestor(TreeNode root, TreeNode p, TreeNode q) {
        if (root == null || root == p || root == q)
            return root;
        TreeNode left = lowestCommonAncestor(root.left, p, q);
        TreeNode right = lowestCommonAncestor(root.right, p, q);
        if (left != null && right != null)
            return root;	// left最後遞歸到p, right遞歸到q,即分别在兩側, 自然目前root就是LCA
        else if (left != null)
            return left;
        else 
            return right;
    }
}