第 15 讲 · 复习课

综合自测与速查手册

这一讲把整门课的结论压成几张表、几段模板代码和一套自测题。 建议用法:先做自测卷,错哪题就回哪一章;所有动画重看一遍;最后把速查表当公式背下来。

建议用时 150 分钟 前置:全部章节 关键词:速查 · 模板 · 自测 · 易错辨析
本讲导航
  • 15.1 全课程复杂度速查大表
  • 15.2 数据结构横向对比(线性表 / 栈队列 / 树 / 图 / 查找结构)
  • 15.3 C++ STL 容器与算法速查(含每个操作的复杂度)
  • 15.4 算法模板库(可直接复制进比赛/作业)
  • 15.5 五十条易错概念辨析
  • 15.6 模拟自测卷(概念题 + 计算题 + 算法设计题,含答案)
  • 15.7 期末/考研复习路线图

15.1 全课程复杂度速查大表

把这张表抄在笔记本第一页。注意三点:n 是数据规模,平均最坏经常不同,额外空间不含输入本身占用的空间。

15.1.1 线性结构

结构 / 操作查找第 i 个按值查找插入删除额外空间关键结论
顺序表(数组)O(1)O(n)O(n)O(n)O(1)随机存取换来了插入删除的搬移代价
单链表O(n)O(n)O(1)(已知前驱)O(1)(已知前驱)每结点 8 字节指针头插/头删都是 O(1)
双向链表O(n)O(n)O(1)O(1)(已知结点即可)每结点 16 字节指针不用找前驱,代价是多一个指针域
循环链表O(n)O(n)O(1)O(1)同单链表只设尾指针可 O(1) 完成两端插入
静态链表O(n)O(n)O(1)O(1)数组模拟不支持指针时的替代方案
顺序栈O(1) 均摊O(1)可能浪费只在一端操作
链栈O(1)O(1)指针开销不会栈满,无需预分配
循环队列O(1)O(1)O(1)牺牲一个单元判满
链队列O(1)O(1)指针开销出队后若为空要重置 rear

15.1.2 串与矩阵

算法 / 结构预处理匹配 / 访问额外空间关键结论
朴素模式匹配 BF最坏 O(n×m)O(1)主串指针回溯是瓶颈
KMPO(m) 求 πO(n)O(m)i 永不后退,最坏 O(n+m)
BM(坏字符 + 好后缀)O(m + σ)平均 O(n/m)O(m + σ)从右往左比,自然文本最快
对称矩阵压缩O(1)n(n+1)/2k = i(i+1)/2 + j(i≥j)
下三角矩阵压缩O(1)n(n+1)/2 + 1常数 c 单独占一个单元
三对角矩阵压缩O(1)3n − 2k = 2i + j 修正后使用
稀疏矩阵(三元组 / CSR)O(t) 或 O(log t)O(t)牺牲随机存取换取空间

15.1.3 树结构

结构与操作平均最坏额外空间关键结论
二叉树四种遍历O(n)O(n)递归栈 O(h)每结点恰好访问一次
二叉排序树 BST 查找O(log n)O(n)(退化成链)O(h)有序插入会退化,需要平衡
由前序 + 中序建树O(n)O(n²)(朴素找根)O(n)用哈希表定位根可优化到 O(n)
赫夫曼树构造O(n log n)O(n log n)O(n)WPL 最小且唯一,树形不唯一
并查集 find≈O(α(n))≈O(α(n))O(n)路径压缩 + 按秩合并后几乎常数

15.1.4 图论算法

算法时间复杂度空间解决的问题限制
DFS / BFS 遍历邻接表 O(n+e) / 邻接矩阵 O(n²)O(n)连通性、遍历序列、连通分量递归 DFS 深度过大会爆栈
BFS 最短路O(n+e)O(n)无权图单源最短路权值必须相同
Prim(朴素)O(n²)O(n)MST适合稠密图
Prim(堆优化)O(m log n)O(n+m)MST适合稀疏图
KruskalO(m log m)O(n+m)MST需要并查集;稀疏图首选
Dijkstra(朴素)O(n²)O(n)单源最短路不能有负权边
Dijkstra(堆优化)O(m log n)O(n+m)单源最短路不能有负权边
Bellman-FordO(nm)O(n)单源最短路 + 判负环较慢
SPFA平均 O(km),最坏 O(nm)O(n+m)单源最短路 + 判负环容易被特殊数据卡
FloydO(n³)O(n²)全源最短路、传递闭包n 通常 ≤ 500
拓扑排序(Kahn)O(n+e)O(n)DAG 的线性序、判环只适用于 DAG
关键路径O(n+e)O(n+e)AOE 网的最短工期与关键活动要求是有向无环网且只有一个源点/汇点

15.1.5 查找

查找方法前提成功 ASL时间复杂度关键结论
顺序查找(无序)(n+1)/2O(n)不成功需 n(或 n+1)次
顺序查找(有序)有序(n+1)/2O(n)失败 ASL 可降到约 n/2
折半查找顺序存储 + 有序≈log₂(n+1) − 1O(log n)判定树高 ⌈log₂(n+1)⌉,是平衡二叉树
插值查找顺序存储 + 有序 + 均匀均匀时 O(log log n)分布极不均匀时退化到 O(n)
斐波那契查找顺序存储 + 有序略优于折半O(log n)只用加减,适合不能做除法/乘法的场景
分块查找块间有序、块内无序√n + 1(块长 √n 时)O(√n)兼顾插入删除与查找的折中
哈希表(开放定址)装填因子 α < 1与 α 相关,约 1/(1−α) 量级理想 O(1)线性探测会堆积;删除要用墓碑
哈希表(链地址法)α 可 > 1≈ 1 + α/2理想 O(1)删除方便,工程主流
必背:折半查找 n = 11 的标准答案 判定树有 11 个内部结点,层数分布为 1、2、4、4(第 1 层 1 个、第 2 层 2 个、第 3 层 4 个、第 4 层 4 个), 因此 ASL_success = (1×1 + 2×2 + 3×4 + 4×4) / 11 = 33/11 = 3。 这个例子在 408 里出现过多次,务必能默画判定树并现场算 ASL。

15.2 数据结构横向对比

15.2.1 顺序存储 vs 链式存储

