圖論[1]

Graph Theory[1]

INFOR 36th. @小海_夢想特急_夢城前

Index

講師

  • 225 賴柏宇
  • 海之音、小海或其他類似的
  • 美術能力見底
  • 表達能力差,不懂要問
  • INFOR 36th 學術長
  • 這頁是偷來的
  • 不會圖論所以來當圖論講師

Reference

  • 本次的講師很弱
  • 本次提供的範例 Code 可能有誤,請見諒
  • 為了提高可讀性,拆成很多函式以及回傳值處理,有時會導致常數較大,請自行修改
  • 表達能力很差,所以聽不懂 一定 一定 要問
  • 講師永遠不會放棄你 啊對 保有最終解釋權 人很ㄐㄅ不在此範圍
  • 簡報或我的觀念可能有誤,歡迎隨時糾正
  • 有些奇怪的連結是我推薦的歌.jpg (不是 Rickroll...)

聲明

拓撲  排序

Topological Sort.

  • Topology
  • 「特定物件經過連續映射後不變性質的研究」
  • 較經典的例子像是「甜甜圈和馬克杯是同胚的」
  • 以比較視覺化的方式來說
    • 物體經過不包括黏合的變形後視為同個物體
  • 在圖論裡,類似地,我們不關心權重 (邊長),只關心連接關係

拓撲

  • Topology
  • 「特定物件經過連續映射後不變性質的研究」
  • 較經典的例子像是「甜甜圈和馬克杯是同胚的」
  • 以比較視覺化的方式來說
    • 物體經過不包括黏合的變形後視為同個物體
  • 在圖論裡,類似地,我們不關心權重 (邊長),只關心連接關係

拓撲

  • 你會發現在有向圖裡常存在一種狀況:
  • A 可以走到 B,但 B 不能走到 A
    • 以下圖舉例, 0 可以走到 2,但 2 無論如何都走不回 0
    • 1 可以走到 2,但 2 不能走到 1

有向圖的特性

0
1
2
  • 定義這樣的狀況叫 「 A 的輩分比 B 高」
  • 實際上應該沒有正式名稱
  • 如果對於一張圖的每個點對都符合這樣的關係,就可以做排序
  • 不一定是唯一的

有向圖的特性

1
2
0
  • 簡單的例外:存在環的時候不行 (顯然)
  • 只要無環都可行
    • 如果 A 能走到 B,B 不能走到 A
    • B 能走到 C ,C 不能走到 B
    • 如果 C 能走到 A 表示有環,所以 C 一定不能走到 A,因此這三點能被排序
    • 考慮更多點也會正確

唯一的例外

  • 對於一個排序後的點序列                                ,圖裡面每個有向邊              ,滿足
  • 每個有向邊都是往後面 (輩分較小) 指

形式化表述

\{p_1, p_2, ..., p_{|V|}\}
(p_i, p_j)
i < j
  • 我們排序完之後,在關心輩分較小的點時可以不用關心輩分大的點
  • 有時候在做事時,真的就得關心點的順序
    • 從起點一路推到終點
    • 從終點反推起點狀態
  • 很多時候,這東西和 DP 會扯上關係
    • 轉移時可以確保不會有前面的點的問題!
    • 等等講最短路就會遇到 (非常快)

為什麼要拓撲排序

  • 注意到排序中輩分最大的那個點一定不會有邊連到它,也就是入度為 0
  • 輩分比它低但最大的點 (也就是第二大的) 一定只會被比它大的點連到(也就是最大點)
    • 如果去除最大點的影響,第二大的入度一定是 0
    • 視為新的圖處理!

演算法概念

  1. 找到入度為 0 的點
  2. 把那個點還有相關的邊拔掉,更新入度
  3. 找到新的入度為 0 的點,放在原本的點後面
  • 重複執行以上流程,直到沒有入度為 0 的點
  • 如果這時還有點沒被拔掉,說明此圖有有向環!

執行流程

  • 類似 BFS ,你會發現如果暴力找點會跑很慢
  • 入度為 0 只會在被更新時發生!
  • 一樣用 queue 把那些入度為 0 的點推入!
  • 所以
    1. 計算所有點的入度
    2. 找到入度為 0 的點,推入 queue
    3. 取出 queue 的前端,放入排序結果
    4. 拔掉這個點,更新入度
      • 如果被拔掉後入度為 0,則推入 queue
    5. 重複直到 queue 為空

BFS 做法

BFS 做法

0
1
2
3
4

Queue: {}

Result: {}

BFS 做法

0
1
2
3
4

Queue: {0, 4}

Result: {}

BFS 做法

1
2
3
4

Queue: {4, 1}

Result: {0}

BFS 做法

1
2
3

Queue: {1}

Result: {0, 4}

BFS 做法

2
3

Queue: {2}

Result: {0, 4, 1}

BFS 做法

3

Queue: {3}

Result: {0, 4, 1, 2}

BFS 做法

Queue: {}

Result: {0, 4, 1, 2, 3}

