重學資料結構(七、圖)

三分惡發表於2020-11-28

@


圖是一種比線性表和樹更為複雜的資料結構。線上性表中,資料元素之間僅有線性關係,每個資料元素只有一個直接前驅和一個直接後繼;在樹形結構中,資料元素之間有著明顯的層次關係,並且每一層中的資料元素可能和下一層中的多個元素(即其孩子結點)相關,但只能和上一層中一個元素(即其雙親結點)相關; 而在圖結構中,結點之間的關係可以是任意的,圖中任意兩個資料元素之間都可能相關。


一、圖的基本概念

在電腦科學中的圖是由點和邊構成的。


圖1:圖的示意圖

在這裡插入圖片描述


1、圖的定義

圖(Graph) G由兩個集合V和E組成,記為G=(V,E) , 其中V是頂點的有窮非空集合,E是V中頂點偶對的有窮集合,這些頂點偶對稱為。V(G)和E(G)通常分別表示圖G的頂點集合和邊集合,E(G)可以為空集。若 E(G)為空,則圖G只有頂點而沒有邊。

對於圖G ,若邊集E(G)為有向邊的集合,則稱該圖為有向圖;若邊集E(G)為無向邊的集合,則稱該圖為無向圖。


圖2:無向圖(a)和有向圖(b)

在這裡插入圖片描述

在有向圖中,頂點對<x, y>是有序的,它稱為從頂點 x到頂點y的一條有向邊。 因此<x,y>與<y, x>是不同的兩條邊。 頂點對用一對尖括號括起來,x是有向邊的始點,y是有向邊的終點。<x, y>也稱作一條弧,則 x為弧尾, y為弧頭。

在無向圖中,頂點對<x, y>是無序的,它稱為從頂點 x與頂點y相關聯的一條邊。這條邊沒有特定的方向,(x,y) 和 (y,x)是同一條邊。為了區別於有向圖,無向圖的一對頂點用括號括起來。


2、圖的基本術語

用n表示圖中頂點數目,用e表示邊的數目, 來看看圖結構中的一些基本術語。

  • 子圖:假設有兩個圖 G = (V, E)和 G'= (V', E'), 如果V'己V 且 E'\(\subseteq\)E, 則稱 G'為 G 的子圖。例如, 圖 3 所示為圖 2 中 G 1 和 G2 子圖的一些例子。


圖3:子圖示例

在這裡插入圖片描述

  • 無向完全圖和有向完全圖:對千無向圖, 若具有 n(n- 1)/2 條邊,則稱為無向完全圖。對於有向圖, 若具有 n(n- l)條弧,則稱為有向完全圖。

  • 稀疏圖和稠密圖:有很少條邊或弧(如 e<nlog2n) 的圖稱為稀疏圖, 反之稱為稠密圖。

  • 權和網:在實際應用中,每條邊可以標上具有某種含義的數值,該數值稱為該邊上的。這些權可以表示從一個頂點到另一個頂點的距離或耗費。這種帶權的圖通常稱為

  • 鄰接點:對於 無向圖 G, 如果圖的邊 (v, v')\(\in\)E, 則稱頂點 v 和 v'互為鄰接點, 即 v 和 v'相鄰接。邊 (v, v')依附於頂點 v 和 v', 或者說邊 (v, v')與頂點 v 和 v'相關聯

  • 度、入度和出度:頂知的度是指和v 相關聯的邊的數目,記為 TD(v) 。例如,圖2 (b) 中G2的頂點 V3 的度是3。對於有向圖,頂點v的度分為入度和出度入度是以頂點v為頭的弧的數目,記為 ID(v); 出度是以頂點 v 為尾的弧的數目,記為OD(v)。頂點 v 的度為 TD(v) = ID(v) + OD(可。例如,圖2中 G1 的頂點v1的入度 ID(v1)=1, 出度 OD(v1)=2, 度TD(v1)= ID(v1) + OD(v1) =3。一般地,如果頂點 Vi 的度記為 TD(vi),那麼一個有n個頂點,e條邊的圖,滿足如下關係:

在這裡插入圖片描述

  • 路徑和路徑長度:在無向圖 G 中,從 頂點 v 到頂點 v'的 路徑是一個頂點序列 (v = vi,0,Vi, 1,…, i;, m= v'), 其中 (vi,j-1, vi,j)\(\in\)E, 其中1\(\leq\)j\(\leq\)m。 如果 G 是有向圖, 則路徑也是有向的,頂點序列應滿 足 <v;,1-1, vi,j)>\(\in\)E, 其中1\(\leq\)j\(\leq\)m。 路徑長度是一條路徑上經過的邊或弧的數目。

  • 迴路或環:第一個頂點和最後一個頂點相同的路徑稱為迴路或環

  • 簡單路徑、 簡單迴路或簡單環:序列中頂點不重複出現的路徑稱為簡單路徑。除了第一個頂點和最後一個頂點之外, 其餘頂點不重複出現的迴路,稱為簡單迴路簡單環

  • 連通、連通圖和連通分量:在無向圖 G 中,如果從頂點 v 到頂點 v'有路徑,則稱 v 和 v'是連通的。如果對於圖中任意兩個頂點 Vi、 Vj\(\in\)V, Vi 和 Vj 都是連通的,則稱 G 是連通圖。圖 2
    (b)中的 G2 就是一個連通圖,而圖 4 (a) 中的 G3 則是非連通圖,但 G3 有 3個連通分量,如圖
    4 (b) 所示。所謂連通分量, 指的是無向圖中的極大連通子圖。


