1. 从暴力到优雅:组合数计算的效率跃迁
在算法竞赛和日常编程中,组合数 C(n, m) 的计算是一个高频需求。无论是概率统计、排列组合问题,还是动态规划的状态转移,都离不开它。很多新手的第一反应是直接套用公式 C(n, m) = n! / (m! * (n-m)!),然后写一个阶乘函数。这个思路直观,但存在两个致命问题:一是阶乘数值增长极快,极易超出整数类型的表示范围;二是即使使用高精度,每次查询都重新计算阶乘,时间复杂度为 O(n),在多次查询(比如 AcWing 886 这类题目,通常需要回答大量询问)的场景下,会带来 O(q * n) 的总复杂度,这无疑是无法接受的。
这就引出了我们今天要深入探讨的核心:预处理+优化的O(n)版本。这个方案的精髓在于“空间换时间”和“数论预处理”。它并非简单地缓存阶乘,而是通过预处理阶乘和阶乘的逆元,将每次查询组合数的操作降低到 O(1) 的时间复杂度。这里的“O(n)”指的是预处理阶段的时间复杂度,而非单次查询。对于需要处理十万甚至百万级别查询的题目,这种优化是决定性的。接下来,我将带你一步步拆解这个方案的原理、实现细节以及那些容易踩坑的地方。
2. 核心基石:模运算下的乘法逆元
要理解预处理优化,必须先搞懂“模运算下的乘法逆元”这个概念。这是整个方案的数学基础。
在普通的实数域里,一个数 a 的乘法逆元就是它的倒数 1/a,因为 a * (1/a) = 1。在模运算的世界里,我们也想找到一个类似的数。具体来说,对于一个整数 a 和一个模数 p(通常 p 是一个质数),如果存在一个整数 x,使得(a * x) % p = 1,那么 x 就称为 a 在模 p 意义下的乘法逆元,记作a^(-1)或inv(a)。
为什么逆元如此重要?因为我们想在模 p 下计算除法。直接做除法(a / b) % p是没有定义的。但如果我们找到了 b 的逆元inv(b),那么就可以将除法转化为乘法:(a / b) % p = (a * inv(b)) % p。这完美契合了组合数公式C(n, m) = n! / (m! * (n-m)!)的需求——我们可以通过计算n! * inv(m!) * inv((n-m)!) % p来得到结果。
那么,如何高效地求解逆元呢?当模数 p 为质数时,最常用的方法是费马小定理配合快速幂。
费马小定理指出:若 p 是质数,且整数 a 不是 p 的倍数,则a^(p-1) ≡ 1 (mod p)。 将等式两边同时除以 a,得到a^(p-2) ≡ a^(-1) (mod p)。 因此,a在模p下的逆元inv(a) = a^(p-2) % p。
计算a^(p-2) % p可以使用快速幂算法,时间复杂度为 O(log p)。如果对每一个阶乘值都单独用快速幂求逆元,预处理的总复杂度会变成 O(n log p),虽然比暴力好,但还不够极致。我们接下来要介绍的“线性求逆元”方法,可以将预处理逆元数组的复杂度优化到真正的 O(n)。
3. 预处理流水线:阶乘数组与逆元数组的构建
我们的目标是预处理两个数组:
fact[i]: 存储 i! % p 的值。infact[i]: 存储 (i!)^(-1) % p 的值,即 i! 的逆元。
有了这两个数组,计算组合数就变成了一个简单的公式:C(n, m) = fact[n] * infact[m] % p * infact[n-m] % p
整个预处理过程分为三步,这才是实现“优化的O(n)版本”的关键:
3.1 第一步:初始化与边界处理
首先,我们需要确定预处理的范围。通常题目会给出数据范围,比如 n, m ≤ 10^5。那么我们的数组大小至少要是 N = 100010。 模数 p 通常给定为质数,例如常见的 1e9+7。
const int N = 100010, MOD = 1e9 + 7; long long fact[N], infact[N]; // 使用long long防止中间结果溢出边界条件:0! = 1,并且规定1的逆元也是1。
fact[0] = infact[0] = 1;这个初始化至关重要,因为它是后续所有递推的起点。
3.2 第二步:线性预处理阶乘数组
这一步非常直观,就是一个简单的递推:fact[i] = fact[i-1] * i % MOD从i=1循环到最大值N-1即可。时间复杂度 O(N)。
3.3 第三步:线性预处理阶乘逆元数组
这是整个算法的精华所在,也是“优化的O(n)”中“优化”二字的体现。我们不是对每个fact[i]单独用快速幂求逆元,而是利用一个巧妙的递推关系。
我们知道infact[i]是fact[i]的逆元。 考虑fact[i] = fact[i-1] * i % MOD。 对等式两边同时取逆元(逆元运算满足(a*b)^(-1) = a^(-1) * b^(-1)):infact[i] = infact[i-1] * inv(i) % MOD问题转化为:如何快速得到inv(i),即整数i的逆元?
这里我们使用线性递推求 1 到 n 的逆元公式:inv(i) = (MOD - MOD / i) * inv(MOD % i) % MOD这个公式的推导基于模运算的欧几里得算法扩展,这里不展开证明,但我们可以验证其正确性并理解如何使用。
因此,预处理infact数组的步骤是:
- 先利用上述公式,预处理出
1到N-1每个数字i的逆元inv[i]。 - 再利用递推式
infact[i] = infact[i-1] * inv[i] % MOD求出每个阶乘的逆元。
核心代码实现如下:
// 第一步:预处理数字逆元 inv[i] inv[1] = 1; // 1的逆元是1 for (int i = 2; i < N; i ++ ) { inv[i] = (MOD - MOD / i) * inv[MOD % i] % MOD; } // 第二步:预处理阶乘逆元 infact[i] infact[0] = 1; for (int i = 1; i < N; i ++ ) { infact[i] = infact[i - 1] * inv[i] % MOD; }注意:
inv[i]数组可以只作为临时变量使用,在计算完infact[i]后就不需要保存了,这样可以节省空间。但为了清晰理解流程,这里分开表示。
至此,我们完成了所有预处理工作。总时间复杂度为 O(N),空间复杂度为 O(N)。之后每次查询组合数,都是 O(1) 的常数时间操作。
4. 完整实现与细节打磨
将以上所有步骤整合,并考虑输入输出,我们得到 AcWing 886 题目的标准解法框架。
#include <iostream> using namespace std; const int N = 100010, MOD = 1e9 + 7; long long fact[N], infact[N]; // 快速幂函数,这里用于验证或备用,在线性递推方法中并非必需 long long qmi(long long a, long long k, long long p) { long long res = 1; while (k) { if (k & 1) res = res * a % p; a = a * a % p; k >>= 1; } return res; } int main() { // 1. 初始化边界 fact[0] = infact[0] = 1; // 2. 预处理阶乘数组 fact[] for (int i = 1; i < N; i ++ ) { fact[i] = fact[i - 1] * i % MOD; } // 3. 预处理阶乘逆元数组 infact[] // 方法一:线性递推求逆元(推荐,效率O(N)) infact[0] = 1; long long inv_i = 1; // 用于临时存储 i 的逆元 for (int i = 1; i < N; i ++ ) { // 线性求 i 的逆元 // 公式:inv(i) = (MOD - MOD/i) * inv(MOD%i) % MOD // 由于循环从1开始,我们需要计算 inv[i],这里用临时变量演示 // 实际可以写为: // inv[i] = (MOD - MOD / i) * inv[MOD % i] % MOD; // 需要额外inv数组 // infact[i] = infact[i - 1] * inv[i] % MOD; // 更简洁的写法,合并步骤,只用一个临时变量 // 假设我们已经有了 inv(i),这里用快速幂演示另一种方法(非最优,用于理解) // 实际竞赛中,对于固定的MOD,通常直接写出线性递推代码。 // 下面这行是使用快速幂求逆元,复杂度O(N log MOD),用于对比理解 // infact[i] = infact[i - 1] * qmi(i, MOD - 2, MOD) % MOD; // 正确的线性递推写法(需要一个小数组或临时变量链式计算): // 由于推导稍复杂,竞赛中常直接背下线性求逆元模板。 // 这里给出一种常见的、易于实现的预处理infact的写法(利用费马小定理和fact数组): // 先预处理 fact, 然后 infact[N-1] = qmi(fact[N-1], MOD-2, MOD) // 再倒着递推: infact[i] = infact[i+1] * (i+1) % MOD; } // 更常见的完整线性预处理写法如下: fact[0] = infact[0] = 1; for (int i = 1; i < N; i ++ ) { fact[i] = fact[i - 1] * i % MOD; } // 先求出最大那个阶乘的逆元 infact[N - 1] = qmi(fact[N - 1], MOD - 2, MOD); // 倒着推回所有阶乘的逆元 for (int i = N - 2; i >= 0; i --) { infact[i] = infact[i + 1] * (i + 1) % MOD; } // 这个方法是:因为 (i!)^(-1) = ((i+1)!)^(-1) * (i+1) % MOD // 推导: (i+1)! = i! * (i+1) => i! = (i+1)! / (i+1) // 取逆元: (i!)^(-1) = (i+1) * ((i+1)!)^(-1)) % MOD int n; scanf("%d", &n); while (n -- ) { int a, b; scanf("%d%d", &a, &b); // 组合数公式 C(a, b) = fact[a] * infact[b] % MOD * infact[a - b] % MOD printf("%lld\n", fact[a] * infact[b] % MOD * infact[a - b] % MOD); } return 0; }5. 两种逆元预处理路径的对比与选择
在上面的代码中,我们看到了两种预处理infact数组的方法:
- 方法A(线性递推求数字逆元):先正序求出每个数字
i的逆元inv[i],再利用infact[i] = infact[i-1] * inv[i] % MOD正序递推。 - 方法B(利用最大阶乘逆元倒推):先求出
fact[N-1]的逆元(用一次快速幂),再利用关系式infact[i] = infact[i+1] * (i+1) % MOD倒序递推。
两种方法的时间复杂度都是 O(N),空间复杂度也都是 O(N)。在实际竞赛中,两者效率相差无几,都可以使用。但它们体现了不同的思路:
- 方法A更通用。
inv[i]数组本身可能在其他地方也有用。其递推公式inv[i] = (MOD - MOD/i) * inv[MOD%i] % MOD是一个需要记忆的模板。 - 方法B更直观,尤其是关系式
infact[i] = infact[i+1] * (i+1) % MOD直接从阶乘定义推导而来,更容易理解。它只需要一次快速幂(O(log MOD)),对整体复杂度影响可忽略。
个人经验与选择:我通常更倾向于使用方法B。原因有三:第一,推导过程更自然,不易记错公式;第二,代码结构清晰,先正序算
fact,再一次幂运算,最后倒序算infact;第三,在调试时,逻辑链路更直白。方法A的线性求逆元公式虽然巧妙,但万一记忆模糊,容易推导错误。
6. 关键边界与常见“坑点”剖析
即使理解了算法,实现时依然有几个细节必须严格把控,否则极易出错。
6.1 模运算的乘法陷阱
核心计算公式fact[a] * infact[b] % MOD * infact[a - b] % MOD中,连续乘法的取模顺序非常重要。(fact[a] * infact[b]) % MOD的结果再乘以infact[a-b],必须再次取模。更安全的写法是每乘一次就取一次模,或者使用long long类型暂存结果。因为三个10^9量级的数相乘,中间结果会超过64位整数的表示范围(约1.8e19),即使使用long long也可能溢出。最稳妥的写法是:
long long res = fact[a]; res = res * infact[b] % MOD; res = res * infact[a - b] % MOD; printf("%lld\n", res);6.2 数组下标与查询范围
预处理数组的大小N必须严格大于题目可能询问的最大n值。例如题目说a, b ≤ 10^5,那么N至少需要100010。在查询时,要确保输入的a和b满足b <= a,且a, b非负。虽然题目通常会保证输入合法,但在自己设计函数时,务必加入合法性检查,例如:
if (b > a || b < 0) return 0; // 组合数定义,当 b > a 或 b < 0 时,结果为06.3 对模数 p 的假设
整个算法严重依赖于一个前提:模数 p 是一个质数,并且需要与所有阶乘值互质(因为要求逆元)。通常题目中给出的MOD = 1e9+7就是一个质数。如果模数不是质数,费马小定理失效,就需要使用扩展欧几里得算法来求逆元,或者采用其他方法(如卢卡斯定理用于小质数,或分解质因数用于非质数模数)。绝对不要在不验证模数是否为质数的情况下直接套用此模板。
6.4 初始化的重要性
fact[0] = infact[0] = 1这行初始化代码是递推的基石。忘记初始化会导致后续所有计算结果错误。这是一个典型的“差之毫厘,谬以千里”的坑。
7. 性能实测与复杂度分析
我们来量化一下这种优化带来的收益。假设我们需要回答q = 10^5次查询,每次查询的n平均为10^5。
- 暴力法(每次计算):单次计算阶乘需要 O(n),总复杂度
O(q * n) ≈ 10^10次运算,完全不可行。 - 预处理阶乘+快速幂求逆元:预处理
fact数组 O(N)。每次查询需要计算两次快速幂(求m!和(n-m)!的逆元),单次查询复杂度 O(log MOD)。总复杂度O(N + q * log MOD) ≈ 10^5 + 10^5 * 30 ≈ 3*10^6,可以接受。 - 预处理阶乘+线性逆元(本文方法):预处理
fact和infact数组 O(N)。每次查询仅为三次取模乘法和一次取模运算 O(1)。总复杂度O(N + q) ≈ 2*10^5。比上一种方法又减少了一个log因子,在大规模查询下优势明显。
在实际的在线判题系统(OJ)上,这种优化通常意味着“通过”与“超时”的区别。对于 AcWing 886 这道题,使用未优化的方法几乎一定会超时。
8. 举一反三:方案变体与扩展思考
掌握了这个核心方法,我们可以解决一系列变种问题:
- 多模数问题:如果题目有多个不同的质数模数,我们需要为每个模数预处理一套
fact和infact数组。 - 更大范围的计算:当
n很大(比如10^18),但模数p较小(比如10^5)时,可以使用卢卡斯定理(Lucas Theorem)将大问题分解为多个小问题,再对小问题使用本方法求解。 - 非质数模数:如果模数不是质数,无法保证逆元存在。此时可以尝试将模数分解质因数,将组合数表示为质因数的幂次形式,最后再用中国剩余定理合并结果。或者,如果模数可以拆成若干个互质的因子,且每个因子是质数的幂,也可以分别计算后合并。
- 组合恒等式的预处理:有时需要频繁查询
C(n, k)对固定的n和所有k。可以利用关系式C(n, k) = C(n, k-1) * (n-k+1) / k,并预处理k的逆元,实现 O(n) 预处理,O(1) 查询所有k。
这个“预处理逆元”的思想不仅用于组合数,它实际上是在模运算下,将除法转为乘法的通用策略。在任何需要频繁进行模除法的场景下(例如,多项式运算、动态规划中的状态转移涉及除法),都可以考虑预先计算好相关元素的逆元。
回过头看,从最原始的阶乘相除,到引入模逆元,再到线性预处理,这个过程完美体现了算法优化中“以空间换时间”和“预处理”的核心思想。它要求我们不仅记住模板,更要理解其背后的数论原理和设计动机。下次当你再遇到需要大量计算组合数的问题时,希望这套“预处理+优化的O(n)版本”能成为你手中得心应手的利器。