列表理解过滤 – “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式是一个单一的名称,迭代器是一个内置的列表或字典,它只被评估一次…”你要弄清楚在任何情况下会发生什么给定的情况。