MST
最小生成树(MST)进阶算法与优化笔记
1. 基础模板:Borůvka 算法核心思想
来源文件:boruvka.cpp
- 问题抽象:给定 $n$ 个点和 $m$ 条边的普通图,求最小生成树。
- 算法思路:
Borůvka 算法是多源并行的 MST 算法,非常适合解决边数极多(如完全图)但边权有规律的连通性问题。
- 初始化:将每个点视为一个独立的连通块(用并查集维护)。
- 找最小边:遍历所有边,为每个连通块找到一条向外连接(连接到其他连通块)的权值最小的边。
- 合并:将找到的这些最小边加入 MST,并合并相应的连通块。
- 迭代:重复步骤 2 和 3,每次迭代连通块数量至少减半,最多运行 $O(\log n)$ 轮,直到所有点合并为一个整体。
点击查看代码
// 定义边结构体
struct Node{
int u, v, w; // u, v: 连接的两个点, w: 边权
};
Node g[N]; // 存储所有边的数组
int p[N];
int find(int x){
if(p[x] != x)
p[x] = find(p[x]);
return p[x];
}
int ans; // 存储最小生成树的总权值
int num = 0; // 记录已经加入最小生成树的边数
// Boruvka 算法核心函数
// 逻辑:把所有集合看成连通块 ->
// 遍历所有边找出每个连通块向外的最短边 ->
// 选最小的更新合并 ->
// 直到成为一个整体
void bor(){
// 初始化并查集,最开始每个点都是一个独立的连通块
for(int i = 1; i <= n; i++)
p[i] = i;
// 大循环:每一轮都会进行一波合并,直到生成树完成
while(1){
bool f = 0; // 标记本轮是否有合并操作,如果没有则说明算法结束
vector<int> mn(n + 1); //连通块最小边的下标
vector<int> vis(m + 1);//边是否被搜过
// d[i] 存储以 i 为根的连通块,目前找到的向外连接的最短边的权值
// 初始化为无穷大 (1e18)
vector<int> d(n + 1, 1e18);
// 第一步:遍历所有边,为每个连通块寻找最短的出边
for(int i = 1; i <= m; i++){
auto &[u, v, w] = g[i]; // 取出第 i 条边的信息 (C++17 结构化绑定)
int a = find(u), b = find(v); // 查找 u 和 v 所在的连通块根节点
// 如果两个点已经在同一个连通块,或者这条边已经用过了,跳过
if(a == b || vis[i]) continue;
// 更新连通块 a 的最短边信息
if(d[a] > w){
d[a] = w; // 更新最小权值
mn[a] = i; // 记录这条边的编号
}
// 更新连通块 b 的最短边信息
if(d[b] > w){
d[b] = w; // 更新最小权值
mn[b] = i; // 记录这条边的编号
}
}
// 第二步:遍历每个节点(实际上是遍历连通块的根),进行合并
for(int i = 1; i <= n; i++){
// 注意:这里逻辑上最好判断一下 if(d[i] == 1e18 || p[i] != i) continue;
// 否则非根节点的 mn[i] 可能是脏数据,但因为 g[0] 为空且 find 机制,通常不会报错但逻辑不严谨
int edge = mn[i]; // 获取连通块 i 记录的最优边的编号
auto &[u, v, w] = g[edge]; // 获取该边的详细信息
int a = find(u), b = find(v); // 再次查找边的两端所在的连通块
// 如果已经连通了(可能在这一轮前面的迭代中被合并了),或者边已使用,跳过
if(a == b || vis[edge]) continue;
// 执行合并操作
vis[edge] = 1; // 标记这条边已被选中
p[a] = b; // 合并两个连通块(将 a 接到 b 上)
ans += w; // 累加权值
f = 1; // 标记本轮发生了合并
}
// 退出条件:如果本轮没有合并任何连通块(f=0),或者边数已达到 n-1(树已成形)
if(!f || num == n - 1){
break;
}
}
}
// 处理单组测试数据的函数
void solve() {
cin >> n >> m; // 输入点数和边数
for(int i = 1; i <= m; i++){
int u, v, w;
cin >> u >> v >> w; // 输入每条边的 u, v, w
Node x = {u, v, w}; // 构造边结构体
g[i] = x; // 存入数组
}
bor(); // 调用 Boruvka 算法
// 如果选出的边数等于 n-1,说明图连通,输出了 MST 权值
if(num == n - 1)
cout << ans;
else
cout << "orz"; // 否则说明图不连通,输出 "orz"
}
2. 模意义下的完全图 MST (CCPC 题目)
来源文件:ccpc C2.cpp (Borůvka版), ccpc网络赛C.cpp (Prim版)
- 问题抽象:给定 $n$ 个点,点权为 $a_i$。这是一张完全图,任意两点 $u, v$ 之间的边权定义为 $(a_u + a_v) \pmod k$。求最小生成树。
- 难点:完全图有 $O(n^2)$ 条边,直接建图必定 TLE/MLE,需要根据边权性质贪心找边。
思路 1:Borůvka + std::set 启发式寻找 (ccpc C2.cpp)
由于边权是 $(a_u + a_v) \pmod k$,对于点 $u$,最优的连接对象 $v$ 的点权 $a_v$ 应该尽可能让和接近 $k$(略大于等于 $k$)或者取绝对的最小值。
- 用全局
set维护所有的{点权, 节点编号}。 - 在 Borůvka 的每一轮中,遍历连通块:
- 先将该连通块内的所有点从全局
set中暂时删除。 - 对连通块内的每个点 $x$,在
set中利用lower_bound寻找大于等于 $k - a_x$ 的最小值;如果找不到,就取set中绝对最小的元素(即*s.begin())。 - 记录该连通块能找到的最优向外边。
- 回溯(将连通块内的点重新加回
set)。
- 合并连通块。利用
set将找边的复杂度从 $O(n^2)$ 降到了 $O(n \log n)$ 级别。
点击查看代码
struct Node {
int x, y, w;
};
//已被卡常
// Boruvka 算法核心函数
// 逻辑:把所有集合看成连通块 ->
// 遍历所有边找出每个连通块向外的最短边 ->
// 选最小的更新合并 ->
// 直到成为一个整体
// 对于每个a[i] 找 k - a[i]存不存在 如果不存在找个最小的就行
// 用二维数组存连通块遍历连通块
// 维护最短边
void solve() {
int ans = 0;
int n, k;
cin >> n >> k;
// 点权
vector<int> a(n + 1);
for (int i = 1; i <= n; i++) {
cin >> a[i];
a[i] %= k;
}
// set 维护总的集合
set<pair<int, int>> s;
for (int i = 1; i <= n; i++) {
s.insert({a[i], i});
}
vector<int> p(n + 1);
// 初始化并查集,最开始每个点都是一个独立的连通块
for (int i = 1; i <= n; i++)
p[i] = i;
auto find = [&](auto &&self, int x) -> int {
if (p[x] != x)
p[x] = self(self, p[x]);
return p[x];
};
auto bor = [&]() {
bool f = 0;
// d[i] 存储以 i 为根的连通块,目前找到的向外连接的最短边的权值
// 连通块的最小边 x -> y 以及边权
vector<Node> mn(n + 1);
vector<int> d(n + 1, 1e18);
// 存连通块中的点
vector<vector<int>> id(n + 1);
for (int i = 1; i <= n; i++) {
id[find(find, i)].push_back(i);
}
// 从连通块中找小边
for (int i = 1; i <= n; i++) {
// 只找根节点
if (id[i].empty())
continue;
// 把这个连通块先删掉
for (int x : id[i]) {
s.erase({a[x], x});
}
// 遍历根节点的每个点
for (int x_id : id[i]) {
// 对每个点算出最小值
if (!s.empty()) {
int goal = k - a[x_id];
auto it = s.lower_bound({goal, -1});
if (it != s.end()) {
auto [y_num,y_id] = *it;
int w = (a[x_id] + a[y_id]) % k;
if (d[i] > w) {
d[i] = w;
int y_id = it->second;
mn[i] = {x_id, y_id, w};
}
} else {
auto [y_num, y_id] = *s.begin();
int w = (a[x_id] + a[y_id]) % k;
if (d[i] > w) {
d[i] = w;
mn[i] = {x_id, y_id, w};
}
}
}
}
// 回溯
for (int x : id[i]) {
s.insert({a[x], x});
}
}
// 合并操作
for (int i = 1; i <= n; i++) {
auto [x, y, w] = mn[i];
int u = find(find, x);
int v = find(find, y);
if (u == v)
continue;
// cerr << x << ' ' << y << ' ' << w << endl;
ans += w;
p[u] = v;
f = 1;
}
if (!f)
return false;
else
return true;
};
while (bor());
cout << ans << endl;
}
思路 2:动态 Prim + 贪心加边 (ccpc网络赛C.cpp)
传统的 Prim 是把一个点的所有边扔进优先队列,这里因为是完全图,不能全扔,采取“动态最优加边”策略。
- 用
set维护尚未加入 MST 的点。 - 优先队列存候选边。初始随便选一个点,找它最优的邻居扔进队列。
- 每次从队列弹出一权值最小的边(假设连向新点 $v$)。如果 $v$ 还没在树中:
- 把 $v$ 标记为已加入(从
set中删除)。 - 关键动态加边:为新点 $v$ 在剩余的
set中找一个最优的邻居加入队列;同时,也为当前的出发点 $u$ 在剩余的set中再找一个次优的邻居补进队列。
- 这样保证了优先队列里始终只有 $O(n)$ 级别的边在竞争,避免了无用边的展开。
点击查看代码
//在prim的基础上每个点只贪心的加入一条最优边到队列中
//我们要动态的加边进去 对于每个点 一次只加最好的一条边
struct Node {
int x, y, w;
bool operator < (const Node &v) const {
return w > v.w;
}
};
void solve() {
priority_queue<Node> q; //{x,y,w} (存的是边 因为原prim我们是知道边的
//但这个不知道要动态加边要把起点也记录
int n,k;
cin >> n >> k;
int ans = 0;
set<pair<int,int>> s;
vector<int> a(n + 1);
for(int i=1;i<=n;i++){
cin >> a[i];
a[i] %= k;
s.insert({a[i],i});
}
//拿到最好的点
auto get = [&](int x) -> pair<int,int>{
auto it = s.lower_bound({k - x,0});
if(it == s.end())
it = s.begin();
return *it;
};
//把第一个点放进去
auto [start_w,start] = *s.begin();
s.erase(s.begin());
auto [next_w,next] = get(start_w);
q.push({start,next,(start_w + next_w) % k});
while(q.size()){
auto [u,v,w] = q.top(); q.pop();
if(!s.count({a[v],v})) {
//题目特色不加这一段
// auto [nu_w,nu] = get(a[u]);
// q.push({u,nu,(a[nu] + a[u]) % k});
continue;
}
//ans在这写别写错了
ans += w;
//删除v
s.erase({a[v],v});
if(s.empty()) break;
auto [nv_w,nv] = get(a[v]);
q.push({v,nv,(a[nv] + a[v]) % k});
auto [nu_w,nu] = get(a[u]);
q.push({u,nu,(a[nu] + a[u]) % k});
}
cout << ans << endl;
}
3. 建立“虚点”转化为标准 MST (USACO Watering Hole)
来源文件:P_1550_USACO_08_OCT_Watering_Hole_G.cpp
- 问题抽象:$n$ 个地点需要水。可以直接在地点 $i$ 挖井(成本 $W_i$),也可以从地点 $j$ 铺设管道到 $i$(成本 $P_{i,j}$)。求让所有地点都有水的最小总成本。
- 思路:经典的点权转边权技巧。
- 引入超级源点(虚点):假设存在一个编号为 $n+1$ 的虚拟水源。
- 在地点 $i$ 挖井,等价于从虚点 $n+1$ 连一条边到点 $i$,边权为 $W_i$。
- 地点间的管道等价于普通的边,边权为 $P_{i,j}$。
- 跑一遍包含虚点在内的 $n+1$ 个点的标准 Prim 算法即可。
点击查看代码
//虚点 prim
struct Node
{
int x,w;
bool operator < (const Node &v) const{
return w > v.w;
}
};
int g2[N][N];
vector<Node> g[N];
void prim(){
int res = 0;
priority_queue<Node> q;
vector<int> d(n+1,1e18);
vector<int> vis(n+1,0);
q.push({1,0});
d[1]=0;
while(q.size() ){
auto [x,y]=q.top();
q.pop();
if(vis[x]) continue;
vis[x] = 1;
res += y;
for(auto [v,w]:g[x]){
if(d[v] > w && !vis[v] )
{
d[v] = w;
q.push({v,d[v]});
}
}
}
cout<<res;
}
void solve() {
cin >> n;
for(int i=1;i<=n;i++)
{
int w;
cin >> w;
g[i].pb({n+1,w});
g[n+1].pb({i,w});
}
for(int i=1;i<=n;i++)
for(int j=1;j<=n;j++)
{
cin >> g2[i][j];
if(i!=j){
g[i].pb({j,g2[i][j]});
}
}
n++;
prim();
}
4. 补图(删边完全图)的 MST
来源文件:牛客寒假营1 J .cpp (Borůvka版), 寒假营 J prim.cpp (Prim+线段树版)
- 问题抽象:给定一个完全图,删去了 $m$ 条边,剩下的合法边权定义为 $a_u + a_v$($a_i$ 为点权)。求该删边图的最小生成树。
- 难点:依然是点多边多,但这次是“补图”连通性问题。
思路 1:Borůvka + std::set 暴力回溯法 (牛客寒假营1 J .cpp)
- 边权是 $a_u + a_v$,所以对于任意点,最优的邻居就是剩余点中 $a_v$ 最小的那个。
- 用全局
set维护{a_i, i},里面存着 $a_i$ 最小的顺序。 - 遍历连通块内的点 $x$ 找最优出边时:
- 把当前连通块本身在
set里删掉。 - 排雷:把点 $x$ 的被删掉的边(无效边)在
set中也暂时删掉。 - 此时
set里的绝对最小值*s.begin()就是对 $x$ 来说合法且权值最小的边。 - 记录后,将“被删掉的边”回溯加回
set。
- 这种做法在 $m$ 不是特别极端的情况下,利用
set快速避开了被删掉的边。
点击查看代码
struct Node {
int x, y, w;
};
// Boruvka 算法核心函数
// 逻辑:把所有集合看成连通块 ->
// 遍历所有边找出每个连通块向外的最短边 ->
// 选最小的更新合并 ->
// 直到成为一个整体
//此题思路很简单我们只需要维护一个set存点和点权很明显第一个点就是最小的
//我们就找到了最小的边 对于一个连通块维护它最小的边存下来更新即可
//细节(因为有删边所以我们可以对set进行删除 回溯操作 集合和删边可能有重复不能多回溯了得记录一下1)
void solve() {
int ans = 0;
int n, m;
cin >> n >> m;
// 点权
vector<int> a(n + 1);
for (int i = 1; i <= n; i++)
cin >> a[i];
// set 维护总的集合
set<pair<int, int>> s;
for (int i = 1; i <= n; i++) {
s.insert({a[i], i});
}
// 删除的边
vector<vector<int>> g(n + 1);
for (int i = 1; i <= m; i++) {
int u, v;
cin >> u >> v;
g[u].push_back(v);
g[v].push_back(u);
}
vector<int> p(n + 1);
// 初始化并查集,最开始每个点都是一个独立的连通块
for (int i = 1; i <= n; i++)
p[i] = i;
auto find = [&](auto &&self, int x) -> int {
if (p[x] != x)
p[x] = self(self, p[x]);
return p[x];
};
int cnt = 0;
auto bor = [&]() {
bool f = 0;
// d[i] 存储以 i 为根的连通块,目前找到的向外连接的最短边的权值
// 连通块的最小边 x -> y 以及边权
vector<Node> mn(n + 1);
vector<int> d(n + 1, 1e18);
// 存连通块中的点
vector<vector<int>> id(n + 1);
for (int i = 1; i <= n; i++) {
id[find(find, i)].push_back(i);
}
// 从连通块中找小边
for(int i=1;i<=n;i++){
//只找根节点
if(id[i].empty()) continue;
//把这个连通块先删掉
for(int x : id[i]){
s.erase({a[x],x});
}
//遍历根节点的每个点
for(int x_id : id[i]){
//把每个点的删边删掉
//别把前面的块中元素回溯了要记录一下
vector<int> tmp;
for(int g_id : g[x_id]){
if(s.count({a[g_id],g_id}))
{ tmp.push_back(g_id);
s.erase({a[g_id],g_id});}
}
//对每个点算出最小值
if(!s.empty()){
auto [y_num,y_id] = *s.begin();
int w =a[x_id] + a[y_id];
if(d[i] > w){
d[i] = w;
mn[i] = {x_id,y_id,w};
}
}
//回溯
for(int g_id : tmp){
s.insert({a[g_id],g_id});
}
}
//回溯
for(int x : id[i]){
s.insert({a[x],x});
}
}
// 合并操作
for (int i = 1; i <= n; i++) {
auto [x, y, w] = mn[i];
int u = find(find, x);
int v = find(find, y);
if (u == v)
continue;
ans += w;
p[u] = v;
cnt ++ ;
f = 1;
}
if (!f)
return false;
else
return true;
};
while (bor());
if(cnt == n - 1)
cout << ans << endl;
else cout << -1 << endl;
}
思路 2:Prim + 线段树区间修改优化 (寒假营 J prim.cpp)
完全图删去几条边,意味着一个点 $v$ 和绝大多数点都是相连的。其合法的邻接点其实是整个区间 [1, n] 减去若干个不合法的点(挖空),即一段段连续的区间。
- 线段树定义:维护所有点到目前 MST 的最短距离(最小边权)及其对应的点编号。
- 初始将所有点距离设为无穷大。把点 $1$ 加入 MST。
- 每次通过线段树全局查询
[1, n]找到距离 MST 最近的点 $v$。 - 将 $v$ 从线段树中逻辑删除(距离设为 inf)。
- 核心更新:当 $v$ 加入 MST 后,需要用 $v$ 去更新其他没加入点的距离。由于 $v$ 和大多数点相连,我们将 $v$ 的不合法边排序后,把
[1, n]切割成若干个连续的合法区间(例如[lst+1, j-1])。 - 用线段树的区间操作 (Range Apply/Lazy Tag) 对这些合法区间内的点尝试更新距离:距离更新为 $a_v + a_i$。
- 循环 $n-1$ 次完成 MST 构建。线段树将区间更新和全局最小值的查询压缩到了 $O(\log n)$ 级别。
点击查看代码
// 考虑prim + 数据结构
// 快速处理对于每一个i 到确定集合的距离
// 考虑用线段树来维护
// Info : 考虑区间最小值的下标 和值
// 还要维护离MST的最小边 (u -> v 权值为 w)
struct Tag
{
int T_min = inf;
int id = -1;
//区间里的点
void apply(const Tag &t)
{
if(t.T_min < T_min){
T_min = t.T_min;
id = t.id;
}
}
};
struct Info
{
int mn = inf;
int i = -1;
//当前区间 离 集合 最近的边
int u = -1;
int v = -1;
int w = inf;
void apply(const Tag &t)
{
if(t.T_min + mn < w){
u = t.id;
v = i;
w = t.T_min + mn;
}
}
};
Info operator+(const Info &a, const Info &b)
{
Info res;
if(a.mn < b.mn){
res.mn = a.mn;
res.i = a.i;
}
else {
res.mn = b.mn;
res.i = b.i;
}
if(a.w < b.w){
res.u = a.u;
res.v = a.v;
res.w = a.w;
}
else{
res.u = b.u;
res.v = b.v;
res.w = b.w;
}
return res;
};
void solve()
{
int n, m;
cin >> n >> m;
vi a(n + 1);
for(int i = 1; i <= n; i++)
cin >> a[i];
vvi g(n + 1);
while(m--) {
int u, v;
cin >> u >> v;
g[u].push_back(v);
g[v].push_back(u);
}
LazySegmentTree<Info, Tag> seg(n + 1);
for(int i=1;i<=n;i++){
seg.modify(i,{a[i],i,-1,-1,inf});
}
//将 1 放入 集合
seg.modify(1,{a[1],1,1,1,0});
int ans = 0;
for(int i=1;i<=n ;i++){ //n - 1 次合并
//挑出全局最小边
auto info = seg.rangeQuery(1,n + 1);//全局最小边
if(info.w >= inf){
cout << -1 << endl;
return ;
}
ans += info.w;
//准备加入的点
int v = info.v;
//第一个全局最小值 肯定是 1 因为我们设定了 u -> v : w
//删除 v;
seg.modify(v,{inf,v,-1,-1,inf});
//更新其他点的
sort(g[v].begin(),g[v].end());
g[v].erase(unique(g[v].begin(),g[v].end()),g[v].end());
g[v].push_back(n + 1);
int lst = 0;
for(auto j : g[v]){
if(lst + 1 < j){
seg.rangeApply(lst + 1,j,{a[v],v});
}
lst = j;
}
}
cout << ans << endl;
}

浙公网安备 33010602011771号