MST

最小生成树(MST)进阶算法与优化笔记

1. 基础模板:Borůvka 算法核心思想

来源文件boruvka.cpp

  • 问题抽象:给定 $n$ 个点和 $m$ 条边的普通图,求最小生成树。
  • 算法思路
    Borůvka 算法是多源并行的 MST 算法,非常适合解决边数极多(如完全图)但边权有规律的连通性问题。
  1. 初始化:将每个点视为一个独立的连通块(用并查集维护)。
  2. 找最小边:遍历所有边,为每个连通块找到一条向外连接(连接到其他连通块)的权值最小的边。
  3. 合并:将找到的这些最小边加入 MST,并合并相应的连通块。
  4. 迭代:重复步骤 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$)或者取绝对的最小值。

  1. 用全局 set 维护所有的 {点权, 节点编号}
  2. 在 Borůvka 的每一轮中,遍历连通块:
  • 先将该连通块内的所有点从全局 set 中暂时删除。
  • 对连通块内的每个点 $x$,在 set 中利用 lower_bound 寻找大于等于 $k - a_x$ 的最小值;如果找不到,就取 set 中绝对最小的元素(即 *s.begin())。
  • 记录该连通块能找到的最优向外边。
  • 回溯(将连通块内的点重新加回 set)。
  1. 合并连通块。利用 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 是把一个点的所有边扔进优先队列,这里因为是完全图,不能全扔,采取“动态最优加边”策略。

  1. set 维护尚未加入 MST 的点
  2. 优先队列存候选边。初始随便选一个点,找它最优的邻居扔进队列。
  3. 每次从队列弹出一权值最小的边(假设连向新点 $v$)。如果 $v$ 还没在树中:
  • 把 $v$ 标记为已加入(从 set 中删除)。
  • 关键动态加边:为新点 $v$ 在剩余的 set 中找一个最优的邻居加入队列;同时,也为当前的出发点 $u$ 在剩余的 set 中再找一个次优的邻居补进队列。
  1. 这样保证了优先队列里始终只有 $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}$)。求让所有地点都有水的最小总成本。
  • 思路:经典的点权转边权技巧。
  1. 引入超级源点(虚点):假设存在一个编号为 $n+1$ 的虚拟水源。
  2. 在地点 $i$ 挖井,等价于从虚点 $n+1$ 连一条边到点 $i$,边权为 $W_i$。
  3. 地点间的管道等价于普通的边,边权为 $P_{i,j}$。
  4. 跑一遍包含虚点在内的 $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)

  1. 边权是 $a_u + a_v$,所以对于任意点,最优的邻居就是剩余点中 $a_v$ 最小的那个。
  2. 用全局 set 维护 {a_i, i},里面存着 $a_i$ 最小的顺序。
  3. 遍历连通块内的点 $x$ 找最优出边时:
  • 把当前连通块本身在 set 里删掉。
  • 排雷:把点 $x$ 的被删掉的边(无效边)在 set 中也暂时删掉。
  • 此时 set 里的绝对最小值 *s.begin() 就是对 $x$ 来说合法且权值最小的边。
  • 记录后,将“被删掉的边”回溯加回 set
  1. 这种做法在 $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] 减去若干个不合法的点(挖空),即一段段连续的区间。

  1. 线段树定义:维护所有点到目前 MST 的最短距离(最小边权)及其对应的点编号。
  2. 初始将所有点距离设为无穷大。把点 $1$ 加入 MST。
  3. 每次通过线段树全局查询 [1, n] 找到距离 MST 最近的点 $v$。
  4. 将 $v$ 从线段树中逻辑删除(距离设为 inf)。
  5. 核心更新:当 $v$ 加入 MST 后,需要用 $v$ 去更新其他没加入点的距离。由于 $v$ 和大多数点相连,我们将 $v$ 的不合法边排序后,把 [1, n] 切割成若干个连续的合法区间(例如 [lst+1, j-1])。
  6. 用线段树的区间操作 (Range Apply/Lazy Tag) 对这些合法区间内的点尝试更新距离:距离更新为 $a_v + a_i$。
  7. 循环 $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;
}

posted @ 2026-05-16 18:17  r_123  阅读(21)  评论(0)    收藏  举报