Monday, November 9, 2009

Roman numerals

I'm reading Mark Pilgrim's new version of Dive Into Python, and he spends some time with Roman numerals as an example for regular expresssions. (In fact, I like it, and him, so much I bought a dead trees copy). But the example piqued my interest. It's the sort of problem Python does really well. And it has the advantage of having a forward and backward transformation (to standard representation), which allows one to test all valid cases. Not too often you see that in a programming problem. It is a good challenge for a beginning Python coder.

  928     CMXXVIII    928
1070 MLXX 1070
30 XXX 30
581 DLXXXI 581
1976 MCMLXXVI 1976
169 CLXIX 169
1940 MCMXL 1940
975 CMLXXV 975
204 CCIV 204
1044 MXLIV 1044

I know that code is boring, but I've decided to put it up (all 70 lines of it), in four parts. The strategy here is to separate the task in two sub-tasks: conversion between decimal and "verbose" Roman, and "compression."

The first part does what the comment says. No error checking:

def compress(s):
# convert 'X'*4 to 'XV', etc.
L = list('IVXLCDM')
c = s[0]
i = L.index(c)
n = len(s)
if n < 4: return s
if n == 4: return c + L[i+1]
if n == 5: return L[i+1]
if 5 < n < 9: return L[i+1] + c*(n-5)
if n == 9: return c + L[i+2]

It's a matter of personal taste, but I tend to use single letter variable names when I can. The clutter bothers me more than the chance for confusion. But I'm consistent (L is always a list, s a string, i an index). Actually, you don't need a list here, a string would be fine. But I wanted to use L since s was already claimed :)

The second part does some trivial error checking. Then it breaks down a decimal year into thousands, hundreds, etc, and adds the appropriate number of 'IXCM' to a list, after running them through compress. Rather than check if it's needed, everything goes through.

def decimalToRoman(n):
assert type(n) == type(1),'not an int'
assert 0 < n < 10000,'out of bounds %i' % n
divs = [1000, 100, 10, 1]
vals = list()
for i in range(4):
d = divs[i]
n = n%d
rL = list()
rNumerals = 'MCXI'
for i,v in enumerate(vals):
c = rNumerals[i]
s = c * v
if i and v: s = compress(s)
rL.append (s)
return ''.join(rL)

The third function does the reverse transformation. It has a nested function definition for the reverse of the "compress" algorithm. There is a second function necessitated by the fact that there may be more than one compression in a given Roman numeral. (A bug in the first version!). In "workToDo", we return -1 when there is no more work.

def romanToDecimal(s):
D = { 'I':1, 'V':5,
'X':10, 'L':50,
'C':100,'D':500, 'M':1000 }
def resolve(s,i):
c = s[i]
first = s[:i]
last = s[i+2:]
if s[i+1] in 'VLD': x = 4
else: x = 9
s2 = first + c*x + last
return s2
def workToDo(s):
for i in range(len(s)-1):
if D[s[i]] < D[s[i+1]]:
return i
return -1
i = workToDo(s)
while i != -1:
s = resolve(s,i)
i = workToDo(s)
n = 0
for c in s: n += D[c]
return n

Finally, the test harness. We print a few random numbers for fun, and then test all the years from 1 to 10,000, going from decimal to Roman and back again.

def test():
for i in range(10):
d = random.choice(range(1,2000))
print str(d).rjust(5),
r = decimalToRoman(d)
d = romanToDecimal(r)
print r.rjust(12),
print str(d).rjust(6)

for d in range(1,10000):
r = decimalToRoman(d)
s = romanToDecimal(r)
if not d == s:
print 'error'
print str(d).rjust(5),
print r.rjust(12),
print s.rjust(6)

No comments: