Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
2 changes: 1 addition & 1 deletion exercises/practice/roman-numerals/.approaches/config.json
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@
"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.",
"blurb": "Test Roman numerals from the largest down and eat the maximum possible at each step.",
"authors": [
"BethanyG",
"colinleach"
Expand Down
25 changes: 11 additions & 14 deletions exercises/practice/roman-numerals/.approaches/if-else/content.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,27 +14,27 @@ def roman(number):
res = ''

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

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

if 4 > x > 0:
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'
res += 'XC'

if 4 > i > 0:
if 4 > i > 0:
res += i * 'I'
elif i == 4:
res += 'IV'
Expand All @@ -61,19 +61,16 @@ This can be done with a list comprehension, left-padding with zeros as necessary
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.
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
Expand All @@ -83,7 +80,6 @@ def roman(number: int) -> str:
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 = ''

Expand All @@ -99,5 +95,6 @@ def roman(number: int) -> str:
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.
(Using `return` instead of `elif` is a matter of personal preference.)

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.
57 changes: 28 additions & 29 deletions exercises/practice/roman-numerals/.approaches/introduction.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,29 +9,28 @@ In the version used for this exercise, the longest string needed to represent a
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).
However, there is no zero value available, so Roman numerals represent powers of 10 with different letters (I, X, C, and M), not by position (1, 10, 100, 1000, etc).

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.
The process 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
Expand All @@ -54,44 +53,44 @@ def roman(number: int) -> str:
return res
```

See [`if-else`][if-else] for details.
See the [`if-else`][if-else] approach 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"))
('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
# get the row in the lookup table for the 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]
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.
See the [`table-lookup`][table-lookup] approach 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.

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:
Expand All @@ -105,15 +104,15 @@ def roman(number: int) -> str:
result = ''
while number:
for arabic in ROMAN.keys():
if number >= arabic:
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.
See the [`loop-over-romans`][loop-over-romans] approach for details.

## Other approaches

Expand Down Expand Up @@ -152,7 +151,7 @@ This is a recursive version of the `loop-over-romans` approach, which only works

```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")
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, [])
Expand All @@ -167,7 +166,7 @@ def roman_recur(num: int, idx: int, digits: list[str]):
return roman_recur(num, idx + 1, digits)
```

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


### Over-use a functional approach
Expand All @@ -176,7 +175,7 @@ See [`recurse-match`][recurse-match] for details.
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)]))
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*.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,10 +4,10 @@
from itertools import starmap

def roman(number: int) -> str:
orders = [(1000, "M "), (100, "CDM"), (10, "XLC"), (1, "IVX")]
options = lambda I, V, X: ["", I, I * 2, I * 3, I + V, V, V + I, V + I * 2, V + I * 3, I + X]
orders = [(1000, 'M '), (100, 'CDM'), (10, 'XLC'), (1, 'IVX')]
options = lambda I, V, X: ['', I, I * 2, I * 3, I + V, V, V + I, V + I * 2, V + I * 3, I + X]
compute = lambda n, chars: options(*chars)[number % (n * 10) // n]
return "".join(starmap(compute, orders))
return ''.join(starmap(compute, orders))
```

This approach is certainly concise and ingenious, though it takes functional programming to a level that some Python programmers might consider a little cryptic.
Expand All @@ -24,7 +24,7 @@ The underlying reason is that lambdas are intended to be anonymous functions emb
Internally, they are all given the same name `<lambda>`, which can greatly complicate debugging.
Their use is a particular bugbear of the Python track maintainer.

We can refactor the code to satisfy the linter by using named `def` statements, and lowercase argument names.
We can refactor the code to satisfy the linter by using named `def` statements and lowercase argument names.
Type hints are also added for documentation:

```python
Expand All @@ -33,21 +33,21 @@ from itertools import starmap

def roman(number: int) -> str:
def options(i: str, v: str, x: str):
return ["", i, i * 2, i * 3, i + v, v, v + i, v + i * 2, v + i * 3, i + x]
return ['', i, i * 2, i * 3, i + v, v, v + i, v + i * 2, v + i * 3, i + x]

def compute(n: int, chars: str) -> iter:
return options(*chars)[number % (n * 10) // n]

orders = [(1000, "M "), (100, "CDM"), (10, "XLC"), (1, "IVX")]
return "".join(starmap(compute, orders))
orders = [(1000, 'M '), (100, 'CDM'), (10, 'XLC'), (1, 'IVX')]
return ''.join(starmap(compute, orders))
```

## Analysis

The central concept is that Roman letters are defined for 1, 5 and 10, times various powers of 10.

`orders` is relatively straightforward: a list of tuples, with each tuple containing the powers of 10 and (as far as possible) the letters for that number times (1, 5, 10).
Roman numerals for 5,000 and 10,000 are not defined, so are replaced here by spaces.
`orders` is relatively straightforward: a list of tuples, with each tuple containing the powers of 10 and (as far as possible) the letters for that number times (1, 5, 10).
Roman numerals for 5,000 and 10,000 are not defined, so spaces are used here instead.

The `options()` function just takes the three letters from one of these tuples and returns a list of numerals that can be constructed from them.
For example, the 10 to 90 range:
Expand All @@ -60,15 +60,15 @@ options('X', 'L', 'C')
There is no zero, so that is replaced by an empty string.

The `compute()` function takes a tuple from `orders` plus the top-level parameter `number`, and converts the appropriate decimal digit to its Roman equivalent, returning an `iterator`.
For example the first digit of 723:
For example, the first digit of 723:

```python
number = 723
[x for x in compute(100, "CDM")]
[x for x in compute(100, 'CDM')]
# => ['D', 'C', 'C']
```

The `starmap()` function ties `orders`, `options()` and `compute` together, splitting up strings and tuples as necessary to give each function the parameters it needs.
The `starmap()` function ties `orders`, `options()`, and `compute()` together, splitting up strings and tuples as necessary to give each function the parameters it needs.
Again, an iterator is returned:

```python
Expand All @@ -79,7 +79,7 @@ number = 723

Finally, `''.join()` converts this iterator to a single string that can be returned as the desired answer.

Once we get past the deliberate obfuscation, it is quite an elegant approach.
Once we get past the deliberate obfuscation, it is quite an elegant approach.
Though perhaps not the most idiomatic Python.

## Credit
Expand Down
Loading
Loading