有向无环图 ( 拓扑排序+关键路径 )

参考资料:

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
*/
View Code

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
*/
View Code

注意

  节点 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");
}
View Code

 

 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
*/
View Code

 

 

 

https://acm.sdut.edu.cn/onlinejudge3/problems/2498

 

 

============ ========= ======== ====== ===== ==== === == =

  咸阳城懂东楼    许浑

一上高城万里愁,蒹葭杨柳似汀洲。

溪云初起日沉阁,山雨欲来风满楼。

鸟下绿芜秦苑夕,蝉鸣黄叶汉宫秋。

行人莫问当年事,故国东来渭水流。

 

posted @ 2020-07-18 23:29  叫我妖道  阅读(2042)  评论(0编辑  收藏  举报
~~加载中~~