学习算法的最好方式,我认为是应从思考一些有趣的题目开始。学习一个算法之前要知道这个算法的价值是什么。
附题:HDU 1863 最小生成树模板题
题目大意:询问你在各个村庄之间修公路,最少的花费是多少。
我们可以把“村庄"抽象成一个一个点,把”修公路的费用"抽象成连接两点的边值(权重)。
我们要求解决的是求出 把这些点(村庄)都连起来(修公路)时的所构成的树的权重总和最少(最少费用)是多少,即最小生成树的权重和。
如图:
注:生成树不含环路,且在有相同权重得情况下最小生成树可能不止一颗,但权重和一定相同。
则如何构建最小生成树成为重中之重!
下面介绍两种算法:Prim算法 和 Kruskal算法。
算法原理先介绍,证明会放在最后,因为这两种算法简单且看上去就像是那么回事~
Prim算法:
Prim算法的思想是:取图中任意一点作为起点放入树中,并向邻近树的点不断延伸,每次延伸的点要求满足到树的距离最短。
我们采用dis[]记录点离树的距离,并采用val[]标记在树中的点,两数组实时更新。
如图:
1:
2:
3:
4:
5:
这里省略一步连接点四的图,所以布置共6步,对应的图中6个点。
所以你知道所构建的最小生成树的权重和是多少吗?没错就是把每幅图中dis[]中红色数字加起来即可,即对应上面的图MinDis=2+4+5+3+7=21。其实dis[]不用在每步更新在树中的点,而是保留下来,这样最后遍历加一遍dis[]也可以得到答案(前提是所以点都可以连成一棵树)。
很可惜C++中没有带有索引功能的优先队列,减少查找和更新权重边的时间,所以在一般的算法题上,可用数组代替,只是时间复杂度会达到N^2。
下面是用Prim算法写上题的代码,此代码可能没有别人的简练,但思路清晰,便于理解与修改。
#include
#include
#include
using namespace std;
#define INF 0x3f3f3f3f
const int _max=105;
int city[_max][_max]; //city记录两城市之间的距离
int val[_max],dis[_max]; //val:标记在树中的点 dis:记录点离树的距离
void Prim( int x , int City )
{
int ans = 0;
for( int i=1; i<=City; i++ ) //初始化起点
dis[i] = city[x][i] ;
val[x] = 1 ;
int MinDis,atPoint;
for( int i=2; i<=City; i++ ) //循环次数为City-1次
{
//找到Dis[]最小权重值
MinDis = INF ;
for( int j=1; j<=City; j++ ) //寻找下一个城市
{
if( !val[j] && MinDis > dis[j] ) //没在树中且距离短
{
MinDis = dis[j];
atPoint = j;
}
}
if( MinDis == INF ) //如果在所有点进入前无路可连,则不连通
{
printf("?\n");
return ;
}
val[atPoint] = 1; //标记
ans+=MinDis;
//更新DIS[]
for( int j=1; j<=City; j++ )
dis[j] = min( dis[j], city[atPoint][j] );
}
printf("%d\n",ans);
}
void init( )
{
for( int i=1; i<=_max; i++ )
{
for( int j=1; j<=_max; j++ )
{
if( i==j ) city[i][j] = 0;
else city[i][j] = INF ;
}
}
//memset(dis,INF,sizeof(dis)); //dis[]也可不初始化
memset(val,0,sizeof(val));
}
int main()
{
int Road,City;
int x,y,w;
while( ~scanf("%d%d",&Road,&City) )
{
if( !Road ) break;
init();
for( int i=1; i<=Road; i++ )
{
scanf("%d%d%d",&x,&y,&w);
city[x][y] = city[y][x] = min( city[x][y], w ); //选取两点最短边
}
Prim(1,City); //选取点一做起点,在City里生成最小生成树
}
return 0;
}
与之对应的Kruskal算法,两者不同将在文后比较。
Kruskal算法:
Kruskal算法的思想是:将边按权重从小到大排序,依次选取边上的两点进行连接,只要满足边上两点不在同一树中就行。
这里我们需要边(Edge)结构,包含数据a,b两端点,以及距离dis;和判断两点是非在一颗树里的并查集数组UF[]。
//不知道并查集的同学不用着急,这里你只要知道它的用处是连接两点,并可判断两点是否在一颗树。
//以后我会附上并查集详解的链接,因为很容易,当然是要用自己的咯,哈哈。
如图:
1:
2:
3:
5:
6:
因为所有点已经连接完成,所有这里省略剩下步骤。
正如图所示,Kruskal算法运算步骤取决于边,算法复杂度(排序+并查集连接+查找+访问Edge[])在ElogE,所需空间与E成正比(E代表边的数量),因为少点可以连多边,所有在开Edge[]时,数组的时候往往需要开的很大。
下面是上题的代码:
#include
#include
using namespace std;
const int _max=10005;
int UF[_max/100+5],sz[_max/100+5];
struct Edge
{
int a,b,dis;
}E[_max],e;
bool E_cmp( const Edge e1, const Edge e2 ) //从大到小排序Edge
{
return e1.dis < e2.dis;
}
void init()
{
for( int i=1; i<=_max/100+5; i++ ) //初始化
{
UF[i]=i;
sz[i]=1;
}
}
int Find( int p ) //并查集查找树根(父节点)
{
while( p!=UF[p] ) p=UF[p];
return p;
}
void Union(int p, int q ) //连接两点
{
int i = Find(p);
int j = Find(q);
//if( i==j ) return; // 这步在此可省略
if( sz[i]1 ) printf("?\n"); //连通分量>1说明图没完全连通
else printf("%d\n",ans);
}
int main()
{
int Road,City;
while( ~scanf("%d%d",&Road,&City) )
{
if( !Road ) break;
init();
for( int i=1; i<=Road; i++ )
scanf("%d%d%d",&E[i].a,&E[i].b,&E[i].dis);
sort(E+1,E+Road+1,E_cmp);
Kruskal(Road,City);
}
return 0;
}
简单总结:
Prim算法和Kruskal算法都是一种贪心算法,都是每次取最小边进行连接。所不同的是Prim算法是从一点出发,找相邻的点中离树最近的加入树中,循环只到所有点都加入了树中为止;而Kruskal算法是完全在找最小的边,唯一要判断的是,两点是不是在同一树中,如果是则跳过,循环只到只有一颗树为止。(假设图是连通的).
在没有用优先队列优化Prim的前提下,Prim算法较慢但空间比Kruskal算法小,在题目中如何取舍要具体题目。
说明:
1:最小生成树不止能解决距离,费用等问题,对于任意一个能抽象成加权无向图的题目或是问题,求最小权重,最小生成树算法:Prim算法和Kruskal算法都能解决。
2:如果所给出的图并不连通,那么会算出多颗由最小生成树组成的森林。
3:最好保持所有边的权重都不相同,这样才能保证最小生成树有且仅有一颗。
应用领域 | 顶点 | 边 |
---|---|---|
电路 | 元器件 | 导线 |
航空 | 机场 | 航线 |
电力分配 | 电站 | 输电线 |
( 摘录自《算法》,感谢 )
证明: //可跳过
定义1:图的一种切分是将图的所有顶点分为两个非空不重叠的两个集合。横切边是一条连接两个不同集合的顶点的边。
切分定理:
命题1:在一份加权有向图中,给定任意的切分,它的横切边中的权重最小者必然属于最小生成树。
证明:
令e为权重最小的横切边,T为图的最小生成树。我们采用反证法:假设T不包含e。那么如果将e加入T,得到的图必然是一个含有一条经过e的环,且这个环至少含有另一条横切边—设为f,f的权重必然大于e(因为e是最小的且所有边权重都不同)。那么我们删掉f保留e可以得到一条权重更小的最小生成树。这与我们的假设T矛盾。
最小生成树贪心算法
命题2:下面这种方法会将含有V个顶点的任意加权连通图中属于最小生成树的边标记为黑色:初始状态下都为灰色,在到一种切分,它产生的横切边都不玩黑色。将它的最小横切边标记为黑色。反复,直到标记了V-1条黑色边为止。这些边所组成的就是一颗最小生成树。
证明:
为了简单,我们假设所有边的权重都不相等,尽管没有这个命题同样成立(-_-||),根据切分定理,如果黑色边的数量小于V-1,必然还存在不会产生黑色横切边的边(假设图是连通的)。只要找到了V-1条黑色的边,就找到了最小生成树。
命题3:Prim算法能够得到任意加权连通图的最小生成树
证明:
由命题2可知,这颗不断生长的树,定义了一个切分且不存在黑色的横切边。该算法会选择权重最小的横切边并根据贪心算法不断将他们标记为黑色。
命题4:Kruskal算法能够计算任意加权连通图的最小生成树。
证明:
用命题2可知,如果下一条将被加入的最小生成树中的边不会和已有的黑色边构成环,那么它跨越了由所有树顶点相邻的顶点组成的集合,以及它们的补集所构成的一个切分。因为加入的这条边不会构成环,它是目前已知唯一一条横切边且是按照权重顺序选择的边,因此,该算法能够连续选择权重最小的横切边,和贪心算法一致。
最后:
本文到此结束,若有不足欢迎指出。后续还将不断更正本文错误,添加一些遗漏。