動態規劃入門模板:背包、LIS、編輯距離

DP 三要素

拿到任何 DP 題,先確認這三件事:

  1. 狀態定義:dp[i] 或 dp[i][j] 代表什麼?
  2. 轉移方程:當前狀態如何從子問題推導?
  3. 初始化:Base case 是什麼?遍歷方向是從前到後還是後到前?

這三點清楚後,程式碼幾乎是機械式填寫。


0/1 背包(01 Knapsack)

問題

有 N 個物品,每個有重量 weights[i] 和價值 values[i],背包容量 W。每個物品只能拿一次,求最大價值。

2D DP(最易理解)

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
int knapsack01(int[] weights, int[] values, int W) {
    int n = weights.length;
    // dp[i][w] = 只考慮前 i 個物品,容量為 w 時的最大價值
    int[][] dp = new int[n + 1][W + 1];

    for (int i = 1; i <= n; i++) {
        for (int w = 0; w <= W; w++) {
            dp[i][w] = dp[i-1][w];  // 不拿第 i 個物品
            if (w >= weights[i-1]) {
                // 拿第 i 個物品
                dp[i][w] = Math.max(dp[i][w], dp[i-1][w - weights[i-1]] + values[i-1]);
            }
        }
    }
    return dp[n][W];
}

1D 滾動陣列(空間 O(W))

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
int knapsack01Optimized(int[] weights, int[] values, int W) {
    int n = weights.length;
    int[] dp = new int[W + 1];  // dp[w] = 容量 w 時的最大價值

    for (int i = 0; i < n; i++) {
        // 必須從右到左(大容量先更新),防止同一物品被重複選取
        for (int w = W; w >= weights[i]; w--) {
            dp[w] = Math.max(dp[w], dp[w - weights[i]] + values[i]);
        }
    }
    return dp[W];
}

為什麼逆序:若從左到右(小容量先更新),dp[w] 更新時用到的 dp[w - weights[i]] 可能已是本輪更新的值,等同於拿了兩次第 i 個物品。

相關題型

  • LC 416 Partition Equal Subset Sum:能否分成兩個和相等的子集 → 能否從陣列選若干數,使總和 = sum/2(0/1 背包,values[i] = weights[i])
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
public boolean canPartition(int[] nums) {
    int sum = Arrays.stream(nums).sum();
    if (sum % 2 != 0) return false;
    int target = sum / 2;
    boolean[] dp = new boolean[target + 1];
    dp[0] = true;
    for (int num : nums) {
        for (int w = target; w >= num; w--) {
            dp[w] = dp[w] || dp[w - num];
        }
    }
    return dp[target];
}
  • LC 494 Target Sum:給每個數加 + 或 -,求組合數 → 轉化為 0/1 背包計數問題

完全背包(Unbounded Knapsack)

每個物品可以無限次拿取。與 0/1 背包的差別:內層迴圈改為正序(小容量先更新),這樣 dp[w - weights[i]] 是本輪更新的值,等同於允許同一物品再次拿取。

1
2
3
4
5
6
7
8
9
int knapsackUnbounded(int[] weights, int[] values, int W) {
    int[] dp = new int[W + 1];
    for (int i = 0; i < weights.length; i++) {
        for (int w = weights[i]; w <= W; w++) {  // 正序!
            dp[w] = Math.max(dp[w], dp[w - weights[i]] + values[i]);
        }
    }
    return dp[W];
}

相關題型

  • LC 322 Coin Change:最少硬幣數 → 完全背包,values[i] = 1,求最小值
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
public int coinChange(int[] coins, int amount) {
    int[] dp = new int[amount + 1];
    Arrays.fill(dp, amount + 1);  // 初始化為不可達的大數
    dp[0] = 0;
    for (int coin : coins) {
        for (int w = coin; w <= amount; w++) {
            dp[w] = Math.min(dp[w], dp[w - coin] + 1);
        }
    }
    return dp[amount] > amount ? -1 : dp[amount];
}
  • LC 518 Coin Change II:組合數 → 完全背包,但兩層迴圈不能交換(外層物品,內層容量)才能避免重複計算同一組合的不同排列

最長遞增子序列(LIS)

O(n²) DP

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
int lis(int[] nums) {
    int n = nums.length;
    // dp[i] = 以 nums[i] 結尾的最長遞增子序列長度
    int[] dp = new int[n];
    Arrays.fill(dp, 1);  // 每個元素自己就是長度 1 的 LIS
    int max = 1;
    for (int i = 1; i < n; i++) {
        for (int j = 0; j < i; j++) {
            if (nums[j] < nums[i]) {
                dp[i] = Math.max(dp[i], dp[j] + 1);
            }
        }
        max = Math.max(max, dp[i]);
    }
    return max;
}

