Chương 3·Bài 37

Minimum Spanning Tree

Đồ thị vô hướng liên thông có trọng số trên cạnh. Một cây khung là tập n−1n - 1 cạnh nối mọi đỉnh mà không tạo chu trình; cây khung nhỏ nhất (minimum spanning tree, MST) là cây khung có tổng trọng số nhỏ nhất. Hai thuật toán chuẩn là Kruskal (sắp cạnh, dùng DSU từ bài 23) và Prim (mọc dần từ một đỉnh như Dijkstra). Cả hai đều dựa trên hai tính chất của cạnh: tính chất cắt cho biết cạnh nào chắc chắn được dùng, tính chất chu trình cho biết cạnh nào có thể bỏ.

Kruskal và tính chất cắt

Tính chất cắt. Chia đỉnh thành hai phần bất kỳ; cạnh nhẹ nhất nối hai phần thuộc một MST nào đó. Nếu một cây khung không dùng nó, thêm nó vào tạo chu trình, chu trình đó phải có một cạnh khác cũng nối hai phần, nặng hơn hoặc bằng; bỏ cạnh đó ra được một cây khung không tệ hơn.

Tính chất chu trình. Cạnh nặng nhất của một chu trình luôn bỏ được: có một MST không dùng nó, vì đi vòng qua phần còn lại của chu trình chỉ tốn các cạnh không nặng hơn. Mục cuối bài dùng kỹ tính chất này.

Kruskal. Sắp cạnh tăng theo trọng số, lần lượt xét: hai đầu khác thành phần thì lấy cạnh và gộp hai thành phần, cùng thành phần thì bỏ. Mỗi cạnh được lấy là cạnh nhẹ nhất nối thành phần của một đầu với phần còn lại, nên đúng theo tính chất cắt. Chi phí O(mlog⁡m)O(m \log m) cho việc sắp, DSU gần như O(1)O(1) mỗi cạnh. Kết thúc với ít hơn n−1n - 1 cạnh nghĩa là đồ thị không liên thông.

Cạnh xanh đậm đã vào cây, cạnh đứt bị bỏ vì hai đầu đã cùng thành phần. Gõ cạnh khác hoặc kéo đỉnh để thử.

Hỏi nhanh

Kruskal đang xét cạnh (u,v)(u, v) nặng 7 và thấy u,vu, v đã cùng thành phần. Vì sao bỏ cạnh này không làm mất cây khung nhỏ nhất?

Ví dụMạng Kết Nối Tiết Kiệm

Đề. n≤105n \le 10^5 trạm, m≤2⋅105m \le 2 \cdot 10^5 tuyến có chi phí. Tìm tổng chi phí nhỏ nhất để nối mọi trạm, hoặc IMPOSSIBLE.

Lời giải. Kruskal: duyệt cạnh theo chi phí tăng dần, lấy cạnh nào nối hai thành phần khác nhau. Đúng theo tính chất cắt: cạnh nhẹ nhất đi qua lát cắt giữa một thành phần và phần còn lại luôn thuộc một cây khung nhỏ nhất. Đếm số cạnh đã lấy; chưa đủ n−1n - 1 cạnh thì đồ thị không liên thông.

Bước 1: DSU. find nén đường đi; unite hợp theo kích thước và trả về có hợp được hay không.

C++
ll find(ll u) {
    return par[u] == u ? u : par[u] = find(par[u]);
}

bool unite(ll u, ll v) {
    u = find(u);
    v = find(v);
    if (u == v) return 0;
    if (sz[u] < sz[v]) swap(u, v);
    par[v] = u;
    sz[u] += sz[v];
    return 1;
}

Bước 2: sắp cạnh theo chi phí. Mỗi cạnh là array (chi phí, u, v), đặt chi phí ở đầu để sort sắp theo nó.

C++
for (ll i = 1; i <= m; i++) cin >> e[i][1] >> e[i][2] >> e[i][0];
sort(e + 1, e + m + 1);

Bước 3: lấy cạnh.

C++
for (ll i = 1; i <= m; i++)
    if (unite(e[i][1], e[i][2])) {
        cost += e[i][0];
        cnt++;
    }

Tổng tới 101410^{14}, cần ll.

Code đầy đủ
#include <bits/stdc++.h>
using namespace std;
using ll = long long;

const ll N = 1e5 + 5, M = 2e5 + 5;
ll n, m, par[N], sz[N];
array<ll, 3> e[M];

