长链剖分详细讲解

文章介绍了长链剖分的概念,包括深儿子和浅儿子的定义,以及长链的性质。通过动态规划优化,可以将复杂度降低到线性。文章详细阐述了优化方法,利用树上启发式合并思想,实现O(n)的时间复杂度和空间复杂度。此外,还讨论了长链剖分在求解树上k级祖先问题中的应用,以及如何结合贪心策略解决相关问题。

摘要生成于 C知道 ,由 DeepSeek-R1 满血版支持, 前往体验 >

前言

本来想随便写一点长剖细节就算了,但是发现长剖好的讲解很少。

长链剖分

节点x有若干个儿子,经过其中一个儿子y可以走到以x为根的子树中深度最深的节点。则称y为x的深儿子,其他儿子是浅儿子。
由深儿子组成的一条链叫做长链。
如果节点x没有儿子,则它自己单独构成一条长链。

长链的性质

总长度

所有长链的总链长相加为n。

性质二

任何一个节点x的k级祖先所在的长链的链长都≥k

复杂度

从任意一个节点出发,跳长链直到根节点,最多跳根号次。
这是由于:每跳到另一个长链,新链的链长至少比旧链的链长+1,那么最坏情况下链长是1、2、3、…,这样,跳根号步,总链长就到达了n,也就必然到达了根节点。

链顶和链底

链顶没什么性质。
链底必然是叶子节点。

长链剖分优化动态规划

设f[u][x]表示节点u向下x步的节点数量。
暴力求解复杂度是O(n2),而且会爆空间。

长链剖分可以优化于深度相关的计算,我们考虑模仿DSU on Tree(树上启发式合并)的思路,O(1)继承深儿子,暴力浅儿子。
这样复杂度是O(n)的,证明一会再说吧。

先说一下实现:
设f[x]表示当前节点向下走x步的节点数量。
我们考虑直接让同一条链上的节点共用f数组,这样可以O(1)继承,具体来说:

我们开辟一片内存:

在这里插入图片描述

那么父亲的f数组在这里:

在这里插入图片描述

儿子的f数组在这里:

在这里插入图片描述

孙子的f数组在这里:

在这里插入图片描述

也就是要移动一位。
代码实现就是,我们用一个指针t[父亲]指向父亲的f[0],然后用一个指针t[儿子]指向父亲的f[1],用指针t[孙子]指向父亲的f[2]。
这里的父亲其实就是链顶。
怎么确定链顶u的位置呢,就设整棵树的内存起点是s,那么u的位置就是s[dfn[u]]所在的位置。
这样的话,每一条链上的所有点占用的空间就是本条链的链长。那么整棵树占用的空间就是所有链长的总和,也就是O(n)。这样空间复杂度就对了。
关于时间复杂度呢?
不难发现,每条链都仅被链顶的父亲暴力转移一次,单次转移复杂度是O(链长),所以总复杂度是O(n)。

上代码:

#include<iostream>
#include<vector>
#include<cstdio>
using namespace std;
int deep[1000005],son[1000005],dest[1000005];
int n;
vector<vector<int>> a;
int dfs1(int u,int fa) {
	dest[u]=deep[u]=deep[fa]+1;
	for(auto&v:a[u]) 
		if(v^fa&&dest[u]<dfs1(v,u))
			dest[u]=dest[v],son[u]=v;
	return dest[u];
}
int dfn[1000005],cnt;
int top[1000005],len[1000005]; 
int dfs2(int u,int fa) {
	if(u==son[fa]) top[u]=top[fa];
	else top[u]=u;
	dfn[u]=++cnt;
	len[u]=1;
	if(son[u]) len[u]+=dfs2(son[u],u);
	for(auto&v:a[u])
		if(fa^v&&son[u]^v)
			dfs2(v,u);
	return len[u];
}
int s[1000005],*f[1000005];
void dfs3(int u,int fa) {
	f[u]=s+dfn[u];
	for(auto&v:a[u])
		if(v^fa)
			dfs3(v,u);
}
int ans[1000005];
int dfs4(int u,int fa) {
	f[u][0]=1;
	if(son[u]) {
		dfs4(son[u],u);
		if(f[son[u]][ans[son[u]]]>f[u][ans[u]])
			ans[u]=ans[son[u]]+1;
	}
	for(auto&v:a[u])
		if(v^fa&&v^son[u]) 
			for(int i=0,k=dfs4(v,u);i<k;i++) {
				f[u][i+1]+=f[v][i];
				if(f[u][i+1]>f[u][ans[u]]||(f[u][i+1]==f[u][ans[u]]&&i+1<ans[u]))
					ans[u]=i+1;
			}
	return len[u];
}
int main(){
	scanf("%d",&n);
	for(int i=0;i<=n;i++) a.push_back({});
	for(int i=1;i<n;i++) {
		int u,v;
		scanf("%d%d",&u,&v);
		a[u].push_back(v);
		a[v].push_back(u);
	}
	dfs1(1,0);
	dfs2(1,0);
	dfs3(1,0);
	dfs4(1,0);
//	for(int i=1;i<=n;i++)
//		cout<<len[i]<<endl;
	for(int i=1;i<=n;i++)
		printf("%d\n",ans[i]);
    return 0; 
} 
  1. 这里的dest数组指的是"deepest",也即当前子树下的最深节点深度。
  2. 唯一的细节就是,在dfs4中,继承重儿子的ans时,仅有儿子的ans对应的f值大于1时才继承,这是因为题目说要求最小化答案,一开始的时候根节点答案默认为0,除非0不是一个答案,否则优先选0
  3. 虽然这个题时间限制开到4.5s,但是用cin、cout仍然会超时。

长链剖分求树上k级祖先

长链剖分维护树上k级祖先可以获得O(nlogn)~O(1)的复杂度,但是在随机数据下慢于重链剖分 。
首先我们对树进行长链剖分。
然后我们倍增出每个节点的2k级祖先。
然后我们初始化出每个链顶往上跳链长步,祖先分别是谁,再初始化出每个链顶,沿着链向下走链长步,经过的节点分别是谁。
对于询问x的k级祖先,我们倍增先跳到x的highbit(k)级祖先那里,这是O(1)的。
这样的话,由于性质,x的这个祖先所在的链长一定>highbit(k)>k-highbit(k)了,那么如果说剩下的步数跳完还在链上的话,就借助刚才初始化的数组从链顶往下跳,如果跳完已经超过链顶了,那么借助刚才初始化的另一个数组从链顶往上跳。这两种情况都是O(1)的。

倍增数组的复杂度,初始化O(nlogn)。

初始化数组的复杂度分析,每条链贡献O(链长)的复杂度,总复杂度就是O(n)。
空间上这两个数组可以使用嵌套vector,但是由于其实只记了O(n)的信息,也可以开数组存下。

highbit可以使用__builtin_clz(x)以O(1)求出,这个函数是(count leading zero),即统计前导零个数。如果是long long类型的话,应当使用__builtin_ctzll(x)。
highbit也可以数组预处理。

代码:

