动态规划(DP)的套路本质上是通过 定义状态 + 状态转移 ,利用 子问题的解 逐步构建原问题的解。它的核心是 避免重复计算(类似缓存),将指数级复杂度优化到多项式级别。下面通过一段预处理代码和经典例子,拆解每一步的思考过程,总结出通用套路。
一、以预处理回文的代码为例,拆解动态规划的思路
下面的代码段中,dp[i][j]
表示子串 s[i...j]
是否是回文(出自LeetCode 131):
java
boolean[][] dp = new boolean[n][n];
for (int i = n - 1; i >= 0; i--) {
for (int j = i; j < n; j++) {
if (i == j) {
dp[i][j] = true;
} else if (s.charAt(i) == s.charAt(j)) {
dp[i][j] = (j - i == 1) || dp[i + 1][j - 1];
}
}
}
1. 定义状态(关键步骤!)
- 问题 :判断子串
s[i...j]
是否是回文。 - 定义状态 :
dp[i][j] = true
表示s[i...j]
是回文,否则为false
。 - 为什么这么定义 :回文的判断有 重叠子问题 。例如,判断
s[0..4]
是否是回文时,可能需要判断s[1..3]
是否是回文,而s[1..3]
可能在之前已经被计算过。
2. 状态转移方程(递推关系)
- 基础情况 :
- 单字符:
i == j
时,dp[i][j] = true
(单个字符是回文)。 - 双字符:
j - i == 1
且s[i] == s[j]
,则dp[i][j] = true
。
- 单字符:
- 递推关系 :
- 如果
s[i] == s[j]
,且内部的子串s[i+1...j-1]
是回文(即dp[i+1][j-1] == true
),那么s[i...j]
也是回文。 - 即:
dp[i][j] = (s[i] == s[j]) && dp[i+1][j-1]
。
- 如果
3. 计算顺序(避免脏数据)
- i 从后往前遍历 :因为
dp[i][j]
依赖于dp[i+1][j-1]
(即左下方的值),必须保证在计算dp[i][j]
时,dp[i+1][j-1]
已经被计算过。 - j 从 i 到末尾遍历 :因为
j
必须大于等于i
(子串的结束位置不能小于起始位置)。
4. 初始化
- 所有
i > j
的情况无意义(默认false
)。 - 单字符情况
i == j
直接初始化为true
。
二、动态规划的通用套路
动态规划的解题步骤可总结为以下模板:
1. 判断问题是否适用 DP
- 重叠子问题 :问题可以被分解为多个子问题,且子问题之间存在重复计算。
- 例如:斐波那契数列
f(n) = f(n-1) + f(n-2)
,计算f(5)
时需要多次计算f(3)
。
- 例如:斐波那契数列
- 最优子结构 :问题的最优解包含子问题的最优解。
- 例如:最短路径问题中,从 A 到 C 的最短路径必须经过 B,那么 A 到 B 的路径也必须是当前状态下的最短路径。
2. 定义状态
- 状态定义 :用一个或多个变量描述问题的某个阶段。
- 一维状态:例如
dp[i]
表示前i
个元素的最优解。 - 二维状态:例如
dp[i][j]
表示从位置i
到j
的最优解。 - 更高维状态:根据问题复杂度增加维度。
- 一维状态:例如
- 关键技巧 :状态的定义要能 涵盖所有可能的情况 ,同时尽量 减少冗余。
3. 推导状态转移方程
- 从子问题到原问题 :找到
dp[i]
与dp[i-1]
、dp[i-2]
等之间的关系。 - 经典例子 :
- 斐波那契数列:
dp[i] = dp[i-1] + dp[i-2]
。 - 背包问题:
dp[i][w] = max(dp[i-1][w], dp[i-1][w - weight[i]] + value[i])
。 - 最长递增子序列:
dp[i] = max(dp[j] + 1)
,其中j < i
且nums[j] < nums[i]
。
- 斐波那契数列:
4. 初始化与边界条件
- 初始化 :设置初始状态的值(如
dp[0] = 0
)。 - 边界条件 :处理
i < 0
或j > n
等越界情况。
5. 确定计算顺序
- 自底向上 :从基础子问题开始,逐步计算更大规模的子问题。
- 例如:先计算
dp[0]
、dp[1]
,再计算dp[2]
。
- 例如:先计算
- 自顶向下(记忆化搜索) :递归计算,但缓存已计算的子问题。
- 例如:用备忘录(Memoization)记录
dp[i]
的值。
- 例如:用备忘录(Memoization)记录
6. 空间优化(可选)
- 滚动数组:如果
dp[i]
只依赖于前几个状态,可用滚动数组压缩空间。- 例如:斐波那契数列只需保存
dp[i-1]
和dp[i-2]
。
- 例如:斐波那契数列只需保存
三、如何识别动态规划问题?
1. 常见题型
- 求最值(最大值、最小值、最长、最短等)。
- 计数问题(有多少种方式、路径等)。
- 判断是否可行(能否分割、能否到达等)。
2. 题目特征
- 问题可分解为多个阶段,每个阶段的决策影响后续阶段。
- 问题有明显的 重叠子问题 或 最优子结构。
3. 与回溯的对比
- 回溯:暴力穷举所有可能,时间复杂度高(指数级)。
- 动态规划:通过缓存子问题解,避免重复计算,时间复杂度低(多项式级)。
四、经典例题强化套路
1. 爬楼梯(一维 DP)
- 问题 :每次爬 1 或 2 阶,到第
n
阶有多少种方法? - 状态定义 :
dp[i]
表示到第i
阶的方法数。 - 转移方程 :
dp[i] = dp[i-1] + dp[i-2]
。 - 初始化 :
dp[0] = 1
,dp[1] = 1
。
2. 最长公共子序列(二维 DP)
- 问题:两个字符串的最长公共子序列长度。
- 状态定义 :
dp[i][j]
表示s1[0..i-1]
和s2[0..j-1]
的最长公共子序列。 - 转移方程 :
- 如果
s1[i-1] == s2[j-1]
,则dp[i][j] = dp[i-1][j-1] + 1
。 - 否则,
dp[i][j] = max(dp[i-1][j], dp[i][j-1])
。
- 如果
五、总结
动态规划的套路可以总结为:
- 定义状态 :明确
dp[i][j]
或dp[i]
的含义。 - 推导转移方程:找到如何用子问题的解构建当前问题的解。
- 确定计算顺序 :确保在计算
dp[i][j]
时,所需子问题已被计算。 - 初始化和边界处理:设置初始值,处理特殊情况。
- 优化空间(可选):用滚动数组或压缩维度减少空间复杂度。
想要熟练掌握动态规划,需要多练习经典题型(如背包问题、最长子序列、编辑距离等),并尝试将问题抽象为状态和转移方程。一开始可能会觉得难以找到状态定义,但随着经验积累,你会逐渐形成直觉。