莫隊演算法學習筆記

pyy1發表於2024-03-30

Part.1 引入

當你遇到一個區間詢問但是難以用線段樹等 log 演算法維護的時候怎麼辦?那就是——莫隊!

莫隊這個東西能支援區間修改、區間查詢的操作,但是這種演算法要求離線。莫隊有很多種,詳細請看下文。

Part.2 普通莫隊

我們先來看一道例題(P1972 的削弱版):

給你一個長度為 \(n\) 的序列 \(a\)\(m\) 次查詢,詢問區間 \([l,r]\) 有多少個不同的數。

資料範圍:\(1\le n,m\le 10^5,1\le a_i\le 10^5\)

普通的暴力就是每次遍歷這個區間,拿個桶記一下,每次需要清空桶。

發現每次清空桶十分浪費,所以可以考慮從上一次的詢問區間伸縮過來。就是記一個 \(tl,tr\),初始為 \(tl=1,tr=0\)。每次把 \(tl\)\(l\) 上靠,把 \(tr\)\(r\) 上靠。程式碼如下:

//add(x) 是加入下標為 x 的數,del(x) 是減去下標為 x 的數,這兩個函式視情況而定
while(tl>l) add(--tl);
while(tr<r) add(++tr);
while(tl<l) del(tl++);
while(tr>r) del(tr--);

當然這不是莫隊,他是可以被卡的(大小區間交替詢問,移動的量級就變成 \(nm\))。

莫隊,就是透過離線後對詢問左右端點排序,達到降低複雜度的目的。

先講做法,記一個塊長 \(B = \sqrt n\),然後以 \(\lfloor\frac{l}{B}\rfloor\) 為第一關鍵字,\(r\) 為第二關鍵字,從小到大排序。這樣處理完所有詢問的時間複雜度上界為 \(O(n\sqrt n)\)

為啥這樣能保證時間複雜度呢?為了方便,我們定義點 \(i\) 在編號為 \(\lfloor{\frac{i}{B}}\rfloor\) 的塊內。不妨設 \(n,m\) 同階。考慮兩種情況:

  1. 上一個左端點和當前處理的左端點在一個塊內:那麼左端點最多移動 \(B\) 次,總共就只有 \(nB\) 次;詢問左端點在一個塊內的右端點是單調遞增的,所以一個塊內至多移動 \(n\) 次,而最多有 \(\lceil\frac{n}{B}\rceil\) 個塊,所以總詢問次數是 \(\lceil\frac{n}{B}\rceil n\)
  2. 上一個左端點和當前處理的左端點不在一個塊內:左端點最多移動 \(2B\) 次,右端點也只移動 \(n\) 次,所以這部分的移動次數就只有 \(2B\lceil\frac{n}{B}\rceil+\lceil\frac{n}{B}\rceil n\)

平均一下,當 \(B\)\(\sqrt n\) 時,時間複雜度就是 \(O(n\sqrt n)\)

給出上面例題的程式碼:

#include<bits/stdc++.h>
using namespace std;
const int N = 1e6+5;
int n,m,a[N],cnt[N],res,ans[N],B;
struct node{
    int l,r,id;
    inline void init(int x){cin>>l>>r,id = x;}
    inline bool friend operator < (node x,node y)//過載運算子,相當於寫一個 cmp
    {
        if(x.l/B!=y.l/B) return x.l<y.l;
        return x.r<y.r;
    }
}q[N];
inline void add(int x)
{
    x = a[x];
    cnt[x]++;
    if(cnt[x]==1) res++;
}
inline void del(int x)
{
    x = a[x];
    cnt[x]--;
    if(!cnt[x]) res--;
}
signed main()
{
    ios::sync_with_stdio(0),cin.tie(0),cout.tie(0);
    cin>>n;
    B = sqrt(n);
    for(int i = 1;i<=n;i++)
        cin>>a[i];
    cin>>m;
    for(int i = 1;i<=m;i++)
        q[i].init(i);
    sort(q+1,q+m+1);
    int l = 1,r = 0;
    for(int i = 1;i<=m;i++)
    {
        while(l>q[i].l) add(--l);
        while(r<q[i].r) add(++r);
        while(l<q[i].l) del(l++);
        while(r>q[i].r) del(r--);
        ans[q[i].id] = res;
    }
    for(int i = 1;i<=m;i++)
        cout<<ans[i]<<'\n';
    return 0;
}

