图论算法: 寻找单源(Dijkstra’s Algorithm,Bellman-Ford Algorithm)和全源最短距离(Floyd-Warshall)

128 阅读6分钟

单源最短距离 (Single Source Shortest Paths)

这个类型的算法可以帮助寻找一个开始点到所有其他的点的最短距离。

松弛(Relax)

如果从开始点到v的最短距离可以通过经过边edge(u,v)来优化,我们会松弛这个边,这个行为叫松弛操作(Relaxation)

所有相关算法的底层逻辑

一直寻找可以松弛的边直到去所有点的最短路程的值收敛了。算法之间的很大的区别是选择松弛边的顺序和方法去到达收敛条件。

Dijkstra’s Algorithm

Constraint (约束条件)

  • 无负边(Negative Edge), Bellman-Ford Algorithm可以解决
  • 无负环(Negative Circle)

实现思路

假设开始的顶点是s

我们优先松弛已经知道了最短路程的顶点opt(s,*)相关的边,但是这么多边我们还是需要一个松弛的顺序,可以通过最小堆(Minimum Heap)来寻找最小权重的相关边是(u, v),权重是w(u,v)sv的路程现在通过松弛这个边被提减少到了opt(s,u) + w(u,v),两个值都是最小值那么答案肯定也是最短路程了。之后从v的出去的也可以被添加进去最小堆了。最后,堆里没有边可以松弛了,代表我们已经找到了单源最短距离了。

初始条件 (Base Case)

除了opt(s,s)=0,其他都是无限大。

代码

int opt[N]
bool visited[N]
最小堆 min_heap
min_heap.push({0, s}) // s 是开始点
while (min_heap is not empty) 
do
    d, v = min_heap.top()
    min_heap.pop()
    if (visited[v]) continue
    opt[v] = d
    visited[v] = true
    for (查看每一个v的出边(u, w)) do
        if (visited[u]) continue
        pq.push(d + w, u)
    done
done

时间复杂度

O(V + E)

为什么不能存在负边?

opt(s,v)=opt(s,u) + w(u,v) 会被破坏,无法保证opt(s,u)是最短距离,可能之后确定最短距离的顶点和u有一个权重是大负数的边,这样导致s通过这个边去u低于opt(s,u),这样之前的最短距离错误了,那么根据opt(s,u)推导出来的最短路程当然也错了。

Bellman-Ford Algorithm

Constraint (约束条件)

  • 无负环(Negative Circle)

实现思路

假设一个图有n个顶点,开始点是s。如果没有相同的顶点被重复使用的话,到其他点的最短路径长度不可能超过n-1。这个算法的整体思路是基于DP的,状态(v,k)代表的是从出发点到顶点v的路径长度不超过k的最短距离。我们最外面的循环是n-1次,每一次迭代k结束代表已经找到了去每一个顶点的长度不超过k的最短路程。一个顶点v的所有相邻节点u在一次迭代中都会被检查是否经过u之后去v会更短一点。

整个算法思路类似洪水蔓延(Flood Fill)从一个开始点在每一个迭代中开始蔓延。一个点u如果被蔓延到被更新了,那么它的邻近节点就有可能被通过它找到更短的距离所有之间的边需要松弛一下(但是这个文章的实现为了方便使用的是每次迭代都尝试松弛全部的边)。如果u没有更新,'洪水'会在这个顶点停止蔓延。整个图没有继续更新看,那么算法就结束了。

这个算法可以使用找最短路程或者检测是否有负环,如果有的话,第n次迭代仍然不会收敛。

递归公式(Recurrence)

需要更新f(v,k)f(v,k), 我们需要找到v的入边集EE(Ingoing Edge),尝试去松弛每一个边

f(v,k)=min(f(v,k1),minu,v,wE(u,k1)+we)f(v,k)=\min(f(v,k-1), \min_{u,v,w \in E}(u,k-1)+w_e)

代码具体实现思路

可以观察到,我们只需要前一轮的结果,所有不需要储存每轮的结果。储存全部的Edge在一起,每一轮尝试松弛每一个边,做n-1轮。

初始条件(Base Case)

f(v,0)是0,其他都是正无限。

代码

struct edge(u,v,w)
顶点数量 n
int dist[N]
edge edges[M] // 所有的边
bool relax() {
    bool relaxed = false // 检查是否跟新了状态
    for (遍历edges里面全部的边(u,v,w)) do
        if (dist[u] != INF and dist[u] + w < dist[v]) do
            relaxed = true
            dist[v] = dist[u] + w
        done
    done
    return relaxed
}
// 返回的值代表这个图有没有负环
bool bellman_ford() {
    for (做n - 1次循环) do
        if (!relax()) break;
    done
    return relax()
}

为什么无法处理负环?

Screenshot 2024-10-27 222358.png 如图,如果到点F的最短距离是ABCDE,那么边F会不会被松弛呢?这个问题取决于整个环是不是负的。

如果这个环不是负的,BCDEF加起来的权重大于0,那么到点2的最短距离不会更新,这样点123456其实已经收敛了不会改变了。

但是如果整个环是负的,经过点6到点2会更小,那这样到点2的最短距离被更新了,会导致3456也在以后的迭代被慢慢更新,整个环的顶点都会一直更新,不会收敛。使用上面的话,这个'洪水'在这负环里会不停歇的蔓延。

时间复杂度

O(VE)

全源最短距离(All Pairs Shortest Paths)

计算出全部对顶点的最短距离。

Floyd-Warshall Algorithm

Constraint (约束条件)

  • 无负环(Negative Circle)

实现思路

这个思路是DP为基础实现的,状态(u,v,i)代表的是使用第i个顶点从uv的最短路径。一对顶点之间的最短距离可能是经过0个以上个中间点(Intermediate Vertex),对于每一对顶点的,我们会尝试从中间点1到n当作中间点更新状态。

递归公式(Recurrence)

f(u,v,i)=min(f(u,v,i1),f(u,i,i1)+f(i,v,i1))f(u,v,i)=\min(f(u, v, i - 1),f(u,i,i - 1) + f(i, v, i - 1))

初始条件

f(u,u,0)是0, f(u,v,0)是两个顶点之间的边权重,其他都是正无限。

代码

int dist[N][N]
顶点数量 n
for (循环n次,i) do
    for (循环n次,v) do
        for (循环n次, u) doe
            dist[u][v] = min(dist[u][v], dist[u][i] + dist[i][v])
        done
    done
done

为什么处理不了负环?

image.png

如果u是点4,v是点2,i是点3,思考一下怎么更新f(u,v, i)。假深f(u,i)是DEB,f(i,v)是CDE,f(u,v,i-1)=DE, 那么这个f(u,v,i)=min(DE, DEB + CDE)=min(DE, BCDE + DE)。如果这个BCDE是一个负环,f(u,v,i)会被更新成原来的最短路径再加一圈负环,这不是最短简单路径了。

问题的关键是这个算法不是Bellman-Ford Algorithm,没有办法限制路径的长度导致利用负环来减短了最短路径(我们应该找最短简单路径但是一些顶点被重复使用了)。

时间复杂度

O(V^3)