1. 这道"看起来很简单"的题,坑点到底在哪里
1.1 刷题群里的争论:直接乘到底行不行
前两天刷题群里有人甩出这道Leetcode 1822,配文是"简单题,秒了"。结果真上手一写,评论区直接吵起来了。吵的点不是什么高深算法,就一句话:到底能不能先把所有元素乘起来,再判断正负?
先看题目本身:给你一个整数数组,返回所有元素乘积的符号。乘积为正数返回1,乘积为负数返回-1,乘积为零返回0。
很多人的第一反应是这样的:
java复制public int arraySign(int[] nums) {
long product = 1;
for (int num : nums) {
product *= num;
}
return product > 0 ? 1 : (product < 0 ? -1 : 0);
}
我自己第一次写的时候也是这个思路,跑测试用例也全过了。但你停一下,仔细看看题目给的数据范围:数组长度最多1000,每个元素的绝对值不超过100。这意味着什么?意味着乘积的理论最大值是100的1000次方,也就是10的2000次方。这个数量级别说long存不下,就是Java的BigInteger虽然理论上能存,但计算起来也纯粹是浪费性能。
这就是这道题最阴险的地方:测试用例里塞一个长度为1000、全是100的数组,你的long直接就爆了。 乘积溢出后变成什么谁也不知道,可能恰好是正数、也可能变成负数,代码的返回值就完全不可控。
1.2 题目的数学本质:符号函数与零的特殊地位
所以这道题真正想考察的,根本不是"你会不会算乘法",而是你能不能绕开乘法,用数学性质直接判断符号。
数学上有一个很朴素的结论:若干个非零整数相乘,结果的符号只取决于其中负数的个数。如果负数的个数是偶数,结果为正;如果是奇数,结果为负。 这个结论不需要高深数学,小学学过"负负得正"就够用了。
但还有个更关键的坑:零。乘法里只要出现一个零,整个乘积就是零,符号自然是0,负数个数那套规则就不适用了。所以解这道题的基本框架是两段式判断:
- 先扫描整个数组,看里面有没有0。只要有0,直接返回0,后面不用管了。
- 如果没有0,就数负数的个数,奇数返回-1,偶数返回1。
这个思路清晰了,实现就是几分钟的事。顺便说一句,这也是"符号函数"(sign function)在实际问题里的典型应用——在计算机里,很多时候我们不关心一个数具体是多大,只关心它在数轴上的方向。
1.3 这一类"防溢出"题型的通用识别特征
把这道题往大了看,它其实属于一个非常常见的题型类别,我一般称它为"结果不可计算,但答案可判断"型题目。这类题的特征是:
- 题目让你判断某种"结果的属性",而不是结果本身。比如本题判断符号,还有判断奇偶、判断末尾有多少个零、判断是否能被某个数整除。
- 直接计算结果会超出数据类型的表示范围,或者计算量巨大。
- 存在某种不经过完整计算就能得出结论的数学规律。
类似的经典题目还有"求阶乘末尾有多少个零"(Leetcode 172),核心思路是数因子5的个数而不是真去算阶乘;还有"判断一个数的阶乘是否能被另一个数整除"之类。都是一个套路:先问自己"我能不能不算出结果就得到答案",如果答案是能,就去找那个规律。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 核心解法拆解:负数计数与零的短路判断
2.1 三段式逻辑:从零到符号的完整推导
这道题的标准解法,本质上是在维护两个布尔状态:数组里有没有零,负数的个数是奇数还是偶数。
具体逻辑可以拆成三个递进的步骤:
第一步,先找零。 为什么零优先?因为零的优先级最高。任何数乘以零都是零,只要数组里有一个零,整个乘积的符号必然为0。这个判断是"一票否决制",不需要再关心任何正负号的信息。
第二步,数负数。 如果数组里没有零,那我们就只需要关心负数的奇偶性。用一个计数器,遍历数组的时候遇到负数就加1。这里有个小优化点:不需要统计负数的"个数"具体是多少,只需要在遍历过程中不断翻转一个符号标志位,遇到一个负数就把sign取反,最后看sign是正是负就行。这样做省掉了计数器溢出的顾虑——虽然题目里负数个数最多也就1000个,不会溢出,但从代码简洁度来说,布尔翻转比计数器更优雅。
第三步,根据奇偶性返回结果。 负数个数如果是偶数,例如0个、2个、4个,乘积为正,返回1;如果是奇数,返回-1。
我用一个例子走一遍流程:数组是 [1, -2, 3, -4, 5]。先扫描,没有0,进入负数计数环节。依次读入:1不是负数,-2是负数所以计数为1,3不是,-4是负数计数变为2,5不是。最终负数个数是2,偶数,返回1。你手动算一下这几个数的乘积:1乘以-2得-2,乘以3得-6,乘以-4得24,乘以5得120,确实是正数,没毛病。
2.2 为什么不能把"判断零"和"数负数"合并成一次遍历
有人可能会问:反正都是遍历数组,我能不能用一次循环同时做两件事——既检查有没有零,又统计负数个数?
java复制public int arraySign(int[] nums) {
int negativeCount = 0;
for (int num : nums) {
if (num == 0) return 0; // 见零就返回
if (num < 0) negativeCount++;
}
return (negativeCount % 2 == 0) ? 1 : -1;
}
实际上这个写法是完全正确的,而且比我先判断有没有零再统计负数的代码更高效。我上面分开讲是为了把逻辑拆清楚,让你理解"为什么可以这么做",但真正写代码的时候,你完全可以在一次遍历里处理。
不过这里有一个值得注意的思维方式:如果你写的是"先扫描一遍看有没有零,再扫描一遍数负数",时间复杂度依然是O(n),因为常数项的差别在算法分析里被忽略了。但在实际工程里,同样的数据走两遍总是比走一遍慢。所以一个合格的程序员在理解了核心逻辑之后,应该能自己合并循环。理解时的分步思考和实现时的尽量合并,这两者并不矛盾。
2.3 时间复杂度和空间复杂度:简单题也要会算
这道题的时间复杂度是O(n),n是数组长度,因为无论如何都要遍历一遍数组才能知道每个元素的正负。空间复杂度是O(1),因为使用的额外变量只有常数个,不管数组多大,额外内存占用都不变。
这些结论本身不复杂,但如果你是准备面试,我建议你养成一个习惯:每做完一道题,都主动说出时间复杂度和空间复杂度,并且说清楚为什么。 别小看这个习惯,很多候选人写得出代码但是一到"你这个算法复杂度是多少"就卡壳。这题如果被问到,你可以这样答:遍历一次数组,所以时间是O(n);只用了常数个变量,所以空间是O(1)。答完还能补一句:"如果要优化,理论上没法低于O(n),因为你至少得看一眼数组里有没有零、有几个负数,这些信息不会凭空出现。"这句话一出来,面试官就知道你是真懂,不是背答案。
3. 多语言实现对比:同一套逻辑的不同写法
3.1 Java版本:最规范、最直白的骨架
下面这个版本是我认为最适合作为标准答案的,结构清晰,变量命名语义明确:
java复制class Solution {
public int arraySign(int[] nums) {
int negativeCount = 0;
for (int num : nums) {
if (num == 0) {
return 0;
}
if (num < 0) {
negativeCount++;
}
}
return (negativeCount % 2 == 0) ? 1 : -1;
}
}
这段代码有三个值得注意的细节:
第一,把num == 0的判断放在num < 0之前。这个顺序很讲究。虽然对一个具体的数来说,它不可能既是0又是负数,这两个判断不会冲突,但在语义上,"检查零"是"一票否决"级别的判断,放在前面更符合人类的阅读直觉——先看有没有致命问题,再看常规情况。
第二,negativeCount % 2 == 0 判断的是"是否为偶数",注意这里我没有写成 negativeCount % 2 == 1,因为如果写成后者,负数个数为0时返回的就不是1而是-1了。这个细节我在面试别人时见过有人翻车,典型的手滑型bug。
第三,用int而不用long。虽然negativeCount最多也就是1000,用什么都行,但语义上"计数"用int就够了,用long反而让人疑惑你是不是担心什么溢出——这里压根没有溢出风险。
3.2 Python版本:语法糖与可读性的平衡
python复制class Solution:
def arraySign(self, nums: List[int]) -> int:
sign = 1
for num in nums:
if num == 0:
return 0
if num < 0:
sign = -sign
return sign
Python版本的写法和Java有个微妙区别:我用的是sign = -sign的翻转方式,而不是计数再判断奇偶。这个写法在Python里非常自然,因为Python的整数可以随意取负,代码读起来也很直观:"遇到负数就把符号翻一下"。最终sign是1还是-1,直接作为结果返回。
不过要注意的是,这个写法的前提是你已经理解了"符号翻转"和"计数奇偶"的等价性。 负数个数为偶数,翻转偶数次符号不变;为奇数,翻转奇数次符号改变。本质上是一样的,但如果你对"负负得正"这个基础概念不够熟,我建议还是先用计数版本,等理解透了再换成翻转版本。
还有一个小技巧:Python里可以用math.prod直接算乘积然后判断,但和前面说的一样,大数乘积会造成不必要的性能开销,而且math.prod返回的是精确整数,当乘积是一个2000位的整数时,虽然Python的大整数能表示,但计算时间会显著增加。刷题时没必要这么做。
3.3 C++版本:位运算的极致派写法
cpp复制class Solution {
public:
int arraySign(vector<int>& nums) {
int sign = 1;
for (int num : nums) {
if (num == 0) return 0;
if (num < 0) sign = -sign;
}
return sign;
}
};
C++的写法跟Python差不多。但有些追求"最简"的选手会想到用位运算——因为正数和负数的符号位不同,num >> 31 在算术右移下正数为0,负数为-1。可以写成:
cpp复制class Solution {
public:
int arraySign(vector<int>& nums) {
int sign = 1;
for (int num : nums) {
if (num == 0) return 0;
sign *= (num > 0 ? 1 : -1);
}
return sign;
}
};
这类位运算版本能跑,但我个人的建议是:刷题阶段别追求这种"炫技"写法。 原因有二:第一,位运算的可读性差,你自己过两周回来看都可能要想一下;第二,实际面试中,面试官更看重的是你能否清晰地表达思路,而不是你是否会用位运算。把逻辑写清楚,比把一个简单的逻辑写得花里胡哨重要得多。工作里写代码总是要给别人维护的,可读性就是生产力。
3.4 三种语言实现的功能等价性与差异总结
| 语言 | 核心写法 | 亮点 | 注意点 |
|---|---|---|---|
| Java | negativeCount计数,取模判断奇偶 | 结构最规范,适合作为标准答案 | 别把% 2 == 0写成% 2 == 1 |
| Python | sign = -sign 符号翻转 | 写法简洁,语义自然 | 前提是理解翻转与计数的等价性 |
| C++ | sign = -sign 或位运算 | 性能最优 | 位运算写法可读性差,面试慎用 |
三种语言跑出来的结果完全一致,选哪个取决于你平时用哪个语言刷题。但无论选哪个,核心逻辑都是一样的:先查零,再查负数奇偶性。这也说明一个道理:算法题解的核心在思路,语言只是载体。
4. 这道题最容易踩的坑:边界条件与典型错误
4.1 必须覆盖的五类测试用例
刷题时不能只在LeetCode上跑那几十个判题用例就完事,自己得有意识地构建边界用例。这道题我建议你至少手动测这五类:
- 单元素数组:
[1]应返回1,[-1]应返回-1,[0]应返回0。单元素是判断逻辑是否正确的最小区块。 - 全正数数组:
[1, 2, 3, 4]应返回1。没有负数时,负数个数是0,0是偶数,所以结果是正。 - 全负数数组:
[-1, -2, -3]应返回-1(3个负数,奇数为负);[-1, -2, -3, -4]应返回1(4个负数,偶数为正)。 - 含零的混合数组:
[-1, 0, 2, -3]应返回0。只要有一个0,其他元素都不用管了。 - 最大规模极端数组:1000个元素,全部是100,应返回1;全部是-100,1000个负数,偶数为负,应返回1;999个-100加1个100,999是奇数,应返回-1。
尤其是第五条,这是用来测你代码是否真的是O(n)且无溢出的关键用例。 如果你的解法是"先算出乘积再判断符号",这一组数据直接暴露问题。
4.2 我见过的高频错误写法
我在帮人review代码时,见过这道题不下五种错误写法。比较典型的有下面几类:
错误一:把乘积存进int/long。 这是最普遍的错误,代码跑通了一些小用例,但一遇到大数组就崩。原因前面已经说透了:溢出。
错误二:用product > 0直接判断,但没考虑多个负数相乘后的溢出干扰。 有些人在乘积里做了取模运算想防溢出,但取模之后符号信息可能就变了,反而得出错误结论。这种方法属于"用错误的方法处理错误的问题"。
错误三:把0当作"不算负数"处理但忘了直接返回0。 比如下面这个写法:
java复制public int arraySign(int[] nums) {
int negativeCount = 0;
for (int num : nums) {
if (num < 0) negativeCount++;
}
return (negativeCount % 2 == 0) ? 1 : -1;
}
看到问题了吗?[1, 0, 2]这个输入,负数个数是0,返回1,但正确答案是0。这就是没有处理零这个"一票否决"条件的典型错误。
错误四:对"正数的符号是1"和"负数的符号是-1"的混淆。 这道题返回值只有三个可能:1、-1、0。有些人会返回nums[i]本身的值,比如遇到正数返回正数本身,遇到负数返回负数本身,这显然是没读题。
4.3 边界条件的直觉训练:从"对不对"到"稳不稳"
做这道题的最高境界,不是跑通用例,而是一眼看出自己的代码在什么情况下会出错。比如看到return (negativeCount % 2 == 0) ? 1 : -1;这行,你立刻就应该想到:如果数组里全是正数,negativeCount是0,0 % 2 == 0成立,返回1,没毛病;但如果数组里恰好有一个0,这个返回就不该执行,因为前面的逻辑已经return 0了。所以你的代码是否能应对所有边界,取决于你能否在脑海里把数组的每一种极端情况都过一遍。
我自己的习惯是:写完代码后,不要急着提交,先自己脑内跑几个极端用例。单元素数组、全是同一个数的数组、最大值数组、空数组(虽然本题说了长度至少为1)……这个过程刚开始有点烦,但坚持一段时间后,你对边界条件的敏感度会明显提升,写出来的代码自然就更稳。
5. 题目延伸:从Leetcode 1822到一类"符号与奇偶"问题
5.1 变体一:不允许显式判断零,怎么办
有些变体题会故意给你加限制,比如"不许写if (num == 0)"。这个限制乍一看很恶心,但其实可以绕开:你可以用数组元素的乘积因子来分析,或者利用这样一个性质——零的符号是0,所以如果整个乘积是0,那么乘出来结果必然有一个因子是0。从数学上看,这个限制反而逼迫你去思考零的本质。
不过说实话,这类强制限制的变体在实际面试中不多见,更多的是一种思维训练。我在面试别人时偶尔会问:"如果不允许使用乘法,你还能判断乘积的符号吗?"问的目的不是考你有没有背过题,而是看你能不能从"乘法有什么性质"出发,自己推导出"符号只取决于负数奇偶性"。能够现场推导规律,比记住某个题的答案重要得多。
5.2 变体二:返回正数个数和负数个数
如果题目改成"返回数组中正数的个数和负数的个数",核心思路就完全不同了,变成单纯的统计问题。但你会发现,统计正数个数、负数个数这道题,和判断积的符号这道题,在"遍历数组并分类"这个层面上是同一个框架——都是把零、正、负三类分开处理,只不过输出不同。
这给我们一个启发:刷题不要孤立地刷,看到类似结构,可以自己尝试改一改条件,看解法会怎么变。 比如把"返回符号"改成"返回有多少个连续子数组乘积为正",难度直接上一个量级。Leetcode 1567就是这样的题目,它需要记录当前连续段的正负状态,而不是只统计全局负数个数。从Leetcode 1822到Leetcode 1567,就是一个从"单点状态"到"动态规划状态"的跳跃。
5.3 变体三:大数情况下的"符号判断"诉求
在真实工程场景里,你不太可能真的遇到"需要判断一个超长数组乘积符号"的需求。但"在无法直接计算结果的情况下,利用某个可观测属性推断结果"的思想,在工程中非常常用。比如:
- 在数据校验中,你可能需要判断一个整数是否溢出,但你不想真的去执行乘法或加法,而是通过判断两个操作数同号还是异号来预测结果是否会溢出。
- 在浮点数计算中,你有时不关心最终数值有多精确,只关心结果是不是正数。此时可以先分析可能影响符号的运算步骤。
- 在图形学里,判断一个点在三角形哪一侧,用的就是叉积的符号。这类问题里,符号本身成为核心信息,而不是数值大小。
所以如果你觉得这道题太简单,不妨把它当作一个引子,想想"符号"这个抽象属性在计算机科学里的广泛应用。一旦建立了这种联想,刷题就不再是背答案,而是在积累一个个可迁移的思维模型。
5.4 从"简单题"到"面试信号":为什么面试官爱出这类题
Leetcode 1822这样的题目,在真正的面试中出现的概率不算很高,但它代表了一类很适合面试的题型:代码量少、逻辑清晰、有边界陷阱、能考察候选人的数学敏感度。 面试官可以从你的代码里看到:
- 你有没有意识到不能直接乘?这是一个数学能力与工程经验的信号。
- 你如何处理零这个特殊元素?这反映了你对边界条件的敏感程度。
- 你能不能解释为什么负数奇偶性决定符号?这反映了你是否理解自己写的代码,而不是背出来的。
- 你有没有主动说时间复杂度和空间复杂度?这反映了你的算法基本功是否扎实。
所以哪怕你在刷题时碰到了一道"简单题",也值得用面试的标准来要求自己:写清楚、讲明白、能扩展到相似问题。一道简单题能讲出三层东西,比一道难题只会套模板要强得多。
6. 我的实际做题心得与避坑经验
6.1 关于"秒杀简单题"的心态问题
说句实在话,Leetcode 1822这道题在"简单"级别里属于偏简单的,核心代码不到十行。但正因为简单,很多人反而不当回事,看一眼题目就开始写,结果在溢出这个坑里翻车却完全不自知,直到提交后看到"Wrong Answer"或"Runtime Error"才反应过来。
我自己刷题这些年,最大的一个体会是:简单题是用来练"稳"的,不是用来练"快"的。 所谓稳,就是你写的每一道简单题,都能一次性通过所有边界测试,并且能跟别人讲清楚为什么这样写是对的。如果每次做简单题都是"哎呀我居然漏了零的情况",那说明你的思维习惯还有改进空间。想改也不难,就是我在第4节说的:写完代码,先自己脑内跑几个极端用例再提交。
6.2 一个真正帮我避开坑的检查清单
针对这道题,我建立一个"符号判断类问题"的检查清单,分享出来供你参考:
- 零检查:数组里有没有零?如果有,结果是不是就直接定了?
- 负数奇偶性:不直接算乘积,能通过负数的个数判断符号吗?
- 溢出风险:我写的代码会不会因为数值太大而溢出?如果会,我的方案是否需要重新设计?
- 单元素/空数组:数组长度很小时,逻辑还成立吗?
- 全正/全负/零混合:最极端的几类数组,输出是否符合直觉?
- 复杂度:我能否准确说出时间复杂度和空间复杂度?
这六条不仅适用于Leetcode 1822,任何一道"判断某种结果属性"的题目,你都可以套这个模板自查。用熟了以后,你写代码的"一次通过率"会明显上升。
6.3 最后分享一个刷题效率的小技巧
很多人刷题是"看完题目→直接写代码→提交→错了再看答案",这个流程的问题在于,你跳过了"设计"这一步,而设计恰恰是刷题的核心价值。 我个人的习惯是:拿到任何一道题,先强迫自己用文字描述思路,哪怕只是在脑子里说一句话也行。比如这道题,我的思路描述是:"因为乘积符号只取决于负数的奇偶性,所以遍历数组统计负数个数同时检查有没有零,遇到零直接返回0,最后根据奇偶性返回1或-1。"
别小看这几十秒钟的自言自语。等你哪天真去面试,你会发现在白板上写代码时,最大的敌人不是不会写,而是思路混乱。如果从一开始就养成了"先想清楚再动手"的习惯,面试时的表现会稳定很多。这也是为什么我今天愿意为这样一道"简单题"写一篇长文——吃透一道题,真的比囫囵吞十道题有用得多。
