Loading

学习笔记——莫队进阶

前言

发现至今没有系统地学过莫队。。。

普通莫队一般人都会,就一分块暴力。

题单 以及 dx 的训练题单 以及 dx 的双倍经验题单

奇怪的碎碎念
本文的题目基本来自于上面的题单,文末的 Tasks 模块是trashbin前面每个模块里看起来比较综合或者难写的题。相当于作业?以及只有板子题和困难题的关键部分会放代码,不然就太长了/qd。

带修莫队

考虑怎么样使得莫队带修,事实上你加一维时间(也就是对于每次询问记录最近一次修改,然后作为关键字之一进行排序)。

考虑块长,一般莫队的块长是 \(\sqrt{n}\) 的,但是在带修的时候,由于多加了一维,\(\sqrt{n}\) 可能被卡到 \(O(n^2)\),所以块长需要调到 \(n^{\frac{2}{3}}\)

酱紫就可以使莫队支持修改了。

例题

模板题。

My Code
const int MAXN=2e5+10;
int cnt[MAXN<<3],cur,a[MAXN];
void add(int i){if(!cnt[a[i]])cur++;cnt[a[i]]++;}
void dec(int i){cnt[a[i]]--;if(!cnt[a[i]])cur--;}
struct Update{int p,col;};
vector<Update> upd;
int B;
struct Query{
	int l,r,pre,id;
	bool friend operator<(Query a,Query b){
		return (a.l/B==b.l/B)?((a.r/B==b.r/B)?a.pre<b.pre:a.r<b.r):a.l<b.l;
	}
};
vector<Query> q;
int ans[MAXN];
int main()
{
	ios::sync_with_stdio(0);
	cin.tie(0);cout.tie(0);
	int n,m;cin>>n>>m;
	rep(i,1,n) cin>>a[i];
	rep(i,1,m){
		char op;cin>>op;
		if(op=='Q'){
			int l,r;cin>>l>>r;int id=q.size();
			q.pb((Query){l,r,upd.size()-1,id});
		}else{
			int p,col;cin>>p>>col;
			upd.pb((Update){p,col});
		}
	}
	B=pow(n,2.0/3);
	sort(q.begin(),q.end());
	int l=1,r=0,t=-1;
	for(auto c:q){
		while(l<c.l) dec(l++);
		while(r>c.r) dec(r--);
		while(l>c.l) add(--l);
		while(r<c.r) add(++r);
		while(t<c.pre){
			t++;if(upd[t].p>=l&&upd[t].p<=r) dec(upd[t].p);
			swap(upd[t].col,a[upd[t].p]);
			if(upd[t].p>=l&&upd[t].p<=r) add(upd[t].p);
		}while(t>c.pre){
			if(upd[t].p>=l&&upd[t].p<=r) dec(upd[t].p);
			swap(upd[t].col,a[upd[t].p]);
			if(upd[t].p>=l&&upd[t].p<=r) add(upd[t].p);t--;
		}ans[c.id]=cur;
	}
	rep(i,0,(int)q.size()-1) cout<<ans[i]<<'\n';
	return 0;
}

求区间出现次数的 mex,如果是暴力维护的话就是记录一个 \(cnt_i\)\(cnt_{cnt_i}\),然后每次看是否要改当前答案,然后再暴力向上检查是否可行。这样的做法可以被卡掉,于是我们考虑优化这个做法。其实我们不一定要一边更新一边求答案,可以最后一起求。考虑这个一起求答案的复杂度,如果要 \(1\sim n\) 都出现,那实际上需要 \(r-l+1\ge \dfrac{n(n-1)}{2}\),也就是说,暴力次数是不超过 \(\sqrt{n}\) 的,不构成瓶颈。

考虑和上面那题一样,维护一个出现 \(i\) 次的数有多少个,然后统计答案就是找到最接近的 \(l,r\) 使得 \(\sum\limits_{i=l}^rcnt_i\ge k\)。这时候容易想到可以用双指针维护,但是如果直接暴力跑的话显然是不行的。那从上一题得到启发,\(cnt_i>0\) 的个数是不超过 \(\sqrt{n}\) 的,所以如果只遍历 \(cnt_i>0\) 的复杂度就是对的了(反正等于 \(0\) 不产生贡献)。于是我们用链表维护这个大于 \(0\)\(cnt_i\),每次向链表尾加入,然后每次遍历链表抠出来,排序,直接尺取就可以了。

回滚莫队

考虑到莫队可以做区间数颜色,那我们可不可以让他像其他数据结构一样呢?比如说,区间最值?

发现传统莫队在求最值的时候没有办法很好地删除一个元素,所以我们考虑能不能让莫队不删除呢?这就是回滚莫队所解决的问题。

它的主要思想就是,对于每个块内,所有询问的右端点都是单调递增的。这样我们如果顺次访问这些询问,右端点是不需要删除的。于是我们就发明了回滚莫队:首先对于每个块,先预处理一下两个端点都在这个块内的情况,单次复杂度是 \(O(\sqrt n)\) 的。然后我们搞两个指针 \(l,r\) 指在块尾以及块尾的下一个。然后对于一个右端点,左端点都暴力往前扫一遍,单次复杂度是 \(O(\sqrt n)\) 的。所以均摊下来复杂度还是 \(O(n\sqrt n)\)。这就是回滚莫队之不删除莫队

例题

先离散化然后记录每个元素下标的最大和最小值就可以了。

My Code
const int MAXN=2e5+10;
int a[MAXN],lsh[MAXN],B;
inline int bl(int id){return (id-1)/B+1;}
struct Query{
	int l,r,id;
	void input(int i){cin>>l>>r;id=i;}
	bool friend operator<(Query x,Query y){
		return bl(x.l)==bl(y.l)?x.r<y.r:x.l<y.l;
	}
}q[MAXN];
int mx[MAXN],mn[MAXN],mx2[MAXN],mn2[MAXN],ans[MAXN];
int cur=0;
void add(int i){
	mx[a[i]]=max(mx[a[i]],i);
	mn[a[i]]=min(mn[a[i]],i);
	cur=max(cur,mx[a[i]]-mn[a[i]]);
}
struct Cancel{int i,mx,mn;};
int main()
{
	ios::sync_with_stdio(0);
	cin.tie(0);cout.tie(0);
	int n;cin>>n;
	rep(i,1,n) cin>>a[i],lsh[i]=a[i];
	sort(lsh+1,lsh+1+n);
	int Cnt=unique(lsh+1,lsh+1+n)-lsh-1;
	rep(i,1,n) a[i]=lower_bound(lsh+1,lsh+1+Cnt,a[i])-lsh;
	B=sqrt(n);
	int m;cin>>m;
	rep(i,1,m) q[i].input(i);
	sort(q+1,q+1+m);
	for(int l=1,r;l<=m;l=r+1){
		r=l;while(r<m&&bl(q[r+1].l)==bl(q[l].l)) r++;
		memset(mn,0x3f,sizeof(mn));
		memset(mx,0,sizeof(mx));
		int blid=bl(q[l].l),pr=blid*B;
		cur=0;
		rep(i,l,r){
			if(bl(q[i].l)==bl(q[i].r)){
				rep(j,q[i].l,q[i].r) mx2[a[j]]=0,mn2[a[j]]=inf;
				ans[q[i].id]=0;
				rep(j,q[i].l,q[i].r){
					mx2[a[j]]=max(mx2[a[j]],j);
					mn2[a[j]]=min(mn2[a[j]],j);
					ans[q[i].id]=max(ans[q[i].id],mx2[a[j]]-mn2[a[j]]);
				}continue;
			}
			while(pr<q[i].r) add(++pr);
			int tmp=cur;
			int pl=blid*B+1;
			vector<Cancel> can;
			while(pl>q[i].l) --pl,can.pb((Cancel){a[pl],mx[a[pl]],mn[a[pl]]}),add(pl);
			ans[q[i].id]=cur;
			cur=tmp;
			reverse(can.begin(),can.end());
			for(auto s:can) mx[s.i]=s.mx,mn[s.i]=s.mn;
		}
	}
	rep(i,1,m) cout<<ans[i]<<'\n';
	return 0;
}

好像是回滚莫队经典题。有点阅读理解,就是求区间权值乘上出现次数的最大值。然后由于是最大值,没有办法删除,所以直接滚就可以了。注意乘的时候要用离散化前的数据。

发现这题和之前的非常不一样。之前求 max 的时候是插入容易删除难,这题求 mex,删除倒是只要与当前答案比个大小就可以了,但是加入的时候需要一个一个向上去比较,是困难的。于是我们考虑另一种回滚莫队——不加入莫队!经过 \(\texttt{L}\color{red}{\texttt{JC00175}}\) 的指点迷津,我们发现不加入莫队其实就是不删除反过来——首先预处理出当前块首到块内最大右端点的答案。然后把左端点在同一块内的询问右端点按从大到小排序,然后每次左指针重新跑一遍就可以了。值得庆祝的是,不加入莫队更为好写,因为它不需要处理左右端点都在同一块内的情况了!

滚子最后一题了。来点人话:每次询问给出 \(l,r,x,y\),求 \([l,r]\) 有多少个子区间,使得其最小值不小于 \(x\) 并且最大值不超过 \(y\)。好困难啊,想了好久了。哦是排列啊,那我们来考虑一下在值域上跑莫队。也就是,我们令 \(b_i=[x\le b_i\le y]\),这样问题就变成查询一段区间内所有极长全 \(1\) 段的 \(\dfrac{len(len+1)}{2}\) 之和。那每次移动左右端点,只会改变一个 \(b_i\)。然后考虑怎么查答案。线段树当然可以做,但是修改变成 \(O(\log n)\) 的,大常数选手瑟瑟发抖。考虑到复杂度的平衡,能不能 \(O(1)\) 修改 \(O(\sqrt n)\) 查询呢?这东西摆明了就是分块啊。

你对于每个块内维护块内所有极长全 \(1\) 串对答案的贡献,然后考虑合并相邻两个块,你就对每个块再维护一下前缀极长 \(1\) 和后缀极长 \(1\),如果两个块前后缀相接了,那就把两块的答案分别减掉然后加上总的答案。这一部分实际上和线段树是一样的。那考虑怎么方便地维护这个答案,用链表!这题的链表十分高妙,\(pre_i\) 表示 \(i\) 所在的极长全 \(1\) 的开头,\(nxt_i\) 表示 \(i\) 所在的极长全 \(1\) 的结尾,然后实际上,我们在合并区间的时候,只需要考虑首尾两个元素的 \(pre\)\(nxt\) 就可以了。然后对每个块维护一下答案,然后如果询问区间小于等于 \(2\sqrt n\) 就直接暴力就可以了。(感谢 \(\texttt{d}\color{red}{\texttt{evinwang}}\) 的指点)

……撤销十分麻烦啊。

从题解那里学到一个方便的写法。就是你开 \(3\) 个数组,然后遇到要撤销的时候,就掏出一个备用的数组,然后直接用这个备用的复制永久化的那个数组,然后之后不用管它就可以了。

上面说的写法
template<int Size>
struct Mytype{
	int tmp[Size],cnt[Size],rel[Size];
	int t=1,vit=0;
	void Roll(){t++;}
	void Vit(){vit=1;t++;}
	void Real(){vit=0;}
	void Clear(){memset(rel,0,sizeof(rel));t++;}
	int& operator[](int i){
		if(vit){
			if(cnt[i]^t) cnt[i]=t,tmp[i]=rel[i];
			return tmp[i];
		}else return rel[i];
	}
};
Mytype<MAXN> a;
//用的时候就是,如果要撤销的,就在做之前先用一次 Vit,然后如果是不撤销的,那就调用 Real。

Summary

回滚莫队的重点就是发现什么是很难维护的,也就是数据结构的选择,注意复杂度的平衡和分析。关于临时修改的方便写法上面已经给出了。

树上莫队

莫队上♂树?然而并不是,是树上♂莫队。并非在树上分块,而是把树变成序列……

树上问题一般分成几类,现在我们分别来分析一下,怎么把树摊成序列来解决。(当然,对于这种问题,dsu on tree 或者点分是更好的选择,但也许有的时候不得不用莫队呢……)

子树内信息统计

这个是比较好想的,直接用 dfn 展开,那子树一定是连续的区间,就做完了。

路径上的信息统计

到路径普通的 dfn 就做不到了,这时候我们掏出一个欧拉序,具体地,我们在进入和退出某子树的时候都把根加入到序列中。

欧拉序有什么用?考虑每个节点一定是被加入两次的,并且夹在这两次中间的都是这个点子树中的点。这样一来,我们就解决了我们上面的问题,为什么 dfn 做不到?因为只用 dfn 可能会把某个节点向外的子树中的节点也算进去。然后有了欧拉序之后,我们可以通过选择第一次出现还是第二次出现来避开子树中的节点或者使它们出现两次从而人为地剔除掉。

具体做的时候,我们对于询问 \(l,r\),找出它们的 \(lca\)。然后我们使 \(first_l\le first_r\)。然后如果 \(lca=l\),则 \(l\to r\) 的路径就是序列上的 \([first_l,first_r]\)。否则,就是序列上的 \([last_x,first_y]\),注意这种情况要带一个 \(lca\)。然后做的时候,记一个 \(vis_i\),表示访问了几次,当前是该加入还是删除,然后做一次就把它异或上 \(1\)。这样一来,我们就不需要一个 \(ins\)\(del\),用这个方法可以把两个函数整合在一起。正确性显然,因为加入后才可能删除,必然是偶数次是删除。

我踩的坑:\(vis_i\) 标记的一定是下标而不是权值,因为权值可能相同。

例题

模板题,直接按上面的套路做就可以了。

