樹的回溯模板

樹與堆積優先度 3/5 — 值得會 — 多半是必備模式的變形值得會 更新於 Nov 12, 2026
章節優先度優先度 5/5 — 必備 — 幾乎每一輪面試都會出現必備優先度 4/5 — 高價值 — 這裡有缺口就會掉關高價值優先度 3/5 — 值得會 — 多半是必備模式的變形值得會優先度 2/5 — 冷門 — 讀過一次即可,除非目標公司已知會問冷門只標在真正需要的章節上 —— 沒標的是背景/參考資料。

範圍 — 樹的路徑問題背後那套「選擇 / 探索 / 復原」骨架,以及每題各自帶著的路徑狀態,外加根到葉卡片沒涵蓋的變形:通往單一節點的路徑(LC 236)、隱式的樹(LC 386、LC 388);根到葉的卡片本身放在 tree_lca_distance.md。 另見:tree_lca_distance.md — 根到葉的路徑卡片(LC 112、113、129、257、437);tree.md — 一般的樹走訪;binary_tree.md — DFS 狀態流動的方向;backtrack.md — 樹以外的回溯。

很多樹的路徑問題共用同一套 DFS 骨架:選擇(延伸路徑狀態)、檢查目標(通常在葉子)、探索兩個子節點、復原。這份表單講的是骨架,以及每題帶著什麼狀態;標準的根到葉卡片(LC 112、113、129、257、437)放在 tree_lca_distance.md,下面各節會連過去。

LeetCode 題目清單

1️⃣ 通用樹回溯模板

java
void dfs(TreeNode node, ...) {

    if (node == null) return;

    // 1. choose (update path / sum / state)
    
    // 2. check goal (usually leaf condition)
    
    // 3. explore children
    dfs(node.left, ...);
    dfs(node.right, ...);

    // 4. undo choice (backtrack)
}

處理路徑問題時,通常要維護:

text
List<Integer> path
some running state (sum / number / string)

2️⃣ 完整的通用模板

java
void dfs(TreeNode node, List<Integer> path, int state) {

    if (node == null) return;

    // choose
    path.add(node.val);
    state = update(state, node.val);

    // goal check
    if (isLeaf(node) && condition(state)) {
        save(path);
    }

    // explore
    dfs(node.left, path, state);
    dfs(node.right, path, state);

    // undo (backtrack)
    path.remove(path.size() - 1);
}

關鍵步驟:

步驟 用途
choose 把節點加進路徑
check 看看是否達成目標
explore 對子節點做 DFS
undo 還原狀態

3️⃣ 套用到 LC 113(Path Sum II)

狀態 = 剩下的總和,再加上一個進入時 append、離開時 pop 的路徑 list。完整的卡片(Python 與 Java、試跑和陷阱)在 tree_lca_distance § Template 4,那份表單負責整個根到葉路徑家族。


4️⃣ 套用到 LC 257(Binary Tree Paths)

狀態 = 路徑字串。字串不可變,每次呼叫各自建出自己的一份,沒有東西要復原。完整解法在 tree_lca_distance § Template 4 的 Path Update Strategies,旁邊就是拿來對照的可變 list 版本。


5️⃣ 套用到 LC 129(根 → 葉的數字)

狀態 = 數字:num * 10 + node.val,以值傳遞,所以同樣沒有東西要復原。完整解法是 Sum Root to Leaf Numbers — LC 129。


6️⃣ 特殊情況:LC 437(Path Sum III)

LC 437 的路徑往下走時可以從任何地方開始,不一定是根。從每個節點各跑一次模板行得通,但在歪斜的樹上是 O(N²)。O(N) 的版本回溯的是一張前綴和 map 而不是 list — 見下面的 §1️⃣1️⃣。


7️⃣ 心智模型(非常重要)

只要看到:

text
root → leaf
path
all paths
sum of paths
string of paths

馬上就該想到:

text
DFS + Backtracking

模板:

text
add node
check leaf
dfs children
remove node

8️⃣ 面試速記密技 優先度 1/5 — 加分項

把這個五行骨架背起來就好:

java
path.add(node.val);

if (leaf condition)
    save result;

dfs(left)
dfs(right)

path.remove(path.size()-1);

大多數根到葉的題目用這個就能解掉。


✅ 面試小訣竅

如果題目裡出現這些字眼:

text
all paths
root to leaf
return list of paths

那幾乎保證是 DFS+回溯。


9️⃣ 快速決策表(該用哪個路徑模板?)

路徑上要做的事 模板 復原步驟 例題
收集所有根到葉的路徑 push 節點/pop 節點 path.remove(size-1) LC 113、LC 257
把路徑摺疊成一個純量(和/數字) 用傳值的方式帶狀態 不用復原 — 值是複製的 LC 129
找到通往某一個節點的路徑 push,只在失敗時 pop 子樹回傳 false 時才 pop LC 236
統計和為目標值的子路徑數 路徑上的前綴和計數器 count[cur] -= 1 LC 437
走訪隱含的樹(數字/文字) 從父節點狀態重建子節點 除回去/彈堆疊 LC 386、LC 388

⭐⭐⭐⭐⭐ 這幾種之間唯一的差別,就是**「路徑狀態」是什麼**,以及要怎麼把它復原。


🔟 模板:根→節點的路徑,只在失敗時復原(LC 236)

核心想法:標準模板在探索完子節點後會無條件 pop。 但當你在找某一個特定節點時,改成只有子樹失敗才 pop—— 這樣搜尋成功時,path 裡剛好留著完整的根→目標路徑。

模式:分別建出 path(root→p) 和 path(root→q),兩者最後一個共同前綴元素就是 LCA。

java
// java
// LC 236 - Lowest Common Ancestor of a Binary Tree
// IDEA: collect root->p and root->q paths with backtracking (pop on failure only),
//       then walk both paths together; last shared node = LCA.
class Solution {
    // time = O(N), space = O(H)  (H = tree height, O(N) worst case)
    public TreeNode lowestCommonAncestor(TreeNode root, TreeNode p, TreeNode q) {
        List<TreeNode> pathP = new ArrayList<>(), pathQ = new ArrayList<>();
        findPath(root, p, pathP);
        findPath(root, q, pathQ);

        TreeNode lca = null;
        for (int i = 0; i < Math.min(pathP.size(), pathQ.size()); i++) {
            if (pathP.get(i) != pathQ.get(i)) break;   // prefix ended
            lca = pathP.get(i);
        }
        return lca;
    }

    private boolean findPath(TreeNode node, TreeNode target, List<TreeNode> path) {
        if (node == null) return false;

        path.add(node);                                 // choose
        if (node == target) return true;                // goal -> KEEP the path

        if (findPath(node.left, target, path)) return true;
        if (findPath(node.right, target, path)) return true;

        path.remove(path.size() - 1);                   // undo ONLY on failure
        return false;
    }
}
python
# python
# LC 236 - Lowest Common Ancestor of a Binary Tree
# IDEA: same backtracking, pop only when the subtree does not contain the target
class Solution(object):
    # time = O(N), space = O(H)
    def lowestCommonAncestor(self, root, p, q):
        def find_path(node, target, path):
            if not node:
                return False
            path.append(node)               # choose
            if node is target:
                return True                 # goal -> keep path
            if find_path(node.left, target, path) or find_path(node.right, target, path):
                return True
            path.pop()                      # undo ONLY on failure
            return False

        path_p, path_q = [], []
        find_path(root, p, path_p)
        find_path(root, q, path_q)

        lca = None
        for a, b in zip(path_p, path_q):
            if a is not b:
                break
            lca = a
        return lca

為什麼要學這個,而不是那個有名的三行後序 LCA? 面試官追問時,活下來的是路徑版:「回傳 p 和 q 之間的距離」、 「印出 p 到 q 的路徑」、「不保證兩個節點都存在」(看那個布林回傳值)。 三行版在只有 p 或 q 其中一個存在時,還是會默默回傳一個節點。


1️⃣1️⃣ 模板:在路徑上做前綴和+復原(LC 437)

§6️⃣ 的「從每個節點做 DFS」是 O(N²)。O(N) 的做法把目前根到節點的路徑當成陣列,套用子陣列和的技巧:一張記錄目前路徑上前綴和的 map,回程時復原 — 在這裡 map 就是路徑狀態,就像 LC 113 的 list 一樣。完整的卡片(Python、Java、試跑,以及陷阱:prefix[0] = 1、復原、Java 的 long)在 tree_lca_distance § Template 5。


1️⃣2️⃣ 模板:在隱含的樹上回溯(LC 386)

不是每棵樹都由 TreeNode 組成。1..n 依字典序排列,本身就是一棵 10 元的 trie: 1 → 10 → 100 …,而字典序恰好就是它的前序走訪。

模式:子節點 = parent * 10 + d。child > n 時剪枝。

java
// java
// LC 386 - Lexicographical Numbers
// IDEA: preorder DFS over the implicit 10-ary trie of numbers; child = cur*10 + d
class Solution {
    // time = O(N), space = O(log N) recursion depth
    public List<Integer> lexicalOrder(int n) {
        List<Integer> res = new ArrayList<>();
        for (int i = 1; i <= 9; i++) dfs(i, n, res);   // 9 trie roots
        return res;
    }

    private void dfs(int cur, int n, List<Integer> res) {
        if (cur > n) return;
        res.add(cur);                                   // preorder: visit before children
        for (int d = 0; d <= 9; d++) {
            int next = cur * 10 + d;
            if (next > n) break;                        // prune (d only grows)
            dfs(next, n, res);
        }
    }
}
python
# python
# LC 386 - Lexicographical Numbers
# IDEA: preorder DFS on the implicit 10-ary trie of 1..n
class Solution(object):
    # time = O(N), space = O(log N)
    def lexicalOrder(self, n):
        res = []

        def dfs(cur):
            if cur > n:
                return
            res.append(cur)
            for d in range(10):
                nxt = cur * 10 + d
                if nxt > n:
                    break
                dfs(nxt)

        for i in range(1, 10):
            dfs(i)
        return res

