從暴力遞迴到動態規劃#

枚舉與遞迴#

當貪心演算法失效時,我們需要通過枚舉來找出所有滿足條件的組合,然後從中選出最優解。而遞迴正是幫助我們生成所有組合的有效方法。

遞迴的價值#

遞迴是搜尋組合的一種非常直觀的思路:

  1. 堆疊與狀態存儲:每次遞迴呼叫保存當前狀態,返回時恢復
  2. 回溯能力:可以「退一步」嘗試新的組合
  3. 深度優先搜尋:遞迴本質上是 DFS

遞迴與問題表達 遞迴形式的求解幾乎就是把題設中的函式表達式照搬過來。 從數學意義上講,遞迴更直觀,且易於理解。

硬幣找零的遞迴解法#

暴力遞迴程式碼
int getMinCountsHelper(int total, int[] values) {
    // 邊界條件:金額為 0,不需要硬幣
    if (total == 0) { return 0; }

    int minCount = Integer.MAX_VALUE;

    for (int i = 0; i < values.length; i++) {
        int currentValue = values[i];

        // 面值大於總額,跳過
        if (currentValue > total) { continue; }

        int rest = total - currentValue;
        int restCount = getMinCountsHelper(rest, values);

        // 組合不可行,跳過
        if (restCount == -1) { continue; }

        int totalCount = 1 + restCount;
        if (totalCount < minCount) {
            minCount = totalCount;
        }
    }

    return (minCount == Integer.MAX_VALUE) ? -1 : minCount;
}

遞迴樹與重叠子問題#

c[0]=5, c[1]=3, k=25 為例,遞迴過程形成一棵樹:

flowchart TD
    A["F(25)"] --> B["F(20)"]
    A --> C["F(22)"]
    B --> D["F(15)"]
    B --> E["F(17)"]
    C --> F["F(17)"]
    C --> G["F(19)"]
    D --> H["..."]
    E --> I["..."]
    F --> J["..."]
    G --> K["..."]

    style E fill:#ffeb3b
    style F fill:#ffeb3b

    linkStyle 3 stroke:#ff5722,stroke-width:2px
    linkStyle 4 stroke:#ff5722,stroke-width:2px

F(17) 被重複計算! 這就是重叠子問題。

重叠子問題的發現 觀察上圖:F(17) 被計算了兩次!這就是重叠子問題。 當余額相同時,後續的求解路徑和結果完全相同。

暴力遞迴的問題#

  1. 性能問題:時間複雜度為指數級 O(Cᴷ)
  2. 可讀性問題:複雜問題難以調試
  3. 重複計算:同一子問題被反覆求解

備忘錄:消除重複計算#

備忘錄的核心思想#

在每次計算出某個子問題的答案後,將結果記錄到備忘錄。之後遇到相同子問題時,直接查詢備忘錄,無需重複計算。

F(25) 依賴 F(20) 和 F(22)
F(20) 需要計算 F(12)
F(22) 也需要計算 F(12)
↓
第一次計算 F(12) 後存入備忘錄
第二次直接從備忘錄取出 → 避免重複計算!

備忘錄的實現#

可以使用兩種資料結構:

  • 陣列:當索引可以直接定位時(如斐波那契數列)
  • 雜湊表:當狀態較複雜時
帶備忘錄的遞迴程式碼
int getMinCountsHelper(int total, int[] values, int[] memo) {
    // 查詢備忘錄
    int savedMinCount = memo[total];
    if (savedMinCount != -2) {
        return savedMinCount;
    }

    int minCount = Integer.MAX_VALUE;

    for (int i = 0; i < values.length; i++) {
        int currentValue = values[i];
        if (currentValue > total) { continue; }

        int rest = total - currentValue;
        int restCount = getMinCountsHelper(rest, values, memo);

        if (restCount == -1) { continue; }

        int totalCount = 1 + restCount;
        if (totalCount < minCount) {
            minCount = totalCount;
        }
    }

    // 存入備忘錄
    if (minCount == Integer.MAX_VALUE) {
        memo[total] = -1;
        return -1;
    }

    memo[total] = minCount;
    return minCount;
}

// 初始化備忘錄
int[] memo = new int[total + 1];
Arrays.fill(memo, -2);  // -2 表示未計算
memo[0] = 0;           // 金額 0 需要 0 枚硬幣

複雜度分析#

方法時間複雜度空間複雜度
暴力遞迴O(Cⁿ)O(n) 堆疊深度
備忘錄O(n × C)O(n) 備忘錄

降維打擊 備忘錄讓我們實現了從指數級 O(Cⁿ) 到多項式級 O(n×C) 的「降維打擊」。

從自頂向下到自底向上#

自頂向下(遞迴 + 備忘錄)#

F(25) → F(20), F(22) → F(15), F(17), F(17), F(19) → ...

每次需要查詢子問題是否已計算,存在額外開銷。

自底向上(動態規劃)#

如果我們能預知處理大問題前必須先解決哪些小問題,就可以先計算所有小問題,再計算大問題。

F(0) → F(1) → F(2) → ... → F(24) → F(25)

自底向上的前提:無後效性 子問題之間的依賴必須是單向的。 如果 A 依賴 B,則 B 不能依賴 A。

動態規劃解法#

自底向上的 DP 程式碼
int getMinCounts(int k, int[] values) {
    int[] memo = new int[k + 1];
    Arrays.fill(memo, k + 1);  // 初始化為「無窮大」
    memo[0] = 0;               // 初始化狀態

    for (int i = 1; i <= k; i++) {          // 自底向上
        for (int coin : values) {            // 嘗試每種硬幣
            if (i - coin < 0) { continue; }
            memo[i] = Math.min(memo[i], memo[i - coin] + 1);  // 決策
        }
    }

    return (memo[k] == k + 1) ? -1 : memo[k];
}

狀態轉移方程#

動態規劃的核心就是狀態轉移方程

$$ DP(n) = \begin{cases} 0, & n = 0 \ -1, & n < 0 \ \min(DP(n), 1 + DP(n - c)), & c \in values \end{cases} $$

構建狀態轉移方程的步驟#

  1. 初始化狀態:原點,計算的開端(memo[0] = 0)
  2. 狀態參數:子問題與原問題之間變化的變數(金額 k)
  3. 決策:改變狀態,讓狀態逼近初始化狀態(挑一枚硬幣)

演進總結#

flowchart LR
    subgraph 演進路徑
        A[貪心演算法<br/>O局部最優] --> B[回溯+遞迴<br/>O正確但慢]
        B --> C[備忘錄<br/>自頂向下]
        C --> D[動態規劃<br/>自底向上]
    end

    A -.->|問題| A1[局部≠整體]
    B -.->|問題| B1["O(2ⁿ) 指數級"]
    C -.->|問題| C1[遞迴開銷]
    D -.->|優勢| D1[最優效率]

    style A fill:#ffebee
    style B fill:#fff3e0
    style C fill:#e3f2fd
    style D fill:#e8f5e9

關鍵洞察 帶備忘錄的遞迴和動態規劃在時間複雜度上是相同的,但動態規劃通過自底向上的迭代方式,避免了遞迴帶來的函式呼叫開銷。