图论算法(七)最小生成树Kruskal算法与(严格)次小生成树

吐槽:严格次小生成树我调了将近10个小时……(虽然有3个小时的电竞时间html

Part 1:最小生成树\(Kruskal\)算法

前言

若是您还不知道最小生成树的定义是什么,请空降这里ios

介于咱们以前已经讨论过了最小生成树的定义和\(Prim\)算法了,此次咱们直奔主题——\(Kruskal\)算法算法

\(Kruskal\)算法工做方式

仍是老样子,咱们抛开正确性不谈,只谈算法工做方式和代码实现数组

\(Kruskal\)的思路很是暴躁:把每一个点当作是一个单独的连通块,而后把边按照边权排序,从小到大一条一条加入最小生成树,这样直到有\(n-1\)条边被加入了最小生成树数据结构

注意每次加入边的时候,至少有原来不连通的两连通块被联通,这样才可以保证不会造成环框架

为何不知足上面的条件就会造成环?ui

显然易见,若是两个点在加入这条边以前,已经能够相互到达了(成为一个连通块),那么这条边就是多余的,在链接以后会造成重边或者环
每个连通块都是一个无根树,在树上任意两点之间加一条边,必定会造成一个环,因此为了避免造成环,咱们不加这条边spa

让咱们举一个生动形象的例子(由于电脑画图实在不方便,这里改为手画了)

好比像上面这张带权无向图:debug

咱们模拟\(Kruskal\),每一个点全都分开,从小到大加入边

重复这个操做一直从小到大的加边,直到完成最小生成树
3d

而后这样咱们就获得了这个图的最小生成树(不惟一),它的大小为\(7\)

\(Kruskal\)算法实现方式

在算法中,有一个很重要的步骤,就是维护每一条边连通的两个点是否是处于同一个连通块以内,若是处于同一连通块以内,那么这条边是不能被加入最小生成树的

因此咱们须要一种数据结构来维护各个连通块的状况,而并查集就很好的知足了咱们的要求

PS:不知道什么是并查集?请戳这里(请从其余园友那里搜叭)

维护一个有\(n\)个集合(每一个集合表明一个点)的并查集,初始时每一个集合中只有一个元素(点\(i\)

\(一、\)对于每次加边以前,查询两个端点\(a,b\)是否在同一集合里,若是不在同一集合里,执行\(2\),若是在同一集合,那么说明\(a,b\)已经连通了,跳过这条边

\(二、\)把这条边计入最小生成树,而后合并点\(a,b\)所在的集合

\(Code\)

#include<cstdio>
#include<cstring>
#include<queue>
#include<stack>
#include<algorithm>
#include<set>
#include<map>
#include<utility>
#include<iostream>
#include<list>
#include<ctime>
#include<cmath>
#include<cstdlib>
#include<iomanip>
typedef long long int ll;
inline int read(){
	int fh=1,x=0;
	char ch=getchar();
	while(ch<'0'||ch>'9'){ if(ch=='-') fh=-1;ch=getchar(); }
	while('0'<=ch&&ch<='9'){ x=(x<<3)+(x<<1)+ch-'0';ch=getchar(); }
	return fh*x;
}
inline int _abs(const int x){ return x>=0?x:-x; }
inline int _max(const int x,const int y){ return x>=y?x:y; }
inline int _min(const int x,const int y){ return x<=y?x:y; }
inline int _gcd(const int x,const int y){ return y?_gcd(y,x%y):x; }
inline int _lcm(const int x,const int y){ return x*y/_gcd(x,y); }

const int maxn=5005;
const int inf=1e9;

int n,m;

struct Edge{
	int a,b,w;
	Edge(){}
}edge[200005];//创建一个结构体,存边的两个端点和权值 
bool operator < (const Edge p,const Edge q){ return p.w<q.w; }
//重载运算符,按照权值从小到大排序 

int fa[maxn];
int find(const int x){
	if(fa[x]==x) return x;
	return fa[x]=find(fa[x]);
}//并查集的查询操做,查询x的集合表明 
inline int Kruskal(){
	int ans=0;//最小生成树大小 
	std::sort(edge,edge+m);//排个序 
	for(int i=1;i<=n;i++)//并查集初始化,每一个点都是独立的连通块 
		fa[i]=i;
	for(int i=0;i<m;i++){//把全部边扫一遍 
		int x=edge[i].a,y=edge[i].b;
		if(find(x)==find(y)) continue;//若是属于一个集合,就跳出 
		fa[find(x)]=y;//合并两个集合 
		ans+=edge[i].w;//统计最小生成树大小 
	}
	return ans;
}

int main(){
	n=read(),m=read();
	for(int i=0,x,y,z;i<m;i++){
		x=read(),y=read(),z=read();
		if(x==y) continue; 
		edge[i].a=x;
		edge[i].b=y;
		edge[i].w=z;//建图 
	}
	printf("%d\n",Kruskal());//跑最小生成树 
	return 0;
}

Part 2:求解(严格)次小生成树

传送门:【模板】严格次小生成树

什么是次小生成树

字面意思来说,次小生成树就是除了最小生成树以外最小的那棵生成树

那么为何把“严格”加了个括号呢?由于它并不严格(废话)

若是最小生成树选择的边集是\(E_M\),严格次小生成树选择的边集是\(E_S\),那么须要知足:\((value(e)\)表示边\(e\)的权值\()\)

\(\sum_{e \in E_M}value(e)<\sum_{e \in E_S}value(e)\)

显然这个公式它比较恶心,简单来讲,就是次小生成树的大小必须严格小于最小生成树的大小

若是咱们只是在最小生成树上更换了边,可是总的生成树大小同样,那么求出的就是不严格的次小生成树(由于一张图的最小生成树能够有多个,此时求出了另外一个最小生成树)

求严格次小生成树(思路篇)

看到“严格次小生成树”,你的第一反应应该是“这个东西会和最小生成树有关!”

所谓“次小”就是除了最小以外的最小的,那么咱们先求出最小的,起码不会对咱们的求解起阻碍做用吧

假设你已经顺手使用\(Prim\)或者\(Kruskal\)算法求出了这张图的最小生成树

如今,咱们能够把这个图上的全部边简单的分红两类了——第一类是在最小生成树上的边(简称树边),第二类是不在最小生成树上的边(简称非树边)

仍是一开始的那个图,最小生成树上的边使用蓝笔标出,咱们已知这个最小生成树的大小\(size=7\)

能够贪心的转化问题——求第二小,也就是求一个新的生成树大小\(size'\)在比\(size\)小的前提下最大

由于严格次小生成树和最小生成树必定不是同一棵树,那么咱们能够考虑用一些非树边,替换掉同等数量的树边

想到替换时,也许你会遇到这些问题,(如下用三元组\((a,b,c)\)表示连通\(a、b\)两点的边,权值为\(c\)

1. 替换时,怎么保证求出的是\(2\)小而不是\(3\)小、\(4\)小、\(n\)小呢?

根据上面的贪心思路:替换后使得\(size'-size\)最小,那么\(size'\)就是严格次小生成树

2. 一条非树边能够替换掉哪些树边?

显然不能够胡乱替换,由于须要保证求出的次小生成树还有个树的模样(不能出现环、必须连通)

想到利用树的性质:当咱们要向一棵树中加入一条边时,会造成且仅造成一个环,而且这个环包含\(a\rightarrow LCA(a,b)\)\(b\rightarrow LCA(a,b)\)路径中的全部边

咱们先把一条非树边\((a,b,c)\)加进去,在\(a\rightarrow LCA(a,b)\)\(b\rightarrow LCA(a,b)\)路径中的边中删除一条便可保证树的性质

上面的解释理解不能?请看下面的图,帮助理解:

如图,黑色的是树边,如今要把一条绿色的非树边\((8,6,n)\)加进去,那么图中标红的树边就能够被替换

书面证实

对于一棵树\(T\)其中任意两个节点\(a,b\)都存在且仅存在一条简单路径:\(a\rightarrow LCA(a,b)\rightarrow b\)
如今链接\((a,b)\),那么上式能够写成这样:\(a\rightarrow LCA(a,b)\rightarrow b\rightarrow a\),显然构成一个环
PS:另外,由于链接\((a,b)\)以前路径只有一条,因此链接后,造成的环也只有一个

证毕

3. 用几条非树边替换几条树边呢?

在解决这个问题以前,先给出另外一个结论:

对于任意一条非树边\((a,b,c)\),在把它加入最小生成树后造成的环中,每一条树边的权值\(w\leq c\)

解决这个问题,须要用到这个结论,因此咱们先来证实一下它

证实

设加入的非树边是\((a,b,c)\),产生环包含树边的集合是\(T\)\(w_e\)表示\(e\)的边权,最小生成树的大小为\(size\)
反证法:
\(p:\exists e\in T\)且有\(w_e>c\)
假设\(p\)为真,那么此时断掉树边\(e\),加入非树边\((a,b,c)\)会使得\(size\)减少
可是\(size\)是咱们所求出的最小生成树的大小,它不可能更小了,因此\(p\)为假
那么\(!p:\forall e\in T w_e\leq c\)必定为真

证毕

有了上面的结论还不够,要想继续解决这个问题,咱们还得大胆猜测,当心求证

作出假设:替换\(1\)条边最优

证实

还记得咱们的贪心思路吗:替换后使得\(size'-size\)最小,那么\(size'\)就是严格次小生成树
咱们假设用一条非树边\(e_1\)去替换一条树边,运用上面的结论,咱们知道全部与\(e_1\)构成环的边的权值\(w\geq w_{e_1}\)
若是咱们作此次替换,\(size'\)的大小就会比\(size\)\(k=w-w_{e_1}\)
考虑特殊状况,\(k=0\)时,表明着咱们把两条权值相等的树边和非树边作了替换
可是这么作对\(size'\)没有任何影响,只对次小生成树的形态有影响,但它长得好很差看咱们并不关心,咱们只关心\(size'\)而已
由于\(k=0\)时,对\(size'\)没有影响,排除这种状况,如今\(k\)的范围\((k>0)\)
(注意咱们之后都再也不考虑\(k=0\)的状况了!!!)
显然咱们选择\(n\)条边,就会产生多个\(k\)值,此时\(size'\)必定大于只选\(1\)条边的\(size'\)
根据贪心思路,要求\(size'\)最小,显然选\(1\)条边作替换最优

证毕

问题解决,选择\(1\)条边作替换最优(也就是最小生成树和次小生成树中权值不相同的边有且只有\(1\)条)

4. 用哪条非树边替换哪条树边?答案怎么更新?
这两个咱们逃不掉得枚举其中一个了,这里显然枚举非树边比较方便(设树边边权为\(w\))

由于咱们要求\(size'\)最大的,因此要求\((c-w>0)\)\((c-w)\)尽可能小,由于\(c\)必定,只要使得\(w\)尽可能大便可(注意这里根据上面的结论,\(c\geq w\)因此不用担忧\(c-w<0\)的状况)

枚举每个非树边\((a,b,c)\),找到路径\(a\rightarrow LCA(a,b)\)\(b\rightarrow LCA(a,b)\)\(w\)最大的那条边,作替换便可,答案就是\(size'=min(size',size+c-w)\)

注意一下小细节:由于\(w\)有可能等于\(c\),若是作替换的话,求出的并非严格次小生成树,因此还须要记录一个次大值\(w'\),若是\(w=c\),那么答案\(size'=min(size',size+c-w')\)

啰啰嗦嗦这么多,按照上面的思路,就能够求严格次小生成树了

求严格次小生成树(实现篇)

明确了思路,有没有感到干劲十足呢?(必须得干劲十足啊,笔者但是\(1\)小时写完,\(7\)小时\(debug\)的男人)

这里梳理一下咱们要用的算法和数据结构吧:

1. 求最小生成树:并查集,\(Kruskal\),无脑存边

2. 最近公共祖先\(LCA\):树上倍增,\(dfs\)预处理,邻接表

3. 区间最大/次大\(RMQ\):树上\(st\)

4. 一句提醒:不开\(long\) \(long\)见祖宗

因此获得公式:基础算法+数据结构+简单证实=毒瘤题(这题其实出的挺好的,既没超纲,又有必定难度)

大致框架有了,剩下的都是代码实现的细节了,就都在代码注释里讲解吧

求严格次小生成树(代码篇)

#include<cstdio>
#include<cstring>
#include<queue>
#include<stack>
#include<algorithm>
#include<set>
#include<map>
#include<utility>
#include<iostream>
#include<list>
#include<ctime>
#include<cmath>
#include<cstdlib>
#include<iomanip>
typedef long long int ll;
inline int read(){
	int fh=1,x=0;
	char ch=getchar();
	while(ch<'0'||ch>'9'){ if(ch=='-') fh=-1;ch=getchar(); }
	while('0'<=ch&&ch<='9'){ x=(x<<3)+(x<<1)+ch-'0';ch=getchar(); }
	return fh*x;
}
inline int _abs(const int x){ return x>=0?x:-x; }
inline ll _max(const ll x,const ll y){ return x>=y?x:y; }
inline ll _min(const ll x,const ll y){ return x<=y?x:y; }
inline int _gcd(const int x,const int y){ return y?_gcd(y,x%y):x; }
inline int _lcm(const int x,const int y){ return x*y/_gcd(x,y); }

const int maxn=100010;
const ll inf=1e18;//赋个极大值 

int n,m;
ll MST,ans=inf;//ans是次小生成树,MST是最小生成树 

struct Node{
	int to;//到达点 
	ll cost;//边权 
};
std::vector<Node>v[maxn];//邻接表存最小生成树 
int dep[maxn];//dep[i]表示第i个点的深度 
bool vis[maxn];
ll f[maxn][25];//f[i][j]存节点i的2^j级祖先是谁 
ll g1[maxn][25];//g1[i][j]存节点i到他的2^j级祖先路径上最大值 
ll g2[maxn][25];//g2[i][j]存节点i到他的2^j级祖先路径上次大值  
void dfs(const int x){//由于是树上st和树上倍增,因此能够一块儿预处理 
	vis[x]=true;//x号点访问过了 
	for(int i=0;i<v[x].size();i++){//扫全部出边 
		int y=v[x][i].to; 
		if(vis[y]) continue;//儿子不能被访问过 
		dep[y]=dep[x]+1;//儿子的深度是父亲+1 
		f[y][0]=x;//儿子y的2^0级祖先是父亲x 
		g1[y][0]=v[x][i].cost;//y到他的2^0级祖先的最大边长 
		g2[y][0]=-inf;//y到他的2^0级祖先的次大边长(没有次大边长,故为-inf) 
		dfs(y);//递归预处理 
	}
}
inline void prework(){//暴力预处理 
	for(int i=1;i<=20;i++)//枚举2^1-2^20 
		for(int j=1;j<=n;j++){//枚举每一个点 
			f[j][i]=f[f[j][i-1]][i-1];//正常的倍增更新 
			g1[j][i]=_max(g1[j][i-1],g1[f[j][i-1]][i-1]);
			g2[j][i]=_max(g2[j][i-1],g2[f[j][i-1]][i-1]);
			//如下是求次大的精华了 
			if(g1[j][i-1]>g1[f[j][i-1]][i-1]) g2[j][i]=_max(g2[j][i],g1[f[j][i-1]][i-1]);
			//j的2^i次大值,是j的2^(i-1)和j^2(i-1)的2^(i-1)最大值中的较小的那一个 
			//特别的,若是这两个相等,那么没有次大值,不更新g2数组 
            else if(g1[j][i-1]<g1[f[j][i-1]][i-1]) g2[j][i]=_max(g2[j][i],g1[j][i-1]);
		}
}
inline void LCA(int x,int y,const ll w){
//非树边链接x,y权值为w 
//求LCA时候直接更新答案 
	ll zui=-inf,ci=-inf;//zui表示最大值,ci表示次大值 
	if(dep[x]>dep[y]) std::swap(x,y);//保证y比x深 
	for(int i=20;i>=0;i--)//倍增向上处理y 
		if(dep[f[y][i]]>=dep[x]){
			zui=_max(zui,g1[y][i]);//更新路径最大值 
			ci=_max(ci,g2[y][i]);//更新路径次大值 
			y=f[y][i];
		}
	if(x==y){
		if(zui!=w) ans=_min(ans,MST-zui+w);//若是最大值和w不等,用最大值更新 
		else if(ci!=w&&ci>0) ans=_min(ans,MST-ci+w);//有毒瘤状况,没有次大值,此时也不能用次大值更新 
		return; 
	}
	for(int i=20;i>=0;i--)
		if(f[x][i]!=f[y][i]){
			zui=_max(zui,_max(g1[x][i],g1[y][i]));
			ci=_max(ci,_max(g2[x][i],g2[y][i]));
			x=f[x][i];
			y=f[y][i];
		}//依旧是普通的更新最大、次大值 
	zui=_max(zui,_max(g1[x][0],g1[y][0]));//更新最后一步的最大值 
	//注意下面这两句又凝结了人类智慧精华
	//由于次大值有可能出如今最后一步上,因此在更新答案前还要更新一下ci
	//若是最后两边的某一边是最大值,ci就只能对另外一边取max  
	if(g1[x][0]!=zui) ci=_max(ci,g1[x][0]);
	if(g2[y][0]!=zui) ci=_max(ci,g2[y][0]);
	if(zui!=w) ans=_min(ans,MST-zui+w);
	else if(ci!=w&&ci>0) ans=_min(ans,MST-ci+w);//依旧特判毒瘤状况 
}

struct Edge{
	int from,to;//链接from和to两个点 
	ll cost;//边权 
	bool is_tree;//记录是否是树边 
}edge[maxn*3];
bool operator < (const Edge x,const Edge y){ return x.cost<y.cost; }
//重载运算符,按照边权从大到小排序 
int fa[maxn];//并查集数组 
inline int find(const int x){
	if(fa[x]==x) return x;
	else return fa[x]=find(fa[x]);
}//查询包含x的集合的表明元素 
inline void Kruskal(){
	std::sort(edge,edge+m);//先排序 
	for(int i=1;i<=n;i++)//初始化并查集 
		fa[i]=i;
	for(int i=0;i<m;i++){
		int x=edge[i].from;
		int y=edge[i].to;
		ll z=edge[i].cost;
		int a=find(x),b=find(y);
		if(a==b) continue;//若是x和y已经连通,continue掉 
		fa[find(x)]=y;//合并x,y所在集合 
		MST+=z;//求最小生成树 
		edge[i].is_tree=true;//标记为树边 
		v[x].push_back((Node){y,z});//邻接表记录下树边 
		v[y].push_back((Node){x,z});
	}
}

int main(){
	n=read(),m=read();
	for(int i=0,x,y;i<m;i++){
		ll z;
		x=read(),y=read();scanf("%lld",&z);
		if(x==y) continue;
		edge[i].from=x;
		edge[i].to=y;
		edge[i].cost=z;
	}//读入整个图 
	Kruskal();//初始化最小生成树 
	dep[1]=1;//设1号点是根节点,把它变成有根树 
	dfs(1);//从1开始预处理 
	prework();//倍增预处理 
	for(int i=0;i<m;i++)//枚举全部边 
		if(!edge[i].is_tree)//若是是非树边,那么更新答案 
			LCA(edge[i].from,edge[i].to,edge[i].cost);
	printf("%lld\n",ans);//输出答案,不开long long见祖宗 
	return 0;//我谔谔终于结束 
}

不严格次小生成树

严格的都说了,那就再稍微说一两句不严格次小生成树的求法

刚才那么多的证实,就是为了让这个不严格变成严格,如今咱们倒过来看,这个问题就变得小儿科了

总体框架不用变,只是咱们不须要处理次大值了,每次更新的时候直接\(ans=min(ans,ans+c-w);\)便可

感谢您的阅读,来个三连球球辣\(OvO\)

相关文章
相关标签/搜索