带根号复杂度数据结构(一)

题目来源

讲解之前先放题单 BF的数据结构题单-省选根号数据结构


基础莫队

莫队算法的核心是暴力求解,只不过附带了很多技巧,使得算法的复杂度在处理数据规模1e5左右的问题时及其可观。以一个简单的问题为例:给出了一个长度为 n n n 的序列,并附带 m m m 次区间询问,求出区间内所有数字出现次数的平方和。

最简单的暴力思路即第一个询问老老实实的从区间的左端点到右端点统计区间内一边统计出现次数,一边用平方和公式 n 2 − ( n − 1 ) 2 = 2 n − 1 n^2-(n-1)^2 = 2n - 1 n2(n1)2=2n1 累计求解。之后的每次询问,只要不断在上一个询问的基础上移动区间的左端点和右端点。每次询问的复杂度为 O ( n ) O(n) O(n),一共 n n n次询问,总时间复杂度为 O ( n 2 ) O(n^2) O(n2),对于5e4的数据量绝对会超时。

莫队则只是对上一种解法稍作修改。首先把长度为 n n n 的序列分成 n \sqrt{n} n 块,每一块的长度也就是 n \sqrt{n} n ,然后按照如下规则对询问的进行排序:第一关键字为区间左端点所在的块,按从小到大;第二关键字为区间右端点,按从小到大; 这一操作的时间复杂度即排序的复杂度 O ( n l o g n ) O(nlogn) O(nlogn)。之后就按照这个新的询问顺序执行上一种暴力的思路,结果时间复杂度就是 O ( n n ) O(n\sqrt{n}) O(nn )。关于如何理解,可以从排序后询问的分布研究。左端点在同一块内的询问,其右端点是单调增的,所以右端点始终保持向右移动。对于左端点在同一块内的询问,它们的右端点移动总共是 O ( n ) O(n) O(n),总共有 n \sqrt{n} n 块,因此,右端点移动总的时间复杂度为 O ( n n ) O(n\sqrt{n}) O(nn )。在看左端点,同一块内左端点的移动的长度必定小于 n \sqrt{n} n ,而相邻块内的移动充其量就是 2 n 2\sqrt{n} 2n ,平均来说,相当于每次询问,都需要把左端点移动 n \sqrt{n} n 个位置,一共 n n n 个询问,即左端点移动总的时间复杂度为 O ( n n ) O(n\sqrt{n}) O(nn )。整合排序、左右端点移动,总体时间复杂度在 O ( n n ) O(n\sqrt{n}) O(nn ) 级别。


P4462 [CQOI2018]异或序列

——问题描述——
见原题
——解题思路——
本题是典型的可以使用莫队解决的数据结构题。首先求出序列的前缀异或,把这个序列称为 s u m sum sum,当我们想知道哪段区间的异或为 k k k 时,实际上就是求解一对 l l l r r r 满足, s u m l − 1 ⊕ s u m r = k sum_{l-1} \oplus sum_{r}=k suml1sumr=k,根据异或的性质,也就是 s u m l − 1 ⊕ k = s u m r sum_{l-1} \oplus k=sum_{r} suml1k=sumr 或者 s u m r ⊕ k = s u m l − 1 sum_{r} \oplus k=sum_{l-1} sumrk=suml1。因此,我们只需要一个数组用于记录异或为 i i i 的区间个数。在之后排完序的询问中,每次区间拓展到一个新的边界,就累计一次异或值为 s u m 边 界 ⊕ k sum_{边界} \oplus k sumk 的区间个数。
这里提一个莫队的优化技巧,在对询问排序时,可以再附加一个条件,即:如果左端点所在块为奇数,则右端点按从小到大排序;若为偶数,则右端点从大到小排。这样的好处就是,当询问的左端点从一个块到另一个块时,右端点可以先移动到就近的点,而不是一次性回退到块中最小右端点的位置,理论上可以节省时间开销。
——代码——

#include <iostream>
#include <cstdio>
#include <algorithm>
#include <cstring>
#include <cmath>
using namespace std;

