列表理解过滤 – “set()陷阱”
一个相当常见的操作是基于另一个list
过滤一个list
。 人们很快发现这一点:
[x for x in list_1 if x in list_2]
对于大的input是很慢的 – 这是O(n * m)。 呸。 我们如何加快速度? 使用一set
进行过滤查找O(1):
s = set(list_2) [x for x in list_1 if x in s]
这给了很好的整体O(n)行为。 我经常看到甚至有资深的编程者陷入了“陷阱”中 :
[x for x in list_1 if x in set(list_2)]
确认! 这又是O(n * m),因为python 每次构buildset(list_2)
,而不仅仅是一次。
我认为这是故事的结尾 – python无法优化它只能build立一次。 只要意识到陷阱。 得住它。 嗯。
#python 3.3.2+ list_2 = list(range(20)) #small for demonstration purposes s = set(list_2) list_1 = list(range(100000)) def f(): return [x for x in list_1 if x in s] def g(): return [x for x in list_1 if x in set(list_2)] def h(): return [x for x in list_1 if x in {0,1,2,3,4,5,6,7,8,9,10,11,12,13,14,15,16,17,18,19}] %timeit f() 100 loops, best of 3: 7.31 ms per loop %timeit g() 10 loops, best of 3: 77.4 ms per loop %timeit h() 100 loops, best of 3: 6.66 ms per loop
呵呵,python(3.3) 可以优化一个字面值。 在这种情况下,它甚至比f()
更快,大概是因为它用LOAD_GLOBAL
取代了LOAD_FAST
。
#python 2.7.5+ %timeit h() 10 loops, best of 3: 72.5 ms per loop
Python 2显然不会做这种优化。 我试着进一步调查python3正在做什么,但不幸的是, dis.dis
无法探究理解expression式的内部。 基本上所有有趣的事情都变成了MAKE_FUNCTION
。
所以,现在我想知道为什么python 3.x优化了设置文字只能build立一次,但没有set(list_2)
?
为了优化set(list_2)
,解释器需要certificatelist_2
(及其所有元素)在迭代之间不会改变。 在一般情况下,这是一个难题,如果口译员甚至没有试图解决这个问题,我也不会感到意外。
另一方面,集合文字在迭代之间不能改变它的值,所以优化是已知的安全的。
从Python 3.2中的新function :
Python的窥视优化器现在可以识别
x in {1, 2, 3}
这样的模式x in {1, 2, 3}
作为对一组常量中成员资格的testing。 优化器重新设置为一个冻结集,并存储预build的常量。
所以,现在我想知道为什么python 3.x优化了设置文字只能build立一次,但没有设置(list_2)?
没有人提到过这个问题:你怎么知道set([1,2,3])
和{1, 2, 3}
set([1,2,3])
{1, 2, 3}
是一样的东西?
>>> import random >>> def set(arg): ... return [random.choice(range(5))] ... >>> list1 = list(range(5)) >>> [x for x in list1 if x in set(list1)] [0, 4] >>> [x for x in list1 if x in set(list1)] [0]
你不能隐藏文字; 你可以影子set
。 所以,在你考虑提升之前,你不仅需要知道list1
没有受到影响,还需要确定set
是你的想法。 有时你可以这样做,无论是在编译时的限制条件还是在运行时更方便,但这绝对是平淡无奇的。
这很有趣:通常当做这样的优化的build议出现时,有一个后顾之处就是尽可能好,这使得更难以推断Python性能会是什么样子,甚至algorithm上。 你的问题提供了这个反对意见的一些证据。
太长的评论
这不会说优化的细节或v2与v3的差异。 但是当我在某些情况下遇到这种情况时,我发现从数据对象中创build一个上下文pipe理器是有用的:
class context_set(set): def __enter__(self): return self def __exit__(self, *args): pass def context_version(): with context_set(list_2) as s: return [x for x in list_1 if x in s]
使用这个我看到:
In [180]: %timeit context_version() 100 loops, best of 3: 17.8 ms per loop
而且在某些情况下,它提供了在理解之前创build对象与在理解之内创build对象之间的一个很好的制止差距,并允许自定义拆除代码(如果需要的话)。
更通用的版本可以使用contextlib.contextmanager
。 这是我的意思是一个快速和肮脏的版本。
def context(some_type): from contextlib import contextmanager generator_apply_type = lambda x: (some_type(y) for y in (x,)) return contextmanager(generator_apply_type)
那么可以这样做:
with context(set)(list_2) as s: # ...
或者一样容易
with context(tuple)(list_2) as t: # ...
基本的原因是一个文字实际上不能改变,而如果它是一个像set(list_2)
这样的expression式,评估目标expression式或理解的迭代可能会改变set(list_2)
的值。 例如,如果你有
[f(x) for x in list_1 if x in set(list_2)]
f
修改list_2
是可能的。
即使是一个简单的[x for x in blah ...]
expression式,理论上可能的是blah
的__iter__
方法可以修改list_2
。
我会想象有一些优化的范围,但目前的行为使事情变得简单。 如果你开始添加优化,如“如果目标expression式是一个单一的名称,迭代器是一个内置的列表或字典,它只被评估一次…”你要弄清楚在任何情况下会发生什么给定的情况。