遞迴

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

範圍 — 把遞迴當成一種機制來談:base case、往下傳的狀態 vs 往上回傳的結果、呼叫堆疊的成本,以及怎麼改寫成迭代。使用遞迴的那些題型家族各自有專屬檔案。 另見:recursion_to_dp.md — 把遞迴加上記憶化變成 DP;backtrack.md — 帶復原動作的遞迴;dfs.md — 在圖與樹上的遞迴;advanced_divide_and_conquer.md — 帶合併步驟的遞迴。

LeetCode 題目清單

0) 速查

什麼時候該用遞迴?

  • 問題有重疊子問題,而且每次都能縮小規模
  • 你能清楚定義出 base case 和遞迴情況
  • 問題天生就能拆成自己的小規模版本
  • 樹/圖走訪或回溯類的問題

快速決策指南

使用情境 模式 核心想法
需要來自父節點的資訊 由上而下 一邊走訪一邊把上下文往下傳
需要來自子節點的結果 由下而上 先解子節點,再合併結果
需要切開再合併結果 分治法 切分問題、各自求解、合併
需要窮舉所有可能 回溯 帶決策的 DFS
多次遞迴呼叫、子問題重複 記憶化 把結果快取起來,避免重複計算

核心原理

對於問題 F(X),X 是輸入:

text
1. Break down into smaller scopes: x₀, x₁, ..., xₙ ∈ X
2. Recursively solve: F(x₀), F(x₁), ..., F(xₙ)
3. Combine results to solve F(X)

小技巧

  • 拿不定主意時:先把遞迴關係式寫下來(F(n) 跟 F(n-1)、F(n-2) 等等是什麼關係)
  • 有重複呼叫時:加上記憶化(把中間結果快取起來)
  • 怕堆疊溢位時:用尾遞迴,或改寫成迭代

1) 概念

1-1) 複雜度分析

時間複雜度: 把遞迴想成一個樹狀結構:

text
        fib(5)
       /      \
    fib(4)    fib(3)
    /    \     /    \
fib(3)  fib(2) fib(2) fib(1)
 /   \    /  \   /  \
fib(2) fib(1) fib(1) fib(0)
 /   \
fib(1) fib(0)

給定一個遞迴演算法:O(T) = R × O(S)

  • R = 遞迴呼叫的次數
  • O(S) = 每次呼叫本身做的事的時間複雜度
  • 沒有記憶化的 Fibonacci:O(2^n)(指數級)

空間複雜度:

與遞迴相關的空間(呼叫堆疊):

  • 遞迴函式呼叫裡的區域變數
  • 輸入參數
  • 輸出變數
  • 堆疊溢位風險:配置的堆疊空間碰到系統上限時

與遞迴無關的空間(heap):

  • 全域變數
  • 記憶化的快取(存中間結果)
  • 重點:分析整體複雜度時,記憶化用掉的空間也要算進去

1-2) 相關概念

遞迴會用在:

  • DFS(深度優先搜尋)—— 樹/圖走訪
  • 回溯 —— 帶剪枝地窮舉所有可能
  • 樹的問題 —— 遞迴演算法天生就合用
  • 動態規劃 —— 搭配記憶化最佳化

2) 模式

2-1) 基本操作

無限地走過 list 裡的元素(在回溯/生成類題目很常見):

python
# Example: LC 22 (Generate Parentheses)
_list = ["(", ")"]
for x in _list:
    _tmp = tmp + x
    help(_tmp)

2-2) 由上而下的遞迴 —— LC 112

定義:從根開始,在每個節點依據父節點傳下來的資訊做決定。也就是所謂的「前序」做法。

時間複雜度:

  • 通常是 O(n),n 是節點數
  • 如果重複解同樣的子問題又沒有記憶化,可能變成 O(n²)

空間複雜度:

  • O(h),h 是遞迴樹的高度(呼叫堆疊)
  • 用了記憶化的話還要 O(n) 的額外空間

使用情境:

  • 需要把資訊從父節點傳給子節點
  • 帶累積狀態的樹走訪
  • 路徑類問題
  • 驗證類問題

優點:

  • 直覺、好懂
  • 對「資訊由父流向子」的問題很自然
  • 適合做提早結束的判斷

缺點:

  • 沒有記憶化的話可能重複計算
  • 呼叫堆疊會讓空間複雜度偏高

模式:

python
def topDown(node, parentInfo):
    # Base case
    if not node:
        return baseResult

    # Use parentInfo to make decision
    currentResult = processWithParentInfo(node, parentInfo)

    # Pass updated info to children
    newParentInfo = updateParentInfo(parentInfo, node)
    leftResult = topDown(node.left, newParentInfo)
    rightResult = topDown(node.right, newParentInfo)

    # Combine results
    return combineResults(currentResult, leftResult, rightResult)

常見的 LeetCode 題目:

  • LC 104: Maximum Depth of Binary Tree
  • LC 110: Balanced Binary Tree
  • LC 112: Path Sum
  • LC 113: Path Sum II
  • LC 124: Binary Tree Maximum Path Sum
  • LC 236: Lowest Common Ancestor
  • LC 257: Binary Tree Paths
  • LC 404: Sum of Left Leaves
  • LC 437: Path Sum III

範例 —— Path Sum(LC 112):

python
def hasPathSum(self, root, targetSum):
    def topDown(node, currentSum):
        if not node:
            return False

        currentSum += node.val

        # Leaf node check
        if not node.left and not node.right:
            return currentSum == targetSum

        # Continue to children with updated sum
        return (topDown(node.left, currentSum) or
                topDown(node.right, currentSum))

    return topDown(root, 0)

2-3) 由下而上的遞迴 —— LC 104

定義:從葉節點開始,把子節點的結果合併起來,一層一層堆出答案。也就是所謂的「後序」做法。

時間複雜度:

  • 通常是 O(n),n 是節點數
  • 一般來說效率更好,因為每個節點剛好走一次

空間複雜度:

  • O(h),h 是遞迴樹的高度(呼叫堆疊)
  • 通常不需要記憶化的額外空間

使用情境:

  • 答案取決於子樹的結果
  • 計算樹的性質(高度、直徑等)
  • 聚合類問題
  • 樹上的動態規劃

優點:

  • 效率較好 —— 每個子問題剛好解一次
  • 對「資訊由子流向父」的問題很自然
  • 程式碼通常比較乾淨
  • 多數情況下效能更好

缺點:

  • 某些問題想起來沒那麼直覺
  • 遞迴呼叫可能需要回傳多個值

模式:

python
def bottomUp(node):
    # Base case
    if not node:
        return baseResult

    # Get results from children first
    leftResult = bottomUp(node.left)
    rightResult = bottomUp(node.right)

    # Process current node using children results
    currentResult = processNode(node, leftResult, rightResult)

    return currentResult

常見的 LeetCode 題目:

  • LC 104: Maximum Depth of Binary Tree
  • LC 110: Balanced Binary Tree
  • LC 543: Diameter of Binary Tree
  • LC 124: Binary Tree Maximum Path Sum
  • LC 968: Binary Tree Cameras
  • LC 979: Distribute Coins in Binary Tree
  • LC 1120: Maximum Average Subtree
  • LC 1130: Minimum Cost Tree From Leaf Values
  • LC 1372: Longest ZigZag Path in a Binary Tree

範例 —— Maximum Depth(LC 104):

python
def maxDepth(self, root):
    def bottomUp(node):
        if not node:
            return 0

        # Get depths from children
        leftDepth = bottomUp(node.left)
        rightDepth = bottomUp(node.right)

        # Current depth is max of children + 1
        return max(leftDepth, rightDepth) + 1

    return bottomUp(root)

範例 —— Balanced Binary Tree(LC 110):

python
def isBalanced(self, root):
    def bottomUp(node):
        if not node:
            return True, 0  # (isBalanced, height)

        # Check left subtree
        leftBalanced, leftHeight = bottomUp(node.left)
        if not leftBalanced:
            return False, 0

        # Check right subtree
        rightBalanced, rightHeight = bottomUp(node.right)
        if not rightBalanced:
            return False, 0

        # Check current node balance
        isCurrentBalanced = abs(leftHeight - rightHeight) <= 1
        currentHeight = max(leftHeight, rightHeight) + 1

        return isCurrentBalanced, currentHeight

    balanced, _ = bottomUp(root)
    return balanced

比較表:

面向 由上而下 由下而上
方向 根 → 葉 葉 → 根
資訊流向 父 → 子 子 → 父
什麼時候用 需要父節點的上下文 需要子樹的結果
效率 可能有重複計算 通常比較好
直覺度 路徑類問題比較直覺 聚合類問題比較直覺
需要記憶化嗎 常常需要 很少需要

2-4) 把狀態傳給下一層遞迴 —— LC 404

把累積的狀態/上下文當成參數傳給子層的遞迴呼叫。需要記住父節點的資訊時很好用。

範例:LC 404(Sum of Left Leaves)

java
// LC 404 - Sum of Left Leaves
// IDEA: Pre-order traversal, pass isLeft flag to track if node is left child
private int processSubtree(TreeNode subtree, boolean isLeft) {
    // Base case: empty subtree
    if (subtree == null) {
        return 0;
    }

    // Base case: leaf node
    if (subtree.left == null && subtree.right == null) {
        return isLeft ? subtree.val : 0;
    }

    // Recursive case: process left and right subtrees
    return processSubtree(subtree.left, true) + processSubtree(subtree.right, false);
}

關鍵洞見:把 isLeft 當參數傳下去,就能追蹤父節點的上下文,不需要全域狀態。


2-5) 遞迴中的「任一為真」 —— LC 572

當你要在多個遞迴呼叫中找出「有沒有任何一個為真」,就用 OR 邏輯。只要有一個遞迴呼叫回傳 true 就提早收工。

範例:LC 572(Subtree of Another Tree)

java
// LC 572 - Subtree of Another Tree
public boolean isSubtree(TreeNode root, TreeNode subRoot) {
    // Check if subtree rooted at 'root' matches 'subRoot'
    // Use OR: if ANY recursive call returns true, short-circuit and return true
    return isSameTree(root, subRoot) 
        || isSubtree(root.left, subRoot) 
        || isSubtree(root.right, subRoot);
}

private boolean isSameTree(TreeNode node1, TreeNode node2) {
    if (node1 == null || node2 == null) {
        return node1 == null && node2 == null;
    }
    return node1.val == node2.val 
        && isSameTree(node1.left, node2.left) 
        && isSameTree(node1.right, node2.right);
}

關鍵洞見:用 OR(||)可以在找到 true 的當下就離開,省掉不必要的遞迴呼叫。


2-6) 笛卡兒積式建構 —— LC 95

定義:切分一個區間,對每種切法遞迴生成所有子結果,再用笛卡兒積把它們組起來,藉此生成所有可能的結構。這是分治法的一種形式,只是「合併」那一步變成列舉所有左 × 右的組合。

時間複雜度:O(4^n / n^(3/2)) —— Catalan 數的成長速度

空間複雜度:O(4^n / n^(3/2)) —— 存下所有生成的結構

使用情境:

  • 生成所有結構相異的樹(BST、完滿二元樹)
  • 列舉一個運算式所有加括號/切分的方式
  • 任何「切分區間,再合併所有子結果」的問題

模式:

text
1. Pick each element i in [start, end] as the "root" / split point
2. Recursively build all left results from [start, i-1]
3. Recursively build all right results from [i+1, end]
4. Cartesian product: for each left × right, construct and collect result
5. Base case: empty range → return [null/None] (one empty result, NOT empty list)
java
// Template: Recursive Construction via Cartesian Product
private List<TreeNode> build(int start, int end) {
    List<TreeNode> res = new ArrayList<>();
    if (start > end) {
        res.add(null);  // CRITICAL: null = valid empty subtree
        return res;
    }
    for (int i = start; i <= end; i++) {
        List<TreeNode> lefts = build(start, i - 1);
        List<TreeNode> rights = build(i + 1, end);
        for (TreeNode l : lefts)
            for (TreeNode r : rights)
                res.add(new TreeNode(i, l, r));
    }
    return res;
}

關鍵洞見:base case 必須回傳 [null](裝著 null 的 list),不是空 list。否則笛卡兒積會把所有左/右子樹為空的樹整批弄丟。

最佳化:用 Map<Pair<Integer,Integer>, List<TreeNode>> 加上記憶化,避免重算重疊的子問題。

常見的 LeetCode 題目:

  • LC 95: Unique Binary Search Trees II
  • LC 96: Unique Binary Search Trees(Catalan 計數)
  • LC 241: Different Ways to Add Parentheses
  • LC 894: All Possible Full Binary Trees
  • LC 1382: Balance a Binary Search Tree

