概述
- 该博客记录了Yukiii学习dp的过程,主要是从
AcWing
和代码随想录
中学习动态规划的解题思路,同时把做过的的题目记录下来方便复习,之后可能会不定期更新。 - 所有dp问题全都遵循5步走的方法,首先用闫氏分析法确定状态表示和状态计算,再初始化dp数组,接着根据状态转移的状态依赖关系确定遍历顺序,最后如果出问题举例推导,并把dp数组打印出来,看是代码实现出问题还是分析出问题。
- 学习y总的思路,从集合的角度思考问题,如果最优解只可能出现在划分出的一边那就是贪心,如果都有可能出现最优解那就是dp。
背包 dp
- 背包问题是一种选择问题,在出现选择哪些物品的问题可以考虑使用背包模型。
- 因此首先看题目是不是选择问题,再看每个物品可以用几次,判断是01背包还是完全背包等等,关键在于能否看出是背包模型。
-
-
为什么两个for循环的嵌套顺序是先遍历物品再遍历背包容量,反过来行不行。
这需要理解递推公式本质和递推方向,
dp[i][j]
由dp[i - 1][j]
和dp[i - 1][j - w[i]]
得到,它们都在dp[i][j]
的左上(正上)方向,两种for循环(逐行和逐列)都能保证dp[i][j]
可以被推导得到。在遍历背包容量维度的时候可以正序也可以倒序,因为它依赖的是上一层
[i - 1]
层的数据,此时已经全被计算出来。 -
二维如何变成一维滚动数组,为什么能变,两个for循环顺序反过来行不行。
优化掉物品维度,也就是变成
dp[j]
,这是因为dp[i][j]
的计算都是依赖于上一层dp[i - 1][...]
的结果,因此可以理解成直接把上一层拷贝到这一层进行覆盖,变成滚动数组。优化之后由于
dp[j]
是由dp[j - w[i]]
得来的,在计算dp[j]
的时候dp[j - w[i]]
应该保留的是[i - 1]
那一层的结果,所以遍历背包容量的顺序应该倒序遍历避免覆盖[i - 1]
层的结果。并且滚动数组做的时候必须要先遍历物品再遍历背包容量,这是因为如果先遍历背包容量,且必须是倒序遍历(即从最右侧自上而下遍历),此时可以发现递推公式中的
dp[j - w[i]]
永远是 0,也就是说背包只能放一个物品。for (int i = 1; i <= n; i ++) for (int j = m; j >= w[i]; j --) dp[j] = max(dp[j], dp[j - w[i]] +v[i]);
-
如何确定当前状态选择了哪些物品。
当时复旦保研机试就考了这题,我居然栽在了这里 TATfor(int i = 1; i <= n; i ++) for (int j = m; j >= w[i]; j --) if (dp[j] <= dp[j - w[i]] + v[i]) { dp[j] = dp[j - w[i]] + v[i]; path[i][j] = true; } for (int i = n, j = m; i && j; i --) if (path[i][j] == 1) { cout << i << ' ' << w[i] << ' ' << v[i] << endl; j -= w[i]; }
-
-
-
单纯的完全背包问题能不能颠倒for循环顺序。
可以,不管哪个 for 循环在外都能保证在计算
dp[j]
时依赖的状态已经计算出来。// 01背包 dp[i][j] = max(dp[i - 1][j], dp[i - 1][j - w[i]] + v[i]); // 完全背包 dp[i][j] = max(dp[i - 1][j], dp[i][j - w[i]] + v[i]); [这式子是通过等价代换得到的] for (int i = 1; i <= n; i ++) for (int j = w[i]; j <= m; j ++) dp[j] = max(dp[j], dp[j - w[i]] + v[i]);
-
-
LeetCode 518. 零钱兑换Ⅱ (装满完全背包的组合数)
LeetCode 377. 组合求和Ⅳ (装满完全背包的排列数)
-
求装满完全背包组合数和排列数的区别,为什么会有这样区别。
求组合数外层遍历物品,内层遍历背包;求排列数外层遍历背包,内层遍历物品。
以本题为例,求组合数先遍历物品,一个个硬币遍历只会出现
{coin[0], coin[1]}
,而求排列数内层遍历物品,因此包含{coin[0], coin[1]}
,{coin[1], coin[0]}
的情况。 -
∴ 求解完全背包问题要区分求的是组合数还是排列数。
-
-
状态表示: 子串
s(1, i)
能否由词典中的词构成。状态转移: 子串
s(1, i)
能构成且s(i, j)
也存在于词典之中,则s(1, j)
也能够构成。
线性 dp
-
-
如果改成一步一个台阶,两个台阶,三个台阶,直到m个台阶,有多少种方法爬到n阶楼顶 ;
这就变成了一个完全背包问题,和
LeetCode 377. 组合求和Ⅳ
完全一样 ;
-
状态机 dp
-
LeetCode 122. 买卖股票的最佳时机Ⅱ (可买卖多次股票,最多持有1股)
-
状态dp的具体思路 ;
// dp[i][0]: 第i天持有股票时的最多现金 // dp[i][1]: 第i天未持有股票时的最多现金 dp[0][0] = -prices[0]; dp[0][1] = 0; for (int i = 1; i < n; i ++) { dp[i][0] = max(dp[i - 1][0], dp[i - 1][1] - prices[i]); dp[i][1] = max(dp[i - 1][1], dp[i - 1][0] + prices[i]); } return dp[n - 1][1];
-
// dp[i][j]: 第i天状态j所剩的最大现金 (一共5种状态)
// 0. 不持有股票
// 1. 持有股票
// 2. 不持有股票,且完成1次买卖
// 3. 持有股票,且完成1次买卖
// 4. 不持有股票,且完成2次买卖
// 初始化要注意dp[0][3]的初始化,相当于是第0天买卖完一次再次买入
dp[0][1] = -prices[0];
dp[0][3] = -prices[0];
for (int i = 1; i < n; i ++)
{
dp[i][0] = dp[i - 1][0];
dp[i][1] = max(dp[i - 1][1], dp[i - 1][0] - prices[i]);
dp[i][2] = max(dp[i - 1][2], dp[i - 1][1] + prices[i]);
dp[i][3] = max(dp[i - 1][3], dp[i - 1][2] - prices[i]);
dp[i][4] = max(dp[i - 1][4], dp[i - 1][3] + prices[i]);
}
return dp[n - 1][4];
- LeetCode 188. 买卖股票的最佳时机Ⅳ (最多买卖k次股票)
- LeetCode 309. 买卖股票的最佳时机含冷冻期
- AcWing 5406. 松散子序列
- LeetCode 198. 打家劫舍
- LeetCode 213. 打家劫舍Ⅱ(环)
区间 dp
状态表示: 所有合并
(i, j)
中的石子的方案的集合,属性: min 代价。划分方式: 按照最后一次合并的位置进行划分。
初始化: 所有自己合并自己的代价为0,
dp[i][i] = 0
,其余代价为0x3f3f3f3f
。
memset(dp, 0x3f, sizeof dp);
for (int i = 1; i <= n; i ++) dp[i][i] = 0;
for (int len = 2; len <= n; len ++)
for (int l = 1; l + len - 1 <= n; l ++)
{
int r = l + len - 1;
for (int k = l; k < r; k ++)
dp[l][r] = min(dp[l][r], dp[l][k] + dp[k + 1][r] + a[r] - a[l - 1]);
}
cout << dp[1][n] << endl;
区分相似题: AcWing 2875. 超级胶水,AcWing 148. 合并果子
超级胶水就是把代价从求和变成求乘积,合并果子则是不需要合并相邻两堆。
树形dp
感觉树形dp的套路都差不多,递归计算dp数组的值,通过儿子的信息计算父亲的信息,或者通过父亲的信息计算儿子的信息。
-
状态表示:
dp(u, 0) dp(u, 1)
所有以u 为根节点的子树,且不选/选u的方案的集合。属性: Max欢乐度。状态计算:
dfs(root); int dfs(int x) { dp[x][1] = happy[x]; for (int i = 0; i < u[x].size(); i ++ ) // 用的 vector<int> u[N] 的邻接表存储树 { int j = u[x][i]; dfs(j); // 递归先计算儿子 dp[x][0] += max(dp[j][0], dp[j][1]); dp[x][1] += dp[j][0]; } } cout << max(dp[root][0], dp[root][1]) << endl;