长链剖分是一种将树剖分成若干条链的方法。
重链剖分选择子树大小最大的点作为重儿子点;而长链剖分每次挑选子树深度最大的点作为重儿子点。
算法概述
其实和重链剖分差不多,无非选择重儿子的标准变成了子树深度最大的点。
重链剖分的一些概念也可以直接搬到长链剖分上来:
- 一个点到其重儿子的边称为重边。
- 一个点到其轻儿子的边称为轻边。
- 重儿子相连形成的链称为长链。
性质
长链剖分后,一个点到根节点的路径上,最多经过 \(O(\sqrt n)\) 条轻边。
所以这个东西和重链剖分相比,有什么特别的作用呢?
一个非常经典的应用就是求树上一个点的 \(k\) 级祖先。
树上 k 级祖先
树上一个点的 \(k\) 级祖先可以采用传统的倍增方法求,时间复杂度 \(O(n \log n)-O(\log n)\)。
当然也可以直接重链剖分后,不停在重链上跳,时间复杂度 \(O(n)-O(\log n)\)。
有没有更快的方法呢?
我们对整棵树进行长链剖分,顺带完成以下预处理:
- 倍增求出每个点的 \(2^i\) 级祖先。
- 对于每条重链的链顶节点,设其所在的重链长度为 \(l\),求出这个点向上的 \(l\) 个祖先和向下的 \(l\) 个儿子。
剖分后,容易发现点 \(u\) 的 \(k\) 级祖先所在的重链长度一定不小于 \(k\)(否则重链可以直接选择经过点 \(u\),长度还变长了)。
令 \(x=\left \lfloor \log k \right \rfloor\),我们先跳 \(2^x\) 级。
跳完之后我们还需要再跳 \(k-2^x\) 级(如果是负数的话意味着我们要往下跳,否则还是往上跳)。而这部分在预处理的时候已经求出来了,因此直接利用预处理的信息就可以了。
时间复杂度 \(O(n \log n)-O(1)\)。
#include <cmath> #include <iostream> #include <vector> using namespace std; typedef unsigned ui; struct gen { ui s; void init(ui seed) { s=seed; } ui next() { ui x=s; x^=x<<13; x^=x>>17; x^=x<<5; return s=x; } }g; int fa[5000005][25],dep[5000005],maxd[5000005],son[5000005],top[5000005]; vector<int> up[5000005],down[5000005]; vector<int> e[5000005]; void dfs1(int u,int f) { dep[u]=maxd[u]=dep[f]+1; fa[u][0]=f; for(int k=1;k<=23;k++) fa[u][k]=fa[fa[u][k-1]][k-1]; for(auto v:e[u]) { dfs1(v,u); if(maxd[v]>maxd[son[u]]) son[u]=v,maxd[u]=maxd[v]; } } void dfs2(int u,int t) { top[u]=t; if(u==t) { for(int i=0,p=u;i<=maxd[u]-dep[u];i++,p=fa[p][0]) up[u].push_back(p); for(int i=0,p=u;i<=maxd[u]-dep[u];i++,p=son[p]) down[u].push_back(p); } if(son[u])dfs2(son[u],t); for(auto v:e[u]) if(v!=son[u])dfs2(v,v); } int get_kth(int x,int k) { if(!k)return x; int t=log2(k); k-=1<<t,x=fa[x][t]; k-=dep[x]-dep[top[x]],x=top[x]; if(k>=0)return up[x][k]; else return down[x][-k]; } int main() { int n,q,root; ui s; cin>>n>>q>>s; g.init(s); for(int i=1;i<=n;i++) { int f; cin>>f; if(f==0)root=i; else e[f].push_back(i); } dfs1(root,0); dfs2(root,root); long long ans=0,lastans=0; for(int i=1;i<=q;i++) { int x=(g.next()^lastans)%n+1,k=(g.next()^lastans)%dep[x]; ans^=(i*(lastans=get_kth(x,k))); } cout<<ans<<endl; return 0; }
长链剖分优化 DP
这个采用了启发式合并的思想。大概思路是对于一个点,先求出其所在重链的信息,随后将其他链的信息合并到这条重链上。
CF1009F 就是一道不错的例题。
题意是给一棵树,对于每个点 \(u\) 求出一个最小深度 \(d\),使得 \(u\) 的 \(d\) 级子节点最多。
一道非常不错的长链剖分练手题。
首先是常规的 DP:设 \(f_{i,j}\) 表示点 \(i\) 的 \(j\) 级子节点的数量。
转移方程很显然(其中 \(v\) 是 \(i\) 的子节点):
$$f_{i,j}=\sum f_{v,j-1}$$
边界是 \(f_{i,0}=1\)。
这样做的时间复杂度是 \(O(n^2)\) 的,需要优化。
这时候长链剖分就登场了(实际上是树上启发式合并的思路)。
我们对于一个节点,先遍历它的重儿子,将重儿子的结果合并(说是合并,其实直接继承了重儿子的结果,并添加了当前点的信息)到当前点上。
接下来我们遍历这个点的其他轻儿子,将这些轻儿子的结果并到当前节点上。
每条重链都只合并了一次,故时间复杂度为 \(O(n)\)。
题解区似乎没有非指针版的实现?其实用 vector 也是可以搞的。
思路仍然是用 vector 存下每个点的信息。不过有几个特殊之处:
- 按深度递增的顺序存储的话,因为合并重儿子信息时要在开头插入元素,效率低下。所以考虑按深度递减的顺序存储信息。
- 合并重儿子信息的时候,直接用 swap 交换而不是复制,在时间和空间上都更优(swap 交换 vector 的时间复杂度是 \(O(1)\) 的)。
#include <cstdio> #include <vector> using namespace std; vector<int> e[1000005],f[1000005]; int fa[1000005],len[1000005],son[1000005]; int ans[1000005]; void dfs1(int u,int f) { fa[u]=f; for(auto v:e[u]) if(v!=f) { dfs1(v,u); if(len[v]>=len[son[u]]) son[u]=v,len[u]=len[v]+1; } } void dfs2(int u) { if(son[u]) { dfs2(son[u]); swap(f[u],f[son[u]]);//直接交换,降低时间空间开销 f[u].push_back(1);//尾部插入深度为零的结果 ans[u]=ans[son[u]]; if(f[u][ans[u]]==1)ans[u]=len[u]; for(auto v:e[u]) { if(v==fa[u]||v==son[u])continue; dfs2(v); for(int i=len[v];i>=0;i--) { int tmp=i+len[u]-len[v]-1; f[u][tmp]+=f[v][i]; if(f[u][tmp]>f[u][ans[u]]||(f[u][tmp]==f[u][ans[u]]&&tmp>ans[u])) ans[u]=tmp;//这里存储的是最优解在数组对应的位置,结果需要倒过来 } } } else { f[u].push_back(1); ans[u]=0; } } int main() { int n; scanf("%d",&n); for(int i=1;i<n;i++) { int u,v; scanf("%d%d",&u,&v); e[u].push_back(v); e[v].push_back(u); } dfs1(1,0); dfs2(1); for(int i=1;i<=n;i++) printf("%d\n",len[i]-ans[i]); return 0; }