Однажды меня посетила мысль, а что если попробовать решить первую задачу Проекта Эйлера всевозможными способами, но с условием, что решение должно быть в одну строку. В итоге получилось более пяти однострочных решений с применением Filter, Map, Reduce, Generator Expression и т.д. В этой статье я покажу то, к чему я пришёл.


Это моя первая статья. Стоит отнестись к ней настороженно. Уникальные решения будут оформлены в отдельные пункты. Менее уникальные - в подпункты.

Условие задачи

Если выписать все натуральные числа меньше 10, кратные 3 или 5, то получим 3, 5, 6 и 9. Сумма этих чисел равна 23.

Найдите сумму всех чисел меньше 1000, кратных 3 или 5.  

Источники: Оригинал или Русскоязычное зеркало

00 - Базовое решение

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

result = 0

for i in range(1, 1000):
    if i%3 == 0 or i%5 == 0:
        result += i

print(result) # => 233168

Перебираем последовательность чисел от 1 до 999. Если перебираемое число делится на 3 или на 5 без остатка от деления, то прибавляем каждое такое число в заранее объявленную переменную result

01 - Generator Expression. Выражение-генератор

print(sum(i for i in range(1, 1000) if i%3 == 0 or i%5 == 0)) # => 233168

Числа из последовательности от 1 до 999, делящиеся на 3 или на 5 без остатка от деления, собираются в генератор. Затем функция sum() складывает содержимое генератора. 

01.a - List Comprehension. Выражение на основе списка

print(sum([i for i in range(1, 1000) if i%3 == 0 or i%5 == 0])) # => 233168

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

01.b - Set Comprehension. Выражение на основе множества

print(sum({i for i in range(1, 1000) if i%3 == 0 or i%5 == 0})) # => 233168

Тоже, что и в предыдущем, но вместо списка здесь множество.

02 - Filter

print(sum(filter(lambda i: i%3 == 0 or i%5 == 0, range(1, 1000)))) # => 233168

Функция filter схожа по принципу работы с выражением-генератором. Функция лямбда применяется к каждому элементу последовательности чисел от 1 до 999. Все числа последовательности, делящиеся на 3 или на 5 без остатка от деления, возвращаются, затем суммируются функцией sum().

03 - Map

print(sum(map(lambda i: i if i%3 == 0 or i%5 == 0 else 0, range(1, 1000)))) # => 233168

Перебираемые числа последовательности от 1 до 999, делящиеся на 3 или 5 без остатка от деления, остаются без изменений, все остальные числа заменяются на ноль. Полученная последовательность суммируется функцией sum().

04 - Reduce

from functools import reduce
print(reduce(lambda x, y: x+y if y%3 == 0 or y%5 == 0 else x, range(1, 1000), 0)) # => 233168

Из всей подборки, этот вариант "очень не очень". Как по степени реализации, так и по времени выполнения(но об этом попозже).  
Если в reduce указан инициализатор(в нашем случае ноль), то он становится накопителем. К нему по очереди прибавляются только те  числа из последовательности от 1 до 999, которые делятся на 3 или на 5 без остатка от деления. Если из функции reduce убрать инициализатор ноль, то инициализатором станет крайний левый элемент последовательности.

05 - Однострочное решение на основе множества

print(sum(set(range(3, 1000, 3)) | set(range(5, 1000, 5)))) # => 233168

Самое элегантное решение, как по красоте написания, так и по времени выполнения.  
Последовательность чисел от 1 до 999, кратную трём, помещаем во множество и объединяем со множеством, содержащим в себе последовательность чисел от 1 до 999, кратную пяти. Содержимое, полученного множества суммируем функцией sum().

05.a - Ещё одно однострочное решение на основе множества

print(sum({*range(3, 1000, 3)} | {*range(5, 1000, 5)})) # => 233168

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

05.b - И ещё одно однострочное решение на основе множества

print(sum(set(range(3, 1000, 3)).union(range(5, 1000, 5)))) # => 233168

Создаём множество, с последовательностью чисел от 1 до 999, кратную трём и присоединяем к нему последовательность чисел от 1 до 999, кратную пяти. Затем функцией sum() суммируем.

05.c И последнее однострочное решение на основе множества

print(sum(set([*range(3, 1000, 3)] + [*range(5, 1000, 5)]))) # => 233168

По аналогии с предыдущими. Распаковываем последовательности чисел в списки. Складываем списки. Оборачиваем во множество. Затем суммируем функцией sum().

Смотрим на скорость выполнения каждого однострочного решения

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

