Skip to content

第9章 算法优化 ​


🎯 本章学习目标 ​

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

  1. 理解优化思想:明白同一问题有多种解法,效率差距可达天壤之别
  2. 掌握打表预处理:会用筛法、前缀和等空间换时间的技巧
  3. 掌握记忆化搜索:能把朴素递归改写成记忆化版本
  4. 掌握快速幂:能手写快速幂模板,理解倍增思想
  5. 理解单调队列(📚 拓展 · 超纲):知道滑动窗口最大值的单调队列解法
  6. 掌握排序后处理:会用排序+双指针优化两数之和等问题
  7. 掌握贪心选择:理解贪心适用条件,会活动安排等经典问题
  8. 学会数据结构选型:知道不同场景下数组/哈希/堆/并查集的选择
  9. 会用数学公式优化:能用等差/等比求和、约瑟夫递推等替代模拟
  10. 培养优化思维:能分析朴素算法瓶颈,选择合适的优化方向

🗺️ 本章知识图谱 ​

算法优化
├── 优化思想
│   ├── 同一问题多种解法
│   │   ├── 斐波那契进化链:O(2ⁿ)→O(n)→O(log n)
│   │   ├── 求和:循环O(n)→公式O(1)
│   │   └── 查找:顺序O(n)→二分O(log n)
│   └── 图论算法选型
│       ├── MST:稠密图Prim,稀疏图Kruskal
│       └── 最短路:Dijkstra / Floyd / Bellman-Ford
├── 七板斧优化方法
│   ├── 打表/预处理:空间换时间
│   │   ├── 埃氏筛法
│   │   └── 前缀和
│   ├── 记忆化搜索:给递归装缓存
│   ├── 去冗余计算:快速幂/倍增
│   ├── 单调队列:滑动窗口最值
│   ├── 排序后处理:排序+双指针
│   ├── 贪心选择:局部最优→全局最优
│   └── 数据结构选型
│       ├── 哈希表:查找O(1)
│       ├── 堆:取最值O(log n)
│       └── 并查集:连通性≈O(1)
├── 数学优化
│   ├── 等差数列求和:O(1)
│   ├── 等比数列求和:O(log n)(+快速幂)
│   ├── 约瑟夫递推:O(n)
│   ├── 周期问题:取模映射
│   └── 消元法:减少循环层数
└── 答题策略
    ├── 编程题:先朴素拿分,再逐步优化
    └── 选择题:看数据规模+前提条件

一、📋 考纲原文与考点全景 ​

据 CCF 官方八级考纲,算法优化部分要求:

掌握算法优化的思想与方法。理解不同算法求解同一问题的效率差异;掌握打表/预处理、记忆化搜索、去除冗余计算、单调栈与队列、排序后处理、贪心选择、合理选择数据结构等常用优化手段;能够运用等差数列、等比数列求和公式及数学推导,将模拟/枚举类算法优化为公式算法。

考点难度选择/判断编程题
同一问题多解法复杂度对比(斐波那契/求和/查找)⭐⭐🔥极高高
打表、预处理、前缀和(空间换时间)⭐⭐高🔥极高
记忆化搜索⭐⭐高高
快速幂、单调队列、排序+双指针⭐⭐⭐中高
数据结构选型(哈希/堆/并查集)⭐⭐⭐高中
等差/等比求和公式 O(1) 优化⭐⭐🔥极高高
公式法替代模拟(约瑟夫、周期问题)⭐⭐⭐中高

🔥 高频考点:八级选择题特别爱考"给出程序,判断优化前后的时间复杂度";编程题则常把"朴素做法超时、优化做法 AC"作为区分度——会不会优化,直接决定你是 60 分还是 90 分。


二、同一问题,多种解法:横向对比中的优化思维 🧠 ​

算法优化的第一课,不是学新算法,而是建立一个信念:一个问题永远不止一种解法,解法之间的效率差距可以大到离谱。

初级阶段的目标是"能做对"——程序跑出正确答案就万事大吉;而八级要求的思维转变是"做得快":在动笔写代码之前,先估算数据规模下朴素做法会不会超时、有没有重复计算可以消除、能不能用一个公式代替一段循环。请记住第8期学过的"1 秒法则":现代评测机 1 秒大约能执行 10⁸ 次简单运算。如果朴素做法的运算量达到 10¹⁰,它不是"慢一点",而是注定超时,此时优化不是锦上添花,而是唯一的活路。

1.1 斐波那契:一条完整的优化进化链 🐇 ​

求斐波那契数列第 n 项 F(n)(F(0)=0, F(1)=1, F(n)=F(n-1)+F(n-2)),是检验优化思想的最佳标本:

解法时间复杂度空间复杂度n=40 的感受
朴素递归O(2ⁿ)O(n) 栈要算上亿次,肉眼可见地卡
记忆化搜索O(n)O(n)瞬间出结果
递推(迭代)O(n)O(1)瞬间出结果
矩阵快速幂 / 通项公式O(log n)O(1)n=10⁹ 也不怕

朴素写法(两语言对照):

cpp
// C++:朴素递归,O(2^n),大量重复计算
long long fib(int n) {
    if (n <= 1) return n;
    return fib(n - 1) + fib(n - 2);  // fib(n-2) 被反复计算
}

