12.图(下)

(4).生成树和最小生成树

#1.什么是生成树和最小生成树?

i.生成树

  G是一个连通无向图,若G’是包含G中所有顶点的一个无回路的连通子图,则称G’为G的一棵生成树,其实还比较简单

  生成树实际上就是某一张图的一种遍历结果,它保证了生成的子图一定是一棵树,因此对于一个具有n个顶点的无向图G其生成树G’一定恰好有n-1条边,在生成树G’中任意添加一条边,则会形成一个回路

  通过DFS得到的生成树叫做深度优先生成树,而BFS得到的生成树则叫做广度优先生成树,连通无向图的生成树不是唯一的,例如对于这个图:
p47
  它的以1为起点的深度优先生成树如下
p48
  不过即便是限定了以1为起点,这个无向连通图的深度优先生成树仍然不是唯一的,例如我们很明显可以看到1→4→2→5→8也是一条合法的深度优先搜索路径,这里只是举出了一种例子,而以1为起点的广度优先搜索树如下
p49
  如果我们认为生成树是无序树,那么以某个点为起点的广度优先生成树是唯一的

ii.最小生成树

  如果我们给图的每条边赋上权重,那么这个图就变成了有权无向图,这时候我们希望求出一个生成树,使得其所有路径的权重和最小,这就是最小生成树(Minimum Spanning Tree, MST),也叫作最小代价生成树

  这个问题其实不难解,因为点到点之间只有连通的情况下才能走,所以这个时候我们可以用一个贪心的思路来解决问题,例如每次选取最小的边,再在后续依次合并,我们有两个比较经典的求最小生成树的方法,分别是:从一点开始慢慢成长成一棵大树的Prim算法不断取最小形成森林再逐渐合并形成一棵大树的Kruskal算法

#2.Prim算法

i.算法思想

  带权连通无向图G=(V,E),设U为V中构成最小生成树的顶点集合,初始时 U = { u 0 } U=\{u_0\} U={u0},算法的流程如下:

  • 从G的某一顶点 u 0 u_0 u0出发,选择与它关联的具有最小权值的边 ( u 0 , v ) (u_0,v) (u0,v),将其顶点加入集合 U U U
  • 以后每一步从一个顶点在U中,而另一个顶点不再U中的各条边选择权值最小的边(u, v),把它的顶点加入到集合U中,直到U=V为止
ii.看看例子

  好像听懂了,又好像没听懂,我们来看看这个例子:
p50
  在这里我们以1作为起始结点,这时候 U = { 1 } U=\{1\} U={1},我们选择最短路径1和对应的顶点加入U,则选择3加入U,此时 U = { 1 , 3 } U=\{1,3\} U={1,3}
p51
  这时候再选择1和3能选到的最小权边,这里选择4,将顶点6放入:
p52
  再选择最小的边(4, 2),此时将顶点4放入, U = { 1 , 3 , 6 , 4 } U=\{1, 3, 6, 4\} U={1,3,6,4}
p53
  然后选取最短的能够增加连接性的边(2, 5),此时将顶点2放入, U = { 1 , 2 , 4 , 5 , 6 } U=\{1, 2, 4, 5, 6\} U={1,2,4,5,6}
p54
  最后把最后一条边权为3的边放入U,此时得到 U = { 1 , 2 , 3 , 4 , 5 , 6 } U=\{1,2,3,4,5,6\} U={1,2,3,4,5,6}
p55
  此时,最小生成树已经生成完毕,所以此时我们会发现,实际上树的性质保证了从任何一个结点开始,这个结构仍然是一棵树,所以,Prim算法可以随便从任何一个结点开始取,到最后都能生成同一棵最小生成树

iii.代码实现

  下面给出的是一个基于邻接表的,利用优先队列优化的Prim算法

#include <cstring>
#include <iostream>
#include <queue>
using namespace std;
const int N = 5050, M = 2e5 + 10;

struct E 
{
  int v, w, x;
} e[M * 2];

int n, m, h[N], cnte;

void adde(int u, int v, int w) 
{
    e[++cnte] = E{v, w, h[u]};
    h[u] = cnte; 
}

struct S 
{
    int u, d;
    bool operator<(const S& s1) const
    {
        return d > s1.d;
    }
};

priority_queue<S> q;
int dis[N];
bool vis[N];

int res = 0, cnt = 0;

void Prim() 
{
    memset(dis, 0x3f, sizeof(dis));
    dis[1] = 0;
    q.push({1, 0});
    while (!q.empty()) {
        if (cnt >= n) break;
        int u = q.top().u, d = q.top().d;
        q.pop();
        if (vis[u]) continue;
        vis[u] = 1;
        ++cnt;
        res += d;
        for (int i = h[u]; i; i = e[i].x) {
            int v = e[i].v, w = e[i].w;
            if (w < dis[v]) {
                dis[v] = w, q.push({v, w});
            }
        }
    }
}

int main() 
{
    cin >> n >> m;
    for (int i = 1, u, v, w; i <= m; ++i) {
        cin >> u >> v >> w;
        adde(u, v, w);
        adde(v, u, w);
    }
    Prim();
    if (cnt == n)
        cout << res;
    else
        cout << "No MST.";
    return 0;
}

  你可以试着理解一下优化后的版本,下面是一个对于邻接矩阵的版本:

void prim(int cost[][MAXN], int n, int u)
{
    int lowcost[MAXN], min;
    int closest[MAXN];
    int i, j, k;
    for (i = 1; i <= n; i++) {
        lowcost[i] = cost[u][i];
        closest[i] = u;
    }
    for (i = 1; i < n; i++) {
        min = MAXINT;
        for (j = 1; j <= n; j++) {
            if (lowcost[j] != 0 && lowcost[j] < min) {
                min = lowcost[j];
                k = j;
            }
            printf("{%3d%3d%3d%5d"} ", closest[k], k, min);
            lowcost[k] = 0;
            for (j = 1; j <= n; j++) {
                if (cost[k][j] != 0 && cost[k][j] < lowcost[j]) {
                    lowcost[j] = cost[k][j];
                    closest[j] = k;
                }
            }
        }
    }
}

#3.Kruskal算法

i.算法思想

  Kruskal算法的思想更加直观一点:

  • 初始时, T = ( V , Φ ) T=(V,\Phi) T=(V,Φ),即T由n个连通分量组成,每个连通分量只有一个顶点,没有边
  • 把边的权值按照从小到大排序,依次进行如下选择:若当前被选择的边的两个顶点在不同的连通分量中,则把这条边加到T中(即每次选权最小且不构成回路的一条边加入T),直到T中有n-1条边为止

  Kruskal的过程强烈依靠并查集来实现,它会判断候选边的两个顶点是否在不同的集合,如果在同一个集合,放弃这条边否则就选择这条边,同时合并两个集合

ii.看看例子

  首先选取最小的一条边1:
p56
  然后再选取最小的一条边2:
p57
  之后再选择3:
p58
  再选择最小的一条边4:
p59
  这一次需要从所有边权为5的边中选一条,只有(2, 3, 5)这条边满足两个顶点在不同的集合中,因此我们选取这一条,至此整个最小生成树就生成完毕了:
p60

iii.代码实现
#include <iostream>
#include <queue>
#include <vector>
#include <algorithm>
using namespace std;
constexpr int eN = 2e5+50; // 边
constexpr int nN = 5e3+20; // 点

struct edge
{
    int beg;
    int end;
    int w;
    bool operator<(const edge& e) const
    {
        return w < e.w;
    }
};

edge g[eN];
vector<int> v(nN, -1);
int mst{ 0 };

int Find(vector<int>& vec, int x)
{
    if (vec[x] < 0) return x;
    return Find(vec, vec[x]);
}

void Union(vector<int>& vec, int x, int y)
{
    int px{ Find(vec, x) }, py{ Find(vec, y) };
    if (px != py) {
        vec[px] += vec[py];
        vec[py] = px;
    }
}

void Kruskal(int N, int M)
{
    sort(g + 1, g + 1 + M);
    for (int i = 1; i <= M; i++) {
        int px{ Find(v, g[i].beg) }, py{ Find(v, g[i].end) };
        if (px != py) {
            Union(v, px, py);
            mst += g[i].w;
        }
    }
}

int main()
{
    int N{ 0 }, M{ 0 };
    cin >> N >> M;
    for (int i = 1; i <= M; i++) {
        int st{ 0 }, end{ 0 }, w{ 0 };
        cin >> st >> end >> w;
        g[i] = { st, end, w };
    }
    Kruskal(N, M);
    int root{ Find(v, 1) };
    for (int i = 2; i <= N; i++) {
        if (Find(v, i) != root) {
            mst = -1;
            break;
        }
    }
    cout << mst << endl;
    return 0;
}

  简单的算法,对吧?

