虚树
虚树
所谓虚树,就是对于一棵指定的树 \(T\),构造一棵新的树 \(T'\) 使得总节点数最小且包含指定的某几个节点和它们的 LCA。OI Wiki 上的几张图就能说明:
虚树能解决什么问题?优化树形 DP。比如给定多组询问,每组询问给出树上的一些关键点,最终的答案只和这些关键点有关。那么此时我们如果每次都 \(O(n)\) 遍历一遍树的话总复杂度就会达到 \(O(nq)\sim O(nq\log n)\),超时;但建立虚树就会大大减少这个复杂度,最后的复杂度均摊在了 \(O(\sum k\log n)\) 级别。
建树
有两种建树方法,OI Wiki 上有讲。一般来说我们使用单调栈建树。
先预处理整棵树的 DFS 序和 LCA,然后对于每组询问的关键点构造虚树。
- 先对所有关键点 \(a_i\) 按照 DFS 序排序。
- 建一个栈 \(\text{stk}\),满足 \(\text{stk}(1)=\mathit{rt}\),\(\text{stk}(\mathit{top})=a_k\),\(\text{stk}(x)\) 为 \(x-1\) 的后代。每当 \(v\) 被弹出栈时,建立 \(u\to v\) 的边。当我们要给栈里加入一个新的节点 \(x\) 时,设 \(l=\operatorname{LCA}(x,\text{stk}(\mathit{top}))\),分类讨论:
- \(l=\text{stk}(\mathit{top})\),也就是 \(x\) 是 \(\text{stk}(\mathit{top})\) 子树内的节点,此时直接将 \(x\) 入栈;
- \(l\ne\text{stk}(\mathit{top})\),也就是 \(x\) 不是 \(\text{stk}(\mathit{top})\) 子树内的节点,此时我们把栈弹出,每弹出一个节点就加一条边,直到变成第一种情况。
- 最后,把尚未弹出栈的节点依次弹出,并连边。
最后的实现是这样的:
int st[MAXN],tp;
void ins(int x){
if(!tp) return st[++tp]=x,void();
int l=lca(st[tp],x);
while(tp>1&&dep[l]<dep[st[tp-1]]){
addedge(st[tp-1],st[tp]);
--tp;
}
if(dep[l]<dep[st[tp]]) addedge(l,st[tp]),--tp;
if(!tp||st[tp]!=l) st[++tp]=l;
st[++tp]=x;
}
int main(){
...
for(int i=1,k;i<=m;++i){
k=read();
for(int j=1;j<=k;++j) a[j]=read();
sort(a+1,a+k+1,[](int x,int y){return dfn[x]<dfn[y];});
if(a[j]!=1) st[++tp]=1;
for(int j=1;j<=k;++j) ins(a[j]);
if(tp) while(--tp) addedge(st[tp],st[tp+1]);
}
}
其实,这个建树的板子还是直接背最方便。
清空
虚树的清空是很需要注意的,为了保持复杂度正确,我们不能 memset 之类,只能每次扫关键点地清空,或者在 DFS 函数的最后清空。下面的例题有这一点。
P2495 [SDOI2011] 消耗战
题意:给定一棵 \(n\) 个点的树和 \(m\) 个询问,边有边权。每个询问给定一些树上的关键点,求出使 \(1\) 节点不能到达任何关键点所需要断开的最少边权和。 \(2\le n\le2.5\times10^5\),\(\sum k_i\le5\times10^5\)。
看到这种 \(\sum k_i\le5\times10^5\) 的就差不多是虚树了。
首先容易得到的是一个 \(O(nq)\) 的 DP:设 \(f_i\) 为使 \(i\) 不与其子树中的任意一个关键点连通的最小代价。显然,枚举 \(i\) 的儿子 \(v\):
- 若 \(v\) 是关键点,\(f_i\gets f_i+w(i,v)\);
- 若 \(v\) 不是关键点,\(f_i\gets f_i+\min\{f_v,w(i,v)\}\);
但是我们发现因为 \(k\) 很稀疏,每一次把所有点都遍历一遍是不必要的,所以我们可以建虚树,只保留关键点,然后在虚树上进行 DP,这样的复杂度就是对的。
所以把虚树建出来,然后在虚树上进行 DP 就可以了。DP 实际上不是本题的难点。
// 前面的快读、树剖不再展示
int st[MAXN],tp;
bool vis[MAXN];
void ins(int x){
if(!tp) return st[++tp]=x,void();
int l=lca(st[tp],x);
while(tp>1&&dep[l]<dep[st[tp-1]]){
addedge(st[tp-1],st[tp]);
--tp;
}
if(dep[l]<dep[st[tp]]) addedge(l,st[tp]),--tp;
if(!tp||st[tp]!=l) st[++tp]=l;
st[++tp]=x;
}
ll dfs3(int u){
ll sum=0;
for(int i=head[u];i;i=e[i].to) sum+=dfs3(e[i].v);
ll res=vis[u]?dd[u]:min(sum,dd[u]);
vis[u]=0;
head[u]=0;
return res;
}
int main(){
n=read();
for(int i=1,u,v,w;i<n;++i){
u=read(),v=read(),w=read();
addedge(u,v,w),addedge(v,u,w);
}
dd[1]=2e18;
dfs(1,0);
dfs2(1,1);
memset(head,0,sizeof(int)*(n+5));
tot=0;
m=read();
for(int i=1,k;i<=m;++i){
k=read();
for(int j=1;j<=k;++j) a[j]=read(),vis[a[j]]=1;
sort(a+1,a+k+1,[](int x,int y){return dfn[x]<dfn[y];});
st[++tp]=1;
for(int j=1;j<=k;++j) ins(a[j]);
if(tp) while(--tp) addedge(st[tp],st[tp+1]);
write(dfs3(1));
tp=tot=0;
}
return fw,0;
}
注意到原树在求出 DFS 序和 LCA 之后就没有用了,所以虚树可以直接使用原树的链式前向星建树。注意我们在 DFS 函数的末尾就把 vis 和 head 清空了,这样才能保证复杂度的正确。
虚树的题本来想再贴几道,结果发现要么可以不拿虚树做,要么难点不在虚树。其实大多数虚树的题难点还是在 DP 上,虚树就是个板子,建完了之后调用就可以。
所以就不贴了。

浙公网安备 33010602011771号