莫隊算法完整總結(普通莫隊、帶修莫隊、樹上莫隊、回滾莫隊)

普通莫隊

一、適用問題

莫隊算法是一種離線算法,用分塊去優化暴力,不包含修改的話,複雜度爲 O(nn+mn)O(n\sqrt n+m\sqrt n)nn 爲序列長度,mm 爲操作總數。

二、算法實現

莫隊本質上就是用分塊去優化暴力的離線算法,將總複雜度降到 O(nn)O(n\sqrt n) 的位置。說白了,就是分塊+暴力。

我們先講暴力的部分。比如一個長度爲 nn 的序列,mm 次查詢,每次查詢詢問區間 [l,r][l,r] 之間的衆數。對於這個問題,暴力求的話就是直接用桶記錄每個數出現的次數,然後遍歷區間 [l,r][l,r],直接統計答案即可。這個暴力過程和莫隊暴力過程沒有任何區別,然後問題就變成了如何用分塊來優化這個暴力呢?

分塊的部分,該算法將整個序列按照 n\sqrt n 大小進行分塊,共分成 n\sqrt n 塊,然後對於所有的詢問,先按照左端點所在的塊編號進行排序,如果塊編號相同,再按照右端點排序。詢問排序完之後,就直接暴力求解即可。代碼的話看一下下面習題就可以掌握了。

最後就是時間複雜度的問題了。如何證明這個算法的時間複雜度呢?我們對每一個塊分開進行考慮,假設有 bib_i 次操作在第 ii 個塊中,則在這個塊中,右端點一定遞增,因此右端點最多移動 nn 次,而左端點每次最多移動 n\sqrt n,一共最多移動 binb_i*\sqrt n 次,每次端點移動的時間複雜度爲 O(1)O(1),因此移動的總次數爲 i=1n(bin+n)=mn+nn\sum\limits_{i=1}^{\sqrt n}(b_i*\sqrt n+n)=m*\sqrt n+n*\sqrt n,因此總複雜度爲 O(nn+mn)O(n\sqrt n+m\sqrt n)

三、普通莫隊習題

1. [2009國家集訓隊] 小Z的襪子

題意: nn 雙顏色不同襪子,mm 次詢問,每次詢問給出 [L,R][L,R] 區間,詢問在 [L,R][L,R] 區間中隨機抽出兩雙顏色相同的襪子的概率,輸出最簡分數形式 (A/B)(A/B)(1n,m50000)(1\leq n,m\leq 50000)

思路: 普通莫隊算法的複雜度是 O(NN)O(N\sqrt N),實現關鍵點就在於能否在區間左右端點移動時,O(1)O(1) 的更新答案。

我們觀察這道題目,可以發現區間 [L,R][L,R] 取出兩雙顏色相同襪子的概率 = 12i=LRnum[i]C(RL+1,2)\frac{\frac{1}{2}*\sum\limits _{i=L}^{R}num[i]}{C(R-L+1,2)}num[i]num[i] 表示在區間 [L,R][L,R] 中有多少雙與 ii 顏色相同的襪子,乘以 12\frac{1}{2} 的原因在於每一對顏色相同的襪子被計算了兩遍。

分析到這裏,就可以發現這是一道普通莫隊的裸題,我們添加與刪除時只需加上或減去當前與該點顏色相同的襪子數,這樣同時可以避免重複計算。

代碼:

#include <bits/stdc++.h>
#define rep(i,a,b) for(int i = a; i <= b; i++)
typedef long long ll;
const int N = 2*1e5+100;
using namespace std;

int a[N],pos[N],n,m,L,R;
ll ans[N][2],flag[N],Ans;
struct Node{
	int l,r,id;
	bool operator < (Node xx) const{
		if(pos[l] == pos[xx.l]) return r < xx.r;
		else return pos[l] < pos[xx.l];
	}
}Q[N];

ll gcd(ll a,ll b) {return b == 0 ? a:gcd(b,a%b);}

void add(int x){
	Ans += flag[a[x]];
	flag[a[x]]++;
}

void del(int x){
	flag[a[x]]--;
	Ans -= flag[a[x]];
}

int main()
{
	L = 1, R = 0;
	scanf("%d%d",&n,&m);
	int sz = sqrt(n);
	rep(i,1,n){
		scanf("%d",&a[i]);
		pos[i] = i/sz;
	}
	rep(i,1,m){
		scanf("%d%d",&Q[i].l,&Q[i].r);
		Q[i].id = i;
	}
	sort(Q+1,Q+1+m);
	rep(i,1,m){
		while(L < Q[i].l) del(L),L++;

		while(L > Q[i].l) L--, add(L);
		
		while(R < Q[i].r) R++, add(R);
		
		while(R > Q[i].r) del(R), R--;

		ll len = Q[i].r-Q[i].l+1;
		ll tp = len*(len-1ll)/(ll)2;
		ll g = gcd(Ans,tp);
		ans[Q[i].id][0] = Ans/g;
		ans[Q[i].id][1] = tp/g;
	}
	rep(i,1,m) printf("%lld/%lld\n",ans[i][0],ans[i][1]);
	return 0;
}
2. 花神的嘲諷計劃Ⅰ

題意: 初始序列長度爲 nnmm 組詢問,每次詢問給出一個 xxyy,以及長度爲 kk 的連續序列。詢問在區間 [x,y][x,y] 中是否存在一段連續的長度爲 kk 的,與詢問中給出的序列相同的一段序列。存在輸出 NoNo,不存在輸出 YesYes(1n,m106)(1\leq n,m\leq 10^6)

