圖論

 

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);
	}
}
  • 求樹上最遠兩點的距離
  • 因為最遠距離必定有一端在根節點的最遠點
  • 所以做兩次 DFS
    • 第一次找到與根節點最遠的點
    • 再從該點出發
  • 可以注意到對 \(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\)
  • 實作時可以維護目前為止的 \(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