Xudong's Blog

一些让Python代码更快的技巧

Word count: 2.4kReading time: 9 min
2021/11/08

更快的字符串拼接

在你的Python程序中,如果有大量字符串等待处理,字符串拼接可能会成为一个瓶颈。基本上,Python有两种字符串拼接的方式:

  1. 使用join()函数将字符串列表合并为一个字符串。

  2. 使用++=符号将每个单独的字符串添加到一个字符串中。

那么哪一种方式更快呢?
废话少说,让我们为拼接相同字符串定义三个不同的函数:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
mylist = ["Yang", "Zhou", "is", "writing"]

# 使用'+'
def concat_plus():
result = ""
for word in mylist:
result += word + " "
return result

# 使用'join()'
def concat_join():
return " ".join(mylist)

# 直接拼接,不使用列表
def concat_directly():
return "Yang" + "Zhou" + "is" + "writing"

根据你的第一印象,你认为哪个函数是最快的,哪个是最慢的呢?
真实的结果可能会让你惊讶:

1
2
3
4
5
6
7
8
import timeit

print(timeit.timeit(concat_plus, number=10000))
# 0.002738415962085128
print(timeit.timeit(concat_join, number=10000))
# 0.0008482920238748193
print(timeit.timeit(concat_directly, number=10000))
# 0.00021425005979835987

如上所示,对于拼接字符串列表,join()方法比在循环中逐个添加字符串更快。原因很简单,一方面,字符串在Python中是不可变数据,每次+=操作都会导致新字符串的创建和旧字符串的复制,这在计算上是昂贵的。另一方面,.join()方法专门针对连接字符串序列进行了优化。它预先计算了结果字符串的大小,然后一次性构建它,因此避免了在循环中使用+=操作时的开销,因此更快。

然而,在测试中,速度最快的函数是直接拼接字符串字面值。其高速度归功于:

  • Python解释器可以在编译时优化字符串字面值的拼接,将它们转化为一个单一的字符串字面值。这没有循环迭代或函数调用的参与,使其成为非常高效的操作。

  • 由于所有字符串在编译时都是已知的,Python可以非常快速地执行此操作,比运行时在循环中拼接甚至优化过的.join()方法都要快。

总而言之,如果你需要拼接一个字符串列表,选择使用join()而不是+=。如果你想直接拼接字符串,只需使用+即可。

列表创建:使用[]而非list()

创建一个列表并不是什么大问题。常见的两种方式是:

  1. 使用list()函数
  2. 直接使用[]

让我们使用一个简单的代码片段来测试它们的性能:

1
2
3
4
5
6
import timeit

print(timeit.timeit('[]', number=10 ** 7))
# 0.1368238340364769
print(timeit.timeit(list, number=10 ** 7))
# 0.2958830420393497

结果显示,执行list()函数比直接使用[]要慢。这是因为[]是一个字面语法,而list()是一个构造函数调用。毫无疑问,调用函数需要额外的时间。

从相同的逻辑来看,当创建一个字典时,我们也应该使用{}而不是dict()

成员测试:使用集合而非列表

成员测试操作的性能严重依赖于底层的数据结构:

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

large_dataset = range(100000)
search_element = 2077

large_list = list(large_dataset)
large_set = set(large_dataset)

def list_membership_test():
return search_element in large_list

def set_membership_test():
return search_element in large_set

print(timeit.timeit(list_membership_test, number=1000))
# 0.01112208398990333

print(timeit.timeit(set_membership_test, number=1000))
# 3.27499583363533e-05

正如上述代码所演示的,集合中的成员测试比列表中的更快。为什么呢?

在Python列表中,成员测试(element in list)是通过迭代每个元素直到找到所需元素或达到列表末尾来完成的。因此,此操作具有O(n)的时间复杂度。

Python中的集合实现为哈希表。在检查成员资格(element in set)时,Python使用哈希机制,其平均时间复杂度为O(1)。

这里的要点是在编写程序时要仔细考虑底层数据结构。合理利用正确的数据结构可以显著加快我们的代码。

使用推导式替代for循环

在Python中,有四种推导式:列表推导式、字典推导式、集合推导式和生成器表达式。它们不仅提供了更简洁的语法来创建相关的数据结构,而且比使用for循环具有更好的性能,因为它们在Python的C实现中进行了优化。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
import timeit

def generate_squares_for_loop():
squares = []
for i in range(1000):
squares.append(i * i)
return squares

def generate_squares_comprehension():
return [i * i for i in range(1000)]

print(timeit.timeit(generate_squares_for_loop, number=10000))
# 0.2797503340989351

print(timeit.timeit(generate_squares_comprehension, number=10000))
# 0.2364629579242319

上述代码是对列表推导式和for循环进行简单速度比较的示例。结果显示,列表推导式更快。除此之外列表推导式在很多情况下也更为简洁,呈现了Python之美 ;)

优先使用局部变量

在Python中,访问局部变量比访问全局变量或对象属性更快。以下是一个实例来证明这一点:

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

class Example:
def __init__(self):
self.value = 0

obj = Example()

def test_dot_notation():
for _ in range(1000):
obj.value += 1

