MOOC 数据结构 | 8. 图(下)

最小生成树问题

什么是最小生成树(Minimum Spanning Tree)

  • 是一棵
    • 无回路
    • |V|个顶点一定有|V| -1 条边
  • 生成
    • 包含全部顶点
    • |V| - 1条边都在图里         

                 

  • 边的权重和最小

结论:最小生成树存在 \leftrightarrow 图连通

贪心算法

  • 什么是“贪”:每一步都要最好的
  • 什么是“好”:权重最小的边
  • 需要约束:
    1. 只能用图里有的边
    2. 只能正好用掉|V| - 1条边
    3. 不能有回路

Prim算法 - 让一棵小树长大

1. 选择V1作为根结点

2. 找最小边,和这棵树有关系的往外长的,即和v1直接相关的最小的边为1,包含了v4

3. 往外长这棵树,以v1和v4为基础,能够往外长出去的。和这棵树有关联的,边权重小的有两个选择,v2和v3都是2,先选上面的2,所以包含了v2

4. 和这棵树相关的较小的是2,包含了v3

5. 和这棵树相关,比较小的边是3,但是会形成回路,所以不行,同理v1和v3间的边也不行。于是就是v4-v7间的边,包含了v7

6. 这棵树就和v6产生了关系,1是边最小的,所以这棵树又包含了v6

7.最后是v5,就是v7-v5这个边,包含v5

8.至此,7个顶点,6条边都收录了。

这个过程和Dijstra算法很像,但是像到什么程度呢?

 ->   

Prim算法:

(1)先随便选一个根结点s,收录进来;

(2)dist是V到生成树MST的最短距离,那么dist的初始化就分三种情况:

  1. 如果一开始顶点V和树是相连的,那么dist就初始化为边的权重:dist[V] = E(s,V)
  2. 如果一开始顶点V和s之间没有直接的边,那么就初始化为正无穷:dist[V] = +∞

(3)树的存法:不需要构建树,对每一个顶点存它的parent的编号,随便选s作为根结点,parent[s] = -1

(4)把V收录进MST:意味着V和这棵树的距离为0

(5)W未被收录:就是dist[W] != 0

(6)while执行结束的条件:这样的V不存在 --两种情况:1)所有的顶点都被收录  2)所欲未收录的顶点dist都是无穷大,图不连通,

(7)while如果结束的情况是剩下的顶点和树的距离都是无穷大,即图不连通,这种情况下就要判断一下树中收录的顶点的个数是否为|V|

(8)时间复杂度取决于“V = 未收录顶点中dist最小者”,如果使用暴力解决(扫描)的方法,那时间复杂度就是T = O(|V|²)

(9)Prim算法适合稠密图

Kruskal算法 - 将森林合并成树

针对于稀疏图(边数和点数一个数量级)

所谓将森林合并成树,就是在初始的状态下,认为每一个顶点都是一棵树。然后通过不断收录边,就把两个树并成了一棵树。每次就直接找权重最小的边,收录进来。

Kruskal算法的过程

1. 针对上图,可以看到有两条权重为1的边,所以就直接收录进来。

2. 下一条可以收录的边是两个权重为2的边:

3. 下一条不构成回路的权重最小的边是 v4-v7之间的边:

4. 下一条是v7-v5的边:

5.至此,7个顶点,6条边都包含了。所以肯定生成了最小生成树

伪代码

void Kruskal(Graph G)
{
    MST = { };            /*开始的时候没有边,所以是空集*/
    while (MST中不到|V| - 1 条 && E中还有边) {
        从E中取一条权重最小的边E(v,w);       /*最小堆*/
        将E(v,w)从E中删除;
        if (E(v,w)不在MST中构成回路)      /*并查集*/
            将E(v,w)加入MST;
        else
            彻底无视E(v,w);
    }
    if (MST中不到|V| -1 条边)
        Error("生成树不存在");
}

Kruskal算法说明:

1. 如何判断E(v,w)是否在MST中构成回路,可以使用“并查集”。
(1)一开始认为每个顶点都是一棵独立的树,也就相当于每一个顶点都是一个独立的集合;

(2)当收录一条边的时候,意味着把两棵树并成一棵,意味着把两个集合并成了一个集合;

(3)当新的边往树中放的时候,要先检查v是属于哪个集合,w是属于哪个集合,如果分别属于两个不同的树,那么加入边之后就并成了一棵,一定是不构成回路的;如果v和w本身已经在同一棵树里面,那么加入边一定会构成一条回路。

2. while结束有两种情况:

(1)MST中正好有|V|-1条边

(2)MST不到|V|-1条边,原图中的没有边了,意味着生成树不存在,也就是原图不连通

3. 如果“从E中取一条权重最小的边E(v,w)”使用最小堆(O(log|E|)),判断“E(v,w)不在MST中构成回路”用并查集,那么整个Kruskal算法的时间复杂度就是T = O(|E| log |E|)

拓扑排序

例:计算机专业排课

专业课的依赖关系图:

这种图叫做 AOV(Activity On Vertex)网络

该问题进行抽象,就是拓扑排序。

  • 拓扑序:如果图中从V 到 W有一条有向路径,则V一定排在W之前。满足此条件的顶点序列称为一个拓扑序
  • 获得一个拓扑序的过程就是拓扑排序
  • AOV如果有合理的拓扑序,则必定是有向无环图(Directed Acyclic Graph,DAG)

     

针对上面提到的计算机专业排课,根据AOV网络,可以有如下排课:

1. 第一个学期可以排没有预修课程的顶点(AOV网络中没有预修课程的顶点有C1, C2, C8,C4)

先输出C1,同时将边删掉

继续输出C2,同时将边删掉:

此时可以发现图中C3和C13都没有前驱结点,但是此时输出不太好。所以不选择这两个顶点。继续输出C8和C4:

2. 同理,第二学期可以输出C3,C13,C9, C5:

3. 第三学期就可以输出C7 ,C6:

4. 第四学期就可以输出C12, C10,C11,C15

5. 最后一个学期就输出C14:

这样,就完成了一个排课的过程。这个过程也就是拓扑排序的过程。规律是选择入度为0的顶点输出,当图中的所有顶点都被抹光(边被删除)之后,拓扑过程也就结束了。

算法

void TopSort()
{
    for(cnt = 0; cnt < |V|; cnt++) {
        V = 未输出的入度为0的顶点; 
        if(这样的V不存在) { //如果外循环没有结束,而找不到这样的V了,意味着图中还有顶点没有输出,但是有边指向它
            Error("图中有回路"); //不是合理的AOV
            break;
        }
        
        输出V, 或者记录V的输出序号;
        for(V 的每个邻接点 W)
            Indegree[W]--;  //就是将输出的顶点包含边抹掉,抹掉也就是将输出顶点的邻接点的入度减1
    }
}

时间复杂度

取决于“V = 未输出的入度为0的顶点; ” 是怎么找的:

1. 如果用扫描所有点的方法,那么“V = 未输出的入度为0的顶点; ”的时间复杂度是 T = O(|V|),而整体的时间复杂度是T = O(|V|²)

2. 聪明的算法:

  随时将入度变为0的顶点放到一个容器(数组、堆栈、队列或链表等)里 ----时间复杂度就变为常数级的 (如下是用队列保存) 

void TopSort()
{
    for(图中每个顶点V)
        if ( Indegree[V] == 0)
            Enqueue(V, Q);
    while(!IsEmpty(Q)) {
        V = Dequeue(Q);
        输出V,或者记录V的输出序号; 
        cnt++;    //记录输出的顶点个数,方便后续判断是否有回路
        for(V的每个邻接点W) {
            if(--Indegree[W] == 0)
                Enqueue(W, Q);
        }
    }
    if(cnt != |V|) 
        Error("图中有回路");
}

每个顶点会入队1次,因为检查了V的每个邻接点,所以每条边也被扫描了一次,所有总的时间复杂度是T = O(|V| + |E|)此算法可以用来检测有向图是否DAG。

完整代码

/* 邻接表存储 - 拓扑排序算法 */
 
bool TopSort( LGraph Graph, Vertex TopOrder[] )
{ /* 对Graph进行拓扑排序,  TopOrder[]顺序存储排序后的顶点下标 */
    int Indegree[MaxVertexNum], cnt;
    Vertex V;
    PtrToAdjVNode W;
       Queue Q = CreateQueue( Graph->Nv );
  
    /* 初始化Indegree[] */
    for (V=0; V<Graph->Nv; V++)
        Indegree[V] = 0;
         
    /* 遍历图,得到Indegree[] */
    for (V=0; V<Graph->Nv; V++)
        for (W=Graph->G[V].FirstEdge; W; W=W->Next)
            Indegree[W->AdjV]++; /* 对有向边<V, W->AdjV>累计终点的入度 */
             
    /* 将所有入度为0的顶点入列 */
    for (V=0; V<Graph->Nv; V++)
        if ( Indegree[V]==0 )
            AddQ(Q, V);
             
    /* 下面进入拓扑排序 */ 
    cnt = 0; 
    while( !IsEmpty(Q) ){
        V = DeleteQ(Q); /* 弹出一个入度为0的顶点 */
        TopOrder[cnt++] = V; /* 将之存为结果序列的下一个元素 */
        /* 对V的每个邻接点W->AdjV */
        for ( W=Graph->G[V].FirstEdge; W; W=W->Next )
            if ( --Indegree[W->AdjV] == 0 )/* 若删除V使得W->AdjV入度为0 */
                AddQ(Q, W->AdjV); /* 则该顶点入列 */ 
    } /* while结束*/
     
    if ( cnt != Graph->Nv )
        return false; /* 说明图中有回路, 返回不成功标志 */ 
    else
        return true;
}

关键路径问题

是拓扑排序的应用。

AOE(Activity On Edge) 网络,一般用于安排项目的工序

             

            

例子:

因为有虚线5到4,这个AOE网络表示持续时间为9和7的任务都要等到前面的持续时间1,1和2的任务完成后才能开始执行。

问题1:整个工期有多长? Earliest[8] = 18

递推公式:

问题2:哪几个组有机动时间?

 倒推公式,从最后一个顶点开始:

  

完整的倒推结果:

机动时间计算公式:

如上图的顶点2和顶点0之间:D<0,2> = Latest[2] - Earliest[0] - C<0,2> = 6 - 0 - 4 = 2。

可见,有三个组有机动时间:

关键路径:由绝对不允许延误的活动组成的路径。 上图的关键路径不止一条,由那些没有机动时间的任务组成的路径。
 

 

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值