计数 dp 部分例题(一~五部分)

数え上げテクニック集 笔记。题目的代码难度都不高。


引言

OI 中有三大专题:dp,数据结构,图论。而在这三大专题中,因为 dp 是从小问题的解法上升至大问题的解法的关键;所以 dp,在这三大专题中,优先性是第一位的。如果在从小问题的解法上升至大问题时,难免于时间和空间的超限,会使得这种题目值得深究;而在 OI 中,人们总是会深究简化所用时间和空间的方法,我们称这种方法为 dp 优化;而在这篇文章中,就会专门对那些耗费大量时间和空间的解法,处心积虑,找出对应的 dp 优化方法。


一、状态设计和简化(状態をまとめる)

例题1:Unhappy Hacking

题意

有一个空串,可以进行下面三种操作:

  • 在末尾加入一个 \(0\)
  • 在末尾加入一个 \(1\)
  • 删去末尾的数,如果串为空则忽略。

\(n\) 次操作后满足整个串等于某个给定的串 \(S\) 的方案数模 \(10^9+7\)\(n,|S|\le 5\times 10^3\)

解法1(本人的 ** 解法)

某次操作可以删去原有匹配好的串的一部分,考虑消除这种操作带来的影响。

此时可以对于每个 \(i\),在填好的串为最后一次和长度为 \(i\) 的前缀相同的时候进行统计。显然之后的操作不包括将前 \(i\) 个字符删除,所以在最后一次匹配长度为 \(i+1\) 的前缀时,一定只会删去新加的字符然后加入一个对应的字符。这两次匹配之间加入/删除的操作可以形成一个合法的括号序列。设 \(dp_{i,j}\) 为进行了 \(i\) 次操作,形成了长度为 \(j\) 的前缀的方案数,则转移为 \(dp_{i,j}=\sum\limits_{k=0}^{\lfloor\frac{i-1}2\rfloor}dp_{i-2k-1,j-1}2^k\frac{\binom{2k}{k}}{k+1}\),(其中 \(\frac{\binom{2k}{k}}{k+1}\) 为长为 \(2k\) 的合法括号序列数,也就是 卡塔兰数)是一个卷积的形式,可以使用 MTT + 快速幂解决。(懒得写。)

注意 \(dp_{i,0}\) 需要 \(O(n^2)\) 特别计算(可能对空串进行删除操作)。

解法2(题解区首页解法

考虑只在删除操作处计算对应的贡献。显然我们只关心未被删除的部分能否构成 \(S\),此时如果某个插入/删除序列中有 \(k\) 次删除操作删除了某个数,则其对应了 \(2^k\) 种实际方案。

\(dp_{i,j}\) 为进行了 \(i\) 次操作,得到了长为 \(j\) 的串的方案数。如果下一次操作为删除,则 \(dp_{i,j}\gets 2dp_{i-1,j+1}\)(特别的,\(dp_{i,0}\gets dp_{i-1,0}\));如果下一次操作为加入,则 \(dp_{i,j}\gets dp_{i-1,j-1}\)\(j>0\))。

代码

点此查看代码
#include <bits/stdc++.h>
using namespace std;
const int maxn=5010;
const int md=1000000007;
int n,m,i,j,b,dp[2][maxn],*X=dp[0],*Y=dp[1]; char s[maxn]; 
inline int Plus(int x,int y){return x-=((x+=y)>=md)*md;} 
int main(){
    scanf("%d%s",&n,s+1);
    m=strlen(s+1); dp[1][0]=1;
    for(i=1;i<=n;++i){
        swap(X,Y); Y[0]=Plus(X[0],Plus(X[1],X[1]));
        for(j=1;j<=i;++j) Y[j]=Plus(X[j-1],Plus(X[j+1],X[j+1]));
    }
    printf("%d",Y[m]);
    return 0;
}

例题2:Road of the King

题意

有一个 \(n\) 个点的图,目前一条边都没有。

