0029 · 让 Python 再快一点

让 Python 再快一点

本节目标:能用实测定位瓶颈,并用常数优化把接近时限的解法压进时限内。

常数因子从哪来

第 28 课讲的是复杂度的量级:O(n^2) 和 O(n \log n) 差的是指数。这一课讲另一件事——同一个量级里,写法不同也能差出几百倍。

第 2 课量过:本机一次循环大约 20 纳秒,每秒约 5 \times 10^7 次。可那个「一次循环」是理想情况。真写起来,一次循环里可能有函数调用、属性查找、全局名查找、容器扫描,每一样都要花时间,加起来就是常数因子。

常数因子不是学术问题。一道题正解 O(n)、n = 10^6,按尺子算 0.02 秒,稳过。可如果在一次循环里调了个函数,实际可能翻倍;如果在循环里对一个列表做 in,那已经不是常数问题了,是你把复杂度写坏了。

这一课里所有数字都是本机实测(Python 3.14,非通用值)。换机器、换版本都会变,浮动还不小。要学的是方法和量级,不是这几个数。

别猜,先量出瓶颈

「不测就猜瓶颈在哪」是这一课的第一个坑。看这样一段场景:

import random

N = 200000
ARR = [random.Random(3).randint(0, 10 ** 6) for _ in range(N)]
KEYS = [random.Random(5).randint(0, 10 ** 6) for _ in range(2000)]

三段分别做三件事:把 20 万个元素装进集合、排序这 20 万个元素、做 2000 次「这个键在不在数组里」。凭直觉猜,哪一段最慢?

多数人会指向那两个 20 万规模的。实测是反的(本机实测,取中位数):

段干了什么用时
A20 万个元素装进集合0.0020 s
B排序 20 万个元素0.0006 s
C2000 次 in,容器是列表1.8992 s
C'2000 次 in,容器是集合0.0021 s

C 段看着最无害——只有 2000 次循环,比 A、B 的 20 万小两个数量级。可它要 1.9 秒,是 A 的 950 倍、B 的 3000 倍。

原因不全是常数因子,主要是复杂度:列表的 in 是线性扫描,20 万乘 2000 是 4 \times 10^8 次比较;集合的 in 平均一步。所以「哪一段慢」不能看代码形状,要看每一段各执行多少次操作。

把这件事用次数算出来——不看时间,只看语句执行次数:

n_small = 30
n_keys = 100000
n_big = 100000

inner_ops = n_small * n_small        # 两重循环,各 30
list_in_ops = n_keys * n_big         # 列表的 in 是线性扫描
set_in_ops = n_keys * 1              # 集合的 in 平均一步
print('   三段各做多少次操作(按语句执行次数算,不看代码长什么样):')
print(f'     两重循环,各 30        :{inner_ops:>18,} 次内层操作')
print(f'     一重循环里的 in,列表  :{list_in_ops:>18,} 次比较')
print(f'     一重循环里的 in,集合  :{set_in_ops:>18,} 次比较')
print(f'   看着凶的那段(两重循环)比看着无害的那段(一个 in)少 '
      f'{list_in_ops / inner_ops:.2e} 倍的操作。')
   三段各做多少次操作(按语句执行次数算,不看代码长什么样):
     两重循环,各 30        :               900 次内层操作
     一重循环里的 in,列表  :    10,000,000,000 次比较
     一重循环里的 in,集合  :           100,000 次比较
   看着凶的那段(两重循环)比看着无害的那段(一个 in)少 1.11e+07 倍的操作。

两重循环(看着吓人)只有 900 次操作;一重的 in 写在列表上(看着无害)是 10^{10} 次。 差一千万倍。

所以定位瓶颈的次序是:先数每段跑多少次 → 再估每次有多贵 → 最后用计时确认。

循环里少做重复的事

