最短路

给定一张图,边上有边权(无权图可以用 BFS 求最短路),定义一条路径的长度为这条路径上经过的边的边权之和,两点间的最短路即为经过的边的边权之和最小的路径。

  • 多源(全源):要对每一个点作为起点的情况,都做最短路,要求出任意两个点之间的最短路。
  • 单源:固定起点,只求这个起点到其他点的最短路。

最短路是图论中最经典的模型之一,在生活中也有很多应用。例如,城市之间有许多高速公路相连接,想从一个地方去另一个地方,有很多种路径,如何选择一条最短路的路径就是最基本的最短路问题。有时候问题会更加复杂,加上别的限制条件,例如某些城市拥有服务区,连续开车达到一定的时间就必须要进服务区休息,或者是某些路段在特定的时间内会堵车,需要绕行等等,这需要更加灵活地使用最短路算法来解决这些问题。

除了解决给定图的最短路问题,最短路模型还可以解决许多看起来不是图的问题。如果解决一个问题的最佳方案的过程中涉及很多状态,这些状态是明确的且数量合适,而且状态之间可以转移,可以考虑建立图论模型,使用最短路算法求解。

Floyd

基于 DP 的思想,设 dpk,i,j 表示中间经过的点的编号不超过 k 的情况下,ij 的最短路。

image

其中初始化为 dp0,i,j=w(i,j)(重边要取最小值,无向图的话再补 ji 这一条),dp0,i,i=0,其他位置为 (理论上最短路的最大值是 (n1)×maxw,在实现时建议取一个足够大并且两倍之后不会溢出的值)。

for (int k = 1; k <= n; k++)
for (int i = 1; i <= n; i++)
for (int j = 1; j <= n; j++)
dp[k][i][j] = min(dp[k - 1][i][j], dis[k - 1][i][k] + dis[k - 1][k][j]);

一般写的时候第一维会优化掉,可以直接在邻接矩阵上做转移。代码非常简洁,三层循环,但要注意,最外层一定是中间点 k

g 是邻接矩阵
for (int k = 1; k <= n; k++)
for (int i = 1; i <= n; i++)
for (int j = 1; j <= n; j++)
g[i][j] = min(g[i][j], g[i][k] + g[k][j]);

三层循环一定是 k,i,j(中间点,起点,终点),每层内部是递增/递减/乱序是随意的。k 这层循环的本质就是每回把一个点加进来考虑。

时间复杂度为 O(n3),所以一般处理 n=500 以内的问题。当存在负权边时 Floyd 算法正确性依然可以保证。

例题:P2910 [USACO08OPEN] Clear And Present Danger S

题意:给定一张 n 个点的图和图中每两个结点的边权,以及一个 m 个点的序列,求该序列中相邻的点的最短路径之和

数据范围0n100,0m104,保证边权非负且不超过 104

参考代码
#include <cstdio>
#include <algorithm>
using namespace std;
const int N = 105;
const int M = 10005;
int a[M], dis[N][N];
int main()
{
int n, m; scanf("%d%d", &n, &m);
for (int i = 1; i <= m; i++) scanf("%d", &a[i]);
for (int i = 1; i <= n; i++)
for (int j = 1; j <= n; j++)
scanf("%d", &dis[i][j]);
for (int k = 1; k <= n; 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]);
int ans = 0;
for (int i = 1; i < m; i++) ans += dis[a[i]][a[i + 1]];
printf("%d\n", ans);
return 0;
}

例题:P1119 灾后重建

询问 xy,只能经过在第 t 天以前重建好的村庄的情况下的最短路,n200q50000

解题思路

Floyd 算法本身就是加点过程,最外层循环就是在加点,至于加点的顺序,不一定非要按编号的顺序,像这道题就希望按照重建完成的时间加点。

可以将询问离线,按询问的 t 的顺序,每次把重建完成的时间小于等于这个询问的 t 的点都加进来,然后看此时 xy 的最短路。

加点的时候要注意 ij 的循环跑满 1n,这样才能把所有路径都跑出来。

最后要注意,Floyd 加点是往路径中间加,所以在回答询问时还要判断起点和终点是否已经重建完成,如果没完成,输出 1

参考代码
#include <cstdio>
#include <algorithm>
const int N = 205;
const int INF = 1e9;
int tm[N], g[N][N];
bool ok[N];
int main()
{
int n, m; scanf("%d%d", &n, &m);
for (int i = 0; i < n; i++) scanf("%d", &tm[i]);
for (int i = 0; i < n; i++)
for (int j = 0; j < n; j++)
g[i][j] = i == j ? 0 : INF;
for (int i = 1; i <= m; i++) {
int x, y, w; scanf("%d%d%d", &x, &y, &w);
g[x][y] = g[y][x] = w;
}
int q; scanf("%d", &q);
int idx = 0;
for (int id = 1; id <= q; id++) {
int x, y, t; scanf("%d%d%d", &x, &y, &t);
while (idx < n && tm[idx] <= t) {
for (int i = 0; i < n; i++)
for (int j = 0; j < n; j++)
g[i][j] = std::min(g[i][j], g[i][idx] + g[idx][j]);
ok[idx] = true; idx++;
}
printf("%d\n", !ok[x] || !ok[y] || g[x][y] == INF ? -1 : g[x][y]);
}
return 0;
}

拓展:加边 Floyd

刚开始给一个 n (n100) 个点 0 条边的图,加 m (mn2) 条边,每次加一条边 (u,v,w) 进来,问所有最短路的情况?

解题思路

针对每一次加的边,枚举 i,jdisi,j=min(disi,j,disi,u+w+disv,j)

时间复杂度为 O(mn2)

例题:P1613 跑路

解题思路

很明显,直接走最短路不对,如果 1n 之间有一条长度为 3 的路,还有一条长度为 4 的路,应该选 4

应该建一个图:如果两个点之间 1s 能到,它俩之间连边权为 1 的边。

问题变成:怎么知道两个点之间能不能 1s 到,也就是两个点之间有没有长度为 2k 的路径?

原图的边权都是 1,首先对于所有原图的边 (u,v),说明 (u,v) 之间有长度为 20=1 的边。因为原图边权均为 1,所以如果 ij 之间有长度为 2x 的路径,一定存在一个中间点 k 满足 ik 之间有长度为 2x1 的路径,kj 之间有长度为 2x1 的路径。

用一个 bool 数组 fi,j,x 表示 ij 之间有没有长度为 2x 的路径(本题中因为路径长度不超过 long long 上界 2631,所以 x 最大可以到 62)。结合 Floyd 算法推出 f 数组,利用 f 数组重新建图:

if (f[i][k][x - 1] && f[k][j][x - 1]) {
f[i][j][x] = g[i][j] = true;
}

最后在新的图上计算 1n 的最短路。

参考代码
#include <cstdio>
#include <queue>
const int N = 55;
bool f[N][N][63], g[N][N];
int ans[N];
int main()
{
int n, m; scanf("%d%d", &n, &m);
for (int i = 1; i <= m; i++) {
int x, y; scanf("%d%d", &x, &y);
g[x][y] = true; f[x][y][0] = true;
}
for (int t = 1; t < 63; t++) {
for (int k = 1; k <= n; k++) {
for (int i = 1; i <= n; i++)
for (int j = 1; j <= n; j++) {
if (f[i][k][t - 1] && f[k][j][t - 1]) {
f[i][j][t] = g[i][j] = true;
}
}
}
}
std::queue<int> q;
for (int i = 1; i <= n; i++) ans[i] = -1;
q.push(1); ans[1] = 0;
while (!q.empty()) {
int u = q.front(); q.pop();
if (u == n) break;
for (int v = 1; v <= n; v++)
if (g[u][v] && ans[v] == -1) {
ans[v] = ans[u] + 1; q.push(v);
}
}
printf("%d\n", ans[n]);
return 0;
}

例题:P1730 最小密度路径

解题思路

经过的边数最多会是多少?因为是无环图,所以任何一条路径的长度都不超过 n1

所以可以枚举路径长度,用 disu,v,x 表示 uv 经过 x 条边的最短路。需要注意本题有重边。

一条路径可以拆成两段,枚举中间点 p,给两边分配长度,则有 disu,v,x=min(disu,v,x,disu,p,y+disp,v,xy),但是这样做的时间复杂度是 O(n5),会超时。

假设计算经过 4 条边的最短路,那么一定有一个点,u 到它的距离为 3 的最短路加上它到 v 距离为 1 的最短路,就是 uv 距离为 4 的最短路。

所以实际上不用考虑分配长度,只需要用 disu,v,x=min(disu,v,x,disu,p,x1+disp,v,1) 就能求出符合条件的最短路了,计算的时间复杂度也就降到了 O(n4)

对于每个询问,遍历不同边数下的最短路计算最小密度。

参考代码
#include <cstdio>
#include <algorithm>
const int N = 55;
const int INF = 1e9;
int g[N][N][N]; // g[x][y][z]表示x到y经过z条边的情况下最短路
bool f[N][N][N];
int main()
{
int n, m; scanf("%d%d", &n, &m);
for (int i = 1; i <= n; i++)
for (int j = 1; j <= n; j++) {
for (int k = 1; k <= n; k++)
g[i][j][k] = i == j ? 0 : INF;
}
for (int i = 1; i <= m; i++) {
int a, b, w; scanf("%d%d%d", &a, &b, &w);
g[a][b][1] = std::min(g[a][b][1], w); // 注意重边
}
for (int cnt = 2; cnt <= n; cnt++) {
for (int k = 1; k <= n; k++)
for (int i = 1; i <= n; i++) {
if (i == k) continue;
for (int j = 1; j <= n; j++) {
if (j == k) continue;
g[i][j][cnt] = std::min(g[i][j][cnt], g[i][k][cnt - 1] + g[k][j][1]);
}
}
}
int q; scanf("%d", &q);
for (int i = 1; i <= q; i++) {
int x, y; scanf("%d%d", &x, &y);
double ans = INF;
for (int cnt = 1; cnt <= n; cnt++)
if (g[x][y][cnt] != INF) // 有总共cnt条边的路径
ans = std::min(ans, 1.0 * g[x][y][cnt] / cnt);
if (ans == INF) printf("OMG!\n");
else printf("%.3f\n", ans);
}
return 0;
}

例题:P1841 [JSOI2007] 重要的城市

判定一个点是否是某两个点之间最短路的必经点(删了这个点,存在其他点 u,v 间的最短路变长了或者变成不连通)。

解题思路

Floyd 算法可以算出任意两点距离 disi,j,如果 disu,x+disx,v=disu,v,则 xu,v 间至少一条最短路上(存在经过 x 的最短路)。

更进一步,如果经过 xuv 间的最短路的数量和 uv 之间全部最短路的数量相等,则 x 就是重要的城市。

fi,j 表示 ij 的最短路方案数,disu,x+disx,v=disu,vfu,x×fx,v=fu,v 说明 xuv 最短路的必经点。

if (dis[i][k] + dis[k][j] < dis[i][j])
f[i][j] = f[i][k] * f[k][j] // 在i到j的最短路上只经过编号<=k的点的方案数
else if (dis[i][k] + dis[k][j] == dis[i][j])
f[i][j] += f[i][k] * f[k][j]

但是 f 可能很大,可能溢出,所以这个做法不一定靠谱,并不是保证正确的做法。

参考代码
#include <cstdio>
using ll = long long;
const int N = 205;
const int INF = 1e9;
int dis[N][N];
ll f[N][N];
bool key[N];
int main()
{
int n, m; scanf("%d%d", &n, &m);
for (int i = 1; i <= n; i++)
for (int j = 1; j <= n; j++)
dis[i][j] = i == j ? 0 : INF;
for (int i = 1; i <= m; i++) {
int u, v, w; scanf("%d%d%d", &u, &v, &w);
dis[u][v] = dis[v][u] = w;
f[u][v] = f[v][u] = 1;
}
for (int k = 1; k <= n; k++)
for (int i = 1; i <= n; i++) {
if (i == k) continue;
for (int j = 1; j <= n; j++) {
if (j == k) continue;
if (dis[i][k] + dis[k][j] < dis[i][j]) {
dis[i][j] = dis[i][k] + dis[k][j];
f[i][j] = f[i][k] * f[k][j];
} else if (dis[i][j] == dis[i][k] + dis[k][j]) {
f[i][j] += f[i][k] * f[k][j];
}
}
}
for (int k = 1; k <= n; k++) {
for (int i = 1; i <= n; i++) {
if (i == k) continue;
for (int j = 1; j <= n; j++) {
if (j == k) continue;
if (dis[i][k] + dis[k][j] == dis[i][j] && f[i][k] * f[k][j] == f[i][j])
key[k] = true;
}
}
}
int cnt = 0;
for (int i = 1; i <= n; i++)
if (key[i]) {
printf("%d ", i); cnt++;
}
if (cnt == 0) printf("No important cities.\n");
return 0;
}

拓展:求 uv 之间经过 xy 这条边的最短路的数量。

解题思路

fu,x×fy,v

考虑 Floyd 算法在添加一个点 k 的时候是在干什么?对于一条 ij 的路,中间经过的点的编号 k

keyi,j 表示 ij 的最短路上中间经过的编号最大的点可能是几。

if (dis[i][k] + dis[k][j] < dis[i][j]) {
// i->j目前的最短路经过的点的最大编号一定是k
key[i][j].clear();
key[i][j].push_back(k);
} else if (dis[i][k] + dis[k][j] == dis[i][j]) {
// i->j目前的最短路经过的点的最大编号可能是以前存下来的,也可能是k
key[i][j].push_back(k);
}

对于一对点 i,j 来讲,keyi,j 说明什么?

  • 如果 keyi,j 只记了一个值,那这个记下来的就是重要城市
  • 如果记了不止一个,可以都不考虑

为什么可以不考虑?如果删除记下来的最大的点,这个点肯定没用;如果删除的是记下来的点里边编号小的点,并且这个点确实是重要城市,它也一定会在 ij 最短路的中间某一段中记录着。

image

因此 keyi,j 实际上不用全记(不需要 vector)。

keyi,j 表示 ij 的最短路上中间经过的编号最大的点可能是几,如果有不止一个可能值,记为 0

keyi,j=0,则 ij 的最短路上中间经过的编号最大的点要么不是重要城市,就算是,也会被更小的段记录下来,不用在 ij 的路径上考虑。

在 Floyd 过程中,如果 disi,k+disk,j<disi,j,那么更新 keyi,jk,如果 disi,k+disk,j=disi,j,那么更新 keyi,j0

注意 k=ik=j 的时候不做相应更新。

最后对于 keyi,j0 的情况,标记 keyi,j 为重要的城市。

参考代码
#include <cstdio>
const int N = 205;
const int INF = 1e9;
int dis[N][N], key[N][N];
bool f[N];
int main()
{
int n, m; scanf("%d%d", &n, &m);
for (int i = 1; i <= n; i++)
for (int j = 1; j <= n; j++)
dis[i][j] = i == j ? 0 : INF;
for (int i = 1; i <= m; i++) {
int u, v, w; scanf("%d%d%d", &u, &v, &w);
dis[u][v] = dis[v][u] = w;
}
for (int k = 1; k <= n; k++)
for (int i = 1; i <= n; i++)
for (int j = 1; j <= n; j++) {
if (dis[i][k] + dis[k][j] < dis[i][j]) {
dis[i][j] = dis[i][k] + dis[k][j];
key[i][j] = k;
} else if (i != k && k != j && dis[i][k] + dis[k][j] == dis[i][j]) {
key[i][j] = 0;
}
}
int cnt = 0;
for (int i = 1; i <= n; i++)
for (int j = 1; j <= n; j++)
if (key[i][j] > 0) {
cnt++; f[key[i][j]] = true;
}
if (cnt == 0) printf("No important cities.\n");
else for (int i = 1; i <= n; i++) if (f[i]) printf("%d ", i);
return 0;
}

