Skip to content

动态规划经典问题:背包、最长公共子序列、最长递增子序列

DP 问题的三板斧

动态规划(DP)是算法面试的重头戏。掌握了它,你就掌握了面试的半壁江山。

但 DP 的题目千变万化,如何以不变应万变?

记住三步:

  1. 定义状态dp[i]dp[i][j] 是什么?
  2. 找转移方程:如何从子问题推导?
  3. 确定遍历顺序:保证子问题先被计算

今天,我们用三道经典题目,掌握 DP 的套路。

一、背包问题

0-1 背包

问题:有 n 件物品,每件重量 w[i],价值 v[i],容量为 C 的背包。求不超过容量的最大价值。

状态定义dp[i][j] = 前 i 件物品,容量为 j 时的最大价值

转移方程

dp[i][j] = max(dp[i-1][j], dp[i-1][j-w[i]] + v[i])
// 不选第 i 件          选第 i 件
java
public int knapsack01(int[] weights, int[] values, int capacity) {
    int n = weights.length;
    int[][] dp = new int[n + 1][capacity + 1];
    
    for (int i = 1; i <= n; i++) {
        for (int j = 0; j <= capacity; j++) {
            dp[i][j] = dp[i - 1][j];  // 不选第 i 件
            if (j >= weights[i - 1]) {
                dp[i][j] = Math.max(dp[i][j], 
                    dp[i - 1][j - weights[i - 1]] + values[i - 1]);
            }
        }
    }
    
    return dp[n][capacity];
}

空间优化:二维变一维

java
public int knapsack01Optimized(int[] weights, int[] values, int capacity) {
    int n = weights.length;
    int[] dp = new int[capacity + 1];
    
    for (int i = 0; i < n; i++) {
        // 逆序遍历,保证用上一行的数据
        for (int j = capacity; j >= weights[i]; j--) {
            dp[j] = Math.max(dp[j], dp[j - weights[i]] + values[i]);
        }
    }
    
    return dp[capacity];
}

为什么逆序遍历?

因为 dp[j] 依赖 dp[j - weights[i]],而 dp[j - weights[i]] 应该是上一行的值。如果顺序遍历,会被当前行覆盖。

完全背包

问题:每件物品可以选无限次。

区别:从正序遍历

java
public int knapsackComplete(int[] weights, int[] values, int capacity) {
    int n = weights.length;
    int[] dp = new int[capacity + 1];
    
    for (int i = 0; i < n; i++) {
        // 正序遍历,可以多次选择同一物品
        for (int j = weights[i]; j <= capacity; j++) {
            dp[j] = Math.max(dp[j], dp[j - weights[i]] + values[i]);
        }
    }
    
    return dp[capacity];
}

多重背包

问题:每件物品最多选 k 次。

java
public int knapsackMultiple(int[] weights, int[] values, int[] limits, int capacity) {
    int n = weights.length;
    int[] dp = new int[capacity + 1];
    
    for (int i = 0; i < n; i++) {
        int limit = limits[i];
        for (int j = capacity; j >= weights[i]; j--) {
            // 枚举选择次数
            for (int k = 1; k <= limit && j >= k * weights[i]; k++) {
                dp[j] = Math.max(dp[j], 
                    dp[j - k * weights[i]] + k * values[i]);
            }
        }
    }
    
    return dp[capacity];
}

背包问题的变体

变体1:恰好装满

java
// 初始化时,只有 dp[0] = 0,其他为 -∞
int[] dp = new int[capacity + 1];
Arrays.fill(dp, Integer.MIN_VALUE);
dp[0] = 0;

变体2:价值最大 vs 件数最少

java
// 价值最大:max
// 件数最少:min

变体3:二维费用背包

java
int[][] dp = new int[C1 + 1][C2 + 1];

二、最长公共子序列(LCS)

问题:给定两个字符串,求最长公共子序列的长度。

状态定义dp[i][j] = text1[0...i-1] 和 text2[0...j-1] 的 LCS 长度

转移方程

if (text1[i-1] == text2[j-1]) {
    dp[i][j] = dp[i-1][j-1] + 1;
} else {
    dp[i][j] = max(dp[i-1][j], dp[i][j-1]);
}
java
public int longestCommonSubsequence(String text1, String text2) {
    int m = text1.length(), n = text2.length();
    int[][] dp = new int[m + 1][n + 1];
    
    for (int i = 1; i <= m; i++) {
        for (int j = 1; j <= n; j++) {
            if (text1.charAt(i - 1) == text2.charAt(j - 1)) {
                dp[i][j] = dp[i - 1][j - 1] + 1;
            } else {
                dp[i][j] = Math.max(dp[i - 1][j], dp[i][j - 1]);
            }
        }
    }
    
    return dp[m][n];
}

