Top Tree & Top Cluster 分块

Top-Tree

image

静态 Top Tree

结构构建:

Top-Tree 其实是一种线段树状物,利用 Top Tree 分治的思想像线段树一样维护信息。

\(E(u)\) 表示表示 \(u\) 所代表的簇的边集,\(V(u)\) 表示 \(u\) 所代表的簇的点集。

定义:

  • \(\operatorname{Rake}\) 操作:

    \(V(\operatorname{Rake}(u,v))=V(u),E(\operatorname{Rake}(u,v))=E(u) \cup E(v)\)

  • \(\operatorname{Compress}\) 操作:

    \(V(\operatorname{Compress}(u,v))=V(u) \oplus V(v),E(\operatorname{Compress}(u,v))=E(u) \cup E(v)\)

在树剖中,我们就用过这两种操作:

我们对轻边使用 \(\operatorname{Rake}\) 全部合并到重边。

对一条重链上的所有集合,使用 \(\operatorname{Compress}\) 合并在一起。

如果使用全局平衡的手段,可以证明操作次数 \(n \log n\) 后就合并成了一个簇。

可以归纳证明,对于一个簇 \(u\)\(E(u)\) 包含了所有完全处于连通块中的边,而 \(V(u)\) 仅包含两个点,这两个点一定是祖孙关系,而且顶上的点一定是所有点中深度最小的,称这两个点是簇的界点,这两个点之间的路径成为簇路径。合并的时候永远只存在一个公共交点,称这个是中心点。

合并操作都是二元函数,可以把合并的过程看作一颗二叉树,可以证明树高是不超过 \(2 \times \log n\) 的。

我们只需要快速实现 \(\operatorname{Rake}\)\(\operatorname{Compress}\) 即可完成像线段树一样的单点修改,全局询问的效果。

而这两个操作就相当于是 \(\operatorname{pushup}\)

类似线段树的分治思想,即全部在儿子中的递归处理,这个节点只额外处理跨过中心点的部分。

到树上我们只额外处理经过中心点路径的信息即可,注意到中心点在儿子一定是界点,所以还需要维护到上下界点的信息。

其实静态 Top Tree 也支持链修改,子树修改等操作,不过由于 pushdown 较为复杂的原因,不再考虑。

常见的可维护信息:

  • 簇大小:

\[sz_{\operatorname{compress}(x,y)}=sz_x+sz_y \]

\[sz_{\operatorname{rake}(x,y)}=sz_x+sz_y \]

  • 簇路径长度:

\[dis_{\operatorname{compress}(x,y)}=dis_x+dis_y \]

\[dis_{\operatorname{rake}(x,y)}=dis_x \]

  • 簇内到两个界点的最远距离(上端点):

\[mxu_{\operatorname{compress}(x,y)}=\max(mxu_x,dis_x+mxu_y) \]

\[mxu_{\operatorname{rake}(x,y)}=\max(mxu_x,mxu_y) \]

  • 簇内直径

\[Mx_{\operatorname{compress}(x,y)}=\max(Mx_x,Mx_y,mxv_x+mxu_y) \]

\[Mx_{\operatorname{rake}(x,y)}=\max(Mx_x,Mx_y,mxu_x+mxu_y) \]

完整代码
inline Data Rake(const Data &A,const Data &B)
{
    int u=A.u,v=A.v,dis=A.dis,sz=A.sz+B.sz;
    int mxu=max(A.mxu,B.mxu);
    int mxv=max(A.mxv,A.dis+B.mxu);
    int ans=max(A.ans,B.ans);
    ans=max(ans,A.mxu+B.mxu);
    return {u,v,sz,dis,mxu,mxv,ans};
}

inline Data Compress(const Data &A,const Data &B)
{
    int u=A.u,v=B.v,dis=A.dis+B.dis,sz=A.sz+B.sz;
    int mxu=max(A.mxu,A.dis+B.mxu);
    int mxv=max(B.mxv,B.dis+A.mxv);
    int ans=max(A.ans,B.ans);
    ans=max(ans,A.mxv+B.mxu);
    return {u,v,sz,dis,mxu,mxv,ans};
}

