这道题表面上是"查表 + 处理六种特例",很多人一上来就想着枚举
IV、IX、XL...... 但其实有一个统一的视角可以把所有规则压成一行:从左到右扫描,当前字符比下一个字符小,就减;否则就加。这个判断不需要任何特例判断,因为它从罗马数字的构造规则里自然推导出来。
题目长什么样
给定一个罗马数字字符串 s,转成整数。七种字符如下:
| 字符 | 数值 |
|---|---|
| I | 1 |
| V | 5 |
| X | 10 |
| L | 50 |
| C | 100 |
| D | 500 |
| M | 1000 |
规则:通常小数字在大数字右边(相加);但有六种减法特例------IV=4、IX=9、XL=40、XC=90、CD=400、CM=900。
输入 :s = "MCMXCIV"
输出 :1994
解释 :M=1000, CM=900, XC=90, IV=4
第一反应:把六种特例先替换掉
最直接的想法------既然只有六种两字母组合是减法,那就先把它们替换成单字符占位符,剩下的全部按字符查表相加。
python
class SolutionReplace:
def romanToInt(self, s: str) -> int:
table = {
"I": 1, "V": 5, "X": 10, "L": 50,
"C": 100, "D": 500, "M": 1000,
}
# 六种减法组合,先替换成"占位符 -> 净值"
special = {
"IV": 4, "IX": 9,
"XL": 40, "XC": 90,
"CD": 400, "CM": 900,
}
total = 0
i = 0
while i < len(s):
if i + 1 < len(s) and s[i:i + 2] in special:
total += special[s[i:i + 2]]
i += 2
else:
total += table[s[i]]
i += 1
return total
| 维度 | 值 | 说明 |
|---|---|---|
| 时间 | O(n) | 每个字符最多访问一次 |
| 空间 | O(1) | 两个固定大小的表 |
这个解法没有问题,但它把"减法"当成了需要单独识别的特殊情况。代码里得维护一张 6 项的特例表,逻辑分叉。
最优解:统一视角------左小右大就减
关键观察:罗马数字的减法规则本质上是这一条------当一个字符比它右边的字符小时,它就是减法的"前缀" 。比如 IV 中 I(1) < V(5),所以 I 要被减。MCM 中 C(100) < M(1000),所以 C 要被减。
所以根本不需要枚举 6 种特例,只要逐位比较"当前 vs 下一位":
text
遍历每一位 i:
- 如果当前值 < 下一位值 → 结果减去当前值
- 否则(≥ 或已是最后一位)→ 结果加上当前值
python
class Solution:
def romanToInt(self, s: str) -> int:
table = {
"I": 1, "V": 5, "X": 10, "L": 50,
"C": 100, "D": 500, "M": 1000,
}
total = 0
for i in range(len(s)):
cur = table[s[i]]
if i + 1 < len(s) and cur < table[s[i + 1]]:
total -= cur
else:
total += cur
return total
为什么这样做是对的?
展开讲一下。罗马数字的合法字符串有一个单调性 :如果 s[i] < s[i+1],那么 s[i] 必然是某个减法组合的开头------比如 IV 中的 I、XC 中的 X。这个性质是罗马数字书写规则保证的(题目也明说测试用例都合法,不会出现 IIV 这种)。
所以一位字符的角色只有两种:
- 它后面紧跟一个比它大的字符 → 它在"被减"位置,贡献
-cur - 否则 → 它在"被加"位置,贡献
+cur
注意:最后一位必然是加 ,因为它后面没有字符可以"压制"它。代码里 i + 1 < len(s) 这个条件自然处理了这一点------i 是最后一位时直接走 else 分支。
举例验证 MCMXCIV = 1994:
text
位置: 0 1 2 3 4 5 6
字符: M C M X C I V
值: 1000 100 1000 10 100 1 5
i=0: 1000 vs 100 → 1000 ≥ 100 → +1000 total=1000
i=1: 100 vs 1000 → 100 < 1000 → -100 total=900
i=2: 1000 vs 10 → 1000 ≥ 10 → +1000 total=1900
i=3: 10 vs 100 → 10 < 100 → -10 total=1890
i=4: 100 vs 1 → 100 ≥ 1 → +100 total=1990
i=5: 1 vs 5 → 1 < 5 → -1 total=1989
i=6: 5 (末位) → +5 total=1994 ✓
可以看到,减法位置(i=1,3,5)正好对应原解释里的 CM、XC、IV 三段------逻辑完全自洽。
跑一遍几个简单示例
text
"III": 1,1,1 三个都 ≥ 下一个或末位 → 1+1+1 = 3
"IV": 1,5 1<5 减, 末位加 → -1+5 = 4
"IX": 1,10 1<10 减, 末位加 → -1+10 = 9
"LVIII": 50,5,1,1,1 → 50≥5+, 5≥1+, 1≥1+, 1≥1+, 末位 1+
= 50+5+1+1+1 = 58
| 维度 | 值 | 说明 |
|---|---|---|
| 时间 | O(n) | 一次线性扫描 |
| 空间 | O(1) | 一张 7 项的查表 |
两种解法放在一起看
| 解法 | 时间 | 空间 | 视角 |
|---|---|---|---|
| 特例替换 | O(n) | O(1) | 减法 = 6 种特殊情况,需要枚举 |
| 左小右大就减 | O(n) | O(1) | 减法 = 普遍规则的一种表现,无需特例 |
两种解法的复杂度完全一致,差别只在心智模型。特例替换把"减"看成异常路径;统一视角把"减"看成由"左小右大"这个单调性自然导出的常态。当规则可以用一个统一判断覆盖时,永远优先选统一判断------代码更短、改动更少、扩展更安全。
这道题教会我什么
把"特例"追问到"规则"
很多看起来需要枚举的特例,背后其实是一条更深的规则。罗马数字的 6 种减法组合,本质上就是"左小右大"这一条规则的 6 种取值。遇到特例表,先问一句"这些特例之间有没有共同结构"------往往能压缩成一行判断。
类似的例子:
- 括号匹配 (LeetCode 20):三种括号
()、[]、{}看似不同,统一规则就是"栈顶必须匹配"。 - 合法 IP 地址(LeetCode 93):每段 0--255 看起来要特判前导零,统一规则就是"无前导零且数值 ≤ 255"。
单调性是很多问题的隐藏结构
这道题能用统一视角,是因为罗马数字的合法字符串具有"减法前缀必单字符"的单调性。识别出输入具有某种单调性,往往能把问题从"枚举"降到"比较相邻元素"。
注意题目保证
题目说测试用例都是合法罗马数字,所以代码里没有 if s[i] not in table 这种防御性判断。如果输入可能非法(比如 s = "ABC"),就需要额外加一道校验。生产代码里要警惕这种"题目保证"的边界------竞品代码或线上数据未必守规矩。
完整测试代码
python
class Solution:
def romanToInt(self, s: str) -> int:
table = {
"I": 1, "V": 5, "X": 10, "L": 50,
"C": 100, "D": 500, "M": 1000,
}
total = 0
for i in range(len(s)):
cur = table[s[i]]
if i + 1 < len(s) and cur < table[s[i + 1]]:
total -= cur
else:
total += cur
return total
if __name__ == "__main__":
s = Solution()
cases = [
("III", 3),
("IV", 4),
("IX", 9),
("LVIII", 58),
("MCMXCIV", 1994),
("I", 1), # 最小
("MMMCMXCIX", 3999), # 题目范围内的最大值
("XLIX", 49), # 题目特意提到的 49
("CMXCIX", 999), # 题目特意提到的 999
]
for roman, expected in cases:
got = s.romanToInt(roman)
ok = "OK" if got == expected else "FAIL"
print(f'输入: "{roman}", 输出: {got} (期望 {expected}) [{ok}]')
相关题目推荐:
- LeetCode 12 · 整数转罗马数字(本题的镜像,从大到小贪心扣减)
- LeetCode 20 · 有效的括号(同样是"看似多特例,实则一规则")
- LeetCode 283 · 移动零(用一次线性扫描解决,思路类比)