圖論
Outline
- 圖介紹
- BFS/DFS
- 歐拉路徑
- 樹的介紹
- 樹上最遠距離
- DAG (有向無環圖)
- DSU
- 樹壓平 、 LCA
- 最小生成樹
- 最短路
圖的介紹


圖的種類
- 稀疏圖
- $$|V| \approx |E|$$
- 稠密圖
- $$E| \approx |V|^2$$
- 無向圖
- 無向邊所構成的圖
- 有向圖
- 有向邊所構成的圖
- 有向無環圖 (DAG)
- 有向邊 + 無環

圖的種類
- 完全圖
- 每個點對間都有邊相連
- 二分圖
- 可把圖分成恰兩部分,使得兩部分內部無邊相連、邊只會連接兩部分


圖的儲存
Edge List
- 就按照題目要求,用一個陣列把所有邊存下來
- 缺點:
- 每條邊之間沒有關聯性,在做圖的走訪時每次要重新枚舉整個陣列
鄰接矩陣
- 建一個 \(n \times n\) 的陣列, \(e[i][j]\) 存從 \(i \rightarrow \ j\) 的邊
- 優點:
- 有表示到節點和邊的關係
- 缺點:
- 這樣空間複雜度為 \(O(n^2)\) , 在稀疏圖太慢
鄰接陣列
- 改善鄰接矩陣,因為稀疏圖實際上邊數很少
- 所以改成開 \(n\) 個 vector 去存每個點 \(i\) 連出去的邊
vector<int> v[N];
vector<pair<int,int> > e[N];
void add_edge(int a,int b){
v[a].eb(b);
}
void add_edge(int a,int b,int w) {
e[a].eb(mp(b,w));
}圖的遍歷
深度優先搜索 (DFS)
- 和人走路相同,
- 會持續從一個點往下走直到沒路可走
- 實作時通常直接用遞迴

void dfs(int x){
vis[x]=true;
for(int i:v[x]){
if(!vis[i]) dfs(i);
}
}廣度優先搜索 (BFS)
- 他是再一個點時往他所有相鄰的點走
- 實作通常是使用 queue

void bfs(int s){
queue<int> q;
q.push(s);
while(!q.empty()){
int x=q.front();q.pop();
for(int i:v[x]) {
if(!vis[i]){
vis[i]=true;
q.push(i);
}
}
}
}- 判斷給定的圖是不是二分圖
- 二分圖定義是可以分成兩堆內部無邊的點集
- 可以把點上色,這樣只要有邊的顏色必定要不同
- DFS、BFS都可做
歐拉路徑
歐拉路徑
- 也稱作一筆畫問題
- 問是否可以在不走重複邊的情況下走完所有邊
- 並構造一組解
歐拉路徑
- 有向圖歐拉路徑存在條件
- 除起終點外其餘點的入度=出度,起點出度多1,終點入度多1
- 無向圖歐拉路徑存在條件
- 至多兩個奇點(度數為奇數)(其實只有 0/2)
- 0個奇點稱為歐拉迴路
歐拉路徑
- 對邊 DFS
- 實作上還是點枚舉
- 離開一條邊時加入答案
- 最終再把答案反轉
歐拉路徑
- 可注意到因為是對邊 DFS
- 因此一條邊遍歷後要從該點的陣列刪除
- 或是可以維護一個標記 \(in[i]\) 代表節點 \(i\) 已經枚舉到哪
- 求字典序最小的歐拉路徑
- 求字典序最小的歐拉路徑
- 只要DFS 時把邊按照字典序排好即可
大家來找碴


wiwihorz 讀書會圖論I簡報的 code
大家來找碴

wiwihorz 讀書會圖論I簡報的 code
- 前面有說過必須避免重複遍歷已走過的邊
- 若遇到恰兩個點互連爆多邊就會噴到 \(O(nm)\)
void dfs(int x){
for(int &p=it[x];p<SZ(e[x]);p++){
if(!used[e[x][p].S]) {
used[e[x][p].S]=1;
dfs(e[x][p].F);
}
}
ans.eb(x);
}一種正確寫法
樹
樹
- 圖論中的一種特例
- 由恰 \(n-1\) 條無向邊所組成的聯通圖

樹的遍歷
- 通常用 DFS
- 不同根節點會建出不同樹
- 可以注意到一個點會恰有一個祖先
- 其他為子節點
- 所以DFS 只需判斷該點是不是祖先
void dfs(int x,int p=-1,int d=0){
dep[x]=d;
for(int i:v[x]){
if(i==p) continue;
dfs(i,x,d+1);
}
}b967. 第 4 題 血緣關係 (樹直徑)
- 求樹上最遠兩點的距離
- 因為最遠距離必定有一端在根節點的最遠點
- 所以做兩次 DFS
- 第一次找到與根節點最遠的點
- 再從該點出發
b967. 第 4 題 血緣關係 (樹DP)
- 可以注意到對 \(x\) 子樹來說距離只有兩種情況
- 通過\(x\) 的
- 橫跨\(x\)某一子樹
- 維護 \(dis[x] , maxdep[x]\) 分別代表 \(x\) 內的最遠距離、距離 \(x\) 的最遠點(最深)
- \(dis[x]=\max_{c_1,c_2 \in x}(dis[x],maxdep[c_1]+max_dep[c_2]+2\)
b967. 第 4 題 血緣關係 (樹DP)
- 實作時可以維護目前為止的 \(maxdep[c_1]\)
- 所以不需同時枚舉點對 \(c_1,c_2\)
int dis[N],maxdep[x];
void dfs(int x,int p=-1){
maxdep[x]=dis[x]=0;
for(int i:v[x]){
if(i!=p){
dfs(i,x);
dis[x]=max(dis[x],max(dis[i],maxdep[x]+maxdep[i]+1));
maxdep[x]=max(maxdep[x],maxdep[i]+1);
}
}
}有向無環圖 (DAG)
有向無環圖
- 每條邊有方向、沒有出現環

拓撲排序
- 要怎麼好的遍歷 DAG
- 走訪一個點之前指向他的邊一定要先走訪
- 因為保證有向無環,必定有點可以先走訪
- 沒有被任何人指到的可以先走訪
- 拔掉該點後一樣是 DAG
拓撲排序

void toplogical sort(){
queue<int> q;
for(int i=1;i<=n;i++) if(!deg[i]) q.push(i);
while(q.size()){
int x=q.front();
q.pop();
for(auto [i,w]:v[x]){
deg[i]--;
if(!deg[i]) q.push(i);
}
}
}DAG dp
a454. TOI2010 第二題:專案時程
-
每個任務 \(x\) 有其花費天數、也有一些順序關係。他能開始做只有當連向 \(x\) 的所有任務都完成了

- 令 \(dp[x]\)代表 \(x\) 完成的天數
- \(dp[x]=max\{dp[i]+w\}(i,x,w)∈edge\)
- 按照拓撲排序順序轉移就好
void toplogical sort(){
queue<int> q;
for(int i=1;i<=n;i++) if(!deg[i]) q.push(i);
while(q.size()){
int x=q.front();
q.pop();
for(auto [i,w]:v[x]){
dp[i]=max(dp[i],dp[x]+w);
deg[i]--;
if(!deg[i]) q.push(i);
}
}
}並查集
並查集
-
一種資料結構可以進行兩種操作
- 詢問元素所在的集合
- 把兩集合合併
這裡的集合在圖論上被稱為連通塊
- 朋友的朋友也是朋友
給定\(M\)筆朋友關係,和\(Q\)筆詢問,問\(A,B\)是不是朋友。
並查集
- 把連通關係當作一顆樹,根節點就是最高層的祖先
- 同一棵樹代表在同一連通塊
- 初始每個人都是一棵樹
- 合併時就等價兩棵樹合併
並查集


並查集
- 既然是兩顆樹合併,直接拿根節點合併即可
- 有兩個優化
- 路徑壓縮
- 啟發式合併
- 用其中一個單次時間複雜度變成 \(O(\log{n})\)
- 兩者都用變成 \(O(\alpha(n)) \sim O(1)\)
並查集
- 路徑壓縮
- 因為你只在乎誰是樹根,尋找時也是要找樹根
- 找到後直接把尋找路段壓縮起來


並查集
- 啟發式合併
- 兩樹合併時用小的指向大的
- 也可以用樹高低的指向大的


並查集
void init(int n){
for(int i=1;i<=n;i++) p[i]=i,sz[i]=1;
}
int fp(int x){
if(x!=p[x]) p[x]=fp(p[x]);//路徑壓縮
return p[x];
}
void Union(int a,int b){
a=fp(a);//找到連通塊的祖先
b=fp(b);
if(a!=b){
if(sz[a]<sz[b]){
swap(a,b);//確保a是數量較大的
}
p[b]=a;//把b指向a
sz[a]+=sz[b];
}
}- 一樣有 CF EDU 教學和習題可練
樹壓平
樹壓平
- 把樹變成序列,維護進入和離開時間戳記
- \(in_i,out_i\)
- 有以下性質
- \([in_x,out_x]\) 表示 \(x\) 子樹
樹壓平

- 時間序列: [1,2,6,6,5,5,2,4,4,3,3,1]
int in[N],out[N];
int t=1;
void dfs(int x,int p=-1){
in[x]=t++; // 進入的時間戳記
for(int i:v[x]) {
if(i!=p) dfs(i,x);
}
out[x]=t++; // 離開的時間戳記
}樹壓平
- 有了時間序列就可以判祖孫關係
- 剛剛提到在 \([in[x],out[x]\) 內出現的是 \(x\) 子樹,
- 也就是 \(x\) 的子孫
bool isanc(int a,int b){
return in[a]<=in[b]&&out[b]<=out[a];
}最低共同祖先(LCA)
LCA
- \(O(\log{n})\) 求任意兩點\(a,b\)在樹上的最小共同祖先
- \(LCA(5,6)=2\)
- \(LCA(3,6)=1\)

LCA
- 假設 \(LCA(a,b)=w\)
- 那 \(w\) 會是 \(a\) 的 \(d_a\) 層祖先
- 對 \(a\) \(1 \sim d_a-1\) 都不是 \(b\) 祖先
- \(d_a \sim \inf \) 都是 \(b\) 祖先
- 有二分搜性質
- 可以把 \(d_a\) 二進位分解
LCA 倍增法
- 既然要二進為分解,代表需要知道 \(2\) 的冪次層祖先
- 倍增
void build(){
for(int i=1;i<=K;i++){
for(int j=1;j<=n;j++) ac[i][j]=ac[i-1][ac[i-1][j]];
}
}LCA
- 二進位分解後就依序看每一個 \(bit\) 要是 \( 1/0 \)
int LCA(int a,int b){
if(isanc(a,b)) return a;
if(isanc(b,a)) return b;
for(int i=K;i>=0;i--){//跳到洽 $k_1-1$ 層的位置
if(!isanc(ac[i][a],b)) a=ac[i][a];
}
return ac[0][a];
}LCA
int in[N],out[N];
int t=1;
void dfs(int x,int p=-1){
in[x]=t++; // 進入的時間戳記
if(p!=-1) ac[0][x]=p;
else ac[0][x]=x; // 預處理父親
for(int i:v[x]) {
if(i!=p) dfs(i,x);
}
out[x]=t++; // 離開的時間戳記
}
void build(){
for(int i=1;i<=K;i++){
for(int j=1;j<=n;j++) ac[i][j]=ac[i-1][ac[i-1][j]];
}
}
bool isanc(int a,int b){//判斷 a 是否為 b 的祖先
return in[a]<=in[b]&&out[b]<=out[a];
}
int LCA(int a,int b){
if(isanc(a,b)) return a;
if(isanc(b,a)) return b;
for(int i=K;i>=0;i--){//跳到洽 $k_1-1$ 層的位置
if(!isanc(ac[i][a],b)) a=ac[i][a];
}
return ac[0][a];
}LCA
- 有了 \(LCA\) 可以幹嘛
- 樹上兩點距離會恰巧通過 \(LCA(a,b)\)
- \(dis(a,b)=dis(a,LCA(a,b))+dis(LCA(a,b),b)\)
- 可以透過建深度陣列求出距離
最小生成樹
最小生成樹
- 給一堆邊,求權重總和最小的樹
-
同時也會是權重最大值最小的樹
Kruskal’s algorithm
- 把邊按照權重由小到大排序
- 依序確認這條邊是否可加入(是否不形成環)
- 最終即可得到答案

Kruskal’s algorithm
- 把邊權由小到大排序
- 一條邊要加入等價會合併兩聯通塊
- 並查集
Kruskal’s algorithm
DSU s; //並查集
void Kruskal(vector<edge> e){
sort(e.begin(),e.end(),cmp);//按照邊權由小到大排
int sum=0;
for(edge ei:e){
if(!s.same(ei.a,ei.b)) s.union(ei.a,ei.b),sum+=ei.w;
}
}Kruskal’s algorithm
- 證明:
- 假設目前找到的為 \(T\) ,權重更小者為 \(T^*\)
- \(T^*\) 按照 Kruskal 加邊順序第一條\(T\) 沒有的邊 \(e\)
- 那考慮把這條邊加上去,那會形成還需要再拔一條邊 \(e^*\)
- 但因為是按照邊權由小到大,所以 \(w(e^*)<w(e) \)
- 故與假設矛盾
- 出現邊權最小的生成樹證明也相同
裏表次元 (Dimension) (校內賽pF)

裏表次元 (Dimension) (校內賽pF)
- 題目等價有一張圖和 \(Q\) 次詢問
- 每次詢問從 \(s \rightarrow t\) 經過的最大邊最小可能是多少
裏表次元 (Dimension) (校內賽pF)
- 給定的是圖,但可以簡化成樹
- 最小生成樹
- 之後題目剩下要怎麼快速求兩點路徑上最大邊
- 倍增、LCA
最短路
最短路
- Dijkistra
- Bellman-ford
- Floyd-Washall
- 給你 \(n\) 點 \(m\) 邊的正權圖
- 求 \(1\) 到所有點的最短距離
- \(1 \leq n \leq 10^5\)
- \(1 \leq m \leq 2 \cdot 10^5\)
- 考慮 BFS
- 當你 BFS 時依序更新路 ...
- 但問題是可能會重複 鬆弛 (後來才該走的路先走了)
- 鬆弛 (relax) : 對節點 \(v\) 更新他指向的所有節點 \(u\)
- 時間複雜度會噴到 \(O(nm)\)
- 但假如可以定一個走的順序就只用走一次
Dijkistra
- BFS 時的問題是可能後來反而距離較小
- 所以如果可以保證每次都走距離小的就好了
- queue \(\rightarrow\) priority_queue
- 按照當前到所有點的距離,每次走最小的更新
Dijkistra

Dijkistra
- 實作時和 BFS Code 大致相同,改成 priority_queue 就好
typedef pair<int,int> pii;
void dijkistra(int s,int t){
priority_queue<pii,vector<pii>,greater<pii>> pq;
pq.push(mp(0,s));
fill(dis,dis+N,INF);
dis[s]=0;
while(pq.size()){
pii now=pq.top();
pq.pop();
for(pii p2:v[now.S]){
if(dis[p2.F]>dis[now.S]+p2.S) {
dis[p2.F]=dis[now.S]+p2.S;
pq.push(mp(dis[p2.F],p2.F));
}
}
}
}Dijkistra
- 每條邊只會走一次、每個點也只會進入一次
- 時間複雜度是 \(O(|V|+|E|\log{|V|})\)
Dijkistra
- 那如果有負權呢 ?
- 允許重複入隊
typedef pair<int,int> pii;
void dijkistra(int s,int t){
priority_queue<pii,vector<pii>,greater<pii>> pq;
pq.push(mp(0,s));
fill(dis,dis+N,INF);
dis[s]=0;
while(pq.size()){
pii now=pq.top();
pq.pop();
if(dis[now.S]<now.F) continue; //允許重複入隊寫法
for(pii p2:v[now.S]){
if(dis[p2.F]>dis[now.S]+p2.S) {
dis[p2.F]=dis[now.S]+p2.S;
pq.push(mp(dis[p2.F],p2.F));
}
}
}
}Dijkistra

- 可判負環的時間複雜度不是好的
Bellman-Ford 算法
- relax 一次不夠,就 relax 很多次
- 一條最短路必定是至多走\(n-1\) 條邊
- 因此只要 \(n-1\) 次 relax 即可
- 時間複雜度為 \(O(nm)\)
SPFA 算法
- 可注意到不一定每次都要 relax 所有點
- 第 \(t-1\) 次沒被 relax , 第 \(t\) 次必定不會
- code 和前面重複入隊 Dijkistra 很像,只是又把 priority_queue \(\rightarrow\) queue
- 期望時間複雜度 \(O(|V|+|E|)\)
- 但最差可能和 Bellman-ford 一樣 \(O(nm)\)
但師大測資一定可以
SPFA 算法

wiwihorz 暑培圖論II簡報的 code
- 給 \(n\) 點 \(m\) 邊的有向有權圖
- 輸出 \(n\) 行,其中第 \(i\) 行包含一個整數,表示從節點 \(1\) 走到節點 \(i\) 的最短路徑長,如果無法走到 \(i\),輸出 QwQ;如果從節點 \(1\) 到 \(i\) 的路徑長可以任意小,輸出 OAO。
- 只要 \(n-1\) 次 relax 即可
- 因此只要第 \(n\) 次還被 relax 到,代表負環可走到該點
- 負環上的點也都會在 \(n\) 次被 relax
- 因此只要從這些點做 BFS 即可

wiwihorz 暑培圖論II簡報的 code

wiwihorz 暑培圖論II簡報的 code
- 給 \(n\) 點 \(m\) 邊的有向有權圖
- 求圖上任一個負環
- 前面講的是判有經過起始點的環的作法
- 唯一與起始點 \(s\) 相關是 \(dis[s]:=0\)
- 但這次只需要判負環,只在乎是否會 relax \(n\) 次
- 把 \(dis[s]\) 丟掉
- 一樣 relax \(n\) 次,找到與負環相通的點
- 從該點倒退走 \(n\) 次後,必在負環上
- 再從該點倒退走找負環
Floyd-Washall 算法
- 求全點對最短距離算法
- 其實是一種 \(dp\)
- 假設有經過點 \(k\)
- \(dis[l][r]=dis[l][k]+dis[k][r]\)
Floyd-Warshall 算法
- \(dis[l][r]=dis[l][k]+dis[k][r]\)
- 先枚舉轉移點 \(k\) , 內部再枚舉距離兩端
- 等價是做當轉移點限用 \(1 \sim k\) 情況下的最短距離
- 迴圈順序錯誤只要跑 3 次就會對
void FloydWarshall(){
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][k]+dis[k][j]);
}
}
}
}基礎圖論
By yuhung94
基礎圖論
- 255