Skip to content

第9章 算法复杂度分析 ​


🎯 本章学习目标 ​

通过本章的学习,你将能够:

  1. 理解大O表示法:掌握时间复杂度和空间复杂度的定义
  2. 掌握三条法则:熟练运用顺序、循环、分支结构的复杂度分析
  3. 熟悉常见等级:记住 O(1), O(log n), O(n), O(n log n), O(n²), O(2ⁿ) 等的增长顺序
  4. 分析循环复杂度:能快速判断单层、多层嵌套循环的复杂度
  5. 分析递归复杂度:能用递归树法分析递归算法的时间复杂度
  6. 区分三种情况:理解最好、最坏、平均时间复杂度的区别
  7. 掌握空间复杂度:能分析算法的空间占用,理解递归栈空间
  8. 根据数据规模反推:能根据 n 的范围反推应该用什么复杂度的算法
  9. 了解时空权衡:理解空间换时间的思想(记忆化、前缀和、哈希等)
  10. 应对真题题型:能解决选择、程序阅读等各类复杂度题目

🗺️ 本章知识图谱 ​

算法复杂度分析
├── 大 O 表示法
│   ├── 定义:最坏情况下的上界
│   ├── 忽略常数系数和低阶项
│   └── 三条法则:顺序相加、循环相乘、分支取大
├── 常见复杂度等级
│   ├── 常数阶 O(1)
│   ├── 对数阶 O(log n)
│   ├── 线性阶 O(n)
│   ├── 线性对数阶 O(n log n)
│   ├── 平方阶 O(n²)
│   ├── 立方阶 O(n³)
│   ├── 指数阶 O(2ⁿ)
│   └── 阶乘阶 O(n!)
├── 时间复杂度分析
│   ├── 循环结构
│   │   ├── 单层循环 O(n)
│   │   ├── 嵌套循环 O(nᵏ)
│   │   └── 倍增/折半 O(log n)
│   ├── 递归结构
│   │   ├── 递归树法
│   │   └── 主定理(了解)
│   └── 三种情况
│       ├── 最好情况
│       ├── 最坏情况(大O关注)
│       └── 平均情况
├── 空间复杂度分析
│   ├── 辅助空间
│   ├── 递归栈空间
│   └── in-place(原地算法)
├── 数据规模反推
│   ├── n ≤ 20 → O(2ⁿ)
│   ├── n ≤ 1000 → O(n²)
│   ├── n ≤ 1e5 → O(n log n)
│   └── n ≤ 1e9 → O(log n)
└── 时空权衡
    ├── 记忆化搜索
    ├── 前缀和
    ├── 哈希表
    └── 用空间换时间

一、为什么需要复杂度分析?🤔 ​

同样是"在 n 个数里找一个数":

  • 从头到尾挨个找 → 平均要看 n/2 个数
  • 二分查找(前提:有序)→ 最多看 log₂n 个数

当 n = 100 万时,前者要看 50 万次,后者只要约 20 次!差距是几万倍。

这就是复杂度的意义:脱离具体机器、编程语言,只看算法本身随数据规模增长的趋势。

💡 核心思想:复杂度关心的是"当 n 趋向无穷大时,运行时间/空间如何增长",所以常数系数、低阶项都可以忽略。


二、大 O 表示法基础 ​

2.1 定义 ​

大 O 表示法用于描述算法在最坏情况下运行时间(或占用空间)随输入规模 n 增长的上界。

数学定义:若存在正常数 C 和 n₀,使得对所有 n ≥ n₀,都有 T(n) ≤ C · f(n),则记 T(n) = O(f(n))。

翻译成大白话:

  • O(f(n)) 表示"增长速度不超过 f(n)"
  • 忽略常数系数:3n → O(n),5n² → O(n²)
  • 只保留最高阶项:n² + 10n + 100 → O(n²)

2.2 三条基本法则 ​

法则说明例子
顺序结构各段复杂度相加,取最高阶O(n) + O(n²) = O(n²)
循环结构循环体复杂度 × 循环次数一层循环 n 次,循环体 O(1) → O(n)
分支结构取各分支中最大复杂度if-else 两个分支 O(n) 和 O(n²) → O(n²)

2.3 常见复杂度等级(⭐ 必背!) ​

按增长率从慢到快排列:

复杂度名称典型算法 / 场景n=10 时大致操作数n=100 时
O(1)常数复杂度数组随机访问、哈希表查找11
O(log n)对数复杂度二分查找、平衡树、GCD~3~7
O(n)线性复杂度单层循环、线性查找10100
O(n log n)线性对数复杂度归并排序、快速排序(平均)~33~664
O(n²)平方复杂度冒泡/选择/插入排序、双层循环10010,000
O(n³)立方复杂度三层循环、Floyd 最短路1,0001,000,000
O(2ⁿ)指数复杂度递归枚举子集、朴素斐波那契1,024~1.27×10³⁰
O(n!)阶乘复杂度暴力全排列3,628,800天文数字

🎯 增长率排序(必须记住): O(1) < O(log n) < O(n) < O(n log n) < O(n²) < O(n³) < O(2ⁿ) < O(n!)

2.4 增长趋势示意(ASCII 图) ​

操
作
数
    │
    │                                            ■ 2^n (爆炸式增长)
    │                                       ■
    │                                  ■
    │                             ■
    │                        ■
    │                   ■
    │              ■
    │         ■
    │        ╱■ n²
    │       ╱
    │      ╱
    │     ╱
    │    ╱
    │   ╱
    │  ╱
    │ ╱─── n log n
    │╱
    │╲ n (斜线)
    │ ╲
    │  ● log n (几乎平)
    │  ●
    │  ● O(1) (水平线)
    └──────────────────────────────────── 输入规模 n →

💡 直观记忆:log n 几乎是平的,n 是斜线,n² 是抛物线,2ⁿ 直接冲天。


三、多项式复杂度 📐 ​

3.1 定义 ​

若算法的时间是 n 的多项式函数,即 O(nᵏ)(k 为非负整数),则称为多项式复杂度。

  • O(1):k=0
  • O(n):k=1
  • O(n²):k=2
  • O(n³):k=3
  • 以此类推……

⚠️ O(√n) = O(n^{0.5}) 虽然不是整数次幂,但通常也归入"多项式量级"讨论(如试除法判素数)。

3.2 嵌套循环分析(核心技巧) ​

规则:看最内层循环体被执行的总次数。

① O(1)——无循环或循环次数固定

cpp
int getFirst(int a[], int n) {
    return a[0]; // 无论 n 多大,只执行 1 次
}

② O(n)——一层循环跟 n 成正比

cpp
long long sum(int a[], int n) {
    long long s = 0;
    for (int i = 0; i < n; i++) { // 循环 n 次
        s += a[i];                // 每次 O(1)
    }
    return s;
}

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

cpp
// 冒泡排序核心
void bubbleSort(int a[], int n) {
    for (int i = 0; i < n; i++) {              // 外层 n 次
        for (int j = 0; j < n - i - 1; j++) {  // 内层平均约 n/2 次
            if (a[j] > a[j + 1]) {
                swap(a[j], a[j + 1]);
            }
        }
    }
}
// 总执行次数 ≈ n + (n-1) + ... + 1 = n(n-1)/2 = O(n²)

④ O(n³)——三层嵌套

cpp
// 矩阵乘法 C = A × B,A、B、C 均为 n×n
void matrixMul(int A[][N], int B[][N], int C[][N], int n) {
    for (int i = 0; i < n; i++) {          // n
        for (int j = 0; j < n; j++) {      // n
            C[i][j] = 0;
            for (int k = 0; k < n; k++) {  // n
                C[i][j] += A[i][k] * B[k][j];
            }
        }
    }
}
// 总次数 = n × n × n = n³ → O(n³)

⑤ 非均匀嵌套——注意内层不是 n

cpp
for (int i = 0; i < n; i++) {
    for (int j = 0; j < i; j++) { // j 从 0 到 i-1
        // ...
    }
}
// 总次数 = 0 + 1 + 2 + ... + (n-1) = n(n-1)/2 = O(n²)

⚠️ 常见陷阱:不是"两层循环就一定是 O(n²)"!要看内层循环的实际次数。

例如 for (int j = 1; j < n; j *= 2) 即使嵌套在 n 次外层循环里,整体也是 O(n log n)。


四、对数复杂度 🌲 ​

4.1 O(log n) 从哪里来? ​

关键特征:每次循环把问题规模"折半"(或缩小固定比例)。

cpp
// 二分查找(在升序数组 a 中找 x)
int binarySearch(int a[], int n, int x) {
    int l = 0, r = n - 1;
    while (l <= r) {
        int mid = l + (r - l) / 2; // 防止溢出
        if (a[mid] == x) return mid;
        else if (a[mid] < x) l = mid + 1;
        else r = mid - 1;
    }
    return -1;
}

分析:每次循环后区间长度变为原来的一半。设循环 k 次后区间长度为 1:

n × (1/2)^k = 1  →  k = log₂n

所以时间复杂度为 O(log n)。

💡 对数的底数不影响大 O:log₂n、log₁₀n、ln n 之间只差常数倍,统一写成 O(log n)。

4.2 其他 O(log n) 场景 ​

场景为什么是 log n
平衡二叉搜索树(AVL、红黑树)查找树高 ≈ log₂n
欧几里得 GCD 算法每两步数值至少减半
快速幂(a^b mod m)指数 b 每次折半
堆操作(push_heap / pop_heap)堆高 log₂n
cpp
// 快速幂:计算 base^exp % mod
long long qpow(long long base, long long exp, long long mod) {
    long long result = 1;
    base %= mod;
    while (exp > 0) {
        if (exp & 1) result = result * base % mod;
        base = base * base % mod;
        exp >>= 1; // 指数折半
    }
    return result;
}
// 循环次数 = log₂(exp) → O(log exp)

4.3 O(n log n)——分治的产物 ​

O(n log n) = "外层 n 的工作" × "每次折半到 1 需要 log n 层"。

典型代表:归并排序

cpp
void merge(int a[], int l, int mid, int r) {
    // 合并两个有序区间,O(n)
    int i = l, j = mid + 1, k = 0;
    int* tmp = new int[r - l + 1];
    while (i <= mid && j <= r) {
        tmp[k++] = (a[i] <= a[j]) ? a[i++] : a[j++];
    }
    while (i <= mid) tmp[k++] = a[i++];
    while (j <= r) tmp[k++] = a[j++];
    for (int t = 0; t < k; t++) a[l + t] = tmp[t];
    delete[] tmp;
}

void mergeSort(int a[], int l, int r) {
    if (l >= r) return;
    int mid = l + (r - l) / 2;
    mergeSort(a, l, mid);       // 排序左半
    mergeSort(a, mid + 1, r);   // 排序右半
    merge(a, l, mid, r);        // 合并 O(n)
}

为什么是 O(n log n)?

  • 递归共 log₂n 层(每次把数组分两半)
  • 每层所有 merge 加起来总共处理 n 个元素
  • 总工作量 = 层数 × 每层工作量 = O(log n) × O(n) = O(n log n)

快速排序平均也是 O(n log n),最坏 O(n²)(见后文"最好/最坏/平均"一节)。


五、指数复杂度 💥 ​

5.1 O(2ⁿ) 的典型来源:递归枚举子集 ​

cpp
// 枚举一个集合的所有子集
void enumerateSubsets(int a[], int n, int i, vector<int>& current) {
    if (i == n) {
        // 处理当前子集(输出等)
        return;
    }
    // 不选第 i 个元素
    enumerateSubsets(a, n, i + 1, current);
    // 选第 i 个元素
    current.push_back(a[i]);
    enumerateSubsets(a, n, i + 1, current);
    current.pop_back();
}

每个元素有"选/不选"两种可能,n 个元素共 2ⁿ 个子集 → O(2ⁿ)。

5.2 朴素斐波那契:经典的 O(2ⁿ) 反例 ​

cpp
// ❌ 朴素递归:存在大量重复计算
long long fib(int n) {
    if (n <= 1) return n;
    return fib(n - 1) + fib(n - 2);
}

递归树中,fib(n) 会分裂成 fib(n-1) 和 fib(n-2),总调用次数呈指数增长,约为 O(2ⁿ)。

当 n = 50 时,调用次数已超过 200 亿次,根本跑不完!

5.3 为什么指数复杂度"不可接受"? ​

nO(n)O(n²)O(2ⁿ)
10101001,024
2020400~100 万
3030900~10 亿
50502,500~1×10¹⁵
10010010,000~1×10³⁰

计算机每秒约执行 10⁸ 次基本操作:

  • O(n) 在 n=10⁸ 时约 1 秒
  • O(n²) 在 n=10⁴ 时约 1 秒
  • O(2ⁿ) 在 n=30 时就要约 10 秒,n=40 就需要十几分钟

⚠️ 结论:指数复杂度只在 n 非常小(通常 n ≤ 20)时才能通过。一旦 n 更大,必须寻找多项式算法(如动态规划)。


六、空间复杂度 📦 ​

6.1 定义 ​

空间复杂度衡量算法运行过程中临时占用的存储空间随 n 增长的趋势,同样用大 O 表示。

注意:

  • 输入数据本身占用的空间通常不计入空间复杂度
  • 只计算辅助空间(额外申请的空间)

6.2 常见例子 ​

算法辅助空间说明
冒泡 / 选择 / 插入排序O(1)只用几个临时变量
归并排序O(n)需要一个等大的临时数组合并
快速排序(递归)O(log n)递归栈深度平均 log n
n 阶 DP(一维)O(n)dp 数组大小 n
n×n 二维 DPO(n²)dp 表大小 n×n
DFS / BFS 遍历图O(V)visited 数组、栈/队列

6.3 递归栈空间 ​

递归调用会在系统栈上为每层保存参数、局部变量、返回地址,因此递归深度 = 栈空间复杂度。

cpp
// 计算 1+2+...+n 的递归写法
long long sum(int n) {
    if (n == 1) return 1;
    return n + sum(n - 1);
}
// 递归深度 n → 栈空间 O(n)
cpp
// 二分查找式递归(如归并排序)
void f(int l, int r) {
    if (l >= r) return;
    int mid = (l + r) / 2;
    f(l, mid);
    f(mid + 1, r);
}
// 递归深度 log₂n → 栈空间 O(log n)

⚠️ 栈溢出:递归深度过大会导致 Stack Overflow。GESP 五级真题曾考:"递归层次过深可能耗尽栈空间"——这是正确说法;而"栈溢出时程序会抛出异常并可继续执行"是错误的(程序会直接崩溃)。

6.4 In-place(原地)算法 ​

若算法的辅助空间为 O(1)(即不随 n 增长),称为 in-place(原地)算法。

  • ✅ 冒泡排序、选择排序、插入排序、快速排序都是 in-place
  • ❌ 归并排序不是 in-place(需要 O(n) 辅助数组)

