聊聊编程里的“魔法棒”:取余运算(Modulo)
💡 写在前面: 最近面试被问到一个倒计时相关问题,又一次用到了取余(Modulo)。说实话,刚入行那会儿,总觉得这玩意儿不就是小学数学里的
求余数吗?除了面试题里用来判断奇偶数,平时好像也没啥大用。但随着代码写得越来越多,逐渐发现
%符号背后其实隐藏着一种处理数据的思维模型——它能把无限延伸的线性世界,折叠成有限可控的 周期世界。今天想和大家分享一下我对取余的重新思考,看看它是怎么帮我们优雅地解决那些头疼的边界问题。
重新认识 %
取余的本质,是将任意数值强行限定在一个固定的循环范围内。无论数字跑多远,% N 都能让它回归到 0 至 N-1 的闭环中。
在教科书里,取余的公式是 a % n = r
-
a:被除数 -
n:除数 -
r:余数
但在代码逻辑里,我更愿意把它理解为两个超级好用的思维模型:
🔄 循环
想象一下家里的挂钟。不管时间怎么流逝,时针转了一圈又一圈,它永远只会停在 1 到 12 之间。取余就是这个表盘
,它能让无限增长的数字,乖乖地在一个固定的"圈"里打转。
✂️ 限制
无论你给我的数字有多大,% n 就像一把剪刀,强行把多出来的部分剪掉,只保留 0 到 n-1 这一小段。
就可以理解为:
-
a:被除数(任意数值) -
n:除数(限定的范围大小,也就是"表盘"的大小) -
r:余数(结果永远在0到n-1之间)
特点
构建"周期闭环"
说白了就是让数字一直在一个圈里转,永远跑不出去。比如轮播图或红绿灯,写 if (index >= length) 来防止数组越界,写多了特别烦。
有了取余,这事儿就简单了:
// 不管 index 涨到几万,结果永远锁死在 0 到 length-1 之间
const safeIndex = index % list.length;
降维与坐标映射
这个主要解决"一维变二维"的问题。比如为了省流量,后端扔过来一个长长的一维数组,你需要在界面上画个九宫格。
别傻乎乎地去搞双层循环,直接用数学搞定。假设一行有 col 列:
-
找列号(X轴):看它在当前行走了几步 -> 取余 (
% col) -
找行号(Y轴):看它已经填满了几行 -> 整除 (
Math.floor(i / col))
// 假设数组索引 i=7,一行3个 (col=3)
const x = 7 % 3; // 1 (第2列)
const y = Math.floor(7 / 3); // 2 (第3行)
// 坐标就是 (1, 2)
均匀离散与分流
一大堆随机数据(比如 1000 万个用户 ID),把它们公平地分给 3 台服务器,怎么分最匀称?
别搞什么复杂的随机算法,直接按 ID 取余。这不仅分得匀,还能保证同一个用户每次都能分到同一台机器上(这在分布式里叫 Hash 一致性)。
- 数字 ID:直接取余。
- 字符串 ID:先算 Hash 值(转成数字),再取余。
// 简单又高效的负载均衡
const targetServer = servers[userId % 3];
// 如果是字符串 ID,就先转成数字(Hash)
// const hash = stringToNumber(userId);
// const targetServer = servers[hash % 3];
声明式逻辑
代码是写给人看的。if-else 是告诉机器"怎么做流程控制",而 % 是告诉人"这里是个循环"。
用 % 最大的好处就是——你再也不会把 > 误写成 >= 了。那种差 1 的 Bug(Off-by-one error),写过代码的都懂有多坑。
倍数与规律捕捉
想每隔 10 行打个日志?或者给表格弄个"斑马纹"(奇偶变色)?
这种"每隔 N 次搞点事情"的逻辑,用取余是最直观的。它就像个节拍器,到了那个点就会响。
// 经典的斑马纹逻辑
const color = index % 2 === 0 ? 'white' : 'gray';
常见的面试题(由简到难)
1. 秒转时分秒(倒计时)
问:给你一个总秒数 3661,怎么在页面上显示 01:01:01?
答:这是最基础的"进制转换"题。
- 低位(秒):总秒数对 60 取余 -> 剩下的零头就是秒。
- 中位(分):总秒数先除以 60 得到总分钟数,再对 60 取余 -> 剩下的零头就是分。
- 高位(时):总分钟数除以 60 -> 剩下的就是时。
const totalSeconds = 3661;
const seconds = totalSeconds % 60; // 1
const minutes = Math.floor(totalSeconds / 60) % 60; // 61 % 60 = 1
const hours = Math.floor(totalSeconds / 3600); // 1
const format = time => time.toString().padStart(2, '0');
console.log(`${format(hours)}:${format(minutes)}:${format(seconds)}`); // 01:01:01
2. 判断质数(Prime Number)
问:怎么判断一个数 n 是不是质数?
答:质数就是只能被 1 和它自己整除的数。
所以,拿 2 到 n-1 之间的所有数去试着除它。只要有一个能被整除(n % i === 0),它就不是质数。
优化点:其实只需要试到 Math.sqrt(n) 就够了,后面都是重复的。
为什么? 因子都是成对出现的。比如
36:
2 × 183 × 124 × 96 × 6(根号 n)9 × 4(重复了!)只要在
6(根号 n) 之前没找到因子,后面也绝不会有(除非是它自己)。同理100的根号是10,你只要试到10就行了,不用傻乎乎试到99。
function isPrime(n) {
if (n <= 1) return false;
if (n === 2) return true; // 2 是质数
if (n % 2 === 0) return false; // 偶数直接排除
// 只需要试除奇数,步长为 2
for (let i = 3; i <= Math.sqrt(n); i += 2) {
if (n % i === 0) return false;
}
return true;
}
3. 判断回文数(不转字符串)
问:给你个数字 12321,怎么判断它是回文?不许转成 String。
答:这题考的是数字拆解的基本功。
你需要理解 % 和 / 在十进制里的黄金搭档关系:
-
% 10是"拿":拿到个位数(剥洋葱的第一层)。 -
/ 10是"扔":扔掉个位数(把洋葱缩小一圈)。
一边拆,一边装:
把 x 的屁股(最后一位)拆下来,装到 reversed 的头上。如果装完发现 reversed === x,那就是回文。
let x = 12321, reversed = 0;
// 假设 x=123
// 第一轮:123 % 10 = 3 (拿3), 123 / 10 = 12 (剩12)
// 第二轮:12 % 10 = 2 (拿2), 12 / 10 = 1 (剩1)
// 第三轮:1 % 10 = 1 (拿1), 1 / 10 = 0 (剩0) -> 结束
while (x > 0) {
reversed = reversed * 10 + x % 10; // 拼到新数末尾
x = Math.floor(x / 10); // 原数去掉末尾
}
4. 负数取余的坑(JS vs 其他语言)
问:(-1) % 5 在 JS 里等于多少?在 Python 里呢?
答:这题特容易踩坑。
- 在 JS(C/Java)里,结果是
-1。因为它们看重"商"向 0 取整。 - 在 Python 里,结果是
4。因为 Python 看重"商"向下取整。
实战解法:
如果在 JS 做轮播图(点击上一张),算出 -1 程序就崩了。
记住这个万能公式,不管正负都能转正:
const index = (current + step + length) % length;
为什么加 length?
因为 % 运算在 JS 里会保留符号。假设当前是第 0 张图(current=0),你要退一张(step=-1),总共5张图(length=5)。
-
不加 length:
(0 + (-1)) % 5 = -1❌(不仅不对,还越界了) -
加 length:
(0 + (-1) + 5) % 5 = 4✅(这就对了,回到了最后一个) -
正向移动:
(0 + 1 + 5) % 5 = 1✅(加一圈不影响正数结果,没副作用)
场景举例:
-
轮播图"上一张":
current=0, step=-1。(0 - 1 + 5) % 5 = 4-> 完美跳到最后一张。 -
贪吃蛇穿墙:蛇头钻出左边界
x=-1。(-1 + width) % width-> 瞬间从右边出来。 -
日期计算:今天是周三
3,问 5 天前是周几?(3 - 5 + 7) % 7 = 5-> 周五。不用脑补倒着数数了。
5. 不用临时变量交换两个数
问:给你两个整数 a 和 b,不许用 temp 变量,怎么交换它们?
答:除了烂大街的位运算(异或),取余其实也能干这事儿(虽然不如位运算快,但思路很骚)。
思路是把两个数"压缩"到一个大数里,再拆出来。
let a = 123, b = 456;
// 假设 n 足够大,比 a 和 b 都大
const n = 1000;
// 压缩:把 b 藏在高位,a 藏在低位
a = a + b * n; // 123 + 456 * 1000 = 456123
b = a % n; // 取出低位,也就是原来的 a
a = Math.floor(a / n); // 取出高位,也就是原来的 b
console.log(a, b); // 456, 123
6. 约瑟夫环问题
场景描述:
有 n 个人围成一圈(编号 0 到 n-1)。从第 0 号开始报数,报到 m 的人出局。下一位继续从 1 开始报数,直到只剩最后一个人。问最后这个人的原始编号是多少?
例子:
- n = 5(5个人:0, 1, 2, 3, 4)
- m = 3(报到3出局)
- 出局过程:2号出局 -> 0号出局 -> 4号出局 -> 1号出局 -> 3号幸存。
- 幸存过程:0, 1, 2, 3, 4 -> 0, 1, 3, 4 -> 1, 3, 4 -> 1, 3 -> 3
这道题有点复杂,先上答案,后面咱们掰开揉碎了讲
/**
* @param {number} n 总人数
* @param {number} m 报数号码(报到几出局)
* @return {number} 最后幸存者的编号
*/
function lastRemaining(n, m) {
let pos = 0; // 时光倒流终点:最后只剩1个人时,幸存者索引是0
// 开始倒推:从2个人 -> 3个人 -> ... -> n个人
for (let i = 2; i <= n; i++) {
pos = (pos + m) % i; // 每一轮人数变多(i),位置都要往后挪 m 位
}
return pos;
}
解法思路:时光倒流(坐标偏移)
这个问题如果顺着想(模拟淘汰),数组删元素很麻烦。但如果我们倒着想,利用坐标偏移规律,就非常简单。
1. 正向(淘汰 = 坐标前移):
想象一下,m=3,第 3 个人(索引 2)被淘汰后。
- 按照规则,下一轮报数从被淘汰者的下一个人(索引 3)开始。
- 这就意味着,索引 3 变成了新一轮的 排头兵(新的索引 0)。
- 相当于所有人整体往前挪了 3 位(注意:不仅仅是填补空缺,而是连起点都变了)。
- 即:
旧索引 - 3 = 新索引。
2. 逆向(恢复 = 坐标后移): 我们要找幸存者最初在哪,可以从终局(只剩他 1 人,索引 0)开始,一步步把时光倒流,恢复之前被淘汰的人。
- 恢复就是淘汰的逆操作。
- 既然淘汰是"往前挪 3 位",那恢复就是"往后挪 3 位"(
+3)。 - 公式呼之欲出:
新索引 + 3 = 旧索引。 -
核心补丁:因为是圆圈,往后挪超出了队尾就要绕回队头,所以必须
% 上轮人数。
推导过程演示(N=5, M=3):
我们只关注最后那个幸存者(假设他叫"天选之子"),他在每一轮的索引是多少?
表头说明:
- n:当前轮剩余人数。
- 倒推公式:
(当前索引 + m) % 上轮人数。通过这个公式,我们可以算出幸存者在上一轮(人数更多时)的位置。
| 轮次 | 剩余人数 | 场景描述 | 计算过程 | 幸存者索引 |
|---|---|---|---|---|
| 终局 | 1 | 只剩天选之子 | 0 (固定) | 0 |
| 倒数第2轮 | 2 | 恢复成2人 | (0 + 3) % 2 |
1 |
| 倒数第3轮 | 3 | 恢复成3人 | (1 + 3) % 3 |
1 |
| 倒数第4轮 | 4 | 恢复成4人 | (1 + 3) % 4 |
0 |
| 开局 | 5 | 恢复成5人 | (0 + 3) % 5 |
3 |
结论:一开始索引为 3 的那个人,就是天选之子。
💡 核心疑点 Q&A:
-
为什么要倒推?
- 正推太麻烦:如果正向模拟,你需要不断地删除数组元素、处理索引越界,数组长度一直在变,计算极其复杂。
-
终局是已知的:无论过程多复杂,最后一定只剩 1 个人,且那个人的索引一定是
0。从确定的结果出发找源头,比从源头去猜结果要容易得多。
-
为什么要恢复上一轮的状态?
- 这是一个递归/递推的问题。
5个人的游戏淘汰一个,就变成了4个人的游戏。 - 如果我们知道
4个人里的幸存者是谁,只要把这个幸存者在4个人局里的位置,映射(还原) 回5个人局里的位置,问题就解决了。 - 所谓"恢复",其实就是坐标变换。
- 这是一个递归/递推的问题。
-
为什么要 % i(当前人数),而不是 % n(总人数)?
- 这是很多人的盲点!
- 每一轮淘汰一个人,圈子的大小都在变。
- 倒数第 2 轮时,圈子只有 2 个人,所以是
% 2;倒数第 3 轮时,圈子有 3 个人,所以是% 3。 - 我们是在那一轮的圈子里进行坐标恢复,当然要模那一轮的人数。
-
公式
(当前索引 + m) % 上轮人数怎么来的?- 这就是我们上面提到的坐标偏移:
-
+ m:代表时光倒流,恢复被删掉的
m个位置。 - % 上轮人数:代表在恢复后的圈子里转圈圈,防止索引越界。
💡 小贴士:数学公式版(递归实现)
如果你在算法书上看到这个公式,别慌,它和我们的代码是一回事:
f(n, m) = (f(n-1, m) + m) % n
-
f(n, m):n 个人时幸存者的索引。 -
f(n-1, m):n-1 个人时幸存者的索引(也就是我们代码里的pos)。 - 代码里的
for循环,就是把这个数学递归公式变成了从 2 到 n 的递推。
递归版代码(仅供参考):
虽然代码看着短,但如果 n 很大,会爆栈哦。还是推荐用上面的 for 循环(迭代版)。
function lastRemainingRecursive(n, m) {
if (n === 1) return 0; // 剩下1个人,索引肯定是0
return (lastRemainingRecursive(n - 1, m) + m) % n;
}
动态规划版(标准 DP):
有了推导公式,自然就能写出 DP。
dp[i] 表示 i 个人时的幸存者索引。
function lastRemainingDP(n, m) {
let dp = new Array(n + 1);
dp[1] = 0; // 只有1个人时,索引是0
for (let i = 2; i <= n; i++) {
dp[i] = (dp[i - 1] + m) % i; // 状态转移方程
}
return dp[n];
}
注:我们最开始写的那个 let pos 的版本,其实就是这个 DP 版本的空间优化版(滚动数组思想),把 dp
数组压缩成了一个变量。
总结
说实话,取余(Modulo)这个概念,以前我也觉得它只是个数学符号,顶多用来算算奇偶数。但当你真的深入去理解它,你会发现它其实是一种化直为曲
的思维方式。
无论是处理时间、轮播图,还是解决像约瑟夫环这样复杂的算法题,取余的核心永远只有两点:控制边界和制造循环。
希望这篇文章能帮你打破对 % 的固有印象。下次在代码里遇到"溢出"、"循环"或者"映射"的问题时,试着停下来想一想:这里是不是可以用取余来简化一下?
多思考,多动手,编程不仅是写代码,更是对数据规律的优雅掌控。