加载中...

虚树

用途:对于一棵树,出现多个询问,查询有关多个结点的答案时,每次遍历整棵树会很慢。但如果 所有询问的所有关键节点总数并不多\(O(A)\)),我们就可以对于每个询问建立一棵虚树,使得每次查询只需要访问这些关键节点,使得复杂度优化到 \(O(A\log n)\)

概念

对于给出的所有关键结点,本身及任意两点的 \(lca\) 组成虚树。

建树方式

1. 二次排序 + LCA 连边

流程

  1. 所有关键点根据 dfn 排序
  2. dfn 排序后的相邻关键点的 lca 的 dfn 加入序列,再根据 dfn 排序,并去重
  3. 序列中的每一对相邻点 \(x,y\)\(x\)\(y\) 右),按照 \(lca(x,y) \rightarrow y\) 连边。

正确性证明

即证明按照这种建树方式,任意两个关键结点的 \(lca\) 均在该虚树中。考虑步骤 2 做完后的序列中的任意两点 \(X,Y\)

  1. \(X,Y\) 在序列中相邻:根据步骤 3,一定有 \(lca(x,y) \rightarrow y\),因此 \(lca(X,Y)\) 一定在虚树中。
  2. \(X,Y\) 在序列中不相邻:即在序列中二者中间相隔着若干点。可以证明,在中间这部分的某两个相邻点的 \(lca\) 一定是 \(lca(X,Y)\),进而在处理这两个相邻点时将 \(lca(X,Y)\) 加入到虚树中。简略证明如下图:

pZIiTPI.jpg

关于步骤 3 连边方式的正确性:即证明对于 \(\forall\) 连边 \(lca(x,y) \rightarrow y\),两个点在原树的路径中一定没有其他关键点(此时关键点是步骤 2 做完后序列中的点)。容易证明这样的连边方式一定是最优的。
pZIiIIA.png

因此我们有结论:一个点集中任意两点的 \(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;
		}
	}
}

code

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];
    }
}

code

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。

code

P3233

code

posted @ 2026-02-04 23:12  小橘奏  阅读(3)  评论(0)    收藏  举报