一篇带你搞定数据结构最小生成树问题

421 阅读12分钟

数据结构入门学习(全是干货)——图论问题之最小生成树

1 最小生成树问题 (Minimum Spanning Tree)

1. 最小生成树的定义

  • 是一棵树

    • 无回路:生成树中不能有环。
    • |V| 个顶点和 |V|-1 条边:最小生成树包含所有顶点,且恰好有 |V|-1 条边。
  • 是生成树

    • 包含全部顶点:生成树必须覆盖图中的所有顶点。
    • 边必须在原图中:所有生成树的边都来自原始图。

  • 添加任何一条边都会形成回路:最小生成树如果再加上一条边,一定会形成一个环。

  • 权重和最小:生成树中的边的权重和是最小的。

  • 连通图保证存在最小生成树:如果图是连通的,则必定存在最小生成树;图连通是生成树存在的前提。

2. 贪心算法

  • 贪心:每一步都选择当前最优的决策,通常指选择权重最小的边。
  • 选择标准:每次选择权重最小且不会导致回路的边。
  • 约束
    • 只能选择原图中的边。
    • 最终选取的边数必须为 |V|-1。
    • 不能形成环。

1.1 Prim 算法——让一棵小树长大

  • 初始状态:从图中的某个顶点开始,逐步将与生成树不连通的最小权重边加入生成树。

收到一半:

因为为了不直接形成回路,所以接下来不能走v2到v4的那条边,不然就直接结束了

最后生成的样子为:

上图的收集算法就是Prim算法,收集的过程有点像Dijkstra算法

回顾Dijkstra算法


void Dijkstra(Vertex s)
{
    while(1){
        V = 未收录顶点中dist最小者;
        if( 这样的V不存在 )
            break;
        collected[V] = true;
        for( V 的每个邻接点 W )
            if(collected[W] == false)
                if( dist[V]+E<v,w> < dist[W] ){
                    dist[W] = dist[V] + E<v,w>;
                    path[W] = V;
                }
    }
}

Prim算法

void Prim()
{
    MST = {s};//跟上方不同的是这里需要先生成一个最小生成树,开始的时候随便选一个根结点s收录进来。这个树怎么存?未必需要真的定义树节点,把树构建出来。可以parent[s] = -1,就是他的父节点的编号,跟并查集里面的概念差不多。这样当我们发现他的parent的
    while(1){
        V = 未收录顶点中dist最小者;//dist在这个最小生成树的问题里被定义成一个顶点v到这棵生成树的最小距离(他跟这个生成树里面已经被收罗进去的顶点之间,所有的距离里面最小的那个,把它定义成是v到这棵树的距离,我们每一次是要从这个距离里面找一个最小的)
        //Prim算法中的dist[V]应该初始化为:E(s,V)或者正无穷
        //E(s,V):顶点到这个树的距离是这个边的权重
        //如果V跟s之间没有直接的边的话,那我们一定要把它定义成正无穷(跟Dijkstra类似)
        if( 这样的V不存在 )//v不存在就直接跳出来,这时候有两种情况:一种就是全部顶点收录了,如果是这种情况那程序顺利结束
            //第二种情况就是所有未收录的顶点的dist全部都是无穷大,意味着剩下那些顶点到这棵树之间都没有边,整个图是不连通的
            break;
        将V收录进MST:把V收进最小生成树意味着这个顶点到这棵树的距离就变成0了(因为他已经是这棵树的一部分了)
        dist[V] = 0;//设成0就相当于已经收进来了
        for( V的每个邻接点 W )
            if( W未被收录 )//dist不是0就意味着没被收录
                if(E(v,w) < dist[W] ){//v到w之间有一条直接的边,而这个边的距离是小于原始的dist
                    dist[W] = E(v,w);//更新一下这个w的距离。v可能是边直接指向w的
                    parent[W] = V;
                }
    }
    if( MST中收的顶点不到|V|个 )
        Error("生成树不存在 or 图不连通")
  • 算法流程

    1. 选择任意一个顶点作为起点,初始化生成树。
    2. 选择生成树与图中不在生成树中的最小权重边,将其加入生成树。
    3. 重复该过程,直到生成树包含所有顶点。
  • 特点:Prim 算法类似于 Dijkstra 算法,适合用于稠密图。

1.2 Kruskal 算法——将森林合并成树

  • 贪心策略:Kruskal 算法直接从最小权重的边开始,逐步将边加入到生成树中。

  • 初始状态:每个顶点都看作一棵树,初始有 |V| 棵树。

什么叫做把森林合并成树呢?就是在初始的状况下认为每一个顶点都是一颗树,然后通过不断的把边收进来,就把两棵树合并成一棵树了,最后就是把所有的7个节点并成一棵树

边为1收完收2,2收完看3和4和5,3跟部分4和5会形成回路所以跳过

到6的时候形成了最小生成树:

以上就是Kruskal算法的基本思想

  • 算法流程

    1. 选择权重最小的边,如果这条边连接的两个顶点属于不同的树,则合并这两棵树。
    2. 重复该过程,直到所有顶点都属于同一棵树(最终的最小生成树)。
  • 特点:Kruskal 算法适合稀疏图,使用了并查集来检测是否会形成环。

Kruskal 算法的伪代码

Kruskal():
    初始化每个顶点为单独的一棵树
    对所有边按权重从小到大排序
    对每条边(u, v):
        如果 u 和 v 不属于同一棵树:
            将边(u, v)加入生成树
            合并 u 和 v 所在的两棵树
void Kruskal( Graph G )
{
    MST = { };//刚开始为空集
    while( MST 中不到|V|-1 条边 && E中还有边 ){
        从 E 中取一条权重最小的边E(v,w);//(v,w)是下标,上面用小括号括起来的v,w也都是。时间复杂度取决于这一步(是所有边都搜集一遍还是其他的方法,采用最小堆的方法是最好的)/*最小堆*/
        将 E(v,w)从 E 中删除;//E(v,w)是边集合
        if( E(v,w)不在 MST 中构成回路)//检查这条边加到最小生成树之后是否构成回路/*并查集*/
            将E(v,w)加入MST;//不构成的话边就被加进来了
        else
            彻底无视 E(v,w);
    }
    //while判定的时候不到|v|-1条边就跳出来的情况是:还没收满,原图里面的边就都已经被删光了,没有边了
    if( MST 中不到|V|-1条边 )
        Error("生成树不存在");
}
当图非常稀疏的时候,也就是E跟v差不多同一个数量级的时候
T = O(|E|log|E|)

并查集:一开始认为每个顶点都是独立的一棵树(集合),当我们把一条边收进去的时候就意味着把两棵树并成一棵
  • 并查集:用于检测两个顶点是否属于同一棵树,合并不同的树。

2 拓扑排序

拓扑排序用于对有向无环图(DAG)进行线性排序,满足每个顶点的前驱节点在其之前输出。

2.1 拓扑排序

例:计算机专业课排课

把课程列表转换为图,其中顶点代表课程,则从V到W有一条边代表:V是W的预修课程

以下是专业课的依赖图(也叫做AOV,是Activity On Vertex的缩写)

以上抽象成一个拓扑排序的问题

  1. 拓扑序:如果图中从V到W有一条有向路径,则V一定排在W之前。满足此条件的顶点序列称为一个拓扑序。(意思就是要学习W之前必须要学习V的话,那么在输出的时候V一定要在W之前被输出)
  2. 获得一个拓扑序的过程就是拓扑排序
  3. AOV如果有合理的拓扑序,则必定是有向无环图(Directed Acyclic Grap,简称DAG)

如图:

什么是合理的拓扑序?有环意味着这个活动他自己是他自己的前驱节点(也就是他在他开始之前就必须要已经结束了),而V必须在V开始之前结束是不合理的。所以这个是不行的,得不到合理的拓扑序

经过排版得出:

规律是:每次我们要输出哪个顶点?输出没有前驱顶点的那个顶点

怎么只是没有前驱顶点?对于顶点来说,我们有两个度可以记得,一个是入度一个是出度

没有前驱顶点的那些课程的特点:他们的入度都是0,没有任何一条边指向他。

拓扑序步骤:

  1. 在输出的时候我们要选择入度为0的那个顶点

  2. 在输出的同时我们要把这个顶点从原始的图里面彻底抹掉,当我们把原来的图抹光的时候,一个正常的拓扑序就产生了

  • 拓扑序:如果图中存在一条从顶点 V 到顶点 W 的路径,则 V 必须在 W 之前被输出。

  • 性质:若图有一个合理的拓扑序列,则它必定是一个有向无环图 (DAG)。

拓扑排序算法

  1. 入度为 0 的顶点:每次选择入度为 0 的顶点输出,并删除它以及它指向的边。
  2. 删除顶点及边:在输出一个顶点后,更新与它相邻顶点的入度。
  3. 重复:直到图中的所有顶点都被输出。

伪代码:

TopologicalSort():
    初始化所有顶点的入度
    将所有入度为 0 的顶点加入队列
    while 队列不为空:
        取出队列中的顶点 u,输出它
        删除顶点 u 以及它的所有出边
        对所有与 u 相邻的顶点 v:
            入度减少 1
            如果 v 的入度为 0,将 v 加入队列
void TopSort()
{
    for( cnt = 0; cnt < |V|; cnt++ ){
        //在TopSort函数中,如果外循环还没结束,就已经找不到“未输出的入度为0的顶点”,则说明:图中必定存在回路
        V = 未输出的入度为0的顶点;//程序复杂度的复杂程度主要取决于这一步。
        //方法1:简单粗暴的把所有点都扫描一遍然后去找那个没有输出过的且入度又为0的顶点,复杂度就为O(|V|)。
        //整体复杂度:T = O(|V|²)	这是不聪明的做法
        //方法2(聪明的算法):随时将入度变为0的顶点放到一个容器(可以理解为任何东西,数组、链表、堆栈、队列之类里都可以,总之放到一个特殊的地方)里面。这样就不用重新去扫描所有的顶点集合,直接从容器里面取一个出来就行了,这样复杂度就直接变成常数级别的了
        if( 这样的V不存在 ){
            Error("图中有回路");
            break;
        }
        输出V,或者记录V的输出序号;
        for( V 的每个邻接点 W )
            Indegree[W]--;//意味着把V到W的这条边给减掉了,W少了一条进来的边,所以入度就会减少1。就是把V抹掉的意思(抹掉不是删掉)
    }
}

应用:

  • 课程安排问题:当课程之间有依赖关系时,可以通过拓扑排序安排课程顺序。

2.2 关键路径

关键路径是用于表示在 AOE(Activity on Edge)网络中不能延误的路径。

  1. 一般用于安排项目的工序
Earliest:所有的任务最早的完成时间
Latest:所有任务最晚完成时间


0顶点表示开始,8顶点表示结束
每一条边代表一件事情
每件事情按照相互依赖的顺序完成了之后到达顶点8为结束
边上的权重代表持续时间
边之间的关系:两个小组之间开工就必须两个小组都完工才能往下走

假设不仅得12组结束,同时还需要等3一起全部完工才能往下走

  • 问题 1:整个工期有多长:通过关键路径算法可以计算项目的最短完成时间。
  • 问题 2:哪些组有机动时间:机动时间表示一个任务可以延迟的最大时间,而不影响项目的总工期。

绿色部分为机动时间:

  • 关键路径:整个项目工期中,任何一个任务的延误都会影响整个工期。

图的例题——旅游规划问题

问题描述

  1. 城市为结点,公路为边

    • 权重 1:距离。
    • 权重 2:收费。

    红色是起点,蓝色是终点。;绿色字体是距离,紫色字体是收费

  2. 单源最短路径

    • Dijkstra 算法:求从起点到终点的最短路径。
    • 权重相同时按收费更新:在路径长度相同时,优先选择收费较少的路径。

Dijkstra 算法的核心逻辑

Dijkstra(Vertex s):
    初始化距离数组 dist 和前驱节点 path
    while (有未收录顶点):
        V = 未收录顶点中 dist 最小者
        将 V 加入已收录集合
        对 V 的每个邻接点 W:
            if dist[V] + 权重 < dist[W]:
                更新 dist[W] 和 path[W]
            else if (距离相等 && 新的收费更小):
                更新费用 cost[W] 和 path[W]

拓展问题

  1. 最短路径条数:计算从源点到各顶点的最短路径有多少条。
  2. 边数最少的最短路径:在求最短路径的同时,确保使用的边数最少。

算法思路:

  • 如果找到更短路径,更新路径和计数器。
  • 如果找到等长路径,增加路径计数。