首先可以了解一下图的几种存储方式 :
(本图取自B站@董晓算法)
https://www.cnblogs.com/Gaoqiking/p/11151530.html
这四种算法都是用于解决最短路径问题的经典算法,即找出从一个源节点到其他所有节点的最短路径。下面我将分别介绍它们:
算法介绍
1. Dijkstra算法:详细讲解()
- Dijkstra算法是一种贪心算法,用于求解带权有向图中的单源最短路径问题(不允许有负权边)。
- 算法的基本思想是从起点出发,逐步确定到各个顶点的最短路径,直到所有顶点都被确定。
- 算法通过维护一个优先队列(通常使用最小堆实现)来选择当前未确定最短路径的节点,并根据当前节点的最短路径长度来更新与其相邻的节点的最短路径。
- Dijkstra算法适用于边权值为正的图,时间复杂度为O(VlogV + E),其中V是顶点数,E是边数。
2. Floyd算法(Floyd-Warshall算法):
- Floyd算法是一种动态规划算法,用于解决所有节点对之间的最短路径问题,即多源最短路径问题。
- 算法基于一个递推关系,通过不断更新中间节点的路径长度来求解最短路径。
- Floyd算法的核心思想是利用中间节点k,尝试缩短节点i和节点j之间的路径长度。
- Floyd算法适用于有向图或无向图,边权值可以是正数、负数或零,但不允许存在负权回路。算法的时间复杂度为O(V^3),其中V是顶点数。
3. Bellman-Ford算法(朴素BF):
- Bellman-Ford算法是一种经典的动态规划算法,用于解决带有负权边的单源最短路径问题。
- 算法基于对所有边进行V-1次松弛操作,其中V是图中顶点的数量。在每一次松弛操作中,更新所有边的权值以确保得到最短路径。
- Bellman-Ford算法还可以检测图中是否存在负权回路,如果存在负权回路,则算法无法得到正确的最短路径。
- 算法的时间复杂度为O(VE),其中V是顶点数,E是边数。与Dijkstra算法相比,Bellman-Ford算法可以处理带有负权边的情况,但效率较低。
4. SPFA算法(Shortest Path Faster Algorithm):
- SPFA算法是一种基于Bellman-Ford算法的优化算法,用于解决单源最短路径问题。
- 与Bellman-Ford算法不同的是,SPFA算法采用了队列优化的思想,减少了不必要的节点松弛操作,提高了算法的效率。
- SPFA算法的基本思想是维护一个队列,不断将可以进行松弛操作的节点加入队列,并在队列不为空时进行处理,直到队列为空为止。
- SPFA算法适用于带有负权边但不存在负权回路的图,时间复杂度取决于实际情况,但通常情况下比Bellman-Ford算法快很多。
例题
下面从一道leetcode例题抛砖引玉:https://leetcode.cn/problems/network-delay-time/description/
题目:有 n 个网络节点,标记为 1 到 n。给你一个列表 times,表示信号经过 有向 边的传递时间。 times[i] = (ui, vi, wi),其中 ui 是源节点,vi 是目标节点, wi 是一个信号从源节点传递到目标节点的时间。现在,从某个节点 K 发出一个信号。需要多久才能使所有节点都收到信号?如果不能使所有节点收到信号,返回 -1 。
Dijkstra算法
朴素Dijkstra(适用于稠密图)
在计算最短路时,如果发现当前找到的最小最短路等于 inf,说明有节点无法到达,可以提前结束算法,返回 −1。
如果所有节点都可以到达,返回 max(dis)。
具体思路:
1.新建距离列表,保存起始节点至每个节点(包括自己)的距离,初始化自己到自己的距离为0,到其他节点距离为正无穷(或者一个特别大的数);
2.从距离列表中获取一个最小距离的点,即__该点的最短路径已经确定__,初始化之后因为初始点的距离就是自己到自己,所以K点就是第一个确定的最短路径,距离为0;
3.然后对该点到其他点(未求得最短路径的点,后面有说明该点是怎么得来的)进行松弛操作,更新该点到其他点的距离;
4.该点的最短路径已确定,即表示该点__已求得最短路径__,后面的循环(第3步)不会再对该点进行操作;
5.循环2--3--4,当所有点的的最短路径都已求得的时候,最大距离就是网络延迟时间的答案,当在循环中发现最小距离为无穷大时,即表示还有点没有到达,返回-1。
class Solution: def networkDelayTime(self, times: List[List[int]], n: int, k: int) -> int: g = [[inf for _ in range(n)] for _ in range(n)] #邻接矩阵 初始权重为无穷大 for x,y,d in times: g[x-1][y-1] = d #存图 # 初始化距离列表,表示从源节点到各节点的最短距离,初始时均为无穷大 dis = [float('inf')] * n dis[k - 1] = 0 # 源节点到自身的距离为0 #初始化标记列表,用于标记节点是否已经确定最短路径 done = [False]*n #主循环,直到所有节点的最短路径都被确定 while True: x= -1 # 当前要确定最短路径的节点编号 for i , j in enumerate(done):# 寻找未确定最短路径且距离最短的节点 if not j and (x<0 or dis[i] < dis[x]): x = i # 如果 x 仍然为 -1,说明所有节点都已确定最短路径,返回最大的最短距离 if x < 0: return max(dis) # 如果节点 x 的距离为无穷大,说明有节点无法到达,返回 -1 if dis[x] == float('inf'): return -1 # 将节点 x 标记为已确定最短路径 done[x] = True # 更新节点 x 的邻居节点的最短距离 for y, d in enumerate(g[x]): # 更新节点 y 的最短距离为当前距离和经过节点 x 到达的距离的较小值 dis[y] = min(dis[y], dis[x] + d)
堆优化 Dijkstra(适用于稀疏图)
寻找最小值的过程可以用一个最小堆来快速完成:
初始把 (dis[k],k) 二元组入堆。
当节点 x 首次出堆时,dis[x]就是写法一中寻找的最小最短路。
更新 dis[y] 时,把 (dis[y],y) 二元组入堆。
注意,如果一个节点 x 在出堆前,其最短路长度 dis[x] 被多次更新,那么堆中会有多个重复的 x,并且包含 xxx 的二元组中的 dis[x]是互不相同的(因为我们只在找到更小的最短路时才会把二元组入堆)。
关于优先队列的解释如下:
优先队列是一种数据结构,它类似于普通的队列(先进先出),但是每个元素都关联有一个优先级。在优先队列中,元素按照优先级顺序而不是插入顺序进行处理。 优先队列的主要操作是插入(添加元素)、弹出(删除并返回具有最高优先级的元素)以及查询(获取具有最高优先级的元素而不删除)。 通常,优先队列可以通过堆(如二叉堆或斐波那契堆)来实现。在 Python 中,`heapq` 模块提供了堆的实现,可以方便地创建优先队列。 堆是一种特殊的树状数据结构,具有以下性质: 1. 在一个最小堆(或最大堆)中,对于任意节点 x 的值,其父节点的值要么大于等于(或小于等于)节点 x 的值。 2. 堆总是一棵完全二叉树,这意味着除了最底层之外,其他层的节点都被完全填充,且最底层尽可能地从左到右填入。 在优先队列中,堆的使用能够有效地支持快速地插入、删除和获取具有最高优先级的元素。 在给定的情况下,如果你需要不断地找出具有最小值(最大值)的元素,则优先队列通常是一个很好的选择。
具体代码
class Solution: def networkDelayTime(self, times: List[List[int]], n: int, k: int) -> int: # 初始化邻接表 g = [[] for _ in range(n)] # 构建邻接表 for x, y, d in times: g[x - 1].append((y - 1, d)) # 初始化距离列表,表示从源节点到各节点的最短距离,初始时均为无穷大 dis = [float('inf')] * n dis[k - 1] = 0 # 源节点到自身的距离为0 # 初始化优先队列,存放 (距离, 节点) 的元组 h = [(0, k - 1)] # Dijkstra 算法主循环 while h: dx, x = heappop(h) # 弹出距离最小的节点 if dx > dis[x]: # 如果该节点之前出堆过,则忽略该节点 continue for y, d in g[x]: # 遍历节点 x 的邻居节点 new_dis = dx + d # 计算经过节点 x 到达节点 y 的距离 if new_dis < dis[y]: # 如果新距离小于当前最短距离 dis[y] = new_dis # 更新节点 y 的最短距离 heappush(h, (new_dis, y)) # 将节点 y 加入优先队列 mx = max(dis) # 最大的最短距离 return mx if mx < float('inf') else -1 # 如果存在无法到达的节点,则返回 -1
Floyd算法
算法思路:
Floyd 算法是一种动态规划算法,用于解决所有节点对之间的最短路径问题。其思路大致如下:
-
初始化一个二维数组
dist
,其中dist[i][j]
表示节点 i 到节点 j 的最短路径长度。如果节点 i 到节点 j 没有直接相连的边,则初始化为无穷大;如果有直接相连的边,则初始化为边的权重。 -
对于每一个中间节点
k
,遍历所有节点对(i, j)
,如果通过节点k
能够使得从节点i
到节点j
的路径变短,则更新dist[i][j] = dist[i][k] + dist[k][j]
。 -
重复步骤 2,直到所有节点对之间的最短路径都被计算出来。
-
最终
dist
数组中的值即为所有节点对之间的最短路径长度。
在本题中,可以利用 Floyd 算法计算出从每个节点到其他所有节点的最短路径长度,然后取其中的最大值作为信号传播的总时间。
具体代码:
class Solution:
def networkDelayTime(self, times: List[List[int]], n: int, k: int) -> int:
# 初始化图的邻接矩阵,初始权重为无穷大
graph = [[float('inf')] * n for _ in range(n)]
# 对角线上的元素初始化为0,表示节点到自身的距离为0
for i in range(n):
graph[i][i] = 0
# 根据输入的边列表更新图的邻接矩阵
for u, v, w in times:
graph[u-1][v-1] = w
# 使用 Floyd 算法计算所有节点对之间的最短路径
for m in range(n):
for i in range(n):
for j in range(n):
# 如果经过节点 m 能够使得从节点 i 到节点 j 的路径变短,则更新最短路径长度
if graph[i][j] > graph[i][m] + graph[m][j]:
graph[i][j] = graph[i][m] + graph[m][j]
# 计算从源节点到所有节点的最短路径长度,并取其中的最大值作为信号传播的总时间
res = max(graph[k-1])
# 如果存在无法到达的节点,则返回 -1
return res if res < float('inf') else -1
Bellman-Ford 算法
Bellman-Ford 算法是一种用于计算单源最短路径的算法,它可以处理带有负权边的图,并能够检测负权环。其简单思路如下:
松弛操作原理
三角形两边之和大于第三边,举个🌰:
求源点 A 到其他结点的最短距离,有两个结点 B 和 C 与源点 A 的距离为 x, y,
若 B 到 C 之间有一条边 z ,那么此时可以考虑通过 B 到达 C, 距离为 x + z,
若 x + z < y,说明通过 B 到达 C 距离更短,就可以更新 C 与 源点 A 的最短距离
拓展:因为松弛操作只会发生在上一轮松弛过的结点的边上,所以可以维护一个队列保存松弛过的结点,该方法即 SPFA算法,可以避免遍历所有边执行松弛操作,但最坏情况下还是会退化成朴素BF
-
初始化距离数组
dist
,表示从源节点到每个节点的最短路径长度。初始时,将源节点到自身的距离设为0,其他节点的距离设为无穷大。 -
进行 n-1 轮松弛操作,每一轮松弛操作都尝试以当前已知的最短路径来更新从源节点到所有其他节点的距离。
-
在每一轮松弛操作中,遍历图中的每一条边,尝试使用该边来缩短从源节点到其它节点的距离。如果发现某个节点的距离可以被缩短,则更新该节点的距离。
-
如果在进行完 n-1 轮松弛操作后,还存在可以缩短距离的情况,说明图中存在负权环,因此算法无法得出正确结果。
-
如果没有负权环存在,那么
dist
数组中存储的即为源节点到每个节点的最短路径长度。
在本题中,可以使用 Bellman-Ford 算法来计算从源节点 k 到每个节点的最短路径长度,并返回其中的最大值作为信号传播的总时间。如果存在负权环,即返回 -1。
下面是使用 Bellman-Ford 算法的代码:
class Solution: def networkDelayTime(self, times: List[List[int]], n: int, k: int) -> int: # 初始化距离数组,源节点到每个节点的距离初始为无穷大,源节点到自身距离为0 dist = [float('inf')] * n dist[k - 1] = 0 # 进行 n-1 轮松弛操作 for _ in range(n - 1): for u, v, w in times: if dist[u - 1] + w < dist[v - 1]: dist[v - 1] = dist[u - 1] + w # 检查是否存在负权环 for u, v, w in times: if dist[u - 1] + w < dist[v - 1]: return -1 # 返回信号传播的总时间,即最大的最短路径长度 return max(dist) if max(dist) < float('inf') else -1
这段代码通过多轮松弛操作来计算从源节点到每个节点的最短路径长度,并最终返回最大的最短路径长度作为信号传播的总时间。
SPFA算法
SPFA(Shortest Path Faster Algorithm)是一种用于解决单源最短路径问题的算法,类似于 Bellman-Ford 算法(就是bellman_ford 的队列优化形式),但是在实际应用中通常比 Bellman-Ford 算法更快。
SPFA 算法的基本思想是通过贪心策略不断更新节点的最短路径估计值,以期望能够在更少的松弛操作中达到最终的结果。其步骤如下:
-
初始化一个队列,将源节点放入队列中,并将源节点的最短路径长度设为0,其他节点的最短路径长度设为无穷大。
-
从队列中依次取出节点,对于每个节点,遍历其邻居节点,如果通过当前节点能够使得从源节点到邻居节点的路径长度变短,则更新邻居节点的最短路径长度,并将邻居节点加入队列中。
-
不断重复步骤 2,直到队列为空。
-
如果在进行更新过程中,某个节点被更新的次数超过了 n 次(n 为节点个数),则说明图中存在负权环,因此算法会停止运行并报告无解。
在本题中,使用 SPFA 算法可以计算从源节点到每个节点的最短路径长度,并返回其中的最大值作为信号传播的总时间。如果存在负权环,则返回 -1。
SPFA 算法在实际中通常能够比 Bellman-Ford 算法更快地求解最短路径问题,因为它采用了一种贪心的思想,更快地收敛到最优解。
具体代码:
class Solution: def networkDelayTime(self, times: List[List[int]], n: int, k: int) -> int: # 初始化距离数组,表示从源节点到每个节点的最短路径长度,初始值为无穷大 dist = [float('inf')] * n dist[k-1] = 0 # 源节点到自身的距离为0 # 使用字典存储图的边信息,键为起始节点,值为目标节点及对应的权重 edges = collections.defaultdict(dict) for u, v, w in times: edges[u-1][v-1] = w # 将边的信息存入字典中,键为起始节点,值为目标节点及对应的权重 # 初始化队列,将源节点加入队列中 q = collections.deque([k-1]) # SPFA 算法主循环 while q: cur = q.popleft() # 从队列中取出当前节点 for nxt, w in edges[cur].items(): # 遍历当前节点的邻居节点及对应的权重 if dist[cur] + w < dist[nxt]: # 如果通过当前节点到达邻居节点的距离更短 dist[nxt] = dist[cur] + w # 更新邻居节点的最短距离 q.append(nxt) # 将邻居节点加入队列中以进行下一轮更新 res = max(dist) # 最大的最短路径长度即为信号传播的总时间 return res if res < float('inf') else -1 # 如果存在无法到达的节点,则返回 -1
四种算法的选择
1:有无负权边
如果图中不存在负权边,优先选择 Dijkstra 算法;如果需要计算所有节点对之间的最短路径,且节点数较小,可以选择 Floyd 算法;如果图中存在负权边,可以考虑使用 SPFA 算法,但需要注意负权环的存在;如果需要检测负权环或者对算法的稳定性要求较高,可以选择 Bellman-Ford 算法。
2:单/多点源
对于单源最短路径问题,Dijkstra 算法、SPFA 算法和 Bellman-Ford 算法都是可行的选择,具体取决于图的特性和性能要求;对于多源最短路径问题,Floyd 算法和多次运行的 SPFA 或 Bellman-Ford 算法可能更适合。