news 2026/10/1 12:07:27

用Python itertools pairwise优雅解决力扣13题罗马数字转整数

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
用Python itertools pairwise优雅解决力扣13题罗马数字转整数

力扣第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+13
IV(I,V)I<V,-1,最后一位V+54
LVIII(L,V),(V,I),(I,I),(I,I)L+50,V+5,两个I各+1,最后一位I+158
MCMXCIV(M,C),(C,M),(M,X),(X,C),(C,I),(I,V)+1000-100+1000-10+100-1+51994

全部正确。提交到力扣后运行时间大概在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 刷力扣时怎么训练这种“建模思路”

罗马数字转整数是一个非常好的教学案例,因为它表面上是字符串题,实际上是相邻关系建模题。拿到题目时先别急着写代码,问自己一句话:题目里的规则描述的是“元素本身的性质”,还是“元素和周围元素的关系”?如果是后者,就去找那个关系到底是什么,再用合适的工具去表达它。

比如这题里的减法规则明显是“相邻关系”——当前字符的值取决于右边那个字符的值。把关系拆成“大于等于”和“小于”两种,算法就出来了。多训练几次这种翻译能力,你会发现同一道题能写出很多种解法,而你选择那种代码最贴近规则描述的解法,那才是真正理解了这个模型。以后再看到字符串、数组、链表题,你会有一种“规则翻译成代码”的直觉,而不是背模板。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/1 12:07:23

基于MFC实现扫雷游戏:对话框工程与核心逻辑详解

简介&#xff1a;这是一份基于MFC框架实现的扫雷游戏完整源码工程&#xff0c;面向正在学习Windows桌面开发、C面向对象编程以及MFC文档视图架构的初学者与进阶者。资源以鼠标点击操作为核心交互方式&#xff0c;界面简洁明了&#xff0c;代码结构清晰&#xff0c;适合作为课程…

作者头像 李华
网站建设 2026/10/1 12:06:18

Codex CLI 接入 Jev 模型服务:配置教程与踩坑指南

最近我在折腾 Codex CLI 的时候&#xff0c;发现一个很有意思的搭配&#xff1a;给 Codex 配上 Jev 模型服务&#xff0c;速度、成本、可用性直接起飞。这里不吹不黑&#xff0c;把配置过程和踩坑记录完整放出来。Codex 是 OpenAI 出的命令行编码代理&#xff0c;能用自然语言直…

作者头像 李华
网站建设 2026/10/1 12:06:11

NI-VISA下用C++调用数字万用表驱动:从SCPI到数据读取

简介&#xff1a;面向C开发者和NI硬件用户的DMM驱动资源&#xff0c;聚焦NI数字万用表&#xff08;DMM&#xff09;板卡的编程控制。资源对应《深入理解DMM驱动&#xff1a;NI数字万用表的C编程实践》&#xff0c;涵盖设备初始化、测量参数配置、数据采集、错误处理与设备关闭等…

作者头像 李华
网站建设 2026/10/1 12:04:21

Qoder本地AI编程引擎:告别HTTP延迟,实现毫秒级代码补全

1. 从“Codex用户”到“Qoder信徒”&#xff1a;一场IDE内AI编程体验的断崖式升级我第一次在IntelliJ IDEA里敲出// TODO: implement retry logic with exponential backoff&#xff0c;然后按下快捷键&#xff0c;等了3秒——光标没动&#xff0c;状态栏显示“Waiting for Cod…

作者头像 李华
网站建设 2026/10/1 12:04:10

字符串处理实战:多语言逆序、分割与转换陷阱解析

字符串大概是编程里最“不起眼”却又最能暴露水平的部分。我写了十几年代码&#xff0c;从C语言的char[]一路折腾到 Java、Python、C#、JavaScript 和各类SQL方言&#xff0c;发现一个很现实的问题&#xff1a;越基础的操作越容易翻车。逆序一个字符串人人都会&#xff0c;但遇…

作者头像 李华
网站建设 2026/10/1 12:04:08

电动车真空助力制动系统建模:从机理到数据驱动的实践

只有真正做过整车项目的工程师才懂&#xff0c;电动车制动系统最神奇的地方&#xff0c;不在卡钳和ESP&#xff0c;而在那块你几乎永远不会注意到的“真空”。每天早晚高峰&#xff0c;你踩下制动踏板&#xff0c;制动力在几十毫秒内建立&#xff0c;脚感和老燃油车几乎没差别。…

作者头像 李华