ACM圖論全解(更新中)

圖論

1.什麼是圖(Graph)?

圖(Graph)是離散數學(Discrete mathematics)的一個分支,也是算法中的一個重要內容。c++

序言


基本上,圖是代表主體(物品)(objects)之間的關係(relationships)的。咱們用一個比較淺顯的例子來代表什麼是圖:git

img

這是一張人和人之間的關係圖,咱們說每一個表明一我的(也就是Object),每條表明他們是朋友關係(也就是relationships)。算法

這個圖片很像【圖】了,從通常性推斷,咱們能夠這樣說:數組

圖 = 點 + 線ide

這句話對,可是不徹底對oop

讓咱們看看下面這個圖學習

image-20210720131941418

你會發現圖中的線多了箭頭,咱們能夠認爲是誰關注了誰。它並不像剛纔那樣是雙向的,而是單向的。spa

這兩種圖將會成咱們在咱們學習算法中遇到的接近90%的圖設計

圖的定義


接下來咱們給圖一個標準的定義:3d

圖 (Graph) 是一個二元組 \(G=(V(G), E(G))\)。其中 \(V(G)\) 是非空集,稱爲 點集 (Vertex set),對於 \(V\) 中的每一個元素,咱們稱其爲 頂點 (Vertex)節點 (Node),簡稱 ;\(E(G)\) 爲 \(V(G)\) 各結點之間邊的集合,稱爲 邊集 (Edge set)

咱們來逐句理解一下這段話:

  1. 圖 (Graph) 是一個二元組 \(G=(V(G), E(G))\)
    • 圖是組成的集合
  2. \(V(G)\) 是非空集
  • 在圖中,邊能夠沒有,可是點不能沒有(至少有一個)。也就是說最小的圖是一個點
  1. \(V(G)\) (點集)
    • 點的集合
  2. \(E(G)\) 爲 \(V(G)\) 各結點之間邊的集合
    • 邊是兩個點才能造成的

咱們說,經常使用 \(G=(V,E)\) 表示圖。

當 \(V,E\) 都是有限集合時,稱 \(G\) 爲 有限圖。當 \(V\) 或 \(E\) 是無限集合時,稱 \(G\) 爲 無限圖

在作題中遇到的,所有都是有限圖,由於計算機只解決有限集合的問題

圖的邊權


若 \(G\) 的每條邊 \(e_k=(u_k,v_k)\) 都被賦予一個數做爲該邊的 ,則稱 \(G\) 爲 賦權圖。若是這些權都是正實數,就稱 \(G\) 爲 正權圖

img

權值是很是重要的一個概念,他們老是在題目中成爲最關鍵的條件。權可能以這樣的形式出現:

  1. 從A到B要t小時時間
  2. 從A到B須要花費x元錢
  3. A到B之間你會付出q點法力值

2.無向圖和有向圖(Undirected graph & Directed graph)

無向圖和有向圖是咱們學習圖論中的最重要的初始概念,搞清無向圖和有向圖,對咱們的作題有很是大的好處。

無向圖的概念比有向圖簡單,因此咱們先學習一下無向圖。

無向圖的定義


若 \(G\) 爲無向圖,則 \(E\) 中的每一個元素爲一個無序二元組 \((u, v)\),稱做 無向邊 (Undirected edge),簡稱 邊 (Edge),其中 \(u, v \in V\)。設 \(e = (u, v)\),則 \(u\) 和 \(v\) 稱爲 \(e\) 的 端點 (Endpoint)

解釋以下:

  1. 則 \(E\) 中的每一個元素爲一個無序二元組 \((u, v)\),稱做 無向邊 (Undirected edge),簡稱 邊 (Edge)
    • E是邊的集合。這句話指任意一條邊能夠用兩個頂點(u,v)相連表示。
    • 由於(u, v)和(v, u)表示的含義是徹底同樣的,因此是無序的
  2. 其中 \(u, v \in V\)。設 \(e = (u, v)\),則 \(u\) 和 \(v\) 稱爲 \(e\) 的 端點 (Endpoint)
    • 頂點(Vertex)在無向圖中有了新名字,u和v連成了邊e,那麼u和v被稱爲e的端點