同一段代码,把「每轮都要重新算一遍」的东西提到循环外面,就是最基础的一招。下面 🅐 是反例、🅑 是正例——注意看 size() 被调了几次:

class Counter:
    """一个会自己数「被调用了几次」的对象。"""

    def __init__(self):
        self.n = 0

    def size(self):
        self.n += 1
        return 1000


def check_in_condition(cnt):
    """把 cnt.size() 写在 while 的条件里。"""
    i = 0
    total = 0
    while i < cnt.size():
        total += i
        i += 1
    return total


def check_hoisted(cnt):
    """先把长度取出来,循环里不再问。"""
    limit = cnt.size()
    i = 0
    total = 0
    while i < limit:
        total += i
        i += 1
    return total


c1, c2 = Counter(), Counter()
r1, r2 = check_in_condition(c1), check_hoisted(c2)
print(f'   两版答案一致吗: {r1 == r2}(都是 {r1})')
print(f'   写在 while 条件里:size() 被调用了 {c1.n:>5} 次')
print(f'   提到循环外面  :size() 被调用了 {c2.n:>5} 次')
   两版答案一致吗: True(都是 499500)
   写在 while 条件里:size() 被调用了  1001 次
   提到循环外面  :size() 被调用了     1 次

答案完全一样,调用次数差一千倍。 而且 while i < cnt.size() 是 n + 1 次而不是 n 次——最后一次是退出循环前那次失败的比较,很容易漏算。

同样的道理适用于循环体里所有不随下标变化的表达式:len(a)、obj.attr、a.b.c、反复算同一个常量。全部提到循环外面。

全局名与局部名

这一节要给你一个反例,因为它跟很多教程讲的不一样。

「局部变量比全局变量快」是常被引用的规律。本机实测下来,它是真的,但幅度很小:

规模全局名(中位数)局部名(中位数)局部快
n = 10^60.0278 s0.0247 s1.13 倍
n = 10^70.2777 s0.2621 s1.06 倍
n = 10^6(每轮引用两次)0.0364 s0.0335 s1.09 倍

只有 6% 到 13%。 而第一次量的时候(n = 10^6、只跑 3 次)我量到的比值是 1.00——完全看不出差别,因为噪声比效应还大。

原因是新版本的 CPython 给全局名查找加了内联缓存,LOAD_GLOBAL 不再每次都真去查字典。所以这条规律没有过时,但已经从「重要的一招」退化成「顺手可以做的事」。

这对你的取舍有直接影响:如果瓶颈分析指到别处(比如一个列表的 in,差 230 倍),把名字改成局部变量省下那 10% 是白费力气。 先量,再决定值不值得动。

输入输出最便宜

有一类优化几乎不需要动脑筋,收益却最大:读入。

逐行 input() 每读一行都要走一次函数调用加一次解析;把整个输入一次读进来、再 split() 成 token,同样的活只让 C 层做一遍。实测读 10^6 行整数:

读法用时(中位数)相对
逐行 int(input()),10^6 行0.4216 s1
data = sys.stdin.buffer.read().split()0.0534 s快 7.9 倍

7.9 倍,而两种读法得到的数字完全一样。输入越大差距越明显,因为逐行 input() 的开销是每行一次,整体读只有一次调用。

真实代码的第二行是 data = sys.stdin.buffer.read().split(),剩下三行照写。把那行换成一个写死的字节串,整段就能单独跑起来看效果:

import io

RAW = b'4\n3 1 4 1\n'                        # 假装这是题目给的输入

data = io.BytesIO(RAW).read().split()        # 真代码这里是 sys.stdin.buffer.read().split()
it = iter(data)
n = int(next(it))
a = [int(next(it)) for _ in range(n)]
print(f'   读到 n = {n},数组 = {a}')
   读到 n = 4,数组 = [3, 1, 4, 1]