另一种方法:

对于每个起点 i,看 i 到每个点的最短路中,有哪些边是有用的,有哪些边是没用的。

枚举边 (u,v),看 (u,v) 在不在以 i 为起点,某个点为终点的最短路上。如果 disi,u+w(u,v)=disi,v,就说明 uviv 的至少一条最短路上;disi,v+w(v,u)=disi,u 同理,说明 vuiv 的至少一条最短路上。只有这些边有用,其他边就算全没有,也不影响 i 到任何点的最短路。

只考虑这些有用的边,会构成一个怎样的图?由于最短路上一定没环(无环),disi,udisi,v 一定是有大小关系的,不可能从大的往小的走(有向),因此构成的必然是有向无环图。这被称为最短路 DAG。

image

找到入度为 1 的点,这个点之前的那个点(不能是起点,因为考虑的是枚举的起点到其它点的最短路)就是重要城市,因为删掉前面那个点,会影响起点到这个入度为 1 的点的最短路。

参考代码
#include <cstdio>
#include <algorithm>
const int N = 205;
const int INF = 1e9;
int g[N][N], dis[N][N], ind[N], pre[N];
bool key[N];
int main()
{
int n, m; scanf("%d%d", &n, &m);
for (int i = 1; i <= n; i++)
for (int j = 1; j <= n; j++) {
dis[i][j] = i == j ? 0 : INF;
g[i][j] = dis[i][j];
}
for (int i = 1; i <= m; i++) {
int u, v, w; scanf("%d%d%d", &u, &v, &w);
g[u][v] = g[v][u] = w;
dis[u][v] = dis[v][u] = w;
}
// Floyd
for (int k = 1; k <= n; k++)
for (int i = 1; i <= n; i++)
for (int j = 1; j <= n; j++)
dis[i][j] = std::min(dis[i][j], dis[i][k] + dis[k][j]);
for (int i = 1; i <= n; i++) { // 枚举起点
for (int u = 1; u <= n; u++) ind[u] = pre[u] = 0;
for (int u = 1; u <= n; u++) {
for (int v = 1; v <= n; v++) {
if (u == v) continue;
if (dis[i][u] + g[u][v] == dis[i][v]) {
ind[v]++; pre[v] = u;
}
}
}
for (int u = 1; u <= n; u++) {
if (ind[u] == 1 && pre[u] != i) key[pre[u]] = true;
}
}
int cnt = 0;
for (int i = 1; i <= n; i++)
if (key[i]) {
printf("%d ", i); cnt++;
}
if (cnt == 0) printf("No important cities.\n");
return 0;
}

Dijkstra

Dijkstra 算法用于处理单源最短路问题,并且边权不能有负的。

例题:P3371 【模板】单源最短路径(弱化版)

题意:给定一个 n 个点 m 条边的有向图,以及一个起点 s,每条边长度(边权)给定,输出 s 到所有终点的最短路径长度(如无法到达则输出 2321),所有边的长度均为非负整数,且保证最短路径长度不会超过 2321

数据范围:1n104,1m5×105

分析:最简单的想法是,使用搜索来寻找所有可能的路径并比较它们的长度,选取最短的路径作为最短路。然而,由于可能的路径太多,时间复杂度是指数级别的,无法在规定时间内运行完毕。因此需要使用最短路算法,从起点开始尝试往外走,不断用已知点的最短路长度来更新其他点的最短路长度。

用一个 dis 数组来记录到目前为止起点到各点的最短路径长度。

image

  1. 在初始时刻,先将整个 dis 数组设为
  2. 对于一条从 uv,长度为 w 的边,如果 dis[u]+w<dis[v],那么就可以将 dis[v] 的值变成 dis[u]+w,因为这代表着从 s 走到 u 经过这条边再走到 v 是一条未发现过的,且比原先的最优路径还要短的路径。这个操作称为松弛。假设 s=1,那么 dis[1]=0 且不会再变化。于是就可以用 1 号点的所有出边来进行松弛并将 1 标记,代表这个点的 dis 值将不再变化
  3. 可以发现 2 号点的 dis 值是当前未标记的点中最小的。由于从 1 走来的边已经全部完成松弛,且 dis[3]dis[4] 都大于 dis[2](这意味着从 34 号点走到 2 号点的边都无法成功松弛),所以此时的 dis[2] 就是从 12 的最短路。接着就可以用与 2 号点相连的所有边进行松弛并标记
  4. 同样地,发现 4 号点的 dis 是当前未标记的点中最小的,它的 dis 值不会再改变,接着用 4 号点进行松弛并标记
  5. 最后,用 3 号点进行松弛并标记得到最终结果

这种求最短路的方法是 Dijkstra 算法,其过程可以概括为:

  1. 将起始点的 dis 置为 0
  2. 选择当前未标记的点中 dis 值最小的一个
  3. 对该点的所有连边依次进行松弛操作
  4. 对该点进行标记
  5. 重复第二步至第四步,直到不存在一条从已标记点通往未标记点的连边

这个算法的核心在于每次取出来的最小的 dis 的点 x,它的 disx 一定是对的,之后不会再更新了。

可以用反证法证明:假设有一个点 x 会再更新 disx,因为边权非负,要想更新的话,disx 一定小于 disx,此时与假设 disx 是最小的矛盾。这也解释了 Dijkstra 算法必须在没有负权边时才能保证正确性。

参考代码
#include <cstdio>
#include <vector>
using namespace std;
const int N = 10005;
const int INF = 2147483647;
struct Edge {
int to, w;
};
vector<Edge> g[N];
int dis[N];
bool vis[N];
int main()
{
int n, m, s;
scanf("%d%d%d", &n, &m, &s);
while (m--) {
int u, v, w; scanf("%d%d%d", &u, &v, &w);
g[u].push_back({v, w});
}
for (int i = 0; i <= n; i++) dis[i] = INF;
dis[s] = 0;
while (true) {
int u = 0;
for (int i = 1; i <= n; i++)
if (!vis[i] && dis[i] < dis[u]) u = i;
if (u == 0) break;
vis[u] = true;
for (Edge e : g[u]) {
int to = e.to, w = e.w;
if (dis[u] + w < dis[to]) dis[to] = dis[u] + w;
}
}
for (int i = 1; i <= n; i++) printf("%d%c", dis[i], i == n ? '\n' : ' ');
return 0;
}

上述算法的时间复杂度是 O(n2+m),尽管这一复杂度能够通过本题,但并不够优秀。

例题:P4779 【模板】单源最短路径(标准版)

本题的 n105,m2×105,因此用前面的算法无法通过本题了

需要对 Dijkstra 算法加一点优化:使用一个小根堆来维护 dis 最小的点。用一个结构体记录结点的编号 idis[i],并以 dis[i] 为关键字排序。每次松弛成功时,将被松弛的边的终点和对应的 dis 值打包放入堆中,在需要寻找 dis 最小的点时将堆顶端的点取出,验证其是否已被标记即可。

为什么在堆顶取出时,要验证其是否已被标记?如果在更新的时候,更新了一个已经被放进优先队列的点(也就是优先队列里有它更新前的 dis 值),那么较早的那次入堆已经成没用的信息了。而标记数组 vis 被标记过意味着这个点一定在之前被取出来过,所以当发现 vis 被标记时,这一次取出的信息不用来更新其它点的最短路,直接 continue。这是一种惰性删除的思想,因为优先队列没法删除非堆顶的元素。

每个点会扫一次边,所以扫边的循环是 O(m) 的,由于每一条边最多被入堆、出堆各一次,且堆内元素最多为 m 个,其时间复杂度为 O(mlogm)

参考代码
#include <cstdio>
#include <vector>
#include <queue>
using namespace std;
const int N = 100005;
const int INF = 2147483647;
struct Edge {
int to, w;
};
vector<Edge> g[N];
int dis[N];
bool vis[N];
struct Node {
int i, d;
};
struct NodeCmp {
bool operator()(const Node& a, const Node& b) const {
return a.d > b.d;
}
};
priority_queue<Node, vector<Node>, NodeCmp> q;
int main()
{
int n, m, s; scanf("%d%d%d", &n, &m, &s);
while (m--) {
int u, v, w; scanf("%d%d%d", &u, &v, &w);
g[u].push_back({v, w});
}
for (int i = 1; i <= n; i++) dis[i] = INF;
dis[s] = 0;
q.push({s, 0});
while (!q.empty()) {
int u = q.top().i; q.pop();
if (vis[u]) continue;
vis[u] = true;
for (Edge e : g[u]) {
int to = e.to, w = e.w;
if (dis[u] + w < dis[to]) {
dis[to] = dis[u] + w; q.push({to, dis[to]});
}
}
}
for (int i = 1; i <= n; i++) printf("%d%c", dis[i], i == n ? '\n' : ' ');
return 0;
}

拓展vis 数组是在弹出时标记还是放入时标记?

分析

image

容易发现,当 mn 同级时,采用堆优化会使得程序运行效率获得极大的提升;但如果 mn2 同级(比如完全图),那么使用堆优化之后复杂度变为 O(n2logn2),反而劣于不加优化的 Dijkstra,在实际应用时,应当根据实际数据的范围来选择使用哪种算法。

例题:P1629 邮递员送信

题意:给定一张 n 个点 m 条边的有向图,邮递员从结点 1 出发,有 n1 个快递,分别要送到结点 2n,每次快递员只能携带一个快递出发并在送完后返回邮局。求邮递员走过的最少的路程。

数据范围1n103,1m105,保证任意两点间可相互到达

解题思路

由于快递员必须每次都要从 1 结点出发,到达结点 i,然后返回 1,因此就是求 1i 的最短路,加上 i1 的最短路。考虑到是有向图,从 1i 的距离不一定等于从 i1 的距离。计算从 1i 的距离很简单,但如何求从其他结点到 1 的距离呢?而从某个点到 1 的路径,等价于从 1 开始,倒着沿路走。于是可以建立另外一个图,将读入的边起点和终点对调后存入这个新图中,从 1 开始计算到其他点的单源最短路径,得到的距离就是原图从其他点到起点的最短路径。一来一回加起来就是送货的最短路径

参考代码
#include <cstdio>
#include <vector>
#include <algorithm>
using namespace std;
const int N = 2005;
const int INF = 1e9;
int dis[N];
bool vis[N];
struct Edge {
int to, w;
};
vector<Edge> g[N];
void update(int u) {
vis[u] = true;
for (Edge e : g[u]) {
int to = e.to, w = e.w;
dis[to] = min(dis[to], dis[u] + w);
}
}
int main()
{
int n, m;
scanf("%d%d", &n, &m);
while (m--) {
int u, v, w;
scanf("%d%d%d", &u, &v, &w);
g[u].push_back({v, w});
g[v + n].push_back({u + n, w});
}
for (int i = 0; i <= n * 2; i++) dis[i] = INF;
dis[1] = 0;
while (true) {
int u = 0;
for (int i = 1; i <= n; i++)
if (!vis[i] && dis[i] < dis[u]) u = i;
if (u == 0) break;
update(u);
}
dis[n + 1] = 0;
while (true) {
int u = 0;
for (int i = n + 1; i <= n * 2; i++)
if (!vis[i] && dis[i] < dis[u]) u = i;
if (u == 0) break;
update(u);
}
int ans = 0;
for (int i = 2; i <= n; i++) ans += dis[i] + dis[i + n];
printf("%d\n", ans);
return 0;
}

例题:P2176 [USACO11DEC] RoadBlock S / [USACO14FEB]Roadblock G/S

给定 n (n100) 个点 m (m5000) 条边的无向图,允许把某一条边的长度变成 2 倍,问 1n 的最短路最多能增加多少?

解题思路

枚举每一条边,将其修改后再跑最短路。取这些情况中最短路的最大值减去一开始的最短路,即为答案,时间复杂度为 O(mn2)O(m2logm)

实际上,只有 1n 的最短路上的边需要考虑翻倍,最多只有 n1 条边。因为如果翻倍的边不在最短路径上,则跑最短路的结果不会变。

如果有多条最短路,只考虑一条也够了。因为这一条路径上可以分成必经边和非必经边,必经边都考虑到了,而非必经边多考虑一下也不会造成影响。

怎么记录一条最短路?只需要在 Dijkstra 算法更新距离的时候,记录一下是谁造成的更新。跑出一条路径后,从 n 顺着记下来的路径往回找就行了。

这样一来可以将时间复杂度优化到 O(n3)

参考代码
#include <cstdio>
#include <algorithm>
const int N = 105;
const int INF = 1e9;
int g[N][N], dis[N], pre[N], n;
bool vis[N];
void dijkstra(bool rec) {
for (int i = 1; i <= n; i++) {
dis[i] = INF;
vis[i] = false;
}
dis[1] = 0;
while (true) {
int u = -1;
for (int i = 1; i <= n; i++)
if (!vis[i] && (u == -1 || dis[i] < dis[u])) u = i;
if (u == -1) break;
vis[u] = true;
for (int i = 1; i <= n; i++)
if (dis[u] + g[u][i] < dis[i]) {
dis[i] = dis[u] + g[u][i];
if (rec) pre[i] = u;
}
}
}
int main()
{
int m; scanf("%d%d", &n, &m);
for (int i = 1; i <= n; i++) {
for (int j = 1; j <= n; j++) g[i][j] = INF;
g[i][i] = 0;
}
for (int i = 1; i <= m; i++) {
int a, b, l; scanf("%d%d%d", &a, &b, &l);
g[a][b] = std::min(g[a][b], l);
g[b][a] = std::min(g[b][a], l);
}
dijkstra(true);
int tmp = dis[n];
int u = n, ans = 0;
while (u != 1) {
// 把边权翻倍
g[pre[u]][u] *= 2; g[u][pre[u]] *= 2;
dijkstra(false);
ans = std::max(ans, dis[n] - tmp);
// 把图恢复原样
g[pre[u]][u] /= 2; g[u][pre[u]] /= 2;
u = pre[u];
}
printf("%d\n", ans);
return 0;
}

拓展:最短路树、最短路 DAG

记下来的这个路径形成的这个图是一种特殊图吗?如果连通的话是一棵树,起点是根,每个点的父亲是更新它最短距离的那个点。如果不连通的话就是森林(多棵树)。这被称为最短路树。

区分最短路树、最短路 DAG:

  1. 最短路树:从起点到每个点只记了一条最短路(有多条的时候根据实现的最短路算法相应记了一条)。
  2. 最短路 DAG:是在跑完最短路以后,留下所有 diss,u+w(u,v)=diss,v 的边,这个时候如果有多条最短路,都会保留下来。

Floyd 算法其实也能记路径,就是记 k,拆成 ukkv,递归处理。

例题:P7100 [W1] 团

解题思路

直接建图边太多了,并且有一些点之间是全都有边的。因此要把边的数量降下来。

考虑在每个集合 Si 中添加一个虚点 xxTi 连接一条边权为 Wi 的无向边。这样一来 ixj 的边权等价于原来的边权 Wi+Wj,但是边数和点数同阶,然后对这个图跑最短路即可。

image

