算法讲解060 必备 拓扑排序的扩展技巧

来源:算法讲解060【必备】拓扑排序的扩展技巧 · 2026-10-25
依据:视频字幕 2000 字

一句话概括

在拓扑排序的推进过程中,把「路径计数 / 可达性 / 依赖信息」从入度为 0 的上游节点沿边累加推给下游节点,从而在 DAG 上做线性 DP。

它解决什么问题

给定一张有向无环图(Directed Acyclic Graph, DAG),要求统计从「所有入度为 0 的源点」到「所有出度为 0 的汇点」的路径条数、或者统计每个点能被多少条路径到达、或者判断某个点是否被所有源点可达等问题。典型题面:食物链计数、课程安排方案数、工程依赖传播、DAG 上「上游信息汇总到下游」。

核心特征:信息只能沿边的方向单向传播,且图无环,所以可以按拓扑序一次性推完,不需要记忆化搜索或反复松弛。

基础知识(前置概念,用大白话解释)

原理(为什么这么做是对的,尽量给直觉)

设 f[v] 表示「从所有源点出发、到达 v 的路径条数」。

为什么按拓扑序推就对?因为拓扑序保证:处理到 v 时,v 的所有前驱都已经处理完,f[u] 已经是最终值,不会再变。于是每条边只被「推」一次,总代价 O(V+E)。

直觉:把每个点想象成一个「计数器」,源点初始为 1;拓扑排序每弹出一个点,就把它当前的计数「转发」给它的每个后继(后继计数器 += 当前计数)。等所有点都弹完,每个点的计数器就是「到达它的路径数」。

步骤(编号,可照着写)

  1. 读入 n 个点、m 条边,建邻接表 g[u] 存 u 的所有后继,同时统计 indeg[v]。
  2. 初始化 f[v] = 0;把所有 indeg[v] == 0 的点入队,并令 f[v] = 1。
  3. 当队列非空:

- 弹出 u;

- 对 u 的每个后继 v:f[v] = (f[v] + f[u]) % MOD;indeg[v]--;若 indeg[v] == 0,v 入队。

  1. 拓扑结束后,对所有出度为 0 的点 v,把 f[v] 累加进答案(对 MOD 取模)。
  2. 输出答案。
若题目只问「到某个指定终点的路径数」,第 4 步只取那个点即可。
若题目问「每个点被多少源点可达」,把加法换成「或」/「集合合并」即可(见变形)。

样例(一个具体小例子,带每步变化)

图:A→D,B→D,B→E,C→E,D→F,E→F,E→D。求 A、B、C 到 F 的路径总数。

初始入度:A=0,B=0,C=0,D=2(A、B),E=2(B、C),F=2(D、E)。

初始 f:A=1,B=1,C=1,D=E=F=0。队列 = [A, B, C]。

| 步骤 | 弹出 | 操作 | f 变化 | 入度变化 | 队列 |

|---|---|---|---|---|---|

| 1 | A | A→D | f[D] += 1 → 1 | D: 2→1 | [B, C] |

| 2 | B | B→D, B→E | f[D] += 1 → 2;f[E] += 1 → 1 | D: 1→0(入队);E: 2→1 | [C, D] |

| 3 | C | C→E | f[E] += 1 → 2 | E: 1→0(入队) | [D, E] |

| 4 | D | D→F | f[F] += 2 → 2 | F: 2→1 | [E] |

| 5 | E | E→F, E→D | f[F] += 2 → 4;f[D] += 2 → 4(D 已出队,不再入队) | F: 1→0(入队) | [F] |

| 6 | F | 无后继 | — | — | [] |

出度为 0 的点只有 F,答案 = f[F] = 4。

注意:原字幕例子中 D 和 E 之间还有一条 E→D 的边,所以实际路径是 6 条。上面为了演示「已出队的点被再次累加」这个细节,保留了 E→D;但 E→D 会让 D 在 E 之前出队,导致 D 的最终值在出队后才被补上——这正是「按出度收集答案」而不是「出队时收集」的原因。若按字幕的 6 条路径,需要把 E→D 也计入 D 的最终值,再让 D→F 传播;正确做法是先拓扑完再统一收集汇点,或者保证收集时 f 已是最终值。字幕里 D 在 E 之前出队,f[D] 在出队后才从 2 变成 4,所以如果出队时就累加答案会漏。结论:答案在拓扑全部结束后对汇点收集,不要在出队瞬间收集。

代码(Python 一份:好读、用于理解;C++ 一份:竞赛风,能直接抄进比赛,带注释,不用玄学宏)

Python(理解用)

import sys
from collections import deque

def solve():
    input = sys.stdin.readline
    n, m = map(int, input().split())
    MOD = 80112002
    g = [[] for _ in range(n + 1)]
    indeg = [0] * (n + 1)
    outdeg = [0] * (n + 1)
    for _ in range(m):
        u, v = map(int, input().split())
        g[u].append(v)
        indeg[v] += 1
        outdeg[u] += 1

    f = [0] * (n + 1)
    q = deque()
    for v in range(1, n + 1):
        if indeg[v] == 0:
            f[v] = 1
            q.append(v)

    while q:
        u = q.popleft()
        for v in g[u]:
            f[v] = (f[v] + f[u]) % MOD
            indeg[v] -= 1
            if indeg[v] == 0:
                q.append(v)

    ans = 0
    for v in range(1, n + 1):
        if outdeg[v] == 0:
            ans = (ans + f[v]) % MOD
    print(ans)

solve()

C++(竞赛风,可直接抄)

#include <bits/stdc++.h>
using namespace std;