三件事要注意。第一,用 sys.stdin.buffer(字节流)而不是 sys.stdin,省掉一次解码。第二,split() 出来的是 bytes,int() 认它,但你要比字符串就得先解码。第三,输出也要一起改:把答案攒进列表、最后 '\n'.join(...) 打印一次,别在循环里 print 十万次。

能用内置就用内置

第 2 课提过一句「内置函数是 C 写的」。这一课把它量出来。同样对 10^6 个元素求和:

写法用时(中位数)相对
手写 for x in a: t += x0.0196 s1
内置 sum(a)0.0041 s快 4.8 倍

差距来自循环本身:手写版每轮要做一次 FOR_ITER、一次加法、一次 STORE_FAST,全在 Python 层;sum(a) 的整个循环在 C 里跑,Python 只付一次调用的钱。

排序上差距更大。n = 3000:

写法用时相对
内置 sorted(a)0.000200 s1
手写冒泡排序0.2269 s慢 1134 倍

这一栏要分开看:1134 倍里,绝大部分来自复杂度(O(n \log n) 对 O(n^2)),只有一小部分来自常数。 别把这一行当成「内置比手写快一千倍」的证据——它证明的是「别手写排序」。

该用的内置还有一批:max、min、any、all、sorted、list.sort、map、sum、collections.deque、bisect、itertools.accumulate。

换个容器更值钱

前面那段 1.9 秒的瓶颈,修法不是改循环写法,是换容器。同一批数据、同一件事,只把列表换成集合:

写法用时(中位数)找到的个数
2000 次 k in ARR(列表,20 万元素)1.6830 s351 个
2000 次 k in S(集合,同一批元素)0.0073 s351 个

230 倍,答案一模一样。

这就是本课最重要的一条取舍:换数据结构通常比抠语法值钱得多。 抠一个局部变量省 10%,换容器省 23000%;而且换容器是改一行,抠语法要改一片。

类似的对应关系还有:

查表与分支

「用查表代替分支判断」也是一条常被引用的规律。它成立,但要看清前提。

下面这张表是同一件事的两种写法(10^6 个值,两边答案逐字一致),只改分支链的长度:

分支数if/elif 链(中位数)列表查表(中位数)查表快
20.0189 s0.0179 s1.06 倍
30.0228 s0.0176 s1.30 倍
40.0276 s0.0171 s1.62 倍
80.0457 s0.0172 s2.65 倍
160.0749 s0.0176 s4.26 倍

看最后两列的形状,而不是倍数:

因为 if/elif 是一路比下来的,第 16 个分支平均要比 8 次;查表是一次索引。这就是「O(k) 对 O(1)」在小 k 上的样子。

所以怎么选:

我刚才第一次量这一项时写错了:查表版后面还留了一条 if 链去用查出来的值,等于「查表 加 分支」,量出来查表反而慢 12%。去掉那条多余的 if、两边都直接取值之后,才是上面这张表。

这件事值得记住:基准测试写错了,会得出刚好相反的结论,而且看起来一样可信。 每次量之前先问自己——两个版本除了我要比的那一点,别的地方真的一样吗?

大循环别用递归

第三个坑:用递归写本来该迭代的大循环。实测 n = 900 求和:

写法用时(中位数)相对
递归 sum_rec0.000076 s1
迭代 sum_iter0.000011 s快 6.7 倍

6.7 倍来自每层调用都要建一个栈帧。但比速度更硬的问题是上限——递归深度到不了任意大:

def sum_rec(a, i=0):
    if i == len(a):
        return 0
    return a[i] + sum_rec(a, i + 1)


def sum_iter(a):
    t = 0
    for x in a:
        t += x
    return t


