算法精解:DAG有向無環圖


DAG是公認的下一代區塊鏈的標志。本文從算法基礎去研究分析DAG算法,以及它是如何運用到區塊鏈中,解決了當前區塊鏈的哪些問題。

關鍵字:DAG,有向無環圖,算法,背包,深度優先搜索,棧,BlockChain,區塊鏈

圖是數據結構中最為復雜的一種,我在上大學的時候,圖的這一章會被老師划到考試范圍之外,作為我們的課后興趣部分。但實際上,圖在信息化社會中的應用非常廣泛。圖主要包括:

  • 無向圖,結點的簡單連接
  • 有向圖,連接有方向性
  • 加權圖,連接帶有權值
  • 加權有向圖,連接既有方向性,又帶有權值

圖是由一組頂點和一組能夠將兩個頂點相連的邊組成。

常見的地圖,電路,網絡等都是圖的結構。

術語

  • 頂點:圖中的一個點
  • 邊:連接兩個頂點的線段叫做邊,edge
  • 相鄰的:一個邊的兩頭的頂點稱為是相鄰的頂點
  • 度數:由一個頂點出發,有幾條邊就稱該頂點有幾度,或者該頂點的度數是幾,degree
  • 路徑:通過邊來連接,按順序的從一個頂點到另一個頂點中間經過的頂點集合
  • 簡單路徑:沒有重復頂點的路徑
  • 環:至少含有一條邊,並且起點和終點都是同一個頂點的路徑
  • 簡單環:不含有重復頂點和邊的環
  • 連通的:當從一個頂點出發可以通過至少一條邊到達另一個頂點,我們就說這兩個頂點是連通的
  • 連通圖:如果一個圖中,從任意頂點均存在一條邊可以到達另一個任意頂點,我們就說這個圖是個連通圖
  • 無環圖:是一種不包含環的圖
  • 稀疏圖:圖中每個頂點的度數都不是很高,看起來很稀疏
  • 稠密圖:圖中的每個頂點的度數都很高,看起來很稠密
  • 二分圖:可以將圖中所有頂點分為兩部分的圖

所以樹其實就是一種無環連通圖。

有向圖

有向圖是一幅有方向性的圖,由一組頂點和有向邊組成。所以,大白話來講,有向圖是包括箭頭來代表方向的。

常見的例如食物鏈,網絡通信等都是有向圖的結構。

術語

上面我們介紹了頂點的度數,在有向圖中,頂點被細分為了:

  • 出度:由一個頂點出發的邊的總數
  • 入度:指向一個頂點的邊的總數

接着,由於有向圖的方向性,一條邊的出發點稱為頭,指向點稱為尾。

  • 有向路徑:圖中的一組頂點可以滿足從其中任意一個頂點出發,都存在一條有向邊指向這組頂點中的另一個。
  • 有向環:至少含有一條邊的起點和終點都是同一個頂點的一條有向路徑。
  • 簡單有向環:一條不含有重復頂點和邊的環。
  • 路徑或環的長度就是他們包含的邊數。

圖的連通性在有向圖中表現為可達性,由於邊的方向性,可達性必須是通過頂點出發的邊的正確方向,與另一個頂點可連通。

鄰接表數組

可表示圖的數據類型,意思就是如何通過一個具體的文件內容,來表示出一幅圖的所有頂點,以及頂點間的邊。

鄰接表數組,以頂點為索引(注意頂點沒有權值,只有順序,因此是從0開始的順序值),其中每個元素都是和該頂點相鄰的頂點列表。

5 vertices, 3 edges
0: 4 1
1: 0
2:
3:
4:

背包

做一個背包集合,用來存儲與一個頂點連通的頂點集合,因為不在意存儲順序,並且只進不出,所以選擇背包結構來存儲。溫習一下背包

package algorithms.bag;

import java.util.Iterator;

// 定義一個背包集合,支持泛型,支持迭代
public class Bag<Item> implements Iterable<Item> {

    private class BagNode<Item> {
        Item item;
        BagNode next;
    }

