0002 · 同一道题,两种写法

同一道题,两种写法

本节目标:能数出一段代码的基本操作次数、写成大 O 记号,并按数据规模与实测判断它会不会超时。

上一课结尾留了一句话:从这一课起,把「看数据范围、估操作次数、定复杂度上限」练成一套固定动作。这一课就把它做成一个能反复使用的记号。

先回到那个上一课已经踩过的坑。样例只有三五个数,跑起来永远飞快,所以「样例通过」对「会不会超时」几乎没有预测力——真正的问题在评测机用最大数据跑你代码的那一刻才暴露。蓝桥杯每道题都写明数据范围与时限,能不能过,几乎在你选定写法的瞬间就定了。这一课要做的,是把「这种写法会不会超时」这个判断,提前到动手写代码之前。判断之前照例先想清楚输入和输出是什么——这一步上一课刚走过,这里不再展开,但它永远是第一步。

先数基本操作

同一个算法在不同机器上跑,速度会有差别,实际用时难以在理论上计算,实测又麻烦。所以衡量算法效率时,通常数的是算法需要执行的基本操作数量:在普通计算机上,加减乘除、访问变量、给变量赋值,都可以算作一次基本操作。

时间复杂度衡量的,是这个数量随数据规模增长的趋势:数据规模越大,算法用时越长,但最重要的不是某个规模下的用时,而是用时随规模增长的趋势。为什么看趋势,而不是看某一次的用时,有两条理由:

上面「每秒数亿次」是来源里针对编译型语言的一般说法,它的例子是 C++;Python 每执行一条语句要拆成很多条底层运算,实测速率会慢一截。Python 这边该是多少,稍后用 timeit 单独量出来,不套用这个数字。

补充一点:实际用时也跟输入内容有关,所以复杂度分最坏与平均两类。竞赛里输入可以在数据范围内任意给定,为了保证任何数据都能过,我们只关心最坏时间复杂度。

大 O 记号

「增长趋势」是个模糊的说法,需要一套记号把它写下来。这套记号叫渐近符号:它忽略一个函数里增长较慢的部分和各项的系数(系数在复杂度分析里通常叫「常数」),只保留决定增长趋势的那部分。

记号的名字有来历。大 O 的 O 来自希腊字母 Omicron,字形与拉丁字母 O 相同;小 o 同源。一个记忆法:含等于用大写、不含用小写;相等是 \Theta、小于(上界)是 O、大于(下界)是 \Omega。竞赛里最常用的是 O,因为我们关心的是程序用时的上界。

形式化定义是这样:设 f(n) 与 g(n) 是定义在正整数上的函数,f(n) = O(g(n)) 当且仅当存在正常数 c 与 n_0,使得对一切 n \ge n_0,都有 0 \le f(n) \le c \cdot g(n)。

翻译成普通话:当 n 足够大之后,f(n) 的增长不超过 g(n) 的某个常数倍。所以 O(g(n)) 说的是「增长趋势的上界」,不是一个精确值。例如 3n^2 + 5n - 3 = O(n^2)——当 n 足够大,3n^2 + 5n - 3 始终不超过 4n^2,取 c = 4 即可。

常数与数量级

渐近符号丢掉系数和低阶项,带来一条最容易踩的判断:O(2n) 与 O(n) 是同一档,O(100n) 与 O(n) 也是同一档。它们差的只是常数倍,而常数倍在数量级面前可以忽略——n 放大十倍,2n 与 100n 也都只放大十倍,增长趋势一样。真正分档的是最高阶项:

写法大 O 记号档次
3n + 7O(n)线性
2n^2 + 100nO(n^2)平方
n^3 + n^2O(n^3)立方
100(固定几次操作)O(1)常数

每个记号都只保留最高阶项、丢掉系数:2n^2 + 100n 里的 100n 是低阶项、2 是系数,都不出现在记号里。

反过来,哪些量算常量也要分清。一段固定跑 N = 100000 次的循环,如果这个 N 不被看作输入规模,它的复杂度就是 O(1)。判断的关键是「哪个量是输入规模」:所有与输入规模无关的量,计算复杂度时都当作 1 处理。

从代码里读出复杂度

有了记号,下一步是从一段代码里把它读出来。规则和循环的嵌套层数直接对应:

先看上一课的求和题。写法一是循环:

n = int(input())
total = 0
for i in range(1, n + 1):
    total += i
print(total)

循环执行 n 次,每次做一次加法、一次赋值,基本操作次数随 n 线性增长,写做 O(n)。写法二是公式:

n = int(input())
print(n * (n + 1) // 2)

不管 n 多大,都只做固定几次乘除,操作次数不随 n 增长,写做 O(1)。

再看一道题:数一数 1 到 n 之间有多少个无序数对 (i, j),满足 i < j。最直接的写法是两层循环:

count = 0
for i in range(1, n + 1):
    for j in range(i + 1, n + 1):
        count += 1
print(count)

内层循环一共跑了 (n-1) + (n-2) + \dots + 1 次,也就是 \dfrac{n(n-1)}{2} 次,写做 O(n^2)。同一道题的另一种写法是公式:从 n 个数里选两个,结果是 \dfrac{n(n-1)}{2},直接算:

n = int(input())
print(n * (n - 1) // 2)

这是 O(1)。同一道题,O(n^2) 与 O(1),差距比上一题更大:n 每放大十倍,前一种写法慢一百倍。

两层循环是竞赛里最常见的超时来源。很多两层循环都能降到一层,那是后面课程的主题(比如区间求和用前缀和,一层预处理加一步查询);这里先记住:写出循环之前,先数它套了几层。

数据规模反推上限

有了 O 记号,再加一把本机实测的「每秒能做多少次基本操作」的尺子,就能把数据范围翻译成「这道题最多能接受哪档复杂度」;反过来,看数据范围,也能反推该用哪档写法。

上一课已经量过:这台机器(Python 3.14 的 free-threading 构建)跑最简单的累加循环,约 189 毫秒跑完 10^7 次,折合每秒约 5\times10^7 次。这比上面「每秒数亿次」的通用说法慢一截,因为那说的是编译型语言的量级,Python 每执行一条语句要拆成很多条底层运算。这个数字只属于这台机器、这个 Python 版本,换一台要重新量——它就是本课的尺子。假设一秒约做 5\times10^7 次基本操作,把各档复杂度在 1 秒内能处理的最大 n 解出来:

复杂度本机 1 秒能处理的最大 n(量级)换算
O(1)几乎任意操作次数不随 n 增长
O(n)5\times10^7n = 5\times10^7
O(n^2)7\times10^3n = \sqrt{5\times10^7}
O(n^3)4\times10^2n = \sqrt[3]{5\times10^7}

还有两档现在先认识名字:O(\log n) 是每步把范围砍半,比线性还快,二分那课展开;O(2^n) 是把每种可能都枚举一遍,只能处理 n 二十几,搜索那课常见。排序这类算法常是 O(n\log n),介于 O(n) 与 O(n^2) 之间。

这张表的用法分正反两个方向:

别背这张表里的数字

表里的「每秒 5\times10^7 次」是本机实测值,换机器、换 Python 版本都会变。正确动作是:用 timeit 在自己机器上量出这个数,再按同一套方程把表重算一遍。背一个「Python 一秒 10^8 次」的通用数字,是常见且有害的做法。

现在回头看开头的结论为什么成立:「样例通过」没有预测力,是因为样例的数据规模离上限差着好几个数量级——样例跑得动,只说明最小那一档能过。判断会不会超时,要看的是题目给出的最大数据范围落在表的哪一行。配套项目「AI 刷题助手」在推荐候选算法时,第一步也正是这样:按题目数据范围,先把复杂度不达标的那批算法筛掉。

空间复杂度

类似的,算法使用的空间随规模增长的趋势叫空间复杂度。通常不计输入本身占的空间,只算算法额外开的那部分。

上面两道题正好是两面:求和公式版与数对公式版都不额外开数组,空间复杂度 O(1);而有些算法会用一份 O(n) 大小的额外空间,把时间从 O(n^2) 降到 O(n)——这种「空间换时间」是后面课程的主题,眼下先记住时间与空间常常可以互换这一条。

练习

练习 先估三档规模,再实测对答案

这一节的动作都在本机 Python 里做,写一个 guess.py 就够。

第一步 · 准备两种写法。 把「数无序数对」的两种写法抄进文件:

import timeit

def pair_loop(n):
    count = 0
    for i in range(1, n + 1):
        for j in range(i + 1, n + 1):
            count += 1
    return count

def pair_formula(n):
    return n * (n - 1) // 2

先用小 n 验一下两个函数结果一致(n = 10 时都该是 45)。

第二步 · 先估后测。 对下面三档规模,先凭这一课学到的尺子估「跑不跑得完 1 秒」,再实测前两档:

for n in (10**3, 10**4):
    t = min(timeit.repeat(lambda: pair_loop(n), number=1, repeat=3))
    print(n, f"{t:.3f} 秒")

第三档 n = 10^5 别真跑——把估计写下来(约几十秒),这一步的价值正在于「不动手也能判断」。

第三步 · 找错在哪一步。 把每档的「我的估计」和「实测耗时」各写一句,标出哪几档估对了、哪几档估错了。对估错的那几档,回查是「数循环次数」数错了,还是「每秒能做多少次」这把尺子记错了——找出这一步,比答对更值钱。

这一课用到的 Python

要做的事写法边界
数清一段循环跑了多少次看 range 的起点、终点、步长range(i + 1, n + 1) 是 i{+}1 到 n,共 n - i 次
单次计时的正确姿势min(timeit.repeat(f, number=1, repeat=3))取多次重复里的最小值,单次计时会被别的程序干扰
判断会不会超时先估操作次数,再对照本机实测估的尺子每台机器不同,换机器要重新量

课程讲到后面,这些「数基本操作、写 O 记号、反推上限」的动作会一遍遍重复,直到成为拿到题就自动做的第一步。

没看懂?随时问 把看不懂的段落(或报错)原样复制回会话,说一句哪里不懂就行,我只解释这一段,不打断你的进度。