最小生成树算法及其常见应用

最小生成树

定义:在无向图\(G=(V,E)\)中,一颗连接所有节点的树(无根树)被称为这个无向图的生成树,其中边权之和最小的那一颗被称为最小生成树
定理:最小生成树一定包含无向图中权值最小的边
证明:
反证法,假设无向图G=(V,E)存在一颗最小生成树不包含权值最小的边,把这条边加入最小生成树集合之后会形成一个环,这个环上任意一条边都比新加入的边的权值大,任意断开一条可以得到一个权值和更小的生成树,假设不成立,故命题成立
推论:
给定一张无向图\(G=(V,E),n=|V|,m=|E|\)\(E\)中选出\(k<n-1\)条边组成\(G\)的一个生成森林,若再从剩余\(m-k\)条边中选\(n-1-k\)条加入生成森林使其变成一颗生成树,并且选出的边权值最小,则该生成树一定包含这\(m-k\)条边中连接生成森林的两个不连通节点的权值最小的边

Kruskal算法

\(Kruskal\)算法就是基于上述推论的。\(Kruskal\)算法总是维护无向图的最小生成森林,最初可以认为生成森林是由零条边构成,每个节点各自构成一颗仅包含一个节点的树
在任意时刻\(Kruskal\)算法从剩余的边中选出一条权值最小的,并把这条边的两个端点属于的生成树连通,图中节点的连接情况可以用并查集维护

详细的说,步骤如下

  1. 建立并查集,每个点各自构成一个集合
  2. 把所有边按权值从小到大排序,依次扫描每一条边\((u,v,w)\)
  3. 如果\(u,v\)属于同一集合,忽略,继续扫描下一条
  4. 将答案累加上\(w\),并合并\(u,v\)所在集合
  5. 重复3.4直到所有边扫描完成
    时间复杂度为\(O(m\log m)\)
struct node{
    int u,v,w;
    bool operator<(const node b)const{  
        w<b.w;
    }
}a[100005];
int f[100005],n,m,ans;
int find(int x){return x==f[x]?x:f[x]=find(f[x]);}
int main(){  
    scanf("%d%d",&n,&m);
    for(int i=1;i<=m;i++)scanf("%d%d%d",&a[i].u,&a[i].v,&a[i].w);
    sort(a+1,a+m+1);
    for(int i=1;i<=n;i++)f[i]=i;
    for(int i=1;i<=m;i++){  
        int u=find(a[i].u),v=find(a[i].v);
        if(u==v)continue;
        f[u]=v;
        ans+=a[i].w;
    }
    printf("%d",ans);
}

Prim

\(Prim\)同样基于推论,与\(Kruskal\)不同的是,\(prim\)算法始终维护最小生成树的一部分,不断扩大这个部分,最终扩展为一颗最小生成树
我们设\(S\)表示我们维护的最小生成树集合,\(T\)表示剩余节点集合,我们每一次需要找到一条边,两个端点分属两个集合且边权最小,然后将这条边累计上答案,属于\(T\)的那个端点加入到\(S\)中,直到\(|S|=|V|\)
在具体实现中,最初\(S\)中只有一号节点,我们维护一个数组\(d\),对于每个节点\(x\),若\(x\in S\),则\(d[x]\)表示\(x\)加入\(S\)时所连接的最小边的权值,若\(x\in T\),则\(d[x]\)表示\(x\)\(S\)中节点相连的最小边权
类比\(Dijkstra\)算法,我们也对其进行更新操作,用数组\(vis\)标记节点是否属于\(S\),然后找到不属于\(S\)的节点的\(d\)值最小的,将其加入\(S\),并且累计答案,然后这个节点对其所有出边且属于\(T\)的节点进行更新操作,直到所有节点更新完毕
时间复杂度为\(O(n^2)\)可以使用二叉堆优化为\(O(m\log n )\),但这就不如\(Kruskal\)算法了,代码复杂度和常数复杂度都是
由于\(prim\)算法的时间复杂度仅与节点个数有关,所以常用于稠密图尤其是完全图的最小生成树求解

