引例1——架设通信网络的最小成本问题
引例2——网络中信息传输的问题
用于网桥(交换机) 设备的 STP (Spanning Tree Protocol)协议
生成树实例
生成树是连通图的极小连通子图。所谓极小是指若在树中任意增加一条边,则将出现一个回路;若去掉一条边,将会使之变成非连通图。
生成树没有确定的根,通常称之为自由树。在自由树中选定一顶点做根,则成为一棵通常的树。从根开始,为每个顶点的孩子规定从左到右的次序,则它就成为一棵有序树。
最小生成树的概念
最小生成树的性质:按照生成树的定义, n 个顶点的连通网络的生成树有 n 个顶点、n-1 条边。
构造最小生成树,要解决以下两个问题:
(1)尽可能选取权值小的边,但不能构成回路(也就是环)。
(2)选取 n-1 条恰当的边以连接网的 n 个顶点。
最小生成树算法
求最小生成树的算法一般都使用贪心策略,经典的算法有 Prim算法和 Krusal 算法等。
这两个算法使用贪心法有着相似的思维,即一个“生成”一条不会产生回路的“安全边”,两算法的区别仅在于求安全边的方法不同。
- 求最小生成树算法1——Prim 算法
基本思想
伪代码细节描述:
设开始点u0=A,与A相关联的边有(A,B)(A,C)和(A,D),其中权值最小的边为(A,C)。
1)Prim算法步骤 1,其中 # 表示无权值,∞ 表示无路径。
u0=A,在候选边集表中列出 A 到其余各点(终点)的权值。
(1)u=A;
(2)候选边集表中找到最短边(u,v)=(A,C),v=C;
(3)在候选边集表中列出 v 到各终点的权值;
(4)比较 u 到终点与 v 到终点 x 的权值,取值小的那条边。
如终点 x=B,(A,B)=6>(C,B)=5,则选择边(C,B)替代(A,B),其他替换的还有(C,E)替换(A,E)与(C,F)替换(A,F),注意相等的边则不做调整。
在调整了整个候选边集后,确定(A,C)边加入。替换掉的边在图中有直接连线的则去掉,如替换的(A,B)线。
注:最底层单元格,由它判断起点到终点的边是否可以删去。
2)prim 算法步骤 2,表格中 “ / ” 表示起点到终点的这条边已经确定,后面不在考虑。
(1)候选边集表中找到最短边(u,v)=(C,F),v=F。注意(A,C)不在考虑。
(2)在候选边集表中列出 v 到各终点的权值,调整的边为(F,D)替换(A,D)线,确定(C,F)边加入。
3)Prim 算法步骤3
(1)候选边集表中找到最短边(u,v)=(F,D),v=D。
(2)在候选边集表中列出 v 到各终点的权值,确定(F,D)边加入。
4)Prim算法步骤4,见图。
v=B;(C,B)边加入。
5)Prim 算法步骤5,见图
v=E;(B,E)边加入。
#include<stdio.h>
#define VERTEX_NUM 6 //图的顶点数
#define INF 32767 //INF 表示 ∞
typedef int InfoType;
struct set {
int starNode[VERTEX_NUM]; //起点
int endNode[VERTEX_NUM]; //终点
int value[VERTEX_NUM]; //权值
} edgeSet; //候选边集
InfoType AdjMatrix[VERTEX_NUM][VERTEX_NUM]; //邻接矩阵
void DispMat(InfoType AdjMatrix[][VERTEX_NUM]);//输出邻接矩阵
void prim(InfoType AdjMatrix[][VERTEX_NUM],int v);
int main() {
int A[VERTEX_NUM][VERTEX_NUM]= {
{INF,6,1,5,INF,INF},
{6,INF,5,INF,3,INF},
{1,5,INF,5,6,4},
{5,INF,5,INF,INF,2},
{INF,3,6,INF,INF,6},
{INF,INF,4,2,6,INF}
}; //初始化邻接矩阵
printf("图的邻接矩阵:\n");
DispMat(A);
printf("\n");
printf("prim 算法求解结果:\n");
prim(A,0);
printf("\n");
return 0;
}
/*==============================================
函数功能:prim方法构造最小生成树
函数输入:邻接矩阵、起始顶点编号
函数输出:无
屏幕输出:邻接矩阵、最小生成树的各边
===============================================*/
//U---已加入最小生成树的点
void prim(int AdjMatrix[][VERTEX_NUM] ,int v) {
int i,j,k;
int Visited[VERTEX_NUM]= {0}; //访问数组,记录已加入 U 的结点
int min; //记录候选边集中的最小权值
for(i=0; i<VERTEX_NUM; i++) { //初始化候选边集
if(i!=v) {
edgeSet.starNode[i]=v; //初始时 v 为起始点
edgeSet.endNode[i]=i; //终点赋值
edgeSet.value[i]=AdjMatrix[v][i]; //赋权值
}
}
//起始顶点 v 加入候选边集
edgeSet.starNode[v]=v;
edgeSet.endNode[v]=v;
edgeSet.value[v]=INF;
for(i=1;i<VERTEX_NUM;i++)
{
min=INF;
//在候选边集中查找未加入 U 中且 value 最小的点 k
for(j=0;j<VERTEX_NUM;j++)
{
if(edgeSet.value[j]<min && Visited[j]==0)
{
min=edgeSet.value[j];
k=j; //k 记录 value 值最小的顶点
}
}
Visited[k]=1; //k 加入 U 中
if(min!=INF)
printf("边(%c,%c)权为:%d\n",edgeSet.starNode[k]+'A',k+'A',min);
//由于顶点 k 的新加入而调整候选边集的 value 和 startNode
for(j=0;j<VERTEX_NUM;j++)
{ //若终点邻接边权值大于始点邻接边权值且i,j,k三点不重合
if(AdjMatrix[k][j]<edgeSet.value[j]&&j!=v)
{
edgeSet.value[j]=AdjMatrix[k][j];
edgeSet.starNode[j]=k;
}
}
}
}
/*==============================================
函数功能:打印邻接矩阵
函数输入:邻接矩阵
函数输出:无
屏幕输出:邻接矩阵
===============================================*/
void DispMat(InfoType AdjMatrix[][VERTEX_NUM])
{
int i,j;
for(i=0;i<VERTEX_NUM;i++)
{
for(j=0;j<VERTEX_NUM;j++)
if(AdjMatrix[i][j]==INF)
printf("%3s"," ∞");
else
printf("%3d",AdjMatrix[i][j]);
printf("\n");
}
}
在看prim函数第77、78行调整候选边集权值和初始点时,注意:若初始点与终点到终点邻接点权值小于或等于时(i->k<=j-<k),starNode不变,value不变。第57行for循环变量i,相当于找邻接边,所以找VERTEX_NUM-1次。
- 求最小生成树算法2——Kruskal 算法
解最小花费生成树问题的“克鲁斯卡尔”算法:
对无向连通赋权图 G = (V, E, W) ,求最小花费生成树 T :
- 对边集 E 按每个边的权值大小进行升序排列。
- 初始化最小花费生成树的边集 T 为空。
- 把图的每个顶点都初始化为树的根节点。
- 取出当前边集 E 中最小权值的边,假设边 e = (u, v) ,如果当前边连接的两个节点 u 和 节点 v 不在同一棵树中,则把两个节点所在的树合并在一起,成为同一棵树。同时从边集 E 中删除边 e 并且把边 e 加入到最小花费生成树的边集 T 中。
- 重复步骤 (4) ,直到边集 T 中的边数为 n - 1 。( n 为图 G 中节点的总个数 )
Kruskal 算法也是基于“穷举法策略”的。通俗也称为“避环法”。从这个名字可以看出算法的主要思路是依次选取权值最小的边来组成树,但要避开会让树构成回路的边。这一点从步骤 (4) 中可以看出。
现在主要问题将集中在以下两个问题上: - 如何判断边 e 所连接的两个节点 u 和 v 不在同一棵树上?
- 如何把两个节点 u 和 v 所在的树合并为一棵树?
解决第一个问题的办法是分别查找节点 u 和 v 的根节点,如果它们的根节点相同,则认为它们在同一棵树上。
解决第二个问题的办法是把节点 u 和 v 的根节点分别作为父、子节点而连接在一起。这样就把两个分离的树组成了同一棵树。
这里为了尽量降低合并后的树的深度,将为树中每个节点保存一个“秩”值,“秩”值代表的是该节点作为树的根节点时,该树的高度。初始时,每个节点都独立成为一棵树,因此它们的“秩”值都为 0 。
在上述合并过程中我们将把“秩”值大的节点作为父节点,“秩”值小的节点作为子节点,如果两个节点的“秩”值相等,则任意让其中一个节点作为父节点,并同时让该节点的“秩”值加一,而子节点的“秩”值不变。
设图G的生成树集合 T 中开始只有图的全部的顶点而没有边,每次选择图 G 的边集 E 中权值最小并且不产生循环的边加入 T 集合,直至覆盖全部结点。
Kruskal 算法描述:
设无向连通图网络 G =(V,E),
V为图的顶点集,E为图的边集,T为生成树集,N为顶点数集
(1)对边集 E 按每个边的权值大小进行升序排列。
(2)初始化最小生成树集 T=(V,Φ)——即只有顶点,没有边。
(3)取出当前边集 E 中最小权值的边,假设边 e=(u,v),如果当前边连接的两个结点 u 和结点 v 不在同一棵树中,则把两个结点所在的树合并在一起,成为同一棵树。同时从边集 E 中删除边 e 并且把边 e 加入到最小生成树集 T 中。
(4)重复步骤(3),直到边集 E 中的边数为 N-1 。
Kruskal 算法中数据结构的设计
每个子树是一个连通分量,即一个子集。在子树每一步的合并中就应该标出它们的根来,即子集中的每个结点都应该记录其根结点的域,若用连续的存储方式,则可以设计成有一个指向其根结点的静态链(下标);而根结点的双亲链中则可存储该树子集中的成员个数,为了和普通结点有所区别,可以把根结点的双亲域中的成员数设为负值。
初始时,每个结点均为根,则数组中的初值都是-1,表示当前的结点为根,以此根为树的子树中只有一个结点。当有边(A,C)加入时,A,C两个子树的根不同,故可以合并这两个子树(或子集),此时若选 A 为子树的根,则 C 变为树的普通结点,它的根的状态记录由原来的 -1 就要改为在下标为 0 的结点 A 。
下图给出了上图中 Kruskal 算法步骤对应的子树中各结点间的关系,其中 L 根表示结点数多的子集的根, S 根表示结点数少的子集的根。初始时每个结点都是一棵子树,它们的根即是自己。在步骤 4 中,加入边(C,F),在步骤 3 结束时的状态可以查到顶点 C 与 F 的双亲分别为 A 与 D,故边(C,F)可以加入,现在 C 亦可作为 D 的双亲,现在的问题是,F 的根应该选哪个结点做它的根?如果依然是 D 为根,则分量{A,C}与{D,F}没有变化;如果选 C 做根,由于 C 的根为 A,则 F 的根与 C 的根应该合并为 A,这样子树 {A,C} 与 {D,F} 由于边(C,F)的加入而合并为一棵子树,见上图中的第 3 步到第 4 步,故在步骤 4 里面,D 的双亲改为下标 0,A 的双亲域由于子树 {D,F} 的加入,增加了两个结点,值变为 -4 。
树的合并问题与并查集
树如何合并的问题属于子集归并的并查集问题。并查集上的子集合并运算,即要合并两个元素所属的子集,首先要确定两个元素所属子集所对应的树的根结点,然后将其中一棵树的根结点链作另一个棵树的子树即可。为减免在合并过程中出现畸形树(近似单链树),通常将成员较少的子集对应的树作为成员较多的子集对应的树的子树。
并查集(Union-find-Sets)是一种树型的数据结构,用于处理一些不相交集合(Disjoint Sets)的合并及查询问题。常常在使用中以森林来表示。集就是让每个元素构成一个单元素的集合,也就是按一定顺序将属于同一组的元素所在的集合合并。
并查集的主要操作有:
(1)初始化:把每个点所在集合初始化为其自身。
(2)查找:查找元素所在的集合,即根结点。
(3)合并:将两个元素所在的集合合并为一个集合。通常来说,合并之前应先判断两个元素是否属于同一集合,这可用查找操作来实现。
Kruskal 算法的程序实现
程序测试数据已按升序排列
1)数据结构描述
将结点数多的集合设为 L,结点数少的集合设为 S 。
(1)边集数组
权值按递增有序放在边集数组 EdgeSet[] 中。
边集数组结构
typedef struct //边集数组单元结构
{ VexType start_vex; //起点
VexType end_vex; //终点
InfoType weight; //权值项可以根据需要设置
int sigle; //当前边是否加入标志,0为初值,1为加入
} EdgeStruct;
(2)双亲结点数组
记录各顶点的根,负值为子集合的结点个数
int parent[VERTEX_NUM];
2)伪代码描述
Kruskal 算法程序
程序默认边集表中数据已按升序排列,过程大致是取一条边,查找根,合并过程,是否加入生成树中,过程结束标志是已经查找完所有边或者所有结点都已经连接;判断是否生成回路后才决定是否将边加入。
/*==================================================
函数功能:求图的最小生成树Kruskal算法
函数输入:图的边集、图的边数、结点数
函数输出:无
===================================================*/
void Kruskal(EdgeStruct EdgeSet[],int edge_num, int vertex_num ) {
int parent[VERTEX_NUM]; //记录各顶点的根,负值为本集合的结点个数
int i,k;
int num=0;
int v1Root,v2Root;
int LRoot, SRoot; //LRoot:大集合的根;SRoot:小集合的根
char LVertex,SVertex;
for (i=0; i<vertex_num; i++) parent[i]=-1;
i=0;
k=0;
while ( k<edge_num && num<vertex_num ) { //边集全部加入或生成树的边集足够
//查找start_vexd的根v1Root,注意边集元素升序
v1Root=(EdgeSet[k].start_vex-'A');
while (parent[v1Root]>=0) v1Root=parent[v1Root];
//查找end_vexd的根v2Root
v2Root=(EdgeSet[k].end_vex-'A');
while (parent[v2Root]>=0) v2Root=parent[v2Root];
//将S集合合并到L集合
if (parent[v1Root]<=parent[v2Root]) {
LRoot=v1Root;
SRoot=v2Root;
LVertex= EdgeSet[k].start_vex;
SVertex= EdgeSet[k].end_vex;
} else {
LRoot=v2Root;
SRoot=v1Root;
LVertex= EdgeSet[k].end_vex;
SVertex= EdgeSet[k].start_vex;
}
printf("%c--%c ",EdgeSet[k].start_vex,EdgeSet[k].end_vex);
printf("v1Root=%c v2Root=%c\n",LRoot+'A',SRoot+'A');
//start_vex与end_vex的根不同,则S集合归并到L集合中
if (v1Root!=v2Root) {
parent[LRoot]+=parent[SRoot]; //L子集与S子集成员数合并
parent[SRoot]=LRoot; //S结点的根改为L的根
EdgeSet[k].sigle=1;
num++;
}
for (i=0; i<vertex_num; i++) printf("%4d",parent[i]);
printf("\n");
k++;
}
}
程序测试:
#include<stdio.h>
#define VERTEX_NUM 6 //测试数据一的顶点数
#define EDGE_NUM 10 //测试数据一的边的数目
#define VERTEX_NUM 7 //测试数据二的顶点数
#define EDGE_NUM 11 //测试数据二的边的数目
typedef char VexType;
typedef int InfoType;
typedef struct { //边集数组单元结构
VexType start_vex; //起点
VexType end_vex; //终点
InfoType weight; //权值项可以根据需要设置
int sigle;
} EdgeStruct;
EdgeStruct EdgeSet[EDGE_NUM]; //边集数组
void Kruskal(EdgeStruct EdgeSet[],int edge_num,int vertex_num);
int main() {
EdgeStruct EdgeSet[EDGE_NUM]
//==================测试数据一======================
= {{'A','C',1,0},{'D','F',2,0},{'B','E',3,0},{'C','F',4,0},
{'A','D',5,0},{'B','C',5,0},{'C','D',5,0},{'A','B',6,0},
{'C','E',6,0},{'E','F',6,0}
};
//==================测试数据一======================
//={{'A','D',5,0},{'C','E',5,0},{'D','F',6,0},{'A','B',7,0},
//{'B','E',7,0},{'B','C',8,0},{'F','E',8,0},{'D','B',9,0},
//{'E','G',9,0},{'F','G',11,0},{'D','E',15,0}};
Kruskal(EdgeSet,EDGE_NUM,VERTEX_NUM);
for(int i=0; i<EDGE_NUM; i++)
if(EdgeSet[i].sigle==1)
printf("%c--%c %d\n",EdgeSet[i].start_vex,EdgeSet[i].end_vex,
EdgeSet[i].weight);
return 0;
}
小结
Prim 和 Kruskal 算法考虑问题的出发点都是使生成树上边的权值之和达到最小,则应使生成树中每一条边的权值尽可能的小。
Kruskal 算法在效率上要比 Prim 算法快,因为 Kruskal 只需要对权重边做一次排序,而 Prim 算法则需要做多次排序。尽管 Prim 算法每次做的算法涉及的权重边不一定会涵盖连通图中的所有边。
Prim从点入手 —— 适用稠密图
Kruskal从边入手 —— 适用稀疏图