还原 LCS 字符串

java
public String findLCS(String text1, String text2) {
    int m = text1.length(), n = text2.length();
    int[][] dp = new int[m + 1][n + 1];
    
    // DP 过程省略...
    
    // 逆向回溯
    StringBuilder sb = new StringBuilder();
    int i = m, j = n;
    while (i > 0 && j > 0) {
        if (text1.charAt(i - 1) == text2.charAt(j - 1)) {
            sb.append(text1.charAt(i - 1));
            i--; j--;
        } else if (dp[i - 1][j] > dp[i][j - 1]) {
            i--;
        } else {
            j--;
        }
    }
    
    return sb.reverse().toString();
}

空间优化:只用两行

java
public int lcsOptimized(String text1, String text2) {
    int m = text1.length(), n = text2.length();
    int[] prev = new int[n + 1];
    int[] curr = new int[n + 1];
    
    for (int i = 1; i <= m; i++) {
        for (int j = 1; j <= n; j++) {
            if (text1.charAt(i - 1) == text2.charAt(j - 1)) {
                curr[j] = prev[j - 1] + 1;
            } else {
                curr[j] = Math.max(prev[j], curr[j - 1]);
            }
        }
        int[] temp = prev;
        prev = curr;
        curr = temp;
        Arrays.fill(curr, 0);
    }
    
    return prev[n];
}

三、最长递增子序列(LIS)

问题:给定一个数组,求最长递增子序列的长度。

方法一:O(n²) DP

状态定义dp[i] = 以 nums[i] 结尾的 LIS 长度

转移方程dp[i] = max(dp[j] + 1), 其中 j < i 且 nums[j] < nums[i]

java
public int lisDP(int[] nums) {
    if (nums.length == 0) return 0;
    
    int n = nums.length;
    int[] dp = new int[n];
    Arrays.fill(dp, 1);
    int maxLen = 1;
    
    for (int i = 1; i &lt; n; i++) {
        for (int j = 0; j &lt; i; j++) {
            if (nums[j] &lt; nums[i]) {
                dp[i] = Math.max(dp[i], dp[j] + 1);
            }
        }
        maxLen = Math.max(maxLen, dp[i]);
    }
    
    return maxLen;
}

方法二:O(n log n) 二分查找

核心思想:维护一个有序数组 tails[i] = 长度为 i+1 的 LIS 的最小结尾元素

java
public int lisBinarySearch(int[] nums) {
    if (nums.length == 0) return 0;
    
    int[] tails = new int[nums.length];
    int size = 0;
    
    for (int num : nums) {
        // 二分查找插入位置
        int i = 0, j = size;
        while (i &lt; j) {
            int mid = (i + j) / 2;
            if (tails[mid] &lt; num) {
                i = mid + 1;
            } else {
                j = mid;
            }
        }
        
        tails[i] = num;
        if (i == size) {
            size++;
        }
    }
    
    return size;
}

为什么 tails[i] 是长度为 i+1 的 LIS 的最小结尾?

可以用数学归纳法证明:

  • tails[0] 是长度为 1 的 LIS 的最小结尾
  • 对于 tails[i],假设它已经是长度为 i+1 的 LIS 的最小结尾
  • 当遍历到 num 时,如果 num > tails[i],可以扩展成更长的 LIS
  • 否则,找到第一个 >= num 的位置更新,保证 tails 是最小的

三大经典问题的对比

问题状态定义转移方程复杂度
0-1 背包dp[i][j] = 前 i 件,容量 jmax(dp[i-1][j], dp[i-1][j-w] + v)O(NC)
LCSdp[i][j] = 两串前 i/j 个equals? dp[i-1][j-1]+1 : max(dp[i-1][j], dp[i][j-1])O(MN)
LISdp[i] = 以 i 结尾max(dp[j]+1) where j<i && a[j]<a[i]O(n²)

总结

三大 DP 经典问题的套路:

  1. 背包问题:选或不选,用二维到一维的压缩
  2. LCS:相等向左上走,不等取最大
  3. LIS:以 i 结尾,向前找比它小的

掌握这三种问题的 DP 模板,面试中遇到变形题也能举一反三。

面试追问方向

  • 0-1 背包为什么逆序遍历?(保证用上一行数据)
  • 完全背包为什么正序遍历?(可以多次选择)
  • LCS 如何还原具体字符串?(逆向回溯)
  • LIS 的二分查找优化原理?(维护最小结尾数组)
  • 还有什么 DP 问题?(编辑距离、单词拆分、股票买卖)

基于 VitePress 构建