Chương 2·Bài 23

Disjoint Set Union

Có nn người, lần lượt có tin “uu và vv quen nhau”, và quen qua người khác cũng tính là cùng nhóm. Xen giữa là các câu hỏi “hai người này đã cùng nhóm chưa”; mỗi câu lần lại cả danh sách quen biết thì tốn O(n+m)O(n + m) với mm tin. Nói theo ngôn ngữ đồ thị của chương 3: mỗi người là một đỉnh, mỗi tin là một cạnh, mỗi nhóm là một thành phần liên thông. Cấu trúc hợp nhất tập hợp (Disjoint Set Union, DSU, còn gọi union-find) giữ các thành phần liên thông như những nhóm rời nhau, mỗi nhóm có một đỉnh đại diện; gộp hai nhóm và hỏi đại diện đều gần như O(1)O(1). DSU là bộ khung của thuật toán Kruskal (bài 37), của nhiều bài xử lý ngoại tuyến và của các bài ràng buộc “cùng/khác”.

Rừng các đại diện

Mỗi nhóm là một cây có gốc: mỗi phần tử uu trỏ tới một phần tử cha par[u] trong cùng nhóm, riêng gốc trỏ vào chính nó, par[u] = u. Gốc là đại diện của nhóm: hai đỉnh cùng nhóm khi và chỉ khi lần theo par tới cùng một gốc. Gộp hai nhóm chỉ cần nối một gốc vào gốc kia. DSU không nhớ cạnh nào của đồ thị, chỉ nhớ đỉnh nào cùng nhóm với đỉnh nào.

Ba nhóm, mỗi nhóm một cây; vòng ngoài đánh dấu gốc. Bấm find(a) với a = 1 để xem nén đường đi.

Nối gốc tùy tiện có thể tạo ra một dây dài, mỗi lần tìm gốc tốn O(n)O(n). Hai cải tiến xử lý chuyện này:

  • Nén đường đi: sau khi tìm được gốc của uu, cho mọi đỉnh trên đường đi trỏ thẳng vào gốc. Lần sau chúng chỉ cần một bước.
  • Gộp theo kích thước: giữ sz[r] là số đỉnh của nhóm có gốc rr, luôn nối gốc nhóm nhỏ vào gốc nhóm lớn. Mỗi lần một đỉnh bị đẩy sâu thêm một tầng, nhóm của nó ít nhất gấp đôi, nên độ sâu không quá log⁡2n\log_2 n.
Gộp lần lượt union(2, 1), union(3, 2), union(4, 3)… một lần tắt và một lần bật gộp theo kích thước rồi so độ sâu.

Có cả hai cải tiến, mỗi thao tác tốn O(α(n))O(\alpha(n)) trung bình, với α\alpha là hàm ngược Ackermann, không quá 4 với mọi nn gặp trong thực tế; chứng minh nằm trong bài của Tarjan (1975). Chỉ một trong hai cũng đã cho O(log⁡n)O(\log n) trung bình, đủ cho gần như mọi bài.

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;
}

unite trả về việc có thật sự gộp hay không. Hai đỉnh đã cùng nhóm thì số nhóm và kích thước không đổi; quên kiểm tra này là cộng sz hai lần.

Hỏi nhanh

Thêm cạnh (u,v)(u, v) khi uu và vv đã cùng nhóm. Điều gì xảy ra với số nhóm?

Ví dụMạng Đường Đang Mở Rộng

Đề. n≤105n \le 10^5 đỉnh, m≤2⋅105m \le 2 \cdot 10^5 cạnh được thêm lần lượt. Sau mỗi cạnh in số thành phần liên thông và kích thước thành phần lớn nhất.

Lời giải. Bắt đầu với nn nhóm và nhóm lớn nhất kích thước 1. Mỗi lần thêm cạnh nối hai nhóm khác nhau, số nhóm giảm 1 và nhóm mới có kích thước bằng tổng hai nhóm. Kích thước nhóm chỉ tăng, nên nhóm lớn nhất chỉ cần cập nhật bằng max với nhóm vừa gộp, không cần cấu trúc gì thêm. Với ví dụ, cạnh 2-32\text{-}3 nối hai cặp thành nhóm 4 đỉnh, cạnh 1-41\text{-}4 nằm trong nhóm đó nên không đổi gì.

Bước 1: tìm gốc và gộp. find nén đường đi bằng đệ quy: mỗi đỉnh trên đường trỏ thẳng vào gốc. unite hợp theo kích thước (gốc nhỏ treo vào gốc lớn) và trả về có thật sự gộp 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: sau mỗi cạnh. Chỉ khi thật sự gộp, số nhóm giảm và kích thước lớn nhất có thể đổi.

