什么是动态规划

动态规划(Dynamic Programming,简称 DP) 是一种通过将原问题分解为若干重叠子问题,并存储子问题的解以避免重复计算,从而高效求解最优化问题的算法思想。

动态规划的核心在于 「记住求过的解」—— 用空间换时间。

flowchart LR
A[原问题] --> B[子问题 1]
A --> C[子问题 2]
A --> D[子问题 n]
B --> E((存储))
C --> E
D --> E
E --> F[组合子问题解]
F --> G[得到原问题最优解]
style E fill:#f96,stroke:#333,stroke-width:2px

适用条件

一个问题能用动态规划求解,需要满足两个关键性质:

性质 说明 反例
最优子结构 原问题的最优解包含子问题的最优解 无权最长简单路径(NP-hard)
重叠子问题 子问题被反复求解,而非每次独立 二分查找(子问题不重叠)

💡 如果子问题不重叠,用分治法即可(如归并排序);只有子问题重叠时,DP 才比分治更有优势。


动态规划的解题步骤

解决 DP 问题通常遵循一个固定的思维框架:

flowchart TD
A[1. 定义状态] --> B[2. 确定状态转移方程]
B --> C[3. 初始化边界条件]
C --> D[4. 确定遍历顺序]
D --> E[5. 返回最终结果]

A -.-> A1["dp[i] 或 dp[i][j] 表示什么"]
B -.-> B1["dp[i] = f(dp[i-1], dp[i-2], ...)"]
C -.-> C1["dp[0], dp[1] 等初始值"]
D -.-> D1["从左到右 / 从上到下"]
E -.-> E1["返回 dp[n] 或 dp[m][n]"]

style A1 fill:#e8f4fd,stroke:#4a90d9
style B1 fill:#e8f4fd,stroke:#4a90d9
style C1 fill:#e8f4fd,stroke:#4a90d9
style D1 fill:#e8f4fd,stroke:#4a90d9
style E1 fill:#e8f4fd,stroke:#4a90d9

下面通过三个递进的例题来掌握这些步骤。


例题一:斐波那契数列(入门)

问题

计算斐波那契数列的第 n 项:

  • F(0) = 0, F(1) = 1
  • F(n) = F(n-1) + F(n-2)(n ≥ 2)

递归 vs DP(为什么需要 DP?)

flowchart TD
subgraph 递归树
F5[F5] --> F4[F4]
F5 --> F3a[F3]
F4 --> F3b[F3]
F4 --> F2a[F2]
F3a --> F2b[F2]
F3a --> F1a[F1]
F3b --> F2c[F2]
F3b --> F1b[F1]
F2a --> F1c[F1]
F2a --> F0a[F0]
F2b --> F1d[F1]
F2b --> F0b[F0]
F2c --> F1e[F1]
F2c --> F0c[F0]
end

style F3a fill:#fdd,stroke:#e74c3c
style F3b fill:#fdd,stroke:#e74c3c
style F2a fill:#fdd,stroke:#e74c3c
style F2b fill:#fdd,stroke:#e74c3c
style F2c fill:#fdd,stroke:#e74c3c

纯递归计算 F(5) 时,F(3) 被计算 2 次、F(2) 被计算 3 次——大量重复!

DP 解法: 用一个数组把算过的值存起来,遇到直接取,不再重复计算。

状态定义与转移

  • 状态定义: dp[i] 表示斐波那契数列第 i 项的值
  • 状态转移方程: dp[i] = dp[i-1] + dp[i-2]
  • 边界条件: dp[0] = 0, dp[1] = 1
  • 遍历顺序: 从左到右

代码实现

public class Fibonacci {
// 1. 递归(指数级 O(2^n),不推荐)
public static int fibRecursive(int n) {
if (n <= 1) return n;
return fibRecursive(n - 1) + fibRecursive(n - 2);
}

// 2. DP 自底向上(O(n) 时间,O(n) 空间)
public static int fibDP(int n) {
if (n <= 1) return n;
int[] dp = new int[n + 1];
dp[0] = 0;
dp[1] = 1;
for (int i = 2; i <= n; i++) {
dp[i] = dp[i - 1] + dp[i - 2];
}
return dp[n];
}

// 3. DP 空间优化(O(n) 时间,O(1) 空间)
public static int fibOptimized(int n) {
if (n <= 1) return n;
int prev2 = 0, prev1 = 1;
for (int i = 2; i <= n; i++) {
int cur = prev1 + prev2;
prev2 = prev1;
prev1 = cur;
}
return prev1;
}
}

复杂度分析

