【无标题】

树链剖分的思想及能解决的问题

树链剖分用于将树分割成若干条链的形式,以维护树上路径的信息。

具体来说,将整棵树剖分为若干条链,使它组合成线性结构,然后用其他的数据结构维护信息。

树链剖分 (树剖/链剖)有多种形式,如 重链剖分长链剖分 和用于 Link/cut Tree 的剖分(有时被称作「实链剖分」),大多数情况下(没有特别说明时),「树链剖分」都指「重链剖分」。

重链剖分可以将树上的任意一条路径划分成不超过 O ( log ⁡ n ) O(\log n) O(logn) 条连续的链,每条链上的点深度互不相同(即是自底向上的一条链,链上所有点的 LCA 为链的一个端点)。

重链剖分还能保证划分出的每条链上的节点 DFS 序连续,因此可以方便地用一些维护序列的数据结构(如线段树)来维护树上路径的信息。

如:

  1. 修改 树上两点之间的路径上 所有点的值。
  2. 查询 树上两点之间的路径上 节点权值的 和/极值/其它(在序列上可以用数据结构维护,便于合并的信息)

除了配合数据结构来维护树上路径信息,树剖还可以用来 O ( log ⁡ n ) O(\log n) O(logn)(且常数较小)地求 LCA。在某些题目中,还可以利用其性质来灵活地运用树剖。

重链剖分

我们给出一些定义:

定义 重子节点 表示其子节点中子树最大的子结点。如果有多个子树最大的子结点,取其一。如果没有子节点,就无重子节点。

定义 轻子节点 表示剩余的所有子结点。

从这个结点到重子节点的边为 重边

到其他轻子节点的边为 轻边

若干条首尾衔接的重边构成 重链

把落单的结点也当作重链,那么整棵树就被剖分成若干条重链。

如图:

实现

树剖的实现分两个 DFS 的过程。代码如下:

第一个 DFS 记录每个结点的父节点(fa)、深度(dep)、子树大小(sz)、重子节点(hs)。

cpp 复制代码
// 第一遍DFS,子树大小,重儿子,父亲,深度
void dfs1(int u,int f)
{
	sz[u]=1;
	hs[u]=-1;
	fa[u]=f;
	dep[u]=dep[f]+1;
	for(auto v : e[u])
	{
		if(v==f) continue;
		dfs1(v,u);
		sz[u]+=sz[v];
		if(hs[u] == -1 || sz[hs[u]] < sz[v]) hs[u]=v;
	}
}

第二个 DFS 记录所在链的链顶(top,应初始化为结点本身)、重边优先遍历时的 DFS 序(id)、DFS 序对应的节点编号()。

cpp 复制代码
// 第二遍DFS,每一个点DFS序,重链上的链头的元素
void dfs2(int u,int t)
{
	l[u]=++tot;
	top[u]=t;
	id[tot]=u;
	if(hs[u]!=-1)
	{
		dfs2(hs[u],t);
	}
	for(auto v : e[u])
	{
		if(v!=hs[u]&&v!=fa[u])
		{
			dfs2(v,v);
		}
	}
	r[u]=tot;
}

以下为代码实现。

我们先给出一些定义:

  • f a ( x ) fa(x) fa(x) 表示节点 x x x 在树上的父亲。
  • d e p ( x ) dep(x) dep(x) 表示节点 x x x 在树上的深度。
  • s i z ( x ) siz(x) siz(x) 表示节点 x x x 的子树的节点个数。
  • h s ( x ) hs(x) hs(x) 表示节点 x x x 的 重儿子
  • t o p ( x ) top(x) top(x) 表示节点 x x x 所在 重链 的顶部节点(深度最小)。
  • d f n ( x ) dfn(x) dfn(x) 表示节点 x x x 的 DFS 序,也是其在树中的编号。

我们进行两遍 DFS 预处理出这些值,其中第一次 DFS 求出 f a ( x ) fa(x) fa(x), d e p ( x ) dep(x) dep(x), s i z ( x ) siz(x) siz(x), s o n ( x ) son(x) son(x),第二次 DFS 求出 t o p ( x ) top(x) top(x), d f n ( x ) dfn(x) dfn(x), r n k ( x ) rnk(x) rnk(x)。

重链剖分的性质

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

重链开头的结点不一定是重子节点(因为重边是对于每一个结点都有定义的)。

所有的重链将整棵树 完全剖分