C++
if (unite(u, v)) {
    comps--;
    best = max(best, sz[find(u)]);
}
cout << comps << ' ' << best << '\n';
Code đầy đủ
#include <bits/stdc++.h>
using namespace std;
using ll = long long;

const ll N = 1e5 + 5;
ll n, m, par[N], sz[N];

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;
    ll comps = n, best = 1;
    while (m--) {
        ll u, v;
        cin >> u >> v;
        if (unite(u, v)) {
            comps--;
            best = max(best, sz[find(u)]);
        }
        cout << comps << ' ' << best << '\n';
    }
}
6 5
1 2
3 4
2 3
1 4
5 6

Mỗi cạnh O(α(n))O(\alpha(n)).

Thêm cạnh theo thứ tự trọng số

Nhiều câu hỏi có dạng “chỉ dùng các cạnh trọng số không quá qq thì …”. Khi mọi câu hỏi biết trước, sắp cạnh tăng theo trọng số và sắp câu hỏi tăng theo ngưỡng; quét ngưỡng tăng dần, thêm mọi cạnh có trọng số không vượt ngưỡng hiện tại rồi trả lời. Mỗi cạnh được thêm đúng một lần nên tổng là O((m+q)log⁡)O((m + q) \log) cho phần sắp xếp. Đây cũng là cách Kruskal dựng cây khung nhỏ nhất.

Khi gộp hai nhóm kích thước aa và bb, số cặp đỉnh mới liên thông là a⋅ba \cdot b; cộng dồn được số cặp liên thông mà không phải đếm lại từ đầu.

Ví dụCặp Đỉnh Theo Ngưỡng

Đề. Cây n≤2⋅105n \le 2 \cdot 10^5 đỉnh có trọng số trên cạnh, m≤2⋅105m \le 2 \cdot 10^5 câu hỏi độc lập. Với ngưỡng qq, đếm số cặp u<vu < v mà cạnh nặng nhất trên đường đi giữa chúng không vượt qq.

Lời giải. Đường đi từ uu tới vv có mọi cạnh ≤q\le q khi và chỉ khi uu và vv cùng thành phần của rừng chỉ gồm các cạnh ≤q\le q. Vậy đáp án là tổng (s2)\binom{s}{2} trên các thành phần. Gộp hai nhóm kích thước aa và bb tạo thêm đúng a⋅ba \cdot b cặp mới. Trả lời câu hỏi theo ngưỡng tăng dần thì các cạnh chỉ được thêm vào, không bao giờ phải bỏ ra. Với ví dụ, ở ngưỡng 3 có hai nhóm {2,3}\{2, 3\} và {4,5}\{4, 5\} được nối, đáp án tăng từ 2 lên 2+2⋅2=62 + 2 \cdot 2 = 6.

Bước 1: sắp cạnh và câu hỏi. Cạnh lưu là (trọng số, u, v) để sort theo trọng số; câu hỏi lưu (ngưỡng, chỉ số gốc) để sau khi sắp vẫn biết in đáp án vào đâu.

C++
for (ll i = 1; i < n; i++) cin >> e[i][1] >> e[i][2] >> e[i][0];
for (ll i = 1; i <= m; i++) {
    cin >> qr[i].first;
    qr[i].second = i;
}
sort(e + 1, e + n);
sort(qr + 1, qr + m + 1);

Bước 2: con trỏ trên danh sách cạnh. Trước câu hỏi ngưỡng qq, thêm mọi cạnh có trọng số không vượt qq; con trỏ j chỉ tiến. Đồ thị là cây nên hai đầu một cạnh luôn ở hai nhóm khác nhau lúc thêm, khỏi kiểm. Mỗi lần gộp cộng sz[u] * sz[v] cặp mới.

C++
for (ll i = 1; i <= m; i++) {
    while (j < n && e[j][0] <= qr[i].first) {
        ll u = find(e[j][1]), v = find(e[j][2]);
        if (sz[u] < sz[v]) swap(u, v);
        cur += sz[u] * sz[v];
        par[v] = u;
        sz[u] += sz[v];
        j++;
    }
    ans[qr[i].second] = cur;
}

