Roman to Integer
ハッシュテーブル1パス走査による O(n) 実装
💡 一言で言うと:「ローマ数字の文字列を読み取り、対応する整数に変換する問題」
アルゴリズム概要
💡 この問題を一言で言うと:「ローマ数字の文字列を読み取り、対応する整数に変換する問題」です。
ローマ数字は基本的に大きい記号から小さい記号へ左から右に並べますが、IV(4)や
IX(9)のように、小さい記号を大きい記号の前に置いて引き算を表す例外パターンが6つあります。
この問題では、その例外パターンを見落とさずに正しく数値へ変換することがポイントになります。
⚠️ なぜ単純な方法では解けないのか
-
すべての文字の値をそのまま足し算するだけでは、
IVが「1+5=6」になってしまい、正しい答え「4」にならない。 - 「今見ている文字」だけでは、足すべきか引くべきかを判断できず、「次に来る文字」との大小比較が必要になる。
入力: s = "III" 出力: 3
同じ記号Iが3つ並んでいるだけなので、単純にI+I+I=3となる。
入力: s = "LVIII" 出力: 58
L=50, V=5, III=3で、いずれも減算パターンを含まないため単純な足し算で58になる。
入力: s = "MCMXCIV" 出力: 1994
M=1000, CM=900, XC=90, IV=4で、CM・XC・IVの3つの減算パターンをすべて含む代表的な例。
Python実装
📋 このコードの構造(先に全体像を把握しよう)
- 入力が文字列であり、空でなく、有効な文字のみで構成されているかを検証する
- ループの外で対応表への参照をローカル変数に束縛し、合計値totalを0で初期化する
- 文字列を先頭から1文字ずつ走査し、次の文字と比較しながらtotalを更新する
- 走査が終わったらtotalをそのまま返す
from __future__ import annotations
from typing import Dict, Final, Optional
class Solution:
"""
Roman to Integer (LeetCode 13) を解決するクラス。
ローマ数字を表す文字列を受け取り、対応する整数値を返す。
"""
# クラス変数として文字→数値の対応表を保持する。
# インスタンスごとに作り直す必要がないデータなので、
# __init__の中ではなくクラス変数として持たせることでメモリを節約できる。
_ROMAN_VALUES: Final[Dict[str, int]] = {
"I": 1,
"V": 5,
"X": 10,
"L": 50,
"C": 100,
"D": 500,
"M": 1000,
}
def romanToInt(self, s: str) -> int:
"""
ローマ数字の文字列を整数に変換する。
Args:
s: 変換対象のローマ数字文字列(例: "MCMXCIV")。
Returns:
変換後の整数値(例: 1994)。
Raises:
TypeError: 入力が文字列でない場合。
ValueError: 入力が空、または未知の文字を含む場合。
"""
# 入力検証(防御的プログラミング:制約上は保証されているが、
# 誤った呼び出しをされても安全に動作するようにする)
self._validate(s)
# ループの外で対応表への参照をローカル変数に束縛しておく。
# self._ROMAN_VALUES というアクセスはPythonの内部で属性検索を
# 伴うため、ループの外で1回だけ済ませておくと検索コストを省ける。
values = self._ROMAN_VALUES
total = 0
length = len(s)
# 左から右へ1文字ずつ走査する
for i in range(length):
current = values[s[i]]
# 次の文字が存在する場合のみその値を取得し、
# 存在しない場合はNoneとして扱う。
next_value: Optional[int] = values[s[i + 1]] if i + 1 < length else None
# 現在値が次の値より小さい場合は「引き算ルール」
# (IV, IX, XL, XC, CD, CM の6パターン)に該当する。
if next_value is not None and current < next_value:
total -= current
else:
total += current
return total
def _validate(self, data: object) -> None:
"""型安全な入力検証を行う"""
if not isinstance(data, str):
raise TypeError("Input must be a string")
if len(data) == 0:
raise ValueError("Input cannot be empty")
# all() + ジェネレータ式で全文字の妥当性チェックを1行で行う。
if not all(char in self._ROMAN_VALUES for char in data):
raise ValueError("Input contains invalid roman numeral characters")
▶ 入力例 "MCMXCIV" での動作トレース
i=0: current='M'(1000), next='C'(100) → 1000 < 100 は偽 → 加算 → total=1000 i=1: current='C'(100), next='M'(1000) → 100 < 1000 は真 → 減算 → total=900 i=2: current='M'(1000), next='X'(10) → 1000 < 10 は偽 → 加算 → total=1900 i=3: current='X'(10), next='C'(100) → 10 < 100 は真 → 減算 → total=1890 i=4: current='C'(100), next='I'(1) → 100 < 1 は偽 → 加算 → total=1990 i=5: current='I'(1), next='V'(5) → 1 < 5 は真 → 減算 → total=1989 i=6: current='V'(5), next=なし → 無条件で加算 → total=1994 出力: 1994 ✅
計算量分析
📖 Big-O 記法の読み方
常に一定の速さ
処理も約2倍
処理は約2倍強
処理は約4倍
| アプローチ | 時間計算量 | 空間計算量 | 備考 |
|---|---|---|---|
| ハッシュテーブル1パス走査(採用) | O(n) | O(1) | 文字列を1回だけ走査。対応表は固定サイズ(7種類)でメモリは入力サイズに依存しない |
| 2文字パターンの事前置換 | O(n) | O(n) | CM・XC・IV等を先に置換してから加算。新しい文字列を都度生成するため余分なメモリを消費する |
🔍 なぜこの計算量になるのか
時間計算量が O(n) になるのは、文字列の各文字をちょうど1回ずつしか見ないためです。次の文字を先読みする処理も、 ループの中でインデックスを1つずらして参照するだけなので、追加のループを必要としません。 空間計算量が O(1) になるのは、文字→数値の対応表が常に7種類固定であり、入力文字列がどれだけ長くなっても (制約上は最大15文字ですが)対応表のサイズや追加変数の数は変わらないためです。
📖 用語集
このページで登場した専門用語をまとめました。分からない言葉が出てきたときに参照してください(五十音順)。
▶ インデックス
▶ 型ヒント
def f(x: int) -> str:のように書く。pylanceのような型チェッカーが、実行前に型の誤りを検出できるようになる。
▶ 計算量
▶ 走査
▶ ハッシュテーブル
dictがこれに相当する。