虚树
用途:对于一棵树,出现多个询问,查询有关多个结点的答案时,每次遍历整棵树会很慢。但如果 所有询问的所有关键节点总数并不多 (\(O(A)\)),我们就可以对于每个询问建立一棵虚树,使得每次查询只需要访问这些关键节点,使得复杂度优化到 \(O(A\log n)\)。
概念
对于给出的所有关键结点,本身及任意两点的 \(lca\) 组成虚树。
建树方式
1. 二次排序 + LCA 连边
流程
- 所有关键点根据 dfn 排序
- dfn 排序后的相邻关键点的 lca 的 dfn 加入序列,再根据 dfn 排序,并去重。
- 序列中的每一对相邻点 \(x,y\)(\(x\) 左 \(y\) 右),按照 \(lca(x,y) \rightarrow y\) 连边。
正确性证明
即证明按照这种建树方式,任意两个关键结点的 \(lca\) 均在该虚树中。考虑步骤 2 做完后的序列中的任意两点 \(X,Y\):
- \(X,Y\) 在序列中相邻:根据步骤 3,一定有 \(lca(x,y) \rightarrow y\),因此 \(lca(X,Y)\) 一定在虚树中。
- \(X,Y\) 在序列中不相邻:即在序列中二者中间相隔着若干点。可以证明,在中间这部分的某两个相邻点的 \(lca\) 一定是 \(lca(X,Y)\),进而在处理这两个相邻点时将 \(lca(X,Y)\) 加入到虚树中。简略证明如下图:
关于步骤 3 连边方式的正确性:即证明对于 \(\forall\) 连边 \(lca(x,y) \rightarrow y\),两个点在原树的路径中一定没有其他关键点(此时关键点是步骤 2 做完后序列中的点)。容易证明这样的连边方式一定是最优的。

