📖 第 8.2 章 ⏱️ 约 80 分钟 🎯 Gold

第 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。

  1. 第一遍: 在原图上运行 DFS,按完成时间将顶点压入栈。
  2. 第二遍:转置图(所有边反向)上,按完成时间的逆序(弹栈顺序)处理顶点。第二遍中每棵 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 对比

TarjanKosaraju
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


⚠️ 常见错误

  1. 混淆有向图与无向图的环: 拓扑排序只适用于有向图。无向图中,任意连通分量都有生成树——不需要"环检测"。

  2. DP 初始化差一: 对"从源点出发的路径计数",初始化 cnt[source] = 1,而非 0。对"最长路径",若计边数则初始化 dp[v] = 0;若路径可能不存在,需正确处理 -∞。

  3. 忽略不可达顶点: DAG 最短路中若 dist[u] == INT_MAX,跳过该顶点——从不可达顶点出发会得到错误值。

  4. 大图 DFS 拓扑排序栈溢出: N = 10⁵ 且有深链时,递归 DFS 可能栈溢出。对大输入优先使用 Kahn 算法(BFS)。

  5. 在有环图上使用拓扑排序: 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-SATN 个布尔变量 + 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] = 0dp[其他] = -∞。对每条边 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]