算法总结

截至 0412(最短路 II)。学到哪写到哪 —— 这张表回答一个问题:看到一道题,我该抓哪个工具?每个工具给「什么时候用、多快、代码骨架、踩过的坑」,细节点回对应课件。

1. 一张总表

算法解决什么复杂度一句话记住它课件
BFS无权图的最短路、层序遍历O(n + m)一圈一圈往外扩0408
DFS连通块、路径枚举、回溯O(n + m)一条道走到黑,走不动再退0408
拓扑排序有依赖的任务排序、判 DAGO(n + m)先把没前置的点拿出来0409
并查集连通性、合并集合、Kruskal 的前半段近 O(1)只关心「是不是一家」0410
Dijkstra(堆)单源最短路,边权非负O(m log m)每轮挑最近的盖章0411
Bellman-Ford单源最短路 + 负环,边权可为负O(n·m)不盖章,全体松弛 n−1 轮0412
SPFA同上,但平均更快(可能被卡)最坏 O(n·m)Bellman-Ford 加个队列0412
Floyd任意两点最短路,n 小O(n³)五行,k 必须最外层0412
归并 / 快排排序(要稳定选归并)O(n log n)分两半,各自排好再合0407
堆(优先队列)反复取最小值、Top-K插入/弹出 O(log n)完全二叉树 + 数组存0406
二分答案答案有单调性(0414)O(log 值域)先想「能不能只判对错」0414 待学
前缀和 / 差分区间求和、区间加(0415)O(1) 查询预处理一次,后面白拿0415 待学
三句话速览

2. 数据结构怎么挑

结构关键操作什么时候选它课件
数组 / vector随机访问 O(1)元素个数确定、按下标访问最多0401
链表插入删除 O(1)(已拿到位置)要频繁在中间插删,且顺序重要0402
栈 stackpush / pop / top括号匹配、逆序、单调栈0011
队列 queuepush / pop / frontBFS、按顺序处理任务0011
堆 priority_queue取最小/最大 O(log n)只关心极值,不关心全部顺序0406
二叉搜索树查/插/删 均摊 O(log n)要同时「查找 + 有序遍历」0405
并查集合并 + 查同根,近 O(1)只问「是不是一家」0410
map / set插入/查找 O(log n)要键值对、要自动去重排序STL 速查

3. 图怎么存

存法内存适合缺点
邻接矩阵 g[u][v]O(n²)n ≤ 500、稠密图、Floydn = 105 直接 40 GB
邻接表 vector<vector<...>>O(n + m)默认就选它判两点是否相邻要扫
边数组 vector<Edge>O(m)Bellman-Ford、Kruskal不能直接查邻居
// 有权图的邻接表(最常用)
vector<vector<pair<int,int>>> g(n + 1);   // g[u] = {v, w}
g[u].push_back({v, w});
for (auto [v, w] : g[u]) { /* 遍历 u 的出边 */ }

注意 P3385 那类题:「w ≥ 0 表示双向边,w < 0 只有单向」,一条输入可能要 push 两次。

4. 遍历:BFS / DFS

两者都是 O(n + m),区别只在「先扩一圈」还是「先钻到底」。

BFS(队列)DFS(递归/栈)
能求无权图最短步数、层序连通块、路径方案、回溯
空间最宽一层 O(n)最深一条链 O(n)(可能爆栈)
标记时机入队时就标记进函数就标记
// BFS:d[v] = 起点到 v 的最少边数
void bfs(int s, const vector<vector<int>>& g, vector<int>& d) {
    queue<int> q;
    d[s] = 0; q.push(s);
    while (!q.empty()) {
        int u = q.front(); q.pop();
        for (int v : g[u])
            if (d[v] == -1) {           // 没访问过才进来
                d[v] = d[u] + 1;
                q.push(v);              // 入队前就标记(d[v] 已经不等于 -1)
            }
    }
}

// DFS:标记连通块
void dfs(int u, const vector<vector<int>>& g, vector<bool>& vis) {
    vis[u] = true;
    for (int v : g[u])
        if (!vis[v]) dfs(v, g, vis);
}

实测(图:1→2、1→3、2→4、3→4,起点 1):d1=0 d2=1 d3=1 d4=2,DFS 访问 4 个点。

5. 拓扑排序

什么时候能用:有向图、没有环(DAG)。典型题面是「先修课」「任务依赖」「谁必须在谁前面」。