维度顺序存储链式存储
存储单元连续任意(由指针连接)
随机存取支持 O(1)不支持,必须顺序走
插入 / 删除平均搬移 n/2 个元素改指针即可 O(1)
空间开销无额外指针;但可能预留浪费每结点额外指针;碎片化
容量需预分配或动态扩容(扩容要整体搬运)按需分配,理论无上限
缓存友好性好(连续访存)差(指针跳转,cache miss 多)
典型用途数组、栈、队列、堆、哈希表、邻接矩阵链表、树、图、LRU 缓存、邻接表

15.2.2 栈 vs 队列 vs 双端队列

结构操作端顺序典型应用
栈 Stack同一端(栈顶)进出后进先出 LIFO函数调用、括号匹配、表达式求值、DFS、回溯、撤销
队列 Queue一端进、另一端出先进先出 FIFOBFS、任务调度、缓冲、排队模拟
双端队列 Deque两端都可进出单调队列、滑动窗口、工作窃取
优先队列按优先级出队最大/最小先出堆排序、Dijkstra、Prim、任务调度

三者关系:栈和队列都是双端队列的特例(限制其中一端的操作即得栈;两端各限制一半即得队列)。

15.2.3 邻接矩阵 vs 邻接表 vs 链式前向星

维度邻接矩阵邻接表(vector)链式前向星
空间O(n²)O(n+e)O(n+e),常数最小
判断 u-v 是否相邻O(1)O(deg(u))O(deg(u))
枚举 u 的邻接点O(n)O(deg(u))O(deg(u))
求入度O(n)(列和)需逆邻接表需逆图或额外数组
加边O(1)O(1)O(1)
删边O(1)O(deg)O(deg)
适用稠密图、需频繁判相邻、Floyd通用,写法最简竞赛首选,省内存且 cache 友好

