
力扣第13题罗马数字转整数很多人第一反应是建哈希表然后开始枚举IV、IX、XL、XC、CD、CM六种组合。我最早也是这样写的代码能过但总觉得逻辑绕。后来翻Python标准库的itertools文档看到pairwise这个函数脑子里突然冒出一个想法罗马数字的减法规则本质上不是“某个字符有多特殊”而是“当前字符和右边字符谁大谁小”的关系。pairwise恰好就是用来处理相邻元素关系的工具。把两者一结合这道题的最简解法就出来了代码短到不像是一道需要动脑的题。这篇文章把我从第一版错误代码到最终pairwise版本的完整过程记录下来包括踩过的坑和排查思路给同样在刷力扣、被字符串题绕晕的朋友做个参考。1. 为什么pairwise会让罗马数字题目突然变简单1.1 罗马数字的减法规则才是整道题的题眼先把题目的底层逻辑看清楚。罗马数字一共有七个字符I1V5X10L50C100D500M1000。如果只是把所有字符对应的数字加起来那“VI”加出来是6没问题但“IV”加出来也是6标准答案却是4。问题就出在“较小的数字写在较大的数字左边时表示减法”。很多人一开始会去背六种特殊情况IV4、IX9、XL40、XC90、CD400、CM900。背下来之后写一堆if或者contains判断。能过吗能。但代码很丑而且总觉得没有真正理解这道题。实际上罗马数字的规则可以压缩成一句话当前字符如果小于右边相邻字符它对结果的贡献就是负数否则是正数。最后一个字符没有右边永远为正。我举个例子把这句话落地。拿“MCMXCIV”也就是1994来拆解M和C是相邻对M比C大M这一位贡献1000C和M是相邻对C比M小C这一位贡献-100M和X是相邻对M比X大M这一位贡献1000X和C是相邻对X比C小X这一位贡献-10C和I是相邻对C比I大C这一位贡献100I和V是相邻对I比V小I这一位贡献-1最后一位V没有右边贡献5加起来就是1000 - 100 1000 - 10 100 - 1 5 1994。可以看到我全程没有去背任何一组特殊组合只需要比较相邻字符的大小关系。1.2 pairwise是什么一句话讲清楚pairwise是Python 3.10版本进入itertools标准库的一个函数。它的作用非常单纯把一个可迭代对象中的元素两两配成相邻对然后返回一个迭代器。比如s MCMXCIVpairwise(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)VI所以加5最后算出来是5少了1。问题根源在于pairwise天然只产出n-1对最后一个字符永远不在任何pair里。你必须把它单独拿出来。改成ans value[s[-1]]后所有单字符用例都正确了。这个坑非常隐蔽因为前几个长字符串测试可能刚好能算对一半让人误以为逻辑没问题。3.4 最终版代码和用例验证最终提交的版本就是我在2.2节贴出的代码。为了让自己放心我手动验证了几个关键用例输入pairwise产生的配对计算过程结果III(I,I)(I,I)两个I各1最后一位I13IV(I,V)IV-1最后一位V54LVIII(L,V)(V,I)(I,I)(I,I)L50V5两个I各1最后一位I158MCMXCIV(M,C)(C,M)(M,X)(X,C)(C,I)(I,V)1000-1001000-10100-151994全部正确。提交到力扣后运行时间大概在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和DL是50不是500D是500不是50。我自己的记忆方法是记住一个序列I、V、X、L、C、D、M对应的数字是1、5、10、50、100、500、1000。规律是每隔一个字符就翻倍或者跳到下一个数量级。前三个正好是1、5、10后面L是50C是100D是500M是1000。多写几次映射表手就记住了。4.5 输入非法怎么办要不要写校验力扣保证输入是1到3999的有效罗马数字所以不需要校验合法性。但如果你扩展思考一下假如输入是非法字符串“IIV”用我们的pairwise算法会算出什么过程是(I,I)相等加1(I,V)中IV减1最后一位V加5结果5。这个结果本身没有标准答案因为“IIV”就不是一个严格合法的罗马数字。所以这套算法适用前提是“输入符合罗马数字语法”这一点在力扣场景下是给好的前提你不需要额外处理。5. 从这道题看pairwise的适用边界与建模思维5.1 什么时候应该想到用pairwise做题多了你会发现很多题目的核心逻辑都可以归纳成“相邻元素之间的关系”。比如判断一个序列中相邻元素是否严格递增统计相邻元素差值检查有没有连续重复字符甚至某种分组统计问题。只要题面里出现“相邻”“左右两个数”“连续”这类关键词而且要比较或处理两个位置之间的关系pairwise就有用武之地。反过来如果题目要求的是“每个元素和所有其他元素的关系”比如三数之和、两层循环暴力匹配那pairwise就完全帮不上忙。它是处理相邻关系的专用工具不是通用的组合生成器。把这一点想明白你就能在刷题时更快地定位到合适的数据结构和工具。5.2 用pairwise写代码最大的收益其实是不容易写错边界回想一下传统遍历写法你每次都要想清楚循环从0开始还是从1开始要不要减1访问s[i1]会不会越界这些问题本身很简单但在真实的刷题状态下越简单的边界越容易在紧张时出错。用pairwise之后这些边界问题被封装进了标准库循环体里只剩下“业务逻辑”——比较大小、加减值。代码读起来几乎就是题目规则的逐句翻译自然不容易写错。我在实际刷题中的体会是很多bug不是算法想错了而是边界的“差一错误”磨掉大量时间。pairwise这种工具能帮你把这种错误概率直接降为零这是它比手写for循环更珍贵的地方。5.3 刷力扣时怎么训练这种“建模思路”罗马数字转整数是一个非常好的教学案例因为它表面上是字符串题实际上是相邻关系建模题。拿到题目时先别急着写代码问自己一句话题目里的规则描述的是“元素本身的性质”还是“元素和周围元素的关系”如果是后者就去找那个关系到底是什么再用合适的工具去表达它。比如这题里的减法规则明显是“相邻关系”——当前字符的值取决于右边那个字符的值。把关系拆成“大于等于”和“小于”两种算法就出来了。多训练几次这种翻译能力你会发现同一道题能写出很多种解法而你选择那种代码最贴近规则描述的解法那才是真正理解了这个模型。以后再看到字符串、数组、链表题你会有一种“规则翻译成代码”的直觉而不是背模板。