第 10 章写了顺序表和链表,第 11 章讲了十种算法思想。这一章把常见课程作业和面试里最容易考到的部分补齐:**四种排序、二叉树、图、并查集**,最后给两张速查表和一张选型清单。
这些结构不需要背,但要做到:给一个需求,能立刻说出用哪个结构、复杂度是多少、边界在哪里。
13.1 四种必须会写的排序
std::sort 平时够用,但“手写一遍”能让你真正理解复杂度从哪来。四种里前两种必须默写,后两种要能讲清思路。
插入排序:像整理扑克牌
for (int i = 1; i < n; ++i) {
int key = a[i];
int j = i - 1;
while (j >= 0 && a[j] > key) { a[j + 1] = a[j]; --j; }
a[j + 1] = key;
}
已排好序的区间在左边不断长大。数据基本有序时非常快(接近 O(n)),也是很多标准库在小数组上的实际做法。
归并排序:分治 + 合并
void mergeSort(std::vector<int>& a, int l, int r, std::vector<int>& buf) {
if (r - l <= 1) { return; } // [l, r) 只剩 0/1 个元素
int mid = l + (r - l) / 2;
mergeSort(a, l, mid, buf);
mergeSort(a, mid, r, buf);
int i = l, j = mid, k = l; // 合并两个有序区间
while (i < mid && j < r) { buf[k++] = (a[i] <= a[j]) ? a[i++] : a[j++]; }
while (i < mid) { buf[k++] = a[i++]; }
while (j < r) { buf[k++] = a[j++]; }
for (int t = l; t < r; ++t) { a[t] = buf[t]; }
}
复杂度稳定在 O(n log n),而且是**稳定排序**(相等元素保持原顺序)。代价是需要 O(n) 的额外空间。
快速排序:选基准 + 分区
void quickSort(std::vector<int>& a, int l, int r) { // [l, r]
if (l >= r) { return; }
int i = l, j = r;
int pivot = a[l + (r - l) / 2]; // 取中间值当基准
while (i <= j) {
while (a[i] < pivot) { ++i; }
while (a[j] > pivot) { --j; }
if (i <= j) { std::swap(a[i], a[j]); ++i; --j; }
}
quickSort(a, l, j);
quickSort(a, i, r);
}
平均 O(n log n)、常数小、原地排序,是 std::sort 的基础。最坏 O(n²)(每次选到极值当基准),随机化或三数取中可以规避。
堆排序:借优先队列的思路
std::make_heap(a.begin(), a.end()); // 建大顶堆 O(n)
for (int i = n - 1; i > 0; --i) {
std::pop_heap(a.begin(), a.begin() + i + 1); // 最大值换到末尾
}
O(n log n)、原地、不用递归。手写版的关键是 sift_down(下沉):父节点和较大的子节点交换,直到满足堆性质。
| 算法 | 平均 | 最坏 | 额外空间 | 稳定 |
|---|---|---|---|---|
| 插入排序 | O(n²) | O(n²) | O(1) | 是 |
| 归并排序 | O(n log n) | O(n log n) | O(n) | 是 |
| 快速排序 | O(n log n) | O(n²) | O(log n) 递归栈 | 否 |
| 堆排序 | O(n log n) | O(n log n) | O(1) | 否 |
✍️ 本节练习
读入 n 个整数,用自己写的归并排序升序输出;再用固定种子 12345 做 200 轮随机对拍,每轮把随机数组分别交给你的排序和 std::sort,断言结果完全一致,最后输出 ok。
输入 一个整数 n,随后 n 个整数。
输出 第一行升序序列;第二行 ok。
6
5 2 9 1 5 61 2 5 5 6 9
ok💡 提示 对拍固定种子:std::mt19937 gen(12345);比较直接用 == 比较两个 vector。
✅ 查看参考答案与解析
#include <algorithm>
#include <cassert>
#include <cstddef>
#include <iostream>
#include <random>
#include <vector>
void mergeSort(std::vector<int>& a, int l, int r, std::vector<int>& buf) {
if (r - l <= 1) { return; }
int mid = l + (r - l) / 2;
mergeSort(a, l, mid, buf);
mergeSort(a, mid, r, buf);
int i = l, j = mid, k = l;
while (i < mid && j < r) { buf[k++] = (a[i] <= a[j]) ? a[i++] : a[j++]; }
while (i < mid) { buf[k++] = a[i++]; }
while (j < r) { buf[k++] = a[j++]; }
for (int t = l; t < r; ++t) { a[t] = buf[t]; }
}
int main() {
int n{};
std::cin >> n;
std::vector<int> a(static_cast<std::size_t>(n));
for (int i = 0; i < n; ++i) { std::cin >> a[i]; }
std::vector<int> buf(a.size());
mergeSort(a, 0, n, buf);
for (int i = 0; i < n; ++i) { std::cout << a[i] << (i + 1 == n ? '\n' : ' '); }
std::mt19937 gen(12345); // 固定种子,结果可复现
std::uniform_int_distribution<int> dist(-1000, 1000);
for (int round = 0; round < 200; ++round) {
int len = 1 + round % 50;
std::vector<int> mine(static_cast<std::size_t>(len)), ref;
for (int i = 0; i < len; ++i) { mine[i] = dist(gen); }
ref = mine;
std::vector<int> tmp(mine.size());
mergeSort(mine, 0, len, tmp);
std::sort(ref.begin(), ref.end());
assert(mine == ref);
}
std::cout << "ok\n";
return 0;
}
解析 对拍是验证自己实现的通用手段:同一份随机输入喂给两个实现,逐元素比较;固定种子保证失败可复现。
13.2 二叉树与二叉搜索树
二叉树是“每个节点最多两个孩子”的结构。二叉搜索树(BST)额外要求:左子树全部小于根,右子树全部大于根——这让查找可以像二分一样折半。
struct Node {
int value{};
std::unique_ptr<Node> left;
std::unique_ptr<Node> right;
explicit Node(int v) : value(v) { }
};
void insert(std::unique_ptr<Node>& root, int v) {
if (!root) { root = std::make_unique<Node>(v); return; }
if (v < root->value) { insert(root->left, v); }
else if (v > root->value) { insert(root->right, v); }
// 相等就忽略(集合语义)
}
void inorder(const std::unique_ptr<Node>& root) { // 中序 = 升序
if (!root) { return; }
inorder(root->left);
std::cout << root->value << ' ';
inorder(root->right);
}
中序遍历输出一定是有序序列,这是 BST 最好用的性质。查找的平均复杂度 O(log n),但**如果插入顺序本身有序,BST 会退化成链表**,复杂度变成 O(n)。
✍️ 本节练习
读入 n 个整数依次插入二叉搜索树(重复值忽略),输出中序遍历结果、最小值、最大值。
输入 第一行整数 n;第二行 n 个整数。
输出 第一行升序的中序序列;第二行 min=;第三行 max=。
6
5 3 8 1 4 71 3 4 5 7 8
min=1
max=8💡 提示 最小值就是一路往左走到底,最大值一路往右走到底。
✅ 查看参考答案与解析
#include <iostream>
#include <memory>
struct Node {
int value{};
std::unique_ptr<Node> left;
std::unique_ptr<Node> right;
explicit Node(int v) : value(v) { }
};
void insert(std::unique_ptr<Node>& root, int v) {
if (!root) { root = std::make_unique<Node>(v); return; }
if (v < root->value) { insert(root->left, v); }
else if (v > root->value) { insert(root->right, v); }
// 相等:忽略,保持集合语义
}
void inorder(const std::unique_ptr<Node>& root, bool& first) {
if (!root) { return; }
inorder(root->left, first);
std::cout << (first ? "" : " ") << root->value;
first = false;
inorder(root->right, first);
}
const Node* minNode(const std::unique_ptr<Node>& root) {
const Node* p = root.get();
while (p && p->left) { p = p->left.get(); }
return p;
}
const Node* maxNode(const std::unique_ptr<Node>& root) {
const Node* p = root.get();
while (p && p->right) { p = p->right.get(); }
return p;
}
int main() {
int n{};
std::cin >> n;
std::unique_ptr<Node> root;
for (int i = 0; i < n; ++i) {
int x{};
std::cin >> x;
insert(root, x);
}
bool first = true;
inorder(root, first);
std::cout << '\n';
std::cout << "min=" << minNode(root)->value << '\n';
std::cout << "max=" << maxNode(root)->value << '\n';
return 0;
}
解析 unique_ptr 让整棵树在 root 析构时自动递归释放,不需要手写析构函数;注意树很深时递归释放同样有爆栈风险。
13.3 图:邻接表、DFS、BFS
图就是“点和边”。存图最常用的方式是**邻接表**:vector<vector<int>>,下标是点,里面存相邻点。点少边多也可以用邻接矩阵。
int n, m;
std::cin >> n >> m;
std::vector<std::vector<int>> g(n);
for (int i = 0; i < m; ++i) {
int u, v;
std::cin >> u >> v;
g[u].push_back(v);
g[v].push_back(u); // 无向图:两个方向都存;有向图去掉这行
}
建好图之后:DFS 用来回答“有哪些点连在一起”(连通块、判环、拓扑序);BFS 用来回答“最少几步能到”(无权图最短路)。两者的边界检查写法不一样,记住这一点就不会写混。
| 问题 | 用哪个 | 复杂度 |
|---|---|---|
| 有多少个连通块 | DFS / BFS 都行 | O(n + m) |
| 从 a 到 b 最少几条边 | BFS | O(n + m) |
| 图里有没有环 | DFS(记录父节点) | O(n + m) |
| 按拓扑序输出 | DFS 后序反着输出 / 入度 BFS | O(n + m) |
✍️ 本节练习
读入 n 个点、m 条无向边,输出两行:整张图的连通块个数;点 0 到点 n-1 的最短边数(不可达输出 -1)。
输入 第一行 n m;接下来 m 行每行两个整数 u v。
输出 第一行 components=;第二行 dist=。
6 5
0 1
1 2
2 3
3 4
1 5components=1
dist=2💡 提示 连通块用 DFS;最短路用 BFS 的 dist 数组,初始 -1 表示未访问。
✅ 查看参考答案与解析
#include <iostream>
#include <queue>
#include <vector>
void dfs(int u, const std::vector<std::vector<int>>& g, std::vector<char>& vis) {
vis[u] = 1;
for (int v : g[u]) {
if (!vis[v]) { dfs(v, g, vis); }
}
}
int main() {
int n{}, m{};
std::cin >> n >> m;
std::vector<std::vector<int>> g(n);
for (int i = 0; i < m; ++i) {
int u{}, v{};
std::cin >> u >> v;
g[u].push_back(v);
g[v].push_back(u);
}
// 1) 连通块计数(DFS)
std::vector<char> vis(n, 0);
int components = 0;
for (int i = 0; i < n; ++i) {
if (!vis[i]) {
++components;
dfs(i, g, vis);
}
}
std::cout << "components=" << components << '\n';
// 2) 无权最短路(BFS)
std::vector<int> dist(n, -1);
std::queue<int> q;
dist[0] = 0;
q.push(0);
while (!q.empty()) {
int u = q.front();
q.pop();
for (int v : g[u]) {
if (dist[v] == -1) {
dist[v] = dist[u] + 1;
q.push(v);
}
}
}
std::cout << "dist=" << dist[n - 1] << '\n';
return 0;
}
解析 BFS 里“入队即标记”是保证每个点只入队一次的关键;如果改成出队时标记,同一个点会被重复入队,复杂度退化甚至出错。
13.4 并查集:合并与查询
并查集解决“谁和谁是一伙的”:不断合并两个集合,随时查询两个人是否同一集合。路径压缩 + 按大小合并之后,单次操作接近 O(1)。
struct DSU {
std::vector<int> parent, size;
explicit DSU(int n) : parent(n), size(n, 1) {
for (int i = 0; i < n; ++i) { parent[i] = i; }
}
int find(int x) { // 路径压缩
if (parent[x] != x) { parent[x] = find(parent[x]); }
return parent[x];
}
void unite(int a, int b) { // 按大小合并
int ra = find(a), rb = find(b);
if (ra == rb) { return; }
if (size[ra] < size[rb]) { std::swap(ra, rb); }
parent[rb] = ra;
size[ra] += size[rb];
}
bool same(int a, int b) { return find(a) == find(b); }
};
递归版 find 写起来短;数据量极大时递归可能爆栈,可以改成 while 循环加两遍扫描的写法。
✍️ 本节练习
读入 n 个人和 m 对朋友关系(朋友的朋友也是朋友),输出朋友圈个数;再读入一对查询 a b,输出他们是否在同一朋友圈。
输入 第一行 n m;接下来 m 行每行两个整数 u v;最后一行两个整数 a b(查询)。
输出 第一行 groups=;第二行同一圈输出 yes,否则输出 no。
5 3
0 1
2 3
3 4
2 4groups=2
yes💡 提示 每读一对朋友就 unite(u, v);统计 groups 就是数 find(i) == i 的个数。
✅ 查看参考答案与解析
#include <iostream>
#include <utility>
#include <vector>
struct DSU {
std::vector<int> parent, sz;
explicit DSU(int n) : parent(static_cast<std::size_t>(n)), sz(static_cast<std::size_t>(n), 1) {
for (int i = 0; i < n; ++i) { parent[static_cast<std::size_t>(i)] = i; }
}
int find(int x) {
if (parent[static_cast<std::size_t>(x)] != x) {
parent[static_cast<std::size_t>(x)] = find(parent[static_cast<std::size_t>(x)]);
}
return parent[static_cast<std::size_t>(x)];
}
void unite(int a, int b) {
int ra = find(a), rb = find(b);
if (ra == rb) { return; }
if (sz[static_cast<std::size_t>(ra)] < sz[static_cast<std::size_t>(rb)]) {
std::swap(ra, rb);
}
parent[static_cast<std::size_t>(rb)] = ra;
sz[static_cast<std::size_t>(ra)] += sz[static_cast<std::size_t>(rb)];
}
};
int main() {
int n{}, m{};
std::cin >> n >> m;
DSU dsu(n);
for (int i = 0; i < m; ++i) {
int u{}, v{};
std::cin >> u >> v;
dsu.unite(u, v);
}
int groups = 0;
for (int i = 0; i < n; ++i) {
if (dsu.find(i) == i) { ++groups; }
}
std::cout << "groups=" << groups << '\n';
int a{}, b{};
std::cin >> a >> b;
std::cout << (dsu.find(a) == dsu.find(b) ? "yes" : "no") << '\n';
return 0;
}
解析 统计集合个数时不要数 parent[i] == i 而不做路径压缩——必须先 find(i) 把路径压平再比较,否则中间节点会被漏算。
13.5 复杂度速查表
写代码时选错容器,往往就是“超时”的根源。这两张表建议抄在笔记本第一页。
| 容器 | 随机访问 | 尾部插入 | 中部插入/删除 | 查找 | 有序 |
|---|---|---|---|---|---|
| vector | O(1) | 平摊 O(1) | O(n) | O(n) | 否 |
| deque | O(1) | O(1) | O(n) | O(n) | 否 |
| list | 不支持 | O(1) | O(1)(已有迭代器) | O(n) | 否 |
| map / set | 不支持 | — | O(log n) | O(log n) | 是 |
| unordered_map / unordered_set | 不支持 | — | 平均 O(1) | 平均 O(1) | 否 |
| priority_queue | 只能看堆顶 | O(log n) | — | — | 堆序 |
| 任务 | 推荐做法 | 复杂度 |
|---|---|---|
| 频繁按下标读写 | vector | O(1) |
| 已知规模先开空间 | vector + reserve | — |
| 去重且要顺序 | set / 排序后 unique | O(n log n) |
| 统计频率 | unordered_map | 平均 O(n) |
| 每次取最值 | priority_queue | O(log n) |
| 大量区间求和 | 前缀和 | 预处理 O(n),查询 O(1) |
| 在单调序列里找边界 | 二分 / lower_bound | O(log n) |
| 判断连通性、动态合并 | 并查集 | 近 O(1) |
13.6 怎么选:一张决策清单
拿到题目先别写代码,按下面的顺序问自己几个问题,答案基本就出来了。
- 数据是线性的一串?→ 先想双指针 / 滑动窗口 / 前缀和。
- 要频繁查“某个值在不在 / 出现过几次”?→ 哈希表(unordered_map)。
- 答案具有“单调性”(越大越容易满足)?→ 二分答案。
- 要求“所有方案 / 所有路径”?→ 回溯(DFS + 撤销)。
- 要求“最少步数 / 最短距离”,边权都一样?→ BFS。
- 要求“最优值”,且每一步的最优能推出全局最优?→ 贪心(先想反例)。
- 要求“最优值”,子问题会重复出现?→ 动态规划(定义状态 → 转移方程 → 初值)。
- 涉及“谁和谁连着 / 合并集合”?→ 并查集。
- 数据规模 < 20?→ 直接枚举 / 状态压缩,别过度设计。
- 数据规模 10^9 但只需要判断能不能?→ 二分或数学推导,别想着开数组。
本章小结
- 四种排序:插入(近乎有序时快)、归并(稳定、O(n log n))、快排(平均最快、原地)、堆排(原地、无递归)。
- 二叉搜索树中序有序,但会退化;map/set 用红黑树保证 O(log n)。
- 图的两种遍历分工明确:DFS 管连通性,BFS 管无权最短路;邻接表是最常用的存图方式。
- 并查集 = 路径压缩 + 按大小合并,处理“连通/合并”问题接近 O(1)。
- 选算法先看数据规模和单调性,再看需不需要枚举所有方案。