My Code
const int MAXN=1e5+10;
vector<int> e[MAXN];
int a[MAXN],lsh[MAXN];
int f[20][MAXN],fst[MAXN],lst[MAXN],tot;
int stk[MAXN<<1],dep[MAXN];
void dfs(int x,int fa){
	fst[x]=++tot;stk[tot]=x;
	f[0][x]=fa;
	rep(i,1,19) f[i][x]=f[i-1][f[i-1][x]];
	for(int s:e[x]){
		if(s==fa) continue;
		dep[s]=dep[x]+1;
		dfs(s,x);
	}lst[x]=++tot;stk[tot]=x;
}
int LCA(int x,int y){
	if(dep[x]<dep[y]) swap(x,y);
	int dlt=dep[x]-dep[y];
	rep(i,0,19) if((1<<i)&dlt) x=f[i][x];
	if(x==y) return x;
	per(i,19,0) if(f[i][x]^f[i][y]) x=f[i][x],y=f[i][y];
	return f[0][x];
}
int B;
struct Query{
	int l,r,id,lca;
	bool friend operator<(Query x,Query y)
	{return x.l/B==y.l/B?x.r<y.r:x.l<y.l;}
}q[MAXN];
bool vis[MAXN];
int cnt[MAXN],cur;
void calc(int x){
	if(vis[x]){
		cnt[a[x]]--;
		if(!cnt[a[x]]) cur--;
	}else{
		if(!cnt[a[x]]) cur++;
		cnt[a[x]]++;
	}vis[x]^=1;
}
int ans[MAXN];
int main()
{
	ios::sync_with_stdio(0);
	cin.tie(0);cout.tie(0);
	int n,m;cin>>n>>m;
	rep(i,1,n) cin>>a[i],lsh[i]=a[i];
	sort(lsh+1,lsh+1+n);
	int Cnt=unique(lsh+1,lsh+1+n)-lsh-1;
	rep(i,1,n) a[i]=lower_bound(lsh+1,lsh+1+Cnt,a[i])-lsh;
	rep(i,2,n){
		int u,v;cin>>u>>v;
		e[u].pb(v);e[v].pb(u);
	}dfs(1,0);
	rep(i,1,m){
		int l,r,lca;q[i].id=i;
		cin>>l>>r;lca=LCA(l,r);
		if(fst[l]>fst[r]) swap(l,r);
		if(lca==l) q[i].l=fst[l],q[i].r=fst[r],q[i].lca=0;
		else q[i].l=lst[l],q[i].r=fst[r],q[i].lca=lca;
	}
	B=sqrt(tot);
	sort(q+1,q+1+m);
	int l=1,r=0;
	rep(i,1,m){
		while(l<q[i].l) calc(stk[l++]);
		while(r>q[i].r) calc(stk[r--]);
		while(l>q[i].l) calc(stk[--l]);
		while(r<q[i].r) calc(stk[++r]);
		if(q[i].lca) calc(q[i].lca);
		ans[q[i].id]=cur;
		if(q[i].lca) calc(q[i].lca);
	}
	rep(i,1,m) cout<<ans[i]<<'\n';
	return 0;
}

(指着电脑)这是我自己的发明!——zmf
摘自《神仙语录》第 234 条

我们考虑怎么跟上时代。

需要换根。如果不用,那么直接 dfn 展开就可以做了——吗?询问是两个子树中分别统计颜色,然后求 \(\sum cnt_{x,col}\times cnt_{y,col}\)。不需要换根都不太会的样子。事实上转化成序列就是求两段取数然后相等的方案数。看了眼题解,怎么又是人类智慧容斥啊。我们令 \(f(l_1,r_1,l_2,r_2)\) 表示两段区间是 \([l_1,r_1]\)\([l_2,r_2]\) 的答案。那就有:

\[f(l_1,r_1,l_2,r_2)=f(1,r_1,1,r_2)-f(1,r_1,1,l_1-1)-f(1,r_2,1,l_2-1)+f(1,l_1-1,1,l_2-1) \]

仔细一想这个容斥好像挺套路的/qd。然后我们令 \(g(l,r)=f(1,l,1,r)\),那么就有:

\[f(l_1,r_1,l_2,r_2)=g(r_1,r_2)-g(r_2,l_1-1)-g(r_1,l_2-1)+g(l_1-1,l_2-1) \]

\(g(l,r)\) 就不难用莫队维护了。

然后你考虑换根,事实上这个换根是假的,如果换的根是在 \(x\) 的子树内,那么 \(x\) 的「子树」就变成去除原来的子树中根所在的子树剩下的部分。如果换的根就是 \(x\),那 \(x\) 的「子树」就是整棵树了。否则 \(x\) 的子树还是原来的子树。这样一来,任意一段询问可以被分成最多 \(2\) 份,然后上面容斥最多拆成 \(4\) 段,所以每一次询问最多拆成 \(8\) 个区间,然后一次询问两个点,所以总共是 \(16\) 个区间,这是好的(笑)。接下来就是裸的莫队了,看题解里说,要卡常,块长要设 \(\dfrac{n}{\sqrt n}\),以及用奇偶优化。

这真是可悲的啊,写挂了 114514 次了/ll。一定一定要注意是读入的编号还是树上的 \(dfn\)!!!
(感谢 \(\texttt{r}\color{red}{\texttt{walxfhg}}\) 的帖子,我犯了同样的错误并捏捏 cmll)

并:这题有个非树上的弱化版,放在 Tasks 里了。

Summary

实际上主要考察的还是码力和细节,以及其它的莫队。但是第二道例题拆贡献的思想是非常重要的。

莫队二次离线

我们考虑到普通莫队在移动端点的时候,需要保证复杂度是 \(O(1)\),这样才能保证整个莫队复杂度是 \(O(n\sqrt n)\)(这里默认 \(n,q\) 同阶),但是有的时候,无论是插入还是删除都很难做到 \(O(1)\),这时候,我们大力观察题目性质,如果说一个数 \(x\) 对区间 \([l,r]\) 的贡献是 \(f(x,l,r)\),并且这个东西是满足 \(f(x,l,r)=f(x,1,r)-f(x,1,l-1)\)(就是说可以差分)的,那么我们就可以使用莫队二次离线。

所谓二次离线,顾名思义就是离线两次。莫队本身就是一离线算法,在离线询问的基础上,我们把每一段区间的答案也预处理出来,这样整体复杂度就 \(O(n\sqrt nk)\to O(n\sqrt n+nk)\),其中 \(k\) 是移动端点的复杂度。

那具体是怎么做的呢?事实上我们已经做过相似的工作了——拆贡献。我们来康康移动端点的过程,比如说,我们把区间从 \([l,r]\) 移动成 \([l,r+x]\)。那我们要在答案中加上:

\[\forall p\in [r+1,r+x],f(p,l,p-1) \]

然后我们的贡献是可以差分的:

\[f(p,l,p-1)=f(p,1,p-1)-f(p,1,l-1) \]

\(f(a,1,b)=g(a,b)\),则:

\[f(p,l,p-1)=g(p,p-1)-g(p,l-1) \]

就变成了前缀的贡献了,貌似可以预处理了。然后这部分处理得最好就是,你对于每个询问,只存储询问需要的 \(g(l,r)\),那总量也有 \(n\sqrt n\) 个,也就是空间复杂度是 \(O(n\sqrt n)\) 的,显然还不够优秀。

但其实进一步的优化也非常简单,你发现 \(g(p,p-1)\) 的数量是 \(O(n)\) 级别的。然后对于另一部分,总的答案实际上是:

\[-\sum_{p=r+1}^{r+x} g(p,l-1) \]

这东西可以归纳成一个五元组:\((l,r,a,fl,id)\) 表示区间 \([l,r]\) 内的每个数对区间 \([1,a]\) 的答案和乘上 \(fl\)(表示正负)对第 \(id\) 个询问的贡献。然后这里是若干个数对同一段区间的贡献,我们把它记在 \(a\) 处,用一个 vector 记录一下。离线完之后暴力扫一遍算这个东西就可以了。然后你考虑存下来的询问中长度和就是莫队的复杂度 \(O(n\sqrt n)\),所以是可以直接暴力算的。

最后注意一下,每次答案算出来的是前一次处理询问的增量,所以需要求一下前缀和再输出。
以及,这里分析是 \(r\) 指针增大时的拆分过程,剩下的 \(3\) 种情况手推应该不难了。
哦对了,一般来说题目中 \(g(p,p-1)=g(p,p)\),如果不一样,预处理的时候要分开。

例题

前方大量 lxl。

就是模板,然后具体做法就是你考虑 \(a\operatorname{xor} b=c\) 等价于 \(a=b\operatorname{xor}c\),然后你用一个 \(cnt_i\) 记录与 \(i\) 异或之后有 \(k\)\(1\) 的数的个数。然后你预处理出 \(popcount=k\) 的所有数就好了。

My Code
const int MAXN=1e5+10;
int a[MAXN],cnt[MAXN],B;
struct Query{
	int l,r,id,ans;Query(){}
	Query(int _l,int _r,int _id){l=_l,r=_r;id=_id;}
	bool friend operator<(Query x,Query y){return x.l/B==y.l/B?x.r<y.r:x.l<y.l;}
}q[MAXN];
struct Info{
	int l,r,fl,id;Info(){}
	Info(int _l,int _r,int _fl,int _id){l=_l,r=_r,fl=_fl,id=_id;}
};vector<Info> Q[MAXN];
vector<int> pkq;
int pre[MAXN],ans[MAXN];
signed main()
{
	ios::sync_with_stdio(0);
	cin.tie(0);cout.tie(0);
	int n,m,k;
	cin>>n>>m>>k;
	rep(i,1,n) cin>>a[i];
	rep(i,0,16383) if(__builtin_popcount(i)==k) pkq.pb(i);
	rep(i,1,m) cin>>q[i].l>>q[i].r,q[i].id=i;
	rep(i,1,n){
		pre[i]=cnt[a[i]];
		for(int s:pkq) cnt[a[i]^s]++;
	}memset(cnt,0,sizeof(cnt));
	B=sqrt(n);
	sort(q+1,q+1+m);
	int l=1,r=0;
	rep(i,1,m){
		if(r<q[i].r) Q[l-1].pb(Info(r+1,q[i].r,-1,i));
		while(r<q[i].r) q[i].ans+=pre[++r];
		if(l>q[i].l) Q[r].pb(Info(q[i].l,l-1,1,i));
		while(l>q[i].l) q[i].ans-=pre[--l];
		if(r>q[i].r) Q[l-1].pb(Info(q[i].r+1,r,1,i));
		while(r>q[i].r) q[i].ans-=pre[r--];
		if(l<q[i].l) Q[r].pb(Info(l,q[i].l-1,-1,i));
		while(l<q[i].l) q[i].ans+=pre[l++];
	}
	rep(i,1,n){
		for(int s:pkq) cnt[a[i]^s]++;
		for(auto qr:Q[i]){
			rep(j,qr.l,qr.r){
				int tmp=cnt[a[j]]-(k==0&&j<=i);//k==0 要特判/qd
				q[qr.id].ans+=qr.fl*tmp;
			}
		}
	}
	rep(i,2,m) q[i].ans+=q[i-1].ans;
	rep(i,1,m) ans[q[i].id]=q[i].ans;
	rep(i,1,m) cout<<ans[i]<<'\n';
	return 0;
}

