开始前,会这些就够了
- 先读 循环表与模逆,知道余数、互素与模逆的含义。
- 会读整数除法、取余和返回值;本篇不重复推导扩展欧几里得。
第一张循环表要求位置除以6余2,第二张要求除以9余5。每条条件单独都有很多答案,但我们需要的是同时满足两张表的位置。模数6和9不互素,并不意味着一定无解。
这次函数的结果必须包含两部分:从哪里开始,以及隔多远重复。只给一个碰巧满足条件的数字,还不足以说明所有答案。
把例子带到自己的电脑上
下载完整练习包
需要 Python 3.10 或更新版本,无第三方依赖;制作时实测 Windows / Python 3.14.0。使用标准库,不需要安装第三方包。下载 ZIP 后选择“全部提取”,进入能看到 README.md 的文件夹。
下载 ZIP · 5 个文件包里有什么
- congruence_merge.py:完整可运行示例
- README.md:运行说明与独立练习
- observed.stdout.txt:本次实际输出
- verification.json:源码和产物验证记录
- SHA256SUMS.txt:内容摘要
在这个文件夹打开 PowerShell,逐行运行
python --version
python -B congruence_merge.py解压到独立练习文件夹,在含 congruence_merge.py 的目录打开终端运行。仅合并两条精确整数同余;模数2..1000000、余数绝对值至多10^12;独立直接枚举参考模数2..20。
找不到命令或文件时,先按 README 检查环境和所在目录。文中标出的阅读时间只估算文章篇幅,安装环境和动手练习可按自己的节奏进行。
两张余数筛子,有没有共同留下的数?
第一条要求 x 除以 m 余 a,第二条要求 x 除以 n 余 b。观察一个完整重复周期中的整数,看看有没有同时满足两条要求的位置。
蓝色并标“①”:只通过第一条;橙色并标“②”:只通过第二条;绿色并标“都”:同时通过;灰色“—”:两条都不通过。
共同因子 g = gcd(6, 9) = 3;余数之差 b − a = 3,能被 g 整除。
全部整数解:x = 14 + 18k,k 为任意整数。
这两张筛子的共同重复周期为 lcm(6, 9) = 18,小于直接相乘得到的 54。在这个周期中,解的规范代表恰好一个。
页面模数限定为 2 到 12,格子列出一个周期;正文程序另外穷举更广的输入,核对负余数归一化和约分后模数为 1 的边界。这是整数约束,不包含现实排班中的时区或夏令时。
先列几项,看到共同位置与冲突位置
x≡2(mod6)允许2、8、14、20、26、32……;x≡5(mod9)允许5、14、23、32、41、50……。14、32、50同时出现,间隔为18。这已经给出一个可回查的猜想:答案应是14再加18的整数倍。
现在只把第二条余数5改成4。第一条意味着x除以3余2;第二条意味着x除以3余1。同一个整数不可能同时满足这两件事,所以即使两条条件单独合法,合在一起也会无解。
这里求的是精确整数约束,不按接近程度取一个折中数。无解要返回明确的None及冲突证据,不能悄悄舍弃其中一条。
公因子先决定:两条要求有没有互相打架
设两条约束为x≡a(mod m)、x≡b(mod n),g=gcd(m,n)。因为g同时整除m和n,两条条件又分别要求x≡a(mod g)、x≡b(mod g)。所以a和b对g必须有同样余数,也就是g必须整除b−a。
这不仅是必要检查。把x写成a+mt并代入第二条,得到mt≡b−a(mod n)。只要差值能被 g 整除,就可以把系数、差值和模数一起除以g,成为(m/g)t≡(b−a)/g(mod n/g)。此时m/g与n/g互素,能够解出t。
这一步缩减的是整个同余关系,不能只把等号两边除掉g却保留原模数。前一篇已讨论模逆,本篇直接把它作为缩减之后的工具;主要任务是判断并合并两条约束。
找到一个代表值,也别漏掉约分后模数为1
对a=2、m=6、b=5、n=9,g=3,缩减后为2t≡1(mod3)。2在模3下的逆元仍是2,因此取t=2,得到x=2+6×2=14。最后把代表值规范到合并周期内,再返回给调用者。
代码先处理相容性,再调用pow(m/g,-1,n/g)求缩减后的逆元。求逆的条件已经由约去最大公因子保证,不能在原始6与9上直接强行求逆。
还有一个容易遗漏的分支:若n/g=1,说明n整除m。相容检查一旦通过,第二条没有增加限制;直接取t=0即可,不需要求模1下的逆元。本例x≡5(mod12)、x≡1(mod4)返回(5,12),trace中的inverse为null。
两个模数相同也按这个逻辑处理:归一化后余数相同就保留原约束,不同就明确无解。不能把相同模数一律拒绝。
为什么重复周期是18,而不是6乘9得到的54?
设r已经满足两条条件。另一个答案x与r的差必须同时是m和n的倍数,因此必须是L=lcm(m,n)的倍数。反过来,给r加任意L的整数倍,两条余数都不会改变。这样才证明全部解恰好是r+Lk。
代码用L=m×(n/g)计算最小公倍数,并返回0≤r<L的代表值。6与9的L为18;在0到53这个乘积窗口里,程序实际找到14、32、50三个答案。
如果只报告“14加54的整数倍”,里面的每个数确实都满足两条条件,却漏掉了32和50所属的答案。一个结果能代回成功,与结果描述涵盖所有解,是两项不同的验收。只有模数互素时,L才等于m×n。
换成负余数或交换输入,不能改变问题本身
函数先把a归一成a%m、b归一成b%n。把一个余数加上或减去本身模数的整数倍,不会改变原约束。比如两条都写为余−4、模数分别为6和9,仍对应前面的(2,6)、(5,9),结果同为(14,18)。
求解过程选择先写x=a+mt,所以中间的t、逆元和缩减模数会随输入次序改变;最终规范代表值与最小周期则应相同。测试分别核对结果相等,不能强求两次trace逐字段一致。
教学入口要求模数为2到1,000,000的整数,余数绝对值不超过10¹²,明确拒绝布尔值、浮点值和字符串。返回的周期最大可能到10¹²;这是两条约束的接口,不承诺把返回值无限接回同样受限的入口。
让小范围逐个试数,核对每一种余数组合
保存为 congruence_merge.py,执行 python -B congruence_merge.py。possible.solution 为 [14,18],impossible.solution 为null,redundant.solution 为[5,12];JSON列表分别表示代表值和周期。下面是此次实际运行输出。
独立参考不用gcd、模逆或合并公式。它先从max(m,n)起逐个找同时被m和n整除的最小正数,再在一个周期内逐个检查两个取余条件,直接得到无解或唯一代表值。
对m、n从2到20,以及a从0到m−1、b从0到n−1的全部43,681组系统,测试比较直接枚举、交换输入次序和余数平移。实际有31,713组相容、11,968组无解;相容结果中576组触发约分后模数为1分支。加上具体例子和11项非法输入,共通过174,743项检查。
展开收起代码 · 小实验
长行可在代码区域内左右滚动,按 Tab 可把焦点移入代码区。
"""Merge two exact congruences; non-coprime moduli may agree or conflict."""
import json
from math import gcd
def merge_with_trace(a, m, b, n):
if any(type(value) is not int for value in (a, m, b, n)):
raise ValueError("all inputs must be integers, not booleans")
if not 2 <= m <= 1_000_000 or not 2 <= n <= 1_000_000:
raise ValueError("moduli must be in 2..1000000")
if abs(a) > 10**12 or abs(b) > 10**12:
raise ValueError("residue magnitude must be at most 10**12")
a, b = a % m, b % n
g = gcd(m, n)
difference = b - a
reduced_m, reduced_n = m // g, n // g
trace = {"normalized": [[a, m], [b, n]], "gcd": g,
"difference": difference, "residues_mod_gcd": [a % g, b % g],
"reduced_modulus": reduced_n}
if difference % g:
trace["compatible"] = False
return None, trace
# x=a+m*t; after division by g, reduced_m and reduced_n are coprime.
if reduced_n == 1:
inverse = None
t = 0 # The second congruence adds no restriction after compatibility.
else:
inverse = pow(reduced_m, -1, reduced_n)
t = (difference // g * inverse) % reduced_n
period = m * reduced_n
representative = (a + m * t) % period
trace.update({"compatible": True, "reduced_coefficient": reduced_m,
"reduced_right_side": difference // g,
"inverse": inverse, "t": t, "period": period})
return (representative, period), trace
def merge_congruences(a, m, b, n):
return merge_with_trace(a, m, b, n)[0]
def direct_oracle(a, m, b, n):
"""No gcd, inverse or merge formula: find a common period and scan it."""
if not 2 <= m <= 20 or not 2 <= n <= 20:
raise ValueError("oracle moduli limited to 2..20")
period = next(value for value in range(max(m, n), m * n + 1)
if value % m == 0 and value % n == 0)
matches = [value for value in range(period)
if value % m == a % m and value % n == b % n]
if len(matches) > 1:
raise AssertionError("more than one representative in minimum common period")
return (matches[0], period) if matches else None
def main():
checks = 0
systems = 0
compatible = 0
reduced_modulus_one = 0
def check(condition):
nonlocal checks
if not condition:
raise AssertionError("congruence verification failed")
checks += 1
for m in range(2, 21):
for n in range(2, 21):
for a in range(m):
for b in range(n):
wanted = direct_oracle(a, m, b, n)
actual, trace = merge_with_trace(a, m, b, n)
check(actual == wanted)
check(merge_congruences(b, n, a, m) == wanted)
check(merge_congruences(a - 2 * m, m, b + 3 * n, n) == wanted)
if actual is not None:
representative, period = actual
check(0 <= representative < period
and representative % m == a
and representative % n == b
and (representative + period) % m == a
and (representative + period) % n == b)
compatible += 1
reduced_modulus_one += trace["reduced_modulus"] == 1
else:
check(trace["residues_mod_gcd"][0] != trace["residues_mod_gcd"][1])
systems += 1
possible, possible_trace = merge_with_trace(2, 6, 5, 9)
impossible, impossible_trace = merge_with_trace(2, 6, 4, 9)
redundant, redundant_trace = merge_with_trace(5, 12, 1, 4)
check(possible == (14, 18))
check(impossible is None)
check(redundant == (5, 12) and redundant_trace["reduced_modulus"] == 1)
check(merge_congruences(7, 6, 1, 6) == (1, 6))
check(merge_congruences(1, 6, 2, 6) is None)
check(merge_congruences(-4, 6, -4, 9) == (14, 18))
check(merge_congruences(10**12, 1_000_000, -10**12, 1_000_000) == (0, 1_000_000))
product_window = [x for x in range(6 * 9) if x % 6 == 2 and x % 9 == 5]
check(product_window == [14, 32, 50])
invalid = [(0, 1, 0, 2), (0, 0, 0, 2), (0, -2, 0, 2),
(0, 2, 0, 1_000_001), (0, True, 0, 2), (True, 2, 0, 3),
(0.0, 2, 0, 3), (0, 2, "0", 3), (0, 2, 0, 3.0),
(10**12 + 1, 2, 0, 3), (0, 2, -10**12 - 1, 3)]
for args in invalid:
try:
merge_congruences(*args)
except ValueError:
check(True)
else:
raise AssertionError("invalid congruence input accepted")
report = {"checks_passed": checks, "exhaustive_systems": systems,
"compatible_systems": compatible, "incompatible_systems": systems - compatible,
"compatible_reduced_modulus_one_cases": reduced_modulus_one,
"possible": {"solution": possible, "trace": possible_trace},
"impossible": {"solution": impossible, "trace": impossible_trace},
"redundant": {"solution": redundant, "trace": redundant_trace},
"solutions_in_product_window_0_to_53": product_window,
"invalid_inputs_rejected": len(invalid),
"scope": "two exact integer congruences; normalized solution modulo lcm; no real calendar model"}
print(json.dumps(report, indent=2))
if __name__ == "__main__":
main()
怎样运行:在独立练习文件夹中执行 python -B congruence_merge.py。2026-10-18 已在 Windows、Python 3.14.0 运行此份源码并通过自带检查。仅合并两条精确整数同余;模数2..1000000、余数绝对值至多10^12;独立直接枚举参考模数2..20。
运行后对照结果
{
"checks_passed": 174743,
"exhaustive_systems": 43681,
"compatible_systems": 31713,
"incompatible_systems": 11968,
"compatible_reduced_modulus_one_cases": 576,
"possible": {
"solution": [
14,
18
],
"trace": {
"normalized": [
[
2,
6
],
[
5,
9
]
],
"gcd": 3,
"difference": 3,
"residues_mod_gcd": [
2,
2
],
"reduced_modulus": 3,
"compatible": true,
"reduced_coefficient": 2,
"reduced_right_side": 1,
"inverse": 2,
"t": 2,
"period": 18
}
},
"impossible": {
"solution": null,
"trace": {
"normalized": [
[
2,
6
],
[
4,
9
]
],
"gcd": 3,
"difference": 2,
"residues_mod_gcd": [
2,
1
],
"reduced_modulus": 3,
"compatible": false
}
},
"redundant": {
"solution": [
5,
12
],
"trace": {
"normalized": [
[
5,
12
],
[
1,
4
]
],
"gcd": 4,
"difference": -4,
"residues_mod_gcd": [
1,
1
],
"reduced_modulus": 1,
"compatible": true,
"reduced_coefficient": 3,
"reduced_right_side": -1,
"inverse": null,
"t": 0,
"period": 12
}
},
"solutions_in_product_window_0_to_53": [
14,
32,
50
],
"invalid_inputs_rejected": 11,
"scope": "two exact integer congruences; normalized solution modulo lcm; no real calendar model"
}规范结果、完整解集与现实周期要分开
返回None时,trace保留两条约束在g上的不同余数;有解时,返回规范代表值和最小周期。验收既检查代表值代回两式,也检查周期为何同时保持两条条件。小范围穷举帮助找错,普遍结论来自前面的相容与差值证明。
正式合并的主要工作是最大公约数和缩减后的模逆,欧几里得式算法按输入位数增长的迭代处理;大整数除法、乘法和取余各自仍有成本,不能因为只写几行就叫常数时间。参考实现为了独立而逐个试数,仅限模数2到20。
课程讲义与SymPy文档给出了相同问题的边界和接口参考。本文没有安装或运行SymPy,也没有把只适用互素模数的直接构造公式改个总周期就当成通用算法。
源码只用标准库,在Python 3.14.0实跑,并通过3.10语法解析;未另跑3.10解释器。它处理精确整数,不包含时区、夏令时、浮点近似、真实排班或密码学安全保证。
记住这些要点
- 非互素模数不必失败,先检查两条余数是否在gcd上相容。
- 相容后同时约去公因子,再求缩减后的逆元。
- 约分后模数为1表示另一条约束已被包含。
- 完整答案是规范代表值加lcm周期的整数倍,不能随意换成模数乘积。
别把例子用过头
- 见到模数不互素就直接宣布无解。
- 约去共同因子时忘记缩减模数。
- 只验一个数代回成功,却没有验完整周期。
- 要求交换输入后的中间trace也完全相同。
自己试一小步
把两条要求合并后,怎样证明没有遗漏答案?
- 先研究x≡3(mod8)、x≡7(mod12),手工写出相容检查与计划返回格式。
- 把第二条余数7改成6,再比较是否仍有解。
- 为两组输入各做交换次序与余数平移,并用一个最小共同周期内的逐个枚举核对。
做到这些,就算完成
- 有解时返回(r,L),满足0≤r<L,r代入两式成立,L是两个模数的最小正公倍数。
- 无解时给出两条余数在最大公因子下互相冲突的证据。
- 交换次序或加减各自模数的整数倍,不改变规范求解结果;允许中间trace不同。
查原始资料
想确认一个细节,先看这份资料对应的部分,不必从头读完所有文档。
- CP-Algorithms 作者:Chinese Remainder Theorem (新标签页)↗
互素条件下的构造,以及非互素时无解或在最小公倍数模下唯一的区别。
- SymPy 官方:solve_congruence 与 crt (新标签页)↗
非互素约束可返回无解或代表值/模数;跳过互素检查可能产生错误结果。
- UC Berkeley CS70:Note 6 Modular Arithmetic (新标签页)↗
非互素约束必须在公因子上相容,范围应使用 LCM;不能只替换互素公式里的乘积。