思路: 這題可以觀察到每次詢問的連續序列長度都是固定爲 kk,因此不難想到用 hashhash 來解決這個問題。我們將每個位置後面連續的一段 kk 哈希起來,然後每個位置就有了一個對應的 hashhash 值。我們將這些 hashhash 值離散化之後,用桶來記錄區間端點移動時對答案的貢獻。

代碼:

#include <bits/stdc++.h>
#define rep(i,a,b) for(int i = a; i <= b; i++)
typedef long long ll;
const int N = 2*1e6+100;
const ll mod = 1e11+7;
using namespace std;

int n,m,k,L,R,flag[N],tot,ans[N],pos[N],pp[N];
ll a[N],b[N],ha[N];
struct Node{
	int l,r,id;
	ll w;
	bool operator < (Node xx) const {
		if(pos[l] != pos[xx.l]) return pos[l] < pos[xx.l];
		else return r < xx.r;
	}
}q[N];

int find(ll x){
	return lower_bound(b+1,b+1+tot,x)-b;
}

ll Hash(int pos){
	ll tp = 0;
	ll base = 1;
	rep(i,pos,pos+k-1){
		tp = (tp+a[i]*base)%mod;
		if(tp < 0) tp = (tp+mod)%mod;
		base = (base*(ll)133)%mod;
		if(base < 0) base = (base+mod)%mod;
	}
	return tp;
}

void add(int x) {flag[pp[x]]++;}

void del(int x) {flag[pp[x]]--;}

int main()
{
	scanf("%d%d%d",&n,&m,&k);
	rep(i,1,n) scanf("%lld",&a[i]);
	rep(i,1,m){
		int xx,yy; scanf("%d%d",&xx,&yy);
		q[i].l = xx, q[i].r = yy, q[i].id = i;
		q[i].r = q[i].r-k+1;
		ll tp = 0;
		ll base = 1;
		rep(j,1,k){
			ll hp; scanf("%lld",&hp);
			tp = (tp+hp*base)%mod;
			if(tp < 0) tp = (tp+mod)%mod;
			base = (base*(ll)133)%mod;
			if(base < 0) base = (base+mod)%mod;
		}
		q[i].w = tp;
		b[++tot] = tp;
	}
	rep(i,1,n-k+1){
		ll tp = Hash(i);
		ha[i] = tp;
		b[++tot] = tp;
	}
	sort(b+1,b+1+tot);
	tot = unique(b+1,b+1+tot)-b-1;
	rep(i,1,n-k+1){
		pp[i] = find(ha[i]);
	}
	int sz = sqrt(n);
	rep(i,1,n) pos[i] = i/sz;
	sort(q+1,q+1+m);
	L = 1, R = 0;
	rep(i,1,m){
		while(L < q[i].l) del(L), L++;
		while(L > q[i].l) L--, add(L);
		while(R < q[i].r) R++, add(R);
		while(R > q[i].r) del(R), R--;
		
		int pos = find(q[i].w);
		if(flag[pos]) ans[q[i].id] = 1;
		else ans[q[i].id] = 0;
	}
	rep(i,1,m){
		if(ans[i]) printf("No\n");
		else printf("Yes\n");
	}
	return 0;
}
3. XOR and Favorite Number

題意: 長度爲 nn 的初始序列,共有 mm 次詢問,每次詢問給出一個 lrkl、r、k,表示查詢區間 [l,r][l,r] 中有多少對 (i,j)(i,j) 滿足 aia_i ^ ai+1a_{i+1} ^ … ^ aj=ka_{j}=k(1n,m105,0k106)(1\leq n,m\leq 10^5,0\leq k\leq 10^6)

思路: 既然是某一區間的異或和,不難想到先求一個異或前綴和,然後對於一個 jj 來說,就是詢問區間 [l,r][l,r] 中有多少個 ii 滿足 sum[i1]sum[i-1] ^ sum[j]=ksum[j]=k

問題拆解到這一步,剩下的問題就比較明瞭了,直接上莫隊,然後用桶維護每一個數的異或前綴和即可。

代碼:

#include <bits/stdc++.h>
#define rep(i,a,b) for(int i = a; i <= b; i++)
typedef long long ll;
const int N = 2*1e6+100;
using namespace std;

int a[N],pos[N],n,m,k,L,R;
ll ans[N],flag[N],Ans;
struct Node{
	int l,r,id;
	bool operator < (Node xx) const{
		if(pos[l] == pos[xx.l]) return r < xx.r;
		else return pos[l] < pos[xx.l];
	}
}Q[N];

void add(int x){
	Ans += flag[a[x]^k];
	flag[a[x]]++;
}

void del(int x){
	flag[a[x]]--;
	Ans -= flag[a[x]^k];
}

int main()
{
	L = 1, R = 0;
	scanf("%d%d%d",&n,&m,&k);
	int sz = sqrt(n);
	rep(i,1,n){
		scanf("%d",&a[i]);
		a[i] = a[i]^a[i-1];
		pos[i] = i/sz;
	}
	rep(i,1,m){
		scanf("%d%d",&Q[i].l,&Q[i].r);
		Q[i].id = i;
	}
	sort(Q+1,Q+1+m);
	flag[0] = 1;
	rep(i,1,m){
		while(L<Q[i].l) del(L-1), L++;
		while(L>Q[i].l) L--, add(L-1);
		while(R<Q[i].r) R++, add(R);
		while(R>Q[i].r) del(R), R--;
		ans[Q[i].id] = Ans;
	}
	rep(i,1,m) printf("%lld\n",ans[i]);
	return 0;
}
4. Chika and Friendly Pairs

題意: 長度爲 nn 的序列,mm 次查詢,每次給出一個 [l,r][l,r],詢問區間 [l,r][l,r] 中有多少對 i,ji,j 滿足 i<ji<ja[i]a[j]k|a[i]-a[j]|\leq k(1n,m27000,1k109)(1\leq n,m\leq 27000,1\leq k\leq 10^9)

