Почему "1000000000000000 в диапазоне (1000000000000001)" так быстро в Python 3?

Я понимаю, что функция range(), которая на самом деле тип объекта в Python 3, генерирует ее содержимое на лету, подобно генератору.

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

1000000000000000 in range(1000000000000001)

Кроме того: кажется, что независимо от того, сколько нулей я добавляю, расчет более или менее занимает одинаковое количество времени (в основном мгновенное).

Я также пробовал такие вещи, но расчет по-прежнему почти мгновен:

1000000000000000000000 in range(0,1000000000000000000001,10) # count by tens

Если я попытаюсь реализовать свою собственную функцию диапазона, результат не так хорош!!

def my_crappy_range(N):
    i = 0
    while i < N:
        yield i
        i += 1
    return

Что делает объект range(), созданный под капотом, что делает его настолько быстрым?


Ответ Martijn Pieters был выбран за его полноту, но также см. ответить на первый ответ за хороший обсуждение того, что означает range как полноценную последовательность в Python 3, и некоторую информацию/предупреждение о потенциальной несогласованности для оптимизации функций __contains__ в реализациях Python. abarnert other answer идет более подробно и содержит ссылки для тех, кто интересуется историей оптимизации в Python 3 (и отсутствием оптимизации xrange в Python 2), Ответы poke и с помощью wim предоставляют соответствующий исходный код C и объяснения для тех, кто заинтересован.

1249
задан Rick Teachey 06 мая '15 в 18:32
источник поделиться
7 ответов

Объект Python 3 range() не генерирует числа немедленно; это интеллектуальный объект последовательности, который производит числа по требованию. Все, что он содержит, это ваши значения начала, остановки и шага, а затем, когда вы перебираете объект, вычисляется следующее целое число каждой итерации.

Объект также реализует object.__contains__ hook и вычисляет, является ли ваш номер частью его диапазона. Вычисление представляет собой операцию постоянного времени O (1). Нет необходимости проверять все возможные целые числа в диапазоне.

В документации range():

Преимущество типа range над регулярным list или tuple заключается в том, что объект диапазона всегда будет принимать тот же (малый) объем памяти, независимо от размера диапазона, который он представляет (поскольку он только сохраняет значения start, stop и step, вычисляя отдельные элементы и поддиапазоны по мере необходимости).

Итак, как минимум, ваш объект range():

