基礎圖論

北一女中 2022 黑魔法訓練營

建國中學 -> 台大資工 賴昭勳

  • IOI 2022 國手
  • APIO 2021, 2022 銀牌
  • 2021 北市賽一等 -> 全國賽一等
  • 2021 NPSC 第一名
  • 我弱
  • 為了向 hhhhaura 學習基礎圖論所以來當基礎圖論講師

講師簡介

課程主題

  • 圖的定義
  • 圖的儲存,遍歷
  • 二分圖
  • 拓撲排序
  • 並查集
  • 最小生成樹
  • 最短路徑

什麼是圖?

去年在做這份講義的時候我在這裡放了一個爛梗,今年覺得不好笑了

唯利是圖

 

 

 

 

一些定義:(無向)圖

重邊

自環

環?

連通塊?

度數?

路徑?

一些定義:(有向)圖

入度 =

出度 =

環?

連通塊?

一些定義:圖的分類

  • 有向圖/無向圖
  • 帶權圖/不帶權圖
  • 連通圖
  • 簡單圖
  • 完全圖

簡報的convention (慣例)

  • 沒有特別說的話都是無向圖
  • \(V\)是點集,\(E\)是邊集
  • 點數用\(n\)表示,邊數用\(m\)表示
  • maxn 是題目中最大需要的點數
  • 1-base

簡報的 default code

//Challenge: Accepted
#include <bits/stdc++.h>
#pragma GCC optimize("Ofast")
using namespace std;
#define ll long long
#define maxn 505
#define pii pair<int, int>
#define ff first
#define ss second
#define io ios_base::sync_with_stdio(0);cin.tie(0);
vector<int> adj[maxn]; //很多程式碼會缺這行

圖的儲存

鄰接矩陣 (adjacency matrix)

二維陣列 \(A\),若\((u, v)\)有連邊就讓\(A[u][v] = 1\),否則\(A[u][v] = 0\)

 

無向圖的時候記得\(A[v][u]\)也要看

空間需要\(O(n^2)\)

鄰接陣列 (adjacency list)

較常見的儲存方式

 

對每一個點維護一個 vector ,紀錄他連到的所有點

空間需要\(O(m)\)

vector<int> adj[maxn];
int main() {
    int n, m;
    cin >> n >> m;
    for (int i = 0;i < m;i++) {
    	int u, v;
        cin >> u >> v;
        adj[u].push_back(v);
        adj[v].push_back(u);
    }
}

圖的遍歷

 深度優先搜尋 (DFS)

 

vector<int> adj[maxn];
bool vis[maxn];
void dfs(int u) {
    vis[u] = 1;
    for (int v:adj[u]) {
    	if (!vis[v]) {
        	dfs(v);
        }
    }
}
int main() {
    for (int i = 1;i <= n;i++) {
    	if (!vis[i]) dfs(i);
    }
}

能走的就先走

記得紀錄每個點是否走過

 廣度優先搜尋 (BFS)

 

vector<int> adj[maxn];
bool vis[maxn];
queue<int> que;
que.push(1);
while (que.size()) {
    int cur = que.front();
    vis[cur] = 1;
    que.pop();
    for (int v:adj[cur]) {
    	if (!vis[v]) {
            que.push(v);
            vis[v] = 1; //重要!
        }
    }
}

用一個佇列 (queue) 存下走得到並且還沒處理的點。

每次拿掉最前面的點並枚舉與他相鄰的點。

 BFS 重要的性質

vector<int> adj[maxn];
bool vis[maxn];
int dis[maxn];

queue<int> que;
que.push(1);

for (int i = 1;i <= n;i++) dis[i] = -1;
dis[1] = 0;
while (que.size()) {
    int cur = que.front();
    vis[cur] = 1;
    que.pop();
    for (int v:adj[cur]) {
    	if (!vis[v]) {
            que.push(v);
            dis[v] = dis[cur] + 1;
            vis[v] = 1; //重要!
        }
    }
}

計算每個點與原點的最短距離!

這裡的邊權重都是\(1\)

證明?

 BFS 的應用

爆搜!

題目是"給你一個...,每次可以做...操作,找到一組步驟達成..."的時候:

 

把題目的東西看成狀態(節點),做的操作看成轉移(邊)去做BFS

 

如:量杯問題

 習題

二分圖

 

 

定義

一個圖\(G\)是二分圖,代表可以把點集\(V\)拆成兩個點集\(X, Y, X \cup Y = V\),使得每一條邊的端點都是從\(X\)的點連到\(Y\)的點。

二分圖塗色: 例題

練習寫寫看?

vector<int> adj[maxn];
bool vis[maxn];
int color[maxn];
bool dfs(int n, int c) { //回傳是否形成二分圖
    color[n] = c;
    vis[n] = 1;
    bool ret = 1;
    for (int v:adj[n]) {
    	if (!vis[v]) {
        	ret &= dfs(v, 3 - c); //1->2, 2->1
        } else if (color[v] == color[n]) {
        	return 0;
        }
    }
    return ret;
}
int main() {
    bool bipartite = 1;
    for (int i = 1;i <= n;i++) {
    	if (!vis[i]) bipartite &= dfs(i, 1);
    }
}

與二分圖的性質有關的題目

拓撲(ㄆㄨ)排序

 

 

事件的順序關係...

有\(n\)件事情要做,但是有\(m\)組限制,每組限制第\(i\)件事情需要比第\(j\)件事情早做完。每次只能做一件事,輸出做完事情的順序,或是輸出無解。

能做的就先做完

把事件看成點,限制看成有向邊。每次選擇一個沒有被限制的點,並把他連出去的限制拔掉。

無解條件?

vector<int> adj[maxn];
int deg[maxn];
int main() {
    for (int i = 0;i < m;i++) {
    	int u, v;
        cin >> u >> v;
        adj[u].push_back(v);
        deg[v]++;
    }
    queue<int> que;
    for (int i = 1;i <= n;i++) {
    	if (deg[i] == 0) que.push(i);
    }
    while (que.size()) {
    	int cur = que.front();que.pop();
        for (int v:adj[cur]) {
            deg[v]--;
            if (deg[v] == 0) {
            	que.push(v);
            }
        }
    }
}

紀錄每個點目前的入度

題目

 

 

定義(們)

  • 有\(n\)個點,\(n-1\)條邊的連通圖
  • 任兩點都恰有一條簡單路徑連接
  • 除了一個點(根節點)之外每個點都有一個不是自己的父節點

根節點

葉節點

子節點(小孩)

父節點(父親)

子樹?

森林?

有根/無根樹

在樹上DFS

不用紀錄 visited

void dfs(int n, int par) {
    for (int v:adj[n]) {
    	if (v != par) dfs(v, n);
    }
}

紀錄節點的深度 (depth)

與根節點之間的距離

int dep[maxn];
void dfs(int n, int par, int d) {
    dep[n] = d;
    for (int v:adj[n]) {
    	if (v != par) dfs(v, n, d+1);
    }
}

樹直徑

樹上距離最遠的兩個點

\(O(n^2)\)作法?

\(O(n)\)作法?

對於任意一點,距離他最遠的點是(其中一個)樹直徑上的點

證明?

int dep[maxn];
int dfs(int n, int par, int d) {
    dep[n] = d;
    int far = n;
    for (int v:adj[n]) {
    	if (v != par) {
            int tmp = dfs(v, n, d+1);
            if (dep[tmp] > dep[far]) far = tmp;
        }
    }
    return far;
}
int main() {
    int u = dfs(1, 0, 0);
    int v = dfs(u, 0, 0);
    //Length: dep[v]
}

紀錄子樹大小

int siz[maxn];
void dfs(int n, int par) {
    siz[n] = 1;
    for (int v:adj[n]) {
    	if (v != par) {
        	dfs(v, n);
            siz[n] += siz[v];
        }
    }
}

樹重心

定義:一個點是樹重心,代表以該點為根,每個小孩的子樹大小皆不超過 \(n / 2\)

\(O(n^2)\)作法?

\(O(n)\)作法?

對於任意一點,如果有相鄰的點子樹大小\( > n / 2\)就往那邊走

證明?

int siz[maxn];
void dfs(int n, int par) {
    siz[n] = 1;
    for (int v:adj[n]) {
        if (v != par) {
            dfs(v, n);
            siz[n] += siz[v];
        }
    }
}
int tot; //總節點數
int get_centroid(int n, int par) {
    for (int v:adj[n]) {
        if (v != par && siz[v] * 2 > tot) {
            return get_centroid(v, n);
        }
    }
    return n;
}
int main() {
    dfs(1, 0);
    int centroid = get_centroid(1, 0);
}

