上个月我调一个日程小工具,功能很简单:输入任意日期,返回它对应星期几。测到1900年1月1日这种老边界时,结果总是差一天。我一度以为是闰年判断有误,后来一层层排查,发现根子不在日期算法上,而在我对“余数”的直觉出了偏差。具体来说,就是对减法同余原理没有真正吃透。
这篇文章就以我那次翻车为引子,把这个原理从头到尾讲清楚:同余的数学定义为什么天然和减法绑定,负余数如何让大数计算“降维”,星期推算、校验码、散列表这些场景里的具体玩法,以及不同编程语言里负数取模五花八门的坑。想解决日期边界问题的程序员、被取模bug折磨的开发者,或者刚接触数论想建立直觉的学生,应该都能从里面找到有用的东西。
1. 从一次星期计算翻车说起:同余到底在说什么
1.1 那个差了一天的Bug
我的日程工具用的是“基准日法”:固定一个已知星期几的日期作为基准点,算出目标日期到基准日的天数差,再对7取模得到星期几。比如基准日取2024年1月1日,这一天是星期一;要算2025年1月1日,中间隔着366天(2024是闰年),366除以7余2,所以它是星期三,完全正确。
问题出在往过去回溯的场景。要算2023年1月1日,目标日期在基准日之前,天数差是负的:-365。我在代码里直接写了days_diff % 7,然后拿这个结果去索引一个星期字符串数组。不同语言里对负数取模的规则不一样,有的返回-1,有的返回6。我当时用的环境返回的是负数,数组下标变成-1,程序没报错,却取到了数组最后一个元素,于是整个星期序列全部错位。这个Bug让我意识到:我根本没想过“余数”还有正负这回事。
1.2 同余的严格定义与通俗理解
先回到数学本体。给定一个正整数m,如果两个整数a和b的差a-b能被m整除,就说a和b在模m下同余,写成:
a ≡ b (mod m)
这个定义有三个要点值得划重点:
- 这里的m叫模数,它必须是一个正整数;
- 对a和b没有限制,负整数完全可以参与;
- 判断标准只有一个:
a-b是不是m的整数倍。
举个例子:2026年1月1日和2024年1月1日之间隔着730天(2024、2025两年中有一个闰年,一个平年,共366+364=730天)。730除以7得104余2,所以730不是7的整数倍,这两天不同余。而731天的话,731-7×104=3?等等,7×104=728,731-728=3,也不是倍数;要到735才是倍数,因为7×105=735。也就是说,只有天数差是7的倍数时,两个日期才是同余的——这也是“星期几”这个东西的本质:它是一个以7为周期循环的余数类。
我当初的Bug本质上是:-365和某个正余数x满足-365 ≡ x (mod 7),但x不是-365被整除后的“带符号余数”。数学上的余数本来就是一整类值,比如模7下,-1、6、13其实都在同一个等价类里,代码却只认其中一个。
1.3 为什么“减法”是刻在同余定义里的
很多人一开始学同余,会误以为核心操作是“取余”,所以天天研究n % m。实际上,同余关系本身就是基于减法来定义的,它的起点不是除法,而是“做差”。
从定义出发:a ≡ b (mod m) 的意思是存在某个整数k,使 a - b = km。这个等式全部是减法和乘法,没有任何取模符号。换句话说,判断两个数是否同余,你只需要问一句话:它们的差是不是模数的倍数?
这个洞察的直接结果是:很多同余问题用减法解决,比用除法取余更自然。比如问:2019和2026在模7下是否同余?用除法,你得分别算2019÷7的余数和2026÷7的余数;用减法,直接算2026-2019=7,一眼看出是7的倍数,完了。减法把两步变成一步,把大数差距变成一个小数。
2. 减法同余原理的核心:负余数与“降维”视角
2.1 从“找最近倍数”看减法降维
我第一次真正理解减法同余的威力,是用它做心算取模的时候。随便给我一个数,比如9843,要算它除以7的余数。传统做法是列除法竖式,但更快的思路是:找一个离9843最近的7的倍数,然后做减法。
7×1400=9800,9843-9800=43;再找7的倍数,42是7×6,于是43-42=1。整个过程就是连续减法,连除法都不用。这就是减法降维:每次减掉一个模数的倍数,把原数变成更小的、与原数同余的数。这本质上就是欧几里得算法里“辗转相减”的同余版本。
用数学语言写就是:
9843 ≡ 9843 - 7×1400 ≡ 43 ≡ 43 - 7×6 ≡ 1 (mod 7)
每一步减掉的都是模数7的倍数,同余关系保持不破,但数字从四位数降到了个位数。多练几次你会发现,心算取模的终点不是“算出商”,而是“把数字减到比模数小”。
2.2 负余数:钟表盘上的另一个等价位置
减法降维里有一个经常被忽略的视角:不一定要往小减,还可以“往负方向看”。
拿钟表系统举例,模数是12。问:钟表上10点再往前拨2小时,是几点?答案是8点,但更“数学正确”的说法是:10-2=8,8 ≡ -4 (mod 12),因为8与-4相差12。钟表盘上8点那个刻度,同时可以代表20点(8+12)、-4点(8-12),它是同一类等价位置。
如果我把题目反过来:某个数除以12余10,那它在模12下还余多少?答案不唯一:10、-2、22都是同一个等价类。其中-2特别有用,因为10可以通过“借一位”变成-2:10 = 12 - 2,所以10 ≡ -2 (mod 12)。这种把正的、偏大的余数转换成负余数的操作,在计算中经常能把“需要进位”的复杂流程变成“往回调一点”的简单减法。
举个实际例子。计算47除以12的余数:47 = 12×4 - 1 = 48-1,所以47 ≡ -1 (mod 12),而-1在非负余数体系里对应12-1=11。心算时我甚至不需要知道47里包含几个12,只需要知道它“离48差1”,就直接得出了余数11。这种“离就近倍数差多少”的思维,比“包含多少个完整倍数”要快得多。
2.3 减法同余的三大运算规则
把减法同余当成工具,至少要掌握它的三个配套性质,这些都是从定义直接推出来的:
- 同余的可减性:若 a ≡ b (mod m),c ≡ d (mod m),则 a-c ≡ b-d (mod m)。两个同余式子可以直接相减得出新的同余关系。
- 同余的加法移项:a + b ≡ c (mod m) 等价于 a ≡ c - b (mod m)。把加法项移到另一边时,减法照常进行。
- 同余的可乘性:若 a ≡ b (mod m),则 ka ≡ kb (mod m)。乘法不破坏同余关系,这为后面的快速幂取模做了铺垫。
这三个用处都很大,后面实战部分都会用到。尤其是第一条可减性,它是在解同余方程组时做“消元”的根本依据。
3. 实战演练:心算星期几、校验码和散列取模
3.1 星期推算的正确打开方式
回到我那个日程工具的核心逻辑。已知基准日2024年1月1日是星期一,要算任意日期星期几,关键就在天数差对7取模。用减法同余可以做到全程心算。
先算一个正数场景:2025年1月1日,间隔366天。366的最近7的倍数是364=7×52,于是366 ≡ 2 (mod 7),即从星期一开始往后推2天,是星期三。
再算一个负数场景:2023年1月1日,间隔-365天。这一回可以直接对负数做减法降维:-365位于-364和-371之间,-364=7×(-52),所以-365 ≡ -364-1 ≡ -1 (mod 7)。-1的意思是从星期一往前推1天,也就是星期日。验证一下:2023年1月1日确实是星期日。
这里有一个关键手法:当负数的绝对值很大时,先把它往“最近的负倍数”上靠。比如-365,最近且不超过它的7的倍数是-371(因为-371=-53×7,且-371 ≤ -365),两者相差6;于是-365 ≡ -371+6 ≡ 6 (mod 7),同样得到星期日。两种减法的角度不同,结果却一致,因为-1和6都属于同一等价类。
3.2 用同余算ISBN-10校验码
图书的ISBN-10校验码是同余理论在现实世界中最干净的缩影。它的规则是:用前9位数字分别乘以10、9、8、7、6、5、4、3、2,求加权和,再用模11算出校验码。
随便设一个前9位:1-234-56789。加权和:
1×10 + 2×9 + 3×8 + 4×7 + 5×6 + 6×5 + 7×4 + 8×3 + 9×2 = 10 + 18 + 24 + 28 + 30 + 30 + 28 + 24 + 18 = 210。
210在模11下的余数:找最近的11倍数,209=11×19,差1,所以210 ≡ 1 (mod 11)。校验码的计算公式是11减掉这个余数后再取模:(11-1) mod 11 = 10。因为校验码允许0到10共11个值,当结果是10时,国际上规定用X表示。所以完整编号是1-234-56789-X。
整个过程没有大家想象中那么玄,背后就是减法同余:先找到加权和离哪个11倍数最近,算出差,再决定校验位补多少能让整个加权和“凑成”11的整数倍。每本书的校验码都等于“为了让总和被11整除还差的那个数”。
3.3 散列表取模:不只下标,还有冲突概率
散列表里最常见的操作是把一个较大的哈希值映射到固定数量的桶,做法是hash % bucket_count。这里减法同余有两层应用。
第一层是快速计算。比如哈希值是2025,桶数是12。如果心算,可以先看12×168=2016,2025-2016=9,所以下标是9;也可以看12×169=2028,2028-2025=3,所以2025 ≡ -3 (mod 12),负数余数转过去就是12-3=9。两条路,结果一致,第二条在某些场景下不需要算两次乘法。
第二层是理解冲突概率。很多人在面试时被问到“为什么桶数选质数比较好”,答案的核心就是:如果桶数是合数,哈希值的某些分布会被“折叠”。比如桶数是10,那么高位为5和15的情况在取模后都落到5,实际上模10只保留了十进制低位,高位信息全丢了。从同余角度看,模10把相差10的整数倍的所有数字视为同一类,这种“丢信息”的程度远大于模11。质数模数能够避免因约数关系造成的规则性重叠,本质上仍然是一个同余类的划分问题。
4. 编程世界里的同余陷阱:负数取模跨语言对比
4.1 同一个表达式,各语言给出不同答案
我的日程工具那个Bug,放到代码层面,根源是不同语言对负数取模采用了不同约定。下面这几个表达式在不同语言里的结果差异,值得每个写过跨平台代码的人记在心里:
| 表达式 | C / JavaScript | Python | 数学上的“非负余数” |
|---|---|---|---|
| 7 % 3 | 1 | 1 | 1 |
| -7 % 3 | -1 | 2 | 2 |
| 7 % -3 | 1 | -2 | 1 |
| -7 % -3 | -1 | -1 | 2 |
C和JavaScript采用截断除法:商直接向零取整,余数符号与被除数一致。所以-7除以3,商是-2(向零取整),余数就是-1。Python采用向下取整除法:商向负无穷取整,余数符号与除数一致,所以-7除以3,商是-3,余数是2。
从数学角度看,Python的返回值更接近“非负余数”,因为它保证结果落在0到m-1区间里;C族的做法则是工程妥协,它让取模运算和整数除法保持“a = (a/m)*m + a%m”这个等式,哪怕余数是负的。这两种设计各有取舍,问题只在于你写代码的时候根本没意识到还有一个设计取舍。
4.2 一个通用的修正写法
针对C/JavaScript类的语言,最稳妥的写法是:
int mod(int a, int m) { int r = a % m; return r < 0 ? r + m : r; }这个修正背后的逻辑,正是减法同原:在模m下,r和r+m是同一个等价类。当r是负数时,加上m就把它变成了0到m-1范围内与r同余且非负的数;当r本来就非负时,不需要任何处理。
如果不想写分支,也可以用表达式((a % m) + m) % m。它的原理是:先得到可能为负的r,加m后必然落在(-m+m, m+m)区间即(0, 2m)之间,再取一次模,如果大于等于m就会减掉m回到正确区间。整个过程就是在做两次“减法同余修正”。代价是多了两次取模运算,在C语言这类性能敏感环境里,非热路径可以接受,热路径还是建议用分支版本。
4.3 位运算优化:2的幂模数下的陷阱与甜头
当模数m是2的幂时,很多程序员会用位运算a & (m-1)替代取模,因为按位与的速度远快于除法。比如m=8,m-1=7,25 & 7 = 1,和25%8的结果一致。
但这里有一个和负数取模纠缠在一起的坑:C语言里-25 & 7的结果是7,而-25 % 8是-1。位运算直接操作补码,它返回的结果对应的是“二进制低位”,并不是数学上的非负余数。也就是说:
int a = -25; int r1 = a & 7; // 结果为7,看似没问题 int r2 = a % 8; // 结果为-1 num_buckets(8) // 如果想用a & 7作下标,会走上另一个分支在这个坑上我吃亏过:某个内部工具用位运算优化了取模,结果遇到负数输入,散列分布整体错乱。后来我定了一条规矩:位运算优化只用在已经保证输入非负的场景,有负数可能性就用分支修正版本,哪怕慢一点,正确性优先。
5. 踩坑记录与进阶玩法
5.1 坑一:对负天数直接取模,后果很隐蔽
回到文章开头。我当时的代码写成:
const dayIndex = (daysDiff % 7 + 7) % 7;其实已经做了负修正,所以真正的问题不在这一行,而在另一个地方:我把daysDiff的算盘打错了。计算1900年1月1日到2024年1月1日的天数时,我只数了1900年到2023年的闰年个数,却没有把“1900年本身是不是闰年”单独拎出来判断。1900能被100整除但不能被400整除,所以它不是闰年,我又给它加了一天。这多出来的一天经过模7运算后,直接把结果推偏了一天。
这类bug的共同特点是:报错位置和根因位置相隔很远。表层是余数不对,深层是计算天数时的一个条件漏判。排查时先确认“进入取模前的那个整数本身是否正确”,再去怀疑取模逻辑,否则容易在错误方向上空转。
5.2 坑二:整除边界上的0不是没有余数
模运算最容易被忽略的边界是“刚好整除”的情况。100 mod 10等于0,这一点不难;难的是在减法同余的框架下处理它。比如要算1004在模4下的余数,找最近的4倍数:1004本身就是4的倍数,余数是0,减法路径是1004-1004=0。看起来简单,但如果你用“往大拿一个倍数再减回来”的套路,比如用1008,1004-1008=-4,-4 ≡ 0 (mod 4),结果也是0,只是中间多了个负余数转换。两种路径都能得到0,但如果你在代码里把负余数直接当成最终答案,没有做非负修正,0这个值就会悄悄变成-4甚至更大的负数。边界值上出错,测试还未必能覆盖到。
5.3 进阶玩法:快速幂取模全程用减法降维
取模运算还有一个高频场景是快速幂。算7的10次方再模13,直接先算7^10=282475249再取模,不是不行,但对大指数来说内存和时间都不现实。正确做法是每一步都做模运算,中间量永远保持很小:
7² = 49,找13的倍数,39是最接近的,49-39=10,所以7² ≡ 10 (mod 13); 7⁴ = (7²)² ≡ 10² = 100,100-91=9,所以7⁴ ≡ 9 (mod 13); 7⁸ = (7⁴)² ≡ 9² = 81,81-78=3,所以7⁸ ≡ 3 (mod 13); 7¹⁰ = 7⁸ × 7² ≡ 3 × 10 = 30,30-26=4,所以最终答案是4。
验证一下,282475249除以13得21728865余4,完全一致。整个过程里,除了第一步需要计算平方,后面全是两位数以内的减法降维。这就是减法同余在大数运算里最实用的形态:不要等到最后才取模,每一步都减到离模数最近,数字自然小,计算自然快。
5.4 进一步扩展:线性同余方程组的减法消元
如果觉得前面的应用还不过瘾,可以看一个进阶玩法:解同余方程组。比如:
x ≡ 2 (mod 3) x ≡ 3 (mod 5)
设x=2+3k,代入第二个式子得到2+3k ≡ 3 (mod 5)。移项:3k ≡ 1 (mod 5)。这时用减法逐个试k:k=0不行,k=1时3k=3,3与1在模5下差2,不行;k=2时3k=6,6-5=1,刚刚好。于是k=2,x=2+3×2=8。验证:8除以3余2,除以5余3,正确。
这个例子里,代入消元和试数靠的都是减法同余:把“差”看成判别标准,把“最近的倍数”当成参照锚点。遇到更大的模数组合时,这套思路可以配合中国剩余定理使用,但底层依然是你我上面反复练习的那个操作:做差、找倍数、化余数。
我在实际工作中,把减法同余从“数学小技巧”变成了“调试工具”。遇到取模相关的边界问题,我会先在草稿纸上用减法把输入值降一遍,确认数学上的正确余数,再对照代码里不同语言的取模约定,几乎每次都能快速定位是计算错误还是约定不一致。这套流程建议你也能养成:先心算一遍,再查代码。次数多了之后,负数取模、闰年边界、整除归零这些问题,都会变成条件反射级的判断,而不是又一次深夜排查的起点。