範例 —— LC 95: Unique Binary Search Trees II:

python
def generateTrees(n):
    if n == 0: return []
    def generate(start, end):
        if start > end:
            return [None]
        all_trees = []
        for i in range(start, end + 1):
            for left in generate(start, i - 1):
                for right in generate(i + 1, end):
                    root = TreeNode(i)
                    root.left = left
                    root.right = right
                    all_trees.append(root)
        return all_trees
    return generate(1, n)

3) 進階技巧

3-1) 記憶化 —— LC 70

想法:把遞迴呼叫的結果快取起來,同一個子問題再出現時就不用重算。

什麼時候用:

  • 遞迴呼叫會重複(重疊子問題)
  • 不做記憶化的話時間複雜度是指數級
  • 拿空間換時間(用雜湊表當快取)

範例 1:Fibonacci

python
# Without memoization: O(2^n) — exponential
def fibonacci(n):
    if n < 2:
        return n
    return fibonacci(n - 1) + fibonacci(n - 2)

# With memoization: O(n) — linear
def fibonacci(n):
    cache = {}
    def helper(n):
        if n in cache:
            return cache[n]
        if n < 2:
            res = n
        else:
            res = helper(n - 1) + helper(n - 2)
        cache[n] = res
        return res
    return helper(n)

範例 2:Climbing Stairs(LC 70)

python
# Without memoization: O(2^n)
class Solution:
    def climbStairs(self, n):
        if n == 1:
            return 1
        if n == 2:
            return 2
        return self.climbStairs(n - 1) + self.climbStairs(n - 2)

# With memoization: O(n)
class Solution:
    def climbStairs(self, n):
        cache = {}
        def helper(n):
            if n in cache:
                return cache[n]
            if n <= 2:
                res = n
            else:
                res = helper(n - 2) + helper(n - 1)
            cache[n] = res
            return res
        return helper(n)

參考:https://leetcode.com/explore/learn/card/recursion-i/255/recursion-memoization/1495/


3-2) 分治法 —— LC 23

模板:

text
1. Divide: Split problem into subproblems
2. Conquer: Solve each subproblem recursively
3. Combine: Merge subproblem results

虛擬碼:

python
def divide_and_conquer(problem):
    # (1) Divide
    subproblems = divide(problem)
    
    # (2) Conquer
    results = [divide_and_conquer(sub) for sub in subproblems]
    
    # (3) Combine
    return combine(results)

常見例子:

  • 合併排序 —— O(n log n)
  • 快速排序 —— 平均 O(n log n)
  • 二分搜尋 —— O(log n)

常見的 LeetCode 題目:

  • LC 22: Generate Parentheses
  • LC 84: Largest Rectangle in Histogram
  • LC 315: Count of Smaller Numbers After Self
  • LC 493: Reverse Pairs
  • LC 1649: Create Sorted Array Through Instructions

參考:https://leetcode.com/explore/learn/card/recursion-ii/470/divide-and-conquer/2869/


3-3) 把遞迴改寫成迭代(展開遞迴)

為什麼要改:

  • 避免堆疊溢位的風險
  • 改善空間/時間效率
  • 減少函式呼叫的開銷

怎麼改:

text
1. Use a stack or queue to replace the system call stack
2. At each recursion point, push parameters onto data structure
3. Replace recursive chain with loop over the data structure

範例:https://leetcode.com/explore/learn/card/recursion-ii/503/recursion-to-iteration/2693/


4) 完整的 LeetCode 範例

4-1) Symmetric Tree(LC 101)

模式:由下而上的遞迴,同時比較兩棵子樹。

python
class Solution:
    def isSymmetric(self, root):
        if not root:
            return True
        return self.mirror(root.left, root.right)

    def mirror(self, left, right):
        if not left or not right:
            return left == right
        if left.val != right.val:
            return False
        return self.mirror(left.left, right.right) and self.mirror(left.right, right.left)

4-2) One Edit Distance(LC 161)

模式:提早剪枝(長度差的絕對值 > 1),再逐一檢查每個位置。

python
class Solution:
    def isOneEditDistance(self, s, t):
        m, n = len(s), len(t)
        if abs(m - n) > 1:
            return False
        if m > n:
            return self.isOneEditDistance(t, s)
        for i in range(m):
            if s[i] != t[i]:
                if m == n:
                    return s[i + 1:] == t[i + 1:]
                return s[i:] == t[i + 1:]
        return m != n

4-3) Merge Two Sorted Lists(LC 21)

模式:單純的遞迴,順手更新區域狀態。

python
class Solution:
    def mergeTwoLists(self, l1, l2):
        if not l1 or not l2:
            return l1 or l2
        if l1.val < l2.val:
            l1.next = self.mergeTwoLists(l1.next, l2)
            return l1
        else:
            l2.next = self.mergeTwoLists(l1, l2.next)
            return l2

4-4) Subtree of Another Tree(LC 572)

模式:搭配遞迴輔助函式的「任一為真」。

python
class Solution:
    def isSubtree(self, root, subRoot):
        def isSameTree(p, q):
            if not p and not q:
                return True
            if not p or not q:
                return False
            return (p.val == q.val and 
                    isSameTree(p.left, q.left) and 
                    isSameTree(p.right, q.right))
        
        if not root and not subRoot:
            return True
        if not root or not subRoot:
            return False
        # Use OR: if any recursive call returns True, stop early
        return (isSameTree(root, subRoot) or 
                self.isSubtree(root.left, subRoot) or 
                self.isSubtree(root.right, subRoot))

Java 版本:

java
public boolean isSubtree(TreeNode root, TreeNode subRoot) {
    if (root == null) {
        return false;
    }
    if (isIdentical(root, subRoot)) {
        return true;
    }
    return isSubtree(root.left, subRoot) || isSubtree(root.right, subRoot);
}

private boolean isIdentical(TreeNode node1, TreeNode node2) {
    if (node1 == null || node2 == null) {
        return node1 == null && node2 == null;
    }
    return node1.val == node2.val && 
           isIdentical(node1.left, node2.left) && 
           isIdentical(node1.right, node2.right);
}

5) 更多遞迴模板

上面那些章節都以樹為中心。下面六個模板涵蓋面試會出現的其他遞迴形狀:鏈結串列重接、遞迴下降剖析、 折半遞迴、純遞迴關係化簡、把一疊東西經由緩衝區搬移,以及交織兩個有序序列。

