在求解最短路径问题之前,我们要假设图中不能包含源点可达的权值为负的环路。因为权值为负的环路会导致最短路径不存在。并且我要非常清楚最短路径算法一个重要性值:最短路径的子路径也是最短路径。
一般来说,最短路径问题可以分为以下四种问题
- 两点最短路径
- 单目的地最短路径
- 单源最短路径
- 所有节点对间最短路径
两点最短路径
一般来说,使用单源最短路径算法求解这个问题即可。因为,求解两点最短路径的所有算法中最坏时间复杂度都与单源最短路径算法时间复杂度一样。
单目的地最短路径
求所有点到某一点的最短路径。
我们只需在反图(将所有边反转后的图)上应用求单源最短路径的算法即可。
单源最短路径
松弛操作
理解单源最短路径算法之前,我们必须了解以下什么是松弛操作。可以说,下面几个算法都是反复的对边进行松弛操作,只是进行的顺序和次数不一样罢了。
对一条有向边(u,v)进行松弛操作就是。看从源点出发到 v,如果最后一跳走边 (u, v) 到 v 是否能更近。
if (dist[u] + w(u, v) < dist[v]){
dist[v] = dist[u] + w(u, v);
}
Bellman-Fold算法
每个点有一个最短距离估计值 dist 属性,其初值:源点为0,其他点为无穷大。
算法的操作核心就是维护 dist 数组,不停的迭代式修改。
N个节点,M条边的有向图,任何一个点到源点的最短路径的长度不超过N-1。
经过第一遍对所有边的松弛操作,最短路径长度为1的点的最短距离估计值已等于最短距离值,后续不会再有变化。
由于长度为 k 的一条最短路径一定是由一条长度为 k-1 的一条最短路径再加一边组成的。因此,经过第二遍对所有边的松弛操作,最短路径长度为2的点的最短距离估计值已等于最短距离值,后续不会再有变化。
......
因此,经过第N-1遍对所有边的松弛操作,最短路径长度为N-1的点的最短距离估计值已等于最短距离值,后续不会再有变化。由于任何一个点到源点的最短路径的长度不超过N-1,现在图中所有点的最短路径估计值均为最短距离值。
并且,只要在遍历过程中,在松弛操作更新最短距离估计值时,同时更新其前序数组,那么最短路径也记录下来了。
Bellman-Fold算法还可以判断图中是否存在源点可达的负权值环路。其操作就是再进行一遍(也就是第 N 遍)对所有边的松弛操作,如果出现了距离更新,那么就出现了负权值环路。
其本质就是负权值环路会导致距离估计值在 n-1 次全局松弛内必然不会收敛。
void BellmanFord() {
for(int i=1;i<=N;i++) dist[i] = INT_MAX;
dist[S]=0;
for (int i = 0; i < N - 1; i++) {
for (int j = 0; j < M; j++) { //遍历每一条边
if (dist[from[j]] + weight[j] < dist[to[j]]) {
dist[to[j]] = dist[from[j]] + weight[j];
}
}
}
}
事实上我们在算法实现时,很难保证每一轮的变化在整个一轮结束时才应用变化。在每遍历一条边的时候,我们都是立刻应用变化。然后,会出现链式修改。不过这只会让算法在前面的循环收敛的更快而已,并不会影响算法的正确性。
SPFA (Shortest Path Faster Algorithm)算法
Bellman-Fold算法有一个严重的缺点(以至于这个算法本身处理提供一种朴素的思想外没有任何的使用价值),他是纯粹的盲目的循环松弛所有的边。然而,并不是在每一次循环中,每一条边都有必要进行松弛。
可以发现,如果某个点这轮的 dist 都没变,那么下一轮再松弛由它发出的边是无意义的。我们便以此对Bellman Fork算法对所有边的循环进行精简。
我们借鉴 BFS 的思路,使用一个队列来维护真正需要进行松弛的边的起点集合。
我们初始时将源点加入队列。 然后每次从队列中取出一个元素,并对所有由它发出的边进行松弛。若松弛成功,则在将这条边所指向的相邻点入队。
如果循环到对列为空,那么说明此时所有的距离估计值已经收敛。
显然,这个算法不再需要循环 n-1 次这个很粗略(因为只有近乎线性的网络才会出现到一个点的最短路径需要遍历所有节点,因此大部分网络只需要循环远远不到 n-1 次即可收敛)的收敛条件,故此算法的效率要远高于Bellman-Fold算法。
bool isInQueue[MAXN];
void SPFA() {
for (int i = 1; i <= N; i++) dist[i] = INT_MAX;
dist[S] = 0;
/*其实就是BFS的架子*/
queue<int> que;
que.push(S);
while (not que.empty()) {
int u = que.front(); que.pop(); isInQueue[u] = false;
for (int i = head[u]; i; i = next[i]) {
int v = to[i];
if (dist[u] + weight[i] < dist[v]) {
dist[v] = dist[u] + weight[i];
if (not isInQueue[v]) {
que.push(v); isInQueue[v] = true;
}
}
}
}
}
Dijkstra算法(使用最小堆优化选未收敛估距离最小点操作)
虽然SPFA 不再盲目的所有边进行松弛了,而是对松弛后可能导致 dist 值变化的边进行松弛。但是其还具有一定的盲目性!!!因为松弛边的起点的 dist 如果不已是最短路径,那么在松弛边的起点的dist一定会再发生改变,那么同样的边还会再进行若干次松弛操作。因此,我们想只有松弛的边的起点的dist已经是最短路径时,才对此边进行松弛操作。这样,任何边最多只会进行一次松弛操作。这样效率就提上去了!!!
因此,我们很自然的会想到维护一个逻辑上的已收敛节点集合和未收敛点集合。
每次从未收敛点集合中找到 dist 最小点加入已收敛点集并对其发出的边进行松弛,此时这个点的 dist 值就是最短距离,直至全部的点加入了收敛点集合。
那么为什么从未收敛点集合中 dist 最小点就是收敛的呢?我们假设此时的 dist 不是最小值,也就真实最短路径比他还小。那么最后一跳必然不在已收敛集合中(所有的收敛点都松弛过其周围的边了)。那么最后一跳一定在未收敛集合内。那么最后一条的前某一跳一定是从已收敛点集合跳到未收敛点集合。那么其完整路径的长度必然比从未收敛点集合中 dist 最小值大。然而我们假定了真实的 dist 比 从未收敛点集合中 dist 最小值小。互相矛盾。
那么我们如何快速的从未收敛点集合中找到 dist 最小点呢?使用最小堆存放未收敛点距离估计值集合即可。
struct Node { //堆节点
int u;//节点号
int w;//权值
//优先队列默认是最大堆,因此我们将比较运算结果反向即可变为最小堆
bool operator<(const Node& node) const {
return w > node.w;
}
};
void Dijkstra() {
for (int i = 1; i <= N; i++) dist[i] = INT_MAX;
dist[S] = 0;
priority_queue<Node> que; //堆
que.push((Node) {S, 0});
while (not que.empty()) {
int u = que.top().u;
int w = que.top().w;
que.pop();
/*如果权值不是最新,说明已经有新的同样的点加入队列,这是无效的旧值。
**使用了惰性删除的思想,避免了主动修改堆中数据的值这个难以实现的操作
*/
if (w != dist[u]) {
continue;
}
for (int i = head[u]; i; i = next[i]) {
int v = to[i];
if (dist[u] + weight[i] < dist[v]) {
dist[v] = dist[u] + weight[i];
que.push((Node) {v, dist[v]});
}
}
}
}
惰性删除就是,如果在某时判断出了要删除某一点,可以不立刻删除,而是在后续的某个时刻要用它的时候,发现他是不合法的,忽视他。
所有节点对间最短路径
Floyd算法
其状态状态函数为
dp[k][i][j] 表示 i ,j 两点只经过编号 1...k 节点的最短路径。dist[i][j]为距离矩阵。
/*滚动数组优化实现*/
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][j],dp[i][k]+dp[k][j]);}}}
}
我们知道,对于给定的一个图,其任何两点的最短路径一定是客观存在的。
因此,我们假设 i 和 j 两点的最短路径为
下面,我们定义路径的收敛性。如果路径 Sm 到 Sn 间的任何子路径所对应的 dp 矩阵中的值已为最短路径长度,那么我们称原路径的 Sm 到 Sn 子路径收敛。
当 k = 0 时,我们认为任意两点间都不存在长度大于1的收敛的子路径。
当 1 ≤ k ≤ N 时,如果两个节点的最短路径中存在 k 节点,那么 k 节点将会把其左右的最长相邻收敛子路径连接起来,从而形成一个更长的收敛子路径。
显然在第N次循环结束后,任何连接点的最短路径均为收敛路径。算法求解完毕!
形象的说,我们就是不停的在以 k 这个跳板节点作为胶水在拼碎片。最终碎片会拼成一条完整的最短路径。
至于如何输出路径的问题,我们只需维护一个path矩阵,其记录任何两节点的最后一个跳板节点即可。