    BagNode head;
    int size;

    @Override
    public Iterator<Item> iterator() {
        return new Iterator<Item>() {
            BagNode node = head;

            @Override
            public boolean hasNext() {
                return node.next != null;
            }

            @Override
            public Item next() {
                Item item = (Item) node.item;
                node = node.next;
                return item;
            }
        };
    }

    public Bag() {
        head = new BagNode();
        size = 0;
    }

    // 往前插入
    public void add(Item item) {
        BagNode temp = new BagNode();
        // 以下兩行代碼一定要聲明,不可直接使用temp = head,那樣temp賦值的是head的引用,對head的所有修改會直接同步到temp,temp就不具備緩存的功能,引發bug。。
        temp.next = head.next;
        temp.item = head.item;
        head.item = item;
        head.next = temp;
        size++;
    }

    public boolean isEmpty() {
        return size == 0;
    }

    public int size() {
        return this.size;
    }

    public static void main(String[] args) {
        Bag<String> bags = new Bag();
        bags.add("hello");
        bags.add("yeah");
        bags.add("liu wen bin");
        bags.add("seminar");
        bags.add("1243");
        System.out.println(bags.size);

//        for (Iterator i = bags.iterator(); i.hasNext(); ) {
//            System.out.println(i.next());
//        }

        // 由於Bag實現了Iterable接口,所以支持以下方式遍歷
        for (String a : bags) {
            System.out.println(a);
        }
    }
}

有向圖結構

下面代碼實現一個有向圖數據結構,並添加常用有向圖屬性和功能。

package algorithms.graph;

import algorithms.bag.Bag;
import ioutil.In;
import ioutil.StdOut;

import java.io.FileReader;

public class Digraph {
    private final int V;// 頂點總數,定義final,第一次初始化以后不可更改。
    private int E;// 邊總數
    private Bag<Integer>[] adj;// {鄰接表}頂點為數組下標,值為當前下標為頂點值所連通的頂點個數。

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

    public Digraph(In in) {
        this(in.readInt());
        int E = in.readInt();
        for (int i = 0; i < E; i++) {
            int v = in.readInt();
            int w = in.readInt();
            addEdge(v, w);
        }
    }

    public int V() {
        return this.V;
    }

    public int E() {
        return this.E;
    }

    /**
     * v和w是兩個頂點,中間加一條邊,增加稠密度。
     *
     * @param v 大V是頂點總數,v是頂點值,所以並v不存在大小限制
     * @param w 同上。
     */
    public void addEdge(int v, int w) {
        adj[v].add(w);
        E++;
    }

    /**
     * 返回一個頂點的連通頂點集合的迭代器
     *
     * @param v
     * @return Bag本身就是迭代器,所以返回該頂點的連通頂點集合Bag即可。
     */
    public Iterable<Integer> adj(int v) {
        return adj[v];
    }

    /**
     * 將圖中所有方向反轉
     *
     * @return 返回一個圖將所有方向反轉后的副本
     */
    public Digraph reverse() {
        Digraph R = new Digraph(V);
        for (int v = 0; v < V; v++) {
            for (int w : adj[v]) {// 遍歷原圖中跟v頂點連通的頂點w。
                R.addEdge(w, v);
            }
        }
        return R;
    }

    /**
     * 按照鄰接表數組結構輸出有向圖內容
     *
     * @return
     */
    public String toString() {
        String s = V + " vertices, " + E + " edges\n";
        for (int v = 0; v < V; v++) {
            s += v + ": ";
            for (int w : this.adj(v)) {
                s += w + " ";
            }
            s += "\n";
        }
        return s;
    }

    public static void main(String[] args) {
        Digraph d = new Digraph(5);
        d.addEdge(0, 1);
        d.addEdge(1, 0);
        d.addEdge(2, 3);
        d.addEdge(0, 4);
        StdOut.println(d);
        /**
         輸出:
         5 vertices, 3 edges
         0: 4 1
         1: 0
         2:
         3:
         4:
         */
    }
}

以上背包和有向圖代碼相關解釋請具體參照代碼中注釋。