有一个人在 \(1\) 号点要进行 \(m\) 次移动,终点不必是 \(1\) 号点,假设第 \(i\) 次从 \(u\) 移动到 \(v\),那么在 \(u\)\(v\) 之间连一条有向边。可以有 \(u=v\)

问有多少种序列能满足:最终 \(n\) 个点组成的图是一个强连通图。答案对 \(10^9+7\) 取模。\(n,m\le 300\)

解法

考虑形成强连通图的一个充要条件:对于某个点 \(u\),满足所有点都能从 \(u\) 到达,所有点也都有到达 \(u\) 的路径。所以在构造时,令 \(u=1\),则每次回到 \(1\) 之后均会使得之前从 \(1\) 出发能到达的点现在能到达 \(1\)。(显然只需要考虑之前不能到达 \(1\) 的点)

\(dp_{i,j,k}\) 为在第 \(i\) 次操作时,目前有 \(j\) 个点能到达 \(1\),而距上次从 \(1\) 出发已经经过了 \(k\) 个不能到达 \(1\) 的点。转移时考虑从当前点能走到哪个点。如果走到了已经经过的 \(k\) 个点,则 \(dp_{i+1,j,k}\gets k\times dp_{i,j,k}\);如果走到了还没有经过的 \(n-j-k\) 个点,则 \(dp_{i+1,j,k+1}\gets (n-j-k)\times dp_{i,j,k}\);如果走到了能到达 \(1\)\(j\) 个点,则 \(dp_{i+1,j+k,0}\gets j\times dp_{i,j,k}\)

代码

点此查看代码
#include <bits/stdc++.h>
using namespace std;
const int maxn=310;
const int md=1000000007;
int n,m,i,j,k,b,c,w;
int dp[2][maxn][maxn];
int main(){
    scanf("%d%d",&n,&m);
    auto X=dp[0],Y=dp[1]; X[1][0]=1;
    for(i=1;i<=m;++i){
        for(j=1,b=n-1;j<=n;++j,--b){
            for(k=0,c=b;k<=b;++k,--c){
                w=X[j][k];
                Y[j][k]=(1LL*k*w+Y[j][k])%md;
                Y[j][k+1]=(1LL*c*w+Y[j][k+1])%md;
                Y[j+k][0]=(1LL*j*w+Y[j+k][0])%md;
            }
        }
        swap(X,Y); memset(Y,0,sizeof(dp[0]));
    }
    printf("%d",X[n][0]);
    return 0;
}

例题3:Hakone

题意

有一个 \(1\sim n\) 的排列 \(P\)。现在对于每个 \(i\),给出 \(P_i\)\(i\) 的大小关系(\(P_i>i/P_i=i/P_i<i\)),求可能的 \(P\) 的数量模 \(10^9+7\)\(n\le 200\)

解法

考虑建一张二分图,第 \(i\) 个左部点向所有可能的 \(P_i\) 的取值的右部点连边,则问题变成了求该二分图的最大匹配数。显然可以删去所有满足 \(P_i=i\) 的对应 \(i\),则之后的每个 \(P_i\ne i\),方便之后的讨论。

显然无论单独看哪一部分的点,钦定每个点匹配的点时都会十分困难(无法确定其对之后的影响)。考虑每次同时处理第 \(i\) 个左部点/右部点,且对于一对匹配点,只在编号更大的点统计贡献(避免后效性)。设 \(dp_{i,j,k}\) 为考虑了前 \(i\) 对点,且 \(j\) 个左部点和 \(k\) 个右部点未匹配的方案数(显然 \(j\) 个左部点只向编号更大的右部点连边)。如果 \(P_{i+1}>i+1\),则该左部点只能向编号更大的右部点连边,\(dp_{i+1,j+1,k+1}\gets dp_{i,j,k}\);否则其需要向编号更小的右部点连边,\(dp_{i+1,j,k}\gets k\times dp_{i,j,k}\)。注意右部点可能和之前的 \(j\) 个左部点之一匹配,可能不匹配,所以还有 \(dp_{i+1,j,k}\gets j\times dp_{i,j,k}\)\(dp_{i+1,j-1,k-1}\gets j\times k\times dp_{i,j,k}\)。注意到合法的 dp 状态一定满足 \(j=k\),所以可以合并后两维。

