LeetCode 537 复数乘法,是我在准备机考刷题时遇到的第 11 题。刷题群里很多人把它归为“水题”,评论区也确实一片祥和,但真到了笔试环境,能在五分钟内一次写对的人并不多。原因很简单:题目给你两个字符串,比如"1+1i"和"1+-1i",让你算出它们的乘积,再以同样的字符串格式返回。复数公式不麻烦,麻烦的是字符串里的符号。这篇文章就围绕这道题,把数学公式、字符串解析、边界处理,以及如何在机考场景下三分钟写出无 Bug 版本的思路完整拆一遍。
1. 题目拆解:LeetCode 537 复数乘法到底在考什么
1.1 复数乘法公式:一行推导,为什么提交时还有人错
两个复数(a+bi)和(c+di)相乘,展开式是:
(a+bi)(c+di) = ac + adi + bci + bdi²
因为i² = -1,所以结果可以整理成:
(ac - bd) + (ad + bc)i
也就是说,最终实部是ac - bd,虚部是ad + bc。这套公式翻译成代码非常直接:
int real = a[0] * b[0] - a[1] * b[1]; int imag = a[0] * b[1] + a[1] * b[0];很多人在第一步实部就写错。我见过好几个初学朋友把实部写成a[0] * b[0] + a[1] * b[1],多了一个加号。为什么?因为在纸面上我们习惯把i² = -1记在心里,顺手就化简了,但代码里没有“符号化简”这一步,你必须在乘法公式里显式处理负号。
可以这样记:两个虚部相乘,会给实部贡献一个负号;虚部和实部交叉相乘,贡献到虚部。公式展开之后没有i参与运算,全是整数运算,所以不存在浮点误差,也没有舍入问题。这也是这道题能做“精确字符串输出”的前提。
1.2 输入格式“1+-1i”背后的生成逻辑
题目输入不是数学课本上那种1 + i的书写方式,而是直接用字符串拼接整数得到"1+1i"。看起来人畜无害,但 LeetCode 的判题系统生成输入时,是把整数直接转成字符串再接上+和i。
于是会出现下面这些看起来很不“数学”的格式:
"1+-1i":表示1 + (-1)i"-1+2i":表示(-1) + 2i"0+0i":表示0 + 0i"-1+-1i":表示(-1) + (-1)i
重点注意"1+-1i"里同时存在+和-。这里的+是实部和虚部之间的固定分隔符,-是虚部自己的符号。如果你用-作为切分依据,一定会得到错误结果。反过来,因为+在整个字符串里唯一存在,它反而是最安全的“锚点”。
这类符号陷阱在机考里特别容易让人翻车。因为本地调试时你通常会看到正常格式"1+1i",很难想象还有"1+-1i"这种输入,结果一提交就被隐藏用例教做人。
1.3 从出题角度看,这题的考核点是什么
LeetCode 537 的难度标着 Easy,但通过率并不算高,主要原因就是字符串解析对新手不友好。它表面的身份是一道数学题,实际内核是一道字符串题。
出题人考察的不是复数理论,而是两个工程能力:第一,从结构化字符串中准确提取数值;第二,按题目要求的格式重新拼接输出。这类问题在机考中很常见:输入是一个字符串,输出也必须是一个字符串,中间过程全是 int。
为什么这种题容易在机考翻车?因为机考环境不会给你友好的断点调试,你能依靠的就是对边界情况的预判。如果平时刷题只跑通示例就跑,遇到"1+-1i"这类用例就会发懵。反过来,如果你在平时把这类格式彻底弄明白,考场上就多了一道稳稳拿分的题。
2. 字符串解析实操:三种方案里我为什么推荐 indexOf
2.1 方案一:split 切分,能用但有两个隐藏问题
许多人的第一反应是String.split("+")。在 Java 里这行代码运行时会直接抛出PatternSyntaxException,原因在于 split 的第一个参数是正则表达式,孤立+在正则里表示“一次或更多次”,语法不完整。必须写成:
String[] parts = s.split("\\+");写成split("\\+")之后,拿到的是["1", "1i"]或者["1", "-1i"],你还需要把末尾的i去掉。这一步有人用replace("i", ""),有人用substring,都行。本题输入里只有一个i,所以两种写法不会出错。
Python 的split('+')没有正则语义,直接写就好。但笔试紧张时,如果你用的是 Java,却把 Python 的习惯带过来,就会卡在这一行上。说到底,split在 Java 里的“正则”属性是一个隐藏心智负担,它和直觉中的“按字面字符切分”并不完全一致。
2.2 方案二:indexOf 定位“+”和“i”,简单直接
我推荐的解析写法是定位两个锚点:+和i。
int plus = s.indexOf('+'); int iPos = s.indexOf('i'); int real = Integer.parseInt(s.substring(0, plus)); int imag = Integer.parseInt(s.substring(plus + 1, iPos));这个写法的逻辑非常透明:
+是实部和虚部的分界点,它是唯一的,不管实部有没有负号。i是虚部字符串的结束位置,它也是唯一的。substring(0, plus)取出实部数字,可能带负号,Integer.parseInt能正确处理。substring(plus + 1, iPos)取出虚部数字,同样可能带负号。
用具体的字符串走一遍:
"1+-1i",indexOf('+')返回 1,indexOf('i')返回 4,substring(0,1)得到"1",substring(2,4)得到"-1",完美。
如果实部是负数呢?比如"-1+2i",indexOf('+')返回 2,substring(0,2)得到"-1",substring(3,5)得到"2",依然没问题。
这段代码不会因为符号改变而失败,因为+永远是分界点,i永远是结束点。在 Java、Python、C++ 里都存在indexOf或类似函数,这个解题模式不需要考虑语言之间的正则规则差异,是跨语言通用的。
2.3 方案三:正则提取,通用但没必要
还有人会想到用正则一次性提取字符串里的所有整数,比如匹配(-?\d+)。好处是即使格式变化,比如实部虚部顺序交换,也能提取出来。
但代价是代码复杂度上去了。需要构建Pattern/Matcher,需要循环find(),还要把group(1)转成 int。每多一行代码就是多一个出错点,而且正则本身有转义规则,机考紧张时写错概率不低。
这里有一个判断标准:当输入格式完全确定时,不需要引入正则。正则适合处理不规则文本,不属于这种固定格式。说白了,用正则解析一个“我已经知道是什么结构”的字符串,属于杀鸡用牛刀。
2.4 三个边界细节:1i、0 实部和多余校验
第一,虚部为 1 或 -1 时,输入依然是"0+1i"、"0+-1i",不会缩写成"0+i"。所以Integer.parseInt解析到的就是整数 1 或 -1,不需要特殊处理。
第二,实部为 0 时,输出必须写"0+1i"这种形式,不能省略实部。题目要求输出保持“实部+虚部i”的格式,哪怕某一项是 0 也不能省略。
第三,parse 阶段不要做任何多余的“智能校验”。比如看到"1+-1i",不要试图把它“修正”成"1-1i",不要觉得这是非法输入。Integer.parseInt根本不在乎子串前面是否连着加号,它只关心取出来的子串本身是不是合法整数。
3. 20 行完整实现:Java、Python、C++ 一次写全
3.1 Java 完整代码与逐行解释
直接贴完整代码:
class Solution { public String complexNumberMultiply(String num1, String num2) { int[] a = parse(num1); int[] b = parse(num2); int real = a[0] * b[0] - a[1] * b[1]; int imag = a[0] * b[1] + a[1] * b[0]; return real + "+" + imag + "i"; } private int[] parse(String s) { int plus = s.indexOf('+'); int iPos = s.indexOf('i'); int real = Integer.parseInt(s.substring(0, plus)); int imag = Integer.parseInt(s.substring(plus + 1, iPos)); return new int[]{real, imag}; } }逐行解释:
parse返回长度为 2 的 int 数组,下标 0 是实部,下标 1 是虚部。indexOf('+')和indexOf('i')找到分隔位置,这两行是整道题的灵魂。- 乘法部分直接用 1.1 节的公式,两个中间结果都是 int。
- 输出用字符串拼接。有人会纠结:虚部为负时输出
"0+-2i",看起来很丑,但这正是题目要求的格式,因为它是“把整数拼回去”的结果。不要画蛇添足改成"0-2i",LeetCode 比较的是字符串,多一个字符少一个字符都不行。
3.2 Python 和 C++ 的关键差异
Python 版本非常简洁:
def complexNumberMultiply(num1: str, num2: str) -> str: def parse(s: str): a, b = s.split('+') return int(a), int(b[:-1]) a1, b1 = parse(num1) a2, b2 = parse(num2) real = a1 * a2 - b1 * b2 imag = a1 * b2 + a2 * b1 return f"{real}+{imag}i"这里split('+')是字面切分,没有 Java 的正则问题。b[:-1]去掉末尾的i,比如"-1i"变成"-1"。
C++ 可以用sscanf,格式串"%d+%di"能自动匹配带符号整数:
class Solution { public: string complexNumberMultiply(string num1, string num2) { int a1, b1, a2, b2; sscanf(num1.c_str(), "%d+%di", &a1, &b1); sscanf(num2.c_str(), "%d+%di", &a2, &b2); int real = a1 * a2 - b1 * b2; int imag = a1 * b2 + a2 * b1; return to_string(real) + "+" + to_string(imag) + "i"; } };注意sscanf的%d会读取带符号整数,所以"1+-1i"也能正确解析。严谨一点的话,可以判断sscanf的返回值是否等于 2,防止输入格式异常时变量未初始化。这道题题目保证输入合法,不加也没问题,但加上只多一行,能帮你定位数据问题。
3.3 复杂度:为什么这道题没有优化空间
时间复杂度是O(N),其中N是字符串长度。substring、parseInt都是线性操作,题目输入最长也不超过 8 个字符,所以实际运行时间几乎可以忽略。空间复杂度是O(1),不算输入输出字符串。
这类“解析-计算-格式化”的题目,复杂度从来不是重点。你不可能不读字符串就算出结果,也不可能不拼接就返回值。出题人真正要的是你在简单逻辑上保持严谨,不犯低级错误。
4. 机考实战:三分钟写出无 Bug 版本的自测清单
4.1 机考环境下最优的写题顺序
在机考里写这道题,我建议顺序固定:
- 先写 parse 函数。
- 再写乘法运算。
- 最后返回字符串。
为什么先写 parse?因为它是这题的真正难点。先搞清楚索引切分逻辑,后面的计算只是套公式。写 parse 的时候不要顺手加格式校验、异常处理,题目保证输入合法,你加了反而可能因为某个边界条件把自己绕晕。
写完 parse 之后,用题目给的示例在脑子里走一遍:"1+1i"和"1+1i"相乘,parse 得到[1,1]和[1,1],real 是1*1 - 1*1 = 0,imag 是1*1 + 1*1 = 2,返回"0+2i",和示例一致,就可以提交。
整个过程熟练之后基本控制在三分钟内。这个速度在机考里很有价值,因为你可以把时间留给后面的难题。
4.2 自测用例清单:六组输入覆盖所有符号组合
我平时刷字符串题会专门记录边界用例。LeetCode 537 建议准备下面这组,基本覆盖所有符号组合:
| num1 | num2 | 预期输出 | 覆盖点 |
|---|---|---|---|
| "1+1i" | "1+1i" | "0+2i" | 最基础乘法公式 |
| "1+-1i" | "1+-1i" | "0+-2i" | 虚部为负的解析 |
| "0+0i" | "1+1i" | "0+0i" | 全零边界 |
| "3+4i" | "1+-2i" | "11+-2i" | 实部虚部混合负号 |
| "-1+-1i" | "-1+-1i" | "0+2i" | 实部虚部都为负 |
| "1+0i" | "1+0i" | "1+0i" | 虚部为 0 |
笔试现场不能真的运行代码,但你可以挑两个手算验证。比如"3+4i"乘"1+-2i",实部是3*1 - 4*(-2) = 3 + 8 = 11,虚部是3*(-2) + 4*1 = -6 + 4 = -2,结果确实是"11+-2i"。手算一遍比盯着代码干想更能确认逻辑正确。
4.3 延伸思路:字符串数字运算的通用套路
字符串表示的数字运算是一大类题,LeetCode 537 属于其中最简单的一种。把这类题归成一个组,复习效率会高很多:
- 字符串相加:从个位开始逐位相加,处理进位。
- 字符串相乘:模拟乘法竖式,结果放在 int 数组里再拼接。
- 分数加减:分子分母分别运算,最后约分。
- 多项式乘法:本质上和复数乘法同一个逻辑,只是系数从两个变成多个。
它们的共同点是三个动作:解析输入、按数学规则计算、格式化输出。你在 537 里练熟的indexOf + substring切分方式,在字符串相加、字符串相乘里也能复用。如果遇到格式复杂到需要嵌套处理的,再考虑状态机或正则。
5. 高频坑位排查:符号、split 和 i² 的细节
5.1 Java 的 split("+") 为什么直接报错
这是一个出现频率极高的问题。Java 的split参数是正则表达式,正则里+表示“前面的元素出现一次或多次”,必须写成"\\+"。所以你会看到网上很多代码写成split("\\+")。这其实就是很多人不喜欢用 split 的原因:它把正则和字符串切分混在一起,不同语言的规则还不一样,同一个直觉在 Python 里可行,在 Java 里就炸掉。
5.2 虚部为 1 或 -1 时,输出不能缩写
数学里1i就是i,-1i就是-i,但 LeetCode 537 的输入输出都是整数拼接格式,不存在省略写法。所以 parse 时遇到"0+1i",虚部数字就是 1;输出时real + "+" + imag + "i"会生成"0+1i"。千万不要自己去优化成"0+i",提交必然错误。
5.3 实部计算里的 i² = -1 体现在哪
看到代码里 real 那一行用了减法,有人会疑惑:不是还有一个b·d·i²项吗?i²等于 -1,所以b·d·i²实际上给实部贡献的是-bd。这就是real = a[0] * b[0] - a[1] * b[1]的原因。虚部则是两个交叉项的和:a[0]*b[1] + a[1]*b[0]。
5.4 值域问题:int 还是 long
题目的输入范围是 -100 到 100,两个数相乘绝对值最多 10000,差值不超过 20000,int 完全够用。但面试里如果遇到这题的变体,告诉你 a、b 范围可能到 10^9,就必须改用 long,字符串拼接时也要用对应的转换方式。机考中先看题目约束再决定类型,这个习惯比这道题本身更重要。
我在某次模拟机考里真见过这题,当时旁边的同学提交后被判错误,他找了半天才发现是把"1+-1i"里的负号当成了分隔符。这种低级失误在笔试里最可惜。刷 LeetCode 537 的最大价值,不是学会复数乘法,而是养成“先确认输入格式,再写解析代码”的习惯。如果你现在正在准备机考,建议把这题的 parse 函数默写一遍,顺手把上面那组自测用例跑一下。能在三分钟内一次写出,这题就真的稳稳拿下了。