专栏文章
Atcoder Educational DP Contest 题解
题解参与者 1已保存评论 0
文章操作
快速查看文章及其快照的属性,并进行相关操作。
- 当前评论
- 0 条
- 当前快照
- 1 份
- 快照标识符
- @minat1t0
- 此快照首次捕获于
- 2025/12/01 23:22 3 个月前
- 此快照最后确认于
- 2025/12/01 23:22 3 个月前
update on 2025.11.10 11:25 完成 F,G,I,J.
update on 2025.11.10 17:30 完成 K,L,M,N,O.
update on 2025.11.10 21:45 完成 P,Q,R.
update on 2025.11.14 22:07 完成 S.
注
过于简单的 A,B,C,D,E,H 不再赘述。
目前尚未完成 T,U,V,W,X,Y,Z 共 题题解。
F. LCS
先求出 LCS,然后从后往前逆推一遍,具体地,从 开始推,记现在到了 ,则:
- 若 ,说明这一位匹配,记录答案(先存起来,因为顺序是反着的),并推到 。
- 解释:对应转移 。
- 否则,这一位不匹配,找到 中较大的转移过去,具体地:
- 若 ,;
- 否则,。
- 解释:对应转移 。
G. Longest Path
DAG 最长路,拓扑排序的过程中记录 表示走到 的最长路,注意答案是 .
I. Coins
概率 DP,设 表示前 枚硬币,扔出 正 反的概率。
初值:。
转移:
-
。
-
边界转移:
- 。
- 。
时间复杂度 。到这里足以 AC 本题了。
不过我们还可以优化一下,发现我们关注的是 的值,因此可以把状态缩减到一维。
设 表示到目前位置正的硬币数量比反着的 多了 个 的概率。
转移类似,不过注意一个问题。
假设我们现在已经抛了 枚硬币,那么此时 的奇偶性一定与 相同。
原因很简单,分讨一下:
- 若 为奇数,那么正、反可能分别为:奇、偶;偶、奇。而 奇 - 偶 = 偶 - 奇 = 奇。
- 若 为偶数,那么正、反可能分别为:偶、偶;奇、奇。而 奇 - 奇 = 偶 - 偶 = 偶。
那么更新时注意下奇偶性即可,优化表现在空间上,空间复杂度 降到 。
这种只关注两维差值的 DP 的这种 Trick 很常见,比如 [CSP-S 2019] Emiya 家今天的饭 中也有应用。
代码
CPP#include <bits/stdc++.h>
using namespace std;
int n;
double ans,p,dp[6005];
int main()
{
scanf("%d",&n);
dp[n] = 1;
for (int i=1;i<=n;i++)
{
scanf("%lf",&p);
for (int j=n-i;j<=n+i;j++)
{
if (i % 2 == (j+n) % 2) dp[j] = dp[j-1] * p + dp[j+1] * (1-p);
}
}
for (int i=1;i<=n;i+=2) ans += dp[n+i];
printf("%.10lf",ans);
return 0;
}
J. Sushi
蛮好的一道 期望 DP 入门题。
很容易发现 特别小,并且各个 之间与位置没有关系。
换而言之,
1 3 2 和 2 3 1 的答案是相同的,因为每次选中各个盘子的概率相同。因此,我们考虑设 表示盘子里有 个寿司的盘子分别有 个时,全部吃完的期望。
这个状态定义空间是 的,而 ,完全没问题。
接下来看转移:
-
如果取了有 个寿司的盘子;
- 概率为 。
- 转移到 (因为有个 的盘子变成了 ),并且期望 。
- 即:.
-
如果取了有 个寿司的盘子;
-
概率为 。
-
转移到 (因为有个 的盘子变成了 ),并且期望 。
-
即:.
-
-
如果取了有 个寿司的盘子;
- 概率为 。
- 转移到 (因为有个 的盘子变成了 ),并且期望 。
- 即:.
-
如果取了有 个寿司的盘子;
- 概率为 。
- 转移到 (因为有个 的盘子变成了 ,本质不变),并且期望 。
- 即:.
整理得到转移式子:
发现存在「自己转移到自己」的情况,不满足 DP「无后效性」的要求。
怎么办?移项!
先把 拆开:
再移项:
合并同类项,两边同时乘上 :
把左边的系数 移过去,得到最终转移式:
根据上面的转移式转移即可,注意几点:
-
初始化 ,不要把 (即 时再转移一下,否则除 错误)。除 错误在
double类型中 不会 RE,会变成 。 -
由于转移存在 ,因此注意 三种情况。
-
取值范围,。注意后面两者,因为 会变成 , 会变成 。
代码
CPP#include <bits/stdc++.h>
using namespace std;
int n,x,c[4];
double dp[305][305][305];
int main()
{
scanf("%d",&n);
for (int i=1;i<=n;i++)
{
scanf("%d",&x);
c[x]++;
}
for (int i=0;i<=c[3];i++)
{
for (int j=0;j<=c[2]+c[3];j++)
{
for (int k=0;k<=c[1]+c[2]+c[3];k++)
{
if (i+j+k == 0) continue;
if (i) dp[i][j][k] += (dp[i-1][j+1][k]+1) * (i*1.0/(i+j+k));
if (j) dp[i][j][k] += (dp[i][j-1][k+1]+1) * (j*1.0/(i+j+k));
if (k) dp[i][j][k] += (dp[i][j][k-1]+1) * (k*1.0/(i+j+k));
dp[i][j][k] += (n-(i+j+k))*1.0/(i+j+k);
}
}
}
printf("%.10lf",dp[c[3]][c[2]][c[1]]);
return 0;
}
K. Stones
坑,开始一眼完全背包,设 表示还有 个石子时谁必胜( 先手, 后手)。
代码
CPP#include <bits/stdc++.h>
using namespace std;
int n,k,x;
bool dp[100005];
int main()
{
scanf("%d%d",&n,&k);
for (int i=1;i<=n;i++)
{
scanf("%d",&x);
for (int j=x;j<=k;j++)
{
dp[j] = dp[j] | (dp[j-x]^1);
}
}
if (dp[k]) puts("First");
else puts("Second");
return 0;
}
过了所有样例,But Wrong Answer.
为什么?发现 Hack:
【输入 #1】
INPUT12 3
1 2
【输出 #1】
OUTPUT1Second
因此,错误的原因就在
dp[j] = dp[j] | (dp[j-x]^1); 这行。因为 可能此时 并不是全局最优决策,我们就把它用上了,所以会导致后面决策的问题。在这个 Hack 中的表现就是,初始 ,然后更新 。
异或的原因是先后手互换。
所以内外层循环换个位置即可,时间复杂度 。
代码
CPP#include <bits/stdc++.h>
using namespace std;
int n,k,a[105];
bool dp[100005];
int main()
{
scanf("%d%d",&n,&k);
for (int i=1;i<=n;i++) scanf("%d",&a[i]);
for (int i=1;i<=k;i++)
{
for (int j=1;j<=n;j++)
{
if (i-a[j] < 0 || dp[i-a[j]]) continue;
dp[i] = 1;
break;
}
}
if (dp[k]) puts("First");
else puts("Second");
return 0;
}
L. Deque
可以先看这道题 iai 2024年3月乙组 - 交替取数,没有太多的分类讨论,但思路相似。
发现每次取完之后剩下的都是一个区间,考虑 区间 DP。
设 为区间 的答案(),那么我们可以根据 计算出来此时该大郎(下文记作 A)还是二郎(下文记作 B)操作。
具体地,若 ,此时该 A,反之轮到 B,比较好想。
那么根据操作方分类讨论:
- 若此时轮到 A(所取的数作为 ,要求 尽可能大):
- 如果取 ,那么答案变为 。
- 如果取 ,那么答案变为 。
- 由此,转移 。
- 若此时轮到 B(所取的数作为 ,要求 尽可能小):
- 如果取 ,那么答案变为 。
- 如果取 ,那么答案变为 。
- 由此,转移 。
最后看初值,对 A、B 取最后一个分类讨论。
- 若 A 取(作 ):。
- 若 B 取(作 ):。
答案即为 。
时空复杂度 。
代码
CPP#include <bits/stdc++.h>
using namespace std;
#define intt long long
int n;
intt a[3005],dp[3005][3005];
int main()
{
scanf("%d",&n);
for (int i=1;i<=n;i++)
{
scanf("%lld",&a[i]);
if (n % 2) dp[i][i] = a[i];
else dp[i][i] = -a[i];
}
for (int len=2;len<=n;len++)
{
for (int l=1;l+len-1<=n;l++)
{
int r = l+len-1;
if (len % 2 == n % 2)
{
dp[l][r] = max(a[l]+dp[l+1][r],a[r]+dp[l][r-1]);
}
else
{
dp[l][r] = min(dp[l+1][r]-a[l],dp[l][r-1]-a[r]);
}
}
}
printf("%lld",dp[1][n]);
return 0;
}
M. Candies
显然的 DP:设 表示前 个人一共发了 个糖果。
边界:,目标:。
转移只需枚举给第 个人发了 个糖果:
时间复杂度 ,无法通过。
发现这个形式是一段 DP 数组的求和,可以写成前缀和的形式。
具体地,记 ,那么转移可写作:
又因为 数组只需要对 更新完后扫一次即可,因此 的计算是 的。
这种优化方式被称为 前缀和优化 DP。
总时空复杂度均为 。
Trick
由于本题中允许分 个糖果,因此 存在且不为 。
因此 时会出现越界问题,我们不妨把 的实际值存在 上,即下标统一加一。
这样就可以很好的避免这个问题,查询时从”上界不变,下界 “ 变为 “上界 ,下界不变” 即可。
还可以进一步优化,发现 数组的转移和第一维 无关,可以直接优化掉一维。
空间复杂度优化到 。
代码
CPP#include <bits/stdc++.h>
using namespace std;
const int p = 1e9+7;
int n,k,x;
int sum[100005],dp[100005];
int mod(int x)
{
if (x < 0) x += p;
if (x >= p) x -= p;
return x;
}
int main()
{
scanf("%d%d",&n,&k);
dp[0] = 1;
for (int j=0;j<=k;j++) sum[j+1] = 1;
for (int i=1;i<=n;i++)
{
scanf("%d",&x);
for (int j=0;j<=k;j++)
{
dp[j] = mod(sum[j+1]-sum[j-min(j,x)]);
}
for (int j=0;j<=k;j++)
{
sum[j+1] = mod(sum[j]+dp[j]);
}
}
printf("%d",dp[k]);
return 0;
}
N. Slimes
同 P1775. 石子合并(弱化版),区间 DP 枚举分界点即可。
O. Matching
我居然切蓝的 DP 了???
很小,考虑一些 之类的做法。
我们设 表示前 个男人,选择女人的集合为 ( 表示未选, 表示已选)时的方案数。
那么初值有 ,其他为 。
目标必须是男女两两匹配,即 。
转移就是枚举集合 ,对于 的 ,如果其在集合中,那么除去 的结果加在一起即可。
记 二进制表示中 的个数为 , 二进制第 位为 ,则形式化的,有:
直接这样做,时间复杂度是 的,虽说跑不满,但是特别悬。
考虑优化:注意到对于不同的 , 的要求是不同的,因此考虑预处理出来 的各个集合 。
这样虽然还是三层循环,但是均摊复杂度是 的了,可以通过本题。
另外,发现 数组的第一维没有作用,可以直接省去。
正确性方面,第一维的 其实就是 ,所以这个对应关系是唯一的,因此直接省去是正确的。
代码
CPP#include <bits/stdc++.h>
using namespace std;
const int MOD = 1e9+7;
int n,a[25][25],sum;
vector<int> s[25];
int dp[(1<<21)+5];
int mod(int &x,int y)
{
x = x + y;
if (x >= MOD) x -= MOD;
return x;
}
int main()
{
scanf("%d",&n);
for (int i=1;i<=n;i++)
{
for (int j=0;j<n;j++)
{
scanf("%d",&a[i][j]);
}
}
for (int j=0;j<(1<<n);j++)
{
sum = 0;
for (int k=0;k<n;k++)
{
if (j & (1<<k)) sum++;
}
s[sum].push_back(j);
}
dp[0] = 1;
for (int i=1;i<=n;i++)
{
for (int j=0;j<(int)s[i].size();j++)
{
for (int k=0;k<n;k++)
{
if (a[i][k] == 0) continue;
if ((s[i][j] & (1<<k)) == 0) continue;
dp[s[i][j]] = mod(dp[s[i][j]],dp[s[i][j]-(1<<k)]);
}
}
}
printf("%d",dp[(1<<n)-1]);
return 0;
}
P. Independent Set
树形 DP,类似 P1352 没有上司的舞会。
设 表示把节点 染成黑色 / 白色时,以 为根的子树内染色的方案数。
由于各子节点的染色方案间 互相独立,因此是 乘法原理,有:
时间复杂度 。
代码
CPP#include <bits/stdc++.h>
using namespace std;
#define intt long long
const int N = 100005, M = 200005;
const int mod = 1e9+7;
int n,u,v,cnt;
intt f[N][2];
int head[N],ver[M],nxt[M];
void add(int x,int y)
{
ver[++cnt] = y;
nxt[cnt] = head[x];
head[x] = cnt;
}
void dfs(int u,int fa)
{
f[u][0] = 1;
f[u][1] = 1;
for (int i=head[u];i;i=nxt[i])
{
int v = ver[i];
if (v == fa) continue;
dfs(v,u);
f[u][0] = (f[u][0]*(f[v][0]+f[v][1])%mod)%mod;
f[u][1] = (f[u][1]*f[v][0])%mod;
}
}
int main()
{
scanf("%d",&n);
for (int i=1;i<n;i++)
{
scanf("%d%d",&u,&v);
add(u,v);
add(v,u);
}
dfs(1,0);
printf("%lld",(f[1][0]+f[1][1])%mod);
return 0;
}
Q. Flowers
一句话题意:带权 LIS。
如果不会 LIS 的 做法,可以先学一下 P1439 两个排列的最长公共子序列,虽然说是“LCS”,但实际转化后就是 LIS。
考虑朴素:设 表示以 结尾(结尾高度为 )时的答案。
那么有转移:
显然我们需要找到 范围内,满足 条件下最大的 ,直接线段树单点修改区间查询最大值。
时间复杂度 ,树状数组也可以,因为这里是前缀最大值。
代码
CPP#include <bits/stdc++.h>
using namespace std;
#define intt long long
const int N = 200005, M = 800005;
int n,h[N];
intt a[N],t[M],dp[N],ans;
#define mid ((l+r)>>1)
#define ls (p<<1)
#define rs ((p<<1)|1)
void update(int p)
{
t[p] = max(t[ls],t[rs]);
return;
}
void change(int p,int l,int r,int x,intt d)
{
if (l == r)
{
t[p] = max(t[p],d);
return;
}
if (x <= mid) change(ls,l,mid,x,d);
else change(rs,mid+1,r,x,d);
update(p);
}
intt query(int p,int l,int r,int q)
{
if (r <= q) return t[p];
intt res = query(ls,l,mid,q);
if (q > mid) res = max(res,query(rs,mid+1,r,q));
return res;
}
int main()
{
scanf("%d",&n);
for (int i=1;i<=n;i++) scanf("%d",&h[i]);
for (int i=1;i<=n;i++) scanf("%lld",&a[i]);
for (int i=1;i<=n;i++)
{
dp[i] = query(1,1,n+1,h[i]) + a[i];
change(1,1,n+1,h[i]+1,dp[i]);
ans = max(ans,dp[i]);
}
printf("%lld",ans);
return 0;
}
R. Walk
设 表示以 为终点的长度为 的路径条数,初始有 。
考虑转移,假设 对应 的边的起点,那么转移有
考虑 的边在邻接矩阵上的性质:若 ,表明存在此边。
因此,转移也可以写作:
注意到 固定时, 是矩阵上的某一列。
也就是说,转移就是对矩阵 的第 列的每一项分别乘上了 对应的值,并且求和。
这不就是 矩阵乘法 的形式吗?也就是说,我们可以写作:
注意到 第二维可以省去,变为:
如果转移 次,式子就变为了(注意矩阵右上角的幂次):
又因为初始 ,则式子可写作:
直接矩阵快速幂加速即可,最终结果即
时间复杂度 。
时间复杂度解析
矩阵大小:。
矩阵乘法:时间复杂度 。
快速幂:时间复杂度 。
实际上还有一个 倍以上的常数,因为快速幂要矩阵乘法两次。
代码
CPP#include <bits/stdc++.h>
using namespace std;
#define intt long long
const int mod = 1e9+7;
int n;
intt sum,k;
struct martix
{
intt a[55][55];
}a,ans;
martix multi(martix x,martix y)
{
martix res;
for (int i=1;i<=n;i++)
{
for (int j=1;j<=n;j++)
{
res.a[i][j] = 0;
}
}
for (int i=1;i<=n;i++)
{
for (int j=1;j<=n;j++)
{
for (int k=1;k<=n;k++)
{
res.a[i][j] = (res.a[i][j] + x.a[i][k] * y.a[k][j] % mod) % mod;
}
}
}
return res;
}
martix qpow(martix x,intt y)
{
martix res;
for (int i=1;i<=n;i++)
{
for (int j=1;j<=n;j++)
{
res.a[i][j] = 0;
}
res.a[i][i] = 1;
}
while (y)
{
if (y & 1) res = multi(res,x);
x = multi(x,x);
y >>= 1;
}
return res;
}
int main()
{
scanf("%d%lld",&n,&k);
for (int i=1;i<=n;i++)
{
ans.a[1][i] = 1;
for (int j=1;j<=n;j++)
{
scanf("%lld",&a.a[i][j]);
}
}
ans = multi(ans,qpow(a,k));
for (int i=1;i<=n;i++)
{
sum = (sum + ans.a[1][i]) % mod;
}
printf("%lld",sum);
return 0;
}
S. Digit Sum
数位 DP 模板题。
看到「十进制数位之和是 的倍数」这样倍数的字眼,一个很经典的做法是转化为「十进制数位之和 」。
这样倍数转「取模为 」的技巧还是很常用的,例如 [NOIP 2016 提高组] 组合数问题,转化后就是个递推组合数。
那么我们考虑设 表示 位数字,数位之和 的数字个数。
考虑转移,显然需要枚举 ,以及这一位填入的数字 。
那么转移有:
稍微解释一下,这个转移是求和的形式,就是考虑填上这个数 后数位之和 从原来的 变为了 。
初值是 ,其余为 ,这个还是很好理解的。
接下来我们考虑数位 DP 中我认为细节较多且较为困难的部分: 统计答案。
一般数位 DP 统计答案的思路是,从高到低枚举数位,然后固定枚举过的位置,计算后面的方案数。
举例
举个例子,我们现在要计算 的答案,那么过程如下:
- 枚举到最高位 ,这一位上如果放 ,那么后面的可以随便放,统计答案。
- 此时相当于拼成了 ,其中 表示这一位可以放的值,即 。
- 枚举到次高位 ,这一位上不管放什么,后面的都不能随便放,不统计答案。
- 枚举到下一位 ,这一位上如果放 ,那么后面的可以随便放,统计答案。
- 此时相当于拼成了 ,其中 表示这一位可以放的值,即 。
- 枚举到下一位 ,这一位上如果放 ,那么后面的可以随便放,统计答案。
- 此时相当于拼成了 ,其中 表示这一位可以放的值,即 。
- 枚举到下一位 ,这一位上如果放 ,那么后面的可以随便放,统计答案。
- 此时相当于拼成了 ,其中 表示这一位可以放的值,即 。
- 枚举到下一位 ,这一位上如果放 ,那么后面的可以随便放,统计答案。
- 此时相当于拼成了 ,其中 表示这一位可以放的值,即 。
- 枚举到下一位 ,这一位上如果放 ,那么后面的可以随便放,统计答案。
- 此时相当于拼成了 ,其中 表示这一位可以放的值,即 。
- 枚举到下一位 ,这一位上放 都是可以的,统计答案。
- 此时相当于拼成了 ,其中 表示这一位可以放的值,即 。
我们发现,对于非最后一位(记作第 位)上的数 ,该位上如果放 ,后面的都可以随便放。而后面随便放的方案数我们又算出来了,因此枚举第 位上放什么,然后统计求和即可。
对于最后一位,特别地记得加上最后一位放 的方案数即可。
这个统计可能比较抽象,可以结合代码看看:
统计答案的代码
CPPfor (int i=n;i>=1;i--)
{
for (int j=0;j<(int)(s[n-i+1]-'0')+(i==1);j++)
{
//这里的 s 是用 char 数组的形式存的 K,最高位对应的是 s[1],最低 s[n]。
ans = mod(ans + dp[i-1][((d-(x+j)%d)%d]);
//这里解释一下,x 是这一位(第 i 位)之前的 K 的数位之和,
//再算上这一位填的值 j,已经放的数位 mod d 的值是 (x+j)%d,
//但是要凑成总的数位之和 mod d = 0,因此剩下还需要补一个 (d-(x+j)%d)%d。
}
x = (x+(int)(s[n-i+1]-'0')) % d;
//这里就是统计下前面的数位之和,因为已经是固定的了。
}
最后注意一下,这题求的计数是 ,而我们最低位为 的也算进去了(不然后面没法转移,因为每次转移相当于在原来的数字前面再拼一位),而 对任何数 取模一定为 ,因此会对答案产生 的贡献,把最终答案 即可。
总时间复杂度 ,其中 是 的位数。
代码
CPP#include <bits/stdc++.h>
using namespace std;
const int MOD = 1e9+7;
char s[10005];
int x,d,n,dp[10005][105],ans;
int mod(int x)
{
if (x >= MOD) x -= MOD;
return x;
}
int dod(int x)
{
while (x < 0) x += d;
while (x >= d) x -= d;
return x;
}
int main()
{
scanf("%s",s+1);
n = strlen(s+1);
scanf("%d",&d);
dp[0][0] = 1;
for (int i=1;i<=n;i++)
{
for (int j=0;j<d;j++)
{
for (int k=0;k<=9;k++)
{
dp[i][dod(j+k)] = mod(dp[i][dod(j+k)]+dp[i-1][j]);
}
}
}
for (int i=n;i>=1;i--)
{
for (int j=0;j<(int)(s[n-i+1]-'0')+(i==1);j++)
{
ans = mod(ans + dp[i-1][dod(-x-j)]);
}
x = dod(x+(int)(s[n-i+1]-'0'));
}
printf("%d",mod(MOD+ans-1));
return 0;
}
想明白了还是不难理解的。
相关推荐
评论
共 0 条评论,欢迎与作者交流。
正在加载评论...