前置知识:分块
分块是一种思想,把一个整体划分为若干个小块,对整块整体处理,零散块单独处理。本文主要介绍块状数组——利用分块思想处理区间问题的一种数据结构。
块状数组把一个长度为 n 的数组划分为 a 块,每块长度为 n/a 。对于一次区间操作,对区间內部的整块进行整体的操作,对区间边缘的零散块单独暴力处理。(所以分块被称为“优雅的暴力”)
这里,块数既不能太少也不能太多。如果太少,区间中整块的数量会很少,我们要花费大量时间处理零散块;如果太多,又会让块的长度太短,失去整体处理的意义。一般来说,我们取块数为 根号n,这样在最坏情况下,我们要处理接近 根号n个整块,还要对长度为 2*根号n 的零散块单独处理,总时间复杂度为 O(根号n)。这是一种根号算法。
显然,分块的时间复杂度比不上线段树和树状数组这些对数级算法。但由此换来的,是更高的灵活性。与线段树不同,块状数组并不要求所维护信息满足结合律,也不需要一层层地传递标记。但它们又有相似之处,线段树是一棵高度约为 的树,而块状数组则可被看成一棵高度为3的树:
只不过,块状数组最顶层的信息不用维护。
预处理
具体地使用块状数组,我们要先划定出每个块所占据的范围:
int sq = sqrt(n);
for (int i = 1; i <= sq; ++i)
{
st[i] = n / sq * (i - 1) + 1; // st[i]表示i号块的第一个元素的下标
ed[i] = n / sq * i; // ed[i]表示i号块的最后一个元素的下标
}
但是,数组的长度并不一定是一个完全平方数,所以这样下来很可能会漏掉一小块,我们把它们纳入最后一块中:
ed[sq] = n;
正如上面所述,sq就是每个块有几个数,然后我们根据这个算出总共有几块,举个例子,假如10个数字,那么sqrt处理后,得到的结果省略小数点后面就是3,但是如果每块3个,那么会多出来一个数字,我们在算块数的时候,因为根号算出的,所以块数是接近3的,但是我们需要4块,因为多出了一个数,我们这时候可以直接向上取整,假如有9个,则每块有3个,共3块,假如有十个,则每块还是三个,但是共四块,处理方法如下
ceil((double)n / size);
然后,我们为每个元素确定它所归属的块:
for (int i = 1; i <= sq; ++i)
for (int j = st[i]; j <= ed[i]; ++j)
bel[j] = i; // 表示j号元素归属于i块
最后,如果必要,我们再预处理每个块的大小:
for (int i = 1; i <= sq; ++i)
size[i] = ed[i] - st[i] + 1;
int read() {
int res = 0;
char c = getchar();
while(!isdigit(c)) c = getchar();
while(isdigit(c)) res = (res << 1) + (res << 3) + c - 48, c = getchar();
return res;
}
for (int i = 1; i <= n; ++i)
A[i] = read();//快读
for (int i = 1; i <= sq; ++i)
for (int j = st[i]; j <= ed[i]; ++j)
sum[i] += A[j];//保存每一块的和
区间修改
首先是区间修改,当x与y在同一块内时,直接暴力修改原数组和sum数组:
if (bel[x] == bel[y])
for (int i = x; i <= y; ++i)
{
A[i] += k;
sum[bel[i]] += k;
}
否则,先暴力修改左右两边的零散区间:
for (int i = x; i <= ed[bel[x]]; ++i)
{
A[i] += k;
sum[bel[i]] += k;
}
for (int i = st[bel[y]]; i <= y; ++i)
{
A[i] += k;
sum[bel[i]] += k;
}
然后对中间的整块打上标记:
for (int i = bel[x] + 1; i < bel[y]; ++i)
mark[i] += k;
暴力修改左右两边的零散区间
ed[bel[x]]表示x所在块的最后一个数字下标
st[bel[y]]表示y所在块的第一个数字下标
这样就把x,y所在的块通过每个数都加上k,并且块的总和也加上,数字加的k的总和,就是你每个数加了几个k,那块的总和也就加几个k,
然后中间打标记
bel[x]表示x所在块的下一个块,i < bel[y]表示y所在块的前一个块,
mark[i] += k;就表示把除了x,y所在块的所有块都标记这块所有数都加了k,到时候想要加回来就用k也就是mark[i]*(块内的元素个数)
区间查询不解释了
同样地,如果左右两边在同一块,直接暴力计算区间和。
if (bel[x] == bel[y])
for (int i = x; i <= y; ++i)
s += A[i] + mark[bel[i]]; // 注意要加上标记
否则,暴力计算零碎块:
for (int i = x; i <= ed[bel[x]]; ++i)
s += A[i] + mark[bel[i]];
for (int i = st[bel[y]]; i <= y; ++i)
s += A[i] + mark[bel[i]];
再处理整块:
for (int i = bel[x] + 1; i < bel[y]; ++i)
s += sum[i] + mark[i] * size[i]; // 注意标记要乘上块长
那么为什么在区间不在一个块内的就单独处理前后,就要单独的每个数都加呢?,因为不在一个块内,假如有1到9个数,分成三个块,假如是2,8,那么你就原本1到3是一个块,但是现在不包括1,所以你不能用sum进行区间修改,因为sum代表的是整个块,然后7,9是一个块,这里只有8,所以同理,那个查询更明确了,前后都不能用sum,中间可以,因为中间肯定是一个完整的块
再啰嗦一点,只有你在当时要求的范围包括l完完整整的那几个块区间,
那几个块区间才可以用sum,预先处理的结果来计算并更新,因为sum里面
存储的都是一整块,并不是分开的
正式知识:莫队算法
这里不讲理论,直接讲题和代码,把代码读懂,然后就可以发现不变和变的地方,将不变的地方抽象的理解每一步骤的目的,具体的思维,写在变的里面
看一下这道题SP3267 DQUERY - D-query
题目到手,我们开始分析本题的算法。这题最简单做法无非暴力——用一个cnt数组记录每个数值出现的次数,再暴力枚举l到r统计次数,最后再扫一遍cnt数组,统计cnt不为零的数值个数,输出答案即可。设最大数值为s,那么这样做的复杂度为O(m(n+s))∽O(n2),对于本题实在跑不起。
我们可以尝试优化一下:
优化1:每次枚举到一个数值num,增加出现次数时判断一下cntnum是否为0,如果为0,则这个数值之前没有出现过,现在出现了,数值数当然要+1。反之在从区间中删除num后也判断一下cntnum是否为0,如果为0数值总数-1。这样我们优化掉了一个O(ms),但还是跑不起。
优化2:我们弄两个指针 l 、r ,每次询问不直接枚举,而是移动 l 、r 指针到询问的区间,直到[l,r]与询问区间重合。在统计答案时,我们也只在两个指针处加减cnt,然后我们就可以用优化1中的方法快速地统计答案啦
优化2就是莫队算法的铺垫,莫队算法的基本思想就是用两个指针不断的移动
因为有n次区间询问,每次就从开始的位置一个一个的移动,就是++的移动
,移动一次,就处理当前的数,比如,我们要查2,5区间,现在两个指针在2,3区间,那右指针就要移动从3移动到5,所以一步一步处理,3移动到4,4这个位置上的数,更新一下aa[4]这个数的个数,用桶去记录cnt[aa[4]]++。为什么要更新,因为你区间把这个数字包括进去了,等从4走到5的时候,看看aa[5],并统计一下更新cnt数组,然后去看看这个数字是不是新出现的,如果是就更新当前结果now++,如果这个数组在挪动之前就统计了出现了一次,那么就不++,这种两个指针越来越大的是好处理的,有些区别的是范围在缩小的。比如查2,5但是目前在2,7,那你右指针是不是要从7挪动到5,所以7先挪动到6,处理之前先看看7这个位置上的数字是不是变成0了,变成0了,就说明这个数字出现次数为0了。就去改当前的结果now–。大概讲了一下思路,说核心代码
int aa[maxn], cnt[maxn], l = 1, r = 0, now = 0; //每个位置的数值、每个数值的计数器、左指针、右指针、当前统计结果(总数)
void add(int pos) {//添加一个数
if(!cnt[aa[pos]]) ++now;//在区间中新出现,总数要+1
++cnt[aa[pos]];
}
void del(int pos) {//删除一个数
--cnt[aa[pos]];
if(!cnt[aa[pos]]) --now;//在区间中不再出现,总数要-1
}
void work() {//优化2主过程
for(int i = 1; i <= q; ++i) {//对于每次询问
int ql, qr;
scanf("%d%d", &ql, &qr);//输入询问的区间
while(l < ql) del(l++);//如左指针在查询区间左方,左指针向右移直到与查询区间左端点重合
while(l > ql) add(--l);//如左指针在查询区间左端点右方,左指针左移
while(r < qr) add(++r);//右指针在查询区间右端点左方,右指针右移
while(r > qr) del(r--);//否则左移
printf("%d\n", now);//输出统计结果
}
}
执行步骤见下面链接的这篇博客的图片
莫队
然后你会发现,哪里是变得,add和del函数是变得,其他不变,但是到这核心思想还没有讲完,莫队为什么可以离线处理,因为他讲每一次的结果保存了下来,并且给这个结果加上一个id,也就是序号,让我们知道他第几次问的问题的结果是什么,那为什么加序号,如果优化2遇到这样的区间,如下图
此时l、r指针在整个序列中移来移去,从头到尾,又从尾到头。我们发现左右指针最坏情况下均移动了O(nm)次,O(1)更新答案,总时间复杂度仍然是O(nm),在最坏情况下跑得比慢的一批的优化1还慢。尽管如此,我们还是可以继续优化。怎么优化,这就跟序号有关了
莫队算法优化的核心是分块和排序。我们将大小为n的序列分为√n 个块,从1到√n 编号,然后根据这个对查询区间进行排序。一种方法是把查询区间按照左端点所在块的序号排个序,如果左端点所在块相同,再按右端点排序。排完序后我们再进行左右指针跳来跳去的操作,虽然看似没多大用,但带来的优化实际上极大。排序根据区间左右端点排,而保证他们的顺序则是上面的序号来保证的,如何带序号,结构体呗
结构体排序策略如下
int cmp(query a, query b) {
return belong[a.l] == belong[b.l] ? a.r < b.r : belong[a.l] < belong[b.l];
}
然后有一部分代码可以通过更换减少时间复杂度,下面有例题讲解并标识出减少时间复杂度的部分
#include <cstdio>
#include <cstring>
#include <cmath>
#include <algorithm>
using namespace std;
#define maxn 1010000
#define maxb 1010
int aa[maxn], cnt[maxn], belong[maxn];
int n, m, size, bnum, now, ans[maxn];
struct query {
int l, r, id;
} q[maxn];
int cmp(query a, query b) {
//相对于上面的排序策略,这个快很多
return (belong[a.l] ^ belong[b.l]) ? belong[a.l] < belong[b.l] : ((belong[a.l] & 1) ? a.r < b.r : a.r > b.r);
}
#define isdigit(x) ((x) >= '0' && (x) <= '9')
int read() {
//优化IO输入,快读,上面讲过
int res = 0;
char c = getchar();
while(!isdigit(c)) c = getchar();
while(isdigit(c)) res = (res << 1) + (res << 3) + c - 48, c = getchar();
return res;
}
void printi(int x) {
//优化IO输出
if(x / 10) printi(x / 10);
putchar(x % 10 + '0');
}
int main() {
scanf("%d", &n);
size = sqrt(n);
bnum = ceil((double)n / size);
for(int i = 1; i <= bnum; ++i)
for(int j = (i - 1) * size + 1; j <= i * size; ++j) {
belong[j] = i;
}
for(int i = 1; i <= n; ++i) aa[i] = read();
m = read();
for(int 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 = 1, r = 0;
for(int i = 1; i <= m; ++i) {
int ql = q[i].l, qr = q[i].r;
while(l < ql) now -= !--cnt[aa[l++]];//1
while(l > ql) now += !cnt[aa[--l]]++;//2
while(r < qr) now += !cnt[aa[++r]]++;//3
while(r > qr) now -= !--cnt[aa[r--]];//4
ans[q[i].id] = now;
}
for(int i = 1; i <= m; ++i) printi(ans[i]), putchar('\n');
return 0;
}
可修改的地方标记在了1,2,3,4,可以仍然保持,del和add函数的写法,更明确