(笔记)树上启发式合并 DSU on tree
这是一种处理树上操作较为常见的 trick。最经典的应用莫过于并查集的按秩合并,其中包括按 \(maxdep\) 和按子树 \(siz\) 合并两种方法。此外,如果遇到一些在搜索过程中每个子树需要处理 \(O(siz)\) 个节点的合并式计数,也可以采用树上启发式合并,将 \(O(n^2)\) 的合并操作变成 \(O(n\log n)\)的。
这样为什么是对的?
我们给出以并查集 \(siz\) 为例的按秩合并时间证明。该合并的方法是假设有树根 \(a,b\),钦定 \(siz[a]<siz[b]\),那么就将 \(a\) 接在 \(b\) 上,反之亦然。
并查集的时间复杂度主要取决于树高,每次衔接会将 \(a\) 的子树内所有节点的 \(dep\) 增加 \(1\)。贪心地想,如果想要让一个节点的 \(dep\) 尽可能大,接上别的父亲的次数就要尽可能大。根据 \(siz_a<siz_b\),进行一次这样的操作,首先需要有一个 \(siz_b>siz_a\),合并后的 \(siz_{merge(a,b)}=siz_a+siz_b>2siz_a\)。对于子树内单个节点 \(u\) 来说,它所处的联通块在每次合并至少变为两倍。要让 \(dep_u\) 一直增加,就要不断地构造新的比它所在联通块大的联通块。考虑到构造一个大小为 \(n\) 的联通块的代价至少为 \(n-1\)(连边),那么使 \(dep_u+1\) 的代价(\(cost_i\))就是指数增长的(\(cost_i\geq 2cost_{i-1}\)),\(m\) 次操作最多有可能使 \(u\) 的深度变为 \(\log m\)。
又因为并查集有效连边最多有 \(n-1\) 次,相当于 \(n-1\) 次操作,那么这样合并树高就是 \(O(\log n)\) 的。
类似地,这样分析时间复杂度时,如果每次操作需要遍历较小的那个子树(耗时 \(O(siz)\),或者对于并查集来说,将 \(O(siz)\) 个节点的深度 \(+1\)),那么也可以采用这样按秩合并的方法,将总时间降到 \(O(n\log n)\),具体证明思路是大差不差的。
典型例题
P8907 [USACO22DEC] Making Friends P
本题难点在于需要维护 \(u\) 及其所有邻居的相互连边,我们可以将其视作 \(u\) 的邻居集合 \((v_1,v_2,...,v_k)\) 中 \(v_i\) 向其所有后缀连边构成的结果,考虑到一条边可以做出贡献影响操作当且仅当遍历到这条边两个端点的任意一个,那么在没有遍历到之前就不管这条边就行。所以在遍历到 \(v_2\) 之前,\(v_2\) 与后续点的连边是没有影响的,因此我们每次将邻居集合中 \(v_1\to v_2,v_3,...,v_k\),可以递推地发现当 \(u\leftarrow v_1\) 时就会继续连接 \(v_2\to v_3,...,v_k\),这样就解决了构造的问题。
实现上我们将邻接表的存图结构变成可以自动去重的 STL set,每次相当于邻居集合中把 \(v_1\) 丢掉然后合并 \(v_1\) 和 \(u\) 的邻居集合,利用按秩合并,每次选较小的一个集合把里面东西抽出来分别加入较大的集合,这样就优化到了 \(O(m\log n)\)。
#include<bits/stdc++.h>
using namespace std;
const int N=2e5+5;
int n,m;
set<int>s[N];
int main(){
scanf("%d%d",&n,&m);
for(int i=1;i<=m;i++){
int u,v;scanf("%d%d",&u,&v);
s[min(u,v)].insert(max(u,v));
}
long long ans=-m;
for(int i=1;i<=n;i++){
ans+=s[i].size();
if(!s[i].size())continue;
int u=*s[i].begin(),v=i;
s[v].erase(s[v].begin());
if(s[v].size()>s[u].size())swap(s[u],s[v]);
for(int i:s[v])s[u].insert(i);
}
printf("%lld",ans);
return 0;
}
CF1709E XOR Tree
本题的思路很好想,可以由类似点分治的思想入手,然后逐步推到正常做法。考虑到一棵以 \(u\) 为根的子树,如果其内部存在通过 \(u\) 的路径异或和为 \(0\),那么只要将 \(a_u\) 置为一个非常大的 \(2^x\) 就可以保证这些路径异或和都不为 \(0\)。又考虑到如果先断父亲再断儿子可能会使答案更劣(可以不用断父亲),那么就先做儿子。这样就可以对每一个节点写一个 set,记录以它为根子树内所有节点到真正的根节点的路径异或和,对于每个节点 \(u,v\),在 \(LCA(u,v)\) 处处理它们的信息,总时间复杂度 \(O(n^2\log n)\)。
优化采用 DSU on tree。考虑到我们的实际处理过程就是不停地遍历一个 set(不妨叫 set1),然后把 set1 与它父亲的 set(不妨叫 set2)合并,时间消耗主要取决于 set1 的大小。本题中 set1 和 set2 的合并顺序是不重要的,因此可以每次选取较小的那个合并到大的上面。根据我们上面的推论,这样可以将 \(O(n^2)\) 的合并变为 \(O(n\log n)\),那么总的时间就由 \(O(n^2\log n)\) 变为了 \(O(n\log^2 n)\)。
#include<bits/stdc++.h>
using namespace std;
const int N=2e5+5;
vector<int>G[N];
int ans,dis[N],a[N],n;
set<int>s[N];
void dfs(int u,int fa){
dis[u]=dis[fa]^a[u];
s[u].insert(dis[u]);
bool tf=0;
for(int v:G[u]){
if(v==fa)continue;
dfs(v,u);
if(s[u].size()<s[v].size())swap(s[u],s[v]);
for(int p:s[v])
if(s[u].find(p^a[u])!=s[u].end())tf=1;
for(int p:s[v])
s[u].insert(p);
}
if(tf){
s[u].clear();
ans++;
}
}
int main(){
ios::sync_with_stdio(0);
cin.tie(0);cout.tie(0);
cin>>n;
for(int i=1;i<=n;i++)
cin>>a[i];
for(int i=1;i<n;i++){
int u,v;cin>>u>>v;
G[u].push_back(v);
G[v].push_back(u);
}
dfs(1,0);
cout<<ans;
return 0;
}
P9233 [蓝桥杯 2023 省 A] 颜色平衡树
题解 P9233【[蓝桥杯 2023 省 A] 颜色平衡树】
该题解详细说明了树上启发式合并的应用方法。这里给了我们一种新的思路,每次只需要寻找 \(u\) 的重儿子保存它的子树信息以备后用,然后暴力做 \(u\) 的轻儿子的子树信息。一个节点 \(v\) 会被遍历的情况当且仅当存在一个节点 \(u\) 是 \(v\) 的若干级祖先且 \(u\) 是一个轻儿子。逐层向下考虑,每一次轻儿子的出现势必让该子树的大小至少变为原来的 \(\frac{1}{2}\)(要不然怎么叫轻儿子),这样最多会出现 \(\log n\) 个轻儿子,那么总的时间就是 \(O(n\log n)\) 了。
点击查看代码
#include<bits/stdc++.h>
using namespace std;
const int N=2e5+5;
int n,C[N],ans,siz[N],son[N];
int buk[N],cntc[N];
vector<int>G[N];
void dfs0(int u){
siz[u]=1;
for(int v:G[u]){
dfs0(v);
siz[u]+=siz[v];
if(siz[v]>siz[son[u]])
son[u]=v;
}
}
inline void add(int u,int d){
cntc[buk[C[u]]]--;
buk[C[u]]+=d;
cntc[buk[C[u]]]++;
for(int v:G[u])
add(v,d);
}
void dfs1(int u,int fa){
for(int v:G[u]){
if(v==son[u])continue;
dfs1(v,u);
}
if(son[u])dfs1(son[u],u);
cntc[buk[C[u]]]--;
buk[C[u]]++;
cntc[buk[C[u]]]++;
for(int v:G[u]){
if(v==son[u])continue;
add(v,1);
}
if(cntc[buk[C[u]]]*buk[C[u]]==siz[u])ans++;
if(son[fa]!=u)add(u,-1);
}
int main(){
ios::sync_with_stdio(0);
cin.tie(0);cout.tie(0);
cin>>n;
for(int i=1;i<=n;i++){
int f;
cin>>C[i]>>f;
if(f)G[f].push_back(i);
}
dfs0(1);
dfs1(1,0);
cout<<ans;
return 0;
}
P9886 [ICPC 2018 Qingdao R] Kawa Exam
题解:P9886 [ICPC 2018 Qingdao R] Kawa Exam
本题区别于上一题除了需要跑一遍 EDCC 以外,还有需要维护除了子树外的所有节点的信息。这里我们在每次 dfs 完成后都不删除信息,对于轻儿子先暴力全部加然后跑重儿子,这之后存储的信息就是全集。接着对于每个轻儿子,先暴力把它们的信息分别删掉,然后 dfs,再把信息加回来
DSU on tree 需要弄清楚维护的信息以及信息的递归关系,最重要的是 dfs 完以后是否需要根据需求(如自己是否是父亲的轻儿子) 删掉信息,然后再进行转移。
本题调试总计耗时近 3h。
点击查看代码
#include<bits/stdc++.h>
using namespace std;
const int N=1e5+5;
int T,n,m;
struct Edge{int u,v,next;};
struct Graph{
int head[N],idx=1;
Edge e[N<<1];
void init(int siz){
idx=1;
for(int i=1;i<=siz;i++)
head[i]=0;
}
void ins(int x,int y){
e[++idx].v=y;
e[idx].u=x;
e[idx].next=head[x];
head[x]=idx;
}
}G1,G2;
int low[N],dfn[N],tms,stk[N],tp;
int ecnt,bel[N],a[N],ans;
vector<int>edcc[N],col[N];
bool isc[N<<1],vis[N];
void tarjan(int u,int fr){
dfn[u]=low[u]=++tms;
stk[++tp]=u;
for(int i=G1.head[u];i;i=G1.e[i].next){
int v=G1.e[i].v;
if(i==(fr^1))continue;
if(!dfn[v]){
tarjan(v,i);
low[u]=min(low[u],low[v]);
if(low[v]>dfn[u]){
isc[i]=isc[i^1]=1;
}
}
else low[u]=min(low[u],dfn[v]);
}
if(dfn[u]==low[u]){
ecnt++;
edcc[ecnt].clear();
col[ecnt].clear();
while(stk[tp]!=u){
edcc[ecnt].push_back(stk[tp]);
col[ecnt].push_back(a[stk[tp]]);
bel[stk[tp--]]=ecnt;
}
edcc[ecnt].push_back(stk[tp]);
col[ecnt].push_back(a[stk[tp]]);
bel[stk[tp--]]=ecnt;
}
}
int son[N],siz[N],pans[N],fat[N];
int cnt[N],ct[N],mx,root[N],rt;
void dfs0(int u,int fa){
siz[u]=1;fat[u]=fa;son[u]=0;
vis[u]=1;
for(int i=G2.head[u];i;i=G2.e[i].next){
int v=G2.e[i].v;
if(v==fa)continue;
dfs0(v,u);
siz[u]+=siz[v];
if(siz[v]>siz[son[u]])
son[u]=v;
}
}
void radd(int u,int d){
for(int v:col[u]){
ct[cnt[v]]--;
cnt[v]+=d;
ct[cnt[v]]++;
mx=max(mx,cnt[v]);
while(!ct[mx]&&mx>0)mx--;
}
}
void add(int u,int fa,int d){
radd(u,d);
for(int i=G2.head[u];i;i=G2.e[i].next){
int v=G2.e[i].v;
if(v==fa)continue;
add(v,u,d);
}
}
void dfs1(int u,int fa){
for(int i=G2.head[u];i;i=G2.e[i].next){
int v=G2.e[i].v;
if(v==fa||v==son[u])continue;
dfs1(v,u);
}
if(son[u])dfs1(son[u],u);
radd(u,1);
for(int i=G2.head[u];i;i=G2.e[i].next){
int v=G2.e[i].v;
if(v==fa||v==son[u])continue;
add(v,u,1);
}
pans[u]=mx;
if(son[fa]!=u)add(u,fa,-1);
}
void dfs2(int u,int fa){
root[u]=rt;
pans[u]+=mx;
for(int i=G2.head[u];i;i=G2.e[i].next){
int v=G2.e[i].v;
if(v==fa||v==son[u])continue;
add(v,u,1);
}radd(u,1);
if(son[u])dfs2(son[u],u);
for(int i=G2.head[u];i;i=G2.e[i].next){
int v=G2.e[i].v;
if(v==fa||v==son[u])continue;
add(v,u,-1);
dfs2(v,u);
}
}
int fa[N];
inline int fr(int x){
if(fa[x]==x)return x;
return fa[x]=fr(fa[x]);
}
bool ins(int x,int y){
int frx=fr(x),fry=fr(y);
if(frx==fry)return 0;
fa[frx]=fry;
return 1;
}
int main(){
ios::sync_with_stdio(0);
cin.tie(0);cout.tie(0);
cin>>T;
while(T--){
cin>>n>>m;
tp=0;ecnt=0;tms=0;ans=0;
G1.init(n);
for(int i=1;i<=n;i++)
cin>>a[i],dfn[i]=low[i]=bel[i]=0;
for(int i=1;i<=m;i++){
isc[i*2]=isc[(i*2)^1]=0;
int u,v;cin>>u>>v;
G1.ins(u,v);
G1.ins(v,u);
}
for(int i=1;i<=n;i++)
if(!dfn[i])
tarjan(i,0);
G2.init(ecnt);
for(int i=1;i<=ecnt;i++)vis[i]=0,fa[i]=i;
for(int i=2;i<=2*m+1;i++){
if(isc[i]&&ins(bel[G1.e[i].u],bel[G1.e[i].v]))
G2.ins(bel[G1.e[i].u],bel[G1.e[i].v]),
G2.ins(bel[G1.e[i].v],bel[G1.e[i].u]);
}
for(int i=1;i<=ecnt;i++)
if(!vis[i]){
rt=i,dfs0(i,0),dfs1(i,0),
dfs2(i,0),ans+=pans[rt];
add(i,0,-1);
}
for(int i=1;i<=m;i++){
int u=bel[G1.e[i*2].u];
int v=bel[G1.e[i*2].v];
if(u==v){cout<<ans;if(i<m)cout<<' ';continue;}
if(fat[u]==v)swap(u,v);
cout<<ans-pans[root[v]]+pans[v];
if(i<m)cout<<' ';
}
cout<<'\n';
}
return 0;
}
P10875 [COTS 2022] 游戏 M
容易想到整体二分,可以使用并查集动态维护边双。然而 \(\log^2\) 整体消耗比较大,考虑直接顺序做加边过程。同样先跑出 MST 然后非树边链覆盖,然后对于每个边双存储其一个端点在边双内的询问,每次合并 DSU 回答即可。由于每个询问被合并次数是 \(\log\) 的,认为所有同阶情况下总共 \(O(n\log n)\)。
点击查看代码
#include<bits/stdc++.h>
using namespace std;
typedef long long LL;
typedef pair<int,int> PII;
const int N=3e5+5;
int n,m,fa[N],cnt,f[N][20],dep[N],ans[N],rpd[N];
vector<PII>vec[N];
struct Edge{int u,v,id;}e[N];
inline int fr(int x){return fa[x]==x?x:fa[x]=fr(fa[x]);}
inline bool ins(int x,int y){
int frx=fr(x),fry=fr(y);
if(frx==fry)return 0;
fa[frx]=fry;return 1;
}
vector<int>G[N];
void init(){
for(int i=1;i<=n;i++)
rpd[i]=fr(i);
for(int i=1;i<=n;i++)
fa[i]=i;
}
void dfs(int u,int fa){
dep[u]=dep[fa]+1;
for(int v:G[u]){
if(v==fa)continue;
f[v][0]=u;
for(int j=1;(1<<j)<=dep[u];j++)
f[v][j]=f[f[v][j-1]][j-1];
dfs(v,u);
}
}
inline int LCA(int x,int y){
if(dep[x]<dep[y])swap(x,y);
for(int i=18;i>=0;i--)
if(dep[f[x][i]]>=dep[y])
x=f[x][i];
if(x==y)return y;
for(int i=18;i>=0;i--)
if(f[x][i]!=f[y][i])
x=f[x][i],y=f[y][i];
return f[x][0];
}
inline void merge(int u,int v,int k){
u=fr(u),v=fr(v);bool tf=0;
if(vec[u].size()>vec[v].size())swap(u,v),tf=1;
for(PII i:vec[u]){
int to=i.first,id=i.second;
if(fr(to)==fr(v))ans[id]=k;
else vec[v].emplace_back(i);
}
vec[u].clear();
if(tf)swap(vec[u],vec[v]);
}
inline void crawl(int u,int edp,int k){
u=fr(u);
while(dep[u]>dep[edp]){
merge(u,f[u][0],k);
ins(u,f[u][0]);
u=fr(u);
}
}
int main(){
ios::sync_with_stdio(0);
cin.tie(0);cout.tie(0);
cin>>n>>m;init();
for(int i=1;i<=m;i++){
int u,v;cin>>u>>v;
if(ins(u,v)){
G[u].emplace_back(v);
G[v].emplace_back(u);
}
else e[++cnt]=Edge{u,v,i};
}
int qn;cin>>qn;
for(int i=1;i<=qn;i++){
int u,v;cin>>u>>v;ans[i]=-1;
vec[u].emplace_back(make_pair(v,i));
vec[v].emplace_back(make_pair(u,i));
}
for(int i=1;i<=n;i++)
if(!f[i][0])dfs(i,0);
init();
for(int i=1;i<=cnt;i++){
if(rpd[e[i].u]==rpd[e[i].v]&&fr(e[i].u)!=fr(e[i].v)){
int u=e[i].u,v=e[i].v,lc=LCA(u,v);
crawl(u,lc,e[i].id),crawl(v,lc,e[i].id);
}
}
for(int i=1;i<=qn;i++)
cout<<ans[i]<<'\n';
return 0;
}

浙公网安备 33010602011771号