CSP-S 提高级课程中心 / 算法进阶 / 第35讲
CSP-S 提高级 · 算法进阶 · s5-13

有向无环图的拓扑排序

把"谁必须在谁之前"这件抽象的事,变成一条线性的顺序。Kahn 算法用入度归零逐个出队,DFS 用逆后序悄悄排好 —— 而拓扑序真正的威力在于:它让 DAG 上的动态规划成为可能。

1 个大纲知识点 Kahn O(n + m) DFS 逆后序 另一条路 DAG + DP 最长路 / 路径计数 12 道洛谷原题
本讲 · 第 35/52 讲有向无环图的拓扑排序
模块 01

情景导航

从"选课先修"和"施工流水线"两个场景出发,认识"谁必须在谁之前"这个极其普遍的问题。

故事选课先修与施工流水线

某大学计算机专业有一份培养方案,课程之间有先修关系:比如"学数据结构之前必须先学程序设计"、"学算法分析之前必须先学数据结构和离散数学"、"毕业设计必须在所有专业课之后"。教务处需要回答一个简单的问题:能不能把这些课排成一条线性顺序,使得每一门课都排在它所有先修课之后?

教务处的小陈把这个问题画成了一张有向图:每门课是一个点,如果"课程 A 是课程 B 的先修课",就画一条有向边 A → B。于是问题变成了"给这张图的所有点排个序,使得每条边都是从前往后指的" —— 这就是拓扑排序(Topological Sort)。

小陈很快发现了一个前提:如果课程之间出现了循环依赖(A 要求先学 B、B 要求先学 C、C 又要求先学 A),那就永远排不出来。所以拓扑排序只对有向无环图(DAG)有意义 —— 而这个"排不出来"本身,恰好就是判断图中有没有环的最简单方法。

同一天,工地的项目经理想问一个"升级版"问题:把每道工序看作一个点,工序之间的先后关系看作有向边,每道工序有耗时。他想知道整个工程最短需要多少天。小陈笑了:这不就是"在 DAG 上求最长路"吗?先做拓扑排序,再顺着拓扑序做一遍 DP 就行了 —— 拓扑序让 DP 有了"无后效性"。

拓扑排序是把"偏序"铺平成一"条线",铺平之后,DP 就能顺着走下去。

💡 故事背后的算法思想

Kahn 算法的直觉:"没有先修要求的课"(入度为 0 的点)可以马上去上。上完之后,它作为先修课的那些课,先修要求就少了一个;当某门课的所有先修要求都被满足(入度归零)时,它也可以上了。用队列维护"当前可上的课",不断取出、删除出边、检查新的入度归零点。

DFS 逆后序的直觉:深度优先遍历时,一个点在"它的所有后继都访问完毕"之后才被"完成"。把完成顺序倒过来,就得到了拓扑序 —— 因为"后继先完成",倒过来就是"后继排在前面"。

为什么拓扑序这么重要:在有环的图上,"从 A 走到 B"可能无限绕圈,DP 的状态会互相依赖、无法确定递推顺序。而 DAG 上的拓扑序保证了"所有能到达 v 的点都排在 v 前面",于是只要按拓扑序推进,每个状态在被用到时都已经算好了 —— 这就是无后效性。

①生活类比
🧥

穿衣服的顺序

先穿袜子再穿鞋,先穿衬衫再穿外套 —— 有先后关系,但袜子与衬衫之间可以任意顺序。

→ 拓扑序不唯一
🍳

做菜的步骤

"切菜 → 下锅 → 装盘"有固定先后;但"烧水"和"切菜"可以并行。

→ 入度为 0 的点可以并行处理
🏗️

工程的关键路径

整个工程的最短工期 = 最长的那条工序链所需时间。

→ DAG 上的最长路
🔁

鸡生蛋还是蛋生鸡

"必须先有鸡才能有蛋、必须先有蛋才能有鸡" —— 循环依赖,永远排不出先后。

→ 有环 ⇒ 无拓扑序
②核心定义

有向无环图(DAG, Directed Acyclic Graph):不含任何有向环的有向图。DAG 是拓扑排序、DAG 上 DP、DAG 最小路径覆盖等一大批算法的共同前提。

拓扑序(Topological Order):把图中所有点排成一个线性序列 p₁, p₂, …, pₙ,使得对图中任意一条有向边 (u, v),都有 u 在序列中出现在 v 之前。换句话说:每条边都从前指向后。

存在条件:一张有向图存在拓扑序 当且仅当它是 DAG(无环)。有环时必然存在 (u,v) 与"v 必须在 u 之前"的矛盾。

拓扑序的唯一性:DAG 的拓扑序通常不唯一。例如"点 1、点 2 之间没有边",那么 1,2 和 2,1 都是合法拓扑序。只有当"任意两点之间都有路径"(即图是一条"全序"链)时,拓扑序才唯一。

入度(In-degree):指向某点的边数,记作 indeg[v]。在"任务调度"语境下,入度 = 该任务还剩下多少个未完成的前置任务。

逆后序(Reverse Post-order):DFS 遍历中,一个点的"完成时间"是把它的所有后继都处理完后才记下的。把所有点的完成顺序反过来,就是拓扑序。

📌 本讲核心知识地图

板块核心内容对应大纲
拓扑序定义线性序列、每条边指向前方、存在条件【7】拓扑排序
Kahn 算法入度表 + 队列、逐个"入度归零"出队、O(n+m)【7】拓扑排序
DFS 逆后序后序遍历 + 反转、O(n+m)、可递归可迭代【7】拓扑排序
判环Kahn 输出个数 < n ⇒ 有环【7】有向图判环
字典序最小把队列换成优先队列【7】拓扑排序变体
DAG 上 DP最长路、最短路、路径计数、期望【7】DAG 上的动态规划
应用建模编译依赖、任务调度、食物链、分层【7】图论建模
③核心口诀
🔑 Kahn 一句话

入度为零就入队,出队时把邻居入度减一,谁减到零谁入队。出队序列就是拓扑序。

🔑 判环

Kahn 跑完后如果出队的点数 < n,说明还有点的入度永远归不了零 → 图中有环。

🔑 DAG + DP

拓扑序是 DAG 上 DP 的唯一正确递推顺序:按拓扑序处理每个点,用它去更新所有出边指向的点。

🔑 字典序最小

把 queue 换成 priority_queue<int, vector<int>, greater<int>>,每次取编号最小的入度零点。

模块 02

知识探究

先把"拓扑序"这件事说清楚,再学两种求法(Kahn 与 DFS 逆后序),最后看它在 DAG 上 DP 里的核心作用。

1拓扑序的定义与存在条件 核心概念

🔍 用一句话说清拓扑序

把图的所有点排成一条线,使得每一条有向边都是从线的左边指向右边。这条线就是一个拓扑序。

等价说法:对每条边 (u, v),u 在拓扑序中必须出现在 v 之前。

为什么有环就排不出来?假设图中有环 v₁ → v₂ → … → vₖ → v₁。由边 (v₁,v₂) 知 v₁ 在 v₂ 前;由 (v₂,v₃) 知 v₂ 在 v₃ 前……一路推下去得到 "v₁ 在 v₁ 前",矛盾。所以有环 ⇒ 无拓扑序。

反过来,DAG 一定有拓扑序吗?有。理由很简单:DAG 一定有至少一个入度为 0 的点(否则从任意点出发一直沿着反向边走,必因点数有限而重复,形成环)。把这个点拿出来放在最前面,删掉它和它的出边,剩下的图仍然是 DAG,于是可以归纳地继续下去。

这个"归纳构造"正是 Kahn 算法!

情况是否存在拓扑序说明
DAG(无环)✅ 一定存在至少一个,通常有多个
有向图中含环❌ 不存在环上的点互相矛盾
DAG 且"任意两点可比"✅ 唯一此时图是一条链(全序)
DAG 且有多个入度 0 的点✅ 多个这些点之间的相对顺序可以任意交换

一个 6 点的例子(本讲反复使用):

点 1入度 0
点 2 / 点 3入度 1
点 4入度 2
点 5 / 点 6

边集:1→2、1→3、2→4、3→4、4→6、3→5、5→6。合法的拓扑序例如 1 2 3 4 5 6,也可以是 1 3 2 5 4 6

0
1
1
2
1
2

初始入度数组 indeg[1..6] = 0, 1, 1, 2, 1, 2

📐 拓扑序的本质:把"偏序关系"(只知道谁在谁前面)铺平成一个"线性顺序"。
2Kahn 算法:入度归零逐个出队 最常用

🔍 算法步骤(四步循环)

第 1 步:统计每个点的入度 indeg[i]。

第 2 步:把所有 indeg[i] == 0 的点加入队列(它们没有任何前置要求,可以立即处理)。

第 3 步:循环取出队首 u,把 u 加入答案序列;然后遍历 u 的每条出边 (u, v),执行 indeg[v]--。这就是"删除边 (u,v)"。

第 4 步:在执行 indeg[v]-- 后,如果 indeg[v] 变成了 0,说明 v 的所有前置要求都满足了,把 v 加入队列。

队列为空时算法结束。此时答案序列就是拓扑序;若答案长度 < n,说明有环。

为什么正确?队列里的点一定是"当前图中入度为 0"的点。把 u 输出时(放在序列前面),u 的所有出边都被处理,后续点不会再有"依赖 u"的未满足条件。归纳地看,每个点入队时它的所有前驱都已经在答案序列里了 —— 正好满足拓扑序的定义。

复杂度:每个点入队出队一次 O(n),每条边被检查一次 O(m),总 O(n + m)。

C++Kahn 算法核心框架
int indeg[MAXN];
vector<int> g[MAXN];          // 邻接表存图
vector<int> ans;               // 存放拓扑序

