简介
我在之前的文章里有对Dijkstra's Algorithm进行了思路的分析和讨论,但是也提到了一点,就是这个算法它有一点限制,要求图里面所有的边的权值都是非负的。因为如果有权值为负数的边,就打破了原来算法里每次取得的当前最小值是全局最优值这个假定了。那么在图里面真有权值为负数的边时,甚至在某些情况下存在有加权值为负数的环时,我们该怎么来计算单点源的最短路径呢?
这里,我们将对Bellman-Ford算法的过程进行讨论。
算法分析
在具体讨论Bellman-Ford算法之前,我们先针对Dijkstra's算法的局限思考一下。这个算法的思路是每次取到给定源最短的路径去作为扩展更新的方向,要满足它取的当前最短路径就是全局最短路径这个条件,就要求它里面所有边的权值都是正数,否则这个条件就不满足了。现在,如果在图里面有一些边为负数,那么该怎么来处理这个问题呢?
我们先来看一个包含有负数权值边的图:
假定上图中我们的起点节点是0,从它开始我们尝试去计算到其他节点的最短距离。它到节点1的最短距离是14,而从0 -> 1 -> 4的距离是14 - 21 = -7。而另外一块0 -> 2 的距离是10,0 -> 2 -> 3 -> 4的距离是10。它比原来的0 -> 1 - > 4的距离大,所以取-7作为最小距离。这里有一个比较有意思的地方,就是 2 -> 3 -> 4 -> 5 -> 2形成了一个环,而且如果我们从2开始走一圈这个环则发现它的加权值是-4。这意味着如果我从2开始走一圈,到节点2的权值比原来要小4。那么这里就存在一个问题,如果我这边围着这个圈绕无数圈呢?理论上它的最短路径就成了无穷小了。显然,这种情况是不能接受的。
另外一个,既然存在有负数的边打破了原来算法的假定,这里就会出现一个情况。我前面假定的一个最短的路径上的点,在后面通过其他路径遍历过来的时候发现其实更加短。那么这个点原来遍历过的一些后续节点也需要再一次更新。比如下图:
我们从起点1开始,基于最开始的选择,它到2节点的最短距离是1,然后它再遍历到节点4的距离是1 + 4 = 5。但是实际上如果我们走路径1 -> 3 -> 5 -> 2,它的距离则为-2,因此这里有一个更加短的路径。这个时候,在前面的遍历过程中,节点4已经访问过了。但是这里我们碰到了更近的路径,所以需要对节点4的路径值再更新一下。
经过上面这两种情况的讨论,我们估计就有了这么一个基本的思路。就是要求包含有负权值边的图中间单点源到其他节点的最短路径,它不是所有情况下都存在的。如果图中有权值和为负数的环,那么求它的最短路径就没有意义了,所以这种情况要避免。另外,在遍历的过程中,如果碰到有比原来节点更加短的路径,则需要继续去更新后续的节点。尤其是碰到的节点是之前访问过的节点的情况下。
实现考量
经过上述的讨论,我们已经有了一个基本的思路。在这个算法中,首先需要通过某种方式来遍历图。在之前的一些讨论中我们也知道,我们可以选择深度优先或者广度优先遍历。那么选择哪种更加合适呢?要看这个问题里的具体情况。因为这里还要求每次碰到一个路径更短的节点,就要继续去更新这个节点后续的节点。也就是说和这个节点相邻的所有节点。从实现的角度来说,我们要记录这个后续需要访问的节点。如果采用深度优先遍历的话,这些需要以后遍历的节点不太好标记,而此时用广度优先遍历的方式显得更加自然一些。
这里还有一个关键的问题,就是常用的那两种遍历方式都是通过有一个关联的bool类型数组来记录某个节点是否已经被访问过。这样在后续再次碰到这个节点的时候就跳过去。而这里是如果后续碰到了这个节点,但是这个节点的新距离更短就需要重新捋一遍。所以在实现里就不能仅仅是做一个标记就完了。我们在每次碰到一个比之前距离更近的节点时,需要根据它是否在当前访问的队列里来处理。如果它在当前访问的队列里,那么代表在后续肯定会再遍历它,这里就不用把它加入到队列里,否则就需要加入进去。而每次从队列里取一个元素出来的时候,就需要将它的标记设置为false,表示它已经不在这个访问队列里了。从这个角度来说,我们用的这个关联的bool数组不是用来记录它是否被访问过,而是用来记录它是否在访问队列中。
根据上述讨论,我们可以得到一个基础的实现代码如下:
public class BellmanFordSP {
private double[] distTo;
private DirectedEdge[] edgeTo;
private boolean[] onQ;
private Queue<Integer> queue;
private int cost;
private Iterable<DirectedEdge> cycle;
public BellmanFordSP(EdgeWeightedDigraph g, int s) {
distTo = new double[g.vertices()];
edgeTo = new DirectedEdge[g.vertices()];
onQ = new boolean[g.vertices()];
queue = new LinkedList<>();
for(int v = 0; v < g.vertices(); v++)
distTo[v] = Double.MAX_VALUE;
distTo[s] = 0.0;
queue.add(s);
while(!queue.isEmpty() && !this.hasNegativeCycle()) {
int v = queue.remove();
onQ[v] = false;
relax(v);
}
}
}
上述代码是整个实现的基础,这里定义了一个队列,用于广度优先遍历实现,同时数组boolean[] onQ用来区分元素是否在队列里。hasNegativeCycle作为判断退出的条件之一。
上述代码里relax方法的实现如下:
private void relax(EdgeWeightedDigraph g, int v) {
for(DirectedEdge e : g.adj(v)) {
int w = e.to();
if(distTo[w] > distTo[v] + e.weight()) {
distTo[w] = distTo[v] + e.weight();
edgeTo[w] = e;
if(!onQ[w]) {
q.add(w);
onQ[w] = true;
}
}
if(cost++ % g.vertices() == 0)
findNegativeCycle();
}
}
实际上上述代码的逻辑也比较好理解,它就是一个近似BFS里取出当前节点后的操作。将该节点距离加上所有邻接节点的权值,看是否比原来的更加短。如果短一些,则这个节点如果不在队列中的话就要加入进去。这里还有一个细节值得注意的就是因为会碰到一些重新遍历原有节点的过程,在实际运行的时候如果又碰上有权值和为负数的环的话,它就进入了一个无穷循环了。所以这里用一个额外的判断。也就是用cost来记录遍历的次数,一旦遍历到图的节点个数那么多次的时候,就判断一次。
为什么要遍历那么多次来判断一次呢?一般来说,如果没有重复遍历的话,在BFS的过程中就相当于将所有的节点都遍历了一遍了。 如果没有的话,就可能已经形成了负值环,也好判断。
这里还有一个细节就是,在图里该怎么判断存在有负值环呢?我们知道之前有一些方法可以判断图中是否存在有环,而是否有负值环的话则是在找到一个环的时候将它所哟边权值加起来看是否为负数,如果为负数则表示存在有,否则就是没有。找环的思路在之前的文章里也有讨论过,一种办法就是用深度优先遍历的方式,每次在一个节点递归调用的时候设置它对应位置的标记值,而在退出的时候设置回去。如果在这个过程中还是碰到了之前标记过的元素,说明形成了一个环。这部分的一个参考实现代码如下:
private void dfs(EdgeWeightedDigraph G, int v) {
onStack[v] = true;
marked[v] = true;
for (DirectedEdge e : G.adj(v)) {
int w = e.to();
// short circuit if directed cycle found
if (cycle != null) return;
// found new vertex, so recur
else if (!marked[w]) {
edgeTo[w] = e;
dfs(G, w);
}
// trace back directed cycle
else if (onStack[w]) {
cycle = new Stack<DirectedEdge>();
DirectedEdge f = e;
while (f.from() != w) {
cycle.push(f);
f = edgeTo[f.from()];
}
cycle.push(f);
// if sum of all items in cycle < 0 return
if(sum(cycle) < 0)
return;
}
}
onStack[v] = false;
}
上述的代码省略了一些细节,不过还是遵循这个基本的思路。这样,整个Bellman-Ford算法的过程就完成了。
总结
总的来说,Bellman-Ford算法的过程类似于BFS的遍历过程,它在遍历过程中将需要加入队列中的元素做一个标记,而从队列中取出的元素则将标记设置回去。在每次碰到一个元素的时候则判断距离的变化,如果距离更近则将这个节点及后续的节点都重新遍历一把。这个实现通过将这个元素加入到队列里就实现了。看似简单的一步却实现了一个很重要的特性。
在循环的过程中还有几个比较麻烦的就是要每隔若干次之后要判断图中间是否存在有权值和为负数的环,以避免程序进入一个无限循环。总的来说,它这几个步骤稍微有点复杂,不过还是不难理解。
参考材料