LogoCSP Wiki By Yundou
4 Graph

最小生成树

定义

G=(V,E,w)G = (V, E, w) 为一个连通无向带权图,其中:

  • VV 是顶点集,
  • EE 是边集,
  • w:ERw: E \to \mathbb{R} 是边权函数(赋予每条边一个实数权重)。

**生成树(Spanning Tree)**是 GG 的一个子图 T=(V,ET)T = (V, E_T),满足:

  1. TT 包含 GG 的所有顶点(即顶点集与 GG 相同);
  2. TT 是一棵树(连通、无环,且恰好有 V1|V| - 1 条边)。

若生成树 TT 的所有边权之和 w(T)=eETw(e)w(T) = \sum_{e \in E_T} w(e)GG 的所有生成树中最小,则称 TTGG最小生成树

补充说明

  1. 存在性
    当且仅当图 GG 是连通图时,最小生成树存在。对于非连通图,可通过**最小生成森林(Minimum Spanning Forest)**覆盖各连通分量。

  2. 唯一性
    最小生成树不一定唯一。若图中存在多条权值相同的边,可能存在不同的最小生成树,但它们的边权总和必定相等。

Kruskal 算法

Kruskal 算法是一种常见并且好写的最小生成树算法,由 Kruskal 发明。该算法的基本思想是从小到大加入边,是个贪心算法。

前置知识

Kruskal算法基于并查集来实现。

实现

图示:

图片描述

伪代码:

1Input. The edges of the graph e, where each element in e is (u,v,w) denoting that there is an edge between u and v weighted w.2Output. The edges of the MST of the input graph.3Method. 4result5sort e into nondecreasing order by weight w6for each (u,v,w) in the sorted e7if u and v are not connected in the union-find set 8connect u and v in the union-find set9resultresult   {(u,v,w)}10return result\begin{array}{ll} 1 & \textbf{Input. } \text{The edges of the graph } e , \text{ where each element in } e \text{ is } (u, v, w) \\ & \text{ denoting that there is an edge between } u \text{ and } v \text{ weighted } w . \\ 2 & \textbf{Output. } \text{The edges of the MST of the input graph}.\\ 3 & \textbf{Method. } \\ 4 & result \gets \varnothing \\ 5 & \text{sort } e \text{ into nondecreasing order by weight } w \\ 6 & \textbf{for} \text{ each } (u, v, w) \text{ in the sorted } e \\ 7 & \qquad \textbf{if } u \text{ and } v \text{ are not connected in the union-find set } \\ 8 & \qquad\qquad \text{connect } u \text{ and } v \text{ in the union-find set} \\ 9 & \qquad\qquad result \gets result\;\bigcup\ \{(u, v, w)\} \\ 10 & \textbf{return } result \end{array}

算法虽简单,但需要相应的数据结构来支持……具体来说,维护一个森林,查询两个结点是否在同一棵树中,连接两棵树。

抽象一点地说,维护一堆 集合,查询两个元素是否属于同一集合,合并两个集合。

其中,查询两点是否连通和连接两点可以使用并查集维护。

如果使用 O(mlogm)O(m\log m) 的排序算法,并且使用 O(mα(m,n))O(m\alpha(m, n))O(mlogn)O(m\log n) 的并查集,就可以得到时间复杂度为 O(mlogm)O(m\log m) 的 Kruskal 算法。

证明

思路很简单,为了造出一棵最小生成树,我们从最小边权的边开始,按边权从小到大依次加入,如果某次加边产生了环,就扔掉这条边,直到加入了 n1n-1 条边,即形成了一棵树。

证明:使用归纳法,证明任何时候 K 算法选择的边集都被某棵 MST 所包含。

基础:对于算法刚开始时,显然成立(最小生成树存在)。

归纳:假设某时刻成立,当前边集为 FF,令 TT 为这棵 MST,考虑下一条加入的边 ee

如果 ee 属于 TT,那么成立。