// Kahn 算法:反复取出入度为 0 的点
vector<int> topoSort(int n, const vector<vector<int>>& g) {
    vector<int> deg(n + 1, 0), res;
    for (int u = 1; u <= n; u++)
        for (int v : g[u]) deg[v]++;              // 统计入度
    queue<int> q;
    for (int i = 1; i <= n; i++) if (deg[i] == 0) q.push(i);
    while (!q.empty()) {
        int u = q.front(); q.pop();
        res.push_back(u);
        for (int v : g[u])
            if (--deg[v] == 0) q.push(v);         // 前置都做完了,可以入队
    }
    return res;
}

顺手判环:如果 res.size() != n,说明剩下的点互相依赖 —— 图里有环。
实测(同上那张图):输出 1 2 3 4。

6. 并查集

什么时候用:题目只问「两个点在不在同一个连通块」「把两个集合并起来」,从不问你路径长什么样。加了路径压缩之后,单次操作几乎 O(1)。

int findRoot(vector<int>& fa, int x) {
    return fa[x] == x ? x : fa[x] = findRoot(fa, fa[x]);   // 路径压缩
}
void unite(vector<int>& fa, int a, int b) {
    a = findRoot(fa, a); b = findRoot(fa, b);
    if (a != b) fa[a] = b;
}

// 初始化:每个点自成一个集合
vector<int> fa(n + 1);
iota(fa.begin(), fa.end(), 0);        // fa[i] = i

两种优化别混:路径压缩(把链拍平)和按秩/按大小合并(小的挂到大的下面)。后者写错不会错答案,只会慢 —— 0410 那题里很多人写成「比较根的编号」,就是这个坑。
实测:unite(1,2)、unite(2,3) 之后,1 和 3 同根、1 和 4 不同根。

7. 最短路三兄弟

算法边权复杂度能判负环什么时候选
Dijkstra(堆)必须 ≥ 0O(m log m)不能默认选它
Bellman-Ford可负O(n·m)能有负权、n·m 不大
SPFA可负最坏 O(n·m)能想赌它快(会被卡)
Floyd可负O(n³)能(f[i][i]<0)任意两点、n ≤ 500
// Dijkstra:堆里存 {距离, 点},小的先出
vector<long long> dijkstra(int n, const vector<vector<pair<int,int>>>& g, int s) {
    const long long INF = 4e18;
    vector<long long> dis(n + 1, INF);
    priority_queue<pair<long long,int>, vector<pair<long long,int>>, greater<>> pq;
    dis[s] = 0; pq.push({0, s});
    while (!pq.empty()) {
        auto [du, u] = pq.top(); pq.pop();
        if (du > dis[u]) continue;                 // 懒删除:这是旧快照
        for (auto [v, w] : g[u])
            if (dis[u] + w < dis[v]) {
                dis[v] = dis[u] + w;
                pq.push({dis[v], v});              // 只压入,不修改堆里的旧值
            }
    }
    return dis;
}
// Bellman-Ford:不盖章,全体边松弛 n-1 轮;第 n 轮还能改 → 负环
bool bellmanFord(int n, const vector<Edge>& edges, int s, vector<long long>& dis) {
    const long long INF = 4e18;
    dis.assign(n + 1, INF);
    dis[s] = 0;
    for (int round = 1; round <= n - 1; round++) {
        bool changed = false;
        for (auto& e : edges) {
            if (dis[e.u] == INF) continue;         // 不可达的点不能拿去加
            if (dis[e.u] + e.w < dis[e.v]) { dis[e.v] = dis[e.u] + e.w; changed = true; }
        }
        if (!changed) break;                       // 整轮没变化就收工
    }
    for (auto& e : edges)
        if (dis[e.u] != INF && dis[e.u] + e.w < dis[e.v]) return false;   // 有负环
    return true;
}
// SPFA:Bellman-Ford 的队列版,用「路径边数 ≥ n」判负环
bool spfa(int n, const vector<vector<pair<int,int>>>& g, int s, vector<long long>& dis) {
    const long long INF = 4e18;
    dis.assign(n + 1, INF);
    vector<int> cnt(n + 1, 0);
    vector<bool> inq(n + 1, false);
    deque<int> q;
    dis[s] = 0; q.push_back(s); inq[s] = true;
    while (!q.empty()) {
        int u = q.front(); q.pop_front(); inq[u] = false;
        for (auto [v, w] : g[u])
            if (dis[u] + w < dis[v]) {
                dis[v] = dis[u] + w;
                cnt[v] = cnt[u] + 1;
                if (cnt[v] >= n) return false;     // 绕了 n 条边还在变短 → 负环
                if (!inq[v]) { q.push_back(v); inq[v] = true; }
            }
    }
    return true;
}
// Floyd:k 是最外层(DP 的阶段),不能换位置
for (int k = 1; k <= n; k++)
    for (int i = 1; i <= n; i++) {
        if (f[i][k] == INF) continue;              // 省掉一半加法,也防溢出
        for (int j = 1; j <= n; j++)
            if (f[i][k] + f[k][j] < f[i][j]) f[i][j] = f[i][k] + f[k][j];
    }

