动态规划+背包问题
背包问题往往可以抽象成:给定一组物品,每种物品都有自己的重量和价格,在限定的总重量内,我们如何选择,才能使得物品的总价格最高。背包问题这个总体概念可以划分出如0/1背包问题 、完全背包问题、多重背包问题等经典题目。
0 / 1背包问题
0表示背包未满,1表示已经放满,因此可以转化为拿或者不拿的问题:在容量有限的情况下,我们是否要拿起物品放入背包中,替换掉以前放入的物品,保证背包中的价值最大。
题目背景
题目分析
当每次放入新物品的时候,我们都需要和上一次放入的物品信息进行比对,比较出价值最大的放法。 所以我们需要记录以前已经解决了的问题信息,即使用动态规划的记忆性来进行填表解题。
在这里可以用一个二维dp数组来表示存放的记录,对于dp[i][j]来说,i表示物品的数量,j表示背包的容量,dp[i][j]本身则表示背包中已有的总价值。比如dp[1][0]表示当背包容量为0时,我放入第一个物品,dp[n][m]表示当背包容量为m时,放入第n个物品。
同时,使用w[]数组存放所有的重量信息,v[]存放所有的价值信息,绘制出一个表格。
至于这里为什么在设计表时,行列的开头都为0,会在后面的分析中提到。
w[i] | v[i] | dp[i][j] | |||||||||||
---|---|---|---|---|---|---|---|---|---|---|---|---|---|
0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | |||
0 | |||||||||||||
2 | 1 | 1 | |||||||||||
3 | 3 | 2 | |||||||||||
4 | 5 | 3 | |||||||||||
7 | 9 | 4 |
首先看第一行,当待放入的数据是第0件物品时,由于它并不存在,所以价值当然为零,直接填入。
w[i] | v[i] | dp[i][j] | |||||||||||
---|---|---|---|---|---|---|---|---|---|---|---|---|---|
0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | |||
0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | ||
2 | 1 | 1 | |||||||||||
3 | 3 | 2 | |||||||||||
4 | 5 | 3 | |||||||||||
7 | 9 | 4 |
接下来看第二行,当背包容量为0,1时,j < w[1],无法放入,背包内价值为0;当背包容量为2时,此时恰好可以放入第一件物品,包内价值为v[1]所表示的1。再往右看由于j都大于w[1],所以后面的内容都填1。
w[i] | v[i] | dp[i][j] | |||||||||||
---|---|---|---|---|---|---|---|---|---|---|---|---|---|
0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | |||
0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | ||
2 | 1 | 1 | 0 | 0 | 1 | 1 | 1 | 1 | 1 | 1 | 1 | 1 | 1 |
3 | 3 | 2 | |||||||||||
4 | 5 | 3 | |||||||||||
7 | 9 | 4 |
接下来再看第三行,当背包容量为2时,j < w[2],无法放入,背包内价值为上一次放入物品后的1;当背包容量为3时,可以放入重量分别为1、2的1号与2号物品。由于已经放了重量为2 (w[1])的物品,此时最多只能再放入重量为1的物品了。可是,如果将1号物品取出,放入2号物品的话,此时包内价值为更高的3。因此这里可以做一个分析:
-
当 j < w[i] 时,待分析的物品比背包容量要重,只能选择不拿。此时背包内的价值就是上一次操作后的背包内价值。
即: dp[i][j] = dp[i-1][j]
-
当 j >= w[i]时,待分析的物品比背包容量要轻,可以选择拿或者不拿。如果不拿,背包内的价值就是上一次操作后的背包内价值;如果要拿,此时背包容量就会减少w[i],需要查看上一次操作时容量为 j - w[i]时的最优解。
即: dp[i][j] = dp[i-1][j-w[i]] + v[i]
按照这个分析来看此时背包容量为 3 的情况,在放入1号物品后,如果不放入2号物品,此时包内的价值为1;如果选择放入2号物品,dp的值为 dp[1][3-3] + 3,结果为3。在 1 和 3 之间经过比较后选择最大值的方案 。
因此可以得出一个状态转移方程式 max { dp[i-1][j], dp[i-1][j-w[i]] + v[i] }
不难看到,在方程式中会出现[i-1]、[j - w[i]]的问题,所以如果我们在填表的时候从0开始计数可能会有报错,所以从1开始计数。
为了验证这个方程式,我们再来看看当容量为5时的最大价值为max { dp[2-1][5], dp[2-1][5-w[2]] + c[2] } = 4,符合逻辑
w[i] | v[i] | dp[i][j] | |||||||||||
---|---|---|---|---|---|---|---|---|---|---|---|---|---|
0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | |||
0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | ||
2 | 1 | 1 | 0 | 0 | 1 | 1 | 1 | 1 | 1 | 1 | 1 | 1 | 1 |
3 | 3 | 2 | 0 | 0 | 1 | 3 | 3 | 4 | 4 | 4 | 4 | 4 | 4 |
4 | 5 | 3 | |||||||||||
7 | 9 | 4 |
题解
接下来我们就可以按照这个方程式进行编码解题了。
#include<iostream>
#include<algorithm>
using namespace std;
int main(){
int m, n; // 背包容量为m 物品个数为n
int w[10];// 具体使用到到 n+1
int v[10];// 具体使用到到 n+1
int dp[20][20] = { 0 }; // 具体使用到到 m+1
cin >> m >> n;
for (int i = 1; i <= n; ++i) {
cin >> w[i] >> v[i];
}
for (int i = 1; i <= n; i++) {
for (int j = 1; j <= m; j++) {
if (j < w[i])
dp[i][j] = dp[i - 1][j];
else
dp[i][j] = max(dp[i - 1][j], dp[i - 1][j - w[i]] + v[i]);
}
}
// 输出动态规划解
for (int i = 0; i <= n; i++) {
for (int j = 0; j <= m; j++) {
cout << dp[i][j] << ' ';
}
cout << endl;
}
return 0;
}
通过滚动数组进行优化
现在可以考虑一下优化方面的问题,我们回到之前的表格可以看到,最新一行的数据只与他的上一行数据有关,当我们分析到3号物品的时候,只用考虑2号物品的那一行,也就是说我们可以在空间上进行优化,不需要记录所有的数据。因此可以考虑将二维数组压缩至一维数组,当一次计算完成后将最新的数据覆盖掉上一行操作中所产生的数据(滚动数组)。
创建一个dp[j]数组,分析完1号物品后进行填表操作,分析完2号后覆盖这一行的数据。
w[i] | v[i] | dp[i][j] | |||||||||||
---|---|---|---|---|---|---|---|---|---|---|---|---|---|
0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | |||
2 | 1 | 1 | 0 | 0 | 1 | 1 | 1 | 1 | 1 | 1 | 1 | 1 | 1 |
使用二维数组时的推导方程
if (j < w[i])
dp[i][j] = dp[i - 1][j];
else
dp[i][j] = max(dp[i - 1][j], dp[i - 1][j - w[i]] + v[i]);
使用一维数组时的推导方程
if (j >= w[i])
dp[j] = max(dp[j], dp[j - w[i]] + v[i]);
w[i] | v[i] | dp[i][j] | |||||||||||
---|---|---|---|---|---|---|---|---|---|---|---|---|---|
0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | |||
3 | 3 | 2 | 0 | 0 | 1 | 1 | 1 | 1 | 1 | 1 | 1 | 1 | 1 |
需要注意的是,现在改为了一维数组后,dp[j - w[i]]位于dp[j]的前方,每次推dp[j]的时候需要从后往前推。因为如果从前往后推的话上一次计算出来的dp[j]会被覆盖掉,而从后往前推可以恰好用到上一次的数据。可能这句话会有些绕口,结合二维数组再来看看。
w[i] | v[i] | dp[i][j] | |||||||||||
---|---|---|---|---|---|---|---|---|---|---|---|---|---|
0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | |||
0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | ||
2 | 1 | 1 | 0 | 0 | 1 | 1 | 1 | 1 | 1 | 1 | 1 | 1 | 1 |
3 | 3 | 2 | 0 | 0 | 1 | 3 | 3 | 4 | 4 | 4 | 4 | 4 | 4 |
4 | 5 | 3 | 0 | 0 | 1 | 3 | 5 |
在二维数组中进行判断是否要取物品放入背包时当前的值需要和dp[i-1][j-w[i]]进行判断(见黑体部分),可如果放在一维数组中从前往后判断的话,dp[2]所对应的值就会经过计算修改为0,导致后面无法得到正确解。因此内层循环需要从后往前推,简单来说,后面的值需要通过前面的值来进行判断,所以不能先修改前面的值。
#include<iostream>
#include<algorithm>
using namespace std;
int main(){
int m, n; // 背包容量为m 物品个数为n
int w[10];// 具体使用到到 n+1
int v[10];// 具体使用到到 n+1
int dp[20] = { 0 }; // 具体使用到到 m+1
cin >> m >> n;
for (int i = 1; i <= n; ++i) {
cin >> w[i] >> v[i];
}
for (int i = 1; i <= n; i++) {
for (int j = m; j >= 1; j--) {
if (j >= w[i])
dp[j] = max(dp[j], dp[j - w[i]] + v[i]);
}
for (int k = 0; k <= m; k++) {
cout << dp[k] << ' ';
}
cout << endl;
}
return 0;
}
完全背包问题
和0/1背包不同的是,在0/1背包中每个物品只能取一次,你只能选择取或者不取;而在完全背包中,某个物品可以取无数次。
题目背景
题目分析
从物品的数量来看,数量的下限为0,数量的上限为背包的最大容量。用当前背包的容量 j / w[i] 时便可以计算出某个物品最多可以取多少个。在0/1背包中我们这样计算dp:
for (int i = 1; i <= n; i++) {
for (int j = m; j >= 1; j--) {
if (j >= w[i])
dp[j] = max(dp[j], dp[j - w[i]] + v[i]);
}
for (int k = 0; k <= m; k++) {
cout << dp[k] << ' ';
}
cout << endl;
}
现在在这个二重循环中,可以再嵌套一层循环,用来表示某个物品可以被取的次数,这样写可以解出完全背包题目。
for (int i = 1; i <= n; i++) {
for (int j = m; j >= 1; j--) {
for (int k = 0; k <= j / w[i]; k++){
if (j >= w[i])
dp[j] = max(dp[j], dp[j - k * w[i]] + k * v[i]);
}
}
for (int k = 0; k <= m; k++) {
cout << dp[k] << ' ';
}
cout << endl;
}
优化
虽然说三重循环可以解出正确答案,但是这样时间复杂度太高了,需要找出一个更优解,首先还是进行填表。
w[i] | v[i] | dp[i][j] | |||||||||||
---|---|---|---|---|---|---|---|---|---|---|---|---|---|
0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | |||
0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | ||
2 | 1 | 1 | 0 | 0 | 1 | 1 | 2 | 2 | 3 | 3 | 4 | 4 | 5 |
3 | 3 | 2 | 0 | 0 | 1 | 3 | 3 | 4 | 6 | ||||
4 | 5 | 3 | |||||||||||
7 | 9 | 4 |
推出状态转移方程式,由于同一个物品可以选多次,即同一行内选取,状态转移方程式为 max { dp[i-1][j], dp[i][j-w[i]] + v[i] },使用和0/1背包中同样的方法,可以将二维dp压缩为一维数组 max {dp[j], dp[j - w[i]] + v[i]}。这个时候你可能会发现,在完全背包中优化后的方程式和0/1背包中的式子完全相同!没错,在0/1问题中新数据是和上一行的数据进行对照,完全背包是和本行的数据对照,当对i进行了压缩后,其余部分是完全相同的。不过,解题时这两者在循环遍历数组时有区别。
0/1背包解题用到的是上一条数据,用的是旧数据,所以需要从后往前遍历
for (int i = 1; i <= n; i++) {
for (int j = m; j >= 1; j--) {
if (j >= w[i])
dp[j] = max(dp[j], dp[j - w[i]] + v[i]);
}
}
完全背包解题使用的是新数据,所以需要从前往后遍历
for (int i = 1; i <= n; i++) {
for (int j = m; j >= 1; j--) {
if (j >= w[i])
dp[j] = max(dp[j], dp[j - w[i]] + v[i]);
}
}
题解
#include<iostream>
#include<algorithm>
using namespace std;
int main(){
int m, n; // 背包容量为m 物品个数为n
int w[10];// 具体使用到到 n+1
int v[10];// 具体使用到到 n+1
int dp[20] = { 0 }; // 具体使用到到 m+1
cin >> m >> n;
for (int i = 1; i <= n; ++i) {
cin >> w[i] >> v[i];
}
for (int i = 1; i <= n; i++) {
for (int j = 1; j <= m ; j++) { // 正向遍历一维数组
if (j >= w[i])
dp[j] = max(dp[j], dp[j - w[i]] + v[i]);
}
}
cout << "max=" << dp[m];
return 0;
}
其实还可以再简化一下代码,这里进行了一次 j >= w[i] 的判断,其实可以将判断放在循环的初始值中,直接从w[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]);
}
}