一、为什么 DAG 能做得更好?

前面的 Dijkstra 算法有两个限制:

  • 需要优先队列,时间复杂度 O(Elog⁡V)O(E \log V)O(ElogV)
  • 边权不能为负
    对于有向无环图(DAG,Directed Acyclic Graph),有一种更快的方法:

按拓扑顺序逐一松弛顶点,只需 O(E+V)O(E + V)O(E+V) 的线性时间,而且允许负权边。
优势总结:


特性DijkstraDAG 拓扑排序法
时间复杂度O(Elog⁡V)O(E \log V)O(ElogV)O(E+V)O(E + V)O(E+V)
允许负权边否是
要求无环否是(DAG 专用)
能求最长路径否是(取反即可)

二、核心思想:拓扑顺序松弛

拓扑顺序的关键性质:

若边 v→wv \to wv→w 存在,则 vvv 在拓扑序中一定排在 www 前面。
这意味着:当我们处理顶点 vvv 时,所有"能影响 distTo[v]distTo[v]distTo[v]"的顶点都已经处理完了。
因此松弛顶点 vvv 之后,distTo[v]distTo[v]distTo[v] 就再也不会被更新了——无需优先队列,从前往后线性扫一遍即可。

拓扑顺序保证:
  处理顶点 v 时:
    所有指向 v 的边 u->v,u 均已被处理
    => distTo[v] 此时已是最终值,永不再变
    => 可以安全地松弛 v 的所有出边

三、命题 S:DAG 最短路径线性时间

命题 S: 对带权 DAG,按拓扑顺序松弛顶点,可在 O(E+V)O(E + V)O(E+V) 时间内求解单源最短路径。
证明:

  • 每条边 v→wv \to wv→w 恰好被松弛一次(当 vvv 被处理时)
  • 松弛后:distTo[w]≤distTo[v]+weight(v→w)distTo[w] \leq distTo[v] + weight(v \to w)distTo[w]≤distTo[v]+weight(v→w)
  • 由于是拓扑顺序,vvv 处理后不会再有边指向 vvv,所以 distTo[v]distTo[v]distTo[v] 不会再变小
  • distTo[w]distTo[w]distTo[w] 只能减小(松弛只减不增)
  • 因此该不等式在算法结束前始终成立
  • 算法结束时最优性条件(命题 P)满足,distTo[]distTo[]distTo[] 即为最短路径
    时间分析:
    T=O(E+V)⏟拓扑排序(DFS)+O(E+V)⏟按拓扑序松弛每条边一次=O(E+V)T = \underbrace{O(E + V)}_{\text{拓扑排序(DFS)}} + \underbrace{O(E + V)}_{\text{按拓扑序松弛每条边一次}} = O(E + V)T=拓扑排序(DFS)O(E+V)​​+按拓扑序松弛每条边一次O(E+V)​​=O(E+V)

四、拓扑排序回顾(DFS 后序逆置)

拓扑排序基于 DFS:

DFS(v):
  标记 v 为已访问
  对 v 的每条出边 v->w:
      若 w 未访问,递归 DFS(w)
  将 v 压入栈
DFS 全部完成后,弹出栈即为拓扑顺序。

0

1

2

3

4

5

6

7

对 tinyEWDAG 从顶点 5 出发,DFS 得到拓扑顺序:
5→1→3→6→4→7→0→25 \to 1 \to 3 \to 6 \to 4 \to 7 \to 0 \to 25→1→3→6→4→7→0→2

五、算法执行追踪(tinyEWDAG,源点 5)

图的边(tinyEWDAG):

5->4(0.35)  5->1(0.32)  5->7(0.28)
4->0(0.38)  4->7(0.34)
1->3(0.29)
3->6(0.52)  3->7(0.37)
6->4(0.93)  6->0(0.58)  6->2(0.40)
7->2(0.34)
0->2(0.26)

按拓扑顺序 5→1→3→6→4→7→0→2 依次松弛:

初始:
  distTo = [INF, INF, INF, INF, INF, 0.00, INF, INF]
                                       ^5
处理 5(distTo=0.00),松弛出边:
  5->4: distTo[4] = 0.00+0.35 = 0.35  edgeTo[4]=5->4
  5->1: distTo[1] = 0.00+0.32 = 0.32  edgeTo[1]=5->1
  5->7: distTo[7] = 0.00+0.28 = 0.28  edgeTo[7]=5->7
  distTo = [INF, 0.32, INF, INF, 0.35, 0.00, INF, 0.28]
处理 1(distTo=0.32),松弛出边:
  1->3: distTo[3] = 0.32+0.29 = 0.61  edgeTo[3]=1->3
  distTo = [INF, 0.32, INF, 0.61, 0.35, 0.00, INF, 0.28]
处理 3(distTo=0.61),松弛出边:
  3->6: distTo[6] = 0.61+0.52 = 1.13  edgeTo[6]=3->6
  3->7: 0.61+0.37=0.98 > 0.28,不更新(不可用)
  distTo = [INF, 0.32, INF, 0.61, 0.35, 0.00, 1.13, 0.28]
处理 6(distTo=1.13),松弛出边:
  6->4: 1.13+0.93=2.06 > 0.35,不更新
  6->0: distTo[0] = 1.13+0.58 = 1.71  edgeTo[0]=6->0
  6->2: distTo[2] = 1.13+0.40 = 1.53  edgeTo[2]=6->2
  distTo = [1.71, 0.32, 1.53, 0.61, 0.35, 0.00, 1.13, 0.28]
处理 4(distTo=0.35),松弛出边:
  4->0: 0.35+0.38=0.73 < 1.71,更新!  edgeTo[0]=4->0
  4->7: 0.35+0.34=0.69 > 0.28,不更新
  distTo = [0.73, 0.32, 1.53, 0.61, 0.35, 0.00, 1.13, 0.28]
处理 7(distTo=0.28),松弛出边:
  7->2: 0.28+0.34=0.62 < 1.53,更新!  edgeTo[2]=7->2
  distTo = [0.73, 0.32, 0.62, 0.61, 0.35, 0.00, 1.13, 0.28]
处理 0(distTo=0.73),松弛出边:
  0->2: 0.73+0.26=0.99 > 0.62,不更新
处理 2(distTo=0.62),无出边。
最终:
  distTo = [0.73, 0.32, 0.62, 0.61, 0.35, 0.00, 1.13, 0.28]

最终最短路径树(SPT,源点 5):

0.32

0.35

0.28

0.29

0.38

0.52

0.34

0
0.73

1
0.32

2
0.62

3
0.61

4
0.35

5
0.00

6
1.13

7
0.28

六、最长路径:取反转化

命题 T: DAG 上的最长路径问题可在 O(E+V)O(E + V)O(E+V) 时间内求解。
转化方法:

将图中所有边的权重取反,然后在新图上求最短路径,最后将结果再取反,即为原图的最长路径。
最长路径(G)=−最短路径(−G)\text{最长路径}(G) = -\text{最短路径}(-G)最长路径(G)=−最短路径(−G)
或者,更简单的实现方式:

  • 初始化 distTo[v]=−∞distTo[v] = -\inftydistTo[v]=−∞(除源点外)
  • 松弛条件改为:若 distTo[v]+weight>distTo[w]distTo[v] + weight > distTo[w]distTo[v]+weight>distTo[w],则更新(取最大值而非最小值)
最短路径 relax:            最长路径 relax:
if (newDist < distTo[w])   if (newDist > distTo[w])
    更新                        更新

最长路径执行追踪(tinyEWDAG,源点 5):

拓扑顺序:5→1→3→6→4→7→0→2(与最短路径相同)
初始:distTo = [-INF,-INF,-INF,-INF,-INF, 0.00,-INF,-INF]
处理 5:distTo[4]=0.35, distTo[1]=0.32, distTo[7]=0.28
处理 1:distTo[3] = 0.32+0.29 = 0.61
处理 3:distTo[6] = 0.61+0.52 = 1.13
        distTo[7] = max(0.28, 0.61+0.37) = max(0.28, 0.98) = 0.98(更新!)
