☰
中国剩余定理CRT与扩展EXCRT:从数学原理到工程实现全解析
2026/10/14 13:19:25 网站建设 项目流程

1. 从一道“看起来无解”的题说起

去年带一个算法小组做模拟项目X,有个同学拿着一道数论题找我,说所有常规枚举思路都超时。题目本质是要解一个同余方程组:某个整数分别除以几个不同的数,余数各不相同,求最小的满足条件的数。单个同余式很简单,多个同余式放一起,很多人第一反应是暴力枚举——数据范围小还能勉强跑,模数一旦到10^9级别,直接歇菜。

我告诉他,这是个经典问题,中国剩余定理(Chinese Remainder Theorem,CRT)就是干这个的。更进一步,如果模数之间不互素,普通CRT会失灵,这时候要用它的扩展版本EXCRT。这篇就系统梳理一下CRT和EXCRT的数学原理、算法实现、常见坑点,以及我在实际写代码和刷题过程中积累的一些经验。适合对数论有一定基础、想彻底搞懂同余方程组解法的读者,也适合正在备战算法竞赛、需要快速上手这部分内容的同学。

2. 中国剩余定理的数学原理

2.1 同余方程组到底在说什么

先明确基本概念。所谓同余方程,就是形如:

x ≡ a (mod m)

表示x除以m的余数是a。一个方程很好理解,但实际场景往往是多个方程联立:

x ≡ a1 (mod m1) x ≡ a2 (mod m2) ... x ≡ ak (mod mk)

中国剩余定理解决的核心问题是:当模数m1, m2, ..., mk两两互素时,这个方程组在模M = m1 * m2 * ... * mk的意义下有唯一解。

我第一次接触这个定理时,觉得"唯一解"这个结论很神奇。为什么唯一?用生活类比:假设你有三个闹钟,第一个每3分钟响一次,第二个每5分钟响一次,第三个每7分钟响一次,现在知道第一次同时响是几点,那后续同时响的时间就固定了——每隔105分钟重复一次。CRT说的就是:满足条件的解每隔M就重复一次,而且在一个完整周期内只有一个。

这个唯一性给了我们一个强有力的工具:只要模数互素,不管方程组有多少个,都能在O(k log M)时间内找到解,而且解的结构是确定的。

2.2 互素条件为什么不能去掉

很多人刚学CRT时有个疑问:模数不互素行不行?答案是不一定不行,但不能直接用标准CRT。

考虑方程组:

x ≡ 1 (mod 2) x ≡ 2 (mod 4)

这个方程组用肉眼看就是矛盾的——x除以4余2,那x一定是偶数,偶数和模2余1矛盾。所以必须判断无解。

再看另一组:

x ≡ 3 (mod 6) x ≡ 5 (mod 10)

x = 15满足第一个(15 mod 6 = 3),也满足第二个(15 mod 10 = 5),有解。但如果直接套用CRT的公式是会出问题的,因为它要求模数互素,6和10的最大公约数是2,不满足条件。

标准CRT的核心步骤是:令M为所有模数的乘积,要求每个Mi = M / mi与mi互素,这样才能求Mi的逆元。如果模数不互素,Mi和mi就可能存在公因子,逆元根本不存在,算法直接崩溃。

所以CRT的适用边界非常清晰:模数两两互素,完美套用;模数不互素且要求解存在,必须用EXCRT。

2.3 构造性证明与通解形式

CRT不仅告诉我们有解,还给出了一种构造解法。设M = m1 * m2 * ... * mk,定义Mi = M / mi。由于模数两两互素,Mi与mi必然互素,因此Mi在模mi意义下存在逆元ti,满足:

Mi * ti ≡ 1 (mod mi)

那么方程组的解可以构造为:

x = Σ (ai * Mi * ti) mod M

验证一下:对任意第j个方程,当i ≠ j时,Mi包含因子mj,所以Mi mod mj = 0;当i = j时,Mi * ti ≡ 1 mod mi,所以这一项贡献ai。其他所有项在模mj下都归零,恰好满足x ≡ aj (mod mj)。

通解形式是x = x0 + k * M,k为任意整数。这正好解释了前面说的"每隔M重复一次"。

实际实现中,直接求每个Mi的逆元需要k次扩展欧几里得,复杂度O(k log M)。之前的做法是逐步合并,下面详细展开。

