聊聊编程里的“魔法棒”:取余运算(Modulo)
💡 写在前面: 但随着代码写得越来越多,逐渐发现 取余的本质,是将任意数值强行 在教科书里,取余的公式是 但在代码逻辑里,我更愿意把它理解为两个超级好用的思维模型: 想象一下家里的挂钟。不管时间怎么流逝,时针转了一圈又一圈,它永远只会停在 无论你给我的数字有多大, 就可以理解为: 说白了就是让数字一直在一个圈里转,永远跑不出去。比如轮播图或红绿灯,写 有了取余,这事儿就简单了: 这个主要解决"一维变二维"的问题。比如为了省流量,后端扔过来一个长长的一维数组,你需要在界面上画个九宫格。 别傻乎乎地去搞双层循环,直接用数学搞定。假设一行有 一大堆随机数据(比如 1000 万个用户 ID),把它们公平地分给 3 台服务器,怎么分最匀称? 别搞什么复杂的随机算法,直接按 ID 取余。这不仅分得匀,还能保证同一个用户每次都能分到同一台机器上(这在分布式里叫 Hash 一致性)。 代码是写给人看的。 用 想每隔 10 行打个日志?或者给表格弄个"斑马纹"(奇偶变色)? 这种"每隔 N 次搞点事情"的逻辑,用取余是最直观的。它就像个节拍器,到了那个点就会响。 问:给你一个总秒数 答:这是最基础的"进制转换"题。 问:怎么判断一个数 答:质数就是只能被 1 和它自己整除的数。 所以,拿 2 到 n-1 之间的所有数去试着除它。只要有一个能被整除( 优化点:其实只需要试到 为什么? 因子都是成对出现的。比如 只要在 问:给你个数字 答:这题考的是数字拆解的基本功。 你需要理解 一边拆,一边装: 问: 答:这题特容易踩坑。 实战解法: 如果在 JS 做轮播图(点击上一张),算出 记住这个万能公式,不管正负都能转正: 为什么加 因为 场景举例: 问:给你两个整数 a 和 b,不许用 答:除了烂大街的位运算(异或),取余其实也能干这事儿(虽然不如位运算快,但思路很骚)。 思路是把两个数"压缩"到一个大数里,再拆出来。 场景描述: 例子: 这道题有点复杂,先上答案,后面咱们掰开揉碎了讲 解法思路:时光倒流(坐标偏移) 这个问题如果顺着想(模拟淘汰),数组删元素很麻烦。但如果我们倒着想,利用坐标偏移规律,就非常简单。 1. 正向(淘汰 = 坐标前移): 2. 逆向(恢复 = 坐标后移): 推导过程演示(N=5, M=3): 我们只关注最后那个幸存者(假设他叫"天选之子"),他在每一轮的索引是多少? 表头说明: 结论:一开始索引为 3 的那个人,就是天选之子。 💡 核心疑点 Q&A: 为什么要倒推? 为什么要恢复上一轮的状态? 为什么要 % i(当前人数),而不是 % n(总人数)? 公式 💡 小贴士:数学公式版(递归实现) 如果你在算法书上看到这个公式,别慌,它和我们的代码是一回事: 递归版代码(仅供参考): 虽然代码看着短,但如果 n 很大,会爆栈哦。还是推荐用上面的 动态规划版(标准 DP): 有了推导公式,自然就能写出 DP。 *注:我们最开始写的那个 说实话,取余(Modulo)这个概念,以前我也觉得它只是个数学符号,顶多用来算算奇偶数。但当你真的深入去理解它,你会发现它其实是一种 无论是处理时间、轮播图,还是解决像约瑟夫环这样复杂的算法题,取余的核心永远只有两点:控制边界和制造循环。 希望这篇文章能帮你打破对 多思考,多动手,编程不仅是写代码,更是对数据规律的优雅掌控。 本文由mdnice多平台发布
最近面试被问到一个倒计时相关问题,又一次用到了取余(Modulo)。说实话,刚入行那会儿,总觉得这玩意儿不就是小学数学里的求余数
吗?除了面试题里用来判断奇偶数,平时好像也没啥大用。% 符号背后其实隐藏着一种处理数据的思维模型——它能把无限延伸的线性世界,折叠成有限可控的
周期世界。今天想和大家分享一下我对取余的重新思考,看看它是怎么帮我们优雅地解决那些头疼的边界问题。重新认识
%限定在一个固定的循环范围内。无论数字跑多远,% N 都能让它回归到 0 至 N-1 的闭环中。a % n = ra:被除数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 列:% col)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)均匀离散与分流
// 简单又高效的负载均衡
const targetServer = servers[userId % 3];
// 如果是字符串 ID,就先转成数字(Hash)
// const hash = stringToNumber(userId);
// const targetServer = servers[hash % 3];声明式逻辑
if-else 是告诉机器"怎么做流程控制",而 % 是告诉人"这里是个循环"。% 最大的好处就是——你再也不会把 > 误写成 >= 了。那种差 1 的 Bug(Off-by-one error),写过代码的都懂有多坑。倍数与规律捕捉
// 经典的斑马纹逻辑
const color = index % 2 === 0 ? 'white' : 'gray';常见的面试题(由简到难)
1. 秒转时分秒(倒计时)
3661,怎么在页面上显示 01:01:01?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:012. 判断质数(Prime Number)
n 是不是质数?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 里呢?-1。因为它们看重"商"向 0 取整。4。因为 Python 看重"商"向下取整。-1 程序就崩了。const index = (current + step + length) % length;length?% 运算在 JS 里会保留符号。假设当前是第 0 张图(current=0),你要退一张(step=-1),总共5张图(length=5)。(0 + (-1)) % 5 = -1 ❌(不仅不对,还越界了)(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. 不用临时变量交换两个数
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, 1236. 约瑟夫环问题
有 n 个人围成一圈(编号 0 到 n-1)。从第 0 号开始报数,报到 m 的人出局。下一位继续从 1 开始报数,直到只剩最后一个人。问最后这个人的原始编号是多少?/**
* @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;
}
想象一下,m=3,第 3 个人(索引 2)被淘汰后。旧索引 - 3 = 新索引。
我们要找幸存者最初在哪,可以从终局(只剩他 1 人,索引 0)开始,一步步把时光倒流,恢复之前被淘汰的人。+3)。新索引 + 3 = 旧索引。% 上轮人数。(当前索引 + m) % 上轮人数。通过这个公式,我们可以算出幸存者在上一轮(人数更多时)的位置。轮次 剩余人数 场景描述 计算过程 幸存者索引 终局 1 只剩天选之子 0 (固定) 0 倒数第2轮 2 恢复成2人 (0 + 3) % 21 倒数第3轮 3 恢复成3人 (1 + 3) % 31 倒数第4轮 4 恢复成4人 (1 + 3) % 40 开局 5 恢复成5人 (0 + 3) % 53 0。从确定的结果出发找源头,比从源头去猜结果要容易得多。5个人 的游戏淘汰一个,就变成了 4个人 的游戏。4个人 里的幸存者是谁,只要把这个幸存者在 4个人 局里的位置,映射(还原) 回 5个人 局里的位置,问题就解决了。% 2;倒数第 3 轮时,圈子有 3 个人,所以是 % 3。(当前索引 + m) % 上轮人数 怎么来的?m 个位置。f(n, m) = (f(n-1, m) + m) % nf(n, m):n 个人时幸存者的索引。f(n-1, m):n-1 个人时幸存者的索引(也就是我们代码里的 pos)。for 循环,就是把这个数学递归公式变成了从 2 到 n 的递推。for 循环(迭代版)。function lastRemainingRecursive(n, m) {
if (n === 1) return 0; // 剩下1个人,索引肯定是0
return (lastRemainingRecursive(n - 1, m) + m) % n;
}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
数组压缩成了一个变量。*总结
化直为曲
的思维方式。% 的固有印象。下次在代码里遇到"溢出"、"循环"或者"映射"的问题时,试着停下来想一想:这里是不是可以用取余来简化一下?