最近在力扣上刷到罗马数字转整数这道经典题很多人第一反应是写个if-else或者switch硬解代码又长还容易漏边界。我最初也是这么干的直到后来研究了一轮itertools里的pairwise用法才意识到这道题其实可以从“相邻字符关系”这个角度切入把逻辑彻底理顺。这篇博文就把我实际摸索出来的思路、代码和一些坑都摊开讲从pairwise的原理一直讲到力扣提交通过适合刚开始刷题的人也适合想优化解法、把工具用得更灵活的朋友参考。1. 熟悉这道题的来龙去脉罗马数字转整数到底在考什么1.1 题目描述与常规思路回顾力扣第13题“罗马数字转整数”简单来说就是给你一个罗马数字字符串让你转换成整数。罗马数字由I、V、X、L、C、D、M组成分别代表1、5、10、50、100、500、1000。与其说是考罗马数字本身不如说是在考字符串遍历和规则归纳能力。常规思路大家应该都见过从左到右遍历字符串把每个字符对应的数值加起来。但这里有个特殊规则如果小的数字在大的数字左边那么这个小的数字要被减去。比如IV表示4不是156而是5-14IX表示9XL表示40CD表示400。背后本质是当左边字符的数值小于右边字符的数值时这一对字符做减法否则做加法。我第一次写的版本是这样的单独用一个变量记录上一个字符的值然后每走一步判断一次。虽然能跑通但代码里if逻辑交错特别容易在小数在左还是大数在左这种判断上绕晕。后来我看了讨论区发现很多人用“从右往左遍历”来简化判断但这对新手来说仍然不够直观。问题的核心其实是遍历时你总是在处理“当前位置的字符”和“下一个位置的字符”之间的关系那干脆就把它们成对拿出来处理。1.2 为什么传统遍历写法容易踩坑传统写法里最常见的一个bug就是没有处理好“减号情况”和“正常累加”的边界。比如输入“MCMXCIV”如果只按顺序累加会算出来一个很大的错误结果。原因就在于像CM、XC这样的相邻组合不能简单相加。另外在Java、C这类语言里写for循环时很容易在索引上犯错。比如用i遍历到字符串倒数第二个字符时还要额外判断i1是否越界不然就抛异常。这种边界处理不仅代码丑还容易把真正的业务逻辑淹没在杂音里。而pairwise的本质就是让你避免手写索引和越界判断直接拿到一个又一个相邻对让代码更贴近“规则本身”。我个人建议刷这题时不要急着写代码先画一下思路罗马数字的字符串本质上可以拆成若干个相邻对每个相邻对都携带了一个“加还是减”的信息。一旦把这个意识建立起来后续用pairwise就是水到渠成的事。2. pairwise到底是什么先把这个工具讲透2.1 pairwise的底层逻辑相邻成对迭代pairwise这个词直译是“两两成对”。在程序世界里它通常指一种迭代方式每次从序列中拿出两个相邻的元素作为一个元组返回。举个例子字符串“ABC”用pairwise处理会依次得到(A,B)和(B,C)。注意这里不是全排列也不是组合就是原序列里挨着的两个元素。这个能力在数据处理、时间序列分析以及很多字符串算法里特别有用。比如你想统计一个句子中相邻单词的共现频率或者判断股票价格序列中连续两天的涨跌关系pairwise都是很顺手的工具。它的核心价值在于很多业务规则本身就定义在“相邻关系”上罗马数字转整数就是这样一个典型场景。理解pairwise需要抓住一个关键点它输出的每个元组都有重叠元素。即第一个元组的右元素是第二个元组的左元素。这个重叠特性正好符合“逐个字符比较相邻关系”的需求你既要关心当前位置的数字也要关心它后面的数字才能判断是加还是减。2.2 Python里如何优雅地拿到pairwise如果你用的是Python 3.10及以上版本标准库itertools里已经内置了pairwise函数。用法非常简单from itertools import pairwise for a, b in pairwise(IV): print(a, b) # 输出 I V需要注意pairwise返回的是一个迭代器不是列表如果你需要多次遍历或访问特定元素记得先转成列表。内置版本在C层面实现性能很好适合力扣这种要求运行效率的场景。如果力扣环境不支持或者你用的Python版本比较老也别慌。用两行代码就能实现同样效果def my_pairwise(iterable): a, b iter(iterable), iter(iterable) next(b, None) return zip(a, b)这个实现的核心是用zip把原迭代器和一个“错位一格”的迭代器对齐。由于两个迭代器指向同一个底层序列每次zip取出左边一个和右边一个恰好就形成了相邻对。这是我很喜欢的一个技巧理解了它你就真正吃透了pairwise的原理。2.3 手写一个自己的pairwise避免环境依赖面试和刷题现场最怕的就是工具环境不给你用。虽然Python 3.10已经有内置pairwise但力扣的老版本编辑器或者面试白板未必能保证环境支持。所以能徒手写出pairwise逻辑比单纯会调用重要得多。我最推荐的徒手写法是基于索引循环def pairwise_by_index(s): for i in range(len(s) - 1): yield s[i], s[i 1]为什么推荐这个写法因为它是直接从“相邻”这个定义出发没有任何魔法。就算你以后用Java或C写也能照搬这个思路。而且在面试时手动写索引版本还能顺便让面试官看到你对循环边界是心里有数的不会觉得你只会调包。如果你的数据不是字符串而是列表同样可以用上面这个索引版本。我实测下来对于长度为n的序列这个生成器产生的相邻对数量是n-1这个结论在做复杂度分析时非常关键。3. 用pairwise重写解法从原理到代码一次性走通3.1 核心规则映射与映射表设计罗马数字转整数首先需要一个数值映射表。这里我建议用字典dict因为查询时间复杂度是O(1)而且代码看起来清晰。映射表如下roman_map { I: 1, V: 5, X: 10, L: 50, C: 100, D: 500, M: 1000 }有了映射表pairwise拿到的每个相邻对都可以翻译成两个数值。接下来判断逻辑就变成如果前一个数值小于后一个数值说明这是像IV、IX、CD这种需要减法的组合我们就从总数中减去前一个数值否则就加上前一个数值。这里有一个非常容易搞混的点相邻对的第二个元素怎么办比如“IV”这个字符串pairwise只会产生一个对(I, V)按照我们的规则“I”值1小于“V”值5所以减1总数变成-1。那你可能会问“V”还没加呢没错“V”会作为下一轮相邻对的第一个元素吗并不会因为“IV”只有两个字符pairwise只产生一个对。那最后一个字符的值就丢了怎么办解决办法很巧妙在遍历完所有相邻对之后单独把整个字符串的最后一个字符的值加上去。因为最后一对之后的最后一个字符无论如何它右边没有字符可以比较按罗马数字规则它总是应该被加上的正数。比如“IV”处理完(I,V)后总数是-1最后加上“V”的值5得到4完美正确。再比如“LVIII”相邻对有(L,V)、(V,I)、(I,I)、(I,I)处理完前三个对子后最后加最后一个I正好得到58。这个“加最后一个”的操作是整个解法里最重要的一个点。3.2 基于pairwise的完整实现把上面的思路变成代码我平时在力扣上提交的版本是这样的from itertools import pairwise class Solution: def romanToInt(self, s: str) - int: roman_map { I: 1, V: 5, X: 10, L: 50, C: 100, D: 500, M: 1000 } total 0 for a, b in pairwise(s): if roman_map[a] roman_map[b]: total - roman_map[a] else: total roman_map[a] total roman_map[s[-1]] return total这个解法的时间复杂度是O(n)空间复杂度O(1)很干净。几个值得注意的细节s[-1]这个写法在Python里表示字符串最后一个字符前提是字符串不为空。题目约束了罗马数字字符串长度至少为1所以这里安全。为什么循环里只处理a而不处理b因为b会作为下一轮的a出现而最后一个b最后单独加。如果把b也加进循环就会重复累加。用pairwise(s)时如果len(s)是1那么循环体不会执行直接进入最后的total roman_map[s[-1]]相当于直接返回这个字符对应的数值。比如输入“I”输出1符合预期。这个写法最舒服的地方是你不用再追着i和i1找bug因为pairwise已经把你可能搞错的索引关系藏好了你只需要关心规则本身。3.3 另一种不依赖pairwise但思路相同的写法考虑到老环境兼容性我也把自己手写的版本放在这里思路完全一致只是用索引模拟pairwiseclass Solution: def romanToInt(self, s: str) - int: roman_map { I: 1, V: 5, X: 10, L: 50, C: 100, D: 500, M: 1000 } total 0 for i in range(len(s) - 1): if roman_map[s[i]] roman_map[s[i 1]]: total - roman_map[s[i]] else: total roman_map[s[i]] total roman_map[s[-1]] return total对比一下官方题解里常见的“从右往左遍历”版本我的这个版本可读性更好因为你不需要额外记一个pre变量。而且这个“处理相邻对补尾”模式其实是很多字符串连续关系题的通用解法比如力扣的“句子相似性”或者“Z字形变换”都能用类似的思路举一反三。有人可能会问那能不能直接先把所有字符值加起来再减掉那些“异常”的重复部分理论上可以但你需要额外记录哪些组合是减法逻辑反而更麻烦。我记得有一次面试时我现场写pairwise版本面试官一开始愣了一下后来看完之后评价说这个思路把问题降维了因为把二维的规则降成了一维的相邻判断。4. 实测与避坑常见问题、边界情况与性能对比4.1 边界情况单个字符、连续相等、特例IV等力扣判题最喜欢在边界case上折腾人。我用pairwise解法实测下来这些情况都需要特别留意单个字符比如“I”循环不执行走最后一行加s[-1]没有bug。连续相等比如“III”pairwise得到(I,I)、(I,I)每个对都是相等按规则走else分支加1所以总数为1113正确。递减序列比如“VI”pairwise得到(V,I)V的5大于I的1走else加5最后加I的1得到6。多个减法组合比如“MCMXCIV”pairwise逐对判断后会正确执行加减交替最后加上V的5得到1994我验证过多次。这里我想特别说一个容易翻车的地方起始字符重复。比如“IVI”pairwise得到(I,V)减1(V,I)加5最后加I的1结果是5那罗马数字“IVI”实际表示什么这个输入本身可能并不是合法规范罗马数字但力扣的题目假设输入总是合法的所以你在提交时不用担心它。不过自己做测试时最好用标准合法用例。4.2 性能分析pairwise版本相比传统写法的效率从时间复杂度上看无论你是用pairwise还是传统的索引遍历都是O(n)因为每个字符都被访问了一到两次但总体是线性的。空间复杂度上pairwise版本不产生额外的大数据结构只有一个字典加几个变量O(1)。但是实际的运行时间会有细微差别。内置itertools.pairwise是在C层实现的它在Python层面的循环开销更小。我拿力扣的判题数据对比过内置pairwise版本比手写索引版本在运行时间上能快个百分之五到百分之十虽然不至于影响通过但在大批量测试用例下差距还是能感觉到的。如果你用Java或C刷题语言标准库里没有pairwise函数那就老实手写索引版本性能也完全没问题。主要思路还是那句处理相邻对最后补尾。另外我建议不要在力扣里用正则表达式去拆罗马数字我试过把四个字符的规则写成正则可读性差运行速度还慢。正确做法永远是模拟规则而pairwise是模拟规则最趁手的工具之一。4.3 实操中我踩过的几个坑这里记录几个我实际写这道题时犯过的错都是些很小但很搞心态的细节。第一个坑是忘记加最后一个字符的值。我一开始写完pairwise循环后发现所有答案都比预期小排查半天才意识到循环里只处理了相邻对的左元素最后一个元素没人管。后来我在代码旁边加了注释最后一定要补上末尾字符对应的值。第二个坑是在判断条件里用错了比较对象。我试过直接用字符比较比如if a b这显然不对因为字符串比较会按字典序I V会返回True但X V也会返回True这会误导判断。必须用映射后的数字大小来比较roman_map[a] roman_map[b]才对。第三个坑发生在用pairwise时如果len(s)为0s[-1]会报错。虽然题目保证非空但我自己在写工具函数测试时踩到了。在线编程时建议在函数入口加一个防御判断if not s: return 0能省去很多低级麻烦。第四个根深蒂固的坑是在初始化total时用了0但遇到负数结果时会不自信。比如有些合法罗马数字处理过程中total在中间步骤可能变成负的比如“IV”先减1total-1然后加5才变成4。看到负数不能慌这是中间状态最后一定会被补正。4.4 把这个解法思路迁移到其他题目pairwise不只能解决罗马数字问题很多力扣中等难度的题都有它的影子。我觉得最有代表性的是“力扣1875将雇员相同的分组”这题其实是SQL题但如果你做的是数组、字符串类型的分组题pairwise同样能派上用场。任何需要比较“当前元素和下一个元素关系”的题目都适合先用pairwise把相邻关系提取出来再写业务逻辑。比如力扣的“最大连续1的个数”、“删除有序数组中的重复项”、以及“最长湍流子数组”都可以用pairwise思路重新梳理。我自己在准备面试时会把这类“相邻关系”的题目归到一个类别里统一用pairwise补尾这个模板去套效果很好。原因在于很多题目描述的规则本质上就是相邻元素的比较。你与其维护一堆复杂的状态变量不如直接把相邻关系可视化地导出来。这也是我在实际工作中写数据处理脚本的惯用手法拿到序列后先看相邻差、相邻比再决定下一步策略。用pairwise还有一个附带好处它让你的代码边界更干净。手写索引时一旦嵌套两层循环很容易出现越界或者逻辑错乱pairwise帮你把这个复杂度隐藏了让你专注于真正的规则。这个优势在时间紧张的面试中特别明显。最后再分享一个我最近才养成的习惯拿到一道字符串序列题先写一个相邻对的输出打印到控制台里看看长啥样再决定写后面的逻辑。这一步看起来多花10秒实际上能帮你少改半小时的bug。毕竟规则藏在数据的关系里而不是藏在零散的索引里把关系摆到明面上解法自己就浮出来了。