Virtual Tree
Virtual Tree (cây ảo, còn gọi auxiliary tree) là kỹ thuật nén một cây đỉnh thành một cây con nhỏ chỉ chứa tập đỉnh quan trọng (key vertices) và các tổ tiên chung thấp nhất (LCA) cần thiết của chúng. Nhờ đó, một truy vấn chỉ liên quan tới đỉnh có thể được xử lý trong thay vì — cực kỳ hữu ích khi có nhiều truy vấn với tổng .
Bài toán điển hình: cho cây đỉnh và truy vấn, mỗi truy vấn cho một tập đỉnh đặc biệt; cần tính một đại lượng (DP, Steiner tree, khoảng cách...) chỉ phụ thuộc vào quan hệ giữa các đỉnh đặc biệt đó. Nếu chạy DP trên toàn cây mỗi truy vấn thì tốn , thường quá lớn. Virtual tree cho phép mỗi truy vấn chỉ làm việc trên đỉnh, đưa tổng về khi đề bảo đảm (hoặc ).
Ý tưởng / Trực giác
Vì sao chỉ cần giữ lại các đỉnh quan trọng và LCA của chúng?
Trên cây, mọi "đường đi" hay "cấu trúc" liên kết tập các đỉnh quan trọng đều đi qua các điểm rẽ nhánh. Một điểm rẽ nhánh chỉ xuất hiện ở chỗ hai nhánh chứa đỉnh quan trọng gặp nhau, tức là tại một LCA của hai đỉnh quan trọng nào đó. Mọi đỉnh khác của cây gốc chỉ nằm trên một đoạn đường thẳng (không rẽ), nên ta có thể "nén" cả đoạn đó thành một cạnh có trọng số trong cây ảo (trọng số = khoảng cách thật) mà không mất thông tin.
Hai quan sát then chốt:
Sắp theo Euler tour (thời điểm vào DFS
tin[]) thì chỉ cần xét LCA của các cặp LIÊN TIẾP. Nếu là tập đỉnh đã sắp tăng theotin, thì tập tất cả các LCA "có ý nghĩa" của mọi cặp đúng bằng . Lý do: LCA của hai đỉnh bất kỳ luôn nằm trong dãy LCA của các cặp liên tiếp giữa chúng (LCA có depth nhỏ nhất). Đây là điều giúp ta không phải xét cặp mà chỉ .Số đỉnh cây ảo . Mỗi đỉnh thêm vào là một LCA của hai đỉnh quan trọng; số điểm rẽ nhánh phân biệt không vượt quá . Vậy cây ảo nhỏ tuyến tính theo .
Cách xây dựng dùng stack mô phỏng việc đi dọc "biên phải" của cây trong thứ tự DFS: stack luôn giữ chuỗi tổ tiên hiện tại từ gốc ảo xuống. Khi thêm đỉnh mới , ta tính ; mọi đỉnh trong stack sâu hơn đã "đóng nhánh" nên được nối cạnh và pop ra, sau đó đẩy (nếu cần) rồi đẩy .
Ví dụ chạy tay
Cây gốc (gốc = 1), depth ghi bên cạnh:
1 (d=0)
/ \
(d=1)2 3 (d=1)
/ \
(d=2)4 5 (d=2)
/
(d=3)6
Giả sử DFS thăm con theo thứ tự tăng dần số hiệu, ta được tin:
đỉnh: 1 2 4 6 5 3
tin : 0 1 2 3 4 5
Tập đỉnh quan trọng: .
Bước 1 — sắp theo tin: tin[6]=3, tin[5]=4, tin[3]=5 → đã đúng thứ tự [6, 5, 3].
Bước 2 — thêm LCA cặp liên tiếp:
Gộp lại: . Sắp theo tin: 1(0), 2(1), 6(3), 5(4), 3(5) → danh sách [1, 2, 6, 5, 3].
Bước 3 — dựng cây bằng stack. Ký hiệu stack từ đáy→đỉnh, ^ chỉ đỉnh stack.
xét 1: stack rỗng -> push 1 stack: [1]
^
xét 2: l = lca(2,1) = 1 = top -> push 2 stack: [1, 2]
^
xét 6: l = lca(6,2) = 2 = top -> push 6 stack: [1, 2, 6]
^
xét 5: l = lca(5,6) = 2 != top(6)
depth[6]=3 > depth[2]=2 -> pop 6, nối cạnh (2,6)
top giờ là 2 = l -> dừng pop, push 5
edges: {(2,6)} stack: [1, 2, 5]
^
xét 3: l = lca(3,5) = 1 != top(5)
depth[5]=2 > depth[1]=0 -> pop 5, nối cạnh (2,5)
depth[2]=1 > depth[1]=0 -> pop 2, nối cạnh (1,2)
top giờ là 1 = l -> dừng pop, push 3
edges: {(2,6),(2,5),(1,2)} stack: [1, 3]
^
hết đỉnh: xả stack: pop 3 nối (1,3)
edges: {(2,6),(2,5),(1,2),(1,3)}
Cây ảo thu được (5 đỉnh, đúng ):
1
/ \
2 3
/ \
6 5
Đỉnh 4 (không quan trọng, không là LCA) đã bị loại. Cạnh ảo thực ra "che" đoạn dài 1; nếu cần trọng số ta gán .
Cài đặt
#include <bits/stdc++.h>
using namespace std;
const int MAXN = 2e5 + 5;
const int LOG = 18;
vector<int> adj[MAXN];
int tin[MAXN], dep[MAXN], up[MAXN][LOG];
int timer_val = 0;
// DFS tính tin[], depth[], bảng nhảy nhị phân up[][]
void dfs(int u, int p) {
tin[u] = timer_val++;
up[u][0] = p;
for (int j = 1; j < LOG; j++)
up[u][j] = up[ up[u][j-1] ][j-1];
for (int v : adj[u])
if (v != p) { dep[v] = dep[u] + 1; dfs(v, u); }
}
int lca(int u, int v) {
if (dep[u] < dep[v]) swap(u, v);
int d = dep[u] - dep[v];
for (int j = 0; j < LOG; j++)
if (d >> j & 1) u = up[u][j];
if (u == v) return u;
for (int j = LOG - 1; j >= 0; j--)
if (up[u][j] != up[v][j]) { u = up[u][j]; v = up[v][j]; }
return up[u][0];
}
// Danh sách kề của CÂY ẢO; nhớ xoá sạch sau mỗi truy vấn
vector<int> vadj[MAXN];
// Xây virtual tree từ tập key[], trả về GỐC của cây ảo.
// Sau khi gọi, vadj[] chứa các cạnh có hướng (cha -> con).
int build_virtual_tree(vector<int> key) {
// 1) sắp theo Euler tour và khử trùng lặp
sort(key.begin(), key.end(),
[](int a, int b){ return tin[a] < tin[b]; });
key.erase(unique(key.begin(), key.end()), key.end());
// 2) bổ sung LCA của các cặp LIÊN TIẾP (chỉ O(K) cặp là đủ)
int m = key.size();
for (int i = 0; i + 1 < m; i++)
key.push_back(lca(key[i], key[i + 1]));
// sắp + khử trùng lần nữa sau khi đã thêm LCA
sort(key.begin(), key.end(),
[](int a, int b){ return tin[a] < tin[b]; });
key.erase(unique(key.begin(), key.end()), key.end());
// 3) dựng cây bằng stack mô phỏng biên phải DFS
for (int u : key) vadj[u].clear(); // dọn trước khi dùng
stack<int> st;
int root = key[0]; // đỉnh có tin nhỏ nhất là gốc ảo
for (int u : key) {
if (st.empty()) { st.push(u); continue; }
int l = lca(u, st.top());
// pop mọi đỉnh sâu hơn l và nối cạnh
while (st.size() >= 2 && dep[ st.top() ] > dep[l]) {
int child = st.top(); st.pop();
int par = (dep[st.top()] >= dep[l]) ? st.top() : l;
vadj[par].push_back(child);
}
if (st.top() != l) { // l chưa có trong stack
vadj[l].push_back(st.top());
st.pop();
st.push(l);
root = (tin[l] < tin[root]) ? l : root;
}
st.push(u);
}
// xả phần còn lại
while (st.size() >= 2) {
int child = st.top(); st.pop();
vadj[st.top()].push_back(child);
}
return root;
}
Ví dụ áp dụng — tổng độ dài Steiner tree nối tập đỉnh quan trọng (tổng độ dài cây con nhỏ nhất bao phủ chúng) có thể tính trực tiếp không cần dựng cây ảo, nhưng minh hoạ rõ vai trò LCA:
// dist[u] = khoảng cách (số cạnh hoặc trọng số) từ gốc tới u
long long steiner_len(vector<int> key, vector<long long>& dist) {
sort(key.begin(), key.end(),
[](int a, int b){ return tin[a] < tin[b]; });
key.erase(unique(key.begin(), key.end()), key.end());
long long ans = 0;
int m = key.size();
// cộng dist của cạnh "vòng quanh" theo thứ tự Euler rồi chia 2
for (int i = 0; i < m; i++) {
int u = key[i], v = key[(i + 1) % m];
ans += dist[u] + dist[v] - 2 * dist[ lca(u, v) ]; // = dist(u,v)
}
return ans / 2; // mỗi cạnh được đếm đúng 2 lần
}
Độ phức tạp
- Tiền xử lý: DFS + bảng nhảy nhị phân thời gian, bộ nhớ cho mảng
up[][]. - Mỗi LCA: với binary lifting (hoặc nếu dùng Euler + Sparse Table).
- Xây một virtual tree với đỉnh quan trọng: chi phí bị chi phối bởi hai lần
sort() và lần gọi LCA (). Vòng stack chỉ vì mỗi đỉnh được push/pop đúng một lần. Vậy mỗi truy vấn . - Tổng cho truy vấn: . Khi đề bảo đảm , tổng là — thay vì của cách chạy DP trên toàn cây.
- Bộ nhớ: cho bảng LCA, cộng tạm thời cho mỗi cây ảo (phải dọn lại
vadj[]sau mỗi truy vấn, không clear toàn bộ đỉnh kẻo mất tính ).
⚠️ Lỗi thường gặp
- Clear toàn bộ
vadj[1..N]mỗi truy vấn → TLE. Việc này biến mỗi truy vấn thành , tổng , đánh mất toàn bộ lợi ích của virtual tree. Chỉ được xoá đúng các đỉnh nằm trong cây ảo lần này (clear khi push, hoặc lưu danh sách đỉnh đã dùng để clear lại). - Quên thêm gốc / LCA chung của cả tập. Nếu chỉ thêm LCA của các cặp liên tiếp mà không sắp + khử trùng lần nữa, gốc thật của cây ảo (LCA của toàn bộ tập) có thể bị thiếu, làm cây ảo rời rạc (rừng). Sau khi push các LCA phải sort lại theo
tinrồiunique. - Dùng
depthđể phân biệt thay vìtinkhi sắp xếp. Thứ tự dựng cây phải theo Euler tourtin, không phải theo độ sâu. Sắp theodepthlàm tính chất "LCA cặp liên tiếp là đủ" không còn đúng và cây dựng sai. - Sai biên trong vòng pop: điều kiện phải là
dep[st.top()] > dep[l](lớn hơn thật sự). Dùng>=sẽ pop nhầm chính đỉnh khi nó đã nằm trong stack, gây nối cạnh sai hoặc vòng lặp hỏng. Luôn giữ lại ít nhất 1 phần tử (st.size() >= 2). - Tràn số khi tính khoảng cách / Steiner. Với tới và trọng số cạnh lớn, tổng khoảng cách dễ vượt
int. Dùnglong longchodist[]và biến cộng dồn. up[u][0]của gốc trỏ về chính nó (hoặc 0 ảo). Nếu đểup[root][0]không khởi tạo,lcacó thể nhảy ra ngoài mảng. Đặtup[root][0] = root(gốc tự làm cha) là an toàn.- Trường hợp biên . Cây ảo chỉ có một đỉnh, không có cạnh; code phải chạy đúng (vòng for thêm LCA không chạy, stack xả về 1 phần tử). Đừng giả định luôn có ít nhất một cạnh.
Biến thể / Mở rộng
- DP trên cây ảo: sau khi dựng
vadj[], chạy DFS trên cây ảo với trạng thái phân biệt "đỉnh quan trọng" và "đỉnh Steiner (LCA thêm vào)". Đây là khung chuẩn cho các bài DP nhiều truy vấn (vd CF Tree and Queries dạng nâng cao). - LCA : thay binary lifting bằng Euler tour + Sparse Table trên mảng độ sâu để mỗi LCA còn , đưa mỗi truy vấn về thuần do sort.
- Cạnh có trọng số: lưu thêm
dis[u]= khoảng cách từ gốc tới ; trọng số cạnh ảo . - Liên hệ: kỹ thuật này dựa trực tiếp trên LCA và Euler tour, xem thêm Cây Fenwick / Segment Tree cho các bài kết hợp truy vấn trên đường đi.
Bài tập luyện
Lưu ý: pool CTOJ hiện chưa có bài thuần virtual tree. Các bài dưới đây rèn nền tảng bắt buộc của kỹ thuật này (LCA, khoảng cách qua LCA, gộp thông tin trên đường đi) — nắm vững chúng trước khi làm bài cây ảo thật.
- Truy vấn khoảng cách (distqry) — (Advanced) tính khoảng cách giữa hai đỉnh bằng — đúng công thức trọng số cạnh ảo.
- Tổ tiên chung thấp nhất (compqry2) — (Advanced) cài LCA bằng binary lifting trong , viên gạch lõi để dựng virtual tree.
- Cú Bay Đêm (running) — (Veteran) kết hợp LCA với difference-on-tree để cộng dồn theo đường đi ; luyện tư duy "đường đi tách tại LCA" mà cây ảo khai thác.