最小生成树

最小生成树是有边权的无向图中的一个常见问题。

在无向图 G(V,E) 中,把连通而且不含有环路的一个子图称为一棵生成树,它包含全部 n 个点和 n-1 条边。边权之和最小的树称为最小生成树(Minimal Spanning Tree, MST)。

MST 的计算用到了一个基本性质:一个图的 MST 一定包含图中权值最小的边。这使得可以用贪心法构造 MST,因为 MST 问题满足贪心法的“最优性原理”,即全局最优包含局部最优。

图的两个基本元素是点和边,与此对应,有两种算法可以构造 MST,这两种算法都基于贪心法。

例题:P3366 【模板】最小生成树

现有一张 n 个点 m 条边的无向图,求其最小生成树边权之和;如果图不连通则输出 orz
输入格式: 第一行包含两个整数 n,m。接下来 m 行每行三个整数 \(x_i,y_i,z_i\),表示存在一条从 \(x_i\)\(y_i\) 的无向边,边权为 \(z_i\)
输出格式: 一行一个整数,表示最小生成树边权之和;如果图不连通则输出 orz
数据范围:\(1 \le n \le 5 \times 10^3, 1 \le m \le 2 \times 10^5, 1 \le z \le 10^4\)

解法 1(Prim 算法): 不妨先假设图连通。由于所有点最后都会在最小生成树中,所以不妨任意设置一个起点(这里设为 \(1\))。该算法的大体思路是从点 \(1\) 开始通过贪心不断向外扩展当前的树,直到所有点都进入生成树中。用一个 dis 数组来记录到目前为止当前树中的点到其他点的最短边长。

image

  1. 在初始状态下,先将整个 dis 数组设为 ∞,表示未更新。
  2. 先将 1 号点放入生成树中并打上标记。此时当前的树中只有一个点,没有边。接着通过 1 号点来更新 dis 数组。这里有一个结论:任何时刻,未标记的点中 dis 最小者,其 dis 值所对应的边一定在一棵最小生成树中。
  3. 容易发现,此时 2 号点的 dis 值是没有标记的点中最小的。根据结论,2 号点的 dis 值所对应的边(即 1 到 2 的边)一定在最小生成树中。将 2 打上标记并用与 2 号点相连的边来更新 dis 数组。
  4. 此时 4 号点的 dis 值是没有标记的点中最小的。根据结论,4 号点的 dis 值所对应的边(即 2 到 4 的边)一定在最小生成树中。此时,将 4 号点放进最小生成树(打上标记),并用与其相连的边来更新 dis 数组。
  5. 最后,剩余 3 号点一个点。根据结论,3 号点 dis 的值所对应的边(即 2 到 3 的边)一定在最小生成树中。将 3 号点放入最小生成树中并打上标记,程序结束。

在求最小生成树的同时累加生成树各边的边权,可以得到答案为 5。

结论: 任何时刻,未标记的点中 dis 最小者,其 dis 值所对应的边(设为 \(e_1\))一定在一棵最小生成树中。

证明: 可以采用反证法。假设最小生成树已经建完,此时再将 \(e_1\) 边加入最小生成树中,则树中出现了一个环。由于这条边连接的是一个已标记的点与未标记的点,则环中显然还有一条边 \(e_2\) 也是连接一个已标记的点与一个未标记的点。此时用 \(e_1\)\(e_2\) 进行替换,得到的结果一定更优。
如果此时的 \(e_1\) 边权和 \(e_2\) 相等,那将 \(e_1\)\(e_2\) 替换同样是一棵最小生成树。对于这种情况两条边可以任选一条放入最小生成树,对答案没有影响。这也印证了题目中最小生成树可能不止一棵的说法。

这种求最小生成树的方法是 Prim 算法,其过程概括如下:

  1. 将 1 号点加入当前生成树中并打上标记,同时更新 dis 数组;
  2. 选择所有未标记的点中 dis 最小的点;
  3. 将该点加入当前生成树中并打上标记;
  4. 使用与该点相连的所有连边更新 dis 数组;
  5. 重复第 2 到第 4 步,直到所有点都已经被标记或所有未标记的点都不存在连边与已标记的点直接相连。

需要注意的是,如果出现所有未标记的点都不存在连边与已标记的点直接相连的情况就意味着图不连通。此时也应当结束算法。可以再记录一个变量 cnt,代表打上标记的点的数量。如果最后 \(cnt \ne n\),则可以判定无解。参考代码如下:

#include <cstdio>
#include <vector>
#include <utility>
using namespace std; 
typedef pair<int, int> PII;
const int N = 5005;
const int INF = 1e9;
vector<PII> graph[N]; // <y, z>
bool vis[N];
int dis[N];
int main()
{
    int n, m; scanf("%d%d", &n, &m);
    for (int i = 1; i <= m; i++) {
        int x, y, z; scanf("%d%d%d", &x, &y, &z);
        graph[x].push_back({y, z});
        graph[y].push_back({x, z});
    }
    for (int i = 0; i <= n; i++) dis[i] = INF; // 初始化dis数组
    dis[1] = 0;
    int cnt = 0, ans = 0;
    while (true) {
        int u = 0;
        for (int i = 1; i <= n; i++) // 寻找dis最小的点
            if (!vis[i] && dis[i] < dis[u]) u = i;
        if (u == 0) break;
        vis[u] = true; cnt++; ans += dis[u]; // 将边加入生成树并将点标记
        for (PII e : graph[u]) {
            int v = e.first, w = e.second;
            if (w < dis[v]) dis[v] = w; // 更新dis数组
        } 
    }
    if (cnt < n) printf("orz\n");
    else printf("%d\n", ans);
    return 0;
}

容易分析得出这段程序的时间复杂度为 \(O(n^2+m)\),在本题中可以通过。但如果 \(n\) 较大则可能出现超时的情况。

可以用一个堆来优化,每次更新 dis 数组后将 dis 值与对应点存入一个优先队列,依据 dis 值排序。这样就可以将时间复杂度优化到 \(O(m \log m)\)

#include <cstdio>
#include <vector>
#include <utility>
#include <queue>
using namespace std;
typedef pair<int, int> PII;
const int N = 5005;
const int INF = 1e9;
vector<PII> graph[N]; // <y,z>
bool vis[N];
int dis[N];
int main()
{
    int n, m; scanf("%d%d", &n, &m);
    for (int i = 1; i <= m; i++) {
        int x, y, z; scanf("%d%d%d", &x, &y, &z);
        graph[x].push_back({y, z});
        graph[y].push_back({x, z});
    }
    for (int i = 1; i <= n; i++) dis[i] = INF;
    // 用于寻找dis最小的点的优先队列
    priority_queue<PII, vector<PII>, greater<PII>> q; // <weight, node_id>
    q.push({0, 1}); dis[1] = 0; 
    int cnt = 0, ans = 0;
    while (!q.empty()) {
        PII cur = q.top(); q.pop(); // 取出dis最小的点
        int u = cur.second;
        if (vis[u]) continue; // 如果已经标记过就跳过
        vis[u] = true; cnt++; ans += dis[u];
        for (PII e : graph[u]) {
            int v = e.first, w = e.second;
            if (w < dis[v]) {
                dis[v] = w; q.push({w, v}); // 更新成功后更新优先队列
            }
        }
    }
    if (cnt < n) printf("orz\n");
    else printf("%d\n", ans);
    return 0;
}

解法 2(Kruskal 算法): 同样地,不妨假设图连通。该算法的大体思路是直接对边进行排序,然后从小到大进行合并。可以想象,在程序运行的过程中将会出现很多的连通块,并且连通块的数量会越来越小。在最后它们会合并为同一个连通块,即最终的最小生成树。

image

  1. 最开始时,所有的点和边都没有被选中。这里同样有一个结论,任何时刻,所有未使用的边中边权最小的一条,如果它所连接的两个点不在同一个连通块中,则这条边一定在最小生成树中。此时,将所有的边按照长度从小到大排序。
  2. 选择目前所有边中边权最小的一条(即 3 到 5 的边)。由于 3 和 5 目前不在同一个连通块中,根据结论,这条边一定在一棵最小生成树中。
  3. 类似地,接下来枚举到 1 和 2 的连边,以及 3 和 4 的连边。根据结论,这两条边也在最小生成树中。
  4. 接下来枚举到的是 4 和 5 的连边。然而此时发现,4 和 5 已经连通,所以这条边不在最小生成树中,可以直接将它舍弃。
  5. 最后,枚举到连接 1 和 3 的连边。将这条边加入最小生成树,发现整张图已经连通。

结论: 所有未使用的边中边权最小的一条(设为 \(e_1\)),如果它所连接的两个点不在同一个连通块中,则这条边已经在一棵最小生成树中。

证明: 类似于 Prim 算法的证明,假设最小生成树已经建完,此时再将 \(e_1\) 边加入最小生成树中,则树中出现了一个环。由于这条边连接的是两个未连通的点,则环中显然还有一条边 \(e_2\) 也未被标记,显然 \(e_1\) 替换 \(e_2\) 更优。同理,若 \(e_1\)\(e_2\) 边权相同,则互相替换不影响答案。

对于连通块的记录,可以使用一个并查集进行处理。最初每个点自成一个集合,每当一条边加入生成树时就合并其所连接的两个点所属的集合即可。对于无解的判断,只需要判断合并的次数是否为 \(n-1\) 次即可。

这种求最小生成树的算法是 Kruskal 算法,其过程概括如下:

  1. 将所有的边从小到大排序;
  2. 将所有点分别放入各自的并查集中;
  3. 选择所有未使用的边中边权最小的;
  4. 若该边所连接的两个点已经连通,则舍去,否则合并这两个并查集;
  5. 重复第 3 到 4 步,直到所有点都已经被标记或所有未标记的点都不存在连边与已标记的点直接相连。

因为 Kruskal 算法的瓶颈在于将所有的边排序,所以时间复杂度为 \(O(m \log m)\)

#include <cstdio>
#include <algorithm>
using namespace std;
const int N = 5005;
const int M = 200005;
struct Edge {
    int x, y, z;
    bool operator<(const Edge& other) const {
        return z < other.z;
    }
};
Edge e[M];
int fa[N];
int query(int x) {
    return fa[x] == x ? x : fa[x] = query(fa[x]); // 并查集查询(路径压缩)
}
int main()
{
    int n, m; scanf("%d%d", &n, &m);
    for (int i = 1; i <= n; i++) fa[i] = i;
    for (int i = 1; i <= m; i++) scanf("%d%d%d", &e[i].x, &e[i].y, &e[i].z);
    sort(e + 1, e + m + 1); // 将边排序
    int ans = 0, cnt = 0;
    for (int i = 1; i <= m; i++) {
        int qx = query(e[i].x), qy = query(e[i].y);
        if (qx != qy) { // 合并两个不在同一连通块的点
            cnt++; ans += e[i].z; fa[qx] = qy; // 将边加入最小生成树并统计答案
        }
    }
    if (cnt < n - 1) printf("orz\n");
    else printf("%d\n", ans);
    return 0;
}

在稠密图中,原版的 Prim 算法时间复杂度较优,但在稀疏图中,堆优化的 Prim 以及 Kruskal 算法表现会更好。在应用时,可以根据实际情况选择合适的算法。

习题:P4047 [JSOI2010] 部落划分

解题思路

求出这个完全图的最小生成树。因为最小生成树上每一条边删去后两部分之间的最短距离就是这条边的边权本身,所以一种最优的部落划分方案就是删去最小生成树中边权前 \(k-1\) 大的边。也就是说,答案为最小生成树中边权第 \(k-1\) 大的那条边的边权。本题为稠密图,可以用 Prim 算法求出最小生成树后按边权排序即可得到答案,时间复杂度为 \(O(n^2+m)\)

参考代码
#include <cstdio>
#include <cmath>
#include <algorithm>
using namespace std;
const int N = 1005;
const double INF = 1e9;
double dis[N], a[N][N], mst[N];
int x[N], y[N];
bool vis[N];
double calc(double dx, double dy) {
    return sqrt(dx * dx + dy * dy);
}
int main()
{
    int n, k; scanf("%d%d", &n, &k);
    for (int i = 1; i <= n; i++) scanf("%d%d", &x[i], &y[i]);
    for (int i = 1; i <= n; i++)
        for (int j = i + 1; j <= n; j++) {
            a[i][j] = calc(x[i] - x[j], y[i] - y[j]);
            a[j][i] = a[i][j];
        }
    for (int i = 0; i <= n; i++) dis[i] = INF;
    int cnt = 0; dis[1] = 0;
    while (true) {
        int u = 0;
        for (int i = 1; i <= n; i++) 
            if (!vis[i] && dis[i] < dis[u]) u = i;
        if (u == 0) break;
        vis[u] = true; cnt++; mst[cnt] = dis[u];
        for (int i = 1; i <= n; i++) {
            if (a[u][i] < dis[i]) dis[i] = a[u][i];
        }
    }
    sort(mst + 1, mst + cnt + 1);
    printf("%.2f\n", mst[n - k + 2]);
    return 0;
}
posted @ 2024-04-05 20:53  RonChen  阅读(11)  评论(0编辑  收藏  举报