处理 6:distTo[4] = max(0.35, 1.13+0.93) = max(0.35, 2.06) = 2.06(更新!)
        distTo[0] = max(-INF, 1.13+0.58) = 1.71
        distTo[2] = max(-INF, 1.13+0.40) = 1.53
处理 4:distTo[0] = max(1.71, 2.06+0.38) = max(1.71, 2.44) = 2.44(更新!)
        distTo[7] = max(0.98, 2.06+0.34) = max(0.98, 2.40) = 2.40(更新!)
处理 7:distTo[2] = max(1.53, 2.40+0.34) = max(1.53, 2.74) = 2.74(更新!)
处理 0:distTo[2] = max(2.74, 2.44+0.26) = max(2.74, 2.70) = 2.74(不更新)
最终最长距离:
  distTo = [2.44, 0.32, 2.74, 0.61, 2.06, 0.00, 1.13, 2.40]

最终最长路径树(LPT,源点 5):

0.32

0.35

0.29

0.52

0.37

0.93

0.58

0.38

0.34

0.34

0
最终2.44

1
0.32

2
2.74

3
0.61

4
最终2.06

5
0.00

6
1.13

7
最终2.40

4
初始0.35

7
初始0.98

0
初始1.71

与普通图的对比(为何 DAG 上最长路径高效而一般图不行):

一般有向图(含环)上的最长路径 = NP 难问题,已知最优算法需指数时间
DAG(无环)上的最长路径       = 线性时间 O(E+V),转化为最短路径即可
"环的存在"让最长路径问题变得指数级困难。

七、C++ 完整实现

#include <iostream>
#include <vector>
#include <stack>
#include <limits>
#include <iomanip>
#include <string>
#include <sstream>
// ============================================================
// 带权有向边
// ============================================================
class DirectedEdge {
    int    v_, w_;
    double wt_;
public:
    DirectedEdge(int v, int w, double wt) : v_(v), w_(w), wt_(wt) {}
    int    from()   const { return v_;  }
    int    to()     const { return w_;  }
    double weight() const { return wt_; }
    std::string str() const {
        std::ostringstream o;
        o << v_ << "->" << w_ << " "
          << std::fixed << std::setprecision(2) << wt_;
        return o.str();
    }
};
// ============================================================
// 带权有向图(邻接表)
// ============================================================
class EdgeWeightedDigraph {
    int V_, E_;
    std::vector<std::vector<DirectedEdge>> adj_;
public:
    explicit EdgeWeightedDigraph(int V) : V_(V), E_(0), adj_(V) {}
    int V() const { return V_; }
    int E() const { return E_; }
    void addEdge(const DirectedEdge& e) {
        adj_[e.from()].push_back(e);
        ++E_;
    }
    const std::vector<DirectedEdge>& adj(int v) const { return adj_[v]; }
};
// ============================================================
// 拓扑排序(基于 DFS 后序逆置)
// 仅适用于 DAG,若图含环则结果无意义
// ============================================================
class Topological {
    std::vector<bool> visited_;  // 标记顶点是否已访问
    std::vector<int>  order_;    // 拓扑顺序(从前到后)
    // DFS:递归访问 v 的所有后继,最后将 v 压栈
    void dfs(const EdgeWeightedDigraph& G, int v,
             std::stack<int>& stk)
    {
        visited_[v] = true;
        for (const auto& e : G.adj(v)) {
            int w = e.to();
            if (!visited_[w])
                dfs(G, w, stk);
        }
        // 后序:v 的所有后继都处理完后,才把 v 入栈
        stk.push(v);
    }
public:
    explicit Topological(const EdgeWeightedDigraph& G)
        : visited_(G.V(), false)
    {
        std::stack<int> stk;
        // 对所有未访问的顶点启动 DFS(处理不连通情况)
        for (int v = 0; v < G.V(); v++)
            if (!visited_[v])
                dfs(G, v, stk);
        // 弹栈得到拓扑顺序(后序逆置 = 拓扑序)
        while (!stk.empty()) {
            order_.push_back(stk.top());
            stk.pop();
        }
    }
    // 返回拓扑顺序
    const std::vector<int>& order() const { return order_; }
};
// ============================================================
// DAG 最短路径(AcyclicSP)
//
// 算法步骤:
//   1. 对图做拓扑排序,得到顶点处理顺序
//   2. 按拓扑顺序逐一松弛每个顶点的所有出边
//
// 时间复杂度 O(E+V),允许负权边,要求无环
// ============================================================
class AcyclicSP {
    static constexpr double INF = std::numeric_limits<double>::infinity();
    std::vector<double> distTo_;  // distTo_[v]: s 到 v 的最短距离
    std::vector<int>    prevV_;   // prevV_[v]:  最短路径上 v 的父顶点
    std::vector<double> prevWt_;  // prevWt_[v]: 父边权重
    // 松弛顶点 v 的所有出边
    // 若经由 v 能缩短到某后继顶点的距离,则更新
    void relax(const EdgeWeightedDigraph& G, int v) {
        for (const auto& e : G.adj(v)) {
            int    w    = e.to();
            double newD = distTo_[v] + e.weight();
            // 最短路径:取更小值
            if (newD < distTo_[w]) {
                distTo_[w] = newD;
                prevV_[w]  = v;
                prevWt_[w] = e.weight();
            }
        }
    }
public:
    AcyclicSP(const EdgeWeightedDigraph& G, int s)
        : distTo_(G.V(), INF),
          prevV_(G.V(), -1),
          prevWt_(G.V(), 0.0)
    {
        distTo_[s] = 0.0;
        // 第一步:拓扑排序
        Topological topo(G);
        // 第二步:按拓扑顺序松弛
        // 由于拓扑顺序保证"v 的所有前驱都已处理",
        // 松弛 v 时 distTo_[v] 已是最终最短值
        for (int v : topo.order())
            relax(G, v);
    }
    double distTo(int v)  const { return distTo_[v]; }
    bool hasPathTo(int v) const { return distTo_[v] < INF; }
    // 回溯 prevV_[] 构建路径
    std::vector<std::pair<int,int>> pathTo(int v) const {
        if (!hasPathTo(v)) return {};
        std::stack<std::pair<int,int>> stk;
        for (int cur = v; prevV_[cur] != -1; cur = prevV_[cur])
            stk.push({prevV_[cur], cur});
        std::vector<std::pair<int,int>> path;
        while (!stk.empty()) { path.push_back(stk.top()); stk.pop(); }
        return path;
    }
};
// ============================================================
// DAG 最长路径(AcyclicLP)
//
// 与 AcyclicSP 的唯一区别:
//   - distTo_ 初始化为 -INF(而非 +INF)
//   - relax 条件改为 newD > distTo_[w](取更大值)
//
// 等价于对所有边权取反后求最短路径,再将结果取反
// ============================================================
class AcyclicLP {
    static constexpr double NEG_INF = -std::numeric_limits<double>::infinity();
    std::vector<double> distTo_;
    std::vector<int>    prevV_;
    std::vector<double> prevWt_;
    // 松弛(最长路径版):取更大值
    void relax(const EdgeWeightedDigraph& G, int v) {
        for (const auto& e : G.adj(v)) {
            int    w    = e.to();
            double newD = distTo_[v] + e.weight();
            // 最长路径:取更大值(与最短路径的唯一区别)
            if (newD > distTo_[w]) {
                distTo_[w] = newD;
                prevV_[w]  = v;
                prevWt_[w] = e.weight();
            }
        }
    }
public:
    AcyclicLP(const EdgeWeightedDigraph& G, int s)
        : distTo_(G.V(), NEG_INF),
          prevV_(G.V(), -1),
          prevWt_(G.V(), 0.0)
    {
        distTo_[s] = 0.0;
        Topological topo(G);
        for (int v : topo.order())
            relax(G, v);
    }
    double distTo(int v)  const { return distTo_[v]; }
    bool hasPathTo(int v) const {
        return distTo_[v] > -std::numeric_limits<double>::infinity();
    }
    std::vector<std::pair<int,int>> pathTo(int v) const {
        if (!hasPathTo(v)) return {};
        std::stack<std::pair<int,int>> stk;
        for (int cur = v; prevV_[cur] != -1; cur = prevV_[cur])
            stk.push({prevV_[cur], cur});
        std::vector<std::pair<int,int>> path;
        while (!stk.empty()) { path.push_back(stk.top()); stk.pop(); }
        return path;
    }
};
// ============================================================
// 打印路径工具函数
// ============================================================
void printPaths(const std::string& title, int src, int V,
                const AcyclicSP& sp)
{
    std::cout << title << "\n";
    std::cout << std::string(55, '-') << "\n";
    for (int t = 0; t < V; t++) {
        std::cout << src << " -> " << t
                  << std::fixed << std::setprecision(2)
                  << "  (" << sp.distTo(t) << ")  ";
        if (sp.hasPathTo(t))
            for (auto [u, v] : sp.pathTo(t))
                std::cout << u << "->" << v << "  ";
        else
            std::cout << "不可达";
        std::cout << "\n";
    }
    std::cout << "\n";
}
void printPathsLP(const std::string& title, int src, int V,
                  const AcyclicLP& lp)
{
    std::cout << title << "\n";
    std::cout << std::string(55, '-') << "\n";
    for (int t = 0; t < V; t++) {
        std::cout << src << " -> " << t
                  << std::fixed << std::setprecision(2)
                  << "  (" << lp.distTo(t) << ")  ";
        if (lp.hasPathTo(t))
            for (auto [u, v] : lp.pathTo(t))
                std::cout << u << "->" << v << "  ";
        else
            std::cout << "不可达";
        std::cout << "\n";
    }
    std::cout << "\n";
}
// ============================================================
// 主程序:tinyEWDAG,源点 5
// ============================================================
int main() {
    // 构造 tinyEWDAG(8 顶点)
    EdgeWeightedDigraph G(8);
    G.addEdge({5, 4, 0.35}); G.addEdge({5, 1, 0.32}); G.addEdge({5, 7, 0.28});
    G.addEdge({4, 0, 0.38}); G.addEdge({4, 7, 0.34});
    G.addEdge({1, 3, 0.29});
    G.addEdge({3, 6, 0.52}); G.addEdge({3, 7, 0.37});
    G.addEdge({6, 4, 0.93}); G.addEdge({6, 0, 0.58}); G.addEdge({6, 2, 0.40});
    G.addEdge({7, 2, 0.34});
    G.addEdge({0, 2, 0.26});
    int s = 5;
    // 打印拓扑顺序
    Topological topo(G);
    std::cout << "拓扑顺序: ";
    for (int v : topo.order()) std::cout << v << " ";
    std::cout << "\n\n";
    // 最短路径
    AcyclicSP sp(G, s);
    printPaths("=== DAG 最短路径(源点 5)===", s, G.V(), sp);
    // 最长路径
    AcyclicLP lp(G, s);
    printPathsLP("=== DAG 最长路径(源点 5)===", s, G.V(), lp);
    return 0;
}

八、算法对比总览

无环 DAG

可能有环

是

否

单源最短路径

图是否含环?

拓扑排序法
O_E+V_
允许负权

边权是否全非负?

Dijkstra
O_E log V_
不允许负权

Bellman-Ford
O_EV_
允许负权
检测负权环

延伸:最长路径
初始化改为 -INF
松弛改取最大值

九、预期输出

拓扑顺序: 5 1 3 6 4 7 0 2
=== DAG 最短路径(源点 5)===
-------------------------------------------------------
5 -> 0  (0.73)  5->4  4->0
5 -> 1  (0.32)  5->1
5 -> 2  (0.62)  5->7  7->2
5 -> 3  (0.61)  5->1  1->3
5 -> 4  (0.35)  5->4
5 -> 5  (0.00)
5 -> 6  (1.13)  5->1  1->3  3->6
5 -> 7  (0.28)  5->7
=== DAG 最长路径(源点 5)===
-------------------------------------------------------
5 -> 0  (2.44)  5->1  1->3  3->6  6->4  4->0
5 -> 1  (0.32)  5->1
5 -> 2  (2.74)  5->1  1->3  3->6  6->4  4->7  7->2
5 -> 3  (0.61)  5->1  1->3
5 -> 4  (2.06)  5->1  1->3  3->6  6->4
5 -> 5  (0.00)
5 -> 6  (1.13)  5->1  1->3  3->6
5 -> 7  (2.40)  5->1  1->3  3->6  6->4  4->7

十、关键点总结

  1. 拓扑顺序是关键:DAG 上按拓扑顺序松弛,保证处理 vvv 时 distTo[v]distTo[v]distTo[v] 已是最终值,无需优先队列。
  2. 线性时间 O(E+V)O(E + V)O(E+V):拓扑排序一遍,松弛一遍,每条边恰好被处理一次。
  3. 允许负权边:证明不依赖边权非负,只依赖拓扑顺序的单向传播性。
  4. 最长路径只需两处修改:初始化从 +∞+\infty+∞ 改为 −∞-\infty−∞,松弛判断从 <<< 改为 >>>。
  5. 无环是前提:若图含环,拓扑排序无法进行,需改用 Bellman-Ford 算法。对一般有向图(含环)求最长路径是 NP 难问题。

并行任务调度与关键路径法(CPM)

基于《算法(第4版)》4.4节,改写为 C++ 视角,配有详细中文解析与完整可运行代码。

一、问题描述

什么是并行任务调度?

想象你在建一栋楼:

  • 打地基 → 必须先于盖墙
  • 盖墙 → 必须先于装窗户
  • 但"装水管"和"装电线"可以同时进行(只要前置任务完成)
    目标:用尽可能多的处理器(工人),在满足所有前置约束的前提下,最短时间内完成所有任务。

问题的本质

这个问题等价于:在一个带权有向无环图(DAG)中,求最长路径。
这个技术叫做 关键路径法(Critical Path Method,CPM),广泛用于工程项目管理。

二、样例问题


任务编号持续时间必须在此之前完成
041.01, 7, 9
151.02
250.0—
336.0—
438.0—
545.0—
621.03, 8
732.03, 8
832.02
929.04, 6

最优调度结果(最短完工时间 = 173.0):

时间轴:
0   ----[任务0: 41]----
0   ----[任务5: 45]----
41  --------[任务1: 51]--------
41  --[任务7: 32]--
41  --[任务9: 29]--
70  ---[任务4: 38]---
70  --[任务6: 21]--
91  ----[任务3: 36]----
91  ----[任务8: 32]----
123 -----------[任务2: 50]----------- (结束于173)

三、关键路径是什么?

关键路径是所有约束链中最长的那条。
在本例中:

任务0(41) → 任务9(29) → 任务6(21) → 任务8(32) → 任务2(50)
总长度 = 41 + 29 + 21 + 32 + 50 = 173

这条链上的任何一个任务延迟,整个项目就会延迟。这就是"关键"的含义。

任何一组有序任务链的总时长,都是整体完工时间的下界。
其中最长的那条链,就是完工时间的精确下界(同时也是上界),因此关键路径长度 = 最优完工时间。

四、建图方法:把调度问题变成最长路径问题

图的构造规则

对于 NNN 个任务,我们构造一个带权 DAG,共 2N+22N + 22N+2 个顶点:

  • 顶点 iii:任务 iii 的开始节点(0≤i<N0 \le i < N0≤i<N)
  • 顶点 i+Ni + Ni+N:任务 iii 的结束节点
  • 顶点 s=2Ns = 2Ns=2N:超级源点
  • 顶点 t=2N+1t = 2N + 1t=2N+1:超级汇点
    三类边:
    | 边的类型 | 起点 | 终点 | 权重 |
    |:—😐:—😐:—😐:—😐
    | 任务本身 | iii(开始) | i+Ni+Ni+N(结束) | 任务持续时间 |
    | 前置约束 i→ji \to ji→j | i+Ni+Ni+N(iii的结束) | jjj(jjj的开始) | 000 |
    | 源点到每个任务 | sss | iii(开始) | 000 |
    | 每个任务到汇点 | i+Ni+Ni+N(结束) | ttt | 000 |

为什么这样建图有效?

  • 从 sss 到任务 iii 开始节点的最长路径,就是任务 iii 最早能开始的时间。
  • 因为路径上每经过一段"任务",都要加上它的持续时间;经过"约束"边,不加时间但保证顺序。
  • 从 sss 到 ttt 的最长路径 = 整个项目的最短完工时间。

图示(本例 DAG)

                   41
        s ---0→[任务0开始]---→[任务0结束]---0→ 1(开始)
        |                       |  0→ 7(开始)
        |                       |  0→ 9(开始)
        |      51               ↓
        s ---0→[任务1开始]---→[任务1结束]---0→ 2(开始)
        |      ...             ...
        |                       ↓
        s ---0→ ... ---→ ... ---0→ t

五、Mermaid 图示:DAG 结构

0

41

0

0

51

0

29

0

21

0

32

0

50

0

超级源点 s

超级汇点 t

任务0
开始

任务0
结束

任务1
开始

任务1
结束

任务9
开始

任务9
结束

任务6
开始

任务6
结束

任务8
开始

任务8
结束

任务2
开始

任务2
结束

六、算法流程

在 DAG 上求最长路径,使用拓扑排序 + 动态规划(也叫 AcyclicLP 算法):
distTo[v]=max⁡(u,v)∈E(distTo[u]+w(u,v)) \text{distTo}[v] = \max_{(u,v) \in E} \left( \text{distTo}[u] + w(u,v) \right) distTo[v]=(u,v)∈Emax​(distTo[u]+w(u,v))
即:到达顶点 vvv 的最长距离 = 所有前驱节点 uuu 的最长距离 + 对应边权的最大值。
算法步骤:

  1. 对图进行拓扑排序
  2. 按拓扑顺序逐个松弛每条边(取最大值而不是最小值)
  3. distTo[i] 即为任务 iii 的最早开始时间
  4. distTo[t] 即为最短完工时间

七、完整 C++ 实现

#include <iostream>
#include <vector>
#include <queue>
#include <stack>
#include <limits>
#include <string>
#include <sstream>
// ============================================================
// 有向加权边
// ============================================================
struct DirectedEdge {
    int from;    // 起点
    int to;      // 终点
    double weight; // 边权(任务持续时间 或 0)
    DirectedEdge(int u, int v, double w) : from(u), to(v), weight(w) {}
};
// ============================================================
// 有向加权图(邻接表)
// ============================================================
class EdgeWeightedDigraph {
public:
    int V; // 顶点数
    std::vector<std::vector<DirectedEdge>> adj; // 邻接表
    EdgeWeightedDigraph(int v) : V(v), adj(v) {}
    // 添加一条有向边
    void addEdge(const DirectedEdge& e) {
        adj[e.from].push_back(e);
    }
};
// ============================================================
// 拓扑排序(基于 DFS)
// ============================================================
class TopologicalSort {
public:
    std::vector<bool> visited;
    std::stack<int> reversePost; // 逆后序 = 拓扑序
    TopologicalSort(const EdgeWeightedDigraph& G) : visited(G.V, false) {
        for (int v = 0; v < G.V; v++) {
            if (!visited[v]) dfs(G, v);
        }
    }
    void dfs(const EdgeWeightedDigraph& G, int v) {
        visited[v] = true;
        for (const auto& e : G.adj[v]) {
            if (!visited[e.to]) dfs(G, e.to);
        }
        reversePost.push(v); // 后序:所有邻居访问完后入栈
    }
    // 返回拓扑顺序
    std::vector<int> order() {
        std::vector<int> result;
        std::stack<int> tmp = reversePost;
        while (!tmp.empty()) {
            result.push_back(tmp.top());
            tmp.pop();
        }
        return result;
    }
};
// ============================================================
// DAG 最长路径算法(AcyclicLP)
// ============================================================
class AcyclicLP {
public:
    std::vector<double> distTo;   // distTo[v] = 从源点到 v 的最长距离
    std::vector<int> edgeTo;      // edgeTo[v] = 到达 v 的最后一条边的起点(用于回溯路径)
    AcyclicLP(const EdgeWeightedDigraph& G, int s) 
        : distTo(G.V, -std::numeric_limits<double>::infinity()), 
          edgeTo(G.V, -1) 
    {
        distTo[s] = 0.0; // 源点到自身距离为 0
        // 拓扑排序
        TopologicalSort topo(G);
        std::vector<int> order = topo.order();
        // 按拓扑顺序松弛每个顶点的所有出边
        for (int v : order) {
            // 如果 v 还没被源点可达,跳过
            if (distTo[v] == -std::numeric_limits<double>::infinity()) continue;
            for (const auto& e : G.adj[v]) {
                // 松弛操作:若经过 v 能到达 e.to 且路径更长,则更新
                if (distTo[e.to] < distTo[v] + e.weight) {
                    distTo[e.to] = distTo[v] + e.weight;
                    edgeTo[e.to] = v; // 记录前驱,用于回溯关键路径
                }
            }
        }
    }
};
// ============================================================
// 解析一行输入:格式为 "持续时间 [后继任务1 后继任务2 ...]"
// ============================================================
struct JobInfo {
    double duration;
    std::vector<int> successors;
};
JobInfo parseJobLine(const std::string& line) {
    JobInfo job;
    std::istringstream iss(line);
    iss >> job.duration;
    int s;
    while (iss >> s) {
        job.successors.push_back(s);
    }
    return job;
}
// ============================================================
// 主程序:关键路径法(CPM)
// ============================================================
int main() {
    // ---- 读取任务数量 ----
    int N;
    std::cin >> N;
    std::vector<JobInfo> jobs(N);
    // ---- 读取每个任务的信息 ----
    // 格式:每行 "持续时间 [后继任务编号...]"
    std::string dummy;
    std::getline(std::cin, dummy); // 消耗掉 N 后面的换行符
    for (int i = 0; i < N; i++) {
        std::string line;
        std::getline(std::cin, line);
        jobs[i] = parseJobLine(line);
    }
    // ---- 建图 ----
    // 图共有 2*N + 2 个顶点:
    //   顶点 i       = 任务 i 的开始节点(0 <= i < N)
    //   顶点 i + N   = 任务 i 的结束节点(0 <= i < N)
    //   顶点 2*N     = 超级源点 s
    //   顶点 2*N + 1 = 超级汇点 t
    int s = 2 * N;     // 超级源点
    int t = 2 * N + 1; // 超级汇点
    EdgeWeightedDigraph G(2 * N + 2);
    for (int i = 0; i < N; i++) {
        double duration = jobs[i].duration;
        // 边1:任务 i 的开始 → 任务 i 的结束,权重 = 持续时间
        G.addEdge(DirectedEdge(i, i + N, duration));
        // 边2:超级源点 → 任务 i 的开始,权重 = 0
        G.addEdge(DirectedEdge(s, i, 0.0));
        // 边3:任务 i 的结束 → 超级汇点,权重 = 0
        G.addEdge(DirectedEdge(i + N, t, 0.0));
        // 边4(前置约束):对于每个后继任务 j
        //   任务 i 的结束 → 任务 j 的开始,权重 = 0(表示 j 必须在 i 完成后开始)
        for (int j : jobs[i].successors) {
            G.addEdge(DirectedEdge(i + N, j, 0.0));
        }
    }
    // ---- 求 DAG 最长路径 ----
    AcyclicLP lp(G, s);
    // ---- 输出结果 ----
    std::cout << "各任务最早开始时间:" << std::endl;
    for (int i = 0; i < N; i++) {
        std::printf("  任务 %2d: %6.1f\n", i, lp.distTo[i]);
    }
    std::printf("最短完工时间: %.1f\n", lp.distTo[t]);
    // ---- 回溯关键路径(从 t 反向追溯) ----
    std::cout << "\n关键路径(从终点反向追溯):" << std::endl;
    std::vector<int> path;
    int cur = t;
    while (cur != s && cur != -1) {
        path.push_back(cur);
        cur = lp.edgeTo[cur];
    }
    path.push_back(s);
    // 反转为正向
    for (int i = (int)path.size() - 1; i >= 0; i--) {
        int v = path[i];
        if (v == s) std::cout << "  [源点s]";
        else if (v == t) std::cout << " -> [汇点t]";
        else if (v < N) std::cout << " -> 任务" << v << "开始";
        else std::cout << " -> 任务" << (v - N) << "结束";
    }
    std::cout << std::endl;
    return 0;
}

