算法 | 树链剖分
树链剖 (pōu) 分:树链剖分用于将树分割成若干条链的形式,使它组合成线性结构,然后就可以用其他的数据结构(例如线段树)维护信息。
树链剖分
树链剖分有很多形式,本文介绍的为其中的重 (zhòng) 链剖分,下文树链剖分均指重链剖分。
1. 剖分方式
对于一棵树的一个节点,称它的子树最大的子节点为重子节点,其他子节点为轻子节点。特别的,树根看作轻子节点。
标记好轻重子节点后,从一个节点到它的重子节点连边,称这种边为重边,与它的轻子节点连边,这种边成为轻边。
由相接的重边构成的一条链便称为一条重链,通过上面描述的方法,即可将一棵树分成互不重复的重链。
一个可视化的例子如下,右图中被绿框框住的便是分出的链:
图片来源:OI-Wiki
使用协议:CC BY-SA 4.0
2. 代码实现
要进行树链剖分,需要维护每个节点的以下信息:
- 节点深度
dep
- 父节点
fa
- 重子节点
son
- 子树大小
siz
- 所在重链的顶部节点
top
- 节点的 DFS 遍历序
dfn
- DFS 序对于的节点编号
rnk
,其为dfn
的逆:
使用两次 DFS 生成上面的信息,第一次生成前
注意:
- DFS 遍历时,重边优先遍历。
- 代码中的树存的双向边,因此需要进行父节点判重。
- 代码中的树使用链式前向星储存,下标从
开始。
void dfs1(int now)
{
son[now] = -1;
siz[now] = 1;
for (int i = h[now]; i; i = ne[i])
{
int &nxt = e[i];
if (nxt == fa[now])
continue;
fa[nxt] = now;
dep[nxt] = dep[now] + 1;
dfs1(nxt);
siz[now] += siz[nxt];
if (son[now] == -1 || siz[son[now]] < siz[nxt])
son[now] = nxt;
}
}
void dfs2(int now, int tp)
{
top[now] = tp;
dfn[now] = ++cnt;
rnk[cnt] = now;
if (son[now] == -1)
return;
dfs2(son[now], tp);
for (int i = h[now]; i; i = ne[i])
{
int &nxt = e[i];
if (nxt == son[now] || nxt == fa[now])
continue;
dfs2(nxt, nxt);
}
}
3. 重链性质
树链剖分的目的就是将树型结构转为线性结构,从而可以套用线性数据结构解决问题。那么剖分出来的链一定要有一些独特的性质,方便对它套用线性数据结构。
- 每条链包含的节点不重不漏
树上的节点属于且仅属于一条重链,同时所有的重链将整棵树完全剖分。
- 同一重链内节点的 DFS 序连续
由于重边优先遍历,所以同一重链中的节点是 DFS 序是连续的。将节点按 DFN 排序后的序列就是剖分后的链。可以看上图中的绿色序号。
- 树上每条路径都可以拆分成不超过
条重链
向下经过一条轻边时,所在子树大小至少
4. 应用方式
4.1. 求最近公共祖先
要求查询两个节点的 LCA,那么每次让深度大的链往上跳,最后深度较小的节点就是 LCA。对于链的深度,定义为这个链的顶端节点的深度。
如果要查询 DFN 序为
所在链的深度为 , 所在链的深度为 ,因此 往上跳到 . 所在链的深度为 , 所在链的深度为 ,因此 往上跳到 .- 发现
处在同一个链上(检查链顶节点相同),此时浅的便是 LCA,即 是 LCA.
int lca(int u, int v)
{
while (top[u] != top[v])
{
if (dep[top[u]] > dep[top[v]])
u = fa[top[u]];
else
v = fa[top[v]];
}
return dep[u] > dep[v] ? v : u;
}
树链剖分解决 LCA 预处理的时间复杂度为
4.2. 路径上维护(权值在点上)
根据重链性质——同一重链内节点的 DFS 序连续,因此就可以用线段树或树状数组维护连续区间的信息,从而相当于维护了树上每一条路径的信息。下面用线段树维护树上路径的权值和举例。
建树
线段树数组中
首先建树时,向长度为
int sum[MAXN], add_val[MAXN];
void build(int s, int t, int p)
{
if (s == t)
{
sum[p] = w[rnk[s]];
return;
}
int m = s + (t - s) / 2;
build(s, m, p * 2);
build(m + 1, t, p * 2 + 1);
sum[p] = sum[p * 2] + sum[p * 2 + 1];
}
路径操作
用线段树维护树链时,线段树的代码是不需要变化的,需要改变的只是调用函数的方式。如果要对树上一条个路径进行操作,需要将其拆分为一条条链,然后分别对每一条链的区间进行操作,如果该操作是查询,那最后将每一条链的答案整合得到最终答案即可。
例如要对 DFS 序为
那如何编写代码来找到要操作的链区间呢?我们的思路是每次深度大的链往上跳,向上跳的时候对刚才那条链进行操作。其实和上一节的求 LCA 一模一样,只不过现在在向上跳的同时要执行操作。
代码结构如下,对于具体的操作,下文也有对应代码:
int do_something(int a, int b)
{
while (top[a] != top[b])
{
int ta = top[a], tb = top[b];
if (dep[ta] >= dep[tb])
{
// do something in range [dfn[ta], dfn[a]]
a = fa[ta];
}
else
{
// do something in range [dfn[tb], dfn[b]]
b = fa[tb];
}
}
if (dep[a] > dep[b])
swap(a, b);
// do something in range [dfn[a], dfn[b]]
return ans;
}
路径区间查询
int query_sum(int a, int b)
{
int ans = 0;
while (top[a] != top[b])
{
int ta = top[a], tb = top[b];
if (dep[ta] >= dep[tb])
{
ans += get_sum(dfn[ta], dfn[a], 1, n, 1);
a = fa[ta];
}
else
{
ans += get_sum(dfn[tb], dfn[b], 1, n, 1);
b = fa[tb];
}
}
if (dep[a] > dep[b])
swap(a, b);
ans += get_sum(dfn[a], dfn[b], 1, n, 1);
return ans;
}
下面用一个例子说明,如果要查询 DFN 序为
所在链的深度为 , 所在链的深度为 ,因此 往上跳到 . 同时查询线段树区间 的结果加入答案。 所在链的深度为 , 所在链的深度为 ,因此 往上跳到 . 同时查询线段树区间 的结果加入答案。- 发现
处在同一个链上(检查链顶节点相同),查询线段树区间 的结果加入答案,结束算法。
路径区间修改
这里用加法举例,其他其实也是完全一样的。
void add_to(int a, int b, int c)
{
while (top[a] != top[b])
{
int ta = top[a], tb = top[b];
if (dep[ta] >= dep[tb])
{
add(dfn[ta], dfn[a], c, 1, n, 1);
a = fa[ta];
}
else
{
add(dfn[tb], dfn[b], c, 1, n, 1);
b = fa[tb];
}
}
if (dep[a] > dep[b])
swap(a, b);
add(dfn[a], dfn[b], c, 1, n, 1);
}
4.3. 路径上维护(权值在边上)
上一节的权值在每个点上,一条路径的权值和就是路径上每个点的和。但有些时候,给定的是边的权值,一条路径的权值是这条路径包含的边的权值和,此时就要做出一点调整。
首先需要调整的是第一个 DFS,它将一条边的边权赋给这条边深度较深的那个节点,将权值在边上的问题转换回了权值在点上的问题。这样就能套用原来的做法解决这个新问题了(根节点的权值为
下面代码中的第 12 行便是修改之处。
void dfs1(int now)
{
son[now] = -1;
siz[now] = 1;
for (int i = h[now]; i; i = ne[i])
{
int &nxt = e[i];
if (nxt == fa[now])
continue;
fa[nxt] = now;
dep[nxt] = dep[now] + 1;
val[nxt] = w[i]; // 将边权转化成子节点点权
dfs1(nxt);
siz[now] += siz[nxt];
if (son[now] == -1 || siz[son[now]] < siz[nxt])
son[now] = nxt;
}
}
将权值转化到点上后,后面 build
函数建树时,就用
但是,此时如果沿用之前的路径操作,会出现问题。就以上面的示意图为例,如果要求
要解决这个错误实际上也很简单,就是在加上最后一条链时,将顶端那个点排除在外,这样就不会加入错误的边了。这样,路径操作的结构变为:(区别在倒数第三行)
int do_something(int a, int b)
{
while (top[a] != top[b])
{
int ta = top[a], tb = top[b];
if (dep[ta] >= dep[tb])
{
// do something in range [dfn[ta], dfn[a]]
a = fa[ta];
}
else
{
// do something in range [dfn[tb], dfn[b]]
b = fa[tb];
}
}
if (dep[a] > dep[b])
swap(a, b);
// do something in range [dfn[a] + 1, dfn[b]]
return ans;
}
本文采用 CC BY-SA 4.0 许可,本文 Markdown 源码:Haotian-BiJi
吆西