Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
70206de
[Roman Numerals] early draft of approaches
colinleach Jan 25, 2024
30020f5
updated approach documents, many changes
colinleach Jan 27, 2024
1a1605d
fixed typos and other glitches
colinleach Jan 28, 2024
b4324cd
Update exercises/practice/roman-numerals/.approaches/introduction.md
colinleach Jan 30, 2024
fc8a15f
Update exercises/practice/roman-numerals/.approaches/introduction.md
colinleach Jan 30, 2024
959dafb
Update exercises/practice/roman-numerals/.approaches/introduction.md
colinleach Jan 30, 2024
610da40
Update exercises/practice/roman-numerals/.approaches/introduction.md
colinleach Jan 30, 2024
c04a47f
Update exercises/practice/roman-numerals/.approaches/introduction.md
colinleach Jan 30, 2024
989ddb0
Update exercises/practice/roman-numerals/.approaches/introduction.md
colinleach Jan 30, 2024
bcd408b
Update exercises/practice/roman-numerals/.approaches/recurse-match/co…
colinleach Jan 30, 2024
cd70e57
Update exercises/practice/roman-numerals/.approaches/introduction.md
colinleach Jan 30, 2024
26235f3
Update exercises/practice/roman-numerals/.approaches/recurse-match/co…
colinleach Jan 30, 2024
adb5312
Update exercises/practice/roman-numerals/.approaches/recurse-match/co…
colinleach Jan 30, 2024
d8d83ae
Fixed TODO in `itertools-starmap`
colinleach Jan 30, 2024
d128392
Merge branch 'approach-roman-numerals' of github.com:colinleach/pytho…
colinleach Jan 30, 2024
1fab95c
Update content.md
colinleach Jan 30, 2024
0e5d1a3
Update content.md again
colinleach Jan 30, 2024
8872373
Update exercises/practice/roman-numerals/.approaches/itertools-starma…
BethanyG Jan 31, 2024
8ff7417
Update exercises/practice/roman-numerals/.approaches/itertools-starma…
BethanyG Jan 31, 2024
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
60 changes: 60 additions & 0 deletions exercises/practice/roman-numerals/.approaches/config.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
{
"introduction": {
"authors": [
"colinleach",
"BethanyG"
]
},
"approaches": [
{
"uuid": "69c84b6b-5e58-4b92-a2c4-17dd7d353087",
"slug": "if-else",
"title": "If Else",
"blurb": "Use booleans to find the correct translation for each digit.",
"authors": [
"BethanyG",
"colinleach"
]
},
{
"uuid": "4ed8396c-f2c4-4072-abc9-cc8fe2780a5a",
"slug": "loop-over-romans",
"title": "Loop Over Romans",
"blurb": "Test Roman Numerals from the largest down and eat the maximum possible at each step.",
"authors": [
"BethanyG",
"colinleach"
]
},
{
"uuid": "ba022b7e-5ea8-4432-ab94-6e9be200a70b",
"slug": "table-lookup",
"title": "Table Lookup",
"blurb": "Use a 2-D lookup table to eliminate loop nesting.",
"authors": [
"BethanyG",
"colinleach"
]
},
{
"uuid": "3d6df007-455f-4210-922b-63d5a24bfaf8",
"slug": "itertools-starmap",
"title": "Itertools Starmap",
"blurb": "Use itertools.starmap() for an ingenious functional approach.",
"authors": [
"BethanyG",
"colinleach"
]
},
{
"uuid": "a492c6b4-3780-473d-a2e6-a1c3e3da4f81",
"slug": "recurse-match",
"title": "Recurse Match",
"blurb": "Combine recursive programming with the recently-introduced structural pattern matching.",
"authors": [
"BethanyG",
"colinleach"
]
}
]
}
103 changes: 103 additions & 0 deletions exercises/practice/roman-numerals/.approaches/if-else/content.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
# If Else

```python
def roman(number):
# The notation: I, V, X, L, C, D, M = 1, 5, 10, 50, 100, 500, 1000
m = number // 1000
m_rem = number % 1000
c = m_rem // 100
c_rem = m_rem % 100
x = c_rem // 10
x_rem = c_rem % 10
i = x_rem

res = ''

if m > 0:
res += m * 'M'

if 4 > c > 0:
res += c * 'C'
elif c == 4:
res += 'CD'
elif 9 > c > 4:
res += 'D' + ((c - 5) * 'C')
elif c == 9:
res += 'CM'

if 4 > x > 0:
res += x * 'X'
elif x == 4:
res += 'XL'
elif 9 > x > 4:
res += 'L' + ((x - 5) * 'X')
elif x == 9:
res += 'XC'

if 4 > i > 0:
res += i * 'I'
elif i == 4:
res += 'IV'
elif 9 > i > 4:
res += 'V' + ((i - 5) * 'I')
elif i == 9:
res += 'IX'

return res
```

