【图论算法】最短路径算法(无权最短路径、Dijkstra算法、带负边值的图、无圈图)

本篇博客将考察各种最短路径问题。
    无权最短路径
    Dijkstra 算法
    具有负边值的图
    无圈图
    所有顶点对间的最短路径
    最短路径的例子–词梯游戏

输入是一个赋权图:与每条边 (vi, vj) 相联系的是穿越该边的开销(或称为值)ci,j 。一条路径v1v2……vN的值是 【图论算法】最短路径算法(无权最短路径、Dijkstra算法、带负边值的图、无圈图)_第1张图片
这叫作赋权路径长(weighted path length)。而无权路径长只是路径上的边数,即 N-1。

单源最短路径问题(Single-Source Shortest-Path Problem):

给定一个赋权图 G=(V, E) 和一个特定顶点 s 作为输入,找出从 s 到 G 中每一个其他顶点的最短赋权路径。

例如,在图1 中,从 v1 到 v6 的最短赋权路径的值为6,路径为 v1 -> v4 -> v7 -> v6 ;在这两个顶点间的最短无权路径长为2。
【图论算法】最短路径算法(无权最短路径、Dijkstra算法、带负边值的图、无圈图)_第2张图片

图1 一个有向图G

前面例子中没有负值边,图2 中的图就指出了负边可能产生的问题。从 v5 到 v4 的路径值为1,但通过循环 v5, v4, v2, v5, v4 存在一条更短的路径,它的值为-5,这个最短路径仍然可以通过循环达到任意小。这个循环叫作负值圈(negative-const cycle)。在没有负值圈时,从 s 到 s 的最短路径为0。
【图论算法】最短路径算法(无权最短路径、Dijkstra算法、带负边值的图、无圈图)_第3张图片

图2 带有负值圈的图

我们可能使用图建立航线或其他大规模运输线路的模型,并利用最短路径算法计算两点间的最佳路线。在这样的以及许多实际应用中,我们可能想要找出从一个顶点 s 到另一个顶点 t 的最短路径。 当前,还不存在找出从 s 到一个顶点的路径比找出从 s 到所有顶点路径更快得算法。

我们将考虑求解该问题 4种形态的算法。首先,要考虑无权最短路径问题,并指出如何以 O(|E|+|V|) 时间求解它。其次,如果假设没有负边,那么如何求解赋权最短路径问题,这个算法在使用一些合理的数据结构实现时的运行时间为 O(|E| log|V|)。如果图有负边,则提供一个简单解法,时间界为 O(|E|·|V|)。最后,将以线性时间解决无圈图这种特殊情形的赋值问题。

无权最短路径

图3 表示一个无权图G,使用某个顶点 s 作为输入参数,我们想要找出从 s 到所有其他顶点的最短路径。显然,这是赋权最短路径问题的特殊情形,因为可以为所有的边都赋以权1。
【图论算法】最短路径算法(无权最短路径、Dijkstra算法、带负边值的图、无圈图)_第4张图片
设我们选择 s 为 v3,此时可立即得到 s 到 v3 的最短路径长为0,将其标记,如图4 所示。

然后开始寻找所有从 s 出发距离为1 的顶点,这可以通过考查邻接到 s 的那些顶点找到,即 v1 和 v6 ,将它表示在图5 中;然后找出从 s 出发最短路径恰为2 的顶点,找出所有邻接到 v1 和 v6 的顶点,这次搜索告诉我们,到 v2 和 v4 的最短路径长为2。
【图论算法】最短路径算法(无权最短路径、Dijkstra算法、带负边值的图、无圈图)_第5张图片
最后,通过考查那些邻接到刚被赋值的 v2 和 v4 的顶点可以发现, v5 和 v7 各有一条三边的最短路径,现在所有的顶点都已经被计算,如图7 所示。
【图论算法】最短路径算法(无权最短路径、Dijkstra算法、带负边值的图、无圈图)_第6张图片

这种搜索图的方法就是广度优先搜索(breadth-first search)。该方法按层处理顶点:距开始点最近的那些顶点首先被求值,而最远的那些顶点最后被求值,这很像树的层序遍历(level-order traversal)。

图8 显示出该算法要用到的表的初始配置,记录了该算法的进行过程。对于每个顶点,我们跟踪3 条信息。首先,把从 s 开始到顶点的距离放到 dv 栏中,开始时除 s 外所有的顶点都不可达。pv 栏中的项为簿记变量,它将使我们显示出实际的路径。known 栏中的项在顶点被处理后置为 true。当一个顶点被标记为 known 时,我们就有了不会再找到更便宜的路径的保证,因此对该顶点的处理实质上已经完成。

无权最短路径算法的伪代码

