Откройте почти любую реализацию перевода чисел из арабской системы в римскую и вы почти со 100% вероятностью увидите там знаменитые дифтонги «CM» (900), «CD» (400) и так далее. И поначалу кажется, что без них не обойтись. Но это не так!
На самом деле, без них можно обойтись и за этим процессом стоит достаточно простая, красивая математика. О чем я хочу рассказать сегодня.
Немного правил и объяснений
Если в какой-то момент осознать, что уникальных чисел в римской нумерации всего 3, то все становится немного проще:
-
«I»
-
«V»
-
«X»
Все остальное получается их комбинацией и домножением на по правилу off by one. Например, «CM» это всего лишь «IX»
, то есть умножение происходит на ту степень, в какой позиции мы ожидаем это число увидеть в арабской записи. Причем, поскольку запись «IX» равнозначна «10 — 1», то при умножении эти скобки можно раскрыть: «I»
«C», «X»
«M» и составить в той же последовательности.
Это крайне простой трюк, но в этом и сложность – увидеть эту закономерность и понять, как её реализовать.
Взглянем на код
Сначала определим наши константы чисел в обеих нумерациях
const integers = [1000, 500, 100, 50, 10, 5, 1] const literals = 'mdclxvi' const arabicToRomanMap = new Map(integers.map((int, index) => [int, literals[index]]))
А затем реализуем конвертер
function toRoman(value: number): string { if (!Number.isInteger(value)) throw new Error(`Expected integer, got ${value}`) if (value <= 0 || value >= 5000) throw new Error(`Expect value between 0 and 5000 exclusive, got ${value}`) if (arabicToRomanMap.has(value)) return arabicToRomanMap.get(value)! return value .toString() .split('') .map(Number) .map(processArabicDigit) .map((digit, index, array) => processRomanDigit(digit, array.length - index)) .join('') }
Разберемся блочно, что вообще тут происходит.
value .toString() .split('') .map(Number)
Этот код просто превращает число в массив цифр: 4956 становится [4, 9, 5, 6]. А дальше начинается магия:)
Посмотрим на реализацию функции processArabicDigit :
const processArabicDigit = (digit: number) => { if (digit === 0) return '' if (arabicToRomanMap.has(digit)) return arabicToRomanMap.get(digit)! const foundLessByOne = integers.find(integer => integer - digit === 1) if (foundLessByOne !== undefined) return `i${arabicToRomanMap.get(foundLessByOne)}` return integers.reduce((accumulator, integer) => { while (digit >= integer) { accumulator += arabicToRomanMap.get(integer) digit -= integer } return accumulator }, '') }
Основная цель этой функции, превратить любую арабскую цифру в последовательность базовых цифр римской нумерации: «I», «V», «X».
-
0 даст пустую строку
-
1 и 5 базовые
-
4 и 9 подчиняются правилу off by one. Они равны в точности «I{цифра + 1}»
-
2, 3, 6, 7, 8 пройдут по жадному алгоритму и превратятся в «II», «III», «VI», «VII», «VIII» соответственно.
То есть, изначальное число 4956 превратится в [«IV», «IX», «V», «VI»]. Осталось провести операцию потенциирования, которой занимается функция processRomanDigit:
const processRomanDigit = (digit: string, position: number) => { if (position === 4) return 'm'.repeat(toArabic(digit)) return digit .split('') .map(char => romanToArabicMap.get(char)!) .map(digitNumber => arabicToRomanMap.get(digitNumber * 10 ** (position - 1))!) .join('') }
Заметим, что position = 4 является граничным случаем. То есть конструирование количество тысяч выпадает из правила, но это не страшно.
Остальные же числа разбиваются на свои базовые и домножаются на 10 в нужной позиции:
-
«I» с позицией 4 станет «MMMM»
-
«IX» с позицией 2 станет «CM» (рассматривалось в примере объяснений)
-
«V» с позицией 1 станет «L»
-
«VI» с позицией 0 останется неизменным
Последний шаг – собрать все вместе. Итого, 4956 превратится «MMMMCMLVI»
Зачем такой подход вообще нужен?
На то есть несколько причин, и одна из них, это просто весело. Не принимать на веру какие-то правила и докапываться до истины вещей, как можно обобщить правило или его вывести.
Вдобавок, варианты реализаций через запоминание дифтонгов плохо масштабируются, если кому-либо когда-либо захочется расширить домен римских чисел сверх 5000. А здесь, это просто придумать буквы, взять следующие значения (5000, 10000) и все. И так до бесконечности.
Даже то захардкоженное значение position = 4 можно обобщить. И с обратной конверсией проблем тоже не возникнет, нужно только придумать, как обобщить регулярку проверки валидного римского числа. Но это уже совсем другая история:)
Весь код, включая обратную конверсию, можно найти в репозитории.
ссылка на оригинал статьи https://habr.com/ru/articles/761596/
Добавить комментарий