NeFut Logo NeFut
EN 管理员登录

高效计算组合数的阶乘逆元线性推导与实现

发布于:2026-05-27 09:17 最后更新:2026-06-06 13:04
#C++ #Tutorial

核心逻辑与数学原理

在 NOIP 级别的组合计数问题中,经常需要在大数取模(模数 $M$ 通常为 $998244353$ 或 $10^9+7$ 等大质数)的约束下计算组合数:

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

由于涉及除法运算,在模意义下无法直接进行分子分母的逐步取模。必须将除法转化为乘法,即乘以分母的乘法逆元:

$$C_n^m \equiv n! \cdot [m!]^{-1} \cdot [(n-m)!]^{-1} \pmod M$$

只要能够高效求出阶乘的逆元,就能在 $O(1)$ 的时间内高频响应任意组合数的查询。

如果对每个询问或每个阶乘独立使用快速幂(费马小定理)求解逆元,单次复杂度为 $O(\log M)$。在 $N \le 10^7$ 且多组询问的极限场景下,这会导致总时间复杂度退化而引发 TLE。因此,必须引入阶乘逆元线性逆推技术


算法推导与状态设计

为了在 $O(N)$ 时间内离线处理出 $1 \sim N$ 所有阶乘的逆元,我们需要利用逆元的反向递推关系。

设 $fact[i] = i! \bmod M$ 为阶乘数组, $invFact[i] = [i!]^{-1} \bmod M$ 为阶乘逆元数组。

根据阶乘的定义:

$$fact[i] = fact[i-1] \cdot i$$

我们在等式两边同时取逆元:

$$[fact[i]]^{-1} \equiv [fact[i-1] \cdot i]^{-1} \pmod M$$

$$invFact[i] \equiv invFact[i-1] \cdot i^{-1} \pmod M$$

为了实现线性不依赖单点逆元,我们将等式两边同时乘以 $i$:

$$invFact[i] \cdot i \equiv invFact[i-1] \cdot i^{-1} \cdot i \pmod M$$

$$invFact[i-1] \equiv invFact[i] \cdot i \pmod M$$

算法执行状态设计

  1. 正向预处理:从 $1$ 到 $N$ 递推计算出标准的阶乘数组 $fact[i]$。
  2. 单点突破:仅对最大边界 $N$ 的阶乘 $fact[N]$ 调用一次快速幂(费马小定理),算出 $invFact[N] = (fact[N])^{M-2} \bmod M$。
  3. 逆向拓扑线性递推:利用刚才推导出的状态转移方程,从 $N$ 开始向 $1$ 反向循环:

$$invFact[i-1] = invFact[i] \cdot i \bmod M$$

在反向扫描的过程中,顺便可以顺手带出单个元素的逆元(若题目有刚需):$i^{-1} = invFact[i] \cdot fact[i-1] \bmod M$。


C++ 标准源码

#include <iostream>
#include <vector>

const int MOD = 998244353; // 生产环境常见高频质数模数
const int MAXN = 5000000;  // 模拟 NOIP 极限数据范围

long long fact[MAXN + 5];
long long invFact[MAXN + 5];

// 费马小定理快速幂
long long quick_power(long long base, long long exp) {
    long long res = 1;
    base %= MOD;
    while (exp > 0) {
        if (exp & 1) res = (res * base) % MOD;
        base = (base * base) % MOD;
        exp >>= 1;
    }
    return res;
}

// 阶乘与阶乘逆元双向线性预处理核心
void init_combinatorics(int n) {
    fact[0] = 1;
    invFact[0] = 1;
    for (int i = 1; i <= n; ++i) {
        fact[i] = (fact[i - 1] * i) % MOD;
    }

    // 核心断点:仅对最后一个元素求一次大快幂
    invFact[n] = quick_power(fact[n], MOD - 2);

    // 反向拉平整体阶乘逆元链条
    for (int i = n; i >= 1; --i) {
        invFact[i - 1] = (invFact[i] * i) % MOD; // 致命注意:此处乘以的是 i,而不是 inv[i]
    }
}

// O(1) 响应组合数查询
long long query_C(int n, int m) {
    if (m < 0 || m > n) return 0; // 边界防御性拦截
    return fact[n] * invFact[m] % MOD * invFact[n - m] % MOD;
}

int main() {
    std::ios_base::sync_with_stdio(false);
    std::cin.tie(NULL);

    int n = MAXN;
    init_combinatorics(n);

    int queries;
    if (std::cin >> queries) {
        while (queries--) {
            int qn, qm;
            std::cin >> qn >> qm;
            std::cout << query_C(qn, qm) << "\n";
        }
    }
    return 0;
}

NOIP 实战避坑指南

  1. 反向递推时公式乘数写错 在执行反向递推 invFact[i-1] = invFact[i] * i % MOD 时,部分选手容易在脑海中混淆概念,顺手写成了 invFact[i-1] = invFact[i] * inv[i] % MOD。这会导致逆元数组直接塌陷。一定要记住:阶乘逆元的反向递推乘的是原数 $i$,而非 $i$ 的逆元
  2. 数组边界越界与 $fact[0]$ 漏初始化 在处理诸如 $C_n^0$ 或 $C_n^n$ 的极端情况时,计算公式会转换为求 invFact[0]。如果忘记初始化 fact[0] = 1invFact[0] = 1,或者在反向循环中没有将 $0$ 的状态安全推出,这些特殊组合数将会乘上未初始化的内存垃圾值 $0$,引发致命的逻辑溃败。

经典 NOIP/洛谷 真题

1. 洛谷 P3197 [HNOI2008] 越狱

2. 洛谷 P2822 组合数问题(NOIP 2016 提高组)

原文链接: local://19.6

[h] 返回首页