bool kahn(int n) {
    queue<int> q;
    for (int i = 1; i <= n; i++)
        if (indeg[i] == 0) q.push(i);        // ② 入度为 0 的点入队

    while (!q.empty()) {
        int u = q.front(); q.pop();
        ans.push_back(u);                    // ③ 出队即确定位置

        for (int v : g[u]) {
            if (--indeg[v] == 0)          // ④ 删边 + 入度减一
                q.push(v);                   // ⑤ 归零则入队
        }
    }
    return (int)ans.size() == n;        // ⑥ 长度不足 n ⇒ 有环
}
▶ Kahn 算法逐步演示(6 点示例)
0统计入度:indeg = [1:0, 2:1, 3:1, 4:2, 5:1, 6:2]。把 入度为 0 的点入队 → 队列 = [1],答案序列 = []。
1出队 1,输出到答案 → ans = [1]。处理出边:1→2 使 indeg[2] = 0 入队;1→3 使 indeg[3] = 0 入队。队列 = [2, 3]。
2出队 2,ans = [1, 2]。处理出边:2→4 使 indeg[4] 从 2 减到 1,还不为 0,不入队。队列 = [3]。
3出队 3,ans = [1, 2, 3]。处理出边:3→4 使 indeg[4] 从 1 减到 0 入队;3→5 使 indeg[5] = 0 入队。队列 = [4, 5]。
4出队 4,ans = [1, 2, 3, 4]。处理出边:4→6 使 indeg[6] 从 2 减到 1,不入队。队列 = [5]。
5出队 5,ans = [1, 2, 3, 4, 5]。处理出边:5→6 使 indeg[6] 从 1 减到 0 入队。队列 = [6]。
6出队 6,ans = [1, 2, 3, 4, 5, 6]。6 没有出边,队列空,算法结束。答案长度 6 = n → 无环。
要点:入度是"还差几个前置条件"。每删掉一条入边就减一,减到 0 就代表"我这就可以上场了"。
💡 小技巧:入度数组可以直接在读边时顺便统计 —— cin >> u >> v; g[u].push_back(v); indeg[v]++;,不需要单独再遍历一遍所有边。
3DFS 逆后序:另一条求拓扑序的路 常考

🌲 核心思想:完成顺序反过来

DFS 遍历时,一个点会有两个时间戳:发现时间(第一次到达)和完成时间(回溯离开)。逆后序就是"按完成时间从晚到早"排列。

为什么它一定是拓扑序?考虑一条边 (u, v)。在 DFS 中讨论两种情况:

· 若访问 u 时 v 尚未被访问 → DFS 会先去访问 v,等 v 及其所有后继都完成后才回到 u。所以 v 的完成时间早于 u。
· 若访问 u 时 v 已经被访问过 → 因为 DFS 的性质(对 DAG 而言不会出现"v 还在访问中"的横叉情况),v 必然已经完成。所以同样 v 的完成时间早于 u。

于是对每条边 (u,v) 都有"v 完成得比 u 早",那么按完成时间倒序排列,u 就排在 v 前面 —— 正是拓扑序的定义。

实现:DFS 结束后把点放入一个栈(或 vector),最后倒序输出。

复杂度:每个点访问一次、每条边检查一次,O(n + m),与 Kahn 相同。

🌲 DFS 逆后序(递归版)
bool vis[MAXN];
vector<int> post;

void dfs(int u) {
    vis[u] = true;
    for (int v : g[u])
        if (!vis[v]) dfs(v);
    post.push_back(u);        // 后序:回溯时记录
}

// 主函数中:对所有未访问的点调用 dfs
for (int i = 1; i <= n; i++)
    if (!vis[i]) dfs(i);

// 逆后序 = 拓扑序
reverse(post.begin(), post.end());
⚠️ 递归深度的隐患

当 n 达到 105 且图退化成一条长链时,递归深度会达到 105 层,极可能爆栈(RE)。

对策一:改用 Kahn 算法(无递归,天然安全)。

对策二:写成"手写栈"的迭代版 DFS,用一个 stack 模拟递归,记录"当前处理到哪条边"。

对策三:在评测环境的编译器参数里调大栈(如 -Wl,--stack=268435456),但很多 OJ 不允许,所以不可靠。

📌 结论:正式比赛里求拓扑序优先用 Kahn,它既没有爆栈风险,又能顺便判环。

用 6 点示例走一遍(邻接表按输入顺序):

DFS(1) → DFS(2) → DFS(4) → DFS(6):6 无出边,完成后 post = [6];回到 4,post = [6, 4];回到 2,post = [6, 4, 2];接着 1 的第二个邻居 3 → DFS(3) → DFS(5) → 6 已访问,5 完成 → post = [6,4,2,5];3 的邻居 4 已访问,3 完成 → post = [6,4,2,5,3];1 完成 → post = [6,4,2,5,3,1]。

倒序得拓扑序:1 → 3 → 5 → 2 → 4 → 6(与 Kahn 得到的 1 2 3 4 5 6 不同,但两者都是合法的拓扑序)。

🔁 记忆:DFS 后序倒着看,就是拓扑序 —— 因为"后继先完成",倒过来就是"后继排前面"。
4用拓扑排序判环:最优雅的有向图判环法

🔄 判据

Kahn 版:跑完 Kahn 后,若 ans.size() < n,则图中有环。

为什么?环上的点入度永远不可能归零 —— 因为环上每个点都有一条来自环内点的入边,而那条入边只会在"前一个环内点出队"时被删掉,可环内点自己都进不了队,自然也出不了队。所以环上的点永远不会被输出。

DFS 版:DFS 时维护一个"访问状态"数组:0 = 未访问、1 = 正在访问(还在栈里)、2 = 已完成。若在访问 u 的过程中遇到一个状态为 1 的邻点 v,说明 v 是 u 的祖先 —— 出现了一条反向边,即存在环。

复杂度:都是 O(n + m)。

C++两种判环写法
/* ===== 写法一:Kahn 判环(推荐) ===== */
bool hasCycle(int n) {
    queue<int> q;
    int cnt = 0;
    for (int i = 1; i <= n; i++)
        if (indeg[i] == 0) q.push(i);
    while (!q.empty()) {
        int u = q.front(); q.pop();
        cnt++;
        for (int v : g[u])
            if (--indeg[v] == 0) q.push(v);
    }
    return cnt < n;               // 输出不足 n 个 ⇒ 有环
}

/* ===== 写法二:DFS 三色标记法 ===== */
int color[MAXN];                 // 0 未访问 / 1 访问中 / 2 已完成
bool cyc = false;

void dfsCheck(int u) {
    color[u] = 1;                          // 进入:标记"访问中"
    for (int v : g[u]) {
        if (color[v] == 1) { cyc = true; return; }   // 遇到祖先 → 有环
        if (color[v] == 0) { dfsCheck(v); if (cyc) return; }
    }
    color[u] = 2;                          // 离开:标记"已完成"
}

判环示例:5 个点,边为 1→2、2→3、3→4、4→2、5→1。

入度:1:1、2:2、3:1、4:1、5:0。Kahn 时队列初始为 [5];出队 5 → indeg[1] = 0 入队;出队 1 → indeg[2] 从 2 减到 1,不入队;队列空。输出序列 [5, 1],长度 2 < 5 → 有环(环是 2→3→4→2)。

🔄 判环口诀:Kahn 数个数,不足 n 就有环;DFS 看颜色,撞到"访问中"就有环。
5字典序最小的拓扑序:把队列换成优先队列 高频变体

🔤 问题与解法

问题:多个合法拓扑序时,求"字典序最小"的那一个(即把序列当作一个数列,从第一项开始逐项比较,要求尽量小)。

解法:把普通 queue 换成小根 priority_queue。这样每次从"当前所有入度为 0 的点"中取出编号最小的那个输出。

为什么贪心是对的?设当前所有入度为 0 的点的最小编号为 m。任何合法的拓扑序在这一步都只能从这些入度为 0 的点中选一个作为下一项,所以第一项的最小可能值就是 m。取 m 之后,剩余问题完全等价于"在删掉 m 后的新 DAG 上求字典序最小拓扑序" —— 这是一个标准的贪心归纳,每一步都取局部最优即可。

复杂度:每次取出/插入是 O(log n),总 O((n + m) log n)。

普通队列:1 4 3 2 5

while (!q.empty()) { int u = q.front(); q.pop(); ... }

点 1 出队后,点 3 和点 4 同时入度归零。队列里是 [4, 3](按入队顺序),所以先出 4。

优先队列:1 3 4 2 5

while (!pq.empty()) { int u = pq.top(); pq.pop(); ... }

堆里是 {3, 4},每次取最小的 3,于是得到字典序最小的结果。

C++字典序最小拓扑序
void kahnLex(int n) {
    priority_queue<int, vector<int>, greater<int>> pq;   // 小根堆
    for (int i = 1; i <= n; i++)
        if (indeg[i] == 0) pq.push(i);

    while (!pq.empty()) {
        int u = pq.top(); pq.pop();        // 每次取编号最小的
        cout << u << ' ';
        for (int v : g[u])
            if (--indeg[v] == 0) pq.push(v);
    }
    cout << endl;
}

示例:5 个点,边为 1→4、4→2、2→5、1→3、3→5。普通队列输出 1 4 3 2 5;优先队列输出 1 3 4 2 5 —— 两种都是合法拓扑序,但只有后者是字典序最小的。

💡 想一想

"字典序最小"和"字典序最大"能不能都靠优先队列解决?能 —— 换成一个 priority_queue(默认大根堆)就得到字典序最大的拓扑序。

但如果题目要求的是"反过来的字典序最小"(例如 HNOI2015 菜肴制作:希望编号小的菜品尽量先做,但判断标准是"编号小的菜越早越好"),就不能简单地贪心了。标准做法是:建反向图,求字典序最大的拓扑序,再倒序输出。这个技巧留到练习里体会。

6DAG 上的动态规划:拓扑序就是递推顺序 本讲重点

📊 为什么拓扑序能让 DP 成立?

动态规划的两个基本要求是"状态定义清晰"和"无后效性"(计算某个状态时,它依赖的状态必须已经算好)。在有环的图上,状态之间会互相依赖、形成死角 —— 你算不出来。而在 DAG 上,只要按拓扑序推进,就能保证"所有能到达 v 的点都排在 v 之前",于是计算 v 时它所有的前驱都已经算完了。

