综合自测与速查手册
这一讲把整门课的结论压成几张表、几段模板代码和一套自测题。 建议用法:先做自测卷,错哪题就回哪一章;所有动画重看一遍;最后把速查表当公式背下来。
- 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) | 主串指针回溯是瓶颈 |
| KMP | O(m) 求 π | O(n) | O(m) | i 永不后退,最坏 O(n+m) |
| BM(坏字符 + 好后缀) | O(m + σ) | 平均 O(n/m) | O(m + σ) | 从右往左比,自然文本最快 |
| 对称矩阵压缩 | — | O(1) | n(n+1)/2 | k = i(i+1)/2 + j(i≥j) |
| 下三角矩阵压缩 | — | O(1) | n(n+1)/2 + 1 | 常数 c 单独占一个单元 |
| 三对角矩阵压缩 | — | O(1) | 3n − 2 | k = 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 | 适合稀疏图 |
| Kruskal | O(m log m) | O(n+m) | MST | 需要并查集;稀疏图首选 |
| Dijkstra(朴素) | O(n²) | O(n) | 单源最短路 | 不能有负权边 |
| Dijkstra(堆优化) | O(m log n) | O(n+m) | 单源最短路 | 不能有负权边 |
| Bellman-Ford | O(nm) | O(n) | 单源最短路 + 判负环 | 较慢 |
| SPFA | 平均 O(km),最坏 O(nm) | O(n+m) | 单源最短路 + 判负环 | 容易被特殊数据卡 |
| Floyd | O(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)/2 | O(n) | 不成功需 n(或 n+1)次 |
| 顺序查找(有序) | 有序 | (n+1)/2 | O(n) | 失败 ASL 可降到约 n/2 |
| 折半查找 | 顺序存储 + 有序 | ≈log₂(n+1) − 1 | O(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) | 删除方便,工程主流 |
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 | 一端进、另一端出 | 先进先出 FIFO | BFS、任务调度、缓冲、排队模拟 |
| 双端队列 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) | 最省内存 |
stack | deque 适配 | — | push O(1) | pop O(1) | — | 只有 top/push/pop |
queue | deque 适配 | — | 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 绪论与复杂度
- 大 O 是上界,不是精确值。
2n² + 3n和n²都是O(n²),但不能说它们「一样快」。 - O(1) 不等于「执行一次」。 执行 1000 条语句仍是 O(1)。
- 循环变量的变化方式是判断复杂度的关键。
i *= 2是 O(log n),i = sqrt(i)是 O(log log n)。 - 递归的时间复杂度要写递推式。 不能只看循环层数。
- 空间复杂度算的是额外空间,递归要算栈深度。
- 算法的「有穷性」指在有限步内结束,不是「不能有循环」。
- 逻辑结构与存储结构是两个独立维度:同一逻辑结构可以有多种存储结构。
15.5.2 线性表
- 顺序表插入的平均移动次数是 n/2,删除是 (n−1)/2(教材常都近似为 n/2)。
- 头结点不是首元结点。 头结点不存数据(或存长度),是为了统一空表与非空表的操作。
- 「在 p 结点前插入」单链表做不到 O(1),除非已知前驱;但「在 p 结点后插入」可以 O(1)(交换数据域的技巧)。
- 双向链表删除结点不需要找前驱,这是它相对单链表最大的优势。
- 静态链表的
next存的是数组下标(游标),不是地址。 - 快慢指针判环时,快指针每次走 2 步,两者一定会在环内相遇(如果有环)。
15.5.3 栈与队列
- 共享栈的栈满条件是
top1 + 1 == top0(两个栈顶相邻),不是相等。 - 链栈通常不设头结点,栈顶就是链表的第一个结点。
- 循环队列「牺牲一个单元」是为了区分队空与队满:队空
front == rear,队满(rear+1)%M == front。 - 循环队列元素个数 =
(rear − front + M) % M,不要漏掉+M。 - 链队列在出队后若队列为空,必须把 rear 重新指向头结点,否则 rear 悬空。
- 中缀转后缀时,右括号不入栈;遇到右括号要一直弹到左括号为止(左括号弹出但不输出)。
- 后缀表达式求值时,遇到运算符弹出的是「先右后左」两个操作数,减法与除法的顺序不能反。
15.5.4 串
- 子串必须连续,子序列可以不连续。
π[i]表示的是真前后缀,长度不能等于 i+1(不包含自身)。- KMP 中
i永不回退;回退的是j,且回退到π[j-1]。 - KMP 不是「任何输入都比朴素快」,它胜在最坏情况的保证。
- BM 只看坏字符规则会出问题(位移可能非正),必须与好后缀规则取最大值。
next数组有三种约定(0 基 π / 1 基教材版 / 位移版),做题前先确认用的是哪一种。
15.5.5 数组、矩阵、树
- 行优先是「先走完最后一行下标」(C/C++),列优先相反(Fortran/Matlab)。
- 对称矩阵只需存
n(n+1)/2个元素;三角矩阵还要多存一个常数 c,是n(n+1)/2 + 1。 - 「n₀ = n₂ + 1」这条性质对任何非空二叉树都成立,与是否完全无关。
- 完全二叉树的深度是
⌊log₂n⌋ + 1,注意是下取整加一。 - 已知前序 + 后序无法唯一确定一棵二叉树;必须要有中序。
- 赫夫曼树不唯一(左右子树可互换、相同权值的合并顺序可不同),但 WPL 唯一。
- 赫夫曼编码是前缀编码:任何编码都不是另一个编码的前缀,因此可以唯一译码。
- 树的「左孩子右兄弟」表示法得到的是二叉树,且该二叉树没有右子树(对单棵树而言)。
15.5.6 图与图论算法
- 无向图度数和 = 2e(握手定理);有向图入度和 = 出度和 = e。
- 无向图
e ≤ n(n−1)/2;有向图e ≤ n(n−1)。别把两者搞混。 - 无向连通图的生成树有恰好 n−1 条边,生成树不唯一但边数是确定的。
- Prim 适合稠密图,Kruskal 适合稀疏图;两个算法都基于贪心。
- Dijkstra 不能处理负权边,因为它依赖「已确定的最短距离不会再变小」这一贪心前提。
- Floyd 的三重循环必须把 k 放在最外层,i/j 放外层会导致结果错误。
- 拓扑排序存在 ⟺ 图是有向无环图(DAG);有环则排序结果不足 n 个顶点。
- 关键路径是 AOE 网中从源点到汇点的最长路径,它的长度等于工程的最短完成时间。
- 关键活动是
e(a) == l(a)的活动;缩短非关键活动不改变总工期。 - DFS 用栈(或递归)、BFS 用队列;求最短路径必须用 BFS(无权图)或 Dijkstra 等(带权图)。
15.5.7 查找与排序
- 折半查找要求顺序存储 + 有序,链表上无法实现 O(log n) 的折半查找。
- 插值查找在数据均匀分布时才快,分布极端不均会退化到 O(n)。
- 哈希表删除元素必须用墓碑标记,直接把位置清空会截断探测链,导致后面的元素查不到。
- 线性探测法会产生堆积(聚集),平方探测可缓解但对表长有要求(4j+3 形式的质数)。
- 比较排序的下界是
Ω(n log n),证明用决策树:n 个元素的排列有 n! 种,决策树至少 n! 个叶子,树高 ≥ ⌈log₂(n!)⌉ = Ω(n log n)。 - 「快些选堆」四个不稳定,其余稳定;稳定性只在比较关键字相等的元素时才有意义。
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)
答案与解析
B。i 的取值序列是 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
答案与解析
B。P[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₂ + 1 得 n₂ = 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 分)
- 一个具有 n 个顶点的无向连通图,至少有 n−1 条边;至多有 n(n−1)/2 条边。
- 在含 n 个元素的有序表中进行折半查找,查找成功的平均查找长度约为 log₂(n+1) − 1。
- 深度为 5 的完全二叉树最多有 31 个结点,最少有 16 个结点。
- 赫夫曼树中不存在度为 1 的结点(因为每次合并都会生成一个度为 2 的新结点)。
- 设哈希表长 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。
后序遍历为 DEBFGCA(左、右、根:DEB + FGC + A)。
12. 给定权值集合 W = {5, 7, 2, 3, 11},构造赫夫曼树并计算 WPL,
写出各字符的赫夫曼编码(按每次合并权值最小者、相同权值取先出现的规则)。
答案与解析
排序后为 {2, 3, 5, 7, 11}。逐步合并:
- 合并 2、3 → 5。集合 {5, 5, 7, 11}(其中新结点 5 与原来的 5 并列)。
- 合并两个 5 → 10。集合 {7, 10, 11}。
- 合并 7、10 → 17。集合 {11, 17}。
- 合并 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)。
13. 已知无向网的邻接矩阵如下(∞ 表示无边), 用 Prim 算法从顶点 1 出发求最小生成树,写出加边的顺序与总权值。
| 1 | 2 | 3 | 4 | 5 | |
|---|---|---|---|---|---|
| 1 | 0 | 6 | 1 | 5 | ∞ |
| 2 | 6 | 0 | 5 | ∞ | 3 |
| 3 | 1 | 5 | 0 | 5 | 6 |
| 4 | 5 | ∞ | 5 | 0 | ∞ |
| 5 | ∞ | 3 | 6 | ∞ | 0 |
答案与解析
从顶点 1 出发,初始 lowcost = [∞, 6, 1, 5, ∞](对应顶点 2~5 到生成树的最小距离)。
- 选最小者:顶点 3(权 1),加入边 (1,3) 权 1。更新 lowcost:顶点 2 变成 min(6, 5) = 5,顶点 4 变成 min(5,5) = 5,顶点 5 变成 6。
- 当前 lowcost = [5(2), 5(4), 6(5)](顶点 2、3 已在树中)。选顶点 2(权 5),加入边 (3,2) 权 5。更新:顶点 5 变成 min(6, 3) = 3。
- 当前 lowcost = [5(4), 3(5)]。选顶点 5(权 3),加入边 (2,5) 权 3。
- 当前 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 覆盖可以避免同一对出现两次。