11--图的应用之最小生成树

472 阅读11分钟

关于图的存储等其他数据结构相关的知识,请看文章# 数据结构与算法基础知识文章汇总、本文来探讨一下图的最小生成树问题。

一、连通图的生成树

1.定义

连通图的生成树是一个图的连通子图,这个连通子图含有图中全部的N个顶点和连通这N个顶点的N-1条边

2.特征

  • 1.子图是一个连通图,即所有的N个顶点都通过N-1条边连接在一起;
  • 2.子图是含有原图中的N个顶点
  • 3.子图边的数量是N-1条边

3.举例

a.例1 image.png

图1是原图,图2是子图,子图含有原图中的所有8个顶点,并且有8-1=7条边把这8个顶点连接起来了,所以图2是图1的连通图的生成树

b.例2

image.png

图3是原图,图4是子图,子图含有原图中的所有8个顶点,并且有8-1 = 7条边,但是这7条边没有把这8个顶点连接起来,顶点A B C DE F G H之间是没有边相连的,所以图4不是图3的连通图的生成树。

二、连通图的最小生成树

假设有如下图这样一个图,图的顶点信息和边信息如下图中所示:

image.png 思考:图中有9个顶点,如何找到确定的8条边,使得连接所有顶点的边的权值的和最小?

下面我们来找几个连通图的生成树:

1.生成树一

image.png

边的权值和为: 11 + 26 + 20 + 22 + 18 + 21 + 24 + 19 = 161

2.生成树二

image.png

边的权值和为:8 + 12 + 10 + 11 + 17 + 19 + 16 + 7 = 110

3.生成树三

image.png

边的权值和为:8 + 12 + 10 + 11 + 16 + 19 + 16 + 7 = 99

通过上面找到的生成树一、二、三的边的权值和的比较,我们最终找到了比较小的生成树,即生成树三。那么生成树三是不是最小的那棵生成树呢?读者可以尝试再找找看是否有比生成树三的边的权值和更小的生成树!

总之,图的最小生成树就是对于拥有N个顶点的树,要找到有N-1条边,使得这N-1条边将N个顶点都连接在一起,并且这N-1条边的权值和最小。

前面讲了这么多,求图的最小生成树有什么用呢?那用途可就大了,下面举个🌰。

image.png

湖南省江华瑶族自治县有9镇7乡共16个乡镇,分布在全县的各个地方,乡镇与乡镇之间的距离都不一样,有的乡镇间隔远,有和乡镇间隔近。假设现在要在江华县的各个镇乡之间修建高压电线,使得各个乡镇都通上电,那么如何规划铺设高压电线的线路图,使得使用的电缆最少呢?

这个问题就是图的最小生成树在现实生活中最典型的应用。下面我们介绍两种求解图的最小生成树的算法:Prim算法和Kruskal算法

三、最小生成树的Prim算法

1.Prim算法介绍

Prim算法是1957年由美国计算机科学家罗伯特·普里姆设计出来的,关于算法的介绍,可以看Prim算法百度百科

2.Prim算法实现思路

首先将如下的图使用邻接矩阵的顺序存储到计算机内存中

image.png

第0步:默认从V0开始查找,标记V0已经加入了最小生成树

第一步:找到V1

image.png

  • 1.与V0连接的顶点有V1和V5,它们的边的权值分别为10和11
  • 2.由于10比较小,所以选择V1,将V0与V1连接起来,标记V1已经加入最小生成树。

第二步:找到V2

image.png

  • 1.与V0连接的V5,与V1连接的有V2、V8、V6。它们的边的权值分别为11、18、12、16,选择最小权的边连接的顶点;
  • 2.11最小,所以选择V5,将V0与V5连接起来,标记V5已经加入最小生成树。

第三步:找到V8

image.png

  • 1.第二步中剩余未比较的权值有18、12、16,与V5有连接的顶点有V6、V4和V0,因为V0已经加入了最小生成树,所以不用管。而V6的边的权值已经记录了,也不用管,于是只需要将V4的边的权值26加入比较即可;
  • 2.18、12、16、26中最小的是12,所以选择V8,连接V1与V8,标记V8已经加入最小生成树。