求区间逆序对,同样我们拆出贡献之后,对于 \(g(p,p)\) 只要前缀树状数组处理一下就可以了。然后对于一个区间内的数对一段前缀的贡献和,二次离线,然后你之后需要一个 \(O(1)\) 查询的数据结构,然后不会了,看题解。。。怎么又是值域分块/qd完全不会分块怎么办。用来代替树状数组,具体地:
\(\texttt{A}\color{red}{\texttt{xDea}}\) 说,你分块维护前缀和不就好了。
然后我听从神的指令~/youl然后这题有个特别恶心的地方就是,你逆序对是有位置关系的,所以你查询的时候要查询下标比它小但是值比它大以及下标比它大但是值比它小的所有答案和。所以你需要码量乘二。

要奇偶排序。(以后能用奇偶排序就一定要用,虽然理论无用但是实际上快到飞起)

Summary

这一科技的运用还是比较牛逼的,对于不同的题目,需要重新分析每一个端点移动对答案的影响,然后考虑存储与计算。

莫队应用

当然说来说去,最重要的还是能不能用出来。下面来几道题,运用一下吧/xyx。

一些常见的应用

做题记录

题意是给你一个初始的二分图,然后每次询问是从右部图中截取一段,然后规定:若 \(A_i+C_j\le Z\),那么 \(i,j\) 之间有边。然后让你求最大匹配数。
首先不难想到,对左边先排个序,那右边的连边一定是连向左边的一段前缀的。然后我们考虑能不能通过一个一个加入点来得到答案,如果像匈牙利那样增广的话显然复杂度是不行的。这时候我们要利用好向前缀连边的性质,换句话说,每个点能向那几个点连边我是能简单地用一个前缀长度来表示的。然后我们贪心地考虑,能力越大责任越大的贪心显然是成立的,所以每个右部点一定是连向左边能连的最大的点,这个东西可以预处理出来。
然后我们令 \(f_i\) 表示 \([1,i]\) 中最后一个没有被连的点,那每次对一个位置加一的时候,我们就找到它的 \(f_i\),然后判断答案是否加一就可以了。然后删除的话滚一下就可以了。

Update 2022.4.9:

你注意到这个废物的复杂度是错的!但是题目没有卡,这题的并查集需要用启发式合并才行。或者就按照神 zzc 的题解来,用平衡树维护。

做题记录

给出一个序列,求最长的一个子区间使得区间中的数是一个排列。
看上去没什么思路的样子。这题也并不是莫队啊。那你考虑如果让你判断区间是不是排列你会怎么干,假如说我是一个一个加入的,那一边加我们一边记录一下有没有数出现超过一次以及当前序列中的最大值是多少。这样如果每个数出现都不超过一次并且最大值就是当前的 \(r-l+1\),那么当前区间中就是一个排列。然后如果你暴力跑的话,左右指针移来移去可能会 \(O(n^2)\)——吗?点开题解,发现是分析复杂度题。我们开一个数组记录每个数出现的次数以及区间和(最大值难以撤销),那么当且仅当每个数出现次数为 \(1\),并且区间和为 \(\dfrac{(r-l+1)(r-l+2)}{2}\) 时这个区间是排列。然后每个排列中必然是含有 \(1\) 的,那我们就从每个 \(1\) 开始向后扫,然后每次左指针回退到 \(r-max+1\),其中 \(max\) 是右端点扫到的当前最值。然后由于右指针是每次递增的,然后左指针就像莫队一样跳到符合长度的位置去就可以了,最多撤销 \(O(n)\),这样就保证复杂度是 \(O(n)\) 了。

做题记录

这才是莫队的样子……给出若干个询问,然后查询区间内只出现一次的数。那你掏出一个数据结构来维护一下,想了一下,感觉线段树什么的 \(\log\) 数据结构不太好搞,那就分块乱搞。你在值域上分块,然后每一块维护出现次数最小值,这样删除是……嗯好像是 \(O(\sqrt n)\) 的,所以大概要滚一下。实在是不想写这么麻烦,于是看了题解。惊为天人,第一次看见这么用栈。用一个栈维护一下出现次数为 \(1\) 的数,然后加入就直接堆上去就可以了,删除,就把栈顶元素塞到删去的元素的位置。可以直接用普通莫队做。
/ll,不会卡常怎么办……官方题解块长直接设为 \(800\) 然后过了/qd。

做题记录

