前言
合併操作一直是 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;
}