DSU Tree
Tác giả:
- Đoàn Quang Huy - THPT Chuyên Nguyễn Tất Thành, Quãng Ngãi.
Reviewer:
- Nguyễn Tiến Mạnh - Đại học Bách khoa Hà Nội.
- Nguyễn Quang Minh - Michigan State University.
- Võ Đức Đoàn - THPT Chuyên Nguyễn Tất Thành, Quãng Ngãi.
- Nguyễn Tấn Minh - Đại học Khoa học Tự Nhiên, ĐHQG-HCM.
Giới thiệu
Trong rất nhiều bài toán về đồ thị, cấu trúc dữ liệu Disjoint-Set Union (DSU) thường được sử dụng để kiểm tra hai đỉnh có thuộc cùng một thành phần liên thông hay không. Nó không lưu lại quá trình gộp các đỉnh. Điều này khiến DSU không thể xử lý một loạt các truy vấn quan trọng trong nhiều bài toán:
- Tìm tập các đỉnh có thể đi tới từ ~u~ khi chỉ dùng ~x~ cạnh đầu tiên.
- Tìm trọng số nhỏ nhất/lớn nhất/tổng giá trị của các cạnh khi đi từ đỉnh u đến đỉnh v.
Vì DSU bình thường không thể lưu các truy vấn dạng này, từ đó ta cần một cấu trúc dữ liệu có thể biểu diễn toàn bộ quá trình gộp tập hợp: DSU-Tree (Reachability Tree).
Cách xây dựng
DSU-Tree là một cây có tổng cộng ~N + M~ nút, trong đó:
- ~N~ nút lá tương ứng với các đỉnh ban đầu của đồ thị.
- ~M~ nút còn lại là các nút mới được tạo ra từ các phép gộp khi duyệt ~M~ cạnh của đồ thị.
Ban đầu, ta tạo N nút lá, mỗi nút biểu diễn một đỉnh của đồ thị và mỗi nút đứng độc lập như một cây riêng lẻ. DSU được sử dụng để biết được gốc của mỗi tập hợp. Ta xét các cạnh ~\left( {u}, {v} \right)~ của đồ thị theo thứ tự tăng dần theo một tiêu chí nhất định (thường là trọng số). Với mỗi cạnh được thêm vào:
- Dùng DSU để tìm gốc hiện tại của ~u~ và ~v~.
- Nếu hai gốc khác nhau, ta thực hiện:
- Tạo một nút mới trong DSU-Tree, nối hai gốc hiện tại của hai thành phần vào làm hai con của nút mới này.
- Cập nhật DSU và coi nút mới này là gốc mới của thành phần liên thông mới. Trong thực tế, nếu một cạnh ~\left( {u}, {v} \right)~ có hai đầu mút thuộc cùng một thành phần liên thông tại thời điểm xét nó, cạnh đó không làm thay đổi DSU-Tree.
Nếu ta bỏ qua các cạnh như vậy, DSU-Tree sẽ có đúng: ~2N - 1~ nút, gồm:
- ~N~ lá ban đầu
- và tối đa ~N - 1~ nút mới tương ứng với các phép gộp có nghĩa.
DSU-Tree có thể được xem như phiên bản DSU không path compression, trong đó mỗi lần gộp được biểu diễn bằng một nút mới trong cây.

