第 38 章 算法的速度——复杂度分析
🏗️ 前情回顾:前面五个模块,你学会了变量、分支、循环、数组、字符串、函数和结构体。你已经能写程序解决问题了。但有一个关键问题你还没认真想过:同样的任务,不同的写法,谁更快? 比如求 1 到 n 的和,用 for 循环累加和用公式
n*(n+1)/2,速度一样吗?这一章,我们一起来学习如何衡量算法的"快慢"——这是竞赛中最基础的素养之一。
🎯 本章目标
学完这一章,你能:
- 理解为什么要分析算法的速度,而不只看程序"能不能跑"
- 掌握时间复杂度的概念,用循环次数估算程序的运行时间
- 读懂大 O 记号:O(n)、O(n²)、O(n³)、O(log n)、O(n log n)
- 了解空间复杂度的概念
- 区分最好情况、最坏情况和平均情况
- 学会一眼看出嵌套循环的复杂度,理解常数项为什么可以忽略
📖 故事引入
小明和小红各写了一个程序,功能一模一样:判断 n 个数里有没有重复的数。
小明的写法是双重循环——每个数和它后面的所有数都比较一遍。小红的写法是先排序,再检查相邻的数。输入 10 个数时,两个程序都是"秒出结果"。但输入 100000 个数时,小明的程序跑了 10 秒还没出结果,小红的程序 0.1 秒就搞定了。
同样的功能,为什么速度差了一百倍?
这就是算法效率的差异。在 CSP-J 竞赛中,一道题的正确解法往往不止一种,但只有复杂度合理的算法才能拿满分。所以你必须学会:不看代码写得多漂亮,而看它跑得多快。
🧱 知识讲解
38.1 什么是时间复杂度
时间复杂度 = 衡量程序运行时间随数据规模增长而变化的规律。不是精确的秒数(不同机器跑出来的时间不一样),而是一个趋势。
最常见的衡量方式:数一数最内层语句被执行了多少次——如果数据规模是 n,执行次数大约是 n 的某个函数 f(n),就说时间复杂度是 O(f(n))。
// 例 1:单层循环 — 执行 n 次
int sum = 0;
for (int i = 1; i <= n; i++) {
sum += i; // 执行 n 次
}
// 时间复杂度:O(n)// 例 2:双重循环 — 执行 n² 次
int count = 0;
for (int i = 1; i <= n; i++) {
for (int j = 1; j <= n; j++) {
count++; // 执行 n × n = n² 次
}
}
// 时间复杂度:O(n²)// 例 3:每次翻倍 — 执行 log₂n 次
int x = 1;
while (x < n) {
x = x * 2; // x: 1 → 2 → 4 → 8 → ... 直到 ≥ n
}
// 循环次数 ≈ log₂n → 时间复杂度:O(log n)38.2 大 O 记号
大 O 记号(Big-O Notation)是描述复杂度的标准"语言"。它只关注增长最快的项,忽略常数和低阶项。
| 大 O | 通俗名称 | 举例 |
|---|---|---|
| O(1) | 常数 | 直接算公式,与 n 无关 |
| O(log n) | 对数 | 二分查找、每次操作范围减半 |
| O(n) | 线性 | 一轮 for 循环 |
| O(n log n) | 线性对数 | 高效排序(快排、归并) |
| O(n²) | 平方 | 双重 for 循环、冒泡/选择/插入排序 |
| O(n³) | 立方 | 三重 for 循环、Floyd 最短路 |
| O(2ⁿ) | 指数 | 递归求斐波那契(暴力版)、穷举子集 |
类比:大 O 就像"车速级别"。具体是 58km/h 还是 62km/h 不太重要(常数项),重要的是"你是走路、骑车还是开车"(O(n) vs O(n²) vs O(log n))。
38.3 一眼看出循环的复杂度
给你一段代码,怎么快速看出它是 O(几)?
法则 1:单层循环,从 1 到 n → O(n)
法则 2:嵌套循环 → 各层循环次数的乘积
for (int i = 1; i <= n; i++) // n 次
for (int j = 1; j <= n; j++) // n 次
doSomething(); // 总共 n² 次 → O(n²)法则 3:循环变量每次翻倍 → O(log n)
for (int i = 1; i <= n; i *= 2) // i: 1, 2, 4, 8, ..., n
doSomething(); // 总共 log₂n 次 → O(log n)法则 4:只看最大项,忽略常数
for (int i = 1; i <= n; i++) // n 次 → O(n)
doSomething();
for (int i = 1; i <= n; i++) // n 次 → 还是 O(n),不是 O(2n)
doSomethingElse();
// 总复杂度:O(n),常数 2 被忽略for (int i = 1; i <= n; i++) // n
for (int j = 1; j <= n; j++) // n²
doSomething();
for (int i = 1; i <= n; i++) // n
doSomethingElse();
// 总复杂度:O(n²),O(n) 被更高阶的 O(n²) 吞掉38.4 空间复杂度
除了时间,还要考虑空间——程序需要多占多少内存。
// 空间 O(1) — 只有几个变量,不随 n 增长
int sum = 0;
for (int i = 1; i <= n; i++) sum += i;
// 空间 O(n) — 开了一个长度为 n 的数组
int a[n];
for (int i = 0; i < n; i++) a[i] = i;竞赛中的经验值:
int a[1000005]≈ 4MB(通常安全)int a[100000005]≈ 400MB(多半超限)- C++ 一般内存限制 256MB,算一算就知道数组能开多大
38.5 最好、最坏和平均情况
同一个算法,面对不同的输入,表现可能大不相同。
举个例子:在数组中查找目标值(线性查找):
for (int i = 0; i < n; i++) {
if (a[i] == target) break; // 找到了就停
}- 最好情况:第一个就是 → O(1)
- 最坏情况:最后一个才是,或者根本不在 → O(n)
- 平均情况:大约检查 n/2 个 → 还是 O(n)(常数被忽略)
通常我们说一个算法的复杂度,如果没有特别说明,指的是最坏情况。但有些算法(比如快速排序)最好和最坏差别很大,需要重点区分。
✋ 动手试试
试试 1:分析下面代码的时间复杂度。
int s = 0;
for (int i = 1; i <= n; i++)
for (int j = 1; j <= i; j++)
s++;(提示:内层循环执行 1 + 2 + 3 + ... + n = n(n+1)/2 次 → O(n²))
试试 2:写一个程序,输入 n,用 clock() 函数测量双重循环运行的时间。把 n 从 1000 翻倍到 2000,观察时间大约变成几倍?
试试 3:为什么 O(n²) 的排序只能处理 n ≤ 10⁴ 左右的数据,而 O(n log n) 能处理 n ≤ 10⁵~10⁶?用 n² 和 n log₂n 的值算一算。
⚠️ 容易犯的错
错 1:把 O(2n) 当成比 O(n) 高一级
❌ "两个 for 循环就是 O(2n),比 O(n) 大一级"
✅ O(2n) 就是 O(n),常数在大 O 里被忽略
错 2:忽略内层循环的变化
❌ 认为 for(i=1;i<=n;i++) for(j=i;j<=n;j++) 是 O(n²),但没注意到 j 从 i 开始,所以其实是 n + (n-1) + ... + 1 = n(n+1)/2 → 仍然是 O(n²),这个判断碰巧对了,但如果内层范围变化更复杂就要仔细算
错 3:log n 的底数纠结
❌ "二分查找到底是 O(log₂n) 还是 O(log₁₀n)?"
✅ 大 O 记号不区分底数,因为 logₐn = log₂n / log₂a,差一个常数因子,被大 O 吞掉。所以统一写成 O(log n)
错 4:用计时器判断算法复杂度
❌ 在 Dev-C++ 里跑了 0.3 秒就以为算法是 O(1)
✅ 要在不同数据规模下测试,看增长趋势,而不是绝对值
📝 练习
基础题
1. 选择题
(1)以下哪个复杂度增长最快?
A. O(n) B. O(n log n) C. O(n²) D. O(2ⁿ)
(2)for(int i=1;i<=n;i*=3) 的复杂度是:
A. O(n) B. O(log n) C. O(n²) D. O(3ⁿ)
(3)空间复杂度的主要作用是:
A. 衡量程序运行时间 B. 衡量程序占用内存 C. 衡量代码行数 D. 衡量变量个数
2. 填空题
(1)双重循环(每层都从 1 到 n)的时间复杂度是 _____。
(2)在 n 个有序数中二分查找的时间复杂度是 _____。
(3)大 O 记号中,O(n² + n) 简化为 _____。
提高题
3. 分析题 — 程序段复杂度判断
分析以下各段代码的复杂度(用大 O 记号):
// (a)
for (int i = 1; i <= n; i += 2)
for (int j = 1; j <= m; j++)
doSomething();// (b)
int i = n;
while (i > 1) {
i = i / 2;
doSomething();
}// (c)
for (int i = 1; i <= n; i++)
for (int j = 1; j <= n; j *= 2)
doSomething();4. 思考题
某 O(n²) 算法在 n=1000 时跑了 1 秒。当 n=10000 时,大约需要多少秒?
挑战题
5. 编程题 — 感受复杂度差异
写三个版本的"求最大子段和"(在 n 个整数中找一段连续子数组,使和最大):
- 版本 A:三重循环 O(n³)
- 版本 B:两重循环 O(n²)
- 版本 C:一遍扫描 O(n)(想想怎么做?提示:如果当前累加和变成负数,就舍弃前面的)
分别测试 n = 100, 1000, 10000,感受复杂度的实际影响。
🧠 本章小结
时间复杂度 → 算法运行速度随数据规模的增长趋势
大 O 记号规则:
- 只保留增长最快的项
- 忽略常数和系数
- O(1) < O(log n) < O(n) < O(n log n) < O(n²) < O(n³) < O(2ⁿ)
空间复杂度 → 算法额外占用内存的量
三种情况:
- 最好情况(最小代价)
- 最坏情况(最大代价,最常用)
- 平均情况(期望代价)
竞赛中 n 的规模与可用复杂度的经验:
n ≤ 30 → O(2ⁿ) 也能过
n ≤ 10³ → O(n²) 可行
n ≤ 10⁵ → O(n log n) 可行
n ≤ 10⁷ → O(n) 可行📝 配套练习
共3题(纸笔分析)。复杂度不是写出来的,是分析出来的。。★核心(课堂必做) ◆拓展(课后练习)
| 级别 | 题号 | 链接 | 覆盖知识点 |
|---|---|---|---|
| ★ 核心 | — | — | 拿M5旧题标注复杂度:百鸡O(n²)→O(n)、双指针O(n)、前缀和O(1) |
| ◆ 拓展 | — | — | 给5段代码分析复杂度(单循环/嵌套/while/=2/递归) |
| ◆ 拓展 | — | — | 判断:n=10^5时哪些算法能过1秒?(O(n)/O(nlogn)/O(n²)/O(2^n)) |
💡 练习建议:先完成 1 道★核心题,确保掌握本章基本方法;再完成 2 道◆拓展题,覆盖不同变式和细节。
配套练习
共3题(纸笔分析)。复杂度不是写出来的,是分析出来的。★核心(课堂必做) · ◆拓展(课后练习)
| 级别 | 题号 | 链接 | 覆盖知识点 |
|---|---|---|---|
| ★ 核心 | — | — | 拿M5旧题标注复杂度:百鸡O(n²)→O(n)、双指针O(n)、前缀和O(1) |
| ◆ 拓展 | — | — | 给5段代码分析复杂度(单循环/嵌套/while/=2/递归) |
| ◆ 拓展 | — | — | 判断:n=10^5时哪些算法能过1秒?(O(n)/O(nlogn)/O(n²)/O(2^n)) |
练习建议:先在课堂完成 1 道★核心题,掌握本章基本方法;课后完成 2 道◆拓展题,覆盖不同变式和细节。
自查清单:
- [ ] 我能说出 5 种以上常见大 O 记号的含义
- [ ] 我会通过数循环层数判断基本复杂度
- [ ] 我理解为什么常数和低阶项在大 O 中被忽略
- [ ] 我知道空间复杂度和时间复杂度的区别
- [ ] 我能区分最好、最坏和平均情况
- [ ] 我明白竞赛中复杂度分析为什么重要——它直接决定你能拿多少分
🚀 下章预告:学完了怎么衡量算法的快慢,接下来我们开始学具体的排序算法。最经典的三种 O(n²) 排序——冒泡、选择和插入——虽然速度不算最快,但它们是理解排序思想的起点。第 39 章,我们来看看它们各自是怎么"把一堆数排整齐"的。