拓扑排序与关键路径
一、相关定义
1. DAG 图
有向无环图称为 (Directed Acyclic Graph) ,简称 DAG 图;
2. AOV 网
在一项大的工程中,可以将其看作是由若干个子工程的集合,这些子工程中存在先后顺序,即某些子工程必须在其他的一些工程完成后才开始;
使用有向图来表示子工程之间的先后关系,将子工程的先后关系建为有向边,形成一张以顶点表示活动,边表示活动之间的先后关系,的 DAG 图,则这样的图称为 AOV 网;
3. AOE 网
定义
用顶点表示事件,边为活动,边权表示活动的时间的 DAG 图为 AOE 网;
对于一个工程来说,只有一个开始状态与一个结束状态,因此在 AOE 网中,只有一个入度为 0 的节点表示工程的开始,即源点,也只有一个出度为 0 的节点表示工程的结束,即汇点;
性质
- 在顶点表示的事件发生之后,从该顶点出发的边上的时间才能开始;
- 在进入某个顶点的有向边所表示的活动完成之后,该顶点表示的事件才能发生;
应用
- 计算完成整个工程的最短工期;
- 确定关键路径,以找出影响工程进度的关键事件;
4. 活动
子工程组成的集合,每个字工程即为一个活动;
5. 前驱活动
有向边起点的活动称为其终点的前驱活动;
当一个活动的前驱全部都完成后,此活动才能进行;
6. 后继活动
有向边终点的活动称为其起点的后继活动;
二、 拓扑排序
1. 定义
将 AOV 网中所有活动排成一个序列,使得每个活动的前驱活动排在该活动的前面即为拓扑排序;
一张图进行拓扑排序后得到的序列被称为拓扑序列;
则一张 DAG 图中,会有多个拓扑序列;
2. DFS
思路
由于一个节点的前驱节点被遍历的时间一定小于其本身被遍历的时间,则可用该节点被 DFS 遍历的过程中的遍历时间来确定其在拓扑序列中的位置;
时间复杂度为 O ( V + E ) O(V + E) O(V+E) ;
实现
从源点开始, DFS 遍历图,在遍历完这个节点以及其的字节点后,将其放入栈中,最终栈底到栈顶的顺序即为一种拓扑序列;
若在最开始遍历节点就将其入栈,则在第一次遍历到汇点时,就会将汇点加入序列,则汇点的其他前驱在序列中在汇点的后面,所以应在节点及其子节点均被访问后再将其入栈;
再在遍历过程中,判断是否有环即可;
代码
int n, m, colour[MAXN];
vector <int> g[MAXN];
stack <int> s;
bool flag[MAXN];
bool dfs(int i) {
flag[i] = true;
colour[i] = 1;
for (int t = 0; t < g[i].size(); t++) {
int v = g[i][t];
if (colour[v] == 1) return false;
if (!flag[v]) {
if (!dfs(v)) return false;
}
}
s.push(i); // 节点以其子节点均遍历完后在将其加入栈中
colour[i] = 2;
return true;
}
int main() {
scanf("%d %d", &n, &m);
for (int i = 1; i <= m; i++) {
int x, y;
scanf("%d %d", &x, &y);
g[x].push_back(y);
}
for (int i = 1; i <= n; i++) {
if (!flag[i]) { // 遍历完整个图
if (!dfs(i)) { // 图不合法
printf("No");
return 0;
}
}
}
while (!s.empty()) {
printf("%d ", s.top()); // 栈顶到栈底的顺序为拓扑序列
s.pop();
}
return 0;
}
3. Kahn 算法
思路
使用 BFS ,由于拓扑排序每次选择活动时间最小的加入序列,则可以利用 BFS 遍历一层层遍历的特点,根据 BFS 遍历的层数得出拓扑序列;
实现
从第一层开始,每次搜索一层后将其删除,下一次继续搜索第一层,搜索到汇点即可得到拓扑序列;
Kahn 算法实现步骤如下,
- 选则入度为 0 的节点,将其放入拓扑序列;
- 从 AOV 网中删除此顶点以及以此节点为起点的所有关联边,即为删除当前所在的层;
- 重复上两步,直到搜索栈为空为止;
- 若输出的顶点数不等于 AOV 网中的顶点数,则说明此时还存在入度不为 0 的点,则原图中有环;
代码
int n, m, in[MAXN], path[MAXN], len;
vector <int> g[MAXN];
bool topo() {
stack <int> s;
int tot = 0; // 记录序列长度
for (int i = 1; i <= n; i++) {
if (in[i] == 0) {
s.push(i); // 入度为 0,入栈
}
}
while (!s.empty()) {
int x = s.top(); // 取栈顶元素
s.pop();
path[++tot] = x; // 存储当前点
for (int t = 0; t < g[x].size(); t++) {
int v = g[x][t];
in[v]--; // 将 x 点删除,其子节点的入度均 - 1
if (in[v] == 0) s.push(v); // 入度为 0,入栈
}
}
return tot == n ? true : false; // 判断图是否有环
}
int main() {
scanf("%d %d", &n, &m);
for (int i = 1; i <= m; i++) {
int x, y;
scanf("%d %d", &x, &y);
g[x].push_back(y);
in[y]++; // 统计入度
}
if (!topo()) printf("No"); // 图不合法
else {
for (int i = 1; i <= n; i++) {
printf("%d ", path[i]);
}
}
return 0;
}
顺序
若要求字典序最小或最大,则用优先队列替代栈即可,这样保证了遍历的字典序,则可确定最终序列的字典序;
三、关建路径
1. 定义
在 AOE 网上,从源点到汇点的长度最大的路径为关键路径,关键路径上的活动被称为关键活动;
由于汇点最终的完成时间即为关键路径的长度,则若关键活动完成时间受到影响,整个工期的完成事件也会受影响,所以;
关键活动为不按期完成就会影响整个工程的活动;
2. 特点
-
关键活动是决定工程工期的关键因素,可通过加快关键活动来缩短整个工程的工期,但关键活动时间一旦缩短到一个下线,则可能出现比该活动用时更多的活动,则该活动不再是关键活动;
-
AOE 网的关键路径不唯一,对于有多条关键路径的网,应加快所有关键路径上关键活动才能加快总工期;
3. 计算关建路径
思路
计算关键路径,需要定义 5 个关键量,其共同前提为保证总工期最短;
- v e [ i ] ve[i] ve[i] 表示事件 i i i 的最早发生时间;
- v l [ i ] vl[i] vl[i] 表示事件 i i i 的最晚发生时间;
- e e [ i ] ee[i] ee[i] 表示活动 i i i 的最早开始时间;
- e l [ i ] el[i] el[i] 表示活动 i i i 的最晚开始时间;
- 松弛时间,即为活动 i i i 的最早开始时间与其的最晚开始时间的差值,即 e l [ i ] − e e [ i ] el[i] - ee[i] el[i]−ee[i] ;
关键路径的长度即为汇点的 v e ve ve 值;
若求关键路径上的活动,步骤如下
1. 计算 v e ve ve
v e [ k ] ve[k] ve[k] 即为从源点开始到顶点 k k k 的最大路径长度;
则从源点开始,按照拓扑排序规则向下递归,
v
e
[
k
]
=
max
{
v
e
[
j
]
+
l
e
n
[
j
,
k
]
}
(
[
j
,
k
]
∈
e
d
g
e
[
k
]
)
ve[k] = \max \{ ve[j] + len[j, k] \} ([j, k] \in edge[k])
ve[k]=max{ve[j]+len[j,k]}([j,k]∈edge[k])
其中,
l
e
n
[
j
,
k
]
len[j, k]
len[j,k] 为边
[
j
,
k
]
[j, k]
[j,k] 的权值,
e
d
g
e
[
k
]
edge[k]
edge[k] 为所有到达顶点
k
k
k 的有向边集合;
源点的 v e ve ve 值为 0 ;
代码如下;
void build_ve() { // 按照拓扑规则
stack <int> s1;
for (int i = 1; i <= n; i++) {
if (in1[i] == 0) {
ve[i] = 0;
s1.push(i);
}
}
while (!s1.empty()) {
int x = s1.top();
s1.pop();
for (int t = 0; t < g1[x].size(); t++) {
int v = g1[x][t].to, tot = g1[x][t].tot;
in1[v]--;
ve[v] = max(ve[v], ve[x] + tot); // 递推求解
if (in1[v] == 0) {
s1.push(v);
}
}
}
return;
}
2. 计算 v l vl vl
v l [ k ] vl[k] vl[k] 即为从汇点开始到顶点 k k k 的最大路径长度;
则从汇点开始,按照逆拓扑排序规则向下递归,
v
l
[
k
]
=
min
{
v
l
[
j
]
−
l
e
n
[
j
,
k
]
}
(
[
j
,
k
]
∈
e
d
g
e
[
k
]
)
vl[k] = \min \{ vl[j] - len[j, k] \} ([j, k] \in edge[k])
vl[k]=min{vl[j]−len[j,k]}([j,k]∈edge[k])
其中,
l
e
n
[
j
,
k
]
len[j, k]
len[j,k] 为边
[
j
,
k
]
[j, k]
[j,k] 的权值,
e
d
g
e
[
k
]
edge[k]
edge[k] 为所有到达顶点
k
k
k 的有向边集合;
汇点的 v e ve ve 值为其的 v e ve ve 值;
代码如下;
void build_vl() { // 逆拓扑规则,反向建边再进行拓扑
memset(vl, 127, sizeof(vl));
stack <int> s2;
for (int i = 1; i <= n; i++) {
if (in2[i] == 0) {
vl[i] = ve[i]; // 初始化
s2.push(i);
}
}
while (!s2.empty()) {
int x = s2.top();
s2.pop();
for (int t = 0; t < g2[x].size(); t++) {
int v = g2[x][t].to, tot = g2[x][t].tot;
in2[v]--;
vl[v] = min(vl[v], vl[x] - tot); // 递归求解
if (in2[v] == 0) {
s2.push(v);
}
}
}
return;
}
3.计算 e e ee ee
若活动 i i i 为边 [ j , k ] [j, k] [j,k] ,则只有在上一个事件完成后, i i i 才能开始;
由于求最早,所以
e
e
[
i
]
ee[i]
ee[i] 即为其的上一事件最早完成时间;
e
e
[
i
]
=
v
e
[
j
]
ee[i] = ve[j]
ee[i]=ve[j]
代码如下;
void build_ee() {
for (int i = 1; i <= m; i++) {
int j = e[i].x, k = e[i].y, tot = e[i].tot;
ee[i] = ve[j];
}
return;
}
4.计算 e l el el
若活动 i i i 为边 [ j , k ] [j, k] [j,k] ,则 i i i 的最晚开始时间应保证完成此事件后时间不会大于 k k k 的最晚开始时间;
则,
e
l
[
i
]
el[i]
el[i] 即为其的下一事件最晚开始时间减去其本身所需时间;
e
l
[
i
]
=
v
l
[
k
]
−
l
e
n
[
j
,
k
]
el[i] = vl[k] - len[j, k]
el[i]=vl[k]−len[j,k]
代码如下;
void build_el() {
for (int i = 1; i <= m; i++) {
int j = e[i].x, k = e[i].y, tot = e[i].tot;
el[i] = vl[k] - tot;
}
return;
}
5.找出关键活动
当松弛时间为 0 时,则说明当前活动的结束时间会直接影响到总工期的完成时间,则当前活动为关键活动;
证明如下
设有 4 个事件 v 1 , v 2 , v 3 , v 4 v_1, v_2, v_3, v_4 v1,v2,v3,v4 ,有 4 个活动 E 1 , E 2 , E 3 , E 4 E_1, E_2, E_3, E_4 E1,E2,E3,E4 ,分别的时间为 x , a , y , b x, a, y, b x,a,y,b , E 2 → E 4 E_2 \rarr E_4 E2→E4 为关键路径;
假设现在非关键路径上活动 E 1 E_1 E1 的松弛时间为 0 ;
则有
{ a + b > x + y a + b − y − x = 0 \begin{cases} a + b > x + y \\ a + b - y - x = 0 \\ \end{cases} {a+b>x+ya+b−y−x=0
整理得
{ a + b − y − x > 0 a + b − y − x = 0 \begin{cases} a + b - y - x > 0 \\ a + b - y - x = 0 \\ \end{cases} {a+b−y−x>0a+b−y−x=0
原式无解,则假设不成立;
所以不按期完成就会影响整个工程的活动为且仅为关键活动;
证明成立;
代码
#include <cstdio>
#include <vector>
#include <stack>
#include <cstring>
#include <algorithm>
#define MAXN 10005
#define INF 2147483647
using namespace std;
int n, m, in1[MAXN], in2[MAXN], ve[MAXN], vl[MAXN], ee[MAXN], el[MAXN], ans = 0, path[MAXN], ans1;
struct graphy {
int to, tot;
};
vector <graphy> g1[MAXN], g2[MAXN];
struct edge {
int x, y, tot;
} e[MAXN];
void build_ve() { // 按照拓扑规则
stack <int> s1;
int tot = 0;
for (int i = 1; i <= n; i++) {
if (in1[i] == 0) {
ve[i] = 0; //
s1.push(i);
}
}
while (!s1.empty()) {
int x = s1.top();
s1.pop();
path[++tot] = x;
for (int t = 0; t < g1[x].size(); t++) {
int v = g1[x][t].to, tot = g1[x][t].tot;
in1[v]--;
ve[v] = max(ve[v], ve[x] + tot); // 递归求解
if (in1[v] == 0) {
s1.push(v);
}
}
}
return;
}
void build_vl() { // 逆拓扑规则,反向建边再进行拓扑
memset(vl, 127, sizeof(vl));
stack <int> s2;
for (int i = 1; i <= n; i++) {
if (in2[i] == 0) {
vl[i] = ve[i]; // 初始化
s2.push(i);
}
}
while (!s2.empty()) {
int x = s2.top();
s2.pop();
for (int t = 0; t < g2[x].size(); t++) {
int v = g2[x][t].to, tot = g2[x][t].tot;
in2[v]--;
vl[v] = min(vl[v], vl[x] - tot); // 递归求解
if (in2[v] == 0) {
s2.push(v);
}
}
}
return;
}
void build_ee() {
for (int i = 1; i <= m; i++) {
int j = e[i].x, k = e[i].y, tot = e[i].tot;
ee[i] = ve[j];
}
return;
}
void build_el() {
for (int i = 1; i <= m; i++) {
int j = e[i].x, k = e[i].y, tot = e[i].tot;
el[i] = vl[k] - tot;
}
return;
}
int main() {
scanf("%d %d", &n, &m);
for (int i = 1; i <= m; i++) {
int x, y, z;
scanf("%d %d %d", &x, &y, &z);
g1[x].push_back( graphy ( {y, z} ) );
g2[y].push_back( graphy ( {x, z} ) );
e[i].x = x, e[i].y = y, e[i].tot = z;
in1[y]++, in2[x]++;
}
build_ve();
build_vl();
build_ee();
build_el();
for (int i = 1; i <= m; i++) {
if (ee[i] == el[i]) {
printf("%d ", i);
}
}
printf("%d", ans + 1);
return 0;
}