概述
本文同步在洛谷博客上进行发表。
给定一个有 n n n 个点, m m m 条有向边的图。请你计算从 s s s 出发,到每个点的距离。
这就是经典的单源最短路径问题,我们可以用很多种算法来实现它。
注:
- 下文所有 n , m , k n,m,k n,m,k 均与上方引用语句含义相同。
- 本文存图方式大多为链式前向星。
由于下文不给完整程序,故补全链式前向星存图代码如下,方便读者理解下文的内容:
int h[NR], tot;
struct Edge
{
int u, v, w, next;
} e[NR];
void add(int u, int v, int w)
{
tot++;
e[tot].u = u, e[tot].v = v, e[tot].w = w;
e[tot].next = h[u], h[u] = tot;
}
不会链式前向星的读者请看这篇文章。
算法 1 1 1 : Floyd text{Floyd} Floyd
Floyd text{Floyd} Floyd 是一个全源最短路径算法。它的本质是 DP text{DP} DP。
状态表示:
d
i
s
i
,
j
,
k
dis_{i,j,k}
disi,j,k 表示
i
i
i 到
j
j
j 间任意一条路径的中间结点(不含两端)编号
∈
[
1
,
k
]
in [1,k]
∈[1,k] 时,
i
,
j
i,j
i,j 间最短路径的长度。
状态转移:
d
i
s
i
,
j
,
k
=
min
(
d
i
s
i
,
j
,
k
−
1
,
d
i
s
i
,
k
,
k
−
1
+
d
i
s
k
,
j
,
k
−
1
)
dis_{i,j,k}=min(dis_{i,j,k-1}, dis_{i,k,k-1}+dis_{k,j,k-1})
disi,j,k=min(disi,j,k−1, disi,k,k−1+disk,j,k−1)
三维的状态可以进行降维,把 k k k 那维压缩掉。但是注意降维后必须优先枚举 k k k。
简化状态:
d
i
s
i
,
j
dis_{i,j}
disi,j 表示
i
i
i 到
j
j
j 间最短路径长度。
状态转移:
d
i
s
i
,
j
=
min
(
d
i
s
i
,
j
,
d
i
s
i
,
k
+
d
i
s
k
,
j
)
dis_{i,j}=min(dis_{i,j}, dis_{i,k}+dis_{k,j})
disi,j=min(disi,j, disi,k+disk,j)
这里 d i s dis dis 其实是个邻接矩阵。
核心代码:
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]);
时间复杂度: O ( n 3 ) O(n^3) O(n3)
优点:代码好写,可以处理带负权边的情况。
缺点:时间复杂度较高,不能处理负环。
算法 2 2 2 : SPFA & Bellman-Ford text{SPFA & Bellman-Ford} SPFA & Bellman-Ford
SPFA text{SPFA} SPFA 算法是老古董 Bellman-Ford text{Bellman-Ford} Bellman-Ford 算法的队列优化版本,它们都是单源最短路径算法。
定义数组 d i s dis dis, d i s i dis_i disi 为 i i i 号结点到 s s s 的距离。
先来讲讲 Bellman-Ford text{Bellman-Ford} Bellman-Ford 算法:
- 遍历所有边,对于每条单向边( u u u 到 v v v,边权为 w w w), d i s v = min ( d i s v , d i s u + w ) dis_v=min(dis_v, dis_u+w) disv=min(disv, disu+w)
- 重复上述操作 ( n − 1 ) (n-1) (n−1) 次。
时间复杂度: O ( n m ) O(nm) O(nm)
我之所以将这个算法称为“老古董”,是因为它已经过时了。它的优化版本 SPFA text{SPFA} SPFA 算法才是重头戏。 SPFA text{SPFA} SPFA 可以减少判断次数,从而获得更高的效率。
因为只有更新过 u u u 的 v v v 才对 v v v 做出了贡献,所以我们定义一个先进先出的队列保存 v v v( u , v u,v u,v 见上文讲解 Bellman-Ford text{Bellman-Ford} Bellman-Ford 算法时的定义),不断进行取队首并更新的操作,直到队列为空,即无结点可以更新。此时最短路径就找到了。
感觉实现起来像广搜。
核心代码:
queue <int> q;
memset(dis, 0x3f, sizeof(dis));
dis[s] = 0; vis[s] = true; q.push(s);
while (!q.empty())
{
int u = q.front(); q.pop(); vis[u] = false;
for (int i = h[u]; i > 0; i = e[i].next)
{
int v = e[i].v, w = e[i].w;
if (dis[v] > dis[u] + w)
{
dis[v] = dis[u] + w;
if (!vis[v]) q.push(v), vis[v] = true;
}
}
}
时间复杂度: O ( k m ) O(km) O(km)
优点:可以出现负权边,可以判负环,稀疏图中效率高。
缺点:稠密图中
Bellman-Ford
text{Bellman-Ford}
Bellman-Ford 到
SPFA
text{SPFA}
SPFA 的优化几乎毫无作用,甚至有可能被卡到
O
(
n
m
)
O(nm)
O(nm)。举个例子,在 NOI 2018 Day1 T1 归程 中,凉心出题人就卡了
SPFA
text{SPFA}
SPFA。因此衍生出一个梗:
关于 SPFA text{SPFA} SPFA:它死了。
言归正传, SPFA text{SPFA} SPFA 还是有它独一无二之处的。它是能判负环的算法(也就两个,另一个是 Bellman-Ford text{Bellman-Ford} Bellman-Ford)中最快的。
判断负环很简单,只要把上面代码拿出来改一改就行了。所以我们先讲思路。
思路不难,但是有点坑。这篇题解对我启发很大,尤其是这句话,请读者务必理解:
不能判松弛次数,要判入队次数!
定义 c n t cnt cnt 数组, c n t i cnt_i cnti 表示 i i i 号结点的入队次数。若 c n t i ≥ n cnt_ige n cnti≥n(具体为什么见上文 Bellman-Ford text{Bellman-Ford} Bellman-Ford 算法讲解,理论上没有负环只需判断最多 ( n − 1 ) (n-1) (n−1) 次),则说明入队次数超过了阈值,所以必有负环。
核心代码:
把上面代码的这句话:
if (!vis[v]) q.push(v), vis[v] = true;
稍作修改,变成:
if (!vis[v])
{
q.push(v); vis[v] = true; cnt[v]++;
if (cnt[v] >= n) return puts("YES"), 0;
}
算法 3 3 3 : Dijkstra text{Dijkstra} Dijkstra
Dijkstra text{Dijkstra} Dijkstra 算法依旧是一个单源最短路径算法。它的堆优化版本也是最短路中效率较高,应用最广的算法。本质是个贪心。
先来讲一下不带优化的版本,大致可分为三个步骤。
- 寻找结点 u u u,使所有未被标记的点中 d i s u dis_u disu 最小,并标记 u u u。
- 遍历所有以 u u u 为起点的单向边(终点为 v v v,边权为 w w w), d i s v = min ( d i s v , d i s u + w ) dis_v=min(dis_v, dis_u+w) disv=min(disv, disu+w)
- 重复前两步,直到所有点都被标记。
核心代码:
memset(dis, 0x3f, sizeof(dis));
dis[s] = 0;
for (int i = 1; i < n; i++)
{
int u = 0;
for (int j = 1; j <= n; j++)
if (!vis[j] && (u == 0 || dis[u] > dis[j])) u = j;
vis[u] = true;
for (int j = h[u]; j > 0; j = e[j].next)
{
int v = e[j].v;
dis[v] = min(dis[v], dis[u] + e[j].w);
}
}
时间复杂度: O ( n 2 ) O(n^2) O(n2)
这个复杂度依然可以优化。我们发现,朴素的 Dijkstra text{Dijkstra} Dijkstra 在寻找 d i s dis dis 值最小的未标记结点时,时间复杂度是 O ( n ) O(n) O(n) 的,完全可以用优先队列进行优化。
定义一个结构体 Node texttt{Node} Node:
struct Node
{
int id, dist;
bool operator < (const Node &x) const
{
return dist > x.dist;
}
};
priority_queue <Node> q;
i
d
id
id 表示结点编号,
d
i
s
t
dist
dist 表示从
i
d
id
id 到
s
s
s 的最短路径长度。
重载运算符作用有两个:
- 使大根堆变成小根堆
- 按距离关键字排序
由此我们可以得出结论:定义的优先队列将会按每个结点到 s s s 的最短路径长度从小到大进行排序。
优化完成。
核心代码:
memset(dis, 0x3f, sizeof(dis));
dis[s] = 0, q.push((Node){s, 0});
while (!q.empty())
{
int u = q.top().id; q.pop();
if (!vis[u])
{
vis[u] = true;
for (int i = h[u]; i > 0; i = e[i].next)
{
int v = e[i].v, w = e[i].w;
if (dis[v] > dis[u] + w)
{
dis[v] = dis[u] + w;
q.push((Node){v, dis[v]});
}
}
}
}
时间复杂度: O ( ( n + m ) log n ) O((n+m)log n) O((n+m)logn)
优点:效率极高。
缺点:不能出现负权边,不能处理负环。
总结
数据小的题目:用
Floyd
text{Floyd}
Floyd。
判断负环或有负权边:用
SPFA
text{SPFA}
SPFA。
带非负权边的图:用
Dijkstra
text{Dijkstra}
Dijkstra + 堆优化。
最后
以上就是温暖老鼠为你收集整理的【学习笔记】最短路的全部内容,希望文章能够帮你解决【学习笔记】最短路所遇到的程序开发问题。
如果觉得靠谱客网站的内容还不错,欢迎将靠谱客网站推荐给程序员好友。
发表评论 取消回复