在剖分时 重边优先遍历,最后树的 DFS 序上,重链内的 DFS 序是连续的。按 DFN 排序后的序列即为剖分后的链。

一颗子树内的 DFS 序是连续的。

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

因此,对于树上的任意一条路径,把它拆分成从 LCA 分别向两边往下走,分别最多走 O ( log ⁡ n ) O(\log n) O(logn) 次,因此,树上的每条路径都可以被拆分成不超过 O ( log ⁡ n ) O(\log n) O(logn) 条重链。

常见应用

路径上维护

用树链剖分求树上两点路径权值和,伪代码如下:

TREE-PATH-SUM ( u , v ) 1 t o t ← 0 2 while u . t o p is not v . t o p 3 if u . t o p . d e e p < v . t o p . d e e p 4 SWAP ( u , v ) 5 t o t ← t o t + sum of values between u and u . t o p 6 u ← u . t o p . f a t h e r 7 t o t ← t o t + sum of values between u and v 8 return t o t \begin{array}{l} \text{TREE-PATH-SUM }(u,v) \\ \begin{array}{ll} 1 & tot\gets 0 \\ 2 & \textbf{while }u.top\text{ is not }v.top \\ 3 & \qquad \textbf{if }u.top.deep< v.top.deep \\ 4 & \qquad \qquad \text{SWAP}(u, v) \\ 5 & \qquad tot\gets tot + \text{sum of values between }u\text{ and }u.top \\ 6 & \qquad u\gets u.top.father \\ 7 & tot\gets tot + \text{sum of values between }u\text{ and }v \\ 8 & \textbf{return } tot \end{array} \end{array} TREE-PATH-SUM (u,v)12345678tot←0while u.top is not v.topif u.top.deep<v.top.deepSWAP(u,v)tot←tot+sum of values between u and u.topu←u.top.fathertot←tot+sum of values between u and vreturn tot

链上的 DFS 序是连续的,可以使用线段树、树状数组维护。

每次选择深度较大的链往上跳,直到两点在同一条链上。

同样的跳链结构适用于维护、统计路径上的其他信息。

子树维护

有时会要求,维护子树上的信息,譬如将以 x x x 为根的子树的所有结点的权值增加 v v v。

在 DFS 搜索的时候,子树中的结点的 DFS 序是连续的。

每一个结点记录 bottom 表示所在子树连续区间末端的结点。

这样就把子树信息转化为连续的一段区间信息。

求最近公共祖先

P3379 【模板】最近公共祖先(LCA)

不断向上跳重链,当跳到同一条重链上时,深度较小的结点即为 LCA。

向上跳重链时需要先跳所在重链顶端深度较大的那个。

cpp 复制代码
int LCA(int u,int v)
{
	while(top[u]!=top[v])
	{
		if(dep[top[u]] < dep[top[v]]) v=fa[top[v]];
		else u=fa[top[u]];
	}
	if(dep[u]<dep[v]) return u;
	else return v;
}

参考代码:

cpp 复制代码
#include <bits/stdc++.h>
#define endl "\n"
#define int long long
using namespace std;

const int N = 5e5 + 3;

using i64 = long long;
int fa[N],hs[N],sz[N],id[N];
int top[N],dep[N],tot,l[N],r[N];
int n,m,s;
vector<int> e[N];
// 第一遍DFS,子树大小,重儿子,父亲,深度
void dfs1(int u,int f)
{
	sz[u]=1;
	hs[u]=-1;
	fa[u]=f;
	dep[u]=dep[f]+1;
	for(auto v : e[u])
	{
		if(v==f) continue;
		dfs1(v,u);
		sz[u]+=sz[v];
		if(hs[u] == -1 || sz[hs[u]] < sz[v]) hs[u]=v;
	}
}
// 第二遍DFS,每一个点DFS序,重链上的链头的元素
void dfs2(int u,int t)
{
	l[u]=++tot;
	top[u]=t;
	id[tot]=u;
	if(hs[u]!=-1)
	{
		dfs2(hs[u],t);
	}
	for(auto v : e[u])
	{
		if(v!=hs[u]&&v!=fa[u])
		{
			dfs2(v,v);
		}
	}
	r[u]=tot;
}
int LCA(int u,int v)
{
	while(top[u]!=top[v])
	{
		if(dep[top[u]] < dep[top[v]]) v=fa[top[v]];
		else u=fa[top[u]];
	}
	if(dep[u]<dep[v]) return u;
	else return v;
}
signed main()
{
	ios::sync_with_stdio(0),cin.tie(0),cout.tie(0);
	cin>>n>>m>>s;
	for(int i=1;i<n;i++)
	{
		int u,v;
		cin>>u>>v;
		e[u].push_back(v);
		e[v].push_back(u);
	}
	dfs1(s,-1);
	dfs2(s,s);
	while(m--)
	{
		int a,b;
		cin>>a>>b;
		cout<<LCA(a,b)<<endl;
	}
}