通用模板(四步):

① 求出拓扑序(Kahn 或 DFS 逆后序)。

② 初始化 DP 数组(起点赋初值、其余赋"最坏值"或 0)。

③ 按拓扑序依次取出点 u,遍历 u 的所有出边 (u, v),用 dp[u] 去更新 dp[v]。

④ 答案在终点的 dp 值(或所有点 dp 的聚合,视题目而定)。

为什么不用记忆化搜索?也可以 —— 记忆化搜索本质上就是"DFS 逆后序 + DP",同样正确。但当转移方向复杂或需要处理"多重入边"的聚合时,显式的拓扑序 + 迭代写法更直观,也避免了递归爆栈。

DP 问题转移方程初始化说明
最长路dp[v] = max(dp[v], dp[u] + w)dp[s] = 0,其余 −∞工程关键路径
最短路(DAG)dp[v] = min(dp[v], dp[u] + w)dp[s] = 0,其余 +∞DAG 上比 Dijkstra 更快
路径计数dp[v] = (dp[v] + dp[u]) % MODdp[s] = 1食物链计数
最长路(点权)dp[v] = max(dp[v], dp[u] + val[v])dp[s] = val[s]点权在目标点上加
可达性reach[v] |= reach[u]reach[s] = 1DAG 上的传递性
期望 / 概率p[v] += p[u] × prob(u,v)p[s] = 1随机游走类问题
C++DAG 上 DP 的通用骨架
// ① 先求拓扑序(存进 order[])
kahn(n);                        // 把结果放在 order 数组里

// ② 初始化
const long long NINF = -1e18;
for (int i = 1; i <= n; i++) dp[i] = NINF;
dp[s] = 0;

// ③ 按拓扑序推进
for (int idx = 0; idx < (int)order.size(); idx++) {
    int u = order[idx];
    if (dp[u] == NINF) continue;             // u 不可达,跳过
    for (auto& ed : g[u]) {                  // ed = (v, w)
        int v = ed.first;
        long long w = ed.second;
        if (dp[u] + w > dp[v]) dp[v] = dp[u] + w;   // 最长路
        // 若是路径计数:dp[v] = (dp[v] + dp[u]) % MOD;
    }
}
① 前提图必须是 DAG
② 求拓扑序Kahn 或 DFS
③ 初始化 dp起点设初值
④ 沿拓扑序转移用 u 更新出边
⑤ 无后效性顺序天然正确
⑥ 取答案终点 dp 或聚合

DAG + 拓扑序 + DP 是提高组最经典的"三件套",几乎每一场 CSP-S 都能见到它的身影

📊 一句话:有环的图不能 DP,DAG 上按拓扑序 DP 一定正确。
7典型应用场景:拓扑排序能建模什么
🏭 依赖与调度
  • 编译顺序:头文件 A 依赖 B,B 依赖 C → 拓扑序给出编译顺序
  • 课程安排:先修课关系 → 判断能否修完
  • 任务调度:工程工序的先后约束
  • 软件包安装:依赖树展开顺序
  • 电子表格重算:单元格公式的依赖
🧬 DAG 上的 DP
  • 关键路径:DAG 最长路(工程最短工期)
  • 食物链计数:从生产者到消费者的路径数
  • 路径统计:DAG 上从 s 到 t 的方案数
  • 期望步数:DAG 上随机游走的期望
  • 点权/边权最优化:在 DAG 上做各种最优性 DP

🏗️ 建模技巧一:把"偏序关系"转成 DAG 的边

题目里凡是出现"A 必须在 B 之前"、"要选 B 必须先选 A"、"B 依赖 A"这样的字眼,就应该想到"连一条边 A → B"。

如果同一个约束出现多次(例如"有多个前置条件"),用入度来累积计数即可。

注意方向:边的方向应该与"先后"一致。如果题目给的是"A 必须在 B 之前",那就是 A → B;如果给的是"B 必须在 A 之后",那还是 A → B。方向搞反会导致入度统计全错。

🧱 建模技巧二:分层与"等级"推导

有些题目要求给点分配"等级",使得"每条边都从低等级指向高等级",并且等级数尽量少。这实际上就是"拓扑分层":

· 先把入度为 0 的点定为第 1 层;
· 删掉它们的出边后,新入度为 0 的点定为第 2 层;
· 依次类推。

更一般地,level[v] = max over 入边 (u,v) of (level[u] + 1) —— 这正是 DAG 上求"最长路"(点权版)的变体!

典型例题是 NOIP2013 普及组的"车站分级":已知每次列车停靠的车站集合,要求推出"每个车站的级别尽量少"。做法是把"停靠站"与"未停靠站"之间连边(未停靠站的级别一定低于停靠站),然后求 DAG 最长路。

🔍 建模技巧三:反图 + 反向拓扑序

有些题目要求"从终点倒着想"(例如"编号小的点尽量排在后面"),标准做法是:把所有边反向建图,在新图上求字典序最大的拓扑序,再把结果倒序输出。

这个技巧的原理是:在原图上"v 排在 u 后面"等价于在反图上"v 排在 u 前面"。所以"原图里字典序最小"就变成了"反图里字典序最大"。

典型例题:HNOI2015 菜肴制作(P3243)—— 直接贪心会错,必须用反图 + 大根堆。

🏭 见到"依赖 / 先后 / 前置",就往拓扑排序想;见到"最优 / 计数 / 路径",就往 DAG 上 DP 想。
8Kahn 与 DFS 逆后序的对比选型
对比项Kahn(入度 + 队列)DFS 逆后序
时间复杂度O(n + m)O(n + m)
空间复杂度O(n + m)(队列 + 邻接表)O(n + m)(栈深度 + 邻接表)
递归 / 爆栈✅ 无递归,安全⚠️ 递归版在链状图会爆栈
能否顺便判环✅ 输出个数 < n 即有环✅ 需额外维护三色标记
求字典序最小✅ 换优先队列即可❌ 不方便
求"编号小的尽量靠后"⚠️ 需反图 + 大根堆⚠️ 需反图
与 DP 结合✅ 按出队顺序推进最自然✅ 递归回溯时天然逆序
推荐度⭐⭐⭐ 首选⭐⭐ 备选 / 记忆化搜索场景
题目特征选什么理由
只要求随便一个拓扑序Kahn(普通队列)代码最短、无递归风险
要求字典序最小Kahn + 小根堆贪心正确
要求"编号小尽量靠后"反图 + 大根堆 + 倒序正着贪心会错
要判环Kahn 数个数一行判断
要在 DAG 上做 DPKahn 顺便给出递推顺序一次拓扑排序两用
递归写法更自然(树形 DP)DFS 逆后序 / 记忆化与树形结构契合
案例案例1:拓扑排序模板 · Kahn 完整代码 模板题

题目:给定 n 个点、m 条边的有向图,输出它的任意一个拓扑序;若图中有环,输出 −1。

🔍 详细分析过程

第1步:识别模型 —— 标准的拓扑排序模板。n、m 可能很大(105 量级),必须用 O(n+m) 的算法。

第2步:存图 —— 有向图用 vector<int> g[MAXN] 邻接表即可(不需要边权,也不需要反向边)。

第3步:统计入度 —— 读边时顺便 indeg[v]++,省一次遍历。

第4步:Kahn 主循环 —— 入度 0 入队;出队即记录到答案;对每条出边做 --indeg[v],归零就入队。

第5步:判环 —— 循环结束后若 ans.size() < n,输出 −1。

易错:① 忘记在最后判断长度(有环时不会报错,只会输出不完整的序列);② indeg[] 是全局数组但被修改,若多组数据要重新统计;③ 有向图不要写反方向。

C++拓扑排序模板 · Kahn 完整代码
#include <bits/stdc++.h>
using namespace std;

const int MAXN = 100005;
int indeg[MAXN];
vector<int> g[MAXN];

int main() {
    ios::sync_with_stdio(0); cin.tie(0);
    int n, m;
    cin >> n >> m;

    for (int i = 1; i <= m; i++) {
        int u, v;
        cin >> u >> v;
        g[u].push_back(v);
        indeg[v]++;                       // 读边时顺便统计入度
    }

    queue<int> q;
    for (int i = 1; i <= n; i++)
        if (indeg[i] == 0) q.push(i);   // 入度为 0 的点先入队

    vector<int> ans;
    while (!q.empty()) {
        int u = q.front(); q.pop();
        ans.push_back(u);
        for (int v : g[u])
            if (--indeg[v] == 0)
                q.push(v);
    }

    if ((int)ans.size() < n) {
        cout << -1 << endl;               // 有环
        return 0;
    }
    for (int i = 0; i < n; i++)
        cout << ans[i] << " \n"[i == n - 1];
    return 0;
}
运行结果(无环)
输入: 6 7
      1 2
      1 3
      2 4
      3 4
      4 6
      3 5
      5 6
输出: 1 2 3 4 5 6

(也可以用 DFS 逆后序得到 1 3 5 2 4 6,两者都合法)
运行结果(有环)
输入: 5 5
      1 2
      2 3
      3 4
      4 2
      5 1
输出: -1

(环是 2→3→4→2。Kahn 只能输出 [5, 1],长度 2 < 5 ⇒ 有环)

💡 想一想

为什么 --indeg[v] == 0 可以写成"先减后判断"?因为每个点只会被它的每条入边减一次,且减到 0 之后不会再被减(此时它的所有入边都已处理)。所以"恰好减到 0"这个事件每次运行中只会发生一次,用前置 -- 是安全的。

案例案例2:DAG 最长路 · 拓扑序 + DP 核心套路

题目:给定一张带权 DAG 与源点 s,求 s 到每个点的最长路径长度(不可达输出 −1)。若图中存在环,请先判断并报错。

🔍 详细分析过程

第1步:为什么不能直接用 SPFA 求最长路 —— 把边权全部取反就可以求最长路,但那要求"取反后无负环",即原图无正环。而 DAG 天然没有环,所以更简单的方法就是"拓扑序 + DP"。

