拓扑排序 学习笔记

今天,我们来聊聊拓扑排序。

何为拓扑排序?

拓扑排序,这个顾名思义似乎有点难。那就直接上定义吧:

对于一个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,可以做做看。

本篇文章至此结束。

posted @ 2020-09-20 12:14  dbxxx  阅读(1284)  评论(3编辑  收藏  举报