第四步:找到V2

image.png

  • 1.第三步剩余未比较的权值有18、16、26,与V8有连接的顶点有V2和V3,连接的边的权值为8和21
  • 2.18、16、26、8、21中最小的是8,所以选择V2,连接V8与V2,标记V2已经加入最小生成树。

第五步:找到V6

image.png

  • 1.第四步中剩余未比较的权值有18、16、26、21,与V2有连接的顶点有V1、V8、V3,因为V1和V8已经加入了最小生成树,所以不用管,只需要将V3的权值22加入比较即可;
  • 2.18、16、26、21、22中最小的是16,所以选择V6,连接V1与V6,标记V6已经加入最小生成树。

第六步:找到V7

image.png

  • 1.第五步剩余未比较的权值有18、26、21、22,与V6有连接的顶点有V3和V4,连接的边的权值为24和19
  • 2.由于V1和V2已经加入最小生成树,所以18移除26、21、22、24、1919最小,所以选择V7,连接V6与V7,标记V7已经加入最小生成树。

第七步:找到V4

image.png

    1. 第六步中剩余未比较的权值有26、21、22、24,与V7有连接的顶点有V3和V4,它们边的权值为16和7
  • 2.26、21、22、24、16、7中最小的是7,所以选择V4,连接V7与V4,标记V4已经加入最小生成树。

第八步:找到V3

image.png

  • 1.第七步中剩余未比较的权值有26、21、22、24、16,与V3有连接的顶点有V2、V8、V6、V7、V4,由于它们都已经加入了最小生成树,所以22、21、24、20不参入比较,最终只剩余16
  • 2.16只有一个值,所以选择V3,连接V7与V3,标记V3已经加入最小生成树。

第九步:结束 此时,已经没有剩余的权值可以比较了,结束查找,最终得到了最小生成树如如: image.png

最小生成树的权值和为:8 + 12 + 10 + 11 + 16 + 19 + 16 + 7 = 99

3.代码实现思路

  • 1.定义两个数组,adjvex用来保存相关顶点下标,即数组下标与所存的值的关系;lowcost保存加入比较的权值,如果未加入比较,而数组下标所对应的顶点的权值为无穷大;
  • 2.初始化adjvex数组和lowcost数组,从V0开始寻找最小生成树,默认V0是最小生成树的第一个顶点;所以adjvex的初始值全为0lowcost数组的初始值为与V0的边表数组
  • 3.循环lowcost数组,找到边的权值的最小值,其下标记为k,k即为我们要查找的顶点下标
  • 4.根据k的边表数组,找到与k有连接的顶点的边的权值,更新lowcost数组;
  • 5.循环所有顶点,找到与顶点k有连接的顶点,更新lowcost数组和adjvex数组。

注意 :更新lowcost数组和adjvex数组的条件!

  • 1.与顶点k之间没有连接
  • 2.当前顶点j没有加入过最小生成树;
  • 3.顶点k与当前顶点j之间的权值小于顶点j其他顶点之间的权值,则更新。即更新lowcost数组时,需要判断更新的值是否小于原位置的值。

4.代码实现

1.定义状态值和数据类型

#define OK 1

#define ERROR 0

#define TRUE 1

#define FALSE 0

#define MAXEDGE 20

#define MAXVEX 20

#define INFINITYC 65535

typedef int Status;    //Status是函数的类型,其值是函数结果状态代码,如OK等

2.设计邻接矩阵顺序存储的数据结构

typedef struct
{

    int arc[MAXVEX][MAXVEX];//边表数组

    int numVertexes, numEdges;//顶点数和边数

}MGraph;

这里用不到顶点数据,所以不存

3.邻接矩阵顺序存储实现