當然,上述演算法還能最佳化,比如奇偶排序(最佳化常數)、二次離線(去掉一隻 log)。感興趣的可以自己學習。

Part.3 帶修莫隊

普通莫隊是不支援修改的,如果有修改操作的話,就可以請出帶修莫隊了!

先給一道例題:P1903,其實就是在普通莫隊的例題基礎上加一個單點修改。

其實就是給普通莫隊加一個時間戳,即之前有多少個修改操作,排序時以其作為第三關鍵字。處理答案時就多記一個當前時間戳 \(t\)。每次把 \(t\) 移動到詢問的時間戳,進行修改,並把在詢問區間內的修改加入貢獻。

\(B\) 取到 \(n^{\frac{2}{3}}\) 時,有最優複雜度 \(O(n^{\frac{5}{3}})\)。我太弱了,不會證明。

貼上例題程式碼:

#include <bits/stdc++.h>
using namespace std;
const int N = 133333+5,M = 1e6+5;
int qsize;
struct que{
	int id,t,l,r;
	inline friend bool operator < (que x,que y)
	{
		if(x.l/qsize!=y.l/qsize) return x.l/qsize<y.l/qsize;
		if(x.r/qsize!=y.r/qsize) return x.r/qsize<y.r/qsize;
		return x.t<y.t;
	}
}q[N];
struct op{
	int p,x;
}o[N];
int n,m,ans,mp[M],a[N],qcnt,ocnt,out[N];
inline void add(int x)
{
	mp[x]++;
	if(mp[x]==1) ans++;
}
inline void del(int x)
{
	mp[x]--;
	if(!mp[x]) ans--;
}
signed main()
{
	ios::sync_with_stdio(0),cin.tie(0),cout.tie(0);
	cin>>n>>m;
	qsize = pow(n,2.0/3.0);
	for(int i = 1;i<=n;i++)
		cin>>a[i];
	for(int i = 1,x,y;i<=m;i++)
	{
		char op;
		cin>>op>>x>>y;
		if(op=='Q') q[++qcnt] = {qcnt,ocnt,min(x,y),max(x,y)};
		else o[++ocnt] = {x,y};
	}
	sort(q+1,q+qcnt+1);
	int l = 1,r = 0,las = 0;
	for(int i = 1;i<=qcnt;i++)
	{
		while(r<q[i].r) add(a[++r]);
		while(r>q[i].r) del(a[r--]);
		while(l>q[i].l) add(a[--l]);
		while(l<q[i].l) del(a[l++]);
		while(las<q[i].t)
		{
			las++;
			if(o[las].p>=l&&o[las].p<=r) del(a[o[las].p]),add(o[las].x);
			swap(a[o[las].p],o[las].x);
		}
		while(las>q[i].t)
		{
			if(o[las].p>=l&&o[las].p<=r) del(a[o[las].p]),add(o[las].x);
			swap(a[o[las].p],o[las].x);
			las--;
		}
		out[q[i].id] = ans;
	}
	for(int i = 1;i<=qcnt;i++)
		cout<<out[i]<<'\n';
	return 0;
}

Part.4 回滾莫隊

回滾莫隊解決的問題就是加入一個數很好維護,但是刪除這個數不好維護(比如區間最值之類的)。其思想就是每次右端點慢慢加,左端點到目標點時計算答案再回到原來的點。

仍然甩出一道例題:SP20644。讓你統計區間中和為零的區間最大長度。

先把問題轉化成字首和,相當於在問你區間中字首和相同的地方最大的長度,然後就變成了P5906