tails[i] 代表「長度為 i+1 的遞增子序列中,最小的結尾元素」。每個新元素用 binary search 找到能替換的位置:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
int lisOptimal(int[] nums) {
    List<Integer> tails = new ArrayList<>();
    for (int num : nums) {
        // 找 tails 中第一個 >= num 的位置
        int pos = Collections.binarySearch(tails, num);
        if (pos < 0) pos = -(pos + 1);  // binarySearch 未找到時回傳 -(insertion point) - 1

        if (pos == tails.size()) {
            tails.add(num);  // num 比所有 tails 都大,LIS 長度 +1
        } else {
            tails.set(pos, num);  // 替換,讓結尾更小(有利於後續延伸)
        }
    }
    return tails.size();
}

注意:tails 陣列不是 LIS 本身,只是用來計算長度。若需要重建 LIS 路徑,需要額外的 parent 陣列追蹤。

相關題型

  • LC 300 Longest Increasing Subsequence:基本題
  • LC 354 Russian Doll Envelopes:2D LIS,先對第一維升序排序,第二維降序排序,再對第二維跑 LIS
1
2
3
4
5
6
public int maxEnvelopes(int[][] envelopes) {
    // 第一維升序,同一寬度的第二維降序(防止同一寬度被多次選取)
    Arrays.sort(envelopes, (a, b) -> a[0] != b[0] ? a[0] - b[0] : b[1] - a[1]);
    int[] heights = Arrays.stream(envelopes).mapToInt(e -> e[1]).toArray();
    return lisOptimal(heights);
}

編輯距離(Edit Distance / Levenshtein)

問題

最少需要幾次操作(insert、delete、replace)將 word1 轉換為 word2。

實作

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
int editDistance(String word1, String word2) {
    int m = word1.length(), n = word2.length();
    // dp[i][j] = word1[0..i-1] 轉換為 word2[0..j-1] 的最少操作數
    int[][] dp = new int[m + 1][n + 1];

    // Base case:一個字串為空時,全部插入或刪除
    for (int i = 0; i <= m; i++) dp[i][0] = i;
    for (int j = 0; j <= n; j++) dp[0][j] = j;

    for (int i = 1; i <= m; i++) {
        for (int j = 1; j <= n; j++) {
            if (word1.charAt(i-1) == word2.charAt(j-1)) {
                dp[i][j] = dp[i-1][j-1];  // 字元相同,不需操作
            } else {
                dp[i][j] = 1 + Math.min(
                    dp[i-1][j-1],  // replace
                    Math.min(
                        dp[i-1][j],  // delete from word1
                        dp[i][j-1]   // insert into word1
                    )
                );
            }
        }
    }
    return dp[m][n];
}

轉移方程說明

1
2
3
4
5
6
7
8
9
word1[i] == word2[j]:
    dp[i][j] = dp[i-1][j-1]  (不需操作)

word1[i] != word2[j]:
    dp[i][j] = 1 + min(
        dp[i-1][j-1],   replace word1[i] with word2[j]
        dp[i-1][j],     delete word1[i]
        dp[i][j-1]      insert word2[j] after word1[i]
    )

相關題型

  • LC 72 Edit Distance:基本題
  • LC 583 Delete Operation for Two Strings:只允許刪除 → LCS 思路,答案 = m + n - 2 * LCS

常見 DP 模式速查

問題類型代表 LeetCode關鍵模板空間優化
0/1 背包416, 494外層物品,內層容量逆序O(W)
完全背包322, 518外層物品,內層容量正序O(W)
LIS300, 354patience sort + binary searchO(n)
編輯距離72, 5832D DP,三選一 minO(min(m,n))
區間 DP312, 1039外層枚舉長度,內層枚舉左端點—
樹形 DP337, 543DFS post-order,回傳多個狀態—
狀態壓縮 DP847, 1125bitmask 表示子集狀態—

區間 DP 框架(補充)

LC 312 Burst Balloons:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
public int maxCoins(int[] nums) {
    int n = nums.length;
    int[] arr = new int[n + 2];
    arr[0] = arr[n + 1] = 1;
    for (int i = 0; i < n; i++) arr[i + 1] = nums[i];

    int m = n + 2;
    int[][] dp = new int[m][m];

    // 枚舉區間長度(從小到大)
    for (int len = 2; len < m; len++) {
        for (int left = 0; left < m - len; left++) {
            int right = left + len;
            // k 是區間 (left, right) 中最後一個被戳破的氣球
            for (int k = left + 1; k < right; k++) {
                dp[left][right] = Math.max(
                    dp[left][right],
                    dp[left][k] + arr[left] * arr[k] * arr[right] + dp[k][right]
                );
            }
        }
    }
    return dp[0][m - 1];
}

小結

  • 0/1 背包 vs 完全背包:差別只在內層迴圈方向(逆序 vs 正序)。
  • LIS:O(n²) 夠用於 n <= 2000;O(n log n) 的 patience sort 是面試加分。
  • 編輯距離:三個操作對應三個方向的 DP 轉移,記住方向比背公式重要。
  • 遇到新 DP 題:先想狀態定義,畫出轉移方向,再考慮空間優化。

留言討論