ll find(ll u) {
    return par[u] == u ? u : par[u] = find(par[u]);
}

bool unite(ll u, ll v) {
    u = find(u);
    v = find(v);
    if (u == v) return 0;
    if (sz[u] < sz[v]) swap(u, v);
    par[v] = u;
    sz[u] += sz[v];
    return 1;
}

signed main() {
    ios::sync_with_stdio(false);
    cin.tie(0);

    cin >> n >> m;
    for (ll i = 1; i <= n; i++) par[i] = i, sz[i] = 1;
    for (ll i = 1; i <= m; i++) cin >> e[i][1] >> e[i][2] >> e[i][0];
    sort(e + 1, e + m + 1);
    ll cost = 0, cnt = 0;
    for (ll i = 1; i <= m; i++)
        if (unite(e[i][1], e[i][2])) {
            cost += e[i][0];
            cnt++;
        }
    if (cnt < n - 1) cout << "IMPOSSIBLE\n";
    else cout << cost << '\n';
}
4 5
1 2 4
1 3 9
2 3 2
2 4 7
3 4 3

O(mlog⁡m)O(m \log m) cho phần sắp.

Gộp đỉnh và dừng sớm

Nguồn chung. “Mỗi thành phần chứa đúng một nhà máy” là cây khung của đồ thị trong đó mọi nhà máy được gộp thành một đỉnh: mỗi thành phần nối với nhà máy của nó tương đương cả mạng nối với đỉnh gộp. Với DSU, chỉ cần gộp sẵn các nhà máy trước khi chạy Kruskal. Cùng cách đó, “xây trạm tại đỉnh ii tốn cic_i” là cạnh từ một đỉnh ảo 0 tới ii nặng cic_i.

Dừng sớm. Kruskal gộp thành phần theo thứ tự cạnh tăng dần. Dừng khi còn đúng kk thành phần thì được rừng kk cây có tổng nhỏ nhất, cũng là MST bỏ đi k−1k - 1 cạnh nặng nhất. Đó là cách phân cụm theo khoảng cách: cạnh tiếp theo không được lấy là khoảng cách nhỏ nhất giữa hai cụm.

Ví dụLưới Điện Nhiều Nguồn

Đề. n≤1000n \le 1000 thành phố, kk thành phố có nhà máy, m≤105m \le 10^5 phương án cáp. Chọn cáp sao cho mỗi thành phần chứa đúng một nhà máy, tổng chi phí nhỏ nhất.

Lời giải. Gộp mọi nhà máy vào cùng một thành phần ngay từ đầu, coi cả nhóm như một đỉnh duy nhất, rồi chạy Kruskal. Một cáp nối hai nhà máy luôn bị bỏ vì hai đầu đã cùng thành phần. Kết quả là cây khung của đồ thị đã gộp; tách nhóm nhà máy ra lại thì mỗi thành phần chứa đúng một nhà máy.

Bước 1: gộp các nhà máy. Nối mỗi nhà máy vào thành phần của nhà máy đầu tiên.

C++
ll first = 0;
for (ll i = 1; i <= k; i++) {
    ll x;
    cin >> x;
    if (!first) first = x;
    par[find(x)] = find(first);
}

Bước 2: Kruskal như thường.

C++
sort(e + 1, e + m + 1);
for (ll i = 1; i <= m; i++) {
    ll u = find(e[i][1]), v = find(e[i][2]);
    if (u == v) continue;
    par[u] = v;
    cost += e[i][0];
}
Code đầy đủ
#include <bits/stdc++.h>
using namespace std;
using ll = long long;

const ll N = 1005, M = 1e5 + 5;
ll n, m, k, par[N];
array<ll, 3> e[M];

ll find(ll u) {
    return par[u] == u ? u : par[u] = find(par[u]);
}

signed main() {
    ios::sync_with_stdio(false);
    cin.tie(0);

    cin >> n >> m >> k;
    for (ll i = 1; i <= n; i++) par[i] = i;
    ll first = 0;
    for (ll i = 1; i <= k; i++) {
        ll x;
        cin >> x;
        if (!first) first = x;
        par[find(x)] = find(first);
    }
    for (ll i = 1; i <= m; i++) cin >> e[i][1] >> e[i][2] >> e[i][0];
    sort(e + 1, e + m + 1);
    ll cost = 0;
    for (ll i = 1; i <= m; i++) {
        ll u = find(e[i][1]), v = find(e[i][2]);
        if (u == v) continue;
        par[u] = v;
        cost += e[i][0];
    }
    cout << cost << '\n';
}
5 6 2
1 5
1 2 3
2 3 2
3 4 6
4 5 1
2 4 5
1 5 100

