动态点分治学习笔记

前言

点分树是一种树上分治算法,其本质是在基于点分治的基础上构造出一棵重构树。

通常用于解决与原树结构无关的树上统计问题。

前置芝士

  1. 首先静态点分治是必然的。
  2. 因为维护数据是往往需要某些毒瘤数据结构,所以可能要用到:动态开点线段树、树状数组、堆等。
  3. 同时 STL 也是代替手写数据结构的好工具。
  4. 百折不挠的 Debug精神

算法原理

首先拿出模板题

显然如果没有修改点权这一操作的话,离线下来就是裸的静态点分治。

多了修改之后,我们难道就只能每修改一次重新点分一次吗(好像挺方便的)

我们可以按照分治递归的顺序提一颗新树出来,易知树高是 \(O(\log n)\) 的。

具体地说,对于每一个找到的重心,将上一层分治时的重心设为它的父亲,得到一颗大小不变、最多 \(\log n\) 层的点分树。

既然它只有 \(\log n\) 层,那么我们即使暴力跳某个点的祖先也就 \(\log n\) 下,这样就很好的使修改操作变得可以乱搞可控起来。

对于以 \(i\) 为根的子树统计答案时,我们通常选择统计:

  1. \(f_1(i)\):以 \(i\) 为根的子树的答案。
  2. \(f_2(i)\):以 \(i\) 为根的子树对 \(fa_i\) 的答案贡献。

容斥之后发现:\(ans(fa_i)=f_1(fa_i)-f_2(i)+f_1(i)\)

然后不断利用暴力统计+简单容斥,题目变得暴力简单起来。

代码实现

日常建树:

int sz[N],mxp[N],fa[N],rt;
bool vis[N];
void get_sz(int u,int fa){
	sz[u]=1;for(int i=head[u],v;i;i=ed[i].nxt) 
	if(!vis[v=ed[i].to] && v!=fa) get_sz(v,u),sz[u]+=sz[v];
}
void get_root(int u,int fa,int Top){
	mxp[u]=sz[Top]-sz[u];
	for(int i=head[u],v;i;i=ed[i].nxt) if(!vis[v=ed[i].to] && v!=fa) 
		get_root(v,u,Top),mxp[u]=max(mxp[u],sz[v]);
	if(!rt || mxp[u]<mxp[rt]) rt=u;
}
void dfs(int u,int last){
	get_sz(u,0);rt=0;get_root(u,0,u);
	u=rt;fa[u]=last;vis[u]=true;
	get_sz(u,0);
	for(int i=head[u],v;i;i=ed[i].nxt) if(!vis[v=ed[i].to]) dfs(v,u);
}

显然一般的建树不可能这么简单,这只是个简单框架。

建完之后就是跳树了。

void query(int u){
    ans=以 u 为根的子树的答案。
    for(int i=u;fa[i];i=fa[i]){
        ans+=以 fa[i] 为根的子树的答案。
        ans-=以 i 为根的子树对 fa[i] 的答案贡献。
    }
}

同样也是简单框架。

值得注意的是对于与统计路径有关的点分树题,我们往往要不断求树上任意两点的距离。

方法有很多,这里推荐简单好写而且跑的很快的树剖:

struct DIS{
    int dep[N],Fa[N],son[N],top[N],Sz[N];
    void dfs1(int u,int fa){
        dep[u]=dep[fa]+1;Fa[u]=fa;Sz[u]=1;
        for(int i=head[u],v;i;i=ed[i].nxt) if((v=ed[i].to)!=fa){
            dfs1(v,u);Sz[u]+=Sz[v];
            if(Sz[v]>Sz[son[u]]) son[u]=v;
        }
    }
    void dfs2(int u,int Top){
        top[u]=Top;if(!son[u]) return;dfs2(son[u],Top);
        for(int i=head[u];i;i=ed[i].nxt){
            int v=ed[i].to;if(v==Fa[u] || v==son[u]) continue;
            dfs2(v,v);
        }
    }
    int lca(int x,int y){
        while(top[x]!=top[y]){
            if(dep[top[x]]<dep[top[y]]) swap(x,y);
            x=Fa[top[x]];
        }
        if(dep[x]>dep[y]) swap(x,y);return x;
    }
    int dis(int x,int y){return dep[x]+dep[y]-2*dep[lca(x,y)];}
    void build(){dfs1(1,0);dfs2(1,1);}
}T;

//调用方法:
T.build();
dis(x,y)=T.dis(x,y);

以下还有几个容易错的实现细节:

  1. 子连通块大小最好不要用 \(sz(v)\),我上面采用的点分树写法巧妙避开了这个问题。
  2. 一般不用重新建树,如果需要建议将原树用结构体封装,避免混淆。
  3. 使用 STL 时注意边界条件,防止 RE。

时间复杂度

说了这么久,点分树的时间复杂度到底有多优秀呢。

显然它的复杂度与你数据结构的复杂度有关,我们假设数据结构的修改和查询操作的复杂度都是 \(O(\log n)\) 的。

同时询问次数不高于节点数量。那么点分树的时间复杂度为 \(O(n\log^2 n)\)

例题

例题一

模板题

套路题,统计时利用树状数组 \(O(\log n)\) 修改并统计前缀和。

由于空间要求,我们需要动态开点。(树状数组怎么动态开点?STL 大法好)

利用 vector 即可实现,对于不熟悉 STL 的同学来说有些麻烦,但这也是熟悉 STL 的好机会。

例题二

开店

题目大意:护一颗带点权、边权树,每次给出 \(x,l,r\),查询 \(\sum_{l\leq A_y\leq r} dis(x,y)\) ,其中 \(A_y\)\(y\) 的点权。

套路题,注意统计路径长度时常常还要统计节点数量,因为 \(Edge(i,fa_i)\) 是需要另外容斥的。

针对这道题,如果没有强制在线的话,可以离线下来将点权离散化后用动态开点的线段树实现。

但是对于强制在线就只能vector+二分+前(后)缀和

为什么是后缀和呢,因为 lower_boundupper_bound 都是左闭右开,使用后缀和更加简便。

例题三

幻想乡战略游戏

可以得出结论:

\(x\) 换为其子节点 \(y\) 更优时,当且仅当 \(2\times sum(y)>sum(1)\)

这里假设 \(1\) 为根节点,\(sum(i)\) 表示以 \(i\) 为根的子树的点权之和。

显然符合条件的 \(y\) 节点仅有一个。

那么我们利用点分树暴力往下找即可,这是一类比较特殊的点分树题。

总结一下就是利用点分树暴力统计。

例题四

小清新数据结构题

丧心病狂的推柿子题,推推柿子就好。

推完之后发现和上一题代码几乎一模一样,就是多维护几个东东。

例题五

捉迷藏

最简单的放最后

套路题,唯一难点是我们需要一个支持插入、查询最大值、查询次大值、删除某个元素的数据结构。

一个比较有技巧性的操作是用两个堆实现惰性删除,需要比较大的代码实现能力。

注意常数较大。

总结

参考资料

点分树还可以结合很多数据结构,所以数据结构的学习是关键之一。

很多点分树的题目同样可以用 LCT 解决,要继续学习。

或许我们还可以来一道大毒瘤

完结撒花。

posted @ 2021-02-16 01:05  LPF'sBlog  阅读(70)  评论(0)    收藏  举报