圖論——環測定與矩陣演算法

冷豪發表於2020-05-17
  圖是計算機經典演算法的重要組成部分,從網際網路結構到電力拓撲,從經濟學的市場模型到醫學對傳染病的感染預測都具有非常廣泛的應用。圖的研究方面可以分為連通性、路徑問題、可達性等多個方面。今天我們僅聚焦於有向圖和無向圖的環測定問題,先使用Java語言實現它們的基本演算法,然後我將利用矩陣為大家展示如何通過數學模型來輔助演算法。
  由一系列相連的節點和所組成的資料結構,叫做圖。無論作為社交網路還是地圖,計算機在對這類問題進行處理的時候都可能會遇到各種數學問題。我們將學習兩種重要的圖模型:無向圖(簡單連線)和有向圖(連線有方向性)。
  一個圖的結構可能非常複雜,但是為了簡化我們的研究,我們只涉及以下兩種最基本的結構,圖1圖2僅僅對調了2、3節點,圖3表示有向圖。

一、無向圖與環測定

  在我們首先要學習的這種圖模型中,邊(edge)僅僅是兩個頂點(vertex)之間的連線。在無向圖中,如果節點1和2之間存在連線表示0-1和1-0同時存在。首先我們需要定義表示無向圖結構的物件:

import java.util.LinkedList;
import java.util.List;

public class Graph {
    private int v; // 頂點總數
    private int e; // 邊總數
    private List<Integer>[] adj; // 鄰接表陣列

    public Graph(int v) {
        this.v = v;
        this.e = 0;
        adj = (List<Integer>[]) new List[v];
        for (int i = 0; i < v; i++) {
            adj[i] = new LinkedList<>();
        }
    }

    /**
     * 由於圖中允許存在平行邊,因此不排除在鄰接表中儲存相同的鍵
     *
     * @param v 定點
     * @param w 定點
     */
    public void addEdge(int v, int w) {
        adj[v].add(w);
        adj[w].add(v);
        e++;
    }

    public void delEdge(int v, int w) {
        if (adj[v].contains(w)) {
            adj[v].remove(w);
            adj[w].remove(v);
        }
    }

    public boolean checkEdge(int v, int w) {
        return adj[v].contains(w);
    }

    public Iterable<Integer> adj(int v) {
            return adj[v];
    }

    public int degree(int v) {
        return adj[v].size();
    }

    public int V() {
        return v;
    }

    public int E() {
        return e;
    }
}

  要檢測在一副無向圖中是否存在環,我們需要用到一種稱為深度優先搜尋(dfs)的演算法。這個演算法的邏輯是,我們從原點(s)出發,從一條連線不斷向下搜尋,如果如果下一個節點是沒有後繼節點就原路返回然後從其它連線繼續,直到對圖中所有的節點都完成遍歷。我們每遍歷到一個節點就會做一次標記,當發現某一個節點的後繼節點已經已經被標記就表示,圖中存在環。演算法如下:

/**
 * 深度優先演算法的應用:環檢測
 */
public class Cycle {
    private boolean[] marked;
    private boolean isCycle;

    public Cycle(Graph g) {
        marked = new boolean[g.V()];
        for (int s = 0; s < g.V(); s++) {
            if (!marked[s]) {
                dfs(g, s, s);
            }
        }
    }

    /**
     * 利用深度優先演算法,檢測無環圖的節點,被標記過的節點w一定等於前節點u(v)
     *
     * @param g
     * @param v
     * @param u
     */
    public void dfs(Graph g, int v, int u) {
        marked[v] = true;
        for (int w : g.adj(v)) {
            if (!marked[w]) {
                dfs(g, w, v);
            } else if (w != u) {
                this.isCycle = true;
                return;
            }
        }
    }

    public boolean isCycle() {
        return isCycle;
    }

    public static void main(String[] args) {
        Graph g = new Graph(3);
        g.addEdge(0, 1);
        g.addEdge(1, 2);
        g.addEdge(0, 2);
        Cycle c = new Cycle(g);
        System.out.println(c.isCycle());
    }
}

  這個演算法並不複雜,但是如果你不太熟悉,最好的方式是深入遞迴的每一步然後充分理解上面的解釋。請注意,深度優先演算法(dfs)圖論演算法解答很多問題的基本思想,所以你應該掌握它。

二、有向圖與環測定

  在有向圖中,邊是單向的:每條邊所連線的兩個頂點都是一個有序對,它們的鄰接性是單向的。有向圖的資料物件表示如下:

import java.util.ArrayList;
import java.util.List;

public class Digraph {
    private int v;
    private int e;
    private List<Integer>[] adj;

    public Digraph(int v) {
        this.v = v;
        adj = (List<Integer>[]) new List[v];
        for(int i = 0; i < v; i++) {
            adj[i] = new ArrayList<Integer>();
        }
    }

    public void addEdge(int v, int w) {
        adj[v].add(w);
        e++;
    }

    public Iterable<Integer> adj(int v) {
        return adj[v];
    }

    public Digraph reverse() {
        Digraph r = new Digraph(v);
        for (int i = 0; i < v; i++) {
            for (int w : adj(v)) {
                r.addEdge(w, v); // 節點方向轉換
            }
        }
        return r;
    }

    public int V() {
        return v;
    }