有向圖的定義


若 \(G\) 爲有向圖,則 \(E\) 中的每個元素爲一個有序二元組 \((u, v)\),有時也寫做 \(u \to v\),稱做 有向邊 (Directed edge)弧 (Arc),在不引發混淆的狀況下也能夠稱做 邊 (Edge)。設 \(e = u \to v\),則此時 \(u\) 稱爲 \(e\) 的 起點 (Tail),\(v\) 稱爲 \(e\) 的 終點 (Head),起點和終點也稱爲 \(e\) 的 端點 (Endpoint)。並稱 \(u\) 是 \(v\) 的直接前驅,\(v\) 是 \(u\) 的直接後繼。

解釋以下:

  1. 則 \(E\) 中的每個元素爲一個有序二元組 \((u, v)\),有時也寫做 \(u \to v\),稱做 有向邊 (Directed edge)弧 (Arc)
    • 因爲有向圖是有方向的,(u, v)和(v, u)表示的含義是不同的,因此說是有序二元組,先後順序影響結果。
    • 邊在有向圖中被稱爲弧(Arc)
  2. 設 \(e = u \to v\),則此時 \(u\) 稱爲 \(e\) 的 起點 (Tail),\(v\) 稱爲 \(e\) 的 終點 (Head)。起點和終點也稱爲 \(e\) 的 端點 (Endpoint)
    • 因爲向量的概念,尾巴是起點,頭是終點。可是咱們通常不會這麼麻煩的去稱呼它。
  3. 並稱 \(u\) 是 \(v\) 的直接前驅,\(v\) 是 \(u\) 的直接後繼。
    • 尾巴是頭的前驅,頭是尾巴的後繼

3.存儲圖的方式(一)——鄰接矩陣

使用一個二維數組 e 來存邊,其中 e[u][v] 爲 1 表示存在 \(u\) 到 \(v\) 的邊,爲 0 表示不存在。

若是是帶邊權的圖,能夠在 e[u][v] 中存儲 \(u\) 到 \(v\) 的邊的邊權。

鄰接矩陣是最基礎的存圖方式,數組的下標表示了兩個,數組的表明了

無向圖用鄰接矩陣存圖


無向圖存圖的特色是:對於每一個(u,v)對來講,在相反的位置(v,u)獲得的值是同樣的。

image-20210720163601097

經過標綠的值能夠看出來,無向圖的存儲是根據斜對角線對稱的。

而且還能夠發現,對角線(0,0),(1,1),(2,2)等的值所有都是0。這體現了一個概念:本身到本身走不通。

image-20210720172111452

image-20210720172223502

有向圖用鄰接矩陣存圖


有向圖存圖的特色是:對於每一個(u,v)對來講,矩陣中只有一個格子會對應。

image-20210720174759199

image-20210720174809756

image-20210720174818647

圖論DFS/BFS:查找文獻


小K 喜歡翻看博客獲取知識。每篇文章可能會有若干個(也有可能沒有)參考文獻的連接指向別的博客文章。小K 求知慾旺盛,若是他看了某篇文章,那麼他必定會去看這篇文章的參考文獻(若是他以前已經看過這篇參考文獻的話就不用再看它了)。

假設博客裏面一共有 n篇文章(編號爲 1 到 n)以及 m條參考文獻引用關係。目前小 K 已經打開了編號爲 1 的一篇文章,請幫助小 K 設計一種方法,使小 K 能夠不重複、不遺漏的看完全部他能看到的文章。

這邊是已經整理好的參考文獻關係圖,其中,文獻 X → Y 表示文章 X 有參考文獻 Y。不保證編號爲 1 的文章沒有被其餘文章引用。

4img

請對這個圖分別進行 DFS 和 BFS,並輸出遍歷結果。若是有不少篇文章能夠參閱,請先看編號較小的那篇(所以你可能須要先排序)。

樣例輸入:
8 9
1 2
1 3
1 4
2 5
2 6
3 7
4 7
4 8
7 8
樣例輸出:
1 2 5 6 3 7 8 4 
1 2 3 4 5 6 7 8

image-20210723085233524

DFS實現說明


DFS是深度優先搜索的英文縮寫(Depth First Search)。深度優先搜索基本上會採用遞歸的方式進行。

DFS「法」如其名,咱們老是先往深處進行搜索,在深度沒法更加深時,會進行回溯。回溯,顧名思義,回到原來的位置。

當一個點經過後,咱們通常都不會重複進入,因此咱們會對其進行「標記」,使其沒法再次進入。

【思路說明】:

  1. 每個點只須要走一次,因此和普通DFS搜索同樣,走過一個節點就須要將其給標記,使其下次不能再走。
    0表示未標記,1表示已經標記了。

    image-20210722093927668

  2. 一個點可能有n條邊,可是這個DFS須要先選擇點較小的那個邊。因此咱們在dfs多條路徑時須要選擇通往點最小的那條邊。

    image-20210722092744644

  3. 須要輸出從小到大的每一個節點,咱們在深搜的時候能夠一邊搜一邊輸出。

【代碼模擬實現】


  1. 選定起始點s,由題意可得起始點是st = 1。
    初始化vis數組,讓每個點都沒有被標記。

    memset(vis, 0, sizeof(vis));

    image-20210723090125330

  2. 從1開始dfs,在每一層dfs中,咱們將當前的點設爲x

    1. 咱們標記1,並進行輸出。循環查找從1點到n點每一個點,也就是e[x][i]

      void dfs(int x) { 
      	vis[x] = 1;
      	cout << x << " ";
          for(int i = 1; i <= n; i++) {
      		if(e[x][i] == 1 && !vis[i]) {
      			dfs(i);
      		}
          }
      }

      image-20210723090227274

    2. 因爲咱們是從小到大對數組進行遍歷的,因此確定是最小的。咱們找到第一個能夠和x連的點,這裏是2

      image-20210723090245708

    3. 接着咱們從2節點開始往下深搜,仍是同樣的操做,標記2並輸出,循環查找從1點到n點每一個點

      image-20210723090255712

    4. 找到第一個和2相連的點5。

      image-20210723090311092

    5. 接着咱們從5節點開始往下深搜,仍是同樣的操做,標記5並輸出,循環查找從1點到n點每一個點

      image-20210723090322843

    6. 咱們發現5下面並無能夠鏈接的節點,因此咱們返回遞歸的上一層,也就是2節點這一層,在2節點中從新尋找

      image-20210723090450981

    7. 接着咱們從6節點開始往下深搜,標記6並輸出,可是6下面並無節點。

      image-20210723090500811

    8. 因此咱們繼續回溯到根的位置

      image-20210723090733878

    9. 咱們選擇3這個點進行搜索

      image-20210723090745835

    10. 接着咱們從3節點開始往下深搜,標記3並輸出

      image-20210723090757886

    11. 接下來是7,標記7並輸出

      image-20210723090808855

    12. 接着回溯後標記4並輸出

      image-20210723090818983

    13. 值得注意的是,4 -> 7這條路徑因爲7這個點被標記了,因此是走不通的

      image-20210723090833654

    14. 最後到8之後輸出,最後7到8也是沒法實現的。程序結束

      image-20210723090845395

DFS代碼實現:

#include <bits/stdc++.h>

using namespace std;

int e[1005][1005];
int vis[1005];
int n, m, a, b, c;
void dfs(int x) {
    vis[x] = 1;
    cout << x << " ";
    for(int i = 1; i <= n; i++) {
        if(e[x][i] == 1 && !vis[i]) {
            dfs(i);
        }
    }

}
int main() {
    cin >> n >> m;
    for(int i = 0; i < m; i++) {
        cin >> a >> b;
        e[a][b] = 1;
    }
    dfs(1);
}

BFS實現說明


【代碼模擬實現】

BFS是廣度優先搜索的英語縮寫(Breadth First Search),要實現BFS,須要使用隊列(queue)。隊列有先進先出(FIFO First In First Out)的特色。

