算法和应用

🔥 高优先级
图的应用就 四大天王:最小生成树、最短路径、拓扑排序 和 关键路径。需掌握这四种应用当中的 每个细节。当然还有 bfs 和 bfs 这两种 遍历方式 也不能忽略。

DFS

深度优先搜索(Depth-First Search,简称 DFS)是一种用于图的遍历和搜索的算法,它从图中的一个起始顶点开始,尽可能深地访问顶点,直到无法继续前进,然后回溯到之前的顶点,继续深入其他分支。DFS 通常用递归或栈来实现,确保在深度方向上优先遍历。

以下是 DFS 算法的一般步骤:

  1. 从起始顶点开始,将其标记为已访问。
  2. 遍历与当前顶点相邻的未访问顶点,选择其中一个未访问顶点作为下一个深度遍历的目标,然后递归地进行 DFS
  3. 如果无法继续深度遍历(即当前顶点没有未访问的邻居),则回溯到上一个顶点,继续遍历其他未访问的邻居。
  4. 重复步骤 2 和步骤 3,直到所有顶点都被访问。
A
B
C
D
E
F
A
B
C
D
E
F
A
B
C
D
E
F
A
B
C
D
E
F
A
B
C
D
E
F
A
B
C
D
E
F

图中 DFS 实现的框架与 树的遍历 类似,不同点在于需要使用 visit 数组记录已经访问过的节点,避免重复遍历:

function DFS(G, v, visited):
    visited[v] ← true
    for each neighbor u of v in G.adjacent[v]:
        if not visited[u]:
            DFS(G, u, visited)

图在 邻接矩阵邻接表 中的 DFS 都遵循这个框架,两者的不同点在于获取 v 的邻居 u 的方式不同。

邻接矩阵

邻接矩阵 中,我们定义一个递归函数 dfs,函数从 start 顶点出发,输出当前结点,然后递归地去访问所有与其相邻的顶点。

注意递归前需要保证下一个顶点未被访问过,这样可以避免重复访问结点以及死循环。

// 全局变量,标记顶点是否被访问过
int visited[MAX_VERTICES];

// 递归函数,从 start 顶点出发
void dfs(int start) {
    visited[start] = 1;
    printf("%d ", start);

    // 依次访问 start 的邻接顶点
    for (int i = 0; i < MAX_VERTICES; i++) {
        // 递归的两个条件:
        // 1. 顶点 (start, i) 之间存在边
        // 2. 顶点 i 未被访问过
        if (adjMatrix[start][i] == 1 && !visited[i]) {
            // 递归调用
            dfs(i);
        }
    }
}

邻接表

邻接表 的递归思路与 邻接矩阵 一致,唯一的区别就是访问邻接顶点方式的不同。

邻接矩阵 中,通过遍历矩阵中的一行来访问相邻顶点。在 邻接表 中,通过遍历链表来访问相邻顶点。

// 全局变量,标记顶点是否被访问过
int visited[MAX_VERTICES];

// 递归函数,从 start 顶点出发
void dfs(struct Graph* graph, int start) {
    visited[start] = 1;
    printf("%d ", start);

    // 依次访问 start 的邻接顶点
    struct Node* temp = graph->adjacencyList[start];
    while (temp) {
        int adjVertex = temp->data;
        // 递归条件:顶点 i 未被访问过
        if (!visited[adjVertex]) {
            dfs(graph, adjVertex);
        }
        temp = temp->next;
    }
}

BFS

广度优先搜索(Breadth-First Search,简称 BFS)是一种用于图的遍历和搜索的算法,它从图中的一个起始顶点开始,逐层地访问与该顶点相邻的顶点,然后再访问这些相邻顶点的邻居,以此类推。BFS 通常用 队列 来实现,确保按照广度优先的顺序访问顶点。

以下是 BFS 算法的一般步骤:

  1. 将起始顶点放入队列中,标记为已访问。
  2. 从队列中弹出一个顶点并访问它。
  3. 遍历该顶点的所有未被访问的邻居,将它们放入队列中,并标记为已访问。
  4. 重复步骤 2 和步骤 3,直到队列为空。
A
B
C
D
E
F
A
B
C
D
E
F
A
B
C
D
E
F
A
B
C
D
E
F
A
B
C
D
E
F
A
B
C
D
E
F

BFS 的伪代码实现如下所示,基于 邻接矩阵邻接表BFS 都遵循这个框架:

function BFS(G, start):
    visited[start] ← true
    enqueue(Q, start)

    while Q is not empty:
        v ← dequeue(Q)
        process(v)
        for each neighbor u of v in G.adjacent[v]:
            if not visited[u]:
                visited[u] ← true
                enqueue(Q, u)

邻接矩阵

实现 BFS 时,首先添加起始顶点进入队列中,然后不断从队列中取出顶点,并添加该顶点的未访问相邻顶点进入队列。重复直到队列为空。

DFS 中,visited 数组是各个递归函数都需要访问的数据结构,为了方便起见,可以定义为全局变量。
BFS 中,由于算法实现是基于迭代而非递归,所以 visited 和队列可以定义为局部变量。

// 从 start 开始遍历图
void bfs(int start, int vertices) {
    // 队列 q
    queue q;
    // 标记顶点是否被访问过
    int visited[MAX_VERTICES];
    // 添加初始顶点
    q.enqueue(start);
    visited[start] = 1;

    // 一直遍历到队列为空
    while (q.size() > 0) {
        // 从队列头部取出结点作为当前结点
        int currentVertex = q.dequeue();
        // 输出当前结点
        printf("%d ", currentVertex);
        // 将当前结点的所有 相邻的未访问顶点 添加到队列中
        for (int i = 0; i < vertices; i++) {
            if (adjMatrix[currentVertex][i] == 1 && !visited[i]) {
                q.enqueue(i);
                visited[i] = 1;
            }
        }
    }
}

邻接表

实现思路与 邻接矩阵 方式一致,不同点在于访问相邻顶点的方式不同。

void bfs(struct Graph* graph, int start) {
    // 队列 q
    queue q;
    // 标记顶点是否被访问过
    int visited[MAX_VERTICES];
    // 添加初始顶点
    q.enqueue(start);
    visited[start] = 1;

    // 一直遍历到队列为空
    while (front != rear) {
        // 从队列头部取出结点作为当前结点
        int currentVertex = q.dequeue();
        // 输出当前结点
        printf("%d ", currentVertex);
        // 将当前结点的所有 相邻的未访问顶点 添加到队列中
        struct Node* temp = graph->adjacencyList[currentVertex];
        while (temp) {
            int adjVertex = temp->data;
            if (!visited[adjVertex]) {
                q.enqueue(adjVertex);
                visited[adjVertex] = 1;
            }
            temp = temp->next;
        }
    }
}

最小生成树

最小生成树(Minimum Spanning TreeMST)是在一个连接所有顶点的无向图中,通过选择一部分边而形成的树,使得树的总权重(或成本)最小。

6
5
9
9
9
3
2
2
8
9
9
8
7
4
6
1
3
9
10
20
5

准确的 MST 定义如下:在无向图 $G = (V, E)$ 中,$(u, v)$ 代表连接顶点 $u$ 和顶点 $v$ 的边,而 $w(u, v)$ 代表此边的权重,若存在 $T$ 为 $E$ 的子集且 $(V, T)$ 为树,使得 $w(T) = \sum_{(u, v) \in T} w(u, v)$ 最小,则此 $T$ 为 $G$ 的 最小生成树

MST 满足以下特点:

  • 包含图中的所有顶点。
  • 通过选择一些边而形成一个树结构,没有环路。
  • 边的总权重最小。

prim 算法

Prim 算法通过 逐步扩展顶点集合 来构建最小生成树:从一个初始顶点出发,每次都选择一条 连接生成树与外部顶点的最小权重边,并将该顶点并入生成树,直到所有顶点都被包含。

prim 算法步骤 如下:

  1. 选定一个起始顶点作为生成树的根。
  2. 初始化生成树,只含有该顶点。
  3. 在所有 连接生成树与未加入顶点的边 中,选取权重最小的一条,并将对应顶点加入生成树。
  4. 重复步骤 3,直到所有顶点均被纳入生成树。
  5. 得到的生成树即为原图的 最小生成树
补充

prim 是一种 贪心算法,这里关键在于理解什么叫做 所有连接生成树与未加入顶点的边

当我们已经有一个部分生成树(初始时只有一个顶点),它把图中的顶点分成两类:

  1. 已加入生成树的顶点集合 $T$
  2. 尚未加入生成树的顶点集合 $V-T$

此时,

  • 所有连接生成树与未加入顶点的边,就是一端在 $T$ 内、另一端在 $V-T$ 内的边。
  • 这些边正好构成了一个“割(cut)”,也叫“横切边”。

Prim 算法的贪心思想就在这里:每次都从这组横切边里挑出一条 权重最小的边,把它对应的未加入顶点纳入生成树。

