【最小生成树】学习笔记

· · 算法·理论

本文中,为了避免歧义,定义:

前置知识

理论

最小生成树是指边权和最小的生成树,\text{(Minimum Spanning Tree,MST)}

比如这个图:

它的最小生成树是:

权值之和为 15

并且没有方案使得其他生成树权值之和比 15 小。

这个树,就是最小生成树。

最小生成树的常见算法有两种:\text{kruskal}\text{prim} 算法。

kruskal

算法实现

- 定义最小生成树是一个空集。 - 找到一条权值最短的边。 - 判断加入此边会不会出现环。 - 会:回到第二步 - 不会:加入集合,删除此边,回到第二步。 判断联通性可以使用[并](https://www.luogu.com.cn/article/yarkuugt)[查](https://blog.csdn.net/bjweimengshu/article/details/108332389)[集](https://www.bilibili.com/video/BV1ge4y1b78w)维护。 ## 时间复杂度分析 并查集的路径压缩和启发式合并就把处理变的时间复杂度降维到了 $\text{O}(m)$,时间花费最大的是排序,用快速排序整体代码的时间复杂度为 $\text{O}(m \log m)$。 如果边权小的话可以直接计数排序就是 $\text{O}(m+W)$($W$ 表示边权最大值)。 ## 举例 ![](https://cdn.luogu.com.cn/upload/image_hosting/otwayogx.png) 那么用 $\text{kruskal}$ 算法求最小生成树的过程为: - 当前 $1 \leftrightarrow 2$ 的边权最小,加入不会出现环,将 $1 \leftrightarrow 2$ 加入集合。当前权值之和:$2$。 - 当前 $2 \leftrightarrow 3$ 的边权最小,加入不会出现环,将 $2 \leftrightarrow 3$ 加入集合。当前权值之和:$2+2=4$。 - 当前 $3 \leftrightarrow 7$ 的边权最小,加入不会出现环,将 $3 \leftrightarrow 7$ 加入集合。当前权值之和:$4+2=6$。 此时,权值为 $2$ 的所有边已经操作完成,图变成了这样: ![](https://cdn.luogu.com.cn/upload/image_hosting/p3a7cvan.png) 我们用刚才的办法操作所有的边权为 $3$ 的点,也不会有环,操作后图变成了: ![](https://cdn.luogu.com.cn/upload/image_hosting/kglxgvdv.png) 权值之和为 $12$。 - 当前 $3 \leftrightarrow 4$ 的边权最小,加入**会**出现环,删掉。 此时,权值为 $4$ 的所有边已经操作完成,图变成了这样: ![](https://cdn.luogu.com.cn/upload/image_hosting/kglxgvdv.png) - 当前 $3 \leftrightarrow 5$ 的边权最小,加入不会出现环,将 $3 \leftrightarrow 5$ 加入集合。当前权值之和:$12+5=17$。 - 当前 $4 \leftrightarrow 6$ 的边权最小,加入**会**出现环,删掉。 - 当前 $6 \leftrightarrow 7$ 的边权最小,加入**会**出现环,删掉。 现在所有的点已经被操作完成,图为: ![](https://cdn.luogu.com.cn/upload/image_hosting/7rr81htq.png) 这个就是它的最小生成树。 ## 代码 用 P3366 举例。 先按照边权为第一关键字排序,再便利每次把边进行操作。连通性可以用并查集维护。使用路径压缩和启发式合并。最后判断联通直接看 $cnt_{\operatorname{findfa}(x)}$ 是否等于 $n$ 即可。 $cnt_i$ 是指 $i$ 为根的树的结点数量。 ```cpp #include<bits/stdc++.h> using namespace std; const int MAXN=5000; const int MAXM=2e5; int n,m,sum; int fa[MAXN+5]; int cnt[MAXN+5]; int findfa(int x) { if(fa[x]==x) { return x; } return fa[x]=findfa(fa[x]); } struct node { int u,v,w; }e[MAXM+5]; bool cmp(node x,node y) { return x.w<y.w; } int main() { ios::sync_with_stdio(0); cin.tie(0); cin>>n>>m; for(int i=1;i<=m;i++) { cin>>e[i].u>>e[i].v>>e[i].w; } for(int i=1;i<=n;i++) { fa[i]=i; cnt[i]=1; } sort(e+1,e+1+m,cmp); for(int i=1;i<=m;i++) { int u=e[i].u; int v=e[i].v; int w=e[i].w; int fau=findfa(u); int fav=findfa(v); if(fau==fav) { continue; } if(cnt[fau]>cnt[fav]) { cnt[fau]+=cnt[fav]; fa[fav]=fa[u]; } else { cnt[fav]+=cnt[fau]; fa[fau]=fa[v]; } sum+=w; } if(cnt[findfa(1)]==n) { cout<<sum; } else { cout<<"orz"; } return 0; } ``` ## 正确性证明 为了造出一棵最小生成树,显然是有 $n-1$ 条边。 用反证法证明: 生成的生成树 T 不是 $\text{MST}$,存在另一个生成树 $T'$,满足 $\operatorname{w}(T')<\operatorname{w}(T)$。 设 $e$ 为 $T$ 中存在且 $T'$ 不存在的最小边权,$e$ 是按权值从小到大选择且不形成环的边。 将 $e$ 加入 $T'$ 会出现环,此环中必有一条边 $f$ 属于 $T'$ 但不属于 $T$。 由于 $e$ 是差异边中权值最小的,因为会优先选择最小权边,故 $\operatorname{w}(f) \ge \operatorname{w}(e)$。 将 $T'$ 中的 $f$ 替换为 $e$,得到新生成树 $T''$,满足 $w(T'')=w(T')-w(f)+w(e)\le w(T ')$。这与 $T'$ 是最小生成树矛盾。 # prim ## 算法实现 $\operatorname{prim}$ 算法也可以求最小生成树,相对于 $\operatorname{kruskal}$ 比较麻烦且时间复杂度相同。但有的题目无法使用 $\operatorname{kruskal}$ 解决,$\operatorname{prim}$ 仍是很重要的算法。 $\operatorname{prim}$ 算法的思想类似于 $\operatorname{dijkstra}$,每次处理边的最小权值。 $\operatorname{prim}$ 算法的实现过程: - 从一个特定的节点 $s$ 开始,将所有与 $s$ 相连的边以权重从小到大放入优先队列;如果权重相同,则根据节点的序号。 - 如果在优先队列中最前面的边 $u\leftrightarrow v$ 中的 $v$ 还没有被访问过,那么我们可以包括 $v$ 来扩展最小生成树并将 $v$ 的边排进优先队列中;否则放弃这条边。不然会出现环。 - 回到第二步,直到最小生成树被构造出来 实际上可以每次跑一遍最大值不需要优先队列。 ## 时间复杂度分析 如果是稀疏图用优先队列优化可以达到 $\text{O}( m\log n)$ 的速度。 如果是稠密图需要每次跑一遍最大值而不应使用优先队列,因为 $m$ 最大是 $\frac{n(n-1)}{2}$,用堆优化可能退化到 $\text{O}(n^3)$,直接暴力找可以直接达到 $\text{O}(n^2)$. ## 举例 (本示例中蓝色边表示未处理的边,绿色的边表示在队列里的边,橙色的边表示最小生成树的边,被擦去的边表示不是最小生成树的边。) ![](https://cdn.luogu.com.cn/upload/image_hosting/7pbg2aqx.png) 求最小生成树的过程为: - 将 $1$ 加入已选定集合 $E=\{1\}$。 - 把和 $1$ 有关的边放入优先队列。 - 此时队列中队首(即边权的最小值)为 $1(1\leftrightarrow 4)$,且没有操作过,选择这条边。此边出队。 - 将 $4$ 加入已选定集合 $E=\{1,4\}$。 - 把和 $4$ 有关的边放入优先队列。 操作后的图变成了这样: ![](https://cdn.luogu.com.cn/upload/image_hosting/aqi86v7v.png) - 此时队列中队首(即边权的最小值)为 $2(1\leftrightarrow 3)$,且没有操作过,选择这条边。此边出队。 - 将 $3$ 加入已选定集合 $E=\{1,3,4\}$。 - 把和 $3$ 有关的边放入优先队列。 此时的图: ![](https://cdn.luogu.com.cn/upload/image_hosting/m8swlgb5.png) - 此时队列中队首(即边权的最小值)为 $1(2\leftrightarrow 3)$,且没有操作过,选择这条边。此边出队。 - 将 $3$ 加入已选定集合 $E=\{1,2,3,4\}$。 - 因为所有边全部入队,所以无需继续把新的边压入队列。 此时的图: ![](https://cdn.luogu.com.cn/upload/image_hosting/mfo16c3v.png) - 此时队列中队首(即边权的最小值)为 $2(2\leftrightarrow 5)$,且没有操作过,选择这条边。此边出队。 - 将 $5$ 加入已选定集合 $E=\{1,2,3,4,5\}$。 - 因为所有边全部入队,所以无需继续把新的边压入队列。 因为现在的 $E$ 集合已满所以最小生成树已经成形,保留所有的橙色边后最小生成树就是: ![](https://cdn.luogu.com.cn/upload/image_hosting/tklryelt.png) --- 显然我刚才的示例的数据比较垃圾没有出现环的情况。但是有环也很好解决,直接看这条边之前处理过没有即可。 ## 代码 按照 P3366 来说。 ```cpp #include<bits/stdc++.h> using namespace std; priority_queue<pair<int,int>,vector<pair<int,int>>,greater<pair<int, int>>> pq; const int INF=1e9; struct node { int v,w; }; int n,m; vector<node>edge[10005]; int dis[10005];//从起点到其他各个点的距离 bool vis[10005];//某个点是否已经加入生成树 int main() { ios::sync_with_stdio(0); cin.tie(0); cin>>n>>m; for(int i=1;i<=m;i++) { int u,v,w; cin>>u>>v>>w; edge[u].push_back({v,w}); edge[v].push_back({u,w}); // 由于是无向图,所以要将另一条边也加入邻接表中 } for(int i=1;i<=n;i++)//初始化 { dis[i]=INF; vis[i]=0; } dis[1]=0; pq.push(make_pair(0,1)); int ans=0; while(!pq.empty()) { int u=pq.top().second; pq.pop(); if(vis[u]) { continue;//如果u已经被访问过,则跳过 } vis[u]=true;//将u标记为已访问 for(int i=0;i<edge[u].size();i++) { int v=edge[u][i].v; int w=edge[u][i].w; if (!vis[v] && w<dis[v]) { dis[v]=w; pq.push(make_pair(dis[v],v)); } } ans+=dis[u]; } for(int i=1;i<=n;i++) { if(vis[i]==0) { cout<<"orz"; return 0; } } cout<<ans; return 0; } ``` ## 正确性证明 还是反证法 假设:设此算法求出的 $T$ 不是最小生成数,存在另一个生成树 $T'$,满足 $\operatorname{w}(T') < \operatorname{w}(T)$ 。 设 $e$ 是第一个被加入 $T$ 但不在 $T'$ 中的边。此时,$e$ 连接已选顶点集合 $E$ 和未选集合 $E'$ 。 $T'$ 必须包含一条边 $f$ 连接 $E$ 和 $E'$ ,否则 $T'$ 不连通。 因为 $e$ 是连接 $E$ 和 $E'$ 的最小权边,故 $\operatorname{w}(e) \le \operatorname{w}(f)$。 若 $\operatorname{w}(f) < \operatorname{w}(e)$,则应优先选择 $f$,与算法规则矛盾。因此 $T'$ 不存在。 假设不成立,所以 $T$ 最小生成树。 # Borůvka ## 算法实现 在稠密图上不如 ${\operatorname{prim}}$,稀疏图上逊于 $\operatorname{kruskal}$,只适用于求**完全图**的最小生成树,但实际上没啥用,主流的两个算法也能求完全图。在朋友面前装装还行。 $\operatorname{kruskal}$ 和 ${\operatorname{prim}}$ 缝合到一起就是 $\operatorname{Borůvka}$ 算法。 $\operatorname{Borůvka}$ 算法的本质是多源 ${\operatorname{prim}}$。 实现步骤如下: - 每个点先看成只有根的树。 - 找到每个森林的临边最小权值。 - 如果加入出现环则跳过。 - 没有环就加入这条边让两个树合并成一个树。 - 回到第二部直到最小生成树确定。 用了 ${\operatorname{prim}}$ 的贪心策略,结合了$\operatorname{kruskal}$ 的并查集,就出了这个算法。 ## 时间复杂度分析 注意到每次进行操作二的时候,找最小值需要 $\text{O}(m)$,所以森林进行一次合并操作就是 $\text{O}(m)

每次都会把两个树合并为一个树所以合并次数为 \log n

综合下来就是 \text{O}(m \log n)

举例

这里用 OIwiki 的示例来说。

模拟过程:

所有边均被处理,\operatorname{Borůvka} 算法结束。

虽然不知道为什么原动图上 EJ 没有边。没边不就是最小生成森林了吗,再且如果就是求的是森林还不如每个节点是一个独根树和为 0

代码

还是 P3366。

#include <iostream>
#include <cstdio>
#include <cstring>
using namespace std;
const int MAXN=5000;
const int MAXM=200000;
int n,m;
int u[MAXM+5],v[MAXM+5],w[MAXM+5];
bool used[MAXM+5];
int fa[MAXN+5],Best[MAXN+5];

int findfa(int x)
{
    if(x==fa[x])
    {
        return x;
    }
    return fa[x]=findfa(fa[x]);
}
bool cmp(int x,int y)
{
    if(y==0)
    {
        return 1;
    }
    if(w[x]!=w[y])
    {
        return w[x]<w[y];
    }
    return x<y;
}
void Boruvka()
{
    for(int i=1;i<=n;i++)
    {
        fa[i]=i;
    }
    int cnt=0,sum=0;
    bool flag=1;
    while(flag)
    {
        flag=0;
        memset(Best,0,sizeof Best);
        for(int i=1;i<=m;i++)
        {
            int U=u[i];
            int V=v[i];
            int W=w[i];
            if(used[i]==1)
            {
                continue;
            }
            int fau=findfa(u[i]);
            int fav=findfa(v[i]);
            if(fau==fav)
            {
                continue;
            }
            if(cmp(i,Best[fau])==1)
            {
                Best[fau]=i;
            }
            if(cmp(i,Best[fav])==1)
            {
                Best[fav]=i;
            }
        }
        for(int i=1;i<=n;i++)
        {
            if(Best[i]!=0&&used[Best[i]]==0)
            {
                flag=1;
                cnt++;
                sum+=w[Best[i]];
                used[Best[i]]=1;
                fa[findfa(u[Best[i]])]=findfa(v[Best[i]]);
            }
        }
    }

    if(cnt==n-1)
    {
        cout<<sum;
    }
    else
    {
        cout<<"orz";
    }
}

int main()
{
    ios::sync_with_stdio(0);
    cin.tie(0);
    cin>>n>>m;
    for(int i=1;i<=m;++i)
    {
        cin>>u[i]>>v[i]>>w[i];
    }
    Boruvka();
    return 0;
}

正确性证明

假设 \operatorname{Borůvka} 算法生成的生成树 T 不是最小生成树。根据反证法,存在另一个生成树 T' 满足 \operatorname w(T') < \operatorname w(T)

考虑 \operatorname{Borůvka} 算法执行过程中第一次选择一条不在 T' 中的边 e 的时刻。

设此时算法处理的树为 xy,且 e 是连接 xy 的最小权边。

由于 T' 是生成树,它必须包含连接 xy 的某条边 f

那么,有: \operatorname w(e) \le \operatorname w(f)

否则,会在该步骤中选择 f 而非 e ,与假设矛盾。

T' 中的边 f 替换为 e,得到新生成树。

此时:\operatorname w(T'') = \operatorname w(T') - \operatorname w(f) + \operatorname w(e) \le \operatorname w(T')

T'' 的总权值不大于 T',这与 T'\text{MST} 矛盾。因此,原假设不成立,故正确。

一些 \text{MST} 好题

P10928 走廊泼水节

大意就是给你一个最小生成树让你加一些边变成完全图,求完全图的权值最小值。

因为要使新增的权值最小,所以此时对于 u 连通块内到 v 连通块的边边权应该为 w+1,如果更小就会破坏原定的最小生成树。贡献和为 (s_u\times s_v-1)\times(w+1)s_i 表示 i 联通块的节点数量。

#include<bits/stdc++.h>
using namespace std;
const int MAXN=6000;
const int MAXM=5999;
int n,sum;
int fa[MAXN+5];
int cnt[MAXN+5];
int findfa(int x)
{
    if(fa[x]==x)
    {
        return x;
    }
    return fa[x]=findfa(fa[x]);
}
struct node
{
    int u,v,w;
}e[MAXM+5];
bool cmp(node x,node y)
{
    return x.w<y.w;
}
int t; 
int main()
{
    ios::sync_with_stdio(0);
    cin.tie(0);
    cin>>t;
    while(t--)
    {
        cin>>n;
        for(int i=1;i<n;i++)
        {
            cin>>e[i].u>>e[i].v>>e[i].w;
        }
        for(int i=1;i<=n;i++)
        {
            fa[i]=i;
            cnt[i]=1;
        }
        sum=0;
        sort(e+1,e+1+n-1,cmp);
        for(int i=1;i<n;i++)
        {
            int u=e[i].u;
            int v=e[i].v;
            int w=e[i].w;
            int fau=findfa(u);
            int fav=findfa(v);
            sum+=(cnt[fau]*cnt[fav]-1)*(w+1);
            if(cnt[fau]>cnt[fav])
            {
                cnt[fau]+=cnt[fav];
                fa[fav]=fa[u];
            }
            else
            {
                cnt[fav]+=cnt[fau];
                fa[fau]=fa[v];
            }
        }
        cout<<sum<<'\n';
    }
    return 0;
}

P1265 公路修建

因为边很多用堆优化反而更慢,每次直接暴力找最小值即可。 ```cpp #include<bits/stdc++.h> #define int long long using namespace std; const int MAXN=5000; int n,m; int x[MAXN+5],y[MAXN+5]; vector<int>e[MAXN+5]; double dis[MAXN+5]; bool vis[MAXN+5]; double len(int i,int j) { return sqrt((int)(x[i]-x[j])*(x[i]-x[j])+ (int)(y[i]-y[j])*(y[i]-y[j])); } signed main() { ios::sync_with_stdio(false); cin.tie(0); cin>>n; for(int i=1;i<=n;i++) { cin>>x[i]>>y[i]; } for(int i=1;i<=n;i++) { dis[i]=1e12,vis[i]=0; } dis[1]=0; double sum=0; for(int i=1;i<=n;i++) { int u=-1; for(int i=1;i<=n;i++) { if(!vis[i]&&(u==-1||dis[i]<dis[u])) { u=i; } } vis[u]=1; sum+=dis[u]; for(int v=1;v<=n;v++) { if(len(u,v)<dis[v]) { dis[v]=len(u,v); } } } cout<<fixed<<setprecision(2)<<sum; return 0; } ``` ## [P4047 [JSOI2010] 部落划分](https://www.luogu.com.cn/problem/P4047) 每次找最近的两个部落把他们合并是最优的。 最后剩下 $k$ 个部落的时候当前处理到的边(加入不联通)就是目标距离。 我用的 $\operatorname{kruskal}$ 和优先队列。 ```cpp #include<bits/stdc++.h> using namespace std; int n,k; struct node { int x,y; double w; bool operator<(const node x)const { return w>x.w; } }; double slove(int x,int y,int xx,int yy) { return sqrt(double((x-xx)*(x-xx)+(y-yy)*(y-yy))); } priority_queue<node> q; int U[1005]; int V[1005]; int fa[10000*10000+10000]; int findfa(int x) { if(fa[x]==x) { return x; } return fa[x]=findfa(fa[x]); } int main() { ios::sync_with_stdio(0); cin.tie(0); cin>>n>>k; for(int i=1;i<=n;i++) { cin>>U[i]>>V[i]; } for(int i=1;i<=n;i++) { for(int j=1;j<=n;j++) { if(i==j||i>j) { continue; } q.push({i,j,slove(U[i],V[i],U[j],V[j])}); } } for(int i=1;i<=n;i++) { fa[i]=i; } int sum=n; while(sum>k) { //cout<<sum<<'\n'; node x=q.top(); q.pop(); int u=x.x; int v=x.y; int fau=findfa(u); int fav=findfa(v); if(fau!=fav) { fa[fau]=fav; sum--; } } while(1) { node x=q.top(); q.pop(); int u=x.x; int v=x.y; int fau=findfa(u); int fav=findfa(v); if(fau!=fav) { printf("%.2lf",x.w); return 0; } } return 0; } ``` ## [P1550 [USACO08OCT] Watering Hole G](https://www.luogu.com.cn/problem/P1550) 对于一个田,要让其有水,有两种办法:挖井,引水入田。 可以定一个特殊点 $s$,$s$ 连向每块田,权值为开凿水井的话费。 这样 $n+1$ 个点,$n+m$ 条边,跑一个最小生成树即可。 ```cpp #include<bits/stdc++.h> using namespace std; const int MAXN=600; int n,m,sum,flag=1; int fa[MAXN+5]; int cnt[MAXN+5]; int findfa(int x) { if(fa[x]==x) { return x; } return fa[x]=findfa(fa[x]); } struct node { int u,v,w; }; vector<node> e; bool cmp(node x,node y) { return x.w<y.w; } int main() { ios::sync_with_stdio(0); cin.tie(0); cin>>n; for(int i=1;i<=n;i++) { int w; cin>>w; e.push_back({0,i,w}); } for(int i=1;i<=n;i++) { for(int j=1;j<=n;j++) { int p; cin>>p; if(i==j || i>j) { continue; } e.push_back({i,j,p}); } } for(int i=0;i<=n;i++) { fa[i]=i; cnt[i]=1; } sort(e.begin(),e.end(),cmp); for(int i=0;i<e.size();i++) { int u=e[i].u; int v=e[i].v; int w=e[i].w; int fau=findfa(u); int fav=findfa(v); if(fau==fav) { continue; } if(cnt[fau]>cnt[fav]) { cnt[fau]+=cnt[fav]; fa[fav]=fa[u]; } else { cnt[fav]+=cnt[fau]; fa[fau]=fa[v]; } sum+=w; } cout<<sum; return 0; } ``` ## [P1396 营救](https://www.luogu.com.cn/problem/P1396) Don't open the door for strangers. 正常搞 $\operatorname{kruskal}$ 就行了,如果遇到加边成环就输出此边的权值。 因为之前的所有边权都小于等于当前边权,所以当前边权是这条路径的最大边权。由于我们是按从小到大加入的,任何其他路径的最大边权不可能比这个小(否则会出现环)。 ```cpp #include<bits/stdc++.h> using namespace std; const int MAXN=1e4; const int MAXM=2e4; int n,m,s,t; int fa[MAXN+5]; int cnt[MAXN+5]; int findfa(int x) { if(fa[x]==x) { return x; } return fa[x]=findfa(fa[x]); } struct node { int u,v,w; }e[MAXM+5]; bool cmp(node x,node y) { return x.w<y.w; } int main() { ios::sync_with_stdio(0); cin.tie(0); cin>>n>>m>>s>>t; for(int i=1;i<=m;i++) { cin>>e[i].u>>e[i].v>>e[i].w; } for(int i=1;i<=n;i++) { fa[i]=i; cnt[i]=1; } sort(e+1,e+1+m,cmp); for(int i=1;i<=m;i++) { int u=e[i].u; int v=e[i].v; int w=e[i].w; int fau=findfa(u); int fav=findfa(v); if(fau==fav) { continue; } if(cnt[fau]>cnt[fav]) { cnt[fau]+=cnt[fav]; fa[fav]=fa[u]; } else { cnt[fav]+=cnt[fau]; fa[fau]=fa[v]; } if(findfa(s)==findfa(t)) { cout<<w; return 0; } } return 0; } ``` # 后记 ~~$\sout{\operatorname{kruskal}}$ 但是用优先队列有种屁股得了关节炎的感觉。~~ $\operatorname{kruskal}$ 和 $\operatorname{prim}$ 都是很重要的算法。 至于 $\operatorname{Borůvka}$ 是不会在 NOI 赛事中出现的,仅限于大纲更改之前。 --- 有没有更强势还吃操作的最小生成树算法呢?[有的兄弟,有的。](https://www.cs.princeton.edu/~chazelle/pubs/mst.pdf)