iT邦幫忙

2025 iThome 鐵人賽

DAY 15
0

經過前幾天的 Roman Numeral Kata 練習,我們已經掌握了將阿拉伯數字轉換為羅馬數字的技巧。今天我們要挑戰反向轉換:將羅馬數字解析回阿拉伯數字 ✨

這個挑戰更加有趣,因為我們需要理解羅馬數字的減法規則,並建立一個穩健的解析器 🎯

旅程回顧 📍

在過去幾天的學習中,我們已經完成了:

  • 基本符號轉換(I, V, X)
  • 擴展到完整範圍(L, C, D, M)
  • 減法規則處理(IV, IX, XL, XC, CD, CM)
  • 千位數和邊界處理(1-3999)

今天要實作反向轉換,讓我們的轉換器具備雙向功能!

今天的目標 🎯

  1. 實作 from_roman() 函數
  2. 處理加法規則(I=1, V=5, X=10 等)
  3. 處理減法規則(IV=4, IX=9, XL=40 等)
  4. 加入輸入驗證
  5. 建立完整的雙向轉換系統

理解羅馬數字的解析規則

在開始實作前,讓我們回顧羅馬數字的解析規則:

基本規則

  • I = 1, V = 5, X = 10, L = 50, C = 100, D = 500, M = 1000
  • 從左到右掃描,累加數值
  • 當小數字在大數字前面時,需要減法(如 IV = 4, IX = 9)

減法規則詳解

特殊減法組合:

  • I 只能放在 V 和 X 前面(IV=4, IX=9)
  • X 只能放在 L 和 C 前面(XL=40, XC=90)
  • C 只能放在 D 和 M 前面(CD=400, CM=900)

解析策略

反向轉換需要處理:

  1. 左到右掃描:逐字符分析
  2. 上下文判斷:根據相鄰字符決定加減法
  3. 規則驗證:確保格式合法性

開始 TDD 循環 🔴🟢🔵

讓我們用 TDD 的方式來實作這個解析器。

Red:第一個測試失敗

建立 tests/day15/test_from_roman_basic.py:

from src.roman.converter import from_roman

def test_single_symbol():
    assert from_roman("I") == 1
    assert from_roman("V") == 5
    assert from_roman("X") == 10

建立 src/roman/converter.py 的 from_roman 函數:

def from_roman(roman):
    if roman == "I":
        return 1
    elif roman == "V":
        return 5
    elif roman == "X":
        return 10

執行測試:

pytest tests/day15/test_from_roman_basic.py -v

Green:讓測試通過

執行測試:

pytest tests/day15/test_from_roman_basic.py -v

測試通過!✅

Refactor:重構成更通用的解決方案

更新 src/roman/converter.py:

def from_roman(roman):
    symbol_values = {
        'I': 1, 'V': 5, 'X': 10, 'L': 50,
        'C': 100, 'D': 500, 'M': 1000
    }
    
    if len(roman) == 1:
        return symbol_values[roman]

第二輪:多個符號相加

Red:新測試失敗

建立 tests/day15/test_from_roman_addition.py:

from src.roman.converter import from_roman

def test_multiple_symbols_addition():
    assert from_roman("II") == 2
    assert from_roman("III") == 3
    assert from_roman("VI") == 6
    assert from_roman("VII") == 7
    assert from_roman("VIII") == 8

更新實作,處理多個符號:

def from_roman(roman):
    symbol_values = {
        'I': 1, 'V': 5, 'X': 10, 'L': 50,
        'C': 100, 'D': 500, 'M': 1000
    }
    
    result = 0
    for char in roman:
        result += symbol_values[char]
    
    return result

Green:讓測試通過

執行測試:

pytest tests/day15/test_from_roman_addition.py -v

測試通過!✅ 基本的相加情況已經處理完成。

第三輪:減法規則處理

Red:減法規則測試

建立 tests/day15/test_from_roman_subtraction.py:

from src.roman.converter import from_roman

def test_subtraction_rules():
    assert from_roman("IV") == 4
    assert from_roman("IX") == 9
    assert from_roman("XL") == 40
    assert from_roman("XC") == 90
    assert from_roman("CD") == 400
    assert from_roman("CM") == 900

現在我們的實作會失敗,因為還沒處理減法規則。讓我們更新實作:

def from_roman(roman):
    symbol_values = {
        'I': 1, 'V': 5, 'X': 10, 'L': 50,
        'C': 100, 'D': 500, 'M': 1000
    }
    
    result = 0
    i = 0
    
    while i < len(roman):
        # 檢查是否需要減法
        if i + 1 < len(roman):
            current_value = symbol_values[roman[i]]
            next_value = symbol_values[roman[i + 1]]
            
            if current_value < next_value:
                # 減法情況
                result += next_value - current_value
                i += 2  # 跳過兩個字符
                continue
        
        # 正常相加
        result += symbol_values[roman[i]]
        i += 1
    
    return result

Green:實作減法邏輯

執行測試確認實作正確:

pytest tests/day15/test_from_roman_subtraction.py -v

測試通過!✅

第四輪:複雜組合測試

Red:複雜組合測試

建立 tests/day15/test_from_roman_complex.py:

from src.roman.converter import from_roman

def test_complex_combinations():
    assert from_roman("XIV") == 14   # 10 + (5 - 1)
    assert from_roman("XIX") == 19   # 10 + (10 - 1)
    assert from_roman("XLIV") == 44  # (50 - 10) + (5 - 1)
    assert from_roman("XCIV") == 94  # (100 - 10) + (5 - 1)
    assert from_roman("CDXLIV") == 444  # (500 - 100) + (50 - 10) + (5 - 1)

Green:驗證複雜組合

執行測試:

pytest tests/day15/test_from_roman_complex.py -v

我們的實作應該已經能處理這些複雜情況了!✅

第五輪:輸入驗證和邊界處理

Red:驗證測試

建立 tests/day15/test_from_roman_validation.py:

import pytest
from src.roman.converter import from_roman

def test_classic_examples():
    assert from_roman("MCMXCIV") == 1994
    assert from_roman("MMMCMXCIX") == 3999

def test_input_validation():
    with pytest.raises(ValueError, match="Empty roman numeral"):
        from_roman("")
    
    with pytest.raises(ValueError, match="Invalid character"):
        from_roman("ABC")
    
    with pytest.raises(ValueError, match="Invalid subtraction"):
        from_roman("IC")  # I 不能在 C 前面

更新實作加入驗證:

def from_roman(roman):
    if not roman:
        raise ValueError("Empty roman numeral")
    
    symbol_values = {
        'I': 1, 'V': 5, 'X': 10, 'L': 50,
        'C': 100, 'D': 500, 'M': 1000
    }
    
    # 驗證字符
    for char in roman:
        if char not in symbol_values:
            raise ValueError(f"Invalid character: {char}")
    
    # 定義合法的減法組合
    valid_subtractions = {'IV', 'IX', 'XL', 'XC', 'CD', 'CM'}
    
    result = 0
    i = 0
    
    while i < len(roman):
        if i + 1 < len(roman):
            current_value = symbol_values[roman[i]]
            next_value = symbol_values[roman[i + 1]]
            
            if current_value < next_value:
                # 檢查是否為合法的減法
                subtraction = roman[i:i+2]
                if subtraction not in valid_subtractions:
                    raise ValueError(f"Invalid subtraction: {subtraction}")
                
                result += next_value - current_value
                i += 2
                continue
        
        result += symbol_values[roman[i]]
        i += 1
    
    return result

Green:完善錯誤處理

執行驗證測試:

pytest tests/day15/test_from_roman_validation.py -v

整合測試:雙向轉換 🔄

最後,我們要確保 to_romanfrom_roman 可以完美配合:

建立 tests/day15/test_roman_bidirectional.py:

from src.roman.converter import to_roman, from_roman