const int MAXN = 1e5+5;
int n;
int m;
int k;
int ans[MAXN];
int sum[MAXN];
int cnt[4*MAXN]; 
struct query {
	int id;
	int l;
	int r;
	int pos;
} q[MAXN];

bool cmp(query q1, query q2) {
	return q1.pos==q2.pos ? (q1.pos&1 ? q1.r<q2.r : q1.r>q2.r) : q1.pos<q2.pos;
}
int main() {
	scanf("%d %d %d", &n, &m, &k);
	for(int i=1;i<=n;++i) {
		int in;
		scanf("%d", &in);
		sum[i] = sum[i-1]^in;
	}
	int siz = (int)sqrt((double)n);
	for(int i=1;i<=m;++i) {
		scanf("%d %d", &q[i].l, &q[i].r);
		q[i].id = i;
		--q[i].l;
		q[i].pos = (q[i].l - 1) / siz + 1;
	}
	sort(q+1,q+n+1,cmp); 
	int left = 1;
	int right = 0;
	int now = 0;
	for(int i=1;i<=m;++i) {
		while(right<q[i].r) {
			++right;
			++cnt[sum[right]];
			now += cnt[sum[right]^k];  
		}
		while(right>q[i].r) {
			now -= cnt[sum[right]^k];
			--cnt[sum[right]];
			--right;
		}
		while(left<q[i].l) {
			now -= cnt[sum[left]^k];
			--cnt[sum[left]];
			++left;
		}
		while(left>q[i].l) {
			--left;
			++cnt[sum[left]];
			now += cnt[sum[left]^k];
		}
		ans[q[i].id] = now;
	}
	for(int i=1;i<=m;++i) {
		printf("%d\n", ans[i]);
	} 
	return 0;
}



带修莫队

顾名思义,带修莫队即支持修改操作的莫对算法。一般题目包括中的描述会包含对区间中某一个点值的更变。比如:给出一个长度为 n n n 的序列,有 m m m 个操作,操作包括两种,一种是把对应位置的数字改成其他数字,一种是区间 [ l , r ] [l,r] [l,r]的查询。

带修莫队在基础莫队上增加了时间轴,每一次修改都记为一个新的时间点,询问的排序方式:第一关键字为左端点的块,按从小到大;第二关键字为右端点的块,按从小到大;第三关键字为修改时间,同样按从小到大。 一般来说,该类问题块的大小倾向于取 n 2 3 n^{\frac{2}{3}} n32。对于从一个询问转移到新的询问,不仅需要考虑左右端点的移动,还要照顾时间的流转。如果当前的询问的时间点小于上个询问的时间点,则需要按照时间顺序逆推把序列还原。时间复杂度的证明和基础莫队大同小异,为 O ( n 5 3 ) O(n^{\frac{5}{3}}) O(n35)


P1903 [国家集训队]数颜色 / 维护队列

——问题描述——
给出一个序列,有两种操作:
1.询问区间 [ l , r ] [l,r] [l,r]中,不同数字的个数;
2.把下表为 x x x 的数字改成其他数字。
——解题思路——
建立一个数组,维护区间中每个数字出现的次数。当询问区间扩展时,如果当前位置的数字在之前的累计次数为0,则答案数加1;当区间缩小时,如果当前位置的数字在之前的累计次数为1,则答案数数加1。左右端点移动完,再考虑时间线移动,把时间从过去或者未来推到当前时间,每次考虑修改的位置是否在询问区间内,如果是,则对答案数做出类似区间扩展或缩小时的修改。
——代码——

#include <iostream>
#include <cstdio>
#include <algorithm>
#include <cmath>
using namespace std;

const int MAXN = 1e6 + 6;
int n;
int m;
int cnt[MAXN];
int a[MAXN];
int ans[MAXN];
int cntq;
int cntr;
struct query {
	int id;
	int l;
	int r;
	int posl;
	int posr;
	int t;
} q[MAXN];
struct rswap {
	int pre;
	int x;
	int col;
} r[MAXN];

