图论 最短路
前言
不要不求甚解,对新知识要刨根问底。背板子刷题不是目的,掌握了才是真理。
图论 最短路
简介
图论 (Graph theory) 是数学的一个分支,图是图论的主要研究对象。图 (Graph) 是由若干给定的顶点及连接两顶点的边所构成的图形,这种图形通常用来描述某些事物之间的某种特定关系。顶点用于代表事物,连接两顶点的边则用于表示两个事物间具有这种关系。
最短路是图论中最基础的一类,常考且有一定的思维量。我们的任务是掌握三种基本求最短路的方法并做到灵活运用。
建图
对于不同的数据范围,我们考虑用邻接矩阵和链式前向星两种方式建图。
邻接矩阵
实际上就是我们常用的二维数组,通常定义 \(d_{i,j}\) 为 \(i\) 点到 \(j\) 点的最短距离。优点是方便且易懂,缺点是很吃内存,\(3000 \times 3000\) 的邻接矩阵就要占到约 \(34.4468MB\),再加上每道题要求的初始化,很容易爆 MLE。
使用方式
建图
很简单,以 \(n\times m\) 的图为例,两层循环搞定。
for(int i=1;i<=n;i++)
for(int j=1;j<=m;j++)
scanf("%d",&d[i][j]);
引用
更简单了,按照定义肆意使用即可。
用途
Floyd 刚需,其他可根据自身习惯和题目适用情况来选择是否使用。
链式前向星
一般由三个数组组成:\(head_i\),\(to_i\),\(next_i\)。若有边权则添加一个 \(w_i\) 数组存边权。链式前向星不仅在内存上优化很多,以 \(n\le 10000\),\(m\le 20000\)(\(n\) 为点数,\(m\) 为边数)为例,链式前向星只占用了 \(0.267105MB\),并且在时间上也有一定幅度的优化。是用的最多的建图方式。
初学可能对这四个数组的含义不太理解其实也不用太理解,可以手搓一个小一点的图,你就会发现它的精妙之处。使用起来简单,只需要记住很短的板子就行了。
使用方式
建图
需要一个 cnt 存不同边的序号,之后不会再使用,注意不要与其他变量重名。
void add(int u,int v/*,int ww*/)
{
to[++cnt]=v;
// w[cnt]=ww;
nxt[cnt]=head[u];
head[u]=cnt;
//这两句千万不能调换顺序!
}
引用
详见注释。
for(int i=head[u];i!=-1;i=nxt[i])
{
int v=to[i];
//循环中 i!=-1 是根据你对 head 数组初始化的数值而来的
//全局变量中初始均为 0,所以写成 for(int i=head[i];i;i=nxt[i])即可
//像我一样担心极小概率事件的可以先 memset(head,-1,sizeof head) 然后如上写
//u 是起点 v 是终点 之后按要求操作即可
}
用途
很广,除了 Floyd 无脑用就行。
Vector
方便的 vector 容器大举入侵存图领域!
只能说写起来很方便,但时间会大↑幅↓增↗加→,且无法(至少我不知道)如何存边权,不推荐一般 OIer 使用该方式。如需使用,建议先钻研一下 vector 容器。
使用方式
定义 std::vector<int>ve[maxn]
。
存图
for(int i=1;i<=m;i++)
{
int u,v;
scanf("%d%d",&u,&v);
ve[u].push_back(v);
}
引用
for(auto i:ve[u])
{
int v=i;
//操作
}
最短路
很多题目有形或无形要求找到某点到某点的最小代价,也诞生了许多跑最短路的算法。
一、Floyd
新手之神!多源最短路之神!最好写的一集!
适用于任何图,不管有向无向,边权正负,但是最短路必须存在,即无负环。
只需要邻接矩阵存图后三层循环即可完成,类似于 dp 的思想。时间复杂度为 \(O(n^3)\),只能极限跑 \(n\le 300\) 的图。
实现
for(int k=1;k<=n;k++)
for(int i=1;i<=n;i++)
for(int j=1;j<=n;j++)
d[i][j]=min(d[i][j],d[i][k]+d[k][j]);
Tip 1
跑最短路前,我们通常会将距离都赋一个极大值。
大家应该已经了解到,如果两数相加超出 int 范围会炸。
那么观察上面的代码,存在一个加和 d[i][k]+d[k][j]
。
所以这个极大值我们应选取一个足够大但不超过 int 范围二分之一的数。
当然可以手搓,不过有更方便的办法。
0x3f
和 0x7f
在 memset 中表示的就是 int 范围下一个极大值的一半和一个极大值,可以自己试一试。
所以,我们赋初值时应该这样写:memset(d,0x3f,sizeof d);
。
例题
模板题
注意一下重边取小以及自身为 0,其余套板子即可。
二、Dijkstra
是一种求解非负权图上单源最短路径的算法。
朴素的 Dijkstra 算法是 \(O(n^2)\) 的复杂度,并不算优秀,所以常用的是堆优化后复杂度降到 \(O(m \log m)\) 的写法。该算法较为稳定,但仅局限于非负权图上。
实现
int n,m,s,cnt;
int hh[N],to[N<<1],ne[N<<1],w[N<<1];//存图
int dis[N];bool yz[N];//算法实现需要
struct rmm
{
int dis,u;
bool operator<(const rmm &a)const{return a.dis<dis;}
};//手写小根堆(小的为根)
namespace Wisadel
{
void Wadd(int u,int v,int ww)
{
to[++cnt]=v;
w[cnt]=ww;
ne[cnt]=hh[u];
hh[u]=cnt;
}
void Wdij(int x)
{
priority_queue<rmm>q;
dis[x]=0;
q.push({0,x});
//先将起点置入堆中
while(!q.empty())
{
int u=q.top().u;
q.pop();//取出
if(yz[u]) continue;
yz[u]=1;//标记
for(int i=hh[u];i!=-1;i=ne[i])
{
int v=to[i];
if(dis[v]>dis[u]+w[i])
{//松弛操作
dis[v]=dis[u]+w[i];
q.push({dis[v],v});
}
}
}
}
short main()
{
n=qr,m=qr,s=qr;
memset(dis,0x7f,sizeof dis);
memset(hh,-1,sizeof hh);
//初始化
fo(i,1,m)
{
int a=qr,b=qr,c=qr;
Wadd(a,b,c);
}
Wdij(s);
return Ratio;
}
}
int main(){return Wisadel::main();}
例题
完全没有坑点,放心打板子。
三、SPFA(优化Bellman–Ford)
Bellman–Ford 算法是一种基于松弛(relax)操作的最短路算法,可以求出有负权的图的最短路,并可以对最短路不存在的情况进行判断。Bellman–Ford 算法所做的,就是不断尝试对图上每一条边进行松弛。我们每进行一轮循环,就对图上所有的边都尝试进行一次松弛操作,当一次循环中没有成功的松弛操作时,算法停止。其时间复杂度为 \(O(nm)\)。
大多数情况下,我们并不需要跑满所有的松弛操作。很显然,只有上一次被松弛的结点,所连接的边,才有可能引起下一次的松弛操作。那么我们用队列来维护哪些结点可能会引起松弛操作,就能只访问必要的边了。这就是 SPFA 算法。
SPFA 也可以用于判断 s 点是否能抵达一个负环,只需记录最短路经过了多少条边,当经过了至少 n 条边时,说明 s 点可以抵达一个负环。
虽然在大多数情况下 SPFA 跑得很快,但其最坏情况下的时间复杂度也是 O(nm),将其卡到这个复杂度也是不难的,所以要谨慎使用。在没有负权边时最好使用 Dijkstra 算法,在有负权边且题目中的图没有特殊性质时,若 SPFA 是标算的一部分,题目不应当给出 Bellman–Ford 算法无法通过的数据范围。
实现
#include<bits/stdc++.h>
using namespace std;
const int Ratio=0;
const int N=5e5+5;
int hh[N],to[N<<1],w[N<<1],ne[N<<1];
int dis[N],cnt[N];bool yz[N];
queue<int>q;
bool spfa(int n,int s)
{
memset(dis,0x3f,sizeof dis);
dis[s]=0,yz[s]=1;
q.push(s);
while(!q.empty())
{
int u=q.front();
q.pop();
yz[u]=0;
for(int i=hh[u];i!=-1;i=ne[i])
{
int v=to[i];
if(dis[v]>dis[u]+w[i])
{
dis[v]=dis[u]+w[i];
cnt[v]=cnt[u]+1;// 记录最短路经过的边数
if(cnt[v]>=n)
return false;
// 在不经过负环的情况下,最短路至多经过 n - 1 条边
// 因此如果经过了多于 n 条边,一定说明经过了负环
if(!yz[v])
q.push(v),yz[v]=1;
}
}
}
return true;
}
例题
确实不卡,但在 Dij 的那道例题中会T4个点。
总结
三种方法各有优劣吧。Floyd 简单易行但时间复杂度高,Dijkstra 更稳定但只能应用于非负权图,SPFA 适用性更高且能判负环但容易被卡。因此我们不能只凭借某一种算法把题过了就不管了,而是要熟练运用这三种,根据不同的数据范围和图的类型来选择使用不同的算法。