圖4:無向圖及其連通分量

在這裡插入圖片描述

  • 強連通圖和強連通分量:在有向圖 G 中,如果對於每一對 Vi, Vj \(\in\)V,Vi\(\not=\)Vj, 從 Vi到 Vj和
    從 Vj 到Vi都存在路徑,則稱G是強連通圖。有向圖中的極大強連通子圖稱作有向圖的強連通分量。例如圖2 中的G1 不是強連通圖,但它有兩個強連通分量,如圖5所示。


圖5:G1 的兩個強連通分量

在這裡插入圖片描述

  • 連通圖的生成樹:一個極小連通子圖,它含有圖中全部頂點,但只有足以構成一棵樹的 n-1 條邊,這樣的連通子圖稱為連通圖的生成樹。圖6所示為G3 中最大連通分量的一棵生成樹。如果在一棵生成樹上新增一條邊,必定構成一個環,因為這條邊使得它依附的那兩個頂點之間有了第二條路徑。


圖6:G3的最大連通分量的一棵生成樹

在這裡插入圖片描述

  • 有向樹和生成森林:有一個頂點的入度為 0, 其餘頂點的入度均為 l1的有向圖稱為有向樹。 一個有向圖的生成森林是由若干棵有向樹組成,含有圖中全部頂點,但只有足以構成若干棵不相交的有向樹的弧。 圖7所示為其一例。


圖7:一個有向圖及其生成森林

在這裡插入圖片描述


二、圖的儲存結構

圖的儲存結構相較線性表與樹來說就更加複雜。

圖的儲存結構比較常見的有兩種,鄰接矩陣和鄰接表。


1、鄰接矩陣

具體地,若圖 G 中包含 n 個頂點,我們就使用一個 n×n 的方陣 A,並使每一頂點都分別對應於某一行(列)。既然圖所描述的是這些頂點各自對應的元素之間的二元關係,故可以很自然地將任意一對元素 u 和 v 之間可能存在二元關係與矩陣 A 中對應的單元 A[u, v]對應起來: 1 或 true 表示存在關係, 0 或 false 表示不存在關係。這一矩陣中的各個單元分別描述了一對元素之間可能存在的鄰接關係,故此得名。


圖8:鄰接矩陣儲存示意圖

在這裡插入圖片描述

(a)是無向圖, (b)是有向圖。無向圖的鄰接矩陣,是一個對稱矩陣。在圖中所示的矩陣,a[i][j] 值都為1,如果是帶權的圖,我們可以將其設定為權值。