This gets the job done.
Something like it would work in most languages, though Python's range test (`a > x > b`) saves some boolean logic.

## Refactoring

The code above is quite long and a bit repetitive.
We should explore ways to make it more concise.

The first block is just a way to extract the digits from the input number.
This can be done with a list comprehension, left-padding with zeros as necessary:

```python
digits = ([0, 0, 0, 0] + [int(d) for d in str(number)])[-4:]
```

The blocks for hundreds, tens and units are all essentially the same, so we can put that code in a function.
We just need to pass in the digit, plus a tuple of translations for `(1, 4, 5, 9)` or their 10x and 100x equivalents.

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.

Using `return` instead of `elif` is a matter of personal preference.
Given that, the code simplifies to:

```python
def roman(number: int) -> str:
def translate_digit(digit: int, translations: iter) -> str:
assert isinstance(digit, int) and 0 <= digit <= 9

units, four, five, nine = translations
if digit < 4:
return digit * units
if digit == 4:
return four
if digit < 9:
return five + (digit - 5) * units
return nine

assert isinstance(number, int)
m, c, x, i = ([0, 0, 0, 0] + [int(d) for d in str(number)])[-4:]
res = ''

if m > 0:
res += m * 'M'
if c > 0:
res += translate_digit(c, ('C', 'CD', 'D', 'CM'))
if x > 0:
res += translate_digit(x, ('X', 'XL', 'L', 'XC'))
if i > 0:
res += translate_digit(i, ('I', 'IV', 'V', 'IX'))

return res
```

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.

Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
def translate_digit(digit: int, translations: iter) -> str:
units, four, five, nine = translations
if digit < 4: return digit * units
if digit == 4: return four
if digit < 9: return five + (digit - 5) * units
return nine

if c > 0: res += translate_digit(c, ('C', 'CD', 'D', 'CM'))
205 changes: 205 additions & 0 deletions exercises/practice/roman-numerals/.approaches/introduction.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,205 @@
# Introduction

There is no single, obvious solution to this exercise, but a diverse array of working solutions have been used.

## General guidance

Roman numerals are limited to positive integers from 1 to 3999 (MMMCMXCIX).
In the version used for this exercise, the longest string needed to represent a Roman numeral is 14 characters (MMDCCCLXXXVIII).
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.

The system is inherently decimal: the number of human fingers has not changed since ancient Rome, nor the habit of using them for counting.
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).

The approaches to this exercise break down into two groups, with many variants in each:
1. Split the input number into digits, and translate each separately.
2. Iterate through the Roman numbers, from large to small, and convert the largest valid number at each step.

## Digit-by-digit approaches

The concept behind this class of approaches:
1. Split the input number into decimal digits.
2. For each digit, get the Roman equivalent and append to a list.
3. Join the list into a string and return it.
Depending on the implementation, there may need to be a list-reverse step.

### With `if` conditions

```python
def roman(number: int) -> str:
assert isinstance(number, int)

def translate_digit(digit: int, translations: iter) -> str:
assert isinstance(digit, int) and 0 <= digit <= 9

units, four, five, nine = translations
if digit < 4:
return digit * units
if digit == 4:
return four
if digit < 9:
return five + (digit - 5) * units
return nine

m, c, x, i = ([0, 0, 0, 0] + [int(d) for d in str(number)])[-4:]
res = ''
if m > 0:
res += m * 'M'
if c > 0:
res += translate_digit(c, ('C', 'CD', 'D', 'CM'))
if x > 0:
res += translate_digit(x, ('X', 'XL', 'L', 'XC'))
if i > 0:
res += translate_digit(i, ('I', 'IV', 'V', 'IX'))
return res
```

See [`if-else`][if-else] for details.

### With table lookup