cur tới (2⋅1052)≈2⋅1010\binom{2 \cdot 10^5}{2} \approx 2 \cdot 10^{10}, dùng long long.

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

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

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, sz[i] = 1;
    for (ll i = 1; i < n; i++) cin >> e[i][1] >> e[i][2] >> e[i][0];
    for (ll i = 1; i <= m; i++) {
        cin >> qr[i].first;
        qr[i].second = i;
    }
    sort(e + 1, e + n);
    sort(qr + 1, qr + m + 1);
    ll cur = 0, j = 1;
    for (ll i = 1; i <= m; i++) {
        while (j < n && e[j][0] <= qr[i].first) {
            ll u = find(e[j][1]), v = find(e[j][2]);
            if (sz[u] < sz[v]) swap(u, v);
            cur += sz[u] * sz[v];
            par[v] = u;
            sz[u] += sz[v];
            j++;
        }
        ans[qr[i].second] = cur;
    }
    for (ll i = 1; i <= m; i++) cout << ans[i] << ' ';
    cout << '\n';
}
5 4
1 2 4
2 3 1
2 4 3
4 5 2
1 2 3 4

Hai lần sắp O((n+m)log⁡)O((n + m) \log), phần còn lại gần tuyến tính.

Xóa cạnh: chạy ngược thời gian

DSU không tách được nhóm. Nhưng nếu mọi lần xóa biết trước, đọc thời gian theo chiều ngược lại: bắt đầu từ trạng thái sau lần xóa cuối, rồi lần lượt thêm các cạnh theo thứ tự ngược với thứ tự xóa. Đáp án sau lần xóa thứ ii chính là trạng thái ngay trước khi thêm lại cạnh thứ ii. Cách này dùng được cho xóa đỉnh, xóa phần tử của dãy, hay những ô bị chặn dần trên lưới. Khi xóa và thêm đan xen tùy ý, cần DSU có hoàn tác chia để trị theo thời gian (bài 74).

Ví dụNhững Cây Cầu Sụp Dần

Đề. Đồ thị N≤105N \le 10^5 đỉnh, M≤105M \le 10^5 cạnh, ban đầu liên thông. Các cạnh bị xóa theo thứ tự input. Sau mỗi lần xóa, in số cặp đỉnh không còn đường đi tới nhau.

Lời giải. DSU gộp được nhưng không tách được, nên ta chạy ngược thời gian: xóa cạnh theo thứ tự xuôi chính là thêm cạnh theo thứ tự ngược. Sau khi xóa hết, không cặp nào liên thông: (N2)\binom{N}{2} cặp bị chia cách. Thêm lại một cạnh nối hai nhóm kích thước aa và bb làm a⋅ba \cdot b cặp liên thông trở lại. Với ví dụ, sau khi xóa cạnh thứ hai thì đỉnh 2 bị cô lập: 4 cặp chứa nó bị chia cách.

Bước 1: trạng thái cuối. Mọi cặp bị chia cách, cur =(N2)= \binom{N}{2}; đáp án có thể tới 5⋅1095 \cdot 10^9 nên dùng ll.

C++
ll cur = n * (n - 1) / 2;

Bước 2: thêm cạnh ngược thời gian. Sau lần xóa thứ ii, các cạnh còn lại chính là các cạnh i+1..Mi + 1..M. Vì vậy duyệt ii từ MM về 1: ghi đáp án thứ ii trước (lúc đó đã thêm lại đúng các cạnh i+1..Mi + 1..M), rồi mới thêm lại cạnh ii.

C++
for (ll i = m; i >= 1; i--) {
    ans[i] = cur;
    ll u = find(a[i]), v = find(b[i]);
    if (u == v) continue;
    if (sz[u] < sz[v]) swap(u, v);
    cur -= sz[u] * sz[v];
    par[v] = u;
    sz[u] += sz[v];
}
Code đầy đủ
#include <bits/stdc++.h>
using namespace std;
using ll = long long;

const ll N = 1e5 + 5;
ll n, m, par[N], sz[N], a[N], b[N], ans[N];

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, sz[i] = 1;
    for (ll i = 1; i <= m; i++) cin >> a[i] >> b[i];
    ll cur = n * (n - 1) / 2;
    for (ll i = m; i >= 1; i--) {
        ans[i] = cur;
        ll u = find(a[i]), v = find(b[i]);
        if (u == v) continue;
        if (sz[u] < sz[v]) swap(u, v);
        cur -= sz[u] * sz[v];
        par[v] = u;
        sz[u] += sz[v];
    }
    for (ll i = 1; i <= m; i++) cout << ans[i] << '\n';
}
5 6
1 2
2 3
1 3
3 4
4 5
3 5

Mỗi cạnh O(α(N))O(\alpha(N)).

DSU có độ lệch

