简单计数DP
计数DP
顾名思义,这是对于方案统计的DP类型
需要记住的公式:
- 在平面直角坐标系中,从点\((x_1,y_1)\)走到\((x_2,y_2)\),\((x_1<x_2,y_1,<y_2),\)每一步只能使得\(x_1+1\)或者是\(y_1+1\),求合法路径的条数为:
证明:因为每一步只能向右或者向上走,所以我们可以把每一步抽象为一个物品,共有\(x_2+y_2-x_1-y_1\)个,从中选出向右走的\(x_2-x_1\)个或者向上走的\(y_2-y_1\)个,由组合数的定义即得
- 一张含有\(n\)个节点的无向图一共有\(2^{n\times (n-1)/2}\)种可能性(节点不编号)
- 一张含有\(n\)个节点的连通无向图(节点有编号),一共有的可能性数量为:
初值:\(f[1]=1\)
4. 一张含有\(n\)个节点,\(j\)条割边,且无重边和自环的无向连通图图一共有的可能性数量为:
设\(f[i,j]\)表示有\(i\)个点\(j\)条割边的无向连通图,有公式:
其中\(G[i,j,k]\)表示由\(i\)个点构成的,含有\(j\)个连通块,\(k\)条割边的无向图总数
有公式;
初值:\(f[k,0]=H[i]-\sum_{j=1}^{k-1}f[k,j]\),其中
- 在一个长度为n的环里,可以交换任意两个节点,使得这个环变为n个自环且使用最少步骤的方案有\(f[n]=n^{n-2}\)
下面的例题中会详细推导这一整个公式,并附上代码
注意事项:
-
之前提到过的:在DP的方案统计的时候,如果一个状态的多个决策之间满足加法原理,而各个决策的方案又满足乘法原理时,在状态转移的时候各个决策必须具备互斥性才不会重复,对于互斥性的问题,我们可以挖掘题设条件,在DP的维度进行限制,比如改变某个维度的含义或者对这个含义进行缩小范围
-
擅长从正反两面思考问题,如果正着不好处理,不妨采用总-反的方式,这是弱化版的容斥原理
-
合理运用组合计数公式以及容斥原理等,记得有时可以打表找感觉
-
十年OI一场空,不开longlong见祖宗
-
注意方案之间的重复性,一定得做到不重不漏
-
在实现代码设计状态的时候可以使用等效转换,将问题变得更容易统计,一般是在不影响答案的情况下将绝对性表述换为相对性表述
-
注意方案之间的内在联系,方案之间也可以求排名,求排名的时候需要像平衡树那样把小的一坨全删了,具体见装饰围栏
套路
- 思路1:为了不重不漏,在计数DP的设计中,我们会经常使用围绕一个(或者是两个)基准点,构造一个不可划分的整体,对此进行计算(见例1)
- 思路2:构造法,将需要的构造出来,见例2
- 一般需要复杂度\(O(log_n)\sim O(nlogn)\)的是数学题,复杂度\(O(n^2+)\)是DP题
下面就开题了:
例题:How many of them
题目描述
在无向连通图中,若一条边被删除后,图会分成不连通的两部分,则称该边为割边。
求满足如下条件的无向连通图的数量:
1、由 N 个节点构成,节点有标号,编号为 1∼N。
2、割边不超过 M 条。
3、没有自环和重边。
熟悉不,这就是我们之前提到的第四条公式,下面展开详细推导
首先我们先讨论\(j>0\)时的做法,很明显,我们可以以1号节点为基准点进行思考
在图论中,我们称不含割边的图为双连通分量,很明显,一个含有割边的无向图一定有着双连通分量
所以我们先来考虑当节点1处于一个大小为\(k\)的双连通分量的时候共有多少方案,很明显,从剩下的\(i-1\)个节点里拿\(k-1\)个就行,即\(f[k,0]*C_{i-1}^{k-1}\),当我们去掉了这个双连通分量之后,剩下的图形一共就有\(G[i-k,x,j-x]\)种,其中\(x\)是小于\(\min(i-k,j)\)的正整数,因为这个双连通分量一旦删去,与他相连的其他连通块的边就是割边,又因为这个双连通分量的大小为\(k\),剩下了\(x\)个连通块,所以每一个连通块都可以连接双连通分量中的\(k\)个节点,由乘法原理得到共有\(k^x\)种,综上所述,我们得到了这样一个递推式:(\(0<j<i\))
而当\(j=0\)时,我们可以这样想,即在一个有着\(n\)个节点(节点编号)的无向连通图减去含有割边的无向连通图数量就得到了不含割边的无向连通图数量,而有着n个节点的无向连通图数量在公式3中已经给出,在公式4里体现为\(H\),于是我们得到了
,现在我们来讨论\(G[i,j,k]\)的求法:我们仍然以1号节点为基准点,对其进行操作,不妨思考,对于这样一张无向连通图,我们可以枚举一号节点所在的连通块的割边数量(\(q\))和大小(\(p\))这样一共有\(f[p,q]\)种方案,然后由乘法原理,再乘上\(G[i-p][j-1][k-q]\),在这里不能忽略了我们选出这\(p\)个节点一共有\(C_{i-1}^{p-1}\)种方法,并且我们还需要从这个连通块里面选出一个节点与我们剩下的图相连,很明显有\(p\)种,于是我们得到了:
代码贴一份,这题时间紧,这份代码常数较大最后两个点过不了,预处理一下组合数(递推),取消#define int long long
,再预处理一下2的幂,在代码中处理\(k^x\)的时候直接循环,不要快速幂,这样就可以过了,不过我懒,懒得,以至于复杂度加了个log,常数还大得惊人,以至于没了
#include<iostream>
#include<cstdio>
using namespace std;
#define int long long
int f[55][55],g[55][55][55],jc[55],inv[55],h[55],p=1e9+7,n,m;
int power(int a,int b){
int ans=1;
while(b){
if(b&1)ans=ans*a%p;
a=a*a%p;
b>>=1;
}
return ans;
}
void init(){
inv[0]=jc[0]=1;
for(int i=1;i<=n;i++)jc[i]=jc[i-1]*i%p,inv[i]=power(jc[i],p-2)%p;
}
int C(int n,int m){
return jc[n]%p*inv[m]%p*inv[n-m]%p;
}
signed main(){
scanf("%lld%lld",&n,&m);
g[0][0][0]=1;
init();
for(int i=1;i<=n;i++){
h[i]=power(2,i*(i-1)/2);
for(int j=1;j<i;j++){
h[i]=(h[i]-h[j]*C(i-1,j-1)%p*power(2,(i-j)*(i-j-1)/2)%p)%p;
}
f[i][0]=h[i];
}
for(int i=1;i<=n;i++){
for(int j=1;j<i;j++){
for(int k=1;k<i;k++){
int s=0;
for(int x=1;x<=min(i-k,j);x++)s=(s+g[i-k][x][j-x]*power(k,x)%p)%p;
f[i][j]+=f[k][0]*C(i-1,k-1)%p*s%p;
}
f[i][0]=((f[i][0]-f[i][j])%p+p)%p;
}
for(int j=1;j<=i;j++)
for(int k=0;k<i;k++)
for(int p1=1;p1<=i;p1++)
for(int q=0;q<=k;q++)
g[i][j][k]=(g[i][j][k]+f[p1][q]*C(i-1,p1-1)%p*p1%p*g[i-p1][j-1][k-q])%p;
}
int ans=0;
for(int i=0;i<=m;i++)ans=(ans+f[n][i])%p;
printf("%lld",(ans%p+p)%p);
}
例题:扑克牌
题面描述:
一副不含王的扑克牌由 52 张牌组成,由红桃、黑桃、梅花、方块 4 组牌组成,每组 13 张不同的面值。
现在给定 52 张牌中的若干张,请计算将它们排成一列,相邻的牌面值不同的方案数。
牌的表示方法为 XY,其中 X 为面值,为 2、3、4、5、6、7、8、9、T、J、Q、K、A 中的一个。
Y 为花色,为 S、H、D、C 中的一个。
如 2S、2H、TD 等。
有T组数据,每组数据有N张牌,时间限制1s
\(1≤T≤20000,
1≤N≤52\)
分析:在此题中,我们设\(a_i\)表示第i种牌花色的牌的数量,\(b_i\)表示面值,很容易想到一个\(O(na_i^{b_i})\)的暴力DP,可惜不可以,于是我们继续挖掘题目条件,一定还有优化的余地
我们发现,
- 题目上的限制仅仅是两个不同而已,于是乎我们并不关心两者的具体权值,只关心是不是相等
- 每种牌的数量很少,这启发我们构建一个以牌的数量为状态的状态转移方程
- 同类牌是可以互相交换的
这题的难度就在状态的设计
因为未使用的牌的数量只有可能为\(1,2,3,4\),于是我们可以设置四个维度的状态,分别表示还有多少种牌剩下一张的,两张的…,又由于四维状态并不好满足限制条件:相邻两个不能相同,于是我们又增加一维,记录上一次是从剩下几张牌的堆里面转移过来的。状态转移方程就昭然若揭
设\(f[a,b,c,d,e]\)表示有a种剩余一张的牌,b种剩余两张的牌,c种剩余3张的牌,d种剩余四张的牌,上一次是从剩余e张牌的堆里拿出来的,有方程(代码形式,数据较大得开ull):
ull ask(int a,int b,int c,int d,int e){
ull& res=f[a][b][c][d][e];
if(res)return res;
if(a)res+=ask(a-1,b,c,d,1)*(a-(e==2));
if(b)res+=ask(a+1,b-1,c,d,2)*2*(b-(e==3));
if(c)res+=ask(a,b+1,c-1,d,3)*3*(c-(e==4));
if(d)res+=ask(a,b,c+1,d-1,4)*4*d;
return res;
}
我们以第一部分为例讲解状态转移方程是怎么来的
\(f[a,b,c,d,e]+=f[a-1,b,c,d,1]*(a-(e=2))\)
由于我们是按照记忆化搜索的形式实现的自顶向下的动态规划,于是我们令上一次的状态为\(f[a',b',c',d',e']\)
对于我们第一个方程,当\(e=2\)时,很明显\(a=a'+1,b=b'-1\)(由第二部分转移方程得到),于是我们这个转移方程也可以写作
\(f[a,b,c,d,e]+=f[a',b'-1,c,d,1]*(a-(e=2))\)
我们会发现,之所以我们的判断条件是\(e=2\),是因为e是反应的上一步的决策,现在我们以\(a',b'\)的形式写出来之后决策是拿走第二堆的牌,于是\(e=2\),剩下的几个部分同理,当\(e=2\)时,我们之所以乘上\(a-1\)就是因为我们需要使用容斥原理消去“两个相邻的不能在一起”这个影响,因为数量为2的牌的堆被拿走了一个,这个在一种情况下与之前的相同的牌挨在一起了,需要消去,同理,e=3的时候会加进去同种牌的一个,那种牌剩下两张,于是共会产生两种可能的冲突,e=4时同理,当然,当e=1的时候就不用管了,那时候都没牌了,第四部分的方程也不用管,那种牌都没拿走过
由于状态转移方程比较复杂,循环形式难以实现,故采用递归实现
#include<iostream>
#define ull unsigned long long
using namespace std;
int n,t,val[75],cnt[5],num[55],tot;
ull f[15][15][15][15][5];
char s[1005];
ull ask(int a,int b,int c,int d,int e){
ull& res=f[a][b][c][d][e];
if(res)return res;
if(a)res+=ask(a-1,b,c,d,1)*(a-(e==2));
if(b)res+=ask(a+1,b-1,c,d,2)*2*(b-(e==3));
if(c)res+=ask(a,b+1,c-1,d,3)*3*(c-(e==4));
if(d)res+=ask(a,b,c+1,d-1,4)*4*d;
return res;
}
signed main(){
ios::sync_with_stdio(false);
cin.tie(0),cout.tie(0);
for(int i=0;i<=4;++i)f[0][0][0][0][i]=1;
int res=0;
cin>>t;
while(t--){
cin>>n;
for(int i=1;i<=n;++i)cin>>s,++val[s[0]-49];
for(int i=1;i<=4;++i)cnt[i]=0;
for(int i=0;i<=50;++i)++cnt[val[i]],val[i]=0;
cout<<"Case #"<<++res<<": "<<ask(cnt[1],cnt[2],cnt[3],cnt[4],0)<<"\n";
}
return 0;
}