方法 时间复杂度 空间复杂度
纯递归 O(2ⁿ) O(n)(调用栈)
DP 数组 O(n) O(n)
DP 空间优化 O(n) O(1)

例题二:0-1 背包问题(经典)

问题

给定 n 件物品和一个容量为 C 的背包。每件物品 i 有重量 w[i] 和价值 v[i]。
每件物品只能选一次(0 或 1 次), 求不超过背包容量的前提下能装的最大总价值。

物品 重量 价值
A 2 3
B 3 4
C 4 5
D 5 6

背包容量 C = 8

决策过程

flowchart TD
subgraph 对每件物品做决策
direction LR
S[物品 i] --> Choice{选 or 不选?}
Choice -->|不选| Skip["dp[i][j] = dp[i-1][j]<br/>继承上一行的结果"]
Choice -->|选| Take["dp[i][j] = dp[i-1][j-w[i]] + v[i]<br/>腾出重量再放"]
end

状态定义与转移

  • 状态定义: dp[i][j] 表示前 i 件物品中选,容量为 j 的背包能装的最大价值
  • 状态转移方程:
dp[i][j] = max(
dp[i-1][j], ← 不选第 i
dp[i-1][j-w[i]] + v[i] ← 选第 i 件(前提:j ≥ w[i]
)
  • 边界条件: dp[0][j] = 0dp[i][0] = 0
  • 遍历顺序: 先遍历物品,再遍历容量

DP 表格演算

容量 j →   0   1   2   3   4   5   6   7   8
物品 ↓ ┌─────────────────────────────────
0 0 0 0 0 0 0 0 0 0
A(w=2) │ 0 0 3 3 3 3 3 3 3
B(w=3) │ 0 0 3 4 4 7 7 7 7
C(w=4) │ 0 0 3 4 5 7 8 9 9
D(w=5) │ 0 0 3 4 5 7 8 9 9 ← 最大价值 9

红色箭头表示做出「选」决策的位置:

flowchart LR
subgraph 最优解回溯
D4["D(w=5,v=6) 不选<br/>j=8 不变"] --> C4["C(w=4,v=5) 选 ✓<br/>容量 -> 8-4=4"]
C4 --> B4["B(w=3,v=4) 不选<br/>j=4 不变"]
B4 --> A4["A(w=2,v=3) 选 ✓<br/>容量 -> 4-2=2"]
A4 --> END["剩余容量 2,结束"]
end

style C4 fill:#cfc,stroke:#27ae60
style A4 fill:#cfc,stroke:#27ae60

最优解: 选 A + C,总重量 2 + 4 = 6 ≤ 8,总价值 3 + 5 = 9

代码实现

public class Knapsack01 {
public static int knapsack(int C, int[] w, int[] v) {
int n = w.length;
int[][] dp = new int[n + 1][C + 1];

for (int i = 1; i <= n; i++) {
for (int j = 1; j <= C; j++) {
// 不选第 i 件
dp[i][j] = dp[i - 1][j];
// 如果能装下,尝试选
if (j >= w[i - 1]) {
dp[i][j] = Math.max(dp[i][j],
dp[i - 1][j - w[i - 1]] + v[i - 1]);
}
}
}
return dp[n][C];
}

// 空间优化(一维数组,容量逆序遍历)
public static int knapsackOptimized(int C, int[] w, int[] v) {
int n = w.length;
int[] dp = new int[C + 1];

for (int i = 0; i < n; i++) {
// 逆序保证每个物品只选一次
for (int j = C; j >= w[i]; j--) {
dp[j] = Math.max(dp[j], dp[j - w[i]] + v[i]);
}
}
return dp[C];
}
}

⚠️ 为什么一维优化要逆序遍历容量?
因为 dp[j-w[i]] 读取的是上一轮(未选当前物品)的值,如果正序就会读到本轮已经更新过的「选了一次」的值,变成完全背包问题了。

复杂度分析

维度 二维 DP 一维优化
时间复杂度 O(n×C) O(n×C)
空间复杂度 O(n×C) O(C)

例题三:最长公共子序列(进阶)

问题

给定两个字符串 text1 和 text2,返回它们的最长公共子序列(LCS)的长度。

子序列: 不要求连续,但相对顺序不变。

  • text1 = “abcde”, text2 = “ace” → LCS = “ace”(长度 3)
  • text1 = “abc”, text2 = “def” → LCS = “”(长度 0)

决策分析

flowchart TD
subgraph 比较 s1[i] 和 s2[j]
direction LR
CMP[字符相等?] -->|Yes| MATCH["dp[i][j] = dp[i-1][j-1] + 1<br/>匹配上,各退一步 +1"]
CMP -->|No| SKIPA["方案A: 跳过 s1[i]<br/>dp[i-1][j]"]
CMP -->|No| SKIPB["方案B: 跳过 s2[j]<br/>dp[i][j-1]"]
SKIPA --> MAX["取较大值"]
SKIPB --> MAX
end

状态定义与转移

  • 状态定义: dp[i][j] 表示 text1 前 i 个字符与 text2 前 j 个字符的 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]) ← 不匹配,取较大子问题
  • 边界条件: dp[0][j] = 0dp[i][0] = 0
  • 遍历顺序: 从左到右、从上到下

