Lesson 0012 · 复杂度分析入门

C++ 进阶 — 第 5 课

三句话速览
  1. 复杂度看「增长趋势」,不看具体秒数。
  2. 数循环:外层几次 × 内层几次,丢掉常数。
  3. 内层每次砍半是 O(log n),并列要相加。
上节课:栈、队列、优先队列 —— 每个操作都标了 O(1)、O(log n),但没说这些记号怎么来的。
这节课:把大 O 讲成能用的工具。你已经见过「冒泡排序很慢」「记忆化让速度飞起来」「set 插入比 vector 快」,这些背后是同一个问题:运行时间随数据量怎么增长。学完你就能自己判断一个算法会不会超时。

1. 什么是时间复杂度

不是精确时间(秒),是「增长趋势」—— 数据量翻倍,操作次数翻几倍?

记号名字n=1000 时大概次数n 翻 10 倍
O(1)常数1不变
O(log n)对数~10+10
O(n)线性1000×10
O(n log n)线性对数~10000×20
O(n²)平方1,000,000×100
O(2ⁿ)指数天文数字💀

这张表怎么读:只看最右边两列。n 从 1000 涨到 10000(翻 10 倍),O(n) 的工作量也翻 10 倍,O(n²) 翻 100 倍,而 O(log n) 只多了 10 次 —— 这就是「阶」的差别。

洛谷经验法则:n ≤ 10⁷ → O(n) 能过;n ≤ 10⁵ → O(n log n) 能过;n ≤ 10³ → O(n²) 能过;n ≤ 20 → O(2ⁿ) 能过。

2. 你会碰到的复杂度

O(1) — 一步到位

int a = v[5];          // 数组按下标取值
st.push(x);            // 栈顶操作
cout << pq.top();     // 堆顶查看

O(n) — 扫一遍

for (int i = 0; i < n; i++)      // 一层循环
    sum += v[i];
v.push_back(x);                   // vector 末尾追加

O(n²) — 嵌套两层的循环

for (int i = 0; i < n; i++)      // 冒泡排序
    for (int j = 0; j < n; j++)
        ...

O(n log n) — sort 级别

sort(v.begin(), v.end());        // 标准排序
pq.push(x);  // 堆操作单次 O(log n),n 次就是 O(n log n)

O(2ⁿ) — 选或不选的暴力枚举

// P1036 选数(选/不选每个元素)
void dfs(...) {
    dfs(idx+1, ...);   // 选
    dfs(idx+1, ...);   // 不选  → 每层分2叉,共 2ⁿ 条路
}

3. 怎么数循环

数循环只问两个问题:外层跑几次、内层每次跑几次。乘起来,再把常数和低次项丢掉,就是阶。下面六段,一段一段数。

① 一层循环 → O(n)

for (int i = 0; i < n; i++)      // 外层:跑 n 次
    sum += a[i];                  // 里面:做 1 件事

外层跑 n 次,内层每次只做一件固定的事(O(1))。合起来 n × 1 = O(n)。

② 两段并列 → 还是 O(n)

for (int i = 0; i < n; i++) cnt++;   // 第一段:n 次
for (int j = 0; j < n; j++) cnt++;   // 第二段:n 次

这是加法不是乘法:n + n = 2n。系数 2 丢掉,还是 O(n)。规则是「并列取最大」—— 如果一段是 n、一段是 n²,答案就是 O(n²),小的那一段被吃掉。

③ 两层都跑满 n → O(n²)

for (int i = 0; i < n; i++)          // 外层:n 次
    for (int j = 0; j < n; j++)      // 内层:每次都跑满 n 次
        cnt++;

外层每走一步,内层就跑一整圈 n 次。n × n = O(n²)。这就是冒泡、选择、插入这些双重循环的阶。

④ 内层跟着 i 走 → 仍然是 O(n²)

for (int i = 0; i < n; i++)          // 外层:n 次
    for (int j = 0; j < i; j++)      // 内层:跑 i 次,i 从 0 到 n-1
        cnt++;

内层的次数是 0 + 1 + 2 + … + (n−1) = n(n−1)/2。虽然只有一半,但 n(n−1)/2 里最高的项还是 n²,系数 1/2 丢掉 → O(n²)。「内层少跑一半」不等于降一阶。

⑤ 每次砍一半 → O(log n)

for (int i = n; i > 0; i /= 2)      // 循环变量每次变成一半
    cnt++;

n → n/2 → n/4 → … → 1。数「除几次才到 1」就是 log₂n,所以是 O(log n)。对照第 1 节那张表:n = 1000 时 log₂1000 差不多就是 10,和我们写的「~10」对得上。

⑥ 外层 n、内层砍半 → O(n log n)

for (int i = 0; i < n; i++)              // 外层:n 次
    for (int j = n; j > 0; j /= 2)       // 内层:log n 次
        cnt++;

外层 n 次 × 内层 log n 次 = O(n log n)。sort 之所以是这个阶,就是因为它做了大约 log n 轮、每轮扫 n 个元素。

六段收成一张表