代码

注意某些日本远古题需要文末换行。

点此查看代码
#include <bits/stdc++.h>
using namespace std;
const int maxn=210;
const int md=1000000007;
int n,i,j,t; char ch; bool p[maxn];
int dp[2][maxn],*X=dp[0],*Y=dp[1];
int main(){
    scanf("%d",&n);
    while(n--){
        scanf(" %c",&ch);
        if(ch=='-') continue;
        p[++t]=(ch=='U');
    }
    X[0]=1;
    for(i=0;i<t;++i,swap(X,Y)){
        for(j=0;j<=i;++j) Y[j]=(1LL*X[j]*j)%md;
        if(p[i+1]) for(j=0;j<=i;++j) Y[j+1]-=((Y[j+1]+=X[j])>=md)*md;
        else for(j=1;j<=i;++j) Y[j-1]=(((1LL*j*j)%md)*X[j]+Y[j-1])%md;
    }
    printf("%d\n",X[0]);
    return 0;
}

二、设计转移顺序(探索順の変更)

1. 降序枚举(大きい順に並べる)

例题1:My friends are small

题意

有一个长为 \(n\) 的数组 \(w\)。设 \(S\)\(w\) 的一个子集,求满足 \(\min_{i\not\in S}i>W-\sum_{i\in S}i\) 的所有 \(S\) 的数量模 \(10^9+7\)\(n\le 200;w_i,W\le 10^4\)

解法

考虑枚举每个最小值对应的方案。此时一定需要选择小于最小值的所有数,不能选择最小值,然后选择另外一些大于最大值的数。此时可以从大到小枚举每个最大值,使用背包维护对应方案数。

代码

点此查看代码
#include <bits/stdc++.h>
using namespace std;
const int maxn=210;
const int maxm=10010;
const int md=1000000007;
int n,i,j,k,s,a,v,W,w[maxn],dp[maxm];
inline void Add(int &x,int y){x-=((x+=y)>=md)*md;}
int main(){
    scanf("%d%d",&n,&W);
    for(i=1;i<=n;++i) scanf("%d",w+i),s+=w[i];
    sort(w+1,w+n+1); dp[0]=1;
    if(s-w[n]<=W&&s>=W) a=1; s-=w[n];
    for(i=n-1,v=w[n];i;v=w[i--]){
        s-=w[i];j=W-s;
        for(k=W;k>=v;--k) Add(dp[k],dp[k-v]);
        for(k=max(0,j-w[i]+1);k<=j;++k) Add(a,dp[k]);
    }
    printf("%d\n",a);
    return 0;
}

2. 在排列中插入(順列は挿入 DP)

在对排列计数的过程中,如果暴力统计当前还有多少个数没有插入,则需要状压 \(O(2^n)\) 种可能的情况。如果每个排列对应的贡献只和相邻元素的大小关系有关,则此时可以考虑 DP 升序/降序枚举每个元素插入的位置,保证已知相邻元素的大小关系。

例题2

题意

\(n\) 栋楼房,高度从 \(1\)\(n\) 不等。求所有楼房有多少种可能的高度,使得从左端能看到 \(L\) 栋楼房,从右端能看到 \(R\) 栋楼房。\(n\le 300\)

解法

考虑将所有楼房按照高度从大到小依次加入。此时每次新加入的楼房只有在序列的开头或结尾时才能造成贡献,而在插入中间时没有贡献。设 \(dp_{i,j,k}\) 为加入了高度为 \(1\sim i\) 的楼房,从左端能看到 \(j\) 栋,从右端能看到 \(k\) 栋的方案数。

例题3:CWOI 19th November 2022 T4

3. 按照区间起/终点大小选择区间(区間は終点でソート)

可以用于解决使用区间覆盖某些位置的问题。

例题4

题意

\(n\) 个在 \([0,X]\) 内的区间。求有多少种选择区间的方式使得选择的区间的并为 \([0,X]\)\(n\le 10^5\)

