我第一次做“数组元素积的符号”这道题时,心里想的是:这不就是白给?遍历一趟,把所有元素乘起来,判断正负不就行了。结果提交之后,第一个大用例直接把我打懵了——返回的符号根本不是正确答案。我盯着屏幕看了半天,才意识到问题出在乘积溢出上。
这道题在很多刷题平台上标着 Easy,但 Easy 不代表无脑。它考察的核心不是“能不能算出乘积”,而是“能不能识别出题目根本不要求你算乘积”。这篇文章我会完整拆解这道数组题目的数学本质、标准解法、四个主流语言的实现,再分享几个我实测踩过的坑和延伸变体。适合正在准备算法面试的开发者,也适合刚接触数组遍历、想搞明白“为什么不能硬乘”的编程新手。
1. 题目真正的坑:先乘后判会把符号判成“溢出符号”
1.1 我的第一版实现:直接累乘然后判断正负
先说说我最初的错误代码,C++ 版本:
cpp复制int arraySign(vector<int>& nums) {
int product = 1;
for (int x : nums) {
product *= x;
}
if (product > 0) return 1;
if (product < 0) return -1;
return 0;
}
从逻辑上看,这个代码完全自洽:乘积大于 0 返回 1,小于 0 返回 -1,等于 0 返回 0。问题不出在逻辑,而出在 int product 这个变量根本装不下真正的乘积。
题目里 nums[i] 的范围通常是 -100 到 100,数组长度最大 1000。也就是说最极端情况下乘积的绝对值是 100 的 1000 次方,这是一个 1 后面跟 2000 个零的天文数字。int 在 32 位环境下最大值是 2147483647,约 21.47 亿;long long 最大值约 9.22e18。这两个都远远装不下。
有人可能会说:“我测试小数组没问题啊。”对,小数组没问题,乘积在 int 范围内时一切正常。但问题恰恰在于,只要有一个测试用例的乘积超出范围,结果就完全不可控了。
1.2 极端测试用例:乘积溢出后,符号变成不可控的数字
我实际跑过的例子:
cpp复制vector<int> nums = {100000, 100000}; // 实际乘积 10^10
在 C++ 里,int 乘法溢出是未定义行为(UB),但在常见的 x86 编译器下,实际结果可能是某个被截断的数。比如 100000 * 100000 = 10000000000,转成 int 后变成了 1410065408,超出的高位直接被截掉,符号位也可能被改写。然后你的函数返回的是“溢出结果的符号”,而不是“真实乘积的符号”。
更闹心的是,溢出后符号可能变成正数,也可能变成负数,完全没有规律。你拿着这段代码去测一个乘积刚好超过 int 范围但真实符号为负的用例,结果可能返回 1,直接误判。
所以“先算乘积再判断符号”这条路,从工程角度来看就是错的。哪怕你把 product 换成 long long,也只是把爆掉的临界点往后移了移,并没有解决本质问题。
1.3 数学本质:乘积符号只依赖于负数个数的奇偶性
我在想通之后发现,这道题其实是一个很漂亮的数学观察:
- 只要数组里有一个 0,乘积就是 0,符号就是 0。
- 如果数组里没有 0,那么乘积的正负只取决于负数出现了奇数次还是偶数次。
- 负数出现奇数次,乘积为负;
- 负数出现偶数次,乘积为正。
- 正数不管出现多少次,都不影响符号。
这里用到的是乘法符号的“同号得正、异号得负”规则。每乘一个负数,都会把当前乘积的符号翻转一次;每乘一个正数,符号保持不变。所以,统计负数个数就足够了。
这个结论意味着:数组里每个元素的具体数值根本不重要,只需要知道它是不是 0、是不是负数。题目问的是“元素积的符号”,不是“元素积的值”,这就是整道题的核心突破口。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 标准解法:一趟遍历、三个分支、有零即零
2.1 遍历逻辑与“有零即零”的提前返回
有了上面的数学基础,代码就变得非常简单。核心逻辑是:
- 初始化负数计数器
negCount = 0; - 遍历数组每一个元素
x:- 如果
x == 0,直接返回 0; - 如果
x < 0,negCount++;
- 如果
- 遍历结束后,如果
negCount是奇数,返回 -1;否则返回 1。
这里有个非常关键的小优化:一旦遇到 0 就可以立刻返回,不需要再遍历后面的元素。因为不管后面的数是正还是负,乘以 0 之后乘积都是 0,符号必然是 0。这个“有零即零”的短路思路,在最好的情况下——数组第一个元素就是 0——整个函数只需要 O(1) 时间就结束了。
有人可能会纠结:如果提前返回,会不会漏掉对后面元素的处理?不会。因为我们的目标只是判断乘积符号,而 0 一旦出现,后续任何元素都无法改变“乘积为 0”这个事实。提前返回不是偷懒,而是利用数学性质剪掉了无效计算。
2.2 负数计数与奇偶判断的等价写法
判断 negCount 的奇偶有两种常用写法:
negCount % 2 == 1,结果为 -1;否则为 1。- 在 C/C++/Java 里也可以用位运算:
negCount & 1,结果为 1 表示奇数。
如果用三目表达式,可以写得很紧凑:
cpp复制return (negCount & 1) ? -1 : 1;
这段代码的意思:negCount 是奇数返回 -1,偶数返回 1。位运算在这里并没有比取模快多少,但在底层硬件上确实是一个更贴近机器指令的写法。面试时用 % 2 完全没问题,用 & 1 也算不上炫技,两种都清楚即可。
2.3 边界用例清单与防御性写法
我整理了一份测试用例清单,实际跑一下能让理解更深:
| 输入数组 | 期望结果 | 说明 |
|---|---|---|
[1, 2, 3] |
1 | 全是正数 |
[-1, -2, -3] |
-1 | 3 个负数,奇数个,乘积为负 |
[-1, -2, 3] |
1 | 2 个负数,偶数个,乘积为正 |
[0, -1, 2] |
0 | 含 0,直接短路 |
[-1] |
-1 | 单元素负数 |
[100] |
1 | 单元素正数 |
[] |
视题目要求 | 通常题目保证长度 ≥ 1,防御性可返回 1 |
关于空数组,题目说明里通常写了 1 <= nums.length <= 1000,所以不用处理。但如果你把这段逻辑抽象成工具函数复用,最好先想清楚空数组“所有元素乘积”的定义——按数学惯例空积为 1,符号为 1。不过这不是这道题的范围,点到即止。
3. 四个主流语言实现对照:语法不同,思路一致
3.1 C++ 版本:范围 for 循环简洁直接
cpp复制int arraySign(vector<int>& nums) {
int negCount = 0;
for (int x : nums) {
if (x == 0) {
return 0;
}
if (x < 0) {
++negCount;
}
}
return (negCount & 1) ? -1 : 1;
}
C++11 以后的范围 for 循环在这种场景下非常合适,不需要关心下标,也不需要手动处理迭代器。注意这里的形参用 vector<int>& 引用传递,避免不必要的数组拷贝。面试时如果写成 vector<int> nums 值传递,遇到大数组会有一次额外拷贝,虽然这道题规模小无所谓,但养成引用传递的习惯是好的。
3.2 JavaScript 版本:for...of 与 reduce 哪种更合适
javascript复制var arraySign = function(nums) {
let negCount = 0;
for (const num of nums) {
if (num === 0) return 0;
if (num < 0) negCount++;
}
return negCount % 2 === 1 ? -1 : 1;
};
JavaScript 里用 for...of 遍历数组是很自然的写法。有人会问:“能不能用 forEach、map、reduce 这些数组方法?”能用,但非常不推荐,尤其是需要提前返回的场景。
forEach 无法在回调里终止整个遍历,除非抛出异常;reduce 即使碰到 0,也必须继续处理完整个数组才能拿到结果,白白浪费性能。我见过有人写:
javascript复制return nums.reduce((a, b) => a * b, 1) < 0 ? -1 : 1;
这段代码不仅可能溢出,还漏掉了 0 的情况——乘积为 0 时应该返回 0,但这里会返回 1。用数组方法之前,先想清楚它是否适合“需要中断遍历”的逻辑。
3.3 Python 版本:最贴近伪代码的写法
python复制def arraySign(self, nums: List[int]) -> int:
neg = 0
for x in nums:
if x == 0:
return 0
if x < 0:
neg += 1
return -1 if neg % 2 else 1
Python 的 for 循环是最直接的。也可以用列表推导先统计负数再检查 0,但那样会遍历数组两遍。这道题 O(n) 遍历一次就够了,没必要两遍。Python 的写法几乎就是伪代码,读起来非常通透,这也是 Python 适合快速验证思路的原因。
3.4 Java 版本:位运算判断奇偶的细节
java复制public int arraySign(int[] nums) {
int neg = 0;
for (int x : nums) {
if (x == 0) return 0;
if (x < 0) neg++;
}
return (neg & 1) == 1 ? -1 : 1;
}
在 Java 里,用 (neg & 1) == 1 判断奇偶是一个很常见的小习惯,语义上和 neg % 2 == 1 完全等价。数组 int[] nums 作为方法参数时是引用传递,不需要考虑像 C++ 那样的拷贝问题。Java 的增强 for 循环在这种纯遍历场景里也足够清晰。
四种语言对比下来,核心思路完全一致:一趟遍历,统计负数,遇到 0 提前返回。语言差异只在语法层,不影响算法本质。
4. 从这道题引申出的数组遍历编码习惯
4.1 提前返回模式:把特殊情况挡在门外
这道题里最大的分支优化是“碰到 0 直接 return 0”。这种提前返回模式在数组遍历里非常常见,比如:判断数组里有没有某个元素、判断数组是否全部满足某条件、查找第一个违规位置。
提前返回最大的好处是,后续逻辑可以少考虑一种状态。比如本题如果没有提前返回,那后面统计负数的循环就要加入很多“但如果有 0 就不统计”的条件,代码会复杂很多。提前返回相当于把特殊状态直接挡在门外,让主逻辑保持“没有 0”这个前提。
我在实际写代码时有一个习惯:先把能快速得出结论的边界条件写在最前面,再写主逻辑。这样不仅代码更清晰,还能减少深层嵌套,提高可读性。
4.2 判断题优先于计算题:识别“数学判断”类问题
编程里有一类题看起来要算一个值,其实问的是这个值满足什么性质。数组元素积的符号就是典型:它看起来要“算乘积”,实际上只问“乘积是正、负还是零”。很多人栽跟头,是因为没有识别出“计算题”其实是“判断题”。
同样的例子还有:
- 判断一个数组所有元素是否都大于 0:不用排序、不用乘,直接遍历判断。
- 判断数组中是否存在重复元素:先排序再比较是一种做法,但用哈希表一次遍历更干脆。
- 判断两个字符串是否为字母异位词:不需要生成所有排列,统计字符频率即可。
这类题有一个共性:不要被“计算”的外壳骗了,先想清楚“答案到底依赖哪些信息”。这道题里,答案只依赖三件事:有没有 0、负数个数是奇是偶、其余全是正数。只要抓住这三个信息,就不用关心具体数值。
4.3 警惕“凑巧正确”的陷阱:用 forEach/reduce 会踩哪些坑
我在各种代码讨论区看到过很多“凑巧正确”的解法。比如 JavaScript 里用 reduce 累乘判断符号,面对小数组、小数值时结果是正确的,一旦数值变大,溢出问题就会暴露;面对 [0, -1] 这种用例,因为 reduce 不会提前终止,乘积确实是 0,但紧接着又被 > 0 判断成 1,错误就出现了。
还有一个经典陷阱是:用 Math.sign 直接判断每个元素的符号再相乘。比如:
javascript复制return nums.reduce((acc, cur) => acc * Math.sign(cur), 1);
这个写法看起来更“数学”,实际上依然有隐患:Math.sign(-0) 返回的是 -0,如果你拿 -0 去参与后续判断,某些场景下会得到完全不符合直觉的结果。虽然这道题里 -0 不会从 nums 中直接出现,但一旦题目扩展成浮点数数组,这种写法就危险了。所以判断类问题,优先用显式分支,别把“符号”这个东西当作普通数值去乘。
5. 变体与延伸:当“元素积的符号”被改写成别的题
5.1 变体一:乘积对 MOD 取模时的负数处理
如果题目改成“返回数组所有元素乘积对 10^9+7 取模的结果”,那就不能只统计符号了,必须要真的算乘积。但这里有个语言层面的坑:C++ 里 -5 % 1000000007 的结果是 -5,而不是 1000000002。如果不做处理直接返回,很容易出错。
正确做法是每乘完一步就取模,并且在结果为负数时加上模数:
cpp复制const long long MOD = 1e9 + 7;
long long ans = 1;
for (int x : nums) {
ans = (ans * x) % MOD;
if (ans < 0) ans += MOD;
}
return ans;
注意,如果 x 本身是负数,C++ 里 x % MOD 也是负数,所以建议先转成正数再乘,或者用上面的统一修正方式。这种细节在实际工程里很容易被忽略,尤其是从“符号判断”这种简单题目切换过来时,思维惯性会让人忘记取模的负号处理。
5.2 变体二:乘积的最后一个非零数字该怎么求
我曾在一个算法讨论组里看到有人问:“如果不让算乘积,但要求返回乘积的最后一个非零数字,怎么做?”这个变体比本题难不少。思路是:统计所有因数中 2 的个数和 5 的个数,因为末尾零是由 2 * 5 产生的;把多余的 2 或 5 剔除后,把每个元素的“去掉所有 2 和 5 因子后的剩余部分”相乘对 10 取模,最后再乘回多余的 2 或 5 对 10 取模。
大致代码逻辑:
cpp复制int lastNonZeroDigit(vector<int>& nums) {
int twos = 0, fives = 0;
int ans = 1;
for (int x : nums) {
int y = x;
while (y % 2 == 0) { y /= 2; twos++; }
while (y % 5 == 0) { y /= 5; fives++; }
ans = (ans * (y % 10)) % 10;
}
if (twos > fives) {
for (int i = 0; i < twos - fives; i++) ans = (ans * 2) % 10;
} else if (fives > twos) {
for (int i = 0; i < fives - twos; i++) ans = (ans * 5) % 10;
}
return ans;
}
这个例子说明:一旦我们把“符号”这个简单的数学性质扩展到“非零数字”,问题的复杂度就上去了。但理解了“结果依赖哪些因子”之后,思路依然清晰:不执着于算出完整乘积,而是拆解出对最终结果有影响的部分。
5.3 变体三:浮点数数组与 -0.0 的符号陷阱
如果输入的数组是 double 类型,事情会变得微妙。IEEE 754 标准里存在 -0.0 这个值,它在某些语言里和 0.0 相等,但符号位不同。
在 C++ 里:
cpp复制double x = -0.0;
if (x == 0) return true; // 返回 true
if (x < 0) return true; // 返回 false
所以如果题目允许浮点数,直接用 x < 0 判断负数会把 -0.0 漏掉。不过 -0.0 对乘积符号没有本质影响,因为 -0.0 的数值就是 0,乘积里但凡有它,结果也应该是 0。这种情况下判断 x == 0.0 就够了。这种边缘情况在普通算法题里很少出现,但一旦出现在工程代码里,会导致很难排查的 bug。
5.4 变体四:只是把结果映射改成其他值
如果题目改成“负数个数为奇数时返回 -100,否则返回 200”,本质上还是统计负数个数。这种表面改条件、底层逻辑不变的题,最怕的就是不看本质,又去傻傻地算乘积。所以做这种题时,第一反应不是“怎么写循环”,而是“这题到底要哪些信息”。
6. 实测复盘:常见错误、答题节奏与刷题启示
6.1 我实际踩过的两个错误
第一个错误前面已经说了,就是直接累乘导致溢出。我一开始的代码用 int product,测小用例没问题,一上大用例就挂。后来我改成 long long product,心想 int 会溢出,long long 总可以了吧,结果还是不行。数组长度最长 1000,元素绝对值最大 100,乘积绝对值最大是 100^1000 = 10^2000,远远超过 long long 的 9.22e18。所以换更大整型只是推迟了溢出,并没有解决根本问题。
第二个错误更隐蔽:我在统计负数时使用了 if (x < 0) neg++,但没有优先处理 0,导致遇到 [0, -1] 这种用例时,负数个数为 1,最后返回 -1,但正确答案是 0。后来我把 if (x == 0) return 0; 提到了前面,问题才解决。这个错误提醒我:遍历数组做条件判断时,分支的先后顺序会直接影响结果。
6.2 面试里这道题应该怎么答
如果这道题出现在技术面试里,我建议的回答顺序是:
- 先和面试官确认:题目说的是“符号”,不是乘积本身;
- 然后抛出核心观察:有 0 则结果为 0,没有 0 时看负数个数奇偶;
- 写代码前先说明复杂度:O(n) 时间,O(1) 空间;
- 写完代码后主动跑边界用例:含 0 数组、全正数组、全负且奇数个、全负且偶数个;
- 最后提一句“如果用 int 累乘会溢出,所以不用累乘”。
这套流程会展示你“先想清楚再动手”的习惯,而不是上来就闷头写循环。面试官通常喜欢看到这种结构化思考,哪怕题目本身很简单,也能体现出你的工程素养。
6.3 这道题给刷题新手的三个启示
第一个启示是“看清题目在问什么”。数组元素积的符号,主语是“符号”,不是“积”。第二个启示是“数学性质往往比模拟计算更可靠”。这道题的数学观察是“负数奇偶决定符号”,用好这个性质,代码量直接砍一半。第三个启示是“边界条件要主动找”。含 0 的情况、单元素的情况、空数组的情况,都应该在纸上先列出来,再去写代码。
我在实际刷题中发现,很多人做 Easy 题容易轻敌,直接套暴力解法,结果在边界条件上翻车。与其这样,不如每道题都按“题目要求什么 → 结果依赖什么 → 如何高效获取”这个思路来解,效率会高很多。
最后说点个人体会。我以前做题有个毛病,看到“返回符号”就忍不住把乘积算出来,直到在这道题上吃了溢出的亏,才真正体会到“题目问什么,你就算什么”这句话的分量。数组元素积的符号这道题,套路一点都不花哨,但它能一遍遍提醒我:拿到任何题目,先别急着套模板写代码;把题面里“结果依赖哪些信息”想清楚,往往比多写几行代码有价值得多。如果你刷题时也经常卡在 Easy 题上,不妨从这道题开始,刻意练习一下“先做数学观察,再写循环”的顺序。
