← 返回

最小生成树学习记录

前置知识和定义

在开始学习最小生成树之前,先了解一些前置知识。

子图

对于一张图 $G=(V,E)$,如果存在一张图 $H=(V’,E’)$ 满足 $V’\subseteq V$ 且 $E’\subseteq E$,则称 $H$ 是 $G$ 的一个子图。记作 $H\subseteq G$。

生成子图

如果存在 $H \subseteq G$ 满足 $V’=V$,则称 $H$ 是 $G$ 的一个生成子图

生成子图又称为支撑子图。

生成树

生成树:一个无向连通图的生成子图,且同时要求是树。也即在图的边集选择了 $n-1$ 条边,将图中的所有顶点连接起来。

最小生成树

我们定义,一个无向连通图的 最小生成树,是指这个图中边权和最小的生成树。

Kruskal 算法

Kruskal 算法是一种常见简单的用于求最小生成树的算法。此算法的基本思想就是贪心的从小到大加入边。

算法流程

  1. 对图中的所有边按边权从小到大排序。
  2. 从小到大枚举每一条边 $(u,v)$,如果 $u$ 和 $v$ 不在同一连通块中,就加入这条边,并将 $u$ 和 $v$ 所在的连通块合并。
  3. 重复步骤 2 直到加入了 $n-1$ 条边。

显然,我们需要使用并查集来维护每个点所在的连通块。那么,此算法的时间复杂度就为 $O(m\log m)$,其中 $m$ 是边数。

证明

归纳法证明

我们使用数学归纳法证明 Kruskal 算法的正确性。

基础:算法开始前,显然成立。

归纳:假设算法在加入了 $k-1$ 条边后,生成了一个最小生成树 $T$。我们需要证明,加入第 $k$ 条边 $(u,v)$ 后,生成的最小生成树 $T’$ 也成立。

我们可以分两种情况讨论:

  1. 如果 $(u,v)$ 不在 $T$ 中,那么 $T’$ 就是 $T$ 加上 $(u,v)$ 这条边。显然,$T’$ 是一个最小生成树。
  2. 如果 $(u,v)$ 在 $T$ 中,那么 $T’$ 就是 $T$ 去掉 $(u,v)$ 这条边,加上 $(u,v)$ 这条边的最小生成树。根据归纳假设,$T$ 是一个最小生成树,所以 $T’$ 也是一个最小生成树。

反证法

我们使用反证法证明 Kruskal 算法的正确性。

假设 Kruskal 算法得到的生成树 $T$ 不是最小生成树,那么必然存在某个最小生成树 $T_{opt}$,使得 $w(T_{opt}) < w(T)$,其中 $w(\cdot)$ 表示生成树的边权和。

设 $e_k = (u,v)$ 是 Kruskal 算法选择的第一个不在 $T_{opt}$ 中的边(按边权从小到大排序后的第 $k$ 条边)。由于 Kruskal 算法会选择这条边,说明在加入 $e_k$ 之前,$u$ 和 $v$ 不在同一个连通块中。

现在考虑 $T_{opt} \cup {e_k}$,这个图必然包含一个环(因为 $T_{opt}$ 是生成树,加入任何边都会形成环)。这个环中至少包含一条边 $e’$ 满足 $w(e’) \geq w(e_k)$(因为 $e_k$ 是连接这两个连通块的最小权边)。

我们可以构造一个新的生成树 $T’ = T_{opt} \cup {e_k} \setminus {e’}$。显然:

但是这与 $T_{opt}$ 是最小生成树的假设矛盾,因为我们找到了一个权值不大于 $T_{opt}$ 的生成树 $T’$,且 $T’$ 包含了 $e_k$。

通过重复这个过程,我们可以证明 Kruskal 算法得到的生成树 $T$ 必须是最小生成树,从而证明了算法的正确性。

代码实现

#include <bits/stdc++.h>
using namespace std;
// #define MULTI_CASES
#define ll long long
#define int ll
#define endl '\n'
#define vi vector<int>
#define PII pair<int, int>
const int MaxN = 2e5 + 100;
const int INF = 1e9;
const int mod = 1e9 + 7;
int T = 1, N, M;
int a[MaxN];
// vector<PII> G1[MaxN];
struct edge
{
    int x, y, z;
} e[MaxN];
bool cmp(edge a, edge b)
{
    return a.z < b.z;
}
int fa[MaxN];
struct MST
{
    vector<int> fn;
    MST(int n)
    {
        for (int i = 0; i <= n; i++)
        {
            fn.push_back(i);
        }
    }
    int find(int x)
    {
        if (fn[x] == x)
        {
            return x;
        }
        return fn[x] = find(fn[x]);
    }
    void join(int x, int y)
    {
        int fx = find(x), fy = find(y);
        if (fx != fy)
        {
            fn[fx] = fy;
        }
    }
    void kruskal(){
        int cnt=0,ans=0;
        sort(e+1,e+M+1,cmp);
        for(int i=1;i<=M;i++){
            int u=find(e[i].x);
            int v=find(e[i].y);
            if(u==v)continue;
            fn[u]=v;
            ans+=e[i].z;
            cnt++;
            if(cnt>N-1)break;
        }
        if(cnt==N-1){
            cout<<ans<<endl;           
        }
        else{
            cout<<"orz"<<endl;
        }
    }
};
inline void Solve()
{
    cin >> N >> M;
    MST mst(N);

    for (int i = 1; i <= M; i++)
    {
        cin >> e[i].x >> e[i].y >> e[i].z;
    }
    mst.kruskal();
}
signed main()
{
#ifdef NOI_IO
    freopen(".in", "r", stdin);
    freopen(".out", "w", stdout);
#endif
    ios::sync_with_stdio(0);
    cin.tie(nullptr), cout.tie(nullptr);
#ifdef MULTI_CASES
    cin >> T;
    while (T--)
#endif
        Solve();
    return 0;
}

Prim 算法

Prim 算法是另一种常见的求最小生成树的算法,它的基本思想是从一个点开始,不断加点。

算法流程

  1. 选择图中的任意一个点作为起点,将其加入生成树。
  2. 从生成树中选择一个点 $u$,将 $u$ 到其他所有点的边加入候选边集合。
  3. 从候选边集合中选择一条边 $(u,v)$,权值最小,将 $v$ 加入生成树。
  4. 重复步骤 2 和 3,直到生成树包含所有点。

优化

如果直接暴力枚举所有边,时间复杂度是 $O(n^2)$,但是我们可以使用类似堆优化Dijkstra的方法,将时间复杂度优化到 $O(m\log n)$。

代码实现

#include <bits/stdc++.h>
using namespace std;
// #define MULTI_CASES
#define ll long long
#define int ll
#define endl '\n'
#define vi vector<int>
#define PII pair<int, int>
const int MaxN = 2e5 + 100;
const int INF = 0x3f3f3f3f;
const int mod = 1e9 + 7;
int T = 1, N, M;
vector<PII> G1[MaxN];
int dist[MaxN];
bool vis[MaxN];
inline void Solve()
{
    cin >> N >> M;
    for (int i = 1; i <= M; i++)
    {
        int u, v, w;
        cin >> u >> v >> w;
        G1[u].push_back({v, w});
        G1[v].push_back({u, w});
    }
    priority_queue<PII, vector<PII>, greater<PII>> pq;
    memset(dist, 0x3f, sizeof(dist));
    dist[1] = 0;
    pq.push({0, 1});
    int ans = 0;
    while (!pq.empty())
    {
        auto [d, u] = pq.top();
        pq.pop();
        if (vis[u])
            continue;
        vis[u] = 1;
        ans += d;
        for (auto [v, w] : G1[u])
        {
            if (!vis[v] && dist[v] > w)
            {
                dist[v] = w;
                pq.push({w, v});
            }
        }
    }
    for (int i = 1; i <= N; ++i)
    {
        if (!vis[i])
        {
            cout << "orz" << endl;
            return;
        }
    }
    cout << ans << endl;
}
signed main()
{
#ifdef NOI_IO
    freopen(".in", "r", stdin);
    freopen(".out", "w", stdout);
#endif
    ios::sync_with_stdio(0);
    cin.tie(nullptr), cout.tie(nullptr);
#ifdef MULTI_CASES
    cin >> T;
    while (T--)
#endif
        Solve();
    return 0;
}

本文采用 CC BY-NC 4.0 许可协议,转载请注明出处并保持非商业用途。