dfs检测是否有环的优化

1,375 阅读6分钟

本文是刷leetcode第207题的感想,《算法》4th english version这本书已经很久没看了,尝试自己写出有向图的leetcode题,心想以前在公司项目里撸过DAG(有向无环图),现在做题肯定不在话下,而且正好检测一下学习成果,也能看看自己的理解是否深刻。
结果做倒是做出来了,就是时间惨不忍睹(250ms,仅超越约6%),很尴尬,再看了看书,发现以前对书上算法的理解有问题,特此记录。

我的做题思路

class Solution {
    public boolean canFinish(int numCourses, int[][] prerequisites) {
        //完成所有课程,说明没有环(有环就互为前置了)
        //需要都可达吗?  不需要。。并没有这个需求
        Object[] adj = new Object[numCourses];
        boolean[] onStack = new boolean[numCourses];
        //构造出邻接链表,这不重要
        int i = prerequisites.length;
        for(int j = 0;j<i;j++){
            Integer left = prerequisites[j][1];
            Integer right = prerequisites[j][0];
            if(adj[right] == null){
                Set<Integer> set = new HashSet<>();
                set.add(left);
                adj[right] = set;
            }else{
                Set<Integer> set = (Set<Integer>)adj[right];
                set.add(left);
                adj[right] = set;
            }
        }
        //对每个节点进行一次dfs,如果发现一次循环之后有环就退出
        for(int k = 0;k<numCourses;k++){
            boolean b = dfs(k,adj,onStack);
            if(b==false){return false;}
        }
        return true;
    }

    private boolean dfs(int i, Object[] adj, boolean[] onStack){
        //是否在方法调用栈上,也就是,是否在来当前节点的路径上
        //这里的栈,其实是方法调用栈的思想,思想是栈,但是为了能随意获取某个元素用了数组
        onStack[i] = true;

        Set<Integer> set = (Set<Integer>)adj[i];
        if(set == null){
            onStack[i] = false;
            return true;};
        for(Integer integer: set){
            if(onStack[integer]==true){
                return false;}
            boolean b = dfs(integer,adj,onStack);
            if(!b){return b;}
        }
        onStack[i] = false;
        return true;
    }
}

与书上算法的区别

public class DirectedCycle {
    private boolean[] marked;        // marked[v] = has vertex v been marked?
    private int[] edgeTo;            // edgeTo[v] = previous vertex on path to v
    private boolean[] onStack;       // onStack[v] = is vertex on the stack?
    private Stack<Integer> cycle;    // directed cycle (or null if no such cycle)

    public DirectedCycle(Digraph G) {
        marked  = new boolean[G.V()];
        onStack = new boolean[G.V()];
        edgeTo  = new int[G.V()];
        for (int v = 0; v < G.V(); v++)
            if (!marked[v] && cycle == null) dfs(G, v);
    }

    // check that algorithm computes either the topological order or finds a directed cycle
    private void dfs(Digraph G, int v) {
        onStack[v] = true;
        marked[v] = true;
        for (int w : G.adj(v)) {

            // short circuit if directed cycle found
            if (cycle != null) return;

            // found new vertex, so recur
            else if (!marked[w]) {
                edgeTo[w] = v;
                dfs(G, w);
            }

            // trace back directed cycle
            else if (onStack[w]) {
                cycle = new Stack<Integer>();
                for (int x = v; x != w; x = edgeTo[x]) {
                    cycle.push(x);
                }
                cycle.push(w);
                cycle.push(v);
            }
        }
        onStack[v] = false;
    }
}