void Graph::unweighted(Vertex s)
{
	for each Vertex v
	{
		v.dist = INFINITY;
		v.known = false;
	}

	s.dist = 0;

	for(int currDist=0;currDist<NUM_VERTICES;currDist++)
		for each Vertex v
			if (!v.known && v.dist == currDist)
			{
				v.known = true;
				for each Vertex w adjacent to v
					if (w.dist == INFINITY)
					{
						w.dist = currDist + 1;
						w.path = v;
					}
			}
}

由于双层嵌套的 for 循环,因此该算法的运行时间为 O(|V|2)。一个明显的低效之处在于,尽管所有的顶点早已成为 known 了,但外层循环还是要继续,直到 NUM_VERTICES -1 为止。我们可以用类似于对拓扑排序所做的那样来排除这种低效性。在任一时刻,只存在两种类型其 dv ≠ ∞ 的 unknown 顶点。一些顶点的 dv =currDist,而其余的则有 dv = currDist + 1。这种想法可以通过使用一个队列而被进一步精化。其中数据变化如图9 所示。

【图论算法】最短路径算法(无权最短路径、Dijkstra算法、带负边值的图、无圈图)_第7张图片

图9 无权最短路算法期间数据变化

相关伪代码及其说明如下。

void Graph::unweighted(Vertex s)
{
	Queue<Vertex> q;

	for each Vertex v
		v.dist = INFINITY;

	s.dist = 0;
	q.enqueue(s); //初始时队列只含距离为currDist的顶点

	while (!q.isEmpty())
	{
		Vertex v = q.dequeue();

		for each Vertex w adjacent to v
			if (w.dist == INFINITY)
			{
				w.dist = v.dist + 1;
				w.path = v;
				q.enque(w); //距离为currDist+1的邻接顶点自队尾入队
			}
	}
}

Dijkstra算法

解决单源最短路径问题的一般方法叫作 Dijkstra 算法。这个有30 年的历史的解法是贪婪算法最好的实例。贪婪算法一般分阶段求解一个问题,在每个阶段它都把出现的当作是最好的去处理。

Dijkstra 算法按阶段进行,正像无权最短路径算法一样。在每个阶段,Dijkstra 算法选择一个顶点 v,它在所有 unknown 顶点中具有最小的 dv,同时算法声明从 s 到 v 的最短路径是 known 的。阶段的其余部分由 dw 值的更新工作组成。
【图论算法】最短路径算法(无权最短路径、Dijkstra算法、带负边值的图、无圈图)_第8张图片

对于图1 中的例子,图10 表示初始配置,这里假设开始节点为 v1。第一个选择的顶点是 v1,路径的长为0。该顶点标记为 known,那么某些表项就需要调整。邻接到 v1 的顶点是 v2 和 v4,这两个顶点的项得到调整,如图11 所示。
【图论算法】最短路径算法(无权最短路径、Dijkstra算法、带负边值的图、无圈图)_第9张图片
下一步,选取 v4 并标记为 known。顶点 v3,v5,v6,v7 是邻接的顶点,都需要调整,如图12 所示。
【图论算法】最短路径算法(无权最短路径、Dijkstra算法、带负边值的图、无圈图)_第10张图片
接着选择 v2 。v4 已经是 known 的了,v5 是邻接的点但不做调整,因为经过 v2 的值为 2+10=13 大于已知的路径。然后选择 v5,v3,对 v6 的距离下调到 3+5=8。
【图论算法】最短路径算法(无权最短路径、Dijkstra算法、带负边值的图、无圈图)_第11张图片
再下一个选取的顶点为 v7:v6 下调到 5+1=6,如图15 所示。
最后,选择 v6,算法到此结束。
【图论算法】最短路径算法(无权最短路径、Dijkstra算法、带负边值的图、无圈图)_第12张图片

算法例程

下面给出实现 Dijkstra 算法的伪代码。每个 Vertex 存储算法中使用的各种数据成员

/**
* Vertex结构的伪代码描述
* 以实际的C++表示,路径通常为 Vertex* 型
* 而描述的许多代码段,或者要求解引用操作符 *,或者使用 -> 操作符,
* 而不用 . 操作符
* 这有点不利于对基本算法思路的理解
*/
struct Vertex
{
	List		adj;	//邻接list(表)
	bool		known;	
	DistType	dist;	//DistType可能是int型量
	Vertex		path;	//如上所述,很可能是 Vertex* 型
		//其他数据成员和成员函数视需要而定
};

通过反证法的证明可指出,只要没有边的值为负值,该算法总能够正常工作。如果使用顺序扫描顶点以找出最小值 dv 这种明显的算法,那么每一步将花费 O(|V|) 时间找到最小值,从而整个算法查找最小值将花费 O(|V|2) 时间。每次更新 dw 的时间是常数,总计为 O(|E|)。因此,总得运行时间为 O(|E|+|V|2) = O(|V|2)

