为什么在 Python 3 中 "1000000000000000 in range(1000000000000001)" 运行得如此之快?

25 浏览
0 Comments

为什么在 Python 3 中 "1000000000000000 in range(1000000000000001)" 运行得如此之快?

据我所知,range()函数实际上是Python 3中的一种对象类型,它会即时生成其内容,类似于生成器。

既然如此,我本来期望以下这行代码需要消耗很长时间,因为为了确定1千万亿是否在范围内,必须生成1千万亿个值:

1_000_000_000_000_000 in range(1_000_000_000_000_001)

此外,无论我添加多少个零,计算时间似乎都差不多(基本瞬间完成)。

我也尝试过类似于这样的操作,但计算仍然几乎是瞬间完成:

# count by tens
1_000_000_000_000_000_000_000 in range(0,1_000_000_000_000_000_000_001,10)

如果我尝试实现自己的range函数,结果就不那么好了!

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

range()对象在内部做了什么使其运行速度如此之快?


选择Martijn Pieters的回答是因为其全面性,但也可以参考abarnert的第一个回答,讨论了Python 3中的range是一个完整的序列意味着什么,以及有关__contains__函数优化在Python实现之间可能存在的潜在不一致性的一些信息/警告。abarnert的另一个回答详细介绍了Python 3中优化的背后历史,并提供了有关Python 2中xrange的优化不足的链接。回答by pokeby wim为有兴趣的人提供了相关的C源代码和解释。

admin 更改状态以发布 2023年5月24日
0
0 Comments

这里的根本误解是认为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

>>> a.index(3)   # implements 'index'
3
>>> a.count(3)   # implements 'count'
1


rangelist的区别在于,range是一个懒惰或动态的序列;它不记住所有的值,只记住它的startstopstep,并在__getitem__上按需创建值。

(顺便说一句,如果你print(iter(a)),你会注意到range使用与list相同的listiterator类型。这是怎么回事?listiterator没有使用list的任何特殊功能,除了它提供了__getitem__的C实现,所以它对于range也可以正常工作。)


现在,没有什么规定说Sequence.__contains__必须是常数时间的 - 实际上,对于像list这样的序列明显不是常数时间的。但是没有什么规定说它不能是。比起实际生成并测试所有值,更容易实现range.__contains__只是数学上检查它((val - start) % step,但需要一些额外的复杂性来处理负步数),那么它为什么不应该用更好的方法呢?

但是似乎语言中没有任何保证这将发生的东西。正如Ashwini Chaudhari指出的,如果你给它一个非整数值,它将会退回到迭代所有值并一个个比较它们的方式,而不是将其转换为整数并进行数学测试。只是因为CPython 3.2+和PyPy 3.x版本碰巧包含这个优化,并且这是一个明显的好主意且易于实现,没有理由IronPython或NewKickAssPython 3.x不能省略它。(事实上,CPython 3.0-3.1没有包含它。)


如果range实际上是像my_crappy_range这样的生成器,那么用这种方式测试__contains__就没有意义,或者至少它的意义不会很明显。如果你已经迭代了前3个值,那么1仍然在生成器中吗?测试1是否会导致它迭代并消耗所有值,直到1(或者大于等于1的第一个值)?

0
0 Comments

Python 3中的range()对象不会立即产生数字;它是一个智能的序列对象,按需产生数字。它只包含您的开始、停止和步长值,然后在迭代对象时每次迭代都会计算下一个整数。

该对象还实现了object.__contains__钩子,并计算您的数字是否是其范围的一部分。计算是一个(几乎)恒定时间操作*。从不需要扫描范围内所有可能的整数。

来自range()对象文档

range类型相对于普通的listtuple的优点在于,无论它所代表的范围有多大(它仅存储startstopstep值,根据需要计算单个项和子范围),range对象始终占用相同(很小的)内存量。

所以,至少你的range()对象会执行以下操作:

class my_range:
    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('my_range object index out of range')
    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问题


* 因为Python整数是无界的,所以随着N的增长,数学运算也会增长时间,使得这成为O(log N)的操作。由于它是在优化的C代码中执行的,并且Python将整数值存储在30位块中,因此在此处涉及的整数的大小方面,您会在看到任何性能影响之前耗尽内存。

0