Cài đặt
Code
struct DSUTree{
int n;
vector<int> parent;
vector<int> repr; // repr[u] = nút hiện tại của component chứa u
vector<vector<int>> tree;
int counter;
DSUTree(int n, int m) : n(n), parent(n + 1, -1), repr(n + 1), tree(n + m + 1), counter(n)
{
iota(repr.begin(), repr.end(), 0); // repr[u] = u
}
int Find(int u){
while(parent[u] >= 0) u = parent[u];
return u;
}
bool Union(int u, int v){
u = Find(u);
v = Find(v);
if(u == v){
// tree[++counter].push_back(repr[u]);
// repr[u] = counter;
return false;
}
if (parent[u] > parent[v]) swap(u, v);
parent[u] += parent[v];
parent[v] = u;
int newNode = ++counter;
// gắn hai repr cũ vào làm con
tree[newNode].push_back(repr[u]);
tree[newNode].push_back(repr[v]);
repr[u] = newNode;
return true;
}
};
Tính chất
Ở mỗi bước gộp, nút mới được tạo ra trong DSU-Tree là gốc trong thành phần liên thông vừa được gộp. Do đó, mọi thông tin về thành phần liên thông của một đỉnh ~x~ tại thời điểm bất kì đều tương ứng với toàn bộ cây con của nút đại diện cho thành phần liên thông đó.Kruskal Reconstruction Tree
Kruskal Reconstruction Tree là một trường hợp đặc biệt của DSU-Tree, trong đó các phép gộp được thực hiện theo thứ tự tăng dần của trọng số cạnh. Nói cách khác, đây chính là DSU-Tree được tạo ra bởi quá trình chạy thuật toán Kruskal khi xây dựng cây khung nhỏ nhất (MST).
Tính chất
Với hai đỉnh ~\left( {u}, {v} \right)~: Cạnh có trọng số lớn nhất trên đường đi giữa ~u~ và ~v~ trong cây ban đầu chính là trọng số gắn với nút LCA(u, v) trong KRT.
Nói cách khác, thay vì tìm max-edge/min-edge trên đường đi, ta chỉ cần thực hiện một truy vấn LCA trên KRT sau khi tiền xử lý.
Mờ rộng
Tính chất trên không chỉ áp dụng cho hai đỉnh đơn lẻ.
Với một tập đỉnh ~S~, trọng số lớn nhất (hoặc nhỏ nhất) giữa mọi cặp đỉnh của ~S~ cũng có thể xác định nhờ KRT: Cạnh có trọng số lớn nhất trên đường đi giữa mọi cặp đỉnh trong tập S chính là cạnh tương ứng với LCA của toàn bộ tập S trên KRT.
Ứng dụng này đặc biệt hiệu quả khi cần phân tích các nhóm đỉnh, xây dựng virtual tree, hoặc xử lý các bài toán offline liên quan đến nhiều truy vấn trên cùng một tập ~S~.
Phân biệt DSU-Tree và DSU on Tree
Trong lập trình thi đấu, hai thuật ngữ DSU-Tree và DSU on Tree rất dễ bị nhầm lẫn do tên gọi tương tự, nhưng bản chất chúng hoàn toàn khác nhau.
| Tiêu chí | DSU Tree | DSU on Tree |
|---|---|---|
| Mục đích | Quản lý các thành phần liên thông trong đồ thị, cho phép gộp các đỉnh và truy vấn thông tin thành phần. | Giải quyết các bài toán trên cây, như truy vấn con, tính chất các nhánh, thường kết hợp với thuật toán DFS. |
| Cấu trúc dữ liệu | Cây đại diện (representative tree) với parent/size/rank. | Sử dụng DSU kết hợp DFS và kỹ thuật "small-to-large" (merge nhỏ vào lớn). |
| Truy vấn | LCA trên KRT, truy vấn lịch sử gộp, max/min-edge. | Truy vấn subtree(đếm, tổng, số loại giá trị). |
| Gộp | Gộp hai thành phần liên thông bằng union (thường kèm path compression). | Gộp thông tin từ con lên cha khi DFS, thường merge nhỏ vào lớn để tối ưu. |
| Phạm vi áp dụng | Đồ thị tổng quát, không cần cấu trúc cây. | Chỉ áp dụng trên cây (tree). |
| Độ phức tạp | ~\mathcal{O}(\alpha(n))~ cho mỗi thao tác (~\alpha~ là hàm nghịch đảo Ackermann). | ~\mathcal{O}(N \log N)~ cho toàn bộ cây khi dùng small-to-large merge. |
| Điểm mạnh | Rất nhanh cho các thao tác union-find trên đồ thị lớn. | Giải quyết được nhiều bài toán phức tạp trên cây mà DSU đơn thuần không xử lý được. |
| Điểm yếu | Không thể trực tiếp xử lý các bài toán trên cây con, đường đi, hoặc thông tin tích lũy. | Phức tạp hơn DSU cơ bản, cần DFS và kỹ thuật merge nhỏ vào lớn. |
Bài tập áp dụng
Codeforces Round 809 - Qpwoeirut and Vertices
Lời giải
Xét một đồ thị vô hướng liên thông gồm ~n~ đỉnh được đánh số từ ~1~ đến ~n~ và ~m~ cạnh được đánh số theo thứ tự cho trước. Với mỗi truy vấn ~(l,r)~, bài toán yêu cầu xác định số nguyên không âm nhỏ nhất ~k~ sao cho, khi chỉ xét các cạnh có chỉ số từ ~1~ đến ~k~, mọi cặp đỉnh ~(a,b)~ thỏa mãn ~l \le a \le b \le r~ đều liên thông với nhau. Nói cách khác, tập các đỉnh ~\{l,l+1,\ldots,r\}~ phải nằm trong cùng một thành phần liên thông của đồ thị con được tạo bởi ~k~ cạnh đầu tiên.Một nhận xét quan trọng là tập các đỉnh ~\{l,l+1,\ldots,r\}~ liên thông khi và chỉ khi mọi cặp đỉnh liên tiếp ~(l,l+1),(l+1,l+2),\ldots,(r-1,r)~, đều liên thông.
Dựa trên nhận xét này, với mỗi ~i \ge 2~, ta định nghĩa ~w_i~ là chỉ số cạnh nhỏ nhất sao cho hai đỉnh ~i-1~ và ~i~ trở nên liên thông khi chỉ sử dụng các cạnh có chỉ số không vượt quá giá trị đó. Trong trường hợp hai đỉnh này đã liên thông ngay từ đầu, ta quy ước ~w_i = 0~. Khi đó, đối với một truy vấn ~(l,r)~ với ~l < r~, điều kiện để đoạn ~[l,r]~ liên thông tương đương với việc tất cả các cặp đỉnh liên tiếp trong đoạn đã được nối, và do đó đáp án của truy vấn chính là ~\max_{i = l+1}^{r} w_i~
Để xác định các giá trị ~w_i~, ta duyệt các cạnh của đồ thị theo thứ tự tăng dần chỉ số và sử dụng cấu trúc DSU để quản lý các thành phần liên thông. Ban đầu, mỗi đỉnh là một thành phần riêng biệt. Khi thêm một cạnh mới, hai thành phần liên thông tương ứng được hợp nhất. Mỗi thành phần liên thông được lưu trữ kèm theo tập các nhãn đỉnh thuộc về nó. Trong quá trình hợp nhất hai thành phần, nếu xuất hiện một đỉnh ~x~ sao cho ~x~ và ~x-1~ hoặc ~x~ và ~x+1~ trước đó thuộc hai thành phần khác nhau, thì cặp đỉnh liên tiếp tương ứng lần đầu tiên trở nên liên thông tại cạnh đang xét, và giá trị ~w~ tương ứng được gán bằng chỉ số của cạnh này. Việc luôn gộp tập có kích thước nhỏ vào tập lớn hơn đảm bảo tổng độ phức tạp của quá trình này là ~\mathcal{O}(n \log n)~.
Sau khi mảng ~w~ được xác định đầy đủ, mỗi truy vấn ~(l,r)~ trở thành một bài toán truy vấn giá trị lớn nhất trên đoạn ~[l+1,r]~ của một mảng tĩnh. Bài toán này có thể được giải hiệu quả bằng cấu trúc Sparse Table, cho phép tiền xử lý trong thời gian ~\mathcal{O}(n \log n)~ và trả lời mỗi truy vấn trong thời gian ~\mathcal{O}(1)~. Do đó, toàn bộ thuật toán đáp ứng tốt các ràng buộc của bài toán và cho phép xử lý hiệu quả số lượng lớn truy vấn trên đồ thị được xây dựng dần theo thứ tự các cạnh.
Code tham khảo
#include <bits/stdc++.h>
using namespace std;
struct DSU {
vector<int> parent;
vector<set<int>> nodes;
DSU(int n) {
parent.resize(n + 1);
nodes.resize(n + 1);
for (int i = 1; i <= n; i++) {
parent[i] = i;
nodes[i].insert(i);
}
}
int find(int x) {
if (parent[x] == x) return x;
return parent[x] = find(parent[x]);
}
bool unite(int a, int b, int edge_id, vector<int>& w) {
a = find(a);
b = find(b);
if (a == b) return false;
if (nodes[a].size() < nodes[b].size())
swap(a, b);
for (int x : nodes[b]) {
if (nodes[a].count(x - 1)) {
if (w[x] == -1)
w[x] = edge_id;
}
if (nodes[a].count(x + 1)) {
if (w[x + 1] == -1)
w[x + 1] = edge_id;
}
nodes[a].insert(x);
}
nodes[b].clear();
parent[b] = a;
return true;
}
};
int main() {
ios::sync_with_stdio(false);
cin.tie(nullptr);
int t;
cin >> t;
while (t--) {
int n, m, q;
cin >> n >> m >> q;
vector<pair<int,int>> edges(m + 1);
for (int i = 1; i <= m; i++) {
cin >> edges[i].first >> edges[i].second;
}
vector<int> w(n + 2, -1);
w[1] = 0;
DSU dsu(n);
for (int i = 1; i <= m; i++) {
dsu.unite(edges[i].first, edges[i].second, i, w);
}
for (int i = 2; i <= n; i++) {
if (w[i] == -1) w[i] = 0;
}
int LOG = log2(n) + 1;
vector<vector<int>> st(LOG, vector<int>(n + 1));
for (int i = 1; i <= n; i++) st[0][i] = w[i];
for (int k = 1; k < LOG; k++) {
for (int i = 1; i + (1 << k) - 1 <= n; i++) {
st[k][i] = max(st[k - 1][i],
st[k - 1][i + (1 << (k - 1))]);
}
}
auto getMax = [&](int l, int r) {
if (l > r) return 0;
int len = r - l + 1;
int k = log2(len);
return max(st[k][l], st[k][r - (1 << k) + 1]);
};
while (q--) {
int l, r;
cin >> l >> r;
if (l == r) {
cout << 0 << " ";
} else {
cout << getMax(l + 1, r) << " ";
}
}
cout << "\n";
}
return 0;
}
Codeforces Round 673 - Graph and Queries
Lời giải
Bài toán yêu cầu xử lý các truy vấn trên một đồ thị vô hướng trong đó các cạnh chỉ bị xóa theo thời gian. Đối với mỗi truy vấn loại một, ta cần tìm giá trị lớn nhất trong thành phần liên thông chứa một đỉnh cho trước và sau đó loại bỏ giá trị này. Do đồ thị thay đổi theo hướng xóa cạnh, các cấu trúc dữ liệu online như DSU thông thường không thể áp dụng. Vì vậy, ta sử dụng kỹ thuật xử lý offline bằng cách đảo ngược thứ tự các truy vấn.
Khi xử lý các truy vấn theo thứ tự ngược, thao tác xóa cạnh sẽ trở thành thao tác thêm cạnh. Điều này cho phép ta sử dụng cấu trúc DSU để duy trì các thành phần liên thông trong suốt quá trình xử lý. Tuy nhiên, do mỗi truy vấn loại một cần truy xuất thông tin cực đại trong toàn bộ thành phần, ta không gộp trực tiếp hai tập hợp trong DSU mà xây dựng DSU-Tree.
Ban đầu, ta sẽ xây dựng DSU-Tree như đã nhắc trên cách xây dựng.
Trước tiên, ta đánh dấu tất cả các cạnh sẽ bị xóa trong tương lai. Sau đó, các cạnh không bao giờ bị xóa được thêm ngay từ đầu vào DSU-tree. Tiếp theo, ta duyệt các truy vấn theo thứ tự ngược. Nếu gặp truy vấn xóa cạnh, ta thực hiện thao tác hợp nhất hai đầu mút của cạnh đó trong DSU-tree. Nếu gặp truy vấn loại một, ta lưu lại gốc DSU tương ứng của đỉnh được hỏi tại thời điểm đó. Sau bước này, mỗi truy vấn loại một đã được đánh dấu tới một đỉnh cụ thể trong DSU-Tree.
Sau khi DSU-Tree được xây dựng hoàn chỉnh, ta thực hiện một phép duyệt DFS trên cây này để gán cho mỗi đỉnh một thời điểm vào ~tin[v]~ và thời điểm ra ~tout[v]~. Nhờ tính chất của DFS, toàn bộ cây con của một đỉnh ~v~ sẽ tương ứng với một đoạn liên tiếp ~[tin[v], tout[v]]~ trong thứ tự Euler. Do đó, truy vấn tìm giá trị lớn nhất trong một thành phần liên thông được chuyển thành truy vấn tìm giá trị lớn nhất trên một đoạn.
Ta xây dựng một cây phân đoạn trên mảng Euler. Tại vị trí ~tin[v]~, ta lưu cặp ~(p_v, tin[v])~, trong đó ~p_v~ là giá trị ban đầu của đỉnh ~v~. Mỗi nút của cây đoạn lưu cặp có giá trị lớn nhất. Khi xử lý một truy vấn loại một, ta thực hiện truy vấn cực đại trên đoạn tương ứng với cây con. Sau khi lấy được giá trị lớn nhất, ta cập nhật vị trí tương ứng trong cây đoạn về ~0~ để phản ánh việc giá trị đó đã bị loại bỏ.
Việc xây dựng DSU-Tree và thực hiện Euler Tour có độ phức tạp tuyến tính theo số đỉnh và số cạnh. Mỗi truy vấn cây đoạn được xử lý trong thời gian ~\mathcal{O}(\log n)~. Do đó, tổng độ phức tạp thời gian của thuật toán là ~\mathcal{O}{(n + m + q)\log n}~.
Code tham khảo
#include <bits/stdc++.h>
using namespace std;
const int MAXN = 500000 + 5;
int n, m, q;
int val[MAXN];
pair<int,int> edges[MAXN];
pair<int,int> queries[MAXN];
bool deleted[MAXN];
int parent[MAXN];
vector<int> tree[MAXN];
int tin[MAXN], tout[MAXN], timer;
pair<int,int> seg[4 * MAXN];
int find_set(int v) {
if (v == parent[v]) return v;
return parent[v] = find_set(parent[v]);
}
void unite(int a, int b) {
a = find_set(a);
b = find_set(b);
if (a == b) return;
++n;
parent[n] = n;
parent[a] = n;
parent[b] = n;
tree[n].push_back(a);
tree[n].push_back(b);
}
void dfs(int v) {
tin[v] = ++timer;
for (int to : tree[v]) {
dfs(to);
}
tout[v] = timer;
}
void build(int v, int tl, int tr) {
if (tl == tr) {
seg[v] = {0, 0};
return;
}
int tm = (tl + tr) >> 1;
build(v<<1, tl, tm);
build(v<<1|1, tm+1, tr);
seg[v] = max(seg[v<<1], seg[v<<1|1]);
}
void update(int v, int tl, int tr, int pos, pair<int,int> x) {
if (tl == tr) {
seg[v] = x;
return;
}
int tm = (tl + tr) >> 1;
if (pos <= tm) update(v<<1, tl, tm, pos, x);
else update(v<<1|1, tm+1, tr, pos, x);
seg[v] = max(seg[v<<1], seg[v<<1|1]);
}
pair<int,int> query(int v, int tl, int tr, int l, int r) {
if (l > r) return {0, 0};
if (l <= tl && tr <= r) return seg[v];
int tm = (tl + tr) >> 1;
return max(
query(v<<1, tl, tm, l, min(r, tm)),
query(v<<1|1, tm+1, tr, max(l, tm+1), r)
);
}
int main() {
ios::sync_with_stdio(false);
cin.tie(nullptr);
cin >> n >> m >> q;
for (int i = 1; i <= n; i++) {
cin >> val[i];
parent[i] = i;
}
for (int i = 1; i <= m; i++) {
cin >> edges[i].first >> edges[i].second;
}
for (int i = 1; i <= q; i++) {
cin >> queries[i].first >> queries[i].second;
if (queries[i].first == 2) {
deleted[queries[i].second] = true;
}
}
for (int i = 1; i <= m; i++) {
if (!deleted[i]) {
unite(edges[i].first, edges[i].second);
}
}
for (int i = q; i >= 1; i--) {
if (queries[i].first == 2) {
int id = queries[i].second;
unite(edges[id].first, edges[id].second);
} else {
queries[i].second = find_set(queries[i].second);
}
}
for (int i = 1; i <= n; i++) {
if (find_set(i) == i) {
dfs(i);
}
}
build(1, 1, n);
for (int i = 1; i <= n; i++) {
update(1, 1, n, tin[i], {val[i], tin[i]});
}
for (int i = 1; i <= q; i++) {
if (queries[i].first == 1) {
int v = queries[i].second;
auto res = query(1, 1, n, tin[v], tout[v]);
cout << res.first << '\n';
if (res.first > 0) {
update(1, 1, n, res.second, {0, 0});
}
}
}
return 0;
}
Bình luận