浅谈容斥 dp —— 不会容斥怎么办
前言
本文同步发表在博客园。
本文将用四道例题入门容斥 dp,可以作为一个入门的学习工具,但并不建议,甚至可以说是反对,尝试用本文进行完整、系统的学习。本文仅适合入门。
本文为浅谈,甚至不如浅谈,因为作为一只大区,我也不是很会容斥 dp。
如果本文有任何漏洞或需要补充的地方,欢迎各位 dsa 在评论区指出问题,我会尽快修正/补充喵!
引言
容斥是什么?
相信大家经常见到“正难则反”。有很多题目直接根据题目条件入手难度极高甚至无法解决,但倒着做就能迎刃而解。这种技巧被称作容斥。
但,容斥肯定不仅限于正难则反。更准确地说,“正难则反”只是容斥思想最直观的一种体现。容斥真正关注的是如何通过统计一些容易计算的情况,再利用它们之间的重叠关系还原出真正的答案。有时候确实是在“总数”中不断排除不合法情况;但有时候,我们统计的甚至不是“不合法的情况”,而是一些“至少满足某个条件”的方案,最后再通过加加减减得到“恰好满足条件”的方案。后面几道题中,我们就会逐渐看到这些不同形式的容斥。
所以……究竟什么是容斥?
容斥最简单的用法,是把“统计满足限制的方案”转化成“总方案减去不满足限制的方案”。但当“不满足限制”又由多个互相重叠的情况组成时,只做一次减法是完全不够的,于是我们需要继续减去重复计算的部分、加回被减多次的部分,不断加加减减,这就是容斥。
但当然,容斥还有更多进阶用法。本文的后两题将带你见证高级容斥的力量。
基本公式
相信大家小学二年级就学过一个叫做韦恩图的东西,比如说三个集合的韦恩图长这样:
假设我们要求出三个集合的并集的大小,我们可以将三个集合的大小加起来。但是中间的重叠部分会多次计算,因此我们将任意两个集合的重叠部分的大小减去。但是这样最中间的中心部分的就被减光了,所以还需要加回来。
用公式表达就是:
这个公式也相信大家在小学二年级就见到过。
观察这个式子,发现当一个新集合为奇数个集合的交集时,它的大小前面的符号为正,否则如果是偶数个集合的并集,符号为负。推广到
这样,我们可以得到一个通项公式:
这个公式在容斥中是一个基础的公式,但它的运用非常广泛,因此我会在这里单独提一嘴。
当然我相信大家都会了,所以我们开始实战演练吧。
正文 - 例题讲解
初试身手:CF451E
在【基本公式】部分,我们提到的求并集公式需要枚举被求并集的所有集合所构成的集合的子集(怎么这么绕口呢 qwq),而子集的枚举是
再看到这道题,眼尖的大家一定很快就发现了
但是不对呀。这道题目里的“集合”是表示什么呢?
我们先做一个最基本的转化题意。题目要求在第
对
接下来我们加回上限限制。这样,原来的总方案数中就一定有一些不满足条件的情况,需要去除,而这恰好与前面所提到的公式匹配。因此在这里,“集合”就是每个盒子里的花的选取情况,选择这个“集合”表示让该盒子里的花的选取数量超出上限(即
假设现在已经枚举出一个子集
这就是这道题目的思路。它充分展现了对前面提到的公式的运用。在直接运用基本公式时,需要分清每个“集合”所代表的信息,以及“选取”这个“集合”对应原题面中什么操作。
::::success[sample code]
#include<bits/stdc++.h>
#define LL long long
#define UInt unsigned int
#define ULL unsigned long long
#define LD long double
#define pii pair<int,int>
#define pLL pair<LL,LL>
#define pDD pair<LD,LD>
#define fr first
#define se second
#define pb push_back
#define isr insert
#define _i128 __int128
using namespace std;
const int N = 25;
const LL MOD = 1e9+7;
LL n,s,a[N],Ans,inv[N];
LL read(){
LL su=0,pp=1;char ch=getchar();
while(ch<'0'||ch>'9'){if(ch=='-')pp=-1;ch=getchar();}
while(ch>='0'&&ch<='9'){su=su*10+ch-'0';ch=getchar();}
return su*pp;
}
LL QP(LL x,LL y=MOD-2){
LL as=1;x%=MOD;
while(y){
if(y&1)as=as*x%MOD;
x=x*x%MOD,y>>=1;
}return as;
}
LL C(LL x,LL y){
if(x<y||y<0)return 0;
LL res=1;
for(LL i=1;i<=y;i++)
res=res*((x-i+1)%MOD)%MOD,
res=res*inv[i]%MOD;
return res;
}
int main(){
n=read(),s=read();
for(int i=1;i<=n;i++)a[i]=read();
for(int i=1;i<n;i++)inv[i]=QP(i);
for(int st=0;st<(1<<n);st++){
LL p1=1,sum=s+n-1;
for(int i=1;i<=n;i++)
if((st>>i-1)&1)p1=-p1,sum-=a[i]+1;
Ans+=p1*C(sum,n-1)%MOD;
Ans=(Ans+MOD)%MOD;
}
cout<<Ans<<"\n";
return 0;
}
::::
DP 现身:CF559C
如果说上一道题是对基本公式的运用,那这道题就是真正的容斥 dp 了。它比较经典也比较板子,我们就以它为例,来感受一下容斥 dp 的巧妙。
首先拿到这个题目,第一眼会觉得它很简单,欸我们直接设计
你忽然意识到这题可以用容斥。事实上,这道题容斥的提醒是很明显的,因为它要求计算的是“不经过任何黑格的方案数”,而这完全可以转化为“总方案数减去经过任一黑格的方案数”。
“经过任一黑格的方案数”吗?这个是不是又可以套用最开始的公式呢?答案是,不行,因为
既然无法直接套用公式,我们就得从每个黑格出发计算。如果记录
像我们在统计区间不同数个数的时候,我们只让区间第一个这种数对答案做贡献;在这里也一样。我们可以钦定第
在转移的时候,
然后就只需要考虑具体如何转移啦!首先基础地,从起点
我们考虑那些不合法的情况。既然前面还经过了别的黑格,那么一定有一个第一个经过的黑格,令其为黑格
这样我们就可以
在这个题目中,我们首先因为时间复杂度的爆炸而否掉了最普通的状态定义,紧接着被“不重不漏计数”卡住,然后想到以第一个经过该黑格作为代表统计方案数,并在转移过程中运用容斥处理,最终得出正确答案。最后补“虚拟黑格”的思想也是一种方便最终答案统计的优秀思想。
::::success[sample code]
#include<bits/stdc++.h>
#define LL long long
#define UInt unsigned int
#define ULL unsigned long long
#define LD long double
#define pii pair<int,int>
#define pLL pair<LL,LL>
#define pDD pair<LD,LD>
#define fr first
#define se second
#define pb push_back
#define isr insert
#define _i128 __int128
using namespace std;
const int N = 2005;
const int M = 2e5+5;
const LL MOD = 1e9+7;
struct node{LL x,y;}a[N];
LL h,w,n,fac[M],inv[M],dp[N];
LL read(){
LL su=0,pp=1;char ch=getchar();
while(ch<'0'||ch>'9'){if(ch=='-')pp=-1;ch=getchar();}
while(ch>='0'&&ch<='9'){su=su*10+ch-'0';ch=getchar();}
return su*pp;
}
bool cmp(node d1,node d2){
return (d1.x<d2.x)||(d1.x==d2.x&&d1.y<d2.y);
}
LL QP(LL x,LL y=MOD-2){
LL as=1;
while(y){
if(y&1)as=as*x%MOD;
x=x*x%MOD,y>>=1;
}return as;
}
LL C(LL x,LL y){
LL res=fac[x]*inv[x-y]%MOD;
return res*inv[y]%MOD;
}
int main(){
h=read(),w=read(),n=read();
for(int i=1;i<=n;i++)
a[i].x=read(),a[i].y=read();
a[++n]={h,w};
sort(a+1,a+n+1,cmp);
fac[0]=1,inv[0]=1;
for(LL x=1;x<=h+w;x++)fac[x]=fac[x-1]*x%MOD;
inv[h+w]=QP(fac[h+w]);
for(LL x=h+w-1;x>=1;x--)inv[x]=inv[x+1]*(x+1)%MOD;
for(int i=1;i<=n;i++){
dp[i]=C(a[i].x+a[i].y-2,a[i].x-1);
for(int j=1;j<i;j++){
if(a[j].x>a[i].x||a[j].y>a[i].y)continue;
LL res=C(a[i].x-a[j].x+a[i].y-a[j].y,a[i].x-a[j].x);
dp[i]=(dp[i]-res*dp[j]%MOD+MOD)%MOD;
}
}
cout<<dp[n]<<"\n";
return 0;
}
::::
小试牛刀:CF1559E
刚才我们已经了解了基本的容斥 dp,接下来就通过一个题目来尝试运用一下吧!这个题目也挺有意思的,在容斥的基础上还结合了前缀和优化 dp、调和级数等方法,是一道综合性较强的好题喵。
题目本质就是求满足三条限制的数组数量。前两条限制非常常规,就是限制了每个位置的数的范围和总和,这也是一个常规的背包 dp 的限制;但是最后一条限制就不一样了——它卡死了
虽然直接维护序列的
但是题目要求的并不是序列
于是就只需要考虑怎么求 因为过于简单所以不过多赘述。但是这个时间复杂度是不支持的对吧,但是因为每次转移是处理一个区间和,所以可以用前缀和优化,这样就能
做到这里你肯定已经会了吧!……吗?不,因为我们要
到这里这道题目就结束了。只能说这道题确实是一个综合性非常强的题,因为它涉及到容斥、前缀和、调和级数等等,有一定思维难度,对容斥 dp 的训练非常有帮助。
::::success[sample code]
#include<bits/stdc++.h>
#define LL long long
#define UInt unsigned int
#define ULL unsigned long long
#define LD long double
#define pii pair<int,int>
#define pLL pair<LL,LL>
#define pDD pair<LD,LD>
#define fr first
#define se second
#define pb push_back
#define isr insert
#define _i128 __int128
using namespace std;
const int N = 55;
const int M = 1e5+5;
const LL MOD = 998244353;
int n,m,l[N],r[N];
LL dp[N][M],ans[M];
int read(){
int su=0,pp=1;char ch=getchar();
while(ch<'0'||ch>'9'){if(ch=='-')pp=-1;ch=getchar();}
while(ch>='0'&&ch<='9'){su=su*10+ch-'0';ch=getchar();}
return su*pp;
}
int main(){
n=read(),m=read();
for(int i=1;i<=n;i++)l[i]=read(),r[i]=read();
int up_G=m/n;//最大 gcd
for(int g=up_G;g>=1;g--){
int sum=m/g+1;//除掉 gcd 后的和上限
for(int i=0;i<=n;i++)
for(int j=0;j<=sum;j++)dp[i][j]=0;
for(int j=0;j<=sum;j++)
dp[0][j]=1;//一个都没有的情况下 0<=j
for(int i=1;i<=n;i++){
//枚举每个数逐次加入
int L=(l[i]+g-1)/g,R=r[i]/g;
//对应除掉 gcd 后的范围 [L,R]
for(int j=L;j<=sum;j++){
//这里 j 从 L 开始枚举,不然无法加入第 i 个
int tl=max(0,j-R),tr=j-L;
//求出原 j 值(和)的范围 [tl,tr]
if(tl>tr)dp[i][j]=0;//越界
else dp[i][j]=(dp[i-1][tr]-(tl>0?dp[i-1][tl-1]:0)+MOD)%MOD;//前缀和求出答案
if(j>0)dp[i][j]=(dp[i][j]+dp[i][j-1])%MOD;//维护前缀和方便转移
}
}
ans[g]=dp[n][sum-1];//当前 gcd 下的答案
for(int j=2*g;j<=up_G;j+=g)
ans[g]=(ans[g]-ans[j]+MOD)%MOD;
//减去非严格 gcd 的部分得到 gcd 恰好为 g 的答案
// cout<<"gcd="<<g<<" ans="<<ans[g]<<"qwq\n";
}
cout<<ans[1]<<"\n";
return 0;
}
::::
大展身手:P4859
前面我们已经做了三道题,想必大家也对容斥 dp 有了一个新的理解了。现在我们已经没什么好害怕的了,直接挑战这道题目吧!
因为输入的
对于一个
然后就可以开始 dp 了。
得到状态之后考虑如何转移
dp 结束后,我们就可以算出
但是