树上统计问题是一类非常常见的问题,
主要基于一颗信息树,除了最基本的树形dp外,许多数据结构应运而生
其中涉及的算法和技巧非常的多,不过仍然有模型和套路可以遵循。
目录:
一、树上差分
二、二维数点模型
三、树链剖分(毛毛虫剖分*)
四、树上启发式合并(dsu on tree)
五、树上分治(点分治 && 点分树* && Top-Tree*)
六、虚树
一、树上差分
树上差分是常见的模型,有时通过抽象模型,可以用简洁的树状数组和线段树,代替树剖。
两种树上差分的模型:
1.单点修改,链求和
链求和可以改成从根到该点的链进行差分,具体的,设 \(sum_u\) 表示从跟到 \(u\) 的链上信息之和。
那么就有:
\(anc=lca(u,v)\)
点权链求和:
\(sum_u+sum_v-sum_{anc}-sum_{fa_{anc}}\)
边权链求和:
将边权下放到点权:
\(sum_u+sum_v-2 \times sum_{anc}\)
考虑什么时候修改的这个点会对另外一个点造成影响。
当然是某个点位于修改这个点的字数内啦,因此可以直接给字数加即可。
这里是子树加,单点查,再差分,就可以用树状数组啦。
例题:P3950 部落冲突(题解:11.24.3)
2.链修改,单点求和。
链修改是区间,考虑差分。
定义前缀和 \(sum_u\) 为 \(u\) 的子树和。
我们就可以发现可以将链修改差分掉,然后求一下子树之和就好了。
具体的:
\(anc=lca(u,v)\)
点权链修改:
\(sum_u+1,sum_v+1,sum_{anc}-1,sum_{fa_{anc}}-1\)
边权链修改:
\(sum_u+1,sum_v+1,sum_{anc}-2\)
练习:P4219 [BJOI2014] 大融合(题解:11.25.4)
涉及一个时光倒流技术,但仍然是从前往后做,采取激活边的手段,而树上差分只是一个工具而已。
总结,实际上,这只是运用思维简化我们的代码量,上述所说皆可以用树链剖分代替,只不过多一个 \(log\) 罢了。
3.另类树上差分
基于只需统计子树内信息到跟的关系,而不需要子树合并,可以用树上差分代替启发式合并。
基本思想是利用了进入子树前记录信息和出子树后记录信息,将二者做差来获得差分结果,类似于有依赖背包问题。
模型:对于每个点 \(i\),统计字数内和 \(i\) 的距离是 \(d_i\) 的点的个数。
做法:维护全局桶 \(cnt[i]\),统计绝对深度为 \(i\) 的点的个数。
进入子树前,令 \(In=cnt[dep[u]+d[i]]\);
遍历完子树后,更新全局桶:\(cnt[dep[u]]+1\);
完成更新后,令 \(Out=cnt[dep[u]+d[i]]\),二者的差即为答案。
练习:P1600 [NOIP2016 提高组] 天天爱跑步
二、二维数点模型
配合dfs序的限制,以及另一层限制,我们很容易就会发现题意抽象后是一个二维数点模型。
二维数点可以用主席树,不再赘述。
例题:P3899 [湖南集训] 更为厉害(题解:11.25.2
一般这种限制复杂的题目,我们都考虑列出来限制,在形式化条件上考虑。
三、树链剖分:
这是树上统计的一类大杀器,可以在 \(O(n \times log^2n)\)
的复杂度内:
支持:
链修改,链查询。
子树修改,子树查询。
具体操作是:
dfs1
统计子树大小
记录节点深度
标记重儿子
void dfs1(int u,int p,int d)
{
dep[u]=d,fa[u]=p,sz[u]=1;
for(auto v:e[u])
{
if(v==p) continue;
dfs1(v,u,d+1);
sz[u]+=sz[v];
if(sz[v]>sz[son[u]]) son[u]=v;
}
}
dfs2:
优先遍历重儿子获得dfs序
获得每个点所在重链的顶点。
void dfs2(int u,int t)
{
dfn[u]=++ts,top[u]=t;
if(!son[u]) return;
dfs2(son[u],t);
for(auto v:e[u])
{
if(v==fa[u]||v==son[u]) continue;
dfs2(v,v);
}
}
由此可以证明:一条树上的链做多被拆分为 \(logn\) 段重链。
于是我们就有了链修改和查询的方式:左右爬山法,跳top
void modify_path(int u,int v)
{
while(top[u]!=top[v])
{
if(dep[top[u]] modify(1,dfn[top[u]],dfn[u],1); u=fa[top[u]]; } if(dep[u] modify(1,dfn[v],dfn[u],1); } 查询也是这样,不再重复。 至于子树,直接dfs序就可以了。 如果需要卡常,可以对每条重链维护一颗线段树,这样修改的 \(\log n\) 会变成 \(\log L\),他想用二叉树卡你就没办法喽 下面我们来看几道例题: 例1:数剖换根:P3979 遥远的国度 树剖换根和树形dp换根不是一回事。 我们很难改变树的形态,考虑在新根的位置对统计答案的影响。 对于链来说:没有任何影响 对于子树来说,分三种情况: 1.查询的点位于新根的子树中 2.查询的点是新根的父亲 3.查询的点与新根不在一颗子树(lca是原根) 对于 \(1,3\) 种情况,画图会发现没有任何影响。 对于 \(2\) 种情况,画图会发现,该点的子树变成了:整棵树-新根方向的子树 由于子树的dfs序连续,因此我们可以将这一段扣掉。 但是难点就在于找到新根方向的子树。 新根方向的儿子有两种情况:是重儿子/不是重儿子 不是重儿子,说明某个重链的顶点一定是当前询问点 是重儿子,说明他就是 \(son_u\) 上代码: int find(int u,int v){ while(top[u]!=top[v]) { if(fa[top[u]]==v) return top[u]; u=fa[top[u]]; } return son[v]; } 例题2:[LNOI2014] LCA 可谓树链剖分历史上划时代意义的一道题。 节点编号是没有任何规律的。 遇到这种还要求l-r之和的可减性信息,还是考虑前缀转化。 现在变成了一个前缀节点和一个节点的lca深度之和。 考虑lca深度的本质是两条链的交。 我们现在已经确定了一条链,因此只需要暴力标记剩余的链,直接查当前链就行了。 练习:P5305 [GXOI/GZOI2019] 旧词 这道题让我们进一步探究到了这种做法的本质。 神秘科技:毛毛虫剖分 四、树上启发式合并(dsu on tree) 这其实是一个基于暴力的做法。 当我们希望统计一个子树信息的时候,可以考虑dsu on tree 每次统计,就暴力将儿子都加上,然后回溯的时候清空,复杂度 \(O(n \times n)\)。 这里面其实是有重复的,我们搜索儿子后,可以选择最多一个留下,因为他的信息也是我的信息。 感性上来讲,一定选 \(size\) 最大的儿子,让我们证明就是。 一个点当且仅当他不是重儿子的时候会被加一次,因此从他到根一共只会被加 \(logn\) 次,总复杂度 \(O(n \times logn)\) 先贴一个模板(子树合并版本): void dfs2(int u,int fa,int t) { for(int i=h[u];~i;i=ne[i]) { int v=e[i]; if(v==fa||v==son[u]) continue; dfs2(v,u,0);//只递归轻儿子 ans[u]=max(ans[u],ans[v]); } if(son[u]) dfs2(son[u],u,1),ans[u]=max(ans[u],ans[son[u]]); for(int i=h[u];~i;i=ne[i]) { int v=e[i]; if(v==fa||v==son[u]) continue; calc(v,u);//如果无需子树合并,这一部分可以去掉 update(v,u,1); } calc(u); update(u,1); ans[u]=max(ans[u],maxv-2*dep[u]),maxv=0; if(!t) update(u,fa,0);//若自己是轻儿子,清空 } dsu有一些关键词:动态统计,字数和并,清空要求。 例1:动态统计:CF375D Tree and Queries 有一个暴力思路是将桶的值再放到一个桶里面,然后树状数组查,这是没问题的。 但是,考虑k固定。其实我们只会改变一个点的值,这个点要么达到贡献的条件,要么还没有,这就是动态统计。 例2:子树合并:CF741D Arpa’s …… 一般发现树上路径信息就想到用点分治来维护,不过加了一个限制:子树内,还是考虑dsu吧。 其实这是两者的结合,dsu处理子树内的问题,而点分治提供子树合并的想法。 具体题解可以看11.28.1 注意,子树清空的时候万不可memeset,否则直接退化回 \(O(n^2)\) 了。 需要再跑一遍dfs清空 五、树上分治 这也是对大暴力的一种优化,不过相对启发式合并,可以处理的信息更多,但同时维护起来也更加复杂。 树上分治有两类,点分治与点分树 第一类一般是单次全局询问,没有修改。 第二类一般是多次中心询问,有可能有修改。 某些问题对树的形态没有要求,只是询问路径和连通块,那就可以往树上分治上想。 树上分治将问题拆成了三部分。 每次选定一个根 完全在子树内:递归到子树内处理 由子树拼合而成:分治后合并 由某棵子树和跟构成:特判即可。 为了保证复杂度,我们将每次的跟都选为当前树的重心 注:这里不一定是树的重心,这要保证选择的点的最大子树大小 \(\le 1/2 \times tot\) 即可。 点分治的具体流程是这样的: \(\big|\) 选定树的重心 \(\big|\) 获得本层信息 \(\big|\) 将重心删掉并递归儿子 在记录本层信息的时候,可以开两个数组 \(p[i]\) \(q[i]\),分别记录本层内部所有信息和某颗子树的信息。 如果询问多次,可以考虑先建出来点分树,再操作(类似点分树) 以下给出模板: 建立点分树: int get_size(int u,int fa) { if(st[u]) return 0; int sz=1; for(int i=h[u];~i;i=ne[i]) { int v=e[i]; if(v==fa) continue; sz+=get_size(v,u); } return sz; } int get_wc(int u,int fa,int tot,int &wc) { if(st[u]) return 0; int sum=1,ms=0; for(int i=h[u];~i;i=ne[i]) { int v=e[i]; if(v==fa) continue; int t=get_wc(v,u,tot,wc); sum+=t; ms=max(ms,t); } ms=max(ms,tot-sum); if(ms<=tot/2) wc=u; return sum; } void build(int u,int fa) { get_wc(u,-1,get_size(u,-1),u); if(st[u]) return; st[u]=true; if(!fa) root=u; else son[fa].push_back(u); for(int i=h[u];~i;i=ne[i]) build(e[i],u); } 统计答案: void calc(int u) { st[u]=true; int pl=0; for(int i=h[u];~i;i=ne[i])//原树节点!!! { int v=e[i],ql=0; get_dist(v,u,w[i],ql);//统计信息 for(int j=0;j { p[pl++]=q[j]; //特判到根+子树合并 } for(int j=0;j } for(int i=0;i for(auto v:son[u]) calc(v);//递归儿子 } 例题:具体题解见这里 例1:树上路径问题(P3806 【模板】点分治 1) 很容易的,我们只需要开一个桶记录有没有就行了。 多次测试因此先建出来点分树效果更佳。 例1.练习1:P2634 [国家集训队] 聪聪可可 概率转求方案数,将距离改为mod意义下的即可。 例1.练习2:P4149 [IOI2011] Race 赋予桶更丰富的内涵。 例1.练习3:P4178 Tree 沿用之前的思路,需要将桶换成动态开点线段树,略显复杂。 为什么分开处理必须用线段树是因为我们没法动态获得有序数组的前缀和。 为什么整体处理是因为子树内部可能有不合法贡献。 考虑整体处理,将子树内部分不合法的容斥掉即可。这也是为什么动态点分治要存一个给父亲的贡献的原因。 例2:连通性问题(P6326 Shopping) 看到连通性问题,就可以考虑点分治,为什么呢。 考虑得出重心后,能不能分治处理即可。 如果连通块不包含当前根,递归到子树处理。 如果连通块包含当前跟,那么就是有根问题,相比无根问题更好解决。 甚至不需要合并 此题难点在于如何再 \(O(n \times m)\) 的复杂度内处理树上有依赖背包问题。 对于平常的书上背包问题,都是转化为分组背包。 而这个题却不能。 具体可以参考树形背包优化 例3:动态点分治:(P6329 【模板】点分树 | 震波) 动态点分治需要我们快速获得询问中心的在分治树上的所有所属重心,且是该中心第几个儿子,以及到该重心的信息。 可以用三元组表示: {u,k,dist}分别表示重心节点编号,是该重心的第几个儿子,以及到重心的距离。 由于对于一个点,在分治树上的父亲不会超过 \(log\) 个,因此可以直接用vector存下 容易想到对每个点建立一个树状数组,以dist为下标的前缀val 但是当前儿子会有贡献,且dist是到父亲的dist而不是到自己重心的dist,因此需要在存一个树状数组存给父亲的贡献。 树状数组一定要注意不能越界。 例3.练习1 P3241 [HNOI2015] 开店 边分治 三度化: void build(int u,int p) { int now=u; for(auto [v,w]:E[u]) { if(v==p) continue; ++tot; add(now,tot,0),now=tot; add(now,v,w); build(v,u); } } 找重心: int get_sz(int u,int fa) { int sz=1; for(int i=h[u];~i;i=ne[i]) { int v=e[i]; if(st[i]) continue; if(v==fa) continue; sz+=get_sz(v,u); } return sz; } int get_wc(int u,int fr,int tot,int &wc) { int sz=1; for(int i=h[u];~i;i=ne[i]) { int v=e[i]; if(st[i]) continue; if(i==(fr^1)) continue; sz+=get_wc(v,i,tot,wc); } int mx=max(sz,tot-sz); if(mx return sz; } 优点是每次只用合并两颗子树,比较方便。 缺点是常数比较大,因为点数会翻倍。 适合处理多树问题。 具体的思路是:在第一棵树上边分治,然后建第二颗树的虚树,统计两种颜色之间的信息。这里的颜色指的是边分治中属于哪颗子树。 #2870. 最长道路tree [WC2018] 通道 六、虚树 考察虚树的题目其实可以很容易地看出需要建立虚树,而虚树的难点也在于建立完虚树后进一步的动态规划和树上统计。 其思想和连通分量类似,将复杂模型简单化。 通常询问形如: 给定一个点集,大小为 \(k\),询问这个点集的一些信息。 有 \(\sum k \le n\) 我们的目标是将每次询问在 \(O(k)\) 的复杂度处理完,因此需要将这 \(k\) 个点抽出来。 虚树建立过程: 对于一个关键点集 \(S\),其虚树点集 \(S'\) 满足 \(\forall u ,v\in S',lca(u,v) \in S'\) \(S \subseteq S'\) 我们的目标就是使 \(\lvert S' \rvert\) 和 \(k\) 为一个数量级。 做法:将关键点集按照原树种dfn排序,加入相邻两项的 \(lca\)。 证明:不会,咕咕咕。 搞完点集后我们仍然需要建立出树结构 做法:将点集 \(S'\) 排序,去重,相邻两项的 \(lca\) 一定是后一项在虚树上的父亲。 证明:不会,咕咕咕。 上代码: int build_VT(vector { idx=0; vector sort(s.begin(),s.end(),cmp); for(int i=1;i { int x=s[i-1],y=s[i]; st[x]=true; ver.push_back(x),ver.push_back(lca(x,y)); } int x=s[s.size()-1]; st[x]=true; ver.push_back(x); sort(ver.begin(),ver.end(),cmp); ver.erase(unique(ver.begin(),ver.end()),ver.end()); for(int i=1;i<(int)ver.size();i++) { int x=ver[i-1],y=ver[i]; int anc=lca(x,y); add(anc,y); } return ver[0]; } 需要特别注意: 辅助数组 \(a\) 需要开2倍空间 根节点就是 \(a[1]\) 至于 \(O(1)\) 求lca?,其实没必要啦,不过dfn序求也是可以的,具体可见12.2.1 其余题解来自:这里 例1:无需边权信息(CF613D Kingdom and its Cities) 需要注意不是所有点都是关键点(对菜鸟自己说的) 例2:需要边权信息(P2495 [SDOI2011] 消耗战) 例1.练习1:P4103 [HEOI2014] 大工程 例3:颜色虚树/虚树优化维度([ABC340G] Leaf Color) 题解 例4:点度虚树: 依次对点度 \( 每个点的贡献是 \(deg_u\),所有点的贡献和为 \(\sum deg_u = n-1\) 因此总复杂度线性。 P11038 【MX-X3-T5】