常见建图技巧
负环¶
- 在图中存在一条环路,其所有边权之和小于 $0$。
- 记为:若环路顶点序列为 $v_1 \to v_2 \to \cdots \to v_k \to v_1$,则
若存在负权环,则可以无限次沿该环 跑 下去,总路径长度不断减小,无法收敛到一个最小值。
Bellman-Ford 检测负环¶
- Bellman–Ford 最短路算法过程中,最多需要 $n-1$ 轮松弛即可收敛。
- 若进行第 $n$ 轮松弛时仍有边被松弛,则说明图中存在从源点可达的负环。
注意: 检测到的是 从起点 s 出发可达的负环,可能无法检测到其他连通分量的负环。
时间复杂度:$O(nm)$,空间复杂度:$O(n+m)$。
void bellman_ford(int s)
{
for (int i = 1; i <= n; i++) dis[i] = 1e9;
dis[s] = 0;
for (int t = 1; t < n; t++) // 没有负边权,松弛 n - 1 次即可
{
for (int i = 1; i <= m; i++)
{
int u = e[i].u, v = e[i].v, w = e[i].w;
if (dis[u] == 1e9) continue; // 避免假松弛
if (dis[v] > dis[u] + w) // 判断是否会进行松弛
{
dis[v] = dis[u] + w;
}
}
}
for (int i = 1; i <= m; i++)
{
int u = e[i].u, v = e[i].v, w = e[i].w;
if (dis[u] == 1e9) continue; // 避免假松弛
if (dis[v] > dis[u] + w)
{
cout << "存在一个从起点 s 出发的负环";
}
}
}
举例说明假松弛
如图所示,显然不存在从 $1$ 出发可达的负环。但我们初始化数组时会初始化 $dis[3]=dis[4]=10^9$。
导致 $dis[4]>dis[3]+(-10)$ 判断成立,这实际是一个 假松弛。进而判定负环存在这是不对的。
SPFA 检测负环¶
SPFA 思想
用队列维护 待松弛 节点,每次取队头 $u$,尝试松弛所有出边 $(u\to v,w)$。
- 若 $dis[u]+w<dis[v]$,则更新 $dis[v]$,并将 $v$ 入队(若尚未在队中)。
负环检测
- 维护数组 $\mathrm{cnt}[v]$,表示当前最短路到 $v$ 所用边数;
- 初始 $\mathrm{cnt}[S]=0$,其余也都设为 0。
- 每次成功松弛 $(u\to v)$ 时:
- 若 $\mathrm{cnt}[v]\ge n$(点数),则说明存在一条可从 $S$ 达到的负环
#include <bits/stdc++.h>
using namespace std;
int n, m, dis[2005], cnt[2005];
bool vis[2005];
vector<pair<int, int>> e[2005];
int spfa(int s)
{
for (int i = 1; i <= n; i++)
{
dis[i] = 1e9;
vis[i] = 0;
cnt[i] = 0;
}
dis[s] = 0, vis[s] = 1, cnt[s] = 0;
queue<int> q;
q.push(s);
while (q.size())
{
int u = q.front();
q.pop();
vis[u] = 0;
for (auto it : e[u])
{
int v = it.first, w = it.second;
if (dis[v] > dis[u] + w)
{
dis[v] = dis[u] + w;
cnt[v] = cnt[u] + 1;
if (cnt[v] >= n) return -1;
if (!vis[v])
{
vis[v] = 1;
q.push(v);
}
}
}
}
return 1;
}
int main()
{
int t;
cin >> t;
while (t--)
{
cin >> n >> m;
for (int i = 1; i <= n; i++) e[i].clear();
while (m--)
{
int u, v, w;
cin >> u >> v >> w;
if (w >= 0)
{
e[u].push_back({v, w});
e[v].push_back({u, w});
}
else
{
e[u].push_back({v, w});
}
}
int res = spfa(1);
if (res == -1) cout << "YES\n";
else cout << "NO\n";
}
return 0;
}
例题:[USACO09NOV] Job Hunt S¶
题意: 牛在每个城市最多赚 $D$,需移动才能继续挣钱。路径无成本,航线需花费 $T$,可用未来收入支付。 问:无限时间内最大可赚多少?若无限,输出 $-1$。
思路: 本题属于既有点权又有边权,首先需要统一为边权。
- 普通道路:到达城市可赚 $D$,等价边权 $+D$.
- 航线:到达城市赚 $D$ 花费 $T$,等价边权 $D - T$.
于是问题化为:在有向图上求 最长路径,存在 正权环 则可以无限挣钱。
最长路松弛
最多 $C-1$ 轮可收敛。 第 $C$ 轮若仍发生松弛,则存在正权环 $\to$ 输出 $-1$.
注意本题起点的值直接初始化为 $D$,直接挣到 $D$ 美元。
#include <bits/stdc++.h>
using namespace std;
int d, p, c, f, s, dis[225];
struct edge
{
int u, v, w;
};
vector<edge> e;
int bellman_ford(int s)
{
for (int i = 1; i <= c; i++) dis[i] = -1e9;
dis[s] = d; // 在起点直接赚钱
for (int i = 1; i < c; i++)
{
for (auto it : e)
{
int u = it.u, v = it.v, w = it.w;
if (dis[u] == -1e9) continue;
if (dis[v] < dis[u] + w)
{
dis[v] = dis[u] + w;
}
}
}
for (auto it : e)
{
int u = it.u, v = it.v, w = it.w;
if (dis[u] == -1e9) continue;
if (dis[v] < dis[u] + w)
{
return -1;
}
}
int mx = -1e9;
for (int i = 1; i <= c; i++) mx = max(mx, dis[i]);
return mx;
}
int main()
{
cin >> d >> p >> c >> f >> s;
for (int i = 1; i <= p; i++)
{
int u, v;
cin >> u >> v;
e.push_back({u, v, d}); // 点权转化为边权,走过去就赚取 d 美元
}
for (int i = 1; i <= f; i++)
{
int u, v, w;
cin >> u >> v >> w;
e.push_back({u, v, d - w}); // 航线扣 w 美元,因此边权为 d - w
}
cout << bellman_ford(s);
return 0;
}
总结¶
- Bellman–Ford/SPFA:可支持任意正负边,最短路和最长路均可。
- Floyd:可支持任意正负边,最短路和最长路均可,但时间复杂度是 $O(n^3)$。
- 只适用于 全正权最短路 或 全负权最长路,不支持混合情形。
Dijkstra 不适于正权最长路
- 最长路无 贪心子结构,每次选最优并不安全。
可以参考如下反例:
若用 Dijkstra 思想(每次取当前最大的 dis),错误地得到:
实际最长路径是
建图技巧¶
反图¶
给定有向图 $G=(V,E)$,其反图 $G'=(V,E')$ 中,每条 $u\to v\in E$ 在 $G'$ 中变为 $v\to u\in E'$。
当我们需要多次求 $\,i\to x$ 的最短路(每个 $i$ 都当源点)时,直接跑 $n$ 次 Dijkstra/SPFA 代价太高。
构造反图后,只需以 $x$ 为源跑一次最短路,即可得到原图中所有 $i\to x$ 的最短距离。
例题:[USACO07FEB] Cow Party¶
题意: $n$ 头牛要去编号为 $x$ 的农场参加派对,派对结束后再各自回家。农场间有 $m$ 条有向路径,路径长度为正。每头牛去和回都走最短路,求这 $n$ 条「往返」路径中最长的一条。
即求解:
思路:
- 正向图 $\,G$:从 $x$ 出发跑一次 Dijkstra,得所有 $dis_{x\to i}$.
- 反向图 $\,G'$:将每条边反向后,从 $x$ 出发跑一次 Dijkstra,得所有 $dis'{x\to i}=dis{i\to x}$.
- 合并答案:$\displaystyle \max_{i}\bigl(dis'{x\to i}+dis{x\to i}\bigr).$
具体实现
- 边权皆正,使用 \alert{堆优化 Dijkstra},复杂度 $O(m\log n)$。
- 反图存储:另一组邻接表 $\texttt{revG}$。
- 使用自定义函数,将数组传入参数减少重复代码。
参考代码
void dijkstra(int s, int dis[], vector<pii> e[])
{
for (int i = 1; i <= n; i++) dis[i] = 1e9;
dis[s] = 0;
vector<bool> vis(n + 1, false);
priority_queue<pii, vector<pii>, greater<pii>> q;
q.push({0, s});
while (!q.empty())
{
int u = q.top().second;
q.pop();
if (vis[u]) continue;
vis[u] = true;
for (auto it : e[u])
{
int v = it.first, w = it.second;
if (dis[v] > dis[u] + w)
{
dis[v] = dis[u] + w;
}
}
}
}
int main()
{
cin >> n >> m >> s;
while (m--)
{
int u, v, w;
cin >> u >> v >> w;
e[u].push_back({v, w});
g[v].push_back({u, w});
}
dijkstra(s, dis1, e);
dijkstra(s, dis2, g);
int ans = 0;
for (int i = 1; i <= n; i++)
ans = max(ans, dis1[i] + dis2[i]);
cout << ans;
return 0;
}
虚拟节点¶
在原图之外新建一些 辅助节点,并用零权或特殊权值的边将它们连入图中,以便一次跑最短/最长路即可解决多个源点或多个汇点的问题。
- 常见场景:多源最短路 $S$ 集合到 $T$ 集合的最短路
- 关键:把 集合 统一为一个点,再做一次常规图算法。
示例:多源 $\to$ 多汇最短路
暴力做法:对每个源点跑一次 Dijkstra/SPFA,取最小值。复杂度 $O(|S|\,(E\log V))$。
用虚拟节点优化
- 新建 虚拟源 $0$,对每个原始源点 $s_i$ 加边 $0\to s_i$,权值 $0$。
- 新建 虚拟汇 $n+1$,对每个原始汇点 $t_i$ 加边 $t_i\to n+1$,权值 $0$。
- 在新图上求从 $0\to n+1$ 的最短路等价于原图。
例题:CF938D — Buy a Ticket¶
题意: 无向图上每个城市 $i$ 参加演唱会需支付门票 $a_i$,同时来回路程费用为 $2\cdot \mathrm{dist}(i,j)$。 求每个城市的最小总花费。
暴力:对每个 $i$ 跑一次最短路 $O(n\,m\log m)$ 会 TLE。
思路:
本题属于既有点权也有边权,这类题通常是要统一处理的。这里需要借助一个虚拟源点来将点权也转化为边权。
巧用“虚拟源”
把门票花费 $a_j$ 看作 从源出发 的单程成本:
即建立一个超级源点 $0$,向每个点 $i$ 连接一条边,长度为 $a_i$。
- 有向边即可。无向边意义不大。
再来考虑往返路程,由于是无向图的缘故。走过去和走回来的路径必然相同,因此可以把每条边的长度直接乘以 $2$。
具体实现
- 新建节点 $0$,对每个城市 $j$ 加边 $0\to j$,权值 $a_j$.
- 原图每条无向边 ${u,v}$ 替为两条有向边 $u\to v$ 和 $v\to u$,权值均为 $2w$。
- 从 $0$ 出发跑一次 Dijkstra,结果 $dis[j]$ 即为答案。
核心代码
vector<vector<pair<int, long long>>> G(n + 1);
for (int i = 1; i <= m; i++)
{
int u, v;
long long w;
cin >> u >> v >> w;
G[u].push_back({v, 2 * w});
G[v].push_back({u, 2 * w});
}
// 虚拟源 0
for (int i = 1; i <= n; i++)
{
G[0].push_back({i, a[i]});
}
例题:[W1] 团¶
题意: 有一张 $n$ 个节点的无向边带权图。它的边很多,用这个方法表示:
- 有 $k$ 个集合;第 $i$ 个集合可以表示为 $S_i={(T_1,W_1),(T_2,W_2),\dots,(T_{|S_i|},W_{|S_i|})}$。
- 对于任何两对 $(T_i,W_i),(T_j,W_j)$ 在同一个集合里面,图中会形成一条连 $T_i$ 和 $T_j$ 的边,边权为 $W_i+W_j$。
请对于所有节点 $i$ 找到 $1$ 到 $i$ 的最短路,即从 $1$ 到 $i$ 的边权和最小的简单路径。
暴力建图显然时间和空间均无法承受。
虚拟节点思路
- 为每个集合 $S_i$ 新建一个 虚拟节点 $v_i$;
- 对集合内每个原节点 $u\in S_i$ 加一条无向边 ${u,v_i}$,权重为给定的 $W_u$;
- 这样就只用了 $\sum_i |S_i|$ 条边,且通过中转 $v_i$ 可实现任意两点互通。
示例
原始做法:集合 $S={u_1,u_2,\ldots,u_k}$ 内两两连边,一共连接 $\binom{k}{2}=\frac{k\cdot (k-1)}{2}\text{ 条边}$
优化后:增加一个虚拟节点 $v_i$,每个点向虚拟节点 $v_i$ 连一条无向边,只需要 $2k$ 条边。
// 假设原节点编号 1..N,集合编号 1..K
int N, K;
vector<vector<pair<int, int>>> G(n + K + 1);
// 对每个集合 i(i=1..K):
for (int i = 1; i <= K; i++)
{
int m;
cin >> m; // 当前集合大小
int vi = n + i; // 虚拟节点编号
for (int i = 1; i <= m; i++)
{
int u, w;
cin >> u >> w;
G[u].push_back({vi, w});
G[vi].push_back({u, w});
}
}
// 之后直接在 G 上跑 Dijkstra 最短路算法
拆点¶
拆点是一种常用于图论建模的技术,核心思想类似于动态规划 —— 在图的节点上附加状态信息,扩展为多维状态图。通常应用于以下场景:
- 节点在不同状态下具有不同的含义或权值。
- 边的转移会影响某种资源(如次数、时间、路径长度等)。
例题:CSP-J 2019 加工零件¶
题意: 给定一个 $n$ 个点 $m$ 条边的无向图,有 $q$ 组询问,每次询问给定一个点 $a$,问从 $a$ 出发是否可以经过恰好 $L$ 条边到达点 $1$。
转化:由于是无向图每组询问可以看成从 $1$ 出发恰好经过 $L$ 条边是否可以到达点 $a$。
暴力:
- 定义
dfs(u, k)
的搜索函数,其中 $u$ 代表当前结点编号,$k$ 代表目前已经走了几条边。 - 边界:当 $u$ 等于 $a$ 且 $k$ 为 $0$ 就证明可以。
- 每次搜索遍历邻点 $v$,执行
dfs(v, k - 1)
即可。 - 每次询问调用
dfs(1, L)
即可。
void dfs(int u, int k)
{
if (flag) return ;
if (u == a && k == 0)
{
flag = 1;
return ;
}
for (auto v : e[u])
{
if (k > 0)
{
dfs(v, k - 1);
}
}
}
优化
结论:如果从 $1$ 出发经过 $L$ 条边可以到达点 $a$,那么经过 $L+2,L+4,\dots$ 条边都可以到达点 $a$。
- 只需要挑选一条边每次来回走一遍让距离加 $2$ 即可。
故而可以预处理出从 $1$ 出发到达每个点经过奇数条边和偶数条边的最短路,对于每一组询问的点 $a,L$,只需要判断在和 $L$ 奇偶性相同的情况下,最短路距离是否小于等于 $L$ 即可。
由于边权均为 $1$,因此使用 BFS 即可处理最短路。
将每个点拆出两种状态,使用 dis[i][j]
存到达点 $i$ 经过奇数还是偶数条边的最短路($j$ 取 $0$ 代表偶数,取 $1$ 代表奇数)。
- 特别的判重数组也需要多开一维,奇数点和偶数点需要分开来避免重复入队。
- 队列当中不仅需要记录每个点的编号,也得记录每个点的状态。
- 设队头为 $u$ 它的状态是 $x$,遍历它的邻点 $v$
- 则 $dis_{v,x\oplus 1}=dis_{u,x}+1$ 然后将 $v$ 和 $v$ 的状态继续入队。
对于每组询问记得特判若无法到达某个点需要输出 $-1$
#include <bits/stdc++.h>
using namespace std;
const int N = 1e5 + 5;
int n, m, q, dis[N][2];
bool vis[N][2];
vector<int> e[N];
void bfs()
{
queue<pair<int, int>> q;
q.push({1, 0}); // 初始在 1 经过 0 条边对应偶数状态入队
dis[1][0] = 0, vis[1][0] = 1;
while (q.size())
{
auto now = q.front(), q.pop();
int u = now.first, x = now.second;
for (auto v : e[u])
{
if (!vis[v][x ^ 1])
{
vis[v][x ^ 1] = 1, dis[v][x ^ 1] = dis[u][x] + 1;
q.push({v, x ^ 1});
}
}
}
}
int main()
{
cin >> n >> m >> q;
while (m--)
{
int u, v;
cin >> u >> v;
e[u].push_back(v);
e[v].push_back(u);
}
while (q--)
{
int a, L;
cin >> a >> L;
if (!vis[a][L & 1]) // 对应状态的 a 没有访问过
{
cout << "No\n";
continue;
}
if (dis[a][L & 1] <= L) cout << "Yes\n";
else cout << "No\n";
}
}
例题:[JLOI2011] 飞行路线¶
题意: 给定一张 $n$ 个点、$m$ 条边的无向图。你可以在路径中至多选择 $k$ 条边免费通过。目标:从起点 $s$ 到终点 $t$ 的最短路径代价。
错误示例 尝试对代价最大的 $k$ 条边进行 贪心 免费处理是错误的,因为这些边不一定出现在最短路径中。
状态设计与建模
- 状态定义:将每个节点拆分为 $k+1$ 个状态,表示当前还剩多少次免费机会:
-
状态转移,假设当前在点 $u$,剩余 $t$ 次免费机会,欲走向点 $v$,边权为 $w$:
- 不使用免费机会:$dis_{v,t} = \min(dis_{v,t}, dis_{u,t} + w)$
- 使用一次免费机会(若 $t > 0$):$dis_{v,t-1} = \min(dis_{v,t-1}, dis_{u,t})$
-
答案与实现细节
- 最终答案:$\displaystyle \min_{j=0}^{k} dis_{t,j}$,不要求用完所有免费机会。
- 优先队列中记录三元组 $(d, u, j)$,分别为当前距离、节点编号、剩余免费次数。
- $\texttt{vis}$ 与 $\texttt{dis}$ 数组需开二维。
- 使用免费机会前必须判断 $t > 0$。
- 不同状态视为不同点,需分别松弛。
代码实现
时间复杂度:$O(m\cdot k\cdot \log{(m\cdot k)})$
void dijkstra(int s, int t)
{
for (int i = 1; i <= n; i++)
for (int j = 0; j <= t; j++)
dis[i][j] = 1e9;
dis[s][t] = 0; // 在起点 s 还有 t 次免费的最短路为 0
priority_queue<array<int, 3>, vec<array<int, 3>>, greater<array<int, 3>>> q; // 小根堆
q.push({0, s});
while (!q.empty())
{
auto [d, u, k] = q.top(); // C++17 语法结构化绑定
q.pop();
if (vis[u][k]) continue;
vis[u][k] = 1;
for (auto it : e[u])
{
int v = it.first, w = it.second;
if (dis[v][k] > dis[u][k] + w) // 不免费
{
dis[v][k] = dis[u][k] + w;
q.push({dis[v][k], v, k});
}
if (k && dis[v][k - 1] > dis[u][k] + 0) // 选择免费,边权为 0
{
dis[v][k - 1] = dis[u][k] + 0;
q.push({dis[v][k - 1], v, k - 1});
}
}
}
}
分层图¶
在上一题中,我们提到将一个节点拆分为 $k+1$ 种状态,相当于从图论视角构造出一个包含 $n(k+1)$ 个 虚拟节点 的新图。
这种方法被称为:分层图建模(Layered Graph Modeling)。
编号方式
假设原图共有 $n$ 个节点,每个节点拆分为 $k+1$ 层:
例如:原图中节点 $1$ 在 $k+1$ 层中的编号分别为 $1,\ n+1,\ 2n+1,\ \dotsc,\ kn+1$。
结构图示¶
我们可以将原图 复制 $k$ 次,构造出 $k+1$ 层结构的分层图:
- 第 0 层:表示未使用任何免费机会。
- 第 1 层:表示使用 1 个免费机会。
- 第 2 层:表示使用 2 个免费机会。
- 以此类推。
跨层连边方式
在构建分层图时,层与层之间也要建立转移边,模拟 使用一次免费机会 的行为。
假设原图中存在一条无向边 $(u,v)$,长度为 $w$,我们在分层图中对应连边:
- 不使用免费机会:在每层 $j$ 中,连接 $u + jn \to v + jn$ 和 $v + jn \to u + jn$,边权为 $w$。
- 使用一次免费机会:连接 $u + jn \to v + (j+1)n$ 和 $v + jn \to u + (j+1)n$,边权为 $0$(前提是 $j < k$)。
代码实现
for (int j = 0; j <= k; j++)
{
// 层内正常连边
e[j * n + u].push_back({j * n + v, w}); // 第 j 层的 u 连向第 j 层的 v 长度为 w
e[j * n + v].push_back({j * n + u, w}); // 第 j 层的 v 连向第 j 层的 u 长度为 w
if (j > 0) // 第 j - 1 层连向第 j 层
{
e[(j - 1) * n + u].push_back({j * n + v, 0}); // 第 j - 1 层的 u 连向第 j 层的 v 长度为 0
e[(j - 1) * n + v].push_back({j * n + u, 0}); // 第 j - 1 层的 v 连向第 j 层的 u 长度为 0
}
}
实现总结:代码层面注意事项¶
- 数组大小注意:总节点数为 $(k+1) \times n$,$\texttt{dis}$、$\texttt{vis}$ 等数组需按此大小定义。
- Dijkstra 模板:在构建好分层图后,最短路算法可以直接套用 Dijkstra 模板,状态二元组为 $(d, u)$,其中 $u$ 是展开后的编号。
- 终点枚举:由于终点可能出现在任意一层,最终答案应为:
- 分层图是 拆点思想 在图论建模中的具体实现手段,适用于附加资源(如免费次数、体力、换乘等)的建模。
- 每一层代表一种资源消耗状态,层间边的设计必须根据题意灵活变化。
- 层间转移的边权、方向和条件需根据题目特性单独分析,避免套模板式误用。
#include <bits/stdc++.h>
using namespace std;
using pii = pair<int, int>;
const int N = 1e4 + 5;
int n, m, k, s, t, dis[N * 15], vis[N * 15];
vector<pii> e[N * 15];
void add(int u, int v, int w)
{
e[u].push_back({v, w});
}
void dijkstra(int s)
{
for (int i = 1; i <= n * (k + 1); i++)
dis[i] = 1e9;
dis[s] = 0;
priority_queue<pii, vector<pii>, greater<pii>> q;
q.push({dis[s], s});
while (q.size())
{
auto now = q.top();
q.pop();
int u = now.second;
if (vis[u]) continue;
vis[u] = 1;
for (auto it : e[u])
{
int v = it.first, w = it.second;
if (dis[v] > dis[u] + w)
{
dis[v] = dis[u] + w;
q.push({dis[v], v});
}
}
}
}
int main()
{
cin >> n >> m >> k >> s >> t;
s++, t++;
while (m--)
{
int u, v, w;
cin >> u >> v >> w;
u++, v++;
// 层内正常连边
for (int j = 0; j <= k; j++)
{
add(u + j * n, v + j * n, w);
add(v + j * n, u + j * n, w);
}
// 层与层连边
for (int j = 0; j < k; j++)
{
add(u + j * n, v + (j + 1) * n, 0);
add(v + j * n, u + (j + 1) * n, 0);
}
}
dijkstra(s);
int ans = 1e9;
for (int i = 0; i <= k; i++) ans = min(ans, dis[t + i * n]);
cout << ans;
return 0;
}
例题:CF1725M - Moving Both Hands¶
题意: 给定 $n$ 个点 $m$ 条边的有向图,每条边有边权 $w$。有两个人,一个人站在 $1$ 号点,另一个人站在 $i$ 号点。其中 $i\in [2,n]$。两个人交替移动,问移动到同一个点的最小总移动距离是多少?
问题等价于找到一个点 $x$,使得 $dis(1\to x)+dis(i\to x)$ 最小。
在反图中 $dis(i\to x)$ 和 $dis(x\to i)$ 等价。
考虑建立分层图来完成这个模型,一共两层。
- 第一层:原图 $G$。
- 第二层:反图 $G'$。
第一层如何连向第二层?
第一层的 $i$ 向第二层的 $i+n$ 连一条边,权值为 $0$
这样的话原问题就好比 $dis(1\to x)+dis(x\to x')+dis(x'\to i')$。
- 其中 $x'=x+n,i'=i+n$。
例题:[ABC132E] Hopscotch Addict¶
题意: 给定一张 $n$ 个点 $m$ 条边的有向图,及起点 $s$ 与终点 $t$。从 $s$ 出发,每次可以走恰好 $3$ 步,问要走多少次 $3$ 步,才能到达 $t$。如果不能到达,输出 $-1$。没有重边自环。
$1\le n,m\le 10^5,s\ne t$。
分层图模型
建立三层分层图。
- 第一层:移动步数模 $3$ 为 $0$。
- 第二层:移动步数模 $3$ 为 $1$。
- 第三层:移动步数模 $3$ 为 $2$。
对于原图一条边 $u\to v$,建立 $u\to v+n,u+n\to v+2n,u+2n\to v$ 这样的三条边。
当然也可以拆点做,定义 $dis_{i,j}$ 为走到 $i$,步数模 $3$ 为 $j$ 的最短路,答案则为 $dis_{t,0}$。本质和分层图一样。
例题:[ABC192E] Train¶
题意: 给定一张无向图,每条边 $(u,v)$ 附带两个参数:
- $t$:表示走这条边的时间
- $k$:表示从 $u$ 出发时间必须是 $k$ 的倍数才可以通过
求从起点 $s$ 到终点 $t$ 的最短路径所需时间。
做法:
在每次处理 Dijkstra 的松弛操作时,分类如下:
- 若当前时间 $d$(即 $dis[u]$) 满足 $d \bmod k = 0$,可立刻通行,耗时为 $t$
- 否则需等待 $x = k - (d \bmod k)$ 的时间,总代价为 $t + x$
注意 long long
。
long long d = dis[u];
if (d % k == 0)
{
if (dis[v] > d + t)
{
dis[v] = d + t;
q.push({dis[v], v});
}
}
else
{
long long wait = k - d % k;
if (dis[v] > d + wait + t)
{
dis[v] = d + wait + t;
q.push({dis[v], v});
}
}
例题:[CSP-J 2023] 旅游巴士¶
题意: 有向图中,每条边设有一个限制值 $a$,表示通行前的最早时间要求。
- 起点出发和终点到达的时间必须为 $k$ 的倍数
- 每条边通行时间为 $1$
求从 $1$ 到 $n$ 的最短路,满足所有限制条件。
思路:
拆点即可,设 $dis_{i,j}$ 表示走到点 $i$,当前累计时间模 $k$ 为 $j$ 时的最短距离。
最终答案:$dis_{n,0}$(需满足终点时间为 $k$ 的倍数)
状态转移:分类处理每条边 $u \rightarrow v$(通行限制 $a$)
设当前到达 $u$ 的时间为 $d$:
- 若 $d \geq a$,可直接通行:
- 若 $d < a$,必须等待,且等待时间需为 $k$ 的整数倍:
- 设在起点等待了 $x$ 个 $k$ 的单位时间。则满足:
解得 $x\geq \frac{a-d}{k}$。由于 $x$ 为整数,因此 $x=\lceil\dfrac{a - d}{k}\rceil$。
因此总耗时为 $d+x\cdot k+1$。
void dijkstra(int st)
{
for (int i = 1; i <= n; i++)
for (int j = 0; j < k; j++)
dis[i][j] = 1e9;
dis[st][0] = 0;
priority_queue<pii, vector<pii>, greater<pii>> q; // pii = pair<int, int>
q.push({0, st});
while (q.size())
{
auto it = q.top();
q.pop();
int u = it.second, d = it.first;
if (vis[u][d % k]) continue;
vis[u][d % k] = 1;
for (auto it : e[u])
{
int v = it.first, a = it.second;
if (d >= a) // 若 d >= a 可以直接通行
{
if (dis[v][(d + 1) % k] > dis[u][d % k] + 1)
{
dis[v][(d + 1) % k] = dis[u][d % k] + 1;
q.push({dis[v][(d + 1) % k], v});
}
}
else
{
int x = (a - d + k - 1) / k; // 求出至少等待多少倍的 k
if (dis[v][(d + 1) % k] > d + k * x + 1)
{
dis[v][(d + 1) % k] = d + k * x + 1;
q.push({dis[v][(d + 1) % k], v});
}
}
}
}
}
Floyd 相关应用¶
Floyd 算法除了用于求解多源最短路径外,还有两个重要应用方向:
- 传递闭包:判断任意两点间是否连通
- 最小环问题:在正权无向图中,寻找最小环的权值和
传递闭包¶
问题定义 给定一张有向图(邻接矩阵表示),求其传递闭包,即判断图中任意两点是否 可达。
思想: 将每条边看作 $0/1$ 值,使用 Floyd 思路更新 是否可达。原 Floyd 的 $\texttt{min}$ 被替换为 $\texttt{or}$ 运算。
- 设 $f_{k,i,j}$ 表示仅允许通过编号不超过 $k$ 的中间点时,点 $i$ 是否可以到达点 $j$。
去除第一维后,我们可以直接进行原地更新:
代码实现
时间复杂度:$O(n^3)$。
- $f[i][j] = 1$ 表示 $i$ 能到 $j$,初始值为邻接矩阵。
for (int k = 1; k <= n; k++)
{
for (int i = 1; i <= n; i++)
{
for (int j = 1; j <= n; j++)
{
f[i][j] = f[i][j] || (f[i][k] && f[k][j]);
}
}
}
bitset 优化传递闭包¶
若图中点数 $n \leq 2000$,使用布尔数组实现会超时。可使用 $\texttt{bitset}$ 提升效率。
优化关键:将内层循环转为按位与运算。
时间复杂度由 $O(n^3)$ 降为 $O\left( \dfrac{n^3}{w} \right)$,其中 $w$ 为机器字长(通常为 $32$ 或 $64$)。
bitset<N> f[N];
for (int k = 1; k <= n; k++)
{
for (int i = 1; i <= n; i++)
{
if (f[i][k])
{
f[i] |= f[k]; // 相当于 f[i][j] |= f[i][k] & f[k][j]
}
}
}
无向图的最小环¶
给定一张正权无向图,求一个 环,使其总权值最小。环需满足:
- 至少包含三个不同的点
- 每条边存在于输入图中
该问题可以通过 Floyd 算法 顺便 求解,在更新最短路的同时记录可能构成环的最小值。
算法思想
Floyd 的特性:三重循环枚举中转点 $k$ 时,意味着路径中不会包含编号大于 $k$ 的点。
在处理第 $k$ 轮中,以 $k$ 为 环的最高点(环上编号最大的点),尝试寻找形如:
的环,其中 $i < k$, $j < k$。环权值为 $dis_{i,j} + w(i,k) + w(k,j)$。保留最小值即为最小环。
代码实现
int ans = 1e9;
for (int k = 1; k <= n; k++)
{
// 枚举构成环的两个点 i, j
for (int i = 1; i < k; i++)
{
for (int j = i + 1; j < k; j++)
{
if (dis[i][j] < 1e9 && w[i][k] < 1e9 && w[k][j] < 1e9)
ans = min(ans, dis[i][j] + w[i][k] + w[k][j]);
}
}
// Floyd 更新最短路径
for (int i = 1; i <= n; i++)
{
for (int j = 1; j <= n; j++)
{
dis[i][j] = min(dis[i][j], dis[i][k] + dis[k][j]);
}
}
}
-
初始邻接矩阵:$dis[i][j] = w[i][j]$,无边设为 $\infty$
-
在更新 $dis[i][j]$ 前统计环,否则可能统计到重复或非简单环
其余例题:[ABC208D] Shortest Path Queries 2¶
给定一张 $n$ 个点 $m$ 条边的有向图,定义:
若路径不存在,则 $f(s,t,k) = 0$。要求计算如下总和:
其中 $n\leq 400$。
思路: 本题主要考察 Floyd 的本质,而不是背会 Floyd 的代码。
注意到 $f(s,t,k)$ 的定义其实就是 Floyd 的状态设计。因此所求内容即为 Floyd DP 转移过程中,任意两点的路径长度之和。
注意 实现时判断两点路径是否存在。
例题:灾后重建¶
有 $N$ 个村庄(编号 $0$ 到 $N-1$)和 $M$ 条双向道路,每个村庄在地震后将在 $t_i$ 天重建完成,第 $t_i$ 天即可通车。
给出 $Q$ 个询问 $(x, y, t)$,问在第 $t$ 天,从村庄 $x$ 到村庄 $y$ 的最短路径是多少?
注意: 路径必须只经过重建完成时间 $\le t$ 的村庄,否则不能通行。
思路:
Floyd 的核心思想:$dis_{i,j}$ 表示只经过 $1\sim k$ 的点的最短路。
本题中,时间 $t$ 限制了「能使用的中转点」——仅限于 $t_i \le t$ 的村庄。
所以我们可以按村庄重建时间顺序,每次把新恢复的村庄 $k$ 加入 Floyd 外层循环。
查询按时间递增给出,可以边处理边做 Floyd 更新。
状态维护 + 动态更新 Floyd
- 使用 $vis[i]$ 标记村庄 $i$ 是否已恢复。
- 设 $st$ 为上次更新到的编号,$ed$ 为当前时间 $t$ 能用的最大编号。
- 对 $k = st \sim ed$ 执行一轮 Floyd 更新,完成增量更新。
// 当前询问时间为 t,维护恢复村庄
while (i + 1 <= n && a[i + 1] <= t)
vis[++i] = true;
ed = i;
// Floyd 增量更新
for (int k = st; k <= ed; ++k)
for (int i = 1; i <= n; ++i)
for (int j = 1; j <= n; ++j)
dis[i][j] = min(dis[i][j], dis[i][k] + dis[k][j]);
处理询问
// 处理本次询问
if (vis[x] && vis[y])
cout << (dis[x][y] < 1e9 ? dis[x][y] : -1) << "\n";
else
cout << "-1\n";
st = ed + 1; // 更新下次起点
复杂度分析
- Floyd 每个恢复村庄只处理一次,总复杂度 $O(n^3)$。
- 查询处理为 $O(q)$,查询时间递增使得 Floyd 可增量更新。
-
总复杂度为:$\boxed{O(n^3 + q)}$,在 $n \le 200,\ q \le 5\times 10^4$ 范围内可接受。
-
Floyd 外层循环的编号代表 允许作为中转的点,这个语义非常重要。