第2步:DP 定义 —— dp[v] 表示"从 s 到 v 的最长路径长度"。初始 dp[s] = 0,其余为 −∞。

第3步:转移 —— 按拓扑序处理每个点 u(跳过 dp[u] = −∞ 的不可达点),对每条出边 (u,v,w):dp[v] = max(dp[v], dp[u] + w)。

第4步:输出 —— dp[v] 为 −∞ 时输出 −1。

为什么正确:按拓扑序处理保证了"计算 dp[v] 时,所有能到达 v 的点都已经处理完毕",所以 dp[v] 已经收敛到最终值,不会再被后续改动 —— 这正是无后效性。

易错:① 不要忘记"跳过 dp[u] 为 −∞ 的点"(否则 −∞ + w 会得到奇怪的值,且会把不可达点错当成可达);② 若需要判断"是否存在环",先跑 Kahn 得到拓扑序,若拓扑序长度 < n 就直接报错;③ dp 数组用 long long,NINF 取 -1e18 而不是 -1。

C++DAG 最长路 · 完整代码
#include <bits/stdc++.h>
using namespace std;

const int MAXN = 100005;
const long long NINF = -1e18;

int indeg[MAXN];
long long dp[MAXN];
vector<pair<int, long long>> g[MAXN];   // (终点, 边权)
vector<int> order;

bool kahn(int n) {                  // 顺便判环
    queue<int> q;
    for (int i = 1; i <= n; i++)
        if (indeg[i] == 0) q.push(i);
    while (!q.empty()) {
        int u = q.front(); q.pop();
        order.push_back(u);
        for (auto& ed : g[u])
            if (--indeg[ed.first] == 0) q.push(ed.first);
    }
    return (int)order.size() == n;
}

int main() {
    ios::sync_with_stdio(0); cin.tie(0);
    int n, m, s;
    cin >> n >> m >> s;
    for (int i = 1; i <= m; i++) {
        int u, v; long long w;
        cin >> u >> v >> w;
        g[u].push_back({v, w});
        indeg[v]++;
    }

    if (!kahn(n)) { cout << "有环" << endl; return 0; }

    for (int i = 1; i <= n; i++) dp[i] = NINF;
    dp[s] = 0;

    for (int u : order) {
        if (dp[u] == NINF) continue;             // u 不可达
        for (auto& ed : g[u]) {
            int v = ed.first;
            long long w = ed.second;
            if (dp[u] + w > dp[v]) dp[v] = dp[u] + w;
        }
    }

    for (int i = 1; i <= n; i++)
        cout << (dp[i] == NINF ? -1 : dp[i]) << " \n"[i == n];
    return 0;
}
运行结果
输入: 6 7 1
      1 2 3
      1 3 1
      2 4 2
      3 4 5
      4 6 4
      3 5 2
      5 6 1
输出: 0 3 1 6 3 10

(dp 计算过程:
  dp[1]=0
  dp[2]=0+3=3,dp[3]=0+1=1
  dp[4]=max(dp[2]+2, dp[3]+5)=max(5,6)=6
  dp[5]=dp[3]+2=3
  dp[6]=max(dp[4]+4, dp[5]+1)=max(10,4)=10
  最长路径是 1→3→4→6,长度 1+5+4=10)

💡 想一想

如果这道题改成"求 s 到 t 的最长路上经过的点数(点权为 1)",转移要怎么写?

把边权换成"到达 v 时加 1"即可:dp[v] = max(dp[v], dp[u] + 1),起点 dp[s] = 1。这就是 NOIP2003 神经网络那一类题的核心。

案例案例3:P4017 最大食物链计数 · DAG 路径计数 经典应用

题目:有一个食物网,每个生物是一个点,"被吃"关系是一条有向边(被捕食者 → 捕食者)。生产者是不会捕食其他生物的点(入度为 0),最高级消费者是不会被其他生物吃的点(出度为 0)。一条"食物链"是从生产者到最高级消费者的一条路径。求食物链的总条数(模 80112002)。

🔍 详细分析过程

第1步:识别模型 —— 食物网是 DAG(不会有"循环食物链"),求的是"从所有入度为 0 的点出发,到所有出度为 0 的点的路径总数"。这是标准的 DAG 路径计数。

第2步:DP 定义 —— w[v] 表示"从任意生产者出发、到达 v 的食物链条数"。初始:所有入度为 0 的点 w = 1(它自己构成一条长度为 0 的链的起点),其余 w = 0。

第3步:转移 —— 按拓扑序处理 u,对每条出边 (u,v):w[v] = (w[v] + w[u]) % MOD。

第4步:答案 —— 把所有出度为 0 的点的 w 值加起来(再取模)。

为什么用拓扑序:计算 w[v] 时需要它的所有前驱的 w 都已算好,而拓扑序恰好保证这一点。

易错:① 初始化时是"所有入度为 0 的点"置 1,不是只有 1 号点;② 答案是"所有出度为 0 的点之和",不是只有 n 号点;③ 加法要取模,且模数 80112002 是题目给定的质数,别写成 1e9+7;④ 必须先用 Kahn 判环(虽然题目保证是 DAG,但养成习惯)。

C++P4017 最大食物链计数 · 完整代码
#include <bits/stdc++.h>
using namespace std;

const int MAXN = 5005, MAXM = 500005;
const long long MOD = 80112002;

int indeg[MAXN], outdeg[MAXN];
long long w[MAXN];                 // w[v]:到达 v 的食物链条数
vector<int> g[MAXN];

int main() {
    ios::sync_with_stdio(0); cin.tie(0);
    int n, m;
    cin >> n >> m;

    for (int i = 1; i <= m; i++) {
        int u, v;                     // u 被 v 吃:u → v
        cin >> u >> v;
        g[u].push_back(v);
        indeg[v]++;
        outdeg[u]++;
    }

    queue<int> q;
    for (int i = 1; i <= n; i++)
        if (indeg[i] == 0) { q.push(i); w[i] = 1; }   // 生产者起始为 1

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

    long long ans = 0;
    for (int i = 1; i <= n; i++)
        if (outdeg[i] == 0) ans = (ans + w[i]) % MOD;    // 只统计最高级消费者

    cout << ans << endl;
    return 0;
}
运行结果
输入: 5 6
      1 2
      1 3
      2 5
      3 5
      3 4
      4 5
输出: 3

(生产者:只有 1 号(入度 0);最高级消费者:只有 5 号(出度 0))
(三条食物链:1→2→5、1→3→5、1→3→4→5)
(计算过程:w[1]=1;w[2]=1;w[3]=1;w[5] += w[2] = 1;w[4] += w[3] = 1;
  w[5] += w[3] = 2;w[5] += w[4] = 3 → 答案 3)
💡 注意这道题把"路径计数"和"拓扑排序"缝在一起了 —— 计数在入度减一之前做(先用 w[u] 更新所有出边,再判断入度是否归零)。顺序颠倒了会漏算或重算。

💡 想一想

如果把题目改成"求最长食物链包含的生物数量",只需把 w[v] = (w[v] + w[u]) % MOD; 换成 len[v] = max(len[v], len[u] + 1);(并且起点 len = 1),答案取所有出度为 0 的点的最大值。可见"拓扑序 + DP"这个骨架能容纳各种不同的目标函数。

模块 03

实践应用

三道递进练习:先手推拓扑序,再练判环与字典序,最后完成一道 DAG 上的路径计数。

A1基础应用题

手推拓扑序:6 个点、7 条边,边为 1→2、1→3、2→4、3→4、4→6、3→5、5→6。请写出(1)每个点的入度;(2)用 Kahn 算法得到的拓扑序;(3)另一条不同的合法拓扑序。

提示:Kahn 时按"入队顺序"出队;改变"同时入度归零的两个点的入队先后",就能得到不同的拓扑序。
查看参考答案

(1)入度:

indeg[1] = 0(无入边)
indeg[2] = 1(来自 1)
indeg[3] = 1(来自 1)
indeg[4] = 2(来自 2 和 3)
indeg[5] = 1(来自 3)
indeg[6] = 2(来自 4 和 5)

(2)Kahn 的拓扑序:1 → 2 → 3 → 4 → 5 → 6

过程:队列 [1] → 出 1,2 与 3 入队 → 出 2(indeg[4] 减到 1)→ 出 3(4 与 5 入队)→ 出 4(indeg[6] 减到 1)→ 出 5(6 入队)→ 出 6。

(3)另一条合法拓扑序:1 → 3 → 2 → 5 → 4 → 6

只需验证每条边都从前指向后:1→2 ✅(1 在 3 前,2 在 4 前)
1→3 ✅;2→4 ✅;3→4 ✅(3 在第 2 位、4 在第 5 位);4→6 ✅;3→5 ✅;5→6 ✅。

💡 用 DFS 逆后序还能得到第三条:1 → 3 → 5 → 2 → 4 → 6。说明拓扑序一般不唯一,只要满足"每条边从前指向后"就都对。

B1进阶应用题

判环 + 字典序:5 个点、5 条边,边为 1→4、4→2、2→5、1→3、3→5。请回答:(1)用普通队列得到的拓扑序;(2)字典序最小的拓扑序;(3)如果把边改成 1→4、4→2、2→1、1→3、3→5,还能排出来吗?

提示:注意两次运行中"同时入度归零的点"的先后。
查看参考答案

初始入度:indeg = [1:0, 2:1, 3:1, 4:1, 5:2]

(1)普通队列:队列 [1] → 出 1,4 与 3 依次入队(队列 [4, 3])→ 出 4,2 入队 → 出 3 → 出 2,5 入队 → 出 5。

结果:1 4 3 2 5

(2)字典序最小(小根堆):堆 {1} → 弹 1,堆 {3, 4} → 弹 3 → 堆 {4} → 弹 4,堆 {2} → 弹 2,堆 {5} → 弹 5。

结果:1 3 4 2 5(比 1 4 3 2 5 更小,因为第二位 3 < 4)

(3)不能排出来。新图中有环:1→4→2→1。用 Kahn 判断:初始入度 indeg[1] = 1、indeg[2] = 1、indeg[3] = 1、indeg[4] = 1、indeg[5] = 2 —— 没有任何点的入度为 0,队列一开始就是空的,输出序列长度为 0 < 5 → 有环。

