背包问题
Contents
多重背包
01 背包中每个物品只有一个,而多重背包中每个物品有 $s_i$ 个。
多重背包二进制优化
对于第 $i$ 个物品,如果它有 $s_i$ 个,就将其用二进制拆分,然后转成 01 背包。
如 $13 = 1 + 2 + 4 + 6$。
注意拆分时,拆出来的物品也要修改它的价值。
复杂度:$O(W \sum\limits_{i=1}^n \log s_i)$,其中 $W$ 为背包容量。
代码
int n, V;
int dp[2005];
void pack01(int v, int w) {
for (int j = V; j >= v; j--) {
dp[j] = max(dp[j], dp[j-v] + w);
}
}
int main() {
cin >> n >> V;
while (n--) {
int v,w,s; cin >> v >> w >> s;
int k = 1;
while (k <= s) {
pack01(v*k, w*k);
s -= k;
k <<= 1;
}
pack01(v*s, w*s);
}
int ans = 0;
for (int j = 0; j <= V; j++) ans = max(ans, dp[j]);
cout << ans << endl;
}
多重背包(单调队列优化)
设 $f_j$ 为装容量为 $j$ 的最大价值,$i$ 为当前考虑的物品,有 $s_i$ 个。
暴力跑多重背包的转移方程是:
$$f_j = \max\limits_{k \in [0,s_i]} f_{j-kw_i} + kv_i$$
这显然是一个滑动窗口的模版,只不过这个窗口是按同余类来的,比如 $j=10$,而 $w_i=3$,$s_i=2$,那么转移 $f_{10}$ 时由 $f_7, f_4$ 来转移。
于是枚举同余类,然后对于每一个同余类都用滑动窗口来优化即可。
复杂度:$O(nW)$,其中 $n$ 为物品种类数。
代码
struct node {
int pos, val;
} q[20005];
int head = -1, tail = 0, n, V, dp[20005];
void solve(int v, int w, int s) {
for (int j = 0; j < v; j++) {
head = 0, tail = -1;
q[++tail] = {0,0};
for (int i = 1; i*v + j <= V; i++) {
while (i - q[head].pos > s) head++;
int cur = i*v + j;
int val = dp[cur] - i*w;
dp[cur] = max(dp[cur], q[head].val + i*w);
while (head <= tail && val >= q[tail].val) tail--;
q[++tail] = {i, val};
}
}
}
int main() {
cin >> n >> V;
for (int i = 1; i <= n; i++) {
int v,w,s; cin >> v >> w >> s;
solve(v,w,s);
}
int ans = 0;
for (int j = 0; j <= V; j++) ans = max(ans, dp[j]);
cout << ans << endl;
}
分组背包
01背包的变形,每一个物品只有一个,但它属于某个组,一个组内只能选最多一个物品。
只要对每一组来跑 01 背包即可。
代码
int dp[1005];
int W, n;
vector<pii> adj[105];
void pack01(int k) {
for (int w = W; w >= 0; w--) {
for (auto [c, v] : adj[k]) {
if (w >= c) dp[w] = max(dp[w], dp[w-c] + v);
}
}
}
int main() {
cin >> W >> n;
for (int i = 1; i <= n; i++) {
int w, v, k; cin >> w >> v >> k;
adj[k].push_back({w, v});
}
for (int k = 1; k <= 100; k++) pack01(k);
cout << dp[W] << endl;
}
背包删除元素
众所周知,背包内加入一个元素是(这里 dp[i]
代表能够装重量和为 $i$ 的物品的方案数):
for (int i = N; i >= w; i--) dp[i] += dp[i - w];
而背包删除一个元素其实也可以反过来做:
for (int i = w; i <= N; i++) dp[i] -= dp[i - w];
• 注意这里的枚举顺序,在删除的时候要保证删除时,用的是加入前的状态,所以是从小到大删。
例题
例1 Google Kickstart 2022 RoundH D. Level Design
题意
给定一个长度为 $n$ 的permutation。
如果把permutation看作一个图(第 $i$ 位的值是 $p_i$ 的话,那么有 $i \rightarrow p_i$)。
我们可以对这个 permutation 进行任意次数的 swap 操作,每次交换任意的 $p_i, p_j$ 的位置。
现在对于每一个 $k \in [1,n]$,回答:
最少需要多少次 swap 操作,使得,得到的图中至少有一个大小为 $k$ 的cycle。
其中,$n \leq 10^5$,每个回答各自独立。
题解
首先经过一些观察,可以发现:
使用 $1$ 次操作,可以让一个cycle合并到另外一个cycle上去,或者也可以将一个大的 cycle 拆成 $2$ 个小cycle。
所以我们先用 DFS 求出所有 cycle 的大小。
然后就是一个背包问题:
有 $n$ 个物品,每个物品有一个大小 $a_i$,并且每次装到背包中有一个 cost $1$。
也可以将一个物品分成两个更小的物品,分割的 cost 也为 $1$。
对于每一个可能的背包大小 $k \in [1,n]$,求最少的cost使得这个背包刚好被装满?
注意到分割操作只有最多一次,因为可以先装背包,将背包装多一点,然后分掉一个使得背包刚好装满。
那装背包的话,物品数量太多了怎么办?
想到 多重背包的二进制背包优化,那个是将大的拆成小的。我们也可以将小的合成为大的。
比如有 $3$ 个大小为 $1$ 的物品,每个cost均为 $1$,我们就可以将其中 $2$ 个合成为一个大小为 $2$,cost为 $2$ 的大物品。
所以对于大小为 $x$,cost为 $c$ 的物品,如果它有 $\geq 3$ 个,就将其中 $2$ 个合成为大小为 $2x$,cost为 $2c$ 的物品。
时间复杂度:所有物品的大小之和为 $n$,所以物品大小的种类数只有 $O(\sqrt n)$ 个。在合并之后,物品的数量不会超过 $O(\sqrt n * \log(10^5))$ 个。
所以就可以跑背包了,复杂度为 $O(n \sqrt n)$。
• 注意背包枚举的时候,外层循环是物品,里层循环是大小。
• 注意到装第一个物品的 cost 为 $0$,这个直接用 dp[0] = -1
来表示。
// 背包枚举顺序:外层是物品,里层是大小
for (pii a : item_vec) {
for (int i = n; i >= 1; i--) {
int item_sz = a.first, cost = a.second;
if (item_sz > i) break;
dp[i] = min(dp[i], dp[i-item_sz] + cost);
}
}
代码
#include <bits/stdc++.h>
using namespace std;
const int maxn = 1e5+5;
const int maxm = 1e8+5;
int T;
int n, p[maxn];
bool vis[maxn];
// int cnt[maxn];
map<int, int> items[maxn]; // items[i]: 大小为 i 的所有物品的 cost (key: cost, value: cnt)
vector<pii> item_vec;
int dp[maxn];
int main() {
cin >> T;
for (int t = 1; t <= T; t++) {
cin >> n;
item_vec.clear();
for (int i = 1; i <= n; i++) {
cin >> p[i];
vis[i] = 0, items[i].clear(), dp[i] = 1e9;
}
dp[0] = -1;
for (int i = 1; i <= n; i++) {
if (!vis[i]) {
int sz = 0;
int u = i;
do {
vis[u] = 1;
sz++;
u = p[u];
} while (!vis[u]);
items[sz][1]++;
}
}
for (int i = 1; i <= n; i++) {
for (auto& itr : items[i]) {
int cost = itr.first;
while (itr.second >= 3) {
itr.second -= 2;
items[i*2][cost*2] += 1;
}
}
}
for (int i = 1; i <= n; i++) {
for (auto itr : items[i]) {
int cost = itr.first, cnt = itr.second;
while (cnt--) {
item_vec.push_back({i, cost}); // 大小, cost
}
}
}
// 背包枚举顺序:外层是物品,里层是大小
for (pii a : item_vec) {
for (int i = n; i >= 1; i--) {
int item_sz = a.first, cost = a.second;
if (item_sz > i) break;
dp[i] = min(dp[i], dp[i-item_sz] + cost);
}
}
int mn = 1e9;
// 考虑拆分物品(最多一次)
for (int i = n; i >= 1; i--) {
dp[i] = min(dp[i], mn + 1);
mn = min(mn, dp[i]);
}
cout << "Case #" << t << ": ";
for (int i = 1; i <= n; i++) cout << dp[i] << " ";
cout << endl;
}
}
例2 CF95E Lucky Country
题意
给定 $n$ 个点,$m$ 条边的无向图。
每次操作可以加一条边。
求最少的操作次数,使得其中一个联通块的大小为 $x$,且 $x$ 只包含 $4,7$ 这两个字符。
其中,$n,m \leq 10^5$,如果操作不存在输出 $-1$。
题解
把联通块大小看作数字,就是每次操作合并两个数字,看怎么样合并出一个 $x$ 只包含 $4,7$ 这两个字符。
一眼多重背包,但数据范围太大了。
再次注意到所有数字的和为 $n$(多重背包二进制的经典老套路),所以不同的数字种类最多为 $O(\sqrt n)$ 个。
于是拿二进制优化一下多重背包就可以 $O(n \sqrt n \log(10^5))$ 跑出来了(实际上复杂度上界很松)。
• 注意二进制合并物品的时候,也要增加相应的 cost。
代码
#include <bits/stdc++.h>
using namespace std;
const int maxn = 1e5+55;
const int maxm = 4e4+55;
bool isLukcy(int x) {
while (x) {
int r = x % 10;
x /= 10;
if (r != 4 && r != 7) return 0;
}
return 1;
}
int dp[maxn], par[maxn], sz[maxn];
int finds(int u) {
if (par[u] == u) return u;
return par[u] = finds(par[u]);
}
void unions(int u, int v) {
u = finds(u), v = finds(v);
if (u == v) return;
if (sz[u] < sz[v]) swap(u,v);
sz[u] += sz[v], sz[v] = 0;
par[v] = u;
}
int n, m, cnt[maxn];
int main() {
cin >> n >> m;
for (int i = 1; i <= n; i++) par[i] = i, sz[i] = 1;
for (int i = 1; i <= m; i++) {
int u,v; cin >> u >> v;
unions(u,v);
}
vector<int> vec;
for (int i = 1; i <= n; i++) {
if (par[i] == i) vec.push_back(sz[i]);
}
for (int x : vec) cnt[x]++;
vector<pii> res;
for (int i = 1; i <= 100000; i++) {
if (cnt[i]) {
for (int j = 1; j <= cnt[i]; j *= 2) {
cnt[i] -= j;
res.push_back({j * i, j});
}
if (cnt[i]) res.push_back({cnt[i] * i, cnt[i]});
}
}
memset(dp, 63, sizeof(dp));
dp[0] = 0;
for (auto [x, c] : res) {
for (int i = 100000; i >= x; i--) {
dp[i] = min(dp[i], dp[i-x] + c);
}
}
int ans = 1e9;
for (int i = 1; i <= n; i++) {
if (isLukcy(i)) {
ans = min(ans, dp[i]);
}
}
cout << ((ans > 1e8) ? -1 : ans - 1) << endl;
}
例3 The Knapsack problem
题意
给定 $n$ 个物品,第 $i$ 个物品的大小为 $w_i$,价值为 $c_i$,每个物品有无数个。
给定一个大小为 $S$ 的背包,求最大价值。
其中,$n,w_i \in [1,10^3], S,c_i \in [0,10^9]$。
题解
完全背包,但是大小为 $10^9$?
不过我们可以发现:一个大小为 $S$ 的背包的最优解肯定能分成两个大小为 $S_1,S_2$ 的背包,使得:
- $S_1 + S_2 = S$,且
- $|S_1-S_2| \leq \max w_i \leq 1000$
因为如果 $|S_1-S_2| > 1000$ 的话,那么将大的那个背包,拿出一个元素塞给小的背包,则两者之差会减去 $2000$,这样就会保证 $|S_1-S_2| < 1000$ 了。
所以要求大小为 $S$ 的背包,不妨分治解决 $S_1,S_2$ 大小的背包,其中
$$S_1, S_2 \in [\frac{S}{2} - 1000, \frac{S}{2} + 1000]$$
这样就可以一直分治下去,我们预处理出来 $4000$ 以内的答案,更大的用分治来解决即可。
• 因为时限只有 1 秒,所以不能用递归。但是注意到每一层需要求出的背包大小是一个连续的区间,如第一层是 $[S,S]$,第二层是 $[\frac{S}{2} - 1000, \frac{S}{2} + 1000]$,每一层都是根据上一层的左右端点 / 2 以后往外延伸。
这样就不用递归,直接一层一层的处理即可。
代码
#include <bits/stdc++.h>
using namespace std;
const int maxn = 1e4+5;
int M = 1000;
int n, S, w[maxn], c[maxn];
ll dp[maxn];
vector<ll> dp2[30];
int L[30], R[30];
int main() {
cin >> n >> S;
for (int i = 1; i <= n; i++) cin >> w[i] >> c[i];
for (int i = 1; i <= n; i++) {
for (int j = w[i]; j <= 4*M; j++) {
dp[j] = max(dp[j], dp[j-w[i]] + c[i]);
}
}
L[1] = S, R[1] = S;
int d;
for (d = 2; ; d++) {
L[d] = max(0, L[d-1] / 2 - M), R[d] = min(S, (R[d-1] + 1) / 2 + M);
// 注意这里是 `R[d] = (R[d-1] + 1)`,保证 R[d] 不会因为整除的问题而导致 dp2[d] 中数量不够
if (R[d] <= 4*M) break;
}
for (; d >= 1; d--) {
for (int j = L[d]; j <= R[d]; j++) {
if (j <= 4*M) dp2[d].push_back(dp[j]);
else {
ll res = 0;
for (int k = max(L[d+1], j/2 - M); k <= min(R[d+1], j/2 + M); k++) {
int i1 = k - L[d+1], i2 = j - k - L[d+1];
res = max(res, dp2[d+1][i1] + dp2[d+1][i2]);
}
dp2[d].push_back(res);
}
}
}
cout << dp2[1][0] << endl;
}