一、排列组合基础与计数原理
任何复杂的计数问题,剥离到最后都由加法原理和乘法原理构成。
1. 两大基本计数原理
- 加法原理(分类讨论):完成一件事有
类不同的方案。如果第一类有 种方法,第二类有 种方法,……,且这些类别之间互不重叠,那么完成这件事共有 种方法。 - 乘法原理(分步进行):完成一件事必须分为
个连续的步骤。第一步有 种方法,第二步有 种方法,……,只有所有步骤全部完成,这件事才算结束。总方案数为 。
2. 排列 (Permutation)
定义:从
推导:第一个位置有
公式:
3. 组合 (Combination)
定义:从
推导:在排列
公式:

4. 组合数的两大核心性质
性质一:对称性
物理意义:从
性质二:帕斯卡定律(杨辉三角递推)
物理意义:考虑特定的第
5. 经典实战模型:隔板法(插板法)
要求每个对象至少分到 1 个
方程:
结论:等价于在
允许某些对象分到 0 个(最为常见)
方程:
结论:给每个变量“借”一个球,使得
二、同余数学基础与模运算律
在算法竞赛中,当答案数值超出 64 位整数(long long)的表达范围时,题目通常要求将结果对一个大整数
1. 同余的定义与本质
定义:给定一个正整数
数学记号:
直观理解:
举例:在日常生活的钟表(模 12)中,17 点和 5 点指向同一个位置,因此
2. 同余的三大核心运算律
同余关系在加法、减法、乘法下表现出极其优美的性质,这意味着我们可以在计算的每一步都随时取模,而不会改变最终结果。注意乘法是先算再取模,中间结果仍要装得下。
假设
- 加法律:
- 减法律:
- 乘法律:
3. C++ 中的取模实战规范
在 C++ 中,% 是取余运算符,但它对负数的处理与数学上的模运算不同(例如 -5 % 3 在 C++ 中等于 -2,而在纯数学中模 3 的余数应为正数 1)。因此,在代码中落实运算律时,必须遵循以下标准写法:
// 约定 0 <= a,b < mod,mod 为常用的约 10^9 级模数
// 1. 安全加法取模
int add(int a,int b){
return (1LL*a+b)%mod;
}
// 2. 安全减法取模(绝对核心:必须加 mod 再取模,防止产生负数)
int sub(int a,int b){
return (1LL*a-b+mod)%mod;
}
// 3. 安全乘法取模
int mul(int a,int b){
return 1LL*a*b%mod;
}

三、模运算的除法危机与乘法逆元
1. 为什么同余没有直接的除法律?
加减乘法均可随时取模,但除法在取模意义下直接计算会导致极其荒谬的错误。
举例:计算
实际数学结果为
如果错误地在除法中分配取模:
为了在模意义下执行“除以
定义:若存在整数
2. 求解逆元的两大算法
方法一:费马小定理(最常用,前提: 必须是质数)
如果
我们将等式左边拆分出一个
对比逆元的定义式
只需使用快速幂算法,即可在
模数换成合数后,有一条对应的推广——欧拉定理,见《数论工具箱》·欧拉定理。
方法二:扩展欧几里得 Exgcd(前提: )

