2023 5月 dp做题记录
- 5月 dp做题记录
- P1064 [NOIP2006 提高组] 金明的预算方案
- P1941 [NOIP2014 提高组] 飞扬的小鸟
- P2679 [NOIP2015 提高组] 子串
- P1850 [NOIP2016 提高组] 换教室
- P2831 [NOIP2016 提高组] 愤怒的小鸟
- P5020 [NOIP2018 提高组] 货币系统
- P6064 [USACO05JAN]Naptime G
- P9344 去年天气旧亭台
- P4095 [HEOI2013]Eden 的新背包问题
- P3174 [HAOI2009] 毛毛虫
- P2340 [USACO03FALL]Cow Exhibition G
- P4059 [Code+#1]找爸爸
- P4342 [IOI1998]Polygon
- CF149D Coloring Brackets
- UVA12991 Game Rooms
- Generate a String
- Games with Rectangle
- CF837D Round Subset
- CF14D Two Paths
- CF527D Clique Problem
- P4310 绝世好题
- P4158 [SCOI2009]粉刷匠
- P1772 [ZJOI2006] 物流运输
- P3861 拆分
5月 dp做题记录
P1064 [NOIP2006 提高组] 金明的预算方案
物体与物体之间有从属的限制关系,且从属关系只有一层,最多有两个个附件,所以我们很容易就可以想到列举出每一组的组合关系,毕竟每组最多四种组合,再跑一遍分组背包,从每组中至多选出一种组合,以满足限制条件。
P1941 [NOIP2014 提高组] 飞扬的小鸟
发现题目是一张 \(n\times m\) 的地图,容易想到初始状态 \(dp_{i,j}\) 为现在飞到第 \(i\) 列,第 \(j\) 行的最少跳跃次数,又发现是从左到右一列一列飞行,每一列只与上一列相关,所以可以用滚动数组滚掉一维。
再注意细节,如果跳跃过高是会顶格的,所以最高行是可以通过前一列所有小于跳跃高度的点转移的,这点要单独枚举。还有背包枚举顺序的问题,如果下落是不会再上升的,所以是先上升的 01 背包,再上升的完全背包,最后在下落的 01 背包,防止下落的状态被完全背包转移。
这题要注意的就是不能少转移,因为用的是滚动数组,前面的都要继承下来。
\(\begin{cases}dp_{i,j}=\min(dp_{i-1,j+y_i},dp_{i-1,j-x_i}+1)\\dp_{i,m}=\min(dp_{i,m},dp_{i-1,k}+1)(k+x_i\ge m) \\dp_{i,j}=\min(dp_{i,j},dp_{i,j-x_i}+1) \end{cases}\)
P2679 [NOIP2015 提高组] 子串
匹配问题,有状态 \(dp_{i,j,k,0/1}\) 表示 \(A\) 串中枚举到第 \(i\) 位,匹配到了 \(B\) 串的第 \(j\) 位,选了 \(k\) 段,并且第 \(i\) 位选/不选的方案数,转移也很好写,写完发现第一维是可以滚动滚掉的,其余注意第二行 \(dp_{i-1,j-1,k,0}\) 是不合法的,不能用这个转移就行。
初始状态 \(dp_{0,0,0,0}=dp_{1,0,0,0}=1\)。
\(\begin{cases}dp_{i,j,k,0}=dp_{i-1,j,k,0}+dp_{i-1,j,k,1}\\dp_{i,j,k,1}=dp_{i-1,j-1,k,1}+dp_{i-1,j-1,k-1,0}+dp_{i-1,j-1,k-1,1}(a_i=b_k) \end{cases}\)
答案为 \(dp_{n,m,g,0}+dp_{n,m,g,1}\)
#include <bits/stdc++.h>
using namespace std;
int read(){
int x = 0, f = 1;
char c = getchar();
while(c < '0' || c > '9'){
if(c == '-') f = -1;
c = getchar();
}
while(c >= '0' && c <= '9'){
x = (x << 1) + (x << 3) + (c - '0');
c = getchar();
}
return x * f;
}
int n, m, g;
char a[1010], b[1010];
long long dp[2][210][210][2];
int main(){
n = read(), m = read(), g = read();
scanf("%s%s", a + 1, b + 1);
dp[0][0][0][0] = dp[1][0][0][0] = 1;
for(int i = 1; i <= n; i++){
for(int j = 1; j <= m; j++){
for(int k = 1; k <= g; k++){
dp[i & 1][0][0][0] = 1, dp[i & 1 ^ 1][0][0][0] = 1, dp[i & 1][j][k][0] = 0, dp[i & 1][j][k][1] = 0;
dp[i & 1][j][k][0] = (dp[i & 1 ^ 1][j][k][0] + dp[i & 1 ^ 1][j][k][1]) % 1000000007;
if(a[i] == b[j]){
dp[i & 1][j][k][1] = (dp[i & 1 ^ 1][j - 1][k][1] + dp[i & 1 ^ 1][j - 1][k - 1][1] + dp[i & 1 ^ 1][j - 1][k - 1][0]) % 1000000007;
}
}
}
}
cout << (dp[n & 1][m][g][0] + dp[n & 1][m][g][1]) % 1000000007 << endl;
return 0;
}
P1850 [NOIP2016 提高组] 换教室
因为是选择性问题,所以状态就很套路,定义状态为 \(dp_{i,j,0/1}\) 表示到了第 \(i\) 堂课,用了 \(j\) 次换课,第 \(i\) 堂课换/不换的最大期望,转移长但好想,无非是成功的期望+失败的期望,四种情况,这次没换上次没换,这次没换上次换,这次换上次没换,这次换上次换,哪次成功就乘 \(p_i\),失败就乘 \(1-p_{i}\),注意上次换到 \(d\),下次默认还是在 \(c\),不会继承。
\(\begin{cases}dp_{i,j,0}=\min(dp_{i-1,j,0},dp_{i-1,j,1}+f_{c_{i-1,c_i}}\times p_i+f_{d_{i-1},c_i}\times(1-p_{i-1}))\\dp_{i,j,1}=\min(dp_{i-1,j-1,0}+f_{c_{i-1},d_i}\times p_i+f_{c_{i-1},c_i}\times(1-p_i), dp_{i-1,j-1,1}+f_{c_{i-1},c_i}\times(1-p_{i-1})\times(1-p_i)+f_{c_{i-1},d_i}\times(1-p_{i-1})\times p_i+f_{d_{i-1},c_i}\times p_{i-1}\times(1-p_i)+f_{d_{i-1},d_i}\times p_{i-1}\times p_i) \end{cases}\)
#include<bits/stdc++.h>
using namespace std;
int n, m, a, b;
int c[2010], d[2010];
double k[2010], ans, dp[2010][2010][2], f[310][310];
int main(){
cin >> n >> m >> a >> b;
for(int i = 1; i <= n; i++) cin >> c[i];
for(int i = 1; i <= n; i++) cin >> d[i];
for(int i = 1; i <= n; i++) cin >> k[i];
for(int i = 1; i <= a; i++){
for(int j = 1; j <= a; j++) f[i][j] = 2147483647;
}
for(int i = 1; i <= b; i++){
int u, v;
double w;
cin >> u >> v >> w;
f[u][v] = f[v][u] = min(f[u][v], w);
}
for(int k = 1; k <= a; k++){
f[k][k] = 0;
for(int i = 1; i <= a; i++){
for(int j = 1; j <= a; j++){
f[i][j] = min(f[i][j], f[i][k] + f[k][j]);
}
}
}
for(int i = 1; i <= n; i++){
for(int j = 0; j <= m; j++) dp[i][j][0] = dp[i][j][1] = 2147483647;
}
dp[1][0][0] = dp[1][1][1] = 0;
for(int i = 2; i <= n; i++){
for(int j = 0; j <= min(i, m); j++){
dp[i][j][0] = min(dp[i - 1][j][0] + f[c[i - 1]][c[i]], dp[i - 1][j][1] + f[c[i - 1]][c[i]] * (1 - k[i - 1]) + f[d[i - 1]][c[i]] * k[i - 1]);
if(j != 0) dp[i][j][1] = min(dp[i - 1][j - 1][0] + f[c[i - 1]][c[i]] * (1 - k[i]) + f[c[i - 1]][d[i]] * k[i], dp[i - 1][j - 1][1] + f[c[i - 1]][c[i]] * (1 - k[i - 1]) * (1 - k[i]) + f[c[i - 1]][d[i]] * (1 - k[i - 1]) * k[i] + f[d[i - 1]][c[i]] * k[i - 1] * (1 - k[i]) + f[d[i - 1]][d[i]] * k[i - 1] * k[i]);
}
}
ans = 0x3f3f3f3f;
for(int j = 0; j <= m; j++){
ans = min(ans, dp[n][j][0]);
if(j != 0) ans = min(ans, dp[n][j][1]);
}
cout << fixed << setprecision(2) << ans << endl;
return 0;
}
P2831 [NOIP2016 提高组] 愤怒的小鸟
这题需要一些数学知识,首先看数据范围,确定是状压 dp,简单的定义状态 \(dp_S\) 为到 \(S\) 状态的最少抛物线数,我们肯定不会去枚举子集,去判断差集能不能被一次穿过,而是想通过 \(S\) 这个状态能给哪些状态转移。
横坐标不同的两点确定一条形式为 \(y=ax^2+bx\) 的抛物线,\(a\) 和 \(b\) 都是可以解出来的
\(\begin{cases}ax_1^2+bx_1=y_1\\ax_2^2+bx_2=y_2\end{cases}\)
②式 \(\Rightarrow ax_2^2\dfrac{x_1}{x_2}+bx_2\dfrac{x_1}{x_2}=y_2\dfrac{x_1}{x_2}\)
\(\Rightarrow ax_1x_2+bx_1=\dfrac{y_2x_1}{x_2}\) ③式
①-③ \((x_1x_2-x_1^2)a=\dfrac{y_2x_1}{x_2}-y_1=\dfrac{y_2x_1-y_1x_2}{x_2}\)
\(a=\dfrac{y_2x_1-y_1x_2}{x_2}\times \dfrac{1}{x_1x_2-x_1^2}=\dfrac{y_2x_1-y_1x_2}{x_1x_2^2-x_1^2x_2}\)
同理得,\(b=\dfrac{y_1x_2^2-y_2x_1^2}{x_1x_2^2-x_1^2x_2}\)
\(n^2\) 预处理一下每两个点连成的抛物线上能有多少点,二进制保存一下
\(\begin{cases}dp_{S|(1<<(j-1))}=\min(dp_{S|(1<<(j-1))},dp_S+1)\\dp_{S|line_{j,k}}=\min(dp_{S|line_{j,k}},dp_S+1)\end{cases}\)
这样的复杂度是 \(O(Tn^22^n)\) 的,要过这题还要优化到 \(O(Tn2^n)\) ,因为抛物线选择的顺序是不影响结果的,最后我们所有小猪都要全部得到,所以 \(n^2\) 枚举所有抛物线的时候,实际上是枚举了原有的抛物线和很远之后的抛物线的,优化的大部分是很远之后的抛物线,这部分会一直重复不断的枚举,但有用的只有一次,所以我们强制选择一下,每次强制选经过最小的没被经过的点的抛物线,这样的抛物线只有 \(n\) 条,这样从 \(0\) 穷举到 \(2^n-1\) 都是有顺序的选抛物线,不存在重复选和提早选,复杂度优化到 \(O(Tn2^n)\) 。
#include<bits/stdc++.h>
using namespace std;
int t, n, m;
double eps = 1e-8, x[20], y[20];
int line[20][20], dp[1 << 20], lowbit[1 << 20];
void findline(double &a, double &b, double x1, double y1, double x2, double y2){
a = -(y1 * x2 - y2 * x1) / (x2 * x2 * x1 - x1 * x1 * x2);
b = (y1 * x2 * x2 - y2 * x1 * x1) / (x1 * x2 * x2 - x2 * x1 * x1);
}
int main(){
cin >> t;
for(int i = 0; i < (1 << 18); i++){
for(int j = 1; j <= 18; j++){
if((i & (1 << (j - 1))) == 0){
lowbit[i] = j;
break;
}
}
}
while(t--){
memset(line, 0, sizeof(line));
memset(dp, 1, sizeof(dp));
cin >> n >> m;
for(int i = 1; i <= n; i++){
scanf("%lf%lf", &x[i], &y[i]);
}
for(int i = 1; i <= n; i++){
for(int j = 1; j <= n; j++){
if(fabs(x[i] - x[j]) < eps) continue;
double a, b;
findline(a, b, x[i], y[i], x[j], y[j]);
if(a > -eps) continue;
for(int k = 1; k <= n; k++){
if(fabs(a * x[k] * x[k] + b * x[k] - y[k]) < eps) line[i][j] |= (1 << (k - 1));
}
}
}
dp[0] = 0;
for(int i = 0; i < (1 << n) - 1; i++){
int j = lowbit[i];
dp[i | (1 << (j - 1))] = min(dp[i | (1 << (j - 1))], dp[i] + 1);
for(int k = 1; k <= n; k++) dp[i | line[j][k]] = min(dp[i | line[j][k]], dp[i] + 1);
}
cout << dp[(1 << n) - 1] << endl;
}
return 0;
}
P5020 [NOIP2018 提高组] 货币系统
要把 \(n\) 个数能表示出的数尽可能用最少的数表示出来,我们首先可以探究一下 \((n,a)\) 与 \((m,b)\) 之间的关系。
\((n,a)\) 表示为集合 \(A\),\((m,b)\) 表示为集合 \(B\),要让 \((n,a)=(m,b)\),我们建的集合 \(B\) 表示出的数不能有 \(A\) 没有的,所以我们深入挖掘一下,如果 \(B\) 中用的数不是 \(A\) 中有的,我们担心这些数 \(A\) 表示不出来,即可以用不在 \(A\) 里的数来表示 \(A\) 的数吗?
我们想验证的猜想其实就是证 \(B\subseteq A\)。我们发现每个集合中的数分为两类,第一类是不能被别人表示的数,第二类是能被别人表示的数,并且容易发现二类数一定能拆分成只用一类数的拼法(因为二类数可以一直拆,如果拆出二类数,那这个数还可以一直拆,直到不能再拆)。
要证 \(B\subseteq A\),因为一类数比较特殊,在 \(A\) 中不可少,所以首先可以证 \(A\) 中的一类数是否一定属于 \(B\)。用反证法,假设一类数 \(x \subseteq A\),且 \(x\nsubseteq B\),那么根据 \((n,a)=(m,b)\),\(B\) 中一定存在一些一类数能表示出 \(x\),这些一类数中一定存在至少一个数不在 \(A\) 中(如果都在 \(A\) 中,那 \(x\) 就不是一类数了),与 \((n,a)=(m,b)\) 矛盾,命题得证。
有了这个结论,就可以证 \(B\subseteq A\) 了,反证法,假设 \(x \subseteq B\),且 \(x \nsubseteq A\),那么 \(A\) 中一定存在一些一类数能表示出 \(x\),根据上面的结论,这些一类数同时也在 \(B\) 中,那么这些数本身就能表示出 \(x\) 了,为什么 \(B\) 里还要加一个 \(x\) 呢,根据 \(m\) 最小,得出 \(x\) 是多余的,所以就不会存在 \(x\) 这种数,也就是 \(B\subseteq A\) 了。
知道了 \(B\subseteq A\) 这题就简单了,题目就变成:求 \(A\) 中最少能留几个数,把所有数表示出来。首先,\(A\) 中的一类数一定都得在 \(B\) 中,然后容易发现,剩下的都是二类数,直接就都能被一定得选的一类数表示出来,这样,不需要多的数,既然必选的数能表示出原来的 \(A\),自然也就可以表示出 \((n,a)\),所以 \(A\) 中的一类数数量就是答案了。中我们把所有数排序,做完全背包,如果当前枚举的数不能被之前小的数表示出来,它就是一类数,答案就是这些数的个数。
P6064 [USACO05JAN]Naptime G
对于每一段,我们都有睡或不睡两种选择,并且是选段问题,并且当前的效用值有没有贡献取决于上一次有没有睡觉,所以我们的状态可以是 \(dp_{i,j,0/1}\) 表示当前在第 \(i\) 段,选了 \(j\) 段睡觉,当前第 \(i\) 段睡/ 不睡的最大总效用值。
\(\begin{cases}dp_{1,0,0}=dp_{1,1,1}=0\\dp_{i,j,0}=\max(dp_{i-1,j,0},dp_{i-1,j,1})\\dp_{i,j,1}=\max(dp_{i-1,j-1,0},dp_{i-1,j-1,1}+a_i)\end{cases}\)
如果这题不可以睡到第二天早上,那么就这样就行了,但是我们发现这题是可以从第一天连着睡到第二天的前几段的。但是容易发现这其中的区别其实就是多了第一段的效用值,其他段并没有影响,因为每一段还是都只能睡一次,不会重复睡。这里有一个技巧,可以把睡觉分为两种情况,一种没有第一段,另一种强制选第一段。在初始化时,把 \(dp_{1,0,0}=dp_{1,1,1}=a_1\) 就行了。
P9344 去年天气旧亭台
选若干段区间 \([i,j]\) (满足 \(c_i=c_j\))覆盖整个区间,使得 \(\sum a_i+a_j\) 最小。因为选区间的顺序是不影响答案的,所以我们可以从左到右考虑,设状态 \(dp_i\) 表示覆盖 \([1,i]\) 用的最小费用,发现选的区间一定是一个紧贴着一个的,\(dp_i\) 跟前面所有的 \(dp_j\) 都相关,枚举断点 \(j\),可以得出朴素的转移方程是
每次都要枚举前面所有的 \(dp_j\) 这样的方程复杂度是 \(O(n^2)\) 的。但是我们很容易可以发现,之前处理的 \(\min\) 之后还可以用,只不过每次处理完多一个 \(dp_{i}+a_{i+1}\) 而已,所以 \(\min\) 里面的值是可以继承的,也就是枚举 \(i\) 的时候,就可以一边处理出当前最小的 \(\min\),考虑到 \(c_j=c_i\) 的限制,我们把 \(\min\) 分成 \(c_j=c_i=0\) 和 \(c_j=c_i=1\) 两种情况,分别更新就行了。这样 \(c_i=0/1\) 时,就可以直接取出前面实时更新的 \(\min\) 来 \(O(1)\) 更新 \(dp_i\) 了。
P4095 [HEOI2013]Eden 的新背包问题
对于每一次询问,给出不能选的一个物品 \(d\),和最大价钱 \(e\),做多重背包后的最大价值。如果每一次询问都做一次多重背包,肯定超时。我们回归背包的最原始状态 \(dp_{i,j}\) 表示前 \(i\) 个物品,最大价钱为 \(j\) 能得到的最大价值。对于前 \(i\) 个,如果不能选的是第 \(i\) 个,那么容易知道答案是 \(dp_{i-1,j}\)。回到这题,我们还要考虑 \(i\) 后面的最大价值,其实 \(i\) 在整个序列中就充当一个分隔左右两部分的隔板,所以我们把 \(i\) 分成前 \(i\) 个和后 \(n-i\) 个两种,分开考虑,每一种里,\(i\) 都是最后一个,所以可以正着和反着分别做一遍多重背包,知道了 \(i\) 前缀和后缀的 \(dp\) 值,再枚举前面用的价钱 \(v\),后面的就是 \(e-v\) 了,左右两段 \(dp\) 值相加就是答案。
答案即 \(ans=\max(ans,dp1_{l_d,i}+dp2_{r_d+1,e-i})\)
其中 \(l_i\) 和 \(r_i\) 表示二进制拆分后,一个物体的左端点和右端点。
P3174 [HAOI2009] 毛毛虫
要找到一条链,使得链上的点加上点延伸出去的点最多,我们可以发现,对于一个点 \(u\),如果在他的子树下连出了两条边,那么链就一定在子树里了。树中的每一条链我们都可以找到这样一个点,使得链在这个点的子树里。只要 \(u\) 连出的边超过两条,在这个点下面的链肯定是连出两条的这种,并且我们希望这条链最大,肯定是连出的两条边的贡献都最大;除非是链,我们才会只连出一条边。所以我们设状态 \(dp_{u,0/1/2}\) 表示在 \(u\) 中连出了 \(0/1/2\) 条边。转移中,对于他的子节点可以继续往下连,也可以到这就停止,\(dp_{u,2}\) 其实就是统计的作用的,他们不会用来转移。
\(\begin{cases}dp_{u,0}=sz_u\\dp_{u,1}=\max(dp_{u,1},\max(dp_{v,1},dp_{v,0}))\\dp_{u,2}=sz_u+maxn1+maxn2-1/sz_u+maxn1/sz_u\end{cases}\)
\(sz_u\) 表示包括自己加上连出的点,两个 \(maxn\) 分别为子树里的最长和次长,注意点 \(u\) 的父亲也会被拓展,一定不要自以为是去掉然后漏了QWQ。
这题的状态还可以更简单一点,可以去掉 \(0/1/2\) 这一维,因为不选其实囊括在 \(dp_{u,1}\) 里了,不选一定是不优的(既然都走到 \(v\) 了,不往下不就亏了)。
后面学完了树的直径,这题还可以更简单,其实就是我们现在每个点的最长链不是 \(0\) 了,而是它所连出的边数加上自己,基于此,加上树的直径的 \(dp\) 求法,设状态为 \(dp_i\) 表示以 \(i\) 为根的子树的最长链是多少,转移和直径一模一样。
\(\begin{cases}ans=\max(ans,dp_u+dp_v-1)\\dp_u=\max(dp_u,dp_v+sz_u-1)\end{cases}\)
\(-1\) 是因为多算了一遍 \(u\) 节点。
P2340 [USACO03FALL]Cow Exhibition G
这题虽然是黄题,但是让我注意到了之前 \(01\) 背包时没有注意到的细节。
要求在满足智商情商非负的情况下,智商情商和最大。我们平时写 \(01\) 背包,一般都不会初始化,因为我们一般只需要直接用到 \(dp_m\) 就可以了,它表示的是前 \(n\) 个,最多花费 \(m\) 的最大价值,\(m\) 是虚值,并不是我们真正用的重量,这也是我们为什么可以不初始化 \(dp_{0} = 0\),其他的 \(dp_i=-inf\)。由于状态中描述是最多,说明前面用的重量是多少我们不在意,所以这样 \(dp_{j-w_i}\) 就可以从随便一个地方开始(可以理解成假设前面用了一些价值是 \(0\) 的物品,把背包塞到了 \(j-w_i\)),因为状态都是 \(0\),前面有贡献的自然会转移,没有就直接用 \(0\) 的价值转移,所以 \(dp_m\) 是正确的,因为不需要里面重量到底用了多少,只想知道价值直接查询 \(dp_m\) 就行了,不需要一个个枚举重量多少。
但我们想在这题里面同时知道智商和情商的花费情况,我们就需要初始化了,因为发现如果初始化了,状态描述就变成了 \(dp_{i,j}\) 表示前 \(i\) 个物品,装到 \(j\) 重量的最大价值。因为此时我们只能从 \(dp_{0,0}\) 转移,每一个 \(j\) 都是真实能够达到的。
所以在这题里,我们随便用一个智商表示重量,求此时情商最大。防止数组越界,我们把重量加一个大的值。最后我们枚举正的智商 \(i\),这时候如果智商 \(i\) 能被组合出来,\(dp_i\) 就不会是 \(-inf\),\(ans=i+dp_i-inf\)。
P4059 [Code+#1]找爸爸
序列匹配问题,一般状态 \(dp_{i,j}\) 表示 \(A\) 序列前 \(i\) 个,匹配了 \(B\) 序列前 \(j\) 个的最大价值。但是我们发现这样子对于这题还不够,因为每一位的匹配不能表示出来,不能计算贡献,所以考虑多加一维表示目前对齐完最后一位的情况
\(\bullet\) 两个都是字母
\(\bullet\) \(A\) 序列是空格,\(B\) 序列是字母
\(\bullet\) \(A\) 序列是字母,\(B\) 序列是空格
\(\bullet\) 两个都是空格
我们观察四个情况,很容易发现最后一个情况是对答案一定是负面影响,因为我们加空格,是为了接近匹配的目标,而第四种不仅接近不了目标,还减少相似度,所以可以直接去掉。
最后我们的状态 \(dp_{i,j,0/1/2}\) 表示 \(A\) 序列前 \(i\) 个,通过空格,匹配了 \(B\) 序列前 \(j\) 个,且对齐后当前的情况为上面说的 \(0/1/2\) 三种之一。如果是 \(dp_{i,j,0}\),没有用空格,那么它上一个状态就是匹配数都少一个,为 \(dp_{i-1,j-1,0}/dp_{i-1,j-1,1}/dp_{i-1,j-1,2}\);如果是 \(dp_{i,j,1}\),\(A\) 目前的最后一位用空格,那么上一个状态就是少了一行后, \(i\) 不变(因为肯定这 \(i\) 不是在当前行匹配的),\(j\) 少一个为 \(dp_{i,j-1,0}/dp_{i,j-1,1}/dp_{i,j-1,2}\);如果是 \(dp_{i,j,2}\),\(B\) 目前的最后一位用空格,那么和上一个同理。
\(\begin{cases}dp_{i,j,0}=\max(dp_{i-1,j-1,0},dp_{i-1,j-1,1},dp_{i-1,j-1,1})+d_{a_i,b_i}\\dp_{i,j,1}=\max(dp_{i,j-1,0}-a,dp_{i,j-1,1}-b,dp_{i,j-1,2}-a)\\dp_{i,j,2}=\max(dp_{i-1,j,0}-a,dp_{i-1,j,1}-a,dp_{i-1,j,2}-b)\end{cases}\)
注意这题的初始化比较多,大多是不可能达到或者不优的,比如 \(dp_{i,0,1}=dp_{0,i,2}=dp_{i,0,0}=dp_{0,i,0}=-inf\),前面两个是一定不优(两个空格),后两个是不存在这样的方案。还有的是 \(dp_{0,i,1}=dp_{i,0,2}=-A-B(i-1)\) 表示对 \(A/B\) 序列补了 \(i\) 个空格的费用。
这题细节还是多,要把细节好好回味一下
#include <bits/stdc++.h>
using namespace std;
int read(){
int x = 0, f = 1;
char c = getchar();
while(c < '0' || c > '9'){
if(c == '-') f = -1;
c = getchar();
}
while(c >= '0' && c <= '9'){
x = (x << 1) + (x << 3) + (c - '0');
c = getchar();
}
return x * f;
}
string A, B;
int x, y;
int a[3010], b[3010], d[6][6], dp[3010][3010][3];
int find(char a){
if(a == 'A') return 1;
if(a == 'T') return 2;
if(a == 'G') return 3;
return 4;
}
int main(){
cin >> A >> B;
int n = A.length(), m = B.length();
for(int i = 0; i < n; i++) a[i + 1] = find(A[i]);
for(int i = 0; i < m; i++) b[i + 1] = find(B[i]);
for(int i = 1; i <= 4; i++){
for(int j = 1; j <= 4; j++) cin >> d[i][j];
}
cin >> x >> y;
for(int i = 1; i <= max(n, m); i++){
dp[i][0][0] = dp[0][i][0] = dp[i][0][1] = dp[0][i][2] = -0x3f3f3f3f;
dp[i][0][2] = dp[0][i][1] = -x - y * (i - 1);
}
dp[0][0][1] = dp[0][0][2] = -0x3f3f3f3f;
for(int i = 1; i <= n; i++){
for(int j = 1; j <= m; j++){
dp[i][j][0] = max(dp[i - 1][j - 1][0], max(dp[i - 1][j - 1][1], dp[i - 1][j - 1][2])) + d[a[i]][b[j]];
dp[i][j][1] = max(dp[i][j - 1][0] - x, max(dp[i][j - 1][1] - y, dp[i][j - 1][2] - x));
dp[i][j][2] = max(dp[i - 1][j][0] - x, max(dp[i - 1][j][1] - x, dp[i - 1][j][2] - y));
}
}
cout << max(dp[n][m][0], max(dp[n][m][1], dp[n][m][2])) << endl;
return 0;
}
P4342 [IOI1998]Polygon
一眼环形 \(dp\),并且是环形 \(dp\) 中经典的合并性问题,对于这种问题,我们可以很套路的断环成链,即把原序列复制一份到它自身后面。其原理支撑就是环形问题中具有区间 dp 的性质(连续性)时,往往会发现在任意一种方案中都有至少一对相邻关系用不上,也就是一圈里有一条边会用不上。并且这个性质还和题目中去掉一条边完美吻合。
这道题就是普通的合并,所以设状态 \(dp_{i,j}\) 表示区间 \([i,j]\) 合并的最大价值,转移也很好列
\(\begin{cases}dp_{i,j}=\max(dp_{i,j},dp_{i,k}+dp_{k+1,j})(c_{k+1}='t') \\dp_{i,j}=\max(dp_{i,j},dp_{i,k}\times dp_{k+1,j})(c_{k+1}='x')\end{cases}\)
但是我们发现,这样子每次区间都取最大最后合并出的不一定是最大的,原因就是数字中有负数,有可能我们的区间是负数乘负数得出的最大值。所以我们思考一下,发现大区间最大价值只跟小区间的最大值和最小值有关。所以我们可以维护两个状态 \(f_{i,j}\) 和 \(g_{i,j}\) 分别表示区间 \([i,j]\) 合并后的最大价值/最小价值。
分类讨论,断点是加法,最大值和最小值互不影响;断点是乘法,排列组合一下,最大乘最大,最大乘最小,最小乘最大,最大乘最小,最大值最小值乘法都一样。
\(\begin{cases}f_{i,j}=\max(f_{i,j},f_{i,k}+f_{k+1,j})(c_{k+1}='t')\\f_{i,j}=\max(f_{i,j},f_{i,k}\times f_{k+1,j},g_{i,k}\times g_{k+1,j},g_{i,k}\times f_{k+1,j},f_{i,k}\times g_{k+1,j})(c_{k+1}='x')\\g_{i,j}=\min(g_{i,j},g_{i,k}+g_{k+1,j})(c_{k+1}='t')\\g_{i,j}=\min(g_{i,j},f_{i,k}\times f_{k+1,j},g_{i,k}\times g_{k+1,j},g_{i,k}\times f_{k+1,j},f_{i,k}\times g_{k+1,j})(c_{k+1}='x')\end{cases}\)
答案便为 \(\max(f_{i,i+n-1})\),遍历一遍就行了。回到之前的性质,如果一个环形的合并状态表示为 \(f_{i,i+n-1}\),那么它没用上的一条边就是 \(i-1\),所以第二问也就解决了。
CF149D Coloring Brackets
一道经典的区间染色题,特别的是由于括号的原因,任意的一段区间不一定是合法的,这些本身就不合法的区间是不好用来转移的。
我们要让状态转移,关心的地方就是题目给出的性质,两两相邻的括号颜色不同,而区间上特别的就是两个端点,这是从小区间转移到大区间我们最需要考虑的两点,所以我们设状态 \(dp_{l,r,0/1/2,0/1/2}\) 表示 \([l,r]\) 区间合法的染色方案数,当前左端点为没染色/染红色/染蓝色,右端点为没染色/染红色/染蓝色,并且默认 \([l,r]\) 区间为合法配对的括号序列。
状态列出来,我们要考虑最开始的问题,怎样保证每次转移的状态都是配对的括号序列呢?递推是不好排除特殊情况的。很简单的想法就是我们自己考虑往哪转移,可以用记忆化搜索,从大的合法序列推向小的合法序列。
首先,每个左括号对应的右括号是可以预处理出来的,用栈模拟匹配括号,看到左括号就推进去;看到右括号,它对应的一定是栈顶的左括号。
我们考虑一个合法的括号序列 \([l,r]\):
\(\bullet\) 如果左右端点是配对的,那么它的小的合法序列就是去掉外面的,为 \([l+1,r-1]\)
\(\bullet\) 如果左右端点不配对,那么序列就由两个合法序列拼起来构成,我们找到左端点配对的右括号位置(一定在 \([l,r]\) 内),那另一半也是合法的,为 \([l,c_l]\) 和 \([c_l+1,r]\)
初始状态就是当 \(l+1=r\) 时,括号一定是长这样 \(()\),显然有 \(dp_{l,r,0,1}=dp_{l,r,0,2}=dp_{l,r,1,0}=dp_{l,r,2,0}=1\)
对于第一种情况,我们先递归 \([l+1,r-1]\),然后枚举端点的颜色:
\(\begin{cases}dp_{l,r,0,1}=dp_{l,r,0,1}+dp_{l+1,r-1,i,j}(j\ne 1)\\dp_{l,r,0,2}=dp_{l,r,0,2}+dp_{l+1,r-1,i,j}(j\ne 2)\\dp_{l,r,1,0}=dp_{l,r,1,0}+dp_{l+1,r-1,i,j}(i\ne 1)\\dp_{l,r,2,0}=dp_{l,r,2,0}+dp_{l+1,r-1,i,j}(i\ne 2)\end{cases}\)
对于第二种情况,先递归求出 \([l,c_l]\) 和 \([c_l+1,r]\),根据乘法原理,方案就是左边的方案乘右边的方案,注意的是两个序列拼起来的两点颜色要不一样,并且可以都不涂色(可能会被当做颜色相同)
\(dp_{l,r,i,j}=dp_{l,r,i,j}+dp_{l,c_l,i,p}\times dp_{c_{l+1},r,q,j}(p\ne q\ or\ p=q=0)\)
这题由于区间的是否合法性(毕竟只有先合法才能转移),可以用记忆化搜索,自己考虑往合法的区间递归。
/*
* @Author: Fire_Raku
* @Date: 2023-05-21 12:06:46
* @Last Modified by: Fire_Raku
* @Last Modified time: 2023-05-21 12:22:05
*/
#include <bits/stdc++.h>
using namespace std;
const int mod = 1000000007;
int read(){
int x = 0, f = 1;
char c = getchar();
while(c < '0' || c > '9'){
if(c == '-') f = -1;
c = getchar();
}
while(c >= '0' && c <= '9'){
x = (x << 1) + (x << 3) + (c - '0');
c = getchar();
}
return x * f;
}
char s[710];
long long c[710], dp[710][710][3][3], ans;
stack<int> st;
void dfs(int l, int r){
if(l + 1 == r){
dp[l][r][0][1] = dp[l][r][1][0] = dp[l][r][2][0] = dp[l][r][0][2] = 1;
return;
}
if(c[l] == r){
dfs(l + 1, r - 1);
for(int i = 0; i <= 2; i++){
for(int j = 0; j <= 2; j++){
if(j != 1) dp[l][r][0][1] += dp[l + 1][r - 1][i][j], dp[l][r][0][1] %= mod;
if(j != 2) dp[l][r][0][2] += dp[l + 1][r - 1][i][j], dp[l][r][0][2] %= mod;
if(i != 1) dp[l][r][1][0] += dp[l + 1][r - 1][i][j], dp[l][r][1][0] %= mod;
if(i != 2) dp[l][r][2][0] += dp[l + 1][r - 1][i][j], dp[l][r][2][0] %= mod;
}
}
return;
}
else{
dfs(l, c[l]), dfs(c[l] + 1, r);
for(int i = 0; i <= 2; i++){
for(int j = 0; j <= 2; j++){
for(int k = 0; k <= 2; k++){
for(int p = 0; p <= 2; p++){
if((k != p) || (!k && !p)) dp[l][r][i][j] += (dp[l][c[l]][i][k] * dp[c[l] + 1][r][p][j]) % mod;
}
}
}
}
return;
}
}
int main(){
cin >> s + 1;
int n = strlen(s + 1);
for(int i = 1; i <= n; i++){
if(s[i] == ')'){
c[st.top()] = i;
c[i] = st.top();
st.pop();
}
else st.push(i);
}
dfs(1, n);
for(int i = 0; i <= 2; i++){
for(int j = 0; j <= 2; j++) ans += dp[1][n][i][j], ans %= mod;
}
cout << ans << endl;
return 0;
}
UVA12991 Game Rooms
求如何放乒乓球桌和游泳池,才能让楼层的人移动总和最小。状态里肯定有当前位置放的是什么来转移,并且因为放的数量没有限制,我们肯定的全放满最优。然后我们观察他们移动的距离,如果放的是乒乓球桌,那么喜欢游泳池的人们,就要走到离他们最近的游泳池,换句话说,就是走出他们所在的极大乒乓球区间,放游泳池同理。所以我们有一个结论,可以把楼层分为一个个连续的兵乓球桌区间和游泳池区间,并且一个区间固定,他里面的人们移动的总距离也就确定。
我们可以预处理出人们移动的距离,\(O(1)\) 计算。因为距离跟走的楼层有关,所以不能用一阶前缀和表示,用二阶前缀和,即前缀和的前缀和。
\(\begin{cases}prep_{i}=prep_{i-1}+pre_i\\prep_{i}=i\times a_1+(i-1)\times a_2+\cdot\cdot\cdot a_i\end{cases}\)
它一阶一阶的,很像我们要的距离。设区间 \([l,r]\) 的人都要往右走到 \(r+1\),总距离可以表示成 \(dis_{l,r}=prep_r-prep_{l-1}-(r-l+1)\times pre_{l-1}\)。相当于减去了 \([1,l-1]\) 中算的 \(a_1-a_{l-1}\) 和 \([l,r]\) 中算的 \(a_1-a_{l-1}\)。
显然,如果区间 \([l,r]\) 两边都可以走人,那么有 \(mid=(l+r)>>1\),\([l,mid]\) 走左边, \([mid+1,r]\) 走右边。
有了这个,我们还需要状态。我们需要知道同色区间,但是现在两端都不知道在哪。这题里我们要转移,就必须要知道某一段全是同一种的区间。而如果我们用区间 dp,很难去表示它(因为 \(dp_{l,r}\) 表示全是同一种颜色的话,最后 \(dp_{1,n}\) 是啥呢;如果不是同一种,那么我们枚举这段区间有什么用呢),最简单的,区间 dp 复杂度 \(O(n^3)\),通过不了本题。所以我们考虑固定右端点,去枚举它的左端点,它们之间是同色段。设状态 \(dp_{i,0/1}\) 表示已经考虑完前 \(i\) 个,第 \(i\) 个放乒乓球桌/游泳池的最小总距离,转移跟上面说的一样。
\(\begin{cases}dp_{i,0}=\min_{0\le j < i}(dp_{j,1}+cost(j+1,i,0))\\dp_{i,1}=\min_{0\le j < i}(dp_{j,0}+cost(j+1,i,1))\end{cases}\)
\(cost(l,r,co)\) 表示区间 \([l,r]\) 染 \(co\) 色的代价。这样,我们既可以考虑到同色段,也考虑到了同色段之外的段的贡献,并且我们固定了右端点,就不需要考虑右端点右边的代价(因为不管右边怎么变,左边的代价还是只跟左边有关)。最后答案是 \(\min(dp_{n,0},dp_{n,1})\)。
#include<bits/stdc++.h>
using namespace std;
int T, n, cas;
long long pre[4010][2], suf[4010][2], prep[4010][2], suff[4010][2], t[4010], p[4010], dp[4010][2];
void init(){
memset(pre, 0, sizeof(pre));
memset(suf, 0, sizeof(suf));
memset(prep, 0, sizeof(prep));
memset(suff, 0, sizeof(suff));
}
long long cost(int l, int r, int co){
if(l == 1 && r == n) return 0x3f3f3f3f3f3f3f;
if(l == 1) return prep[r][co];
if(r == n) return suff[l][co];
int mid = (l + r) >> 1;
long long tot = 0;
tot += suff[l][co] - suff[mid + 1][co] - (mid - l + 1) * suf[mid + 1][co];
tot += prep[r][co] - prep[mid][co] - (r - mid) * pre[mid][co];
return tot;
}
int main(){
cin >> T;
for(int k = 1; k <= T; k++){
init();
cas++;
cin >> n;
for(int i = 1; i <= n; i++){
cin >> t[i] >> p[i];
pre[i][0] = pre[i - 1][0] + t[i];
pre[i][1] = pre[i - 1][1] + p[i];
prep[i][0] = prep[i - 1][0] + pre[i][0];
prep[i][1] = prep[i - 1][1] + pre[i][1];
}
for(int i = n; i >= 1; i--){
suf[i][0] = suf[i + 1][0] + t[i];
suf[i][1] = suf[i + 1][1] + p[i];
suff[i][0] = suff[i + 1][0] + suf[i][0];
suff[i][1] = suff[i + 1][1] + suf[i][1];
}
for(int i = 1; i <= n; i++) dp[i][0] = dp[i][1] = 0x3f3f3f3f3f3f3f;
dp[0][0] = dp[0][1] = 0;
for(int i = 1; i <= n; i++){
for(int j = 0; j < i; j++){
dp[i][0] = min(dp[i][0], dp[j][1] + cost(j + 1, i, 0));
dp[i][1] = min(dp[i][1], dp[j][0] + cost(j + 1, i, 1));
}
}
cout << "Case #" << cas << ": " << min(dp[n][0], dp[n][1]) << endl;
}
return 0;
}
简化题意,要在原图上找一颗生成树覆盖所有边,并且每个点连它的父亲的长度乘上到根的距离的和最短。
看到数据范围,猜测它是状压 dp。考虑当前连了多少点,并且由于转移跟点到根的距离有关,我们可能会设 \(dp_{i,j}\) 表示以 \(i\) 为根,状态为 \(j\) 的最小代价。但是我们仔细想会发现这样还是不好转移(因为根不变,只有状态还是不能转移,并且我们还是不知道距离,并且,节点状态为本身时,它就是根节点,而且根节点不能转移,一看就是多余的)。
我们再观察树,因为是一颗树,所以点到根的距离其实就是它的深度,把树整理好它会是一层一层的,并且如果要到更远的点,前面一定会先走更近的点。树中有一个东西和距离很像,树高。它相当于表示目前最远的一些点离根的距离,或许可以用它来转移,因为当前的树高确定下来,只需要知道最后一层的节点,就可以知道代价,各个树高之间是可以转移的。
设状态 \(dp_{i,j}\) 表示当前树高为 \(i\),已选节点状态为 \(j\) 时的最大价值。我们一层一层来转移状态,要算 \(dp_{i,j}\),我们只需要再枚举树高 \(i-1\) 时的状态 \(k\),也就是 \(j\) 的子集,转移就是
\(cost_{k,j}\) 表示从状态 \(k\) 到状态 \(j\) 的最小代价。这个是可以预处理出来的,因为我们从上一层到下一层,有且仅会经过一条边,所以这里的状态 \(k\) 到状态 \(j\),中间是不会经过其他点的,所以从 \(k\) 变成 \(j\) 需要的点一定只需要通过 \(k\) 中其中一个点的一条边就能连接。所以总最小代价就是每个需要的点连接到状态 \(k\) 里的最小代价之和。两个不能转移的状态直接赋值成极大值。
到这里,问题是,我们枚举的子集 \(k\) 与原集合 \(j\) 产生的差集一定会在第 \(i\) 层吗?好像不能确定,如果是这样那么这么转移不会是错解吗?但是我们可以证明这是不会影响答案的,也就是差集不管在不在第 \(i\) 层,最终答案都不会是由错误的计算更新的。
证:设 \(p\) 为 \(j\) 和 \(k\) 的差集,假设 \(p\) 中的有一些点不是由 \(k\) 中的最大深度的点连接,那么一定存在一个集合 \(q\),为集合 \(k\) 再加上这些不是由 \(k\) 的最大深度的点连接的点,从这个状态 \(dp_{i-1,q}\) 转移一定会覆盖掉 \(dp_{i-1,k}\) 这样的不优解(因为不在最深层的边你也按最深层的代价计算了,结果肯定会被 \(q\) 这样把算错的点包含起来的状态(相当于 \(p\) 改错好的状态)覆盖),证毕。
#include<bits/stdc++.h>
using namespace std;
int n, m;
long long ans = 0x3f3f3f3f3f3f3f3f;
long long dp[15][1 << 14], f[15][15], po[15], cost[1 << 13][1 << 13];
int main(){
cin >> n >> m;
memset(f, 0x3f, sizeof(f));
for(int i = 1; i <= m; i++){
int u, v;
long long w;
cin >> u >> v >> w;
f[u][v] = f[v][u] = min(f[u][v], w);
}
po[0] = 1;
for(int i = 1; i <= n; i++){
po[i] = po[i - 1] * 2;
}
for(int i = 1; i < (1 << n); i++){
for(int j = i; j; j = (j - 1) & i){
bool flg = 0;
int now = i ^ j;
for(int p = n; p >= 1; p--){
long long minn = 0x3f3f3f3f3f3f3f3f;
if((po[p - 1] & now) == po[p - 1]){
for(int q = 1; q <= n; q++){
if((po[q - 1] & j) == po[q - 1]){
minn = min(minn, f[p][q]);
}
}
if(minn == 0x3f3f3f3f3f3f3f3f){
flg = 1;
break;
}
cost[j][i] += minn;
}
}
if(flg){
cost[j][i] = 0x3f3f3f3f3f3f3f3f;
}
}
}
memset(dp, 0x3f, sizeof(dp));
for(int i = 1; i <= n; i++) dp[1][1 << (i - 1)] = 0;
for(int k = 2; k <= n; k++){
for(int i = 1; i < (1 << n); i++){
for(int j = i; j; j = (j - 1) & i){
if(cost[j][i] == 0x3f3f3f3f3f3f3f3f) continue;
dp[k][i] = min(dp[k][i], dp[k - 1][j] + cost[j][i] * (k - 1));
}
}
}
for(int i = 1; i <= n; i++) ans = min(ans, dp[i][(1 << n) - 1]);
cout << ans << endl;
return 0;
}
Generate a String
和入门动态规划时讲的题很像,但是它多了一种步骤,可以删除字符,并且我们分析发现,这种做法并不是无用的。
如果我们按照之前的状态 \(dp_i\) 表示生成前 \(i\) 字符的最小代价,转移就是
\(\begin{cases}dp_i=\min(dp_{i-1},dp_{i+1})\\dp_i=\min(dp_i,dp_{i/2})\ (i\bmod 2=0)\end{cases}\)
这样的 \(dp\) 是没办法递推的,因为更新 \(dp_i\) 时 \(dp_{i+1}\) 还没更新。一种思路是把状态抽象成一个点,去跑最短路,但是通过不了。
我们考虑*为什么会有删除操作,因为前面翻倍之后超过了 \(i\),要删去,所以删除操作还可以表示成 \(dp_i=\min_{2\times k\ge i}(dp_k+y+(2\times k - i)\times x)\),转化一下就变成 \(dp_i=\min_{2\times k\ge i}(dp_k+2\times k\times x)+y-i\times x\) 容易发现 \(\min\) 里面的值是可以用单调队列维护的,队列中的值对应在序列里也是单调的。
在思考,发现删除操作最多只会连续做一次,因为如果超过两次,为什么不在翻倍前就删呢,翻倍了删的数也翻倍,一定不优。所以直接把 \(dp_{i+1}\) 的状态再往前推,是 \(dp_{(i+1)/2}\),这样第三行转移就可以写成 \(dp_i=\min(dp_i,dp_{(i+1)/2})\ (i\bmod 2=1)\)
Games with Rectangle
在 \(n\times m\) 的网格里选 \(k\) 个矩形,要求矩形一个套一个,不能重叠,求不同的方案数。
首先数据范围是不允许超过 \(O(n^2)\) 的,所以也就不能同时枚举三个值或者枚举端点。
根据乘法原理,总方案数等于横列合法方案数乘上纵列合法方案数,所以我们分别求出两个的方案数再相乘就是答案。每次转移跟层数和每次的长度有关,并且我们可以把 \(n\) 和 \(m\) 作为最后的长度,即题目给出的网格是第 \(k+1\) 个矩形。所以设状态 \(dp_{i,j}\) 表示已经从内到外考虑到第 \(i\) 层,第 \(i\) 层的长度为 \(j\) 的合法方案数,转移是
\(dp_{i,j}=\sum\limits_{1\le k\le j-2}dp_{i-1,k}\times (j-k+1)\)
但是即使这样复杂度还是 \(O(n^2k)\),考虑优化。空间上容易发现 \(i\) 这一维可以降维掉。后面的式子很有规律,随着 \(k\) 的变化,乘的数不断变大,似乎 \(dp_{i,j}\) 和 \(dp_{i,j-1}\) 转移之后区别不大。
\(dp_{i,j}=\sum\limits_{1\le k\le j-2}dp_{i-1,k}\times (j-k+1)\)
\(dp_{i,j-1}=\sum\limits_{1\le k\le j-3}dp_{i-1,k}\times (j-k)\)
只是枚举的 \(k\) 多了一,列出式子发现之间就少了 \(\sum\limits_{1\le k \le j-2}dp_{i-1,k}\),这部分明显可以用前缀和维护。转移可以写成
\(dp_{i,j}=dp_{i,j-1}+\sum\limits_{1\le k \le j-2}dp_{i-1,k}\)。
复杂度降到 \(O(n^2)\),初始化为 \(dp_{1,i}=1\) 答案为 \(dp_{k+1,n}\times dp_{k+1,m}\),\(k+1\) 具体在代码中表现为循环 \(k\) 次。
仔细思考后,发现这题既然用乘法原理简化到求横纵坐标各自的方案,其实就是在横纵坐标中各取 \(2\times k\) 个点的方案数,为 \(C^{2\times k}_{n-1}\) 和 \(C^{2\times k}_{m-1}\) ,两个相乘就是答案。为什么不用考虑合不合法呢,因为对于每种组合,我们每次都取最大最小横坐标和最大最小纵坐标作为下一步,重复这个操作,最后一定是合法的。
CF837D Round Subset
从 \(n\) 个数中选 \(k\) 个数,使得相乘后末尾的 \(0\) 最多。容易发现,末尾的 \(0\) 由 \(2\times5\) 得到。所以末尾 \(0\) 的个数就是 \(k\) 个数中 \(2\) 的总个数和 \(5\) 的总个数的最小值。每个数我们都可以预处理出 \(2\) 和 \(5\) 的个数。
这是一个选数问题,基本的状态 \(dp_{i,j}\) 表示前 \(i\) 个数选了 \(j\) 个,因为同时要考虑到 \(2\) 和 \(5\) 的个数,我们可以借鉴 P2340 [USACO03FALL]Cow Exhibition G 一题中的思想,把 \(2\) 抽象成重量,求 \(5\) 最多为多少。所以状态可以再加一维,\(dp_{i,j,k}\) 表示前 \(i\) 个数选了 \(j\) 个,\(2\) 的个数为 \(j\) 个时,\(5\) 最多为多少个。由于 \(j\) 并不是虚值,所以我们要初始化 \(dp_{0,0,0}=0\)。
\(dp_{i,j,k}=\max(dp_{i-1,j,k},dp_{i-1,j-1,k-b_i}+c_i)\)
\(b_i\) 表示 \(2\) 的个数,\(c_i\) 表示 \(5\) 的个数。可以发现 \(i\) 一维是可以用滚动数组的。注意的是,用滚动数组,不能少转移,比如 \(k<b_i\) 时,也要写 \(dp_{i,j,k}=dp_{i-1,j,k}\),不能直接去掉。统计答案时,我们枚举 \(2\) 的个数,\(ans=\max(ans,\min(i,dp_{n,k,i}))\),为 \(2\) 的个数与 \(5\) 的个数的最小值。
CF14D Two Paths
求树中两条链,链不相交,使得两条链的长度相乘乘积最大。如果这题不是求乘积,其实就是求树的直径,设状态 \(dp_u\) 表示以 \(u\) 为根的最长链。
\(\begin{cases}ans=\max(ans,dp_u+dp_v+1)\\dp_u=\max(dp_u,dp_v+1)\end{cases}\)
而思考不相交的两条链,有的性质是,他们至少能用一条边使得它们相连,也就是说,它们之间隔着至少一条边。
那思路就有了,我们枚举他们之间隔着的边,这棵树就被分成了两棵树,分别在两棵树里面求树的直径,两个相乘就是至少隔着这条边时的最大值。依次更新 \(ans\) 就可以了。
CF527D Clique Problem
虽然这题的优解是贪心而不是 \(dp\),但是 \(dp\) 的角度也是很精彩的。这道题的突破口是 \(abs(x_i-x_j)\ge w_i+w_j\)。如果单纯的不加优化,这样是必须要枚举两个数的,但是我们假设只枚举一个数 \(x_i\),并且 \(x_i\ge x_j\),式子就可以变成 \(-x_j-w_j\ge w_i-x_i\),这里是把下标相同放一边,也就是 \(x_j+w_j\le x_i-w_i\)。所以对答案有影响的就是每个点的 \(x_i+w_i\) 和 \(x_i-w_i\)。我们枚举到一个 \(i\),就可以用 \(x_i-w_i\) 这个条件求出满足的点,具体操作就是把记录 \(x_i+w_i\) 的数组排序,二分查找第一个大于 \(x_i-w_i\) 的下标 \(pos\),\(pos-1\) 都是合法的。
解决完这个,我们考虑状态,如果是 \(dp_i\) 表示前 \(i\) 个点的话,可我们上面筛出来的点并不是在 \([1,i)\) 之内的,并且我们要上面筛的点的 \(dp\) 值,如果是无序的不好找。所以状态需要顺序,设状态 \(dp_i\) 为第 \(i\) 大的 \(x_i+w_i\) 构成的最大团点数。这时候更新是有顺序的,需要转移的状态是在一起的,并且二分查找可以去重了,我们要的是满足条件的状态,而不是点数,所以可以用到线段树优化维护 \(dp_i\),二分查找找到第 \(i\) 大对应的点,单点修改,查询区间最大值,\(dp_i=\max(maxn+1)\)。\(maxn\) 表示筛出的点中的最大 \(dp\) 值。复杂度为 \(O(nlogn)\)
贪心的方法就是转换条件,可以发现 \(x_i+w_i\) 和 \(x_i-w_i\) 映射在数轴上就是一段区间,把 \(l_i=x_i+w_i\),\(r_i=x_i-w_i\)。满足 \(r_i\ge l_j\) 的两点是有边的,那么题目就转换成,在数轴上取最多线段,每条线段不相互覆盖。直接贪心,从 \(r\) 最小的线段开始取,能取就取。复杂度为 \(O(nlogn)\),常数比 \(dp\) 小。
P4310 绝世好题
选数问题,通常从左往右考虑,这题的限制不多,只需要状态 \(dp_i\) 表示考虑前 \(i\) 个,并且选了第 \(i\) 个的最长长度。这题唯一给出的题目限制是序列 \(b\) 中 \(b_i\&b_{i-1}\ne0\)。 朴素的,如果不加优化,转移是
\(dp_i=\max_{1\le j<i}(dp_i,dp_j+1)(a_i\&a_j\ne0)\)
这是很容易想到的,瓶颈是复杂度是 \(O(n^2)\) 的。我们考虑什么样的 \(j\) 对 \(i\) 是有贡献的。由于是 \(\&\),所以能不能转移,跟二进制中的 \(1\) 有关,容易发现,在二进制中,如果 \(a_j\) 与 \(a_i\) 在同一位上都有 \(1\),这样的 \(j\) 是有贡献的。所以就有一个想法,二进制的每一位决定了你之后对什么样的数有贡献,我们可以对二进制的每一位都建立一个优先队列,每枚举到一个数都进行二进制拆分,拆到 \(1\),说明它在这一位上对之后的 \(a_i\) 是有贡献的,把它存到这一位的优先队列里,之后如果有一个 \(a_i\) 的二进制某一位是 \(1\),直接从这一位的优先队列中取出最大值更新就可以了。
在这个优化里,我们优化了枚举 \(j\) 的时间,换成了常数小的二进制拆分,又因为我们只关心有贡献的每一位上的最大值,所以用到了优先队列,优化了在同一位上有贡献的数多余的更新。
复杂度约为 \(O(nlogn)\)。
P4158 [SCOI2009]粉刷匠
给定 \(n\) 条 \(m\) 格的木板,每次可以给一个区间涂色,要求最多涂 \(t\) 次正确涂色的最多格子数。观察题目,不同木板之间是不会相互影响的,
所以我们可以很快求出一块木板涂色几次的最多正确格子数,设状态 \(f_{i,j,k}\) 表示第 \(i\) 块木板涂到第 \(j\) 格,期间涂了 \(k\) 次的最多正确格子数,由于此题对区间长度没有要求,并且涂长了不会影响答案,我们可以直接默认涂的每个区间都是紧密贴合的,也就是不留不涂色的,这样状态也可以很快就列出来
\(f_{i,j,k}=\max_{1\le p\le j}(f_{i,j,k},f_{i,p-1,k-1}+\max(sum0_{i,j}-sum0_{i,p-1},sum1_{i,j}-sum1_{i,p-1}))\)
\(sum_{i,j}\) 表示我们预处理的每块木板 \(1\) 和 \(0\) 的前缀和,枚举最近的区间左端点 \(p\),在区间 \([p,j]\) 我们涂 \(1\) 和 \(0\) 中最多的数字,这样是最优的。
于是我们就知道了每块木板各种情况下的贡献,不用担心木板内部的情况,我们现在只需要考虑怎么分配涂色次数。按顺序涂色,把每块木板抽象成一个物品,我们投入的次数越多,贡献越大,所以我们从上到下考虑,设状态 \(dp_{i,j}\) 表示前 \(i\) 块木板最多涂了 \(j\) 次的最多正确格子数,每次枚举这次要在第 \(i\) 块木板上涂 \(k\) 次,那么状态就是从 \(dp_{i-1,j-k}\) 上转移而来。
\(dp_{i,j,k}=\max_{0\le k\le min(m,t)}(dp_{i-1,j},dp_{i-1,j-k}+f_{i,m,k})\)
答案 \(ans=\max_{0\le i\le t}(dp_{n,i})\)。题目中有些地方不用跑 \(t\) 次,原因是每块木板最多 \(50\) 格,所以只需要知道涂色次数与格数的最小值之内的状态就行了,
不然复杂度肯定过不了,还有要注意左端点是可以和右端点重合的,也就是只涂一格,一块木板也可以一次都不涂,也要转移。
P1772 [ZJOI2006] 物流运输
有 \(n\) 天,每天要从 \(1\) 送到 \(n\),这期间某些天某些点会损坏,更换路线要额外加钱,求 \(n\) 天所花的最少花费。一看到这题,就直接想到 \(dp_{i,j}\) 表示前 \(i\) 天换了 \(j\) 次,但是这样列不出方程,因为我们不能决定要走哪条路,事实上当前的最短路并不是最优的,有可能这次最短但是导致要给换路价钱。
思考之前的状态有什么缺点,这道题并没有限制我们的更换次数,我们记录它并没有用,我们记录的初衷可能是想表示这次换不换,但是知道这一次换和不换不能顾及到整个局面,况且你不换也不一定就是你上次走的最短路。
我们重点要解决决定路线的问题,观察条件,更换路线要加钱,并且每天都得送货,这使得这 \(n\) 天被分为了若干个连续的区间,也可以观察到并不是每一段连续的天数都可以走同一条路的(有的时候必须换路),而且每个区间里走的是同一条路,并且一定是最短路。这是一个很特别的性质,这样就有了一个结论,只要当前区间确定下来,并且是合法的(即可以走同一条路),你就可以知道价钱。所以这题就变成了一个分段问题,我们可以固定右端点,枚举左端点来确定一个最近的区间,并且默认左端点前的路线不同,固定这个区间走同一条路,跑这些天里的最短路,就是价钱。
状态只需要一维,即 \(dp_i\) 表示前 \(i\) 天的最短价钱,我们不关心换了几次,每次换了就直接实时加换路钱就行了。
\(dp_i=\min_{1\le j\le i}(dp_i,dp_{j-1}+(i-j+1)\times cost+k)\)
由于只需要单源最短路,所以直接跑 \(spfa\) 或 \(dijkstra\) 都可以。注意的是 \(j\) 可以倒序枚举,因为后面不能走的点,前面还是不能走,如果当前 \([j,i]\) 已经不能用同一条路了,那前面也不行,直接不枚举了。初始化 \(dp_0=-k\),因为第一次换路线不用钱,更新时遇到这个状态是会直接把 \(k\) 抵消掉,相当于没花钱。
为什么可以默认前面的 \(j-1\) 路线不同呢? 我们想一下如果 \(j-1\) 路线一样会怎么样,这时当我们往左就会枚举到真正意义上的断点时(也就是不能走之前的路,换了一条路时),之前的在 \(j\) 时的左端点一定不优(因为相同路线换成相同路线还要花钱),并且一定会被真断点给覆盖掉。错解一定会被覆盖,不会影响答案。
#include <bits/stdc++.h>
using namespace std;
int read(){
int x = 0, f = 1;
char c = getchar();
while(c < '0' || c > '9'){
if(c == '-') f = -1;
c = getchar();
}
while(c >= '0' && c <= '9'){
x = (x << 1) + (x << 3) + (c - '0');
c = getchar();
}
return x * f;
}
int n, m, k, e, cnt;
bool vis[300][1100], flg[300], inq[300];
int dp[1100], h[1100], dis[300];
struct node{
int to, nxt, w;
}ed[4100];
void add(int u, int v, int w){
ed[++cnt].to = v;
ed[cnt].nxt = h[u];
ed[cnt].w = w;
h[u] = cnt;
}
int spfa(){
queue<int> q;
for(int i = 1; i <= m; i++) dis[i] = 0x3f3f3f3f, inq[i] = 0;
inq[1] = 1;
dis[1] = 0;
q.push(1);
while(!q.empty()){
int u = q.front();
q.pop();
inq[u] = 0;
for(int i = h[u]; i; i = ed[i].nxt){
int v = ed[i].to;
if(flg[v]) continue;
if(dis[v] > dis[u] + ed[i].w){
dis[v] = dis[u] + ed[i].w;
if(!inq[v]){
inq[v] = 1;
q.push(v);
}
}
}
}
return dis[m];
}
int main(){
n = read(), m = read(), k = read(), e = read();
for(int i = 1; i <= e; i++){
int u = read(), v = read(), w = read();
add(u, v, w), add(v, u, w);
}
int d = read();
for(int i = 1; i <= d; i++){
int num = read(), a = read(), b = read();
for(int j = a; j <= b; j++){
vis[num][j] = 1;
}
}
memset(dp, 0x3f, sizeof(dp));
dp[0] = -k;
for(int i = 1; i <= n; i++){
for(int j = 1; j <= m; j++) flg[j] = 0;
for(int j = i; j >= 1; j--){
for(int k = 1; k <= m; k++) if(vis[k][j]) flg[k] = 1;
int res = spfa();
if(res == 0x3f3f3f3f) break;
dp[i] = min(dp[i], dp[j - 1] + (i - j + 1) * res + k);
}
}
cout << dp[n] << endl;
return 0;
}
P3861 拆分
要求把 \(n\) 拆成一些不小于 \(2\) 的互不相同的数的乘积的方案数。看到数据范围 \(n\le 10^{12}\),肯定要先缩小范围。容易发现,\(n\) 只可能由它的因数相乘得来,每个数都是如此。所以 \(n\) 的转移只与 \(n\) 的因数有关,我们可以用 \(O(\sqrt{n})\) 的方法把因数筛出来,并且因数很少,\(d(10^{12})\le 6720\)。
由于 \(n\) 就是通过这些数得到,并且不需要合成,只需要把数选出来就行了,更不需要考虑选出的数有没有互不相同的问题了(每个数独一无二,只选一次)。
题目就转化成了,在这些因数中选一些数,组成 \(n\) 的方案数有多少。这不就是背包问题吗,只是这里选出的物品变成了相乘。先把因数从小到大排序,因为大数由小数得到。
因为 \(n\) 只可能由它的因数相乘得来,其他都是多余的。如果两个因数相乘不是因数,那就是没有用的,因为它一定拼不出 \(n\),即我们要枚举的重量只可能是 \(n\) 的因数。所以,我们最终只关心因数的拆分方案,可以设状态 \(dp_{i,j}\) 表示前 \(i\) 个数选出若干个数,表示出 \(p_j\) 的方案数,省去了很多空间和时间。
\(\begin{cases}dp_{i,j}=dp_{i-1,j}\\dp_{i,j}=dp_{i,j}+dp_{i-1,k}(p_j\bmod p_i=0)\end{cases}\)
这里的 \(k=pos1_{p_j/p_i}\ /\ pos2_{n/p_j/p_i}\),其中 \(p_j/p_i\),必须是整除,如果是整除,那么这个数一定在原来的因数中(\(a\mid b\),\(b\mid c\),则 \(a\mid c\))。\(pos1_i\) 是我们预处理的每个数的位置,对于大于 \(\sqrt{n}\) 的数,由于一定有一个小于 \(\sqrt{n}\) 的数与之对应,所以记 \(pos2_{p_i}=n-i+1\),空间复杂度降到 \(O(\sqrt{n})\)。这里的转移很基础,就是分为选/不选第 \(i\) 个数,选了就是从 \(k\) 上转移。
注意,这里求的是方案数,在不能降维的情况下,一定不能漏转移,也就是 \(j\) 必须从 \(1\) 到 \(tot\) 不能遗漏转移 \(dp_{i,j}=dp_{i-1,j}\),因为这对于所有情况都成立,其他情况在 \(j<i\) 时可以跳过。
复杂度约为 \(O(T(\sqrt{n}+d(n)^2))\)。
#include<bits/stdc++.h>
using namespace std;
long long t, n, mod = 998244353, cnt;
long long dp[8010][8010], p[8010];
int pos1[1000010], pos2[1000010];
int main(){
cin >> t;
while(t--){
cnt = 0;
cin >> n;
long long sqrn = sqrt(n);
for(long long i = 1; i * i <= n; i++){
if(n % i == 0){
p[++cnt] = i;
if(n / i != i) p[++cnt] = n / i;
}
}
sort(p + 1, p + cnt + 1);
for(int i = 1; i * 2 <= cnt + 1; i++){
pos1[p[i]] = i;
pos2[p[i]] = cnt - i + 1;
}
memset(dp, 0, sizeof(dp));
dp[1][1] = 1;
for(int i = 2; i <= cnt; i++){
for(int j = cnt; j >= 1; j--){
dp[i][j] = dp[i - 1][j];
if(j < i) continue;
if(p[j] % p[i] == 0){
long long now = p[j] / p[i];
int k = (now <= sqrn) ? pos1[now] : pos2[n / now];
dp[i][j] = (dp[i][j] + dp[i - 1][k]) % mod;
}
}
}
cout << dp[cnt][cnt] - 1 << endl;
}
return 0;
}