解法

(原文内解法有误)将所有区间按照左端点升序排序后,选择的一些区间一定会覆盖 \([0,X]\) 的某个前缀。设 \(dp_{i,j}\) 为使用前 \(i\) 个区间覆盖 \([0,j]\) 的方案数,设第 \(i\) 个区间为 \([L_i,R_i]\),则转移即为 \(\forall j\ge L_i,dp_{i-1,j}\to dp_{i,\max(j,R_i)}\)。可以使用权值线段树优化计算。

三、改写条件(条件の言い換え)

某些时候题目所给出的条件比较复杂,此时需要提炼出一个足够严谨而简洁的充要条件代替之。

例题1:A or...or B Problem

题意

\([A,B]\) 中的若干数(至少一个)的按位或有多少种。\(A,B<2^{60}\)

解法

考虑去掉二进制数 \(A,B\) 的 LCP,然后找出一个最大的 \(2^p\) 满足 \(A<2^p\le B\),然后讨论 \([A,2^p)\)\([2^p,B]\) 的对应答案。

显然 \([A,2^p)\) 内取任意数的按位或一定小于 \(2^p\),所以这部分内对答案造成的贡献为 \(2^p-A\);而对于 \([2^p,B]\),考虑满足 \(2^p+2^q\le B<2^p+2^{q+1}\) 的最大 \(q\),此时 \(\forall v\in[2^p,2^p+2^q)\),将它们和 \(2^p+2^q\) 按位或可得 \([2^p+2^q,2^p+2^{q+1})\) 内的所有数;且 \([2^p,B]\) 内的所有数按位或均小于 \(2^p+2^{q+1}\),所以这部分对答案造成的贡献为 \(2^{q+1}-1\)

然后考虑分别取 \([A,2^p)\)\([2^p,B]\) 内的任意数按位或(就是取 \([A,2^p)\)\([2^p,2^p+2^{q+1}-1]\) 中各一个数按位或)的数量。此时显然这个值的最小值为 \(2^p+A\),最大值为 \(2^{p+1}-1\),且 \(\forall v\in[A,2^p),v\,{\rm{AND}}\,2^p\) 可以取遍 \([2^p+A,2^{p+1}-1]\) 内的所有数。将所有答案区间取并即可得出答案。

代码

点此查看代码
#include <bits/stdc++.h>
using namespace std;
long long a,b,p,q;
int main(){
    scanf("%lld%lld",&a,&b);
    if(a==b){putchar('1');return 0;}
    for(q=a^b,p=1;p<=q;p<<=1); 
    --p; a&=p; b&=p;
    for(p=1;p<=b;p<<=1); p>>=1;
    for(q=1;p+q<=b;q<<=1);  
    printf("%lld",p+p+q-a-max(a,q));
    return 0;
}

例题2:Eternal Average

题意

\(n\)\(0\)\(m\)\(1\)。每次会删去 \(k\) 个数,然后加入它们的平均数。求最后留下的数的可能的值的数量对 \(10^9+7\) 取模。\(n,m,k\le 2\times 10^3\)

解法

设最后留下的数为 \(Z\)。考虑使用 \(k\) 进制写出 \(Z\),则 \(Z\) 的所有位上的数字和和 \(m\) 在模 \(k-1\) 意义下同余。证明考虑将所有加法不进行仅为操作时,所有位上的数字之和刚好为 \(m\)。同时考虑使用相同的方法操作,但是将 \(0\)\(1\) 互换,最后得到的数为 \(1-Z\),且对应的所有位上的数字和和 \(n\) 在模 \(k-1\) 意义下同余。(由于 \((m+n-1)\bmod (k-1)=0\),所以后者一定会发生)

