圖論基礎(自認為很全)

Ricnard發表於2020-11-03

什麼是圖

在一個社交網路中,每個帳號和他們之間的關係構成了一張巨大的網路,就像下面這張圖:
在這裡插入圖片描述
那麼在電腦中,我們要用什麼樣的資料結構來儲存這個網路呢?這個網路需要用一個之前課程裡未提到過的資料結構,也就是接下來要講解的結構來儲存。

到底什麼是圖?圖是由一系列頂點和若干連結頂點集合內兩個頂點的邊組成的資料結構。數學意義上的圖,指的是由一系列點與邊構成的集合,這裡我們只考慮有限集。通常我們用 G=(V,E) 表示一個圖結構,其中V表示點集,E表示邊集。

在頂點集合所包含的若干個頂點之間,可能存在著某種兩兩關係——如果某兩個點之間的確存在這樣的關係的話,我們就在這兩個點之間連邊,這樣就得到了邊集的一個成員,也就是一條邊。對應到社交網路中,頂點就是網路中的使用者,邊就是使用者之間的好友關係。

如果用邊來表示好友關係的話,對於微信這種雙向關注的社交網路沒有問題,但是對於微博這種單向關注的要如何表示呢?

於是引出了兩個新的概念:有向邊和無向邊。

簡而言之,一條有向邊必然是從一個點指向另一個點,而相反方向的邊在有向圖中則不一定存在;而有的時候我們並不在意構成一條邊的兩個頂點具體誰先誰後,這樣得到的一條邊就是無向邊。就像在微信中,AB的好友,那B也一定是A的好友,而在微博中,A關注B並不意味著B也一定關注A

對於圖而言,如果圖中所有邊都是無向邊,則稱為無向圖,反之稱為有向圖。

簡而言之,無向圖中的邊是“好友”,而有向圖中的邊是“關注”。一般而言,我們在資料結構中所討論的圖都是有向圖,因為有向圖相比無向圖更具有代表性。

實際上,無向圖可以由有向圖來表示。如果AB兩個點之間存在無向邊的話,那用有向圖也可以表示為AB兩點之間同時存在ABBA兩條有向邊。

仍然以社交網路舉例:雖然微博中並不存在明確定義的好友關係,但是一般情況下,如果你和另一個 ID 互相關注的話,那麼我們也可以近似認為,你和 TA 是好友。

我們來形式化地定義一下圖:圖是由頂點集合(簡稱點集)和頂點間的邊(簡稱邊集)組成的資料結構,通常用G(V,E)來表示。其中點集用V(G) 來表示,邊集用 E(G) 來表示。在無向圖中,邊連線的兩個頂點是無序的,這些邊被稱為無向邊。例如下面這個無向圖G,其點集V(G)={1,2,3,5,6},邊集為E(G)={(1,2),(2,3),(1,5),(2,6),(5,6)}
在這裡插入圖片描述

而在有向圖中,邊連線的兩個頂點之間是有序的。箭頭的方向就表示有向邊的方向。

例如下面這張有向圖G'
在這裡插入圖片描述
其點集V(G′)={1,2,3,5,6},邊集為E(G′)={(1,2),(2,3),(2,6),(6,5),(1,5)}。對於每條邊 (u,v) ,我們稱其為從u到v的一條有向邊,u是這條有向邊的起點v 是這條有向邊的終點。注意在有向圖中,(u,v)(v,u) 是不同的兩條有向邊。

圖的分類

在這裡插入圖片描述
在這裡插入圖片描述
對於一個圖,如果以任意一個點為起點,在圖上沿著邊走都可以到達其他所有點(有向圖必須沿有向邊的方向),那麼這個圖就是連通圖。顯然完全圖一定是連通圖。

定義

在無向圖中,頂點的是指某個頂點連出的邊數。例如在下圖中,頂點 b 的度數為3,頂點 a 的度數為4。
在這裡插入圖片描述
在有向圖中,和度對應的是入度出度這兩個概念。頂點的入度是指以該頂點為終點的有向邊數量;頂點的出度是指以頂點為起點的有向邊數量。需要注意的是,在有向圖裡,頂點是沒有的概念的。例如在下圖中,頂點 a 的入度為1,出度為3;頂點 c 的入度為2,出度為2。
在這裡插入圖片描述

