线段树合并 & Dsu on Tree & 启发式合并(附赠长链剖分)

前言

我发现这三样东西有一些相似度,而且解决的问题类型相似,故一起写并作比较。

(由于长剖是后来加的,所以很多语句会把四个写成三个,懒得改了)

线段树合并 & Dsu on Tree & 启发式合并(附赠长链剖分) 学习笔记与分析

总的来说,这三种方法经常用于解决静态的可合并集合的查询问题(可合并指的是一部分查询可能是一些集合的并集)

具体来说有如下案例:

  1. 树上,对于每个节点的子树做询问。

  2. 将给定集合动态合并 + 查询集合。(由于合并关系也可以看成一棵树,故也是相似的)

下文中,称前者为静态问题,后者为动态问题。

线段树合并

此处的线段树指的是动态开点线段树(显然,静态的直接重建一颗就行了,不需要单独的算法)

合并的过程其实十分暴力:将两棵线段树重叠部分加起来,将不重叠的统一指到一棵树上就结束了,复杂度是 \(O(k)\),其中 \(k\) 是两棵线段树重叠部分的大小。

听起来很暴力,但是如果对于每个点建一棵单个点构成的线段树,然后按任何顺序全部合并起来,却是 \(O(n\log n)\) 的。

Proof: 当两棵线段树中有重合的点的时候才会递归,否则直接 return。

所以每次合并的时间复杂度为重合的点数。而对于重合的两个点,合并后会删掉一个。

因此每合并一次就会删掉一个点,而初始一共 \(n\log n\) 个点,故复杂度均摊 \(O(n\log n)\)

事实上这三个算法都是这样,用看似暴力的合并做到 \(\text{polylog}\) 的复杂度。

Dsu on Tree

只适用于静态问题!

我们考虑暴力求解,则每个点都要把子树内所有点加进去再退出来(否则空间爆炸)。

但是如果按照 dfs 的顺序求解,先解决这个点的所有儿子节点,那么最后一个儿子的计算,加进去了就不用退出来,直接用于其父亲节点计算。

父亲计算答案时候只需要加入其它子树内的所有点即可。

显然,选择重儿子来保留最优。

然后我们发现这又变成 \(O(n\log n)\) 的了。

Proof: 我们可以发现:每一个点只会在其到根路径中若干重链交界处被统计(也即是从该点到根路径上的轻边数量)。

而由于重链剖分的性质,任意一个点往上跳时所经过的重链数量不超过 \(O(\log n)\),所以重链交界处(轻边)的数量也不会超过 \(O(\log n)\),因此每个点的统计次数也为 \(O(\log n)\)

之所以只适用于静态是因为需要提前处理重儿子。

启发式合并

这个简单而且灵活嗷。

回忆并查集的启发式合并,逻辑是把小的合并到大的里面,这里也可以使用类似的方法!

还是 dfs,观察计算某个子树时候是将它本身和它的所有子节点的子树合并起来,于是我们维护一个集合,最开始每个点自成一个集合,然后合并时候将小的合并到大的里面就行了。

维护集合可以使用 setunordered_map,平衡树等等。

复杂度为 \(O(n\log n\times x)\),其中 \(x\) 是维护集合插入的复杂度。

Proof: 每次合并复杂度为小的集合的元素数量,故只需要统计一个元素作为小集合元素贡献多少次。

发现每个元素贡献一次,它所在集合大小至少翻倍,故最多贡献 \(O(\log n)\) 次。

长链剖分优化 DP(upd on 12.25)

闲话:当时我是知道这玩意的,但是由于我以为这个只有一道万年典题还被另外三种几乎平替了就没管,不过现在发现另有高手,那还是写。

长剖合并多一条要求:信息只和子树深度相关。

但是合并就可以做到更优秀的复杂度 \(O(n)\)

你先长链剖分写出来,然后 DP 的时候, DP 长儿子(注意和 dsu on tree 的“最后”区分),再把非长儿子的 DP 值暴力扔进来。

然后,这个玩意居然是 \(O(n)\) 的,事实就是这样,小编对此也很惊讶。

说起来简单,但是实际写的时候需要指针来实现,似乎并不好写。

而且基本上可以被前三种多一个 \(\log\) 来平替。

因为信息数是子树深度,而子树深度不大于子树大小。

去实现了 P5904,有如下的发现:

其实可以避免指针的使用,考虑同样用 unordered_map 像启发式合并一样的写法,只不过改成长剖的顺序就可以做到好写的 \(O(n)\),不过仍有两个注意事项:1. 注意合并根和长儿子时候也有贡献。2. 如果合并信息有错位的情况,就有很大概率需要整体打 tag,维护一下就好了。

另外,启发式合并虽然可以做,却并不吃香:1. 如果信息不错位,两种方法差不多好写,长剖多一个几行的 DFS 罢了。2. 如果错位了,启发式合并包含了大量父子交换,需要维护的整体 tag 会更加复杂。

给个代码:

#include<bits/stdc++.h>
using namespace std;
#define int long long
#define N 114514
unordered_map<int,int> f[N],g[N];
long long ans;
int d[N],n,i,u,v,mt[N];
vector<int> e[N];
int son[N],md[N];
void dfs0(int u,int fa)
{
	d[u]=d[fa]+1;
	md[u]=1;
	for(auto v:e[u]) if(v!=fa)
	{
		dfs0(v,u);
		if(md[v]+1>md[u]) md[u]=md[v]+1,son[u]=v;
	}
}
void dfs(int u,int fa)
{
	int v=son[u];
	if(v)
	{
		dfs(v,u);
		if(g[v].find(d[u]+2+mt[v])!=g[v].end()) ans+=g[v][d[u]+2+mt[v]];
		swap(f[u],f[v]);
		swap(g[u],g[v]);mt[u]=mt[v]+2;
	}
	f[u][d[u]]++;
	for(auto v:e[u]) if(v!=fa&&v!=son[u])
	{
		dfs(v,u);
		for(auto x:f[v])
		{
			int dep=x.first;
			if(dep>d[v]) ans+=1ll*f[u][dep-2]*g[v][dep+mt[v]];
			ans+=1ll*g[u][dep+mt[u]]*f[v][dep];
		}
		for(auto x:f[v])
		{
			int dep=x.first;
			if(dep>d[v]) g[u][dep-2+mt[u]]+=g[v][dep+mt[v]];
			g[u][dep+mt[u]]+=1ll*f[u][dep]*f[v][dep];
		}
		for(auto x:f[v]) f[u][x.first]+=f[v][x.first];
	}
}
signed main()
{
	cin>>n;
	for(i=1;i<n;i++) cin>>u>>v,e[u].push_back(v),e[v].push_back(u);
	dfs0(1,0);
	dfs(1,0);
	cout<<ans;
}

例题

CF600E Lomsat gelral:

每个点染一个颜色,对于每个点,计算其子树内染的最多的颜色编号,有多个则计算编号和。

  1. 线段树合并:值域线段树维护一下最大值和答案,然后直接合并即可,时空复杂度为 \(O(n\log n)\)

  2. Dsu on Tree:因为这个算法是一个一个计算的,故只需要一个桶就可以计算答案,复杂度很自然就是时间 \(O(n\log n)\),空间 \(O(n)\)

  3. 启发式合并:由于这个算法要维护完整的集合,每个点开一个桶就不行了,只好改用 unordered_map,时间复杂度 \(O(n\log n)\)(常数略大),空间 \(O(n)\)

  4. 长链剖分:信息量等于子树大小,大于深度,本做法不能解决该问题。

P3224 [HNOI2012] 永无乡

\(n\) 个岛,每个岛一个重要度(各不相同),\(q\) 次操作,每次在两个岛之间架桥(合并两个联通块),或询问某个岛所在联通块第 \(k\) 重要的岛。

  1. 线段树合并:用值域线段树维护第 \(k\) 小即可,时空复杂度同上。

  2. Dsu on Tree:由于本题不是静态问题,不能预处理重儿子,本做法不能解决该问题。

  3. 启发式合并:启发式合并平衡树即可,空间复杂度是 \(O(n)\),时间复杂度如使用 Splay 或者 FHQ_Treap,运用一些技巧合并是 \(O(n\log n)\),直接合并或使用 pb_ds\(O(n\log ^2n)\)

  4. 长链剖分:信息量等于子树大小,大于深度,且不是静态问题,本做法不能解决该问题。

CF1009F Dominant Indices

1~3. 由于和例题一差别不大,复杂度相同,而且信息不错位,比较好写。

  1. 长链剖分:由于信息只和深度相关,可以解决,时空复杂度 \(O(n)\)

那么下面我们简单对比一下这几种做法。

对比

空间复杂度:线段树合并 \(O(n\log n)\),另外三种 \(O(n)\)

时间复杂度:线段树合并 \(O(n\log n)\),另外两种 \(O(n\log nx)\),其中 \(x\) 为 维护集合单个元素插入删除的复杂度,长剖 \(O(n)\)

解决问题类型:线段树合并和启发式合并灵活,离线在线都可以,Dsu On Tree 只能解决树形状固定的静态问题,长剖只解决树形状固定且信息只和深度相关的问题。

具体一点:

线段树合并由于基于线段树,解决问题很灵活,而且复杂度很难因为套上啥就多一只 \(\log\),但是空间也差一些。

只要不卡空间,是这类问题的一个通用解法。

相对来说,启发式合并主要优势在于代码短小,好写好调,但如果合并平衡树时候这个优势就消失了

另外,如果要维护的问题相对复杂,则合并需要 setmap 配合维护,反而困难。

而且由于合并 unordered_map 实在太太太慢了甚至还没有 map 快,所以除非你可以合并 vector(极少数情况),否则你可以视作至少两只 \(\log\)

Dsu On Tree 和启发式合并的区别是它只维护静态问题,但是由于逐个计算,如果答案能用桶/非维护集合的数据结构较方便统计,它就有较大优势,反之,如果必须要使用 set 等维护,这个算法意义就不大。

长剖一般都可以用另外三种多一只 \(\log\) 解决,除非开到 \(10^6\) 另外三种都可以过。

posted @ 2024-06-27 15:31  Fun_Strawberry  阅读(275)  评论(0)    收藏  举报