我們還是按照普通莫隊的方式排序。回滾莫隊由以下幾部分組成:

  1. 左右端點在一個塊內,直接暴力做;
  2. 左端點的塊和上一個的不同,設這個塊的右端點為 \(rt\),那麼 \(now_l\) 就要移動到 \(rt+1\)\(now_r\) 就要移動到 \(rt\),並把當前答案清零;
  3. \(now_r\) 移動到當前詢問的右端點,一邊移動一邊計算答案;
  4. \(now_l\) 移動到當前詢問的左端點,注意移動完之後需要回到原來的位置,所以記錄原來的答案以便復原,其餘的正常計算貢獻;
  5. \(now_l\) 移動回去,我們只需要把這段區間的貢獻消掉就行,這是難點。然後把答案還原。

需要注意的是,回滾莫隊不支援奇偶排序

回到這道例題,考慮如何消掉貢獻。我們計算答案的時候維護一個 \(mx_i\) 表示 \(i\) 最先出在那個位置,而 \(mx_i\) 小於原來 \(now_i\)​ 的就會消掉貢獻。

放程式碼:

#include <bits/stdc++.h>
using namespace std;
const int N = 5e4+5;
int n,m,a[N],b[N],blk,lt[N],rt[N];
struct node{
	int l,r,id;
	inline void init(int x){cin>>l>>r,l--,id = x;}
	inline friend bool operator < (node x,node y)
	{
		if(b[x.l]!=b[y.l]) return x.l<y.l;
		return x.r<y.r;
	}
}q[N];
int mn[N<<1],mx[N<<1],ans[N];
signed main()
{
    ios::sync_with_stdio(0),cin.tie(0),cout.tie(0);
    cin>>n>>m;
	blk = sqrt(n);
	a[0] = n,b[0] = 1;
	for(int i = 1;i<=n;i++)
		cin>>a[i],b[i] = (i-1)/blk+1,a[i]+=a[i-1];
	for(int i = 1;i<=b[n];i++)
		rt[i] = i*blk;
	rt[b[n]] = n;
	for(int i = 1;i<=m;i++)
		q[i].init(i);
	sort(q+1,q+m+1);
	int l = 0,r = 0,las = 0,tmp = 0;
	for(int i = 1;i<=m;i++)
	{
		if(b[q[i].l]==b[q[i].r])
		{
			for(int j = q[i].l;j<=q[i].r;j++)
				mx[a[j]] = 0;
			tmp = 0;
			for(int j = q[i].r;j>=q[i].l;j--)
				if(!mx[a[j]]) mx[a[j]] = j;
				else tmp = max(tmp,mx[a[j]]-j);
			ans[q[i].id] = tmp;
			for(int j = q[i].l;j<=q[i].r;j++)
				mx[a[j]] = 0;
			continue;
		}
		if(b[q[i].l]!=las)
		{
			while(l<rt[b[q[i].l]]+1) mx[a[l]] = mn[a[l]] = 0,l++;
			while(r>rt[b[q[i].l]]) mx[a[r]] = mn[a[r]] = 0,r--;
			r = l-1;
			tmp = 0,las = b[q[i].l];
		}
		while(r<q[i].r)
		{
			r++;
			if(!mn[a[r]]) mn[a[r]] = mx[a[r]] = r;
			else tmp = max(tmp,r-mn[a[r]]),mx[a[r]] = r;
		}
		int _l = l,res = tmp;
		while(_l>q[i].l)
		{
			_l--; 
			if(!mx[a[_l]]) mx[a[_l]] = _l;
			else res = max(res,mx[a[_l]]-_l); 
		}
		ans[q[i].id] = res;
		while(_l<l)
		{
			if(mx[a[_l]]==_l) mx[a[_l]] = 0;
			_l++;
		}
	}
	for(int i = 1;i<=m;i++)
	    cout<<ans[i]<<'\n';
	return 0;
}

另外推薦一道回滾莫隊好題:AT_joisc2014_c

Part.5 樹上莫隊

還是先給一道例題:SP10707

我們考慮對樹進行 DFS,求出其尤拉序。

給個例子:

graph

這顆樹的尤拉序為 \(1,2,4,4,2,3,5,7,7,8,8,5,6,6,3,1\)。我們記節點 \(i\) 第一次出現的位置為 \(st_i\),第二次出現的位置為 \(ed_i\)

