圖論

黃博崇

  • 成功高中電子計算機研習社35th 總務/教學

講師介紹

目錄

  • 介紹圖
  • 圖的存取
  • 名詞解釋
  • 圖的遍歷
  • 拓樸排序
  • 最短距離
  • 並查集
  • 最小生成樹

圖(graph)

由一些節點(vertices/node)和邊(edge)組成

每個節點有自己的編號

圖(graph)

邊(egde)

分為有向邊和無向邊

如果在一張圖中全部都是無向邊稱為無向圖

否則是有向圖

1

2

3

無向圖

1

2

3

有向圖

邊(edge)

邊可能有帶權重

1

2

3

無向帶權圖

1

2

3

有向帶權圖

4

7

2

3

度數(degree)

入度(in degree)代表有多少邊指向這個節點

出度(out degree)代表有多少邊從這個節點出去

1

2

3

4

in degree out degree
1 1 1
2 0 2
3 2 1
4 1 0

圖的存取

假設一張圖有n個點m個邊

宣告一個二維陣列v[ n ][ n ]

v[ i ][ j ]代表i~j邊的長度

空間複雜度為O(n^2)

鄰接矩陣

3

2

1

4

4

2

8

v 0 1 2 3 4
0 0 0 0 0 0
1 0 0 0 0 0
2 0 4 0 0 0
3 0 0 2 0 0
4 0 8 0 0 0

優點:

  • 能夠O(1)直接獲得兩點間的邊權

鄰接矩陣

缺點:

  • 空間複雜度為O(n^2)容易爆掉

code

#include <bits/stdc++.h>
using namespace std;
int v[10005][10005];
int main(){
	int n, m;
	cin >> n >> m;
	for(int i = 0; i < m; i++){
		int a, b, w;
		cin >> a >> b >> w;
		v[a][b] = w;
		//v[b][a] = w; 無向圖 
	}
	for(int i = 1; i <= n; i++){
		for(int j = 1; j <= n; j++){
			cout << v[i][j] << ' ';
		}
		cout << '\n';
	}
}

宣告一個長度為n存pair的vector v[n]

v[i].push_back({j, w})代表新增點i連到點j長度為w的邊

空間複雜度O(m)

鄰接串列

3

2

1

4

4

2

8

v[ 1 ] = {{2, 4}, {4, 8}}

v[ 2 ] = {{3, 2}}

優點:

  • 相較鄰接矩陣能夠省許多空間

鄰接串列

缺點:

  • 無法快速獲得兩點間的邊權

code

#include <bits/stdc++.h>
using namespace std;
vector <pair <int, int> > v[10005];
int main(){
	int n, m;
	cin >> n >> m;
	for(int i = 0; i < m; i++){
		int a, b, w;
		cin >> a >> b >> w;
		v[a].push_back({b, w});
		v[b].push_back({a, w});
	}
	for(int i = 1; i <= n; i++){
		cout << i << " : ";
		for(auto j : v[i]){
			cout << j.first << ", " << j.second << " ";
		}
		cout << '\n';
	}
}

宣告一個struct存邊的兩端點和長度

邊陣列

3

2

1

4

4

2

8

edge[0]={1,2,4}

edge[1]={2,3,2}

edge[2]={1,4,8}

code

#include <bits/stdc++.h>
using namespace std;
struct e{
	int u, v, w;
}edge[100005];
int main(){
	int n, m;
	cin >> n >> m;
	for(int i = 0; i < m; i++){
		int a, b, w;
		cin >> a >> b >> w;
		edge[i] = {a, b, w};
	}
	for(int i = 0; i < m; i++){
		cout << edge[i].u << " " << edge[i].v << " " << edge[i].w << '\n';
	}
}

名詞解釋

一個連通塊中的任兩點間都一定有路徑連通

連通塊

2

1

5

4

3

6

7

環上的點能經過一些邊再回到原本的點

2

1

5

4

3

有向且無環的圖

DAG

2

1

5

4

3

有n個點n-1條邊的圖

Tree

2

1

5

4

3

類似迷宮的圖

網格圖

把點分成兩種顏色

相鄰的點都是不同的顏色

二分圖

圖的遍歷

通常以遞迴實作

  1. 到達一個點並標記為已走過
  2. 看他能夠走到哪些還沒被走過的點
  3. 往下個點走

DFS

2

1

5

4

3

6

7

順序

  • 1 2 5 3 4 6 7
  • 1 3 5 4 6 7 2

code

bool visit[100005];
vector <int> g[100005]; 
void dfs(int v){
	visit[v] = true;
	for(int i : g[v]){
		if(!visit[i]){
			dfs(i);
		}
	}
	return;
}

一個DFS可以跑完一個連通塊

如果題目不只一個連通塊記得跑完所有點

for(int i = 1; i <= n; i++){
	if(!visit[i]){
		dfs(i);
	}
}

像倒水一樣慢慢擴散,通常用queue實作

  1. 從queue裡拿出點
  2. 看他能夠走到哪些還沒被走過的點
  3. 把它們丟進queue裡並標記已走到過
  4. 直到queue裡沒東西

BFS

2

1

5

4

3

6

7

順序

  • 1 2 5 3 7 6 4
  • 1 5 2 3 7 6 4

code

bool visit[100005];
vector <int> g[100005]; 
void bfs(){
	queue <int> q;
	visit[1] = true;
	q.push(1);
	while(!q.empty()){
		int v = q.front();
		q.pop();
		for(int i : g[v]){
			if(!visit[i]){
				q.push(i);
				visit[i] = true;
			}
		}
	}
	return;
}

先看這題 zerojudge a982

網格圖不用建成鄰接矩陣或串列

可以用座標來存

在網格圖中每次BFS也會走完一個連通塊

網格圖的BFS

code(暴力版)

string g[105];
bool visit[105][105];
bool bfs(){
	queue <pair <int, int>> q;
	visit[1][1] = true;
	q.push({1, 1});
	while(!q.empty()){
		int x = q.front().first, y = q.front().second;
		if(x == n - 2 && y == n - 2){
			return true;
		}
		q.pop();
		if(g[y - 1][x] == '.' && !visit[y - 1][x]){ //up
			q.push({x, y - 1});
			visit[y - 1][x] = true;
		}
		if(g[y + 1][x] == '.' && !visit[y + 1][x]){ //down
			q.push({x, y + 1});
			visit[y + 1][x] = true;
		}
		if(g[y][x - 1] == '.' && !visit[y][x - 1]){ //left
			q.push({x - 1, y});
			visit[y][x - 1] = true;
		}
		if(g[y][x + 1] == '.' && !visit[y][x + 1]){ //right
			q.push({x + 1, y});
			visit[y][x + 1] = true;
		}
	}
	return false;
}

code(比較不暴力版)

int dir[4][2] = {{1, 0}, {-1, 0}, {0, 1}, {0, -1}};
string g[105];
bool visit[105][105];
bool bfs(){
	queue <pair <int, int>> q;
	visit[1][1] = true;
	q.push({1, 1});
	while(!q.empty()){
		int x = q.front().first, y = q.front().second;
		if(x == n - 2 && y == n - 2){
			return true;
		}
		q.pop();
		for(int i = 0; i < 4; i++){
			int nextx = x + dir[i][0], nexty = y + dir[i][1];
			if(g[nexty][nextx] == '.' && !visit[nexty][nextx]){
				q.push({nextx, nexty});
				visit[nexty][nextx] = true;
			}
		}
	}
	return false;
}

例題

TIOJ 1209

zerojudge e810

zerojudge b689

拓樸排序

在DAG中將所有點排成一個序列

滿足所有點都是由前向後的

拓樸排序不是唯一的

介紹

2

1

5

4

3

6

7

拓樸排序

  • 1 2 3 5 4 6 7
  • 1 3 2 4 5 6 7

在DAG上DP就可以使用拓樸排序

需要記錄入度

  1. 將入度為0的點放入序列
  2. 將這個點連到的所有點入度減1(把邊拔掉)
  3. 重複直到所有點都放入序列

可用queue實作

2

1

5

4

3

6

7

拓樸排序

  • 1 2 3 5 4 6 7
  • 1 3 2 4 5 6 7

code

vector <int> g[100005];
int indeg[100005];
vector <int> topo;
void toposort(){
	queue <int> q;
	for(int i = 1; i <= n; i++){
		if(indeg[i] == 0) q.push(i);
	}
	while(!q.empty()){
		int v = q.front();
		q.pop();
		topo.push_back(v);
		for(auto i : g[v]){
			indeg[i]--;
			if(indeg[i] == 0) q.push(i);
		}
	}
}

例題

TCIRC d095

zerojudge f167

Atcoder dp contest_G

最短距離

負環

