有向无环图 ( 拓扑排序+关键路径 )
参考资料:
https://www.bilibili.com/video/BV1Ut41197TE?from=search&seid=17921312669232031384
《2022年 数据结构考研复习指导》王道论坛
一,有向无环图
1, 定义:
无环的有向图,简称 DAG(Directed Acyclic Graph)
2,应用
通常将计划,施工,生产,程序流程等当成一个工程,一个工程可以分为若干子工程,只要完成这些子工程(活动),就可以完成整体工程。而 DAG 可以用来描述这些工程
3,分类
DAG 的两种表示方法
① AOV 网 (activity on vertex network)
用一个有向图表示工程的各子工程及其相互制约的关系,其中顶点表示活动,有向边表示活动之间的优先制约关系,则称这种有向图为顶点表示活动的网,简称 AOV
应用:拓扑排序
② AOE 网 (activity on edge network)
在带权有向图中,以有向边表示活动,边上的权值表示该活动的持续时间,顶点表示事件,则称这种有向图为边表示活动的网,简称 AOE
应用:关键路径
4,关于事件与活动的理解
事件是工程的完成进度;活动是完成工程的步骤。
如果把炒菜当成一个工程的话,那么我们可以将其分为两个活动和三个事件:开始 —洗菜—> 洗菜完成 —炒菜—> 炒菜完成。
其中,洗菜可以再细分为洗花菜和洗空心菜两个活动,只有当两个活动都完成完,洗菜完成的事件才算完成
而且,设洗花菜时间 t1,洗空心菜时间 t2,炒菜时间 t3,若 t1 > t2,则
t1 为事件洗菜完成的最早发生时间,t1 + t3 为整个工程的最短工期
二,拓扑排序
1,拓扑序列
在 AOV 网中,我们将全部活动排成一个线性序列,使得 AOV 网中有向边 <i,j> 存在,则在这个序列中,i 一定在 j 的前面。具有这种性质的线性序列称为拓扑序列,相应的算法称为拓扑排序
2,步骤
① 在有向图中选择一个没有任何后继的顶点(入度为 0 的点),然后输出它
② 从图中删除该顶点及所有以它为直接后继的有向边。
③ 重复上述两步,直至全部顶点均输出或者图中不存在无前驱的顶点为止(注意:输出的即为拓扑序列,且拓扑序列不唯一)
3,延伸应用
检查 AOV 网是否存在环:
对于有向图构造拓扑序列,若网中所有顶点都在他的拓扑序列中,则该 AOV 网中必定不存在环
4,逆拓扑排序
① 定义
Ⅰ 在有向图中选择一个没有任何前驱的顶点(出度为 0 的点),然后输出它
Ⅱ 从图中删除该顶点及所有以它为直接前驱的有向边。
Ⅲ 重复上述两步,直至全部顶点均输出或者图中不存在无后继的顶点为止
② 拓扑序列的逆序序列即为逆拓扑序列
所以可以在求拓扑序列时,用栈储存,这样输出的时候就是逆拓扑序列
三,代码实现
1, 存图的数据结构
用 vector<int >v[N] 作为邻接表
其中,向量 v[i] 里存的是点 i 的邻接点。
2, 算法思想
除了一开始就入度为 0 的点,其余可能会出现入度为 0 的点只在删除点时产生
所以利用这点,可以用栈或者队列实现拓扑排序。这里说栈和队列都可以的原因是:存入栈或者队列的元素是入度为 0 的点,而它们是并列的关系,先后顺序并不影响其正确性
3, 步骤
① 将入度为 0 的点入队列
② 队首结点出队,将该节点的邻接点的入度减1,若这些邻接点入度减 1 后入度为 0 的结点入队
③ 循环 2 至队列为空,则出队顺序即为拓扑序列
4, 结果分析
① 若出队元素个数小于图的顶点数,则存在环
② 若队列在某次添加结点后,队内元素个数 > 1,则拓扑序列不唯一;否则,拓扑序列唯一
5,代码
#define _CRT_SECURE_NO_WARNINGS #include<stdio.h> #include<stdlib.h> #include<string.h> #include<vector> #include<queue> using namespace std; #define N 100+5 vector<int>v[N]; // 邻接表 int in[N]; // 记录入度 int way[N]; // 存放拓扑序列 void TopoSort(int n) { queue<int>q; for (int i = 1; i <= n; i++) // 将 入度为 0 的顶点入队列 if (in[i] == 0) q.push(i); int f = 0, cnt = 0; while (q.size()) { if (q.size() > 1) // 判断拓扑序列是否唯一 f = 1; int vertex = q.front(); q.pop(); way[cnt++] = vertex; // 记录拓扑序列 for (int i = 0; i < v[vertex].size(); i++) // 遍历 vertex 的邻接点 { int next = v[vertex][i]; if (--in[next] == 0) // 将其入度为0 的邻接点入队列 q.push(next); } } if (cnt != n) printf("存在环:"); else if (f == 0) printf("拓扑序列唯一:"); else if (f == 1) printf("拓扑序列不唯一:"); for (int i = 0; i < cnt; i++) printf("%d ", way[i]); puts(""); } int main(void) { int n, m; // 顶点数 和 边数 while (scanf("%d%d", &n, &m) != EOF) { memset(in, 0, sizeof(in)); for (int i = 0; i < n; i++) v[i].clear(); for (int i = 0; i < m; i++) { int x, y; scanf("%d%d", &x, &y); v[x].push_back(y); in[y]++; } TopoSort(n); } system("pause"); return 0; } /* 按顺序为:唯一,不唯一,存在环 4 3 1 2 2 3 3 4 5 4 1 2 2 3 3 4 2 5 4 4 1 2 2 3 3 4 3 2 */
6,代码理解(纯个人理解,不保证正确)
在尝试区分 TopoSort 与 BFS 的时候,就想到了,BFS遍历的是一个树,TS遍历的是一张图,图中可能有多个树。所以TS实质上就是一次同时进行多个BFS,以遍历图的一种算法
据此可以推算出TS中要计算入度为 0 的目的,其实就是为了等其它线路的 BFS,这样才不会导致某条先到某个点的 BFS 路线吃独食。这里的吃独食指先到该点的BFS路线删除到该点,再接着遍历,导致别的路线的BFS创业未半而中道崩殂,遍历不下去了
所以,计算入度为 0 的点是 TS 能够进行多个 BFS 的保证
7,解题关键字
① 有多个起点
② DAG
四,关键路径
1,定义
在 AOE 网中,只有所有活动都完成了,整个工程才能算结束。因此,从源点到汇点的所有路径中,具有最大路径长度的路径称为关键路径,其上的活动称为关键活动
2,性质
完成整个工程的最短时间就是关键路径的长度。反证法易得
3,相关概念
① 事件 Vk 的最早发生时间 ve(k)
Ⅰ 定义
从源点到 Vk 的最长路径长度
Ⅱ 性质
ve(k) 决定了所有从 Vk 开始的活动能够开工的最早时间
Ⅲ 应用
其中,最大的 ve(k) 就是关键路径的长度,所以可以用来求整个工程的最短工期
Ⅳ 计算
初始时,令 ve[] = 0
遍历拓扑序列
用当前入度为 0 的点 vertex,更新 ve(next)
ve(next) = max( ve(next), ve(vertex) + dis(vertex, next) )
② 事件 Vk 的最迟发生时间 vl(k)
Ⅰ 定义
最短工期 - 从汇点到 Vk 的最长路径长度
Ⅱ 性质
vl(k) 决定了在不推迟最短工期的前提下,所有从 Vk 开始的活动能够开工的最迟时间
处在关键路径上的事件的 ve(k) == vl(k)
Ⅲ 计算
初始时,令 vl[] = 最短工期
遍历逆拓扑序列
用当前出度为 0 的点 vertex 的后继顶点 next, 更新 vl(vertex)
vl(vertex) = min( vl(vertex), vl(next) - dis(vertex, next) )
③ 活动 Ai 的最早开始时间 e(i)
Ⅰ 定义
活动弧的起点所代表的事件的最早发生时间
Ⅱ 计算
若 <vertex, next> 表示活动 Ai,则有 e(i) = ve(vertex)
④ 活动 Ai 的最迟开始时间 l(i)
Ⅰ 定义
活动弧的终点所代表的事件的最迟发生时间 - 该活动所需时间
Ⅱ 计算
若 <vertex, next> 表示活动 Ai,则有 l(i) = vl(next) - dis(vertex, next)
⑤ 时间余量 d(i) = l(i) - e(i)
Ⅰ含义
在不影响整个工程的工期的情况下,Ai 可以在 e(i) 到 l(i) 之间任意时间开始
所以 d(i) 代表:
在不推迟最短工期的前提下,Ai 可以拖延的最长时间
Ⅱ 性质
当 d(i) == 0 时
代表 Ai 必须要一可以开始就马上开始,否则会拖延整个工程的时间。此时, Ai 是关键活动
4,代码实现
#define _CRT_SECURE_NO_WARNINGS #include<stdio.h> #include<stdlib.h> #include<string.h> #include<vector> #include<queue> #include<stack> #include<set> #include<algorithm> using namespace std; #define inf 0x3f3f3f3f #define N 1000+5 typedef struct Node{ int to, w; }st; vector<st>v[N]; // 邻接表 stack<int>tp; // 拓扑序列 int in[N]; // 入度 int ve[N]; // 点 i 的最早开始时间 int vl[N]; // 点 i 的最迟开始时间 int topoSort(int n) // 求最短工期和拓扑序列 { queue<int>q; for (int i = 1; i <= n; i++) if (in[i] == 0) q.push(i); int rs = -1; // 最短工期 while (q.size()) { int vertex = q.front(); q.pop(); tp.push(vertex); rs = max(rs, ve[vertex]); // 最大的 ve 即最短工期 for (int i = 0; i < (int)v[vertex].size(); i++) { int next = v[vertex][i].to; ve[next] = max(ve[next], ve[vertex] + v[vertex][i].w); // 根据拓扑序列更新 ve if (--in[next] == 0) q.push(next); } } if ((int)tp.size() == n) return rs; return -1; } struct cmp { bool operator() (const st &p1, const st &p2) const { return (p1.to < p2.to); } }; int ctiticalPath(int n) { // 求拓扑序列 和 ve int rs = topoSort(n); // 最短工期 printf("%d\n", rs); if (rs == -1) return -1; // 求 vl fill(vl + 1, vl + n + 1, rs); while ((int)tp.size()) { int vertex = tp.top(); tp.pop(); for (int i = 0; i < (int)v[vertex].size(); i++) { int next = v[vertex][i].to; vl[vertex] = min(vl[vertex], vl[next] - v[vertex][i].w); } } // 遍历所有边,找到关键活动,输出字典序最小的 for (int i = 1; i <= n;) { set<st, cmp>s; // 点 i 的邻接点, 排序 for (int j = 0; j < (int)v[i].size(); j++) s.insert(v[i][j]); for (auto it = s.begin(); it != s.end(); it++) { st vi = *it; int next = vi.to, w = vi.w; int e = ve[i], l = vl[next] - w; // <i, next> 对应的活动的 ve/vl if (e == l) { printf("%d %d\n", i, next); i = next; break; } else i++; } } } void init(int n) // 数据初始化 { while (!tp.empty()) tp.pop(); for (int i = 0; i <= n; i++) v[i].clear(); memset(ve, 0, sizeof(ve)); memset(in, 0, sizeof(in)); } int main(void) { int n, m; while (scanf("%d%d", &n, &m) != EOF) { init(n); for (int i = 0; i < m; i++) { int x, y, w; scanf("%d%d%d", &x, &y, &w); v[x].push_back(st{ y, w }); in[y]++; } ctiticalPath(n); } system("pause"); return 0; } /* 测试数据: 9 11 1 2 6 1 3 4 1 4 5 2 5 1 3 5 1 4 6 2 5 7 9 5 8 7 6 8 4 8 9 4 7 9 2 答案: 18 1 2 2 5 5 7 7 9 */
注意
节点 k 在拓扑排序时出队列,代表事件 Vk 的所有前置活动已经完成,代表此时 ve(k) 已经更新完毕,成为事件 Vk 的最早发生时间。所以选择在出队列的时候比较求最大的 ve(k)
五,例题
1,求所有路径中最长的路径
(1)链接:剑指 Offer II 112. 最长递增路径 - 力扣(LeetCode) (leetcode-cn.com)
(2)题解
① 一个 n * m 的矩阵的所有递增路径就是有 n * m 个点的有向无环图,其中,递增路径决定图必然是无环的,而且这样的图是存在多个起点的。DAG + 多个起点,这正是 TS 算法的关键字
② BFS 可以根据出队列的优先顺序求出某个点到其它点的最短路径,这是由于 bfs 每次入队列的点都是等效的,且点都是由近到远遍历的。可以形象的理解为 bfs 是一层一层的波纹在蔓延出去,其中波纹最先触及的地方就是初始点到该地方的最短路径。而如果我们把 bfs 的每一层波纹中的点记录下来,理解为该点比上一层波纹中的点多走了一步路,那么比较所有点就可以求得最长路径了
③ 那么,要如何记录属于同一层波纹的点 ?
使用两个队列,第一个队列存当前层波纹的点,第二个队列存第一个队列搜索出来的下一层波纹的点。然后,使用第一个队列搜索,第二个队列记录,这样就把不同层的点区分出来了
#define _CRT_SECURE_NO_WARNINGS #include<stdio.h> #include<stdlib.h> #include<vector> #include<queue> using namespace std; #define N 40000+5 vector<int>v[N]; int in[N]; int ans[N]; int dy[] = { 0, 1, 0, -1 }, dx[] = { -1, 0, 1, 0 }; int longestIncreasingPath(vector<vector<int>>& matrix) { int n = matrix.size(), m = matrix[0].size(); // 计算入度,转为邻接表 for (int i = 0; i < n; i++) { for (int j = 0; j < m; j++) { for (int k = 0; k < 4; k++) { int x = i + dx[k], y = j + dy[k]; if (x >= 0 && x < n&&y >= 0 && y < m && matrix[i][j] < matrix[x][y]) { in[y + x*m]++; v[j + i*m].push_back(y + x*m); } } } } queue<int>q1, q2; for (int i = 0; i < n*m; i++) if (in[i] == 0) q1.push(i); int cnt = 0; // 计算 bfs 的最长迭代次数 while (q1.size()) { q2 = q1; while (q2.size()) { int vertex = q2.front(); q2.pop(); q1.pop(); for (int i = 0; i < v[vertex].size(); i++) { int next = v[vertex][i]; if (--in[next] == 0) q1.push(next); } } cnt++; } return cnt; } int main(void) { //vector<vector<int>> a = { { 9,9,4 },{6,6,8}, { 2,1,1 } }; vector<vector<int>> a = { { 5, 8, 7, 8, 5 } }; printf("%d\n", longestIncreasingPath(a)); system("pause"); }
2,最短工期
(1)链接:http://acm.hdu.edu.cn/showproblem.php?pid=4109
(2)题解:完成所有指令的最短时间为最短工期 + 最后一个活动所需的时间,这里最后一个活动 (在题中指的是指令) 所需要的时间为 1。所以求最短工期,输出时加 1 即可
#define _CRT_SECURE_NO_WARNINGS #include<stdio.h> #include<stdlib.h> #include<string.h> #include<vector> #include<queue> #include<algorithm> using namespace std; #define N 110 typedef struct Node { int to, w; }st; vector<st>v[N]; int in[N], ve[N], way[N]; void topoSort(int n) { queue<int>q; for (int i = 0; i < n; i++) if (in[i] == 0) q.push(i); int rs = 0, cnt = 0; while (q.size()) { int vertex = q.front(); q.pop(); cnt++; rs = max(rs, ve[vertex]); for (int i = 0; i < v[vertex].size(); i++) { int next = v[vertex][i].to; ve[next] = max(ve[next], ve[vertex] + v[vertex][i].w); if (--in[next] == 0) q.push(next); } } if (cnt != n) puts("Impossible"); else printf("%d\n", rs); } int main(void) { int n, m; while (scanf("%d%d", &n, &m) != EOF) { for (int i = 0; i <= n; i++) v[i].clear(); memset(in, 0, sizeof(in)); memset(ve, 0, sizeof(ve)); for (int i = 0; i < m; i++) { int x, y, w; scanf("%d%d%d", &x, &y, &w); v[x].push_back(st{ y, w }); in[y]++; } topoSort(n); } system("pause"); return 0; } /* 测试数据: 9 12 0 1 6 0 2 4 0 3 5 1 4 1 2 4 1 3 5 2 5 4 0 4 6 9 4 7 7 5 7 4 6 8 2 7 8 4 答案: 18 测试数据: 4 5 0 1 1 0 2 2 2 1 3 1 3 4 3 2 5 答案: Impossible */
https://acm.sdut.edu.cn/onlinejudge3/problems/2498
============ ========= ======== ====== ===== ==== === == =
一上高城万里愁,蒹葭杨柳似汀洲。
溪云初起日沉阁,山雨欲来风满楼。
鸟下绿芜秦苑夕,蝉鸣黄叶汉宫秋。
行人莫问当年事,故国东来渭水流。