树上启发式合并

树上启发式合并

  • 算法原理+流程+时间复杂度分析
  • [D. Tree Requests](https://codeforces.com/problemset/problem/570/D)

又称dsu on tree,但这个算法和并查集没啥联系,可能借鉴了按秩合并的思想吧。

速通版:
问题如果可以转化成关于子树的查询,(比如查询某棵子树内的信息、比如查询兄弟子树的信息),那么可以先尝试暴力做法:
对于rt,我们一棵一棵地遍历它的子树,每棵子树都是先统计答案,然后更新相关信息。 如果我们枚举每棵子树,统计答案,然后清空信息,这样是标准的O(N²), 但是我们发现,最多可以保留1棵子树的信息不清空因为回到rt时这颗子树和前面0棵子树的信息不会产生答案贡献,从第二颗子树开始才会产生贡献, 所以我们希望让保留信息的子树尽量大,于是结合重儿子的概念,保留重子树,这样可以证明是O(Nlogn)

也就是说,dsu on tree仅仅是改了个dfs的顺序,让重子树最后计算,并保留它的信息给根节点。

算法原理+流程+时间复杂度分析

参考大佬博客传送

我们先从第一个例题开始看起:

E. Lomsat gelral
题意是给定了一个无根树,带点权,q次询问,每次询问某棵子树内,点权众数之和。(如果2 3 4都是众数,那么答案是2+3+4=9)

首先最直接的暴力方法就是,枚举每棵子树,通过遍历其节点O(n)求出答案,并删除记录的信息,时间复杂度是O(n²)

优化方法,我们注意到对于根节点rt,遍历完其所有孩子节点的子树后,最后递归回来的这颗子树不需要删除,可以直接由rt继承,我们自然想到最后这个子树的size越大
越好,这便是所谓启发式合并。 子树size最大不正是轻重链剖分里面的重儿子的概念吗? 所以算法流程就出来了:

  • 首先轻重链剖分,求出重儿子

  • 全局维护出现众数的出现次数,以及所求的答案,进行dfs。优先dfs轻儿子,最后dfs重儿子,重儿子的影响保留,rt重新统计轻儿子的贡献并记录答案,如果rt是其父节点的轻儿子,删除rt的影响,否则保留。(建议看代码以及注释)

  • 删除操作就是暴力递归给轻儿子,每到一个节点就消除影响。

  • 时间复杂度分析 ,我们考虑某个节点u,u被访问只有两种情形:
    ①通过重链被访问,这种情况只会访问1次 ,因为重链的影响是永久性的,不会被删
    ②通过轻边被访问,由于树剖后根节点到u只有logn条轻边,所以u只会被访问logn次。
    综上时间复杂度为nlogn
    例题代码:

#include
using namespace std;
//#pragma GCC optimize(2)
#define ull unsigned long long
#define ll long long
#define pii pair<int, int>
#define pdd pair<double, double>
#define re register
#define lc rt<<1
#define rc rt<<1|1
const int maxn = 1e5 + 10;
const ll mod = 998244353;
const ll inf = (ll)4e17+5;
const int INF = 1e9 + 7;
const double pi = acos(-1.0);
//给定树 带点权 询问子树中,出现次数最多的若干种点权的点权和 (众数之和)
//暴力解法 对于每个子树都花O(n)求出其答案,并用O(N)清空影响  时间O(N平方)
//发现对于根节点rt,最后递归的子树可以不删,保留信息给根节点,我们最后递归重儿子,保留重儿子的信息 即:启发式合并
//我们保留重儿子,删除轻儿子的影响,并最后重新统计一遍轻儿子
//nlogn 某个点u被访问:要么通过重链(不会被删,只会被访问1次),要么通过轻边,但根到u只有logn条轻边,所以最多logn次
vector<int> g[maxn];
int v[maxn];
int cnt[maxn];
int mmx=-1;//众数出现次数
ll ret, ans[maxn];
int n;
//轻重链剖分
int son[maxn],siz[maxn];
void dfs(int rt,int fa) 
{
	siz[rt]=1;
	for(int &i:g[rt]) 
	{
		if(i==fa) continue;
		dfs(i,rt);
		if(siz[i] > siz[son[rt]]) son[rt]=i;
		siz[rt]+=siz[i];
	}
}
int Son;//当前子树的重儿子
void add(int rt,int fa,int a)//rt 统计贡献 贡献v
{
	cnt[v[rt]]+=a;
	if(cnt[v[rt]]==mmx) 
	{
		ret+=v[rt];
	}
	else if(cnt[v[rt]] > mmx) 
	{
		mmx=cnt[v[rt]];
		ret=v[rt];
	}
	for(int &i:g[rt]) 
	{
		if(i==fa || i==Son) continue;//重边不会被访问 也就是每次通过轻边最多访问size/2个节点
			add(i,rt,a);
	}
}
void dfs2(int rt,int fa,bool ok) //先递归轻链 最后再求重儿子并且父节点的答案继承重儿子的答案 再暴力统计轻儿子
{
	for(int &i:g[rt]) 
	{
		if(i==fa || i==son[rt]) continue;
			dfs2(i,rt,0);
	}
	if(son[rt]) dfs2(son[rt],rt,1),Son=son[rt];//重链的信息是永久性的 不会被删
	add(rt,fa,1);
	ans[rt]=ret;
	if(!ok) //是轻儿子子树的信息 需要删去整棵轻儿子子树
	{
		Son=0;//保证轻儿子的重儿子也要删去
		add(rt,fa,-1);
		mmx=-1;
		ret=0;
	}
}
int main()
{	
	scanf("%d",&n);
	for(int i=1;i<=n;i++) scanf("%d",v+i);
	for(int i=1;i<n;i++) 
	{
		int v,u;
		scanf("%d %d",&u,&v);
		g[u].push_back(v);
		g[v].push_back(u);
	}
	dfs(1,1);
	dfs2(1,1,0);
	for(int i=1;i<=n;i++) cout<<ans[i]<<' ';
	return 0;
}

总结一下就是:
树上启发式合并一般适用于

  • 离线查询
  • 不带修改
  • 针对子树的查询,可以是根-某棵子树内,也可以是横跨子树1-子树2(这个也许不明显,但是可以把查询离线并转化成子树查询,不过如果不同子树的信息会互相干扰,那就无法转化成子树查询了)
    需要手动转化成子树查询的例题
  • 流程:
    ①先把询问离线并转化成子树查询,思考枚举每棵子树的暴力求解方法
    (通常是维护一个全局的桶或者变量,或者别的数据结构比如set,线段树…),维护的信息在不同子树之间不能有关联,且递归完一棵轻子树就需要删除这些信息,
    然后把最后一棵子树(重子树)的答案和维护的信息保留并合并给根节点,无法合并的需要东西需要在更新完后就清空,可以合并的信息需要清空当且仅当rt是轻儿子。

②预处理:树剖求出重儿子(忽略长链剖分,因为太罕见 )
③solve:递归所有轻儿子,统计该子树答案后删除影响;
递归重儿子,记录答案,不删除影响
暴力遍历rt的轻儿子,统计rt的答案
如果rt是其父亲的轻儿子,就暴力删除rt的影响。(根据所维护的数据结构的不同有时候不需要递归遍历,可以直接删掉)

常数优化小技巧:add函数的递归用dfs序来实现

D. Tree Requests

在这里插入图片描述
子树查询,不带修改,先思考暴力解法。
对于每棵子树,我们统计cnt[d][i]表示该子树中,深度为d,颜色为i的节点个数,
离线查询,统计完rt的信息后,枚举rt的每个查询{h,id},检查cnt[h][i]中奇数是否超过了1个,如果是,ans[id]=0,否则ans[id]=1;

暴力解法是O(n²),但是发现最后一棵子树是的信息可以合并给根节点的,所以可以用dsu on tree优化成nlogn。

#include
using namespace std;
//#pragma GCC optimize(2)
#define ull unsigned long long
#define ll long long
#define pii pair<int, int>
const int maxn = 5e5 + 10;
const ll mod = 998244353;
const ll inf = (ll)4e17+5;
const int INF = 1e9 + 7;
const double pi = acos(-1.0);
ll inv(ll b){if(b==1)return 1;return(mod-mod/b)*inv(mod%b)%mod;}
//1为根 有根树 带点权 点权a到z 询问rt子树 深度为d的这层节点 点权重排能否构成回文串
 
//暴力 -> 树上启发式合并
vector<int> g[maxn];
int cnt[maxn][30];//深度为i 颜色为j的个数
int son[maxn],siz[maxn],dep[maxn];
char col[maxn];
vector<pii> q[maxn];
bool ans[maxn];
//轻重链剖分
void dfs(int rt) 
{
	siz[rt]=1;
	for(int &i:g[rt]) 
	{
		dep[i]=dep[rt]+1;
		dfs(i);
		if(siz[i] > siz[son[rt]]) son[rt]=i;
		siz[rt]+=siz[i];
	}
}
bool check(int d) //check深度为d  颜色分布情况
{
	int odd=0;//奇数个数
	for(int i=1;i<=26;i++) 
	{
		odd+=(cnt[d][i]&1);
	}
	return odd<=1;
}
int SON;
void add(int rt,int v) //这个函数其实就是暴力解法的步骤 只不过当前rt的重儿子不需要递归下去 
{
	int id=col[rt]-'a'+1;
	cnt[dep[rt]][id]+=v;
	for(int &i:g[rt]) 
	{
		if(i==SON) continue;//重儿子不需要统计
		add(i,v);
	}
}
void dfs2(int rt,bool ok) 
{
	for(int &i:g[rt]) 
	{
		if(i==son[rt]) continue;
		dfs2(i,0);
	}
	if(son[rt]) 
	{
		dfs2(son[rt],1);
		SON=son[rt];
	}
	add(rt,1),SON=0;
	for(auto i:q[rt]) 
	{
		ans[i.second]=check(i.first);
	}
	if(!ok) //是轻儿子 删了
	{
		add(rt,-1);
	}
}
int n,m;
int main()
{
	dep[1]=1;
	scanf("%d %d",&n,&m);
	for(int i=2;i<=n;i++) 
	{
		int fa;scanf("%d",&fa);
		g[fa].push_back(i);
	}
	scanf("%s",col+1);
	dfs(1);
	for(int i=1;i<=m;i++) 
	{
		int u,d;
		scanf("%d %d",&u,&d);
		q[u].push_back({d,i});
	}
	dfs2(1,0);
	for(int i=1;i<=m;i++) 
		if(ans[i]) puts("Yes");
		else puts("No");
	return 0;
}

你可能感兴趣的:(启发式合并,算法)