树链剖分

问题引入

有时候我们会不可避免地遇上一些复杂的树上问题:

  1. 修改 树上两点之间的路径上 所有点的值。
  2. 查询 树上两点之间的路径上 节点权值的 和/极值/其它

这些问题暴力解决都具有一定难度,但是如果能够有一种方式将树上问题转化为序列上的问题,那么就能够运用相关数据结构在 \(\mathcal{O}(\log n)\) 的时间内处理。

树链剖分便提供了如此方案,能够将整棵树剖分为若干条链,使它组合成线性结构,然后用其他的数据结构维护信息。下面我们主要讨论四种问题的解决(\(\text{Luogu P3384}\)):

  1. 将树从 \(u\)\(v\) 结点最短路径上所有节点的值都加上 \(val\)
  2. 求树从 \(u\)\(v\) 结点最短路径上所有节点的值之和。
  3. 将以 \(u\) 为根节点的子树内所有节点值都加上 \(val\)
  4. 求以 \(u\) 为根节点的子树内所有节点值之和。

需要注意的是,树链剖分本身不能维护任何信息。因此,学习树链剖分前还需熟练使用诸如线段树、树状数组等等常见数据结构以及差分等序列操作思想。

基本概念

树链剖分(树剖 / 链剖)有多种形式,如 重链剖分长链剖分 和用于 Link/cut Tree 的剖分(有时被称作「实链剖分」),在此,我们讨论的是「重链剖分」。

重儿子 对于每一个非叶子节点,它的儿子中子树规模最大的一个为重儿子。

轻儿子 对于每一个非叶子节点,它的儿子中除了重儿子以外的所有儿子即为轻儿子。

重边 一个父亲连接他的重儿子的边称为重边。

轻边 除了重边以外的所有边即为轻边。

重链 相邻重边连起来组成的一条链叫重链,每一条重链一定以根或轻儿子为起点。

image

剖分实现

通常使用 2 次 DFS 实现重链剖分。

注意以下变量及其意义:

  • \(\texttt{dep}\):节点深度
  • \(\texttt{siz}\):子树大小
  • \(\texttt{son}\):重儿子编号
  • \(\texttt{fa}\):父节点编号
  • \(\texttt{top}\):该节点所在重链的顶点
  • \(\texttt{id}\):DFS 序(dfn),也是该结点剖分后对应的序列上的序号
  • \(\texttt{w1[i]}\):剖分前 \(i\) 号节点对应的点权
  • \(\texttt{w[i]}\):剖分后 \(i\) 号节点对应的点权(注意此处 \(i\) 与上一行的 \(i\) 不一定一致)

第一次 DFS

第一次 DFS 不进行剖分,主要是预处理出任意节点 \(u\)深度子树大小重儿子编号父节点编号

具体操作较简单,此处不再展开

void dfs1(int u, int f){
	dep[u] = dep[f] + 1, fa[u] = f, siz[u] = 1;
	int Max = -1;
	for(auto v : G[u]){
		if(v == f) continue;
		dfs1(v, u); siz[u] += siz[v];
		if(siz[v] > Max) son[u] = v, Max = siz[v];
	}
	return;
}

第二次 DFS

预处理出任意节点 \(u\)所在重链顶点DFS 序

注意此处为了使剖分后每条重链上的点对应的区间连续,在 DFS 过程中每次都应先从重儿子开始。

void dfs2(int u, int Top){
	id[u] = ++cnt, w[cnt] = w1[u], top[u] = Top;
	if(son[u]) dfs2(son[u], Top);
	for(auto v : G[u]){
		if(v == fa[u] || v == son[u]) continue;
		dfs2(v, v);
	}
	return;
}

以上两次 DFS 的时间复杂度都是 \(\mathcal{O}(n)\) 的。

性质初窥

经过两次的 DFS,我们得到的序列具有以下性质:

  • 从根节点出发到任意节点不会经超过 \(\boldsymbol{\mathcal{O}(\log n)}\) 条重链

    当我们向下经过一条 轻边 时,所在子树的大小至少会除以二

    所以我们至少跨过 \(\mathcal{O}(\log n)\) 条轻边,即 \(\mathcal{O}(\log n)\) 条重链

  • 树上每个节点都属于且仅属于一条重链

  • 一条重链上的点 \(\texttt{id}\) 连续。

  • 一条子树上的点 \(\texttt{id}\) 连续。

简单应用

路径维护

求两点之间的路径相当于求他们的 LCA。我们先分类讨论:

  1. \(u\)\(v\) 在同一条重链上,那么 LCA 即深度较小的点;
  2. 否则如果 \(\texttt{dep[u]}\ge\texttt{dep[v]}\),则 \(u\) 跳到重链顶端的父节点上;如果 \(\texttt{dep[v]}\ge\texttt{dep[u]}\),则 \(v\) 跳到重链顶端的父节点上;直至 \(u\)\(v\) 处于同一重链位置。

注意到以上操作和倍增求 LCA 极其相似,我们便可使用相似的代码来处理。

以下提供 路径加路径求和 的参考代码:

void modify_chain(int u, int v, int val){
	while(top[u] != top[v]){
		if(dep[top[u]] < dep[top[v]]) swap(u, v);
		modify(1, id[top[u]], id[u], val);
		u = fa[top[u]];
	}
	if(dep[u] > dep[v]) swap(u, v);
	modify(1, id[u], id[v], val);
	return;
}
int query_chain(int u, int v){
	int ans = 0;
	while(top[u] != top[v]){
		if(dep[top[u]] < dep[top[v]]) swap(u, v);
		add(ans, query(1, id[top[u]], id[u]));
		u = fa[top[u]];
	}
	if(dep[u] > dep[v]) swap(u, v);
	add(ans, query(1, id[u], id[v]));
	return ans;
}

时间复杂度取决于 modify 和 query 操作的复杂度,一般为 \(\mathcal{O}(n\log n)\),而路径操作需要 \(\mathcal{O}(\log n)\) 的复杂度,因此总复杂度为 \(\mathcal{O}(n\log^2n)\)

子树维护

子树维护较简单,注意到 DFS 序的连续性即可。

void modify_tree(int u, int val){
	modify(1, id[u], id[u] + siz[u] - 1, val);
}
int query_tree(int u){
	return query(1, id[u], id[u] + siz[u] - 1);
}

求 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;
}

例题练习

[模板] 重链剖分/树链剖分

Luogu

模板题。实现树上路径加、子树加。维护树上路径和、子树和。

[HAOI2015] 树上操作

Luogu LOJ

实现单点加、子树加。维护路径和。

code

[ZJOI2008] 树的统计

Luogu LOJ

实现树上单点修改。维护树上路径和、最大值。

code

其他题目

training list

参考资料

OI Wiki - 树链剖分

posted @ 2024-02-09 23:50  hayzxjr  阅读(41)  评论(0)    收藏  举报