O(mlog⁡m)O(m \log m).

Ví dụTách Mạng Thành Hai Vùng

Đề. Mạng liên thông n≤105n \le 10^5 điểm, m≤106m \le 10^6 đường có chi phí. Giữ lại một số đường sao cho mạng chia thành đúng hai nhóm liên thông, tổng chi phí nhỏ nhất.

Lời giải. Mỗi nhóm cần ít nhất một cây khung, nên đáp án là một rừng hai cây có tổng nhỏ nhất. Rừng đó chính là cây khung nhỏ nhất bỏ đi cạnh nặng nhất: Kruskal dừng sớm khi còn đúng hai thành phần, tức sau khi lấy n−2n - 2 cạnh.

Bước 1: Kruskal dừng sớm. Điều kiện vòng lặp cnt < n - 2. Đồ thị liên thông nên luôn lấy đủ n−2n - 2 cạnh.

C++
for (ll i = 1; i <= m && cnt < n - 2; i++) {
    ll u = find(e[i][1]), v = find(e[i][2]);
    if (u == v) continue;
    par[u] = v;
    cost += e[i][0];
    cnt++;
}

Đọc 10610^6 cạnh nên hai dòng tăng tốc nhập xuất là cần.

Code đầy đủ
#include <bits/stdc++.h>
using namespace std;
using ll = long long;

const ll N = 1e5 + 5, M = 1e6 + 5;
ll n, m, par[N];
array<ll, 3> e[M];

ll find(ll u) {
    return par[u] == u ? u : par[u] = find(par[u]);
}

signed main() {
    ios::sync_with_stdio(false);
    cin.tie(0);

    cin >> n >> m;
    for (ll i = 1; i <= n; i++) par[i] = i;
    for (ll i = 1; i <= m; i++) cin >> e[i][1] >> e[i][2] >> e[i][0];
    sort(e + 1, e + m + 1);
    ll cost = 0, cnt = 0;
    for (ll i = 1; i <= m && cnt < n - 2; i++) {
        ll u = find(e[i][1]), v = find(e[i][2]);
        if (u == v) continue;
        par[u] = v;
        cost += e[i][0];
        cnt++;
    }
    cout << cost << '\n';
}
6 8
1 2 3
2 3 2
1 3 6
3 4 8
4 5 1
5 6 4
4 6 5
2 5 7

Sắp 10610^6 cạnh: O(mlog⁡m)O(m \log m).

Prim cho đồ thị dày

Prim mọc cây từ một đỉnh: giữ dvd_v là cạnh rẻ nhất nối vv với cây hiện tại; mỗi bước lấy đỉnh ngoài cây có dd nhỏ nhất, thêm vào cây với cạnh đó, rồi cập nhật dd của các đỉnh khác. Mỗi bước đúng theo tính chất cắt (cắt giữa cây và phần còn lại). Với đồ thị đầy đủ n2n^2 cạnh, bản không dùng hàng đợi, mỗi bước tìm min bằng một vòng lặp, tốn O(n2)O(n^2), tốt hơn Kruskal O(n2log⁡n)O(n^2 \log n) và không cần lưu danh sách cạnh. Đồ thị thưa thì dùng hàng đợi ưu tiên như Dijkstra, O(mlog⁡n)O(m \log n).

Cây mọc từ đỉnh 1; nhãn trên đỉnh ngoài cây là cạnh rẻ nhất nối nó với cây.

Ví dụMạng Kết Nối Dày

Đề. n≤1000n \le 1000 nút, chi phí nối mọi cặp cho bằng ma trận n×nn \times n. Tìm tổng chi phí nhỏ nhất để nối mọi nút.

Lời giải. Đồ thị đầy đủ có 5⋅1055 \cdot 10^5 cạnh; Kruskal phải sắp hết chúng. Prim trực tiếp trên ma trận chỉ tốn O(n2)=106O(n^2) = 10^6 phép và không cần danh sách cạnh: mọc cây từ đỉnh 1, mỗi bước thêm đỉnh ngoài cây nối với cây bằng cạnh rẻ nhất.

Bước 1: nhãn của đỉnh ngoài cây. d[v] là cạnh rẻ nhất nối vv với cây hiện tại, in[v] đánh dấu đã vào cây. Bắt đầu với d[1] = 0.

