图-最小生成树-Prim与Kruskal算法

最小生成树

最小生成树(Minimum Spanning Tree,MST)是在一个给定的无向图G(V,E)中求一棵树 T,使得这棵树拥有图 G中的所有顶点,且所有边都是来自图 G 中的边,并且满足整棵树的边权之和最小。

最小生成树有三个性质需要掌握:

  • 最小生成树是树,因此其边数等于顶点数减 1,且树内一定不会有环
  • 对给定的图G(V,E),其最小生成树可以不唯一,其边权之和一定是唯一的
  • 由于最小生成树是无向图上生成的,因此其根结点可以使这棵树上的任意一个结点。

一般来说,如果题目中涉及最小生成树本身的输出,为了让最小生成树唯一,一般都会直接给出根结点。

求解最小生成树一般有两种算法:Prim 算法和 Kruskal 算法,都采用了贪心的思想,只是贪心的策略不一样。

Prim算法

基本思想

对于图 G(V, E) 设置集合 S,存放已经被访问的顶点,然后每次从集合 V-S 中选择集合 S 的最短距离最小的一个顶点(记为 u),访问并加入集合 S。之后,令顶点 u 为中介点,优化所有从 u 能到达的顶点 v 与集合 S 之间的最短距离。这样的操作执行 n 次(n为顶点个数),直到集合 S 已经包含所有顶点。

也就是执行 n 次下面两个步骤:

  • 每次从集合 V - S 中选择与集合 S 最近的一个顶点(记为 u),访问 u 并将其加入集合 S,同时把这条离集合 S 最近的边加入最小生成树中。
  • 令顶点 u 作为集合 S 与集合 V-S 连接的接口,优化从 u 能到达的未访问顶点 v 与集合 S 的最短距离。

可以看到 Prim 算法的思想与最短路径中 Dijkstra 算法的思想几乎相同,只是在设计最短距离的时候用集合 S 代替了 Dijkstra 中的起点 s。

具体实现

prim 算法需要实现两个关键的概念,即集合 S 的实现、顶点Vi 与集合 S 的最短距离。

  • 集合 S 的实现方法和 Dijkstra 中相同,即使用一个 bool型数组 vis[] 来表示顶点是否已被访问。
  • 不妨令 int 型数组 d[] 来存放顶点 Vi 与集合 S 的最短距离。初始时除了起点 s 的d[s] 赋为0,其余顶点都赋值为一个很大的数 INF,即不可达。

可以看到 prim 算法与 **Dijkstra **算法使用的思想几乎完全相同,只有在数组 d[] 的含义上有所区别。在 Dijkstra 中 d[] 含义为起点 s 到达 Vi 的最短距离,在 prim 算法中 d[] 含义为顶点 Vi 到集合 S 的最短距离,二者的区别仅仅在于最短距离是顶点 Vi 针对的是起点s还是集合V。

所以伪代码和 Dijkstra 很像:

// 数组 d 为顶点与集合 S 的最短距离
void Prim(G, d[]) {
  初始化;
  for(循环 n 次) {
    u = 使 d[u] 最小的还未访问的顶点的标号;
    记 u 已经被访问;
    for(从 u 出发能到达的所有顶点 v) {
      if(v 未被访问 && 以 u 为中介点使得 v 与集合 S 的最短距离 d[v] 更优) {
        将 G[u][v] 赋值给 v 与集合 S 的最短距离 d[v];
      }
    }
  }
}

具体实现:

邻接矩阵版

const int maxn = 1000;	// 最大顶点数
const INF = 1e9;

int n, G[maxn][maxn];	// n 为顶点数
int d[maxn];					// 顶点与 S 的最短距离
bool vis[maxn] = {false};	// 标记数组

int prim(int s)
{		// s 号为默认初始点,函数返回最小生成树的边权之和
  fill(d, d+maxn, INF);	// fill 函数整个数组赋值为 INF
  d[s] = 0;	// 只有顶点到集合 S 的距离为 0,其余全为 INF
  int ans = 0;	// 存放最小生成树的边权之和
  for(int i = 0; i < n; i++) {
    
    	int u = -1, MIN = INF;	// u 使得 d[u] 最小,MIN 存放该最小的 d[u]
     	for(int j = 0; j < n; j++) {
        if(vis[j] == false && d[j] < MIN) {
					u = j;
          MIN = d[j];
        }
 			}
    	if(u == -1) return -1;
    	vis[u] = true;	// 标记为已访问
    	ans += d[u];		// 将与集合 S 距离最小的表加入到最小生成树
    	for(int v = 0; v < n; v++) {
        // v 未访问 && u 能到达 v && 以 u 为中介可以使 v 距离集合 s 更近
        if(vis[v] == false && G[u][v] != INF && G[u][v] < d[v]) {
          d[v] = G[u][v];	// 将 G[u][v]赋值给 d[v]
        }
      }
  }
 return ans;
}


邻接表版

struct Node {
  int v, dis;	// v 为目标边顶点,dis 为边权
};

vector<Node> Adj[maxn];
int n;
int d[maxn];
bool vis[maxn] = {false};

int prim(int s) {
  fill(d, d+maxn, INF);
  d[s] = 0;
  int ans = 0;	// 最小生成树边权之和
  for(int i = 0; i < n; i++) {
    
    int u = -1, MIN = INF;
    for(int j = 0; i < n; j++) {
      if(vis[j] == false && d[j] < MIN) {
        u = j;
        MIN = d[j];
      }
    }
    // 找不到小于INF的 d[u],剩下的顶点和集合 S 不连通
    if(u == -1) return -1;
    vis[u] = true;
    ans += d[u];
    for(int j = 0; j < Adj[u].size(); j++) {
      int v = Adj[u][j].v;	// 通过邻接表直接获得 u 能到达的顶点 v
      if(vis[v] == false && Adj[u][j].dis < d[v]) {
        // v 未访问 && 以 u 为中介点可以使得 v 距离集合 S 更近
        d[v] = G[u][v];	// 将G[u][v]赋值给 d[v]
      }
    }
  }
  return ans;
}

Kruskal 算法

基本思想

Kruskal 使用了边贪心的策略,思想很简洁。

基本思想为:在初始状态隐去图中的所有边,这样图中每个顶点都自成一个连通块。之后执行下面的步骤:

  1. 对所有边按照边权从小到大进行排序
  2. 按边权从小到大测试所有边,如果当前所连接的两个顶点不在同一个连通块中,则把这条测试边加入当前最小生成树中;否则将这条边舍弃
  3. 执行步骤2,直到最小生成树中边数等于总顶点数减 1 或者测试完所有边时结束。当结束时如果最小生成树的边数小于总顶点树减 1,说明该图不连通。

简单来说就是:每次选择图中最小边权的边,如果两段的顶点在不同的连通块中,就把这条边加入到最小生成树中。

具体实现

伪代码:

int Kruskal() {
  令最小生成树的边权之和为 ans,最小生成树的当前边数为 Num_Edge;
  将所有边按边权从小到大排序;
  for(从小到大枚举所有边) {
    if(当前测试的两个端点在不同的连通块中) {
      将该测试边加入最小生成树中;
      ans += 测试边的边权;
      最小生成树的当前边数 Num_Edge + 1;
      当边数 Num_Edge 等于顶点数减1 时结束循环;
    }
  }
  return ans;
}

伪代码中有两个细节需要注意:

  • 如何判断是否测试边的两个端点在不同的连通块中
  • 如何将测试边加入到最小生成树中

如果把每个连通块当成一个集合,那么就转化成判断两个端点是否在同一个集合中,可以使用并查集,而合并刚好可以解决第二个问题,那么具体实现如下:

const int maxn = 1000;

struct egde {
  int u, v;
  int cost;	// 边权
}E[maxn];

// 按照边的权重排序,小权重放在前面
bool cmp(edge a, edge b) {
  return a.cost < b.cost;
}

int father[N]; // 并查集数组
int findFather(int x) {	// 并查集查询函数
  while(x != father[x]) {
    x = father[x];
  }
  return x;
}

// kruksal 函数返回最小生成边权之和,参数 n 为顶点个数,m 为图的边数
int krukal(int n, int m) {
  // ans 为所求边权之和,Num_Edge 为当前生成树的边数
  int ans = 0, Num_Edge = 0;
  for(int i = 1; i <= n; i++) {
    father[i] = i;
  }	// 并查集初始化
  sort(E, E+m, cmp);
  for(int i = 0; i < m; i++) {	// 枚举所有边
    int faU = findFather(E[i].v);
    int faV = findFather(E[i].u);
    if(faU != faV) { // 不在一个集合中
      father[faU] = faV;
      ans += E[i].cost;	// 边权之和增加测试边的边权
      Num_Edge++;
      if(Num_Edge == n - 1) break;	// 边数等于顶点数减 1 时结束算法
    }
  }
  if(Num_Edge != n-1) return -1;
  else return ans; 
}

可以看到,kruksal 适合顶点数目比较多,边数较少的情况,这和 prim 算法正好相反。

如果是 稠密图,则用 prim 算法,如果是 稀疏图,则用 kruksal 算法。

如果图本身不连通,使用 kruksal 算法必须在连通图下讨论,不然一定不能形成一颗完整的最小生成树。

浙大的还是畅通公车给你 是一个很好的例子。

posted @ 2020-03-22 14:09  南风sa  阅读(462)  评论(0编辑  收藏  举报