Gắn cho mỗi đỉnh một số dif[u], bằng “giá trị” của uu trừ giá trị của cha. Lần theo đường đi tới gốc và cộng dồn, ta được độ lệch của uu so với gốc; nén đường đi vẫn làm được, chỉ cần cộng dồn dif trước khi nối thẳng vào gốc. Hai đỉnh cùng nhóm thì hiệu giá trị của chúng là hiệu hai độ lệch so với gốc chung, xác định hoàn toàn.

Số cạnh mỗi cạnh là dif so với cha. find(1) cộng dồn 5 + (−3) rồi nối thẳng 1 vào gốc; thử thêm ràng buộc giữa hai nhóm.

Ràng buộc xv−xu=Dx_v - x_u = D: nếu u,vu, v đã cùng nhóm, kiểm hiệu độ lệch có bằng DD không; nếu khác nhóm, nối gốc rvr_v vào gốc rur_u với dif[rv] chọn sao cho ràng buộc đúng, tức D+dif[u]−dif[v]D + \mathrm{dif}[u] - \mathrm{dif}[v] khi dif đã là độ lệch so với gốc. Thay phép cộng bằng XOR là ràng buộc “cùng/khác” trên hai trạng thái (tô hai màu, bài 30), thay bằng cộng modulo 3 là quan hệ vòng ba nhóm.

C++
ll find(ll u) {
    if (par[u] == u) return u;
    ll r = find(par[u]);
    dif[u] += dif[par[u]];
    return par[u] = r;
}

Hỏi nhanh

Trong DSU có độ lệch, uu và vv cùng gốc rr, dif[u] = 3 và dif[v] = 7 là độ lệch so với rr. Hiệu xv−xux_v - x_u bằng bao nhiêu?

Ví dụHệ Phương Trình Vị Trí

Đề. N≤105N \le 10^5 biến nguyên trong [0,109][0, 10^9] và M≤2⋅105M \le 2 \cdot 10^5 phương trình xR−xL=Dx_R - x_L = D với 0≤D≤1040 \le D \le 10^4. In Yes nếu hệ có nghiệm, ngược lại No.

Lời giải. DSU có độ lệch: dif[u] là xu−xpar(u)x_u - x_{\mathrm{par}(u)}, và sau find(u) thì dif[u] là xux_u trừ giá trị của gốc. Xử lý từng phương trình; gặp phương trình trong cùng nhóm mà hiệu không khớp thì hệ vô nghiệm. Không mâu thuẫn thì luôn chọn được nghiệm trong miền: trong một nhóm, hai biến bất kỳ nối nhau bởi đường đi qua không quá N−1N - 1 phương trình, nên chênh lệch không quá 105⋅104=10910^5 \cdot 10^4 = 10^9; dịch cả nhóm để biến nhỏ nhất bằng 0. Với ví dụ, bốn phương trình đầu cho x5−x1=16x_5 - x_1 = 16, trái với phương trình cuối.

Bước 1: find cộng dồn độ lệch. Tìm gốc của cha trước; lúc đó dif[par[u]] đã là độ lệch của cha so với gốc, cộng nó vào dif[u] rồi nối uu thẳng vào gốc.

C++
ll find(ll u) {
    if (par[u] == u) return u;
    ll r = find(par[u]);
    dif[u] += dif[par[u]];
    return par[u] = r;
}

Bước 2: một phương trình xr−xl=dx_r - x_l = d. Cùng nhóm thì kiểm dif[r] - dif[l] == d. Khác nhóm thì nối gốc bb (của rr) vào gốc aa (của ll) với độ lệch ww chọn sao cho sau khi nối xr−xl=dx_r - x_l = d: xr−xa=w+dif[r]x_r - x_a = w + \mathrm{dif}[r] và xl−xa=dif[l]x_l - x_a = \mathrm{dif}[l], nên w=d+dif[l]−dif[r]w = d + \mathrm{dif}[l] - \mathrm{dif}[r]. Nếu theo kích thước phải nối ngược (aa vào bb) thì độ lệch đổi dấu.

C++
ll a = find(l), b = find(r);
if (a == b) {
    if (dif[r] - dif[l] != d) ok = 0;
    continue;
}
ll w = d + dif[l] - dif[r];
if (sz[a] < sz[b]) {
    swap(a, b);
    w = -w;
}
par[b] = a;
dif[b] = w;
sz[a] += sz[b];

Độ lệch tới 10910^9, cộng nhiều lần vẫn vừa long long.

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

const ll N = 1e5 + 5;
ll n, m, par[N], sz[N], dif[N];

