动态规划:从懵逼到装逼,一篇让你彻底搞懂DP的终极指南
引言:当递归开始"套娃"时
想象一下,你正在爬楼梯,每次可以跨1阶或2阶。问到第n阶有几种方法?你可能会想:"这不就是斐波那契数列嘛!"然后写下这样的代码:
java
public int climbStairs(int n) {
if (n <= 2) return n;
return climbStairs(n - 1) + climbStairs(n - 2);
}
恭喜你,你成功制造了一个时间复杂度为O(2^n)的"炸弹"!当n=50时,你的电脑开始怀疑人生,而你开始怀疑自己的编程能力。
别担心,这就是动态规划(Dynamic Programming,简称DP)要解决的问题!让我们一起来揭开DP的神秘面纱。
什么是动态规划?不是动态地规划!
首先,DP这个名字有点误导性------它既不"动态",也不总是关于"规划"。这个名字是发明者理查德·贝尔曼为了拿政府经费而故意起的高大上(他承认了!),其实叫"状态缓存"或"表格填充法"更合适。
官方定义:动态规划是一种通过把原问题分解为相对简单的子问题的方式求解复杂问题的方法。
人话版:记住已经算过的结果,别傻乎乎地重复计算!
DP的三大法宝
要判断一个问题能否用DP解决,看它是否具备这三个特征:
1. 重叠子问题 (Overlapping Subproblems)
就像前面的爬楼梯问题,计算climbStairs(5)时需要计算climbStairs(4)和climbStairs(3),而计算climbStairs(4)又需要计算climbStairs(3)和climbStairs(2)。看到了吗?climbStairs(3)被计算了多次!
2. 最优子结构 (Optimal Substructure)
问题的最优解包含子问题的最优解。比如在背包问题中,如果我知道前i-1个物品的最优解,那么前i个物品的最优解可以通过它推导出来。
3. 无后效性 (State Transition)
当前状态只与之前的状态有关,与之后的状态无关。一旦状态确定,后续决策不会影响之前的状态。
DP的两种写法:自顶向下 vs 自底向上
1. 记忆化搜索 (Memoization) - 自顶向下
这是最符合人类思维的写法:在递归的基础上加缓存。
java
public int climbStairs(int n) {
int[] memo = new int[n + 1];
Arrays.fill(memo, -1);
return dfs(n, memo);
}
private int dfs(int n, int[] memo) {
if (n <= 2) return n;
if (memo[n] != -1) return memo[n]; // 已经计算过,直接返回
memo[n] = dfs(n - 1, memo) + dfs(n - 2, memo); // 缓存结果
return memo[n];
}
2. DP表 (Tabulation) - 自底向上
这是更标准的DP写法,从小问题开始逐步解决大问题。
java
public int climbStairs(int n) {
if (n <= 2) return n;
int[] dp = new int[n + 1];
dp[1] = 1;
dp[2] = 2;
for (int i = 3; i <= n; i++) {
dp[i] = dp[i - 1] + dp[i - 2];
}
return dp[n];
}
3. 空间优化版
其实我们只需要前两个状态,不需要保存整个数组:
java
public int climbStairs(int n) {
if (n <= 2) return n;
int prev1 = 1, prev2 = 2;
for (int i = 3; i <= n; i++) {
int curr = prev1 + prev2;
prev1 = prev2;
prev2 = curr;
}
return prev2;
}
经典案例详解:0-1背包问题
问题描述:有一个容量为W的背包和n个物品,每个物品有重量weight[i]和价值value[i]。如何选择物品放入背包,使得总价值最大?
1. 暴力递归(感受一下痛苦)
java
public int knapSack(int W, int[] weights, int[] values, int n) {
if (n == 0 || W == 0) return 0;
// 当前物品重量超过剩余容量,跳过
if (weights[n - 1] > W) {
return knapSack(W, weights, values, n - 1);
} else {
// 选择1:不放入当前物品
int notTake = knapSack(W, weights, values, n - 1);
// 选择2:放入当前物品
int take = values[n - 1] + knapSack(W - weights[n - 1], weights, values, n - 1);
return Math.max(notTake, take);
}
}
时间复杂度:O(2^n) ------ 指数爆炸!
2. 记忆化搜索版
java
public int knapSack(int W, int[] weights, int[] values) {
int n = values.length;
int[][] memo = new int[n + 1][W + 1];
for (int[] row : memo) {
Arrays.fill(row, -1);
}
return dfs(W, weights, values, n, memo);
}
private int dfs(int W, int[] weights, int[] values, int n, int[][] memo) {
if (n == 0 || W == 0) return 0;
if (memo[n][W] != -1) return memo[n][W];
if (weights[n - 1] > W) {
memo[n][W] = dfs(W, weights, values, n - 1, memo);
} else {
int notTake = dfs(W, weights, values, n - 1, memo);
int take = values[n - 1] + dfs(W - weights[n - 1], weights, values, n - 1, memo);
memo[n][W] = Math.max(notTake, take);
}
return memo[n][W];
}
3. DP表版(标准解法)
java
public int knapSack(int W, int[] weights, int[] values) {
int n = values.length;
int[][] dp = new int[n + 1][W + 1];
for (int i = 1; i <= n; i++) {
for (int w = 1; w <= W; w++) {
if (weights[i - 1] <= w) {
// 选择当前物品或不选择
dp[i][w] = Math.max(
values[i - 1] + dp[i - 1][w - weights[i - 1]],
dp[i - 1][w]
);
} else {
// 当前物品太重,放不下
dp[i][w] = dp[i - 1][w];
}
}
}
return dp[n][W];
}
4. 空间优化版(滚动数组)
java
public int knapSack(int W, int[] weights, int[] values) {
int n = values.length;
int[] dp = new int[W + 1];
for (int i = 0; i < n; i++) {
// 必须倒序遍历,否则会重复计算
for (int w = W; w >= weights[i]; w--) {
dp[w] = Math.max(dp[w], values[i] + dp[w - weights[i]]);
}
}
return dp[W];
}
注意:内层循环必须倒序!正序会变成完全背包问题(物品可以重复选择)。
DP问题分类与解题模板
1. 线性DP
经典问题:最长递增子序列(LIS)
java
public int lengthOfLIS(int[] nums) {
if (nums.length == 0) return 0;
int[] dp = new int[nums.length];
Arrays.fill(dp, 1);
int maxAns = 1;
for (int i = 1; i < nums.length; i++) {
for (int j = 0; j < i; j++) {
if (nums[i] > nums[j]) {
dp[i] = Math.max(dp[i], dp[j] + 1);
}
}
maxAns = Math.max(maxAns, dp[i]);
}
return maxAns;
}
2. 区间DP
经典问题:矩阵连乘最小次数
java
public int matrixChainOrder(int[] p) {
int n = p.length - 1; // 矩阵个数
int[][] dp = new int[n][n];
// l为区间长度
for (int l = 2; l <= n; l++) {
for (int i = 0; i < n - l + 1; i++) {
int j = i + l - 1;
dp[i][j] = Integer.MAX_VALUE;
for (int k = i; k < j; k++) {
int cost = dp[i][k] + dp[k + 1][j] + p[i] * p[k + 1] * p[j + 1];
dp[i][j] = Math.min(dp[i][j], cost);
}
}
}
return dp[0][n - 1];
}
3. 状态压缩DP
经典问题:旅行商问题(TSP)
java
public int tsp(int[][] graph) {
int n = graph.length;
int VISITED_ALL = (1 << n) - 1;
int[][] dp = new int[n][1 << n];
for (int i = 0; i < n; i++) {
Arrays.fill(dp[i], -1);
}
return tspHelper(0, 1, graph, dp, VISITED_ALL);
}
private int tspHelper(int pos, int mask, int[][] graph, int[][] dp, int VISITED_ALL) {
if (mask == VISITED_ALL) {
return graph[pos][0]; // 返回起点
}
if (dp[pos][mask] != -1) {
return dp[pos][mask];
}
int ans = Integer.MAX_VALUE;
for (int city = 0; city < graph.length; city++) {
if ((mask & (1 << city)) == 0) { // 如果城市未访问
int newAns = graph[pos][city] + tspHelper(city, mask | (1 << city), graph, dp, VISITED_ALL);
ans = Math.min(ans, newAns);
}
}
return dp[pos][mask] = ans;
}
DP vs 分治 vs 贪心:找对方法很重要
方法 | 子问题关系 | 子问题重复 | 求解顺序 |
---|---|---|---|
分治 | 相互独立 | 无重复 | 任意顺序 |
贪心 | 贪心选择 | 无重复 | 自顶向下 |
DP | 重叠子问题 | 有重复 | 自底向上 |
简单区分:
- 分治:子问题不重叠(如归并排序)
- 贪心:局部最优希望得到全局最优(不一定正确)
- DP:子问题重叠且需要保存中间结果
DP避坑指南:常见错误与解决方案
1. 错误:错误的状态定义
症状 :状态转移方程极其复杂,或者根本写不出来 解药:重新思考状态表示,有时候增加一个维度就能豁然开朗
2. 错误:错误的状态转移顺序
症状 :计算dp[i]时,需要的子问题还没计算 解药:搞清楚依赖关系,调整循环顺序
3. 错误:空间复杂度优化错误
症状 :使用滚动数组后结果不对 解药:检查是否需要倒序遍历,避免覆盖还需要使用的状态
4. 错误:初始化错误
症状 :边界情况处理错误 解药:仔细考虑dp数组的初始值,特别是dp[0]和dp[1]
DP最佳实践:四步解题法
第一步:定义状态
问自己:用什么变量可以完整描述一个问题?常见的状态定义:
- dp[i]:以第i个元素结尾的...
- dp[i][j]:前i个元素,满足j条件的...
- dp[i][j][k]:前i个元素,在状态j和k下的...
第二步:确定状态转移方程
这是DP的核心!思考:当前状态如何从已知状态转移而来? 写出递推关系式,确保覆盖所有可能情况
第三步:初始化
设置边界条件,这是递归的终止条件或DP的起点
第四步:确定计算顺序
确保计算当前状态时,所需要的子状态都已经计算完成
面试考点及解析
常见DP面试题
- 爬楼梯问题(简单)
- 最长递增子序列(中等)
- 0-1背包问题(中等)
- 编辑距离(困难)
- 买卖股票系列(中等~困难)
面试官想考察什么?
- 能否识别出DP问题
- 能否正确定义状态
- 能否写出状态转移方程
- 能否进行空间优化
- 边界条件处理能力
解题思路示例:编辑距离
问题:给定两个单词word1和word2,计算将word1转换成word2所使用的最少操作数(插入、删除、替换)
解题步骤:
-
定义状态:dp[i][j]表示word1前i个字符转换成word2前j个字符的最小操作数
-
状态转移方程:
- 如果word1[i] == word2[j]:dp[i][j] = dp[i-1][j-1](不需要操作)
- 否则:dp[i][j] = min( dp[i-1][j] + 1, // 删除word1[i] dp[i][j-1] + 1, // 在word1插入word2[j] dp[i-1][j-1] + 1 // 替换word1[i]为word2[j] )
-
初始化:
- dp[0][j] = j(空串变word2前j个字符,需要j次插入)
- dp[i][0] = i(word1前i个字符变空串,需要i次删除)
-
代码实现:
java
public int minDistance(String word1, String word2) {
int m = word1.length(), n = word2.length();
int[][] dp = new int[m + 1][n + 1];
// 初始化
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] = Math.min(
Math.min(dp[i - 1][j], dp[i][j - 1]),
dp[i - 1][j - 1]
) + 1;
}
}
}
return dp[m][n];
}
总结:DP其实没那么难!
动态规划本质上是一种"聪明"的暴力破解法,通过记忆化避免重复计算。掌握DP的关键在于:
- 多练习:DP问题有套路,但更需要经验
- 学会分析:先想递归关系,再改写成DP
- 注意细节:初始化、边界条件、循环顺序
- 空间优化:学会使用滚动数组降低空间复杂度
最后送大家一句话:DP就像谈恋爱,有时候直接表白(暴力)会失败,但通过细心观察(状态定义)、积累经验(记忆化)、循序渐进(自底向上),最终一定能成功!