int prim(){  
    memset(d,0x3f,sizeof d);
    memset(vis,0,sizeof vis);
    d[1]=0;
    for(int i=1;i<n;i++){  
        int num=-1;
        for(int j=1;j<=n;j++){  
            if(!vis[j]&&(num==-1||d[num]>d[j]))num=j;
        }
        vis[num]=1;
        for(int j=1;j<=n;j++){  
            if(!vis[j])d[j]=min(d[j],a[num][j]);
        }
    }
    for(int i=2;i<=n;i++)ans+=d[i];
    return ans;
}

因为\(d\)数组的定义,于是\(\sum_{i=1}^nd[i]\)就是答案

最小生成树的应用

例题1:北极网络

国防部(DND)希望通过无线网络连接几个北部前哨站。

在建立网络时将使用两种不同的通信技术:每个前哨站都有一个无线电收发器,一些前哨站还有一个通信卫星。

任意两个拥有通信卫星的前哨站不论它们的位置如何,都可以通过卫星进行通信。

而如果利用无线电进行通信,则需要两个前哨站的距离不能超过 \(D\) 方可进行通信。

\(D\) 的大小取决于收发器的功率,收发器的功率越大,\(D\) 也就越大,但是需要的成本也就越高。

出于采购和维护的考虑,所有的前哨站都采用相同的收发器,也就是说所有前哨站的无线电通信距离 \(D\) 都是相同的。

你需要确定在保证任意两个前哨站之间都能进行通信(直接或间接)的情况下,\(D\) 的最小值是多少。

输入格式
第一行包含整数 \(N\),表示共有 \(N\) 组测试数据。

每组数据的第一行包含两个整数 \(S\)\(P\),其中 \(S\) 为卫星个数,\(P\) 为前哨站个数。

接下来 \(P\) 行每行包含两个整数 \(x\)\(y\),分别表示一个前哨站的横纵坐标。

输出格式
输出一个实数,表示 \(D\) 的最小值,结果保留两位小数。

数据范围
\(1≤S≤100,\)
\(S≤P≤500,\)
\(0≤x,y≤10000\)

分析:
首先这道题是一个完全图,我们需要对每一个前哨站互相连边
算法1:二分答案
单调性很容易发现,\(D\)更大肯定比\(D\)更小使得可以通信的前哨站更多,于是我们可以二分答案,设当前二分的值是\(mid\),我们将边权大于\(mid\)的边赋值为\(1\),小于\(mid\)的边赋值为0,很明显,我们假设所有边权为\(0\)的边所连接到的点构成的若干个连通块,而我们的卫星可以使得连通块相连,即对每一个连通块安装一个卫星,就可以连通整张图,于是我们只需要统计连通块的个数,与\(S\)相比较即可确定二分上下界变化,对于连通块的求解需要使用并查集维护

const int N = 510;
const double eps = 1e-4;
int n, m, x[N], y[N], fa[N];
double d[N][N];
int findfa(int x) { return x == fa[x] ? x : fa[x] = findfa(fa[x]); }
bool check(double now) {
    for (int i = 1; i <= n; i++) fa[i] = i;
    for (int i = 1; i < n; i++)
        for (int j = i + 1; j <= n; j++)
            if (d[i][j] <= now) fa[findfa(i)] = findfa(j);
    int sum = 0;
    for (int i = 1; i <= n; i++)
        if (fa[i] == i) sum++;
    return sum <= m;
}
int main() {
    int tt; cin >> tt;
    while (tt--) {
        cin >> m >> n;
        for (int i = 1; i <= n; i++)
            scanf("%d%d", &x[i], &y[i]);
        for (int i = 1; i < n; i++)
            for (int j = i + 1; j <= n; j++)
                d[i][j] = (double)sqrt((x[i] - x[j]) * (x[i] - x[j]) + (y[i] - y[j]) * (y[i] - y[j]));
        double l = 0.0, r = 14145.0, mid;
        while (l + eps < r) {
            mid = (l + r) / 2;
            if (check(mid)) r = mid;
            else l = mid;
        }
        printf("%.2lf\n", r);
    }
    return 0;
}

算法2:\(Kruskal\)
由上文推论,在本题中,我们实质上需要维护最小生成树的一个子图,满足子图上最大边的边权不超过\(D\),由推论性质得到,我们最小生成树有\(P-1\)条边,而卫星可以使得\(S-1\)条边不计入,于是我们只需要统计最小生成树里第\(P-1-(S-1)=P-S\)大的边的边权就是答案

int p,s,f[100005],cnt,vis[100005],m;
struct node{
	int len,u,v;
	bool operator<(const node b)const{
		return len<b.len;
	}
}a[1000005];
pair<int,int>in[100005];
int get(pair<int,int> a,pair<int ,int> b){
	return (a.first-b.first)*(a.first-b.first)+(a.second-b.second)*(a.second-b.second);
}
int find(int x){
	return x==f[x]?x:f[x]=find(f[x]);
}
void init(){
	m=0;
	scanf("%d%d",&s,&p);
	for(int i=1;i<=p;i++)scanf("%d%d",&in[i].first,&in[i].second);
	for(int i=1;i<=p;i++){
		for(int j=i+1;j<=p;j++){
			a[++m]={get(in[i],in[j]),i,j};
		}
	}
	sort(a+1,a+m+1);
}
int Kruskal(){
	memset(vis,0,sizeof vis);
	for(int i=1;i<=p;i++)f[i]=i;
	cnt=0;
	for(int i=1;i<=m;i++){
		int u=a[i].u,v=a[i].v;
		u=find(u),v=find(v);
		if(v==u)continue;
		if(++cnt==p-s)return a[i].len;
		f[u]=v;
	}
}
int main(){
	freopen("1.in","r",stdin);
	int n;
	scanf("%d",&n);
	while(n--){
		init();
		printf("%.2f",sqrt(Kruskal()));
	//	for(int i=1;i<=m;i++)printf("%.3f ",sqrt(a[i].len));
		puts("");
	}
}

算法1的时间复杂度是\(O(n^2\log V)\),算法2的时间复杂度是\(O(n^2\log n)\),理论上讲算法2更优
这道题启发我们:
1.发掘题目单调性,使用二分判定答案
2.考虑Kruskal和prim思想的本质,理解最小生成树定理

例题2:沙漠之王

简单的说题意,就是求最优比率生成树,即每条边有成本\(cost\)和长度\(len\),要求\(\frac{\sum cost}{\sum len}\)的最小值
很明显,这是一道0/1分数规划与最小生成树的综合题
我们只需要建立一张图,每个边的权值是\(cost_i-mid\times len_i\),对这张图求最小生成树,若边权和非负0则令\(l=mid\),否则令\(r=mid\)
为什么是最小生成树而不是最大生成树呢?因为只有取最小生成树的时候,我们满足这张图其余生成树的答案一定不小于最小生成树的比率,否则就可以继续减小(这里蓝书上出错了)

double cost[1005][1005];int n,vis[1005];
double d[1005][1005],dis[1005];
double b[1005][1005];
struct node{
	int x,y,h;
}a[1005];
double get(node a,node b){
	return sqrt((a.x-b.x)*(a.x-b.x)+(a.y-b.y)*(a.y-b.y));
}
void init(){
	for(int i=1;i<=n;i++){
		for(int j=i;j<=n;j++){
			cost[i][j]=cost[j][i]=get(a[i],a[j]);
			d[i][j]=d[j][i]=abs(a[i].h-a[j].h);
		}
	}
}
bool check(double mid){
//	printf("%.5f\n",mid);
	for(int i=1;i<=n;i++){
		for(int j=i;j<=n;j++){
			if(i==j){
				b[i][j]=2e9;
				continue;
			}
			b[i][j]=b[j][i]=d[i][j]-mid*cost[i][j];
		//	printf("%.5f ",b[i][j]); 
		}
	//	puts("");
	}
	memset(vis,0,sizeof vis);
	for(int i=1;i<=n;i++)dis[i]=2e9;
	dis[1]=0;
	for(int i=1;i<n;i++){
		double now;int id=0;
		for(int j=1;j<=n;j++){
			if(!vis[j]&&(id==0||(now>dis[j])))id=j,now=dis[j];
		}
	//	printf("%d\n",id);
		vis[id]=1;
		for(int j=1;j<=n;j++){
			if(!vis[j]){
		//		printf("A%d %.4f ",j,dis[j]);
				dis[j]=min(dis[j],b[id][j]);
		//		printf("%.4f\n",dis[j]);
			}
		}
	}
	double ans=0;
	for(int i=2;i<=n;i++)ans+=dis[i];
	//printf("%.3f\n",ans);
	return ans>=0;
}
int main(){
	//freopen("10.in","r",stdin);
	while(~scanf("%d",&n)&&n){
		for(int i=1;i<=n;i++){
			scanf("%d%d%d",&a[i].x,&a[i].y,&a[i].h);
		} 
		init();
		double l=0,r=50;
		while(r-l>1e-8){
			double mid=(l+r)/2;
			if(check(mid))l=mid;
			else r=mid;
		}
		printf("%.3f\n",(l+r)/2);
	}
}