💡 快速排序虽然递归需要 O(log n) 栈空间,但通常仍被视为 in-place(因为它不需要额外的数组来存放数据)。


七、递归算法复杂度分析 🔁 ​

递归算法的复杂度分析有两件利器:递归树法和主定理。

7.1 方法一:递归树法 ​

步骤:

  1. 画出递归调用树
  2. 每一层的工作量 = 该层所有节点的工作量之和
  3. 总复杂度 = 所有层工作量之和

例 1:归并排序 T(n) = 2T(n/2) + O(n)

                    n              第0层:工作量 n
                  /   \
                n/2   n/2          第1层:工作量 n/2 + n/2 = n
               /  \   /  \
             n/4 n/4 n/4 n/4       第2层:工作量 n
              ...  ...  ...
             共 log₂n 层,每层工作量 n
             
总工作量 = n × log n = O(n log n)

例 2:朴素斐波那契 T(n) = T(n-1) + T(n-2) + O(1)

                    fib(n)
                   /      \
             fib(n-1)    fib(n-2)
             /     \       /    \
        fib(n-2) fib(n-3) ...   ...
  • 树高约 n
  • 第 k 层最多有 2ᵏ 个节点
  • 节点总数 ≤ 2⁰ + 2¹ + ... + 2ⁿ = 2ⁿ⁺¹ - 1
  • → O(2ⁿ)

7.2 方法二:主定理(Master Theorem) ​

📚 拓展 · 超纲:主定理属提高内容,五级考纲只要求"含多项式、对数的复杂度估算"。能识别 T(n)=aT(n/b)+f(n) 的常见形式即可,不要求严格推导。

对于形如

T(n) = a · T(n/b) + f(n)

的递归式(其中 a ≥ 1,b > 1,f(n) 是分解/合并的代价),主定理给出三种情况:

设 crit = log_b a(即 n^{log_b a}),比较 f(n) 与 n^{crit}:

情况条件结论直觉
①f(n) = O(n^{crit - ε}),ε>0(f 更"轻")T(n) = Θ(n^{crit})叶子节点主导
②f(n) = Θ(n^{crit} · logᵏ n),k≥0(同阶)T(n) = Θ(n^{crit} · log^{k+1} n)两者相当,多乘 log
③f(n) = Ω(n^{crit + ε}),ε>0(f 更"重"),且满足正则条件T(n) = Θ(f(n))根节点主导

💡 GESP 五级不需要严格证明,能识别最常见的形式即可。

7.3 主定理常见例子 ​

递归式abcrit = log_b af(n)情况结果
二分查找 T(n)=T(n/2)+O(1)120O(1)=O(n⁰)②(k=0)O(log n)
归并排序 T(n)=2T(n/2)+O(n)221O(n)=O(n¹)②(k=0)O(n log n)
二分递归+线性扫描 T(n)=2T(n/2)+O(1)221O(1) 更轻①O(n)
Strassen 矩阵乘法 T(n)=7T(n/2)+O(n²)72≈2.81O(n²) 更轻①O(n^2.81)
不当递归 T(n)=2T(n/2)+O(n²)221O(n²) 更重③O(n²)

7.4 斐波那契:O(2ⁿ) → O(n) 的飞跃 ​

① 朴素递归(O(2ⁿ),❌):前面已讲,大量重复计算。

② 记忆化搜索(O(n),✅):用数组记录已算过的值。

cpp
#include <iostream>
#include <algorithm>
using namespace std;

long long memo[105]; // 记忆化数组

long long fib(int n) {
    if (n <= 1) return n;
    if (memo[n] != -1) return memo[n]; // 已算过,直接返回
    return memo[n] = fib(n - 1) + fib(n - 2);
}

int main() {
    fill(memo, memo + 105, -1); // 初始化为"未计算"
    cout << fib(50) << endl;    // 瞬间出结果
    return 0;
}

每个 fib(i) 只计算一次,共计算 n 个值 → 时间 O(n),空间 O(n)。

③ 递推迭代(O(n) 时间 + O(1) 空间,✅✅):

cpp
long long fib(int n) {
    if (n <= 1) return n;
    long long a = 0, b = 1;
    for (int i = 2; i <= n; i++) {
        long long c = a + b;
        a = b;
        b = c;
    }
    return b;
}

🎯 对比总结:

方法时间空间
朴素递归O(2ⁿ) ❌O(n)
记忆化搜索O(n) ✅O(n)
递推迭代O(n) ✅O(1) ✅

这就是典型的时空权衡——用记忆化的 O(n) 空间,把 O(2ⁿ) 时间降到了 O(n)。


八、最好 / 最坏 / 平均情况 ​

很多算法在不同输入下表现不同。以快速排序为例:

8.1 快速排序回顾 ​

cpp
int partition(int a[], int l, int r) {
    int pivot = a[r];      // 选最右元素为基准
    int i = l - 1;
    for (int j = l; j < r; j++) {
        if (a[j] < pivot) {
            i++;
            swap(a[i], a[j]);
        }
    }
    swap(a[i + 1], a[r]);
    return i + 1;
}

