Вот вам задача: надо проверить, входит ли число 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. Запускается механизм спискового включения, генерирующий список из примерно 1 миллиарда логических значений, указывающих на то, равно ли каждое из чисел 200 миллионам.

  2. Затем запускается функция 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 ценность генераторных выражений в том, что они останавливаются при обнаружении совпадения.

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

  1. Создаётся генератор, который идёт по значениям от 0 до 999,999,999 и выдаёт True для каждого значения, равного 200 миллионам.

  2. Функция any получает генератор и запрашивает из него первое значение.

  3. Генератор начинает проходиться по числам до тех пор, пока не выдаст то, которое соответствует условию равенства 200 миллионам. После этого он выдаёт True. Это значение получает any.

  4. Когда функция 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)


  1. AmdY
    15.08.2022 14:16
    +9

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


    1. Kwent
      15.08.2022 15:18
      +4

      Тут в целом больше вопросов, чем ответов. Называем статью "Эффективное использование any и all в Python" и говорим, что выделить память на весь список дороже, чем создать генератор. Ну такое


      1. Keeper13
        15.08.2022 15:36
        +2

        Интересно, в WunderFund production-код так же пишут?


    1. poctik404
      15.08.2022 15:55

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

      ch = 200000000
      if ch >= 1 and ch <= 1000000000:
          print(True)
      else:
          print(False)

      При этом, код выполняется менее, чем за 1 секунду...


      1. BareDreamer
        15.08.2022 17:26
        +3

        На Питоне это записывается проще:

        print(1 <= ch <= 1000000000)

        Возможно, автор имел в виду какую-то более сложную задачу, для которой действительно нужна функция any или all.


  1. bogolt
    15.08.2022 21:51
    +2

    def find_200_million():
       return True
    


  1. NeoCode
    16.08.2022 09:29

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

    В каких языках они еще есть?

    Является ли само по себе выражение "спискового включения" объектом, или оно сразу "раскрывается" в список (запускается на генерацию)? (здесь я имею в виду аналогию с лямбда-функцией в большинстве языков: само по себе лямбда-выражение - это не вызов функции, а специальный объект, который можно сохранить в переменной; вызов функции происходит только при применении операции вызова "()" )

    Еще приходит в голову аналогия с диапазонными объектами, которые тоже есть в некоторых языках. Выражения вида "10..20" можно интерпретировать как специальные объекты, хранящие два числа, а можно - как полноценные списки всех чисел в заданном диапазоне.


  1. mbait
    16.08.2022 11:44
    +1

    Не создавайте списки для однопроходных алгоритмов, для этого есть генераторы

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


  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
    


  1. 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

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