虚树 Virtual Tree 学习笔记
引入
题目大意:给一棵 \(n\) 个点的树,\(m\) 次询问 \(k\) 个点,要求切断一些边使点 1 不可达这些点,求最小切断的边权和。
\(n\le 2.5*10^5,m\le 5*10^5,\sum k\le 5*10^5\)
先考虑一个朴素的 DP,每次询问扫一遍整个树。
设 \(f_i\) 为使点 \(i\) 的子树内的关键点不与 1 连通的最小花费,
\(g_i\) 为 1 到 \(i\) 路径上的边权的最小值。
若 \(i\) 为查询的点,\(f_i=g_i\)。
否则 \(f_i=\min(g_i,\sum f_{son_i})\)。
时间复杂度 \(O(nQ)\)。
发现 \(\sum k\) 与 \(n\) 同阶,所以一次查询里的关键点很少,很多点都是没有用的。
这时可以用虚树来优化。
什么是虚树?
虚树常见于树形 DP 中。
如果一次询问所包含的关键点很少(关键点可以理解为对答案有实际影响的点),即有很多点都是多余无用的。
如果每次我们还是暴力地扫一遍整棵树,那是 \(O(nQ)\) 的。
我们考虑把关键点及其有关的点取出来,按照原树的祖先后代关系新建一棵树,这就是虚树。
如果所有询问的关键点总数与 \(n\) 同阶,则最后复杂度就是 \(O(n+Q)\) 了。
如何建一棵虚树?
前置知识
我们需要一个高效的 LCA 算法;
\(O(\log n)\) 可以用常数更小的树剖代替倍增。
\(O(n\log n)\) 预处理,\(O(1)\) 查询的 RMQ 也可。
选点
显然我们需要的点是那些关键点和他们之间的 LCA。
根据前人的智慧加上我自己的感性理解,先把所有关键点按 dfs 序排好然后相邻两个求 LCA。
这些 LCA + 根节点 + 所有 \(k\) 个关键点 = 我们所需的点。
所有点的个数就是 \(O(k)\) 的。
连边
先把所有点按 dfs 序排序,然后去重。
我们用一个单调栈维护虚树上由根节点出发的一条链。
开始把根节点 1 入栈。
之后遍历排好序的点。
设当前点为 \(x=d_i\) 与栈顶为点 \(top\)。
若 \(LCA(x,top)=top\),则点 \(x\) 仍在 \(1\rightarrow top \rightarrow x\) 这条链上,则连边 \((top,x)\),将 \(x\) 直接入栈。
若 \(y=LCA(x,top)\neq top\),则点 \(x\) 不在 \(1\rightarrow top\) 这条链上了,\(y\) 即为这条链与链 \(1\rightarrow x\) 的交叉点。
\(y\) 显然也是我们选的点之一,此时一直退栈直到栈顶为 \(y\),然后连边 \((y,x)\),将 \(x\) 入栈。
回顾连边
当 \(d_i\) 连完边后,事实上我们的栈顶一直都为 \(d_i\)。
而与 \(d_i\) 连边的点为栈顶 \(LCA(top,d_i)\),即 \(LCA(d_{i-1},d_i)\)。
也就是说在排完序后,只需连 \((LCA(d_{i-1},d_i),d_i)\) 即可,看起来十分简洁,其中要连 \((1,d_1)\)。
也就是说我们实际并不需要一个栈来维护链。
总结
- 把关键点按 dfs 序排序。
- 关键点相邻两个求 LCA。
- 所有点(LCA + 关键点)按 dfs 序排序。
- 遍历数组,连 \((LCA(d_{i-1},d_i),d_i)\),\((1,d_1)\)。
建完虚树后再在虚树上做开头的 DP 即可。
于是我们的第一道题就迎刃而解了!
代码如下:
#include<bits/stdc++.h>
using namespace std;
int n,m;
const int N=2.5e5+5;
int to[N*2],nx[N*2],st[N],tot,co[N*2];
void add(int u,int v,int w){
to[++tot]=v,nx[tot]=st[u],st[u]=tot,co[tot]=w;
}
vector<int> edge[N];
int top[N],fa[N],son[N],sz[N],d[2*N],dep[N];
#define ll long long
ll f[N],g[N];
int lca(int x,int y){
while(top[x]!=top[y]){
if(dep[top[x]]>dep[top[y]])x=fa[top[x]];
else y=fa[top[y]];
}
if(dep[x]>dep[y])return y;
return x;
}
int dfn[N],dfn1;
void dfs(int x,int y){
sz[x]=1,fa[x]=y,dep[x]=dep[y]+1;
for(int i=st[x];i;i=nx[i]){
int v=to[i];
if(v!=y){
g[v]=min(g[x],(ll)co[i]);
dfs(v,x),sz[x]+=sz[v];
if(sz[v]>sz[son[x]])son[x]=v;
}
}
}
void dfs2(int x){
dfn[x]=++dfn1;
if(son[fa[x]]==x)top[x]=top[fa[x]];
else top[x]=x;
if(son[x])dfs2(son[x]);
for(int i=st[x];i;i=nx[i]){
int v=to[i];
if(v!=fa[x]&&v!=son[x])dfs2(v);
}
}
int K;
int cmp(int x,int y){
return dfn[x]<dfn[y];
}
int bz[N];
void solve(int x){
ll sum=0;
for(int v:edge[x]){
solve(v);
sum+=f[v];
}
edge[x].clear();
if(bz[x])f[x]=g[x];
else f[x]=min(g[x],sum);
bz[x]=0;
}
int main(){
// freopen("1.in","r",stdin);
ios::sync_with_stdio(0);
cin>>n;
for(int i=1;i<n;i++){
int u,v,w;
cin>>u>>v>>w;
add(u,v,w),add(v,u,w);
}
g[1]=1e18;
dfs(1,0);
dfs2(1);
cin>>m;
while(m--){
cin>>K;
for(int i=1;i<=K;i++){
cin>>d[i];
bz[d[i]]=1;
}
sort(d+1,d+1+K,cmp);
int tt=0;
for(int i=1;i<K;i++){
d[i+K]=lca(d[i],d[i+1]);
}
d[K+K]=1;
sort(d+1,d+K+K+1,cmp);
for(int i=1;i<=K+K;i++){
if(d[i-1]!=d[i])d[++tt]=d[i];
}
for(int i=2;i<=tt;i++){
edge[lca(d[i-1],d[i])].push_back(d[i]);
}
solve(1);
cout<<f[1]<<"\n";
}
}
套路
数据范围中看见类似 \(\sum m\le 10^5\) 的东西时就要考虑建虚树了。
但是建虚树人人都会,重点是如何计算。