void quickSort(int a[], int l, int r) {
    if (l >= r) return;
    int p = partition(a, l, r);
    quickSort(a, l, p - 1);
    quickSort(a, p + 1, r);
}

8.2 三种情况 ​

情况输入特征递归树形态复杂度
最好每次 pivot 都把数组均分(在中间)完美平衡二叉树,log n 层O(n log n)
平均随机输入大致平衡O(n log n)
最坏数组已有序(或逆序),pivot 每次在端点斜树,只有一个子问题,n 层O(n²)

⚠️ 考点提示:题目若只问"快速排序的时间复杂度",通常默认填 O(n log n);若明确问"最坏情况",则答 O(n²)。归并排序没有最坏情况退化问题,始终是 O(n log n),这是它相对于快排的优势之一。

其他常见三态算法:

算法最好平均最坏
冒泡排序(已优化)O(n)O(n²)O(n²)
插入排序O(n)O(n²)O(n²)
选择排序O(n²)O(n²)O(n²)
线性查找O(1)O(n)O(n)
二分查找O(1)O(log n)O(log n)

九、根据数据范围反推复杂度(⚡ 实战核心技能) ​

这是选择题中最实用的技巧:看到题目给的数据范围,立刻判断什么复杂度能过。

9.1 经验表(以"1 秒 ≈ 10⁸ 次操作"估算) ​

数据规模 n可接受的复杂度对应算法
n ≤ 10O(n!)、O(2ⁿ)暴力全排列、子集枚举
n ≤ 20O(2ⁿ)、O(n!)状压 DP、暴力搜索
n ≤ 100O(n⁴) 勉强,O(n³) 稳妥三层循环 DP
n ≤ 1,000O(n²)、O(n² log n)双层循环、 floyd
n ≤ 10⁴O(n²) 极限冒泡/选择排序上限
n ≤ 10⁵O(n log n)归并/快排、二分
n ≤ 10⁶O(n log n) 或 O(n)排序、线性扫描、哈希
n ≤ 10⁷O(n)只能线性扫描
n ≤ 10⁹O(log n)、O(1)二分、数学公式、快速幂

9.2 记忆口诀 ​

  • n ≤ 20 → 指数算法还能救
  • n ≤ 100 → O(n⁴) 也能凑
  • n ≤ 1000 → O(n³) 正合适
  • n ≤ 1e5 → 必须 n log n
  • n ≤ 1e6 → n log n 是底线
  • n ≤ 1e9 → 只能 log n

9.3 实战判断流程 ​

拿到题目
   │
   ├── 看 n 多大?
   │
   ├── n ≤ 20  → 可以写 DFS / 状压 DP(O(2ⁿ) 或 O(n·2ⁿ))
   ├── n ≤ 100 → 可以写 O(n³)(如 Floyd、区间 DP)
   ├── n ≤ 1000 → 可以写 O(n²)(双重循环 DP)
   ├── n ≤ 1e5 → 必须 O(n log n)(排序、二分、堆)
   ├── n ≤ 1e6 → 必须 O(n log n) 或 O(n)
   └── n > 1e8 → 必须 O(log n) 或 O(1)(二分/数学)

🎯 GESP 真题套路:题目给 n ≤ 10⁵,选项出现 O(n²)——直接排除;给 n ≤ 20,出现 O(2ⁿ)——可能正确。


十、时空权衡 🔄 ​

时空权衡(Time-Space Tradeoff):用更多的存储空间来换取更快的运行时间,是算法优化的核心思想之一。

10.1 记忆化(Memoization) ​

前面斐波那契的例子:用 O(n) 数组记住已算结果,把 O(2ⁿ) 降到 O(n)。

10.2 预处理 / 前缀和 ​

cpp
// 问题:多次查询数组区间 [l, r] 的和
// 朴素:每次遍历 O(n),查询 m 次 → O(nm)
// 前缀和:预处理 O(n),每次查询 O(1),共 O(n + m)

int a[MAXN], pre[MAXN];

void buildPrefix(int n) {
    pre[0] = 0;
    for (int i = 1; i <= n; i++) {
        pre[i] = pre[i - 1] + a[i]; // 预处理
    }
}

int rangeSum(int l, int r) {
    return pre[r] - pre[l - 1]; // O(1) 查询
}

用 O(n) 的 pre 数组,把查询从 O(n) 降到 O(1)。

10.3 哈希表:O(1) 查找的代价 ​

cpp
#include <unordered_map>
using namespace std;

// 在数组中找两个数之和为 target
// 朴素:双重循环 O(n²),O(1) 空间
// 哈希:一遍扫描 O(n),O(n) 空间
bool twoSum(int a[], int n, int target) {
    unordered_map<int, bool> seen;
    for (int i = 0; i < n; i++) {
        if (seen.count(target - a[i])) return true;
        seen[a[i]] = true;
    }
    return false;
}

10.4 常见时空权衡策略 ​

