一、组合数学基础
1.1 组合数定义
在离散数学中,组合数
C
(
n
,
k
)
C(n,k)
C(n,k)(记为
(
n
k
)
\dbinom{n}{k}
(kn))表示在
n
n
n 个不同元素中选取
k
k
k 个元素的非重复组合方式数量,其经典定义为:
( n k ) = n ! k ! ( n − k ) ! ( 0 ⩽ k ⩽ n ) \dbinom{n}{k} = \frac{n!}{k!(n-k)!} \quad (0 \leqslant k \leqslant n) (kn)=k!(n−k)!n!(0⩽k⩽n)
1.2 递推关系
式如《算法导论》中常见的手法,我们从递归方程入手分析。组合数满足著名Pascal递推公式:
( n k ) = ( n − 1 k − 1 ) + ( n − 1 k ) \dbinom{n}{k} = \dbinom{n-1}{k-1} + \dbinom{n-1}{k} (kn)=(k−1n−1)+(kn−1)
边界条件:
(
n
0
)
=
(
n
n
)
=
1
∀
n
⩾
0
\dbinom{n}{0} = \dbinom{n}{n} = 1 \quad \forall n \geqslant 0
(0n)=(nn)=1∀n⩾0
二、经典算法实现
2.1 直接递归算法
int comb_recursive(int n, int k) {
if (k == 0 || k == n) return 1;
return comb_recursive(n-1, k-1) + comb_recursive(n-1, k);
}
复杂度分析:
- 时间复杂度: O ( 2 n ) O(2^n) O(2n)(这显然无法接受)
- 空间复杂度: O ( n ) O(n) O(n)(递归栈深度)
该算法适用于教学演示递归关系,但通过算法导论中的递归树分析可知存在大量重复计算,不具备实用价值。
2.2 动态规划解法
int comb_dp(int n, int k) {
vector<vector<int>> dp(n+1, vector<int>(k+1, 0));
for(int i = 0; i <= n; ++i) {
for(int j = 0; j <= min(i, k); ++j) {
dp[i][j] = (j == 0 || j == i) ? 1 : dp[i-1][j-1] + dp[i-1][j];
}
}
return dp[n][k];
}
优化空间复杂度:
int comb_dp_optimized(int n, int k) {
vector<int> dp(k+1, 0);
dp[0] = 1;
for(int i = 1; i <= n; ++i) {
for(int j = min(i, k); j > 0; --j) {
dp[j] += dp[j-1];
}
}
return dp[k];
}
复杂度分析:
- 时间复杂度: O ( n k ) O(nk) O(nk)
- 空间复杂度:原算法 O ( n k ) O(nk) O(nk),优化后 O ( k ) O(k) O(k)
该方法通过递推存储中间结果避免了重复计算,达到多项式时间复杂度,适用于中小规模数据。
三、取模运算与高效算法
3.1 模运算预处理
当结果需要取模时(
C
(
n
,
k
)
m
o
d
p
C(n,k) \bmod p
C(n,k)modp),我们结合数论中的Fermat小定理实现模逆运算:
const int MOD = 1e9+7;
vector<long long> fact, inv_fact;
void precompute(int n) {
fact.resize(n+1);
inv_fact.resize(n+1);
fact[0] = 1;
for(int i=1; i<=n; ++i)
fact[i] = fact[i-1] * i % MOD;
inv_fact[n] = mod_pow(fact[n], MOD-2);
for(int i=n-1; i>=0; --i)
inv_fact[i] = inv_fact[i+1] * (i+1) % MOD;
}
int comb_mod(int n, int k) {
return fact[n] * inv_fact[k] % MOD * inv_fact[n-k] % MOD;
}
复杂度分析:
- 预处理时间复杂度: O ( n ) O(n) O(n)
- 查询时间复杂度: O ( 1 ) O(1) O(1)
3.2 Lucas定理应用
对于大质数
p
p
p,当
n
>
p
n > p
n>p时需要采用分治策略:
int lucas_theorem(int n, int k, int p) {
if(k == 0) return 1;
return lucas_theorem(n/p, k/p, p) * comb_mod(n%p, k%p) % p;
}
该算法时间复杂度为 O ( log p n ) O(\log_p n) O(logpn),主要应用于: p p p为质数且 n n n极大时的组合数取模计算。
四、算法选择策略
算法类型 | 适用场景 | 时间复杂度 | 空间复杂度 |
---|---|---|---|
Direct Recursive | 教学演示 | O ( 2 n ) O(2^n) O(2n) | O ( n ) O(n) O(n) |
Dynamic Programming | n ≤ 5000 n \leq 5000 n≤5000 | O ( n 2 ) O(n^2) O(n2) | O ( n ) O(n) O(n) |
Modular Preprocess | n ≤ 1 0 6 n \leq 10^6 n≤106 | O ( n ) O(n) O(n) preprocessing | O ( n ) O(n) O(n) |
Lucas + Preprocess | n ≤ 1 0 18 n \leq 10^{18} n≤1018, prime p ≤ 1 0 6 p \leq 10^6 p≤106 | O ( p + log p n ) O(p + \log_p n) O(p+logpn) | O ( p ) O(p) O(p) |
五、典型应用场景
5.1 二项式定理展开
(
a
+
b
)
n
=
∑
k
=
0
n
(
n
k
)
a
n
−
k
b
k
(a+b)^n = \sum_{k=0}^{n} \dbinom{n}{k} a^{n-k}b^k
(a+b)n=k=0∑n(kn)an−kbk
5.2 组合数路径计数
在
m
×
n
m \times n
m×n网格图从左上到右下的路径数等于
(
m
+
n
m
)
\dbinom{m+n}{m}
(mm+n)
int uniquePaths(int m, int n) {
return comb_mod(m+n-2, min(m-1, n-1));
}
5.3 概率计算应用
在n次独立重复试验中的应用:
P
(
k
)
=
(
n
k
)
p
k
(
1
−
p
)
n
−
k
P(k) = \dbinom{n}{k} p^k (1-p)^{n-k}
P(k)=(kn)pk(1−p)n−k
六、进阶技巧
6.1 Catalan数计算
使用组合数表达式:
C
n
=
1
n
+
1
(
2
n
n
)
C_n = \frac{1}{n+1}\dbinom{2n}{n}
Cn=n+11(n2n)
6.2 组合数奇偶性判定
利用Lucas定理可得:
(
n
k
)
\dbinom{n}{k}
(kn)为奇数的充要条件是
k
k
k的二进制表示是
n
n
n的子集
七、常用模板
#include <bits/stdc++.h>
using namespace std;
const int MOD = 1e9 + 7; // 模数,根据题目要求修改为质数
const int N = 1e5 + 10; // 预处理的最大范围,根据题目要求调整
long long fact[N]; // 存储阶乘 % MOD 的结果
long long inv_fact[N]; // 存储阶乘逆元 % MOD 的结果
long long qpow(long long a, long long b) {
long long res = 1;
while (b) {
if (b & 1) res = res * a % MOD;
a = a * a % MOD;
b >>= 1;
}
return res;
}
void init_comb() {
fact[0] = 1;
// 计算阶乘
for (int i = 1; i < N; ++i) {
fact[i] = fact[i - 1] * i % MOD;
}
// 计算最大位置阶乘的逆元
inv_fact[N - 1] = qpow(fact[N - 1], MOD - 2);
// 递推计算阶乘逆元
for (int i = N - 2; i >= 0; --i) {
inv_fact[i] = inv_fact[i + 1] * (i + 1) % MOD;
}
}
long long comb(int n, int k) {
if (n < 0 || k < 0 || k > n) return 0;
return fact[n] * inv_fact[k] % MOD * inv_fact[n - k] % MOD;
}
int main() {
init_comb(); // 初始化,必须调用
cout << "C(5, 2) = " << comb(5, 2) << endl; // 输出 10
cout << "C(100000, 50000) = " << comb(100000, 50000) << endl; // 需要保证 N > 100000
return 0;
}
输出样例:
C(5, 2) = 10
C(100000, 50000) = 149033233
总结:
组合数的计算既是经典的数学问题,也蕴含着丰富的算法思想。在算法竞赛中也常常出现,要好好理解组合数的思想,感受下组合数的魅力。