快速決策表

題目裡的訊號 模板 例題
重建/重排鏈結串列 5-1) 重接指標並回傳新的 head LC 206, 24, 25, 203, 234
字串裡有巢狀括號/文法 5-2) 遞迴下降(共用游標) LC 394, 224, 1106, 736
n 每一步是按倍率縮小 5-3) 折半遞迴 LC 50, 1922, 231/326/342
從 f(n-1) 推出封閉形式的 f(n) 5-4) 遞迴關係化簡 LC 779, 1823, 273
只能碰到最上面那個,其餘的必須先暫放到別處 5-5) 經由緩衝區搬移 Towers of Hanoi(CtCI 8.6)、sort-a-stack(CtCI 3.5)
列出保留兩個順序的每一種排列 5-6) 交織兩個序列 CtCI 4.9,參見 LC 97

5-1) 遞迴式鏈結串列重接 —— LC 206 / 24 / 25 優先度 5/5 — 必備 — 幾乎每一輪面試都會出現

定義:一個遞迴的鏈結串列函式收下某段子串列的 head,然後回傳已經處理完的那段子串列的新 head。呼叫者再把回傳的 head 接到自己的節點上。所有指標手術都發生在遞迴呼叫回來之後(也就是說,在串列上這是由下而上的做法)。

三步驟契約 —— 這三步做對,所有串列遞迴都會自己掉出來:

text
1. Base case   : list too short to change -> return head unchanged
2. Recurse     : newTail/rest = f(<node further down the list>)
3. Rewire      : fix current node's `next`, then RETURN the node that is now first

時間:O(n) —— 每個節點碰一次。空間:O(n) 的呼叫堆疊(LC 25 是 O(n/k))。

關鍵洞見:絕對不要想「原地改、回傳 void」。回傳值就是新的 head;LC 206 忘了設 head.next = null 就是那個經典的環狀 bug。

範例 —— LC 206: Reverse Linked List

java
// java
// LC 206 - Reverse Linked List
// IDEA: recursion returns the NEW head; on the way back up, make my successor point at me
// time = O(n), space = O(n) (call stack)
public ListNode reverseList(ListNode head) {
    // base: empty or single node -> already reversed
    if (head == null || head.next == null) return head;

    ListNode newHead = reverseList(head.next); // newHead = tail of original list
    head.next.next = head;                     // successor now points back at me
    head.next = null;                          // CRITICAL: cut old link, else cycle
    return newHead;                            // head never changes going back up
}
python
# python
# LC 206 - Reverse Linked List
# IDEA: recursion returns the NEW head; on the way back up, make my successor point at me
# time = O(n), space = O(n) (call stack)
def reverseList(head):
    if not head or not head.next:
        return head
    new_head = reverseList(head.next)
    head.next.next = head
    head.next = None      # CRITICAL: cut old link, else cycle
    return new_head

範例 —— LC 24: Swap Nodes in Pairs

java
// java
// LC 24 - Swap Nodes in Pairs
// IDEA: swap the first two nodes, recurse on the rest, return the 2nd node as new head
// time = O(n), space = O(n)
public ListNode swapPairs(ListNode head) {
    if (head == null || head.next == null) return head; // 0 or 1 node left

    ListNode second = head.next;
    head.next = swapPairs(second.next); // rest of list, already swapped
    second.next = head;
    return second;                      // second is now the head of this pair
}
python
# python
# LC 24 - Swap Nodes in Pairs
# IDEA: swap the first two nodes, recurse on the rest, return the 2nd node as new head
# time = O(n), space = O(n)
def swapPairs(head):
    if not head or not head.next:
        return head
    second = head.next
    head.next = swapPairs(second.next)
    second.next = head
    return second

範例 —— LC 25: Reverse Nodes in k-Group(把 LC 24 從 k=2 推廣到任意 k)

java
// java
// LC 25 - Reverse Nodes in k-Group
// IDEA: probe k nodes ahead; if a full group exists, recurse on the remainder FIRST,
//       then reverse this group with the recursive result as its new tail
// time = O(n), space = O(n/k) recursion depth
public ListNode reverseKGroup(ListNode head, int k) {
    // step 1: is there a full group of k? if not, leave the tail untouched
    ListNode node = head;
    for (int i = 0; i < k; i++) {
        if (node == null) return head;
        node = node.next;
    }
    // node = (k+1)-th node = start of the remainder

    // step 2: solve the remainder first, it becomes what this group points to
    ListNode prev = reverseKGroup(node, k);

    // step 3: standard iterative reverse of exactly k nodes onto `prev`
    ListNode cur = head;
    for (int i = 0; i < k; i++) {
        ListNode nxt = cur.next;
        cur.next = prev;
        prev = cur;
        cur = nxt;
    }
    return prev; // prev = k-th node = new head of this group
}
python
# python
# LC 25 - Reverse Nodes in k-Group
# IDEA: probe k nodes ahead; if a full group exists, recurse on the remainder FIRST,
#       then reverse this group with the recursive result as its new tail
# time = O(n), space = O(n/k) recursion depth
def reverseKGroup(head, k):
    node = head
    for _ in range(k):
        if not node:
            return head          # fewer than k nodes left -> keep as-is
        node = node.next

    prev = reverseKGroup(node, k)  # reversed remainder
    cur = head
    for _ in range(k):
        nxt = cur.next
        cur.next = prev
        prev = cur
        cur = nxt
    return prev

各種變形

  • LC 203(Remove Linked List Elements) —— 變化點:做的是刪除而不是重排,所以「重接」那一步變成有條件的 return。不需要 dummy 節點:
java
// java
// LC 203 - Remove Linked List Elements
// IDEA: clean the rest first, then decide whether to keep myself
// time = O(n), space = O(n)
public ListNode removeElements(ListNode head, int val) {
    if (head == null) return null;
    head.next = removeElements(head.next, val);
    return head.val == val ? head.next : head; // skip myself if I match
}
  • LC 234(Palindrome Linked List) —— 變化點:完全不重接;把呼叫堆疊當成反向迭代器。遞迴回溯的過程往回走,同時用一個成員變數往前走。