15.2.4 十种排序总对比(核心大表,必背

排序算法类别最好平均最坏 空间稳定是否基于比较特点 / 适用
冒泡排序交换类O(n)O(n²)O(n²)O(1)稳定教学用;加「本趟无交换即结束」可提前退出
快速排序交换类O(n log n)O(n log n)O(n²)O(log n)不稳定实测最快;有序输入 + 取端点做基准会退化
直接插入排序插入类O(n)O(n²)O(n²)O(1)稳定小规模 / 基本有序时极快,是高级排序的收尾手段
希尔排序插入类O(n log n)与增量有关 ≈O(n^1.3)O(n²)O(1)不稳定增量序列决定性能;Hibbard 增量可达 O(n^1.5)
简单选择排序选择类O(n²)O(n²)O(n²)O(1)不稳定交换次数最少(≤ n−1 次),适合交换代价大的场景
堆排序选择类O(n log n)O(n log n)O(n log n)O(1)不稳定唯一「最坏 O(n log n) + 原地」的常用算法;cache 不友好
归并排序归并类O(n log n)O(n log n)O(n log n)O(n)稳定唯一稳定的 O(n log n) 比较排序;适合外排序、链表排序
基数排序基数类O(d(n+r))O(d(n+r))O(d(n+r))O(n+r)稳定d = 位数,r = 基数;适合整数 / 定长字符串
计数排序基数类O(n+k)O(n+k)O(n+k)O(n+k)稳定值域 k 必须可控;是基数排序的子过程
桶排序基数类O(n)O(n)O(n²)O(n+k)稳定数据均匀分布时线性;桶划分不好会退化
锦标赛排序选择类O(n log n)O(n log n)O(n log n)O(n)不稳定用完全二叉树保存比较结果,是堆排序的原型
四个「唯一性」结论(考试高频)
  • 唯一一个「最坏情况也是 O(n log n) 且空间 O(1)」的常用排序:堆排序
  • 唯一一个「平均 O(n log n) 且稳定」的比较排序:归并排序
  • 唯一一个「不基于比较」还能做到线性时间的常用排序:基数排序(及其子过程计数排序)。
  • 「交换次数最少」的排序:简单选择排序(最多 n−1 次交换)。
稳定性速记口诀 不稳定的只有四个:(快速排序)、(希尔排序)、(简单选择排序)、(堆排序)—— 「快些选堆」四个字。其余(冒泡、插入、归并、基数、计数)都稳定。

15.3 C++ STL 容器与算法速查

15.3.1 容器对比

容器底层结构随机访问插入删除查找特点
vector动态数组O(1)尾部 O(1) 均摊,中间 O(n)尾部 O(1),中间 O(n)O(n)默认首选;reserve 避免反复扩容
deque分段连续数组O(1)两端 O(1)两端 O(1)O(n)queue / stack 的默认底层
list双向链表O(n)已知迭代器 O(1)已知迭代器 O(1)O(n)splice 是 O(1),但 cache 差
forward_list单链表O(n)已知前驱 O(1)已知前驱 O(1)O(n)最省内存
stackdeque 适配push O(1)pop O(1)只有 top/push/pop
queuedeque 适配push O(1)pop O(1)不能遍历
priority_queue二叉堆(vector)O(log n)O(log n)top O(1)默认大根堆;小根堆要写 greater<int>
set / map红黑树O(log n)O(log n)O(log n)有序,可 lower_bound
multiset / multimap红黑树O(log n)O(log n)O(log n)允许重复键
unordered_set / unordered_map哈希表平均 O(1)平均 O(1)平均 O(1)无序;可能被构造数据卡到 O(n)
bitset位数组O(1)支持位运算,空间压缩 32/64 倍
string动态字符数组O(1)尾部均摊 O(1)中间 O(n)O(n)(find可直接当字符容器用

15.3.2 常用算法与技巧速查

#include <bits/stdc++.h>
using namespace std;

int main() {
    /* ---------- 1. 排序与去重 ---------- */
    vector<int> a = {3, 1, 4, 1, 5, 9, 2, 6};
    sort(a.begin(), a.end());                        // 升序,O(n log n)
    sort(a.begin(), a.end(), greater<int>());        // 降序
    stable_sort(a.begin(), a.end());                 // 稳定排序,O(n log^2 n) 或 O(n log n)
    a.erase(unique(a.begin(), a.end()), a.end());    // 去重(必须先排序)
    reverse(a.begin(), a.end());                     // 反转
    /* random_shuffle 已在 C++17 中废弃,改用 shuffle + 随机数引擎: */
    mt19937 rng(chrono::steady_clock::now().time_since_epoch().count());
    shuffle(a.begin(), a.end(), rng);                // 随机打乱,O(n)

    /* ---------- 2. 二分查找(必须有序) ---------- */
    auto it1 = lower_bound(a.begin(), a.end(), 4);   // 第一个 >= 4 的位置
    auto it2 = upper_bound(a.begin(), a.end(), 4);   // 第一个 >  4 的位置
    bool has = binary_search(a.begin(), a.end(), 4); // 是否存在
    int cnt = upper_bound(a.begin(), a.end(), 4) - lower_bound(a.begin(), a.end(), 4);  // 出现次数

    /* ---------- 3. 最值与统计 ---------- */
    int mx = *max_element(a.begin(), a.end());
    int mn = *min_element(a.begin(), a.end());
    long long sum = accumulate(a.begin(), a.end(), 0LL);   // 注意初值写 0LL 防溢出

    /* ---------- 4. 排列 ---------- */
    sort(a.begin(), a.end());
    do {
        // 处理当前排列
    } while (next_permutation(a.begin(), a.end()));  // 枚举全排列 O(n!)

    /* ---------- 5. 小根堆的两种写法 ---------- */
    priority_queue<int, vector<int>, greater<int>> pqMin;
    priority_queue<int> pqMax;                       // 默认大根堆
    pqMin.push(3); pqMin.push(1);
    int top = pqMin.top();                           // 1
    pqMin.pop();
    /* 对 pair 排序:先比 first 再比 second;大根堆取负值即可变相实现小根堆 */

    /* ---------- 6. 万能头与快读 ---------- */
    ios::sync_with_stdio(false);                     // 关同步,cin/cout 提速
    cin.tie(nullptr);

    /* ---------- 7. 常用常量与边界 ---------- */
    const int INF = 0x3f3f3f3f;                      // 10^9 级,两个 INF 相加不溢出 int
    const long long LINF = 0x3f3f3f3f3f3f3f3fLL;
    /* memset 只能按字节填充:0 / -1 / 0x3f 是安全的,其它值不要用 memset */

    /* ---------- 8. 字符串流分割 ---------- */
    string line = "10 20 30";
    istringstream iss(line);
    int x;
    while (iss >> x) { /* 依次读出 10 20 30 */ }

    return 0;
}

15.4 算法模板库

下面这些模板可以直接复制到作业与比赛中使用,全部经过验证。

15.4.1 基础工具

#include <bits/stdc++.h>
using namespace std;
typedef long long ll;
const int INF = 0x3f3f3f3f;
const int MOD = 1e9 + 7;

/* 快速幂:a^b mod p,O(log b) */
ll qpow(ll a, ll b, ll p = MOD) {
    ll r = 1; a %= p;
    while (b) {
        if (b & 1) r = r * a % p;
        a = a * a % p;
        b >>= 1;
    }
    return r;
}

/* 最大公约数 / 最小公倍数 */
ll gcd(ll a, ll b) { return b ? gcd(b, a % b) : a; }
ll lcm(ll a, ll b) { return a / gcd(a, b) * b; }

/* 埃氏筛:求 [1,n] 内所有质数,O(n log log n) */
vector<int> sieve(int n) {
    vector<bool> is(n + 1, true);
    vector<int> primes;
    is[0] = is[1] = false;
    for (int i = 2; i <= n; ++i) {
        if (!is[i]) continue;
        primes.push_back(i);
        if ((ll)i * i <= n)
            for (ll j = (ll)i * i; j <= n; j += i) is[j] = false;
    }
    return primes;
}

/* 欧拉线性筛:同时求质数与最小质因子,O(n) */
vector<int> linearSieve(int n, vector<int>& minp) {
    vector<bool> comp(n + 1, false);
    vector<int> primes;
    minp.assign(n + 1, 0);
    for (int i = 2; i <= n; ++i) {
        if (!comp[i]) { primes.push_back(i); minp[i] = i; }
        for (int p : primes) {
            if ((ll)i * p > n) break;
            comp[i * p] = true;
            minp[i * p] = p;
            if (i % p == 0) break;
        }
    }
    return primes;
}

/* 扩展欧几里得:求 ax + by = gcd(a,b) 的一组解 */
ll exgcd(ll a, ll b, ll& x, ll& y) {
    if (!b) { x = 1; y = 0; return a; }
    ll d = exgcd(b, a % b, y, x);
    y -= a / b * x;
    return d;
}

int main() {
    cout << qpow(2, 10) << "\n";        // 1024
    cout << gcd(12, 18) << "\n";       // 6
    auto ps = sieve(30);
    for (int p : ps) cout << p << ' ';
    cout << "\n";
    return 0;
}

15.4.2 并查集 / 堆 / 哈希

#include <bits/stdc++.h>
using namespace std;

/* ================= 并查集(路径压缩 + 按大小合并) ================= */
struct DSU {
    vector<int> fa, sz;
    explicit DSU(int n) : fa(n), sz(n, 1) { iota(fa.begin(), fa.end(), 0); }
    int find(int x) {                       // 路径压缩:均摊 O(α(n))
        return fa[x] == x ? x : fa[x] = find(fa[x]);
    }
    bool unite(int a, int b) {
        a = find(a); b = find(b);
        if (a == b) return false;
        if (sz[a] < sz[b]) swap(a, b);
        fa[b] = a; sz[a] += sz[b];
        return true;
    }
    bool same(int a, int b) { return find(a) == find(b); }
};

/* ================= 手写小根堆(也可直接用 priority_queue) ================= */
struct MinHeap {
    vector<int> h;
    void push(int x) {
        h.push_back(x);
        int i = h.size() - 1;
        while (i && h[(i - 1) / 2] > h[i]) { swap(h[i], h[(i - 1) / 2]); i = (i - 1) / 2; }
    }
    int top() const { return h[0]; }
    void pop() {
        h[0] = h.back(); h.pop_back();
        int i = 0, n = h.size();
        while (true) {
            int l = 2 * i + 1, r = 2 * i + 2, m = i;
            if (l < n && h[l] < h[m]) m = l;
            if (r < n && h[r] < h[m]) m = r;
            if (m == i) break;
            swap(h[i], h[m]); i = m;
        }
    }
    bool empty() const { return h.empty(); }
};

/* ================= 防卡哈希(竞赛必备) ================= */
struct custom_hash {
    static uint64_t splitmix64(uint64_t x) {
        x += 0x9e3779b97f4a7c15ULL;
        x = (x ^ (x >> 30)) * 0xbf58476d1ce4e5b9ULL;
        x = (x ^ (x >> 27)) * 0x94d049bb133111ebULL;
        return x ^ (x >> 31);
    }
    size_t operator()(uint64_t x) const {
        static const uint64_t FIXED_RANDOM =
            chrono::steady_clock::now().time_since_epoch().count();
        return splitmix64(x + FIXED_RANDOM);
    }
};
/* 用法:unordered_map<long long, int, custom_hash> mp; */

int main() {
    DSU d(10);
    d.unite(1, 2); d.unite(2, 3);
    cout << d.same(1, 3) << "\n";        // 1
    MinHeap hp;
    for (int x : {5, 1, 9, 3}) hp.push(x);
    cout << hp.top() << "\n";            // 1
    return 0;
}

15.4.3 图论模板(一次拷全)

#include <bits/stdc++.h>
using namespace std;
const int INF = 0x3f3f3f3f;
const int MAXN = 100005;        /* 顶点数上限 */
const int MAXM = 200005;        /* 边数上限(无向图要开 2 倍) */

/* ================= 链式前向星(竞赛主流写法,与第 08 讲一致) =================
   head[u] = 顶点 u 的第一条出边下标(-1 表示没有)
   to[i] / w[i] / nxt[i] = 第 i 条边的终点 / 边权 / 同起点的下一条边
   etot = 下一个可用的边结点下标 */
int head[MAXN], to[MAXM], nxt[MAXM], w[MAXM];
int etot = 0;

void initGraph(int n) {
    memset(head, -1, sizeof(int) * (n + 1));   /* 加边之前必须先 memset 成 -1 */
    etot = 0;
}
void addEdge(int u, int v, int weight = 1) {
    to[etot] = v; w[etot] = weight; nxt[etot] = head[u]; head[u] = etot++;
}
/* 无向图调用两次 addEdge(u, v, w); addEdge(v, u, w); */

/* ================= 朴素 Dijkstra(邻接矩阵,O(n^2)) ================= */
int n;
vector<vector<int>> g;          // g[u][v] = 权值,无边为 INF
vector<long long> dijkstraNaive(int s) {
    vector<long long> dist(n + 1, LLONG_MAX);
    vector<bool> vis(n + 1, false);
    dist[s] = 0;
    for (int iter = 0; iter < n; ++iter) {
        int u = -1;
        for (int v = 1; v <= n; ++v)
            if (!vis[v] && (u == -1 || dist[v] < dist[u])) u = v;
        if (u == -1 || dist[u] == LLONG_MAX) break;
        vis[u] = true;
        for (int v = 1; v <= n; ++v)
            if (g[u][v] < INF && dist[u] + g[u][v] < dist[v])
                dist[v] = dist[u] + g[u][v];
    }
    return dist;
}

/* ================= 堆优化 Dijkstra(链式前向星,O(m log n)) ================= */
vector<long long> dijkstraHeap(int s, int n) {
    vector<long long> dist(n + 1, LLONG_MAX);
    priority_queue<pair<long long, int>, vector<pair<long long, int>>, greater<>> pq;
    dist[s] = 0; pq.push({0, s});
    while (!pq.empty()) {
        auto [d, u] = pq.top(); pq.pop();
        if (d > dist[u]) continue;                 // 过期元素,跳过
        for (int e = head[u]; e != -1; e = nxt[e]) {
            int v = to[e];
            if (d + w[e] < dist[v]) {
                dist[v] = d + w[e];
                pq.push({dist[v], v});
            }
        }
    }
    return dist;
}

/* ================= Floyd(O(n^3),注意 k 在最外层) ================= */
void floyd(vector<vector<long long>>& d, int n) {
    for (int k = 1; k <= n; ++k)
        for (int i = 1; i <= n; ++i) {
            if (d[i][k] == LLONG_MAX) continue;
            for (int j = 1; j <= n; ++j)
                if (d[k][j] != LLONG_MAX && d[i][k] + d[k][j] < d[i][j])
                    d[i][j] = d[i][k] + d[k][j];
        }
}

/* ================= Kruskal 最小生成树 ================= */
struct Edge { int u, v, w; bool operator<(const Edge& o) const { return w < o.w; } };
long long kruskal(vector<Edge>& edges, int n) {
    sort(edges.begin(), edges.end());
    vector<int> fa(n + 1);
    iota(fa.begin(), fa.end(), 0);
    function<int(int)> find = [&](int x) { return fa[x] == x ? x : fa[x] = find(fa[x]); };
    long long total = 0; int used = 0;
    for (const Edge& e : edges) {
        int a = find(e.u), b = find(e.v);
        if (a == b) continue;                      // 成环,丢弃
        fa[b] = a; total += e.w; ++used;
        if (used == n - 1) break;                  // 已经选了 n-1 条边
    }
    return used == n - 1 ? total : -1;             // -1 表示图不连通
}

/* ================= 拓扑排序(Kahn) ================= */
vector<int> topoSort(const vector<vector<int>>& adj, vector<int> indeg, int n) {
    queue<int> q;
    for (int i = 1; i <= n; ++i) if (!indeg[i]) q.push(i);
    vector<int> order;
    while (!q.empty()) {
        int u = q.front(); q.pop();
        order.push_back(u);
        for (int v : adj[u]) if (--indeg[v] == 0) q.push(v);
    }
    if ((int)order.size() != n) return {};         // 有环
    return order;
}

int main() {
    /* 用一个小例子自测:全局数组版必须显式初始化(把 head 清成 -1) */
    initGraph(5);
    addEdge(1, 2, 2); addEdge(2, 3, 3); addEdge(1, 3, 10);
    auto dist = dijkstraHeap(1, 5);
    cout << dist[3] << "\n";                      // 5
    return 0;
}

15.4.4 字符串与动态规划模板

#include <bits/stdc++.h>
using namespace std;

/* ================= KMP:前缀函数 + 匹配 ================= */
vector<int> prefixFunction(const string& P) {
    int m = P.size(); vector<int> pi(m, 0);
    for (int i = 1; i < m; ++i) {
        int len = pi[i - 1];
        while (len && P[i] != P[len]) len = pi[len - 1];
        if (P[i] == P[len]) ++len;
        pi[i] = len;
    }
    return pi;
}
vector<int> kmpAll(const string& S, const string& P) {     // 返回所有出现位置
    vector<int> res;
    if (P.empty()) return res;
    vector<int> pi = prefixFunction(P);
    int j = 0;
    for (int i = 0; i < (int)S.size(); ++i) {
        while (j && S[i] != P[j]) j = pi[j - 1];
        if (S[i] == P[j]) ++j;
        if (j == (int)P.size()) { res.push_back(i - j + 1); j = pi[j - 1]; }
    }
    return res;
}

/* ================= 字符串哈希(双模数,防碰撞) ================= */
struct StrHash {
    static const int M1 = 1000000007, M2 = 998244353, B = 131;
    vector<long long> h1, h2, p1, p2;
    explicit StrHash(const string& s) {
        int n = s.size();
        h1.assign(n + 1, 0); h2.assign(n + 1, 0);
        p1.assign(n + 1, 1); p2.assign(n + 1, 1);
        for (int i = 0; i < n; ++i) {
            h1[i + 1] = (h1[i] * B + s[i]) % M1;
            h2[i + 1] = (h2[i] * B + s[i]) % M2;
            p1[i + 1] = p1[i] * B % M1;
            p2[i + 1] = p2[i] * B % M2;
        }
    }
    pair<long long, long long> get(int l, int r) const {   // 0 基,闭区间
        long long a = ((h1[r + 1] - h1[l] * p1[r - l + 1]) % M1 + M1) % M1;
        long long b = ((h2[r + 1] - h2[l] * p2[r - l + 1]) % M2 + M2) % M2;
        return {a, b};
    }
};

/* ================= 01 背包(一维滚动数组) ================= */
int knapsack01(const vector<int>& w, const vector<int>& v, int W) {
    vector<int> dp(W + 1, 0);
    for (int i = 0; i < (int)w.size(); ++i)
        for (int j = W; j >= w[i]; --j)              // 必须倒序!否则变成完全背包
            dp[j] = max(dp[j], dp[j - w[i]] + v[i]);
    return dp[W];
}

/* ================= 完全背包(一维滚动数组) ================= */
int knapsackComplete(const vector<int>& w, const vector<int>& v, int W) {
    vector<int> dp(W + 1, 0);
    for (int i = 0; i < (int)w.size(); ++i)
        for (int j = w[i]; j <= W; ++j)              // 正序!
            dp[j] = max(dp[j], dp[j - w[i]] + v[i]);
    return dp[W];
}

/* ================= 最长上升子序列 LIS:O(n log n) ================= */
int lis(const vector<int>& a) {
    vector<int> tails;                               // tails[i] = 长度 i+1 的上升子序列的最小结尾
    for (int x : a) {
        auto it = lower_bound(tails.begin(), tails.end(), x);   // 严格递增
        if (it == tails.end()) tails.push_back(x);
        else *it = x;
    }
    return tails.size();
}

/* ================= 最长公共子序列 LCS ================= */
int lcs(const string& a, const string& b) {
    int n = a.size(), m = b.size();
    vector<vector<int>> dp(n + 1, vector<int>(m + 1, 0));
    for (int i = 1; i <= n; ++i)
        for (int j = 1; j <= m; ++j)
            dp[i][j] = (a[i - 1] == b[j - 1]) ? dp[i - 1][j - 1] + 1
                                              : max(dp[i - 1][j], dp[i][j - 1]);
    return dp[n][m];
}

int main() {
    for (int p : kmpAll("ABABABCABABABCABA", "ABABCABA")) cout << p << ' ';   // 2 9
    cout << "\n";
    cout << knapsack01({1, 3, 4}, {15, 20, 30}, 4) << "\n";                    // 35
    cout << lis({10, 9, 2, 5, 3, 7, 101, 18}) << "\n";                        // 4
    cout << lcs("abcde", "ace") << "\n";                                      // 3
    return 0;
}

15.5 五十条易错概念辨析

15.5.1 绪论与复杂度

  1. 大 O 是上界,不是精确值。 2n² + 3n 都是 O(n²),但不能说它们「一样快」。
  2. O(1) 不等于「执行一次」。 执行 1000 条语句仍是 O(1)。
  3. 循环变量的变化方式是判断复杂度的关键。 i *= 2 是 O(log n),i = sqrt(i) 是 O(log log n)。
  4. 递归的时间复杂度要写递推式。 不能只看循环层数。
  5. 空间复杂度算的是额外空间,递归要算栈深度。
  6. 算法的「有穷性」指在有限步内结束,不是「不能有循环」。
  7. 逻辑结构与存储结构是两个独立维度:同一逻辑结构可以有多种存储结构。

15.5.2 线性表

  1. 顺序表插入的平均移动次数是 n/2,删除是 (n−1)/2(教材常都近似为 n/2)。
  2. 头结点不是首元结点。 头结点不存数据(或存长度),是为了统一空表与非空表的操作。
  3. 「在 p 结点前插入」单链表做不到 O(1),除非已知前驱;但「在 p 结点后插入」可以 O(1)(交换数据域的技巧)。
  4. 双向链表删除结点不需要找前驱,这是它相对单链表最大的优势。
  5. 静态链表的 next 存的是数组下标(游标),不是地址。
  6. 快慢指针判环时,快指针每次走 2 步,两者一定会在环内相遇(如果有环)。

15.5.3 栈与队列

  1. 共享栈的栈满条件是 top1 + 1 == top0(两个栈顶相邻),不是相等。
  2. 链栈通常不设头结点,栈顶就是链表的第一个结点
  3. 循环队列「牺牲一个单元」是为了区分队空与队满:队空 front == rear,队满 (rear+1)%M == front
  4. 循环队列元素个数 = (rear − front + M) % M,不要漏掉 +M
  5. 链队列在出队后若队列为空,必须把 rear 重新指向头结点,否则 rear 悬空。
  6. 中缀转后缀时,右括号不入栈;遇到右括号要一直弹到左括号为止(左括号弹出但不输出)。
  7. 后缀表达式求值时,遇到运算符弹出的是「先右后左」两个操作数,减法与除法的顺序不能反。

15.5.4 串

  1. 子串必须连续,子序列可以不连续。
  2. π[i] 表示的是前后缀,长度不能等于 i+1(不包含自身)。
  3. KMP 中 i 永不回退;回退的是 j,且回退到 π[j-1]
  4. KMP 不是「任何输入都比朴素快」,它胜在最坏情况的保证
  5. BM 只看坏字符规则会出问题(位移可能非正),必须与好后缀规则取最大值
  6. next 数组有三种约定(0 基 π / 1 基教材版 / 位移版),做题前先确认用的是哪一种

15.5.5 数组、矩阵、树

  1. 行优先是「先走完最后一行下标」(C/C++),列优先相反(Fortran/Matlab)。
  2. 对称矩阵只需存 n(n+1)/2 个元素;三角矩阵还要多存一个常数 c,是 n(n+1)/2 + 1
  3. 「n₀ = n₂ + 1」这条性质对任何非空二叉树都成立,与是否完全无关。
  4. 完全二叉树的深度是 ⌊log₂n⌋ + 1,注意是下取整加一
  5. 已知前序 + 后序无法唯一确定一棵二叉树;必须要有中序。
  6. 赫夫曼树不唯一(左右子树可互换、相同权值的合并顺序可不同),但 WPL 唯一
  7. 赫夫曼编码是前缀编码:任何编码都不是另一个编码的前缀,因此可以唯一译码。
  8. 树的「左孩子右兄弟」表示法得到的是二叉树,且该二叉树没有右子树(对单棵树而言)。

15.5.6 图与图论算法

  1. 无向图度数和 = 2e(握手定理);有向图入度和 = 出度和 = e。
  2. 无向图 e ≤ n(n−1)/2;有向图 e ≤ n(n−1)。别把两者搞混。
  3. 无向连通图的生成树有恰好 n−1 条边,生成树不唯一但边数是确定的。
  4. Prim 适合稠密图,Kruskal 适合稀疏图;两个算法都基于贪心。
  5. Dijkstra 不能处理负权边,因为它依赖「已确定的最短距离不会再变小」这一贪心前提。
  6. Floyd 的三重循环必须把 k 放在最外层,i/j 放外层会导致结果错误。
  7. 拓扑排序存在 ⟺ 图是有向无环图(DAG);有环则排序结果不足 n 个顶点。
  8. 关键路径是 AOE 网中从源点到汇点的最长路径,它的长度等于工程的最短完成时间。
  9. 关键活动是 e(a) == l(a) 的活动;缩短非关键活动不改变总工期
  10. DFS 用栈(或递归)、BFS 用队列;求最短路径必须用 BFS(无权图)或 Dijkstra 等(带权图)。

15.5.7 查找与排序

  1. 折半查找要求顺序存储 + 有序,链表上无法实现 O(log n) 的折半查找。
  2. 插值查找在数据均匀分布时才快,分布极端不均会退化到 O(n)。
  3. 哈希表删除元素必须用墓碑标记,直接把位置清空会截断探测链,导致后面的元素查不到。
  4. 线性探测法会产生堆积(聚集),平方探测可缓解但对表长有要求(4j+3 形式的质数)。
  5. 比较排序的下界是 Ω(n log n),证明用决策树:n 个元素的排列有 n! 种,决策树至少 n! 个叶子,树高 ≥ ⌈log₂(n!)⌉ = Ω(n log n)。
  6. 「快些选堆」四个不稳定,其余稳定;稳定性只在比较关键字相等的元素时才有意义

15.6 模拟自测卷

共 16 题,满分 94 分(选择题 10×2 + 填空题 7 空×2 + 计算与作图 4×10 + 算法设计 2×10), 建议 120 分钟内完成。选择题、计算与作图、算法设计题的答案与解析都在题下折叠,请先做完再看; 填空题的答案直接给出,当作默写清单用。

一、选择题(每题 2 分,共 20 分)

1. 以下关于数据结构的说法,正确的是( )

A. 数据的存储结构是数据元素之间的逻辑关系的抽象表示
B. 顺序存储结构的插入删除效率一定高于链式存储结构
C. 同一逻辑结构可以采用不同的存储结构实现
D. 抽象数据类型与具体的存储结构一一对应

答案与解析

C。逻辑结构独立于存储结构,同一逻辑结构(如线性表)既可以用顺序存储也可以用链式存储(A、D 错)。 顺序存储的插入删除是 O(n),链式是 O(1),所以 B 反了。

2. 设 n 为问题规模,下面程序段的时间复杂度是( )

int i = 1, s = 0;
while (i <= n) { s += i; i *= 3; }

A. O(n) B. O(log₃n) C. O(n log n) D. O(√n)

答案与解析

Bi 的取值序列是 1, 3, 9, 27, …,第 k 次循环 i = 3^(k-1), 循环条件是 3^(k-1) ≤ n,即 k ≤ log₃n + 1,所以循环执行 O(log n) 次。 底数在复杂度里可以忽略,写成 O(log n)。

3. 在一个长度为 n 的顺序表中删除第 i 个元素(1 ≤ i ≤ n),需要移动的元素个数为( )

A. n − i B. n − i + 1 C. n − i − 1 D. i

答案与解析

A。删除第 i 个元素后,需要把它后面的 n − i 个元素整体前移一位。 平均移动次数为 (0+1+…+(n−1))/n = (n−1)/2

4. 已知循环队列的存储空间为数组 Q[0..M-1]front 指向队头元素、 rear 指向队尾元素的下一个位置,则队列中元素个数为( )

A. rear − front B. (rear − front + M) % M C. (rear − front) % M + 1 D. rear − front − 1

答案与解析

B。循环队列中 rear 可能已经绕回,直接相减可能为负, 必须加 M 后再取模。这是最常考的一个小公式。

5. 设模式串 P = "ababaa",采用 0 基前缀函数 π,则 π[4] 的值是( )

A. 2 B. 3 C. 4 D. 1

答案与解析

BP[0..4] = "ababa",它的真前缀有 "a"、"ab"、"aba"、"abab", 真后缀有 "a"、"ba"、"aba"、"baba",相等的最长者是 "aba",长度 3。 (完整 π 数组为 [0,0,1,2,3,1]。)

6. 一棵二叉树中有 30 个叶子结点、20 个度为 1 的结点,则该二叉树的结点总数为( )

A. 79 B. 80 C. 81 D. 无法确定

答案与解析

A。由 n₀ = n₂ + 1n₂ = 30 − 1 = 29; 总数 n = n₀ + n₁ + n₂ = 30 + 20 + 29 = 79

7. 关于有向图的拓扑排序,下列说法正确的是( )

A. 有向图存在拓扑序列的充要条件是它是无环图(DAG)
B. 任何有向图都存在拓扑序列
C. 一个 DAG 的拓扑序列一定唯一
D. 拓扑序列的长度等于图中边的条数

答案与解析

A。有向图存在拓扑序列的充要条件是它是有向无环图; 有环时 Kahn 算法只能输出不到 n 个顶点。拓扑序列一般不唯一 (除非任意两个顶点之间都存在路径关系)。长度是顶点数 n,不是边数。

8. 用 Dijkstra 算法求下图单源最短路径(图中有负权边),结果是( )

A. 一定正确 B. 可能出错 C. 一定出错 D. 只对无向图出错

答案与解析

B。Dijkstra 的贪心前提是「一旦某个顶点的 dist 被确定为最小,之后不会再被更小的值更新」。 存在负权边时这个前提被破坏,结果可能出错。经典反例:顶点 A→B 权 1,A→C 权 5,C→B 权 −4, 正确的最短路 A→C→B 长度为 1,但 Dijkstra 会先确定 B 的距离为 1,从而得不到正确答案(或次序出错)。 存在负权时应改用 Bellman-Ford / SPFA。

9. 对 8 个元素进行折半查找,查找成功时最多需要比较几次?

A. 2 B. 3 C. 4 D. 8

答案与解析

C。判定树高度为 ⌈log₂(8+1)⌉ = 4,最坏情况下比较 4 次。 一般地,n 个元素的折半查找最多比较 ⌈log₂(n+1)⌉ 次。

10. 以下排序算法中,时间复杂度为 O(n log n) 且稳定的是( )

A. 快速排序 B. 堆排序 C. 归并排序 D. 希尔排序

答案与解析

C。四个里只有归并排序是稳定的。快速排序最坏 O(n²) 且不稳定;堆排序不稳定; 希尔排序不稳定且复杂度与增量有关。口诀:快些选堆不稳定。

二、填空题(每空 2 分,共 14 分)

  1. 一个具有 n 个顶点的无向连通图,至少有 n−1 条边;至多有 n(n−1)/2 条边。
  2. 在含 n 个元素的有序表中进行折半查找,查找成功的平均查找长度约为 log₂(n+1) − 1
  3. 深度为 5 的完全二叉树最多有 31 个结点,最少有 16 个结点。
  4. 赫夫曼树中不存在度为 1 的结点(因为每次合并都会生成一个度为 2 的新结点)。
  5. 设哈希表长 m = 11,哈希函数 H(k) = k mod 11,用线性探测法处理冲突, 依次插入 {12, 23, 45, 57, 20, 3},则元素 3 最终存放的下标是 6
    解析:12 % 11 = 1 → 存 1;23 % 11 = 1,冲突 → 2;45 % 11 = 1,冲突 → 2、3; 57 % 11 = 2,冲突 → 3、4;20 % 11 = 9;3 % 11 = 3,冲突 → 4、5、6。 最终哈希表为 [_,12,23,45,57,_,3,_,_,20,_]

三、计算与作图题(每题 10 分,共 40 分)

11. 已知一棵二叉树的前序遍历为 ABDECFG,中序遍历为 DBEAFCG, 请画出这棵二叉树,并写出它的后序遍历序列。

答案与解析

前序的第一个字符 A 是根;在中序中 A 左边是 DBE(左子树),右边是 FCG(右子树)。 对左子树:前序 BDE,中序 DBE → 根 B,左 D,右 E。 对右子树:前序 CFG,中序 FCG → 根 C,左 F,右 G。

A B C D E F G
图 15-1 第 11 题还原出的二叉树

后序遍历为 DEBFGCA(左、右、根:DEB + FGC + A)。

12. 给定权值集合 W = {5, 7, 2, 3, 11},构造赫夫曼树并计算 WPL, 写出各字符的赫夫曼编码(按每次合并权值最小者、相同权值取先出现的规则)。

答案与解析

排序后为 {2, 3, 5, 7, 11}。逐步合并:

  1. 合并 2、3 → 5。集合 {5, 5, 7, 11}(其中新结点 5 与原来的 5 并列)。
  2. 合并两个 5 → 10。集合 {7, 10, 11}。
  3. 合并 7、10 → 17。集合 {11, 17}。
  4. 合并 11、17 → 28,得到根。

WPL 等于所有非叶结点的权值之和(这是一个非常好用的快捷算法): 5 + 10 + 17 + 28 = 60

编码(左 0 右 1,根为 28):以一棵可能的树为例—— {2,3} 合并成 5,再与另一个 5 合并成 10,10 与 7 合并成 17,17 与 11 合并成 28。 可得到 2 → 000,3 → 001,5 → 01, 7 → 10,11 → 11(具体编码与树形有关,但 WPL 恒为 60)。

注意 赫夫曼树不唯一(左右可交换、相同权值可交换顺序),所以编码可能不同, 但只要每一步都取最小的两个权值合并,WPL 一定是最小的 60

13. 已知无向网的邻接矩阵如下(∞ 表示无边), 用 Prim 算法从顶点 1 出发求最小生成树,写出加边的顺序与总权值。

12345
10615
26053
315056
4550
5360
答案与解析

从顶点 1 出发,初始 lowcost = [∞, 6, 1, 5, ∞](对应顶点 2~5 到生成树的最小距离)。

  1. 选最小者:顶点 3(权 1),加入边 (1,3) 权 1。更新 lowcost:顶点 2 变成 min(6, 5) = 5,顶点 4 变成 min(5,5) = 5,顶点 5 变成 6。
  2. 当前 lowcost = [5(2), 5(4), 6(5)](顶点 2、3 已在树中)。选顶点 2(权 5),加入边 (3,2) 权 5。更新:顶点 5 变成 min(6, 3) = 3。
  3. 当前 lowcost = [5(4), 3(5)]。选顶点 5(权 3),加入边 (2,5) 权 3
  4. 当前 lowcost = [5(4)]。选顶点 4(权 5),加入边 (1,4) 权 5

加边顺序:(1,3)1 → (3,2)5 → (2,5)3 → (1,4)5, 总权值 = 1 + 5 + 3 + 5 = 14

(若用 Kruskal:按权排序 1,3,3,5,5,5,6,6 → 取 (1,3)1、(2,5)3、(1,4)5 或 (2,3)5、(3,4)5, 注意不能成环,最终同样得到 4 条边、总权 14。)

14. 对序列 {49, 38, 65, 97, 76, 13, 27, 49} 分别写出 一趟快速排序(以第一个元素为基准)与一趟归并排序(二路归并,步长 1)后的结果。

答案与解析

一趟快速排序(以 49 为基准,双指针从两端向中间扫):

  • 右指针从 49 开始往左找 < 49 的:27 < 49,停下;左指针从 38 开始往右找 > 49 的:65 > 49,停下;交换 → 49, 38, 27, 97, 76, 13, 65, 49
  • 继续:右指针找 < 49:13;左指针找 > 49:97;交换 → 49, 38, 27, 13, 76, 97, 65, 49
  • 继续:右指针找到 13 位置与左指针相遇,把基准 49 放到该位置 → 13, 38, 27, 49, 76, 97, 65, 49

所以一趟划分后为 {13, 38, 27, 49, 76, 97, 65, 49},基准 49 的最终位置是下标 3(0 基)。

一趟二路归并(步长 1,两两合并):

[49,38]→[38,49][65,97]→[65,97][76,13]→[13,76][27,49]→[27,49], 结果为 {38, 49, 65, 97, 13, 76, 27, 49}

四、算法设计题(每题 10 分,共 20 分)

15. 设计一个算法,判断单链表中是否存在环;若存在,找出环的入口结点。要求空间复杂度 O(1),并说明原理。

参考解答
#include <iostream>
using namespace std;

struct Node { int val; Node* next; Node(int v): val(v), next(nullptr) {} };

/* 快慢指针(Floyd 判环)
   阶段一:slow 每次 1 步、fast 每次 2 步,若相遇则有环。
   阶段二:让一个指针回到头结点,两个指针每次都走 1 步,再次相遇处即为环入口。
   原理:设头到环入口距离 a,入口到相遇点距离 b,相遇点到入口距离 c。
         相遇时 slow 走了 a+b,fast 走了 a+b+k(b+c) 且是 slow 的 2 倍,
         可得 a = (k-1)(b+c) + c,即从相遇点走 c 步到入口 = 从头走 a 步到入口。 */
Node* detectCycle(Node* head) {
    Node *slow = head, *fast = head;
    while (fast && fast->next) {
        slow = slow->next;
        fast = fast->next->next;
        if (slow == fast) {                 // 阶段一:相遇,有环
            Node* p = head;
            while (p != slow) {             // 阶段二:找入口
                p = p->next;
                slow = slow->next;
            }
            return p;                       // 环入口
        }
    }
    return nullptr;                         // 无环
}

int main() {
    Node* n1 = new Node(1); Node* n2 = new Node(2);
    Node* n3 = new Node(3); Node* n4 = new Node(4);
    n1->next = n2; n2->next = n3; n3->next = n4; n4->next = n2;   // 4 指回 2
    Node* entry = detectCycle(n1);
    cout << (entry ? entry->val : -1) << "\n";   // 2
    return 0;
}

关键点:空间 O(1);时间复杂度 O(n)。若用哈希表记录访问过的结点,则空间是 O(n), 达不到题目要求。

16. 给定整数数组 a[] 与整数 target,求所有满足 a[i] + a[j] = target 的下标对 (i, j)(i < j)。要求时间 O(n)、空间 O(n), 并说明为什么不能简单地先排序再用双指针(提示:考虑下标要求与重复元素)。

参考解答
#include <iostream>
#include <unordered_map>
#include <vector>
using namespace std;

/* 哈希表一次遍历:对每个 a[j],看 target - a[j] 是否出现过
   时间 O(n)、空间 O(n) */
vector<pair<int,int>> twoSum(const vector<int>& a, int target) {
    unordered_map<int, int> lastSeen;      // 值 → 最近一次出现的下标
    vector<pair<int,int>> res;
    for (int j = 0; j < (int)a.size(); ++j) {
        int need = target - a[j];
        auto it = lastSeen.find(need);
        if (it != lastSeen.end()) res.push_back({it->second, j});
        lastSeen[a[j]] = j;                // 后出现的覆盖先出现的,保证 i < j
    }
    return res;
}

/* 若允许 O(n log n) 且只要求「是否存在」,可以排序 + 双指针:
   sort(a.begin(), a.end());
   int i = 0, j = n - 1;
   while (i < j) {
       int s = a[i] + a[j];
       if (s == target) return true;
       else if (s < target) ++i;
       else --j;
   }
   但排序会打乱原始下标,若题目要求返回下标就必须额外记录位置;
   而且要返回「所有」下标对时,排序 + 双指针还要处理重复元素的组合,容易漏解或重复。 */

int main() {
    vector<int> a = {2, 7, 11, 15, 3, 6};
    for (auto& p : twoSum(a, 9)) cout << p.first << "," << p.second << "  ";   // 0,1  4,5
    cout << "\n";
    return 0;
}

要点:哈希法在遍历到 a[j] 时,表里存的全是下标小于 j 的元素, 因此天然满足 i < j;用 lastSeen[a[j]] = j 覆盖可以避免同一对出现两次。

15.7 复习路线图

第 1 轮:搭骨架 线性表 · 栈 · 队列 1~4 讲,重画结构图 第 2 轮:攻难点 KMP · 树 · 图 5~9 讲,手推 + 动画 第 3 轮:记结论 查找 · 排序 10~12 讲,背对比表 第 4 轮:写代码 DP · 图论算法 · 洛谷题单 13~14 讲,动手过题 四轮复习检查表(每完成一项打勾) ☐ 能手写顺序表 / 单链表 / 双向链表的插入与删除,并说清指针修改顺序 ☐ 能默写大 O 推导的 5 个典型循环与 3 个典型递推式 ☐ 能用纸笔推出任意模式串的 π 数组与教材版 next / nextval ☐ 能默画 n = 11 的折半查找判定树并现场算 ASL ☐ 能背出十种排序的时间 / 空间 / 稳定性,并说出「快些选堆」口诀 ☐ 能独立手推 Prim、Kruskal、Dijkstra、Floyd 各一遍(含表格) ☐ 能算 AOE 网的 ve / vl / e / l 并找出关键路径 ☐ 能写出 01 背包、完全背包、LIS、LCS 四个 DP 模板并说清遍历顺序的原因
图 15-2 四轮复习路线图与自查清单
最后一句话 数据结构的复习不是「看会」,而是「写会」和「推会」。 凡是能在纸上推一遍的东西,就不要只靠眼睛看;凡是能写十行代码验证的结论,就不要只背结论。 把本讲的自测卷做三遍(第一遍开卷、第二遍闭卷、第三遍限时),这门课就稳了。