⚠️ 易错点:朴素递归慢的根源不是"递归"本身,而是重复计算——F(5) 会被算 5 次,F(10) 会被算 55 次,F(n) 的调用次数本身就是斐波那契数!

优化写法:矩阵快速幂 O(log n)(📚 拓展 · 超纲)

利用恒等式 (Fn+1FnFnFn−1)=(1110)n\begin{pmatrix}F_{n+1}&F_n\\F_n&F_{n-1}\end{pmatrix}=\begin{pmatrix}1&1\\1&0\end{pmatrix}^n,配合第5章学过的倍增/快速幂思想,把 n 次转移压成 log n 次矩阵乘法:

cpp
// C++:矩阵快速幂求斐波那契,O(log n)
typedef long long ll;
void mul(ll a[2][2], ll b[2][2], ll c[2][2]) {
    ll t[2][2] = {0};
    for (int i = 0; i < 2; i++)
        for (int j = 0; j < 2; j++)
            for (int k = 0; k < 2; k++)
                t[i][j] += a[i][k] * b[k][j];
    for (int i = 0; i < 2; i++)
        for (int j = 0; j < 2; j++) c[i][j] = t[i][j];
}
ll fib(ll n) {
    ll res[2][2] = { {1,0},{0,1} };       // 单位矩阵(相当于 1)
    ll base[2][2] = { {1,1},{1,0} };      // 转移矩阵
    while (n) {                          // 快速幂框架
        if (n & 1) mul(res, base, res);
        mul(base, base, base);
        n >>= 1;
    }
    return res[0][1];                    // res = base^n,右上角即 F(n)
}

🔥 高频考点:O(2ⁿ) → O(n) → O(log n) 这条进化链,选择题常考"哪种写法能处理 n=10⁹"(答案:矩阵快速幂/通项公式),判断题常考"记忆化搜索把时间复杂度从 O(2ⁿ) 降到了 O(n²)"(❌,是降到 O(n))。

1.2 求和:循环累加 O(n) → 公式 O(1) ​

求 1+2+…+n:

cpp
// C++ 朴素:循环累加 O(n)
long long s = 0;
for (int i = 1; i <= n; i++) s += i;

// C++ 优化:等差求和公式 O(1)
long long s = 1LL * n * (n + 1) / 2;

n=10⁹ 时,循环要跑 10 亿次,公式只算一次乘除——这就是数学的力量。

1.3 查找:顺序查找 O(n) → 二分查找 O(log n) ​

在有序数组中找 x:

cpp
// C++ 朴素:顺序查找 O(n)
int findNaive(int a[], int n, int x) {
    for (int i = 0; i < n; i++)
        if (a[i] == x) return i;
    return -1;
}

// C++ 优化:二分查找 O(log n)(前提:数组有序)
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;
        if (a[mid] < x) l = mid + 1;
        else r = mid - 1;
    }
    return -1;
}

⚠️ 易错点:二分查找的前提是数据有序!在无序数组上二分,结果毫无意义。n=10⁶ 时 O(n) 与 O(log n) ≈ 20 次比较,差距约 5 万倍。另外注意取中点要用 l + (r-l)/2 而非 (l+r)/2——两数相加在极端情况下可能溢出 int 范围,这是笔试读代码题里的经典细节。

1.4 图论算法选型:没有"最好",只有"最合适" ​

第7期学过的图论算法,选型本身就是优化:

问题算法时间复杂度适用场景
最小生成树Prim(邻接矩阵)O(n²)稠密图、点数少
最小生成树Kruskal(并查集)O(m log m)稀疏图、边数少
单源最短路Dijkstra(堆优化)O(m log n)边权非负
单源最短路Bellman-Ford / SPFAO(nm)有负权边/判负环
全源最短路FloydO(n³)n 很小、一次求所有点对

🔥 高频考点:"边权为负的图能不能用 Dijkstra?"——不能(贪心选最小距离的前提被破坏);"n=100 的稠密图求全源最短路用什么?"——Floyd,代码最短、常数最小。


三、算法优化的一般方法:"七板斧" 🛠️ ​

2.1 打表 / 预处理:空间换时间 📦 ​

思想:把会被反复用到的结果提前算好存起来,用空间换时间。

典型场景:Q 次询问"x 是不是质数"。朴素做法每次试除,总复杂度 O(Q·√x);埃氏筛预处理后每次询问 O(1):

cpp
// C++ 朴素:每次询问试除 O(√x)
bool isPrimeNaive(int x) {
    if (x < 2) return false;
    for (int i = 2; 1LL * i * i <= x; i++)
        if (x % i == 0) return false;
    return true;
}

// C++ 优化:埃氏筛预处理 O(N log log N),询问 O(1)
const int MAXN = 1000005;
bool notPrime[MAXN];
void sieve() {
    notPrime[0] = notPrime[1] = true;
    for (int i = 2; 1LL * i * i < MAXN; i++)
        if (!notPrime[i])
            for (int j = i * i; j < MAXN; j += i)
                notPrime[j] = true;
}
// 询问:!notPrime[x] 即为质数

同族利器——前缀和:区间和询问从 O(n) 降到 O(1):