度的性質

在無向圖或有向圖中,頂點的度數總和為邊數的兩倍,即:
在這裡插入圖片描述
而在有向圖中,有一個很明顯的性質就是,入度等於出度
|
|
|
|
|
無向圖度數統計

#include <iostream>
using namespace std;
int deg[105];
int main() {
    int n, m;
    cin >> n >> m;
    for (int i = 0; i < m; i++) {
        int u, v;
        cin >> u >> v;
        deg[u]++;
		deg[v]++;
    }
    for (int i = 1; i <= n; i++) {
        cout << deg[i] << " ";
    }
    return 0;
}

有向圖度數統計

#include <iostream>
using namespace std;
int outdeg[105], indeg[105];
int main() {
    int n, m;
    cin >> n >> m;
    for (int i = 0; i < m; i++) {
        int u, v;
        cin >> u >> v;
        outdeg[u]++;
        indeg[v]++;
    }
    for (int i = 1; i <= n; i++) {
        cout << outdeg[i] << " " << indeg[i] << endl;
    }
    return 0;
}

圖的儲存(無權值)

圖該怎麼存呢?

鄰接矩陣

基礎知識

什麼是鄰接矩陣呢?所謂鄰接矩陣儲存結構就每個頂點用一個一維陣列儲存邊的資訊,這樣所有點合起來就是用矩陣表示圖中各頂點之間的鄰接關係。所謂矩陣其實就是二維陣列。
對於有n個頂點的圖 G=(V,E) 來說,我們可以用一個 n×n 的矩陣 A 來表示 G 中各頂點的相鄰關係,如果 vivj之間存在邊(或弧),則 A[i][j]=1 ,否則 A[i][j]=0 。下圖為有向圖 G1 和無向圖 G2 對應的鄰接矩陣:
在這裡插入圖片描述

一個圖的鄰接矩陣是唯一的,矩陣的大小隻與頂點個數N有關,是一個 N×N 的矩陣。前面我們已經介紹過,在無向圖裡,如果頂點 vivj 之間有邊,則可認為頂點 vivj 有邊,頂點 vjvi 也有邊。對應到鄰接矩陣裡,則有 A[i][j]=A[j][i]=1 。因此我們可以發現,無向圖的鄰接矩陣是一個對稱矩陣。

在鄰接矩陣上,我們可以直觀地看出兩個頂點之間是否有邊(或弧),並且很容易求出每個頂點的度,入度和出度。

這裡我們以 G1 為例,演示下如何利用鄰接矩陣計算頂點的入度和出度。頂點的出度,即為鄰接矩陣上點對應行上所有值的總和,比如頂點1出度即為0+1+1+1=3;而每個點的入度即為點對應列上所有值的總和,比如頂點3對應的入度即為1+0+0+1=2。
在這裡插入圖片描述
接下來我們就先一起學習構造和使用鄰接矩陣的方法。鄰接矩陣是一個由1和0構成的矩陣。處於第 i 行、第 j 列上的元素1和0分別代表頂點i到j之間存在或不存在一條又向邊。

顯然在構造鄰接矩陣的時候,我們需要實現一個整型的二維陣列。由於當前的圖還是空的,因此我們還要把這個二維陣列中的每個元素都初始化為0。

在構造好了一個圖的結構後,我們需要把圖中各邊的情況對應在鄰接矩陣上。實際上,這一步的實現非常簡單,當從頂點x到y存在邊時,我們只要把二維陣列對應的位置置為1就好了。
在這裡插入圖片描述
用鄰接矩陣來構建圖需要如下幾步,我們可以用二維陣列G來表示一個圖。

初始化

初始化的過程很簡單,只需要把陣列初始化為0即可。可以藉助memset來快速地將一個陣列中的所有元素都初始化為0。(其實定義成全域性變數就行了……)

