阅读视图

发现新文章,点击刷新页面。

聊聊编程里的“魔法棒”:取余运算(Modulo)

💡 写在前面: 最近面试被问到一个倒计时相关问题,又一次用到了取余(Modulo)。说实话,刚入行那会儿,总觉得这玩意儿不就是小学数学里的求余数 吗?除了面试题里用来判断奇偶数,平时好像也没啥大用。

但随着代码写得越来越多,逐渐发现 % 符号背后其实隐藏着一种处理数据的思维模型——它能把无限延伸的线性世界,折叠成有限可控的 周期世界。今天想和大家分享一下我对取余的重新思考,看看它是怎么帮我们优雅地解决那些头疼的边界问题。

重新认识 %

取余的本质,是将任意数值强行限定在一个固定的循环范围内。无论数字跑多远,% N 都能让它回归到 0N-1 的闭环中。

在教科书里,取余的公式是 a % n = r

  • a:被除数
  • n:除数
  • r:余数

但在代码逻辑里,我更愿意把它理解为两个超级好用的思维模型:

🔄 循环

想象一下家里的挂钟。不管时间怎么流逝,时针转了一圈又一圈,它永远只会停在 112 之间。取余就是这个表盘 ,它能让无限增长的数字,乖乖地在一个固定的"圈"里打转。

✂️ 限制

无论你给我的数字有多大,% n 就像一把剪刀,强行把多出来的部分剪掉,只保留 0n-1 这一小段。

就可以理解为:

  • a:被除数(任意数值)
  • n:除数(限定的范围大小,也就是"表盘"的大小)
  • r:余数(结果永远在 0n-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 × 18
  • 3 × 12
  • 4 × 9
  • 6 × 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 ✅(加一圈不影响正数结果,没副作用)

场景举例

  1. 轮播图"上一张"current=0, step=-1(0 - 1 + 5) % 5 = 4 -> 完美跳到最后一张。
  2. 贪吃蛇穿墙:蛇头钻出左边界 x=-1(-1 + width) % width -> 瞬间从右边出来。
  3. 日期计算:今天是周三 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. 为什么要倒推?

    • 正推太麻烦:如果正向模拟,你需要不断地删除数组元素、处理索引越界,数组长度一直在变,计算极其复杂。
    • 终局是已知的:无论过程多复杂,最后一定只剩 1 个人,且那个人的索引一定是 0。从确定的结果出发找源头,比从源头去猜结果要容易得多。
  2. 为什么要恢复上一轮的状态?

    • 这是一个递归/递推的问题。5个人 的游戏淘汰一个,就变成了 4个人 的游戏。
    • 如果我们知道 4个人 里的幸存者是谁,只要把这个幸存者在 4个人 局里的位置,映射(还原)5个人 局里的位置,问题就解决了。
    • 所谓"恢复",其实就是坐标变换
  3. 为什么要 % i(当前人数),而不是 % n(总人数)?

    • 这是很多人的盲点!
    • 每一轮淘汰一个人,圈子的大小都在变
    • 倒数第 2 轮时,圈子只有 2 个人,所以是 % 2;倒数第 3 轮时,圈子有 3 个人,所以是 % 3
    • 我们是在那一轮的圈子里进行坐标恢复,当然要模那一轮的人数
  4. 公式 (当前索引 + 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)这个概念,以前我也觉得它只是个数学符号,顶多用来算算奇偶数。但当你真的深入去理解它,你会发现它其实是一种化直为曲 的思维方式。

无论是处理时间、轮播图,还是解决像约瑟夫环这样复杂的算法题,取余的核心永远只有两点:控制边界制造循环

希望这篇文章能帮你打破对 % 的固有印象。下次在代码里遇到"溢出"、"循环"或者"映射"的问题时,试着停下来想一想:这里是不是可以用取余来简化一下?

多思考,多动手,编程不仅是写代码,更是对数据规律的优雅掌控。

❌