void pushup(int u)
{
    if(tr[u].type==0) return;
    tr[u].w=(tr[u].type==1?Rake:Compress)(tr[ls].w,tr[rs].w);
    tr[ls].fa=u,tr[rs].fa=u;
}

Data Init(int u,int v,int c)
{
    return {u,v,1,c,max(c,0ll),max(c,0ll),max(c,0ll)};
}

int New(int u,int v,int c)
{
    int x=++idx;
    tr[x].w=Init(u,v,c);
    tr[x].type=0;
    return x;
}

int Merge(int u,int v,int type)
{
    tr[++idx]={u,v,0,type};
    pushup(idx);
    return idx;
}

int Dac(vector<int> &vec,int l,int r,int type)
{
    if(l==r) return vec[l];
    int sum=0;
    for(int i=l;i<=r;i++) sum+=tr[vec[i]].w.sz;
    int mid=r-1;
    for(int i=l,cur=0;i<r;i++)
    {
        cur+=tr[vec[i]].w.sz;
        if(cur*2>=sum) 
        {
            mid=i;
            break;
        }
    }
    return Merge(Dac(vec,l,mid,type),Dac(vec,mid+1,r,type),type);
}

void dfs(int u,int p)
{
    sz[u]=1,fa[u]=p,dep[u]=dep[p]+1;
    for(auto [v,w]:e[u])
    {
        if(v==p) continue;
        dfs(v,u);
        Id[v]=New(u,v,w);
        sz[u]+=sz[v];
        if(sz[v]>sz[son[u]]) son[u]=v;
    }
}

int build(int top)
{
    vector<int> cpr;
    if(Id[top]) cpr.push_back(Id[top]);
    for(int u=top;son[u];u=son[u])
    {
        vector<int> rke;
        for(auto [v,w]:e[u])
        {
            if(v==fa[u] || v==son[u]) continue;
            rke.push_back(build(v));
        }
        if(rke.size()) cpr.push_back(Merge(Id[son[u]],Dac(rke,0,rke.size()-1,1),1));
        else cpr.push_back(Id[son[u]]);
    }
    return Dac(cpr,0,cpr.size()-1,2);
}

点集化改造:

总的来说遵循几个规则:

  • 簇内点集是边集中的所有边更靠下的节点,相当于边权下放点权,上界点不属于这个簇的点集合。
  • 簇内上界点信息是未定义的,不能使用上界点的信息维护簇内信息

收缩树上同层的所有簇最多只有一个公共点且一定是界点。

考虑把每个簇的上界点扣掉,这样每个点在同一层中最多属于一个簇。

需要注意的是这时根节点在任何一层不属于任何一个簇,可以通过添加一个虚根解决。

在维护有关点集合的信息的时候,可以认为在一个簇中,上界点的信息是未定义的,也就是我们初始化包括 \(\operatorname{Rake},\operatorname{Compress}\) 操作合并,维护信息的时候,都不能使用或统计新形成的簇中上界点的信息。
但是可以使用簇中有关下界点的信息。

当我们只关注路径端点的信息的时候,我们只需要让上界点不是端点即可。

但是我们关注路径上所有点的信息的时候,我么们不得不考虑跨过上界点的路径,这些路径需要额外存储,和不跨过上界点路径分开考虑。

比如我们在维护同色联通块的时候,到上界点的同色联通块大小很好维护,但是到下界点的同色联通快大小如果直接维护是错的。

主要原因是,在 \(\operatorname{Rake}\) 的时候,我们会将跨过左儿子根路径,以右儿子上界点为根的同色联通块大小加入到当前簇到下界点的联通块中,这部分跨过了新簇上界点,但是我们并不知道上界点的颜色,我们无法断定这部分是否合法。如果将这部分和没有跨过上界点,已经确定合法的联通块混为一谈,就寄寄了,所以还需要额外存储跨过簇上界点,到下界点的联通块大小。然后在 \(\operatorname{Compress}\) 的时候判断合法性并归入不包含上界点的部分。

维护同色(0/1)联通块的合并信息示例
struct Cluster
{
	int u,v,dis,sz;
	bool tag[2];
	int su[2],sv[2][2];//以 u 为根的颜色联通块大小 以 v 为根,是否经过 u 的颜色联通块大小 
};

Cluster Rake(const Cluster &A,const Cluster &B)
{
	Cluster C;
	C.u=A.u,C.v=A.v,C.dis=A.dis,C.sz=A.sz+B.sz;
	for(auto i:{0,1})
	{
		C.tag[i]=A.tag[i];
		C.su[i]=A.su[i]+B.su[i];
		C.sv[i][0]=A.sv[i][0];
		C.sv[i][1]=A.sv[i][1]+(A.tag[i])*B.su[i];
	}
	return C;
}

Cluster Compress(const Cluster &A,const Cluster &B)
{
	Cluster C;
	C.u=A.u,C.v=B.v,C.dis=A.dis+B.dis,C.sz=A.sz+B.sz;
	int z=col[A.v];
	for(auto i:{0,1})
	{
		C.tag[i]=A.tag[i] && B.tag[i];
		C.su[i]=A.su[i]+(A.tag[i]*B.su[i]);
		C.sv[i][0]=B.sv[i][0]+(z==i)*B.sv[i][1]+(B.tag[i])*A.sv[i][0];
		C.sv[i][1]=(B.tag[i])*A.sv[i][1];
	}
	return C;
}

如果询问的不是全局信息,而是关于一个点为中心的信息,我们可以利用分治的思想,不断跳父亲,统计以跨过父节点中心点的信息。

由于是一颗二叉树,因此每次只用问一边,这是相比点分树的优势。

注意到问的信息一定是跨过上下界点之一的,因此维护的时候也只需要维护跨过上下界点的就好了,注意需要同时维护到询问点到当前跳到的簇的上下界点的信息。

同色联通块大小维护示例
int query(int x)
{
	int c=col[x];
	bool tagu=1,tagv=1;
	int res=1,v=Id[x];
	while(tr[v].fa)
	{
		int u=tr[v].fa;
		if(tr[u].type==1)//Rake
		{
			if(ls==v)
			{
				int z=col[tr[rs].w.u];
				res+=((z==c && tagu)*tr[rs].w.su[c]);
			}
			else
			{
				int z=col[tr[ls].w.u];
				res+=((z==c && tagu)*tr[ls].w.su[c]);
				tagv=(tagu && z==c && tr[ls].w.tag[c]);
			}
		}
		else//Compress
		{
			if(ls==v)
			{
				res+=(tagv)*tr[rs].w.su[c];
				tagv=(tagv && tr[rs].w.tag[c]);
			}
			else
			{
				int z=col[tr[ls].w.u];
				res+=(tagu)*(tr[ls].w.sv[c][0]+(z==c)*tr[ls].w.sv[c][1]);
				tagu=(tagu && tr[ls].w.tag[c]);
			}
		}
		v=u;
	}
	return res;
}

应用:

边修改是容易的,注意点修改不用把相邻的边都修改,通过点集化改造,只需要修改父边即可。

树上单点/边修改,全局/点中心查询。

维护簇内白点到界点的最远距离,簇内答案即可。

维护簇内白点到界点的最近距离,注意簇内不能使用上界点。

这是上面那个例子里说的,由于点集化改造不能知道上界点信息,对于下界点的维护稍微麻烦一点,同时常数也略大。因此可以说 Top Tree 并不太擅长处理联通快问题。

和上面的几乎一样。

容易发现使用 Top Tree + 点集化改造,这就是个板子,相比点分树做法还能支持加点。

处理动态 Dp

优点相比于普通树剖应该好在复杂度部分少一个 log,而且不用额外考虑轻儿子的问题。

