# Giới thiệu về Virtual/Auxiliary Tree (Cây ảo) [TOC] ## Bài toán mở đầu Cho một cây gồm $N$ đỉnh không trọng số với $Q$ truy vấn. Mỗi truy vấn có dạng : - $k \ a_1 \ a_2 \ a_3 \ ... \ a_k$ (với $a_i \neq a_j$). Tính tổng $d(a_i, a_j)$ với mọi $1 \leq i < j \leq k$, trong đó $d(u, v)$ là khoảng cách ngắn nhất từ đỉnh $u$ đến đỉnh $v$ trên cây. Giới hạn : $1 \leq N, Q \leq 3 \times 10^5$, tổng $k$ trong $Q$ truy vấn không vượt quá $3 \times 10^5$. ## Hướng tiếp cận : Trâu tất cả các cặp - Trong thuật toán trâu, trong mỗi truy vấn ta chỉ cần duyệt qua $C^2_k$ cặp đỉnh và tính tổng $d(a_i, a_j)$. Nếu ta sử dụng Nhảy nhị phân (Binary jumping) thì ta sẽ tính được một cặp trong độ phức tạp $O(\log_2(n))$, còn sử dụng Đường đi Euler trên cây sẽ cho ta độ phức tạp "ổn hơn" là $O(1)$. Tuy nhiên thì cả 2 phương pháp tính từng cặp này sẽ không hiệu quả. - Độ phức tạp tệ nhất sẽ là $O(N^2)$ ::: spoiler **Code mẫu** ::: ## Hướng tiếp cận : Trâu O(N) - Ta có một nhận xét quan trọng sau : + Trong một truy vấn, ta giả sử xét cạnh $(u, v)$, nếu khi **loại bỏ cạnh này** mà **hai thành phần liên thông** sinh ra vẫn tồn tại ít nhất một đỉnh $\in$ $k$ đỉnh trong truy vấn thì ta sẽ cộng đáp án lên **tích** số đỉnh $\in$ thành phần liên thông thứ nhất với số đỉnh $\in$ thành phần liên thông thứ hai. + Hãy xem hình sau : ![](https://hackmd.io/_uploads/SkrenMEba.png) + Lúc này, cặp $(u, v)$ mà ta đang xét sẽ có $X \times Y$ cặp đỉnh $(a_i, a_j)$ với lúc này $a_i \in$ thành phần liên thông thứ nhất và $a_j \in$ thành phần liên thông thứ hai. ![](https://hackmd.io/_uploads/rJC63f4WT.png) - Vậy bài toán của ta sẽ trở thành : đếm số lượng các cặp đỉnh $(a_i, a_j)$ đi qua cạnh $(u, v)$ nào đó. Đáp án của ta chính là tổng số cặp đỉnh thỏa điều kiện với mỗi cạnh $(u, v)$ - Độ phức tạp của ta bây giờ với mỗi truy vấn là $O(N)$, nên độ phức tạp tệ nhất là $O(Q \times N)$ ::: spoiler **Code mẫu** ::: ## Hướng tiệp cận : Dựng Cây Ảo - Từ hướng tiệp cận $O(N)$ với mỗi truy vấn, ta cần có một số tối ưu để thuật toán nhanh hơn. Giả sử hai đỉnh $u$ và $v$ đều $\in k$ đỉnh trong truy vấn và giả thuyết giữa $u$ và $v$ không tồn tại đỉnh nào khác thuộc những đỉnh trong truy vấn. ![](https://hackmd.io/_uploads/ryqW0fEbT.png) - Ta thấy rằng giữa $u$ và $v$ vốn không có đỉnh nào khác "đặc biệt", nhưng ta lại phải duyệt cả $T$ cạnh ($T$ là $d(u, v)$). Vậy nên ta cần có một "thứ gì đó" để dựng lại một đồ thị khác gồm không quá nhiều đỉnh (vì nếu không thuật toán sẽ trở lại $O(N)$ cho mỗi truy vấn) và số cạnh cần xét là tối ưu nhất có thể. ### Trọng số cạnh - Thay vì "duyệt lại" $d(u, v)$, ta sẽ **gán** trọng số trong đồ thị mới với cạnh $(u, v)$ là $d(u, v)$. Ta đã giải quyết được **vấn đề** duyệt cạnh. ![](https://hackmd.io/_uploads/rJvYV74Z6.png) ### Đỉnh "quan trọng" - Tuy nhiên một vấn đề mới lại được đặt ra là **số đỉnh** trong **cây mới** ta cần dựng ít nhất là bao nhiêu? - **Câu trả lời** là nhỏ hơn $2k$. - Tập hợp những đỉnh "quan trọng" đó chắc chắn sẽ bao gồm $k$ đỉnh trong truy vấn - Ta sẽ **sắp xếp** lại $a$ của ta theo **thứ tự duyệt DFS** và thêm tập hợp của ta **tổ tiên chung gần nhất** của $k-1$ cặp $(a_i, a_{i+1})$. Tập hợp những đỉnh đó của ta bây giờ sẽ chính là số đỉnh cần thiết cho cây mới. :::spoiler **Chứng minh** - Giả sử xét hai đỉnh $u$ và $v$ trong truy vấn hiện tại. Sẽ có hai trường hợp sau : + $u$ hoặc $v$ là tổ tiên của đỉnh còn lại : trường hợp này ta không cần xét vì trong tập hợp gốc của ta vốn chứa đỉnh là tổ tiên của đỉnh còn lại. + $u$ và $v$ nằm trong hai cây con $T_u$ và $T_v$ phân biệt. Không mất tính tổng quát, giả sử ta duyệt $u$ trước $v$ theo thứ tự DFS. Nếu $u$ kề $v$ sau khi sắp xếp theo **thứ tự từ điển**, $LCA(u, v)$ sẽ xuất hiện trong tập hợp của ta. Ngược lại, điều này đồng nghĩa với việc khi ta xét đoạn $p_l=u, p_{l+1}, p_{l+2}, ..., p_r=v$, ta có $p_{l+1}, p_{l+2}, ... p_{r-1} \in T_u$, nên $LCA(p_l=u, v) = LCA(p_{l+1}, v) = LCA(p_{l+2}, v) = ... = LCA(p_{r-1}, v)$ ::: ### Dựng cạnh - Bây giờ ta sẽ đến với việc xây dựng cạnh cho Cây Ảo của chúng ta. - Sau khi dựng hết tập hợp những đỉnh cần thiết, ta lại tiếp tục sắp xếp chúng theo thứ tự duyệt DFS. - Ta sẽ từ thứ tự DFS và Đường đi Euler trên cây để "tìm" lại các cạnh. - Giả sử ta đang có tập hợp đỉnh sau khi sắp xếp lại là $r_1, r_2, r_3, ...r_q$. Ta cần chọn ra một **gốc** để đặt làm đỉnh của cây khi dựng cạnh, để xử lý bước này dễ nhất có thể, ta sẽ chọn **đỉnh có thức tự DFS sớm nhất**, chính là $r_1$. - Tiếp theo, ta cần phải tìm **cha** cho các đỉnh còn lại. Để tìm cha của một đỉnh, ta cần phải quan tâm **nhánh cây**, các bạn hãy xem hình sau - Khi chuyển từ nhánh $r_1 \ - \ ... \ - \ u \ - \ v$ sang nhánh $r_1 \ - \ ... \ - \ u \ - \ w$, ta cần phải có một **cấu trúc dữ liệu** để lưu lại một phần của nhánh trước đó. Đó chính là `stack`. - Ý tưởng là `stack` ban đầu của ta chứa $r_1$, ta sẽ xét từ $i=2, 3, ..., q$, nếu đầu `stack` hiện tại vẫn không chứa đỉnh $r_i$ (tức là đã sinh ra nhánh cây mới), thì ta sẽ `pop` cho đến khi nào cây con đầu `stack` của ta chứa $r_i$. Sau đó, ta sẽ thêm cạnh $r_i$ hiện tại với đầu `stack` (hay chính là cha của $p_i$). - Các bạn có thể xem ví dụ sau để dễ hình sau: ![](https://hackmd.io/_uploads/S1FNeMB-T.png) - Ta gọi `stack` của ta là $s$. hiện tại, nhánh của ta đang là $s=${$r_1, ..., u, ..., v$}. Lúc này $u$ là tổ tiên chung gần nhất giữa $v$ và $w$. Khi tới đỉnh $w$ ta sẽ lần lượt `pop` các đỉnh sau đỉnh $u$, sau đó `stack` của ta sẽ trở thành $s=${$r_1, ..., u$}, sau đó ta sẽ thêm cạnh $(u, w)$ và `push` $w$ vào $s$. - Vậy ta đã kết thúc việc xây dựng ý tưởng của thuật toán mở đầu. :::spoiler **Code mẫu** ```cpp= #include<bits/stdc++.h> using namespace std; const int N=3e5+5; int n, q, dfs_timer, VirtualTreeSize, h[N], tin[N], tout[N], par[N][20], sz[N]; vector<int> g[N], minigraph[N]; /* NOTE sz[u] : tổng số đỉnh thuộc truy vấn trong cây con gốc u của Cây ảo g là danh sách kề của cây gốc minigraph là danh sách kề của Cây ảo */ void dfs(int u, int e){ tin[u]=++dfs_timer; for(int v:g[u]) if(v!=e){ h[v]=h[u]+1; par[v][0]=u; for(int i=1; i<=17; ++i){ par[v][i]=par[par[v][i-1]][i-1]; } dfs(v, u); } tout[u]=dfs_timer; } //Kiểm tra nếu cây con gốc u chứa đỉnh v bool inside(int u, int v){ return tin[u]<=tin[v] and tout[v]<=tout[u]; } //Tìm tổ tiên chung gần nhất giữa u và v int getLCA(int u, int v){ if(inside(u, v)) return u; if(inside(v, u)) return v; for(int i=17; i>=0; --i){ if(h[u]>=(1<<i) and !inside(par[u][i], v)){ u=par[u][i]; } } return par[u][0]; } //Hàm so sánh thứ tự duyệt DFS bool dfsTimer(int u, int v){ return tin[u]<tin[v]; } //Duyệt xử lý trên Cây ảo được nén từ Cây gốc long long dfs_minigraph(int u, int e){ long long res=0; for(int v:minigraph[u]) if(v!=e){ //Tổng đáp án của mỗi cạnh trước đó res+=dfs_minigraph(v, u); //tính tổng số đỉnh thuộc truy vấn trong cây con gốc u sz[u]+=sz[v]; } if(e!=-1){ // Dễ thấy do e là cha của u trên Cây ảo // nên d(e, u) cũng chính là h[u]-h[e] // sz[u] là số đỉnh thuộc truy vấn trong cây con gốc u // VirtualTreeSize - sz[u] là số đỉnh thuộc truy vấn // nằm ngoài cây con gốc res+=1ll*(h[u]-h[e])*sz[u]*(VirtualTreeSize-sz[u]); } return res; } void solve_query(){ //Input truy vấn int k; cin>>k; vector<int> nodes(k); for(int i=0; i<k; ++i) { cin>>nodes[i]; //Do trong Cây ảo, ta sử dụng a[i] hay lúc này là nodes[i] //Nên cần phải đánh dấu lại sz[nodes[i]]=1; } //Sắp xếp theo thứ tự DFS sort(nodes.begin(), nodes.end(), dfsTimer); //Thêm vào tập hợp LCA của nodes[i] và nodes[i+1] for(int i=0; i+1<k; ++i){ nodes.push_back(getLCA(nodes[i], nodes[i+1])); } //Lọc các giá trị phân biệt sort(nodes.begin(), nodes.end(), dfsTimer); nodes.erase(unique(nodes.begin(), nodes.end()), nodes.end()); //Khởi tạo stack bằng gốc của Cây ảo vector<int> s={nodes[0]}; for(int i=1; i<nodes.size(); ++i){ //Khi cây con đầu stack không chứa nodes[i] thì pop while(!inside(s.back(), nodes[i])){ s.pop_back(); } //Thêm cạnh đầu stack hiện tại với nodes[i] int p=s.back(); minigraph[p].push_back(nodes[i]); minigraph[nodes[i]].push_back(p); s.push_back(nodes[i]); } //Xử lý bài toán mở đầu VirtualTreeSize=nodes.size(); cout<<dfs_minigraph(s[0], -1)<<'\n'; //Reset lại đồ thị Cây ảo for(int i=0; i<VirtualTreeSize; ++i){ minigraph[nodes[i]].clear(); sz[nodes[i]]=0; } } int main(){ ios_base::sync_with_stdio(0); cin.tie(NULL); cout.tie(NULL); cin>>n>>q; for(int i=1; i<n; ++i){ int u, v; cin>>u>>v; g[u].push_back(v); g[v].push_back(u); } dfs(1, 1); while(q--){ solve_query(); } return 0; } ``` ::: # Thuật toán Virtual Tree (Cây ảo) - Sắp xếp $a_1, a_2, a_3, ..., a_k$ theo thứ tự DFS. - Thêm vào tập hợp những đỉnh quan trọng trong Cây ảo ta dựng $a_1, a_2, a_3, ..., a_k, LCA(a_1, a_2), LCA(a_2, a_3), ..., LCA(a_{k-1}, a_k)$. - Dựng lại các cạnh với trọng số bằng `stack`. Khi cây con đầu `stack` của ta không chứa đỉnh đang xét hiện tại thì ta sẽ `pop` cho đến khi gặp. - Độ phức tạp : $O(3 \times 10^5 \times \log n)$ với mỗi truy vấn sử dụng độ phức tạp $O(k \times \log n)$ :::spoiler **Code mẫu** ```cpp= #include<bits/stdc++.h> using namespace std; const int N=3e5+5; int n, q, dfs_timer, VirtualTreeSize, h[N], tin[N], tout[N], par[N][20], sz[N]; vector<int> g[N], minigraph[N]; void dfs(int u, int e){ tin[u]=++dfs_timer; for(int v:g[u]) if(v!=e){ h[v]=h[u]+1; par[v][0]=u; for(int i=1; i<=17; ++i){ par[v][i]=par[par[v][i-1]][i-1]; } dfs(v, u); } tout[u]=dfs_timer; } bool inside(int u, int v){ return tin[u]<=tin[v] and tout[v]<=tout[u]; } int getLCA(int u, int v){ if(inside(u, v)) return u; if(inside(v, u)) return u; for(int i=17; i>=0; --i){ if(h[u]>=(1<<i) and !inside(par[u][i], v)){ u=par[u][i]; } } return par[u][0]; } int getDist(int u, int v){ return h[u]+h[v]-2*h[getLCA(u, v)]; } bool dfsTimer(int u, int v){ return tin[u]<tin[v]; } void solve_query(){ int k; cin>>k; vector<int> nodes(k); for(int i=0; i<k; ++i) { cin>>nodes[i]; sz[nodes[i]]=1; } sort(nodes.begin(), nodes.end(), dfsTimer); for(int i=0; i+1<k; ++i){ nodes.push_back(getLCA(nodes[i], nodes[i+1])); } sort(nodes.begin(), nodes.end(), dfsTimer); nodes.erase(unique(nodes.begin(), nodes.end()), nodes.end()); vector<int> s={nodes[0]}; for(int i=1; i<nodes.size(); ++i){ while(!inside(s.back(), nodes[i])){ s.pop_back(); } int p=s.back(); minigraph[p].push_back(nodes[i]); minigraph[nodes[i]].push_back(p); s.push_back(nodes[i]); } //Xử lý bài toán sau khi dựng xong Cây ảo } int main(){ ios_base::sync_with_stdio(0); cin.tie(NULL); cout.tie(NULL); cin>>n>>q; for(int i=1; i<n; ++i){ int u, v; cin>>u>>v; g[u].push_back(v); g[v].push_back(u); } dfs(1, 1); while(q--){ solve_query(); } return 0; } ``` ::: # Bài tập áp dụng [Codeforces Round 339 (Div. 1) D](https://codeforces.com/contest/613/problem/D) - Tóm tắt đề bài : Cho cây $n$ đỉnh không trọng số và $q$ truy vấn dạng $k \ a_1 \ a_2 \ ... \ a_k$. Với mỗi truy vấn, cần đếm số lượng đỉnh **ít nhất và khác** các đỉnh trong truy vấn cần được bỏ đi để $k$ đỉnh trong truy vấn **đôi một không liên thông** với nhau. [Bedao OI Contest 1 - Bài 1 : Đếm cầu](https://oj.vnoi.info/problem/bedao_oi1_a) - Tóm tắt đề bài : Cho **đa đồ thị** $n$ đỉnh $m$ cạnh và $q$ truy vấn. Mỗi truy vấn dạng $a \ b \ c \ d$, cần đếm số lượng **cầu** từ bất kì đường đi đơn từ $c$ đến $d$ sau khi đã thêm cạnh $(a, b)$ vào đồ thị gốc. (Lưu ý, sau mỗi truy vấn, đồ thị gốc không thay đổi)