DP 表格演算

text1 = "abcde", text2 = "ace"

"" a c e
"" 0 0 0 0
a 0 1 1 1 ← a 匹配
b 0 1 1 1
c 0 1 2 2 ← c 匹配
d 0 1 2 2
e 0 1 2 3 ← e 匹配 → 答案 3

代码实现

public class LCS {
public static 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 字符串
public static String getLCS(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]);
}
}
}

// 回溯还原
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();
}
}

复杂度分析

维度
时间复杂度 O(m × n)
空间复杂度 O(m × n)(可优化至 O(min(m, n)))

动态规划解题套路总结

flowchart TB
Start[拿到问题] --> Q1{是否求最值?}
Q1 -->|是| Q2{能分解为子问题?}
Q2 -->|是| Q3{子问题重叠?}
Q3 -->|是| DP[用 DP 求解]
Q3 -->|否| DIV[用分治法]
Q2 -->|否| GREEDY[试试贪心]

DP --> STEP1[定义 dp 数组含义]
STEP1 --> STEP2[写转移方程]
STEP2 --> STEP3[确定边界]
STEP3 --> STEP4[确定遍历顺序]
STEP4 --> DONE[输出结果]

style DP fill:#4a90d9,color:#fff
style DONE fill:#27ae60,color:#fff

常见 DP 题型速查

类型 状态定义特征 典型题
线性 DP dp[i] 与前 i 项有关 斐波那契、爬楼梯、最大子数组和
区间 DP dp[i][j] 表示区间 [i, j] 最长回文子串、石子合并
背包 DP dp[i][j] 前 i 件物品容量 j 0-1 背包、完全背包、多重背包
树形 DP dp[node] 以 node 为根的子树 打家劫舍 III、树的最大独立集
数位 DP dp[pos][state] 在数位上做 DP 数字 1 的个数、不包含 49 的数字

刷题推荐顺序

  1. 爬楼梯dp[i] = dp[i-1] + dp[i-2](入门)
  2. 打家劫舍dp[i] = max(dp[i-1], dp[i-2] + nums[i])
  3. 最大子数组和dp[i] = max(nums[i], dp[i-1] + nums[i])
  4. 0-1 背包 → 二维 DP 表格理解透彻
  5. 最长递增子序列 → O(n²) 入门 → O(n log n) 进阶
  6. 最长公共子序列 → 两个维度的 DP 训练
  7. 编辑距离 → LCS 的进阶版
  8. 完全平方数 → 背包思想的变体

总结

动态规划的难点不在于代码实现,而在于 识别问题类型定义合适的状态。掌握了状态定义和转移方程,代码往往只有几行。

关键记忆:

  • ✅ 最优子结构 + 重叠子问题 = DP
  • ✅ 状态定义是 DP 的灵魂,转移方程是骨架
  • ✅ 先二维理解,再一维优化(不要一上来就优化)
  • ✅ 多画 DP 表格,演算两三组数据就能发现规律
  • 多刷题,形成「状态直觉」 —— 看到题目就知道该用一维还是二维 DP
flowchart LR
subgraph 学习路径
FIB[斐波那契<br/>入门] --> HOUSE[打家劫舍<br/>简单一维]
HOUSE --> KNAPSACK[0-1 背包<br/>二维经典]
KNAPSACK --> LCS["LCS/LIS<br/>序列DP"]
LCS --> EDIT[编辑距离<br/>二维进阶]
EDIT --> TREE[树形DP<br/>高阶]
end

style FIB fill:#e8f4fd,stroke:#4a90d9
style HOUSE fill:#e8f4fd,stroke:#4a90d9
style KNAPSACK fill:#d4edda,stroke:#27ae60
style LCS fill:#d4edda,stroke:#27ae60
style EDIT fill:#fce4ec,stroke:#e74c3c
style TREE fill:#fce4ec,stroke:#e74c3c