Problem Overview
LeetCode 12 Integer to Roman asks you to convert a positive integer in the range 1 to 3999 into its Roman numeral representation. Roman numerals are built from seven standard symbols: I (1), V (5), X (10), L (50), C (100), D (500), and M (1000).
In addition to the seven base symbols, Roman numerals use six subtractive forms to avoid four-character repetition: IV (4), IX (9), XL (40), XC (90), CD (400), and CM (900). These subtractive pairs are where most naïve implementations introduce brittle conditional logic that can be replaced with a cleaner table-driven approach.
The problem guarantees the input is between 1 and 3999, so you never need to handle zero, negative numbers, or numbers requiring more than three M symbols. This bounded domain is the reason the algorithm runs in O(1) time regardless of implementation style.
- Convert integer (1–3999) to Roman numeral string
- I=1, V=5, X=10, L=50, C=100, D=500, M=1000
- Subtractive: IV=4, IX=9, XL=40, XC=90, CD=400, CM=900
- No four consecutive same symbols allowed (IV not IIII, IX not VIIII)
Greedy with Extended Table
The key insight is to build a table of 13 entries that includes all six subtractive forms alongside the seven base symbols, sorted in descending order by value: [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]. With this table, a single greedy loop handles everything.
The algorithm iterates through the table from largest to smallest. For each entry, it repeatedly appends the symbol to the result string and subtracts the value from the number as long as the number is still greater than or equal to that value. When the number can no longer be reduced by the current entry, it moves to the next smaller entry.
This approach produces the correct Roman numeral string for any integer in 1–3999 without any if-else branches inside the loop. The 13-entry table has already encoded all the special cases as regular greedy steps.
The 13-Entry Table Trick
The 13-entry table trick eliminates all subtractive-form special cases: 900, 400, 90, 40, 9, and 4 are handled as regular greedy steps, not as exceptions. Adding CM, CD, XC, XL, IX, and IV directly into the sorted table means the same while-loop body covers all 13 cases identically.
Algorithm
The algorithm is straightforward once the 13-entry table is in place. Initialize an empty result string, then for each (value, symbol) pair in the table from largest to smallest, use a while loop to consume as much of the remaining number as the current value allows.
Each while-loop iteration appends the current symbol once and subtracts the current value once. This continues until the number drops below the current value, at which point the outer for loop advances to the next smaller (value, symbol) pair.
After all 13 entries have been processed, the number is guaranteed to be exactly 0 because the smallest entry is (1, I) which covers any remaining unit. Return the accumulated result string.
- 1Define table = [(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")]
- 2Initialize result = ""
- 3For each (value, symbol) in table:
- 4 While num >= value: result += symbol; num -= value
- 5Return result
Why Greedy Works
Roman numerals are fundamentally a greedy representation: the largest symbol that fits into the remaining value always goes first. This is why starting from M (1000) and working down to I (1) always produces the canonical Roman numeral — there is no benefit to saving a larger symbol for later.
The subtractive forms IV, IX, XL, XC, CD, and CM are treated as atomic units in the extended table. When the greedy algorithm reaches the entry (4, "IV"), it handles "4" exactly as it handles "5" with V or "10" with X — by appending the symbol and subtracting the value. No special branching is needed because the table already encodes the intended symbol for each breakpoint.
Correctness also depends on the fact that the table covers every reachable remainder. After consuming all M, CM, D, and CD entries, the remaining number is at most 99. After consuming C, XC, L, and XL, the remainder is at most 9. Each level perfectly drains to the next level's range, so the greedy never gets stuck.
Why Greedy Is Always Optimal
The greedy approach works because Roman numeral values are carefully chosen: each value is at least roughly 2x the next smaller breakpoint (e.g., 10 > 2×4, 9 > 2×5 is not true but 9 and 5 are separate entries). The table structure ensures greedy always gives the shortest valid representation without backtracking.
Walk-Through Example
Consider num=1994. The greedy starts at 1000: 1994 >= 1000, append M, num becomes 994. 994 < 1000, move to next entry.
At 900 (CM): 994 >= 900, append CM, num becomes 94. 94 < 900, advance. At 500, 400, 100: all too large, skip. At 90 (XC): 94 >= 90, append XC, num becomes 4. At 50, 40, 10, 9, 5: all too large, skip. At 4 (IV): 4 >= 4, append IV, num becomes 0.
Result = "MCMXCIV". This is the correct Roman numeral for 1994. Notice that CM (900) and XC (90) and IV (4) were each consumed in a single while-loop iteration — no special cases, just the table driving the greedy.
- 1num=1994: 1994>=1000 → append M, num=994
- 2994>=900 → append CM, num=94
- 394<500, 94<400, 94<100 → skip D, CD, C
- 494>=90 → append XC, num=4
- 54<50, 4<40, 4<10, 4<9, 4<5 → skip L, XL, X, IX, V
- 64>=4 → append IV, num=0 → return "MCMXCIV"
Code Walkthrough — Python and Java
In Python: define the table as a list of tuples — table = [(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")]. Initialize result = "". For value, symbol in table: while num >= value: result += symbol; num -= value. Return result. The entire solution is about 10 lines.
In Java: define two parallel arrays — int[] values = {1000,900,500,400,100,90,50,40,10,9,5,4,1} and String[] symbols = {"M","CM","D","CD","C","XC","L","XL","X","IX","V","IV","I"}. Use a StringBuilder for efficiency. For (int i=0; i<values.length; i++): while (num >= values[i]): sb.append(symbols[i]); num -= values[i]. Return sb.toString().
Time complexity is O(1) because the input is bounded by 3999, which means the total number of symbol appends across all iterations is bounded by a constant (the maximum is 3+1+3+1+3+1+3 = 15 characters for 3888 = "MMMDCCCLXXXVIII"). Space complexity is O(1) for the fixed-size table and O(1) for the bounded output.
Table Must Be Sorted Descending
The table must be sorted descending by value. If you put 4 before 5 or 9 before 10 in the table, the greedy picks the wrong symbol first: num=9 would match IV (4) and then V (5) and emit "IVV" instead of correctly matching IX (9) and emitting "IX". Always place larger values first.