浅谈 OI 中各种合并操作
前言
合并操作一直是 OI 中一大考点,今天请各位跟着笔者来梳理一下各种合并操作。
启发式合并
几乎可以说是最经典的合并了。
假定我们可以在 \(O(k)\) 的时间内往某个集合中插入一个数,那么我们就可以在 \(O(n \log n k)\) 的时间内合并若干个元素总量为 \(n\) 的集合。
集合启发式合并
[NOI2022] 众数
看到查询绝对众数我们便想到一个方法:
用桶记录每个元素出现次数,查询时从序列中随机抽取 \(\log q\) 个数验证是否是绝对众数。
易证这种做法期望是正确的。这里略去。
然后对于在末尾插入删除以及拼接多个序列,我们可以用双端队列维护。
但是在拼接序列是怎么插入元素,暴力插入元素是 \(O(nq)\) 的。我们可以把较短的序列中的元素暴力插入到较长的序列中。
但是这么做的复杂度有保证吗?
注意到每次把一个元素插入到另一个序列中,花费了 \(O(1)\) 的时间(哈希表和双端队列均可以 \(O(1)\) 插入),而且这个操作使这个元素所在的序列长度至少翻了一倍,又因为总共只有 \(n\) 各元素,所以序列长度至多为 \(n\),所以一个元素最多被插入 \(\log n\) 次。
那么合并操作的总复杂度就是 \(O(n \log n)\)
参考代码
#include<bits/stdc++.h>
#include<bits/extc++.h>
using namespace std;
namespace IO{
const int SIZE=1<<21;
static char ibuf[SIZE],obuf[SIZE],*iS,*iT,*oS=obuf,*oT=oS+SIZE-1;
int qr;
char qu[55],c;
bool f;
#define getchar() (IO::iS==IO::iT?(IO::iT=(IO::iS=IO::ibuf)+fread(IO::ibuf,1,IO::SIZE,stdin),(IO::iS==IO::iT?EOF:*IO::iS++)):*IO::iS++)
#define putchar(x) *IO::oS++=x,IO::oS==IO::oT?flush():0
#define flush() fwrite(IO::obuf,1,IO::oS-IO::obuf,stdout),IO::oS=IO::obuf
#define puts(x) IO::Puts(x)
template<typename T>
inline void read(T&x){
for(f=1,c=getchar();c<48||c>57;c=getchar())f^=c=='-';
for(x=0;c<=57&&c>=48;c=getchar()) x=(x<<1)+(x<<3)+(c&15);
x=f?x:-x;
}
template<typename T>
inline void write(T x){
if(!x) putchar(48); if(x<0) putchar('-'),x=-x;
while(x) qu[++qr]=x%10^48,x/=10;
while(qr) putchar(qu[qr--]);
}
inline void Puts(const char*s){
for(int i=0;s[i];i++)
putchar(s[i]);
putchar('\n');
}
struct Flusher_{~Flusher_(){flush();}}io_flusher_;
}
using IO::read;
using IO::write;
const int maxn = 5e5+1;
const int zbz = 22;
int tot;
int n,q;
class hhx{
public:
__gnu_pbds::gp_hash_table<int,int> warma;
int L,R;
inline void push_back(int x);
inline void push_front(int x);
inline void pop_back();
inline void pop_front();
inline int back();
inline int front();
inline int rd();
inline int size();
};
inline void hhx::push_back(int x){
warma[++R]=x;
}
inline void hhx::push_front(int x){
warma[--L]=x;
}
inline void hhx::pop_back(){
--R;
}
inline void hhx::pop_front(){
++L;
}
inline int hhx::back(){
return warma[R];
}
inline int hhx::front(){
return warma[L];
}
inline int hhx::rd(){
return warma[L+rand()%(R-L+1)];
}
inline int hhx::size(){
return R-L+1;
}
struct Node{
__gnu_pbds::gp_hash_table<int,int> cnt;//出现次数
hhx lwx;
}chifan[maxn];
__gnu_pbds::gp_hash_table<int,int> xzy;
inline void insert(int pos,int x,bool type){
++chifan[pos].cnt[x];
if(type==0)
chifan[pos].lwx.push_back(x);
else
chifan[pos].lwx.push_front(x);
}
inline void del(int pos){
int d=chifan[pos].lwx.back();
chifan[pos].lwx.pop_back();
--chifan[pos].cnt[d];
}
inline void merge(int x1,int x2,int x3){
int f=0;
if(chifan[x1].lwx.size()<chifan[x2].lwx.size()){
f=1;
swap(x1,x2);
}
for(int u,i=chifan[x2].lwx.L;i<=chifan[x2].lwx.R;i++){
u=chifan[x2].lwx.warma[i];
insert(x1,u,f);
}
xzy[x3]=x1;
}//这里是启发式合并
vector<int> X;
vector<int> wyb;
inline int query(){
int m;
read(m);
X.clear();
wyb.clear();
for(int i=1;i<=m;i++){
int x;
read(x);
x=xzy[x];
X.push_back(x);
}
int sum=0;
for(int u:X){
sum+=chifan[u].lwx.size();
}
for(int i=1;i<=zbz;i++){
int pos=rand()%sum+1;
int v=0,s=0;
for(int v1:X){
s+=chifan[v1].lwx.size();
if(s>=pos){
v=v1;
break;
}
}
wyb.push_back(chifan[v].lwx.rd());
}
for(int u:wyb){
int s=0;
for(int v:X){
s+=chifan[v].cnt[u];
}
if((s<<1)>sum){
return u;
}
}
return -1;
}
signed main(){
ios::sync_with_stdio(0);
cin.tie(0);
cout.tie(0);
srand(time(0));
read(n);
read(q);
for(int i=0;i<=n;i++) chifan[i].lwx.L=1,chifan[i].lwx.R=0;
for(int i=1;i<=n;++i){
xzy[i]=i;
int m;
read(m);
for(int j=1;j<=m;++j){
int x;
read(x);
insert(i,x,0);
}
}
for(int i=1;i<=q;++i){
int opt;
read(opt);
if(opt==1){
int x,y;
read(x);
read(y);
x=xzy[x];
insert(x,y,0);
}
else if(opt==2){
int x;
read(x);
x=xzy[x];
del(x);
}
else if(opt==3){
write(query());
putchar('\n');
}
else{
int x1,x2,x3;
read(x1);
read(x2);
read(x3);
x1=xzy[x1];
x2=xzy[x2];
merge(x1,x2,x3);
}
}
}
树上启发式合并
树上启发式合并多用于解决对子树的询问。
这个虽然本质上与集合启发式合并一致,但是在实现上却有很大的不同。
具体的思路是让父节点继承节点最多的儿子(重儿子)的信息,在把其他的儿子(轻儿子)的信息暴力插入。
但是这么做空间复杂度是 \(O(n \log n)\) 怎么办?
答案是让全局节点信息公用一个集合,每次按如下流程操作:
-
先递归求解这个点的所有轻儿子并求解对于它们的询问。不保留它们的信息。
-
递归求解这个点的重儿子并求解对于它们的询问。保留它们的信息。
-
将这个节点的轻儿子子树内的信息插入集合并回答对于当前节点的询问。
这么做时间复杂度还是 \(O(n \log n)\) 但是空间复杂度却变成了 \(O(n)\)。
树形结构合并
线段树合并
[Vani有约会]雨天的尾巴 /【模板】线段树合并
我们通过差分可以把问题转变为子树内众数(注意,这里可能会有某个点需要删除一个数,所以不可以单纯地用桶维护),这个可以用权值线段树维护,可是怎么讲子节点的信息合并到父节点呢?
当然,你可以直接树上启发式合并,这么做是 \(O(n \log^2 n)\) 的,有没有更好的解法?
首先,我们可以将权值线段树变成动态开点权值线段树(不同的同学请先学习动态开点)。这样就保证一个大小为 \(u\) 的集合线段树上至多有 \(u \log n\) 个节点。
然后考虑怎么合并两棵线段树。
我们可以递归进行,假设我们要合并两个节点,先分别合并这两个节点的左右儿子,再更新这个节点的信息。
以及假若这两个节点有一个节点为空,直接返回另一个节点作为合并结果。
写出代码就是这样:
int merge(int a,int b,int l,int r){
if(a==0||b==0) return a+b;
if(l==r){
tree[a].cnt+=tree[b].cnt;
tree[a].val=l;
return a;
}
int mid=(l+r)/2;
tree[a].ls=merge(tree[a].ls,tree[b].ls,l,mid);
tree[a].rs=merge(tree[a].rs,tree[b].rs,mid+1,r);
pushup(a);
return a;
}
这个复杂度看上去很鬼畜,但是对的,为啥?
我们发现每调用一次 \(merge\) 函数那么就会合并两个不同的节点,也就是说节点总数就会减一。
那么又因为总共只有 \(n \log n\) 个节点,所有这个函数至多被调用 \(n \log n\) 次。
那么我们就 \(O(n \log n)\) 地做完了。
参考代码
#include<bits/stdc++.h>
using namespace std;
const int inf = 2e5;
int n,q;
const int maxn = 2e5+114;
vector<int> Add[maxn*2],Del[maxn*2];
int ans[maxn];
int tot;
int root[maxn];
int fa[maxn][18];
int depth[maxn];
int lg[maxn];
vector<int> edge[maxn];
struct Node{
int ls,rs,val,cnt;
}tree[maxn * 20];
void pushup(int &cur){
if(tree[tree[cur].ls].cnt<tree[tree[cur].rs].cnt){
tree[cur].cnt=tree[tree[cur].rs].cnt;
tree[cur].val=tree[tree[cur].rs].val;
}
else if(tree[tree[cur].rs].cnt<tree[tree[cur].ls].cnt){
tree[cur].cnt=tree[tree[cur].ls].cnt;
tree[cur].val=tree[tree[cur].ls].val;
}
else{
tree[cur].cnt=tree[tree[cur].ls].cnt;
tree[cur].val=min(tree[tree[cur].ls].val,tree[tree[cur].rs].val);
}
}
void addtag(int &cur,int lt,int rt,int l,int r,int v){
if(lt>r||rt<l) return ;
if(cur==0){
cur=++tot;
}
if(lt==rt){
tree[cur].cnt+=v;
tree[cur].val=lt;
return ;
}
int mid = (lt+rt)/2;
addtag(tree[cur].ls,lt,mid,l,r,v);
addtag(tree[cur].rs,mid+1,rt,l,r,v);
pushup(cur);
}
int merge(int a,int b,int l,int r){
if(a==0||b==0) return a+b;
if(l==r){
tree[a].cnt+=tree[b].cnt;
tree[a].val=l;
return a;
}
int mid=(l+r)/2;
tree[a].ls=merge(tree[a].ls,tree[b].ls,l,mid);
tree[a].rs=merge(tree[a].rs,tree[b].rs,mid+1,r);
pushup(a);
return a;
}
inline void add(int u,int v){
edge[u].push_back(v);
edge[v].push_back(u);
}
inline void dfs1(int now,int fath){
fa[now][0]=fath;
depth[now]=depth[fath] + 1;
for(int i=1;i<=lg[depth[now]];++i)
fa[now][i] = fa[fa[now][i-1]][i-1];
for(int nxt:edge[now]){
if(nxt==fath) continue;
dfs1(nxt,now);
}
}
int LCA(int x,int y){
if(depth[x] < depth[y])
swap(x, y);
while(depth[x] > depth[y])
x=fa[x][lg[depth[x]-depth[y]]- 1];
if(x==y)
return x;
for(int k=lg[depth[x]]-1; k>=0; --k)
if(fa[x][k] != fa[y][k])
x=fa[x][k],y=fa[y][k];
return fa[x][0];
}
void change(int u,int v,int z){
Add[u].push_back(z);
Add[v].push_back(z);
int w=LCA(u,v);
Del[w].push_back(z);
Del[fa[w][0]].push_back(z);
}
void dfs2(int now,int fa){
for(int nxt:edge[now]){
if(nxt==fa) continue;
dfs2(nxt,now);
root[now]=merge(root[now],root[nxt],1,inf);
}
pushup(root[now]);
for(int c:Add[now]){
addtag(root[now],1,inf,c,c,1);
}
for(int c:Del[now]){
addtag(root[now],1,inf,c,c,-1);
}
ans[now]=tree[root[now]].val;
}
signed main(){
ios::sync_with_stdio(0);
cin.tie(0);
cout.tie(0);
cin>>n>>q;
for(int i = 1; i <= n; ++i)
lg[i]=lg[i-1]+(1<<lg[i-1]==i);
for(int i=1;i<n;i++){
int u,v;
cin>>u>>v;
add(u,v);
}
dfs1(1,0);
for(int i=1;i<=q;i++){
int u,v,z;
cin>>u>>v>>z;
change(u,v,z);
}
dfs2(1,0);
for(int i=1;i<=n;i++) cout<<ans[i]<<'\n';
}
Trie 合并
[省选联考 2020 A 卷] 树
转化题意便知道我们需要在每个点上用一种数据结构维护全局加一和异或和,很自然地想到用 01trie 维护,具体怎么维护限于篇幅就不赘述了,现在我们只考虑怎么把子树内 01trie 合并到父节点的问题。
类似于线段树合并一样的思想,首先我们要让 01trie 变成动态开点式的,然后在合并时依然是先合并左右儿子的信息,再更新节点本身的信息。
复杂度分析和线段树合并类似,都是 \(O(n \log n)\) 的。
不过这里给读者多留一个问题:压位 trie 合并能否实现?倘若能实现,其复杂度是否是 \(O(n \log_{w} n)\)?
参考代码
#include<bits/stdc++.h>
#define int long long
using namespace std;
const int maxn = 1e6+114;
int anser,tot;
int col[maxn];
int n;
vector<int> edge[maxn];
struct Node{
int ls,rs,v,val;
int cnt;
}trie[maxn*40];
queue<int> is;
int root[maxn];
void pushup(int &cur,int pos){
if(cur!=pos)
trie[cur].val=((trie[cur].cnt&1)?trie[cur].v:0)+((trie[trie[cur].ls].val^trie[trie[cur].rs].val)<<1);
else
trie[cur].val=(trie[trie[cur].ls].val^trie[trie[cur].rs].val);
}
void insert(int &cur,int pos){
if(is.size()==0) return;
if(cur==0){
cur=++tot;
}
if(cur!=pos){
trie[cur].v=(is.front()&1),trie[cur].cnt++;
is.pop();
}
if(!(is.front()&1)) insert(trie[cur].ls,pos);
else insert(trie[cur].rs,pos);
pushup(cur,pos);
}
int merge(int &a,int &b,int pos){
if(a==0||b==0) return a+b;
trie[a].cnt+=trie[b].cnt;
trie[a].ls=merge(trie[a].ls,trie[b].ls,pos);
trie[a].rs=merge(trie[a].rs,trie[b].rs,pos);
pushup(a,pos);
return a;
}
void add(int &cur,int pos){
if(cur==0){
return ;
}
swap(trie[cur].ls,trie[cur].rs);
if(trie[cur].ls!=0)
trie[trie[cur].ls].v=0;
if(trie[cur].rs!=0)
trie[trie[cur].rs].v=1;
add(trie[cur].ls,pos);
pushup(trie[cur].ls,pos);
pushup(trie[cur].rs,pos);
pushup(cur,pos);
return ;
}
void chifan(int x){
while(is.size()>0) is.pop();
while(x!=0){
is.push(x&1);
x>>=1;
}
while(is.size()<22) is.push(0);
return ;
}
void dfs(int u,int fa){
for(int v:edge[u]){
if(v==fa) continue;
dfs(v,u);
merge(root[u],root[v],u);
}
chifan(col[u]);
insert(root[u],u);
anser+=trie[root[u]].val;
add(root[u],u);
}
signed main(){
ios::sync_with_stdio(0);
cin.tie(0);
cout.tie(0);
cin>>n;
for(int i=1;i<=n;i=-~i){
cin>>col[i];
root[i]=++tot;
}
for(int i=2;i<=n;i=-~i){
int v;
cin>>v;
edge[i].push_back(v);
edge[v].push_back(i);
}
dfs(1,0);
cout<<anser;
}