数据结构与算法(十四) -- 图的最小生成树

1,279 阅读8分钟

一、最小生成树的概念

假设我们需要为9个村庄之间建立网络通讯, 那么就必须设计一条可以穿过所有村庄的路线, 为了最大的节约成本, 这里就需要使用到最小生成树的概念.

如图:

这是一个网结构, V0 - V8分别代表9个村庄, 权值为各个村庄之间的路程. 为了尽可能的缩小成本, n个顶点用n-1条边连接起来的连通图, 并且得到的权值最小, 这样就是最小成本了.

方案一:

权值和 = 11 + 26 + 20 + 24 + 21 + 22 + 19 + 18 = 161

方案二:

权值和 = 8 + 12 + 10 + 11 + 17 + 19 + 16 + 7 = 100

方案三:

权值和 = 11 + 10 + 12 + 8 + 16 + 19 + 16 + 7 = 99

经过对比可以得知, 不同的方案所需要的成本是不一样的, 方案三所需要的成本最小.

所以我们把构造连通网的最小代价生成树称之为最小生成树.

找连通网的最小生成树, 经典的算法有两种, 分别为普里姆算法和克鲁斯卡尔算法.

二、普里姆(Prim)算法

首先来构造这个图的邻接矩阵.

2.1、普里姆算法思想

  1. 我们选取一个顶点, 这里以V0开头. 此时有两条路径可选: V1 V5, 选取最小路径V1, 当前节点为V0 V1
  2. 从V0 V1的边中挑选出权值最小的顶点V5. 当前节点为V0 V1 V5
  3. 从V0 V1 V5中挑选出权值最小的顶点V8. 当前节点为V0 V1 V5 V8
  4. 从V0 V1 V5 V8中挑选出权值最小的顶点V2. 当前节点为V0 V1 V5 V8 V2
  5. 从V0 V1 V5 V8 V2中挑选出权值最小的顶点V6. 当前节点为V0 V1 V5 V8 V2 V6
  6. 从V0 V1 V5 V8 V2 V6中挑选出权值最小的顶点V7. 当前节点为V0 V1 V5 V8 V2 V6 V7
  7. 从V0 V1 V5 V8 V2 V6 V7中挑选出权值最小的顶点V4. 当前节点为V0 V1 V5 V8 V2 V6 V7 V4
  8. 从V0 V1 V5 V8 V2 V6 V7 V4中挑选出权值最小的顶点V3. 当前节点为V0 V1 V5 V8 V2 V6 V7 V4 V3

在第6步的时候, 我们发现, V6->V5 比 V6->V7的权值更小, 但是我们选择了来到V7. 因为选择V5就会形成一个闭环, 这不符合我们的要求, 所以在代码中需要用来记录顶点是否已经添加到生成树中.

2.2、普里姆算法实现

  1. 定义两个数组; adjvex用来保存相关顶点下标; lowcost保存顶点之间的权值
  2. 初始化两个数组, 从V0开始寻找最小生成树, 默认V0是最小生成树的第一饿顶点
  3. 循环lowcost, 根据权值找到顶点K
  4. 更新lowcost数组
  5. 循环所有顶点, 找到与顶点K有关系的顶点, 并更新lowcost数组与adjves数组

更新lowcost数组与adjves数组的条件:

  1. 与顶点K之间有连接
  2. 当前节点j没有加入过最小生成树
  3. 顶点K与当前顶点j之间的权值小于顶点j与其他顶点K之间的权值, 则更新

代码:

#define MAXVEX 20
#define INFINITYC 65535

/* Prim算法生成最小生成树 */
void MiniSpanTree_Prim(MGraph G) {
    
    int sum = 0;//最小路径和(权值)
    int min;//最小权值
    int k;//记录当前所在顶点下标
    
    //保存相关顶点下标
    int adjvex[MAXVEX];
    //与保存的顶点相关边的权值
    int lowcost[MAXVEX];
    
    
    lowcost[0] = 0;//从V0开始, 代表V0已加入最小生成树
    adjvex[0] = 0;//从V0开始
    
    //V0已经加入最小生成树的情况下初始化
    for (int i = 1; i < G.numVertexes; i++) {
        lowcost[i] = G.arc[0][i];
        adjvex[i] = 0;
    }
    
    //遍历顶点, 0代表V0, 不需要处理
    for (int i = 1; i < G.numVertexes; i++) {
        
        min = INFINITYC;
        k = 0;

        //从lowcost中找到最小权值
        for (int j = 0; j < G.numVertexes; j++) {
            if (lowcost[j] != 0 && lowcost[j] < min) {
                min = lowcost[j];
                k = j;
                
            }
        }
        
        printf("(V%d, V%d) = %d\n", adjvex[k], k, G.arc[adjvex[k]][k]);
        sum += G.arc[adjvex[k]][k];
        lowcost[k] = 0;
        
        //从当前加入最小生成树的顶点开始遍历(邻接矩阵横向), 更新最小权值到lowcost,
        for (int z = 1; z < G.numVertexes; z++) {
            if (lowcost[z] != 0 && G.arc[k][z] < lowcost[z]) {
                lowcost[z] = G.arc[k][z];//更新最小权值
                adjvex[z] = k;//记录最小权值顶点
            }
        }
    }
    printf("sum = %d\n", sum);
}

三、克鲁斯卡尔(Kruskal)算法

普里姆算法是以某个顶点为起点, 逐步找到顶点上最小权值的边来构成最小生成树. 而克鲁斯卡尔算法就是直接以边为目标去构建, 因为权值在边上, 直接寻找最小权值来构建, 只是构建的时候要考虑一下闭环问题.

3.1、克鲁斯卡尔算法思路

  1. 将邻接矩阵转化为边表数组.
  2. 对边表数组根据权值按照从小到大的顺序排序
  3. 遍历所有的边, 通过parent数组找到边的连接信息, 避免闭环问题
  4. 如果不存在闭环问题, 则加入到最小生成树中, 并修改parent数组

这个边表数组代表的意思就是把权值按照顺序排列, begin 与 end分别为两个顶点下标. 这样就记录了所有边的顶点信息与权重信息.

3.2、代码实现

定义一个边表数组结构:

/* 对边集数组Edge结构的定义 */
typedef struct {
    int begin;
    int end;
    int weight;
} Edge;

具体实现:


/* 交换权值以及头和尾 */
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) {
    
    for (int i = 0; i < G->numEdges; i++) {
        for (int j = i + 1; j < G->numEdges; j++) {
            if (edges[i].weight > edges[j].weight) {
                Swapn(edges, i, j);
            }
        }
    }
}

//查找是否闭环
int Find(int *parent, int f) {
    while ( parent[f] > 0) {
        f = parent[f];
    }
    return f;
}

/* 生成最小生成树 */
void MiniSpanTree_Kruskal(MGraph G) {
    
    int sum = 0;
    
    /* 用来记录顶点间的连接关系. 通过它来防止最小生成树产生闭环;*/
    int parent[MAXVEX];
    Edge edges[MAXVEX];
    
    int k = 0;//记录边个数
    //遍历出每一条边的权值以及相关顶点
    for (int i = 0; i < G.numVertexes; i++) {
        for (int j = i + 1; j < G.numVertexes; j++) {
            if (G.arc[i][j] < INFINITYC) {
                edges[k].begin = i;
                edges[k].end = j;
                edges[k].weight = G.arc[i][j];
                //printf("(V%d V%d)%d\n", edges[k].begin, edges[k].end, edges[k].weight);
                k++;
            }
        }
    }
    
    //冒泡排序
    sort(edges, &G);
    
    //初始化parent
    for (int i = 0; i < MAXVEX; i++) {
        parent[i] = 0;
    }
    
    //最小生成树
    for (int i = 0; i < G.numEdges; i++) {
        int n = Find(parent, edges[i].begin);
        int m = Find(parent, edges[i].end);
        if (n != m) {
            parent[n] = m;
            sum += edges[i].weight;
        }
    }
    printf("sum = %d\n", sum);
}

在这里parent的意义在于处理闭环, 在上述最小生成树中:

  1. 当i = 0的时候, begin = 4, parent[4] = 0即n = 4. end = 7, parent[7] = 0即m = 7, parent = {0, 0, 0, 0, 7, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0};
  2. 当i = 1的时候, begin = 2, parent[2] = 0即n = 2. end = 8, parent[8] = 0即m = 8, parent = {0, 0, 8, 0, 7, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0};
  3. 当i = 2的时候, begin = 0, parent[0] = 0即n = 0. end = 1, parent[1] = 0即m = 1, parent = {1, 0, 8, 0, 7, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0};
  4. 当i = 3的时候, begin = 0, parent[0] = 1 -> parent[1] = 0, 即n = 1. end = 5,parent[5] = 0即 m = 5 parent = {1, 5, 8, 0, 7, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0};
  5. 当i = 4的时候, begin = 1, parent[1] = 5 -> parent[5] = 0, 即n = 5. end = 8,parent[8] = 0即 m = 8 parent = {1, 5, 8, 0, 7, 8, 0, 0, 0, 0, 0, 0, 0, 0, 0};
  6. 当i = 5的时候, begin = 3, parent[3] = 0, 即n = 3. end = 7,parent[7] = 0即 m = 7 parent = {1, 5, 8, 7, 7, 8, 0, 0, 0, 0, 0, 0, 0, 0, 0};
  7. 当i = 6的时候, begin = 1, parent[1] = 5 -> parent[5] = 8 -> parent[8] = 0, 即n = 8. end = 6,parent[6] = 0 即 m = 6 parent = {1, 5, 8, 7, 7, 8, 0, 0, 6, 0, 0, 0, 0, 0, 0};
  8. 当i = 7的时候, begin = 5, parent[5] = 8 -> parent[8] = 6 -> parent[6] = 0, 即n = 6. end = 6,parent[6] = 0 即 m = 6, 此时 m == n, 会闭环, 所以不修改parent
  9. 当i = 8的时候, begin = 1, parent[1] = 5 -> parent[5] = 8 -> parent[8] = 6 -> parent[6] = 0, 即n = 6. end = 2,parent[2] = 8 -> parent[8] = 6 -> parent[6] = 0 即 m = 6 此时 m == n, 会闭环, 所以不修改parent
  10. 当i = 9的时候, begin = 6, parent[6] = 0, 即n = 6. end = 7,parent[7] = 0 即 m = 7, parent = {1, 5, 8, 7, 7, 8, 7, 0, 6, 0, 0, 0, 0, 0, 0};
  11. 当i = 10的时候, begin = 7, parent[7] = 0, 即n = 7. end = 4,parent[4] = 7 -> parent[7] = 0 即 m = 7, 此时 m == n, 会闭环, 所以不修改parent
  12. 当i = 11的时候, begin = 3, parent[3] = 7 -> parent[7] = 0, 即n = 7. end = 8,parent[8] = 6 -> parent[6] = 7 -> parent[7] = 0 即 m = 7, 此时 m == n, 会闭环, 所以不修改parent
  13. 当i = 12的时候, begin = 2, parent[2] = 8 -> parent[8] = 6 -> parent[6] = 7 -> parent[7] = 0, 即n = 7. end = 3,parent[3] = 7 -> parent[7] = 0 即 m = 7, 此时 m == n, 会闭环, 所以不修改parent
  14. 当i = 13的时候, begin = 3, parent[3] = 7 -> parent[7] = 0, 即n = 7. end = 6,parent[6] = 7 -> parent[7] = 0 即 m = 7, 此时 m == n, 会闭环, 所以不修改parent
  15. 当i = 14的时候, begin = 4, parent[4] = 7 -> parent[7] = 0, 即n = 7. end = 5,parent[5] = 8 -> parent[8] = 6 -> parent[6] = 7 -> parent[7] = 0 即 m = 7, 此时 m == n, 会闭环, 所以不修改parent