Problem 89 — Roman Numerals

solved Fri, 18 Jul 2025, 21:39 · level 3 · difficulty 8% projecteuler.net github

For a number written in Roman numerals to be considered valid there are basic rules which must be followed. Even though the rules allow some numbers to be expressed in more than one way there is always a "best" way of writing a particular number.

For example, it would appear that there are at least six ways of writing the number sixteen:

IIIIIIIIIIIIIIII
VIIIIIIIIIII
VVIIIIII
XIIIIII
VVVI
XVI

However, according to the rules only XIIIIII and XVI are valid, and the last example is considered to be the most efficient, as it uses the least number of numerals.

The 11K text file, roman.txt (right click and 'Save Link/Target As...'), contains one thousand numbers written in valid, but not necessarily minimal, Roman numerals; see About... Roman Numerals for the definitive rules for this problem.

Find the number of characters saved by writing each of these in their minimal form.

Note: You can assume that all the Roman numerals in the file contain no more than four consecutive identical units.


Problem Analysis

The task is fundamentally a transcoding exercise: each input line is a valid but possibly wasteful Roman numeral, and I need to total how many characters I would save by rewriting every one of them in its shortest legal form. The crucial observation is that the rules of Roman notation define a unique canonical form for every positive integer in the range the file covers. A canonical form is simply the single "best" representation chosen by a fixed set of rules - here, the shortest valid numeral. Because that form is unique, I never have to weigh competing representations: I convert each numeral to a plain integer (a neutral, unambiguous intermediate) and re-encode that integer minimally. The difference in lengths is the saving for that line.

The re-encoding step is a greedy algorithm: at each step I consume the largest "denomination" that still fits the remaining value, where the denominations are the seven base symbols (I, V, X, L, C, D, M) plus the six subtractive pairs (IV, IX, XL, XC, CD, CM). Greedy is provably optimal here for the same reason it is optimal for making change with this particular denomination set - no combination of smaller pieces can express a value in fewer characters than taking the largest fitting piece first. Both the parse and the encode run in O(L) time per numeral, where L is the numeral's length (bounded by a small constant), so the whole program is O(N) in the number of lines - a thousand here, processed essentially instantly.

Solution Approach

Greedy Parse-then-Encode (Python and C)

Both files implement the same two-phase algorithm; I describe it once and note the language differences afterward.

Phase 1 - parse to integer. I scan the numeral from right to left, holding the value of the previously seen (rightmost) symbol in a variable last. For each symbol I look up its value n: if last > n the symbol is the smaller half of a subtractive pair (the I in IX), so I subtract it; otherwise I add it. Reading right-to-left makes this work with no look-ahead, because the larger neighbour of any subtractive pair is always already in last by the time I reach the smaller symbol. The Python version iterates over reversed(numeral) and indexes a dict; the C version uses a backward for loop and a switch in char_value, which the compiler turns into a fast jump table.

Phase 2 - encode minimally. I keep a table of (value, string) denominations ordered from largest to smallest, including all six subtractive pairs. In a loop, while the remaining number is positive, I pick the largest denomination that fits, subtract its value, and record its contribution. Because the table includes the subtractive pairs, the greedy choice always yields the canonical minimal form. The Python encoder finds the fitting denomination with max((value, numeral) for ... if value <= number) and builds the string by appending to a list. The C encoder linearly scans descending parallel arrays (numeral_values[] / numeral_strings[]), breaking on the first fit, and accumulates only the length rather than the string, since the problem asks only for character counts.

Measuring savings. For each line I add len(original) - len(minimal) to a running total. Lines that are already minimal contribute zero. The Python solution reads the file with splitlines; the C solution reads the whole file into one buffer and walks it with pointer arithmetic, skipping \r and \n between lines.

workbench

Test cases

categoryinputanswer
main file_url = https://projecteuler.net/resources/documents/0089_roman.txt 743

Results

solutioncategoryanswerverdict avg seconds cavg seconds py
p0089_s0 main 743 correct 0.000080 (63) 0.009392 (63)

Files

Tags & blogs