前言
“从 10 个人里选 3 个人组队,有多少种选法?“——你在高中数学课上学过:。
但在算法竞赛里,这道题往往是这样的:“从 个人里选 个人组队,答案对 或 取模。“直接算 ?那是一个约 位的数,存都存不下。而除法在模意义下又不能直接做——需要逆元。
上一篇 02-04 快速幂与逆元 里预处理了阶乘和逆阶乘,现在正好用上。这篇文章覆盖从 到 查询组合数的全部方法。
问题的本质
排列与组合
排列 :从 个不同元素中有序选 个。。
组合 :从 个不同元素中无序选 个。。
关系:(组合乘上 个的排列数 = 排列)。
核心公式
的几个常用性质:
| 性质 | 公式 | 直觉 |
|---|---|---|
| 对称性 | 选 个 = 不选 个 | |
| 递推 | 杨辉三角 | |
| 求和 | 每个元素选或不选 |
理论 + 代码
预处理阶乘 + 逆阶乘( 预处理, 查询)
在 02-04 里已经讲过。回顾一遍:
预处理 fact[i] = i! mod p 和 inv_fact[i] = (i!)^{-1} mod p,然后每次查询只需三次查表加两次模乘。
const long long MOD = 1e9 + 7;
const int MAXN = 1000006;
long long fact[MAXN], inv_fact[MAXN];
long long qpow(long long a, long long n) {
long long res = 1; a %= MOD;
while (n > 0) {
if (n & 1) res = res * a % MOD;
a = a * a % MOD; n >>= 1;
}
return res;
}
void init() {
fact[0] = 1;
for (int i = 1; i < MAXN; i++) // ① 阶乘:fact[i] = i × (i-1)!
fact[i] = fact[i-1] * i % MOD;
inv_fact[MAXN - 1] = qpow(fact[MAXN - 1], MOD - 2); // ② (N!)^{-1}
for (int i = MAXN - 2; i >= 0; i--) // ③ 倒推
inv_fact[i] = inv_fact[i+1] * (i+1) % MOD;
}
long long C(int n, int k) {
if (k < 0 || k > n) return 0; // ④ 边界:非法输入返回 0
return fact[n] * inv_fact[k] % MOD * inv_fact[n-k] % MOD;
// ⑤ n! × (k!)^{-1} × ((n-k)!)^{-1}
}
- ③ 倒推原理:。因为 ,所以 ,逆元 。
- ⑤ 两次
* ... % MOD。从左到右算:fact[n] * inv_fact[k]结果取模,再乘inv_fact[n-k]取模。中间值最大 ,long long没问题。
排列数
long long P(int n, int k) {
if (k < 0 || k > n) return 0;
return fact[n] * inv_fact[n - k] % MOD;
}
只需要一个逆阶乘,比组合数还简单。
例题
例题 1:M&A 021 — Combination Easy(★1)
题目:给定 和 ,求 。
数据范围:
思路:,直接用公式 不会溢出( 刚好在 long long 边缘,但先乘后除可以避免)。
代码:
#include <cstdio>
using namespace std;
int main() {
int n, r;
scanf("%d%d", &n, &r);
// ① C(n, r) = n×(n-1)×...×(n-r+1) / (1×2×...×r)
long long num = 1, den = 1;
for (int i = 0; i < r; i++) {
num *= (n - i); // ② 分子:n, n-1, ..., n-r+1
den *= (i + 1); // ③ 分母:1, 2, ..., r
}
printf("%lld\n", num / den);
}
逐行解析:
- ②③ 同时乘分子和分母,避免单独算阶乘溢出。先乘后除,因为 一定是整数,所以
num一定能被den整除。
例题 2:M&A 051 — Combination Hard(★2)
题目:从格子 出发,每次向右或向上走一步。到达 有多少种走法?答案对 取模。
数据范围:
思路:从 到 需要走 步,其中选 步向右(其余向上)。方案数 = 。
最大 ,用预计算阶乘 + 逆元:
代码:
#include <cstdio>
using namespace std;
const long long MOD = 1000000007;
const int MAXN = 200010;
long long fact[MAXN], inv_fact[MAXN];
long long power(long long base, long long exp) {
long long ans = 1;
while (exp > 0) {
if (exp & 1) ans = ans * base % MOD;
base = base * base % MOD;
exp >>= 1;
}
return ans;
}
int main() {
// ① 预计算阶乘和阶乘逆元
fact[0] = 1;
for (int i = 1; i < MAXN; i++)
fact[i] = fact[i-1] * i % MOD;
inv_fact[MAXN-1] = power(fact[MAXN-1], MOD - 2); // ② 费马小定理求逆
for (int i = MAXN - 2; i >= 0; i--)
inv_fact[i] = inv_fact[i+1] * (i+1) % MOD; // ③ 递推:1/i! = 1/(i+1)! × (i+1)
int X, Y;
scanf("%d%d", &X, &Y);
int n = X + Y;
// ④ C(n, X) = n! / (X! × Y!)
printf("%lld\n", fact[n] * inv_fact[X] % MOD * inv_fact[Y] % MOD);
}
逐行解析:
- ② (费马小定理)。
- ③ 从大往小递推:,因为 。
例题 3:Tessoku Book — A30 Combination
题目:求 。
数据范围:
思路:和例题 2 一样的预计算方法。作为独立练习。
代码:(同例题 2 的预计算部分,略)
例题 4:Tessoku Book — A05 Three Cards
题目:从 到 的整数中选 3 个不同的数,使它们的和恰好为 。有多少种选法?
数据范围:,
思路:三重循环 ,太慢。但可以枚举前两个数 ,则 ,检查 且 。
代码:
#include <cstdio>
using namespace std;
int main() {
int N, K;
scanf("%d%d", &N, &K);
int cnt = 0;
for (int a = 1; a <= N; a++) { // ① 枚举最小的数
for (int b = a + 1; b <= N; b++) { // ② 枚举中间的数
int c = K - a - b; // ③ 第三个数由 a+b+c=K 确定
if (c > b && c <= N) // ④ 保证 b < c 且 c ≤ N
cnt++;
}
}
printf("%d\n", cnt);
}
逐行解析:
- ①② 的约束通过
b = a + 1保证。 - ③④ ,必须满足 (保证 )和 (不超出范围)。
例题 5:M&A 052 — Knight(★3)
题目:国际象棋棋盘上,马从原点 出发,每步可以从 移到 或 。到达 的路径数是多少?答案对 取模。
数据范围:
思路:设用了 次 移动和 次 移动,则:
解得 。如果 或 不能被 3 整除,或者 ,答案为 0。
否则,总步数 ,从 步中选 步做第一种移动,方案数为 。用预处理阶乘+逆元 查询。
代码:
#include <cstdio>
using namespace std;
const int MOD = 1000000007;
// ① 预处理阶乘和逆元
long long fac[2000010], invfac[2000010];
long long power(long long base, long long exp) {
long long ans = 1;
while (exp > 0) {
if (exp % 2 == 1) ans = ans * base % MOD;
base = base * base % MOD;
exp /= 2;
}
return ans;
}
long long C(int n, int r) {
if (r < 0 || r > n) return 0;
return fac[n] * invfac[r] % MOD * invfac[n-r] % MOD; // ② C(n,r) = n!/(r!(n-r)!)
}
int main() {
int X, Y;
scanf("%d%d", &X, &Y);
// ③ 检查是否有解
int a_num = 2*Y - X, b_num = 2*X - Y;
if (a_num < 0 || b_num < 0 || a_num % 3 != 0 || b_num % 3 != 0) {
printf("0\n");
return 0;
}
int a = a_num / 3, b = b_num / 3;
int n = a + b;
// ④ 预处理阶乘
fac[0] = 1;
for (int i = 1; i <= n; i++)
fac[i] = fac[i-1] * i % MOD;
invfac[n] = power(fac[n], MOD-2); // ⑤ 费马小定理求逆元
for (int i = n-1; i >= 0; i--)
invfac[i] = invfac[i+1] * (i+1) % MOD;
printf("%lld\n", C(n, a)); // ⑥ 答案 = C(a+b, a)
}
逐行解析:
- ③ 列方程 ,解出 。必须是非负整数才有解。
- ④⑤ 预处理 到 以及它们的逆元。用费马小定理:。
- ⑥ 表示从 步中选 步做第一种移动,这就是路径总数。
- 验证:,。路径 和 。✓
参考文献
理论讲义 — AtCoder Algorithm Lectures
教材讲解 — 競技プログラミングの鉄則 第 5 章
基础练习 — アルゴリズムと数学 演習問題集
- 021 Combination Easy(C(n,r) n≤20)【例题】
- 051 Combination Hard(C(X+Y,X) mod p)【例题】
- 052 Knight(组合计数:棋盘路径数)【例题】
- 057 Domino Tiling(矩阵快速幂计数)
- 091 How Many Ways?(三重循环计数)
- 066 Three Cards(有界选择+容斥)
- 102 Tricolor Pyramid(组合数+Lucas定理)
系统练习 — 競技プログラミングの鉄則
系列索引
第零章 基础工具
第一章 搜索技术
第二章 数学基础
第三章 数据结构
- 03-01 栈、队列与单调栈
- 03-02 堆与优先队列
- 03-03 并查集
- 03-04 树状数组
- 03-05 线段树
- 03-06 懒标记线段树
- 03-07 Sparse Table 与倍增
- 03-08 字符串哈希
第四章 图论
- 04-01 图的遍历
- 04-02 最短路—Dijkstra 与 01-BFS
- 04-03 最短路—Bellman-Ford 与 Floyd
- 04-04 拓扑排序
- 04-05 最小生成树
- 04-06 强连通分量与 2-SAT
- 04-07 二分图与网络流
- 04-08 树上问题
第五章 动态规划
- 05-01 DP入门—状态与转移
- 05-02 背包问题族
- 05-03 LIS、LCS与编辑距离
- 05-04 区间DP
- 05-05 状态压缩DP
- 05-06 树形DP与数位DP
- 05-07 矩阵快速幂与线性递推
第六章 贪心
第七章 字符串
第八章 进阶