ll find(ll u) {
    if (par[u] == u) return u;
    ll r = find(par[u]);
    dif[u] += dif[par[u]];
    return par[u] = r;
}

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;
    bool ok = 1;
    while (m--) {
        ll l, r, d;
        cin >> l >> r >> d;
        ll a = find(l), b = find(r);
        if (a == b) {
            if (dif[r] - dif[l] != d) ok = 0;
            continue;
        }
        ll w = d + dif[l] - dif[r];
        if (sz[a] < sz[b]) {
            swap(a, b);
            w = -w;
        }
        par[b] = a;
        dif[b] = w;
        sz[a] += sz[b];
    }
    cout << (ok ? "Yes" : "No") << '\n';
}
5 5
1 2 4
2 3 7
3 4 3
4 5 2
1 5 15

Mỗi phương trình O(α(N))O(\alpha(N)).

Ví dụQuan Hệ Hai Trạng Thái

Đề. n≤105n \le 10^5 tên, mỗi tên mang một trong hai trạng thái chưa biết. Lần lượt mm ràng buộc “cùng trạng thái” hoặc “khác trạng thái”: in YES và giữ lại nếu không mâu thuẫn với các ràng buộc đã giữ, ngược lại in NO và bỏ qua. Sau đó qq câu hỏi: in 1 nếu hai tên chắc chắn cùng trạng thái, 2 nếu chắc chắn khác, 3 nếu chưa suy ra được.

Lời giải. DSU có độ lệch theo modulo 2: dif[u] là 0 nếu uu cùng trạng thái với cha, 1 nếu khác, cộng dồn bằng XOR. Sau find(u), dif[u] cho biết uu có cùng trạng thái với gốc không, nên hai tên cùng nhóm cùng trạng thái khi và chỉ khi dif[u] ^ dif[v] bằng 0.

Bước 1: đổi tên thành số và find. map gán mỗi tên một số từ 1 tới nn. find như DSU có độ lệch, cộng bằng XOR.

C++
ll find(ll u) {
    if (par[u] == u) return u;
    ll r = find(par[u]);
    dif[u] ^= dif[par[u]];
    return par[u] = r;
}

Bước 2: một ràng buộc kiểu tt. t=1t = 1 là cùng, t=2t = 2 là khác, tức đòi dif[u] ^ dif[v] == t - 1. Cùng nhóm thì chỉ kiểm. Khác nhóm thì nối gốc và chọn dif của gốc bị nối để ràng buộc đúng: dif[u]⊕dif[v]⊕(t−1)\mathrm{dif}[u] \oplus \mathrm{dif}[v] \oplus (t - 1). XOR đối xứng nên đổi chiều nối không cần đổi giá trị.

C++
ll u = id[x], v = id[y], a = find(u), b = find(v);
if (a == b) {
    cout << ((dif[u] ^ dif[v]) == t - 1 ? "YES" : "NO") << '\n';
    continue;
}
if (sz[a] < sz[b]) swap(a, b);
par[b] = a;
dif[b] = dif[u] ^ dif[v] ^ (t - 1);
sz[a] += sz[b];

Bước 3: câu hỏi. Khác nhóm thì chưa suy ra được, in 3; cùng nhóm in 1+(dif[u]⊕dif[v])1 + (\mathrm{dif}[u] \oplus \mathrm{dif}[v]), tức 1 nếu cùng, 2 nếu khác.

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

const ll N = 1e5 + 5;
ll n, m, q, par[N], sz[N], dif[N];
map<string, ll> id;

ll find(ll u) {
    if (par[u] == u) return u;
    ll r = find(par[u]);
    dif[u] ^= dif[par[u]];
    return par[u] = r;
}

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

    cin >> n >> m >> q;
    for (ll i = 1; i <= n; i++) {
        string s;
        cin >> s;
        id[s] = i;
        par[i] = i, sz[i] = 1;
    }
    while (m--) {
        ll t;
        string x, y;
        cin >> t >> x >> y;
        ll u = id[x], v = id[y], a = find(u), b = find(v);
        if (a == b) {
            cout << ((dif[u] ^ dif[v]) == t - 1 ? "YES" : "NO") << '\n';
            continue;
        }
        if (sz[a] < sz[b]) swap(a, b);
        par[b] = a;
        dif[b] = dif[u] ^ dif[v] ^ (t - 1);
        sz[a] += sz[b];
        cout << "YES\n";
    }
    while (q--) {
        string x, y;
        cin >> x >> y;
        ll u = id[x], v = id[y];
        if (find(u) != find(v)) cout << 3 << '\n';
        else cout << 1 + (dif[u] ^ dif[v]) << '\n';
    }
}
5 6 5
an binh chi dung em
1 an binh
2 binh chi
1 an chi
2 chi dung
1 dung em
2 an em
an binh
an chi
an dung
chi em
binh em