vector<vector<int>> graph;
vector<int> topological_sort() {
    int n = graph.size();
    vector<int> result;
    vector<int> indegree(n, 0);
    queue<int> nexts;
    // count indegree
    for (int i = 0; i < n; i++)
        for (int j = 0; j < graph[i].size(); j++)
            indegree[graph[i][j]]++;
    
    for (int i = 0; i < n; i++)
        if (indegree[i] == 0) nexts.push(i);
    
    while (!nexts.empty()) {
        int cur = nexts.front();
        nexts.pop();
        result.push(cur);
        for (int i = 0; i < graph[cur].size(); i++) {
            int nxt = graph[cur][i];
            indegree[nxt]--;
            if (indegree[nxt] == 0) nexts.push(nxt);
        }
    }
    
    return result;
}ult;
}

  • 如果序列結果     有                    ,依據之前的結論可知道此圖有環
  • 反之此圖無有向環,稱為有向無環圖 (DAG)

正確性

P
|P| < |V|
  • 有 BFS 做法當然有 DFS 做法
  • 會一種做法就好了,為什麼要學這個做法?
    • 時間戳記 (time stamp) 的概念以後會用到

DFS 做法

  • 在遍歷的過程中碰到點 / 離開點的時間順序

Time Stamp

struct Node {
    int in_time, out_time;
    bool visited = false;
    vector<int> neighbor;
};
void dfs(int cur, vector<Node> &graph) {
    static int time_stamp = 0;
    graph[cur].in_time = time_stamp++;
    graph[cur].visited = true;
    for (const auto &nxt : graph[cur].neighbor)
        if (!graph[nxt].visited) dfs(nxt, graph);
    graph[cur].out_time = time_stamp++;
}
  • 實際上在幹嘛

Time Stamp

0
(*, *)
1
(*, *)
2
(*, *)
4
(*, *)
3
(*, *)
5
(*, *)
6
(*, *)
  • 實際上在幹嘛

Time Stamp

0
(0, *)
1
(*, *)
2
(*, *)
4
(*, *)
3
(*, *)
5
(*, *)
6
(*, *)
  • 實際上在幹嘛

Time Stamp

0
(0, *)
1
(1, *)
2
(*, *)
4
(*, *)
3
(*, *)
5
(*, *)
6
(*, *)
  • 實際上在幹嘛

Time Stamp

0
(0, *)
1
(1, *)
2
(*, *)
4
(2, *)
3
(*, *)
5
(*, *)
6
(*, *)
  • 實際上在幹嘛

Time Stamp

0
(0, *)
1
(1, *)
2
(*, *)
4
(2, 3)
3
(*, *)
5
(*, *)
6
(*, *)
  • 實際上在幹嘛

Time Stamp

0
(0, *)
1
(1, 4)
2
(*, *)
4
(2, 3)
3
(*, *)
5
(*, *)
6
(*, *)
  • 實際上在幹嘛

Time Stamp

0
(0, *)
1
(1, 4)
2
(5, *)
4
(2, 3)
3
(*, *)
5
(*, *)
6
(*, *)
  • 實際上在幹嘛

Time Stamp

0
(0, *)
1
(1, 4)
2
(5, 6)
4
(2, 3)
3
(*, *)
5
(*, *)
6
(*, *)
  • 實際上在幹嘛

Time Stamp

0
(0, 7)
1
(1, 4)
2
(5, 6)
4
(2, 3)
3
(*, *)
5
(*, *)
6
(*, *)
  • 實際上在幹嘛

Time Stamp

0
(0, 7)
1
(1, 4)
2
(5, 6)
4
(2, 3)
3
(8, *)
5
(*, *)
6
(*, *)
  • 實際上在幹嘛

Time Stamp

0
(0, 7)
1
(1, 4)
2
(5, 6)
4
(2, 3)
3
(8, 9)
5
(*, *)
6
(*, *)
  • 實際上在幹嘛

Time Stamp

0
(0, 7)
1
(1, 4)
2
(5, 6)
4
(2, 3)
3
(8, 9)
5
(10, *)
6
(*, *)
  • 實際上在幹嘛

Time Stamp

0
(0, 7)
1
(1, 4)
2
(5, 6)
4
(2, 3)
3
(8, 9)
5
(10, *)
6
(11, *)
  • 實際上在幹嘛

Time Stamp

0
(0, 7)
1
(1, 4)
2
(5, 6)
4
(2, 3)
3
(8, 9)
5
(10, *)
6
(11, 12)
  • 實際上在幹嘛

Time Stamp

0
(0, 7)
1
(1, 4)
2
(5, 6)
4
(2, 3)
3
(8, 9)
5
(10, 13)
6
(11, 12)
  • 實際上在幹嘛

Time Stamp

0
(0, 7)
1
(1, 4)
2
(5, 6)
4
(2, 3)
3
(8, 9)
5
(10, 13)
6
(11, 12)
  • 值得觀察的是離開戳記
    • 如果 u 輩分比 v 大
      • 先到 u:u 的離開戳記較大
      • 先到 v:因為無法馬上到 u,u 的離開戳記較大
    • 如果有環
      • 注意到遇到「有進入戳記但無離開戳記」表示還沒離開但卻可以到
      • 這種情況下有環

