LeetCode 13 · 罗马数字转整数:左小右大就减

这道题表面上是"查表 + 处理六种特例",很多人一上来就想着枚举 IVIXXL...... 但其实有一个统一的视角可以把所有规则压成一行:从左到右扫描,当前字符比下一个字符小,就减;否则就加。这个判断不需要任何特例判断,因为它从罗马数字的构造规则里自然推导出来。


题目长什么样

给定一个罗马数字字符串 s,转成整数。七种字符如下:

字符 数值
I 1
V 5
X 10
L 50
C 100
D 500
M 1000

规则:通常小数字在大数字右边(相加);但有六种减法特例------IV=4IX=9XL=40XC=90CD=400CM=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 项的特例表,逻辑分叉。


最优解:统一视角------左小右大就减

关键观察:罗马数字的减法规则本质上是这一条------当一个字符比它右边的字符小时,它就是减法的"前缀" 。比如 IVI(1) < V(5),所以 I 要被减。MCMC(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 中的 IXC 中的 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)正好对应原解释里的 CMXCIV 三段------逻辑完全自洽。

跑一遍几个简单示例

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}]')

相关题目推荐

相关推荐
生成论实验室5 小时前
机器人:一个自主运动的系统
人工智能·算法·语言模型·机器人·自动驾驶·agi·安全架构
Qres8215 小时前
算法复键——树状数组
数据结构·算法
H178535090965 小时前
SolidWorks第四部分_直接实体建模特征9_替换面原理
线性代数·算法·机器学习·3d建模·solidworks
不会就选b5 小时前
算法日常・每日刷题--<二分查找>3
算法
绿算技术6 小时前
Mooncake 与绿算ForinnBase GroundPool如何联手打破推理僵局?
科技·算法·架构
-森屿安年-6 小时前
63. 不同路径 II
c++·算法·动态规划
老余捞鱼6 小时前
线性回归实战:5步验证你的量化因子是否真有效
算法·金融·回归·线性回归·ai量化
想吃火锅10056 小时前
【leetcode】121.买卖股票的最佳时机js/c++
算法·leetcode·职场和发展
码云数智-大飞6 小时前
RAII 与智能指针深度拆解
java·前端·算法
Dick5077 小时前
ROS2 常用命令表
人工智能·学习·算法·机器人