静态 Top Tree
前言
发现自己在大力 DS 这个领域有一些欠缺,所以来补一下。
很多人对静态 TopTree 有一个误解,以为这个东西是一坨史根本不可能写出来,或者是以为这是个完全没用的 useless 数据结构。
其实这个东西一点都不难写,合理封装之后可以说没有比树剖难写多少。而且可以降维打击一些题目。
有的人之前想学,但是被 OI wiki 的巨大长页面打退了。其实静态 Top Tree 没有那么复杂。
构建
大家都知道解决序列上信息可以合并的问题的时候可以用线段树。如果想把这个信息搬到树上,可以使用树链剖分这种重标号技巧来转化为序列上问题再套用线段树,但是会多一个 \(O(\log n)\) 的代价。
考虑优化。大家都知道线段树其实是定义了序列上的一个合并的结构,使得这个合并结构的深度为 \(O(\log n)\)。那我们在树上也尝试设计一个这种合并的结构会怎么样呢?
线段树上一个节点对应了序列的一段区间,仿照线段树,我们定义 TopTree 上一个节点可以表示为 \((u,v,E)\) 分别是原树上两个节点以及一个边集 \(E\),需要保证 \(E\) 里面的所有边都是被夹在 \((u,v)\) 之间的(注意不用保证 \((u,v)\) 中间夹的所有边都在 \(E\) 中)。这样的一个节点我们称其为“簇”(Cluster)。十分形象地,我们可以用一条简短的连接 \((u,v)\) 的边来代替这个簇的边集。
最开始的时候,树上每条边 \((u,v)\) 代表一个簇 \((u,v,\{(u,v)\})\),称这些簇为基簇(Base Cluster)。
考虑怎么合并两个簇。因为树的结构相比序列复杂很多,所以我们定义两种合并操作:Rake 和 Compress。
Rake 操作,是把 \((u,v,E_x)\) 和 \((u,w,E_y)\) 合并成 \((u,v,E_x\cup E_y)\),需要满足 \(w\) 度数为 \(1\)。合并得到的 \((u,v,E_x\cup E_y)\) 被称为 Rake 簇。
Compress 操作,是把 \((u,v,E_x)\) 和 \((v,w,E_y)\) 合并成 \((u,w,E_x\cup E_y)\),需要满足 \(v\) 度数为 \(2\)。合并得到的 \((u,v,E_x\cup E_y)\) 被称为 Compress 簇。
需要注意以上所有合并都要保证 \(E_x\cap E_y=\varnothing\)。
可以发现,我们总是可以通过不断地 Rake 一度节点为界点的簇,Compress 一对二度节点为界点的簇,来把整棵树合并成一个大簇。
我们把这样一个合并的过程建成一棵树,树上每个节点是一个簇,其左右儿子为合并时的两个簇,叶子节点是所有基簇。这就是一棵 TopTree。
问题在于,这样合并出来的 TopTree 树高可以达到 \(O(n)\)。考虑优化树高。我们可以几乎仿照全局平衡二叉树的做法,先给树重剖,然后考虑每条重链,先递归把这条重链的每个轻儿子合并成自己一个簇,现在这条重链是一个毛毛虫的结构。然后把所有轻儿子 Rake 到重链上,最后把整条重链 Compress 成一个簇。
但是容易发现这会被菊花图之类的东西把高度卡到 \(O(n)\)。于是我们 Rake、Compress 起来一堆簇的时候可以使用分治合并。这样树高是 \(O(\log^2n)\),那不是才勉强赶上树剖吗。
此时仿照全局平衡二叉树的做法,按照簇的大小带权分治合并。可以把树高做到 \(O(\log n)\)。
下面是我个人实现的一个静态 TopTree 建树模板。
enum Type{BASE,RAKE,COMPRESS};
struct Cls{
int u,v,siz;
Cls(int u=0,int v=0,int siz=0):u(u),v(v),siz(siz){}
};
Cls rake(Cls &x,Cls &y){
Cls res=Cls(x.u,x.v,x.siz+y.siz);
return res;
}
Cls compress(Cls &x,Cls &y){
Cls res=Cls(x.u,y.v,x.siz+y.siz);
return res;
}
struct Node{
int ls,rs,fa;
Type tp;
Cls c;
Node(int ls=0,int rs=0,int fa=0,Type tp=BASE,Cls c=Cls()):ls(ls),rs(rs),fa(fa),tp(tp),c(c){}
}t[N*2];
int tot;
void pushup(int p){
if(t[p].tp==BASE)return;
t[p].c=(t[p].tp==RAKE?rake:compress)(t[t[p].ls].c,t[t[p].rs].c);
}
int merge(int x,int y,Type tp){
t[++tot]=Node(x,y,0,tp);
t[x].fa=t[y].fa=tot;
pushup(tot);
return tot;
}
int id[N],siz[N],son[N],fa[N];
vector<int> G[N];
int divide(vector<int> &b,int l,int r,Type tp){
if(l==r)return b[l];
int ssiz=0,csiz=0,m=r-1;
for(int i=l;i<=r;i++)ssiz+=t[b[i]].c.siz;
for(int i=l;i<r;i++){
csiz+=t[b[i]].c.siz;
if(csiz*2>=ssiz){
m=i;
break;
}
}
return merge(divide(b,l,m,tp),divide(b,m+1,r,tp),tp);
}
int build(int x){
vector<int> cb;
if(id[x])cb.pb(id[x]);
while(son[x]){
vector<int> rb;
for(int v:G[x])
if(v!=fa[x]&&v!=son[x])
rb.pb(build(v));
if(rb.size())cb.pb(merge(id[son[x]],divide(rb,0,rb.size()-1,RAKE),RAKE));
else cb.pb(id[son[x]]);
x=son[x];
}
return divide(cb,0,cb.size()-1,COMPRESS);
}
void dfs(int u,int f=0){
siz[u]=1,fa[u]=f;
for(int v:G[u]){
if(v!=f){
t[++tot]=Node(0,0,0,BASE,Cls(u,v,1));
id[v]=tot;
dfs(v,u);
siz[u]+=siz[v];
if(siz[son[u]]<siz[v])
son[u]=v;
}
}
}
可以看到,代码十分可读、优美。并且总长只有 1.5k。
应用
实际应用中,我们需要在每个簇内维护一些与问题相关的信息。值得注意的是我们需要认真思考界点上的贡献是否记录在簇的信息中。
维护 DDP
TopTree 其中一个用途就是维护树上 DDP。TopTree 这种合并的性质非常好,使我们只需要考虑两种合并,而不需要像传统 DDP 一样还要考虑各种贡献删除。
以洛谷 DDP 模板,最大权独立集为例。每个簇维护一个 \(f_{0/1,0/1}\) 表示上下界点是否选,上界点不算入贡献。这样需要建立一个虚点连接到 \(1\) 上。合并的时候分讨 Rake 和 Compress 的合并即可。代码只有 2.7k。
另外一个题,切树游戏,可以说是发挥了 TopTree 的特长,即不用删除轻儿子信息再添加,这样就不用扩域维护。依然定义 \(f_{0/1,0/1}\) 为上下界点是否选的集合幂级数,上界点可以选但是选的话异或值不计入贡献。代码只有 3.5k。

浙公网安备 33010602011771号