第 8.2 章:拓扑排序与 DAG DP
📝 前置要求: 本章需要第 5.1 章(图的表示)、第 5.2 章(BFS/DFS)及第 6.1~6.2 章(DP 基础)。学习前应熟悉邻接表和基本记忆化搜索。
有向无环图(DAG) 是没有环的有向图。DAG 能建模依赖关系——任务、先修课程、构建系统、谜题状态——并支持一种特殊算法:拓扑排序,它将顶点排成一条线,使得每条有向边都从前面的顶点指向后面的顶点。
学习目标:
- 用 Kahn 算法(BFS)和 DFS 实现拓扑排序
- 检测有向图中的环
- 在 DAG 上应用 DP:最长路径、路径计数、关键路径分析
- 用 Tarjan 或 Kosaraju 算法求强连通分量(SCC)
- 构建缩点 DAG,并在一般有向图上做 DAG DP
- 将 2-SAT 问题规约为 SCC 问题求解
- 识别"在约束下排序"的问题本质是拓扑排序
8.2.0 什么是 DAG?
有向无环图的边有方向且不含环:
DAG(合法): 不是 DAG(含环):
A → B → D A → B
↓ ↓ ↑ ↓
C ──────►E D ← C
DAG 在以下场景中自然出现:
- 先修课程: 课程 A 必须在课程 B 之前修
- 构建系统: 模块 A 必须在模块 B 之前编译
- 状态机: 谜题状态,不能返回之前的状态
- 任务调度: 事件 A 必须在事件 B 之前发生
DAG 的核心操作是拓扑排序:将所有顶点排成一条线,使得对于每条有向边 (u → v),u 都出现在 v 之前。
DAG: 合法的拓扑序:
A → B → D A, C, B, D, E
↓ ↑ A, B, C, D, E
C ──────► (可能存在多个合法排序)
8.2.1 Kahn 算法(BFS 拓扑排序)
核心思想: 反复移除入度为 0(没有前置依赖)的顶点。移除一个顶点后,其后继顶点的入度各减 1;任何入度降至 0 的顶点加入队列。
📄 C++ 完整代码
#include <bits/stdc++.h>
using namespace std;
// 返回拓扑序,若检测到环则返回空 vector
vector<int> topoSort(int n, vector<vector<int>>& adj) {
// 第 1 步:计算入度
vector<int> indegree(n, 0);
for (int u = 0; u < n; u++)
for (int v : adj[u])
indegree[v]++;
// 第 2 步:将所有源点(入度为 0 的顶点)加入队列
queue<int> q;
for (int i = 0; i < n; i++)
if (indegree[i] == 0)
q.push(i);
vector<int> order;
while (!q.empty()) {
int u = q.front(); q.pop();
order.push_back(u);
for (int v : adj[u]) {
indegree[v]--; // 删除边 u → v
if (indegree[v] == 0) // v 的最后一个前置完成
q.push(v);
}
}
// 若输出不包含所有顶点,说明有环
if ((int)order.size() != n) return {}; // 检测到环
return order;
}
int main() {
ios_base::sync_with_stdio(false);
cin.tie(NULL);
int n, m;
cin >> n >> m;
vector<vector<int>> adj(n);
for (int i = 0; i < m; i++) {
int u, v;
cin >> u >> v;
u--; v--;
adj[u].push_back(v);
}
vector<int> order = topoSort(n, adj);
if (order.empty()) {
cout << "检测到环——不存在拓扑序\n";
} else {
for (int v : order) cout << v + 1 << " ";
cout << "\n";
}
return 0;
}
复杂度: O(V + E)
💡 环检测: 若 Kahn 算法的输出顶点数少于 N,则存在环。那些"卡住"的顶点(入度始终无法降至 0 的)就是环的组成部分。
Kahn 算法追踪示例
📄 查看代码:Kahn 算法追踪示例
图:0→1, 0→2, 1→3, 2→3, 3→4
初始入度:[0, 1, 1, 2, 1]
队列:[0]
弹出 0 → order=[0],入度 1→0, 2→0
队列:[1, 2]
弹出 1 → order=[0,1],入度 3→1
弹出 2 → order=[0,1,2],入度 3→0
队列:[3]
弹出 3 → order=[0,1,2,3],入度 4→0
队列:[4]
弹出 4 → order=[0,1,2,3,4]
5 个顶点全部处理完 → 合法的拓扑序!
8.2.2 基于 DFS 的拓扑排序
另一种方法:DFS 按完成时间的逆序输出结果。
📄 另一种方法:DFS 按**完成时间的逆序**输出结果。
#include <bits/stdc++.h>
using namespace std;
vector<int> adj_list[100001];
vector<int> topo_order;
int color[100001]; // 0=白色(未访问), 1=灰色(栈中), 2=黑色(已完成)
bool has_cycle = false;
void dfs(int u) {
color[u] = 1; // 标记为"处理中"
for (int v : adj_list[u]) {
if (color[v] == 1) {
has_cycle = true; // 后向边 → 有环!
return;
}
if (color[v] == 0)
dfs(v);
}
color[u] = 2; // 标记为"已完成"
topo_order.push_back(u); // 完成子树后再加入结果
}
int main() {
int n, m;
cin >> n >> m;
// ... 读取边 ...
for (int i = 0; i < n; i++)
if (color[i] == 0)
dfs(i);
reverse(topo_order.begin(), topo_order.end()); // ← 关键:反转完成顺序
// topo_order 现在是合法的拓扑序
}
为什么要反转完成顺序? DFS 中,一个顶点完成(加入结果)的时间晚于其所有可达顶点完成的时间。因此 DFS 中较晚完成的顶点应排在最前面;取反转后即得拓扑序。
⚠️ Kahn vs DFS: 两者都有效。Kahn 更直观,且能自然地通过计数检测环。DFS 版对于递归实现有时更简洁。
8.2.3 DAG 上的 DP
一旦得到拓扑序,就可以高效地在 DAG 上运行 DP:按拓扑序处理顶点,根据前驱顶点的状态更新每个顶点的状态。
核心思路: 按拓扑序处理顶点 v 时,v 的所有前驱都已处理完毕。因此可以用 dp[前驱] 来计算 dp[v]。
DAG 中的最长路径
📄 查看代码:DAG 中的最长路径
// 到达每个顶点的最长路径
vector<int> dp(n, 0); // dp[v] = 到达 v 的最长路径
// 按拓扑序处理
for (int u : topo_order) {
for (int v : adj[u]) {
dp[v] = max(dp[v], dp[u] + edge_weight[u][v]);
// ↑ 当前最优 ↑ 通过 u→v 延伸路径
}
}
int ans = *max_element(dp.begin(), dp.end());
从源点到各顶点的路径计数
vector<long long> cnt(n, 0);
cnt[source] = 1; // 到达源点的方案数为 1
for (int u : topo_order) {
for (int v : adj[u]) {
cnt[v] += cnt[u]; // 加上到达 u 的所有路径,经 u→v 延伸
cnt[v] %= MOD; // 若需要取模
}
}
// cnt[t] = 从 source 到 t 的路径数
USACO 风格示例:关键路径(最早完成时间)
任务 1..N,各有执行时长。任务 v 在所有前置任务完成后才能开始。求每个任务最早的开始时间。
📄 C++ 完整代码
// earliest_start[v] = max(earliest_start[u] + duration[u]) 对所有前驱 u 取最大
vector<int> earliest(n, 0);
for (int u : topo_order) {
for (int v : adj[u]) {
earliest[v] = max(earliest[v], earliest[u] + duration[u]);
}
}
// 项目总完成时间 = max(earliest[v] + duration[v]) 对所有 v
int finish_time = 0;
for (int v = 0; v < n; v++)
finish_time = max(finish_time, earliest[v] + duration[v]);
8.2.4 DAG DP 在 USACO Gold 中的题型模式
模式 1:将状态转移视为 DAG
很多 DP 问题可以可视化为 DAG:
- 顶点 = DP 状态
- 边 = 状态之间的转移
- DAG 性质 = 转移只向"前方"进行(无环)
认识到这一点,可以将 DP 递推式转化为显式图问题。
模式 2:排序/调度
"N 个任务有先后依赖关系(任务 A 必须在任务 B 之前完成)。求调度顺序 / 最少执行阶段数 / 关键路径。"
直接应用拓扑排序 + DAG DP。
模式 3:带约束的路径计数
"在给定约束(选择 B 不能跟在选择 A 之后)下,有多少种合法的选择序列?"
将选择建模为顶点,约束建模为有向边(A → B 表示"A 后面接 B 不合法"),然后在结果 DAG 中计路径数。
模式 4:DAG 中的最短/最长路径
DAG 的最短路可以用拓扑排序 + DP 在 O(V+E) 内解决——比 Dijkstra 的 O(E log V) 更快。若图无负边且同时是 DAG,优先选这种方法。
// DAG 中从源点 s 出发的最短路(支持负权!)
vector<int> dist(n, INT_MAX);
dist[s] = 0;
for (int u : topo_order) {
if (dist[u] == INT_MAX) continue;
for (auto [v, w] : adj[u]) {
dist[v] = min(dist[v], dist[u] + w);
}
}
8.2.5 强连通分量(SCC)
有向图的**强连通分量(SCC)**是顶点的极大子集,子集内任意两个顶点互相可达。
示例:
0 → 1 → 2
↑ ↓ ↓
└── 3 4
SCC:{0, 1, 3}、{2}、{4}
- 0→1→3→0:互相可达 → 同一个 SCC
- 2 可从 SCC{0,1,3} 到达,但无法返回 → 独立的 SCC
- 4 同理孤立
SCC 在 USACO Gold 中的意义:
- 缩点 DAG: 将每个 SCC 收缩为单个节点后,结果必然是 DAG。这使得可以在有环图上做 DAG DP。
- 2-SAT: 每子句含 2 个字面量的布尔可满足性问题可以规约为 SCC。
- 可达性查询: "u 能到达 v 吗?" → 判断它们是否在同一 SCC,或 SCC(u) 是否在缩点 DAG 中可达 SCC(v)。
Tarjan SCC 算法
核心思想: 一次 DFS,维护一个栈和两个数组:
disc[v]:v 的 DFS 发现时间low[v]:从 v 的子树出发,经过至多一条"后向边"可达的最小发现时间
当 low[v] == disc[v] 时,v 是某个 SCC 的根——弹出栈中直到 v 为止的所有顶点。
📄 当 `low[v] == disc[v]` 时,v 是某个 SCC 的根——弹出栈中直到 v 为止的所有顶点。
#include <bits/stdc++.h>
using namespace std;
const int MAXN = 100001;
vector<int> adj[MAXN];
int disc[MAXN], low[MAXN], timer_val = 0;
bool on_stack[MAXN];
stack<int> stk;
int scc_id[MAXN]; // 每个顶点属于哪个 SCC?
int scc_count = 0;
void dfs(int u) {
disc[u] = low[u] = ++timer_val;
stk.push(u);
on_stack[u] = true;
for (int v : adj[u]) {
if (disc[v] == 0) { // 树边:v 未访问
dfs(v);
low[u] = min(low[u], low[v]);
} else if (on_stack[v]) { // 后向边,属于当前 SCC
low[u] = min(low[u], disc[v]);
}
// 交叉边/前向边(on_stack[v]==false 且 disc[v]!=0):忽略
}
// 若 u 是某个 SCC 的根
if (low[u] == disc[u]) {
scc_count++;
while (true) {
int v = stk.top(); stk.pop();
on_stack[v] = false;
scc_id[v] = scc_count;
if (v == u) break;
}
}
}
int main() {
ios_base::sync_with_stdio(false);
cin.tie(NULL);
int n, m;
cin >> n >> m;
for (int i = 0; i < m; i++) {
int u, v; cin >> u >> v; u--; v--;
adj[u].push_back(v);
}
for (int i = 0; i < n; i++)
if (disc[i] == 0)
dfs(i);
cout << "SCC 数量:" << scc_count << "\n";
for (int i = 0; i < n; i++)
cout << "顶点 " << i << " → SCC " << scc_id[i] << "\n";
return 0;
}
复杂度: O(V + E)——一次 DFS。
💡 SCC 编号说明: Tarjan 算法以缩点 DAG拓扑序的逆序给 SCC 编号。编号为 1 的 SCC 是 DAG 的汇点;编号最大的 SCC 是源点。
Kosaraju SCC 算法
核心思想: 两次 DFS。
- 第一遍: 在原图上运行 DFS,按完成时间将顶点压入栈。
- 第二遍: 在转置图(所有边反向)上,按完成时间的逆序(弹栈顺序)处理顶点。第二遍中每棵 DFS 树恰好是一个 SCC。
📄 C++ 完整代码
#include <bits/stdc++.h>
using namespace std;
const int MAXN = 100001;
vector<int> adj[MAXN]; // 原图
vector<int> radj[MAXN]; // 转置图(边反向)
bool visited[MAXN];
int scc_id[MAXN];
stack<int> finish_order;
// 第一遍:在原图上 DFS,记录完成顺序
void dfs1(int u) {
visited[u] = true;
for (int v : adj[u])
if (!visited[v])
dfs1(v);
finish_order.push(u); // 完全处理后压栈
}
// 第二遍:在转置图上 DFS,标记 SCC
void dfs2(int u, int id) {
visited[u] = true;
scc_id[u] = id;
for (int v : radj[u])
if (!visited[v])
dfs2(v, id);
}
int main() {
ios_base::sync_with_stdio(false);
cin.tie(NULL);
int n, m;
cin >> n >> m;
for (int i = 0; i < m; i++) {
int u, v; cin >> u >> v; u--; v--;
adj[u].push_back(v);
radj[v].push_back(u); // 反向边
}
// 第一遍:填充 finish_order
fill(visited, visited + n, false);
for (int i = 0; i < n; i++)
if (!visited[i])
dfs1(i);
// 第二遍:在转置图上按逆完成顺序处理
fill(visited, visited + n, false);
int scc_count = 0;
while (!finish_order.empty()) {
int u = finish_order.top(); finish_order.pop();
if (!visited[u])
dfs2(u, ++scc_count);
}
cout << "SCC 数量:" << scc_count << "\n";
return 0;
}
复杂度: O(V + E)——两次 DFS。
Tarjan vs Kosaraju 对比
| Tarjan | Kosaraju | |
|---|---|---|
| DFS 次数 | 1 次 | 2 次 |
| 空间 | 栈 + 数组 | 原图 + 转置图 |
| SCC 编号顺序 | 拓扑逆序 | 拓扑正序 |
| 竞赛推荐 | 更简洁,竞赛首选 | 更易理解和调试 |
💡 USACO 中: Tarjan 更简洁,是竞赛编程的首选。Kosaraju 更易于理解和调试。
缩点 DAG + DP
找到 SCC 后,可以构建缩点 DAG,并在其上运行 DP。
📄 找到 SCC 后,可以构建缩点 DAG,并在其上运行 DP。
// 用 Tarjan 算法找 SCC 后,构建缩点 DAG
// scc_id[v] = 顶点 v 所在的 SCC(1-indexed,拓扑逆序)
// scc_count = SCC 总数
vector<int> scc_adj[MAXN]; // 缩点 DAG 中的边
set<pair<int,int>> seen; // 避免重复边
for (int u = 0; u < n; u++) {
for (int v : adj[u]) {
if (scc_id[u] != scc_id[v]) {
// 不同 SCC 之间的边
auto e = make_pair(scc_id[u], scc_id[v]);
if (!seen.count(e)) {
seen.insert(e);
scc_adj[scc_id[u]].push_back(scc_id[v]);
}
}
}
}
// 现在 scc_adj 是 DAG——对其做 DAG DP
// 示例:缩点 DAG 按 SCC 大小加权的最长路径
vector<int> scc_size(scc_count + 1, 0);
for (int i = 0; i < n; i++) scc_size[scc_id[i]]++;
// DAG DP:dp[v] = 以 SCC v 为结尾的路径上的最大顶点数
vector<int> dp(scc_count + 1, 0);
// 按拓扑序处理(Tarjan 给出拓扑逆序,因此从 1 到 scc_count 遍历)
for (int u = 1; u <= scc_count; u++) {
dp[u] += scc_size[u];
for (int v : scc_adj[u]) {
dp[v] = max(dp[v], dp[u]);
}
}
int ans = *max_element(dp.begin() + 1, dp.end());
8.2.6 差分约束(补充内容)
差分约束系统是一组形如以下的不等式:
x_j - x_i ≤ w_{ij}
当 USACO 题目要求"给变量赋值,使所有成对差值约束满足,并找到最小/最大赋值"时,就会出现这类问题。
核心思路: 差分约束系统等价于最短路问题!
将每个约束 x_j - x_i ≤ w 转化为有向边 i → j,权重为 w。
则:
- 若约束图无负环 → 存在可行解
- 最紧的可行解由从虚源出发的最短路给出
📄 C++ 完整代码
// 求解差分约束系统
// 约束:x[b] - x[a] <= w → 添加边 a→b,权重 w
// 返回最小合法赋值(x[i] = 从虚源到 i 的最短距离)
// 若不可行(含负环)则返回空 vector
vector<long long> solve_difference_constraints(
int n, // n 个变量 x[0..n-1]
vector<tuple<int,int,int>>& constraints // {a, b, w}: x[b]-x[a]<=w
) {
// 添加虚源 s = n,添加边 s→i 权重 0(对所有 i)
// (使 x[i] >= 0 并提供公共参考点)
int s = n;
vector<tuple<int,int,int>> edges = constraints;
for (int i = 0; i < n; i++)
edges.push_back({s, i, 0}); // x[i] - x[s] <= 0 → x[i] <= 0
// 从源点 s 运行 Bellman-Ford
vector<long long> dist(n + 1, 0); // 源点距离 = 0
for (int iter = 0; iter < n; iter++) {
for (auto [u, v, w] : edges) {
if (dist[u] + w < dist[v])
dist[v] = dist[u] + w;
}
}
// 检测负环
for (auto [u, v, w] : edges) {
if (dist[u] + w < dist[v])
return {}; // 含负环 → 无可行解
}
return vector<long long>(dist.begin(), dist.begin() + n);
}
USACO 题型: "给时间/位置赋值,使 A 在 B 之后至少 D 时间发生"的问题可以直接对应差分约束。
8.2.7 2-SAT(二元可满足性问题)
2-SAT 是 Tarjan SCC 最重要的应用之一。它解决这样的问题:给 N 个布尔变量赋值(真/假),使得一组每条包含 2 个字面量的子句的合取为真。
问题形式
有 N 个布尔变量 x₁, x₂, ..., xₙ(每个可以是真或假)。给定 M 个子句,每条形如:
(xᵢ = aᵢ) OR (xⱼ = aⱼ)
其中 aᵢ, aⱼ ∈ {true, false}。
目标: 找到满足所有子句的赋值,或报告无解。
💡 USACO 伪装: "对每组选择 A 或 B。若从第 i 组选了 A,则必须从第 j 组选 B。"这就是 2-SAT!
构建蕴含图
核心转换: OR 子句 (p OR q) 等价于两条蕴含:
¬p → q (若 p 为假,则 q 必为真)
¬q → p (若 q 为假,则 p 必为真)
对每个变量 xᵢ,创建两个节点:2i(xᵢ = true)和 2i+1(xᵢ = false,即 ¬xᵢ)。
变量 xᵢ → 节点 2i (xᵢ 为 TRUE)
节点 2i+1 (xᵢ 为 FALSE,¬xᵢ)
对子句 (xᵢ = a) OR (xⱼ = b):
- 设 p =
xᵢ = a对应的节点,¬p =xᵢ = ¬a对应的节点 - 设 q =
xⱼ = b对应的节点,¬q =xⱼ = ¬b对应的节点 - 添加边:¬p → q 以及 ¬q → p
2-SAT 实现
📄 查看代码:2-SAT 实现
#include <bits/stdc++.h>
using namespace std;
struct TwoSat {
int n;
vector<vector<int>> adj, radj;
vector<int> order, comp;
vector<bool> visited;
TwoSat(int n) : n(n), adj(2*n), radj(2*n), comp(2*n), visited(2*n) {}
// 添加子句:(变量 u 取值 val_u) OR (变量 v 取值 val_v)
// val = true → 使用节点 2*var
// val = false → 使用节点 2*var+1
void add_clause(int u, bool val_u, int v, bool val_v) {
// ¬(u=val_u) → (v=val_v)
adj[2*u + !val_u].push_back(2*v + val_v);
radj[2*v + val_v].push_back(2*u + !val_u);
// ¬(v=val_v) → (u=val_u)
adj[2*v + !val_v].push_back(2*u + val_u);
radj[2*u + val_u].push_back(2*v + !val_v);
}
// 强制变量 u 取值 val(添加单元子句:u=val 被强制)
// 等价于:add_clause(u, val, u, val)
// 即:要么 u=val 要么 u=val → u 必须为 val
void force(int u, bool val) {
// ¬val → val(即若 ¬val 则 val,强制 val 为真)
adj[2*u + !val].push_back(2*u + val);
radj[2*u + val].push_back(2*u + !val);
}
void dfs1(int v) {
visited[v] = true;
for (int u : adj[v])
if (!visited[u]) dfs1(u);
order.push_back(v);
}
void dfs2(int v, int c) {
comp[v] = c;
for (int u : radj[v])
if (comp[u] == -1) dfs2(u, c);
}
// 若可满足返回 true,并将解填入 result[]
bool solve(vector<bool>& result) {
// 在蕴含图上运行 Kosaraju SCC
fill(visited.begin(), visited.end(), false);
for (int v = 0; v < 2*n; v++)
if (!visited[v]) dfs1(v);
fill(comp.begin(), comp.end(), -1);
int c = 0;
for (int i = (int)order.size()-1; i >= 0; i--) {
if (comp[order[i]] == -1)
dfs2(order[i], c++);
}
result.resize(n);
for (int i = 0; i < n; i++) {
// 若 xᵢ 和 ¬xᵢ 在同一个 SCC → 矛盾 → 无解
if (comp[2*i] == comp[2*i+1]) return false;
// 选择:若 SCC(xᵢ) 的编号大于 SCC(¬xᵢ),则 xᵢ = true
// (Kosaraju 给拓扑序较后的 SCC 分配更大的编号)
result[i] = comp[2*i] > comp[2*i+1];
}
return true;
}
};
int main() {
ios_base::sync_with_stdio(false);
cin.tie(NULL);
int n, m;
cin >> n >> m; // n 个变量,m 个子句
TwoSat sat(n);
for (int i = 0; i < m; i++) {
// 读取子句:(x[u] = a) OR (x[v] = b)
// 其中 u,v 为 0-indexed,a,b 为 0 或 1
int u, a, v, b;
cin >> u >> a >> v >> b;
sat.add_clause(u, a, v, b);
}
vector<bool> result;
if (sat.solve(result)) {
for (int i = 0; i < n; i++)
cout << "x[" << i << "] = " << result[i] << "\n";
} else {
cout << "不可满足\n";
}
return 0;
}
复杂度: O(N + M)——在蕴含图上运行 Kosaraju SCC。
为什么这个方法有效
核心洞见:若 xᵢ 和 ¬xᵢ 在同一个 SCC 中,则蕴含图中存在从 xᵢ 到 ¬xᵢ 的路径,以及从 ¬xᵢ 到 xᵢ 的路径。这意味着 xᵢ 强迫 ¬xᵢ,¬xᵢ 也强迫 xᵢ——产生矛盾,不存在合法赋值。
若没有变量与其否定落在同一 SCC 中,则始终可以根据拓扑序构造合法赋值。
USACO 2-SAT 题型模式
模式 1:"每组恰好选择 A 或 B"
对第 i 组:变量 xᵢ = true 表示"选 A",false 表示"选 B"
约束"若第 i 组选 A,则第 j 组必须选 B":
add_clause(i, false, j, false) // ¬(i=A) OR ¬(j=A)
模式 2:"A、B、C、D 中至多有一个为真"(n 变量的至多一个约束)
// 链式编码:若 xᵢ 为真,则 x_{i+1}..x_{n-1} 都为假
// 朴素方法:对每对 (i, j)(i < j)add_clause(i, false, j, false),O(n²) 条子句
// 链式技巧 O(n):引入辅助变量 yᵢ = "x₀..xᵢ 中至少有一个为真"
模式 3:"将 N 个元素分到 L 侧或 R 侧,有约束"
xᵢ = true → 元素 i 在左侧
xᵢ = false → 元素 i 在右侧
约束"i 和 j 不能同时在左侧":add_clause(i, false, j, false)
约束"若 j 在右侧则 i 必须在左侧":add_clause(i, true, j, true)
💡 思路陷阱
陷阱 1:把含环的有向图当 DAG 处理
错误判断: "这题有拓扑顺序,用 toposort + DP 就行了"
实际情况: 图中可能有环(SCC),直接 toposort 会漏掉部分节点
反例:有向图 A→B→C→A→D
错误:toposort 输出 [D](只有 3 个节点在环外)
正确:先用 Tarjan 找 SCC,把 {A,B,C} 缩成一个超级节点,再在缩点 DAG 上做 DP
识别信号: 题目说"有向图"但没说"无环" → 先检测环或求 SCC,再决定是否能用 toposort
陷阱 2:2-SAT 误认为是普通贪心
错误判断: "每个位置选 A 或 B,有约束,贪心从左到右扫一遍"
实际情况: 约束之间有传递性,贪心无法保证全局一致
反例:5 个组,约束如下(若选 A₁ 则必须选 B₂,若选 A₂ 则必须选 B₃...)
贪心:组 1 选 A₁ → 组 2 选 B₂ → 组 3 选 A₃ → 组 4 选 B₄ → 组 5 可能无解
2-SAT:建立完整蕴含图,SCC 分析一次性得出全局一致解
识别信号: 每个位置/元素有两种选择 + 成对约束("如果...则...")→ 考虑 2-SAT
⚠️ 常见错误
-
混淆有向图与无向图的环: 拓扑排序只适用于有向图。无向图中,任意连通分量都有生成树——不需要"环检测"。
-
DP 初始化差一: 对"从源点出发的路径计数",初始化
cnt[source] = 1,而非 0。对"最长路径",若计边数则初始化dp[v] = 0;若路径可能不存在,需正确处理 -∞。 -
忽略不可达顶点: DAG 最短路中若
dist[u] == INT_MAX,跳过该顶点——从不可达顶点出发会得到错误值。 -
大图 DFS 拓扑排序栈溢出: N = 10⁵ 且有深链时,递归 DFS 可能栈溢出。对大输入优先使用 Kahn 算法(BFS)。
-
在有环图上使用拓扑排序: Kahn 会静默返回部分排序。务必检查
order.size() == n。
📋 章节小结
📌 核心要点
| 概念 | 说明 |
|---|---|
| DAG | 无环的有向图;建模依赖/排序关系 |
| 拓扑排序 | 所有边从左指向右的线性序;O(V+E) |
| Kahn 算法 | BFS 基于入度;自然检测环 |
| DFS 拓扑排序 | DFS 完成后加入结果;最后反转 |
| 环检测 | Kahn:输出数 < N 有环;DFS:灰→灰边有环 |
| DAG DP | 按拓扑序处理;dp[v] 仅依赖 dp[前驱] |
| 最长路径 | dp[v] = max(dp[u] + 权重),对所有前驱 u |
| 路径计数 | cnt[v] = sum(cnt[u]),对所有前驱 u |
| SCC(Tarjan) | 一次 DFS;disc[]/low[]/栈;O(V+E);拓扑逆序 |
| SCC(Kosaraju) | G 和 Gᵀ 各一次 DFS;O(V+E);拓扑正序 |
| 缩点 DAG | 每个 SCC 收缩为一个节点;结果必然是 DAG |
| 2-SAT | N 个布尔变量 + 2 字面量子句;建蕴含图 → SCC;O(N+M) |
| 差分约束 | x[j]-x[i]≤w → 边 i→j;可行 = 无负环(Bellman-Ford) |
❓ 常见问题
Q:每棵树都是 DAG 吗?
A:有根树(边从父节点指向子节点)是 DAG。无根树没有方向,不适用此问题。若将树根化,则是 DAG。
Q:拓扑排序可以有多个合法序吗?
A:可以。若两个顶点之间没有依赖关系,顺序可互换。只有当 DAG 是一条简单路径(链)时,拓扑序才唯一。
Q:Dijkstra 与 DAG 最短路——分别在什么时候用?
A:若图是 DAG,用拓扑排序 + DP:O(V+E),支持负权,更简单。若图有环但无负边,用 Dijkstra:O(E log V)。若有环且有负边,用 Bellman-Ford:O(VE)。
Q:如何求"完成所有任务的最少轮次/阶段数"?
A:这就是 DAG 中的"最长路径"(关键路径)。最少阶段数 = 1 + 最长路径的长度。
🔗 与后续章节的联系
- 第 8.3 章(树形 DP): 树形 DP 是在特殊 DAG(有根树)上的 DP,本章技术直接适用。
- 第 6.3 章(进阶 DP): 状压 DP 的状态通常构成 DAG(转移只从子集到超集)。
- 第 5.2 章(BFS/DFS): 本章的两种算法都是对第 5.2 章 BFS/DFS 的扩展。
🏋️ 练习题
🟢 简单
8.2-E1. 课程表 (等价于 LeetCode 207)
N 门课程,M 个先修要求。给定先修对 (a, b),表示"必须先修 b 才能修 a",判断能否完成所有课程(即是否无环)。
提示
运行 Kahn 算法。若输出包含所有 N 门课程,则无环 → 可以完成。否则有环 → 无法完成。
8.2-E2. DAG 中的最长路径
给定 N 个顶点、M 条有权有向边和源点 S,求从 S 出发的最长路径。
提示
拓扑排序,然后按拓扑序处理顶点。初始化 dp[S] = 0,dp[其他] = -∞。对每条边 u→v:dp[v] = max(dp[v], dp[u] + w)。
🟡 中等
8.2-M1. 网格路径计数 (网格 DP 作为 DAG)
在 N×M 的网格中,每步只能向右或向下走。某些格子被封锁。统计从 (1,1) 到 (N,M) 的路径总数。
提示
网格是 DAG(移动只向右/向下)。按行主序处理格子(已是拓扑序)。cnt[i][j] = cnt[i-1][j] + cnt[i][j-1],若 (i,j) 未被封锁。
8.2-M2. 带依赖的任务调度 (USACO 风格)
N 个任务各有执行时长,M 条依赖边。每个任务只有在其所有前置任务完成后才能开始。所有可并行的任务同时进行。求完成所有任务的最短总时间。
提示
这是关键路径法(CPM)。运行 Kahn 拓扑排序。对拓扑序中的每个顶点,计算 earliest_start[v] = max(earliest_start[u] + duration[u]) 对所有前驱 u 取最大。答案 = max(earliest_start[v] + duration[v])。
🔴 困难
8.2-H1. 路径计数取模 (USACO Gold 2012 — Cow Rectangles)
给定有 N 个顶点(至多 10⁵)和 M 条边的 DAG,源点 S 到目标 T。统计 S 到 T 的路径总数对 10⁹+7 取模的结果。某些顶点被"标记";只统计经过至少一个标记顶点的路径。
提示
用容斥原理:(经过 ≥1 个标记顶点的路径数)=(所有路径数)-(不经过任何标记顶点的路径数)。对"不经过任何标记顶点"的情形,将标记顶点从图中移除后重新计数。
🏆 挑战
8.2-C1. 缩点 DAG + DP (困难)
给定可能含环的有向图。在缩点 DAG(每个 SCC 收缩为单个顶点)中,求路径上经过的最大顶点数。
提示
运行 Tarjan 或 Kosaraju 求 SCC 及其大小。构建缩点 DAG。在缩点 DAG 上做最长路径 DP,每个顶点的"权值"为该 SCC 的大小。答案为 max dp[v]。