基礎圖論
北一女中 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 的應用
爆搜!
習題
二分圖
定義
一個圖\(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的時候:
- 把\(a\)跟\(b\)裡面較深的往上走到相同的深度
- 如果此時\(a = b\),LCA就是\(a\)
- 用前面的方法,\(k\)由大到小,如果\(a\)和\(b\)的\(2^k\)倍祖先不同的話就往上走。
- 最後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的性質
並查集 (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 (戴克斯特拉) 演算法
很重要 一定要念對
- 維護一個 heap (priority_queue),裡面存著點與該點找到的最短距離 \(cur, dis\)。
- 每次取出距離最小的點,此時如果該點未被處理過,則\(dis\)為該點的最短距離,進行步驟 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 | |
---|---|---|---|
問題類型 | 單點源 | 單點源 | 全點對 |
時間複雜度 | |||
空間複雜度 | |||
限制 | 無 | 邊權為正 | 無 |
最短路應用
多點源最短路
給一張無向帶權圖,有\(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
基礎圖論
- 1,347