☰
模逆元详解:从费马小定理到扩展欧几里得,彻底搞懂模除法
2026/10/20 7:44:00 网站建设 项目流程

文章正文

有一类问题,新手一碰就头痛——明明是整数运算,你却要在模意义下做除法。比如给你一个算式3 / 2 mod 7,很多初学者的第一反应是先算3 / 2 = 1.5,发现结果不是整数,直接懵掉。这个场景在组合数取模、哈希碰撞处理、密码学计算以及各类算法竞赛题里几乎天天出现。而解决它的核心工具,就是标题里那个词:逆元。简单说,模意义下没有真正的除法,但我们可以把“除以某个数”转换成“乘以这个数的逆元”,让一切重新回到整数乘法轨道上。这篇文章不讲空话,直接从数学原理出发,把推导过程一步一步写清楚,并给出 Python 和 C++ 两个方向的完整实现,以及我在实际调试中踩过的坑。无论你是刚接触数论的萌新,还是写过几年代码但一直没搞懂模除法的老手,这都能帮你一次理顺整条链路。

1. 从“除不下去”说起:模运算除法到底卡在哪

1.1 取模的四则运算里,除法为什么突然失效

我们从小就熟悉四则运算:加减乘除随便做。到了模运算这里,加法、减法、乘法都很好说话:

  • (a + b) mod m = (a mod m + b mod m) mod m
  • (a - b) mod m = (a mod m - b mod m + m) mod m
  • (a * b) mod m = (a mod m) * (b mod m) mod m

这三条规则保证了你可以先取模再运算,也可以先运算再取模,结果一致。但除法并没有这么简单的规则。假设我想计算(a / b) mod m,如果直接套用“先除再取模”,遇到b不能被a整除的情况,就不得不面对小数。可问题恰恰在于,模算术的世界里根本没有小数,所有的数都生活在0到m-1的整数集合里。于是“除以b”这件事就变得很尴尬:你到底想让我得到哪个整数?

一个更本质的困扰是,在普通算术里,“除以 2”等价于“乘以 0.5”,但0.5并不存在于取模后的整数集合中。所以我们需要另找一个整数,让它在乘法意义上扮演0.5的角色。这个整数就是逆元。比如在模 7 的世界里,2的逆元是4,因为2 * 4 = 8 ≡ 1 (mod 7)。于是3 / 2 mod 7就等价于3 * 4 mod 7 = 12 mod 7 = 5。你看,除法被成功转换成了乘法,全程没有任何小数出现。

1.2 从“求倒数”到“求逆元”:视线的转换

回忆一下普通算术里的倒数定义:一个数x的倒数x^(-1)满足x * x^(-1) = 1。模运算里的逆元定义几乎一模一样,只是把等式换成了模意义下的同余:如果gcd(a, m) = 1,那么存在唯一的整数x(在模m的意义下)满足a * x ≡ 1 (mod m),这个x就叫a在模m下的逆元,记作a^(-1) mod m。

这里出现了一个至关重要的前置条件:gcd(a, m) = 1,也就是a和模数m互质。如果它们不互质,比如在模 6 下去求2的逆元,你会发现无论x取什么整数,2x mod 6都只能是0、2、4这三个值,永远凑不出1。所以逆元存在的底层原因是数论里的线性同余方程a * x + m * y = 1有整数解,而这个方程有解当且仅当gcd(a, m)整除1,也就是两者互质。一句话总结:不是所有数在任意模下都有逆元,动手之前先看互质。

顺带一提,很多人把逆元和模运算里的“负数倒数”搞混。普通算术里2的倒数是0.5,模 7 里2的逆元是4,两者在数值上毫无关联,它们只是各自世界里的“乘法单位元生成器”。理解这一点,后续看代码就顺畅多了。

1.3 为什么我们要花心思去优化“除法”

有人会说,既然逆元这么绕,那我先算完再取模不就行了?问题是很多场景里数值大得离谱。以组合数C(100000, 50000)为例,它的结果有上万位十进制数字,直接算出来根本存不进任何基本数据类型,更不用说拿去取模。算法竞赛里常见的做法是要求输出C(n, k) mod 1000000007,这时你必须在模意义下完成所有运算,包括阶乘之间的除法。没有逆元,这道题几乎无从下手。

同样,在实现哈希表的除法散列、设计随机数生成器、构造加密算法时,只要运算停留在模算术范围里,除法就总是绕不开的坎。逆元的价值在于,它把“除法”这个在整数集合里不可用的操作,转换成完全可用的“乘法”,让那些原本无法直接计算的式子重新有了可执行路径。理解了这一点,你才算真正读懂了标题里的“优化”二字——它优化的不是常数级别的时间,而是把一类根本做不了的运算变成能在模意义下精确执行的运算。

2. 三类求逆元方法:数学推导、边界条件与选型

2.1 费马小定理:模素数的专属快速通道

如果你的模数m是一个素数,那恭喜你,求逆元有一条天然的捷径——费马小定理。定理说的内容是:如果p是素数,且a不被p整除,那么成立同余式a^(p-1) ≡ 1 (mod p)。

稍微变形一下就能得到结论。因为a^(p-1) ≡ a * a^(p-2) ≡ 1 (mod p),根据逆元的定义,a^(p-2) mod p就是a在模p下的逆元。也就是说,在素模下,求逆元这件事直接变成了求一次快速幂。这是所有方法里代码最简单、思路最清楚的一种。

特别要补充一个细节:很多公式里会写a^(p-2) mod p,但如果你遇到的模数是1000000007这种经典大素数,底数a本身可能非常大。这时候必须使用快速幂算法,把幂运算的时间复杂度压到O(log p),同时每一步取模,防止中间结果溢出。我在第 3 节会给出完整实现。

2.2 扩展欧几里得:合数模数下的通用解法

费马小定理好用,但它要求模数必须是素数。如果模数是 2024 这种合数,定理直接失效,你只能回到更一般的工具——扩展欧几里得算法。回顾一下,求a在模m下的逆元,本质上是找整数x, y满足方程a * x + m * y = 1。这个等式两边同时取模m,立刻得到a * x ≡ 1 (mod m),所以x就是我们要的逆元。

扩展欧几里得算法在求出gcd(a, m)的同时,还能顺带把一组(x, y)凑出来。前提依然不变:gcd(a, m)必须等于 1,否则方程无解。用递归回溯的方式写,总能在O(log min(a, m))的时间内完成计算。这个算法比快速幂稍难理解,但它是应对任意模数的万能钥匙,建议每个写代码的人都把它收藏成自己的标准模板。

2.3 欧拉定理:把素数的限制进一步放宽

如果觉得费马小定理太局限,又想避开扩展欧几里得复杂的回溯过程,中间还有一个折中方案——欧拉定理。欧拉定理的内容是:如果gcd(a, m) = 1,那么a^(φ(m)) ≡ 1 (mod m),其中φ(m)是欧拉函数,表示小于等于m且与m互质的正整数个数。费马小定理其实是欧拉定理在m为素数时的特例,因为素数p的φ(p) = p - 1。

于是求逆元就变成了a^(-1) ≡ a^(φ(m)-1) (mod m)。看着很美好,但实际使用时要先算出φ(m),而计算欧拉函数需要对m做质因数分解。对于一次调用,分解的复杂度往往不比扩展欧几里得低。所以我在真实工程里,除非是“批量求逆元但模数相同且已预先算好欧拉函数”的场景,否则更推荐直接用扩展欧几里得。

2.4 选型决策:一张表讲清楚三种方案的适配场景
方法适用条件核心思路时间复杂度实现难度
费马小定理模数为素数幂运算O(log m)低
扩展欧几里得模数任意(要求互质)解同余方程O(log m)中
欧拉定理模数任意(要求互质)先分解求 phiO(sqrt(m))中高

我的个人选型习惯是:默认模数是1000000007、998244353这种大素数时,直接费马小定理;一旦要处理动态模数或者题目没保证模数是素数,立刻切成扩展欧几里得。别在选型上浪费时间,真正难的是后面那些坑。

3. Python / C++ 代码实现:把每一步落到键盘

3.1 Python 3.8+ 的 pow 内置签名:一行搞定模逆元

Python 从 3.8 版本开始,内置函数pow增加了对负指数的支持,可以直接求模逆元,用法非常简单:

m = 1000000007 a = 42 inv = pow(a, -1, m) # 返回 a 在模 m 下的逆元 print(inv)

很多人第一次看到pow(a, -1, m)会以为是求a的倒数,但只要知道这是 Python 官方为模逆元开的“后门”,就会觉得真香。底层实际执行的就是扩展欧几里得算法,所以它不要求模数是素数,只要求gcd(a, m) == 1。如果二者不互质,调用会抛出ValueError,这个报错行为本身就是一次很好的保护。

不过要提醒一句,这是 CPython 官方实现的行为,如果你在跑一些旧的 Python 版本,或者在一个特殊定制的解释器环境里,pow的负指数支持不一定存在。上线前先确认版本,或者直接用下面自己实现的版本,能省下不少排查时间。

3.2 不依赖内置 magic 的 Python 扩展欧几里得

如果你不想依赖内置行为,或者希望彻底理解每一步,自己写扩展欧几里得是最稳妥的选择。代码不长,但回溯过程值得仔细盯几遍:

def exgcd(a, b): if b == 0: return a, 1, 0 g, x, y = exgcd(b, a % b) return g, y, x - (a // b) * y def mod_inv(a, m): g, x, y = exgcd(a, m) if g != 1: raise ValueError(f"{a} 在模 {m} 下不存在逆元") return x % m # 关键:把结果规范到 [0, m-1] print(mod_inv(42, 1000000007))

这里的递归推导需要一点耐心。当b == 0时,gcd(a, 0) = a,同时显然有a * 1 + 0 * 0 = a,所以返回(a, 1, 0)作为递归出口。往上回溯时,假设已经求出了gcd(b, a % b)对应的系数(x, y),也就是b * x + (a % b) * y = g,再把a % b换回a - (a // b) * b,展开整理后就能得到新的系数。这一步是扩展欧几里得里最容易写错的地方,强烈建议自己动手推一遍。

3.3 C++ 快速幂求逆元:应对素数模数的标准姿势

在 C++ 里没有 Python 那样一行pow的便利,一切靠手写。先看适用于素数模数的快速幂版本:

using int64 = long long; int64 mod_pow(int64 a, int64 b, int64 mod) { int64 res = 1; while (b > 0) { if (b & 1) res = res * a % mod; a = a * a % mod; b >>= 1; } return res; } int64 mod_inv_fermat(int64 a, int64 p) { // 前提:p 是素数,且 a 不是 p 的倍数 return mod_pow(a, p - 2, p); }

这里的核心是快速幂的二进制分解思想:要算a^b,把指数b拆成二进制位,每一位对应一个平方项。遍历每一位时,当前位是1就乘上当前的a,同时每轮都把a平方一次。因为每一步都对mod取模,过程中所有中间值都能控制在int64范围内,前提是mod的平方不超过int64的上限。对于1e9+7这种常用模数,(1e9+7)^2 ≈ 1e18,刚好卡在long long边缘,还算安全。

3.4 C++ 扩展欧几里得求逆元:合数模数的通用方案

通用方案直接把上面的 Python 逻辑翻译成 C++,但多了几个必须注意的细节:

#include <iostream> long long exgcd(long long a, long long b, long long &x, long long &y) { if (b == 0) { x = 1; y = 0; return a; } long long x1, y1; long long g = exgcd(b, a % b, x1, y1); x = y1; y = x1 - (a / b) * y1; return g; } long long mod_inv(long long a, long long m) { long long x, y; long long g = exgcd(a, m, x, y); if (g != 1) return -1; // 不存在时返回 -1 if (x < 0) x += m; // 规范到 [0, m-1] return x; } int main() { long long inv = mod_inv(42, 1000000007); std::cout << inv << "\n"; return 0; }

需要注意,递归版扩展欧几里得虽然简洁,但递归深度是O(log m),正常完全没问题。真正容易踩雷的是最后一步:算出来的x可能是负数,必须通过一次+= m把它修正回非负范围。如果你把x直接拿去参与后续乘法,负数取模的结果会把你坑到怀疑人生。这个问题我放在第 5 节详细展开。

3.5 一个直观的验证过程:逆元到底对不对

拿到一个逆元后,怎么确认它是正确的?最直白的验证方式是反乘:计算a * inv(a) mod m,看看结果是不是1。我在调试时常常随手跑一段验证循环,尤其是第一次写扩展欧几里得模板的时候。比如求3在模7下的逆元,程序返回5,验证3 * 5 = 15 ≡ 1 (mod 7),这就说明对了。这个习惯建议保留下来,尤其是修改模板或切换到不同的模数时,一个随手的验证能省掉后面数个小时的排错。

4. 组合数取模等实战场景:逆元不是玩具,是基建

4.1 预处理阶乘逆元:让组合数 C(n, k) 变成 O(1)

真正让逆元在工程里“封神”的场景,是组合数取模。我们要算C(n, k) mod m,公式是C(n, k) = n! / (k! * (n-k)!)。分母里的阶乘带着除法,在模意义下不能直接算,但可以这样处理:先预处理出所有阶乘,比如fact[i] = i! mod m,然后再预处理出每个阶乘的逆元inv_fact[i] = (i!)^(-1) mod m。有了这两张表,组合数就能一步算出来:

C(n, k) mod m = fact[n] * inv_fact[k] % m * inv_fact[n-k] % m

这里的难点在于高效预处理inv_fact,不能对每个i都单独跑一次快速幂,否则总复杂度是O(n log m),在n = 10^6时会比较吃力。省力的办法是利用一个递推关系:inv_fact[i] = inv_fact[i+1] * (i+1) % m,这是因为(i+1)!的逆元乘上(i+1)就等于i!的逆元。从大往小递推,总复杂度降到O(n)。

4.2 模数不是素数:如何用逆元加中国剩余定理组合计算

有些问题的模数不是素数,比如要求结果模2024,但题目内部逻辑仍然依赖逆元。突破口是拆模数。先把2024分解成若干个素数的幂,比如2024 = 8 * 11 * 23,然后分别在这些小模数下计算答案,因为8、11、23两两互质且每个都是素数幂,在小模数下可以正常用逆元。最后用中国剩余定理把多个模运算结果合并成模2024的唯一答案。这个思路在实际竞赛题里出现频率不低,但其实现阶段你只需要记住结论:逆元在合数模数下并非完全不可用,而是需要先拆模数,再各自处理。

4.3 分数在模意义下的映射:把“除法”真正变成乘法

除了组合数,另一个高频应用是和概率、期望相关的题目。例如你想计算某个事件发生的概率p = a / b,要求输出p mod m。常规思路是输出a * inv(b) mod m。这在统计学题和概率动态规划里已经成为标准写法,不需要把a / b化成小数,更不用提精度问题。这个“把分数映射到模域”的思想,本质上是逆元概念的延伸。理解了这个以后,你再看很多题解里的奇怪操作,比如x / y mod 1e9+7直接写成x * mod_inv(y) % MOD,就不会再觉得莫名其妙了。

5. 常见问题与踩坑排查实录

5.1 模数不是素数时直接快速幂翻车

我自己刚入门时就干过这么一件事:模数直接用1e9+7很爽,后来换题时模数变成了1e9+9,我也没多想就直接套费马小定理公式,结果答案怎么算都不对。查了半天,才发现1e9+9虽然也很大,但它不是素数。费马小定理的成立前提被打破,a^(p-2) mod p根本不是逆元。这事的教训是:看到模数先下意识问一句“它是素数吗”,再决定用快速幂还是扩展欧几里得。如果是让你自己实现模块化的库函数,直接默认统一走扩展欧几里得,省心。

5.2 负数取模的歧义,一不小心就被编译器坑

C++ 里取模和数学上的取模不太一样,负数%正数可能得到负数。比如-5 % 3,在 C++ 里结果通常是-2,而不是你期望的1。这个问题在扩展欧几里得得到负的逆元时非常明显:如果直接拿x去用,后续乘法的结果会因为符号问题错得毫无规律。解决办法是在拿到x后先规范到[0, m-1],也就是x = (x % m + m) % m,或者像我在 3.4 节里写的那样,发现小于 0 就加一次m。这个习惯一旦养成,能避免很多莫名其妙的越界错误。

5.3 逆元不存在时你还在硬算

有些场景里,你无法保证每个数都和模数互质。比如mod_inv(0, m),gcd(0, m) = m,显然不等于 1,所以0根本没有逆元。如果你的代码没有检查gcd == 1,快速幂版本可能会返回一个看似正常的数字,但实际上完全不是你要的那个值。扩展欧几里得版本倒是会在g != 1时让你知道,但前提是你确实写了这个检查。我的建议是封装函数时把“不存在逆元”作为一个明确分支,返回-1或直接抛异常,给调用方一个清晰的信号。

5.4 别忘了溢出:乘法中间值可能飙上天才

取模操作看似安全,但乘法中间值可能超出int甚至long long的范围。举个例子,a = 50000,b = 50000,两者都远小于模数1e9+7,但乘积是2.5e9,已经超出int范围。如果再乘以另一个数,风险更大。稳妥做法是统一用long long做中间变量,必要时用__int128(编译器支持的话)。另一个习惯是把取模操作尽量滞后,比如先算完所有乘积再统一取模,但要保证中间值不溢出,否则就得一边乘一边取模。这两者没有绝对的对错,纯粹的工程取舍。

5.5 问题速查表
现象可能原因解决方案
费马小定理求解结果错误模数非素数改成扩展欧几里得
得到负的逆元扩展欧几里得返回负数系数加上模数规范化到正数
程序抛出除零或值错误数a与模数不互质业务逻辑上先判断合法性
大模数下运算结果异常乘法中间值溢出使用long long或__int128
预处理阶乘逆元复杂度爆炸对每个元素单独求一次逆元改用反向递推公式 O(n) 预处理

6. 个人实操心得与扩展

6.1 模板封装:把逆元包进一个稳定的结构体

在实际工程和竞赛模板里,我习惯把求逆元的能力封装成一个结构体,不单独散落着写。比如一个组合数取模模板会包含:快速幂函数、扩展欧几里得函数、阶乘数组和阶乘逆元数组,以及对外暴露的C(n, k)接口。这样使用时只需要初始化一次,后续所有组合查询都是O(1)。封装的同时,我还会放一个断言:要求0 <= k <= n,省得调用者传参不规范导致数组越界。这种“把基础工具沉淀成基础设施”的做法,能让后续写题目或业务逻辑时清爽很多。

6.2 关于逆元,我最后想单独说的三件事

第一,不要在比赛或上线前临时手写模板。手写很容易在细节上翻车,比如忘记% m、忘记规范化负数、递归边界写错。平时就要把代码敲熟,甚至背下来,考试或上线时才能当作肌肉记忆去调用。第二,不要只记代码不记原理。原理是唯一能帮你应对所有变化的东西,代码只是原理的一种具体表达。遇到新场景,比如需要对某个不常见的合数模数求逆元,原理会让你立刻想到扩展欧几里得,而不是死磕费马小定理。第三,善用 Python 的pow(a, -1, m)做快速验证。我在用 C++ 写算法题时,如果某个逆元算得心里没底,会迅速用一个 Python 脚本验算一遍。虽然工具链不同,但结果正确性是同一套数学保证的,这种交叉验证能大幅提高调试效率。

逆元这件事,说到底就是在整数取模的世界里重新发明了“除法”。当你第一次看到3 * 4 ≡ 1 (mod 7),第一次用扩展欧几里得求出一个负系数再把它修正为正数,第一次写出C(100000, 50000) mod 1000000007并跑出正确结果时,你会真正感受到这个工具的威力。希望读完这篇文章,你也能在实际项目里把它用得更顺手、更果断。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询