参考代码
#include <cstdio>
#include <vector>
#include <utility>
#include <queue>
using ll = long long;
using node = std::pair<ll, ll>;
const int N = 600005;
const ll INF = 4557430888798830399ll;
std::vector<node> g[N];
bool vis[N];
ll dis[N];
int main()
{
int n, k; scanf("%d%d", &n, &k);
for (int i = 1; i <= k; i++) {
int s; scanf("%d", &s);
for (int j = 1; j <= s; j++) {
int t, w; scanf("%d%d", &t, &w);
g[t].push_back({n + i, w});
g[n + i].push_back({t, w});
}
}
for (int i = 1; i <= n + k; i++) dis[i] = INF;
std::priority_queue<node, std::vector<node>, std::greater<node>> q;
dis[1] = 0; q.push({0, 1});
while (!q.empty()) {
node tmp = q.top(); q.pop();
int u = tmp.second;
if (vis[u]) continue;
vis[u] = true;
for (node nd : g[u]) {
int v = nd.first, w = nd.second;
if (dis[u] + w < dis[v]) {
dis[v] = dis[u] + w;
q.push({dis[v], v});
}
}
}
for (int i = 1; i <= n; i++) printf("%lld ", dis[i]);
return 0;
}

习题:P1462 通往奥格瑞玛的道路

解题思路

题目目的:求出到达路线中最大收费的最小值

看到“最小化……最大值”问题,可以先考虑二分答案可不可做,需要分析题目是否符合某种单调性。

本题中若最多一次交的费越大,能用的结点就越多,可以走到终点的可能性也就越大,反之则可能性越小,因此可以使用二分答案实现

而判断某个最大交费限制下能否到达终点则可以将损失血量看作边权转化为最短路问题,即此时“不完整的图”(由于最大交费限制)上的最短路是否小于等于初始血量

参考代码
#include <cstdio>
#include <algorithm>
#include <vector>
#include <queue>
using namespace std;
typedef long long LL;
const int N = 10005;
const LL INF = 1e14;
int f[N], n, m, b;
LL dis[N];
bool vis[N];
struct Edge {
int to, c;
};
vector<Edge> g[N], sub[N];
struct Node {
int id;
LL d;
};
struct NodeCmp {
bool operator()(const Node& a, const Node& b) const {
return a.d > b.d;
}
};
bool check(int x) {
if (x < f[1]) return false;
for (int i = 1; i <= n; i++) vis[i] = false;
for (int i = 1; i <= n; i++) dis[i] = INF;
priority_queue<Node, vector<Node>, NodeCmp> q;
q.push({1, 0}); dis[1] = 0;
while (!q.empty()) {
int u = q.top().id; q.pop();
if (vis[u]) continue;
vis[u] = true;
for (Edge e : g[u]) {
if (f[e.to] <= x && dis[u] + e.c < dis[e.to]) {
dis[e.to] = dis[u] + e.c;
q.push({e.to, dis[e.to]});
}
}
}
return dis[n] <= b;
}
int main()
{
scanf("%d%d%d", &n, &m, &b);
int ans = -1, l = 0, r = 0;
for (int i = 1; i <= n; i++) {
scanf("%d", &f[i]); r = max(r, f[i]);
}
while (m--) {
int x, y, z; scanf("%d%d%d", &x, &y, &z);
g[x].push_back({y, z}); g[y].push_back({x, z});
}
while (l <= r) {
int mid = (l + r) / 2;
if (check(mid)) {
ans = mid; r = mid - 1;
} else l = mid + 1;
}
if (ans == -1) printf("AFK\n");
else printf("%d\n", ans);
return 0;
}

分层图最短路

例题:P4568 [JLOI2011] 飞行路线

由于购买机票需要花费金钱,所以肯定不会重复乘坐多次同样的航线或者多次访问同一个城市。如果 k=0,本题就是最基础的最短路问题。但题目中提供了一些特殊情况(对有限条边设置为免费),可以使用分层图的方式,将图多复制 k 次,原编号为 i 的结点复制为编号 i+jn(1jk) 的结点,然后对于原图存在的边,第 j 层和第 j+1 层的对应结点也需要连上,看起来就是相同结构的图上下堆叠起来,因此被称为分层图

image

从上面一层跳到下面一层就是乘坐免票的飞机,花费的代价是 0,这个过程是不可逆的,每乘坐一次免费航班就跳到下一层图中,因此上一层到下一层的边是单向的。从编号为 s 的结点开始,计算到其他点的单源最短路径。因为并不一定要坐满 k 次免费航班,所以查找所有编号 t+jn(0jn) 的结点作为终点的最短路的值,找到的最小的值就是答案。

#include <cstdio>
#include <vector>
#include <queue>
#include <algorithm>
using namespace std;
const int N = 110005;
const int INF = 1e9;
struct Edge {
int to, w;
};
vector<Edge> g[N];
int dis[N];
bool vis[N];
struct Node {
int i, d;
};
struct NodeCmp {
bool operator()(const Node& a, const Node& b) {
return a.d > b.d;
}
};
priority_queue<Node, vector<Node>, NodeCmp> q;
int main()
{
int n, m, k, s, t;
scanf("%d%d%d%d%d", &n, &m, &k, &s, &t);
while (m--) {
int a, b, c; scanf("%d%d%d", &a, &b, &c);
g[a].push_back({b, c}); g[b].push_back({a, c});
for (int i = 1; i <= k; i++) {
int u = a + i * n, v = b + i * n;
g[u].push_back({v, c}); g[v].push_back({u, c});
g[u - n].push_back({v, 0}); g[v - n].push_back({u, 0});
}
}
for (int i = 0; i < k * n + n; i++) dis[i] = INF;
dis[s] = 0;
q.push({s, 0});
while (!q.empty()) {
int u = q.top().i; q.pop();
if (vis[u]) continue;
vis[u] = true;
for (Edge e : g[u]) {
int to = e.to, w = e.w;
if (dis[u] + w < dis[to]) {
dis[to] = dis[u] + w; q.push({to, dis[to]});
}
}
}
int ans = INF;
for (int i = 0; i <= k; i++) ans = min(ans, dis[t + i * n]);
printf("%d\n", ans);
return 0;
}

0-1 图最短路

一般求解最短路径,高效的方法是 Dijkstra 算法,如果用优先队列实现则时间复杂度为 O(mlogm)m 为边数。但是在边权为 01 的特殊图中,利用双端队列可以在 O(n+m) 时间内求得最短路径。Dijkstra 算法中优先队列的作用是保证单调性,实际上 01 图可以不使用优先队列的原因就在于只需要在队列的两端插入就可以保证单调性。

例题:P2937 [USACO09JAN] Laserphones S

给一个网格图,里边有两个 C,求一条拐弯最少的连接两个 C 的路径。(注意输入是先列数再行数)

解题思路

状态肯定不能只设计成现在在哪儿,因为转移跟方向也有关系。

假设现在在 (x,y),方向为 d,相当于用分层图形式,把原来的一个点拆成四个,初始时把起点 4 种方向的状态都当成起始状态。

接下来就是看要走的方向和当前方向是不是一致,决定边权是 0 还是 1。如果接下来走的方向和现在方向一样,边权是 0;如果接下来走的方向和现在方向不一样,边权是 1

可以想象一下最短路计算过程中优先队列的变化过程:0......1 -> 1......2 -> 2......3 -> ......。由于边权只有 01 两种,所以可以不用优先队列,改用双端队列:如果走的是边权为 0 的,更新后的点放到队首;如果走的是边权为 1 的,更新后的点放到队尾。

每个点最多入队两次,时间复杂度为 O(+)

这个问题叫做 01 最短路,这个做法可以看作是 Dijkstra 算法在特殊情况下的一种优化。