```python
def roman(number):
assert (number > 0)

# define lookup table (as a tuple of tuples, in this case)
table = (
("I", "II", "III", "IV", "V", "VI", "VII", "VIII", "IX"),
("X", "XX", "XXX", "XL", "L", "LX", "LXX", "LXXX", "XC"),
("C", "CC", "CCC", "CD", "D", "DC", "DCC", "DCCC", "CM"),
("M", "MM", "MMM"))

# convert the input integer to a list of single digits
digits = [int(d) for d in str(number)]

# we need the row in the lookup table for our most-significant decimal digit
inverter = len(digits) - 1

# translate decimal digits list to Roman numerals list
roman_digits = [table[inverter - i][d - 1] for (i, d) in enumerate(digits) if d != 0]

# convert the list of Roman numerals to a single string
return ''.join(roman_digits)
```

See [`table-lookup`][table-lookup] for details.


## Loop over Romans approaches

In this class of approaches we:
1. Create a mapping from Roman to Arabic numbers, in some suitable format. (_`dicts` or `tuples` work well_)
2. Iterate nested loops, a `for` and a `while`, in either order.
3. At each step, append the largest possible Roman number to a list and subtract the corresponding value from the number being converted.
4. When the number being converted drops to zero, join the list into a string and return it.
Depending on the implementation, there may need to be a list-reverse step.

This is one example using a dictionary:

```python
ROMAN = {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'}

def roman(number: int) -> str:
result = ''
while number:
for arabic in ROMAN.keys():
if number >= arabic:
result += ROMAN[arabic]
number -= arabic
break
return result
```

There are a number of variants.
See [`loop-over-romans`][loop-over-romans] for details.

## Other approaches

### Built-in methods

Python has a package for pretty much everything, and Roman numerals are [no exception][roman-module].

```python
>>> import roman
>>> roman.toRoman(23)
'XXIII'
>>> roman.fromRoman('MMDCCCLXXXVIII')
2888
```

First it is necessary to install the package with `pip` or `conda`.
Like most external packages, `roman` is not available in the Exercism test runner.

This is the key part of the implementation on GitHub, which may look familiar:

```python
def toRoman(n):
result = ""
for numeral, integer in romanNumeralMap:
while n >= integer:
result += numeral
n -= integer
return result
```

The library function is a wrapper around a `loop-over-romans` approach!

### Recursion

This is a recursive version of the `loop-over-romans` approach, which only works in Python 3.10 and later:

```python
ARABIC_NUM = (1000, 900, 500, 400, 100, 90, 50, 40, 10, 9, 5, 4, 1)
ROMAN_NUM = ("M", "CM", "D", "CD", "C", "XC", "L", "XL", "X", "IX", "V", "IV", "I")

def roman(number: int) -> str:
return roman_recur(number, 0, [])

def roman_recur(num: int, idx: int, digits: list[str]):
match (num, idx, digits):
case [_, 13, digits]:
return ''.join(digits[::-1])
case [num, idx, digits] if num >= ARABIC_NUM[idx]:
return roman_recur(num - ARABIC_NUM[idx], idx, [ROMAN_NUM[idx],] + digits)
case [num, idx, digits]:
return roman_recur(num, idx + 1, digits)
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Oh! LOVE THIS. 💖

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I wouldn't quite go that far, but I wanted to bring in pattern matching. Python as a Scala emulator...

```

See [`recurse-match`][recurse-match] for details.


### Over-use a functional approach

```python
def roman(number):
return ''.join(one*digit if digit<4 else one+five if digit==4 else five+one*(digit-5) if digit<9 else one+ten
for digit, (one,five,ten)
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)]))
```

*This is Python, but not as we know it*.

As the textbooks say, further analysis of this approach is left as an exercise for the reader.

## Which approach to use?

In production, it would make sense to use the `roman` package.
It is debugged and supports Roman-to-Arabic conversions in addtion to the Arabic-to-Roman approaches discussed here.

Most submissions, like the `roman` package implementation, use some variant of [`loop-over-romans`][loop-over-romans].

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.
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.

No performance article is currently included for this exercise.
The problem is inherently limited in scope by the design of Roman numerals, so any of the approaches is likely to be "fast enough".



[if-else]: https://exercism.org/tracks/python/exercises/roman-numerals/approaches/if-else
[table-lookup]: https://exercism.org/tracks/python/exercises/roman-numerals/approaches/table-lookup
[loop-over-romans]: https://exercism.org/tracks/python/exercises/roman-numerals/approaches/loop-over-roman
[recurse-match]: https://exercism.org/tracks/python/exercises/roman-numerals/approaches/recurse-match
[roman-module]: https://github.com/zopefoundation/roman
Loading