数据结构基础--最小生成树

最小生成树

文章目录

最小生成树的定义:

对于一个带权连通无向图 G = ( V , E ) G = (V, E) G=(V,E),生成树不同,每棵树的权(即树中所有边上的权值之和)也可能不同。设 R 为 G 的所有生成树的集合,若 T 为 R 中边的权值之和最小的生成树,则 T 称为 G 的最小生成树(Minimum - Spanning - Tree, MST)。

  • 最小生成树可能有多个,但边的权值之和总是唯一且最小的

  • 最小生成树的边数 = 顶点数 - 1。砍掉一条则不连通,增加一条边则会出现回路

  • 如果一个连通图本身就是一棵树,则其最小生成树就是它本身

  • 只有连通图才有生成树,非连通图只有生成森林

求最小生成树 Prim算法 Kruskal算法

Prim(普里姆)算法

Prim(普里姆)算法定义:

从某一项顶点开始构建生成树,每次将代价最小的新顶点纳入生成树,直到所有顶点都纳入为止

时间复杂度 : O ( ∣ V ∣ 2 ) O(\vert V \vert^2) O(∣V∣2),适合用于边稠密图

从 V 0 V_0 V0 开始,总共需要 n − 1 n - 1 n−1 轮处理

每一轮处理:循环遍历所有结点,找到 lowCast 最低的,且还没加入树的顶点。

再次循环遍历,更新还没加入的各个顶点的 lowCast 值

每一轮的时间复杂度 : O(2n)

总时间复杂度 : O ( ∣ V ∣ 2 ) O(\vert V \vert^2) O(∣V∣2)

Kruskal(克鲁斯卡尔)算法

Kruskal(克鲁斯卡尔)算法定义:

每次选择一条权值最小的边,使这条边的两头连通(原本已经连通的就不选),直到所有结点都连通

时间复杂度: O ( ∣ E ∣ log ⁡ 2 ∣ E ∣ ) O(\vert E \vert \log_2 \vert E \vert) O(∣E∣log2∣E∣),适合用于边稀疏图

初始:将各条边按权值排序

共执行 e 轮,每轮判断两个顶点是否属于同一集合,需要 O ( log ⁡ 2 e ) O(\log_2 e) O(log2e)

总时间复杂度 O ( e log ⁡ 2 e ) O(e\log_2 e) O(elog2e)

最短路径

常考问题:

单源最短路径问题

每对顶点间最短路径

graph TD A[最短路径问题] --> B[单源最短路径] A[最短路径问题] --> C[各顶点间的最短路径] B --> B1[BFS 算法(无权图)] B --> B2[Dijkstra 算法(带权图、无权图)] C --> C1[Floyd 算法(带权图、无权图)] %% 可选:自定义样式 style A fill:#3498db, stroke:#333, stroke-width:2px, color:#fff, roundedCorners:10 style B fill:#9b59b6, stroke:#333, stroke-width:2px, color:#fff, roundedCorners:10 style C fill:#2ecc71, stroke:#333, stroke-width:2px, color:#fff, roundedCorners:10 style B1 fill:#f39c12, stroke:#333, stroke-width:2px, color:#fff, roundedCorners:10 style B2 fill:#f39c12, stroke:#333, stroke-width:2px, color:#fff, roundedCorners:10 style C1 fill:#e74c3c, stroke:#333, stroke-width:2px, color:#fff, roundedCorners:10

PS: 无权图可以视为一种特殊的带权图,只是每条边的权值都为1

BFS算法

实现代码 :

c++ 复制代码
// 求顶点 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; // 路径长度加 1
                path[w] = u;     // 最短路径应从 u 到 w
                visited[w] = TRUE; // 设已访问标记
                EnQueue(Q, w);   // 顶点 w 入队
            }
        }
    }
}

就是对BFS的小修改,在visit一个顶点时,修改其最短路径长度 d[] 并且在 path[] 记录前驱结点

Dijkstra算法

带权路径长度:当图是带权图时,一条路径上所有边的权值之和,称为该路径的带权路径长度

初始化 : 从 V 0 V_0 V0 开始

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 − 1 n - 1 n−1 轮处理(循环逻辑):

循环遍历所有顶点,执行以下操作:

  1. 找到未确定最短路径dist 最小的顶点 (V_i),令 final[i] = true
  2. 检查自 V i V_i Vi的顶点,对于所有邻接自 V i V_i Vi顶点的顶点 V j V_j Vj,若final[j] == falsedist[i] + arcs[i][j] < dist[j],则令dist[j] = dist[i] + arcs[i][j] path[j] = i

(注:arcs[i][j] 表示 (V_i) 到 (V_j) 的弧的权值 )

时间复杂度: O ( n 2 ) 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];

( p a t h ( k ) [ i ] [ j ] = k (\mathrm{path}^{(k)}[i][j] = k (path(k)[i][j]=k

否则: A ( k ) 和 p a t h ( k ) \mathrm{A}^{(k)} 和 \mathrm{path}^{(k)} A(k)和path(k) 保持原值

核心实现代码:

c++ 复制代码
//......准备工作,根据图的信息初始化矩阵 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 ( n 2 ) O(n^2) O(n2) 即 O ( ∣ V ∣ 3 ) O(|V|^3) O(∣V∣3)

空间复杂度: O ( n 2 ) 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 图(有向无环图)表示一个工程。顶点表示活动,有向边 < V i , V j > <V_i, V_j> <Vi,Vj>表示活动 V i V_i Vi必须先于活动 V j V_j Vj进行

拓扑排序的定义:

拓扑排序:在图论中,由一个有向无环图的顶点组成的序列,当且仅当满足下列条件时,称为该图的一个拓扑排序:

  1. 每个顶点出现且只出现一次。
  2. 若顶点A在序列中排在顶点B的前面,则在图中不存在从顶点B到顶点A的路径。

或定义为:

拓扑排序是对有向无环图的顶点的一种排序,它使得若存在一条从顶点A到顶点B的路径,则在排序中顶点B出现在顶点A的后面。每个AOV网都有一个或多个拓扑排序序列。

拓扑排序就是:找到做事情的先后顺序

拓扑排序的实现:

  1. 从AOV网中选择一个没有前驱(入度为0)的顶点并输出。
  2. 从网中删除该顶点和所有以它为起点的有向边。
  3. 重复步骤1和步骤2直到当前的AOV网为空或当前网中不存在无前驱的顶点为止。

拓扑排序的代码实现;

c 复制代码
#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;    //排序失败,有向图中有回路
    else
        return true;     //拓扑排序成功
}

时间复杂度: O ( ∣ V ∣ + ∣ E ∣ ) O(|V| + |E|) O(∣V∣+∣E∣)

若采用邻接矩阵,则需 O ( ∣ V ∣ 2 ) O(|V|^2) O(∣V∣2)

逆拓扑排序

逆拓扑排序的定义:

对一个AOV网,如果采用下列步骤进行排序,则称之为逆拓扑排序:

  1. 从AOV网中选择一个没有后继(出度为0)的顶点并输出。
  2. 从网中删除该顶点和所有以它为终点的有向边。
  3. 重复步骤1和步骤2直到当前的AOV网为空。

代码实现:

c 复制代码
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)算法:

c 复制代码
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 网具有以下两个性质:

  1. 只有在某顶点所代表的事件发生后,从该顶点出发的各有向边所代表的活动才能开始;
  2. 只有在进入某顶点的各有向边所代表的活动都已结束时,该顶点所代表的事件才能发生:另外,有些活动是可以并行进行的 。

在AOE网中仅有一个入度为0的顶点,称为开始顶点(源点),它表示整个工程的开始

也仅有一个出度为0的顶点,称为结束顶点(汇点),它表示整个工程的结束。

  • 从源点到汇点的有向路径可能有多条,所有路径中,具有最大路径长度的路径称为关键路径 ,而把关键路径上的活动称为关键活动

  • 完成整个工程的最短时间就是关键路径的长度,若关键活动不能按时完成,则整个工程的完成时间就会延长事件 v k v_k vk的最早发生时间 v e ( k ) ve(k) ve(k)------ 决定了所有从 v k v_k vk开始的活动能够开工的最早时间

    事件 v k v_k vk的最迟发生时间 v l ( k ) vl(k) vl(k)------ 它是指在不推迟整个工程完成的前提下,该事件最迟必须发生的时间。

    活动 a i a_i ai的最早开始时间 e ( i ) e(i) e(i)------ 指该活动弧的起点所表示的事件的最早发生时间

    活动 a i a_i ai的最迟开始时间 l ( i ) l(i) l(i)------ 它是指该活动弧的终点所表示事件的最迟发生时间与该活动所需时间之差。

    活动 a i a_i ai的时间余量 d ( i ) = l ( i ) − e ( i ) d(i)=l(i)-e(i) d(i)=l(i)−e(i),表示在不增加完成整个工程所需总时间的情况下,活动 a i a_i ai可以拖延的时间

    若一个活动的时间余量为零,则说明该活动必须要如期完成, d ( i ) = 0 d(i)=0 d(i)=0即 l ( i ) = e ( i ) l(i)=e(i) l(i)=e(i)的活动 a i a_i ai是关键活动

关键活动 组成的路径就是关键路径

Q : 求所有事件的最早发生时间 v e ( ) ve() ve()

A : 按拓扑排序序列,依次求各个顶点的 v e ( k ) ve(k) ve(k): v e ( 源点 ) = 0 ve(\text{源点}) = 0 ve(源点)=0,

v e ( k ) = Max { v e ( j ) + Weight ( v j , v k ) } ve(k) = \text{Max} \{ ve(j) + \text{Weight}(v_j, v_k) \} ve(k)=Max{ve(j)+Weight(vj,vk)}, v j v_j vj为 v k v_k vk 的任意前驱

以下图为例 :

拓扑序列:V1、V3、V2、V5、V4、V6

v e ( 1 ) = 0 ve(1) = 0 ve(1)=0

v e ( 3 ) = 2 ve(3) = 2 ve(3)=2

v e ( 2 ) = 3 ve(2) = 3 ve(2)=3

v e ( 5 ) = 6 ve(5) = 6 ve(5)=6

v e ( 4 ) = max ⁡ { v e ( 2 ) + 2 , v e ( 3 ) + 4 } = 6 ve(4) = \max\{ve(2) + 2, ve(3) + 4\} = 6 ve(4)=max{ve(2)+2,ve(3)+4}=6

v e ( 6 ) = max ⁡ { v e ( 5 ) + 1 , v e ( 4 ) + 2 , v e ( 3 ) + 3 } = 8 ve(6) = \max\{ve(5) + 1, ve(4) + 2, ve(3) + 3\} = 8 ve(6)=max{ve(5)+1,ve(4)+2,ve(3)+3}=8

Q : 求所有事件的最迟发生时间 v l ( ) vl() vl()

A : 按逆拓扑排序序列,依次求各个顶点的 v l ( k ) vl(k) vl(k) : v l ( 汇点 ) = v e ( 汇点 ) , vl(\text{汇点}) = ve(\text{汇点}), vl(汇点)=ve(汇点),

v l ( k ) = Min { v l ( j ) − Weight ( v k , v j ) } vl(k) = \text{Min} \{ vl(j) - \text{Weight}(v_k, v_j) \} vl(k)=Min{vl(j)−Weight(vk,vj)}, v j v_j vj 为 v k v_k vk 的任意后继

Q : 求所有活动的最早发生时间 e ( ) e() e()

A : 若边 < v k , v j > \lt v_k, v_j \gt <vk,vj>表示活动 a i a_i ai,则有 e ( i ) = v e ( k ) e(i) = ve(k) e(i)=ve(k)

Q : 求所有活动的最迟发生时间 l ( ) l() l()

A : 若边 < v k , v j > \lt v_k, v_j \gt <vk,vj>表示活动$ a i a_i ai,则有 l ( i ) = v l ( j ) − Weight ( v k , v j ) 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)$

  • 若关键活动耗时增加,则整个工程的工期将增长

  • 缩短关键活动的时间,可以缩短整个工程的工期

  • 当缩短到一定程度时,关键活动可能会变成非关键活动

