一. 树链剖分原理和实现
1.树链剖分的概念
用途:在一棵树上进行路径的修改、求极值、求和。
- 树链,就是树上的路径。
- 剖分,就是把路径分类为 重链 和 轻链 。
- 树链剖分就是把一些点合成一条路径,使其在线段树中的下标有序,用线段树来维护。
- 这样就可以使得查询、修改的效率大大提高。
- 假设我们把路径分好链了,每次询问两个点对(x,y)时,
- 若 x 和 y 在同一链中,直接询问线段树中的 u 和 v(因为同一条链中下标是连续的)
- 其中 u, v 是 x , y 对应的、线段树中的点。
- 若不在同一链中,我们从深度大的点上一点一点向上爬(找fa),
- 每次记录该点所在的链上的情况,直到 x, y在同一条链上即可。
- 注意:树链剖分中的线段树中每个点代表的意义可以是原图的 边 或 点 。
即:将树分割成多条链,然后利用数据结构(线段树、树状数组等)来维护链。
首先就是一些必须知道的概念:
- 重结点:子树结点数目最多的结点;
- 轻结点:父亲节点中除了重结点以外的结点;
- 重边:父亲结点和重结点连成的边;
- 轻边:父亲结点和轻结点连成的边;
- 重链:由多条重边连接而成的路径;
- 轻链:由多条轻边连接而成的路径;
比如上面这幅图中,用黑线连接的结点都是重结点,其余均是轻结点,
2-11就是重链,2-5就是轻链,用红点标记的就是该结点所在重链的起点,
(5,8,10结点单独成链),也就是下文提到的top结点。
算法中定义了以下的数组用来存储上边提到的概念:
名称 | 解释 |
---|---|
siz[u] | 保存以u为根的子树节点个数 |
top[u] | 保存当前节点所在链的顶端节点 |
son[u] | 保存重儿子 |
dep[u] | 保存结点u的深度值 |
faz[u] | 保存结点u的父亲节点 |
tid[u] | 保存树中每个节点剖分以后的新编号(DFS的执行顺序) |
seg[u] ( 或rnk[u] ) | 保存当前节点在树中的位置(下标) |
除此之外,还包括两种性质:
- 如果(u, v)是一条轻边,那么size(v) < size(u)/2;
- 从根结点到任意结点的路、所经过的轻重链的个数,必定都小于O(logn);
首先定义以下数组:
const int MAXN = (100000 << 2) + 10;
int siz[MAXN];//number of son
int top[MAXN];//top of the heavy link
int son[MAXN];//heavy son of the node
int dep[MAXN];//depth of the node
int faz[MAXN];//father of the node
int tid[MAXN];//ID -> DFSID
int rnk[MAXN];//DFSID -> ID
变量声明:
const int maxn=1e5+10;
struct edge{
int next,to;
}e[2*maxn];
struct Node{
int sum,lazy,l,r,ls,rs;
}node[2*maxn];
int rt,n,m,r,a[maxn],cnt;
int head[maxn],f[maxn],d[maxn],size[maxn];
int son[maxn],rk[maxn],top[maxn],id[maxn];
算法大致需要进行两次的DFS,第一次DFS可以得到当前节点的父亲结点(faz数组)、
当前结点的深度值(dep数组)、子结点数量(size数组)、重结点(son数组)
2.树链剖分的实现
1,对于一个点,首先求出它所在子树大小,找到它的重儿子(处理出size,son数组),
解释: 比如说点1,它有三个儿子2,3,4。2所在子树的大小是5,3所在子树的大小是2,
4所在子树的大小是6,那么1的重儿子是4。
ps: 如果一个点的多个儿子所在子树大小相等且最大,就随便选一个做重儿子。
注意,叶节点没有重儿子,非叶节点有且只有一个重儿子。
2,在dfs过程中顺便记录其父亲及深度(处理出f,d数组),操作1,2可以通过一遍dfs完成。
void dfs1(int u,int fa,int depth){ //当前节点、父节点、层次深度
f[u]=fa; d[u]=depth;
size[u]=1; //这个点本身size=1
for(int i=head[u];i;i=e[i].next){
int v=e[i].to;
if(v==fa) continue;
dfs1(v,u,depth+1); //层次深度+1
size[u]+=size[v]; //子节点的size已被处理,用它来更新父节点的size
if(size[v]>size[son[u]]) son[u]=v; //选取size最大的作为重儿子
}
}
dfs1(root,0,1);
3,第二遍dfs,然后连接重链,同时标记每一个节点的dfs序,
并且为了用数据结构来维护重链,我们在dfs时保证一条重链上各个节点dfs序连续(即处理出数组top,id,rk)
void dfs2(int u,int t){ //当前节点、重链顶端
top[u]=t; id[u]=++cnt; //标记dfs序
rk[cnt]=u; //序号cnt对应节点u
if(!son[u]) return;
dfs2(son[u],t);
/* 我们选择优先进入重儿子来保证一条重链上各个节点dfs序连续,
一个点和它的重儿子处于同一条重链,
所以重儿子所在重链的顶端还是t。 */
for(int i=head[u];i;i=e[i].next){
int v=e[i].to;
if(v!=son[u]&&v!=f[u])
dfs2(v,v); //一个点位于轻链底端,那么它的top必然是它本身
}
}
4,两遍dfs就是树链剖分的主要处理,通过dfs我们已经保证一条重链上各个节点dfs序连续,
那么可以想到,我们可以通过数据结构(以线段树为例)来维护一条重链的信息。
回顾上文的那个题目,修改和查询操作原理是类似的,以查询操作为例,其实就是个LCA,
不过这里使用了top来进行加速,因为top可以直接跳转到该重链的起始结点,轻链没有起始结点之说,
他们的top就是自己。需要注意的是,每次循环只能跳一次,并且让结点深的那个来跳到top的位置,
避免两个一起跳从而插肩而过。
int sum(int x,int y){
int ans=0,fx=top[x],fy=top[y];
while(fx!=fy){ //两点不在同一条重链
if(d[fx]>=d[fy]){
ans+=query(id[fx],id[x],rt); //线段树区间求和,处理这条重链的贡献
x=f[fx],fx=top[x]; //将x设置成原链头的父亲结点,走轻边,继续循环
}
else{
ans+=query(id[fy],id[y],rt);
y=f[fy],fy=top[y];
}
}
//循环结束,两点位于同一重链上,但两点不一定为同一点,所以我们还要统计这两点之间的贡献
if(id[x]<=id[y]) ans+=query(id[x],id[y],rt);
else ans+=query(id[y],id[x],rt);
return ans;
}
5,树链剖分的时间复杂度
树链剖分的两个性质:
1,如果(u, v)是一条轻边,那么size(v) < size(u)/2;
2,从根结点到任意结点的路所经过的轻重链的个数必定都小于logn;
可以证明,树链剖分的时间复杂度为O(nlog^2n)
二. 例题练习