莫队算法完整总结(普通莫队、带修莫队、树上莫队、回滚莫队)

普通莫队

一、适用问题

莫队算法是一种离线算法,用分块去优化暴力,不包含修改的话,复杂度为 O ( n n + m n ) O(n\sqrt n+m\sqrt n) n n 为序列长度, m m 为操作总数。

二、算法实现

莫队本质上就是用分块去优化暴力的离线算法,将总复杂度降到 O ( n n ) O(n\sqrt n) 的位置。说白了,就是分块+暴力。

我们先讲暴力的部分。比如一个长度为 n n 的序列, m m 次查询,每次查询询问区间 [ l , r ] [l,r] 之间的众数。对于这个问题,暴力求的话就是直接用桶记录每个数出现的次数,然后遍历区间 [ l , r ] [l,r] ,直接统计答案即可。这个暴力过程和莫队暴力过程没有任何区别,然后问题就变成了如何用分块来优化这个暴力呢?

分块的部分,该算法将整个序列按照 n \sqrt n 大小进行分块,共分成 n \sqrt n 块,然后对于所有的询问,先按照左端点所在的块编号进行排序,如果块编号相同,再按照右端点排序。询问排序完之后,就直接暴力求解即可。代码的话看一下下面习题就可以掌握了。

最后就是时间复杂度的问题了。如何证明这个算法的时间复杂度呢?我们对每一个块分开进行考虑,假设有 b i b_i 次操作在第 i i 个块中,则在这个块中,右端点一定递增,因此右端点最多移动 n n 次,而左端点每次最多移动 n \sqrt n ,一共最多移动 b i n b_i*\sqrt n 次,每次端点移动的时间复杂度为 O ( 1 ) O(1) ,因此移动的总次数为 i = 1 n ( b i n + n ) = m n + n n \sum\limits_{i=1}^{\sqrt n}(b_i*\sqrt n+n)=m*\sqrt n+n*\sqrt n ,因此总复杂度为 O ( n n + m n ) O(n\sqrt n+m\sqrt n)

三、普通莫队习题

1. [2009国家集训队] 小Z的袜子

题意: n n 双颜色不同袜子, m m 次询问,每次询问给出 [ L , R ] [L,R] 区间,询问在 [ L , R ] [L,R] 区间中随机抽出两双颜色相同的袜子的概率,输出最简分数形式 ( A / B ) (A/B) ( 1 n , m 50000 ) (1\leq n,m\leq 50000)

思路: 普通莫队算法的复杂度是 O ( N N ) O(N\sqrt N) ,实现关键点就在于能否在区间左右端点移动时, O ( 1 ) O(1) 的更新答案。

我们观察这道题目,可以发现区间 [ L , R ] [L,R] 取出两双颜色相同袜子的概率 = 1 2 i = L R n u m [ i ] C ( R L + 1 , 2 ) \frac{\frac{1}{2}*\sum\limits _{i=L}^{R}num[i]}{C(R-L+1,2)} n u m [ i ] num[i] 表示在区间 [ L , R ] [L,R] 中有多少双与 i i 颜色相同的袜子,乘以 1 2 \frac{1}{2} 的原因在于每一对颜色相同的袜子被计算了两遍。

分析到这里,就可以发现这是一道普通莫队的裸题,我们添加与删除时只需加上或减去当前与该点颜色相同的袜子数,这样同时可以避免重复计算。

代码:

#include <bits/stdc++.h>
#define rep(i,a,b) for(int i = a; i <= b; i++)
typedef long long ll;
const int N = 2*1e5+100;
using namespace std;

int a[N],pos[N],n,m,L,R;
ll ans[N][2],flag[N],Ans;
struct Node{
	int l,r,id;
	bool operator < (Node xx) const{
		if(pos[l] == pos[xx.l]) return r < xx.r;
		else return pos[l] < pos[xx.l];
	}
}Q[N];

ll gcd(ll a,ll b) {return b == 0 ? a:gcd(b,a%b);}

void add(int x){
	Ans += flag[a[x]];
	flag[a[x]]++;
}

void del(int x){
	flag[a[x]]--;
	Ans -= flag[a[x]];
}

int main()
{
	L = 1, R = 0;
	scanf("%d%d",&n,&m);
	int sz = sqrt(n);
	rep(i,1,n){
		scanf("%d",&a[i]);
		pos[i] = i/sz;
	}
	rep(i,1,m){
		scanf("%d%d",&Q[i].l,&Q[i].r);
		Q[i].id = i;
	}
	sort(Q+1,Q+1+m);
	rep(i,1,m){
		while(L < Q[i].l) del(L),L++;

		while(L > Q[i].l) L--, add(L);
		
		while(R < Q[i].r) R++, add(R);
		
		while(R > Q[i].r) del(R), R--;

		ll len = Q[i].r-Q[i].l+1;
		ll tp = len*(len-1ll)/(ll)2;
		ll g = gcd(Ans,tp);
		ans[Q[i].id][0] = Ans/g;
		ans[Q[i].id][1] = tp/g;
	}
	rep(i,1,m) printf("%lld/%lld\n",ans[i][0],ans[i][1]);
	return 0;
}
2. 花神的嘲讽计划Ⅰ

题意: 初始序列长度为 n n m m 组询问,每次询问给出一个 x x y y ,以及长度为 k k 的连续序列。询问在区间 [ x , y ] [x,y] 中是否存在一段连续的长度为 k k 的,与询问中给出的序列相同的一段序列。存在输出 N o No ,不存在输出 Y e s Yes ( 1 n , m 1 0 6 ) (1\leq n,m\leq 10^6)

思路: 这题可以观察到每次询问的连续序列长度都是固定为 k k ,因此不难想到用 h a s h hash 来解决这个问题。我们将每个位置后面连续的一段 k k 哈希起来,然后每个位置就有了一个对应的 h a s h hash 值。我们将这些 h a s h hash 值离散化之后,用桶来记录区间端点移动时对答案的贡献。

代码:

#include <bits/stdc++.h>
#define rep(i,a,b) for(int i = a; i <= b; i++)
typedef long long ll;
const int N = 2*1e6+100;
const ll mod = 1e11+7;
using namespace std;

int n,m,k,L,R,flag[N],tot,ans[N],pos[N],pp[N];
ll a[N],b[N],ha[N];
struct Node{
	int l,r,id;
	ll w;
	bool operator < (Node xx) const {
		if(pos[l] != pos[xx.l]) return pos[l] < pos[xx.l];
		else return r < xx.r;
	}
}q[N];

int find(ll x){
	return lower_bound(b+1,b+1+tot,x)-b;
}

ll Hash(int pos){
	ll tp = 0;
	ll base = 1;
	rep(i,pos,pos+k-1){
		tp = (tp+a[i]*base)%mod;
		if(tp < 0) tp = (tp+mod)%mod;
		base = (base*(ll)133)%mod;
		if(base < 0) base = (base+mod)%mod;
	}
	return tp;
}

void add(int x) {flag[pp[x]]++;}

void del(int x) {flag[pp[x]]--;}

int main()
{
	scanf("%d%d%d",&n,&m,&k);
	rep(i,1,n) scanf("%lld",&a[i]);
	rep(i,1,m){
		int xx,yy; scanf("%d%d",&xx,&yy);
		q[i].l = xx, q[i].r = yy, q[i].id = i;
		q[i].r = q[i].r-k+1;
		ll tp = 0;
		ll base = 1;
		rep(j,1,k){
			ll hp; scanf("%lld",&hp);
			tp = (tp+hp*base)%mod;
			if(tp < 0) tp = (tp+mod)%mod;
			base = (base*(ll)133)%mod;
			if(base < 0) base = (base+mod)%mod;
		}
		q[i].w = tp;
		b[++tot] = tp;
	}
	rep(i,1,n-k+1){
		ll tp = Hash(i);
		ha[i] = tp;
		b[++tot] = tp;
	}
	sort(b+1,b+1+tot);
	tot = unique(b+1,b+1+tot)-b-1;
	rep(i,1,n-k+1){
		pp[i] = find(ha[i]);
	}
	int sz = sqrt(n);
	rep(i,1,n) pos[i] = i/sz;
	sort(q+1,q+1+m);
	L = 1, R = 0;
	rep(i,1,m){
		while(L < q[i].l) del(L), L++;
		while(L > q[i].l) L--, add(L);
		while(R < q[i].r) R++, add(R);
		while(R > q[i].r) del(R), R--;
		
		int pos = find(q[i].w);
		if(flag[pos]) ans[q[i].id] = 1;
		else ans[q[i].id] = 0;
	}
	rep(i,1,m){
		if(ans[i]) printf("No\n");
		else printf("Yes\n");
	}
	return 0;
}
3. XOR and Favorite Number

题意: 长度为 n n 的初始序列,共有 m m 次询问,每次询问给出一个 l r k l、r、k ,表示查询区间 [ l , r ] [l,r] 中有多少对 ( i , j ) (i,j) 满足 a i a_i ^ a i + 1 a_{i+1} ^ … ^ a j = k a_{j}=k ( 1 n , m 1 0 5 , 0 k 1 0 6 ) (1\leq n,m\leq 10^5,0\leq k\leq 10^6)

思路: 既然是某一区间的异或和,不难想到先求一个异或前缀和,然后对于一个 j j 来说,就是询问区间 [ l , r ] [l,r] 中有多少个 i i 满足 s u m [ i 1 ] sum[i-1] ^ s u m [ j ] = k sum[j]=k

问题拆解到这一步,剩下的问题就比较明了了,直接上莫队,然后用桶维护每一个数的异或前缀和即可。

代码:

#include <bits/stdc++.h>
#define rep(i,a,b) for(int i = a; i <= b; i++)
typedef long long ll;
const int N = 2*1e6+100;
using namespace std;

int a[N],pos[N],n,m,k,L,R;
ll ans[N],flag[N],Ans;
struct Node{
	int l,r,id;
	bool operator < (Node xx) const{
		if(pos[l] == pos[xx.l]) return r < xx.r;
		else return pos[l] < pos[xx.l];
	}
}Q[N];

void add(int x){
	Ans += flag[a[x]^k];
	flag[a[x]]++;
}

void del(int x){
	flag[a[x]]--;
	Ans -= flag[a[x]^k];
}

int main()
{
	L = 1, R = 0;
	scanf("%d%d%d",&n,&m,&k);
	int sz = sqrt(n);
	rep(i,1,n){
		scanf("%d",&a[i]);
		a[i] = a[i]^a[i-1];
		pos[i] = i/sz;
	}
	rep(i,1,m){
		scanf("%d%d",&Q[i].l,&Q[i].r);
		Q[i].id = i;
	}
	sort(Q+1,Q+1+m);
	flag[0] = 1;
	rep(i,1,m){
		while(L<Q[i].l) del(L-1), L++;
		while(L>Q[i].l) L--, add(L-1);
		while(R<Q[i].r) R++, add(R);
		while(R>Q[i].r) del(R), R--;
		ans[Q[i].id] = Ans;
	}
	rep(i,1,m) printf("%lld\n",ans[i]);
	return 0;
}
4. Chika and Friendly Pairs

题意: 长度为 n n 的序列, m m 次查询,每次给出一个 [ l , r ] [l,r] ,询问区间 [ l , r ] [l,r] 中有多少对 i , j i,j 满足 i < j i<j a [ i ] a [ j ] k |a[i]-a[j]|\leq k ( 1 n , m 27000 , 1 k 1 0 9 ) (1\leq n,m\leq 27000,1\leq k\leq 10^9)

