更快的字符串拼接
在你的Python程序中,如果有大量字符串等待处理,字符串拼接可能会成为一个瓶颈。基本上,Python有两种字符串拼接的方式:
-
使用
join()
函数将字符串列表合并为一个字符串。 -
使用
+
或+=
符号将每个单独的字符串添加到一个字符串中。
那么哪一种方式更快呢?
废话少说,让我们为拼接相同字符串定义三个不同的函数:
1 | mylist = ["Yang", "Zhou", "is", "writing"] |
根据你的第一印象,你认为哪个函数是最快的,哪个是最慢的呢?
真实的结果可能会让你惊讶:
1 | import timeit |
如上所示,对于拼接字符串列表,join()
方法比在循环中逐个添加字符串更快。原因很简单,一方面,字符串在Python中是不可变数据,每次+=
操作都会导致新字符串的创建和旧字符串的复制,这在计算上是昂贵的。另一方面,.join()
方法专门针对连接字符串序列进行了优化。它预先计算了结果字符串的大小,然后一次性构建它,因此避免了在循环中使用+=
操作时的开销,因此更快。
然而,在测试中,速度最快的函数是直接拼接字符串字面值。其高速度归功于:
-
Python解释器可以在编译时优化字符串字面值的拼接,将它们转化为一个单一的字符串字面值。这没有循环迭代或函数调用的参与,使其成为非常高效的操作。
-
由于所有字符串在编译时都是已知的,Python可以非常快速地执行此操作,比运行时在循环中拼接甚至优化过的
.join()
方法都要快。
总而言之,如果你需要拼接一个字符串列表,选择使用join()
而不是+=
。如果你想直接拼接字符串,只需使用+
即可。
列表创建:使用[]
而非list()
创建一个列表并不是什么大问题。常见的两种方式是:
- 使用
list()
函数 - 直接使用
[]
让我们使用一个简单的代码片段来测试它们的性能:
1 | import timeit |
结果显示,执行list()
函数比直接使用[]
要慢。这是因为[]
是一个字面语法,而list()
是一个构造函数调用。毫无疑问,调用函数需要额外的时间。
从相同的逻辑来看,当创建一个字典时,我们也应该使用{}
而不是dict()
。
成员测试:使用集合而非列表
成员测试操作的性能严重依赖于底层的数据结构:
1 | import timeit |
正如上述代码所演示的,集合中的成员测试比列表中的更快。为什么呢?
在Python列表中,成员测试(element in list)是通过迭代每个元素直到找到所需元素或达到列表末尾来完成的。因此,此操作具有O(n)的时间复杂度。
Python中的集合实现为哈希表。在检查成员资格(element in set)时,Python使用哈希机制,其平均时间复杂度为O(1)。
这里的要点是在编写程序时要仔细考虑底层数据结构。合理利用正确的数据结构可以显著加快我们的代码。
使用推导式替代for循环
在Python中,有四种推导式:列表推导式、字典推导式、集合推导式和生成器表达式。它们不仅提供了更简洁的语法来创建相关的数据结构,而且比使用for循环具有更好的性能,因为它们在Python的C实现中进行了优化。
1 | import timeit |
上述代码是对列表推导式和for循环进行简单速度比较的示例。结果显示,列表推导式更快。除此之外列表推导式在很多情况下也更为简洁,呈现了Python之美 ;)
优先使用局部变量
在Python中,访问局部变量比访问全局变量或对象属性更快。以下是一个实例来证明这一点:
1 | import timeit |
这就是Python的工作原理。直观地说,当函数被编译时,其中的局部变量是已知的,但是其他外部变量需要时间来检索。这里的差异时间可能很少,但在处理大量数据时,我们可以利用它来优化我们的代码。
优先使用内置模块和库
提到Python时,默认指的是CPython。因为CPython是Python语言的默认且最广泛使用的实现。
考虑到它的大多数内置模块和库是用C语言编写的,这是一种更快且更底层的语言,我们应该充分利用这些内置工具,避免重新发明轮子。
1 | import timeit |
上述程序比较了两种在列表中计算元素频率的方法。利用collections模块中内置的Counter比自己编写for循环更快、更整洁,也更优秀。
利用缓存装饰器加快函数调用
缓存是一种常用的技术,用于避免重复计算并提高程序速度。在大多数情况下,我们无需自己编写缓存处理代码,因为Python提供了一个开箱即用的装饰器,即@functools.cache
。
以下是一个执行两个斐波那契数生成函数的示例,其中一个使用了缓存装饰器,而另一个没有:
1 | import timeit |
结果证明了functools.cache
装饰器如何使我们的代码更快。基本的斐波那契函数效率低下,因为在获取fibonacci(30)
结果的过程中多次重新计算相同的斐波那契数。
缓存版本明显更快,因为它缓存了先前计算的结果。因此,它仅计算每个斐波那契数一次,随后使用相同参数调用将从缓存中检索。
但是,需要注意的是,使用cache函数缓存的结果会被保存在内存中,并且是永久性的,因此如果使用cache装饰器缓存了一些不再需要的结果,这些结果就会一直占用内存。为了避免这种情况,可以手动清除缓存。并且cache装饰器只能缓存有限的参数类型,例如基本数据类型、元组和不可变集合等。对于可变对象(如列表和字典)和自定义类对象,需要自行实现缓存机制。
更快的无限循环
在创建无限循环时,我们可以使用while True
或while 1
。它们的性能差异通常微乎其微,但有趣的是while 1
稍微更快。
这源于数字1是一个字面常量,而True是Python全局名称,需要在Python的全局范围内查找,因此需要一点点的额外开销。
让我们通过一个代码片段来比较这两种方式的实际性能:
1 | import timeit |
正如我们所见,while 1
确实稍微更快。
然而,现代的Python解释器(如CPython)经过高度优化,这样的差异通常微不足道。因此,我们不需要过于担心这个微小的差异。更不用说,while True
比while 1
更易读。
惰性加载Python模块
在Python脚本的顶部导入所有模块似乎是一种自然的做法。
但实际上,我们并不一定要这样做。
而且,如果一个模块过于庞大,按需导入是一个更好的选择。
1 | def my_function(): |
如上代码所示,heavy_module 是在函数内部导入的。这是一种“惰性加载”的思想,其中导入被延迟到 my_function 被调用的时候。
这种方法的好处在于,如果在脚本执行过程中从未调用 my_function,那么 heavy_module 就不会被加载,从而节省资源并缩短脚本的启动时间。