输入格式说明

10
41.0  1 7 9
51.0  2
50.0
36.0
38.0
45.0
21.0  3 8
32.0  3 8
32.0  2
29.0  4 6

预期输出

各任务最早开始时间:
  任务  0:    0.0
  任务  1:   41.0
  任务  2:  123.0
  任务  3:   91.0
  任务  4:   70.0
  任务  5:    0.0
  任务  6:   70.0
  任务  7:   41.0
  任务  8:   91.0
  任务  9:   41.0
最短完工时间: 173.0
关键路径(从终点反向追溯):
  [源点s] -> 任务0开始 -> 任务0结束 -> 任务9开始 -> 任务9结束 -> 任务6开始 -> 任务6结束 -> 任务8开始 -> 任务8结束 -> 任务2开始 -> 任务2结束 -> [汇点t]

八、时间复杂度分析

命题 U:关键路径法在线性时间内解决并行任务调度问题。

步骤时间
建图O(N+M)O(N + M)O(N+M)(MMM 为约束数量)
拓扑排序(DFS)O(V+E)=O(N+M)O(V + E) = O(N + M)O(V+E)=O(N+M)
最长路径(松弛)O(V+E)=O(N+M)O(V + E) = O(N + M)O(V+E)=O(N+M)
总计O(N+M)O(N + M)O(N+M)(线性时间)

正确性证明思路:

  1. 下界:DAG 中从 sss 到任意顶点 vvv 的任意路径,都对应一组必须串行完成的任务序列。这些任务串行完成的时间就是 vvv 的完成时间的下界。
    因此 distTo[t]\text{distTo}[t]distTo[t](最长路径长度)是完工时间的下界。
  2. 上界:最长路径给出的调度方案是可行的——每个任务的开始时间都在其所有前置任务完成之后(因为前置约束边权为 0 但保证了拓扑顺序)。
    因此 distTo[t]\text{distTo}[t]distTo[t] 也是一个可以实际达到的完工时间,即上界。
  3. 下界 = 上界 → 最优。
    关键路径长度=最短可能完工时间 \text{关键路径长度} = \text{最短可能完工时间} 关键路径长度=最短可能完工时间

九、扩展:带相对截止期限的调度

问题升级

在原有约束基础上,增加一类约束:
“任务 jjj 必须在任务 iii 开始后的 ddd 个时间单位内开始”
例如:

  • 任务 2 必须在任务 4 开始后的 12 个时间单位内开始(d=12d=12d=12)
  • 即:任务 4 的开始时间 ≥\ge≥ 任务 2 的开始时间 −12- 12−12

如何建模?

这类约束等价于:在调度图中,从任务 jjj 的开始节点到任务 iii 的开始节点加一条权重为 −d-d−d 的边:
start(i)≤start(j)+d⟺start(i)−start(j)≤d \text{start}(i) \le \text{start}(j) + d \quad \Longleftrightarrow \quad \text{start}(i) - \text{start}(j) \le d start(i)≤start(j)+d⟺start(i)−start(j)≤d
这意味着图中会出现负权边,并且由于截止约束可能形成环(例如 iii 约束 jjj,jjj 又约束 iii)。
命题 V:带相对截止期的并行调度,等价于带负权边的有向图中的最短路径问题(可能有环)。
将图中所有权重取反,最长路径变为最短路径。此时需要使用 Bellman-Ford 算法:

  • 若存在负权环,则说明约束互相矛盾,调度不可行。
  • 若无负权环,则 Bellman-Ford 给出最优调度。

负权边示意

             -12(截止约束:2必须在4开始后12单位内开始)
任务2开始  <----------  任务4开始

场景算法能处理负权?能处理环?
无截止约束AcyclicLP(拓扑+DP)能(非负即可)不能(需是DAG)
有截止约束Bellman-Ford能能
Dijkstra—不能—

十、完整流程总结(ASCII 流程图)

输入:N个任务(持续时间 + 前置约束)
         |
         v
    构造 DAG
    ┌─────────────────────────────────┐
    │  2N+2 个顶点                    │
    │  • 超级源点 s → 每个任务开始(权0) │
    │  • 每个任务:开始→结束(权=时长)  │
    │  • 前置约束:结束→后继开始(权0)  │
    │  • 每个任务结束 → 超级汇点 t(权0)│
    └─────────────────────────────────┘
         |
         v
    拓扑排序(DFS 逆后序)
         |
         v
    按拓扑序松弛所有边
    distTo[v] = max(distTo[v], distTo[u] + w(u,v))
         |
         v
    输出:distTo[i] = 任务i最早开始时间
          distTo[t] = 最短完工时间(关键路径长度)

十一、Java vs C++ 对照表


Java 写法C++ 对应写法
StdIn.readInt()std::cin >> N
StdIn.readLine()std::getline(std::cin, line)
String.split("\\s+")std::istringstream 逐词读取
StdOut.printf(...)std::printf(...)
EdgeWeightedDigraph 类自定义 EdgeWeightedDigraph 结构体
AcyclicLP 类自定义 AcyclicLP 类
double 负无穷std::numeric_limits<double>::infinity()

核心思想一句话总结:把调度问题的"最短完工时间",转化为 DAG 上从超级源点到超级汇点的"最长路径长度",用拓扑排序 + 动态规划在线性时间内求解。

一般有向图中的最短路径:Bellman-Ford 算法与套汇问题

基于《算法(第4版)》4.4节,改写为 C++ 视角,配详细中文解析与完整可运行代码。

一、引入:负权边带来了什么新问题?

上一节讲到,带权 DAG 的最短路径可以用拓扑排序解决;Dijkstra 算法适用于正权边的有向图。
但现实中,负权边大量存在(例如带截止期限的任务调度)。
负权边对最短路径的直觉影响:

  • 正权图中,路径越短(边越少)越好,我们找"捷径"。
  • 负权图中,我们反而想要"绕弯路"——多走几条负权边,总权重反而更小。

二、三种错误的"草率想法"

草率想法一:把所有边权加上一个正数让权重非负

做法:找到最小(最负)的边权 wmin⁡w_{\min}wmin​,把所有边权加上 ∣wmin⁡∣|w_{\min}|∣wmin​∣。
为什么不行?
路径经过的边越多,被"惩罚"越多。原来最短的路径(边多但总权小)在变换后可能变得比原来长的路径还差。变换前后的最短路径没有对应关系。

草率想法二:改造 Dijkstra 算法

Dijkstra 的核心假设:每次从优先队列取出的顶点,其最短距离已经确定,不会再被更新。这依赖于"给路径加一条边,路径只会更长"。
一旦有负权边,加边反而会让路径变短,这个假设就被打破了,Dijkstra 算法会给出错误答案。

草率想法三:找最短简单路径

即使有负权环,两个顶点之间的最短简单路径(不重复顶点)总是存在的。
为什么不行? 目前已知求最短简单路径的最好算法在最坏情况下是指数时间的(详见第6章,NP难问题)。对于实际应用来说太慢了。

三、负权环:让最短路径变成无意义的概念

什么是负权环?

有向环 v0→v1→⋯→vk→v0 的总权重<0 \text{有向环 } v_0 \to v_1 \to \cdots \to v_k \to v_0 \text{ 的总权重} < 0 有向环 v0​→v1​→⋯→vk​→v0​ 的总权重<0
例子:边 4→74 \to 74→7(权 0.37),7→57 \to 57→5(权 0.28),5→45 \to 45→4(权 -0.66),构成环:
0.37+0.28+(−0.66)=−0.01 0.37 + 0.28 + (-0.66) = -0.01 0.37+0.28+(−0.66)=−0.01
只要在这个环上多转一圈,路径总权重就减少 0.01。转无限圈就能得到负无穷的路径权重——最短路径不存在。

命题 W(最短路径存在的条件)

从 sss 到 vvv 存在最短路径,当且仅当:

  1. 存在从 sss 到 vvv 的至少一条有向路径,且
  2. 从 sss 到 vvv 的所有路径上,没有任何顶点处于负权环上。

三类情况总结

顶点分类:
┌─────────────────────────────────────────────┐
│  灰色:从 s 不可达            distTo = +∞   │
│  白色:可达但路径经过负权环   distTo = +∞   │
│  黑色:可达且无负权环污染     有确定最短路径 │
└─────────────────────────────────────────────┘

四、Bellman-Ford 算法

核心思想

不依赖边的顺序,对所有边进行 VVV 轮松弛。
命题 X(Bellman-Ford 正确性):
初始化 distTo[s]=0\text{distTo}[s] = 0distTo[s]=0,其余为 +∞+\infty+∞。对所有边进行松弛,重复 VVV 次。若图中无从 sss 可达的负权环,则结束后 distTo[v]\text{distTo}[v]distTo[v] 即为从 sss 到 vvv 的最短路径长度。
为什么 VVV 轮足够?
任何不经过负权环的最短路径,最多包含 V−1V-1V−1 条边。归纳可证:第 iii 轮结束后,所有最多经过 iii 条边的最短路径都已正确计算。
时间复杂度:O(VE)O(VE)O(VE)(VVV 轮,每轮松弛 EEE 条边)
空间复杂度:O(V)O(V)O(V)

五、基于队列的 Bellman-Ford(优化版本)

关键观察

朴素版本每轮都松弛所有 EEE 条边,但实际上,只有上一轮 distTo\text{distTo}distTo 值发生变化的顶点,其出边才可能在下一轮导致新的更新。

优化策略

用一个 FIFO 队列跟踪"上一轮被更新的顶点",只对这些顶点的出边进行松弛。

数据结构


变量类型作用
distTo[]double[]从源点到各顶点的最短距离
edgeTo[]有向边数组最短路径树中各顶点的前驱边
onQ[]bool[]顶点是否在队列中(避免重复入队)
queueFIFO队列待松弛的顶点列表
costint松弛次数计数器(用于定期检测负权环)

算法执行演示(有负权边的例子)

图的边信息(tinyEWDn.txt):

边             权重
0->2          0.26
0->4          0.38
4->5          0.35
4->7          0.37
5->4          0.35
5->7          0.28
5->1          0.32
7->3          0.39
7->5          0.28
1->3          0.29
2->7          0.34
3->6          0.52
6->2         -1.20
6->0         -1.40
6->4         -1.25
第1轮(源点0出发):
  队列: [0]
  松弛 0->2: distTo[2]=0.26, 入队2
  松弛 0->4: distTo[4]=0.38, 入队4
第2轮:
  队列: [2, 4]
  松弛 2->7: distTo[7]=0.60, 入队7
  松弛 4->5: distTo[5]=0.73, 入队5
  松弛 4->7: 0.38+0.37=0.75 > 0.60, 不更新
第3轮:
  队列: [7, 5]
  松弛 7->3: distTo[3]=0.99, 入队3
  松弛 5->1: distTo[1]=1.05, 入队1
  松弛 5->4: 0.73+0.35=1.08 > 0.38, 不更新
第4轮:
  队列: [3, 1]
  松弛 3->6: distTo[6]=1.51, 入队6
第5轮(负权边生效!):
  队列: [6]
  松弛 6->4: distTo[4]=1.51+(-1.25)=0.26  更新!入队4
  (同理 6->0, 6->2 也可能更新)
第6~8轮:
  4被更新后,4->5->1 都需要重新松弛...
  最终 distTo[1]=0.93(比之前的1.05更短)

最终结果:

distTo[0]=0.00  distTo[1]=0.93  distTo[2]=0.26
distTo[3]=0.99  distTo[4]=0.26  distTo[5]=0.61
distTo[6]=1.51  distTo[7]=0.60

六、负权环检测

检测方法

在 edgeTo[] 数组中,如果存在有向环,该环必然是负权环(因为一个顶点被第二次加入最短路径,意味着第二次的距离更小,说明绕了一圈后路径变短了)。
每隔 VVV 次松弛,检查一次 edgeTo[] 中是否有环。

如何在 edgeTo[] 中找环?

把 edgeTo[] 中所有非空边构成一个新图,对这个子图用 DFS 检测有向环。

七、完整 C++ 实现

