"动态规划就像走迷宫时做标记,走过的路不用再走第二遍!" 🧭
🤔 什么是动态规划?
生活中的例子:爬楼梯
问题:爬10层楼梯,每次可以爬1层或2层,有多少种爬法?
暴力递归(会超时):
爬10层 = 爬9层的方法数 + 爬8层的方法数
↓ ↓
爬8层+爬7层 爬7层+爬6层
重复计算了很多次!😱
比如"爬7层"被计算了2次
"爬6层"被计算了3次
...
动态规划(用数组记录):
用dp数组记录结果:
dp[1] = 1 (爬1层:1种方法)
dp[2] = 2 (爬2层:2种方法)
dp[3] = dp[1] + dp[2] = 3
dp[4] = dp[2] + dp[3] = 5
...
dp[10] = dp[8] + dp[9] = 89 ✅
时间:O(n),超快!⚡
🎯 动态规划的核心思想
三要素
- 重叠子问题 - 有重复计算
- 最优子结构 - 大问题的最优解包含小问题的最优解
- 状态转移方程 - 找到递推关系
解题步骤
第1步:定义状态
dp[i]表示什么?
第2步:找状态转移方程
dp[i]和dp[i-1]、dp[i-2]...的关系?
第3步:初始化
dp[0]、dp[1]是什么?
第4步:确定遍历顺序
从前往后?从后往前?
第5步:返回结果
dp[n]或max(dp)?
📚 经典DP问题
1. 斐波那契数列 ⭐
问题:f(n) = f(n-1) + f(n-2),f(0)=0, f(1)=1
// 递归(会超时)
int fib(int n) {
if (n <= 1) return n;
return fib(n-1) + fib(n-2);
}
// 时间:O(2^n) 😱
// DP(自底向上)
int fib(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];
}
// 时间:O(n) ⚡
// 空间优化(只需要两个变量)
int fib(int n) {
if (n <= 1) return n;
int prev = 0, curr = 1;
for (int i = 2; i <= n; i++) {
int next = prev + curr;
prev = curr;
curr = next;
}
return curr;
}
// 空间:O(1) 👍
2. 爬楼梯(LeetCode 70)⭐⭐
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];
}
// 状态转移方程:dp[i] = dp[i-1] + dp[i-2]
// 含义:到第i层 = 从第i-1层跨1步 + 从第i-2层跨2步
3. 打家劫舍(LeetCode 198)⭐⭐⭐
问题:小偷不能偷相邻的房子,求最大金额
房子:[2, 7, 9, 3, 1]
不能偷相邻的,怎么偷最多?
分析:
偷第0个房子:2
偷第1个房子:7(不偷第0个)
偷第2个房子:2+9=11(偷第0个+第2个)
偷第3个房子:max(7+3, 11)=11(第1个+第3个 或 只偷到第2个)
偷第4个房子:max(11+1, 11)=12(第2个+第4个 或 只偷到第3个)
最多偷12!✅
public int rob(int[] nums) {
int n = nums.length;
if (n == 0) return 0;
if (n == 1) return nums[0];
int[] dp = new int[n];
dp[0] = nums[0];
dp[1] = Math.max(nums[0], nums[1]);
for (int i = 2; i < n; i++) {
// 偷第i个 或 不偷第i个
dp[i] = Math.max(dp[i-2] + nums[i], dp[i-1]);
}
return dp[n-1];
}
// 状态转移方程:dp[i] = max(dp[i-2] + nums[i], dp[i-1])
// 偷第i个房子 vs 不偷第i个房子
4. 零钱兑换(LeetCode 322)⭐⭐⭐
问题:凑出金额需要最少多少个硬币?
硬币:[1, 2, 5]
金额:11
分析:
amount=1: 1个硬币(1)
amount=2: 1个硬币(2)
amount=3: 2个硬币(1+2)
amount=4: 2个硬币(2+2)
amount=5: 1个硬币(5)
amount=6: 2个硬币(5+1)
...
amount=11: 3个硬币(5+5+1) ✅
public int coinChange(int[] coins, int amount) {
int[] dp = new int[amount + 1];
Arrays.fill(dp, amount + 1); // 初始化为一个大值
dp[0] = 0; // 金额0需要0个硬币
for (int i = 1; i <= amount; i++) {
for (int coin : coins) {
if (i >= coin) {
dp[i] = Math.min(dp[i], dp[i - coin] + 1);
}
}
}
return dp[amount] > amount ? -1 : dp[amount];
}
// 状态转移方程:dp[i] = min(dp[i], dp[i-coin] + 1)
// 含义:凑出金额i = 凑出金额(i-coin) + 1个硬币coin
5. 最长递增子序列 LIS(LeetCode 300)⭐⭐⭐⭐
问题:找出数组中最长的递增子序列
数组:[10, 9, 2, 5, 3, 7, 101, 18]
最长递增子序列:[2, 3, 7, 101] 或 [2, 3, 7, 18]
长度:4 ✅
public int lengthOfLIS(int[] nums) {
int n = nums.length;
int[] dp = new int[n];
Arrays.fill(dp, 1); // 每个元素本身长度为1
int maxLen = 1;
for (int i = 1; i < n; i++) {
for (int j = 0; j < i; j++) {
if (nums[i] > nums[j]) {
dp[i] = Math.max(dp[i], dp[j] + 1);
}
}
maxLen = Math.max(maxLen, dp[i]);
}
return maxLen;
}
// dp[i]:以nums[i]结尾的最长递增子序列长度
// 状态转移:dp[i] = max(dp[j] + 1) where j < i and nums[j] < nums[i]
// 时间:O(n²)
6. 0-1背包问题 ⭐⭐⭐⭐⭐
问题:背包容量W,有n个物品,每个物品有重量w[i]和价值v[i],求最大价值
背包容量:4
物品:
重量:[1, 3, 4]
价值:[15, 20, 30]
分析:
背包容量4:
选物品0(重1价15) + 物品1(重3价20) = 重4价35 ✅
或选物品2(重4价30) = 重4价30
最大价值:35
public int knapsack(int W, int[] weights, int[] values) {
int n = weights.length;
int[][] dp = new int[n + 1][W + 1];
// dp[i][w]:前i个物品,容量为w时的最大价值
for (int i = 1; i <= n; i++) {
for (int w = 1; w <= W; w++) {
if (weights[i-1] <= w) {
// 可以选第i个物品
dp[i][w] = Math.max(
dp[i-1][w], // 不选第i个
dp[i-1][w-weights[i-1]] + values[i-1] // 选第i个
);
} else {
// 不能选第i个物品
dp[i][w] = dp[i-1][w];
}
}
}
return dp[n][W];
}
// 状态转移方程:
// dp[i][w] = max(dp[i-1][w], dp[i-1][w-weights[i]] + values[i])
空间优化(一维数组):
public int knapsack(int W, int[] weights, int[] values) {
int n = weights.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], dp[w - weights[i]] + values[i]);
}
}
return dp[W];
}
// 空间:O(W) 👍
7. 最长公共子序列 LCS(LeetCode 1143)⭐⭐⭐⭐
问题:找出两个字符串的最长公共子序列
s1 = "abcde"
s2 = "ace"
LCS = "ace"
长度:3 ✅
public int longestCommonSubsequence(String text1, String text2) {
int m = text1.length();
int 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];
}
// 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])
8. 编辑距离(LeetCode 72)⭐⭐⭐⭐⭐
问题:将word1转换成word2的最少操作次数(插入、删除、替换)
word1 = "horse"
word2 = "ros"
操作:
1. horse → rorse (替换h为r)
2. rorse → rose (删除r)
3. rose → ros (删除e)
最少3步 ✅
public int minDistance(String word1, String word2) {
int m = word1.length();
int n = word2.length();
int[][] dp = new int[m + 1][n + 1];
// 初始化
for (int i = 0; i <= m; i++) dp[i][0] = i; // word1删除所有字符
for (int j = 0; j <= n; j++) dp[0][j] = j; // word1插入所有字符
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] + 1, // 删除
dp[i][j-1] + 1 // 插入
),
dp[i-1][j-1] + 1 // 替换
);
}
}
}
return dp[m][n];
}
🎯 DP的套路总结
线性DP
一维数组:
dp[i] 表示以第i个元素为结尾的...
例子:最长递增子序列、打家劫舍
二维数组:
dp[i][j] 表示...
例子:背包问题、编辑距离、LCS
区间DP
dp[i][j] 表示区间[i, j]的...
例子:最长回文子串
状态压缩DP
用二进制表示状态
例子:旅行商问题
💡 DP优化技巧
1. 空间优化
// 二维数组 → 一维数组
// 背包问题、LCS等
// 原始:dp[i][j]
// 优化:dp[j] (滚动数组)
2. 记忆化搜索
// 递归 + 备忘录
int[] memo = new int[n];
Arrays.fill(memo, -1);
int dfs(int i) {
if (memo[i] != -1) return memo[i];
// 计算结果
int result = ...;
memo[i] = result;
return result;
}
📝 总结
🎓 记忆口诀
动态规划有三要,
重叠子问题找到。
最优子结构要有,
状态转移方程巧。
先定义状态含义,
再找递推关系。
初始化要清楚,
遍历顺序别忘记。
背包LCS经典题,
编辑距离也要会!
核心模板
// DP模板
public int dp(参数) {
// 1. 定义dp数组
int[] dp = new int[n];
// 2. 初始化
dp[0] = ...;
// 3. 状态转移
for (int i = 1; i < n; i++) {
dp[i] = f(dp[i-1], dp[i-2], ...);
}
// 4. 返回结果
return dp[n-1];
}
恭喜你!🎉 你已经攻克了动态规划这个难点!
记住:DP就是用空间换时间,把计算过的结果存起来! 💎
📌 重点记忆:背包问题、LCS、编辑距离
🤔 思考题:什么问题适合用DP?
(答案:有重叠子问题和最优子结构的问题!)