背包九讲
preface
卧槽,发现我的命名有问题,我把 \(v\) 定义成价值(\(value\)),没什么问题。但是我把 \(w\) 定义成体积,好像有点没道理啊。
事实上,\(v=volumn, w=weight\) 比较合适。
1. \(01\) 背包
2. 完全背包
3. 多重背包
每个物品最多可以选 K 个,多重背包有三种做法:暴力,二进制优化,优先队列优化(\(hard\)),我们只需要使用二进制优化即可,不要使用暴力!
Code
#include <iostream>
#include <cstring>
#include <algorithm>
using namespace std;
const int N = 2010, M = 1010 * 15;
int n, m, s; // s表示转换为01背包之后的物品个数
int f[N], w[M], v[M];
int main()
{
cin >> n >> m;
for(int i = 1; i <= n; i ++ )
{
int _w, _v, _s;
cin >> _w >> _v >> _s;
// 转换为 01 背包
int k = 1;
while(_s >= k)
{
w[s] = k * _w;
v[s] = k * _v;
s ++ ;
_s -= k;
k <<= 1;
}
if(k)
{
w[s] = _s * _w;
v[s] = _s * _v;
s ++ ; // 这里的 s++ 别忘了
}
}
for(int i = 0; i < s; i ++ )
for(int j = m; j >= w[i]; j -- )
f[j] = max(f[j], f[j - w[i]] + v[i]);
cout << f[m] << endl;
return 0;
}
4. 分组背包
有多组物品,每个组只能选一个物品
对于分组背包问题,有一个细节点,就是对于每个组内的物品,是放在体积之前遍历,还是放在体积之后遍历,还是都可以呢?
或者说,对于这两种遍历方式,是否会造成同一组内的物品选了多个?
// 1. 物品在体积之后
for 物品
for 体积
for 每组内的物品
/*-------------*/
// 2. 物品在体积之前
for 物品
for 每组内的物品
for 体积
答案是:
- 对于没有使用空间优化的版本,两种遍历方式都可以
- 对于使用空间优化的版本,每组内的物品的遍历顺序必须放在体积的后面
解释一下第 \(2\) 点,如果我们按照下面形式遍历:
for 物品 i
for 每组内的物品 k
for 体积 j
其实也没那么麻烦,如果先遍历每个组的物品,在遍历体积的话,那么 f[]
就会被该物品更新掉,更新掉就意味着我们选了该物品。
例如,f[10] = max(f[10], f[10-4] + 100);
更新了 f[10]
。
此时,该组内的下一个物品再次遍历到 f[10]
,它希望使用的是上一层的 f[10]
。
但是因为上一层的 f[10]
已经被之前的物品更新了,所以此时的 f[10]
就该层的 f[10]
。
但是它不不知道啊,它只负责 max
,因此它可能再次更新 f[10]
,这就导致,f[10]
包含了该组的两个物品。
那么为什么没有空间优化的版本可以颠倒顺序呢?这是因为它们显示指定了层,所以不会出现当前层的数据更新了上一层数据的情况。
而在空间优化版本中,如果我们先遍历体积,在遍历物品,那么我们必须制定体积从大到小遍历,因为 f[j-v]
会用到上一层的小体积。
另外,空间优化版本中,体积的遍历是从大到小的,这是因为状态转移需要用到上一层
f[i-1][]
的状态,而非当前层f[i][]
的状态。
5. 二维费用背包
背包有多个限制,通常只有一个限制[体积],现在多了一个限制[费用]
其实也普通背包没啥区别, 就是多了一层判断
// 2023/6/7
对于二维费用的背包问题,空间优化版本中,第三位费用不用倒叙枚举,在 \(Acwing\) 测试代码通过了。
其实二维费用和 \(01\) 背包都一样,都是借用上一层的状态转移,由于我们的体积是倒序枚举的,而费用那一维在体积之后,因此只需要倒序枚举体积即可确保状态是从上一层转移的。
这是因为我们得先枚举体积,再枚举费用,而体积都还没枚举过,费用的枚举顺序如何自然也就无关紧要了(因为它也肯定没没枚举)。
当然,要是不太理解,倒序枚举呗就。
Code
// 空间优化版本
#include <iostream>
#include <cstring>
#include <algorithm>
using namespace std;
const int N = 1010;
int n, m1, m2;
int w1[N], w2[N], v[N];
int f[N][N];
int main()
{
cin >> n >> m1 >> m2;
for(int i = 1; i <= n; i ++ )
cin >> w1[i] >> w2[i] >> v[i];
for(int i = 1; i <= n; i ++ )
for(int j = m1; j >= w1[i]; j -- )
for(int k = m2; k >= w2[i]; k -- )
f[j][k] = max(f[j][k], f[j - w1[i]][k - w2[i]] + v[i]);
cout << f[m1][m2] << endl;
return 0;
}
6. 混合背包
混合背包
混合背包问题的思路很简单,既然有多种类型的背包,那么我们分类讨论即可。
麻烦的点在于,对于不同的背包模型,我们的存放数据的类型是不一样的,例如,存放分组背包和普通背包的体积的价值的数组,一个是一位数组,一个是二维数组。另外,我们还需要存储 \(flag\) 来指示,当前背包是何种类型的。
为了消除这些差异,我们当然可以既分配一个一维数组,又分配一个二维数组,但这样对空间的消耗未免也太大了!
其实我们有更好的方法,就是在输入体积和价值之后,直接 \(dp\),而不是等到输入完所有体积和价值。
这一方法对于所有背包模型都是适用的。
Code
#include <iostream>
#include <cstring>
#include <algorithm>
using namespace std;
const int N = 1010, M = 10010;
int n, m;
int f[N];
int _v[M], _w[M];
int main()
{
cin >> n >> m;
for(int i = 1; i <= n; i ++ )
{
int w, v, s;
cin >> w >> v >> s;
if(s == 0) // 完全背包
{
for(int j = w; j <= m; j ++ )
f[j] = max(f[j], f[j - w] + v);
}
else if(s > 0) // 多重背包
{
int c = 1, k = 1;
while(s >= k)
{
_v[c] = k * v;
_w[c] = k * w;
s -= k;
k <<= 1;
c ++ ;
}
if(s)
{
_v[c] = s * v;
_w[c] = s * w;
s -= k;
c ++ ;
}
for(int u = 1; u <= c; u ++ )
for(int j = m; j >= _w[u]; j -- )
f[j] = max(f[j], f[j - _w[u]] + _v[u]);
}
else if(s == -1)// 01背包
{
for(int j = m; j >= w; j -- )
f[j] = max(f[j], f[j - w] + v);
}
else
{
cout << "Error input: " << s << endl;
exit(0);
}
}
cout << f[m] << endl;
return 0;
}
7. 有依赖的背包
选物品\(A\),就必须选物品\(B\),此谓依赖
这种类型的背包问题比较特殊,其余八个都是线性 \(dp\),而这个却是树状 \(dp\)。这是因为在这种背包模型中,天然存在树形结构。
由于 \(dp\) 的类型变了,\(f[i][j]\) 的含义也要变了。
在这里,我们规定,\(f[root][m]\) 表示以 \(root\) 为根的子树,体积为 \(m\) 时的最大价值。
在求解时,有点像分组背包问题,因为我们没有制定层,所以,组的遍历必须在体积之后。
Code
#include <iostream>
#include <cstring>
#include <algorithm>
using namespace std;
const int N = 110, M = 210;
int n, m;
int v[N], w[N], root;
int h[N], e[M], ne[M], idx;
int f[N][N];
void add(int a, int b)
{
e[idx] = b, ne[idx] = h[a], h[a] = idx ++ ;
}
void dfs(int u)
{
// 自己肯定是要选的,因为根节点肯定是第一次遍历到,因此不用取 max
for(int j = v[u]; j <= m; j ++ ) f[u][j] = w[u];
for(int i = h[u]; i != -1; i = ne[i]) // 组
{
int son = e[i];
dfs(son); // 递归处理子节点,保证最优解
for(int j = m; j >= v[u]; j -- ) // 体积
for(int k = 0; k <= j - v[u]; k ++ ) // 根据划分给子节点的体积来分组
f[u][j] = max(f[u][j], f[u][j - k] + f[son][k]);
}
}
int main()
{
memset(h, -1, sizeof h);
cin >> n >> m;
for(int i = 1; i <= n; i ++ )
{
int fa;
cin >> v[i] >> w[i] >> fa;
if(fa == -1) root = i;
else add(fa, i);
}
dfs(root);
cout << f[root][m] << endl;
return 0;
}
8. 求方案数
背包问题求方案数
方案数的变化取决于最大价值的变化
Code
#include <iostream>
#include <cstring>
#include <algorithm>
using namespace std;
const int mod = 1e9 + 7;
const int N = 1010;
int n, m;
int f[N], v[N], w[N];
int cnt[N]; // 保存方案
int main()
{
cin >> n >> m;
for(int i = 1; i <= n; i ++ ) cin >> w[i] >> v[i];
for(int i = 0; i <= m; i ++ ) cnt[i] = 1; // 什么都不选也是一种方案
for(int i = 1; i <= n; i ++ )
{
for(int j = m; j >= w[i]; j -- )
{
// 原本是:
// f[j] = max(f[j], f[j - w[i]] + v[i]);
int t = f[j - w[i]] + v[i];
if(t > f[j])
{
cnt[j] = cnt[j - w[i]];
f[j] = f[j - w[i]] + v[i];
}
else if(t == f[j])
{
cnt[j] = (cnt[j] + cnt[j - w[i]]) % mod;
}
}
}
cout << cnt[m] << endl;
return 0;
}
9. 求具体方案
背包问题求方案
方案的变化取决于最大价值的变化
Code
// 按顺序遍历得到的就是最小字典序的方案
#include <iostream>
#include <cstring>
#include <algorithm>
#include <vector>
using namespace std;
const int N = 1010;
int n, m;
int f[N], v[N], w[N];
vector<int> path[N]; // path[i] 表示体积为i时,最大价值的最小字典序方案
int main()
{
cin >> n >> m;
for(int i = 1; i <= n; i ++ ) cin >> w[i] >> v[i];
for(int i = 1; i <= n; i ++ )
{
for(int j = m; j >= w[i]; j -- )
{
int t = f[j - w[i]] + v[i];
if(t > f[j])
{
path[j] = path[j - w[i]];
path[j].push_back(i);
f[j] = f[j - w[i]] + v[i];
}
else if(t == f[j])
{
auto p = path[j - w[i]];
p.push_back(i);
if(p < path[j]) path[j] = p;
}
}
}
for(auto &x : path[m]) cout << x << ' ';
cout << endl;
return 0;
}