NeFut Logo NeFut
EN 管理员登录

深度解析拓扑排序与关键路径算法

发布于:2026-05-27 09:17 最后更新:2026-06-06 13:04
#algorithm #C++ #Graph

核心逻辑与数学原理

拓扑排序在解决关键路径(Critical Path Method, CPM)问题和工程规划中扮演着决定性的代数角色。在带有边权的有向无环图(DAG)中,边权通常代表完成某项子任务所需的时间,而图中的顶点代表工程进行到某一阶段的“事件”。

为了精确锁定哪些任务是“关键任务”(即没有任何延时容忍度、一旦延误将直接导致整个工期延后的任务),我们需要在拓扑空间上双向驱动以下四个核心代数状态:

1. 事件最早发生时间 $ve[i]$(Forward Pass)

$$ve[i] = ext{max}_{(u, i) ext{ in } E} ig{ ve[u] + w(u, i) ig} $$

整个工程的最短总工期,严格等于汇点(终点)的 $ve$ 值。

2. 事件最迟发生时间 $vl[i]$(Backward Pass)

$$vl[i] = ext{min}_{(i, v) ext{ in } E} ig{ vl[v] - w(i, v) ig\ $$

3. 活动(边)的时间余量状态剥离

当顶点的状态被正反向拓扑双向熟化后,我们即可将视角拉平到具体的活动(边 $e = (u, v)$,权值为 $w$)上:

$$\Delta e = el[e] - ee[e] = vl[v] - ve[u] - w$$

4. 关键路径的代数判据

若一条边(活动)满足 $ \Delta e == 0$**(即 $vl[v] - ve[u] - w == 0$),则称该活动为关键活动。关键活动没有任何偷懒的余量。由关键活动串联而成的从源点到汇点的完整路径,即为关键路径**。一条工程 DAG 中可能同时存在多条并列的关键路径。


算法推导与状态设计

1. 双重拓扑栈控制

  1. 先跑一遍标准 Kahn 拓扑排序算法。在节点出队时,将其压入一个辅助栈 reverse_order 中。
  2. 当拓扑排序完毕,reverse_order 栈顶到栈底的顺序天然构成该图的逆拓扑序
  3. 利用正向拓扑序递推求出全量的 ve[i]
  4. 弹出 reverse_order 栈,利用逆拓扑序递推求出全量的 vl[i]

C++ 标准源码(关键路径核心提取模板)

#include <iostream>
#include <vector>
#include <queue>
#include <stack>
#include <algorithm>

const int MAXN = 100005;
const int INF = 0x3f3f3f3f;

struct Edge {
    int from, to, weight;
};

std::vector<Edge> adj[MAXN];
int in_degree[MAXN];
int ve[MAXN]; // 事件最早发生时间
int vl[MAXN]; // 事件最迟发生时间

std::vector<int> topo_order;   // 正拓扑序
std::stack<int> reverse_order; // 逆拓扑序

bool find_critical_path(int n) {
    std::queue<int> q;
    for (int i = 1; i <= n; ++i) {
        if (in_degree[i] == 0) {
            q.push(i);
        }
        ve[i] = 0; // 初始时间基座置 0
    }

    // 1. 正向拓扑驱动:松弛 ve 状态
    while (!q.empty()) {
        int u = q.front();
        q.pop();
        topo_order.push_back(u);
        reverse_order.push(u); // 压入逆序栈

        for (size_t i = 0; i < adj[u].size(); ++i) {
            int v = adj[u][i].to;
            int w = adj[u][i].weight;

            // 状态转移:取前置依赖的最长用时
            if (ve[u] + w > ve[v]) {
                ve[v] = ve[u] + w;
            }

            if (--in_degree[v] == 0) {
                q.push(v);
            }
        }
    }

    // 环路安全性校验
    if (topo_order.size() != static_cast<size_t>(n)) return false;

    // 寻找全局总工期(最大早发生时间点)
    int total_project_time = 0;
    for (int i = 1; i <= n; ++i) {
        total_project_time = std::max(total_project_time, ve[i]);
    }

    // 2. 初始化逆向边界:所有汇点的最迟发生时间赋为总工期
    for (int i = 1; i <= n; ++i) {
        vl[i] = total_project_time; 
    }

    // 3. 逆向拓扑驱动:回溯 vl 状态
    while (!reverse_order.empty()) {
        int u = reverse_order.top();
        reverse_order.pop();

        for (size_t i = 0; i < adj[u].size(); ++i) {
            int v = adj[u][i].to;
            int w = adj[u][i].weight;

            // 逆向状态转移:后续限制反向逼近
            if (vl[v] - w < vl[u]) {
                vl[u] = vl[v] - w;
            }
        }
    }

    return true;
}

int main() {
    std::ios_base::sync_with_stdio(false);
    std::cin.tie(NULL);

    int n, m;
    if (!(std::cin >> n >> m)) return 0;

    std::vector<Edge> all_edges;
    for (int i = 0; i < m; ++i) {
        int u, v, w;
        std::cin >> u >> v >> w;
        adj[u].push_back(Edge{u, v, w});
        all_edges.push_back(Edge{u, v, w});
        in_degree[v]++;
    }

    if (!find_critical_path(n)) {
        std::cout << "Graph contains cycles, no critical path exists.\n";
        return 0;
    }

    std::cout << "Total Project Duration: " << ve[topo_order.back()] << "\n";
    std::cout << "Critical Activities (Edges):\n";

    // 4. 遍历全量活动边,通过代数判据提取关键活动
    for (size_t i = 0; i < all_edges.size(); ++i) {
        int u = all_edges[i].from;
        int v = all_edges[i].to;
        int w = all_edges[i].weight;

        // 核心代数判据:vl[v] - ve[u] - w == 0
        if (vl[v] - ve[u] - w == 0) {
            std::cout << u << " -> " << v << " (weight: " << w << ")\n";
        }
    }

    return 0;
}

NOIP 实战避坑指南

  1. 多源点或多汇点图的 vl 初始边界污染: 如果工程图存在多个没有出边的汇点,有些选手会贪图省事直接写一句 vl[n] = ve[n]。这在单汇点图里是对的。但如果是多汇点图,只有部分的 vl 被正确约束,其他汇点的 vl 将保持初始的 0 或错误的大整数值。逆向拓扑回溯时,这些错误的边界值会瞬间污染整张图的 vl 状态,导致计算出的关键路径完全错乱。
  1. 误认为“$ve[i] == vl[i]$ 的节点所连接的边就是关键活动”: 这是一个极为经典的理论高发错误点。在某些特殊拓扑结构中,可能存在两个点 $u$ 和 $v$,它们都满足 $ve[u] == vl[u]$ 且 $ve[v] == vl[v]$,但是连接它们的边权 $w$ 较小,导致 $vl[v] - ve[u] - w > 0$。这意味着这条边本身具有时差机动权,它根本不是关键活动。铁律:判定关键路径必须严格对边(活动)执行 $vl[v] - ve[u] - w == 0$ 的代数判定,绝不能仅对端点做等值判定。

经典 NOIP/洛谷 真题

1. 洛谷 P1113 杂务

2. 洛谷 P1821 [USACO07MAR] 银牛派对 Cow Party

  1. 正向驱动:直接在原图上以 $X$ 为源点跑一次 Dijkstra 算法,求出的 $dist[i]$ 即为每头牛从派对返回各自农场的最短路径。
  2. 反向驱动:将原图所有的有向边全部方向翻转(建立反向图),再次以 $X$ 为源点跑一次 Dijkstra 算法。由于边权倒置,此时求出的 $dist'[i]$ 本质上就变成了各城市在原图上前往 $X$ 的最短路径。两部分时间相加 $dist[i] + dist'[i]$ 即可剥离出每头牛的完全生命周期代价。这种“正向推算、反向约束”的对称性构成了复杂图论状态设计的核心底座。
原文链接: local://12.3

[h] 返回首页