题解:P3384 【模板】重链剖分 / 树链剖分

· · 算法·理论

本来是写给树链剖分模板的,以诈骗新手,但没人审,所以申请全站推荐。

这是一篇单 \log 的题解

参考资料:

https://negiizhao.blog.uoj.ac/blog/4912

https://www.cnblogs.com/laijinyi/p/18373391/Top-Tree-1

https://www.luogu.com.cn/article/42ajqoux

https://www.cnblogs.com/Richardwhr/p/18834468

https://oi.wiki/ds/top-tree/

前言(与后文无关)

首先考虑一个问题:我们如何表示一棵树?

最简单的方法显然用点和边表示,但这种方式不利于我们进行动态树操作,所以我们来考虑下面这种树的表示法。

首先定义(cluster):簇是一个边的集合,满足最多只有两个界点与其他簇相邻,一般我们称深度小的界点为上界点,深度大的界点为下界点

每一条边显然都是一个簇,我们称为基簇(base cluster);而所有边显然也是一个簇,我们称为根簇(root cluster)。那么我们就可以把基簇通过一些合并操作变成根簇,然后通过记录合并的顺序来记录整颗树的信息。

考虑 RakeCompress 两个操作合并簇,一个 Compress 操作可以消除一个度为 2 的点,一个 Rake 操作可以消除一个度为 1 的点。(如图) 由于 Rake 和 Compress 操作都是二元操作,所有我们可以用一棵二叉树来表示整个合并的过程,我们称为 Top Tree。问题就转化为了如何维护这棵 Top Tree。(左图为合并的过程,右图为 Top Tree(圆点为 Rake 节点,方点为 Compress 节点))

我们可以 O(n) 建出一棵静态 Top Tree,并用一些方法把树高控制在 O(\log n),所以我们就可以在 O(\log n) 的时间内单边修改,全局查询,我们就可以解决如动态直径、树上 DDP 等问题。

但这种方法具有局限性:它只支持单边修改,不支持链修改,子树修改。所以我们需要一种更高级的方法来维护。

SATT 是什么

SATT(Self-Adjusting Top Tree),是最强大的动态树,它支持高效的动态树操作,路径操作,子树操作。

Warning:SATT 是基于点的,Top Tree 是基于边的,所以 SATT 其实更类似于 LCT 而不是 Top Tree。

对于一棵树分层定根,每一层选一条到根链,建一棵 BST 维护 Compress 操作的顺序,我们称为 Compress Tree,其节点称为 Compress Node。对于每一个节点,可能有多个簇要 Rake 上去,我们让它们先互相 Rake,变成一个簇后再 Rake 上去,建一棵 BST 维护 Rake 操作的顺序,我们称为 Rake Tree,其节点称为 Rake Node

我们可以认为 SATT 就是把 LCT 的所有虚儿子存储在 Rake Tree 上来做到高效子树操作。

发现 Compress Tree 和 Rake Tree 此时是相互独立的,所以考虑如何把它们结合到一起。我们把 Rake Tree 的根节点连到 Compress Node 的中儿子上,把 Compress Tree 的根节点连到 Rake Node 的中儿子上。由于 Rake Tree 表示合并顺序,而 Compress Tree 表示实际的点的顺序,所以 Compress Node 不一定有中儿子,而 Rake Node 一定要有中儿子

下面是我自己画的图。

原树(粗边表示实链):

一种可能的 SATT 形态(单字母点表示 Compress Node,双字母点表示 Rake Node):

如何维护 SATT

我们一般使用 Splay 来维护整棵 SATT,来保证均摊复杂度为单 \log

需要维护的信息:

#include<bits/stdc++.h>
#define For(i,j,k) for(int i=j;i<=k;i++)
#define dFor(i,j,k) for(int i=j;i>=k;i--)
using namespace std;
#define MAXN 100005
#define ls son[0]
#define rs son[1]
#define ms son[2]
#define endl '\n'
int n,m,rt,p;
int st[MAXN*2],top=0,tot=0;
struct Data{
    int sz,sum;
    Data(int _sz=0,int _sum=0){
        sz=_sz;sum=_sum;
    }
};
Data operator +(Data x,int y){
    return Data(x.sz,(x.sum+1ll*y*x.sz%p)%p);
}
Data operator +(Data x,Data y){
    return Data((x.sz+y.sz)%p,(x.sum+y.sum)%p);
}
struct Node{
    int son[3],val,fa,right,left;
    bool rev;
    int pathadd,treeadd;
    Data path,tree;
    void clear(){
        ls=0;rs=0;ms=0;val=0;fa=0;right=0;left=0;rev=0;pathadd=0;treeadd=0;
        path=tree=Data();
    }
}tr[MAXN*2];

需要实现的函数:基本于 LCT 相同。

```cpp bool isroot(int x){ return tr[tr[x].fa].ls!=x&&tr[tr[x].fa].rs!=x; } int dir(int x){ if(x==tr[tr[x].fa].ls) return 0; if(x==tr[tr[x].fa].rs) return 1; return 2; } ``` $\texttt{pushup/pushdown/pushall}$($\texttt{type=0}$ 表示 Compress Node,$\texttt{type=1}$ 表示 Rake Node): 对于路径修改,只会在 Compress Node 上发生,懒标记传播到左儿子和右儿子。对于子树修改,如果在 Compress Node 上,懒标记传播到 Compress Node 的中儿子和右儿子;如果在 Rake Node 上,懒标记传播到左儿子、右儿子和中儿子上,并更新中儿子的路径修改标记。 ```cpp void rev(int x){ if(!x) return ; swap(tr[x].ls,tr[x].rs); swap(tr[x].left,tr[x].right); tr[x].rev^=1; } void addpath(int x,int k){ if(!x) return ; tr[x].val=(tr[x].val+k)%p; tr[x].path=tr[x].path+k; tr[x].pathadd=(tr[x].pathadd+k)%p; } void addtree(int x,int k){ if(!x) return ; tr[x].tree=tr[x].tree+k; tr[x].treeadd=(tr[x].treeadd+k)%p; } void pushdown(int x,bool type){ if(type==0){ if(tr[x].rev){ rev(tr[x].ls); rev(tr[x].rs); tr[x].rev=0; } if(tr[x].pathadd!=0){ addpath(tr[x].ls,tr[x].pathadd); addpath(tr[x].rs,tr[x].pathadd); tr[x].pathadd=0; } if(tr[x].treeadd!=0){ addtree(tr[x].ls,tr[x].treeadd); addtree(tr[x].rs,tr[x].treeadd); addtree(tr[x].ms,tr[x].treeadd); tr[x].treeadd=0; } }else{ if(tr[x].treeadd!=0){ addtree(tr[x].ls,tr[x].treeadd); addtree(tr[x].rs,tr[x].treeadd); addtree(tr[x].ms,tr[x].treeadd); addpath(tr[x].ms,tr[x].treeadd); tr[x].treeadd=0; } } } void update(int x,bool type){ if(!isroot(x)) update(tr[x].fa,type); pushdown(x,type); } void pushup(int x,bool type){ tr[x].left=tr[x].ls?tr[tr[x].ls].left:x; tr[x].right=tr[x].rs?tr[tr[x].rs].right:x; if(type==0){ tr[x].path=tr[tr[x].ls].path+tr[tr[x].rs].path+Data(1,tr[x].val); tr[x].tree=tr[tr[x].ls].tree+tr[tr[x].rs].tree+tr[tr[x].ms].tree; }else{ tr[x].tree=tr[tr[x].ls].tree+tr[tr[x].rs].tree+tr[tr[x].ms].tree+tr[tr[x].ms].path; } } ``` $\texttt{rotate/splay}$: ```cpp void rotate(int x,bool type){ int y=tr[x].fa,z=tr[y].fa,r=dir(x); if(z) tr[z].son[dir(y)]=x; tr[y].son[r]=tr[x].son[!r]; tr[x].son[!r]=y; if(tr[y].son[r]) tr[tr[y].son[r]].fa=y; tr[y].fa=x; tr[x].fa=z; pushup(y,type); pushup(x,type); } void splay(int x,bool type,int target=0){ update(x,type); while(!isroot(x)&&tr[x].fa!=target){ int y=tr[x].fa; if(!isroot(y)&&tr[y].fa!=target){ rotate(dir(x)==dir(y)?y:x,type); } rotate(x,type); } } ``` $\texttt{splice/access}$(这是整个 SATT 最重要的操作): $\texttt{access}$ 操作表示将一个节点移到根簇的下界点,操作方法如下。 我们先打通该节点到根的路径,使其全部转变为 Compress Tree。 1.我们让该节点成为当前 Compress Tree 的下界点:把该节点 Splay 到 Compress Tree 的根节点,然后把它的右儿子换成 Rake Node,合并到中儿子处,使该节点成为当前 Compress Tree 的下界点。 2.我们把该节点换成它的父节点,我们让该节点跨过上方的 Rake Tree:首先把父节点(Rake Node)Splay 到 Rake Tree 的根节点,然后尝试把该节点接到爷节点的右节点上。如果爷节点存在右节点,我们就把右节点变成 Rake Node,交换该节点和右节点即可。这样就跨过了 Rake Tree,这一操作被就称为 $\texttt{splice}$。 3.重复操作 2 直到到达根节点。 我们发现此时到根的路径已经打通了,且当前节点就处在下界点的位置,我们进行一次 Splay 将其转到树顶即可。 **类比 LCT,删除右儿子就是把右儿子换到 Rake Tree 上。** ```cpp void setfa(int x,int f,int type){ if(x){ tr[tr[x].fa].son[dir(x)]=0; tr[x].fa=f; } tr[f].son[type]=x; } void clear(int x){ tr[x].clear(); st[++top]=x; } void del(int x){ if(tr[x].ls){ int y=tr[tr[x].ls].right; splay(y,1,x); setfa(tr[x].rs,y,1); setfa(y,tr[x].fa,2); pushup(y,1); }else{ setfa(tr[x].rs,tr[x].fa,2); } pushup(tr[x].fa,0); clear(x); } void splice(int x){ splay(x,1); int y=tr[x].fa; splay(y,0); pushdown(x,1); if(tr[y].rs){ swap(tr[tr[x].ms].fa,tr[tr[y].rs].fa); swap(tr[x].ms,tr[y].rs); pushup(x,1); }else{ setfa(tr[x].ms,y,1); del(x); } pushup(y,0); } int newnode(){ if(top){ return st[top--]; }else{ return ++tot; } } ``` $\texttt{makeroot/split(expose)/link}$: ```cpp void makeroot(int x){ access(x); rev(x); } void link(int x,int y){ access(x); makeroot(y); setfa(y,x,1); pushup(x,0); } void expose(int x,int y){ makeroot(x); access(y); } ``` $\texttt{modify/query}$(对外接口): ```cpp void modifyp(int x,int y,int k){ expose(x,y); addpath(y,k); } void modifyt(int x,int k){ expose(rt,x); tr[x].val=(tr[x].val+k)%p; addtree(tr[x].ms,k); pushup(x,0); } Data queryp(int x,int y){ expose(x,y); return tr[y].path; } Data queryt(int x){ expose(rt,x); return tr[tr[x].ms].tree+Data(1,tr[x].val); } ``` 主函数: ```cpp int main(){ ios::sync_with_stdio(0); cin.tie(0);cout.tie(0); cin>>n>>m>>rt>>p; tot=n; For(i,1,n){ cin>>tr[i].val; tr[i].val%=p; pushup(i,0); } For(i,1,n-1){ int u,v; cin>>u>>v; link(u,v); } while(m--){ int op; cin>>op; if(op==1){ int x,y,k; cin>>x>>y>>k; modifyp(x,y,k); }else if(op==2){ int x,y; cin>>x>>y; cout<<queryp(x,y).sum<<endl; }else if(op==3){ int x,k; cin>>x>>k; modifyt(x,k); }else{ int x; cin>>x; cout<<queryt(x).sum<<endl; } } return 0; } ``` # SATT 的时间复杂度 具体证明我也不大会,大体上类似于 LCT,尝试使用势能函数证明,[证明过程](https://oi.wiki/ds/top-tree/#satt-%E7%9A%84%E6%97%B6%E9%97%B4%E5%A4%8D%E6%9D%82%E5%BA%A6%E8%AF%81%E6%98%8E)。 最终结论是对于一个 $n$ 个节点的树,一次 $\texttt{access}$ 操作的均摊时间复杂度为 $O(\log n)$。Tarjan 的原论文中证明了一个很松的上界,为 $96\log n$ 次 $\texttt{rotate}$,不过不用担心,实际上远远取不到这个上界。(见下表) 理论时间复杂度:$O((n+m)\log n)$。 # SATT 的实际效率 对于本题 P3384。 [SATT](https://www.luogu.com.cn/record/258363002):最大点 $\texttt{446\ ms,11.91\ MB}$。 [树剖](https://www.luogu.com.cn/record/258363410):最大点 $\texttt{229\ ms,17.00\ MB}$。 本地测试结果(完全二叉树,只有操作 $1,2$): |数据范围|树剖|SATT| |:--------:|:--:|:--:| |$n=10^4,m=10^4$|$\texttt{0.06\ s}$|$\texttt{\ 0.06\ s}$| |$n=10^5,m=10^5$|$\texttt{0.41\ s}$|$\texttt{\ 0.38\ s}$| |$n=10^6,m=10^6$|$\texttt{7.35\ s}$|$\texttt{\ 6.06\ s}$| |$n=10^7,m=10^7$|$\texttt{125.73\ s}$|$\texttt{\ 88.67\ s}$| 随机 $\texttt{access}$,平均每次 $\texttt{access}$ 操作花费的 $\texttt{rotate}$ 次数为 $k\log n$($n=10^6,m=10^6$): |$k$|随机树|链|菊花|完全二叉树|向日葵|毛毛虫| |:-:|:-:|:-:|:-:|:-:|:-:|:-:| |SATT|$1.2$|$1.4$|$1.5$|$1.4$|$1.4$|$1.3$| |LCT|$0.3$|$1.3$|$0.0$|$0.3$|$0.6$|$1.2$|