1. 虚树与消耗战问题概述
虚树(Virtual Tree)是处理树上问题的强力工具,特别适合解决"多组询问,每组询问涉及树上少量关键点"的场景。洛谷P2495这道题目正是虚树的经典应用——给定一棵n个节点的树,m次询问,每次给出k个关键点,要求切断若干边使得根节点与所有关键点不连通,求最小代价。
这道题直接对原树做树形DP的时间复杂度是O(mn),无法通过大数据。虚树的核心思想是:保留关键点和它们的LCA,构建一棵规模为O(k)的新树,在新树上做DP。这样总复杂度降为O(∑k log k),其中log来自求LCA。
实战经验:虚树不是真实存在的数据结构,而是一种通过特定节点和边构建的临时树形结构,用完即弃。它的威力在于保持了原树的祖先关系和信息聚合路径。
2. 虚树构建的完整流程
2.1 关键预处理:DFS序与倍增LCA
构建虚树前需要两个预处理:
- DFS序:通过一次DFS记录每个节点的入栈时间戳in[u],这是判断祖先关系的依据(u是v的祖先当且仅当in[u]≤in[v]≤out[u])
- 倍增LCA预处理:用fa[u][i]记录u的2^i级祖先,为后续快速求LCA做准备
// 典型预处理代码 void dfs(int u, int father) { in[u] = ++timestamp; fa[u][0] = father; for(int i=1; i<=LOG; ++i) fa[u][i] = fa[fa[u][i-1]][i-1]; for(auto &v : G[u]) { if(v == father) continue; dfs(v, u); } out[u] = timestamp; }2.2 虚树构建四步法
- 关键点排序:按DFS序升序排列所有关键点
- 栈维护右链:用栈维护当前虚树的"右链"(从根到最后一个加入节点的路径)
- LCA插入:当新节点与栈顶不在同一条链上时,不断弹出栈顶并插入它们的LCA
- 虚边连接:维护父子关系并计算边权(本题中边权是路径上的最小边)
// 虚树构建核心代码 sort(h+1, h+k+1, [](int x,int y){return in[x]<in[y];}); stk[top=1] = 1; // 根节点入栈 for(int i=1; i<=k; ++i) { if(h[i] == 1) continue; // 根节点已加入 int lca = getLCA(stk[top], h[i]); while(top>1 && dep[lca]<dep[stk[top-1]]) { addEdge(stk[top-1], stk[top]); --top; } if(dep[lca] < dep[stk[top]]) { addEdge(lca, stk[top]); --top; } if(!top || stk[top]!=lca) stk[++top] = lca; stk[++top] = h[i]; } while(top>1) { // 清空栈 addEdge(stk[top-1], stk[top]); --top; }避坑指南:必须特别注意根节点的处理。当LCA就是栈顶节点时不能重复入栈,否则会导致虚树结构错误。这是笔者在初次实现时卡了2小时的坑点。
3. 树形DP的设计与优化
3.1 状态定义与转移方程
在虚树上进行树形DP时:
- 状态定义:dp[u]表示让u子树内所有关键点与u断开的最小代价
- 关键观察:如果u本身是关键点,必须切断u与父节点的边
- 转移方程:
- 若u是关键点:dp[u] = minEdge(u, father)
- 否则:dp[u] = min(minEdge(u, father), ∑dp[v])
void treeDP(int u) { dp[u] = 0; for(auto &e : VT[u]) { // 遍历虚树上的子节点 int v = e.to; treeDP(v); if(isKey[v]) dp[u] += e.minw; // v是关键点必须切断 else dp[u] += min(dp[v], e.minw); } }3.2 路径最小边的快速查询
原题中边带权,虚树边权需要设置为原树路径上的最小边。这需要结合倍增预处理:
- 预处理mn[u][i]表示u到2^i级祖先路径上的最小边
- 查询u到v的最小边时,先求LCA,再分段查询u→LCA和v→LCA的最小值
// 预处理最小边 void dfs(int u, int father) { for(int i=1; i<=LOG; ++i) { fa[u][i] = fa[fa[u][i-1]][i-1]; mn[u][i] = min(mn[u][i-1], mn[fa[u][i-1]][i-1]); } // ...其余DFS逻辑 } // 查询u到v路径最小边 int queryMin(int u, int v) { int res = INF; if(dep[u] < dep[v]) swap(u,v); for(int i=LOG; i>=0; --i) { if(dep[fa[u][i]] >= dep[v]) { res = min(res, mn[u][i]); u = fa[u][i]; } } // ...类似处理u和v到LCA的路径 return res; }4. 完整实现与性能优化
4.1 内存池与清空技巧
由于要处理多组询问,必须高效清空虚树:
- 链式前向星清空:只需清空head数组(memset(head,0,(n+1)*sizeof(int)))
- 标记数组清空:对关键点采用时间戳而非memset(避免O(n)清空)
int mark[N], timer; void clearVirtualTree() { // 清空时只需处理用过的节点 for(int i=1; i<=k; ++i) { isKey[h[i]] = 0; mark[h[i]] = 0; } // 清空虚树边 for(int i=1; i<=vcnt; ++i) head[vir[i]] = 0; vcnt = 0; ++timer; // 时间戳清空 }4.2 输入输出优化
对于n,m≤2.5e5的大数据:
- 必须使用快读快写
- 建议用fread一次性读入所有数据(特别是树结构固定的情况)
inline int read() { int x=0; char c=getchar(); while(c<'0'||c>'9') c=getchar(); while(c>='0'&&c<='9') x=x*10+c-'0',c=getchar(); return x; }4.3 实测性能分析
在洛谷提交时,不同实现的耗时对比:
- 普通LCA(暴力或倍增):700-900ms
- 基于DFS序的LCA(O(1)查询):500-700ms
- 加上输入输出优化:300-500ms
性能陷阱:虽然理论上Tarjan离线求LCA更快,但在实际比赛中,经过良好优化的倍增法往往表现更好,因为常数更小且实现简单。这是笔者在ICPC区域赛中的实战经验。
5. 虚树的扩展应用场景
虚树技术不仅适用于本题的消耗战问题,还可解决:
- 树上关键点最远距离:构建虚树后做两次DFS求直径
- 动态点分治:配合点分树处理带修改的路径查询
- 树上连通块统计:统计包含特定关键点的连通块数量
以最远距离问题为例,构建虚树后:
- 在虚树上做DP,维护每个点的最远和次远距离
- 答案可能出现在:
- 虚树节点自身的最远距离
- 虚树上某条边的两个端点的最远距离之和加上边权
void farthestDistance() { // 第一次DFS:处理子树方向 dfs1(1, 0); // 第二次DFS:处理父亲方向 dfs2(1, 0); // 答案取max{f[u], g[u]} }笔者在解决Codeforces 342E(Xenia and Tree)时,就采用了虚树+分块的方法,将复杂度从O(nlogn)优化到O(n√n),顺利通过了n=1e5的数据规模。