這幾天學習了莫隊算法,試着寫一篇比較詳細的莫隊教程吧...
普通莫隊
簡介
莫隊是一種基於分塊思想的離線算法,用於解決區間問題,適用范圍如下:
-
只有詢問沒有修改。
-
允許離線。
-
在已知詢問 \([l,r]\) 答案的情況下可以 \(O(1)\) 得到 \([l,r-1],[l,r+1],[l-1,r],[l+1,r]\) 的答案。
滿足以上三個條件就可以在 \(O(n\sqrt{m}+mlogm)\) 的時間復雜度下得到每個詢問的解。
算法思想
莫隊的精髓就在於通過對詢問進行排序,並把詢問的結果作為下一個詢問求解的基礎,使得暴力求解的復雜度得到保證。
上文中“適用范圍”的第三點“在已知詢問 \([l,r]\) 答案的情況下可以 \(O(1)\) 得到 \([l,r-1],[l,r+1],[l-1,r],[l+1,r]\) 的答案”即是“把詢問的結果作為下一個詢問求解的基礎”的方法。
在這題中,用 \(cnt_i\) 表示當前處理的區間內顏色為i的襪子出現的次數,用 \(\mathrm{len}\) 表示當前處理的區間的長度,用 \(x\) 表示新增的那只襪子的顏色。
以已知區間 \([l,r]\) 的答案求解區間 \([l,r+1]\) 為例。分別處理分子和分母:
- 分母為任選兩只襪子的組合總數,原先是 \(\frac{\mathrm{len}*(\mathrm{len}-1)}{2}\),現在是 \(\frac{\mathrm{len}*(\mathrm{len}+1)}{2}\),增加了 \(\mathrm{len}\) 。
- 分子為兩只襪子顏色相同的組合總數,比原來增加了 \(cnt_x\),即新增的這只襪子和原本就在當前區間內的相同顏色的襪子的組合。
因此,將一只顏色為x的襪子計入答案的函數就可以寫出來了:
//fz代表分子,fm代表分母
void add(int x)
{
fz+=cnt[x];
++cnt[x];
fm+=len;
++len;
}
同理可以寫出將一只顏色為x的襪子移出答案的函數:
void del(int x)
{
--cnt[x];
fz-=cnt[x];
--len;
fm-=len;
}
於是,我們就可以得到一個暴力的算法:用 \(l\) 和 \(r\) 分別記錄當前區間的兩個端點,然后用下面這段代碼來更新答案(q[i].l,q[i].r代表正在處理的詢問的兩個端點,col[p]代表第 \(p\) 只襪子的顏色):
while (l>q[i].l)
{
add(col[--l]);
}
while (r<q[i].r)
{
add(col[++r]);
}
while (l<q[i].l)
{
del(col[l++]);
}
while (r>q[i].r)
{
del(col[r--]);
}
然而,這個算法的時間復雜度是 \(O(nm)\) 的(因為最壞情況下每次 \(l\) 和 \(r\) 兩個指針都要走 \(O(n)\) 的距離,而一共有 \(m\) 次詢問),和暴力完全一樣甚至跑的更慢。
別忘了,之前我說過,莫隊的精髓就在於通過對詢問進行排序,使得暴力求解的復雜度得到保證。
我們的目的是使 \(l\) 和 \(r\) 兩個指針走過的總距離盡量的小,這時候就要用到分塊的思想了。
把整個區間 \([1,n]\) 分成若干塊,以詢問的左端點所在塊為第一關鍵字,以詢問的右端點大小為第二關鍵字,對詢問進行排序,那么:
- 對於同一塊的詢問,\(l\) 指針每次最多移動塊的大小,\(r\) 指針的移動則是單調的,總共移動最多 \(n\) 。
- 對於不同塊的詢問,\(l\) 每次換塊時最多移動兩倍塊的大小, \(r\) 每次換塊時最多移動 \(n\) 。
總結:(用 \(B\) 表示塊的大小)\(l\) 指針每次移動 \(O(B)\),\(r\) 指針每塊移動 \(O(n)\) 。
所以:
- \(l\) 的移動次數最多為詢問數×塊的大小,即 \(O(mB)\) 。
- \(r\) 的移動次數最多為塊的個數×總區間大小,即 \(O(n^2/B)\) 。
因此,總移動次數為 \(O(mB+n^2/B)\) 。
沒錯,這就是個雙勾函數,所以當 \(B=\sqrt{\frac{n^2}{m}}\) 即 \(\frac{n}{\sqrt{m}}\) 時復雜度最小,為 \(O(n\sqrt{m})\) 。
剩下的最后一個問題:初始的當前區間是什么?
只要任意指定一個空區間就好了,如 \(l=1,r=0\) 。
所以,整個莫隊算法就可以概括為:
-
將詢問記錄下來。
-
以 \(\frac{n}{\sqrt{m}}\) 為塊的大小,以詢問的左端點所在塊為第一關鍵字,以詢問的右端點大小為第二關鍵字,對詢問進行排序。
-
暴力處理每個詢問。
-
輸出答案。
總的復雜度為 \(O(n\sqrt{m}+mlogm)\) 。
P.S. 網上很多教程說分塊大小取 \(\sqrt{n}\) 最優,復雜度為 \(O(n\sqrt{n})\),這是不嚴謹的,當n、m差別較大時使用 \(\sqrt{n}\) 作為分塊大小效率會明顯偏低。
例題代碼
[國家集訓隊]小Z的襪子 AC代碼:
#include <iostream>
#include <cstdio>
#include <algorithm>
#include <cmath>
using namespace std;
const int N=50010;
void add(int x);
void del(int x);
int gcd(int a,int b);
int n,m,B,fz,fm,len,col[N],cnt[N],ans[N][2];
struct Query
{
int l,r,id;
bool operator<(Query& b)
{
return l/B==b.l/B?r<b.r:l<b.l;
}
} q[N];
int main()
{
int i,l=1,r=0,g;
cin>>n>>m;
B=n/sqrt(m);
for (i=1;i<=n;++i)
{
cin>>col[i];
}
for (i=0;i<m;++i)
{
cin>>q[i].l>>q[i].r;
q[i].id=i;
}
sort(q,q+m);
for (i=0;i<m;++i)
{
if (q[i].l==q[i].r)
{
ans[q[i].id][0]=0;
ans[q[i].id][1]=1;
continue;
}
while (l>q[i].l)
{
add(col[--l]);
}
while (r<q[i].r)
{
add(col[++r]);
}
while (l<q[i].l)
{
del(col[l++]);
}
while (r>q[i].r)
{
del(col[r--]);
}
g=gcd(fz,fm);
ans[q[i].id][0]=fz/g;
ans[q[i].id][1]=fm/g;
}
for (i=0;i<m;++i)
{
printf("%d/%d\n",ans[i][0],ans[i][1]);
}
return 0;
}
void add(int x)
{
fz+=cnt[x];
++cnt[x];
fm+=len;
++len;
}
void del(int x)
{
--cnt[x];
fz-=cnt[x];
--len;
fm-=len;
}
int gcd(int a,int b)
{
return b==0?a:gcd(b,a%b);
}
其它例題
帶修莫隊
前面說過,普通的莫隊只能解決沒有修改的問題,那么帶修改的問題怎么解決呢?帶修莫隊就是一種支持單點修改的莫隊算法。
算法簡介
還是對詢問進行排序,每個詢問除了左端點和右端點還要記錄這次詢問是在第幾次修改之后(時間),以左端點所在塊為第一關鍵字,以右端點所在塊為第二關鍵字,以時間為第三關鍵字進行排序。
暴力查詢時,如果當前修改數比詢問的修改數少就把沒修改的進行修改,反之回退。
需要注意的是,修改分為兩部分:
-
若修改的位置在當前區間內,需要更新答案(del原顏色,add修改后的顏色)。
-
無論修改的位置是否在當前區間內,都要進行修改(以供add和del函數在以后更新答案)。
分塊大小的選擇以及復雜度證明
(用 \(B\) 表示分塊大小,\(c\) 表示修改個數,\(q\) 表示詢問個數,l塊表示以 \(l/B\) 分的塊,r塊表示以 \(r/B\) 分的塊,每個l塊包含 \(n/B\) 個r塊)
-
對於時間指針 \(now\):對於每個r塊,最壞情況下會移動 \(c\),共有 \(\left(\frac{n}{B}\right)^2\) 個r塊,所以總移動次數為 \(\frac{cn^2}{B^2}\) 。
-
對於左端點指針 \(l\) :l塊內移動每次最多 \(B\),換l塊每次最多 \(2B\),所以總移動次數為 \(O(qB)\) 。
-
對於右端點指針 \(r\):r塊內移動每次最多 \(B\),換r塊每次最多 \(2B\),所有l塊內移動次數之和為 \(O(qB)\);換l塊時最多移動 \(n\),總的換l塊時移動次數為 \(O\left(\frac{n^2}{B}\right)\);所以總的移動次數為 \(O\left(qB+\frac{n^2}{B}\right)\) 。
所以:總移動次數為 \(O\left(\frac{cn^2}{B^2}+qB+\frac{n^2}{B}\right)\) 。
由於一般的題目都不會告訴你修改和詢問分別的個數,所以統一用 \(m\) 表示,即 \(O\left(\frac{mn^2}{B^2}+mB+\frac{n^2}{B}\right)\) 。
那么 \(B\) 取多少呢...Mathematica告訴我大約是這個
(圖炸不補,就是一個很復雜的式子)。
所以還是不要糾結帶修莫隊的最佳分塊大小好了...視作 \(n=m\) 的話,就可以得到總移動次數為 \(O\left(\frac{n^3}{B^2}+nB+\frac{n^2}{B}\right)\),那么 \(B=n^{\frac{2}{3}}\) 時取最小值 \(O\left(n^{\frac{5}{3}}\right)\) 。
所以:帶修莫隊的漸進時間復雜度為 \(O\left(nlogn+n^{\frac{5}{3}}\right)\) (視作 \(n=m\))。
例題代碼
這次就不詳細分析例題了,直接上代碼。
[國家集訓隊]數顏色 AC代碼:
#include <iostream>
#include <cstdio>
#include <algorithm>
#include <cmath>
using namespace std;
void add(int x);
void del(int x);
void modify(int x,int ti); //這個函數會執行或回退修改ti(執行還是回退取決於是否執行過,具體通過swap實現),x表明當前的詢問是x,即若修改了區間[q[x].l,q[x].r]便要更新答案
int n,m,B,cnt[1000010],a[50010],ans,ccnt,qcnt,now,out[50010];
struct Change
{
int p,col;
} c[50010];
struct Query
{
int l,r,t,id;
bool operator<(Query& b)
{
return l/B==b.l/B?(r/B==b.r/B?t<b.t:r<b.r):l<b.l;
}
} q[50010];
int main()
{
int i,l=2,r=1;
char type[10];
cin>>n>>m;
B=pow(n,0.66666);
for (i=1;i<=n;++i)
{
cin>>a[i];
}
for (i=1;i<=m;++i)
{
scanf("%s",type);
if (type[0]=='Q')
{
++qcnt;
cin>>q[qcnt].l>>q[qcnt].r;
q[qcnt].t=ccnt;
q[qcnt].id=qcnt;
}
else
{
++ccnt;
cin>>c[ccnt].p>>c[ccnt].col;
}
}
sort(q+1,q+qcnt+1);
for (i=1;i<=qcnt;++i)
{
while (l>q[i].l)
{
add(a[--l]);
}
while (r<q[i].r)
{
add(a[++r]);
}
while (l<q[i].l)
{
del(a[l++]);
}
while (r>q[i].r)
{
del(a[r--]);
}
while (now<q[i].t)
{
modify(i,++now);
}
while (now>q[i].t)
{
modify(i,now--);
}
out[q[i].id]=ans;
}
for (i=1;i<=qcnt;++i)
{
cout<<out[i]<<endl;
}
return 0;
}
void add(int x)
{
if (cnt[x]++==0)
{
++ans;
}
}
void del(int x)
{
if (--cnt[x]==0)
{
--ans;
}
}
void modify(int x,int ti)
{
if (c[ti].p>=q[x].l&&c[ti].p<=q[x].r)
{
del(a[c[ti].p]);
add(c[ti].col);
}
swap(a[c[ti].p],c[ti].col); //下次執行時必定是回退這次操作,直接互換就可以了
}
其它例題
樹上莫隊
其實,莫隊算法除了序列還可以用於樹。復雜度同序列上的莫隊(不帶修 \(O(n\sqrt{m}+mlogm)\),帶修 \(O\left(nlogn+n^{\frac{5}{3}}\right)\))。
例題:[WC2013]糖果公園
分塊方式
這里需要看一道專門為樹上莫隊設計的題目 [SCOI2005]王室聯邦。
用這道題所要求的方式進行分塊,並用后文的方式更新答案,就能保證復雜度(復雜度分析見后文)。
那么如何滿足每塊大小在 \([B,3B]\),塊內每個點到核心點路徑上的所有點都在塊內呢?
這里先提供一種構造方式,再予以證明:
dfs,並創建一個棧,dfs一個點時先記錄初始棧頂高度,每dfs完當前節點的一棵子樹就判斷棧內(相對於剛開始dfs時)新增節點的數量是否>=B,是則將棧內所有新增點分為同一塊,核心點為當前dfs的點,當前節點結束dfs時將當前節點入棧,整個dfs結束后將棧內所有剩余節點歸入已經分好的最后一個塊。
參考代碼:
void dfs(int u,int fa)
{
int t=top;
for (int i=head[u];i;i=nxt[i])
{
int v=to[i];
if (v!=fa)
{
dfs(v,u);
if (top-t>=B)
{
++tot;
while (top>t)
{
bl[sta[top--]]=tot;
}
}
}
}
sta[++top]=u;
}
dfs(1,0);
while (top)
{
bl[sta[top--]]=tot;
}
如果你看懂了這個方法的話,每塊大小>=B是顯然的,下面證明為何每塊大小<=3B:
對於當前節點的每一棵子樹:
-
若未被分塊的節點數>B,那么在dfs這棵子樹的根節點時就一定會把這棵子樹的一部分分為一塊直至這棵子樹的剩余節點數<=B,所以這種情況不存在。
-
若未被分塊的節點數=B,這些節點一定會和棧中所有節點分為一塊,棧中之前還剩 \([0,B-1]\) 個節點,那么這一塊的大小為 \([B,2B-1]\) 。
-
若未被分塊的節點數<B,當未被分塊的節點數+棧中剩余節點數>=B時,這一塊的大小為 \([B,2B-1)\),否則繼續進行下一棵子樹。
對於dfs結束后棧內剩余節點,數量一定在 \([1,B]\) 內,而已經分好的每一塊的大小為 \([B,2B-1]\),所以每塊的大小都在 \([B,3B)\) 內(我看有的博客寫的剩余節點數量在 \([1,B+1]\) 內,所以最后一塊可能達到 \(3B\)...然而我覺得最多 \(3B-1\)啊QAQ)。
修改方式
所謂“修改”,就是由詢問 \((cu,cv)\) 更新至詢問 \((tu,tv)\) 。
如果把兩條路徑上的點全部修改..顯然是和暴力一樣的嘛!
這里直接給出結論好了...
(下文中 \(T(u,v)\) 表示 \(u\) 到 \(v\) 的路徑上除 \(lca(u,v)\) 外的所有點構成的集合,\(S(u,v)\) 代表u到v的路徑,\(xor\) 表示集合對稱差(就跟異或差不多))
-
兩個指針 \(cu,cv\) (相當於序列莫隊的 \(l,r\) 兩個指針), \(ans\)記錄\(T(cu,cv)\) 的答案,\(vis\) 數組記錄每個節點是否在 \(T(cu,cv)\) 內;
-
由 \(T(cu,cv)\) 更新至 \(T(tu,tv)\) 時,將 \(T(cu,tu)\) 和 \(T(cv,tv)\) 的 \(vis\) 分別取反,並相應地更新答案;
-
將答案記錄到 \(out\) 數組(離線后用於輸出那個)時對 \(lca(cu,cv)\) (此時的 \(cu,cv\) 已更新為上一步中的 \(tu,tv\)) 的 \(vis\) 取反並更新答案,記錄完再改回來(因為lca比較煩,所以就這樣做了QAQ)。
第二步證明如下:
\(\quad\,T(cu,cv)\ xor\ T(tu,tv)\)
\(=[S(cu,root)\ xor\ S(cv,root)]\ xor\ [S(tu,root)\ xor\ S(tv,root)]\) (lca及以上相消)
\(=[S(cu,root)\ xor\ S(tu,root)]\ xor\ [S(cv,root)\ xor\ S(tv,root)]\) (交換律、結合律)
\(=T(cu,tu)\ xor\ T(cv,tv)\)
之所以要把 \(T(cu,cv)\ xor\ T(tu,tv)\) 轉化成 \(T(cu,tu)\ xor\ T(cv,tv)\),是因為這樣的話就能通過對詢問排序來保證復雜度。
關於單點修改
樹上莫隊的單點修改和序列莫隊類似,唯一不同就是,修改后是否更新答案通過vis數組判斷。
復雜度分析
每塊大小在 \([B,3B)\),所以兩點間路徑長度也在 \([B,3B)\),塊內移動就是 \(O(B)\) 的;編號相鄰的塊位置必然是相鄰的,所以兩塊間路徑長度也是 \(O(B)\);然后就和序列莫隊的復雜度分析類似了...
例題代碼
#include <iostream>
#include <cstdio>
#include <algorithm>
#include <cmath>
using namespace std;
const int N=100010;
void pathmodify(int u,int v); //將T(u,v)取反並更新答案
void opp(int x); //將節點x取反並更新答案
void modify(int ti); //進行或回退修改ti
int lca(int u,int v);
void dfs(int u); //進行分塊並記錄dep數組、f數組(用於求lca、兩點間路徑)
void add(int u,int v);
int head[N],nxt[N<<1],to[N<<1],cnt;
int n,m,Q,B,bl[N],tot,V[N],W[N],a[N],sta[N],top,qcnt,ccnt,dep[N],f[20][N],num[N],now;
long long ans,out[N];
bool vis[N];
struct Query
{
int u,v,t,id;
bool operator<(Query& y)
{
return bl[u]==bl[y.u]?(bl[v]==bl[y.v]?t<y.t:bl[v]<bl[y.v]):bl[u]<bl[y.u];
}
} q[N];
struct Change
{
int p,x;
} c[N];
int main()
{
int i,j,u,v,lc,type;
cin>>n>>m>>Q;
B=pow(n,0.666);
for (i=1;i<=m;++i)
{
cin>>V[i];
}
for (i=1;i<=n;++i)
{
cin>>W[i];
}
for (i=1;i<n;++i)
{
cin>>u>>v;
add(u,v);
add(v,u);
}
dfs(1);
for (i=1;i<=16;++i)
{
for (j=1;j<=n;++j)
{
f[i][j]=f[i-1][f[i-1][j]];
}
}
while (top)
{
bl[sta[top--]]=tot;
}
for (i=1;i<=n;++i)
{
cin>>a[i];
}
for (i=0;i<Q;++i)
{
cin>>type;
if (type==0)
{
++ccnt;
cin>>c[ccnt].p>>c[ccnt].x;
}
else
{
cin>>q[qcnt].u>>q[qcnt].v;
q[qcnt].t=ccnt;
q[qcnt].id=qcnt;
++qcnt;
}
}
sort(q,q+qcnt);
u=v=1;
for (i=0;i<qcnt;++i)
{
pathmodify(u,q[i].u);
pathmodify(v,q[i].v);
u=q[i].u;
v=q[i].v;
while (now<q[i].t)
{
modify(++now);
}
while (now>q[i].t)
{
modify(now--);
}
lc=lca(u,v);
opp(lc);
out[q[i].id]=ans;
opp(lc);
}
for (i=0;i<qcnt;++i)
{
cout<<out[i]<<endl;
}
return 0;
}
void pathmodify(int u,int v)
{
if (dep[u]<dep[v])
{
swap(u,v);
}
while (dep[u]>dep[v])
{
opp(u);
u=f[0][u];
}
while (u!=v)
{
opp(u);
opp(v);
u=f[0][u];
v=f[0][v];
}
}
void opp(int x)
{
if (vis[x])
{
ans-=1ll*V[a[x]]*W[num[a[x]]--];
}
else
{
ans+=1ll*V[a[x]]*W[++num[a[x]]];
}
vis[x]^=1;
}
void modify(int ti)
{
if (vis[c[ti].p])
{
opp(c[ti].p);
swap(a[c[ti].p],c[ti].x);
opp(c[ti].p);
}
else
{
swap(a[c[ti].p],c[ti].x);
}
}
int lca(int u,int v)
{
if (dep[u]<dep[v])
{
swap(u,v);
}
int i;
for (i=0;i<=16;++i)
{
if ((dep[u]-dep[v])&(1<<i))
{
u=f[i][u];
}
}
if (u==v)
{
return u;
}
for (i=16;i>=0;--i)
{
if (f[i][u]!=f[i][v])
{
u=f[i][u];
v=f[i][v];
}
}
return f[0][u];
}
void dfs(int u)
{
int t=top;
for (int i=head[u];i;i=nxt[i])
{
int v=to[i];
if (v!=f[0][u])
{
f[0][v]=u;
dep[v]=dep[u]+1;
dfs(v);
if (top-t>=B)
{
++tot;
while (top>t)
{
bl[sta[top--]]=tot;
}
}
}
}
sta[++top]=u;
}
void add(int u,int v)
{
nxt[++cnt]=head[u];
head[u]=cnt;
to[cnt]=v;
}
莫隊的擴展
其實莫隊可以擴展到高維,參見二維莫隊解題報告。
更一般地,若 \(Q(x_1,x_2,\cdots,x_k)\) 為一個詢問,\(\forall i\in[1,k]\),\(x_i\) 的規模都為 \(n\),可以在時間 \(\mathrm{T}\) 內求解 \(Q(x_1,x_2,\cdots,x_i\pm 1,\cdots,x_n)\),共有 \(m\) 個詢問,那么就可以在 \(O\left(kmlogm+nTm^\frac{k-1}{k}\right)\) 的時間復雜度下離線求解。
(蒟蒻的大膽猜想而已..並沒有嚴格證明)
To be finished:回滾莫隊(只增莫隊)..(有時間再填坑吧Orz)