裴蜀定理:扩展欧几里得的理论基础
对于不全为
这就是裴蜀定理(Bézout's Identity)。进一步地,二元一次不定方程
在求乘法逆元时,我们要求:
当且仅当
两边对
当模数
利用扩展欧几里得算法求出
四、组合数 查询框架推导与实现

当组合数定义式
我们需要通过
这里的 inv[i] 存的是
-
顺序递推阶乘:
fact[i] = fact[i-1] * i % mod。 -
单次快速幂求边界逆元:直接对最大的阶乘
fact[N-1]求解其逆元,存入inv[N-1]。 -
逆向递推逆元(核心黑科技):
由于
。 映射到模意义下,可以直接得出递推式:
inv[i] = inv[i+1] * (i+1) % mod。这样从大到小只需一次遍历,乘法即可替代所有快速幂运算。
标准化模板代码:
#include<bits/stdc++.h>
#define int long long
using namespace std;
// 查询要求 0 <= n < N;本模板还要求 mod 为质数且 N-1 < mod
const int N=500005;
const int mod=1e9+7;
int fact[N],inv[N];
// 内部工具:快速幂
int qpow(int a,int b){
int res=1;
while(b){
if(b&1)res=res*a%mod;
a=a*a%mod;
b>>=1;
}
return res;
}
// 核心:线性预处理阶乘与逆元
void init(){
fact[0]=inv[0]=1;
// 1. 正向预处理阶乘
for(int i=1;i<N;i++){
fact[i]=fact[i-1]*i%mod;
}
// 2. 求出最大阶乘的逆元
inv[N-1]=qpow(fact[N-1],mod-2);
// 3. 逆向推导所有阶乘的逆元
for(int i=N-2;i>=1;i--){
inv[i]=inv[i+1]*(i+1)%mod;
}
}
// 核心:O(1) 查询组合数
int C(int n,int m){
if(m<0||m>n||n<0)return 0;
// C_n^m = n! * (m!)^-1 * ((n-m)!)^-1
return fact[n]*inv[m]%mod*inv[n-m]%mod;
}
void solve(){
// 例:查询 100 个元素中取 50 个的方案数
cout<<C(100,50)<<'\n';
}
signed main(){
ios::sync_with_stdio(0),cin.tie(0);
init(); // 程序入口必须优先执行初始化
solve();
return 0;
}
五、极端规模环境:卢卡斯定理 (Lucas)(选学)
在少数压轴题中,会遇到极端数据规模:
此时必须使用卢卡斯定理将规模强行降级:
物理意义与实现思维:
卢卡斯定理的本质,是将

标准化模板代码:
// 独立于上一节的模 1e9+7 表;p 为质数且 p <= 1e5
int p;
vector<long long> fac, invf;
long long power(long long a, long long b) {
long long res = 1;
while (b) {
if (b & 1) res = res * a % p;
a = a * a % p;
b >>= 1;
}
return res;
}
void init_lucas(int mod) {
p = mod;
fac.assign(p, 1);
invf.assign(p, 1);
for (int i = 1; i < p; i++) fac[i] = fac[i-1] * i % p;
invf[p-1] = power(fac[p-1], p-2);
for (int i = p-1; i >= 1; i--) invf[i-1] = invf[i] * i % p;
}
long long Csmall(int n, int m) {
if (m > n) return 0;
return fac[n] * invf[m] % p * invf[n-m] % p;
}
long long Lucas(long long n, long long m) {
if (m == 0) return 1;
return Lucas(n/p, m/p) * Csmall(n%p, m%p) % p;
}
// 使用:先 init_lucas(p),再查询 Lucas(n,m);更换模数必须重新初始化
关键只有一句:表算到 p-1 就停,千万别拿 p! 求逆元,它模 p 已经是 0。
六、进阶:逆元实现拓展与组合数算法选型
1. 扩展欧几里得代码实现与最小非负解
前文已经讲过,当模数
exgcd 求出的特解 x = (x % p + p) % p。
手算小例子:
求 exgcd 递归到底层后回溯,最终会算出特解 (-3 % 5 + 5) % 5 = 2。
// 求解给定 a 和模数 m 下的乘法逆元
// 输入保证:a >= 1, m >= 2 且 gcd(a, m) = 1,数值在 long long 承受范围内
// 输入样例:3 5
// 输出样例:2
#include<bits/stdc++.h>
#define int long long
using namespace std;
void exgcd(int a, int b, int &x, int &y) {
if (b == 0) {
x = 1;
y = 0;
return;
}
int x1, y1;
exgcd(b, a % b, x1, y1);
x = y1;
y = x1 - (a / b) * y1;
}
void solve() {
int a, m;
if (!(cin >> a >> m)) return;
int x, y;
exgcd(a, m, x, y);
// 转化为最小非负解
int inv_a = x % m;
if(inv_a < 0) inv_a += m;
cout << inv_a << '\n';
}
signed main() {
ios::sync_with_stdio(0), cin.tie(0);
solve();
return 0;
}
2. 批量求 逆元的线性递推 (选学)
当题目要求求出
前提条件(极其关键):
必须满足 inv[0],导致 inv[p] 以及后续大量的逆元全部错误地计算为 0。若需要大于
递推公式与代码落地:
数学推导可得 inv[i] = (p - p / i) * inv[p % i] % p
// 线性时间预处理 1 到 n 的所有逆元
// 输入保证:n < p, p 为质数。例如 n <= 1e7, p <= 2e9 保证乘法安全
// 输入样例:4 13
// 输出样例:1 7 9 10
#include<bits/stdc++.h>
#define int long long
using namespace std;
const int N = 10000005; // 按题目实际 n 决定,不宜过大浪费内存
int inv[N];
void solve() {
int n, p;
if (!(cin >> n >> p)) return;
inv[1] = 1;
cout << inv[1] << (1 == n ? "" : " ");
for (int i = 2; i <= n; i++) {
// (p - p / i) 防止产生负数,右侧逆元已在之前算出
inv[i] = (p - p / i) * inv[p % i] % p;
cout << inv[i] << (i == n ? "" : " ");
}
cout << '\n';
}
signed main() {
ios::sync_with_stdio(0), cin.tie(0);
solve();
return 0;
}
3. 组合数算法选型指南
面对一道新题,该用哪种组合数求法?这取决于
-
在 左右,无模数或模数任意 - 武器:杨辉三角(帕斯卡定律)
递推。 - 优势:纯加法操作,完全无视模数是否为质数。
- 空间:整张表是
。到 时,32 位元素约需 MB,64 位元素约需 MB;能否用 32 位要看答案值域或模数,再对照题目的内存限制。 - 注意:如果题目不要求取模,组合数增长极快,普通
long long算不到百阶就会溢出,必须配合高精度(大整数)加法使用。
- 武器:杨辉三角(帕斯卡定律)
-
, 为质数,且 (最常见环境) - 武器:阶乘与逆阶乘
预处理, 查询。 - 限制:如果
,则 ,没有逆元,此路不通。
- 武器:阶乘与逆阶乘
-
极大(如 ), 较小(如 ), 为大质数 - 武器:根据定义直接暴力计算分子分母。
- 做法:直接套公式
。分子暴力循环连乘 次,分母算一次 后求逆元,总时间复杂度仅为 。不需要庞大的预处理。
-
极大(如 ),且模数 为小质数( ) - 武器:Lucas 定理。
- 优势:它能把超大规模的组合数打散成多个小于
的小组合数相乘,是小模数大下标时的首选武器。