void Graph::dijkstra(Vertex s)
{
	for each Vertex v
	{
		v.dist = INFINITY;
		v.known = false;
	}

	s.dist = 0;

	while (there is an unknown distance vertex)
	{
		Vertex v = smallest unknown distance vertex;

		v.known = true;

		for each Vertex w adjacent to v
			if (!w.known)
			{
				DistTypw cvw = cost of edge from v to w;

				if (v.dist + cvw < w.dist)
				{
					//更新 w
					decrease(w.dist to v.dist + cvw);
					w.path = v;
				}
			}
	}
}

如果图是稀疏的,边数 |E| = O(|V|),那么这种算法就太慢了,此时距离就需要存储在优先队列中进行处理。

打印路径

下面的递归例程可以打印出路径。该例程递归地打印路径上直到顶点 v 前面的顶点的路径,然后再打印顶点 v。

/**
* 假设到 v 的最短路径存在
* 在运行 Dijkstra算法之后打印该最短路径
*/
void Graph::printPath(Vertex v)
{
	if (v.path != NOT_A_VERTEX)
	{
		printPath(v.path);
		cout << " to ";
	}
	cout << v;
}

具有负边值的图

如果图有负的边值,那么 Dijkstra 算法是行不通的。问题在于,一旦一个顶点 u 被声明是 known 的,那就可能从某个另外的 unknown 顶点 v 有一条回到 u 的很负的路径。在这种情况下,选取从 s 到 v 再回到 u 的路径要比从 s 到 u 但不过 v 更好。把赋权的算法和无权的算法结合可以解决这个问题,但要付出运行时间剧烈增长的代价。

开始,我们把 s 放到队列中。然后,在每一阶段我们让一个顶点 v 出队。找出所有邻接到 v 使得 dw > dv + cv,w 的顶点 w。然后更新 dw 和 pw,并在 w 不在队列中的时候把它放入队列中。可以为每个顶点设置一个比特位(bit) 以指示它在队列中出现与否。重复这个过程知道队列空为止。

带有负的边值的赋权最短路径算法的伪代码
void Graph::weightedNegative(Vertex s)
{
	Queue<Vertex>q;

	for each Vertex v
		v.dist = INFINITY;

	s.dist = 0;
	q.enqueue(s);

	while (!q.isEmpty())
	{
		Vertex v = q.dequeue();

		for each Vertex w adjacent to v
			if (v.dist + cvw < w.dist)
			{
				//更新 w
				w.dist = v.dist + cvw;
				w.path = v;
				if (w is not already in q)
					q.enqueue(w);
			}
	}
}

每个顶点最多可以出队 |V| 次,因此,如果使用邻接表,则运行时间是 O(|E|·|V|)

无圈图

如果知道图是无圈的,则可以通过改变声明顶点为 known 的顺序,或叫作顶点选取法则,来改进 Dijkstra 算法。新法则是以拓扑顺序选择顶点的,因为当一个顶点 v 被选取后,按照拓扑排序的法则它没有从 unknown 顶点出发的入边,因此它的距离 dv 可不再被降低。由于选择和更新可以在拓扑排序实施的时候进行,因此算法能够一趟完成。

使用这种选取法则不需要优先队列,由于选择花费常数时间,因此运行时间为 O(|E|+|V|)

关键路径分析

无圈图的一个更重要的用途是关键路径分析法(critical path analysis)。

动作节点图

以图17 为例子。每个节点表示一个必须执行的动作以及完成动作所花费的时间。因此,改图叫作动作节点图(activity-node graph)。图中的边代表优先关系:一条边 (v, w) 意味着动作 v 必须在动作 w 开始前完成。
【图论算法】最短路径算法(无权最短路径、Dijkstra算法、带负边值的图、无圈图)_第13张图片

图17 动作节点图

这种类型的图常常用来模拟方案的构建。则需要考虑的重要问题为方案最早完成时间是何时?从图中可以看到,沿路径 A, C, F, H 需要10 个时间单位。另一个重要问题是确定哪些动作可以延迟,延迟多长,而不至于影响最少完成时间。例如,延迟 A, C, F ,H 中的任一个都将使完成时间推迟到10 个单位时间之后,而动作B 可以被延迟两个时间单位而不至于影响最后完成时间。

事件节点图

为了进行这种运算,我们把动作节点图转化成事件节点图(event-node graph)。每个事件对应一个动作和所有相关的动作的完成。从事件节点图中节点 v 可达到的那些事件只可在事件v 完成后才能开始。在一个动作依赖于多个其他动作的情况下,可能需要插入哑边(dummy edge) 和 哑结点(dummy node)。对应图17 的事件节点图如图18 所示。
【图论算法】最短路径算法(无权最短路径、Dijkstra算法、带负边值的图、无圈图)_第14张图片