bool cmp(query q1, query q2) {
	return q1.posl!=q2.posl ? q1.posl<q2.posl : q1.posr!=q2.posr ? q1.posr<q2.posr : q1.t<q2.t;
}
int main() {
	scanf("%d %d", &n, &m);
	for(int i=1;i<=n;++i) {
		scanf("%d", &a[i]);
	}
	int kuai = pow((double)n, (double)2/3);
	for(int i=1;i<=m;++i) {
		char c[2];
		scanf(" %s", c);
		if (c[0]=='Q') {
			++cntq;
			q[cntq].id = cntq;
			scanf("%d %d", &q[cntq].l, &q[cntq].r);
			q[cntq].posl = q[cntq].l / kuai;
			q[cntq].posr = q[cntq].r / kuai;
			q[cntq].t = cntr;  	
		}
		else {
			++cntr;
			scanf("%d %d", &r[cntr].x, &r[cntr].col);
		}
	}
	sort(q+1,q+cntq+1,cmp);
	int nowans = 0;
	int nowt = 0;
	int left = 1;
	int right = 0;
	for(int i=1;i<=cntq;++i) {
		while(right<q[i].r) {
			nowans += !cnt[a[++right]]++;
		}
		while(right>q[i].r) {
			nowans -= !--cnt[a[right--]];
		}
		while(left<q[i].l) {
			nowans -= !--cnt[a[left++]];
		}
		while(left>q[i].l) {
			nowans += !cnt[a[--left]]++;
		}
		while(nowt<q[i].t) {
			++nowt;
			r[nowt].pre = a[r[nowt].x];
			a[r[nowt].x] = r[nowt].col;
			if(left<=r[nowt].x && r[nowt].x<=right) {
				nowans += !cnt[a[r[nowt].x]]++;
				nowans -= !--cnt[r[nowt].pre];
			}
		}
		while(nowt>q[i].t) {
			a[r[nowt].x] = r[nowt].pre;
			if(left<=r[nowt].x && r[nowt].x<=right) {
				nowans += !cnt[a[r[nowt].x]]++;
				nowans -= !--cnt[r[nowt].col];
			}
			--nowt;
		}
		ans[q[i].id] = nowans;
	}
	for(int i=1;i<=cntq;++i) {
		printf("%d\n", ans[i]);
	}
	return 0;
}



回滚莫队

回滚莫队解决的问题一般沾一点“可持续化”的味道,区间两个端点的移动不仅仅是累计个数的问题,同时还要考虑去间扩展前记录的历史版本。比如:给出一个长度为 n n n 的序列,有 m m m 次询问,问题是区间 [ l , r ] [l,r] [l,r] 中相同数字相隔的最远距离。

试想,我们维护两个数组,一个记录区间中某个数字最靠左的位置,另一个记录最靠右的位置。如果原本区间内包含三个同样的数值 v a l val val,当区间缩小,只剩下两个 v a l val val 时,如何还能知道三个 v a l val val 的中间位置。很直观的想法就是用一个队列维护每个 v a l val val 的位置,一旦这么做,空间上开销又会增加许多。

善用莫队算法的分块思路,可以在使用较少的空间的同时,使时间复杂度仍然维持在 O ( n n ) O(n\sqrt{n}) O(nn ) 级别。询问排序和序列分块的规则与基础莫队相同。唯一不同之处在于区间的移动。如果区间的左右端点都在同一块中,则直接暴力扫描出答案,块的大小为 n \sqrt{n} n ,所以时间复杂度为 O ( n ) O(\sqrt{n}) O(n )。如果询问区间的左右端点在不同块中,对于左端点在同一个块的每一个询问我们都把左端点的位置定在块的右边界,让左端点不断的在右边界和询问的左端点间移动;右端点的移动则与基础莫队相同。我们先移动右端点,记录当前的答案(这个答案表示左端点在右边界时的情况),然后移动左端点到询问的目标位置,此时可以得出整个询问的答案。最后,需要把左端点回溯到块的有边界,同时抹去最右出现位置被左端点滚动过的数据。当前记录的最远距离也回溯到左端点向左扩展前的大小。如果当前询问左端点所在的块与上一个询问不同了,则重新设置左端点的起始位置到当前块的右边界,同时也要清空两个维护的数组。这一操作本质上只是左端点的来回滚动,实际时间复杂度还是 O ( n ) O(\sqrt{n}) O(n )。所以,总体上说回滚莫队与基础莫队的时间复杂度只是常数上的区别。