参考代码
#include <cstdio>
#include <deque>
#include <algorithm>
const int N = 105;
const int INF = 1e9;
char s[N][N];
int dx[4] = {-1, 0, 0, 1};
int dy[4] = {0, -1, 1, 0};
struct Node {
int x, y, d;
};
int dis[N][N][4];
bool vis[N][N][4];
int main()
{
int n, m; scanf("%d%d", &m, &n);
for (int i = 1; i <= n; i++) scanf("%s", s[i] + 1);
int x1, y1, x2, y2; x1 = y1 = x2 = y2 = 0;
for (int i = 1; i <= n; i++)
for (int j = 1; j <= m; j++) {
for (int dir = 0; dir < 4; dir++) dis[i][j][dir] = INF;
if (s[i][j] == 'C') {
if (x1 == 0) {
x1 = i; y1 = j;
} else {
x2 = i; y2 = j;
}
}
}
std::deque<Node> q;
for (int dir = 0; dir < 4; dir++) {
q.push_back({x1, y1, dir});
dis[x1][y1][dir] = 0;
}
while (!q.empty()) {
Node tmp = q.front(); q.pop_front();
int x = tmp.x, y = tmp.y, d = tmp.d;
if (vis[x][y][d]) continue;
vis[x][y][d] = true;
for (int dir = 0; dir < 4; dir++) {
int xx = x + dx[dir], yy = y + dy[dir];
if (xx < 1 || xx > n || yy < 1 || yy > m || s[xx][yy] == '*') continue;
int w = 1 - (dir == d);
if (dis[x][y][d] + w < dis[xx][yy][dir]) {
dis[xx][yy][dir] = dis[x][y][d] + w;
if (w == 0) q.push_front({xx, yy, dir});
else q.push_back({xx, yy, dir});
}
}
}
int ans = INF;
for (int dir = 0; dir < 4; dir++) ans = std::min(ans, dis[x2][y2][dir]);
printf("%d\n", ans);
return 0;
}

例题:P4667 [BalticOI 2011 Day1] Switch the Lamp On

时间限制为 150ms;内存限制为 125MB
问题描述:Casper 正在设计电路。有一种正方形的电路元件,在它的两组相对顶点中,有一组会用导线连接起来,另一组则不会。有 N×M 个这样的元件(1N,M500),排列成 N 行,每行 M 个。电源连接到电路板的左上角,灯连接到电路板的右下角。只有在电源和灯之间有一条电线连接的情况下,灯才会亮。为了亮灯,任何数量的电路元件都可以转动 90°(两个方向)。
编写一个程序,求出最少需要旋转多少电路元件。

image

本题可以建模为最短路径问题。把起点 s 到终点 t 的路径长度记录为需要旋转的元件数量。从一个点到邻居点,如果元件不旋转就能到达,则距离为 0;如果需要旋转元件才行,距离为 1。题目要求找出 st 的最短路径。

如果用 Dijkstra 算法,复杂度为 O(NMlogNM),因为这个图的边的数量级是 NM,而题目给的时间限制为 150ms,可能会超时。

在优先队列优化的 Dijkstra 算法中,优先队列的作用是在队列中找到距离起点最短的那个结点,并弹出它。使用优先队列的原因是,每个结点到起点的距离不同,需要用优先队列保证单调性。

本题是一种特殊情况,边权为 0 或 1。简单地说,就是“边权为 0,插到队头;边权为 1,插入队尾”,这样就省去了优先队列维护有序性的代价,从而减少了计算,优化了时间复杂度。这个操作用双端队列实现,这样保证了距离更近的点总是在队列的前面,队列中元素是单调的。每个结点只入队和出队一次,总的时间复杂度是线性的。

#include <cstdio>
#include <deque>
#include <utility>
#include <string>
using namespace std;
typedef pair<int, int> PII;
const int N = 505;
const int INF = 1e9;
int dis[N][N]; // dis记录从起点出发的最短路径
char s[N][N];
// 4个点
int d1[4][2] = {{-1, -1}, {-1, 1}, {1, -1}, {1, 1}};
// 4个电子元件
int d2[4][2] = {{-1, -1}, {-1, 0}, {0, -1}, {0, 0}};
int main()
{
int n, m; scanf("%d%d", &n, &m);
for (int i = 1; i <= n; i++) scanf("%s", s[i] + 1);
for (int i = 1; i <= n + 1; i++)
for (int j = 1; j <= m + 1; j++)
dis[i][j] = INF;
deque<PII> q; q.push_back({1, 1}); dis[1][1] = 0;
string match = "\\//\\"; // 注意反斜杠需要加转义字符
while (!q.empty()) {
PII cur = q.front(); q.pop_front(); // 弹出队头
int x = cur.first, y = cur.second;
for (int i = 0; i < 4; i++) { // 4个方向
char ch = match[i];
int px = x + d1[i][0], py = y + d1[i][1];
int cx = x + d2[i][0], cy = y + d2[i][1];
if (px >= 1 && px <= n + 1 && py >= 1 && py <= m + 1) {
if (cx >= 1 && cx <= n && cy >= 1 && cy <= m) {
if (s[cx][cy] == ch && dis[x][y] < dis[px][py]) {
dis[px][py] = dis[x][y];
q.push_front({px, py});
}
if (s[cx][cy] != ch && dis[x][y] + 1 < dis[px][py]) {
dis[px][py] = dis[x][y] + 1;
q.push_back({px, py});
}
}
}
}
}
if (dis[n + 1][m + 1] == INF) printf("NO SOLUTION\n");
else printf("%d\n", dis[n + 1][m + 1]);
return 0;
}

次短路

例题:P1491 集合位置

路径上不允许经过重复的点(如果最短路有多条,次短路长度就是最短路长度)。

解题思路

先求出一条最短路的路径,次短路一定不会把求出来的那条最短路全都经过一遍,至少有一条边不属于求出来的那条最短路径上的边。

枚举删除最短路径上的一条边,重新跑最短路。

时间复杂度为 O(n3)O(nmlogm)

参考代码
#include <cstdio>
#include <algorithm>
#include <cmath>
const int N = 205;
const double INF = 1e9;
int n, x[N], y[N], pre[N];
double g[N][N], dis[N];
bool vis[N];
double distance(int i, int j) {
double dx = x[i] - x[j];
double dy = y[i] - y[j];
return sqrt(dx * dx + dy * dy);
}
void dijkstra(bool rec) {
for (int i = 1; i <= n; i++) {
dis[i] = INF; vis[i] = false;
}
dis[1] = 0;
while (true) {
int u = -1;
for (int i = 1; i <= n; i++)
if (!vis[i] && (u == -1 || dis[i] < dis[u])) u = i;
if (u == -1) break;
vis[u] = true;
for (int i = 1; i <= n; i++) {
double w = g[u][i];
if (dis[u] + w < dis[i]) {
dis[i] = dis[u] + w;
if (rec) pre[i] = u;
}
}
}
}
int main()
{
int m; scanf("%d%d", &n, &m);
for (int i = 1; i <= n; i++) scanf("%d%d", &x[i], &y[i]);
for (int i = 1; i <= n; i++) {
for (int j = 1; j <= n; j++) g[i][j] = INF;
g[i][i] = 0;
}
for (int i = 1; i <= m; i++) {
int p, q; scanf("%d%d", &p, &q);
g[p][q] = g[q][p] = std::min(g[p][q], distance(p, q));
}
dijkstra(true);
if (dis[n] == INF) {
printf("-1\n"); return 0;
}
int u = n;
double ans = INF;
while (u != 1) {
double tmp = g[pre[u]][u];
g[pre[u]][u] = g[u][pre[u]] = INF;
dijkstra(false);
ans = std::min(ans, dis[n]);
g[pre[u]][u] = g[u][pre[u]] = tmp;
u = pre[u];
}
if (ans == INF) printf("-1\n");
else printf("%.2f\n", ans);
return 0;
}

例题:P2865 [USACO06NOV] Roadblocks G

允许经过重复点和重复边的最短路。

解题思路

类似分层图,用 disu,0 记从起点到 u 的最短路,disu,1 记从起点到 u 的次短路。

初始化 dis1,0=0dis1,1=,往优先队列里放 (dis, 点),刚开始放 (0, 1)

vis 标记也要给每个点准备两份(有两次取出机会),一个点 u 第一次取出来的时候,一定是最短路,第二次取出来的时候,就是次短路了,所以每个点只会有两次有效取出,有效取出才会往后扫边更新。

