Skip to content

Commit 4e69833

Browse files
colinleachBethanyG
andauthored
[Roman Numerals] Draft of Approaches (exercism#3605)
* [Roman Numerals] early draft of approaches * updated approach documents, many changes * fixed typos and other glitches * Update exercises/practice/roman-numerals/.approaches/introduction.md Co-authored-by: BethanyG <BethanyG@users.noreply.github.com> * Update exercises/practice/roman-numerals/.approaches/introduction.md Co-authored-by: BethanyG <BethanyG@users.noreply.github.com> * Update exercises/practice/roman-numerals/.approaches/introduction.md Co-authored-by: BethanyG <BethanyG@users.noreply.github.com> * Update exercises/practice/roman-numerals/.approaches/introduction.md Co-authored-by: BethanyG <BethanyG@users.noreply.github.com> * Update exercises/practice/roman-numerals/.approaches/introduction.md Co-authored-by: BethanyG <BethanyG@users.noreply.github.com> * Update exercises/practice/roman-numerals/.approaches/introduction.md Co-authored-by: BethanyG <BethanyG@users.noreply.github.com> * Update exercises/practice/roman-numerals/.approaches/recurse-match/content.md Co-authored-by: BethanyG <BethanyG@users.noreply.github.com> * Update exercises/practice/roman-numerals/.approaches/introduction.md Co-authored-by: BethanyG <BethanyG@users.noreply.github.com> * Update exercises/practice/roman-numerals/.approaches/recurse-match/content.md Co-authored-by: BethanyG <BethanyG@users.noreply.github.com> * Update exercises/practice/roman-numerals/.approaches/recurse-match/content.md Co-authored-by: BethanyG <BethanyG@users.noreply.github.com> * Fixed TODO in `itertools-starmap` * Update content.md Found a mistake already * Update content.md again * Update exercises/practice/roman-numerals/.approaches/itertools-starmap/content.md * Update exercises/practice/roman-numerals/.approaches/itertools-starmap/content.md --------- Co-authored-by: BethanyG <BethanyG@users.noreply.github.com>
1 parent cbbfcfd commit 4e69833

File tree

12 files changed

+747
-0
lines changed

12 files changed

+747
-0
lines changed
Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
{
2+
"introduction": {
3+
"authors": [
4+
"colinleach",
5+
"BethanyG"
6+
]
7+
},
8+
"approaches": [
9+
{
10+
"uuid": "69c84b6b-5e58-4b92-a2c4-17dd7d353087",
11+
"slug": "if-else",
12+
"title": "If Else",
13+
"blurb": "Use booleans to find the correct translation for each digit.",
14+
"authors": [
15+
"BethanyG",
16+
"colinleach"
17+
]
18+
},
19+
{
20+
"uuid": "4ed8396c-f2c4-4072-abc9-cc8fe2780a5a",
21+
"slug": "loop-over-romans",
22+
"title": "Loop Over Romans",
23+
"blurb": "Test Roman Numerals from the largest down and eat the maximum possible at each step.",
24+
"authors": [
25+
"BethanyG",
26+
"colinleach"
27+
]
28+
},
29+
{
30+
"uuid": "ba022b7e-5ea8-4432-ab94-6e9be200a70b",
31+
"slug": "table-lookup",
32+
"title": "Table Lookup",
33+
"blurb": "Use a 2-D lookup table to eliminate loop nesting.",
34+
"authors": [
35+
"BethanyG",
36+
"colinleach"
37+
]
38+
},
39+
{
40+
"uuid": "3d6df007-455f-4210-922b-63d5a24bfaf8",
41+
"slug": "itertools-starmap",
42+
"title": "Itertools Starmap",
43+
"blurb": "Use itertools.starmap() for an ingenious functional approach.",
44+
"authors": [
45+
"BethanyG",
46+
"colinleach"
47+
]
48+
},
49+
{
50+
"uuid": "a492c6b4-3780-473d-a2e6-a1c3e3da4f81",
51+
"slug": "recurse-match",
52+
"title": "Recurse Match",
53+
"blurb": "Combine recursive programming with the recently-introduced structural pattern matching.",
54+
"authors": [
55+
"BethanyG",
56+
"colinleach"
57+
]
58+
}
59+
]
60+
}
Lines changed: 103 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,103 @@
1+
# If Else
2+
3+
```python
4+
def roman(number):
5+
# The notation: I, V, X, L, C, D, M = 1, 5, 10, 50, 100, 500, 1000
6+
m = number // 1000
7+
m_rem = number % 1000
8+
c = m_rem // 100
9+
c_rem = m_rem % 100
10+
x = c_rem // 10
11+
x_rem = c_rem % 10
12+
i = x_rem
13+
14+
res = ''
15+
16+
if m > 0:
17+
res += m * 'M'
18+
19+
if 4 > c > 0:
20+
res += c * 'C'
21+
elif c == 4:
22+
res += 'CD'
23+
elif 9 > c > 4:
24+
res += 'D' + ((c - 5) * 'C')
25+
elif c == 9:
26+
res += 'CM'
27+
28+
if 4 > x > 0:
29+
res += x * 'X'
30+
elif x == 4:
31+
res += 'XL'
32+
elif 9 > x > 4:
33+
res += 'L' + ((x - 5) * 'X')
34+
elif x == 9:
35+
res += 'XC'
36+
37+
if 4 > i > 0:
38+
res += i * 'I'
39+
elif i == 4:
40+
res += 'IV'
41+
elif 9 > i > 4:
42+
res += 'V' + ((i - 5) * 'I')
43+
elif i == 9:
44+
res += 'IX'
45+
46+
return res
47+
```
48+
49+
This gets the job done.
50+
Something like it would work in most languages, though Python's range test (`a > x > b`) saves some boolean logic.
51+
52+
## Refactoring
53+
54+
The code above is quite long and a bit repetitive.
55+
We should explore ways to make it more concise.
56+
57+
The first block is just a way to extract the digits from the input number.
58+
This can be done with a list comprehension, left-padding with zeros as necessary:
59+
60+
```python
61+
digits = ([0, 0, 0, 0] + [int(d) for d in str(number)])[-4:]
62+
```
63+
64+
The blocks for hundreds, tens and units are all essentially the same, so we can put that code in a function.
65+
We just need to pass in the digit, plus a tuple of translations for `(1, 4, 5, 9)` or their 10x and 100x equivalents.
66+
67+
It is also unnecessary to keep retesting the lower bounds within an `elif`, as the code line will only be reached if that is satisfied.
68+
69+
Using `return` instead of `elif` is a matter of personal preference.
70+
Given that, the code simplifies to:
71+
72+
```python
73+
def roman(number: int) -> str:
74+
def translate_digit(digit: int, translations: iter) -> str:
75+
assert isinstance(digit, int) and 0 <= digit <= 9
76+
77+
units, four, five, nine = translations
78+
if digit < 4:
79+
return digit * units
80+
if digit == 4:
81+
return four
82+
if digit < 9:
83+
return five + (digit - 5) * units
84+
return nine
85+
86+
assert isinstance(number, int)
87+
m, c, x, i = ([0, 0, 0, 0] + [int(d) for d in str(number)])[-4:]
88+
res = ''
89+
90+
if m > 0:
91+
res += m * 'M'
92+
if c > 0:
93+
res += translate_digit(c, ('C', 'CD', 'D', 'CM'))
94+
if x > 0:
95+
res += translate_digit(x, ('X', 'XL', 'L', 'XC'))
96+
if i > 0:
97+
res += translate_digit(i, ('I', 'IV', 'V', 'IX'))
98+
99+
return res
100+
```
101+
102+
The last few lines are quite similar and it would be possible to refactor them into a loop, but this is enough to illustrate the principle.
103+
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
def translate_digit(digit: int, translations: iter) -> str:
2+
units, four, five, nine = translations
3+
if digit < 4: return digit * units
4+
if digit == 4: return four
5+
if digit < 9: return five + (digit - 5) * units
6+
return nine
7+
8+
if c > 0: res += translate_digit(c, ('C', 'CD', 'D', 'CM'))
Lines changed: 205 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,205 @@
1+
# Introduction
2+
3+
There is no single, obvious solution to this exercise, but a diverse array of working solutions have been used.
4+
5+
## General guidance
6+
7+
Roman numerals are limited to positive integers from 1 to 3999 (MMMCMXCIX).
8+
In the version used for this exercise, the longest string needed to represent a Roman numeral is 14 characters (MMDCCCLXXXVIII).
9+
Minor variants of the system have been used which represent 4 as IIII rather than IV, allowing for longer strings, but those are not relevant here.
10+
11+
The system is inherently decimal: the number of human fingers has not changed since ancient Rome, nor the habit of using them for counting.
12+
However, there is no zero value available, so Roman numerals represent powers of 10 with different letters (I, X, C, M), not by position (1, 10, 100, 1000).
13+
14+
The approaches to this exercise break down into two groups, with many variants in each:
15+
1. Split the input number into digits, and translate each separately.
16+
2. Iterate through the Roman numbers, from large to small, and convert the largest valid number at each step.
17+
18+
## Digit-by-digit approaches
19+
20+
The concept behind this class of approaches:
21+
1. Split the input number into decimal digits.
22+
2. For each digit, get the Roman equivalent and append to a list.
23+
3. Join the list into a string and return it.
24+
Depending on the implementation, there may need to be a list-reverse step.
25+
26+
### With `if` conditions
27+
28+
```python
29+
def roman(number: int) -> str:
30+
assert isinstance(number, int)
31+
32+
def translate_digit(digit: int, translations: iter) -> str:
33+
assert isinstance(digit, int) and 0 <= digit <= 9
34+
35+
units, four, five, nine = translations
36+
if digit < 4:
37+
return digit * units
38+
if digit == 4:
39+
return four
40+
if digit < 9:
41+
return five + (digit - 5) * units
42+
return nine
43+
44+
m, c, x, i = ([0, 0, 0, 0] + [int(d) for d in str(number)])[-4:]
45+
res = ''
46+
if m > 0:
47+
res += m * 'M'
48+
if c > 0:
49+
res += translate_digit(c, ('C', 'CD', 'D', 'CM'))
50+
if x > 0:
51+
res += translate_digit(x, ('X', 'XL', 'L', 'XC'))
52+
if i > 0:
53+
res += translate_digit(i, ('I', 'IV', 'V', 'IX'))
54+
return res
55+
```
56+
57+
See [`if-else`][if-else] for details.
58+
59+
### With table lookup
60+
61+
```python
62+
def roman(number):
63+
assert (number > 0)
64+
65+
# define lookup table (as a tuple of tuples, in this case)
66+
table = (
67+
("I", "II", "III", "IV", "V", "VI", "VII", "VIII", "IX"),
68+
("X", "XX", "XXX", "XL", "L", "LX", "LXX", "LXXX", "XC"),
69+
("C", "CC", "CCC", "CD", "D", "DC", "DCC", "DCCC", "CM"),
70+
("M", "MM", "MMM"))
71+
72+
# convert the input integer to a list of single digits
73+
digits = [int(d) for d in str(number)]
74+
75+
# we need the row in the lookup table for our most-significant decimal digit
76+
inverter = len(digits) - 1
77+
78+
# translate decimal digits list to Roman numerals list
79+
roman_digits = [table[inverter - i][d - 1] for (i, d) in enumerate(digits) if d != 0]
80+
81+
# convert the list of Roman numerals to a single string
82+
return ''.join(roman_digits)
83+
```
84+
85+
See [`table-lookup`][table-lookup] for details.
86+
87+
88+
## Loop over Romans approaches
89+
90+
In this class of approaches we:
91+
1. Create a mapping from Roman to Arabic numbers, in some suitable format. (_`dicts` or `tuples` work well_)
92+
2. Iterate nested loops, a `for` and a `while`, in either order.
93+
3. At each step, append the largest possible Roman number to a list and subtract the corresponding value from the number being converted.
94+
4. When the number being converted drops to zero, join the list into a string and return it.
95+
Depending on the implementation, there may need to be a list-reverse step.
96+
97+
This is one example using a dictionary:
98+
99+
```python
100+
ROMAN = {1000: 'M', 900: 'CM', 500: 'D', 400: 'CD',
101+
100: 'C', 90: 'XC', 50: 'L', 40: 'XL',
102+
10: 'X', 9: 'IX', 5: 'V', 4: 'IV', 1: 'I'}
103+
104+
def roman(number: int) -> str:
105+
result = ''
106+
while number:
107+
for arabic in ROMAN.keys():
108+
if number >= arabic:
109+
result += ROMAN[arabic]
110+
number -= arabic
111+
break
112+
return result
113+
```
114+
115+
There are a number of variants.
116+
See [`loop-over-romans`][loop-over-romans] for details.
117+
118+
## Other approaches
119+
120+
### Built-in methods
121+
122+
Python has a package for pretty much everything, and Roman numerals are [no exception][roman-module].
123+
124+
```python
125+
>>> import roman
126+
>>> roman.toRoman(23)
127+
'XXIII'
128+
>>> roman.fromRoman('MMDCCCLXXXVIII')
129+
2888
130+
```
131+
132+
First it is necessary to install the package with `pip` or `conda`.
133+
Like most external packages, `roman` is not available in the Exercism test runner.
134+
135+
This is the key part of the implementation on GitHub, which may look familiar:
136+
137+
```python
138+
def toRoman(n):
139+
result = ""
140+
for numeral, integer in romanNumeralMap:
141+
while n >= integer:
142+
result += numeral
143+
n -= integer
144+
return result
145+
```
146+
147+
The library function is a wrapper around a `loop-over-romans` approach!
148+
149+
### Recursion
150+
151+
This is a recursive version of the `loop-over-romans` approach, which only works in Python 3.10 and later:
152+
153+
```python
154+
ARABIC_NUM = (1000, 900, 500, 400, 100, 90, 50, 40, 10, 9, 5, 4, 1)
155+
ROMAN_NUM = ("M", "CM", "D", "CD", "C", "XC", "L", "XL", "X", "IX", "V", "IV", "I")
156+
157+
def roman(number: int) -> str:
158+
return roman_recur(number, 0, [])
159+
160+
def roman_recur(num: int, idx: int, digits: list[str]):
161+
match (num, idx, digits):
162+
case [_, 13, digits]:
163+
return ''.join(digits[::-1])
164+
case [num, idx, digits] if num >= ARABIC_NUM[idx]:
165+
return roman_recur(num - ARABIC_NUM[idx], idx, [ROMAN_NUM[idx],] + digits)
166+
case [num, idx, digits]:
167+
return roman_recur(num, idx + 1, digits)
168+
```
169+
170+
See [`recurse-match`][recurse-match] for details.
171+
172+
173+
### Over-use a functional approach
174+
175+
```python
176+
def roman(number):
177+
return ''.join(one*digit if digit<4 else one+five if digit==4 else five+one*(digit-5) if digit<9 else one+ten
178+
for digit, (one,five,ten)
179+
in zip([int(d) for d in str(number)], ["--MDCLXVI"[-i*2-1:-i*2-4:-1] for i in range(len(str(number))-1,-1,-1)]))
180+
```
181+
182+
*This is Python, but not as we know it*.
183+
184+
As the textbooks say, further analysis of this approach is left as an exercise for the reader.
185+
186+
## Which approach to use?
187+
188+
In production, it would make sense to use the `roman` package.
189+
It is debugged and supports Roman-to-Arabic conversions in addtion to the Arabic-to-Roman approaches discussed here.
190+
191+
Most submissions, like the `roman` package implementation, use some variant of [`loop-over-romans`][loop-over-romans].
192+
193+
Using a [2-D lookup table][table-lookup] takes a bit more initialization, but then everthing can be done in a list comprehension instead of nested loops.
194+
Python is relatively unusual in supporting both tuples-of-tuples and relatively fast list comprehensions, so the approach seems a good fit for this language.
195+
196+
No performance article is currently included for this exercise.
197+
The problem is inherently limited in scope by the design of Roman numerals, so any of the approaches is likely to be "fast enough".
198+
199+
200+
201+
[if-else]: https://exercism.org/tracks/python/exercises/roman-numerals/approaches/if-else
202+
[table-lookup]: https://exercism.org/tracks/python/exercises/roman-numerals/approaches/table-lookup
203+
[loop-over-romans]: https://exercism.org/tracks/python/exercises/roman-numerals/approaches/loop-over-roman
204+
[recurse-match]: https://exercism.org/tracks/python/exercises/roman-numerals/approaches/recurse-match
205+
[roman-module]: https://github.com/zopefoundation/roman

0 commit comments

Comments
 (0)