#include<iostream>
#include<algorithm>
#include<vector>
using namespace std;
#define ui unsigned int
ui s;
inline ui get(ui x) {
	x ^= x << 13;
	x ^= x >> 17;
	x ^= x << 5;
	return s = x; 
}
int n,m;
vector<vector<int>> a;
int dest[500005],deep[500005],fa[500005],son[500005];
int dfs1(int u) {
	dest[u]=deep[u]=deep[fa[u]]+1;
	for(auto&v:a[u])
		if(v^fa[u]) 
			if(dest[fa[v]=u]<dfs1(v))
				dest[u]=dest[v],son[u]=v;
	return dest[u];
}
int dfn[500005],cnt,top[500005],len[500005];
int dfs2(int u) {
	dfn[u]=++cnt;
	len[u]=1;
	if(son[fa[u]]==u) top[u]=top[fa[u]];
	else top[u]=u;
	if(son[u]) len[u]+=dfs2(son[u]);
	for(auto&v:a[u])
		if(v^fa[u]&&v^son[u])
			dfs2(v);
	return len[u];
}
vector<vector<int>> g,h;//g向上,h向下 
void dfs3(int ux,int u,int k) {
	g[ux].push_back(u);
	if(k^len[ux]) dfs3(ux,fa[u],k+1);
}
void dfs4(int ux,int u,int k) {
	h[ux].push_back(u);
	if(k^len[ux]) dfs4(ux,son[u],k+1);
}
int f[20][500005];
int solve(int u,int k) {
	if(!k) return u;
	int x=31-__builtin_clz(k);
	u=f[x][u];
	k-=1<<x;
	if(deep[u]-deep[top[u]]>=k)
		return h[top[u]][deep[u]-deep[top[u]]-k];
	else
		return g[top[u]][k-(deep[u]-deep[top[u]])];
}
int main(){
	cin>>n>>m>>s;
	for(int i=0;i<=n;i++) a.push_back({}),g.push_back({}),h.push_back({});
	int root;
	for(int i=1;i<=n;i++) {
		int u=i;
		int v;
		cin>>v;
		if(!v) {
			root=u;
			continue;
		}
		a[u].push_back(v);
		a[v].push_back(u);
	}
	dfs1(root);
	dfs2(root);
	for(int i=1;i<=n;i++) f[0][i]=fa[i];
	for(int k=1;k<20;k++)
		for(int i=1;i<=n;i++)
			f[k][i]=f[k-1][f[k-1][i]];
	for(int i=1;i<=n;i++)
		if(i==top[i])
		dfs3(i,i,0),dfs4(i,i,1);
	long long sum=0,ans=0;
//	for(int i=1;i<=n;i++)
//		cout<<deep[i]<<' ';
//	cout<<endl;
	for(long long i=1;i<=m;i++) {
		int u=(get(s)^ans)%n+1;
		int k=(get(s)^ans)%deep[u];
		ans=solve(u,k);
//		cout<<ans<<endl;
		sum=sum^(i*ans);
	}
	cout<<sum;
    return 0;
} 
  1. 唯一的细节就是注意在每一次求出新的询问的时候,要先算出u,再计算k,取模deep[u]时,不能再带入u的计算公式,因为get(s)会改变。

长链剖分与贪心

我们可以联想到,每一条长链的底端都是叶子结点。(事实上,所有的树链底端都是叶子结点。)

一个假装是对的贪心:
每次选择最大的路径,然后将路径上所有点的权值清零。
那么我们可以用长链剖分来实现这个贪心。
链长改为最大的路径权值和,这样子把每条长链的权值丢进一个堆里面取k次即可。
——yyb

后记

于是皆大欢喜。