图18 事件节点图
最早完成时间

为找出方案的最早完成时间,我们只需要找出从第一个事件到最后一个事件的最长路径的长。

如果 ECi 是节点 i 的最早完成时间,则可用的法则为

EC1 = 0
EC w = max(ECv + cv,w),

图19 显示了在本例中的事件节点图中每个事件的最早完成时间。
【图论算法】最短路径算法(无权最短路径、Dijkstra算法、带负边值的图、无圈图)_第15张图片

图19 最早完成时间
最晚时间

还可以计算每个事件能够完成而又不影响最后完成时间的最晚时间 LCi。进行这项工作的公式为

LCn = ECn
LCv = min(LCw - cv,w),

对于每个顶点,通过保存一个所有邻接而且在先的顶点的表,这些值就可以以线性时间算出。各顶点的最早完成时间通过顶点的拓扑排序算出,而最晚完成时间则通过倒转它们的拓扑顺序来计算。最晚完成时间如图20 所示。
【图论算法】最短路径算法(无权最短路径、Dijkstra算法、带负边值的图、无圈图)_第16张图片

图20 最晚完成时间
松弛时间

事件节点图中每条边的松弛时间(slack time) 代表对应动作可以被延迟而又不至于推迟整体完成的时间量。容易看出

Slack(v,w) = LCw - ECv - cv,w

图21 指出在事件节点图中每个动作的松弛时间(作为第三项被标示)。对于每个节点,其项上的数字是最早完成时间,而底下的数字是最晚完成时间。
【图论算法】最短路径算法(无权最短路径、Dijkstra算法、带负边值的图、无圈图)_第17张图片

图21 最早完成时间、最晚完成时间和松弛时间

某些动作的松弛时间为零,这些动作是关键性动作,它们必须按计划结束。至少存在一条完全由零-松弛边组成的路径,这样的路径就是关键路径(critical path)。本例中的关键路径则为 1, 2, 4, 7, 10。

所有顶点对间的最短路径

有时需要找出图中所有顶点之间的最短路径,这可以运行 |V| 次适当的单源(single-source) 算法,但对于稠密图,还是应该用更快些的算法。
之后会给出 对赋权图求解这种问题的一个 O(|V|3) 算法 。虽然对于稠密图它具有和运行 |V| 次简单(非-优先队列)Dijkstra 算法相同的时间界,但它循环非常紧凑,以至于这种专业化的所有顶点对算法很可能在实践中更快。当然,对于稀疏图,更快的是运行 |V| 次用优先队列编码的 Dijkstra 算法。

最短路径的例

思考以下问题:使用C++ 来计算词梯游戏(word ladder)。在一个词梯中,每个单词均由其前面的单词改变一个字母而得到。例如,可以通过一系列单字母替换而将 zero 转换为 five:zero hero here hire five。

这是一个无权最短路径问题,其中每一个单词都是一个顶点,如果两个单词可以通过单字母替换而相互转换,那么它们之间就有边存在(双向)。

该例程创建一个map,其关键字是单词,相应的值是包含从单字母变换得到的那些单词的vector 对象。即,这个 map 代表一个以邻接表格式表示的图。

//求词梯的C++ 例程
//从邻接映射(adjacency map) 进行最短路径计算,返回一个向量
//该向量包含从first 到second 得到的单词相继变化
unordered_map<string,string>
findChain(const unordered_map<string, vector<string>>& adjacentWords,
	const string& first, const string& second)
{
	unordered_map<string, string> previousWord;
	queue<string>q;

	q.push(first);

	while (!q.empty())
	{
		string current = q.front();
		q.pop();
		auto itr = adjacentWords.find(current);

		const vector<string>& adj = itr->second;
		for(const string&str:adj)
			if (previousWord[str] == "")
			{
				previousWord[str] = current;
				q.push(str);
			}
	}
	previousWord[first] = "";

	return previousWord;
}

//在最短路径计算运行之后,计算包含从first 到second 得到的
//单词相继变化的vector 对象
vector<string> getChainFromPreviousMap(
	const unordered_map<string, string>& previous, const string& second)
{
	vector<string>result;
	auto& prev = const_cast<unordered_map<string, string>&>(previous);

	for (string current = second; current != ""; current = prev[current])
		result.push_back(current);

	reverse(begin(result), end(result));
	return result;
}

创作不易,如果这篇【文章】有帮助到你,希望可以给作者点个赞,你的鼓励是我最大的动力!

你可能感兴趣的:(数据结构,数据结构,图论算法,最短路径,Dijkstra)