void CreateMGraph(MGraph *G)
{
    int i, j;

    /* printf("请输入边数和顶点数:"); */
    G->numEdges=15;
    G->numVertexes=9;

    for(i = 0; i < G->numVertexes; i++)// 初始化图
    {
        for( j = 0; j < G->numVertexes; j++)
        {
            if (i==j)
                G->arc[i][j]=0;
            else
                G->arc[i][j] = G->arc[j][i] = INFINITYC;
        }
    }
    
    G->arc[0][1]=10;
    G->arc[0][5]=11;
    G->arc[1][2]=18;
    G->arc[1][8]=12;
    G->arc[1][6]=16;
    G->arc[2][8]=8;
    G->arc[2][3]=22;
    G->arc[3][8]=21;
    G->arc[3][6]=24;
    G->arc[3][7]=16;
    G->arc[3][4]=20;
    G->arc[4][7]=7;
    G->arc[4][5]=26;
    G->arc[5][6]=17;
    G->arc[6][7]=19;

    //无向图对角线对称
    for(i = 0; i < G->numVertexes; i++)
    {
        for(j = i; j < G->numVertexes; j++)
        {
            G->arc[j][i] =G->arc[i][j];
        }
    }
}

4.Prim算法实现

void MiniSpanTree_Prim(MGraph G)
{
    int min, i, j, k;
    int sum = 0;

    //保存相关顶点下标
    int adjvex[MAXVEX];

    //保存相关顶点间边的权值
    int lowcost[MAXVEX];

    //初始化第一个权值为0,即v0加入生成树

    //lowcost的值为0,在这里就是此下标的顶点已经加入生成树
    lowcost[0] = 0;

    //初始化第一个顶点下标为0
    adjvex[0] = 0;

    //1. 初始化
    for(i = 1; i < G.numVertexes; i++)    
    {   
        lowcost[i] = G.arc[0][i]; //保存V0的边表数组,即与V0有连接的顶点的边信息
        adjvex[i] = 0;  //初始化都为v0的下标
    }

    //2. 循环除了下标为0以外的全部顶点, 找到lowcost数组中最小的顶点k
    for(i = 1; i < G.numVertexes; i++)
    {
        //初始化最小权值为∞
        //通常设置为不可能的大数字如32767、65535等
        min = INFINITYC;
        j = 1;k = 0;
        
        //找到lowcost数组中最小的权值的所对应的下标,即为要找的顶点的下标
        while(j < G.numVertexes)
        {
            //如果权值不为0且权值小于min,为0表示已经加入了最小生成树了
            if(lowcost[j] != 0 && lowcost[j] < min)
            {
                //则让当前权值成为最小值,更新min
                min = lowcost[j];

                //将当前最小值的下标存入k
                k = j;
            }
            j++;
        }

        //打印当前顶点边中权值最小的边
        //顶点adjvex[k] 与 顶点k之间连接的边即为要找的最小生成树的边
        printf("(V%d, V%d)=%d\n", adjvex[k], k ,G.arc[adjvex[k]][k]);
        //最小生成树的边的和
        sum += G.arc[adjvex[k]][k];

        //3.将当前顶点的权值设置为0,表示此顶点已经加入最小生成树
        lowcost[k] = 0;

        /* 循环所有顶点,找到与顶点k 相连接的顶点
         1. 与顶点k 之间连接;
         2. 该结点没有被加入到生成树;
         3. 顶点k 与 顶点j 之间的权值 < 顶点j 与其他顶点的权值,则更新lowcost 数组;
         */
         
         //循环顶点k的边表数组,如果对应的边的权值小于lowcost对应位置(j)的值,则更新lowcost数组
        for(j = 1; j < G.numVertexes; j++)
        {
            //如果顶点k与顶点j的边权值小于lowcost的j位置的权值,并且j未加入最小生成树,则更新lowcost[j]
            if(lowcost[j] != 0 && G.arc[k][j] < lowcost[j])
            {
                //将较小的权值存入lowcost相应位置
                lowcost[j] = G.arc[k][j];

                //将下标为k的顶点存入adjvex
                adjvex[j] = k;
            }
        }
    }
    printf("sum = %d\n",sum);
}

5.调试代码

    printf("Hello,最小生成树_Prim算法\n");

    MGraph G;

    CreateMGraph(&G);

    MiniSpanTree_Prim(G);

5.代码执行过程

第一次执行

image.png

第二次执行

image.png

第三次执行

image.png

第四次执行

image.png

第五次执行

image.png

第六次执行

image.png

第七次执行

image.png

第八次执行

image.png

四、最小生成树的Kruskal算法

1.Kruskal算法介绍

关于Kruskal算法的介绍,请查看Kruskal算法百度百科

2.算法实现思路

首先将如下的图使用邻接矩阵的顺序存储到计算机内存中

image.png

第一步:找到最小的边7、连接这条边的顶点V4和V7,记录V4与V7的连接关系

image.png

第二步:找到第二小的边8,连接这条边的顶点V2和V8,记录V2与V8的连接关系

image.png

第三步:找到第三小的边10,连接这条边的顶点V0和V1,记录V0与V1的连接关系

image.png

第四步:找到第四小的边11,连接这条边的顶点V0和V5,记录V0与V5的连接关系

image.png

第五步:找到第五小的边12,连接这条边的顶点V1与V8,记录V1与V8的连接关系

image.png

第六步:找到第六小的边16,由于权值为16的边有两条,分别为V1与V6和V3与V7,连接V1与V6,连接V3与V7,并且记录V1与V6的连接关系和V3与V7的连接关系

image.png

第七步:找到第七小的边17,由于V6-V1-V0-V5连接,如果把V6和V5连接,则会形成闭环,所以V6和V5不能连接

第八步:找到第八小的边18,由于V2-V3-V1连接,如果把V2和V1连接,则会形成闭环,所以V2和V1不能连接

第九步:找到第九小的边19,连接这条边的顶点V6和V7,记录V6与V7的连接

image.png

第十步:依次从小到大找到后面的边,判断当前的边的两个顶点连接后是否会形成闭环,如果会发生,则不连接两个顶点;否则,则连接两个顶点,并记录两个顶点之间的连接关系

3.代码实现思路

  • 1.将邻接矩阵转换成边表数组,边表数组记录的元素的数据结构中要能记录两个顶点的下标,begin指向下标较小的顶点下标,end指向下标较大的顶点下标,weight记录权值;
  • 2.对边表数组进行升序排序;
  • 3.遍历升序排序后的边表数组,通过parent数组记录顶点与顶点之前的连接关系;
  • 4.通过parent数组判断两个顶点连接后会不会形成闭环,如果不会形成闭环,则连接,这两个顶点加入了最小生成树。

4.代码实现

1.邻接矩阵的顺序实现

在Prim算法中已经实现了邻接矩阵的顺序存储的实现,这里就不重复写了。

2.边表数组元素的数据结构定义

typedef struct
{
    int begin; //指向下标较小的顶点的下标
    int end;//指向下标较大的顶点的下标
    int weight;//边的权值

}Edge ;

3.对边表数组进行升序排序

//交换方法
void Swapn(Edge *edges,int i, int j)
{
    int tempValue;

    //交换edges[i].begin 和 edges[j].begin 的值
    tempValue = edges[i].begin;
    edges[i].begin = edges[j].begin;
    edges[j].begin = tempValue;   

    //交换edges[i].end 和 edges[j].end 的值
    tempValue = edges[i].end;
    edges[i].end = edges[j].end;
    edges[j].end = tempValue;

    //交换edges[i].weight 和 edges[j].weight 的值
    tempValue = edges[i].weight;
    edges[i].weight = edges[j].weight;
    edges[j].weight = tempValue;
}

//边表数组按权值进行升序排序
void sort(Edge edges[],MGraph *G)
{
    //对权值进行排序(从小到大)
    int i, j;

    for ( i = 0; i < G->numEdges; i++)
    {
        for ( j = i + 1; j < G->numEdges; j++)
        {
            if (edges[i].weight > edges[j].weight)
            {
                Swapn(edges, i, j);
            }
        }
    }
    
    printf("边集数组根据权值排序之后的为:\n");
    for (i = 0; i < G->numEdges; i++)
    {
        printf("(%d, %d) %d\n", edges[i].begin, edges[i].end, edges[i].weight);
    }
}

4.找到顶点在parent数组中的连接路径的尾部顶点的下标

int Find(int *parent, int f)
{
    while ( parent[f] > 0)
    {
        f = parent[f];
    }
    return f;
}

假设当前的parent数组的数据如下:

image.png 我们现在要判断V6和V5是否能连接,则需要将6和5分别在parent中查找连接路径的尾部结点的下标,查找逻辑如下:

  • 1.parent[6] = 0,结束查找,返回6;
  • 2.parent[5] = 8,parent[8] = 6,parent[6] = 0,结束查找,返回6;

此时,返回的值相等都为6,所以V6和V5不能相连,否则将形成闭环

5.Kruskal算法实现

void MiniSpanTree_Kruskal(MGraph G)
{
    int i, j, n, m;
    int sum = 0;
    int k = 0;

    /* 定义一数组用来判断边与边是否形成环路
     用来记录顶点间的连接关系. 通过它来防止最小生成树产生闭环;
     */
    int parent[MAXVEX];

    //定义边集数组,edge的结构为begin,end,weight,均为整型
    Edge edges[MAXEDGE];

    //1.将邻接矩阵转换成边表数组
    for ( i = 0; i < G.numVertexes-1; i++)
    {
        for (j = i + 1; j < G.numVertexes; j++)
        {
            //如果当前路径权值 != ∞
            if (G.arc[i][j] < INFINITYC)
            {
                //将路径对应的begin,end,weight 存储到edges 边集数组中.
                edges[k].begin = i;
                edges[k].end = j;
                edges[k].weight = G.arc[i][j];

                //边集数组计算器k++;
                k++;
            }
        }
    }

    //2.对边表数组进行升序排序
    sort(edges, &G);

    //3.初始化parent 数组为0. 9个顶点;
    for (i = 0; i < MAXVEX; i++)
        parent[i] = 0;

    //4. 计算最小生成树
    printf("打印最小生成树:\n");

    //循环每一条边 G.numEdges 有15条边
    for (i = 0; i < G.numEdges; i++)
    {
        //获取begin,end 在parent 数组中的信息;
        //如果n = m ,将begin 和 end 连接,就会产生闭合的环.
        n = Find(parent,edges[i].begin);
        m = Find(parent,edges[i].end);

        //printf("n = %d,m = %d\n",n,m);

        //假如n与m不等,说明此边没有与现有的生成树形成环路
        if (n != m)
        {
            //将此边的结尾顶点放入下标为起点的parent中
            //表示此顶点已经在生成树集合中
            parent[n] = m;

            //打印最小生成树路径
            printf("(%d, %d) %d\n", edges[i].begin, edges[i].end, edges[i].weight);
            sum += edges[i].weight;
        }
    }

    printf("sum = %d\n",sum);
}

6.调试代码

    printf("Hello,最小生成树_Kruskal算法\n");

    MGraph G;

    CreateMGraph(&G);

    MiniSpanTree_Kruskal(G);

5.代码执行过程

第一次执行

image.png

第二次执行

image.png

第三次执行

image.png

第四次执行

image.png 第五次执行

image.png

第六次执行

image.png

第七次执行

image.png 此时,V6与V5的连接会导致闭环

image.png

第八次执行

image.png

第九次执行

image.png 第十次执行

image.png

第十一次执行

image.png

第十二次执行

image.png

第十三次执行

image.png

第十四次执行

image.png

第十五次执行

image.png

五、总结

  • 1.图的生成树是指包含图中全部N个顶点的,由N-1条边将这N个顶点连接起来的连通子图。图的所有生成树中,N-1条边的权值和最小的生成树称为图的最小生成树
  • 2.求图的最小生成树的算法有Prim算法Kruskal算法