公司动态
组合数取模算法详解:从逆元、卢卡斯定理到工程实践
1. 从“板子”说起为什么我们需要一个组合数取模的模板在算法竞赛和某些特定领域的工程开发里你肯定不止一次遇到过“组合数取模”这个问题。题目可能长这样给定n,m和一个质数p求C(n, m) % p的值。新手的第一反应往往是直接套公式n! / (m! * (n-m)!)然后取模。但稍微一试就会发现除法在模运算下并不直接成立因为取模运算不满足除法分配律。这就是我们需要“板子”的原因——一个经过验证的、能处理各种边界条件和数据范围的代码模板让你在关键时刻能稳定、高效地算出结果而不用现场推导逆元或者纠结于溢出。“板子”这个词在算法圈里很形象它就像一块电路板上面集成了实现某个特定功能所需的所有元件和线路。对于组合数取模一个成熟的板子需要处理好几个核心问题如何高效计算阶乘及其逆元、如何处理不同的n,m范围尤其是当n和m很大时、以及当模数p可能很小或者n,m大于p时的特殊情况这时就需要卢卡斯定理。网上的代码片段很多但如果不理解其背后的原理和适用边界直接套用很容易踩坑。比如用预处理阶乘逆元的方法时如果n超过了预处理的长度或者模数p不是质数程序就会出错。这篇文章我就结合自己多年的踩坑经验把几种主流的组合数取模“板子”的原理、实现、适用场景和避坑要点彻底讲清楚让你不仅能有代码可抄更能明白什么时候该用哪块“板子”。2. 基石乘法逆元与预处理阶乘在讨论具体板子前我们必须先夯实基础乘法逆元和阶乘预处理。这是大多数高效组合数取模算法的核心。2.1 为什么需要逆元一个直观的例子组合数公式C(n, m) n! / (m! * (n-m)!)包含除法。在模p运算中我们不能直接计算(a / b) % p而需要转化为(a * inv(b)) % p其中inv(b)是b在模p下的乘法逆元。乘法逆元的定义是对于一个整数b和模数p如果存在一个整数x使得(b * x) % p 1那么x就是b模p的逆元记作inv(b)或b^{-1}。注意逆元存在的充要条件是gcd(b, p) 1即b与模数p互质。当p是质数时所有1到p-1的整数都与p互质都有逆元。这就是为什么组合数取模通常要求p是质数。举个例子计算C(5, 2) % 7。 直接计算C(5,2)10,10 % 7 3。 用逆元法验证p7是质数。5! 120,2! 2,3! 6。我们需要算(120 * inv(2) * inv(6)) % 7。找2模7的逆元(2 * 4) % 7 1所以inv(2) 4。找6模7的逆元(6 * 6) % 7 36 % 7 1所以inv(6) 6。计算(120 % 7) * 4 * 6 % 7 (1 * 4 * 6) % 7 24 % 7 3。结果正确。2.2 高效求逆元费马小定理与线性递推如何快速计算单个数的逆元当p为质数时最常用的是费马小定理如果p是质数且a不是p的倍数那么a^(p-1) % p 1。由此可得a的逆元inv(a) a^(p-2) % p。这可以通过快速幂算法在O(log p)时间内计算。// 快速幂求逆元 (p为质数) long long inv(long long a, long long p) { long long res 1, base a, exponent p - 2; while (exponent) { if (exponent 1) res res * base % p; base base * base % p; exponent 1; } return res; }但是如果我们需要频繁计算1到n所有数的逆元比如为了预处理阶乘的逆元逐个用快速幂求效率是O(n log p)不够优。这时可以用线性递推法在O(n)时间内预处理出所有逆元。递推公式为inv[i] (p - p / i) * inv[p % i] % p其中inv[1] 1。这个公式的推导需要一点数论知识但记住结论并理解其O(n)的效率优势即可。// 线性递推求1~n的逆元 (p为质数) vectorlong long inv(n 1); inv[1] 1; for (int i 2; i n; i) { inv[i] (p - p / i) * inv[p % i] % p; }2.3 阶乘与阶乘逆元的预处理有了逆元我们就可以预处理阶乘数组fact[i] i! % p和阶乘逆元数组inv_fact[i] inv(i!) % p。fact[i] fact[i-1] * i % pinv_fact[i] inv_fact[i1] * (i1) % p(需要先求出inv_fact[n]再倒推)或者利用inv_fact[i] inv_fact[i-1] * inv[i] % p(需要先预处理inv[i])。通常采用第二种方法配合线性递推逆元// 预处理阶乘和阶乘逆元上限为 n vectorlong long fact(n 1), inv_fact(n 1); fact[0] 1; for (int i 1; i n; i) fact[i] fact[i-1] * i % p; // 预处理1~n的逆元 vectorlong long inv(n 1); inv[1] 1; for (int i 2; i n; i) inv[i] (p - p / i) * inv[p % i] % p; // 预处理阶乘逆元 inv_fact[0] 1; for (int i 1; i n; i) inv_fact[i] inv_fact[i-1] * inv[i] % p;预处理完成后计算组合数就变成了O(1)的查表操作C(n, m) fact[n] * inv_fact[m] % p * inv_fact[n-m] % p实操心得1数组大小的选择这里的n是你能接受的预处理上限。在竞赛中通常根据题目数据范围设定比如n 1e6。务必确保你查询的n和m不超过这个上限否则需要换用其他方法如卢卡斯定理。同时数组要开成long long类型防止中间乘法运算溢出。3. 当n和m巨大时卢卡斯定理登场预处理阶乘的方法非常快但它有一个致命限制n和m必须小于等于你预处理的边界N同时也必须小于模数p因为预处理依赖p为质数且逆元存在。如果题目给出的n和m高达1e18但模数p相对较小比如1e5量级预处理所有阶乘显然不可能。这时就需要卢卡斯定理。3.1 卢卡斯定理的原理与证明思路卢卡斯定理描述如下对于非负整数n,m和质数p有C(n, m) % p C(n % p, m % p) * C(n/p, m/p) % p其中C(n/p, m/p)这部分可以继续递归地用卢卡斯定理计算直到m/p为0。这个定理的强大之处在于它将一个巨大的n,m的组合数取模问题分解为若干个规模在p以内的小问题。因为n % p和m % p必然小于p所以我们可以用预处理好的、范围在[0, p-1]的阶乘表来O(1)计算C(n%p, m%p)。定理的证明基于二项式系数和p进制表示核心思想是考察(1x)^n在模p下的展开式并利用(1x)^p ≡ 1 x^p (mod p)当p为质数时这一关键同余式。对于应用者我们更关心其实现和边界条件。3.2 卢卡斯定理的递归实现与迭代实现一个最直接的实现是递归// 假设已预处理好 fact[0..p-1] 和 inv_fact[0..p-1] long long C(long long n, long long m, long long p) { if (m n) return 0; // 小范围直接计算 return fact[n] * inv_fact[m] % p * inv_fact[n - m] % p; } long long lucas(long long n, long long m, long long p) { if (m 0) return 1; // 递归分解 return C(n % p, m % p, p) * lucas(n / p, m / p, p) % p; }递归写法清晰但存在递归栈开销。我们可以写成迭代形式效率更高long long lucas_iterative(long long n, long long m, long long p) { if (m 0) return 1; long long res 1; while (n 0 || m 0) { long long ni n % p, mi m % p; if (mi ni) return 0; // 组合数定义若 m n 则为0 res res * C(ni, mi, p) % p; n / p; m / p; } return res; }实操心得2卢卡斯定理的预处理范围使用卢卡斯定理时我们只需要预处理0到p-1的阶乘及其逆元。这意味着即使n是1e18只要p是1e5级别的质数我们也只需要一个大小为p的数组内存和预处理时间都大大减少。这是处理大n小p问题的利器。3.3 卢卡斯定理的边界条件与常见错误使用卢卡斯定理时有几个坑点需要特别注意p必须是质数卢卡斯定理成立的前提是p为质数。如果p不是质数则需要使用扩展卢卡斯定理处理合数模数那要复杂得多。C(n%p, m%p)中的m%p可能大于n%p在递归或迭代的每一步我们计算C(n_i, m_i)其中n_i n%p,m_i m%p。虽然整体上m n但m_i是有可能大于n_i的。根据组合数定义此时C(n_i, m_i) 0。在代码中必须判断如果m_i n_i直接返回0。这是很多初学者忽略的地方。递归终止条件当m被除到0时C(n, 0) 1这是递归的基准情况。我遇到过一道题n和m很大p是质数直接用预处理阶乘爆内存改用卢卡斯定理后却一直得到错误答案。调试后发现就是在某一步递归时m % p比n % p大了而我没有判断这个条件导致去计算了非法的组合数进而因为阶乘逆元访问越界或得到荒谬结果。4. 综合板子适配不同场景的代码模板在实际做题或开发中我们希望能有一个“智能”的板子能根据输入的n,m,p自动选择最合适的方法。下面我给出一个综合性的模板它优先尝试使用预处理阶乘的O(1)方法如果n超出预处理范围但p是质数且n可能很大则降级到卢卡斯定理。4.1 模板代码结构与解释#include bits/stdc.h using namespace std; typedef long long ll; // 快速幂求逆元 (用于初始化阶乘逆元或单次求逆) ll mod_pow(ll a, ll b, ll p) { ll res 1; while (b) { if (b 1) res res * a % p; a a * a % p; b 1; } return res; } // 组合数取模模板类 struct CombMod { ll p; ll N; // 预处理阶乘的上限 vectorll fact, inv_fact; // 初始化预处理 [0, N] 的阶乘及其逆元 CombMod(ll mod, ll maxN) : p(mod), N(maxN), fact(N1), inv_fact(N1) { fact[0] 1; for (ll i 1; i N; i) fact[i] fact[i-1] * i % p; // 用费马小定理求 N! 的逆元然后倒推 inv_fact[N] mod_pow(fact[N], p-2, p); for (ll i N-1; i 0; --i) { inv_fact[i] inv_fact[i1] * (i1) % p; } } // 小范围直接计算 C(n, m)要求 n,m N ll C_small(ll n, ll m) { if (m 0 || m n) return 0; return fact[n] * inv_fact[m] % p * inv_fact[n - m] % p; } // 卢卡斯定理计算 C(n, m)不要求 n,m N但要求 p 是质数 ll C_lucas(ll n, ll m) { if (m 0 || m n) return 0; if (m 0) return 1; // 递归实现清晰易懂 return C_small(n % p, m % p) * C_lucas(n / p, m / p) % p; } // 对外接口智能选择方法 // 策略如果 n N用 O(1) 查询否则用卢卡斯定理假设p是质数 ll C(ll n, ll m) { if (m 0 || m n) return 0; if (n N) { return C_small(n, m); } else { // 这里假设调用者确保 p 是质数 return C_lucas(n, m); } } }; // 使用示例 int main() { const ll MOD 1e9 7; // 常用质数模数 const ll MAX_N 1e6; // 根据题目需求调整 CombMod comb(MOD, MAX_N); // 示例1n, m 在预处理范围内 cout comb.C(100, 50) endl; // 使用 O(1) 查询 // 示例2n, m 非常大 cout comb.C(1e18, 1e17) endl; // 自动使用卢卡斯定理 return 0; }4.2 模板的灵活性与配置要点这个模板的核心是CombMod类它在构造时根据给定的模数p和预处理上限N初始化阶乘表。N的选择这是性能与通用性的权衡。N越大能直接O(1)计算的情况就越多但初始化时间和内存占用也越高。通常N设置为题目中n和m的常见最大值或者内存允许的最大值比如1e6到1e7。如果题目明确n可能非常大1e7那么你应该设置一个较小的N比如p的大小因为卢卡斯定理只需要p以内的阶乘并依赖卢卡斯定理。逆元的计算模板中使用了快速幂求fact[N]的逆元然后倒推。这与之前提到的线性递推正推是等价的但省去了单独预处理inv数组的步骤。两种方法都可以选择你熟悉的。错误处理模板中的C函数检查了m 0 || m n的情况直接返回0这符合组合数定义。质数检查模板的C_lucas函数没有检查p是否为质数。调用者必须确保当n N时传入的p是质数否则结果错误。一个更健壮的实现可以在构造函数或C_lucas中加入质数检查如 Miller-Rabin 算法但会增加复杂度。在竞赛中题目通常会明确说明p是质数。实操心得3内存与时间的权衡我曾经在一道题目上因为N设置得过大1e7而导致内存超限fact和inv_fact两个vectorll就占用了约2 * 1e7 * 8 bytes ≈ 160MB。后来分析题目数据发现n虽然常值在1e6以内但有一个测试点p很小n极大。于是我调整策略将N设为p的最大值1e5对于大n一律走卢卡斯定理路径顺利通过了所有测试点。关键是要根据数据特征动态调整策略。5. 进阶话题非质数模数与扩展卢卡斯定理前面讨论的所有方法都基于一个关键假设模数p是质数。这是因为我们需要用到费马小定理求逆元而逆元存在的条件就是gcd(a, p)1。如果p不是质数比如p 10000000001e9或者p 6那么对于某些a逆元可能不存在直接套用上述模板会出错。5.1 合数模数带来的挑战当p是合数时计算C(n, m) % p的标准方法是扩展卢卡斯定理。其核心思想是将合数p分解质因数p p1^k1 * p2^k2 * ... * pt^kt。然后分别计算C(n, m) % pi^ki最后利用中国剩余定理将结果合并。对于每一个质数幂pi^ki计算C(n, m) % pi^ki本身也是一个挑战因为即使模数是pi^kipi是质数分母中的阶乘也可能与模数不互质导致逆元不存在。扩展卢卡斯定理通过移除阶乘中所有的pi因子将问题转化为与pi互质的部分求逆元以及计算pi因子的幂次。5.2 扩展卢卡斯定理的实现概览实现扩展卢卡斯定理较为复杂主要包括以下步骤质因数分解p。对于每个质因子pi及其幂次ki a. 计算n!,m!,(n-m)!中剔除所有pi因子后剩余部分模pi^ki的值。同时记录被剔除的pi因子的总指数。 b. 组合数C(n, m)中pi的指数等于n!中pi的指数减去m!和(n-m)!中pi的指数之和。 c. 最终C(n, m)对pi^ki取模的结果是(剩余部分之积 * pi^(指数差)) % pi^ki。其中剩余部分之积的模逆元可以利用扩展欧几里得算法求解因为剩余部分与pi互质。中国剩余定理合并得到一组同余方程x ≡ ai (mod pi^ki)用中国剩余定理求出x ≡ ? (mod p)。由于其实现冗长且在实际竞赛和工程中模数为质数的情况占绝大多数这里不展开完整代码。但你需要知道当题目明确模数为合数或者你无法确定模数性质时简单的阶乘预处理或卢卡斯定理是行不通的。避坑指南如何判断该用哪种方法看模数p如果题目说p是质数或者p是常见的质数如1e97,998244353那么优先使用预处理阶乘逆元或卢卡斯定理。看数据范围n,m如果n, m 1e6或你内存允许的预处理上限用预处理阶乘O(1)查询最快。如果n, m很大如1e18但p是质数且p不大如1e5用卢卡斯定理。如果n, m很大且p不是质数那么你必须使用扩展卢卡斯定理或者寻找其他数学转化方法。看询问次数如果需要计算海量次组合数如百万次即使n不大预处理阶乘的O(1)查询也远优于每次重新计算。6. 实战中的优化技巧与性能考量即使有了正确的板子在极端情况下如n非常大、p非常小且询问次数极多仍然可能遇到性能瓶颈。这里分享几个优化技巧。6.1 预处理的选择全局静态 vs 局部动态如果你的程序需要多次处理不同模数p的组合数查询或者p在运行时才确定那么每次创建一个新的CombMod对象并预处理阶乘可能会成为时间开销大头。一种优化策略是如果模数p是固定的、常见的如1e97可以将其阶乘表声明为全局静态变量在程序开始时初始化一次后续所有查询共用。如果模数p变化但最大值N固定可以考虑预计算多个常用质数的阶乘表或者采用“懒加载”策略为每个新出现的p缓存其阶乘表。6.2 卢卡斯定理的递归深度卢卡斯定理的递归深度大约是log_p(n)。当p很小比如2而n很大时递归深度会很大log_2(1e18) ≈ 60虽然通常可以接受但存在栈溢出风险特别是在某些递归栈深度受限的环境。使用迭代实现可以避免这个问题这也是我推荐迭代实现的原因之一。6.3 模运算的常数优化在组合数计算的核心表达式fact[n] * inv_fact[m] % p * inv_fact[n-m] % p中进行了两次模乘和一次取模。编译器通常能很好地优化但在性能极其敏感的场合可以尝试使用long double或__int128进行中间计算最后取模以减少取模次数。但要注意这可能会牺牲一些可移植性并需要确保中间结果不溢出。// 一种可能的优化谨慎使用 ll C_opt(ll n, ll m, ll p) { if (m 0 || m n) return 0; // 使用更宽的整数类型避免中间溢出 unsigned long long res fact[n]; res res * inv_fact[m] % p; res res * inv_fact[n - m] % p; return (ll)res; }6.4 应对极端情况n, m 接近 p当使用卢卡斯定理且n或m接近甚至等于p时需要特别注意。在递归的最后一层或某层n % p或m % p可能为0。我们的阶乘表fact和inv_fact通常只预处理到p-1。计算C(p, k) % p时根据组合数定义和模p的性质当0 k p时C(p, k) % p 0。因为分子p!包含因子p而分母k!(p-k)!不包含因为k和p-k都小于p。我们的C_small函数如果正确地用fact[p]这里p超出了数组范围计算就会出错。实际上在卢卡斯定理的每一步我们计算的C(n_i, m_i)中的n_i和m_i都严格小于p因为是模p的结果所以只要我们的阶乘表覆盖0到p-1就是安全的。fact[p]永远不会被访问到。我曾在一次比赛中遇到一个边界情况p7,n7,m3。用卢卡斯定理C(7,3) % 7 C(0, 3) * C(1, 0) % 7。第一步C(0,3)中30根据组合数定义应为0所以最终结果是0。如果代码没有判断m_i n_i的情况就会去计算fact[0] * inv_fact[3] * inv_fact[-3]导致错误。因此边界判断至关重要。