虚树 学习笔记

2023/10/6 发现找不到题做了,决定学习新算法。经过在一些题单中的翻找,决定学习虚树。


Part1. 引入

以一道例题来引入虚树吧。

[HEOI2014] 大工程

给定一棵有 \(n\) 个点的树,边权均为 \(1\)
现在有 \(q\) 次询问。每次询问取 \(k\) 个点出来建立完全图。定义连接两个点的代价为在树上 \(a, b\) 的最短路径的长度。求:

  1. 建立完全图的代价和。
  2. 代价最小的边的代价。
  3. 代价最大的边的代价。
    对于 \(100\%\) 的数据,\(1\le n\le 10^6,1\le q\le 5\times 10^4,\sum k\le 2\times n\)

以第一问为例。考虑树形 dp。设 \(sz_i\) 为以 \(i\) 为根的子树中被标记的点的个数,\(g_i\) 为以 \(i\) 为根的子树中所有被标记的节点到根节点的距离和。

将子树合并,考虑每条边的贡献即可,两个子树合并时的贡献和状态转移方程为:

\[ans \leftarrow ans + (g_u + sz_u) \times sz_v + g_v \times sz_u \]

\[g_u = g_u + g_v + sz_v \]

\[sz_u = sz_v + sz_u \]

但是对于每次询问都要遍历整棵树,单次询问是 \(O(n)\) 的,不可接受。

那么有什么办法解决呢?我们发现有 \(\sum k\le 2\times n\),也就是每次特殊点是稀疏的,那么是否可以将每次询问的时间复杂度压缩到仅与 \(k\) 有关呢?

显然是有的,虚树可以方便的解决「多次询问,每次询问给定一个特殊点集,求在这一点集上某一问题的答案」这样的问题。

Part2. 虚树的概念

我们发现在上面的树形 dp 中,有很多点其实没什么作用。具体而言,我们可以只保留点集中的点和点集中的点的 lca,而其它的点和边可以忽视,为新树分配边权为原树两点的距离。

借一下 OI-Wiki 的图捏。

如图所示,点集为 \({4, 6, 7}\)。我们不关心 \(2\) 号节点和 \(5\) 号节点,因为它们与点集无关,然后合并边 \((1, 2)\)\((2, 4)\) 的边权为 \((1, 4)\) 的边权,在本题中即令 \((1, 4)\) 边的边权为 \(2\)

而另一边,为了保留 \(6, 7\) 节点的信息,我们还需要保存它们的 lca 节点 \(3\) 的信息,以及这些点的共同 lca 节点 \(1\) 的信息。

虚树大概就是这个样子,虚树中 保存着信息的重要节点 都被保留了,而虚树的点数被压缩到了 \(O(k)\) 级别的。

在本题中,在虚树中以新的边权树形 dp,我们发现仍然能算出正确的结果,因为我们仅仅是压缩掉了原树中对于特殊点而言无用的节点而已。

Part3. 虚树的建立

虚树是非常有用的,但是我们要先把它复杂度正确的建起来。总不能直接 \(O(k ^ 2)\) 直接枚举 lca 节点,然后时间复杂度达到了惊人的 \(O(\sum{k ^ 2})\)

一般的构建方法可以使用单调栈构建,但是不够直观。而在 这篇博客 中介绍了一种直观简洁的虚树构建方法,代码也非常好写,即通过「二次排序 + LCA 连边」构建虚树。步骤如下:

  1. 首先我们需要找出虚树中所有的点。可以先将点集中的点按 dfs 序排序,然后再将相邻的点求下 lca。根据 dfs 序的性质,这样我们就得到了所有的点的可能的 lca。

  2. 然后对这些点进行一个去重,就得到了虚树中的所有节点。

  3. 再次按 dfs 序排序,每个点在虚树上的父亲节点即为它和它的前驱的 lca。

为什么这样是正确的呢?考虑下 dfs 序的性质。按 dfs 序从小到大枚举点,设相邻两点为 \(u\)\(v\),其中 \(u\) 的 dfs 序在 \(v\) 之前,则有两种情况:

  • \(u\) 点是 \(v\) 点的祖先。

  • \(u\) 点先向上走一些,然后拐下去走到 \(v\)。在这种情况其实也表示考虑完了 \(u\) 的子树,因为 \(u\) 下面的子树如果有东西也被第一种情况考虑完了。

一棵一棵子树合并,自然可以从低到高,dfs 序从小到大的找到所有的 lca。

首先发现一个性质,\(u\)\(v\) 往后第一个 dfs 序的节点,根据上文所提到的性质,dfs 序其实从它的 lca 过来一直是递增的。

因为我们知道从 lca 节点到 \(v\) 的过程之中,点的 dfs 序在不断增大。

如果 LCA 和 \(u\) 之间有节点 \(p\) 的话,那么 \(p\) 的 dfs 序必然小于 \(u\) 的 dfs 序,而这显然是不符合排序顺序的。

所以 \(u\) 和 lca 中没有重复的节点。

那么会不会有遗漏呢?我们发现按照这个构造流程,除了 dfs 序处于第一个的节点,其他都有连向它的边,所以正好构造一棵虚树。

因此这样的流程成功在 \(O(k \log n)\) 内构造出了一颗虚树。

代码实现大概是这样子的。

  /* ... */
	cin >> tot;
	for(int i = 1; i <= tot; i ++)
		cin >> a[i], vis[a[i]] = 1;
	sort(a + 1, a + tot + 1, cmp);
	num = tot;
	for(int i = 2; i <= num; i ++){
		int lca = getlca(a[i], a[i - 1]);
		if(lca != a[i] && lca != a[i - 1])
			a[++ tot] = lca;
  	}
	sort(a + 1, a + tot + 1);
	num = tot;
	tot = unique(a + 1, a + tot + 1) - (a + 1);
	sort(a + 1, a + tot + 1, cmp);
	for(int i = 2; i <= tot; i ++){
		int lca = getlca(a[i], a[i - 1]);
		edg[lca].push_back(a[i]);
	}
  /* ...*/

Part4. 虚树上树形 dp / 其它复杂的算法

事实上建完虚树后就是要思考的部分了。只要在虚树上,再怎么乱搞你的时间复杂度都是只与 \(k\) 有关的了。在例题中,构建完虚树后就形成了非常非常简单的一个树形 dp。

当然要考虑下合并完边后的边权。

状态转移方程如下:

\[ans \leftarrow ans + (g_u + sz_u \times w) \times sz_v + g_v \times sz_u \]

\[g_u = g_u + g_v + sz_v \times w \]

\[sz_u = sz_v + sz_u \]

同样,第二小问和第三小问也是简单的树形 dp,这边就不给出方程了。

Part5. 例题代码

#include<iostream>
#include<fstream>
#include<algorithm>
#include<cstring>
//#define int long long
using namespace std;
const int inf = 0x3f3f3f3f;
int n, T;
vector<int> edge[1000005];
int fa[1000005], siz[1000005], son[1000005], dep[1000005];
int dfs1(int u, int ft){
	fa[u] = ft, siz[u] = 1, dep[u] = dep[ft] + 1;
	for(int i = 0, v; i < edge[u].size(); i ++){
		v = edge[u][i];
		if(v == ft) continue;
		dfs1(v, u);
		siz[u] += siz[v];
		if(siz[v] > siz[son[u]]) son[u] = v;
	}
	return 0;
}
int dfn[1000005], top[1000005], rnk[1000005], tim;
int dfs2(int u, int ft, int tp){
	dfn[u] = ++ tim, rnk[tim] = u, top[u] = tp;
	if(son[u] != 0)
		dfs2(son[u], u, tp);
	for(int v : edge[u]){
		if(v == ft || v == son[u]) continue;
		dfs2(v, u, v);
	}
	return 0;
}
int getlca(int u, int v){
	if(dep[top[u]] <= dep[top[v]]) swap(u, v);
	if(top[u] == top[v]){
		if(dep[u] > dep[v]) return v;
		return u;
	}
	return getlca(fa[top[u]], v);
}
int a[1000005], tot, num;
bool cmp(int u, int v){
	return dfn[u] < dfn[v];
}
bool vis[1000005];
vector<int> edg[1000005];
vector<int> vl[1000005];
long long g[1000005];
int sz[1000005];
int mx[1000005], mn[1000005];
long long ret;
int ret1, ret2;
int dfs(int u){
	bool sta = 0;
	if(vis[u]) sz[u] ++, mx[u] = mn[u] = 0, sta = 1;
	for(int i = 0, v, w; i < edg[u].size(); i ++){
		v = edg[u][i], w = vl[u][i];
		dfs(v);
		ret += 1ll * (g[u] + 1ll * sz[u] * w) * sz[v] + 1ll * g[v] * sz[u];
//		if(vis[u])
		if(sz[v] != 0 && sta)
			ret1 = min(ret1, mn[u] + mn[v] + w), ret2 = max(ret2, mx[u] + mx[v] + w);
		if(sz[v] != 0){
			mn[u] = min(mn[v] + w, mn[u]);
			mx[u] = max(mx[v] + w, mx[u]);
			if(!sta) sta = 1;
		}
		g[u] += g[v] + 1ll * sz[v] * w;
		sz[u] += sz[v];
	}
//	cout << u << " " << mx[u] << " " << mn[u] << " " << ret1 << " " << ret2 << "\n";
	return 0;
}
signed main(){
	ios::sync_with_stdio(0);
	cin.tie(0), cout.tie(0); 
	cin >> n;
	memset(mn, 0x3f, sizeof(mn));
	for(int i = 1, u, v; i < n; i ++)
		cin >> u >> v, edge[u].push_back(v), edge[v].push_back(u);
	dfs1(1, 0), dfs2(1, 0, 1);
	cin >> T;
	while(T --){
		cin >> tot;
		for(int i = 1; i <= tot; i ++)
			cin >> a[i], vis[a[i]] = 1;
		sort(a + 1, a + tot + 1, cmp);
		num = tot;
		for(int i = 2; i <= num; i ++){
			int lca = getlca(a[i], a[i - 1]);
			if(lca != a[i] && lca != a[i - 1])
				a[++ tot] = lca;
		}
		sort(a + 1, a + tot + 1);
		num = tot;
		tot = unique(a + 1, a + tot + 1) - (a + 1);
		sort(a + 1, a + tot + 1, cmp);
		for(int i = 2; i <= tot; i ++){
			int lca = getlca(a[i], a[i - 1]);
			edg[lca].push_back(a[i]);
			vl[lca].push_back(dep[a[i]] - dep[lca]);
		}
		ret = 0;
		ret1 = inf;
		ret2 = 0;
		dfs(a[1]);
		cout << ret << " " << ret1 << " " << ret2 << "\n";
		for(int i = 1; i <= tot; i ++)
			vis[a[i]] = 0, edg[a[i]].clear(), vl[a[i]].clear(), g[a[i]] = sz[a[i]] = mx[a[i]] = 0, mn[a[i]] = inf;
	}
	return 0;
}

其实跑得还挺快。

Part6. 总结

其实虚树毕竟只是工具,毕竟看到题目是怎么问的其实可以一眼虚树()。真正困难的还是建完虚树的部分,这个时候就很考验树上算法的功底了。

posted @ 2023-10-06 22:11  AzusidNya  阅读(102)  评论(0)    收藏  举报