文章目录
引入
在大城市C,道路交通网络错综复杂,你因为有急事需要从A处赶往B处。一路上有若干个路口。你已经预知了各个相连的路口之间的预计通行时间,且A和B是连通的。那么你要怎么来选择你要走的路线,来让你可以最快地到达B呢?
该问题是经典的单源最短路问题,给定一张带权图(可以有向,也可以无向),标定起点和终点,你的目标是求出起点到终点的最短距离。
在求解最短路之前,我们首先约定,给定的图一定是不带负环的。带负环图显然没有最短路。
算法
Bellman-Ford/SPFA
Bellman-Ford
Bellman-Ford算法是一种动态规划算法。
不妨约定起点为0,则规定dis[i]为点i到0的距离,顶点总数为n,则Bellman-Ford的过程可用如下步骤描述:
- 将dis数组中所有值设为INF,然后将dis[0]设为0.
- 遍历每一条边,检查边的两个端点v和w,更新它们的dis.
- 将步骤2执行n-1次.
解释:
-
dis的更新类似于动态规划的状态转移,其转移方程如下:
d i s [ i ] = m i n ( d i s [ i ] , d i s [ j ] + w [ j ] [ i ] ) dis[i] = min(dis[i], dis[j] + w[j][i]) dis[i]=min(dis[i],dis[j]+w[j][i])
其中 w [ j ] [ i ] w[j][i] w[j][i]是从j走到i的花费。 -
执行n-1的原因是,最短路的长度最长为n-1(因为最短路一定不包含环路),而每次遍历,我们至少会使最终正确的那条路长度+1.
Bellman-Ford代码如下(使用邻接矩阵,方便理解):
const int maxn = 1005;
// 使用结构体存边
struct edge{
int u, v, w;
}a[maxn * maxn];
cosnt int INF = 0x3f3f3f3f;
int d[maxn];
int n, m; // 假设有n个顶点,m条边
int Bellman_Ford(int start, int des) // 传入起点与终点
{
for(int i = 1; i <= n; ++i) d[i] = INF;
d[start] = 0;
for(int i = 0; i < n - 1; ++i){
for(int j = 0; j < m; ++j){
d[a[j].u] = min(d[a[j].u], d[a[j].v] + a[j].w);
}
}
return d[des];
}
Bellman-Ford的复杂度为 O ( n ∗ m ) O(n*m) O(n∗m).
SPFA优化
Bellman-Ford算法逐个遍历边的算法显然有点“暴力”,因此诞生了一种叫SPFA(Shortest Path Faster Algorithm,更快最短路)的算法,作为对Bellman-Ford算法的优化.
SPFA采用队列来进行优化,步骤如下:
- 开一个队列,将起点存入.
- 取出队头节点u,对所有与其相邻的节点进行松弛操作(即更新)
- 如果某个与u相邻的节点v被成功更新了,且v不在队列中,将v入队.
- 当队列空时,结束。
代码如下:
const int maxn = 1005;
// 使用邻接表存边
struct edge{
int to, w;
};
cosnt int INF = 0x3f3f3f3f;
int d[maxn];
int n, m; // 假设有n个顶点,m条边
bool v[maxn] = {0}; // 标记该点是否在队列中
vector<edge> a[maxn];
void spfa(int start, int des)
{
queue<int> q;
for(int i = 0; i < n; ++i) d[i] = INF;
d[start] = 0;
v[start] = 1;
while(!q.empty())
{
int now = q.front();
q.pop();
v[now] = 0;
for(int j = 0; j < a[now].size(); ++j){
if(d[a[now][j].to] > d[now] + a[now][j].w){
d[a[now][j].to = d[now] + a[now][j].w;
if(!v[a[now][j].to]){
v[a[now[j].to] = 1;
q.push(a[now][j].to);
}
}
}
}
}
SPFA的最优复杂度是
O
(
n
)
O(n)
O(n),即每条边只访问了一次。这种情况最简单之一就是整个图是一条单链表。
由于每次寻找的是当前最有可能需要被更新的点,该贪心策略存在很大的随机性,很容易被数据卡掉,退化成
O
(
n
∗
m
)
O(n*m)
O(n∗m),在ACM竞赛中,最坏情况数据几乎必定存在,这样的复杂度当然是难以接受的。(完全图中复杂度为
O
(
n
3
)
O(n^3)
O(n3))
所以,Bellman-Ford/SPFA真的没有用吗?
该算法有它自己的方便之处,那就是可以处理带负权的图,而且能检测出图中的负环。
如何检测负环?
对Bellman-Ford中的步骤,如果我们不在外层嵌套 n − 1 n-1 n−1层的for循环,而是规定,当对边的一轮遍历中没有出现有效更新时结束,这样就能判断出负环。
因为最短路的长度最长为n-1,所以我们只要检查对边遍历的次数,如果遍历边的轮数超过了n-1,那么就说明我们找的路径中已经存在回路了,而回路还能发生有效更新,那必定是有负环了。
至于带负权的图,Dijkstra是可能得不到正确解的,因此SPFA或许还有它的用武之地?
然而我们可以魔改处理一下Dijkstra,来让他变得可以处理负权图。后文会介绍到,Dijkstra的复杂度是要优于SPFA的。
所以,有这样一句OI界传颂已久的话:SPFA已死
Floyd
与Bellman算法一样,Floyd也将使用动态规划思想解决问题。不同的是,它解决的并不是单源最短路的问题,而是多源最短路问题。也就是说,Floyd算法将可以算出任意两点间的最短路,复杂度稳定在 O ( n 3 ) O(n^3) O(n3)。
Floyd的策略是,定义
d
p
[
i
]
[
j
]
dp[i][j]
dp[i][j]为从 i 到 j 的最短路。枚举“中间点”,对每对点对
(
i
,
j
)
(i,j)
(i,j)以中间点为中继更新路程,状态转移方程为:
d
p
[
i
]
[
j
]
=
m
i
n
(
d
p
[
i
]
[
j
]
,
d
p
[
i
]
[
k
]
+
d
p
[
k
]
[
j
]
)
dp[i][j] = min(dp[i][j], dp[i][k] + dp[k][j])
dp[i][j]=min(dp[i][j],dp[i][k]+dp[k][j])
Floyd算法的思想非常简单,代码实现也十分简洁。
代码:
const int maxn = 1005;
//使用邻接矩阵存边(直接存入dp数组)
cosnt int INF = 0x3f3f3f3f;
int dp[maxn][maxn];
void Floyd()
{
for(int k = 0; k < n; ++k)
for(int i = 0; i < n; ++i)
for(int j = 0; j < n; ++j)
dp[i][j] = min(dp[i][k] + dp[k][j], dp[i][j]);
}
Dijkstra
从多源最短路回到单源最短路,还有另一种基于贪心而不是动态规划的Dijkstra算法。该算法在竞赛中应用最为广泛,可以吊打SPFA,较为重要。
普通Dijkstra
不妨先通过Dijkstra最初的模样来了解它。
Dijkstra的步骤是:
- 初始化。这个初始化操作和Bellman-Ford一样。
- 标记起点已访问过,然后更新所有与起点相邻的点的距离。
- 从所有点中找到当前dis[i]最小的点,然后将其标记为已访问。
- 更新所有与起点相邻的点的距离。
- 重复步骤3,4,直到所有点都被访问。
该贪心算法可以用下面的思考粗略证明:
首先更新出起点到某几个点i,j,k……的距离。
如果我们要更新某个与点i相邻的点g距离,且点i是i,j,k……这些点中距离起点最近的点。那么这个最短路必定是起点-> i -> g,因为如果我们选择任何其他路线作为g和起点的中间路线,其路线都一定比经过i点长。这是由i当前距起点最近决定的。
下面贴Dijkstra无优化的代码:
const int maxn = 1005;
// 使用邻接表存边
struct edge{
int to, w;
};
cosnt int INF = 0x3f3f3f3f;
int d[maxn];
int n; // 假设有n个顶点
bool v[maxn] = {0}; // 标记该点是否访问过
vector<edge> a[maxn];
void Dijkstra(int start, int des)
{
for(int i = 0; i < n; ++i) d[i] = INF;
d[start] = 0;
v[start] = 1;
int cnt = 1;
while(cnt < n)
{
int min_d = INF, pos = -1;
for(int i = 0; i < n; ++i)
{
if(!v[i] && d[i] < min_d){
min_d = d[i];
pos = i;
}
}
if(pos == -1) break; // 如果pos = -1而且所有点未全被访问,说明这个图没有连通,找不到单源最短路
v[pos] = 1;
cnt++;
for(int i = 0; i < a[pos].size(); ++i)
{
int u = a[pos][i].to;
if(d[u] > min_d + a[pos][i].w){
d[u] = min_d + a[pos][i].w;
}
}
}
}
显然,该算法的复杂度为 O ( n 2 ) O(n^2) O(n2).
堆优化
你是否注意到,寻找当前dis值最小的点,这个操作其实是可以“有序”的,我们大可以事先把最小的点处理出来。通过这种思想,堆优化的Dijkstra就诞生了。
我们把当前所有待处理的点按照距起点的距离排序,形成一个优先队列,这样就免去了每次寻找最小值的步骤。
代码:(记得定义排序方法)
const int maxn = 1005;
// 使用邻接表存边
struct edge{
int to, w;
bool operator < (const edge& x)const{
return w > x.w;
}
};
cosnt int INF = 0x3f3f3f3f;
int d[maxn];
int n; // 假设有n个顶点
bool v[maxn] = {0}; // 标记该点是否访问过
vector<edge> a[maxn];
void Dijkstra(int start, int des)
{
for(int i = 0; i < n; ++i) d[i] = INF;
d[start] = 0;
priority_queue<edge> q;
while(!q.empty())
{
int now = q.top().to;
q.pop();
if(v[now]) continue;
v[now] = 1;
for(int i = 0; i < a[now].size(); ++i){
int u = a[now][i].to;
int w = a[now][i].w;
if(!v[u] && d[u] > d[now] + w){
q.push(u);
}
}
}
}
该优化算法中,我们需要处理 n n n个节点,同时也维护一个优先队列,故算法复杂度为 O ( m l o g n ) O(mlogn) O(mlogn).
可以看出,由于需要多次进行优先队列的调整,当图足够稠密时,该算法的复杂度也将退化到 O ( n 2 l o g n ) O(n^2logn) O(n2logn).
故我们的策略将是:稠密图使用普通版,稀疏图使用堆优化.
另外,该算法在稀疏图中求全源最短路也可以使用,对n个点各使用一次Dijkstra即可代替Floyd,效果拔群。
Johnson算法-负权图预处理Dijkstra(全源最短路)
前面说到,Dijkstra并不能处理负权图,这是它贪心的策略导致的,在图中含有负权边时,使用Dijkstra很可能求出错误答案,还是得用SPFA.
(反例就不给了吧,主要是懒得画图)
那么如何处理图/算法,来让我们的Dijkstra可以解决负权图呢?
(SPFA太太太太太太太太慢了)
这里我们只讲全源最短路的情况。
不妨新建一个虚拟节点,从这个点向其他所有点连一条边权为0的边。然后用SPFA跑一遍以该点为起点的单源最短路,存入一个数组(记为b),然后让每条边的权值从 w w w变为 w + h u − h v w+h_u-h_v w+hu−hv.其中, u u u, v v v分别为这条边的起点和终点。
这样处理后,我们再跑n遍Dijkstra就可以了。
(当然,稠密图我们也可以直接Floyd,不用花里胡哨)
为什么这样之后边权一定会变为正呢?
根据三角不等式,两边之和大于等于第三边,我们得到
h
v
≤
w
u
,
v
+
h
u
h_v≤w_{u,v}+h_u
hv≤wu,v+hu,则新的边权
w
+
h
u
−
h
v
≥
0
w+h_u-h_v≥0
w+hu−hv≥0,得证.
(证明参考自洛谷神犇StudyingFatherP5905的题解)
诶,你问单源带负权怎么办?老老实实用SPFA吧(所以其实还没死,偶尔还能用)