目录
生成树
我们说树是一种特殊的图,有n个顶点的树满足一下三个特性中的任意两个(之所以是任意两个是因为由其中两个必定能够推出另外一个):
1.全图必须是连通的
2.全图中只有n-1条边
3.全图中不含环
生成树则是在一个有V个顶点的无向连通图中,取其中V-1条边,连接其顶点得到的子图。如下所示:
右图是左图的一颗生成树,但并非我们今天要介绍的最小生成树。
最小生成树
那么什么是最小生成树呢?最小生成树首先肯定是一颗生成树,但它有一个要求:必须是该图所有生成树中边权和最小的那颗。也就是说,最小生成树解决的是如何用最小的代价将N-1条边连接N个点的问题。
如何得到最小生成树?
这里介绍两种算法:Prim算法和Kruskal算法,它们共同的思想都是贪心,但具体细节差异不小。
prim算法的实现
Prim算法从顶点入手,它将顶点分为了两个集合:U与V。U集合中存放已经加入了最小生成树的点,V集合则是还没有加入最小生成树的点集。设数组dis[i]表示第i个顶点到集合U的距离,首先任意选取一个顶点开始(一般取1号),初始化它的dis值为0,例:dis[1]=0,其它顶点为正无穷。再定义数组vst[i],如果i顶点已经被放入集合U中,则vst[i]=1,否则设为0。
从集合V中选定现在dis值最小的顶点,将其放入集合U,修改与它直接相连且还在集合V中的所有顶点的dis值,设当前选定的顶点为i,它要修改的顶点为j(j可以是多个顶点),如果i到j的距离edge{I,j}.dis小于dis[j],则将dis[j]修改为i到j的距离。不断重复以上步骤,直到所有顶点都在集合U中。
如果不理解上述一大段的文字描述,可以参考下面的图示:
实际举例如下,白点表示在集合U中,绿点是当前要加入集合U的顶点,蓝点表示在集合V中:
dis[1]=0:dis[2]=2 、dis[3]=12、dis[4]=10 dis[2]=2:dis[3]=8(8<12)、dis[5]=9
dis[3]=8:dis[4]=6(6<10)、dis[5]=3(3<9) dis[5]=3:dis[4]=6(6<7)
dis[4]=6 得到最小生成树
Prim算法的正确性如何理解?
想要不成环地连接N个顶点,需要连接N-1次。我们每一次操作都会连接一个新的顶点,而且所花代价最小(体现在dis值不断更新为最小值上),换一种描述方式就是:每次操作都用了最小的代价,用一条边连接了一个新顶点,该操作将会执行N-1次。若想严格的证明可利用反证法,请感兴趣的读者自行搜索。
prim算法的时间复杂度及其优化?
先来看看朴素算法的流程,设该图有E条边、N个结点,在执行操作前我们要从所有的点中选择dis值最小的点,并且dis值会随着选择点而改变,也就是说我们需要每次都重新搜索一遍所有结点,才能找到改变后的最小dis值,这部分的时间复杂度为O(N),而找到dis值最小的点后就要重新修改与它相连的所有结点的dis值,执行次数最多不会超过N-1,因此时间复杂度为O(N)。以上过程对每个结点都执行了一次,所以总时间复杂度为O( )。
#include<cstdio>
#include<cstring>
using namespace std;
const int INF = 0x7fffffff, N = 505;
//g邻接矩阵存图,dis存放顶点到集合V中顶点的距离,vst存放
int g[N][N], dis[N], vst[N];
int prim(int n){
int temp, min, ans=0;
for(int i=1;i<=n;++i)
dis[i]=INF;
dis[1]=0;
for(int i=1;i<=n;++i){
min=INF;
//找到还未选定的顶点中dis最小的那个
for(int j=1;j<=n;++j)
if(!vst[j] && min>=dis[j]){
min=dis[j];
temp=j;
}
vst[temp]=1;//该顶点已被选定,将该顶点的vst标记为1
ans+=dis[temp];
//修改相连顶点的dis值
for(int j=1;j<=n;++j)
if(!vst[j] && dis[j]>g[temp][j])
dis[j]=g[temp][j];
}
return ans;
}
int main(){
int m,n;
scanf("%d%d",&n,&m);
int x,y,w;
for(int i=1;i<=n;++i)
for(int j=1;j<=n;++j)
g[i][j]=INF;
for(int i=1;i<=m;++i){
scanf("%d%d%d",&x,&y,&w);
g[x][y]=g[y][x]=w;
}
memset(vst,0,sizeof(vst));
int ans=prim(n);
printf("%d\n",ans);
return 0;
}
现在考虑如何优化。由于图的结点数固定,而prim算法是基于结点遍历的,所以这部分循环肯定没有优化空间了。那么,能够优化的部分就只有每次选出dis值最小的顶点这步操作了,我们很容易将其抽象成这样一个问题:存在集合S,里面的元素可能会随时增加、删除、修改,而我们需要做到随时返回集合内的最小值,而这个问题则可以使用二叉堆轻松解决,这里求的是最小值,所以我们需要建立一个小根堆。如果使用C++的STL库,可以直接利用优先队列priority_queue解决此问题而不需手写堆(参考这篇博文:优先队列的使用)。当然,如果直接使用优先队列,你会发现存在一些问题,例如:从二叉堆取出了最小的dis值,却不知道它属于哪个顶点、要修改之前顶点的dis值时发现它们存入了优先队列使我们不能找到它们、优先队列是默认大根堆的,而prim算法要求的却是小根堆。
幸运的是,这几种情况都是存在解决方案的,先看第一个问题:不知道找出的dis值属于哪个顶点,是因为将dis数组存入优先队列时不能存入下标,既然这样就将dis定义成一个结构体数组,结构体内存储dis值与它对应的顶点,这样取出时就能知道它的顶点信息了。当然,还有更简单的方式,使用STL中的pair,pair允许将一对元素打包成一个,譬如pair<int,int> p就能一个变量存放两个元素,这样第一个元素存放dis值,第二个元素存放它对应的顶点就ok了。
再看第二个问题:没办法找到已经存入优先队列的结点,是因为优先队列只允许我们访问优先级最高的元素。既然如此,那我们不去找它们不就行了?哎——不去找它那要怎么修改呢?答案是,尽管我们已经使用了pair来保存dis值,但我们仍保留dis数组,修改时修改dis数组,而存入时则利用pair存入优先队列。不过这又带来了一个问题:既然不是修改而是添加新数据,那原来的数据因为无法找到所以也无法删除,这又要怎么办呢?答案是,不用管它。为什么?想想什么时候dis会修改,只有当这个顶点的dis变得更小时才会修改,所以后续存入的该顶点的dis一定比之前存入优先队列的dis要小,取出时一定先取出后放入的数据。至于前放入的数据,不要忘了我们还有一个数组vst呢!如果后续的时候我们取到了前放入顶点的数据,由于这个顶点已经被取过了,它的vst值被标记为1,将直接出队且不进行后续步骤,因此对最终的答案是不会产生影响的。
最后一个问题则比较好解决,方案有很多,如果是自定义结构体的,可以直接重载运算符;如果是使用pair的可以用这种方式定义优先队列:priority_queue< pair<int,int>, vector<int>, greater<int> > q; 当然,还有最简单的一种方法,存入时直接将dis取负值就行了。
好了,这样我们就能使用优先队列来优化Prim算法了。不过,最后Prim还有一处可以优化:可以将邻接矩阵的存图方式改为邻接表存图,这不是必须的,但却是很有必要的,顶点过多时,使用邻接矩阵的空间复杂度将难以承受,尽管邻接表将使代码编写变得复杂,但仍需掌握。以下代码使用了结构体+vector实现邻接表,这样在题目有多组输入时处理更加方便。
#include<cstdio>
#include<cstring>
#include<queue>
using namespace std;
const int N = 105;
//邻接表
struct Edge{
int to,dis;
};
vector<Edge> head[N];
//添边
void addEdge(int from,int to,int dis){
Edge edge;
edge.to=to;
edge.dis=dis;
head[from].push_back(edge);
}
//prim算法的辅助数组
int dis[N], vst[N];
priority_queue< pair<int,int> > q;
int prim(){
int ans=0;
dis[1]=0;
q.push(make_pair(-dis[1],1));
while(q.size()){
int i=q.top().second;
q.pop();
if(vst[i]) continue;
vst[i]=1;
ans+=dis[i];
for(int j=0;j<head[i].size();++j){
Edge edge=head[i][j];
int x=edge.to;
if(dis[x]>edge.dis){
dis[x]=edge.dis;
q.push(make_pair(-dis[x],x));
}
}
}
return ans;
}
//预处理
void pretreatment(int n){
for(int i=1;i<=n;++i) head[i].clear();
memset(vst,0,sizeof(vst));
memset(dis,0x7f,sizeof(dis));
}
int main(){
int n,m;
while (~scanf("%d", &n),n){
pretreatment(n);
int u,v,w;
m=n*(n-1)/2;
//邻接表建图
for(int i=1;i<=m;++i){
scanf("%d%d%d",&u,&v,&w);
addEdge(u,v,w);
addEdge(v,u,w);//无向图
}
printf("%d\n",prim());
}
return 0;
}
Kruskal算法的实现
Kruskal算法相比于Prim算法,它的思路就更容易理解了:它从边出发,首先将所有边按边权大小从小到大排序,然后依次选边,若添加了边后形成环则舍弃该边,直至选出N-1条边。
选择边(1,2)
选择边(3,5) 选择边(3,4)
选择边(4,5)后成环,舍弃 选择边(2,3),得到最小生成树
Kruskal算法的正确性?
Kruskal算法是显而易见的,想让总和最小,当然每次都选择最小的值,不合要求的值舍掉即可,所以相比之下Kruskal算法更符合我们常见的思维习惯。
Kruskal算法的时间复杂度及其优化?
首先是边排序的时间复杂度:ElogE(E为边数),然后是从E条边中选出N-1条边,注意这里最多可能会执行E次而不是N-1次,因为不是每次选边都是符合条件的!我们还需要判断选边后是否成环,简单的做法就是每次都检测当前图中是否含环,可以深搜来做,时间复杂度为O(E+N),执行E次就是O(),所以总时间复杂度为O(),怎么看都应该是有点大的,所以需要优化。
在什么地方可以优化呢?排序肯定是不行了的,理论上快排已经是最优选择了,那么就只有在判环上入手了。于是,我们选择用并查集来优化判环,不熟悉并查集的读者可以参考我的这篇博客:并查集。并查集判环的思想十分简单,即每选择一条边就将它的两个端点加入到并查集中,在后续的选边前,先判断它的两个端点是否在同一集合中,若在,则说明形成了环,不能添加该边。假设单次查询并查集的时间复杂度为α(n),最多操作了E次,这样总的时间复杂度为O(Eα(n)),加上排序的时间,最终的时间复杂度为O(ElogE+Eα(n)),如果并查集再使用路径压缩优化,查找一次的时间复杂度能优化到接近常数,所以可以近似得到O(ElogE)的时间复杂度。
#include<cstdio>
#include<iostream>
#include<algorithm>
using namespace std;
const int N = 10010;
struct Edge{
int from, to;
int dis;
bool operator < (const Edge e) const{
return dis < e.dis;
}
} e[N],temp[N]; //temp记录最终生成的最小生成树
int father[N];
void merge(int x, int y){
father[y]=x;
}
int find(int x){ //路径压缩
return x==father[x]?x : father[x]=find(father[x]);
}
int main(){
int n, m; //n个结点,m条边
cin>>n>>m;
for(int i=1;i<=m;++i)
cin>>e[i].from>>e[i].to>>e[i].dis;
for(int i=1;i<=n;++i)
father[i]=i;
sort(e+1,e+m+1);
int ans = 0, cnt = 0; //ans记录最小生成树的权值和,cnt记录当前已经加入的边数
for(int i=1;i<=m;++i){
int f1=find(e[i].from);
int f2=find(e[i].to);
if(f1!=f2){
temp[cnt].from=e[i].from;
temp[cnt].to=e[i].to;
ans += e[i].dis;
merge(f1,f2);
cnt++;
}
if(cnt==n-1) break;
}
if(cnt<n-1) cout<<"Impossible\n";
else{
for(int i=0;i<cnt;++i)
cout<<temp[i].from<<" "<<temp[i].to<<"\n";
cout<<ans<<"\n";
}
return 0;
}