From 45c8cac1bd592b6243d6026cba0c91f946b98f8b Mon Sep 17 00:00:00 2001 From: Yrahcaz7 <74512479+Yrahcaz7@users.noreply.github.com> Date: Tue, 29 Sep 2026 23:29:34 -0400 Subject: [PATCH 1/3] implement remaining audit items --- .../roman-numerals/.approaches/config.json | 22 +++- .../.approaches/if-else/content.md | 66 ++++++------ .../.approaches/if-else/snippet.txt | 2 +- .../.approaches/introduction.md | 100 ++++++++---------- .../.approaches/itertools-starmap/content.md | 22 ++-- .../.approaches/itertools-starmap/snippet.txt | 8 +- .../.approaches/loop-over-romans/content.md | 82 +++++++++----- .../.approaches/recurse-match/content.md | 17 +-- .../.approaches/recurse-match/snippet.txt | 6 +- .../.approaches/table-lookup/content.md | 18 ++-- .../.approaches/table-lookup/snippet.txt | 4 +- 11 files changed, 192 insertions(+), 155 deletions(-) diff --git a/exercises/practice/roman-numerals/.approaches/config.json b/exercises/practice/roman-numerals/.approaches/config.json index cadc8c3acf8..90b51a5c389 100644 --- a/exercises/practice/roman-numerals/.approaches/config.json +++ b/exercises/practice/roman-numerals/.approaches/config.json @@ -3,6 +3,9 @@ "authors": [ "colinleach", "BethanyG" + ], + "contributors": [ + "yrahcaz7" ] }, "approaches": [ @@ -14,16 +17,22 @@ "authors": [ "BethanyG", "colinleach" + ], + "contributors": [ + "yrahcaz7" ] }, { "uuid": "4ed8396c-f2c4-4072-abc9-cc8fe2780a5a", "slug": "loop-over-romans", - "title": "Loop Over Romans", + "title": "Loop Over Roman Numerals", "blurb": "Test Roman numerals from the largest down and eat the maximum possible at each step.", "authors": [ "BethanyG", "colinleach" + ], + "contributors": [ + "yrahcaz7" ] }, { @@ -34,6 +43,9 @@ "authors": [ "BethanyG", "colinleach" + ], + "contributors": [ + "yrahcaz7" ] }, { @@ -44,16 +56,22 @@ "authors": [ "BethanyG", "colinleach" + ], + "contributors": [ + "yrahcaz7" ] }, { "uuid": "a492c6b4-3780-473d-a2e6-a1c3e3da4f81", "slug": "recurse-match", "title": "Recurse Match", - "blurb": "Combine recursive programming with the recently-introduced structural pattern matching.", + "blurb": "Combine recursive programming with structural pattern matching.", "authors": [ "BethanyG", "colinleach" + ], + "contributors": [ + "yrahcaz7" ] } ] diff --git a/exercises/practice/roman-numerals/.approaches/if-else/content.md b/exercises/practice/roman-numerals/.approaches/if-else/content.md index 7d610361485..23198d5ca06 100644 --- a/exercises/practice/roman-numerals/.approaches/if-else/content.md +++ b/exercises/practice/roman-numerals/.approaches/if-else/content.md @@ -16,29 +16,29 @@ def roman(number): if m > 0: res += m * 'M' - if 4 > c > 0: + if c < 4: res += c * 'C' elif c == 4: res += 'CD' - elif 9 > c > 4: + elif c < 9: res += 'D' + ((c - 5) * 'C') elif c == 9: res += 'CM' - if 4 > x > 0: + if x < 4: res += x * 'X' elif x == 4: res += 'XL' - elif 9 > x > 4: + elif x < 9: res += 'L' + ((x - 5) * 'X') elif x == 9: res += 'XC' - if 4 > i > 0: + if i < 4: res += i * 'I' elif i == 4: res += 'IV' - elif 9 > i > 4: + elif i < 9: res += 'V' + ((i - 5) * 'I') elif i == 9: res += 'IX' @@ -46,41 +46,20 @@ def roman(number): 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. +Though this approach is rather naive, but it gets the job done. +Something similar would work in most languages, though the usage of the `*` operator for string repetition is fairly Python-specific. -## Refactoring +Here, the first block of code uses the floor division operator (`//`) and the modulo operator (`%`) to extract each digit from the input number. +Then, the following blocks determine the Roman equivalent for each digit and append it to the resulting string. -The code above is quite long and a bit repetitive. -We should explore ways to make it more concise. +A more concise variation of the approach is discussed below. -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. - -Given that, the code simplifies to: +## Variation #1 ```python -def roman(number: int) -> str: - 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 - - m, c, x, i = ([0, 0, 0, 0] + [int(d) for d in str(number)])[-4:] +def roman(number): + m, c, x, i = ([0, 0, 0, 0] + [int(digit) for digit in str(number)])[-4:] res = '' if m > 0: @@ -93,8 +72,23 @@ def roman(number: int) -> str: res += translate_digit(i, ('I', 'IV', 'V', 'IX')) return res + + +def translate_digit(digit, translations): + 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 ``` -(Using `return` instead of `elif` is a matter of personal preference.) +In this variant, a [list comprehension][list-comprehension] is used to extract the digits from the input number, left-padding with zeros as necessary. +For determining the Roman equivalent for each digit, this solution uses a helper function that takes a digit and a tuple of translations for `(1, 4, 5, 9)` (or their 10x and 100x equivalents). 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. + + +[list-comprehension]: https://docs.python.org/3/tutorial/datastructures.html#list-comprehensions diff --git a/exercises/practice/roman-numerals/.approaches/if-else/snippet.txt b/exercises/practice/roman-numerals/.approaches/if-else/snippet.txt index 829bb41dd23..df19e51f85f 100644 --- a/exercises/practice/roman-numerals/.approaches/if-else/snippet.txt +++ b/exercises/practice/roman-numerals/.approaches/if-else/snippet.txt @@ -1,4 +1,4 @@ - def translate_digit(digit: int, translations: iter) -> str: + def translate_digit(digit, translations): units, four, five, nine = translations if digit < 4: return digit * units if digit == 4: return four diff --git a/exercises/practice/roman-numerals/.approaches/introduction.md b/exercises/practice/roman-numerals/.approaches/introduction.md index 05360bd86a3..d272b9f1ad6 100644 --- a/exercises/practice/roman-numerals/.approaches/introduction.md +++ b/exercises/practice/roman-numerals/.approaches/introduction.md @@ -18,19 +18,16 @@ The approaches to this exercise break down into two groups, with many variants i ## Digit-by-digit approaches -The process behind this class of approaches: +The process behind this class of approaches begins by splitting the input number into decimal digits. +Then for each digit, the Roman equivalent is determined, and the results are collected into a string and returned. -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. +Depending on the implementation, the resulting string (or an intermediate representation) may need to be reversed. ### With `if` conditions ```python -def roman(number: int) -> str: - def translate_digit(digit: int, translations: iter) -> str: +def roman(number): + def translate_digit(digit, translations): units, four, five, nine = translations if digit < 4: return digit * units @@ -40,7 +37,7 @@ def roman(number: int) -> str: return five + (digit - 5) * units return nine - m, c, x, i = ([0, 0, 0, 0] + [int(d) for d in str(number)])[-4:] + m, c, x, i = ([0, 0, 0, 0] + [int(digit) for digit in str(number)])[-4:] res = '' if m > 0: res += m * 'M' @@ -53,7 +50,7 @@ def roman(number: int) -> str: return res ``` -See the [`if-else`][if-else] approach for details. +See the [if else][if-else] approach for details. ### With table lookup @@ -67,58 +64,55 @@ def roman(number): ('M', 'MM', 'MMM')) # convert the input integer to a list of single digits - digits = [int(d) for d in str(number)] + digits = [int(digit) for digit in str(number)] # 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 - idx][digit - 1] for idx, digit in enumerate(digits) if digit != 0] # convert the list of Roman numerals to a single string return ''.join(roman_digits) ``` -See the [`table-lookup`][table-lookup] approach for details. - +See the [table lookup][table-lookup] approach for details. -## Loop over Romans approaches -In this class of approaches we: +## Loop over Roman Numerals approaches -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. +In this class of approaches, we begin by creating a mapping from Roman to Arabic numbers, in some suitable format (_`dicts` or `tuples` work well_). +Then, we use nested loops to repeatedly append the largest possible Roman number to a sequence and subtract the corresponding value from the number being converted. +When the number being converted drops to zero, we return the resulting sequence (converting to a string if necessary). -Depending on the implementation, there may need to be a list-reverse step. +Depending on the implementation, the resulting string (or an intermediate representation) may need to be reversed. 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'} +ROMANS = {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: +def roman(number): result = '' while number: - for arabic in ROMAN.keys(): + for arabic in ROMANS.keys(): if number >= arabic: - result += ROMAN[arabic] + result += ROMANS[arabic] number -= arabic break return result ``` There are a number of variants. -See the [`loop-over-romans`][loop-over-romans] approach for details. +See the [loop over roman numerals][loop-over-romans] approach for details. ## Other approaches ### Built-in methods -Python has a package for pretty much everything, and Roman numerals are [no exception][roman-module]. +Python has a package for pretty much everything, and Roman numerals are no exception: ```python >>> import roman @@ -128,54 +122,51 @@ Python has a package for pretty much everything, and Roman numerals are [no exce 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 -``` +First it is necessary to install the package with `pip`, `conda`, or another tool. +Like most external packages, the [`roman` module][roman-module] is not available in the Exercism test runner. -The library function is a wrapper around a `loop-over-romans` approach! +The [key part of `toRoman()`'s implementation][roman-module-implementation] can be viewed on GitHub, which may look familiar. +This is because the library function is a wrapper around a "loop over roman numerals" approach! ### Recursion -This is a recursive version of the `loop-over-romans` approach, which only works in Python 3.10 and later: +This is a recursive version of the "loop over roman numerals" 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: +def roman(number): return roman_recur(number, 0, []) -def roman_recur(num: int, idx: int, digits: list[str]): +def roman_recur(num, idx, digits): 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 - ARABIC_NUM[idx], idx, [ROMAN_NUM[idx]] + digits) + case _: return roman_recur(num, idx + 1, digits) ``` -See the [`recurse-match`][recurse-match] approach for details. +See the [recurse match][recurse-match] approach for details. + + +### `itertools.starmap()` +TBA ### 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)])) + 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(digit) for digit in str(number)], + ['--MDCLXVI'[-idx*2-1 : -idx*2-4 : -1] for idx in range(len(str(number)) - 1, -1, -1)] + ) + ) ``` *This is Python, but not as we know it*. @@ -187,7 +178,7 @@ As the textbooks say, further analysis of this approach is left as an exercise f In production, it would make sense to use the `roman` package. It is debugged and supports Roman-to-Arabic conversions in addition 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]. +Most submissions, like the `roman` package implementation, use some variant of the [loop over roman numerals][loop-over-romans] approach. Using a [2-D lookup table][table-lookup] takes a bit more initialization, but then everything 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. @@ -202,3 +193,4 @@ The problem is inherently limited in scope by the design of Roman numerals, so a [loop-over-romans]: https://exercism.org/tracks/python/exercises/roman-numerals/approaches/loop-over-romans [recurse-match]: https://exercism.org/tracks/python/exercises/roman-numerals/approaches/recurse-match [roman-module]: https://github.com/zopefoundation/roman +[roman-module-implementation]: https://github.com/zopefoundation/roman/blob/6c0a134c091df4b63fc60c7e720fe1fb645f521d/src/roman/__init__.py#L73-L78 diff --git a/exercises/practice/roman-numerals/.approaches/itertools-starmap/content.md b/exercises/practice/roman-numerals/.approaches/itertools-starmap/content.md index 6609e7f40ff..ed9c30212b2 100644 --- a/exercises/practice/roman-numerals/.approaches/itertools-starmap/content.md +++ b/exercises/practice/roman-numerals/.approaches/itertools-starmap/content.md @@ -3,16 +3,16 @@ ```python from itertools import starmap -def roman(number: int) -> str: +def roman(number): 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] + compute = lambda val, chars: options(*chars)[number % (val * 10) // val] 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. -The [`itertools.starmap()`][starmap] method is a variant of `map()` that takes its argument parameters pre-zipped in tuples. +The [`itertools.starmap()`][itertools-starmap] method is a variant of `map()` that takes its argument parameters pre-zipped in tuples. It has the signature `starmap(f: function, i: iter) -> iter`. ## Linting @@ -31,12 +31,12 @@ Type hints are also added for documentation: from itertools import starmap -def roman(number: int) -> str: - def options(i: str, v: str, x: str): +def roman(number): + def options(i, v, 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] + def compute(val, chars): + return options(*chars)[number % (val * 10) // val] orders = [(1000, 'M '), (100, 'CDM'), (10, 'XLC'), (1, 'IVX')] return ''.join(starmap(compute, orders)) @@ -68,7 +68,7 @@ number = 723 # => ['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()`][itertools-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 @@ -77,7 +77,7 @@ number = 723 # => ['', 'DCC', 'XX', 'III'] ``` -Finally, `''.join()` converts this iterator to a single string that can be returned as the desired answer. +Finally, [`''.join()`][str-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. Though perhaps not the most idiomatic Python. @@ -86,5 +86,7 @@ Though perhaps not the most idiomatic Python. We owe this approach to @MAPKarrenbelt, who must have had fun with it. -[starmap]: https://docs.python.org/3/library/itertools.html#itertools.starmap + +[itertools-starmap]: https://docs.python.org/3/library/itertools.html#itertools.starmap [pep8]: https://peps.python.org/pep-0008/#programming-recommendations +[str-join]: https://docs.python.org/3/builtins/stdtypes.html#str.join diff --git a/exercises/practice/roman-numerals/.approaches/itertools-starmap/snippet.txt b/exercises/practice/roman-numerals/.approaches/itertools-starmap/snippet.txt index 4f5e75e6db5..3fe56be2c08 100644 --- a/exercises/practice/roman-numerals/.approaches/itertools-starmap/snippet.txt +++ b/exercises/practice/roman-numerals/.approaches/itertools-starmap/snippet.txt @@ -1,7 +1,7 @@ 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] +def roman(number): + 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)) diff --git a/exercises/practice/roman-numerals/.approaches/loop-over-romans/content.md b/exercises/practice/roman-numerals/.approaches/loop-over-romans/content.md index cded1e44ba5..5c32c22e451 100644 --- a/exercises/practice/roman-numerals/.approaches/loop-over-romans/content.md +++ b/exercises/practice/roman-numerals/.approaches/loop-over-romans/content.md @@ -1,32 +1,37 @@ # Loop Over Roman Numerals ```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'} +ROMANS = {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: +def roman(number): result = '' while number: - for arabic in ROMAN.keys(): + for arabic in ROMANS.keys(): if number >= arabic: - result += ROMAN[arabic] + result += ROMANS[arabic] number -= arabic break return result ``` This approach is one of a family, using some mapping from Arabic (decimal) numbers to Roman numbers. +This specific solution uses a dictionary to hold the mappings, though other data structures can work as well. + +Here, the `roman()` function iterates over the mapping and finds the biggest Roman numeral that can be subtracted from the number being converted. +Next, that numeral is appended to the resulting string, and its corresponding decimal value is subtracted from the number being converted. +This process then repeats until the number being converted drops to zero, and then the accumulated string is returned. + -The code above uses a dictionary. -With minor changes, we could also use nested tuples: +## Variation #1 ```python ROMANS = ((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: +def roman(number): roman_num = '' for arabic, roman in ROMANS: while arabic <= number: @@ -35,38 +40,43 @@ def roman(number: int) -> str: return roman_num ``` -Using a pair of lists is also possible, with a shared index from `enumerate()`. +This variant uses nested tuples instead of a dictionary to hold the mappings. +It also finds the largest Roman numeral slightly differently: It iterates over the mapping first, repeating the appending and subtraction of each number as many times as necessary. + + +## Variation #2 ```python -# Use a translation -numbers = [1000, 900, 500, 400, 100, 90, 50, 40, 10, 9, 5, 4, 1] -names = [ 'M', 'CM','D','CD', 'C','XC','L','XL', 'X','IX','V','IV', 'I'] +NUMBERS = [1000, 900, 500, 400, 100, 90, 50, 40, 10, 9, 5, 4, 1] +NAMES = [ 'M', 'CM','D','CD', 'C','XC','L','XL', 'X','IX','V','IV', 'I'] -def roman(number: int) -> str: - # List of Roman symbols +def roman(number): res = [] - while (number > 0): - # Find the largest amount we can chip off - for idx, val in enumerate(numbers): + while number > 0: + for idx, val in enumerate(NUMBERS): if number >= val: - res.append(names[idx]) + res.append(NAMES[idx]) number -= val break return ''.join(res) ``` -However, for a read-only lookup it may be better to use (immutable) tuples for `numbers` and `names`. +This solution uses a pair of lists, with a shared index from `enumerate()`. +It also accumulates the numerals using a list instead of a string, and uses a [`str.join()`][str-join] at the end get the final result. + +However, for a read-only lookup it may be better to use (immutable) tuples for `NUMBERS` and `NAMES`. + -As Roman numerals are built up from letters for 1, 5, and 10 times powers of 10, it is possible to shorten the lookup and build up most of the digits programmatically: +## Variation #3 ```python # The 10's, 5's, and 1's position chars for 1, 10, 100, and 1000. DIGIT_CHARS = ['XVI', 'CLX', 'MDC', '??M'] -def roman(number: int) -> str: +def roman(number): # Generate a mapping from numeric value to Roman numeral. mapping = [] for position in range(len(DIGIT_CHARS) - 1, -1, -1): @@ -89,19 +99,33 @@ def roman(number: int) -> str: return out ``` -The code below does something similar to the dictionary approach at the top of this page, but more concisely: +This variant takes advantage of the fact that Roman numerals are built up from letters for 1, 5, and 10 times powers of 10 to build up the mapping programmatically. + + +## Variation #4 ```python +DIVISOR_MAP = {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 = '' - divisor_map = {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'} - for divisor, symbol in divisor_map.items(): + for divisor, symbol in DIVISOR_MAP.items(): major, number = divmod(number, divisor) result += symbol * major return result ``` +This solution removes a level of looping by replacing the inner loop with calculations to determine how many of each number can be subtracted from the number being converted. +Here the built-in [`divmod()`][divmod] function is used to get the number of times the numeral can be subtracted (`major`), and what remains of the number being converted after the subtraction (`number`) in one step. + +Incidentally, notice the use of [type hints][type-hints]: `def roman(number: int) -> str`. +This is optional in Python and is (currently) ignored by the interpreter, but is useful for documentation purposes. + +Increasingly, code editors and IDEs such as VSCode and PyCharm understand the type hints, using them to flag problems and provide advice. + + +## Conclusions These five solutions all share some common features: @@ -114,7 +138,7 @@ This is because strings are immutable, so they need to be copied at each step, a However, Roman numerals are always so short that the difference is minimal in this case. -Incidentally, notice the use of type hints: `def roman(number: int) -> str`. -This is optional in Python and is (currently) ignored by the interpreter, but is useful for documentation purposes. -Increasingly, code editors and IDEs such as VSCode and PyCharm understand the type hints, using them to flag problems and provide advice. +[divmod]: https://docs.python.org/3/builtins/functions.html#divmod +[str-join]: https://docs.python.org/3/builtins/stdtypes.html#str.join +[type-hints]: https://docs.python.org/3/library/typing.html diff --git a/exercises/practice/roman-numerals/.approaches/recurse-match/content.md b/exercises/practice/roman-numerals/.approaches/recurse-match/content.md index 14b97ef1e87..ffbfc536e7a 100644 --- a/exercises/practice/roman-numerals/.approaches/recurse-match/content.md +++ b/exercises/practice/roman-numerals/.approaches/recurse-match/content.md @@ -4,16 +4,16 @@ 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: +def roman(number): return roman_recur(number, 0, []) -def roman_recur(num: int, idx: int, digits: list[str]): +def roman_recur(num, idx, digits): 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 - ARABIC_NUM[idx], idx, [ROMAN_NUM[idx]] + digits) + case _: return roman_recur(num, idx + 1, digits) ``` @@ -26,17 +26,20 @@ However, Roman numerals are so limited in scale that they could be an ideal use In practice, there is no obvious advantage to recursion over using a loop (_everything you can do with recursion you can do with a loop and vice-versa_). Note the use of [structural pattern matching][pep-636], available in Python since version 3.10. -There is also an [official tutorial][structural-pattern-matching] for this new feature. +There is also an [official tutorial][structural-pattern-matching] for this feature. The code above is adapted from a Scala approach, where it may be more appropriate. -Once we get past the unfamiliar-in-Python syntax, this code is doing essentially the same as other [`loop-over-romans`][loop-over-romans] approaches. +Once we get past the unfamiliar-in-Python syntax, this code is doing essentially the same as other [loop over roman numerals][loop-over-romans] approaches. + + +## Variation #1 Without the pattern matching, a recursive approach might look something like this: ```python LOOKUP = [(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')] + (40, 'XL'), (10, 'X'), (9, 'IX'), (5, 'V'), (4, 'IV'), (1, 'I')] def convert(number, idx, output): if idx > 12: diff --git a/exercises/practice/roman-numerals/.approaches/recurse-match/snippet.txt b/exercises/practice/roman-numerals/.approaches/recurse-match/snippet.txt index d5b6d091a3a..c5a07cbfbba 100644 --- a/exercises/practice/roman-numerals/.approaches/recurse-match/snippet.txt +++ b/exercises/practice/roman-numerals/.approaches/recurse-match/snippet.txt @@ -3,6 +3,6 @@ def roman_recur(num: int, idx: int, digits: list[str]): 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) \ No newline at end of file + return roman_recur(num - ARABIC_NUM[idx], idx, [ROMAN_NUM[idx]] + digits) + case _: + return roman_recur(num, idx + 1, digits) diff --git a/exercises/practice/roman-numerals/.approaches/table-lookup/content.md b/exercises/practice/roman-numerals/.approaches/table-lookup/content.md index b95395f27c3..2a68bc805a3 100644 --- a/exercises/practice/roman-numerals/.approaches/table-lookup/content.md +++ b/exercises/practice/roman-numerals/.approaches/table-lookup/content.md @@ -10,21 +10,21 @@ def roman(number): ('M', 'MM', 'MMM')) # convert the input integer to a list of single digits - digits = [int(d) for d in str(number)] + digits = [int(digit) for digit in str(number)] # 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 - idx][digit - 1] for idx, digit in enumerate(digits) if digit != 0] # convert the list of Roman numerals to a single string return ''.join(roman_digits) ``` -In this approach we loop over decimal digits, not their Roman equivalents. +In this approach we loop over decimal digits (using a [list comprehension][list-comprehension]), not their Roman equivalents. -The key point is to have a 2-dimensional lookup table, with each row corresponding to a separate digit: ones, tens, hundreds, thousands. +The key point is to have a 2-dimensional lookup table, with each row corresponding to a separate digit: ones, tens, hundreds, or thousands. Each digit can then be converted to its Roman equivalent with a single lookup. Note that we need to compensate for Python's zero-based indexing by (in effect) subtracting 1 from each row and column. @@ -34,7 +34,7 @@ Note that we need to compensate for Python's zero-based indexing by (in effect) In the code above, we used the `inverter` variable to work bottom-to-top through the lookup table. This allows working left-to-right through the decimal digits. -Alternatively, we could reverse the `digits` list, go top-to-bottom through the lookup table, then reverse the `roman_digits` list before the final `join()`. +Alternatively, we could reverse the `digits` list, go top-to-bottom through the lookup table, then reverse the `roman_digits` list before the final [`join()`][str-join]. ```python def roman(number): @@ -46,10 +46,10 @@ def roman(number): ('M', 'MM', 'MMM')) # convert the input integer to a list of single digits, in reverse order - digits = [int(d) for d in str(number)][::-1] + digits = [int(digit) for digit in str(number)][::-1] # translate decimal digits list to Roman numerals list - roman_digits = [table[i][d - 1] for i, d in enumerate(digits) if d != 0] + roman_digits = [table[idx][digit - 1] for idx, digit in enumerate(digits) if digit != 0] # reverse the list of Roman numerals and convert to a single string return ''.join(roman_digits[::-1]) @@ -62,3 +62,7 @@ The `[::-1]` indexing is idiomatic Python, but less experienced programmers may ## Credit This approach was adapted from one created by @cmcaine on the Julia track. + + +[list-comprehension]: https://docs.python.org/3/tutorial/datastructures.html#list-comprehensions +[str-join]: https://docs.python.org/3/builtins/stdtypes.html#str.join diff --git a/exercises/practice/roman-numerals/.approaches/table-lookup/snippet.txt b/exercises/practice/roman-numerals/.approaches/table-lookup/snippet.txt index 0ad912bd504..e88fff35c35 100644 --- a/exercises/practice/roman-numerals/.approaches/table-lookup/snippet.txt +++ b/exercises/practice/roman-numerals/.approaches/table-lookup/snippet.txt @@ -3,6 +3,6 @@ table = ( ('X', 'XX', 'XXX', 'XL', 'L', 'LX', 'LXX', 'LXXX', 'XC'), ('C', 'CC', 'CCC', 'CD', 'D', 'DC', 'DCC', 'DCCC', 'CM'), ('M', 'MM', 'MMM')) -digits = [int(d) for d in str(number)][::-1] -roman_digits = [table[i][d - 1] for i, d in enumerate(digits) if d != 0] +digits = [int(digit) for digit in str(number)][::-1] +roman_digits = [table[idx][digit - 1] for idx, digit in enumerate(digits) if digit != 0] return ''.join(roman_digits[::-1]) From bef8031e85d694183605fc1fe171234969d22d49 Mon Sep 17 00:00:00 2001 From: Yrahcaz7 <74512479+Yrahcaz7@users.noreply.github.com> Date: Tue, 29 Sep 2026 23:39:58 -0400 Subject: [PATCH 2/3] add itertools.starmap section in introduction --- .../.approaches/introduction.md | 21 +++++++++++++++++-- 1 file changed, 19 insertions(+), 2 deletions(-) diff --git a/exercises/practice/roman-numerals/.approaches/introduction.md b/exercises/practice/roman-numerals/.approaches/introduction.md index d272b9f1ad6..f90d6b448cf 100644 --- a/exercises/practice/roman-numerals/.approaches/introduction.md +++ b/exercises/practice/roman-numerals/.approaches/introduction.md @@ -152,9 +152,25 @@ def roman_recur(num, idx, digits): See the [recurse match][recurse-match] approach for details. -### `itertools.starmap()` +### With `itertools.starmap()` + +```python +from itertools import starmap + + +def roman(number): + def options(i, v, x): + return ['', i, i * 2, i * 3, i + v, v, v + i, v + i * 2, v + i * 3, i + x] + + def compute(val, chars): + return options(*chars)[number % (val * 10) // val] + + orders = [(1000, 'M '), (100, 'CDM'), (10, 'XLC'), (1, 'IVX')] + return ''.join(starmap(compute, orders)) +``` + +See the [`itertools.starmap()`][itertools-starmap] approach for details. -TBA ### Over-use a functional approach @@ -192,5 +208,6 @@ The problem is inherently limited in scope by the design of Roman numerals, so a [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-romans [recurse-match]: https://exercism.org/tracks/python/exercises/roman-numerals/approaches/recurse-match +[itertools-starmap]: https://exercism.org/tracks/python/exercises/roman-numerals/approaches/itertools-starmap [roman-module]: https://github.com/zopefoundation/roman [roman-module-implementation]: https://github.com/zopefoundation/roman/blob/6c0a134c091df4b63fc60c7e720fe1fb645f521d/src/roman/__init__.py#L73-L78 From 5cf2e1101c21885c1706b260edf340eb759eb558 Mon Sep 17 00:00:00 2001 From: Yrahcaz7 <74512479+Yrahcaz7@users.noreply.github.com> Date: Wed, 30 Sep 2026 14:03:03 -0400 Subject: [PATCH 3/3] improve `recurse-match` and `itertools-starmap` --- .../.approaches/introduction.md | 7 ++- .../.approaches/itertools-starmap/content.md | 62 +++++++++---------- .../.approaches/itertools-starmap/snippet.txt | 8 +-- .../.approaches/recurse-match/content.md | 24 ++++--- .../.approaches/recurse-match/snippet.txt | 6 +- 5 files changed, 58 insertions(+), 49 deletions(-) diff --git a/exercises/practice/roman-numerals/.approaches/introduction.md b/exercises/practice/roman-numerals/.approaches/introduction.md index f90d6b448cf..1e25f0f174c 100644 --- a/exercises/practice/roman-numerals/.approaches/introduction.md +++ b/exercises/practice/roman-numerals/.approaches/introduction.md @@ -142,9 +142,9 @@ def roman(number): def roman_recur(num, idx, digits): match (num, idx, digits): case [_, 13, digits]: - return ''.join(digits[::-1]) + return ''.join(digits) case [num, idx, digits] if num >= ARABIC_NUM[idx]: - return roman_recur(num - ARABIC_NUM[idx], idx, [ROMAN_NUM[idx]] + digits) + return roman_recur(num - ARABIC_NUM[idx], idx, digits + [ROMAN_NUM[idx]]) case _: return roman_recur(num, idx + 1, digits) ``` @@ -159,13 +159,14 @@ from itertools import starmap def roman(number): + orders = [(1000, 'M '), (100, 'CDM'), (10, 'XLC'), (1, 'IVX')] + def options(i, v, x): return ['', i, i * 2, i * 3, i + v, v, v + i, v + i * 2, v + i * 3, i + x] def compute(val, chars): return options(*chars)[number % (val * 10) // val] - orders = [(1000, 'M '), (100, 'CDM'), (10, 'XLC'), (1, 'IVX')] return ''.join(starmap(compute, orders)) ``` diff --git a/exercises/practice/roman-numerals/.approaches/itertools-starmap/content.md b/exercises/practice/roman-numerals/.approaches/itertools-starmap/content.md index ed9c30212b2..4bc527e259f 100644 --- a/exercises/practice/roman-numerals/.approaches/itertools-starmap/content.md +++ b/exercises/practice/roman-numerals/.approaches/itertools-starmap/content.md @@ -3,50 +3,26 @@ ```python from itertools import starmap + def roman(number): 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 val, chars: options(*chars)[number % (val * 10) // val] - 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. - -The [`itertools.starmap()`][itertools-starmap] method is a variant of `map()` that takes its argument parameters pre-zipped in tuples. -It has the signature `starmap(f: function, i: iter) -> iter`. - -## Linting - -One issue with this code is the use of named lambdas. -This is discouraged by [PEP-8][pep8], and linters will complain about it. - -The underlying reason is that lambdas are intended to be anonymous functions embedded within other expressions. -Internally, they are all given the same name ``, 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. -Type hints are also added for documentation: - -```python -from itertools import starmap - - -def roman(number): def options(i, v, x): return ['', i, i * 2, i * 3, i + v, v, v + i, v + i * 2, v + i * 3, i + x] def compute(val, chars): return options(*chars)[number % (val * 10) // val] - orders = [(1000, 'M '), (100, 'CDM'), (10, 'XLC'), (1, 'IVX')] 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. + ## Analysis -The central concept is that Roman letters are defined for 1, 5 and 10, times various powers of 10. +The central concept in this solution 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). +`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, and 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. @@ -68,7 +44,10 @@ number = 723 # => ['D', 'C', 'C'] ``` -The [`starmap()`][itertools-starmap] function ties `orders`, `options()`, and `compute()` together, splitting up strings and tuples as necessary to give each function the parameters it needs. +Next, the [`itertools.starmap()`][itertools-starmap] function ties `orders`, `options()`, and `compute()` together. +(The `starmap()` function is a variant of `map()` that takes its argument parameters pre-zipped in tuples.) + +Here, `starmap()` is used to split up strings and tuples as necessary to give each function the parameters it needs. Again, an iterator is returned: ```python @@ -79,9 +58,30 @@ number = 723 Finally, [`''.join()`][str-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 understand all of the parts and how they fit together, it is quite an elegant approach. Though perhaps not the most idiomatic Python. + +## Variation #1 + +```python +from itertools import starmap + +def roman(number): + 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 val, chars: options(*chars)[number % (val * 10) // val] + return ''.join(starmap(compute, orders)) +``` + +This variation uses named lambdas instead of nested functions to make the code even more concise. +However, named lambdas are discouraged by [PEP-8][pep8], and linters will complain about it. + +The underlying reason is that lambdas are intended to be anonymous functions embedded within other expressions. +Internally, they are all given the same name ``, which can greatly complicate debugging. +Their use is a particular bugbear of the Python track maintainer. + + ## Credit We owe this approach to @MAPKarrenbelt, who must have had fun with it. diff --git a/exercises/practice/roman-numerals/.approaches/itertools-starmap/snippet.txt b/exercises/practice/roman-numerals/.approaches/itertools-starmap/snippet.txt index 3fe56be2c08..9e7f3e6729a 100644 --- a/exercises/practice/roman-numerals/.approaches/itertools-starmap/snippet.txt +++ b/exercises/practice/roman-numerals/.approaches/itertools-starmap/snippet.txt @@ -1,7 +1,7 @@ -from itertools import starmap - def roman(number): 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] + def options(i, v, x): + return ['', i, i * 2, i * 3, i + v, v, v + i, v + i * 2, v + i * 3, i + x] + def compute(val, chars): + return options(*chars)[number % (val * 10) // val] return ''.join(starmap(compute, orders)) diff --git a/exercises/practice/roman-numerals/.approaches/recurse-match/content.md b/exercises/practice/roman-numerals/.approaches/recurse-match/content.md index ffbfc536e7a..421f12ca4c3 100644 --- a/exercises/practice/roman-numerals/.approaches/recurse-match/content.md +++ b/exercises/practice/roman-numerals/.approaches/recurse-match/content.md @@ -10,14 +10,26 @@ def roman(number): def roman_recur(num, idx, digits): match (num, idx, digits): case [_, 13, digits]: - return ''.join(digits[::-1]) + return ''.join(digits) case [num, idx, digits] if num >= ARABIC_NUM[idx]: - return roman_recur(num - ARABIC_NUM[idx], idx, [ROMAN_NUM[idx]] + digits) + return roman_recur(num - ARABIC_NUM[idx], idx, digits + [ROMAN_NUM[idx]]) case _: return roman_recur(num, idx + 1, digits) ``` -[Recursion][recursion] is possible in Python, but it is much less commonly used than in some other languages. +Similar to the [loop over roman numerals][loop-over-romans] approach, this solution uses a mapping from Arabic numbers to Roman numbers. +Here, `roman()` calls the helper function `roman_recur()`, which has arguments for the number being converted (`num`), the index of the mapping to check (`idx`), and list of the currently computed digits (`digits`). + +The code then uses [structural pattern matching][pep-636] (added in Python 3.10) to branch into three different cases depending on the input. +(See the [official pattern matching tutorial][structural-pattern-matching] for detail on how this works.) + +The first case occurs when there are no more entries of the mapping to check, and it uses [`''.join()`][str-join] to join the list of digits into the final Roman numeral and return it. + +The second case checks if the Arabic number at `idx` can be subtracted from the number being converted. If it can, `roman_recur()` is called [recursively][recursion] with the subtracted number, and the list of Roman digits updated with the corresponding Roman number at `idx`. + +The final case uses a wildcard (`_`), so it always runs if the previous cases do not. This case also returns the result from calling `roman_recur()` again, but this time it increments `idx` so the next entry in the mapping is checked. + +Though [recursion][recursion] is possible in Python, it is much less commonly used than in some other languages. A major limitation is the lack of tail-call optimization, which can easily trigger stack overflow if the recursion goes too deep. The maximum recursion depth for Python defaults to 1000 to avoid this overflow. @@ -25,13 +37,8 @@ The maximum recursion depth for Python defaults to 1000 to avoid this overflow. However, Roman numerals are so limited in scale that they could be an ideal use case for playing with recursion. In practice, there is no obvious advantage to recursion over using a loop (_everything you can do with recursion you can do with a loop and vice-versa_). -Note the use of [structural pattern matching][pep-636], available in Python since version 3.10. -There is also an [official tutorial][structural-pattern-matching] for this feature. - The code above is adapted from a Scala approach, where it may be more appropriate. -Once we get past the unfamiliar-in-Python syntax, this code is doing essentially the same as other [loop over roman numerals][loop-over-romans] approaches. - ## Variation #1 @@ -57,4 +64,5 @@ def roman(number): [recursion]: https://diveintopython.org/learn/functions/recursion [pep-636]: https://peps.python.org/pep-0636/ [structural-pattern-matching]: https://docs.python.org/3/tutorial/controlflow.html#match-statements +[str-join]: https://docs.python.org/3/builtins/stdtypes.html#str.join [loop-over-romans]: https://exercism.org/tracks/python/exercises/roman-numerals/approaches/loop-over-romans diff --git a/exercises/practice/roman-numerals/.approaches/recurse-match/snippet.txt b/exercises/practice/roman-numerals/.approaches/recurse-match/snippet.txt index c5a07cbfbba..f26102a3949 100644 --- a/exercises/practice/roman-numerals/.approaches/recurse-match/snippet.txt +++ b/exercises/practice/roman-numerals/.approaches/recurse-match/snippet.txt @@ -1,8 +1,8 @@ -def roman_recur(num: int, idx: int, digits: list[str]): +def roman_recur(num, idx, digits): match (num, idx, digits): case [_, 13, digits]: - return ''.join(digits[::-1]) + return ''.join(digits) case [num, idx, digits] if num >= ARABIC_NUM[idx]: - return roman_recur(num - ARABIC_NUM[idx], idx, [ROMAN_NUM[idx]] + digits) + return roman_recur(num - ARABIC_NUM[idx], idx, digits + [ROMAN_NUM[idx]]) case _: return roman_recur(num, idx + 1, digits)