C++
for (ll i = 1; i <= n; i++) d[i] = INF;
d[1] = 0;

Bước 2: nn vòng. Mỗi vòng tìm đỉnh ngoài cây có d nhỏ nhất bằng một vòng lặp O(n)O(n), đưa vào cây và cộng d của nó, rồi cập nhật d của các đỉnh còn lại qua cạnh từ đỉnh vừa thêm.

C++
for (ll it = 1; it <= n; it++) {
    ll u = 0;
    for (ll v = 1; v <= n; v++)
        if (!in[v] && (!u || d[v] < d[u])) u = v;
    in[u] = 1;
    cost += d[u];
    for (ll v = 1; v <= n; v++)
        if (!in[v]) d[v] = min(d[v], c[u][v]);
}
Code đầy đủ
#include <bits/stdc++.h>
using namespace std;
using ll = long long;

const ll N = 1005, INF = 1e18;
ll n, c[N][N], d[N], in[N];

signed main() {
    ios::sync_with_stdio(false);
    cin.tie(0);

    cin >> n;
    for (ll i = 1; i <= n; i++)
        for (ll j = 1; j <= n; j++) cin >> c[i][j];
    for (ll i = 1; i <= n; i++) d[i] = INF;
    d[1] = 0;
    ll cost = 0;
    for (ll it = 1; it <= n; it++) {
        ll u = 0;
        for (ll v = 1; v <= n; v++)
            if (!in[v] && (!u || d[v] < d[u])) u = v;
        in[u] = 1;
        cost += d[u];
        for (ll v = 1; v <= n; v++)
            if (!in[v]) d[v] = min(d[v], c[u][v]);
    }
    cout << cost << '\n';
}
5
0 4 9 8 7
4 0 2 6 5
9 2 0 3 10
8 6 3 0 1
7 5 10 1 0

O(n2)O(n^2).

Chỉ giữ cạnh có thể dùng

Đồ thị đầy đủ n=105n = 10^5 đỉnh có 5⋅1095 \cdot 10^9 cạnh, không thể liệt kê. Nhưng theo tính chất chu trình, cạnh nặng nhất của bất kỳ chu trình nào cũng bỏ được. Khi trọng số có cấu trúc, thường chỉ O(n)O(n) cạnh có thể thuộc MST. Với chi phí min⁡(∣xi−xj∣,∣yi−yj∣)\min(|x_i - x_j|, |y_i - y_j|): sắp theo xx, cạnh giữa hai điểm không kề nhau theo xx có phần ∣xi−xj∣|x_i - x_j| bằng tổng các khoảng kề ở giữa, mỗi khoảng là một cạnh nhẹ hơn hoặc bằng, nên chỉ cần n−1n - 1 cạnh kề theo xx cộng n−1n - 1 cạnh kề theo yy.

Hỏi nhanh

Ba thành phố có hoành độ 1,4,101, 4, 10 (sau khi sắp). Vì sao cạnh theo trục xx giữa thành phố đầu và thành phố cuối, nặng 99, không cần đưa vào danh sách?

Ví dụNối Thành Phố Theo Trục

Đề. n≤105n \le 10^5 thành phố trên mặt phẳng, đường giữa hai thành phố tốn min⁡(∣xi−xj∣,∣yi−yj∣)\min(|x_i - x_j|, |y_i - y_j|). Tìm tổng chi phí nhỏ nhất để nối tất cả.

Lời giải. Không thể xét n2n^2 cặp. Nhận xét: chỉ cần các cạnh giữa hai thành phố kề nhau khi sắp theo xx (chi phí hiệu xx) và kề nhau khi sắp theo yy (chi phí hiệu yy), tổng 2(n−1)2(n - 1) cạnh. Chi phí dùng cho một cạnh có thể lớn hơn chi phí thật min⁡(∣Δx∣,∣Δy∣)\min(|\Delta x|, |\Delta y|), nhưng cùng cặp đó, hoặc một đường rẻ không kém, cũng có mặt trong danh sách của trục kia, nên tổng nhỏ nhất không đổi.

Bước 1: cạnh theo từng trục. id sắp theo xx, thêm n−1n - 1 cạnh giữa hai thành phố kề nhau; rồi sắp lại theo yy và làm tương tự.