Mỗi thao tác O(log⁡n)O(\log n) cho map cộng O(α(n))O(\alpha(n)) cho DSU.

Gộp nhóm nhỏ vào nhóm lớn

Mỗi nhóm thường mang theo dữ liệu riêng: tập nhãn, danh sách đỉnh, các giá trị lớn nhất. Khi gộp, đổ từng phần tử của nhóm nhỏ vào nhóm lớn. Một phần tử bị chuyển thì nhóm chứa nó ít nhất gấp đôi, nên mỗi phần tử bị chuyển không quá log⁡2n\log_2 n lần: tổng O(nlog⁡n)O(n \log n) lần chuyển, nhân thêm chi phí của map hay set nếu dùng. Đó cũng là lý lẽ của gộp theo kích thước, áp lên dữ liệu thay vì độ sâu cây.

Ví dụThống Kê Nhãn Trong Nhóm

Đề. N≤2⋅105N \le 2 \cdot 10^5 phần tử, phần tử ii có nhãn CiC_i, ban đầu đứng riêng. Q≤2⋅105Q \le 2 \cdot 10^5 thao tác: gộp nhóm của aa và bb, hoặc hỏi nhóm chứa xx có bao nhiêu phần tử nhãn yy.

Lời giải. Mỗi gốc giữ một map từ nhãn tới số lượng của cả nhóm. Gộp hai nhóm thì đổ map nhỏ hơn vào map lớn hơn (gộp nhỏ vào lớn). Mỗi mục bị chuyển thì map chứa nó lớn ít nhất gấp đôi, nên một mục bị chuyển không quá log⁡2N\log_2 N lần. Với ví dụ, sau khi gộp {1,3}\{1, 3\}, map của nhóm có nhãn 1 xuất hiện 2 lần.

Bước 1: khởi tạo. Mỗi phần tử là một nhóm riêng với map một mục.

C++
for (ll i = 1; i <= n; i++) {
    ll c;
    cin >> c;
    par[i] = i;
    cnt[i][c] = 1;
}

Bước 2: gộp nhỏ vào lớn. Gốc nào có map lớn hơn thì giữ làm gốc mới, để dữ liệu luôn nằm ở gốc; map nhỏ xóa đi sau khi đổ.

C++
ll u = find(x), v = find(y);
if (u == v) continue;
if (cnt[u].size() < cnt[v].size()) swap(u, v);
for (pair<ll, ll> p : cnt[v]) cnt[u][p.first] += p.second;
cnt[v].clear();
par[v] = u;

Bước 3: câu hỏi. Tra map của gốc; dùng count trước để không tạo mục rỗng mới trong map.

C++
ll u = find(x);
cout << (cnt[u].count(y) ? cnt[u][y] : 0) << '\n';
Code đầy đủ
#include <bits/stdc++.h>
using namespace std;
using ll = long long;

const ll N = 2e5 + 5;
ll n, q, par[N];
map<ll, ll> cnt[N];

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 >> q;
    for (ll i = 1; i <= n; i++) {
        ll c;
        cin >> c;
        par[i] = i;
        cnt[i][c] = 1;
    }
    while (q--) {
        ll t, x, y;
        cin >> t >> x >> y;
        if (t == 1) {
            ll u = find(x), v = find(y);
            if (u == v) continue;
            if (cnt[u].size() < cnt[v].size()) swap(u, v);
            for (pair<ll, ll> p : cnt[v]) cnt[u][p.first] += p.second;
            cnt[v].clear();
            par[v] = u;
        } else {
            ll u = find(x);
            cout << (cnt[u].count(y) ? cnt[u][y] : 0) << '\n';
        }
    }
}
6 8
1 2 1 3 2 1
2 1 2
1 1 3
2 1 1
1 2 5
1 1 2
2 5 1
2 4 3
2 4 1

Tổng O(Nlog⁡N⋅log⁡N)O(N \log N \cdot \log N): mỗi mục chuyển O(log⁡N)O(\log N) lần, mỗi lần một thao tác map O(log⁡N)O(\log N).

Nhảy qua các phần tử đã xong

Trên một dãy, có những phần tử khi đã “xong” (bình đã đầy, ô đã tô, chỗ đã có người) thì không bao giờ được xét lại. Cho mỗi phần tử xong trỏ sang phần tử bên phải, par[i] = i + 1, còn phần tử chưa xong trỏ về chính nó; find(i) khi đó là phần tử chưa xong đầu tiên từ ii trở đi. Thêm một ô lính canh n+1n + 1 không bao giờ xong. Nén đường đi làm các chuỗi phần tử đã xong bị nhảy qua một bước, và mỗi phần tử chỉ chuyển sang xong một lần, nên tổng chi phí gần tuyến tính. Đây là cách tô màu các đoạn “chỉ tô ô chưa tô” trong O(nα(n))O(n \alpha(n)).