这道题是最优比率生成树模型,非常重要

例题3:黑暗城堡

在顺利攻破 \(Lordlsp\) 的防线之后,\(lqr\) 一行人来到了 \(Lordlsp\) 的城堡下方。

\(Lord lsp\) 黑化之后虽然拥有了强大的超能力,能够用意念力制造建筑物,但是智商水平却没怎么增加。

现在 \(lqr\) 已经搞清楚黑暗城堡有 \(N\) 个房间,\(M\) 条可以制造的双向通道,以及每条通道的长度。

\(lqr\) 深知 \(Lord lsp\) 的想法,为了避免每次都要琢磨两个房间之间的最短路径,\(Lord lsp\) 一定会把城堡修建成树形的。

但是,为了尽量提高自己的移动效率,\(Lord lsp\) 一定会使得城堡满足下面的条件:

\(D[i]\) 为如果所有的通道都被修建,第 \(i\) 号房间与第 \(1\) 号房间的最短路径长度;而 \(S[i]\) 为实际修建的树形城堡中第\(i\) 号房间与第 \(1\) 号房间的路径长度;要求对于所有整数 \(i\),有 \(S[i]=D[i]\) 成立 。

为了打败 \(Lord lsp\)\(lqr\) 想知道有多少种不同的城堡修建方案。

你需要输出答案对 \(2^31–1\) 取模之后的结果。

输入格式
第一行有两个整数 \(N\)\(M\)

之后 \(M\) 行,每行三个整数 \(X\)\(Y\)\(L\),表示可以修建 \(X\)\(Y\) 之间的一条长度为 \(L\) 的通道。

输出格式
一个整数,表示答案对 231–1 取模之后的结果。

数据范围
\(2≤N≤1000,\)
\(N−1≤M≤N(N−1)/2,\)
\(1≤L≤100\)

简单来说,本题让我们统计在一张无向图里有多少最短路径生成树,最短路径生成树是指对于任意边\((u,v,w)\)都有\(D[u]+u=D[v]\)
我们可以先使用\(Dijkstra\)求出D数组,然后统计\(\forall v\in[1,n]\)有多少个\(u\)满足\(D[u]+w_{u,v}=D[v]\),记为\(cnt_v\),最后把所有的cnt乘起来即可,至于原因,因为正权图的原因,对于cnt的计算可以使用对D从小到大排序之后按顺序统计,由于数据较小我就直接上\(n^2\)其实是我没调好
正确性证明:很明显,当\(u\)满足\(D[u]+w_{u,v}=D[v]\),此时从v连接所有的u都不会影响最短路径生成树,由乘法原理即可得到,这是无后效性的,因为最终n个节点必定会连通