追問(O(1) 額外空間) — 面試官最愛的變化。同樣的走法,改成迭代; 「往上回到父節點」變成除以 10:

java
// java
// LC 386 - Lexicographical Numbers (O(1) space, explicit backtracking)
// IDEA: go down (*10) when possible, else move right (+1),
//       else climb (/10) until a right sibling exists.
class Solution {
    // time = O(N), space = O(1) excluding output
    public List<Integer> lexicalOrder(int n) {
        List<Integer> res = new ArrayList<>();
        int cur = 1;
        for (int i = 0; i < n; i++) {
            res.add(cur);
            if ((long) cur * 10 <= n) {
                cur *= 10;                                  // go deeper
            } else {
                while (cur % 10 == 9 || cur + 1 > n) cur /= 10;   // backtrack up
                cur++;                                      // next sibling
            }
        }
        return res;
    }
}
python
# python
# LC 386 - Lexicographical Numbers (O(1) space, explicit backtracking)
class Solution(object):
    # time = O(N), space = O(1) excluding output
    def lexicalOrder(self, n):
        res, cur = [], 1
        for _ in range(n):
            res.append(cur)
            if cur * 10 <= n:
                cur *= 10                       # go deeper
            else:
                while cur % 10 == 9 or cur + 1 > n:
                    cur //= 10                  # backtrack up
                cur += 1                        # next sibling
        return res

1️⃣3️⃣ 模板:用深度當鍵的路徑狀態堆疊(LC 388)

一段有縮排的文字就是一棵序列化的樹,tab 的數量就是深度。 你完全不用把樹建出來——只要保留當前根→節點的路徑長度, 一旦跳到比較淺的深度,較深的那些項目就自動被丟掉(也就是回溯)。

核心想法:pathLen[d] = 深度 d 處的目錄前綴長度。 由上往下掃描時寫入 pathLen[depth + 1],會覆蓋掉任何殘留的兄弟分支, 這正好就是經典模板裡的「pop」。

java
// java
// LC 388 - Longest Absolute File Path
// IDEA: implicit tree from indentation; pathLen[depth] = prefix length of current root->node path.
//       A shallower line overwrites the deeper entries => automatic backtrack.
class Solution {
    // time = O(N) over input chars, space = O(D) depth
    public int lengthLongestPath(String input) {
        Map<Integer, Integer> pathLen = new HashMap<>();
        pathLen.put(0, 0);                              // depth 0 has no prefix
        int best = 0;

        for (String line : input.split("\n")) {
            int depth = line.lastIndexOf('\t') + 1;     // tabs are always leading
            String name = line.substring(depth);

            if (name.contains(".")) {                   // leaf = file
                best = Math.max(best, pathLen.get(depth) + name.length());
            } else {                                    // internal node = dir
                pathLen.put(depth + 1, pathLen.get(depth) + name.length() + 1);  // +1 for '/'
            }
        }
        return best;
    }
}
python
# python
# LC 388 - Longest Absolute File Path
# IDEA: depth-keyed path-length map; deeper stale entries are overwritten, never reused
class Solution(object):
    # time = O(N), space = O(D)
    def lengthLongestPath(self, input):
        best = 0
        path_len = {0: 0}

        for line in input.split('\n'):
            name = line.lstrip('\t')
            depth = len(line) - len(name)

            if '.' in name:                                        # file -> leaf
                best = max(best, path_len[depth] + len(name))
            else:                                                  # dir -> internal node
                path_len[depth + 1] = path_len[depth] + len(name) + 1   # +1 for '/'

        return best

容易踩到的坑

  • 完全沒有檔案時要回傳 0("a" → 0),而不是最長的目錄路徑。
  • 那個 +1 是 / 分隔符;深度 0 的檔案剛好不會多一個分隔符。
  • 用 Deque<Integer> 一路彈到 size == depth,是同一件事的顯式堆疊寫法。

1️⃣4️⃣ 變形:狀態往下流,結構往上改(LC 1110)

骨架相同,但不是往路徑上 push,而是把一個布林值往下傳(「我的父節點被刪了,所以我是一棵新樹的根」),並把重接好的子樹往上回傳(被刪的節點回傳 None,父節點就把那條連結拿掉)。這是改樹的結構而不是路徑問題,所以它的卡片和其他結構修改放在一起:binary_tree § Template 10。


1️⃣5️⃣ 路徑模板總表

LC 題目 路徑狀態 復原方式
113 Path Sum II List<Integer> +剩餘的和 path.remove(size-1)
257 Binary Tree Paths 字串(傳值) 隱含
129 Sum Root to Leaf Numbers int num(傳值) 隱含
236 Lowest Common Ancestor 節點 list,失敗才 pop 有條件的 pop
437 Path Sum III Map<prefixSum, count> count[cur] -= 1
386 Lexicographical Numbers 目前的數字 cur /= 10
388 Longest Absolute File Path pathLen[depth] 覆寫/彈到該深度
1110 Delete Nodes And Return Forest parentWasDeleted 旗標 往上回傳 null