從暴力遞迴到動態規劃#
枚舉與遞迴#
當貪心演算法失效時,我們需要通過枚舉來找出所有滿足條件的組合,然後從中選出最優解。而遞迴正是幫助我們生成所有組合的有效方法。
遞迴的價值#
遞迴是搜尋組合的一種非常直觀的思路:
- 堆疊與狀態存儲:每次遞迴呼叫保存當前狀態,返回時恢復
- 回溯能力:可以「退一步」嘗試新的組合
- 深度優先搜尋:遞迴本質上是 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:2pxF(17) 被重複計算! 這就是重叠子問題。
重叠子問題的發現 觀察上圖:F(17) 被計算了兩次!這就是重叠子問題。 當余額相同時,後續的求解路徑和結果完全相同。
暴力遞迴的問題#
- 性能問題:時間複雜度為指數級 O(Cᴷ)
- 可讀性問題:複雜問題難以調試
- 重複計算:同一子問題被反覆求解
備忘錄:消除重複計算#
備忘錄的核心思想#
在每次計算出某個子問題的答案後,將結果記錄到備忘錄。之後遇到相同子問題時,直接查詢備忘錄,無需重複計算。
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} $$
構建狀態轉移方程的步驟#
- 初始化狀態:原點,計算的開端(memo[0] = 0)
- 狀態參數:子問題與原問題之間變化的變數(金額 k)
- 決策:改變狀態,讓狀態逼近初始化狀態(挑一枚硬幣)
演進總結#
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關鍵洞察 帶備忘錄的遞迴和動態規劃在時間複雜度上是相同的,但動態規劃通過自底向上的迭代方式,避免了遞迴帶來的函式呼叫開銷。