ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

整数转罗马数字:贪心算法与查表法双解法详解

整数转罗马数字:贪心算法与查表法双解法详解

做力扣题有个规律——很多看起来“基础”的中等题,恰恰是最练基本功的地方。第12题“整数转罗马数字”就是典型代表。这题不涉及高深的算法,也不考复杂的C++语法,但它同时考察三样东西:你对领域规则(罗马数字)的建模能力、对贪心思想的理解程度、以及代码落地时的边界意识。今天把这题从头到尾拆一遍,从最直观的贪心解法讲到查表法,再把我自己刷这道题时踩过的坑、调试记录和验证方法都摊开来说。无论你是刚刷题的新手,还是准备面试想快速过中档题的老手,都能从中拿到点能直接用的东西。

1. 题目到底在考什么

1.1 先把罗马数字规则彻底理清

罗马数字不是单纯的进制转换,它有一套自己的别扭规则。基本符号一共七个:I代表1,V代表5,X代表10,L代表50,C代表100,D代表500,M代表1000。通常情况下,罗马数字是从大到小排列书写的,比如3写作III,13写作XIII,58写作LVIII(50 + 5 + 3)。

但如果所有数字都只靠从大到小拼接,就没法表达4、9这类数字了。罗马数字的解决办法是“减法原则”:当一个较小的符号出现在较大符号的左侧时,表示两者相减。于是4写作IV(5减1),9写作IX(10减1)。这个原则同样作用于十位和百位:40写作XL(50减10),90写作XC(100减10),400写作CD(500减100),900写作CM(1000减100)。

题目明确限制输入范围是1到3999,这个约束非常重要——它锁死了问题的规模,也意味着M最多出现三次,不需要考虑5000以上的表示法。也正是这个范围限制,让这道题的所有解法都能做到常数级复杂度。如果把范围扩大到几万甚至更大,问题性质就变了,得另想办法。

1.2 真正的难点只有六种特殊组合

把规则捋完你会发现,真正需要特殊处理的不是那几个基本符号,而是六个“减法组合”:4、9、40、90、400、900。我把它们整理成一张表,后面对照用。

数字罗马数字拆解逻辑
4IV5 - 1
9IX10 - 1
40XL50 - 10
90XC100 - 10
400CD500 - 100
900CM1000 - 100

我一开始刷这道题的时候,第一反应是“把数字拆成个十百千位,每个位单独映射”。这个思路没错,但直接实现会发现一个关键问题:1994这种数,百位是900,十位是90,个位是4,这三个都不是基本符号,必须用组合符号CM、XC、IV来表达。所以拆位没错,但拆完之后你手里拿的映射表必须是完整的13个键值对,不是7个。

换句话说,这题的本质是:将整数映射到一组“符号值”,其中包含7个基础符号和6个组合符号。想清楚这一点,解法思路就打开了。

2. 贪心解法:最符合直觉的写法

2.1 贪心策略的思考过程

既然罗马数字的书写习惯是从大到小,那么最自然的转换策略就是:每次选当前数字能使用的最大符号值,追加对应符号,然后从数字里减去这个值,重复直到数字归零。这个策略在算法里叫贪心。

为什么贪心在这里成立?这是很多人忽略的问题。关键在于符号值集合是“可加的”,而且不存在“跳过较大值反而能得到更优表示”的情况。举个例子:对任意一个大于等于1000的数,先放一个M永远不会亏,因为M就是最大的单位,你不可能通过不放M、改用多个其他符号组合出更简洁的结果。同理,遇到900到999之间的数,直接用CM(900)开头就是最优的,因为其他表达方式要么符号数更多,要么不符合罗马数字规范。贪心能成立的根因在于:这个13个符号的值集合足够“稀疏”,每个数字的罗马表示是唯一确定的,不存在歧义分支。

明白了这一点,写代码就顺理成章了。把13个键值对按值从大到小排好,逐个尝试,能减就减。

2.2 完整C++实现代码

class Solution { public: string intToRoman(int num) { int values[] = {1000, 900, 500, 400, 100, 90, 50, 40, 10, 9, 5, 4, 1}; string symbols[] = {"M", "CM", "D", "CD", "C", "XC", "L", "XL", "X", "IX", "V", "IV", "I"}; string result; for (int i = 0; i < 13; ++i) { while (num >= values[i]) { result += symbols[i]; num -= values[i]; } } return result; } };

这段代码最需要注意的地方是数组的排列顺序:1000、900、500、400这种“一个大的配一个减法组合”的交错排列。我见过有人写成1000、500、400、900,结果数字一遇到900就直接错误。顺序错了,贪心就失效了。另外循环里的while而不是if很关键——数字可能是3000,需要连续追加三个M,用if只能追加一次。

2.3 复杂度与正确性分析

时间复杂度是O(1):values数组固定13个元素,循环次数与输入规模无关。严格来说,每个数字最多追加的字符次数有上限(3999对应MMMCMXCIX,共15个字符),所以就算把字符串拼接算进去,也是严格常数级。空间复杂度同样是O(1),只用了固定大小的数组和一个结果字符串。

判断正确性有一个很直接的验证方法:你随便挑几个数,比如1994,手动模拟一遍。num是1994,先比较1000,减一次,result是"M",num变994;接着比较900,减一次,result是"MCM",num变94;再比较90,result是"MCMXC",num变4;最后比较4,result是"MCMXCIV",num归零,结束。整个过程完全符合罗马数字规范。

3. 查表法:面试官更认可的另一种标准答案

3.1 拆位映射的核心思路

贪心解法虽然简洁,但还有一个思路更直接,也更适合面试时展示你对“空间换时间”的理解:把数字按千位、百位、十位、个位拆开,每一位分别查表。

拆位的数学基础很简单:num / 1000得到千位数字,num % 1000 / 100得到百位数字,num % 100 / 10得到十位数字,num % 10得到个位数字。每个位上的数字范围都是0到9,只要提前把每个位的0到9对应的罗马数字写进数组,拼起来就是答案。

这个方案的好处是:完全不用考虑“减法原则”的实现逻辑,因为规则已经被编码进表里了。比如百位的数组下标4对应"CD",下标9对应"CM",你在建表的时候就已经把特殊组合处理掉了。对读代码的人来说,查表法的可读性比贪心更好,一眼就能看懂映射关系。

3.2 完整C++实现代码

class Solution { public: string intToRoman(int num) { string thousands[] = {"", "M", "MM", "MMM"}; string hundreds[] = {"", "C", "CC", "CCC", "CD", "D", "DC", "DCC", "DCCC", "CM"}; string tens[] = {"", "X", "XX", "XXX", "XL", "L", "LX", "LXX", "LXXX", "XC"}; string ones[] = {"", "I", "II", "III", "IV", "V", "VI", "VII", "VIII", "IX"}; return thousands[num / 1000] + hundreds[num % 1000 / 100] + tens[num % 100 / 10] + ones[num % 10]; } };

写这个代码有个细节坑:百位的表里,下标1到3是"C"、"CC"、"CCC",下标4是"CD",下标5是"D",下标6到8是"DC"、"DCC"、"DCCC",下标9是"CM"。很多人容易把下标4和9的位置写混,或者漏掉空字符串。空字符串是关键——因为0在罗马数字里没有对应符号,进位为0时该位必须返回空串,否则整个结果就多出无用字符。数组下标从0开始,正好用空串占住0号位置,这个设计是被刻意安排的。

3.3 两种解法的对比分析

对比维度贪心法查表法
核心思想每次选最大可用符号拆位后查预置表
代码行数约10行约6行(含表)
可读性依赖数组顺序,需理解贪心逻辑映射关系一目了然
时间复杂度O(1)O(1)
空间复杂度O(1)O(1)
扩展难度增加符号需改两个数组增加位需新建行数组

从我实际面试的经验来看,这两种写法面试官都认。但如果你时间允许,我建议优先给贪心解法,因为它在讲解时能把“为什么贪心成立”这个加分点带出来;讲完再补一句“其实也可以用查表法”,顺手展示第二种写法,会让面试官觉得你对这道题的掌握是全方位的。

4. 我刷这道题踩过的坑

4.1 坑一:把4和9当成普通字符硬拼

我第一次写这题时,想的是“把数字拆成4个位,每个位用switch生成对应的I、V、X字符串,最后拼起来”。结果在个位等于4的时候,我输出的还是"IIII",等当时代码跑测试用例发现4应该是"IV",我才意识到:6个减法组合必须作为一个整体单元处理,不能拆开。这个坑的本质是“规则建模不完整”——你只用7个基础符号建模,却忘了罗马数字系统里有13个有效符号。

4.2 坑二:值表顺序写反导致全军覆没

贪心解法里,values数组的顺序直接决定结果正确性。我试过把900排在1000前面,结果数字是1900时,程序先减900再减1000,输出"CMCM"(1000被拆成了900+100),正确结果应该是"MCM"。这个错误特别隐蔽,因为单看某个局部步骤,每个操作都有道理,但全局顺序错了。排查方法是:打一个日志,打印每一步的num和追加的symbol,立刻能看出顺序问题。

4.3 坑三:用switch写13个分支,又长又容易漏

有人喜欢用switch-case逐值判断,也不是不行,但13个分支写下来代码非常臃肿,而且容易漏掉"CM"这类组合。我看过一份代码,case 4和case 9覆盖了,case 40和case 90忘了,最后跑测试用例全崩。我的建议是:能查表就查表,能用数组就数组,switch适合分支逻辑复杂但数量少的情况,不适合这种“值到字符串”的纯映射。

4.4 坑四:查表法里下标计算错误

查表法的常见错误集中在取余和整除的优先级上。我第一次写的时候写成了hundreds[num % 1000 / 100],这个其实是对的,因为%和/优先级相同,从左到右结合,先算num % 1000再除以100。但有人会写成hundreds[num / 100 % 10],这个在百位上是等价的,可一旦套用到十位就容易出错。更稳妥的做法是加括号明确意图:hundreds[(num / 100) % 10],既不容易出歧义,别人读代码也省心。

5. 边界测试用例与验证方法

5.1 必测的用例清单

刷题时提交一次过固然开心,但我建议在提交前自己先跑一遍这批用例,能拦住大部分低级错误。

输入期望输出覆盖点
1"I"最小值边界
4"IV"个位减法组合
9"IX"个位减法组合
40"XL"十位减法组合
90"XC"十位减法组合
400"CD"百位减法组合
900"CM"百位减法组合
58"LVIII"纯累加场景
1994"MCMXCIV"多位混合场景
3999"MMMCMXCIX"最大值边界

5.2 验证思路与自测技巧

手工验证1994这个用例最能说明问题。1994拆成1000 + 900 + 90 + 4,对应符号是M + CM + XC + IV,拼接为"MCMXCIV"。这是力扣官方示例里最容易出错的一个,因为同时涉及三个不同位上的减法组合。如果你用贪心法跑这个用例,代码会依次匹配1000、900、90、4,顺序完全符合罗马数字的书写规范。

我还发现一个实用自测技巧:把结果反着解析一遍来验证。比如你得到"MCMXCIV",按符号从大到小依次解析:M是1000,CM是900,XC是90,IV是4,累加正好是1994。如果你写的转换函数能通过所有正向用例,再反向校验一遍,正确率基本就稳了。力扣第13题正好是“罗马数字转整数”,两道题互为逆运算,刷完第12题顺手把第13也做了,等于白赚一个验证工具。

6. 从这道题延伸出的实战思考

6.1 贪心思想在什么情况下才靠谱

这道题能这么轻松地使用贪心,是因为罗马数字的13个符号值构成了一个“完全覆盖”的表示系统——任何1到3999的整数都有且仅有一种合法表示,且较大符号永远优先。这个前提在现实工程里并不总是成立。比如货币找零问题里,如果有面值25、20、5、1的硬币,贪心找零在某些金额下就不是最优解。所以每当你打算用贪心,先问自己一句:局部最优是否真的能推出全局最优?这道题能,是因为符号值之间存在明确的整除层次关系,不存在需要跨层级搭配的场景。

6.2 建表思维才是这题最大的收获

老实说,这题的算法含量在力扣里只能算中下,但它对工程实践的启发不小。现实中很多“格式转换”问题——数字转中文大写、时间戳转日期、状态码转描述文本——本质都是“规则建模 + 查表”。与其写一堆if-else或switch分支,不如把规则整理成数组或哈希表,用数据驱动逻辑。这题里的查表法就是这个思路的浓缩版。我后来在好几个项目里都用了类似的“预置映射表”方案,代码可读性提升非常明显,出bug的概率也低得多。

6.3 我的个人刷题习惯分享

最后分享一个我自己的习惯:每道题AC之后,我会把官方题解和排名靠前的评论区解法都看一遍,重点看有没有比我的解法更简洁的思路。第12题我第一次用的是贪心,AC之后看到查表法,当时觉得很惊艳——因为查表法把“特殊情况”变成了“表项”,根本不需要在逻辑里特判。从那以后我做字符串转换类的题目,都会先想想能不能建表。这个习惯帮我养成了“先建模、再写码”的思维方式。如果你也正在刷力扣,建议试试这个方法,刷题数量和代码质量并不总是成正比的,方法论比数量重要得多。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进