Roman Numerals: Converting Between Ancient and Modern Number Systems

Roman numerals might seem like ancient history, but they are still encountered in modern contexts—from movie copyright years to Super Bowl numbers, from book chapters to clock faces. As developers, we occasionally need to convert between Roman numerals and integers, and the algorithms involved are more interesting than you might expect.

In this article, we will explore two complementary implementations of converting integers to Roman numerals and converting Roman numerals back to integers. Both directions have their unique challenges and elegant solutions.

Why Roman Numeral Conversion?

Beyond the obvious use cases mentioned above, Roman numeral conversion is an excellent programming exercise because it:

Teaches Pattern Recognition: Roman numerals follow specific rules that require understanding both additive and subtractive notation.

Demonstrates Algorithm Design: The conversion algorithms showcase different approaches—greedy algorithms for integer-to-Roman and state machine patterns for Roman-to-integer.

Handles Edge Cases: From handling invalid input to dealing with the largest valid Roman numeral (MMMCMXCIX = 3999), these implementations must be robust.

Tests String Manipulation: Working with character sequences and building strings efficiently is core to both conversions.

Roman Numeral Basics

Before diving into the code, let us review the fundamentals:

  • I = 1, V = 5, X = 10, L = 50, C = 100, D = 500, M = 1000

Roman numerals typically follow an additive system where symbols are arranged from largest to smallest (e.g., VIII = 8). However, subtractive notation is used for certain combinations to avoid four consecutive identical symbols:

  • IV = 4 (not IIII)
  • IX = 9 (not VIIII)
  • XL = 40 (not XXXX)
  • XC = 90 (not LXXXX)
  • CD = 400 (not CCCC)
  • CM = 900 (not DCCCC)

The valid range for standard Roman numerals is 1 to 3999.

Integer to Roman Numeral Conversion

The algorithm for converting an integer to a Roman numeral uses a greedy approach. We start with the largest possible Roman numeral value and subtract it repeatedly until we cannot anymore, then move to the next smaller value.

The Algorithm

public String intToRoman(int num) {
    // Define value-symbol pairs in descending order, including subtractive cases
    int[] values = {1000, 900, 500, 400, 100, 90, 50, 40, 10, 9, 5, 4, 1};
    String[] symbols = {"M", "CM", "D", "CD", "C", "XC", "L", "XL", "X", "IX", "V", "IV", "I"};
    
    StringBuilder result = new StringBuilder();
    
    // Greedy approach: use largest possible values first
    for (int i = 0; i < values.length; i++) {
        while (num >= values[i]) {
            result.append(symbols[i]);
            num -= values[i];
        }
    }
    
    return result.toString();
}

How It Works

  1. Ordered Mapping: We maintain parallel arrays of values and their corresponding Roman numeral symbols, sorted in descending order. Crucially, we include the subtractive notation pairs (CM, CD, XC, XL, IX, IV) in their proper positions.

  2. Greedy Iteration: For each value-symbol pair, we repeatedly append the symbol to our result and subtract the value from our number as long as the number is greater than or equal to the value.

  3. Automatic Subtractive Handling: Because we include subtractive pairs in our mapping, the algorithm naturally handles cases like 4, 9, 40, 90, 400, and 900 correctly without special logic.

Example Walkthrough

Let us convert 1994 to Roman numerals:

  • Start with 1994
  • 1994 ≥ 1000: append "M", subtract 1000 → 994, result = "M"
  • 994 ≥ 900: append "CM", subtract 900 → 94, result = "MCM"
  • 94 ≥ 90: append "XC", subtract 90 → 4, result = "MCMXC"
  • 4 ≥ 4: append "IV", subtract 4 → 0, result = "MCMXCIV"
  • Done: MCMXCIV

Key Considerations

  • Performance: O(1) time complexity since the number of iterations is bounded by the number of symbols (13), regardless of input size.
  • Space: O(1) space for the algorithm structure, O(log n) for the result string where n is the input number.
  • Validation: The implementation should validate that input is within the valid range (1-3999).

Roman Numeral to Integer Conversion

Converting from Roman numerals to integers requires a different approach. We need to process the string from left to right while understanding when to add or subtract based on the context.

The Algorithm

public int romanToInt(String s) {
    // Map each Roman numeral character to its value
    Map<Character, Integer> romanMap = new HashMap<>();
    romanMap.put('I', 1);
    romanMap.put('V', 5);
    romanMap.put('X', 10);
    romanMap.put('L', 50);
    romanMap.put('C', 100);
    romanMap.put('D', 500);
    romanMap.put('M', 1000);
    
    int result = 0;
    int prevValue = 0;
    
    // Process string from right to left
    for (int i = s.length() - 1; i >= 0; i--) {
        int currentValue = romanMap.get(s.charAt(i));
        
        // If current value is less than previous, we need to subtract (subtractive notation)
        if (currentValue < prevValue) {
            result -= currentValue;
        } else {
            result += currentValue;
        }
        
        prevValue = currentValue;
    }
    
    return result;
}