可能有多条关键路径,只提高一条关键路径上的关键活动速度并不能缩短整个工程的工期,只有加快那些包括在所有关键路径上的关键活动才能达到缩短工期的目的。

相关推荐
董董灿是个攻城狮6 小时前
适合小白的 DeepSeek 基本原理介绍
算法
阿维的博客日记6 小时前
LeetCode 165. 比较版本号 - 优雅Java解决方案
java·算法·leetcode
pusue_the_sun6 小时前
C语言强化训练(3)
c语言·开发语言·算法
草莓熊Lotso6 小时前
【C++】类型转换详解:显式与隐式转换的艺术
c++·经验分享·笔记·其他·算法
喜欢吃豆6 小时前
从像素到篇章:深入剖析光学字符识别(OCR)的技术原理
人工智能·算法·语言模型·自然语言处理·大模型·ocr
lifallen6 小时前
深入了解Flink核心:Slot资源管理机制
大数据·数据结构·数据库·算法·flink·apache
序属秋秋秋7 小时前
《C++进阶之STL》【红黑树】
开发语言·数据结构·c++·笔记·学习·stl
.桂花载酒.7 小时前
数据结构8---排序
数据结构
小欣加油8 小时前
leetcode 1576 替换所有的问号
c++·算法·leetcode·职场和发展