Регулярное выражение для проверки римских чисел (на Python)

Многим в детстве нравились приключенческие игры с головоломками и загадками. Найти решение было личным вызовом, а его поиски напоминали охоту за сокровищами. И когда, наконец, загадка была разгадана, ощущения были потрясающими!

Башенные часы, цифра "4" записана как IIII, а не IV
Повсеместно записывать число «четыре» как «IV» стали только в XIX веке, до этого наиболее часто употреблялась запись «IIII». 

Регулярные выражения могут помочь ощутить тот же детский восторг. (Регулярные выражения на английском языке — regular expressions, сокращенно — regex, — прим. перев.). Их использование открывает множество возможностей. К тому же, когда читаешь регулярное выражение и понимаешь, что оно означает, — это как расшифровать египетский иероглиф, как внезапно обнаружить, что знаешь чужой язык!

На Codewars есть задачка: нужно написать функцию для конвертации римских чисел в арабские. Мы не будем разбирать решение этой задачи целиком. Вместо этого сосредоточимся на одном этапе: проверке, ввел ли пользователь валидное римское число.

Как формируются римские числа

Римские числа записываются римскими цифрами, которые вы видите в табличке:

Тысячи СотниДесяткиЕдиницы
1MCXI
2MMCCXXII
3MMMCCCXXXIII
4
CDLXIV
5
DLV
6
DCLXVI
7
DCCLXXVII
8
DCCCLXXXVIII
9
CMXCIX

Кажется, цифры для обозначения тысяч заканчиваются на [MMM], а значит, самое большое число, которое можно написать римскими цифрами, это 3999 ([MMMCMXCIX]). Для целей этой статьи и решения нашей задачи будем считать, что римские числа укладываются в диапазон от 1 до 3999. (На самом деле есть и большие числа, подробнее об этом можно почитать в Википедии, — прим. перев.).

Что нужно понимать, так это важность порядка в расположении римских цифр. Если вы поставите их не в том порядке, вы напишете неправильное или вообще нечитаемое число. Как было показано в таблице выше, число начинается с тысяч [M] 1000, затем идут сотни [D/C] 500/100, затем десятки [L/X] 50/10 и, наконец, единицы [V/I] 5/1.

Но это еще далеко не все! Цифры могут повторяться не больше трех раз подряд (например, 300 = CCC), а потом заменяются комбинацией двух цифр (400 = CD, а не CCCC). Дальше вы можете продолжать использовать C, но уже перед M, как в числе 900 (CM).

Не так-то все просто, верно?

Мем про римские цифры. Учитель: "Как записать 4000 римскими цифрами?" Я: "Мммм..." Учитель: "Отлично, мой мальчик!"

Ладно, давайте повторим наши условия

  • Римские числа — это числа в диапазоне от [I] 1 до [MMMCMXCIX] 3999
  • Цифры в числе располагаются в строго определенном порядке: [M] 1000 / [D] 500 / [C] 100 / [L] 50 / [X] 10 / [V] 5 / [I] 1
  • Любая отдельная цифра не может повторяться больше трех раз подряд, на четвертый раз используется уже пара цифр
  • Пары цифр бывают следующие: [CM] 900 / [CD] 400 / [XC] 90 / [XL] 40 / [IX] 9 / [IV] 4

Вы замечаете, как начинает вырисовываться регулярное выражение?

Давайте переведем это в код

Мы будем использовать один очень полезный при написании регулярных выражений тег, — VERBOSE или re.X). Он позволяет разделять паттерн на несколько строк, чтобы сделать его более читаемым. Давайте попробуем!

import re

def is_roman_number(num):

    pattern = re.compile(r"""   
                                ^M{0,3}
                                (CM|CD|D?C{0,3})?
                                (XC|XL|L?X{0,3})?
                                (IX|IV|V?I{0,3})?$
            """, re.VERBOSE)

    if re.match(pattern, num):
        return True

    return False

Вах! Это уже потрясающе выглядит! Рассмотрим строки паттерна подробнее:

  • ^M{0,3} = От 0 до 3 символов [M] в начале строки [^]
  • (CM|CD|D?C{0,3})? = Одна пара [CM] или одна пара [CD], или [D], за которой идет от 0 до 3 символов [C]. Каждый элемент является опциональным [?], так же, как и весь блок [()?]
  • (XC|XL|L?X{0,3})? = Одна пара [XC] или одна пара [XL] или [L], за которой идет от 0 до 3 символов [X]. Каждый элемент является опциональным [?], так же, как и весь блок [()?]
  • (IX|IV|V?I{0,3})?$ = Одна пара [IX] или одна пара [IV] или [V], за которой идет от 0 до 3 символов [I]. Каждый элемент является опциональным [?], так же, как и весь блок [()?], стоящий в конце строки [$]

Протестируем наш код

Используем fstring для вызова функции и сравнения строки с паттерном:

num_valid = 'MMDCCLXXIII'
num_invalid = 'CCCMMVIIVV'

print(f"{num_valid} - это {'не ' if not is_roman_number(num_valid) else ''}римское число")
print(f"{num_invalid} - это {'не ' if not is_roman_number(num_invalid) else ''}римское число")

# Вывод:
# MMDCCLXXIII - это римское число
# CCCMMVIIVV - это не римское число

Не так уж и плохо! А теперь посмотрите на это и попробуйте сказать, что это не самая прекрасная вещь, которую вы видели в жизни!

^M{0,3}(CM|CD|D?C{0,3})?(XC|XL|L?X{0,3})?(IX|IV|V?I{0,3})?$'