2

1

5

4

3

每走一圈距離會變更少

和負環連接的點不會有最短距離

-2

3

-4

-10

1

假設任兩點i、j間的當前最短距離是dis[i][j]

如果有一點k存在且dis[i][k]+dis[k][j]<dis[i][j]

那麼就可以更新dis[i][j]的值

鬆弛

i

j

k

12

4

5

  1. 暴力枚舉三個點i、j、k
  2. 將k看成i、j的中點
  3. 對i、j鬆弛

時間複雜度為O(n^3)

Floyd-Warshall Algorithm

code

for(int k = 1; k <= n; k++){
	for(int i = 1; i <= n; i++){
		for(int j = 1; j <= n; j++){
			if(dis[i][k] + dis[k][j] < dis[i][j]){
				dis[i][j] = dis[i][k] + dis[k][j];
        		}
		}
	}
}

單源點最短距離

起點只有一個

詢問起點到任意點的最短距離

Dijkstra's Algorithm

陣列dis[i]代表起點到i的最短距離

  1. 尋找距離起點最近的點當作"已確認最短路徑的點"
  2. 用這個點鬆弛其他能被走到的點

用priority_queue來實作

時間複雜度為O(mlogm)

無法處理負邊權

code

#define f first
#define s second
typedef pair <int, int> pii;
int dis[100005];
vector <pii> g[100005];
priority_queue <pii, vector <pii>, greater <pii> > pq;
void dijkstra(int n, int st){
	for(int i = 1; i <= n; i++){
		dis[i] = 1e9;
	}
	dis[st] = 0;
	pq.push({0, st});
	while(!pq.empty()){
		pii tmp = pq.top();
		pq.pop();
		if(dis[tmp.s] != tmp.f){
			continue;
		}
		for(auto i : g[tmp.s]){
			int newdis = dis[tmp.s] + i.s;
			if(newdis < dis[i.f]){
				dis[i.f] = newdis;
				pq.push({dis[i.f], i.f});
			}
		}
	}
}

例題

TCIRC d096

TIOJ 2283

zerojudge d792

名詞解釋

2

1

5

4

3

6

子樹

父節點

子節點

二元樹

2

1

5

4

3

6

每個節點最多只有2個子節點

樹直徑

2

1

5

4

3

6

樹上最長的1條路徑

也就是樹上最遠兩個點的距離

7

樹直徑

  1. 從任一點dfs到最遠的點
  2. 再從那個點dfs到最遠點,所走的路徑長就是樹直徑

只能在非負邊權使用

code

vector <int> g[100005];
int far_d, far_v;
void dfs(int v, int fa, int d = 0){
	if(d > far_d){
		far_d = d;
		far_v = v;
	}
	for(auto i : g[v]){
		if(i == fa) continue;
		dfs(i, v, d + 1);
	}
}
int diam(){
	far_d = -1;
	dfs(1, 1);
	far_d = -1;
	dfs(far_v, far_v);
	return far_d;
}

樹重心

2

1

5

4

3

6

一個樹會存在1或2個樹重心

樹重心的最大子樹是所有點中最小的

且子樹大小不大於n/2

樹重心

需紀錄目前重心和大小、每個點所有子樹加自己大小總和

  1. 從任一點開始dfs
  2. 找到他的最大子樹
  3. 檢查是否比目前重心的最大子樹小

code

vector <int> g[100005];
int G, Gsize;
int sz[100005];
void dfs(int v, int fa){
	int maxsz = 0;
	sz[v] = 1;
	for(auto i : g[v]){
		if(i == fa) continue;
		dfs(i, v);
		maxsz = max(maxsz, sz[i]);
		sz[v] += sz[i];
	}
	maxsz = max(maxsz, n - sz[v]);
	if(maxsz < Gsize){
		Gsize = maxsz;
		G = v;
	}
}

例題

zerojudge b967

並查集(disjoint set)

介紹

一種處理集合的樹狀資料結構

  • 能夠查詢元素所在的集合
  • 能夠合併兩集合

ex:

a、b是同學,b、c是同學

所以a、c也是同學

查詢

宣告一個陣列root[]代表每個點的"根",初始化每個點的根都是自己

要查詢時,不停往上找根直到根是自己

當兩個點最終找到的根一樣代表位於同個集合

1

2

3

4

5

6

root[1]=2

root[2]=3

root[3]=3

root[4]=3

root[5]=6

root[6]=6

合併

在兩個點中找到其中一個點最終的根

將另一個點的根直接設為它

1

2

3

4

5

6

root[1]=2

root[2]=3

root[3]=3

root[4]=3

root[5]=6

root[6]=6(3)

code

int root[100005];
int findroot(int x){
	if(root[x] == x) return x;
	return findroot(root[x]);
}
void connect(int x, int y){
	root[findroot(y)] = findroot(x);
}

好像有點慢?

啟發式合併

把較小的樹合進較大的樹

1

2

3

4

5

6

小合進大

root[1]=2

root[2]=3

root[3]=3

root[4]=3

root[5]=6

root[6]=3

大合進小

root[1]=2

root[2]=3

root[3]=6

root[4]=3

root[5]=6

root[6]=6

時間複雜度為O(logn)

code

void connect(int x, int y){
	if(findroot(x) == findroot(y)) return;
	if(size[findroot(x)] > size[findroot(y)]){
		size[findroot(x)] += size[findroot(y)];
		root[findroot(y)] = findroot(x);
	}
	else{
		size[findroot(y)] += size[findroot(x)];
		root[findroot(y)] = findroot(y);
	}
}

路徑壓縮

每次查詢某個點完後

直接將根設為最後找到的根

1

2

3

4

1

2

3

4

時間複雜度為alpha(n)

code

int findroot(int x){
	if(root[x] == x) return x;
	root[x] = findroot(root[x]);
	return root[x];
}

給一些點和邊

有兩種操作

  • 移除某兩點間的邊
  • 詢問兩點是否連通

並查集要怎麼把點移出集合?

能夠只靠合併集合做到嗎?

1

2

3

4

5

6

離線(倒著做)

  1. 先將所有操作紀錄起來且不要建圖
  2. 只建沒在操作中被移除的邊
  3. 把紀錄的操作反過來,如果是詢問操作就照目前建的圖查看連通性,如果是移除操作就把它變成增加邊

ex:

  1. 移除2、3的邊
  2. 查詢1、6連通性
  3. 移除4、6的邊
  4. 查詢2、5連通性

1

2

3

4

5

6

原圖

例題

TIOJ 1312

TIOJ 2277

TIOJ 1192

最小生成樹(MST)

介紹

生成樹代表包含了一張圖所有點的樹

一張圖有可能有很多生成樹

最小生成樹代表邊權和最小的生成樹

1

2

3

4

5

9

2

3

5

7

15

6

Kruskal's Algorithm

  1. 將所有邊以權重由小排到大
  2. 如果目前選到的邊兩端點已經在生成樹中就無視這條邊,反之將這條邊加進生成樹中

以並查集維護

時間複雜度為O(mlogm))

code

struct e{
	int u, v, w;
};
vector <e> edge;
bool cmp(e a, e b){
	return a.w < b.w;
}
int main(){
	int n, m;
	cin >> n >> m;
	for(int i = 0; i < m; i++){
		int a, b, w;
		cin >> a >> b >> w;
		edge.push_back({a, b, w});
	}
	sort(edge.begin(), edge.end(), cmp);
	int sum = 0;
	for(int i = 0; i < m; i++){
		int u = edge[i].u, v = edge[i].v, w = edge[i].w;
		if(findroot(u) != findroot(v)){
			connect(u, v);
			sum += w;
		}
	}
}

Prim's Algorithm

和Dijkstra類似

改成每次尋找離目前最小生成樹最近的點

用priority_queue維護

code

#define f first
#define s second
typedef pair <int, int> pii;
bool visit[100005];
vector <pii> g[100005];
priority_queue <pii, vector <pii>, greater <pii> > pq;
int prim(){
	int sum = 0;
	pq.push({0, 1});
	while(!pq.empty()){
		pii tmp = pq.top();
		pq.pop();
		if(visit[tmp.s]) continue;
		visit[tmp.s] = true;
		sum += tmp.f;
		for(auto i : g[tmp.s]){
        		pq.push({i.s, i.f});
		}
	}
	return sum;
}

例題

TCIRC d098

Atcoder ABC282_E

參考資料

  • ap325
  • 2021建中校內培訓簡報
  • 2022 ION Camp
  • 賴阿蘭放課簡報

進階圖論

  • DFS Tree
  • 關節點和橋
  • 強連通分量scc
  • LCA
  • 樹壓平
  • 樹鏈剖分

圖論

By patrickh

圖論

  • 311