memset(G, 0, sizeof(G));

注意,memset只能用來初始化0和 -1,並且需要加上標頭檔案cstring

上面的程式碼等價於:

for (int i = 0; i < N1; i++) { // N1 為陣列第一維大小
    for (int j = 0; j < N2; j++) { // N2 為陣列第二維大小
        G[i][j] = 0;
    }
}

當然我們平常使用鄰接矩陣的時候下標只用 1n 或者 0n−1 (這個看題目中點的編號)

插入邊

如果插入一條無向邊 (u,v) ,只需要

G[u][v] = 1;
G[v][u] = 1;

也可以寫成G[u][v] = G[v][u] = 1;
如果插入一條有向邊 (u,v) ,只需要G[u][v] = 1;

訪問邊

如果G[u][v] = 1,說明有一條從 uv 的邊,否則沒有從 uv 的邊。

鄰接矩陣的使用

#include <iostream>
using namespace std;
const int maxn = 105;
int G[maxn][maxn];
int main() {
    int n, m;
    cin >> n >> m;
    for (int i = 0; i < m; i++) {
        int u, v;
        cin >> u >> v;
		G[u][v] = G[v][u] = 1;
    }
    for (int i = 1; i <= n; i++) {
        for (int j = 1; j <= n; j++) {
            cout << G[i][j] << " ";
        }
        cout << endl;
    }
    return 0;
}

鄰接表

鄰接表的思想是,對於圖中的每一個頂點,用一個陣列來記錄這個點和哪些點相連。由於相鄰的點會動態的新增,所以對於每個點,我們需要用vector來記錄。

也就是對於每個點,我們都用一個vector來記錄這個點和哪些點相連。比如對於一張有 10 個點的圖,vector<int> G[11]就可以用來記錄這張圖了。對於一條從 ab 的有向邊,我們通過G[a].push_back(b)就可以把這條邊新增進去;如果是無向邊,則需要在G[a].push_back(b)的同時G[b].push_back(a)
在這裡插入圖片描述
上圖演示了一個圖對應的鄰接表。每一行的第一列表示的是最外層vector陣列的下標。

鄰接表的優缺點

優點

  1. 節省空間:當圖的頂點數很多、但是邊的數量很少時,如果用鄰接矩陣,我們就需要開一個很大的二維陣列,最後我們需要儲存 n*n 個數。但是用鄰接表,最後我們儲存的資料量只是邊數的兩倍。
  2. 可以記錄重複邊:如果兩個點之間有多條邊,用鄰接矩陣只能記錄一條,但是用鄰接表就能記錄多條。雖然重複的邊看起來是多餘的,但在很多時候對解題來說是必要的。

缺點

當然,有優點就有缺點,用鄰接表存圖的最大缺點就是隨機訪問效率低。比如,我們需要詢問點 a 是否和點 b 連,我們就要遍歷G[a],檢查這個vector裡是否有 b 。而在鄰接矩陣中,只需要根據G[a][b]就能判斷。

因此,我們需要對不同的應用情景選擇不同的存圖方法。如果是稀疏圖(頂點很多、邊很少),一般用鄰接表;如果是稠密圖(頂點很少、邊很多),一般用鄰接矩陣。


當點數較多(多於 5000)時,使用鄰接矩陣會超出空間限制,需要使用鄰接表。

鄰接表的實現

#include <iostream>
#include <vector>
using namespace std;
const int maxn = 105;
vector<int> G[maxn];
int main() {
    int n, m;
    cin >> n >> m;
    for (int i = 0; i < m; i++) {
        int u, v;
        cin >> u >> v;
        G[u].push_back(v);
        G[v].push_back(u);
    }
    for (int i = 1; i <= n; i++) {
        cout << i << " : ";
        for (int j = 0; j < G[i].size(); j++) {
            cout << G[i][j] << " ";
        }
        cout << endl;
    }
    return 0;
}

圖的儲存(帶權值)

鄰接矩陣

