概述
-
最短路算法主要研究图上两点之间的最短路径问题。
-
事实上,许多问题都能转化为图来求解(图的本质就是点和边的集合,点是元素,边是元素之间的二元关系)。
-
所以最短路算法并不局限于“给出一张图,...”。
Floyd
il void 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]=min(dis[i][j],dis[i][k]+dis[k][j]);
}
概述
-
Floyd 算法是一种全源最短路算法。
-
Floyd 算法可处理负权,无法处理负环。
-
前一条的证明见 Bellman-Ford。
-
后一条的理由如下:
-
路径可以过负环的两点之间的最短路为无穷小,正常的任何算法都显然算不出(根本存不了这种东西好吧)。
-
实际效果的话,对于从 \(i\) 出发不能经过负环到 \(j\) 的 \(i,j\),和无负环情况一样;
-
对于能经过负环的,虽然负环大小有限,其上每个点至多作为一次“当前考虑到的最大可行中继点”被放进来,但其他非负环上点做中继的时候,它的 \(dis[i][k]+dis[k][j]\) 中可能就有 \(1/2\) 项本身过负环,所以最终的 \(dis\) 会为 \(-inf\)。
-
-
-
Floyd 算法的实现基于邻接矩阵。
原理
-
本质上是 dp,上面的代码实质上压掉了第一维。
-
完整的 dp 如下:
-
状态设计:\(dis_{k,i,j}\) 代表只考虑用编号小于等于 \(k\) 的点作为中继(注意,与路径端点无关!)的情况下,从 \(i\) 到 \(j\) 的最短路距离。
-
初始化:\(dis_{0,i,j}=e_{i\to j}\)。如果不存在对应边,那么 \(dis_{0,i,j}=+\infty\),当然赋成 \(-1\) 然后特判也行。
-
状态转移方程:\(dis_{k,i,j}=\min(dis_{k-1,i,j},dis_{k-1,i,k}+dis_{k-1,k,j})\)。
-
第二项符号是 + 还是 min 或者别的什么,取决于题目对路径长度的定义。
-
证明
-
前面一种是不经过第 \(k\) 个点中继,那么显然可以保留结果。
-
后一种是经过第 \(k\) 个点中继。显而易见,在无负权环的图中任意一条从 \(k\) 出发/到 \(k\) 截止的最短路不可能以 \(k\) 为中继,所以使用上一次的状态来转移是合法的。
-
\(O(n^3)\),空间 \(O(n^2)\)。
-
压维而非滚维的正确性:本轮转移中肯定用到的都是 \(dis_{i,k},dis_{k,j}\) 之类含 \(k\) 的,但由上,从 \(k\) 出发/到 \(k\) 截止的最短路不经过 \(k\),故每轮转移的出发点在该轮一定不被更新。
其他应用
-
Floyd 还有一个妙用:求长度最小的简单环(关于简单环,请参考图论-定义)。
-
考虑简单环的环长。把环内最大点记为 \(k+1\),且 \(\exists e_{i\to k+1},e_{k+1\to j},i,j\in cir\),显然该环的最小长度为 \(dis_{k,i,j}+e_{i\to k+1}+e_{k+1\to j}\)。
-
则我们可以对于每个 \(k\),在进行 Floyd 前
for(i,j)
基于上式求最小环。可以得到最小简单环环长。 -
又因为找到的环一定包含 \(i,j,k+1\) 三个点,记录每个 \(dis\) 由谁更新后利用回溯也可以找到环上点与边。
-
为什么后两个用的是 \(e\) 而非 \(dis\)?
-
负权图中,\(dis\) 可能会绕远路绕到 \(i,j\) 之间的点上再绕回来(这样走经过的路径长度为负数),于是这个环就不是一个简单环,甚至于它会反复计算负权边的权值。
-
而环长度的定义是 \(\sum\limits_{e\in cir} e\),即环上每条边的权值之和。
-
Dijkstra
const int inf=0x3f3f3f3f;
int dis[maxn];
bitset<maxn> vis;
struct point{
int id,dis;
point(){}
point(int _id,int _dis){id=_id,dis=_dis;}
il bool operator<(const point &b)const{return dis>b.dis;}
};
priority_queue<point,vector<point> >q;
il void dijk(int s){
For(i,1,n) dis[i]=inf; dis[s]=0; q.push(point(s,0));
ri point now;
while(!q.empty()){
now=q.top(),q.pop();
if(vis[now.id]) continue;
vis[now.id]=1;
for(ri edge a:e[now.id])
if(dis[a.to]>now.dis+a.l){
dis[a.to]=now.dis+a.l;
q.push(point(a.to,dis[a.to]));
}
}
return;
}
概述
-
Dijkstra 算法是一种单源最短路算法。
-
Dijkstra 无法处理负权边。
-
一切单源最短路,一切,取出最短路中用到的边,这些边一定是一棵树,称为最短路树。
原理
-
我们下面把“最短路长度尚未确定的点”称为未出队点,确定的称为已出队点(请注意,未出队不代表就在那个
priority_queue
内)。 -
1.每次在最短路长度尚未确定的点中,选取到源点 \(s\) 最近的 \(u\)。
-
在无负权图中,可以证明,此时的 \(dis_u\) 一定就是实际的 \(s\to u\) 的最短路长度:
-
\(\forall dis_v<dis_u\) 的 \(v\),它们都已经出队,且已尝试过更新 \(dis_u\)。
-
\(\forall dis_v\geqslant dis_u\) 的 \(v\),既然是无负权图,则一定有 \(dis_v+e_{v\to u}\geqslant dis_u\),没有更新的必要。
-
故此时的 \(dis_u\) 一定就是实际的最短路长度。
-
-
2.从 \(u\) 出发,尝试更新所有未出队点的最短路,即 \(\forall v,dis_v=\min(dis_v,dis_u+e_{u\to v})\)。
-
通常使用邻接表实现,故没有边就不更新。
-
注意,我们虽然把 \(s\) 之外的点的 \(dis\) 都初始化为 \(inf\) ,但它不能真的取一个接近类型上限的值(譬如说 \(2139062143\) 或 \(2^{31}-1\) 对于
int
),因为这样一加会炸类型上限。
-
-
3.将 \(u\) 出队。如果队列不空,则继续找最近的点,重复上述操作。
实现
-
朴素实现
-
就枚举呗,跟 Prim 真的非常像(上面贴的代码是优化版的)。
-
每次找点 \(O(n)\),找 \(n\) 次,每条边会做一次更改 \(dis\) 的尝试。
-
故总复杂度 \(O(n^2+m)\)。
-
-
优化实现
-
使用各种能 \(O(\log)\) 取出最近点的数据结构。
-
最常用的是
priority_queue
,因为好写。-
需要注意的是我们无法更改优先队列中的元素的值(可能会有人说
mutable
关键字但我不会)。 -
故我们不得不把 \(dis\) 放在结构体里才能让优先队列正常运行,从而我们只能每次把 \(id\) 和 \(dis\) 打包丢进优先队列。
-
从而会有大量无用元素积压,所以它的表现很差,极限情况下每条边都会导致一次入队(和后面的出队),\(O((n+m)\log m)\)。
-
需要额外考虑一个问题:一个点是否可能会入队多次。
-
在它的 \(dis\) 已经彻底敲定,用它来更新别人之后,它肯定不会入队。
-
但在那之前它可能会被更新很多次,\(dis\) 最小的那次会首先被取出,后面的都没用。
-
所以我们应当加一个 \(vis\) 数组,一旦它被取出了一次,就
vis=1
,后面再被取出直接continue
。
-
-
-
显然可以用各种堆(二叉堆/配对堆/左偏树)加上记录元素在堆中的编号来做 decrease-key,从而复杂度降至 \(O((n+m)\log n)\)。类似地,线段树也可以。
-
有一个很神的东西叫做 Fibo 堆:
-
这个东西只有 pop 是 \(O(\log)\),其他操作都是 \(O(1)\)。
-
故用它优化 Dijk 的理论复杂度为 \(O(m+n\log n)\)。
-
但事实上,它太难写。并且,它的复杂度是均摊的,在实际使用中表现并没有那么优秀(甚至会输给配对堆,见 OI Wiki Dijk 处的链接),故不常用。
-
-
注意到除了 Fibo 堆之外,其他的在稠密图中都是负优化。所以请根据 \(m\) 与 \(n\) 的相对大小谨慎选择。
-
Bellman-Ford 与 SPFA
int dis[maxn],q[maxn<<4],hd=1,tl;
bool vis[maxn];
void spfa(int s){
For(i,1,n) dis[i]=inf; dis[s]=0; q[++tl]=s; vis[s]=1;
while(tl>=hd){
vis[q[hd]]=0; int now=q[hd++];
for(bian a:e[now])
if(dis[a.to]>dis[now]+a.l){
dis[a.to]=dis[now]+a.l;
if(!vis[a.to]) vis[a.to]=1,q[++tl]=a.to;
}
}
return;
}
概述
-
Bellman-Ford 算法是一种单源最短路算法。
-
Bellman-Ford 算法可处理负权边,无法处理负环。
-
狭义的 SPFA 是队列优化的 Bellman-Ford。
原理
-
Bellman-Ford:dp。
-
状态设计:\(dis_{k,i}\) 为从 \(s\) 到 \(i\) 只走不超过 \(k\) 步的最短路。
-
初始化:\(dp_{0,s}=0\)。
-
状态转移方程:\(dis_{k,i}=\min(dis_{k-1,i},\min_j(dis_{k-1,j}+e_{j,i}))\)。
-
-
\(O(n^2)\)。
-
观察上面的转移式,可以看到 \(k\) 的转移实质上是可以滚维/压维的。
-
如果我们压它的维,转移中会有环。怎么办?
-
考虑使用带环 dp 的方式来处理它。
-
SPFA
-
每次某个点的最短路被修改了就把它扔进队列,准备从它出发更新别的点的最短路。
-
用 \(vis\) 记录一下是否已经在队列里,不要重复入队。
-
\(\Omega(m)\sim O(nm)\)。一般认为随机数据下是 \(O(m)\) 的,构造数据下是 \(O(nm)\) 的。
-
有一系列优化,譬如 \(dis\) 小于队头就放到队头,\(dis\) 大于平均扔到队尾(有点模拟优先队列的味道),在随机数据下很有效。
-
然而,训练有素的出题人还是可以把你卡到 \(O(nm)\)。事实上,这些优化可以被卡到指数复杂度。
-
所以如果被卡了就去用 Dijk 吧...大型比赛一定、一定、一定不要用 SPFA!!!(除非打暴力明知 Dijk 过不了,赌一手 SPFA 能过)
-
其他应用:找负环
- 路径可以过负环的两个点肯定没有最短路,但是 SPFA 可以判断(并找到)负环。
-
任意一个点最多被每个点都更新一次,毕竟这个 dp 的本质是走谁作中转。
-
所以如果一个点的入队次数 \(\geqslant n\),一定有负环!
-
记录更新来源,可以不断倒序回溯,找到负环。
-
\(O(nm)\)(毕竟都入队n次了)。
-
可以用 \(num\) 数组来优化。假如 \(i\) 更新了 \(j\),\(num[j]=num[i]+1\),\(O()\) ...这个优化的正确性毋庸置疑,但也有可能被卡成无优化。
-
可以看到这个 \(num\) 实际代表的是最短路经过的点的个数,它 \(>n\) 则一定有负环了(一条最短路不可能含超过 \(>n\) 个点,即不能有超过 \(n-2\) 个中转点,除非有一个点过了两次)。\(num[s]=1\)。
-
需要额外附注一下的是,常规的 SPFA 会自动地清空 \(vis\),但找到负环就跳出时,记得手动清理。
-
差分约束算法
概述
-
差分约束算法的一般形式如下:
-
给定一组多元不等式(也可能含一些等式),试求解:
-
某两个元的最大差/最小差。
-
一组符合条件的解。
-
最小差问题
-
将 \(x_1,x_2,...\) 抽象为一组图论意义下的点,将不等式抽象为它们之间的边。
-
考虑一个最短路问题的基本形式:\(dis_i\leqslant dis_j+e_{j\to i}\)。
-
如果我们把所有的不等式都化成类似的小于情况(\(x_i-x_j\leqslant k\)),那么就可以把它化归到上面的最短路模型里:\(e_{j\to i}=k\)。
-
实际意义:\(x_i\) 最多比 \(x_j\) 大 \(k\)。从而求最小差的意义就是多条“\(x_i\) 比 \(x_j\) 最多大多少”路径的最短路。
最大差问题
- 同上。全部转化为大于情况,那就是多个“最少大多少”中的最长路。
求符合条件的解(或者判断是否有解)。
-
做一个超级源点,剩下自己整。
-
有负环就是无解。
-
证明:将对应不等式左右相加(按 \(x_i-x_j\leqslant k\) 的形式),显然得到 \(0\leqslant -k,k> 0\)。
例题
-
\(\text{P5960 [模板] 差分约束算法}\)
-
\(\text{P2294 [HNOI2005] 狡猾的商人}\)