#include <iostream>
#include <vector>
#include <queue>
#include <stack>
#include <limits>
#include <cmath>
#include <string>
#include <sstream>
#include <iomanip>
// ============================================================
// 有向加权边
// ============================================================
struct DirectedEdge {
    int u, v;       // 起点和终点
    double w;       // 边权
    DirectedEdge() : u(-1), v(-1), w(0) {}
    DirectedEdge(int u, int v, double w) : u(u), v(v), w(w) {}
};
// ============================================================
// 有向加权图(邻接表)
// ============================================================
class EdgeWeightedDigraph {
public:
    int V;
    std::vector<std::vector<DirectedEdge>> adj;
    EdgeWeightedDigraph(int v) : V(v), adj(v) {}
    void addEdge(const DirectedEdge& e) {
        adj[e.u].push_back(e);
    }
};
// ============================================================
// 在 edgeTo[] 子图中检测有向环(DFS)
// ============================================================
class CycleFinder {
public:
    std::vector<bool> visited;
    std::vector<bool> onStack;   // 当前 DFS 栈上的顶点(用于检测环)
    std::vector<int> edgeTo;     // 前驱顶点(用于回溯环路径)
    std::vector<DirectedEdge> cycle; // 检测到的环(边序列)
    bool hasCycle;
    CycleFinder(const EdgeWeightedDigraph& G)
        : visited(G.V, false), onStack(G.V, false),
          edgeTo(G.V, -1), hasCycle(false)
    {
        for (int v = 0; v < G.V && !hasCycle; v++) {
            if (!visited[v]) dfs(G, v);
        }
    }
    void dfs(const EdgeWeightedDigraph& G, int v) {
        visited[v] = true;
        onStack[v] = true; // 进入 DFS 栈
        for (const auto& e : G.adj[v]) {
            if (hasCycle) return;
            if (!visited[e.v]) {
                edgeTo[e.v] = v;
                dfs(G, e.v);
            } else if (onStack[e.v]) {
                // 发现环:e.v 在当前栈上,说明有环
                hasCycle = true;
                // 回溯收集环上的边
                cycle.clear();
                // 从 v 回溯到 e.v
                std::stack<int> path;
                int cur = v;
                while (cur != e.v) {
                    path.push(cur);
                    cur = edgeTo[cur];
                }
                path.push(e.v);
                // 收集路径上的边(这里简化:只记录顶点序列)
                // 实际环的边在 edgeTo[] 子图中
                return;
            }
        }
        onStack[v] = false; // 离开 DFS 栈
    }
};
// ============================================================
// 基于队列的 Bellman-Ford 最短路径算法
// ============================================================
class BellmanFordSP {
private:
    int V;
    std::vector<double> distTo;        // distTo[v] = 源点到 v 的最短距离
    std::vector<DirectedEdge> edgeTo;  // edgeTo[v] = 最短路径树中到达 v 的边
    std::vector<bool> onQ;             // onQ[v] = v 是否在队列中
    std::queue<int> q;                 // 待松弛的顶点队列
    int cost;                          // 松弛次数(用于定期检测负权环)
    std::vector<int> negCycle;         // 负权环顶点序列(空表示没有)
    bool hasNegCycle;                  // 是否存在负权环
    // 松弛顶点 v 的所有出边
    void relax(const EdgeWeightedDigraph& G, int v) {
        for (const auto& e : G.adj[v]) {
            int w = e.v;
            // 如果经过 v 到 w 的路径更短,则更新
            if (distTo[w] > distTo[v] + e.w) {
                distTo[w] = distTo[v] + e.w;
                edgeTo[w] = e;
                // 将 w 加入队列(若未在队列中)
                if (!onQ[w]) {
                    q.push(w);
                    onQ[w] = true;
                }
            }
            // 每隔 V 次松弛,检测一次负权环
            if (++cost % V == 0) {
                findNegativeCycle(G);
                if (hasNegCycle) return; // 发现负权环,立即停止
            }
        }
    }
    // 在 edgeTo[] 构成的子图中寻找有向环
    // 若该子图有环,则该环必为负权环
    void findNegativeCycle(const EdgeWeightedDigraph& G) {
        // 构造 edgeTo[] 子图
        EdgeWeightedDigraph spt(V);
        for (int v = 0; v < V; v++) {
            if (edgeTo[v].u != -1) { // 该边存在
                spt.addEdge(edgeTo[v]);
            }
        }
        // 用 DFS 检测子图中的环
        CycleFinder cf(spt);
        if (cf.hasCycle) {
            hasNegCycle = true;
            // 回溯找出负权环上的顶点
            // 找到环的入口点(onStack 中某个被重复访问的点)
            // 简化实现:直接标记 hasNegCycle,环路径通过 edgeTo[] 可追溯
        }
    }
public:
    BellmanFordSP(const EdgeWeightedDigraph& G, int s)
        : V(G.V),
          distTo(G.V, std::numeric_limits<double>::infinity()),
          edgeTo(G.V),        // 默认构造(u=-1表示无效边)
          onQ(G.V, false),
          cost(0),
          hasNegCycle(false)
    {
        distTo[s] = 0.0;
        q.push(s);
        onQ[s] = true;
        // 主循环:队列非空且无负权环则继续
        while (!q.empty() && !hasNegCycle) {
            int v = q.front(); q.pop();
            onQ[v] = false;
            relax(G, v);
        }
    }
    // 查询到顶点 v 的最短距离
    double dist(int v) const {
        return distTo[v];
    }
    // 是否有从源点到 v 的路径
    bool hasPathTo(int v) const {
        return distTo[v] < std::numeric_limits<double>::infinity();
    }
    // 是否存在负权环
    bool hasNegativeCycle() const {
        return hasNegCycle;
    }
    // 打印从源点到 v 的最短路径
    void printPathTo(int v) const {
        if (!hasPathTo(v)) {
            std::cout << "  无路径可达" << std::endl;
            return;
        }
        std::stack<DirectedEdge> path;
        int cur = v;
        while (edgeTo[cur].u != -1) {
            path.push(edgeTo[cur]);
            cur = edgeTo[cur].u;
        }
        while (!path.empty()) {
            auto e = path.top(); path.pop();
            std::cout << "  " << e.u << " -> " << e.v
                      << "  (权重: " << e.w << ")" << std::endl;
        }
    }
};
// ============================================================
// 套汇检测(Arbitrage)
// ============================================================
void solveArbitrage() {
    // 输入格式:
    // 第一行:货币数量 V
    // 接下来 V 行:货币名称 + V 个汇率
    // 示例数据(5种货币)
    int V = 5;
    std::string names[] = {"USD", "EUR", "GBP", "CHF", "CAD"};
    // 汇率矩阵 rate[i][j] = 用货币i换货币j的汇率
    double rate[5][5] = {
        {1,     0.741, 0.657, 1.061, 1.005},
        {1.349, 1,     0.888, 1.433, 1.366},
        {1.521, 1.126, 1,     1.614, 1.538},
        {0.942, 0.698, 0.619, 1,     0.953},
        {0.995, 0.732, 0.650, 1.049, 1    }
    };
    // 建图:边权 = -ln(汇率)
    // 原因:乘积最大化 <=> 对数之和最大化 <=> 负对数之和最小化
    // 套汇机会 <=> 负权环(绕一圈后对数之和为负,即乘积大于1)
    EdgeWeightedDigraph G(V);
    for (int i = 0; i < V; i++) {
        for (int j = 0; j < V; j++) {
            // 边权 = -ln(rate[i][j])
            // 若存在负权环,则该环对应一个套汇机会
            G.addEdge(DirectedEdge(i, j, -std::log(rate[i][j])));
        }
    }
    // 以顶点 0(USD)为源点运行 Bellman-Ford
    BellmanFordSP bf(G, 0);
    std::cout << "=== 套汇检测 ===" << std::endl;
    if (bf.hasNegativeCycle()) {
        std::cout << "发现套汇机会!" << std::endl;
        std::cout << "(注:本简化版本标记了存在,完整环路追踪见下方手动演示)" << std::endl;
    } else {
        std::cout << "无套汇机会" << std::endl;
    }
    // 手动演示已知的套汇路径:USD -> EUR -> CAD -> USD
    std::cout << "\n演示套汇路径 USD -> EUR -> CAD -> USD:" << std::endl;
    double stake = 1000.0;
    std::cout << std::fixed << std::setprecision(5);
    std::cout << "  " << stake << " USD";
    stake *= rate[0][1]; // USD -> EUR
    std::cout << " -> " << stake << " EUR" << std::endl;
    std::cout << "  " << stake << " EUR";
    stake *= rate[1][4]; // EUR -> CAD
    std::cout << " -> " << stake << " CAD" << std::endl;
    std::cout << "  " << stake << " CAD";
    stake *= rate[4][0]; // CAD -> USD
    std::cout << " -> " << stake << " USD" << std::endl;
    std::cout << "  利润: " << (stake - 1000.0) << " USD ("
              << ((stake / 1000.0 - 1.0) * 100.0) << "%)" << std::endl;
}
// ============================================================
// 主程序:测试 Bellman-Ford 算法
// ============================================================
int main() {
    std::cout << "=== 测试1:含负权边的最短路径 ===" << std::endl;
    // 构造 tinyEWDn 图(8个顶点,含负权边)
    EdgeWeightedDigraph G(8);
    G.addEdge({4, 5,  0.35});
    G.addEdge({5, 4,  0.35});
    G.addEdge({4, 7,  0.37});
    G.addEdge({5, 7,  0.28});
    G.addEdge({7, 5,  0.28});
    G.addEdge({5, 1,  0.32});
    G.addEdge({0, 4,  0.38});
    G.addEdge({0, 2,  0.26});
    G.addEdge({7, 3,  0.39});
    G.addEdge({1, 3,  0.29});
    G.addEdge({2, 7,  0.34});
    G.addEdge({6, 2, -1.20}); // 负权边
    G.addEdge({3, 6,  0.52});
    G.addEdge({6, 0, -1.40}); // 负权边
    G.addEdge({6, 4, -1.25}); // 负权边
    BellmanFordSP sp(G, 0);
    if (sp.hasNegativeCycle()) {
        std::cout << "检测到负权环,最短路径无意义!" << std::endl;
    } else {
        std::cout << "从源点 0 出发的最短距离:" << std::endl;
        for (int v = 0; v < 8; v++) {
            std::cout << std::fixed << std::setprecision(2);
            std::cout << "  到顶点 " << v << ": ";
            if (sp.hasPathTo(v))
                std::cout << sp.dist(v) << std::endl;
            else
                std::cout << "不可达" << std::endl;
        }
        std::cout << "\n到顶点 1 的最短路径:" << std::endl;
        sp.printPathTo(1);
    }
    std::cout << std::endl;
    // 测试2:含负权环的图
    std::cout << "=== 测试2:含负权环的图 ===" << std::endl;
    EdgeWeightedDigraph G2(8);
    G2.addEdge({4, 5,  0.35});
    G2.addEdge({5, 4, -0.66}); // 负权边,与4->7->5->4构成负权环
    G2.addEdge({4, 7,  0.37});
    G2.addEdge({5, 7,  0.28});
    G2.addEdge({7, 5,  0.28});
    G2.addEdge({5, 1,  0.32});
    G2.addEdge({0, 4,  0.38});
    G2.addEdge({0, 2,  0.26});
    G2.addEdge({7, 3,  0.39});
    G2.addEdge({1, 3,  0.29});
    G2.addEdge({2, 7,  0.34});
    G2.addEdge({6, 2,  0.40});
    G2.addEdge({3, 6,  0.52});
    G2.addEdge({6, 0,  0.58});
    G2.addEdge({6, 4,  0.93});
    BellmanFordSP sp2(G2, 0);
    if (sp2.hasNegativeCycle()) {
        std::cout << "检测到负权环!调度/路径问题不可行。" << std::endl;
    } else {
        std::cout << "无负权环,正常输出最短路径。" << std::endl;
    }
    std::cout << std::endl;
    // 测试3:套汇问题
    solveArbitrage();
    return 0;
}

