dfs檢測是否有環的優化

Wayne_Kdl發表於2019-12-30

本文是刷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應該是這樣的樣子:

dfs檢測是否有環的優化

因為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);
複製程式碼

dfs檢測是否有環的優化
雖然edgeTo[w]不等於V,但是程式碼裡對w和v進行了手動push,並不是利用edgtTo,因此沒有問題。

總結

今天的策略,即先自己寫再看書,今天看起來還是很成功的,不僅如此,以後刷題也要多看高效率的題解,多想著從各個角度解決問題。不過,第一遍目測還是做完拉倒吧哈哈哈哈。

相關文章