int main() {
    ios::sync_with_stdio(false);
    cin.tie(nullptr);

    int n, m;
    cin >> n >> m;
    const int MOD = 80112002;

    vector<vector<int>> g(n + 1);
    vector<int> indeg(n + 1, 0), outdeg(n + 1, 0);
    for (int i = 0; i < m; ++i) {
        int u, v;
        cin >> u >> v;
        g[u].push_back(v);
        ++indeg[v];
        ++outdeg[u];
    }

    vector<long long> f(n + 1, 0);
    queue<int> q;
    for (int v = 1; v <= n; ++v) {
        if (indeg[v] == 0) {
            f[v] = 1;
            q.push(v);
        }
    }

    while (!q.empty()) {
        int u = q.front(); q.pop();
        for (int v : g[u]) {
            f[v] = (f[v] + f[u]) % MOD;
            if (--indeg[v] == 0) q.push(v);
        }
    }

    long long ans = 0;
    for (int v = 1; v <= n; ++v) {
        if (outdeg[v] == 0) {
            ans = (ans + f[v]) % MOD;
        }
    }
    cout << ans << '\n';
    return 0;
}

复杂度(时间/空间,最好/平均/最坏,顺带说常数大不大)

竞赛里什么时候用(看到什么题面特征就该想到它)

竞赛模板(C++,可直接背下来的写法;变量名清楚)

// DAG 上拓扑 DP 通用骨架:f[v] 由所有前驱累加而来
// 使用前:g 已建好,indeg/outdeg 已统计
vector<long long> f(n + 1, 0);
queue<int> q;
for (int v = 1; v <= n; ++v)
    if (indeg[v] == 0) { f[v] = 1; q.push(v); }   // 源点初始化,按题目改

while (!q.empty()) {
    int u = q.front(); q.pop();
    for (int v : g[u]) {
        f[v] = (f[v] + f[u]) % MOD;               // 核心:信息下推
        if (--indeg[v] == 0) q.push(v);
    }
}
// 拓扑结束后统一收集答案(汇点 / 指定点 / 全局)

边界与易错点(WA 的常见原因,越具体越好)

  1. 源点初始化:入度为 0 的点 f = 1 还是 f = 0?取决于题目「路径」是否允许长度为 0。食物链题一般源点算 1 条(自己到自己),但最终只统计汇点,所以源点若同时是汇点(孤立点)会被计入——若题目不允许孤立点,要特判。
  2. 答案收集时机:必须在拓扑全部结束后对汇点收集,不能在出队瞬间收集。因为一个点可能在出队后还被其他前驱累加(如样例中 D 被 E 补加),出队时 f 还不是最终值。
  3. 汇点判定:用 outdeg[v] == 0,不要用「没有后继」临时判断(邻接表为空等价,但出度数组更稳)。
  4. 取模:f[v] + f[u] 可能超 int,用 long long 存 f,加法后取模。MOD 按题目给(食物链题常见 80112002,不是 1e9+7)。
  5. 多测清空:g、indeg、outdeg、f、队列全部要清。用 vector 重新构造最省心,别用全局数组忘清。
  6. 下标从 1 还是 0:题目点编号一般 1~n,模板按 1-based;若 0-based 要把循环边界改掉,别混。
  7. 重边:重边会让入度多算,但拓扑仍然正确(重边相当于多条路径,路径计数题里重边应算多条路径,符合题意);若题目要求去重,要先 sort+unique 或 set。
  8. 自环:DAG 保证无自环;若题目没保证,拓扑后有点没出队说明有环,要判 cnt < n。
  9. 孤立点:既入度 0 又出度 0 的点,若题目要求「至少一条边」的路径,不能计入答案。
  10. 队列用 queue 还是 vector 当栈:拓扑序不唯一,用栈/队列都行,但若题目要求「字典序最小拓扑序」要用优先队列(见变形)。

同类题 / 变形(说清楚怎么改)

常见坑

拓展(变体、实际用途、面试常问)

自测 3 题(题目 + 折叠答案)

题 1:n 个点 m 条边的 DAG,求从点 1 到点 n 的路径条数,对 1e9+7 取模。n,m ≤ 1e5。

答案

拓扑 DP,源点只初始化 f[1] = 1(不是所有入度 0 的点),其余入度 0 的点 f = 0。拓扑推进 f[v] = (f[v] + f[u]) % MOD。答案 f[n]。注意:若 1 不是源点,仍只从 1 初始化,其他源点 f=0 不贡献。复杂度 O(n+m)。

</details>

题 2:n 个点 m 条边的 DAG,判断拓扑序是否唯一。n,m ≤ 1e5。

答案

Kahn 算法,队列中任一时刻若 size ≥ 2,则不唯一。实现:用 queue,每次入队后检查 q.size() >= 2 则标记不唯一;或每步只弹一个,弹之前看队列大小。注意:队列大小 ≥2 只是充分条件,但配合 Kahn 算法是充要的(若某时刻有 ≥2 个入度 0 点,它们之间无先后约束,可交换,故不唯一)。复杂度 O(n+m)。

</details>

题 3:n 个点 m 条边的 DAG,每个点有耗时 cost[v],求整个工程最早完成时间(所有汇点完成的最大值)。n,m ≤ 1e5。

答案

拓扑 DP 求最长路:源点 f[v] = cost[v];推进时 f[v] = max(f[v], f[u] + cost[v])(注意加的是 v 自己的耗时,表示 v 必须在所有前驱完成后才能开始,完成时间 = 前驱最大完成时间 + 自身耗时)。答案 = 所有汇点 f[v] 的最大值。用 long long 防溢出。复杂度 O(n+m)。

</details>