否则,T+eT+e 一定存在一个环,考虑这个环上不属于 FF 的另一条边 ff(一定只有一条)。

首先,ff 的权值一定不会比 ee 小,不然 ff 会在 ee 之前被选取。

然后,ff 的权值一定不会比 ee 大,不然 T+efT+e-f 就是一棵比 TT 还优的生成树了。

所以,T+efT+e-f 包含了 FF,并且也是一棵最小生成树,归纳成立。

例题

口袋的天空

nn 朵云,你要将它们连成 kk 个棉花糖,将 XiX_i 云朵和 YiY_i 连接起来需要 LiL_i 的代价,求最小代价。

#include <algorithm>
#include <iostream>
using namespace std;
 
int fa[1010];  // 定义父亲
int n, m, k;
 
struct edge {
  int u, v, w;
};
 
int l;
edge g[10010];
 
void add(int u, int v, int w) {
  l++;
  g[l].u = u;
  g[l].v = v;
  g[l].w = w;
}
 
// 标准并查集
int findroot(int x) { return fa[x] == x ? x : fa[x] = findroot(fa[x]); }
 
void Merge(int x, int y) {
  x = findroot(x);
  y = findroot(y);
  fa[x] = y;
}
 
bool cmp(edge A, edge B) { return A.w < B.w; }
 
// Kruskal 算法
void kruskal() {
  int tot = 0;  // 存已选了的边数
  int ans = 0;  // 存总的代价
  for (int i = 1; i <= m; i++) {
    int xr = findroot(g[i].u), yr = findroot(g[i].v);
    if (xr != yr) {   // 如果父亲不一样
      Merge(xr, yr);  // 合并
      tot++;          // 边数增加
      ans += g[i].w;  // 代价增加
    }
    if (tot >= (n - k)) {  // 检查选的边数是否满足 k 个棉花糖
      cout << ans << '\n';
      return;
    }
  }
  cout << "No Answer\n";  // 无法连成
}
 
int main() {
  cin >> n >> m >> k;
  for (int i = 1; i <= n; i++) {  // 初始化
    fa[i] = i;
  }
  for (int i = 1; i <= m; i++) {
    int u, v, w;
    cin >> u >> v >> w;
    add(u, v, w);  // 添加边
  }
  sort(g + 1, g + m + 1, cmp);  // 先按边权排序
  kruskal();
  return 0;
}

Prim 算法

Prim 算法是另一种常见并且好写的最小生成树算法。该算法的基本思想是从一个结点开始,不断加点(而不是 Kruskal 算法的加边)。

实现

图示:

图片描述

具体来说,每次要选择距离最小的一个结点,以及用新的边更新其他结点的距离。

其实跟 Dijkstra 算法一样,每次找到距离最小的一个点,可以暴力找也可以用堆维护。

堆优化的方式类似 Dijkstra 的堆优化,但如果使用二叉堆等不支持 O(1)O(1) decrease-key 的堆,复杂度就不优于 Kruskal,常数也比 Kruskal 大。所以,一般情况下都使用 Kruskal 算法,在稠密图尤其是完全图上,暴力 Prim 的复杂度比 Kruskal 优,但 不一定 实际跑得更快。

暴力:O(n2+m)O(n^2+m)

二叉堆:O((n+m)logn)O((n+m) \log n)

Fib 堆:O(nlogn+m)O(n \log n + m)

伪代码:

1Input. The nodes of the graph V ; the function g(u,v) whichmeans the weight of the edge (u,v); the function adj(v) whichmeans the nodes adjacent to v.2Output. The sum of weights of the MST of the input graph.3Method.4result05choose an arbitrary node in V to be the root6dis(root)07for each node v(V{root})8dis(v)9restV10while rest11curthe node with the minimum dis in rest12resultresult+dis(cur)13restrest{cur}14for each node vadj(cur)15dis(v)min(dis(v),g(cur,v))16return result\begin{array}{ll} 1 & \textbf{Input. } \text{The nodes of the graph }V\text{ ; the function }g(u, v)\text{ which}\\ & \text{means the weight of the edge }(u, v)\text{; the function }adj(v)\text{ which}\\ & \text{means the nodes adjacent to }v.\\ 2 & \textbf{Output. } \text{The sum of weights of the MST of the input graph.} \\ 3 & \textbf{Method.} \\ 4 & result \gets 0 \\ 5 & \text{choose an arbitrary node in }V\text{ to be the }root \\ 6 & dis(root)\gets 0 \\ 7 & \textbf{for } \text{each node }v\in(V-\{root\}) \\ 8 & \qquad dis(v)\gets\infty \\ 9 & rest\gets V \\ 10 & \textbf{while } rest\ne\varnothing \\ 11 & \qquad cur\gets \text{the node with the minimum }dis\text{ in }rest \\ 12 & \qquad result\gets result+dis(cur) \\ 13 & \qquad rest\gets rest-\{cur\} \\ 14 & \qquad \textbf{for}\text{ each node }v\in adj(cur) \\ 15 & \qquad\qquad dis(v)\gets\min(dis(v), g(cur, v)) \\ 16 & \textbf{return } result \end{array}

注意:上述代码只是求出了最小生成树的权值,如果要输出方案还需要记录每个点的 disdis 代表的是哪条边。

// 使用二叉堆优化的 Prim 算法。
#include <cstring>
#include <iostream>
#include <queue>
using namespace std;
constexpr int N = 5050, M = 2e5 + 10;
 
struct E {
  int v, w, x;
} e[M * 2];
 
int n, m, h[N], cnte;
 
void adde(int u, int v, int w) { e[++cnte] = E{v, w, h[u]}, h[u] = cnte; }
 
struct S {
  int u, d;
};
 
bool operator<(const S &x, const S &y) { return x.d > y.d; }
 
priority_queue<S> q;
int dis[N];
bool vis[N];
 
int res = 0, cnt = 0;
 
void Prim() {
  memset(dis, 0x3f, sizeof(dis));
  dis[1] = 0;
  q.push({1, 0});
  while (!q.empty()) {
    if (cnt >= n) break;
    int u = q.top().u, d = q.top().d;
    q.pop();
    if (vis[u]) continue;
    vis[u] = true;
    ++cnt;
    res += d;
    for (int i = h[u]; i; i = e[i].x) {
      int v = e[i].v, w = e[i].w;
      if (w < dis[v]) {
        dis[v] = w, q.push({v, w});
      }
    }
  }
}
 
int main() {
  cin >> n >> m;
  for (int i = 1, u, v, w; i <= m; ++i) {
    cin >> u >> v >> w, adde(u, v, w), adde(v, u, w);
  }
  Prim();
  if (cnt == n)
    cout << res;
  else
    cout << "No MST.";
  return 0;
}

证明

从任意一个结点开始,将结点分成两类:已加入的,未加入的。

每次从未加入的结点中,找一个与已加入的结点之间边权最小值最小的结点。

然后将这个结点加入,并连上那条边权最小的边。

重复 n1n-1 次即可。

证明:还是说明在每一步,都存在一棵最小生成树包含已选边集。

基础:只有一个结点的时候,显然成立。

归纳:如果某一步成立,当前边集为 FF,属于 TT 这棵 MST,接下来要加入边 ee

如果 ee 属于 TT,那么成立。

否则考虑 T+eT+e 中环上另一条可以加入当前边集的边 ff

首先,ff 的权值一定不小于 ee 的权值,否则就会选择 ff 而不是 ee 了。

然后,ff 的权值一定不大于 ee 的权值,否则 T+efT+e-f 就是一棵更小的生成树了。

因此,eeff 的权值相等,T+efT+e-f 也是一棵最小生成树,且包含了 FF

例题:

On this page