前言
好久没写博客了上来水一发
当年调板子的时候调到天昏地暗头脑发晕
但是写顺手之后太~ 爽 ~啦!(x
然而光是求lca还是建议倍增,树剖常数太大哩(悲
Part 0 引入
对于序列上的区间加减,大家想必有很多解决的方式了。静态可以通过预处理、动态规划等降低复杂度,动态往往使用数据结构进行维护,让每次询问都有相对优秀的复杂度。
但如果是在树上呢?
先考虑静态,那我们同样有不少方法,树形dp,树上差分,倍增,etc.
但如果是动态,我们需要一种算法去适应树的结构,就如我们用线段树等去匹配序列的结构一样,从而实现子树||链上的修改和查询。
所以今天我们要讨论的是树上动态问题中一个常用的手段——重链剖分。
Part 1 定义
树链剖分其实算是一大类算法的集合,其中包括但不限于重链剖分,长链剖分,LCT等用于树上动态问题的算法。但一般而言,重链剖分是用的最多也最广泛的,所以后来树链剖分和重链剖分概念上就基本等价了
(什么公车私用剧情
那么就重链剖分而言,完全可以采用语文阅读理解的方式。
第二个字——链,后两个字——剖分,实际上是在进行对算法的描述。也就是说,这种算法的本质是把一棵树通过某种方式,剖分成一条一条的链,再对每条链(也就是序列)采用已知算法来求解。
很明显的,如果我们随意进行剖分,可能会出现链数量过多的问题,从而无法保证复杂度。因此大佬们发明了不少保证复杂度的剖分方式。
那么第一个字——重,自然是在阐述这种算法的特别之处了。
下面开始定义:
对于树上某一节点:
重儿子: 所有子节点中子树最大的被称为重儿子。
轻儿子: 除重儿子之外的子节点。
重边:从该结点到重子节点的边为
轻边:~~~~
重链:若干条首尾衔接的重边构成重链
附图(图源为他人博客,侵删)
可以看到,以重儿子的原则进行dfs并进行编号后,整棵树被划分成了若干DFN序连续的链条,只需要把他们按DFN序排列后(想象一下拆成链条,序号连续处把链条首尾相接绑起来,然后拉成一串,锵锵!)就是一段连续的序列啦~
这下就可以召唤线段树了(
那么按上文方式定义之后,我们可以得到两个性质:
(1)轻边(u,v)中,size(v)<=size(u/2)
解释:否则它就是重儿子了
(2)从根到某一点的路径上,不超过logn条轻边和不超过logn条重链。
解释:由性质1,轻儿子为根的子树的大小必然不超过原树大小的1/2,因此每经过一条轻边,树的大小至少减小一半,因此最多logn条轻边
而每走一条轻边就相当于从一条重链跳到另一条重链上,因此最多logn条重链
Part 1.5 复杂度
那么有人就要问了: 复杂度是怎么保证的呢?
答:对于每一次查询,我们把查询的链分在我们划分的多条链上,分别进行线段树修改/查询。 线段树修改查询操作的时间复杂度是 O(logn);而在树链上每走一条轻边,子树大小就 /= 2,所以最多走 logn 条轻边,处理logn条重链,复杂度 O(logn)。
嗯,这样我们就得到了一个优秀的O(logn · logn)
(而且这两个logn基本上不满
Part 2 模板
重链剖分的代码可以概括为:DFS+线段树+LCA
DFS是为了处理信息,线段树和LCA是为了解决问题
下面一步一步来讲:
①DFS(两遍)
通过定义部分我们能知道,树链剖分的前置工作非常非常多(),要处理重儿子和重链(也就说明要子树大小,链上信息等),要处理树上编号和线段树编号(即DFN序)的对应关系,还要处理LCA需要用到的深度等……阿巴阿巴
一般而言我们分为两次DFS。第一次,我们处理出重儿子和其他易于统计的信息:
//son[x]:x的重儿子的编号
inline void dfs1(int n)
{
size[n]=1;
for(int i=head[n],v;;i=e[i].nxt){
v=e[i].v;
if(v==fa[n])continue;
fa[v]=n,deep[v]=deep[n]+1;
dfs1(v);size[n]+=size[v];
if(size[v]>=size[son[n]])//更新重儿子
son[n]=v;
}
}
第二次,我们按照重儿子优先的原则去dfs并记下DFN序和节点编号的对应关系,顺便处理链上信息。
//top[n]:n所在重链的链首的编号
//id[n]:节点n的DFN序
//rank[n]:DFN序为n的节点的编号
//op:链首编号(原来我也玩___)
inline void dfs2(int n,int op)
{
id[n]=++cnt;top[n]=op;rank[cnt]=n;
if(!son[n])continue;//没重儿子就是到叶节点了
dfs2(son[n],op);//走重儿子,重链连续,链首不变
for(int i=head[n],v;;i=e[i].nxt){
v=e[i].v;
if(v==fa[n]||v==son[n])continue;
dfs2(v,v)//过轻边到轻儿子,即另一条重链的链首
}
}
②线段树
至此我们就处理完了所有需要的信息,成功剖树为链~
接下来老规矩上线段树即可,没啥可讲的
//需要进行的操作依题目而变,所以只写一点点代码
inline void build(int l,int r,int n)
{
t[n].l=l,t[n].r=r;
if(l==r){
t[n].val=val[rank[l]];
//这个映射可以思考一下
return;
}
int mid=(l+r)/2;
build(l,mid,n<<1);
build(mid+1,r,(n<<1)|1);
update(n);
}
③LCA
树链剖分中找LCA遵循一下过程:(以x,y为例)
1、当x,y不在同一条重链,比较x和y所在重链链首的深度大小
2、较深的那个沿着重链跳到链首的父亲处,重复1
3、当x,y在同一条重链,深度小的为LCA
因为每次“跳”必定经过轻边,复杂度同上文一样有保证
之所以比较链首深度而非节点本身的深度,是担心出现跳过了的情况,比如下图:
(太丑了,简直丑贯天灵
对于节点A,B而言深度相同,如果此时选择先跳B,就会直接跳到根节点,就寄了(
比较链首深度就不会出现这个情况~
代码如下:
//找LCA的代码
inline int Lca_Place(int x,int y)
{
while(top[x]!=top[y]){
if(deep[top[x]]<deep[top[y]])swap(x,y);
x=fa[top[x]];
}
return (deep[x]>deep[y])?(y):(x);
}
//x到y路径操作的代码,操作就是自己线段树对应的那部分
//这里写的加,ql和qr是写来好看的
ivoid Lca_Add(int x,int y)
{
while(top[x]!=top[y]){
if(deep[top[x]]<deep[top[y]])swap(x,y);
ql=id[top[x]],qr=id[x];//x到链首进行加
addsome(ql,qr,1);
x=fa[top[x]];
}
if(deep[x]>deep[y])swap(x,y);
ql=x,qr=y,addsome(ql,qr,1);
}
Part 3 水题
[JLOI2014] 松鼠的新家:链加
[NOI2015]软件包管理器:链查询/修改,子树查询/修改
[USACO15DEC]Max Flow P 链加
[ZJOI2008]树的统计 链加+链极值
……(挺多的)
Ex 换根
一点点小的扩展芝士,虽然还没遇见过能用的地方(
我们要支持这样一个操作:将一个指定的节点设置为树的新根。
首先很明显的,我们不可能换一次根就重新剖一次树,因此我们考虑换根之后答案发生什么变化:
链上的修改无所谓,因为换根不会影响路径,唯一要考虑的就是对子树答案的影响。
考虑我们现在的树根是root,当前询问的子树根节点是u。 那么会有以下情况(以下LCA均指原树中的LCA)
1.u==root,那么u的子树就是整棵树。
2.LCA(root,u)≠u,即root不在u的子树中。那么u现在的子树就是原来的子树
3.LCA(root,u)=u,即u在原来的树中是root的祖先。那么我们找到u到root路径上的第一个儿子。这个儿子对应的原树中的子树,就是现在u的子树的补集。
分别对应转换一下就好了。实际上有点像天平向左向右倾斜的变化,手动画个图就ok~
Part 4 总结
总的思想就是用链维护树,用线段树维护链
要注意的细节非常之多,而且线段树一定是基础,不能出错!否则调代码调死你,鬼知道错的是dfs1,dfs2,线段树还是lca(
那么最后感谢阅读本文~
有空可以来私信催稿避免我摆烂 ,尽量保证一周五篇起(当然不一定是题解