Введение

В данной статье я планирую провести разбор тестового задания для Python-разработчика, с которым мне пришлось столкнуться при поиске работы. Разбирая шаги и решения этого теста, я надеюсь поделиться полезными наработками и советами для тех, кто также стремится успешно пройти подобные испытания.

Задача

Тип зпдач весьма классический, про билетики:

У нас имеются автобусные билеты с номерами от 000000 до 999999. Билет считается 'счастливым', если сумма первых двух цифр равна сумме следующих двух цифр, и эта сумма равна сумме последних двух цифр. При этом не должно возникнуть ситуации, когда конкатенация пары цифр первого блока равна конкатенации пары цифр второго блока и третьего.

Для примера, рассмотрим билет с номером 123456:

  • Сумма первых двух цифр: 1 + 2 = 3

  • Сумма следующих двух цифр: 3 + 4 = 7

  • Сумма последних двух цифр: 5 + 6 = 11

Обозначим символом || операцию конкатинации:

  • конкатинация первых двух цифр: 1 || 2 = 12

  • конкатинация следующих двух цифр: 3 || 4 = 34

  • конкатинация следующих двух цифр: 5 || 6 = 56

Условия не выполняются, и билет считается не "счастливым".

Теперь мы хотим определить, сколько "счастливых" билетов находится в каждом рулоне троллейбуса.

Каждый рулон включает в себя 100 000 билетов (номера от 000000 до 099999, от 100000 до 199999 и так далее)

Решение "в лоб"

Под 'решением в лоб' я понимаю метод, основанный на переборе всех возможных значений. Следовательно, для такого подхода к решению задачи, первым этапом является генерация всех возможных значений:

if __name__ == '__main__':

    for i in range(1000000): 
        ticket_number = f'{i:06d}'
        print(ticket_number)

Код использует цикл от 0 до 999999 и форматирует каждое число как строку, добавляя ведущие нули при необходимости с использованием f-строки и форматирования числа с шестью знаками. Таким образом, формируется последовательность номеров от 000000 до 999999.

Далее код выделяет три нужные подстроки (пары чисел) [partn].

if __name__ == '__main__':

    for i in range(1000000):

        ticket_number = f'{i:06d}'

        part1 = ticket_number[0:2]
        part2 = ticket_number[2:4]
        part3 = ticket_number[4:6]

        print(part1, part2, part3)

отфильтрем номера, которые не сооветствую условию об конкатинации, для этого достататочно их сравнить "="

    for i in range(1000000):


        ticket_number = f'{i:06d}'

        part1 = ticket_number[0:2]
        part2 = ticket_number[2:4]
        part3 = ticket_number[4:6]

        if part1 == part2 == part3:
            continue

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

def sum_digits(pair):
    return int(pair[0]) + int(pair[1])

и отфильтруем пары по равенству чисел

if sum_digits(part1) == sum_digits(part2) == sum_digits(part3):
    print(part1, part2, part3)

Остается только записать результат в карту (map) и вывести его.

def sum_digits(pair):
    return int(pair[0]) + int(pair[1])


if __name__ == '__main__':

    map = {i: 0 for i in range(10)}

    for i in range(1000000):

        ticket_number = f'{i:06d}'

        part1 = ticket_number[0:2]
        part2 = ticket_number[2:4]
        part3 = ticket_number[4:6]

        if part1 == part2 == part3:
            continue

        if sum_digits(part1) == sum_digits(part2) == sum_digits(part3):
            map[int(ticket_number[0])] += 1


    for k, v in map.items():
        print(f"рулон {k + 1} имеет: {v} счастливых билетов")

Результат:

рулон 1 имеет: 375 счастливых билетов
рулон 2 имеет: 455 счастливых билетов
рулон 3 имеет: 515 счастливых билетов
рулон 4 имеет: 555 счастливых билетов
рулон 5 имеет: 575 счастливых билетов
рулон 6 имеет: 575 счастливых билетов
рулон 7 имеет: 555 счастливых билетов
рулон 8 имеет: 515 счастливых билетов
рулон 9 имеет: 455 счастливых билетов
рулон 10 имеет: 375 счастливых билетов

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

