12. Integer to Roman

Convert an integer (1–3999) to a Roman numeral. The whole trick is the lookup table: order the value→symbol pairs descending and include the six subtractive forms (CM, CD, XC, XL, IX, IV) as first-class entries, then greedily subtract.

● MediumMathGreedyHash MapTypeScript

PROBLEM What we're solving

Convert an integer num (1 to 3999) into its Roman numeral string. Roman numerals use I, V, X, L, C, D, M, written largest-to-smallest and summed — except for six subtractive pairs (IV, IX, XL, XC, CD, CM). Example: num = 1994 → "MCMXCIV" (M=1000, CM=900, XC=90, IV=4). Example: num = 58 → "LVIII" (L=50, V=5, III=3).

KEY IDEA Put the subtractive pairs IN the table

Insight → the only reason Roman numerals seem fiddly is the subtractive forms (4=IV, 9=IX, 40=XL, …). Stop special-casing them: list them as ordinary entries in a descending value→symbol 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]. Now it's a plain greedy: repeatedly take the largest value that still fits, append its symbol, subtract it, until num hits zero.

RECIPE Build the table, then greedily subtract

  • 0 · Build the table. Two parallel arrays (or a list of pairs) ordered by value descending, with the six subtractive pairs inlined. This is the entire difficulty of the problem.
  • 1 · Sweep largest → smallest. For each entry, while num >= value, you can place that symbol — because the table is sorted, the first entry that fits is always the largest legal one (greedy is optimal here).
  • 2 · Append and subtract. result += symbol then num -= value. Repeat the while-loop so e.g. 30 places X three times.
  • 3 · Stop at zero. When num reaches 0, result is the answer. Because 1 I is in the table, this always terminates.
Classic confusion →trying to detect "is this digit a 4 or a 9?" per decimal place and emitting IV/IX by hand. That works but is bug-prone. Once the subtractive pairs live in the table, there are no special cases — a 4 simply means the 4 IV entry fits and the 5 Vone didn't.

COST Complexity & alternatives

Per-digit hardcoding
O(1)
Map each decimal place (ones/tens/…) to a precomputed string. Fast, but four lookup tables and easy to mis-author.
Greedy subtract
O(1)
One descending table, one greedy loop. Bounded work since num ≤ 3999.

Both are effectively O(1): the table has 13 fixed entries and the answer has at most ~15 characters (e.g. 3888 = MMMDCCCLXXXVIII), so the loop body runs a constant-bounded number of times. Space is O(1) beyond the output string.

Pattern transfer →the "descending value table + greedy take-what-fits" shape is the canonical coin change with a canonical coin system (where greedy is provably optimal). It also mirrors Roman to Integer (the inverse: scan symbols, subtract when a smaller value precedes a larger one) and any number-to-words-style encoding.

RUN IT Greedy subtract: largest symbol that fits, repeat

step 0 / 14
STARTInput: 1994. Walk the value→symbol table from largest to smallest, greedily subtracting the largest value that still fits. The subtractive pairs (CM, CD, XC, XL, IX, IV) are baked into the table.
1function intToRoman(num: number): string {
2▶ const values = [1000, 900, 500, 400, 100, 90, 50, 40, 10, 9, 5, 4, 1];
3▶ const symbols = ['M', 'CM', 'D', 'CD', 'C', 'XC', 'L', 'XL', 'X', 'IX', 'V', 'IV', 'I'];
4
5▶ let result = '';
6
7 for (let i = 0; i < values.length; i++) {
8 while (num >= values[i]) { // largest value that still fits
9 result += symbols[i]; // append its symbol
10 num -= values[i]; // subtract it off
11 }
12 }
13
14 return result;
15}
tableM1000CM900D500CD400C100XC90L50XL40X10IX9V5IV4I1
State
1994
num (left)
""
result
remaining valuesymbol appendedresult so farentry too big — skip
slowfast

TYPESCRIPT The solution, annotated

intToRoman.ts
function intToRoman(num: number): string {
  // Table ordered DESC, with subtractive pairs inlined.
  const values  = [1000, 900, 500, 400, 100, 90, 50, 40, 10, 9, 5, 4, 1];
  const symbols = ['M', 'CM', 'D', 'CD', 'C', 'XC', 'L', 'XL', 'X', 'IX', 'V', 'IV', 'I'];

  let result = '';

  for (let i = 0; i < values.length; i++) {
    while (num >= values[i]) {     // largest value that still fits
      result += symbols[i];        // append its symbol
      num -= values[i];            // subtract it off
    }
  }

  return result;
}

Reading it block by block

Lines 3–4 — the table. Two parallel arrays ordered by value descending, with the subtractive pairs (CM, CD, XC, XL, IX, IV) sitting right next to their non-subtractive neighbours. This table is the whole algorithm.
Line 6 — accumulator. resultstarts empty and only ever grows by appending symbols left to right, so it's already in the correct order.
Line 8 — outer sweep.Iterate the table from the largest value down. Because it's sorted, the first entry whose value fits is guaranteed to be the largest legal symbol — which is exactly what greedy needs.
Lines 9–11 — take what fits. While num >= values[i], append symbols[i] and subtract values[i]. The while (not if) lets a single entry repeat — e.g. 30 emits XXX, 3000 emits MMM.
Line 15 — return. Once the sweep finishes, num is 0 and result holds the full numeral. Having 1 I in the table guarantees the loop always drains num completely.
Complexity → O(1) time and space (beyond the output). The table is a fixed 13 entries and num ≤ 3999, so the total number of appended symbols is bounded by a small constant (≤ 15).

INTERVIEWFollow-ups they'll ask

  • "Why is greedy correct here?" The Roman value system is canonical: each table entry is large enough that taking the biggest fitting value never forces a worse choice later. (For arbitrary coin systems greedy can fail — this one is designed not to.)
  • "How would you do Roman → Integer?"Scan left to right; add each symbol's value, but if a symbol's value is less than the one after it, subtract it instead (handles IV, IX, …).
  • "What about numbers above 3999?"Standard Roman numerals don't cover them; you'd need overlines/vinculum notation or a different convention. State the assumption.
  • "Could you avoid the subtractive pairs in the table?"Yes, but then you'd special-case each 4 and 9 per decimal place — more code and more edge cases. Inlining them keeps the loop branch-free.

OPTIMAL Math

intToRoman.ts
function intToRoman(num: number): string {
  // Table ordered DESC, with subtractive pairs inlined.
  const values  = [1000, 900, 500, 400, 100, 90, 50, 40, 10, 9, 5, 4, 1];
  const symbols = ['M', 'CM', 'D', 'CD', 'C', 'XC', 'L', 'XL', 'X', 'IX', 'V', 'IV', 'I'];

  let result = '';

  for (let i = 0; i < values.length; i++) {
    while (num >= values[i]) {     // largest value that still fits
      result += symbols[i];        // append its symbol
      num -= values[i];            // subtract it off
    }
  }

  return result;
}
Complexity → O(1) time and space (beyond the output). The table is a fixed 13 entries and num ≤ 3999, so the total number of appended symbols is bounded by a small constant (≤ 15).

ALT 1 Per-decimal-place lookup tables

O(1) time · O(1) space

Precompute the Roman string for each digit in each place (ones, tens, hundreds, thousands), then concatenate the four lookups.

approach-2.ts
function intToRoman(num: number): string {
  const thousands = ['', 'M', 'MM', 'MMM'];
  const hundreds  = ['', 'C', 'CC', 'CCC', 'CD', 'D', 'DC', 'DCC', 'DCCC', 'CM'];
  const tens      = ['', 'X', 'XX', 'XXX', 'XL', 'L', 'LX', 'LXX', 'LXXX', 'XC'];
  const ones      = ['', 'I', 'II', 'III', 'IV', 'V', 'VI', 'VII', 'VIII', 'IX'];

  return (
    thousands[Math.floor(num / 1000)] +
    hundreds[Math.floor(num / 100) % 10] +
    tens[Math.floor(num / 10) % 10] +
    ones[num % 10]
  );
}
Note → Marginally faster (no loop), but it bakes in four hand-authored tables — more to write and more places to make a typo. The greedy single-table version is the one most people remember under pressure.

MNEMONIC The one-liner

"Descending table with the subtractive pairs baked in — then greedily take the biggest that fits."

TRIGGERS When you see ___ → reach for ___

integer → Roman numeraldescending value→symbol table + greedy subtract
subtractive forms (IV, IX, XL…)add them as 6 extra table entries
canonical coin system, fewest tokensgreedy take-largest-that-fits
repeat the same symbol (XXX, MMM)inner while, not if

SKELETON The reusable shape

skeleton.ts
function intToRoman(num: number): string {
  const values  = [1000, 900, 500, 400, 100, 90, 50, 40, 10, 9, 5, 4, 1];
  const symbols = ['M', 'CM', 'D', 'CD', 'C', 'XC', 'L', 'XL', 'X', 'IX', 'V', 'IV', 'I'];
  let result = '';
  for (let i = 0; i < values.length; i++) {
    while (num >= values[i]) {
      result += symbols[i];
      num -= values[i];
    }
  }
  return result;
}

FLASHCARDS Tap to flip

What is the single trick that makes Integer→Roman easy?
Put the six subtractive pairs (CM, CD, XC, XL, IX, IV) directly into a descending value→symbol table — then it is plain greedy.
tap to flip
1 / 7
Answer to reveal explanations. No penalty for retries.Score: 0 / 9
QUESTION 01
What is the core trick that removes all special-casing?
QUESTION 02
For num = 1994, what is the output?
QUESTION 03
Why is the inner loop a `while` rather than an `if`?
QUESTION 04
Why is the greedy choice (take the largest value that fits) correct here?
QUESTION 05
What does num = 58 produce?
QUESTION 06
What must be true about the table for the algorithm to work?
QUESTION 07
What is the time complexity?
QUESTION 08
#12 · Integer to RomanConvert an integer to Roman numerals greedily against a value→symbol table that includes the six subtractive pairs (CM, CD, XC, XL, IX, IV): repeatedly subtract the largest value that fits and append its symbol.Which algorithmic approach does this primarily use?
QUESTION 09
#12 · Integer to RomanConvert an integer to Roman numerals greedily against a value→symbol table that includes the six subtractive pairs (CM, CD, XC, XL, IX, IV): repeatedly subtract the largest value that fits and append its symbol.Which implementation correctly solves it?