实测(1→2=2、2→3=2、2→4=1、1→3=5、3→4=3、1→4=4,从 1 出发):四种算法给出的距离都是 0 2 4 3,d(1,4)=3。

选错的代价:把负权图丢给 Dijkstra,它不会崩 —— 它给你一个看起来正常但错误的答案(0412 的实测:d4 给 3,真值 2)。这种错误最难发现。

8. 排序

算法平均最坏稳定什么时候自己写
冒泡O(n²)O(n²)是只在 n 很小、或教学演示
选择O(n²)O(n²)否交换次数要最少时
插入O(n²)O(n²)是数据几乎有序、或 n 很小
归并O(n log n)O(n log n)是要稳定 / 顺手数逆序对
快排O(n log n)O(n²)否平时不用,sort 就是它
std::sortO(n log n)O(n log n)否默认就该用它
sort(a.begin(), a.end());                        // 升序
sort(a.begin(), a.end(), greater<int>());        // 降序

// 多关键字:先按第一个降序,再按第二个升序
sort(a.begin(), a.end(), [](const P& x, const P& y) {
    if (x.first != y.first) return x.first > y.first;
    return x.second < y.second;
});

// 要稳定排序用 stable_sort(相等元素保持原来的相对顺序)

优先队列是「堆」不是「排序」:只保证队首是极值,其余元素内部无序。要全部有序还是得 sort。

9. 还没学的(预告)

10. 易错清单

  1. INF 相加溢出:INT_MAX + INT_MAX 变负数,比任何距离都小 → 用 0x3f3f3f3f 或加 >= INF 判断。
  2. 多测不清空:dis / vis / 邻接表每组重建,P3385 是典型。
  3. 忘记快读两行:ios::sync_with_stdio(false); cin.tie(nullptr); —— 实测 10 万点读入从 351.9 ms 降到 33.9 ms。
  4. 该 long long 用了 int:距离累加、乘积、坐标平方和(P3958 就栽在这)。
  5. BFS 标记写在出队时:同一个点会被重复入队,最坏变慢但不至于错;写在入队时最稳。
  6. Dijkstra 忘了懒删除:if (du > dis[u]) continue;,不然一堆过期快照照样往外松弛。
  7. Floyd 的 k 没放最外层:换位置能过样例,真出错(实测给出 10,真值 3)。
  8. 并查集没初始化:fa[i] = i,忘了就是全域乱连。
  9. 拓扑排序不看环:res.size() != n 说明有环,别把残缺顺序当答案输出。
  10. 重边只留一条:多测/多边题里,后写的边会覆盖前面的(P3371 那个邻接矩阵版本的坑)。

附:复杂度与 n 的对照

复杂度1 秒大约能处理典型场景
O(n)n ≈ 108扫一遍、前缀和
O(n log n)n ≈ 106 – 107sort、堆 Dijkstra
O(n²)n ≈ 5000朴素最短路、Floyd 以外的小 n
O(n³)n ≈ 300 – 500Floyd
O(2n)n ≤ 20 – 25状压、子集枚举

这张表是经验值,常数因子差很多:同样的 O(n log n),手写堆和 std::sort 能差几倍。看题目限制时留一倍余量。

本页代码都编译跑过:全部骨架放在同一个文件里用 g++ -O2 -std=c++17 -Wall -Wextra 编译(零警告),并跑出文中那几组实测输出(BFS 的 0 1 1 2、拓扑的 1 2 3 4、四种最短路一致的 0 2 4 3)。想补哪个算法、或者哪一段看不懂,直接问。
这张表怎么用:别当成要背的清单。做题时先问「这题在问什么」 —— 是连通性?是顺序?是最便宜的路?答案指向哪一行,再去翻对应课件。考前扫一眼第 1 节和第 10 节就够了。

对应课件:0408 图的遍历 · 0409 连通性与拓扑排序 · 0410 并查集 · 0411 最短路 I · 0412 最短路 II · STL 速查 · 刷题清单 · 返回资源列表