绪论
前几章节我们介绍了图中的一系列知识点——基础术语(有向,无向,边,顶点,度)和存储方式(邻接矩阵,邻接表),遍历结点的方式(宽度优先遍历和深度优先遍历),拓扑排序(我个人觉得应该将拓扑排序看作图遍历的一种方法)。
这一章节我们讨论结点之间不同要求,不同情况下求最优路径的方式。BFS作为一种求单源结点到单源结点最短路径的方式,在其他情况求解最优路径的表现并不适用,同时对于求解单源结点到单源结点最短路径,也有在BFS的基础上优化的更好的算法。
Shortest Path
这里讨论的最优路径都是最短路径,两个节点的最短路径就是指两个节点之间的多条路径中路径权重最小的一条路径(这里将无权图的看作每条路径权重相等的有权图)。
例如下图中寻找结点1到结点13的最短路径。
Applications
寻找最短(优)路径在现实生活中的应用非常之多,直接应用就是地图软件的路径规划和计算机网络中的信息传输。
Bellman-Ford algorithm
求解图上最短路问题的算法适用于不同情形的主要有以下几种:
这一章节先介绍迪杰斯特拉算法和贝尔曼福特算法。
这两种算法针对的是寻找单个节点到其他所有节点的最短路径,’从算法思想上看可以看作现实生活中寻找结点到各个结点最短路径直接想法的数学算法体现。
(我本来打算按照课件上的顺序介绍,但是在思考两种算法的时候发现了一些有趣的事,所以擅自调整了介绍的顺序)
我们按照顺序思考下面几个问题:
1.寻找单个结点到各个结点的最短路径,单个结点到单个结点独立计算最短路径的算法是否适用?
这个问题的答案在我们没有找到结点到结点最短路径的关系之前并不是显然的,因为如果结点到结点的最短路径之间是完全互相独立的,那么我们会很抱歉地发现:即使再整体地去考虑整个问题,最终都避免不了独立计算结点到单个节点地最短路径。
BFS,UCS就可以做到独立计算结点到单个节点的最短路径。
不过我们稍作推理就能够发现,结点到结点的最短路径并不是完全互相独立的,例如下图中我们求解结点A到其余结点的最短路径,结点A到结点C的最短路径和结点A到结点F的最短路径的一部分是重合的,在我们求解A到C的最短路径之前,我们希望能够先求解出A到F的最短路径,且在计算结点A到结点C的最短//路径时能够避免尽可能避免掉结点A到结点F路径长度的重复计算。
这样我们就能够大概想到从整体来计算某节点到其他结点最短路径的策略框架:每次确定一个(或多个)结点的最短路径,然后通过已经确定最短路径的结点向未确定最短路径的结点进行扩展,未确定最短路径的结点继承已确定最短路径结点的最短路径(来避免重复计算)。
为了方便计算,我们认为起始结点到起始结点的最短路径在算法进行最开始时就确定且长度为0。
2.我们在什么条件能够确定一个结点当前通过继承得到的路径已经是最短路径?
因为仅仅是继承前继结点的最短路径并不能保证我们当前结点得到的路径是最短路径,上面继承得到结点A到结点C最短路径的方法在不同路径长度的相同结构图中都不存在普遍性。如果结点A到结点C的路径长度为8,那么结点A到结点F的最短路径仍为A->B->F,但是结点A到结点C的最短路径变为A->C,继承得到的路径A->B->F->C并不是A到C的最短路径。
最简单(最简单但并不是复杂度最低)确认一个结点当前通过继承得到的路径已经是最短路径的方法是保证该节点的前继结点的最短路径确定,于是可以得到下面的递推表达式。
我们从起点开始一层一层向外扩展,每次扩展将当前结点经过边数小于等于k的最短路径添加新的可能的边扩展到经过边数小于等于k+1的最短路径,这个扩展的操作在bellman-ford算法中往往称为松弛操作。由于bellman-ford算法进行的背景不包括负环(负环路径可以进行排除),所以最多经过结点个数|V|-1次松弛操作就可以得到所有结点的最短路径。
这样就可以得到最基础版本的bellman-ford算法。
最基础版本的bellman-ford算法的复杂度为O(VE),不过我们在松弛的过程中会发现,由于结点的最短路径是通过继承前继结点的最短路径不断更新的,对于某一次的松弛操作,不需要检查所有结点的最短路径是否需要更新,只需要检查上一次松弛操作最短路径发生更新的结点的后继节点即可。
于是我们可以队列来对上面的算法进行优化,每次出队需要检查的结点,然后进队最短路径发生更新的结点,用cnt记录每条最短路径的长度,如果路径的长度大于结点的总个数,说明出现了负环的情况。
这个用队列优化过后的bellman-ford算法就是我们常说的SPFA算法(SPFA在最坏情况下和bellman-ford算法的复杂度是一样的),具体代码如下:
bool bellman_ford(int s){queue<int>Q;//因为不用寻找d值最小的元素,所以用FIFO队列即可
memset(inq,0,sizeof(inq)); memset(cnt,0,sizeof(cnt));
//inq[i]表示结点是否在队列中,进入过队列的元素出去之后还能再进入队列
//一个结点可以多次进入队列,可以用来排除负环,cnt[i]表示到达结点i当前路径经过的结点数
for (int i=0;i<n;i++) d[i]=INF; d[s]=0; inq[s]=1; Q.push(s);//这里和Dijkstra都是一样的
while (!Q.empty()){int u=Q.front(); Q.pop(); inq[u]=false;//一个结点可以多次进入队列
for (int i=0;i<G[u].size();i++){ Edge&e=edges[G[u][i]];//考察结点u出去的每条边
if (d[u]<INF&&d[e.to]>d[u]+e.dist){//事实上我还是不能理解为什么要判断d[u]<INF
d[e.to]=d[u].e.dist; p[e.to]=G[u][i];//d值和p值的修改
if (!inq[e.to]){Q.push(e.to); inq[e.to]=true;//插入队列
if (++cnt[e.to]>n) return false;
//如果路径上的结点数大于n,那么代表路径上一定存在重复的结点,也就是存在环
//然而已经经过d[e.to]>d[u]+e.dist条件的判断,那么这个环上的权重一定为负
//说明图中存在负环,最短路不存在
}
}
}
}
}
Dijkstra’s algorithm
回归到上面的第二个问题:我们在什么条件能够确定一个结点当前通过继承得到的路径已经是最短路径?通过经验我们可以发现,对于同两个结点更新次数越多,路径越短越倾向于是最短路径,所以当我们需要进行扩展又不希望像bellman-ford(SPFA)算法一样扩展的如此频繁时,我们会考虑结合贪心法,每次只扩展到起点路径长度最短的结点。
于是有“结合了贪心思想的bellman-ford算法”如下:
1.每次扩展到起点路径长度最短的结点,确定该结点的最短路径,更新其后继结点到起点的路径长度。
2.进行|V|-1次1的操作确定所有结点的最短路径。
那么我们每次确定的结点的路径是否为最短的呢?用反证法来考虑,假设某一次确定的结点的路径并不是最短的,也就是说存在一条“与当前路径不同路”的路径到该结点的路径长度更短,由于每次扩展都是选择的到起点最短的路径进行扩展,那么这另一条路径的从起点开始的某一段只能在算法进行到该节点后面的过程中出现(如果在前面出现就会与每次扩展都是选择的到起点最短的路径进行扩展矛盾)。
算法进行到该节点后面的过程中出现的路径长度一定大于前面出现的路径,说明这造成反例的路径除了从起点开始的一段,其余的边长度之和一定为负数。
到这里事实上我们就推出反例发生的情况了,说明我们这里“结合了贪心思想的bellman-ford算法”并不适用于所有的情况。但是我们发现反例发生的情况是另一条路径后半段边的长度之和为负数,在现实生活中,很多情况下图中边的权一定为非负数,此时的反例并不会发生。
也就是说我们这里“结合了贪心思想的bellman-ford算法”可以适用于边权全为非负数的图的单源结点到其他所有节点最短路问题,这就是我们说的Dijkstra算法和它的使用背景。
Dijkstra算法从实现的角度看和Prim算法极为类似,同样可以用优先队列和Fibonacci Heaps来对后继结点最短路径长度的更新(decrease-key)效率进行优化。
总结
图上的最短路算法在很多人看来是从暴力搜索,模拟,数据结构到真正开始接触高效算法的第一步,绝大多数的书籍包括科大的课件都是按照Dijkstra算法->Bellman-Ford算法->Floyd算法介绍的,因为Dijkstra算法只能在非负权图中进行,Bellman-Ford算法可以处理负权图,Floyd算法则是处理图中任意两个结点最短路径的高效方法(我们在后面几章介绍),这也更符合我们先解决简单问题再逐步加难的处理问题的方法(?)。
但在我(们)实际的推理过程中,我们会觉得Bellman-Ford算法更接近于我们刚接触问题想到的“笨办法”(最基本的解决方法),Dijkstra算法则更倾向于我们按照上面的“笨办法”处理了很久总结出来的“聪明办法”,不过这个“聪明办法”我们实际通过数学研究之后发现它适用于我们满足一定条件的绝大多数情况。
事实上我们查阅资料就会发现,Bellman-Ford算法以及它的优化SPFA算法是在1957年提出的,而Dijkstra算法是在它提出的后两年1959年提出的。
那么Floyd算法会不会和这两种算法又有所联系(Floyd算法的正式提出是1962年),或者说所有的算法都是这样若有若无联系起来的?想来也算是枯燥学习的一大乐事了。