โ† Back to DevBytes

Solving Roman to Integer in Python: Step-by-Step Guide

Introduction to Roman to Integer Conversion

The Roman to Integer problem is one of the most popular algorithmic challenges on platforms like LeetCode, and it frequently appears in coding interviews. The task is straightforward: given a string representing a Roman numeral, convert it into its corresponding integer value. While the problem sounds simple, it teaches fundamental concepts about string parsing, hash maps, and algorithmic thinking that every developer should master.

Roman numerals are represented by seven different symbols: I, V, X, L, C, D, and M. Each symbol corresponds to a specific value, and the numerals are typically written from largest to smallest value, moving left to right. However, there's a twist โ€” certain combinations use subtractive notation, which makes the problem more interesting.

Understanding Roman Numerals

Before diving into code, it's essential to understand how Roman numerals work. Here's the mapping of symbols to their integer values:

Generally, numerals are additive. For example, II equals 2 (1 + 1), and VII equals 7 (5 + 1 + 1). However, Roman numerals also use subtractive notation for specific cases to avoid four identical characters in a row. Instead of writing IIII for 4, the Romans wrote IV, meaning 5 minus 1.

There are six instances where subtraction is used:

For example, MCMXCIV translates to 1994: M (1000) + CM (900) + XC (90) + IV (4).

Why This Problem Matters

You might wonder why converting Roman numerals to integers is relevant in modern software development. The truth is, this problem tests several critical skills that translate directly to real-world programming tasks.

First, it evaluates your ability to work with string manipulation and character-by-character parsing โ€” a common requirement when processing text data, configuration files, or custom formats. Second, it tests your understanding of hash maps (dictionaries in Python), which are among the most frequently used data structures in production code. Finally, it challenges you to recognize patterns and edge cases, a skill that's invaluable when building robust, bug-free applications.

Beyond interviews, you might encounter Roman numerals in publishing (page numbers, chapter numbers), copyright dates, clock faces, and even in naming conventions for sequels of movies or software versions. Having a reliable conversion utility in your toolkit can save time and prevent errors.

Approach 1: The Left-to-Right Method

The first approach involves iterating through the string from left to right. The key insight is that if a symbol's value is less than the next symbol's value, we subtract it; otherwise, we add it. This handles the subtractive notation naturally without needing special cases.

Step-by-Step Algorithm

Here's how the left-to-right method works:

Implementation

def roman_to_integer(s: str) -> int:
    """
    Convert a Roman numeral string to an integer.
    
    Args:
        s: A string representing a valid Roman numeral
        
    Returns:
        The integer value of the Roman numeral
    """
    # Mapping of Roman symbols to their integer values
    roman_map = {
        'I': 1,
        'V': 5,
        'X': 10,
        'L': 50,
        'C': 100,
        'D': 500,
        'M': 1000
    }
    
    total = 0
    n = len(s)
    
    for i in range(n):
        # If current value is less than next value, subtract it
        if i + 1 < n and roman_map[s[i]] < roman_map[s[i + 1]]:
            total -= roman_map[s[i]]
        else:
            total += roman_map[s[i]]
    
    return total


# Test cases
print(roman_to_integer("III"))      # Output: 3
print(roman_to_integer("IV"))       # Output: 4
print(roman_to_integer("IX"))       # Output: 9
print(roman_to_integer("LVIII"))    # Output: 58
print(roman_to_integer("MCMXCIV"))  # Output: 1994

Let's trace through the example MCMXCIV to understand how this works:

The algorithm correctly produces 1994. This approach runs in O(n) time complexity, where n is the length of the string, and uses O(1) extra space since the dictionary has a fixed size.

Approach 2: The Right-to-Left Method

An alternative approach iterates from right to left. This method maintains a running total and keeps track of the previous value. If the current value is less than the previous value, we subtract it; otherwise, we add it. This approach can feel more intuitive because it mirrors how we naturally read Roman numerals โ€” accumulating value as we go.

Implementation

def roman_to_integer_reverse(s: str) -> int:
    """
    Convert a Roman numeral string to an integer using right-to-left traversal.
    
    Args:
        s: A string representing a valid Roman numeral
        
    Returns:
        The integer value of the Roman numeral
    """
    roman_map = {
        'I': 1,
        'V': 5,
        'X': 10,
        'L': 50,
        'C': 100,
        'D': 500,
        'M': 1000
    }
    
    total = 0
    prev_value = 0
    
    # Traverse from right to left
    for char in reversed(s):
        current_value = roman_map[char]
        
        if current_value < prev_value:
            total -= current_value
        else:
            total += current_value
        
        prev_value = current_value
    
    return total


# Test cases
print(roman_to_integer_reverse("III"))      # Output: 3
print(roman_to_integer_reverse("IV"))       # Output: 4
print(roman_to_integer_reverse("MCMXCIV"))  # Output: 1994

Tracing MCMXCIV from right to left: V (5, add, total=5), I (1, less than 5, subtract, total=4), C (100, greater than 1, add, total=104), X (10, less than 100, subtract, total=94), M (1000, greater than 10, add, total=1094), C (100, less than 1000, subtract, total=994), M (1000, greater than 100, add, total=1994). The result is again 1994.

Approach 3: Using Replace for Subtractive Pairs

