应用场景:最优化 · 计数 · 可行性判定
交互式动画演示,可调整参数并单步执行。
全屏打开| i\w | 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 |
|---|---|---|---|---|---|---|---|---|---|
| 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 |
| 1 | 0 | · | · | · | · | · | · | · | · |
| 2 | 0 | · | · | · | · | · | · | · | · |
| 3 | 0 | · | · | · | · | · | · | · | · |
| 4 | 0 | · | · | · | · | · | · | · | · |
graph LR A[原问题] --> B[重叠子问题] B --> C[最优子结构] C --> D[状态+转移] D --> E[记忆化/打表]
动态规划(Dynamic Programming, DP) 是将复杂问题分解为重叠子问题,把每个子问题的解记忆化存储,从而把指数级暴力搜索降到多项式级别的算法思想。
它与分治的区别在于:子问题是否重复。如果子问题互不重叠(如归并排序),那是分治;如果子问题大量重叠,必须用 DP。
任何 DP 问题都可以从以下三个维度建模:
| 维度 | 思考问题 |
|---|---|
| 状态 | 我需要记住哪些信息才能继续推导? |
| 转移 | 当前状态可以由哪些更小的状态推出来? |
| 边界 | 最小的问题(不能再分的)答案是什么? |
| 顺序 | 状态之间有拓扑序吗?按照什么顺序遍历? |
状态沿数组下标线性展开。
例 1:最长上升子序列(LIS)
public int lengthOfLIS(int[] nums) {
int n = nums.length;
int[] dp = new int[n];
Arrays.fill(dp, 1);
int ans = 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);
}
}
ans = Math.max(ans, dp[i]);
}
return ans;
}tails[k] = 长度为 k+1 的递增子序列的最小尾值,用二分达到 O(n log n)例 2:最长公共子序列(LCS)
求两个字符串的最长公共子序列长度。
public int longestCommonSubsequence(String s1, String s2) {
int m = s1.length(), n = s2.length();
int[][] dp = new int[m + 1][n + 1]; // dp[i][j] = s1[0..i) 与 s2[0..j) 的 LCS 长度
for (int i = 1; i <= m; i++) {
for (int j = 1; j <= n; j++) {
if (s1.charAt(i - 1) == s2.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];
}int[] dp = new int[n + 1]状态 =
dp[i][j]= 前 i 个物品在容量 j 下的最优解。
0/1 背包(每件物品最多选一次):
// n 件物品,weights[i] 重量,values[i] 价值,容量 W
public int knapsack01(int[] weights, int[] values, int W) {
int n = weights.length;
int[] dp = new int[W + 1]; // 滚动数组
for (int i = 0; i < n; i++) {
for (int j = W; j >= weights[i]; j--) { // ⚠️ 倒序:避免重复选
dp[j] = Math.max(dp[j], dp[j - weights[i]] + values[i]);
}
}
return dp[W];
}完全背包(每件物品可无限次):
public int knapsackComplete(int[] weights, int[] values, int W) {
int n = weights.length;
int[] dp = new int[W + 1];
for (int i = 0; i < n; i++) {
for (int j = weights[i]; j <= W; j++) { // ✅ 正序:允许重复
dp[j] = Math.max(dp[j], dp[j - weights[i]] + values[i]);
}
}
return dp[W];
}关键区别:
多重背包(每件物品最多 k 次):用二进制拆分 + 0/1 背包,或单调队列优化。
子问题的范围是一个区间,状态用
dp[l][r]表示[l, r]上的最优解。
经典模板(戳气球 / 矩阵链乘 / 最优三角剖分):
for (int len = 2; len <= n; len++) { // 区间长度
for (int l = 0; l + len - 1 < n; l++) {
int r = l + len - 1;
dp[l][r] = Integer.MAX_VALUE;
for (int k = l; k < r; k++) { // 分割点
dp[l][r] = Math.min(dp[l][r], dp[l][k] + dp[k+1][r] + cost(l, r, k));
}
}
}例:最长回文子序列
public int longestPalindromeSubseq(String s) {
int n = s.length();
int[][] dp = new int[n][n];
for (int i = 0; i < n; i++) dp[i][i] = 1;
for (int len = 2; len <= n; len++) {
for (int l = 0; l + len - 1 < n; l++) {
int r = l + len - 1;
if (s.charAt(l) == s.charAt(r)) {
dp[l][r] = dp[l + 1][r - 1] + 2;
} else {
dp[l][r] = Math.max(dp[l + 1][r], dp[l][r - 1]);
}
}
}
return dp[0][n - 1];
}把"在 / 不在 / 是否使用过"等布尔信息编进状态。常见于:
例:买卖股票的最佳时机含冷冻期
// dp[i][0]=持有,dp[i][1]=不持有且在冷冻期,dp[i][2]=不持有且不在冷冻期
dp[i][0] = max(dp[i-1][0], dp[i-1][2] - prices[i]);
dp[i][1] = dp[i-1][0] + prices[i];
dp[i][2] = max(dp[i-1][1], dp[i-1][2]);在树上做后序遍历,状态从子树汇聚上来。
int[] dfs(TreeNode u) {
int[] res = new int[2]; // res[0]=不选u, res[1]=选u
if (u == null) return res;
int[] l = dfs(u.left), r = dfs(u.right);
res[0] = Math.max(l[0], l[1]) + Math.max(r[0], r[1]);
res[1] = u.val + l[0] + r[0];
return res;
}例:打家劫舍 III、树的直径、节点最大权和。
求把 word1 转成 word2 的最少操作数(插入 / 删除 / 替换)。
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; // 删除 i 次
for (int j = 0; j <= n; j++) dp[0][j] = 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], // 删除 word1[i-1]
Math.min(dp[i][j - 1], // 插入 word2[j-1]
dp[i - 1][j - 1]) // 替换
);
}
}
}
return dp[m][n];
}int[] dp = new int[n + 1]统计
[L, R]中满足条件的整数个数,状态是"高位前缀 + 是否贴上界 + 前导零"。
模板:
long dfs(int pos, boolean tight, boolean lead, int... other) {
if (pos == -1) return (lead ? 0 : 1); // 取决于定义
if (!tight && !lead && memo[pos][...] != -1) return memo[pos][...];
long ans = 0;
int up = tight ? digits[pos] : 9;
for (int d = 0; d <= up; d++) {
ans += dfs(pos - 1, tight && d == up, lead && d == 0, ...);
}
if (!tight && !lead) memo[pos][...] = ans;
return ans;
}当 dp[i] 只依赖 dp[i-1] 时,用 1 维数组即可。
例:斐波那契、爬楼梯、打家劫舍、打字。
当状态是布尔集合(哪几位用过),用位掩码 int mask。
例:最短 Hamilton 路径、状态压缩 TSP。
把转移中"区间最值"的部分从 O(n) 降到 O(1)。
例:滑动窗口最大值、最大子序和(限定长度)。
当转移是线性递推(如斐波那契),构造矩阵后用快速幂降到 O(log n)。
例:爬楼梯、爬楼梯 II(一次最多 k 步)。
4 个最常用调试技巧:
dp[i] 由哪个 dp[j] 推出打出来,验证依赖关系。O(2^n) 暴力,跟 O(n²) DP 在 n≤10 上随机对拍。| 难度 | 题目 | 模型 |
|---|---|---|
| 🟢 | 爬楼梯、打家劫舍、斐波那契 | 入门递推 |
| 🟢 | 最大子序和、乘积最大子数组 | 线性 DP |
| 🟡 | 最长上升子序列、最长公共子序列 | 线性 DP |
| 🟡 | 0/1 背包、完全背包、零钱兑换 | 背包 |
| 🟡 | 最长回文子序列、戳气球 | 区间 DP |
| 🟡 | 编辑距离、不同子序列 | 字符串 DP |
| 🟠 | 买卖股票系列(含冷冻期) | 状态机 |
| 🟠 | 打家劫舍 III、树的直径 | 树形 DP |
| 🔴 | 最短 Hamilton 路径 | 状态压缩 |
| 🔴 | 不含连续 1 的非负整数 | 数位 DP |
当你卡题时,回到第 1 步:"暴力是什么?暴力在重复算什么?" 答案是 DP 的状态。