在前面,圖中的邊都只是用來表示兩個點之間是否存在關係,而沒有體現出兩個點之間關係的強弱。比如在社交網路中,不能單純地用0、1來表示兩個人否為朋友。當兩個人是朋友時,有可能是很好的朋友,也有可能是一般的朋友,還有可能是不熟悉的朋友。

我們用一個數值來表示兩個人之間的朋友關係強弱,兩個人的朋友關係越強,對應的值就越大。而這個值就是兩個人在圖中對應的邊的權值,簡稱邊權。對應的圖我們稱之為帶權圖

如下就是一個帶權圖,我們把每條邊對應的邊權標記在邊上:在這裡插入圖片描述
帶權圖也分成帶權有向圖和帶權無向圖。前面學到的關於圖的性質在帶權圖上同樣成立。實際上,我們前面學習的圖是一種特殊帶權圖,只不過圖中所有邊的權值只有1一種;而在帶權圖中,邊的權值可以是任意的。

用鄰接矩陣儲存帶權圖和之前的方法一樣,用G[a][b]來表示 ab 之間的邊權(我們需要用一個數值來表示邊不存在,如0)。同樣,對於無向圖,這個矩陣依然是對稱的。
在這裡插入圖片描述
如上所示,左邊的圖對應的右邊的鄰接矩陣

帶權圖的鄰接矩陣

#include <iostream>
#include <cstring>
using namespace std;
const int maxn = 105;
int G[maxn][maxn];
int sum[maxn];
int main() {
    int n, m;
    cin >> n >> m;
    for (int i = 0; i < m; i++) {
        int u, v, w;
        cin >> u >> v >> w;
		G[u][v] = w;
    }
    for (int i = 1; i <= n; i++) {
        for (int j = 1; j <= n; j++) {
            cout << G[i][j] << " ";
        }
        cout << endl;
    }
    for (int i = 1; i <= n; i++) {
        for (int j = 1; j <= n; j++) {
            sum[i] += G[i][j];
        }
    }
    for (int i = 1; i <= n; i++) {
        cout << sum[i] << " ";
    }
    return 0;
}

鄰接表

用鄰接表儲存帶權圖和之前的實現方式略有區別,我們需要用一個結構體來記錄一條邊連線的點和這條邊的邊權,然後用一個vector來儲存若干個結構體,實際上就是把一個點所連線的點以及這條邊的邊權"打包"起來放到鄰接表中。

結構體的定義舉例如下:

struct node {
    int v;  // 用來記錄連線的點
    int w;  // 用來記錄這條邊的邊權
};

我們通常把有向圖中加入一條邊寫成一個函式,例如加入一條有向邊 (u,v) 、邊權為 w ,就可以用如下的函式來實現(我們需要把圖定義成全域性變數)。

vector<node> G[105];
// 插入有向邊
void insert(int u, int v, int w) {
    node temp;
    temp.v = v;
    temp.w = w;
    G[u].push_back(temp);
}

而插入一條無向邊,實際上相當於插入兩條方向相反的有向邊:

// 插入無向邊
void insert2(int u, int v, int w) {
    insert(u, v, w);
    insert(v, u, w);
}

帶權圖鄰接表的實現

#include <iostream>
#include <vector>
using namespace std;
const int maxn = 105;
struct node {
    int v;
    int w;
};
vector<node> G[maxn];
void insert(int u, int v, int w) {
    node temp;
    temp.v = v;
    temp.w = w;
    G[u].push_back(temp);
}
int main() {
    int n, m;
    cin >> n >> m;
    for (int i = 0; i < m; i++) {
        int u, v, w;
        cin >> u >> v >> w;
        insert(u, v, w);
    }
    for (int i = 1; i <= n; i++) {
        for (int j = 0; j < G[i].size(); j++) {
			cout << i << " " << G[i][j].v << " " << G[i][j].w << endl;
        }
    }
    return 0;
}

終於寫完了……累死我了……

歡迎大家指出錯誤或給出建議
歡迎大家指出錯誤或給出建議
歡迎大家指出錯誤或給出建議
重要的事說三遍……
最後不要臉的求贊+關注+收藏

相關文章