本内容来源于本人著作《趣学算法》,在线章节:http://www.epubit.com.cn/book/details/4825
校园网是为学校师生提供资源共享、信息交流和协同工作的计算机网络。校园网是一个宽带、具有交互功能和专业性很强的局域网络。如果一所学校包括多个学院及部门,也可以形成多个局域网络,并通过有线或无线方式连接起来。原来的网络系统只局限于以学院、图书馆为单位的局域网,不能形成集中管理以及各种资源的共享,个别学院还远离大学本部,这些情况严重地阻碍了整个学校的网络化需求。现在需要设计网络电缆布线,将各个单位的局域网络连通起来,如何设计能够使费用最少呢?
图2-58 校园网络
2.7.1 问题分析
某学校下设10个学院,3个研究所,1个大型图书馆,4个实验室。其中,1~10号节点代表10个学院,11~13号节点代表3个研究所,14号节点代表图书馆,15~18号节点代表4个实验室。该问题用无向连通图G =(V,E)来表示通信网络,V表示顶点集,E表示边集。把各个单位抽象为图中的顶点,顶点与顶点之间的边表示单位之间的通信网络,边的权值表示布线的费用。如果两个节点之间没有连线,代表这两个单位之间不能布线,费用为无穷大。如图2-59所示。
图2-59 校园网连通图
那么我们如何设计网络电缆布线,将各个单位连通起来,并且费用最少呢?
对于n个顶点的连通图,只需n−1条边就可以使这个图连通,n−1条边要想保证图连通,就必须不含回路,所以我们只需要找出n−1条权值最小且无回路的边即可。
需要说明几个概念。
(1)子图:从原图中选中一些顶点和边组成的图,称为原图的子图。
(2)生成子图:选中一些边和所有顶点组成的图,称为原图的生成子图。
(3)生成树:如果生成子图恰好是一棵树,则称为生成树。
(4)最小生成树:权值之和最小的生成树,则称为最小生成树。
本题就是最小生成树求解问题。
2.7.2 算法设计
找出n−1条权值最小的边很容易,那么怎么保证无回路呢?
如果在一个图中深度搜索或广度搜索有没有回路,是一件繁重的工作。有一个很好的办法——避圈法。在生成树的过程中,我们把已经在生成树中的结点看作一个集合,把剩下的结点看作另一个集合,从连接两个集合的边中选择一条权值最小的边即可。
首先任选一个结点,例如1号结点,把它放在集合U中,U={1},那么剩下的结点即V−U={2,3,4,5,6,7},V是图的所有顶点集合。如图2-60所示。
图2-60 最小生成树求解过程
现在只需在连接两个集合(V和V−U)的边中看哪一条边权值最小,把权值最小的边关联的结点加入到集合U。从图2-68可以看出,连接两个集合的3条边中,结点1到结点2的边权值最小,选中此条边,把2号结点加入U集合U={1,2},V−U={3,4,5,6,7}。
再从连接两个集合(V和V−U)的边中选择一条权值最小的边。从图2-61可以看出,连接两个集合的4条边中,结点2到结点7的边权值最小,选中此条边,把7号结点加入U集合U={1,2,7},V−U={3,4,5,6}。
图2-61 最小生成树求解过程
如此下去,直到U=V结束,选中的边和所有的结点组成的图就是最小生成树。
是不是非常简单啊?
这就是Prim算法,1957年由美国计算机科学家Robert C.Prim发现的。那么如何用算法来实现呢?
首先,令U={u0},u0∈V,TE={}。u0可以是任何一个结点,因为最小生成树包含所有结点,所以从哪个结点出发都可以得到最小生成树,不影响最终结果。TE为选中的边集。
然后,做如下贪心选择:选取连接U和V−U的所有边中的最短边,即满足条件i∈U,j∈V−U,且边(i,j)是连接U和V−U的所有边中的最短边,即该边的权值最小。
然后,将顶点j加入集合U,边(i,j)加入TE。继续上面的贪心选择一直进行到U=V为止,此时,选取到的所有边恰好构成图G的一棵最小生成树T。
算法设计及步骤如下。
步骤1:确定合适的数据结构。设置带权邻接矩阵C存储图G,如果图G中存在边(u,x),令C[u][x]等于边(u,x)上的权值,否则,C[u][x]=∞;bool数组s[],如果s[i]=true,说明顶点i已加入集合U。
如图2-62所示,直观地看图很容易找出 U 集合到 V−U集合的边中哪条边是最小的,但是程序中如果穷举这些边,再找最小值就太麻烦了,那怎么办呢?
图2-62 最小生成树求解过程
可以通过设置两个数组巧妙地解决这个问题,closest[j]表示V−U中的顶点j到集合U中的最邻近点,lowcost[j]表示V−U中的顶点j到集合U中的最邻近点的边值,即边(j,closest[j])的权值。
例如,在图2-62中,7号结点到U集合中的最邻近点是2,closest[7]=2,如图2-63所示。7号结点到最邻近点2的边值为1,即边(2,7)的权值,记为lowcost[7]=1,如图2-64所示。
图2-63 closest[]数组
图2-64 lowcost[]数组
只需要在V−U集合中找lowcost[]值最小的顶点即可。
步骤2:初始化。令集合U={u0},u0∈V,并初始化数组closest[]、lowcost[]和s[]。
步骤3:在V−U集合中找lowcost值最小的顶点t,即lowcost[t]=min{lowcost[j]|j∈V−U},满足该公式的顶点t就是集合V−U中连接集合U的最邻近点。
步骤4:将顶点t加入集合U。
步骤5:如果集合V−U为空,算法结束,否则,转步骤6。
步骤6:对集合V−U中的所有顶点j,更新其lowcost[]和closest[]。更新公式:if(C[t] [j]<lowcost [j] ) { lowcost [j]= C [t] [j]; closest [j] = t; },转步骤3。
按照上述步骤,最终可以得到一棵权值之和最小的生成树。
2.7.3 完美图解
设G =(V,E)是无向连通带权图,如图2-65所示。
图2-65 无向连通带权图G
(1)数据结构
设置地图的带权邻接矩阵为C[][],即如果从顶点i到顶点j有边,就让C[i][j]=<i,j>的权值,否则C[i][j]=∞(无穷大),如图2-66所示。
图2-66 邻接矩阵C[ ][ ]
(2)初始化
假设u0=1;令集合U={1},V−U={2,3,4,5,6,7},TE={},s[1]=true,初始化数组closest[]:除了1号结点外其余结点均为1,表示V−U中的顶点到集合U的最临近点均为1,如图2-67所示。lowcost[]:1号结点到V−U中的顶点的边值,即读取邻接矩阵第1行,如图2-68所示。
图2-67 closest[]数组
图2-68 lowcost[]数组
初始化后如图2-69所示。
图2-69 最小生成树求解过程
(3)找最小
在集合V−U={2,3,4,5,6,7}中,依照贪心策略寻找V−U集合中lowcost最小的顶点t,如图2-70所示。
图2-70 lowcost[]数组
找到最小值为23,对应的结点t=2。
选中的边和结点如图2-71所示。
图2-71 最小生成树求解过程
(4)加入U战队
将顶点t加入集合U={1,2},同时更新V−U={3,4,5,6,7}。
(5)更新
刚刚找到了到U集合的最邻近点t = 2,那么对t在集合V−U中每一个邻接点j,都可以借助t更新。我们从图或邻接矩阵可以看出,2号结点的邻接点是3和7号结点:
C[2][3]=20<lowcost[3]=∞,更新最邻近距离lowcost[3]=20,最邻近点closest[3]=2;
C[2][7]=1<lowcost[7]=36,更新最邻近距离lowcost[7]=1,最邻近点closest[7]=2;
更新后的closest[j]和lowcost[j]数组如图2-72和图2-73所示。
图2-72 closest[]数组
图2-73 lowcost[]数组
更新后如图2-74所示。
图2-74 最小生成树求解过程
closest[j]和lowcost[j]分别表示V−U集合中顶点j到U集合的最邻近顶点和最邻近距离。3号顶点到U集合的最邻近点为2,最邻近距离为20;4、5号顶点到U集合的最邻近点仍为初始化状态1,最邻近距离为∞;6号顶点到U集合的最邻近点为1,最邻近距离为28;7号顶点到U集合的最邻近点为2,最邻近距离为1。
(6)找最小
在集合V−U={3,4,5,6,7}中,依照贪心策略寻找V−U集合中lowcost最小的顶点t,如图2-75所示。
图2-75 lowcost[]数组
找到最小值为1,对应的结点t=7。
选中的边和结点如图2-76所示。
图2-76 最小生成树求解过程
(7)加入U战队
将顶点t加入集合U={1,2,7},同时更新V−U={3,4,5,6}。
(8)更新
刚刚找到了到U集合的最邻近点t =7,那么对t在集合V−U中每一个邻接点j,都可以借t更新。我们从图或邻接矩阵可以看出,7号结点在集合V−U中的邻接点是3、4、5、6结点:
C[7][3]=4<lowcost[3]=20,更新最邻近距离lowcost[3]=4,最邻近点closest[3]=7;
C[7][4]=9<lowcost[4]=∞,更新最邻近距离lowcost[4]=9,最邻近点closest[4]=7;
C[7][5]=16<lowcost[5]=∞,更新最邻近距离lowcost[5]=16,最邻近点closest[5]=7;
C[7][6]=25<lowcost[6]=28,更新最邻近距离lowcost[6]=25,最邻近点closest[6]=7;
更新后的closest[j]和lowcost[j]数组如图2-77和图2-78所示。
图2-77 closest[]数组
图2-78 lowcost[]数组
更新后如图2-79所示。
图2-79 最小生成树求解过程
closest[j]和lowcost[j]分别表示V−U集合中顶点j到U集合的最邻近顶点和最邻近距离。3号顶点到U集合的最邻近点为7,最邻近距离为4;4号顶点到U集合的最邻近点为7,最邻近距离为9;5号顶点到U集合的最邻近点为7,最邻近距离为16;6号顶点到U集合的最邻近点为7,最邻近距离为25。
(9)找最小
在集合V−U={3,4,5,6}中,依照贪心策略寻找V−U集合中lowcost最小的顶点t,如图2-80所示。
图2-80 lowcost[]数组
找到最小值为4,对应的结点t=3。
选中的边和结点如图2-81所示。
图2-81 最小生成树求解过程
(10)加入U战队
将顶点t加入集合U ={1,2,3,7},同时更新V−U={4,5,6}。
(11)更新
刚刚找到了到U集合的最邻近点t =3,那么对t在集合V−U中每一个邻接点j,都可以借助t更新。我们从图或邻接矩阵可以看出,3号结点在集合V−U中的邻接点是4号结点:
C[3][4]=15>lowcost[4]=9,不更新。
closest[j]和lowcost[j]数组不改变。
更新后如图2-82所示。
图2-82 最小生成树求解过程
closest[j]和lowcost[j]分别表示V−U集合中顶点j到U集合的最邻近顶点和最邻近距离。4号顶点到U集合的最邻近点为7,最邻近距离为9;5号顶点到U集合的最邻近点为7,最邻近距离为16;6号顶点到U集合的最邻近点为7,最邻近距离为25。
(12)找最小
在集合V−U={4,5,6}中,依照贪心策略寻找V−U集合中lowcost最小的顶点t,如图2-83所示。
图2-83 lowcost[]数组
找到最小值为9,对应的结点t=4。
选中的边和结点如图2-84所示。
图2-84 最小生成树求解过程
(13)加入U战队
将顶点t加入集合U ={1,2,3,4,7},同时更新V−U={5,6}。
(14)更新
刚刚找到了到U集合的最邻近点t =4,那么对t在集合V−U中每一个邻接点j,都可以借助t更新。我们从图或邻接矩阵可以看出,4号结点在集合V−U中的邻接点是5号结点:
C[4][5]=3<lowcost[5]=16,更新最邻近距离lowcost[5]=3,最邻近点closest[5]=4;
更新后的closest[j]和lowcost[j]数组如图2-85和图2-86所示。
图2-85 closest[]数组
图2-86 lowcost[]数组
更新后如图2-87所示。
图2-87 最小生成树求解过程
closest[j]和lowcost[j]分别表示V−U集合中顶点j到U集合的最邻近顶点和最邻近距离。5号顶点到U集合的最邻近点为4,最邻近距离为3;6号顶点到U集合的最邻近点为7,最邻近距离为25。
(15)找最小
在集合V−U={5,6}中,依照贪心策略寻找V−U集合中lowcost最小的顶点t,如图2-88所示。
图2-88 lowcost[]数组
找到最小值为3,对应的结点t=5。
选中的边和结点如图2-89所示。
图2-89 最小生成树求解过程
(16)加入U战队
将顶点t加入集合U={1,2,3,4,5,7},同时更新V−U={6}。
(17)更新
刚刚找到了到U集合的最邻近点t =5,那么对t在集合V−U中每一个邻接点j,都可以借助t更新。我们从图或邻接矩阵可以看出,5号结点在集合V−U中的邻接点是6号结点:
C[5][6]=17<lowcost[6]=25,更新最邻近距离lowcost[6]=17,最邻近点closest[6]=5;
更新后的closest[j]和lowcost[j]数组如图2-90和图2-91所示。
图2-90 closest[]数组
图2-91 lowcost[]数组
更新后如图2-92所示。
图2-92 最小生成树求解过程
closest[j]和lowcost[j]分别表示V−U集合中顶点j到U集合的最邻近顶点和最邻近距离。6号顶点到U集合的最邻近点为5,最邻近距离为17。
(18)找最小
在集合V−U={6}中,依照贪心策略寻找V−U集合中lowcost最小的顶点t,如图2-93所示。
图2-93 lowcost[]数组
找到最小值为17,对应的结点t=6。
选中的边和结点如图2-94所示。
图2-94 最小生成树求解过程
(19)加入U战队
将顶点t加入集合U ={1,2,3,4,5,6,7},同时更新V−U={}。
(20)更新
刚刚找到了到U集合的最邻近点t =6,那么对t在集合V−U中每一个邻接点j,都可以借t更新。我们从图2-94可以看出,6号结点在集合V−U中无邻接点,因为V−U={}。
closest[j]和lowcost[j]数组如图2-95和图2-96所示。
图2-95 closest[]数组
图2-96 lowcost[]数组
得到的最小生成树如图2-97所示。
图2-97 最小生成树
最小生成树权值之和为57,即把lowcost数组中的值全部加起来。
2.7.4 伪代码详解
(1)初始化。s[1]=true,初始化数组closest,除了u0外其余顶点最邻近点均为u0,表示V−U中的顶点到集合U的最临近点均为u0;初始代数组lowcost,u0到V−U中的顶点的边值,无边相连则为∞(无穷大)。
s[u0] = true; //初始时,集合中U只有一个元素,即顶点u0
for(i = 1; i <= n; i++)
{
if(i != u0) //除u0之外的顶点
{
lowcost[i] = c[u0][i]; //u0到其它顶点的边值
closest[i] = u0; //最邻近点初始化为u0
s[i] = false; //初始化u0之外的顶点不属于U集合,即属于V-U集合
}
else
lowcost[i] =0;
}
(2)在集合V−U中寻找距离集合U最近的顶点t。
int temp = INF;
int t = u0;
for(j = 1; j <= n; j++) //在集合中V-U中寻找距离集合U最近的顶点t
{
if((!s[j]) && (lowcost[j] < temp)) //!s[j] 表示j结点在V-U集合中
{
t = j;
temp = lowcost[j];
}
}
if(t == u0) //找不到t,跳出循环
break;
(3)更新lowcost和closest数组。
s[t] = true; //否则,将t加入集合U
for(j = 1; j <= n; j++) //更新lowcost和closest
{
if((!s[j]) && (c[t][j] < lowcost[j])) // !s[j] 表示j结点在V-U集合中
//t到j的边值小于当前的最邻近值
{
lowcost[j] = c[t][j]; //更新j的最邻近值为t到j的边值
closest[j] = t; //更新j的最邻近点为t
}
}
2.7.5 实战演练
//program 2-7
#include <iostream>
using namespace std;
const int INF = 0x3fffffff;
const int N = 100;
bool s[N];
int closest[N];
int lowcost[N];
void Prim(int n, int u0, int c[N][N])
{ //顶点个数n、开始顶点u0、带权邻接矩阵C[n][n]
//如果s[i]=true,说明顶点i已加入最小生成树
//的顶点集合U;否则顶点i属于集合V-U
//将最后的相关的最小权值传递到数组lowcost
s[u0] = true; //初始时,集合中U只有一个元素,即顶点u0
int i;
int j;
for(i = 1; i <= n; i++)//①
{
if(i != u0)
{
lowcost[i] = c[u0][i];
closest[i] = u0;
s[i] = false;
}
else
lowcost[i] =0;
}
for(i = 1; i <= n; i++) //②
{
int temp = INF;
int t = u0;
for(j = 1; j <= n; j++) //③在集合中V-u中寻找距离集合U最近的顶点t
{
if((!s[j]) && (lowcost[j] < temp))
{
t = j;
temp = lowcost[j];
}
}
if(t == u0)
break; //找不到t,跳出循环
s[t] = true; //否则,讲t加入集合U
for(j = 1; j <= n; j++) //④更新lowcost和closest
{
if((!s[j]) && (c[t][j] < lowcost[j]))
{
lowcost[j] = c[t][j];
closest[j] = t;
}
}
}
}
int main()
{
int n, c[N][N], m, u, v, w;
int u0;
cout <<"输入结点数n和边数m:"<<endl;
cin >> n >> m;
int sumcost = 0;
for(int i = 1; i <= n; i++)
for(int j = 1; j <= n; j++)
c[i][j] = INF;
cout <<"输入结点数u,v和边值w:"<<endl;
for(int i=1; i<=m; i++)
{
cin >> u >> v >> w;
c[u][v] = c[v][u] = w;
}
cout <<"输入任一结点u0:"<<endl;
cin >> u0 ;
//计算最后的lowcost的总和,即为最后要求的最小的费用之和
Prim(n, u0, c);
cout <<"数组lowcost的内容为:"<<endl;
for(int i = 1; i <= n; i++)
cout << lowcost[i] << " ";
cout << endl;
for(int i = 1; i <= n; i++)
sumcost += lowcost[i];
cout << "最小的花费是:" << sumcost << endl << endl;
return 0;
}
算法实现和测试
(1)运行环境
Code::Blocks
(2)输入
输入结点数n和边数m:
7 12
输入结点数u,v和边值w:
1 2 23
1 6 28
1 7 36
2 3 20
2 7 1
3 4 15
3 7 4
4 5 3
4 7 9
5 6 17
5 7 16
6 7 25
输入任一结点u0:
1
(3)输出
数组lowcost的内容为:
0 23 4 9 3 17 1
最小的花费是:57
2.7.6 算法解析
(1)时间复杂度:在Prim(int n,int u0,int c[N][N])算法中,一共有4个for语句,第①个for语句的执行次数为n,第②个for语句里面嵌套了两个for语句③、④,它们的执行次数均为n,对算法的运行时间贡献最大。当外层循环标号为1时,③、④语句在内层循环的控制下均执行n次,外层循环②从1~n。因此,该语句的执行次数为n*n=n²,算法的时间复杂度为O(n²)。
(2)空间复杂度:算法所需要的辅助空间包含i、j、lowcost和closest,则算法的空间复杂度是O(n)。
该算法可以从两个方面优化:
(1)for语句③找lowcost最小值时使用优先队列,每次出队一个最小值,时间复杂度为logn,执行n次,总时间复杂度为O( n logn)。
(2)for语句④更新lowcost和closest数据时,如果图采用邻接表存储,每次只检查t的邻接边,不用从1~n检查,检查更新的次数为E(边数),每次更新数据入队,入队的时间复杂度为logn,这样更新的时间复杂度为O( Elogn)。