今天,我們來聊聊拓撲排序。
何為拓撲排序?
拓撲排序,這個顧名思義似乎有點難。那就直接上定義吧:
對於一個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\) 這條邊,那么2必須出現在3的前面,但是這里2卻出現在3的后面,因此不是拓撲序。
拓撲排序和DAG的關系
現在,你對拓撲排序的理解一定加深了一些。那么接下來讓我們思考一個問題,拓撲排序為什么一定要在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\)。根據拓撲序的定義,就有 \(i < j\) 且 \(i > j\),顯然不存在 \(i, j\) 滿足此邏輯關系,即有無向邊的圖無拓撲序。
為什么有環的圖無拓撲序?
假設存在有環的有 \(n\) 個點的圖 \(G\) 的拓撲序 \(a\),那么一定存在 \(k(1 < k \le n), p(1 \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\)。根據拓撲序的定義,就有 \(p + k < p + 1\),但 \(k > 1\),因此 \(p + k < p + 1\) 不可被滿足,即有環圖無拓撲序。
其實,無向邊可以看做包含兩個點的環,所以他們的證明很相似。
至此證畢。
拓撲排序的實現
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,可以做做看。
本篇文章至此結束。