C++
sort(id + 1, id + n + 1, byx);
for (ll i = 1; i < n; i++) e.push_back({x[id[i + 1]] - x[id[i]], id[i], id[i + 1]});
sort(id + 1, id + n + 1, byy);
for (ll i = 1; i < n; i++) e.push_back({y[id[i + 1]] - y[id[i]], id[i], id[i + 1]});

Bước 2: Kruskal trên 2(n−1)2(n - 1) cạnh.

C++
sort(e.begin(), e.end());
for (array<ll, 3> t : e) {
    ll u = find(t[1]), v = find(t[2]);
    if (u == v) continue;
    par[u] = v;
    cost += t[0];
}
Code đầy đủ
#include <bits/stdc++.h>
using namespace std;
using ll = long long;

const ll N = 1e5 + 5;
ll n, x[N], y[N], id[N], par[N];
vector<array<ll, 3>> e;

ll find(ll u) {
    return par[u] == u ? u : par[u] = find(par[u]);
}

bool byx(ll a, ll b) {
    return x[a] < x[b];
}

bool byy(ll a, ll b) {
    return y[a] < y[b];
}

signed main() {
    ios::sync_with_stdio(false);
    cin.tie(0);

    cin >> n;
    for (ll i = 1; i <= n; i++) {
        cin >> x[i] >> y[i];
        id[i] = par[i] = i;
    }
    sort(id + 1, id + n + 1, byx);
    for (ll i = 1; i < n; i++) e.push_back({x[id[i + 1]] - x[id[i]], id[i], id[i + 1]});
    sort(id + 1, id + n + 1, byy);
    for (ll i = 1; i < n; i++) e.push_back({y[id[i + 1]] - y[id[i]], id[i], id[i + 1]});
    sort(e.begin(), e.end());
    ll cost = 0;
    for (array<ll, 3> t : e) {
        ll u = find(t[1]), v = find(t[2]);
        if (u == v) continue;
        par[u] = v;
        cost += t[0];
    }
    cout << cost << '\n';
}
5
0 0
5 2
6 10
12 9
20 3

O(nlog⁡n)O(n \log n).

Cạnh nào vào cây

Tính chất chu trình. Nếu cạnh (u,v,w)(u, v, w) nặng hơn hẳn mọi cạnh trên một đường từ uu tới vv, nó không thuộc MST nào. Hệ quả: với trọng số khác nhau, cạnh (u,v,w)(u, v, w) thuộc MST khi và chỉ khi các cạnh nhẹ hơn ww chưa nối được uu với vv, tức lúc Kruskal tới ww thì uu và vv còn khác thành phần. Nhiều câu hỏi “thêm cạnh này thì nó có vào cây không” giải ngoại tuyến: trộn cạnh câu hỏi vào danh sách đã sắp, tới cạnh câu hỏi thì chỉ kiểm DSU mà không gộp.

Cây nhỏ nhất bắt buộc chứa một cạnh. Thêm (u,v,w)(u, v, w) vào MST tạo một chu trình gồm nó và đường (u,v)(u, v) trên cây; bỏ cạnh nặng nhất trên đường đó. Tổng mới là MST−max⁡(u,v)+w\text{MST} - \max(u, v) + w, với max⁡(u,v)\max(u, v) là cạnh nặng nhất trên đường trong cây, tính bằng LCA kèm max (bài 36).

Ví dụCạnh Thử Trong Cây Khung

Đề. Đồ thị liên thông n,m≤2⋅105n, m \le 2 \cdot 10^5 với trọng số khác nhau, q≤2⋅105q \le 2 \cdot 10^5 câu hỏi độc lập: thêm cạnh (u,v,w)(u, v, w) thì nó có thuộc MST không.

Lời giải. Cạnh (u,v,w)(u, v, w) thêm vào được Kruskal chọn khi và chỉ khi các cạnh nhẹ hơn ww chưa nối được uu với vv. Vậy trộn cạnh gốc và cạnh câu hỏi, sắp theo trọng số (mọi trọng số khác nhau nên không có hòa), rồi chạy Kruskal: gặp cạnh gốc thì gộp, gặp cạnh câu hỏi thì chỉ kiểm hai đầu còn khác thành phần không. Không gộp cạnh câu hỏi, vì các câu hỏi độc lập.

Bước 1: một danh sách chung. Mỗi phần tử là (trọng số, u, v, chỉ số câu hỏi); cạnh gốc có chỉ số 0.