策略空间代价时间收益
记忆化搜索O(状态数)指数 → 多项式
前缀和 / 差分O(n)区间查询 O(1)
哈希表O(n)查找 O(1)
打表(预处理所有答案)O(答案范围)查询 O(1)
动态规划(vs 递归)O(状态数)避免重复子问题
计数排序O(值域)O(n + 值域)

💡 编程中的平衡:空间有限时不能一味"以空间换时间";时间紧迫时,适当的预处理可以救命。要根据题目限制灵活选择。


十一、看代码判复杂度——实战技巧 🧐 ​

GESP 选择题常给一段代码问复杂度。按以下步骤分析:

  1. 找循环:一层循环看跟谁成正比(n?log n?固定?)
  2. 看嵌套:嵌套循环把各层次数相乘
  3. 注意折半:循环变量 i *= 2 或 i /= 2 → log n
  4. 注意递归:写出递归式,用递归树/主定理
  5. 取最坏:大 O 描述的是最坏情况(除非特别说明)
  6. 忽略常量:3n、100n、n+5 都是 O(n)

示例分析 ​

cpp
for (int i = 1; i < n; i *= 2) {     // 执行 log₂n 次
    for (int j = 0; j < n; j++) {    // 每次 n 次
        // O(1) 操作
    }
}
// 总次数 = log₂n × n = O(n log n)
cpp
for (int i = 1; i < n; i++) {
    for (int j = i; j < n; j *= 2) {
        // 注意 j 从 i 开始倍增
    }
}
// 外层 n 次,内层最多 log n 次 → O(n log n)
cpp
for (int i = 1; i < n; i++) {
    for (int j = 0; j < n; j += i) {
        
    }
}
// i=1: n 次;i=2: n/2 次;... i=n: 1 次
// 总和 = n(1 + 1/2 + 1/3 + ... + 1/n) ≈ n ln n = O(n log n)

⚠️ 调和级数:1 + 1/2 + 1/3 + ... + 1/n ≈ ln n,是 O(log n) 量级,考试中偶尔出现。


十二、📝 实战练习 ​

练习题 1(程序阅读题) ​

题目: 阅读以下两个判断素数的函数,回答问题。

cpp
#include <iostream>
using namespace std;

bool isPrimeA(int N) {
    if (N < 2) return false;
    for (int i = 2; i < N; i++) {
        if (N % i == 0) return false;
    }
    return true;
}

bool isPrimeB(int N) {
    if (N < 2) return false;
    for (int i = 2; i * i <= N; i++) {
        if (N % i == 0) return false;
    }
    return true;
}

关于两个函数的时间复杂度,下列说法正确的是( )

A. isPrimeA 更优,时间复杂度为 O(N)
B. isPrimeB 更优,时间复杂度为 O(√N)
C. 两个函数时间复杂度相同,都是 O(N)
D. isPrimeA 更优,时间复杂度为 O(√N)

🔑 点击查看答案与解析

答案: B

  • isPrimeA:for 循环 i 从 2 到 N-1,最坏(N 是素数)执行 N-2 次 → O(N)
  • isPrimeB:for 循环 i 从 2 到 √N,最坏执行 √N-1 次 → O(√N)
  • O(√N) < O(N),所以 isPrimeB 更优。

🎯 此题是 GESP 五级经典真题,核心是识别 试除法判素数优化到 √n。

--- ### 练习题 2(单选题)

题目: 下列代码的时间复杂度为()

cpp
for (int i = 1; i < n; i *= 2) {
    cout << i << " ";
}

A. O(n)  B. O(log n)  C. O(n log n)  D. O(n²)

🔑 点击查看答案与解析

答案: B

循环变量 i 每次翻倍:1, 2, 4, 8, ..., 直到 ≥ n。共执行 log₂n 次,每次 O(1) → O(log n)。

--- ### 练习题 3(单选题)

题目: 下列关于归并排序和快速排序的叙述,正确的是()

A. 归并排序最坏情况下时间复杂度为 O(n²) B. 快速排序在任何情况下都是 O(n log n) C. 归并排序需要 O(n) 的辅助空间,快速排序平均时间复杂度 O(n log n) D. 快速排序是稳定排序,归并排序是不稳定排序

🔑 点击查看答案与解析

答案: C

  • A 错:归并排序始终是 O(n log n),不会退化。
  • B 错:快排最坏(有序数组 + 端点 pivot)为 O(n²)。
  • C 正确:归并需要临时数组合并 O(n) 空间;快排平均 O(n log n)。
  • D 错:说反了——归并稳定,快排不稳定。
---

练习题 4(单选题) ​

题目: 某题数据规模为 n ≤ 20,要求统计一个集合中满足某条件的子集个数。下列复杂度中最合适的是()

A. O(n!)  B. O(2ⁿ)  C. O(n³)  D. O(n log n)

🔑 点击查看答案与解析

答案: B

  • 子集总数为 2ⁿ,枚举子集的复杂度自然是 O(2ⁿ)。
  • n=20 时 2²⁰ ≈ 10⁶(约 100 万),在 1 秒内可完成。
  • O(n!) 一般用于全排列问题;n ≤ 20 时 O(2ⁿ) 最贴合子集枚举场景。
  • 如果题目允许,也可以用状压 DP 在 O(n·2ⁿ) 内解决,但基础枚举就是 O(2ⁿ)。

