Loop Over Romans

Roman Numerals
Roman Numerals in 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

This approach is one of a family, using some mapping from Arabic (decimal) to Roman numbers.

The code above uses a dictionary. With minor changes, we could also use nested tuples:

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:
    assert(number > 0)

    roman_num = ""
    for (k, v) in ROMANS:
        while k <= number:
            roman_num += v
            number -= k
    return roman_num

Using a pair of lists is also possible, with a shared index from the enumerate().

# 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']

def roman(number: int) -> str:
    "Take a decimal number and return Roman Numeral Representation"

    # List of Roman symbols
    res = []

    while (number > 0):
        # Find the largest amount we can chip off
        for i, val in enumerate(numbers):
            if (number >= val):
                res.append(names[i])
                number -= val
                break

    return ''.join(res)

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, 10 times powers of 10, it is possible to shorten the lookup and build up most of the digits programmatically:

# The 10's, 5's and 1's position chars for 1, 10, 100, 1000.
DIGIT_CHARS = ["XVI", "CLX", "MDC", "??M"]


def roman(number: int) -> str:
    """Return the Roman numeral for a number."""
    # Generate a mapping from numeric value to Roman numeral.
    mapping = []
    for position in range(len(DIGIT_CHARS) - 1, -1, -1):
        # Values: 1000, 100, 10, 1
        scale = 10 ** position
        chars = DIGIT_CHARS[position]
        # This might be: (9, IX) or (90, XC)
        mapping.append((9 * scale, chars[2] + chars[0]))
        # This might be: (5, V) or (50, D)
        mapping.append((5 * scale, chars[1]))
        # This might be: (4, IV) or (40, XD)
        mapping.append((4 * scale, chars[2] + chars[1]))
        mapping.append((1 * scale, chars[2]))

    out = ""
    for num, numerals in mapping:
        while number >= num:
            out += numerals
            number -= num
    return out

The code below is doing something similar to the dictionary approach at the top of this page, but more concisely:

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():
        major, number = divmod(number, divisor)
        result += symbol * major
    return result

These five solutions all share some common features:

  • Some sort of translation lookup.
  • Nested loops, a whileand a for, in either order.
  • At each step, find the largest number that can be subtracted from the decimal input and appended to the Roman representation.

When building a string gradually, it is often better to build an intermediate list, then do a join() at the end, as in the third example. This is because strings are immutable, so need to be copied at each step, and the old strings need to be garbage-collected.

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 (currently) ignored by the interpreter, but is useful for documentation purposes.

Increasingly, IDE's such as VSCode and PyCharm understand the type hints, using them to flag problems and provide advice.

4th Dec 2024 · Found it useful?

Other Approaches to Roman Numerals in Python

Other ways our community solved this exercise
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'))
If Else

Use booleans to find the correct translation for each digit.

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"))
    digits = [int(d) for d in str(number)][::-1]
    roman_digits = [table[i][d - 1] for (i, d) in enumerate(digits) if d != 0]
    return ''.join(roman_digits[::-1])
Table Lookup

Use a 2-D lookup table to eliminate loop nesting.

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]
    compute = lambda n, chars: options(*chars)[number % (n * 10) // n]
    return "".join(starmap(compute, orders))
Itertools Starmap

Use itertools.starmap() for an ingenious functional approach.

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)
Recurse Match

Combine recursive programming with the recently-introduced structural pattern matching.