Нужен код-ревью решения задачи представления десятичных чисел в римской системе счисления

Задача:
Семь различных символов представляют римские цифры со следующими значениями:

Римские числа Десятичные числа
I 1
V 5
X 10
L 50
C 100
D 500
M 1000

Римские цифры образуются путем добавления преобразований значений десятичных знаков от самого высокого к самому низкому. Преобразование значения десятичного знака в римское число имеет следующие правила:

Если значение не начинается с 4 или 9, выберите символ максимального значения, которое можно вычесть из входных данных, добавьте этот символ к результату, вычтите его значение и преобразуйте остаток в римскую цифру.

Если значение начинается с 4 или 9, используйте субтрактивную форму , представляющую один символ, вычитаемый из следующего символа, например, 4 на 1 ( I) меньше 5 ( V): IV и 9 на 1 ( I) меньше 10 ( X): IX. Используются только следующие субтрактивные формы: 4 ( IV), 9 ( IX), 40 ( XL), 90 ( XC), 400 ( CD) и 900 ( CM).

Только степени 10 ( I, X, C, M) могут быть добавлены последовательно не более 3 раз для представления кратных 10. Вы не можете добавить 5 ( V), 50 ( L) или 500 ( D) несколько раз. Если вам нужно добавить символ 4 раза, используйте вычитательную форму.

Дано целое число, преобразуйте его в римскую цифру.

Пример 1:

Ввод: число = 3749

Вывод: "MMMDCCXLIX"

Объяснение:

3000 = МММ как 1000 (М) + 1000 (М) + 1000 (М)
 700 = DCC как 500 (D) + 100 (C) + 100 (C)
  40 = XL как 10 (X) меньше 50 (L)
   9 = IX как 1 (I) меньше 10 (X)
Примечание: 49 не на 1 (I) меньше 50 (L), поскольку преобразование основано на десятичных знаках.

Пример 2:

Ввод: число = 58

Вывод: "LVIII"

Объяснение:

50 = L, 8 = VIII

Пример 3:

Ввод: число = 1994

Вывод: "MCMXCIV"

Объяснение:

1000 = М
 900 = СМ
  90 = ХС
   4 = IV

Ограничения:

1 <= num <= 3999

Ссылка:
https://leetcode.com/problems/integer-to-roman/description/

Недавно писал код для этой задачи. Ключевая особенность в том, что я решил не добавлять в кортежи тех значений, которых нет в таблице, (за исключением None). Вот что вышло:

def int_to_roman(num):
    result = []
    nums = (1000, 500, 100, 50, 10, 5, 1, None)
    roman_nums = ('M', 'D', 'C', 'L', 'X', 'V', 'I')

    while not num == 0:
        for max_deductible in nums:
            # Находим такое наибольшее вычитаемое, которое будет меньше либо равно num
            if max_deductible <= num:
                # Сохраняем индекс вычитаемого
                max_deductible_index = nums.index(max_deductible)
                break

        # Сохраняем предыдущее число (до max_deductible)
        previous_num = nums[max_deductible_index - 1]
        flag = len(str(max_deductible)) == len(str(previous_num))

        # Выбор вычитаемого из next_num, которое подходит для данного случая
        possibly_deductible = (nums[max_deductible_index + 1], max_deductible)[flag]

        # Если существует число менее num и более max_deductible (в nums)
        if all((possibly_deductible, previous_num)) and previous_num - possibly_deductible <= num:
            num -= (previous_num - possibly_deductible)
            possibly_deductible_index = nums.index(possibly_deductible)

            # В римском вычитании сначала ставится вычитаемое, а потом уменьшаемое
            result.append(roman_nums[possibly_deductible_index] +
                          roman_nums[max_deductible_index - 1])
        else: # Если такого числа не существует, вычитаем max_deductible
            num -= max_deductible
            result.append(roman_nums[max_deductible_index])

    return ''.join(result) # Возращаем результат

Буду рад услышать любую критику. Но важно понимать, что я относительно недавно пришёл в эту сферу, и поэтому предлагать какие-нибудь функции, методы из библиотек/фреймворков не следует.


Ответы (1 шт):

Автор решения: Vitalizzare

Общее замечание - комментарии малосодержательны и отсутствуют в спорных местах, где они нужны.

def int_to_roman(num):

Было бы неплохо указать тип аргументов и возвращаемого значения. И почему бы не написать документирующую строку после декларации?

    result = []
    nums = (1000, 500, 100, 50, 10, 5, 1, None)
    roman_nums = ('M', 'D', 'C', 'L', 'X', 'V', 'I')

Было бы правильным прокомментировать наличие None в конце nums, необходимость которого не очевидна.

    while not num == 0:

В условии стоит ограничение 1 <= num <= 3999. У вас проверки нет. Почему?

        for max_deductible in nums:
            # Находим такое наибольшее вычитаемое, которое будет меньше либо равно num
            if max_deductible <= num:
                # Сохраняем индекс вычитаемого
                max_deductible_index = nums.index(max_deductible)
                break

Во-первых, слово deductible в имени переменной выбрано неудачно, поскольку предполагает работу с финансами или страховкой, а это не ваш случай. Рассмотрите нейтральные альтернативы, например subtractable.

Во-вторых, от этого цикла можно (и нужно) избавиться, постепенно опускаясь вниз по значениям nums. Но если уж использовать, то наверное с enumerate чтобы не искать индекс. Также будет уместен комментарий, почему не нужно беспокоиться о сравнении None < num.

        # Сохраняем предыдущее число (до max_deductible)
        previous_num = nums[max_deductible_index - 1]