Замерим время выполнения (самым преметивным способом):

start_time = time.time()
  //предыдущий код
print(time.time() - start_time)

Время выполнения составило 1.136587381362915 некотрых едениц времени.

Более оптиальное решение

Однако вместо того, чтобы перебирать все 100000 значений, достаточно ограничиться перебором всех возможных пар от 00 до 99. Это осуществимо, зная, сколько каждой паре соответствует других пар с той же суммой цифр. Сначала мы рассчитаем и запишем эту информацию в мапу (где ключ это сумма двух цифр, а значение количество таких сумм).

map = defaultdict(int)

for n1, n2 in product(range(10), repeat=2): //перебор всех двоек
    map[n1 + n2] += 1 // добовляем 1 в ту сумму которой соответствует двойка 

Затем, как было упомянуто ранее, мы снова переберем все пары двоек, посчитаем, сколько счастливых билетов соответствует каждой из них, и запишем результат.

    res = {}

    for n1 in range(0, 10):
        res[n1] = 0
        for n2 in range(0, 10):
            res[n1] += (map[n1 + n2] ** 2) - 1

После этого мы возводим результат в квадрат, так как помимо перебираемой двойки есть еще две пары. Затем вычитаем 1, так как это представляет собой случай, который не соответствует требованию конкатенации.

Запишем это еще проще:

    res = {n1: sum((map[n1 + n2] ** 2) - 1 for n2 in range(10)) for n1 in range(10)}

Итого имеем:

from collections import defaultdict
from itertools import product

if __name__ == '__main__':

    map = defaultdict(int)

    for n1, n2 in product(range(10), repeat=2):
        map[n1 + n2] += 1

    res = {n1: sum((map[n1 + n2] ** 2) - 1 for n2 in range(10)) for n1 in range(10)}

    for k, v in res.items():
        print(f"рулон {k + 1} имеет: {v} счастливых билетов")

Результат:

рулон 1 имеет: 375 счастливых билетов
рулон 2 имеет: 455 счастливых билетов
рулон 3 имеет: 515 счастливых билетов
рулон 4 имеет: 555 счастливых билетов
рулон 5 имеет: 575 счастливых билетов
рулон 6 имеет: 575 счастливых билетов
рулон 7 имеет: 555 счастливых билетов
рулон 8 имеет: 515 счастливых билетов
рулон 9 имеет: 455 счастливых билетов
рулон 10 имеет: 375 счастливых билетов

После внесения вышеупомянутых оптимизаций время выполнения снизилось в 3100 раз, что является впечатляющим результатом. Замер времени выполнения с использованием примитивного метода составил 0.0003666877746582031 тех же единиц времени, что говорит о существенном ускорении процесса.

Это существенное ускорение достигается за счет уменьшения числа итераций в циклах с 100 000 до всего 200. Стоит также отметить, что количество итераций можно уменьшить вдвое, учитывая симметрию результата (делать я этого конечно же не буду).

Вывод

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

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

Однако, во втором варианте, который был продемонстрирован, мы применили оптимизированный метод, ограничившись перебором лишь нужных пар. Это привело к значительному снижению числа итераций и, как следствие, к ускорению времени выполнения в 3100 раз.

P.S. код можно глянуть на GitHub