DFS 做法

0
(0, 7)
1
(1, 4)
2
(5, 6)
4
(2, 3)
3
(8, 9)
5
(10, 13)
6
(11, 12)
  • 結論:離開戳記比較大則輩分較大!
  • 在 DFS 時一邊偵測是否有環,一邊在離開時記錄排序結果
  • 其實可以利用「是否是同一次 DFS」來判斷是否有環

DFS 做法

0
(0, 7)
1
(1, 4)
2
(5, 6)
4
(2, 3)
3
(8, 9)
5
(10, 13)
6
(11, 12)
vector<int> topological_sort(const vector<vector<int>> &graph) {
    vector<int> result;
    result.reserve(graph.size());
    vector<int> in_time(graph.size(), 0), out_time(graph.size(), 0);
    auto dfs = [&](int cur, auto &&dfs) -> bool {
        static int time_stamp = 0;
        in_time[cur] = ++time_stamp;
        for (const auto &nxt : graph[cur]) {
            if (!in_time[nxt]) {
                if (!dfs(nxt, dfs)) return false;
            }
            else if (!out_time[nxt]) {
                return false;
            }
        }
        result.push_back(cur);
        out_time[cur] = ++time_stamp;
        return true;
    };
    for (int origin = 0; origin < graph.size(); origin++)
        if (!in_time[origin])
            if (!dfs(origin, dfs)) return vector<int>(0);
    return result;
}

注意如果是用 vector 直接 push_back 排出來的結果是由輩分小到大

bool dfs(int cur, const vector<vector<int>> &graph, vector<int> &result, vector<int> &in_time, vector<int> &out_time) {
    static int time_stamp = 0;
    in_time[cur] = ++time_stamp;
    for (const auto &nxt : graph[cur]) {
        if (!in_time[nxt]) {
            if (!dfs(nxt, graph, result, in_time, out_time)) return false;
        } else if (!out_time[nxt])
            return false;
    }
    result.push_back(cur);
    out_time[cur] = ++time_stamp;
    return true;
}
vector<int> topological_sort(const vector<vector<int>> &graph) {
    vector<int> result;
    result.reserve(graph.size());
    vector<int> in_time(graph.size(), 0), out_time(graph.size(), 0);
    for (int origin = 0; origin < graph.size(); origin++)
        if (!in_time[origin])
            if (!dfs(origin, graph, result, in_time, out_time)) return vector<int>(0);
    return result;
}

給看不懂 lambda 函式的人

最短路算法