看了看书,发现书上的变量比我多两个edgeTo、marked,edgeTo是因为需要记录一下到底环由哪几个节点构成,我们这里只需要检测是否有环,因此edgeTo不需要。
那么再来看marked

  • 在DirectedCycle方法中,就已经用到了marked,也就是,如果已经到过的节点,那么就不做dfs了。
  • 在dfs中
    • 对于没有到过的节点,做dfs
    • 到过的节点&在方法调用栈上,就说明有环
    • 到过的节点&不在方法调用栈上,说明在其他路径上已经处理过该节点,什么都不做
      这里有两个问题:
  • 在DirectedCycle方法中,对于到过的节点不做dfs
  • 在dfs方法中,对于到过&不在方法调用栈上的节点,什么都不做
    也可以说是一个问题:

从其他节点出发到过的节点,或者对其他节点dfs已经处理过的节点,是否能确保是安全的。
这里的安全包括两点:

  • 如果已经处理过的路径有环,那么正在处理的路径也有环(这种情况实际不可能存在,因为如果已经处理过的路径有环,那么方法就会退出,也不会处理新的路径了)
  • 如果已经处理过的路径无环,假如已经处理过的路径的一部分是正在处理的路径的一部分,那么已经处理过的路径所在的部分无环。(如果包含已处理过路径的正在处理路径有环,那么被包含的部分应该也在环中,该命题成立,则需要被证明的逆否命题也成立)

因此可以得出已被处理过的节点是安全的结论,因此可以忽略marked==true的节点。
我在我的代码中加入了marked优化,直接让执行时间从250ms降低到了6ms!!!

一个小重点

其实有一个重点结论:如果dfs到了存在于环中的某个节点,那么一定会进入环!那么他的逆否命题就是,如果dfs处理过的节点没有进入环,那么该节点一定不在环中
因为节点在环中,那么节点的邻接链表中一定存在一个同样处在环中的节点。这么显而易见的特点,一定一定要利用起来。

误区

之前是直接看的书,没有刷题,有些东西没有想透,比如这个marked,我认为书中的代码,是要实现的功能的最小可用版本,但是marked又看不懂,而for循环又是从0开始的,书上的各种图都是0就是起始节点,因此我认为,只有在从起始结点开始dfs时,这个marked也有用。包括我在做项目时,我也定了一个起始节点。
但是在做题的时候发现,他并没有一个起始节点,我对每一个节点做dfs毫无问题,加上marked之后也毫无问题,于是我发现了,并不需要从一个起始节点开始dfs, 即使一开始dfs的是比较靠后的节点,之后循环的是靠前的节点,那也是没有问题的,而且对这种情况可以做优化。
我以为marked是实现某个功能的,没想到marked是为了优化。但是其实我也能感觉到自己的思路有问题,假设0123是有关联关系的,45是有关联关系的,那难道就一定是4之后是5吗?书中完全没有这样的前提条件,是我自己在瞎想罢了。
之所以没想通,还是因为有向图和无向图的区别其实有点大,无向图中的marked好理解,有向图中的marked还是多了些需要注意的地方。

edgeTo

可以注意到,只有marked[w]为false的时候才添加edgeTo[w]=v,也就是说,对每个节点来说,只有第一次到达这个节点的时候才设置edgeTo,那么这是为什么呢?
会不会edgeTo设置错了?也就是说edgeTo对某个在环中的点设置了不在环中的路径?基于此假设,那么

  • 节点V应有某个不在环中的节点指向V
  • 节点V在环中

那么V应该是这样的样子:


因为V在环中,所以V除了不在环中的节点指向V,其他必然至少有两条路径,一条出,一条入,而这条出的路径又在环中,那么根据代码和画图来看

                cycle = new Stack<Integer>();
                for (int x = v; x != w; x = edgeTo[x]) {
                    cycle.push(x);
                }
                cycle.push(w);
                cycle.push(v);

虽然edgeTo[w]不等于V,但是代码里对w和v进行了手动push,并不是利用edgtTo,因此没有问题。

总结

今天的策略,即先自己写再看书,今天看起来还是很成功的,不仅如此,以后刷题也要多看高效率的题解,多想着从各个角度解决问题。不过,第一遍目测还是做完拉倒吧哈哈哈哈。