1) Breadth-first search
BFS可以看成是从起点开始一个波往各个方向传播,直到通过所有的节点,波的前沿的元素把图的节点分为3类,1.波的前沿已经通过,也就是已经当过波的前沿的节点。2.波的前沿中的节点。3. 其他的元素,也就是还没有被touch过的元素。
波的前沿可以用一个FIFO队列来维护,一个节点出队列的时候,算法会遍历其邻居,根据颜色把白色的也就是波没touch过的元素加到队尾(同时颜色变灰)。然后这元素就彻底出队列了,颜色也变为黑的。(初始队列为空,然后把源加入)
“发现”需要严格定义一下,一个节点$v$被发现指的是这个节点进入队列的时候。$u$记为正在出队列的节点,$v$是$u$的邻居,边$uv$也被记录下来,这个边是发现$v$的边(这个边是breadth-first tree的一个边)。
breadth-first tree会因为邻居节点的不同排序而变化,1) 如果$v_1,v_2$都是$u$的邻居,而且$uv_1, uv_2$都被加入到tree里,$v_1,v_2$的顺序并不影响tree的结构。2) 如果$v$是$u_1$的邻居,也是$u_2$的邻居,$u_1, u_2$的顺序会导致$u_1v$或者$u_2v$被加入到tree里。
最短路的一个性质(Triangle inequality):如果$u, v$是邻居,那么从任意节点$s$出发的到$u, v$最短路的差是0或者1。
对于不加权的图,路径长度的差别只能是整数,所以上面取值只能是0,1,但是如果是加权的图,如果存在$u$指向$v$的边$(u, v)$,那么到$u$的最短路加上这条边的权给出了到$v$的最短路的一个上界,也就是 $$\delta[v]\leq \delta[u] + w(u, v)$$ 比如如果存在另外一条$u$到$v$的路径长度为0,两个$\delta$是相等的,但是知道边$(u, v)$并不知道这个信息,所以只给出一个上界。
队列的性质:源加入队列的时候队列里只有一个元素,这个元素出栈后,源的邻居(也就是d=1的元素)被加入的队列里,且只有d=1的元素,d=1的元素只会造成d=2的元素被加入到队列里,而且只在队列尾部,因为FIFO,只有当d=1的元素被处理完了之后,才会开始处理d=2的元素。以此类推可以得
a) 队列里的元素d最多差1。
b) 队列的元素d按升序排列。
$d$不小于最短路$\delta$,如果$d[v]=n$,那么根据算法存在一条源$s$到$v$的长度为$n$的路径,$s$到$v$的最短路$\delta [v]$必小于等于$d[v]$。
证明黑色节点的$d=\delta$:显然起点$s$满足$d[s]=\delta[s]=0$,$s$的邻居满足$d=\delta=1$(变黑后),假设$v$是不满足$d=\delta$的黑节点里面$\delta$最小的,考虑$s$到$v$的最短路,假设$u$是这条最短路上的$v$的前导,那么$d[u]=\delta[u]$,而$d[v] > \delta[v]=\delta[u]+1=d[u]+1$,也就是$u$和$v$的$d$至少差2。根据算法,相邻黑节点的$d$最多差1,矛盾。
算法运行的同时检查图是不是bipartite graph:出队列的节点开始把自己的邻居加入到队列里的时候,如果遇到已经有$d[v]$的元素,可以检查是不是奇偶性相同,如果相同因为$d[v]$就是距离,这说明这两点是bipartite的同一部分,但是他们之间又有了当前这个边,这和图是bipartite的矛盾。
2) Depth-first search
可以用递归或者非递归的方式实现,如果用非递归方式,用一个实际的stack存储要访问的节点队列(对比上面的FIFO队列),从源开始,把源压入堆栈,检查stack里最上面的元素有没有白色邻居,如果有白色邻居把它压入stack(discover),以此类推直到发现第一个没有白色邻居的节点(没有邻居也是没有白色邻居),访问这个节点,并把这个节点染成黑色(finish),然后从堆栈里把它pop出来,再检查stack里最上面的元素有没有白色邻居。
上面的方法等于是要自己维护调递归时用堆栈里的数据,比如递归方法里的邻居节点循环到什么位置了,递归的时候是调用堆栈维护的,如果不维护可以用搜索第一个白色邻居的方式,如果维护可以在每个节点上加index成员,记录最近检查过的邻居的index,stack pop出来的节点直接去检查这个邻居就可以了。
进栈和出栈和time的关系,进栈之前节点记录发现时间d[v]=time,然后time++,出栈之前time++,节点出栈后记录结束时间f[v]=time。
用递归的方式,考虑从起点开始,第一个孩子的第一个孩子这样递归,可以得到一个linked list,如果图是无回路的就是树,最后会遇到一个没有第一个孩子的叶子节点(记为l)。显然递归函数遇到这个节点的时候会第一次返回,倒数第二层的递归函数里是个循环,循环的访问l的父节点的所有孩子,这个循环会前进一步,访问l父亲的第二个孩子。
如果有回路的图递归函数第一次返回也可能是遇到了以前已经访问过的节点了。无回路的图用递归的方式是不可能遇到已经被touch过的节点的,所以tree遍历的时候不记录节点是不是被访问过(还因为tree是有向图,父节点指向子节点)。
3) 拓扑排序
有向图的拓扑排序的序的定义$u > v$如果存在$u$指向$v$的边。
Depth-first search在有向图里发现back edge说明图里有cycle,而且cycle必然会造成depth-first search里发现back edge。
算法:排序队列起始为空,开始depth-first search,当一个节点finish的时候,把这个节点放到排序队列的前端,depth-first search结束的时候的排序队列是图的一个拓扑排序。
如果存在$u$到$v$的edge $u\rightarrow v$,那么$f[v] < f[u]$
4) 有向图的强连通分支
定义:同一个强连通分支里的任意两个节点之间有路径连通,特别的u到v有路径且v到u也有路径。
定理:有向图的转置是把所有的边反向,一个有向图和它转置的强连通分支相同。
定理:如果把强连通分支合并成一个节点,得到的有向图是无回路有向图(dag)。如果在图上做一个depth-first search,取强连通分支$C$里面$d$最小的为这个$C$的$d$(也就是里面最早被发现的节点),取$C$里面最大的$f$为这个$C$的$f$(最后结束的)。
定理:如果存在强连通分支$C$到$C'$的边$(u, v)$,那么$f[C] > f[C']$。(边$(u, v)$的存在实际上断绝了$C'$到$C$存在边的可能)。考虑图的转置,只要把结论反过来就可以,因为$f$和$C$和转置没有关系,转置不会改变$f$或者$C$。
算法:先在有向图$G$上运行depth-first search,记录每个节点的finish时间$f[u]$,在图的转置$G^T$上运行depth-first search,但是主循环里面的顺序按$f[u]$的降序。第二个depth-first search输出的depth-first search forest里每个tree都是一个强连通分支。
第二个depth-first search起始的时候在$f$最大的强连通分支上运行,也就是原图$G$上只有这个强连通分支只有出去的边,转置后$G^T$上同一个强连通分支只有进来的边,所以这个强连通分支的节点访问结束后会返回主循环,而不会碰到其他强连通分支,当进行到第$k$个强连通分支的时候,$k-1$已经是黑色的,而第$k$个连通分支只有从没访问过的强连通分支进来的边,出去的边都是指向已经访问过的前$k-1$个分支。
5) 最小支撑树
支撑树可以看成边的集合,包括这些边的端点。
Kruskal's algorithm(贪心算法):$E$ 为所有边的集合,$A$初始为空集,每次从$E$中选出权重最小的,并且加入$A$后不在$A$里产生回路的边加入$A$中,得支撑树之后算法结束。
实现:找剩下的边权重最小的很简单,只要把边按权重排序,每次取最小的即可。找到一个权重最小的后验证是不是会造成回路要用disjoint sets,起始的时候用make-set操作把图里的所有节点都做成子集,用方法find-set检查两个节点是不是已经在同一子集中了(如果已经在同一子集中,加入这个边会产生回路),如果在就抛弃这条边,如果不在,把边放到A中,并且把两个找到的子集合并。所有的边都检查过后算法结束。
注意任何时候,保留的边和所有的节点构成的集合是一个图,里面没有回路。算法结束的时候结果是一个连通的图,而且没有回路。
定义Cut:无向图$G$的一个cut是其节点集合的一个划分,分成两部分。如果一条边的两个端点分属两个子集,则称这条边cross这个cut。A是边的集合,说一个cut respects集合A,意思是A里的所有边都不cross cut。
safe edge:边的集合A是一个最小支撑树的子集,如果加入边$uv$后还是一个最小支撑树的子集,称$uv$为A的safe edge。
定理:连通无向图$G$,边的集合A被一个最小支撑树包含,一个cut respects集合A,那么corss这个cut的所有边里面权重最小的边是A的safe edge。
Prim's algorithm:也是贪心算法,不过在往A里面加入新的边的时候要求边的一端连接A,另一端不连。
实现:当算法选择一个新的要加入到A里的边和节点的时候,所有和A相连的边必须都考虑过。
每个节点赋予一个key,初始的时候所有节点的key都是$\infty$。所有和A相邻节点的key都会变成连接它和A的权值最小的边的weight。
a) 把图里所有节点放到一个按其key维护的优先队列里,任取一个节点作为支撑树的起点,把它的key置0,父亲指针置空。
b) 每次从优先队列里把key最小的u抓出来,检查所有u的邻居,如果邻居在队列里(记为v),并且连接邻居的边(u, v)的weight比v的key值小,更新v的key为这个weight(就是v出发的cross cut的最小的边),并将v的父亲指针$\pi[v]$指向u。
c) 在从优先队列里抓最小值之前,所有的不在队列里的节点的邻居都被考虑过,也就是cut的cross edge都反映到他们邻居的key里面了。
6) 最短路算法
最短路不能有cycle,所以最长的最短路的边数小于等于$|V| - 1$。算法结束后对每个节点除了起点,都定义了一个前导$\pi(v)$,前导是和有向的边相反的,最短路包括$(u, v)$那么$\pi(v)=u$。
Bellman-Ford algorithm:复杂度$O(VE)$,允许有负权值的边,但是不允许有从源reachable的负权值cycle,算法会侦测出这样的cycle。
RELAX函数是对一个边的操作,传入边的两个端点的$d$值,和边$(u, v)$的权值,检查$d[u], d[v], w(u, v)$是不是满足最短路的性质,并进行修正$d$和$\pi$。图中的最短路的长度最长为$|V|-1$条边,Bellman-Ford algorithm做$|V|-1$次对所有边的RELAX,第一次边数为1的最短路终点节点的$d$变为最短路的长度,第二次边数为2的最短路的终点节点的$d$变为最短路的长度,所以$|V|-1$次循环后所有的最短路终点节点的$d$都变成最短路的长度。
利用Topologically Sort的结果:Topologically Sort结束后,得到了一个节点的序,所有的边都是前面的节点指向后面的节点,一个按这个序的节点的循环一次,对每个节点RELAX其adj list里的每个边,这些边都是这个节点出发的,循环结束,算法结束。
分析:每个从源头reachable的path都是按这个path的边的顺序RELAX的(可能不是一条path结束另一个开始,是同时进行的),所以所有节点都处理过后,$d$,$\pi$构成了最短路tree。
任务顺序:用DAG建模,有向的边是任务,wight是任务需要的时间,如果两个任务有直接依赖,把两个边直接接起来,那么图里最长的路径是完成所有任务需要的时间的下界。用最短路算法可以解决这个问题,只有把权值全部变为负的,然后运行算法。
Dijkstra's algorithm:这个算法和Prim's algorithm最小支撑树的算法很类似,在Prim's algorithm算法里用一个优先队列维护cut另一边的节点,队列最上面的是cross cut的最小的边,所以key是某一条边的weight。在这里key是节点的$d$值,从优先队列里抓出来的最小值的节点去向是S,S初始的时候只有源头一个节点,每次从优先队列里抓出来key最小的节点(记为u),遍历u的所有邻居,update他们的$d$值,和Prim's algorithm里面的分析一样,所有S里的节点的邻居都是考虑过的。
分析:只需要证明当节点$u$被加入S的时候$d[u]=\delta(u)$,假设$u$是第一个这样的元素不满足这个条件,也就是$d[u] > \delta(u)$,设$p$为实现$\delta(u)$的最短路,如果$u$在这条路径上的前导在S里,那么前导指向$u$的边RELAX的时候会更新$d[u]$为$\delta[u]$;现在假设这个前导不在S里,记为$y$为最后一个不在S里的路径上的前导,有$d[y]=\delta[y]$,根据算法,因为当$u$被抓出来的时候$y$也在优先队列里,所以这时候$d[u] \leq d[y]$,因为$p$是最短路,有$d[y]=\delta[y]\leq \delta[u]\leq d[u]$,所以$d[u]=\delta(u)$。
7) All-Pairs Shortest Paths
用GDPP来建模最短路
a) 定义$\Omega_k$为所有边的个数最多为$k$无环路径,显然$0\leq k \leq |V|-1$。
b) $\Omega_{k-1}$里的entries变换为$\Omega_{k}$里的entries的方式是,任取一个节点$v$作为终点,考察到达这个节点的边,比如$(u, v)$把所有长度最多为$k-1$终点为$u$的无环路径加上$(u, v)$,并且加上后不能有环,就得到了$\Omega_{k}$里的entries。
c) 因为我们要找的是最短路径,所以$M$是变换的代价,定义为$M(p^{(k-1)}, p^{(k)})=w$,其中$w$是新加上边的weight。
d) 为了定义最优值函数$\mathcal{F}$需要把$\Omega_k$分类,因为最优值函数不是在整个$\Omega_k$上取值,定义$\Omega_{k,ij}$是$\Omega_k$的子集,约束是路径的起点为$i$,终点为$j$。$\mathcal{F}(\Omega_{k,ij})$定义为里面的路径长度最小的路径。
e) 递归关系定义为: $$\mathcal{F}(\Omega_{k,ij}) = \min_{l}[ \mathcal{F}(\Omega_{k-1,il}) + w(l, j)]$$ f) 用矩阵维护$\mathcal{F}(\Omega_{k,ij})$的值,起始的时候这个矩阵是weight矩阵,$i=j$为0,$i$到$j$无边为$\infty$,得递归下一步矩阵$\mathcal{F}(\Omega_{k+1,ij})$的时候,先初始化为正无穷,因为要记录$\min$的值,遍历矩阵的$i,j$,当$i,j$定了以后,遍历递归关系里的$l$每次和$\mathcal{F}(\Omega_{k+1,ij})$对应的元素比较,如果较小就更新其值。