代码长什么样外层跑几次内层跑几次合起来阶
单层 i < nn1nO(n)
两段并列n + n—2nO(n)
双层都 < nnnn²O(n²)
内层 j < inin(n−1)/2O(n²)
i /= 2log₂n1log₂nO(log n)
外层 n、内层 /= 2nlog₂nn log₂nO(n log n)

4. 常见复杂度对照表

把前面的结论收在一起。这张表只有量级,不写毫秒 —— 同一阶换台机器能差好几倍,能比的只有「数据量翻 10 倍,工作量变几倍」。

阶名字n 翻 10 倍本课出现过的例子
O(1)常数不变数组按下标取值、栈顶操作、堆顶查看
O(log n)对数+10每次砍一半的循环(第 3 节⑤)、set / map 查找
O(n)线性×10一层循环扫一遍(第 3 节①)
O(n log n)线性对数×20sort、n 次堆操作(第 3 节⑥)
O(n²)平方×100两层都跑满的嵌套循环(第 3 节③④)
O(2ⁿ)指数💀选 / 不选的暴力枚举

最后一行没有「翻 10 倍」可言:数据每多一个,工作量就翻一倍,很快就谁也跑不动了。所以看到指数,先回头问一句「n 到底多大」。

5. 你学过的数据结构复杂度速查

数据结构末尾增删中间增删查找
vectorO(1) ✅O(n) ❌O(n)
setO(log n)O(log n)O(log n)
mapO(log n)O(log n)O(log n)
stackO(1)——
queueO(1)——
priority_queueO(log n)—O(1)(堆顶)
为什么 set 查找是 O(log n) 而 vector 是 O(n)?
set 里面是平衡二叉树,每次比较砍掉一半候选(像猜数字)。vector 只能一个一个翻 —— 数据量翻倍,set 只多一次比较,vector 多一倍时间。
这也解释了第 3 节⑤为什么砍一半就是 log n:两者是同一件事。

6. 空间复杂度

同理 —— 额外用了多少内存。日常记两个:

vector<int> v(n);     // O(n) — 存 n 个
int memo[1001];        // O(1) — 固定大小,不随输入增长

注意「额外」两个字:输入数据本身占的那份不算,只算你为了算答案另外开的空间。memo[1001] 写死了一千个格子,输入再大也不涨,所以是 O(1)。

7. 实战判断

n = 100000, 二重循环 for(i) for(j) → O(n²) = 10¹⁰ 次 → 超时 💀
n = 100000, sort → O(n log n) ≈ 1.7×10⁶ 次 → 轻松 ✅
n = 20,     dfs 选/不选 → O(2²⁰) ≈ 10⁶ 次 → 能过 ✅
n = 1000,   二重循环 → O(n²) = 10⁶ 次 → 能过 ✅

这四行就是「先看 n 的范围,再看自己的阶」的完整流程。同样是二重循环,n = 1000 能过,n = 100000 就必超时 —— 阶没变,变的是 n。

8. 坑点清单

9. 小测

Q1. 一层 for 循环遍历 n 个数,复杂度是?
Q2. n=100000 时,O(n²) 大约多少次操作?
Q3. 为什么记忆化让 P1028 从爆炸变瞬间?
Q4. for (int i = n; i > 0; i /= 2) cnt++; 的复杂度是?
Q5. for (i < n) for (j < i) cnt++; 的复杂度是?

10. 动手练习

  1. 先猜后数:把第 3 节的六段代码抄进编辑器,每段先写一个猜测的阶,再按「外层几次 × 内层几次」手算一遍,对不上的地方标记出来。
  2. 回头看旧代码:翻出 0011 的括号匹配(只有一层 for (char c : s)),数一数它是几阶;再想想为什么它没有跟着变慢。
  3. 四行判断:用第 7 节那四行,分别写一句结论 —— 「n = 10⁵ 的二重循环」「n = 10⁵ 的 sort」「n = 20 的选/不选 DFS」「n = 10³ 的二重循环」,各自是过还是超时,理由是哪一条经验法则。
  4. 选做:把 n(n−1)/2 代几个 n(10、100、1000)算出来,和 n²/2 比一比,看看差多少比例 —— 你会明白为什么系数可以扔。

卡住时的自查顺序

  1. 先圈出循环变量:它每次怎么变?i++、i /= 2、还是别的?
  2. 外层跑几次?把 n 代成 8 或 10 数一遍,别硬背。
  3. 内层每次跑几次?固定、跟着 i 走、还是砍半?
  4. 乘起来(并列的用加法),再把常数和低次项划掉。
  5. 拿第 4 节的对照表核一下,阶写对了吗。
  6. 还是不确定:把代码和你的数法一起贴过来,我们逐行对。

本课小结

下一课预告
0013 类:构造函数与析构函数 —— 从「时间」转到「对象的一生」:对象创建时自动跑构造函数,销毁时自动跑析构函数,中间用初始化列表少走一步。
RAII(构造拿资源、析构还资源)是 C++ 最重要的一条规矩,下一课会用 new / delete[] 把它讲具体。

← 上一课:栈、队列与优先队列 · 课程总览

本课重点 第 3 节(怎么数循环)和第 4 节(对照表)是后面所有课的尺子