-
-
Notifications
You must be signed in to change notification settings - Fork 1.5k
[Roman Numerals] Draft of Approaches #3605
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
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 30020f5
updated approach documents, many changes
colinleach 1a1605d
fixed typos and other glitches
colinleach b4324cd
Update exercises/practice/roman-numerals/.approaches/introduction.md
colinleach fc8a15f
Update exercises/practice/roman-numerals/.approaches/introduction.md
colinleach 959dafb
Update exercises/practice/roman-numerals/.approaches/introduction.md
colinleach 610da40
Update exercises/practice/roman-numerals/.approaches/introduction.md
colinleach c04a47f
Update exercises/practice/roman-numerals/.approaches/introduction.md
colinleach 989ddb0
Update exercises/practice/roman-numerals/.approaches/introduction.md
colinleach bcd408b
Update exercises/practice/roman-numerals/.approaches/recurse-match/co…
colinleach cd70e57
Update exercises/practice/roman-numerals/.approaches/introduction.md
colinleach 26235f3
Update exercises/practice/roman-numerals/.approaches/recurse-match/co…
colinleach adb5312
Update exercises/practice/roman-numerals/.approaches/recurse-match/co…
colinleach d8d83ae
Fixed TODO in `itertools-starmap`
colinleach d128392
Merge branch 'approach-roman-numerals' of github.com:colinleach/pytho…
colinleach 1fab95c
Update content.md
colinleach 0e5d1a3
Update content.md again
colinleach 8872373
Update exercises/practice/roman-numerals/.approaches/itertools-starma…
BethanyG 8ff7417
Update exercises/practice/roman-numerals/.approaches/itertools-starma…
BethanyG File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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
103
exercises/practice/roman-numerals/.approaches/if-else/content.md
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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. | ||
|
|
8 changes: 8 additions & 0 deletions
8
exercises/practice/roman-numerals/.approaches/if-else/snippet.txt
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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
205
exercises/practice/roman-numerals/.approaches/introduction.md
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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) | ||
| ``` | ||
|
|
||
| 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 | ||
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Oh! LOVE THIS. 💖
There was a problem hiding this comment.
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...