图 - 最短路径
从一个顶点到达另一个顶点的成本最小的路径。
我们采用一个一般性的模型,即加权有向图。在加权有向图中,每条有向路径都有一个与之关联的路径权重,它是路径中的所有边的权重之和。这种重要的度量方式使得我们能够将这个问题归纳为 “找到有个顶点到达另一个顶点的权重最小的有向路径”。
单点最短路径。给定一幅加权有向图和一个起点 s ,“从 s 到给定的目的顶点 v 是否存在一条有向路径?如果有,找出最短(总权重最小)的那条路径”。
相关问题:
1.加权有向图的 API 和实现以及单点最短路径的 API;
2.解决边的权重非负的最短路径问题的经典 Dijkstra 算法;
3.在无环加权有向图中解决该问题的一种快速方法,边的权重可以是负数;
4.适用于一般情况的经典 Bellman-Ford 算法,其中图可以含有环,边的权重也可以是负数;
还需要算法来找出负权重的环,以及不含有这种环的加权有向图中的最短路径。
1.最短路径的性质
1.路径是有向的。最短路径需要考虑各条边的方向。
2.权重不一定等价于距离。
3.并不是所有顶点都是可达的。为了简化问题,这里的样图都是强连通的。
4.负权重会使问题更复杂。
5.最短路径一般都是简单的。这里的算法会忽略构成环的零权重边,因此找到的最短路径都不会含有环。
6.最短路径不一定是惟一的。从一个顶点到达另一个顶点的最短路径可能有多条,我们只要找到其中一条即可。
7.可能存在平行边和自环。平行边中权重最小的边才会被选中,最短路径也不可能包含自环,除非自环的权重为零,但会忽略它。
最短路径
我们的重点是单点最短路径问题,其中给出起点 s ,计算的结果是一棵最短路径树(SPT),它包含了顶点 s 到所有可达的顶点的最短路径。
这样一棵树一定存在的:一般来说,从 s 到一个顶点有可能存在两条长度相等的路径,可以删除其中一条路径的最后一条边。如此这般,直到从起点到每个顶点都只有一条路径相连(即一棵树)。通过构造这棵最短路径树,可以为用例提供从 s 到图中任何顶点的最短路径,表示方法为一组指向父结点的链接。
2.加权有向图的数据结构
加权有向图边的API
public class DirectedEdge { private int v;//边的起点 private int w;//边的终点 private double weight;//边的权重 public DirectedEdge(int v,int w,double weight) { this.v = v; this.w = w; this.weight = weight; } public double Weight() { return weight; } public int From() { return v; } public int To() { return w; } }
加权有向图的API
public class EdgeWeightedDigraph { private int v;//顶点总数 private int e;//边的总数 private List<DirectedEdge>[] adj;//邻接表 public EdgeWeightedDigraph(int v) { this.v = v; this.e = 0; adj = new List<DirectedEdge>[v]; for (int i = 0; i < v; i++) { adj[i] = new List<DirectedEdge>(); } } public int V() { return v; } public int E() { return e; } public void AddEdge(DirectedEdge _e) { adj[_e.From()].Add(_e); e++; } public IEnumerable<DirectedEdge> Adj(int v) { return adj[v]; } public IEnumerable<DirectedEdge> Edges() { List<DirectedEdge> edges = new List<DirectedEdge>(); foreach (var _adj in adj) { edges.AddRange(_adj); } return edges; } }
最短路径的API
最短路径的数据结构
最短路径树中的边。和深度优先搜索,广度优先搜索一样,使用一个顶点索引的 DirectedEdge 对象的父链接数组 edgeTo[ ] ,其中 edgeTo[v] 的值为树中连接 v 和它的父结点的的边(也是从 s 到 v 的最短路径上的最后一条边)。
到达起点的距离。我们需要一个由顶点索引的数组 distTo[ ] ,其中 distTo[v] 为从 s 到 v 的已知最短路径的长度。
我们约定,edgeTo[s] 的值为 null,distTo[s] 的值为 0,从起点到不可达的顶点的距离为 Double.MaxValue。
边的松弛
我们的最短路径 API 的实现都基于一个被称为松弛(relaxation)的简单操作。一开始我们只知道图的边和它们的权重,distTo[ ] 中只有起点所对应的元素的值为 0 ,其余元素的值均被初始化为 Double.MaxValue 。 随着算法的执行,它将起点到其他顶点的最短路径信息存入 edgeTo[ ] 和 distTo[ ] 数组。在遇到新的边时,通过更新这些信息就可以得到最短路径。特别是,我们在其中会用到边的松弛技术,定义为:放松边 v -> w 意味着检查从 s 到 w 的最短路径是否是先从 s 到 v,然后再由 v 到 w 。如果是,则根据这个情况更新数据结构的内容。由 v 到 w 的最短路径是 distTo[v] 与 e.Weight() 之和 —— 如果这个值不小于 distTo[w] ,则这条边失效并忽略。
private void Relax(DirectedEdge e) { int v = e.From(), w = e.To(); if(distTo[w] > distTo[v] + e.Weight()) { distTo[w] = distTo[v] + e.Weight(); edgeTo[w] = e; } }
下图是边的放松操作之后可能出现两种情况。一种情况是边失效(左边),不更新任何数据;另一种情况是 v -> w 就是到达 w 的最短路径(右边),这将会更新 edgeTo[w] 和 distTo[w] (这可能会使另一些边失效,但也可能产生一些新的有效边)。
顶点的松弛
实际上,实现会放松从一个给定顶点指出的所有边。从任意 distTo[v] 为有限值的顶点 v 指向任意 distTo[ ] 为无穷的顶点的边都是有效的。如果 v 被放松,那么这些有效边都会被添加到 edgeTo[ ] 中。某条从起点指出的边将会是第一条被加入 edgeTo[ ] 中的边。算法会谨慎选择 顶点,使得每次顶点松弛操作都能得出到达某个顶点的更短路径,最后逐渐找出到达每个顶点的最短路径。
private void Relax(EdgeWeightDigraph G,int v) { foreach(DirectedEdge e in G.Adj(v)) { int w = e.To(); if(distTo[w] > distTo[v] + e.Weight()) { distTo[w] = distTo[v] + e.Weight(); edgeTo[w] = e; } } }
3.最短路径算法的理论基础
边的放松一项非常容易实现的重要操作,它是实现最短路径算法的基础。同时,它也是理解这个算法的理论基础并使我们能够完整地证明算法的正确性。
最优性条件
最短路径的最优性条件:令 G 为一幅加权有向图,顶点 s 是 G 中的起点,distTo[ ] 是一个由顶点索引的数组,保存的是 G 中路径的长度。对于从 s 可达的所有顶点 v ,distTo[v] 的值是从 s 到 v 的某条路径的长度,对于从 s 不可达的所有顶点 v ,该值为无穷大。当且仅当对于从 v 到 w 的任意一条边 e ,这些值都满足 distTo[w] <= distTo[v] + e.Weight() 时(换句话说,不存在有效边时),它们是最短路径的长度。
验证
上面最优性条件的一个重要的实际应用是最短路径的验证。无论一种算法会如何计算 distTo[ ] ,都只需要遍历图中的所有边一边并检查最优性条件是否满足就能够知道该数组中的值是否是最短路径的长度。最短路径的算法可能会很复杂,因此能够快速验证计算的结果就很重要。后面会有 Check() 方法。
通用算法
通用最短路径算法:将 distTo[s] 初始化为 0 ,其他 distTo[ ] 元素初始化为无穷达,继续如下操作:
放松 G 中的任意边,直到不存在有效边为止。
对于任意从 s 可达的顶点 w ,在进行这些操作之后,distTo[w] 的值即为从 s 到 w 的最短路径的长度且 edgeTo[w] 的值即为该路径上的最后一条边。
证明:放松边 v -> w 必然会将 distTo[w] 的值设为从 s 到 w 的某条路径的长度且将 edgeTo[w] 设为该路径上的最后一条边。对于从 s 可达的任意顶点 w,只要 distTo[w] 仍然是无穷达,到达 w 的最短路径上的某条边肯定仍然是有效的,因此算法的操作会不断继续,直到由 s 可达的每个顶点的 distTo[ ] 值均变为到达顶点的某条路径的长度。对于已经找到最短路径的任意顶点 v ,在算法的计算过程中 distTo[v] 的值都是从 s 到 v 的某条路径的长度且必然是单调递减的。因此,它递减的次数必然是有限的(每切换一条 s 到 v 简单路径就递减一次)。当不存在有效边的时候,最优性条件就成立了。
将最优性条件和通用算法放在一起讨论的关键原因是,通用算法并没有指定边的放松顺序。因此,要证明这些算法都能通过计算得到最短路径,只需要证明它们都会放松所有的边直到所有边都失效即可。
4.Dijkstra 算法
在最小生成树中,分享了寻找加权无向图中的最小生成树的 Prim 算法:构造最小生成树的每一步都向这棵树中添加一条新的边。Dijkstra 算法采用了类似的方法来计算最短路径树。首先将 distTo[s] 初始化为 0,distTo[ ] 中的其他元素初始化为正无穷大。然后将 distTo[ ] 最小的非树顶点放松并加入树中,如此这般,直到所有的顶点都在树中或者所有的非树顶点的 distTo[ ] 值均为无穷大。
Dijkstra 算法能够解决边权重非负的加权有向图的单起点最短路径问题。证明:如果 v 是从起点可达的,那么所有 v -> w 的边都只会被放松一次。当 v 被放松时,必有 distTo[w] <= distTo[v] + e.Weight() 。该不等式在算法结束前都会成立,因此 distTo[v] 则不会改变(因为边的权重非负且在每一步中算法都会选择 distTo[ ] 最小的顶点,之后的放松操作不可能使任何 distTo[ ] 的值小于 distTo[v])。因此,在所有从 s 可达的顶点均被添加到树中之后,最短路径的最优性条件成立。
数据结构
要实现 Dijkstra 算法,除了 distTo[ ] 和 edgeTo[ ] 数组之外还需要一条索引优先队列 pq ,以保存需要被放松的顶点。 IndexMinPQ 可以将索引和键(优先级)关联起来并且可以删除并返回优先级最低的索引。在这里,只要将顶点 v 和 distTo[v] 关联起来就立即可以得到 Dijkstra 算法的实现。edgeTo[ ] 中的元素所对应的可达顶点构成了一棵最短路径树。
如下图,根据算法的证明,已知树节点所对应的 distTo[ ] 值均为最短路径的长度。对于优先队列中的任意顶点 w ,distTo[w] 是从 s 到 w 的最短路径的长度,该路径上的中间顶点在树中且路径结束于横切边 edgeTo[w] 。优先级最小的顶点的 distTo[ ] 值就是最短路径的权重,它不会小于已经放松过的任意顶点的最短路径的权重,也不会大于还未被放松过的任意顶点的最短路径的权重。这个顶点就是下一个要被放松的顶点。所有从 s 可达的顶点都会按照最短路径的权重顺序被放松。
实现
public class DijkstraSP { private DirectedEdge[] edgeTo; private double[] distTo; private IndexMinPQ<Double> pq; public DijkstraSP(EdgeWeightedDigraph G,int s) { edgeTo = new DirectedEdge[G.V()]; distTo = new double[G.V()]; pq = new IndexMinPQ<double>(G.V()); for (int v = 0; v < G.V(); v++) { distTo[v] = Double.MaxValue; } distTo[0] = 0.0; pq.Insert(s,0.0); while (!pq.IsEmpty()) { Relax(G,pq.DelMIn()); } } private void Relax(EdgeWeightedDigraph G, int v) { foreach (var e in G.Adj(v)) { int w = e.To(); if (distTo[w] > distTo[v] + e.Weight()) { distTo[w] = distTo[v] + e.Weight(); edgeTo[w] = e; if (pq.Contains(w)) { pq.Change(w, distTo[w]); } else { pq.Insert(w,distTo[w]); } } } } }
轨迹
1.将顶点 0 添加到树中,将顶点 2 和 4 加入优先队列;
2.从优先队列中删除顶点 2,将 0 -> 2 添加到树中,将顶点 7 加入优先队列;
3.从优先队列中删除顶点 4,将 0 -> 4 添加到树中,将顶点 5 加入优先队列,边 4 -> 7 失效;
4.从优先队列中删除顶点 7,将 2 -> 7添加到树中,将顶点 3 加入到优先队列,边 7 -> 5 失效;
5.从优先队列中删除顶点 5,将 4 -> 5添加到树中,将顶点 1 加入优先队列,边 5 -> 7 失效;
6.从优先队列中删除顶点 1,将 5 -> 1添加到树中,边 1 -> 3 失效;
7.从优先队列中删除顶点 6,将 3 -> 6 添加到树中。
算法按照顶点到起点的最短路径的长度的增序将它们添加到最短路径树中。
在一幅含有 V 个顶点和 E 条边的加权有向图中,使用 Dijkstra 算法计算结点为给定起点的最短路径树所需的空间与 V 成正比,时间与 ElogV 成正比(最坏情况下)。
变种
只需对 Dijkstra 算法的实现稍作修改就能解决这个问题的其他版本。例如,加权无向图中的单点最短路径。
如果将无向图看做有向图,创建一幅由相同顶点构成的加权有向图,且对于无向图中的每条边,相应地创建两条方向不同的有向边。有向图中的路径和无向图中的路径存在一一对应的关系,路径的权重也是相同的——最短路径的问题是等价的。
给定两点的最短路径。
给定一幅加权有向图以及一个起点 s 和一个终点 t,找到从 s 到 t 的最短路径。
要解决这个问题,可以使用 Dijkstra 算法并在从优先队列中取到 t 之后终止搜索。
任意顶点对之间的最短路径
下面的代码解决了任意顶点对之间的最短路径问题,所需的时间和空间都与 EVlogV 成正比。它构造了 DijkstraSP 对象的数组,每个元素都将相应的顶点作为起点。在用例进行查询时,代码会访问起点所对应的单点最短路径对象并将目的顶点作为参数进行查询。
public class DijkstraAllPairsSP { private DijkstraSP[] all; public DijkstraAllPairsSP(EdgeWeightedDigraph G) { all = new DijkstraSP[G.V()]; for (int v = 0; v < G.V(); v++) { all[v] = new DijkstraSP(G,v); } } public IEnumerable<DirectedEdge> Path(int s, int t) { return all[s].Path(t); } public double Dist(int s, int t) { return all[s].Dist(t); } }
欧几里得图中的最短路径
在顶点为平面上的点且边的权重于顶点欧几里得间距成正比的图中,解决单点,给定两点和任意顶点对之间的最短路径。
下图是Dijkstra 算法在处理欧几里得图时用若干不同的起点产生最短路径树的过程。
下面,将会考虑加权无环图中的最短路径算法并且将在线性时间内解决该问题。然后是负权重的加权有向图中的最短路径问题,Dijkstra 算法并不适用于这种情况。
5.无环加权有向图中的最短路径算法
许多应用中的加权有向图都是不含有有向环的。现在来看一种比 Dijkstra 算法更快,更简单的在无环加权有向图中找出最短路径的算法,它的特点是:
1.能够在线性时间内解决单点最短路径的问题;
2.能够处理负权重的边;
3.能够解决相关的问题,例如找出最长的路径。
这种算法是在有向图中学过的无环有向图的拓扑排序算法的简单扩展。
特别的是,只要将顶点的放松和拓扑排序结婚起来,马上就能够得到一种解决无环加权有向图中的最短路径问题 的算法。首先,将 distTo[s] 初始化为 0 ,其他 distTo[ ] 元素初始化为无穷大,然后一个一个地按照拓扑顺序放松所有顶点。
命题 S :按照拓扑顺序放松顶点,就能在和 E+V 成正比的时间内解决无环加权有向图的单点最短路径问题。每条边 v –> w 都只会被放松一次。当 v 被放松时,得到:distTo[w] <= distTo[v] + e.Weight() 。在算法结束前该不等式都成立,因为 distTo[v] 是不会变化的(因为是按照拓扑顺序放松顶点,在 v 被放松之后算法不会再处理任何指向 v 的边)而 distTo[w] 只会变小(任何放松操作都只会减小 distTo[ ] 中元素的值)。因此,在所有从 s 可达的顶点都被加入到树中后,最短路径的最优性条件成立。
namespace ShortestPaths { /// <summary> /// 基于拓扑的无环加权有向图的最短路径算法 /// </summary> public class AcyclicSP { private DirectedEdge[] edgeTo; private double[] distTo; public AcyclicSP(EdgeWeightedDigraph G, int s) { edgeTo = new DirectedEdge[G.V()]; distTo = new double[G.V()]; for (var i = 0; i < G.V(); i++) { distTo[i] = Double.MaxValue; } distTo[0] = 0; Topological top = new Topological(G); foreach (var v in top.Order()) { Relax(G,v); } } private void Relax(EdgeWeightedDigraph G, int v) { foreach (DirectedEdge e in G.Adj(v)) { int w = e.To(); if (distTo[w] > distTo[v] + e.Weight()) { distTo[w] = distTo[v] + e.Weight(); edgeTo[w] = e; } } } } }
示例轨迹:
1.用深度优先搜索得到图的顶点的拓扑排序 5 1 3 6 4 7 0 2;
2.将顶点 5 和从它指出的所有边添加到树中;
3.将顶点 1 和边 1->3 添加到树中;
4.将顶点 3 和边 3->6 添加到树中,边 3->7 失效;
5.将顶点 6 和边 6->2, 6->0 添加到树中,边 6->4 失效;
6.将顶点 4 和边 4->0 添加到树中,边 4->7 和 6->0 失效;
7.将顶点 7 和边 7->2 添加到树中,边 6->2 失效;
8.将顶点 0 添加到树中,边 0->2 失效;
9.将顶点 2 添加到树中。
命题 S 很重要,因为它的 “无环” 能够极大地简化问题的论断。对于最短路径问题,基于拓扑排序的方法比 Diijkstra 算法快的倍数与 Diijkstra 算法中所有优先队列操作的总成本成正比。另外,命题 S 的证明和边的权重是否非负无关,因此无环加权有向图不会受任何限制。用这个特点可以解决边的负权重问题。
最长路径
考虑在无环加权有向图中寻找最长路径的问题,边的权重可正可负。
实现:复制原始无环加权有向图得到一个副本并将副本中的所有边的权重变为负值。这样,副本中的最短路径即为原图中的最长路径。要将最短路径问题的答案转换为最长路径问题的答案,只需将方案中的权重变为正值即可。所需时间与 E+V 成正比。
轨迹:
在一般的加权有向图(边的权重可能为负)中寻找最长简单路径的已知最好算法在最坏情况下所需的时间是指数级别。出现环的可能性似乎使这个问题的难度以指数级别增长。
并行调度任务
这里再次考虑有向图中出现过的任务调度问题,这次解决一下调度问题:
优先级限制下的并行任务调度。给定一组需要完成的任务和每个任务所需的时间,以及一组关于任务完成的先后次序的优先级限制。在满足限制条件的前提下应该如何在若干相同的处理器(数量不限)安排任务并在最短时间内完成所有任务?
有向图中调度的模型默认只有单个处理器:将任务按照拓扑顺序排序,完成任务的总耗时就是所有任务所需要的总时间。现在假设有足够多的处理器并能够同时处理任意多的任务,受到的只有优先级的限制。
存在一种线性时间的算法 —— 一种叫做“关键路径”的方法能够证明这个问题与无环加权有向图中的最长路径问题是等价的。
假设任意可用的处理器都能在任务所需的时间内完成它,那么我们的重点就是尽早安排每一个任务。例如,下面表给出了一个任务调度问题。下图给出了解决方案,显示了这个问题所需的最短时间 173.0 。
这份调度方案满足了所有限制条件,没有其他调度方案比这耗时更少,因为任务必须按照 0 -> 9 -> 6 -> 8 -> 2 的顺序完成。这个顺序就是这个问题的关键路径。由优先级限制指定的每一列任务都代表了调度方案的一种可能的时间下限。如果将一系列任务的长度定义为完成所有任务的最早可能时间,那么最长的任务序列就是问题的关键路径,因为在这份任务序列中任何任务的启动延迟都会影响到整个项目的完成时间。
解决:
解决并行任务调度问题的关键路径方法的步骤如下:创建一幅无环加权有向图,其中包含一个起点 s 和一个终点 t 且每个人物都对应着两个顶点(一个起始顶点和一个结束顶点。对于每个任务都有一条从它的起始顶点指向结束顶点的边,边的权重为任务所需要的时间。对于每条优先级限制 v -> w ,添加一条从 v 的结束顶点指向 w 的起始顶点的权重为零的边。我们还需要为每个任务添加一条从起点指向该任务的起始顶点的权重为零的边以及一条从该任务的结束顶点到终点的权重为零的边。这样,每个任务预计的开始时间即为从起点到它的起始顶点的最长距离。
接下来就是在无环加权有向图中寻找一个最长路径——关键路径。
static void Main(string[] args) { string[] strs = File.ReadAllLines(@"jobs.txt"); var N = Int32.Parse(strs[0]);//任务数 EdgeWeightedDigraph G = new EdgeWeightedDigraph(2*N+2);//2*N+2 为节点数,每个任务两个我节点,再加上起始两个节点 int s = 2 * N, t = 2 * N + 1;//起点和终点 for (var i = 0; i < N; i++) { string[] a = strs[i].Split(" "); double duration = Double.Parse(a[0]); G.AddEdge(new DirectedEdge(i,i+N,duration));//任务起点指向任务终点 G.AddEdge(new DirectedEdge(s,i,0)); G.AddEdge(new DirectedEdge(i+N,t,0)); for (var j = 1; j < a.Length; j++) { int successor = Int32.Parse(a[j]); G.AddEdge(new DirectedEdge(i+N,successor,0)); } } AcyclicSP lp = new AcyclicSP(G,s); for (var i = 0; i < N; i++) Console.WriteLine($"{i} 开始时间:"+lp.DistTo(i)); Console.WriteLine("t distTo:" + lp.DistTo(t)); }
这里实现的任务调度问题的关键路径方法将问题归约为寻找无环加权有向图的最长路径问题。它会根据任务调度问题的描述用关键路径的方法构造一幅加权有向图,然后使用 AcylicLP 找到图中的最长路径,最后打印出各条最长路径的长度,也就是正好是每个任务的开始时间。
解决优先级限制下的并行任务调度问题的关键路径法所需的时间为线性级别。为什么 CPM 类能解决问题?算法的正确性依赖于两个因素。首先,在相应的有向无环图中,每条路径都是由任务的起始顶点和结束顶点组成的并由权重为零的优先级限制条件的边分隔 —— 从起点 s 到任意顶点 v 的任意路径的长度都是任务 v 的开始 / 结束时间的下限,因为这已经是在同一台处理器上顺序完成这些任务的最优的排列顺序了。因此,从起点 s 到终点 t 的最长路径就是所有任务的完成时间的下限。第二,由最长路径得到的所有开始和结束时间都是可行的 —— 每个任务都只能在优先级限制指定的先导任务完成之后开始,因为它的开始时间就是顶点到它的起始顶点的最长路径的长度。因此,从起点 s 到 终点 t 的最长路径长度就是所有任务完成时间的上限。
相对最后期限限制下的并行任务调度
一般的最后期限(deadline)都是相对于第一个任务的开始时间而言的。假设在任务调度问题中加入一种新类型的限制,需要某个任务必须在指定的时间点之前开始,即指定和另一个任务的开始时间的相对时间。这种类型的限制条件在争分夺秒的生产线上以及许多其他应用中都很常见,但它也会使得任务调度问题更难解决。如下表,假设要在前面的示例中加入一个限制条件,使 2 号任务必须在 4 号任务启动后的 12 个时间单位之内开始。实际上,在这里最后期限限制的是 4 号任务的开始时间:它的开始时间不能早于 2 号任务开始 12 个时间单位。在示例中,调度表中有足够的空档来满足这个最后期限限制:我们可以令 4 号任务开始于 111 时间,即 2 号任务计划开始时间前的 12 个时间单位处。需要注意的是,如果 4 号任务耗时很长,这个修改可能会延长整个调度计划的完成时间。同理,如果再添加一个最后期限的限制条件,令 2 号任务必须在 7 号任务启动后的 70 个时间单位内开始,还可以将 7 号任务的开始时间调整到 53,这样就不用修改 3 号任务和 8 号任务的计划开始时间。但是如果继续限制 4 号任务必须在 0 号任务启动后的 80 个时间单位内开始,那么就不存在可行的调度计划了:限制条件 4 号任务必须在 0 号任务启动后的 80 个时间单位内开始以及 2 号任务必须在 4 号任务启动后的 12 个时间单位之内开始,意味着 2 号任务必须在 0 号任务启动后的 93 个时间单位之内开始,但因为存在任务链 0(41 个时间单位)-> 9(29 个时间单位)-> 6(21 个时间单位)-> 8(32 个时间单位)-> 2,2 号任务最早也只能在 0 号任务启动后的 123 个时间单位之内开始。
向任务调度问题中添加的最后期限限制
相对最后期限限制下的并行任务调度问题是一个加权有向图中的最短路径问题(可能存在环和负权重边)。 根据任务调度的描述构造加权有向图,为每条最后期限限制添加一条边:如果任务 v 必须在任务 w 启动后的 d 个单位时间内开始,则添加条从 v 指向 w 的负权重为 d 的边。将所有边的权重取反即可将该问题转化为一个最短路径问题。如果存在可行的调度方案,证明也就完成了。判断一个调度方案是否可行也是计算的一部分。
上面的示例说明了负权重的边在实际应用的模型中也能起到重要作用。它说明,如果能够有效解决负权重边的最短路径问题,那就能够找到相对最后期限限制下的并行任务调度问题的解决方案。之前学过的算法都无法完成这个任务:Dijkstra 算法只适用于正权重的边,AcylicSP 算法要求有向图是无环的。下面解决含有负权重且不一定是无环的有向图中的最短路径问题。
6.一般加权有向图中的最短路径问题
上面讨论的最后期限限制下的任务调度问题告诉我们负权重的边不仅仅是一个数学问题。相反,它能够极大地扩展解决最短路径问题模型的应用范围。接下来,考虑既可能含有环也可能含有负权重的边的加权有向图中的最短路径算法。
开始之前,先学习一下这种有向图的基本性质以及更新我们对最短路径的认识。下图展示的是负权重的边对有向图中的最短路径的影响。也许最明显的改变就是当存在负权重的边时,权重较小的路径含有的边可能会比权重较大的路径更多。在只存在正权重的边时,我们的重点在于寻找近路;但当存在负权重的边时,我们可能会为了经过负权重的边而绕远。这种效应使得我们要将查找 “最短” 路径的感觉转变为对算法本质的理解。因此需要抛弃直觉并在一个简单,抽象的层面上考虑这个问题。
尝试一
第一个想法是先找到权重最小(最小负值)的边,然后将所有边的权重加上这个负值的绝对值,这样原有向图就转变成了一幅不含有负权重边的有向图。但这种做法不会解决任何问题,因为新图中的最短路径和原图中的最短路径毫无关系。
尝试二
第二个想法是改造 Dijkstra 算法。这种算法最根本的缺陷在于原算法的基础在于根据距离起点的远近依次检查路径,添加一条边会使路径变得更长。但添加任意负权重的边只会使得路径更短。
负权重的环
当我们在研究含有负权重边的有向图时,如果该图中含有一个权重为负的环,那么最短路径的概念就失去意义了。如下图,除了边 5 -> 4 的权重为 -0.66 外,它和前面的示例完全相同。这里,环 4-> 7 -> 5 -> 4 的权重为:
0.37 + 0.28 – 0.66 = -0.01;
我们只要围着这个环兜圈子就能得到权重任意短的路径!注意,有向环的所有边的权重并不一定都必须是负的,只要权重之和是负的即可。
定义:加权有向图中的负权重环是一个总权重为负的有向环。
现在,假设从 s 到可达的某个顶点 v 的路径上的某个顶点在一个负权重环上。在这种情况下,从 s 到 v 的最短路径是不可能存在的,因为可以利用这个负权重环构造权重任意小的路径。换句话说,在负权重环存在的情况下,最短路径问题是没有意义的。
当且仅当加权有向图中至少存在一条从 s 到 v 的有向路径且所有从 s 到 v 的有向路径上的任意顶点都不存在于任何负权重环中时,s 到 v 的最短路径才是存在的。
注意,要求最短路径上的任意顶点都不存在于负权重环中意味着最短路径是简单的,而且与正权重边的图一样都能够得到此类顶点的最短路径树。
尝试三
无论是否存在负权重环,从 s 到可达的其他顶点的一条最短的简单路径都是存在的。为什么不定义最短路径以方便寻找呢?但是,已知解决这个问题的最好算法在最坏情况下所需的时间是指数级别的(后面会降到)。一般来说,这种问题太难了,只会研究它的简单版本。
因此,一个定义明确且可以解决加权有向图最短路径的算法要能够:
1.对于从起点不可达的顶点,最短路径为正无穷;
2.对于从起点可达但路径上的某个顶点属于一个负权重环的顶点,最短路径为负无穷;
3.对于其他所有顶点,计算最短路径的权重。
从文章的开始到现在,我们为最短路径问题加上了各种限制,使得我们能够找到解决相应问题的办法。首先,我们不允许负权重边的存在;其次不接受有向环。现在我们放宽所有这些条件并重点解决一般有向图中的问题。
负权重环的检测。 给定的加权有向图中含有负权重环吗?如果有,找到它。
负权重环不可达时的单点最短路径。给定一幅加权有向图和一个起点 s 且从 s 瓦法到达任何负权重环。是否存在一条从 s 到给定的顶点 v 的有向路径?如果有,找出最短的那条路径。
总结。尽管在含有环的有向图中最短路径是一个没有意义的问题,而且也无法有效解决在这种有向图中高效找出最短简单路径的问题,在实际应用中仍然需要能够识别其中的负权重环。例如,在最后期限限制下的任务调度问题中,负权重环的出现可能相对较少;限制条件和最后期限都是从现实世界中的实际限制得来的,因此负权重环大多可能来自于问题陈述中的错误。找出负权重环,改正相应的错误,找到没有负权重环问题的调度方案才是解决问题的正确方式。在其他情况下,找到负权重环就是计算的目标。
Bellman-Ford 算法能够有效解决这些问题并且同样适用于正权重边的有向图。
Bellman-Ford 算法。在任意含有 V 个顶点的加权有向图中给定起点 s ,从 s 无法到达任何负权重环,以下算法能够解决其中的单点最短问题:将 distTo[s] 初始化为 0 ,其他 distTo[ ] 元素初始化为无穷大。以任意顺序放松有向图所有边,重复 V 轮。
这个方法非常通用,因为它没有指定边的放松顺序。下面将注意力集中在一个通用性稍逊的方法上,其中只放松从任意顶点指定的所有边(任意顺序):
for (int pass = 0; pass < G.V(); pass++) { for (v = 0; v < G.V(); v++) { foreach (DirectedEdge e in G.Adj(v)) Relax(e); } }
它总是会放松 VE 条边且只需稍作修改即可使算法在一般情景下更高效。
基于队列的 Bellman-Ford 算法
其实,根据经验我们很容易知道在任意一轮中许多边的放松都不会成功:只有上一轮中的 distTo[ ] 值发生变化的顶点指出的边才能够改变其他 distTo[ ] 元素的值。为了记录这样的顶点,我们使用了一条 FIFO 队列。算法在处理正权重标准样图中进行的操作轨迹如下图,在图中,左侧是每一轮中队列中的有效顶点(红色),紧接着是下一轮中的有效顶点(黑色)。首先将起点加入队列,然后按照以下步骤计算最短路径树:
1.放松边 1 -> 3 并将顶点 3 加入队列;
2.放松边 3 -> 6 并将顶点 6 加入队列;
3.放松边 6 -> 4, 6 -> 0 和 6 -> 2 并将顶点 4,0 和 2 加入队列;
4.放松边 4 -> 7,4 -> 5 并将顶点 7 和 5 加入队列。放松已经失效的边 0 -> 4 和 0 -> 2。然后再放松边 2 -> 7 (并重新为 4 -> 7 着色)。
5.放松边 7 -> 5 (并重新为 4 -> 5 着色)但不将顶点 5 加入队列(它已经在队列中了)。放松已经失效的边 7 -> 3。然后放松已经失效的边 5 -> 1, 5 -> 4 和 5 -> 7。此时队列为空。
实现
根据上面的描述实现 Bellman-Ford 算法所需的代码很少,它基于以下两种其他的数据结构:
1.一条用来保存即将被放松的顶点的队列 Queue;
2.一个由顶点索引的 bool 数组 OnQ[ ] ,用来指示顶点是否已经存在于队列中,以防止将顶点重复加入队列。
首先,将起点 s 加入队列中,然后进入一个循环,其中每次都从队列中取一个顶点并将其放松。要将一个顶点插入队列,需要修改之前的 Relax 方法实现,以便将被成功放松的边所指向的顶点加入队列中。这些数据结构能够保证:
1.队列中不会出现重复的顶点;
2.在某一轮中,改变了 edgeTo[ ] 和 distTo[ ] 的值得所有顶点都会在下一轮中处理。
要完整地实现该算法,我们就需要保证在 V 轮后算法能够终止。实现它的一种方法是显式记录放松的轮数。下面的代码使用了另一种算法,后面详细说:它会在有向图的 edgeTo[ ] 中检测是否存在负权重环,如果找到则结束运行。
public class BellmanFordSP { private double[] distTo;//从起点到某个顶点的路径长度 private DirectedEdge[] edgeTo;//从起点到某个顶点的最后一条边 private bool[] onQ;//该顶点是否存在于队列中 private Queue<int> queue;//正在被放松的顶点 private int cost;//relax 的调用次数 private IEnumerable<DirectedEdge> cycle;//edgeTo[] 中的是否有负权重环 public BellmanFordSP(EdgeWeightedDigraph G,int s) { distTo = new double[G.V()]; edgeTo = new DirectedEdge[G.V()]; onQ = new bool[G.V()]; queue = new Queue<int>(); for (int v = 0; v < G.V(); v++) distTo[v] = Double.MaxValue; distTo[s] = 0; queue.Enqueue(s); onQ[s] = true; while (queue.Count != 0 && !HasNegativeCycle()) { int v = queue.Dequeue(); onQ[v] = false; Relax(G,v); } } //负权重环的检测 private bool HasNegativeCycle() { throw new NotImplementedException(); } private void Relax(EdgeWeightedDigraph G, int v) { foreach (DirectedEdge e in 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]) { queue.Enqueue(w); onQ[w] = true; } } if (cost++ % G.V() == 0) FindNegativeCycle(); } } //查找负权重环 private void FindNegativeCycle() { throw new NotImplementedException(); } }
Relax 方法将成功放松的边指向的所有顶点加入到一条 FIFO 队列中(队列中不出现重复的顶点)并周期性地检查 edgeTo[ ] 表示的子图中是否存在负权重环。
对于任意含有 V 个顶点的加权有向图和给定的起点 s ,在最坏情况下基于队列的 Bellman-Ford 算法解决最短路径问题(或者找到从 s 可达的负权重环)所需的时间与 EV 成正比,空间和 V 成正比。
如果不存在从 s 可达的负权重环,算法会在进行 V-1 轮放松操作后结束(因为所有最短路径含有的边数都小于 V-1)。如果的确存在一个从 s 可达的负权重环,那么队列永远不可能为空。在第 V 轮放松之后,edgeTo[ ] 数组必然会包含一条含有一个环的路径(从某个顶点 w 回到它自己)且该环的权重必然是负的。因为 w 会在路径上出现两次且 s 到 w 的第二次出现处的路径长度小于 s 到 w 的第一次出现的路径长度。在最坏情况下,该算法的行为和通用算法相似并会将所有的 E 条边全部放松 V 轮。
基于队列的 Bellman-Ford 算法对于相同的问题比较路径长度的次数少于 Disjkstra 算法。
负权重的边
下图显示了 Bellman-Ford 算法在处理含有负权重边的有向图的轨迹。首先将起点加入队列 queue ,然后按照以下步骤计算最短路径树。
1.放松边 0 -> 2 和 0 -> 4 并将顶点 2,4 加入队列。
2.放松边 2 -> 7 并将顶点 7 加入队列。放松边 4 -> 5 并将顶点 5 加入队列。然后放松失效的边 4 -> 7。
3.放松边 7 -> 3 和 5 -> 1 并将顶点 3 和 1 加入队列。放松失效的边 5 -> 4 和 5 -> 7 。
4.放松边 3 -> 6 并将顶点 6 加入队列。放松失效的边 1 -> 3 。
5.放松边 6 -> 4 并将顶点 4 加入队列。这条负权重的边使得到顶点 4 的路径变短,因此它的边需要被再次放松。从起点到顶点 5 和 1 的距离已经失效并会在下一轮修正。
6.放松边 4 -> 5 并将顶点 5 加入队列。放松失效的边 4 -> 7 。
7.放松边 5 -> 1 并将顶点 1 加入队列。放松失效的边 5 -> 4 和 5 -> 7 。
8.放松失效的边 1 -> 3 。队列为空。
在这个例子中,最短路径树就是一条从顶点 0 到顶点 1 的路径。从顶点 4,5 和 1 指出的所有边都被放松了两次。
负权重环的检测
实现 BellmanFordSP 会检测负权重环来避免陷入无限的循环中。我们也可以将这段检测代码独立出来使得用例可以检查并得到负权重环。在 BellmanFordSP 的构造函数运行之后,在将所有边放松 V 轮之后当且仅当队列非空时有向图中才存在从起点可达的负权重环。如果是这样, edgeTo[ ] 数组所表示的子图必然含有这个负权重环。我们修改有向图中的 DirectedCycle 类来在加权有向图中寻找环。这种检查的成本分为以下几个部分:
1.添加一个变量 cycle 和一个私有函数 FindNegativeCycle 。如果找到负权重环,该方法会将 cycle 的值设为含有环中所有边的一个迭代器(如果没有找到则设为 null)。
2.每调用 V 次 Relax 方法后即调用 FindNegativeCycle 方法。
这种方法能够保证构造函数中的循环必然终止。另外,用例可以调用 HasNegativeCycle 来判断是否存在从起点可达的负权重环。
//查找负权重环 private void FindNegativeCycle() { int V = edgeTo.Length; EdgeWeightedDigraph spt; spt = new EdgeWeightedDigraph(V); for (int v = 0; v < V; v++) { if (edgeTo[v] != null) spt.AddEdge(edgeTo[v]); } EdgeWeightedCycleFinder cf; cf = new EdgeWeightedCycleFinder(spt); cycle = cf.Cycle(); } //负权重环的检测 private bool HasNegativeCycle() { return cycle != null; } public IEnumerable<DirectedEdge> NegativeCycle() { return cycle; }
下图是 Bellman-Ford 算法在一幅含有负权重环的有向图中的运行轨迹。头两轮放松操作与前面的例子一样,在第三轮中,算法放松了边 7 -> 3 和 5 -> 1 并将顶点 3 和 1 加入队列后开始放松负权重边 5 -> 4 。在这次放松操作中算法发现了一个负权重环 4 -> 5 -> 4 。它将 5 -> 4 加入最短路径树中并在 edgeTo[ ] 将环和起点隔离起来。从这时开始,算法沿着环继续运行并减少到达所遇到的所有顶点的距离,直至检测到环的存在,此时队列非空。环被保存在 edgeTo[ ] 中,FindNegativeCycle 会在其中找到它。
7.总结
下表总结了上面的各种最短路径算法的重要性质。在这些算法中进行选择的第一个条件是问题所涉及的有向图的基本性质。它含有负权重的边吗?它含有环吗?它含有负权重的环吗?除了这些基本性质之外,加权有向图的特性多种多样,因此在有多个合适的选择时就需要通过实验找出最佳算法。