1. 从“模板”说起:为什么竞赛选手需要图论模板?
如果你参加过蓝桥杯这类算法竞赛,或者正在备赛,一定对“模板”这个词不陌生。它不是什么可以一键通关的作弊代码,而是一个经过千锤百炼、封装了核心逻辑、边界清晰、可以直接套用的代码框架。尤其是在图论这个领域,题目千变万化,但底层算法就那么几个。Dijkstra求最短路,Floyd处理多源最短路,Prim或Kruskal构建最小生成树……这些算法的思想是固定的,但如果在考场上现场推导、调试,时间根本不够用。
因此,一个可靠的个人模板库,就是你竞赛中的“武器库”。它意味着:第一,你对算法原理有深刻理解,才能写出正确且高效的模板;第二,你经过了大量练习,知道模板在哪些细节上容易出错(比如邻接表的初始化、优先队列的比较函数、无穷大的取值);第三,你能根据题目要求,快速对模板进行微调适配,而不是从头开始。
今天,我就结合自己多年备赛和带学生的经验,拆解一下图论中最核心的几个算法模板——Dijkstra、Floyd、Prim。我们不只讲代码怎么写,更要讲清楚为什么这么写,以及在实战中会遇到哪些坑。目标是让你拥有一套拿起来就能用、用起来不出错的“蓝桥杯国赛级”图论模板。
2. Dijkstra算法模板:单源最短路的基石与实战变形
Dijkstra算法是解决边权非负的图中单源最短路径问题的绝对主力。它的核心思想是贪心:每次从未确定最短路径的顶点中,选取一个距离源点最近的顶点,然后松弛其邻接点。
2.1 标准邻接表版模板(优先队列优化)
这是最常用、效率最高的版本,时间复杂度为 O((V+E)logV),其中V是顶点数,E是边数。
#include <bits/stdc++.h> using namespace std; typedef pair<int, int> PII; // first: 距离, second: 顶点编号 const int MAXN = 100010; // 根据题目最大顶点数调整 const int INF = 0x3f3f3f3f; // 一个很大的数,表示无穷大 vector<PII> graph[MAXN]; // 邻接表,graph[u] = {v, w} int dist[MAXN]; // 从源点到每个点的最短距离 bool visited[MAXN]; // 标记是否已确定最短路径 void dijkstra(int start) { // 初始化 memset(dist, 0x3f, sizeof(dist)); memset(visited, false, sizeof(visited)); dist[start] = 0; // 小顶堆,按距离从小到大排序 priority_queue<PII, vector<PII>, greater<PII>> pq; pq.push({0, start}); while (!pq.empty()) { // 取出当前距离源点最近的点 auto [d, u] = pq.top(); pq.pop(); // 重要优化:如果这个点之前已经通过更短的路径处理过,则跳过 // 因为优先队列里可能存了同一个点的多个不同距离 if (visited[u]) continue; visited[u] = true; // 标记为已处理 // 松弛操作:遍历u的所有邻接点 for (auto &[v, w] : graph[u]) { if (dist[v] > dist[u] + w) { dist[v] = dist[u] + w; // 将新的距离入队,注意这里允许同一个点多次入队 pq.push({dist[v], v}); } } } }核心细节与避坑指南:
visited数组的必要性:很多人会问,有了dist数组判断,为什么还需要visited?这是因为优先队列中可能存储了同一个节点旧的、更大的距离值。当这个旧值被弹出时,其对应的dist[v]可能已经被更新得更小了。此时用visited标记可以避免用旧值进行无效的松弛操作,这是一个关键的性能优化和正确性保证。- 无穷大
INF的选择:0x3f3f3f3f是一个魔法数字,其值约为10^9。选择它有两个好处:一是两个INF相加不会溢出int范围;二是memset用0x3f填充时,每个字节都是0x3f,整个int恰好就是0x3f3f3f3f。绝对不要用INT_MAX,因为dist[u] + w可能导致溢出变成负数。 - 邻接表的存储:使用
vector<pair<int, int>>比vector<vector<int>>更节省空间,也清晰。pair的第一个元素是目标顶点,第二个是边权。 - 优先队列的比较:
priority_queue默认是大顶堆,我们需要小顶堆,所以使用greater<PII>作为比较函数。也可以自定义结构体,重载<运算符。
2.2 常见变形与考点
蓝桥杯不会只考裸的Dijkstra,常见变形有:
- 求最短路径条数:增加一个
cnt[MAXN]数组,cnt[start]=1。在松弛时,如果dist[v] > dist[u] + w,则cnt[v] = cnt[u];如果dist[v] == dist[u] + w,则cnt[v] += cnt[u]。 - 记录最短路径:增加一个
pre[MAXN]数组,在松弛成功时,记录pre[v] = u。最后从终点递归或迭代回溯即可得到路径。 - 边权有零:Dijkstra算法本身允许边权为0,算法依然正确。
- 多源单目标:如果要求多个起点到一个终点的最短距离,可以反向建图,然后从终点跑一次Dijkstra。
- 第K短路:这是Dijkstra的进阶应用,通常使用A*算法,模板会更复杂。
注意:Dijkstra算法不能处理负权边。因为其贪心策略基于“当前最短路径即全局最短路径”的假设,负权边会破坏这个假设。如果图中存在负权边,需要使用SPFA或Bellman-Ford算法。
3. Floyd算法模板:全源最短路与传递闭包
Floyd算法是经典的动态规划算法,用于求解图中所有顶点对之间的最短路径。它的思想极其简洁:对于每一对顶点(i, j),考虑是否存在一个中间顶点k,使得从i到j经过k的路径比已知路径更短。
3.1 标准模板与初始化
#include <bits/stdc++.h> using namespace std; const int MAXN = 505; // Floyd一般用于顶点数较少的图(N<=500) const int INF = 0x3f3f3f3f; int dist[MAXN][MAXN]; // dist[i][j] 表示i到j的最短距离 int n; // 顶点数 void floyd() { // 三重循环,k一定要放在最外层! for (int k = 1; k <= n; ++k) { for (int i = 1; i <= n; ++i) { for (int j = 1; j <= n; ++j) { // 防止溢出,先判断INF if (dist[i][k] != INF && dist[k][j] != INF) { dist[i][j] = min(dist[i][j], dist[i][k] + dist[k][j]); } } } } } // 初始化示例 void init() { // 1. 自己到自己的距离为0 for (int i = 1; i <= n; ++i) { for (int j = 1; j <= n; ++j) { if (i == j) dist[i][j] = 0; else dist[i][j] = INF; } } // 2. 读入边 // int u, v, w; // cin >> u >> v >> w; // dist[u][v] = min(dist[u][v], w); // 注意处理重边,取最小值 // 如果是无向图,还需要 dist[v][u] = w; }为什么k必须放在最外层?
这是Floyd算法最核心的理解点。动态规划的状态定义是:dist[k][i][j]表示“只允许使用前k个顶点作为中间点,从i到j的最短路径长度”。我们压缩了第一维,用二维数组dist[i][j]在本地更新。k是阶段,必须放在最外层,这样才能保证在计算dist[i][j]时,所有经过顶点1...k-1的路径都已经被考虑过。如果k放在内层,逻辑就完全错了。
3.2 算法特性与实战应用
- 时间复杂度:O(V³),因此通常只用于顶点数较少(V ≤ 500)的稠密图。
- 空间复杂度:O(V²),需要存储整个邻接矩阵。
- 负权边处理:Floyd可以处理带负权边的图,但不能处理负权环。如果存在负权环,则图中存在顶点到自身的最短距离为负数(
dist[i][i] < 0),这可以用来检测负环。 - 传递闭包:Floyd的思想可以推广到任何具有传递性的关系上。例如,判断图的连通性(有向图的可达性)。我们定义
reach[i][j]为true表示i可达j。那么核心代码变为:
这在解决一些逻辑推理、状态可达性问题时非常有用。for (int k = 1; k <= n; ++k) for (int i = 1; i <= n; ++i) for (int j = 1; j <= n; ++j) reach[i][j] = reach[i][j] || (reach[i][k] && reach[k][j]);
实战踩坑点:
- 重边处理:初始化读入边时,一定要用
min(dist[u][v], w),因为题目可能给出多条u到v的边,我们需要保留最短的那条。 - 无穷大判断:在更新
dist[i][j]时,必须先判断dist[i][k]和dist[k][j]是否为INF,否则INF + w可能导致溢出变成负数,从而错误地更新dist[i][j]。 - 顶点编号:题目顶点编号可能从0开始,也可能从1开始。模板中通常从1开始,如果从0开始,循环范围要相应调整。
4. Prim算法模板:最小生成树的贪心构造
Prim算法用于在加权无向连通图中求最小生成树(MST)。其思想与Dijkstra非常相似:从任意一个顶点开始,每次将距离当前生成树集合最近的顶点加入集合,并更新其他顶点到集合的距离。
4.1 标准模板(邻接矩阵版)
邻接矩阵版实现简单,适合稠密图(边数接近顶点数平方)。
#include <bits/stdc++.h> using namespace std; const int MAXN = 1005; const int INF = 0x3f3f3f3f; int n; // 顶点数 int g[MAXN][MAXN]; // 邻接矩阵,g[i][j]表示边权,INF表示无边 int distToTree[MAXN]; // 每个点到当前生成树集合的最短距离 bool inMST[MAXN]; // 标记顶点是否已在生成树中 int prim() { // 初始化 memset(distToTree, 0x3f, sizeof(distToTree)); memset(inMST, false, sizeof(inMST)); // 从顶点1开始构建MST distToTree[1] = 0; int totalWeight = 0; // 最小生成树的总权值 // 循环n次,每次加入一个顶点 for (int i = 0; i < n; ++i) { // 1. 寻找距离当前生成树最近的、还未加入的顶点 int u = -1; for (int v = 1; v <= n; ++v) { if (!inMST[v] && (u == -1 || distToTree[v] < distToTree[u])) { u = v; } } // 如果找不到,说明图不连通(对于非连通图,这里需要处理) if (distToTree[u] == INF) { return INF; // 返回INF表示无法构成生成树 } // 2. 将该顶点加入生成树 inMST[u] = true; totalWeight += distToTree[u]; // 3. 用新加入的顶点更新其他顶点到生成树集合的距离 for (int v = 1; v <= n; ++v) { // 只更新不在树中,且通过u可以更近到达树的点 // 注意:这里是和g[u][v]比较,不是和distToTree[u]相加! if (!inMST[v] && g[u][v] < distToTree[v]) { distToTree[v] = g[u][v]; } } } return totalWeight; } // 初始化示例 void init() { memset(g, 0x3f, sizeof(g)); // 读入边 // for (int i = 0; i < m; ++i) { // int u, v, w; // cin >> u >> v >> w; // g[u][v] = g[v][u] = min(g[u][v], w); // 无向图,处理重边 // } }4.2 优先队列优化版(邻接表版)
对于稀疏图,使用邻接表和优先队列可以将时间复杂度从O(V²)优化到O(E log V),类似Dijkstra。
#include <bits/stdc++.h> using namespace std; typedef pair<int, int> PII; // first: 到树的距离, second: 顶点编号 const int MAXN = 100010; const int INF = 0x3f3f3f3f; vector<PII> graph[MAXN]; int distToTree[MAXN]; bool inMST[MAXN]; int prim() { memset(distToTree, 0x3f, sizeof(distToTree)); memset(inMST, false, sizeof(inMST)); distToTree[1] = 0; int totalWeight = 0; int nodeCount = 0; // 记录已加入生成树的节点数 priority_queue<PII, vector<PII>, greater<PII>> pq; pq.push({0, 1}); while (!pq.empty() && nodeCount < n) { auto [d, u] = pq.top(); pq.pop(); if (inMST[u]) continue; inMST[u] = true; totalWeight += d; nodeCount++; for (auto &[v, w] : graph[u]) { // Prim的核心:更新的是顶点v到“整个生成树集合”的距离 // 这个距离就是v与树中某点连边的**最小权值** // 所以这里比较的是 w 和 distToTree[v] if (!inMST[v] && w < distToTree[v]) { distToTree[v] = w; pq.push({distToTree[v], v}); } } } // 如果最终nodeCount < n,说明图不连通 return nodeCount == n ? totalWeight : INF; }Prim vs Dijkstra:一个关键区别
这是最容易混淆的地方。两者代码结构很像,都用了贪心和优先队列,但更新的逻辑不同:
- Dijkstra更新的是从源点到点v的路径总权值:
dist[v] = min(dist[v], dist[u] + w)。 - Prim更新的是点v到当前生成树集合的最小边权:
distToTree[v] = min(distToTree[v], w)。
在Prim的优先队列优化版中,入队的是{w, v},这个w是边(u,v)的权值,而不是累加和。理解这一点,就不会把两个算法写串了。
4.3 实战注意事项与Kruskal的抉择
- 图不连通:Prim算法默认从1号点开始,如果图不连通,算法只能生成1号点所在连通分量的最小生成树。模板中通过判断
nodeCount是否等于n或distToTree[u]是否为INF来处理。更通用的做法是,在发现无法选取新顶点时,尝试从下一个未访问的顶点开始新的Prim,计算多个连通分量的MST。 - 重边与自环:初始化时要用
min处理重边。自环(自己到自己的边)通常对MST无意义,可以忽略。 - Prim vs Kruskal:
- Prim:适合稠密图,尤其是用邻接矩阵实现的朴素版。思想是“加点法”。
- Kruskal:适合稀疏图。思想是“加边法”,需要对所有边按权值排序,然后用并查集判断是否成环。代码通常比Prim更简短。
- 选择:在蓝桥杯比赛中,如果顶点数少(N≤500),用邻接矩阵的Prim很直观。如果边数远小于顶点数平方,用Kruskal或Prim的优先队列版更优。建议两个模板都掌握。
5. 模板的调试、验证与内存管理
有了模板,不代表高枕无忧。在竞赛中,如何快速验证模板的正确性,以及避免低级错误,同样重要。
5.1 设计测试用例
针对每个模板,准备几个经典的测试用例,包括:
- 基本功能测试:简单的小图,手动能算出结果。
- 边界测试:
- 单个顶点。
- 两个顶点,一条边或多条重边。
- 完全图(边数最多)。
- 特殊数据测试:
- 边权相等。
- 边权非常大(检验
INF设置是否合理)。 - 图不连通(对Prim和遍历算法)。
- 负权边测试:用Dijkstra测负权边(应该出错),用Floyd测负权边(应该能运行,检查结果)。
5.2 常见错误排查清单
当程序结果不对时,按以下顺序检查:
- 初始化:
dist、visited、graph数组是否正确初始化?INF值是否足够大且安全? - 输入处理:顶点编号是从0还是1开始?是无向图还是有向图?有没有处理重边(取
min)? - 数组大小:
MAXN是否足够大?通常开到题目最大范围+5或题目最大范围*2(对于链式前向星)。 - 算法逻辑:
- Dijkstra:优先队列弹出的点是否判断了
visited?松弛条件是否正确? - Floyd:
k循环是否在最外层?三重循环的起点和终点是否正确(通常是1到n)? - Prim:更新
distToTree时,是比较边权w,还是distToTree[u] + w?(这是和Dijkstra的核心区别)。
- Dijkstra:优先队列弹出的点是否判断了
- 输出:如果结果是
INF,输出的是什么?题目要求输出-1还是特定值?
5.3 内存与性能优化
对于大型图(蓝桥杯国赛有时会卡这个):
- 使用链式前向星:这是空间效率最高的存图方式,特别适合边数巨大的稀疏图。虽然写起来比
vector邻接表稍复杂,但能节省大量空间,访问也更快。建议掌握其模板。struct Edge { int to, w, next; } edges[MAXM]; // MAXM 是最大边数 int head[MAXN], cnt; void addEdge(int u, int v, int w) { edges[++cnt].to = v; edges[cnt].w = w; edges[cnt].next = head[u]; head[u] = cnt; } // 遍历u的邻接点:for (int i = head[u]; i; i = edges[i].next) - 关闭流同步:在C++中,使用
cin/cout时,在main函数开头加上ios::sync_with_stdio(false); cin.tie(0); cout.tie(0);可以大幅提升输入输出效率。 - 使用全局数组:避免在函数内定义大数组,可能造成栈溢出。所有大数组都定义为全局变量。
- 谨慎使用
endl:endl会刷新缓冲区,非常慢。输出换行时用'\n'。
6. 从模板到解题:以一道真题为例
我们以一道经典的、融合了图论思想的题目(类似“高僧斗法”)来演示如何运用模板思维。题目抽象后本质是:在一个一维棋盘上,棋子移动规则固定,求从初始状态到目标状态的最少步数。
解题思路转化:
- 状态抽象:将棋盘的每一种布局定义为一个“图”的“顶点”。
- 边权定义:如果通过一次合法移动,能从布局A变成布局B,那么在顶点A和B之间连一条边权为1的边。
- 问题转化:求从“初始状态顶点”到“目标状态顶点”的最短路径长度。这变成了一个边权为1的最短路问题。
算法选择:
- 因为边权为1,可以使用BFS。BFS在无权图中本身就是求最短路的高效算法。
- 状态数量(顶点数)可能很多,需要设计高效的状态表示(如哈希)和判重。
模板化思维:
- 这里的“图”是隐式的,我们用BFS来遍历。
- BFS也可以有模板:队列管理、距离数组
dist、访问标记visited、状态转移函数。 - 核心代码框架和Dijkstra的思想一脉相承:都是不断从“前沿”取出一个状态,扩展其邻居,更新距离。
代码框架示意:
// 状态表示,例如用字符串或整数编码 typedef string State; queue<State> q; unordered_map<State, int> dist; // 记录到每个状态的距离 dist[startState] = 0; q.push(startState); while (!q.empty()) { State cur = q.front(); q.pop(); if (cur == targetState) break; vector<State> nextStates = generateNext(cur); // 状态转移函数 for (State &next : nextStates) { if (!dist.count(next)) { // 未访问过 dist[next] = dist[cur] + 1; q.push(next); } } } // 结果在 dist[targetState] 中,若不存在则为默认值0通过这个例子可以看到,所谓“图论模板”,不仅仅是那几个经典算法。更重要的是一种建模能力:将实际问题抽象为点、边、权值,然后选择合适的算法模板(BFS、Dijkstra、Floyd、Prim)来解决。平时多练习这种转化,比赛时才能快速破题。
最后,模板是死的,人是活的。我建议你在理解透彻的基础上,亲手将这几个模板敲上几十遍,并用自己的测试数据去验证。过程中,你会自然记住那些易错点。到了赛场上,你才能像条件反射一样,快速、准确地写出核心代码,把宝贵的时间留给更难的建模和优化问题。记住,最可靠的模板,是刻在你脑子里的、经过自己大量实战检验的那一套。