Graph Theory

Lecturer: 22527 Brine

我是誰

22527 鄭竣陽
Brine
BrineTW#7355
  • 建中資訊參伍 學術長
  • 建中電研肆貳 學術
  • 地科讀書會 正則人
  • 為了排版走火入魔
  • 吃語法糖吃到蛀牙
  • 想學圖論所以當圖論講師
  • 絕對會忘記你是誰
  • 絕對不記得摯友有誰
  • 絕對不會放棄你除非你很機掰
Index[0]
Index[1]
Index[2]
Index[3]

警告!

  • 這次的講師很弱
    • 在講的時候可能不太會表達
    • 也有可能自己觀念有點奇怪
    • 在寫範例程式碼時可能疏忽
  • 如果上課時有不理解的地方,可以跟講師說
    • 他會努力講到讓你聽懂
  • 回家寫題目遇到問題可以丟到讀書會伺服器或是私訊講師

參考資料

Glossary

圖論的基本名詞

什麼是「圖」

  • 唯利是圖

什麼是「圖」

  • 唯利是圖
1
2
0
5
3
4

所以什麼是圖?

圖的表示

  • 頂點(vertex / vertices)
    • 常用 \(v\) 來表示
    • 頂點的集合為 \(V\)
  • 邊(edge)
    • 常用 \( e = (u,\ v) \) 表示
    • 邊的集合為 \(E\)
  • 圖(graph)
    • 圖就是點和邊的集合
    • 通常用 \( G(V,\ E) \) 表示

邊的屬性

  • 權重(weight)
  • 方向(direction)
    • 有向/無向(directed / undirected)
    • 如果 \((u,\ v)\) 和 \((v,\ u)\) 所代表的是不同的邊,則說這兩個邊有向
  • 自環(loop)
    • 起點終點相同的邊,即 \((v, v)\)
  • 重邊(parallel edges)
    • 兩個一模一樣的邊

點的屬性

  • 權重(weight)
  • 度(degree)
    • 一個點連到的邊的數量
    • 入度/出度(indegree / outdegree)
      • 在有向圖之中分為進去和出去的邊

路徑相關

  • 路徑(path)
    • 一個頂點和邊交錯的序列,每條邊都要有連到左右兩個點
    • 以 \((v_{start},\ e_1,\ v_1,\ e_2,\ v_2,...,\ e_n,\ v_{end})\) 表示
    • 行跡(trace)
      • 沒有重複的路徑
    • 簡單路徑(track)
      • 沒有重複頂點的路徑
    • 迴路(circuit)
      • 起點和終點為相同頂點的路徑
      • 環(cycle)
        • 只有起點和終點為相同頂點的迴路

路徑

0
1
3
2
4
7
6
5

行跡

0
1
3
2
4
7
6
5

簡單路徑

0
1
3
2
4
7
6
5

迴路

0
1
3
2
4
7
6
5

0
1
3
2
4
7
6
5

無向圖/有向圖

  • 只有無向邊或有向邊
0
1
3
2
4
0
1
3
2
4

