图的最短路
图的广度优先搜索
在DFS中,一旦发现一个新节点就会立即执行从它开始的递归,这个算法一开始执行就会离源点越来越远,因此称为“深度优先”。这种搜索方式与“栈”后进先出的特性是相同的,我们甚至可以避免递归而用“栈”来实现图的深度优先搜索。
与“后进先出”的栈相对应是“先进先出”的“队列”。把源点推入队列,在队尾推入与源点相连的点,接着我们将队头(此时是源点)从队头推出,然后从队头开始取出结点,每次再把与当前节点相邻且没有访问过的点推入队尾……这种搜索方式称为“广度优先搜索”(BFS)。容易发现,BFS的复杂度也是线性的。
我们尝试用BFS来访问没有边权(边权为1)的图。起初队列里只有源点。之后某个时刻队列中加入了所有与源点距离为1的点,并且弹出了源点本身——此时队列里有且仅有所有到源点距离为1的点。接着我们会依次访问所有这些距离为1的点,并且推入所有与这些点相邻的点。由于所有距离为1的都已经在队列里了,因此所有这些后加入的点到源点的最短距离至少为2。而容易看出2对于这些点来说都是可行的,因此我们证明了接下来被推入的节点到源点的最短距离统统为2。因此一定有某时刻队列里有且仅有的是到源点最短路为2的点……依此类推,我们可以证明我们总能先后找到某时刻队列里仅保存了所有到源点最短距离为\(d\)的点。这说明,BFS的访问顺序可以理解为是以“到源点距离”从小到大一圈一圈访问的。如果我们仿照DFS树的样子画出BFS树,会发现BFS树上节点的深度就是到源点的最短距离。
至此,我们已经在线性时间内解决了单源点的无权图最短路问题。
Dijkstra算法
现在考虑带权的图。我们先讨论边权是正整数的情形。
一个直接的想法是,如果我们把一条权为\(k\)的边拆成\(k\)条权为1的边和\(k-1\)个新的节点,那么问题又转化为了无权图的问题,只需要做BFS就可以求出最短路。但这样做使得复杂度多乘了“权的上界”这个因子。我们应该看看能不能做得更好——当我们把边拆成小的边之后,我们事实上做了很多“无用功”,因为我们费力求出了到每个“虚拟点”的最短路,这里面可能存在着冗余之处,给我们提供了优化的空间。
在BFS中,顺序是关键。只要确定了访问的顺序,就可以依据上一轮记录的信息来求出最短路。如果我们能够用某种方法“推断”出上述拆边后的BFS中我们是以怎样的顺序访问各个节点的,也就可以求出最短路了。
我们引入一个“闹钟”来表示我们的“推断”。当我们在拆了边之后的图上从源点出发BFS时,我们知道我们将会的做的就是依次访问所有到源点距离为\(d\)的点。因此我们可以在原图上给所有与源点有直接边的点上,把闹钟设置为边权的大小,代表我们“推测”将会在第\(w_i\)秒到达这个节点。当然,这个推测不一定是对的。但在我们到达任何一个“真的”点之前,一切都在我们的掌控之中。所以,对于边权最小的那个边,我们的推测就一定是对的,闹钟的值就是最短路。此时这个点的BFS要开始走向新的边了,因此必须从这个点出发推测与它相邻的点,并相应地给这些点设置闹钟,如果这次设置的值比之前要小了,那就更新为这次的值。同样的,在下一个闹钟响起之前,一切都是在掌控中的,因为我们完全预测了真正的BFS的运行。因此,下一个响起的闹钟也就是相应节点的最短路……
上面这个设闹钟的做法绝对是正确的,因为它不过是用了一种更省时的方法来模拟BFS。我们现在想从中抽象出我们在数学上到底做了什么。我们对于每个点,用它的边权重新设置了周围点的闹钟。而一旦我们开启时间的运行,我们只关心闹钟时刻最小的那个点——因此如果我们抛弃“闹钟”这一概念,就可以这样描述我们的算法过程:对于当前点\(u\),访问所有与它相邻的还没有被确定最短路的点\(v\)。如果\(t_u+w(u,v)<t_v\)就用\(t_u+w(u,v)\)更新\(t_v\)。然后,我们从所有未确定最短路的点中选出\(t\)最小的那个,重复上述操作(如果有多个最小值,理论上我们应该同时取出并更新;但实际上没有必要,因为假如有多个最小值,下一个取出的一定是与上一个的\(t\)一样的点)。也可以理解为,每一次我们从所有不在集合\(S\)的点中选出一个\(t\)最小的,用它更新周围的\(t\),然后把它加入\(S\)。算法结束时,\(S\)内已经包括了所有点,而这些点的\(t\)值就是最短路。
这个算法就是Dijkstra算法,它本质上是BFS的一个推广。从推广的过程中,我们使用时间来模拟边权,这就意味着我们无法保证这一算法对负权边的适用性,因为时间是不能倒着走的(事实上,有反例可以证明Dijkstra对负权图是错误的)。但我们能做的推广是把正整数推广到正实数(虚拟点的多少而已)。Dijkstra对正权图是始终正确的。
Dijkstra需要耗费比BFS更多的时间,因为它的取点顺序不是简单地取出队首,而是需要取出“队列”中\(t\)最小的那个点。这样的队列称为“优先队列”。如果我们用枚举来找最小的点,Dijkstra的复杂度是\(O(|V|^2+|E|)\)的。但好在我们已经有数据结构能帮助我们维护“支持插入和删除的集合中的最值”,最常见的就是一种叫“堆”的二叉树,它可以在\(O(\log n)\)的时间内完成插入和删除,\(O(1)\)访问最值。这样,Dijkstra的复杂度就被优化为了\(O((|V|+|E|)\log |V|)\)(每访问一条边都可能会进行依次更新,更新就等价于堆上的插入,因此\(|E|\)被移进了括号内)。
这里简单讨论一下二叉堆的实现方式。我们使插入和删除的方式保证二叉堆始终归纳地是一棵满二叉树,即节点顺序从上到下、从左到右依次排列。我们同时归纳地保证每个父节点维护的权值比他的两个子节点都要小(称为小根堆)。那么根节点就是最小值。当插入一个元素的时候,我们先暂时把新的节点按顺序放在最后面。此时它可能破坏了父节点小于子节点的性质,但它此时只在它与父节点这仅仅两个点间破坏了这一性质。如果我们把它与父节点的权值交换,那么在这里性质得到了维护(因为它一定是父节点,左节点、右节点这三个里面值最小的), 而父节点与父节点的父节点的性质可能被破坏了。如果性质依然是不满足的,我们就再做一次交换,每一次交换就会把性质的破坏沿着到根节点的链向上转移,这种交换可能一直持续到根节点。根节点交换以后,就不能破坏更多的性质了,因此整个堆的性质得到了维护。由于树的深度是\(O(\log n)\)的,因此插入的复杂度也是\(O(\log n)\)。删除一个节点时,我们用最后一个叶节点的权值替换根节点,并且把叶节点删除。此时,我们为了使得性质不被破坏,把根节点与子节点中最小的那个交换,然后一直向下,这和插入是完全类似的,复杂度也是\(O(\log n)\)。
如果我们用\(d-\)叉树来实现堆,那么插入的复杂度可以优化为\(O(\log_d n)\),删除的复杂度却变成了\(O(d\log_d n)\)。
带负权图的最短路
Dijkstra算法不能处理负权边的问题,因此对于带负权的图,需要其他的最短路算法。在求最短路之前,我们首先需要讨论什么时候图的最短路“存在”。注意,我们在最短路问题时,没有规定每条边、每个点只能走一次。但事实上,如果某条边某个点被经过了两次,就意味着最短路径上出现了环。而对于图上出现的环,我们如果选择不走停留在原地也依旧会形成一条路径。如果环上边权之和是正的,那么我们就在做无用功了,因此正权图的最短路上一定不会出现环;如果环上边权之和为0,那么走不走都一样;图上存在负环,那么只要我们不停沿着负环绕圈,那么最短路可以无穷小。所以在讨论负权图的最短路问题时,我们首先规定图上不能存在负环。
经过上述讨论,我们发现了即使在负权图中,最短路径也不会包括环。也就是,最短路径上经过的点是不会重复的。因此路径最多不会经过超过\(|V|\)个点,对应地最短路路径可以被表示为\(s \to u_1 \to u_2 \to \cdots \to u_k \to t\)。我们想要特别说明,这条路径不仅代表了\(s \to t\)的最短路这一信息,还同时代表了\(s\)到每一个\(u_i\)的最短路——如果对于某个\(u_i\),\(s \to u_i\)不是最短路,那么把它替换成最短路,一定能够缩短总长。这是最短路径本身具有的一种性质。
在Dijkstra中有一个我们从“闹钟”(BFS)抽象出来的重要的数学操作:\(d(v)=\min\{d(v),d(u)+w_{u,v}\}\)。这个操作称为更新(或者松弛)。通过这一更新操作,我们能使每个点的\(d\)值由刚开始的无穷大逐渐靠近最终我们想要的最短路。并且我们知道,每一次更新都代表了某条路径,因此这种更新是多多益善的。更重要的是,如果\(d(u)\)已经是最短路,而\(u \to v\)是最终的最短路径中相邻的点,那么\(u \to v\)的更新就能得到\(d(v)\)作为最短路答案。
在Dijkstra中,我们通过闹钟确定出了一个明确的“更新顺序”。但在负权图中我们没有这样明确的顺序。于是,我们先对每条边都进行一次更新。由于已知的条件只有\(d(s)=0\),因此我们只更新了与\(s\)相邻的点。但重要的是,假如我们在上帝视角已经知道了\(s \to u_1 \to u_2 \to \cdots \to u_k \to t\)这条最短路径,那么\(u_1\)在刚才的更新中已经确定出最短路了。再对每条边进行一次更新,那么由于\(u_1\to u_2\)这条边一定会被更新,所以\(d(u_2)\)也已经变成最短路了。依次类推,在\(|V|-1\)轮更新之后,我们就一定已经求出\(d(t)\)作为最短路了。这就是Bellman-Ford算法。复杂度\(O(|V|\cdot |E|)\)。
从上面过程中,我们其实也已经得到了一个判断图上有没有负环的\(O(|V|\cdot |E|)\)算法——对图进行\(|V|\)轮更新,如果\(d(t)\)在第\(|V|\)轮更新中依然被修改得更小,那么一定出现了负环。
在Bellman-Ford中,我们的每一轮都在做着相同的操作——对所有的\(u\)做\(d(v)=\min\{d(v),d(u)+w_{u,v}\}\)。在这里,其实我们做了比较多的无用功。如果某一轮的\(d(u)\)和上一轮一模一样,那么它的这一轮的更新一定没有任何效果。一个自然的想法是,我们记录下当前这一轮哪些节点被“成功更新”了,在下一轮我们只需要对这些节点来做更新操作即可。如果某一轮更新一次都没有成功,那么我们就可以提前结束算法了。用数学语言等价地描述上述过程,我们把第一轮更新成功的点加入某个集合\(S_1\),第二轮只用\(S_1\)中的节点更新,更新成功的节点加入\(S_2\),依此类推,当集合为空集时结束算法。进一步分析发现,连“第几轮”这个概念都不重要了,我们可以用一个队列来完成这个过程,我们把更新成功的点加入队列(如果已经在队列里就不需要再次进队了),每次用队头的元素来更新,直到队列为空。这就是SPFA算法,它是Bellman-Ford的优化算法。注意,SPFA的更新与BFS有本质上的不同,虽然在代码的写法上很多时候容易混淆——BFS中一个节点不会多次成为队头,同时我们用一个数组来防止对一个节点的重复访问;而在SPFA中,一个节点会多次成为队头,我们用数组来防止一个节点本身在队内时还被推入队列。在稀疏图中,SPFA算法的平均复杂度能达到\(O(|E|)\);而在最坏情况下,它依然退化为\(O(|V|\cdot|E|)\)。因此本质上,这并不能被称为一种“优化”。
DAG上的最短路
如果以拓扑序来进行更新(每个点更新从它出发的边),第一个点的最短路是已知的,第二个点的最短路要么是0,要么只能是从第一个点出发,因此一定已经被更新为最短路;第三个点同理。所以我们归纳地知道,我们用这样的方法能够得到每个点的最短路。
这里蕴含着一个重要的思想。我们尝试用另一种方法来求解\(d\)。Bellman-Ford中的一个重要性质是,如果\(d(u)\)已经确定是\(u\)的最短路,那么最短路径上\(u \to v\)的边会在更新过程\(d(v)=\min\{d(v),d(u)+w_{u,v}\}\)中直接得到\(v\)的最短路。那么在DAG中,对于第\(i\)个节点,我们知道在刚才的方法里一定是某个\(j<i\)通过更新操作\(d(i)=d(j)+w_{j,i}\)来使得\(d(i)\)取得最小值的。所以,假如\(1\)到\(i-1\)的最短路都已知,那么我们只需要枚举有边到达\(i\)的点\(j\),取\(d(j)+w_{j,i}\)中最小的那个就是\(d(i)\)了。归纳地,我们也可以求出所有点的最短路\(d(i)\)。这种方法称为“动态规划”——通过枚举已经求出答案的问题来得到当前问题,而当前问题的答案一旦确定又可以用来解决以后的问题。“动态规划”就是“DAG的最短路”。