Комментарии (18)


  1. twistfire92
    10.11.2023 09:24

    Условия выполняются, и билет считается "счастливым".

    У вас тут ошибочка, видимо


    1. PicoPicoRobotWoman Автор
      10.11.2023 09:24

      спасибо


  1. A-V-tor
    10.11.2023 09:24
    +1

    Хех хех
    Где же Вы такие "классические" тестовые по python находите?
    Это задачка скорее для решения на собесе...


  1. SyrexS
    10.11.2023 09:24

    Судя по условию билет 133122 счастливый.

    А по вашему коду он не пройдет.

    if part1 == part2 == part3


    1. PicoPicoRobotWoman Автор
      10.11.2023 09:24

      print('13' == '31' == '22') // False выводит

      все верно, так и должно быть


      1. SyrexS
        10.11.2023 09:24
        +2

        Ну так он выведет False и билетик попадет в счастливые - так?

        Получается что и 133113 - тоже попадет в счастливые

        Условие не очень понятно:

        part1 != part2 & part1 != part3

        или Все три числа не должны быть равны друг другу?


  1. olegtsss
    10.11.2023 09:24
    +5

    Наивный способ разобрали подробно, а оптимальное решение декларировали, ограничившись коротким описанием. Не буду врать, не понятным что там и как. Пришлось лезть в debug, чтобы разобраться с нуля. Хотелось бы увидеть более детальное разъяснение (с применение кроме описательного словами метода и других), которое кстати позволит поискать как ошибки в проектировании, так и другие, а также другие решения.


  1. iamawriter
    10.11.2023 09:24
    +1

    Вот этот текст - "Билет считается 'счастливым', если сумма первых двух цифр равна сумме следующих двух цифр, и эта сумма равна сумме последних двух цифр. При этом не должно возникнуть ситуации, когда конкатенация пары цифр первого блока равна конкатенации пары цифр второго блока и третьего." почему-то вызывает у меня когнитивный диссонанс. Я бы интерпретировал его таким образом, что три пары двузначных чисел, из которых состоит номер билета, должны иметь одинаковую сумму своих цифр, и при этом быть различны между собой. При такой интерпретации в вашем "наивном" решении вместо if part1 == part2 == part3: я бы написал if (part1 == part2) or (part2 == part3) or (part1 == part3):, что привело бы к тому, что всего счастливых билетов 3240.

    Ну и мое видение чуть менее "наивного" решения выглядело бы как-то так:

    Чуть менее "наивное" решение
    from collections import Counter
    
    # для каждой суммы пары цифр из диапазона 0..9 подсчитываем
    # количество чисел из диапазона 0..99, сумма цифр которых равна
    # этой сумме.
    # Заметьте, что все числа из диапазона 0..99 у нас уникальны,
    # что гарантирует отсутствие совпадающих чисел
    # (условие различия всех 3-х двузначных чисел, составлющих номер билета)
    counter = Counter([i + j for i in range(10) for j in range(10)])
    
    # На "счастливость" могут претендовать только те суммы пары цифр,
    # для которых имеется не менее 3-х различных чисел, чтобы 
    # было что расставлять по 3-м позициям в номере билета
    lucky_numbers = {
        s: count for s, count in counter.items() if count >= 3
    }
    
    total_lucky_tickets = 0
    for i in range(10):
        lucky_tickets = 0
        for j in range(10):
            if i + j in lucky_numbers:
                # n - число различных чисел для суммы i+j,
                # за исключением числа ij
                n = lucky_numbers[i + j] - 1
                # эти n различных чисел можно разместить
                # по двум позициям n*(n-1) способом
                lucky_tickets += n * (n - 1)
        total_lucky_tickets += lucky_tickets
        print(f"roll №{i + 1} -> {lucky_tickets}")
    
    print(f"total lucky tickets = {total_lucky_tickets}") # 3240

    Что думаете о такой интерпретации?..


  1. eandr_67
    10.11.2023 09:24
    +2

    Если оставаться в рамках интерпретации автора вопроса, то:

    # 20 итераций: фиктивный 20-й элемент - чтобы упростить последующий цикл
    res = [v * v - 1 for v in [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 9, 8, 7, 6, 5, 4, 3, 2, 1, 0]]
    sm = sum(res[:10]) # 10 итераций
    for i in range(10): # 10 итераций
      print('рулон %d имеет: %d счастливых билетов' % (i + 1, sm))
      sm += res[i + 10] - res[i]

    Итого имеем 40 итераций.

    Количество вариантов каждой суммы считается в уме - для этого не надо делать 100 итераций посредством product.


    1. santjagocorkez
      10.11.2023 09:24

      А можно поподробнее суть решения?


      1. eandr_67
        10.11.2023 09:24
        +5

        1. Всего у нас 19 возможных сумм двух цифр: от 0 (0 + 0) до 18 (9 + 9). При этом для каждой суммы k количество способов её получения n[k] = 10 - |k - 9| (я не стал вычислять циклом, а просто загнал все значения в массив, где индекс - сумма, а кол-во способов - значение).

        2. Всего существует n[k]² комбинаций двух групп с суммой k каждая.

        3. Из невозможности тройного равенства получаем, что для каждой левой группы цифр со значением k существует n[k]² - 1 счастливая комбинация двух правых групп.

        4. Если первая цифра билета - i, то k может принимать значения от i до i + 9. И для получения кол-ва счастливых билетов s[i] достаточно просуммировать значения от n[i]² - 1 до n[i + 9]² - 1 включительно.

        5. Если у нас уже вычислено s[i], то не надо пересчитывать s[i + 1] целиком - достаточно от s[i] отнять n[i]² - 1 и прибавить n[i + 10]² - 1 (скользящее окно).

        А т.к. и исходная сумма чисел 1²-1 .. 10²-1, и каждое из значений n[i]² - 1 считаются простыми формулами без циклов (которые ещё и сокращаются в процессе вычислений), то всю программу можно ужать до единственного цикла:

        sm = 10 * (10 + 1) * (2 * 10 + 1) // 6 - 10 # сумма 1²-1 .. 10²-1 без цикла
        for i in range(0, 10): # в программе осталось 10 итераций
          print(sm)
          sm += 80 - 20 * i # скользящее окно после раскрытия скобок и сокращений


        1. santjagocorkez
          10.11.2023 09:24

          Спасибо большое


  1. Tathagata
    10.11.2023 09:24
    +3

    конкатЕнация же!)


  1. wataru
    10.11.2023 09:24
    +2

    А еще можно ускорить почти в сотню раз, воспользовавшись математикой.

    Сумму S от 2 до 18 двумя цифрми можно набрать N(S)=9-|S-10| способами. Для каждой суммы есть N(S)^3-N(S) счастливых билетов (каждая треть может быть любым способом, но надо вычесть плохие-счастливые билеты, где все три части совпадают). Общее количество билетов - сумма по S. Стоит рассмотреть отдельно случаи когда S не превышает 10, и наоборот. Тогда модули можно раскрыть, причесать выражение и использовать формлу суммы кубов/квадратов/арифметической прогресии.


  1. SergeiMinaev
    10.11.2023 09:24

    При этом не должно возникнуть ситуации, когда конкатенация пары цифр
    первого блока равна конкатенации пары цифр второго блока и третьего.

    Что значит "не должно возникнуть ситуации"? Во время выполнения вычисления не должно возникнуть такой ситуации? Можно понять именно так. В вашем первом решении (которое "в лоб") такая ситуация возникает целых 100 раз - на участке `if part1 == part2 == part3`. Чтобы ситуации не возникло, можно попробовать что-то математичное, как в комментарии выше. Да и то - не ясно, что именно понимать под "ситуацией". Согласен, что это занудство, но расплывчатые формулировки иногда вызывают страшные флешбэки :)


  1. CrazyElf
    10.11.2023 09:24
    +1

    map - встроенная функция питона. Перекрывать встроенные функции своими переменными - нехорошо, потом сюрпризы будут, когда вы встроенными функциями захотите воспользоваться.
    Ну и количество "ашипог" в тексте просто удручает.


  1. Anarchist
    10.11.2023 09:24

    О боже... Задача должна быть решаема со сложностью O(1). При чем тут питон вообще?


  1. artyom-tugaryov
    10.11.2023 09:24

    Кто же создаёт переменную с зарезервированным именем?