DAG与拓扑排序

现实生活中我们经常要做一连串事情,这些事情之间有顺序关系或依赖关系,做一件事情之前必须先做另一件事,如安排客人的座位、穿衣服的先后、课程学习的先后等。这些事情可以抽象为图论中的拓扑排序(Topological Sorting)问题。

例题:P4017 最大食物链计数

给出一个食物网,要求出这个食物网中最大食物链的数量。这里的“最大食物链”,指的是生物学意义上的食物链,即开头是不会捕食其他生物的生产者,结尾是不会被其他生物捕食的消费者。答案可能很大,所以要对 \(80112002\) 取模。

分析: 考虑把这个食物网转换为一个图,食物网有什么样的特点呢?

  1. 食物网中的捕食关系一定是单向的(比如猫吃鱼,而不是鱼吃猫)。
  2. 食物网中的捕食关系一定是无环的,不存在 A 捕食 B,B 捕食 C,C 捕食 A 这种情况。

所以可以发现食物网其实就是一个 DAG(有向无环图)。在这道题目中“最大食物链”的定义就是一条从入度为 \(0\) 的点开始到出度为 \(0\) 的点结束的链,即要计算这样的链的个数。

image

用一个数组 f[x] 表示从任意一个入度为 0 的点到点 x 的食物链计数。那么对于任意一个入度为 0 的点 y,它的 f[y]=1。对于一个入度非 0 的点 z,它的 f[z] 等于能到达点 z 的点 u 的 f[u] 之和。

如点 3,它的食物链计数等于点 1 的食物链计数加上点 10 的,即 f[3]=f[1]+f[10]=1+1=2。而对于点 6,它的食物链计数等于点 2 的食物链计数加上点 3 的,即 f[6]=f[2]+f[3]=1+2=3。这样最后只要对所有出度为 0 的点的事物链计数求和就能求出题目所求的答案了。

在计算 f[x] 的过程中,需要保证对于点 x,所有能到达点 x 的点 y 的 f[y] 已经被计算过了,这样就需要确定一个合适的计算顺序。这种方法叫做 拓扑排序。拓扑排序并不是对一个数列进行排序,而是在 DAG 上对点进行排序,使得在搜到点 x 时所有能到达点 x 的点 y 的结果已经计算完成了。具体流程如下:

  1. 将所有入度为 0 的点加入处理队列。
  2. 将处于队头的点 x 取出,遍历点 x 能到达的所有点 y。
  3. 对于每一个 y,删去从点 x 到点 y 的边。在具体的实现中,只需要让 y 的入度减一即可。在这一步中,顺便可以对点 y 的数据进行维护,在这题中是 f[y]=(f[y]+f[x])%MOD
  4. 如果点 y 的入度减到 0 了,说明所有能到 y 的点都被计算过了,这时将点 y 加入处理队列。
  5. 重复步骤 2 直到处理队列为空。

这样,就保证了在食物链计数这题中求 f[x] 的顺序正确。代码如下:

#include <cstdio>
#include <vector>
#include <queue>
using namespace std;
const int N = 5005;
const int MOD = 80112002;
vector<int> graph[N];
int ind[N], f[N];
int main()
{
    int n, m; scanf("%d%d", &n, &m);
    for (int i = 1; i <= m; i++) {
        int a, b; scanf("%d%d", &a, &b);
        graph[a].push_back(b); // 存边
        ind[b]++; // 点b的入度+1
    }
    queue<int> q;
    for (int i = 1; i <= n; i++) 
        if (ind[i] == 0) {
            q.push(i); f[i] = 1; // 将入度为0的点加入队列
        }
    while (!q.empty()) {
        int u = q.front(); q.pop();
        for (int v : graph[u]) {
            f[v] = (f[v] + f[u]) % MOD;
            ind[v]--;
            if (ind[v] == 0) q.push(v); // 此时点y的依赖都解除了,将点y加入队列
        }
    }
    int ans = 0;
    for (int i = 1; i <= n; i++) 
        if (graph[i].size() == 0) ans = (ans + f[i]) % MOD;
    printf("%d\n", ans);
    return 0;
}

答案需要对 80112002 取模,在计算 f 时一边加一边取模,以及在对出度为 0 的点的食物链计数求和时一边加一边取模。如果只在输出答案时取模,那么可能在累加的过程中答案超出了数据类型存储的范围而导致答案的错误。

例题:P1983 [NOIP2013 普及组] 车站分级

对于每趟车次而言,不停的站级别要小于停靠的车站,因此可以将问题建模为所有停靠的车站向不停的车站连有向边,则至少需要的等级数量等于最长链的边数加 1。

这里的建图还可以进一步优化,对于每一趟车次可以增加一个虚拟结点,所有停靠的车站指向这个虚拟结点,而虚拟结点又指向所有不停靠的车站,这样可以将建图的边数由 \(|S_1| \times |S_2|\) 降至 \(|S_1| + |S_2|\),其中 $|S_1| $ 和 $ |S_2|$ 分别为停靠车站的数量和不停靠车站的数量。这样的话答案为最长链的边数除以 2 再加 1。

#include <cstdio>
#include <vector>
#include <queue>
#include <algorithm>
using namespace std;
const int N = 2005;
vector<int> graph[N];
int ind[N], level[N];
int main()
{
    int n, m; scanf("%d%d", &n, &m);
    for (int i = 1; i <= m; i++) {
        int s; scanf("%d", &s);
        // virtual node (n+i)
        int pre = 0;
        for (int j = 1; j <= s; j++) {
            int stop; scanf("%d", &stop);
            if (j == 1) pre = stop;
            graph[stop].push_back(n + i); ind[n + i]++;
            for (int k = pre + 1; k < stop; k++) {
                graph[n + i].push_back(k);
                ind[k]++;
            }
            pre = stop;
        }
    }
    queue<int> q;
    int ans = 1;
    for (int i = 1; i <= n + m; i++) 
        if (ind[i] == 0) q.push(i);
    while (!q.empty()) {
        int u = q.front(); q.pop();
        for (int v : graph[u]) {
            level[v] = max(level[v], level[u] + 1);
            ans = max(ans, level[v]);
            ind[v]--;
            if (ind[v] == 0) q.push(v);
        }
    }
    printf("%d\n", ans / 2 + 1);
    return 0;
}
posted @ 2024-04-05 14:58  RonChen  阅读(15)  评论(0编辑  收藏  举报