java
// java
// LC 234 - Palindrome Linked List
// IDEA: recursion unwinds back-to-front; `front` pointer moves front-to-back in lockstep
// time = O(n), space = O(n) (O(1) space alternative: reverse the 2nd half iteratively)
private ListNode front;
public boolean isPalindrome(ListNode head) {
    front = head;
    return check(head);
}
private boolean check(ListNode node) {
    if (node == null) return true;
    if (!check(node.next)) return false; // go to the end first
    if (node.val != front.val) return false;
    front = front.next;                  // compare back-node vs front-node
    return true;
}
python
# python
# LC 234 - Palindrome Linked List
# IDEA: recursion unwinds back-to-front; `front` pointer moves front-to-back in lockstep
# time = O(n), space = O(n)
def isPalindrome(head):
    front = head
    def check(node):
        nonlocal front
        if not node:
            return True
        if not check(node.next):
            return False
        if node.val != front.val:
            return False
        front = front.next
        return True
    return check(head)
  • LC 143(Reorder List) —— 變化點:是組合而不是新的遞迴。用快慢指標從中間切開,用 LC 206 反轉後半段,再把兩半交錯合併(LC 21 的合併步驟)。

常見的 LeetCode 題目

  • LC 206: Reverse Linked List(基本模板)
  • LC 24: Swap Nodes in Pairs
  • LC 25: Reverse Nodes in k-Group(LC 24 的困難版)
  • LC 203: Remove Linked List Elements
  • LC 234: Palindrome Linked List
  • LC 143: Reorder List
  • LC 21: Merge Two Sorted Lists(見 4-3)

5-2) 遞迴下降剖析 —— LC 394 / 224 優先度 5/5 — 必備 — 幾乎每一輪面試都會出現

定義:剖析巢狀字串時,每條文法規則寫一個函式,所有函式共用同一個游標(索引)。每個函式剛好吃掉自己那條規則的字元,然後把游標停在它們的後面。巢狀括號 = 遞迴;多層優先序 = 相互遞迴(expr 呼叫 term,term 呼叫 expr)。

時間:O(n) 個 token(文法會展開時是 O(output),例如 LC 394)。 空間:O(巢狀深度)。

模式:

text
1. Keep the cursor OUTSIDE the recursion (field in Java, `nonlocal` in Python).
   Passing `int i` by value does not work - the caller must see how far the callee ate.
2. One function per grammar rule; each one:
     - reads the tokens of its own rule
     - recurses at the nesting point ('(' , '[' , a sub-expression)
     - returns its value with the cursor sitting on the NEXT unconsumed char
3. Be explicit about who consumes the closing delimiter (pick a convention, keep it).

關鍵洞見:最常見的 bug 就是把游標寫成參數而不是共用狀態 —— 這樣父層會把子層已經吃掉的字元再剖析一次。先把文法用 BNF 寫下來;程式碼只是它的機械式翻譯。

範例 —— LC 394: Decode String(文法:str := (char | int '[' str ']')*)

java
// java
// LC 394 - Decode String
// IDEA: recursive descent with a shared cursor; each call handles ONE bracket level
// time = O(total output length), space = O(nesting depth)
private int i = 0;

public String decodeString(String s) {
    i = 0;
    return parse(s);
}

// parses until end-of-string or the ']' that closes the current level
private String parse(String s) {
    StringBuilder sb = new StringBuilder();
    while (i < s.length() && s.charAt(i) != ']') {
        char c = s.charAt(i);
        if (Character.isDigit(c)) {
            int k = 0;
            while (Character.isDigit(s.charAt(i))) {   // multi-digit repeat count
                k = k * 10 + (s.charAt(i) - '0');
                i++;
            }
            i++;                        // consume '['
            String inner = parse(s);    // recurse: body of this bracket
            i++;                        // consume ']'
            for (int t = 0; t < k; t++) sb.append(inner);
        } else {
            sb.append(c);
            i++;
        }
    }
    return sb.toString();
}
python
# python
# LC 394 - Decode String
# IDEA: recursive descent with a shared cursor; each call handles ONE bracket level
# time = O(total output length), space = O(nesting depth)
def decodeString(s):
    i = 0

    def parse():
        nonlocal i
        out = []
        while i < len(s) and s[i] != "]":
            if s[i].isdigit():
                k = 0
                while s[i].isdigit():          # multi-digit repeat count
                    k = k * 10 + int(s[i]); i += 1
                i += 1                          # consume '['
                inner = parse()                 # recurse: body of this bracket
                i += 1                          # consume ']'
                out.append(inner * k)
            else:
                out.append(s[i]); i += 1
        return "".join(out)

    return parse()

範例 —— LC 224: Basic Calculator(兩層文法上的相互遞迴)

text
expr := term (('+' | '-') term)*
term := number | '(' expr ')' | '-' term      # unary minus, e.g. "-(3+4)"
java
// java
// LC 224 - Basic Calculator
// IDEA: one function per grammar rule; expr <-> term is mutual recursion, '(' re-enters expr
// time = O(n), space = O(paren depth)
private int p = 0;

public int calculate(String s) {
    p = 0;
    return expr(s);
}

private int expr(String s) {           // left-to-right +/- chain
    int res = term(s);
    while (true) {
        skipSpace(s);
        if (p >= s.length()) break;
        char c = s.charAt(p);
        if (c == '+')      { p++; res += term(s); }
        else if (c == '-') { p++; res -= term(s); }
        else break;                    // hit ')' -> let the caller consume it
    }
    return res;
}

private int term(String s) {           // number | '(' expr ')' | unary minus
    skipSpace(s);
    char c = s.charAt(p);
    if (c == '(') {
        p++;                           // consume '('
        int v = expr(s);               // mutual recursion
        skipSpace(s);
        p++;                           // consume ')'
        return v;
    }
    if (c == '-') { p++; return -term(s); }
    int v = 0;
    while (p < s.length() && Character.isDigit(s.charAt(p))) {
        v = v * 10 + (s.charAt(p) - '0');
        p++;
    }
    return v;
}