八、算法流程图

否

是

否

是

是

否

是

是

否

否

初始化
distTo[s]=0, 其余=+∞
源点 s 入队

队列非空?

完成:输出最短路径

取出队首顶点 v

松弛 v 的所有出边 v->w

distTo[v]+w(v,w) < distTo[w]?

不更新,继续下一条边

更新 distTo[w]
edgeTo[w] = v->w

w 不在队列中?

w 入队,onQ[w]=true

cost++ 是 V 的倍数?

检测 edgeTo[] 子图中的环

发现负权环?

终止:报告负权环

九、套汇问题详解

什么是套汇?

在货币兑换市场中,如果存在一组货币 c1→c2→⋯→ck→c1c_1 \to c_2 \to \cdots \to c_k \to c_1c1​→c2​→⋯→ck​→c1​,使得:
rc1c2×rc2c3×⋯×rckc1>1 r_{c_1 c_2} \times r_{c_2 c_3} \times \cdots \times r_{c_k c_1} > 1 rc1​c2​​×rc2​c3​​×⋯×rck​c1​​>1
则可以用 xxx 单位的 c1c_1c1​,经过这些兑换后,得到超过 xxx 单位的 c1c_1c1​,从而无风险获利。

如何转化为最短路径问题?

命题 Z:套汇问题等价于带权有向图中的负权环检测问题。
关键变换:对每条汇率边取负自然对数作为新的边权:
w(s→t)=−ln⁡(rst) w(s \to t) = -\ln(r_{st}) w(s→t)=−ln(rst​)
原因:

  • 原问题:乘积 r1×r2×⋯×rk>1r_1 \times r_2 \times \cdots \times r_k > 1r1​×r2​×⋯×rk​>1
  • 两边取对数:ln⁡r1+ln⁡r2+⋯+ln⁡rk>0\ln r_1 + \ln r_2 + \cdots + \ln r_k > 0lnr1​+lnr2​+⋯+lnrk​>0
  • 取反:(−ln⁡r1)+(−ln⁡r2)+⋯+(−ln⁡rk)<0(-\ln r_1) + (-\ln r_2) + \cdots + (-\ln r_k) < 0(−lnr1​)+(−lnr2​)+⋯+(−lnrk​)<0
    这正好对应新图中的负权环!

数值示例

汇率表(部分):

从\到USDEURCAD
USD10.7411.005
EUR1.34911.366
CAD0.9950.7321

套汇路径:USD →\to→ EUR →\to→ CAD →\to→ USD
0.741×1.366×0.995=1.00714⋯>1 0.741 \times 1.366 \times 0.995 = 1.00714\cdots > 1 0.741×1.366×0.995=1.00714⋯>1
变换后的边权(取负对数):
−ln⁡(0.741)−ln⁡(1.366)−ln⁡(0.995)=0.2998−0.3119+0.0050=−0.0071<0 -\ln(0.741) - \ln(1.366) - \ln(0.995) = 0.2998 - 0.3119 + 0.0050 = -0.0071 < 0 −ln(0.741)−ln(1.366)−ln(0.995)=0.2998−0.3119+0.0050=−0.0071<0
负权环得证!
实际利润(以1000 USD为本):

1000.00 USD
  × 0.741  = 741.00 EUR
  × 1.366  = 1012.206 CAD
  × 0.995  = 1007.145 USD
利润 = 7.145 USD(0.714%)
按每分钟交易100万USD计算 ≈ 每分钟盈利 7145 USD

套汇的图示

×0.741
边权=-ln(0.741)=+0.300

×1.366
边权=-ln(1.366)=-0.312

×0.995
边权=-ln(0.995)=+0.005

USD

EUR

CAD

环的总边权:0.300+(−0.312)+0.005=−0.007<00.300 + (-0.312) + 0.005 = -0.007 < 00.300+(−0.312)+0.005=−0.007<0,即负权环,对应套汇机会。

十、三种最短路径算法对比


算法限制条件典型时间最坏时间额外空间适用场景
Dijkstra(堆优化)边权必须非负O(Elog⁡V)O(E \log V)O(ElogV)O(Elog⁡V)O(E \log V)O(ElogV)O(V)O(V)O(V)正权图,最优选择
拓扑排序法图必须是 DAGO(E+V)O(E + V)O(E+V)O(E+V)O(E + V)O(E+V)O(V)O(V)O(V)无环图,线性时间最优
Bellman-Ford(队列版)无负权环即可O(E+V)O(E + V)O(E+V)O(VE)O(VE)O(VE)O(V)O(V)O(V)通用,可检测负权环

选择策略:

图有负权边?
  ├─ 否 → Dijkstra(最快)
  └─ 是
       图有环?
         ├─ 否 → 拓扑排序法(线性时间)
         └─ 是 → Bellman-Ford(兼顾正确性与负权环检测)

十一、关键性质总结

命题 Y(队列版 Bellman-Ford 的保证):
若图中无从源点 sss 可达的负权环,算法在经过不超过 V−1V-1V−1 轮松弛后终止,时间 O(VE)O(VE)O(VE),空间 O(V)O(V)O(V)。
若存在从 sss 可达的负权环,队列永远不会变空,第 VVV 轮后 edgeTo[] 子图中必然含有环,该环即为负权环。
为什么 edgeTo[] 中的环一定是负权环?
若顶点 www 在第 VVV 轮后被第二次加入路径,说明第二次到达 www 的路径比第一次更短(否则不会更新),绕了一圈路径反而变短——这正是负权环的定义。

核心思想一句话总结:Bellman-Ford 用"反复松弛所有可能有效的边(队列驱动)+ 定期检测 edgeTo[] 子图中的环",在 O(VE)O(VE)O(VE) 时间内解决含负权边的最短路径问题,并顺带检测负权环(套汇机会)。

Logo

北京人形旗下天工造物具身智能开源社区,聚焦具身天工与慧思开物两大平台

更多推荐