圖論(一)
建國中學 陳仲肯
目錄
- 圖
- 樹
- 圖的儲存
- 圖的遍歷
- 最短路徑
- 有向無環圖、拓樸排序
- 並查集
- 最小生成樹
我寫過的題目的解可以在這裡找到,這份簡報中的題目都有
抄學長的講義(X
圖
圖(X
圖(O




無向圖
有向圖
點
邊
圖=點+邊
有一些點,點和點之間可能會有邊連接
一條邊只能連接兩個點,但一個點可能連出很多條邊
表示一堆東西的關係,比如親戚關係、車站路線

邊可能有「權重」,簡稱邊權,常代表邊的長度
點也可能有點權,例如走到那個點會得到多少錢
1
2
5
3
3
4
6
5
1
奇怪的邊
2
1
重邊
自環
沒有重邊或自環的圖稱為簡單圖
度數
一個點連接的邊數
有向圖的話:
連出去的叫出度
連進來的叫入度
1
2
3
4
5
3
1
1
1
0
相鄰
兩個點藉由一條邊連在一起稱這兩個點相鄰
子圖
從圖中隨便選一些點和邊
1
2
3
4
5
1
2
3
4
路徑
1
2
3
4
5
從一個點開始走,經由一些邊走到一個點
走過的邊就是一條路徑
簡單路徑:沒有重複經過一個點的路徑
環
起點=終點的路徑
1
2
3
4
5
連通、連通分量
連通:圖中任兩點都存在路徑
連通分量:點數盡量多的連通子圖
(可以連的都連起來)
1
2
3
4
5
6
8
7
9
樹
沒有環的簡單連通圖
樹
沒有環的圖,通常會畫成這樣,像是掛在天花板上
1
3
2
4
6
5
根節點
隨便指定的點,通常畫圖時會畫在樹的最上面
1
3
2
4
6
5
- 有根樹:有根的樹
- 無根樹:沒有根的樹,所以可以自己隨便亂設
子節點/兒子:下面(遠離根節點方向)的點
父節點/爸爸:上面(往根節點方向)的點
有根樹中:
子節點/兒子:下面(遠離根節點方向)的點
父節點/爸爸:上面(往根節點方向)的點
有根樹中:
葉節點
沒有子節點的點
1
3
2
4
6
5
7
祖先:
有根樹中:
子孫:
子樹
以X為根的子樹:以X為根,包含X以及X的子孫的樹
1
3
2
4
6
5
以3為根的子樹
深度
到根節點的邊數
1
3
2
4
6
5
1
2
2
3
3
3
高度
最深的點的深度
3
圖的儲存
兩種常用的方法
鄰接矩陣
1 | 2 | 3 | 4 | |
1 | 0 | 1 | 1 | 0 |
2 | 0 | 0 | 1 | 1 |
3 | 0 | 0 | 0 | 0 |
4 | 1 | 0 | 0 | 0 |
1
2
3
4
j
i
若有重邊可以把1改成邊的數量
bool G[N][N]; int n,m; cin>>n>>m; int u,v; for(int i=0;i<m;++i){ cin>>u>>v; G[u][v]=G[v][u]=1; }
無向圖
兩向的邊都加入
有向圖
實作:輸入圖
bool G[N][N]; int n,m; cin>>n>>m; int u,v; for(int i=0;i<m;++i){ cin>>u>>v; G[u][v]=1; }
通常會先給你點數、邊數
再給每條邊的起點、終點
有邊權的話
#include <cstring> bool G[N][N]; int n,m; memset(G,-1,sizeof(G)); cin>>n>>m; int u,v,w;//weight for(int i=0;i<m;++i){ cin>>u>>v>>w; G[u][v]=w; }
-1代表不可能出現的值,如果有負邊權就改成不可能出現的數
memset(a,b,c):把a開始c個位元都改成b,可以把陣列通通改成一個值,b可以是0,-1,但不能是1,因為位元的特性,-1=滿滿的1
空間複雜度不管多少邊,都是O(點數2)
特色
枚舉一個點連出的邊:O(點數)
枚舉每一條邊:O(點數2)
找u到v的邊:O(1)
鄰接串列
1
2
3
4
1:2,3
2:3,4
3:
4:
實作:輸入圖
用vector
#include <vector> vector<int> G[N]; int n,m; cin>>n>>m; int u,v; for(int i=0;i<m;++i){ cin>>u>>v; G[u].push_back(v); }
無向圖就加上
G[v].push_back(u);
如果有邊權
#include <vector> #include <utility> #define pii pair<int,int> vector<pii> G[N]; int n,m; cin>>n>>m; int u,v,w; for(int i=0;i<m;++i){ cin>>u>>v>>w; G[u].push_back({v,w}); G[v].push_back({u,w}); }
pair<int,int> 存{點,邊權}
如果覺得pair還要記哪個是哪個很麻煩...
struct E{ int v,w; }; vector<E> G[N]; //input G[u].push_back({v,w}); //枚舉一個點連出去的邊 for(auto [v,w]:G[u])
這個寫法pair也可用
特色
空間複雜度是O(邊數)
枚舉一個點連出的邊:O(他的度數)
枚舉每一條邊:O(點數+邊數)
找u到v的邊:O(u的度數)
還有很多存圖方式
比如存邊
-
稠密圖:邊很多的圖,邊數大概是點數2
-
稀疏圖:邊很少的圖,邊數大概是點數
把圖簡單粗暴的分類一下
因為 點數−1≤邊數≤2點數∗(點數−1)∈O(點數2)
空間複雜度是O(邊數)
枚舉一個點連出的邊:O(他的度數)
枚舉每一條邊:O(點數+邊數)
找u到v的邊:O(u的度數)
空間複雜度:O(點數2)
枚舉一個點連出的邊:O(點數)
枚舉每一條邊:O(點數2)
找u到v的邊:O(1)
鄰接串列
鄰接矩陣
矩陣適用於稠密圖,串列適用於稀疏圖
題目通常是稀疏圖,結論是用串列就好
兩種存圖方法比較
圖的遍歷
DFS、BFS
滿滿題目
遍歷是啥?
目的:把每個點看過一遍
方法:DFS、BFS
DFS(Depth First Search)
深度優先搜尋
- 走到一個點,標示為已經走過
- 拜訪與他相鄰但沒走過的點
- 結束
過程(從1開始DFS)
1
2
3
4
5
1
2
3
4
5
DFS的路徑一定是一棵樹,之後會用到
實作
vector<int> G[N]; bool vis[N]; void dfs(int cur){//current vis[cur]=1; for(int nxt:G[cur]){//next if(vis[nxt])continue; dfs(nxt); } } int main(){ for(int i=0;i<n;++i){ if(!vis[i])dfs(i); } }
樹的實作可以省掉vis
vector<int> G[N]; bool vis[N]; void dfs(int cur,int fa){//current, father for(int nxt:G[cur]){//nxt if(nxt==fa)continue; dfs(nxt,cur); } } int main(){ //根是1的連通樹 dfs(1,1); }
特色
超好寫
所以很常用
BFS(Breadth-First Search)
廣度優先搜尋
- 開一個queue存要拜訪的點
- 選擇queue第一個點當作下一個點,並把他劃掉
- 把與他相鄰但沒走過的點加到queue
- 結束
1
2
3
4
5
過程(從1開始BFS)
1
2
3
4
5
實作
#include <vector> #include <queue> queue<int> Q; vector<int> G[N]; for(int i=0;i<n;++i){ //i作為起點 if(vis[i])continue; Q.push(i); while(!Q.empty()){ int cur=Q.front();Q.pop(); for(int nxt:G[cur]){ if(vis[nxt])continue; Q.push(nxt); } } //Q必是空的 }
特色
拜訪的節點到起點的最短距離非嚴格遞增
也就是說越先拜訪的點離起點越近
(不管邊權的話)
例題
n=m=0時結束,但n!=0,m=0要做
最短路徑
單點源最短路徑
給你一張帶權圖,求一個點到所有點的最短距離
前提:不能有負環
不然可以一直繞

名詞:鬆弛(Relaxation)
繞其他邊可能會更好
起點到u,v的距離是du,dv,u,v距離d
du+d≤dv時,更新dv=du+d

這時考慮用這條邊鬆弛
已經看過了這兩條邊
Bellman-Ford Algorithm
枚舉所有邊進行鬆弛,鬆弛 V−1 次
時間複雜度:O(VE)
為什麼V−1次一定可以?
因為一條簡單路徑最多只有V個點V−1條邊
#define pii pair<int,int> vector<pii> G[N]; int dis[N]; //Bellman-Ford,起點1 fill(dis,dis+n+1,INF); dis[1]=0; for(int t=1;t<n;++t){ for(int u=1;u<=n;++u){ for(auto [v,d]:G[u]){ dis[v]=min(dis[v],dis[u]+d); } } }
優化版的Bellman-Ford aka SPFA
(Shortest Path Faster Algorithm)
每次只枚舉上次被鬆弛的點的邊
- 期望複雜度:O(V+E)
- 最差複雜度:O(VE)
實作
- 開一個queue存上次被鬆弛過的點
- 每次拿第一個出來鬆弛
- 把有鬆弛到的點加入queue
裸題
如果鬆弛V-1次後還能鬆弛就代表有一條V條邊的最短路徑
一定有負環
想不開的話可以用SPFA寫
還要更快?
如果沒有負邊的話...
最短路徑樹
子節點的距離一定不比父節點小

Dijkstra 戴克司tra
- 做一個最短路徑樹,原點到樹上每個點的最短距離都確定
- 每次找不在樹上、離原點最近的節點,原點到他的距離就確定了(因為不會有更近的路徑)
- 更新與他相鄰的點到原點的距離

實作方法
- 找離原點最近的點->用priority queue存到原點的距離
- pii存{到原點距離,index}
- priority_queue小的優先
priority_queue<pii,vector<pii>,greater<pii>>
這裡如果放「小於」的話,pq會是從大到小
所以放「大於」就會從小到大
實作方法2
用struct存邊
要寫自定義的cmp,給priority queue用
好處是不用管pair的first跟second分別代表什麼
struct E{ int v,w; }; vector<E> G[N]; struct cmp{ bool operator()(E a,E b){ return a.w>b.w; } }; priority_queue<E,vector<E>,cmp> pq;
時間複雜度
把每條邊都push鬆弛一次、每個點pop一次
push pop都是O(logn)
n最多到E
O((E+V)logE)
優化
如果有鬆弛到才加入priority_queue
vector<E> G[N];//存邊 priority_queue<E,vector<E>,cmp> pq;//dijkstra ll dis[N]; void Dijsktra(){ //init fill(dis,dis+n+1,INF); dis[1]=0; pq.push({1,0});//到1的距離為0(起點 while(!pq.empty()){ E cur=pq.top();pq.pop(); if(dis[cur.v]<cur.w)continue; for(auto [v,w]:G[cur.v]){ if(dis[v]>cur.w+w){ pq.push({v,cur.w+w}); dis[v]=cur.w+w; } } } }
全點對最短距離
Floyd-Warshall
核心想法:每條路徑由起點、中繼點、終點構成
Floyd-Warshall
dp狀態
dp[k][i][j]:利用前k個點當中繼點,i到j的最短距離
沒路過k 有路過k
dp轉移
dp[k][i][j]=min(dp[k−1][i][j],dp[k−1][i][k]+dp[k−1][k][j])
複雜度:O(V3)
為什麼是對的?
如果能考慮到所有兩點間的路徑,就能找到最短路徑
i到j的路徑分為中繼點有0、沒有0
各自又分成有1、沒有1...於是包含了所有路徑
為什麼是對的?
如果能考慮到所有兩點間的路徑,就能找到最短路徑
i到j的路徑分為中繼點有0、沒有0
各自又分成有1、沒有1...於是包含了所有路徑
舉例
1
2
7
4
5
題目
DAG與拓樸排序
Directed Acyclic Graph 有向無環圖
DAG有向無環圖例子

什麼時候會用到
每個點代表一個狀態
有向邊代表可能的轉移方法
要找出一個順序,能夠把所有狀態一次算好
入度為0的代表結果已經確定,所以可以拔掉
並且更新他連出去的點的入度、狀態
實作
int deg[N]; vector<int> G[N],order; queue<int> Q; void topological_sort(){ memset(deg,0,sizeof(deg)); for(int i=0;i<n;++i){ for(int j:G[i])++deg[j]; } for(int i=0;i<n;++i){ if(deg[i]==0)Q.push(i); } while(!Q.empty()){ int cur=Q.front();//以下要拔掉cur,並更新連出去的點 Q.pop(); order.push_back(cur); for(int nxt:G[cur]){ //cur轉移到nxt if(--deg[nxt]==0)Q.push(nxt); } } }
可以拓樸排序完再DP
也可以邊拓樸排序邊DP
<---噁心寫法
並查集
DSU Disjoint Set Union
功能
-
快速的合併兩個集合
- 查詢一個元素所在的集合(代表元素)
圖論中的用途
-
快速的合併兩個連通塊
-
查詢一個點所在的連通塊(代表點)
作法
每個集合/連通塊當作一棵有根樹
根節點當作代表元素
如何找代表?
在樹上一直往上走,根節點就是代表
如何合併?
把其中一個樹的根節點隨便接在另外一棵樹上
1
3
2
4
作法
每個集合/連通塊當作一棵有根樹
根節點當作代表元素
如何找代表?
在樹上一直往上走,根節點就是代表
如何合併?
把其中一個樹的根節點隨便接在另外一棵樹上
1
3
2
4
實作
舉例:一開始每個點都獨立,一直合併
- 開一個點數大小的陣列,第i個代表i的上級,一開始都是i
- 查詢時一直往上走
- 合併時把任一棵樹的根的爸爸設為另一棵樹的樹根
int f[N]; void init(){ for(int i=1;i<=n;++i)f[i]=i; } int fa(int me){ if(f[me]==me)return me; return fa(f[me]); } void join(int a,int b){ int A=fa(a),B=fa(b); f[A]=B; }
int f[N],sz[N]; void init(){ for(int i=1;i<=n;++i)f[i]=i,sz[i]=1; } int fa(int me){ if(f[me]==me)return me; f[me]=fa(f[me]);//路徑壓縮 return f[me]; } void join(int a,int b){ int A=fa(a),B=fa(b); if(sz[A]<sz[B]){ //A併到B f[A]=B; sz[A]+=sz[B]; }else{ //B併到A f[B]=A; sz[B]+=sz[A]; } }
扣(依大小啟發式合併)
2021 11 APCS P4(ZJ g598)
先做一次塗色,每次加點時...




把每個顏色當成一個元素
加入新的邊u--v時
如果u,v的顏色在同一個集合就衝突了,否則
把(u的顏色)跟(v的同事的顏色)合併
把(v的顏色)跟(u的同事的顏色)合併
最小生成樹
MST Minimum Spanning Tree
定義
對於所有連通圖,點數與原圖相同、邊數最少的連通子圖
一定是一棵樹,稱為生成樹
邊權總和最小的生成樹即稱為最小生成樹
利用性質
一個連通圖內兩個未完成的最小生成樹
用最短的邊連接一定最好
Kruskal演算法
每次找當前權重最小的邊,
看端點是否在兩個不同的最小生成樹中,
是的話就把兩棵樹合併起來。
如何合併?
並查集
struct E{int u,v,w;}; bool cmp(E a,E b){return a.w<b.w;} E edge[N]; int Kruskal(){ sort(edge, edge+m, cmp); int ans=0; for(int i=0;i<m;i++){ if(fa(edge[i].u)!=fa(edge[i].v)){ ans+=edge[i].w; join(edge[i].u, edge[i].v); } } return ans; }
Prim's Algorithm
跟Dijkstra類似
把「與原點距離」改成「與樹距離」
struct E{int v,w;}; struct cmp{bool operator()(E a,E b){return a.w>b.w;}}; priority_queue<E,vector<E>,cmp> pq; pq.push({1,0}); int ans=0; dis[1]=0;//到樹距離 while(!pq.empty()){ E cur=pq.top();pq.pop(); if(vis[cur.v])continue; vis[cur.v]=1; ans+=cur.w; for(auto [v,w]:G[cur.v]){ if(!vis[v]&&dis[v]>w){ dis[v]=w; pq.push({v,w}); } } }
題目:TIOJ 1211
裸題
圖論(一)(資讀)
By kennyfs
圖論(一)(資讀)
- 1,318