最短路算法
Posted lpf-666
tags:
篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了最短路算法相关的知识,希望对你有一定的参考价值。
目录
最短路算法
简述
最短路是一种及常见的算法,在OI考试及日常生活中,都很常见,也是图论学习的初步算法。
牢固掌握最短路算法,是极为重要的。
我们掌声有请代码天团最短路 (F4) 闪亮登场!【尖叫声】
好,现在你已经认识它们了,我们来逐一学习吧( ̄▽ ̄)/
常见的最短路算法有以下几种:
●Floyd算法:
- 多源最短路,求出所有点对的最短路长度
- 时间复杂度:(O(n3))
● Dijkstra算法:
- 单源最短路,求出某个点s到所有点的最短路长度
- 时间复杂度:(O(n2)/O(m log n))
- 无法处理负权
● SPFA算法,即队列优化的Bellman-Ford算法:
- 单源最短路,求出某个点s到所有点的最短路长度
- 时间复杂度:声称为(O(m)),最坏(O(nm)),容易卡到最坏
- 可以处理负权边,可以判断负权环
松弛操作
松弛操作:通过某条路径更新dis[v]的值
- $if (dis[v] > dis[u] + e.dist) dis[v] = dis[u] + e.dist $
- 尝试使用s到u的最短路加上边(u,v)的长度来更新s到v的最短路
几乎是所有最短路(单源)算法的核心。
Floyd算法
算法原理
Floyd算法是一个经典的动态规划算法。
用通俗的语言来描述的话,首先我们的目标是寻找从点i到点j的最短路径。
从动态规划的角度看问题,我们需要为这个目标重新做一个诠释。(这个诠释正是动态规划最富创造力的精华所在)
实现方式
从任意节点i到任意节点j的最短路径不外乎2种可能,1是直接从i到j,2是从i经过若干个节点k到j。
所以,我们假设(Dis(i,j))为节点u到节点v的最短路径的距离,
对于每一个节点k,我们检查(Dis(i,k) + Dis(k,j) < Dis(i,j))是否成立,
如果成立,证明从i到k再到j的路径比i直接到j的路径短,我们便设置(Dis(i,j) = Dis(i,k) + Dis(k,j)),
这样一来,当我们遍历完所有节点k,(Dis(i,j))中记录的便是i到j的最短路径的距离。
代码实现
只有5行,简单易懂,但新手容易写错的地方是枚举顺序,一定是先中间节点(k),再枚举(i,j)
1 for(k=1;k<=n;k++)
2 for(i=1;i<=n;i++)
3 for(j=1;j<=n;j++)
4 if(e[i][j]>e[i][k]+e[k][j])
5 e[i][j]=e[i][k]+e[k][j];
传递闭包
在交际网络中,给定若干个元素和若干个二元对关系,且关系具有传递性,
“通过传递性推导出更多的元素之间的关系” 被称为传递闭包。
建立邻接矩阵,d,其中 (d(i,j)=1) 表示 (i) 与 (j) 有关系,(d(i,j)=0) 表示 (i) 与 (j) 没有关系,特别的,(d(i,i)=1) 。
使用 Floyd 算法可以解决传递闭包问题,代码如下:
#include<cstdio>
#include<algorithm>
#include<iostream>
#include<cstring>
#include<cmath>
#define maxn 330
using namespace std;
int n,m;
bool d[maxn][maxn];
int main(){
scanf("%d %d",&n,&m);
for(int i=1;i<=n;i++) d[i][i]=true;
for(int i=1;i<=m;i++){
int x,y;
scanf("%d %d",&x,&y);
d[x][y]=d[y][x]=true;
}
for(int k=1;k<=n;k++){
for(int i=1;i<=n;i++){
for(int j=1;j<=n;j++){
d[i][j]|=d[i][k]&d[k][j];//核心代码
}
}
}
for(int i=1;i<=n;i++){
for(int j=1;j<=n;j++){
if(d[i][j]) printf("%d %d
",i,j);
}
}
return 0;
}
这样就完美解决了这个问题(没学之前,有一次老师出了原题,我考试后自闭了)
实际应用
由于Floyd的时间复杂度并不优秀,它在实际应用中往往只起到思想启蒙的作用。
我们可以用Floyd的思想来计算一些题目。(通常只要发现是Floyd的思想,代码实现十分简单)
当然如果是需要模板题的话,可以看这里。
想要加深理解的话,看这道题
Dijkstra算法
算法原理
本算法基于贪心思想,并不适用于有负权图中。
设G=(V,E)是一个带权有向图,把图中顶点集合V分成两组,第一组为已求出最短路径的顶点集合。
(用S表示,初始时S中只有一个源点,以后每求得一条最短路径 , 就将加入到集合S中,直到全部顶点都加入到S中)
第二组为其余未确定最短路径的顶点集合(用U表示),按最短路径长度的递增次序依次把第二组的顶点加入S中。
在加入的过程中,总保持从源点v到S中各顶点的最短路径长度不大于从源点v到U中任何顶点的最短路径长度。
此外,每个顶点对应一个距离,S中的顶点的距离就是从v到此顶点的最短路径长度。
U中的顶点的距离,是从v到此顶点只包括S中的顶点为中间顶点的当前最短路径长度。
以上原理没看懂没有关系,主要是以下的实现方式
实现方式
- 初始化(dist[1]=0),其余节点的 (dist) 的值为正无穷大。
- 找出一个未被标记的点、(dist[x]) 最小的节点 (x) ,然后标记节点 (x) 。
- 扫描节点 (x) 的所有出边 ((x,y,z)) ,若 (dist[y]>dist[x]+z) ,则使用 (dist[x]+z) 更新 (dist[y]) 。
- 重复以上 2,3 两个步骤,直到所有节点都被标记。
代码实现
未加优化的算法如下,时间复杂度 (O(n^2))。
#include<cstdio>
#include<algorithm>
#include<iostream>
#include<cstring>
#include<cmath>
#define N 10010
#define M 500010
#define maxd 2147483647
using namespace std;
int n,m,s,dis[N];
bool use[N];
int head[M],cnt=0;
struct node{
int next,to,val;
}edge[M];
void addedge(int x,int y,int z){
cnt++;
edge[cnt].next=head[x];
edge[cnt].to=y;
edge[cnt].val=z;
head[x]=cnt;
return;
}
void dij(){
for(int i=1;i<=n;i++) dis[i]=maxd;
memset(use,false,sizeof(use));
dis[s]=0;
for(int i=2;i<=n;i++){
int minn=maxd,k;
for(int j=1;j<=n;j++){
if(!use[j]&&minn>dis[j]){minn=dis[j];k=j;}
}//寻找全局最小值
use[k]=true;
for(int j=head[k];j;j=edge[j].next){
int go=edge[j].to;
if(use[go]) continue;
dis[go]=min(dis[go],dis[k]+edge[j].val);
}
}
return;
}
int main(){
scanf("%d %d %d",&n,&m,&s);
int u,v,w;
for(int i=1;i<=m;i++){
scanf("%d %d %d",&u,&v,&w);
addedge(u,v,w);
}
dij();
for(int i=1;i<=n;i++) printf("%d ",dis[i]);
//system("pause");
return 0;
}
那么我们考虑怎么优化呢?
我们可以发现,上面程序的主要瓶颈在于寻找全局最小值的过程(见注释)
所以我们可以用一个小根堆进行维护,用 (O(log n)) 的时间获取最小值,并用 (O(log n)) 的时间执行一条边的扩展更新,
最终在 (O(m log n)) 的时间内完成算法,代码如下:
#include<cstdio>
#include<algorithm>
#include<iostream>
#include<cstring>
#include<cmath>
#include<queue>
#define N 100010
#define M 1000010
#define maxd 2147483647
using namespace std;
int n,m,s;
int head[N],dis[N],cnt=0;
bool use[N];
struct node{
int next,to,val;
}edge[M];
priority_queue<pair<int,int> >q;
//为了避免重载小于号。
//pair的first用于存储dis[]的相反数(变小根堆),second用存储编号。
void addedge(int x,int y,int z){
cnt++;
edge[cnt].next=head[x];
edge[cnt].to=y;
edge[cnt].val=z;
head[x]=cnt;
return;
}
void dij(){
for(int i=1;i<=n;i++) dis[i]=maxd;
memset(use,false,sizeof(use));
q.push(make_pair(0,s));
dis[s]=0;
while(!q.empty()){
int now=q.top().second;
q.pop();
if(use[now]) continue;
use[now]=true;
for(int i=head[now];i;i=edge[i].next){
int y=edge[i].to;
int z=edge[i].val;
if(dis[y]>dis[now]+z){
dis[y]=dis[now]+z;
q.push(make_pair(-dis[y],y));
}
}
}
return;
}
int main(){
scanf("%d %d %d",&n,&m,&s);
int u,v,w;
for(int i=1;i<=m;i++){
scanf("%d %d %d",&u,&v,&w);
addedge(u,v,w);
}
dij();
for(int i=1;i<=n;i++) printf("%d ",dis[i]);
//system("pause");
return 0;
}
实际应用
由于时间复杂度出众,Dijkstra算法是单源最短路的常用解法之一(前提是没有负权)
温馨提示:如果你既可以用Dijkstra,又可以用(SPFA) ,请不要选择(SPFA) ,不然你将承担十年OI一场空的风险
应用范围很广,经常在题目中遇到,一定要牢牢掌握。
SPFA算法
算法原理
(SPFA) 特殊之处在于它是一个基于队列的最短路算法。
它的原理是对图进行V-1次松弛操作,得到所有可能的最短路径。
优点是边的权值可以为负数、实现简单。缺点是容易被卡。
实现方式
由于它是队列优化的 Bellman-Ford 算法,所以我们先介绍 Bellman-Ford 算法。
Bellman-Ford 算法基于迭代思想。它的流程如下:
- 扫描所有的边 ((x,y,z)) ,若 (dist[y]>dist[x]+z) 则用 (dist[x]+z) 更新 (dist[y]) 。
- 重复上述步骤,直到没有更新操作产生。
时间复杂度为 (O(nm)) 。
而 (SPFA) 算法流程如下:
- 建立一个队列,最初队列中只含有起点1。
- 取出队头节点 (x) ,扫描它的所有出边((x,y,z)),若 (dist[y]>dist[x]+z) 则用 (dist[x]+z) 更新 (dist[y]) 。
- 同时,如果 (y) 不在队列中,则把 (y) 入队。
- 重复 2~3? 步,直至队列为空。
是不是十分简单易懂?
这个队列避免了 Bellman-Ford 算法中对不需要扩展的节点的冗余扫描,在稀疏图中的效率极高。
时间复杂度很玄学,为 (O(km)) 级别,其中可以证明,一般 (kleq2) 。
但是在稠密图或特殊构造的网络图(良心出题人),该算法可能退化成 (O(nm)) 。
所以没有负权时用Dijkstra算法,有负权时用 (SPFA) 算法(这个时候他还卡你 (SPFA) 就没道理了)
代码实现
普通的 (SPFA) 算法实现简单,代码如下:
#include<cstdio>
#include<algorithm>
#include<iostream>
#include<cmath>
#include<cstring>
#include<queue>
#define N 100010
#define M 1000010
#define maxd 2147483647
using namespace std;
int n,m,s;
int head[N],cnt=0,dis[N];
bool use[N];
struct node{
int next,to,val;
}edge[M];
queue<int>q;
void addedge(int x,int y,int z){
cnt++;
edge[cnt].next=head[x];
edge[cnt].to=y;
edge[cnt].val=z;
head[x]=cnt;
return;
}
void spfa(){
for(int i=1;i<=n;i++) dis[i]=maxd;
memset(use,false,sizeof(use));
use[s]=true;
dis[s]=0;
q.push(s);
while(!q.empty()){
int now=q.front();q.pop();
use[now]=false;
for(int i=head[now];i;i=edge[i].next){
int y=edge[i].to;
int z=edge[i].val;
if(dis[y]>dis[now]+z){
dis[y]=dis[now]+z;
if(!use[y]) q.push(y),use[y]=true;
}
}
}
return;
}
int main(){
scanf("%d %d %d",&n,&m,&s);
int u,v,w;
for(int i=1;i<=m;i++){
scanf("%d %d %d",&u,&v,&w);
addedge(u,v,w);
}
spfa();
for(int i=1;i<=n;i++) printf("%d ",dis[i]);
//system("pause");
return 0;
}
优化当然是有的,但是由于优化价值不大,甚至更容易被卡掉,所以这里就不介绍了。
如果要判断负环,仅需要计算每一个点的入队情况,如果某个点松弛了第n次,说明有负环。
实际应用
这个代码主要用于负权图中,稀疏图表现也还行。
如果你担心有负权但出题人又卡 (SPFA) 的话,请自行寻找出路(其实应该不会出现这种情况)
分层图最短路
前置知识
分层图最短路是指在可以进行分层图的图上解决最短路问题。(分层图:可以理解为有多个平行的图)
一般模型是:在一个正常的图上可以进行 k 次决策,对于每次决策,不影响图的结构,只影响目前的状态或代价。
一般将决策前的状态和决策后的状态之间连接一条权值为决策代价的边,表示付出该代价后就可以转换状态了。
一般有两种方法解决分层图最短路问题:
- 建图流:建图时直接建成k+1层。
- 升维流:多开一维记录机会信息。
当然具体选择哪一种方法,看数据范围吧 。
方法一
我们建k+1层图。然后有边的两个点,多建一条到下一层边权为0的单向边,如果走了这条边就表示用了一次机会。
有N个点时,1~n表示第一层, (1+n)~(n+n)代表第二层, 以此类推。
因为要建K+1层图,数组要开到n * ( k + 1),点的个数也为n * ( k + 1 ) 。
请看代码:
#include <iostream>
#include <string.h>
#include <stdio.h>
#include <algorithm>
#include <queue>
#include <vector>
#define ll long long
#define inf 0x3f3f3f3f
#define pii pair<int, int>
const int mod = 1e9+7;
const int maxn = 5e4 * 42;
using namespace std;
struct node {int to,w,next;} edge[maxn];
int head[maxn], cnt;
int dis[maxn], vis[maxn];
int n, m, s, t, k;
struct Dijkstra
{
void init()
{
memset(head,-1,sizeof(head));
memset(dis,0x3f,sizeof(dis));
memset(vis,0,sizeof(vis));
cnt = 0;
}
void add(int u,int v,int w)
{
edge[cnt].to = v;
edge[cnt].w = w;
edge[cnt].next = head[u];
head[u] = cnt ++;
}
void dijkstra()
{
priority_queue<pii,vector<pii>,greater<pii> > q;
dis[s] = 0; q.push({dis[s],s});
while(!q.empty())
{
int now = q.top().second;
q.pop();
if(vis[now]) continue;
vis[now] = 1;
for(int i = head[now]; i != -1; i = edge[i].next)
{
int v = edge[i].to;
if(!vis[v] && dis[v] > dis[now] + edge[i].w)
{
dis[v] = dis[now] + edge[i].w;
q.push({dis[v],v});
}
}
}
}
}dj;
int main()
{
while(~scanf("%d%d%d", &n, &m, &k))
{
dj.init(); scanf("%d%d",&s,&t);
while(m--)
{
int u, v, w;
scanf("%d%d%d",&u, &v, &w);
for(int i = 0; i <= k; i++)
{
dj.add(u + i * n, v + i * n, w);
dj.add(v + i * n, u + i * n, w);
if(i != k)
{
dj.add(u + i * n, v + (i + 1) * n, 0);
dj.add(v + i * n, u + (i + 1) * n, 0);
}
}
}
dj.dijkstra(); int ans = inf;
for(int i = 0; i <= k; i++)
ans = min(ans, dis[t + i * n]);
printf("%d
",ans);
}
}
方法二
我们把dis数组和vis数组多开一维记录k次机会的信息。
dis[ i ][ j ] 代表到达 i 用了 j 次免费机会的最小花费.
vis[ i ][ j ] 代表到达 i 用了 j 次免费机会的情况是否出现过.
更新的时候先更新同层之间(即花费免费机会相同)的最短路,然后更新从该层到下一层(即再花费一次免费机会)的最短路。
不使用机会 dis[v][c] = min(min,dis[now][c] + edge[i].w);
使用机会 dis[v][c+1] = min(dis[v][c+1],dis[now][c]);
写法类似于 (DP) 。
代码见下:
#include <iostream>
#include <string.h>
#include <stdio.h>
#include <algorithm>
#include <queue>
#include <vector>
#define ll long long
#define inf 0x3f3f3f3f
#define pii pair<int, int>
const int mod = 1e9+7;
const int maxn = 1e5+7;
using namespace std;
struct node{int to, w, next, cost; } edge[maxn];
int head[maxn], cnt;
int dis[maxn][15], vis[maxn][15];
int n, m, s, t, k;
struct Dijkstra
{
void init()
{
memset(head,-1,sizeof(head));
memset(dis,127,sizeof(dis));
memset(vis,0,sizeof(vis));
cnt = 0;
}
void add(int u,int v,int w)
{
edge[cnt].to = v;
edge[cnt].w = w;
edge[cnt].next = head[u];
head[u] = cnt ++;
}
void dijkstra()
{
priority_queue <pii, vector<pii>, greater<pii> > q;
dis[s][0] = 0;
q.push({0, s});
while(!q.empty())
{
int now = q.top().second; q.pop();
int c = now / n; now %= n;
if(vis[now][c]) continue;
vis[now][c] = 1;
for(int i = head[now]; i != -1; i = edge[i].next)
{
int v = edge[i].to;
if(!vis[v][c] && dis[v][c] > dis[now][c] + edge[i].w)
{
dis[v][c] = dis[now][c] + edge[i].w;
q.push({dis[v][c], v + c * n});
}
}
if(c < k)
{
for(int i = head[now]; i != -1; i = edge[i].next)
{
int v = edge[i].to;
if(!vis[v][c+1] && dis[v][c+1] > dis[now][c])
{
dis[v][c+1] = dis[now][c];
q.push({dis[v][c+1], v + (c + 1) * n});
}
}
}
}
}
}dj;
int main()
{
while(~scanf("%d%d%d", &n, &m, &k))
{
dj.init(); scanf("%d%d",&s,&t);
while(m--)
{
int u, v, w;
scanf("%d%d%d",&u, &v, &w);
dj.add(u, v, w);
dj.add(v, u, w);
}
dj.dijkstra();
int ans = inf;
for(int i = 0; i <= k; i++)
ans = min(ans, dis[t][i]);
printf("%d
", ans);
}
}
可见本写法较为复杂(因为我不会),所以推荐第一种做法。
你会发现讲述时经常用到 “机会”这个词,原因是这道题
可以当做模板题了。
有点权的最短路
一般的最短路并没有点权,但如果遇到点权怎么办呢?
- 思路一:先不管点权,走到一个点之后再加,结果发现不可行。
- 思路二:上面提到的分层思想,将点权化为边权,发现可行。
具体流程如下:
- 将 (n) 个点的图分为两层,共计 (2n) 个点 。
- 输入点权,将点 (i) 与点 (n+i) 相连,边权为点权。
- 输入边权,假设为 ((u,v,w)) ,那么将点 (u+n) 与点 (v) 相连,边权为 (w) 。
- 跑一遍共 (2n) 个点的最短路。
- 输出 (dis(n+1...n+n)) 即为答案。
代码如下:
#include<cstdio>
#include<algorithm>
#include<iostream>
#include<cstring>
#include<cmath>
#include<queue>
#include<vector>
#define N 100010
#define M 1000010
#define maxd 2147483647
using namespace std;
int n,m,s,dis[2*N];
bool use[2*N];
struct node{
int to,val;
};
vector<node>edge[2*N];
priority_queue<pair<int,int> >q;
void dij(){
for(int i=1;i<=2*n;i++) dis[i]=maxd;
memset(use,false,sizeof(use));
q.push(make_pair(0,s));
dis[s]=0;
while(!q.empty()){
int now=q.top().second;
q.pop();
if(use[now]) continue;
use[now]=true;
for(int i=0;i<edge[now].size();i++){
int y=edge[now][i].to;
int z=edge[now][i].val;
if(dis[y]>dis[now]+z){
dis[y]=dis[now]+z;
q.push(make_pair(-dis[y],y));
}
}
}
return;
}
int main(){
scanf("%d %d %d",&n,&m,&s);
int a,b,c;
node p;
for(int i=1;i<=n;i++){
scanf("%d",&a);
p.to=i+n;
p.val=a;
edge[i].push_back(p);
}
for(int i=1;i<=m;i++){
scanf("%d %d %d",&a,&b,&c);
p.to=b;
p.val=c;
edge[a+n].push_back(p);
}
dij();
for(int i=n+1;i<=2*n;i++) printf("%d ",dis[i]);
system("pause");
return 0;
}
代码使用 (dij) 实现,其他实现方法大同小异。
虽然运用并不是很广,但这里提到一下,以后可能会用的到。
结语
这么重要的算法怎么能不学呢?
全文资料:《算法竞赛进阶指南》以及某谷 (dalao) 的博客
熟练掌握三种基础算法之后,一定要灵活应用。
咕咕咕
以上是关于最短路算法的主要内容,如果未能解决你的问题,请参考以下文章