计数类 dp 做题记录(长期更新)
前言
因为本人太弱,急需锻炼思维,固从现在起开始着手写计数题,并写下题解分析思路的欠缺。另外本文将长时间更新,所以我准备把它置顶,尽量日更!
upd on 24.11.6
现版本改成长期更新。
P3643 [APIO2016] 划艇 2024.8.28
简要题意
现在有 \(n\) 个区间,每个区间范围为 \([l_i,r_i]\)。现在有 \(n\) 个元素需要赋值,每个元素的值要么为零,要么在给定的区间内。对于一个值非零的元素 \(a_i\),需要满足它的数值严格大于所有标号比它小的元素,即 \(a_i\ge\max_{1\le j<i}\{a_j\}\)。求方案数。
数据范围:\(n\le500,1\le l_i\le r_i\le10^9\)。
题解
首先去想题目性质,然后很高兴地发现根本没有什么性质。然后先考虑朴素 dp,我们令 \(f_{i,j}\) 表示第 \(i\) 个元素值为 \(j\) 的方案数,最后答案为 \(\sum_{i=1}^{i\le n}\sum_{j}f_{i,j}\)。
然后考虑转移,其实转移也很暴力我就直接放式子了:
为方便转移,初始 \(f_{0,1}=1\)。
然后不用多说这个肯定爆了。第二维值域是 \(10^9\) 所以能够想到将区间离散化,然后第二维改成区间。这样转移就需要小小的改变一下,因为涉及到了区间选若干点,所以需要加一个系数。那么系数我们怎么求呢?
假设当前区间长度为 \(len\),元素为第 \(i\) 个。枚举到前 \(j\) 个元素时,现在有 \(i-j\) 个元素将会放在当前区间,我们就把问题抽象成有 \(1\) 到 \(len\) 一共 \(len\) 个数,我们需要从中选出最多 \(m\) 个,选择的方案数就是转化时乘上的系数。考虑对于一次选择,我可以选或不选,若我选就会从中选取一个数就正常做;但如果我不选呢?就把它看成我选了零。于是我们就可以往序列中加入 \(m\) 个零,现在有一共 \(len+m\) 个数,我要从中选出 \(m\) 个,方案为 \({len+m}\choose m\)。然而因为 dp 状态钦定第 \(i\) 个数必选,所以我们实际往序列中加入的零的个数应该会比上述操作少一个(因为保证至少有一个数也就是 \(i\) 不为零)。于是最后的 dp 转移就变成了下面的:
但是现在总复杂度还是 \(O(n^4)\) 的,还需要一个小优化,然后考虑哪些状态可以一起考虑。我们可以发现,对于当前的状态 \(f_{i,j}\),我只要满足一个状态 \(f_{k,l}\) 的第二维小于 \(j\) 也就是 \(l<j\),就可以将所有的 \(f_{k,l},l<j\) 累加然后整体乘上一个组合数,所以记一个 \(g_{i,j}=\sum_{k=1}^{j-1}f_{i,k}\),然后就得到了最后的转移式:
时间复杂度 \(O(n^3)\) 不做过多解释。
代码
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
简要题意
有一个长度为 \(n\) 的序列,现在有 \(q\) 次操作,每次操作都可以交换两个数的位置,对于每次操作可以选择执行或不执行。对于所有的情况求出一共的逆序对数量。
题解
首先要知道一个技巧:在多种情况下计数可以转换成求概率再乘上情况数。
然后就可以将题意转换成求 \(a_i>a_j,i<j\) 的概率和,于是就可以设计一个 dp \(f_{i,j}\) 表示 \(a_i>a_j,i<j\) 的概率。当一次操作交换 \(x,y\) 时,对于所有的 \(f_{x,i},f_{y,i},f_{i,x},f_{i,y}\) 都会改变。就拿 \(f_{x,i}\) 举例,一次操作后它有 \(\frac{1}{2}\) 的概率继承之前的状态,还有 \(\frac{1}{2}\) 的概率变成 \(f_{y,i}\) 的状态,所以转移就是两个状态相加除以二。对于每次操作都需要改变 \(O(n)\) 的状态,时间复杂度 \(O(nq)\)。
代码
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
简要题意
有一个 \(4\times N\) 的木板需要粉刷,第 \(i\) 行 \(j\) 列的颜色记为 \(A(i,j)\)。 有 256 种颜色,记为 \(0\dots255\),为了使得粉刷比较好看,粉刷需要满足如下。
要求:
- \(A(x,y)>=A(x,y-1)\);
- 有一些指定的 \((x1,y1)\) 和 \((x2,y2)\) 满足 \(A(x1,y1)=A(x2,y2)\);
请问有多少种满足要求的粉刷方式?
数据范围
\(1\le n\le15,0\le M\le100\)
题解
先考虑题目性质:
- 根据要求的第一条,我们可以知道对于一个合法的木板,每一行没有影响,而且每一行的数从小到大单调不减。
- 题目中的数据范围超级小。
首先如果你单纯想用三维甚至二维 dp 就解决问题可能比较麻烦,既然数据范围很小,我们可以考虑高维 dp 的做法。其实我们可以很自然的想到一个爆炸的 dp,我们考虑 \(f_{i1, c1, i2, c2, i3, c3, i4, c4}\) 表示每一行填到某一位以及当前位置的颜色它的方案数。然后可以把这个东西抽象成有四个完全背包,这个状态相当于把四个背包放在一起考虑。对于每个背包,有无数个标号为 \(0\dots255\) 的物品,代价为 1,价值为方案数。然后类似背包直接转移。
然后 dp 爆炸了。考虑优化。其实你会发现上面的 dp 我们没有任何的限制,导致它很混乱,会有很多的状态,导致我们复杂度爆炸。所以我们需要合并一些能够合并的状态。对于上面的状态我们能够确定一点就是每一行的位置是一定要单独维护的,这一点毋庸置疑。但是每一个位置的颜色可以改变,如果没有限制直接记录下来会产生很多状态,所以我们可以每次把颜色统一起来。现在我们换一种 dp 状态,考虑 \(f_{col,i1,i2,i3,i4}\) 表示当前要填的颜色(数)是 \(col\),我们每一行填到某个位置时的方案数,然后转移就和上面的一样,可以类比一下。
至于要求某的点颜色相等,就是在转移的时候判断一下当前的状态是否合法,也就是对于对应行的填的位置必须都在有限制的位置的同一侧,这个可以预处理一下。最后时间复杂度就是 \(O(256n^4)\)。
代码
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
简要题意
有一个 \(n\) 个点的图,目前一条边都没有。
有一个人在 \(1\) 号点要进行 \(m\) 次移动,终点不必是 \(1\) 号点,假设第 \(i\) 次从 \(u\) 移动到 \(v\),那么在 \(u\) 与 \(v\) 之间连一条有向边。
问有多少种序列能满足:最终 \(n\) 个点组成的图是一个强连通图。答案对 \(10^9+7\) 取模。
数据范围
\(1\le n,m\le 300\)
题解
对于这种连通图计数类问题,有一个常见的套路,就是你去考虑 \(1\) 号点的连通情况。就比如这一道题我们需要考虑现在一共走到过哪一些点,以及一号点所在的强连通分量大小。根据这个思路可以很容易的写出状态 \(f_{i,j,k}\) 表示走了 \(i\) 步,一共走到过 \(j\) 个点,其中一号点所在的强连通分量大小为 \(k\)。
我们可以发现一个性质,就是如果现在去走一个在一号点所在的强连通分量中的点,那么目前所有点都会变成一个强连通分量(显然)。所以状态的转移也就差不多出来了。
但是如果正常转移你会发现很难写,对于一个状态 \(f_{i,j,k}\) 有非常多的转移方法,但是从 \(f_{i,j,k}\) 转移到其他地方就要简单很多,外面可以分三种情况讨论:
- 下一步走之前没走过的点:\(f_{i+1,j+1,k}=f_{i,j,k}\times(n-j)\);
- 下一步走之前走过但是不在一号点所在强连通分量中的点:\(f_{i+1,j,k}=f_{i,j,k}\times(j-k)\);
- 下一步走一号点所在强连通分量中的点:\(f_{i+1,j,j}=f_{i,j,k}\times k\)。
最后答案为 \(f_{m,n,n}\),此题得解!
代码
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;
}