for n in (900, 990, 995, 996, 997, 998, 999, 1000):
    a = list(range(n))
    try:
        r = sum_rec(a)
        print(f'   n = {n:>4}: 递归版跑通,和 {r},迭代版和 {sum_iter(a)},一致 {r == sum_iter(a)}')
    except RecursionError:
        print(f'   n = {n:>4}: 递归版 RecursionError')
   n =  900: 递归版跑通,和 404550,迭代版和 404550,一致 True
   n =  990: 递归版跑通,和 489555,迭代版和 489555,一致 True
   n =  995: 递归版跑通,和 494515,迭代版和 494515,一致 True
   n =  996: 递归版跑通,和 495510,迭代版和 495510,一致 True
   n =  997: 递归版跑通,和 496506,迭代版和 496506,一致 True
   n =  998: 递归版跑通,和 497503,迭代版和 497503,一致 True
   n =  999: 递归版 RecursionError
   n = 1000: 递归版 RecursionError

边界就在 998 和 999 之间——跟第 12 课、第 17 课量到的是同一个数(默认上限 1000,主模块本身占一层)。

所以规则很简单:深度靠 n 决定的递归,一律改迭代,或者用显式栈。深度只跟 \log n 有关的(比如分治、树遍历)可以留着。

优化不许改答案

第二个坑是为了优化牺牲正确性。这一课教的每一招都会改代码,改代码就可能改错。所以每改一处,都要回验答案:

import random

random.seed(11)
bad_iter = 0
bad_hoist = 0
trials = 300
for _ in range(trials):
    n = random.randint(0, 50)
    a = [random.randint(-9, 9) for _ in range(n)]
    if sum_iter(a) != sum(a):
        bad_iter += 1
    if check_in_condition(Counter()) != check_hoisted(Counter()):
        bad_hoist += 1
print(f'   {trials} 组随机数据上,优化版与原版答案不一致的组数:')
print(f'      手写循环 vs 内置 sum            : {bad_iter} 组')
print(f'      size() 写在条件里 vs 提到外面   : {bad_hoist} 组')
   300 组随机数据上,优化版与原版答案不一致的组数:
      手写循环 vs 内置 sum            : 0 组
      size() 写在条件里 vs 提到外面   : 0 组

0 组。 这就是第 15 课那套对拍:优化后的版本与被替换掉的版本,在同一批随机数据上必须逐字一致,边界包含空数组和负数。

顺序不能颠倒:先让它对,再让它快,而且每快一次都要重新证明它对。 反过来做(先快再想法子对)会把你带进「改一处、坏一处、看不出来」的泥潭。

还有一个更隐蔽的错法:优化后把复杂度也改了,小数据上却看不出差别。 比如把列表换成集合,n = 10 时两版耗时都是 0.000001 秒,对拍全过——可这不能说明你换对了,只能说明小数据测不出来。所以对拍的数据要按题目的上限量级生成,不能只用手边的小样例。

这一课用到的判断

练习 四招优化,每招都留记录

这一课的动手部分落在 lab 里(任务与断言见 lab/0029-constant-factor-tuning/,做完对照 lab/solutions/)。要做的是四件事。

  1. 先量出瓶颈。 拿一道接近时限的题(lab 里给了骨架),把代码分成几段,每段单独计时,再数一遍每段的语句执行次数。写下你原以为哪段最慢、实测是哪段——两个答案不一样才算做了这一步。
  2. 依次上三到四招,每招都记数。 按「换容器 → 整体读入 → 提前算不变式 → 用内置」的顺序,每改一招就重跑一次计时并回验答案。做一张表:第几招、改了什么、耗时变成多少、答案还对吗。
  3. 和内置实现比一次。 同一件事,用你优化后的写法再跑一遍,然后换成对应的内置(sum、sorted、set、heapq)跑一遍,看还差多少倍,写下差在哪一层。
  4. 故意写错一次。 把某一招改坏(比如把 while i < limit 写成 while i <= limit),跑一次对拍,确认它能被抓出来;再改回来。这一步是为了确认你的对拍真的有效,不是摆设。
没看懂?随时问 把看不懂的段落(或报错)原样复制回会话,说一句哪里不懂就行,我只解释这一段,不打断你的进度。