Với mỗi truy vấn QUERY(u, v), ta cần duyệt toàn bộ đường đi từ \(u\) lên LCA rồi xuống \(v\). Cách naïve mất \(O(N)\) mỗi truy vấn, \(Q\) truy vấn sẽ là \(O(N \cdot Q)\) — quá chậm với \(N, Q = 10^5\).
Đường đi trên cây không phải là một đoạn liên tục trong mảng, nên không thể dùng Segment Tree trực tiếp.
Với mỗi đỉnh \(u\) (không phải lá), gọi \(sz[v]\) là kích thước cây con gốc \(v\).
Heavy edge của \(u\): cạnh nối \(u\) với con \(v\) mà \(sz[v]\) lớn nhất (nếu nhiều con cùng kích thước, chọn bất kỳ).
Light edge của \(u\): tất cả các cạnh còn lại từ \(u\) đến các con.
graph TD
A["1 (sz=10)"] -->|"heavy, sz=7"| B["3"]
A -->|"light, sz=4"| C["2"]
B --> D["6"]
B --> E["7"]
C --> F["4"]
C --> G["5"]
G --> H["8"]
Cạnh \((1, 3)\) là heavy edge vì \(sz[3] = 7 > sz[2] = 4\).
Tính chất cốt lõi: Light edges giảm một nửa kích thước¶
Định lý
Trên đường đi từ gốc đến bất kỳ lá, có tối đa \(\log N\) light edges.
Chứng minh: Khi đi qua một light edge từ \(u\) sang con \(v\), ta có \(sz[v] \leq sz[u]/2\) (vì \(v\) không phải heavy child). Vậy kích thước cây con giảm ít nhất một nửa. Sau tối đa \(\log N\) bước, kích thước giảm xuống 1.
Đây chính là "bí mật" khiến HLD hoạt động hiệu quả: mỗi lần nhảy qua light edge, cây con co lại ít nhất một nửa.
Mỗi heavy path (chuỗi các heavy edges liên tiếp) tạo thành một chain. Ta gán DFS order (thứ tự duyệt) sao cho mỗi chain là một đoạn liên tục trong mảng.
#include<bits/stdc++.h>usingnamespacestd;constintMAXN=100005;intn;vector<int>adj[MAXN];intparent[MAXN],depth[MAXN],sz[MAXN],heavy[MAXN];voiddfs(intu,intp){parent[u]=p;// Bước 1a: lưu cha của u để sau này nhảy chainsz[u]=1;// Bước 1b: ban đầu cây con chỉ có chính uheavy[u]=-1;// Bước 1c: giả sử u là lá (chưa có con nặng)intmax_sz=0;// Bước 1d: kích thước lớn nhất trong các con đã duyệtfor(intv:adj[u]){if(v==p)continue;// Bước 1e: bỏ qua cạnh về chadepth[v]=depth[u]+1;// Bước 1f: con sâu hơn cha 1 đơn vịdfs(v,u);// Bước 1g: đệ quy tính sz[v] và heavy[v] trướcsz[u]+=sz[v];// Bước 1h: cộng dồn kích thước cây conif(sz[v]>max_sz){// Bước 1i: con nào to nhất thì làm heavymax_sz=sz[v];heavy[u]=v;// Bước 1j: ghi nhận con nặng của u}}}
importsysfromcollectionsimportdefaultdictsys.setrecursionlimit(300000)n=0adj=defaultdict(list)parent=[0]*200005depth=[0]*200005sz=[0]*200005heavy=[-1]*200005defdfs(u,p):parent[u]=p# Bước 1a: lưu cha của usz[u]=1# Bước 1b: ban đầu cây con chỉ có chính uheavy[u]=-1# Bước 1c: giả sử u là lámax_sz=0# Bước 1d: kích thước lớn nhất trong các conforvinadj[u]:ifv==p:continue# Bước 1e: bỏ qua cạnh về chadepth[v]=depth[u]+1# Bước 1f: con sâu hơn cha 1dfs(v,u)# Bước 1g: đệ quy tính sz[v] trướcsz[u]+=sz[v]# Bước 1h: cộng dồn kích thướcifsz[v]>max_sz:max_sz=sz[v]heavy[u]=v# Bước 1i: con to nhất làm heavy
Thứ tự thăm đỉnh rất quan trọng: gốc, heavy child, heavy child của heavy child, rồi quay lại light children. Nhờ vậy, toàn bộ heavy path được gán liên tục trong mảng \(pos[]\).
intcur_pos;inthead[MAXN],pos[MAXN];intval[MAXN],arr[MAXN];voiddecompose(intu,inth){head[u]=h;// Bước 2a: h là đỉnh đầu chain chứa upos[u]=cur_pos;// Bước 2b: pos[u] là vị trí của u trong mảng phẳngarr[cur_pos]=val[u];// Bước 2c: đưa giá trị đỉnh u vào mảng arrcur_pos++;// Bước 2d: tăng con trỏ vị trí cho đỉnh tiếp theoif(heavy[u]!=-1){decompose(heavy[u],h);// Bước 2e: heavy child ở cùng chain (giữ nguyên h)}for(intv:adj[u]){if(v==parent[u]||v==heavy[u])continue;decompose(v,v);// Bước 2f: light child mở chain mới (head = chính nó)}}// Gọi trong main:// dfs(1, -1);// decompose(1, 1);
cur_pos=0head=[0]*200005pos_arr=[0]*200005val=[0]*200005seg_arr=[0]*200005defdecompose(u,h):globalcur_poshead[u]=h# Bước 2a: h là đỉnh đầu chain chứa upos_arr[u]=cur_pos# Bước 2b: pos là vị trí của u trong mảng phẳngseg_arr[cur_pos]=val[u]# Bước 2c: đưa giá trị u vào mảngcur_pos+=1# Bước 2d: tăng con trỏ vị tríifheavy[u]!=-1:decompose(heavy[u],h)# Bước 2e: heavy child ở cùng chainforvinadj[u]:ifv==parent[u]orv==heavy[u]:continuedecompose(v,v)# Bước 2f: light child mở chain mới# Gọi:# dfs(1, -1)# decompose(1, 1)
structSegTree{intn;vector<longlong>tree;voidinit(int_n){n=_n;tree.assign(4*n,0);// Bước 3a: cây rỗng, cần 4*n ô nhớ}voidbuild(intnode,inttl,inttr,longlonga[]){if(tl==tr){tree[node]=a[tl];// Bước 3b: nút lá = giá trị arr[tl]return;}inttm=(tl+tr)/2;// Bước 3c: chia đôi đoạn [tl, tr]build(2*node,tl,tm,a);// Bước 3d: dựng nửa tráibuild(2*node+1,tm+1,tr,a);// Bước 3e: dựng nửa phảitree[node]=tree[2*node]+tree[2*node+1];// Bước 3f: nút cha = tổng 2 con}voidupdate(intnode,inttl,inttr,intpos,longlongval){if(tl==tr){tree[node]=val;// Bước 3g: tới lá thì gán giá trị mớireturn;}inttm=(tl+tr)/2;// Bước 3h: quyết định đi trái hay phảiif(pos<=tm)update(2*node,tl,tm,pos,val);elseupdate(2*node+1,tm+1,tr,pos,val);tree[node]=tree[2*node]+tree[2*node+1];// Bước 3i: cập nhật lại tổng}longlongquery(intnode,inttl,inttr,intl,intr){if(l>tr||r<tl)return0;// Bước 3j: đoạn không giao nhauif(l<=tl&&tr<=r)returntree[node];// Bước 3k: đoạn nằm gọn trong truy vấninttm=(tl+tr)/2;// Bước 3l: chia đôi để truy vấn 2 nửareturnquery(2*node,tl,tm,l,r)+query(2*node+1,tm+1,tr,l,r);}};
SegTreest;longlongpath_query(intu,intv){longlongres=0;// Bước 4a: tích lũy đáp án đường điwhile(head[u]!=head[v]){// Bước 4b: còn khác chain thì còn phải nhảyif(depth[head[u]]>depth[head[v]])swap(u,v);// Bước 4c: đảm bảo v ở chain sâu hơnres+=st.query(1,0,n-1,pos[head[v]],pos[v]);// Bước 4d: cộng cả đoạn chain của vv=parent[head[v]];// Bước 4e: nhảy v lên cha của đầu chain (qua light edge)}if(depth[u]>depth[v])swap(u,v);// Bước 4f: cùng chain, đưa u lên trênres+=st.query(1,0,n-1,pos[u],pos[v]);// Bước 4g: cộng đoạn cuối cùngreturnres;}voidupdate_node(intu,longlongval){st.update(1,0,n-1,pos[u],val);// Bước 4h: đổi giá trị tại pos[u] trên SegTree}
st=SegTree(n)defpath_query(u,v):res=0# Bước 4a: tích lũy đáp ánwhilehead[u]!=head[v]:# Bước 4b: còn khác chain thì còn nhảyifdepth[head[u]]>depth[head[v]]:u,v=v,u# Bước 4c: đảm bảo v ở chain sâu hơnres+=st.query(1,0,n-1,pos_arr[head[v]],pos_arr[v])# Bước 4d: cộng cả đoạn chain của vv=parent[head[v]]# Bước 4e: nhảy v lên cha của đầu chainifdepth[u]>depth[v]:u,v=v,u# Bước 4f: cùng chain, đưa u lên trênres+=st.query(1,0,n-1,pos_arr[u],pos_arr[v])# Bước 4g: cộng đoạn cuốireturnresdefupdate_node(u,val):st.update(1,0,n-1,pos_arr[u],val)# Bước 4h: đổi giá trị tại pos[u]
Hàm decompose luôn thăm heavy child ngay sau đỉnh hiện tại (cùng giá trị \(head\)). Các light children được thăm sau, tạo chain mới. Do đó toàn bộ một heavy path nhận \(pos\) liên tiếp.
Tính chất 2: Đường đi bất kỳ gồm \(O(\log N)\) đoạn liên tục.
Đường đi từ \(u\) đến \(v\) đi qua LCA. Trên mỗi nửa đường (từ \(u\) lên LCA, từ \(v\) lên LCA), mỗi lần nhảy qua light edge, kích thước cây con giảm ít nhất một nửa. Số light edges tối đa là \(\log N\), nên số đoạn liên tục tối đa là \(2 \log N = O(\log N)\).
Tính chất 3: Subtree là đoạn liên tục.
Trong DFS, toàn bộ cây con của \(u\) được thăm liên tục trước khi quay lại. Do đó:
Chuỗi heavy edges liên tiếp, ứng với đoạn liên tục
\(pos[u]\)
Vị trí đỉnh \(u\) trong mảng DFS order
\(head[u]\)
Đỉnh đầu chain chứa \(u\)
Path query
\(O(\log^2 N)\) - nhảy chain + Segment Tree
Subtree query
\(O(\log N)\) - 1 lần Segment Tree
LCA
\(O(\log N)\) - nhảy chain
Điểm mấu chốt: HLD biến đường đi trên cây thành \(O(\log N)\) đoạn liên tục. Mỗi đoạn liên tục truy vấn Segment Tree mất \(O(\log N)\). Tổng mỗi truy vấn đường đi: \(O(\log^2 N)\).
Lời khuyên: Hãy hiểu rõ DFS order trên cây trước khi học HLD. HLD chỉ là "DFS order thông minh" — ưu tiên thăm heavy child trước!