C++
for (ll i = 1; i <= m; i++) {
    ll a, b, c;
    cin >> a >> b >> c;
    e.push_back({c, a, b, 0});
}
for (ll i = 1; i <= q; i++) {
    ll u, v, w;
    cin >> u >> v >> w;
    e.push_back({w, u, v, i});
}

Bước 2: Kruskal có câu hỏi xen kẽ.

C++
sort(e.begin(), e.end());
for (array<ll, 4> t : e) {
    ll u = find(t[1]), v = find(t[2]);
    if (t[3]) ans[t[3]] = (u != v);
    else if (u != v) par[u] = v;
}
Code đầy đủ
#include <bits/stdc++.h>
using namespace std;
using ll = long long;

const ll N = 2e5 + 5;
ll n, m, q, par[N], ans[N];
vector<array<ll, 4>> e;

ll find(ll u) {
    return par[u] == u ? u : par[u] = find(par[u]);
}

signed main() {
    ios::sync_with_stdio(false);
    cin.tie(0);

    cin >> n >> m >> q;
    for (ll i = 1; i <= n; i++) par[i] = i;
    for (ll i = 1; i <= m; i++) {
        ll a, b, c;
        cin >> a >> b >> c;
        e.push_back({c, a, b, 0});
    }
    for (ll i = 1; i <= q; i++) {
        ll u, v, w;
        cin >> u >> v >> w;
        e.push_back({w, u, v, i});
    }
    sort(e.begin(), e.end());
    for (array<ll, 4> t : e) {
        ll u = find(t[1]), v = find(t[2]);
        if (t[3]) ans[t[3]] = (u != v);
        else if (u != v) par[u] = v;
    }
    for (ll i = 1; i <= q; i++) cout << (ans[i] ? "Yes" : "No") << '\n';
}
5 6 5
1 2 2
2 3 5
1 3 9
3 4 12
4 5 15
2 5 20
1 3 1
1 3 3
1 3 7
2 4 10
1 5 25

O((m+q)log⁡(m+q))O((m + q) \log (m + q)).

Ví dụCây Khung Bắt Buộc Từng Cạnh

Đề. Đồ thị liên thông n,m≤2⋅105n, m \le 2 \cdot 10^5. Với từng cạnh, in tổng nhỏ nhất của một cây khung bắt buộc chứa cạnh đó.

Lời giải. Lấy cây khung nhỏ nhất (tổng SS). Bắt buộc có cạnh (u,v,w)(u, v, w): thêm nó vào cây tạo đúng một chu trình, gồm cạnh đó và đường (u,v)(u, v) trên cây; bỏ cạnh nặng nhất của đường cũ cho cây tốt nhất chứa cạnh này. Đáp án là S−max⁡đường(u,v)+wS - \max_{\text{đường}}(u, v) + w. Cạnh thuộc cây có cạnh nặng nhất trên đường chính bằng nó, nên đáp án là SS.

Bước 1: Kruskal dựng cây. Sắp chỉ số cạnh theo trọng số, cộng SS và thêm cạnh được chọn vào danh sách kề của cây.

C++
for (ll k = 1; k <= m; k++) {
    ll i = id[k], u = find(eu[i]), v = find(ev[i]);
    if (u == v) continue;
    par[u] = v;
    S += ew[i];
    g[eu[i]].push_back({ev[i], ew[i]});
    g[ev[i]].push_back({eu[i], ew[i]});
}

Bước 2: bảng nhảy kèm cạnh nặng nhất. BFS trên cây: up[0][v] là cha, mx[0][v] là trọng số cạnh vv với cha; các tầng sau gộp mx như bài 36.

C++
for (ll j = 1; j < LG; j++)
    for (ll v = 1; v <= n; v++) {
        up[j][v] = up[j - 1][up[j - 1][v]];
        mx[j][v] = max(mx[j - 1][v], mx[j - 1][up[j - 1][v]]);
    }

Bước 3: cạnh nặng nhất trên đường. pmax(a, b) đi đúng hai pha tìm LCA, gộp mx của mỗi đoạn nhảy và hai cạnh cuối.

C++
ll pmax(ll a, ll b) {
    ll res = 0;
    if (dep[a] < dep[b]) swap(a, b);
    for (ll j = LG - 1; j >= 0; j--)
        if (dep[a] - (1LL << j) >= dep[b]) {
            res = max(res, mx[j][a]);
            a = up[j][a];
        }
    if (a == b) return res;
    for (ll j = LG - 1; j >= 0; j--)
        if (up[j][a] != up[j][b]) {
            res = max({res, mx[j][a], mx[j][b]});
            a = up[j][a];
            b = up[j][b];
        }
    return max({res, mx[0][a], mx[0][b]});
}

