【题解】「HAOI2008」硬币购物(dp 优化、容斥)
「HAOI2008」硬币购物
题面
共有 4 种硬币。面值分别为 \(c_1,c_2,c_3,c_4\)。
某人去商店买东西,去了 \(n\) 次,对于每次购买,他带了 \(d_i\) 枚第 \(i\) 种硬币,想购买价值和为 \(s\) 的东西。
请问每次有多少种付款方法。
\(1\le c_i,d_i,s\le10^5,1\le n\le 1000\)。
题解一
- 这个想法来自强大的 \(\texttt{Booksnow}\)。
我们一开始会想到完全背包,但是发现它会超时。
一开始的想法:设 \(dp[i, j]\) 表示前 \(i\) 种硬币,购买了价值为 \(j\) 的物品,方案数为多少。
对于每一次购买,我们的转移代码大致如下
memset(dp, 0, sizeof(dp));
dp[0][0] = 1;
for(int i = 1; i <= 4; ++i) { // 枚举物品
for(int j = 0; j <= s; ++j) { // 枚举购买价值
for(int k = 0; k <= d[i]; ++k) { // 枚举当前物品买几个
if(j - k * c[i] >= 0) {
dp[i][j] += dp[i - 1][j - k * c[i]];
}
}
}
}
cout << dp[4][s] << "\n";
我们仔细模拟其中 \(k\) 所在层所做的事,也就是下面这段代码
for(int k = 0; k <= d[i]; ++k) {
if(j - k * c[i] >= 0) {
dp[i][j] += dp[i - 1][j - k * c[i]];
}
}
我们发现,它实际是将 \(dp[i - 1, j]\)、\(dp[i - 1, j - c[i]]\)、\(dp[i - 1, j - 2c[i]]\)、\(\dots dp[i - 1, j - k\cdot c[i]]\) 加到了一个值 \(dp[i, j]\) 上。
注意到,被加的这些项类似于一个前缀和,只不过这个“前缀和”是间隔 \(c[i]\) 加一次。并且,在 \(i\) 确定之后,这个前缀和只跟 \(j\) 和 \(c[i]\) 有关。
所以我们可以预处理这个前缀和。具体地,在 \(i\) 确定时,设 \(sum[x]\) 表示 \(dp[i - 1, x - k\cdot c[i]]~~(k=1,2,3,\dots)\) 的和(变量为 \(k\)),那么 \(sum[x]=sum[x - c[i]] + dp[i - 1, x]\)。
然后,之前 \(k\) 所在层就可以改写为
dp[i][j] = sum[j];
但是这样还是不对,这是因为原来的代码里面,\(k\) 有一个限制
k <= d[i];
所以应该改写为
// dp[i][j] = sum[j] - sum[j - (d[i] + 1) * c[i]]
// 但是j - (d[i] + 1) * c[i]可能会越界,所以要特判一下
dp[i][j] = sum[j];
if(j - (d[i] + 1) * c[i] >= 0) {
dp[i][j] -= sum[j - (d[i] + 1) * c[i]];
}
然后这道题就做完了,每一次购买的时间复杂度是 \(O(4s)=O(s)\),总时间复杂度是 \(O(ns)\)。
总结
这里的 dp 优化最关键的一步是预处理 \(sum\) 数组,这启示我们将多维 dp 的内层所求提前预处理,达到平衡复杂度的作用(从预处理\(O(0)\),计算\(O(n^2)\) 到预处理\(O(n)\),计算\(O(n)\))。
代码
#include<bits/stdc++.h>
using namespace std;
using i64 = long long;
int n, s, c[5], d[5];
i64 dp[5][100005], sum[100005];
int main() {
ios::sync_with_stdio(false);
cin.tie(nullptr);
for(int i = 1; i <= 4; ++i) {
cin >> c[i];
}
cin >> n;
for(int t = 1; t <= n; ++t) {
for(int i = 1; i <= 4; ++i) {
cin >> d[i];
}
cin >> s;
memset(dp, 0, sizeof(dp));
dp[0][0] = 1;
for(int i = 1; i <= 4; ++i) {
// sum[x]表示从x开始向前跳c[i],dp[i - 1, x - k * c[i]]的和
sum[0] = dp[i - 1][0];
for(int j = 1; j <= s; ++j) { // 预处理sum[]数组
if(j >= c[i]) {
sum[j] = sum[j - c[i]] + dp[i - 1][j];
} else {
sum[j] = dp[i - 1][j];
}
}
for(int j = 0; j <= s; ++j) {
dp[i][j] = sum[j];
if(j - (d[i] + 1) * c[i] >= 0) {
dp[i][j] -= sum[j - (d[i] + 1) * c[i]];
}
}
}
cout << dp[4][s] << "\n";
}
return 0;
}
题解二
先不考虑每次询问中 \(d_i\) 的限制,即在认为所有硬币都有无限个的前提下,预处理 \(dp[i]\) 表示买价值为 \(i\) 的物品的总方案数(完全背包)。
然后对于每次的 \(d_i\) 限制,考虑容斥。答案 $=dp[i]\ - $ 1种硬币超限的方案数 \(+\) 2种硬币超限的方案数 \(-\) 3种硬币超限的方案数 \(+\) 4种硬币超限的方案数。
考虑第 \(i\) 种硬币超限的方案数,我们直接钦定 \(d_i+1\) 个这种硬币被选,其他的随便选即可。因此方案数为 \(dp[n - c_i\times (d_i+1)]\)。
如果计算多个硬币同时超限的方案数,类似地可以得出方案数为 \(dp[n-c_i\times(d_i+1)-c_j\times(d_j+1)-\dots]\)。
枚举哪些硬币超限的二进制集合 \(sta\),然后根据判断是 \(+\) 还是 \(-\) 上该状态的方案数即可。
时间复杂度 \(O(4s+4\cdot 2^4\cdot n)\)。
代码
#include<bits/stdc++.h>
using namespace std;
using i64 = long long;
const int S = 1E5 + 5;
int n, s, c[5], d[5];
i64 dp[S];
int main(){
ios::sync_with_stdio(false);
cin.tie(nullptr);
for(int i = 0; i < 4; ++i) {
cin >> c[i];
}
cin >> n;
dp[0] = 1;
for(int i = 0; i < 4; ++i) {
for(int j = c[i]; j < S; ++j) {
dp[j] += dp[j - c[i]]; // 完全背包
}
}
while(n--) {
for(int i = 0; i < 4; ++i) {
cin >> d[i];
}
cin >> s;
i64 ans = dp[s];
for(int sta = 1; sta < (1 << 4); ++sta) { // sta != 0
int cnt = 0, now = s;
for(int i = 0; i < 4; ++i) {
if((sta >> i) & 1) {
++cnt;
now -= (d[i] + 1) * c[i];
}
}
if(now >= 0) {
if(cnt % 2 == 0) {
ans += dp[now];
} else {
ans -= dp[now];
}
}
}
cout << ans << "\n";
}
return 0;
}