#define x first
#define y second
#define mp make_pair
#define int long long
#define p ((1ll<<31)-1)
priority_queue<pair<int,int> >q;
pair<int,int>a[100005];
int head[10005],nxt[1000005],ver[1000005],cost[1000005],d[1000005],vis[1000005],tot,n,m,cnt[100050];
void add(int u,int v,int w){
	nxt[++tot]=head[u],ver[tot]=v,cost[tot]=w,head[u]=tot;
}
void dijkstra(){
	q.push(mp(0,1));
	memset(d,0x3f,sizeof d);
	d[1]=0;
	while(q.size()){
		int u=q.top().y,w=d[u];q.pop();
		if(vis[u])continue;
		vis[u]=1;
		for(int i=head[u];i;i=nxt[i]){
			int v=ver[i],z=cost[i];
			if(d[v]>w+z){
				d[v]=w+z;
				q.push(mp(-d[v],v));
			}
		}
	}
	//for(int i=1;i<=n;i++)printf("%d ",d[i]);
	//puts("");
}
int prim(){
	for(int u=1;u<=n;u++){
		for(int i=head[u];i;i=nxt[i]){
			int v=ver[i];
			if(d[u]+cost[i]==d[v])cnt[v]++;
		}
	}
	int ans=1;
	for(int i=1;i<=n;i++)if(cnt[i])ans=ans*cnt[i]%p;
	return ans;
}
signed main(){
//	freopen("castle8.in","r",stdin);
	scanf("%lld%lld",&n,&m);
	for(int i=1;i<=m;i++){
		int u,v,w;
		scanf("%lld%lld%lld",&u,&v,&w);
		add(u,v,w);
		add(v,u,w); 
	}
	dijkstra();
	printf("%lld\n",prim());
}

这道题是关于最短路径生成树模型的,并且求方案数,结论务必理解记忆

例题4野餐规划

一群小丑演员,以其出色的柔术表演,可以无限量的钻进同一辆汽车中,而闻名世界。

现在他们想要去公园玩耍,但是他们的经费非常紧缺。

他们将乘车前往公园,为了减少花费,他们决定选择一种合理的乘车方式,可以使得他们去往公园需要的所有汽车行驶的总公里数最少。

为此,他们愿意通过很多人挤在同一辆车的方式,来减少汽车行驶的总花销。

由此,他们可以很多人驾车到某一个兄弟的家里,然后所有人都钻进一辆车里,再继续前进。

公园的停车场能停放的车的数量有限,而且因为公园有入场费,所以一旦一辆车子进入到公园内,就必须停在那里,不能再去接其他人。

现在请你想出一种方法,可以使得他们全都到达公园的情况下,所有汽车行驶的总路程最少。

第一行包含整数 n,表示人和人之间或人和公园之间的道路的总数量。

接下来 n 行,每行包含两个字符串 A、B 和一个整数 L,用以描述人 A 和人 B 之前存在道路,路长为 L,或者描述某人和公园之间存在道路,路长为 L。

道路都是双向的,并且人数不超过 20,表示人的名字的字符串长度不超过 10,公园用 Park 表示。

再接下来一行,包含整数 s,表示公园的最大停车数量。

你可以假设每个人的家都有一条通往公园的道路。
简述题意,其实就是说,求图上的一颗生成树,使得1号节点的入度不超过S的情况下权值和最小
这里介绍一种并不常用的最小生成树算法:消圈算法
简单来说,就是在无向图上随便指定一颗生成树,然后考虑剩下的边,加入一个就会产生环,然后把环上最大的边断开,这样重复操作直到加入的边比环上最大边还要大为止,这样就得到了最小生成树,只不过由于程序实现比较复杂,复杂度也并不突出,于是就很少用(最古老的最小生成树算法)
对于本题,我们需要使用这种思想
首先,我们将1号节点与其他节点的边断开,这样我们就得到了若干个连通块,对每一个连通块分别求最小生成树,设有T个连通块,若T>S本题无解,因为不可能连通
然后我们尝试对于一个连通块T_i,T_i中所有与1有边的节点,我们选择最小的那一个进行连接,这样我们就连通了这个连通块,以此类推,我们就得到了原图的一棵生成树
接着我们就使用消圈算法的思想,尝试改动S-T条边使得答案更优,由于每个连通块内部再无需改动了,因为连通块内部求了最小生成树,于是我们的改动也只与1号节点有关
我们考虑与1号节点有边但边没有选入生成树的节点,将其加入生成树后势必会形成一个环,在环上找最大的边(假设这个点是x,则我们需要找到的最大的边就是原生成树上1->x的最大边)进行断开,就得到了一个权值和更小的生成树,重复这个步骤直到改动完S-T条边或者改动的边无法加入生成树为止,这个步骤需要我们维护1号节点在生成树上到每个节点所经过的边的边权最大值,可以使用深度优先遍历维护

