罗马数字背后的秘密——LeetCode XII XIII 题记

印象中的罗马数字,多出现在文档标题或序号中:I、II、III、IV、V、VI 等。它是阿拉伯数字传入之前使用的一种数码。其采用七个罗马字母作数字:Ⅰ(1)、X(10)、C(100)、M(1000)、V(5)、L(50)、D(500),注意是没有 0 的。罗马数字的记数方法如下:

  • 相同的数字连写,所表示的数等于这些数字相加得到的数,如 Ⅲ=3;
  • 小的数字在大的数字的右边,所表示的数等于这些数字相加得到的数,如 Ⅷ=8、Ⅻ=12;
  • 小的数字(限于 Ⅰ、X 和 C)在大的数字的左边,所表示的数等于大数减小数得到的数,如 Ⅳ=4、Ⅸ=9;
  • 在一个数的上面画一条横线,表示这个数增值 1,000 倍

LeetCode 接连两道题目与罗马数字的转换有关,分别是第 12 题 整数转罗马数字(中等难度) 和 第 13 题 罗马数字转整数(简单),借着题目、让我们去会一会这罗马数字吧!

题目一

第 12 题 整数转罗马数字

罗马数字包含以下七种字符: I, V, X, L,C,D 和 M。

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

例如, 罗马数字 2 写做 II ,即为两个并列的 1。12 写做 XII ,即为 X + II 。 27 写做 XXVII, 即为 XX + V + II 。

通常情况下,罗马数字中小的数字在大的数字的右边。但也存在特例,例如 4 不写做 IIII,而是 IV。数字 1 在数字 5 的左边,所表示的数等于大数 5 减小数 1 得到的数值 4 。同样地,数字 9 表示为 IX。这个特殊的规则只适用于以下六种情况:

  • I 可以放在 V (5) 和 X (10) 的左边,来表示 4 和 9。
  • X 可以放在 L (50) 和 C (100) 的左边,来表示 40 和 90。
  • C 可以放在 D (500) 和 M (1000) 的左边,来表示 400 和 900。

给定一个整数,将其转为罗马数字。输入确保在 1 到 3999 的范围内。

示例:

输入: 3
输出: "III"

输入: 4
输出: "IV"

输入: 9
输出: "IX"

输入: 58
输出: "LVIII"
解释: L = 50, V = 5, III = 3.

输入: 1994
输出: "MCMXCIV"
解释: M = 1000, CM = 900, XC = 90, IV = 4.

来源:力扣(LeetCode)
链接:https://leetcode-cn.com/problems/integer-to-roman

思路

整数向罗马数字转换,首先要按从大到小转换成对应的罗马字符,再按其计数方法进行排列拼接。正常规律是由大到小排列字符,特殊情况出现在 4、9、40、90、400 以及 900 时,会出现小字符先于大字符出现。

我们不妨这么设计逻辑,取出整数中每一位数字,比如 1864:个位 4 需要特殊表示 IV,十位上 6 即 60 表示为 LX(L 为 50,X 为 10),百位上 8 即 800 表示为 DCCC,千位上 1 即 1000 表示为 M。按由大到小的顺序拼接字符得到 MDCCCLXIV。总结下就是,对应个十百千位逐位转成罗马字符,再由大到小进行拼接。

代码

class Solution:
    def intToRoman(self, num: int) -> str:
    	# 表示 1、10、100、1000 个十百千的罗马字符
        one = ["I","X","C","M"]
        # 表示 5、50、500 的罗马字符,多加一个空字符串是为了让两个数列等长
        five = ["V","L","D",""]
        # result 用于拼接罗马字符
        result = ""
        # 确保数字按题目要求在 1-3999
        if num in range(1,4000):
        	# 针对我们刚对个十百千位建立的数列我们进行遍历
            for i in range(4):
                temp = ""
                # n 每次取除以10的余数,即数字最后一位,当不够取时 n 为 0
                n = num%10
                # 如果在该位上数字小于4,那么就是该罗马字符重复 n 次
                if n<4:
                    temp += one[i]*n
                # n 为 4 时,表示1的字符出现在表示5的字符的左侧
                elif n==4:
                    temp += one[i]+five[i]
                # [5,9)时,5的字符拼接剩余(n-5)的1字符
                elif 5<=n and n<9:
                    temp += five[i]+one[i]*(n-5)
                # n 为 9 时,1 的字符出现在 10 的字符左侧
                elif n==9:
                	# 控制不超出列表范围
                    if i<3:
                        temp += one[i]+one[i+1]
                # 生成的罗马字符由小到大,拼接时要将新生成的拼接到之前字符左侧
                result = temp+result
                # num 新赋值为整除 10 之后结果用于遍历计算
                num = num//10
        # 拼接的罗马字符串返回
        return result

