拓撲排序 學習筆記


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

何為拓撲排序?

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

對於一個DAG(有向無環圖)\(G\),將 \(G\) 中所有頂點排序為一個線性序列,使得圖中任意一對頂點 \(u\)\(v\),若 \(u\)\(v\) 之間存在一條從 \(u\) 指向 \(v\) 的邊,那么 \(u\) 在線性序列中一定在 \(v\) 前。

啥意思呢。比如這樣一個DAG:

w56Ard.png

幾種可能的拓撲序是:

  • 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,可以做做看。

本篇文章至此結束。


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM