LeetCode 12 · 整数转罗马数字:13 张牌的贪心扣减

这是 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"]------CDCM 是减法形式,但在表里和加法形式平起平坐。这看起来很自然,但有一个问题:减法规则没有显式表达,只是凑巧 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=900CD=400 等)恰好填补了相邻加法值之间的"差值空隙"------比如 D=500C=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=5XC 必须在 i=6L 之前被尝试------因为 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 题里我们说"减法组合 = 普通面额"------两题合起来证明了:减法形式在罗马数字体系里根本不是异常路径 ,它只是另一个面额。一旦把 CMM 看成同等地位的 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}]")

相关题目推荐