    public int E() {
        return e;
    }

}

  雖然,有向圖比無向圖增加了複雜度,但是對於環的檢測思路卻依然適用。我們依然會使用dfs演算法,對路徑進行標註。並檢視每一個後繼節點。與之前稍有不同的地方是,即使我們發現後繼節點在之前的遍歷中已經被標註,我們還需要另外一個標記來確保這些結點處於同一條路徑上。例如下圖就不存在有向環:

  在幾何學中,向量4(V4) 與 向量2(V2)和向量3(V3)的關係:V4 = V2 + V3。顯然只有當:V4 + V2 + V3 = 0 時,環才成立。有向環的演算法如下:

import java.util.Stack;

/**
 * 尋找有向環
 */
public class DirectedCycle {
    private boolean[] marked;
    private int[] edgeTo;
    private Stack<Integer> cycle;
    private boolean[] onStack;

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

    /**
     * 每次進入遞迴時新增環標記,遞迴返回時取消環標記
     *
     * @param g
     * @param v
     */
    private void dfs(Digraph g, int v) {
        onStack[v] = true;
        marked[v] = true;
        for (int w : g.adj(v)) {
            if (hasCycle()) {
                return;
            } else if (!marked[w]) {
                edgeTo[w] = v;
                dfs(g, w);
            } else if (onStack[w]) { // 通過onStack記錄深度優先搜尋的每一個節點,如果節點記錄marked。則表示圖中存在有向環
                cycle = new Stack<>();
                for (int x = v; x != w; x = edgeTo[x]) {
                    cycle.push(x);
                }
                cycle.push(w);
                cycle.push(v);
            }
        }
        onStack[v] = false;
    }

    public boolean hasCycle() {
        return cycle != null;
    }

    public Iterable<Integer> cycle() {
        return cycle;
    }

    public static void main(String[] args) {
        Digraph digraph = new Digraph(3);
        digraph.addEdge(0, 1);
        digraph.addEdge(1, 2);
        digraph.addEdge(2, 0);

        DirectedCycle cycle = new DirectedCycle(digraph);
        boolean b = cycle.hasCycle();
        System.out.println(b);
    }
}

三、矩陣在圖論中的應用*

  在熟悉了無向圖和有向圖對環測定的演算法後,我們應該深入演算法的本質。我將嘗試利用矩陣計算來向你展示數學模型的魅力。如果你不熟悉矩陣計算也不要緊,因為本文不會給出任何演算法上的實現。或許你會疑惑,為什麼要用引入矩陣。我們知道,大多數的演算法在處理大型資料結構的時候,都很容易因為CPU的計算瓶頸而受到限制。如果能夠利用GPU平行計算的能力就可以讓運算能力獲得極大改善。而GPU最擅長的運算就是矩陣。

  回到上面給出的圖,我們用矩陣的行向量表示一條連線而用列向量表示一個節點。左圖表示如下:

A1 =
    -1     1     0     0
     0    -1     1     0
     0     0    -1     1
     0     1     0    -1

矩陣的第一行[-1 1 0 0]表示邊1(E1)連線了節點0(N0)和節點1(N1),第二行[0 -1 1 0]表示E2連線N1和N2,以此類推。那麼如何才能知道圖中是否存在環呢?我們使用Matlab作為工具,利用4 x 4的單位矩陣與A1組成增廣矩陣,並對這個矩陣進行高斯-若爾當消元。

>> B1 = rref([A, eye(4)])

B1 =
     1     0     0    -1    -1     0     0     1
     0     1     0    -1     0     0     0     1
     0     0     1    -1     0     0    -1     0
     0     0     0     0     0     1     1     1

  觀察矩陣B1的前4列,我們可以得知N0,N1,N2,N3四個節點相互連通。並且E4不影響整個圖的連通性。再觀察矩陣B1的後四列,它由單位矩陣變化而來,記錄了A1的消元過程。第4行[0 1 1 1]表示原矩陣A1的E2,E3和E4相加為0,你可以認為圖的連線2、3、4組成了一個環。請注意,由於A1表示無向環,你可以任意選擇連線中的兩個頂點的方向。

  同樣的規律放到有向圖中依然成立,只是為了表示連線的方向,我們需要規定N0 到 N1的邊為[-1 1 0 0]。因此矩陣A1依然可以作為圖3的數學模型。得到的B1依然可以觀察到後4列最後一行[0 1 1 1],只是它的幾何意義有所區別:還記得上文中我們有關有向圖中環測定的公式嗎:V4 + V2 + V3 = 0,沒錯,[0 1 1 1]真正的幾何含義就是前面這個公式,只不過在無向圖中,我們簡化了判斷。同樣,如果我們交換有向圖的2、3節點,利用矩陣運算依然可以得到相同結果:

A2 =
    -1     1     0     0
     0    -1     0     1
     0     0     1    -1
     0     1    -1     0

>> B2 = rref([A2, eye(4)])

B2 =
     1     0     0    -1    -1     0     1     1
     0     1     0    -1     0     0     1     1
     0     0     1    -1     0     0     1     0
     0     0     0     0     0     1     1     1

四、總結

  到此,我有關環測定和矩陣使用的說明就基本完成了。如果你對線性代數不甚瞭解,可能對最後一段的理解有些吃力。不要緊,正如我在前文中說明的,你不需要任何與矩陣相關的知識,而我也並沒有提供匹配的演算法。我寫最後一段的意義在於讓你明白,圖和矩陣運算有著緊密聯絡。很多看似深奧的演算法邏輯背後其實時數學運算的結果。如果你在閱讀了本文後能夠掌握有向圖和無向圖的深度優先演算法,並感覺在演算法的理論上還有所啟發,這就足夠了。

相關文章