树链剖分学习笔记
前言
树链剖分就是将一棵树分成几条链,把树上操作变为序列操作,从而利用数据结构简单维护。
更具体一点:(from OI wiki)
重链剖分可以将树上的任意一条路径划分成不超过 \(\log n\) 条连续的链,
每条链上的点深度互不相同(即是自底向上的一条链,链上所有点的 LCA 为链的一个端点)。
重链剖分还能保证划分出的每条链上的节点 DFS 序连续,
因此可以方便地用一些维护序列的数据结构(如线段树)来维护树上路径的信息。
那么显然这么有用的思想,为什么不学呢。
原理
如果要学习树剖,我们将不可避免的接触到下列定义:
- 重儿子:对于每一个非叶子节点,它的儿子中 儿子数量最多的那一个儿子 为该节点的重儿子。
- 轻儿子:对于每一个非叶子节点,它的儿子中 非重儿子 的剩下所有儿子即为轻儿子。
- 重边:连接任意两个重儿子的边叫做重边。
- 轻边:剩下的即为轻边。
- 重链:相邻重边连起来的 连接一条重儿子 的链叫重链。
从上列定义中不难得出几条简单的性质:
- 叶子节点没有重儿子也没有轻儿子。(
因为它没有儿子) - 对于叶子节点,若其为轻儿子,则有一条以自己为起点的长度为 \(1\) 的链。
- 每一条重链以轻儿子为起点。
然后我们可以按照先重后轻的顺序遍历整棵树,得到其 DFS 序。
可以证明这样剖分之后,树上的任意一条路径就被分成不超过 \(\log n\) 段。
利用这个特性就可以乱搞优美地维护某些奇奇怪怪满足区间可加性的数据了。
代码实现
大部分的树剖预处理阶段都分为两步。
首先是 dfs1()
,仅用于简单的基础性预处理,主要内容有:
- 每个节点的父亲节点
fa
。 - 每个节点的深度
dep
。 - 每个节点的子树大小
sz
。 - 每个节点的重儿子编号
son
。
显然这都是树上问题的基本操作,不做过多介绍。
int dep[N],sz[N],son[N],fa[N];
void dfs1(int u,int last){
fa[u]=last;
dep[u]=dep[last]+1;
sz[u]=1;
int mx=-1;
for(int i=head[u];i;i=ed[i].nxt){
int v=ed[i].to;
if(v==last) continue;
dfs1(v,u);
sz[u]+=sz[v];
if(sz[v]>mx) mx=sz[v],son[u]=v;
}
return;
}
然后进入 dfs2()
,树剖重点之一。
这里我们要预处理的数据有:
- 每个节点按照先重后轻的顺序剖分后的 dfs 序
id
。 - 每个节点所在链的链顶
top
。 - 有时还需要记录每个 dfs 序对应的节点信息,便于运用到数据结构的预处理上。
强调顺序一定是先重后轻的,
这样既保证了每一条重链的 dfs 序是一致的,也保证了每一个子树内的编号是连续的(因为是 dfs)
有这样优美的性质才方便用数据结构维护。
int id[N],top[N],tot=0;
void dfs2(int u,int Top){
id[u]=++tot;
top[u]=Top;
val2[tot]=val1[u];
//val1[]是读入时的点权,val2[]是dfs序上的点权。
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);
}
return;
}
然后预处理之后是快乐的应用环节,以简单的修改为例:
//将 x到y 之间的路径上的每一个点的点权改为 z。
void Modify(int x,int y,int z){
while(top[x]!=top[y]){
if(dep[top[x]]<dep[top[y]]) swap(x,y);
update(1,1,n,id[top[x]],id[x],z);
x=fa[top[x]];
}
if(dep[x]>dep[y]) swap(x,y);
update(1,1,n,id[x],id[y],z);
return;
}
简单说我们是不断将较深节点跳链,直至两者在同一条链中。
显然跳链次数不超过 \(\log n\) 次,而线段树上的 update 也是 \(O(\log n)\) 的。
保证了一般树剖的时间复杂度为 \(O(n\log^2 n)\)。
那么如果要讲某棵子树内的所有节点都修改该怎么办办呢,其实更简单:
//将 以x为根的子树 中的每一个点的点权改为 z。
void Modify_Root(int x,int z){
update(1,1,n,id[x],id[x]+sz[x]-1,z);
}
显然查询时的代码举一反三就好了。
例题
例题一
用上面的代码即可简单 AC。
例题二
没错树剖可以用来求 LCA 而且跑得飞快还很好写。
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;
}
主要部分它就这么多,预处理和之前一样。
例题三
没错,如果将每一种宗教都开一棵线段树貌似空间 \(O(n^2)\) 是真的存不下。
但是如果我们动态开点不就变成 \(O(n\log n)\) 了吗。
例题四
很好的一道树剖题。
这道题告诉我们在往上跳链的同时可能需要记录某些数据。
线段树要记录:
- 左端点的颜色
lc
。 - 右端点的颜色
rc
。 - 区间成段更新的标记
col
。 - 区间颜色段数
dat
。
区间合并的时候如果 左子树的右端 和 右子树的左端 颜色相同那么 ans--
。
显然当前剖链与上一次的链在相交的边缘可能颜色相同,
所以统计答案的时候要记录下上一次链的左端点的颜色,与当前链右端点的颜色
比较这两个颜色,若相同则 ans--
。
最后跳到同一条链上时还要注意细节。
例题五
没错树剖还支持换根。
先以 \(1\) 号节点为根剖一下,修改时也直接以 \(1\) 为根进行修改,询问时采用不同策略。
设当前根为 root
,询问的子树为 \(x\):
root=x
:当然就是全局最小值,直接输出。x
是root
在以 \(1\) 为根情况下的子树:子树结构完全相同,直接按照正常操作查询。x
不属于以上情况,也不在 \(1\) 到root
的链上,在其他的支叉上:没有影响,直接查询。x
在 \(1\) 到root
的链上:显然以x
为根的子树就是原树上除去(x,root]
上的节点,查询其它即可。
在判断的同时我们还需要求 \(x\to root\) 上的 \(x\) 的子节点,利用树剖可以快速方便的解决。
总结
是不是非常简单