PrimDemo_Reversedcluster_final步骤5: 最终最小生成树T = {A,B,C,D,E}, 总权重 = 2+1+2+4 = 9cluster_step4步骤4: 选择最小横切边 C-D (权重2)T = {A,B,C,D}, V-T = {E}cluster_step3步骤3: 选择最小横切边 B-C (权重1)T = {A,B,C}, V-T = {D,E}cluster_step2步骤2: 选择最小横切边 A-B (权重2)T = {A,B}, V-T = {C,D,E}cluster_step1步骤1: 初始状态 - 从A开始T = {A}, V-T = {B,C,D,E}cluster_legendcluster_keyPrim算法步骤演示A5AB5BA5->B52C5CB5->C51E5EB5->E54D5DC5->D52A4AB4BA4->B42C4CB4->C41E4EB4->E44D4DC4->D42D4->E45A3AB3BA3->B32D3DA3->D36C3CB3->C31E3EB3->E34C3->D32D3->E35A2AB2BA2->B22C2CA2->C23D2DA2->D26B2->C21E2EB2->E24C2->D22D2->E25A1AB1BA1->B12C1CA1->C13D1DA1->D16B1->C11E1EB1->E14C1->D12D1->E15legend_tree已在生成树legend_not未加入legend_tree->legend_not横切边legend_selected当前选择legend_tree->legend_selected生成树边

这样保证了:

  • 每次扩展都是局部最优(加入的边在候选集合里是最小的)。
  • 最终能够得到全局最优的最小生成树(证明基于“割性质”,不是重点这里不说明)。

参考 prim 算法的流程图如下:

PrimAlgorithmA开始B选择起始顶点加入生成树A->BC所有顶点都已加入生成树?B->CD算法结束得到最小生成树C->DE找到连接生成树内外顶点的所有边中权重最小的边C->EF将该边和对应顶点加入生成树E->FF->C

举个实际例子,对于以下的无向图,假设我们从顶点 a 出发使用 prim 算法寻找最小生成树,会逐步选择出 ab、bc、ci、cf、fg、gh、cd 以得到最小生成树:

a
b
h
i
c
g
f
d
4
8
11
8
7
2
1
6
7
4
14
a
b
h
i
c
g
f
d
4
8
11
8
7
2
1
6
7
4
14
a
b
h
i
c
g
f
d
4
8
11
8
7
2
1
6
7
4
14
a
b
h
i
c
g
f
d
4
8
11
8
7
2
1
6
7
4
14
a
b
h
i
c
g
f
d
4
8
11
8
7
2
1
6
7
4
14
a
b
h
i
c
g
f
d
4
8
11
8
7
2
1
6
7
4
14
2
2
2
2
2
2
a
b
h
i
c
g
f
d
4
8
11
8
7
2
1
6
7
4
14
2
a
b
h
i
c
g
f
d
4
8
11
8
7
2
1
6
7
4
14
2

kruskal 算法

Kruskal 算法是一种用于求解 最小生成树MST)的贪心算法。Kruskal 算法的基本思想是从边的权重最小的边开始,逐步构建生成树,确保不会形成环路。

以下是 Kruskal 算法的一般流程:

  1. 初始化一个空的生成树,开始时生成树中没有边。
  2. 将图中的所有边按照权重 从小到大进行排序
  3. 依次 从排序后的边列表中选取边,加入生成树,但要确保 添加该边不会形成环路
  4. 重复步骤 3,直到生成树包含了所有的顶点,即生成树中的边数等于顶点数减 1。
  5. 返回生成树作为 最小生成树

参考 kruskal 算法的流程图如下:

KruskalAlgorithmstart开始init初始化空的生成树T边集合E = ∅start->initsort将所有边按权重从小到大排序init->sortselect从排序边列表中选取最小权重边esort->selectcheck添加边e是否会形成环路?select->checkcomplete生成树是否包含n-1条边?(n为顶点数)add将边e加入生成树Tcheck->add(不形成环路)skip跳过此边echeck->skip(形成环路)add->completeskip->completecomplete->select(继续选取下一条边)result返回最小生成树Tcomplete->result(生成树完成)end结束result->endnote1核心思想:贪心策略每次选择最小权重的边同时避免形成环路

继续使用上文中 prim 中的无向图作为例子,使用 kruskal 算法,会依次选择 hg、ic、gf、ab、cf、cd、ah 以得到最小生成树:

a
b
h
i
c
g
f
d
4
8
11
8
7
2
1
6
7
4
14
2
a
b
h
i
c
g
f
d
4
8
11
8
7
2
1
6
7
4
14
2
a
b
h
i
c
g
f
d
4
8
11
8
7
2
1
6
7
4
14
2
a
b
h
i
c
g
f
d
4
8
11
8
7
2
1
6
7
4
14
2
a
b
h
i
c
g
f
d
4
8
11
8
7
2
1
6
7
4
14
2
a
b
h
i
c
g
f
d
4
8
11
8
7
2
1
6
7
4
14
2
a
b
h
i
c
g
f
d
4
8
11
8
7
2
1
6
7
4
14
2
a
b
h
i
c
g
f
d
4
8
11
8
7
2
1
6
7
4
14
2

最短路径

dijkstra 算法

Dijkstra 算法是一种用于计算 单源最短路径 的经典算法,适用于 带非负权重 的有向或无向图。

Dijkstra_Animation

Dijkstra 算法是一种 贪心算法。它的 核心思想 是:从起点出发,不断“扩展”到距离起点最近的节点,并利用这些节点去尝试更新其他节点的最短路径,直到所有节点的最短路径都被确定。

换句话说,算法始终保持一个 “已确定最短路径的集合”,并且在每一步中把离起点最近的“候选节点”加入其中:

DijkstraDemocluster_final步骤5: 所有节点最短路径确定S = {A,B,C,D,E}cluster_step4步骤4: 确定D的最短路径 d(D)=5S = {A,B,C,D}, V-S = {E}cluster_step3步骤3: 确定C的最短路径 d(C)=3S = {A,B,C}, V-S = {D,E}cluster_step2步骤2: 确定B的最短路径 d(B)=2S = {A,B}, V-S = {C,D,E}cluster_step1步骤1: 初始状态 - 从起点A开始S = {A}, V-S = {B,C,D,E}cluster_legendcluster_keyDijkstra算法核心思想演示A5Ad=0B5Bd=2A5->B52C5Cd=3B5->C51E5Ed=6B5->E54D5Dd=5C5->D52A4Ad=0B4Bd=2A4->B42C4Cd=3B4->C41E4Ed=6B4->E44D4Dd=5C4->D42D4->E45A3Ad=0B3Bd=2A3->B32D3Dd=5A3->D36C3Cd=3B3->C31E3Ed=6B3->E34C3->D32D3->E35A2Ad=0B2Bd=2A2->B22C2Cd=3A2->C23D2Dd=∞A2->D26B2->C21E2Ed=6B2->E24C2->D22D2->E25A1Ad=0B1Bd=2A1->B12C1Cd=3A1->C13D1Dd=6A1->D16B1->C11E1Ed=∞B1->E14C1->D12D1->E15legend_confirmed已确定最短路径legend_candidate候选节点legend_confirmed->legend_candidate用于更新legend_current当前选择(距离最近)legend_confirmed->legend_current最短路径note每步选择距离起点最近的候选节点
补充

prim 算法和 dijkstra 算法的核心步骤十分类似:

  • 两个算法都用一个集合(已确定的顶点集合)逐步扩展。
  • 每次都要在“已选顶点集合”与“未选顶点集合”之间,挑选一个“最优”的候选顶点加入集合。

但是 prim 比较的是比较的是 边的权重(只看与生成树相连的边),dijkstra 比较的是 从源点出发的路径长度(边的累计权重)。

🛠️ dijkstra 算法需要定义以下 数据结构

  • dist[]:数组,dist[v] 表示起点到节点 v当前已知最短路径长度
    • 初始化时:dist[source] = 0,其余节点均为
  • visited[]:布尔数组,表示某个节点的最短路径是否已经被最终确定。
    • 初始时:所有节点均为 false

✨ dijkstra 算法流程   如下:

  1. 初始化
    • 设起点为 source
    • dist[source] ← 0
    • 对于其他顶点 vdist[v] ← ∞
    • visited[v] ← false
  2. 主循环(重复 |V| 次,即所有顶点都被处理完)
    • 从所有 未访问 的顶点中,找到一个 dist[] 值最小的顶点 u。 (这意味着:当前已知路径中,u 是离起点最近的尚未确定的节点。)
    • 将该顶点标记为已访问:visited[u] ← true
    • 对于 u 的每个邻居 v
      • 如果 v 未被访问,并且通过 u 到达 v 的路径更短(即 dist[u] + w(u,v) < dist[v]), 则更新:
        dist[v] ← dist[u] + w(u,v)
        
        (这一步就是“松弛操作”,表示尝试通过 u 改进到 v 的最短路径。)
  3. 结束
    • 当所有顶点都被访问后,dist[v] 就保存了起点到各个顶点的最短路径长度。