⚠️ 这是一个特别容易踩的坑:入度为 0 的点可能不存在(例如"整个图就是一个大环")。写 Kahn 时不要假设"至少有一个入度为 0 的点"。

C1综合应用题

DAG 路径计数:6 个点、7 条边,边为 1→2、1→3、2→4、3→4、4→6、3→5、5→6。求从 1 到 6 的不同路径条数,以及从 1 到每个点的最长路径长度(每条边长度视为 1)。

提示:先求拓扑序,再按拓扑序做两遍 DP(一遍计数、一遍最长链)。
查看参考答案

拓扑序:1, 2, 3, 4, 5, 6

(1)路径计数 way[](way[1] = 1):

way[1] = 1
way[2] = way[1] = 1
way[3] = way[1] = 1
way[4] = way[2] + way[3] = 1 + 1 = 2
way[5] = way[3] = 1
way[6] = way[4] + way[5] = 2 + 1 = 3

答案:3 条路径 —— 1-2-4-6、1-3-4-6、1-3-5-6

(2)最长链长度 len[](len[1] = 1):

len[1] = 1
len[2] = len[1] + 1 = 2
len[3] = len[1] + 1 = 2
len[4] = max(len[2], len[3]) + 1 = 3
len[5] = len[3] + 1 = 3
len[6] = max(len[4], len[5]) + 1 = 4

答案:从 1 到 6 的最长路径长度为 4(路径 1-3-4-6 或 1-3-5-6,都是 4 个点)

✨ 核心代码骨架:

for (int u : order) {
 for (int v : g[u]) {
  way[v] = (way[v] + way[u]) % MOD;  // 计数
  len[v] = max(len[v], len[u] + 1);  // 最长链
 }
}

⚠️ 注意"点权版最长链"的初始化是 len[s] = 1(起点自己算 1 个点),而不是 0。若求的是"边数最长",则初始化为 0。

模块 04

总结提升

①核心要点与万能模板
考点关键结论必须记住的细节
拓扑序定义每条有向边都从序列前面指向后面存在 ⇔ 图是 DAG
唯一性一般不唯一只有"全序链"才唯一
Kahn 流程入度 0 入队 → 出队输出 → 出边入度减一 → 归零入队O(n + m)
判环ans.size() < n ⇒ 有环环上的点入度永远不归零
DFS 逆后序后序遍历 + 反转递归版在长链会爆栈
DFS 判环三色标记,撞到"访问中"即有环0 未访问 / 1 访问中 / 2 已完成
字典序最小队列换成小根堆O((n+m) log n)
编号小靠后反图 + 大根堆 + 倒序正着贪心会错
DAG 上 DP按拓扑序依次转移最长路、最短路、计数、期望
入度统计读边时顺便 indeg[v]++省一次遍历
C++万能模板一:Kahn 拓扑排序(带判环)
const int MAXN = 100005;
int indeg[MAXN];
vector<int> g[MAXN];
vector<int> order;

bool kahn(int n) {                 // 返回 true 表示无环
    queue<int> q;
    for (int i = 1; i <= n; i++)
        if (indeg[i] == 0) q.push(i);
    while (!q.empty()) {
        int u = q.front(); q.pop();
        order.push_back(u);
        for (int v : g[u])
            if (--indeg[v] == 0) q.push(v);
    }
    return (int)order.size() == n;
}
C++万能模板二:DAG 上 DP(最长路 + 路径计数)
const long long NINF = -1e18, MOD = 80112002;
long long dp[MAXN], way[MAXN];

// 前提:已用 kahn() 求出 order[],且图无环
void dagDP(int n, int s) {
    for (int i = 1; i <= n; i++) { dp[i] = NINF; way[i] = 0; }
    dp[s] = 0;
    way[s] = 1;

    for (int u : order) {
        if (dp[u] == NINF && way[u] == 0) continue;   // u 不可达
        for (auto& ed : g[u]) {
            int v = ed.first;
            long long w = ed.second;
            // 最长路:dp[v] = max(dp[v], dp[u] + w);
            // 最短路:dp[v] = min(dp[v], dp[u] + w);
            // 路径计数:way[v] = (way[v] + way[u]) % MOD;
            (void)v; (void)w;
        }
    }
}
🎯 思维定式

题目里出现 "依赖 / 先后 / 前置 / 必须在…之前" → 建 DAG 跑拓扑排序;出现 "最优 / 计数 / 方案数 / 关键路径" + 无环 → 拓扑序 + DP。

②拓展案例
拓展1反向图 + 大根堆:为什么"编号小尽量靠前"不能直接贪心 易错经典

问题(HNOI2015 菜肴制作 P3243):n 道菜,m 条限制"菜 x 必须在菜 y 之前做好"。要求输出制作顺序,使得"编号小的菜尽量早做" —— 具体来说,先让 1 号菜尽可能早,再在满足这一点的前提下让 2 号菜尽可能早,依次类推。

❌ 直接贪心为什么错

有人想:"这不就是字典序最小拓扑序吗?" 用小根堆贪心即可。但请考虑这个例子:n = 4,限制为 "4 必须在 1 之前"(即 4→1)。

· 小根堆贪心:初始入度为 0 的点是 {2, 3, 4},取最小的 2 → 再取 3 → 再取 4 → 最后 1。结果 2 3 4 1。

· 但题目要的是"1 尽量早"。上面的结果里 1 排在第 4 位。有没有更好的?由于 4 必须在 1 前,1 最早只能排第 2 位:4 1 2 3。显然 4 1 2 3 比 2 3 4 1 更好(因为 1 出现在了第 2 位而不是第 4 位)。

所以"字典序最小"和"编号小的尽量靠前"是两个不同的目标!前者比较第 1 位、第 2 位……;后者关心的是"小号元素的位置尽量靠前"。

✅ 正确做法:反向思考

关键转化:"让编号小的尽量靠前"等价于"让编号大的尽量靠后"。

而"让编号大的尽量靠后"可以在反向图上通过"每次取编号最大的入度零点"来实现 —— 因为反图中的拓扑序正好是原图拓扑序的倒序,取反图中最大的点放到最后,就等价于把它推到原图的最末尾。

算法:

① 建反向图(把每条边 u→v 改成 v→u),并在反向图上统计入度。

② 用大根堆(priority_queue<int> 默认就是大根堆)做 Kahn,得到反向图的拓扑序。

③ 把序列倒序输出。

为什么对:倒序输出后,原图上"排在最后"的就是反图上"最先被取出"的那些点,而每次取的是最大的编号 —— 于是大编号被推到越靠后的位置,小号自然就被挤到越前面。

C++P3243 菜肴制作 · 核心代码
// 读边时反向建图
for (int i = 1; i <= m; i++) {
    int x, y; cin >> x >> y;      // 原意:x 必须在 y 之前
    rg[y].push_back(x);              // 反向图:y → x
    indeg[x]++;                      // 注意是 x 的入度 +1(反向图里 x 的入边来自 y)
}

priority_queue<int> pq;               // 默认大根堆
for (int i = 1; i <= n; i++)
    if (indeg[i] == 0) pq.push(i);

vector<int> res;
while (!pq.empty()) {
    int u = pq.top(); pq.pop();
    res.push_back(u);
    for (int v : rg[u])
        if (--indeg[v] == 0) pq.push(v);
}
if ((int)res.size() < n) { cout << "Impossible!" << endl; continue; }
for (int i = n - 1; i >= 0; i--)
    cout << res[i] << " \n"[i == 0];   // 倒序输出
🔁 口诀:"小号尽量靠前" ⟺ "大号尽量靠后" ⟺ 反图 + 大根堆 + 倒序输出。
拓展2拓扑分层:车站分级 (NOIP2013 普及组 P1983) 建模训练

问题:有 n 个车站排成一条线路,已知 m 趟列车的停靠情况(每趟列车给出它停靠的站点集合,且列车不会跳过"更重要的站")。要求给每个车站分配一个"级别"(正整数),使得"每趟列车中,未停靠的站的级别一定小于它停靠的任何一个站的级别",并且使用的级别总数尽量少。求最少的级别数。

🔍 建模思路

第1步:识别偏序约束。对每趟列车,"它停靠的所有站"级别都必须大于"它经过但没停的所有站"。也就是说:未停靠站 → 停靠站 之间存在约束关系。

第2步:连边建图。对每趟列车,把该车上"停靠集合"与"未停靠集合"之间两两连边(未停靠 → 停靠)。由于"级别严格更大",这个图必然是 DAG(不会出现环,否则级别要严格大于自己)。

第3步:求最长路(点权为 1)。设 level[v] 表示"从任意一个源点出发到 v 的最长链长度"+ 1。因为"v 的级别必须大于所有前驱的级别",所以取所有前驱的最大值加 1 就是 v 的最小可行级别。

第4步:答案 = 所有 level 的最大值。

复杂度分析:直接"两两连边"在最坏情况下是 O(m n²) 条边,需要优化(例如用中转点、或者只连"未停靠站 → 相邻停靠站")。这是这道题最难的地方,也是它作为 NOIP 压轴题的份量所在。

核心转移:level[v] = max over (u,v) of (level[u] + 1),初始所有入度为 0 的点 level = 1。

🚉 分层问题的通法:把"级别严格更大"写成有向边,跑拓扑序 + 最长路。
拓展3DAG 上的期望 DP:P6154 游走 进阶

问题:给定一张有向无环图(可能有重边与自环以外的特殊情况,题目保证无环),等概率地随机选择一条路径,求这条路径长度的期望值(即所有路径长度的平均值)。

🔍 期望 = 总和 / 条数

最直接的想法是把"所有路径长度之和"与"路径条数"分别算出来,再相除。设:

· cnt[v]:以 v 为起点的路径条数(含"长度为 0 的空路径");
· sum[v]:以 v 为起点的所有路径的长度之和。

反向拓扑序递推(从出边往回推):

cnt[v] = 1 + Σ cnt[w]  sum[v] = Σ (sum[w] + cnt[w])

