1. 程式人生 > 實用技巧 >資料結構與演算法(十八):圖

資料結構與演算法(十八):圖

一、什麼是圖

1.概述

首先,我們已經在之前學習過了樹這種資料結構,樹能反映一對多的關係,但是卻無法反映多對多的關係,因此我們引入了圖這種資料結構。

對於圖,其節點也可以叫做頂點,每個節點具有零或者多個相連節點,每個節點之間的連線稱為,從一個節點到達另一個節點路線都稱為路徑

以上圖為例,其中:

  • 無向圖:頂點之間連線沒有方向。比如從A到C,可是A -> B -> C,也可以是A -> D -> B -> C。
  • 有向圖:頂點之間連線有方向。如果A到B,必須是A -> B,不能是B -> A
  • 帶權圖:邊帶有權值。

2.樹與圖的關係

實際上,對於有向圖還分為兩種情況,即圖中含環或者圖中不含環的單向圖,其中含環的圖可以從某個頂點出發最終返回原點。

結合對圖的定義,我們不難發現,樹也可以理解為不含有環的單向圖,是圖的子集。

兩者的區別在於:

  • 圖中每個節點可以有任意數量的邊,而樹兩個節點間僅僅只有一條邊
  • 圖沒有根節點,而樹有
  • 圖中可以存著環,而樹不行
  • 如果有n個節點,圖最多有n*(n-1)條邊,而樹最多有n-1條邊

二、圖的表示與構建

圖的表示就是邊與邊關係的表示,有二維陣列(鄰接矩陣)和連結串列(鄰接表)兩種表示方法。

1.鄰接矩陣

我們建立一個二維陣列(矩陣),第一維表示頂點,而第二維表示與該頂點相連線的點。

比如說0號點與1,2,3,4相連,與0(自己)和5不相連,表示為[0][011110],其中,二維陣列中的1表示與0號點相連,0表示與0號點不相連

2.鄰接表

鄰接表相比鄰接矩陣,只表示關聯的邊而不表示不關聯的表,相對鄰接矩陣而言更簡潔也更節省空間

3.程式碼實現

我們使用鄰接矩陣的方式來示範如何使用程式碼構建一個圖。

為了方便理解,我們使用兩個陣列來表示節點與節點之間的對應關係:

如上圖,上圖的節點之間的對應關係通過兩個陣列來表示就是{0,0,0,0,1} -> {1,2,3,4,2},即 0->1,0->2,,0->3,,0->4,,1->2,可見要建立的圖有5個節點。

對應實現程式碼如下:

/**
 * @Author:CreateSequence
 * @Date:2020-08-04 16:50
 * @Description:圖
 */
public class Graph {

    //節點與節點間的相連關係
    private int[] node1;
    private int[] node2;
    //有幾個節點
    private int num;
    //邊的數量
    private int sideNum;

    private int[][] graph;

    public Graph(int[] node1, int[] node2, int num) {
        this.node1 = node1;
        this.node2 = node2;
        this.num = num;
        this.sideNum = 0;

        //建立圖
        CreateGraph();
    }

    /**
     * 建立圖
     */
    private void CreateGraph(){
        //獲取二維陣列,一維表示節點,二維表示節點的相鄰節點
        graph = new int[num][num];

        //初始化陣列
        for (int i = 0; i < num; i++) {
            graph[i] = Arrays.copyOf(graph[i], num);
        }

        //新增節點
        for (int i = 0; i < node1.length; i++) {

            //統計邊數
            if (graph[node1[i]][node2[i]] == 0) {
                sideNum++;
            }

            graph[node1[i]][node2[i]] = 1;
            graph[node2[i]][node1[i]] = 1;
        }
    }

    /**
     * 展示圖
     */
    public void show() {
        for (int[] n1 : graph) {
            for (int n2 : n1) {
                System.out.print(n2 + " ");
            }
            System.out.println();
        }

        System.out.println("有" + num + "個節點," + sideNum + "條邊");
    }

}

//輸出
0 1 1 1 1 
1 0 1 0 0 
1 1 0 0 0 
1 0 0 0 0 
1 0 0 0 0 
有5個節點,5條邊

三、圖的深度優先搜尋

圖的遍歷有兩種策略:深度優先搜尋(DFS)和廣度優先搜尋(BFS)。

以下的演示我們仍基於第二部分建立的圖為示例:

1.思路分析

dfs的搜尋大體思路是這樣的:

首先訪問第一個鄰接結點,然後再以這個被訪問的鄰接結點作為初始結點,訪問它的第一個鄰接結點,然後重複以上步驟直到完成遍歷。

這個思路如果學過樹的遍歷會感覺非常熟悉。由前面知道,樹就是一種特殊的圖,所以樹的前、中、後序遍歷其實就是樹的dfs

2.程式碼實現

將思路轉換為程式碼實現的步驟:

  • 訪問第一個節點v,並且將其標記為已訪問
  • 查詢第一個節點的鄰接節點w:
    1. 如果w節點不存在,則繼續查詢v的下一個鄰接節點
    2. 如果w存在,並且未訪問,則將w當成下一個v,進行遞迴

第一步,我們需要在Graph類中新增isVisted公共變數用於標記節點是否被訪問:

//記錄節點是否被訪問
private boolean[] isVisted;

第二步,我們需要查詢節點是否存在相連節點方法

/**
 * 查詢鄰接節點
 * @param index
 * @return
 */
private int getNeighbor(int index) {
    for (int i = 0; i < graph.length; i++) {
        //如果當前節點存在鄰接節點就返回下標
        if (graph[index][i] > 0) {
            return i;
        }
    }
    return -1;
}

/**
 * 查詢下一個鄰接節點的下標
 * @param index1
 * @param index2
 * @return
 */
private int getNextNeighbor(int index1, int index2) {
    for (int i = index2 + 1; i < graph.length; i++) {
        //如果當前節點存在鄰接節點就返回下標
        if (graph[index1][index2] > 0) {
            return i;
        }
    }
    return -1;
}

第三步,藉助訪問標記和查詢鄰接節點方法實現dfs

/**
 * 深度優先搜尋
 * @param index
 */
private void dsf(int index) {
    //訪問節點
    System.out.print(index + "->");
    //標記已訪問節點
    isVisted[index] = true;
    //獲取第一個鄰接節點
    int w = getNeighbor(index);
    //如果鄰接節點存在
    while (w != -1){
        //並且該鄰接節點未訪問
        if (!isVisted[w]) {
            dsf(w);
        }
        //如果該節點已被訪問,就訪問當前節點的鄰接節點的下一個鄰接節點
        w = getNextNeighbor(index, w);
    }
}

public void dfs() {
    //對所有節點進行dfs
    for (int i = 0; i < num; i++) {
        //如果該節點仍未被訪問才進行dfs
        if (!isVisted[i]) {
            dsf(i);
        }
    }
}

//執行結果
0->1->2->3->4->

四、圖的廣度優先搜尋

1.思路分析

bfs的大題思路是這樣的:

首先建立一個佇列,把第一個鄰接節點入隊,然後佇列元素出隊,把該元素的鄰接節點入隊,然後出隊.....重複該步驟,一層一層的遍歷同級節點

如果我們按這個思路,將4作為起始節點,那麼第一個4入隊,然後4出隊,把4的鄰接節點0入隊,接著0出隊,把0的鄰接節點1,2,3,入隊;同理如果將0作為起始節點,那麼第一次0入隊,然後0出隊,把0的鄰接節點1,2,3入隊......

2.程式碼實現

將思路轉換為程式碼實現的步驟:

  • 訪問初始節點v,標記併入隊
  • 當佇列不為空時,將隊頭節點u出隊,否則跳過本次迴圈
  • 查詢u的第一個鄰接節點w,如果不存在就重複步驟2,否則:
    1. 若w未被訪問,則標記併入隊
    2. 查詢u繼w後的下一個鄰接節點,重複步驟3

這裡繼續複用上文dfs中使用的 getNeighbor()getNextNeighbor()isVisted[]

/**
 * 廣度優先遍歷
 * @param index
 */
private void bfs(int index){
    //建立佇列
    LinkedList queue = new LinkedList<>();

    //訪問節點
    System.out.print(index + "->");
    //標記已訪問節點
    isVisted[index] = true;
    //節點入隊
    queue.addLast(index);

    //迴圈直到遍歷完所有佇列中的節點
    int u, w = -1;
    while (queue.isEmpty()) {
        //取出佇列頭結點下標
        u = (int) queue.removeFirst();
        //獲取出隊節點的鄰接節點
        w = getNeighbor(u);
        while (w != -1) {
            //如果為被訪問過
            if (!isVisted[w]) {
                //訪問節點並標記
                System.out.print(u + "->");
                isVisted[w] = true;
                //將節點入隊
                queue.addLast(w);
            }

            //接著查詢下一個鄰接節點
            w = getNextNeighbor(u, w);
        }
    }
}

public void bfs() {
    this.isVisted = new boolean[num];
    //對所有節點進行bfs
    for (int i = 0; i < num; i++) {
        //如果該節點仍未被訪問才驚喜dfs
        if (!isVisted[i]) {
            bfs(i);
        }
    }
}

//執行結果
0->1->2->3->4->

值得一提是,雖然上文的例子不太直觀,但是bfs也常常用於樹的層次遍歷,比如

//測試資料
int num = 9;
int[] u = {0, 0, 1, 2, 3, 3, 4, 4};
int[] v = {1, 2, 3, 4, 5, 6, 7, 8};
//輸出結果
0->1->2->3->4->5->6->7->8->

可以很明顯的看出,是一層一層遍歷的,這也很直觀的反應了bfs的執行邏輯。