python性能优化(1)

列表操作

同样造一个一万元素的列表:列表推导式最快,append 循环只慢一点点,用 + 一路拼接则慢出一个量级。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
import timeit

def way1(max1):
mylist = []
for i in range(max1):
mylist.append(i*i)

def way2(max1):
mylist = [i*i for i in range(max1)]

def way3(max1):
mylist = []
for i in range(max1):
mylist = mylist + [i*i]

if __name__ == '__main__':
t1 = timeit.Timer("way1(10000)","from __main__ import way1")
t2 = timeit.Timer("way2(10000)","from __main__ import way2")
t3 = timeit.Timer("way3(10000)","from __main__ import way3")
print(t1.timeit(number=1000)) # 0.214 append 循环,平均 0.214 ms 一次
print(t2.timeit(number=1000)) # 0.202 列表推导式,平均 0.202 ms 一次
print(t3.timeit(number=1000)) # 102.995 列表相加,平均 103 ms 一次

前两者的差距其实很小,列表推导式只比 append 循环快 6% 左右,省掉的是每轮一次属性查找和方法调用,把它当成”数量级优化”就夸大了。真正拉开量级的是第三种写法:mylist + [i*i] 每次都新建一个列表、把已有元素整体复制一遍,整体是 O(n²),一万个元素就慢了 500 倍。

搜索成员时

set比list快。 列表O(n), 集合O(1).

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
import timeit

def way1(max1):
mylist = [i*i for i in range(max1)]
n = 0
for i in range(10000):
if i in mylist:
n += 1
else:
n += i*2
return n


def way2(max1):
myset = {i*i for i in range(max1)}
n = 0
for i in range(10000):
if i in myset:
n += 1
else:
n += i*2
return n

if __name__ == '__main__':
t1 = timeit.Timer("way1(1000)","from __main__ import way1")
t2 = timeit.Timer("way2(1000)","from __main__ import way2")
print(t1.timeit(number=10)) # 1.479847
print(t2.timeit(number=10)) # 0.056411800000000234

if 放在 for循环外面

如果判断条件跟循环变量无关,放在循环里就是每轮都白算一次。把它提到循环外面,只判断一次:

1
2
3
4
5
6
7
8
9
10
11
12
# 慢:条件与 i 无关,却判断了 n 次
for i in range(n):
if mode == 'double':
result.append(i * 2)
else:
result.append(i)

# 快:判断一次,选定分支再循环
if mode == 'double':
result = [i * 2 for i in range(n)]
else:
result = list(range(n))

巧用 try/except

Python 里 try不抛异常时几乎没有开销,而每次都先判断反而要多做一次查找。所以”先做,出错再处理”(EAFP)通常比”先检查再做”(LBYL)快:

1
2
3
4
5
6
7
8
9
10
11
# LBYL:每次都查一遍 key 在不在
if key in d:
v = d[key]
else:
v = 0

# EAFP:命中率高时更快
try:
v = d[key]
except KeyError:
v = 0

前提是异常确实少见。如果十次里有八次都要抛异常,反过来会更慢——抛异常本身是有代价的。差距有多大值得实测一下(200 万次,CPython 3.14):

写法 键命中 键缺失
try/except 0.019s 0.159s
if key in d 0.036s 0.022s
d.get(key, 0) 0.032s 0.035s

命中时 try/except 确实最快,但一旦键缺失就暴涨到 8 倍——而 d.get() 两头都稳。所以 dict 取默认值这个具体场景,最该用的既不是 LBYL 也不是 EAFP,而是被这两个词遮住的第三个选项:

1
v = d.get(key, 0)          # 不用判断,也不用抛异常

要往 dict 里累加就用 d[key] = d.get(key, 0) + 1,或者干脆上 collections.defaultdict / Counter

顺带把”try 几乎没有开销”这句话的依据补上:Python 3.11 引入了零成本异常(zero-cost exception),把 SETUP_FINALLY 这类运行时指令换成了编译期生成的异常表——不抛异常时 try 块真的一条额外指令都不执行。这也意味着这条建议在 3.10 及更早的版本上并不那么成立,那时候进 try 是要付指令的。

神器 collections

标准库 collections 里的容器并不都是 C 实现的:dequedefaultdictOrderedDict 来自 C 扩展 _collections,而 CounterChainMapUserDict/UserList/UserStringnamedtuple 都是纯 Python 写的。不过纯 Python 的那几个照样值得用——语义现成、代码更短,该借 C 的热点也借了:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
from collections import deque, defaultdict, Counter

# deque:两端进出都是 O(1);list.insert(0, x) 是 O(n)
q = deque()
q.appendleft(1) # 头部插入不用整体搬移
q.popleft()

# defaultdict:省掉"键在不在"的判断
groups = defaultdict(list)
for name, kind in items:
groups[kind].append(name) # 不用先 setdefault

# Counter:计数与取前 N,不用手写字典累加再排序
c = Counter(words)
c.most_common(3)

Counter 就是上面说的纯 Python 类,但它的计数热点走的是 C 辅助函数 _collections._count_elements,所以同样一份数据交给它数,还是比手写”先判断键在不在再累加”的 dict 循环快一点(实测 0.134 s vs 0.163 s)。

list 当队列时尤其要注意:pop(0)insert(0, x) 都会移动整个列表,数据量一大就成瓶颈,换 deque 即可。