思路: 由于 n n m m 的范围比较小,可以考虑使用莫队分块算法,在加入和删除的地方使用树状数组统计答案即可。

代码:

#include <bits/stdc++.h>
#define mem(a,b) memset(a,b,sizeof a);
#define rep(i,a,b) for(int i = a; i <= b; i++)
#define per(i,a,b) for(int i = a; i >= b; i--)
#define __ ios::sync_with_stdio(0);cin.tie(0);cout.tie(0)
typedef long long ll;
typedef double db;
const int N = 27000+100;
const db EPS = 1e-9;
using namespace std;

void dbg() {cout << "\n";}
template<typename T, typename... A> void dbg(T a, A... x) {cout << a << ' '; dbg(x...);}
#define logs(x...) {cout << #x << " -> "; dbg(x);}

int n,m,k,a[N],b[3*N],tot,L,R,pos[N],now[N][3];
struct Node{
	int l,r,id;
	bool operator < (Node xx) const {
		if(pos[l] == pos[xx.l]) return r < xx.r;
		else return pos[l] < pos[xx.l];
	}
}q[N];
ll c[3*N],ans[N],Ans;

inline int lowbit(int x) {return x&(~x+1);}
inline void update(int x,ll v) {for(;x<=tot;x+=lowbit(x)) c[x]+=v;}
inline ll ask(int x){
	ll tp = 0;
	while(x) tp += c[x], x -= lowbit(x);
	return tp;
} 

int find(int x){
	return lower_bound(b+1,b+1+tot,x)-b;
}

void add(int x){
	int p1 = now[x][1], p2 = now[x][2];
	Ans += ask(p1)-ask(p2);
	update(now[x][0],1);
}

void del(int x){
	update(now[x][0],-1);
	int p1 = now[x][1], p2 = now[x][2];
	Ans -= ask(p1)-ask(p2);
}

int main()
{
	L = 1, R = 0;
	scanf("%d%d%d",&n,&m,&k);
	int sz = sqrt(n);
	rep(i,1,n){
		scanf("%d",&a[i]);
		b[++tot] = a[i]; b[++tot] = a[i]+k; b[++tot] = a[i]-k-1;
		pos[i] = i/sz;
	}
	sort(b+1,b+1+tot);
	tot = unique(b+1,b+1+tot)-b-1;
	rep(i,1,n){
		now[i][0] = find(a[i]);
		now[i][1] = find(a[i]+k);
		now[i][2] = find(a[i]-k-1);
	}
	rep(i,1,m){
		scanf("%d%d",&q[i].l,&q[i].r);
		q[i].id = i;
	}
	sort(q+1,q+1+m);
	rep(i,1,m){
		while(L < q[i].l){
			del(L);
			L++;
		}
		while(L > q[i].l){
			L--;
			add(L);
		}
		while(R < q[i].r){
			R++;
			add(R);
		}
		while(R > q[i].r){
			del(R);
			R--;
		}
		ans[q[i].id] = Ans;
	}
	rep(i,1,m) printf("%lld\n",ans[i]);
	return 0;
}
5. 莫队求组合数前缀和

题意: q q 组询问,每次给出一个 n n m m ,求 i = 0 i = m C n i \sum\limits_{i=0}^{i=m}C_n^i ( 1 n , m , q 2 1 0 5 ) (1\leq n,m,q\leq 2*10^5)

思路: F ( n , m ) = i = 1 m C n i F(n,m)=\sum\limits_{i=1}^mC_n^i ,思考 F ( n , m ) F(n,m) F ( n , m + 1 ) F(n,m+1) F ( n + 1 , m ) F(n+1,m) 之间的关系。

F ( n , m + 1 ) = F ( n + m ) + C n m + 1 F(n,m+1)=F(n+m)+C_n^{m+1} F ( n + 1 , m ) = 2 F ( n , m ) C n m F(n+1,m)=2*F(n,m)-C_n^{m} 。预处理出阶乘和逆元之后,即可 O ( 1 ) O(1) 进行端点移动。

总结: 这其实是一道广义莫队问题,所谓广义莫队问题就是题目中并没有明确指明查询区间 [ l , r ] [l,r] 的答案,而是将所查询的问题转化为 F ( n , m ) F(n,m) 的形式,然后实现 F ( n , m ) F(n,m) F ( n + 1 , m ) F(n+1,m) 以及 F ( n , m + 1 ) F(n,m+1) 之间的 O ( 1 ) O(1) 转移,只要求出之间转移的公式就可以直接 O ( n n ) O(n*\sqrt n) 离线求出最终答案。


带修改莫队

一、适用问题

带修改的莫队算法就是在普通的莫队基础上增加了单点修改操作,时间复杂度为 O ( n 5 3 ) O(n^{\frac{5}{3}})

二、算法实现

带修改的莫队仍然是利用分块对查询和修改排序,尽可能地减少运行时间。

假设我们按照 k k 大小进行分块,则一共有 n k \frac{n}{k} 个块,然后对于每个操作,一共有三个参数,分别是 l l r r i d id ,表示区间左右端点和操作时间,我们先按照左端点的块号进行排序,再按照右端点的块号进行排序,最后按照操作时间进行排序。

莫队暴力时也需要维护三个值,L、R、T 表示当前控制的左右区间以及操作时间。对于每个查询,需要将 L L R R T T 移动到指定位置再进行计算,因此可以将带修改莫队理解为三维莫队。