对于边 uv

  • 如果当前这一次能更新最短路,就把原最短路放到次短路上,更新最短路,将点 v 和这次更新后的 dis 放入优先队列。
  • 如果当前这一次只能更新次短路,就把次短路更新,将点 v 和这次更新后的 dis 放入优先队列。

u 在第一次取出时标记 visu,0,第二次取出时标记 visu,1,之后如果再取出来的时候 visu,0visu,1 均已被标记,说明这个堆顶已经无效了,直接 continue

参考代码
#include <cstdio>
#include <vector>
#include <queue>
using pr = std::pair<int, int>;
const int N = 5005;
const int INF = 1e9;
std::vector<pr> g[N];
int dis[N][2];
bool vis[N][2];
int main()
{
int n, r; scanf("%d%d", &n, &r);
for (int i = 1; i <= r; i++) {
int a, b, d; scanf("%d%d%d", &a, &b, &d);
g[a].push_back({b, d});
g[b].push_back({a, d});
}
std::priority_queue<pr, std::vector<pr>, std::greater<pr>> q;
for (int i = 1; i <= n; i++) dis[i][0] = dis[i][1] = INF;
dis[1][0] = 0; q.push({0, 1});
while (!q.empty()) {
pr p = q.top(); q.pop();
int u = p.second, x = -1;
if (!vis[u][0]) {
vis[u][0] = true; x = 0;
} else if (!vis[u][1]) {
vis[u][1] = true; x = 1;
} else continue;
for (pr e : g[u]) {
int v = e.first, w = e.second;
int tmp = dis[u][x] + w;
if (tmp < dis[v][0]) {
dis[v][1] = dis[v][0];
dis[v][0] = tmp;
q.push({dis[v][0], v});
} else if (tmp > dis[v][0] && tmp < dis[v][1]) {
dis[v][1] = tmp;
q.push({dis[v][1], v});
}
}
}
printf("%d\n", dis[n][1]);
return 0;
}

同余最短路

同余最短路就是把余数相同的情况归为一类,找形成这种情况的最短路径的问题,通常与周期性问题有关。

例题:P3403 跳楼机

给定 x,y,z,h,对于 k[1,h],有多少个 k 能够满足 ax+by+cz=k
a,b,c0;1x,y,z105;h2631

disi 表示只通过 操作 1操作 2,满足 p mod z=i 能够达到的最低楼层 p,即 操作 1操作 2 后能得到的模 z 意义下与 i 同余的最小数,用来计算该同余类满足条件的数的个数。

可以得到两种转移:

  • ix(i+x) mod z
  • iy(i+y) mod z

这相当于对 i(i+x) mod z 建了一条边权为 x 的边,对 i(i+y) mod z 建了一条边权为 y 的边。

接下里只需要求出 dis0,dis1,dis2,,disz1,只需要跑一次最短路就可求出相应的 disi

答案即为:i=0z1(hdisiz+1),加 1 是因为 disi 所在楼层也算一次。

#include <cstdio>
#include <queue>
#include <utility>
using namespace std;
typedef long long LL;
typedef pair<LL, int> PLI;
const int N = 100005;
const LL INF = 1e15;
LL dis[N];
bool vis[N];
int main()
{
LL h; int x, y, z;
scanf("%lld%d%d%d", &h, &x, &y, &z);
for (int i = 0; i < z; i++) dis[i] = INF;
dis[1 % z] = 1;
priority_queue<PLI, vector<PLI>, greater<PLI>> q; // <dis, id>
q.push({1, 1 % z});
while (!q.empty()) {
int u = q.top().second; q.pop();
if (vis[u]) continue;
vis[u] = true;
// +x
int nxt = (u + x) % z;
if (dis[u] + x < dis[nxt]) {
dis[nxt] = dis[u] + x;
q.push({dis[nxt], nxt});
}
// +y
nxt = (u + y) % z;
if (dis[u] + y < dis[nxt]) {
dis[nxt] = dis[u] + y;
q.push({dis[nxt], nxt});
}
}
LL ans = 0;
for (int i = 0; i < z; i++)
if (dis[i] <= h && dis[i] != INF) ans += (h - dis[i]) / z + 1;
printf("%lld\n", ans);
return 0;
}

习题:[ABC077D] Small Multiple

给定 k,求 k 的倍数中,数位和最小的那一个的数位和。(2k105)

解题思路

任意一个正整数都可以从 1 开始,按照某种顺序执行 ×10+1 两种操作得到,而其中 +1 操作的次数就是这个数的数位和。考虑最短路。

对于所有的 0xk1,从 x10x 连边权为 0 的边,从 xx+1 连边权为 1 的边。(模 k 意义下)

每个 k 的倍数在这个图中都对应从 1 号点到 0 号点的一条路径,求出最短路即可。某些路径不合法(如连续走 10+1),但这些路径产生的答案不优,不影响最终结果。

时间复杂度为 O(k)

参考代码
#include <cstdio>
#include <deque>
using namespace std;
const int N = 100005;
const int INF = 1e9;
int dis[N];
int main()
{
int k; scanf("%d", &k);
for (int i = 0; i < k; i++) {
dis[i] = INF;
}
dis[1] = 1;
deque<int> dq; dq.push_back(1);
while (!dq.empty()) {
int u = dq.front(); dq.pop_front();
// *10
int v = u * 10 % k;
if (dis[u] < dis[v]) {
dq.push_front(v); dis[v] = dis[u];
}
// +1
v = (u + 1) % k;
if (dis[u] + 1 < dis[v]) {
dq.push_back(v); dis[v] = dis[u] + 1;
}
}
printf("%d\n", dis[0]);
return 0;
}

Bellman-Ford

disi,u 表示从起点经过 i 条边到达点 u 的最短路,则有 disi,v=min{disi1,u+w(u,v)}

在没有负环的图上,最短路最多经过 n1 条边,因此第一维也只需要做到 n1

如果求至多经过 i 条边时的最短路,可以在每一轮开始前让 disi,udisi1,u

时间复杂度为 O(nm),空间可以压缩到一维。

类似于分层图,但因为没有 disi,u 更新 disi,v 的情况,所以不用优先队列,直接按层推就可以。

压维前:

for (int i = 1; i <= n; i++) {
for (int j = 1; j <= n; j++) dis[i][j] = dis[i - 1][j];
for (int j = 1; j <= n; j++)
for (Edge e : g[j])
dis[i][e.v] = min(dis[i][e.v], dis[i - 1][j] + e.w);
}

压维后:

for (int i = 1; i <= n; i++) {
for (int j = 1; j <= n; j++)
for (Edge e : g[j])
dis[e.v] = min(dis[e.v], dis[j] + e.w);
}

例题:B3601 [图论与代数结构 201] 最短路问题_1

参考代码
#include <cstdio>
#include <vector>
#include <utility>
#include <algorithm>
using ll = long long;
const int N = 2005;
const ll INF = 1e18;
std::vector<std::pair<int, int>> g[N];
ll dis[N];
int main()
{
int n, m; scanf("%d%d", &n, &m);
for (int i = 1; i <= m; i++) {
int u, v, w; scanf("%d%d%d", &u, &v, &w);
g[u].push_back({v, w});
}
for (int i = 1; i <= n; i++) dis[i] = INF;
dis[1] = 0;
for (int i = 1; i < n; i++) {
for (int j = 1; j <= n; j++)
for (auto e : g[j]) {
int v = e.first, w = e.second;
dis[v] = std::min(dis[v], dis[j] + w);
}
}
for (int i = 1; i <= n; i++) printf("%lld ", dis[i] == INF ? -1 : dis[i]);
return 0;
}

队列优化的 Bellman-Ford

回顾 Bellman-Ford 算法的计算过程,如果 3 这个点在第 2 轮最短路没发生更新,还用 3 去参与下一轮吗?因为 dis2,3dis1,3 一样,用 dis2,3 往后更新和 dis1,3 往后更新效果是一样的,而这个过程在第 2 轮已经做过了,所以不用让其参加下一轮。但要注意,这个不用是指“暂时不用”,如果某一轮它的 dis 又被更新到了那么它还要继续参加之后的下一轮。

