在 LeetCode 上看到 1018 这道题的时候,我第一反应是“这不就是遍历一遍,把二进制数算出来取模嘛”。结果真上手写了才发现,坑全在细节里:数组长度可以到十万级别,直接算数值会溢出;判断条件是“每个前缀”而不是“整个数组”;还有那个著名的耗时显示,同样是 O(n) 的写法,提交记录里可能从 70ms 到 130ms 来回横跳。这篇文章就把这道 可被 5 整除的二进制前缀 从读题到优化的完整过程拆开讲一遍,包括推导、代码、踩过的坑,以及它能延伸出来的通用套路。
1. 题目到底在问什么:拆解输入输出与隐含边界
1.1 从题目描述到可操作的数学模型
LeetCode 1018 的英文名是 Binary Prefix Divisible By 5,中文叫“可被 5 整除的二进制前缀”。题目给的是一个只包含 0 和 1 的数组 nums,要求返回一个等长的布尔数组 answer。对于每个下标 i,你要判断 nums[0] 到 nums[i] 这段连续元素按顺序拼接成的二进制数,是否能被 5 整除。
举个例子,nums = [0, 1, 1]:
- 前缀
0,二进制就是0,0 % 5 = 0,结果true。 - 前缀
0, 1,二进制是01,也就是十进制1,1 % 5 = 1,结果false。 - 前缀
0, 1, 1,二进制是011,也就是十进制3,3 % 5 = 3,结果false。
所以答案是 [true, false, false]。
注意这里的“二进制前缀”不是说你真的要把数组转成字符串,再转成十进制整数。nums 的长度上限通常是 10^4 甚至 10^5,如果老老实实把整个二进制数算出来,2^100000 这种天文数字连 BigInteger 都得掂量一下性能。题目真正的考点在于:我们能不能只通过余数来递推判断。
1.2 为什么这道题容易被“二进制”三个字带偏
我第一次看到这题的时候,脑子里冒出来的方案是:每遍历到一个位置,就把当前前缀转成字符串,然后用 parseInt(binaryString, 2) 解析成整数,再取模。听上去很直接,对吧?但两个问题会立刻暴露:
第一,parseInt 对超过 32 位或 64 位的整数会直接失真或者抛出异常。JavaScript 的 parseInt 遇到超大二进制字符串时,会丢失精度;Java 的 Integer.parseInt 直接抛 NumberFormatException。也就是说,一旦测试数据给到一个稍微长一点的前缀,这个方案就当场崩掉。
第二,即使你换成长整型 Long.parseLong,长度超过 63 位依然不行。LeetCode 的测试用例可不会跟你客气,nums 长度上千很常见,所以这个思路从根上就错了。
正确的方向应该是回到数学本身:判断一个数能不能被 5 整除,只跟这个数除以 5 的余数有关。我们要维护的不是“整个前缀的数值”,而是“前缀数值模 5 的余数”。这个余数永远在 0~4 之间,再大的数组长度都不会溢出。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 只存余数就够了:核心推导与同余原理
2.1 递推公式是怎么来的
假设已经处理到前 i 个元素,当前前缀对应的十进制值为 cur。现在来了一个新元素 bit(取值 0 或 1),新的前缀值怎么算?
二进制左移一位相当于乘以 2,再加上当前位。所以:
code复制new_cur = cur * 2 + bit
到这里,大部分人都能看懂。问题是 cur 可能非常大。但我们关心的不是 new_cur 本身,而是 new_cur % 5。
模运算有一个性质:和的模等于模的和,积的模等于模的积。形式化一点:
code复制(a + b) % m = ((a % m) + (b % m)) % m
(a * b) % m = ((a % m) * (b % m)) % m
所以:
code复制new_cur % 5 = ((cur * 2) % 5 + bit % 5) % 5
= ((cur % 5) * 2 + bit) % 5
也就是说,我只需要记住 cur % 5,就能推算出下一个前缀的 cur % 5。用 remainder 表示:
code复制remainder = (remainder * 2 + bit) % 5
每轮迭代后,判断 remainder == 0 即可。
2.2 一个生活化的类比
你可以想象你在数一堆苹果,但不需要记住总数,只需要记住“如果每次 5 个装一袋,最后剩下几个”。每来一批新苹果,就把“剩下的几个”和新来的苹果合在一起,再重新 5 个一装,看剩几个。
比如当前剩下 3 个,新来 2 个,一共 5 个,正好装完,剩余 0 个。你不需要知道之前累计苹果总数到底是 23 个还是 103 个,因为装袋结果只跟“上一轮的剩余”和“本轮新来的数量”有关。这个“剩余”就是代码里的 remainder。
2.3 为什么这个性质对“二进制前缀”特别友好
有人可能会问:为什么题目偏偏选 5 来做整除判断?换成 7、11、13,这个方法还成立吗?
答案是照样成立。上面的递推式里,5 可以换成任意正整数 k:
code复制remainder = (remainder * 2 + bit) % k
判断条件改成 remainder == 0 即可。这意味着这个套路可以推广到“判断二进制前缀是否能被任意数整除”的通用解法。之所以 1018 题选 5,是因为 5 在二进制下有特殊性,后面第 5 节我会专门讲。
再补充一点:有些同学可能在别的题解里看到过“只有最后一位是 0 或 5 的十进制数才能被 5 整除”这种结论。它本身没错,但那是针对十进制末尾而言的。我们这里判断的是二进制前缀的数值,10 的倍数在二进制里末尾并不一定是 0(比如十进制 10 的二进制是 1010),所以不能直接用“最后一位是不是 0/1”来判断。这也是很多人踩坑的地方。
3. 代码落地:从第一版到避免无谓开销
3.1 最直观的实现(Java 版)
先给出一版清晰、可读性优先的 Java 实现:
java复制class Solution {
public List<Boolean> prefixesDivBy5(int[] nums) {
List<Boolean> result = new ArrayList<>();
int remainder = 0;
for (int bit : nums) {
remainder = (remainder * 2 + bit) % 5;
result.add(remainder == 0);
}
return result;
}
}
核心逻辑就三行。remainder * 2 + bit 最大值也就是 4 * 2 + 1 = 9,完全不会溢出。
Python 版本同样简单:
python复制from typing import List
class Solution:
def prefixesDivBy5(self, nums: List[int]) -> List[bool]:
ans = []
remainder = 0
for bit in nums:
remainder = (remainder * 2 + bit) % 5
ans.append(remainder == 0)
return ans
3.2 关于“耗时 100ms”的优化讨论
标题里提到的“耗时 100”,我在刷题时也关注过。LeetCode 的耗时统计其实有几层噪音:服务器负载、JIT 预热状态、语言本身的运行时差异,甚至连续提交同一份代码都可能差出 30ms。所以“优化到 100ms 以下”有时候是个伪命题,但我们可以做一些“消除不必要开销”的微优化,让代码在绝大多数环境下跑得更稳。
一个常见优化是避免使用 List<Boolean> 的装箱开销。Java 的泛型集合只能存对象,boolean 基本类型放进 ArrayList<Boolean> 时会发生自动装箱,产生 Boolean 对象。如果数组特别长,这会带来额外的内存分配和 GC 压力。更好的做法是直接返回 boolean[]:
java复制class Solution {
public boolean[] prefixesDivBy5(int[] nums) {
boolean[] ans = new boolean[nums.length];
int remainder = 0;
for (int i = 0; i < nums.length; i++) {
remainder = (remainder * 2 + nums[i]) % 5;
ans[i] = (remainder == 0);
}
return ans;
}
}
boolean[] 是基本类型数组,不涉及装箱,内存开销更小。如果你看题目要求,有些版本确实允许返回 List<Boolean>,但 LeetCode 的判定本质上只关心元素值,用数组完全没问题。
再看一个可能被过度优化的点:有人觉得取模运算 % 比加减乘除慢,于是想用判断代替:
java复制remainder = remainder * 2 + nums[i];
if (remainder >= 5) {
remainder %= 5;
}
这个写法有个问题:remainder * 2 + bit 最多是 9,你直接 % 5 一步到位更清晰。现代 CPU 对 % 5 这类小常数取模的优化已经非常好了,没必要为了省一次取模破坏代码可读性。真正的性能瓶颈从来不在这条取模指令上,而在算法复杂度、集合装箱、无意义的重复计算上。
3.3 边界情况与复杂度分析
时间复杂度是 O(n),只需要遍历数组一次。空间复杂度是 O(n),因为要返回一个长度为 n 的布尔数组。如果不算返回结果占用的空间,额外空间只是 O(1) 的 remainder 变量。
需要特别注意的边界情况:
nums长度为 0:题目一般保证至少有一个元素,但如果出现,返回空数组即可。nums全是 0:所有前缀都是二进制0,0 % 5 == 0,结果全为true。nums开头是 1:第一个前缀是1,1 % 5 = 1,结果为false。- 连续多个
1:1, 3, 7, 15, 31...,余数依次是1, 3, 2, 0, 1,是循环的。这正好对应下面第 5 节要讲的规律。
我自己在验证时,常用 nums = [1, 1, 1, 1, 1] 来快速走查:预期答案是 [false, false, false, true, false]。因为二进制 1111 是 15,能被 5 整除,而 11111 是 31,不能。这里能直观地看到余数并不是单调递增的,而是循环变化,这正是取模运算的特点。
4. 实测踩坑复盘:为什么答案老是不对
4.1 坑一:试图把前缀转成真整数
最经典的错误版本长这样(Java):
java复制int cur = 0;
for (int i = 0; i < nums.length; i++) {
cur = cur * 2 + nums[i];
ans[i] = (cur % 5 == 0);
}
单独看小样本,比如 [0, 1, 1],这个代码完全正确。但一旦 nums 长度超过 30,cur 就会溢出,变成负数或者截断后的错误值,cur % 5 自然也不可靠。比如 cur 溢出后刚好变成 5,cur % 5 == 0 成立,但真实前缀值可能根本不能被 5 整除。
这就像你在算一笔流水账,账本记录到一定行数就自动清零重来,那么你看到的“当前余额”根本反映不了真实总额。
4.2 坑二:把“每个前缀”理解成“整个数组某个区间”
题目要求的是“对于每个 i,判断 nums[0..i] 这个前缀”。注意是从开头到当前下标的连续子数组,不是任意子数组。
我见过有人写成:
java复制for (int i = 0; i < nums.length; i++) {
int sum = 0;
for (int j = 0; j <= i; j++) {
sum = sum * 2 + nums[j];
}
ans[i] = (sum % 5 == 0);
}
虽然逻辑上没错,但复杂度是 O(n^2),在 n = 100000 时直接超时。这种写法也说明没有吃透“递推”的含义:前缀和是可以通过上一轮结果直接算出来的,不需要重新从头累加。
4.3 坑三:误以为 0 和 1 之外的值不会出现
题目明确说 nums[i] 是 0 或 1,但如果你在写代码时假设了这一点,万一扩展场景里出现其他数字就会出错。我在本地测试时故意把 nums 改成 [0, 2, 1] 试过,remainder = (remainder * 2 + 2) % 5 也能计算,但结果已经和题目定义的“二进制前缀”无关了。所以做题时最好在代码注释里标注输入约束,面试时也要主动跟面试官确认“输入是否保证只有 0 和 1”。
4.4 面试追问版:如果改成十进制大数怎么办
这题还有一个很常见的面试延伸:给你一个十进制大数的字符串,要求判断它能否被 5 整除,你会怎么做?
答案其实特别简单——十进制数能不能被 5 整除,只看最后一位是 0 还是 5。但面试官接着会问:那如果判断能否被 3 整除呢?这就不能只看最后一位了。你需要用各位数字之和能否被 3 整除来判断。
那如果给的是二进制大数,判断能否被 5 整除呢?这时候“只看最后一位”的捷径不管用了,因为 5 不是 2 的幂。于是就要回到这篇的核心思想:从左到右扫描,维护余数。这恰好是 1018 这题真正的训练价值——教会你在没有“位级捷径”的情况下,用同余原理做流式判断。
5. 从 1018 延伸开去:模 k 判断的通用方法论
5.1 能被 2、4、8 整除:二进制下的位级捷径
如果题目改成“二进制前缀能否被 2 整除”,那根本不用维护余数:二进制数最后一位是 0,就一定能被 2 整除。这和我们判断十进制数能否被 10 整除只看最后一位是一个道理。
同理,判断能否被 4 整除,看二进制最后两位;判断能否被 8 整除,看最后三位。因为 2^k 在二进制下的模正好是“只看低 k 位”。这类题目如果上来就无脑维护余数,虽然也能过,但会错过出题人埋的位运算考点。
5.2 为什么 5 没有类似的“位级捷径”
二进制下能被 5 整除的数,最后一位可以是 0 也可以是 1(比如二进制 101 是 5,最后一位是 1;二进制 1010 是 10,最后一位是 0)。所以不存在“看最后一位”的简单规则。那有没有“看若干位”的规则?
答案是:存在,但不像 2 的幂那么直观。因为 5 和 2 互质,二进制对 5 取模的循环节长度是 4(后面会讲),所以理论上可以每隔 4 位做一次分组处理。但这样实现的复杂度远高于直接维护余数,而且不容易推广。在面试场景下,维护余数才是更通用、更稳妥的解法。
5.3 余数状态机:一个可以迁移的思维框架
如果你把 remainder 当成“状态”,把读入的 bit 当成“输入”,那么这道题的每一轮迭代就是一次状态转移:
code复制状态 0 到 4,输入 0 或 1,下一状态 = (当前状态 * 2 + 输入) % 5
这本质上是一个有限状态自动机(DFA)。LeetCode 里有很多题可以套这个框架,比如判断字符串是否是合法数字、KMP 的失效函数、正则表达式匹配等。一旦你形成“维护一个有限状态,每次读入一个符号,根据转移规则更新状态”的思维模式,很多看似复杂的题目都会瞬间变得清爽。
再举一个实战中常见的例子:处理超长整数流时,判断累计值能否被某个数整除。比如网络传输中做校验,某些校验算法就是基于对数据流取模来维护一个检错状态。你不需要缓存整条流的数据,只需要一个 remainder 变量,这就是流式处理的威力。
5.4 推广:判断二进制前缀能否被 7 整除
如果把除数从 5 改成 7,核心代码几乎不用变:
java复制remainder = (remainder * 2 + bit) % 7;
但如果你想追求更优,可以研究 7 在二进制下的周期规律。因为:
code复制2^0 % 7 = 1
2^1 % 7 = 2
2^2 % 7 = 4
2^3 % 7 = 1
周期是 3。也就是说,二进制数的第 0、3、6... 位对模 7 的贡献都是 1,第 1、4、7... 位贡献都是 2,第 2、5、8... 位贡献都是 4。你可以把二进制数按三位一组分组求和,再对 7 取模。这就是“3 位一组”的快捷判断法。5 的情况类似,因为:
code复制2^0 % 5 = 1
2^1 % 5 = 2
2^2 % 5 = 4
2^3 % 5 = 3
2^4 % 5 = 1
循环节长度是 4。所以理论上也能按四位一组分组判断,但工程上收益不大,因为维护余数已经是 O(1) 空间 O(n) 时间了。
6. 我的复盘体会:这道题到底练了什么能力
把 1018 做完之后,我最大的感受是:它表面上是个“简单题”,实际上在训练两件事。
第一件事是把数学性质翻译成代码逻辑。很多人看到“可被 5 整除”第一反应是算真值,但只有在踩过溢出坑之后,才会真正理解同余定理在算法里的应用。这个理解一旦建立,以后遇到“大数取模”“流式判断”“滚动哈希”这些概念时,就会顺滑很多。滚动哈希(Rolling Hash)的底层思想就是状态转移 + 取模,只是多了一个窗口滑动的操作。
第二件事是区分“看起来对”和“真的对”。我在本地测试 [0, 1, 1]、[1, 1, 1] 全对,在 LeetCode 一提交就错,才发现是 cur 溢出了。后来我把 cur 换成 cur % 5,同一份代码从“部分通过”变成“一次通过”。这个调试过程比看十篇题解都管用。所以我建议新手拿到这道题,先故意写一个“直接算真值”的版本,跑一跑长测试用例,亲眼看到结果错误,再动手改成余数版本。亲手制造过 bug,才记得住这个坑。
耗时方面,我不建议纠结于“100ms”这种数字。LeetCode 的耗时统计波动很大,同一份代码凌晨提交和晚上高峰提交差 50ms 都不稀奇。真正应该关心的是:时间复杂度是否达到 O(n),空间复杂度是否达到 O(1)(不计返回数组),代码里有没有明显的装箱、重复遍历、字符串拼接等浪费。把这几点做到位,性能自然是达标的。
最后再分享一个小技巧:写这类“前缀判断”题目时,可以在草稿纸上先把递推公式写出来,再对着公式写代码。公式是 remainder = (remainder * 2 + bit) % 5,代码就是 remainder = (remainder * 2 + nums[i]) % 5,一个字符都不会差。如果写代码前能画一张类似“状态转移表”的图(行是当前余数 0~4,列是输入 0/1,格子是下一状态),你会发现题解里的很多优化其实都藏在这张表里。这也是我认为 1018 值得反复做、值得讲给身边人听的根本原因——它简单,但一点都不单薄。
