Introduction to Roman to Integer Conversion
The Roman to Integer problem is one of the most popular algorithmic challenges on platforms like LeetCode. It asks you to convert a string representing a Roman numeral into its corresponding integer value. While Roman numerals may seem like an ancient relic, this problem is an excellent exercise in string parsing, conditional logic, and understanding how positional value systems work.
In this tutorial, we will walk through solving the Roman to Integer problem using the Go programming language. We will cover the rules of Roman numerals, break down the algorithm step by step, implement a clean solution, and discuss best practices along the way.
Understanding Roman Numerals
Before writing any code, it is essential to understand how Roman numerals work. Roman numerals are represented by seven different symbols, each with a fixed integer value:
I= 1V= 5X= 10L= 50C= 100D= 500M= 1000
Normally, Roman numerals are written from largest to smallest value, moving left to right. For example, LVIII equals 58 because L (50) + V (5) + III (3) = 58.
However, there is a special rule: when a smaller value appears before a larger value, the smaller value is subtracted rather than added. This is called subtractive notation. There are six valid instances of this rule:
IbeforeV(4) andX(9)XbeforeL(40) andC(90)CbeforeD(400) andM(900)
For example, IX equals 9, not 11, because I comes before X and is therefore subtracted. Similarly, MCMXCIV equals 1994: M (1000) + CM (900) + XC (90) + IV (4).
Why This Problem Matters
The Roman to Integer problem is more than just a trivia exercise. It teaches several fundamental programming concepts:
- String traversal: You must iterate through characters and make decisions based on their values.
- Lookup tables: Mapping symbols to values is a common pattern in many real-world applications.
- Conditional logic: The subtractive rule requires you to compare adjacent values and adjust your logic accordingly.
- Edge case handling: You need to consider single-character strings, maximum-length strings, and invalid inputs.
These skills transfer directly to real-world tasks such as parsing configuration files, processing tokens, and validating user input.
Designing the Algorithm
The most elegant approach to solving this problem is to iterate through the string from left to right, comparing each symbol with the next one. If the current symbol's value is less than the next symbol's value, we subtract the current value from the total. Otherwise, we add it.
Here is the step-by-step logic:
- Create a map that associates each Roman numeral character with its integer value.
- Initialize a running total to zero.
- Loop through the string from index 0 to the second-to-last character.
- For each character, compare its value with the value of the next character.
- If the current value is less than the next value, subtract the current value from the total.
- Otherwise, add the current value to the total.
- After the loop, add the value of the last character to the total.
- Return the total.
This approach works because the subtractive rule only ever involves two adjacent characters. By always looking one character ahead, we can determine whether the current character represents addition or subtraction.
Implementing the Solution in Go
Now let us translate this algorithm into Go code. We will create a function called romanToInt that takes a string and returns an integer.
package main
import "fmt"
func romanToInt(s string) int {
// Map each Roman numeral symbol to its integer value
values := map[byte]int{
'I': 1,
'V': 5,
'X': 10,
'L': 50,
'C': 100,
'D': 500,
'M': 1000,
}
total := 0
n := len(s)
for i := 0; i < n; i++ {
// If the current value is less than the next value, subtract it
if i+1 < n && values[s[i]] < values[s[i+1]] {
total -= values[s[i]]
} else {
// Otherwise, add it
total += values[s[i]]
}
}
return total
}
func main() {
testCases := []string{"III", "LVIII", "MCMXCIV", "IX", "IV", "MMXXIV"}
for _, tc := range testCases {
fmt.Printf("%s -> %d\n", tc, romanToInt(tc))
}
}
When you run this program, you should see the following output:
III -> 3
LVIII -> 58
MCMXCIV -> 1994
IX -> 9
IV -> 4
MMXXIV -> 2024
Let us trace through the example MCMXCIV to verify the logic:
- Index 0:
M(1000). Next isC(100). 1000 is not less than 100, so add 1000. Total = 1000. - Index 1:
C(100). Next isM(1000). 100 is less than 1000, so subtract 100. Total = 900. - Index 2:
M(1000). Next isX(10). 1000 is not less than 10, so add 1000. Total = 1900. - Index 3:
X(10). Next isC(100). 10 is less than 100, so subtract 10. Total = 1890. - Index 4:
C(100). Next isI(1). 100 is not less than 1, so add 100. Total = 1990. - Index 5:
I(1). Next isV(5). 1 is less than 5, so subtract 1. Total = 1989. - Index 6:
V(5). No next character, so add 5. Total = 1994.
The final result is 1994, which is correct.
Handling Edge Cases and Input Validation
The basic solution assumes the input is a valid Roman numeral string. In production code, you should validate the input to handle edge cases gracefully. Let us enhance our function with validation:
package main
import (
"errors"
"fmt"
)
var ErrInvalidRomanNumeral = errors.New("invalid Roman numeral")
func romanToIntValidated(s string) (int, error) {
if len(s) == 0 {
return 0, ErrInvalidRomanNumeral
}
values := map[byte]int{
'I': 1,
'V': 5,
'X': 10,
'L': 50,
'C': 100,
'D': 500,
'M': 1000,
}
total := 0
n := len(s)
for i := 0; i < n; i++ {
current, ok := values[s[i]]
if !ok {
return 0, fmt.Errorf("%w: invalid character '%c'", ErrInvalidRomanNumeral, s[i])
}
if i+1 < n {
next, ok := values[s[i+1]]
if !ok {
return 0, fmt.Errorf("%w: invalid character '%c'", ErrInvalidRomanNumeral, s[i+1])
}
if current < next {
total -= current
} else {
total += current
}
} else {
total += current
}
}
return total, nil
}
func main() {
inputs := []string{"III", "ABC", "", "XLII"}
for _, input := range inputs {
result, err := romanToIntValidated(input)
if err != nil {
fmt.Printf("Error for %q: %v\n", input, err)
} else {
fmt.Printf("%q -> %d\n", input, result)
}
}
}
This version returns an error if the input is empty or contains characters that are not valid Roman numeral symbols. Using Go's error wrapping with %w allows callers to check for specific error types while preserving context.
Alternative Approach: Right-to-Left Traversal
Another common approach is to traverse the string from right to left. In this method, you keep track of the previous value. If the current value is less than the previous value, you subtract it; otherwise, you add it. This approach can feel more intuitive to some developers:
func romanToIntReverse(s string) int {
values := map[byte]int{
'I': 1,
'V': 5,
'X': 10,
'L': 50,
'C': 100,
'D': 500,
'M': 1000,
}
total := 0
prev := 0
for i := len(s) - 1; i >= 0; i-- {
current := values[s[i]]
if current < prev {
total -= current
} else {
total += current
}
prev = current
}
return total
}
Both approaches have the same time complexity of O(n) and space complexity of O(1), so the choice between them is largely a matter of personal preference and readability.
Best Practices
Use Maps for Constant-Time Lookups
Using a Go map to store symbol-value pairs provides O(1) lookup time. This is cleaner and more maintainable than a long chain of switch or if statements. If you ever need to support additional symbols, you only need to update the map.
Validate Input Early
Always check for empty strings and invalid characters before processing. Failing fast prevents subtle bugs and makes debugging easier. In Go, returning errors rather than panicking is the idiomatic way to handle invalid input.
Keep Functions Focused
Your romanToInt function should do one thing: convert a Roman numeral to an integer. Avoid mixing parsing logic with I/O operations like printing or logging. This makes the function easier to test and reuse.
Write Table-Driven Tests
Go's testing package is well-suited for table-driven tests. Here is an example test file:
package main
import "testing"
func TestRomanToInt(t *testing.T) {
tests := []struct {
input string
expected int
}{
{"III", 3},
{"IV", 4},
{"IX", 9},
{"LVIII", 58},
{"MCMXCIV", 1994},
{"MMXXIV", 2024},
{"I", 1},
{"MMMCMXCIX", 3999},
}
for _, tt := range tests {
result := romanToInt(tt.input)
if result != tt.expected {
t.Errorf("romanToInt(%q) = %d; expected %d", tt.input, result, tt.expected)
}
}
}
Table-driven tests make it easy to add new test cases and clearly document the expected behavior of your function.
Avoid Premature Optimization
The straightforward O(n) solution is already optimal for this problem. Do not waste time trying to micro-optimize the loop or replace the map with a switch statement unless profiling shows a real bottleneck. Clarity should always come first.
Conclusion
Solving the Roman to Integer problem in Go is a great way to practice string manipulation, map lookups, and conditional logic. By understanding the subtractive notation rule and implementing a simple left-to-right traversal, you can build a clean and efficient solution. Remember to validate your input, write comprehensive tests, and keep your code readable. The patterns you learn here โ lookup tables, adjacent comparisons, and error handling โ will serve you well across countless other programming challenges.