图论(未完工)
Part-1 扫盲
先进行一下图论的基础知识扫盲



Part-2 最短路
前置知识:DFS、BFS、栈与队列
最短路是图论中比较经典的一类问题,应用场景广泛,包括最优化DP、网络流等领域。
1.1 相关定义
先明确最短路问题的核心定义:
- 带权图:每条边带有权值的图称为带权图(赋权图)。所有边的权值非负的图称为非负权图;所有边的权值为正的图称为正权图。
- 边权:边的权值称为边权,记作 \(w_e\) 或 \(w_{u,v}\);带权边记作 \((u,v,w)\)。若边不带权,默认边权为 1。
- 路径长度:路径上每条边的权值之和称为路径长度。
- 负环:长度为负数的环称为负环。
- 最短路:在一张图上,称 \(s\) 到 \(t\) 的最短路为最短的连接 \(s\) 到 \(t\) 的路径。若不存在这样的路径(不连通或不可达),或最小值不存在(存在可经过的负环),则最短路不存在。
- 记 \(s\) 表示最短路起点,\(t\) 表示最短路终点。
1.2 单源最短路问题
问题描述:给定一个源点 \(s\),求 \(s\) 到图上任意点 \(u\) 的最短路长度 \(D_u\)。
设 \(dis_u\) 表示 \(s\) 到 \(u\) 的估计最短路长度,初始化 \(dis_s=0\) 且 \(dis_u=+\infty \ (u \neq s)\),算法结束时应当满足:
下面介绍几种常用的最短路求解算法:
1.2.1 Bellman-Ford
Bellman-Ford 是一种暴力求解最短路的算法。
核心操作是不断进行松弛操作:每一轮松弛遍历所有边 \((u,v)\),用 \(dis_u + w_{u,v}\) 去更新 \(dis_v\),重复该过程直到完成 \(n-1\) 轮(\(n\) 为图的节点数)。
Bellman-Ford 可有效判断负环:若完成 \(n-1\) 轮松弛后,第 \(n\) 轮松弛仍能更新某个节点的 \(dis\) 值,则说明图中存在负环。
算法时间复杂度为 \(O(nm)\)(\(m\) 为图的边数)。
1.2.2 Dijkstra
Dijkstra 算法适用于 非负权图。
称扩展结点 \(u\) 为:对 \(u\) 的所有邻边 \((u,v)\),用 \(dis_u + w_{u,v}\) 更新 \(dis_v\)。
算法核心逻辑:在满足 \(dis_u = D_u\) 的结点中,取出 \(dis\) 最小且未扩展过的点进行扩展。由于边权非负,取出结点的最短路长度单调不降。
核心证明:当前 \(dis\) 最小且未扩展过的点,其 \(dis_u\) 一定等于 \(D_u\)(最短路由长度更小的最短路更新而来,最小 \(dis\) 结点无法被其他结点更新)。
实现方式:用优先队列维护待扩展结点。扩展 \(u\) 时,若 \(dis_u + w_{u,v} < dis_v\),则将二元组 \((dis_u + w_{u,v}, v)\) 入队(第一元为距离,第二元为节点编号);取出结点时,以距离为关键字取最小值,若取出的距离与该节点当前的 \(dis\) 不相等,说明该节点已扩展过,直接跳过(避免复杂度退化)。
算法时间复杂度:
- 优先队列实现:\(O(m\log m)\)
- 稠密图(\(m\) 接近 \(n^2\)):用暴力找最小 \(dis\) 代替优先队列,时间复杂度 \(O(n^2)\)。
扩展:01 BFS
当边权仅为 0 或 1 时,可用双端队列代替优先队列:
- 扩展权值为 0 的边成功时,将节点压入队首;
- 扩展权值为 1 的边成功时,将节点压入队尾。
时间复杂度优化为 \(O(n+m)\)。
模板题代码(洛谷 P4779)
点击查看代码
#include <bits/stdc++.h>
using namespace std;
// 定义pair<int, int>为pii,简化代码
#define pii pair<int, int>
// 节点数量上限
const int N = 1e5 + 5;
int n, m, s; // n:节点数,m:边数,s:起点
int dis[N]; // dis[u]:起点s到u的最短路长度
vector<pii> e[N]; // 邻接表:e[u]存储(u, v, w),其中pair的first是v,second是w
int main() {
// 输入节点数、边数、起点
cin >> n >> m >> s;
// 读入所有边,构建邻接表
for (int i = 1; i <= m; i++) {
int u, v, w;
scanf("%d%d%d", &u, &v, &w);
e[u].push_back(make_pair(v, w));
}
// 初始化dis数组:全部设为无穷大(0x3f3f3f3f),起点s的dis设为0
memset(dis, 0x3f, sizeof(dis));
dis[s] = 0;
// 优先队列:小根堆,按pair的first(距离)升序排列
priority_queue<pii, vector<pii>, greater<pii>> q;
// 起点入队:(距离0, 节点s)
q.push(make_pair(0, s));
while (!q.empty()) {
// 取出当前距离最小的节点
auto t = q.top();
q.pop();
int id = t.second; // 节点编号
int dist = t.first; // 队列中存储的该节点的距离
// 若队列中的距离不等于当前dis[id],说明该节点已扩展过,跳过
if (dist != dis[id]) continue;
// 遍历该节点的所有邻边,进行松弛操作
for (auto edge : e[id]) {
int v = edge.first; // 邻接节点
int w = edge.second; // 边权
// 若通过当前节点到v的路径更短,则更新dis[v]并入队
if (dis[id] + w < dis[v]) {
dis[v] = dis[id] + w;
q.push(make_pair(dis[v], v));
}
}
}
// 输出起点到所有节点的最短路长度
for (int i = 1; i <= n; i++) {
cout << dis[i] << " ";
}
return 0;
}
1.2.3 SPFA 算法(队列优化的 Bellman-Ford)与负环检测
关于SPFA,它______。
SPFA(Shortest Path Faster Algorithm)本质是 Bellman-Ford 算法的队列优化版本,核心思想是“只处理有价值的节点”,大幅减少无效的松弛操作,是检测负环的核心算法。
一、SPFA 核心思路(形象化理解)
Bellman-Ford 算法的痛点:每一轮都无脑遍历所有边,哪怕这些边对应的节点根本没有机会更新最短路(比如节点的 dis 值还是无穷大)。
SPFA 的优化逻辑(分步骤拆解):
- 队列筛选:用队列只维护“有机会更新邻节点最短路”的节点(即刚被松弛过的节点);
- 去重标记:给每个节点加
vis数组(入队标记),避免同一个节点重复入队(比如节点已在队列中,就不再压入,减少冗余); - 按需松弛:只有节点被松弛(
dis值更新)且不在队列中时,才将其压入队列。
👉 通俗比喻:
Bellman-Ford 像老师“逐个人检查全班所有学生的所有作业”;
SPFA 像老师“只把‘有错题需要订正’的学生叫到办公室,订正完后再检查这些学生的同桌是否有连带错题”,效率大幅提升。
二、SPFA 时间复杂度与使用建议
| 维度 | 说明 |
|---|---|
| 理论复杂度 | 与 Bellman-Ford 一致,仍为 \(O(nm)\)(最坏情况会退化成 Bellman-Ford) |
| 实际效率 | 普通图上效率极高,但会被“特殊构造的卡常数据”逼到最坏复杂度 |
| 使用场景 | 1. 处理含负权边的最短路问题;2. 检测图中的负环(核心场景);3. 非负权图优先用 Dijkstra(更稳定) |
三、SPFA 关键注意点
若用 SPFA 求解“点对点最短路”(如费用流 EK 算法),队头是目标节点时不能直接结束算法!
原因:节点入队仅代表它的 dis 值被更新过,但不代表已取到最终最短路(后续可能有负权边进一步更新)。
四、SPFA 检测负环(核心应用)
负环的本质:绕环走一圈,路径总长度会变小(最短路可无限缩短)。
检测原理:一个节点的最短路径最多经过 \(n-1\) 条边(不重复经过节点),若经过超过 \(n-1\) 条边,说明绕了负环。
两种检测方法对比(个人推荐第二种):
| 检测方法 | 核心逻辑 | 效率 |
|---|---|---|
| 统计节点入队次数 | 若节点入队次数 ≥ n,说明存在负环 | 较慢(冗余) |
| 统计最短路边数 | 记录 \(len_i\) 表示“起点到 \(i\) 的最短路边数”,若 \(len_v = n\),说明存在负环 | 更快(直接检测核心条件) |
注:某不愿意透露姓名的NOI出题人曾表示:带负环的题目业界公约是建议用SPFA,但是不会卡你的Bellman-Ford(毕竟一些情况SPFA会退化),but...
通俗解释:
假设有 5 个节点,从起点到任意节点的最短路径最多走 4 条边(不绕圈)。如果某节点的最短路径走了 5 条边,说明路径里一定有环,且这个环是负环(否则绕环只会让路径变长,不会被选为最短路)。
五、SPFA检测负环 代码
点击查看代码
#include <bits/stdc++.h>
using namespace std;
const int N = 2e3 + 5; // 节点数量上限
int n, m; // n:节点数,m:边数
int dis[N]; // dis[i]:起点到i的最短路估计值
int len[N]; // len[i]:起点到i的最短路径经过的边数
int vis[N]; // vis[i]:标记i是否在队列中(避免重复入队)
vector<pair<int, int>> e[N]; // 邻接表:e[u]存储(邻节点v, 边权w)
void solve() {
// 多组测试用例,每次清空邻接表(避免残留数据)
cin >> n >> m;
for (int i = 1; i <= n; i++) e[i].clear();
// 读入所有边,构建邻接表
for (int i = 1; i <= m; i++) {
int u, v, w;
cin >> u >> v >> w;
e[u].push_back(make_pair(v, w));
// 题目特殊条件:若边权非负,添加双向边(根据模板题场景)
if (w >= 0) e[v].push_back({u, w});
}
// 初始化:dis设为无穷大(0x3f3f3f3f),vis设为0(不在队列)
memset(dis, 0x3f, sizeof(dis));
memset(vis, 0, sizeof(vis));
// 起点为1:自身到自身距离为0,最短路边数为0,压入队列
queue<int> q;
q.push(1);
dis[1] = 0;
len[1] = 0;
vis[1] = 1; // 标记起点在队列中
while (!q.empty()) {
int t = q.front(); // 取出队头节点
q.pop();
vis[t] = 0; // 标记为不在队列(后续可重新入队)
// 遍历t的所有邻边,尝试松弛操作
for (auto it : e[t]) {
int to = it.first; // 邻节点v
int w = it.second; // 边(t, v)的权值
int new_dis = dis[t] + w; // 经过t到to的新距离
// 松弛条件:新距离比当前dis[to]更小
if (new_dis < dis[to]) {
dis[to] = new_dis; // 更新最短路估计值
len[to] = len[t] + 1; // 最短路边数+1(多走了一条边)
// 关键:边数等于n → 存在负环(最多n-1条边)
if (len[to] == n) {
cout << "YES" << endl;
return; // 找到负环,直接返回(无需继续遍历)
}
// 若to不在队列中,压入队列并标记
if (!vis[to]) {
vis[to] = 1;
q.push(to);
}
}
}
}
// 遍历完所有节点,未找到负环
cout << "NO" << endl;
}
int main() {
int T; // 测试用例数
cin >> T;
while (T--) solve(); // 处理每组测试用例
return 0;
}
1.2.4 三角形不等式(最短路的核心性质)
在单源最短路径问题中,对于图中任意一条边 \((u, v) \in E\),满足:
该不等式被称为三角形不等式。
-
符号说明:
- \(D_u\):起点到节点 \(u\) 的真实最短路长度;
- \(D_v\):起点到节点 \(v\) 的真实最短路长度;
- \(w_{u,v}\):边 \((u, v)\) 的权值。
-
核心含义:
“起点→\(u\)→\(v\)” 的路径长度,一定不会比 “起点→\(v\) 的最短路” 更短(否则就违背了“最短路”的定义)。 -
延伸价值:
三角形不等式是单源最短路的本质性质;而差分约束问题,正是这个性质的“逆应用”——已知若干类似三角形不等式的约束,求满足条件的变量解。
1.3 差分约束问题(三角形不等式的逆应用)
问题描述
给定若干形如 \(x_a - x_b \le c\) 或 \(x_a - x_b \ge c\) 的不等式约束,求任意一组满足所有约束的变量解 \(\{x_i\}\)(若存在)。
1.3.1 算法核心思路(从不等式到图的转化)
一、不等式标准化(统一形式)
所有差分约束的不等式,都能转化为 “\(x_j \le x_i + c\)”(或等价的 \(x_i + c \ge x_j\)),这与最短路的三角形不等式 \(D_v \le D_u + w_{u,v}\) 完全一致!
常见约束的转化规则(关键):
| 原始约束 | 转化后形式 | 对应建边方式 | 核心逻辑 |
|---|---|---|---|
| \(x_a - x_b \le c\) | \(x_a \le x_b + c\) | 从 \(b\) 向 \(a\) 连边,权值为 \(c\) | 对应 \(D_a \le D_b + w_{b,a}\) |
| \(x_a - x_b \ge c\) | \(x_b \le x_a - c\) | 从 \(a\) 向 \(b\) 连边,权值为 \(-c\) | 先变形再套用上述规则 |
| \(x_a = x_b\) | \(\begin{cases}x_a - x_b \le 0 \\ x_a - x_b \ge 0\end{cases}\) | 双向边:\(b→a\)(0)、\(a→b\)(0) | 两个约束同时满足即相等 |
👉 通俗比喻:
差分约束的每个不等式,就是给图加了一条“规则边”;求解变量解的过程,就是在这张图上求最短路的过程——最短路的结果 \(\text{dis}[i]\) 就是变量 \(x_i\) 的一组合法解。
二、建图的关键补充(解决图不连通问题)
如果直接按约束建边,可能出现“图不连通”(部分节点没有入边/出边),导致无法遍历所有约束。解决方法有两种(二选一):
- 超级源点法:新增一个“超级源点 0”,从 0 向所有节点连一条权值为 0 的边(保证所有节点能被遍历);
- 全节点入队法:初始化时将所有节点的 \(\text{dis}\) 设为 0,并把所有节点压入 SPFA 队列(无需超级源点,更简洁)。
三、求解与无解判断
- 求解算法:由于约束中的 \(c\) 可能为负数(边权可能为负),必须用支持负权边的 Bellman-Ford 或 SPFA 算法;
- 无解条件:若图中存在负环,则说明约束矛盾,无解!
- 原因:负环意味着“绕环一圈,路径长度无限减小”,对应不等式要求“一个数加负数后仍不小于自身”,这是不可能的(比如 \(x_1 \le x_2 + 1\)、\(x_2 \le x_1 - 2\),联立得 \(x_1 \le x_1 -1\),矛盾)。
四、时间复杂度
与 Bellman-Ford/SPFA 一致,为 \(O(nm)\)(\(n\) 为变量数/节点数,\(m\) 为约束数/边数)。
1.3.2 差分约束模板题代码(洛谷 P5960,带详细注释)
点击查看代码
#include <bits/stdc++.h>
using namespace std;
const int N = 5e3 + 5; // 节点(变量)数量上限
int n, m; // n:变量数,m:约束数
int vis[N]; // vis[i]:标记节点i是否在SPFA队列中(避免重复入队)
int dis[N]; // dis[i]:最终解(对应变量x_i的取值)
int len[N]; // len[i]:起点到i的最短路边数(用于检测负环)
vector<pair<int, int>> e[N]; // 邻接表:e[u]存储(邻节点v, 边权w)
int main() {
// 输入变量数n、约束数m
cin >> n >> m;
// 读入所有约束,转化为边并构建邻接表
for (int i = 1; i <= m; i++) {
int u, v, w;
cin >> u >> v >> w;
// 模板题约束为:x_u - x_v ≤ w → 转化为 x_u ≤ x_v + w
// 对应建边:v → u,权值为w(匹配三角形不等式 D_u ≤ D_v + w_{v,u})
e[v].push_back(make_pair(u, w));
}
// SPFA初始化:全节点入队法(解决图不连通问题)
queue<int> q;
for (int i = 1; i <= n; i++) {
q.push(i); // 所有节点初始入队
vis[i] = 1; // 标记在队列中
// dis默认初始化为0(等价于x_i ≥ 0的隐含约束,不影响最终解的合法性)
}
// SPFA核心逻辑:遍历队列,松弛+负环检测
while (!q.empty()) {
int t = q.front(); // 取出队头节点
q.pop();
vis[t] = 0; // 标记为不在队列(后续可重新入队)
// 遍历t的所有邻边,尝试松弛操作
for (auto it : e[t]) {
int to = it.first; // 邻节点(对应变量x_to)
int w = it.second; // 边权(对应约束中的常数c)
int new_dis = dis[t] + w; // 松弛后的新距离(x_to的候选值)
// 松弛条件:新值更小,更新dis[to]
if (new_dis < dis[to]) {
dis[to] = new_dis; // 更新解(x_to = dis[to])
len[to] = len[t] + 1; // 最短路边数+1
// 检测负环:边数≥n → 约束矛盾,无解
if (len[to] == n) {
cout << "NO" << endl;
exit(0); // 直接退出程序
}
// 若节点to不在队列中,压入队列并标记
if (!vis[to]) {
q.push(to);
vis[to] = 1;
}
}
}
}
// 输出一组合法解(dis[i]即为x_i的取值)
for (int i = 1; i <= n; i++) {
cout << dis[i] << " ";
}
return 0;
}

浙公网安备 33010602011771号