Это то место, где комментарий может быть более содержательным, указывая на случай, когда индекс проваливается в -1.

        flag = len(str(max_deductible)) == len(str(previous_num))
        # Выбор вычитаемого из next_num, которое подходит для данного случая
        possibly_deductible = (nums[max_deductible_index + 1], max_deductible)[flag]

Очень странное место, вводящее в заблуждение выбором имени flag. Считаю само наличие этой переменной необоснованным, было бы лучше использовать if ... else .... Также не лишним было бы прокомментировать случай len('1000') == len('None'), поскольку это совпадение слишком притянуто за уши и по алгоритму не считывается.

        # Если существует число менее num и более max_deductible (в nums)
        if all((possibly_deductible, previous_num)) and previous_num - possibly_deductible <= num:

Если я правильно понимаю алгоритм, то if all((possibly_deductible, previous_num)) and ... сводится к if previous_num is not None and .... Комментарий к строке довольно странный; он сбивает с толку, а не объясняет.

            num -= (previous_num - possibly_deductible)
            possibly_deductible_index = nums.index(possibly_deductible)

            # В римском вычитании сначала ставится вычитаемое, а потом уменьшаемое
            result.append(roman_nums[possibly_deductible_index] +
                          roman_nums[max_deductible_index - 1])

Спорное место. Если мы считаем выражения вида 'IV', 'IX', 'XL', ... одной цифрой, представленной в виде комбинации двух графем, тогда претензий нет. А если эти выражения считаем комбинацией двух римских цифр, тогда вы неявно дублируете финальное соединение набора цифр. Полагаю, что вместо одного append нужно делать два идущих друг за другом, чтобы исключить скрытое соединение цифр.

        else: # Если такого числа не существует, вычитаем max_deductible
            num -= max_deductible
            result.append(roman_nums[max_deductible_index])

Если возвращаемое значение - строка, в которой римские цифры соединяются встык, то вся эта проверка убирается добавлением пар (900, 'CM'), (400, 'CD'), (90, 'XC'), ....

    return ''.join(result) # Возращаем результат

Последний комментарий не информативен.


В целом, алгоритм понятен. С учетом указанных замечаний, его можно оптимизировать, добавив обозначения вида 'IV', 'XL', 'CD', .... Нет смысла вычислять их алгоритмически в силу малого количества:

def int_to_roman(num: int) -> str:
    """Return the Roman representation of the given number between 1 and 3999 including.
    Examples:
    >>> int_to_roman(3749)
    'MMMDCCXLIX'
    >>> int_to_roman(1994)
    'MCMXCIV'
    >>> int_to_roman(58)
    'LVIII'
    >>> int_to_roman(0)    # doctest: +IGNORE_EXCEPTION_DETAIL
    Traceback (most recent call last):
    AssertionError
    """
    assert 1 <= num <= 3999    # unalterable alternative: if not (1 <= num <= 3999): raise ValueError()
    result = []
    base_numbers = (1000, 900, 500, 400, 100, 90, 50, 40, 10, 9, 5, 4, 1)
    roman_codes = ('M', 'CM', 'D', 'CD', 'C', 'XC', 'L', 'XL', 'X', 'IX', 'V', 'IV', 'I')
    base_code = zip(base_numbers, roman_codes)
    base, roman = next(base_code)
    while num > 0:
        while base > num:
            base, roman = next(base_code)
        num -= base
        result.append(roman)
    return ''.join(result) 

if __name__ == '__main__':
    import doctest
    doctest.run_docstring_examples(int_to_roman, None)

Если же в силу каких-либо соображений таблицы кодировки нужно ограничить простыми цифрами, то исходный алгоритм можно отредактировать так:

def int_to_roman(num: int) -> str:
    """Return the Roman representations for num in the interval [1, 3999]
    >>> int_to_roman(3749)
    'MMMDCCXLIX'
    >>> int_to_roman(1994)
    'MCMXCIV'
    >>> int_to_roman(58)
    'LVIII'
    """
    assert 1 <= num <= 3999 
    result = []
    base = (1000, 500, 100, 50, 10, 5, 1)
    code = ('M', 'D', 'C', 'L', 'X', 'V', 'I')
    step = 0
    while num > 0:
        # Находим такое наибольшее вычитаемое, которое будет меньше либо равно num
        while base[step] > num:
            # Сохраняем индекс вычитаемого            
            step += 1
        # Выбор вычитаемого из next_num, которое подходит для данного случая       
        shift = step%2            
        # Если существует число менее num и более max_deductible (в nums) 
        if step > 0 and num >= (diff:=base[step-1] - base[step+shift]):
            num -= diff
            # В римском вычитании сначала ставится вычитаемое, а потом уменьшаемое
            result.append(code[step+shift])
            result.append(code[step-1])
        else: # Если такого числа не существует, вычитаем max_deductible
            num -= base[step]
            result.append(code[step])
    return ''.join(result)

Оригинальные комментарии сохранены для сравнения с исходным кодом. Значимые изменения:

  1. Из опорных чисел удален None, вместо него проверяем step > 0.
  2. Смещение к наименьшему числу одного порядка (пр.: 1 для 500->100, 0 для 100->100) рассчитывается по четности текущей позиции.
→ Ссылка