(其中 w 取遍 v 的所有后继;sum[w] + cnt[w] 的含义是"每条以 w 为起点的路径前面接上边 (v,w),长度都加 1"。)

答案:(Σv sum[v]) / (Σv cnt[v]),在模意义下计算(用逆元)。

另一种等价写法(正着推):设 f[v] 表示"以 v 结尾的期望长度",按拓扑序正推。两种写法都要注意递推方向的正确性。

为什么必须用拓扑序:cnt 和 sum 都依赖"所有后继的值",而拓扑序保证"后继排在后面",所以只需要按拓扑序倒序(或反向图的正序)推进即可。

易错:① 期望要取模时,除法要乘逆元(pow(denominator, MOD−2, MOD));② 别忘了"空路径"(长度为 0)是否计入——题目一般要求"至少含一条边的路径",需要相应调整;③ 重边要分别计数。

🎲 DAG 上做期望:同时维护"方案数"和"总量",最后相除(或逐层加权平均)。
③思考与启发

🤔 思考1:为什么"入度为 0 的点"一定存在?能构造一个不存在的图吗?

Kahn 算法从"入度为 0 的点"开始,但如果图里没有这样的点呢?

查看参考答案

答案是:可以构造,例如整个图就是一个大环。n 个点、边为 1→2、2→3、…、n→1,每个点的入度都是 1,没有任何点的入度为 0。此时 Kahn 的初始队列是空的,输出序列长度为 0 < n → 正确判定为有环。

数学上的一般结论:

· 若图是 DAG,则至少存在一个入度为 0 的点(否则从任意点出发一直沿反向边走,点数有限必然重复,形成环,矛盾)。

· 若图有环,则可能没有入度为 0 的点(如上面的大环),也可能有(如"环 + 一条指向环的链",链的起点入度为 0)。

顺着这个证明还能得到:在 DAG 上也一定至少有一个"出度为 0 的点"(把上面的证明应用于反向图即可)。这个结论在构造题里非常常用。

📌 写 Kahn 时不要假设"初始队列非空"。空队列直接导致输出长度为 0,判环逻辑会自然处理它,但如果你在循环前用了什么"取队首"的操作就会崩。

🤔 思考2:拓扑序唯一需要什么条件?"任意两点之间都有路径"是否等价?

思考一下"唯一性"这件事的充要条件。

查看参考答案

充要条件:图中任意两个不同的点之间都存在一条有向路径(即可比较)。满足这个条件的 DAG 称为"全序"或"链状"的关系。

充分性证明:若任意两点可比,那么按拓扑序排列后,任意两点的相对顺序都被唯一确定了,所以拓扑序唯一。

必要性证明(反证):若存在两点 u、v 之间没有任何路径(既不能 u→…→v,也不能 v→…→u),那么 Kahn 运行到某一刻时,u 和 v 会同时在队列里,此时先出 u 或先出 v 会得到两个不同的合法拓扑序 → 不唯一。

一个实用的判定方法:运行 Kahn 时,如果队列的大小始终 ≤ 1,那么拓扑序唯一;只要某一刻队列里有 ≥ 2 个点,就说明存在多种拓扑序。

💡 这个判定经常出现在题目里,例如"判断给定的比赛结果能否确定唯一排名"(P1347 排序):每加入一条新边就跑一次拓扑排序,如果队列大小始终为 1 则唯一;如果在某一步出现了环则矛盾。

🤔 思考3:DAG 上的 DP 与"记忆化搜索"有什么关系?

很多人写 DAG 上的 DP 时习惯用记忆化搜索,这算不算"没有求拓扑序"?

查看参考答案

本质上是一回事。记忆化搜索隐含地使用了 DFS 逆后序作为递推顺序:递归函数 f(u) 在返回之前,一定会先递归地把所有后继 f(v) 算好,然后把结果保存下来。这个"计算顺序"正是 DFS 的完成顺序 —— 也就是逆后序。

两者各有优劣:

· 记忆化搜索:代码短、只访问"真正需要"的状态(惰性求值)、天然适合树形 DP。缺点是有递归深度限制(可能爆栈),且在有环时不会报错而是无限递归(除非额外加"访问中"标记)。

· 显式拓扑排序 + 迭代 DP:无爆栈风险、可以顺便判环、能处理"多重入边的聚合顺序"这类细节。缺点是会多访问一些"其实用不到"的状态。

选择建议:图的规模大(n ≥ 105)或链状结构明显时用拓扑排序 + 迭代;树形结构或状态转移到不了几个点时用记忆化搜索更方便。

🌟 特别提醒:如果题目不保证无环,用记忆化搜索必须加"访问中"标记,否则有环时会栈溢出或死循环。用 Kahn 则一行判断就搞定了。

模块 05

学习检测

先找错,再背口诀——把易错点消灭在考试之前。

⚡高频错误诊断
易错1

忘记判环:输出不完整的序列却当成正确答案

❌ 错误
Kahn 跑完直接输出 ans,没有检查长度。

后果:图中有环时程序默默输出了一个残缺序列,OJ 判 WA 但你看不出原因(甚至在小数据上"看起来是对的")。
✅ 正确
if ((int)ans.size() < n) { cout << -1; return 0; }

养成习惯:只要用了 Kahn,就在最后加这一行判断,代价是 O(1)。
口诀:Kahn 跑完必数个数,不足 n 就有环!
易错2

把"字典序最小"与"编号小尽量靠前"混为一谈

❌ 错误
看到"希望小号在前面"就直接用小根堆贪心。

后果:像 HNOI2015 菜肴制作那样的题目会 WA。反例:n = 4、约束 4→1,小根堆给出 2 3 4 1,但 4 1 2 3 才符合"1 尽量早"。
✅ 正确
字典序最小(逐位比较)→ 小根堆 + 正图。
编号小尽量靠前 → 反图 + 大根堆 + 倒序输出。
两者是不同的目标函数,务必读清题面。
口诀:字典序最小正着贪,小号靠前反着来!
易错3

DFS 逆后序忘了反转

❌ 错误
把后序(post)直接当作拓扑序输出。

后果:得到的是"拓扑序的倒序",每条边都从后指向前,完全反了。小数据上不容易察觉(如果图本身很简单)。
✅ 正确
后序必须 reverse(post.begin(), post.end()) 之后才是拓扑序;或者改成"逆序插入"(用 push_front 或用栈)。
记忆方法:后序里"最后完成的点在最末尾",而最后完成的往往是源点,源点应该在拓扑序最前面 → 所以要反转。
口诀:后序倒着读,才是拓扑序!
易错4

DAG DP 时忘记"跳过不可达点"

❌ 错误
按拓扑序推进时,对每个点都用 dp[u] + w 去更新出边。若 u 不可达(dp[u] = −∞),−∞ + w 仍是一个极大的负数,但若用的是"−1 表示不可达"就会被误当成"可达且长度 −1"。
❌ 错误
用 −1 或 INT_MIN 作为"不可达"的标记,然后直接参与加法运算。

后果:① INT_MIN + w 溢出;② −1 + w 可能变成正数,把不可达点错误地变成可达。
✅ 正确
用 long long 的 NINF = -1e18 作标记,并在循环开始处加一句:
if (dp[u] == NINF) continue;
这样不可达点完全不会污染其他点。
口诀:不可达先跳过,别让 −∞ 去加边!
易错5

路径计数时"加法"与"入度减一"的顺序颠倒

❌ 错误
写成 if (--indeg[v] == 0) q.push(v); 再 w[v] += w[u];

后果:看着好像没问题 —— 但如果一个点有多个前驱,它的 w 值会分散在多个时刻被累加,而"入度归零"后立即入队;只要累加确实发生在入队之前就没问题,但一旦写成"入队后再累加"(比如在别的地方补),就会出现"点已经出队了、w 还没加完"的错误。
✅ 正确
约定统一顺序:先累加 DP 值,再判断入度是否归零。
w[v] = (w[v] + w[u]) % MOD;
if (--indeg[v] == 0) q.push(v);

这样保证"点入队时,它所有前驱的贡献都已经累加完毕"。
口诀:先算值,再减度;入队之前,账要结清!
📋速查表
任务做法复杂度关键点
随便一个拓扑序Kahn + queueO(n+m)入度 0 入队,出队即输出
字典序最小拓扑序Kahn + 小根堆O((n+m) log n)每次取编号最小
编号小的尽量靠前反图 + 大根堆 + 倒序O((n+m) log n)先取编号最大
判有向图是否有环Kahn 输出个数 < nO(n+m)或 DFS 三色标记
拓扑序是否唯一Kahn 队列大小始终保持 ≤ 1O(n+m)出现 2 个即不唯一
DAG 最长路拓扑序 + dp[v]=max(dp[v],dp[u]+w)O(n+m)跳过不可达点
DAG 最短路拓扑序 + dp[v]=min(dp[v],dp[u]+w)O(n+m)比 Dijkstra 更快
DAG 路径计数拓扑序 + way[v]=(way[v]+way[u])%MODO(n+m)源点 way = 1
拓扑分层 / 级别level[v] = max(level[u]) + 1O(n+m)点权版最长路
DFS 逆后序后序遍历 + reverseO(n+m)递归版可能爆栈
常见目标初始化怎么写答案怎么取
从单一源点 s 出发的最长路dp[s] = 0,其余 NINFdp[t] 或 max(dp[i])
从所有入度 0 的点出发的路径数所有 indeg=0 的点 way = 1Σ way[i](i 出度为 0)
从 s 到 t 的路径数way[s] = 1way[t]
最长链(点权)所有 indeg=0 的点 len = 1max(len[i])
分层 / 级别所有 indeg=0 的点 level = 1max(level[i])
📌笔记卡
🧠 必记概念
  • 拓扑序:每条边都从序列前面指向后面
  • 存在 ⇔ 是 DAG;一般不唯一
  • 入度 = 还差几个未完成的前置条件
  • Kahn:入度 0 入队,出边减一,归零入队
  • 判环:输出个数 < n
  • 唯一性:队列大小始终 ≤ 1
  • DAG + 拓扑序 = DP 的合法递推顺序