这题和我之前做过有一题有点像,字符能重排变成回文的当且仅当每个字符出现次数最多有一个字符是奇数次。考虑字符集是 \(26\),而 \(2^{26}=67108864\),空间好像恰好够。所以想到状压,每次加入一个字符相当于是异或上一个 \(2\) 的次幂。然后考虑区间数合法子区间套路就是每个点加入时对区间产生的贡献。我们预处理一下异或前缀和,那区间 \([l,r]\) 合法的条件就是 \(\operatorname{popcount}(pre_r\operatorname{xor} pre_{l-1})\le 1\),也就是区间查询两个数异或后有小于 \(1\)\(1\) 的数对数。这样每次加入一个数是 \(O(26)\) 的,大概率会寄。于是我们考虑二次离线,记 \(f(x,l,r)\) 表示加入 \(x\) 对于区间 \([l,r]\) 的贡献,然后这东西可以差分,直接二离就好了。

看题解说,\(O(26n\sqrt n)\) 是可以过的啊/qd。那就暴力吧。

做题记录$\color{red}{\texttt{Unsolved}}$

比较牛逼的莫队和其他数据结构的结合,用到了 ddp 的思想。排序和去重两件事,我们可以用一个计数器来桶排,最困难的地方就在于乘上一个斐波那契数列。通常来讲,看到斐波那契,要么弃疗要么想到用矩阵。这样一来,我们在一棵权值线段树上维护每个位置是斐波那契的第几项,然后事实上我们用一个矩阵来表示(因为矩阵可以表递推)。然后在莫队的时候如果加入一个数,就在这个数及之后乘上一个矩阵,这样每个位置直接算出答案加和就可以了。这样直接做复杂度是 \(O(8n\sqrt n\log n)\) 的,能过,但是可以二次离线做到 \(O(n\sqrt n)\)(谁写啊)。

经典运用——莫队+bitset

这种运用好像例子挺多的,所以单独拿出来具体学学。

例题

注意是个数和,所以就是三个区间长度减去三个区间内重复的数的总数。

妙啊,直接用 bitset 只能维护颜色种类数,于是我们考虑让相同的数颜色不同。但是对于不同区间,出现的第一次又要是一样的,所以我们在莫队的时候对于颜色 \(i\),是在 bitset 上的第 \(i+cnt_i-1\) 位标记的。然后你求三个区间的交集大小就可以了。然后你把询问分成若干份做就可以了。

My Code
const int MAXN=1e5+10;
bitset<MAXN> bst[MAXN>>2],cur;
int B,ans[MAXN];
struct Query{
	int l,r,id;bool friend operator<(Query a,Query b)
	{return a.l/B==b.l/B?((a.l/B)&1?a.r<b.r:a.r>b.r):a.l<b.l;}
};
struct LocalQ{
	int l1,r1,l2,r2,l3,r3;
	void input(int i){
		cin>>l1>>r1>>l2>>r2>>l3>>r3;
		ans[i]=r1-l1+r2-l2+r3-l3+3;
	}
}qry[MAXN];
int a[MAXN],lsh[MAXN],n,cnt[MAXN];
void ins(int x){
	cnt[a[x]]++;
	cur[a[x]+cnt[a[x]]-1]=1;
}
void del(int x){
	cur[a[x]+cnt[a[x]]-1]=0;
	cnt[a[x]]--;
}
void solve(int ql,int qr){
	if(ql>qr) return;
	memset(cnt,0,sizeof(cnt));
	vector<Query> q;
	rep(i,ql,qr){
		q.pb(Query{qry[i].l1,qry[i].r1,i});
		q.pb(Query{qry[i].l2,qry[i].r2,i});
		q.pb(Query{qry[i].l3,qry[i].r3,i});
	}B=n/sqrt(q.size());
	sort(q.begin(),q.end());
	cur.reset();
	rep(i,0,qr-ql+1) bst[i].set();
	int l=1,r=0;
	for(auto s:q){
		while(l>s.l) ins(--l);
		while(r<s.r) ins(++r);
		while(l<s.l) del(l++);
		while(r>s.r) del(r--);
		bst[s.id-ql]&=cur;
	}
	rep(i,ql,qr) ans[i]-=3*bst[i-ql].count();
}
int main()
{
	ios::sync_with_stdio(0);
	cin.tie(0);cout.tie(0);
	int m;cin>>n>>m;
	rep(i,1,n) cin>>a[i],lsh[i]=a[i];
	sort(lsh+1,lsh+1+n);
	rep(i,1,n) a[i]=lower_bound(lsh+1,lsh+1+n,a[i])-lsh;
	rep(i,1,m) qry[i].input(i);
	int dlt=(m>>2);
	solve(1,dlt);solve(dlt+1,dlt*2);
	solve(dlt*2+1,dlt*3);solve(dlt*3+1,m);
	rep(i,1,m) cout<<ans[i]<<'\n';
	return 0;
}

恐惧。询问区间是否有两数加减乘除结果是 \(x\)

一个一个考虑,询问减的话,你记录每个数的出现次数,以及每个数是否出现(bitset),然后每次查询一个数的时候就与上本身左移 \(x\) 位。然后加法本质相同,你考虑每次将 \(10^5-i\) 的位置标记为出现过,这样就询问 \(x\) 的时候,你直接用加法的 bitset 与上减法的左移 \(10^5-x\) 位。