可達性

上面提到了有向圖中的可達性和圖中的連通性的關系,可達性是連通性的特殊形式,對方向敏感,所以提到有向圖,不可不研究可達性。

可達性解答了“從一個頂點v到達另一個頂點w,是否存在一條有向路徑”等類似問題。

深度優先搜索

解答可達性問題,要借助深度優先搜索算法。為了更好的理解深度優先算法,先來搞清楚如何完全探索一個迷宮。

Tremaux搜索

完全探索一個迷宮的規則是:從起點出發,不走重復路線,走到終點走出迷宮。具體流程:

  • 每當第一次到達一個新的頂點或邊時,標記上。
  • 在走的過程中,遇到一個已標記的頂點或邊時,退回到上一個頂點。
  • 當回退到的頂點已沒有可走的邊時繼續回退。

我想Tremaux搜索會給我們帶來一些啟發,回到圖的深度優先搜索算法。

package algorithms.graph;

import algorithms.bag.Bag;
import ioutil.StdOut;

/**
 * 基於深度優先搜索(Depth First Search)解答有向圖頂點可達性問題。
 */
public class DigraphDFS {
    private boolean[] marked;// 是否標記過

    /**
     * 算法:在圖中找到從某個頂點出發的所有頂點
     *
     * @param digraph
     * @param start
     */
    public DigraphDFS(Digraph digraph, int start) {
        marked = new boolean[digraph.V()];// 初始化marked數組
        dfs(digraph, start);
    }

    /**
     * 算法:在圖中找到從某些頂點出發的所有頂點,這些頂點被作為一個集合傳入。
     *
     * @param digraph
     * @param startSet
     */
    public DigraphDFS(Digraph digraph, Iterable<Integer> startSet) {
        marked = new boolean[digraph.V()];
        for (int w : startSet) {
            dfs(digraph, w);
        }
    }

    /**
     * 查詢某個頂點是否被標記(是否可達,因為標記過就是可達的)
     *
     * @param v
     * @return
     */
    public boolean marked(int v) {
        return marked[v];
    }

    /**
     * 深度優先搜索核心算法,通過標記,在圖中從v頂點出發找到有效路徑
     * <p>
     * 返回的是通過標記形成的一條有效路徑。
     *
     * @param digraph
     * @param v
     */
    private void dfs(Digraph digraph, int v) {
        marked[v] = true;// 標記起點可達。
        for (int w : digraph.adj(v)) {// 遍歷v頂點可達的一級頂點。
            if (!marked[w]) dfs(digraph, w);// 如果發現w頂點未到達過,則繼續從w開始dfs(即向前走了一步)
        }
    }

    public static void main(String[] args) {
        Digraph d = new Digraph(5);// 初始化五個頂點的圖
        d.addEdge(0, 1);
        d.addEdge(1, 0);
        d.addEdge(2, 3);
        d.addEdge(0, 4);
        Bag<Integer> startSet = new Bag<>();
        startSet.add(2);
        DigraphDFS reachable = new DigraphDFS(d, startSet);
        for (int v = 0; v < d.V(); v++) {
            if (reachable.marked(v)) {
                StdOut.print(v + " ");
            }
            StdOut.println();
        }
        /**
         * 輸出:
         *
         

         2
         3

         */
    }
}

startSet是入參條件,只有一個值為2,即在圖中找尋2的有效路徑,通過圖中的邊我們可以看出,2的有效路徑只有3,所以輸出是正確的。

可達性的一種應用:垃圾收集

我們都知道一般的對象垃圾收集都是計算它的引用數。在圖結構中,把對象作為頂點,引用作為邊,當一個對象在一段時間內未被他人引用的時候,這個頂點就是孤立的,對於其他有效路徑上的頂點來說它就是不可達的,因此就不會被標記,這時候,例如JVM就會清除掉這些對象釋放內存,所以JVM也是一直在跑類似以上這種DFS的程序,不斷找到那些未被標記的頂點,按照一定時間規則進行清除。

有向無環圖

不包含有向環的有向圖就是有向無環圖,DAG,Directed Acyclic Graph。

上面我們循序漸進的介紹了圖,有向圖,本節開始介紹有向無環圖,概念也已經給出,可以看出有向無環圖是有向圖的一種特殊結構。那么第一個問題就是

如何監測有向圖中沒有有向環,也就是如何確定一個DAG。

尋找有向環

基於上面的問題,我們要做一個尋找有向環的程序,這個程序還是依賴DFS深度優先搜索算法,如果找不到,則說明這個有向圖是DAG。

先來補個坑,其實前面包括背包我在之前都寫過,但因為前面那篇文章是我第一篇博文,我還太稚嫩,沒有掌握好的編輯器,也沒有粘貼代碼,所以這里有必要重新填坑。

package algorithms.stack;

import ioutil.StdOut;

import java.util.Iterator;
import java.util.NoSuchElementException;

public class Stack<Item> implements Iterable<Item> {
    private int SIZE;
    private Node first;// 棧頂

    public Stack() {// 初始化成員變量
        SIZE = 0;
        first = null;
    }

    private class Node {
        private Item item;
        private Node next;
    }

    // 棧:往first位置插入新元素
    public void push(Item item) {
        Node temp = first;
        first = new Node();
        first.item = item;
        first.next = temp;
        SIZE++;
    }

    // 棧:從first位置取出新元素,滿足LIFO,后進先出。
    public Item pop() {
        if (isEmpty()) throw new RuntimeException("Stack underflow");
        Item item = first.item;
        first = first.next;
        SIZE--;
        return item;
    }

    public boolean isEmpty() {
        return first == null;
    }

    public int size() {
        return this.SIZE;
    }

    @Override
    public Iterator<Item> iterator() {
        return new Iterator<Item>() {
            Node node = first;

            @Override
            public boolean hasNext() {
                return first != null;
            }

            @Override
            public Item next() {
                if (!hasNext()) throw new NoSuchElementException();
                Item item = node.item;
                node = node.next;
                return item;
            }
        };
    }

    public static void main(String[] args){
        Stack<String> stack = new Stack<>();
        stack.push("heyheyhey");
        stack.push("howau");
        stack.push("231");
        StdOut.println(stack.SIZE);
        StdOut.println(stack.pop());
    }
}

我們要做尋找有向環的程序的話,要依賴棧的結構,所以上面把這個坑給填了,下面回歸到尋找有向環的程序。(當然,你也可以直接使用java.util.Stack類)

package algorithms.graph;

import ioutil.StdOut;

import java.util.Stack;

public class DirectedCycle {
    private boolean[] marked;// 以頂點為索引,值代表了該頂點是否標記過(是否可達)
    private Stack<Integer> cycle; // 用來存儲有向環頂點。
    // *****重點理解這里start****
    private int[] edgeTo;// edgeTo[0]=1代表頂點1->0, to 0的頂點為1。
    // *****重點理解這里end****
    private boolean[] onStack;// 頂點為索引,值為該頂點是否參與dfs遞歸,參與為true

    public DirectedCycle(Digraph digraph) {
        // 初始化成員變量
        marked = new boolean[digraph.V()];
        onStack = new boolean[digraph.V()];
        edgeTo = new int[digraph.V()];
        cycle = null;
        // 檢查是否有環
        for (int v = 0; v < digraph.V(); v++) {
            dfs(digraph, v);
        }
    }

    private void dfs(Digraph digraph, int v) {
        onStack[v] = true;// 遞歸開始,頂點上棧
        marked[v] = true;
        for (int w : digraph.adj(v)) {// 遍歷一條邊,v-> w
            // 終止條件:找到有向環
            if (hasCycle()) return;
            // 使用onStack標志位來記錄有效路徑上的點,如果w在棧上,說明w在前面當了出發點,
            if (!marked[w]) {
                edgeTo[w] = v;// to w的頂點為v
                dfs(digraph, w);
            } else if (onStack[w]) {// 如果指到了已標記的頂點,且該頂點遞歸棧上。(棧上都是出發點,而找到了已標記的頂點是終點,說明出發點和終點相同了。)
                cycle = new Stack<Integer>();
                for (int x = v; x != w; x = edgeTo[x]) {//起點在第一次循環中已經push了,不要重復
                    cycle.push(x);// 將由v出發,w結束的環上中間的結點遍歷push到cycle中。
                }
                cycle.push(w);// push終點
            }
        }
        onStack[v] = false;// 當遞歸開始結算退出時,頂點下棧。
    }

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

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

