Вот вам задача: надо проверить, входит ли число 200 миллионов в диапазон от 0 до 1 миллиарда. Знаю, что на Python её решение выглядит до крайности примитивно — достаточно воспользоваться функцией any
и списковым включением:
def find_200_million() -> bool:
return any([number == 200_000_000 for number in range(1_000_000_000)])
Правда, работает это всё не так уж и быстро. Всё же, речь идёт о миллиарде чисел… Программа просто «подвисает» на то время, что нужно для её выполнения. В моём случае это «подвисание» растянулось на 42 секунды.
Почему? Можно ли обойтись без «подвисания»?
Для начала разберёмся с тем, что тут происходит, а потом подумаем о том, можно ли как-то ускорить эту программу.
Когда мы вызываем функцию — происходит две вещи:
Запускается механизм спискового включения, генерирующий список из примерно 1 миллиарда логических значений, указывающих на то, равно ли каждое из чисел 200 миллионам.
Затем запускается функция
any
. Она перебирает этот список, проверяя, равен ли какой-нибудь из его элементов значениюTrue
. А как только это значение будет обнаружено, функция возвращает результаты своей работы.
Получается, что тут имеется два цикла, в ходе выполнения которых происходит 1,2 миллиарда итераций! Функция any
доберётся до 200-миллионного значения достаточно быстро. А вот списковое включение, с другой стороны, создаёт «тяжёлый» список из 1 миллиарда логических значений. Это занимает много времени. Когда я запустил эту программу, формирование списка заняло примерно 40 секунд. А функция any
нашла в нём первое значение True
всего за 2 секунды.
Теперь, когда мы выяснили, что тут происходит — зададимся вопросом о том, можно ли улучшить производительность программы. Главные проблемы здесь — необходимость работы с огромным количеством значений, и то, что создание списка занимает определённое время. А значит — если мы сократим количество значений, добавляемых в список, это должно привести к тому, что программа будет работать быстрее.
Более эффективная реализация механизма спискового включения
Наш список можно сделать меньше, пользуясь фильтрующей конструкцией if
спискового включения. Это приведёт к генерированию списка, содержащего только значения True
:
def find_200_million() -> bool:
return any([True for number in range(1_000_000_000) if number == 200_000_000])
Теперь мы генерируем список, который содержит лишь один элемент — для каждого числа, равного 200 миллионам. В нашем случае это значит, что получится список, содержащий лишь один элемент. Тестируя это решение, я обнаружил, что список формируется за 33 секунды — примерно на 7 секунд быстрее. А функция any
завершает работу почти мгновенно, так как ей надо проверить список, в котором содержится лишь один элемент. Поэтому теперь вместо 42 секунд на решение задачи уходит лишь 33 секунды!
Можно ли это ещё как-нибудь ускорить?
33 секунды — это всё ещё серьёзное время. Как его улучшить? Сейчас механизм спискового включения перебирает 1 миллиард элементов, каждый из них проверяет, а потом выдаёт список. Что если вместо этого использовать генераторное выражение? Принесёт ли это пользу?
def find_200_million() -> bool:
return any(True for number in range(1_000_000_000) if number == 200_000_000)
Этот вариант программы выдаёт результат всего за 6 секунд!
Почему генераторное выражение оказывается таким быстрым?
Генераторные выражения не быстрее списковых включений. Единственное их преимущество заключается в том, что они создают следующий элемент списка по запросу. А списковые включения заранее генерируют весь список. При использовании any
ценность генераторных выражений в том, что они останавливаются при обнаружении совпадения.
Пошагово разберём вышеприведённый код для того чтобы лучше понять то, как он работает:
Создаётся генератор, который идёт по значениям от 0 до 999,999,999 и выдаёт
True
для каждого значения, равного 200 миллионам.Функция
any
получает генератор и запрашивает из него первое значение.Генератор начинает проходиться по числам до тех пор, пока не выдаст то, которое соответствует условию равенства 200 миллионам. После этого он выдаёт
True
. Это значение получаетany
.Когда функция
any
получаетTrue
, она завершает работу, ничего больше не проверяя.
Получается, что эта программа быстрее из-за того, что она не перебирает числа диапазона, превышающие 200 миллионов!
Если же, с другой стороны, мы искали бы последнее число в диапазоне (999,999,999), скорость нашего решения была бы практически такой же, как при использовании спискового включения. Новое решение быстрее старого только в том случае, когда первое значение, удовлетворяющее условию, не находится в конце пространства поиска.
Хорошо, а что если бы мы не воспользовались фильтрацией с помощью if?
А тут появляется такой вопрос: что если бы мы построили условие с использованием получаемого значения и не пользовались бы фильтром if
в генераторном выражении?
def find_200_million() -> bool:
return any(number == 200_000_000 for number in range(1_000_000_000))
Этот вариант решения задачи завершает работу примерно за 9 секунд. То есть — он примерно на 3 секунды медленнее, чем при использовании фильтра if
. Это так из-за того, что теперь генератор получает False
для чисел от 0 до 199,999,999, а any
, до завершения работы, приходится делать 200 миллионов проверок. А это на 199,999,999 больше проверок, чем тогда, когда мы пользовались фильтрацией с помощью if
.
Итоги
Использование фильтрации, основанной на if
, сужает пространство поиска, которое нужно просматривать any
. При правильном использовании фильтрации функции any
приходится проверить лишь первое полученное значение. Кроме того, в ситуациях, подобной нашей, рекомендуется пользоваться генераторными выражениями. Они не всегда быстрее списковых включений, но они останавливаются сразу после того, как any
завершает работу. Это часто приводит к экономии времени и всегда позволяет экономить память!
Так, погодите, а что там с функцией all?
Применимо ли это всё к функции all
? Можно ли ускорить и её? Да! Это возможно!
Функция any
интересуется значениями True
и возвращает True
, находя первое из них, или False
— если не нашла ни одного. А функция all
действует с точностью до наоборот. Она ищет значения False
и возвращает False
, когда найдёт первое такое значение, или возвращает True
, если ни одного значения False
не нашла. Поэтому те же подходы к оптимизации кода можно применить и к all
, перенастроив if
на поиск состояния отказа и выдавая False
, а не True
!
def all_not_equal_to_200_million() -> bool:
return all(False for number in range(1_000_000_000) if number == 200_000_000)
В этом коде False
выдаётся только в том случае, если будет найдено число, удовлетворяющее состоянию отказа, соответствующего равенству значения 200 миллионам. Если ни одно число не равно 200 миллионам — ничего не будет выдано и all
вернёт True
, так как генератор окажется пустым.
В итоге оказывается, что выжать максимальную производительность из any
и all
можно, пользуясь генераторными выражениями и фильтрами, основанными на if
.
О, а приходите к нам работать? ???? ????
Мы в wunderfund.io занимаемся высокочастотной алготорговлей с 2014 года. Высокочастотная торговля — это непрерывное соревнование лучших программистов и математиков всего мира. Присоединившись к нам, вы станете частью этой увлекательной схватки.
Мы предлагаем интересные и сложные задачи по анализу данных и low latency разработке для увлеченных исследователей и программистов. Гибкий график и никакой бюрократии, решения быстро принимаются и воплощаются в жизнь.
Сейчас мы ищем плюсовиков, питонистов, дата-инженеров и мл-рисерчеров.
Комментарии (10)
NeoCode
16.08.2022 09:29Не знаю Питона, но вообще эти списковые включения выглядят весьма интересно.
В каких языках они еще есть?
Является ли само по себе выражение "спискового включения" объектом, или оно сразу "раскрывается" в список (запускается на генерацию)? (здесь я имею в виду аналогию с лямбда-функцией в большинстве языков: само по себе лямбда-выражение - это не вызов функции, а специальный объект, который можно сохранить в переменной; вызов функции происходит только при применении операции вызова "()" )
Еще приходит в голову аналогия с диапазонными объектами, которые тоже есть в некоторых языках. Выражения вида "10..20" можно интерпретировать как специальные объекты, хранящие два числа, а можно - как полноценные списки всех чисел в заданном диапазоне.
mbait
16.08.2022 11:44+1Не создавайте списки для однопроходных алгоритмов, для этого есть генераторы
Весь смысл статьи. Это ещё и перевод. Не, ну понятно, корпоративный блог кушает деньги, нужно в него что-то писать.
Vlashu
17.08.2022 17:22Всегда опасаюсь, что кто-то через поиск найдет по тексту задачи подобную информацию и перенесет в проект решение не глядя...
Про операции сравнения уже написали выше в комментариях, поэтому вот еще один способ решения задачи, более близкий к исходному варианту + сравнение с функцией из статьи:def foo() -> bool: return 200_000_000 in (range(1_000_000_000)) from dis import dis dis(foo) 2 0 LOAD_CONST 1 (200000000) 2 LOAD_GLOBAL 0 (range) 4 LOAD_CONST 2 (1000000000) 6 CALL_FUNCTION 1 8 COMPARE_OP 6 (in) 10 RETURN_VALUE def find_200_million() -> bool: return any(True for number in range(1_000_000_000) if number == 200_000_000) dis(find_200_million) 2 0 LOAD_GLOBAL 0 (any) 2 LOAD_CONST 1 (<code object <genexpr> at 0x7f3d31d61930, file "<stdin>", line 2>) 4 LOAD_CONST 2 ('find_200_million.<locals>.<genexpr>') 6 MAKE_FUNCTION 0 8 LOAD_GLOBAL 1 (range) 10 LOAD_CONST 3 (1000000000) 12 CALL_FUNCTION 1 14 GET_ITER 16 CALL_FUNCTION 1 18 CALL_FUNCTION 1 20 RETURN_VALUE
WiRus1337
17.08.2022 18:40Показатели неполные! Для корректности нужно смотреть также и на наихудший результат, т.к. для поиска последнего элемента в каком то варианте кода может понадобиться неожиданно больше времени чем в другом варианте кода. Я решил перепроверить.
Ищем значение 200000000 any([number == 200000000 for number in range(1_000_000_000)]) 0:00:45.137719 any([True for number in range(1_000_000_000) if number == 200000000]) 0:00:35.904143 any(True for number in range(1_000_000_000) if number == 200000000) 0:00:07.327660 any(number == 200000000 for number in range(1_000_000_000)) 0:00:10.658496 all(False for number in range(1_000_000_000) if number == 200000000) 0:00:07.024172
Далее решил проверить поиск числа 1.
Ищем значение 1 any([number == 1 for number in range(1_000_000_000)]) 0:00:44.296145 any([True for number in range(1_000_000_000) if number == 1]) 0:00:37.544449 any(True for number in range(1_000_000_000) if number == 1) 0:00:00.000023 any(number == 1 for number in range(1_000_000_000)) 0:00:00.000003 all(False for number in range(1_000_000_000) if number == 1) 0:00:00.000003
Ну и конечно же худший вариант, число 999999999.
Ищем значение 999999999 any([number == 999999999 for number in range(1_000_000_000)]) 0:00:50.238195 any([True for number in range(1_000_000_000) if number == 999999999]) 0:00:37.451774 any(True for number in range(1_000_000_000) if number == 999999999) 0:00:36.485738 any(number == 999999999 for number in range(1_000_000_000)) 0:00:49.075029 all(False for number in range(1_000_000_000) if number == 999999999) 0:00:36.864547
Как видно, результат не такой уж и радостный, но в некоторых случаях лучше чем изначальный вариант :)
AmdY
Не знаю питон, но сомневаюсь, что там для проверки числа нужно генерировать массив на миллион элементов. Почему больше-меньше не используется?
Kwent
Тут в целом больше вопросов, чем ответов. Называем статью "Эффективное использование any и all в Python" и говорим, что выделить память на весь список дороже, чем создать генератор. Ну такое
Keeper13
Интересно, в WunderFund production-код так же пишут?
poctik404
Согласен, авторы просто усложняют жизнь, потому что больше-меньше работает вполне достойно:
При этом, код выполняется менее, чем за 1 секунду...
BareDreamer
На Питоне это записывается проще:
Возможно, автор имел в виду какую-то более сложную задачу, для которой действительно нужна функция any или all.