P5906 【模板】回滚莫队&不删除莫队

——题目描述——
见原题
——解题思路——
见上文
——代码——

#include <iostream>
#include <cstdio>
#include <algorithm>
#include <cstring>
#include <cmath>
#include <utility>
using namespace std;

typedef pair<int, int> pii;
const int MAXN = 2e5+5;
int n;
int m;
pii in[MAXN];
int history[MAXN];
int cntkuai;
int cntl[MAXN];
int cntr[MAXN];
int a[MAXN];
int ans[MAXN];
int bru[MAXN];
struct query {
	int id;
	int l;
	int r;
	int pos;
} q[MAXN];

bool cmp(query q1, query q2) {
	return q1.pos!=q2.pos ? q1.pos<q2.pos : q1.r<q2.r;
}
int main() {
	scanf("%d", &n);
	for(int i=1;i<=n;++i) {
		scanf("%d", &in[i].first);
		in[i].second = i;
	}
	sort(in+1, in+n+1);
	int prenum = 0;
	int newnum = 0;
	for(int i=1;i<=n;++i) {
		if (in[i].first!=prenum) {
			++newnum;
			prenum = in[i].first;
		}
		a[in[i].second] = newnum;
	}
	int kuai = sqrt((double)n);
	scanf("%d", &m);
	for(int i=1;i<=m;++i) {
		scanf("%d %d", &q[i].l, &q[i].r);
		q[i].id = i;
		q[i].pos = (q[i].l-1) / kuai + 1;
	}
	sort(q+1, q+m+1, cmp);
	int nowkuai = 0;
	int leftbase = 1;
	int left = 1;
	int right = 0;
	int nowans = 0;
	int clearptr = 0;
	for(int i=1;i<=m;++i) {
		if ((q[i].r-1)/kuai+1==q[i].pos) {
			for(int j=q[i].l;j<=q[i].r;++j) {
				bru[a[j]] = 0;
			}
			for(int j=q[i].l;j<=q[i].r;++j) {
				if (!bru[a[j]]) {
					bru[a[j]] = j;
				}
				else {
					ans[q[i].id] = max(ans[q[i].id], j-bru[a[j]]);
				}
			}
			continue;
		}
		if (nowkuai!=q[i].pos) {
			for(int j=1;j<=clearptr;++j) {
				cntr[history[j]] = 0;
				cntl[history[j]] = 0;
			}
			nowkuai = q[i].pos;
			leftbase = min(nowkuai*kuai, n);
			left = leftbase + 1;
			right = left - 1;
			nowans = 0;
			clearptr = 0;
		}
		while(right<q[i].r) {
			++right;
			cntr[a[right]] = right;
			if (!cntl[a[right]]) {
				cntl[a[right]] = right;
				history[++clearptr] = a[right];
			}
			nowans = max(nowans, right-cntl[a[right]]);
		}
		int sw = nowans;
		while(left>q[i].l) {
			--left;
			if (cntr[a[left]]) {
				nowans = max(nowans, cntr[a[left]]-left);
			}
			else {
				cntr[a[left]] = left;
			}
		}
		ans[q[i].id] = nowans;
		while(left<=leftbase) {
			if (cntr[a[left]]==left) {
				cntr[a[left]] = 0;
			}
			++left;
		}
		nowans = sw;
	}
	for(int i=1;i<=m;++i) {
		printf("%d\n", ans[i]);
	}
	return 0;
}
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值