【思路說明】:

  1. 每個點只須要走一次,因此和普通BFS搜索同樣,走過一個節點就須要將其給標記,使其下次不能再走。
    0表示未標記,1表示已經標記了。

    image-20210722093927668

  2. 一個點可能有n條邊,可是這個DFS須要先選擇點較小的那個邊。因此咱們在搜索的時候,要將連着的邊按從小到大的順序放入。

    image-20210722162912424

  3. 隊列裏面須要順序存放後面應該有的數

    image-20210722161239137

  4. 須要輸出從小到大的每一個節點,在廣搜時一邊搜索一邊輸出

【代碼模擬實現】


  1. 對於整個代碼,咱們須要重置vis數組,而且將隊列生成。

    memset(vis, 0, sizeof(vis));
    queue<int> Q;
  2. 和DFS不一樣,咱們開始就須要對1號節點進行vis的記錄,而且壓入隊列(push)

    Q.push(1);
    vis[1] = 1;

    image-20210723090903629

  3. 循環至隊列中沒有元素爲止:注意這裏的操做,拿到隊列頭的數據後,當即彈出。
    (由於數據拿到之後這個頭就沒有任何意義了,先彈出有助於代碼的連貫性,很是推薦這種寫法)

    while(!Q.empty()) {
        int u = Q.front(); Q.pop();
    }
  4. 彈出隊頭後輸出
    循環塞入在矩陣中相連的全部沒有被標記過的點(固然須要從小到大循環)
    假如這個點沒有標記,那麼標記它

    while(!Q.empty()) {
        int u = Q.front(); Q.pop();
        cout << u << " ";
        for(int i = 1; i <= n; i++) {
            if(e[u][i] == 1 && !vis[i]) {
                Q.push(i);
                vis[i] = 1;
            }
        }
    }

    image-20210723090914640

  5. 接着進入下一輪循環,咱們將2獲得後彈出隊列,標記與2相連的矩陣元素

    image-20210723091352533

  6. 接着咱們將3彈出隊列,標記與3相連的矩陣元素

    image-20210723091404421

  7. 接着咱們將4彈出隊列,標記與4相連的矩陣元素:
    可是注意,這裏7被標記過了,所以不能再加

    image-20210723091418661

  8. 最後順序輸出隊列中的剩餘元素

    image-20210723092032065

    image-20210723092045030

    image-20210723092223680

    image-20210723092306011

BFS代碼:

memset(vis, 0, sizeof(vis));
queue<int> Q;
Q.push(1);
vis[1] = 1;
while(!Q.empty()) {
    int u = Q.front(); Q.pop();
    cout << u << " ";
    for(int i = 1; i <= n; i++) {
        if(e[u][i] == 1 && !vis[i]) {
            Q.push(i);
            vis[i] = 1;
        }
    }
}

重要拓展:BFS的標記方式

咱們剛纔使用的BFS標記方式是常規的標記方式。思路實現和DFS基本一致。

memset(vis, 0, sizeof(vis));
queue<int> Q;
Q.push(1);
while(!Q.empty()) {
    int u = Q.front(); Q.pop();
    if(vis[u]) continue; //注意這兩行
    vis[u] = 1; //注意這兩行
    cout << u << " ";
    for(int i = 1; i <= n; i++) {
        if(e[u][i] == 1 && !vis[i]) {
            Q.push(i);
        }
    }
}

image-20210723093857420

經過代碼比較能夠直觀的看出,這兩份代碼的不一樣之處就是標記的位置不一樣。若是一開始對起始點進行標記,再繼續經過內循環標記,這樣的方法會致使代碼的紊亂。可是咱們一開始不得不用這種方法來標記BFS。
這裏牽扯到一個比較細緻的問題:

爲何咱們從DFS的for循環外標記,變成了for循環內標記呢?

由於DFS每次向下一層,只會拿到一個點,可是BFS卻須要在一個循環中搜到多個點

可是這裏有一個新的方法,在隊列彈出時標記,也就是咱們新的寫法。這個寫法是通用寫法,能夠減小你的思考量級

這個寫法的惟一壞處就是:隊列內的點可能會重複出現