private void skipSpace(String s) {
    while (p < s.length() && s.charAt(p) == ' ') p++;
}
python
# python
# LC 224 - Basic Calculator
# IDEA: one function per grammar rule; expr <-> term is mutual recursion, '(' re-enters expr
# time = O(n), space = O(paren depth)
def calculate(s):
    i = 0

    def skip_space():
        nonlocal i
        while i < len(s) and s[i] == " ":
            i += 1

    def expr():                       # term (('+'|'-') term)*
        nonlocal i
        res = term()
        while True:
            skip_space()
            if i >= len(s):
                break
            if s[i] == "+":
                i += 1; res += term()
            elif s[i] == "-":
                i += 1; res -= term()
            else:
                break                 # ')' -> caller consumes it
        return res

    def term():                       # number | '(' expr ')' | '-' term
        nonlocal i
        skip_space()
        if s[i] == "(":
            i += 1
            v = expr()
            skip_space()
            i += 1
            return v
        if s[i] == "-":
            i += 1
            return -term()
        v = 0
        while i < len(s) and s[i].isdigit():
            v = v * 10 + int(s[i]); i += 1
        return v

    return expr()

各種變形

  • LC 1106(Parsing A Boolean Expression) —— 同一個游標模板;文法是 expr := 't' | 'f' | '!(' expr ')' | ('&'|'|') '(' expr (',' expr)* ')',所以遞迴要收集一串子結果,再用 and/or 摺起來。
  • LC 736(Parse Lisp Expression) —— 同一個模板再加一個作用域堆疊:let 會綁定變數,所以每次遞迴呼叫都要帶著(或 push/pop)一份環境表。
  • LC 770(Basic Calculator IV) —— 同一個模板,但每個子結果是一個多項式(排序後的變數 tuple → 係數的對照表)而不是整數。

常見的 LeetCode 題目

  • LC 394: Decode String
  • LC 224: Basic Calculator
  • LC 1106: Parsing A Boolean Expression
  • LC 736: Parse Lisp Expression
  • LC 770: Basic Calculator IV

5-3) 折半遞迴(快速冪) —— LC 50 優先度 4/5 — 高價值 — 這裡有缺口就會掉關

定義:當參數是按倍率縮小(通常是 /2)而不是減 1 時,遞迴深度就從 O(n) 掉到 O(log n)。最經典的例子就是二進位快速冪:

text
x^n = (x^(n/2))^2            if n is even
x^n = (x^(n/2))^2 * x        if n is odd
x^0 = 1                      base case

時間:O(log n)。空間:O(log n) 的呼叫堆疊。

關鍵洞見:half 只算一次,然後把它平方。寫成 fastPow(x, n/2) * fastPow(x, n/2) 看起來一樣,卻讓遞迴樹重新展開成 O(n)。

範例 —— LC 50: Pow(x, n)

java
// java
// LC 50 - Pow(x, n)
// IDEA: binary exponentiation - halve the exponent every call, square the result
// time = O(log n), space = O(log n)
public double myPow(double x, int n) {
    long N = n;                     // widen: -Integer.MIN_VALUE overflows an int
    if (N < 0) {
        x = 1 / x;
        N = -N;
    }
    return fastPow(x, N);
}