考慮詢問 \(u\)\(v\) 這條路徑,不妨設 \(st_u<st_v\),分兩種情況討論:

  1. \(v\)\(u\) 的子樹中,我們只需要去掉 \(v\) 的子樹,詢問區間就是 \(st_u\sim st_v\)
  2. 否則,我們需要去掉 \(u,v\) 的子樹,那麼詢問 \(ed_u\sim st_v\) 即可。但是發現走到 \(st_v\) 時還沒有退出 \(lca(u,v)\) 的子樹,所以還要單獨算上 \(lca(u,v)\)

其他的和普通莫隊都是一樣的,但注意要開兩倍空間!

上程式碼:

#include <bits/stdc++.h>
using namespace std;
const int N = 1e5+5;
int n,m,idx,ans[N],a[N],b[N],tt,cnt[N],res,st[N],ed[N],pre[N],son[N],dep[N],top[N],sz[N],f[N],qsize;
vector<int> g[N];
bool vis[N];
void dfs1(int u,int fa)
{
	f[u] = fa,dep[u] = dep[fa]+1,sz[u] = 1,st[u] = ++idx,pre[idx] = u;
	for(auto v:g[u])
	{
		if(v==fa) continue;
		dfs1(v,u);
		sz[u]+=sz[v];
		if(sz[v]>sz[son[u]]) son[u] = v;
	}
	ed[u] = ++idx,pre[idx] = u;
}
void dfs2(int u,int tp)
{
	top[u] = tp;
	if(!son[u]) return;
	dfs2(son[u],tp);
	for(auto v:g[u])
	{
		if(v==f[u]||v==son[u]) continue;
		dfs2(v,v);
	}
}
inline int Lca(int x,int y)
{
	while(top[x]!=top[y])
	{
		if(dep[top[x]]<dep[top[y]]) swap(x,y);
		x = f[top[x]];
	}
	if(dep[x]>dep[y]) swap(x,y);
	return x;
}
struct node{
	int l,r,lca,id;
	inline void init(int x)
	{
		id = x;
		int u,v;
		cin>>u>>v;
		if(st[u]>st[v]) swap(u,v);
		lca = Lca(u,v);
		if(lca==u) l = st[u],r = st[v],lca = 0;
		else l = ed[u],r = st[v];
	}
	inline friend bool operator < (node x,node y)
	{
		if(x.l/qsize==y.l/qsize) return x.r<y.r;
		return x.l<y.l;
	}
}q[N];
inline void add(int x)
{
	cnt[x]++;
	if(cnt[x]==1) res++;
}
inline void del(int x)
{
	cnt[x]--;
	if(cnt[x]==0) res--;
}
inline void work(int x)
{
	x = pre[x];
	vis[x]^=1;
	if(vis[x]) add(a[x]);
	else del(a[x]);
}
signed main()
{
    ios::sync_with_stdio(0),cin.tie(0),cout.tie(0);
    cin>>n>>m;
	for(int i = 1;i<=n;i++)
	    cin>>a[i],b[++tt] = a[i];
	sort(b+1,b+tt+1),tt = unique(b+1,b+tt+1)-b-1;
	for(int i = 1;i<=n;i++)
		a[i] = lower_bound(b+1,b+tt+1,a[i])-b;
	for(int i = 1,u,v;i<n;i++)
		cin>>u>>v,g[u].push_back(v),g[v].push_back(u);
	dfs1(1,0),dfs2(1,1);
	for(int i = 1;i<=m;i++)
		q[i].init(i);
	qsize = sqrt(n);
	sort(q+1,q+m+1);
	int l = 1,r = 0;
	for(int i = 1;i<=m;i++)
	{
		while(l>q[i].l) work(--l);
		while(r<q[i].r) work(++r);
		while(l<q[i].l) work(l++);
		while(r>q[i].r) work(r--);
		if(q[i].lca) work(st[q[i].lca]);
		ans[q[i].id] = res;
		if(q[i].lca) work(st[q[i].lca]);
	}
	for(int i = 1;i<=m;i++)
	    cout<<ans[i]<<'\n';
	return 0;
}

Part.6 總結

莫隊是個非常好的資料結構,建議深度學習!

碼字不易,給個贊吧~

\[\text{THE END} \]

相關文章