例题

「ZJOI2008」树的统计

题目大意

对一棵有 n n n 个节点,节点带权值的静态树,进行三种操作共 q q q 次:

  1. 修改单个节点的权值;
  2. 查询 u u u 到 v v v 的路径上的最大权值;
  3. 查询 u u u 到 v v v 的路径上的权值之和。

保证 1 ≤ n ≤ 30000 1\le n\le 30000 1≤n≤30000, 0 ≤ q ≤ 200000 0\le q\le 200000 0≤q≤200000。

解法

根据题面以及以上的性质,你的线段树需要维护三种操作:

  1. 单点修改;
  2. 区间查询最大值;
  3. 区间查询和。

单点修改很容易实现。

由于子树的 DFS 序连续(无论是否树剖都是如此),修改一个节点的子树只用修改这一段连续的 DFS 序区间。

问题是如何修改/查询两个节点之间的路径。

考虑我们是如何用 倍增法求解 LCA 的。首先我们 将两个节点提到同一高度,然后将两个节点一起向上跳。对于树链剖分也可以使用这样的思想。

在向上跳的过程中,如果当前节点在重链上,向上跳到重链顶端,如果当前节点不在重链上,向上跳一个节点。如此直到两节点相同。沿途更新/查询区间信息。

对于每个询问,最多经过 O ( log ⁡ n ) O(\log n) O(logn) 条重链,每条重链上线段树的复杂度为 O ( log ⁡ n ) O(\log n) O(logn),因此总时间复杂度为 O ( n log ⁡ n + q log ⁡ 2 n ) O(n\log n+q\log^2 n) O(nlogn+qlog2n)。实际上重链个数很难达到 O ( log ⁡ n ) O(\log n) O(logn)(可以用完全二叉树卡满),所以树剖在一般情况下常数较小。

给出一种代码实现:

cpp 复制代码
// st 是线段树结构体
int querymax(int x, int y) {
  int ret = -inf, fx = top[x], fy = top[y];
  while (fx != fy) {
    if (dep[fx] >= dep[fy])
      ret = max(ret, st.query1(1, 1, n, dfn[fx], dfn[x])), x = fa[fx];
    else
      ret = max(ret, st.query1(1, 1, n, dfn[fy], dfn[y])), y = fa[fy];
    fx = top[x];
    fy = top[y];
  }
  if (dfn[x] < dfn[y])
    ret = max(ret, st.query1(1, 1, n, dfn[x], dfn[y]));
  else
    ret = max(ret, st.query1(1, 1, n, dfn[y], dfn[x]));
  return ret;
}

Nauuo and Binary Tree

这是一道交互题,也是树剖的非传统应用。

题目大意

有一棵以 1 1 1 为根的二叉树,你可以询问任意两点之间的距离,求出每个点的父亲。

节点数不超过 3000 3000 3000,你最多可以进行 30000 30000 30000 次询问。

解法

首先可以通过 n − 1 n-1 n−1 次询问确定每个节点的深度。

然后考虑按深度从小到大确定每个节点的父亲,这样的话确定一个节点的父亲时其所有祖先一定都是已知的。

确定一个节点的父亲之前,先对树已知的部分进行重链剖分。

假设我们需要在子树 u u u 中找节点 k k k 所在的位置,我们可以询问 k k k 与 u u u 所在重链的尾端的距离,就可以进一步确定 k k k 的位置,具体见图:

其中红色虚线是一条重链, d d d 是询问的结果即 d i s ( k , b o t [ u ] ) dis(k, bot[u]) dis(k,bot[u]), v v v 的深度为 ( d e p [ k ] + d e p [ b o t [ u ] ] − d ) / 2 (dep[k]+dep[bot[u]]-d)/2 (dep[k]+dep[bot[u]]−d)/2。

