Intuition
The obvious plan is to greedily subtract the standard symbol values (M, D, C, L, X, V, I) and then bolt on special-case if-statements for the six subtractive forms - 4, 9, 40, 90, 400, 900 - after the fact. That works but doubles the code and is easy to get subtly wrong. Instead, put those subtractive pairs directly into the value table itself, as first-class entries ordered right alongside the plain ones, all sorted descending by value. Once CM=900 and IX=9 live in the same table as M=1000 and X=10, one greedy loop - repeatedly subtracting the largest value that still fits - handles every case uniformly, with zero special-casing.
Put the six subtractive forms into the value table as if they were symbols, and the whole problem becomes a plain greedy: repeatedly take the largest value that fits. The table's descending order is what makes greedy optimal here.
Approach
Build one descending table
Create a list of (value, symbol) pairs covering all thirteen cases: 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 - in that exact descending order. The subtractive entries are not an afterthought; they are ordinary rows in the table.
Greedily consume the table
Walk the table from largest to smallest value. For each entry, while the remaining number is still at least that value, subtract it and append the symbol to the result, repeating until it no longer fits, then move to the next entry.
Stop once the number hits zero
Because the table is exhaustive and covers every digit position including its subtractive form, the number reaches exactly zero by the time the loop finishes, and the accumulated string is the complete Roman numeral.
Solution & live demo
Common pitfalls
Omitting the subtractive pairs from the table
table = [(1000,'M'), (500,'D'), (100,'C'), ...]
table = [(1000,'M'), (900,'CM'), (500,'D'), (400,'CD'), ...]
Without them, 4 renders as "IIII" and 9 as "VIIII" — valid arithmetic but not valid Roman numerals. Treating CM and IV as ordinary table entries handles every case with no special branch.
Using if instead of while
if num >= value:
num -= value
result.append(symbol)while num >= value:
A symbol can repeat up to three times — 3000 is "MMM". Taking each value at most once truncates every number needing repetition.
Building the table out of order
table = [(1,'I'), (4,'IV'), (5,'V'), ...]
# strictly descending by value
Greedy only produces the shortest valid numeral if the largest usable value is always taken first. Ascending order emits a string of Is and never reaches the larger symbols.
Edge cases
Matched directly by the IV or IX table entry, no special-case code needed.
Greedily consumes M three times, then CM, XC, IX in sequence: MMMCMXCIX.
Matches the last table entry, I, directly.
The while loop inside one table entry fires three times in a row, appending MMM.