private double fastPow(double x, long n) {
    if (n == 0) return 1.0;
    double half = fastPow(x, n / 2);   // compute ONCE
    return (n % 2 == 0) ? half * half : half * half * x;
}
python
# python
# LC 50 - Pow(x, n)
# IDEA: binary exponentiation - halve the exponent every call, square the result
# time = O(log n), space = O(log n)
def myPow(x, n):
    if n < 0:
        x, n = 1 / x, -n

    def fast(x, n):
        if n == 0:
            return 1.0
        half = fast(x, n // 2)          # compute ONCE
        return half * half if n % 2 == 0 else half * half * x

    return fast(x, n)

各種變形

  • LC 1922(Count Good Numbers) —— 變化點:同樣的遞迴,但要取模,而且 n 可以到 10^15,所以非 O(log n) 不可。偶數索引有 5 種選擇(0,2,4,6,8),奇數索引有 4 種(質數 2,3,5,7)→ 5^ceil(n/2) * 4^floor(n/2) mod 1e9+7。
java
// java
// LC 1922 - Count Good Numbers
// IDEA: modular fast power; ceil(n/2) even slots x 5 choices, floor(n/2) odd slots x 4
// time = O(log n), space = O(log n)
private static final int MOD = 1_000_000_007;

public int countGoodNumbers(long n) {
    return (int) (powMod(5, (n + 1) / 2) * powMod(4, n / 2) % MOD);
}

private long powMod(long b, long e) {
    if (e == 0) return 1;
    long half = powMod(b, e / 2);
    long sq = half * half % MOD;
    return (e % 2 == 0) ? sq : sq * b % MOD;
}
python
# python
# LC 1922 - Count Good Numbers
# IDEA: modular fast power; ceil(n/2) even slots x 5 choices, floor(n/2) odd slots x 4
# time = O(log n), space = O(log n)
def countGoodNumbers(n):
    MOD = 10 ** 9 + 7

    def pow_mod(b, e):
        if e == 0:
            return 1
        half = pow_mod(b, e // 2)
        sq = half * half % MOD
        return sq if e % 2 == 0 else sq * b % MOD

    return pow_mod(5, (n + 1) // 2) * pow_mod(4, n // 2) % MOD
  • LC 231 / 326 / 342(Power of Two / Three / Four) —— 變化點:方向反過來 —— 把 n 一路除到 1,而不是往上乘出來。一個模板涵蓋三題(換底數就好);記得擋掉 n < 1,否則遞迴永遠不會結束。
java
// java
// LC 326 - Power of Three (same shape for LC 231 base 2, LC 342 base 4)
// IDEA: peel one factor per call; n is a power of b iff it divides down to exactly 1
// time = O(log n), space = O(log n)
public boolean isPowerOfThree(int n) {
    if (n < 1) return false;   // 0 and negatives are never powers
    if (n == 1) return true;   // 3^0
    return n % 3 == 0 && isPowerOfThree(n / 3);
}
python
# python
# LC 326 - Power of Three (same shape for LC 231 base 2, LC 342 base 4)
# IDEA: peel one factor per call; n is a power of b iff it divides down to exactly 1
# time = O(log n), space = O(log n)
def isPowerOfThree(n):
    if n < 1:
        return False
    if n == 1:
        return True
    return n % 3 == 0 and isPowerOfThree(n // 3)

常見的 LeetCode 題目

  • LC 50: Pow(x, n)
  • LC 1922: Count Good Numbers(模數快速冪)
  • LC 231: Power of Two
  • LC 326: Power of Three
  • LC 342: Power of Four

5-4) 遞迴關係化簡(沒有樹,也沒有搜尋) —— LC 779 / 1823 優先度 4/5 — 高價值 — 這裡有缺口就會掉關

定義:有些問題根本沒有資料結構可以走訪。整個解就是一行把 f(n) 和 f(n-1)(或 f(n/2)、f(n/1000)……)連起來的遞迴關係式。面試考的是你能不能推導出那個關係;推出來之後程式碼只有 3 行。

怎麼推導:

text
1. Write out the answer for n = 1, 2, 3, 4 by hand.
2. Ask: "given the answer for n-1, what single operation produces the answer for n?"
   - index mapping   -> which position in row n-1 does position k in row n come from?
   - shift/rotation  -> after one round, what does the smaller problem's answer become?
3. Base case = the smallest n you can answer without thinking.

時間:O(遞迴的深度)。空間:O(深度) 的呼叫堆疊(尾遞迴形狀可以輕鬆改成 O(1) 的迴圈)。

範例 —— LC 779: K-th Symbol in Grammar

第 n 列是把第 n-1 列的每個 0 換成 01、每個 1 換成 10。所以第 n 列的位置 k 來自第 n-1 列的位置 (k+1)/2:k 為奇數就複製父元素,k 為偶數就翻轉它。

java
// java
// LC 779 - K-th Symbol in Grammar
// IDEA: position k of row n comes from position (k+1)/2 of row n-1; even k flips the bit
// time = O(n), space = O(n)
public int kthGrammar(int n, int k) {
    if (n == 1) return 0;                            // row 1 is just "0"
    int parent = kthGrammar(n - 1, (k + 1) / 2);
    return (k % 2 == 1) ? parent : 1 - parent;       // odd = copy, even = flip
}
python
# python
# LC 779 - K-th Symbol in Grammar
# IDEA: position k of row n comes from position (k+1)//2 of row n-1; even k flips the bit
# time = O(n), space = O(n)
def kthGrammar(n, k):
    if n == 1:
        return 0
    parent = kthGrammar(n - 1, (k + 1) // 2)
    return parent if k % 2 == 1 else 1 - parent

範例 —— LC 1823: Find the Winner of the Circular Game(Josephus 遞迴式)

第一次淘汰之後剩下 n-1 個人,而且計數要從往前 k 個位置重新開始 —— 所以小問題的答案只要平移 k(再對 n 取模)就好。

java
// java
// LC 1823 - Find the Winner of the Circular Game
// IDEA: Josephus recurrence (0-indexed): f(1) = 0, f(n) = (f(n-1) + k) % n
// time = O(n), space = O(n)
public int findTheWinner(int n, int k) {
    return winner(n, k) + 1;              // convert 0-indexed seat to 1-indexed
}

private int winner(int n, int k) {
    if (n == 1) return 0;
    return (winner(n - 1, k) + k) % n;    // shift the smaller answer by k
}
python
# python
# LC 1823 - Find the Winner of the Circular Game
# IDEA: Josephus recurrence (0-indexed): f(1) = 0, f(n) = (f(n-1) + k) % n
# time = O(n), space = O(n)
def findTheWinner(n, k):
    def winner(m):
        if m == 1:
            return 0
        return (winner(m - 1) + k) % m
    return winner(n) + 1

變形 —— 按數量級拆解,而不是一次減 1:LC 273(Integer to English Words)

變化點:這個遞迴式每次剝掉最大的量級單位(Billion/Million/Thousand/Hundred),再對餘下的部分遞迴,所以深度是 O(log10 n) 而不是 O(n)。

java
// java
// LC 273 - Integer to English Words
// IDEA: split off the biggest unit, name it, recurse on the remainder; helper always
//       returns a space-terminated chunk so concatenation is uniform
// time = O(log10(n)), space = O(log10(n))
private static final String[] BELOW_20 = {"", "One", "Two", "Three", "Four", "Five", "Six",
        "Seven", "Eight", "Nine", "Ten", "Eleven", "Twelve", "Thirteen", "Fourteen",
        "Fifteen", "Sixteen", "Seventeen", "Eighteen", "Nineteen"};
private static final String[] TENS = {"", "Ten", "Twenty", "Thirty", "Forty", "Fifty",
        "Sixty", "Seventy", "Eighty", "Ninety"};

public String numberToWords(int num) {
    if (num == 0) return "Zero";          // only place "Zero" is ever printed
    return words(num).trim();
}

private String words(int num) {
    if (num == 0)         return "";
    if (num < 20)         return BELOW_20[num] + " ";
    if (num < 100)        return TENS[num / 10] + " " + words(num % 10);
    if (num < 1000)       return BELOW_20[num / 100] + " Hundred " + words(num % 100);
    if (num < 1000000)    return words(num / 1000) + "Thousand " + words(num % 1000);
    if (num < 1000000000) return words(num / 1000000) + "Million " + words(num % 1000000);
    return words(num / 1000000000) + "Billion " + words(num % 1000000000);
}
python
# python
# LC 273 - Integer to English Words
# IDEA: split off the biggest unit, name it, recurse on the remainder; helper always
#       returns a space-terminated chunk so concatenation is uniform
# time = O(log10(n)), space = O(log10(n))
BELOW_20 = ["", "One", "Two", "Three", "Four", "Five", "Six", "Seven", "Eight", "Nine",
            "Ten", "Eleven", "Twelve", "Thirteen", "Fourteen", "Fifteen", "Sixteen",
            "Seventeen", "Eighteen", "Nineteen"]
TENS = ["", "Ten", "Twenty", "Thirty", "Forty", "Fifty", "Sixty", "Seventy", "Eighty",
        "Ninety"]

def numberToWords(num):
    if num == 0:
        return "Zero"

    def words(n):
        if n == 0:
            return ""
        if n < 20:
            return BELOW_20[n] + " "
        if n < 100:
            return TENS[n // 10] + " " + words(n % 10)
        if n < 1000:
            return BELOW_20[n // 100] + " Hundred " + words(n % 100)
        if n < 10 ** 6:
            return words(n // 1000) + "Thousand " + words(n % 1000)
        if n < 10 ** 9:
            return words(n // 10 ** 6) + "Million " + words(n % 10 ** 6)
        return words(n // 10 ** 9) + "Billion " + words(n % 10 ** 9)

    return words(num).strip()

常見的 LeetCode 題目

  • LC 779: K-th Symbol in Grammar(索引對映)
  • LC 1823: Find the Winner of the Circular Game(Josephus)
  • LC 390: Elimination Game(對反轉後的半數問題遞迴)
  • LC 273: Integer to English Words(按數量級拆解)
  • LC 233: Number of Digit One(逐位遞迴)
  • LC 509: Fibonacci Number(教科書等級的遞迴式 —— 記得加記憶化)

5-5) 把一疊東西經由緩衝區搬移 — Towers of Hanoi 優先度 4/5 — 高價值 — 這裡有缺口就會掉關

定義:一疊東西只有最上面那個碰得到,所以要碰最底下那個之前,得先把它上面的全部暫放到別處, 做出那一步你確實會做的單一移動,再把暫放的東西搬回來。遞迴真正的工作,是在每次呼叫決定哪個位置扮演哪個角色 —— 而角色互換就是整個技巧。

text
move(n, src, dst, buf):
    move(n-1, src, buf, dst)      # clear the top n-1 out of the way — dst is now the buffer
    move disk n: src -> dst       # the one move you actually know how to make
    move(n-1, buf, dst, src)      # bring them back on top — src is now the buffer
python
# python
# Towers of Hanoi (CtCI 8.6) - move n disks from src to dst via buf, never putting
# a larger disk on a smaller one
# IDEA: the three pegs swap roles between the two recursive calls; that rotation,
#       not the move itself, is what makes the recursion correct
# time = O(2^n) moves — T(n) = 2T(n-1) + 1 = 2^n - 1
# space = O(n) auxiliary (the call stack); the `moves` list itself is O(2^n) output
def hanoi(n, src, dst, buf, moves):
    if n == 0:
        return
    hanoi(n - 1, src, buf, dst, moves)      # dst acts as the buffer here
    moves.append((src, dst))                # move the bottom disk
    hanoi(n - 1, buf, dst, src, moves)      # src acts as the buffer here

# hanoi(3, "A", "C", "B", moves)
#   -> A->C, A->B, C->B, A->C, B->A, B->C, A->C     (7 = 2^3 - 1 moves)
java
// java
// Towers of Hanoi - the same recursion on real stacks, so the invariant is checkable
// IDEA: recurse to park n-1 disks on the buffer, move one, recurse to restore them
// time = O(2^n), space = O(n)
void hanoi(int n, Deque<Integer> src, Deque<Integer> dst, Deque<Integer> buf) {
    if (n == 0) return;
    hanoi(n - 1, src, buf, dst);
    dst.push(src.pop());                    // smaller disks are all parked on buf
    hanoi(n - 1, buf, dst, src);
}
  • 為什麼是指數級,以及為什麼那已是最佳。 T(n) = 2T(n-1) + 1 = 2^n - 1,而其中每一步都是真正必要的 —— 輸出本身就有那麼多行,所以沒有任何演算法能做得更好。
  • 別處也有同樣的形狀。 「只用遞迴反轉一個 stack」是這個動作,只是把呼叫堆疊本身當作緩衝區; 「用一個額外的 stack 排序 stack」(CtCI 3.5)也是同一個動作,只是改用那個額外的 stack 當緩衝區。

5-6) 交織兩個序列 — 每一種交錯排列 優先度 3/5 — 值得會 — 多半是必備模式的變形

定義:給定兩個有序串列,輸出每一個保留各串列內部相對順序的序列。任何時刻,下一個元素只能是其中一個串列的頭, 或另一個串列的頭 —— 所以遞迴就是兩個分支,中間夾著精確的復原。

python
# python
# GENERAL PATTERN: all interleavings of two lists, each list's order preserved
# IDEA: take the head of one list, recurse, put it back; then the same for the other.
#       Mutate-recurse-restore keeps one copy of each list instead of slicing.
# time = O(C(m+n, m) * (m+n)) — one output per interleaving; space = O(m + n)
from collections import deque

def weave(first, second, prefix, out):
    if not first or not second:                  # one side is exhausted: no choice left
        out.append(prefix + list(first) + list(second))
        return

    head = first.popleft()                       # branch 1: take from `first`
    prefix.append(head)
    weave(first, second, prefix, out)
    prefix.pop()                                 # undo, exactly
    first.appendleft(head)

    head = second.popleft()                      # branch 2: take from `second`
    prefix.append(head)
    weave(first, second, prefix, out)
    prefix.pop()
    second.appendleft(head)

它真正派上用場的地方 — CtCI 4.9:列出能建出某棵給定 BST 的每一種插入順序。root 必須最先插入; 之後左子樹的順序和右子樹的順序彼此獨立,可以自由交織。

python
# python
# CtCI 4.9 - all insertion orders that rebuild this exact BST
# IDEA: root first, then weave every left-subtree order against every right-subtree
#       order — the two sides constrain each other in no way at all
# time = exponential in n (the answer itself is), space = O(n) per sequence
def all_sequences(node):
    if node is None:
        return [[]]                              # one empty order, not zero orders
    left = all_sequences(node.left)
    right = all_sequences(node.right)

    out = []
    for l in left:
        for r in right:
            weave(deque(l), deque(r), [node.val], out)
    return out

# BST     2          -> [[2, 1, 3], [2, 3, 1]]
#        / \
#       1   3
  • base case 回傳 [[]],不是 []。 空子樹恰好有一種合法順序 —— 空的那種。 回傳 [] 會讓上層每個乘積迴圈什麼都產生不出來。
  • 列舉 vs 判定。 LC 97(Interleaving String)只問 s3 是不是一種交錯;那是對 (i, j) 的 DP, O(mn)。列出全部則不可能這麼快 —— 答案有 C(m+n, m) 個。在面試現場點出這個差別,值得說上一句。
  • 這裡的「修改—遞迴—復原」紀律和 backtrack.md 裡的是同一套;唯一不尋常的是, 被復原的狀態是輸入,而不是部分答案。

5-7) 其他掛著 Recursion 標籤的經典題

這些題目都落在上面已經談過的模式裡,列出來只是求完整:

LC 題目 屬於
10 Regular Expression Matching 對 (i, j) 做由上而下的遞迴 + 記憶化 —— 見 recursion_to_dp.md
44 Wildcard Matching 跟 LC 10 一樣,只是 * 配對的是一整段,而不是「前一個字元重複 0 次以上」
486 Predict the Winner 對 (l, r) 做 minimax 遞迴 + 記憶化 —— 見 recursion_to_dp.md
60 Permutation Sequence 階乘進位制:先把 k 轉成從 0 起算(k - 1),再用 k / (n-1)! 選出每一位數,再對剩下的遞迴