于是可以用一个队列维护哪些点已经被更新(处于活跃状态)。

每次将最短路 dis 更新到的点放入队列,一般会用一个数组表示这个点现在在不在队列中,避免重复入队,每次入队时标记这个点在队列里了,出队时标记它不在队列里了。

常见的其它常数优化:

  • SLF:将普通队列换成双端队列,每次将入队结点距离和队首比较,如果更大则插入队尾,否则插入队首。
  • LLL:将普通队列换成双端队列,每次将入队结点和队内距离平均值比较,如果更大则插入至队尾,否则插入队首。

注意最差时间复杂度仍然是 O(nm),如果图中没有负权边还是应该考虑 Dijkstra 算法。

在一般随机图上跑不满,制作 hack 数据的方法详见知乎:如何看待 SPFA 算法已死这种说法?

例题:B3601 [图论与代数结构 201] 最短路问题_1

参考代码
#include <cstdio>
#include <algorithm>
#include <vector>
#include <queue>
#include <utility>
using ll = long long;
const int N = 2005;
const ll INF = 1e18;
std::vector<std::pair<int, int>> g[N];
ll dis[N];
bool inq[N];
int main()
{
int n, m; scanf("%d%d", &n, &m);
for (int i = 1; i <= m; i++) {
int u, v, w; scanf("%d%d%d", &u, &v, &w);
g[u].push_back({v, w});
}
for (int i = 1; i <= n; i++) dis[i] = INF;
std::queue<int> q;
q.push(1); dis[1] = 0; inq[1] = true;
while (!q.empty()) {
int u = q.front(); q.pop(); inq[u] = false;
for (auto e : g[u]) {
int v = e.first, w = e.second;
if (dis[v] > dis[u] + w) {
dis[v] = dis[u] + w;
if (!inq[v]) {
q.push(v); inq[v] = true;
}
}
}
}
for (int i = 1; i <= n; i++) printf("%lld ", dis[i] == INF ? -1 : dis[i]);
return 0;
}

判负环

一种判负环的简单做法是用一个数组 cnt 记录从起点出发的每个点的当前最短路经过了多少条边,如果超过了 n 就有负环。

每次更新一个点最短路时,该点的 cnt 等于更新它的点的 cnt1

需要注意,如果只从 1 号点出发判断负环,只能判断出 1 号点能不能走到负环,不能判整张图是不是有负环,如果想判整张图,可以新建一个虚点 0,向每个点连一条边权为 0 的边,从 0 号点出发开始判。

例题:P3385 【模板】负环

参考代码
#include <cstdio>
#include <vector>
#include <utility>
#include <queue>
const int N = 2005;
const int INF = 1e9;
std::vector<std::pair<int, int>> g[N];
int dis[N], cnt[N];
bool inq[N];
void solve() {
int n, m; scanf("%d%d", &n, &m);
for (int i = 1; i <= n; i++) {
g[i].clear(); dis[i] = INF; inq[i] = false; cnt[i] = 0;
}
for (int i = 1; i <= m; i++) {
int u, v, w; scanf("%d%d%d", &u, &v, &w);
g[u].push_back({v, w});
if (w >= 0) g[v].push_back({u, w});
}
std::queue<int> q;
q.push(1); dis[1] = 0; inq[1] = true;
while (!q.empty()) {
int u = q.front(); q.pop(); inq[u] = false;
for (auto e : g[u]) {
int v = e.first, w = e.second;
if (dis[u] + w < dis[v]) {
dis[v] = dis[u] + w;
cnt[v] = cnt[u] + 1;
if (cnt[v] >= n) {
printf("YES\n"); return;
}
if (!inq[v]) {
q.push(v); inq[v] = true;
}
}
}
}
printf("NO\n");
}
int main()
{
int t; scanf("%d", &t);
for (int i = 1; i <= t; i++) solve();
return 0;
}

差分约束系统

用于求解 n 个未知数 x1xnm 个形如 xuxvw 也就是 xuxv+w 的不等式的解。

两种建图方式:

  1. vu 连边权为 w 的有向边,建立虚点 0 向每个点连边权为 0 的有向边,从点 0 开始跑最短路,得到的是每个 xu0xu 的最大值,如果图中有负环则无解。
  2. uv 连边权为 w 的有向边,建立虚点 0 向每个点连边权为 0 的有向边,从点 0 开始跑最长路,得到的是每个 xu0xu 的最大值,如果图中有负环则无解。

例题:P5960 【模板】差分约束

参考代码
#include <cstdio>
#include <vector>
#include <queue>
#include <utility>
const int N = 5005;
const int INF = 1e9;
std::vector<std::pair<int, int>> g[N];
int cnt[N], dis[N];
bool inq[N];
int main()
{
int n, m; scanf("%d%d", &n, &m);
for (int i = 1; i <= m; i++) {
int c1, c2, y;
scanf("%d%d%d", &c1, &c2, &y);
g[c2].push_back({c1, y});
}
for (int i = 1; i <= n; i++) {
dis[i] = INF;
g[0].push_back({i, 0});
}
std::queue<int> q;
q.push(0); dis[0] = 0; inq[0] = true;
while (!q.empty()) {
int u = q.front(); q.pop(); inq[u] = false;
for (auto e : g[u]) {
int v = e.first, w = e.second;
if (dis[u] + w < dis[v]) {
dis[v] = dis[u] + w;
cnt[v] = cnt[u] + 1;
if (cnt[v] >= n + 1) { // 注意多了一个虚点0
printf("NO\n"); return 0;
}
if (!inq[v]) {
q.push(v); inq[v] = true;
}
}
}
}
for (int i = 1; i <= n; i++) printf("%d ", dis[i]);
return 0;
}

例题:P1993 小 K 的农场

解题思路
  1. xaxbc 实际上就是 xbxac,可以让 ab 连一条边权为 c 的边
  2. xaxbc 是差分约束系统的标准形式,让 ba 连一条边权为 c 的边
  3. xa=xb 也可以转化成不等式关系,即 xbxa0xaxb0,让 ab 之间互相连边权为 0 的边
参考代码
#include <cstdio>
#include <vector>
#include <utility>
#include <queue>
const int N = 5005;
const int INF = 1e9;
std::vector<std::pair<int, int>> g[N];
int cnt[N], dis[N];
bool inq[N];
int main()
{
int n, m; scanf("%d%d", &n, &m);
for (int i = 1; i <= m; i++) {
int t, a, b, c; scanf("%d%d%d", &t, &a, &b);
if (t != 3) scanf("%d", &c);
if (t == 1) g[a].push_back({b, -c});
else if (t == 2) g[b].push_back({a, c});
else {
g[a].push_back({b, 0}); g[b].push_back({a, 0});
}
}
for (int i = 1; i <= n; i++) {
g[0].push_back({i, 0}); dis[i] = INF;
}
std::queue<int> q;
q.push(0); inq[0] = true; dis[0] = 0;
while (!q.empty()) {
int u = q.front(); q.pop(); inq[u] = false;
for (auto e : g[u]) {
int v = e.first, w = e.second;
if (dis[u] + w < dis[v]) {
dis[v] = dis[u] + w;
cnt[v] = cnt[u] + 1;
if (cnt[v] >= n + 1) {
printf("No\n"); return 0;
}
if (!inq[v]) {
q.push(v); inq[v] = true;
}
}
}
}
printf("Yes\n");
return 0;
}
posted @   RonChen  阅读(219)  评论(0编辑  收藏  举报
相关博文:
阅读排行:
· 阿里最新开源QwQ-32B,效果媲美deepseek-r1满血版,部署成本又又又降低了!
· 单线程的Redis速度为什么快?
· SQL Server 2025 AI相关能力初探
· AI编程工具终极对决:字节Trae VS Cursor,谁才是开发者新宠?
· 展开说说关于C#中ORM框架的用法!
点击右上角即可分享
微信分享提示