接下来估算复杂度,假设 m m 次查询中,一共有 a a 次查询, b b 次修改。因此当确定左右端点块号时,即查询即按照时间排序时, T T 最多移动 b b 次,因此 T T 的移动一共有 n k n k b \frac{n}{k}*\frac{n}{k}*b 次。而每次查询,区间左右端点最多移动 2 k 2*k 次,因此 l l r r 最多移动 a 2 k a*2*k 次,因此总时间复杂度为 O ( b n 2 k 2 + 2 a k ) O(b*\frac{n^2}{k^2}+2*a*k) 。我们可以求导求这个函数的最小值,可以发现最后的答案会在 k = n 2 3 k=n^{\frac{2}{3}} 处取到最优解,因此整个算法的复杂度也就达到了 O ( n 5 3 ) O(n^{\frac{5}{3}}) 处。

三、带修改莫队习题

1. Machine Learning

题意: 长度为 n n 的初始序列,共有 m m 次操作,操作 1 1 给出一个 l l r r ,令 c i c_i i i [ l , r ] [l,r] 中出现的次数,询问 M e x ( c 0 , c 1 , . . . , c 1 0 9 ) Mex(c_0,c_1,...,c_{10^9}) 。操作 2 2 则将 a p a_p 改成 x x ( 1 n , m 1 0 5 ) (1\leq n,m\leq 10^5)

思路: 这个问题唯一的操作难点在于 m e x mex 函数的求取,其实我们可以像求取 S G SG 函数的 m e x mex 一样,直接暴力求取即可。然后其余部分就是常规的带修改莫队的操作了。

代码:

#include <cstdio>
#include <iostream>
#include <cstring>
#include <cmath>
#include <algorithm>
#define __ ios::sync_with_stdio(0);cin.tie(0);cout.tie(0)
#define rep(i,a,b) for(int i = a; i <= b; i++)
#define LOG1(x1,x2) cout << x1 << ": " << x2 << endl;
#define LOG2(x1,x2,y1,y2) cout << x1 << ": " << x2 << " , " << y1 << ": " << y2 << endl;
#define LOG3(x1,x2,y1,y2,z1,z2) cout << x1 << ": " << x2 << " , " << y1 << ": " << y2 << " , " << z1 << ": " << z2 << endl;
typedef long long ll;
typedef double db;
const int N = 2*1e5+100;
const int M = 1e5+100;
const db EPS = 1e-9;
using namespace std;

int n,qq,a[N],b[N],tot,Qnum,Cnum,pos[N],ans[N],L,R,T,flag[N],vis[N];
struct Query{
	int l,r,id,t;
	bool operator < (Query xx) const {
		if(pos[l] != pos[xx.l]) return pos[l] < pos[xx.l];
		else if(pos[r] != pos[xx.r]) return pos[r] < pos[xx.r];
		else return t < xx.t;
	}
}q[M];
struct Change{
	int pos,val;
}C[M];

int find(int x){
	return lower_bound(b+1,b+1+tot,x)-b;
}

void add(int x){
	if(flag[a[x]]!=0) vis[flag[a[x]]]--;
	flag[a[x]]++; vis[flag[a[x]]]++;
}

void del(int x){
	vis[flag[a[x]]]--; flag[a[x]]--;
	if(flag[a[x]] != 0) vis[flag[a[x]]]++;
}

void Work(int x,int i){
	if(C[x].pos >= q[i].l && C[x].pos <= q[i].r){
		vis[flag[a[C[x].pos]]]--; flag[a[C[x].pos]]--;
		if(flag[a[C[x].pos]] != 0) vis[flag[a[C[x].pos]]]++;
		if(flag[C[x].val] != 0) vis[flag[C[x].val]]--;
		flag[C[x].val]++; vis[flag[C[x].val]]++;
	}
	swap(a[C[x].pos],C[x].val);
}

int solve(){
	rep(i,0,n)
		if(!vis[i]) return i;
}

int main()
{
	scanf("%d%d",&n,&qq);
	rep(i,1,n){
		scanf("%d",&a[i]);
		b[++tot] = a[i];
	}
	rep(i,1,qq){
		int op,l,r; scanf("%d%d%d",&op,&l,&r);
		if(op == 1) Qnum++, q[Qnum] = {l,r,Qnum,Cnum};
		else C[++Cnum] = {l,r}, b[++tot] = r;
	}
	sort(b+1,b+1+tot);
	tot = unique(b+1,b+1+tot)-b-1;
	int sz = pow(n,0.66666666666666);
	rep(i,1,n) pos[i] = i/sz;
	sort(q+1,q+1+Qnum);
	L = 1, R = 0, T = 0;
	vis[0] = 1;
	rep(i,1,n) a[i] = find(a[i]);
	rep(i,1,Cnum) C[i].val = find(C[i].val);
	rep(i,1,Qnum){
		while(L < q[i].l) del(L++); 
		while(L > q[i].l) add(--L);
		while(R < q[i].r) add(++R);
		while(R > q[i].r) del(R--);
		while(T < q[i].t) Work(++T,i);
		while(T > q[i].t) Work(T--,i);
		ans[q[i].id] = solve();
	}
	rep(i,1,Qnum) printf("%d\n",ans[i]);
	return 0;
}
2. 数颜色

题意: 长度为 n n 的一个序列,每一个点都有一个颜色,一共 m m 次操作。第一种操作询问 [ l , r ] [l,r] 中一共有多少种不同的颜色,第二种操作则修改第 p p 个点的颜色。 ( 1 n , m 1 0 4 ) (1\leq n,m\leq 10^4)

思路: 开一个桶记录一下每种颜色出现的次数,然后就是一道莫队带修改的模板题了。

代码:

#include <bits/stdc++.h>
#define rep(i,a,b) for(int i = a; i <= b; i++)
typedef long long ll;
const int N = 2*1e5+100;
const int M = 1e6+100;
const db EPS = 1e-9;
using namespace std;

int n,m,a[N],Qnum,Cnum,pos[N],flag[M],L,R,T,Ans,ans[N];
struct Query{
	int l,r,Ti,id;
	bool operator < (Query xx) const {
		if(pos[l] != pos[xx.l]) return pos[l] < pos[xx.l];
		else if(pos[r] != pos[xx.r]) return pos[r] < pos[xx.r];
		else return Ti < xx.Ti;
	}
}Q[N];
struct Change{
	int pos,val;
}C[N];

void add(int x){
	flag[a[x]]++;
	if(flag[a[x]] == 1) Ans++;
}

void del(int x){
	flag[a[x]]--;
	if(flag[a[x]] == 0) Ans--;
}

void Work(int x,int i){
	if(C[x].pos >= Q[i].l && C[x].pos <= Q[i].r){
		flag[a[C[x].pos]]--; if(flag[a[C[x].pos]] == 0) Ans--;
		flag[C[x].val]++; if(flag[C[x].val] == 1) Ans++;
	}
	swap(C[x].val,a[C[x].pos]);
}

int main()
{
	scanf("%d%d",&n,&m);
	rep(i,1,n) scanf("%d",&a[i]);
	rep(i,1,m){
		char op[10]; int xx,yy; scanf("%s",op);
		scanf("%d%d",&xx,&yy);
		if(op[0] == 'Q') Qnum++, Q[Qnum] = {xx,yy,Cnum,Qnum};
		else C[++Cnum] = {xx,yy}; 
	}
	int sz = pow((ll)n, 0.66666666666);
	//分块大小为(n*t)^(1/3), t为修改的坐标范围
	//O(((n^4)*t))^(1/3))
	rep(i,0,n) pos[i] = i/sz;
	sort(Q+1,Q+1+Qnum);
	L = 1, R = 0, T = 0;
	rep(i,1,Qnum){
		while(L < Q[i].l) {del(L); L++;}
		while(L > Q[i].l) {L--; add(L);}
		while(R < Q[i].r) {R++; add(R);}
		while(R > Q[i].r) {del(R); R--;}
		while(T < Q[i].Ti) {++T; Work(T,i);}
		while(T > Q[i].Ti) {Work(T,i); T--;}
		ans[Q[i].id] = Ans;
	}
	rep(i,1,Qnum)
		printf("%d\n",ans[i]);
	return 0;
}

树上带修改莫队

一、适用问题

树上带修改的莫队算法就是将普通的带修改莫队问题搬到了树上进行操作,时间复杂度为 O ( n 5 3 ) O(n^{\frac{5}{3}})

二、算法实现

树上莫队问题仍然是通过分块进行解决,但是分块的序列发生了变化。这个序列需要满足,给出两点就能在序列上找出这两点之间的路径。

我们考虑常见的树上序列, d f s dfs 序,但是很明显 d f s dfs 序不满足这个条件,其中会有很多无效的节点。因此我们引出欧拉序来解决这个问题,欧拉序和 d f s dfs 序的区别是, d f s dfs 序只在遍历到这个节点时才会将这个节点加入序列,而欧拉序还会在回溯到这个节点时将节点加入序列。

因此在欧拉序中,每个点会有一个第一次到达的点和第二次到达的点,我们分别记为 f i r [ i ] fir[i] l a s [ i ] las[i] 。对于树上两点 x x y y f i r [ x ] < f i r [ y ] fir[x]<fir[y] ), u u x x y y 两点的 l c a lca ,若 x = u x=u ,则在 [ f i r [ x ] , f i r [ y ] ] [fir[x],fir[y]] 这段区间中,只有 x x y y 路径上的点只出现一次。若 x = x =\not u ,则在 [ l a s [ x ] , f i r [ y ] ] [las[x],fir[y]] 这段区间中只有 x x y y 路径上的点只出现一次,而且不包含 l c a ( x , y ) lca(x,y) 这个点。因此我们在树上莫队问题中,需要记录每个点出现的次数,第一次出现则加贡献,第二次出现则减贡献,且若 x = x=\not u ,还需加上 l c a lca 的贡献。

解决完树上莫队的序列问题,就可以转化成普通莫队进行计算了。不带修改则块大小为 2 n \sqrt {2n} ,带修改则块大小为 ( 2 n ) 2 3 (2n)^{\frac{2}{3}} ,其中 2 n 2n 为欧拉序长度。

三、树上带修莫队习题

1. 糖果公园 [WC2013]

题意: n n 个点的一棵树,每个点上都有一个糖果,糖果的种类不同,第 i i 类糖果的贡献为 V [ i ] V[i] ,第 j j 次吃第 i i 类糖果对答案的贡献为 V [ i ] W [ j ] V[i]*W[j] 。现有 q q 次操作,每次可以将第 x x 个点上的糖果类型改为 y y ,也可以查询从 x x 点到 y y 点的答案。 ( 1 n , q 1 0 5 ) (1\leq n,q\leq 10^5)

思路: 莫队问题只需要关注加入节点和删除节点对答案的影响,因此只需要统计每一类糖果在路径中出现的次数即可完成节点增删时对答案的影响。

该题思路不难,但树上莫队细节较多,需要查看代码并自行实现一遍。

代码:

#include <cstdio>
#include <iostream>
#include <cstring>
#include <cmath>
#include <algorithm>
#define __ ios::sync_with_stdio(0);cin.tie(0);cout.tie(0)
#define rep(i,a,b) for(int i = a; i <= b; i++)
#define LOG1(x1,x2) cout << x1 << ": " << x2 << endl;
#define LOG2(x1,x2,y1,y2) cout << x1 << ": " << x2 << " , " << y1 << ": " << y2 << endl;
#define LOG3(x1,x2,y1,y2,z1,z2) cout << x1 << ": " << x2 << " , " << y1 << ": " << y2 << " , " << z1 << ": " << z2 << endl;
typedef long long ll;
typedef double db;
const int N = 1e5+100;
const int M = 1e6+100;
const db EPS = 1e-9;
using namespace std;

