大纲:
动态五部曲如下:1. 确定 dp 数组( dp table )以及下标的含义2. 确定递推公式3. dp 数组如何初始化4. 确定遍历顺序5. 举例推导 dp 数组
一、动态规划理论基础
什么是动态规划?
动态规划,英⽂:
Dynamic Programming
,简称
DP
,如果某⼀问题有很多重叠⼦问题,使⽤动态规划是最有效的。
所以动态规划中每⼀个状态⼀定是由上⼀个状态推导出来的,
这⼀点就区分于贪⼼,贪⼼没有状态推导,⽽是从局部直接选最优的
动态规划应该如何debug?
找问题的最好⽅式就是把
dp数组打印出来,看看究竟是不是按照⾃⼰思路推导的!
做动规的题⽬,写代码之前⼀定要把状态转移在
dp
数组的上具体情况模拟⼀遍,⼼中有数,确定最后推出的是想要的结果。
然后再写代码,如果代码没通过就打印
dp
数组,看看是不是和⾃⼰预先推导的哪⾥不⼀样。
如果打印出来和⾃⼰预先模拟推导是⼀样的,那么就是⾃⼰的递归公式、初始化或者遍历顺序有问题 了。 如果和⾃⼰预先模拟推导的不⼀样,那么就是代码实现细节有问题。
我这⾥代码都已经和题解⼀模⼀样了,为什么通过不了呢? 发出这样的问题之前,其实可以⾃⼰先思考这三个问题:
- 这道题⽬我举例推导状态转移公式了么?
- 我打印dp数组的⽇志了么?
- 打印出来了dp数组和我想的⼀样么?
二、基础题目
509. 斐波那契数
70.
爬楼梯
746.
使⽤最⼩花费爬楼梯
62.
不同路径
63.
不同路径
II
343.
整数拆分
96.
不同的⼆叉搜索树
对于⾯试的话,其实掌握01背包,和完全背包,就够⽤了,最多可以再来⼀个多重背包
即⼀个商品如果可以重复多次放⼊是完全背包,⽽只能放⼊⼀次是01背包,写法还是不⼀样的
01 背包
有
N
件物品和⼀个最多能被重量为
W
的背包。第
i
件物品的重量是
weight[i]
,得到的价值是
value[i]
。
每
件物品只能⽤⼀次
,求解将哪些物品装⼊背包⾥物品价值总和最⼤
![](https://i-blog.csdnimg.cn/blog_migrate/e198bb54a7cbb20d785f9b63e50f9aaa.png)
这是标准的背包问题,以⾄于很多同学看了这个⾃然就会想到背包,甚⾄都不知道暴⼒的解法应该怎么解了。这样其实是没有从底向上去思考,⽽是习惯性想到了背包,那么暴⼒的解法应该是怎么样的呢?
每⼀件物品其实只有两个状态,取或者不取,所以可以使⽤回溯法搜索出所有的情况,那么时间复杂度就是O(2^n)
,这⾥的
n
表示物品数量。
所以暴⼒的解法是指数级别的时间复杂度。进⽽才需要动态规划的解法来进⾏优化!
在下⾯的讲解中,我举⼀个例⼦:
背包最⼤重量为
4
。
物品为:
![](https://i-blog.csdnimg.cn/blog_migrate/aad9f446538f950686c9346ffbaf90dd.png)
问背包能背的物品最⼤价值是多少?以下讲解和图示中出现的数字都是以这个例⼦为例。
⼆维 dp 数组 01 背包
依然动规五部曲分析⼀波。
1. 确定dp数组以及下标的含义
对于背包问题,有⼀种写法, 是使⽤⼆维数组,即
dp[i][j]
表示从下标为
[0-i]
的物品⾥任意取,放进容量为j
的背包,价值总和最⼤是多少
。 只看这个⼆维数组的定义,⼤家⼀定会有点懵,看下⾯这个图:
![](https://i-blog.csdnimg.cn/blog_migrate/f79fc8b4d46246812352cb60d2f4be83.png)
2. 确定递推公式
再回顾⼀下
dp[i][j]
的含义:从下标为
[0-i]
的物品⾥任意取,放进容量为
j
的背包,价值总和最⼤是多少。
那么可以有两个⽅向推出来
dp[i][j]
,
- 由dp[i - 1][j]推出,即背包容量为j,⾥⾯不放物品i的最⼤价值,此时dp[i][j]就是dp[i - 1][j]
- 由dp[i - 1][j - weight[i]]推出,dp[i - 1][j - weight[i]] 为背包容量为j - weight[i]的时候不放物品i的最⼤价值,那么dp[i - 1][j - weight[i]] + value[i] (物品i的价值),就是背包放物品i得到的最⼤价值
所以递归公式:
dp[i][j] = max(dp[i - 1][j], dp[i - 1][j - weight[i]] + value[i]);
3. dp数组如何初始化
关于初始化,⼀定要和
dp
数组的定义吻合,否则到递推公式的时候就会越来越乱
。
⾸先从
dp[i][j]
的定义出发,如果背包容量
j
为
0
的话,即
dp[i][0]
,⽆论是选取哪些物品,背包价值总和⼀定为0
。如图:
![](https://i-blog.csdnimg.cn/blog_migrate/e2077ac3c635f747d1f5790f937c633b.png)
在看其他情况。
状态转移⽅程
dp[i][j] = max(dp[i - 1][j], dp[i - 1][j - weight[i]] + value[i]);
可以看出
i
是由
i-1
推导出来, 那么i
为
0
的时候就⼀定要初始化。
dp[0][j]
,即:
i
为
0
,存放编号
0
的物品的时候,各个容量的背包所能存放的最⼤价值。
// 倒叙遍历
for (int j = bagWeight; j >= weight[0]; j--) {
dp[0][j] = dp[0][j - weight[0]] + value[0]; // 初始化i为0时候的情况
}
⼤家应该发现,这个初始化为什么是倒叙的遍历的?正序遍历就不⾏么? 正序遍历还真就不⾏,dp[0][j]
表示容量为
j
的背包存放物品
0
时候的最⼤价值,物品
0
的价值就是
15
,因为题⽬中说了每个物品只有⼀个!
所以
dp[0][j]
如果不是初始值的话,就应该都是物品
0
的价值,也就是15。 但如果⼀旦正序遍历了,那么物品0
就会被重复加⼊多次! 例如代码如下:
// 正序遍历
for (int j = weight[0]; j <= bagWeight; j++) {
dp[0][j] = dp[0][j - weight[0]] + value[0];
}
例如
dp[0][1]
是
15
,到了
dp[0][2] = dp[0][2 - 1] + 15;
也就是
dp[0][2] = 30
了,那么就是物品
0
被重复放⼊了。
所以⼀定要倒叙遍历,保证物品0只被放⼊⼀次!这⼀点对01背包很重要,后⾯在讲解滚动数组的时候,还会⽤到倒叙遍历来保证物品使⽤⼀次!
此时
dp
数组初始化情况如图所示
![](https://i-blog.csdnimg.cn/blog_migrate/b0cd3bbd8f8c6db104326f485efebaaa.png)
dp[0][j]
和
dp[i][0]
都已经初始化了,那么其他下标应该初始化多少呢? dp[i][j]在推导的时候⼀定是取价值最⼤的数,如果题⽬给的价值都是正整数那么⾮
0
下标都初始化为
0
就可以了,因为0
就是最⼩的了,不会影响取最⼤价值的结果。
如果题⽬给的价值有负数,那么⾮
0
下标就要初始化为负⽆穷了。例如:⼀个物品的价值是
-2
,但对应的位置依然初始化为0
,那么取最⼤值的时候,就会取
0
⽽不是
-2
了,所以要初始化为负⽆穷。
这样才能让dp数组在递归公式的过程中取最⼤的价值,⽽不是被初始值覆盖了。
最后初始化代码如下:
// 初始化 dp
vector<vector<int>> dp(weight.size() + 1, vector<int>(bagWeight + 1, 0));
for (int j = bagWeight; j >= weight[0]; j--) {
dp[0][j] = dp[0][j - weight[0]] + value[0];
}
4.
确定遍历顺序
在如下图中,可以看出,有两个遍历的维度:物品与背包重量
![](https://i-blog.csdnimg.cn/blog_migrate/a99cf028c7a71e3e47358a58dbe86c62.png)
那么问题来了,
先遍历 物品还是先遍历背包重量呢?
其实都可以!! 但是先遍历物品更好理解。
![](https://i-blog.csdnimg.cn/blog_migrate/1244ecded44662afab4cd95040596f0e.png)
![](https://i-blog.csdnimg.cn/blog_migrate/54ec4617e238b4cb7db2f0a01f93be48.png)
![](https://i-blog.csdnimg.cn/blog_migrate/9aa4f35e31a8ca1c9392bc5552046fd6.png)
![](https://i-blog.csdnimg.cn/blog_migrate/a63a5a7e68e4c349fe8b62e24057a950.png)
完整
C++
测试代码
void test_2_wei_bag_problem1() {
vector<int> weight = {1, 3, 4};
vector<int> value = {15, 20, 30};
int bagWeight = 4;
// ⼆维数组
vector<vector<int>> dp(weight.size() + 1, vector<int>(bagWeight + 1, 0));
// 初始化
for (int j = bagWeight; j >= weight[0]; j--) {
dp[0][j] = dp[0][j - weight[0]] + value[0];
}
// weight数组的⼤⼩ 就是物品个数
for(int i = 1; i < weight.size(); i++) { // 遍历物品
for(int j = 0; j <= bagWeight; j++) { // 遍历背包容量
if (j < weight[i]) dp[i][j] = dp[i - 1][j];
else dp[i][j] = max(dp[i - 1][j], dp[i - 1][j - weight[i]] +
value[i]);
}
}
cout << dp[weight.size() - 1][bagWeight] << endl; }
int main() {
test_2_wei_bag_problem1();
}
01背包理论基础(滚动数组)我觉得更好理解一些
在动态规划中,如果使⽤⼀维dp数组,物品遍历的for循环放在外层,遍历背包的for循环放在内层,且内层for循环倒叙遍历!
⼀维dp数组(滚动数组)
![](https://i-blog.csdnimg.cn/blog_migrate/182ab5d554f785860a45eabc6465fe3b.png)
动规五部曲分析如下:
1. 确定dp数组的定义
在⼀维
dp
数组中,
dp[j]
表示:容量为
j
的背包,所背的物品价值可以最⼤为
dp[j]
。
2. ⼀维dp数组的递推公式
dp[j]
为 容量为
j
的背包所背的最⼤价值,那么如何推导
dp[j]
呢?
dp[j]
可以通过
dp[j - weight[j]]
推导出来,
dp[j - weight[i]]
表示容量为
j - weight[i]
的背包所背的最⼤价值。dp[j - weight[i]] + value[i] 表示 容量为
j -
物品
i
重量 的背包 加上 物品
i
的价值。(也就是容量为
j
的背包,放⼊物品i
了之后的价值即:
dp[j]
)
此时
dp[j]
有两个选择,⼀个是取⾃⼰
dp[j]
,⼀个是取
dp[j - weight[i]] + value[i]
,指定是取最⼤的,毕竟是求最⼤价值,
所以递归公式为:
dp[j] = max(dp[j], dp[j - weight[i]] + value[i]);
可以看出相对于⼆维
dp
数组的写法,就是把
dp[i][j]
中
i
的维度去掉了。
3. ⼀维dp数组如何初始化
关于初始化,⼀定要和
dp
数组的定义吻合,否则到递推公式的时候就会越来越乱
。
dp[j]
表示:容量为
j
的背包,所背的物品价值可以最⼤为
dp[j]
,那么
dp[0]
就应该是
0
,因为背包容量为
0所背的物品的最⼤价值就是0
。
那么
dp
数组除了下标
0
的位置,初始为
0
,其他下标应该初始化多少呢?
看⼀下递归公式:
dp[j] = max(dp[j], dp[j - weight[i]] + value[i]);
dp
数组在推导的时候⼀定是取价值最⼤的数,如果题⽬给的价值都是正整数那么⾮
0
下标都初始化为
0
就可以了,如果题⽬给的价值有负数,那么⾮0
下标就要初始化为负⽆穷
这样才能让
dp
数组在递归公式的过程中取的最⼤的价值,⽽不是被初始值覆盖了
。
那么我假设物品价值都是⼤于
0
的,所以
dp
数组初始化的时候,都初始为
0
就可以了。
4. ⼀维dp数组遍历顺序
代码如下:
for(int i = 0; i < weight.size(); i++) { // 遍历物品
for(int j = bagWeight; j >= weight[i]; j--) { // 遍历背包容量
dp[j] = max(dp[j], dp[j - weight[i]] + value[i]);
}
}
这⾥⼤家发现和⼆维
dp
的写法中,遍历背包的顺序是不⼀样的!
⼆维
dp
遍历的时候,背包容量是从⼩到⼤,⽽⼀维
dp
遍历的时候,背包是从⼤到⼩。
为什么呢?
倒叙遍历是为了保证物品
i
只被放⼊⼀次!
举⼀个例⼦:物品
0
的重量
weight[0] = 1
,价值
value[0] = 15
如果正序遍历
dp[1] = dp[1 - weight[0]] + value[0] = 15
dp[2] = dp[2 - weight[0]] + value[0] = 30
此时
dp[2]
就已经是
30
了,意味着物品
0
,被放⼊了两次,所以不能正序遍历。
为什么倒叙遍历,就可以保证物品只放⼊⼀次呢?
倒叙就是先算
dp[2] = dp[2 - weight[0]] + value[0] = 15
(
dp
数组已经都初始化为
0
)
dp[1] = dp[1 - weight[0]] + value[0] = 15
所以从后往前循环,每次取得状态不会和之前取得状态重合,这样每种物品就只取⼀次了。
再来看看两个嵌套
for
循环的顺序,代码中是先遍历物品嵌套遍历背包容量,那可不可以先遍历背包容量嵌套遍历物品呢?
不可以!
因为⼀维
dp
的写法,背包容量⼀定是要倒序遍历(原因上⾯已经讲了),如果遍历背包容量放在上⼀层,那么每个dp[j]
就只会放⼊⼀个物品,即:背包⾥只放⼊了⼀个物品。
5. 举例推导dp数组
⼀维
dp
,分别⽤物品
0
,物品
1
,物品
2
来遍历背包,最终得到结果如下:
![](https://i-blog.csdnimg.cn/blog_migrate/61a668b48e5125c2ea7bf302b69117c4.png)
⼀维
dp01
背包完整
C++
测试代码
![](https://i-blog.csdnimg.cn/blog_migrate/4c88972fad991637dd76d7d3b9047ded.png)
可以看出,⼀维dp 的01背包,要⽐⼆维简洁的多! 初始化 和 遍历顺序相对简单了。
所以我倾向于使⽤⼀维
dp
数组的写法,⽐较直观简洁,⽽且空间复杂度还降了⼀个数量级!在后⾯背包问题的讲解中,我都直接使⽤⼀维dp
数组来进⾏推导
。
416.
分割等和⼦集
1049.
最后⼀块⽯头的重量
II
494.
⽬标和
474.
⼀和零