目录
  1. 1. AOV 和 AOE
    1. 1.1. 应用
  2. 2. 最长路径
  3. 3. 关键路径
    1. 3.1. 推导求解
      1. 3.1.1. 具体实现
        1. 3.1.1.1. C++
    2. 3.2. 动态规划
关键路径

AOV 和 AOE

顶点活动(Activity On Vector,AOV)网 是指 顶点表示活动,而 边集表示活动间优先关系有向图。AOV 显然不能存在环,否则会逻辑错误。

边活动(Activity On Edge,AOE)网 是指用 带权边集表示活动,而 顶点表示事件有向图,其中边权表示完成活动所需条件。

应用

一般来说,AOE 用于表示一个工程的进行过程,而工程常常可以划分为若干个子工程(即“活动”),显然 AOE 也不该有环。

对工程来说总会有一个起始时刻和结束时刻,因此 AOV 一般只有一个源点(入度为 0)和一个汇点(出度为 0)。实际情况若有多个源点或汇点,可以添加一个“超级源点”(出发连接所有源点,边权为 0)和一个“超级汇点”(汇点出发连接到它,边权为 0)。

如果 AOV 给定了各顶点活动所需要时间,则可以将 AOV 转化为 AOE:

  1. 将 AOV 的顶点拆成两个顶点,表示活动的起点和终点,两个顶点使用有向边连接,边权给定
  2. 原 AOV 中所有的边视为 0 权边

通常,我们会想知道一个工程需要的最短时间是多少?这就引出了 关键路径:AOE 网中最长路径,把在关键路径上的活动称为 关键活动,显然 关键活动 会影响整个工程的进度。

最长路径

根据之前学的最短路径,我们可以简单的把所有边的边权乘 -1,然后使用 Bellman-Ford 或者 SPFA 来求解最短路径,然后结果求反。

如果有正环,则最长路径不存在。但是,如果要求最长简单路径,虽然一定存在,无法通过 Bellman-Ford 等算法求解,因此 最长路径问题是 NP-Hard 问题

最长路径问题(Longest Path Problem):求解图的 最长简单路径

但如果是求解有向无环图(DAG)的最长路径长度,即求解关键路径,比 BF 或 SPFA 更高效的做法。

关键路径

推导求解

求关键路径,实际上就是:求解 DAG 中最长路径

由于关键活动是不允许拖延的活动,因此 关键活动的最早开始时间必须等于最迟开始时间。可以设置 两个数组 e[]l[] 来表示活动的最早开始时间和最迟开始时间。因此求关键活动就是:e[r] == l[r] 的活动 r。

顶点即事件,会存在最早发生时间和最迟发生时间,其中最早发生时间 == 上一个活动的最早结束时间,最迟发生时间 == 下一个活动的最迟开始时间。因此设置 两个数组 ve[]vl[] 来表示事件 i 的最早发生时间和最迟发生时间

因此对 e[]l[] 的求解可以转化为对 ve[]vl[] 的求解,假设事件 Vi 在经过活动 ar 之后达到时间 Vj

  1. 对活动 ar 而言,只要在事件 Vi 最早发生时马上开始,就可以使得 ar 的开始时间最早,因此 e[r] = ve[i]

  2. 如果 l[r] 时活动 ar 的最迟发生时间,那么 l[r] + length[r] 就是事件 Vj 最迟发生时间,因此 l[r] = vl[j] - length[r]

那么如何求出 ve[]vl[] 这两个数组成为关键:

  1. 假设 k 个事件 Vi1 ~ Vik 通过相应活动 ar1 ~ ark 到达事件 Vj,那么 Vj 的最早开始事件 ve[j]=max(ve[ip]+length[rp]), p[1,k]ve[j] = max(ve[ip] + length[rp]),\ p\in [1, k]

    • 通过拓扑序列保证:在访问某个结点时保证它的前驱结点的 ve[] 都已计算完毕
  2. 假设从事件 Vi 出发通过通过相应活动 ar1 ~ ark 到达 k 个事件 Vi1 ~ Vik,那么 Vi 的最迟发生时间 vl[i]=min(vl[jp]length[rp]), p[1,k]vl[i] = min(vl[jp] - length[rp]),\ p\in [1, k]

    • 通过逆拓扑序列(拓扑序的颠倒)保证:后继结点都已计算完毕。

具体实现

通过了上面的推导,可以给出步骤总结:先求点,再夹边

  1. 按拓扑序和逆拓扑序分别计算出各顶点(事件)的最早发生时间和最迟发生时间:

    {ve[j]=mini,ij(ve[i]+length[ij])vl[i]=minj,ij(vl[j]length[ij])\left\{ \begin{aligned} &最早(拓扑序):ve[j] = \min \limits_{i, 边i\rightarrow j存在}(ve[i] + length[i\rightarrow j]) \\ &最迟(逆拓扑序):vl[i] = \min \limits_{j, 边i\rightarrow j存在}(vl[j] - length[i\rightarrow j]) \end{aligned} \right.

    • 使用拓扑排序获得拓扑序和逆拓扑序(将拓扑序入栈,出栈顺序就是逆拓扑序)
  2. 用上面的结果计算各边(活动)的最早开始事件和最迟开始时间:

    {e[ij]=ve[i]l[ij]=vl[j]length[ij]\left\{ \begin{aligned} &最早:e[i\rightarrow j] = ve[i] \\ &最迟:l[i\rightarrow j] = vl[j] - length[i\rightarrow j] \end{aligned} \right.

  3. e[ij]=l[ij]e[i\rightarrow j] = l[i\rightarrow j] 就是关键活动

C++

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
bool topologicalSort() {
queue<int> q;
for (int i = 0; i < n; ++i) {
if (inDegree[i] == 0) q.push(i);
}
while (!q.empty()) {
int u = q.front();
q.pop();
topOrder.push(u);
for (int i = 0; i < adj[u].size(); i++) {
int v = adj[u][i].v;
inDegree[v]--;
if (inDegree[v] == 0) q.push(v);
if (ve[u] + adj[u][i].w > ve[v]) {
ve[v] = ve[u] + adj[u][i].w;
}
}
}
if (topOrder.size() == n) return true;
else return false;
}

int CriticalPath() {
memset(ve, 0, sizeof(ve));
if (!topologicalSort()) return -1;
// vl 初始化为汇点的 ve 值
fill(vl, vl + n, ve[n - 1]);

// topOrder 出栈就是逆拓扑序列
while (!topOrder.empty()) {
int u = topOrder.top();
topOrder.pop();
for (int i = 0; i < adj[u].size(); ++i) {
int v = adj[u][i].v;
if (vl[v] - adj[u][i].w < vl[u]) {
vl[u] = vl[v] - adj[u][i].w;
}
}
}

// 遍历邻接表所有的边,计算出 e、l
for (int u = 0; u < n; ++u) {
for (int i = 0; i < adj[u].size(); ++i) {
int v = adj[u][i].v, w = adj[u][i].w;
int e = ve[u], l = ve[v] - w;
if (e == l) {
printf("%d -> %d\n", u, v);
}
}
}

// 返回关键路径长度,即汇点的最早开始时间
return ve[n - 1];
}
  • 若不知道汇点,因为汇点的最早开始时间最大,所有 ve[] 数组中最大的就是汇点

动态规划

dp[i] 表示从 i 号位出发能获得的最长路径长度,这样 dp[] 中的最大值就是关键路径长度。

那么状态转移方程为:dp[i]=max(dp[j]+length[ij]  (i,j)E)dp[i] = max(dp[j]+length[i \rightarrow j]\ |\ (i, j) \in E)

  • 可以使用自底向上的求法,按照逆拓扑序列来求解 dp[]
  • 也可以自顶向下,递归地求解 dp[]
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
int DP(int i) {
if (dp[i] > 0) return dp[i];
for (int j = 0; j < n; ++j) {
if (G[i][j] != INF) {
int t = DP(j) + G[i][j];
if (t > dp[i]) {
dp[i] = t;
path[i] = j;
}
}
}
return dp[i];
}

// 调用 printPath 需要先获取最大的 dp[i] 的索引值 i
void printPath(int i) {
printf("%d", i);
while (path[i] != -1) {
i = path[i];
printf(" -> %d", i);
}
}

若固定终点,求 DAG 的最长路径:同样的 dp[] 定义,同样的状态转移方程:dp[i]=max(dp[j]+length[ij]  (i,j)E)dp[i] = max(dp[j]+length[i \rightarrow j]\ |\ (i, j) \in E)

但是,dp[] 初始化不同,因为固定了终点,所以只有 dp[T] = 0,而对其他的数值可以初始化为一个很小的负数,然后设置一个 vis[] 数组来记录是否访问过。

文章作者: EasonZzZz
文章链接: http://yoursite.com/2021/04/26/算法之旅/图/关键路径/
版权声明: 本博客所有文章除特别声明外,均采用 CC BY-NC-SA 4.0 许可协议。转载请注明来自 Nice To Meet U