经常遇到一类问题,提供一个图,判断其中是否含环。所谓的环是一条起点与终点相同的路径(至少含有一条边,两个结点)。由于不带环的连通图和带环的连通图有着本质的区别,不带环的连通图是树,而树相较于一般的图可以支持更多更高效的算法,比如log2(n)时间复杂度内找任意两点的路径信息,在树上进行树形DP等等。
图按照边是否有向可以分为有向图和无向图。在两类图中找环的时间复杂度均为O(n),而判断是否含环的时间复杂度也是O(n),因此只陈述找环的方法。
无向图找环
无向图找环,因为无向图中没有明确的根,我们可以令任意结点为根做DFS操作,每次搜索到一个结点,就修改其状态为已访问,直到搜索到已访问的结点u,这时候通过退栈必定会碰到另外一个u结点,二者之间的路径就是环。
findLoop(node, father, stack) //node为根结点,father设为空,stack用于记录可能存在的环信息 stack.push(node) if(node.visit) return true node.visit = true for child in node.children if(child == father) continue if(findLoop(child, node, stack)) return true stack.pop() return false
说明正确性。很显然如果我们第二次访问某个结点,很显然两次访问对应的路径必定有相同的根结点(因为无向图的原因DFS会搜索整个连通图中的所有结点),由于DFS每次得到的路径不同,因此我们得到了两条起点相同终点相同的路径,将两条路径首位相连,我们就得到了一个环。如果我们第二次访问相同结点u,那么当前路径中必定包含u,因为在第一次搜索到u时,我们会继续搜索其子树,此时沿着第二条路径逆向走,必定会抵达某个访问过的结点(可能是根),那么若这个结点不是u,就违背了u是第一个被二次访问结点这一前提,故当前路径中必定包含u,两个结点之间的路径中没有重复结点,是一个简单环。
复杂度非常简单,我们为每个未被访问过的结点调用该方法,每次进入方法,或者修改结点的访问状态,或者找到环,而函数内部的逻辑(不含循环)是常数时间复杂度,循环最多发生|E|次,因此时间复杂度为O(|V|+|E|)。
有向图找环
有向图找环相对比较复杂。由于图未必连通(可能由若干连通子图构成),我们需要建立一个公共的根结点r,并从r向图中所有结点建立一条单向边(由于r只有出边,因此r必定不是环的一部分,即不影响我们找到的环)。之后从r出发进行DFS。同样我们需要增加访问状态来避免重复搜索,但是访问两次的结点未必构成环,比如考虑两条路径r->a->b与r->b,很显然a与b之间未必构成环。我们还需要加入一个在栈标志instk,为true表示这个结点在当前路径上,false表示不在。只有搜到的二次访问结点满足该结点在栈中,才能保证其处于环上。
findLoop(node, stack) if node.visit if node.instk == false return false stack.push(node) return true; node.visit = true node.instk = true stack.push(node) for child in node.children if(findLoop(child, stack)) return true node.instk = false stack.pop() return false
如果图中确实含环,即存在路径u->...->u,那么当我们访问到环上的任意结点u时,由于DFS的缘由,必定会回到自身或是找到另外一个环并退出,无论哪种情况我们都找到了一个环。而如果第一次发现一个结点u被二次访问且在栈中,那么由于该结点在栈中,那么路径中必然包含u,二者之间的路径则形成了环,而由于路径中只含有访问一次的结点(除了u),因此找到的环是简单环。
时间复杂度与无向图的一致,也是O(|V|+|E|)。