Luogu P4017 最大食物链计数

题目分析

首先,我们需要正确理解题意。题目要求计算一个“食物网”中“最大食物链”的数量。

  1. 食物网是什么? 题目描述了生物之间的吃与被吃关系。例如,“A 被 B 吃”可以理解为有一个从 A 指向 B 的箭头。这样,整个食物网就构成了一个有向图。每个生物是一个节点,每条捕食关系是一条有向边

  2. 什么是“最大食物链”? 题目定义为“最左端是不会捕食其他生物的生产者,最右端是不会被其他生物捕食的消费者。”

    • 生产者:不会捕食其他生物。在图中,这意味着它没有入边(没有箭头指向它),即入度为 0 的节点。
    • 最终消费者:不会被其他生物捕食。在图中,这意味着它没有出边(没有箭头从它发出),即出度为 0 的节点。
    • 因此,一条“最大食物链”就是图中的一条从任意一个入度为 0 的节点出发,到任意一个出度为 0 的节点结束的路径
  3. 问题核心:题目要求的就是这种路径的总数量。

  4. 重要提示:“数据中不会出现环”。这是一个关键信息,说明这个有向图是一个有向无环图(DAG)。在 DAG 上求解路径计数问题,通常使用拓扑排序 + 动态规划的方法。

思路详解:拓扑排序 + 动态规划

这个问题的本质是在一个 DAG 中,统计从所有入度为 0 的点到所有出度为 0 的点的路径总数。

我们可以使用动态规划来解决。

状态定义

我们定义 dp[i] 表示从生产者(入度为0的节点)到节点 i 有多少条不同的食物链(路径)out_degree[i] 表示一个节点的出度

状态转移方程

要计算到达节点 i 的路径数 dp[i],我们可以考虑所有能够直接到达 i 的前驱节点。假设节点 j 有一条边指向 i(即 j -> i),那么任何一条以 j 结尾的食物链,都可以通过 j -> i 这条边延伸,形成一条以 i 结尾的食物链。

因此,到达节点 i 的总路径数,等于所有能到达 i 的前驱节点 j 的路径数之和。

状态转移方程为:

\[dp_i = \sum_{j \to i} dp_j \]

其中,\(j \to i\) 表示存在一条从节点 j 到节点 i 的有向边。

初始化(DP 边界)

食物链必须从生产者(入度为 0 的节点)开始。对于任何一个生产者节点 p,它自身就构成了一条长度为 1 的食物链的起点。因此,我们将所有入度为 0 的节点 pdp[p] 初始化为 1。其他节点的 dp 值初始为 0

计算顺序

为了确保在计算 dp[i] 时,所有前驱节点 jdp[j] 值都已经被计算出来,我们需要按照图的拓扑序来进行计算。拓扑排序保证了我们访问任何一个节点时,它的所有前驱节点都已经被访问过。

最终答案

根据 dp[i] 的定义,dp 数组计算完成后,dp[i] 存储了从生产者到节点 i 的路径总数。题目要求的是“最大食物链”的数量,即终点必须是“最终消费者”(出度为 0 的节点)。

所以,最终答案就是所有出度为 0 的节点 kdp[k] 值之和。

\[\text{Ans} = \sum_{\text{out\_degree}_k=0} dp_k \]

别忘了,在每一步计算中都要对 80112002 取模。

算法步骤总结

  1. 建图:读入 nm,以及 m 条边。使用邻接表存储图,并同时计算每个节点的入度出度
  2. 拓扑排序:找到一个图中所有节点的拓扑序列。可以通过 DFS 或者基于队列的 Kahn 算法实现。代码中使用的是 DFS 方法。
  3. 动态规划
    • 创建一个 dp 数组,全部初始化为 0。
    • 遍历拓扑序列中的每个节点 u
    • 如果节点 u 是生产者(入度为 0),则设置 dp[u] = 1
    • 对于节点 u 的每一个后继节点 v(即存在边 u -> v),执行状态转移:dp[v] = (dp[v] + dp[u]) % MOD
  4. 统计结果:遍历所有节点 i(从 1 到 n),如果节点 i 是最终消费者(出度为 0),则将 dp[i] 累加到最终答案 ans 中。
  5. 输出:输出 ans

代码解析

import sys
# 增加递归深度限制,防止大数据下 DFS 爆栈 (虽然本题数据范围较小,但这是个好习惯)
sys.setrecursionlimit(100000) 
input = sys.stdin.readline

# 1. 初始化和建图
n, m = map(int, input().split())
graph = [[] for _ in range(n + 1)]  # 邻接表存图
in_degree, out_degree = [0] * (n + 1), [0] * (n + 1) # 统计入度和出度

for _ in range(m):
    x, y = map(int, input().split()) # x 被 y 吃,即 x -> y
    graph[x].append(y)
    in_degree[y] += 1
    out_degree[x] += 1

# 2. 拓扑排序 (基于 DFS)
visit, topo, MOD = [False] * (n + 1), [], 80112002

def dfs(u: int):
    visit[u] = True
    for v in graph[u]:
        if not visit[v]:
            dfs(v)
    # 在节点u的所有后继都访问完后,再将 u 入栈
    # 这会得到一个逆拓扑序
    topo.append(u)

# 从所有生产者(入度为 0 的点)开始 DFS,保证能遍历到所有可达节点
for i in range(1, n + 1):
    if not in_degree[i] and not visit[i]: # visit 判断避免重复访问
        dfs(i)

# 将逆拓扑序反转,得到正确的拓扑序
topo.reverse()

# 3. 动态规划
dp, ans = [0] * (n + 1), 0

# 按照拓扑序进行 DP
for u in topo:
    # 初始化:如果 u 是生产者,路径数为 1
    if in_degree[u] == 0:
        dp[u] = 1
    
    # 状态转移:将 u 的路径数贡献给它的所有后继节点 v
    for v in graph[u]:
        dp[v] = (dp[v] + dp[u]) % MOD

# 4. 统计答案
# 累加所有最终消费者(出度为 0 的点)的 dp 值
for i in range(1, n + 1):
    if out_degree[i] == 0:
        ans = (ans + dp[i]) % MOD

# 5. 输出结果
print(ans)

代码细节说明

  • 拓扑排序:代码中的 DFS 实现方式,在一个节点的所有子节点都处理完毕后,才将该节点加入 topo 列表。这会产生一个逆向的拓扑序列。因此,在 dfs 调用结束后需要 topo.reverse() 来得到正确的拓扑序列(生产者在前,消费者在后)。
  • DP 初始化:代码中没有显式地初始化 dp 数组,而是在遍历拓扑序列时,遇到生产者 (in_degree[u] == 0) 时才设置 dp[u] = 1。这与我们的思路是等价的,因为在拓扑序中,生产者一定出现在所有能被它到达的节点之前。
  • DP 转移for v in graph[u]: dp[v] = (dp[v] + dp[u]) % MOD 这行代码完美地实现了状态转移方程。因为是按照拓扑序遍历,当计算 dp[v] 时,dp[u] 的值已经是最终确定的。
posted @ 2025-08-05 20:55  AFewMoon  阅读(13)  评论(0)    收藏  举报