并查集
并查集的各类应用
- 优化暴力枚举。
- Kruskal 重构树。
- 维护可重集。
应用1:优化枚举
例题1
题意
有一个长为 的数组,有 个操作,每个操作是将某个区间按位或上一个数;求最后的数组。。
解法
考虑将每个数拆成 个二进制位维护。显然某一位如果是 ,则之后无论怎样操作都会是 ;不需要再进行考虑。可以在使用并查集维护全为 的连通块;修改时暴力赋值并且合并集合即可。
CF1641C Anonymity Is Important 和 P7219 [JOISC2020] 星座 3 等题也用到了类似合并连通块的方法。
不过这个题显然可以差分。
例题2:P5610 [Ynoi2013] 大学
题意
有一个长为 的序列 ,进行 次操作:
- 将区间 中是 的倍数的数除以 。
- 查询区间 中所有数的和。
强制在线。 。
解法
考虑每个数被除的次数。如果不考虑 的情况,则一个数 最多被除 次。所以如果能快速找到某个询问对应的区间 中 的倍数的数并暴力处理,则时间复杂度是正确的。
考虑使用集合维护每个涉及的数的倍数。如果某个 有对应的因数 ,则将 插入到 对应的集合中(值域很小,可以直接建 个集合,值域大则需要处理所有因数并离散化)。处理某个区间除的操作 时,可以二分出 对应的集合内的所有数。
依次处理这些数,如果某个数被除之后不再有因数 ,则将该数删除。注意由于某个数被其某个因数除后,不会有新的因数,故这些集合没有插入某个数的操作,只会删除某个数。至于查询区间和,可以使用树状数组处理前缀和,然后每个数被除后再在树状数组对应位置单点减即可。
理论上需要在其他一些集合中也要删掉这个数;但是这需要比较某个数被除之前和之后的因数,单次时间复杂度较高。所以可以在处理这个询问时,如果这个数因为在之前某些询问中除过,导致这个数不再有 因数,则直接删去这个数,这样删除的次数不超过 (其中 为值域, 为 的因数个数,,下同)。
常数方面:
-
在二分查找时,如果维护集合时使用的是平衡树,则可以直接二分出集合中的所有数,但是这会导致常数大(原题时限 500 ms);
而如果使用并查集,则只能在最开始 对应的集合的所有下标中二分,但是常数和空间复杂度很小。
使用并查集时注意:删去某个数可以把该数的并查集与 最开始的集合中 这个数的下一个数的集合合并(这是并查集中删除元素的一个好方法),并且需要 顺次合并,保证一个集合对应的区间的最右端元素总是这个集合的代表元素;同时在开始的并查集中必须只能维护开始的集合内的数,每个数对应一个集合(这样的空间复杂度为 ,否则空间复杂度为 ),某个数的真实后继就是这个数在原集合中的后继所在的并查集的代表元素。
-
由于常数,最好使用内存池维护并查集而不要用
std::vector
。具体地,预先开好一个大数组,然后每个数组对应一个指针,某个指针和下一个指针之间的间隔就是这个指针对应的数组的大小。 -
二分查找得出询问区间对应的并查集区间可以只用二分左端点,再依次向右扫描并处理。
-
询问某个数的因数可以用
(安利)这种方法。 -
尽量少直接用
long long
,实际上只需要在读入处理、输出答案和树状数组处用long long
。
这些常数优化使得下述代码不开 O2 最坏 495 ms(有更好的常数优化请在评论区指出)。
代码
点此查看代码
#include <bits/stdc++.h> using namespace std; namespace FastIO{ const int MXBUF=1<<21; const int BRDBUF=MXBUF-1; char bufr[MXBUF],*_head,*_tail; inline char GetChar(){ if(_head!=_tail) goto End; _head=_tail=bufr; _tail+=fread(bufr,1,MXBUF,stdin); if(_head!=_tail) goto End; return EOF; End:return *(_head++); } int wr=-1; char bufw[MXBUF]; inline void Flush(){ fwrite(bufw,1,wr+1,stdout); wr=-1; } inline void PutChar(const char c){ if(wr==BRDBUF) Flush(); bufw[++wr]=c; } inline void Readi(int &a){ static char ch; a=0; ch=GetChar(); while((ch^'0')>9){ if(ch==EOF) return; ch=GetChar(); } while((ch^'0')<10){ a=a*10+(ch^'0'); ch=GetChar(); } } inline void Readl(long long &a){ static char ch; a=0; ch=GetChar(); while((ch^'0')>9){ if(ch==EOF) return; ch=GetChar(); } while((ch^'0')<10){ a=a*10+(ch^'0'); ch=GetChar(); } } inline void Write(long long a){ static char BUF[130]; static int top; top=-1; do{BUF[++top]=(a%10)|'0';}while(a/=10); do{PutChar(BUF[top]);}while(top--); PutChar(10); } } using namespace FastIO; #define ll long long const int maxd=210; const int maxn=100010; const int maxv=500010; int n,m,i,j,l,r,w,o; int a[maxn],v[maxv],pr[maxv]; ll t,ld,rd,wd,c[maxn]; inline void add(int p,int d){ for(;p<=n;p+=(p&-p)) c[p]+=d; } inline ll query(int p){ ll rt=0; for(;p;p^=(p&-p)) rt+=c[p]; return rt; } int siz[maxv],*fa[maxv],*id[maxv],*pt,*px; int Find(int p){ if(px[p]==p) return p; return px[p]=Find(px[p]); } int pf[maxn*maxd],pi[maxn*maxd]; void dfs(int x,int q){ if(x==1) return; int tv=v[x],ct=1,tq=q; while(v[x]==tv) x/=tv,++ct; while(ct--){ if(q!=tq){ fa[q][siz[q]]=siz[q]; id[q][siz[q]++]=i; } dfs(x,q); q*=tv; } } int main(){ for(i=2;i<maxv;++i){ if(!v[i]) v[i]=pr[++w]=i; for(j=1;j<=w;++j){ if(v[i]<pr[j]||i*pr[j]>=maxv) break; v[i*pr[j]]=pr[j]; } } w=0; Readi(n); Readi(m); for(i=1;i<=n;++i){ Readi(r); if(!r) continue; ++siz[r]; add(i,r); if(r>w) w=r; a[i]=r; } fa[2]=pf; id[2]=pi; for(i=2;i<w;++i){ ++siz[i]; for(j=(i<<1);j<=w;j+=i) siz[i]+=siz[j]; fa[i+1]=fa[i]+siz[i]; id[i+1]=id[i]+siz[i]; siz[i]=0; } for(i=1;i<=n;++i) if(a[i]>1) dfs(a[i],1); for(i=2;i<=w;++i) fa[i][siz[i]]=siz[i]; while(m--){ Readi(o); Readl(ld); Readl(rd); l=ld^t; r=rd^t; if(o==1){ Readl(wd); w=wd^t; if(!fa[w]) continue; pt=id[w]; px=fa[w]; l=Find(lower_bound(pt,pt+siz[w],l)-pt); for(;;){ i=pt[l]; if(i>r||(!i)) break; if(!(a[i]%w)){ a[i]/=w; add(i,(1-w)*a[i]); } if(a[i]%w) px[l]=Find(l+1); l=Find(l+1); } } else Write(t=query(r)-query(l-1)); } Flush(); return 0; }
应用2:Kruskal 重构树
定义 & 构造
在使用 Kruskal 算法生成某张有 个点的无向图的最小生成树时,进行如下过程:
建立一张新的无向图 ,每个节点代表最小生成树形成过程中,由选中的边构成的图的连通块对应的点集。最开始 Kruskal 算法没有选中任何边;则最开始 只有 个节点,每个节点代表原图的一个节点,点权为 。
在依次加边的过程中,如果某次加入的边权为 的边连通了之前的连通块 ;则在 中新建一个节点 代表 合并后的连通块,将 代表的节点和 连边, 点点权赋为 。具体实现可以用并查集标记每个点所在连通块在 对应的节点的编号。
最后 为拥有 个节点的二叉树的形态,称 为原图的 Kruskal 重构树。
性质
Kruskal 重构树的叶子节点恰有 个,每个非叶子节点恰有两个子节点。
由 Kruskal 算法的执行过程/最小生成树同时是最小瓶颈生成树可得:两个节点 和 在原图中的所有简单路径上的最大边权最小值 = 和 在 Kruskal 重构树上的 LCA 的点权。
同理,从 出发只经过边权不小于 的边所能到达的点集为 在 Kruskal 重构树上的点权不小于 的最浅祖先的子树的所有叶子节点,这个祖先在实现中可以用倍增查询。由此可以获得从某个点只走边权不大于某个数的边能走到的点集的性质。
例题:P4768 [NOI2018] 归程
题意
有一张 个点 条边的无向图,第 条边有边权 。 组询问,第 组询问给出点 和权值 ,询问如果只经过 值大于 的边,最终到达的节点与 号节点的最短距离( 为每条边的长度)。
强制在线。。
解法
解法参考 Kruskal 重构树的第三条性质,只需要额外预处理 号点到每个节点的最短路即可。关于 SPFA,它死了。
同时也有可持久化并查集做法,将 排序去重后对每个可能的 合并对应的点集,在集合代表元素处维护到 号点的最短路的最小值。不过空间复杂度更大并且代码较长。
Kruskal 重构树代码很简单。
代码
点此查看代码
#include <bits/stdc++.h> using namespace std; const int maxl=19; const int maxn=200010; const int maxm=400010; int t,n,m,u,v,w,s,i,j,p,qr,tot=-1; long long k; int h[maxn],fa[maxn],dis[maxm],anc[maxn],val[maxm]; int ft[maxl][maxm]; bool vis[maxn]; struct edge{ int to,nxt,ed,we,frm; inline bool operator <(const edge &a)const{return we>a.we;} }E[maxm<<1]; priority_queue<pair<int,int> > q; int Find(int pt){ if(pt==fa[pt]) return pt; return fa[pt]=Find(fa[pt]); } int main(){ scanf("%d",&t); while(t--){ scanf("%d%d",&n,&m); for(i=1;i<=n;++i){ dis[i]=2147483647; h[i]=-1; fa[i]=anc[i]=i; val[i]=vis[i]=0; } while(m--){ scanf("%d%d%d%d",&u,&v,&w,&s); E[++tot]={v,h[u],w,s,u};h[u]=tot; E[++tot]={u,h[v],w,s,v};h[v]=tot; } q.push(make_pair(dis[1]=0,1)); while(!q.empty()){ u=q.top().second; q.pop(); if(vis[u]) continue; vis[u]=1; for(i=h[u];~i;i=E[i].nxt){ v=E[i].to; w=E[i].ed; if(dis[v]>dis[u]+w){ dis[v]=dis[u]+w; q.push(make_pair(-dis[v],v)); } } } sort(E,E+tot+1); for(i=0;i<=tot;++i){ u=Find(E[i].to); v=Find(E[i].frm); if(u!=v){ fa[u]=v; ft[0][anc[u]]=ft[0][anc[v]]=++n; val[n]=E[i].we; dis[n]=min(dis[anc[u]],dis[anc[v]]); anc[v]=n; } } for(j=1;j<maxl;++j) for(i=1;i<=n;++i) ft[j][i]=ft[j-1][ft[j-1][i]]; n=(n+1)>>1; w=0; scanf("%d%lld%d",&qr,&k,&s); while(qr--){ scanf("%d%d",&u,&p); u=(k*w+u-1)%n+1; p=(k*w+p)%(s+1); for(j=maxl-1;j>=0;--j) if(val[ft[j][u]]>p) u=ft[j][u]; printf("%d\n",w=dis[u]); } tot=-1; } return 0; }
应用:可撤销并查集
例题:P7518 [省选联考 2021 A/B 卷] 宝石
题意
有一棵大小为 的树,第 个点的点权为 ,所有点权为 之间的正整数。 组询问,第 组询问给出一条简单路径 ,求这条路径上的所有点权组成的序列中,与给定长为 的序列 的某个前缀相同的最长子序列长度。 保证 中的数两两不同。
解法
考虑把 的查询分成两部分:(可以包括 使得查询变为 ,因为 内任意两数不相同)和 。
首先考虑从某个节点 到祖先节点 的链如何匹配 。由于 内任意两数不相同,则匹配到某个数之后的下一个数是固定的。令 (同时 ,如果 没有出现在 中则 ),则在匹配到某个数 之后需要匹配 。令 点的最深的、 值为 的祖先为 ,最深的、 值为 的祖先为 (可以为 本身);则可以从 开始先跳一次 开始匹配,然后一直跳 ,直到再跳一次会跳到 以上或匹配到 ,则可以确定 最多能够匹配多少位。对应在实现中,记第 次查询为 ,其中 匹配了 位,则可以将 维护在 上,方便从 处向下匹配。
然后考虑从某个节点 到后代节点 的链如何匹配 。注意此时不一定从 开始匹配,故在 点维护的查询需要同时维护当前匹配的位数 ,然后在 dfs 一遍时向下匹配即可。注意需要在 处维护表示“某个询问的链在此处截止”的标记。但是题目中可能出现多条链重合的情况,此时需要对每一个满足匹配到了某一位的查询的信息进行更新。此时可以维护匹配到某个 的数的查询集合,记为 。如果从 开始有某个上述的 ,则需要将 询问分到 中;如果查询到了某个 值为 的节点,则需要将整个 的元素分到 中(然后 为空,方便区分之后加入的/匹配到的目前匹配了 位的询问);在某个节点如果查到表示“某个询问对应的链在此结束”的标记,则查询这个询问所在的集合;最后从某个节点回溯时,需要将原先分到的 的查询分回 ,同时需要将这个节点维护的所有 对应的询问移除原加入的集合。这里可以使用可撤销并查集模拟这个过程,将每个 的下标维护在集合代表元素上。
思路讲得较为复杂,但是代码较为简单,测完大样例基本不需要再怎么修改就可以 100 pts 了。
代码
点此查看代码
#include <bits/stdc++.h> using namespace std; const int maxl=19; const int maxm=50010; const int maxn=200010; int n,m,c,i,j,u,v,w,a,q,st,tot; int cnt[maxm],toc[maxm],rt[maxm],col[maxn]; int h[maxn],fa[maxl][maxn],nxt[maxl][maxn]; int fst[maxn],dep[maxn],ft[maxn]; int siz[maxn],cot[maxn],ans[maxn]; struct edge{int to,nxt;}E[maxn<<1]; struct node{int id,col;}; vector<node> que[maxn]; vector<int> edn[maxn]; void dfsl(int p,int f){ int lp,to,ct=col[p]; int pr=rt[ct]; rt[ct]=p; fst[p]=rt[st]; fa[0][p]=f; nxt[0][p]=rt[toc[ct]]; for(lp=h[p];lp;lp=E[lp].nxt){ to=E[lp].to; if(to==f) continue; dep[to]=dep[p]+1; dfsl(to,p); } rt[ct]=pr; } inline int lca(int x,int y){ if(dep[x]<dep[y]) swap(x,y); for(j=maxl-1;j>=0;--j) if(dep[fa[j][x]]>=dep[y]) x=fa[j][x]; if(x==y) return x; for(j=maxl-1;j>=0;--j) if(fa[j][x]!=fa[j][y]) x=fa[j][x],y=fa[j][y]; return fa[0][x]; } void dfsa(int p,int f){ for(node t:que[p]){ u=rt[t.col]; if(u){ ft[t.id]=u; ++siz[u]; } else{ rt[t.col]=t.id; cot[t.id]=t.col; } } int x=rt[col[p]],y=rt[toc[col[p]]]; if(x){ rt[col[p]]=0; if(y){ cot[x]=0; if(siz[x]<siz[y]){ ft[x]=y; siz[y]+=siz[x]; } else{ ft[y]=x; siz[x]+=siz[y]; cot[x]=toc[col[p]]; rt[toc[col[p]]]=x; } } else{ cot[x]=toc[col[p]]; rt[toc[col[p]]]=x; } } int lp,to; for(int t:edn[p]){ to=t; while(to!=ft[to]) to=ft[to]; ans[t]=cnt[cot[to]]-1; } for(lp=h[p];lp;lp=E[lp].nxt){ to=E[lp].to; if(to==f) continue; dfsa(to,p); } if(x){ cot[x]=col[p]; rt[col[p]]=x; if(y){ if(ft[x]!=x){ ft[x]=x; siz[y]-=siz[x]; } else{ ft[y]=y; siz[x]-=siz[y]; rt[toc[col[p]]]=y; } } else rt[toc[col[p]]]=0; } for(node t:que[p]){ u=rt[t.col]; if(!u) continue; if(t.id!=u){ ft[t.id]=t.id; --siz[u]; } else rt[t.col]=0; } } int main(){ scanf("%d%d%d%d",&n,&m,&c,&u); cnt[u]=1; st=u; for(i=2;i<=c;++i){ scanf("%d",&v); toc[u]=v; u=v; cnt[u]=i; } cnt[0]=c+1; for(i=1;i<=n;++i) scanf("%d",col+i); for(i=1;i<n;++i){ scanf("%d%d",&u,&v); E[++tot]={v,h[u]}; h[u]=tot; E[++tot]={u,h[v]}; h[v]=tot; } dep[0]=-1; dfsl(1,0); for(i=1;i<maxl;++i){ for(j=1;j<=n;++j){ fa[i][j]=fa[i-1][fa[i-1][j]]; nxt[i][j]=nxt[i-1][nxt[i-1][j]]; } } scanf("%d",&q); for(i=1;i<=q;++i){ scanf("%d%d",&u,&v); w=lca(u,v); u=fst[u]; a=st; if(dep[u]>=dep[w]){ for(j=maxl-1;j>=0;--j) if(dep[nxt[j][u]]>=dep[w]) a=col[u=nxt[j][u]]; if(!toc[a]) ans[i]=c; else{ que[w].push_back((node){i,toc[a]}); edn[v].push_back(i); } } else{ que[w].push_back((node){i,st}); edn[v].push_back(i); } siz[i]=1; ft[i]=i; } dfsa(1,0); for(i=1;i<=q;++i) printf("%d\n",ans[i]); }
点击点赞为 Fran-Cen 助力!
本文来自博客园,作者:Fran-Cen,转载请注明原文链接:https://www.cnblogs.com/Fran-CENSORED-Cwoi/p/16751585.html
【推荐】国内首个AI IDE,深度理解中文开发场景,立即下载体验Trae
【推荐】编程新体验,更懂你的AI,立即体验豆包MarsCode编程助手
【推荐】抖音旗下AI助手豆包,你的智能百科全书,全免费不限次数
【推荐】轻量又高性能的 SSH 工具 IShell:AI 加持,快人一步
· 分享一个免费、快速、无限量使用的满血 DeepSeek R1 模型,支持深度思考和联网搜索!
· 基于 Docker 搭建 FRP 内网穿透开源项目(很简单哒)
· ollama系列1:轻松3步本地部署deepseek,普通电脑可用
· 按钮权限的设计及实现
· 【杂谈】分布式事务——高大上的无用知识?