#4.次小生成树

  次小生成树采取了一个试探的方式,对于当前已经生成的最小生成树去尝试换用别的边:

#include <iostream>
#include <algorithm>
using namespace std;

constexpr int INF = 0x3f3f3f3f;
constexpr int N = 505;
int G[N][N]{ {0} }, used[N][N]{ {0} }, path[N][N]{ {0} };
int pre[N]{ 0 }, dis[N]{ 0 };
bool vis[N]{ false };
int m, n;

void init() 
{
    for (int i = 1; i <= n; i++) {
        vis[i] = false;
        for (int j = 1; j <= n; j++)
            G[i][j] = INF;
    }
}

int Prim(int st) 
{
    int sum = 0, Min;
    for (int i = 1; i <= n; i++) {
        dis[i] = G[st][i];
        pre[i] = st;
    }
    dis[st] = 0;
    vis[st] = 1;
    for (int i = 1; i < n; i++) {
        Min = INF;
        for (int j = 1; j <= n; j++) {
            if (!vis[j] && Min > dis[j]) {
                Min = dis[j];
                st = j;
            }
        }
        vis[st] = 1;
        sum += Min;
        used[st][pre[st]] = used[pre[st]][st] = 1;
        for (int j = 1; j <= n; j++) {
            if (vis[j] && j != st)
                path[j][st] = path[st][j] = max(path[j][pre[st]], dis[st]);
            if (!vis[j] && dis[j] > G[st][j]) {
                dis[j] = G[st][j];
                pre[j] = st;
            }
        }
    }
    return sum;
}

int sec_Prim(int tmp) 
{
    int sum{ INF };
    for (int i = 1; i <= n; i++) {
        for (int j = 1; j <= n; j++) {
            if (i != j && !used[i][j])
                sum = min(sum, tmp + G[i][j] - path[i][j]);
        }
    }
    return sum;
}

  通过这个方式可以在连通的情况下求出另外一个最小生成树,这个次小生成树可能比最小生成树更大,也可能和当前的最小生成树相同

(5).最短路径问题

#1.加权有向图的最短路径问题

  从学校出发,到最近的餐厅,要走多少路呢?作为一个懒人,我肯定会选择走更短的路,所以我们可以把这个问题抽象一下:把地图上的每个地标作为顶点,选择两个地标间的最短路径长度为对应的边权重,于是求到更加远的,不能直达的两点的最短路径,就是我们希望解决的问题了,而这也就是加权有向图的最短路径问题,对于一个无向图,其实事情也是一样的,我们可以认为它是一个A到B和B到A的边权相同的图

#2.单源最短路径问题—Dijkstra算法

  Dijkstra是个很厉害的人,从他的名字就可以看出来:他的名字里居然同时有i,j,k三个连续字母! Dijkstra算法用于求单源无负权边图的最短路径,也就是求得从一个点开始,到全局所有其他点的最短路径,但是要注意的是,Dijkstra不能应对负权边的问题,这个问题在Bellman-Ford算法有了解决

i.基本实现方法

  Dijkstra的算法是这样的:

  • S = { v } S=\{v\} S={v},即从顶点v出发
  • 按以下步骤逐个求得从v到其他顶点的最短路径,直到把所有顶点的最短路径求出:
    • a. 选取不在S中且具有最小距离的顶点K
    • b.把K加入集合S
    • c.修改不再S中的顶点的距离

  我们需要使用三个数组来存放有向图的各种信息:

  • dist[]: 记录各个顶点的当前距离(当前到达v的距离)
  • pre[]: 存放从起点v到顶点j的最短路径j前面的顶点
  • S[]: 类似于visited,即标记已经求出最短路的顶点

  例如对于下面这张图:
p61
  我们根据算法的具体操作每一步可以得到dist,pre,S三个数组的变化如下:
p62
  具体的步骤这里就不再赘述了,接下来给出一段基本的实现思路:

int cost[MAXN][MAXN]{{0}};
int dist[MAXN]{0}, pre[MAXN]{0}, n{0}, v{0};

void dijkstra(int n, int v)
{
    bool s[MAXN]{0};
    int i, j, k, min;
    for (i = 1; i <= n; i++) {
        dist[i] = cost[v][i];
        s[i] = 0;
        if (dist[i] < MAXINT) 
            pre[i] = v;
        else pre[i] = 0;
    }
    s[v] = true;
    pre[v] = 0;

    for (i = 1; i <= n; i++) {
        min = MAXINT;
        k = 0;
        for (j = 1; j <= n; j++) {
            if (!s[j]) 
                if (dist[j] != 0 && dist[j] < min) {
                    min = dist[j];
                    k = j;
                } 
        }
        if (k == 0) break;
        s[k] = 1;
        for (j = 1; j <= n; j++) {
            if (s[j] == 0 && cost[k][j] < MAXINT) {
                if (dist[k] + cost[k][j] < dist[j]) {
                    dist[j] = dist[k] + cost[k][j];
                    pre[j] = k;
                }
            }
        }
    }
}

  这样的实现方法的时间复杂度为 O ( ∣ V ∣ 2 ) O(|V|^2) O(V2)

ii.优先队列优化方法

  我们可以基于优先队列的方式来优化Dijkstra:

constexpr int maxn = 5e5 + 20;

struct edge
{
    int v, w;
};

struct node
{
    int dis, u;
    bool operator>(const node& a) const
    {
        return dis > a.dis;
    }
};

vector<edge> e[maxn];
int dis[maxn], vis[maxn];
priority_queue<node, vector<node>, greater<node>> q;

void dijkstra(int n, int s)
{
    memset(dis, 0x3f, sizeof(dis));
    dis[s] = 0;
    q.push({ 0, s });
    while (!q.empty()) {
        int u = q.top().u;
        q.pop();
        if (vis[u]) continue;
        vis[u] = 1;
        for (const auto& ed : e[u]) {
            int v = ed.v, w = ed.w;
            if (dis[v] > dis[u] + w) {
                dis[v] = dis[u] + w;
                q.push({ dis[v], v });
            }
        }
    }
}

  这种情况下,时间复杂度可以被优化到 O ( ∣ E ∣ log ⁡ ∣ V ∣ ) O(|E|\log|V|) O(ElogV)

#3.多源最短路径问题—Floyd算法

  Floyd比较好的一点是,可以直接求出每一个点到其他的点的最短路径长度,比较坏的一点是它的时间复杂度非常高,为 O ( ∣ V ∣ 3 ) O(|V|^3) O(V3),对于一个数据量非常大的图,我们可能得换用Johnson算法求出全局的最短路径

