力扣第13题罗马数字转整数,很多人第一反应是建哈希表,然后开始枚举IV、IX、XL、XC、CD、CM六种组合。我最早也是这样写的,代码能过,但总觉得逻辑绕。后来翻Python标准库的itertools文档,看到pairwise这个函数,脑子里突然冒出一个想法:罗马数字的减法规则本质上不是“某个字符有多特殊”,而是“当前字符和右边字符谁大谁小”的关系。pairwise恰好就是用来处理相邻元素关系的工具。把两者一结合,这道题的最简解法就出来了,代码短到不像是一道需要动脑的题。这篇文章把我从第一版错误代码到最终pairwise版本的完整过程记录下来,包括踩过的坑和排查思路,给同样在刷力扣、被字符串题绕晕的朋友做个参考。
1. 为什么pairwise会让罗马数字题目突然变简单
1.1 罗马数字的减法规则,才是整道题的题眼
先把题目的底层逻辑看清楚。罗马数字一共有七个字符:I=1,V=5,X=10,L=50,C=100,D=500,M=1000。如果只是把所有字符对应的数字加起来,那“VI”加出来是6,没问题;但“IV”加出来也是6,标准答案却是4。问题就出在“较小的数字写在较大的数字左边时,表示减法”。
很多人一开始会去背六种特殊情况:IV=4、IX=9、XL=40、XC=90、CD=400、CM=900。背下来之后写一堆if或者contains判断。能过吗?能。但代码很丑,而且总觉得没有真正理解这道题。实际上罗马数字的规则可以压缩成一句话:当前字符如果小于右边相邻字符,它对结果的贡献就是负数,否则是正数。最后一个字符没有右边,永远为正。
我举个例子把这句话落地。拿“MCMXCIV”也就是1994来拆解:
- M和C是相邻对,M比C大,M这一位贡献+1000
- C和M是相邻对,C比M小,C这一位贡献-100
- M和X是相邻对,M比X大,M这一位贡献+1000
- X和C是相邻对,X比C小,X这一位贡献-10
- C和I是相邻对,C比I大,C这一位贡献+100
- I和V是相邻对,I比V小,I这一位贡献-1
- 最后一位V没有右边,贡献+5
加起来就是1000 - 100 + 1000 - 10 + 100 - 1 + 5 = 1994。可以看到,我全程没有去背任何一组特殊组合,只需要比较相邻字符的大小关系。
1.2 pairwise是什么,一句话讲清楚
pairwise是Python 3.10版本进入itertools标准库的一个函数。它的作用非常单纯:把一个可迭代对象中的元素,两两配成相邻对,然后返回一个迭代器。比如s = "MCMXCIV",pairwise(s)会依次产出:
('M', 'C') ('C', 'M') ('M', 'X') ('X', 'C') ('C', 'I') ('I', 'V')一共6对,正好是字符串长度减1。注意最后一对是(I, V),不会再出现(V, 某个元素),因为它只配对到倒数第二个元素为止。
打个比方,一排人排队,pairwise就是让相邻的两个人临时组合成“前后桌”。第一个人和第二个人一组,第二个人和第三个人一组,依次下去,最后一个人不动,因为后面没人了。这个“最后一个人落单”的特性在罗马数字题里反而很有用——最后一位的规则本来就是独立确定的。
如果你的Python版本还没到3.10,也可以自己用zip和切片实现相同的效果:
zip(s, s[1:])对于字符串来说,这两种方式产生的配对完全一样。我后面写代码时会给出两个版本,方便你在力扣和本地环境之间自由切换。
2. pairwise解力扣第13题:核心设计与代码
2.1 三种主流解法,我实际都试了一遍
先说“哈希表加六组特判”的写法。思路很简单:先把所有字符的值加一遍,再在字符串里查找那六种特殊组合,每找到一个就减去两倍的前一个字符值。因为前一个字符被多加了一次,而它实际应该做减法,所以减去两倍才能“纠正”过来。
这种写法的问题在于代码分支多,而且顺序很容易搞错。比如“MCMXCIV”,如果我只检查了“CM”没检查“XC”,结果就是错的。你还要保证每个特殊组合最多出现一次,一旦出现连续嵌套的写法,心理压力会很大。能过题,但不够清爽。
再说传统遍历写法:
class Solution: def romanToInt(self, s: str) -> int: value = { 'I': 1, 'V': 5, 'X': 10, 'L': 50, 'C': 100, 'D': 500, 'M': 1000 } ans = 0 for i in range(len(s)): if i + 1 < len(s) and value[s[i]] < value[s[i + 1]]: ans -= value[s[i]] else: ans += value[s[i]] return ans这个版本逻辑上是对的,但每次都要在循环里判断i + 1 < len(s),总有一种边界处理的不安全感。我真正想要的是:把“取相邻对”这件事交给工具,循环体里只留业务逻辑。pairwise就是来干这个的。
2.2 pairwise版本:把“数字值”变成“符号值”
用pairwise重写之后,核心思路发生了一个微妙转变:我不再是“逐位判断该加还是该减”,而是先把每个字符位抽象成一个带符号的贡献值。
当前字符cur和右边字符nxt配对后,会出现两种情况:
- value[cur] 小于 value[nxt],说明cur在减法位置上,贡献为 -value[cur]
- value[cur] 大于等于 value[nxt],说明cur在加法位置上,贡献为 +value[cur]
最后一个字符单独处理,永远为正。这个建模成立的理由在于,罗马数字中“减法”只发生在相邻两个字符之间,而且只需要判断大小,不需要管具体是哪两个字符。这比单独记忆六种特例要本质得多。
于是核心代码变成了这样:
from itertools import pairwise class Solution: def romanToInt(self, s: str) -> int: value = { 'I': 1, 'V': 5, 'X': 10, 'L': 50, 'C': 100, 'D': 500, 'M': 1000 } ans = value[s[-1]] for cur, nxt in pairwise(s): if value[cur] < value[nxt]: ans -= value[cur] else: ans += value[cur] return ans这里最关键的一行是ans = value[s[-1]]。因为pairwise只会产出n-1对,最后一个字符永远不会出现在循环里,所以干脆在初始化时把它加进去。这样一来,循环体里完全不需要判断越界,也不需要判断“这是不是最后一个元素”。
2.3 兼容低版本Python的两种写法
力扣目前主流的Python3环境已经支持3.10以上,直接用from itertools import pairwise没有问题。如果你本地装的是旧版本,或者面试时不允许用标准库,有两个替代方案。
第一个是用zip(s, s[1:]),原理和pairwise一模一样:
class Solution: def romanToInt(self, s: str) -> int: value = { 'I': 1, 'V': 5, 'X': 10, 'L': 50, 'C': 100, 'D': 500, 'M': 1000 } ans = value[s[-1]] for cur, nxt in zip(s, s[1:]): if value[cur] < value[nxt]: ans -= value[cur] else: ans += value[cur] return ans第二个方案是用itertools里的tee手动实现pairwise。原理是先复制出两个迭代器a和b,让b跳过第一个元素,再用zip把a和b拼起来:
from itertools import tee def my_pairwise(iterable): a, b = tee(iterable) next(b, None) return zip(a, b)注意tee复制出来的迭代器是独立的,next(b, None)不会影响a。这个方法在理解pairwise内部原理时很有帮助,实际刷题时用前两种就足够了。
2.4 顺带说一句:第12题不适合用pairwise
力扣还有个第12题“整数转罗马数字”,方向正好反过来,是把一个整数拆成罗马数字字符串。这种题的核心是“从大到小贪心取数字”,比如3999要先取3000也就是MMM,再取900,也就是CM。这个过程中不存在“相邻字符比较”的需求,pairwise帮不上忙。
我见过有人学了一个工具就到处套,结果在第12题上卡了半天。结论很简单:pairwise擅长处理“已存在的序列中相邻元素的关系”,不擅长“从零开始生成序列”。分清这一点,你才能在不同题目里选对工具。
3. 实操过程:从第一版错误代码到最终提交通过
3.1 第一版:只加不减,样例直接错
我最初写这道题时,脑子里只有“把每个罗马字符对应的值加起来”,于是写出了下面这种代码:
ans = 0 for ch in s: ans += value[ch] return ans拿“III”一跑,输出3,对了。拿“LVIII”一跑,输出58,也对了。然后拿“IV”一跑,输出6,预期4,直接翻车。原因就是忽略了减法规则。这一步的错误其实是好事,它逼着我去重新读题目规则,最后才意识到“相邻字符的大小关系”才是关键。
3.2 第二版:先加后扣,代码丑但能跑
意识到有减法规则后,我的第二版走的是“先加所有值,再扣特殊组合”的路子。思路是先把所有字符的值加起来,然后扫描六种特殊组合,每出现一次就扣掉两倍的前一个字符值。
我写了一个字典:
special = {'IV': 2, 'IX': 2, 'XL': 20, 'XC': 20, 'CD': 200, 'CM': 200}这里的2、20、200分别对应两倍的I、X、C的值。然后用一个循环去字符串里找这些子串。测试了几个例子能过,但我心里很清楚这份代码有一个隐患:如果输入字符串里出现两次特殊组合,或者组合之间有交叉,计数很容易出错。虽然力扣第13题保证输入是合法的罗马数字,理论上不会出现“IVI”这种重叠写法,但读代码的人不一定理解这个隐含前提。这个版本我最终没有提交,因为读起来太累。
3.3 第三版:pairwise版本,但忘记加最后一位
然后就是重写pairwise版本。第一稿我犯了一个特别典型的错误:
ans = 0 for cur, nxt in pairwise(s): if value[cur] < value[nxt]: ans -= value[cur] else: ans += value[cur] return ans看着好像没啥问题,但跑“I”这个用例时,pairwise产出为空,循环一次都不执行,ans仍然是0。正确答案应该是1。再跑“VI”,pairwise产出(V, I),V>I所以加5,最后算出来是5,少了1。
问题根源在于:pairwise天然只产出n-1对,最后一个字符永远不在任何pair里。你必须把它单独拿出来。改成ans = value[s[-1]]后,所有单字符用例都正确了。这个坑非常隐蔽,因为前几个长字符串测试可能刚好能算对一半,让人误以为逻辑没问题。
3.4 最终版代码和用例验证
最终提交的版本就是我在2.2节贴出的代码。为了让自己放心,我手动验证了几个关键用例:
| 输入 | pairwise产生的配对 | 计算过程 | 结果 |
|---|---|---|---|
| III | (I,I),(I,I) | 两个I各+1,最后一位I+1 | 3 |
| IV | (I,V) | I<V,-1,最后一位V+5 | 4 |
| LVIII | (L,V),(V,I),(I,I),(I,I) | L+50,V+5,两个I各+1,最后一位I+1 | 58 |
| MCMXCIV | (M,C),(C,M),(M,X),(X,C),(C,I),(I,V) | +1000-100+1000-10+100-1+5 | 1994 |
全部正确。提交到力扣后运行时间大概在40ms左右,内存消耗13MB上下,跟传统写法几乎没有性能差别。
4. 常见问题与排查技巧实录
4.1 pairwise有没有版本限制?没有环境怎么办?
有,pairwise是Python 3.10才加入itertools的。如果你用的是3.8、3.9,直接from itertools import pairwise会报ImportError。两个解决办法:一是升级Python到3.10以上,二是用zip(s, s[1:])代替。力扣现在的Python3环境已经支持pairwise,但你在本地旧环境调试时可能会踩到版本坑,建议先确认python --version。
如果面试官明确说不能用标准库工具,那手写一个pairwise也很简单,用我前面给的tee实现就行。我一般会先写zip版本,然后随口补一句“其实Python 3.10里有一个等价的itertools.pairwise”,这样既展示了代码能力,也展示了对标准库的熟悉程度。
4.2 为什么用value[s[-1]]初始化,而不是在循环里特判?
因为pairwise的语义就是“只处理相邻对”,最后一位没有后缀,天然不属于配对范围。与其在循环里加一个if i == len(s) - 1的特判,不如把最后一位直接作为初始值,循环体里就干干净净。
这个设计还有一个额外好处:对于单字符输入“I”,循环一次不跑,直接返回1,天然正确。如果写传统下标循环,单字符输入还得小心i + 1 < len(s)的判断,否则访问s[i + 1]就出界了。
4.3 空字符串怎么办?需要额外处理吗?
力扣第13题保证了输入s至少有一个字符,所以value[s[-1]]不会越界。但如果你把这个函数拿去做通用工具,最好在前面加一行:
if not s: return 0这属于防御性编程,多写一行不亏。我自己在本地测试时因为手滑传入过空字符串,直接被IndexError提示打懵了一下,后来才想起是因为没有判空。
4.4 罗马数字映射表有没有记忆技巧?
七个字符里最容易搞混的是L和D:L是50不是500,D是500不是50。我自己的记忆方法是记住一个序列:I、V、X、L、C、D、M,对应的数字是1、5、10、50、100、500、1000。规律是每隔一个字符就翻倍或者跳到下一个数量级。前三个正好是1、5、10,后面L是50,C是100,D是500,M是1000。多写几次映射表,手就记住了。
4.5 输入非法怎么办?要不要写校验?
力扣保证输入是1到3999的有效罗马数字,所以不需要校验合法性。但如果你扩展思考一下:假如输入是非法字符串“IIV”,用我们的pairwise算法会算出什么?过程是(I,I)相等加1,(I,V)中I<V减1,最后一位V加5,结果5。这个结果本身没有标准答案,因为“IIV”就不是一个严格合法的罗马数字。所以这套算法适用前提是“输入符合罗马数字语法”,这一点在力扣场景下是给好的前提,你不需要额外处理。
5. 从这道题看pairwise的适用边界与建模思维
5.1 什么时候应该想到用pairwise
做题多了你会发现,很多题目的核心逻辑都可以归纳成“相邻元素之间的关系”。比如判断一个序列中相邻元素是否严格递增,统计相邻元素差值,检查有没有连续重复字符,甚至某种分组统计问题。只要题面里出现“相邻”“左右两个数”“连续”这类关键词,而且要比较或处理两个位置之间的关系,pairwise就有用武之地。
反过来,如果题目要求的是“每个元素和所有其他元素的关系”,比如三数之和、两层循环暴力匹配,那pairwise就完全帮不上忙。它是处理相邻关系的专用工具,不是通用的组合生成器。把这一点想明白,你就能在刷题时更快地定位到合适的数据结构和工具。
5.2 用pairwise写代码,最大的收益其实是不容易写错边界
回想一下传统遍历写法,你每次都要想清楚:循环从0开始还是从1开始?要不要减1?访问s[i+1]会不会越界?这些问题本身很简单,但在真实的刷题状态下,越简单的边界越容易在紧张时出错。用pairwise之后,这些边界问题被封装进了标准库,循环体里只剩下“业务逻辑”——比较大小、加减值。代码读起来几乎就是题目规则的逐句翻译,自然不容易写错。
我在实际刷题中的体会是,很多bug不是算法想错了,而是边界的“差一错误”磨掉大量时间。pairwise这种工具能帮你把这种错误概率直接降为零,这是它比手写for循环更珍贵的地方。
5.3 刷力扣时怎么训练这种“建模思路”
罗马数字转整数是一个非常好的教学案例,因为它表面上是字符串题,实际上是相邻关系建模题。拿到题目时先别急着写代码,问自己一句话:题目里的规则描述的是“元素本身的性质”,还是“元素和周围元素的关系”?如果是后者,就去找那个关系到底是什么,再用合适的工具去表达它。
比如这题里的减法规则明显是“相邻关系”——当前字符的值取决于右边那个字符的值。把关系拆成“大于等于”和“小于”两种,算法就出来了。多训练几次这种翻译能力,你会发现同一道题能写出很多种解法,而你选择那种代码最贴近规则描述的解法,那才是真正理解了这个模型。以后再看到字符串、数组、链表题,你会有一种“规则翻译成代码”的直觉,而不是背模板。