在算法竞赛和日常编程中,组合数学是一个绕不开的话题。而求组合数 $C_n^m$(即从 $n$ 个元素中选出 $m$ 个元素的方案数)更是基础中的基础。

虽然数学公式 $C_n^m = \frac{n!}{m!(n-m)!}$ 看起来很简单,但在计算机中实现时,我们必须根据 $n, m$ 的数据范围 以及 模数 $p$ 的性质 来选择不同的算法,否则很容易面临 超时(TLE)long long 的问题。

本文总结了四种常见的组合数求法,帮你搞定所有场景。

核心公式

$$ C_n^m = \frac{A_n^m}{m!} = \frac{n!}{m!(n-m)!} $$

$$ C_n^m = C_{n-1}^m + C_{n-1}^{m-1} \quad (\text{杨辉三角递推性质}) $$


方法一:递推法(杨辉三角)

Image

1. 适用场景 (When)

2. 核心原理 (Why)

基于组合数的递推性质 C(n, k) = C(n-1, k) + C(n-1, k-1)
这就好比动态规划,我们可以预处理出一个二维数组(杨辉三角),之后查询只需要 O(1) 。因为只涉及加法,所以不用担心除法求逆元的问题。

3. 代码实现

const int N = 2010;
const int MOD = 1e9 + 7;
long long c[N][N];

void init() {
    for (int i = 0; i < N; i++) {
        c[i][0] = 1; // 边界情况
        for (int j = 1; j <= i; j++) {
            // C(i, j) = C(i-1, j) + C(i-1, j-1)
            c[i][j] = (c[i - 1][j] + c[i - 1][j - 1]) % MOD;
        }
    }
}

方法二:预处理阶乘 + 乘法逆元

1. 适用场景 (When)

2. 核心原理 (Why)

利用公式 C(n, m) = n! / (m! * (n-m)!) % p
在模运算下,除以一个数等于乘以这个数的逆元。因为 p 是质数,我们可以根据费马小定理,数 x 的逆元就是 x^(p-2) % p

我们通过 $O(n)$ 的预处理计算出:

  1. 阶乘表 f[]f[i] = i!
  2. 阶乘逆元表 g[]:先用快速幂算出最大值 n! 的逆元,然后利用公式 inv(i!) = inv((i+1)!) * (i+1) 倒推算出所有阶乘的逆元 。

查询时仅需 O(1) 的时间复杂度。

3. 代码实现

typedef long long LL;
const int N = 100010; // 根据题目要求调整大小

int n, p;      // 全局变量存储范围和模数
LL f[N], g[N]; // f: 阶乘, g: 阶乘逆元

// 快速幂求逆元: a^(p-2) % p
LL qpow(LL a, LL b, LL p)
{
    LL ret = 1;
    while(b)
    {
        if(b & 1) ret = ret * a % p;
        a = a * a % p;
        b >>= 1;
    }
    return ret;
}

void init()
{
    // 1. 预处理阶乘
    f[0] = 1;
    for(int i = 1; i <= n; i++)
    {
        f[i] = f[i - 1] * i % p;
    }

    // 2. 预处理阶乘的逆元
    // 先利用费马小定理算出最大值 n! 的逆元
    g[n] = qpow(f[n], p - 2, p);
    // 再逆向递推算出其他阶乘的逆元: inv(i!) = inv((i+1)!) * (i+1)
    for(int i = n - 1; i >= 0; i--)
    {
        g[i] = (i + 1) * g[i + 1] % p;
    }
}

// 查询组合数
LL C(int n, int m)
{
    if(n < m) return 0;
    // C(n, m) = n! * inv((n-m)!) * inv(m!)
    return f[n] * g[n - m] % p * g[m] % p;
}

方法三:直接定义法(循环求解)

1. 适用场景 (When)

2. 核心原理 (Why)

直接利用定义:

$$ C_n^m = \frac{n \times (n-1) \times \dots \times (n-m+1)}{m!} $$

一边循环乘分子,一边计算分母 m! 的逆元。时间复杂度仅为 O(m)

3. 代码实现

// 快速幂求逆元
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;
}

LL C(int n, int m, int p) {
    if (m > n) return 0;
    LL up = 1, down = 1;
    // 循环计算分子和分母
    for (int i = 1; i <= m; i++) {
        up = up * (n - i + 1) % p; 
        down = down * i % p;       
    }
    // 结果 = 分子 * 分母的逆元
    return up * qmi(down, p - 2, p) % p;
}

方法四:卢卡斯定理 (Lucas Theorem)

1. 适用场景 (When)

2. 核心原理 (Why)

n 太大导致无法预处理阶乘数组,但模数 p 很小。
卢卡斯定理将大组合数递归转化为小组合数的乘积:

$$ C_n^m \equiv C_{n \pmod p}^{m \pmod p} \times C_{n/p}^{m/p} \pmod p $$

递归边界是当 m=0 时返回 1。对于 n % p 这种小范围的组合数,可以直接用定义法求解。

3. 代码实现

typedef long long LL;
int p; // 模数

// 快速幂求逆元
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;
}

// 定义法求小范围组合数 C(a, b) % p
LL C(int a, int b, int p) { 
    if (b > a) return 0;
    LL res = 1;
    // 相当于计算 (a * ... * (a-b+1)) / b!
    for (int i = 1, j = a; i <= b; i++, j--) {
        res = res * j % p;
        res = res * qmi(i, p - 2, p) % p;
    }
    return res;
}

// 卢卡斯定理递归
LL lucas(LL n, LL m, int p) {
    if (m == 0) return 1;
    return lucas(n / p, m / p, p) * C(n % p, m % p, p) % p;
}

总结:如何选择算法?(决策速查表)

数据范围 模数 $p$ 特点 推荐算法 时间复杂度
$n, m \le 2000$ 任意 查询次数多 方法一:递推法 (杨辉三角) 预处理 $O(n^2)$,查询 $O(1)$
$n \le 10^6$ 大质数 ($p&gt;n$) 标准情况 方法二:预处理阶乘 预处理 $O(n)$,查询 $O(1)$
$n \le 10^{18}, m \le 10^5$ 大质数 ($p&gt;n$) $m$ 很小,不想预处理 方法三:直接定义法 查询 $O(m \log p)$
$n, m \le 10^{18}$ 小质数 ($p \le 10^5$) $n, m$ 巨大,$p$ 较小 方法四:卢卡斯定理 $O(p + \log_p n)$
$n \le 10^6$ 非质数无模数 需高精度或合数模 补充方法:分解质因数法 视具体实现而定