帶點權/帶邊權

  • 也可以都帶(?
0
5
1
7
2
1
3
2
4
2
0
1
3
2
4
9
8
4
7

簡單圖 Simple Graph

  • 沒有重邊
  • 沒有自環
0
1
3
2
4
0
1
3
2
4

連通圖 Connected Graph

  • 如果所有點都是無向邊是否所有點都可以到任意一個點
0
1
3
2
4
0
1
3
2
4

弱連通/強連通 WC/SC

  • weakly / strongly connected
  • 是否要變成無向圖才能所有點都能抵達其他點
0
1
3
2
4
0
1
3
2
4

完全圖 Complete Graph

  • 每個可能的邊都存在的簡單圖
0
1
3
2

子圖 Subgraph

0
1
3
2
\forall v_i, e_i \in G_{sub}(V,\ E),\ v_i, e_i \in G(V,\ E)
  • 若某圖的所有邊和點都屬於另一圖,則其為該圖之子圖
0
1
3
2

補圖 Complement

0
1
3
2
  • 兩圖點集相同
  • 邊集無交集且聯集等於所有可能的邊
0
1
3
2

Tree

  • 沒有環的無向連通圖
0
1
3
2
4
  • 會有多少邊?
  • 森林 forest
7
6
5

二分圖 Bipartite Graph

  • 可以分為兩個點集,沒有連接兩點集內點的邊
0
1
3
2
4
7
6
5
0
1
3
2
4
7
6
5

有向無環圖 DAG

  • directed acyclic graph
0
1
3
2
4

稀疏圖/稠密圖

|E|\le|V|\log|V|
|V|\log|V| \le |E| \le |V|^2
  • sparse / dense

例題

Graph Storage

圖的儲存

鄰接矩陣 Adjacency Matrix

  • 將所有邊的可能用 \(\mathcal{O}(|V|^2)\) 的陣列表示
G[i][j] = \begin{cases} 1,\ if\ (i, j) \in E \\ 0,\ if\ (i, j) \notin E \end{cases}
  • 將所有邊的可能用 \(\mathcal{O}(|V|^2)\) 的陣列表示
G[i][j] = \begin{cases} 1,\ if\ (i, j) \in E \\ 0,\ if\ (i, j) \notin E \end{cases}
0
1
3
2
4
\longrightarrow \begin{bmatrix} 0 & 1 & 0 & 0 & 0\\ 1 & 0 & 1 & 1 & 0\\ 0 & 1 & 0 & 1 & 1\\ 0 & 1 & 1 & 0 & 0\\ 0 & 0 & 1 & 0 & 0 \end{bmatrix}

鄰接矩陣 Adjacency Matrix

鄰接矩陣實作

  • 之後前幾行可能會被我省略掉喔
int main() {
    int vertexCount, edgeCount;
    cin >> vertexCount >> edgeCount;

    vector< vector<bool> > graph;
    graph.assign(vertexCount, vector<bool>(vertexCount));

    int u, v;
    for (int i = 0; i < edgeCount; i++) {
        cin >> u >> v;
        graph[u][v] = graph[v][u] = 1;
    }
}

鄰接串列 Adjacency List

  • 將每個點有連到的點用記錄在一個清單上

鄰接串列 Adjacency List

  • 將每個點有連到的點用記錄在一個清單上
0
1
3
2
4
0:\text{\{1\}}
1:\text{\{0, 2, 3\}}
2:\text{\{1, 3, 4\}}
3:\text{\{1, 2\}}
4:\text{\{2\}}
\longrightarrow

空間複雜度?

鄰接串列實作

int main() {
    int vertexCount, edgeCount;
    cin >> vertexCount >> edgeCount;

    vector< vector<int> > graph(vertexCount);

    int u, v;
    for (int i = 0; i < edgeCount; i++) {
        cin >> u >> v;
        graph[u].push_back(v);
        graph[v].push_back(u);
    }
}

有向圖儲存

  • 就少存一邊就好啊
cin >> u >> v;
graph[u].push_back(v);
graph[v].push_back(u);
cin >> u >> v;
graph[u].push_back(v);
cin >> u >> v;
graph[u][v] = 1;
graph[v][u] = 1;
cin >> u >> v;
graph[u][v] = 1;

帶權圖儲存

  • 會用 pair<int, int> 是因為有內建比較函式
typedef pair<int, int> Edge;

int main() {
    int vertexCount, edgeCount;
    cin >> vertexCount >> edgeCount;

    vector< vector<Edge> > graph(vertexCount);

    int u, v, w; // w is for weight
    for (int i = 0; i < edgeCount; i++) {
        cin >> u >> v >> w;
        graph[u].push_back({w, v});
        graph[v].push_back({w, u});
    }
}

如果不想用 pair?

  • p.first  p.second 不好打?
  •  priority_queue 會有問題,但很難寫
struct E {
    int to;
    int w;
};

int main() {
    priority_queue<E, vector<E>, function<bool(E, E)> > pq(
        [](const E& a, const E& b) {
            return (a.w != b.w ? a.w < b.w : a.to < b.to);
        }
    );
}

比較

  • 在稠密圖用鄰接矩陣,稀疏圖用鄰接串列!
項目 鄰接矩陣 鄰接串列
空間複雜度
插入邊
查詢邊
枚舉邊
\mathcal O(|V|^2)
\mathcal O(1)
\mathcal O(1)
\mathcal O(|V| + |E|)
\mathcal O(1)
\mathcal O(|E|)
\mathcal O(|V|^2)
\mathcal O(|V| + |E|)
Other Methods

其他存圖的方式

網格座標(?

  • 我不知道這個叫什麼,但反正就是有這種東西
  • 我之後可能就叫他網格座標喔
#include <iostream>

using namespace std;

int main() {
    int height, length;
    cin >> height >> length;

    vector<string> graph(height);
    for (auto& s: graph) cin >> s;
}
h = 5, l = 4\\ \begin{bmatrix} 0000\\ 0100\\ 0010\\ 0000\\ 0100 \end{bmatrix}

分開輸入

  • 可以外面包一圈邊界比較好做事
  • 把不能走的也改成邊界
int main() {
    int height, length;
    cin >> height >> length;

    vector<vector<int>> graph(height + 2, 
                              vector<int>(length + 2, -1));

    for (int i = 1; i <= height; i++) {
        for (int j = 1; j <= length; j++) {
            cin >> graph[i][j];
            if (graph[i][j]) graph[i][j] = -1;
        }
    }
}
#include <bits/stdc++.h>

using namespace std;

int main() {
    int vertexCount, edgeCount;
    cin >> vertexCount >> edgeCount;

    vector< pair<int, int> > edgeList(edgeCount);

    for (auto& [u, v]: edgeList) {
        cin >> u >> v;
    }
}

邊串列 Edge List

  • 把邊丟到集合中

存樹

  • 指標
  • 陣列
1
3
2
4
7
6
5
  • 樹相關的會有更電的來講

例題

Graph Traversal

圖的遍歷

  我們現在有某個點突然有很多的水的一張圖,而我們現在想要計算出這張圖中的點何時會被水淹到,該怎麼做呢?

淹水 Floodfill

  我們現在有某個點突然有很多的水的一張圖,而我們現在想要計算出這張圖中的點何時會被水淹到,該怎麼做呢?

4
0
2
3
2
7
3
8
5
1
9
6

淹水 Floodfill

  我們現在有某個點突然有很多的水的一張圖,而我們現在想要計算出這張圖中的點何時會被水淹到,該怎麼做呢?

0
0
4
2
3
2
7
3
8
5
1
9
6

淹水 Floodfill

  我們現在有某個點突然有很多的水的一張圖,而我們現在想要計算出這張圖中的點何時會被水淹到,該怎麼做呢?

4
1
2
7
3
1
8
1
5
1
1
1
9
6
0
0

淹水 Floodfill

  我們現在有某個點突然有很多的水的一張圖,而我們現在想要計算出這張圖中的點何時會被水淹到,該怎麼做呢?

4
1
2
2
7
3
1
8
1
5
1
1
1
9
2
6
2
0
0

淹水 Floodfill

淹水 Floodfill

  我們現在有某個點突然有很多的水的一張圖,而我們現在想要計算出這張圖中的點何時會被水淹到,該怎麼做呢?

4
1
2
2
7
3
3
1
8
1
5
1
1
1
9
2
6
2
0
0

暴力作法

  • 每次確定每一格有沒有被淹到,有的話就更新他所有鄰居點
    • 每輪做 \(\mathcal O(|V|)\) 次檢查
    • 最多檢查 \(\mathcal O(|V|)\) 次(why?)
    • 時間複雜度 \(\mathcal O(|V|^2)\)!
  • ​花太多時間確定誰有資格溢出了!

小觀察

  • 只有剛被淹過的點有需要去往下淹
  • 每個點只會當最外圍一次
  • 可以開個下回合要溢出的點的清單!
  • 用個 queue 來記錄就好了
  • 這就是廣度優先搜尋/遍歷(Breadth-First Search / traversal)
  • 通常簡稱 BFS

複雜度?

  • 剛剛我們想了一個不錯的方法來處理這個問題
  • 那這個演算法的時間複雜度是什麼?
  • 所有點都只會進去/出來 queue 一次
    • \(\mathcal O(|V|)\) 完成所有操作?
  • 每個點都會有不同的邊數,怎麼估計複雜度?
    • 每個邊會被走到兩次
      • 用這條邊淹到一個點時
      • 下次操作淹回去失敗時
    • \(\mathcal O(|E|)\) 完成所有邊的操作
  • ​總複雜度 \(\mathcal O(|V| + |E|)\)
  • 額外空間複雜度(存圖以外的)?

BFS code

  • 記得一淹到某個點就要記錄!
vector<int> bfs(int source, vector< vector<int> >& graph) {
    queue<int> q;
    q.push(source);

    vector<int> distance(graph.size(), INT32_MAX);
    distance[source] = 0;

    while (!q.empty()) { // while (q.size())
        int& current = q.front();

        for (auto& n: graph[current]) {
            if (distance[n] != INT32_MAX) continue;

            distance[n] = distance[current] + 1;
            q.push(n);
        }

        q.pop();
    }

    distance[source] = 0;
    return distance;
}

怎麼在網格座標圖 BFS

  • 四個方向的
while (!q.empty()) { // while (q.size())
        auto& [x, y] = q.front();
        // queue< pair<int, int> > q;

        if (graph[x][y + 1] == INT32_MAX) {
            graph[x][y + 1] = graph[x][y] + 1;
            q.push({x, y + 1});
        }
        if (graph[x][y - 1] == INT32_MAX) {
            graph[x][y - 1] = graph[x][y] + 1;
            q.push({x, y - 1});
        }
        if (graph[x + 1][y] == INT32_MAX) {
            graph[x + 1][y] = graph[x][y] + 1;
            q.push({x + 1, y});
        }
        if (graph[x - 1][y] == INT32_MAX) {
            graph[x - 1][y] = graph[x][y] + 1;
            q.push({x - 1, y});
        }

        q.pop();
    }

看起來還好?

  • 作為一個聰明人,你不喜歡程式有重複結構吧
    • 那我喜歡怎麼辦
    • :那現在水可以朝八個方向淹
    • 沒關係
    • 那現在空間變三維的,往 26 個方向擴散

將重複部分換成迴圈

const int dx[] = {1,-1, 0, 0};
const int dy[] = {0, 0, 1,-1};

while (!q.empty()) {
    auto& [x, y] = q.front();
    
    for (int i = 0; i < 4; i++) {
        auto& nextPosition = graph[x + dx[i]][y + dy[i]];
        
        if (nextPosition == INT32_MAX) {
            nextPosition = graph[x][y] + 1;
            q.push({x + dx[i], y + dy[i]});
        }
    }
    
    q.pop();
}

BFS 的功用

  • 用某種方式走完整張圖
  • 淹水模擬
  • 無權圖最短路徑
    • 帶權的會發生什麼事情?

如果把 queue 換掉?

  • 換成 stack 會長什麼樣子呢?
6
1
4
2
3
0
5
9
7
8

如果把 queue 換掉?

  • 換成 stack 會長什麼樣子呢?
6
1
4
2
3
0
0
5
9
7
8
\\ \\ \\ 0\\

如果把 queue 換掉?

  • 換成 stack 會長什麼樣子呢?
6
1
4
2
1
3
0
0
5
9
7
8
\\ \\ \\ 0\\
2

如果把 queue 換掉?

  • 換成 stack 會長什麼樣子呢?
6
1
2
4
2
1
3
0
0
5
9
7
8
\\ \\ \\ 0\\
2
1

如果把 queue 換掉?

  • 換成 stack 會長什麼樣子呢?
6
3
1
2
4
2
1
3
0
0
5
9
7
8
\\ \\ \\ 0\\
2
1
6

如果把 queue 換掉?

  • 換成 stack 會長什麼樣子呢?
6
3
1
2
4
2
1
3
0
0
9
7
8
5
3
\\ \\ \\ 0\\
2
1
5

如果把 queue 換掉?

  • 換成 stack 會長什麼樣子呢?
6
3
1
2
2
1
3
0
0
9
7
8
5
3
\\ \\ \\ 0\\
4
4
\\ 1
2
1
5

如果把 queue 換掉?

  • 換成 stack 會長什麼樣子呢?
6
3
1
2
2
1
3
0
0
7
8
5
3
4
1
9
\\ 2
\\ \\ \\ 0\\
4
9

如果把 queue 換掉?

  • 換成 stack 會長什麼樣子呢?
6
3
1
2
2
1
3
0
0
7
5
3
4
1
9
2
8
2
\\ \\ \\ 0\\
4
8

如果把 queue 換掉?

  • 換成 stack 會長什麼樣子呢?
6
3
1
2
2
1
3
0
0
5
3
4
1
9
2
8
2
7
3
\\ \\ \\ 0\\
4
8
7

如果把 queue 換掉?

  • 換成 stack 會長什麼樣子呢?
6
3
1
2
2
1
3
0
0
5
3
4
1
9
2
8
2
7
3
\\ \\ \\ 0\\
4
8
7

如果把 queue 換掉?

  • 換成 stack 會長什麼樣子呢?
6
3
1
2
2
1
3
0
0
5
3
4
1
9
2
8
2
7
3
\\ \\ \\ 0\\
4
8
7

如果把 queue 換掉?

  • 換成 stack 會長什麼樣子呢?
6
3
1
2
2
1
0
0
5
3
4
1
9
2
8
2
7
3
3
1
\\ \\ \\ 0\\
4
8
7
\\ \\ \\ 3\\

如果把 queue 換掉?

  • 換成 stack 會長什麼樣子呢?
6
3
1
2
2
1
0
0
5
3
4
1
9
2
8
2
7
3
3
1
\\ \\ \\ 0\\
4
8
7
\\ \\ \\ 3\\

剛剛發生了什麼事?

  • 如果一個點可以走,我就往那個點走,把他寫上標記
  • 走了之後就從那個點繼續往下
  • 我們把這個動作稱為深度優先搜尋(Depth-First Search)
    • DFS

stack 實作

  • 除了在 stack 裡面紀錄點的編號,還要記錄這個點已經看過的邊
    • 如果不記錄會有什麼不好的?
    • 有沒有可以不記錄的情況?
  • 有沒有辦法可以不用記錄編號呢?
    • 是不是用遞迴維護 stack 就好了!
    • 除非因為資料太大導致遞迴 stack overflow,否則用遞迴就好

DFS code

  • 函式裡面塞太多東西不太舒服?
void dfs(int current, vector< vector<int> >& graph, vector<bool>& visited) {
    visited[current] = 1;

    for (auto& n: graph[current]) {
        if (visited[n]) continue;

        dfs(n, graph, visited);
    }
}

int main() {
    int vC, eC;
    cin >> vC >> eC;

    vector< vector<int> > graph(vC);

    int u, v;
    for (int i = 0; i < eC; i++) {
        cin >> u >> v;
        graph[u].push_back(v);
    }

    vector<bool> visited(vC);
    dfs(0, graph, visited);
}

使用 lambda

  • 這裡有我之前做的簡報,如果不懂可以看看
vector<bool> visited(vC);
function<void(int)> dfs = [&](int current) {
    visited[current] = 1;
    
    for (auto& n: graph[current]) {
        if (!visited[n]) dfs(n);
    }
};
  • 也可以用全域變數就好,但我不喜歡
  • 要用 lambda 遞迴必須要標註型別不能用 auto

前/中/後序遍歷

  • preorder / inorder / postorder
  • 僅限二元樹
vector< pair<int, int> > tree(vC);
int leftChild, rightChild;
for (int i = 0; i < vC; i++) {
    cin >> leftChild >> rightChild;
    tree[i] = {leftChild, rightChild};
}

vector<int> preorder, inorder, postorder;
function<void(int)> dfs = [&](int current) {
    auto [l, r] = tree[current];

    preorder.push_back(current);
    if (l >= 0) dfs(l);
    inorder.push_back(current);
    if (r >= 0) dfs(r);
    postorder.push_back(current);
};

DFS 的功能

  • 用某種方式走完整張圖
  • 蛤?就這樣?
    • DFS 功能看起來較少,但
      • 通常 DFS 空間需求較低
      • 相對好寫
      • 之後會用 DFS 來作其他事喔

例題

Topological Sort

拓樸排序

什麼是「拓樸」

  • 我不會
  • 在圖論中
    • 只關心點的連接關係
    • 不關心邊的權重等
  • 有錯的話請電神糾正我 QQ
0
1
2
0
1
2

什麼是拓樸排序

  • 在有向圖中,我們會想要把點作輩分關係的排序
  • 準確點說一個拓樸順序(topological order)是:
    • 一個圖 \(G(V, E)\) 的頂點的排序 \(P = (p_1, p_2, ..., p_{|V|})\)
    • 且對於所有有向邊 \((p_i, p_j)\) 滿足 \(i < j\)
    • 而做這件事情就叫做拓樸排序(topological sort)
  • 一張有向圖一定能被拓樸排序嗎?
    • 不一定!
    • 只要圖上有環,就沒辦法被拓樸排序!

為什麼要拓樸排序?

  • 有些時候我們在解決問題的時候需要關注點的先後順序
    • 從源頭往下做來推算累積下來的狀態
    • 從結尾回溯推出遊戲起始盤面的結果
    • ……
  • 透過獲得 \(V\) 的拓樸順序,我們就可以確保轉移狀態時沒有問題!

如何拓樸排序?

  • 能夠被選為第一個點的點,入度一定為 0
  • 選完第一個元素之後,選所有他連到的點當下一個一定都沒有問題
    • 把這個元素的出度都視為不存在
  • 剩下的點不管選誰當下一個都和第一個沒有關係了
  • 將剩下的圖視為新的圖來解決!

演算法執行概念

  • 對於一張有向圖 \(G(V, E)\):
    1. 找到任意一個入度為 0 的點 \(p\)
    2. 將其放入排序的第一格
    3. 將 \(p\) 和其出度移除,得到 \(G'\)
    4. 求出 \(G'\) 的拓樸順序,放在 \(p\) 後面
  • 遞迴?
    • 真的需要嗎?

BFS 寫法

  1. 計算所有點的入度
  2. 並把入度為 0 的點全部推入一個 queue
  3. 從 queue 中取出首位元素 \(p\) 放入排序結果
  4. 將 \(p\) 連到的點的入度減 1
    • 如果該點入度變為 0,將其推入 queue
  5. 重複直到結束

BFS 版程式碼

  • 這份程式的回傳值是對的嗎?
vector<int> topologicalSort(vector< vector<int> >& graph) {
    vector<int> indegree(graph.size());
    for (auto& v: graph) for (auto& n: v) indegree[n]++;

    vector<int> order;
    order.reserve(graph.size());

    queue<int> q;
    for (int i = 0; i < graph.size(); i++) {
        if (indegree[i] == 0) q.push(i);
    }

    while (!q.empty()) {
        auto& current = q.front();

        order.push_back(current);
        for (auto& n: graph[current]) {
            if (--indegree[n] == 0) q.push(n);
        }
    }

    return order;
}

如何判斷正確性

  • 如果 \(|P| < |V|\),答案顯然有錯
    • 為什麼會發生這種狀況呢?
      • 剩下的點入度皆不為 0
    • 怎麼會發生的?
      • 寫爛了
      • 有環存在於此有向圖
  • 如果 \(|P| = |V|\) 呢?
    • ​只要符合條件一定合法
      • ​沒有環存在於此有向圖
    • ​拓樸順序有解 \(\longleftrightarrow \) 此圖有向無環

另外一種想法

  • 接下來會介紹另外一種拓樸排序的方法
    • 此方法用到了叫做「時間戳記」(time stamp)的概念
  • 只要會一種就能拓樸排序了我幹嘛學?
    • 之後會用到

時間戳記

  • 講完 BFS,接下來想當然就是要用 DFS 啊
  • 在 DFS 的過程中
    1. 進入一個點
    2. 對這個點進行 DFS
    3. 離開這個點
  • 如果紀錄進入和離開每一個點的時機?
0
(?,?)
1
(?,?)
4
(?,?)
2
(?,?)
5
(?,?)
3
(?,?)

時間戳記

  • 講完 BFS,接下來想當然就是要用 DFS 啊
  • 在 DFS 的過程中
    1. 進入一個點
    2. 對這個點進行 DFS
    3. 離開這個點
  • 如果紀錄進入和離開每一個點的時機?
0
(0,?)
1
(?,?)
4
(?,?)
2
(?,?)
5
(?,?)
3
(?,?)

時間戳記

  • 講完 BFS,接下來想當然就是要用 DFS 啊
  • 在 DFS 的過程中
    1. 進入一個點
    2. 對這個點進行 DFS
    3. 離開這個點
  • 如果紀錄進入和離開每一個點的時機?
0
(0,?)
1
(1,?)
4
(?,?)
2
(?,?)
5
(?,?)
3
(?,?)

時間戳記

  • 講完 BFS,接下來想當然就是要用 DFS 啊
  • 在 DFS 的過程中
    1. 進入一個點
    2. 對這個點進行 DFS
    3. 離開這個點
  • 如果紀錄進入和離開每一個點的時機?
0
(0,?)
1
(1,?)
4
(?,?)
2
(2,?)
5
(?,?)
3
(?,?)

時間戳記

  • 講完 BFS,接下來想當然就是要用 DFS 啊
  • 在 DFS 的過程中
    1. 進入一個點
    2. 對這個點進行 DFS
    3. 離開這個點
  • 如果紀錄進入和離開每一個點的時機?
0
(0,?)
1
(1,?)
4
(?,?)
2
(2,3)
5
(?,?)
3
(?,?)

時間戳記

  • 講完 BFS,接下來想當然就是要用 DFS 啊
  • 在 DFS 的過程中
    1. 進入一個點
    2. 對這個點進行 DFS
    3. 離開這個點
  • 如果紀錄進入和離開每一個點的時機?
0
(0,?)
1
(1,4)
4
(?,?)
2
(2,3)
5
(?,?)
3
(?,?)

時間戳記

  • 講完 BFS,接下來想當然就是要用 DFS 啊
  • 在 DFS 的過程中
    1. 進入一個點
    2. 對這個點進行 DFS
    3. 離開這個點
  • 如果紀錄進入和離開每一個點的時機?
0
(0,5)
1
(1,4)
4
(?,?)
2
(2,3)
5
(?,?)
3
(?,?)

時間戳記

  • 講完 BFS,接下來想當然就是要用 DFS 啊
  • 在 DFS 的過程中
    1. 進入一個點
    2. 對這個點進行 DFS
    3. 離開這個點
  • 如果紀錄進入和離開每一個點的時機?
0
(0,5)
1
(1,4)
4
(?,?)
2
(2,3)
5
(?,?)
3
(6,?)

時間戳記

  • 講完 BFS,接下來想當然就是要用 DFS 啊
  • 在 DFS 的過程中
    1. 進入一個點
    2. 對這個點進行 DFS
    3. 離開這個點
  • 如果紀錄進入和離開每一個點的時機?
0
(0,5)
1
(1,4)
4
(?,?)
2
(2,3)
5
(7,?)
3
(6,?)

時間戳記

  • 講完 BFS,接下來想當然就是要用 DFS 啊
  • 在 DFS 的過程中
    1. 進入一個點
    2. 對這個點進行 DFS
    3. 離開這個點
  • 如果紀錄進入和離開每一個點的時機?
0
(0,5)
1
(1,4)
4
(?,?)
2
(2,3)
5
(7,8)
3
(6,?)

時間戳記

  • 講完 BFS,接下來想當然就是要用 DFS 啊
  • 在 DFS 的過程中
    1. 進入一個點
    2. 對這個點進行 DFS
    3. 離開這個點
  • 如果紀錄進入和離開每一個點的時機?
0
(0,5)
1
(1,4)
4
(?,?)
2
(2,3)
5
(7,8)
3
(6,9)

時間戳記

  • 講完 BFS,接下來想當然就是要用 DFS 啊
  • 在 DFS 的過程中
    1. 進入一個點
    2. 對這個點進行 DFS
    3. 離開這個點
  • 如果紀錄進入和離開每一個點的時機?
0
(0,5)
1
(1,4)
4
(10,?)
2
(2,3)
5
(7,8)
3
(6,9)

時間戳記

  • 講完 BFS,接下來想當然就是要用 DFS 啊
  • 在 DFS 的過程中
    1. 進入一個點
    2. 對這個點進行 DFS
    3. 離開這個點
  • 如果紀錄進入和離開每一個點的時機?
0
(0,5)
1
(1,4)
4
(10,11)
2
(2,3)
5
(7,8)
3
(6,9)

時間戳記

  • 講完 BFS,接下來想當然就是要用 DFS 啊
  • 在 DFS 的過程中
    1. 進入一個點
    2. 對這個點進行 DFS
    3. 離開這個點
  • 如果紀錄進入和離開每一個點的時機?
0
(0,5)
1
(1,4)
4
(10,11)
2
(2,3)
5
(7,8)
3
(6,9)

觀察一下

  • 剛剛的動畫中,值得注意的是「離開戳記」
  • 對於兩個點 \(u, v\):
    • \(u\) 可以單向走到 \(v\),且 \(u\) 先被拜訪
      • 離開 \(v\) 後才會離開 \(u\),\(u\) 離開戳記較大
    • \(u\) 可以單向走到 \(v\),且 \(v\) 先被拜訪
      • 離開 \(v\) 後才會碰到 \(u\),\(u\) 離開戳記較大
    • \(u\) 和 \(v\) 可以互相走到
      • ​誰先走到誰的離開戳記就較大
  • ​透過離開戳記判斷順序!
0
(0,5)
1
(1,4)
5
(7,8)
3
(6,9)
4
(10,11)
2
(2,3)

DFS 程式碼

  • 這份程式碼其實是爛的!
    • 你能看出為什麼嗎?
vector<int> topologicalSort(vector< vector<int> >& graph) {
    vector<int> order;
    order.reserve(graph.size());

    vector<int> visited(graph.size());
    bool cyclic = false;
    function<void(int, int&)> dfs = [&](int now, int& s) {
        if (cyclic) return;

        visited[now] = s;
        for (auto& n: graph[now]) {
            if (visited[n] == s) {
                cyclic = true;
                return;
            }
            if (!visited[n]) dfs(n, s);
        }

        order.push_back(now);
    };

    for (int i = 0; i < graph.size(); i++) dfs(i, i);

    if (cyclic) return vector<int>(0);

    reverse(order.begin(), order.end());
    return order;
}

小細節

  • 不要耍毒瘤就不會有這種問題了!
function<void(int, const int&)> dfs
= [&](int now, const int s) {
    if (cyclic || visited[now]) return;

    visited[now] = s;
    for (auto& n: graph[now]) {
        if (visited[n] == s) {
            cyclic = true;
            return;
        }
        if (!visited[n]) dfs(n, s);
    }

    order.push_back(now);
};

for (int i = 0; i < graph.size(); i++) dfs(i, i+1);

例題

Disjoint Set

並查集

查詢和合併連通分量

  • 今天我希望有一個資料結構,支援以下操作:
    • 查詢元素 \(e\) 所在的集合
    • 合併兩個給定的集合
  • 假設今天這是一個圖論的問題,那上述操作即為:
    • 查詢點 \(v\) 所在的連通分量
    • 將兩連通分量連起來

怎麼做?

  • 查詢時,回傳該集合「代表元素」的編號
  • 只要找到代表元素就回傳

 

  • 令 \(q(i)\) 回傳元素 \(i\) 所在集合的代表元素
  • 假設一開始所有元素都是獨自形成一個集合,就是 \(q(i) = i\)
  • 合併集合時,從兩集合各選出一個元素 \(a, b\)
  •  \(q(a) = b\) 就好?

修改   的定義

  • 剛剛那樣會爆掉,那我們該怎麼辦
  • 既然我們剛剛說初始條件 \(q(i) = i\),那不如我們試試看照著做
    • 如果要「確定」找到的代表元素正確(唯一)要怎麼做
      • 剛才的方法可以使得連通分量中 \(q(i) = i\) 的元素唯一嗎?
        • 可以的話就這樣做吧!
q(i)
vector<int> master(vertexCount);

int query(int i) {
    if (master[i] == i) return i;
    
    return query(master[i]);
}

合併集合

  • 看起來是什麼樣子?
0
1
3
2
4

合併集合

  • 看起來是什麼樣子?
0
1
3
2
4
  • 合併只要 \(\mathcal O(1)\)!
    • 那查詢呢?

鍊狀的情況

  • 如果照我們的想法,但合併時變出一條鍊會怎樣?
0
1
3
2
4

鍊狀的情況

  • 如果照我們的想法,但合併時變出一條鍊會怎樣?
0
1
3
2
4
  • \(\mathcal O(n)\) 查詢,不太妙
    • 怎麼辦?

啟發式合併

  • 我們想要使樹的高度(深度)越低越好
  • 可以從合併的方法下手!
    • 如果我們開個紀錄「自己是根時樹的高度」的陣列呢
      • 合併時比較兩棵樹的高度
      • 將比較矮的接到比較高的下面
      • 這樣有多好?
    • 複雜度長怎樣
      • 讓最大樹高變成 \(h\) 至少需要 \(2^h-1\) 次合併
        • 樹高不可能超過 \(\lceil \log|V| \rceil\)!
      • 查詢的複雜度現在只剩 \(\mathcal O(\log |V|)\) 了

複雜度分析

  • 是不是有人看不懂剛剛在幹嘛
0
1
2
3

複雜度分析

  • 是不是有人看不懂剛剛在幹嘛
0
1
3
2

複雜度分析

  • 是不是有人看不懂剛剛在幹嘛
0
1
3
2
4

複雜度分析

  • 是不是有人看不懂剛剛在幹嘛
0
1
3
2
4

複雜度分析

  • 是不是有人看不懂剛剛在幹嘛
0
1
3
2
4
5
7
6
8

複雜度分析

  • 是不是有人看不懂剛剛在幹嘛
0
1
3
2
4
5
7
6
8

複雜度分析

  • 是不是有人看不懂剛剛在幹嘛
0
1
3
2
4
5
7
6
8
  • 需要兩個高度為 \(h\) 的樹合併,高度才會增加 1!
  • 任何兩不同高度的樹合併都不增加最大深度
  • \(V\) 合併的深度屬於 \(\mathcal O(\log|V|)\)

路徑壓縮

  • \(q(i)\) 現在是「找到代表元素為自己的元素後回傳」
    • 那我們能不能在遞迴時順便做些事情呢?
  • 在查詢時順便把路徑上每個元素的代表元素改成終點聽起來不錯吧
int query(int i) {
    return (master[i] == i ? i : query(master[i]));
}
int query(int i) {
    if (master[i] == i) return i;
    
    master[i] = query(master[i]);
    return master[i];
}

圖例

  • 剛才那樣有點抽象對吧
0
1
3
2
4
5

圖例

  • 剛才那樣有點抽象對吧
0
1
3
2
4
5

圖例

  • 剛才那樣有點抽象對吧
0
1
3
2
4
5

圖例

  • 剛才那樣有點抽象對吧
0
1
3
2
4
5

圖例

  • 剛才那樣有點抽象對吧
0
1
3
2
4
5

圖例

  • 剛才那樣有點抽象對吧
0
1
3
2
4
5

圖例

  • 剛才那樣有點抽象對吧
0
1
3
2
4
5

圖例

  • 剛才那樣有點抽象對吧
0
1
3
2
4
5

圖例

  • 剛才那樣有點抽象對吧
0
1
3
2
4
5
  • 查詢時每次多一個修改的動作,但以後查詢變輕鬆了!

快,還要更快

  • 光是剛才的路徑壓縮,就快到不行了
    • 查詢複雜度為 \(\mathcal O(\log_{(2 + \frac{Q}{|V|})}|V|)\)
    • 我不會證明
  • 但其實還可以更快?!
    • 記得剛才的「啟發式合併」嗎?
    • 配合路徑壓縮的話複雜度可以再更低
    • 啟發式合併 + 路徑壓縮的複雜度只有 \(\mathcal O(\alpha(|V|))\)

Disjoint Set code

  • 只寫路徑壓縮通常就夠快了啦
struct DisjointSet {
    vector<int> master, depth;

    disjointSet(int vertexCount) {
        master.resize(vertexCount);
        depth.resize(vertexCount);

        for (int i = 0; i < vertexCount; i++) {
            master[i] = i;
            depth[i] = 1;
        }
    }

    int query(int i) {
        if (master[i] == i) return i;

        return master[i] = query(master[i]);
    }

    void merge(int a, int b) {
        a = query(a);
        b = query(b);

        if (a == b) return;

        if (depth[a] < depth[b]) swap(a, b);
        master[b] = a;
        depth[a] += depth[b];
    }
}; // 記得分號

例題

Lowest Common Ancestor

最低共同祖先

我發現樹論會講,這裡先講其中一種作法就好了

什麼是最低共同祖先

  • 要研究兩生物的親緣關係,我們會從演化樹找到兩生物分化的地方
\text D
\text C
\text B
\text A
\text{LCA(A, B)}
\text{LCA(A, D)}
\text{LCA(C, D)}
  • 可是這樣看起來是最高欸
  • 但我們的根應該要在最上面,全資訊圈的樹都是那樣長的

資訊學的 LCA

  • 在一個有根樹上,我們一定可以找到任意兩點的 LCA

資訊學的 LCA

  • 在一個有根樹上,我們一定可以找到任意兩點的 LCA
0
5
1
4
2
7
6
3

資訊學的 LCA

  • 在一個有根樹上,我們一定可以找到任意兩點的 LCA
0
5
1
4
2
7
6
3

資訊學的 LCA

  • 在一個有根樹上,我們一定可以找到任意兩點的 LCA
0
5
1
4
2
7
6
3

資訊學的 LCA

  • 在一個有根樹上,我們一定可以找到任意兩點的 LCA
0
5
1
4
2
7
6
3

資訊學的 LCA

  • 在一個有根樹上,我們一定可以找到任意兩點的 LCA
0
5
1
4
2
7
6
3

資訊學的 LCA

  • 在一個有根樹上,我們一定可以找到任意兩點的 LCA
0
5
1
4
2
7
6
3
  • 要處理樹上兩點的問題很常需要用到 LCA
    • 像是兩點間的距離
  • 如何查詢 LCA?
    • 倍增法!

倍增法 binary lifting

  • 要用多少的砝碼才能夠湊出 \([0, 1000]\cap\mathbb{Z}\) 的所有值呢
    • 只要十個就好!
    • 怎麼做?
      • 考慮二進制
        • \((1000)_{10} = (1111101000)_{2}\)
        • \((225)_{10} = (0011100001)_{2}\)
      • 只需要 \(\lceil \log_2 x\rceil\) 個砝碼就可以表示 \([0, x]\cap\mathbb{Z}\) 的所有值!
  • ​​找自己的 \(n\) 倍祖先也是一樣
    • 只要 \(\lceil \log_2 n\rceil\) 個 \(2^k\) 倍祖先就可以 \(\mathcal O(\log n)\) 查詢

要怎麼建祖先表

  • 我們不難發現,自己 \(k\) 倍祖先的 \(k\) 倍祖先就是自己的 \(2k\) 祖先
  • 我們只要有所有人的 \(2^j\) 倍祖先,就可以有所有人的 \(2^{j + 1}\) 倍祖先
vector< vector<int> > ancestor;

for (int j = 1; j <= __lg(maxDepth); j++) {
    for (int i = 0; i < vertexCount; i++) {
        ancestor[i][j] = ancestor[ancestor[i][j - 1]][j - 1];
    }
}

查詢共同祖先

  • 我們現在有祖先表了,要怎麼用他?
  • 如果我們要查詢兩點 \(u, v\) 的共同祖先,我們要:
    • 假設他們的共同祖先是他們的第 \(k\) 祖先
    • 想辦法用二進制把 \(k\) 表達出來?
    • 好像哪裡怪怪的?

查詢共同祖先

  • 我們現在有祖先表了,要怎麼用他?
  • 如果我們要查詢兩點 \(u, v\) 的共同祖先,我們要:
    • 假設他們的共同祖先是他們的第 \(k\) 祖先
    • 想辦法用二進制把 \(k\) 表達出來?
    • 好像哪裡怪怪的?
    • 如果兩者深度不同,假設不成立!
  • 先讓深度較深的先向上走,走到同深度
  • 這樣就假設就成立了
  • 所以我們應該還要記錄節點的深度
0
5
1
4
2
7
6
3

DFS 邊紀錄深度

  • 把深度當成 visited 來用
vector<int> depth(vC);
function<void(int, int)> dfs = [&](int current, int d = 1) {
    depth[current] = d++;
    for (auto& n: graph[current]) {
        if (depth[n]) continue;

        dfs(n, d);
    }
};

LCA code

  • 把前面的東西也補上就完成了!
function<int(int, int)> lca = [&](int u, int v) {
    if (depth[u] < depth[v]) swap(u, v);
    if (depth[u] != depth[v]) {
        int dif = depth[u] - depth[v];

        for (int i = 0; dif > 0; i++) {
            if (dif & 1) u = ancestor[u][i];
            dif >>= 1;
        }
    }

    for (int i = __lg(maxDepth); i >= 0; i--) {
        if (ancestor[u][i] != ancestor[v][i]) {
            u = ancestor[u][i];
            v = ancestor[v][i];
        }
    }

    return ancestor[u][0];
};

完整的 LCA

  • 把剛剛所有東西接在一起
#include <bits/stdc++.h>

using namespace std;

int main() {
    int vertexCount, query;
    cin >> vertexCount >> query;

    vector< vector<int> > graph(vertexCount);
    int a, b;
    for (int i = 1; i < vertexCount; i++) {
        cin >> a >> b;
        graph[a].push_back(b);
        graph[b].push_back(a);
    }


    vector< vector<int> > ancestor(vertexCount, vector<int>(1));
    vector<int> depth(vertexCount);
    function<void(int, int)> dfs = [&](int current, int d) {
        depth[current] = d++;
        for (auto& n: graph[current]) {
            if (depth[n]) continue;
            
            ancestor[n][0] = current;
            dfs(n, d);
        }
    };

    dfs(0, 1);

    int maxDepth = 0;
    for (auto& n: depth) maxDepth = max(maxDepth, n);
    for (auto& v: ancestor) v.resize(__lg(maxDepth) + 1);

    for (int j = 1; j <= __lg(maxDepth); j++) {
        for (int i = 0; i < vertexCount; i++) {
            ancestor[i][j] = ancestor[ancestor[i][j - 1]][j - 1];
        }
    }

    function<int(int, int)> lca = [&](int u, int v) {
        if (depth[u] < depth[v]) swap(u, v);
        if (depth[u] != depth[v]) {
            int dif = depth[u] - depth[v];

            for (int i = 0; dif > 0; i++) {
                if (dif & 1) u = ancestor[u][i];
                dif >>= 1;
            }
        }

        if (u == v) return u;

        for (int i = __lg(maxDepth); i >= 0; i--) {
            if (ancestor[u][i] != ancestor[v][i]) {
                u = ancestor[u][i];
                v = ancestor[v][i];
            }
        }

        return ancestor[u][0];
    };

    while (query--) {
        cin >> a >> b;
        cout << lca(a, b) << '\n';
    }
}

其他的 LCA 求法

  • 在之後的樹論讀書會中:
    • 倍增法(複習)
    • 樹壓平
    • 樹鏈剖分
  • 在之後的離線讀書會中:
    • Tarjan's LCA 演算法

例題

Spanning Tree

生成樹

邊權和最小的連通子圖

  • 今天有一張連通圖 \(G(V, E)\),我們想要一張子圖 \(G'(V', E')\) 符合:
    • 點集 \(V = V'\)
    • 邊權和 \(\displaystyle \sum_{e \in E'} w_e\) 最小
    • \(G'\) 仍然連通
  • 在正常情況下(\(\forall e \in E, w_e > 0\)),該圖一定是一棵樹
    • 我們稱這棵樹為最小生成樹,Minimum Spanning Tree,MST
    • 什麼情況下可以不是樹?
    • 最小生成樹唯一嗎

Cycle Property

  • 環性質
  • 對於任何圖上的環 \(C\),邊權嚴格最大的邊 \(e\) 不屬於最小生成樹
  • 用反證法來證明此性質:
    • 假設 \(e\) 屬於最小生成樹
    • 刪除 \(e\) 會使最小生成樹分裂成兩個子樹
    • 但環 \(C\) 上剩下的邊必定存在一個 \(e'\) 能連接兩個子樹
    • \(w_e > w_{e'}\),此 MST 並非最小權重,矛盾!
  • 這個可以拿來幹嘛?
    • 等一下就知道了
0
5
1
4
2
6
3

Cut property

  • 🈹性質
  • 什麼是割?

Cut property

  • 🈹性質
  • 什麼是割?
0
1
3
2
4

Cut property

  • 🈹性質
  • 什麼是割?
0
1
3
2
4

Cut property

  • 🈹性質
  • 什麼是割?
0
1
3
2
4

Cut property

  • 🈹性質
  • 什麼是割?
0
1
3
2
4

Cut property

  • 🈹性質
  • 什麼是割?
    • 把一張圖的點分割為兩子集
    • 一個割會形成一個割集(cut-set)
    • 割集為跨越兩子點集的邊集
  • 割集中權重嚴格最小的邊屬於 MST
    • 跟剛才一樣,反證法
    • 若它不屬於,換成它一定更好
0
1
3
2
4

不嚴格怎麼辦

  • 如果剛才環或是割中,沒有邊權嚴格大於/小於所有邊的邊呢
    • 這個時候就是「平手」,選誰都一樣
    • 對於 cycle property,就是兩個邊都一定不會存在於某個 MST
      • 言下之意就是,會有超過一個 MST 存在而至少有一個沒有其中一個邊
    • 對於 cut property,就是兩個邊都一定會存在於某個 MST 中
0
5
1
4
2
6
3
7
7
1
1
5
3
4
9
1
2

不嚴格怎麼辦

  • 如果剛才環或是割中,沒有邊權嚴格大於/小於所有邊的邊呢
    • 這個時候就是「平手」,選誰都一樣
    • 對於 cycle property,就是兩個邊都一定不會存在於某個 MST
      • 言下之意就是,會有超過一個 MST 存在,而至少有一個沒有其中一個邊
    • 對於 cut property,就是兩個邊都一定能存在於某個 MST 中
0
5
1
4
2
6
3
7
7
1
1
5
3
4
9
1
2

不嚴格怎麼辦

  • 如果剛才環或是割中,沒有邊權嚴格大於/小於所有邊的邊呢
    • 這個時候就是「平手」,選誰都一樣
    • 對於 cycle property,就是兩個邊都一定不會存在於某個 MST
      • 言下之意就是,會有超過一個 MST 存在,而至少有一個沒有其中一個邊
    • 對於 cut property,就是兩個邊都一定能存在於某個 MST 中
0
5
1
4
2
6
3
7
7
1
1
5
3
4
9
1
2

不嚴格怎麼辦

  • 如果剛才環或是割中,沒有邊權嚴格大於/小於所有邊的邊呢
    • 這個時候就是「平手」,選誰都一樣
    • 對於 cycle property,就是兩個邊都一定不會存在於某個 MST
      • 言下之意就是,會有超過一個 MST 存在,而至少有一個沒有其中一個邊
    • 對於 cut property,就是兩個邊都一定能存在於某個 MST 中
0
5
1
4
2
6
3
7
7
1
1
5
3
4
9
1
2

不嚴格怎麼辦

  • 如果剛才環或是割中,沒有邊權嚴格大於/小於所有邊的邊呢
    • 這個時候就是「平手」,選誰都一樣
    • 對於 cycle property,就是兩個邊都一定不會存在於某個 MST
      • 言下之意就是,會有超過一個 MST 存在,而至少有一個沒有其中一個邊
    • 對於 cut property,就是兩個邊都一定能存在於某個 MST 中
0
5
1
4
2
6
3
7
7
1
1
5
3
4
9
1
2

不嚴格怎麼辦

  • 如果剛才環或是割中,沒有邊權嚴格大於/小於所有邊的邊呢
    • 這個時候就是「平手」,選誰都一樣
    • 對於 cycle property,就是兩個邊都一定不會存在於某個 MST
      • 言下之意就是,會有超過一個 MST 存在,而至少有一個沒有其中一個邊
    • 對於 cut property,就是兩個邊都一定能存在於某個 MST 中
0
5
1
4
2
6
3
7
7
1
1
5
3
4
9
1
2

不嚴格怎麼辦

  • 如果剛才環或是割中,沒有邊權嚴格大於/小於所有邊的邊呢
    • 這個時候就是「平手」,選誰都一樣
    • 對於 cycle property,就是兩個邊都一定不會存在於某個 MST
      • 言下之意就是,會有超過一個 MST 存在,而至少有一個沒有其中一個邊
    • 對於 cut property,就是兩個邊都一定能存在於某個 MST 中
0
5
1
4
2
6
3
7
7
1
1
5
3
4
9
1
2

不嚴格怎麼辦

  • 如果剛才環或是割中,沒有邊權嚴格大於/小於所有邊的邊呢
    • 這個時候就是「平手」,選誰都一樣
    • 對於 cycle property,就是兩個邊都一定不會存在於某個 MST
      • 言下之意就是,會有超過一個 MST 存在,而至少有一個沒有其中一個邊
    • 對於 cut property,就是兩個邊都一定能存在於某個 MST 中
0
5
1
4
2
6
3
7
7
1
1
5
3
4
9
1
2
  • 從最小的邊開始,依次嘗試加入 MST
  • 如果加入後不會形成環,它就屬於 MST
  • 為什麼這是對的?
    • 分類討論!

加入邊後形成環

  • 我們剛才說「從邊權小到邊權大開始試」
    • 那最後一個加入的邊所形成的環中一定是它邊權最大
    • 根據 cycle property,它一定不屬於某個 MST
      • 既然它不屬於某一個,我們就讓它不屬於我們這個吧!

加入邊後不形成環

  • 加入邊後不形成環,代表它使兩個連通分量連通
    • 它連接了兩個被分割的點集
    • 根據剛才的「由小到大」,它是屬於某個割集中最小權者
    • 根據 cut property,它一定屬於某一個 MST
      • 既然它屬於某一個,我們就讓它屬於我們這個吧!

我們證明它是對的了!

  • 那要怎麼實作?
  • 我們需要一個可以「判斷兩點是否連通」的資料結構
  • 使用剛剛學到的 disjoint set!
  • 它的複雜度會是什麼呢?
    • 需要對邊排序,複雜度 \(\mathcal O(|E|\log|E|)\)
    • 使用並查集 \(|E|\) 次,複雜度 \(\mathcal O(|E|\cdot \alpha(|E|))\)
    • 總複雜度 \(\mathcal O(|E|\log|E|)\)

Kruskal Code

  • 先別急,等等還有另外一種方法
typedef pair<int, int> pii;
typedef pair<int, pii> pi_ii;

struct DisjointSet {
    vector<int> master;

    DisjointSet(int vertexCount) {
        master.resize(vertexCount);
        iota(master.begin(), master.end(), 0);
    }

    int query(int a) {
        if (a == master[a]) return a;

        return master[a] = query(master[a]);
    }

    bool connected(int a, int b) {
        return query(a) == query(b);
    }

    void merge(int a, int b) {
        master[query(a)] = master[b];
    }
};

int kruskal(vector<pi_ii>& minEdge, DisjointSet& ds) {
    int cost = 0;
    
    for (auto& [w, uv]: minEdge) {
        auto& [u, v] = uv;

        if (ds.connected(u, v)) continue;

        ds.merge(u, v);
        cost += w;
    }

    return cost;
}

int main() {
    ios_base::sync_with_stdio(0), cin.tie(0), cout.tie(0);

    int vertexCount, edgeCount;
    cin >> vertexCount >> edgeCount;
    DisjointSet ds(vertexCount);

    vector<pi_ii> minEdge(edgeCount);
    for (auto& [w, uv]: minEdge) {
        auto& [u, v] = uv;
        cin >> u >> v >> w;
	}
    
    sort(minEdge.begin(), minEdge.end());

    cout << kruskal(minEdge, ds) << '\n';
}
  • 一般是稱為 Prim's algorithm,但是是 Jarník 先發現的
  • 步驟:
    1. 選擇任意一點開始
    2. 選擇目前點集連出去所有邊中最小者
    3. 將該邊的另外一端加入當前點集
    4. 重複直到所有點都被加入當前點集

為什麼是對的

  • 🈹性質!

為什麼是對的

  • 🈹性質!
8
1
3
2
7
5
6
0
4
9

為什麼是對的

  • 🈹性質!
8
1
3
2
7
5
6
0
4
9
13
2
2
5
2
7
9
2
6
8
2
3
4
7
1

為什麼是對的

  • 🈹性質!
8
1
3
2
7
5
6
0
4
9
13
2
2
5
2
7
9
2
6
8
2
3
4
7
1

為什麼是對的

  • 🈹性質!
8
1
3
2
7
5
6
0
4
9
13
2
2
5
2
7
9
2
6
8
2
3
4
7
1

為什麼是對的

  • 🈹性質!
8
1
3
2
7
5
6
0
4
9
13
2
2
5
2
7
9
2
6
8
2
3
4
7
1

為什麼是對的

  • 🈹性質!
8
1
3
2
7
5
6
0
4
9
13
2
2
5
2
7
9
2
6
8
2
3
4
7
1

為什麼是對的

  • 🈹性質!
8
1
3
2
7
5
6
0
4
9
13
2
2
5
2
7
9
2
6
8
2
3
4
7
1

為什麼是對的

  • 🈹性質!
8
1
3
2
7
5
6
0
4
9
13
2
2
5
2
7
9
2
6
8
2
3
4
7
1

為什麼是對的

  • 🈹性質!
8
1
3
2
7
5
6
0
4
9
13
2
2
5
2
7
9
2
6
8
2
3
4
7
1

為什麼是對的

  • 🈹性質!
8
1
3
2
7
5
6
0
4
9
13
2
2
5
2
7
9
2
6
8
2
3
4
7
1

為什麼是對的

  • 🈹性質!
8
1
3
2
7
5
6
0
4
9
13
2
2
5
2
7
9
2
6
8
2
3
4
7
1

為什麼是對的

  • 🈹性質!
8
1
3
2
7
5
6
0
4
9
13
2
2
5
2
7
9
2
6
8
2
3
4
7
1
  • 每次皆可視為以選取和未選取兩點集
    • 這個割的割集符合割性質!

Prim Code

  • 這樣的複雜度是 \(\mathcal O(|E|\log|E|)\)
  • 可以用 Fibonacci heap 做到 \(\mathcal O(|E| + |V|\log|V|)\)
typedef pair<int, int> pii;

int prim(vector< vector<pii> >& graph) {
    priority_queue<pii, vector<pii>, greater<pii> > pq;
    vector<int> currentMinCost(graph.size(), INT32_MAX);
    vector<bool> inside(graph.size(), false);
    int sum = 0;

    pq.push({0, 0});
    currentMinCost[0] = 0;

    while (!pq.empty()) {
        auto [w, u] = pq.top();
        pq.pop();

        if (inside[u]) continue;
        inside[u] = true;
        sum += w;

        for (auto& [w, v]: graph[u]) {
            if (!inside[v] && currentMinCost[v] > w) {
                currentMinCost[v] = w;
                pq.push({w, v});
            }
        }
    }

    return sum;
}

int main() {
    ios_base::sync_with_stdio(0), cin.tie(0), cout.tie(0);

    int vertexCount, edgeCount;
    cin >> vertexCount >> edgeCount;

    vector< vector<pii> > graph(vertexCount);
    int u, v, w;
    for (int i = 0; i < edgeCount; i++) {
        cin >> u >> v >> w;
        graph[u].push_back({w, v});
        graph[v].push_back({w, u});
    }

    cout << prim(graph) << '\n';
}

例題

Shortest Path

最短路徑

最短路徑

  • 在生活中,我們常常遇到交通上的問題
  • 所以這大概是整堂資讀裡面跟生活最相關的了
  • 討論最短路徑的時候,會有不同的限制條件,例如:
    • 帶權/無權
    • 有負邊/無負邊
    • 有負環/無負環
    • 單點源/全點對
    • ……
  • 待會會介紹一些常見的最短路徑演算法

無權單點源最短路徑

  • 之前講過了!
  • 就是 BFS
  • 通常是類似走迷宮的題目
  • 如果是樹的話只要 DFS 就好
  • 複雜度 \(\mathcal O(|V| + |E|)\)

全點對最短路徑

  • 今天我們想要把整張圖的所有點對互相抵達的最短距離都算出來
    • 全點對最短路徑
  • 要怎麼做才好
  • 動態規劃!
    • 令 \(dp[k][i][j]\) 為使用前 \(k\) 個點中繼,從 \(i\) 到 \(j\) 的最短距離
    • \(dp[k+1][i][j] = min(dp[k][i][j], dp[k][i][k+1] + dp[k][k+1][j])\)
    • 可以滾動,所以 \(k\) 不用寫在狀態裡面
  • ​時間複雜度:\(\mathcal O(|V|^3)\)
  • 好寫,但不能處理負邊
vector< vector<int> > d(vC, vector<int>(vC, 1e9 + 225));
for (int i = 0; i < vC; i++) d[i][i] = 0;
int u, v, w;
for (int i = 0; i < eC; i++) {
    cin >> u >> v >> w;
    d[u][v] = min(d[u][v], w);
}

for (int k = 0; k < vC; k++) {
    for (int i = 0; i < vC; i++) {
        for (int j = 0; j < vC; j++) {
            d[i][j] = min(d[i][j], d[i][k] + d[k][j]);
        }
    }
}

單點源最短路徑

  • 講到單點源(single-source)最短路徑,就一定要提到一個名詞
  • 鬆弛(relaxation)
    • 定義從起點 \(s\) 到點 \(i\) 的最短距離為 \(d_i\)
    • 有什麼條件時可以使 \(d_u\) 降低?
      • 存在一個邊 \(e = (u, v)\)
      • \(d_u > d_v + w_e\)

單點源最短路徑

s
v
u
2
25
  • 講到單點源(single-source)最短路徑,就一定要提到一個名詞
  • 鬆弛(relaxation)
    • 定義從起點 \(s\) 到點 \(i\) 的最短距離為 \(d_i\)
    • 有什麼條件時可以使 \(d_u\) 降低?
      • 存在一個邊 \(e = (u, v)\)
      • \(d_u > d_v + w_e\)

單點源最短路徑

  • 講到單點源(single-source)最短路徑,就一定要提到一個名詞
  • 鬆弛(relaxation)
    • 定義從起點 \(s\) 到點 \(i\) 的最短距離為 \(d_i\)
    • 有什麼條件時可以使 \(d_u\) 降低?
      • 存在一個邊 \(e = (u, v)\)
      • \(d_u > d_v + w_e\)
  • 不覺得比較適合叫他「繃緊」嗎?
s
v
u
2
25
2
  • 剛才我們定義了 \(d_i\)
  • 只要存在一個邊 \(e = (u, v)\) 滿足 \(d_v > d_u + e_w\) 就得更新 \(d_v\)
  • 怎麼更新 \(d_v\)?
  • 暴力來!
    • 先將所有點和原點距離設為 \(\infty\)(一個很大的數)
    • 只要遍歷 \(E\),將需要鬆弛的點鬆弛
      • 遍歷一次?
      • 要遍歷 \(E\) 幾次才會是對的?

遍歷 \(E\) 的次數

  • 剛剛我們已經知道遍歷 \(E\) 若干次會是好的了
    • 總有一天他會是對的
    • 跑個 while 迴圈,沒辦法鬆弛就結束?
      • 不是不行
      • 那這樣做的複雜度會是?
  • 如果觀察一下,會發現在一張無負環的簡單圖:
    • 為什麼要「無負環」?
    • 一條簡單路徑最多只有 \(|V| - 1\) 條邊
      • 只要超過 \(|V| - 1\) 條邊就會有環
    • 最多只要鬆弛 \(|V| - 1\) 次!
    • 時間複雜度 \(\mathcal O(|V||E|)\)

Bellman-Ford Code

  • 看起來超笨
  • 其實只要發現沒有鬆弛就可以跳出
struct Edge {
    int u, v, w;
};

vector<int> Bellman_Ford(int vertexCount, int source, vector<Edge>& edge) {
    vector<int> distance(vertexCount, INT32_MAX);

    distance[source] = 0;

    for (int i = 1; i < vertexCount; i++) {
        for (auto& [u, v, w]: edge) {
            distance[u] = min(distance[u], distance[v] + w);
            distance[v] = min(distance[v], distance[u] + w);
        }
    }

    return distance;
}

佇列最佳化

  • 一直比較不能再鬆弛的點好笑嗎
  • 只要跑上次有被鬆弛到的點就好了!
  • 這個做法在中國被叫做 Shortest Path Faster Algorithm
    • 謝囉
  • ​​​所以要怎麼實作「只嘗試鬆弛上次鬆弛過的點」
    • 把起點放到 queue 中
    1. 從 queue 中取出 \(u\)
    2. 用有 \(u\) 的邊去鬆弛其他點
    3. 有被鬆弛到的全部丟入 queue
    4. 重複直到 queue 沒有元素
  • 這個做法得用鄰接串列!

SPFA 的複雜度

  • 這個演算法的複雜度感覺小很多了呢
    • 究竟進步了多少呢
    • \(\mathcal O(|V||E|) \longrightarrow \mathcal O(|V||E|)\)
  • 不是,這不是完全沒有進步嗎?
  • 實際上運行起來可不是這樣
    • 對於一個隨機生成的圖,他的期望複雜度是 \(\mathcal O(|V|+|E|)\)!
    • 快的跟鬼一樣
    • 不過注意,這裡指的是「隨機生成」時
    • 現在比賽已經有開始卡這種做法的趨勢,不一定能用了

SPFA Code

  • 雖然他已經沒了,但還是看一下
typedef pair<int, int> pii;

vector<int> SPFA(vector< vector<pii>& graph, int source) {
    vector<int> distance(graph.size(), INT32_MAX);
    distance[source] = 0;

    queue<int> q;
    vector<bool> inQueue(graph.size(), 0);
    q.push(source);

    while (!q.empty()) {
        auto v = q.front();
        q.pop();
        inQueue[v] = 0;

        for (auto& [u, w]: graph[v]) {
            distance[u] = min(distance[u], distance[v] + w);

            if (inQueue[u]) continue;
            q.push(u);
            inQueue[u] = 1;
        }
    }

    return distance;
}
  • 假設圖上沒有負邊
    • 沒有負邊可以有什麼特性?
    • 如果 \(d_u\) 已經被最小化?
      • 用 \(u\) 來鬆弛別人的結果 \(d'_v \ge d_u\)
    • 取當前距離最小的點 \(u\) 來鬆弛所有人!
  • 演算法長怎樣?
    1. 從圖上距離最小的點 \(u\) 開始,標記已使用
    2. 將所有該點連到的點鬆弛
    3. 以未被使用過最小的點繼續,直到沒有點

複雜度分析

  • 可以把 Dijkstra 的過程分成兩個部分
    • 找到目前距離最小且未被用過的點
    • 修改該點連到的點之距離
  • 複雜度為 \(\mathcal O(|E| \cdot T_{modify} + |V| \cdot T_{get \min})\)
  • 幾種常見實作
    • 自平衡二元樹 \(\mathcal O((|E| + |V|) \log |E|)\)
    • 暴力做線性搜 \(\mathcal O(|E| + |V|^2)\)
    • Fibonacci heap \(\mathcal O(|E| + |V| \log |V|)\)

Dijkstra Code

typedef pair<int, int> pii;

vector<int> Dijkstra(vector< vector<pii> >& graph, int source) {
    vector<int> distance(graph.size(), INT32_MAX);
    distance[source] = 0;

    vector<bool> visited(graph.size(), 0);
    priority_queue<pii, vector<pii>, greater<pii> > pq;
    pq.push({0, source});

    while (!pq.empty()) {
        auto [w, u] = pq.top();
        pq.pop();

        if (visited[u]) continue;
        visited[u] = 1;

        for (auto& [w, v]: graph[u]) {
            if (distance[v] <= distance[u] + w) continue;

            distance[v] = distance[u] + w;
            pq.push({distance[v], v});
        }
    }

    return distance;
}

例題

Connectivity

連通性

連通性

  • 如何判斷一張圖是否連通
    • 簡單的 BFS / DFS 都能處理
  • 但除了連通與否,連通的強度也是一大重點

連通性

  • 如何判斷一張圖是否連通
    • 簡單的 BFS / DFS 都能處理
  • 但除了連通與否,連通的強度也是一大重點
  • 如果中間的關鍵點沒了就不連通了
    • 越高的容錯率越好
  • 點連通度
    • 最少要拔掉多少點使得圖不連通
  • 邊連通度
    • 最少要拔掉多少邊使得圖不連通

連通度的重要性

  • 現實生活中,我們常常需要連通度不太差的網路
  • 如果供電系統壞掉一條電線或電線杆就停電,非常不好
    • 一般情況下我們會希望能建構出一個連通度 \(\ge 2\) 的網路

連通度的重要性

  • 現實生活中,我們常常需要連通度不太差的網路
  • 如果供電系統壞掉一條電線或電線杆就停電,非常不好
    • 一般情況下我們會希望能建構出一個連通度 \(\ge 2\) 的網路

連通度的重要性

  • 現實生活中,我們常常需要連通度不太差的網路
  • 如果供電系統壞掉一條電線或電線杆就停電,非常不好
    • 一般情況下我們會希望能建構出一個連通度 \(\ge 2\) 的網路

連通度的重要性

  • 現實生活中,我們常常需要連通度不太差的網路
  • 如果供電系統壞掉一條電線或電線杆就停電,非常不好
    • 一般情況下我們會希望能建構出一個連通度 \(\ge 2\) 的網路

連通度的重要性

  • 現實生活中,我們常常需要連通度不太差的網路
  • 如果供電系統壞掉一條電線或電線杆就停電,非常不好
    • 一般情況下我們會希望能建構出一個連通度 \(\ge 2\) 的網路

連通度的重要性

  • 現實生活中,我們常常需要連通度不太差的網路
  • 如果供電系統壞掉一條電線或電線杆就停電,非常不好
    • 一般情況下我們會希望能建構出一個連通度 \(\ge 2\) 的網路
  • 但有時候就是會遇到連通度只有 \(1\) 的網路
    • 每個點或每個邊被拔掉都會使圖不連通嗎?
    • 誰被拔掉會使圖不連通?

割點 Cut Vertex

  • 還記得什麼是「割」嗎
    • 使點集分割為兩個子集
    • 割點也是一樣的意思
      • 把割點拔掉就可以使圖不連通

割點 Cut Vertex

  • 還記得什麼是「割」嗎
    • 使點集分割為兩個子集
    • 割點也是一樣的意思
      • 把割點拔掉就可以使圖不連通

割點 Cut Vertex

  • 還記得什麼是「割」嗎
    • 使點集分割為兩個子集
    • 割點也是一樣的意思
      • 把割點拔掉就可以使圖不連通

割點 Cut Vertex

  • 還記得什麼是「割」嗎
    • 使點集分割為兩個子集
    • 割點也是一樣的意思
      • 把割點拔掉就可以使圖不連通
  • 又被稱為關節點 articulation vertex
  • 要怎麼求出所有的割點呢?

暴力作法

  • 要怎麼求出一個點 \(v\) 是不是割點呢?
    • 根據定義,把他去掉後圖片不連通的話,該點即為割點
    • 那我們就照定義做
      • 選一個點 \(v\),拔除之
      • 對圖做一次 DFS,判斷沒有經過 \(v\) 能否遍歷整張圖
      • 對所有點做以上操作
    • \(\mathcal O(|V|)\) 窮舉點,\(\mathcal O(|V| + |E|)\) DFS
      • 總複雜度 \(\mathcal O(|V|^2 + |V||E|)\)

DFS Tree

  • 在討論連通度時,我們有一個強大的工具,DFS Tree(DFST)
  • 在 DFS 走過所有能走的點時,碰到的邊可以分為以下幾種:
    • 樹邊(tree edge)
      • DFS 時候碰到新的點用的邊
      • 從祖先連到其子代
    • 返祖邊(back edge):從子孫連到祖先
    • 前向邊(forward edge):從祖先連到非子代的子孫
    • 交錯邊(cross edge):跨越兩顆樹的邊

DFS Tree 如何分類邊

  • 我們就照剛才的定義跑,然後為那些邊著色吧

DFS Tree 如何分類邊

  • 我們就照剛才的定義跑,然後為那些邊著色吧

DFS Tree 如何分類邊

  • 我們就照剛才的定義跑,然後為那些邊著色吧

DFS Tree 如何分類邊

  • 我們就照剛才的定義跑,然後為那些邊著色吧

DFS Tree 如何分類邊

  • 我們就照剛才的定義跑,然後為那些邊著色吧

DFS Tree 如何分類邊

  • 我們就照剛才的定義跑,然後為那些邊著色吧

DFS Tree 如何分類邊

  • 我們就照剛才的定義跑,然後為那些邊著色吧

DFS Tree 如何分類邊

  • 我們就照剛才的定義跑,然後為那些邊著色吧

DFS Tree 如何分類邊

  • 我們就照剛才的定義跑,然後為那些邊著色吧

DFS Tree 如何分類邊

  • 我們就照剛才的定義跑,然後為那些邊著色吧

DFS Tree 如何分類邊

  • 我們就照剛才的定義跑,然後為那些邊著色吧

DFS Tree 如何分類邊

  • 我們就照剛才的定義跑,然後為那些邊著色吧

DFS Tree 如何分類邊

  • 我們就照剛才的定義跑,然後為那些邊著色吧

DFS Tree 如何分類邊

  • 我們就照剛才的定義跑,然後為那些邊著色吧

DFS Tree 如何分類邊

  • 我們就照剛才的定義跑,然後為那些邊著色吧
  • 那如果換成無向圖呢?

DFS Tree 如何分類邊

  • 我們就照剛才的定義跑,然後為那些邊著色吧
  • 那如果換成無向圖呢?
    • 無向圖只有樹邊跟返祖邊,我們先來做無向圖吧

如何判斷誰是割點

  • 可以看得出來,這些點是割點

如何判斷誰是割點

  • 這樣好像看不出來什麼性質,移動一下好了
  • 可以看得出來,這些點是割點

如何判斷誰是割點

  • 一棵樹中除了葉都是割點
    • 拔除 \(v\) 使圖被分成 \(\deg(v)\) 塊
  • 在 DFS 樹中呢?
    • 若 \(v\) 的任意子孫不能連回祖先
    • 沒有返祖邊

DFS 樹的根節點 \(r\)

  • 只要子孫都能連回祖先,自己就不是割點
  • 對於 \(r\),自己沒有祖先可言
    • 子孫不可能連回祖先
  • 只要 \(\deg(r) > 1\),\(r\) 即為割點

利用割點性質

  • 只要符合剛才性質的,就是割點
  • 那我們照定義,對每個節點的子孫(\(\mathcal O(|V|)\))
    • 嘗試不經過該節點回到該節點的祖先(\(\mathcal O(|V| + |E|)\))
    • 單點複雜度 \(\mathcal O(|V|^2 + |V||E|)\)
      • 比剛剛還糟糕
      • 勢必得要加速演算法不然我教幹嘛

一位傳奇人物

  • Robert Endre Tarjan
    • 1986 圖靈獎
  • 貢獻
    • 並查集
    • 離線 LCA 演算法
    • 連通性演算法
    • splay tree
    • Fibonacci heap
  • 你今晚的夢魘

Tarjan 的想法

  • 剛剛我們的問題有:
    • 每次判定子節點連回祖先太久
    • 每個節點的子節點太多
  • 所以他就想到我們可以:
    • 定義一個函數 \(\text{low}(v)\) 回傳 \(v\) 不經過親代能走到的最低深度
    • 只要 \(\text{low}(v) < \text{depth}(u)\) 就代表 \(v\) 不通過 \(u\) 也能走回祖先
    • 如果維護成功,就可以均攤複雜度
  • 除此之外,在無向圖中:
    • \(u\) 的子代 \(v\) 的子樹只需存在一點 \(w\) 使得 \(\text{low}(w) < \text{depth}(u)\)
    • 只要查詢 \(v\) 就好,均攤只要 \(\mathcal O(1)\)

如何計算 \(\text{low}(v)\)

  • 一個點 \(v\) 如果能不靠祖先連到深度為 \(d\) 的點,代表:
    • 自己能有邊能連到深度為 \(d\) 的點
    • 自己的子孫能連到深度為 \(d\) 的點
  • ​\(\text{low}(v)\) 能從子代轉移!
    • ​DFS 時記錄然後遞迴完子代後比較就好!
    • 複雜度只有 \(\mathcal O(|V| + |E|)\)!

Tarjan's Cut Vertex

  • 記錄深度太麻煩了,不如我們記錄 DFS 入點時的順序吧!
vector<int> low(vC);
vector<int> preorder(vC);
vector<bool> isCutVertex(vC);

int counter = 0;
function<void(int, int)> dfs = [&](int u, int last) {
    low[u] = preorder[u] = ++counter;

    int child = 0;

    for (auto& v: graph[u]) {
        if (v == last) continue;

        if (preorder[v]) { // is back edge
            low[u] = min(low[u], preorder[v]);
        } else {
            dfs(v, u);

            if (last >= 0 && low[v] >= preorder[u]) {
                isCutVertex[u] = true;
            }

            child++;
            low[u] = min(low[u], low[v]);
        }
    }

    if (last < 0 && child > 1) isCutVertex[u] = true;
};

dfs(0, -1);

為什麼 \(\text{order(i)}\) 是好的

  • 相信他就好你管那麼多
  • 在建 DFS Tree 的時候,有以下兩種情況:
    • 往下遍歷時,碰到還沒有走訪過的點
      • 一般的樹邊
      • 連到的全部都是自己的子代
      • 該點的前序 (進入戳記) 一定大於自己
    • 往下遍歷時,碰到已經被走訪過的點
      • 一定是連到自己直系祖先的返祖邊
        • 如果連到不是自己的直系祖先?

Bridge

  • 我們已經討論完關鍵點了
  • 現在我們想要探討一個連通圖的關鍵邊
  • 拔掉就會使圖不連通的邊

Bridge

  • 我們已經討論完關鍵點了
  • 現在我們想要探討一個連通圖的關鍵邊
  • 拔掉就會使圖不連通的邊

Bridge

  • 我們已經討論完關鍵點了
  • 現在我們想要探討一個連通圖的關鍵邊
  • 拔掉就會使圖不連通的邊
  • 跟割點有什麼關係?

Bridge

  • 我們已經討論完關鍵點了
  • 現在我們想要探討一個連通圖的關鍵邊
  • 拔掉就會使圖不連通的邊
  • 跟割點有什麼關係?
  • 還是不知道?

還是 Tarjan 的想法

  • 我們把剛才那張圖變成它的 DFST
  • 我們把剛才那張圖變成它的 DFST

還是 Tarjan 的想法

還是 Tarjan 的想法

  • 我們把剛才那張圖變成它的 DFST
  • 對於一個點 \(v\)
    • 若其子樹裡沒有返祖邊能連到 \(v\) 的祖先 \(u\) 或以上
    • 則 \((u, v)\) 是一座橋
  • 一樣用 \(\text{low}(v)\) 函數處理
    • 改成 \(\text{low}(v) \le \text{order}(u)\)

Tarjan's Bridge

  • 重邊需要另外看,如果不是簡單圖要記得喔
vector<int> low(vC);
vector<int> order(vC);
set<pii> isBridge;

int counter = 0;
function<void(int, int)> dfs = [&](int current, int last) {
    low[current] = order[current] = ++counter;

    for (auto& v: graph[current]) {
        if (v == last) continue;

        if (order[v]) { // is back edge
            low[current] = min(low[current], order[v]);
            // a back edge always forms a cycle
        } else {
            dfs(v, current);
            low[current] = min(low[current], low[v]);

            if (low[v] > order[current]) {
                isBridge.insert({current, v});
            }
        }
    }
};

dfs(0, -1);

例題

Components

連通分量

連通分量的定義

  • 若在一張圖 \(G\) 上存在一個連通子圖 \(G'\),則 \(G'\) 為 \(G\) 的連通分量

連通分量的定義

  • 若在一張圖 \(G\) 上存在一個連通子圖 \(G'\),則 \(G'\) 為 \(G\) 的連通分量

連通分量的定義

  • 若在一張圖 \(G\) 上存在一個連通子圖 \(G'\),則 \(G'\) 為 \(G\) 的連通分量

連通分量的定義

  • 若在一張圖 \(G\) 上存在一個連通子圖 \(G'\),則 \(G'\) 為 \(G\) 的連通分量

連通分量的定義

  • 若在一張圖 \(G\) 上存在一個連通子圖 \(G'\),則 \(G'\) 為 \(G\) 的連通分量
  • 若連通分量無法再連到其他點,則此連通分量極大(maximal)
  • 通常我們預設討論的連通分量皆為極大連通分量

雙連通分量

  • 我們剛剛在把圖上的割點和橋找出來了
  • 現在我們可以把圖分隔成若干個區塊,中間以割點或橋相隔
  • 這些區塊被稱為雙連通分量(biconnected components, BCC)
    • 有些時候是:
      • 點雙連通分量稱為 biconnected components
        • 拔掉任何邊、點仍連通的連通分量
      • 邊雙連通分量稱為 bridge connected components
        • 拔掉任何邊仍連通的連通分量
    • 或是 2-vertex / edge-connected components
  • 雙連通分量有什麼用?

把圖「縮起來」

  • 邊雙連通分量

把圖「縮起來」

  • 邊雙連通分量
8
1
3
2
7
6
5
0
4
9

把圖「縮起來」

  • 邊雙連通分量
8
1
3
2
7
6
5
0
4
9

把圖「縮起來」

  • 邊雙連通分量
8
1
3
2
7
6
5
0
4
9

把圖「縮起來」

  • 邊雙連通分量
6
5
0
1239
487

把圖「縮起來」

  • 點雙連通分量

把圖「縮起來」

  • 點雙連通分量
8
1
3
2
7
6
5
4
9
0

把圖「縮起來」

  • 點雙連通分量
8
1
3
2
7
6
5
4
9
0

把圖「縮起來」

  • 點雙連通分量
8
1
3
2
7
6
5
0
4
9

把圖「縮起來」

  • 點雙連通分量
8
1
3
2
7
6
5
0
4
9

把圖「縮起來」

  • 點雙連通分量
  • 看起來沒有縮欸
2
5
4
9
1239
0
6
487

縮完點之後可以幹嘛

  • 有的時候我們不會喜歡一張有環的圖,複雜的圖很難被處理
  • 但是如果今天要處理的是一棵樹呢?
    • 只要把任何一張圖縮起來,他就會變成一棵樹(或森林)!
  • 我們可以透過找出連通分量來使問題變得可做
    • 要用割點或橋來分割就看題目要求
  • 要怎麼找連通分量呢?

邊雙連通分量

  • 找邊雙連通分量要做的事情就是不能走橋
  • 那我們就把橋都標記起來,然後 DFS
    • DFS 的時候若該邊是橋就不往下走
  • 成功了欸
    • 標記然後 DFS 應該不用教吧
  • 那點雙連通分量也是一樣嗎?

點雙連通分量

  • 橋雙連通分量是「由點為主體的集合」
  • 點雙連通分量是「由邊為主體的集合」
    • 這兩者有什麼最大的區別?

點雙連通分量

  • 橋雙連通分量是「由點為主體的集合」
  • 點雙連通分量是「由邊為主體的集合」
    • 這兩者有什麼最大的區別?
1
3
2
5
0
4

點雙連通分量

  • 橋雙連通分量是「由點為主體的集合」
  • 點雙連通分量是「由邊為主體的集合」
    • 這兩者有什麼最大的區別?
2
4
1234
0
5

點雙連通分量

  • 橋雙連通分量是「由點為主體的集合」
  • 點雙連通分量是「由邊為主體的集合」
    • 這兩者有什麼最大的區別?
  • 點雙連通分量不能輕鬆把點標記成不可通行
    • 因為一個點可以存在於兩個點雙連通分量
      • 誰?
      • 割點!
  • 這樣的話還能用跟剛才類似的方法嗎?
    • 可以!
    • 但很麻煩
    • 既然這樣,我們不如來想想別的作法吧

人生而懶惰

  • 我們做了一次 DFS,把 DFS 樹找出來
  • 接著我們對某些點 DFS,算出所有點雙連通分量
    • 可是做兩次 DFS 好麻煩,要寫不一樣的函式
    • 也許只要做一次?
  • 我們其實可以只做一次 DFS 來求得所有的點雙連通分量!
    • 在 DFS 離開一個點時做一點判定
    • 若該點是割點,把其子孫中沒有 BCC 的邊指派一個 BCC

這樣是對的嗎

  • 相信他就對了?
    • 根不管是否為割點

這樣是對的嗎

  • 相信他就對了?
    • 根不管是否為割點

這樣是對的嗎

  • 相信他就對了?
    • 根不管是否為割點

這樣是對的嗎

  • 相信他就對了?
    • 根不管是否為割點

這樣是對的嗎

  • 相信他就對了?
    • 根不管是否為割點

這樣是對的嗎

  • 相信他就對了?
    • 根不管是否為割點

這樣是對的嗎

  • 相信他就對了?
    • 根不管是否為割點

這樣是對的嗎

  • 相信他就對了?
    • 根不管是否為割點

這樣是對的嗎

  • 相信他就對了?
    • 根不管是否為割點

這樣是對的嗎

  • 相信他就對了?
    • 根不管是否為割點

這樣是對的嗎

  • 相信他就對了?
    • 根不管是否為割點

這樣是對的嗎

  • 相信他就對了?
    • 根不管是否為割點

這樣是對的嗎

  • 相信他就對了?
    • 根不管是否為割點

Vertex BCC Code

struct Edge {
    int a, b;

    Edge(int u = -1, int v = -1): a(u), b(v) {}
};

vector< vector<int> > graph;
vector<int> low, preorder;
vector<int> isCutVertex;
stack<Edge> s;

vector<int> bccId;
vector< vector<int> > bcc;

int counter = 0;
int bccCounter = 0;

void dfs(int u, int p) {
    low[u] = preorder[u] = ++counter;
    int child = 0;

    for (auto& v: graph[u]) {
        Edge e(u, v);

        if (v == p) continue;

        s.push(e);

        if (preorder[v]) {
            low[u] = min(low[u], preorder[v]);
            continue;
        }

        child++;
        dfs(v, u);
        low[u] = min(low[u], low[v]);

        if (low[v] >= preorder[u]) {
            isCutVertex[u] = 1;
            ++bccCounter;
            Edge temp;

            do {
                temp = s.top(), s.pop();

                if (bccId[temp.a] != bccCounter) bcc[bccCounter].push_back(temp.a);
                if (bccId[temp.b] != bccCounter) bcc[bccCounter].push_back(temp.b);

                bccId[temp.a] = bccId[temp.b] = bccCounter;
            } while (temp.a != u || temp.b != v);
        }

    }

    if (p <= 0 && child == 1) {
        isCutVertex[u] = 0;
    }
}

怎麼縮圖?

  • 其實直接照定義縮可能會長出很麻煩的東西
  • 有個資料結構叫做封鎖割樹圓方樹(block-cut tree)
    • 把同一個點雙連通分量改成:
      • 所有分量內的邊消失
      • 全部連到一個虛點(自己創的點),也就是方點
    • 這樣的話就一樣有樹的性質了
    • 看要求再把資訊放在圓點或方點上
  • 自己研究,我懶得放 code

回到邊雙連通分量

  • 剛剛我們討論完點雙連通分量一次求完的方法
  • 會發現其實邊雙連通分量也是一樣的概念
  • 而且其實邊雙連通比較好求?
  • 把 stack 裡面存的東西從邊換成點,判割點換成判定橋

Edge BCC Code

vector< vector<int> > graph;
vector<int> low, preorder;
stack<int> s;

vector<int> bccId;
vector< vector<int> > bcc;

int counter = 0;
int bccCounter = 0;

void dfs(int u, int p) {
    low[u] = preorder[u] = ++counter;
    s.push(u);

    for (auto& v: graph[u]) {
        if (v == p) continue;

        if (preorder[v]) {
            low[u] = min(low[u], preorder[v]);
            continue;
        }

        dfs(v, u);
        low[u] = min(low[u], low[v]);

        if (low[v] > preorder[u]) {
            ++bccCounter;
            int w;

            do {
                w = s.top(), s.pop();
                bccId[w] = bccCounter;
            } while (w != u);
        }

    }

    if (p < 0) {
        ++bccCounter;
        int w;
        while (!s.empty()) {
            w = s.top(), s.pop();
            bccId[w] = bccCounter;
        }
    }
}

有向圖上的連通分量

  • 我們終於要來講我們逃離很久的有向圖了!
  • 我們在討論有向圖的連通性的時候,會討論以下兩種分量
    • 強連通分量(strongly connected component)
      • 對於任意兩點 \(u, v\),存在 \(u \rightarrow v\) 的路徑
      • 注意不用「拔掉一個點或邊仍然成立」,不用雙連通
    • 弱連通分量(weakly connected component)
      • 對於任意兩點 \(u, v\),存在 \(u \rightarrow v\) \(v \rightarrow u\) 的路徑
      • 注意弱連通分量的定義和弱連通圖不太一樣
        • ​為什麼?
        • 我的解釋:「這種定義應用比較多」

求強連通分量的意義

  • 一個強連通分量會使我們沒辦法好好轉移狀態

求強連通分量的意義

  • 一個強連通分量會使我們沒辦法好好轉移狀態

求強連通分量的意義

  • 一個強連通分量會使我們沒辦法好好轉移狀態

求強連通分量的意義

  • 一個強連通分量會使我們沒辦法好好轉移狀態

求強連通分量的意義

  • 一個強連通分量會使我們沒辦法好好轉移狀態
    • 縮點之後變成一張有向無環圖(DAG)!
    • 只要是 DAG 就可以拓樸排序了

Tarjan's SCC

  • 對,還是他,每次都是他
  • 應該知道要用什麼了吧
    • 沒錯!就是 DFS 樹
    • 但是現在,我們要面對 DFS 樹的每一種邊了!
      • 樹邊
      • 返祖邊
      • 前向邊
      • 交錯邊
    • 怎麼辦?
      • 不怎麼辦,就正常處理多判一點條件就好了

樹邊 vs. 前向邊

  • 戳到樹邊就是一定代表進入點時為首次入點嗎?
    • 不是,有可能前向邊先戳到了
      • 那怎麼辦?
      • 不怎麼辦!
    • 若 \(u\) 先被樹邊戳到,\(\text{low}(u)\) 已經轉移了
      • 就把它用 visited 判定,然後正常來就好
    • \(u\) 先被前向邊戳到呢?
      • ​在樹邊再次戳到時,一樣轉移就好
    • ​樹邊和前向邊不會產生影響!

返祖邊 vs. 交錯邊

  • 返祖邊跟交錯邊同樣有機會戳到比自己輩分大的點
    • 怎麼區分兩者?
    • 交錯邊會從祖先另外一個子樹戳過來
      • 如果他們不是同一個 SCC,那就不轉移
        • 如果戳到的點已經被分派 SCC 了,那它一定是交錯邊

返祖邊 vs. 交錯邊

  • 返祖邊跟交錯邊同樣有機會戳到比自己輩分大的點
    • 怎麼區分兩者?
    • 交錯邊會從祖先另外一個子樹戳過來
      • 如果他們不是同一個 SCC,那就不轉移
        • 如果戳到的點已經被分派 SCC 了,那它一定是交錯邊

返祖邊 vs. 交錯邊

  • 返祖邊跟交錯邊同樣有機會戳到比自己輩分大的點
    • 怎麼區分兩者?
    • 交錯邊會從祖先另外一個子樹戳過來
      • 如果他們不是同一個 SCC,那就不轉移
        • 如果戳到的點已經被分派 SCC 了,那它一定是交錯邊

返祖邊 vs. 交錯邊

  • 返祖邊跟交錯邊同樣有機會戳到比自己輩分大的點
    • 怎麼區分兩者?
    • 交錯邊會從祖先另外一個子樹戳過來
      • 如果他們不是同一個 SCC,那就不轉移
        • 如果戳到的點已經被分派 SCC 了,那它一定是交錯邊

返祖邊 vs. 交錯邊

  • 返祖邊跟交錯邊同樣有機會戳到比自己輩分大的點
    • 怎麼區分兩者?
    • 交錯邊會從祖先另外一個子樹戳過來
      • 如果他們不是同一個 SCC,那就不轉移
        • 如果戳到的點已經被分派 SCC 了,那它一定是交錯邊

返祖邊 vs. 交錯邊

  • 返祖邊跟交錯邊同樣有機會戳到比自己輩分大的點
    • 怎麼區分兩者?
    • 交錯邊會從祖先另外一個子樹戳過來
      • 如果他們不是同一個 SCC,那就不轉移
        • 如果戳到的點已經被分派 SCC 了,那它一定是交錯邊

返祖邊 vs. 交錯邊

  • 返祖邊跟交錯邊同樣有機會戳到比自己輩分大的點
    • 怎麼區分兩者?
    • 交錯邊會從祖先另外一個子樹戳過來
      • 如果他們不是同一個 SCC,那就不轉移
        • 如果戳到的點已經被分派 SCC 了,那它一定是交錯邊

返祖邊 vs. 交錯邊

  • 返祖邊跟交錯邊同樣有機會戳到比自己輩分大的點
    • 怎麼區分兩者?
    • 交錯邊會從祖先另外一個子樹戳過來
      • 如果他們不是同一個 SCC,那就不轉移
        • 如果戳到的點已經被分派 SCC 了,那它一定是交錯邊

返祖邊 vs. 交錯邊

  • 返祖邊跟交錯邊同樣有機會戳到比自己輩分大的點
    • 怎麼區分兩者?
    • 交錯邊會從祖先另外一個子樹戳過來
      • 如果他們不是同一個 SCC,那就不轉移
        • 如果戳到的點已經被分派 SCC 了,那它一定是交錯邊

返祖邊 vs. 交錯邊

  • 返祖邊跟交錯邊同樣有機會戳到比自己輩分大的點
    • 怎麼區分兩者?
    • 交錯邊會從祖先另外一個子樹戳過來
      • 如果他們不是同一個 SCC,那就不轉移
        • 如果戳到的點已經被分派 SCC 了,那它一定是交錯邊

返祖邊 vs. 交錯邊

  • 返祖邊跟交錯邊同樣有機會戳到比自己輩分大的點
    • 怎麼區分兩者?
    • 交錯邊會從祖先另外一個子樹戳過來
      • 如果他們不是同一個 SCC,那就不轉移
        • 如果戳到的點已經被分派 SCC 了,那它一定是交錯邊

返祖邊 vs. 交錯邊

  • 返祖邊跟交錯邊同樣有機會戳到比自己輩分大的點
    • 怎麼區分兩者?
    • 交錯邊會從祖先另外一個子樹戳過來
      • 如果他們不是同一個 SCC,那就不轉移
        • 如果戳到的點已經被分派 SCC 了,那它一定是交錯邊

返祖邊 vs. 交錯邊

  • 返祖邊跟交錯邊同樣有機會戳到比自己輩分大的點
    • 怎麼區分兩者?
    • 交錯邊會從祖先另外一個子樹戳過來
      • 如果他們不是同一個 SCC,那就不轉移
        • 如果戳到的點已經被分派 SCC 了,那它一定是交錯邊

返祖邊 vs. 交錯邊

  • 返祖邊跟交錯邊同樣有機會戳到比自己輩分大的點
    • 怎麼區分兩者?
    • 交錯邊會從祖先另外一個子樹戳過來
      • 如果他們不是同一個 SCC,那就不轉移
        • 如果戳到的點已經被分派 SCC 了,那它一定是交錯邊

返祖邊 vs. 交錯邊

  • 返祖邊跟交錯邊同樣有機會戳到比自己輩分大的點
    • 怎麼區分兩者?
    • 交錯邊會從祖先另外一個子樹戳過來
      • 如果他們不是同一個 SCC,那就不轉移
        • 如果戳到的點已經被分派 SCC 了,那它一定是交錯邊

返祖邊 vs. 交錯邊

  • 返祖邊跟交錯邊同樣有機會戳到比自己輩分大的點
    • 怎麼區分兩者?
    • 交錯邊會從祖先另外一個子樹戳過來
      • 如果他們不是同一個 SCC,那就不轉移
        • 如果戳到的點已經被分派 SCC 了,那它一定是交錯邊

返祖邊 vs. 交錯邊

  • 返祖邊跟交錯邊同樣有機會戳到比自己輩分大的點
    • 怎麼區分兩者?
    • 交錯邊會從祖先另外一個子樹戳過來
      • 如果他們不是同一個 SCC,那就不轉移
        • 如果戳到的點已經被分派 SCC 了,那它一定是交錯邊

返祖邊 vs. 交錯邊

  • 返祖邊跟交錯邊同樣有機會戳到比自己輩分大的點
    • 怎麼區分兩者?
    • 交錯邊會從祖先另外一個子樹戳過來
      • 如果他們不是同一個 SCC,那就不轉移
        • 如果戳到的點已經被分派 SCC 了,那它一定是交錯邊

返祖邊 vs. 交錯邊

  • 返祖邊跟交錯邊同樣有機會戳到比自己輩分大的點
    • 怎麼區分兩者?
    • 交錯邊會從祖先另外一個子樹戳過來
      • 如果他們不是同一個 SCC,那就不轉移
        • 如果戳到的點已經被分派 SCC 了,那它一定是交錯邊

返祖邊 vs. 交錯邊

  • 返祖邊跟交錯邊同樣有機會戳到比自己輩分大的點
    • 怎麼區分兩者?
    • 交錯邊會從祖先另外一個子樹戳過來
      • 如果他們不是同一個 SCC,那就不轉移
        • 如果戳到的點已經被分派 SCC 了,那它一定是交錯邊

Tarjan's SCC Code

  • 用 scc[i] 判斷交錯邊是否要更新
#include <bits/stdc++.h>

using namespace std;

vector<int> tarjan(vector< vector<int> >& graph) {
    vector<int> preorder(graph.size());
    vector<int> low(graph.size());
    vector<int> scc(graph.size());
    stack<int> s;

    int counter = 0;
    int sccId = 0;
    function<void(int)> dfs = [&](int u) {
        preorder[u] = low[u] = ++counter;
        s.push(u);

        for (auto& v: graph[u]) {
            if (scc[v]) continue;

            if (preorder[v]) low[u] = min(low[u], preorder[v]);
            if (!preorder[v]) {
                dfs(v);
                low[u] = min(low[u], low[v]);
            }
        }

        if (low[u] == preorder[u]) {
            ++sccId;
            int v;
            do {
                v = s.top(), s.pop();
                scc[v] = sccId;
            } while (u != v);
        }
    };

    for (int i = 0; i < graph.size(); i++) if (!preorder[i]) dfs(i);

    return scc;
}

另一位連通超人

  • Sambasiva Rao Kosaraju
  • 這個人想到了另外一種算強連通的方法
    • 太神奇了,他不是 Tarjan 欸
    • 但是他沒有發表該論文
    • 但他不是 Tarjan 欸
  • 讓我們來看看他怎麼想的吧!

把邊倒過來的圖

  • 在談 Kosaraju 的算法的時候,就要先談一個重要的東西
    • 反圖(transpose / converse / reverse graph)
      • 對於一個圖 \(G(V, E)\),若有一個圖:
        • 和它有相同的點集
        • 所有的邊 \((u, v)\) 都有能一一對應的邊 \((v, u)\)
      • 則稱這張圖為 \(G\) 的反圖,記為 \(G'\) 或 \(G^T, G^R \cdots\)
    • 反圖可以拿來幹嘛?

反圖的性質

  • 我們把一張圖和它的反圖放在一起,看看它們之間的關係吧

反圖的性質

  • 我們把一張圖和它的反圖放在一起,看看它們之間的關係吧

反圖的性質

  • 我們把一張圖和它的反圖放在一起,看看它們之間的關係吧

反圖的性質

  • 我們把一張圖和它的反圖放在一起,看看它們之間的關係吧
    • 反圖和原圖具有相同的 SCC!
    • 若在原圖中,存在一條 \(u\) 到 \(v\) 的路徑
      • 反圖也存在一條 \(u\) 到 \(v\) 的路徑 \(\leftrightarrow\) \(u, v\) 在同一個 SCC 中

Kosaraju's SCC

  • 對一張有向圖做「偽」拓樸排序
    • 也就是「將圖 DFS 一次,並記錄其後序(離開戳記)」
  • 依照偽拓樸排序的結果,將點由後序大到小在反圖上 DFS
    • 給所有當前點開始走沒有 SCC 的點同一個新的 SCC
  • 做完了!
    • 真的假的?
    • 對,就這樣,超級好寫
    • 但是時間執行起來比 Tarjan 稍微久了一點點
  • 對於沒辦法馬上了解 Tarjan 的人,學習 Kosaraju 比較方便
  • 甚至有些人只記 Kosaraju's SCC,確保自己會 SCC

Kosaraju's SCC Code

  • 就跟你說了,短的跟鬼一樣
void dfs(int current, vector<int>& postorder, vector<bool>& visited, vector< vector<int> >& graph) {
    visited[current] = 1;

    for (auto& v: graph[current]) {
        if (visited[v]) continue;
        dfs(v, postorder, visited, graph);
    }

    postorder.push_back(current);
}

void sfd(int current, vector<int>& scc, const int id, vector< vector<int> >& hparg) {
    scc[current] = id;

    for (auto& v: hparg[current]) {
        if (scc[v]) continue;
        sfd(v, scc, id, hparg);
    }
}

把東西包成函式處理

  • 你會覺得比較長是因為我把建反圖放在裡面
vector<int> kosaraju(vector< vector<int> >& graph) {
    vector<bool> visited(graph.size());
    vector<int> postorder;
    postorder.reserve(graph.size());

    function<void(int)> dfs = [&](int u) {
        if (visited[u]) return;

        for (auto& v: graph[u]) {
            visited[v] = true;
            dfs(v);
        }

        postorder.push_back(u);
    };

    for (int u = 0; u < graph.size(); u++) dfs(u);


    vector< vector<int> > hparg(graph.size());
    for (int u = 0; u < graph.size(); u++) {
        for (auto& v: graph[u]) hparg[v].push_back(u);
    }

    vector<int> scc(graph.size());
    int sccCounter = 0;

    function<void(int)> sfd = [&](int u) {
        scc[u] = sccCounter;

        for (auto& v: hparg[u]) {
            if (scc[v]) continue;
            sfd(v);
        }
    };
	
    reverse(postorder.begin(), postorder.end());
    for (auto& u: postorder) {
        if (!scc[u]) ++sccCounter, sfd(u);
    }

    return scc;
}

Kosaraju 的特色

  • 比較好寫
  • 相對直觀
  • 不是 Tarjan
  • 常數比較大
  • 不是 Tarjan

\(2-\text{SAT}\)

  • 在資訊科學中,有個著名的問題,\(k\) 可滿足性問題
    • 布林變數(boolean variable):
      • \(v = 1 \leftrightarrow \neg v = 0\)
      • \(v = 0 \leftrightarrow \neg v = 1\)
    • 文字(literal):\(l = v\ /\ \neg v\)
    • 子句(clause):\(c = (l_1 \vee l_2 \vee l_3 \vee \cdots \vee l_k)\)
    • 合取範式(conjunctive normal form, CNF):
      • 符合形如 \(c_1 \land c_2 \land \cdots \land c_n\) 的布林算式
    • 析取範式(disjunctive normal form, DNF):
      • ​\(\vee\) 和 \(\land\) 互換的 CNF
  • ​​\(k-\text{SAT}\) 就是求解子句長度皆為 \(k\) 之 CNF 的問題

一開始

  • 我們先從 \(1-\text{SAT}\) 開始吧
  • 子句長度為 \(1\) 的 CNF 是什麼?
    • 形如 \(l_1 \land l_2 \land \cdots \land l_n\)
    • 例:\(\ a \land \neg b \land c \land \neg d\)
  • 不就是把所有東西都設成 true 就好嗎
    • 還有檢查有沒有 \(l_i \land \neg l_i\) 存在
    • 沒了,就這樣
  • 阿你是在期待什麼

回到 \(2-\text{SAT}\)

  • 所以這要怎麼解
    • 我都把它放在圖論了,應該跟圖論有點關係吧?
  • 我們先想一下在一個子句 \(c=(a \vee b)\) 中,有什麼性質吧
    • 在 \(c = 1\) 時,\(b = 1\) 是 \(\neg a = 1\) 的必要條件
    • 那 \(b = 1\) 是 \(\neg a = 1\) 的充分條件嗎?
      • ​不是!
    • 如果 \(c_1 \land c_2 \land \cdots \land c_n = 1\),則 \(c_i = 1\)
      • 若 \(c_i = (a_i,\,b_i)\):
        • \(\neg a \rightarrow b\)
        • \(\neg b \rightarrow a\)

把狀態化成圖

  • 剛剛狀態都化成箭頭了,不要不知道這是有向圖欸

把狀態化成圖

  • 剛剛狀態都化成箭頭了,不要不知道這是有向圖欸
  • 然後呢?
  • 多一些狀態好了,例如 \((a \vee \neg b)\land(b \vee c)\land(\neg a \vee \neg c)\land(b \vee \neg d)\)
a
b
\neg a
\neg b

把狀態化成圖

  • 剛剛狀態都化成箭頭了,不要不知道這是有向圖欸
  • 然後呢?
  • 多一些狀態好了,例如 \((a \vee \neg b)\land(b \vee c)\land(\neg a \vee \neg c)\land(c \vee \neg d)\)
a
b
\neg a
\neg b
c
d
\neg c
\neg d

什麼情況會無解

  • 當 \(a\) 和 \(\neg a\) 同時需要成立的時候

什麼情況會無解

  • 當 \(a\) 和 \(\neg a\) 同時需要成立的時候
  • \((a \vee \neg b)\land(b \vee \neg c)\land(\neg a \vee \neg b)\land(b \vee c)\)
a
\neg a
b
\neg b
c
\neg c

什麼情況會無解

  • 當 \(a\) 和 \(\neg a\) 同時需要成立的時候
  • \((a \vee \neg b)\land(b \vee \neg c)\land(\neg a \vee \neg b)\land(b \vee c)\)
    • ​當 \(a\) 和 \(\neg a\) 在同一個 SCC 中時!
a
\neg a
b
\neg b
c
\neg c

如果不是強連通?

  • 不在同一個強連通分量的話,就一定可以構造解

如果不是強連通?

  • 不在同一個強連通分量的話,就一定可以構造解
  • \((a \vee \neg b)\land(b \vee \neg c)\land(a \vee b)\land(\neg b \vee \neg c)\)
a
\neg a
b
\neg b
c
\neg c

如果不是強連通?

  • 不在同一個強連通分量的話,就一定可以構造解
  • \((a \vee \neg b)\land(b \vee \neg c)\land(a \vee b)\land(\neg b \vee \neg c)\)
  • 從沒有被需求的開始
    • 反過來拓樸排序!
a
\neg a
b
\neg b
c
\neg c

課後練習

  • 都會 1-SAT 和 2-SAT 了吧
    • 相信大家都會數學歸納法
  • 請回家解 3-SAT,寫完跟我對答案
  • 補充參考資料

例題

Thank you

但我不是你們的偶像

建中資讀 5th 圖論

By Brine

建中資讀 5th 圖論

迷圖羔陽

  • 784