int cnt,m,s,deg,ans,a[32][32],d[32],lst[32],vis[32],c[32],t[32][32],ver[32],p,f[32],mxx[32],mxy[32];
//t数组维护目前的最小生成树森林,f、mxx、mxy维护的是1->x的路径上的最大边
map<string,int>H;
void prim(int rt){//求连通块内部最小生成树,使用prim更好处理
    d[rt]=0;
    for(int i=1;i<=p;i++){
        int u=0;
        for(int j=1;j<=p;j++)
            if(!vis[ver[j]] &&(u==0||d[ver[j]]<d[u]))u=ver[j];
        vis[u]=1;
        for(int j=1;j<=p;j++){
            int v=ver[j];
            if(!vis[v]&&d[v]>a[u][v])
                d[v]=a[u][v],lst[v]=u;
        }
    }
    int maxx=rt;
    for(int i=1;i<=p;i++){
        int u=ver[i];
        if(rt==u)continue;
        ans+=d[u];
        t[lst[u]][u]=t[u][lst[u]]=d[u];
        if(a[1][u]<a[1][maxx])maxx=u;
    }
    deg++;
    ans += a[1][maxx];
    t[1][maxx]=t[maxx][1]=a[1][maxx];
}
void dfs(int u){//划分连通块
    ver[++p]=u;
    c[u]=1;
    for(int v=1;v<=cnt;v++)
        if(a[u][v] != 0x3f3f3f3f && !c[v])dfs(v);
}
void Prim(){//求各个连通块内的最小生成树
    memset(d,0x3f,sizeof(d));
    memset(vis,0,sizeof(vis));
    memset(t,0x3f,sizeof(t));
    c[1]=1;
    for(int i=2;i<=cnt;i++)
        if(!c[i]){
            p=0;
            dfs(i);
            prim(i);
        }
}
void dp(int u){//计算f、mxx、mxy,
    vis[u]=1;
    for(int v=2;v<=cnt;v++)
        if(t[u][v] != 0x3f3f3f3f && !vis[v]){
            if(f[u]>t[u][v])
                f[v]=f[u],mxx[v]=mxx[u],mxy[v]=mxy[u];
            else 
                f[v]=t[u][v],mxx[v]=u,mxy[v]=v;
            dp(v);
        }
    vis[u]=0;
}
bool solve(){//执行最后的消圈过程
    int mn=1<<30,id;
    for(int i=2;i<=cnt;i++){
        if(t[1][i]!=0x3f3f3f3f||a[1][i]==0x3f3f3f3f)continue;
        if(a[1][i]-t[mxx[i]][mxy[i]]<mn){
            mn=a[1][i]-t[mxx[i]][mxy[i]];
            id=i;
        }
    }
    if(mn>=0)return 0;
    ans+=mn,t[1][id]=t[id][1]=a[1][id];
    t[mxx[id]][mxy[id]]=t[mxy[id]][mxx[id]]=0x3f3f3f3f;
    f[id]=a[1][id],mxx[id]=1,mxy[id]=id;
    vis[1]=1;
    dp(id);
    return 1;
}
int main(){
    H["Park"]=1;cnt=1;//注意P是大写的(调试2h就因为这个)
    scanf("%d",&m);
    memset(a,0x3f,sizeof(a));
    for(int i=1;i<=m;i++){
        int u,v,w;
        char x[15],y[15];
        scanf("%s%s%d",x,y,&w);
        if(!H[x])H[x]=++cnt;
        if(!H[y])H[y]=++cnt;
        u=H[x],v=H[y];
        a[u][v]=a[v][u]=min(a[u][v], w);
    }
    scanf("%d",&s);
    Prim();
    memset(vis,0,sizeof(vis));
    dp(1);
    while(deg<s){
        if(!solve())break;
        deg++;
    }
    printf("Total miles driven: %d\n",ans);
}

此题启发我们消圈思想,拆图计算,合并消圈

例题5:四叶草魔杖

题目描述
给定一张无向图,结点和边均有权值。所有结点权值之和为 0,点权可以沿边传递,传递点权的代价为边的权值。求让所有结点权值化为 0 的最小代价。

解法
容易想到本题与最小生成树有关。一种不难想出的思路是求出原图的最小生成树,将最小生成树上所有边的权值之和作为答案。

但经过思考,可以发现这样得到的不一定是最优解。首先,原图可能并不联通;其次,可以将原图划分为若干个点权之和均为 0 的子图,在这些子图中分别转移点权,最后将答案合并。这样得到的方案或许会更优。

此时我们发现划分方案不止一种,如何确定最终的方案成了需要解决的最大问题。

注意到本题中 \(N\) 范围较小,允许我们把所有点权和为 0 的子图(以下简称“合法子图”)的最小生成树全部求出。因此可以先枚举原图点集的所有子集,对于每个点权和为 0 的点集,用这些点和连接它们的边构造一张合法子图。我们能够轻易求出这些合法子图的最小生成树。但有些合法子图或许并不联通,为避免对之后的求解造成影响,需要把这些子图的最小生成树边权和设为 \(∞\).

接下来需要把这些子图中的若干个合并起来,得到全局最优解。与划分的情形相同,合并这些子图的方案也有多种。可以使用 \(DP\) 得到最优解。

具体地,考虑进行类似背包的 \(DP\),将每个合法子图视作可以放入背包的一个物品。设 \(A、B\)为两个不同合法子图的点集,合法子图的最小生成树边权和为 \(S\),可以写出如下状态转移方程:

\(f_{A∪B}=\min\lbrace f_{A∪B},f_A+S_B\rbrace,A∩B=⊘\)
最终 \(f_{2^n-1}\) 即为所求的答案。至此,本题得到解决。

int n,m,a[20],fa[20],s[1<<16],f[1<<16],ans[1<<16];
struct node{
    int u,v,w;
    bool operator <(const node b)const{
		return w<b.w;
	}
}t[150];
int find(int x){return fa[x]==x?x:fa[x]=find(fa[x]);}
int kruskal(int S){
    int ans=0;
    for(int i=0;i<n;i++)
        if(S&(1<<i))fa[i]=i;
    for(int i=1;i<=m;i++){
        if(!(S&(1<<t[i].u)&&S&(1<<t[i].v)))continue;
        int u=find(t[i].u),v=find(t[i].v);
        if(u!=v){
            fa[u]=v;
            ans+=t[i].w;
        }
    }
    int op=-1;
    for(int i=0;i<n;i++)
        if(S&(1<<i))
            if(op==-1)op=find(i);
            else if(find(i)!=op)return 0x3f3f3f3f;
    return ans;
}
int main(){
    scanf("%d%d",&n,&m);
    for(int i=0;i<n;i++)
        scanf("%d",&a[i]);
    for(int i=1;i<(1<<n);i++)
        for(int j=0;j<n;j++)
            if(i&(1<<j))s[i]+=a[j];
    if(!m){
    	   if(s[(1<<n)-1])puts("Impossible");
    	   else puts("0");
    	   return 0;
 	  }
    for(int i=1;i<=m;i++)
        scanf("%d%d%d",&t[i].u,&t[i].v,&t[i].w);
    sort(t+1,t+m+1);
    for(int i=1;i<(1<<n);i++){
        if(s[i]==0)ans[i]=kruskal(i);
        f[i]=0x3f3f3f3f;
    }
    f[0]=0;
    for(int i=1;i<(1<<n);i++){
    	if(s[i])continue;
        for(int j=0;j<(1<<n);j++)
            if(!(i&j))
                f[i|j]=min(f[i|j],f[j]+ans[i]);
    }
    if(f[(1<<n)-1]==0x3f3f3f3f)printf("Impossible");
    else printf("%d",f[(1<<n)-1]);
} 

这题是一道动态规划与最小生成树的综合题,主要启发我们缜密思考题目,学会把题目转化为图论模型

总的来说,今天这一节需要掌握的知识点有

  1. 最小生成树的kruskal和prim算法,消圈算法思想
  2. 最小生成树定理及推论
  3. 最优比率生成树,结合0/1分数规划模型
  4. 最短路径生成树的定义与性质
    需要掌握的思想有
  5. 拆分图形,分而治之
  6. 单调性思想
  7. DP结合图论
posted @ 2022-11-30 22:37  spdarkle  阅读(939)  评论(0编辑  收藏  举报