📐 必背模板
  • 入度统计:g[u].push_back(v); indeg[v]++;
  • Kahn 核心:if (--indeg[v] == 0) q.push(v);
  • 判环:if ((int)ans.size() < n) 有环;
  • 字典序最小:priority_queue<int, vector<int>, greater<int>>
  • 反图技巧:反向建边 + 大根堆 + 倒序输出
  • DAG 最长路:dp[v] = max(dp[v], dp[u]+w)
  • 路径计数:way[v] = (way[v]+way[u]) % MOD
⚠️ 考场提醒
  • 边的方向别搞反("A 在 B 前" → A→B)
  • Kahn 完必须判长度
  • "字典序最小"与"小号靠前"是两回事
  • DFS 逆后序记得 reverse
  • 长链图用 Kahn,DFS 递归会爆栈
  • DAG DP 要跳过不可达点(NINF 用 −1e18)
  • 计数题先取模,注意题目给的模数
  • 先算 DP 值、再减入度,顺序别乱
课后练习

分层练习作业

A基础 / B提高 / C挑战,难度递增。C组后附 12 道洛谷原题。

🟢 A组 · 基础(4题)

A1概念题

什么是拓扑序?什么样的图有拓扑序?拓扑序唯一吗?如何判断一个有向图有没有环?

查看参考答案

拓扑序:把图中所有点排成一个线性序列,使得对每条有向边 (u, v),u 都出现在 v 之前(即每条边都从序列前面指向后面)。

存在条件:有向图存在拓扑序 当且仅当它是有向无环图(DAG)。有环时,环上的点互相要求"在对方之前",矛盾,所以排不出来。

唯一性:一般不唯一。当且仅当"任意两个不同的点之间都存在有向路径"(图是一条链)时,拓扑序才唯一。

判环的两种方法:

① Kahn 数个数:拓扑排序后如果输出序列长度 < n,说明有环(环上的点入度永远不归零)。O(n+m)。

② DFS 三色标记:用 0 = 未访问、1 = 访问中、2 = 已完成标记每个点。若在访问 u 时遇到一个状态为 1 的邻点 v,说明 v 是 u 的祖先,形成环。O(n+m)。

易错:① 把"有环"和"图不连通"混淆 —— 不连通不影响拓扑排序;② 忘记判环,直接输出不完整的序列。

A2编程题

手推拓扑排序:6 个点、7 条边,边为 1→2、1→3、2→4、3→4、4→6、3→5、5→6。写出每个点的入度,并用 Kahn 算法写出拓扑序。

查看参考答案

入度:indeg[1] = 0、indeg[2] = 1、indeg[3] = 1、indeg[4] = 2、indeg[5] = 1、indeg[6] = 2

Kahn 过程:

① 入度 0 的点只有 1 → 队列 [1]

② 出 1,输出 1;1→2 使 indeg[2] = 0 入队;1→3 使 indeg[3] = 0 入队 → 队列 [2, 3]

③ 出 2,输出 2;2→4 使 indeg[4] = 1 → 队列 [3]

④ 出 3,输出 3;3→4 使 indeg[4] = 0 入队;3→5 使 indeg[5] = 0 入队 → 队列 [4, 5]

⑤ 出 4,输出 4;4→6 使 indeg[6] = 1 → 队列 [5]

⑥ 出 5,输出 5;5→6 使 indeg[6] = 0 入队 → 队列 [6]

⑦ 出 6,输出 6 → 队列空

拓扑序:1 2 3 4 5 6(长度 6 = n,无环)

另一条合法拓扑序:1 3 2 5 4 6(验证每条边都从前指向后即可)

A3判断题

判断下面说法的正误并说明理由:
(1)任何有向图都有拓扑序;
(2)DAG 的拓扑序一定唯一;
(3)Kahn 算法的时间复杂度是 O(n + m);
(4)Kahn 算法一开始至少要有一个入度为 0 的点。

查看参考答案

(1)错。只有 DAG 才有拓扑序。有环时(例如一个三元环 1→2→3→1)不存在任何合法顺序。

(2)错。DAG 的拓扑序一般不唯一。例如"点 1、点 2 之间没有边",那么 1 2 和 2 1 都是合法拓扑序。只有"任意两点都可比"时才唯一。

(3)对。每个点入队出队各一次 O(n),每条边被检查一次 O(m),总计 O(n + m)。

(4)错。如果整个图就是一个大环(1→2→…→n→1),那么所有点的入度都是 1,没有任何入度为 0 的点。此时初始队列为空,Kahn 输出长度 0 → 正确判定为有环。所以写代码时不能假设初始队列非空。

补充结论:对 DAG 一定有入度为 0 的点(否则从任意点一直走反向边必成环);但对一般的图不一定。

A4编程题

用 Kahn 算法判断有向图是否有环:5 个点、5 条边,边为 1→2、2→3、3→4、4→2、5→1。请说明运行过程和结论。

查看参考答案

入度:indeg[1] = 1(来自 5)、indeg[2] = 2(来自 1 和 4)、indeg[3] = 1(来自 2)、indeg[4] = 1(来自 3)、indeg[5] = 0

Kahn 过程:

① 入度 0 的只有 5 → 队列 [5]

② 出 5,输出 5;5→1 使 indeg[1] 从 1 减到 0 入队 → 队列 [1]

③ 出 1,输出 1;1→2 使 indeg[2] 从 2 减到 1,不为 0 → 队列空,算法结束

结论:输出序列为 [5, 1],长度 2 < 5 → 图中有环。环是 2→3→4→2(权值和无关,只看结构)。

#include <bits/stdc++.h>
using namespace std;
const int MAXN = 10005;
int indeg[MAXN];
vector<int> g[MAXN];
int main(){
    ios::sync_with_stdio(0); cin.tie(0);
    int n = 5, m = 5;
    int eu[6]={0,1,2,3,4,5}, ev[6]={0,2,3,4,2,1};
    for (int i = 1; i <= m; i++) { g[eu[i]].push_back(ev[i]); indeg[ev[i]]++; }

    queue<int> q;
    for (int i = 1; i <= n; i++) if (indeg[i] == 0) q.push(i);

    int cnt = 0;
    while (!q.empty()) {
        int u = q.front(); q.pop(); cnt++;
        for (int v : g[u]) if (--indeg[v] == 0) q.push(v);
    }
    cout << (cnt < n ? "有环" : "无环") << endl;
    return 0;
}

输出:有环

易错:这道题的初始队列只有一个点(5),如果代码里写了"取队首"而没判空,就会在别的情形(比如整体是一个大环)下崩溃。务必用 while (!q.empty()) 的形式。

🟡 B组 · 提高(4题)

B1编程题

字典序最小拓扑序:5 个点、5 条边,边为 1→4、4→2、2→5、1→3、3→5。分别用普通队列和优先队列求拓扑序。

查看参考答案

初始入度:indeg[1] = 0、indeg[2] = 1、indeg[3] = 1、indeg[4] = 1、indeg[5] = 2

(1)普通队列:队列 [1] → 出 1,4 与 3 依次入队 → 队列 [4, 3] → 出 4,2 入队 → 队列 [3, 2] → 出 3 → 出 2,5 入队 → 出 5。

结果:1 4 3 2 5

(2)优先队列(小根堆):堆 {1} → 弹 1,堆 {3, 4} → 弹 3 → 堆 {4} → 弹 4,堆 {2} → 弹 2,堆 {5} → 弹 5。

结果:1 3 4 2 5(字典序最小)

对比:两个序列的第二位分别是 4 和 3,而 3 < 4,所以后者更小 —— 这就是"每次取编号最小的入度零点"带来的效果。

priority_queue<int, vector<int>, greater<int>> pq;   // 小根堆
for (int i = 1; i <= n; i++) if (indeg[i] == 0) pq.push(i);
while (!pq.empty()) {
    int u = pq.top(); pq.pop();
    cout << u << ' ';
    for (int v : g[u]) if (--indeg[v] == 0) pq.push(v);
}

易错:优先队列的第三个模板参数 greater<int> 不能漏,否则默认是大根堆,求出来的是字典序最大的拓扑序。

B2思维题

反图技巧:n = 4,约束为"4 必须在 1 之前"(即 4→1)。要求输出制作顺序,使得"编号小的菜尽量早做"(先让 1 尽量早,再让 2 尽量早……)。请说明正确做法与错误做法。

查看参考答案

❌ 错误做法(正图 + 小根堆):初始入度为 0 的点是 {2, 3, 4}(1 的入度是 1)。取最小的 2 → 再取 3 → 再取 4 → 最后 1。结果 2 3 4 1。

这显然不好:1 号菜排在第 4 位。而 1 的入度只来自 4,只要先做 4,1 立马就能做。

✅ 正确做法(反图 + 大根堆 + 倒序):

① 反向建图:原来的 4→1 变成 1→4。此时入度为 0 的点是 {1, 2, 3}。

② 用大根堆:弹 3 → 弹 2 → 弹 1(出队时 4 的入度归零入堆)→ 弹 4。得到反图拓扑序 [3, 2, 1, 4]。

③ 倒序输出:4 1 2 3 —— 1 号菜排在第 2 位,比 2 3 4 1 好得多。

为什么正确:"让编号小的尽量靠前"等价于"让编号大的尽量靠后"。在反图上每次取编号最大的入度零点放到序列里,倒序后就等于在原图上把它推到最靠后的位置 —— 大号被推走了,小号自然就被挤到前面。

易错:这道题的关键在于"字典序最小"和"小号尽量靠前"是两个不同的目标。前者是逐位比较(2 3 4 1 比 4 1 2 3 小!),后者关心的是小号元素的位置。读题时务必分清。

B3编程题

DAG 最长路:6 个点、7 条带权边,源点 1,边为 1→2:3、1→3:1、2→4:2、3→4:5、4→6:4、3→5:2、5→6:1。求 s=1 到每个点的最长路长度。

查看参考答案

拓扑序:1 2 3 4 5 6

按拓扑序 DP(dp[1] = 0,其余为 −∞):

dp[1] = 0
处理 1:dp[2] = 0+3 = 3;dp[3] = 0+1 = 1
处理 2:dp[4] = 3+2 = 5
处理 3:dp[4] = max(5, 1+5) = 6;dp[5] = 1+2 = 3
处理 4:dp[6] = 6+4 = 10
处理 5:dp[6] = max(10, 3+1) = 10
处理 6:无出边

答案:dp[1..6] = 0, 3, 1, 6, 3, 10

最长路径是 1→3→4→6,长度 1+5+4 = 10。

易错:① 必须先确认无环(本题保证 DAG,但通用代码要判);② dp 初始化为 -1e18 而不是 −1(−1 会被误当成"可达且长度为 −1");③ 处理每个点时先判断 if (dp[u] == NINF) continue;。

B4编程题

DAG 路径计数:食物网有 5 个点、6 条边,边为 1→2、1→3、2→5、3→5、3→4、4→5(u→v 表示 v 吃 u)。生产者是入度为 0 的点,最高级消费者是出度为 0 的点。求食物链总条数(模 80112002)。

查看参考答案

入度 / 出度:

indeg:1:0、2:1、3:1、4:1、5:3
outdeg:1:2、2:1、3:2、4:1、5:0

生产者:只有 1 号(入度 0);最高级消费者:只有 5 号(出度 0)。

DP 过程(w[1] = 1,其余入度为 0 的点也置 1):

w[1] = 1
处理 1:w[2] += w[1] = 1;w[3] += w[1] = 1
处理 2:w[5] += w[2] = 1
处理 3:w[5] += w[3] = 2;w[4] += w[3] = 1
处理 4:w[5] += w[4] = 3
处理 5:无出边

答案:统计所有出度为 0 的点(只有 5)→ w[5] = 3

三条食物链:1→2→5、1→3→5、1→3→4→5

易错:① 初始化时是"所有入度为 0 的点"置 1,不是只有 1 号点;② 答案是"所有出度为 0 的点之和",不是只有 n 号点;③ 取模用题目给的 80112002,别写成 1e9+7;④ 先累加 w[v],再判断入度是否归零,顺序不能颠倒。

🔴 C组 · 挑战(4题)

C1建模题

拓扑分层:5 个点、4 条边,边为 1→3、2→3、3→4、3→5。要求给每个点分配正整数级别,使得每条边都从低级别指向高级别,且级别数尽量少。求最少的级别数与各点的级别。

查看参考答案

建模:设 level[v] 为 v 的级别。约束"每条边从低指高"意味着对每条边 (u,v) 有 level[v] ≥ level[u] + 1。为了占用级别数尽量少,取等号即可 —— 于是 level[v] = max over 入边 (u,v) of (level[u] + 1),这正是点权版最长路。

计算:

入度为 0 的点:1、2 → level[1] = level[2] = 1
level[3] = max(level[1], level[2]) + 1 = 2
level[4] = level[3] + 1 = 3
level[5] = level[3] + 1 = 3

答案:最少级别数 = 3;级别分布为 1:1, 2:1, 3:2, 4:3, 5:3

验证:1→3 是 1→2 ✅;2→3 是 1→2 ✅;3→4 是 2→3 ✅;3→5 是 2→3 ✅。

// 拓扑序 + 最长路(点权为 1)
for (int i = 1; i <= n; i++)
    if (indeg[i] == 0) { level[i] = 1; q.push(i); }

while (!q.empty()) {
    int u = q.front(); q.pop();
    for (int v : g[u]) {
        level[v] = max(level[v], level[u] + 1);   // 点权版最长路
        if (--indeg[v] == 0) q.push(v);
    }
}
int ans = *max_element(level + 1, level + n + 1);

易错:① level 的初值必须给 1(而不是 0),因为级别是正整数;② 更新 level[v] 必须放在"入度减一"之前或之后都行,但必须在 v 出队之前完成;③ 若题目不保证无环,要先判环。

C2综合题

DFS 逆后序与三色判环:请写出 DFS 求拓扑序的完整代码,并说明如何在不额外遍历的情况下同时判断环。

查看参考答案

三色标定:color[u] = 0 未访问、= 1 正在访问(还在递归栈里)、= 2 已完成。

判环原理:若在访问 u 的过程中遇到一个 color[v] == 1 的邻点 v,说明 v 是 u 的祖先 —— 从 v 到 u 再到 v 形成了一条反向边,即存在环。

int color[MAXN];
vector<int> post;
bool cyc = false;

void dfs(int u) {
    color[u] = 1;                            // 进入:访问中
    for (int v : g[u]) {
        if (color[v] == 1) { cyc = true; return; }    // 撞到祖先 → 有环
        if (color[v] == 0) { dfs(v); if (cyc) return; }
    }
    color[u] = 2;                            // 离开:已完成
    post.push_back(u);                        // 后序
}

// 主函数
for (int i = 1; i <= n; i++)
    if (color[i] == 0) { dfs(i); if (cyc) break; }

if (cyc) cout << "有环" << endl;
else {
    reverse(post.begin(), post.end());        // ← 关键!逆后序
    for (int x : post) cout << x << ' ';
}

用 6 点示例验证(邻接表按输入顺序):

DFS(1) → DFS(2) → DFS(4) → DFS(6):6 完成后 post = [6];4 完成 → [6,4];2 完成 → [6,4,2];1 的第二个邻居 3 → DFS(5)(6 已访问)完成 → [6,4,2,5];3 完成 → [6,4,2,5,3];1 完成 → [6,4,2,5,3,1]。

倒序得拓扑序:1 3 5 2 4 6(与 Kahn 的 1 2 3 4 5 6 不同,但都合法)

易错:① 忘记 reverse 是最常见的错误;② n 大时递归会爆栈,应改用 Kahn 或手写栈;③ 三色标记中"访问中"状态不能省 —— 只用"已访问"布尔数组是无法区分"前向边"和"反向边"的,会把横叉边误判成环。

C3综合题

DAG 上的期望:一张 DAG,从所有路径中等概率随机选一条,求路径长度的期望。请写出递推式与实现要点。

查看参考答案

思路:期望 = 所有路径长度之和 ÷ 路径总条数。分别维护两个数组。

定义(以 v 为起点):

· cnt[v]:以 v 为起点的路径条数(含"长度为 0 的空路径");
· sum[v]:以 v 为起点的所有路径的长度之和。

递推式(按拓扑序倒序推进,即从出边往回推):

cnt[v] = 1 + Σw∈后继 cnt[w]
sum[v] = Σw∈后继 (sum[w] + cnt[w])

含义:每条"以 w 为起点的路径"前面接上边 (v,w),长度都 +1;所以 sum 里对应增加 sum[w] + cnt[w]。+1 是空路径(v 自己作为终点)。

答案:(Σv sum[v]) / (Σv cnt[v])(在模意义下用逆元,或直接用 double)。

实现要点:

① 用 Kahn 求出拓扑序 order,然后从后往前遍历 order(或反向建图后从前往后)。

② 所有值都要取模;如果题目要求分数形式,用 cnt 求逆元再乘 sum。

③ 注意区分"是否允许空路径"、"是否只统计至少含一条边的路径" —— 这会影响 cnt 初值是 1 还是 0,以及最终答案的调整。

④ 有重边时要分别计数(每条边都是独立的一条路径)。

易错:① 递推方向搞错(必须从"终点往起点"推,因为 cnt[v] 依赖所有后继);② 忘记取模或逆元;③ 把"路径条数"和"路径长度之和"的初始化搞混(cnt 初值 1 表示空路径,sum 初值 0)。

C4思维题

综合思考:为什么"求拓扑序唯一"不能只看"是否连通"?请举出一个"连通但不唯一"和一个"不连通但唯一"(或说明不可能)的例子。

查看参考答案

唯一性的充要条件:图中任意两个不同的点之间都存在有向路径(即这个"偏序关系"实际上是全序)。

「连通但不唯一」的例子:3 个点,边只有 1→2。从"无向意义上看"点 1、2、3 不在同一个连通块,但即使改为"1→2、3→2"(有向意义下 3 也能到 2)—— 点 1 和点 3 之间依然没有路径。此时队列里会同时出现 1 和 3,先出谁都可以 → 两种合法拓扑序(1 3 2 和 3 1 2)。

更好的例子:3 个点,边为 1→3、2→3。点 1 与点 2 之间没有路径,队列初始为 {1, 2} → 拓扑序有 1 2 3 和 2 1 3 两种。但去掉方向看,这张图是连通的!

「不连通但唯一」不可能:如果图在有向可达意义上不"完全连通",就存在两个点 u、v 互不可达。此时把它们放进同一张图里,Kahn 到某一刻必然会让 u 和 v 同时出现在队列中(因为它们各自的前驱链互不影响),于是可以交换它们的出场顺序得到两个不同的拓扑序。所以唯一 ⇒ 强连通(作为偏序是全序)。

实用判定方法:运行 Kahn 时,若队列大小始终 ≤ 1,则拓扑序唯一;只要某一刻队列大小 ≥ 2,就说明有多种拓扑序(那些同时在队列里的点可以任意交换顺序)。

典型应用:P1347 排序 —— 每读入一条新边就跑一次拓扑排序,用一个变量记录"当前是否唯一 / 是否已出现环",从而判断能否确定所有元素的相对顺序。

🔵 洛谷原题精选(12 道)

按难度从易到难排序,建议先做题号 1~6(图的遍历与拓扑模板),再攻 7~12。

1
P5318
【深基18.例3】查找文献入门
2
B3644
【模板】拓扑排序 / 家谱树入门
3
P1113
杂务普及
4
P1807
最长路普及
5
P1347
排序普及
6
P1038
[NOIP2003 提高组] 神经网络普及
7
P4017
最大食物链计数普及
8
P2712
摄像头普及
9
P1983
[NOIP2013 普及组] 车站分级提高
10
P3183
[HAOI2016] 食物链提高
11
P3243
[HNOI2015] 菜肴制作提高
12
P6154
游走提高