int n,m,k,V[N],W[N],head[N],tot,C[N],qnum,cnum,f[N][25],t,d[N],Euler[2*N],ncnt,fir[N],las[N],pos[2*N],L,R,T,flag[N],vis[N];
//pos-分块位置、fir-欧拉序第一次、las-欧拉序第二次、Euler-欧拉序数组、ncnt-欧拉序数组长度
//vis-这个树上节点出现了几次, flag-这个糖果种类
ll ans[N],now;
struct Edge{
	int to,next;
}e[2*N];
struct Query{
	int l,r,id,lca,t; //l、r、id-查询顺序、lca-两点lca、t-之前有几次修改
	bool operator < (Query xx) const {
		if(pos[l] != pos[xx.l]) return pos[l] < pos[xx.l];
		else if(pos[r] != pos[xx.r]) return pos[r] < pos[xx.r];
		else return t < xx.t;
	}
}q[N];
struct Change{
	int pos, val;
}ch[N];

void add(int x,int y){
	e[++tot].to = y, e[tot].next = head[x], head[x] = tot;
}

//求出欧拉序以及lca预处理
void dfs(int u,int fa)
{
	Euler[++ncnt] = u; fir[u] = ncnt;
    d[u]=d[fa]+1; f[u][0]=fa;
    for(int i=1;(1<<i)<=d[u];i++)
        f[u][i]=f[f[u][i-1]][i-1];
    for(int i=head[u]; i; i=e[i].next){
        int v=e[i].to;
        if(v!=fa) dfs(v,u);
    }
    Euler[++ncnt] = u; las[u] = ncnt;
}    

int LCA(int x,int y)
{
	if(d[x] > d[y]) swap(x,y);
	for(int i = t; i >= 0; i--)
		if(d[f[y][i]] >= d[x]) y = f[y][i];  //往上追溯,直至y和x位于同一深度
	if(x == y) return x;  //如果已经找到了,就返回x
	for(int i = t; i >= 0; i--)
		if(f[x][i] != f[y][i]) x = f[x][i], y = f[y][i];  //x和y同时往上走,一直到x和y恰好为lca的子节点
	return f[x][0];  //x和y共同的根节点就是lca 
}

void Add(int pos){
	flag[C[pos]]++;
	now += (ll)W[flag[C[pos]]]*(ll)V[C[pos]];
}

void Del(int pos){
	now -= (ll)W[flag[C[pos]]]*(ll)V[C[pos]];
	flag[C[pos]]--;
}

void add_del(int pos){ //增加和减少取决于这个点被遍历了几次
	vis[pos] ? Del(pos) : Add(pos);
	vis[pos] ^= 1;
}

void work(int x){
	if(vis[ch[x].pos]){ //修改点为有效点
		add_del(ch[x].pos); //减掉
		swap(C[ch[x].pos], ch[x].val);
		add_del(ch[x].pos); //加上
	}
	else swap(C[ch[x].pos], ch[x].val);
}

int main()
{
	scanf("%d%d%d",&n,&m,&k);
	rep(i,1,m) scanf("%d",&V[i]);
	rep(i,1,n) scanf("%d",&W[i]);
	rep(i,1,n-1){
		int xx,yy; scanf("%d%d",&xx,&yy);
		add(xx,yy); add(yy,xx);
	}
	rep(i,1,n) scanf("%d",&C[i]);
	t = (int)(log(n)/log(2))+1;
	dfs(1,0);
	int sz = pow(ncnt,2.0/3.0);
	for(int i = 0; i <= ncnt; i++) pos[i] = i/sz;
	rep(i,1,k){
		int op,x,y; scanf("%d%d%d",&op,&x,&y);
		if(op){
			int lca = LCA(x,y);
			q[++qnum].t = cnum; q[qnum].id = qnum;
			//根据lca判断欧拉序顺序, 若x不为y lca, 则欧拉序中不包含lca, 因此还需加上lca的贡献
			if(fir[x] > fir[y]) swap(x,y);
			if(x == lca) q[qnum].l = fir[x], q[qnum].r = fir[y], q[qnum].lca = 0;
			else q[qnum].l = las[x], q[qnum].r = fir[y], q[qnum].lca = lca;
		}
		else ch[++cnum] = {x,y};
	}
	sort(q+1,q+1+qnum);
	L = 1, R = 0, T = 0;
	rep(i,1,qnum){
		while(L < q[i].l){
			add_del(Euler[L]); L++;
		}
		while(L > q[i].l){
			L--; add_del(Euler[L]);
		}
		while(R < q[i].r){
			R++; add_del(Euler[R]);
		}
		while(R > q[i].r){
			add_del(Euler[R]); R--;
		}
		while(T < q[i].t){
			++T; work(T);
		}
		while(T > q[i].t){
			work(T); --T;
		}
		if(q[i].lca) add_del(q[i].lca); //lca不在欧拉序列区间中
		ans[q[i].id] = now;
		if(q[i].lca) add_del(q[i].lca); //恢复这个区间的状态
	}
	rep(i,1,qnum) printf("%lld\n",ans[i]);
	return 0;
}

回滚莫队

一、适用问题

普通莫队最重要的辨别点在于可以 O ( 1 ) O(1) 的增加或删除节点,而回滚莫队的关键点在于只能 O ( 1 ) O(1) 的增加或者删除节点,增加或删除只能二者选其一。

常见的此类问题比如 m a x max m i n min m e x mex 等等… 而回滚莫队的复杂度也很修改,不带修改的时候只有 O ( n n ) O(n\sqrt n) ,如果带修改的话加上其自身的较大常数,时间复杂度就会比较囍…

二、算法实现

回滚莫队的关键点在于只能增加或删除节点,我们以求取 m a x max 为例。求取 m a x max 时,增加节点时可以顺便更新答案,但是删除节点时就非常不好维护,因此我们需要设计一个只需要增加节点的莫队算法。

  1. 首先还是老套路,按照 n \sqrt n 进行分块,并确定每一块的左右边界,分别为 x l [ i ] x r [ i ] xl[i]、xr[i]
  2. 然后我们按照左端点所在块编号为第一关键字,右端点大小为第二关键字,对所有查询进行排序。
  3. 接下来对于所有左右端点在同一块中的查询,我们直接暴力求取答案,复杂度为 O ( n ) O(\sqrt n)
  4. 对于左端点所在块相同的查询,其右端点不断递增,因此右端点最多移动 O ( n ) O(n) ,总共 n \sqrt n 个块,右端点复杂度为 O ( n n ) O(n\sqrt n)
  5. 接下来考虑左端点的移动,我们对于所有左端点所在块相同的查询,每一个查询结束之后都要把左端点移动到 x r [ i ] + 1 xr[i]+1 的位置,即左端点所在块的右端点 + 1 +1 的位置,这样可以保证每次查询都是不断增加节点的,因此不会影响最终答案。每个查询,左端点最多移动距离为 O ( n ) O(\sqrt n) ,因此左端点移动的复杂度为 O ( m n ) O(m\sqrt n) 。所以综合左右端点的移动,该算法的复杂度为 O ( m n + n n ) O(m\sqrt n+n\sqrt n) ,考虑到 m m 的范围通常与 n n 一致,因此最终复杂度为 O ( n n ) O(n\sqrt n)

上述过程就是回滚莫队的求取过程,习题中分别给出了增加节点和减少节点的回滚莫队算法,其它具体实现细节可以查看代码。

三、回滚莫队习题

1. 历史研究

题意: 长度为 n n 的序列,每个数的大小为 x i x_i 。一共 q q 次查询,每次给出一个区间 l l r r ,询问区间 [ l , r ] [l,r] 中每个数贡献的最大值,一个数的贡献为 x i c n t [ x i ] x_i*cnt[x_i] ,即数大小 * 该数出现次数。 ( 1 n , q , 1 0 5 , 1 x i 1 0 9 ) (1\leq n,q,\leq 10^5, 1\leq x_i\leq 10^9)

思路: 首先把序列离散化,然后用一个桶记录每一个数字出现的次数。

接下来就是回滚莫队的基本操作了,求出每块的左右端点,然后对查询排序。每次查询时判断左右端点是否在同一个快内,如果在就暴力求,如果不在就增加节点扩充区间。每个查询结束后,要将左端点再移动到该块的右边界 + 1 +1 位置,具体的实现细节见代码。

代码:

#include <bits/stdc++.h>
#define rep(i,a,b) for(int i = a; i <= b; i++)
typedef long long ll;
const int N = 2e5+100;
using namespace std;

int n,m,sz,pos[N],a[N],b[N],tot,val[N],xl[N],xr[N],cnt[N],L,R,_cnt[N],lastblock;
//Maxn - 左右端点控制的最大值,temp - 临时最大值
//cnt - 左右端点移动时计数,_cnt - 左右端点同块时的计数
ll ans[N],Maxn,temp;
struct Node{
	int l,r,id;
	bool operator < (Node xx) const {
		if(pos[l] == pos[xx.l]) return r < xx.r;
		else return pos[l] < pos[xx.l];
	}
}q[N];

void init(){
	scanf("%d%d",&n,&m); sz = sqrt(n);
	rep(i,1,n) {scanf("%d",&a[i]); b[++tot] = a[i];}
	rep(i,1,m) {scanf("%d%d",&q[i].l,&q[i].r); q[i].id = i;}
	sort(b+1,b+1+tot); tot = unique(b+1,b+1+tot)-b-1;
	rep(i,1,n) val[i] = lower_bound(b+1,b+1+tot,a[i])-b;
	rep(i,1,n){
		pos[i] = i/sz;
		xl[pos[i]] = (xl[pos[i]] == 0 || xl[pos[i]] > i) ? i : xl[pos[i]];
		xr[pos[i]] = (xr[pos[i]] < i) ? i : xr[pos[i]];
	}
	sort(q+1,q+1+m);
}	

inline ll add(int x){
	return (++cnt[val[x]])*(ll)b[val[x]];
}

inline void del(int x) {cnt[val[x]]--;}

void solve(){
	L = 1, R = 0, lastblock = -1;
	rep(i,1,m){
		if(pos[q[i].l] == pos[q[i].r]){
			ll temp = 0;
			rep(j,q[i].l,q[i].r) temp = max(temp,(++_cnt[val[j]])*(ll)b[val[j]]);
			rep(j,q[i].l,q[i].r) _cnt[val[j]]--;
			ans[q[i].id] = temp;
		}
		else{
			if(lastblock != pos[q[i].l]){
				while(L < xr[pos[q[i].l]]+1) del(L), L++;
				while(R > L-1) del(R), R--;
				Maxn = 0; lastblock = pos[q[i].l];
			}
			//Maxn为右半部分的最大值,不包含左端点所在块的情况
			while(R < q[i].r) R++, Maxn = max(Maxn,add(R));
			temp = Maxn;
			//temp从Maxn继承而来,表示整个区间的最大值
			while(L > q[i].l) L--, temp = max(temp,add(L));
			while(L < xr[pos[q[i].l]]+1) del(L), L++;
			ans[q[i].id] = temp;
		}
	}
}

