前言
这是一篇 LATEX PPT 改 md。
今天的题单:点我。
今天是我第一次上台,PPT 可能不是那么美观, 如果有错误请大胆指出。
例题不会很难,请放心食用。
简介
莫队简介
什么是莫队?
莫队是由莫涛大神提出的一种暴力区间操作算法,它框架简单、板子好写、复杂度优秀。
然而由于莫队算法应用的毒瘤,很多莫队题难度评级都很高(蓝紫黑),令很多初学者望而却步。但如果你真正理解了莫队的算法原理,它用起来还是挺简单的。
前置知识:
- 分块思想。
sort
的用法(包括重载运算符或 cmp
函数,多关键字排序)。
使用莫队的情境
若 m=O(n)(即 m、n 同阶),且 [l,r] 的答案能 O(1) 地转换到 [l−1,r],[l,r+1],[l+1,r],[l,r−1] 区间(即相邻区间)的答案,那么莫队可以在 O(nn) 的时间复杂度内离线求出所有询问的答案。
注意:莫队是离线算法。如果题目强制在线,则不以可用莫队。
什么是离线、在线?
- 如果算法需要知道所有询问才能开始算法,则称此算法为离线算法。
- 读入一个询问,回答一个询问的算法叫在线算法。
- 强制在线就是要求你读入一个询问就立马回答。
莫队的基本思想
离线存下所有询问,借助分块按照一定的顺序处理这些询问,使得询问之间可以互相利用(一般情况下为了方便,只会是本次询问利用上次询问的答案),以减小时间复杂度。
普通莫队
算法基础
算法流程
- 离线存下所有询问。
- 以二元组 (bel[li],ri) 为关键字升序对所有询问排序。
其中 i 表示当前询问编号,bel[li](belong,属于)表示 li 所在的块的编号。
- 遍历每个询问,维护两个指针 [l,r] 表示当前区间,now 表示当前答案。
初始时 l=1,r=0(如果 l=0,那么我们还需要删除 a0,导致一些奇怪的错误)。
l,r 需区别于 li,ri,它们一对是我们维护的指针(下标),一对是数据给出的询问。
- 移动区间 [l,r]→[li,ri]。途中维护区间 [l,r] 的答案 now。
- 移动结束后,记录区间 [li,ri] 的答案 ansi。(ansi←now)。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53
| constexpr int N=114514; int n,m,a[N],S;
inline int bel(int x){return (x-1)/S+1;} struct query { int l,r,id; friend inline bool operator < (query x,query y) {return (bel(x.l)==bel(y.l)?x.r<y.r:bel(x.l)<bel(y.l));} }; query q[N]; bitset<N> ans;
int cnt[N],cf=0;
inline void add(int pos) { cnt[a[pos]]++; if(cnt[a[pos]]==2)cf++; } inline void del(int pos) { cnt[a[pos]]--; if(cnt[a[pos]]==1)cf--; }
void mt() { S=int(ceil(pow(n,0.5))); sort(q+1,q+m+1); for(int i=1,l=1,r=0;i<=m;i++) { #define q q[i] while(q.l<l)add(--l); while(r<q.r)add(++r); while(l<q.l)del(l++); while(q.r<r)del(r--); ans[q.id]=!cf; #undef q } }
int main() { mt(); for(int i=1;i<=m;i++)puts(ans[i]?"Yes":"No"); return 0; }
|
算法复杂度
下面的讨论中 m=O(n)。
单次移动 l,r 中的一个复杂度显然 O(1)。
考虑 l,r 分别移动的次数。
- 考虑 l:设块 i 内的询问的左端点个数为 xi,则块 i 中移动 l 的次数顶多 xi×n。一共 n 个块,移动 l 的总时间复杂度为:
===i=1∑n(xi×n)(n)×i=1∑n(xi)n×mO(nn)
- 考虑 r:每块内的 xi 个 lj(1≤j≤xi) 肯定一一对应着 xi 个 rj。
显然这 xi 个 rj 最多会使 r 移动 n 的长度(lj 同一块时,按 rj 升序,故不降)。
一共 n 个块,移动 r 的总时间复杂度为:n×n=O(nn)。
- 则总时间复杂度为 O(1)×[O(nn)+O(nn)]=O(nn)。
算法优化
奇偶性排序
刚才的复杂度分析中提出了一些极端情况:xi 个 rj 最多使 r 移动 n 的长度。
而 n 个块都可能使 r 移动 n 的长度,例如下列询问(n=100,S=10):
按原先的排序策略,r 需要反复横跳,十分浪费时间。
如果将处理顺序改为以下顺序将大大加速:
怎么修改排序策略?
依然以 bel[li] 为第一关键字升序排序,若 bel[li] 为奇数,以 ri 为第二关键字升序排序,反之若 bel[li] 为偶数,以 ri 为第二关键字降序排序。
这样我们的 r 指针在处理完这个奇数块的问题后,将在返回的途中处理偶数块的问题,再向 n 移动处理下一个奇数块的问题,优化了 r 指针的移动次数,一般情况下,这种优化能让程序快 30% 左右。
——OI-Wiki。
实测:810 ms → 622 ms,快约 23.2%。
奇偶性排序:代码
1 2 3 4 5 6 7 8 9 10
| struct query { int l,r,id; friend inline bool operator < (query x,query y) { if(bel(x.l)!=bel(y.l))return bel(x.l)<bel(y.l); if(bel(x.l)&1)return x.r<y.r; else return x.r>y.r; } };
|
坑点:注意重载运算符不能出现 a<b,a>b 同时为真的情况,否则会 RE。如以下实现方式就是错误的:
1 2 3 4 5 6 7 8 9
| struct query { int l,r,id; friend inline bool operator < (query x,query y) { if(bel(x.l)!=bel(y.l))return bel(x.l)<bel(y.l); return (bel(x.l)&1)==(x.r<y.r); } };
|
常数级展开
发现 add()
,del()
两个函数可以压行并展开到 mt()
中。
这看似鸡肋的优化实测 572 ms——又优化了 50 ms。
代码:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16
| void mt() { S=int(ceil(pow(n,0.5))); sort(q+1,q+m+1); for(int i=1,l=1,r=0;i<=m;i++) { #define q q[i] while(q.l<l)cf+=(++cnt[a[--l]]==2); while(r<q.r)cf+=(++cnt[a[++r]]==2); while(l<q.l)cf-=(--cnt[a[l++]]==1); while(q.r<r)cf-=(--cnt[a[r--]]==1); ans[q.id]=!cf; #undef q } }
|
玄学剪枝
我考虑到有时候可能转移大半天还不如暴力重新算,所以想出了一个玄学剪枝:
1 2 3 4 5 6
| if(abs(q.l-l)+abs(q.r-r)>(r-l+1)+(q.r-q.l+1)) { while(l<=r)cf-=(--cnt[a[r--]]==1); r=(l=q.l)-1; }
|
(这段语句加在 #define q q[i]
后面。)
没想到只优化了 1 ms(悲。也许是每次都判断的代价太大,抵消了直接跳转的优化。
预处理 bel
我写好几题才发现其他大佬都预处理了 bel,于是赶紧改过来。快了 58 ms。
1 2 3 4 5 6 7 8 9 10 11 12 13
| int S;int bel[N]; inline void initbel() { S=int(ceil(pow(n,0.5))); for(int i=1;i<=n;i++)bel[i]=(i-1)/S+1; } ... void mt() { initbel(); sort(q+1,q+m+1); ... }
|
例题
简要题意:给出一个长度为 n 的数列 a,m 个询问,每次询问给出数对 l,r 表示查询区间 [l,r] 中有多少不同的数。
数据范围:n≤3×105,m≤2×105,ai≤106。
板子题,难度在于如何 O(1) 转移答案。
考虑用数组 cnti 表示 [l,r] 中 i 出现了几次,变量 now 表示 [l,r] 中有多少不同的数。
要添加 apos,那么 cnt[apos]←cnt[apos]+1。
此时若 cnt[apos]=1,即多了一个不同的数,那么 now←now+1。
同理删除 apos 时 cnt[apos]←cnt[apos]−1,若 cnt[apos]=0(少了一个数),now←now−1。
其他的正常地跑莫队即可。但此题似乎卡常。
简要题意:给出一个长度为 n 的数列 a(值域 [1,k]),m 个询问,每次询问给出数对 l,r 表示查询:
i=1∑kci2
其中 ci 表示数字 i 在 [l,r] 中的出现次数。
数据范围:1≤n,m,k≤5×104。
难度依然在于如何 O(1) 转移答案。
c 数组很好维护,但答案(设它为 s)就不那么好维护了。
由于每次添加或删除数时只会改变 ca[pos],而且只会 ±1。所以:
由
s=i=1∑kci2=c12+⋯+ca[pos]2+⋯+ck2
可得
s′=c12+⋯+(ca[pos]±1)2+⋯+ck2=c12+⋯+ca[pos]2±2×ca[pos]+1+⋯+ck2=s±2×ca[pos]+1
转移时修改即可。
简要题意:给出一个长度为 n 的数列 a(值域 [1,n]),m 个询问,每次询问给出数对 l1,r1,l2,r2 表示查询:
x=0∑∞get(l1,r1,x)×get(l2,r2,x)
get(l,r,x) 表示区间 [l,r] 中 x 的出现次数。
数据范围:n,m≤5×104,1≤ai≤n。
首先因为每次询问给出的是一个四元组,所以莫队不能直接做。
考虑对询问进行拆分。
可以发现,get(l,r,x)=get(1,r,x)−get(1,l−1,x)。展开:
(为方便,记 g(p)=get(1,p,x))
==x=0∑∞get(l1,r1,x)×get(l2,r2,x)x=0∑∞(g(r1)−g(l1−1))×(g(r2)−g(l2−1))x=0∑∞g(r1)×g(r2)−g(l1−1)×g(r2)−g(l2−1)×g(r1)+g(l1−1)×g(l2−1)
若记 ask(l,r)=g(l)×g(r),
原式=ask(r1,r2)−ask(l1−1,r2)−ask(l2−1,r1)+ask(l1,l2)
肉眼可见,我们将原来的四元询问转换为四个二元询问。虽然 l,r 并不表示区间,但我们可以用相同的思想处理询问。
如何 O(1) 转移 ask(l,r)?
考虑若其中一个(g(l),g(r))增加 1,则它们的积会增加另一个数。具体见代码。
核心代码:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32
| int cl[N],cr[N],now,ans[N]; void mt() { init(); sort(q+1,q+(m<<2)+1); for(int i=1,l=0,r=0;i<=(m<<2);i++) { #define q q[i] while(l<q.l)++cl[a[++l]],now+=cr[a[l]]; while(r<q.r)++cr[a[++r]],now+=cl[a[r]]; while(q.l<l)--cl[a[l]],now-=cr[a[l--]]; while(q.r<r)--cr[a[r]],now-=cl[a[r--]]; ans[q.id]+=(q.t*now); #undef q } }
int main() { ... for(int i=1,l1,r1,l2,r2,p=1;i<=m;i++) { scanf("%d %d %d %d",&l1,&r1,&l2,&r2); q[p++]={min(l1-1,l2-1),max(l1-1,l2-1),i,1 }; q[p++]={min(l1-1,r2), max(l1-1,r2), i,-1}; q[p++]={min(l2-1,r1), max(l2-1,r1), i,-1}; q[p++]={min(r1,r2), max(r1,r2), i,1 }; } ... return 0; }
|
题意:同 DQUERY - D-query。但数据范围不同,还卡莫队。
我们就这么屈服了吗?没有!
其实本题莫队理论上 n=106,O(nn)≈109 是可以过的,因为莫队经常跑不满。但经不住毒瘤出题人卡莫队。
考虑常数优化。注意到大部分时间花在转移 l,r 指针上。
由于 ai 的波动程度可能非常大,又由于我们的转移方式是
1
| while(q.l<l)now+=(!(cnt[a[--l]]++));
|
访问 cnt 时下标波动程度大,导致访问慢。
如何加速?关键在于用等价但是数组访问只与下标有关的转移方式。因为下标经过排序后相对连续。
考虑记录两个数组 prei,nxti 分别表示上一个和下一个出现 ai 的位置的下标。如添加 l=i 时,r<nxti,即右边那个最近的 ai 已经不在 [l,r] 中(显然左边那个更不会),那么 [l,r] 中多了一个不同的数,now←now+1。其他同理。
代码:
1 2 3 4 5 6 7 8 9 10 11 12 13
| for(int i=1;i<=n;i++)pre[i]=lst[a[i]],lst[a[i]]=i; fill(lst,lst+A,0x3f3f3f3f); for(int i=n;i;i--)nxt[i]=lst[a[i]],lst[a[i]]=i; for(int i=1,l=1,r=0;i<=m;i++) { #define q q[i] while(q.l<l)now+=(nxt[--l]>r); while(r<q.r)now+=(pre[++r]<l); while(l<q.l)now-=(nxt[l++]>r); while(q.r<r)now-=(pre[r--]<l); ans[q.id]=now; #undef q }
|
*带修莫队
简介
如何实现带修莫队?
发现普通莫队不支持修改,那么如何使它支持修改操作呢?
考虑存询问时加一个变量 ti 表示进行此询问时前面修改了几次。同时存下每一个修改操作(无需排序)。
再新增一个指针 t 表示当前区间所在的时间位置。那么移动方向就从 4 个变为 6 个:[l−1,r,t],[l,r+1,t],[l+1,r,t],[l,r−1,t],[l,r,t−1],[l,r,t+1]。新增的两个为时间轴上的移动。
例题
简要题意:给出一个长度为 n 的数列,m 个操作,要求支持两种操作:查询区间有多少不同的数、单点修改。
数据范围:n,m≤1.33333×105,ai≤106。
板子题,直接上代码:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38
| constexpr int N=214514,A=1145141; int n,m,S,qm,a[N]; inline int bel(int x){return (x-1)/S+1;} struct query { int l,r,t,id; friend inline bool operator < (query x,query y) { return (bel(x.l)^bel(y.l)?bel(x.l)<bel(y.l):(bel(x.r)^bel(y.r)?bel(x.r)<bel(y.r):x.t<y.t)); } };query q[N]; struct modify {int p,v;};modify mo[N];
int cnt[A],now,ans[N]; void mt() { S=int(ceil(pow(n,0.66))); sort(q+1,q+qm+1); for(int i=1,l=1,r=0,t=0;i<=qm;i++) { #define q q[i] #define p mo[t].p #define v mo[t].v while(q.l<l)now+=(!(cnt[a[--l]]++)); while(r<q.r)now+=(!(cnt[a[++r]]++)); while(l<q.l)now-=(!(--cnt[a[l++]])); while(q.r<r)now-=(!(--cnt[a[r--]])); while(t<q.t){t++;if(l<=p&&p<=r)now-=(!(--cnt[a[p]])-!(cnt[v]++));swap(a[p],v);} while(q.t<t){if(l<=p&&p<=r)now-=(!(--cnt[a[p]])-!(cnt[v]++));swap(a[p],v);t--;} ans[q.id]=now; #undef q #undef p #undef v } }
|
*回滚莫队
简介
何时使用回滚莫队?
对于某些问题,普通莫队可能难以解决。原因在于删除和添加只有一个可实现。这时就需要回滚莫队了。
先看题:AT_joisc2014_c 歴史の研究。
简要题意:区间询问,给出一个长度为 n 的数列 a,m 个询问,每次询问要求回答
∀xmax{x×cnt(x)}
其中 cnt(x),表示 x 在 [l,r] 区间中的出现次数。
此题添加数很方便,但删除数却很麻烦。因为当最大值改变(如变小)时,我们无法确定新的最大值。
又如求区间 mex 时(P4137 Rmq Problem / mex),删除数方便,添加数麻烦。
这时就要用到回滚莫队了。它只用删除和添加中的一个操作,剩下的就回滚解决。
算法实现
算法流程
- 对原序列进行分块,对询问按以 bel[li] 升序为第一关键字,ri 升序为第二关键字排序。
- 如果 bel[li]=bel[li−1],那么将 l 初始化为 bel[li] 的右端点的后一个位置,将 r 初始化为 bel[li] 的右端点。
此时:
- 若 bel[li]=bel[ri],直接暴力回答。
- 反之,
- 移动 r→ri。
- 暂存当前答案 tmp。
- 移动 l→li。
- 记录答案 ansi←now。
- 回滚,使 l 回滚回 bel[li] 的右端点的后一个位置,同时更新答案 now←tmp。
代码实现
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52
| typedef long long ll; constexpr int N=114514,INF=0x3f3f3f3f; int n,m; int a[N]; int bel[N],S; struct query { int l,r,id; friend inline bool operator < (query x,query y) {return (bel[x.l]^bel[y.l]?bel[x.l]<bel[y.l]:x.r<y.r);} }; query q[N]; ll ans[N],now,tmp; unordered_map<int,int> cnt,tcnt;
void rbmt() { S=int(ceil(pow(n,.5))); for(int i=1;i<=n;i++)bel[i]=(i-1)/S+1; sort(q+1,q+m+1);bel[q[0].l=0]=INF; for(int i=1,l,r;i<=m;i++) { if(bel[q[i].l]^bel[q[i-1].l])cnt.clear(),now=-INF,r=bel[q[i].l]*S,l=r+1; #define q q[i] if(bel[q.l]==bel[q.r]) { tmp=-INF,tcnt.clear(); for(int j=q.l;j<=q.r;j++)tmp=max(tmp,1ll*(++tcnt[a[j]])*a[j]); ans[q.id]=tmp; } else { while(r<q.r)now=max(now,1ll*(++cnt[a[++r]])*a[r]); tmp=now; while(q.l<l)now=max(now,1ll*(++cnt[a[--l]])*a[l]); ans[q.id]=now; now=tmp; while(l<=bel[q.l]*S)--cnt[a[l++]]; } #undef q } }
int main() { scanf("%d %d",&n,&m); for(int i=1;i<=n;i++)scanf("%d",a+i); for(int i=1;i<=m;i++)scanf("%d %d",&q[i].l,&q[i].r),q[i].id=i; rbmt(); for(int i=1;i<=m;i++)printf("%lld\n",ans[i]); return 0; }
|
莫队延迟更新答案
出处
该方法整理自:
也许还有。我看到的就这些。
其实就是普通莫队的升级版。
算法思想 & 实现
对莫队算法进行观察,不难发现移动 l,r 指针过程中的答案我们并不需要,于是就不一定要 O(1) 移动指针并更新答案。只需要能 O(1) 移动指针,并记录修改,移动 l,r 结束后再 ≤O(n) 地更新答案即可。
这个寄巧很早就出现了,但无人详细记载,故记之。
更具体的,考虑 P4137 Rmq Problem / mex 这题。
此题 n,m,ai≤2×105,我们可以进行值域分块,开个桶,cnti 表示 i 在 [l,r] 中出现次数(而非是否出现,方便维护)。每个块 S 维护块内的数在 [l,r] 中出现的个数,即
exiS=i∈S∑[cnti>0]
当移动指针,添加/删除数时,直接修改 cnt,同时修改 exi。不急着更新答案,但移动指针结束后,扫过值域分块,O(n) 更新答案 now。具体的,O(n) 遍历每个块 S,若 exiS=∣S∣,跳到下一个块,反之 mex 必定在块内,再次 O(n) 遍历每个块内元素 i∈S,必定 ∃i,cnti=0,∀j<i,cnti>0,此时 i 即为所求。
本质上,该方法就是利用莫队对答案修改 | 询问次数 O(nn)∣O(n) 的特性,调整对答案的维护复杂度,将原来维护的答案修改 | 询问复杂度 O(1)∣O(1) 的高要求降为 O(1)∣O(n),以适应更多题目。
但有时第一时间想到的是 O(n)∣O(1) 做法,此时就需要修改策略。
代码
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40
| constexpr int N=214514,B=514; int n,m,a[N]; struct qry{int l,r,id;}; qry q[N];
int S,bel[N]; int cnt[N],exi[B]; int ans[N]; void mt() { S=int(ceil(n*pow(m,-.5))); for(int i=0;i<=n;i++)bel[i]=i/S+1; std::sort(q+1,q+m+1,[](qry x,qry y)->bool { return bel[x.l]!=bel[y.l]? (bel[x.l]<bel[y.l]): (bel[x.l]&1)==(x.r<y.r); }); for(int i=1,l=1,r=0,j,k;i<=m;i++) { #define q q[i] while(q.l<l)l--,exi[bel[a[l]]]+=!(cnt[a[l]]++); while(r<q.r)r++,exi[bel[a[r]]]+=!(cnt[a[r]]++); while(l<q.l)exi[bel[a[l]]]-=!(--cnt[a[l]]),l++; while(q.r<r)exi[bel[a[r]]]-=!(--cnt[a[r]]),r--; for(j=1;exi[j]==S;j++); for(k=(j-1)*S;cnt[k];k++); ans[q.id]=k; } }
int main() { scanf("%d %d",&n,&m); for(int i=1;i<=n;i++)scanf("%d",a+i); for(int i=1;i<=m;i++)scanf("%d %d",&q.l,&q.r),q.id=i; mt(); for(int i=1;i<=m;i++)printf("%d\n",ans[i]); return 0; }
|
对不住了,回滚莫队。
题意:给定一个序列,多次询问一段区间 [l,r],求区间中相同的数的最远间隔距离。序列中两个元素的间隔距离指的是两个元素下标差的绝对值。
数据范围:1≤n,m≤2⋅105,1≤ai≤2⋅109。
考虑普通莫队。硬伤:当删除值时,若最大值改动,难以维护。
考虑离散化后对原题值域进行分块。维护每个值在 [l,r] 中的最远距离,同时维护块内距离最大值和全局最大值。此时复杂度(修改 | 询问)O(n)−O(1)。
考虑转换成 O(n)−O(1)。
「qwaszx」大佬的神之一手——再次值域分块!
刚才的对数进行值域分块就不要了,只要保存每个数在区间中最边缘的两个位置,同时分块改为对距离值域分块!具体的,cnti 表示有多少个数满足 [l,r] 内最远间隔距离为 i,sumS 表示块 S 内 cnt 之和,即 sumS=∑j∈Scntj。
若修改最远间隔距离,直接 O(1) 修改 cnt 和 sum,而查询时则从大往小扫过值域分块,具体的,块 T 为最大的一个块使得满足 sumT>0,则一定 ∃k∈S,∀l,l>k,cntl=0,cntk>0。即 k 本身为最远间隔距离的最大值。
实现与 P4137 Rmq Problem / mex 类似。
嘿嘿没想到吧,主席树!
又双㕛叒叕考虑值域分块。像上一题一样维护 cnt,sum,O(1) 修改。cnti 表示 [l,r] 内 i 的数量。
从小到大扫过每个块,记录 sum 的前缀和,若前缀和 preS−1+sumS≥k,则第 k 大在该区间内。从小到大扫过 S 内表示的每个数,累加 cnt 直到找到第 k 大。
结束
Thanks!
后记
此 PPT 是本人一边学习莫队一边写的,肯定有诸多不足,还请包容。
当然还有树上莫队、二维莫队,由于难度过高,我自己也不会,就不多做介绍了。可以通过 OI-Wiki 自学。