拓撲排序 學習筆記
今天,我們來聊聊拓撲排序。
何為拓撲排序?
拓撲排序,這個顧名思義似乎有點難。那就直接上定義吧:
對於一個DAG(有向無環圖)\(G\),將 \(G\) 中所有頂點排序為一個線性序列,使得圖中任意一對頂點 \(u\) 和 \(v\),若 \(u\) 和 \(v\) 之間存在一條從 \(u\) 指向 \(v\) 的邊,那麼 \(u\) 線上性序列中一定在 \(v\) 前。
啥意思呢。比如這樣一個DAG:
幾種可能的拓撲序是:
- 1 2 4 3 5 6 7
- 1 2 3 4 5 6 7
- 1 4 2 5 6 3 7
也就是說,DAG的拓撲序可能並不唯一。
那麼,1 3 2 4 5 6 7是不是這張圖的拓撲序呢?答案是否定的,因為圖中存在 \(2 \rightarrow 3\)
現在,你對拓撲排序的理解一定加深了一些。那麼接下來讓我們思考一個問題,拓撲排序為什麼一定要在DAG上?不在DAG上難道不行嗎?
首先,DAG是有向無環圖的意思,我們從有向和無環兩個方面分別做反義詞,也就是無向,有環。
接下來我們證明為什麼這兩種情況不能出現。
為什麼有無向邊的圖無拓撲序?
假設存在有無向邊的有 \(n\) 個點的圖 \(G\) 的拓撲序 \(a\),那麼一定存在兩個數 \(i, j (1 \le i, j \le n)\),滿足 \(a_i \rightarrow a_j \in G, a_j \rightarrow a_i \in G\)
為什麼有環的圖無拓撲序?
假設存在有環的有 \(n\) 個點的圖 \(G\) 的拓撲序 \(a\),那麼一定存在 \(k(1 \le k \le n), 0 \le p \le n - k\) 使得 \(a_{p+1} \rightarrow a_{p+2}, a_{p+2} \rightarrow a_{p+3}, a_{p+3} \rightarrow a_{p+4}, \ldots, a_{p+k-1} \rightarrow a_{p+k}, a_{p+k} \rightarrow a_{p+1} \in G\)
其實,無向邊可以看做包含兩個點的環,所以他們的證明很相似。
至此證畢。
拓撲排序的實現
dfs
眾所周知,dfs可以解決任何一個不帶時限的題目(
那麼我們就來想下怎麼用dfs實現拓撲排序吧。
- 首先定義一個標記陣列 \(flag\)。
- 初始化 \(flag_i = 0\)
- 迴圈遍歷 \(u = 1 \rightarrow n\),當 \(flag_u = 0\) 時,進行dfs。dfs此處是一個bool函式,如果返回true則代表執行正常,如果返回false代表發現了環或無向邊。那麼如果此時的dfs函式返回一個false值,直接返回,無拓撲序。至於dfs怎麼判環,會在下方的dfs函式處理步驟給出。
接下來是dfs函式處理步驟:
- 對於一個來自引數的節點 \(u\),先令 \(flag_u \leftarrow -1\),然後遍歷其出邊。如果發現 \(u \rightarrow v \in G\),且 \(flag_v = -1\),說明有環,返回false。然後如果 \(flag_v = 0\)(\(flag_v = 1\) 代表此點安全,不必再處理),我們就進行dfs(v)的操作。如果遞迴的dfs(v)返回了false,本體也返回false。
- 如果沒有返回false,說明當前節點處理正常。令 \(flag_u \leftarrow 1\),將 \(u\) 節點插入拓撲序,返回true。
核心程式碼如下:
int flag[maxn];
std :: vector <int> topo;
std :: vector <int> G[maxn];
int n, m;
bool dfs(int u) {
flag[u] = -1;
for (int i = 0; i < G[u].size(); ++i) {
int v = G[u][i];
if (flag[v] == -1) return false;
else if (flag[v] == 0) {
if (!dfs(v)) return false;
}
}
flag[u] = 1;
topo.push_back(u);
return true;
}
bool toposort() {
topo.clear();
for (int i = 1; i <= n; ++i) flag[i] = 0;
for (int i = 1; i <= n; ++i) {
if (flag[i] == 0) {
if (!dfs(i)) return false;
}
}
std :: reverse(topo.begin(), topo.end());
return true;
}
Kahn演算法
Kahn演算法有時候也叫做toposort的bfs版本。
演算法流程如下:
- 將入度為 \(0\) 的點組成一個集合 \(S\)
- 從 \(S\) 中取出一個頂點 \(u\),插入拓撲序列。
- 遍歷頂點 \(u\) 的所有出邊,並全部刪除,如果刪除這條邊後對方的點入度為 \(0\),也就是沒刪前,\(u \rightarrow v\) 這條邊已經是 \(v\) 的最後一條入邊,那麼就把 \(v\) 插入 \(S\)。
- 重複執行上兩個操作,直到 \(S = \varnothing\)。此時檢查拓撲序列是否正好有 \(n\) 個節點,不多不少。如果拓撲序列中的節點數比 \(n\) 少,說明 \(G\) 非DAG,無拓撲序,返回false。如果拓撲序列中恰好有 \(n\) 個節點,說明 \(G\) 是DAG,返回拓撲序列。
也就是說,Kahn演算法的核心就是維護一個入度為0的頂點。
核心程式碼如下:
int ind[maxn];
bool toposort() {
topo.clear();
std :: queue <int> q;
for (int i = 1; i <= n; ++i) {
if (ind[i] == 0) q.push(i);
}
while (!q.empty()) {
int u = q.front();
topo.push_back(u);
q.pop();
for (int i = 0; i < G[u].size(); ++i) {
int v = G[u][i];
--ind[v];
if (ind[v] == 0) q.push(v);
}
}
if (topo.size() == n) return true;
return false;
}
拓撲排序實現的時間複雜度
Kahn演算法和dfs演算法的時間複雜度都為 \(\operatorname{O}(E+V)\)。感興趣的讀者可以自證,這裡不再詳細闡述。
另外,如果要求字典序最小或最大的拓撲序,只需要將Kahn演算法中的q佇列替換為優先佇列即可,總時間複雜度為 \(\operatorname{O}(E+V\log V)\)。
拓撲排序的用途
說了這麼半天,拓撲排序有什麼用途嗎?
- 判環
- 判鏈
- 處理依賴性任務規劃問題
處理依賴性任務規劃問題的模板是UVA10305,可以做做看。
本篇文章至此結束。