定义
乘法逆元是群论中的一个概念,它也在各类算法题中重现,是最好需要掌握的一项技巧。在同余中,乘法逆元的简单定义是,满足下式的 xxx 称为 aaa 模 nnn 的乘法逆元。
ax≡1(modn)(1)ax\equiv1 (\operatorname{mod}n)\tag 1ax≡1(modn)(1) 然而这个乘法逆元 xxx 还不一定是存在的。aaa 模 nnn 的乘法逆元存在的充要条件是,aaa 和 nnn 互质。
这个乘法逆元的作用就是,求 y/a(modn)y/a(\operatorname{mod} n)y/a(modn) 可以转化为求 yx(modn)yx(\operatorname{mod} n)yx(modn)。
举一个实用的例子,求组合数 C(n,k)=n!k!(n−k)!C(n,k)=\cfrac{n!}{k!(n-k)!}C(n,k)=k!(n−k)!n! 对一个很大的质数 ppp 的模。显然,如果只是一个 (n,k)(n,k)(n,k) 数据,暴力求一下也就行了。但如果给你很多很多 (n,k)(n,k)(n,k) 的数据,时间复杂度就不可接受了。此时乘法逆元的作用就表现出来:
假设我们预先知道 nnn 的上界,那我们就可以用 O(n)O(n)O(n) 的复杂度构建fact
数组,其中fact[i]
的值是 i!modpi!\operatorname{mod} pi!modp。另外,再用 O(nlogp)O(n\log p)O(nlogp) 的复杂度构建invs
数组,其中invs[i]
的值是 i!i!i! 模 ppp 的乘法逆元。
在经过 O(nlogp)O(n\log p)O(nlogp) 复杂度的预处理之后,求解组合数就变成了 O(1)O(1)O(1) 复杂度的事情。即C(n,k)=fact[n] * invs[k] * invs[n - k] % p
。
gcd 算法
先介绍一下求解逆元的前置知识。
辗转相除法求最大公因数
相信大家在学习一门编程语言的时候,接触过求解最大公因数的算法。最出名的莫过于辗转相除法。记 a,ba,ba,b 的最大公因数为 gcd(a,b)\operatorname{gcd}(a,b)gcd(a,b),那么 gcd(a,b)=gcd(b,amodb)\operatorname{gcd}(a,b)=\operatorname{gcd}(b,a\operatorname{mod}b)gcd(a,b)=gcd(b,amodb)。
当时你大概没有考虑过这个算法的时间复杂度是多少。实际上,使用辗转相除法求解 a,ba,ba,b 的最大公因数,时间复杂度是 O(log(max(a,b)))O(\log(\max(a,b)))O(log(max(a,b)))。
拓展 gcd 算法
先要用到拓展 gcd算法。这个算法的目标是,对于任意的正整数 a,ba,ba,b 找到 x,yx,yx,y 满足:
ax+by=gcd(a,b)(2)ax+by=\operatorname{gcd}{(a,b)}\tag 2ax+by=gcd(a,b)(2) 注意到当 b=0b=0b=0 时,上式化为 ax+by=bax+by=bax+by=b,那么就找到了 x=0,y=1x=0,y=1x=0,y=1。
而当 b≠0b\neq 0b=0 时,假设有一个 x^,y^\hat x,\hat yx^,y^ 满足 bx^+(amodb)y^=gcd(b,amodb)(3)b\hat x+(a\operatorname{mod}b)\hat y=\operatorname{gcd}(b,a\operatorname{mod}b)\tag3bx^+(amodb)y^=gcd(b,amodb)(3) 根据辗转相除法我们知道 (2)(3)(2)(3)(2)(3) 两式的右边是相等的,因此得到 ax+by=bx^+(amodb)y^(3)ax+by=b\hat x+(a\operatorname{mod}b)\hat y\tag 3ax+by=bx^+(amodb)y^(3) 其中,令 a=kb+t(k,t∈Z,0≤t<b)a=kb+t(k,t\in\mathbb Z,0\leq t<b)a=kb+t(k,t∈Z,0≤t<b),那么 (3)(3)(3) 又可以化为 kbx+tx+by=bx^+ty^(4)kbx+tx+by=b\hat x+t\hat y\tag{4}kbx+tx+by=bx^+ty^(4) 稍微整理一下,可得 b(kx+y−x^)+t(x−y^)=0(5)b(kx+y-\hat x)+t(x-\hat y)=0\tag 5b(kx+y−x^)+t(x−y^)=0(5) 因此,假设我们已经求得了 x^,y^\hat x,\hat yx^,y^,那么 x=y^x=\hat yx=y^ 和 y=x^−kx=x^−y^(a//b)y=\hat x-kx=\hat x-\hat y(a//b)y=x^−kx=x^−y^(a//b) 就是我们想要一组 (x,y)(x,y)(x,y) 的值。其中 a//b=⌊a/b⌋(a,b>0)a//b=\lfloor a/b\rfloor(a,b>0)a//b=⌊a/b⌋(a,b>0)。
由拓展 gcd 算法中,求解 (x,y)(x,y)(x,y) 和求 gcd(a,b)\operatorname{gcd}(a,b)gcd(a,b) 是同步的,辗转相除法求解 gcd(a,b)\operatorname{gcd}(a,b)gcd(a,b) 的终止条件也是 b=0b=0b=0。因此拓展 gcd 算法的时间复杂度也应该是 O(log(max(a,b)))O(\log(\max(a,b)))O(log(max(a,b))),这个 gcd 在拓展算法中起到一个引路的作用。
求解模素数的乘法逆元
有了上面的基础,就可以求解模素数的乘法逆元了。假设 nnn 是一个正素数,a>0a>0a>0 且 a≠na\neq na=n,那我们可以在 O(log(min(a,n)))O(\log(\min(a,n)))O(log(min(a,n))) 的时间复杂度内求得满足 ax+ny=gcd(a,n)=1ax+ny=\operatorname{gcd}(a,n)=1ax+ny=gcd(a,n)=1 的 (x,y)(x,y)(x,y)。式子两边同时对 nnn 去模,得到 ax≡1(modn)ax\equiv 1(\operatorname{mod}n)ax≡1(modn)。因此,通过拓展 gcd 算法求得的 xxx 就是我们想要的乘法逆元。
附带 python 代码一份,用于求 bbb 模 kkk 的乘法逆元。
def extended_gcd(a, b):
if a == 0:
return b, 0, 1
gcd, x1, y1 = extended_gcd(b % a, a)
x = y1 - (b // a) * x1
y = x1
return gcd, x, y
def mod_inverse(b, k):
gcd, x, _ = extended_gcd(b, k)
if gcd != 1:
raise ValueError(f"No modular inverse for {b} mod {k}")
return x % k