思路: 由於 nnmm 的範圍比較小,可以考慮使用莫隊分塊算法,在加入和刪除的地方使用樹狀數組統計答案即可。

代碼:

#include <bits/stdc++.h>
#define mem(a,b) memset(a,b,sizeof a);
#define rep(i,a,b) for(int i = a; i <= b; i++)
#define per(i,a,b) for(int i = a; i >= b; i--)
#define __ ios::sync_with_stdio(0);cin.tie(0);cout.tie(0)
typedef long long ll;
typedef double db;
const int N = 27000+100;
const db EPS = 1e-9;
using namespace std;

void dbg() {cout << "\n";}
template<typename T, typename... A> void dbg(T a, A... x) {cout << a << ' '; dbg(x...);}
#define logs(x...) {cout << #x << " -> "; dbg(x);}

int n,m,k,a[N],b[3*N],tot,L,R,pos[N],now[N][3];
struct Node{
	int l,r,id;
	bool operator < (Node xx) const {
		if(pos[l] == pos[xx.l]) return r < xx.r;
		else return pos[l] < pos[xx.l];
	}
}q[N];
ll c[3*N],ans[N],Ans;

inline int lowbit(int x) {return x&(~x+1);}
inline void update(int x,ll v) {for(;x<=tot;x+=lowbit(x)) c[x]+=v;}
inline ll ask(int x){
	ll tp = 0;
	while(x) tp += c[x], x -= lowbit(x);
	return tp;
} 

int find(int x){
	return lower_bound(b+1,b+1+tot,x)-b;
}

void add(int x){
	int p1 = now[x][1], p2 = now[x][2];
	Ans += ask(p1)-ask(p2);
	update(now[x][0],1);
}

void del(int x){
	update(now[x][0],-1);
	int p1 = now[x][1], p2 = now[x][2];
	Ans -= ask(p1)-ask(p2);
}

int main()
{
	L = 1, R = 0;
	scanf("%d%d%d",&n,&m,&k);
	int sz = sqrt(n);
	rep(i,1,n){
		scanf("%d",&a[i]);
		b[++tot] = a[i]; b[++tot] = a[i]+k; b[++tot] = a[i]-k-1;
		pos[i] = i/sz;
	}
	sort(b+1,b+1+tot);
	tot = unique(b+1,b+1+tot)-b-1;
	rep(i,1,n){
		now[i][0] = find(a[i]);
		now[i][1] = find(a[i]+k);
		now[i][2] = find(a[i]-k-1);
	}
	rep(i,1,m){
		scanf("%d%d",&q[i].l,&q[i].r);
		q[i].id = i;
	}
	sort(q+1,q+1+m);
	rep(i,1,m){
		while(L < q[i].l){
			del(L);
			L++;
		}
		while(L > q[i].l){
			L--;
			add(L);
		}
		while(R < q[i].r){
			R++;
			add(R);
		}
		while(R > q[i].r){
			del(R);
			R--;
		}
		ans[q[i].id] = Ans;
	}
	rep(i,1,m) printf("%lld\n",ans[i]);
	return 0;
}
5. 莫隊求組合數前綴和

題意: qq 組詢問,每次給出一個 nnmm,求 i=0i=mCni\sum\limits_{i=0}^{i=m}C_n^i(1n,m,q2105)(1\leq n,m,q\leq 2*10^5)

思路:F(n,m)=i=1mCniF(n,m)=\sum\limits_{i=1}^mC_n^i,思考 F(n,m)F(n,m)F(n,m+1)F(n,m+1)F(n+1,m)F(n+1,m) 之間的關係。

F(n,m+1)=F(n+m)+Cnm+1F(n,m+1)=F(n+m)+C_n^{m+1}F(n+1,m)=2F(n,m)CnmF(n+1,m)=2*F(n,m)-C_n^{m}。預處理出階乘和逆元之後,即可 O(1)O(1) 進行端點移動。

總結: 這其實是一道廣義莫隊問題,所謂廣義莫隊問題就是題目中並沒有明確指明查詢區間 [l,r][l,r] 的答案,而是將所查詢的問題轉化爲 F(n,m)F(n,m) 的形式,然後實現 F(n,m)F(n,m)F(n+1,m)F(n+1,m) 以及 F(n,m+1)F(n,m+1) 之間的 O(1)O(1) 轉移,只要求出之間轉移的公式就可以直接 O(nn)O(n*\sqrt n) 離線求出最終答案。


帶修改莫隊

一、適用問題

帶修改的莫隊算法就是在普通的莫隊基礎上增加了單點修改操作,時間複雜度爲 O(n53)O(n^{\frac{5}{3}})

二、算法實現

帶修改的莫隊仍然是利用分塊對查詢和修改排序,儘可能地減少運行時間。

假設我們按照 kk 大小進行分塊,則一共有 nk\frac{n}{k} 個塊,然後對於每個操作,一共有三個參數,分別是 llrridid,表示區間左右端點和操作時間,我們先按照左端點的塊號進行排序,再按照右端點的塊號進行排序,最後按照操作時間進行排序。

莫隊暴力時也需要維護三個值,L、R、T 表示當前控制的左右區間以及操作時間。對於每個查詢,需要將 LLRRTT 移動到指定位置再進行計算,因此可以將帶修改莫隊理解爲三維莫隊。