Trọng số và tổng tới 101410^{14}, dùng long long.

Code đầy đủ
#include <bits/stdc++.h>
using namespace std;
using ll = long long;

const ll N = 2e5 + 5, LG = 18;
ll n, m, par[N], dep[N], up[LG][N], mx[LG][N], eu[N], ev[N], ew[N], id[N];
vector<pair<ll, ll>> g[N];

ll find(ll u) {
    return par[u] == u ? u : par[u] = find(par[u]);
}

bool cmp(ll a, ll b) {
    return ew[a] < ew[b];
}

ll pmax(ll a, ll b) {
    ll res = 0;
    if (dep[a] < dep[b]) swap(a, b);
    for (ll j = LG - 1; j >= 0; j--)
        if (dep[a] - (1LL << j) >= dep[b]) {
            res = max(res, mx[j][a]);
            a = up[j][a];
        }
    if (a == b) return res;
    for (ll j = LG - 1; j >= 0; j--)
        if (up[j][a] != up[j][b]) {
            res = max({res, mx[j][a], mx[j][b]});
            a = up[j][a];
            b = up[j][b];
        }
    return max({res, mx[0][a], mx[0][b]});
}

signed main() {
    ios::sync_with_stdio(false);
    cin.tie(0);

    cin >> n >> m;
    for (ll i = 1; i <= m; i++) {
        cin >> eu[i] >> ev[i] >> ew[i];
        id[i] = i;
    }
    for (ll i = 1; i <= n; i++) par[i] = i;
    sort(id + 1, id + m + 1, cmp);
    ll S = 0;
    for (ll k = 1; k <= m; k++) {
        ll i = id[k], u = find(eu[i]), v = find(ev[i]);
        if (u == v) continue;
        par[u] = v;
        S += ew[i];
        g[eu[i]].push_back({ev[i], ew[i]});
        g[ev[i]].push_back({eu[i], ew[i]});
    }
    vector<ll> ord = {1};
    up[0][1] = 1;
    for (ll i = 0; i < (ll)ord.size(); i++) {
        ll u = ord[i];
        for (pair<ll, ll> e : g[u])
            if (e.first != up[0][u]) {
                up[0][e.first] = u;
                mx[0][e.first] = e.second;
                dep[e.first] = dep[u] + 1;
                ord.push_back(e.first);
            }
    }
    for (ll j = 1; j < LG; j++)
        for (ll v = 1; v <= n; v++) {
            up[j][v] = up[j - 1][up[j - 1][v]];
            mx[j][v] = max(mx[j - 1][v], mx[j - 1][up[j - 1][v]]);
        }
    for (ll i = 1; i <= m; i++) cout << S - pmax(eu[i], ev[i]) + ew[i] << '\n';
}
4 5
1 2 1
2 3 2
3 4 3
1 4 10
1 3 4

O((n+m)log⁡n)O((n + m) \log n).

Khi đi thi

  • Nối tất cả với chi phí nhỏ nhất, hay “cạnh lớn nhất trên đường càng nhỏ càng tốt”: MST. Cây khung nhỏ nhất cũng làm nhỏ nhất cạnh nặng nhất trên mọi đường giữa hai đỉnh.
  • Chi phí đặt tại đỉnh là cạnh tới một đỉnh ảo; nhiều đỉnh có sẵn kết nối thì gộp trước trong DSU. Đồ thị đầy đủ lớn: tìm O(n)O(n) cạnh có thể dùng bằng tính chất chu trình.
  • Đồ thị đầy đủ n≤5000n \le 5000: Prim O(n2)O(n^2); câu hỏi về một cạnh cụ thể: trộn vào Kruskal ngoại tuyến hoặc lấy cạnh nặng nhất trên đường trong cây.

Tóm tắt

  • Tính chất cắt: cạnh nhẹ nhất qua một lát cắt thuộc một MST; tính chất chu trình: cạnh nặng nhất của một chu trình bỏ được.
  • Kruskal: sắp cạnh, DSU, O(mlog⁡m)O(m \log m); dừng ở kk thành phần cho rừng kk cây nhỏ nhất.
  • Prim: mọc cây theo cạnh rẻ nhất, O(n2)O(n^2) không hàng đợi cho đồ thị dày.
  • Trọng số có cấu trúc: chỉ cần các cạnh kề nhau sau khi sắp.
  • Cây bắt buộc chứa (u,v,w)(u, v, w): MST−max⁡(u,v)+w\text{MST} - \max(u, v) + w.

