最小生成树

前置知识并查集。

从一张图 \(G\) 中选择一个子图 \(S\),使得 \(S\) 是一棵树,并且 \(S\) 的权值和最小,这棵树称为最小生成树。

kruskal

kruskal 的核心思想是从小到大加入边,如果两条边不在一个连通块内,那么将这条边选入最小生成树,同时合并这两个块。

题目中一般会先求出一张图的最小生成树,然后再在最小生成树上操作,因此实现时我们可以先将所有边存起来,然后通过 kruskal 算法建出最小生成树,并在树上继续进行后续的算法。

P3366 【模板】最小生成树

AC 代码

#include <bits/stdc++.h>

using namespace std;

const int N=5000;
int n, m;

struct DSU {
	int n;
	vector <int> fa;
	DSU(int n): n(n) {
		fa.resize(n+1);
		iota(fa.begin(), fa.end(), 0);
	}
	int find(int x) {
		return fa[x]==x? fa[x]: fa[x]=find(fa[x]);
	}
	void merge(int x, int y) {
		x = find(x);
		y = find(y);
		fa[x] = y;
	}
};

int kruskal()
{
	vector <array<int,3>> e(m);
	for (auto& [w, u, v]: e) { cin >> u >> v >> w; }
	sort(e.begin(), e.end());
	int ans = 0, cnt = 0;

	DSU d(n);
	for (auto& [w, u, v]: e) { d.merge(u, v); }
	for (int i = 1; i <= n; ++i) {
		if (d.find(i) != d.find(1)) { return -1; }
	}

	d = DSU(n);
	for (auto& [w, u, v]: e) {
		if (d.find(u) != d.find(v)) {
			d.merge(u, v);
			ans += w;
			++cnt;
		}
		if (cnt == n-1) { break; }
	}
	return ans;
}

int main()
{
	cin >> n >> m;
	int res = kruskal();
	if (res == -1) { cout << "orz\n"; }
	else           { cout << res << "\n"; }
}

gym103729A. Nucleic Acid Test

这道题需要综合运用最短路以及最小生成树的知识。

求最小速度可以转化为求最长距离,\(v=\left\lceil\dfrac{l}{t}\right\rceil\)。

因为我们可以任意选择起点,所以走遍核酸点的最优方案是以每两个核酸点之间的最短路构建出的最小生成树。

然后考虑非核酸点。节点可以重复访问,我们只需要最小化从核酸点走出再走回的时间,因此对某个非核酸点节点来说,最优的方案是从最近的核酸点走出再走回。

由上述分析可知,我们需要走过的最长路径是最小生成树上最长的那条边,以及非核酸点到核酸点最短距离的两倍。根据 \(v=\left\lceil\dfrac{l}{t}\right\rceil\) 计算即可,注意特判 \(t=0\) 的情况。

AC 代码

#include <bits/stdc++.h>
using namespace std;
typedef long long ll;
typedef pair<int,ll> pii;
const int N=605;
const ll inf = LLONG_MAX;
int n, m, k;
ll t, dis[N], mnd[N];
bool vis[N], isSta[N];
vector<int> sta;
vector<pii> G[N];
vector<array<ll,3>> E;
struct DSU {
    int n;
    vector<int> f;
    DSU(int n): n(n), f(vector<int>(n+1)) {
        iota(f.begin(), f.end(), 0);
    }
    int find(int x) {
        return f[x]==x? f[x]: f[x]=find(f[x]);
    }
    bool same(int x, int y) {
        return find(x)==find(y);
    }
    void merge(int x, int y) {
        x = find(x);
        y = find(y);
        if (x == y) { return; }
        f[x] = y;
    }
};
void Dijkstra(int s)
{
    priority_queue<pii,vector<pii>,greater<pii>> q;
    for (int i = 1; i <= n; ++i) {
        dis[i] = inf;
        vis[i] = 0;
    }
    dis[s] = 0;
    q.push( { 0, s } );
    while (!q.empty()) {
        int u = q.top().second; q.pop();
        if (vis[u]) { continue; }
        vis[u] = 1;
        for (auto [v, w]: G[u]) {
            if (dis[v] > dis[u] + w) {
                dis[v] = dis[u] + w;
                if (!vis[v]) { q.push( { dis[v], v } ); }
            }
        }
    }
    for (int i = 1; i <= n; ++i) { mnd[i] = min(mnd[i], dis[i]); }
    for (auto x: sta) {
        if (s < x) { E.push_back( { dis[x], s, x } ); }
    }
}
ll ceilDiv(ll x, ll y) { return (x+y-1)/y; }
int main()
{
    cin >> n >> m >> k >> t;
    for (int i = 1; i <= n; ++i) {
        mnd[i] = inf;
    }
    int edgeCnt = 0;
    DSU ddd(n);
    for (int i = 1; i <= m; ++i) {
        ll a, b, c;
        cin >> a >> b >> c;
        G[a].emplace_back(b, c);
        G[b].emplace_back(a, c);
        if (!ddd.same(a, b)) {
            ddd.merge(a, b);
            ++edgeCnt;
        }
    }
    if (edgeCnt != n-1) { cout << "-1\n"; return 0; }
    if (t == 0) { cout << "-1\n"; return 0; }
    for (int i = 1; i <= k; ++i) {
        int x; cin >> x;
        sta.push_back(x);
        isSta[x] = true;
    }

    // 从核酸点到其他所有节点的距离 min
    for (auto x: sta) { Dijkstra(x); }

    // 核酸点之间跑 mst
    DSU d(n);
    ll mx = 0;
    sort(E.begin(), E.end());
    for (auto [w, u, v]: E) {
        if (!d.same(u, v)) {
            mx = max(mx, w);
            d.merge(u, v);
        }
    }

    // 距离 max 为 mx
    // v = ceilDiv(mx, t)
    for (int i = 1; i <= n; ++i) {
        if (!isSta[i]) { mx = max(mx, mnd[i]*2); }
    }
    cout << ceilDiv(mx, t) << "\n";
}

kruskal 算法的证明

考虑使用反证法,设按照 kruskal 算法得出的生成树为 \(T\),它和实际的生成树 \(T'\) 相差一条边 \(e\)。

从 \(T\) 生成 \(T'\) 的方式是,在基环树 \(T+e\) 的环上找到最小的一条边删去。

由于 kruskal 算法每次选择的都是最小的边,因此满足 \(e_T\leq e\),因此 \(T'\) 必然不优于 \(T\)。

上述证明方法称为“破环法”,kruskal 算法的证明可以自然地导出【瓶颈路】和【严格次大生成树】的算法。

【瓶颈路】和【严格次大生成树】需要前置知识:倍增 LCA。

gym105930H. Minimum Spanning Tree

其实就是 kruskal 算法。

最核心的观察,一定取 \(|u-v|=1\) 时最优。尝试使用破环法证明,新增一条边,一定是为了替换掉原来最大的那一条边,而满足 \(|u-v|=1\) 的一共有 \(n-1\) 条边,当删除原来的边后,若 \(i\) 和 \(i+1\) 不连通,则新增这条边,可以满足要求。

直接模拟替换的过程并不好写,尝试魔改 kruskal。

先连接所有原先边权为 \(0\) 的边,对剩下的边从小到大枚举尝试将其加入生成树,直到所需边数不超过 \(k\) 时换为用 \((i-1,i)\) 连接。

AC 代码

#include <bits/stdc++.h>
using namespace std;
typedef long long ll;

struct DSU {
    int n;
    vector<int> f;
    DSU(int n): n(n), f(n+1) {
        iota(f.begin(), f.end(), 0);
    }
    int find(int x) {
        return f[x]==x? x: f[x]=find(f[x]);
    }
    bool same(int x, int y) {
        return find(x) == find(y);
    }
    void merge(int x, int y) {
        x = find(x);
        y = find(y);
        if (x == y) { return; }
        f[x] = y;
    }
};

void solve()
{
    cerr << "test case #\n";
    int n, m, k;
    cin >> n >> m >> k;
    vector<array<int,4>> E(m);
    for (int i = 0; i < m; ++i) {
        auto& [w, u, v, s] = E[i];
        cin >> u >> v >> w;
        s = i + 1;
    }
    sort(E.begin(), E.end());
    DSU d(n);
    vector<int> ans;
    ll sum = 0;
    int need = n-1;
    for (auto [w, u, v, s]: E) {
        if (w > 0 && need <= k) { break; }
        if (!d.same(u, v)) {
            sum += w;
            d.merge(u, v);
            ans.push_back(s);
            --need;
        }
    }

    vector<pair<int,int>> add_edge;
    for (int i = 2; i <= n; ++i) {
        if (!d.same(i-1, i)) {
            d.merge(i-1, i);
            add_edge.emplace_back(i-1, i);
            ++sum;
        }
    }

    cout << add_edge.size() << "\n";
    for (auto [u, v]: add_edge) {
        cout << u << " " << v << "\n";
    }
    cout << sum << "\n";
    for (auto id: ans) {
        cout << id << " ";
    }
    for (int i = 1; i <= (int)add_edge.size(); ++i) {
        cout << m + i << " ";
    }
    cout << "\n";
}

int main()
{
    cin.tie(nullptr)->sync_with_stdio(false);
    int t = 1;
    cin >> t;
    while (t--) { solve(); }
}

次小生成树

在最小生成树的基础上增加一条边,记其边权为 \(w\),在形成的环上找到边权小于 \(w\) 且最大的那一条边删去。

对于之前没有加入最小生成树中的每一条边,考虑这样操作所形成的所有生成树,其中权值最小的那一棵即为次小生成树。

所谓“环”,实际上就是新加的一条边 \(u-v\),再加上原本生成树上 \(u,v\) 两点间的路径。我们可以通过树上倍增求解次小值。当然由于边权可能相同,如果想求出严格次小生成树,需要同时维护最小值和严格次小值。详见例题。

P4180 [BJWC2010] 严格次小生成树

在最小生成树的基础上增加一条边,记其边权为 \(w\),在形成的环上找到边权严格小于 \(w\) 且最大的那一条边删去。对于之前没有加入最小生成树中的每一条边,考虑这样操作所形成的所有生成树,其中权值最小的那一棵即为严格次小生成树。

严格小于 \(w\) 的那一条边,可以通过 \(u,v\) 两点到其 \(lca\) 路径上边的最大值和严格次大值求出。为求出这两个值,只需要修改一下倍增 LCA 的算法:在求出 LCA 的同时分别求出两点到其 LCA 路径上的最小值和严格次小值,再取较小即可。

注意特判重边。注意开 long long。注意 inf 不要开小。

AC 代码

#include <bits/stdc++.h>

using namespace std;
typedef long long ll;
typedef pair<int,ll> pil;

const int N=1e5+5, I=30;
int n, m, dep[N], f[I+5][N];
ll mx[I+5][N][2];
vector <pil> G[N];

struct dsu {
    int n;
    vector <int> fa;
    dsu(int n): n(n) {
        fa.assign(n+1, 0);
        iota(fa.begin(), fa.end(), 0);
    }
    int find(int x) {
        return fa[x]==x? fa[x]: fa[x]=find(fa[x]);
    }
    bool same(int x, int y) {
        return find(x) == find(y);
    }
    void merge(int x, int y) {
        x = find(x);
        y = find(y);
        fa[x] = y;
    }
};

void update(ll x, ll mx[2])
{
    if (x > mx[0]) {
        mx[1] = mx[0];
        mx[0] = x;
    } else if (x > mx[1] && x < mx[0]) {
        mx[1] = x;
    }
}

void dfs(int u, int fa)
{
    f[0][u] = fa;
    dep[u] = dep[fa] + 1;
    for (int i = 1; i <= I; ++i) {
        f[i][u] = f[i-1][ f[i-1][u] ];
        update(mx[i-1][u][0], mx[i][u]);
        update(mx[i-1][u][1], mx[i][u]);
        update(mx[i-1][ f[i-1][u] ][0], mx[i][u]);
        update(mx[i-1][ f[i-1][u] ][1], mx[i][u]);
    }
    for (auto [v,w]: G[u]) {
        if (v != fa) {
            update(w, mx[0][v]);
            dfs(v, u);
        }
    }
}

int getMxEdge(int u, int v, ll val)
{
    ll ans[2] = {0,0};
    auto jump = [&](int &u, int i) {
        update(mx[i][u][0], ans);
        update(mx[i][u][1], ans);
        u = f[i][u];
    };
    auto getAns = [&]() {
        if (ans[0] < val) { return ans[0]; }
        return ans[1];
    };
    if (dep[u] < dep[v]) { swap(u, v); }
    for (int i = I; i >= 0; --i) {
        if (dep[f[i][u]] >= dep[v]) { jump(u, i); }
    }
    if (u == v) {
        return getAns();
    }
    for (int i = I; i >= 0; --i) {
        if (f[i][u] != f[i][v]) { jump(u, i); jump(v, i); }
    }
    jump(u, 0);
    jump(v, 0);
    return getAns();
}

int main()
{
    cin.tie(nullptr)->sync_with_stdio(false);
    cin >> n >> m;
    vector <array<int,3>> E, H;
    for (int i = 1; i <= m; ++i) {
        int u, v, w;
        cin >> u >> v >> w;
        if (u == v) { continue; }
        E.push_back( { w, u, v } );
    }
    sort(E.begin(), E.end());
    dsu d(n);
    ll tr = 0;
    for (int i = 1; i <= E.size(); ++i) {
        auto [w, u, v] = E[i-1];
        if (!d.same(u,v)) {
            G[u].push_back( { v, w } );
            G[v].push_back( { u, w } );
            d.merge(u, v);
            tr += w;
        } else {
            H.push_back(E[i-1]);
        }
    }
    dfs(1, 0);
    ll ans = LLONG_MAX;
    for (auto [w, u, v]: H) {
        ans = min(ans, tr - getMxEdge(u, v, w) + w);
    }
    cout << ans << "\n";
}

最小瓶颈路

无向图 \(G\) 中从 \(x\) 到 \(y\) 的一条路径,满足路径上最大的边权在所有 \(x\) 到 \(y\) 的简单路径中最小。

P1967 [NOIP 2013 提高组] 货车运输

本题是最小瓶颈路模板题。

做法是 kruskal 重构树。对图上任意的两点,都可以在重构树上找到一条路径。使用破圈法尝试替换这两点间的路径只会使答案更劣。于是这棵树上两点间的路径即为所求。

为求出这个【最小边权】,只需要修改一下倍增 LCA 的算法:在求出 LCA 的同时分别求出两点到其 LCA 路径上的最小值,再取较小即可。

AC 代码

#include <bits/stdc++.h>

using namespace std;
typedef pair<int,int> pii;

const int N=1e4+5, I=20;
int n, m;
int f[I+5][N], mn[I+5][N], dep[N];
vector<pii> G[N];

struct dsu {
    int n;
    vector<int> f;
    dsu(int n): n(n) {
        f.resize(n+1);
        iota(f.begin(), f.end(), 0);
    }
    int find(int x) {
        return f[x]==x? f[x]: f[x]=find(f[x]);
    }
    bool same(int x, int y) {
        return find(x) == find(y);
    }
    void merge(int x, int y) {
        x = find(x);
        y = find(y);
        f[x] = y;
    }
};

void dfs(int u, int fa)
{
    f[0][u] = fa;
    dep[u] = dep[fa] + 1;
    for (int i = 1; i <= I; ++i) {
        f[i][u] = f[i-1][ f[i-1][u] ];
        mn[i][u] = min( mn[i-1][u], mn[i-1][ f[i-1][u] ] );
    }
    for (auto [v, w]: G[u]) {
        if (v != fa) {
            mn[0][v] = w;
            dfs(v, u);
        }
    }
}

int LCA(int u, int v)
{
    int ans = numeric_limits<int>::max();
    function<void(int&,int)> jump = [&](int &u, int i) {
        ans = min(ans, mn[i][u]);
        u = f[i][u];
    };
    if (dep[u] < dep[v]) { swap(u, v); }
    for (int i = I; i >= 0; --i) {
        if (dep[f[i][u]] >= dep[v]) { jump(u, i); }
    }
    if (u == v) { return ans; }
    for (int i = I; i >= 0; --i) {
        if (f[i][u] != f[i][v]) {
            jump(u, i); jump(v, i);
        }
    }
    jump(u, 0); jump(v, 0);
    return ans;
}

int main()
{
	cin.tie(nullptr)->sync_with_stdio(false);
    cin >> n >> m;
    vector<array<int,3>> E(m);
    for (auto& [w, u, v]: E) { cin >> u >> v >> w; }
    dsu d(n);
    sort(E.begin(), E.end(), greater<array<int,3>>());
    for (auto [w, u, v]: E) {
        if (!d.same(u, v)) {
            G[u].push_back( { v, w } );
            G[v].push_back( { u, w } );
            d.merge(u, v);
        }
    }
    for (int i = 1; i <= n; ++i) {
        if (!dep[i]) { dfs(i, 0); }
    }
    int q; cin >> q;
    while (q--) {
        int u, v; cin >> u >> v;
        if (!d.same(u, v)) { cout << "-1\n"; }
        else { cout << LCA(u, v) << "\n"; }
    }
}

E. Another Exercise on Graphs

本题是瓶颈路的另外一种做法,可以求解第 \(k\) 小的瓶颈路。这种解法和生成树无关,仅作为拓展。

考虑二分答案。二分所求的最小的第 \(k\) 大边权 \(x\),将边权不大于 \(x\) 的边重设为 \(0\),大于 \(x\) 的边重设为 \(1\),\(x\) 越大,新图上 \(a\) 到 \(b\) 最短路就越小。此时令 \(a\) 到 \(b\) 最短路小于等于 \(k\) 的最小的 \(x\) 即为所求。由于本题 \(n\) 较小,\(q\) 较大,考虑预处理出所有情况下的最短路。

\(n\leq 400\),考虑使用 Floyd。先处理出所有边权为 \(1\) 的情况,然后按旧图权值从小到大把新图中 \(u\to v\) 的权值从 \(1\) 变成 \(0\),枚举 \(i,j\) 使用这条边进行松弛操作,即

\[dis(i,j)\overset{\min}{\leftarrow}\min\begin{cases}dis(i,u)+dis(v,j)\\dis(i,v)+dis(u,j)\end{cases} \]

每轮松弛操作需要 \(O(n^2)\) 的时间。

对于 E1,可以每修改一条边,进行一次松弛操作,时间复杂度为 \(O(mn^2)\)。

对于 E2,发现如果新图上两点最短路已经为 \(0\) 了,修改这条边是没有意义的。因此仅当 \(u,v\) 两点最短路不为 \(0\) 时,将 \(u\to v\) 这条边的权值改为 \(0\),进行一轮 \(O(n^2)\) 的松弛操作。类似生成树,有效的修改只会进行 \(n-1\) 次,时间复杂度为 \(O(n^3)\)。

总时间复杂度 \(O(n^3+q\log n)\)。

AC 代码

#include <bits/stdc++.h>

using namespace std;

const int inf=1e9+7;

void solve()
{
    int n, m, q;
    cin >> n >> m >> q;
    vector dis(n, vector(n+1, vector<int>(n+1, inf)));
    vector<array<int,3>> e;
    for (int i = 1; i <= m; ++i) {
        int u, v, w;
        cin >> u >> v >> w;
        e.push_back( { w, u, v } );
        dis[0][u][v] = dis[0][v][u] = 1;
    }
    for (int i = 1; i <= n; ++i) { dis[0][i][i] = 0; }
    for (int k = 1; k <= n; ++k) {
        for (int i = 1; i <= n; ++i) {
            for (int j = 1; j <= n; ++j) {
                dis[0][i][j] = min(dis[0][i][j], dis[0][i][k]+dis[0][k][j]);
            }
        }
    }
    sort(e.begin(), e.end());
    int tot = 0;
    vector<int> val(n);
    for (int k = 0; k < m; ++k) {
        auto [w, u, v] = e[k];
        if (dis[tot][u][v] == 0) { continue; }
        val[++tot] = w;
        for (int i = 1; i <= n; ++i) {
            for (int j = 1; j <= n; ++j) {
                dis[tot][i][j] = dis[tot-1][i][j];
            }
        }
        dis[tot][u][v] = 0;
        for (int i = 1; i <= n; ++i) {
            for (int j = 1; j <= n; ++j) {
                dis[tot][i][j] = min( {
                    dis[tot][i][j],
                    dis[tot][i][u] + dis[tot][v][j],
                    dis[tot][i][v] + dis[tot][u][j]
                } );
            }
        }
    }
    while (q--) {
        int a, b, k;
        cin >> a >> b >> k;
        int L=0, R=tot;
        while (L != R-1) {
            int mid = (L+R) >> 1;
            if (dis[mid][a][b] < k) { R = mid; }
            else { L = mid; }
        }
        cout << val[R] << " ";
    }
    cout << "\n";
}

int main()
{
    int t; cin >> t;
    while (t--) { solve(); }
}
posted @ 2025-04-19 09:50  LittleDrinks  阅读(69)  评论(0)    收藏  举报