缺点是大概率需要点集化改造,但是处理树剖不擅长的边信息 ddp 有很大优势。

处理分治 Dp(树上背包合并)

树上背包,背包合并复杂度是 \(O(sz_u+sz_v)\) 的,可以使用 Top Tree 分治做到 \(O(n \log n)\)

一个经典应用是闵和,像序列分治维护闵和一样维护树上闵和,因为开下节点大小的数组对于 Top Tree 来说十分容易。

而如果硬套树剖那一套的话本质是一样的。

边信息,闵和。Top Tree 用脚维护即可,吊打树剖做法。

维护邻域信息

主要优势在于能 \(O(n \log n)\) 预处理,\(O(1)\) 查询,点分树做不到。

如果带修改也只能像点分树一样跳父亲了。

查询 \(u\) 为中心,半径为 \(d\) 邻域。

找到 \((u,fa_u)\) 所在基簇的父亲中,最大的簇满足直径 $ \le d$ ,然后可以发现邻域可以表示成,簇中全部,簇外到界点不超过父亲直径的有根邻域。

每个簇簇内预处理到簇内直径邻域大小,簇外预处理到父亲簇直径大小,两部分都只需要考虑到界点。

刚刚说的就是这个题的维护方式。

动态 Top Tree

功能强大,支持静态 Top Tree 功能的基础上。还能换根。

什么?连 LCT 都不会,放弃了。

Top Cluster 分块

由于是边集划分,容易处理有关边的信息,点信息也可以用点集化的方法处理。

还是和上面一样的划分,不过我们这次不要 \(\log n\) 层簇集合了,只要一层簇集合,满足每个簇的大小是 \(O(B)\),总共有 \(O(\frac{n}{B})\) 个簇。

构建:

划分规则与方式:

从根开始 dfs,维护一个栈保留当前未被划分的边集和(存的是点,代表这个点的父亲边)

一个点,结束 dfs 后,判断是否要以他为上界点划分出一些簇,条件满足其一就要划分:

  • 以他为根的尚未被划分的联通块边集数量超过了 \(B\)
  • 有至少两个儿子内部存在界点
  • 是整棵树的根节点

维护 \(sz_u\) 表示这个点为根的尚未被划分的边集和大小,\(ed_u\) 表示以 \(u\) 为根的子树中最浅的界点,没有就是 0。有这两个就能判断上面的条件了。

然后如果需要划分,那么我们将儿子划分为若干个簇满足:

  • 大小不能超过 \(B\)
  • 每个簇只能有一个下界点;

因此我们秉承能不分就不分的贪心思路,就是对的,所以分的条件就是:

  • 如果加上以这个点为根的未划分数量,就超限了;
  • 如果已经有下界点,且这个子树里也有下界点;
  • 没有下一个儿子了。
划分代码实现
void dfs(int u,int p)
{
	for(auto it=e[u].begin();it!=e[u].end();it++) 
	{
		if(it->first==p)
		{
			e[u].erase(it);
			break;
		}
	} 
	fa[u]=p,bg[u]=tot;
	int cnt=0;
	sz[u]=0;
	for(auto [v,w]:e[u])
	{
		q[++tot]=v;
		dis[v]=dis[u]+w;
		dfs(v,u);
		sz[u]+=sz[v];
		if(ed[v]) ed[u]=ed[v],cnt++;
	}
	if(sz[u]>B || cnt>1 || !p)
	{
		sz[u]=0,ed[u]=u,Cls.push_back(u);
		int j=bg[u]+1,cnt=0,ndw=0;
		for(auto v:e[u])
		{
			if(cnt+sz[v]>B || (ndw && ed[v]))
			{
				int l=j,r=bg[v]-1;
				Add_cluster(u,ndw,l,r),cnt=ndw=0;
				j=r+1;
			}
			cnt+=sz[v],ndw|=(ed[v]);
		}
		Add_cluster(u,ndw,j,tot);
		tot=bg[u];
	}
	sz[u]++;
}

块内预处理

对应上面那个代码里的 Add_cluster

对于划分出来的每个簇,我们维护一些信息:

  • \(Vec_v=\{u, u \in Cluster(v)\}\): 根据点集化理论,我们用下界点带指一个一个簇。这个数组存属于这个簇的所有边(存点,代表父边,因此上界点不在集合中)

  • 对于簇内点 \(x\),U_x,V_x 分别表示所属簇的上界点和下界点;

  • \(Ne_u\): 表示每个点到簇路径上最近的点是谁,簇路径上的点满足 \(Ne_u=u\)

  • \(Fa_u\): 这个仅针对界点,处理在收缩树上界点的父亲是谁。

我们可以发现一个性质是我们按照 dfs 的顺序加入点的话,天然满足树的拓扑序,因此直接顺序遍历用父亲信息更新自己就行了。换句话说暴力跳是均摊正确的(如果在簇路径上,不会跳,否则会跳一步。

预处理代码实现
void Add_cluster(int u,int v,int l,int r)
{
	if(l>r) return;
	if(!v) v=q[r];
	Fa[v]=u;
	for(int i=l;i<=r;i++) vec[v].push_back(q[i]);
	for(int z=v;z!=u;z=fa[z]) ne[z]=z;
	ne[u]=u;
	for(auto x:vec[v])
	{
		U[x]=u,V[x]=v;
		int z=x;
		while(!ne[z]) z=fa[z];
		ne[x]=ne[z];
	}
}

应用:

一条根链 \((u,Rt)\) 可以被拆解成三部分:

  • \([u,Ne_u)\) 从这个点到簇路径上最近的点,这部分完全是散点,只需要统计散点加的次数。

  • \([Ne_u,U_u)\) 从簇路径上最近的点到簇上界点,这部分会受到整块加的影响,需要知道整块加的次数。

  • \([U_u,Rt]\) 从簇路径一直到根,这部分一定是簇路径,且不会超过 \(O(\frac{n}{B})\) 条簇路径。

一颗子树可以被拆解为两部分:

  • 从这个点到所属簇的下界点在簇内构成的子树;

  • 下界点在收缩树上的子树;

平衡复杂度:

树上数据结构的复杂度一般都带 \(\log\),树分块可以像序列分块一样平衡复杂度,做到链加链求和修改查询 \(O(\sqrt n)-O(1)\)\(O(1)-O(\sqrt n)\)

用二次离线莫队变成 \(O(n)\) 次链修改,\(O(n \sqrt n)\) 次链查询的形式,使用树分块平衡复杂度。

处理邻域信息:

除了所在簇之外,邻域信息都可以在某个簇中表示成以某个界点为中心的邻域,界点只有 \(O(\sqrt n)\) 个,可以预处理。

块内邻域直接暴力就好了。

整块的贡献在收缩树上换根 dp 搞出来每条簇路径被经过的次数。

散块的贡献可以通过预处理到一个界点距离在一定范围内,到界点的距离和解决,利用刚才换根 dp 出来的东西可以知道需要贡献几次。

块内的贡献可以暴力 dp,求出距离分别小于一种界点组合的树上距离笛卡尔积,同样可以换根 dp 在固定一维的情况下搞出来。

最后处理询问所在块是同一个的贡献,直接标记一种点另一种点做换根 dp 算贡献。

这题应该仅限于口胡了。

配合莫队食用:

对于有根邻域,用回滚莫队可以做到 \(n \sqrt m\) 处理 \(m\) 个任意有根邻域询问。

具体来说,如果根不在簇路径上,邻域总大小不超过 \(O(B)\),直接暴力。

如果根在簇路径上,把所有在同一条簇路径上的询问拿出来,按照需要从下界点额外拓展邻域大小排序。

簇内部分回滚,簇外单调不降,直接暴力。

posted @ 2025-04-18 22:05  Richard_whr  阅读(167)  评论(2)    收藏  举报