Dijkstra 算法流程可以通过以下流程图理解:

DijkstraAlgorithmstart开始init初始化阶段• dist[source] = 0• dist[v] = ∞ (其他节点)• visited[v] = false (所有节点)start->initloop_check是否还有未访问的节点?init->loop_checkfind_min找到未访问节点中dist[]值最小的节点uloop_check->find_minfinish算法结束dist[]包含最短路径loop_check->finishmark_visited标记节点u为已访问visited[u] = truefind_min->mark_visitedcheck_neighbors遍历u的所有邻居vmark_visited->check_neighborsneighbor_conditionv未访问 且dist[u] + w(u,v) < dist[v] ?check_neighbors->neighbor_conditionrelax松弛操作dist[v] = dist[u] + w(u,v)neighbor_condition->relaxnext_neighbor检查下一个邻居neighbor_condition->next_neighborrelax->next_neighborall_neighbors_doneu的所有邻居都检查完了?next_neighbor->all_neighbors_doneall_neighbors_done->loop_checkall_neighbors_done->neighbor_condition

Dijkstra 的考察侧重于手工模拟其过程,代码实现一般不会考察,这里给出算法的简单 伪代码实现,帮助各位理清其中的处理细节。

function Dijkstra_Simple(Graph, source):
    对于图中每个顶点 v:
        dist[v] ← ∞          // 起点到 v 的距离初始化为无穷大
        visited[v] ← false   // 所有顶点初始都未被访问
    dist[source] ← 0         // 起点到自身距离为 0

    重复 |图中顶点数| 次:
        u ← 所有未访问的顶点中 dist[u] 最小的顶点
        visited[u] ← true    // 标记 u 已被访问

        对于 u 的每个相邻顶点 v:
            边权重 weight ← 图中边(u, v) 的权重
            如果 v 未访问 且 dist[v] > dist[u] + weight:
                dist[v] ← dist[u] + weight  // 通过 u 到 v 更短,更新最短路径

    return dist[] // 表示起点到所有顶点的最短路径

假设顶点的个数为 $V$,边的个数为 $E$,那么 最佳实现(使用了优先队列)的 dijkstra 算法时间复杂度为 $O(V+E)$。

floyd 算法

Floyd Warshall 是一种基于 动态规划 的方法,用于求解任意两个顶点之间的最短路径,它适用于带权有向图。

该算法的核心思想是尝试使用每一个顶点作为中转点,逐步更新所有顶点对之间的最短路径。对任意两点 $i → j$,判断是否可以通过一个中间点 $k$ 得到更短路径:

$$dist[i][j] = min(dist[i][j], dist[i][k] + dist[k][j])$$

这个过程会遍历所有可能的中转点 $k$,不断优化路径。

