1. 程式人生 > 實用技巧 >莫隊學習筆記

莫隊學習筆記

都0202年了怎麼還會考根號資料結構呢?

莫隊是一種用於處理靜態區間查詢的一類方法,其的時間複雜度為 \(O(m\sqrt n)\)

當然,由於其適用面之廣,也出現了諸如帶修莫隊,線上莫隊,二次離線莫隊,樹上莫隊,二維莫隊 以及套在一起 的變形。

1.普通莫隊

首先我們要明白,莫隊究竟是怎麼優化暴力的。

考慮這樣一道題:給定 \(n\) 個數字,\(m\) 次詢問區間 \([l_i,r_i]\) 出現次數最多的數的出現次數。\(n,m,a_i\leq 10^5\)

這是最經典的區間眾數,一般情況只有 \(O(m\sqrt n)\) 的做法。莫隊就是其中一種。

考慮如何用莫隊處理這件事:

首先如果只有一次詢問,是不是很好處理:直接用一個桶記錄某個數字出現次數即可,然後更新的同時處理最大值。

那麼再考慮如果保證詢問 \(\forall i<j\leq m\ ,\ l_i\leq l_j\ ,\ r_i\leq r_j\) 怎麼處理?

顯然我們可以用類似於雙指標的思想,不斷加入右指標 \(pr\) 的值並向右移動直到 \(pr=r_i\),再不斷刪除左指標 \(pl\) 的值並向右移動直到 \(pl=l_i\)。複雜度 \(O(n+m)\)

再考慮如果保證詢問 \(\forall i,j\leq m\ ,\ \text{若}\ l_i< l_j\ \text{那麼}\ r_i\leq r_j\) 怎麼處理?

按左端點排個序就好了。複雜度 \(O(n\log n)\)

但是,幾乎不會有題會有這種限制。如果單純的按左端點排序,右指標移來移去,顯然會被卡成 \(O(n^2)\)

所以現在我們就要一個新的排序方式,這就是莫隊的精髓(沒錯,重點就在於排序)。

我們發現,假如我們按左端點為第一關鍵字,右端點為第二關鍵字,那麼右指標的運動距離是 \(O(n^2)\) 的,而左指標卻只有 \(O(n)\)

顯然這應該是可以優化的。具體來說,我們並不一定要求左指標嚴格遞增,只要它不超過 \(O(\sqrt{n})\) 就好了。

這樣我們得出了一種排序:首先先將所有點按某一數字 \(B\) 分塊,即 \(b_i=\frac i B\)

然後對於一個區間,先按左指標的所在塊排序,再按右指標排序,即 \(b_{l_i}\)

為第一關鍵字,\(r_i\) 為第二關鍵字。

可以發現,由於最多隻有 \(\frac n B\) 個塊,所以左指標移動路程為 \(O(\frac{n^2} B)\),而右指標在每個塊中最多移動 \(O(n)\) 步,所以移動路程為 \(O(nB)\)

所以總複雜度 \(O(\frac{n^2} B+nB)\),顯然當 \(B=\sqrt m\) 時最小,即 \(O(n\sqrt m+m\sqrt n)\)

例題太多了,洛谷題單裡面較簡單的應該都是普通莫隊。

雖然它是一個根號演算法,但是它常數真的很小。它還有一些優化,其中比較有用的是奇偶優化,即對於奇數的右端點從左到右排,偶數的從右到左排。

這樣由於處理完奇數後右端點在最右端,不用移回左端點重新做,所以可以減小將近一半的常數。

2.帶修莫隊

雖然普通莫隊很優秀,常數也比分塊高明瞭不少,但是它終究還是靜態的,如果加一個修改就沒辦法了。

所以我們就需要一個變形:帶修莫隊。

首先我們把修改操作也離線下來,看成一個時間。然後對於某個詢問,也加上時間,即 \((l,r,t)\) 表示詢問 \([l,r]\) 區間,並且在 \(t\) 時間操作後進行詢問。

然後我們把時間也看成一個指標。特別,如果加入的修改在當前區間裡面,應當立刻對區間答案進行修改處理影響。

那麼這樣,我們的排序就變成:先按 \(b_{l_i}\) 排序,再按 \(b_{r_i}\) 排序,再按 \(t_i\) 排序。

分析複雜度:\(O(\frac{n^3} {B^2}+\frac{n^2} B+nB)\)。取 \(B=n^{\frac 2 3}\)。得到 \(O(n^{\frac 5 3})\)

同樣,常數還是很小。不要相信莫隊的時間複雜度

3.回滾莫隊

按照莫隊的做法,我們需要支援一個數據結構,使之能夠快速插入/刪除一個數字。

但事實上有很多資料結構刪除會存在問題,比如並查集/線性基/求最大值。這時候普通莫隊就會出現問題。

那麼,有沒有一種莫隊能夠只插入呢?有的,這就是回滾莫隊。

具體來說,對於兩個區間 \([l_1,r_1],[l_2,r_2]\),如果 \(l_1,l_2\) 在同一個塊中,那麼該塊的右端點到 \(\min(r_1,r_2)\) 這段區間是公共的。

可以發現,按照莫隊的排序,如果左指標在同一個塊中,那麼右指標單調遞增。所以右指標是不會有刪除操作的。

所以我們不妨每次先記錄左指標移動前的答案和移動時的所有變化,在移動結束之後,將這部分變化和左指標歸位,即“回滾”。

特別的,對於左右端點在同一塊內的情況,暴力處理即可。如果兩次操作不同塊,直接暴力清空所有資料,然後左右指標直接移動到新塊的右端點。

可以發現,由於在同一塊內,每次左指標移動距離 \(O(\sqrt n)\),復原一次 \(O(\sqrt n)\),右端點一個塊中均攤 \(O(m)\),所以總複雜度還是 \(O(n\sqrt m+m\sqrt n)\)

例:[joisc2014]歴史の研究

4.樹上莫隊

詳見 [WC2013] 糖果公園。其實樹上莫隊應該叫樹+莫隊,因為莫隊處理的還是序列。

5.線上莫隊

這個其實和普通莫隊有一些出入了。畢竟莫隊的本質應該就是那個排序(?)。

首先,我們要處理的詢問是允許差分的,即對於 \([l,r]_x\)\(x\) 對於區間 \([l,r]\) 的貢獻)的資訊,我們可以通過 \([1,r]_x\)\([1,l-1]_x\) 推出。

考慮強制線上時,我們不能得到上一次的結果。所以我們對 \(B\) 個區間分別設定一個關鍵點,然後處理所有 \([1,b_i]_x\)。對於塊與塊之間的資訊,我們只需要記錄答案就行。

這樣預處理時空複雜度 \(O(Bn+\frac{n^2} B)\)

詢問的時候我們從最近的一段塊轉移過來,時間複雜度 \(O(Bm)\)

顯然當 \(B=\sqrt n\) 時總時間複雜度 \(O(m\sqrt n)\) 最優。

6.二次離線莫隊

一個很神仙的演算法,不過不是很難理解。

當然使用的前提還是詢問可差分.

但是我們發現直接莫隊,由於插入/刪除的時間 \(T(n)\) 比較大(比如區間逆序對中採用樹狀陣列是 \(T(n)=O(\log n)\)),總時間可能會退化成 \(O(T(n)n\sqrt n)\) 而難以接受。

考慮如何優化。我們可以把操作看成若干次 \(\pm\ [l,r]_x\) 操作。差分後就是若干次 \(\pm\ [1,r]_x\)

而這個可以直接離線從左往右 \(O(nT(n)+M)\) 完成。這裡 \(M\) 是莫隊移動指標的次數,即 \(M=O(m\sqrt n)\)

這樣時間已經足夠優秀了,不過空間是 \(O(n+M)\)\(O(m\sqrt n)\) 的,可能會爆炸。

考慮如何優化。因為 \([1,r]_x\)\(x=l\operatorname{或}r\operatorname{或}l-1\operatorname{或}r+1\),對於2,4種我們完全可以先預處理出來,直接計入答案。

考慮1,3種,我們移動左指標時右指標並不會移動,所以此時連續的 \(x_i\) 一定構成一個區間。我們直接存下這個區間就好了。

這樣空間複雜度 \(O(n+m)\),時間 \(O((n+m)T(n)+m\sqrt n)\),十分優秀。