這一表示形式也可以推廣至帶權圖,具體方法是,將每條邊的權重記錄在該邊對應得矩陣單元中。

需要注意的是:

  • (1) 鄰接矩陣表示法對於以圖的頂點為主的運算比較適用;
  • (2) 除完全圖外, 其他圖的鄰接矩陣有許多零元素, 特別是當 n 值較大, 而邊數相對完全圖的邊又少得多時, 則此矩陣稱為“ 稀疏矩陣” , 比較浪費儲存空間。

圖的鄰接矩陣表示方法簡單實現如下:

/**
 * @Author 三分惡
 * @Date 2020/11/28
 * @Description 圖的鄰接矩陣儲存實現
 */
public class AMWGraph {
    private ArrayList vertexList;//儲存點的連結串列
    private int[][] edges;//鄰接矩陣,用來儲存邊
    private int numOfEdges;//邊的數目

    public AMWGraph(int n) {
        //初始化矩陣,一維陣列,和邊的數目
        edges=new int[n][n];
        vertexList=new ArrayList(n);
        numOfEdges=0;
    }

    //得到結點的個數
    public int getNumOfVertex() {
        return vertexList.size();
    }

    //得到邊的數目
    public int getNumOfEdges() {
        return numOfEdges;
    }

    //返回結點i的資料
    public Object getValueByIndex(int i) {
        return vertexList.get(i);
    }

    //返回v1,v2的權值
    public int getWeight(int v1,int v2) {
        return edges[v1][v2];
    }

    //插入結點
    public void insertVertex(Object vertex) {
        vertexList.add(vertexList.size(),vertex);
    }

    //插入結點
    public void insertEdge(int v1,int v2,int weight) {
        edges[v1][v2]=weight;
        numOfEdges++;
    }

    //刪除結點
    public void deleteEdge(int v1,int v2) {
        edges[v1][v2]=0;
        numOfEdges--;
    }

    //得到第一個鄰接結點的下標
    public int getFirstNeighbor(int index) {
        for(int j=0;j<vertexList.size();j++) {
            if (edges[index][j]>0) {
                return j;
            }
        }
        return -1;
    }

    //根據前一個鄰接結點的下標來取得下一個鄰接結點
    public int getNextNeighbor(int v1,int v2) {
        for (int j=v2+1;j<vertexList.size();j++) {
            if (edges[v1][j]>0) {
                return j;
            }
        }
        return -1;
    }
}

2、鄰接表

鄰接矩陣雖然比較直觀,但是空間利用率是上並不理想。其中大量的單元所對應的邊有可能並未在圖中出現,這也是靜態向量結構普遍的不足。既然如此,我們為什麼不將向量改為列表呢?

鄰接表是圖的一種連結儲存結構。 鄰接表表示法只關心存在的邊,將頂點的鄰接邊用列表表示。


圖9:鄰接表儲存示意圖

在這裡插入圖片描述
我們來看一下具體的實現。


2.1、有向圖介面定義

這是有向圖的抽象介面定義。

/**
 * @Author 三分惡
 * @Date 2020/11/28
 * @Description 有向圖介面
 */
public interface IDirectGraph<V> {

    /**
     * 新增頂點
     *
     * @param v 頂點
     * @since 0.0.2
     */
    void addVertex(final V v);

    /**
     * 刪除頂點
     *
     * @param v 頂點
     * @return 是否刪除成功
     * @since 0.0.2
     */
    boolean removeVertex(final V v);

    /**
     * 獲取頂點
     *
     * @param index 下標
     * @return 返回頂點資訊
     * @since 0.0.2
     */
    V getVertex(final int index);

    /**
     * 新增邊
     *
     * @param edge 邊
     * @since 0.0.2
     */
    void addEdge(final Edge<V> edge);

    /**
     * 移除邊
     *
     * @param edge 邊資訊
     * @since 0.0.2
     */
    boolean removeEdge(final Edge<V> edge);

    /**
     * 獲取邊資訊
     *
     * @param from 開始節點
     * @param to   結束節點
     * @since 0.0.2
     */
    Edge<V> getEdge(final int from, final int to);
}

2.2、邊的實現