然后考虑乘法,加入一个数的影响就是对于每个已经存在的数 \(a\)\(ax\) 可以作为一个积。然后就不会了,看题解。???乘法直接暴力枚举是对的?哦确实吧,就你无论什么数,它因子个数都是根号级别的,然后你直接枚举因子暴力判断就可以了。

到现在你已经可以通过 P3674 小清新人渣的本愿 了。

然后来考虑除法。根号分治,你考虑两个数相除等于一个给定的值,那如果这个给定的值是大于 \(\sqrt n\) 的,即 \(\dfrac{a}{b}>\sqrt n\),那么有 \(b<\dfrac{a}{\sqrt n}\le \dfrac{n}{\sqrt n}\le \sqrt n\),所以直接枚举 \(b\),然后看有没有 \(bx\) 出现,直接莫队复杂度是 \(O(\sqrt n)\) 的。然后如果 \(x<\sqrt n\),我们把这些询问全部存下来,然后对于每个值 \(x\) 都分别做一遍。你维护一个 \(lst_i\)\(ans_r\) 表示当前 \(i\) 的最后出现位置和最大的 \(l\) 使得 \([l,r]\) 内有两数商为 \(x\)。这样每个数扫一遍是 \(O(n)\) 的,所以总共是 \(O(n\sqrt n)\)

理解题意比较困难啊,就是每次询问一个区间和一个数 \(b\),然后要求最大的 \(x\),使得有 \(x\) 个数在区间中出现并且能构成一个等差数列,公差为 \(b\),并且第一项要是小于 \(b\) 的。然后我们套路地用 bitset 维护每个区间内出现的数。这样我们就把问题转化为,bitset 从低到高每 \(b\) 位划分成一段,问从低到高一次取,最多取几段使得每一段与起来的结果不为 \(0\)。于是你就得到了一个 \(O(n\sqrt n+\frac{10^5}{\omega})\) 的优秀复杂度,你企图用这个复杂度过这个题结果发现是挂的。为什么?当 \(b<\omega\) 的时候,你会发现 \(O(\frac{10^5}{b}\times \frac{b}{\omega})\)\(\frac{b}{omega}\)\(1\),而前面的又近似是 \(10^5\),然后你就寄了(就类似于那个用 bitset 循环让复杂度除以 \(\omega\) 的笑话)。

那怎么办呢?你考虑这个时候 \(b\) 的值比较小,最大只有 \(64\),你可以对于每个 \(b\) 预处理一下(这样的套路十分常见,是根号分治的扩展?)。然后我每次就在这种处理的时候卡住了/ll。看了题解,其实也是套路,遇到这种小于根号的情况就对每个值分开考虑。比如这题,你就直接对每个 \(b\) 单独处理。然后考虑暴力算的时候,每次询问求的东西实际上是 \(b\) 的每个剩余系中 \(\operatorname{mex}\) 的最大值,每个剩余系你暴力维护就可以了。这样对于每个询问我们都跑一次莫队,然后维护区间 \(b\) 的剩余系的 bitset,每次询问查找每个剩余系的 \(\operatorname{mex}\) 然后取最大值就好了。

关于取 \(\operatorname{mex}\) 的方法:对 bitset 进行一个 flip() 然后用一个 _Find_first() 就能找到第一个 \(0\) 的位置啦,减一就是当前的答案~(关于 _Find_first() 的复杂度

!你发现无法实现那个对 bitset 分成长度为 \(b\) 的若干段的操作。于是只能用手写 bitset/qd。

那就咕了吧/qd。\(\color{red}{\texttt{Unsolved}}\)

任务

一些看起来比较困难并且有可能会咕掉的题(绝对不咕)。当成作业/qd。大多数是缝合怪不太想写。

总结

  1. 关于莫队端点移动的问题,oiwiki 上有提及,但是实际上只要你让区间先增大然后减小就可以了,个人习惯:--l,++r,l++,r--
  2. 关于莫队做题的套路,你首先肯定得知道是 DS 题,然后可以离线。先考虑普通莫队,选择其它数据结构来维护当前答案,最低要求是 \(O(1)\) 修改,\(O(\sqrt n)\) 查询,然后不行再考虑 \(\log\) 能不能过,然后考虑能不能用 bitset。接下来如果都不好维护就看看回滚能不能滚掉一维来使得能用其它 DS 维护。最后再考虑能不能差分用二离。

后记

第一次这么认真地学大型 DS,有的时候一些基本的套路还是不够熟练,以及写代码挂在傻逼地方的情况也比较多。用了接近 \(4\) 天的时间把莫队相关的东西全部学了一遍,该写的毒瘤题也写了,改调的代码也调了,感觉我又彳亍了。

这篇博客写了我好久(主要是边做题边写),然后接下来打算把补 dp 的坑填一点之后,去学一下分块(发现根号数据结构实在是太差了)。所以——

\[\texttt{To Be Continued} \]

posted @ 2022-03-30 16:51  ZCETHAN  阅读(252)  评论(3编辑  收藏  举报