计数类 dp 做题记录(长期更新)
前言
因为本人太弱,急需锻炼思维,固从现在起开始着手写计数题,并写下题解分析思路的欠缺。另外本文将长时间更新,所以我准备把它置顶,尽量日更!
upd on 24.11.6
现版本改成长期更新。
P3643 [APIO2016] 划艇 2024.8.28
简要题意
现在有
数据范围:
题解
首先去想题目性质,然后很高兴地发现根本没有什么性质。然后先考虑朴素 dp,我们令
然后考虑转移,其实转移也很暴力我就直接放式子了:
为方便转移,初始
然后不用多说这个肯定爆了。第二维值域是
假设当前区间长度为
但是现在总复杂度还是
时间复杂度
代码
点击查看代码
int n, l[N], r[N], z[N << 1], tot;
ll f[N], c[N], inv[N], ans;
ll add(ll x, ll y){
x += y; return x >= p ? x - p : x;
}
signed main(){
// fileio(fil);
n = rd();
for(int i = 1; i <= n; ++i){
z[i - 1 << 1 | 1] = l[i] = rd(), z[i << 1] = r[i] = rd() + 1;
}
sort(z + 1, z + 1 + (n << 1));
tot = unique(z + 1, z + 1 + (n << 1)) - z - 1;
for(int i = 1; i <= n; ++i){
l[i] = lower_bound(z + 1, z + 1 + tot, l[i]) - z;
r[i] = lower_bound(z + 1, z + 1 + tot, r[i]) - z;
}
inv[1] = f[0] = c[0] = 1;
for(int i = 2; i <= n; ++i)inv[i] = 1ll * (p - p / i) * inv[p % i] % p;
for(int i = 1; i < tot; ++i){
int len = z[i + 1] - z[i];
for(int j = 1; j <= n; ++j)c[j] = c[j - 1] * (len + j - 1) % p * inv[j] % p;
for(int j = n; j; --j)if(l[j] <= i and i + 1 <= r[j]){
ll s = 0; int cnt = 1;
for(int k = j - 1; ~ k; --k){
s = add(s, c[cnt] * f[k] % p);
cnt += l[k] <= i and i + 1 <= r[k];
}
f[j] = add(f[j], s);
}
}
for(int i = 1; i <= n; ++i)ans += f[i];
printf("%lld", ans % p);
return 0;
}
小结
其实做完这道题时感觉完全不够紫题,但是在看题解之前怎么都切不了。其实暴力 dp 我肯定会,离散化我想到了,后面的组合数也很基础,最后的前缀和相对于其他优化也简单得多。但是,为什么我就是做不出来呢?因为我不熟悉知识间的组合与衔接,不肯从暴力入手,老是想怎么直接出正解,而真正的正解需要前面大量的铺垫。它或许是 OIer 做题时的妙手偶得,但更是大量的经验与积累!
而对于我来说,我拿到一道题应该去做什么?我首先要去分析题目的性质,然后根据性质看看能不能得出进一步结论。有了以上的东西,我就可以去根据已有的东西思考如何得出答案,这一期间可以先将时间复杂度暂放。最后再来慢慢优化求解的过程,方法。还有不要忘了验证正确性!
AGC30 - D - Inversion Sum 2024.8.29
简要题意
有一个长度为
题解
首先要知道一个技巧:在多种情况下计数可以转换成求概率再乘上情况数。
然后就可以将题意转换成求
代码
点击查看代码
int n, q, a[N];
ll f[N][N];
ll qmi(ll x, int y){
ll res = 1;
for(; y; y >>= 1, x = x * x % p)if(y & 1)res = res * x % p;
return res;
}
const ll i2 = p + 1 >> 1;
ll add(ll x, ll y){
x += y; return x >= p ? x - p : x;
}
signed main(){
// fileio(fil);
n = rd(), q = rd();
for(int i = 1; i <= n; ++i)a[i] = rd();
for(int i = 1; i <= n; ++i)for(int j = 1; j <= n; ++j)f[i][j] = a[i] > a[j];
for(int i = 1; i <= q; ++i){
int x = rd(), y = rd();
for(int j = 1; j <= n; ++j)if(j ^ x and j ^ y)f[x][j] = f[y][j] = add(f[x][j], f[y][j]) * i2 % p, f[j][x] = f[j][y] = add(f[j][x], f[j][y]) * i2 % p;
f[x][y] = f[y][x] = add(f[x][y], f[y][x]) * i2 % p;
}
ll s = qmi(2, q), res = 0;
for(int i = 1; i < n; ++i)for(int j = i + 1; j <= n; ++j)res = add(res, f[i][j]);
printf("%lld", res * s % p);
return 0;
}
draw 2024.8.30
简要题意
有一个
要求:
;- 有一些指定的
和 满足 ;
请问有多少种满足要求的粉刷方式?
数据范围
题解
先考虑题目性质:
- 根据要求的第一条,我们可以知道对于一个合法的木板,每一行没有影响,而且每一行的数从小到大单调不减。
- 题目中的数据范围超级小。
首先如果你单纯想用三维甚至二维 dp 就解决问题可能比较麻烦,既然数据范围很小,我们可以考虑高维 dp 的做法。其实我们可以很自然的想到一个爆炸的 dp,我们考虑
然后 dp 爆炸了。考虑优化。其实你会发现上面的 dp 我们没有任何的限制,导致它很混乱,会有很多的状态,导致我们复杂度爆炸。所以我们需要合并一些能够合并的状态。对于上面的状态我们能够确定一点就是每一行的位置是一定要单独维护的,这一点毋庸置疑。但是每一个位置的颜色可以改变,如果没有限制直接记录下来会产生很多状态,所以我们可以每次把颜色统一起来。现在我们换一种 dp 状态,考虑
至于要求某的点颜色相等,就是在转移的时候判断一下当前的状态是否合法,也就是对于对应行的填的位置必须都在有限制的位置的同一侧,这个可以预处理一下。最后时间复杂度就是
代码
点击查看代码
const int N = 20, p = 1e5, M = 105;
int n, m, o[N][N][N][N], xx[M], yy[M], _x[M], _y[M], ii[5];
int f[N][N][N][N];
signed main(){
freopen("draw.in", "r", stdin);
freopen("draw.out", "w", stdout);
n = rd(), m = rd();
for(int i = 1; i <= m; ++i)xx[i] = rd(), yy[i] = rd(), _x[i] = rd(), _y[i] = rd();
for(int i = 1; i <= m; ++i)for(ii[1] = 0; ii[1] <= n; ++ii[1])for(ii[2] = 0; ii[2] <= n; ++ii[2])
for(ii[3] = 0; ii[3] <= n; ++ii[3])for(ii[4] = 0; ii[4] <= n; ++ii[4])o[ii[1]][ii[2]][ii[3]][ii[4]] |= ii[xx[i]] >= yy[i] ^ ii[_x[i]] >= _y[i];
f[0][0][0][0] = 1;
for(int c = 0; c < 256; ++c){
for(int i = 1; i < 5; ++i)for(ii[1] = 0; ii[1] <= n; ++ii[1])for(ii[2] = 0; ii[2] <= n; ++ii[2])
for(ii[3] = 0; ii[3] <= n; ++ii[3])for(ii[4] = 0; ii[4] <= n; ++ii[4]){
int nkp = f[ii[1]][ii[2]][ii[3]][ii[4]];
if(++ii[i] <= n)(f[ii[1]][ii[2]][ii[3]][ii[4]] += nkp) %= p;
--ii[i];
}
for(ii[1] = 0; ii[1] <= n; ++ii[1])for(ii[2] = 0; ii[2] <= n; ++ii[2])for(ii[3] = 0; ii[3] <= n; ++ii[3])
for(ii[4] = 0; ii[4] <= n; ++ii[4])if(o[ii[1]][ii[2]][ii[3]][ii[4]])f[ii[1]][ii[2]][ii[3]][ii[4]] = 0;
}
printf("%05d", f[n][n][n][n]);
return 0;
}
小结&反思
我在考试的时候被硬控了很久,然后就是感觉思维没打开,不敢去想最开始的八维 dp,只限制于二维到三维,结果转移一直写不出来,但好像 max 有一种神秘做法,好像跟我的想法差不多,思路几乎一模一样,我只差最后一个地方的转移没有想清楚,但是考试时我没有笃定我的信念想下去,非常可惜!然后最近 hfu 也跟我聊过,他也提到了我的这个弱点,我的确应当及时反思,但是越在关键时候越要相信自己,我就没有这种冲劲,太过拘泥。我以后要注意不要给自己一些紧迫感、压抑感,要学会放松、学会顺着自己的想法,不要太在意他们的指点!
Road of the King 2024.9.6
简要题意
有一个
有一个人在
问有多少种序列能满足:最终
数据范围
题解
对于这种连通图计数类问题,有一个常见的套路,就是你去考虑
我们可以发现一个性质,就是如果现在去走一个在一号点所在的强连通分量中的点,那么目前所有点都会变成一个强连通分量(显然)。所以状态的转移也就差不多出来了。
但是如果正常转移你会发现很难写,对于一个状态
- 下一步走之前没走过的点:
; - 下一步走之前走过但是不在一号点所在强连通分量中的点:
; - 下一步走一号点所在强连通分量中的点:
。
最后答案为
代码
点击查看代码
int n, m;
ll f[N][N][N];
int add(ll x, int y){
return x - p + y >= 0 ? x - p + y : x + y;
}
signed main(){
// fileio(fil);
n = rd(), m = rd();
f[0][1][1] = 1;
for(int i = 0; i < m; ++i)for(int j = 1; j <= n; ++j)for(int k = 1; k <= j; ++k){
f[i + 1][j + 1][k] = add(f[i + 1][j + 1][k], f[i][j][k] * (n - j) % p);
f[i + 1][j][k] = add(f[i + 1][j][k], f[i][j][k] * (j - k) % p);
f[i + 1][j][j] = add(f[i + 1][j][j], f[i][j][k] * k % p);
}
cout << f[m][n][n];
return 0;
}
P9823 [ICPC2020 Shanghai R] The Journey of Geor Autumn 2024.11.20
简要题意
给定
求好排列的个数对
数据范围:
题解
看到这题首先有一个一眼的
但是这个题数据范围卡的很死,显然是让你寻找线性做法。然后你会发现其实不管你怎么变化,只要第一维记录下标它就一定还需要记录别的东西。
引用大佬
枚举下标没前途
所以果断转换第一维的状态,像这种计数题中还可以记录填的数是什么,因为题目中限制条件是大于某个数,所以我们维护前缀最大值。我们可以理解成把
我们设
然后我们的转移也就呼之欲出了:
这样虽然状态是
然后把后面的系数拆开:
于是我们就记录
代码
点击查看代码
const int N = 1e7 + 5, p = 998244353;
int n, k;
ll f[N], g[N], fac[N], inv[N];
ll qmi(ll x, int y){
ll res = 1;
for(; y; y >>= 1, x = x * x % p)if(y & 1)res = res * x % p;
return res;
}
int main(){
freopen("b.in", "r", stdin);
freopen("b.out", "w", stdout);
n = rd(), k = rd(); fac[0] = inv[0] = 1;
for(int i = 1; i <= n; ++i)fac[i] = fac[i - 1] * i % p;
inv[n] = qmi(fac[n], p - 2);
for(int i = n - 1; i; --i)inv[i] = inv[i + 1] * (i + 1) % p;
if(k == 1)return puts("1") & 0;
for(int i = 1; i <= k; ++i)f[i] = fac[n - 1] * inv[n - i] % p;
for(int i = 1; i <= n; ++i){
if(i <= k)f[i] = (f[i] + g[i - 1] * inv[n - i]) % p;
else f[i] = (f[i] + (g[i - 1] + p - g[i - k - 1]) * inv[n - i]) % p;
g[i] = (g[i - 1] + f[i] * fac[n - i - 1]) % p;
}
wt(f[n]);
return 0;
}
[ARC178D]Delete Range Mex 2024.11.20
简要题意
对于一个排列
数据范围:
题解
对于这道题目我们先去寻找它有什么性质。考虑我们如何删掉一个数
加入我们现在要把
回到此题。现在给出了最后剩下的
枚举
若
解释一下转移。对于一个区间
代码
点击查看代码
const int N = 505, p = 998244353;
int n, m, a[N], pos[N], f[N][N][N];
int add(int x, int y){return x - p + y >= 0 ? x - p + y : x + y;}
int main(){
freopen("c.in", "r", stdin);
freopen("c.out", "w", stdout);
n = rd(), m = rd();
for(int i = 1; i <= m; ++i)a[i] = rd(), pos[a[i]] = i;
if(pos[0])f[pos[0]][pos[0] + 1][0] = 1;
else for(int i = 1; i <= m + 1; ++i)f[i][i][0] = 1;
for(int x = 1; x < n; ++x)if(pos[x])
for(int i = 1; i <= m + 1; ++i)for(int j = i; j <= m + 1; ++j)
f[min(pos[x], i)][max(pos[x] + 1, j)][x] = add(f[min(pos[x], i)][max(pos[x] + 1, j)][x], f[i][j][x - 1]);
else{
for(int i = 1, tmp = 0; i <= m + 1; ++i, tmp = 0)for(int j = i; j <= m + 1; ++j)
tmp = add(tmp, f[i][j][x - 1]), f[i][j][x] = add(f[i][j][x], tmp);
for(int j = 1, tmp = 0; j <= m + 1; ++j, tmp = 0)for(int i = j; i; --i)
tmp = add(tmp, f[i][j][x - 1]), f[i][j][x] = add(f[i][j][x], tmp);
}
wt(f[1][m + 1][n - 1]);
return 0;
}
ARC132E 2024.12.12
简要题意
有
求无法操作时方格为左的期望数。
数据范围:
题解
首先看到这个操作我们可以把每一段方格看成一个整体,以空为分界线,然后最自然的思路就是对于每一个整体考虑对答案的贡献。
然后大概说一下我的想法。就是对于第
- 对于没被更新的情况,需要满足对于它左边的空都向左走,右边的空都向右走,贡献就是原始区间中左方格的个数。
- 对于被更新的情况,如果最后被左边向右的覆盖则无贡献,否则就贡献区间长度。
然后经过一番推导你会发现,第二种情况求解的复杂度似乎是
我们可以从最终状态入手。
考虑最终的方格状态一定是左边一段左方格,右边一段右方格,然后中间的一段是原始状态。原因显然。
所以我们只用讨论最后哪一段没有被覆盖过,直接枚举是
于是我们可以设计一个状态
然后对于枚举段的右边和左边其实是等价的,所以这段的答案就是
其中
代码
点击查看代码
signed main(){
for(int i = 1; i <= n; ++i)
if(a[i] == '<')++cnt;
else if(a[i] == '.')s[++tot] = i, c[tot] = cnt;
c[++tot] = cnt; f[1] = 1;
for(int i = 2; i <= tot; ++i)f[i] = 1ll * f[i - 1] * (p + 1 - qmi(2 * i - 2, p - 2)) % p;
for(int i = 1; i <= tot; ++i)ans = (0ll + ans + 1ll * f[i] * f[tot - i + 1] % p * (c[i] - c[i - 1] + s[i - 1])) % p;
}
ARC101E 2024.12.25
简要题意
有一棵树,树上有偶数个节点。你需要给这些点两两配对,一组已经配对的点会将两点之间的树边进行一次覆盖。一组合法方案需要满足树上所有边都被覆盖至少一次,求合法方案数。
数据范围:
思路
首先我们去观察题目性质,发现没有什么特殊的地方。我最开始只想到一个非常暴力的
一个儿子的子树内互相匹配,但是需要有一个点与外面的点匹配(不然这个子树与 之间的边就无法被覆盖);- 一个子树内的点向
的其他子树匹配; - 一个子树的点向
子树以外的点匹配。
或许还有一些没有罗列出来,但反正就是不可做。于是我们正难则反,考虑先求出不合法的情况,然后容斥做。
题解
如何求不合法的情况呢?我们可以通过钦定一些边不覆盖来容斥。比如当我计算到以
具体的,我们设
若
你乍一看这不就是树上背包吗?时间复杂度
现在我们已经基本找出状态转移的方程,但现在我们还需要思考一个问题:
一个点数为
的连通块,将里面的点不重不漏两两匹配的方案数
首先对于
但考虑到我们只是没有考虑这些方案中会有的不合法情况,所以需要稍微容斥一下,在转移的时候还需要给一个
然后看到之前的
最后答案就是
代码
点击查看代码
void dfs(int u, int fa){
sz[u] = f[u][1] = 1;
for(int i = hd[u]; i; i = e[i].nxt){
int v = e[i].to; if(v == fa)continue;
dfs(v, u); copy(f[u], f[u] + 1 + sz[u], g);
fill(f[u], f[u] + 1 + sz[u], 0);
for(int j = 1; j <= sz[u]; ++j)for(int k = 1; k <= sz[v]; ++k)
f[u][j] = del(f[u][j], mul(mul(f[v][k], s[k]), g[j])),
f[u][j + k] = add(f[u][j + k], mul(g[j], f[v][k]));
sz[u] += sz[v];
}
}
P3343地震后的幻想乡 2025.2.12
简要题意
给定一张
思路
首先有一个比较神秘的跟概率有关的东西,虽然题面中已经给出提示,但这里还是进行简单说明:
引理:将长度为
的区间随机划成 段,每段长度期望是 。
笔者询问 deepseek,deepseek 给出了三种证明方法,在此仅给出笔者知道的一种。
我们设第
根据期望的线性性和对称性我们可以得到:
所以
所以第
首先看每种情况做贡献的概率,确定了选择哪些边要选和哪个边做贡献后就好做了,有
因为恰好选第
其中
最后的答案就是:
化简得:
代码
点击查看代码
signed main(){
n = rd(), m = rd();
for(int i = 1, u, v; i <= m; ++i)u = rd(), v = rd(), ++mp[(1 << u - 1) | (1 << v - 1)];
for(int S = 1; S < (1 << n); ++S)for(int T = S; T; T = T - 1 & S)d[S] += mp[T];
c[0][0] = c[1][0] = 1;
for(int i = 1; i <= m; c[++i][0] = 1)for(int j = 1; j <= i; ++j)c[i][j] = c[i - 1][j - 1] + c[i - 1][j];
for(int S = 1; S < (1 << n); ++S)for(int i = 0; i <= d[S]; ++i){
for(int T = S & S - 1; T; T = T - 1 & S)if(T & (S & - S))
for(int j = 0; j <= min(i, d[T]); ++j)f[S][i] += g[T][j] * c[d[S ^ T]][i - j];
g[S][i] = 1.0 * c[d[S]][i] - f[S][i];
}
for(int i = 0; i <= m; ++i)ans += f[(1 << n) - 1][i] / c[m][i];
ans /= 1.0 + m;
printf("%.6f", ans);
return 0;
}
【推荐】国内首个AI IDE,深度理解中文开发场景,立即下载体验Trae
【推荐】编程新体验,更懂你的AI,立即体验豆包MarsCode编程助手
【推荐】抖音旗下AI助手豆包,你的智能百科全书,全免费不限次数
【推荐】轻量又高性能的 SSH 工具 IShell:AI 加持,快人一步
· 全程不用写代码,我用AI程序员写了一个飞机大战
· DeepSeek 开源周回顾「GitHub 热点速览」
· 记一次.NET内存居高不下排查解决与启示
· MongoDB 8.0这个新功能碉堡了,比商业数据库还牛
· .NET10 - 预览版1新功能体验(一)