Ví dụDãy Bình Tràn

Đề. n≤2⋅105n \le 2 \cdot 10^5 bình xếp từ trên xuống, bình ii dung tích aia_i. Đổ xx đơn vị vào bình pp: nước làm đầy bình rồi tràn xuống bình kế tiếp, tràn khỏi bình nn thì mất. Xen kẽ là câu hỏi lượng nước trong bình kk; tổng m≤2⋅105m \le 2 \cdot 10^5 thao tác.

Lời giải. Mô phỏng thẳng có thể đi qua cả trăm nghìn bình đầy ở mỗi lần đổ. Dùng DSU để nhảy qua các bình đã đầy: par[i] = i là bình chưa đầy, đầy thì par[i] = i + 1, nên find(p) là bình chưa đầy đầu tiên từ pp trở xuống. Ô n+1n + 1 là lính canh không bao giờ đầy.

Mỗi lần đổ bắt đầu ở bình chưa đầy đầu tiên; cung xanh là con trỏ của bình đã đầy.

Bước 1: khởi tạo. Mọi bình, kể cả lính canh, đều trỏ vào chính nó.

C++
for (ll i = 1; i <= n + 1; i++) par[i] = i;

Bước 2: một lần đổ. Bắt đầu ở bình chưa đầy đầu tiên, rót tối đa phần còn trống. Bình vừa đầy thì nối sang bình kế, rồi find lại để nhảy qua cả chuỗi bình đầy phía dưới. Vòng dừng khi hết nước hoặc tới lính canh (nước tràn khỏi bình nn thì mất).

C++
p = find(p);
while (p <= n && x > 0) {
    ll add = min(x, a[p] - w[p]);
    w[p] += add;
    x -= add;
    if (w[p] == a[p]) par[p] = p + 1;
    p = find(p);
}

Câu hỏi chỉ in w[k], lượng nước đang có trong bình kk.

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

const ll N = 2e5 + 5;
ll n, m, a[N], w[N], par[N];

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;
    for (ll i = 1; i <= n; i++) cin >> a[i];
    for (ll i = 1; i <= n + 1; i++) par[i] = i;
    cin >> m;
    while (m--) {
        ll t;
        cin >> t;
        if (t == 1) {
            ll p, x;
            cin >> p >> x;
            p = find(p);
            while (p <= n && x > 0) {
                ll add = min(x, a[p] - w[p]);
                w[p] += add;
                x -= add;
                if (w[p] == a[p]) par[p] = p + 1;
                p = find(p);
            }
        } else {
            ll k;
            cin >> k;
            cout << w[k] << '\n';
        }
    }
}
4
3 5 2 4
9
1 2 4
1 1 5
2 1
2 2
1 2 5
2 3
2 4
1 4 5
2 4

Mỗi lần đổ dừng ở một bình chưa đầy hoặc ở lính canh, và mỗi bình chuyển sang đầy đúng một lần; nhờ nén đường đi, tổng số bước là O((n+m) α(n))O((n + m)\,\alpha(n)).

Khi đi thi

  • Cạnh chỉ thêm, hỏi liên thông, số nhóm, kích thước nhóm: DSU. Có xóa nhưng biết trước thứ tự: chạy ngược thời gian. Câu hỏi theo ngưỡng biết trước: sắp cạnh và câu hỏi rồi thêm dần.
  • Ràng buộc “cùng/khác”, “hơn kém DD”, “trội hơn theo vòng” là DSU có độ lệch; cập nhật độ lệch trong find trước khi nén.
  • find đệ quy với gộp theo kích thước sâu không quá log⁡2n\log_2 n tầng, an toàn; chỉ nén đường đi mà không gộp theo kích thước thì lần đầu có thể sâu tới nn tầng, dễ tràn ngăn xếp.

Tóm tắt

  • Mỗi nhóm là một cây, gốc là đại diện; find lần tới gốc có nén đường đi, unite nối gốc nhóm nhỏ vào gốc nhóm lớn.
  • Gộp hai nhóm kích thước a,ba, b tạo thêm a⋅ba \cdot b cặp liên thông.
  • Biết trước mọi câu hỏi: thêm cạnh theo trọng số tăng dần, hoặc đảo thời gian để xóa thành thêm.
  • dif[u] là độ lệch so với cha; cộng dồn trong find cho hiệu giữa hai đỉnh cùng nhóm, thay bằng XOR hay modulo cho ràng buộc trạng thái.
  • Đổ dữ liệu nhóm nhỏ vào nhóm lớn: mỗi phần tử chuyển không quá log⁡2n\log_2 n lần.
  • par[i] = i + 1 cho phần tử đã xong biến DSU thành con trỏ “phần tử chưa xong kế tiếp”.