Shortest Path.

  • 欸 非常直觀 最短路就是最短路
  • 你可以拿來算你家到學校的最短路啊 我不反對(
  • 主要分成幾種:
    • 單點源/全點對
    • 帶權/不帶權
    • 有負邊/無負邊
    • 有負環/無負環
  • 依照條件不同,有不同的演算法

最短路

  • 單點源:選定一個源點,看源點到其他點最短是多少
  • 就是 BFS
  • 如果是樹 DFS 就夠了,通常我們會叫它深度

單點源 + 不帶權

  • 單點源帶權最短路的基礎
  • 如果存在一個中繼邊              使得源點到     的距離      可以縮短(也就是                             )就要更新
  • 叫鬆弛不叫繃緊的原因是因為採用了更多的邊(大概)

鬆弛 (relaxation)

e(u, v)
u
d_u
d_v + w_{e} < d_u
7122
O
v
u
2
d_u = 7122
  • 單點源帶權最短路的基礎
  • 如果存在一個中繼邊              使得源點到     的距離      可以縮短(也就是                             )就要更新
  • 叫鬆弛不叫繃緊的原因是因為採用了更多的邊(大概)

鬆弛 (relaxation)

e(u, v)
u
d_u
d_v + w_{e} < d_u
7122
O
v
u
2
2
d_u = 7122
d_v + w_{uv} = 2 + 2< 7122
  • 單點源帶權最短路的基礎
  • 如果存在一個中繼邊              使得源點到     的距離      可以縮短(也就是                             )就要更新
  • 叫鬆弛不叫繃緊的原因是因為採用了更多的邊(大概)

鬆弛 (relaxation)

e(u, v)
u
d_u
d_v + w_{e} < d_u
7122
O
v
u
2
2
d_u = 4
  • 沿用定義      指當前從源點到點    的最短距離
  • 暴力一點想想看
    • 一開始將源點到所有點的距離設成無限大
    • 一直跑過邊集,有得更新就更新
    • 沒得更新就結束
  • 時間複雜度?

Bellman-Ford

d_i
i
  • 不管怎麼說,在程式裡我們都不希望看到 while(true) 這種東西
  • 具體來說更新幾次後會是好的?
    • 不考慮負環,簡單路徑最多有                條邊
    • 更新                次可以確保所有邊都產生影響了
  • 時間複雜度
  • 有負環(權重加總為負的環)會卡死,可以一直卡在裡面不出來權重就會越來越小
  • 如果更新完                仍可以更新,表示存在負環

O(while(true))?

|V|-1
|V|-1
O(|V||E|)
|V|-1
  • 只有上一輪被鬆弛過的點會鬆弛到旁邊的點
    • 可以用 BFS 的想法,把有鬆弛到的點推進去 queue 裡面,具體來說:
    1. 取出一個點
    2. 用這個點鬆弛附近的點
    3. 把成功鬆弛的點放回去 queue
    • queue 空的時候就做完了
  • 因為要用到「附近的點」,所以得用鄰接串列

優化

  • 這作法叫 SPFA (Shortest Path Faster Algorithm)
  • 時間複雜度?
    •  
    • 這樣沒有變好啊?
    • 注意到對於隨機生成的圖可以做到
  • 注意,是對於「隨機生成」的圖,比賽要卡也是可以卡成
  • 因此,如果比賽的解可能會用到 SPFA ,則它的測資範圍應該要是 Bellman-Ford 可以過的

優化

O(|V||E|)
O(|V|+|E|)
O(|V||E|)
vector<int> Bellman_Ford(int origin, int n, const vector<vector<pair<int, int>>> &graph) {
    vector<int> d(graph.size(), INF); // 從源點到各個點的答案
    for (int i = 0; i < graph.size(); i++)
        for (int v = 0; v < graph.size(); v++)
            for (const pair<int, int> &edge: graph[v])
#define edge.second w
#define edge.first u
                if (d[v] + w < d[u]) d[u] = d[v] + w;
    return d;
}
vector<int> SPFA(int origin, const vector<vector<pair<int, int>>> &graph;) {
    vector<int> d(graph.size(), INF);
    vector<bool> inQueue(graph.size(), false);
    queue<int> nexts;
    nexts.push(origin);
    inQueue[origin] = true;
    while (!nexts.empty()) {
        int v = nexts.front();
        nexts.pop();
        inQueue[v] = false;
        for (const pair<int, int> &edge : graph[v]) {
#define w edge.second
#define u edge.first
            if (d[v] + w < d[u] && !inQueue[u]) {
                d[u] = d[v] + w;
                nexts.push(u);
                inQueue[u] = true;
            }
        }
    }
    return d;
}
  • 在日常生活中,兩點距離不為負比較正常吧
  • 如果不帶負權可以做哪些優化
    • 注意到如果      已經最小了,用    鬆弛別人得到      都會大等      ,
    • 並且,如果用比較大的                       來鬆弛       ,怎麼樣都不會鬆弛成功
  • 我們可以證明如果每次都選最小的點去鬆弛其他人會是好的!
  • 這樣每條邊只會被用到一次

不帶負權

d_u
u
d_v'
d_u
d_u + w = d_v' \geq d_u
d_v, d_v \geq d_u
d_u
  • 證明每次選擇當前距離最小的點 u 總是好的
    • 注意到如果用其他點鬆弛 u 並不會比較好,因為距離更長
    • 用 u 鬆弛其他人的距離也會更長,無法反過來鬆弛 u
    • u 是最短路,得證

證明(?

  • 剛剛講的東西如何實作?
  • 把 BFS 的 queue 換成 priority queue 就可以了
  • 在 priority queue 裡面記錄更新後的 d、點 v
  1. 將一個點取出並標記 visited
  2. 鬆弛附近的點 v,得到新的 d ,如果有鬆弛把 (d, v) 丟進 priority queue 裡面
  3. 以沒有被使用過的、距離最小的點繼續

Dijkstra

struct Edge {
    int v, w;
    friend bool operator<(const Edge& a, const Edge& b) {
        return a.w > b.w;
    }
};
vector<int> Dijkstra(int origin) {
	vector<int> d(graph.size(), INF);
	vector<bool> visited(graph.size(), false);
	d[origin] = 0;
	priority_queue<Edge> nexts;
	nexts.push({origin, 0});
	while (!nexts.empty()) {
		Edge cur = nexts.top();
		nexts.pop();
		if (visited[cur.v]) continue;
		visited[cur.v] = true;
		for (const Edge& e : graph[cur.v]) {
			if (d[cur.v] + e.w >= d[e.v]) continue;
			d[e.v] = d[cur.v] + e.w;
			nexts.push({e.v, d[e.v]});
		}
	}
	return d;
}

吃點毒

  • 兩個部分
    • 選當前距離最小點
    • 修改點
  •  
  • 依據實作的不同,有不同的複雜度
    • 陣列暴力搜尋最小值:
    • binary heap:
    • 一些奇怪的堆:

時間複雜度

O(|V| + |E|^2)
O(|V| + |E|\ log\ |E|)
O(|V|\ log\ |V| + |E|)
O(|V|T_{getmin} + |E|T_{modify})

單點源最短路算法比較

Bellman-Ford

處理負環

處理負權

帶環

時間複雜度

Dijkstra

處理負環

處理負權

帶環

時間複雜度

DAG 上最短路

處理負環

處理負權

帶環

時間複雜度

帶負權/負環時用

O(|V||E|)

一般情況最常用

補充

O(|E|\ log\ |E|)
O(|V|+|E|)
  • Floyd-Warshall
  • 開二維陣列記錄每個點對          之間的最短路
  • 不要懷疑,DP
    • 令                  是           用前    個點作中繼點的最短距離
    •  
  • 複雜度
  • 可以滾動

全點對最短路

dp[u][v][i+1] = min(dp[u][v][i], dp[u][i][i] + dp[i][v][i])
dp[u][v][i]
(u, v)
(u, v)
O(|V|^3)
i

一樣,處理不了負環

但是負權會是好的

vector<vector<int>> Floyd_Warshall(const vector<vector<pair<int, int>>> &graph) {
    vector<vector<int>> result(graph.size(), vector<int>(graph.size(), INF));
    for (int i = 0; i < graph.size(); i++) result[i][i] = 0;
    for (int u = 0; u < graph.size(); u++)
        for (const auto &[v, w] : graph[u])
            result[u][v] = w;
    for (int k = 0; k < graph.size(); k++)
        for (int u = 0; u < graph.size(); u++)
            for (int v = 0; v < graph.size(); v++)
                result[u][v] = min(result[u][v], result[u][k] + result[k][v]);
    return result;
}

併查集

Disjoint Set Union Algorithm, DSU

「我是你今晚的噩夢。」

「參加資讀就別想逃過我的魔爪。」

Tarjan   

– Robert Endre Tarjan,
沒有說過

「不少他發明的算法都以他的名字命名,以至於有時會讓人混淆幾種不同的算法。」

– 維基百科

Dis - 不, Joint - 共同, Set - 集合

"Dis" "joint" "set"

有交集

Dis - 不, Joint - 共同, Set - 集合

"Dis" "joint" "set"

不交集

簡單來說,各集合彼此之間沒有交集

  • 一個資料結構,專門處理這種集合的問題
  • 各集合沒有共同元素
    • 一個元素必定屬於其中一個集合
  • 有哪些性質你希望可以套用在這種資料結構上的?
  • 它應該支援哪些操作?

併查集

  • 一個標準的併查集,應該支援
    • 查詢
      • 查詢一個元素所在的集合,回傳一個集合的代表元素
    • 合併
      • 合併兩個集合
  • 其他可以支援的操作
    • 查詢目前有幾個集合
    • 每個集合有幾個元素

併查集

  • 話是這麼說沒錯,但為什麼不是在資結教?
    • 因為這和圖論關係大啊廢話
  • 如果我們將有邊連在一起的點視在同一個集合
    • 查詢:回傳一個點作為代表元素
    • 合併:加入一條邊
    • 可以查詢兩個點是否連通
      • 連通分量

圖論上

  • 令         為    所在集合的代表元素
  • 我們假設一開始有 n 個集合
  • 每個集合內都只有一個元素,即

實作

1
q(i)
i
q(i) = i
2
0
3
4
  • 令         為    所在集合的代表元素
  • 我們假設一開始有 n 個集合
  • 每個集合內都只有一個元素,即
    • 合併兩集合時要怎麼選擇代表元素?

實作

1
q(i)
i
q(i) = i
2
0
3
4
  • 令         為    所在集合的代表元素
  • 我們假設一開始有 n 個集合
  • 每個集合內都只有一個元素,即
    • 合併兩集合時要怎麼選擇代表元素?
    • 合併兩個點要怎麼選擇代表元素?

實作

1
q(i)
i
q(i) = i
2
0
3
4
  • 令         為    所在集合的代表元素
  • 我們假設一開始有 n 個集合
  • 每個集合內都只有一個元素,即
    • 合併兩個點要怎麼選擇代表元素?
    • 不如令                  ?

實作

1
q(i)
i
q(i) = i
2
0
3
4
q(v) = u
  • 令         為    所在集合的代表元素
  • 我們假設一開始有 n 個集合
  • 每個集合內都只有一個元素,即
    • 合併兩個點要怎麼選擇代表元素?
    • 不如令                  ?

實作

1
q(i)
i
q(i) = i
2
0
3
4
q(v) = u
q(1) = 0
  • 令         為    所在集合的代表元素
  • 我們假設一開始有 n 個集合
  • 每個集合內都只有一個元素,即
    • 合併兩個點要怎麼選擇代表元素?
    • 不如令                  ?

實作

1
q(i)
i
q(i) = i
2
0
3
4
q(v) = u
q(1) = 0
q(2) = 1
  • 令         為    所在集合的代表元素
  • 我們假設一開始有 n 個集合
  • 每個集合內都只有一個元素,即
    • 合併兩個點要怎麼選擇代表元素?
    • 不如令                  ?

實作

1
q(i)
i
q(i) = i
2
0
3
4
q(v) = u
q(1) = 0
q(2) = 1

啊啊爛了,1 和 2 回傳的代表元素不同

  • 令         是    指向它的代表元素的「過程」?
    • 什麼意思?
    • 如果目前的元素還指向其他點,就要繼續追查下去!
    • 這樣代表元素唯一嗎?

修改 q(i) 定義

1
q(i)
i
2
0
3
4
q(1) = 0
q(2) = 1

查詢

struct DSU {
    vector<int> master;
    DSU(int n) {
        master.resize(n);
        for (int i = 0; i < n; i++) master[i] = i;
    }
    int find(int i) {
        if (master[i] == i) return i;
        return find(master[i]);
    }
};
  • 如果有人特別麻煩,弄出了以下的東西:

一些奇怪例外

1
2
0
q(1) = 2
q(2) = 0
q(0) = 1
  • 如果有人特別麻煩,弄出了以下的東西:
  • 你可以照                    的想法走,一律把數字小接往數字大
  • 有必要嗎?
  • 如果在每次合併時就看看兩個點是否在同一集合就沒這問題了
  • 或者,利用源頭合併就沒問題了

一些奇怪例外

1
2
0
q(1) = 2
q(2) = 0
q(0) = 1
struct DSU {
    void combine(int a, int b) {
        master[find(a)] = find(b);
    }
};

合併

看起來的樣子

0
1
2
4
3
1
3

看起來的樣子

0
1
2
4
3

Merge(2, 3)

1
  • 類似二元搜尋樹,這東西也會退化:
    • 查詢變成           了

問題

0
1
2
3
O(n)
  • 類似二元搜尋樹,這東西因為沒有環所以其實是很多棵樹(森林)
  • 所以如果能修正樹高,搜尋的複雜度就可以降低

修正樹高

0
1
2
3
  • 最簡單的想法:既然查詢同一條路徑會有同樣的答案,那每次查完就把整條路徑更新成答案

壓縮路徑

0
1
2
3
q(0) = 0
q(1) = 0
q(2) = 1
q(3) = 2
  • 最簡單的想法:既然查詢同一條路徑會有同樣的答案,那每次查完就把整條路徑更新成答案

壓縮路徑

0
1
2
3
q(0) = 0
q(1) = 0
q(2) = 1
q(3) = 2
  • 最簡單的想法:既然查詢同一條路徑會有同樣的答案,那每次查完就把整條路徑更新成答案

壓縮路徑

0
1
2
3
q(0) = 0
q(1) = 0
q(2) = 1
q(3) = 2
  • 最簡單的想法:既然查詢同一條路徑會有同樣的答案,那每次查完就把整條路徑更新成答案

壓縮路徑

0
1
2
3
q(0) = 0
q(1) = 0
q(2) = 1
q(3) = 2
  • 最簡單的想法:既然查詢同一條路徑會有同樣的答案,那每次查完就把整條路徑更新成答案

壓縮路徑

0
1
2
3
q(0) = 0
q(1) = 0
q(2) = 1
q(3) = 2
  • 最簡單的想法:既然查詢同一條路徑會有同樣的答案,那每次查完就把整條路徑更新成答案

壓縮路徑

0
1
2
3
q(0) = 0
q(1) = 0
q(2) = 1
q(3) = 2
  • 最簡單的想法:既然查詢同一條路徑會有同樣的答案,那每次查完就把整條路徑更新成答案

壓縮路徑

0
1
2
3
q(0) = 0
q(1) = 0
q(2) = 0
q(3) = 2
  • 最簡單的想法:既然查詢同一條路徑會有同樣的答案,那每次查完就把整條路徑更新成答案

壓縮路徑

0
1
2
3
q(0) = 0
q(1) = 0
q(2) = 0
q(3) = 0

查詢完後有效降低樹高了!

  • 最簡單的想法:既然查詢同一條路徑會有同樣的答案,那每次查完就把整條路徑更新成答案

壓縮路徑

0
1
2
3
q(0) = 0
q(1) = 0
q(2) = 0
q(3) = 0

查詢完後有效降低樹高了!

但這樣單次查詢還是 O(n) 耶...

  • 不只看「單次」操作複雜度,而是操作好幾次後平均
    • 聚集法:T(n)/操作次數
    • 記帳法:Credit
    • 位能法:位能函數
  • 一般是考慮最壞情況
  • 併查集使用路徑壓縮後單次操作降為
  • 我不會證明

均攤分析

O(log_{(1+\frac {Q}{|V|})}|V|)
  • 啟發式算法:把奇怪的東西依照經驗用在一起,唬爛一下感覺複雜度不會爛就是了(?
  • 在不需要精確解的情況下,不需要那麼多時間空間的算法
  • 如果你覺得我講了和沒講一樣,恭喜你理解了它的精髓

啟發式合併

  • 我們想要讓樹的高度降低,其實可以從合併時下手
  • 怎麼合併是好的?

啟發式合併

樹高:根到最遠點路徑長

h_1
h_2
  • 我們想要讓樹的高度降低,其實可以從合併時下手
  • 怎麼合併是好的?

啟發式合併

樹高:根到最遠點路徑長

h_1
h_2
  • 我們想要讓樹的高度降低,其實可以從合併時下手
  • 怎麼合併是好的?

啟發式合併

樹高:根到最遠點路徑長

新樹高:

h_1
h_2
max(h_1, h_2 + 1)
  • 我們想要讓樹的高度降低,其實可以從合併時下手
  • 怎麼合併是好的?
  •                                :讓      比      大,樹高就不會增加
  • 在樹根記錄那棵樹目前的高度
  • 複雜度?

啟發式合併

h_2
max(h_1, h_2 + 1)
h_2
h_1
h_1
  • 假設達到高度     最少要經過          次合併
  •  
  •  
  • 觀察前幾項,0, 1, 3, 7, 15, 31...
  •  
  • 樹高不超過                   ,因此查詢複雜度

複雜度分析

h
f(h)
f(h + 1) = 2f(h) + 1
f(1) = 0
f(h) = 2^h - 1,\ h = log(f(h) + 1) = log\ |V|
\lceil log(|V|) \rceil
O(log\ |V|)
  • 把兩種優化套在一起
  • 時間複雜度?
    •  
    • 我說過了,我不會證明
  • 什麼是
    • 阿克曼函數的反函數
      • 阿克曼函數:                      ,共 x 個 2
      • 反函數:                        ,也就是說,這東西很小

もっと速く

O(\alpha (|V|))
\alpha (|V|)
\Alpha (x) = 2^{2^{2^{...}}}
\alpha (\Alpha(x)) = x
struct DisjointSet {
    vector<int> master, depth;
    DisjointSet(int n) {
        master.resize(n);
        depth.resize(n, 0);
        for (int i = 0; i < n; i++) master[i] = i;
    }
    int find(int n) {
        if (n == master[n]) return n;
        return (master[n] = find(master[n]));
    }
    void combine(int a, int b) {
        a = find(a), b = find(b);
        if (a == b) return;
        if (depth[a] < depth[b]) swap(a, b);
        else if (depth[a] == depth[b]) depth[a]++;
        master[b] = a;
    }
};

綜合

APCS 2023 十月場 P3 / ZJ m372

最小生成樹

Minimum Spanning Tree, MST.

  • 考慮以下問題:
    • 有 n 個地點需要(有線)網路,其中可以連接 m 條可能的邊,每條邊有價格      ,求總價格最小的做法
  • 這個問題有哪些限制?
    • 這個問題的解是原圖的一個子圖,並且點集與原圖相等
    • n 個點都需要被至少一條邊連通
    • 顯然,在正常情況(              )下,它會是一棵樹
  • 解是唯一的嗎?

最小生成子圖

w_i
w_i > 0
  • 環性質
  • 對於圖上任一個環 C 中最大的邊 e 一定不在最小生成樹中
  • 反證法
    • 如果 e 在最小生成樹 T 中,刪除 e 將會使 T 分裂成兩個子樹
    • 必定存在 C 中另一邊 e' 使得兩個子樹連通
    • 兩子樹權重和不變,但 e 被換成更小的 e'
    • 新的生成樹 T' 權重和更小,矛盾

Cycle Property

  • 如果最大權不只一個?
    • MST 不是唯一的,最大權邊一定不在某個 MST 中

Cycle Property

0
1
2
3
4
5
1
2
3
4
5
6
7
8
9
0
1
2
3
4
5
1
2
3
4
5
6
7
8
9
0
1
2
3
4
5
1
2
3
4
5
6
7
8
9
0
1
2
3
4
5
1
2
3
4
5
6
7
8
9
0
1
2
3
4
5
1
2
3
4
5
6
7
8
9
0
1
2
3
4
5
1
2
3
4
5
6
7
8
9
  • 如果在加入邊時,就偵測是否會形成環?
    • 在原圖無環的情況下,加入一條邊最多可能會形成幾個環?
    • 怎麼判斷加入的邊會不會形成環?
      • 利用剛剛學的併查集,看看兩個點是不是原本就連通!
    • 如何尋找環中最大的邊?

Kruskal

  • 我們不如保證加入的邊一定是最大的
    • 怎麼做?
    • 排序!
  • 依照邊權由小到大加入,如果會形成環就不採用這個邊
  • 正確性
    • 保證生成出的圖會是無環的 (樹)
    • 不採用的邊都是保證不在某個 MST 中的
    • 所以保證生成出的是某個 MST

Kruskal

  • 複雜度
    • 對邊排序:
    • 對每個邊使用併查集:
  • 總複雜度:

Kruskal

O(|E|\ log\ |E|)
O(|E|\ \alpha (|V|))
O(|E|\ log\ |E|)
0
1
2
3
4
5
0
1
2
3
4
5
1
0
1
2
3
4
5
1
2
0
1
2
3
4
5
1
2
3
0
1
2
3
4
5
1
2
3
4
0
1
2
3
4
5
1
2
3
4
5
0
1
2
3
4
5
1
2
3
4
5
6
0
1
2
3
4
5
1
2
3
4
5
6
7
0
1
2
3
4
5
1
2
3
4
5
6
7
8
0
1
2
3
4
5
1
2
3
4
5
6
7
8
9
namespace MST {
    struct DSU {
        vector<int> master, depth;
        DSU(int n) {
            master.resize(n);
            depth.resize(n, 0);
            for (int i = 0; i < n; i++) master[i] = i;
        }
        int find(int n) {
            return (n == master[n]) ? n : (master[n] = find(master[n]));
        }
        void combine(int a, int b) {
            a = find(a), b = find(b);
            if (a == b) return;
            if (depth[a] < depth[b]) swap(a, b);
            else if (depth[a] == depth[b]) depth[a]++;
            master[b] = a;
        }
    };

    vector<vector<pair<int, int>>> Kruskal(const vector<vector<pair<int, int>>> &graph) {
        DSU components(graph.size());
        vector<pair<int, pair<int, int>>> edges;  // {w, {u, v}}
        vector<vector<pair<int, int>>> result(graph.size());

        for (int u = 0; u < graph.size(); u++)
            for (const auto &[v, w] : graph[u])
                edges.push_back({w, {u, v}});

        sort(edges.begin(), edges.end());

        for (const auto &[w, e] : edges) {
#define u e.first
#define v e.second
            if (components.find(u) != components.find(v)) {
                components.combine(u, v);
                result[u].push_back({v, w}), result[v].push_back({u, w});
            }
        }
#undef u
#undef v
        return result;
    }
}

事實上如果原本圖就用邊串列存會方便很多

如果用鄰接串列存圖建議用下一個寫法

  • 割性質
  • 什麼是割?
    • 點集分為二子點集
  • 割集(也就是跨越兩個點集的邊的集合)中邊權最小的一定在 MST 裡
  • 反證法
    • left as an exercise for the reader

Cut Property

0
1
2
3
4
5
1
2
3
4
5
6
7
8
9
  • 我們可以枚舉一個點集的子集,這樣割集是唯一的
    • 是沒錯,但聽起來就很爛
    • 有沒有什麼改良的方法?
  • 觀察小的割集長怎樣
    • 當其中一個點集 V' 只有一個點?
    • 連出去的最小邊一定沒問題

Prim's Algorithm

0
1
2
3
4
5
1
2
3
4
6
7
8
9
5
  • 接著我們要避免之後的割集考慮到這條邊
    • 那我們將這條邊連到的點加入 V' 吧
  • 這樣我們又有一個新的點集
    • 如此迭代就可以了
  • 要怎麼找割集?
    • 每次迭代都暴力找?
    • 老方法了,有新的邊都記錄下來
    • 每次都要求最小值
      • priority queue

Prim's Algorithm

0
1
2
3
4
5
1
2
3
4
6
7
8
9
5
  • 時間複雜度?
    • 剛剛那樣的實作是
    • 用其他堆可以

Prim's Algorithm

0
1
2
3
4
5
1
2
3
4
6
7
8
9
5
O(|E|\ log\ |E|)
O(|E| + |V|\ log\ |V|)
0
1
2
3
4
5
1
2
3
4
5
6
7
8
9
0
1
2
3
4
5
1
2
3
4
5
6
7
8
9
0
1
2
3
4
5
1
2
3
4
5
6
7
8
9
0
1
2
3
4
5
1
2
3
4
5
6
7
8
9
0
1
2
3
4
5
1
2
3
4
5
6
7
8
9
0
1
2
3
4
5
1
2
3
4
5
6
7
8
9
typedef pair<int, int> pii;
typedef pair<int, pii> pipii;
typedef vector<vector<pair<int, int>>> Graph;
Graph Prim(const Graph &graph) {
    // edge: {w, v}
    Graph result(graph.size());
    vector<bool> visited(graph.size(), false);
    priority_queue<pipii, vector<pipii>, greater<pipii>> nexts;
#define w first
#define u second.first
#define v second.second
    visited[0] = true;
    for (const auto &[nxt_w, nxt_v] : graph[0])
        nexts.push({nxt_w, {0, nxt_v}});
    while (!nexts.empty()) {
        auto cur_edge = nexts.top();
        nexts.pop();
        if (visited[cur_edge.v]) continue;
        visited[cur_edge.v] = true;
        result[cur_edge.u].push_back({cur_edge.w, cur_edge.v});
        result[cur_edge.v].push_back({cur_edge.w, cur_edge.u});
        for (const auto &[nxt_w, nxt_v] : graph[cur_edge.v])
            nexts.push({nxt_w, {cur_edge.v, nxt_v}});
    }
#undef u
#undef v
#undef w
    return result;
}

演算法 - 圖論[1]

By 海之音

演算法 - 圖論[1]

[ 建北電資聯合小社 / 四校聯合放課 ] - 圖論[1] / Graph Theory[1]

  • 202