    public static void main(String[] args) {
        Digraph d = new Digraph(6);
        d.addEdge(0, 1);
        d.addEdge(1, 2);
        d.addEdge(2, 3);
        d.addEdge(3, 0);
        DirectedCycle directedCycle = new DirectedCycle(d);
        if (directedCycle.hasCycle()) {
            for (int a : directedCycle.cycle()) {
                StdOut.println(a);
            }
        } else {
            StdOut.println("DAG");
        }
    }
}

這段代碼不長但其中算法比較復雜,我盡力在注釋中做了詳細解釋,如有任何不明之處,歡迎隨時留言給我。

以上程序的測試用圖為

6 vertices, 4 edges
0: 1 
1: 2 
2: 3 
3: 0 
4: 
5:

肉眼可以看出,這是一個0-1-2-3-0的一個有向環,所以以上程序的執行結果為:


3
2
1
0


先入棧的在后面,可以看出是0-1-2-3的有向環結構。如果我們將圖的內容改為:

6 vertices, 4 edges
0: 1 
1: 2 
2: 3 
3: 
4: 
5: 0

則明顯最后一個拼圖3-0被我們打破了,變成了無所謂的5-0,這時該有向圖就不存在有向環。此時以上程序執行結果為:


DAG


DAG與BlockChain

上面一章節我們將DAG深挖了挖,我想到這里您已經和我一樣對DAG的算法層面非常了解,那么它和如今沸沸揚揚的區塊鏈有什么關聯呢?本章節主要介紹這部分內容。

在前面的文章中,我們已經了解了區塊鏈技術,無論是比特幣還是以太坊,都是基於一條鏈式結構,實現了去中心化的,點對點的,trustless的一種新型技術。然而這條鏈式結構在面臨業務拓展的時候屢屢遭受新的挑戰,例如塊存儲量問題,交易速度問題,數據總量過大,單節點存儲壓力等等。而DAG是基於圖的一種實現方式,之所以不允許有向環的出現,是因為DAG可以保證結點交易的順序,可以通過上面介紹過的有效路徑來找到那根主鏈。如果出現了有向環,那系統就亂了。如果沒有有向環的話,DAG中可以有多條有效路徑連接各個頂點,因此DAG可以說是更加完善,強大的新一代區塊鏈結構。
image
目前非常有名的采用DAG技術的區塊鏈產品有DagCoin,IOTA,ByteBall等,他們都是基於DAG,在性能和儲量上面有了全面的提升。

這里面仍然會有“分叉”的可能,處理方式也是相同的,看哪個結點能夠有新的后續,這個部分我們在講“叔塊”的時候說過。

區塊鏈采用DAG結構以后稱為了blockless,無塊化的結構,即我們不再將交易打包到塊中,以塊為單元進行存儲,而是直接將交易本身作為基本單元進行存儲。另外,DAG也有雙花的可能,也是上面“分叉問題”引起的,但它在確認有效路徑以后會自動恢復。同時,DAG是異步共識,具體機制還不了解,但它解決了交易性能問題。

總結

本文循序漸進地從圖到有向圖到有向無環圖,詳細地介紹了相關術語,api代碼實現,也補充入了背包和棧的代碼實現,重點研究了圖的深度優先搜索算法以及尋找有向環算法。最后對DAG和區塊鏈的關系進行了簡介,希望隨着技術發展,DAG有望成為真正的區塊鏈3.0。

參考資料

Algorithms 4th,網上資料

更多文章請轉到醒者呆的博客園


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM