圖論

建國中學 張均豪

圖?

一張圖=一些點+一些邊

1

2

4

5

3

​有時點上或邊上會有權重

1

2

4

5

3

5

3

4

7

9

有向圖/無向圖

1

2

4

5

3

1

2

4

5

3

簡單圖

2

2

3

1

1

沒有自環或重邊的圖,

稱為簡單圖

自環

重邊

度數/相鄰

若兩點之間有連邊,則稱他們相鄰

一個點的度數為該點連出的邊數

路徑

1

2

4

5

3

簡單路徑:不經過重複點的路徑

1

2

4

5

3

起終點相同的路徑

連通/連通分量

1

2

4

5

3

6

弱/強連通/強連通分量

1

2

4

5

3

6

子圖

1

2

4

5

3

1

2

4

3

補圖

1

2

4

3

1

2

4

3

完全圖

1

2

4

3

樹/森林

1

2

4

3

5

7

6

8

9

沒有環的連通圖稱為樹,森林就是很多棵樹

二分圖

1

2

4

3

7

6

5

把一張圖分為兩部分,使兩部分內沒有邊連接

有向無環圖(DAG)

1

2

4

3

7

6

5

稀疏圖/稠密圖

  • 稀疏:邊很少(跟點數差不多)
  • 稠密:邊很多(點數的平方)

圖的儲存/遍歷

鄰接矩陣

G[i][j]=1
G[i][j]=0

=>

=>

i, j

之間有連邊

i, j

之間沒有連邊

1

2

4

5

3

=>

\begin{bmatrix} 0&1&0&1&0 \\ 1&0&1&1&1 \\ 0&1&0&0&0 \\ 1&1&0&0&0 \\ 0&1&0&0&0 \end{bmatrix}

扣的

bool G[MAXN][MAXN];
int main(){
    int n,m;
    cin>>n>>m;
    for(int i=0;i<m;i++){
        int a,b;
        cin>>a>>b;
        G[a][b]=1;
        G[b][a]=1;
    }
}

鄰接串列

把與每個點相鄰的點記錄起來

1

2

4

5

3

=>

1:2,4
2:1,3,4,5
3:2
5:2
4:1,2

扣的++

vector<int> G[MAXN];
int main(){
    int n,m;
    cin>>n>>m;
    for(int i=0;i<m;i++){
        int a,b;
        cin>>a>>b;
        G[a].push_back(b);
        G[b].push_back(a);
    }
}

哪個比較好?

  • 鄰接矩陣用於稠密圖
  • 鄰接串列用於稀疏圖
  • 大部分題目的圖都是稀疏圖
  • 結論: 用鄰接串列

註: 還有其他存圖方式,只不過很少用

圖的遍歷-深度優先搜尋(DFS)

  • 走到一個點
  • 看他可以走到哪些點
  • 把那些點都走完
  • 離開

圖的遍歷-深度優先搜尋(DFS)

vector<int> G[MAXN];
bool visited[MAXN];
void dfs(int x){
    visited[x]=true;
    for(auto i:G[x]){
        if(!visited[i]) dfs(i);
    }
}
int main(){
    for(int i=0;i<n;i++){
        if(!visited[i]) dfs(i);
    }
}

用遞迴實作

記得要把所有連通分量都跑一次

圖的遍歷-廣度優先搜尋(BFS)

  • 先開一個queue
  • 從queue前面拿出一個點
  • 看他可以走到哪些點
  • 把那些點丟進queue裡
  • 離開

圖的遍歷-廣度優先搜尋(BFS)

vector<int> G[MAXN];
queue<int> que;
bool visited[MAXN];
void bfs(int x){
    que.push(x);
    visited[x]=1;
    while(!que.empty()){
        int now=que.front();
        que.pop();
        for(auto i:G[now]){
            if(!visited[i]){
                visited[i]=1;
                que.push(i);
            }
        }
    }
}

特性: 可以維護起點到所有點的最短距離

TIOJ 1336

給一個                 的照片,- 代表空地,G 代表綠地,W 代表河流或湖泊,B 代表建築物。如果有兩格八方位相鄰的綠地,那麼兩格綠地會被計算為同一塊綠地。同樣地,八方位相鄰的空地也會被視為同一塊空地。現在需要知道城市中究竟有多少塊綠地和空地。

H\times W

TIOJ 1085

給定一個立體(x * y * z)的迷宮,某人自(1,1,1)走至(x,y,z),請求出一條最短路徑,若有多組解,任一組都可。

TIOJ 1209

給定多張無向圖,對於每張圖,若該圖是二分圖,請輸出Yes,否則輸出No。

(1\leq |V| \leq 40,000; 0 \leq |E| \leq 500,000)

名詞介紹

1

2

4

3

5

7

6

8

9

樹根

父節點

子節點

樹葉

子樹

No judge

給定一棵有根樹,求每個點

(1)到根節點的距離

(2)以該點為根的子樹大小

vector<int> G[MAXN];
int dis[MAXN], sz[MAXN];
void dfs(int x, int f){
    dis[x]=dis[f]+1;
    sz[x]=1;
    for(auto i:G[x]){
        if(i!=f){
            dfs(i,x);
            sz[x]+=sz[i];
        }
    }
}

樹直徑

一棵樹上最長的簡單路徑

做法:先從任意一點DFS到離自己最遠的點v,

再從點v DFS到離他最遠的點u,

 則u,v之間的簡單路徑即為樹直徑 

樹重心

樹上的一個點,如果將這點當作整棵樹的根,就可以使根節點最大的子樹最小。

可以證明該最大子樹大小不超過N/2(N為頂點數),且樹重心最多兩個,且若有兩個則他們必為鄰居。

做法:先從任意一點開始DFS,

如果找到一個子樹大小超過N/2,就往那個子樹走,

走到無法再走(所有子樹大小都<=N/2)時,

即找到樹重心。

DAG與拓樸排序

當我們想要在DAG上DP

每個點代表一個狀態

有向邊代表可能的轉移方式

拓樸排序則代表一種DP可行的計算順序

做法:紀錄每個點的入度,把所有入度為0的點拔掉,

在更新每個點的入度,重複直到點被拔光為止。

int deg[MAXN];
queue<int> que;
vector<int> G[MAXN], order;
void topological_sort(){
    for(int i=1;i<=n;i++){
        for(auto j:G[i]) deg[j]++;
    }
    for(int u=1;i<=n;i++){
        if(deg[i]==0) que.push(i);
    }
    while(!que.empty()){
        int tmp=que.front();
        que.pop();
        order.push_back(tmp);
        for(auto i:G[tmp]){
            deg[i]--;
            if(deg[i]==0) que.push(i);
        }
    }
}

並查集

簡介

並查集是一個資料結構,它可以:

(1)查詢一元素所在的集合

(2)把兩個集合合併成一個

用於圖論中,它可以:

(1)查詢一元素位於的連通塊

(2)把兩個連通塊合併成一個

作法

對每個元素記錄自己的"上級",一開始先設為自己

查詢時,一直往上級尋找,直到停下來,

此時稱為找到"代表元素"

合併時,對兩個的元素分別找到"代表元素"後,

將其中一個的上級設為另一個

優化

路徑壓縮: 在找到代表元素後,直接將上級設為它

啟發式合併:合併時,把小集合併進大集合裡

複雜度:            

成長速度極慢,可視為常數

O(\alpha (n))
int dsu[MAXN];
void init(int n){
    for(int i=1;i<=n;i++){
        dsu[i]=i;
    }
}
int query(int x){
    if(x==dsu[x]) return x;
    int tmp=query(dsu[x]);
    dsu[x]=tmp;
    return tmp;
}
void union(int x, int y){
    int a=query(x), b=query(y);
    dsu[a]=b;
}

最短路徑

單點源最短路徑

給定一張帶權圖,求某一點到所有點的最短路徑

前提:不能有負環

鬆弛(Relaxation)

假如起點到點         的距離分別為

且                    

就可以把     更新為       

d_u, d_v
u, v
d_u + w_{u,v} < d_v
d_v
d_u + w_{u,v}

Bellman-Ford Algorithm

枚舉所有邊進行鬆弛,鬆弛             次

時間複雜度:           

O(VE)
V-1
vector<pii> G[MAXN];
int dis[MAXN];
void Bellman_Ford(){
    dis[1]=0;
    for(int i=2;i<=n;i++) dis[i]=INF;
    for(int i=0;i<n-1;i++){
        for(int j=0;j<m;j++){
            int a = edge[j].F, b = edge[j].S;
            if(dis[a]!=INF && dis[a]+c[j]<dis[b]){
                dis[b]=dis[a]+c[j];
            }
        }
    }
}

SPFA

剛剛好像有點慢(?

優化:每次只枚舉有被鬆弛到的點的邊,

期望複雜度:               

最差複雜度:               

O(VE)
O(V+E)

還要更快?

如果沒有負邊的話...

最短路徑樹

子節點的距離一定不比父節點小

Dijkstra's Algorithm

每次選取一個不在樹上且距離最近的點,

加到樹上來鬆弛其他點,

用 priority_queue 維護!

複雜度                                   

O((E+V)logE)
vector<pii> G[MAXN];
priority_queue <pii, vector<pii>, greater<pii> > pq;
void Dijkstra(){
    fill(dis,dis+n+1,INF);
    dis[1]=0;
    pq.push({0,1});
    pii cur;
    for(int i=0;i<n;i++){
        do{
            cur=pq.top();
            pq.pop();
        } while(!pq.empty()&&cur.F>dis[cur.S]);
        for(auto i:G[cur.S]){
            if(dis[i.F] > cur.F+i.S){
                dis[i.F]=cur.F+i.S;
                pq.push({dis[i.F], i.F});
            }
        }
    }
}

全點對最短路徑

一條路徑除了起終點,其他都是中繼點

Floyd-Warshall Algorithm

dp[k][i][j]=由第 i 點前往第 j 點,使用前 k 點的最短路徑

dp[k][i][j]=min(dp[k-1][i][j], dp[k-1][i][k]+dp[k-1][k][j])

複雜度:              

O(V^3)
int dis[MAXN][MAXN];
void Floyd_Warshall(){
    for(int i=1;i<=n;i++){
        for(int j=1;j<=n;j++){
            if(i==j) dis[i][j]=0;
            else dis[i][j]=INF;
        }
    }
    //cin
    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]);
            }
        }
    }
}

OJDL7010、7042、7043

題敘略

TIOJ 1641

有                      個城市、                            條連接兩城市的單向道。你有一單位的貨物要從 A 運到 B,但是每經過一條道路後,你必須要多帶原先      倍的貨物,其中      是該條道路的「方便率」。求到達終點時你最少會有多少單位的貨物。

N \leq 10^4
M \leq 2\times 10^5
C_i
C_i

TIOJ 2049(極挑戰)

有                               個城市、                            條連接兩城市的雙向道。給定起終點S、T,求若拔掉一個點後,S到T的最短路徑最大會是多少。

N \leq 3\times 10^5
M \leq 3\times 10^5

最小生成樹

定義

對於所有連通圖,必定存在一個子圖頂點數與原圖相同,

且是一棵樹,稱為生成樹。

邊權總和最小的生成樹即稱為最小生成樹

性質

當我們要合併兩個最小生成樹

使用連接兩樹權重最小的邊一定會最好

Kruskal's Algorithm

每次找當前權重最小的邊,

看他是否連接兩個不同的最小生成樹,

是的話就代表需要用到這條邊,

並且把兩棵樹合併起來。

還記得並查集嗎?

int Kruskal(){
    sort(edge, edge+m, cmp);
    int ans=0;
    for(int i=0;i<m;i++){
        if(query(edge[i].a)!=query(edge[i].b)){
            ans+=edge[i].c;
            union(edge[i].a, edge[i].b);
        }
    }
    return ans;
}

Prim's Algorithm

跟 Dijkstra 概念類似

將"與起點的距離"換成"與當前最小生成樹的距離"

TIOJ 1211

最小生成樹練習

LCA

Lowest Common Ancestor

1

2

4

3

5

7

6

8

9

LCA(5,6)=1

LCA(7,8)=4

LCA(3,9)=3

做法#1:倍增法

還記得我上禮拜講的sparse table 嗎?

\(anc[i][j]\) 代表點 \(i\) 的 \(2^j\) 輩祖

然後就可以得到

\(anc[i][j]=anc[anc[i][j-1]][j-1]\)

先對所有的\(i, j\)找到\(anc[i][j]\)

那這樣可以幹嘛呢

加快「一格一格爬上去」的過程!

那這樣可以幹嘛呢

首先,把深度比較深的那個點往上移到兩個點深度相同。(兩點LCA必只有一個深度)

 

那這樣可以幹嘛呢

如果此時\(a == b\)(也就是\(a\)是\(b\)的祖先,則直接回傳

 

那這樣可以幹嘛呢

再者,從最大的長度開始跑,如果\(anc[i][a] != anc[i][b]\),則LCA必定在\(anc[i][a]\)的祖先

 

那這樣可以幹嘛呢

最後,我們會走到兩個點\(a, b\),使得\(a != b, anc[0][a] == anc[0][b]\),因此根據定義,\(anc[0][a]\)就是LCA

 

int anc[20][MAXN], dep[MAXN];
void dfs(int x, int f, int d){
    anc[0][x]=f;
    dep[x]=d;
    for(auto i:G[x]){
        if(x!=f){
            dfs(i,x,d+1);
        }
    }
}
void init(){
    dfs(1,0,1);
    for(int j=1;j<20;j++){
        for(int i=1;i<=n;i++){
            anc[j][i]=anc[j-1][anc[i][j-1]];
        }
    }
}
int find_lca(int a, int b){
    if(dep[a]<dep[b]) swap(a,b);
    int k=dep[a]-dep[b];
    for(int c=0;k>0;c++){
        if(k%2){
            a=anc[c][a];
        }
        k/=2;
    }
    for(int j==__lg(n);j>0;j--){
        if(anc[j][a]!=anc[j][b]){
            a=anc[j][a];
            b=anc[j][b];
        }
    }
    if(a!=b) return anc[a][0];
    return a;
}
Made with Slides.com