最小生成树,次小生成树以及杂题选讲
rearranged on 2025 04 18,done!
十分有用的算法呢,本文介绍最小生成树两种常用的算法和一种比较特化的 boruvka 算法,以及在瓶颈路问题中有用的 kruskal 重构树。
前置知识
更多知识
定义
无向连通图的最小生成树(Minimum Spanning Tree,MST)为边权和最小的生成树。
简单来说,就是在一个无向连通图中找出一些边,让这些边与顶点组成的新图是一棵树,且选出来的这些边的权值和是最小的。
Kruskal 算法
Kruskal 算法是 OI 中最常用的 MST 算法,因为此算法基于对边的选择,适于解决稀疏图中的最小生成树。
具体来说,Kruskal 算法首先给图中所有边按边权从小到大排序,之后从小边权到大边权选择边合并两个点所在的集合,直到所有点均处于同一个集合,MST 于是被求解出来。可见此算法运用了贪心思想。
由于过程中涉及到了对点所在集合的判断,需要使用并查集数据结构加以辅助。
此算法的时间复杂度瓶颈在于对边排序,因此时间复杂度为 \(O(m \log m)\),其中 m 为图中边的数量。
关于证明和更详细的图解见OI-wiki。
示例代码如下:
Show me the code
const int N=1e4+5;
const int M=1e5+5;
int fa[N];
struct e{
int u;
int v;
int w;
}edge[M];
int ecnt=0;
int _find(int u){//找父亲
return u==fa[u]?u:fa[u]=_find(fa[u]);
}
bool cmp(e x,e y){//结构体排序函数
return x.w<y.w;
}
int main(){
int n,m;
n=rd;m=rd;//n 为点的个数,m 为边的个数
for(int i=1;i<=n;i++){//初始化并查集
fa[i]=i;
}
for(int i=1;i<=m;i++){
int u=rd,v=rd,w=rd;//将边的信息存入一个数组
edge[++ecnt].u=u;
edge[ecnt].v=v;
edge[ecnt].w=w;
}
sort(edge+1,edge+1+ecnt,cmp);//按边权排序
ll ans=0;//MST 的权值
for(int i=1;i<=ecnt;i++){//从小边权到大边权选择边
int t1=_find(edge[i].u),//使用并查集获取这条边两个顶点的集合
t2=_find(edge[i].v);
if(t1!=t2){//不在同一集合时
fa[t1]=t2;//合并
ans+=edge[i].w;//权值增加
}
//如果在同一集合中则不应再加入 MST
}
cout<<ans;//输出权值和
return 0;
}
Prim 算法
与 Kruskal 算法不同,Prim 算法针对边极多的无向连通图(例如无向完全图),在这种情况下 Kruskal 算法往往在排序时就会 TLE。
在这本题中城市之间可能的道路就组成了一个无向完全图,由于这道题严苛的空间大小以至于存下这些边都会导致 MLE。
Prim 算法是依靠点选择最小边的过程,类似于 Dijstra 算法。
首先在图中任意指定一个点作为 MST 集合的起始点。
准备一个优先队列,以边权从小到大排序。
遍历这个点的所有邻接边,取一个 dist 数组记录每个节点到 MST 最短的距离。
容易发现这个点一定在 MST 集合中,因此可以用这个点邻接边的边权直接更新点的距离。
如果比现有的距离小,则这个边有可能被加入 MST,于是将这个边加入优先队列中。
这样,从优先队列头上取出的点一定距离现有的 MST 集合最近,因此这个点和边就可以直接加入 MST 中了。
取出后,此节点已经划到了 MST 集合里,做一下标记以免被重复遍历影响时间复杂度。
不过可能存在无向图不连通的情况,也可能存在 MST 已经完成但是队列不空还在循环浪费时间的情况,这时可以用一个 cnt 变量记录加入 MST 的点数,如果等于输入则 MST 已完成可以直接退出循环,如果循环退出了 cnt 依然小于输入则这个无向图不连通。
虽然但是,一般写 prim 算法不写优先队列优化的,因为这里的优先队列是个负优化。在无向完全图上的边数一般被认为是与点数的平方同阶的,即 \(n^2 \sim m\),这是朴素的寻找最大 dist 的 Dijstra 时间复杂度已经是 \(O(n^2)\) 的了,用优先队列反而会多一个 \(\log\)。
示例代码如下:
Show me the code
void prim(){
memset(dis,0x7f,sizeof dis);//初始化所有节点的距离为正无穷
q.push(e{2,0}); //任选一节点作为初始节点,压入队列
dis[2]=0;//自己到自己没有距离
while(q.size()){//循环
if(cnt>=n)break;//已经找到了大于等于n个节点,MST 已经完成,直接退出。
int u=q.top().v;//取堆顶
double w=q.top().w;
q.pop();
if(vis[u])continue;//如果已经加入 MST 则跳过
vis[u]=1;//划到 MST 中
cnt++;//统计
res+=w;//边权
for(int i=1;i<=n;i++){//遍历边,这是 P1265 的写法,平常 vector 和链式前向星都是可以的
int vi=i;
if(i==u)continue;
double wi=dist(px[i],py[i],px[u],py[u]);
if(wi<dis[vi]){//距离比现有的小
dis[vi]=wi;//更新距离
q.push(e{vi,wi});//加入优先队列
}
}
}
}
小性质
对于一个无向连通图中的任意最小生成树,其相同边权的边的数量是一定的。
这个结论可以用来完成 JSOI 2008 最小生成树计数。
问题时间
P4047 [JSOI2010] 部落划分
给定平面直角坐标系上的 \(n\) 个点,要求将这些点划分成 \(k\) 个集合(部落),使得任意两个不同集合之间的最小点对距离最大化。
数据范围
\(2 \leq k \leq n \leq 10^3\),坐标范围 \(0 \leq x, y \leq 10^4\)。
其实就是一个 Kruskal 了,看看你对 Kruskal 的理解如何。
Kruskal 算法维护一个并查集,按照边权大小加入并含并两点对应的连通块。当两点所属连通块相同时,不作操作。当加入边足够时,即得最小生成树。
每次加边操作必然会合并两个连通块,且此时的边是所有边中最小的。
嗯?这似乎与我们题目里要求的东西很像?此时的边就是各两个连通块之间距离的最小值。
考虑 \(k\) 个连通块的限制.在 Kruskal 里计算连通块个数是容易的,且由于我们每次都选择最小边含并,这正好可以保证最近的两连通距块间的离最远。于是原题目的要求就在 Kruskal 的过程中被解决了。
Code
Show me the code
P8191 [USACO22FEB] Moo Network G
题目描述
给定平面上的 \(N\) 个点,坐标 \((x_i,y_i)\) 。需要构建一个连通所有点的网络,连接两点的代价是它们欧几里得距离的平方 \((x_i-x_j)^2+(y_i-y_j)^2\)。求使所有点连通的最小总代价。数据范围
- \(1 \leq N \leq 10^5\)
- \(0 \leq x_i \leq 10^6\)
- \(0 \leq y_i \leq 10\)
- 时间限制:4 秒
奶牛数量非常多,对应在坐标系上可能的边非常非常多。直接跑 Kruskal 或者 Prim 是不可能的,这时考虑哪些边是一定不优于其它边的,换言之,我们可以排除掉一些一定不会在 MST 中出现的一些边。
注意到奶牛们的纵坐标很小,考虑给 \(u\) 点剪边。若 \(u\) 点向另一个纵坐标为 \(y_i\) 的点考虑。\(y_u\)(\(u\) 的纵坐标) 与 \(y_i\) 的差是一定的,则我们要找的一定是 \(x\) 坐标相差最小的两点,这一过程可用二分快速实现。于是在题目中的数据范围下每个点向外连的边被我们剪到了至多 \(10\) 条,这样就可以跑 Kruskal 了。
code
Show me the code
P2847 [USACO16DEC] Moocast G
题目描述
给定平面上的 \(N\) 个点,每个点可以选择一个传输半径 \(\sqrt{X}\)。若两点间的欧几里得距离不超过 \(\sqrt{X}\) 则可以通信。要求找到最小的整数 \(X\),使得所有点通过这种通信方式构成一个连通图。数据范围
- \(1 \leq N \leq 1000\)
- 坐标范围 \(0 \leq x, y \leq 25000\)
通信距离与价格的关系已经给成出,问题可以等价与找到 MST 上边权最大的一条边,此时 MST 上的所有边都比此边短或相等。
最小边也很好算,就是 Kruskal 过程中加入的最后一条边。
code
Show me the code
P9619 生成树
题目描述
给定一个无向完全图 \(G(V,E)\) 和节点权值数组 \(a\)。定义边 \(e(u,v)\) 的权值为 \(a_u \oplus a_v\),生成树权值为其所有边权之和。要求计算所有不同生成树的权值之和,结果对 \(998244353\) 取模。数据范围
- \(1 \leq n \leq 10^6\)
- \(0 \leq a_i \leq 10^9\)
首先有结论:无向完全图的生成树有 \(n^{n-2}\) 个不同的,即:每条边会在生成树中出现 \(n^{n-2}\) 次。这个东西的证明可以用 Prüfer 序列,显然我不会证。
于是我们的问题可以弱化成:求解 \(\sum_{u,v\in V} e(u,v)\)。
但是点也有很多,对应的边也有很多,暴力计算也是不行的。因为是二进制运算,我们自然想到了拆位。枚举每一个点权,拆位并记录在第 \(i\) 位上有 \(1\) 的点权有多少个。之后枚举一个点权的各位,若此位为 \(0\),所有此位为 \(1\) 的点会与此点异成出 \(1\) 从而在求和中多出 \(2^i\) 的贡献。\(0\) 的情况是类似的。过程复杂度为 \(O ( n\log n )\)。
code
Show me the code
P1967 [NOIP 2013 提高组] 货车运输
题目描述
给定一个包含 \(n\) 个城市和 \(m\) 条双向道路的图,每条道路有重量限制 \(z\)。对于 \(q\) 次询问,每次给出起点 \(x\) 和终点 \(y\),求从 \(x\) 到 \(y\) 的路径中最小边权的最大值(即货车能运输的最大重量)。若无法到达则输出 \(-1\)。数据范围
- \(1 \leq n \leq 10^4\)
- \(1 \leq m \leq 5 \times 10^4\)
- \(1 \leq q \leq 3 \times 10^4\)
- \(0 \leq z \leq 10^5\)
考虑一棵原图的最大生成树,只走最大生成树上的路径显然是最优的。于是问题变成最大生成树两点间边权的最小值。这一过程可用倍增维护。
code
Show me the code
P8207 [THUPC 2022 初赛] 最小公倍树
题目描述
给定区间 \([L, R]\),构造一个无向完全图,其中每个节点对应一个整数,边权为两数的最小公倍数。求该图的最小生成树边权和。数据范围
- \(1 \leq L \leq R \leq 10^6\)
- \(R - L \leq 10^5\)
\(l\) 与 \(r\) 虽很大但相差的值有限,这一点还是很良心的。
考虑 \(\operatorname{lcm}(a,b)=\dfrac{a\times b}{\gcd(a,b)}\),这种与 gcd 有关的东西一般有两种方法,分解质因数、枚举质因子和枚举公因数。由于本题中直接出现了公因数,我们当然选择约束力更强的枚举公因数。
我们从大到小的枚举公因数,显然仅需从 \(\dfrac{l+r}{2}\) 开始枚举即可,考虑这些有公因数的点我们可以干什么,设此公因数为 \(k\)。
第一种,如果 \(k \in [l,r]\),则选出的点为 \(k ,2k,\cdots ,nk \le r\) ,此时我们 \((k,2k),(k,3k) \cdots\) 如此连边即为最优情况,要注意这样做是比 \((k,2k),(2k,3k) \cdots\) 连要优,且如果我们往后再次枚举到了一个 \(k_i < k\) ,此时用这个 \(k_i\) 一定是不如现在优的。
第二种,如果 \(k < l\) ,我们依然可以选出 \(l< ik ,(i+1) k … jk <r\) 这些点,因为 \(k\) 不能参与到最小公信数的构建,此时 \([ik,(i+1)k],[(i+1)k,(i+2)k],\cdots\) 连边是最优的。
两种情况连的边总数有个上界是 \(O ( r \log r )\) 的,很松,因为是 \(l\) 到 \(r\) 而非 \(1\)到 \(r\)。可以用调和极数证出来,但是你说得对我不会证,但总之是可以轻松跑过的。
code
Show me the code
CF2081D MST in Modulo Graph
题目描述
给定一个包含 \(n\) 个顶点的完全图,每个顶点有权值 \(p_i\)。边 \((x,y)\) 的权重为 \(\max(p_x,p_y) \bmod \min(p_x,p_y)\)。求该图的最小生成树总权重。数据范围
- 测试用例数 \(1 \leq t \leq 10^4\)
- 顶点数 \(1 \leq n \leq 5 \times 10^5\)(所有测试用例总和)
- 顶点权值 \(1 \leq p_i \leq 5 \times 10^5\)
- 保证所有测试用例的 \(\max(p_i)\) 总和不超过 \(5 \times 10^5\)
输出要求
对每个测试用例输出最小生成树的总权重。
类似于最小公倍树,像构建同余系一样,我们首先给点按照点权排序,然后枚举各数 \(x\),在点权中找出区间 \([x,2x),[2x,3x) \cdots\),我们枚举到的点和这些区间里顺次连出的边权显然是一单增的序列,所以我们仅需给各区间中点权最小的边连边即可。
然后依然可以用一些数学证明边是 \(O(n\log n)\) 级别的,因此找到的区间自然也是 \(O(n\log n)\) 个,找区间中点权最小显然可以二分.于是总复杂度是 \(O(n\log^2 n)\)。
code
Show me the code
CF1120D Power Tree
题目描述
给定一棵以 \(1\) 为根的 \(n\) 个节点的树,每个节点有一个非负价格 \(c_i\)。叶子节点定义为非根且度数为 \(1\) 的节点。游戏分为三个阶段:
- Arkady 购买树中非空节点集合
- Vasily 在叶子节点上放置任意整数
- Arkady 可对购买的节点 \(v\) 执行操作:选择整数 \(x\),将该节点子树中所有叶子的值加 \(x\)
求 Arkady 在第一阶段花费的最小总成本 \(s\),使得无论 Vasily 如何设置初始值,都能通过操作使所有叶子归零。同时输出所有可能出现在最优解中的节点编号。
数据范围
- 节点数 \(2 \leq n \leq 2 \times 10^5\)
- 节点价格 \(0 \leq c_i \leq 10^9\)
输出要求
第一行输出最小成本 \(s\) 和可能出现在最优解中的节点数 \(k\)
第二行升序输出 \(k\) 个节点编号
很好玩的一个题
首先 Arkady 可以给子树加任意值是个极好的性质,但只能给子树加。
看到子树加肯定要想到 DFN 上的区间加。于是我们给原树 DFS 并只给叶子标上 DFN 。这样对于树上任意一点,它统领的叶子的 DFN 就连续了,于是预处理下各点统领的 DFN 就把子树加放到了区间上。
然后区间加同一值我们又可以想到差分,我们把 DFN 做下差分,区间操作又可以变成差分 DFN 数组上 左端点加 与 右端点\(+1\)的减。
设这个区间为 \([l,r)\),你可以发现这个操作的本质就是把 \(l\) 上的数转移到 \(r\) 上去。为了让所有叶子都为 \(0\), 即让所有差分 DFN 的值为 \(0\),也就是只要我们能把各位上的数都移到 \(n+1\) 的位置就可以,由于 Arkady 可以移动任意的数,即只要各位都与 \(n+1\) 连通,Arkady 的任务不论 Vasily 怎样对树进行操作,都一定可以实现。
要所有点连通,这就是个最小生成树!现在回看一阶段的购买,它的本质也就是花费一个代价,让点对应 DFN 区间的左端点与右端点\(+1\)的位置联通,把这个当成边权,最小生成树算法即可。
于是这个题就做完了,小技巧还是很实用的。
code
Show me the code
次小生成树
无向图上的次小生成树有两种:
-
非严格次小生成树:在无向图中,边权和最小的满足边权和 大于等于 最小生成树边权和的生成树。
-
严格次小生成树 :在无向图中,边权和最小的满足边权和 严格大于 最小生成树边权和的生成树。
首先有一个结论就是次小生成树一定只与最小生成树相差一条边,所以要求解次小生成树,只需要尝试加入一次那些不在最小生成树上的边,并断开该边连接的两点在最小生成树上的一条边即可。
然后这里有个更重要的结论是如果我们打算在最小生成树的基础上修改来得到题目要求的生成树,我们仅可以替换一条边。
非严格次小生成树和严格的求解方法一样的,一块说了吧~
枚举边是容易的,但我们要找到替换的边。这个边的边权一定不大于枚举的边,原因显然。那我们一定要维护两点路径上边的最大值。如果要严格次小还要维护严格的次大值,因为若最大值与枚举边权相同.我们要断次大值的边。代码上维护次大值的地方注意下即可。
问题时间
P4180 [BJWC2010] 严格次小生成树
题目描述
求一个无向图上的严格次小生成树。
板子喵。
不知道这东西为什么会有紫。
code
Show me the code
#define rd read()
#include<bits/stdc++.h>
using namespace std;
typedef long long ll;
const ll INF=-99999999999999999;
ll read(){
ll x=0,f=1;
char c=getchar();
while(c>'9'||c<'0'){if(c=='-') f=-1;c=getchar();}
while(c>='0'&&c<='9'){x=(x<<3)+(x<<1)+(c^48);c=getchar();}
return x*f;
}
const int N=1e5+5;
const int M=3e5+5;
const int lgr=25;
ll mmst=0;
struct e{
int u;int v;int w;bool mst;
}ed[M];
bool cmp(e x,e y){return x.w<y.w;}
int fa[N];
int _find(int u){return u==fa[u]?u:fa[u]=_find(fa[u]);}
struct ex{int v;int w;};
vector<ex> edge[N];
int fat[N][lgr+5];
int dep[N];
ll tmax[N][lgr+5];//最长边
ll smax[N][lgr+5];//次长边
ll cha[M/2];
bool cmp1(ll x,ll y){return x>y;}
void bfs(int s){
memset(dep,0x3f,sizeof dep);
queue<int> q;q.push(s);
dep[s]=1;fat[s][0]=0;dep[0]=0;
while(q.size()){
int u=q.front();q.pop();
for(int i=0;i<edge[u].size();i++){
int v=edge[u][i].v;
int w=edge[u][i].w;
if(dep[v]>dep[u]+1){
dep[v]=dep[u]+1;
fat[v][0]=u;
tmax[v][0]=w;smax[v][0]=INF;
q.push(v);
for(int j=1;j<=lgr;j++){
fat[v][j]=fat[fat[v][j-1]][j-1];
ll cache[5]={0,tmax[v][j-1],tmax[fat[v][j-1]][j-1],smax[v][j-1],smax[fat[v][j-1]][j-1]};
ll mmax=INF,ssmax=INF;
for(int k=1;k<=4;k++){
if(cache[k]>mmax){
ssmax=mmax;
mmax=cache[k];
}
else if(cache[k]!=mmax&&cache[k]>ssmax)ssmax=cache[k];
}
tmax[v][j]=mmax;smax[v][j]=ssmax;
}
}
}
}
}
ll qtmax,qsmax;
void lca(int u,int v){
int cnt=0;
if(dep[u]<dep[v])swap(u,v);
for(int i=lgr;i>=0;i--){
if(dep[fat[u][i]]>=dep[v]){
cha[++cnt]=tmax[u][i];
cha[++cnt]=smax[u][i];
u=fat[u][i];
}
}
if(u==v){
for(int i=1;i<=cnt;i++){
if(cha[i]>qtmax){
qsmax=qtmax;
qtmax=cha[i];
}
else if(cha[i]!=qtmax&&cha[i]>qsmax)qsmax=cha[i];
}
return;
}
for(int i=lgr;i>=0;i--){
if(fat[u][i]!=fat[v][i]){
cha[++cnt]=tmax[u][i];
cha[++cnt]=smax[u][i];
cha[++cnt]=tmax[v][i];
cha[++cnt]=smax[v][i];
u=fat[u][i];
v=fat[v][i];
}
}
cha[++cnt]=tmax[u][0];
cha[++cnt]=tmax[v][0];
for(int i=1;i<=cnt;i++){
if(cha[i]>qtmax){
qsmax=qtmax;
qtmax=cha[i];
}
else if(cha[i]!=qtmax&&qsmax<cha[i])qsmax=cha[i];
}
return;
}
int main(){
int cnt=0;
int n,m;
cin>>n>>m;
for(int i=1;i<=m;i++){
int u=rd,v=rd,w=rd;
if(u==v)continue;
cnt++;
ed[cnt].u=u;ed[cnt].v=v;ed[cnt].w=w;
}
sort(ed+1,ed+1+cnt,cmp);
for(int i=1;i<=n;i++)fa[i]=i;
for(int i=1;i<=cnt;i++){
int u=ed[i].u;
int v=ed[i].v;
int w=ed[i].w;
int t1=_find(u),
t2=_find(v);
if(t1!=t2){
fa[t1]=t2;
ed[i].mst=1;
mmst+=ed[i].w;
edge[u].push_back(ex{v,w});
edge[v].push_back(ex{u,w});
}
}
ll ans=-INF;
bfs(1);
for(int i=1;i<=cnt;i++){
if(!ed[i].mst){
int u=ed[i].u;
int v=ed[i].v;
int w=ed[i].w;
qtmax=INF;
qsmax=INF;
lca(u,v);
if(qtmax!=w)ans=min(ans,mmst+w-qtmax);
else if(qsmax!=w&&qsmax>=0)ans=min(ans,mmst+w-qsmax);
}
}
cout<<ans;
return 0;
}
大秦王的道路系统
题目描述
给定n个城市的坐标(X,Y)和人口P,需要构建一个树型道路网络连接所有城市。其中可以指定一条魔法道路不计入总长度,要求选择魔法道路使得连接的两个城市总人口A与剩余道路总长度B的比值A/B最大化。输入格式
- 测试用例数t (t ≤ 10)
- 每个测试用例:
- 城市数n (2 < n ≤ 1000)
- n行城市数据,每行包含X,Y坐标和人口P
输出要求
对每个测试用例输出最大的 \(A/B\) 比值,保留两位小数
比较 trivial 的题,好像和次小生成树没啥关系?
首先这个 \(n\le 1000\) 允许我们可以枚举每个点对,钦定两个点对间连接魔法道路。
然后因为要是一棵树,必须要删掉一条边,为了让比值尽可能大,在 \(A\) 已固定的情况下.我们肯定要删最长边来让 \(B\) 更小。
于是用倍增维护任意两点间路径路径上的边权最大值即可。
code
Show me the code
P4806 [CCC 2017] 最小费用流
题目描述
给定 \(N\) 个节点的连通图和初始生成树,每条边有费用 \(C_i\)。可对一条边使用推进器将其费用降为 \(\max(0, C_i-D)\)。每天可替换一条边,求使生成树费用最小的最小操作天数。数据范围
- \(1 \leq N \leq 10^5\)
- \(N-1 \leq M \leq 2 \times 10^5\)
- \(0 \leq D \leq 10^9\)
- \(1 \leq C_i \leq 10^9\)
神人翻译
首先如果不考虑这个推进器,那么花费最小的方案一定确定了,自然天数也是确定的。但在推进器的影响下,我们还可以用它推进原方案中的一条边,来让天数少一天。
自然的考虑先把最小方案应用上去,再枚举那些原来在方案中但现在不在的边,尝试删掉最小方案中的一条边来把这个边放进去。
怎么放呢?我们用倍僧预处理出 MST 上各点到根路径上 被推进后原来不在方案中的边的权的 最大值,这就是我们要删的边,但如果我们推进后的这条边边权大于两点间所有后加入的边,这条边就不能被放入。之后枚举原来在方案中但现在不在的边替换取 min 即可。
code
Show me the code

浙公网安备 33010602011771号