Bài tập

1
Kết Nối Tăng Dầncanuc80k2300DSU cơ bản: thao tác nối gọi unite, thao tác hỏi so hai find.
LQDOJ
2
Mạng Bạn Bè Trực Tuyếncanuc80k2302Đánh số tên bằng map, sau mỗi cặp bạn in sz của nhóm chứa họ.
LQDOJ
3
Phạm Vi Lan Truyềncanuc80k2303Trong mỗi nhóm, nối thành viên đầu với từng thành viên còn lại; đáp án mỗi người là sz của nhóm chứa họ.
LQDOJ
4
Hai Mạng Giao Thôngcanuc80k2306Hai DSU riêng cho hai mạng; đếm số đỉnh có cùng cặp (gốc mạng bộ, gốc mạng sắt) bằng map.
LQDOJ
5
Đồng Nhất Dãy Đối Xứngcanuc80k2307Mỗi cặp vị trí đối xứng nối hai giá trị; số thao tác ít nhất là số lần unite thật sự gộp.
LQDOJ
6
Các Nhóm Liên Kết Đầy Đủcanuc80k2311Mỗi thành phần phải là đồ thị đầy đủ: đếm cạnh trong từng nhóm và so với s(s−1)/2s(s-1)/2.
LQDOJ
7
Hoàn Thiện Bảng Ôcanuc80k2314Coi mỗi hàng và mỗi cột là một đỉnh, ô đã có nối hàng với cột; đáp án là số nhóm trừ 1.
LQDOJ
8
Vị Trí Có Thể Cố Địnhcanuc80k2318Các vị trí cùng nhóm hoán đổi tự do với nhau; đếm ii có find(i) == find(p[i]).
LQDOJ
9
Tổng Đoạn Còn Lạicanuc80k2321Đảo thứ tự xóa thành thêm; mỗi vị trí được bật thì gộp với hàng xóm đã bật, giữ tổng của nhóm và max toàn cục.
LQDOJ
10
Mạng Sau Chuỗi Sự Cốcanuc80k2323Đảo thời gian như bài cầu sụp, nhưng in số thành phần; cạnh không bao giờ bị xóa thêm vào từ đầu.
LQDOJ
11
Suy Ra Tổng Toàn Dãycanuc80k2328Biết tổng [l,r][l, r] là biết Sr−Sl−1S_r - S_{l-1}; nối đỉnh l−1l - 1 với rr, tổng toàn dãy xác định khi 00 và NN cùng nhóm.
LQDOJ
12
Quan Hệ Chu Kỳ Ba Nhómcanuc80k2337DSU có độ lệch modulo 3: cùng nhóm là lệch 0, trội hơn là lệch 1; đếm phát biểu mâu thuẫn hoặc có chỉ số vượt NN.
LQDOJ
13
Đỉnh Lớn Thứ K Trong Thành Phầncanuc80k2341k≤10k \le 10: mỗi gốc giữ 10 đỉnh lớn nhất của nhóm, gộp hai danh sách khi nối rồi cắt lại 10.
LQDOJ
14
Đường Đi Theo Ngưỡng Độ Caocanuc80k2342Năng lượng tại vv luôn là e+ha−hve + h_a - h_v, nên chỉ cấm đỉnh cao hơn ha+eh_a + e; sắp cạnh theo độ cao lớn hơn của hai đầu, sắp câu hỏi theo ha+eh_a + e, thêm dần.
LQDOJ
15
Tổng Độ Chênh Trên Đường Câycanuc80k2344Tổng max trừ tổng min: bật đỉnh theo giá trị tăng dần, gộp với hàng xóm đã bật, cộng ava_v nhân số cặp mới; làm lại theo giảm dần cho min.
LQDOJ

Đọc thêm

  • Disjoint Set UnionVNOI WikiBài tiếng Việt về cài đặt và các ứng dụng của DSU.
  • Disjoint Set Unioncp-algorithmsNén đường đi, gộp theo kích thước, độ lệch tới gốc, chạy ngược và nhiều ứng dụng.
  • 并查集OI WikiPhân tích độ phức tạp và DSU có trọng số trên cạnh.
  • Disjoint Set UnionUSACO GuideBài luyện từ USACO và CSES theo mức độ.