int main()
{
	init();
	solve();
	rep(i,1,m) printf("%lld\n",ans[i]);
	return 0;
}
2. Rmq Problem / mex

题意: 长度为 n n 的序列,每个数的大小为 a i a_i 。一共 m m 次查询,每次给出一个区间 l l r r ,询问区间 [ l , r ] [l,r] 中数的 m e x mex ,其中一个区间的 m e x mex 指该区间内最小没有出现过的自然数。 ( 1 n , q , 2 1 0 5 , 0 a i 1 0 9 ) (1\leq n,q,\leq 2*10^5, 0\leq a_i\leq 10^9)

思路: 由于是求 m e x mex ,而数字总数为 2 e 5 2e5 ,因此不需要对数字进行离散化。然后我们来分析这个问题的关键点,即增删节点的特性。

不难发现,对于这个问题来说,删除节点可以 O ( 1 ) O(1) 的更新答案,但是增加节点后答案的变化难以确定,因此考虑采用删除节点形式的回滚莫队来解决这个问题。

删除节点的回滚莫队,就是区间长度不断缩小的情况。因此我们需要对每个查询的左端点所在块编号进行升序,对每个查询的右端点进行降序,这样可以保证右端点是不断递减的。

然后对于左右端点在同一个块中的情况,我们依然是暴力求取答案。而对于不在同一块中的情况,我们需要每次查询结束后都将左端点移动到查询左端点所在的块的左边界上,这样才能保证区间长度在不断缩小。

除了上述这些回滚莫队的共性点之外,我们还需要关注一些特性点。对于这个问题,我们需要在最开始将左右边界分别设置为 1 1 n n ,这样的目的是保证区间长度是不断递减的。然后求取答案时,我们需要维护两部分答案,一部分是区间 [ l , r ] [l,r] 中完全包含左端点所在块的部分的答案,另一部分即为当前查询的结果。

保存第一部分答案的目的在于增加节点是不能 O ( 1 ) O(1) 维护答案的,因此左端点递增之后答案就会变化而且不能恢复,所以如果不保存第一部分的答案是不能直接继承到下一个查询的,具体细节看代码就能够理解。

代码:

#include <bits/stdc++.h>
#define rep(i,a,b) for(int i = a; i <= b; i++)
const int N = 2e5+100;
using namespace std;

int n,m,a[N],sz,pos[N],xl[N],xr[N],cnt[N],ans[N],_cnt[N],lastblock,L,R;
struct Node{
	int l,r,id;
	bool operator < (Node xx) const {
		if(pos[l] == pos[xx.l]) return r > xx.r;
		else return pos[l] < pos[xx.l];
	}
}q[N];

void init(){
	scanf("%d%d",&n,&m);
	rep(i,1,n) scanf("%d",&a[i]);
	rep(i,1,m) scanf("%d%d",&q[i].l,&q[i].r), q[i].id = i;
	rep(i,1,n)
		if(a[i] > 2e5) a[i] = 2e5+1;
	int sz = sqrt(n);
	rep(i,1,n){
		pos[i] = i/sz; //点i所在块
		xl[pos[i]] = xl[pos[i]] == 0 ? i : xl[pos[i]]; //pos[i]块的左端点
		xr[pos[i]] = xr[pos[i]] < i ? i : xr[pos[i]]; //pos[i]块的右端点
	}
	sort(q+1,q+1+m);
}

inline void add(int x){
	cnt[a[x]]++;
}

inline void del(int x,int& hp){
	cnt[a[x]]--;
	if(cnt[a[x]] == 0 && a[x] < hp) hp = a[x];
}

void solve(){
	L = 1, R = n, lastblock = -1;
	rep(i,1,n) cnt[a[i]]++;
	int minn = 0;
	while(cnt[minn]) minn++;
	int base_min = minn;
	rep(i,1,m){
		if(pos[q[i].l] == pos[q[i].r]){
			rep(j,q[i].l,q[i].r) _cnt[a[j]]++;
			int now = 0;
			while(_cnt[now]) now++;
			rep(j,q[i].l,q[i].r) _cnt[a[j]]--;
			ans[q[i].id] = now;
		}
		else{
			if(lastblock != pos[q[i].l]){
				//每一次进入新的块时,右端点都是直接到n的,因此区间只有左端点在递增,可以不断O(1)维护答案
				while(R < n) R++, add(R);
				while(L < xl[pos[q[i].l]]) del(L,base_min), L++;
				minn = base_min; lastblock = pos[q[i].l];
			}
			//minn为包含左端点整个块的答案,用于继承到后续查询
			while(R > q[i].r) del(R,minn), R--;
			//temp为查询的答案
			int temp = minn;
			while(L < q[i].l) del(L,temp), L++;
			while(L > xl[pos[q[i].l]]) L--, add(L);
			ans[q[i].id] = temp;
		}
	}
}

int main()
{
	init();
	solve();
	rep(i,1,m) printf("%d\n",ans[i]);
	return 0;
}

总结

其实莫队说到底就是一个分块算法,主要关键点就在于能不能 O ( 1 ) O(1) 的增删节点,可不可以离线, O ( n n ) O(n\sqrt n) 以及 O ( n 5 3 ) O(n^{\frac{5}{3}}) 能不能接受。

几个莫队算法的主要区别就在于是否需要修改,有无上树,是否可以 O ( 1 ) O(1) 增删节点,还是只能满足其一,辨别出关键点之后就比较容易上手,所以题目如果没有思路的话一定要想起这个离线分块算法哦!

最后,祝大家 A 题愉快!(๑•̀ㅂ•́)و✧

发布了244 篇原创文章 · 获赞 115 · 访问量 3万+

猜你喜欢

转载自blog.csdn.net/qq_41552508/article/details/100556943