💡 这就是"根据数据范围反推复杂度"的典型应用:n=20 是 O(2ⁿ) 的标志性信号。

--- ### 练习题 5(单选题)

题目: 已知递归关系 T(n) = 2T(n/2) + O(n),且 T(1) = O(1)。则 T(n) = ()

A. O(log n)  B. O(n)  C. O(n log n)  D. O(n²)

🔑 点击查看答案与解析

答案: C

这是最经典的主定理形式:

  • a = 2,b = 2,crit = log₂2 = 1
  • f(n) = O(n) = O(n¹),与 ncritn^{crit} 同阶 → 主定理第 ② 种情况(k=0)
  • 结果 = Θ(n¹ · log¹ n) = O(n log n)

这正是归并排序的递归式。用递归树也能直观看出:共 log n 层,每层工作量 n,总和 n log n。

---

练习题 6(单选题) ​

题目: 递归求阶乘的空间复杂度是?( )

A. O(1)
B. O(n)
C. O(log n)
D. O(n²)

🔑 点击查看答案与解析

答案: B

解析:

递归的空间复杂度主要取决于递归深度(调用栈的深度)。

阶乘递归 fact(n) 的调用链:

  • fact(n) → fact(n-1) → fact(n-2) → ... → fact(1)

递归深度为 n,每层需要一个栈帧,所以空间复杂度是 O(n)。

选 B。

⚠️ 易错点:不要只看声明了几个变量就以为空间是 O(1),递归的栈空间也是空间!

---

练习题 7(单选题) ​

题目: 如果某题的数据范围是 n ≤ 10⁵(10万),下面哪种复杂度的算法最可能通过?( )

A. O(n²)
B. O(n log n)
C. O(2ⁿ)
D. O(n!)

🔑 点击查看答案与解析

答案: B

解析:

估算各复杂度在 n=10⁵ 时的操作数:

  • A. O(n²) = 10¹⁰ = 100 亿次 → 太慢了,肯定超时
  • B. O(n log n) ≈ 10⁵ × 17 ≈ 170 万次 → ✅ 没问题
  • C. O(2ⁿ) → 天文数字,想都不要想
  • D. O(n!) → 更不用说了

经验规则(1秒大约能跑 10⁷ ~ 10⁸ 次操作):

  • n ≤ 20 → O(2ⁿ) 可以
  • n ≤ 1000 → O(n²) 可以
  • n ≤ 10⁵ → O(n log n) 可以
  • n ≤ 10⁷ → O(n) 可以
  • n ≤ 10⁹ → O(log n) 可以

选 B。

💡 实战技巧:考试时看到 n 的范围,先反推应该用什么复杂度的算法,再想对应的算法是什么。

---

练习题 8(判断题) ​

题目: 以下复杂度从小到大排列是否正确:O(1) < O(log n) < O(n) < O(n²) < O(n log n) < O(2ⁿ)( )

🔑 点击查看答案与解析

答案: 错误 ❌

解析:

正确的从小到大顺序是:

O(1) < O(log n) < O(n) < O(n log n) < O(n²) < O(n³) < O(2ⁿ) < O(n!)

题目中把 O(n²) 放在了 O(n log n) 前面,是错误的。

n log n 比 n² 小得多!比如 n=1000 时:

  • n log n ≈ 1000 × 10 = 10,000
  • n² = 1,000,000

差了 100 倍!

🔥 必背口诀:常对幂指阶(常数、对数、多项式、指数、阶乘)。多项式里面还有:n < n log n < n² < n³。

---

练习题 9(程序阅读题) ​

题目: 阅读以下代码,分析其时间复杂度。

cpp
int fun(int n) {
    int count = 0;
    for (int i = 0; i < n; i++) {
        for (int j = 1; j < n; j *= 2) {
            count++;
        }
    }
    return count;
}

(1)这段代码的时间复杂度是?
A. O(n)  B. O(n²)  C. O(n log n)  D. O(log n)

(2)当 n = 1024 时,count 的值大约是多少?
A. 1024  B. 10240  C. 524288  D. 1048576

🔑 点击查看答案与解析

答案: (1)C (2)B

解析:

(1)时间复杂度分析:

  • 外层循环 i 从 0 到 n-1,共执行 n 次
  • 内层循环 j 从 1 开始,每次乘 2,直到 j >= n 停止。即 j 取值为 1, 2, 4, 8, ..., 共 log₂n 次
  • 总执行次数 = n × log₂n → O(n log n)

选 C。

(2)n = 1024 时 count 的值:

  • 外层 1024 次
  • 内层:log₂1024 = 10 次(2¹⁰ = 1024)
  • count ≈ 1024 × 10 = 10240

选 B。

💡 经典组合:外层线性 + 内层折半/倍增 = O(n log n)。归并排序、快速排序(平均)都是这个复杂度量级。


练习题 10(编程题) ​

题目: 题目描述:验证"1 秒约执行 10⁸ 次操作"的经验法则。给定一个整数 n,编写程序执行 n 次空循环(只做计数),输出执行的次数。

(本题用于理解数据规模与运行时间的关系,实际考试中会以选择题形式考查"给定 n 的范围,哪种复杂度能通过"。)

思考题:

  • 如果 n = 10⁷,程序大约运行多久?
  • 如果 n = 10⁹,会发生什么?
🔑 点击查看答案与解析

参考答案:

cpp
#include <iostream>
#include <ctime>
using namespace std;

int main() {
    long long n;
    cin >> n;
    
    clock_t start = clock();  // 记录开始时间
    
    long long count = 0;
    for (long long i = 0; i < n; i++) {
        count++;  // 简单操作:计数
    }
    
    clock_t end = clock();    // 记录结束时间
    double duration = (double)(end - start) / CLOCKS_PER_SEC;
    
    cout << "执行次数:" << count << endl;
    cout << "耗时:" << duration << " 秒" << endl;
    cout << "每秒操作数约:" << (long long)(n / duration) << endl;
    
    return 0;
}

解析与思考题答案:

这是一道"体验型"编程题,目的是让你直观感受不同复杂度在实际运行中的差异:

  • n = 10⁷(1000万):约 0.1 秒左右,很快完成
  • n = 10⁸(1亿):约 1 秒,这就是经验法则的来源
  • n = 10⁹(10亿):约 10 秒,严重超时

复杂度与数据规模对照表(C++,1秒):

复杂度安全上限典型算法
O(n)~ 10⁷ ~ 10⁸线性扫描、计数排序
O(n log n)~ 10⁶ ~ 2×10⁶归并排序、快速排序
O(n²)~ 5000 ~ 10⁴冒泡排序、二维DP
O(n³)~ 200 ~ 300Floyd最短路、三维DP
O(2ⁿ)n ≤ 20 ~ 25子集枚举、状压DP

🔥 实战技巧:拿到题先看 n 的范围,反推算法复杂度,再想对应的算法是什么。这是竞赛选手的"条件反射"。


十三、📊 考点总结表 ​

考点重要程度出题形式易错点
大O表示法定义与规则⭐⭐⭐选择、判断忽略常数和低阶项的规则理解不清
循环复杂度分析⭐⭐⭐选择、程序阅读内层循环次数不仔细数,想当然按层数判断
对数复杂度来源⭐⭐⭐选择折半/倍增导致O(log n)的场景识别
递归复杂度(递归树+主定理)⭐⭐选择、判断主定理三种情况混淆;递归树层数算错
快速排序三种情况⭐⭐⭐选择、判断最坏情况O(n²)的触发条件记不清
空间复杂度(含递归栈)⭐⭐⭐选择、判断忽略递归栈空间;原地算法概念不清
数据范围反推复杂度⭐⭐⭐选择1秒操作数量级记忆不准;不留安全余量
时空权衡思想⭐⭐选择、判断记忆化/前缀和/哈希表的空间代价理解
常见算法复杂度对比⭐⭐⭐选择、判断排序算法的时间/空间/稳定性张冠李戴
复杂度增长排序⭐⭐⭐选择、判断O(√n)、O(n log n)等中间量级位置记错

⭐ 必背速记卡 ​

┌──────────────────────────────────────────────────────────────────┐
│                   ⭐ GESP 五级复杂度必背要点                      │
├──────────────────────────────────────────────────────────────────┤
│                                                                  │
│  1. 增长率排序:                                                  │
│     O(1) < O(log n) < O(n) < O(n log n) < O(n²) < O(n³)         │
│             < O(2ⁿ) < O(n!)                                      │
│                                                                  │
│  2. 循环变量倍增/折半 → O(log n)                                 │
│  3. k 层嵌套循环,每层跟 n 相关 → O(nᵏ)                          │
│  4. 归并排序:始终 O(n log n),空间 O(n),稳定                    │
│  5. 快速排序:平均 O(n log n),最坏 O(n²),不稳定,原地           │
│  6. 冒泡/选择/插入:O(n²),其中冒泡/插入最好情况 O(n)            │
│  7. 试除法判素数:O(√n)                                          │
│  8. 朴素斐波那契:O(2ⁿ);记忆化/递推:O(n)                       │
│  9. 主定理 T(n)=aT(n/b)+f(n):                                   │
│     - f 更轻 → O(n^{log_b a})                                    │
│     - 同阶   → O(n^{crit} log n)                                 │
│     - f 更重 → O(f(n))                                           │
│ 10. n ≤ 20 可 O(2ⁿ);n ≤ 1e5 须 O(n log n);n ≤ 1e9 须 O(log n)│
│ 11. 辅助空间 O(1) = in-place;递归深度 = 栈空间                  │
│ 12. 时空权衡:记忆化、前缀和、哈希表——用空间换时间              │
│                                                                  │
└──────────────────────────────────────────────────────────────────┘

百炼成钢,融会贯通