sum(i for i in range(1, 1000) if i%3 == 0 or i%5 == 0) # 203 usec
sum([i for i in range(1, 1000) if i%3 == 0 or i%5 == 0]) # 181 usec
sum({i for i in range(1, 1000) if i%3 == 0 or i%5 == 0}) # 189 usec
sum(filter(lambda i: i%3 == 0 or i%5 == 0, range(1, 1000))) # 306 usec
sum(map(lambda i: i if i%3 == 0 or i%5 == 0 else 0, range(1, 1000))) # 313 usec
reduce(lambda x, y: x+y if y%3 == 0 or y%5 == 0 else x, range(1, 1000), 0)# 324 usec
sum(set(range(3, 1000, 3)) | set(range(5, 1000, 5))) # 47 usec
sum({*range(3, 1000, 3)} | {*range(5, 1000, 5)}) # 47 usec
sum(set(range(3, 1000, 3)).union(range(5, 1000, 5))) # 41 usec
sum(set([*range(3, 1000, 3)] + [*range(5, 1000, 5)])) # 43 usec

Методика расчёта: python -m timeit "выражение"

Быстрее всего справились с задачей последние четыре варианта.


Заключение

Всего получилось 5 уникальных + 5 не уникальных решений. Благодаря этой задаче у меня появилось более устойчивое понимание работы функций Filter, Map, Reduce. И если раньше я недоумевал, почему функцию Reduce убрали из основного модуля, то теперь я не сомневаюсь в правильности этого решения.

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

Всем спасибо.

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


  1. sci_nov
    24.10.2021 16:37

    Не знаете почему решение на множествах оказалось быстрее? Я предполагаю, что из-за меньшего потребления памяти.


    1. insecto
      24.10.2021 16:56

      Мне кажется, потому что в них никакой питонячий код не вызывается в цикле, все циклы нативные.


      1. sci_nov
        24.10.2021 17:02

        По идее, list comprehension для этого и рекомендован. Это надо ещё догадаться использовать множества. Возможно, что от версии python зависит.


        1. insecto
          24.10.2021 18:02
          +1

          list comprehensions всё равно вызывает питонячий код if i%3 == 0 or i%5 == 0 на каждой итерации, а питонячий код это тормоза. А например set(range(3, 1000, 3)) может у себя внутри очень быстро нативно поинкрементить переменную, без выхода в нижний мир.


          1. sci_nov
            24.10.2021 19:11

            А, да.


  1. wataru
    24.10.2021 17:19
    +14

    А как же самое быстрое решение на основе математики?


    print((1000-1000%3)//3*(1000-1000%3+3)//2+(1000-1000%5)//5*(1000-1000%5+5)//2 - (1000-1000%15)//15*(1000-1000%15+15)//2)

    На моем компе занимает 0.00827 usec, когда как самое быстрое ваше решение занимет 17.3 usek. Где-то этак в 2000 раз быстрее. Если планируете и дальше решать Project Eurler, то именно такие решения вам и стоит искать.


    Берется это решение отсюда: Возьмем сумму всех чисел, делящихся на 3 и сумму всех чисел делящехся на 5. Сложив их мы получим почти то, что надо. Только числа делящееся на 3 и на 5 одновременно (делящееся на 15) будут подсчитаны дважды. Поэтому их сумму надо вычесть.


    Далее, сумму чисел до n, делящехся на некое k можно подсчитать как арифметическую прогрессиюю k+2k+....+z, где z — самое большое число, делящееся на k, меньшее n.


    1. Balling
      25.10.2021 03:41

      Осталось запилить эвристику для всех компиляторов!


    1. paluke
      25.10.2021 05:56
      +1

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


    1. dmitry22275
      16.11.2021 16:25
      +1

      Вот только неправильно.


      1. wataru
        16.11.2021 16:30
        -1

        Действительно, я 1000 учитывал в сумме, когда как в задании стоит слово "меньше". Если поменять 1000 на 999 — то работет правильно.


  1. amakhrov
    24.10.2021 21:17

    Любопытна разница между list comprehension и filter. Похоже на большие накладные расходы на вызов лямбды. Которая по какой-то причине не заинлайнилась.

    А reduce, кстати, единственный из функциональных вариантов не создает промежуточных списков в памяти. Реально непонятно, почему он медленнее того же sum+filter. Арифметическое суммирование в питоне медленнее функции sum?


    1. northzen
      24.10.2021 22:45

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


  1. Imposeren
    21.11.2021 14:13
    +1

    Можно вместо объединения множеств. Попробовать sum от itertools.chain двух range