动态规划(Dynamic Programming)详解:从入门到 LeetCode Hot 100
动态规划(Dynamic Programming,简称 DP)是算法中非常重要的一种思想,主要用于解决具有重复子问题和最优子结构的问题。
与普通的递归、暴力枚举相比,动态规划通过保存已经计算过的子问题结果,避免重复计算,从而降低时间复杂度。
动态规划的难点通常不在代码实现,而在于如何分析出:
dp数组究竟代表什么?- 状态转移方程应该如何推导?
- 初始状态应该如何设置?
- 应该按照什么顺序遍历?
- 最终应该返回哪个状态?
本文结合 LeetCode Hot 100 中的经典题目,逐步讲解动态规划的核心思想、常见模型和解题方法。
一、动态规划的基本思想
1. 什么是动态规划?
动态规划的核心思想是:
将一个复杂问题拆分成多个规模更小的子问题,通过保存子问题的计算结果,避免重复计算,最终得到原问题的答案。
先看一个经典问题。
示例:斐波那契数列
斐波那契数列的定义:
\ F(n)= \\begin{cases} 0, \& n=0\\\\ 1, \& n=1\\\\ F(n-1)+F(n-2), \& n\\geq 2 \\end{cases} \\
例如:
text
F(0) = 0
F(1) = 1
F(2) = 1
F(3) = 2
F(4) = 3
F(5) = 5
最直接的做法是使用递归:
cpp
int fib(int n) {
if (n <= 1) {
return n;
}
return fib(n - 1) + fib(n - 2);
}
但是这种方法存在一个问题:重复计算了大量相同的子问题。
例如计算 fib(5):
text
fib(5)
/ \
fib(4) fib(3)
/ \ / \
fib(3) fib(2) fib(2) fib(1)
可以看到,fib(3)、fib(2) 被重复计算。
随着 n 增大,重复计算会越来越严重,时间复杂度为 O(2^n) 量级。
使用动态规划优化
我们可以使用一个数组保存已经计算出的结果。
cpp
int fib(int n) {
if (n <= 1) {
return n;
}
vector<int> dp(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];
}
这里:
cpp
dp[i] = dp[i - 1] + dp[i - 2];
表示第 i 个斐波那契数由前两个状态推导得到。
每个状态只计算一次,因此:
- 时间复杂度:O(n)。
- 空间复杂度:O(n)。
还可以进一步优化空间:
cpp
int fib(int n) {
if (n <= 1) {
return n;
}
int a = 0;
int b = 1;
for (int i = 2; i <= n; i++) {
int c = a + b;
a = b;
b = c;
}
return b;
}
因为计算当前状态只需要前两个状态,所以不需要保存整个数组。
- 时间复杂度:O(n)。
- 空间复杂度:O(1)。
这就是动态规划的基本思想:保存已经计算过的结果,利用之前的状态推导当前状态。
2. 动态规划的两个重要特征
并不是所有递归问题都适合使用动态规划,通常需要关注两个特征。
(1)最优子结构
原问题的最优解可以由子问题的最优解推导得到。
例如,打家劫舍问题中,考虑前 i 间房屋能够获得的最大金额,可以根据前面较小规模问题的最优结果推导。
(2)重叠子问题
在递归求解过程中,相同的子问题会被反复计算。
例如斐波那契数列:
text
fib(5)
├── fib(4)
│ └── fib(3)
└── fib(3)
fib(3) 被计算了多次。
动态规划通过保存子问题的结果,避免重复计算。
二、动态规划的解题步骤
这是整篇文章最重要的部分。拿到一道动态规划题目,不要急着写代码,建议按照下面六个步骤进行分析。
第一步:确定 dp 数组的含义
首先明确:dpi 到底表示什么? 这是动态规划最关键的一步。
例如爬楼梯问题:
cpp
dp[i]
表示到达第 i 阶楼梯,一共有多少种不同的方法。
再例如打家劫舍:
cpp
dp[i]
表示考虑前 i 间房屋时,能够偷窃到的最高金额。
不同题目中,dp 数组的含义可能完全不同。
第二步:确定状态转移方程
状态转移方程表示当前状态如何由之前的状态推导得到。
例如爬楼梯:
\ dp\[i=dpi-1+dpi-2 \]
因为到达第 i 阶楼梯,最后一步只有两种情况:
- 从第 i-1 阶爬一步。
- 从第 i-2 阶爬两步。
所以将两种情况相加即可。
第三步:确定初始化条件
动态规划需要从已知状态开始推导。例如:
cpp
dp[0] = 1;
dp[1] = 1;
这些初始值必须符合 dp 数组的定义。初始化错误,后面的状态转移即使正确,也可能得到错误答案。
第四步:确定遍历顺序
动态规划通常需要按照一定顺序遍历。例如:
cpp
for (int i = 2; i <= n; i++)
因为 dp[i] 依赖 dp[i-1] 和 dp[i-2],所以必须先计算较小的状态。
对于二维动态规划,还需要分析两个维度的遍历顺序。
第五步:确定最终返回值
需要明确最终答案存放在哪里。例如:
cpp
return dp[n];
但并不是所有题目都返回 dp[n]。有些题目返回:
cpp
return dp[m][n];
有些题目需要遍历整个 dp 数组,找到最大值。
第六步:考虑空间优化
如果当前状态只依赖前面少量状态,可以考虑压缩 dp 数组。
例如斐波那契数列只依赖前两个状态,可以使用两个变量代替整个数组。
但是空间优化应该放在状态转移方程确定之后,不要一开始就急着优化。
动态规划解题模板
text
1. 确定 dp 数组的含义
2. 推导状态转移方程
3. 确定初始化条件
4. 确定遍历顺序
5. 确定最终返回值
6. 考虑空间优化
后面的题目都按照这个思路进行分析。
三、一维动态规划
一维动态规划是最基础的动态规划模型,通常使用一维数组 dp[i] 表示某个位置或某个规模下的状态。
1. LeetCode 70:爬楼梯
题目描述
假设你正在爬楼梯,需要 n 阶才能到达楼顶。每次可以爬 1 阶或者 2 阶。问有多少种不同的方法可以爬到楼顶?
例如:
text
n = 3
有三种方法:
1 + 1 + 1
1 + 2
2 + 1
输出:3
解题思路
这道题是动态规划最经典的入门题目。
① 确定 dp 数组的含义
定义:
cpp
dp[i]
表示到达第 i 阶楼梯的不同方法数量。
② 确定状态转移方程
到达第 i 阶楼梯,只有两种可能:
- 从第 i-1 阶爬一步。
- 从第 i-2 阶爬两步。
因此:
\ dp\[i=dpi-1+dpi-2 \]
③ 确定初始化条件
cpp
dp[0] = 1;
dp[1] = 1;
这里 dp[0] = 1 表示从起点出发、不进行任何操作,有一种方式到达起点。这个定义是为了让状态转移方程统一成立。
④ 确定遍历顺序
从小到大遍历:
cpp
for (int i = 2; i <= n; i++)
因为当前状态依赖前两个状态。
⑤ 确定返回值
cpp
return dp[n];
代码实现
cpp
class Solution {
public:
int climbStairs(int n) {
vector<int> dp(n + 1);
dp[0] = 1;
dp[1] = 1;
for (int i = 2; i <= n; i++) {
dp[i] = dp[i - 1] + dp[i - 2];
}
return dp[n];
}
};
空间优化
当前状态只依赖前两个状态,因此可以使用两个变量代替数组。
cpp
class Solution {
public:
int climbStairs(int n) {
int a = 1;
int b = 1;
for (int i = 2; i <= n; i++) {
int c = a + b;
a = b;
b = c;
}
return b;
}
};
复杂度分析:
- 时间复杂度:O(n)。
- 空间复杂度:O(1)。
**总结:**爬楼梯问题本质上是斐波那契数列模型,重点是理解当前状态如何由前面的两个状态推导得到。
2. LeetCode 198:打家劫舍
题目描述
一个专业的小偷计划偷窃沿街的房屋。每间房屋都有一定数量的现金,但是相邻房屋安装了防盗系统。如果两间相邻的房屋在同一晚上被偷窃,系统会自动报警。
给定一个数组 nums,表示每间房屋中的金额。求在不触发警报的情况下,能够偷窃到的最高金额。
例如:
text
nums = [1,2,3,1]
输出:4
偷窃第 1 间和第 3 间房屋:
1 + 3 = 4
解题思路
这道题的核心是:当前房屋偷还是不偷?
① 确定 dp 数组的含义
定义:
cpp
dp[i]
表示考虑前 i 间房屋时,能够偷窃到的最高金额。
注意:这里的 i 表示房屋数量,因此数组下标从 0 开始时,需要注意下标转换。为了方便理解,我们定义:
text
dp[0]:考虑前 0 间房屋的最大金额
dp[1]:考虑前 1 间房屋的最大金额
dp[2]:考虑前 2 间房屋的最大金额
② 确定状态转移方程
对于第 i 间房屋,有两种选择。
情况一:不偷当前房屋。那么最大金额就是前 i-1 间房屋的最大金额:
\ dp\[i=dpi-1 \]
情况二:偷当前房屋。由于不能偷相邻房屋,所以只能加上前 i-2 间房屋的最大金额:
\ dp\[i=dpi-2+numsi-1 \]
两种情况取最大值:
\ dp\[i=\max(dpi-1,dpi-2+numsi-1) \]
③ 初始化条件
cpp
dp[0] = 0;
dp[1] = nums[0];
④ 遍历顺序
从前往后遍历:
cpp
for (int i = 2; i <= n; i++)
⑤ 返回值
cpp
return dp[n];
代码实现
cpp
class Solution {
public:
int rob(vector<int>& nums) {
int n = nums.size();
vector<int> dp(n + 1, 0);
dp[0] = 0;
dp[1] = nums[0];
for (int i = 2; i <= n; i++) {
dp[i] = max(
dp[i - 1],
dp[i - 2] + nums[i - 1]
);
}
return dp[n];
}
};
注意:LeetCode 本题保证 nums 至少有一个元素,因此上述初始化成立。如果写成通用函数,需要额外处理空数组。
空间优化
当前状态只依赖前两个状态,可以使用两个变量:
cpp
class Solution {
public:
int rob(vector<int>& nums) {
int prev2 = 0;
int prev1 = 0;
for (int num : nums) {
int cur = max(prev1, prev2 + num);
prev2 = prev1;
prev1 = cur;
}
return prev1;
}
};
复杂度分析:
- 时间复杂度:O(n)。
- 空间复杂度:O(1)。
**总结:**打家劫舍的关键是将每间房屋的选择分成两种情况:
- 偷当前房屋。
- 不偷当前房屋。
然后根据两种情况的结果取最大值。这种根据不同选择推导状态的方式,在动态规划中非常常见。
四、背包问题
背包问题是动态规划中非常重要的一类模型。它的核心思想是:给定一定容量的背包,以及若干物品,每个物品具有一定的重量和价值,需要根据限制条件选择物品,使最终收益最大或者满足某种目标。
常见类型:
- 0-1 背包:每件物品只能选择一次。
- 完全背包:每件物品可以选择无限次。
其中最需要掌握的是两者的遍历顺序区别。
1. 0-1 背包和完全背包的区别
假设:
text
背包容量:10
物品:
重量:2,价值:3
重量:3,价值:4
重量:5,价值:6
0-1 背包中,每件物品最多只能选择一次。完全背包中,每件物品可以重复选择。
在一维 dp 数组中,两者最重要的区别是容量遍历方向:
| 类型 | 容量遍历方向 | 原因 |
|---|---|---|
| 0-1 背包 | 从大到小 | 防止当前物品被重复使用 |
| 完全背包 | 从小到大 | 允许当前物品重复使用 |
例如:
cpp
// 0-1 背包
for (int j = W; j >= weight[i]; j--) {
dp[j] = max(dp[j], dp[j - weight[i]] + value[i]);
}
cpp
// 完全背包
for (int j = weight[i]; j <= W; j++) {
dp[j] = max(dp[j], dp[j - weight[i]] + value[i]);
}
为什么方向不同?因为一维 dp 数组会覆盖之前的状态。
- 从大到小:计算当前容量时,
dp[j-weight[i]]仍然是上一轮物品处理后的状态,因此当前物品不会被重复使用。 - 从小到大:计算当前容量时,
dp[j-weight[i]]可能已经包含了当前物品,因此允许重复使用。
这是背包问题非常重要的知识点。
2. LeetCode 322:零钱兑换
题目描述
给定不同面额的硬币 coins,以及一个总金额 amount。每种硬币可以使用无限次。求凑成总金额所需的最少硬币数量。如果无法凑成该金额,返回 -1。
例如:
text
coins = [1,2,5]
amount = 11
输出:3
因为:
5 + 5 + 1 = 11
解题思路
这道题属于完全背包问题,因为每种硬币可以重复使用。
① 确定 dp 数组的含义
定义:
cpp
dp[i]
表示凑成金额 i 所需要的最少硬币数量。
② 确定状态转移方程
假设当前选择一枚面额为 coin 的硬币,那么之前需要凑成的金额为:
text
i - coin
如果之前凑成该金额需要 dp[i-coin] 枚硬币,那么当前总共需要 dp[i-coin] + 1 枚硬币。
因为可以选择不同面额,所以需要取最小值:
\ dp\[i=\min(dpi,dpi-coin+1) \]
③ 初始化条件
cpp
dp[0] = 0;
凑成金额 0 不需要任何硬币。其他金额初始化为一个较大的数:
cpp
vector<int> dp(amount + 1, amount + 1);
因为最多使用 amount 枚面额为 1 的硬币,如果无法凑成,最终就不会得到有效结果。
④ 遍历顺序
这里使用完全背包的遍历方式:
cpp
for (int coin : coins) {
for (int i = coin; i <= amount; i++) {
...
}
}
金额从小到大遍历,允许当前硬币被重复使用。
⑤ 返回值
如果:
cpp
dp[amount] == amount + 1
说明无法凑成目标金额,返回 -1。否则返回 dp[amount]。
代码实现
cpp
class Solution {
public:
int coinChange(vector<int>& coins, int amount) {
vector<int> dp(amount + 1, amount + 1);
dp[0] = 0;
for (int coin : coins) {
for (int i = coin; i <= amount; i++) {
dp[i] = min(
dp[i],
dp[i - coin] + 1
);
}
}
return dp[amount] == amount + 1
? -1 : dp[amount];
}
};
关键点分析
① 为什么初始化为 amount + 1?
因为最坏情况下,如果存在面额为 1 的硬币,凑成金额 amount 最多需要 amount 枚硬币。因此 amount + 1 可以作为一个不可能达到的初始值。
② 为什么金额要从小到大遍历?
因为每种硬币可以重复使用。例如:
text
coins = [2]
amount = 4
当遍历到金额 2 时:
cpp
dp[2] = dp[0] + 1 = 1;
继续遍历金额 4:
cpp
dp[4] = dp[2] + 1 = 2;
这里 dp[2] 已经使用过当前面额的硬币,因此可以再次使用。如果从大到小遍历,就会变成每种硬币只能使用一次的 0-1 背包思路。
③ 为什么外层遍历硬币,内层遍历金额?
这种顺序属于组合型完全背包写法,每种硬币按照固定顺序处理,避免将不同硬币的选择顺序重复计算。
本题只求最少硬币数量,改变遍历顺序也能得到正确的最少数量,但要注意:如果题目改为统计组合数或排列数,遍历顺序就会影响答案。
复杂度分析
- 时间复杂度:O(n × amount),n 为硬币种类数量。
- 空间复杂度:O(amount)。
五、字符串动态规划
字符串动态规划通常使用二维 dp 数组,状态一般与两个字符串的前缀、子串或者下标有关。
1. LeetCode 139:单词拆分
题目描述
给定一个字符串 s 和一个字符串列表 wordDict。判断是否可以使用字典中的单词拼接出字符串 s。字典中的单词可以重复使用。
例如:
text
s = "leetcode"
wordDict = ["leet", "code"]
输出:true
因为:
"leet" + "code" = "leetcode"
解题思路
这道题可以使用一维动态规划。
① 确定 dp 数组的含义
定义:
cpp
dp[i]
表示字符串 s 的前 i 个字符能否被字典中的单词拼接出来。
注意:这里的 i 表示字符数量,不是最后一个字符的下标。因此:
cpp
dp[0]
表示空字符串可以被拼接出来。
② 确定状态转移方程
如果:
text
dp[j] == true
并且:
text
s[j...i-1]
是字典中的一个单词,那么:
text
dp[i] = true
也就是说:
\ dp\[i=dpj\ \&\&\ sj,i)\\in wordDict \\
只要存在一个满足条件的 j,就可以将 dp[i] 设置为 true。
③ 初始化条件
cpp
dp[0] = true;
空字符串可以看作已经成功完成拼接。
④ 遍历顺序
从前往后遍历字符串的前缀长度。对于每个 i,枚举之前可能的切分位置 j。
⑤ 返回值
cpp
return dp[n];
代码实现
cpp
class Solution {
public:
bool wordBreak(string s, vector<string>& wordDict) {
int n = s.size();
unordered_set<string> dict(
wordDict.begin(), wordDict.end()
);
vector<bool> dp(n + 1, false);
dp[0] = true;
for (int i = 1; i <= n; i++) {
for (int j = 0; j < i; j++) {
if (dp[j] && dict.count(s.substr(j, i - j))) {
dp[i] = true;
break;
}
}
}
return dp[n];
}
};
关键点分析
① 为什么 dp 数组长度是 n + 1?
因为这里的 dp 下标表示前缀长度。例如:
text
s = "leetcode"
那么:
text
dp[0]:空字符串
dp[1]:"l"
dp[2]:"le"
...
dp[8]:"leetcode"
所以需要 n+1 个位置。
② 为什么 dp0 必须初始化为 true?
例如:
text
s = "leet"
当我们判断前四个字符是否能够被拼接时,j = 0。如果:
cpp
dp[0] == true
并且 "leet" 在字典中,就能得到:
cpp
dp[4] = true;
如果 dp[0] 初始化为 false,那么就无法从第一个字符开始完成匹配。
复杂度分析
设字符串长度为 n,字典中单词数量为 m。
- 时间复杂度:通常可按 O(n²) 次状态判断理解;考虑
substr()和哈希计算的实际开销,最坏情况下可能达到 O(n³) 量级。 - 空间复杂度:O(n + 字典存储空间)。
六、子序列动态规划
子序列问题是动态规划中非常重要的一类题目,通常需要考虑:
- 当前元素是否加入子序列。
- 当前元素和之前元素之间的关系。
- 两个字符串之间的匹配关系。
1. LeetCode 300:最长递增子序列
题目链接:300. 最长递增子序列 - 力扣(LeetCode)
题目描述
给定一个整数数组 nums,找到其中最长严格递增子序列的长度。
例如:
text
nums = [10,9,2,5,3,7,101,18]
输出:4
最长递增子序列:
[2,3,7,101]
注意:子序列不要求连续,但元素之间的相对顺序不能改变。
解题思路
① 确定 dp 数组的含义
定义:
cpp
dp[i]
表示以 nums[i] 结尾的最长严格递增子序列的长度。
注意,这里必须强调:dpi 表示以第 i 个元素结尾,而不是前 i 个元素中的最长递增子序列长度。
② 确定状态转移方程
对于当前元素 nums[i],我们需要遍历它前面的所有元素。如果:
cpp
nums[j] < nums[i]
说明可以将当前元素接在以 nums[j] 结尾的递增子序列后面。那么:
\ dp\[i=\max(dpi,dpj+1) \]
其中:
\ 0\\leq j\
③ 初始化条件
每个元素自身都可以构成一个长度为 1 的递增子序列:
cpp
vector<int> dp(n, 1);
④ 遍历顺序
外层从左向右遍历 i,内层遍历所有满足 j < i 的位置。
⑤ 返回值
由于 dpi 表示以 i 结尾的最长递增子序列,因此最终答案不一定是 dp[n-1]。需要取整个 dp 数组的最大值:
\ ans=\\max(dp\[0,dp1,...,dpn-1) \]
代码实现
cpp
class Solution {
public:
int lengthOfLIS(vector<int>& nums) {
int n = nums.size();
vector<int> dp(n, 1);
int ans = 1;
for (int i = 0; i < n; i++) {
for (int j = 0; j < i; j++) {
if (nums[j] < nums[i]) {
dp[i] = max(
dp[i], dp[j] + 1
);
}
}
ans = max(ans, dp[i]);
}
return ans;
}
};
关键点分析
① 为什么判断条件是 numsj < numsi?
因为题目要求严格递增。如果使用:
cpp
nums[j] <= nums[i]
就允许相同元素出现在递增子序列中,不符合题意。
② 为什么返回最大值,而不是 dpn-1?
因为最长递增子序列不一定以数组最后一个元素结尾。例如:
text
nums = [1,3,2]
对应:
text
dp = [1,2,2]
最终答案是 2。虽然最后一个元素也能构成长度为 2 的子序列,但一般情况下,不能假设答案一定以最后一个元素结尾。
复杂度分析
- 时间复杂度:O(n²)。
- 空间复杂度:O(n)。
补充:这道题还可以使用贪心 + 二分查找优化到 O(nlogn),但那属于另一种解法,后面可以单独学习。
2. LeetCode 1143:最长公共子序列
题目链接:1143. 最长公共子序列 - 力扣(LeetCode)
题目描述
给定两个字符串 text1 和 text2,返回它们最长公共子序列的长度。
例如:
text
text1 = "abcde"
text2 = "ace"
输出:3
最长公共子序列:
"ace"
注意:子序列不要求连续。
解题思路
这道题是二维动态规划的经典模型。因为需要同时考虑两个字符串,所以使用二维 dp 数组。
① 确定 dp 数组的含义
定义:
cpp
dp[i][j]
表示字符串 text1 的前 i 个字符和字符串 text2 的前 j 个字符之间的最长公共子序列长度。
注意这里的 i、j 表示前缀长度。因此:
cpp
dp[0][j] = 0;
dp[i][0] = 0;
② 确定状态转移方程
分为两种情况。
情况一:当前两个字符相等。
cpp
text1[i - 1] == text2[j - 1]
说明当前字符可以加入公共子序列。因此:
\ dp\[ij=dpi-1j-1+1 \]
情况二:当前两个字符不相等。那么至少需要放弃其中一个字符串的当前字符,分别考虑:
- 不考虑
text1的当前字符。 - 不考虑
text2的当前字符。
取两种情况的最大值:
\ dp\[ij=\max(dpi-1j,dpij-1) \]
③ 初始化条件
cpp
vector<vector<int>> dp(
m + 1,
vector<int>(n + 1, 0)
);
因为任意一个字符串和空字符串的最长公共子序列长度都是 0。
④ 遍历顺序
从左上角向右下角遍历。因为当前状态依赖:
text
dp[i-1][j-1]
dp[i-1][j]
dp[i][j-1]
这些状态都需要提前计算。
⑤ 返回值
cpp
return dp[m][n];
代码实现
cpp
class Solution {
public:
int longestCommonSubsequence(
string text1,
string text2
) {
int m = text1.size();
int n = text2.size();
vector<vector<int>> dp(
m + 1,
vector<int>(n + 1, 0)
);
for (int i = 1; i &lt;= m; i++) {
for (int j = 1; j &lt;= n; j++) {
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]
);
}
}
}
return dp[m][n];
}
};
关键点分析
① 为什么字符串下标使用 i-1 和 j-1?
因为 dp 数组下标表示前缀长度,而 C++ 字符串下标从 0 开始。例如:
text
text1 = "abcde"
当:
cpp
i = 3;
表示考虑前 3 个字符,即 "abc"。最后一个字符的下标就是:
cpp
i - 1
也就是 text1[2]。
② 为什么字符不相等时取最大值?
因为两个字符串的当前字符不同,它们不可能同时作为当前公共子序列的最后一个字符。因此需要尝试去掉其中一个字符,看看哪种情况能得到更长的公共子序列。
复杂度分析
- 时间复杂度:O(m × n)。
- 空间复杂度:O(m × n)。
七、区间与二维动态规划
二维动态规划不一定都是字符串问题。有些题目需要在二维状态空间中进行计算,还有些题目需要考虑一个区间内的最优结果。这里再看两道 Hot 100 经典题目。
1. LeetCode 64:最小路径和
题目描述
给定一个包含非负整数的 m × n 网格。从左上角出发,每次只能向右或者向下移动。求到达右下角的最小路径和。
例如:
text
grid = [
[1,3,1],
[1,5,1],
[4,2,1]
]
输出:7
路径:
1 -> 3 -> 1 -> 1 -> 1
解题思路
① 确定 dp 数组的含义
定义:
cpp
dp[i][j]
表示从左上角到达位置 (i,j) 的最小路径和。
② 确定状态转移方程
到达当前位置,只能从上方或者左方过来。因此:
\ dp\[ij=\min(dpi-1j,dpij-1)+gridij \]
③ 初始化条件
起点:
cpp
dp[0][0] = grid[0][0];
第一行只能从左边过来:
cpp
dp[0][j] = dp[0][j-1] + grid[0][j];
第一列只能从上方过来:
cpp
dp[i][0] = dp[i-1][0] + grid[i][0];
④ 遍历顺序
从左上角向右下角遍历。
⑤ 返回值
cpp
return dp[m-1][n-1];
代码实现
cpp
class Solution {
public:
int minPathSum(vector<vector<int>>& grid) {
int m = grid.size();
int n = grid[0].size();
vector<vector<int>> dp(
m,
vector<int>(n, 0)
);
dp[0][0] = grid[0][0];
// 初始化第一行
for (int j = 1; j &lt; n; j++) {
dp[0][j] =
dp[0][j - 1] + grid[0][j];
}
// 初始化第一列
for (int i = 1; i &lt; m; i++) {
dp[i][0] =
dp[i - 1][0] + grid[i][0];
}
// 计算其他位置
for (int i = 1; i &lt; m; i++) {
for (int j = 1; j &lt; n; j++) {
dp[i][j] = min(
dp[i - 1][j],
dp[i][j - 1]
) + grid[i][j];
}
}
return dp[m - 1][n - 1];
}
};
复杂度分析
- 时间复杂度:O(m × n)。
- 空间复杂度:O(m × n)。
2. LeetCode 72:编辑距离
题目描述
给定两个单词 word1 和 word2。可以对 word1 进行以下三种操作:
- 插入一个字符。
- 删除一个字符。
- 替换一个字符。
求将 word1 转换成 word2 所需要的最少操作次数。
例如:
text
word1 = "horse"
word2 = "ros"
输出:3
解题思路
这道题是二维动态规划中比较经典的一道困难题。
① 确定 dp 数组的含义
定义:
cpp
dp[i][j]
表示将 word1 的前 i 个字符转换成 word2 的前 j 个字符所需要的最少操作次数。
② 确定状态转移方程
分为两种情况。
情况一:当前字符相等。
cpp
word1[i - 1] == word2[j - 1]
说明当前字符不需要进行操作。因此:
\ dp\[ij=dpi-1j-1 \]
情况二:当前字符不相等。需要考虑三种操作。
删除: 删除 word1 当前的最后一个字符。
\ dp\[ij=dpi-1j+1 \]
插入: 在 word1 末尾插入一个字符,使其与 word2 当前字符匹配。
\ dp\[ij=dpij-1+1 \]
替换: 将 word1 当前字符替换成 word2 当前字符。
\ dp\[ij=dpi-1j-1+1 \]
最终取三种操作的最小值:
\ dp\[ij=\min \begin{cases} dpi-1j+1\\ dpij-1+1\\ dpi-1j-1+1 \end{cases} \]
③ 初始化条件
将一个字符串转换为空字符串,需要删除它的全部字符:
cpp
dp[i][0] = i;
将空字符串转换成另一个字符串,需要插入全部字符:
cpp
dp[0][j] = j;
④ 遍历顺序
从左上角向右下角遍历。
⑤ 返回值
cpp
return dp[m][n];
代码实现
cpp
class Solution {
public:
int minDistance(string word1, string word2) {
int m = word1.size();
int n = word2.size();
vector<vector<int>> dp(
m + 1,
vector<int>(n + 1, 0)
);
// 初始化第一列
for (int i = 0; i &lt;= m; i++) {
dp[i][0] = i;
}
// 初始化第一行
for (int j = 0; j &lt;= n; j++) {
dp[0][j] = j;
}
for (int i = 1; i &lt;= m; i++) {
for (int j = 1; j &lt;= n; j++) {
if (word1[i - 1] == word2[j - 1]) {
dp[i][j] = dp[i - 1][j - 1];
}
else {
dp[i][j] = min({
dp[i - 1][j] + 1,
dp[i][j - 1] + 1,
dp[i - 1][j - 1] + 1
});
}
}
}
return dp[m][n];
}
};
关键点分析
① 为什么有三种状态转移?
因为题目允许三种编辑操作:
- 删除。
- 插入。
- 替换。
每一种操作都对应一种之前的状态。
② 为什么字符相同时不需要操作?
因为当前两个字符已经匹配,不需要修改它们。直接继承之前的最优结果:
cpp
dp[i][j] = dp[i - 1][j - 1];
③ 为什么初始化是 i 和 j?
例如:
text
word1 = "abc"
word2 = ""
需要删除 3 个字符,因此:
cpp
dp[3][0] = 3;
反过来:
text
word1 = ""
word2 = "abc"
需要插入 3 个字符,因此:
cpp
dp[0][3] = 3;
复杂度分析
- 时间复杂度:O(m × n)。
- 空间复杂度:O(m × n)。
八、动态规划中的空间优化
前面很多题目都使用了二维或者一维 dp 数组。但有些情况下,当前状态只依赖前面少量状态,没有必要保存整个数组。这时可以考虑空间优化。
1. 一维数组优化
例如爬楼梯:
cpp
dp[i] = dp[i - 1] + dp[i - 2];
只需要保存前两个状态:
cpp
int a = 1;
int b = 1;
for (int i = 2; i <= n; i++) {
int c = a + b;
a = b;
b = c;
}
空间复杂度从 O(n) 降低到 O(1)。
2. 二维数组优化成一维数组
例如最长公共子序列:
cpp
dp[i][j]
只依赖:
text
dp[i-1][j-1]
dp[i-1][j]
dp[i][j-1]
实际上只需要保存上一行和当前行的数据。进一步可以使用一个一维数组完成空间压缩。
cpp
class Solution {
public:
int longestCommonSubsequence(
string text1,
string text2
) {
int m = text1.size();
int n = text2.size();
vector<int> dp(n + 1, 0);
for (int i = 1; i &lt;= m; i++) {
int prev = 0;
for (int j = 1; j &lt;= n; j++) {
int temp = dp[j];
if (text1[i - 1] == text2[j - 1]) {
dp[j] = prev + 1;
}
else {
dp[j] = max(dp[j], dp[j - 1]);
}
prev = temp;
}
}
return dp[n];
}
};
这里:
dp[j]在更新之前,表示上一行的dp[i-1][j]。dp[j-1]表示当前行已经更新的dp[i][j-1]。prev保存左上角的旧状态dp[i-1][j-1]。
通过这三个状态,就可以完成二维动态规划的状态转移。空间复杂度从 O(m × n) 降低为 O(n)。
注意:空间优化不是简单地把二维数组改成一维数组,而是必须明确每次更新时,哪些旧状态还需要保留。
九、动态规划常见问题与面试考点
1. 动态规划和递归有什么区别?
递归是一种函数调用方式,动态规划是一种解决问题的算法思想。
递归可以直接重复计算子问题,也可以通过记忆化搜索保存结果。动态规划通常使用自底向上的方式,按照状态依赖关系逐步计算。
2. 动态规划和记忆化搜索有什么区别?
两者都可以避免重复计算。
记忆化搜索:
- 通常使用递归。
- 从原问题开始向下搜索。
- 遇到已经计算过的状态,直接返回保存的结果。
动态规划(通常指递推式 DP):
- 通常使用循环。
- 从较小的子问题开始计算。
- 按照状态转移顺序逐步得到最终答案。
两者解决的状态关系可以相同,只是计算顺序和实现方式不同。
3. 为什么动态规划需要初始化?
因为状态转移方程需要依赖之前的状态。如果初始状态没有正确设置,就无法正确推导后续状态。
例如:
cpp
dp[0] = 1;
在爬楼梯中表示到达起点有一种方式。而在打家劫舍中:
cpp
dp[0] = 0;
表示没有房屋时,最大收益为 0。初始化必须结合 dp 数组的具体定义,不能死记硬背。
4. 为什么有些题目需要倒序遍历?
这通常出现在一维数组优化后的背包问题中。0-1 背包中,每件物品只能使用一次。如果容量正序遍历,当前物品可能被重复使用,所以需要倒序遍历。
完全背包中,每件物品可以重复使用,因此通常正序遍历容量。
5. 为什么有些动态规划题目返回 dp 数组最大值,而不是最后一个元素?
因为 dp 数组的含义可能是:
cpp
dp[i] = 以第 i 个元素结尾的最优结果
例如最长递增子序列。最长递增子序列不一定以最后一个元素结尾,因此必须遍历整个数组取最大值。
而有些题目定义:
cpp
dp[i] = 前 i 个元素的最优结果
这种情况下,最终答案通常就是 dp[n]。关键在于理解 dp 数组的定义。
6. 动态规划一定需要二维数组吗?
不一定。
- 一维动态规划:爬楼梯、打家劫舍、零钱兑换。
- 二维动态规划:最长公共子序列、编辑距离、最小路径和。
- 空间优化:使用滚动数组或者少量变量保存必要状态。
数组维度取决于状态定义,而不是题目本身有几个参数。
十、LeetCode Hot 100 动态规划刷题清单
掌握前面的题目后,可以按照下面的顺序继续练习。
| 题号 | 题目 | 核心模型 | 难度 |
|---|---|---|---|
| 70 | 爬楼梯 | 一维 DP | 简单 |
| 118 | 杨辉三角 | 二维 DP | 简单 |
| 198 | 打家劫舍 | 状态选择 | 中等 |
| 279 | 完全平方数 | 完全背包 | 中等 |
| 322 | 零钱兑换 | 完全背包 | 中等 |
| 139 | 单词拆分 | 字符串 DP | 中等 |
| 300 | 最长递增子序列 | 子序列 DP | 中等 |
| 152 | 乘积最大子数组 | 双状态 DP | 中等 |
| 62 | 不同路径 | 二维 DP | 中等 |
| 64 | 最小路径和 | 二维 DP | 中等 |
| 1143 | 最长公共子序列 | 二维 DP | 中等 |
| 72 | 编辑距离 | 字符串 DP | 困难 |
注:具体题目集合会随 LeetCode Hot 100 版本调整,以上以经典题目为主。
十一、总结
动态规划的核心并不是背诵某个固定模板,而是建立正确的状态定义,并通过状态转移方程解决问题。
拿到一道动态规划题目时,建议按照下面的顺序分析:
- **确定状态:**dpi 或 dpij 表示什么?
- **推导转移:**当前状态可以由哪些之前的状态得到?
- **确定初始化:**哪些状态是已知的?
- **确定遍历顺序:**如何保证依赖的状态已经计算?
- **确定答案:**最终应该返回哪个状态?
- **空间优化:**是否可以减少不必要的状态存储?
动态规划需要重点掌握以下几类模型:
| 模型 | 代表题目 | 核心思路 |
|---|---|---|
| 一维 DP | 爬楼梯 | 当前状态依赖前面状态 |
| 状态选择 DP | 打家劫舍 | 不同选择取最优结果 |
| 背包 DP | 零钱兑换 | 根据容量和物品进行状态转移 |
| 字符串 DP | 单词拆分 | 枚举前缀切分位置 |
| 子序列 DP | 最长递增子序列 | 考虑之前元素的影响 |
| 双序列 DP | 最长公共子序列 | 同时考虑两个字符串 |
| 二维路径 DP | 最小路径和 | 从相邻位置推导当前状态 |
| 编辑距离 DP | 编辑距离 | 插入、删除、替换三种操作 |
**最后一个建议:**学习动态规划时,不要只看代码。每道题都应该自己在纸上写出:
text
dp 数组的含义
状态转移方程
初始化条件
遍历顺序
返回值
尤其是状态转移方程,一定要能够自己推导出来。
只要能够独立分析这些内容,动态规划就不再是单纯依赖背诵代码的算法,而是一套可以迁移到不同问题上的解题方法。