例:【模板】莫隊二次離線
首先 \(a\operatorname{xor} b=c\Rightarrow a\operatorname{xor} c=b\),而 \(\operatorname{popcount}(c)=k\),所以我們可以列舉所有 \(c\),得到 \(b\)

因為一次插入/刪除操作只能用比較暴力的列舉,即一次 \(\binom {14} k\),最壞大約有3000多次。

顯然我們無法接受 \(O(m\sqrt n\binom {14} k)\)。考慮二次離線。

顯然一個數字對區間的貢獻 \([l,r]_x=[1,r]_x-[1,l-1]_x\) 可以差分。按照上述方式就可以做到 \(O(n\binom {14} k+m\sqrt n)\) 的優秀複雜度。

細節比較多。

#include<iostream>
#include<cstdio>
#include<cstring>
#include<vector>
#include<algorithm>
#define ll long long
#define N 100010
#define B 14
#define M 16400
using namespace std;
int g[3500],f[M],tot;
int bl[N],a[N];
inline void ins(int x,int v){for(int i=1;i<=tot;i++) f[g[i]^x]+=v;}
struct node{
	int l,r,id;
	bool operator <(const node a)const{return bl[l]==bl[a.l]?r<a.r:bl[l]<bl[a.l];}
}q[N];
ll ans[N];
struct ques{
	int l,r,id;//[1,x]_l~[1,x]_r
};
vector<ques>v[N];
ll res1[N],res2[N];//[1,x]_{x+1},[1,x+1]_{x}
int fl=1,fr,uid;
void addqr()
{
	++fr;
	if(v[fl-1].size() && v[fl-1][v[fl-1].size()-1].id==-uid) v[fl-1][v[fl-1].size()-1].r=fr;
	else v[fl-1].push_back((ques){fr,fr,-uid});
}
void delqr()
{
	if(v[fl-1].size() && v[fl-1][v[fl-1].size()-1].id==uid) v[fl-1][v[fl-1].size()-1].l=fr;
	else v[fl-1].push_back((ques){fr,fr,uid});
	--fr;
}
void addql()
{
	if(v[fr].size() && v[fr][v[fr].size()-1].id==-uid) v[fr][v[fr].size()-1].r=fl;
	else v[fr].push_back((ques){fl,fl,-uid});
	++fl;
}
void delql()
{
	--fl;
	if(v[fr].size() && v[fr][v[fr].size()-1].id==uid) v[fr][v[fr].size()-1].l=fl;
	else v[fr].push_back((ques){fl,fl,uid});
}
int main()
{
	int n,m,k;
	scanf("%d%d%d",&n,&m,&k);
	for(int i=0;i<1<<B;i++)
	if(__builtin_popcount(i)==k) g[++tot]=i;
	for(int i=1;i<=n;i++) scanf("%d",&a[i]),bl[i]=i/344;
	for(int i=1;i<=m;i++) scanf("%d%d",&q[i].l,&q[i].r),q[i].id=i;
	sort(q+1,q+m+1);
	for(uid=1;uid<=m;uid++)
	{
		while(fl<q[uid].l) addql();
		while(fl>q[uid].l) delql();
		while(fr<q[uid].r) addqr();
		while(fr>q[uid].r) delqr();
	}
	for(int i=1;i<=n;i++)
	{
		res1[i]=f[a[i]];
		ins(a[i],1);
		res2[i]=f[a[i]];
		for(int j=0;j<(int)v[i].size();j++)
		{
			for(int k=v[i][j].l;k<=v[i][j].r;k++)
			{
				if(v[i][j].id<0) ans[-v[i][j].id]-=f[a[k]];
				else ans[v[i][j].id]+=f[a[k]];
			}
		}
	}
	fl=1,fr=0;
	for(uid=1;uid<=m;uid++)
	{
		ans[uid]+=ans[uid-1];
		while(fl<q[uid].l) ans[uid]+=res2[fl],fl++;
		while(fl>q[uid].l) --fl,ans[uid]-=res2[fl];
		while(fr<q[uid].r) ++fr,ans[uid]+=res1[fr];
		while(fr>q[uid].r) ans[uid]-=res1[fr],fr--;
	}
	for(int i=1;i<=m;i++) res1[q[i].id]=ans[i];
	for(int i=1;i<=m;i++) printf("%lld\n",res1[i]);
	return 0;
}