image-20210723113538662

就像這張圖同樣,1,2,3,4都會使5加入隊列中,可是因爲5號點並無標記,因此會持續加入隊列中。

雖然看上去慢了,可是實際問題中不可能有如此多的邊,因此咱們認爲這種寫法是常量偏大,可是不會影響整體速度

所有代碼:

#include <bits/stdc++.h>

using namespace std;

int e[1005][1005];
int vis[1005];
int n, m, a, b, c;
void dfs(int x) {
    cout << x << " ";
    for(int i = 1; i <= n; i++) {
        if(e[x][i] == 1 && !vis[i]) {
            vis[i] = 1;
            dfs(i);
        }
    }
}
int main() {
    cin >> n >> m;
    for(int i = 0; i < m; i++) {
        cin >> a >> b;
        e[a][b] = 1;
    }
    dfs(1);
    cout << endl;
    memset(vis, 0, sizeof(vis));
    queue<int> Q;
    Q.push(1);
    vis[1] = 1;
    while(!Q.empty()) {
        int u = Q.front(); Q.pop();
        cout << u << " ";
        for(int i = 1; i <= n; i++) {
            if(e[u][i] == 1 && !vis[i]) {
                Q.push(i);
                vis[i] = 1;
            }
        }
    }
}

4.單源最短路徑Dijkstra的鄰接矩陣實現

鬆弛

"鬆弛"是一個很是理論性的概念,但總得說來就是三個字:

抄近道

譬如你要從杭州上城區去往紹興北站,你能夠選擇直接坐大巴直達,須要2個小時的路程。可是若是你選擇地鐵轉高鐵,那可能只須要40分鐘。

在圖論中,若是不指出點權的話,那麼默認換乘這種操做是不須要時間的,也就是說咱們若是能夠經過一箇中轉站到達指定地點,可是比原來快的話,咱們就會選擇換乘的這條路線。形式以下:

image-20210723125317685

在這張圖中,咱們會選擇20 + 40 分鐘的這條路

原理說明

單源最短路徑是什麼意思?

表示從一個點出發到除這個點外的距離

代碼實現

5.存儲圖的方式(二)——鄰接表

什麼是鄰接表?

image-20210723133151360

鄰接表長這樣,咱們通常分爲Head(頭)和Node(節點)

頭和節點用一句話概況就是:

頭連向節點中的每個點。

有向圖用鄰接表存圖

與鄰接矩陣相反,咱們先來看有向圖的實現方式。

image-20210723133345499

如何理解頭連向節點中的每個點。這句話呢?咱們用兩幅圖看看

image-20210723133732542

image-20210723133743186

0連向2和5兩個點。0做爲頭(Head),而節點(Node)跟在後面。默認,鄰接表是由鏈表構成的。

鄰接表的一個特性是:節點值(Node)是不能隨意讀取的,譬如0到5是否能連上,你只能遍歷全部節點。

無向圖用鄰接表存圖

image-20210723134243332

image-20210723134252962

STL庫:vector的用法

vector是可變數組

咱們都知道c++中的數組是不能變化容量的,那麼也就致使了空間上你無從知曉該開多大

可是vector能夠隨意的進行插入,容量隨即變大。對vector的尾部插入一個數,就是push_back();

讀取方式與數組徹底同樣。

size()能夠拿到當前vector的容量。

vector<int> v;
v.push_back(12);
int c = v[0];
int sz = v.size();

vector數組實現鄰接表

咱們用vector的數組形式實現鄰接表

每個Head後的Node,都是一組vector

vector<int> e[3];

image-20210723134834209

e[0].push_back(7);

image-20210723134952288

e[1].push_back(9)

image-20210723135058997

e[0].push_back(12);

image-20210723135203596

遍歷從i點出發的鄰接表

鄰接表不能任意讀取,通常鄰接表用來遍歷找出從i點出發能通往的全部點。

int i = st;
for(int j = 0; j < e[i].size(); j++) {
	int v = e[i][j];
}

使用鄰接表完成單元最短路徑Dijkstra

相關文章
相關標籤/搜索