一次同余方程看起来只是 ,真正要回答的却有三问:有没有解、有几个解、怎么把它们全部找出来。下面按这条线索把原理、手算和代码放在一起。
同余在说什么
整数 、 对模 同余,记作
意思是 整除 ,也就是两者除以 余数相同。例如 ,因为 。
同余可以像普通等式那样做加减乘,但不能随便约分。 成立,两边同除以 得到 ,这就错了。正确做法是:约掉的因子必须和模数一起约。
一次同余方程
最常见的是一次同余方程
其中 ,未知数 是整数。它等价于:存在整数 ,使得
这是二元一次不定方程,也叫线性丢番图方程。于是整套理论可以落到一件事上:求 ,再看它能不能整除 。
设 。
- 若 ,无解。
- 若 ,恰有 个模 互不同余的解。
- 先求出一个特解 ,通解就是
也可以写成对更小的模 只有一个解:
为什么是这个判别
的左边一定是 的倍数,因为 同时整除 和 。所以右边 也必须是 的倍数,否则无解。
反过来,裴蜀定理保证:存在整数 使 。两边乘 ,就得到 的一组表示,于是原方程有解。
解的个数来自周期。若 是特解,则
也都是解。这些解模 恰好分成 类,对应 。
扩展欧几里得:把特解算出来
普通欧几里得算法只给出 。扩展欧几里得在辗转相除的同时,把
里的系数也记下来。
迭代写法最适合写代码。维护两组系数,使每一步的余数都能写成 、 的整数组合:
def egcd(a: int, b: int) -> tuple[int, int, int]:
"""返回 (d, x, y),满足 d = gcd(a, b) = a*x + b*y。"""
old_r, r = a, b
old_s, s = 1, 0
old_t, t = 0, 1
while r:
q = old_r // r
old_r, r = r, old_r - q * r
old_s, s = s, old_s - q * s
old_t, t = t, old_t - q * t
return old_r, old_s, old_t得到 后, 就是 的一个解。原方程若 ,特解就是
再把 收进 即可。
手算例子
例一:有唯一解
解 。
,能整除 ,所以模 恰有一解。
用扩展欧几里得:
回代:,故 。两边乘 :
验算:。
例二:有多个解
解 。
,且 ,所以有 个解。先把方程除以 :
模 的逆元是 ,因为 。于是
特解 ,通解
也就是模 的三个解:。
验算:,,。
例三:无解
。,但 不能整除……等等, 能整除 ,所以有解。改成 : 且 ,无解。直观理解是:偶数乘任何整数再模 ,余数只能是偶数,到不了 。
模逆元是特例
当 时, 有唯一解,这个解叫 模 的逆元,记作 。于是
费马小定理给素数模一个快算法:若 是素数且 ,则
一般模数仍用扩展欧几里得更稳妥。
def modinv(a: int, m: int) -> int:
d, x, _ = egcd(a, m)
if d != 1:
raise ValueError(f"{a} 与 {m} 不互素,没有逆元")
return x % m同余方程组:中国剩余定理
一组两两互素的模
在模 下恰有一解。构造法是:
最后取 。
若模数不两两互素,仍可能有解,条件更严:对任意 ,
这时可以用两两合并:先解前两个,得到一个新的同余,再和下一个合并。
例四:韩信点兵
,,,。
- ,逆元 ,因为
- ,逆元
- ,逆元
除以 余 ,除以 余 ,除以 余 。
一份可直接用的实现
下面把「单方程」和「方程组」放在一起。单方程返回模 下全部互异解;方程组用两两合并,不要求模数互素。
from math import gcd
def egcd(a: int, b: int) -> tuple[int, int, int]:
old_r, r = a, b
old_s, s = 1, 0
old_t, t = 0, 1
while r:
q = old_r // r
old_r, r = r, old_r - q * r
old_s, s = s, old_s - q * s
old_t, t = t, old_t - q * t
return old_r, old_s, old_t
def solve_linear(a: int, b: int, m: int) -> list[int]:
"""解 a x ≡ b (mod m),返回 [0, m) 内全部解;无解返回空列表。"""
if m <= 0:
raise ValueError("模数必须为正")
a, b = a % m, b % m
d, x, _ = egcd(a, m)
if b % d:
return []
x0 = (x * (b // d)) % (m // d)
step = m // d
return sorted((x0 + step * t) % m for t in range(d))
def merge(a1: int, m1: int, a2: int, m2: int) -> tuple[int, int] | None:
"""合并 x ≡ a1 (mod m1) 与 x ≡ a2 (mod m2)。"""
d = gcd(m1, m2)
if (a2 - a1) % d:
return None
# 解 m1 * t ≡ a2 - a1 (mod m2)
t = solve_linear(m1, a2 - a1, m2)
if not t:
return None
lcm = m1 // d * m2
return (a1 + m1 * t[0]) % lcm, lcm
def solve_system(congruences: list[tuple[int, int]]) -> tuple[int, int] | None:
"""congruences 为 [(a, m), ...],表示 x ≡ a (mod m)。
有解返回 (x0, modulus),无解返回 None。"""
x, mod = 0, 1
for a, m in congruences:
merged = merge(x, mod, a, m)
if merged is None:
return None
x, mod = merged
return x, mod
if __name__ == "__main__":
print(solve_linear(3, 4, 7)) # [6]
print(solve_linear(6, 9, 15)) # [4, 9, 14]
print(solve_linear(4, 3, 6)) # []
print(solve_system([(2, 3), (3, 5), (2, 7)])) # (23, 105)跑一下就能对上前面的手算。solve_linear 的时间是欧几里得的 ,再加 枚举全部解;方程组是依次合并,模数变大时注意用 Python 整数即可,不必担心溢出。
写代码时容易踩的坑
先取模再求 。 可能是负数,Python 的 % 会收成非负,扩展欧几里得也能处理负数,但先规范化更不容易错。
特解要乘 ,不是乘 。漏掉这一步,方程就解成了 而不是 。
通解的步长是 ,不是 。 的相邻解相差 ,不是 。
合并方程组时,新模是 ,不是随便乘。模数不互素时,这一步决定解是否唯一。
若只要任意一个解,不必枚举 个,返回 即可。竞赛里常见「输出任意解或 」。
和更高次方程的边界
二次同余 在素数模上可以用 Cipolla 或 Tonelli–Shanks,那是另一套二次剩余的语言。更高次则进入原根、指数和离散对数。一次同余是这些算法的底座:求逆、CRT 拆模、把大模拆成素因子幂,几乎都会先回到 。
所以不必把一次同余看成小学奥数。它是数论算法里最常被调用的那一层。把存在性、特解和通解三件事分开,手算和代码就会对得上。