A third approach handles subtractive notation explicitly by replacing two-character combinations with their single equivalent before summing. This is less efficient but demonstrates a creative problem-solving angle that some developers find easier to reason about.

def roman_to_integer_replace(s: str) -> int:
    """
    Convert a Roman numeral to integer by replacing subtractive pairs first.
    
    Args:
        s: A string representing a valid Roman numeral
        
    Returns:
        The integer value of the Roman numeral
    """
    # Replace subtractive combinations with additive equivalents
    s = s.replace("IV", "IIII")
    s = s.replace("IX", "VIIII")
    s = s.replace("XL", "XXXX")
    s = s.replace("XC", "LXXXX")
    s = s.replace("CD", "CCCC")
    s = s.replace("CM", "DCCCC")
    
    roman_map = {
        'I': 1,
        'V': 5,
        'X': 10,
        'L': 50,
        'C': 100,
        'D': 500,
        'M': 1000
    }
    
    return sum(roman_map[char] for char in s)


# Test cases
print(roman_to_integer_replace("IV"))       # Output: 4
print(roman_to_integer_replace("MCMXCIV"))  # Output: 1994
print(roman_to_integer_replace("MMXXIV"))   # Output: 2024

While this approach is elegant in its simplicity, it creates new strings with each replace call, making it less efficient for very long inputs. However, since Roman numerals are inherently short (the longest valid numeral under 4000 is relatively compact), this performance difference is negligible in practice.

Adding Input Validation

In production code, you should never assume input is valid. Let's build a more robust version that validates the input string before performing the conversion. This is a best practice that separates toy code from production-ready code.

def roman_to_integer_validated(s: str) -> int:
    """
    Convert a Roman numeral to an integer with input validation.
    
    Args:
        s: A string representing a Roman numeral
        
    Returns:
        The integer value of the Roman numeral
        
    Raises:
        ValueError: If the input is empty or contains invalid characters
    """
    if not s:
        raise ValueError("Input string cannot be empty")
    
    roman_map = {
        'I': 1,
        'V': 5,
        'X': 10,
        'L': 50,
        'C': 100,
        'D': 500,
        'M': 1000
    }
    
    # Validate all characters are valid Roman symbols
    for char in s:
        if char not in roman_map:
            raise ValueError(f"Invalid Roman numeral character: '{char}'")
    
    total = 0
    n = len(s)
    
    for i in range(n):
        if i + 1 < n and roman_map[s[i]] < roman_map[s[i + 1]]:
            total -= roman_map[s[i]]
        else:
            total += roman_map[s[i]]
    
    return total


# Example with error handling
try:
    result = roman_to_integer_validated("MCMXCIV")
    print(f"Result: {result}")  # Output: Result: 1994
except ValueError as e:
    print(f"Error: {e}")

try:
    result = roman_to_integer_validated("ABC")
except ValueError as e:
    print(f"Error: {e}")  # Output: Error: Invalid Roman numeral character: 'A'

Best Practices

When implementing a Roman to Integer converter, keep these best practices in mind:

Optimized Version with Module-Level Constant

# Define the mapping once at module level
ROMAN_MAP = {
    'I': 1,
    'V': 5,
    'X': 10,
    'L': 50,
    'C': 100,
    'D': 500,
    'M': 1000
}


def roman_to_integer_optimized(s: str) -> int:
    """
    Optimized Roman to Integer conversion using a module-level constant.
    
    Args:
        s: A string representing a valid Roman numeral
        
    Returns:
        The integer value of the Roman numeral
    """
    if not s or any(c not in ROMAN_MAP for c in s):
        raise ValueError("Invalid Roman numeral")
    
    total = 0
    prev = 0
    
    for char in reversed(s):
        value = ROMAN_MAP[char]
        if value < prev:
            total -= value
        else:
            total += value
        prev = value
    
    return total


# Comprehensive test suite
test_cases = [
    ("I", 1),
    ("III", 3),
    ("IV", 4),
    ("V", 5),
    ("IX", 9),
    ("X", 10),
    ("XL", 40),
    ("L", 50),
    ("XC", 90),
    ("C", 100),
    ("CD", 400),
    ("D", 500),
    ("CM", 900),
    ("M", 1000),
    ("LVIII", 58),
    ("MCMXCIV", 1994),
    ("MMXXIV", 2024),
    ("MMMCMXCIX", 3999),
]

for numeral, expected in test_cases:
    result = roman_to_integer_optimized(numeral)
    status = "PASS" if result == expected else "FAIL"
    print(f"{status}: {numeral} -> {result} (expected {expected})")

Conclusion

The Roman to Integer problem is an excellent exercise that combines string manipulation, hash map usage, and algorithmic reasoning. Whether you choose the left-to-right, right-to-left, or replace-based approach, the key is understanding the subtractive notation rule that defines Roman numerals. By following the implementations and best practices outlined in this guide, you'll have a robust, well-tested solution that handles edge cases gracefully. More importantly, the patterns you learn here โ€” dictionary lookups, directional traversal, and input validation โ€” will serve you well across countless other programming challenges you'll encounter throughout your career.

๐Ÿ›  Tools from DevBytes

Inventory Tracker Pro โ€” Excel inventory system, low-stock alerts ยท $19
AI Dev Kit for Mac โ€” local AI dev environment templates ยท $9.99
KeyMapper for Mac โ€” custom keyboard shortcut toolkit ยท $7.99

โ† Back to all articles