罗马数字和 Bash

作者: Dave Taylor

复古编码的乐趣:一个罗马数字转换器——我回到我的大学时代,重新解决我的家庭作业!

我在计算机时代的黎明时期获得了计算机科学学士学位。好吧,也许没有那么久远,但我们在课堂上确实谈论了 Ada 和 FORTRAN。然而,作为加州大学圣地亚哥分校的校友,选择 UCSD Pascal 作为首选编程语言也就不足为奇了。别担心;我的教育事业中没有涉及穿孔卡片和纸带。

与现代计算机科学研究一样,我们花费大量时间编写编码算法以及解决问题和谜题。我是一个桌游玩家,所以我很高兴尝试解决“哲学家就餐问题”、“四色问题”或“旅行商问题”。您可能也尝试解决过同样的问题。

有一个编码问题一直困扰着我,那就是罗马数字转换程序。作为我的第一门编程课的一部分,我记得这是一个非常棘手的问题,但我们没有互联网或 GitHub 来搜寻巧妙的解决方案或灵感。

因此,本着复古编码的精神,让我们构建一个脚本,可以将罗马数字转换为常规十进制等效值。

罗马数字

我知道,您会说“嗯,提醒我一下,什么是罗马数字?”,即使您在电影和书籍中一直看到它们。您只是忽略了作为版权声明出现的 MCMLXIII。有趣的是,行业普遍认为,制片厂使用这些罗马数字而不是更易理解的“Copyright 1963”,是为了掩盖电影的年代(顺便说一句,MCMLXIII = 1963)。

事实证明,罗马数字很有趣,因为它们本质上被分组为逻辑段。最基本的是,每个字母都有一个特定的十进制值,所以让我们从这里开始(请参阅表 1 中的值)。

表 1. 罗马数字及其值
符号 I V X L C D M
1 5 10 50 100 500 1000

如果您想将值 60 写成罗马数字,那很简单:LX。但是,反转这两个值,它就是一个完全不同的值:XL = 40。为什么?因为当较低值的符号出现在较高值符号之前时,它被认为是该值的减少。这个花哨的名称是减法记数法

换句话说,LX = 50 + 10,但 XL = L – X = 50 – 10。

现在您可以看到,早期的值如何根据后续值是高于还是低于当前值而分解为值簇。这是逻辑

MCMLXIII = M + CM + L + X + III = 1000 + 900 + 50 + 10 + 3

通用规则涉及单字符前瞻。也就是说,值 4 表示为 IV(字面上是 5 – 1),所以虽然 8 是 VIII,但值 9 是 IX。幸运的是,8 永远不会是 IIX,因为这违反了单值减法,并且这些值始终是相邻的,因此您不会看到 IM 或 VC。

明白了吗?真的很简单,一旦您了解了字母值和减法记数法。

编写罗马数字转换器

让我们开始编写一些代码。

第一个挑战是弄清楚如何将单个罗马数字值转换为其十进制等效值。我们人类可以看一眼前面呈现的表格,并记住 L = 50,但我们也必须教 shell 那个技巧。

这将是二维数组的完美用法,其中主索引将是字符值,等效的二级值将是相应的十进制值。唉,Bash 的弱点之一是它不支持多维数组。

我打算偷懒,只使用带有 case 语句的函数。这也让我可以同时支持大写和小写值


mapit() {
case $1 in
I|i) equiv=1 ;;
V|v) equiv=5 ;;
X|x) equiv=10 ;;
L|l) equiv=50 ;;
C|c) equiv=100 ;;
D|d) equiv=500 ;;
M|m) equiv=1000 ;;
* ) echo "Error: Value $1 unknown" >&2 ; exit 2 ;;
esac
}

回想一下,在 Bash 中,您不能让函数返回值,但您可以向函数发送参数。然后在函数中可以作为位置参数 $1、$2、$3 等访问它们。因此 $1 是正在映射到十进制值的字母。返回值是全局变量 $equiv。与更优雅的语言相比,这有点笨拙,但是...就是这样。

让我们先把这段代码片段放在一边,看看关于如何一次解析字符串一个字符的常见问题。有很多不同的方法可以弄清楚究竟应该如何完成,但我将使用巧妙的 Bash 函数 seq

seq 命令生成从开始值到结束值的序列——最容易在命令行中实现


$ seq 3 7
3
4
5
6
7

这就是 Bash 字符串函数真正有帮助的地方!让我们使用其中两个。首先,${#string} 返回字符串中的字符数。然后,更复杂的引用 ${string:start:num} 返回从变量 stringstart 位置开始,长度为 num 个字符的子字符串。

将这三件事放在一起,您就有一种逐字符遍历字符串的优雅方法。如果用户指定的值被称为 romanvalue(以便最大程度地助记,当然),则这个简单的 for 循环会分解字符串


for index in $(seq 1 ${#romanvalue}) ; do
echo "Letter $index is ${romanvalue:index-1:1}"
done

现在您可以将其与前面介绍的函数 mapit 集成。组合起来,您得到这个脚本


for index in $(seq 1 ${#romanvalue}) ; do
mapit ${romanvalue:index-1:1}
echo "${romanvalue:index-1:1} = $equiv"
done

结果呢?让我们分解之前的序列


$ sh roman.sh MCMLXIII
converting MCMLXIII
M = 1000
C = 100
M = 1000
L = 50
X = 10
I = 1
I = 1

很容易将它加起来


sum=$(( $sum + $equiv ))

在不补偿减法记数法的情况下,您会发现——不正确!——MCMLXII 总计为 2163。这是有道理的:CM 最终变为 1100 而不是 900,因此它与正确值相差正好 200。

显然,这个程序还需要另一部分智能——一种基本上说的算法,“如果下一个值大于当前值,则从下一个值中减去当前值。否则,将当前值添加到下一个值,并将该值添加到总和中。”

问题是这意味着代码中存在相当大的更改,因为您不能只是说“查找值,将其添加到总和”,而是需要实现前瞻概念。

而这正是我计划在我的下一篇文章中介绍的内容,届时我将完成此脚本并查看反向——一个让您获得十进制值的罗马数字等效值的脚本。后者特别有趣,因为没有大于 1000 的罗马数字,因此根据记数约定,最大值将是 3*1000+999。

或者是不是?你告诉我。

Dave Taylor 长期以来一直在 UNIX 和 Linux 系统上编写 shell 脚本。他是《Mac OS X Unix 学习指南》和《酷炫 Shell 脚本》的作者。您可以在 Twitter 上找到他,账号为 @DaveTaylor,您可以通过他的技术问答网站联系他:Ask Dave Taylor

加载 Disqus 评论