<think>嗯,用户现在问的是关于优化树形DP的问题。之前他们问过代码是否能通过样例,现在转向具体的算法优化方法。我需要先回忆一下的基本概念和应用场景。 主要用于优化树上的动态规划问题,特别是涉及到节点深度的操作,比如查询k级祖先或者子树中与深度相关的统计。用户可能是在解决这类题目时遇到了性能问题,需要优化时间复杂度。 用户之前的对话中提到了代码在处理深度时可能存在根节点定义的问题,这可能影响了样例的正确性。现在他们想深入了解如何优化树形DP,可能需要理论上的解释和实现细节。 我应该从的定义开始,解释它与重的不同之处,比如按子树最大深度划,而不是子树大小。然后说明如何优化DP,比如利用指针偏移技术,减少内存配和复制,从而将复杂度从O(n^2)降到O(n)。 接着,需要举一个具体的例子,比如树上每个节点的子树中某个深度相关的统计问题,用传统方法和优化的方法对比。然后给出代码框架,展示如何实现,比如预处理重儿子,DFS过程中处理轻和重的合并。 用户可能还想知道适用的场景和注意事项,比如问题是否与深度相关,内存管理的技巧等。最后总结的优势和局限性,确保用户能够正确应用。 需要确保解释清晰,步骤详细,避免使用过于专业的术语,但又要准确。可能用户有竞赛编程的背景,所以代码示例部应该简洁明了,突出关键部,比如指针传递和组合并的操作。</think>是优化树形动态规划(DP)的重要技术,尤其擅处理**与深度相关**的树形结构问题(如子树深度统计、k级祖先查询等)。其核心思想是通过**式结构复用计算结果**,将复杂度从 *O(n^2)* 优化至 *O(n)*。 --- ### **一、核心逻辑** #### **1. 规则** - **定义**:每个节点选择**子树深度最大**的子节点作为“重儿子”(类比重选择子树大小最大的子节点)。 - **结构**:由重儿子连接形成的称为****,其他子节点作为的起点形成新。 #### **2. 优化原理** - **指针偏移复用内存**:在DFS过程中,优先处理重儿子所在的,通过**直接传递指针**复用其内存空间,避免重复计算。 - **轻合并复杂度摊**:轻据通过内存拷贝合并到父,由于每条轻度唯一且较短,总时间复杂度保持线性。 --- ### **二、典型应用场景** #### **1. 子树深度统计** **问题**:统计每个节点子树中所有节点到它的最大距离(即子树高度)。 **传统DP**:每个节点遍历子树,时间复杂度 *O(n^2)*。 **优化**:利用传递高度信息,合并轻时直接取最大值,复杂度 *O(n)*。 #### **2. k级祖先查询** **问题**:快速查询树上任意节点的第k级祖先。 **传统方法**:倍增法 *O(n log n)* 预处理,*O(log k)* 查询。 **优化**:预处理每个顶的祖先跳跃表,结合二进制拆,*O(n)* 预处理,*O(1)* 查询。 --- ### **三、优化实现步骤(以子树最大深度为例)** #### **1. 预处理结构** ```cpp vector<int> depth(n), max_depth(n); // depth[u]: 节点u的深度 vector<int> heavy_son(n, -1); // heavy_son[u]: 节点u的重儿子 // 第一次DFS:计算每个节点的重儿子 void dfs1(int u, int fa) { max_depth[u] = depth[u] = depth[fa] + 1; for (int v : tree[u]) { if (v == fa) continue; dfs1(v, u); if (max_depth[v] > max_depth[u]) { max_depth[u] = max_depth[v]; heavy_son[u] = v; } } } ``` #### **2. DP过程复用内存** ```cpp // dp[u][i]: 节点u子树中深度为depth[u]+i的节点 vector<int> dp[MAX_N]; // 动态组内存池 // 第二次DFS:利用传递指针 void dfs2(int u, int fa) { if (heavy_son[u] != -1) { dfs2(heavy_son[u], u); // 优先处理重儿子 dp[u] = dp[heavy_son[u]]; // 直接复用重儿子的内存 dp[u].push_back(1); // 当前节点自身贡献 } else { dp[u].push_back(1); // 叶子节点初始化 } // 合并轻据 for (int v : tree[u]) { if (v == fa || v == heavy_son[u]) continue; dfs2(v, u); for (int i = 0; i < dp[v].size(); ++i) { if (i+1 < dp[u].size()) { dp[u][i+1] += dp[v][i]; // 偏移合并轻据 } else { dp[u].push_back(dp[v][i]); } } } } ``` --- ### **四、复杂度析** - **时间**:每个轻据最多被拷贝一次,总拷贝次为 *O(n)*。 - **空间**:通过指针复用,总内存占用为 *O(n)*(所有度之和为n)。 --- ### **五、适用问题特征** 1. **子树统计与深度强相关**(如子树高度、某深度的节点)。 2. **查询路径与结构相关**(如k级祖先、路径统计)。 3. **需线性复杂度优化**的树形DP问题。 --- ### **六、对比其他优化方法** | 方法 | 优势 | 局限性 | |--------------|-----------------------------|-------------------------| | **** | 严格线性复杂度,内存复用高效 | 仅适用于深度相关问题 | | **重** | 通用性强,适合路径查询问题 | 复杂度常带log因子 | | **DFS序+线段树** | 灵活支持区间操作 | 复杂度 *O(n log n)* | 通过,可以将特定树形DP问题的复杂度优化到理论下限,是算法竞赛中树形问题的核心优化手段之一。
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值