def test_local_variable():
value = obj.value
for _ in range(1000):
value += 1
obj.value = value

print(timeit.timeit(test_dot_notation, number=1000))
# 0.036605041939765215

print(timeit.timeit(test_local_variable, number=1000))
# 0.024470250005833805

这就是Python的工作原理。直观地说,当函数被编译时,其中的局部变量是已知的,但是其他外部变量需要时间来检索。这里的差异时间可能很少,但在处理大量数据时,我们可以利用它来优化我们的代码。

优先使用内置模块和库

提到Python时,默认指的是CPython。因为CPython是Python语言的默认且最广泛使用的实现。

考虑到它的大多数内置模块和库是用C语言编写的,这是一种更快且更底层的语言,我们应该充分利用这些内置工具,避免重新发明轮子。

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

def count_frequency_custom(lst):
frequency = {}
for item in lst:
if item in frequency:
frequency[item] += 1
else:
frequency[item] = 1
return frequency

def count_frequency_builtin(lst):
return Counter(lst)

large_list = [random.randint(0, 100) for _ in range(1000)]

print(timeit.timeit(lambda: count_frequency_custom(large_list), number=100))
# 0.005160166998393834
print(timeit.timeit(lambda: count_frequency_builtin(large_list), number=100))
# 0.002444291952997446

上述程序比较了两种在列表中计算元素频率的方法。利用collections模块中内置的Counter比自己编写for循环更快、更整洁,也更优秀。

利用缓存装饰器加快函数调用

缓存是一种常用的技术,用于避免重复计算并提高程序速度。在大多数情况下,我们无需自己编写缓存处理代码,因为Python提供了一个开箱即用的装饰器,即@functools.cache

以下是一个执行两个斐波那契数生成函数的示例,其中一个使用了缓存装饰器,而另一个没有:

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

def fibonacci(n):
if n in (0, 1):
return n
return fibonacci(n - 1) + fibonacci(n - 2)

@functools.cache
def fibonacci_cached(n):
if n in (0, 1):
return n
return fibonacci_cached(n - 1) + fibonacci_cached(n - 2)

# 测试每个函数的执行时间
print(timeit.timeit(lambda: fibonacci(30), number=1))
# 0.09499712497927248
print(timeit.timeit(lambda: fibonacci_cached(30), number=1))
# 6.458023563027382e-06

结果证明了functools.cache装饰器如何使我们的代码更快。基本的斐波那契函数效率低下,因为在获取fibonacci(30)结果的过程中多次重新计算相同的斐波那契数。

缓存版本明显更快,因为它缓存了先前计算的结果。因此,它仅计算每个斐波那契数一次,随后使用相同参数调用将从缓存中检索。

但是,需要注意的是,使用cache函数缓存的结果会被保存在内存中,并且是永久性的,因此如果使用cache装饰器缓存了一些不再需要的结果,这些结果就会一直占用内存。为了避免这种情况,可以手动清除缓存。并且cache装饰器只能缓存有限的参数类型,例如基本数据类型、元组和不可变集合等。对于可变对象(如列表和字典)和自定义类对象,需要自行实现缓存机制。

更快的无限循环

在创建无限循环时,我们可以使用while Truewhile 1。它们的性能差异通常微乎其微,但有趣的是while 1稍微更快。

这源于数字1是一个字面常量,而True是Python全局名称,需要在Python的全局范围内查找,因此需要一点点的额外开销。

让我们通过一个代码片段来比较这两种方式的实际性能:

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

def loop_with_true():
i = 0
while True:
if i >= 1000:
break
i += 1

def loop_with_one():
i = 0
while 1:
if i >= 1000:
break
i += 1

print(timeit.timeit(loop_with_true, number=10000))
# 0.1733035419601947
print(timeit.timeit(loop_with_one, number=10000))
# 0.16412191605195403

正如我们所见,while 1确实稍微更快。

然而,现代的Python解释器(如CPython)经过高度优化,这样的差异通常微不足道。因此,我们不需要过于担心这个微小的差异。更不用说,while Truewhile 1更易读。

惰性加载Python模块

在Python脚本的顶部导入所有模块似乎是一种自然的做法。
但实际上,我们并不一定要这样做。
而且,如果一个模块过于庞大,按需导入是一个更好的选择。

1
2
3
def my_function():
import heavy_module
# 函数的其余部分

如上代码所示,heavy_module 是在函数内部导入的。这是一种“惰性加载”的思想,其中导入被延迟到 my_function 被调用的时候。

这种方法的好处在于,如果在脚本执行过程中从未调用 my_function,那么 heavy_module 就不会被加载,从而节省资源并缩短脚本的启动时间。

CATALOG
  1. 1. 更快的字符串拼接
  2. 2. 列表创建:使用[]而非list()
  3. 3. 成员测试:使用集合而非列表
  4. 4. 使用推导式替代for循环
  5. 5. 优先使用局部变量
  6. 6. 优先使用内置模块和库
  7. 7. 利用缓存装饰器加快函数调用
  8. 8. 更快的无限循环
  9. 9. 惰性加载Python模块