组合数学
这东西作为csp初赛常驻题目,有时也会在程序题里ex人,所以,这一块的基础一定要打牢
组合数学概述
两大原理
这里指的是加法原理和乘法原理
加法原理
加法原理即把同阶段的不同的情况相加,举个形象的例子:
从一个城市到另一个城市,有 n n n条公路,同时又有 m m m条铁路,则总共有 n + m n+m n+m种方法
乘法原理
乘法原理即把不同阶段的选择数相乘,举个例子
从a城市经过b城市到c城市,从a到b有 n n n条路,从c到b有 m m m条路,则从a经过b到c有 n ∗ m n*m n∗m条路径
排列数和组合数
下面我们了解一下组合数学的两大基本运算
C n m C_{n}^{m} Cnm表示在 m m m个不同的物体中 n n n选择n个的方案数,叫做组合数,有
C n m = n ! m ! ( n − m ) ! C_{n}^{m}=\frac{n!}{m!(n-m)!} Cnm=m!(n−m)!n!
A n m A_{n}^{m} Anm表示在 m m m个不同的物体中 n n n选择n个排成序列的方案数,叫做排列数,有
A n m = n ! ( n − m ) ! A_{n}^{m}=\frac{n!}{(n-m)!} Anm=(n−m)!n!
这样我们就解决了两个最基本的问题
下面我们来看一下原理(我用容易理解的方法讲一下):
先看排列数 A n m A_{n}^{m} Anm,对于待选的序列,第一项有 n n n个选择,第二项有 n − 1 n-1 n−1个,一次类推,第 m m m项有 n − m + 1 n-m+1 n−m+1个,根据乘法原理,把它们相乘,总共即为从 n n n乘到 n − m + 1 n-m+1 n−m+1,也就是, A n m = n ! ( n − m ) ! A_{n}^{m}=\frac{n!}{(n-m)!} Anm=(n−m)!n!
下面再看组合数 C n m C_{n}^{m} Cnm,它和排列数的区别在于排列数区分了物品的顺序,而组合数没有,那么就可以直接把有序转化成无序,即 C n m = A n m m ! C_{n}^{m}=\frac{A_{n}^{m}}{m!} Cnm=m!Anm,代入得 C n m = n ! m ! ( n − m ) ! C_{n}^{m}=\frac{n!}{m!(n-m)!} Cnm=m!(n−m)!n!
其他实用的数
各个伟大的前辈们已经为我们整理出了一套完整的组合数学体系,其中除组合数和排列数外,还有其他的数,全部与排列组合有关
Catalan数
我没看到Catalan数的标准表示法,就自己随便写吧
首先我们来看一组题目(也可以想想为什么是一组)
已知一个凸n边型,众所周知,它可以划分为n-1个三角形,求有多少种划分方法
已知一棵二叉树,有n个节点,求这课二叉树有几种情况(注意二叉树区分左右子树)
有n个序列依次进栈,求有多少种合法的出栈序列
一个长度为n的序列全部由1和-1组成,要求多少个序列满足所有前缀和为非负数
由于OIer对二叉树的喜爱与赞美之情我们从第二个问题入手
令当前问题的答案为
h
n
h_n
hn,显然,我们必须要选一个根节点,剩下的节点就被分成左子树和右子树,于是我们得到递推式:
h
n
=
∑
i
=
0
n
−
1
h
i
h
n
−
i
−
1
(
h
0
=
h
1
=
1
)
h_n=\sum_{i=0}^{n-1} h_ih_{n-i-1} \left(h_0=h_1=1\right)
hn=i=0∑n−1hihn−i−1(h0=h1=1)
这就是Catalan数的递推式,显然,只要符合这个递推式就是Catalan数,我们代入剩下的问题就知道了——其实这几个问题的答案完全相同
同时我们还可以得到一些其他公式:
一阶递推公式:
h
n
=
4
n
−
2
n
+
1
h
n
−
1
h_n=\frac{4n-2}{n+1}h_{n-1}
hn=n+14n−2hn−1
一个通项公式:
h
n
=
C
n
2
n
−
C
n
+
1
2
n
h_n=C_n^{2n}-C_{n+1}^{2n}
hn=Cn2n−Cn+12n
另一个更常用的通项公式:
h
n
=
1
n
+
1
C
n
2
n
h_n=\frac{1}{n+1}C_n^{2n}
hn=n+11Cn2n
以下是推导:
我们可以通过同一个问题两个解法的转换来获得Catalan数的通项公式。
对于第三个问题,我们设合法的序列个数为
h
n
h_n
hn,不合法的序列为
u
n
u_n
un
显然有:
h
n
+
u
n
=
C
n
2
n
h_n+u_n=C_n^{2n}
hn+un=Cn2n
若括号序列的最短的不合法的前缀长度是
k
k
k,显然
k
k
k是奇数且这个前缀由
k
/
2
+
1
k/2+1
k/2+1个-1和
k
/
2
k/2
k/2个1组成,同时第
k
k
k个值是-1。
如果我们把这
k
k
k个数,得到了一个由
n
+
1
n+1
n+1个-1和
n
−
1
n-1
n−1个1组成的序列,显然这个取反操作是可逆的(找大于0的前缀和并且取反即可),那么不合法的序列和由
n
+
1
n+1
n+1个-1和
n
−
1
n-1
n−1个1组成的序列就是一一对应的,因此
u
n
=
C
n
+
1
2
n
u_n=C_{n+1}^{2n}
un=Cn+12n
相减可以得到:
h
n
=
C
n
2
n
−
C
n
+
1
2
n
h_n=C_n^{2n}-C_{n+1}^{2n}
hn=Cn2n−Cn+12n
稍稍转化一下可以得到:
h
n
=
1
n
+
1
C
n
2
n
h_n=\frac{1}{n+1}C_n^{2n}
hn=n+11Cn2n
然后一阶递推公式就显而易见了,直接用通项公式减一下就行。
第一类Stiring数
定义很简单,表示把n个元素划分成m个非空循环排列集合的方案数。
记为
s
n
m
s_n^m
snm
直接先看递推式吧:
分两种情况:
这个元素若取,答案就是
(
n
−
1
)
s
n
−
1
m
(n-1)s_{n-1}^m
(n−1)sn−1m
若不取,答案就是
s
n
−
1
m
−
1
s_{n-1}^{m-1}
sn−1m−1
根据加法原理直接相加得到:
s
n
m
=
s
n
−
1
m
−
1
+
(
n
−
1
)
s
n
−
1
m
s_n^m=s_{n-1}^{m-1}+(n-1)s_{n-1}^m
snm=sn−1m−1+(n−1)sn−1m
第二类Stiring数
定义也非常简单,表示把n个元素划分成m个非空集合的方案数。
记为
S
n
m
S_n^m
Snm
还是直接先看递推式吧:
分两种情况:
这个元素若取,答案就是
m
S
n
−
1
m
mS_{n-1}^m
mSn−1m
若不取,答案就是
S
n
−
1
m
−
1
S_{n-1}^{m-1}
Sn−1m−1
根据加法原理直接相加得到:
S
n
m
=
S
n
−
1
m
−
1
+
m
S
n
−
1
m
S_n^m=S_{n-1}^{m-1}+mS_{n-1}^m
Snm=Sn−1m−1+mSn−1m
Bell数
表示把n个元素划分成若干个非空集合的方案数。
记为
B
n
B_n
Bn
显然有
B
n
=
∑
k
=
1
n
S
n
k
B_n=\sum_{k=1}^{n}S_n^k
Bn=k=1∑nSnk
我真不知道这玩意有什么用,就只是一个求和吗?
亿些经典问题
咕 咕 咕
程序中的排列组合
逆元
什么是逆元
我们计算组合数的时候,经常会遇到要除以一个很大的数,同时要求余,我们知道一般情况下,乘法的逆运算是除法,但是,在mod p的情况下,这将不再成立,比如:
1*3%2=1
1%2*3%2=1
但是,
9/3%2=1
9%2/3%2=1
显然除法不适用于mod运算,因此我们需要一种全新的运算。
众所周知,若a*b=1
,则x/a=x*b
我们尝试把他推到至mod运算:若a*b≡1 (mod p)
,则x/a%p=x%p*b%p
此时我们称a
是b
的逆元,同时b
是a
的逆元
看着挺玄乎是不是,把逆元的定义和倒数的定义结合起来看就明白了
有了逆元这玩意,我们就可以解决代码里爆精度或者爆long long
的问题了,直接把除法转换成逆元运算就行了。
注意,在逆元运算中,a一定与p互质
逆元怎么算
有了逆元的定义,我们就该在程序中计算逆元了
费马小定理法
首先我们来看伟大的费马小定理
那么,根据费马小定理:在 a a a与 p p p互质的情况下,有:
a p − 1 ≡ 1 m o d p a^{p-1} \equiv 1 \mod p ap−1≡1modp
那么,我们直接拉出来一个a得到:
a p − 2 × a ≡ 1 m o d p a^{p-2} \times a \equiv 1 \mod p ap−2×a≡1modp
熟不熟悉!!这就符合我们逆元的定义,于是我们得到了一个重要信息:
在mod p 的情况下, a a a与 a p − 2 a^p-2 ap−2互为逆元
由于一般这个P大得离谱,所以我们采取快速幂(这个原理大家应该都懂,不懂得看看代码应该也懂了):
int _pow(int x,int y){
if(!y){
return 1;
}
int t=_pow(x,y>>1);
if(y&1){
return t*t%P*x%P;
}
else{
return t*t%P;
}
}
然后就放出代码:
#include<bits/stdc++.h>
#define int long long
#define P 1000000007
using namespace std;
int inv[1000005];
int _pow(int x,int y){
int res=1;
while(y){
if(y&1){
res=res*x%P;
}
x=x*x%P;
y>>=1;
}
return res;
}
int Inverse(int x){
if(!inv[x]){
inv[x]=_pow(x,P-2)
}
return inv[x];
}
signed main(){
int a;
scanf("%lld%lld",&a);
printf("%lld",Inverse(a));
return 0;
}
单次时间复杂度为 O ( l o g P ) O(log P) O(logP),算是一个大常数但是有时还是有点慢,加上记忆化之后感觉也不够快,看着难受。
拓展欧几里得法
不管难不难受了,为什么空间允许不用线性求
我们直接看下一种方法:拓展欧几里得法
所谓欧几里得算法,就是我们平时说的“辗转相除法”求最大公因数,我们来复习一下欧几里得算法:
规定
gcd
(
m
,
0
)
=
n
\gcd(m,0)=n
gcd(m,0)=n
则有
gcd
(
m
,
n
)
=
g
c
d
(
m
m
o
d
n
,
n
)
\gcd(m,n)=gcd(m \mod n,n)
gcd(m,n)=gcd(mmodn,n)
在这个非常有趣的欧几里得算法之上,我们有了拓展欧几里得算法:
那么,我们尝试对于不定方程 m x + n y = g c d ( m , n ) mx+ny=gcd(m,n) mx+ny=gcd(m,n)求解
显而易见,我们套用之前的
gcd
\gcd
gcd的话,运算结束时,
方程为
m
×
x
+
0
×
y
=
g
c
d
(
m
,
0
)
m \times x+0 \times y=gcd(m,0)
m×x+0×y=gcd(m,0)
转化一下就是
m
×
x
=
m
m \times x=m
m×x=m
直接得到
{
x
=
1
y
=
0
\left\{ \begin{array}{lc} x=1\\ y=0\\ \end{array} \right.
{x=1y=0
这里我们知道了最终状态的解,然后看一般情况:
(注意:以下出现的
/
/
/意思都是整除)
已知
gcd
(
n
,
m
)
=
g
c
d
(
m
m
o
d
n
,
n
)
{
gcd
(
m
,
n
)
=
m
x
1
+
n
y
1
gcd
(
m
m
o
d
n
,
n
)
=
(
m
m
o
d
n
)
x
2
+
n
y
2
\gcd(n,m)=gcd(m \mod n,n) \\ \left\{ \begin{array}{lc} \gcd(m,n)=mx_1+ny_1 \\ \gcd(m \mod n,n)=(m \mod n)x_2+ny_2 \\ \end{array} \right. \\
gcd(n,m)=gcd(mmodn,n){gcd(m,n)=mx1+ny1gcd(mmodn,n)=(mmodn)x2+ny2
然后我们进行推论:
∵
m
m
o
d
n
=
m
−
m
/
n
×
n
∴
g
c
d
(
m
m
o
d
n
,
n
)
=
(
m
−
m
/
n
×
n
)
x
2
+
n
y
2
∵
gcd
(
n
,
m
)
=
g
c
d
(
m
m
o
d
n
,
n
)
∴
(
m
−
m
/
n
×
n
)
x
2
+
n
y
2
=
m
x
1
+
n
y
1
\because m \mod n=m-m/n \times n \\ \therefore gcd(m \mod n,n)=(m-m/n \times n)x_2+ny_2 \\ \because \gcd(n,m)=gcd(m \mod n,n) \\ \therefore (m-m/n \times n)x_2+ny_2=mx_1+ny_1
∵mmodn=m−m/n×n∴gcd(mmodn,n)=(m−m/n×n)x2+ny2∵gcd(n,m)=gcd(mmodn,n)∴(m−m/n×n)x2+ny2=mx1+ny1
我们对着最后那个式子一通爆算(这里实在懒得写了),就可以得出:
{
x
1
=
y
2
y
1
=
x
2
−
m
/
n
∗
y
2
\left\{ \begin{array}{lc} x_1=y_2\\ y_1=x_2-m/n*y_2\\ \end{array} \right. \\
{x1=y2y1=x2−m/n∗y2
有了这个伟大的结论,我们就可以通过类似欧几里得算法的方式得到
x
x
x和
y
y
y的一组解
int ex_gcd(int a,int b,int &x,int &y){
if(b==0){
x=1;
y=0;
return a;
}
int res=ex_gcd(b,a%b,x,y);
int t=x;
x=y;
y=t-a/b*y;
return res;
}
然后我们回来继续狂推:
我们刚才实际上解了
m
x
+
n
y
=
gcd
(
m
,
n
)
mx+ny=\gcd(m,n)
mx+ny=gcd(m,n)这个方程,这个方程也叫“贝祖等式”,显然,在这个等式中
x
x
x与
y
y
y互质,同时,贝祖等式一定存在整数解
x
,
y
x,y
x,y,于是……
一个更加伟大的结论浮出水面:
a,b互质的充分必要条件是方程ax+by=1有整数解。
下面我们回归逆元,根据限制,
a
a
a与
p
p
p互质,我们就可以得到
a
x
+
p
y
=
1
ax+py=1
ax+py=1
根据逆元的定义:
a
×
b
≡
1
(
m
o
d
p
)
a\times b≡1 (\mod p)
a×b≡1(modp)
不讲详细证明了,我们直接对着这两个等式瞪一会,我们就会发现两个等式中的x是相等的,于是,我们就可以直接用拓展欧几里得法求出
a
a
a的逆元,以下放出代码:
#include<bits/stdc++.h>
#define int long long
#define p 1000000007
using namespace std;
int inv[1000005];
int ex_gcd(int a,int b,int &x,int &y){
if(b==0){
x=1;
y=0;
return a;
}
int res=ex_gcd(b,a%b,x,y);
int t=x;
x=y;
y=t-a/b*y;
return res;
}
int Inverse(int a){
int res,x,y;
res=ex_gcd(a,n,x,y);
if(res==1){
inv[i]=(x%n+n)%n;
}
else{
inv[i]=-1;
}
return inv[i];
}
signed main(){
int a;
scanf("%lld%lld",&a);
printf("%lld",Inverse(a));
return 0;
}
所以搞半天它单次计算的时间复杂度仍然是
O
(
log
p
)
O(\log p)
O(logp)!@#%^&*()
但是没关系,如果P不是质数,拓展欧几里得算法就有发挥的余地了。
反正相信大家现在已经懂了基本的拓展欧几里得算法,可以在各种地方使用了。
线性递推法
O(n)的方法终于来了
这是一种递推的方法,肉眼可见,1的逆元就是1,然后我们开始推递推公式(这是我在网上学的,但找不到出处了):
这里的/仍然是整除
现在假设
k
=
p
/
i
,
r
=
p
m
o
d
i
∴
p
=
k
∗
i
+
r
∴
k
∗
i
+
r
≡
0
(
m
o
d
p
)
∴
k
∗
r
−
1
+
i
−
1
≡
0
(
m
o
d
p
)
∴
i
−
1
≡
−
k
∗
r
−
1
(
m
o
d
p
)
∴
i
n
v
[
i
]
=
−
(
p
/
i
)
∗
i
n
v
[
p
m
o
d
i
]
m
o
d
p
∵
i
>
0
∴
i
n
v
[
i
]
=
(
p
−
p
/
i
)
∗
i
n
v
[
p
m
o
d
i
]
m
o
d
p
现在假设k=p/i,r=p\mod i\\ \therefore p=k∗i+r\\ \therefore k∗i+r≡0(\mod p)\\ \therefore k∗r−1+i−1≡0(\mod p)\\ \therefore i−1≡−k∗r−1(modp)\\ \therefore inv[i]=−(p/i)∗inv[p\mod i]\mod p\\ \because i>0\\ \therefore inv[i]=(p−p/i)∗inv[p \mod i]\mod p\\
现在假设k=p/i,r=pmodi∴p=k∗i+r∴k∗i+r≡0(modp)∴k∗r−1+i−1≡0(modp)∴i−1≡−k∗r−1(modp)∴inv[i]=−(p/i)∗inv[pmodi]modp∵i>0∴inv[i]=(p−p/i)∗inv[pmodi]modp
因此,递推式即为
i
n
v
[
i
]
=
(
p
−
p
/
i
)
∗
i
n
v
[
p
m
o
d
i
]
m
o
d
p
inv[i]=(p−p/i)∗inv[p \mod i]\mod p
inv[i]=(p−p/i)∗inv[pmodi]modp
下面直接看代码就没问题了,式子都出来了:
#include<bits/stdc++.h>
#define int long long
#define P 1000000007
using namespace std;
int inv[1000005];
void init(){
inv[1]=1;
for(int i=2;i<=1000000;i++){
inv[i]=(P-P/i)*inv[P%i]%P;
}
}
signed main(){
int a;
scanf("%lld%lld",&a);
printf("%lld",Inverse(a));
return 0;
}
时间复杂度可算是来到了 O ( n ) O(n) O(n)预处理, O ( 1 ) O(1) O(1)查询
特殊逆元:阶乘逆元
这是最特殊也是最常用的逆元求法,排列组合中非常常用,其实就是费马小定理求逆元,不过应用在阶乘上,并没有本质区别,顺手也求个阶乘
由于阶乘逆元通常用来求排列组合,所以直接拿这个当板子:
#include<bits/stdc++.h>
#define int long long
#define P 1000000007
using namespace std;
int inv[1000005],fac[1000005];
int _pow(int a,int b){
if(b==0){
return 1;
}
int t=_pow(a,b/2);
if(b%2==1){
return t*t%P*a%P;
}
else{
return t*t%P;
}
}
void init(){
inv[0]=1;
fac[0]=1;
for(int i=1;i<=1000000;i++){
fac[i]=fac[i-1]*i%P;
inv[i]=_pow(fac[i],P-2);
}
}
int C(int n,int m){
return fac[n]*inv[m]%P*inv[n-m]%P;
}
int A(int n,int m){
return fac[n]*inv[n-m]%P;
}
void solve(){
int a,b;
scanf("%lld%lld",&a,&b);
printf("%lld\n",C(a,b));
printf("%lld\n",A(a,b));
}
signed main(){
int T;
init();
scanf("%lld",&T);
while(T--){
solve();
}
}
时间复杂度 O ( n log n ) O(n\log n) O(nlogn)预处理, O ( 1 ) O(1) O(1)查询(鬼知道这个板子用了多少次)