✨ 算法具体流程如下:

  1. 初始化距离矩阵 dist

    • dist[i][j] = 0(若 i=j
    • dist[i][j] = 边权(若 i→j 有边)
    • dist[i][j] = ∞(其余情况)
  2. 三重循环更新最短路径

    for k in 所有顶点:
        for i in 所有顶点:
            for j in 所有顶点:
                if dist[i][k] + dist[k][j] < dist[i][j]:
                    dist[i][j] ← dist[i][k] + dist[k][j]
    
  3. 输出 dist 矩阵 :表示所有顶点对之间的最短路径长度。

若顶点数为 $n$,则 Floyd Warshall 算法的时间复杂度为 $O(n^3)$,该算法适合在算法题中兜底使用,因为实现比较简单,建议大家熟练掌握代码实现。

拓扑排序

拓扑排序 这样一种对图中顶点的排序方式:

将图中的所有顶点排序,使得对于每一条有向边 uv,顶点 u 都排在 v 之前。

拓扑排序 主要应用于 DAG(有向无环图)或 AOV 网,这种图没有环路,因此可以对其进行拓扑排序。

首先需要区分 AOVAOE 这两个概念,注意 拓扑排序 适用于 AOV关键路径 适用于 AOE

AOV 网

AOV(Activity on Vertex Network)网是一种以顶点表示活动或任务的网络模型。每个 顶点 代表一个 任务或活动,而 表示任务之间的 依赖关系。在AOV网中,任务或活动通常表示为顶点,而依赖关系(任务的先后顺序)表示为有向边。

AOV_NetworkAOV网络示例:软件开发项目顶点=任务/活动,有向边=依赖关系cluster_legend图例AA需求分析BB系统设计A->B依赖CC数据库设计A->C依赖DD前端开发B->D依赖EE后端开发B->E依赖FF数据库实现C->F依赖GG系统集成D->G依赖E->G依赖F->G依赖HH测试G->H依赖II部署H->I依赖start开始任务normal中间任务end结束任务

算法步骤

计算 拓扑排序 的算法有两种,一种是 Kahn 算法(基于入度),另一种是 DFS+栈 的实现。

Kahn算法

Kahn 算法的核心思想是 依次选取入度为 0 的顶点进行输出,其具体步骤如下:

  1. 选择一个没有前驱顶点(入度为 0)的顶点作为起始顶点,将其加入拓扑排序的结果序列中。
  2. 从图中移除该顶点以及与之相关的边(即将与之相邻的顶点的入度减 1)。
  3. 重复步骤 1 和步骤 2,直到所有的顶点都被加入到拓扑排序的结果序列中,或者发现图中存在环路。
  4. 如果所有的顶点都被成功加入结果序列,那么这个序列就是拓扑排序的结果。

下图展示了一个采用 Kahn 算法输出 AOE 网中的 拓扑序列 的实例:

1
2
4
3
5
2
4
3
5
4
3
5
3
5
5
结点编号
1
2
3
4
5
初始结点度
0
1
2
2
2
1 轮后
0
2
1
2
2 轮后
1
0
2
3 轮后
0
1
4 轮后
0
5 轮后
输出 1
输出 2
输出 4
输出 3
输出 1
输出 2
输出 4
输出 3
输出 5
DFS + 栈

此外,可以通过 DFS+栈 得到 拓扑序列,其步骤如下:

  1. 对图进行深度优先遍历。
  2. 每个节点 DFS 结束后将其压入栈中。
  3. 最后将栈中元素逆序输出,就是 拓扑排序 结果。
topological_sort_dfscluster_2结果cluster_1DFS+栈过程cluster_0原始DAGresult栈逆序输出:A→C→E→B→D→Fverify✓满足拓扑序约束s11.访问A→B→D→FF入栈s22.D完成栈:[F,D]s1->s2s33.B完成栈:[F,D,B]s2->s3s44.访问C→EE完成s3->s4s55.C完成栈:[F,D,B,E,C]s4->s5s66.A完成栈:[F,D,B,E,C,A]s5->s6AABBA->BCCA->CDDB->DC->DEEC->EFFD->FE->F

DFS 过程中,每个节点在其所有后继节点访问完成后才被加入栈中,因此栈中元素的逆序正好满足 拓扑排序 所需的“前驱先于后继”的约束。


拓扑排序 的时间复杂度通常为 $O(V + E)$,其中 $V$ 是顶点的数量, $E$ 是边的数量。这个算法非常适合于解决任务调度和依赖关系问题,因为它可以确定任务的执行顺序或依赖关系的合理性。

关于拓扑排序,还需要关注以下几点:

  • 拓扑排序的结果 可能不唯一,因为在构建过程中往往会出现多个入度为 0 的顶点,不同的选择顺序会产生不同的合法排序结果。
  • 如果图中 存在环路,那么 无法进行拓扑排序,因为无法找到入度为 0 的顶点作为起始顶点。

关键路径

关键路径 是指 AOE 网(Activity On Edge Network)中,从源点到汇点所经过的 最长路径。这条路径决定了整个项目完成所需的最短时间,因此被称为 关键 路径。

AOE 网

AOE(Activity on Edge Network)网是一种以边表示活动或任务的网络模型。每条边代表一个任务或活动,而顶点通常表示事件,表示任务的开始或完成时间。

AOE_NetworkAOE网络示例 - 软件开发项目(Activity on Edge Network)cluster_legend图例说明V0V0项目开始V1V1设计完成V0->V1A1: 需求分析(5天)V0->V1V2V2采购完成V0->V2A2: 设备采购(8天)V3V3开发完成V1->V3A3: 系统开发(12天)V1->V3V4V4测试完成V1->V4A5: 测试准备(6天)V2->V3A4: 环境搭建(3天)V3->V4A6: 集成测试(4天)V3->V4V5V5项目结束V4->V5A7: 验收交付(2天)V4->V5legend_event事件(里程碑)legend_start开始事件legend_end结束事件legend_activity活动/任务legend_critical关键路径

为了理解 关键路径,首先需要明确 AOE 网中的以下概念:

  • 源点:在 AOE 网中仅有一个入度为 $0$ 的顶点,称为开始顶点,表示整个工程的开始。
  • 汇点:在 AOE 网中仅有一个出度为 $0$ 的顶点,称为结束结点,表示整个工程的结束。
  • 关键路径的长度:完成整个工程的最短时间,也是 AOE 网中从源点到汇点的 最长路径
  • 关键活动关键路径 上的活动。
  • 活动的时间余量:某个活动可以被延迟的最大时间,而不会影响整个项目的最短完成时间(总工期)。
  • ve(k):事件 $v_k$ 的最早可以发生时间。
  • vl(k):事件 $v_k$ 的最晚可以发生时间。

有几个概念比较重要,还需要进行进一步的阐述:

事件和活动

AOE 网中,边代表活动(Activity)顶点代表事件(Event)

  • 活动(边):表示一个具体的工作、任务或操作,具有确定的持续时间。一个活动必须在其起点事件“发生”之后才能开始。
  • 事件(顶点):表示一个时间点,通常是某些活动的“开始”或“结束”条件已经满足,代表某种逻辑上的时刻状态。

通俗来讲:活动是“做某事”,事件是“可以开始做某事的时间点”

事件的发生时间

AOE 网中,一个事件的“发生”是指其所有前驱活动都已完成,这标志着“条件成熟”,从该事件出发的活动才可能被启动。

两个关键时间

时间名称含义数学符号
最早发生时间某事件最早可以发生的时间ve(i)
最晚发生时间某事件最晚必须发生的时间(不影响工期)vl(i)

事件发生的判断逻辑

一个事件是否可以发生,不看它的出边活动,而是看它的所有前驱活动(入边)是否完成

  • 若某事件 i 的所有进入活动(任务)都已完成 → 则事件 i 发生;
  • 一旦事件 i 发生,其所有出边活动 可以开始,但 不必须同时开始
  • 出边活动的实际开始时间取决于它们自身的调度灵活性(浮动时间)。
活动时间余量

“活动的时间余量”在 AOE 网中指的是:

一个活动可以被延迟的最大时间,而不会影响整个项目的最短完成时间(总工期)。

首先,为什么要有时间余量呢?因为在 AOE 网络中,不是所有活动都在 关键路径 上。

  • 关键路径上的活动:必须准时进行,没有时间余量(余量 = 0)
  • 非关键路径上的活动:允许适当延迟,但必须不阻碍项目的最晚结束时间

时间余量可以通过以下数学公式计算:

对于一个活动 $A(i \rightarrow j)$,持续时间为 $d_{ij}$,其时间余量:

$$\text{时间余量} = vl(j) - ve(i) - d_{ij}$$

其中:

  • $ve(i)$:起点事件 i 的最早发生时间
  • $vl(j)$:终点事件 j 的最晚发生时间
  • $d_{ij}$:活动持续时间
关键路径和活动

关键路径 上的所有活动都是 关键活动,它是决定整个工程的关键因素,因此可通过加快关键活动来缩短整个工程的工期。

关键路径和关键活动具有以下典型特征

  1. 关键活动的时间余量为 0:即对某活动 $A(i \rightarrow j)$ 有

    $$ vl(j) - ve(i) - d_{ij} = 0 $$

    说明该活动不能被延迟任何时间,否则将影响总工期。

  2. 关键活动连接的事件满足

    $$ ve(i) = vl(i) \quad \text{且} \quad ve(j) = vl(j) $$

    即活动的起点和终点事件的最早发生时间等于最晚发生时间。它们必须在确定的时间点发生,不能提前也不能推迟。

  3. 关键路径是工程中的最长路径:它决定了整个项目从开始到结束所需的最短时间,也即项目总工期。

  4. 关键路径可能有多条:若多个路径的总时长都等于项目的最短完成时间,这些路径上的活动都必须受到严格监控。

算法步骤

求解 关键路径核心思路 在于找到所有 最早发生时间最晚发生时间 相同的结点,连接这些结点即可得到关键路径。

所以在求关键路径的算法中,我们需要依次计算每个顶点的最早发生时间(ve)和最晚发生时间(vl),然后判断哪些顶点的 vevl 相等。

为了得到关键路径,需要分三步计算:

1. 计算最早发生时间 ve

  • 源点 出发,设 ve(start) = 0
  • 按照 拓扑排序 的顺序依次计算其他顶点的最早发生时间:

$$ ve(k) = \max_{v_j \in Pred(v_k)} { ve(j) + weight(v_j, v_k) } $$

其中 $Pred(v_k)$ 表示 $v_k$ 的所有前驱结点。 含义:顶点 $v_k$ 的最早开始时间,取决于它所有前驱活动完成的最晚时间。

2. 计算最迟发生时间 vl

  • 汇点 出发,设 vl(end) = ve(end)
  • 按照 逆拓扑排序 的顺序依次计算其余顶点的最迟发生时间:

$$ vl(k) = \min_{v_j \in Succ(v_k)} { vl(j) - weight(v_k, v_j) } $$

其中 $Succ(v_k)$ 表示 $v_k$ 的所有后继结点。 含义:顶点 $v_k$ 的最迟开始时间,取决于它所有后继活动最早开始的最早约束。

3. 确定关键路径

  • 若某个顶点满足 ve(i) = vl(i),则说明该顶点没有时间浮动,必须严格按时发生;
  • 将这些顶点按拓扑顺序连接起来,得到关键路径。
CriticalPath关键路径求解过程示例工程项目网络图cluster_3步骤4: 确定关键路径 (ve=vl)cluster_2步骤3: 计算最迟发生时间 vlcluster_1步骤2: 计算最早发生时间 vecluster_0步骤1: 原始AOV网络图cluster_legend图例A4Ave=vl=0B4Bve=vl=3A4->B43C4Cve=2≠vl=4A4->C42D4Dve=7≠vl=10B4->D44E4Eve=vl=9B4->E46C4->D41C4->E45F4Fve=vl=12D4->F42E4->F43result关键路径: A → B → E → F关键路径长度: 3 + 6 + 3 = 12A3Avl=0B3Bvl=3A3->B33C3Cvl=6A3->C32D3Dvl=10B3->D34E3Evl=9B3->E36C3->D31C3->E35F3Fvl=12D3->F32E3->F33note7vl(B) = min{vl(D)-4, vl(E)-6}= min{10-4, 9-6} = 3note8vl(C) = min{vl(D)-1, vl(E)-5}= min{10-1, 9-5} = 4note9vl(A) = min{vl(B)-3, vl(C)-2}= min{3-3, 4-2} = 0A2Ave=0B2Bve=3A2->B23C2Cve=2A2->C22D2Dve=7B2->D24E2Eve=9B2->E26C2->D21C2->E25F2Fve=12D2->F22E2->F23note1ve(D) = max{ve(B)+4, ve(C)+1}= max{3+4, 2+1} = 7note2ve(E) = max{ve(B)+6, ve(C)+5}= max{3+6, 2+5} = 9note3ve(F) = max{ve(D)+2, ve(E)+3}= max{7+2, 9+3} = 12A1A开始B1BA1->B13C1CA1->C12D1DB1->D14E1EB1->E16C1->D11C1->E15F1F结束D1->F12E1->F13legend_node• 红色/黄色节点: 关键节点 (ve=vl)• 灰色/蓝色节点: 非关键节点 (ve≠vl)• 红色粗线: 关键路径活动• 虚线/灰色线: 非关键活动

用图表达树

当表达式中存在共享的子表达式时,二叉树可能不是存储这些表达式的最有效方式,因为它会重复存储共享的子表达式。在这种情况下,可以使用 有向无环图DAG)来表示表达式,这样可以避免重复,并且更有效地表示表达式。

比如对于表达式 (x + y) * ((x + y) / x),用二叉树和 DAG 的表示如下图:

expression_comparison表达式 (x + y) * ((x + y) / x) 的两种表示方法cluster_tree二叉树表示(有重复)cluster_dagDAG表示(无重复,共享节点)tree_mult*tree_plus1+tree_mult->tree_plus1tree_div/tree_mult->tree_divtree_x1xtree_plus1->tree_x1tree_y1ytree_plus1->tree_y1tree_plus2+tree_div->tree_plus2tree_x3xtree_div->tree_x3tree_x2xtree_plus2->tree_x2tree_y2ytree_plus2->tree_y2dag_mult*dag_plus+dag_mult->dag_plusdag_div/dag_mult->dag_divdag_xxdag_plus->dag_xdag_yydag_plus->dag_ydag_div->dag_plusdag_x2xdag_div->dag_x2explanation   优势对比:   • 二叉树:7个节点,存储重复   • DAG:6个节点,避免重复   • DAG节点可以有多个父节点   • DAG更节省存储空间