接下來估算複雜度,假設 mm 次查詢中,一共有 aa 次查詢,bb 次修改。因此當確定左右端點塊號時,即查詢即按照時間排序時,TT 最多移動 bb 次,因此 TT 的移動一共有 nknkb\frac{n}{k}*\frac{n}{k}*b 次。而每次查詢,區間左右端點最多移動 2k2*k 次,因此 llrr 最多移動 a2ka*2*k 次,因此總時間複雜度爲 O(bn2k2+2ak)O(b*\frac{n^2}{k^2}+2*a*k)。我們可以求導求這個函數的最小值,可以發現最後的答案會在 k=n23k=n^{\frac{2}{3}} 處取到最優解,因此整個算法的複雜度也就達到了 O(n53)O(n^{\frac{5}{3}}) 處。

三、帶修改莫隊習題

1. Machine Learning

題意: 長度爲 nn 的初始序列,共有 mm 次操作,操作 11 給出一個 llrr,令 cic_iii[l,r][l,r] 中出現的次數,詢問 Mex(c0,c1,...,c109)Mex(c_0,c_1,...,c_{10^9})。操作 22 則將 apa_p 改成 xx(1n,m105)(1\leq n,m\leq 10^5)

思路: 這個問題唯一的操作難點在於 mexmex 函數的求取,其實我們可以像求取 SGSG 函數的 mexmex 一樣,直接暴力求取即可。然後其餘部分就是常規的帶修改莫隊的操作了。

代碼:

#include <cstdio>
#include <iostream>
#include <cstring>
#include <cmath>
#include <algorithm>
#define __ ios::sync_with_stdio(0);cin.tie(0);cout.tie(0)
#define rep(i,a,b) for(int i = a; i <= b; i++)
#define LOG1(x1,x2) cout << x1 << ": " << x2 << endl;
#define LOG2(x1,x2,y1,y2) cout << x1 << ": " << x2 << " , " << y1 << ": " << y2 << endl;
#define LOG3(x1,x2,y1,y2,z1,z2) cout << x1 << ": " << x2 << " , " << y1 << ": " << y2 << " , " << z1 << ": " << z2 << endl;
typedef long long ll;
typedef double db;
const int N = 2*1e5+100;
const int M = 1e5+100;
const db EPS = 1e-9;
using namespace std;

int n,qq,a[N],b[N],tot,Qnum,Cnum,pos[N],ans[N],L,R,T,flag[N],vis[N];
struct Query{
	int l,r,id,t;
	bool operator < (Query xx) const {
		if(pos[l] != pos[xx.l]) return pos[l] < pos[xx.l];
		else if(pos[r] != pos[xx.r]) return pos[r] < pos[xx.r];
		else return t < xx.t;
	}
}q[M];
struct Change{
	int pos,val;
}C[M];

int find(int x){
	return lower_bound(b+1,b+1+tot,x)-b;
}

void add(int x){
	if(flag[a[x]]!=0) vis[flag[a[x]]]--;
	flag[a[x]]++; vis[flag[a[x]]]++;
}

void del(int x){
	vis[flag[a[x]]]--; flag[a[x]]--;
	if(flag[a[x]] != 0) vis[flag[a[x]]]++;
}

void Work(int x,int i){
	if(C[x].pos >= q[i].l && C[x].pos <= q[i].r){
		vis[flag[a[C[x].pos]]]--; flag[a[C[x].pos]]--;
		if(flag[a[C[x].pos]] != 0) vis[flag[a[C[x].pos]]]++;
		if(flag[C[x].val] != 0) vis[flag[C[x].val]]--;
		flag[C[x].val]++; vis[flag[C[x].val]]++;
	}
	swap(a[C[x].pos],C[x].val);
}

int solve(){
	rep(i,0,n)
		if(!vis[i]) return i;
}

int main()
{
	scanf("%d%d",&n,&qq);
	rep(i,1,n){
		scanf("%d",&a[i]);
		b[++tot] = a[i];
	}
	rep(i,1,qq){
		int op,l,r; scanf("%d%d%d",&op,&l,&r);
		if(op == 1) Qnum++, q[Qnum] = {l,r,Qnum,Cnum};
		else C[++Cnum] = {l,r}, b[++tot] = r;
	}
	sort(b+1,b+1+tot);
	tot = unique(b+1,b+1+tot)-b-1;
	int sz = pow(n,0.66666666666666);
	rep(i,1,n) pos[i] = i/sz;
	sort(q+1,q+1+Qnum);
	L = 1, R = 0, T = 0;
	vis[0] = 1;
	rep(i,1,n) a[i] = find(a[i]);
	rep(i,1,Cnum) C[i].val = find(C[i].val);
	rep(i,1,Qnum){
		while(L < q[i].l) del(L++); 
		while(L > q[i].l) add(--L);
		while(R < q[i].r) add(++R);
		while(R > q[i].r) del(R--);
		while(T < q[i].t) Work(++T,i);
		while(T > q[i].t) Work(T--,i);
		ans[q[i].id] = solve();
	}
	rep(i,1,Qnum) printf("%d\n",ans[i]);
	return 0;
}
2. 數顏色

題意: 長度爲 nn 的一個序列,每一個點都有一個顏色,一共 mm 次操作。第一種操作詢問 [l,r][l,r] 中一共有多少種不同的顏色,第二種操作則修改第 pp 個點的顏色。(1n,m104)(1\leq n,m\leq 10^4)

思路: 開一個桶記錄一下每種顏色出現的次數,然後就是一道莫隊帶修改的模板題了。

代碼:

#include <bits/stdc++.h>
#define rep(i,a,b) for(int i = a; i <= b; i++)
typedef long long ll;
const int N = 2*1e5+100;
const int M = 1e6+100;
const db EPS = 1e-9;
using namespace std;