這是有向圖的邊的實現:

/**
 * @Author 三分惡
 * @Date 2020/11/28
 * @Description 邊
 */
public class Edge<V> {

    /**
     * 開始節點
     * @since 0.0.2
     */
    private V from;

    /**
     * 結束節點
     * @since 0.0.2
     */
    private V to;

    /**
     * 權重
     * @since 0.0.2
     */
    private double weight;

    public Edge(V from, V to) {
        this.from = from;
        this.to = to;
    }

    public V getFrom() {
        return from;
    }

    public void setFrom(V from) {
        this.from = from;
    }

    public V getTo() {
        return to;
    }

    public void setTo(V to) {
        this.to = to;
    }

    public double getWeight() {
        return weight;
    }

    public void setWeight(double weight) {
        this.weight = weight;
    }

    @Override
    public String toString() {
        return "Edge{" +
                "from=" + from +
                ", to=" + to +
                ", weight=" + weight +
                '}';
    }

    @Override
    public boolean equals(Object o) {
        if (this == o) {
            return true;
        }
        if (o == null || getClass() != o.getClass()) {
            return false;
        }
        Edge<?> edge = (Edge<?>) o;
        return Double.compare(edge.weight, weight) == 0 &&
                to.equals(edge.to) &&
                from.equals(edge.from);
    }

    @Override
    public int hashCode() {
        return hashCode();
    }
}

2.3、有向圖節點

這裡我們不再單獨做頂點的實現,所以 節點=頂點+邊。

/**
 * @Author 三分惡
 * @Date 2020/11/28
 * @Description
 */
public class GraphNode<V> {

    /**
     * 頂點資訊
     * @since 0.0.2
     */
    private V vertex;

    /**
     * 以此頂點為起點的邊的集合,是一個列表,列表的每一項是一條邊
     *
     * (1)使用集合,避免重複
     */
    private Set<Edge<V>> edgeSet;

    /**
     * 初始化一個節點
     * @param vertex 頂點
     */
    public GraphNode(V vertex) {
        this.vertex = vertex;
        this.edgeSet = new HashSet<Edge<V>>();
    }

    /**
     * 新增一條邊
     * @param edge 邊
     */
    public void add(final Edge<V> edge) {
        edgeSet.add(edge);
    }

    /**
     * 獲取目標邊
     * @param to 目標邊
     * @return 邊
     * @since 0.0.2
     */
    public Edge<V> get(final V to) {
        for(Edge<V> edge : edgeSet) {
            V dest = edge.getTo();

            if(dest.equals(to)) {
                return edge;
            }
        }

        return null;
    }

    /**
     * 獲取目標邊
     * @param to 目標邊
     * @return 邊
     * @since 0.0.2
     */
    public Edge<V> remove(final V to) {
        Iterator<Edge<V>> edgeIterable = edgeSet.iterator();

        while (edgeIterable.hasNext()) {
            Edge<V> next = edgeIterable.next();

            if(to.equals(next.getTo())) {
                edgeIterable.remove();
                return next;
            }
        }

        return null;
    }

    public V getVertex() {
        return vertex;
    }

    public Set<Edge<V>> getEdgeSet() {
        return edgeSet;
    }

    @Override
    public String toString() {
        return "GraphNode{" +
                "vertex=" + vertex +
                ", edgeSet=" + edgeSet +
                '}';
    }

}

2.4、有向圖具體實現

接下來是有向圖的鄰接表表示具體實現。

/**
 * @Author 三分惡
 * @Date 2020/11/28
 * @Description
 */
public class ListDirectGraph<V> implements IDirectGraph<V> {

    /**
     * 節點連結串列
     *
     * @since 0.0.2
     */
    private List<GraphNode<V>> nodeList;

    /**
     * 初始化有向圖
     *
     * @since 0.0.2
     */
    public ListDirectGraph() {
        this.nodeList = new ArrayList<GraphNode<V>>();
    }


    public void addVertex(V v) {
        GraphNode<V> node = new GraphNode<V>(v);

        // 直接加入到集合中
        this.nodeList.add(node);
    }


    public boolean removeVertex(V v) {
        //1. 移除一個頂點
        //2. 所有和這個頂點關聯的邊也要被移除
        Iterator<GraphNode<V>> iterator = nodeList.iterator();
        while (iterator.hasNext()) {
            GraphNode<V> graphNode = iterator.next();

            if (v.equals(graphNode.getVertex())) {
                iterator.remove();
            }
        }

        return true;
    }


    public V getVertex(int index) {
        return nodeList.get(index).getVertex();
    }


    public void addEdge(Edge<V> edge) {
        //1. 新增一條邊,直接遍歷列表。
        // 如果存在這條的起始節點,則將這條邊加入。
        // 如果不存在,則直接報錯即可。

        for (GraphNode<V> graphNode : nodeList) {
            V from = edge.getFrom();
            V vertex = graphNode.getVertex();

            // 起始節點在開頭
            if (from.equals(vertex)) {
                graphNode.getEdgeSet().add(edge);
            }
        }
    }


    public boolean removeEdge(Edge<V> edge) {
        // 直接從列表中對應的節點,移除即可
        GraphNode<V> node = getGraphNode(edge);
        if (null != node) {
            // 移除目標為 to 的邊
            node.remove(edge.getTo());
        }

        return true;
    }


    public Edge<V> getEdge(int from, int to) {
        // 獲取開始和結束的頂點
        V toVertex = getVertex(from);

        // 獲取節點
        GraphNode<V> fromNode = nodeList.get(from);
        // 獲取對應結束頂點的邊
        return fromNode.get(toVertex);
    }

    /**
     * 獲取圖節點
     *
     * @param edge 邊
     * @return 圖節點
     */
    private GraphNode<V> getGraphNode(final Edge<V> edge) {
        for (GraphNode<V> node : nodeList) {
            final V from = edge.getFrom();

            if (node.getVertex().equals(from)) {
                return node;
            }
        }

        return null;
    }

    /**
     * 獲取對應的圖節點
     *
     * @param vertex 頂點
     * @return 圖節點
     * @since 0.0.2
     */
    private GraphNode<V> getGraphNode(final V vertex) {
        for (GraphNode<V> node : nodeList) {
            if (vertex.equals(node.getVertex())) {
                return node;
            }
        }
        return null;
    }


}

三、圖的遍歷

和樹的遍歷類似,圖的遍歷也是從圖中某一頂點出發,按照某種方法對圖中所有頂點訪問且僅訪問一次。然而, 圖的遍歷要比樹的遍歷複雜得多。 因為圖的任一頂點都可能和其餘的頂點相鄰接。 所以在訪問了某個頂點之後, 可能沿著某條路徑搜尋之後, 又回到該頂點上。

根據搜尋路徑的方向, 通常有兩條遍歷圖的路徑:深度優先遍歷和廣度優先遍歷。 它們對無向圖和有向圖都適用。


1、深度優先遍歷

深度優先(DepthFirst Search, DFS)遍歷類似千樹的先序遍歷,是樹的先序遍歷的推廣。

對於一個連通圖,深度優先搜尋遍歷的過程如下。

初始條件下所有節點為白色,選擇一個作為起始頂點,按照如下步驟遍歷:

  • a. 選擇起始頂點塗成灰色,表示還未訪問

  • b. 從該頂點的鄰接頂點中選擇一個,繼續這個過程(即再尋找鄰接結點的鄰接結點),一直深入下去,直到一個頂點沒有鄰接結點了,塗黑它,表示訪問過了

  • c. 回溯到這個塗黑頂點的上一層頂點,再找這個上一層頂點的其餘鄰接結點,繼續如上操作,如果所有鄰接結點往下都訪問過了,就把自己塗黑,再回溯到更上一層。

  • d. 上一層繼續做如上操作,直到所有頂點都訪問過。

以下面一個有向圖為例來展示這個過程:


圖9:有向圖深度優先遍歷

在這裡插入圖片描述

具體程式碼實現:

@Override
public List<V> dfs(V root) {
    List<V> visitedList = Guavas.newArrayList();
    Stack<V> visitingStack = new Stack<>();
    // 頂點首先壓入堆疊
    visitingStack.push(root);
    // 獲取一個邊的節點
    while (!visitingStack.isEmpty()) {
        V visitingVertex = visitingStack.peek();
        GraphNode<V> graphNode = getGraphNode(visitingVertex);
        boolean hasPush = false;
        if(null != graphNode) {
            Set<Edge<V>> edgeSet = graphNode.getEdgeSet();
            for(Edge<V> edge : edgeSet) {
                V to = edge.getTo();
                if(!visitedList.contains(to)
                        && !visitingStack.contains(to)) {
                    // 尋找到下一個臨接點
                    visitingStack.push(to);
                    hasPush = true;
                    break;
                }
            }
        }
        // 迴圈之後已經結束,沒有找到下一個臨點,則說明訪問結束。
        if(!hasPush) {
            // 獲取第一個元素
            visitedList.add(visitingStack.pop());
        }
    }
    return visitedList;
}


2、廣度優先遍歷

廣度優先(Breadth First Search, BFS)遍歷類似於樹的按層次遍歷的過程。

廣度優先搜尋在進一步遍歷圖中頂點之前,先訪問當前頂點的所有鄰接結點。

  • a.首先選擇一個頂點作為起始結點,並將其染成灰色,其餘結點為白色。

  • b. 將起始結點放入佇列中。

  • c. 從佇列首部選出一個頂點,並找出所有與之鄰接的結點,將找到的鄰接結點放入佇列尾部,將已訪問過結點塗成黑色,沒訪問過的結點是白色。如果頂點的顏色是灰色,表示已經發現並且放入了佇列,如果頂點的顏色是白色,表示還沒有發現

  • d. 按照同樣的方法處理佇列中的下一個結點。

基本就是出隊的頂點變成黑色,在佇列裡的是灰色,還沒入隊的是白色。

以下面一個有向圖為例來展示這個過程:


圖10:有向圖廣度優先遍歷

在這裡插入圖片描述

來看一下具體程式碼實現:

@Override
public List<V> bfs(final V root) {
    List<V> visitedList = Guavas.newArrayList();
    Queue<V> visitingQueue = new LinkedList<>();
    // 1. 放入根節點
    visitingQueue.offer(root);
    // 2. 開始處理
    V vertex = visitingQueue.poll();
    while (vertex != null) {
        // 2.1 獲取對應的圖節點
        GraphNode<V> graphNode = getGraphNode(vertex);
        // 2.2 圖節點存在
        if(graphNode != null) {
            Set<Edge<V>> edgeSet = graphNode.getEdgeSet();
            //2.3 將不在訪問列表中 && 不再處理佇列中的元素加入到佇列。
            for(Edge<V> edge : edgeSet) {
                V target = edge.getTo();
                if(!visitedList.contains(target)
                    && !visitingQueue.contains(target)) {
                    visitingQueue.offer(target);
                }
            }
        }
        //3. 更新節點資訊
        // 3.1 放入已經訪問的列表
        visitedList.add(vertex);
        // 3.2 當節點設定為最新的元素
        vertex = visitingQueue.poll();
    }
    return visitedList;
}




上一篇:重學資料結構(六、樹和二叉樹)



本部落格為學習筆記,參考資料如下!
水平有限,難免錯漏,歡迎指正!


參考:

【1】:鄧俊輝 編著. 《資料結構與演算法》
【2】:王世民 等編著 . 《資料結構與演算法分析》
【3】: Michael T. Goodrich 等編著.《Data-Structures-and-Algorithms-in-Java-6th-Edition》
【4】:嚴蔚敏、吳偉民 編著 . 《資料結構》
【5】:程傑 編著 . 《大話資料結構》
【6】:圖的理解:儲存結構與鄰接矩陣的Java實現
【7】:java 實現有向圖(Direct Graph)
【8】:資料結構——圖簡介(java程式碼實現鄰接矩陣)
【9】:圖的理解:深度優先和廣度優先遍歷及其 Java 實現

相關文章