cpp
// C++:前缀和预处理 O(n),每次区间询问 O(1)
long long pre[1000005];
for (int i = 1; i <= n; i++) pre[i] = pre[i - 1] + a[i];
// 区间 [l, r] 的和:
long long s = pre[r] - pre[l - 1];
对比预处理单次询问Q 次询问总复杂度
朴素(每次现算)无O(n)O(Q·n)
前缀和/筛法O(n)O(1)O(n + Q)

2.2 记忆化搜索:给递归装个"缓存" 🧾 ​

思想:递归函数算过的状态存起来,第二次遇到直接返回。以"走台阶(每次1或2级)"为例:

cpp
// C++ 朴素:裸递归 O(2^n)
long long waysNaive(int n) {
    if (n <= 1) return 1;
    return waysNaive(n - 1) + waysNaive(n - 2);
}

// C++ 优化:记忆化搜索 O(n)
long long memo[1005];
long long ways(int n) {
    if (n <= 1) return 1;
    if (memo[n]) return memo[n];          // 算过就直接取
    return memo[n] = ways(n - 1) + ways(n - 2);
}

💡 记忆化搜索与递推(DP)时间复杂度相同,区别只是自顶向下 vs 自底向上;它只算"用得到的状态",在状态稀疏时甚至比递推更省。

2.3 去掉冗余计算:快速幂 ⚡ ​

求 aᵇ mod m,朴素连乘做 b 次乘法;注意到 aᵇ = (a^(b/2))²,把指数折半:

cpp
// C++ 朴素:连乘 O(b)
long long powNaive(long long a, long long b, long long mod) {
    long long res = 1;
    for (long long i = 0; i < b; i++) res = res * a % mod;
    return res;
}

// C++ 优化:快速幂 O(log b)
long long qpow(long long a, long long b, long long mod) {
    long long res = 1;
    while (b) {
        if (b & 1) res = res * a % mod;  // 指数该位为1,乘进答案
        a = a * a % mod;                 // 底数平方
        b >>= 1;                         // 指数右移一位
    }
    return res;
}

b=10⁹ 时,O(b) 必死,O(log b) ≈ 30 次循环轻松 AC。

2.4 单调队列入门:滑动窗口最大值 🪟(📚 拓展 · 超纲) ​

问题:长度为 n 的数组,求所有长度为 k 的滑动窗口的最大值。朴素做法每个窗口扫一遍 O(nk);用单调双端队列维护"候选最大值",每个元素至多入队出队一次,O(n):

cpp
// C++ 优化:单调队列 O(n)
#include <deque>
deque<int> dq;                       // 存下标,队首→队尾对应值严格递减
for (int i = 0; i < n; i++) {
    while (!dq.empty() && dq.front() <= i - k) dq.pop_front(); // 队首过期
    while (!dq.empty() && a[dq.back()] <= a[i]) dq.pop_back();  // 保递减
    dq.push_back(i);
    if (i >= k - 1) cout << a[dq.front()] << " ";               // 队首即窗口最大
}

🔥 高频考点:单调队列里存的是下标不是值(要判断过期);队列内对应的值保持单调递减求最大值、单调递增求最小值。

2.5 排序后处理:两数之和 🔢 ​

问题:数组中是否存在两个数之和等于 target。朴素双重循环 O(n²);先排序,再双指针 O(n log n):

cpp
// C++ 朴素:双重循环 O(n^2)
for (int i = 0; i < n; i++)
    for (int j = i + 1; j < n; j++)
        if (a[i] + a[j] == target) { /* 找到 */ }

// C++ 优化:排序 + 双指针 O(n log n)
sort(a, a + n);
int l = 0, r = n - 1;
while (l < r) {
    int s = a[l] + a[r];
    if (s == target) { /* 找到 */ break; }
    else if (s < target) l++;   // 和太小,左指针右移
    else r--;                   // 和太大,右指针左移
}

排序后处理的同族应用还有:双指针去重、排序后贪心、排序后二分——"先排序"往往是 O(n²) → O(n log n) 的第一步。

2.6 贪心选择:活动安排问题 📅 ​

问题:n 个活动各有开始/结束时间,同一时刻只能参加一个,最多能参加几个?朴素枚举/DP 是 O(n²) 起步;按结束时间排序后贪心——结束越早越先选,给后面留更多时间——O(n log n):

cpp
// C++ 优化:贪心 O(n log n)
struct Act { int s, e; };
sort(acts, acts + n, [](const Act& x, const Act& y) {
    return x.e < y.e;                     // 按结束时间升序
});
int cnt = 0, lastEnd = -1;
for (int i = 0; i < n; i++)
    if (acts[i].s >= lastEnd) {           // 与上一个选中活动不冲突
        cnt++;
        lastEnd = acts[i].e;
    }

⚠️ 易错点:贪心不是万能的!它能用的前提是问题具备贪心选择性质(局部最优能推出全局最优)。0/1 背包用贪心选单位价值最高的物品就是错的——考试判断题常设此陷阱。

2.7 数据结构选型:选对"容器",复杂度天差地别 🗂️ ​

同一个操作,用不同数据结构,复杂度可能跨数量级:

需求数组哈希表堆(优先队列)并查集
按值查找/判存在O(n)O(1)O(n)—
取最值O(n) 排序 O(n log n)O(n)O(log n)—
插入/删除O(n)O(1)O(log n)—
合并集合/判连通O(n) DFS——≈O(1)

并查集模板(连通性问题的标配,Kruskal 算法的核心):

cpp
// C++:并查集(路径压缩)
int fa[100005];
void init(int n) { for (int i = 1; i <= n; i++) fa[i] = i; }
int find(int x) { return fa[x] == x ? x : fa[x] = find(fa[x]); }
void unite(int x, int y) { fa[find(x)] = find(y); }

🔥 高频考点:"统计不同元素个数"用哈希集合 O(n),不要双重循环 O(n²);"动态维护前 k 大"用堆;"朋友关系是否连通、修道路连通所有城市"用并查集。

2.8 优化手法速查总表 ​

考前最后再过一遍这张表,考场上对号入座:

症状(朴素做法)诊断优化药方复杂度变化
递归反复算同一状态重复计算记忆化搜索 / 递推O(2ⁿ)→O(n)
多次询问,每次现算重复劳动打表、筛法、前缀和预处理O(Qn)→O(n+Q)
循环里每次从头找最值/求和冗余扫描单调队列、堆、前缀和O(nk)→O(n)
双重循环找配对枚举无序排序 + 双指针 / 哈希表O(n²)→O(n log n)
每步都做选择且无后效性可贪心排序后贪心选择O(n²)→O(n log n)
连乘/连加次数巨大可折半快速幂 / 倍增O(n)→O(log n)
按值查找/判存在很慢容器选错数组换哈希表O(n)→O(1)
连通性判断反复 DFS容器选错并查集O(n)→≈O(1)
有规律的累加/计数可建模等差/等比公式、数学递推O(n)→O(1)

⚠️ 易错点:所有优化都有前提——二分要求有序、贪心要求贪心选择性质、记忆化要求子问题重叠、公式法要求结果仍在数值范围内(注意取模与溢出)。不看前提乱用优化,比不优化更危险。


四、用数学知识优化算法 🔢 ​

编程的尽头是数学。当一段循环所做的事情恰好是某个有公式的数列求和、某个有周期的规律、某个可以消元的方程组时,数学就能让成百上千次循环蒸发为一行表达式。八级考纲明确要求掌握两类数列求和公式,并能把"模拟/枚举"升级为"公式计算"。

3.1 等差数列求和:O(n) → O(1) ​

公式:Sn=n(a1+an)2=na1+n(n−1)2dS_n = \dfrac{n(a_1+a_n)}{2} = na_1 + \dfrac{n(n-1)}{2}d

求 1 到 100 所有奇数之和(1+3+5+…+99,共 50 项):

cpp
// C++ 朴素:循环 O(n)
long long s = 0;
for (int i = 1; i <= 99; i += 2) s += i;

// C++ 优化:公式 O(1):n=50 项,首项1,末项99
long long s = 50LL * (1 + 99) / 2;   // = 2500

3.2 等比数列求和:O(n) → O(1) ​

公式(q≠1):Sn=a1(1−qn)1−q=a1(qn−1)q−1S_n = \dfrac{a_1(1-q^n)}{1-q} = \dfrac{a_1(q^n-1)}{q-1}

经典例子:1+2+4+…+2ⁿ⁻¹ = 2ⁿ−1(国王赏麦故事)。求 1+3+32+⋯+3n−11+3+3^2+\cdots+3^{n-1}:

cpp
// C++ 朴素:循环累加 O(n)
long long s = 0, t = 1;
for (int i = 0; i < n; i++) { s += t; t *= 3; }

// C++ 优化:等比公式 + 快速幂 O(log n)
long long s = (qpow(3, n, MOD) - 1 + MOD) % MOD
              * qpow(2, MOD - 2, MOD) % MOD;  // (3^n-1)/(3-1),除法取模用逆元

⚠️ 易错点:整数场景等比公式可直接整除;若答案要取模,"除以 (q−1)"要换成乘逆元(费马小定理:q−1 的逆元为 (q−1)^(MOD−2)),这是八级编程题的隐蔽失分点。

3.3 公式法替代模拟:约瑟夫问题 🎯 ​

问题:n 个人围成圈编号 0~n−1,从 0 号开始报数,数到 k−1 的人出局,求最后幸存者编号。

朴素模拟用列表删除,每次删除 O(n),总计 O(n²),n=10⁶ 时超时。数学递推:设 J(n) 为 n 个人时的幸存者编号,则

J(1)=0,J(n)=(J(n−1)+k) mod nJ(1)=0,\qquad J(n)=(J(n-1)+k)\bmod n

cpp
// C++ 优化:数学递推 O(n)
int josephus(int n, int k) {
    int pos = 0;                        // J(1) = 0
    for (int i = 2; i <= n; i++)
        pos = (pos + k) % i;            // J(i) = (J(i-1)+k) mod i
    return pos;
}

周期问题同理:斐波那契数列的个位以 60 为周期(Pisano 周期 mod 10),求 F(n) 的个位无需算到 n,只需 n % 60:

周期思想在考试中十分常见:星期几的推算、循环小数的循环节、棋盘/灯阵按固定规律翻转,本质都是"先打一个周期的表,再用取模把大下标映射回一个周期内"。它把看似无穷的递推,压缩成有限张查找表——这正是打表法与数学规律的完美结合。

3.4 数学推导缩小枚举范围:百钱买百鸡 🐔 ​

公鸡 5 文/只、母鸡 3 文/只、小鸡 1 文 3 只,100 文买 100 只,各买多少?朴素三重枚举 O(n³)。

推导:设公鸡 x、母鸡 y、小鸡 z:

{x+y+z=1005x+3y+z3=100\begin{cases} x+y+z=100\\ 5x+3y+\dfrac{z}{3}=100 \end{cases}

第二式乘 3 减第一式:14x+8y=20014x+8y=200,即 7x+4y=1007x+4y=100,得 y=(100−7x)/4y=(100-7x)/4。只需枚举 x 一个变量,O(n³) → O(n):

cpp
// C++ 优化:数学推导后单重循环 O(n)
for (int x = 0; 7 * x <= 100; x++) {
    int rem = 100 - 7 * x;
    if (rem % 4 != 0) continue;        // y 必须是整数
    int y = rem / 4;
    int z = 100 - x - y;
    if (z >= 0 && z % 3 == 0)          // 小鸡数必须为 3 的倍数
        cout << x << " " << y << " " << z << endl;
}

💡 通用套路:遇到多重循环枚举,先列方程、用约束消元——每消掉一个变量,复杂度就降一个数量级。


五、综合例题 ✏️ ​

例题1(单选题) ​

下列求斐波那契第 n 项的方法中,能够在 n=10⁹ 时 1 秒内出结果的是( )

  • A. 朴素递归 fib(n)=fib(n-1)+fib(n-2)
  • B. 记忆化搜索
  • C. 一维数组递推
  • D. 矩阵快速幂
🔑 点击查看答案与解析

答案: D

A 为 O(2ⁿ),B、C 为 O(n) 且需要 O(n) 空间,n=10⁹ 都无法完成;矩阵快速幂为 O(log n),约 30 次矩阵乘法即可。通项公式法(比内公式)在理论上也是 O(1),但大数下浮点精度不足,竞赛中以矩阵快速幂为准。


例题2(判断题) ​

"打表预处理是用空间换时间,因此只要内存允许,对任何题目都应该先预处理,预处理不会带来任何坏处。"

🔑 点击查看答案与解析

答案: 错误 ❌

预处理有价值的前提是结果会被多次/反复使用。若只询问一次,预处理 O(N) 反而比直接计算 O(√x) 更慢;此外预处理占用内存,N 过大时可能 MLE。优化要结合数据范围与询问次数,没有"永远最优"的手段。


例题3(编程题):区间和询问 ​

题目:给定 n 个整数组成的数组 a,以及 Q 次询问,每次给出 l、r,求 a[l]+…+a[r](下标从 1 开始)。

输入:第一行 n、Q;第二行 n 个整数;接下来 Q 行每行两个整数 l、r。 数据范围:n, Q ≤ 10⁵,|aᵢ| ≤ 10⁴。

🔑 点击查看答案与解析

**解析:**朴素每次循环累加 O(Q·n) = 10¹⁰ 次运算,超时;前缀和预处理 O(n),每次询问 O(1),总计 O(n+Q)。

cpp
#include <iostream>
using namespace std;
long long pre[100005];
int main() {
    ios::sync_with_stdio(false);
    int n, Q;
    cin >> n >> Q;
    for (int i = 1; i <= n; i++) {
        long long x; cin >> x;
        pre[i] = pre[i - 1] + x;
    }
    while (Q--) {
        int l, r; cin >> l >> r;
        cout << pre[r] - pre[l - 1] << "\n";
    }
    return 0;
}

例题4(编程题):约瑟夫报数 ​

题目:n 个小朋友围成一圈编号 1~n,从 1 号开始报数,报到 m 的小朋友出列,下一个人重新从 1 开始报数。求最后剩下的小朋友编号。1 ≤ n ≤ 10⁶,1 ≤ m ≤ 1000。

🔑 点击查看答案与解析

**解析:**n 达 10⁶,链表/数组模拟删除 O(n²) 必然超时。用约瑟夫递推 J(1)=0,J(i)=(J(i−1)+m) mod i(0 基编号),最终答案 +1 转回 1 基:

cpp
#include <iostream>
using namespace std;
int main() {
    long long n, m;
    cin >> n >> m;
    long long pos = 0;                  // J(1) = 0
    for (long long i = 2; i <= n; i++)
        pos = (pos + m) % i;
    cout << pos + 1 << endl;            // 转回 1 基编号
    return 0;
}


六、📝 实战练习 ​

练习题 1(单选题) ​

题目: 下列求斐波那契数列第 n 项的方法中,能够处理 n = 10⁹ 并在 1 秒内出结果的是( )

A. 朴素递归 fib(n) = fib(n-1) + fib(n-2)
B. 记忆化搜索
C. 一维数组递推
D. 矩阵快速幂

🔑 点击查看答案与解析

答案: D **解析:**A 为 O(2ⁿ),B、C 为 O(n) 且需要 O(n) 空间,n=10⁹ 都无法完成;矩阵快速幂为 O(log n),约 30 次矩阵乘法即可。通项公式法(比内公式)在理论上也是 O(1),但大数下浮点精度不足,竞赛中以矩阵快速幂为准。

练习题 2(单选题) ​

题目: 给定长度为 n 的数组,有 Q 次询问,每次查询区间 [l, r] 的和。n 和 Q 均为 10⁵ 量级,最优的解法时间复杂度是( )

A. O(Q·n)
B. O(n + Q)
C. O(n log n + Q log n)
D. O(n² + Q)

🔑 点击查看答案与解析

答案: B **解析:**朴素每次循环累加 O(Q·n) = 10¹⁰ 次运算,必然超时;前缀和预处理 O(n),每次询问 O(1),总计 O(n+Q) = 2×10⁵,轻松通过。 🍎 这就像老师要多次问"第3到第8个同学的身高和是多少",提前算好"前缀身高和",每次一问一答,不用每次重新量。

练习题 3(单选题) ​

题目: 关于贪心算法,下列说法正确的是( )

A. 贪心算法总能得到全局最优解
B. 0/1 背包问题用贪心算法(按单位价值排序)可以得到最优解
C. 活动安排问题(选择最多不冲突活动)按结束时间排序后贪心选择是正确的
D. 只要问题可以分解成子问题,就一定能用贪心算法

🔑 点击查看答案与解析

答案: C **解析:**A 错,贪心只有在满足贪心选择性质时才得到全局最优;B 错,0/1 背包用贪心是经典反例(可能装不满);C 对,活动安排按结束时间排序贪心是经典正确用法;D 错,"一定"太绝对,要看具体问题。 ⚠️ 贪心不是万能的,考试判断题常设陷阱。

练习题 4(单选题) ​

题目: 要高效判断两个节点是否在同一个连通分量中(即判连通),最合适的数据结构是( )

A. 数组
B. 哈希表
C. 链表
D. 并查集

🔑 点击查看答案与解析

答案: D **解析:**并查集(路径压缩+按秩合并)的查找操作近似 O(1),专门用于处理连通性/集合合并问题。哈希表适合判存在但不适合判连通;数组和链表判断连通需要遍历,效率低。

练习题 5(判断题) ​

题目: 打表预处理是用空间换时间,因此只要内存允许,对任何题目都应该先做预处理,预处理不会带来任何坏处。( )

🔑 点击查看答案与解析

答案: 错误 ❌ 解析:预处理有价值的前提是结果会被多次/反复使用。若只询问一次,预处理 O(N) 反而比直接计算 O(√x) 更慢;此外预处理占用内存,N 过大时可能 MLE。优化要结合数据范围与询问次数,没有"永远最优"的手段。

练习题 6(判断题) ​

题目: 快速幂算法的时间复杂度是 O(log b),其中 b 是指数。当 b = 10⁹ 时,只需约 30 次循环。( )

🔑 点击查看答案与解析

答案: 正确 ✅ **解析:**快速幂每次把指数折半(b >>= 1),共循环 log₂b 次,每次做常数次乘法和取模,故时间复杂度 O(log b)。当 b=10⁹ 时,只需约 30 次循环。

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

题目: 阅读以下代码,回答问题。

cpp
int countDivisors(int n) {
    int total = 0;
    for (int i = 1; i <= n; i++) {
        for (int j = i; j <= n; j += i) {
            total++;
        }
    }
    return total;
}

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

(2)这段代码实际上计算的是什么?请简述一种进一步优化的思路。

🔑 点击查看答案与解析

解析:

  1. 时间复杂度:O(n log n) 外层 n 次,对于每个 i,内层枚举 i 的倍数,有 n/i 次。 总次数 = n/1 + n/2 + n/3 + ... + n/n = n × (1 + 1/2 + 1/3 + ... + 1/n) = n × H(n) 调和级数 H(n) ≈ ln n + γ(欧拉常数),故总复杂度 O(n log n)。

  2. 优化思路: 这个函数实际求的是 1~n 中所有数的约数个数之和。 也可以理解为:对每个 d(约数),计算 1~n 中有多少个数是 d 的倍数,即 n/d 个。 所以答案 = Σ(n/d),d 从 1 到 n,和上面一致。 要优化到 O(√n):利用整除分块(数论分块),n/d 的值在很多区间内相同,如 d ∈ [l, r] 时 n/d = k,可一块一块地加,共约 2√n 个块。

练习题 8(编程题) ​

题目: 约瑟夫报数问题:n 个人围成一圈,编号为 1 ~ n。从 1 号开始报数,报到 m 的人出列,下一个人重新从 1 开始报数。求最后剩下的人的编号。

输入:两个整数 n, m(1 ≤ n ≤ 10⁶,1 ≤ m ≤ 1000)
输出:一个整数,最后剩下的人的编号

样例输入:

5 3

样例输出:

4
🔑 点击查看答案与解析

**解析:**n 达 10⁶,链表/数组模拟删除 O(n²) 必然超时。用约瑟夫递推公式:

J(1) = 0,J(i) = (J(i−1) + m) mod i(0 基编号),最终答案 +1 转回 1 基。

cpp
#include <iostream>
using namespace std;
int main() {
    long long n, m;
    cin >> n >> m;
    long long pos = 0;                  // J(1) = 0
    for (long long i = 2; i <= n; i++)
        pos = (pos + m) % i;
    cout << pos + 1 << endl;            // 转回 1 基编号
    return 0;
}

时间复杂度 O(n),空间 O(1),n=10⁶ 也能轻松通过。 🍎 这就像"每次有人出局后,把圈子重新编号"——递推公式直接告诉你最后剩下的人在新编号里的位置,不用真的模拟出列过程。


练习题 9(单选题) ​

题目: 给定一个含 n 个顶点、m 条边的带权无向图,求最小生成树。下列说法正确的是?

A. 任何情况下 Kruskal 算法都比 Prim 算法快
B. 当图是稠密图(m 接近 n²)时,Prim(邻接矩阵)更合适
C. 当图是稀疏图(m 接近 n)时,Prim(邻接矩阵)更合适
D. Kruskal 算法不需要排序,所以更快

🔑 点击查看答案与解析

答案: B

解析: 最小生成树两大算法选型对比:

算法数据结构时间复杂度适用场景
Prim(邻接矩阵)二维数组O(n²)稠密图(m 大、n 小)
Prim(堆优化)优先队列+邻接表O(m log n)稀疏图
Kruskal并查集+排序边O(m log m)稀疏图(m 小、n 大)
  • A 错误:"任何情况下都快"太绝对。稠密图中 Prim(O(n²)) 反而比 Kruskal(O(m log m)=O(n² log n)) 更快
  • B 正确 ✅:稠密图 m ≈ n²,Prim 邻接矩阵版 O(n²) 优于 Kruskal 的 O(n² log n)
  • C 错误:稀疏图应该用 Kruskal 或堆优化 Prim,邻接矩阵版 Prim 的 O(n²) 会浪费大量时间扫不存在的边
  • D 错误:Kruskal 需要先对所有边按权值排序(O(m log m)),这是它的主要时间开销

💡 选型口诀:稠密用 Prim(邻接矩阵),稀疏用 Kruskal。就像路少的时候数路边(Kruskal 数边),路多的时候数路口(Prim 数点)。


练习题 10(判断题) ​

题目: "两数之和"问题(在数组中找两个数之和等于 target),用排序 + 双指针的方法可以把时间复杂度从 O(n²) 降到 O(n)。( )

🔑 点击查看答案与解析

答案: 错误 ❌

解析: 排序 + 双指针的时间复杂度是 O(n log n),不是 O(n)。

分解来看:

  1. 排序:O(n log n) —— 这是瓶颈
  2. 双指针扫描:O(n) —— 左右指针各移动最多 n 次

总复杂度 = O(n log n) + O(n) = O(n log n)

虽然没有降到 O(n),但从 O(n²) 降到 O(n log n) 已经是巨大的提升了:

  • n = 10⁴ 时:O(n²) = 10⁸(接近1秒上限),O(n log n) ≈ 10⁴ × 14 ≈ 1.4×10⁵(飞快)
  • n = 10⁵ 时:O(n²) = 10¹⁰(严重超时),O(n log n) ≈ 10⁵ × 17 ≈ 1.7×10⁶(轻松通过)

🔥 常见陷阱:不要因为"双指针是线性扫描"就以为整体是 O(n)——别忘了前面排序的代价!排序往往是排序后处理类算法的复杂度瓶颈。


七、📊 考点总结表 ​

考点重要程度出题形式易错点
斐波那契优化链⭐⭐⭐选择、判断记忆化搜索是O(n)不是O(n²);矩阵快速幂O(log n)
前缀和/打表预处理⭐⭐⭐选择、编程只问一次时预处理反而更慢;注意下标从1开始
记忆化搜索⭐⭐⭐选择、编程忘记初始化记忆数组;边界条件处理不当
快速幂⭐⭐⭐选择、编程忘记取模导致溢出;初始值设为0(应为1)
排序+双指针⭐⭐选择、编程双指针移动条件判断错误;需先排序
贪心算法⭐⭐⭐选择、判断误以为所有问题都能用贪心;0/1背包用贪心是错的
数据结构选型⭐⭐⭐选择哈希表vs堆vs并查集适用场景混淆
等差/等比求和公式⭐⭐⭐选择、编程等比求和取模时除法要换成乘逆元
约瑟夫递推⭐⭐选择、编程0基/1基编号转换错误;递推初始条件记错
数学消元优化⭐⭐编程变量关系推导错误;忘记检验约束条件

⭐ 必背速记卡 ​

┌─────────────────────────────────────────┐
│  1. 优化核心思想:                      │
│     消除重复计算,减少枚举范围,        │
│     用对数据结构,数学公式替代模拟       │
├─────────────────────────────────────────┤
│  2. 斐波那契进化链:                    │
│     朴素递归O(2ⁿ) → 记忆化/递推O(n)     │
│     → 矩阵快速幂O(log n)                │
├─────────────────────────────────────────┤
│  3. 打表/前缀和:空间换时间             │
│     Q次询问从O(Qn)降到O(n+Q)            │
│     前提:结果会被反复使用              │
├─────────────────────────────────────────┤
│  4. 记忆化搜索=递归+缓存               │
│     算过的状态存起来,下次直接返回       │
│     与DP时间相同,区别:自顶向下vs自底向上│
├─────────────────────────────────────────┤
│  5. 快速幂模板:                        │
│     res=1; while(b){if(b&1)res=res*a%m;│
│     a=a*a%m; b>>=1;}                    │
│     时间O(log b)                        │
├─────────────────────────────────────────┤
│  6. 单调队列存下标(要判断过期),       │
│     递减求最大值,递增求最小值           │
├─────────────────────────────────────────┤
│  7. 排序+双指针:两数之和等配对问题      │
│     O(n²) → O(n log n)                  │
│     前提:排序不影响答案正确性           │
├─────────────────────────────────────────┤
│  8. 贪心不是万能的!                    │
│     需满足贪心选择性质+最优子结构        │
│     0/1背包用贪心是错的                 │
├─────────────────────────────────────────┤
│  9. 数据结构选型:                      │
│     判存在→哈希表O(1)                   │
│     取最值→堆O(log n)                   │
│     判连通→并查集≈O(1)                  │
├─────────────────────────────────────────┤
│ 10. 等差数列求和:n(a₁+aₙ)/2 → O(1)     │
│     等比数列求和:a₁(qⁿ-1)/(q-1)        │
│     取模时除法换乘逆元                   │
├─────────────────────────────────────────┤
│ 11. 约瑟夫递推:J(1)=0,                 │
│     J(n)=(J(n-1)+m) mod n(0基)        │
│     从O(n²)模拟降到O(n)递推             │
├─────────────────────────────────────────┤
│ 12. 周期问题:先找周期T,再n%T映射       │
│     斐波那契个位周期=60                 │
├─────────────────────────────────────────┤
│ 13. 编程题优化三步走:                  │
│     先写朴素拿分→找瓶颈→小步替换验证    │
├─────────────────────────────────────────┤
│ 14. 优化都有前提:                      │
│     二分要有序、贪心要性质、             │
│     记忆化要有重叠子问题                 │
└─────────────────────────────────────────┘

考场优化题答题策略 🎯 ​

八级编程题采用 IOI 赛制、可多次提交,遇到"会做但可能超时"的题,建议按三步走:

  1. 先写朴素版拿部分分:确认逻辑正确、小数据能过,先提交保住基础分,绝不在空白代码上硬想最优解;
  2. 对着复杂度表找瓶颈:用第8章的方法估算朴素版复杂度,对照本章"症状—药方"总表定位优化点;
  3. 小步替换、随时验证:每次只替换一个模块(如先加前缀和,再换快速幂),保证每一步都可回退、可验证。

选择题/判断题中的优化题,则务必看清三个要素:数据规模(n 有多大)、询问次数(Q 有多少)、前提条件(是否有序、是否有负权)——复杂度数字本身不难,难的是把题目场景和优化前提对应起来。


八、🏁 全书回顾:九章旅程 ​

至此,本书九章已全部学完,让我们一起回看这段旅程:

章主题对应考纲知识块
第1章总览:考纲全景与备考规划——
第2章计数原理加法原理、乘法原理
第3章排列与组合排列、组合
第4章杨辉三角杨辉三角的定义与实现
第5章倍增法倍增的概念
第6章代数与平面几何一元/二元一次方程、基本图形面积
第7章图论算法及综合应用最小生成树(Kruskal/Prim)、最短路(Dijkstra/Floyd)
第8章算法的时间和空间效率分析复杂度一般分析方法、各类算法复杂度
第9章算法优化算法差异分析、一般优化方法、数学知识优化

九章内容,从考纲全景到组合数学,从倍增思想到图论算法,从复杂度分析到本章的算法优化——八级考纲的每一块版图,我们都已走遍。


九、考完八级之后:通往信息学竞赛的进阶路径 🚀 ​

GESP 八级是 GESP 认证的最高级,但它不是终点,而是信息学竞赛之路的起点站。通过八级,意味着你已经具备了挑战权威赛事的知识储备:

阶段赛事/认证定位与 GESP 八级的衔接
①CSP-J(入门级)中国计算机学会主办,初中组信奥预选赛八级内容可全覆盖,建议作为赛后第一站热身
②CSP-S(提高级)高中组信奥预选赛,含金量高需补充:更难的 DP、图论进阶、数据结构(线段树/树状数组)
③NOIP(全国青少年信息学奥林匹克联赛)省赛区竞赛,升学认可度高CSP-S 取得成绩后的自然延伸
④省选 / NOI(全国赛)各省选拔 → 全国总决赛省队水平,需系统学习高级算法与大量集训
⑤IOI(国际信息学奥林匹克)中学生信奥全球最高舞台国家集训队佼佼者代表中国出征

十、🎓 全书寄语 ​

亲爱的考生:

九章内容,从第1章的考纲全景图开始,到本章的算法优化收尾。你已经知道 O(2ⁿ) 与 O(log n) 之间隔着多么巨大的鸿沟——而这恰恰像极了学习本身:朴素的努力很珍贵,但找到好方法的努力,才能指数级地成长。

GESP 八级是 CCF 编程能力等级认证的最高峰。当你走出考场、拿到那张八级证书时,请记得:它证明的不只是你会写代码,而是你已经拥有了用计算机的视角拆解问题、用数学的头脑简化问题、用工程师的严谨解决问题的思维方式。

山高路远,道阻且长。CSP-J/S 的赛场在等你,NOIP 的领奖台在等你,更远的地方,IOI 的赛场灯火通明。

GESP 八级全书至此学完,而你的信息学竞赛之路,才刚刚开始。

向着更高的舞台,出发吧!🚩


百炼成钢,融会贯通