int n,m,a[N],Qnum,Cnum,pos[N],flag[M],L,R,T,Ans,ans[N];
struct Query{
	int l,r,Ti,id;
	bool operator < (Query xx) const {
		if(pos[l] != pos[xx.l]) return pos[l] < pos[xx.l];
		else if(pos[r] != pos[xx.r]) return pos[r] < pos[xx.r];
		else return Ti < xx.Ti;
	}
}Q[N];
struct Change{
	int pos,val;
}C[N];

void add(int x){
	flag[a[x]]++;
	if(flag[a[x]] == 1) Ans++;
}

void del(int x){
	flag[a[x]]--;
	if(flag[a[x]] == 0) Ans--;
}

void Work(int x,int i){
	if(C[x].pos >= Q[i].l && C[x].pos <= Q[i].r){
		flag[a[C[x].pos]]--; if(flag[a[C[x].pos]] == 0) Ans--;
		flag[C[x].val]++; if(flag[C[x].val] == 1) Ans++;
	}
	swap(C[x].val,a[C[x].pos]);
}

int main()
{
	scanf("%d%d",&n,&m);
	rep(i,1,n) scanf("%d",&a[i]);
	rep(i,1,m){
		char op[10]; int xx,yy; scanf("%s",op);
		scanf("%d%d",&xx,&yy);
		if(op[0] == 'Q') Qnum++, Q[Qnum] = {xx,yy,Cnum,Qnum};
		else C[++Cnum] = {xx,yy}; 
	}
	int sz = pow((ll)n, 0.66666666666);
	//分塊大小爲(n*t)^(1/3), t爲修改的座標範圍
	//O(((n^4)*t))^(1/3))
	rep(i,0,n) pos[i] = i/sz;
	sort(Q+1,Q+1+Qnum);
	L = 1, R = 0, T = 0;
	rep(i,1,Qnum){
		while(L < Q[i].l) {del(L); L++;}
		while(L > Q[i].l) {L--; add(L);}
		while(R < Q[i].r) {R++; add(R);}
		while(R > Q[i].r) {del(R); R--;}
		while(T < Q[i].Ti) {++T; Work(T,i);}
		while(T > Q[i].Ti) {Work(T,i); T--;}
		ans[Q[i].id] = Ans;
	}
	rep(i,1,Qnum)
		printf("%d\n",ans[i]);
	return 0;
}

樹上帶修改莫隊

一、適用問題

樹上帶修改的莫隊算法就是將普通的帶修改莫隊問題搬到了樹上進行操作,時間複雜度爲 O(n53)O(n^{\frac{5}{3}})

二、算法實現

樹上莫隊問題仍然是通過分塊進行解決,但是分塊的序列發生了變化。這個序列需要滿足,給出兩點就能在序列上找出這兩點之間的路徑。

我們考慮常見的樹上序列,dfsdfs 序,但是很明顯 dfsdfs 序不滿足這個條件,其中會有很多無效的節點。因此我們引出歐拉序來解決這個問題,歐拉序和 dfsdfs 序的區別是,dfsdfs 序只在遍歷到這個節點時纔會將這個節點加入序列,而歐拉序還會在回溯到這個節點時將節點加入序列。

因此在歐拉序中,每個點會有一個第一次到達的點和第二次到達的點,我們分別記爲 fir[i]fir[i]las[i]las[i]。對於樹上兩點 xxyyfir[x]<fir[y]fir[x]<fir[y]),uuxxyy 兩點的 lcalca,若 x=ux=u,則在 [fir[x],fir[y]][fir[x],fir[y]] 這段區間中,只有 xxyy 路徑上的點只出現一次。若 x=x =\not u,則在 [las[x],fir[y]][las[x],fir[y]] 這段區間中只有 xxyy 路徑上的點只出現一次,而且不包含 lca(x,y)lca(x,y) 這個點。因此我們在樹上莫隊問題中,需要記錄每個點出現的次數,第一次出現則加貢獻,第二次出現則減貢獻,且若 x=x=\not u,還需加上 lcalca 的貢獻。

解決完樹上莫隊的序列問題,就可以轉化成普通莫隊進行計算了。不帶修改則塊大小爲 2n\sqrt {2n},帶修改則塊大小爲 (2n)23(2n)^{\frac{2}{3}},其中 2n2n 爲歐拉序長度。

三、樹上帶修莫隊習題

1. 糖果公園 [WC2013]

題意: nn 個點的一棵樹,每個點上都有一個糖果,糖果的種類不同,第 ii 類糖果的貢獻爲 V[i]V[i],第 jj 次喫第 ii 類糖果對答案的貢獻爲 V[i]W[j]V[i]*W[j]。現有 qq 次操作,每次可以將第 xx 個點上的糖果類型改爲 yy,也可以查詢從 xx 點到 yy 點的答案。(1n,q105)(1\leq n,q\leq 10^5)

思路: 莫隊問題只需要關注加入節點和刪除節點對答案的影響,因此只需要統計每一類糖果在路徑中出現的次數即可完成節點增刪時對答案的影響。

該題思路不難,但樹上莫隊細節較多,需要查看代碼並自行實現一遍。

代碼:

#include <cstdio>
#include <iostream>
#include <cstring>
#include <cmath>
#include <algorithm>
#define __ ios::sync_with_stdio(0);cin.tie(0);cout.tie(0)
#define rep(i,a,b) for(int i = a; i <= b; i++)
#define LOG1(x1,x2) cout << x1 << ": " << x2 << endl;
#define LOG2(x1,x2,y1,y2) cout << x1 << ": " << x2 << " , " << y1 << ": " << y2 << endl;
#define LOG3(x1,x2,y1,y2,z1,z2) cout << x1 << ": " << x2 << " , " << y1 << ": " << y2 << " , " << z1 << ": " << z2 << endl;
typedef long long ll;
typedef double db;
const int N = 1e5+100;
const int M = 1e6+100;
const db EPS = 1e-9;
using namespace std;