3. 经典CRT的算法实现

3.1 逆元的三种求法对比

求解CRT的第一步是求模逆元。我总结一下常用方法,按实际工程中的选型顺序讲。

第一种是扩展欧几里得算法。它直接求解方程a * t + m * b = gcd(a, m),当gcd(a, m) = 1时,t就是a在模m下的逆元。优点是适用范围广,不要求模数是素数,只要互素就能求。实现也很简单:

def exgcd(a, b): if b == 0: return (a, 1, 0) g, x1, y1 = exgcd(b, a % b) return (g, y1, x1 - (a // b) * y1) def modinv(a, m): g, x, _ = exgcd(a, m) if g != 1: raise ValueError("逆元不存在") return (x % m + m) % m

第二种是费马小定理。当模数p是素数时,a^(p-2) ≡ a^(-1) mod p,用快速幂求解。但前提是p必须是素数,限制太大,工程中不如exgcd通用。

第三种是线性求逆元。预处理1到n的逆元,用递推公式inv[i] = (m - m//i) * inv[m % i] % m,适合批量场景。但CRT通常是几个模数,没必要上线性预处理。

我的建议很直接:CRT场景下无脑用扩展欧几里得,因为模数是否素数不可控,exgcd是最稳妥的选择。

3.2 Python实现与边界处理

标准CRT实现代码如下,每个细节我都写清楚为什么这样处理:

def crt(remainders, moduli): # remainders: 余数列表 a1, a2, ..., ak # moduli: 模数列表 m1, m2, ..., mk M = 1 for m in moduli: M *= m result = 0 for a, m in zip(remainders, moduli): Mi = M // m ti = modinv(Mi, m) result = (result + a * Mi * ti) % M return result

有几个边界细节值得注意。

第一,乘法溢出。a * Mi * ti三个数相乘,如果M已经接近10^18,中间结果可能爆掉64位。解决方案是每项累加时及时取模,并且尽量用大整数语言内置处理。在C++里得用__int128或者配合快速乘,Python则天然支持大整数,比较省心。

第二,负数余数。真实业务中可能给出负余数,比如x ≡ -2 (mod 5),等于x ≡ 3 (mod 5)。所以在传给CRT之前要统一做处理:

a = a % m

第三,模数必须是正数且大于1。如果模数为1,任何整数模1都等于0,方程退化为无意义,要单独过滤掉。

3.3 逐个合并的另一种思路

经典CRT还有一种写法,不需要一次性求所有Mi的逆元,而是逐对合并。给定两个方程:

x ≡ a1 (mod m1) x ≡ a2 (mod m2)

设x = a1 + m1 * t,代入第二个方程:

a1 + m1 * t ≡ a2 (mod m2)

移项得:

m1 * t ≡ a2 - a1 (mod m2)

令g = gcd(m1, m2),如果a2 - a1不能被g整除,则无解;否则两边同时除以g,方程变为:

(m1/g) * t ≡ (a2 - a1)/g (mod m2/g)

此时由于m1/g与m2/g互素,可以求出逆元,得到t的一个特解t0。合并后的方程为:

x ≡ a1 + m1 * t0 (mod lcm(m1, m2))

这种逐对合并的方式是EXCRT的核心,也是我实际更推荐日常使用的方式,因为它的通用性更强。

4. 扩展CRT(EXCRT)推导与实现

4.1 标准CRT的局限与EXCRT的基本思路

标准CRT要求所有模数两两互素,但实际场景中这个条件经常不满足。密码学、RSA加速、时间戳换算等问题里,模数之间有没有公因子不是我们能控制的。

EXCRT的思路是“化归”:不管有多少个方程,先合并前两个,变成一个新方程,再与第三个合并,以此类推。每一步如果发现矛盾,整个方程组就无解。最终合并成一个同余方程:

x ≡ final_a (mod final_m)

这里的final_m是所有模数的最小公倍数,而不是把它们直接相乘。这个区别很重要——如果非要相乘,那相当于把所有模数都放大了很多倍,解空间会变得不正常。

4.2 两两合并公式的完整推导

这一步必须手把手推一遍,保证每个人都能从零算出来。

当前有两个方程:

x ≡ r1 (mod m1) x ≡ r2 (mod m2)

由第一个方程,设x = r1 + m1 * y,其中y是某个整数。代入第二个方程:

r1 + m1 * y ≡ r2 (mod m2)

化简:

m1 * y ≡ r2 - r1 (mod m2)

设g = gcd(m1, m2)。如果(r2 - r1) % g != 0,整个方程组无解。解释一下:左边m1 * y一定是g的倍数,模m2之后得到的余数也必须与r2 - r1在模g下同余,如果不同余就得不出任何整数y。

有解的情况下,方程两边同时除以g,得到:

(m1/g) * y ≡ (r2 - r1)/g (mod m2/g)

令mm = m1/g,rr = (r2 - r1)/g,mm2 = m2/g。因为gcd(mm, mm2) = 1,mm在模mm2下可逆,设inv_mm = modinv(mm, mm2),那么:

y ≡ inv_mm * rr (% mod mm2)

解出y的一个特解y0 = (inv_mm * rr) % mm2,于是x的一个特解是:

x0 = r1 + m1 * y0

合并后的方程是:

x ≡ x0 (mod lcm(m1, m2))

其中lcm(m1, m2) = (m1 / g) * m2。注意要先除以g再乘,避免溢出。

4.3 无解判定与溢出处理的完整代码

基于上述推导,我写了一个完整的Python实现,这是我在多个项目里反复用过的版本:

def exgcd(a, b): if b == 0: return (a, 1, 0) g, x1, y1 = exgcd(b, a % b) return (g, y1, x1 - (a // b) * y1) def modinv(a, m): if m == 1: return 0 g, x, _ = exgcd(a, m) if g != 1: raise ValueError("逆元不存在") return (x % m + m) % m def excrt(remainders, moduli): # 初始化:合并第一个方程 r = remainders[0] % moduli[0] m = moduli[0] for i in range(1, len(remainders)): r2 = remainders[i] % moduli[i] m2 = moduli[i] # 计算 g = gcd(m, m2) g = math.gcd(m, m2) # 检查是否有解 if (r2 - r) % g != 0: raise ValueError("同余方程组无解") # 合并两个方程 mm = m // g mm2 = m2 // g rr = (r2 - r) // g # 求解 mm * y ≡ rr (mod mm2) inv_mm = modinv(mm % mm2, mm2) y0 = (inv_mm * rr) % mm2 # 新方程的余数和模数 r = (r + m * y0) % (m // g * m2) m = m // g * m2 return r import math print(excrt([3, 5, 7], [4, 7, 9])) # 示例

注意里面有个容易踩的坑:modinv(mm % mm2, mm2)这里我写了mm % mm2,因为mm本身可能比mm2大,虽然理论上mm和mm2互素,但扩展欧几里得对负数或大数也能处理,取模一下更安全,也能避免一些欧几里得递归中的边界问题。

合并过程中r的更新,我直接用(r + m * y0) % new_m,new_m是更新后的模数。这里Python大整数不担心溢出,但如果用C++写,m * y0可能爆64位,必须边乘边模,或者用快速乘函数。

4.4 复杂度分析与选型建议

EXCRT每合并一个方程,核心操作是扩展欧几里得求逆元,复杂度O(log max(m1, m2))。总复杂度O(k log M),k是方程个数。这个复杂度即使在模数高达10^18时也依然高效。

相比之下,暴力枚举复杂度O(M),完全不可用;标准CRT虽然常数更小,但受限于互素条件。所以我的选型建议是:日常代码直接总是用EXCRT,它天然兼容CRT的场景,模数互素时它走的就是CRT合并的逻辑,模数不互素也能正确处理。没必要为了省那点常数去区分两种算法,工程上稳定性比微小的性能差异重要得多。

5. 应用场景与典型实战案例

5.1 大整数运算的拆分加速

CRT一个经典应用是“把大数拆成小数算”。假设我们需要对一个大整数N做高精度乘法、取模运算,N的位数很长,直接运算容易溢出。利用CRT的思想,可以选定一组两两互素的模数m1, m2, ..., mk,保证M > N * N的最大可能结果,然后先把所有运算在模mi下分别进行,最后通过CRT恢复真实结果。

这有点像把一个大蛋糕切成好几块,每一块单独称量,最后加总还原重量。好处是每块都很小,运算不会溢出,而且部分运算可以并行。密码学库中常见这种做法。

5.2 密码学RSA加速中的实际应用

RSA解密是模幂运算,模数n是两个大素数p和q的乘积。直接计算c^d mod n性能很差,利用CRT可以拆成两个模运算:

c^d mod p c^d mod q

计算完毕后再用CRT合并结果。这里的模数p和q互素,正好符合CRT条件。这样做的好处是指数运算的模数变小了,运算量大幅降低,解密速度提升接近4倍。

实际工程中,这么干还会顺带解决一个安全问题:如果在计算过程中某个校验不到位,攻击者可以通过错误注入拿到p或q,从而分解n。所以使用CRT加速时,一定要做重加密校验或使用随机化冗余,这些细节在密码学实现里是生死攸关的。

5.3 算法竞赛里的典型题目模式

我整理了竞赛中常见的CRT/EXCRT出题模式,帮助大家快速识别题型。

第一类是“裸CRT”题,明确告诉你模数两两互素,让你求解同余方程组。这类题比较简单,掌握基础板子就能过。

第二类是“EXCRT”题,模数明确不互素,但保证有解。需要识别出不能套裸CRT,改用EXCRT合并。

第三类是“CRT + 快速幂/数论函数”的组合题,比如要求解满足CRT条件的数中最大质因子、最小幂余之类。这类题核心是先解出x,再在此基础上做其它数论操作。

第四类是“CRT + DP”的构造题,题目不会直接给你同余方程组,但你把状态设计出来后,发现转移本质上是一堆同余条件。这种题型最有迷惑性,也最能区分是否真正理解CRT。

5.4 一个完整示例全流程跑通

我用一个稍微复杂的例子演示EXCRT各步骤的具体数值变化。设方程组:

x ≡ 2 (mod 3) x ≡ 3 (mod 5) x ≡ 2 (mod 7)

这是经典CRT的入门例题。M = 3 * 5 * 7 = 105。算每个Mi和逆元:

M1 = 35,35 mod 3 = 2,逆元是 t1 = 2(因为2*2=4≡1 mod 3) M2 = 21,21 mod 5 = 1,逆元是 t2 = 1 M3 = 15,15 mod 7 = 1,逆元是 t3 = 1

x = (2 * 35 * 2 + 3 * 21 * 1 + 2 * 15 * 1) mod 105 = (140 + 63 + 30) mod 105 = 233 mod 105 = 23。验证一下:23 mod 3 = 2,23 mod 5 = 3,23 mod 7 = 2,完全正确。

再用EXCRT手跑一遍合并流程。先合并前两个:

x ≡ 2 (mod 3) x ≡ 3 (mod 5)

g = gcd(3, 5) = 1,mm = 3,mm2 = 5,rr = 3 - 2 = 1。求3在模5下的逆元,即2(3*2=6≡1 mod 5)。y0 = 2 * 1 = 2。新模数 = 15,新余数 = 2 + 3 * 2 = 8。于是得到:

x ≡ 8 (mod 15)

再与第三个方程合并:

x ≡ 2 (mod 7)

g = gcd(15, 7) = 1,mm = 15,mm2 = 7,rr = 2 - 8 = -6 ≡ 1(这里先取模再判断也行,代码里直接算差值,然后取模)。15 mod 7 = 1,逆元是1。y0 = 1 * (-6) mod 7 = 1。新余数 = 8 + 15 * 1 = 23,新模数 = 105。最终x ≡ 23 (mod 105)。结果一致。

注意合并到第二步时,差值出现负数,代码中要对其取模处理,否则可能算出负的y0,虽然取mod之后结果等价,但为了可读性,建议统一用((r2 - r) % g)加%的方式提前归一。

6. 常见问题与排查技巧实录

6.1 负数取模的坑

在多数编程语言里,-5 % 3的结果是-2,而不是1。这会导致逆元计算和合并时的差值计算出现意外。

我习惯写一个统一的“归一化余数”函数:

def norm_remainder(a, m): return (a % m + m) % m

凡是进入CRT/EXCRT函数的余数,先归一到[0, m-1]区间,能省掉后面大量调试时间。这个习惯我是在一次跑模拟数据时踩了坑才养成的——当时所有答案都差一个模数,排查了半天才发现是负数余数问题。

6.2 乘法溢出的三种应对策略

模数范围小时怎么写都行,但一旦模数接近10^18,C++里m * y0直接溢出成负数,结果全错。我常用的三种策略:

一是用__int128,简单粗暴,但并非所有编译器都支持,可移植性差。

二是实现快速乘,用加法模拟乘法,边加边取模:

typedef long long ll; ll mulmod(ll a, ll b, ll mod) { ll res = 0; while (b) { if (b & 1) res = (res + a) % mod; a = (a + a) % mod; b >>= 1; } return res; }

三是利用浮点技巧或直接换Python大整数。工程上我优先用Python快速验证逻辑,再转到C++时替换为mulmod。

6.3 无解与合数模数的误判

一个常见误区:看到模数不互素就直接断言无解。实际不是这样。两个方程可能在某些余数搭配下有解,在另一些搭配下无解。必须在合并时检查(r2 - r) % g == 0,而不是提前判死刑。

另一个误区:把标准CRT用在非互素模数上,然后得到看似合理的错误答案。这个特别隐蔽,因为某些模数组合虽然不互素,但恰好解存在,标准CRT的结果可能恰好碰对,也可能错。为了避免这种随机性,我在项目里干脆一律使用EXCRT。

6.4 调试技巧:从小数据暴力验证

CRT题目的一个好处是可以用暴力验证。随意生成几个小模数、小余数,暴力枚举0到M-1找x,与EXCRT的结果对比。一旦不匹配,说明代码有bug。

我建议写一个辅助测试函数:

def brute_crt(remainders, moduli, limit=None): M = 1 for m in moduli: M *= m if limit is None or M < limit: for x in range(M): if all(x % m == a for a, m in zip(remainders, moduli)): return x return None

这个函数在模数很小时极其好用,我每次写完EXCRT都会先跑一轮暴力验证,确认板子没有低级错误再上正式数据。

6.5 合并顺序对结果的影响

从数学上,合并顺序不影响最终解的唯一性,因为每个方程的解都是模final_m意义下唯一的。但从数值稳定性和性能上,建议先合并模数较小的方程,因为中间模数增长更平缓,差值计算更不容易溢出。我自己在实测对比中,先合并小模数比先合并大模数的时候,中间乘积值平均能小一个数量级。

如果你的输入模数列表特别多,还可以考虑用分治合并,两两配对后递归合并,能把计算次数控制在较小常数内。不过大多数场景线性合并就足够了。

6.6 与动态规划结合的“伪同余”场景

最后分享一个容易踩的思维陷阱。有些题目描述里根本没有"余数"、"同余"这些词,但状态转移中出现类似"每隔x天循环一次"、"每个人报到第y次"的描述,本质就是同余问题。识别关键句很重要:“同时发生”、“同时满足某个周期条件”、“循环节”,这些词都暗示CRT可以介入。

我见过一个有趣的案例,某个任务调度系统需求是三个定时任务在特定时间点对齐,找下一个共同触发时间。三个任务周期互不整除,还要满足各自的偏移,这就是一个典型的EXCRT问题。把业务模型映射到数学同余方程组,比硬模拟整个调度周期高效得多。

7. 最后分享几个我踩坑后的固定习惯

写CRT相关代码之前,我现在一定会做三件事:归一化所有余数、统一用EXCRT而不是CRT、写完先跑暴力验证。这三件事看起来简单,却帮我省下了大量调试时间。很多AC代码看起来流畅优雅,实际上背后都是无数次被负数取模、边界溢出和模数互素误判折磨出来的。

另外,如果你在写密码学相关需求,记得CRT加速解密之后一定要加防护。我最初写RSA-CRT实现时,忽略重加密校验,后来读安全分析文章才发现攻击者可以利用错误注入还原出私钥参数。从那以后,凡是用CRT做密码加速,我都默认要加一次还原对比。

这个内容能扩展的方向还挺多,比如CRT在插值算法中的推广、Garner算法、CRT与多项式环的结合,甚至量子算法中对周期估计的辅助。但基础的CRT和EXCRT就够大多数场景使用了。先把这篇里的原理和代码吃透,遇到复杂问题再逐步往上叠加,会顺手很多。

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

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

立即咨询