Почему "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 и объяснения для тех, кто заинтересован.
Ответы
Ответ 1
Объект Python 3 range()
не генерирует числа сразу; это умный объект последовательности, который производит числа по требованию. Все, что он содержит, - это ваши значения start, stop и step, а затем при выполнении итерации по объекту вычисляется следующее целое число на каждой итерации.
Объект также реализует ловушку object.__contains__
и вычисляет, является ли ваше число частью его диапазона. Расчет - это операция 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, step = stop, start, -step
else:
lo, hi = start, stop
self.length = 0 if lo > hi else ((hi - lo - 1) // 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, в которой реализован тест на содержание.
Ответ 2
Основное недоразумение здесь состоит в том, что 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
)?
Ответ 3
Используйте источник, 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)
Ответ 4
Чтобы добавить к 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
Ответ 5
Если вам интересно, почему эта оптимизация была добавлена в range.__contains__
, и почему она не была добавлена в xrange.__contains__
в 2.7:
Во-первых, как обнаружила Эшвини Чаудхари, выпуск 1766304 был открыт явно для оптимизации [x]range.__contains__
. Патч для этого был принят и зарегистрирован для версии 3.2, но не перенесен в версию 2.7, потому что xrange вел себя так долго, что я не вижу, что он покупает, чтобы зафиксировать этот патч поздно ". (2.7 было почти в тот момент.)
В то же время:
Первоначально, xrange
был не совсем последовательным объектом. Как сказано в документах 3.1 :
Объекты диапазона имеют очень небольшое поведение: они поддерживают только индексирование, итерацию и функцию len
.
Это было не совсем так; объект xrange
на самом деле поддерживает несколько других вещей, которые приходят автоматически с индексированием и len
, *, включая __contains__
(через линейный поиск). Но никто не думал, что в то время стоило делать их полными последовательностями.
Затем, в рамках реализации абстрактных базовых классов PEP, было важно выяснить, какие встроенные типы должны быть помечены как реализующие, какие ABC, и xrange
/range
заявили, что реализуют collections.Sequence
, даже при том, что это все еще обрабатывало то же самое "очень маленькое поведение". Никто не заметил этой проблемы до выпуска 9213. Патч для этой проблемы не только добавил index
и count
к 3.2 range
, он также переработал оптимизированный __contains__
(который использует ту же математику с index
, и непосредственно используется count
). ** Это изменение также коснулось 3.2 и не было перенесено в 2.x, потому что "это исправление, которое добавляет новые методы". (На данный момент, 2.7 уже прошел статус rc.)
Таким образом, было две возможности вернуть эту оптимизацию в 2.7, но оба они были отклонены.
* На самом деле, вы даже получаете итерацию бесплатно только с индексированием, но в 2.3 объекты xrange
получили собственный итератор.
** Первая версия фактически реализовала его и неправильно указала детали - например, она дала бы вам MyIntSubclass(2) in range(5) == False
. Но обновленная версия патча Даниэля Штутцбаха восстановила большую часть предыдущего кода, включая откат к универсальному медленному _PySequence_IterSearch
, который до 3.2 range.__contains__
неявно использовался, когда оптимизация не применяется.
Ответ 6
Другие ответы объяснили это хорошо уже, но я хотел бы предложить еще один эксперимент, иллюстрирующий характер объектов диапазона:
>>> 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]
Как вы можете видеть, объект диапазона - это объект, который помнит свой диапазон и может использоваться много раз (даже при итерации по нему), а не только одноразовый генератор.
Ответ 7
Это все о ленивом подходе к оценке и некоторой дополнительной оптимизации из range
.
Значения в диапазонах не нужно вычислять до реального использования или даже из-за дополнительной оптимизации.
Кстати, ваше целое число не такое большое, рассмотрим sys.maxsize
sys.maxsize in range(sys.maxsize)
довольно быстрый
из-за оптимизации - легко сравнить данное целое число только с минимальным и максимальным диапазоном.
но:
float(sys.maxsize) in range(sys.maxsize)
довольно медленный.
(в этом случае оптимизация в range
отсутствует, поэтому, если python получает неожиданное значение с плавающей запятой, python сравнивает все числа)
Вы должны знать о деталях реализации, но на них не следует полагаться, потому что это может измениться в будущем.
Ответ 8
Вот реализация в C#
. Вы можете увидеть, как Contains
работает за O (1) раз.
public struct Range
{
private readonly int _start;
private readonly int _stop;
private readonly int _step;
// other members omitted for brevity
public bool Contains(int number)
{
// precheck - if the number is not in a valid step point, return false
// for example, if start=5, step=10, stop=1000, it is obvious that 163 is not in this range (due to remainder)
if ((_start % _step + _step) % _step != (number % _step + _step) % _step)
return false;
// with the help of step sign, we can check borders in linear manner
int s = Math.Sign(_step);
// no need if/else to handle both cases - negative and positive step
return number * s >= _start * s && number * s < _stop * s;
}
}
Ответ 9
TL; DR
Объект, возвращаемый range()
, на самом деле является объектом range
. Этот объект реализует интерфейс итератора, так что вы можете последовательно выполнять его значения, как генератор, но он также реализует интерфейс __contains__
, который фактически вызывается, когда объект появляется справа оператора in
. Метод __contains__()
возвращает логическое значение того, находится ли элемент в объекте. Так как объекты range
знают свои границы и шаг, это очень легко реализовать в O (1).