树上启发式合并学习笔记+杂题

图论系列:

前言:

欲买桂花同载酒,终不似,少年游。

相关题单:戳我

一.树上启发式合并

前置知识:树的重儿子

1.引入

启发式算法是基于人类的经验和直观感觉,对一些算法的优化。(其实就是感觉是对的就是对的),例如并查集的启发式合并,将小集合合并到大集合中。

因为在路径压缩的时候,大集合的根没有改变,只有小集合内的元素会递归根,于是优化了时间复杂度。

2.过程

有点抽象啊,还是给定例题吧。

例题:树上数颜色

给出一棵 \(n\) 个节点以 \(1\) 为根的树,节点 \(u\) 的颜色为 \(c_u\),现在对于每个结点 \(u\) 询问\(u\) 为根的子树里一共出现了多少种不同的颜色。(\(n \leq 2e5,c_i \leq 2e5\)

发现这题没法 dfs 一次直接做的原因?因为对于存颜色的桶只可能开一个,记\(t\)\(t_i\) 表示 \(i\) 颜色有多少个点,对于一个有多个儿子的节点(这里的儿子就先表示以儿子为根的子树),统计完儿子 \(1\) 之后需要把儿子 \(1\) 的贡献删去之后才能去统计儿子 \(2\) 的答案(否则可能一种根本不在儿子 \(2\) 内的颜色,在儿子 \(1\) 内,但最后儿子 \(2\) 还是算上了这个颜色)。

相当于为了得知一个点的答案,我们需要遍历这个点的整棵子树。这样时间复杂度就是 \(O(n^2)\) 的,显然不优。那么儿子之间贡献不能混在一起,那当前点的儿子可以将自己的贡献转移到当前点上吗?这显然是可以的,在儿子子树内的点,同时也是当前点为根的子树的一部分。(但是儿子子树内的贡献不能混在一起嘛,所以这个保留贡献的儿子只能是最后一个遍历,因为之前遍历完一个儿子,得到答案之后就需要将这个儿子的贡献删去)。

为了尽可能的优化时间复杂度,贪心的想,当然是保留子树最大的那个儿子,也就是重儿子。于是对于一个点,首先先遍历完其所有的轻儿子,对于轻儿子统计完答案便将其贡献删去,然后统计重儿子的答案,重儿子的贡献保留

显然对于一个点,如果其遍历了 \(x\) 次,那么重儿子也遍历了 \(x\) 次,轻儿子遍历了 \(2x\)(遍历一次当前点,就需要统计一次轻儿子的答案+将轻儿子的答案删去)。

3.证明

类似树链剖分的证明,还是定义连向重儿子的为重边,连向轻儿子的为轻边,对于一棵有 \(n\) 个节点的树。

根节点到树上任意节点的轻边数不超过 \(\log n\) 条。我们设根到该节点有 \(x\) 条轻边该节点的子树大小为 \(y\),显然轻边连接的子节点的子树大小小于父亲的一半(若大于一半就不是轻边了),则 \(y<n/2^x\),显然 \(n>2^x\),所以 \(x<\log n\)

又因为如果一个节点是其父亲的重儿子,则它的子树必定在它的兄弟之中最多,所以任意节点到根的路径上所有重边连接的父节点在计算答案时必定不会遍历到这个节点,所以一个节点的被遍历的次数等于它到根节点路径上的轻边数 \(+1\)(之所以要 \(+1\) 是因为它本身要被遍历到),所以一个节点的被遍历次数 \(=\log n+1\) ,总时间复杂度则为 \(O(n(\log n+1))=O(n\log n)\)

4.实现

例题的代码

const int M=2e5+5;
int n,maxson,num;
int c[M],t[M],ans[M];

int cnt=0;
struct N{
	int to,next;
}; N p[M<<1];
int head[M],siz[M],son[M];
inline void add(int a,int b)
{
	++cnt;
	p[cnt].next=head[a];
	head[a]=cnt;
	p[cnt].to=b;
}

inline void dfs1(int u,int f)
{
	siz[u]=1;
	for(int i=head[u];i!=0;i=p[i].next)
	{
		int v=p[i].to;
		if(v==f) continue;
		dfs1(v,u);
		siz[u]+=siz[v];
		if(siz[v]>siz[son[u]]) son[u]=v;
	}
}//就是树剖的第一个dfs,目的是找出每个点的重儿子 
inline void solve(int u,int f,int k)
{
	if(k>0)
	{
		if(++t[c[u]]==1) ++num; 
	}
	else
	{
		if(--t[c[u]]==0) --num;
	}
	for(int i=head[u];i!=0;i=p[i].next)
	{
		int v=p[i].to;
		if(v==f||v==maxson) continue;
		solve(v,u,k);
	}
}
inline void dfs(int u,int f,int opt)//opt为0表示这是轻儿子,否则是重儿子
{
	for(int i=head[u];i!=0;i=p[i].next)
	{
		int v=p[i].to;
		if(v==f||v==son[u]) continue;
		dfs(v,u,0);//儿子之间颜色不能乱,先将轻儿子的答案统计出来,递归出来的时候由于opt=0所以贡献会删完 
	}
	if(son[u]) dfs(son[u],u,1),maxson=son[u];//如果有重儿子就统计重儿子,同时记录这个点的重儿子 
	solve(u,f,1);//暴力加上轻儿子的答案(所以要将重儿子排除在外) 
	ans[u]=num,maxson=0;//注意,如果是轻儿子的话,轻儿子要删完 
	if(!opt) solve(u,f,-1);//是轻儿子,贡献删完 
}

signed main()
{
	ios::sync_with_stdio(false);
	cin.tie(0);cout.tie(0);
	cin>>n;
	for(int i=1;i<=n;i++) cin>>c[i];
	for(int i=1,a,b;i<n;i++)
	{
		cin>>a>>b;
		add(a,b),add(b,a);
	}
	dfs1(1,0);
	dfs(1,0,1);
	for(int i=1;i<=n;i++) cout<<ans[i]<<" ";
	return 0;
}

5.习题

CF208E Blood Cousins

一棵树/森林,给定 \(q\) 个询问,询问第 \(x\) 个点 \(p\) 级表亲。存在一个人 \(z\) ,是 \(a\)\(b\) 共同的 \(p\) 级祖先,那么称 \(a\)\(b\)\(p\) 级表亲。

那么对于一个询问朴素应该怎么做?显然是先找到 \(x\) 点的 \(p\) 级祖先,记为 \(y\) ,然后看 \(y\) 子树内深度为 \(p\) 的节点有多少个再减去一(自身)就是答案。那么干脆之间将询问就转化成询问某个节点子树内深度为 \(p\) 的节点的数量。

那么分析一下这种问题是否可以启发式合并,主要是看是否儿子贡献是否可以传递给父亲啊。考虑暴力做法,显然就是对于每一个点跑其子树,统计出其子树内各个深度的点的个数即可。这样如果一个儿子统计完其子树内各个深度的节点个数,显然是可以转移给父亲的。

于是使用树上启发式合并,对于每个节点下的子树都暴力统计各个深度的节点数,保留重儿子,删去轻儿子的贡献。

代码:

const int M=1e5+5;
int n,q,maxson;
int fa[M][18],t[M],ans[M];
vector<int> r;
vector<pii> s[M];
 
int cnt=0;
struct N{
	int to,next;
};N p[M<<1];
int head[M];
inline void add(int a,int b)
{
	++cnt;
	p[cnt].next=head[a];
	head[a]=cnt;
	p[cnt].to=b;
}
 
int siz[M],son[M],deep[M];
inline void dfs1(int u,int f)
{
	deep[u]=deep[f]+1,siz[u]=1,fa[u][0]=f;
	for(int i=head[u];i!=0;i=p[i].next)
	{
		int v=p[i].to;
		if(v==f) continue;
		dfs1(v,u);
		siz[u]+=siz[v];
		if(siz[v]>siz[son[u]]) son[u]=v;
	}
}
 
inline void solve(int u,int f,int k)
{
	t[deep[u]]+=k;//暴力统计各个深度的点的个数
	for(int i=head[u];i!=0;i=p[i].next)
	{
		int v=p[i].to;
		if(v==f||v==maxson) continue;
		solve(v,u,k);
	}
}
 
inline void dfs2(int u,int f,int opt)//opt=0还是表示为轻儿子,否则为重儿子
{
	for(int i=head[u];i!=0;i=p[i].next)
	{
		int v=p[i].to;
		if(v==son[u]||v==f) continue;
		dfs2(v,u,0);
	}
	if(son[u]) dfs2(son[u],u,1),maxson=son[u];//最后一个遍历重儿子,因为要保留贡献
	solve(u,f,1),maxson=0;//又将轻儿子的贡献暴力加进来
	for(auto it:s[u])//处理当前点上的询问
	{
		ans[it.second]=t[deep[u]+it.first]-1;
	}
	if(!opt) solve(u,f,-1);//如果是轻儿子就需要删去这个点子树下的所有贡献
}
 
inline int find(int x,int res)
{
	for(int i=17;i>=0;--i)
	{
		if(res>=deep[x]-deep[fa[x][i]])
		{
			res-=(deep[x]-deep[fa[x][i]]);
			x=fa[x][i];
		}
	}
	if(res) return 0;
	return x;
}//树上倍增
signed main()
{
	ios::sync_with_stdio(false);
	cin.tie(0);cout.tie(0);
	cin>>n;
	for(int i=1,x;i<=n;++i)
	{
		cin>>x;
		if(!x) r.push_back(i);
		else add(x,i);
	}
	for(int root:r) dfs1(root,0);//可能不连通ing,预处理出重儿子以及各个点的父亲节点,以及各个点的深度
	for(int j=1;j<=17;++j)
	{
		for(int i=1;i<=n;++i) fa[i][j]=fa[fa[i][j-1]][j-1];
	}//由于要跳某个点的k级祖先,所以使用树上倍增,这里是预处理
	cin>>q;
	for(int i=1,x,k,f;i<=q;++i)
	{
		cin>>x>>k;
		f=find(x,k);//x的树上k级祖先(如果找到的f是0,说明这个点没有树上第k级祖先,答案自然为0,不用管)
		if(f) s[f].push_back(mk(k,i));//将每个节点的询问存在一个vector中(一个点上可能包含了多个询问),要找的就是k级祖先子树内深度为k的节点个数,i表示这个是第几个询问,好将答案存下来
	}
	for(int root:r) dfs2(root,0,0);//树上启发式合并
	for(int i=1;i<=q;++i) cout<<ans[i]<<" ";
	return 0;
}

CF291E Tree-String Problem

树上跑 kmp,当练习题(其实是没写代码力)

CF600E Lomsat gelral

给定一颗有根树,每个节点都有一个颜色, \(i\) 节点的颜色为 \(c_i\) ,如果一种颜色在以 \(x\) 为根的子树内出现次数最多,称其在以 \(x\) 为根的子树中占主导地位。显然,同一子树中可能有多种颜色占主导地位。要求对于每一个 \(i\in[1,n]\),求出以 \(i\) 为根的子树中,占主导地位的颜色的编号和

还是想儿子的贡献是否可以转移到父亲节点上并且是否能快速得到询问的答案,如果我们是统计每种颜色出现了多少次的话,虽说是可以将贡献转移至父亲节点,但是答案似乎处理起来有点麻烦。因为可能有多个颜色占据主导地位,那如果我们使用一个桶数组记录一下每一个颜色出现的次数,然后用一个 \(res\) 记录当前颜色出现次数最多的编号和就行了。

可能会问如果颜色出现增减的时候是否好维护,增加的时候显然,减少的时候都是一个子树贡献全部砍掉,于是 \(res\) 这种时候直接赋为 \(0\) 即可

代码:

const int M=1e5+5;
int n,res,max_son,maxx;
int c[M],ans[M],t[M];
//res表示出现次数最多的颜色的编号和,maxx表示出现次数最多的 
int cnt=0;
struct N{
	int to,next;
};N p[M<<1];
int head[M];
inline void add(int a,int b)
{
	++cnt;
	p[cnt].next=head[a];
	head[a]=cnt;
	p[cnt].to=b;
}
 
int son[M],siz[M];
inline void dfs1(int u,int f)
{
	siz[u]=1;
	for(int i=head[u];i!=0;i=p[i].next)
	{
		int v=p[i].to;
		if(v==f) continue;
		dfs1(v,u);
		siz[u]+=siz[v];
		if(siz[v]>siz[son[u]]) son[u]=v;
	}
}
 
inline void dfs3(int u,int f,int k)
{
	t[c[u]]+=k;
	if(t[c[u]]>maxx) maxx=t[c[u]],res=c[u];
	else if(t[c[u]]==maxx) res+=c[u];
	for(int i=head[u];i!=0;i=p[i].next)
	{
		int v=p[i].to;
		if(v==f||v==max_son) continue;
		dfs3(v,u,k);
	}
}
 
inline void dfs2(int u,int f,int opt)
{
	for(int i=head[u];i!=0;i=p[i].next)
	{
		int v=p[i].to;
		if(v==f||v==son[u]) continue;
		dfs2(v,u,0);
	}
	if(son[u]) dfs2(son[u],u,1),max_son=son[u];
	dfs3(u,f,1);
	max_son=0,ans[u]=res;
	if(!opt) dfs3(u,f,-1),maxx=res=0;//这个时候maxx&res直接赋0即可,因为整个子树都删了
}
 
signed main()
{
	ios::sync_with_stdio(false);
	cin.tie(0);cout.tie(0);
	cin>>n;
	for(int i=1;i<=n;++i) cin>>c[i];
	for(int i=1,a,b;i<n;++i)
	{
		cin>>a>>b;
		add(a,b),add(b,a);
	}
	dfs1(1,0),dfs2(1,0,1);//预处理重儿子+树上启发式合并
	for(int i=1;i<=n;++i) cout<<ans[i]<<" ";cout<<"\n";
	return 0;
}

CF570D Tree Requests

给定一个以 \(1\) 为根的 \(n\) 个结点的树,每个点上有一个字母(a-z),每个点的深度定义为该节点到 \(1\) 号结点路径上的点数。每次询问 \(a, b\) 查询以 \(a\) 为根的子树内深度为 \(b\) 的结点上的字母重新排列之后是否能构成回文串。(这里的深度指的就是到树根的距离)

需要让某一深度的节点上的字母重新排序之后构成一个回文串,也就是说最多只有一个字母出现的次数是奇数次。对于每个深度每种字母出现的次数,显然是可以由儿子转移到父亲的。于是树上启发式合并,\(t_{i,j}\) 记录当前深度为 \(i\),字符为 \(j\) 的出现次数

对于每一个查询,将其离线到每一个点上,在 dfs 的过程中,如果当前点有一个对于深度为 \(x\) 的询问,判断深度为\(x\)\(26\) 个字母出现的次数是否满足最多只有一个字母出现的次数是奇数次。如果满足就是 YES ,否则为 NO

代码:

#define pii pair<int,int>
#define mk make_pair
using namespace std;
inline int max(int x,int y){return x>y?x:y;}
inline int min(int x,int y){return x>y?y:x;}
const int M=5e5+5;
int n,q,maxson,res,flag;
int ans[M],t[M][26];
string str;
vector<pii> s[M];
 
int cnt=0;
struct N{
	int to,next;
};N p[M<<1];
int head[M];
 
inline void add(int a,int b)
{
	++cnt;
	p[cnt].next=head[a];
	head[a]=cnt;
	p[cnt].to=b;
}
 
int siz[M],son[M],deep[M],maxx[M];
inline void dfs1(int u,int f)
{
	deep[u]=deep[f]+1,siz[u]=1,maxx[u]=deep[u];
	for(int i=head[u];i!=0;i=p[i].next)
	{
		int v=p[i].to;
		if(v==f) continue;
		dfs1(v,u);
		siz[u]+=siz[v],maxx[u]=max(maxx[u],maxx[v]);
		if(siz[v]>siz[son[u]]) son[u]=v;
	}
}
 
inline void solve(int u,int f,int k)
{
	t[deep[u]][str[u]-'a']+=k;//简单的增添贡献
	for(int i=head[u];i!=0;i=p[i].next)
	{
		int v=p[i].to;
		if(v==f||v==maxson) continue;
		solve(v,u,k);
	}
}
 
inline void dfs2(int u,int f,int opt)
{
	for(int i=head[u];i!=0;i=p[i].next)
	{
		int v=p[i].to;
		if(v==son[u]||v==f) continue;
		dfs2(v,u,0);//先处理轻儿子的答案
	}
	if(son[u]) dfs2(son[u],u,1),maxson=son[u];//然后处理重儿子的答案并保留
	solve(u,f,1),maxson=0;
	for(auto it:s[u])//处理当前点上的询问
	{
		res=0,flag=0;
		//要求的深度是it.first
		for(int i=0;i<26;++i)
		{
			if(t[it.first][i]&1) ++flag;
			res+=t[it.first][i]; 
		}
		if(res&1)
		{
			if(flag==1) ans[it.second]=1;
		}
		else
		{
			if(!flag) ans[it.second]=1;
		}
	}
	if(!opt) solve(u,f,-1);
}
signed main()
{
	ios::sync_with_stdio(false);
	cin.tie(0);cout.tie(0);
	cin>>n>>q;
	for(int i=2,x;i<=n;++i) cin>>x,add(x,i);
	cin>>str,str=' '+str;
	dfs1(1,0);//预处理重儿子
	for(int i=1,x,k;i<=q;++i)
	{
		cin>>x>>k;
		if(k<deep[x]||maxx[x]<k) ans[i]=1;//特判
		else s[x].push_back(mk(k,i));//将其离线
	}
	dfs2(1,0,1);
	for(int i=1;i<=q;++i) cout<<(ans[i]?"Yes":"No")<<"\n";
	return 0;
}

CF1009F Dominant Indices

也是板子题,给定一棵以 \(1\) 为根,\(n\) 个节点的树。设 \(d(u,x)\)\(u\) 子树中到 \(u\) 距离为 \(x\) 的节点数。对于每个点,求一个最小的 \(k\),使得 \(d(u,k)\) 最大。

考虑到对于一个点子树内的距离其为 \(x\) 的节点不是很好转移,若一个儿子 \(son_a\) 距离其为 \(2\) 的节点有 \(3\) 个,那么对于 \(a\) 来说,就是距离其为 \(3\) 的节点有 \(3\) 个(与长剖类似啊,需要用到指针维护),这样维护起来就不太方便。于是我们还是转成统计每个节点子树内的深度(也就是到根的距离),那么假如对于节点 \(a\),其子树内深度为 \(d\) 的节点数最多&是最多的里面的最小的那个(满足条件嘛),那么真实求得的 \(k\) 就是 \(d-deep_a\)

所以问题又转化为了统计一个节点子树内出现次数最多的深度,这个显然比较简单,拿桶统计一下,然后在用一个变量 \(pos\) 存一下当前出现次数最多的条件下尽量小的深度,然后点 \(u\)\(k\) 值就是 \(pos-deep_u\)

代码:

const int M=1e6+5;
int n,max_son,maxx,pos;
int t[M],ans[M];
 
int cnt=0;
struct N{
	int to,next;
};N p[M<<1];
int head[M];
inline void add(int a,int b)
{
	++cnt;
	p[cnt].next=head[a];
	head[a]=cnt;
	p[cnt].to=b;
}
 
int siz[M],son[M],deep[M];
inline void dfs1(int u,int f)
{
	siz[u]=1,deep[u]=deep[f]+1;
	for(int i=head[u];i!=0;i=p[i].next)
	{
		int v=p[i].to;
		if(v==f) continue;
		dfs1(v,u);
		siz[u]+=siz[v];
		if(siz[v]>siz[son[u]]) son[u]=v;
	}
}
 
inline void solve(int u,int f,int k)
{
	t[deep[u]]+=k;
	if(t[deep[u]]>maxx) maxx=t[deep[u]],pos=deep[u];
	else if(t[deep[u]]==maxx) pos=min(pos,deep[u]);//处理pos的值
	for(int i=head[u];i!=0;i=p[i].next)
	{
		int v=p[i].to;
		if(v==f||v==max_son) continue;
		solve(v,u,k);
	}
}
 
inline void dfs2(int u,int f,int opt)
{
	for(int i=head[u];i!=0;i=p[i].next)
	{
		int v=p[i].to;
		if(v==f||v==son[u]) continue;
		dfs2(v,u,0); 
	}
	if(son[u]) dfs2(son[u],u,1),max_son=son[u];
	solve(u,f,1);
	max_son=0,ans[u]=pos-deep[u];
	if(!opt) solve(u,f,-1),maxx=pos=0;//一样的,不用管删单点的情况,树上启发式合并要删都是将所有的贡献删完。
}
 
signed main()
{
	ios::sync_with_stdio(false);
	cin.tie(0);cout.tie(0);
	cin>>n;
	for(int i=1,a,b;i<n;++i)
	{
		cin>>a>>b;
		add(a,b),add(b,a);
	}
	dfs1(1,0),dfs2(1,0,1);
	for(int i=1;i<=n;++i) cout<<ans[i]<<"\n";
	return 0;
}

CF375D Tree and Queries

给定一棵 \(n\) 个节点的树,根节点为 \(1\)。每个节点上有一个颜色 \(c_i\)\(m\) 次操作。询问在以 \(u\) 为根的子树中,出现次数 \(\ge k\) 的颜色有多少种。(\(2\le n\le 10^5\)\(1\le m\le 10^5\)\(1\le c_i,k\le 10^5\))。

比较经典,对于每个询问,还是离线到每个点头上。用桶 \(t\) 存一下每个颜色出现的次数,但是这里会出现一个问题,就是询问出现的 \(k\) 值不是固定的,所以我们可能还需要使用一个数组来存一下出现次数超过 \(i\) 的个数,记为 \(pre\) 数组, \(pre_i\) 就表示出现次数超过 \(i\) 的颜色个数,于是在每个点上处理询问的时候就可以 \(O(1)\) 完成了。

另外,这题还是树上莫队的模板题。

代码:

const int M=1e5+5;
int n,q,max_son;
int c[M],t[M],ans[M],pre[M];
vector<pii> s[M];
 
int cnt=0;
struct N{
	int to,next;
};N p[M<<1];
int head[M];
inline void add(int a,int b)
{
	++cnt;
	p[cnt].next=head[a];
	head[a]=cnt;
	p[cnt].to=b;
}
 
int son[M],siz[M];
inline void dfs1(int u,int f)
{
	siz[u]=1;
	for(int i=head[u];i!=0;i=p[i].next)
	{
		int v=p[i].to;
		if(v==f) continue;
		dfs1(v,u);
		siz[u]+=siz[v];
		if(siz[v]>siz[son[u]]) son[u]=v;
	}
}
 
inline void solve(int u,int f,int k)
{
	if(k>0) ++t[c[u]],++pre[t[c[u]]];//修改t数组与pre数组
	else --pre[t[c[u]]],--t[c[u]];
	for(int i=head[u];i!=0;i=p[i].next)
	{
		int v=p[i].to;
		if(v==f||v==max_son) continue;
		solve(v,u,k);
	}
}
 
inline void dfs2(int u,int f,int opt)
{
	for(int i=head[u];i!=0;i=p[i].next)
	{
		int v=p[i].to;
		if(v==f||v==son[u]) continue;
		dfs2(v,u,0);
	}
	if(son[u]) dfs2(son[u],u,1),max_son=son[u];
	solve(u,f,1);
	for(auto it:s[u]) ans[it.second]=pre[it.first];
	max_son=0;
	if(!opt) solve(u,f,-1);
}
 
signed main()
{
	ios::sync_with_stdio(false);
	cin.tie(0);cout.tie(0);
	cin>>n>>q;
	for(int i=1;i<=n;++i) cin>>c[i];
	for(int i=1,a,b;i<n;++i)
	{
		cin>>a>>b;
		add(a,b),add(b,a);
	}
	for(int i=1,x,k;i<=q;++i)
	{
		cin>>x>>k;
		s[x].push_back(mk(k,i));//将询问离线到每个点上
	}
	dfs1(1,0),dfs2(1,0,1);
	for(int i=1;i<=q;++i) cout<<ans[i]<<"\n";
	return 0;
}

CF246E Blood Cousins Return

给定一个森林,点有点权,多次询问某个节点子树内,距离其为某个值的所有点的点权拥有的不同的值。

也很简单喵,假设现在询问的是节点 \(u\) 子树内距离节点 \(u\)\(k\) 的所有点的点权不同值个数,那么还是考虑转化,因为对于单个点的距离不好维护(因为如果点有变动,距离就不一样了,不好转移),于是还是考虑转成真实深度,于是询问的就是 \(u\) 子树内深度为 \(deep_u+k\) 的所有点的点权不同值个数

由于点权是字符串,哈希完了之后不可能值域很小(毕竟如果有 \(2e5\) 个名字的话,值域至少也得 \(1e11\) 以上),所以直接用 map 或者 set 存就是了,这里用 map 啊,对于每一深度都拿一个 map 来记录该深度的点上的字符串,于是对于上面的答案自然就是 \(mapp[deep_u+k].size()\)map 是支持删除一个键值的,非常方便。这样的时间复杂度是 \(O(n \log^2 n)\) 的。

代码:

const int M=2e5+5;
int n,q,maxson;
string str[M];
vector<pii> s[M];
vector<int> t;
map<string,int> mapp[M];
 
int cnt=0;
struct N{
	int to,next;
};N p[M];
int head[M],ans[M];
inline void add(int a,int b)
{
	++cnt;
	p[cnt].next=head[a];
	head[a]=cnt;
	p[cnt].to=b;
}
 
int siz[M],son[M],deep[M];
inline void dfs1(int u,int f)
{
	siz[u]=1,deep[u]=deep[f]+1;
	for(int i=head[u];i!=0;i=p[i].next)
	{
		int v=p[i].to;
		dfs1(v,u);
		siz[u]+=siz[v];
		if(siz[v]>siz[son[u]]) son[u]=v;
	}
}
 
inline void solve(int u,int f,int k)
{
	if(k>0) mapp[deep[u]][str[u]]=1;//map直接统计
	else mapp[deep[u]].erase(str[u]);//map直接删
	for(int i=head[u];i!=0;i=p[i].next)
	{
		int v=p[i].to;
		if(v==maxson) continue;
		solve(v,u,k);
	}
}
 
inline void dfs2(int u,int f,int opt)
{
	for(int i=head[u];i!=0;i=p[i].next)
	{
		int v=p[i].to;
		if(v==son[u]) continue;
		dfs2(v,u,0);
	}
	if(son[u]) dfs2(son[u],u,1),maxson=son[u];
	solve(u,f,1),maxson=0;
	for(auto it:s[u])
	{
		ans[it.second]=mapp[it.first+deep[u]].size();//距离转深度
	}
	if(!opt) solve(u,f,-1);
}
 
signed main()
{
	ios::sync_with_stdio(false);
	cin.tie(0);cout.tie(0);
	cin>>n;
	for(int i=1,x;i<=n;++i)
	{
		cin>>str[i]>>x;
		if(!x) t.push_back(i);
		else add(x,i);
	}
	cin>>q;
	for(int i=1,x,k;i<=q;++i)
	{
		cin>>x>>k;
		s[x].push_back(mk(k,i));//询问离线至每个点
	}
	for(int root:t) dfs1(root,0);
	for(int root:t) dfs2(root,0,0);
	for(int i=1;i<=q;++i) cout<<ans[i]<<"\n";
	return 0;
}

CF1585G Poachers

蛤,我还不会 SG 函数那快退吧 ,咕咕咕了(┭┮﹏┭┮)。

P9886 [ICPC2018 Qingdao R] Kawa Exam

咕一下,这题会补(坐等明早考试时补)。

CF741D Arpa’s letter-marked tree and Mehrdad’s Dokhtar-kosh paths

神仙题,对于一棵根为 \(1\) 的树,每条边上有一个字符(av\(22\) 种)。一条简单路径被称为 \(Dokhtar-kosh\),当且仅当路径上的字符经过重新排序后可以变成一个回文串。求每个子树中最长的 \(Dokhtar-kosh\) 路径的长度。

也是重新排序之后可以变成一个回文串啊,回文串就要求出现次数为奇数的字符最多为 \(1\) 。首先考虑对于 \(u\) 的子树上的一条路径可能是怎么构成的:

  • 可能是从 \(u \to v\)\(v\) 是子树内任意一点。

  • 可能是 \(v \to w\)\(v,w\) 都是子树内任意一点。

现在要求一条满足要求的最长路径,我们发现,如果我们现在已经处理好了每个子树内的答案,那么相当于第二种路径可能,\(v,w\) 两点都在同一儿子的子树内的情况就已经统计完了,于是只用考虑那些 \(v,w\) 在不同儿子子树内的情况啊,而这种情况有一个非常好的性质,这条路径必然过 \(u\)

那么如何记录一条路径的状态,观察到我们只关心某个字符出现次数的奇偶性,且字符集只有 \(22\),那么我们颗可以状态压缩为一个长为 \(22\)\(2\) 进制数,某位上为 \(1\) 就表示这个字符出现了奇数次。那么对于一条路径,若其合法必然是二进制数上只会出现 \(0\)\(1\)\(1\) 。那么我们可以对于每种路径,统计一下其出现的深度的最大值即可。

为了方便,在一开始 dfs 的时候,将边权转为点权,然后做一遍异或前缀和,这样两个点权异或起来就是这两个点的路径异或权值了(因为原本是 \(w_x \oplus w_y \oplus w_{lca_{x,y}} \oplus w_{lca_{x,y}}\),后面两个抵消掉了),于是就简单了,首先统计第一种路径的最大值,然后一颗一颗儿子子树的加进去,先统计,后更改贡献。(具体实现见代码)。

代码:

const int M=5e5+5;
int n;
int ans[M],t[M*20],pre[25];
 
int cnt=0;
struct N{
	int to,next,val;
};N p[M<<1];
int head[M];
inline void add(int a,int b,int c)
{
	++cnt;
	p[cnt].next=head[a];
	head[a]=cnt;
	p[cnt].to=b,p[cnt].val=c;
}
 
int siz[M],c[M],son[M],deep[M];
int dfn[M],id[M],num;
inline void dfs1(int u,int f)
{
	dfn[u]=++num,id[num]=u,siz[u]=1,deep[u]=deep[f]+1;
	for(int i=head[u];i!=0;i=p[i].next)
	{
		int v=p[i].to;
		c[v]=(c[u]^p[i].val);
		dfs1(v,u);
		siz[u]+=siz[v];
		if(siz[v]>siz[son[u]]) son[u]=v;
	}
}
 
inline void dfs2(int u,int f,int opt)
{
	for(int i=head[u];i!=0;i=p[i].next)
	{
		int v=p[i].to;
		if(v==f||v==son[u]) continue;
		dfs2(v,u,0),ans[u]=max(ans[u],ans[v]);
	}
	if(son[u]) dfs2(son[u],u,1),ans[u]=max(ans[u],ans[son[u]]);//儿子们的答案继承
	for(int k=0;k<=22;++k)
	{
		if(t[(c[u]^pre[k])]) ans[u]=max(ans[u],t[(c[u]^pre[k])]-deep[u]);//第一种路径
	}
	t[c[u]]=max(t[c[u]],deep[u]);
	for(int i=head[u],pos;i!=0;i=p[i].next)
	{
		int v=p[i].to;
		if(v==f||v==son[u]) continue;
		for(int j=dfn[v];j<=dfn[v]+siz[v]-1;++j)
		{
			pos=id[j];//当前的这个点
			for(int k=0;k<=22;++k)
			{
				if(t[(c[pos]^pre[k])])
				{
					ans[u]=max(ans[u],t[(c[pos]^pre[k])]+deep[pos]-2*deep[u]);
				}
			}
		}//对于每一颗子树先统计对当前点的答案,然后更新t数组(每种路径的深度最深为多少)
		for(int j=dfn[v];j<=dfn[v]+siz[v]-1;++j)//子树在dfs序中就是一段
		{
			pos=id[j];
			t[c[pos]]=max(t[c[pos]],deep[pos]);
		}
		//这里为和不含重儿子?因为重儿子对t的贡献是保留了的(不然咋叫启发式合并)
		//相当于每一个轻儿子都会和重儿子内的点连接一遍,保证所有路径都被统计到了
	}
	if(!opt)
	{
		for(int i=dfn[u],pos;i<=dfn[u]+siz[u]-1;++i) pos=id[i],t[c[pos]]=0;
	}
}
 
signed main()
{
	ios::sync_with_stdio(false);
	cin.tie(0);cout.tie(0);
	cin>>n,pre[1]=1;
	for(int i=2;i<=22;++i) pre[i]=pre[i-1]*2;//预处理出合法路径的情况(那么一条路径异或上合法路径,找到的另一种路径权值和当前路径异或起来就是合法路径,即x^y=z,x^z=y)
	char opt;
	for(int i=2,x;i<=n;++i) cin>>x>>opt,add(x,i,(1<<(opt-'a')));//先将边权赋上
	dfs1(1,0);//处理重儿子,边权转点权+异或前缀和
	dfs2(1,0,1);//树上启发式合并
	for(int i=1;i<=n;++i) cout<<ans[i]<<" ";
	return 0;
}

CF715C Digit Tree

练习题,思路很简单,但是代码时间有点复杂。

posted @ 2024-11-14 20:34  call_of_silence  阅读(9)  评论(0编辑  收藏  举报