i.实现方法

  Floyd的思路是这样的:假设求从 v i v_i vi v j v_j vj的最短路径,如果 v i v_i vi v j v_j vj有边,那就存在一条长度为cost[i][j]的边,但这不一定是最短路径,因为可能存在一条从 v i v_i vi v j v_j vj,但包含其他中间点的更短的路径
  因此我们需要进行n次试探,测试看从 v i v_i vi v j v_j vj是否能有包含其他中间点的更短路径,所以Floyd的基于代价矩阵A实现,对于第k步的 A ( k ) A^{(k)} A(k),我们可以有以下递推公式:
{ A ( 0 ) ( i , j ) = c o s t ( i , j ) , A ( k ) ( i , j ) = min ⁡ { A ( k − 1 ) ( i , j ) , A ( k − 1 ) ( i , k ) + A ( k − 1 ) ( k , j ) } , 1 ≤ k ≤ n \begin{cases} A^{(0)}(i,j) = cost(i,j),\\ A^{(k)}(i,j) = \min\{A^{(k-1)}(i,j), A^{(k-1)(i,k)+A^{(k-1)}(k,j)}\}, 1\leq k\leq n \end{cases} {A(0)(i,j)=cost(i,j),A(k)(i,j)=min{A(k1)(i,j),A(k1)(i,k)+A(k1)(k,j)},1kn
A ( 0 ) A^{(0)} A(0)为初始给定的代价矩阵,而 A ( k ) ( i , j ) ( 1 ≤ i , j ≤ n ) A^{(k)}(i,j)(1\leq i, j\leq n) A(k)(i,j)(1i,jn)表示从顶点i到顶点j的中间顶点序号不大于k的最短路径长度
  有了这个,我们就可以递推地产生 A ( 0 ) , A ( 1 ) , . . . , A ( n ) A^{(0)},A^{(1)}, ..., A^{(n)} A(0),A(1),...,A(n)了, A ( n ) A^{(n)} A(n)是我们最后需要的解,所以,它的代码真的超级简单:

void floyd(int n)
{
    for (int i = 1; i <= n; i++) {
        for (int j = 1; j <= n; j++) {
            a[i][j] = cost[i][j];
            path[i][j] = 0; // path数组用于存储路径
        }
    }
    for (int k = 1; k <= n; k++) {
        for (int i = 1; i <= n; i++) {
            for (int j = 1; j <= n; j++) {
                if (a[i][j] > a[i][k] + a[k][j]) {
                    a[i][j] = a[i][k] + a[k][j];
                    path[i][j] = k;
                }
            }
        }
    }
}

  一个非常简单的三重循环,一个直接就能看出来的 O ( n 3 ) O(n^3) O(n3)时间复杂度,对吧?

ii.求传递闭包

  对于一个关系R,具备某个性质P的闭包可以定义为:能够使关系R满足属性P的最小集合,关系R可能本身不满足P,我们可以添加较少的一些元组使得其满足性质P,而传递闭包需要的则是 x R y , y R z ⇒ x R z xRy, yRz\Rightarrow xRz xRy,yRzxRz
  R的传递闭包记为t®,它可以用这个算法得到:
t ( R ) = ⋃ i = 1 ∞ R i t(R) = \bigcup_{i=1}^\infty R^i t(R)=i=1Ri

  对于一个有限的关系R,我们可以用有限次的并完成这个操作,因此,我们可以用Floyd算法求传递闭包:

...
for (int k = 1; k <= n; k++) {
    for (int i = 1; i <= n; i++) {
        for (int j = 1; j <= n; j++) {
            a[i][j] |= a[i][k] && a[k][j];
        }
    }
}

#4.还有更多

  实际上求最短路径的方法还有很多,例如Johnson全源最短路算法、Bellman-Ford算法等等,其中Bellman-Ford算法在后续的 《计算机网络》 课程中还会学到(基于距离向量的路由算法),你可以去了解一下,OI-Wiki就是一个很不错的选择

(6).拓扑排序

#1.什么是拓扑序列?

  例如计算机科学与技术学院开的很多课,具有先修课程的要求:

课程代号课程名称先修课
C1程序设计基础
C2离散数学C1
C3数据结构C1,C2
C4汇编语言C1
C5语言的设计与分析C3,C4
C6计算机原理C10
C7编译原理C3,C5
C8操作系统C3,C6
C9高等数学
C10普通物理C9

  如果以顶点作为活动,我们可以得到以下的有向图:
p63
  这样的图被我们称为顶点表示活动的网络,简称AOV网络,所以我们需要一个顺序,能够在先修的限制下,得到一个合法的课程修读顺序

  接下来是一大堆定义:设S是一个集合,R是S上的一个关系,a,b是S中的元素,若 ( a , b ) ∈ R (a,b)\in R (a,b)R,则称a是b关于R的前驱元素,b是a关于R的后继元素

  设S是一个集合,R是S上的一个关系,a,b,c是S中的元素,若有 ( a , b ) ∈ R (a,b)\in R (a,b)R ( b , c ) ∈ R (b,c)\in R (b,c)R,则必有 ( a , c ) ∈ R (a,c)\in R (a,c)R,则称R是S上的传递关系

  若对于S中的任意元素,不存在 ( a , a ) ∈ R (a,a)\in R (a,a)R,则称R是S上的一个非自反关系

  若S上的一个关系R是传递的以及非自反的,则称R是S上的一个半序关系

  在任何一个具有半序关系R的有限集合中,至少有一个元素没有前驱,也至少有一个元素没有后继

  若R是集合S上的一个半序关系, A = a 1 , a 2 , . . . , a n A=a_1,a_2,...,a_n A=a1,a2,...,an是S中元素的一个序列,且当 ( a i , a j ) ∈ R (a_i,a_j)\in R (ai,aj)R时,有 i < j i<j i<j,则称A是相对于R的一个拓扑序列,若 a i a_i ai a j a_j aj关于R没有关系,则它们在A中的排列次序不受限制

  所以在这么多定义之后,我们就知道什么是拓扑序列了,实际上就是希望得到一个AOV网络的合法执行顺序,所以很容易知道:AOV网络中不能存在有向环路,否则不可能得出一个能够合法执行的顺序,而操作一个有向图得到拓扑序列的过程就是拓扑排序

#2.拓扑排序实现方式

  其实拓扑排序实现起来非常简单:

  • 在网络中选择一个没有前驱的顶点输出
  • 从网络中删除该顶点和以它为尾的所有有向边

  之后重复这两个步骤,直到所有的顶点都被输出网络上的顶点都有前驱顶点(出现回路) 为止

  在计算机中的拓扑排序我们利用邻接表存储有向图,并且维护一个储存每个顶点入度的数组,当某个顶点的入度为0时,就输出这个顶点,同时将该顶点的所有后继顶点入度减一,为了避免重复检测入度为0的顶点,我们可以用一个队列/栈存放这些度为0的顶点,在这里我们采取队列实现:

#include <iostream>
#include <queue>
#include <vector>
using namespace std;
constexpr int MAXN = 5e5 + 20;

int n, m;
vector<int> G[MAXN];
int in[MAXN]{ 0 };  // 存储每个结点的入度

bool toposort()
{
    vector<int> L;
    queue<int> S;
    for (int i = 1; i <= n; i++)
        if (in[i] == 0) S.push(i);
    while (!S.empty()) {
        int u = S.front();
        S.pop();
        L.push_back(u);
        for (const auto& v : G[u]) {
            if (--in[v] == 0) {
                S.push(v);
            }
        }
    }
    if (L.size() == n) {
        for (const auto& i : L) cout << i << ' ';
        return true;
    }
    else {
        return false;
    }
}

int main()
{
    cin >> n;
    for (int i = 1; i <= n; i++) {
        int in1{ 0 };
        do {
            cin >> in1;
            if (in1 != 0) {
                G[i].push_back(in1);
                in[in1]++;
            }
        } while (in1 != 0);
    }
    toposort();
    return 0;
}

  这个过程非常简单:我们首先从邻接表中找出入度为0的顶点,加入队列后,依次取出,把与它相连的入度为0的顶点都入队,然后把它加入输出序列中,一直这么操作,直到队列为空为止,这时候如果输出序列和顶点数相同,则无环,拓扑排序正常,否则其中存在环,无法进行拓扑排序

#3.关键路径和AOE网络

i.什么是AOE网络

  接下来是最后一个问题:如果我们把顶点作为某个状态,而有向边代表活动,则会得到另一种网络:AOE网络(Activity On Edge Network)

  表示实际工程的AOE-网络应该没有有向回路,存在唯一的入度为0的开始顶点,唯一的出度为0的结束顶点

  例如下面这个AOE网络,其中V1表示工程开始,V2表示活动a1完成,V3表示活动a2完成,V4表示活动a3和a4完成,V5表示活动a5和a6完成,此时整个工程就完成了
p64

ii.关键路径

  在AOE网络中,我们主要关心:完成整个工程至少需要多少时间,这个问题其实比较好解决,因为完成整个工程的最小时间是从开始顶点到结束顶点的最长路径长度

  而我们称从开始顶点到结束顶点的最长路径为关键路径,一个AOE网络可以有多条关键路径,关键路径上的所有活动都是关键活动

p64
  例如我们在上图中用到的这个AOE网络,我们可以定义最早开始时间:例如V1作为起点肯定是0,而V2只依赖从V1开始完成活动a1,所以V2最早也要从5才能开始,对应的我们可以得出V3为7;而V4要好好考虑一下,因为V4同时依赖于V2和V3,从V2到V4需要到8,而从V3到V4需要13,因此这时候我们会发现:因为V4依赖于V3,而V3到V4最早也要在13才能达到,所以V4的最早开始时间是13,对应的也可以得到V5的最早开始时间是17

  有最早开始时间,就可以有最晚开始时间,因为我们刚刚发现这个AOE网络中有那么几个活动因为另外的活动影响,中间存在一段时间什么都不用干,这时候我们要倒着推,某个事件的最迟开始时间为所有事件的完成时间,减去从当前事件到事件完成顶点的最长路径长度,因此我们可以知道:V5的最迟开始时间是17,V4的最迟开始时间是13,V3的最迟开始时间是17-max{10, 3}=7,V2的最迟开始时间是10,V1当然是0啦

  你会发现,V2的最迟开始时间是10,最早开始时间是5,这也就意味着,对应V2的活动并没有那么急迫,换而言之:V2对应的活动并不是影响整个工程结束的关键活动

  对于活动,我们同样可以定义活动的最早开始时间和最迟开始时间,对于活动 a i a_i ai,它由边 < j , k > <j,k> <j,k>表示,则最早开始时间就是顶点j的最早开始时间,而最迟开始时间则是顶点k的最迟开始时间减去活动 a i a_i ai的时间,这样一来,我们就可以求得对应各个活动的最早开始时间和最迟开始时间

我们称,最迟开始时间等于最早开始时间的活动为关键活动,所以在求解关键活动的时候,我们需要求到整个AOE网络的四个数组:顶点最早开始时间,顶点最迟开始时间,活动最早开始时间,活动最迟开始时间,下面我会给出一段代码解决这个问题

iii.代码实现

  这里用用一道题作为例子:
p1p2

  参考代码如下:

#include <iostream>
#include <vector>
#include <queue>
#include <map>
#include <cstring>
#include <algorithm>
using namespace std;
constexpr int MAXN = 120;

struct edge
{
    int beg;
    int end;
    int w;
};

vector<edge> e[MAXN];
int in[MAXN]{ 0 };
int ee[MAXN]{ 0 }, le[MAXN]{ 0 };
int xe[MAXN]{ 0 }, l[MAXN]{ 0 };
bool v[MAXN]{ 0 };
vector<edge> all_es;
map<int, edge> ma;
vector<int> ans;

bool toposort(int n)
{
    vector<int> L;
    queue<int> S;
    for (int i = 1; i <= n; i++) {
        if (in[i] == 0) S.push(i);
    }

    while (!S.empty()) {
        int u{ S.front() };
        S.pop();
        if (v[u]) continue;
        v[u] = true;
        L.push_back(u);
        for (const auto& v : e[u]) {
            if (--in[v.end] == 0) S.push(v.end);
            ee[v.end] = max(ee[v.end], ee[u] + v.w);
        }
    }
    if (L.size() == n) {
        ans = L;
        return true;
    }
    else {
        ans = vector<int>{};
        return false;
    }
}

int main()
{
    int n{ 0 }, m{ 0 };
    cin >> n >> m;
    memset(le, 0x3f, sizeof(le));
    all_es.push_back({ 0,0,0 });
    ee[1] = 0, le[n] = n;
    for (int i = 1; i <= m; i++) {
        int u, v, w;
        cin >> u >> v >> w;
        e[u].push_back({ u, v, w });
        all_es.push_back({ u,v,w });
        in[v]++;
    }
    bool available{ toposort(n) };
    if (available) {
        int mx = 0, mxi = 0;
        for (int i = 1; i <= n; i++) {
            if (ee[i] > mx) {
                mx = ee[i];
                mxi = i;
            }
        }
        le[mxi] = ee[mxi];
        for (auto it = ans.rbegin() + 1; it != ans.rend(); it++) {
            for (const auto& eg : e[*it]) {
                le[*it] = min(le[*it], le[eg.end] - eg.w);
            }
        }

        for (int i = 1; i <= m; i++) {
            xe[i] = ee[all_es[i].beg];
            l[i] = le[all_es[i].end] - all_es[i].w;
        }

        vector<edge> all;
        mx = 0;
        for (int i = 1; i <= n; i++) {
            mx = max(mx, ee[i]);
        }
        cout << mx << endl;
        for (int i = 1; i <= m; i++) {
            if (xe[i] == l[i]) {
                all.push_back(all_es[i]);
            }
        }
        stable_sort(all.begin(), all.end(),
            [](const edge& e1, const edge& e2) {
                if (e1.beg != e2.beg) {
                    return e1.beg < e2.beg;
                }
                else {
                    return e1.end > e2.end;
                }
            });
        for (const auto& fe : all) {
            cout << fe.beg << "->" << fe.end << endl;
        }
    }
    else cout << 0 << endl;
    return 0;
}

小结

  图论始终是古老而又富有活力的学科,我们在这一篇中只是简单地介绍了一系列关于图的算法和数据结构,还有很多很多关于图的内容,限于篇幅,我没有在这里介绍,在未来有可能会在这个系列中补充。
  到这里,数据结构这个系列的博客的所有课内部分就基本结束了,在之后,我会用几篇博客来单独补充红黑树、B树和B+树等一系列在之前的博客中没有具体讨论的内容,不过最近就要暂时停更了,感谢大家一个学期以来的支持!

Logo

开放原子开发者工作坊旨在鼓励更多人参与开源活动,与志同道合的开发者们相互交流开发经验、分享开发心得、获取前沿技术趋势。工作坊有多种形式的开发者活动,如meetup、训练营等,主打技术交流,干货满满,真诚地邀请各位开发者共同参与!

更多推荐