def test_bidirectional_conversion():
    """測試雙向轉換的一致性"""
    test_numbers = [1, 4, 5, 9, 10, 40, 50, 90, 100, 400, 500, 900, 1000, 1994]
    
    for number in test_numbers:
        roman = to_roman(number)
        converted_back = from_roman(roman)
        assert converted_back == number

執行完整測試 🧪

執行所有測試確保實作正確:

# 執行 Day 15 專門測試
pytest tests/day15/ -v

# 執行覆蓋率測試
pytest tests/day15/ --cov=src.roman --cov-report=term-missing

預期輸出:

=================== test session starts ===================
tests/day15/test_from_roman_basic.py::test_single_symbol PASSED
tests/day15/test_from_roman_addition.py::test_multiple_symbols_addition PASSED
tests/day15/test_from_roman_subtraction.py::test_subtraction_rules PASSED
tests/day15/test_from_roman_complex.py::test_complex_combinations PASSED
tests/day15/test_from_roman_validation.py::test_classic_examples PASSED
tests/day15/test_from_roman_validation.py::test_input_validation PASSED
tests/day15/test_roman_bidirectional.py::test_bidirectional_conversion PASSED

=================== 7 passed in 0.05s ===================

Name                   Stmts   Miss  Cover   Missing
------------------------------------------------------
src/roman/__init__.py      0      0   100%
src/roman/converter.py    45      0   100%
------------------------------------------------------
TOTAL                     45      0   100%

性能優化考量 🚀

時間複雜度分析

我們的實作具有良好的性能:

  • 時間複雜度:O(n),其中 n 是羅馬數字字串的長度
  • 空間複雜度:O(1),只使用固定的額外空間

可能的優化方向

  1. 預編譯模式:如果需要大量轉換,可以預先編譯減法規則
  2. 快取結果:對於頻繁轉換的數字,可以使用記憶化
  3. 批次處理:同時處理多個轉換請求

完整實作

完整實作 src/roman/converter.py:

def from_roman(roman: str) -> int:
    """將羅馬數字轉換為阿拉伯數字"""
    if not roman:
        raise ValueError("Empty roman numeral")
    
    symbol_values = {
        'I': 1, 'V': 5, 'X': 10, 'L': 50,
        'C': 100, 'D': 500, 'M': 1000
    }
    
    valid_subtractions = {'IV', 'IX', 'XL', 'XC', 'CD', 'CM'}
    
    # 驗證字符
    for char in roman:
        if char not in symbol_values:
            raise ValueError(f"Invalid character: {char}")
    
    result = 0
    i = 0
    
    while i < len(roman):
        if i + 1 < len(roman):
            current_value = symbol_values[roman[i]]
            next_value = symbol_values[roman[i + 1]]
            
            if current_value < next_value:
                # 檢查是否為合法的減法
                subtraction = roman[i:i+2]
                if subtraction not in valid_subtractions:
                    raise ValueError(f"Invalid subtraction: {subtraction}")
                
                result += next_value - current_value
                i += 2
                continue
        
        result += symbol_values[roman[i]]
        i += 1
    
    return result

今天的收穫 🏆

今天我們完成了羅馬數字的反向轉換功能:

關鍵成就

  • TDD 實踐:從簡單到複雜,逐步實作功能
  • 算法理解:掌握羅馬數字解析和減法規則識別
  • 程式碼品質:完整錯誤處理和邊界測試
  • 系統設計:建立穩健的雙向轉換系統
  • 除錯技能:學會追蹤算法執行和問題排除
  • 性能意識:理解算法複雜度和優化策略

下一步展望

明天我們將繼續完善羅馬數字轉換器,加入更多的測試案例和功能增強,確保我們的實作能夠處理各種邊界情況和實際應用需求。

透過 TDD 的紅綠重構循環,我們成功建立了一個穩健的雙向轉換系統,這種方法確保了程式碼的正確性和可維護性 🚀


上一篇
Day 14 - 完整範圍實作(1-3999) 🏛️
系列文
Python pytest TDD 實戰:從零開始的測試驅動開發15
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言