題目

樹的最低共同祖先 (LCA)

 

 

LCA是什麼?

若點\(x\)是點\(y\)的祖先,那麼

1. \(x = y\) 或是

2. \(x\)是\(y\)的父親的祖先。

兩個點\(u, v\)的最低共同祖先(LCA)就是深度最大的點\(x\),使得\(x\)同時是\(u\)和\(v\)的祖先

暴力的演算法

先預處理每個點的深度。

每次拿兩個點中較深的點走到他的父親,當兩個點第一次重合的時候就是LCA。

 

走的步數是兩點之間的距離,最差是\(O(n)\)

要怎麼更快?

快速的往上走?

對每個人紀錄他的\(1, 2, 4, \cdots, 2^k\)倍祖先。

用 Sparse Table 的方式!

演算法

先紀錄每個點的深度以及\(2^k\)倍祖先,要詢問\(a\)跟\(b\)的LCA的時候:

  1. 把\(a\)跟\(b\)裡面較深的往上走到相同的深度
  2. 如果此時\(a = b\),LCA就是\(a\)
  3. 用前面的方法,\(k\)由大到小,如果\(a\)和\(b\)的\(2^k\)倍祖先不同的話就往上走。
  4. 最後LCA是\(a\)的父親

 

時間複雜度:\(O(n \log n)\)預處理, \(O(\log n)\)詢問

為什麼?

int anc[18][maxn];
int dep[maxn];
void dfs(int n, int par, int d) {
    anc[0][n] = par;
    dep[n] = d;
    for (int v:adj[n]) {
    	if (v != par) dfs(v, n, d+1);
    }
}
int lca(int a, int b){
    if (dep[a] < dep[b]) swap(a, b);
    for (int i = 17;i >= 0;i--) {
    	if (dep[anc[i][a]] >= dep[b]) {
        	a = anc[i][a];
        }
    }
    if (a == b) return a;
    for (int i = 17;i >= 0;i--) {
    	if (anc[i][a] != anc[i][b]) {
        	a = anc[i][a];
            b = anc[i][b];
        }
    }
    return anc[0][a];
}
int main() {
    dep[0] = -1;
    dfs(1, 0, 0);
    for (int i = 1;i < 18;i++) {
    	for (int j = 1;j <= n;j++) {
            anc[i][j] = anc[i-1][anc[i-1][j]];
        }
    }
}

 LCA的性質

樹上\(a\)走到\(b\)的路徑為:

\(a\)到\(LCA(a, b)\)到\(b\)

 

可以計算兩點之間的距離!


 

題目

並查集 (DSU)

 

 

似乎已經教過了

但感覺需要再講一遍

可以加一條邊,

並查詢目前兩個點是否連通的資料結構

 

有路徑壓縮和啟發式合併兩種優化

複習一下實作

struct DSU{
    int par[maxn];
    void init(int n) {
        for (int i = 1;i <= n;i++) par[i] = i;
    }
    int find(int a) {
        if (a == par[a]) return a;
        int ret = find(par[a]);
        par[a] = ret;
        return ret;
    }
    bool Union(int a, int b) {
        a = find(a), b = find(b);
        if (a == b) return 0;
        par[a] = b;
        return 1;
    }
} dsu;

二分圖判斷+

有一張\(n\)個點的圖,一開始沒有任何邊。有\(m\)次修改,每次新增一條邊,回答那張圖是不是二分圖。

 

\(n, m \leq 2 \times 10^5\)

離線?

在線?

有一張\(n\)個點的圖,一開始沒有任何邊。在時間\(i, 1 \leq i \leq m\)的時候新增\((u, v)\)這條邊,接下來有\(q\)筆詢問\(a, b\),回答\(a, b\)兩點最早在什麼時間連通。

 

\(n, m, q \leq 2 \times 10^5\)

題目

最小生成樹 (MST)

 

問題定義

有一張\(n\)點\(m\)邊的連通帶權無向圖,每一條邊的花費是\(w_i\),從這張圖的邊選出一棵包含所有點的樹,並且最小化邊的花費總和。

生成樹:從一張圖的邊取出一棵樹,使得所有點連通。