int n,m,k,V[N],W[N],head[N],tot,C[N],qnum,cnum,f[N][25],t,d[N],Euler[2*N],ncnt,fir[N],las[N],pos[2*N],L,R,T,flag[N],vis[N];
//pos-分塊位置、fir-歐拉序第一次、las-歐拉序第二次、Euler-歐拉序數組、ncnt-歐拉序數組長度
//vis-這個樹上節點出現了幾次, flag-這個糖果種類
ll ans[N],now;
struct Edge{
	int to,next;
}e[2*N];
struct Query{
	int l,r,id,lca,t; //l、r、id-查詢順序、lca-兩點lca、t-之前有幾次修改
	bool operator < (Query xx) const {
		if(pos[l] != pos[xx.l]) return pos[l] < pos[xx.l];
		else if(pos[r] != pos[xx.r]) return pos[r] < pos[xx.r];
		else return t < xx.t;
	}
}q[N];
struct Change{
	int pos, val;
}ch[N];

void add(int x,int y){
	e[++tot].to = y, e[tot].next = head[x], head[x] = tot;
}

//求出歐拉序以及lca預處理
void dfs(int u,int fa)
{
	Euler[++ncnt] = u; fir[u] = ncnt;
    d[u]=d[fa]+1; f[u][0]=fa;
    for(int i=1;(1<<i)<=d[u];i++)
        f[u][i]=f[f[u][i-1]][i-1];
    for(int i=head[u]; i; i=e[i].next){
        int v=e[i].to;
        if(v!=fa) dfs(v,u);
    }
    Euler[++ncnt] = u; las[u] = ncnt;
}    

int LCA(int x,int y)
{
	if(d[x] > d[y]) swap(x,y);
	for(int i = t; i >= 0; i--)
		if(d[f[y][i]] >= d[x]) y = f[y][i];  //往上追溯,直至y和x位於同一深度
	if(x == y) return x;  //如果已經找到了,就返回x
	for(int i = t; i >= 0; i--)
		if(f[x][i] != f[y][i]) x = f[x][i], y = f[y][i];  //x和y同時往上走,一直到x和y恰好爲lca的子節點
	return f[x][0];  //x和y共同的根節點就是lca 
}

void Add(int pos){
	flag[C[pos]]++;
	now += (ll)W[flag[C[pos]]]*(ll)V[C[pos]];
}

void Del(int pos){
	now -= (ll)W[flag[C[pos]]]*(ll)V[C[pos]];
	flag[C[pos]]--;
}

void add_del(int pos){ //增加和減少取決於這個點被遍歷了幾次
	vis[pos] ? Del(pos) : Add(pos);
	vis[pos] ^= 1;
}

void work(int x){
	if(vis[ch[x].pos]){ //修改點爲有效點
		add_del(ch[x].pos); //減掉
		swap(C[ch[x].pos], ch[x].val);
		add_del(ch[x].pos); //加上
	}
	else swap(C[ch[x].pos], ch[x].val);
}

int main()
{
	scanf("%d%d%d",&n,&m,&k);
	rep(i,1,m) scanf("%d",&V[i]);
	rep(i,1,n) scanf("%d",&W[i]);
	rep(i,1,n-1){
		int xx,yy; scanf("%d%d",&xx,&yy);
		add(xx,yy); add(yy,xx);
	}
	rep(i,1,n) scanf("%d",&C[i]);
	t = (int)(log(n)/log(2))+1;
	dfs(1,0);
	int sz = pow(ncnt,2.0/3.0);
	for(int i = 0; i <= ncnt; i++) pos[i] = i/sz;
	rep(i,1,k){
		int op,x,y; scanf("%d%d%d",&op,&x,&y);
		if(op){
			int lca = LCA(x,y);
			q[++qnum].t = cnum; q[qnum].id = qnum;
			//根據lca判斷歐拉序順序, 若x不爲y lca, 則歐拉序中不包含lca, 因此還需加上lca的貢獻
			if(fir[x] > fir[y]) swap(x,y);
			if(x == lca) q[qnum].l = fir[x], q[qnum].r = fir[y], q[qnum].lca = 0;
			else q[qnum].l = las[x], q[qnum].r = fir[y], q[qnum].lca = lca;
		}
		else ch[++cnum] = {x,y};
	}
	sort(q+1,q+1+qnum);
	L = 1, R = 0, T = 0;
	rep(i,1,qnum){
		while(L < q[i].l){
			add_del(Euler[L]); L++;
		}
		while(L > q[i].l){
			L--; add_del(Euler[L]);
		}
		while(R < q[i].r){
			R++; add_del(Euler[R]);
		}
		while(R > q[i].r){
			add_del(Euler[R]); R--;
		}
		while(T < q[i].t){
			++T; work(T);
		}
		while(T > q[i].t){
			work(T); --T;
		}
		if(q[i].lca) add_del(q[i].lca); //lca不在歐拉序列區間中
		ans[q[i].id] = now;
		if(q[i].lca) add_del(q[i].lca); //恢復這個區間的狀態
	}
	rep(i,1,qnum) printf("%lld\n",ans[i]);
	return 0;
}

回滾莫隊

一、適用問題

普通莫隊最重要的辨別點在於可以 O(1)O(1) 的增加或刪除節點,而回滾莫隊的關鍵點在於只能 O(1)O(1) 的增加或者刪除節點,增加或刪除只能二者選其一。