此时如果存在对应的 \(Z\),设 \(c_i\)\(Z\) 的第 \(i\) 位,\(d_i\)\(1-Z\) 的第 \(i\) 位;则可以每次选择一个非 \(0\)\(c_i\) 减一,然后将 \(c_{i+1}\) 加上 \(k\),直到 \(\sum_i c_i=m\)。同时对 \(d\) 也进行这样的操作。然后考虑整个合并的过程可以看成建出一棵 \(k\) 叉树的过程。然后考虑操作之前的 \(c_i\)\(d_i\),设 \(l(c)\) 为满足 \(c_i\ne 0\) 的最大 \(i\),则 \(c_{l(c)}+d_{l(c)}=k\),且 \(\forall i<l(c),c_i+d_i=k-1\),可以构造出一棵合法的二叉树,其中每个 \(c_i+d_i\) 对应了深度为 \(i\) 的所有叶节点(可能为 \(0/1\))。然后在将某个 \(c_i\) 减一,将 \(c_{i+1}\)\(k\) 的过程可以看成将一个叶节点变成一棵直径为 \(2\)\(k\) 叉树的过程;所以操作后的 \(c,d\) 也可以建出对应的 \(k\) 叉树,每一种 \(c\) 均为合法。

注意 \(c\) 需要最后一位非 \(0\),需要特判。

代码

点此查看代码
#include <bits/stdc++.h>
using namespace std;
const int maxn=2010;
const int md=1000000007;
int n,m,k,i,j,a,b,s[maxn],dp[2][maxn][2]; 
inline void Add(int &x,int y){x-=((x+=y)>=md)*md;}
int main(){
    scanf("%d%d%d",&n,&m,&k);
    dp[0][0][0]=1; 
    b=max(n,m)<<1;
    auto X=dp[0],Y=dp[1];
    for(i=1;i<=b;++i){
        Add(s[0]=X[0][0],X[0][1]);
        for(j=1;j<=m;++j){
            Add(s[j]=X[j][0],X[j][1]);
            Add(s[j],s[j-1]);
        }
        for(j=0;j<=m;++j){
            Add(Y[j][0]=X[j][0],X[j][1]);
            Y[j][1]=s[j-1]; if(j>=k) Add(Y[j][1],md-s[j-k]);
            if(!((m-j)%(k-1))&&i*(k-1)-j+1>=0&&i*(k-1)-j+1<=n) Add(a,Y[j][1]);
        }
        swap(X,Y); 
    }
    printf("%d",a);
    return 0;
}

四、结合贪心思想(greedy からの帰着)

例题1

题意

求有多少个 \(1\sim n\) 的排列可以被分成两个单增的子序列。\(n\le 2000\)

解法

枚举某种划分方案对应的序列并不划算,因为某个序列可以有多种划分方案。考虑对于某个排列 \(p\) 如何判断其是否满足条件。设 \(dp_i\) 为考虑了前 \(i\) 个数,且 \(\max_{j=1}^i p_j\) 不在的另一个序列的末尾最小值为多少。如果新的 \(p_{i+1}>\max_{j=1}^i p_j\),则 \(dp_{i+1}=dp_i\),否则需要 \(p_{i+1}>dp_i\)\(dp_{i+1}=p_{i+1}\)

考虑构造排列时,在排列中通过依次插入 \(1\sim n\) 的数来计算排列数量(避免算重,且确定大小关系可以更好转移)。显然每次插入后某个子序列的末尾一定是当前最后一个数。在插入完前 \(i\) 个数后,需要维护在这个序列内 另一个子序列的末尾的最小值(以免在使用其他划分方法时算重)的位置(维护后面的元素的插入方法)。设 \(dp_{i,j}\) 为插入前 \(i\) 个数,且另一个子序列末尾的最小值离末尾有 \(j\) 个元素时的方案数,转移时看 \(i+1\) 是否插入在末尾,有 \(dp_{i+1,j}=\sum_{k=j-1}^{i-1}dp_{i,k}\),可以使用后缀和优化计算。


所以在统计满足某种限制对应的序列数量时,由于贪心对应的策略会保证某个序列不会有多种贪心操作的方案,所以可以把目前贪心决策对应的状态作为 dp 状态,可以保证不重复。

例题2:Salaj

题意

