数据结构基础--最小生成树
最小生成树
文章目录
- 最小生成树
- 最小生成树的定义:
- Prim(普里姆)算法
- Kruskal(克鲁斯卡尔)算法
- 最短路径
- BFS算法
- Dijkstra算法
- Floyd算法
- 有向无环图
- 拓扑排序
- 逆拓扑排序
- 关键路径
最小生成树的定义:
对于一个带权连通无向图 G=(V,E)G = (V, E)G=(V,E),生成树不同,每棵树的权(即树中所有边上的权值之和)也可能不同。设 R 为 G 的所有生成树的集合,若 T 为 R 中边的权值之和最小的生成树,则 T 称为 G 的最小生成树(Minimum - Spanning - Tree, MST)。
-
最小生成树可能有多个,但边的权值之和总是唯一且最小的
-
最小生成树的边数 = 顶点数 - 1。砍掉一条则不连通,增加一条边则会出现回路
-
如果一个连通图本身就是一棵树,则其最小生成树就是它本身
-
只有连通图才有生成树,非连通图只有生成森林
Prim(普里姆)算法
Prim(普里姆)算法定义:
从某一项顶点开始构建生成树,每次将代价最小的新顶点纳入生成树,直到所有顶点都纳入为止
时间复杂度 : O(∣V∣2)O(\vert V \vert^2)O(∣V∣2),适合用于边稠密图
从 V0V_0V0 开始,总共需要 n−1n - 1n−1 轮处理
每一轮处理:循环遍历所有结点,找到 lowCast 最低的,且还没加入树的顶点。
再次循环遍历,更新还没加入的各个顶点的 lowCast 值
每一轮的时间复杂度 : O(2n)
总时间复杂度 : O(∣V∣2)O(\vert V \vert^2)O(∣V∣2)
Kruskal(克鲁斯卡尔)算法
Kruskal(克鲁斯卡尔)算法定义:
每次选择一条权值最小的边,使这条边的两头连通(原本已经连通的就不选),直到所有结点都连通
时间复杂度:O(∣E∣log2∣E∣)O(\vert E \vert \log_2 \vert E \vert)O(∣E∣log2∣E∣),适合用于边稀疏图
初始:将各条边按权值排序
共执行 e 轮,每轮判断两个顶点是否属于同一集合,需要 O(log2e)O(\log_2 e)O(log2e)
总时间复杂度 O(elog2e)O(e\log_2 e)O(elog2e)
最短路径
常考问题:
单源最短路径问题
每对顶点间最短路径
graph TDA[最短路径问题] --> B[单源最短路径]A[最短路径问题] --> C[各顶点间的最短路径]B --> B1[BFS 算法(无权图)]B --> B2[Dijkstra 算法(带权图、无权图)]C --> C1[Floyd 算法(带权图、无权图)]%% 可选:自定义样式style A fill:#3498db, stroke:#333, stroke-width:2px, color:#fff, roundedCorners:10style B fill:#9b59b6, stroke:#333, stroke-width:2px, color:#fff, roundedCorners:10style C fill:#2ecc71, stroke:#333, stroke-width:2px, color:#fff, roundedCorners:10style B1 fill:#f39c12, stroke:#333, stroke-width:2px, color:#fff, roundedCorners:10style B2 fill:#f39c12, stroke:#333, stroke-width:2px, color:#fff, roundedCorners:10style C1 fill:#e74c3c, stroke:#333, stroke-width:2px, color:#fff, roundedCorners:10
PS: 无权图可以视为一种特殊的带权图,只是每条边的权值都为1
BFS算法
实现代码 :
// 求顶点 u 到其他顶点的最短路径
void BFS_MIN_Distance(Graph G, int u) {// d[i] 表示从 u 到 i 结点的最短路径for (i = 0; i < G.vexnum; ++i) {d[i] = ∞; // 初始化路径长度(∞ 需替换为实际无穷大值,如 INT_MAX 等)path[i] = -1; // 最短路径从哪个顶点过来}d[u] = 0;visited[u] = TRUE;EnQueue(Q, u);while (!isEmpty(Q)) { // BFS 算法主过程DeQueue(Q, u); // 队头元素 u 出队// 遍历 u 的邻接顶点for (w = FirstNeighbor(G, u); w >= 0; w = NextNeighbor(G, u, w)) {if (!visited[w]) { // w 为 u 的尚未访问的邻接顶点d[w] = d[u] + 1; // 路径长度加 1path[w] = u; // 最短路径应从 u 到 wvisited[w] = TRUE; // 设已访问标记EnQueue(Q, w); // 顶点 w 入队}}}
}
就是对BFS的小修改,在visit一个顶点时,修改其最短路径长度 d[] 并且在 path[] 记录前驱结点
Dijkstra算法
带权路径长度:当图是带权图时,一条路径上所有边的权值之和,称为该路径的带权路径长度
初始化 : 从 V0V_0V0 开始
令 final[0] = true; dist[0] = 0; path[0] = -1
。
其余顶点:final[k] = false
dist[k] = arcs[0][k]
path[k] = (arcs[0][k] == ∞) ? -1 : 0
n−1n - 1n−1 轮处理(循环逻辑):
循环遍历所有顶点,执行以下操作:
- 找到未确定最短路径且
dist
最小的顶点 (V_i),令final[i] = true
。 - 检查自ViV_iVi的顶点,对于所有邻接自ViV_iVi顶点的顶点VjV_jVj,若
final[j] == false
且dist[i] + arcs[i][j] < dist[j]
,则令dist[j] = dist[i] + arcs[i][j]
path[j] = i
(注:arcs[i][j]
表示 (V_i) 到 (V_j) 的弧的权值 )
时间复杂度:O(n2)O(n^2)O(n2) 即 O(∣V∣2)O(|V|^2)O(∣V∣2)
Floyd算法
Floyd 算法:求出每一对顶点之间的最短路径
使用动态规划思想,将问题的求解分为多个阶段
对于 n 个顶点的图 G,求任意一对顶点 Vi -> Vj 之间的最短路径可分为如下几个阶段:
初始:不允许在其他顶点中转,最短路径是?
0:若允许在 V₀ 中转,最短路径是?
1:若允许在 V₀、V₁ 中转,最短路径是?
2:若允许在 V₀、V₁、V₂ 中转,最短路径是?
…
n-1:若允许在 V₀、V₁、V₂ …… Vₙ₋₁ 中转,最短路径是?
规则:
若$ \mathrm{A}^{(k - 1)}[i][j] > \mathrm{A}^{(k - 1)}[i][k] + \mathrm{A}^{(k - 1)}[k][j]$
则执行更新:A(k)[i][j]=A(k−1)[i][k]+A(k−1)[k][j];\mathrm{A}^{(k)}[i][j] = \mathrm{A}^{(k - 1)}[i][k] + \mathrm{A}^{(k - 1)}[k][j];A(k)[i][j]=A(k−1)[i][k]+A(k−1)[k][j];
(path(k)[i][j]=k(\mathrm{path}^{(k)}[i][j] = k(path(k)[i][j]=k
否则:A(k)和path(k)\mathrm{A}^{(k)} 和 \mathrm{path}^{(k)}A(k)和path(k) 保持原值
核心实现代码:
//......准备工作,根据图的信息初始化矩阵 A 和 path
for (int k=0; k<n; k++){ //考虑以 vk 作为中转点for(int i=0; i<n; i++) { //遍历整个矩阵,i为行号,j为列号for (int j=0; j<n; j++){if (A[i][j]>A[i][k]+A[k][j]){ //以 vk 为中转点的路径更短A[i][j]=A[i][k]+A[k][j]; //更新最短路径长度path[i][j]=k; //中转点}}}
}
时间复杂度:O(n2)O(n^2)O(n2) 即 O(∣V∣3)O(|V|^3)O(∣V∣3)
空间复杂度:O(n2)O(n^2)O(n2) 即 O(∣V∣2)O(|V|^2)O(∣V∣2)
Floyd 算法不能解决带有“负权回路”的图(有负权值的边组成回路),这种图可能没有最短路径
有向无环图
有向无环图:若一个有向无环中不存在环,则称为有向无环图,简称DAG图
解题方法:
S1:把各个操作数不重复地排成一排
S2 : 标出各个运算符地生效顺序(先后顺序有点出入无所谓)
S3:按顺序加入运算符,注意 “分层”
S4 : 从底向上逐层检查同层的运算符是否可以合体
((a+b)∗(b∗(c+d))+(c+d)∗e)∗((c+d)∗e)
拓扑排序
AOV 网 :
AOV 网 (Activity On Vertex Network,用顶点表示活动的网):
用 DAG 图(有向无环图)表示一个工程。顶点表示活动,有向边<Vi,Vj><V_i, V_j><Vi,Vj>表示活动ViV_iVi必须先于活动VjV_jVj进行
拓扑排序的定义:
拓扑排序:在图论中,由一个有向无环图的顶点组成的序列,当且仅当满足下列条件时,称为该图的一个拓扑排序:
- 每个顶点出现且只出现一次。
- 若顶点A在序列中排在顶点B的前面,则在图中不存在从顶点B到顶点A的路径。
或定义为:
拓扑排序是对有向无环图的顶点的一种排序,它使得若存在一条从顶点A到顶点B的路径,则在排序中顶点B出现在顶点A的后面。每个AOV网都有一个或多个拓扑排序序列。
拓扑排序就是:找到做事情的先后顺序
拓扑排序的实现:
- 从AOV网中选择一个没有前驱(入度为0)的顶点并输出。
- 从网中删除该顶点和所有以它为起点的有向边。
- 重复步骤1和步骤2直到当前的AOV网为空或当前网中不存在无前驱的顶点为止。
拓扑排序的代码实现;
#define MaxVertexNum 100 //图中顶点数目的最大值
typedef struct ArcNode{ //边表结点int adjvex; //该弧所指向的顶点的位置struct ArcNode *nextarc; //指向下一条弧的指针//InfoType info; //网的边权值(可根据需要启用)
}ArcNode;typedef struct VNode{ //顶点表结点VertexType data; //顶点信息(VertexType需提前定义,如typedef char VertexType;)ArcNode *firstarc; //指向第一条依附该顶点的弧的指针
}VNode, AdjList[MaxVertexNum];typedef struct{AdjList vertices; //邻接表int vexnum, arcnum; //图的顶点数和弧数
}Graph; bool TopologicalSort(Graph G){InitStack(S); //初始化栈,存储入度为0的顶点(InitStack需提前实现,如用顺序栈或链栈)for(int i = 0; i < G.vexnum; i++)if(indegree[i] == 0) //indegree数组需提前定义,存储各顶点入度Push(S, i); //将所有入度为0的顶点进栈(Push需与InitStack匹配实现)int count = 0; //计数,记录当前已经输出的顶点数while(!IsEmpty(S)){ //栈不空,则存在入度为0的顶点(IsEmpty需匹配栈的实现)Pop(S, i); //栈顶元素出栈(Pop需正确实现,获取出栈顶点序号i)print[count++] = i;//输出顶点i(print数组需提前定义,存储拓扑序列)for(ArcNode *p = G.vertices[i].firstarc; p; p = p->nextarc){int v = p->adjvex; if(!(--indegree[v])) //将所有i指向的顶点的入度减1,若入度减为0则入栈Push(S, v);}}if(count < G.vexnum)return false; //排序失败,有向图中有回路elsereturn true; //拓扑排序成功
}
时间复杂度:O(∣V∣+∣E∣)O(|V| + |E|)O(∣V∣+∣E∣)
若采用邻接矩阵,则需O(∣V∣2)O(|V|^2)O(∣V∣2)
逆拓扑排序
逆拓扑排序的定义:
对一个AOV网,如果采用下列步骤进行排序,则称之为逆拓扑排序:
- 从AOV网中选择一个没有后继(出度为0)的顶点并输出。
- 从网中删除该顶点和所有以它为终点的有向边。
- 重复步骤1和步骤2直到当前的AOV网为空。
代码实现:
bool ReverseTopologicalSort(Graph G, int reverse_print[]) {int outdegree[MaxVertexNum]; // 存储各顶点出度// 初始化出度数组for (int i = 0; i < G.vexnum; i++) {outdegree[i] = 0;for (ArcNode *p = G.vertices[i].firstarc; p; p = p->nextarc)outdegree[i]++; // 统计每个顶点的出度}int stack[MaxVertexNum], top = -1; // 栈存储出度为0的顶点for (int i = 0; i < G.vexnum; i++)if (outdegree[i] == 0)stack[++top] = i; // 出度为0的顶点入栈int count = 0;while (top != -1) {int i = stack[top--]; // 出度为0的顶点出栈reverse_print[count++] = i; // 输出顶点// 查找所有指向i的顶点j,更新其出度for (int j = 0; j < G.vexnum; j++) {for (ArcNode *p = G.vertices[j].firstarc; p; p = p->nextarc) {if (p->adjvex == i) { // 找到j->i的边if (--outdegree[j] == 0) // j的出度减为0则入栈stack[++top] = j;break;}}}}return count == G.vexnum; // 有环则返回false
}
逆拓扑排序的实现(DFS)算法:
void DFSTraverse(Graph G){// 对图G进行深度优先遍历for(v=0;v<G.vexnum;++v)// 初始化已访问标记数据visited[v]=FALSE; for(v=0;v<G.vexnum;++v) // 本代码中是从v=0开始遍历if(!visited[v]) DFS(G,v);
}void DFS(Graph G,int v){// 设已访问标记visited[v]=TRUE; for(w=FirstNeighbor(G,v);w>=0;w=NextNeighbor(G,v,w))if(!visited[w]){ // w为u的尚未访问的邻接顶点DFS(G,w); }// 输出顶点print(v);
}
DFS实现逆拓扑排序:在顶点退栈前输出
关键路径
在带权有向图中,以顶点表示事件,以有向边表示活动,以边上的权值表示完成该活动的开销(如完成活动所需的时间),称之为用边表示活动的网络,简称AOE网 (Activity On Edge NetWork) 。
AOE 网具有以下两个性质:
- 只有在某顶点所代表的事件发生后,从该顶点出发的各有向边所代表的活动才能开始;
- 只有在进入某顶点的各有向边所代表的活动都已结束时,该顶点所代表的事件才能发生:另外,有些活动是可以并行进行的 。
在AOE网中仅有一个入度为0的顶点,称为开始顶点(源点),它表示整个工程的开始
也仅有一个出度为0的顶点,称为结束顶点(汇点),它表示整个工程的结束。
-
从源点到汇点的有向路径可能有多条,所有路径中,具有最大路径长度的路径称为关键路径,而把关键路径上的活动称为关键活动
-
完成整个工程的最短时间就是关键路径的长度,若关键活动不能按时完成,则整个工程的完成时间就会延长事件vkv_kvk的最早发生时间ve(k)ve(k)ve(k)—— 决定了所有从vkv_kvk开始的活动能够开工的最早时间
事件vkv_kvk的最迟发生时间vl(k)vl(k)vl(k)—— 它是指在不推迟整个工程完成的前提下,该事件最迟必须发生的时间。
活动aia_iai的最早开始时间e(i)e(i)e(i)—— 指该活动弧的起点所表示的事件的最早发生时间
活动aia_iai的最迟开始时间l(i)l(i)l(i)—— 它是指该活动弧的终点所表示事件的最迟发生时间与该活动所需时间之差。
活动aia_iai的时间余量d(i)=l(i)−e(i)d(i)=l(i)-e(i)d(i)=l(i)−e(i),表示在不增加完成整个工程所需总时间的情况下,活动aia_iai可以拖延的时间
若一个活动的时间余量为零,则说明该活动必须要如期完成,d(i)=0d(i)=0d(i)=0即l(i)=e(i)l(i)=e(i)l(i)=e(i)的活动aia_iai是关键活动
由关键活动组成的路径就是关键路径
Q : 求所有事件的最早发生时间 ve()ve()ve()
A : 按拓扑排序序列,依次求各个顶点的ve(k)ve(k)ve(k):ve(源点)=0ve(\text{源点}) = 0ve(源点)=0,
ve(k)=Max{ve(j)+Weight(vj,vk)}ve(k) = \text{Max} \{ ve(j) + \text{Weight}(v_j, v_k) \}ve(k)=Max{ve(j)+Weight(vj,vk)},vjv_jvj为 vkv_kvk 的任意前驱
以下图为例 :
拓扑序列:V1、V3、V2、V5、V4、V6
ve(1)=0ve(1) = 0ve(1)=0
ve(3)=2ve(3) = 2ve(3)=2
ve(2)=3ve(2) = 3ve(2)=3
ve(5)=6ve(5) = 6ve(5)=6
ve(4)=max{ve(2)+2,ve(3)+4}=6ve(4) = \max\{ve(2) + 2, ve(3) + 4\} = 6ve(4)=max{ve(2)+2,ve(3)+4}=6
ve(6)=max{ve(5)+1,ve(4)+2,ve(3)+3}=8ve(6) = \max\{ve(5) + 1, ve(4) + 2, ve(3) + 3\} = 8ve(6)=max{ve(5)+1,ve(4)+2,ve(3)+3}=8
Q : 求所有事件的最迟发生时间 vl()vl()vl()
A : 按逆拓扑排序序列,依次求各个顶点的 vl(k)vl(k)vl(k) :vl(汇点)=ve(汇点),vl(\text{汇点}) = ve(\text{汇点}),vl(汇点)=ve(汇点),
vl(k)=Min{vl(j)−Weight(vk,vj)}vl(k) = \text{Min} \{ vl(j) - \text{Weight}(v_k, v_j) \}vl(k)=Min{vl(j)−Weight(vk,vj)},vjv_jvj 为vkv_kvk 的任意后继
Q : 求所有活动的最早发生时间 e()e()e()
A : 若边<vk,vj>\lt v_k, v_j \gt<vk,vj>表示活动aia_iai,则有e(i)=ve(k)e(i) = ve(k)e(i)=ve(k)
Q : 求所有活动的最迟发生时间 l()l()l()
A : 若边<vk,vj>\lt v_k, v_j \gt<vk,vj>表示活动$aia_iai,则有l(i)=vl(j)−Weight(vk,vj)l(i) = vl(j) - \text{Weight}(v_k, v_j)l(i)=vl(j)−Weight(vk,vj)
Q : 求所有活动的时间余量 d()d()d()
A : d(i)=l(i)−e(i)d(i) = l(i) - e(i)d(i)=l(i)−e(i)$
-
若关键活动耗时增加,则整个工程的工期将增长
-
缩短关键活动的时间,可以缩短整个工程的工期
-
当缩短到一定程度时,关键活动可能会变成非关键活动
可能有多条关键路径,只提高一条关键路径上的关键活动速度并不能缩短整个工程的工期,只有加快那些包括在所有关键路径上的关键活动才能达到缩短工期的目的。