背包 DP

changelog:
2022.8.29,添加了一道题目,主体还没有动工。
2022.10.24,主体开始动工。

如无特殊说明,本文中 \(v\) 表示 volume, \(w\) 表示 weight

1 经典背包模型

1.1 01 背包模型

\(dp_{i,j}\) 表示前 \(i\) 个物品取 \(j\) 的体积获得的最大收益。则有:

\[dp_{i,j} = \max \left\{ \begin{array}{ll} dp_{i-1, j} \\ dp_{i-1, j - w_i} + v_i \end{array} \right. \]

倒序枚举 \(j\) 滚掉第一维,时间复杂度 \(O(nm)\)

#include<bits/stdc++.h>
using namespace std;
#define int long long
#define f(i, a, b) for(int i = (a); i <= (b); i++)
#define cl(i, n) i.clear(),i.resize(n);
#define endl '\n'
typedef long long ll;
typedef unsigned long long ull;
typedef pair<int, int> pii;
const int inf = 1e9;
void cmax(int &x, int y) {if(x < y) x = y;}
void cmin(int &x, int y) {if(x > y) x = y;}
int dp[100010], v[110], w[110];
signed main() {
    ios::sync_with_stdio(0);
    cin.tie(NULL);
    cout.tie(NULL);
    time_t start = clock();
    //think twice,code once.
    //think once,debug forever.
    int n, k; cin >> n >> k;
    f(i, 1, n) cin >> w[i] >> v[i];
    f(i, 1, n) {
        for(int j = k; j >= 0; j--) {
            if(j - w[i] >= 0) cmax(dp[j], dp[j - w[i]] + v[i]);
        }
    }
    int ans = 0;
    f(i, 1, k) ans = max(ans, dp[i]);
    cout << ans<<endl;
    time_t finish = clock();
    //cout << "time used:" << (finish-start) * 1.0 / CLOCKS_PER_SEC <<"s"<< endl;
    return 0;
}

1.2 完全背包

每一件物品可选若干件。
01 背包的模板正序枚举即可。

1.3 多重背包

\(i\) 件物品最多选 \(c_i\) 件。
有三种做法,时间复杂度逐渐降低:

暴力拆分法
将每个物品拆分成 \(c_i\) 件,然后 01 背包。
时间复杂度 \(O(n \sum c_i)\)

二进制拆分法
考虑物品拆成若干件之后仍可以组合成任何个数的原来的物品,并且不能组合成其他个数的物品。也就是拆成 \(2^0, 2^1, ..., 2^j, c_i - 2^{j+1} - 1\)
时间复杂度 \(O(n \log \sum c_i)\)

单调队列优化
单调队列可以优化的是决策区间单调移动的转移。考虑转移方程 \(dp_j \leftarrow \max \limits_{1 \le k \le c_i}\{dp_{j - k \times w_i} + k \times v_i\}\)
虽然这样看看不出可以单调队列优化的样子,但是我们画出决策点的位置看看:
image
可以发现,在模 \(V_i\) 同余的意义下,不同余的状态之间不会发生任何转移:
image
而同余的状态下,是明显的单调队列可以做的转移:
image
因此我们使用单调队列优化转移。
注意依然要倒序枚举!
其时间复杂度为 \(O(nm)\),也就是单层 \(i\) 循环中每个元素只会被入队和出队两次。

#include<bits/stdc++.h>
using namespace std;
#define int long long
#define f(i, a, b) for(int i = (a); i <= (b); i++)
#define cl(i, n) i.clear(),i.resize(n);
#define endl '\n'
typedef long long ll;
typedef unsigned long long ull;
typedef pair<int, int> pii;
const int inf = 1e9;
void cmax(int &x, int y) {if(x < y) x = y;}
void cmin(int &x, int y) {if(x > y) x = y;}
int n, m; 
int c[110], w[110], v[110];
int dp[100010];
int calc(int i, int u, int k) {
    //i,u 是常数,calc 是单调队列排序的关键字。
    return dp[u + k * v[i]] - k * w[i];
}
int q[210];
signed main() {
    ios::sync_with_stdio(0);
    cin.tie(NULL);
    cout.tie(NULL);
    time_t start = clock();
    //think twice,code once.
    //think once,debug forever.
    cin >> n >> m;
    f(i, 1, n) cin >> c[i] >> v[i] >> w[i];
    memset(dp, 0xcf, sizeof dp);
    dp[0] = 0;
    f(i, 1, n) {
        f(u, 0, v[i] - 1) {
            //模 v[i] 的余数类
            int l = 1, r = 0; //单调队列,建议手写,不要用 deque
            //倒序枚举
            int maxp = (m - u) / v[i]; //类中的最大数的下标,下标等于数/v[i]。
            //插入最初候选集合
            for(int k = maxp - 1; k >= max(0ll, maxp - c[i]); k--) {
                while(l <= r && calc(i, u, q[r]) <= calc(i, u, k)) r--;
                q[++r] = k;
            }
            //倒序枚举状态
            for(int p = maxp; p >= 0; p--) {
                while(l <= r && q[l] > p - 1) l++;
                //队列非空的话取队首决策
                if(l <= r) cmax(dp[u + p * v[i]], calc(i, u, q[l]) + p * w[i]);
                //插入新决策,维护队尾单调性
                if(p - c[i] - 1 >= 0) {
                    while(l <= r && calc(i, u, q[r]) <= calc(i, u, p - c[i] - 1)) r--;
                    q[++r] = p - c[i] - 1;
                }
            }
        }
    }
    int ans = 0;
    f(i, 1, m) {
        cmax(ans, dp[i]);
    }
    cout << ans << endl;
    time_t finish = clock();
    //cout << "time used:" << (finish-start) * 1.0 / CLOCKS_PER_SEC <<"s"<< endl;
    return 0;
}

评价:二进制拆分法还是比较好写的。

Jury Compromise

题意:有 \(n\) 个人,每个人有得分 \(a_i,b_i\)。需要选择 \(m\) 个人,使得最小化 \(|\sum \limits_{j} a_j - \sum \limits_{j} b_j|\)。在此基础上,最大化 \(a_j + b_j\)
\(n \le 200, m \le 20\)

分析:新定义“体积”和“价值”的好题。定义第 \(i\) 个人的体积为 \(a_i - b_i\)(这里不可以设为绝对值,不好转移),价值为 \(a_j + b_j\)。答案也就是 \(|a_i - b_i|\) 最小的时候 \(a_j + b_j\) 最大的一个。

由此可以看出来,\(dp\) 相当于记录了一个背包,并去重了本题意义上“等价”的东西。记录了之后我们再在背包里取用所需即可。

CF1716D

题意:给定 \(n,k\),可以从 \(0\) 开始,第一次加 \(k\) 的若干倍,第二次加 \(k+1\) 的若干倍...一直加到 \(n\)。对于 \(n=1,2,...,n\) 求方案数。
\(1 \le k \le n \le 2 \times 10^5\)

分析:是一道背包问题。对于 \(k,k+1,k+2,...,k+632\)(当 \(n=2 \times 10^5,k=1\) 时有上界 \(632\))取前一段物品,每一种物品可以取 \(1,...,\inf\) 个。是完全背包,但是需要加一个“强制跳转”的操作。

\(dp[i][j]\) 为只使用前 \(i\) 件物品(每一件都必须至少用一个)到达 \(j\) 的方案数。那么有如下转移:(先是像 01 背包那样强制(即覆盖之前的)往前跳一次,然后是完全背包)

    dp[0]=1;
    f(j,k,k+632){
        for(int i = n; i >= 0; i--) {
            if(i >= j) dp[i] = dp[i - j];
            else dp[i] = 0;
        }
        f(i,1,n){
            if(i-j<0)continue;
            dp[i]=dp[i]+dp[i-j]; dp[i] %= mod;
            ans[i] += dp[i]; ans[i] %= mod;
        }
    }

时间复杂度 \(O(n \sqrt n)\)\(632 \sim \sqrt n\)

P6567 [NOI Online #3 入门组] 买表

题意:
Jimmy 到 Symbol 的手表店买手表,Jimmy 只带了 \(n\) 种钱币,第 \(i\) 种钱币的面额为 \(k_i\) 元,张数为 \(a_i\) 张。Symbol 的店里一共有 \(m\) 块手表,第 \(i\) 块手表的价格为 \(t_i\) 元。

Symbol 的手表店不能找零,所以 Jimmy 只能在凑出恰好的钱数时才能购买一块手表。现在对于店里的每块手表,Jimmy 想知道他能不能凑出恰好的钱数进行购买。

分析:
依然是先构建好背包,然后对于询问要什么给什么。

\(dp_i\) 表示能不能凑齐 \(i\) 元。是一个多重背包,体积就是面额。

这里采用二进制优化变成 01 背包,但是是 \(2000 \times 500000\) 的,爆炸了。没事,用个 bitset 大法。

#include<bits/stdc++.h>
using namespace std;
#define int long long
#define f(i, a, b) for(int i = (a); i <= (b); i++)
#define cl(i, n) i.clear(),i.resize(n);
#define endl '\n'
typedef long long ll;
typedef unsigned long long ull;
typedef pair<int, int> pii;
const int inf = 1e9;
void cmax(int &x, int y) {if(x < y) x = y;}
void cmin(int &x, int y) {if(x > y) x = y;}
int cnt = 0;
int k[210], a[210], v[2010]; 
bitset<500010> dp;
signed main() {
    ios::sync_with_stdio(0);
    cin.tie(NULL);
    cout.tie(NULL);
    time_t start = clock();
    //think twice,code once.
    //think once,debug forever.
    int n, m; cin >> n >> m;
    f(i, 1, n) {
        cin >> k[i] >> a[i];
        int pow2 = 1;
        f(j, 0, 20) {
            if(pow2 <= a[i] && pow2 * 2 > a[i]) {
                v[++cnt] = (a[i] - pow2 + 1) * k[i];
                break;
            }
            else {
                v[++cnt] = pow2 * k[i];
                pow2 *= 2;
            }
        }
    }
    dp[0] = 1;
    f(i, 1, cnt) {
        dp = dp | (dp << v[i]);
    }
    f(i, 1, m) {
        int t; cin >> t;
        if(dp[t]) cout << "Yes\n";
        else cout << "No\n";
    }
    time_t finish = clock();
    //cout << "time used:" << (finish-start) * 1.0 / CLOCKS_PER_SEC <<"s"<< endl;
    return 0;
}

dp_e

题意:01 背包。
\(n \le 100, V \le 10^9, w_i \le 10^3\)

分析:
正常做是 \(O(nV)\),肯定会 T。
但是要有一个交换体积和权重的意识。毕竟体积就是权重,权重就是体积。
这道题把体积当成权重来看,把权重当成体积来看。
于是是 \(O(n^2 w_i)\) 的,能过。

#include<bits/stdc++.h>
using namespace std;
#define int long long
#define f(i, a, b) for(int i = (a); i <= (b); i++)
#define cl(i, n) i.clear(),i.resize(n);
#define endl '\n'
typedef long long ll;
typedef unsigned long long ull;
typedef pair<int, int> pii;
const int inf = 1e9;
void cmax(int &x, int y) {if(x < y) x = y;}
void cmin(int &x, int y) {if(x > y) x = y;}
int dp[1000010];
int w[110], v[110];
int maxv = 100000;
signed main() {
    ios::sync_with_stdio(0);
    cin.tie(NULL);
    cout.tie(NULL);
    time_t start = clock();
    //think twice,code once.
    //think once,debug forever.
    int n, m; cin >> n >> m;
    f(i, 1, n) cin >> w[i] >> v[i];
    memset(dp, 0x3f, sizeof(dp));
    dp[0] = 0;
    f(i, 1, n) {
        for(int j = maxv; j >= 0; j--){
            if(j - v[i] >= 0) cmin(dp[j], dp[j - v[i]] + w[i]);
        }
    }
    for(int j = maxv; j >= 0; j--) if(dp[j] <= m) {
        cout << j << endl;
        break;
    }
    time_t finish = clock();
    //cout << "time used:" << (finish-start) * 1.0 / CLOCKS_PER_SEC <<"s"<< endl;
    return 0;
}

2 背包优化

上次在看 He_Ren 的代码时候发现一个很重要的优化:dp 的时候记录目前背包体积和,然后只 dp 到这里,这个优化对常数的影响很大。

3 树上背包

CF440D Berland Federalization

一个大小为 \(n\) 的树,划分成若干个连通块,使得至少一个连通块包含恰好 \(k\) 个点,最小化块间的边数。

\(n,k \le 400\)


关于树上若干个连通块的问题可以考虑树形背包。

注意到只需要选出一个 \(k\) 点连通块,剩下的块就不割了。于是 \(dp_{i, j}\) 表示某几个连通块以 \(i\) 为根,\(i\) 所在的连通块大小为 \(j\),对于枚举的每一个儿子转移从 \(dp_{son, x}, dp_{i, y}\) 转移。

注意树形背包过程中 \(dp_{i, *}\) 可能会相互影响,所以比较好的方式是记录一个 \(tmp\) 数组,表示加入了这个子树之后 \(dp\) 数组的变化。待这个点全部转移完之后,再将 \(tmp\) 合并到 \(dp\) 中。

时间复杂度是 \(O(n^2)\) 的,需要记录方案,这题限制宽松可以直接 dp 里面带个 vector \(O(n^3)\) 记录,也可以使用传统的方法。

树上背包的输出方案,同样是对于每一个取用状态,判断是从哪一个状态转移来的(值等于那个状态转移过来的值)。但是问题是按照之前的记录方法,后面的会影响前面的 \(dp\) 值。应该怎么做呢?

可以换一种更好的方式记录 \(dp\) 值:\(dp'_{i,j,k}\) 表示算完 \(j\) 个儿子之后 \(dp_{i,k}\) 的值。为了保证时间复杂度,开不定长数组,那么状态数就等于原来的转移数 \(O(n^2)\)。同时这样就可以保留所有信息了。

还原的时候,考虑过程 \(\tt{getans(i,x)}\) 表示 \(dp_{i,x}\) 选的是哪 \(x\) 个点。倒序枚举儿子,注意找到一个符合条件的 \((j,r)\) 之后 \(x \rightarrow x-r\),递归到 \(\tt{getans(j,r)}\),并且 break\(j\) 的枚举。

本题转移还有其独特的一个可以学习的地方:这题记录的是连通块到外部的边数,我们可以不记录其根到父亲的那一条边,只记录子树内的边;初始时认为每个儿子都和自己断开,转移的时候如果连上一个儿子那么代价 \(-1\)。灵活设置使得转移变得简单,也是树上问题一个很重要的点。

要点:

  • 一开始让 \(g\) 不包含父亲是个很好的选择。
  • 注意倒序枚举。
  • 注意 break。
  • 注意 resize 写的对不对,不对的话可能会复杂度假掉。
vector<vector<int>> dp[401]; vector<int> g[401]; int sz[401]; 
int e[401][401]; vector<int> edge; pair<int, int> pre[401][401]; 
int ans=inf; bool vis[401];  int n, k; int lst; int anc[401]; 
void dfs(int now) {
    sz[now]++; dp[now].resize(g[now].size() + 1); dp[now][0].resize(sz[now] + 1); 
    dp[now][0][1]=g[now].size(); int cnt = 0; 
    for(int i : g[now]){
        dfs(i); dp[now][++cnt].resize(sz[now] + sz[i] + 1); fill(dp[now][cnt].begin(), dp[now][cnt].end(), inf); 
        f(x, 1, sz[now]) dp[now][cnt][x] = dp[now][cnt - 1][x];
        f(x, 1, sz[i]) f(y, 1, sz[now])
            if(dp[now][cnt][y+x] > dp[now][cnt - 1][y] + dp[i][g[i].size()][x] - 1) {
                dp[now][cnt][y+x] = dp[now][cnt - 1][y] + dp[i][g[i].size()][x] - 1; 
            }
        sz[now] += sz[i]; 
    }
    if((int)dp[now][g[now].size()].size() >= k + 1 && ans > dp[now][g[now].size()][k]) 
        ans = dp[now][g[now].size()][k] + (now != 1), lst = now; 
}
void getans(int now, int r) {
    vis[now] = 1; if(r == 1) return; int cnt = g[now].size(); 
    for(auto it = g[now].rbegin(); it != g[now].rend(); it++) {
        int i = *it; 
        f(x, 1, sz[i]) {
            if(r - x < 0 || (int)dp[now][cnt - 1].size() <= r - x) continue; 
            if(dp[now][cnt][r] == dp[now][cnt - 1][r - x] + dp[i][g[i].size()][x] - 1) {
                getans(i, x); r -= x; break; 
            }
        }
        --cnt; 
    }
}
posted @ 2022-08-29 23:33  OIer某罗  阅读(33)  评论(0编辑  收藏  举报