圖(Graph)是離散數學(Discrete mathematics)的一個分支,也是算法中的一個重要內容。c++
基本上,圖是代表主體(物品)(objects)之間的關係(relationships)的。咱們用一個比較淺顯的例子來代表什麼是圖:git
這是一張人和人之間的關係圖,咱們說每一個點表明一我的(也就是Object),每條線表明他們是朋友關係(也就是relationships)。算法
這個圖片很像【圖】了,從通常性推斷,咱們能夠這樣說:數組
圖 = 點 + 線ide
這句話對,可是不徹底對oop
讓咱們看看下面這個圖學習
你會發現圖中的線多了箭頭,咱們能夠認爲是誰關注了誰。它並不像剛纔那樣是雙向的,而是單向的。spa
這兩種圖將會成咱們在咱們學習算法中遇到的接近90%的圖設計
接下來咱們給圖一個標準的定義:3d
圖 (Graph) 是一個二元組 \(G=(V(G), E(G))\)。其中 \(V(G)\) 是非空集,稱爲 點集 (Vertex set),對於 \(V\) 中的每一個元素,咱們稱其爲 頂點 (Vertex) 或 節點 (Node),簡稱 點;\(E(G)\) 爲 \(V(G)\) 各結點之間邊的集合,稱爲 邊集 (Edge set)。
咱們來逐句理解一下這段話:
咱們說,經常使用 \(G=(V,E)\) 表示圖。
當 \(V,E\) 都是有限集合時,稱 \(G\) 爲 有限圖。當 \(V\) 或 \(E\) 是無限集合時,稱 \(G\) 爲 無限圖。
在作題中遇到的,所有都是有限圖,由於計算機只解決有限集合的問題
若 \(G\) 的每條邊 \(e_k=(u_k,v_k)\) 都被賦予一個數做爲該邊的 權,則稱 \(G\) 爲 賦權圖。若是這些權都是正實數,就稱 \(G\) 爲 正權圖。
權值是很是重要的一個概念,他們老是在題目中成爲最關鍵的條件。權可能以這樣的形式出現:
無向圖和有向圖是咱們學習圖論中的最重要的初始概念,搞清無向圖和有向圖,對咱們的作題有很是大的好處。
無向圖的概念比有向圖簡單,因此咱們先學習一下無向圖。
若 \(G\) 爲無向圖,則 \(E\) 中的每一個元素爲一個無序二元組 \((u, v)\),稱做 無向邊 (Undirected edge),簡稱 邊 (Edge),其中 \(u, v \in V\)。設 \(e = (u, v)\),則 \(u\) 和 \(v\) 稱爲 \(e\) 的 端點 (Endpoint)。
解釋以下:
若 \(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\) 的直接後繼。
解釋以下:
使用一個二維數組 e
來存邊,其中 e[u][v]
爲 1 表示存在 \(u\) 到 \(v\) 的邊,爲 0 表示不存在。
若是是帶邊權的圖,能夠在 e[u][v]
中存儲 \(u\) 到 \(v\) 的邊的邊權。
鄰接矩陣是最基礎的存圖方式,數組的下標表示了兩個點,數組的值表明了邊。
無向圖存圖的特色是:對於每一個(u,v)對來講,在相反的位置(v,u)獲得的值是同樣的。
經過標綠的值能夠看出來,無向圖的存儲是根據斜對角線對稱的。
而且還能夠發現,對角線(0,0),(1,1),(2,2)等的值所有都是0。這體現了一個概念:本身到本身走不通。
有向圖存圖的特色是:對於每一個(u,v)對來講,矩陣中只有一個格子會對應。
小K 喜歡翻看博客獲取知識。每篇文章可能會有若干個(也有可能沒有)參考文獻的連接指向別的博客文章。小K 求知慾旺盛,若是他看了某篇文章,那麼他必定會去看這篇文章的參考文獻(若是他以前已經看過這篇參考文獻的話就不用再看它了)。
假設博客裏面一共有 n篇文章(編號爲 1 到 n)以及 m條參考文獻引用關係。目前小 K 已經打開了編號爲 1 的一篇文章,請幫助小 K 設計一種方法,使小 K 能夠不重複、不遺漏的看完全部他能看到的文章。
這邊是已經整理好的參考文獻關係圖,其中,文獻 X → Y 表示文章 X 有參考文獻 Y。不保證編號爲 1 的文章沒有被其餘文章引用。
4
請對這個圖分別進行 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
DFS是深度優先搜索的英文縮寫(Depth First Search)。深度優先搜索基本上會採用遞歸的方式進行。
DFS「法」如其名,咱們老是先往深處進行搜索,在深度沒法更加深時,會進行回溯。回溯,顧名思義,回到原來的位置。
當一個點經過後,咱們通常都不會重複進入,因此咱們會對其進行「標記」,使其沒法再次進入。
【思路說明】:
每個點只須要走一次,因此和普通DFS搜索同樣,走過一個節點就須要將其給標記,使其下次不能再走。
0表示未標記,1表示已經標記了。
一個點可能有n條邊,可是這個DFS須要先選擇點較小的那個邊。因此咱們在dfs多條路徑時須要選擇通往點最小的那條邊。
須要輸出從小到大的每一個節點,咱們在深搜的時候能夠一邊搜一邊輸出。
【代碼模擬實現】
選定起始點s,由題意可得起始點是st = 1。
初始化vis數組,讓每個點都沒有被標記。
memset(vis, 0, sizeof(vis));
從1開始dfs,在每一層dfs中,咱們將當前的點設爲x
咱們標記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); } } }
因爲咱們是從小到大對數組進行遍歷的,因此確定是最小的。咱們找到第一個能夠和x連的點,這裏是2
接着咱們從2節點開始往下深搜,仍是同樣的操做,標記2並輸出,循環查找從1點到n點每一個點
找到第一個和2相連的點5。
接着咱們從5節點開始往下深搜,仍是同樣的操做,標記5並輸出,循環查找從1點到n點每一個點
咱們發現5下面並無能夠鏈接的節點,因此咱們返回遞歸的上一層,也就是2節點這一層,在2節點中從新尋找
接着咱們從6節點開始往下深搜,標記6並輸出,可是6下面並無節點。
因此咱們繼續回溯到根的位置
咱們選擇3這個點進行搜索
接着咱們從3節點開始往下深搜,標記3並輸出
接下來是7,標記7並輸出
接着回溯後標記4並輸出
值得注意的是,4 -> 7這條路徑因爲7這個點被標記了,因此是走不通的
最後到8之後輸出,最後7到8也是沒法實現的。程序結束
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是廣度優先搜索的英語縮寫(Breadth First Search),要實現BFS,須要使用隊列(queue)。隊列有先進先出(FIFO First In First Out)的特色。
【思路說明】:
每個點只須要走一次,因此和普通BFS搜索同樣,走過一個節點就須要將其給標記,使其下次不能再走。
0表示未標記,1表示已經標記了。
一個點可能有n條邊,可是這個DFS須要先選擇點較小的那個邊。因此咱們在搜索的時候,要將連着的邊按從小到大的順序放入。
隊列裏面須要順序存放後面應該有的數
須要輸出從小到大的每一個節點,在廣搜時一邊搜索一邊輸出
【代碼模擬實現】
對於整個代碼,咱們須要重置vis數組,而且將隊列生成。
memset(vis, 0, sizeof(vis)); queue<int> Q;
和DFS不一樣,咱們開始就須要對1號節點進行vis的記錄,而且壓入隊列(push)
Q.push(1); vis[1] = 1;
循環至隊列中沒有元素爲止:注意這裏的操做,拿到隊列頭的數據後,當即彈出。
(由於數據拿到之後這個頭就沒有任何意義了,先彈出有助於代碼的連貫性,很是推薦這種寫法)
while(!Q.empty()) { int u = Q.front(); Q.pop(); }
彈出隊頭後輸出
循環塞入在矩陣中相連的全部沒有被標記過的點(固然須要從小到大循環)
假如這個點沒有標記,那麼標記它
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; } } }
接着進入下一輪循環,咱們將2獲得後彈出隊列,標記與2相連的矩陣元素
接着咱們將3彈出隊列,標記與3相連的矩陣元素
接着咱們將4彈出隊列,標記與4相連的矩陣元素:
可是注意,這裏7被標記過了,所以不能再加
最後順序輸出隊列中的剩餘元素
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標記方式是常規的標記方式。思路實現和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); } } }
經過代碼比較能夠直觀的看出,這兩份代碼的不一樣之處就是標記的位置不一樣。若是一開始對起始點進行標記,再繼續經過內循環標記,這樣的方法會致使代碼的紊亂。可是咱們一開始不得不用這種方法來標記BFS。
這裏牽扯到一個比較細緻的問題:
爲何咱們從DFS的for循環外標記,變成了for循環內標記呢?
由於DFS每次向下一層,只會拿到一個點,可是BFS卻須要在一個循環中搜到多個點。
可是這裏有一個新的方法,在隊列彈出時標記,也就是咱們新的寫法。這個寫法是通用寫法,能夠減小你的思考量級
這個寫法的惟一壞處就是:隊列內的點可能會重複出現
就像這張圖同樣,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; } } } }
"鬆弛"是一個很是理論性的概念,但總得說來就是三個字:
抄近道
譬如你要從杭州上城區去往紹興北站,你能夠選擇直接坐大巴直達,須要2個小時的路程。可是若是你選擇地鐵轉高鐵,那可能只須要40分鐘。
在圖論中,若是不指出點權的話,那麼默認換乘這種操做是不須要時間的,也就是說咱們若是能夠經過一箇中轉站到達指定地點,可是比原來快的話,咱們就會選擇換乘的這條路線。形式以下:
在這張圖中,咱們會選擇20 + 40 分鐘的這條路
單源最短路徑是什麼意思?
表示從一個點出發到除這個點外的距離
鄰接表長這樣,咱們通常分爲Head(頭)和Node(節點)
頭和節點用一句話概況就是:
頭連向節點中的每個點。
與鄰接矩陣相反,咱們先來看有向圖的實現方式。
如何理解頭連向節點中的每個點。這句話呢?咱們用兩幅圖看看
0連向2和5兩個點。0做爲頭(Head),而節點(Node)跟在後面。默認,鄰接表是由鏈表構成的。
鄰接表的一個特性是:節點值(Node)是不能隨意讀取的,譬如0到5是否能連上,你只能遍歷全部節點。
vector是可變數組
咱們都知道c++中的數組是不能變化容量的,那麼也就致使了空間上你無從知曉該開多大
可是vector能夠隨意的進行插入,容量隨即變大。對vector的尾部插入一個數,就是push_back();
讀取方式與數組徹底同樣。
size()能夠拿到當前vector的容量。
vector<int> v; v.push_back(12); int c = v[0]; int sz = v.size();
咱們用vector的數組形式實現鄰接表
每個Head後的Node,都是一組vector
vector<int> e[3];
e[0].push_back(7);
e[1].push_back(9)
e[0].push_back(12);
鄰接表不能任意讀取,通常鄰接表用來遍歷找出從i點出發能通往的全部點。
int i = st; for(int j = 0; j < e[i].size(); j++) { int v = e[i][j]; }