树?能吃吗?
Wonder_Fish · · 算法·理论
cnblogs
[2025-04-29] 新增树上依赖型背包。
[2025-06-11] 将最小生成树相关内容移到图论文章中。
LCA
先放一篇文章
这部分不算是一种算法,而是最近见到一些关于 LCA 的各种东西。原来自己并不理解 LCA。
首先讲一下
好神奇的做法!查询吊打倍增和树剖,常数全方位吊打欧拉序,就是空间没有树剖优秀。学了这个卡常的时候很有用。
以下内容参考 魏老师博客。
设树上的两个结点
我们不得不使用欧拉序求 LCA 的原因是在欧拉序中,
以下内容假设
- 情况 1:当
u 不是v 的祖先
设
并且对于 dfs 序而言,祖先一定出现在后代之前,所以任何
所以我们只需要求
- 情况 2:当
u 是v 的祖先
直接用上面的结论会查到
考虑令查询区间从
对于情况 1,
综上,若
代码很好写,但是注意 st 表的一些易错点。
int mnd(int x,int y){return (dfn[x]<dfn[y])?x:y;}
void dfs(int x,int fa){
dfn[x]=++cnt,st[cnt][0]=fa;
for(int i=hd[x];i;i=eg[i].nxt)
if(eg[i].v!=fa) dfs(eg[i].v,x);
return ;
}
int lca(int x,int y){
if(x==y) return x;
if((x=dfn[x])>(y=dfn[y])) x^=y^=x^=y;
int d=l[y-x];
return mnd(st[x+1][d],st[y-(1<<d)+1][d]);
}
signed main(){
read(n),read(m),read(s);
for(int i=1;i<n;++i)
read(x),read(y),add(x,y),add(y,x);
dfs(s,0); l[1]=0;
for(int i=2;i<=n;++i) l[i]=l[i>>1]+1;
for(int i=1;i<=l[n];++i)
for(int j=1;j+(1<<i)-1<=n;++j)
st[j][i]=mnd(st[j][i-1],st[j+(1<<(i-1))][i-1]);
while(m--) read(x),read(y),writeln(lca(x,y));
return 0;
}
P4211
在看见求
此题就是用这个结论,信息可减,把询问离线差分一下,扫描线即可。
P11364
用到了区间 LCA 的一些性质。结论是
考虑处理出所有
虚树
有时候问题只与一些点和它们的 LCA 有关,此时如果遍历整棵树会浪费时间,但如果只看这些点就可以使用很多暴力做法。
虚树的构造就是用栈维护一个最右链,按 dfs 序从小到大加入点,分类讨论。
int build(){
sort(h+1,h+k+1,cmp);
int tp; stk[++top]=h[1];
for(int i=2;i<=k;++i){
int nw=h[i],lc=lca(nw,stk[top]); tp=0;
while(dep[stk[top]]>dep[lc]){
if(tp) add(stk[top],tp); tp=stk[top--];
}
if(stk[top]!=lc) stk[++top]=lc;
if(tp) add(lc,tp); stk[++top]=nw;
}
tp=0;
while(top){
if(tp) add(stk[top],tp); tp=stk[top--]; //这一步别忘了
}
return tp;
}
P4103
就是虚树上做树形 dp。
对于两两路径和,考虑计算每一条边的贡献,即边权乘上边两端关键点个数的积。
虚树上一条边
CF639F
先边双缩点,再建虚树,然后再在虚树上连边,缩点,然后看给定点集中的点是否都在一个强联通分量中。
代码细节较多。
P3323
首先看到
对于在虚树中的点,容易用 bfs 或换根 dp 求出到它们距离最近的关键点。对于虚树上的一条边 pair 类型,还要存编号),两次 dfs 即可,然后在每条边的地方讨论一下找到分界点。
还有一些点既未出现在虚树上,也没出现在虚树的边上,但是这些点可以挂到离它们最近的虚树点或虚树边上省略的点上。对每个虚树点求出有多少点挂在上面,对于被省略的路径,也可以用原树的
虚树都写完了才发现教练标了它是 10 级知识点
upd:NOI 大纲修订,虚树不是 10 级了!
点/边分治
当处理的问题与树的形态无关(比如统计某种路径的个数)时,考虑把路径分为经过根的和不经过根的,每次统计经过根的,然后再把树分为几个子树,在每个子树内找一个根做同样的统计。有点像 CDQ 分治。
统计经过某点的路径一般是将所有路径分为 2 段,然后计算拼起来的合法路径数量。
为了保证复杂度,每次定一棵树的重心为根。
点分树就是将一个点向它所有子树的重心连边重新构成的树,相当于将整个点分治的过程记录下来。
P6329
点分树模板题。
点分树,又名动态点分治,就是对于一些强制在线或带修多次询问的题目,不能离线处理所有询问,就将点分治的过程涉及到的半条路径都存下来,然后询问/修改的时候暴力跳分治中心,在存下来的数据结构上查询/修改。
本题若没有修改操作,显然可以将询问挂在点上然后点分治。每次分治将所有半路径按到分治中心的距离排序,查询都是一段前缀,双指针+前缀和似乎就可以做,当然也可以树状数组。现在带修,单点修改某个点的权值,那么考虑用树状数组维护。
建出点分树,然后对每个分治中心,存下维护它的所有半路径的树状数组(需要 vector 动态分配空间,总空间是
上面讲得较为粗略,实际上有不少细节,不过相对思路来说不重要了(bushi
P3241
这题点分树或边分树都能写。
边分治即每次找一条边尽量将树平均分为两棵子树,计算跨过这条边的答案,然后再对分成的两部分递归处理直到不存在边为止。
但是对于一般的树,这样可能会有一些问题。比如菊花图,无论选哪条边都只能分成一个大小为 1 的点和剩下的部分,于是复杂度达到了
然而这题都保证度数
若是没有强制在线,直接把询问挂在点上,每次分治的时候计算跨过当前边的点的贡献,树状数组维护即可。
强制在线考虑建出边分树,一次询问一个点涉及到的分治中心只有
CF715C
设当前分治中心为
直接算的话左边的部分与
这样就可以用点分治做了。
在不少题目中可能会用到类似的方式,有时候变形要复杂很多,比如 P7283 这题要拆
AT_cf17_final_j
用到一个结论:求一个完全图的最小生成树,可以把所有边分成若干个边集,对于每个边集内求最小生成树,再把剩下的边保留再求最小生成树,可以得到原图的最小生成树。
点分治,考虑经过重心的路径产生的边集的 MST,然后加到最终的边集中,再跑 kruskal。
在这里边权算的是
似乎也可以用 Boruvka 做,但不会。
CF150E
调的时候一直 TLE 以为哪常数大了或者按秩合并假了结果是点分治重心找错了
用处理众数的基本套路,先二分,然后将
点分治,然后单调队列按秩合并。合并的时候把所有子树按子树内最大深度从小到大排序,然后先访问深度小的。这样每次初始化的复杂度是
P4183
很好,是我想不出来的智慧题。
有人说过如果你觉得某题是个好题,那说明你的水平和这题还差很远。
首先想出一个结论:是封上若干个叶节点,使得对于每个叶节点,Bessie 到它的距离
考虑确定起始点的情况怎么做。称一个节点被封上,为在离他最近的叶节点处放一个农民,并且农民离他的距离
考虑换根 dp继续挖掘性质,不考虑自己就是叶节点的情况,起点
记
下面只需要找出所有
求
P6199
类似 Tree MST,先在第一棵树上淀粉质,边权转化为
剩下的问题只和分治区域有关,考虑建出这些点在第二棵树上的虚树。
接下来问题完全转化为 Tree MST 那题了。可以考虑继续使用淀粉质,这样总的边数是
题解给出更简单的做法:考虑求
写代码的时候注意判一下当前点是不是建虚树引入的辅助点,如果是就没有第二类边。
目前最优解,好耶。
长链剖分
类似重链剖分,只是重链是以子树大小最大的儿子作为重儿子,而长链是以子树深度最大的儿子作为长儿子。
长链剖分的性质:
-
性质 1:从根节点到任意叶子结点所经过的轻边条数不超过
\sqrt n 。 -
性质 2:一个结点的
k 级祖先所在长链的长度一定不小于k 。(反证法易得) -
性质 3:每个节点所在长链末端为其子树内最深的结点。(定义)
长链剖分一般用于优化与深度相关的树形 dp,一般状态为
P5903
树上 k 级祖先也算是长剖的经典应用。这个可以用倍增做到
先预处理出
现在要查询
CF1009F
经典的长链剖分优化 dp。
设
CF526G
运用到长链性质。
P4292
分数规划 + 长链剖分优化 dp(也可以像 CF150E 一样点分治+单调队列按秩合并)
二分答案,把所有边权减去当前二分的平均值,看长度符合要求的边权和最大的路径边权和是否大于 0。
设
梦熊 NOI 第 16 场 T1
分类讨论,分为三条路径都在
发现若子树内存在
对于第一类情况,直接长链剖分优化 dp 即可。
对于第二类情况,发现实时维护子树外与
子树外的东西是一条路径,考虑点分治将路径拆为两段,
复杂度确实是
dsu on tree
这种看上去很暴力但是复杂度正确的算法真的好厉害!
每次都遍历所有子树再清空再遍历以父亲为根的树太浪费时间了,发现最后一个遍历的子树的答案可以不用清空直接继承给父亲,dsu on tree 的思想是考虑直接继承重儿子的答案。
由于每个点向上到根所经过的轻边条数不超过
CF208E
dsu on tree 经典题。
把询问离线挂到树上然后对每个点算子树内所有深度的点的个数,对于轻儿子算完就清空,对于重儿子不清空保留。
感觉 dsu on tree 的题都差不多所以就不放了。动态维护加入和清空点的方式和莫队很像。
树上依赖型背包
刚学的 trick,用于解决选了某个点就必须选择其父节点的有根树上背包。
其时间复杂度的优化主要体现在把树拍为序列,回避了背包合并,每次都只加入单种物品。
为了方便,规定下面讨论的这棵树的节点编号等于 dfs 序。
首先树上背包做这个问题可以算出以每个点为根的子树的答案,其实我们并不需要这些,只需要求点 1 的答案。
考虑改写 dp 状态,设
在点
-
若
x 不选,那么直接跳过x 的子树,将f_{x+sz_x} 直接搬到f_x 。 -
若
x 选,那么从f_{x+1} 加入若干物品x 转移到f_x ,注意至少需要加入一个物品。
这样为什么能保证选出来的物品一定满足依赖要求,也就是不存在某点
考虑归纳证明,设
对于点
若选
然后模拟一下发现所有情况都可以被这样算到,正确性得证。
背包加法合并(瞎起的名字,与
P6326
朴素的树上背包
注意到要选出一个连通块,若钦定包含某个点,那么问题就转化为以这个点为根的树上依赖型背包。对每个点都做一次,复杂度降为
上述做法存在很多重复计算,钦定某一个点
考虑点分治,每次钦定选取重心,然后递归解决重心的子树。时间复杂度
才发现原来点分治也可以分治连通块!