莫队算法初探

codesonic

2018-08-15 19:31:30

Personal

log 2022.9.27 复活更新回滚莫队,删除了关于HH的项链的因为时代变迁的过时说法 ### 普通莫队 莫队算法(mo's algorithm)一般分为两类,一是莫队维护区间答案,二是维护区间内的数据结构。当然也有树上莫队,带修改莫队、二维莫队等等。这篇文章主要介绍的是普通莫队算法 我们考虑一个问题,给一个序列,m次询问,每次询问你区间$[l,r]$有多少种不同的颜色。$n,m\leq 100000$ ~~尽管可以用主席树做,但是我们假装不行~~ 先考虑暴力,对每次询问遍历一遍$[l,r]$,这样是$O(nm)$的, 换种方式暴力,定义ql和qr,表示区间$[ql,qr]$内有多少颜色,再定义cnt数组,$cnt_i$表示第i种颜色在区间$[ql,qr]$出现了多少次。 我们一个一个询问处理,对于询问$[l,r]$,我们挪动xl到l,xr到r 因为这个是莫队算法的基础,所以模拟一下这个过程: ![](https://cdn.luogu.com.cn/upload/pic/28710.png) 我们的初始在这个状态,假设蓝色为1,红色为2,绿色为3 那么$cnt_1=3$,$cnt_2=3$,$cnt_3=1$,我们把qr向右挪一下 ![](https://cdn.luogu.com.cn/upload/pic/28709.png) 这样多了一个绿色,$cnt_3$加1 继续挪一下 ![](https://cdn.luogu.com.cn/upload/pic/28711.png) 多了一个红色,$cnt_2$加上1 此时我们发现,我们的右指针已经和询问的右端点重合了,接下来挪左指针 ![](https://cdn.luogu.com.cn/upload/pic/28712.png) 少了一个蓝色,$cnt_1$减去1 现在我们得出了答案为3,$cnt_1=2$,$cnt_2=4$,$cnt_3=2$ 提供这部分的代码: ```cpp inline void add(int x){ cnt[x]++; if(cnt[x]==1)ans++; } inline void del(int x){ cnt[x]--; if(cnt[x]==0)ans--; } //在获取答案时: 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--]); ``` 我们发现,每次挪动都都是$O(1)$的,每次询问我们最多要挪动n次,所以时间复杂度还是$O(nm)$。 我们有没有办法来加速呢? 这种暴力的耗时就耗在挪动次数上,我们要让他挪动的次数尽量少 肯定是有的,我们**将询问先储存下来**,再按照某种方法排序,让他减少挪动的次数,这样会变快一些 这种方法是**强行离线**,然后排序,所以这样的普通莫队不能资瓷修改 那么怎么排序呢? 一种解决方式是优先按照左端点排序。 这种排序的方式,保证左端点只会向右挪,但是右端点每次最坏还是可以从最前面挪到最后面,从最后面挪到最前面,这样的复杂度还是$O(nm)$,是不行的 我们的排序是要使左右端点挪动的次数尽量少,所以这里就有一种排序方法: 将序列分成$\sqrt{n}$个长度为$\sqrt{n}$的块,若左端点在同一个块内,则按右端点排序(以左端点所在块为第一关键字,右端点为第二关键字) 注意,莫队的挪动操作要尽量快,若不是$O(1)$的,复杂度会原地爆炸 对于n与m同阶,一般可以设块长度为$\sqrt{n}$. 以下内容可以忽略,是我口胡的证明 >可以证明,这样的复杂度是$O(n\sqrt{n})$的,简略的提一下证明过程 >我们排序之后,每个块内均摊有根号$\sqrt{n}$个询问的l端点,显然这l个端点的右端点是有序的,最多一共会移动n次,所以对于一个块,复杂度是$O(n)$ >然后有根号n个块,所以总的复杂度是$O(n\sqrt{n})$ 但对于询问特别大的情况,$O(n\sqrt{n})$可能会超时,需要用其他的长度,我们来分析一下什么情况下均摊复杂度最优 我们设块长度为$S$,那么对于任意多个在同一块内的询问,挪动的距离就是$n$,一共$\frac{n}{S}$个块,移动的总次数就是$\frac{n^2}{S}$,移动可能跨越块,所以还要加上一个$mS$的复杂度,总复杂度为$O(\frac{n^2}{S}+mS)$,我们要让这个值尽量小,$S$取$\frac{n}{\sqrt{m}}$是最优的,此时复杂度为$O(\frac{n^2}{\frac{n}{\sqrt{m}}}+m(\frac{n}{\sqrt{m}}))=O(n\sqrt{m})$ >然而在随机情况下,发现块大小是$\frac{n}{\sqrt{m\times\frac{2}{3}}}$反而会快一些(因为询问的区间大小期望是 $n\times \frac{2}{3}$,因此每次挪动距离当成 $\frac{2}{3}n$ 处理) >还有一种卡常方法,按照奇偶性排序,我们正常的排序方式是这样的: ```cpp bool cmp(query a,query b){ return (a.r/block)==(b.r/block)?a.l<b.l:a.r<b.r; } ``` >奇偶性排序是这样的: ```cpp bool cmp(node a,node b){ return pos[a.l]^pos[b.l]?pos[a.l]<pos[b.l]:pos[a.l]&1?a.r<b.r:a.r>b.r; } ``` >这样能快是因为指针移到右边后不用再跳回左边,而跳回左边后处理下一个块又要跳回右边,这样能减少一半操作,理论上能快一倍 **注意:分块时块的大小不是固定的,要根据题目具体分析,分析的过程如上面分析m极大时的复杂度** 刚刚那道数颜色其实是[ [SDOI2009]HH的项链](https://www.luogu.org/problemnew/show/P1972),然而现在裸的莫队过不去了。 然而莫队吸口氧卡卡常还是能过的,不开O2会TLE 2 然而现在过不了力,有没有卡常大师再优化一手 ```cpp #include <bits/stdc++.h> using namespace std; int read() { char x; while((x = getchar()) > '9' || x < '0') ; int u = x - '0'; while((x = getchar()) <= '9' && x >= '0') u = (u << 3) + (u << 1) + x - '0'; return u; } int buf[105]; inline void write(int i) { int p = 0; if(i == 0) p++; else while(i) { buf[p++] = i % 10; i /= 10; } for(int j = p-1; j >= 0; j--) putchar('0' + buf[j]); } #define il inline #define re register int block,ans=0,cnt[1000001]; int n,m,a[500010],Ans[500010]; struct node { int l,r,id; }q[500010]; il bool cmp(node a,node b){ return (a.l/block)^(b.l/block)?a.l<b.l:(((a.l/block)&1)?a.r<b.r:a.r>b.r); } il void add(int x){ if(!cnt[a[x]])ans++; cnt[a[x]]++; } il void del(int x){ cnt[a[x]]--; if(!cnt[a[x]])ans--; } int i; int main(){ n=read(); for(i=1;i<=n;++i)a[i]=read(); m=read(); block=n/sqrt(m*2/3); for(i=1;i<=m;++i){ q[i].l=read(); q[i].r=read(); q[i].id=i; } sort(q+1,q+m+1,cmp); int l=0,r=0; for(i=1;i<=m;++i){ int ql=q[i].l,qr=q[i].r; while(l<ql)del(l++); while(l>ql)add(--l); while(r<qr)add(++r); while(r>qr)del(r--); Ans[q[i].id]=ans; } for(i=1;i<=m;++i)write(Ans[i]),printf("\n"); return 0; } ``` 接下来是:[P2709 小B的询问](https://www.luogu.org/problemnew/show/P2709) 经典题,是裸的莫队 一句话题意:求一个区间中每个数出现次数的平方和 还是设$cnt_i$为i在当前区间出现的次数 如果$cnt_i$多了一个,那 > ans+=2*cnt[x]+1 如果$cnt_i$多了一个,那 > ans-=2*cnt[x]-1 没了。 ```cpp #include<cstdio> #include<cmath> #include<algorithm> using namespace std; const int maxn=50005; long long int c[maxn]; long long int sum[maxn]; struct node{ long long int l,r,num; }q[maxn]; long long anss[maxn]; long long int block; long long int ans=0; bool cmp(node a,node b){ return (a.r/block)==(b.r/block)?a.l<b.l:a.r<b.r; } inline void del(int x){ sum[c[x]]--; ans-=2*sum[c[x]]+1; } inline void add(int x){ sum[c[x]]++; ans+=2*sum[c[x]]-1; } int main() { long long int n,m,k; scanf("%lld%lld%lld",&n,&m,&k); block=sqrt(n); for(long long int i=1;i<=n;i++){ scanf("%lld",&c[i]); } for(long long int i=1;i<=m;i++){ scanf("%lld%lld",&q[i].l,&q[i].r); q[i].num=i; } sort(q+1,q+1+m,cmp); int l=1,r=0; for(long long int i=1;i<=m;i++){ long long int ql=q[i].l,qr=q[i].r; while(l<ql){ del(l); l++; } while(l>ql){ l--; add(l); } while(r<qr){ r++; add(r); } while(r>qr){ del(r); r--; } anss[q[i].num]=ans; } for(long long int i=1;i<=m;i++){ printf("%lld\n",anss[i]); } return 0; } ``` ### 带修改莫队 普通莫队是不能带修改的 我们可以强行让它可以修改,就像DP一样,可以强行加上一维**时间维**,表示这次操作的时间。 即把询问$[l,r]$变成$[l,r,time]$ 那么我们的坐标也可以在时间维上移动,即$[l,r,time]$多了一维可以移动的方向,可以变成: - $[l-1,r,time]$ - $[l+1,r,time]$ - $[l,r-1,time]$ - $[l,r+1,time]$ - $[l,r,time-1]$ - $[l,r,time+1]$ 这样的转移也是$O(1)$的,但是我们排序又多了一个关键字,再搞搞就行了 可以用和普通莫队类似的方法排序转移,做到$O(n^{\frac{5}{3}})$ 这一次我们排序的方式是以$n^{\frac{2}{3}}$为一块,分成了$n^{\frac{1}{3}}$块,第一关键字是左端点所在块,第二关键字是右端点所在块,第三关键字是时间。 还是来证明一下时间复杂度(默认块大小为$n^\frac{2}{3}$): - 左右端点所在块不变,时间在排序后单调向右移,这样的复杂度是$O(n)$ - 若左右端点所在块改变,时间一次最多会移动n个格子,时间复杂度$O(n)$ - 左端点所在块一共有$n^{\frac{1}{3}}$中,右端点也是$n^{\frac{1}{3}}$种,一共${n^{\frac{1}{3}}}\times{n^{\frac{1}{3}}}=n^{\frac{2}{3}}$种,每种乘上移动的复杂度$O(n)$,总复杂度$O(n^{\frac{5}{3}})$ ### 树上莫队 莫队只能处理线性问题,我们要把树强行压成一维的 我们可以将树的括号序跑下来,把括号序分块,在括号序上跑莫队 具体怎么做呢? dfs一棵树,然后如果dfs到x点,就push_back(x),dfs完x点,就直接push_back(-x),然后我们在挪动指针的时候 - 新加入的值是x ---> add(x) - 新加入的值是-x ---> del(x) - 新删除的值是x ---> del(x) - 新删除的值是-x ---> add(x) 这样的话,我们就把一棵树处理成了序列。 好像还有对树的连通块分块的方法,不过好像比较难我也不会( 例题是[[WC2013]糖果公园](https://www.luogu.org/problemnew/show/P4074),这题是带修改树上莫队 题意是给你一棵树,每个点有颜色,每次询问 $\sum_{c}val_c\sum_{i=1}^{cnt_c}w_i$ val表示该颜色的价值 cnt表示颜色出现的次数 w表示该颜色出现i次后的价值 先把树变成序列,然后每次添加/删除一个点,这个点的对答案的的贡献是可以在$O(1)$时间内获得的,即$val_c\times w_{cnt_{c+1}}$ 发现因为他会把起点的子树也扫了一遍,产生多余的贡献,怎么办呢? 因为扫的过程中起点的子树里的点肯定会被扫两次,但贡献为0 所以可以开一个vis数组,每次扫到点x,就把$vis_x$异或上1 如果$vis_x=0$,那这个点的贡献就可以不计 所以可以用树上莫队来求 修改的话,加上一维时间维即可,变成带修改树上莫队 然后因为所包含的区间内可能没有LCA,对于没有的情况要将多余的贡献删除,然后就完事了 code: ```cpp #include<algorithm> #include<iostream> #include<cstdio> #include<cmath> #define DEBUG printf("line:%d func:%s\n",__LINE__,__FUNCTION__); using namespace std; const int maxn=200010; int f[maxn],g[maxn],id[maxn],head[maxn],cnt,last[maxn],dep[maxn],fa[maxn][22],v[maxn],w[maxn]; int block,index,n,m,q; int pos[maxn],col[maxn],app[maxn]; bool vis[maxn]; long long ans[maxn],cur; struct edge { int to,nxt; } e[maxn]; int cnt1=0,cnt2=0;//时间戳 struct query { int l,r,t,id; bool operator <(const query &b)const { return (pos[l]<pos[b.l])||(pos[l]==pos[b.l]&&pos[r]<pos[b.r])||(pos[l]==pos[b.l]&&pos[r]==pos[b.r]&&t<b.t); } } a[maxn],b[maxn]; inline void addedge(int x, int y) { e[++cnt]=(edge) { y,head[x] }; head[x]=cnt; } void dfs(int x) { id[f[x]=++index]=x; for(int i=head[x]; i; i=e[i].nxt) { if(e[i].to!=fa[x][0]) { fa[e[i].to][0]=x; dep[e[i].to]=dep[x]+1; dfs(e[i].to); } } id[g[x]=++index]=x;//括号序 } inline void swap(int &x,int &y) { int t; t=x; x=y; y=t; } inline int lca(int x,int y) { if(dep[x]<dep[y]) swap(x,y); if(dep[x]!=dep[y]) { int dis=dep[x]-dep[y]; for(int i=20; i>=0; i--) if(dis>=(1<<i)) dis-=1<<i,x=fa[x][i]; }//爬到同一高度 if(x==y) return x; for(int i=20; i>=0; i--) { if(fa[x][i]!=fa[y][i]) x=fa[x][i],y=fa[y][i]; } return fa[x][0]; } inline void add(int x) { if(vis[x]) cur-=(long long )v[col[x]]*w[app[col[x]]--]; else cur+=(long long )v[col[x]]*w[++app[col[x]]]; vis[x]^=1; } inline void modify(int x,int t) { if(vis[x]) { add(x); col[x]=t; add(x); } else col[x]=t; }//在时间维上移动 int main() { scanf("%d%d%d",&n,&m,&q); for(int i=1; i<=m; i++) scanf("%d",&v[i]); for(int i=1; i<=n; i++) scanf("%d",&w[i]); for(int i=1; i<n; i++) { int x,y; scanf("%d%d",&x,&y); addedge(x,y); addedge(y,x); } for(int i=1; i<=n; i++) { scanf("%d",&last[i]); col[i]=last[i]; } dfs(1); for(int j=1; j<=20; j++) for(int i=1; i<=n; i++) fa[i][j]=fa[fa[i][j-1]][j-1];//预处理祖先 int block=pow(index,2.0/3); for(int i=1; i<=index; i++) { pos[i]=(i-1)/block; } while(q--) { int opt,x,y; scanf("%d%d%d",&opt,&x,&y); if(opt==0) { b[++cnt2].l=x; b[cnt2].r=last[x]; last[x]=b[cnt2].t=y; } else { if(f[x]>f[y]) swap(x,y); a[++cnt1]=(query) { lca(x,y)==x?f[x]:g[x],f[y],cnt2,cnt1 }; } } sort(a+1,a+cnt1+1); int L,R,T;//指针坐标 L=R=0; T=1; for(int i=1; i<=cnt1; i++) { while(T<=a[i].t) { modify(b[T].l,b[T].t); T++; } while(T>a[i].t) { modify(b[T].l,b[T].r); T--; } while(L>a[i].l) { L--; add(id[L]); } while(L<a[i].l) { add(id[L]); L++; } while(R>a[i].r) { add(id[R]); R--; } while(R<a[i].r) { R++; add(id[R]); } int x=id[L],y=id[R]; int llca=lca(x,y); if(x!=llca&&y!=llca) { add(llca); ans[a[i].id]=cur; add(llca); } else ans[a[i].id]=cur; } for(int i=1; i<=cnt1; i++) { printf("%lld\n",ans[i]); } return 0; } ``` ### 回滚莫队!! [例题](https://www.luogu.com.cn/problem/P5906): 给定一个序列,多次询问一段区间 $[l,r]$,求区间中**相同的数的最远间隔距离**. 当然本题普通莫队也是可以完成的,因为可以预处理每个数的相同前驱后继从而使删除操作变简单,这非常朴素. 考虑到朴素的莫队算法在处理删除操作时会有一些麻烦. 如本题中删除一个元素后更新下一个最远元素的操作肥肠困难. 而利用回滚莫队可以减少删除操作,让更多的操作是插入而非删除. 为了让操作只有插入没有删除,回滚莫队的排序方式是让询问从中心点向左右拓展,覆盖尽量多询问. 重复此操作直至所有询问被处理.