您的位置:首页 > 理论基础 > 数据结构算法

【数据结构】——图的最小生成树算法(普里姆+克鲁斯卡尔)

2020-06-03 04:41 141 查看

这里的图指的是带权无向图,也就是无向网。
关于最小生成树,https://blog.csdn.net/namewdy/article/details/105645409

图的最小生成树要解决的问题:用最小的代价连通图中的所有顶点。

下面两种算法都是运用贪心思想,利用MST(Minimum Spanning Tree)性质构建最小生成树。

MST性质: 假设N=(V, E)是一个连通网,U是顶点集V的一个非空子集。若(u, v)是一条具有最小权值的边,其中u∈U,v∈V-U,则必存在一棵包含边(u, v)的最小生成树。

证明: 假设网N的任何一棵最小生成树都不包含(u, v)。设T是连通网上的一棵最小生成树,当边(u, v)加入到T中时,由生成树的定义,T中必存在一条包含(u, v)的回路。在这个回路中任意去除一条除(u, v)的边便可消除回路,同时得到另一棵生成树。因为新包含(u, v)的生成树代价小于T,由此和假设矛盾。

1.普里姆算法(Prim)

普里姆算法主要针对的是邻接矩阵形式存储的稠密网。
普里姆算法视频讲解

算法描述

假设N=(V, E)是连通网,TE是N上最小生成树中边的集合,U是N上最小生成树的顶点集合。算法从U={uou_ouo​} (uou_ouo​∈V),TE={ }开始,重复执行下述操作:在所有u∈U,v∈V-U的边(u, v)∈E中找一条代价最小的边(u0,v0u_0,v_0u0​,v0​)并入集合TE,同时v0v_0v0​并入U,直至U=V为止。此时TE中必有n-1条边,则T=(U, TE)为N的最小生成树。

普里姆算法的具体实现

在实际的算法实现中,需要创建两个辅助数组 closest 和 lowcost。

lowcost 数组记录各顶点到在建生成树的最小权值,例如lowcost[ i ]=0代表顶点viv_ivi​已经被加入到在建生成树的顶点集U中,lowcost[ i ]=+∞+\infty+∞表示顶点viv_ivi​不在U中且与在建生成树中的任意顶点均不直接连通,lowcost[ i ]=num表示顶点viv_ivi​不在U中且与在建生成树中的某些顶点直接连通并且所有连通的边中权值最小的边权值为num。

closest 数组记录顶点viv_ivi​到生成树的最小权值边在生成树一端的顶点。

lowcost 数组首先需要根据邻接矩阵初始化为起始顶点viv_ivi​到其它各顶点的权值,lowcost[ j ] = matrix[ locate(viv_ivi​) ][ j ],因为无向网中matrix[ locate(viv_ivi​) ][ locate(viv_ivi​) ]为+∞+\infty+∞,所以根据lowcost数组的定义还需要将lowcost[ locate(viv_ivi​) ] 初始化为0。closest数组初始化为起始顶点viv_ivi​。

以上面左图无向网的邻接矩阵为例,普里姆算法的java实现:

public class Prim {

char[] vertex = { 'A', 'B', 'C', 'D', 'E', 'F' }; 					// 顶点数组
int[][] matrix = new int[6][6]; 									// 邻接矩阵
int INF = 1 << 31 - 1; 												// INF表示正无穷

// 创建邻接矩阵
private void creatMartix() {
matrix[locate('A')][locate('B')] = matrix[locate('B')][locate('A')] = 6;
matrix[locate('B')][locate('E')] = matrix[locate('E')][locate('B')] = 3;
matrix[locate('E')][locate('F')] = matrix[locate('F')][locate('E')] = 6;
matrix[locate('F')][locate('D')] = matrix[locate('D')][locate('F')] = 2;
matrix[locate('D')][locate('A')] = matrix[locate('A')][locate('D')] = 5;
matrix[locate('C')][locate('A')] = matrix[locate('A')][locate('C')] = 1;
matrix[locate('C')][locate('B')] = matrix[locate('B')][locate('C')] = 5;
matrix[locate('C')][locate('E')] = matrix[locate('E')][locate('C')] = 6;
matrix[locate('C')][locate('F')] = matrix[locate('F')][locate('C')] = 4;
matrix[locate('C')][locate('D')] = matrix[locate('D')][locate('C')] = 5;

for (int i = 0; i < matrix.length; i++) {
for (int j = 0; j < matrix.length; j++) {
if (matrix[i][j] == 0) {
matrix[i][j] = INF;
}
}
}

}

private int locate(char v) {
int i = 0;
for (; i < vertex.length; i++) {
if (v == vertex[i])
break;
}
return i;
}

// 普里姆算法
private void prim(char v) {
int[] lowcost = new int[matrix.length];
char[] closest = new char[matrix.length];

// lowcost、closest数组的初始化
for (int j = 0; j < matrix.length; j++) {
lowcost[j] = matrix[locate(v)][j];
closest[j] = v;
}
lowcost[locate(v)] = 0;

int mincost;
int k = 0; 										// 保存离在建生成树最近顶点的数组下标
for (int i = 1; i < matrix.length; i++) { 		// n个顶点的图需要寻找n-1次
mincost = INF;
// 根据各顶点到在建生成树的最小权值找出离在建生成树最近的顶点
for (int j = 0; j < matrix.length; j++) {
// lowcost[j]!=0限制只在所有生成树外的顶点与生成树之间的边中寻找
if (lowcost[j] != 0 && lowcost[j] < mincost) {
mincost = lowcost[j];
k = j;
}
}
// 根据找到的最近顶点输出最短边的信息
System.out.println("边 (" + closest[k] + "," + vertex[k] + ") 权:" + mincost);
// 将这个顶点加入到生成树中
lowcost[k] = 0;
// 因为生成树中新加入了顶点,所以需要重新更新在建生成树以外的顶点到在建生成树的最小距离
for (int j = 0; j < matrix.length; j++) {
if (lowcost[j] != 0 && matrix[k][j] < lowcost[j]) {
lowcost[j] = matrix[k][j];
closest[j] = vertex[k];
}
}
}
}

public static void main(String[] args) {
Prim p = new Prim();
p.creatMartix();
p.prim('A');
}

}

算法分析

  • 普里姆算法是通过逐渐增加在建生成树中的顶点完成生成树的构建,所以也叫加点法。
  • 因为代码中的双层for循环,所以时间复杂度O(n2)Ο(n^2)O(n2),nnn为图的顶点个数。
  • 空间复杂度等于两个辅助数组的空间,为O(n)Ο(n)O(n)。
  • 从时间复杂度可以看出程序求解的时间与顶点总数有关,与边的数量无关,因此适用于求稠密网的最小生成树。
  • 当选最小权值边时如果存在多条权值相等的边,可以任选一个。因此当存在多条权值相等的边时,该算法构建的生成树可能不唯一,但总权值一定相等。

2.克鲁斯卡尔算法(Kruskal)

假设连通网N=(V, E),则令最小生成树的初始状态为只有n个顶点而无边的非连通图T=(V, { }),图中每个顶点自成一个连通分量。在E中选择代价最小的边,若该边依附的顶点落在T不同的连通分量上,则将此边加入到T中,否则舍去此边而选择下一条代价最小的边。依次类推,直至T中所有顶点都在同一连通分量上为止。

算法的关键点在于如何判断代价最小的边加入T后是否落在T的不同连通分量上,也就是判断T中是否出现了环,这里使用了一种叫做并查集的数据结构,通过这种数据结构可以方便的查找一个元素所属的集合和对两个集合进行合并。并查集(disjoint set)视频讲解

这种数据结构的实现是:

  • T中不同的连通分量作为不同子树,根据树的双亲表示法将这些子树存储,同一棵子树代表同一个连通分量的顶点集合,判断两个顶点是否属于一个集合只需要判断至两个顶点所在子树的根结点是否相同。
  • 如果边的两个顶点属于一个集合,说明这两个顶点之间一定连通,此时如果将这条边加入则这两个顶点之间存在两条路径,构成环。反之如果不属于一个集合,则将该边加入不会构成环。
  • 该边加入后将两个没有联系的集合联系了起来,所以加入一条边之后需要将该边两个顶点所在的子树合并,合并时为了避免容易增加树的高度,增大之后的查询所属集合耗费的时间,可以选择将高度较小的子树的根结点接在高度较大子树的根结点上。

用克鲁斯卡尔算法生成上面左图无向网的最小生成树:

public class Kruskal {

class Edge {
char begin;
char end;
int weight;

public Edge(char begin, char end, int weight) {
this.begin = begin;
this.end = end;
this.weight = weight;
}
}

char[] vertex = { 'A', 'B', 'C', 'D', 'E', 'F' };
Edge[] edges;

/*
* 这里为了方便直接按权值大小顺序手动添加网的所有边。
* 也可以根据邻接矩阵的下三角或者上三角生成边数组,
* 生成之后还要根据边的权值大小对生成的边数组排序。
*/
private void creatEdges() {
edges = new Edge[10];
edges[0] = new Edge('A', 'C', 1);
edges[1] = new Edge('D', 'F', 2);
edges[2] = new Edge('B', 'E', 3);
edges[3] = new Edge('C', 'F', 4);
edges[4] = new Edge('B', 'C', 5);
edges[5] = new Edge('C', 'D', 5);
edges[6] = new Edge('A', 'D', 5);
edges[7] = new Edge('A', 'B', 6);
edges[8] = new Edge('C', 'E', 6);
edges[9] = new Edge('E', 'F', 6);
}

class TreeNode {
char verName;						// 结点表示的顶点
int parent;							// 结点父结点的位置下标
int rank;							// 保存以该结点为根结点的树的高度

public TreeNode(char verName) {
this.verName = verName;
}
}

TreeNode[] tree = { new TreeNode('A'),
new TreeNode('B'),
new TreeNode('C'),
new TreeNode('D'),
new TreeNode('E'),
new TreeNode('F') };

private void initTree() {
for (int i = 0; i < tree.length; i++) {
tree[i].parent = i;				// 初始每个结点的父结点都指向自己
tree[i].rank = 1;				// 所有单独的顶点都是一棵高度为1的树
}
}

private void kruskal() {
creatEdges();
initTree();
int i = 1;
int j = 0;
char begin;
char end;
// n个顶点的最小生成树n-1条边,所以需要循环添加n-1次
while (i <= vertex.length - 1) {
begin = edges[j].begin;
end = edges[j].end;
// 如果边的两个顶点属于两棵不同的子树则加入该边不会构成环
if (findRoot(begin) != findRoot(end)) {
// 输出边的信息并根据边两端的顶点将顶点所在子树合并
System.out.println("边 (" + begin + "," + end + ") 权:" + edges[j].weight);
union(begin, end);
i++;
}
// 判断下一条边
j++;
}
}

// 将两棵不相交的子树合并
private void union(char begin, char end) {
int i = findRoot(begin);
int j = findRoot(end);
// begin顶点所在子树的高度大于end顶点所在子树的高度
if (tree[i].rank > tree[j].rank) {
// end顶点所在子树的根结点连接在begin顶点所在子树的根结点
tree[j].parent = i;
} else {
// 否则begin顶点所在子树的根结点连接在end顶点所在子树的根结点
tree[i].parent = j;
// 如果两棵子树高度相等,那么连接之后需要将end顶点所在子树的高度+1
if (tree[i].rank == tree[j].rank) {
tree[j].rank++;
}
}
}

// 根据所给顶点返回顶点所在的子树的根结点位置
private int findRoot(char c) {
// 找到所给顶点的位置
int index = locate(c);
// 根据所给顶点循环找到所在子树的根结点,根结点的特点是父结点也指向自己
while (index != tree[index].parent) {
index = tree[index].parent;
}
return index;
}

// 返回顶点在所有子树的存储数组中的位置
private int locate(char v) {
int i = 0;
for (; i < tree.length; i++) {
if (v == tree[i].verName)
break;
}
return i;
}

public static void main(String[] args) {
Kruskal k = new Kruskal();
k.kruskal();
}

}

算法分析

  • 克鲁斯卡尔算法是通过逐渐为非连通图添加边数来构建生成树,所以也叫加边法。
  • 算法执行的时间主要包括对边进行排序的时间和并查集查找根结点的时间。设e条边,用堆排序对边进行排序的时间为O(elog2e)Ο(elog_2e)O(elog2​e)。选边操作最多执行e次,因为并查集构建的树高度一定小于边数,所以每次查找根结点的时间都小于O(log2e)Ο(log_2e)O(log2​e),所以总的时间复杂度O(elog2e)Ο(elog_2e)O(elog2​e)。
  • 算法的执行时间只与边数e有关,与顶点总数无关,因此适用于求稀疏网的最小生成树。
  • 同样当有多条权值相等的边且加入非连通图后都落在两个不同的连通分量上不构成环,可以任选一条边加入。所以该算法得到的生成树可能不唯一,但总代价都相等。
内容来自用户分享和网络整理,不保证内容的准确性,如有侵权内容,可联系管理员处理 点击这里给我发消息
标签: