这是 LeetCode 13 · 罗马数字转整数 的镜像题。13 题是"罗马 → 整数",靠"左小右大就减"一行规则搞定;12 题反过来"整数 → 罗马",靠"从大到小贪心扣减"一行规则搞定。两题合起来,把"减法形式是特例还是常态"这件事讲得非常完整------只要把 6 种减法组合预先并入查表,所谓"特例"就消失了。
题目长什么样
给定一个 1 到 3999 的整数,转成罗马数字。
输入 :num = 3749
输出 :"MMMDCCXLIX"
解释 :3000 = MMM,700 = DCC,40 = XL,9 = IX,注意 49 不是 LI- 这种跨位减法,必须按位独立处理。
第一反应:按位硬编码
罗马数字有"按位独立"的特点:千位、百位、十位、个位各自转换互不干扰。所以可以为每一位预存 10 种可能(0-9),按位查表拼接。
python
class SolutionHardcode:
def intToRoman(self, num: int) -> str:
M = ["", "M", "MM", "MMM"]
C = ["", "C", "CC", "CCC", "CD", "D", "DC", "DCC", "DCCC", "CM"]
X = ["", "X", "XX", "XXX", "XL", "L", "LX", "LXX", "LXXX", "XC"]
I = ["", "I", "II", "III", "IV", "V", "VI", "VII", "VIII", "IX"]
return (
M[num // 1000]
+ C[(num % 1000) // 100]
+ X[(num % 100) // 10]
+ I[num % 10]
)
| 维度 | 值 | 说明 |
|---|---|---|
| 时间 | O(1) | 最多 4 次查表 + 拼接 |
| 空间 | O(1) | 4 张固定 10 项的表 |
这种解法速度快、内存小,但它把减法组合硬塞进了一张 10 元素数组里 。比如百位表 ["", "C", ..., "CD", ..., "CM"]------CD 和 CM 是减法形式,但在表里和加法形式平起平坐。这看起来很自然,但有一个问题:减法规则没有显式表达,只是凑巧 4 和 9 这两个索引位置上放的是减法符号。如果将来规则变了(比如想加 4 减法变成别的写法),改起来不直观。
最优解:13 张牌的贪心扣减
换个视角:把"能用减法表示的"和"用加法表示的"一视同仁 ,统一作为 13 个 (value, symbol) 二元组:
text
1000 → M, 900 → CM, 500 → D, 400 → CD,
100 → C, 90 → XC, 50 → L, 40 → XL,
10 → X, 9 → IX, 5 → V, 4 → IV,
1 → I
然后从大到小扫描这张表:当前值能从 num 里扣几次就扣几次,每次扣都把对应符号追加到结果。扣不动了就换下一张牌。
python
class Solution:
PAIRS = [
(1000, "M"), (900, "CM"), (500, "D"), (400, "CD"),
(100, "C"), (90, "XC"), (50, "L"), (40, "XL"),
(10, "X"), (9, "IX"), (5, "V"), (4, "IV"), (1, "I"),
]
def intToRoman(self, num: int) -> str:
out = []
for value, sym in self.PAIRS:
while num >= value:
out.append(sym)
num -= value
if num == 0:
break
return "".join(out)
为什么这样做是对的?
关键观察:罗马数字的 13 个 token 在所有合法表示中互不重叠,且按值从大到小贪心选取永远能得到唯一解。
为什么"互不重叠"?因为 6 个减法组合(CM=900、CD=400 等)恰好填补了相邻加法值之间的"差值空隙"------比如 D=500 和 C=100 之间的 CD=400,让你不用 CCCC 就能凑出 400。把 13 个 token 排序后,相邻两个的差值就是下一个可识别的减法组合。
为什么"贪心正确"?这是经典的"贪心算法应用于面额体系 "问题。罗马数字的 13 个面额满足贪心选择性质 (greedy-choice property):用更大的 token 永远不亏,因为更大的 token 总能用更少的字符表示同一个数,而且不会"用早了导致后面拼不出来"。比如 num=1400:先扣 M=1000,剩 400,再扣 CD=400,得 MCD;如果先扣 D=500 反而要再扣 4 次 C=100 凑成 DCCCC,这是非法罗马数字。贪心自动避开了这种错误。
这个性质不是所有面额体系都有。人民币/美元硬币面额(1, 5, 10, 25)有;任意面额(比如 1, 3, 4)就没有------找零 6 应该是 3+3 而不是 4+1+1。所以"贪心正确"是需要验证的,本题的 13 个面额恰好满足。
跑一遍示例 1
text
num = 3749
PAIRS = [(1000,M), (900,CM), (500,D), (400,CD), (100,C), (90,XC),
(50,L), (40,XL), (10,X), (9,IX), (5,V), (4,IV), (1,I)]
i=0 value=1000 sym=M
3749 ≥ 1000 → out=[M], num=2749
2749 ≥ 1000 → out=[M,M], num=1749
1749 ≥ 1000 → out=[M,M,M], num=749
749 < 1000 → 下一个
i=1 value=900 sym=CM
749 < 900 → 下一个
i=2 value=500 sym=D
749 ≥ 500 → out=[M,M,M,D], num=249
249 < 500 → 下一个
i=3 value=400 sym=CD
249 < 400 → 下一个
i=4 value=100 sym=C
249 ≥ 100 → out=[..., C], num=149
149 ≥ 100 → out=[..., C,C], num=49
49 < 100 → 下一个
i=5 value=90 sym=XC
49 < 90 → 下一个
i=6 value=50 sym=L
49 < 50 → 下一个
i=7 value=40 sym=XL
49 ≥ 40 → out=[..., XL], num=9
9 < 40 → 下一个
i=8 value=10 sym=X
9 < 10 → 下一个
i=9 value=9 sym=IX
9 ≥ 9 → out=[..., IX], num=0
num=0 → break
最终: "MMMDCCXLIX" ✓
跑一遍示例 3
text
num = 1994
i=0 (1000, M): 1994 ≥ 1000 → out=[M], num=994
994 < 1000 → 下一个
i=1 (900, CM): 994 ≥ 900 → out=[M,CM], num=94
i=2 (500, D): 94 < 500
i=3 (400, CD): 94 < 400
i=4 (100, C): 94 < 100
i=5 (90, XC): 94 ≥ 90 → out=[M,CM,XC], num=4
i=6 (50, L): 4 < 50
i=7 (40, XL): 4 < 40
i=8 (10, X): 4 < 10
i=9 (9, IX): 4 < 9
i=10 (5, V): 4 < 5
i=11 (4, IV): 4 ≥ 4 → out=[M,CM,XC,IV], num=0 → break
最终: "MCMXCIV" ✓
注意 i=5 的 XC 必须在 i=6 的 L 之前被尝试------因为 PAIRS 按值从大到小排序,90 > 50 自然在前。这就是"从大到小贪心"的核心:让更大的面额优先被扣,避免用更小的面额拼出非法形式。
| 维度 | 值 | 说明 |
|---|---|---|
| 时间 | O(1) | 表是定长 13,num 上限 3999,循环常数次 |
| 空间 | O(1) | 13 项表 + 输出字符串 |
虽然时间复杂度严格说是 O(1)(因为 num ≤ 3999,最大字符数 ≤ 15),但把它看作 O(log num) 更通用------符号数量随 num 增长对数级。
两种解法放在一起看
| 解法 | 时间 | 空间 | 视角 |
|---|---|---|---|
| 按位硬编码 | O(1) | O(1) | 千/百/十/个位各自独立查表,减法被"塞"进数组索引 |
| 贪心扣减 | O(1) | O(1) | 13 个 token 一视同仁,减法组合 = 普通面额 |
两种解法实测速度几乎一样,差异主要在心智模型:
- 硬编码版把规则"扁平化"成了 4 张 10 元素的表,规则变多时要重新拆位、扩表。
- 贪心扣减版把规则"列表化"成 13 个二元组,规则变多时只要往
PAIRS里加一行------扩展性更好,代码意图也更直观。
这道题的"硬编码 vs 贪心"对比,本质上是在比何时把隐式规则(按位 + 减法索引位置)换成显式规则(统一面额表)。当规则数量可控(13 项),显式列表几乎永远更优。
这道题教会我什么
减法形式不是特例,是面额
13 题里我们说"左小右大就减"是统一规则,12 题里我们说"减法组合 = 普通面额"------两题合起来证明了:减法形式在罗马数字体系里根本不是异常路径 ,它只是另一个面额。一旦把 CM 和 M 看成同等地位的 token,所有"特殊处理"都消失。
这个抽象在工程里很常见:很多"看起来要特判"的情况,其实是模型表达不够通用。
贪心算法适用条件:贪心选择性质
不是所有问题都能用贪心。能用贪心的前提是问题具有"贪心选择性质"------局部最优选择能导向全局最优。罗马数字面额体系满足,因为更大面额总是能用更少字符表示同一数,且不会"提前用掉大面额导致后面拼不出来"。
识别一个问题是贪心友好还是必须 DP,是面试和实战的核心技能:
- 找零问题(人民币面额):贪心可解
- 找零问题(任意面额):必须 DP(完全背包)
- 区间调度(按结束时间排序):贪心可解
- 0-1 背包:必须 DP
当数据范围有限,"通用解法"未必输"硬编码"
很多人觉得硬编码一定更快。但在 num ≤ 3999 这种小范围下,贪心版的 13 步循环和硬编码版的 4 次查表实测差距在纳秒级,完全可忽略。这种情况下优先选可读性、扩展性更好的通用解法。性能差异只有在数据范围很大(10^9 级)时才需要斤斤计较。
完整测试代码
python
class Solution:
PAIRS = [
(1000, "M"), (900, "CM"), (500, "D"), (400, "CD"),
(100, "C"), (90, "XC"), (50, "L"), (40, "XL"),
(10, "X"), (9, "IX"), (5, "V"), (4, "IV"), (1, "I"),
]
def intToRoman(self, num: int) -> str:
out = []
for value, sym in self.PAIRS:
while num >= value:
out.append(sym)
num -= value
if num == 0:
break
return "".join(out)
if __name__ == "__main__":
s = Solution()
cases = [
(3749, "MMMDCCXLIX"),
(58, "LVIII"),
(1994, "MCMXCIV"),
(1, "I"),
(4, "IV"),
(9, "IX"),
(40, "XL"),
(90, "XC"),
(400, "CD"),
(900, "CM"),
(3999, "MMMCMXCIX"),
(3888, "MMMDCCCLXXXVIII"), # 全加法形式的最长
]
for num, expected in cases:
got = s.intToRoman(num)
ok = "OK" if got == expected else "FAIL"
print(f"输入: {num}, 输出: {got} (期望 {expected}) [{ok}]")
相关题目推荐:
- LeetCode 13 · 罗马数字转整数(本题的镜像,"左小右大就减"一行规则搞定)
- LeetCode 322 · 零钱兑换(贪心 vs DP 的经典对照,理解贪心适用条件)
- LeetCode 435 · 无重叠区间的个数(贪心算法典型应用)