这样的话,如果 v v v 只有一个儿子, k k k 的父亲就是 v v v,否则可以递归地在 w w w 的子树中找 k k k 的父亲。

时间复杂度 O ( n 2 ) O(n^2) O(n2),询问复杂度 O ( n log ⁡ n ) O(n\log n) O(nlogn)。

具体地,设 T ( n ) T(n) T(n) 为最坏情况下在一棵大小为 n n n 的树中找到一个新节点的位置所需的询问次数,可以得到:

T ( n ) ≤ { 0 n = 1 T ( ⌊ n − 1 2 ⌋ ) + 1 n ≥ 2 T(n)\le \begin{cases} 0&n=1\\ T\left(\left\lfloor\frac{n-1}2\right\rfloor\right)+1&n\ge2 \end{cases} T(n)≤{0T(⌊2n−1⌋)+1n=1n≥2

2999 + ∑ i = 1 2999 T ( i ) ≤ 29940 2999+\sum_{i=1}^{2999}T(i)\le 29940 2999+∑i=12999T(i)≤29940,事实上这个上界是可以通过构造数据达到的,然而只要进行一些随机扰动(如对深度进行排序时使用不稳定的排序算法),询问次数很难超过 21000 21000 21000 次。

??? note "参考代码"

```cpp

#include

#include

#include

using namespace std;

const int N = 3010;

int n, fa[N], ch[N][2], dep[N], siz[N], son[N], bot[N], id[N];

int query(int u, int v) {
  printf("? %d %d\n", u, v);
  fflush(stdout);
  int d;
  scanf("%d", &d);
  return d;
}

void setFather(int u, int v) {
  fa[v] = u;
  if (ch[u][0])
    ch[u][1] = v;
  else
    ch[u][0] = v;
}

void dfs(int u) {
  if (ch[u][0]) dfs(ch[u][0]);
  if (ch[u][1]) dfs(ch[u][1]);

  siz[u] = siz[ch[u][0]] + siz[ch[u][1]] + 1;

  if (ch[u][1])
    son[u] = int(siz[ch[u][0]] < siz[ch[u][1]]);
  else
    son[u] = 0;

  if (ch[u][son[u]])
    bot[u] = bot[ch[u][son[u]]];
  else
    bot[u] = u;
}

void solve(int u, int k) {
  if (!ch[u][0]) {
    setFather(u, k);
    return;
  }
  int d = query(k, bot[u]);
  int v = bot[u];
  while (dep[v] > (dep[k] + dep[bot[u]] - d) / 2) v = fa[v];
  int w = ch[v][son[v] ^ 1];
  if (w)
    solve(w, k);
  else
    setFather(v, k);
}

int main() {
  int i;

  scanf("%d", &n);

  for (i = 2; i <= n; ++i) {
    id[i] = i;
    dep[i] = query(1, i);
  }

  sort(id + 2, id + n + 1, [](int x, int y) { return dep[x] < dep[y]; });

  for (i = 2; i <= n; ++i) {
    dfs(1);
    solve(1, id[i]);
  }

  printf("!");
  for (i = 2; i <= n; ++i) printf(" %d", fa[i]);
  printf("\n");
  fflush(stdout);

  return 0;
}
```

长链剖分

长链剖分本质上就是另外一种链剖分方式。

定义 重子节点 表示其子节点中子树深度最大的子结点。如果有多个子树最大的子结点,取其一。如果没有子节点,就无重子节点。

定义 轻子节点 表示剩余的子结点。

从这个结点到重子节点的边为 重边

到其他轻子节点的边为 轻边

若干条首尾衔接的重边构成 重链

把落单的结点也当作重链,那么整棵树就被剖分成若干条重链。

如图(这种剖分方式既可以看成重链剖分也可以看成长链剖分):

长链剖分实现方式和重链剖分类似,这里就不再展开。

常见应用

首先,我们发现长链剖分从一个节点到根的路径的轻边切换条数是 n \sqrt{n} n 级别的。

??? note "如何构造数据将轻重边切换次数卡满"

我们可以构造这么一颗二叉树 T:

假设构造的二叉树参数为 $D$。

若 $D \neq 0$, 则在左儿子构造一颗参数为 $D-1$ 的二叉树,在右儿子构造一个长度为 $2D-1$ 的链。

若 $D = 0$, 则我们可以直接构造一个单独叶节点,并且结束调用。

这样子构造一定可以将单独叶节点到根的路径全部为轻边且需要 $D^2$ 级别的节点数。

取 $D=\sqrt{n}$ 即可。
长链剖分优化 DP

一般情况下可以使用长链剖分来优化的 DP 会有一维状态为深度维。

我们可以考虑使用长链剖分优化树上 DP。

具体的,我们每个节点的状态直接继承其重儿子的节点状态,同时将轻儿子的 DP 状态暴力合并。

??? note "CF 1009F"

我们设 f i , j f_{i,j} fi,j 表示在子树 i 内,和 i 距离为 j 的点数。

直接暴力转移时间复杂度为 $O(n^2)$

我们考虑每次转移我们直接继承重儿子的 DP 数组和答案,并且考虑在此基础上进行更新。

首先我们需要将重儿子的 DP 数组前面插入一个元素 1, 这代表着当前节点。

然后我们将所有轻儿子的 DP 数组暴力和当前节点的 DP 数组合并。

注意到因为轻儿子的 DP 数组长度为轻儿子所在重链长度,而所有重链长度和为 $n$。

也就是说,我们直接暴力合并轻儿子的总时间复杂度为 $O(n)$。

注意,一般情况下 DP 数组的内存分配为一条重链整体分配内存,链上不同的节点有不同的首位置指针。

DP 数组的长度我们可以根据子树最深节点算出。

例题参考代码:

cpp 复制代码
--8<-- "docs/graph/code/hld/hld_3.cpp"

当然长链剖分优化 DP 技巧非常多,包括但是不仅限于打标记等等。这里不再展开。

参考 租酥雨的博客

长链剖分求 k 级祖先

即询问一个点向父亲跳 k k k 次跳到的节点。

首先我们假设我们已经预处理了每一个节点的 2 i 2^i 2i 级祖先。

现在我们假设我们找到了询问节点的 2 i 2^i 2i 级祖先满足 2 i ≤ k < 2 i + 1 2^i \le k < 2^{i+1} 2i≤k<2i+1。

我们考虑求出其所在重链的节点并且按照深度列入表格。假设重链长度为 d d d。

同时我们在预处理的时候找到每条重链的根节点的 1 1 1 到 d d d 级祖先,同样放入表格。

根据长链剖分的性质, k − 2 i ≤ 2 i ≤ d k-2^i \le 2^i \leq d k−2i≤2i≤d, 也就是说,我们可以 O ( 1 ) O(1) O(1) 在这条重链的表格上求出的这个节点的 k k k 级祖先。

预处理需要倍增出 2 i 2^i 2i 次级祖先,同时需要预处理每条重链对应的表格。

预处理复杂度 O ( n log ⁡ n ) O(n\log n) O(nlogn), 询问复杂度 O ( 1 ) O(1) O(1)。

练习

「洛谷 P3379」【模板】最近公共祖先(LCA)(树剖求 LCA 无需数据结构,可以用作练习)

「JLOI2014」松鼠的新家(当然也可以用树上差分)

「HAOI2015」树上操作

「洛谷 P3384」【模板】重链剖分/树链剖分

「NOI2015」软件包管理器

「SDOI2011」染色

「SDOI2014」旅行

「POI2014」Hotel 加强版(长链剖分优化 DP)

攻略(长链剖分优化贪心)

相关推荐
Youkiup1 小时前
【重构数组,形成排列的最大长度】
算法
星夜孤帆1 小时前
LeetCode之图的广度优先搜索
算法·宽度优先
忍界英雄1 小时前
LeetCode: 673.最长子序列的数量 动态规划 时间复杂度O(n*n)
算法·leetcode·动态规划
Renascence.4091 小时前
力扣--649.Dota2参议院
java·数据结构·算法·leetcode
山脚ice1 小时前
【Hot100】LeetCode—62. 不同路径
算法·leetcode·职场和发展
小小工匠1 小时前
加密与安全_ sm-crypto 国密算法sm2、sm3和sm4的Java库
java·算法·安全·sm2·sm3·sm4
陈小唬2 小时前
树形结构构建的两种方式
java·数据库·算法
7yewh3 小时前
STM32常用数据采集滤波算法
stm32·嵌入式硬件·算法
不会编程的小江江3 小时前
【动态规划】(一)动态规划理论及基础题目
c++·算法·动态规划
qq_535246143 小时前
代码随想录 第九章 动态规划part03 01背包问题 一维 416. 分割等和子集
算法·动态规划·代理模式