前言
先把这个标题里的说法处理掉:标题里那个倍数承诺是没有依据的口号。代码能优化到什么程度,取决于你的程序把时间花在哪里、数据规模多大、瓶颈是计算还是 I/O。同一个技巧,在 A 的程序里效果明显,在 B 的程序里可能几乎看不出来。任何声称「用了某招就固定提速若干」的说法都不可信,本文不会复述这个倍数,也不会给出任何替代的数字。
那本文讲什么?讲可迁移的优化手段和它们背后的原理。你学会了原理,就能判断某个改动在你的代码里值不值得做,再用timeit量出真实效果。
底线约定:涉及快慢,本文一律只讲原理与复杂度,不下性能结论——具体差异取决于场景,请用timeit自行测量。并且声明:本机没有 Python 解释器,文中代码无法在本环境运行,均按官方文档整理,请以官方文档为准;本文不声称任何代码被实际执行过。
一、优化前先做的事
测出瓶颈
优化的第一原则是「不要猜」。用标准库的timeit模块,把候选写法放在同样的数据上比较。
# 适用于 Python 3.8+
import timeit
setup = "values = list(range(1000))"
# timeit.timeit(stmt, setup, number) 返回执行 number 次的总秒数
a = timeit.timeit("out = [v * 2 for v in values]", setup=setup, number=1000)
b = timeit.timeit(
"out = []\nfor v in values:\n out.append(v * 2)",
setup=setup,
number=1000,
)
print(a, b)timeit会关闭垃圾回收的干扰、重复多次取总和,比你自己用time.time()前后相减要可靠。要观察波动,用timeit.repeat。
判断瓶颈类型
- 纯计算的循环 → 看算法与数据结构,看能不能下沉到 C 实现。
- 大量等待网络或文件 → 看并发模型,而不是抠语法。
- 内存吃紧 → 看是不是构造了不必要的大对象。
方向选错,后面怎么改都是白费。
二、减少重复计算与重复查找
把循环不变量提到外面
循环里每次算一遍结果都一样的东西,应该先算好。
# 适用于 Python 3.8+
import re
lines = ["id=1", "id=22", "id=333"]
# 改成:编译一次,循环里复用
pattern = re.compile(r"\d+")
found = [pattern.search(line).group() for line in lines]
print(found)re模块内部对最近用过的模式有缓存,所以即使在循环里调用re.compile也不至于每次都真正重新编译;但显式编译一次更清楚,也免去缓存查找。
记忆化
functools.lru_cache能把「相同参数重复调用」的结果缓存下来。
# 适用于 Python 3.8+
from functools import lru_cache
@lru_cache(maxsize=None)
def comb(n, k):
if k == 0 or k == n:
return 1
return comb(n - 1, k - 1) + comb(n - 1, k)
print(comb(20, 10))lru_cache的签名是lru_cache(maxsize=128, typed=False),可以直接当无参装饰器用。它适合参数取值集合不大的函数;如果参数每次都不同,缓存只增不减,反而占内存。functools.cache是lru_cache(maxsize=None)的简写形式(Python 3.8 起提供)。
用集合和字典替代列表查找
成员判断是最常见的「隐性线性扫描」。
| 容器 | 成员判断的复杂度 | 适用 |
|---|
list/tuple | 线性扫描 | 数据量小、顺序有意义 |
set | 哈希查找 | 只要「在不在」 |
dict | 哈希查找 | 要「在不在」且要取对应值 |
# 适用于 Python 3.8+
records = list(range(1, 10001))
targets = [3, 5000, 9999]
lookup = set(records) # 建一次集合
hit = [t for t in targets if t in lookup]
print(hit)一次建集合、多次查询,是把查找从线性降到哈希的常见做法。
三、用 C 实现替代手写循环
Python 内置的很多函数和容器方法是用 C 写的,调用它们等于把循环交给 C 去跑,省掉大量字节码分派。
# 适用于 Python 3.8+
nums = [5, 3, 9, 1]
print(sum(nums), max(nums), min(nums)) # 内置聚合
print(sorted(nums)) # 内置排序
print(any(n > 8 for n in nums), all(n > 0 for n in nums))字符串拼接是另一个典型例子。
# 适用于 Python 3.8+
words = ["a", "b", "c"] * 100
# 逐次拼接:每一次都可能产生新的字符串对象
bad = ""
for w in words:
bad += w
# 一次性拼接:先收集,再用 join
good = "".join(words)
print(len(bad), len(good))str.join会先算出总长度再一次性构造结果,避免了中间对象反复产生。
列表推导式(list comprehension)也是把循环收进解释器内部更紧凑的实现里的一种写法,用来构造列表时通常比手写「先建空列表再 append」的循环更省事;是否更快取决于具体场景,请自行用timeit测量。
# 适用于 Python 3.8+
squares = [n * n for n in range(1000)]四、内存层面的优化
生成器:按需产出
只需要遍历一次的大数据处理,用生成器可以避免一次性构造整个列表。
# 适用于 Python 3.8+
def read_lines(path):
with open(path, encoding="utf-8") as f:
for line in f:
yield line.rstrip("\n")
# 逐行处理,不在内存里攒一整份
# for line in read_lines("data.txt"):
# ...生成器只能遍历一次,遍历完就空了;需要反复遍历时要重新创建它。
__slots__
给类声明__slots__后,实例不再各自带一个__dict__,实例占用的内存更小。
# 适用于 Python 3.8+
class Vector:
__slots__ = ("x", "y", "z")
def __init__(self, x, y, z):
self.x, self.y, self.z = x, y, z
v = Vector(1.0, 2.0, 3.0)
print(v.x)代价是不能给实例动态添加未声明的属性,继承链上如果有不带__slots__的父类,收益会被削弱。
array与memoryview
密集的同类型数值用array存储比列表更紧凑;memoryview允许在不复制的情况下访问底层缓冲区。
# 适用于 Python 3.8+
from array import array
values = array("d", [1.0, 2.0, 3.0]) # "d" 表示双精度浮点
print(len(values), values[1])
buf = memoryview(bytearray(b"abc"))
print(buf[0], bytes(buf))局部变量绑定
函数内局部变量的访问比全局名和属性访问更直接。把循环里频繁使用的全局名绑定成局部名,能省下反复查找。
# 适用于 Python 3.8+
import math
def normalize(points):
sqrt = math.sqrt # 绑定为局部名
return [sqrt(p) for p in points]
print(normalize([1.0, 4.0, 9.0]))常见坑点
1. 相信「固定倍数」的宣传。
- ❌ 以为套上某个写法就一定能快好几倍,改完发现毫无变化。
- ✅ 先测量瓶颈,再针对瓶颈改;能快多少由场景决定。
2. 不测量就凭感觉优化。
- ❌ 花大力气优化一段只占极少运行时间的代码。
- ✅ 用
timeit定位热点,把精力放在真正耗时的地方。
3. 在需要顺序或小数据量时强行换集合。
- ❌ 把有重复元素且需要顺序的列表直接换成集合,丢了信息。
- ✅ 集合只用于「成员判断」「去重」这类不关心顺序的场景。
4. 给参数每次都不同的函数加缓存。
- ❌ 无脑加
lru_cache,缓存从不命中,只吃内存。 - ✅ 只对「相同参数会被重复调用」的函数用记忆化。
5. 在循环里反复编译正则。
- ❌ 每轮迭代都
re.compile一遍。 - ✅ 编译一次,循环外复用。
6. 生成器被遍历两次。
- ❌ 第二次遍历得到空结果,还以为是数据问题。
- ✅ 需要多次遍历就物化成列表,或每次重新生成。
7. 给带__slots__的实例动态加属性。
- ❌ 运行时想挂一个新属性,报 AttributeError。
- ✅ 需要在
__slots__里声明该属性,或放弃使用__slots__。
8. 用字符串+=拼大段文本。
- ❌ 循环里不断拼接,产生大量中间字符串。
- ✅ 收集后用
str.join一次性拼接。
总结
| 方向 | 手段 | 原理 |
|---|
| 测量 | timeit | 先定位瓶颈,避免盲目改 |
| 重复计算 | 提到循环外、lru_cache | 相同结果不重复求 |
| 查找 | 列表换集合 / 字典 | 哈希替代线性扫描 |
| 循环 | 内置函数、join、推导式 | 把循环下沉到 C 实现 |
| 内存 | 生成器、__slots__、array | 减少对象与字典开销 |
这个标题里的倍数口号看看就好,真正该带走的是方法:先测量、按瓶颈选手段、用timeit验证。优化不是背几条技巧,而是学会判断「这里值不值得改」;至于改完到底快了多少,只有你自己测出来的数字才算数。