【普及】最短路专项训练

题单介绍

- **[【旧版】$2019$ 年初版本的最短路讲解](https://www.luogu.com.cn/blog/LinearExpectation/shortest-path-old-version)** - **[【新版】$2021$ 年初版本的最短路讲解](https://www.luogu.com.cn/blog/LinearExpectation/shortest-path-in-another-sight)** 日后会有分层图的内容,当然你也可以直接去搜分层图题单 求收藏qaq ## 前言 Floyd,Bellman-Ford,SPFA,Dijkstra,Johnson,是图论路上绕不去的坑,这一篇博文应该不会介绍 Johnson 最短路算法了,而会重点介绍其余四种的思想内涵。 思想与方法往往比代码的实现更重要,对于一道题目,我们首先认识到他所对应的算法,然后我们应该用自己的大脑来思考(~~而不是用别人的~~)这道题应该按照怎样的逻辑顺序来进行操作——考虑如何通过具体的实现来达到要求的功能,而不是考虑如何修改模板以通过此题——后者往往会是一个痛苦的过程。 该说的话还是要说的,**在阅读本文前,请确保您基本理解 [图论相关概念](https://oi-wiki.org/graph/concept/) 中的大部分内容**,同时也建议您记忆学习一下 **[笔记:四种存图方式](https://www.luogu.com.cn/blog/LinearExpectation/Ways-of-storing-graphs)** 以便理解菜鸡的代码。 参考文献(迫真): - https://www.luogu.com.cn/blog/ltzlInstallBl/post-suan-fa-bi-ji-dijkstra - https://www.cnblogs.com/bigsai/p/11537975.html - https://visualgo.net/zh/sssp 常见问题: - 为什么要 `#define int long long`?因为 `0x7fffffff` 可能会溢出。 ## Floyd-Warshall:暴力出奇迹 这是一个全源最短路算法。 是的,这意味着我们可以通过他求得所有节点之间的最短路,怎么办呢?事实上,我们可以考虑一个很暴力的办法,接下来我们从输入开始。 我们会读入 $m$ 条边,那么先用邻接矩阵存储起来(由于 Floyd 的时间复杂度是 $O(n^3)$ 的,所以不用担心 $n^2$ 的空间复杂度,并且这里为了存储所有最短路也应该得使用 $O(n^2)$ 的邻接矩阵),接下来,我们将会以 $e_{u,v}$ 表示从 $u$ 到 $v$ 的一条有向边。一开始,他们要么是 $0$,即自己到自己($e_{x,x}$);要么是 $\infty$,即没有通路;要么是两点之间的直接道路。 首先对于从 $u$ 到 $v$ 的一条路,如果想要找到最短路,我们肯定要试一试其他的边,也就是“不止经过这两个边直接路径”的路径,那我们假设存在一个桥梁 $k$。 如果 $e_{u,k}+e_{k,v}$ 的长度会小于 $u$ 到 $v$ 的长度,就意味着我们能够更新这个节点了。那么就更新,这样子,我们按顺序更新完所有的节点,就可以获得到一张完整的图——一个被填满的 $e$ 数组,即所有节点之间的最短路。 此时就处理完毕了,注意循环枚举 $k$ 在最外层,内层的 $u$ 和 $v$ 没有顺序要求,一共三层循环,每层循环到 $n$ 为止,所以时间复杂度是 $O(n^3)$ 的。 [【模板】单源最短路径(弱化版)](https://www.luogu.com.cn/problem/P3371) 可以拿到 $70$ 分的 Floyd 程序。 ```cpp #include<bits/stdc++.h> #define int long long #define INF 0x7fffffff using namespace std; int n,m,s; int edge[10005][10005]; void Floyd(){ for(int k=1;k<=n;k++){ for(int i=1;i<=n;i++){ if(i==k||edge[i][k]==INF)continue; for(int j=1;j<=n;j++){ edge[i][j]=min(edge[i][j],edge[i][k]+edge[k][j]); } } } } signed main(){ scanf("%lld%lld%lld",&n,&m,&s); for(int i=1;i<=n;i++) for(int j=1;j<i;j++) edge[i][j]=edge[j][i]=INF; for(int i=1;i<=m;i++){ int u,v,len; scanf("%lld%lld%lld",&u,&v,&len); edge[u][v]=min(edge[u][v],len); }Floyd(); for(int i=1;i<=n;i++){ cout<<edge[s][i]<<' '; }return 0; } ``` 适用的前提是比必须存在最短路,即不能有负环。 ## Bellman-Ford:简单且清晰 我们发现,如果像 Floyd 那样“撒大网捕小鱼”,效率是很低的,所以我们会想要省略一下无用的内容——我们不如更新所有节点的最短路时,就只更新和 $s$ 相关的吧!那我们又该怎么操作呢? Bellman-Ford 的解决思路是:“所有的最短路都由边组成。”也就是说,如果我们每次松弛 $m$ 条边,就至少能确定一个结点的最短路,这样松弛 $n$ 次之后,我们就能够得到一张完整的“关于 $s$ 的最短路表”了。 因为不知道该用哪一种方法存图(你现在被迫假装不知道),我们就先用邻接矩阵试一试吧(好写,虽然你会发现正解更好写)! 我们首先需要一个 $k$ 循环 $n$ 次,控制我们松弛的轮数;再来一个二维的循环 $i,j$ 遍历每一条边,每次“如果走了从 $s$ 到 $x$ 的当前最短路+从 $x$ 到 $y$”比“直接走当前 $y$ 的最短路”更快,那最短路就应该要更新了!这被称为一次松弛($\rm\small relax$)。 代码如下,容易理解: ```cpp #include<bits/stdc++.h> #define INF 0x7fffffff #define MAXN 10005 #define int long long using namespace std; int edge[MAXN][MAXN],dis[MAXN]; int n,m,s; void BellmanFord(){ dis[s]=0; for(int k=1;k<=n;k++){//一共要进行 n 轮松弛 for(int i=1;i<=n;i++){ for(int j=1;j<=n;j++){ dis[i]=min(dis[i],dis[j]+edge[j][i]); } } } }signed main(){ cin>>n>>m>>s; for(int i=0;i<=n;i++)dis[i]=INF; for(int i=0;i<=n;i++) for(int j=0;j<=n;j++)edge[i][j]=INF; for(int i=0;i<m;i++){ int u,v,w; cin>>u>>v>>w; edge[u][v]=min(edge[u][v],w); }BellmanFord(); for(int i=1;i<=n;i++)cout<<dis[i]<<' '; return 0; } ``` 这时候你发现,这个效率好低啊,怎么看着也是 $O(n^3)$ 啊……不假,确实这种方法效率十分低下,在模板(弱化)中拿到的分数仅 $40$ 分,比 Floyd 还低。为什么? 原因就在于我们遍历了大量无用的边,试想你要遍历 $n^2$ 条边,而总共只有 $m$ 条边,对于稀疏图(或随机数据)来说,基本满足 $m\ll n^2$,这意味着我们应该用一种能够遍历所有边而不浪费时间的方式。 什么方式呢?邻接表?链式前向星? 错,直接存边。 这样我们就能遍历这 $m$ 条边,而这样的方法复杂度是 $O(mn)$,也就是 $n$ 轮松弛每次 $m$ 条边。这样的方法(大常数)能在弱化版获得 $70$ 分,和 Floyd 一样(比 Floyd 期望分数高)。 代码: ```cpp #include<bits/stdc++.h> #define INF 0x7fffffff #define MAXN 10005 #define int long long using namespace std; struct edge{ int u,v,w; }e[500005]; int edge[MAXN][MAXN],dis[MAXN]; int n,m,s; void BellmanFord(){ dis[s]=0; for(int k=1;k<=n;k++){//一共要进行 n 轮松弛 for(int i=1;i<=m;i++){ int u=e[i].u,v=e[i].v,w=e[i].w; dis[v]=min(dis[v],dis[u]+w); } } }signed main(){ scanf("%lld%lld%lld",&n,&m,&s); for(int i=0;i<=n;i++)dis[i]=INF; for(int i=1;i<=m;i++){ int u,v,w; scanf("%lld%lld%lld",&u,&v,&w); e[i].u=u;e[i].v=v;e[i].w=w; }BellmanFord(); for(int i=1;i<=n;i++)cout<<dis[i]<<' '; return 0; } ``` 但是这样凌乱地松弛(跳来跳去),总感觉有些混乱和浪费啊,难道我们不能做到“有目的”地松弛吗?我们为什么要去松弛那些“相关节点”都没有被更新过的边啊,这也不能得到任何新的结果啊? 诶,对了,这启发了我们。 ## SPFA:虽死犹光荣 **算法概括**:每一次松弛之后,只有与松弛结点 $x$ 相邻的节点(的最短路长度)才可能会被更新,于是我们可以开始松弛这个节点。那么,我们改如何安排我们松弛的顺序呢? 用队列——是的,SPFA 是 Bellman-Ford 的队列优化,通过队列保证我们每次进行松弛都有结果,这样我们就能获得一个(上界复杂度不变的)优化算法。我们的操作是这样的:每次松弛一个节点,就将他的相邻节点入队,然后取出队头进行操作。 接下来我们详细地进行讲解: Bellman-Ford 中有相当一部分操作事实上是无用的,核心思想是在每一轮松弛中更新**所有节点**到起点,而这带来了一个问题,对于正在计算的节点 $u$,如果紧接着计算他的相邻节点,那么一定会有一个结果——无论是否更新,都完成了一次**有效的松弛**。这种操作可以加速收敛的过程,而进一步思考:有哪些节点需要入队? 我们应该把最短路状态有变化的节点入队,因为只有他们才会影响我们下一步的更新。而此时出现了一个问题——一个入过队的节点 $u$,可能在之后的松弛中再次更新,这一点直接影响了这一算法的效率:反复入队的节点可能很少也可能很多,这取决于图的特征,并且: > 即使两个图的节点和边数完全一样,仅仅是几条边的权值不同,他们的 SPFA 队列也会差距很大,如此感性上理解,**SPFA 是不稳定的**。 这里给出 SPFA 的代码: ```cpp #include<bits/stdc++.h> #define int long long using namespace std; const int MAXN=1000005; struct edge{ int to,nxt,w; }e[MAXN]; int n,m,cnt,s,head[MAXN]; int dis[MAXN]; bool inq[MAXN];//inq[i]=1 表示 i 在队列里 void init(){ for(int i=0;i<MAXN;i++){ e[i].nxt=-1; head[i]=-1; inq[i]=0; dis[i]=0x7fffffff; }//初始化链式前向星和入队标志 }void addedge(int u,int v,int w){ e[cnt].to=v; e[cnt].w=w; e[cnt].nxt=head[u]; head[u]=cnt++; }void SPFA(){ dis[s]=0;queue<int>q; q.push(s);inq[s]=1; while(!q.empty()){ int u=q.front();q.pop(); inq[u]=0; for(int i=head[u];~i;i=e[i].nxt){ int v=e[i].to,w=e[i].w; if(dis[u]+w<dis[v]){//如果可以更短 dis[v]=dis[u]+w;//松弛 if(!inq[v]){//如果不在队里 inq[v]=1;//就入队 q.push(v); } } } } }signed main(){ scanf("%lld%lld%lld",&n,&m,&s); init();//初始化 for(int i=0;i<m;i++){ int u,v,w; scanf("%lld%lld%lld",&u,&v,&w); addedge(u,v,w); }SPFA(); for(int i=1;i<=n;i++)printf("%lld ",dis[i]); return 0; } ``` 这个程序能通过弱化数据版本,强化数据版本为 $32$ 分,改成优先队列时 $52$ 分,改成栈是 $68$ 分,但是栈版本不能通过弱化数据。建议大家不要乱改,有时会有严重的后果。 先给大家看一段话: > 众所周知 SPFA 可以看做是 Bellman-Ford 的队列优化。 Bellman-Ford 每轮松弛会使最短路的边数至少 $+1$,而最短路的边数最多为 $n−1$,则其复杂度上界是稳定的 $O(nm)$ 的。 > > ——[「笔记」如何卡 Spfa](https://www.cnblogs.com/luckyblock/p/14317096.html) 但是 SPFA 能被卡,能被卡到 Bellman-Ford 的复杂度,我们先讲原因,再讲方法: > 究其原因,要从 SPFA 是 Bellman-ford 的优化说起。在 $n$ 个点 $m$ 条边的图中,Bellman-ford 的复杂度是 $n\times m$,依次对每条边进行松弛操作,重复这个操作 $n-1$ 次后则一定得到最短路,如果还能继续松弛,则有负环。这是因为最长的没有环路的路,也只不过是 $n$ 个点 $n-1$ 条边构成的,所以松弛 $n-1$ 次一定能得到最短路。 > > SPFA 的意义在于,如果一个点上次没有被松弛过,那么下次就不会从这个点开始松弛。每次把被松弛过的点加入到队列中,就可以忽略掉没有被松弛过的点。 > > 但是最外层的循环还是 $n-1$ 次。如果把被松弛的点放到前边,他相当于没有进行完这一轮松弛,就开始了一些其他的操作。但是这些其他的操作可能是无用的,因为这些操作的起始点可能还会被这一轮松弛更新。 > > 所以传统的 SPFA 的复杂度不会超过 $n\times m$,并且每个点都不会第 $n$ 次入队。 渐进意义上,他的复杂度依然是 $O(nm)$,一共只有 $m$ 条边所以每个节点最多只会入队 $m$ 次。SPFA 在本质上只是改变的入队的顺序,其复杂度上界依然是 $O(nm)$,接下来介绍卡法([参考资料](https://www.zhihu.com/question/292283275)): **普通 SPFA**: 链套菊花,可以让这个菊花节点反复被入队,造成时间的大量浪费。或者我们可以构造一个具有大量次短路的网格图,使得 SPFA 容易一错到底,即“在网格图中走错一次路可能导致很高的额外开销”。我们可以考虑构造一个网格图套菊花,或者把两种方法结合起来,放在同一个 Subtask 中。 **LLL 优化**: 方法是比较入队的边权与 $ave$(平均值),如果更大则插入到队尾。卡死的方法是向 $1$ 连接一条权值极大的边。 **NTR 优化**: https://www.zhihu.com/question/268382638/answer/337778164 没看懂,将就着看看吧。 **SLF 优化**: 极其广为人知的优化,每次将入队结点距离和队首比较,如果更大则插入至队尾。依然可以用链套菊花的方式卡,“链上用几个并列在一起的小边权”。带容错的版本依然可以通过高边权和的方式卡死。 如果是 $\rm swap$ 版本的“每当队列改变时,如果队首距离大于队尾,则交换首尾。” 也可以用类似的方法卡死。 那么,我们怎么样能够把它卡到 $2^n$ 级别呢(有负权的情况)?这就可以参考 [\[HDOJ 4889\] Scary Path Finding Algorithm \[SPFA\]](https://blog.csdn.net/jinzhao1994/article/details/38311561?depth_1-) 的方案,可以发现,SLF 其实不是一个优化,反而在某些情况下会起到极其恐怖的负优化效果,“它丢掉了一轮一轮松弛的这个特性,导致复杂度可能呈指数级上升”。 这里给出一个结论(建议): - Dijkstra 是正权图上数学严格证明的最优算法,优先学习。 - 写 SPFA 的时候当做 Bellman-Ford,其**复杂度并非更优**而是随机图跑得快而已。 为什么我们说 Dij 稳定?因为他带有标记,每个节点的计算次数是有限度的,并且从 $O(n^2)$ 优化到 $O((m+n)\log n)$ 是不难的。 ## Dijkstra:正统最短路 ### Dijkstra 基础 现在我们很有钱,一共有 $n$ 个节点,$m$ 条边(大概是有向边),以及一个初始节点 $s$,要求我们求出从 $s$ 到其他所有节点的最短路,怎么办呢? 首先我们需要一个数组 $d$,用于存储从 $s$ 到 $1\sim n$ 的最短路长度,表示为 $d_1\sim d_n$。于此类似的,我们需要一个 $e$ 数组来存储整张图,$e_{u,v}$ 表示从 $u$ 到 $v$ 的路径长度。此外,我们还需要一个数组 $vis$ 来确定从 $s$ 到一个节点的最短路是否被确定了。 首先,我们可以把 $s$ 的所有出边都加入到 $d$ 数组中,譬如这个图: ![](https://cdn.luogu.com.cn/upload/image_hosting/d3qg0843.png) 如果令 $s=1$,那么它的 $d=\{0,5,3,2,\infty,\infty\}$ 接着我们需要在这个数组中找到最小的那个节点——也就是离出发点最近的那个节点,你会发现,这个记得点是 $4$,那么我们考虑走向 $4$ 号节点。 这时这个 $4$ 号节点的最短路就已经确定了,为什么?因为如果走任何一条其他路,刚走第一步就会比这条最短路要长,所以,直接走到 $4$ 必然比绕其他的路要来的短。那么,我们标记 $4$ 为已经确定最短路的点 接着我们要做的是考察 $4$ 号节点的所有出边,这时你需要看一看的是,如果我们从 $4$ 经过再到某个节点 $x$,会不会比直接从 $1$ 到 $x$ 更近?如果是的,那么更新最短路就可以了。 你发现 $1\to 4\to 5$ 长度为 $8$,比 $d_5=\infty$ 要小多了,那么更新吧!此时的的 $d=\{0,5,3,2,8,\infty\}$ 这时,未确定最短路的点中,最小的是 $3$ 了,那就走向 $3$ 吧,并且把他标记为已经确定。$3$ 的出边指向 $4,5$,此时执行如下: - $(1\to 3\to 4)>(1\to 4)$,不需要更新。 - $(1\to 3\to 5)<(1\to 4\to 5)$,需要更新。 这时候发现有一条路比原有的 $d_5$ 更小!那么就可以更新了更新完是这样:$d=\{0,5,3,2,7,\infty\}$。此时的 $vis$ 数组中 $1,3,4$ 都已经被确定了。我们把目光投向 $2$。 继续更新!此时的 $2$ 一共有 $3,6$ 两个出边,作类似的判断之后我们发现只有 $6$ 可以更新,那么 $d=\{0,5,3,2,7,10\}$,$vis={1,2,3,4}$。 接着的更新内容就简单地概述了: - 看向 $5$ 号节点。 - 更新不了节点,$5$ 号设为确定。 - 看向 $6$ 号节点。 - 更新不了节点。 算法结束。这时候你会发现,有一部分边是没有走过的,我们只遍历了一些点,并且一直在走“权值最小”的那条路。这似乎是贪心,那么,他为什么正确呢? 对于一个不含负边的图,全局最小值不可能再被其他节点更新,因为每当 $x$ 被纳入了 $vis$ 数组,他的最短路就已经确定了,正如前文所说:“如果走任何一条其他路(走已经更新的最短路),所使用长度必然的比当前这条最短路要长,所以,现在这条老路不可能再被其他已确定最短路更长的路径更新了。” 由于这种算法不包含后悔的情况,所以不能应对含负权边的图。 由于有 $n$ 个节点,每个节点必然走过一次,且每次都需要在 $n$ 个 $d_i$ 中找最小值,且每次都要在找所有的出边,所以时间复杂度是 $O(n^2)$ 的。 ### Dijkstra 优化 我们发现,在 $d$ 数组中找到最小值的这个过程可以优化,怎么优化呢? 如果用堆优化是 $O(m\log n)$,如果用优先队列时 $O(m\log m)$。但是也有说法说堆优化是 $O((n+m)\log n)$,似乎还挺优秀的。 由于 $m$ 最大时可以达到 $n^2$ 级别,所以时间复杂度看着办吧。似乎对于普通的数据并不容易出现 $m=n^2$ 这种情况(稠密图),所以对于随机数据,你可能可以选择卡常而不优化,但是面对刻意为之的数据,你不得不承认:这个优化是广泛而且有效的。 接下来的代码用链式前向星存图,如果不会请回到本文开头找资料。 首先你需要一个 $\rm\small node$ 类型,这种类型包含两个信息:$\rm\small id,dis$,分别是上文所说的“下标”和“距离起点的长度”。下标也就是这个节点的编号。接着我们重载运算符,让 $<$ 比较的“依据”是距离起点的长度,这样我们导入优先队列时,就自然按照距离排序,我们也就能够很容易取出“距离最短”的那个点了。 这一段的参考代码: ```cpp struct node{ int dis,id; bool operator <(const node &x)const{ return x.dis<dis; }//重载运算符,比较节点的方式是比较其相距起点的距离 //便于之后使用优先队列比较 }; ``` 然后我们每次取出队头、弹出就行了。注意一些小优化: - 处理 $x$ 节点时,如果他已经被处理过,就跳过。 为什么“已经确定”的节点还会再遇到一次?这是因为优先队列只能对队头进行操作,不能够实现删除任意元素,所以我们每次松弛都要压入。那这样为什么不会影响正确性呢?因为我们每次取出节点,都是取它“所有在队列中值”最小的那个,而既然“最小”,必定更符合要求,所以是正确的的。 - 遍历 $x$ 的出边 $y$ 的时候,如果处理过,就跳过。 没什么有用的优化,但是看着代码逻辑更清晰。 这里给出全文代码,可以结合注释理解。 ```cpp #include<bits/stdc++.h> using namespace std; #define int long long #define MAXN 1000005 #define MAXM 2000005 #define INF 0x7fffffff struct edge{ int to,w,nxt; }e[MAXM]; int head[MAXN],dis[MAXN]; int n,m,s,cnt; bool sure[MAXN];//通往该节点的最短路是否确定 struct node{ int dis,id; bool operator <(const node &x)const{ return x.dis<dis; }//重载运算符,比较节点的方式是比较其相距起点的距离 //便于之后使用优先队列比较 }; void addedge(int u,int v,int w){ e[cnt].w=w;//权值为 w e[cnt].to=v;//终点为 v e[cnt].nxt=head[u];//接到 u 的 "邻居链表" 开头 head[u]=cnt++;//把 "开头 " 更新为这条边 }void init(){//初始化 for(int i=0;i<=n;i++){ head[i]=-1;//尚未连边 e[i].nxt=-1;//每个节点都没连边 }cnt=0;//一条边都没有 }priority_queue<node>q; void Dijkstra(){ dis[s]=0;q.push((node){0,s});//压入起点 while(!q.empty()){ node cur=q.top();q.pop();//当前处理的节点 int x=cur.id,d=cur.dis; if(sure[x])continue;//如果这个节点的最短路已经确定,跳过 sure[x]=1;//否则标记为确定,并且开始着手确定 for(int i=head[x];~i;i=e[i].nxt){//遍历出边 int y=e[i].to;//取出这条边 if(sure[y])continue;//如果取出了,就跳过 if(dis[y]>dis[x]+e[i].w){//如果路径更短 dis[y]=dis[x]+e[i].w;//松弛 q.push((node){dis[y],y});//加入队列 } } } }signed main(){ scanf("%lld%lld%lld",&n,&m,&s); init();//初始化 for(int i=0;i<=n;i++)dis[i]=INF;//全部设为 INF for(int i=0;i<m;i++){ int u,v,w; scanf("%lld%lld%lld",&u,&v,&w); addedge(u,v,w); }Dijkstra(); for(int i=1;i<=n;i++)printf("%lld ",dis[i]); return 0; } ``` 之所以能应对重边,因为你存储的时候会把两个边都存下来。

题目列表

  • [USACO08OPEN] Clear And Present Danger S
  • 【模板】单源最短路径(弱化版)
  • 道路重建
  • 赚钱
  • 采购特价商品
  • 最短路计数
  • 拉近距离
  • 采蘑菇
  • 小明的游戏
  • [SDOI2010] 大陆争霸
  • [APIO2011] 寻路
  • 【模板】单源最短路径(标准版)
  • [USACO07NOV] Cow Hurdles S
  • 无向图的最小环问题
  • [USACO19DEC] Milk Pumping G
  • [POI2009] WIE-Hexer
  • [NOI2007] 社交网络
  • [USACO09DEC] Cow Toll Paths G
  • [JSOI2015] 地铁线路
  • 邮递员送信
  • 基础最短路练习题
  • [NOIP2012 普及组] 文化之旅
  • 逃离僵尸岛
  • [NOI2018] 归程