1. 程式人生 > 實用技巧 >拓撲排序 學習筆記

拓撲排序 學習筆記

今天,我們來聊聊拓撲排序。

何為拓撲排序?

拓撲排序,這個顧名思義似乎有點難。那就直接上定義吧:

對於一個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是有向無環圖的意思,我們從有向無環兩個方面分別做反義詞,也就是無向,有環

接下來我們證明為什麼這兩種情況不能出現。

為什麼有無向邊的圖無拓撲序?

假設存在有無向邊的有 \(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 \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\)

。根據拓撲序的定義,就有 \(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,可以做做看。

本篇文章至此結束。