因此我们有结论:一个点集中任意两点的 \(lca\) ,一定包含在将所有点按照 dfn 排序并将每两个相邻点求 \(lca\) 组成的点集中。
复杂度分析
若原关键点的数量为 \(k\),则虚树大小最多是 \(2k-1\)(可能含去重),规模是 \(O(k)\) 的。又涉及到求两点的 \(lca\),因此建树复杂度是 \(O(k\log n)\) 的。
模板
const int maxn = 3e5 + 5;
int n;
vector<int> G[maxn], G0[maxn]; // G是原树,G0 是虚树
int a[maxn], aa[maxn<<1], tot; // aa 需要存储二次排序去重前的 dfn 序列,每个结点可能会有重复,故需要开2倍空间
bool hve[maxn]; // hve[u]:u在当前询问中是否为重要点
int dfn[maxn], dep[maxn], fa[maxn][20], tim;
void dfs(int u, int father){
dfn[u] = ++tim;
dep[u] = dep[father] + 1;
fa[u][0] = father;
for(int i = 1; i <= 19; i ++){
fa[u][i] = fa[fa[u][i - 1]][i - 1];
}
for(auto v : G[u]){
if(v == father) continue;
dfs(v, u);
}
}
int lca(int u, int v){
if(dep[u] < dep[v]) swap(u, v);
for(int i = 19; i >= 0; i --){
if(dep[fa[u][i]] >= dep[v]){
u = fa[u][i];
}
}
if(u == v) return v;
for(int i = 19; i >= 0; i --){
if(fa[u][i] != fa[v][i]){
u = fa[u][i];
v = fa[v][i];
}
}
return fa[u][0];
}
bool cmp_dfn(int x, int y){
return dfn[x] < dfn[y];
}
int k; // 当前询问的关键点数量
// 建虚树(二次排序 + lca 连边)
int buildVirtualTree(){ // 返回值是整棵虚树的根
sort(a + 1, a + k + 1, cmp_dfn);
tot = 0;
for(int i = 1; i <= k; i ++){
aa[++tot] = a[i];
}
for(int i = 1; i < k; i ++){
aa[++tot] = lca(a[i], a[i + 1]);
}
sort(aa + 1, aa + 1 + tot, cmp_dfn);
tot = unique(aa + 1, aa + 1 + tot) - aa - 1;
for(int i = 1; i < tot; i ++){
int u = lca(aa[i], aa[i + 1]), v = aa[i + 1];
G0[u].pb(v); // 有根树,只需要连一条有向边
vec.pb(u);
}
return aa[1];
}
关于虚树还有一个比较有用的性质:往已经构建好的虚树中可以加入其他任意点。因此若需要某些特定点时,可以将其直接加入到虚树中并构建。比如下面的 P2495。
2. 单调栈
不常用,也不想记录了。需要的话看 oi-wiki。
虚树优化树形 dp
CF613D
本身就是一道很不错的树形 dp 题,还需要利用虚树优化。
先不考虑虚树,想一下怎么直接对原来的树做 dp:
状态定义
\(dp_{u}\):考虑子树 \(u\),将其内部所有的重要点两两隔开,需要选取的非重要点的最少数量。
状态转移
根据状态定义直接作转移即可。这里不再给出详细说明,直接看 code 部分自己体会就好。
ll dp[100005][2];
void dfs2(int u){
if(G0[u].size() == 0){ // 叶节点
if(hve[u]){
dp[u][0] = inf;
dp[u][1] = 0;
}
else{
dp[u][0] = 0;
dp[u][1] = inf;
}
return;
}
int sum0 = 0, mn = inf;
int sum1 = 0;
int sum_min = 0;
for(auto v : G0[u]){
dfs2(v);
sum0 += dp[v][0];
sum1 += min(dp[v][0], dp[v][1] + 1);
sum_min += min(dp[v][0], dp[v][1]);
mn = min<ll>(mn, dp[v][1] - dp[v][0]);
}
if(hve[u]){
dp[u][0] = inf;
dp[u][1] = sum1;
}
else{ // 决策非重要点u是否攻占
dp[u][0] = min(sum0, sum_min + 1);
if(mn <= 0){
dp[u][1] = sum0 + mn;
}
else{
dp[u][1] = inf;
}
}
}
P4103
状态定义:
int siz[1000005]; // siz[u]: 子树 u 内关键点的数量
ll sum[1000005]; // sum[u]: u 到其子树内所有关键点的距离之和
int mindist[1000005]; // mindist[u]: u 到其子树内的所有关键点的最小距离
int maxdist[1000005]; // maxdist[u]: u 到其子树内的所有关键点的最大距离
ll costSum;
int costMax, costMin;
// 这里为了方便,直接用三个变量来存每个点的 dp 总和与最值
状态转移
void dfs2(int u){
siz[u] = hve[u] ? 1 : 0;
sum[u] = 0;
if(hve[u]){
mindist[u] = maxdist[u] = 0;
}
else{
mindist[u] = inf;
maxdist[u] = -inf;
}
for(auto v : G0[u]){
dfs2(v);
int d = dep[v] - dep[u];
costMin = min(costMin, mindist[u] + d + mindist[v]);
mindist[u] = min(mindist[u], d + mindist[v]);
costMax = max(costMax, maxdist[u] + d + maxdist[v]);
maxdist[u] = max(maxdist[u], d + maxdist[v]);
costSum += 1ll * siz[u] * (1ll * siz[v] * d + sum[v]) + 1ll * siz[v] * sum[u];
sum[u] += sum[v] + 1ll * siz[v] * d;
siz[u] += siz[v];
}
}
P2495
对于本题,答案和 1 号结点直接相关,因此 建虚树时需要额外将 1 号结点加入到虚树中,保证虚树中存在该点。
状态定义
\(dp_{u}\):考虑子树 \(u\),使得子树 \(u\) 中所有关键点不能到达 \(u\) 的最小代价。
状态转移
考虑 \(dp_{u} \leftarrow dp_{v}\) 的转移:
- \(v\) 是关键点 \(\rightarrow dp_{u} += w(u,v)\)
- \(v\) 不是关键点 \(\rightarrow dp_{u} += \min(dp_{v},w(u,v))\)
最终 \(ans = dp_{1}\)。
还剩下一个问题:虚树中每条边的边权应该怎样设置?考虑虚树在原树上 dp 的过程,显然每条边应当设置为两个端点在原树路径上的最小边权,可以直接通过倍增来预处理。
具体实现见 code。


浙公网安备 33010602011771号