Bài tập

1
Cây Nối Rẻ Nhấtcanuc80k3701Kruskal, in các cạnh đã chọn với đầu nhỏ trước, sắp theo thứ tự từ điển; không đủ n−1n - 1 cạnh thì Impossible.
LQDOJ
2
Kết Nối Giữa Các Điểmcanuc80k3702Gộp sẵn các cặp đã nối, rồi Kruskal trên mọi cặp điểm với khoảng cách Euclid.
LQDOJ
3
Kết Nối Bằng Ưu Đãicanuc80k3705Ngoài các ưu đãi, chỉ cần cạnh từ đỉnh có aa nhỏ nhất tới mọi đỉnh khác với giá amin+aia_{min} + a_i; Kruskal trên n−1+mn - 1 + m cạnh.
LQDOJ
4
Quy Hoạch Lưới Điệncanuc80k3706Thêm đỉnh ảo 0 nối tới mỗi thành phố bằng chi phí xây trạm; Prim O(n2)O(n^2) trên n+1n + 1 đỉnh, in trạm và dây đã chọn.
LQDOJ
5
Chuẩn Hóa Giới Hạn Đườngcanuc80k3707MST theo chi phí hạ tốc độ max⁡(0,s−k)\max(0, s - k); nếu mọi cạnh trong cây đều dưới kk thì phải nâng cạnh gần kk nhất.
LQDOJ
6
Cây Khung Trên Dãycanuc80k3708Duyệt giá trị tăng dần, từ mỗi vị trí loang hai phía khi các phần tử còn là bội của nó; mỗi cạnh nặng ai<pa_i < p thay cho cạnh pp, còn lại nối kề nhau bằng pp.
LQDOJ
7
Loại Bỏ Lựa Chọn Đồng Giácanuc80k3709Xử lý theo từng nhóm cùng trọng số: đếm cạnh của nhóm còn nối hai thành phần khác nhau trước khi gộp, rồi trừ số cạnh thực sự được gộp.
LQDOJ
8
Ngưỡng Hành Trìnhcanuc80k3713Tầm lái tối thiểu là cạnh nặng nhất của MST; không liên thông thì IMPOSSIBLE.
LQDOJ
9
Lợi Ích Khi Phá Cạnhcanuc80k3717Giữ một MST, bỏ mọi cạnh còn lại có trọng số dương: đáp án là tổng max⁡(0,c)\max(0, c) trên các cạnh không thuộc MST.
LQDOJ
10
Kết Nối Bằng Nhómcanuc80k3719Mỗi nhóm chỉ cần nối các đỉnh liên tiếp trong danh sách với cùng trọng số; Kruskal trên ∑(Ki−1)\sum (K_i - 1) cạnh.
LQDOJ
11
Ngưỡng Xa Nhất Giữa Các Mốccanuc80k3720Chạy Kruskal tới khi mọi đỉnh đặc biệt cùng thành phần; cạnh cuối cùng lấy là đáp án chung cho mọi đỉnh đặc biệt.
LQDOJ
12
Ma Trận Khoảng Cách Câycanuc80k3726Ma trận phải đối xứng, đường chéo 0; dựng MST theo ma trận rồi kiểm khoảng cách trên cây trùng ma trận.
LQDOJ
13
Vai Trò Của Từng Cạnhcanuc80k3736Theo từng nhóm cùng trọng số, nén thành phần DSU thành đỉnh, cạnh cầu trong đồ thị nhóm là “mọi MST”, cạnh không cầu là “ít nhất một”, cạnh nối hai đỉnh đã cùng thành phần là “không”.
LQDOJ
14
Cây Khung Trọng Số 0-1canuc80k3746Cạnh nặng 1 là cạnh cho trước; đáp án là số thành phần liên thông của đồ thị bù trừ 1, BFS giữ tập các đỉnh chưa thăm.
LQDOJ
15
Đường Hầm Giữa Các Trạmcanuc80k3747Chỉ cần cạnh giữa các hành tinh kề nhau khi sắp theo từng tọa độ xx, yy, zz: 3(n−1)3(n - 1) cạnh.
LQDOJ

Đọc thêm