\(n\) 个点。现在需要进行 \(n(n-1)\) 次操作,每次操作选择两个点 \(u,v\) 满足 \(u\to v\) 边不存在,然后连上 \(u\to v\) 的有向边。设 \(c_i\) 为第 \(i\) 次操作后图上的强连通分量个数,求有多少种 \(c\)\(n\le 50\)

解法(暂缺,不会证明)

知道如何证明者可以在下面补充证明。

补一下 \({\color{black}{\rm Z}}{\color{red}{\rm{MJ}}}\) 巨佬 的证明:

代码(暂缺)

点此查看代码

五、根据事件找准参数(場合分けのテクニック)

在对某些元素计数的时候,为了去重,如果不能使用容斥原理在计算完后去重,则应该找到一个能够和元素一一确定的一个量。

例题1

题意

有一个长为 \(n\) 的数组 \(a\)。现在需要选出若干数分别分到 \(A,B\) 集合,满足 \(\forall i\in A,j\in B;i<j\);求有多少种分的方法满足 \(\sum_{i\in A}i=\sum_{j\in B}j\)\(n,|a|\le 100,a\) 中的数互不相同。

解法

排序后的 \(a\) 中,\(A\)\(B\) 一定会在两个不交的前后缀内。如果单独在前后缀内分别选择就会出现重复。考虑去重问题:设 \(A_{i,j}\)强制选择了 \(\boldsymbol{a_i}\),且选择的所有数之和为 \(j\) 的方案数。转移时可以使用普通背包。然后在每个后缀同样进行一次背包即可。

例题2:Piling Up

题意

一开始有 \(n\) 个颜色为黑白的球,但不知道黑白色分别有多少, \(m\) 次操作,每次先拿出一个球,再放入黑白球各一个,再拿出一个球,最后拿出的球按顺序排列会形成一个颜色序列,求颜色序列有多少种。答案对 \(10^9+7\) 取模。\(n,m\le 3\times 10^3\)

解法

\(dp_{i,j}\)\(i\) 次操作后,剩余 \(j\) 个白球的对应序列数。然而这样做会算重,例如序列 白 白 会同时在 \(dp_{1,0}\sim dp_{1,m-1}\) 里面出现。

然后考虑只让某个序列在一个 dp 值内出现,但是同时需要维护每个 dp 值对应的转移。考虑实际上某个序列对应了原有的白球需要在某个区间里面,且这个区间一定会随着序列的长度而减小。此时可以直接取区间左端点对应的 dp 值计入答案。转移时设 \(dp_{i,j,0/1}\) 表示 \(i\) 次操作,剩余 \(j\) 个白球,且白球是否被取完的序列数,最后取 \(\sum_{i=0}^mdp_{n,i,1}\) 为答案即可。

代码

点此查看代码
#include <bits/stdc++.h>
using namespace std;
const int maxn=3010;
const int md=1000000007;
int n,m,i,j,w,b,dp[2][maxn][2]; 
inline void Add(int &x,int y){x-=((x+=y)>=md)*md;}
int main(){
    scanf("%d%d",&n,&m);
    auto X=dp[0],Y=dp[1]; X[0][1]=1;
    for(i=1;i<=n;++i) X[i][0]=1;
    for(i=1;i<=m;++i){
        for(j=0;j<=n;++j){
            if(j){
                b=(j==1); w=X[j][0];
                Add(Y[j-1][b],w);
                Add(Y[j][b],w); 
                w=X[j][1];
                Add(Y[j-1][1],w);
                Add(Y[j][1],w);
            }
            if(j!=n){
                w=X[j][0];
                Add(Y[j][0],w);
                Add(Y[j+1][0],w);
                w=X[j][1];
                Add(Y[j][1],w);
                Add(Y[j+1][1],w);
            }
        }
        swap(X,Y); memset(Y,0,(m+1)<<3);
    }
    for(i=w=0;i<=m;++i) Add(w,X[i][1]);
    printf("%d",w);
    return 0;
}
posted @ 2023-01-08 11:50  Fran-Cen  阅读(189)  评论(0编辑  收藏  举报