常見的此類問題比如 maxmaxminminmexmex 等等… 而回滾莫隊的複雜度也很修改,不帶修改的時候只有 O(nn)O(n\sqrt n),如果帶修改的話加上其自身的較大常數,時間複雜度就會比較囍…

二、算法實現

回滾莫隊的關鍵點在於只能增加或刪除節點,我們以求取 maxmax 爲例。求取 maxmax 時,增加節點時可以順便更新答案,但是刪除節點時就非常不好維護,因此我們需要設計一個只需要增加節點的莫隊算法。

  1. 首先還是老套路,按照 n\sqrt n 進行分塊,並確定每一塊的左右邊界,分別爲 xl[i]xr[i]xl[i]、xr[i]
  2. 然後我們按照左端點所在塊編號爲第一關鍵字,右端點大小爲第二關鍵字,對所有查詢進行排序。
  3. 接下來對於所有左右端點在同一塊中的查詢,我們直接暴力求取答案,複雜度爲 O(n)O(\sqrt n)
  4. 對於左端點所在塊相同的查詢,其右端點不斷遞增,因此右端點最多移動 O(n)O(n),總共 n\sqrt n 個塊,右端點複雜度爲 O(nn)O(n\sqrt n)
  5. 接下來考慮左端點的移動,我們對於所有左端點所在塊相同的查詢,每一個查詢結束之後都要把左端點移動到 xr[i]+1xr[i]+1 的位置,即左端點所在塊的右端點 +1+1 的位置,這樣可以保證每次查詢都是不斷增加節點的,因此不會影響最終答案。每個查詢,左端點最多移動距離爲 O(n)O(\sqrt n),因此左端點移動的複雜度爲 O(mn)O(m\sqrt n)。所以綜合左右端點的移動,該算法的複雜度爲 O(mn+nn)O(m\sqrt n+n\sqrt n),考慮到 mm 的範圍通常與 nn 一致,因此最終複雜度爲 O(nn)O(n\sqrt n)

上述過程就是回滾莫隊的求取過程,習題中分別給出了增加節點和減少節點的回滾莫隊算法,其它具體實現細節可以查看代碼。

三、回滾莫隊習題

1. 歷史研究

題意: 長度爲 nn 的序列,每個數的大小爲 xix_i。一共 qq 次查詢,每次給出一個區間 llrr,詢問區間 [l,r][l,r] 中每個數貢獻的最大值,一個數的貢獻爲 xicnt[xi]x_i*cnt[x_i],即數大小 * 該數出現次數。(1n,q,105,1xi109)(1\leq n,q,\leq 10^5, 1\leq x_i\leq 10^9)

思路: 首先把序列離散化,然後用一個桶記錄每一個數字出現的次數。

接下來就是回滾莫隊的基本操作了,求出每塊的左右端點,然後對查詢排序。每次查詢時判斷左右端點是否在同一個快內,如果在就暴力求,如果不在就增加節點擴充區間。每個查詢結束後,要將左端點再移動到該塊的右邊界 +1+1 位置,具體的實現細節見代碼。

代碼:

#include <bits/stdc++.h>
#define rep(i,a,b) for(int i = a; i <= b; i++)
typedef long long ll;
const int N = 2e5+100;
using namespace std;

int n,m,sz,pos[N],a[N],b[N],tot,val[N],xl[N],xr[N],cnt[N],L,R,_cnt[N],lastblock;
//Maxn - 左右端點控制的最大值,temp - 臨時最大值
//cnt - 左右端點移動時計數,_cnt - 左右端點同塊時的計數
ll ans[N],Maxn,temp;
struct Node{
	int l,r,id;
	bool operator < (Node xx) const {
		if(pos[l] == pos[xx.l]) return r < xx.r;
		else return pos[l] < pos[xx.l];
	}
}q[N];

void init(){
	scanf("%d%d",&n,&m); sz = sqrt(n);
	rep(i,1,n) {scanf("%d",&a[i]); b[++tot] = a[i];}
	rep(i,1,m) {scanf("%d%d",&q[i].l,&q[i].r); q[i].id = i;}
	sort(b+1,b+1+tot); tot = unique(b+1,b+1+tot)-b-1;
	rep(i,1,n) val[i] = lower_bound(b+1,b+1+tot,a[i])-b;
	rep(i,1,n){
		pos[i] = i/sz;
		xl[pos[i]] = (xl[pos[i]] == 0 || xl[pos[i]] > i) ? i : xl[pos[i]];
		xr[pos[i]] = (xr[pos[i]] < i) ? i : xr[pos[i]];
	}
	sort(q+1,q+1+m);
}	

inline ll add(int x){
	return (++cnt[val[x]])*(ll)b[val[x]];
}

inline void del(int x) {cnt[val[x]]--;}

void solve(){
	L = 1, R = 0, lastblock = -1;
	rep(i,1,m){
		if(pos[q[i].l] == pos[q[i].r]){
			ll temp = 0;
			rep(j,q[i].l,q[i].r) temp = max(temp,(++_cnt[val[j]])*(ll)b[val[j]]);
			rep(j,q[i].l,q[i].r) _cnt[val[j]]--;
			ans[q[i].id] = temp;
		}
		else{
			if(lastblock != pos[q[i].l]){
				while(L < xr[pos[q[i].l]]+1) del(L), L++;
				while(R > L-1) del(R), R--;
				Maxn = 0; lastblock = pos[q[i].l];
			}
			//Maxn爲右半部分的最大值,不包含左端點所在塊的情況
			while(R < q[i].r) R++, Maxn = max(Maxn,add(R));
			temp = Maxn;
			//temp從Maxn繼承而來,表示整個區間的最大值
			while(L > q[i].l) L--, temp = max(temp,add(L));
			while(L < xr[pos[q[i].l]]+1) del(L), L++;
			ans[q[i].id] = temp;
		}
	}
}

int main()
{
	init();
	solve();
	rep(i,1,m) printf("%lld\n",ans[i]);
	return 0;
}
2. Rmq Problem / mex

題意: 長度爲 nn 的序列,每個數的大小爲 aia_i。一共 mm 次查詢,每次給出一個區間 llrr,詢問區間 [l,r][l,r] 中數的 mexmex,其中一個區間的 mexmex 指該區間內最小沒有出現過的自然數。(1n,q,2105,0ai109)(1\leq n,q,\leq 2*10^5, 0\leq a_i\leq 10^9)

思路: 由於是求 mexmex,而數字總數爲 2e52e5,因此不需要對數字進行離散化。然後我們來分析這個問題的關鍵點,即增刪節點的特性。

不難發現,對於這個問題來說,刪除節點可以 O(1)O(1) 的更新答案,但是增加節點後答案的變化難以確定,因此考慮採用刪除節點形式的回滾莫隊來解決這個問題。

刪除節點的回滾莫隊,就是區間長度不斷縮小的情況。因此我們需要對每個查詢的左端點所在塊編號進行升序,對每個查詢的右端點進行降序,這樣可以保證右端點是不斷遞減的。

然後對於左右端點在同一個塊中的情況,我們依然是暴力求取答案。而對於不在同一塊中的情況,我們需要每次查詢結束後都將左端點移動到查詢左端點所在的塊的左邊界上,這樣才能保證區間長度在不斷縮小。

除了上述這些回滾莫隊的共性點之外,我們還需要關注一些特性點。對於這個問題,我們需要在最開始將左右邊界分別設置爲 11nn,這樣的目的是保證區間長度是不斷遞減的。然後求取答案時,我們需要維護兩部分答案,一部分是區間 [l,r][l,r] 中完全包含左端點所在塊的部分的答案,另一部分即爲當前查詢的結果。

保存第一部分答案的目的在於增加節點是不能 O(1)O(1) 維護答案的,因此左端點遞增之後答案就會變化而且不能恢復,所以如果不保存第一部分的答案是不能直接繼承到下一個查詢的,具體細節看代碼就能夠理解。

代碼:

#include <bits/stdc++.h>
#define rep(i,a,b) for(int i = a; i <= b; i++)
const int N = 2e5+100;
using namespace std;

int n,m,a[N],sz,pos[N],xl[N],xr[N],cnt[N],ans[N],_cnt[N],lastblock,L,R;
struct Node{
	int l,r,id;
	bool operator < (Node xx) const {
		if(pos[l] == pos[xx.l]) return r > xx.r;
		else return pos[l] < pos[xx.l];
	}
}q[N];

void init(){
	scanf("%d%d",&n,&m);
	rep(i,1,n) scanf("%d",&a[i]);
	rep(i,1,m) scanf("%d%d",&q[i].l,&q[i].r), q[i].id = i;
	rep(i,1,n)
		if(a[i] > 2e5) a[i] = 2e5+1;
	int sz = sqrt(n);
	rep(i,1,n){
		pos[i] = i/sz; //點i所在塊
		xl[pos[i]] = xl[pos[i]] == 0 ? i : xl[pos[i]]; //pos[i]塊的左端點
		xr[pos[i]] = xr[pos[i]] < i ? i : xr[pos[i]]; //pos[i]塊的右端點
	}
	sort(q+1,q+1+m);
}

inline void add(int x){
	cnt[a[x]]++;
}

inline void del(int x,int& hp){
	cnt[a[x]]--;
	if(cnt[a[x]] == 0 && a[x] < hp) hp = a[x];
}

void solve(){
	L = 1, R = n, lastblock = -1;
	rep(i,1,n) cnt[a[i]]++;
	int minn = 0;
	while(cnt[minn]) minn++;
	int base_min = minn;
	rep(i,1,m){
		if(pos[q[i].l] == pos[q[i].r]){
			rep(j,q[i].l,q[i].r) _cnt[a[j]]++;
			int now = 0;
			while(_cnt[now]) now++;
			rep(j,q[i].l,q[i].r) _cnt[a[j]]--;
			ans[q[i].id] = now;
		}
		else{
			if(lastblock != pos[q[i].l]){
				//每一次進入新的塊時,右端點都是直接到n的,因此區間只有左端點在遞增,可以不斷O(1)維護答案
				while(R < n) R++, add(R);
				while(L < xl[pos[q[i].l]]) del(L,base_min), L++;
				minn = base_min; lastblock = pos[q[i].l];
			}
			//minn爲包含左端點整個塊的答案,用於繼承到後續查詢
			while(R > q[i].r) del(R,minn), R--;
			//temp爲查詢的答案
			int temp = minn;
			while(L < q[i].l) del(L,temp), L++;
			while(L > xl[pos[q[i].l]]) L--, add(L);
			ans[q[i].id] = temp;
		}
	}
}

int main()
{
	init();
	solve();
	rep(i,1,m) printf("%d\n",ans[i]);
	return 0;
}

總結

其實莫隊說到底就是一個分塊算法,主要關鍵點就在於能不能 O(1)O(1) 的增刪節點,可不可以離線,O(nn)O(n\sqrt n) 以及 O(n53)O(n^{\frac{5}{3}}) 能不能接受。

幾個莫隊算法的主要區別就在於是否需要修改,有無上樹,是否可以 O(1)O(1) 增刪節點,還是隻能滿足其一,辨別出關鍵點之後就比較容易上手,所以題目如果沒有思路的話一定要想起這個離線分塊算法哦!

最後,祝大家 A 題愉快!(๑•̀ㅂ•́)و✧

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章