class my_range(object):
    def __init__(self, start, stop=None, step=1):
        if stop is None:
            start, stop = 0, start
        self.start, self.stop, self.step = start, stop, step
        if step < 0:
            lo, hi = stop, start
        else:
            lo, hi = start, stop
        self.length = ((hi - lo - 1) // abs(step)) + 1

    def __iter__(self):
        current = self.start
        if self.step < 0:
            while current > self.stop:
                yield current
                current += self.step
        else:
            while current < self.stop:
                yield current
                current += self.step

    def __len__(self):
        return self.length

    def __getitem__(self, i):
        if i < 0:
            i += self.length
        if 0 <= i < self.length:
            return self.start + i * self.step
        raise IndexError('Index out of range: {}'.format(i))

    def __contains__(self, num):
        if self.step < 0:
            if not (self.stop < num <= self.start):
                return False
        else:
            if not (self.start <= num < self.stop):
                return False
        return (num - self.start) % self.step == 0

Все еще не хватает нескольких вещей, которые поддерживает реальный range() (например, методы .index() или .count(), хэширование, тестирование равенства или нарезка), но должны дать вам представление.

Я также упростил реализацию __contains__, чтобы сосредоточиться только на целых тестах; если вы даете реальному объекту range() нецелое значение (включая подклассы int), запускается медленное сканирование, чтобы увидеть, есть ли совпадение, точно так же, как если бы вы использовали тест сдерживания против списка всех содержащиеся значения. Это было сделано для продолжения поддержки других числовых типов, которые, как оказалось, поддерживали тестирование равенства с помощью целых чисел, но не ожидали поддержки целочисленной арифметики. См. Исходную проблему Python, которая реализовала тест сдерживания.

1226
ответ дан Martijn Pieters 06 мая '15 в 18:33
источник поделиться

Основное недоразумение здесь состоит в том, что range является генератором. Не это. На самом деле это не какой-то итератор.

Вы можете сказать это довольно легко:

>>> a = range(5)
>>> print(list(a))
[0, 1, 2, 3, 4]
>>> print(list(a))
[0, 1, 2, 3, 4]

Если бы это был генератор, итерация его однажды исчерпала бы его:

>>> b = my_crappy_range(5)
>>> print(list(b))
[0, 1, 2, 3, 4]
>>> print(list(b))
[]

На самом деле range есть последовательность, как и список. Вы можете даже проверить это:

>>> import collections.abc
>>> isinstance(a, collections.abc.Sequence)
True

Это означает, что он должен следовать всем правилам последовательности:

>>> a[3]         # indexable
3
>>> len(a)       # sized
5
>>> 3 in a       # membership
True
>>> reversed(a)  # reversible
<range_iterator at 0x101cd2360>
>>> a.index(3)   # implements 'index'
3
>>> a.count(3)   # implements 'count'
1

Разница между range и a list заключается в том, что a range является ленивой или динамической последовательностью; он не запоминает всех своих значений, он просто запоминает свои start, stop и step и создает значения по запросу на __getitem__.

(Как примечание, если вы print(iter(a)), вы заметите, что range использует тот же тип listiterator как list. Как это работает? A listiterator не использует ничего специального about list за исключением того факта, что он обеспечивает реализацию C __getitem__, поэтому он отлично работает и для range.)


Теперь нет ничего, что говорит о том, что Sequence.__contains__ должно быть постоянным временем - фактически, для очевидных примеров таких последовательностей, как list, это не так. Но нет ничего, что говорило бы, что этого не может быть. И проще реализовать range.__contains__, чтобы просто проверить его математически ((val - start) % step, но с некоторой дополнительной сложностью справиться с отрицательными шагами), чем фактически генерировать и тестировать все значения, поэтому почему бы не сделать это лучше

Но на языке, похоже, ничего не происходит, гарантируя, что это произойдет. Как указывает Ашвини Чаудхари, если вы даете ему нецелое значение, вместо того, чтобы преобразовывать его в целое и выполнять математический тест, он будет возвращаться к итерации всех значений и их сопоставлению по одному. И только потому, что версии CPython 3.2+ и PyPy 3.x содержат эту оптимизацию, и это очевидная хорошая идея и ее легко сделать, нет никаких причин, по которым IronPython или NewKickAssPython 3.x не могли бы это исключить. (И на самом деле CPython 3.0-3.1 не включил его.)


Если range фактически был генератором, например my_crappy_range, тогда было бы бессмысленно тестировать __contains__ таким образом, или, по крайней мере, так, как это имеет смысл, не было бы очевидно. Если вы уже повторили первые 3 значения, это 1 еще in генератор? Если тестирование для 1 заставляет его итерировать и потреблять все значения до 1 (или до первого значения >= 1)?

512
ответ дан abarnert 06 мая '15 в 19:01
источник поделиться

Используйте источник, Luke!

В CPython range(...).__contains__ (обертка метода) в конечном итоге делегирует простой расчет, который проверяет, может ли значение быть в диапазоне. Причиной скорости здесь является использование математических рассуждений о границах, а не прямая итерация объекта диапазона. Чтобы объяснить используемую логику:

  • Убедитесь, что номер находится между start и stop, и
  • Убедитесь, что значение шага не "перешагивает" наш номер.

Например, 994 находится в range(4, 1000, 2), потому что:

  • 4 <= 994 < 1000 и
  • (994 - 4) % 2 == 0.

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

static int
range_contains_long(rangeobject *r, PyObject *ob)
{
    int cmp1, cmp2, cmp3;
    PyObject *tmp1 = NULL;
    PyObject *tmp2 = NULL;
    PyObject *zero = NULL;
    int result = -1;

    zero = PyLong_FromLong(0);
    if (zero == NULL) /* MemoryError in int(0) */
        goto end;

    /* Check if the value can possibly be in the range. */

    cmp1 = PyObject_RichCompareBool(r->step, zero, Py_GT);
    if (cmp1 == -1)
        goto end;
    if (cmp1 == 1) { /* positive steps: start <= ob < stop */
        cmp2 = PyObject_RichCompareBool(r->start, ob, Py_LE);
        cmp3 = PyObject_RichCompareBool(ob, r->stop, Py_LT);
    }
    else { /* negative steps: stop < ob <= start */
        cmp2 = PyObject_RichCompareBool(ob, r->start, Py_LE);
        cmp3 = PyObject_RichCompareBool(r->stop, ob, Py_LT);
    }

    if (cmp2 == -1 || cmp3 == -1) /* TypeError */
        goto end;
    if (cmp2 == 0 || cmp3 == 0) { /* ob outside of range */
        result = 0;
        goto end;
    }

    /* Check that the stride does not invalidate ob membership. */
    tmp1 = PyNumber_Subtract(ob, r->start);
    if (tmp1 == NULL)
        goto end;
    tmp2 = PyNumber_Remainder(tmp1, r->step);
    if (tmp2 == NULL)
        goto end;
    /* result = ((int(ob) - start) % step) == 0 */
    result = PyObject_RichCompareBool(tmp2, zero, Py_EQ);
  end:
    Py_XDECREF(tmp1);
    Py_XDECREF(tmp2);
    Py_XDECREF(zero);
    return result;
}

static int
range_contains(rangeobject *r, PyObject *ob)
{
    if (PyLong_CheckExact(ob) || PyBool_Check(ob))
        return range_contains_long(r, ob);

    return (int)_PySequence_IterSearch((PyObject*)r, ob,
                                       PY_ITERSEARCH_CONTAINS);
}

"Мяч" идеи упоминается в в строке:

/* result = ((int(ob) - start) % step) == 0 */ 

В качестве окончательной заметки - посмотрите на функцию range_contains в нижней части фрагмента кода. Если точная проверка типа не выполняется, мы не используем описанный умный алгоритм, вместо этого возвращаемся к немому поиску итерации диапазона с помощью _PySequence_IterSearch! Вы можете проверить это поведение в интерпретаторе (здесь я использую v3.5.0):

>>> x, r = 1000000000000000, range(1000000000000001)
>>> class MyInt(int):
...     pass
... 
>>> x_ = MyInt(x)
>>> x in r  # calculates immediately :) 
True
>>> x_ in r  # iterates for ages.. :( 
^\Quit (core dumped)
266
ответ дан wim 06 мая '15 в 18:41
источник поделиться

Чтобы добавить к Martijns ответ, это важная часть источника (в C, поскольку объект диапазона написан в собственном коде):

static int
range_contains(rangeobject *r, PyObject *ob)
{
    if (PyLong_CheckExact(ob) || PyBool_Check(ob))
        return range_contains_long(r, ob);

    return (int)_PySequence_IterSearch((PyObject*)r, ob,
                                       PY_ITERSEARCH_CONTAINS);
}

Итак, для объектов PyLong (который находится int в Python 3), он будет использовать функцию range_contains_long для определения результата. И эта функция по существу проверяет, находится ли ob в указанном диапазоне (хотя в C он немного сложнее).

Если это не объект int, он возвращается к итерации до тех пор, пока не найдет значение (или нет).

Вся логика может быть переведена на псевдо-Python следующим образом:

def range_contains (rangeObj, obj):
    if isinstance(obj, int):
        return range_contains_long(rangeObj, obj)

    # default logic by iterating
    return any(obj == x for x in rangeObj)

def range_contains_long (r, num):
    if r.step > 0:
        # positive step: r.start <= num < r.stop
        cmp2 = r.start <= num
        cmp3 = num < r.stop
    else:
        # negative step: r.start >= num > r.stop
        cmp2 = num <= r.start
        cmp3 = r.stop < num

    # outside of the range boundaries
    if not cmp2 or not cmp3:
        return False

    # num must be on a valid step inside the boundaries
    return (num - r.start) % r.step == 0
98
ответ дан poke 06 мая '15 в 18:41
источник поделиться

Если вам интересно, почему эта оптимизация была добавлена ​​в range.__contains__ и почему она не была добавлена ​​в xrange.__contains__ в версии 2.7:

Во-первых, как обнаружил Ashwini Chaudhary, вопрос 1766304 был явно открыт для оптимизации [x]range.__contains__. Патч для этого был принят и проверен для 3.2, но не был обращен к 2.7, потому что "xrange вел себя так вот так долго, что я не видят, что он покупает нас, чтобы совершить патч в последнее время". (2,7 почти не было в этой точке.)

Тем:

Первоначально xrange был объектом не-последовательности. Как 3.1 docs говорят:

Объекты Range имеют очень малое поведение: они поддерживают только индексирование, итерацию и функцию len.

Это было не совсем так; объект xrange фактически поддерживал несколько других вещей, которые автоматически появляются с индексацией и len, * включая __contains__ (через линейный поиск). Но никто не думал, что стоит сделать их полными последовательностями в то время.

Затем, в рамках реализации абстрактных базовых классов PEP, важно выяснить, какие встроенные типы должны быть отмечены как реализация ABC, и xrange/range утверждал, что реализует collections.Sequence, хотя он все еще обрабатывает одно и то же "очень малое поведение". Никто не заметил эту проблему до issue 9213. Патч для этой проблемы не только добавил index и count в 3.2 range, но и переработал оптимизированный __contains__ (который разделяет ту же математику с index и напрямую используется count) **Это изменение продолжалось и для 3.2, и не было обращено к 2.x, потому что "это исправление, добавляющее новые методы". (В этот момент 2.7 уже прошел статус rc.)

Итак, было две возможности, чтобы эта оптимизация была обращена к 2.7, но оба они были отклонены.


* На самом деле, вы даже получаете итерацию бесплатно с len и индексированием, но в 2.3 xrange объекты, полученные пользовательский итератор. Затем они потеряли в 3.x, который использует тот же тип listiterator как list.

** Первая версия фактически переопределила его и неправильно указала детали - например, это даст вам MyIntSubclass(2) in range(5) == False. Но Даниэль Штуцбах обновил версию исправления, восстановил большую часть предыдущего кода, включая резерв общего, медленного _PySequence_IterSearch, который pre-3.2 range.__contains__ неявно использовал, когда оптимизация не применяется.

75
ответ дан abarnert 07 мая '15 в 0:42
источник поделиться

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

>>> r = range(5)
>>> for i in r:
        print(i, 2 in r, list(r))

0 True [0, 1, 2, 3, 4]
1 True [0, 1, 2, 3, 4]
2 True [0, 1, 2, 3, 4]
3 True [0, 1, 2, 3, 4]
4 True [0, 1, 2, 3, 4]

Как вы можете видеть, объект диапазона - это объект, который помнит свой диапазон и может использоваться много раз (даже при итерации по нему), а не только одноразовый генератор.

33
ответ дан Stefan Pochmann 06 мая '15 в 19:04
источник поделиться

Все о ленивом подходе к оценке и некоторой дополнительной оптимизации range. Значения в диапазонах не должны вычисляться до реального использования или даже из-за дополнительной оптимизации.

Кстати, ваше целое число не такое большое, рассмотрим sys.maxsize

sys.maxsize in range(sys.maxsize) довольно быстро

благодаря оптимизации - легко сравнить заданное целое число с минимальным и максимальным диапазоном.

а

float(sys.maxsize) in range(sys.maxsize) выполняется довольно медленно.

(в этом случае оптимизация отсутствует в range, так как после получения неожиданного float python будет сравнивать все числа)

1
ответ дан Sławomir Lenart 16 марта '18 в 13:47
источник поделиться

Другие вопросы по меткам