曇天も払う光なら凄惨な旅路だって往ける 若这束光芒能驱散黑暗 拨云见|

AzusidNya

园龄:1年7个月粉丝:4关注:4

虚树 学习笔记

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


Part1. 引入

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

[HEOI2014] 大工程

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

  1. 建立完全图的代价和。
  2. 代价最小的边的代价。
  3. 代价最大的边的代价。
    对于 100% 的数据,1n106,1q5×104,k2×n

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

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

ansans+(gu+szu)×szv+gv×szu

gu=gu+gv+szv

szu=szv+szu

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

那么有什么办法解决呢?我们发现有 k2×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(k2) 直接枚举 lca 节点,然后时间复杂度达到了惊人的 O(k2)

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

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

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

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

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

  • u 点是 v 点的祖先。

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

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

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

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

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

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

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

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

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

/* ... */
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。

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

状态转移方程如下:

ansans+(gu+szu×w)×szv+gv×szu

gu=gu+gv+szv×w

szu=szv+szu

同样,第二小问和第三小问也是简单的树形 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. 总结

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

本文作者:AzusidNya の 部屋

本文链接:https://www.cnblogs.com/AzusidNya/p/17745184.html

版权声明:本作品采用知识共享署名-非商业性使用-禁止演绎 2.5 中国大陆许可协议进行许可。

posted @   AzusidNya  阅读(59)  评论(0编辑  收藏  举报
点击右上角即可分享
微信分享提示
评论
收藏
关注
推荐
深色
回顶
收起