生成樹的權重是邊的權重總和,最小生成樹就是要最小化權重。

Kruskal's Algorithm

把邊按照權重從小到大排序,維護目前的連通塊(DSU),從小的邊開始加:

  • 如果此時邊的端點在不同的連通塊就選他
  • 否則就捨棄他

 

複雜度\(O(m \log m)\)

證明?

Prim's Algorithm

維護「目前的最少生成樹」

隨便選一個點當起點,每次把距離生成樹上最近的點加到樹中,再更新其他點的值。

 

 

複雜度\(O(m \log m)\)或\(O(n^2)\)

證明?

struct DSU{
    int par[maxn];
    void init(int n) {
        for (int i = 1;i <= n;i++) par[i] = i;
    }
    int find(int a) {
        return a == par[a] ? a : (par[a] = find(par[a]));
    }
    bool Union(int a, int b) {
        a = find(a), b = find(b);
        if (a == b) return 0;
        par[a] = b;
        return 1;
    }
} dsu;
struct edge{
    int u, v, w;
    edge(){u = v = w = 0;}
    edge(int a,int b, int c){u = a, v = b, w = c;}
};
int main() {
    int n, m;
    cin >> n >> m;
    vector<edge> ed;
    for (int i = 0;i < m;i++) {
        int u, v, w;
        cin >> u >> v >> w;
        ed.push_back(edge(u, v, w));
    }
    sort(ed.begin(), ed.end(), 
        [&] (edge x, edge y){return x.w < y.w;});
    dsu.init(n);
    ll ans = 0;
    for (edge e:ed) {
        int u = e.u, v = e.v, w = e.w;
        if (dsu.Union(u, v)) {
            ans += w;
        }
    }
    cout << ans << "\n";
}

題目

最短路 Part 1.

 

 

問題定義

有一張\(n\)點\(m\)邊的帶權圖,對於一條\(u\)到\(v\)的路徑,他的權重為每條邊的權重總和。\(u\)到\(v\)的最短路徑是權重最小的,\(u\)到\(v\)的路徑。

  • 單點源最短路
  • 全點對最短路
  • 負環

鬆弛

假設我紀錄了\(u\)到\(v\)目前找到的最短路徑長,叫做\(dis[u][v]\)(沒找到路徑的話就用\(\infty\)表示),

 

那如果存在一個中間點\(x\),使得

\(dis[u][x] + dis[x][v] < dis[u][v]\),

就能把 \(dis[u][v]\) 更新為 \(dis[u][x] + dis[x][v]\),這個動作叫做鬆弛 (relax)

單點源最短路

一個起點\(s\)到所有點\(v\)的最短路

Bellman-Ford 演算法

紀錄每個點目前與原點\(s\)的最短距離 \(dis[i]\)

一開始\(dis[s] = 0, dis[i] = \infty (i \neq s)\)

 

重複\(n\)次:

枚舉所有邊\((u, v)\),假設邊權是 \(w\),如果\(dis[u] + w < dis[v]\),就對 \(v\) 進行鬆弛。

 

註:無向邊的話\((u, v)\)和\((v, u)\)都要看

時間複雜度\(O(nm)\)

證明?

Bellman-Ford 的使用時機

有負邊的時候可以用!

 

判斷負環?

看\(n\)回合後是否有邊可以鬆弛

SPFA

Shortest Path Faster Algorithm

優化過的Bellman-Ford!

 

每回合只更新「前一回合有被鬆弛」的點相鄰的邊。

用 queue 維護?

const ll inf = 1LL<<60;
vector<pii> adj[maxn];
ll dis[maxn];
bool vis[maxn];
int main() {
    int n, m;
    cin >> n >> m;
    for (int i = 0;i < m;i++) {
        int u, v, w;
        cin >> u >> v >> w;
        adj[u].push_back({v, w});
    }
    for (int i = 1;i <= n;i++) dis[i] = inf;    
    dis[1] = 0;
    
    bool negative_cycle = 0;
    for (int round = 0;round <= n;round++) {
        for (int u = 1;u <= n;u++) {
            for (auto [v, w]:adj[u]) {
                if (dis[u] + w < dis[v]) {
                    if (round == n) {
                        negative_cycle = 1;
                    } else {
                        dis[v] = dis[u] + w;
                    }
                }
            }
        }
    }
}

這題比較難!

提示 ->

無解條件是:能從\(1\)走到一個正環再走到\(n\)

Dijkstra (戴克斯特拉) 演算法

很重要 一定要念對

考慮不帶權的單點源最短路,我們用BFS維護一個 queue,每次處理一個點時最短路徑大小已知,因此只需要拿該點去更新其他點一次

 

沒有負邊的假設下,Dijkstra 就像是有帶權的BFS。

DP的想法!

Dijkstra (戴克斯特拉) 演算法

很重要 一定要念對

  1. 維護一個 heap (priority_queue),裡面存著點與該點找到的最短距離 \(cur, dis\)。
  2. 每次取出距離最小的點,此時如果該點未被處理過,則\(dis\)為該點的最短距離,進行步驟 3.
  3. 對\(cur\)相鄰的點鬆弛(更新距離)並加進 heap 中

複雜度 \(O(m \log n)\)或\(O(n^2)\)

const ll inf = 1LL<<60;
vector<pii> adj[maxn]; 
ll dis[maxn];
int main() {
    int n, m;
    cin >> n >> m;
    for (int i = 0;i < m;i++) {
        int u, v, w;
        cin >> u >> v >> w;
        adj[u].push_back({v, w});
    }
    for (int i = 1;i <= n;i++) dis[i] = inf;
    dis[1] = 0;
    priority_queue<pii, vector<pii>, greater<pii> > pq;    
    pq.push({0, 1});
    while (pq.size()) {
        ll d = pq.top().ff;
        int cur = pq.top().ss;
        pq.pop();
        if (d != dis[cur]) continue; //重要
        for (pii p:adj[cur]) {
            int v = p.ff, w = p.ss;
            if (d + w < dis[v]) {
                dis[v] = d + w;
                pq.push({dis[v], v});
            }
        }
    }
    for (int i = 1;i <= n;i++) cout << dis[i] << " ";
    cout << "\n";
}

最短路 Part 2.

 

最後一部分了!

Floyd-Warshall 演算法

全點對最短路

想法:利用DP紀錄所有點對之間目前最短的距離!

 

令 \(dis[i][j]\) 為點 \(i\) 到 \(j\) 的最短距離

一開始如果\((i, j)\)有連邊的話就令 \(dis[i][j] = w_{i, j}\)

如果 \(i = j\) 令 \(dis[i][j] = 0\)

否則 \(dis[i][j] = \infty\)

Floyd-Warshall 演算法

從 \(1\) 到 \(n\) 枚舉路徑上的中繼點

每次更新所有點對

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

複雜度 \(O(n^3)\)

證明?

const ll inf = 1LL<<60;
ll dis[maxn][maxn];
int main() {
    int n, m;
    cin >> n >> m;
    for (int i = 1;i <= n;i++) {
        for (int j = 1;j <= n;j++) dis[i][j] = inf;
        dis[i][i] = 0;
    }
    for (int i = 0;i < m;i++) {
        int u, v, w;
        cin >> u >> v >> w;
        dis[u][v] = min(dis[u][v], w);
        dis[v][u] = min(dis[v][u], w);
    }
    for (int k = 1;k <= n;k++) {
        for (int i = 1;i <= n;i++) {
            for (int j = 1;j <= n;j++) {
                dis[i][j] = min(dis[i][j], 
                    dis[i][k] + dis[k][j]);
            }
        }
    }
}

演算法統整

Bellman-Ford Dijkstra Floyd-Warshall
問題類型 單點源 單點源 全點對
時間複雜度  
空間複雜度
限制 邊權為正
O(nm)
O(m \log n), O(n^2)
O(n^3)
O(m)
O(m)
O(n^2)

最短路應用

多點源最短路

給一張無向帶權圖,有\(k\)個起點,對於每個點輸出他到任意一個起點的最短距離

 

\(n, m \leq 10^5, k \leq n\)

最短路應用 Pt. 2

加一條邊?

給一張無向帶權圖,有\(q\)個假想的方案,每個方案會考慮加上一條權重為 \(w_i\) 的邊 \((u_i, v_i)\),問加上那條邊之後最短路距離。

(不會真的加邊,也就是說每個方案彼此獨立)

 

\(n, m, q \leq 10^5\)

題目

謝謝大家><

希望大部分有聽懂w

記得要做練習題喔

基礎圖論

By justinlai2003