获得范围(n)对的最快方法



想象一下,您想要处理0到n-1的所有数字对,例如,对于n = 4,它是这六对:

[(0, 1), (0, 2), (0, 3), (1, 2), (1, 3), (2, 3)]

创建这些配对的三种方法:

list(combinations(range(n), 2))
[(i, j) for i, j in combinations(range(n), 2)]
[(i, j) for i in range(n) for j in range(i+1, n)]

n = 1000:的基准结果

44.1 ms ± 0.2 ms  f_combinations_pure
57.7 ms ± 0.3 ms  f_combinations
66.6 ms ± 0.1 ms  f_ranges

注意,我并不是真的只对存储(i, j)感兴趣。这只是ij的一个最小示例用法,因此我们可以在没有太多开销的情况下比较不同的方法。事实上,您希望使用ij一些事情,例如[my_string[i:j] for ...]来获取子字符串(注释启发了这个问题(。所以list(combinations(...))在这里并不重要,我展示它只是为了表明这一点(尽管我仍然喜欢看到它有多快(。

问题1:为什么f_rangesf_combinations慢?它的for i in总共只运行n次,所以与运行n*(n-1)/2次的for j in相比,它微不足道。for j in range(...)只分配一个数字,而for i, j in combinations(...)构建并分配的数字,因此后者应该慢。为什么更快

问题2:你能想出的最快的方法是什么?为了公平比较,它应该是一个列表理解[(i, j) for ...],产生相同的配对列表。

(由于我自己包含了一个答案(这是鼓励的(,所以我在其中包含了基准代码。(

关于问题1:为什么rangecombinations

虽然for j in range(...)确实有只分配一个数字的优势,但它有一个的缺点,那就是一遍又一遍地创建它们。在Python中,数字是对象,它们的创建(和删除(需要一些时间。

另一方面,combinations(...)首先只创建和存储一次数字对象,然后在对中反复使用它们。你可能会认为"请稍等,它可以重用数字,但它将这些对生成为tuple对象,因此每次迭代也创建一个对象">。好它有一个优化。它实际上一次又一次地重用同一个tuple对象,用不同的数字填充它";什么不可能!元组是不可变的">嗯。。。表面上它们是不变的,是的。但是如果combinations迭代器发现没有对其结果元组的其他引用;骗子;并无论如何对其进行修改。在C代码级别,它可以做到这一点。如果没有任何其他参考,那么也没有什么害处。请注意,for i, j in ...对元组进行解包,并且不保留对它的引用。如果使用for pair in ...,则pair是对它的参考,并且不会应用优化,实际上每次都会创建一个新的result元组。如果您感兴趣,请参阅combinations_next的源代码。

关于问题2:最快的方法是什么

我找到了四种更快的方法:

44.1 ms ± 0.2 ms  f_combinations_pure
51.7 ms ± 0.1 ms  f_list
52.7 ms ± 0.2 ms  f_tee
53.6 ms ± 0.1 ms  f_copy_iterator
54.6 ms ± 0.1 ms  f_tuples
57.7 ms ± 0.3 ms  f_combinations
66.6 ms ± 0.1 ms  f_ranges

所有四种更快的方法都避免了range解决方案速度较慢的原因:它们不是创建(和删除(Θ(n²(int对象,而是反复使用相同的对象。

f_tuples将它们放入元组中,并在切片上迭代:

def f_tuples(n):
nums = tuple(range(n))
return [(i, j)
for i in nums
for j in nums[i+1:]]

f_list将它们放入一个列表中,然后在每次j循环之前,它删除第一个数字:

def f_list(n):
js = list(range(n))
return [(i, j)
for i in range(n)
if [js.pop(0)]
for j in js]

f_copy_iterator将它们放入一个元组中,然后为i使用迭代器,为j使用该迭代器的副本(这是一个从i后一个位置开始的迭代器(:

def f_copy_iterator(n):
nums = iter(tuple(range(n)))
return [(i, j)
for i in nums
for j in copy(nums)]

f_tee使用itertools.tee获得与copy类似的效果。它的JSj值的主要迭代器,在每次j循环之前,它丢弃第一个值,然后将JS转换为剩余值的第二个迭代器:

def f_tee(n):
return [(i, j)
for JS in [iter(range(n))]
for i in range(n)
for _, (JS, js) in [(next(JS), tee(JS))]
for j in js]

额外的问题:像那些更快的方法一样优化值得吗

也许不会。也许你最好只用for i, j in combinations(...)。更快的方法并不快多少,而且有点复杂。此外,在现实中,您实际上会使用ij做一些事情(比如获取子字符串(,因此相对较小的速度优势变得相对较小。

但我希望你至少觉得这很有趣,也许有一天会学到一些新的东西,有用的。

完整的基准代码

在线试用!

def f_combinations_pure(n):
return list(combinations(range(n), 2))

def f_combinations(n):
return [(i, j) for i, j in combinations(range(n), 2)]

def f_ranges(n):
return [(i, j) for i in range(n) for j in range(i+1, n)]

def f_tuples(n):
nums = tuple(range(n))
return [(i, j) for i in nums for j in nums[i+1:]]

def f_list(n):
js = list(range(n))
return [(i, j) for i in range(n) if [js.pop(0)] for j in js]

def f_copy_iterator(n):
nums = iter(tuple(range(n)))
return [(i, j) for i in nums for j in copy(nums)]

def f_tee(n):
return [(i, j)
for JS in [iter(range(n))]
for i in range(n)
for _, (JS, js) in [(next(JS), tee(JS))]
for j in js]

fs = [
f_combinations_pure,
f_combinations,
f_ranges,
f_tuples,
f_list,
f_copy_iterator,
f_tee
]
from timeit import default_timer as time
from itertools import combinations, tee
from statistics import mean, stdev
from random import shuffle
from copy import copy
# Correctness
expect = fs[0](1000)
for f in fs:
result = f(1000)
assert result == expect
# Prepare for timing
times = {f: [] for f in fs}
def stats(f):
ts = [t * 1e3 for t in sorted(times[f])[:5]]
return f'{mean(ts):4.1f} ms ± {stdev(ts):3.1f} ms '
# Timing
for i in range(25):
shuffle(fs)
for f in fs:
start = time()
result = f(1000)
end = time()
times[f].append(end - start)
del result
# Results
for f in sorted(fs, key=stats):
print(stats(f), f.__name__)

最新更新