组合计数在高中阶段是数学的一个必修的章节,难度不大。所以你以为的组合计数题仅仅是像这样对吗?
[HNOI2008]越狱
题目描述
监狱有 n个房间,每个房间关押一个犯人,有 m 种宗教,每个犯人会信仰其中一种。如果相邻房间的犯人的宗教相同,就可能发生越狱,求有多少种状态可能发生越狱。
答案对 100,003取模。
这个问题很简单对吧,直接用等价转换法和乘法计数原理就可做出来了。
但是和数论结合的组合计数才是真正的硬菜:
AcWing 213.古代猪文(SDOI2010)
同样是省选考察组合计数,为啥过了两年SD的就比HN的难了这么多呢?
重要引理:Lucas定理:。
这题可以很容易想到以下几步操作:看到幂的形式,想到欧拉定理的推论,直接将指数对取模就行。然后通过试除法来枚举n的约数d,然后一个个用Lucas定理求出取模后的值相加得到指数,再用一次快速幂即可。但是由于999911659是质数,=
999911658。这个模数太过于巨大,会导致Lucas定理退化成O(n)的时间复杂度,不能接受。所以接下来就是重头戏:优化。
1.对于999911658的分析:将这个数分解质因数得到:999911658=2*3*4679*35617。这个分解式有一个很明显的特点就是:所有因子的次数都是1。在这里考虑到用CRT。
思路就是将2、3、4679、35617分别作为模数求出指数取模后的值分别设为a1,a2,a3,a4,然后求解一个线性同余方程组即可。
这个题目还是主要来考察CRT的运用啊,我们还是要先去想到缩小模数,对于很大的模数进行分解质因数,发现特殊的性质,便想到采用CRT。(对CRT求出的通解的实质理解要深刻)
代码如下:
#include<iostream>
using namespace std;
const int mod=999911658;
typedef long long ll;
ll a[5],b[5]={0,2,3,4679,35617},fac[500010],ans;
void init(ll n){
fac[0]=1;
for(ll i=1;i<=n;i++){
fac[i]=fac[i-1]*i%n;
}
}
ll qmi(ll a,ll k,ll p){
ll res=1;
while(k){
if(k&1) res=res*a%p;
a=a*a%p;
k>>=1;
}
return res%p;
}
ll C(ll n,ll m,ll p){
if(n<m) return 0;
return fac[n]*qmi(fac[n-m],p-2,p)%p*qmi(fac[m],p-2,p)%p;
}
ll lucas(ll n,ll m,ll p){
if(n<m) return 0;
if(!n) return 1;
return lucas(n/p,m/p,p)*C(n%p,m%p,p)%p;
}
void CRT(){
for(int i=1;i<=4;i++){
ll Mi=mod/b[i];
ll t=qmi(Mi,b[i]-2,b[i]);
ans=(ans+a[i]*Mi%mod*t%mod)%mod;
}
}
int main(){
ll n,q;
cin>>n>>q;
if(q%(mod+1)==0){
cout<<0;
return 0;
}
for(int k=1;k<=4;k++){
init(b[k]);
for(ll i=1;i*i<=n;i++){
if(n%i==0){
a[k]=(a[k]+lucas(n,i,b[k]))%b[k];
if(i*i!=n){
a[k]=(a[k]+lucas(n,n/i,b[k]))%b[k];
}
}
}
}
CRT();
cout<<qmi(q,ans,mod+1);
}
AcWing 212.计数交换(史诗级难度)
首先我们先考虑题目中最少的交换次数m要如何求出。一开始我还在想是不是用归并排序求逆序对的个数,后来才发现我是个**。逆序对的个数表示的其实是:每次交换相邻的两个数最后使得序列有序的最少操作次数。而题目中是可以任意交换的。所以这里要来扩展知识了:如何用最少的操作次数使得1-n的一个任意排列变为升序排列?(一次操作指的是交换序列中的任意两个元素)。我们考虑在p[i]与i之间连一条有向边,容易知道最后这个序列中会存在若干个环。那么我们的目标就是把这若干个环全部变成自环,并求出其最少的操作次数。
我们猜想:把一个长度为n的环变成n个自环,至少需要(n-1)次操作。
证明:利用数学归纳法,当i=1时,结论显然成立。那么假设对于任意整数k(),这个结论都成立。那么当k=n时,我们交换这个环中的任意两个元素vi,vj(i<j)。很显然两环的长度分别为j-i与n-(j-i),总操作次数为j-i-1与n-(j-i)-1,总和为n-2,加上之前的操作次数为n-1次。
证毕
有了这一步的铺垫,下面的转化就更加轻松了。对于上面的证明过程,我们不妨假设将长度为n的环拆成长度分别为x,y的两个环有T(x,y)种方法。那么容易知道:T(x,y)=n/2(if x==y且n为偶数),n(else)。
所以将长度为n的环拆成n个自环的方法总数Fn就出来了:……(注意复习:多重集的排列数:见《算法进阶指南》P171)
那么最终答案为:……(式子太复杂,可以自己推,推完了看《算法进阶指南》P173)
当然,这样还不能过掉这一道史诗级的难题。因为Fn的复杂求法,使得时间复杂度达到了O(n^2),但是我们通过找规律发现Fn=n^(n-2),可以把时间复杂度缩减至O(nlogn)。(等我更新后的证明)
总结:我们在多刷题的同时要多记一些可用的模板,作为自己的知识储备。(就像学数学一样,在一道题中接触到的方法也许在以后的某一道题中也可以得到应用)。
错排列
错排列:顾名思义,是指完全错开的排列:即n个信封所匹配的n个信件全部装错的方案总数。那么根据容斥原理,很容易推出下列公式:错排列公式
那么在这里主要讲解一下错排列的递推公式:D[n]=(n-1)(D[n-1]+D[n-2]),证明如下:
考虑第k个数排在第n位,一共有n-1种选法。
再考虑第n个数:
如果第n个数排回了第k位,那么无疑n个位置中已经有2个位置已经确定。那么剩下可能的选择方案数为D[n-2]种。
如果第n个数没有排回第k位,那么此时的n相当于前面过程中任意的k。选择的方式相当于是在剩下的n-1个数中再做一次错排列,方案数为D[n-1]种。
综上所述,D[n]=(n-1)(D[n-1]+D[n-2])
例题1:AcWing 230.排列计数
这题就是一个错排列的裸题了,但是要注意边界情况的特判:
#include<iostream>
#include<cstdio>
using namespace std;
const int N=1e6+5,mod=1e9+7;
typedef long long ll;
ll fac[N],inv[N],d[N];
ll qmi(ll a,ll b,int p){
ll res=1;
while(b){
if(b&1) res=res*a%p;
a=a*a%p;
b>>=1;
}
return res%p;
}
void init(){
fac[0]=1;
for(int i=1;i<N;i++){
fac[i]=fac[i-1]*i%mod;
inv[i]=qmi(fac[i],mod-2,mod);
}
d[2]=1;
for(int i=3;i<N;i++){
d[i]=(i-1)*(d[i-1]+d[i-2])%mod;
}
}
int main(){
int T;
scanf("%d",&T);
init();
while(T--){
ll n,m;
scanf("%lld%lld",&n,&m);
if(n-m==1) puts("0");
else if(n==m) puts("1");
else if(!m) printf("%lld\n",d[n]);
else{
printf("%lld\n",fac[n]*inv[m]%mod*inv[n-m]%mod*d[n-m]%mod);
}
}
return 0;
}
范德蒙德卷积
公式如下:
这个公式可以用来化简式子,减少时间复杂度。
组合计数类题目的其它解题技巧:
1.动态规划法
有一些组合计数类题目不易于直接从组合数角度计算,那么不妨从动态规划的角度入手,找出递推关系式来解决问题。甚至组合数的预处理方法也是动态规划法,即:。
所以引出了求组合数方法1:
#include<iostream>//用杨辉三角来理解也是可以的
using namespace std;
const int N=2005,mod=1e9+7;
int C[N][N];
int main(){
int n;
cin>>n;
for(int i=0;i<N;i++){
for(int j=0;j<=i;j++){
if(!j) C[i][j]=1;//三角边界上的1
else C[i][j]=(C[i-1][j]+C[i-1][j-1])%mod;
}
}
while(n--){
int a,b;
cin>>a>>b;
cout<<C[a][b]<<endl;
}
return 0;
}
例题1:Bulls and Cows
这题就是一个经典的动态规划解决计数类问题的题目,本题较易,仅供参考。
2.转换角度法
组合计数题有时会存在去重遗漏之类的麻烦情况,那么不妨转换一下看问题的角度,兴许就可以解决问题。
例题1:CQOI2014数三角形
这题本人一开始的想法十分复杂,实际上我们可以直接考虑枚举每一条线段(两个端点可以直接确定,故枚举端点即可)。那么我们选三角形时就不妨直接将这两个端点固定下来,然后在线段上再另找任意一点即可。这样不用考虑一整条所在直线去重之类的情况,不但简化了程序,还做到了不重不漏。
#include<iostream>
using namespace std;
const int N=1e3+5;
typedef long long ll;
int gcd(int a,int b){
if(!b) return a;
return gcd(b,a%b);
}
ll C(int a){
return (ll)a*(a-1)*(a-2)/6;
}
int main(){
int n,m;
cin>>n>>m;
ll res=C((m+1)*(n+1))-(m+1)*C(n+1)-(n+1)*C(m+1);
for(int i=1;i<=n;i++){
for(int j=1;j<=m;j++){
int d=gcd(i,j);
res-=2ll*(ll)(m-j+1)*(n-i+1)*(d-1);
}
}
cout<<res<<endl;
}