表现

提交了几次,耗时 52-76ms 不等,时间上表现不错,内存消耗有些高。

执行用时 : 52 ms, 在所有 Python3 提交中击败了 85.07%的用户
内存消耗 : 13.8 MB, 在所有 Python3 提交中击败了 5.26% 的用户

优化

这代码里有个明显的不足:无论数字是几位,都会经历个十百千位完整的遍历。应该根据整数取位时的计算结果添加提前跳出循环的判断,注意:这个判断不能放到对 num%10 取余的结果上,因为 1803 中的 0 也会触发;应该放到 num//10 取整时、若结果为 0 即不足位了,可以提前跳出循环:

class Solution:
    def intToRoman(self, num: int) -> str:
        one = ["I","X","C","M"]
        five = ["V","L","D",""]
        result = ""
        if num in range(1,4000):
            for i in range(4):
                temp = ""
                n = num%10
                if n<4:
                    temp += one[i]*n
                elif n==4:
                    temp += one[i]+five[i]
                elif 5<=n and n<9:
                    temp += five[i]+one[i]*(n-5)
                elif n==9:
                    if i<3:
                        temp += one[i]+one[i+1]
                result = temp+result
                num = num//10
                # 检测 num 整除结果不足下一位时,提前结束
                if num==0:
                	break
        # 拼接的罗马字符串返回
        return result

可能是位数太少,且在每次遍历过程中添加了这么一个判断,修改后提升效果并不明显。

学习

接下来我们瞅瞅其它题解代码,翻了几篇,好多都提到了“贪心算法”:

贪心算法的基本思路是从问题的某一个初始解出发一步一步地进行,根据某个优化测度,每一步都要确保能获得局部最优解。每一步只考虑一个数据,他的选取应该满足局部优化的条件。若下一个数据和部分最优解连在一起不再是可行解时,就不把该数据添加到部分解中,直到把所有数据枚举完,或者不能再添加算法停止
百度百科——贪心算法

贪心算法有两个基本元素:贪心选择和最优子结构:

贪心选择
贪心选择是指所求问题的整体最优解可以通过一系列局部最优的选择,即贪心选择来达到。这是贪心算法可行的第一个基本要素,也是贪心算法与动态规划算法的主要区别。

最优子结构
当一个问题的最优解包含其子问题的最优解时,称此问题具有最优子结构性质。运用贪心策略在每一次转化时都取得了最优解。问题的最优子结构性质是该问题可用贪心算法或动态规划算法求解的关键特征。

与我们前两天尝试过的动态规划相比,区别如下:

贪心算法的每一次操作都对结果产生直接影响,而动态规划则不是。贪心算法对每个子问题的解决方案都做出选择,不能回退;动态规划则会根据以前的选择结果对当前进行选择,有回退功能。动态规划主要运用于二维或三维问题,而贪心一般是一维问题

具体到我们这个数字转化的问题,这里应用贪心算法的点在于:每一步都使用当前情况下最大的罗马数来表示。还是拿 1864 当例子,罗马字符中最大满足的数是 M(1000),接下来 864 中是 D(500),继续 364 中是 3 个 C(100),之后 64 中是 LX(60),最后 IV(4)。为了加速匹配,将各字符、以及特殊值所在的字符都添加到哈希表中,图表及代码如下:

Key Value
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
class Solution:
    def intToRoman(self, num: int) -> str:
        # 使用哈希表,按照从大到小顺序排列
        hashmap = {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'}
        res = ''
        for key in hashmap:
            if num // key != 0:
                count = num // key  # 比如输入4000,count 为 4
                res += hashmap[key] * count 
                num %= key
        return res

#作者:z1m
#链接:https://leetcode-cn.com/problems/integer-to-roman/solution/tan-xin-ha-xi-biao-tu-jie-by-ml-zimingmeng/

这里字典中的 key 值要由大到小的顺序,这也利用了 Python 3.6 中字典默认按插入时顺序。至于匹配过程,直接用整数来对 key 值进行整除,有值即对该 key 值对应的罗马字符进行输出。

相较自己的代码,这份题解代码利用哈希表来对罗马字符取值过程加速;同时哈希表中数据越全,会越简化匹配过程中的步骤。

还有更暴力变态的写法:

class Solution:
    def intToRoman(self, num: int) -> str:
        M = ["", "M", "MM", "MMM"] # 1000,2000,3000
        C = ["", "C", "CC", "CCC", "CD", "D", "DC", "DCC", "DCCC", "CM"] # 100~900
        X = ["", "X", "XX", "XXX", "XL", "L", "LX", "LXX", "LXXX", "XC"] # 10~90
        I = ["", "I", "II", "III", "IV", "V", "VI", "VII", "VIII", "IX"] # 1~9
        return M[num//1000] + C[(num%1000)//100] + X[(num%100)//10] + I[num%10]

#作者:z1m
#链接:https://leetcode-cn.com/problems/integer-to-roman/solution/tan-xin-ha-xi-biao-tu-jie-by-ml-zimingmeng/

试了下运行效果,估计是数据体量小,性能提升表现地并不明显。

题目二

第 13 题 罗马数字转整数

题目描述与 12 题基本一致,介绍罗马字符和整数对应规则,要求是给定一个罗马数字,将其转换成整数。输入确保在 1 到 3999 的范围内。

示例:

输入: "III"
输出: 3

输入: "IV"
输出: 4

输入: "IX"
输出: 9

输入: "LVIII"
输出: 58
解释: L = 50, V= 5, III = 3.

输入: "MCMXCIV"
输出: 1994
解释: M = 1000, CM = 900, XC = 90, IV = 4.

来源:力扣(LeetCode)
链接:https://leetcode-cn.com/problems/roman-to-integer

思路

这次是字符串转数字,首先无论顺序如何,单个罗马字符对应的整数是固定的,即使是 IV (4)或 IX(9)这特殊情况,左侧字符取负值、右侧字符正常取值相加即可。这么下来我们的思路就出来了,将所有字符转化为数字,如果下一位的数字大于当前位数字,即代表遇到特殊情况,将当前位数字取相反数。最终将所有转化来的数字相加得到结果。

代码

class Solution:
    def romanToInt(self, s: str) -> int:
    	# 建立字典、或者说哈希表
        rule = {"M":1000,"D":500,"C":100,"L":50,"X":10,"V":5,"I":1}
        result = 0
        # 对罗马字符串遍历
        for i in range(len(s)):
        	# 获取当前位置字符对应的数字
            temp = rule[s[i]]
            # 判断下一位是否大于当前数字,是的话取相反数
            if i+1<len(s):
                if temp<rule[s[i+1]]:
                    temp = -temp
            # 数值加入到结果中
            result+=temp        
        return result

表现

执行用时 : 56 ms, 在所有 Python3 提交中击败了 72.85% 的用户
内存消耗 : 13.6 MB, 在所有 Python3 提交中击败了 6.45% 的用户

不知为何,每次的内存消耗表现都不咋样。

优化

回头分析代码的话,可以将 temp 判断是否取相反数的过程简化下,比如:

class Solution:
    def romanToInt(self, s: str) -> int:
        rule = {"M":1000,"D":500,"C":100,"L":50,"X":10,"V":5,"I":1}
        result = 0
        for i in range(len(s)):
        	# 使用三元表达式来简化对 temp 判断赋值的语句
            temp = -rule[s[i]] if i+1<(len(s)) and rule[s[i]]< rule[s[i+1]] else rule[s[i]]
            result+=temp        
        return result

翻看了下题解,可能是难度位简单的缘故,貌似思路都差不多,再不然就是将特殊情况也列在哈希表中进行匹配了。

总结

两道题目围绕着罗马数字和整数之间相互转化展开,通过代码实现,我们体会了贪心算法、以及哈希表匹配。因为题目要求限制,涉及到的计算量不大,暴力版本的代码也都挺友好的,用来完成任务是足够了。

之前写代码都是围绕着结果来写,刷题时就会为了性能表现来追求更高效的算法设计,这个过程可能比较耗时费力,但如果能掌握算法精要、以后再编码必然大有裨益的。

关于罗马数字的描述中提到了没有零:

遗憾的是,罗马数字里没有 0。这种记数法有很大不便。如果表示 8732 这个数、那么就得写成,如果要有 0 就方便多了。0 引入的时间是在中世纪,那时欧洲教会的势力非常强大,他们千方百计地阻止 0 的传播,甚至有人为了传播 0 而被处死。

罗马数字 Ⅰ、Ⅱ、Ⅲ、Ⅳ、Ⅴ、Ⅵ、Ⅶ、Ⅷ、Ⅸ,在原有的 9 个罗马数字中本来就不存在 0。罗马教皇还自己认为用罗马数字来表示任何数字不但完全够用而且十全十美,他们甚至向外界宣布:“罗马数字是上帝发明的,从今以后不许人们再随意增加或减少一个数字。” 0 是被人们禁止使用的。

有点奇怪,怪搞笑的。

你可能感兴趣的:(LeetCode,Leetcode,罗马数字,python,数字转换,贪心算法)