How It Works

  1. Right-to-Left Processing: By processing the string from right to left, we naturally handle the subtractive notation. If we encounter a smaller value before a larger value, it means we are in a subtractive case.

  2. Simple Decision Rule: At each position, we compare the current value with the previous value (to the right). If current < previous, subtract; otherwise, add.

  3. No Special Cases: This elegant approach handles all subtractive notation (IV, IX, XL, XC, CD, CM) automatically without explicit checks.

Example Walkthrough

Let us convert "MCMXCIV" back to 1994:

  • Start from right: V (5), prevValue = 0 → add 5, result = 5, prevValue = 5
  • I (1) < V (5) → subtract 1, result = 4, prevValue = 1
  • C (100) > I (1) → add 100, result = 104, prevValue = 100
  • X (10) < C (100) → subtract 10, result = 94, prevValue = 10
  • M (1000) > X (10) → add 1000, result = 1094, prevValue = 1000
  • C (100) < M (1000) → subtract 100, result = 994, prevValue = 100
  • M (1000) > C (100) → add 1000, result = 1994, prevValue = 1000
  • Done: 1994

Alternative: Left-to-Right Processing

Some implementations process left-to-right by looking ahead:

public int romanToIntLeftToRight(String s) {
    Map<Character, Integer> romanMap = new HashMap<>();
    romanMap.put('I', 1);
    romanMap.put('V', 5);
    romanMap.put('X', 10);
    romanMap.put('L', 50);
    romanMap.put('C', 100);
    romanMap.put('D', 500);
    romanMap.put('M', 1000);
    
    int result = 0;
    
    for (int i = 0; i < s.length(); i++) {
        int currentValue = romanMap.get(s.charAt(i));
        
        // Look ahead to check for subtractive notation
        if (i + 1 < s.length()) {
            int nextValue = romanMap.get(s.charAt(i + 1));
            if (currentValue < nextValue) {
                result -= currentValue;
                continue;
            }
        }
        
        result += currentValue;
    }
    
    return result;
}

Both approaches work, but the right-to-left version is slightly more elegant as it does not require look-ahead logic.

Key Considerations

  • Performance: O(n) time complexity where n is the length of the Roman numeral string.
  • Space: O(1) space for the algorithm (the map is constant size).
  • Validation: Should validate that input contains only valid Roman numeral characters and follows proper Roman numeral rules.

Common Edge Cases and Validation

Robust implementations should handle:

Input Validation

public boolean isValidRoman(String s) {
    if (s == null || s.isEmpty()) {
        return false;
    }
    
    // Check for valid characters
    if (!s.matches("^[IVXLCDM]+$")) {
        return false;
    }
    
    // Additional checks for proper Roman numeral formation:
    // - No more than 3 consecutive I, X, C, or M
    // - No more than 1 consecutive V, L, or D
    // - Proper subtractive notation (IV not IL, IX not IC, etc.)
    
    return true;
}

Range Validation

For integer-to-Roman conversion:

public String intToRomanWithValidation(int num) {
    if (num < 1 || num > 3999) {
        throw new IllegalArgumentException(
            "Number must be between 1 and 3999. Got: " + num
        );
    }
    return intToRoman(num);
}

Example Test Cases

Both implementations should correctly handle:

  • Single characters: I → 1, V → 5, X → 10, etc.
  • Additive cases: III → 3, VIII → 8, XXIII → 23
  • Subtractive cases: IV → 4, IX → 9, XL → 40, XC → 90, CD → 400, CM → 900
  • Complex numbers: MCMXCIV → 1994, MMMCMXCIX → 3999
  • Edge cases: I (1), MMMCMXCIX (3999)

Real-World Applications

These implementations are useful in:

  • Date Formatting: Converting years to Roman numerals for copyright notices
  • Document Processing: Parsing outlines and chapter numbers
  • Cultural Applications: Converting dates for formal invitations or monuments
  • Educational Tools: Teaching number systems and algorithm design
  • API Services: Providing conversion utilities in web applications

Performance Characteristics

Both algorithms are highly efficient:

  • Integer to Roman: Constant time O(1) for the conversion logic since the maximum number of symbols is fixed
  • Roman to Integer: Linear time O(n) where n is the length of the input string, typically very small (max 15 characters for 3999)
  • Memory: Both use O(1) auxiliary space

For a web API serving thousands of requests per second, these implementations will not be the bottleneck.

Conclusion

Roman numeral conversion is a perfect example of how understanding the domain rules leads to elegant algorithmic solutions. The integer-to-Roman conversion demonstrates the power of greedy algorithms with properly ordered mappings, while Roman-to-integer conversion shows how right-to-left processing naturally handles complex notation rules.

These implementations serve both practical purposes in modern applications and educational value in understanding algorithm design. Whether you are processing historical documents, formatting dates, or just exploring number systems, these bidirectional conversion functions provide a solid foundation.

Happy coding!