莫队算法
摘抄于https://blog.csdn.net/weixin_43914593/article/details/108485396
基础莫队算法
莫队算法=离线+暴力+分块
离线与在线的概念。在线是交互式的,一问一答;如果前面的答案用于后面的提问,称为“强制在线”。离线是非交互的,一次性读取所有问题,然后一起回答,"记录所有步,回头再做”。
基础的莫队算法是一种离线算法,它通常用于不修改只查询的一类区间问题,复杂度
O
(
n
n
)
O(n\sqrt{n})
O(nn) ,没有在线算法线段树或树状数组好,但是编码很简单。下面是一道莫队模板题。
HH项链 洛谷 1972
题目描述:给定一个数量,询问某个区间内不同的数有多少个。
输入:第一行一个正整数 n,表示数列长度。第二行n个正整数 ai。第三行一个整数m,表示HH 询问的个数。接下来 m 行,每行两个整数 L,R,表示询问的区间。
输出:输出m行,每行一个整数,依次表示询问对应的答案。
暴力法
可以用STL的unique函数去重,一次耗时
O
(
n
)
O(n)
O(n),m次查询耗时
O
(
m
n
)
O(mn)
O(mn)。或者自己编码,用扫描法遍历区间,计数。
查询一个区间有多少个不同的数
定义一个统计数组cnt[],cnt[x]表示区间内x的个数,定义两个指针表示当前区间,L,R。开始L=1,R=0。
给出一个区间分别将L与R指针一步一步移至区间的左右边界,当右指针指向了一个新值,那么这个值在统计数组对应的位置加一,如果对应位置从0变成了1,那么ans加一。同理左指针指向一个新值那么这个值对应的位置减一,如果对应位置变成了0,那么ans减一。
下面的例子是统计区间[3, 7]内有多少不同的数字,初始指针L=1,R=0。
图(1):L=1、R=0时,cnt[4]=0, cnt[7]=0, cnt[9]=0…答案ans=0。
图(2):L=2、R=0时,cnt[4]=-1, cnt[7]=0。
图(3):L=3、R=2时,cnt[4]=0, cnt[7]=0, cnt[9]=0…
图(4):L=3、R=3时,cnt[4]=0, cnt[7]=0, cnt[9]=1。出现了一个等于1的cnt[9],答案ans = 1。
图(5):L=3、R=7时,cnt[4]=1, cnt[7]=0, cnt[9]=1, cnt[6]=2, cnt[3]=1,…其中cnt[4], cnt[9], cnt[6], cnt[3]都出现过等于1的情况,所以答案ans = 4。
统计多个数组
从上面一次查询可以看出,m次查询只需要重复上述过程m次即可。
我们按字典序将区间按左边界排序,小的在前,对于左边界相同的区间按右边界排序,小的在前。
1)简单情况,区间交错,区间[x1, y1]、[x2, y2]的关系是x1 ≤ x2,y1 ≤ y2。例如下图中,查询两个区间[2, 6]、[4, 8]。
图(1)L、R停留在第1个区间上,得到了第1个区间的统计结果;图(2)L、R停留在第2个区间上,得到了第2个区间的结果。m次查询的m个区间,L、R指针只需要从左头到右(单向移动)扫描整个数组一次即可,总复杂度O(n)。
2)复杂情况,既有区间交错,又有区间包含。区间[x1, y1]、[x2, y2]的包含关系是指x1 ≤ x2,y1 ≥ y2。例如下图中,区间[2, 9]包含了区间[3, 5]。此时L从头到尾单向扫描,而R指针却需要来回往复扫描,每次扫描的复杂度是O(n)。m次查询的总复杂度是O(mn)。
区间查询问题的几何解释
洛谷P1972的区间查询问题,可以概况为这样一种离线的几何模型:
(1)m个询问对应m个区间,区间之间的转移,可以用L、R指针扫描,能以O(1)的复杂度从区间[L,R]移动到[L±1, R±1]。
(2)把一个区间[L, R]看成平面上的一个坐标点(x, y),L对应x,R对应y,那么区间的转移等同于平面上坐标点的转移,计算量等于坐标点之间的曼哈顿距离。注意,所有的坐标点(x, y)都满足x ≤ y,即所有的点都分布在上半平面上。
(3)完成m个询问,等于从原点出发,用直线连接这m个点,形成一条“Hamilton路径”,路径的长度就是计算量。若能找到一条最短的路径,计算量就最少。
Hamilton最短路径问题是NP难度的,没有多项式复杂度的解法。那么有没有一种较优的算法,能快速得到较好的结果呢?
暴力法是按照坐标点(x, y)的x值排序而生成的一条路径,它不是好算法。例如下图(1)的简单情况,暴力法的顺序是好的;但是图(2)的复杂情况,暴力法的路径是(0, 0)-(2, 9)-(3, 5),曼哈顿距离(2-0) + (9-0) + (3-2) + (9-5) = 16,不如另一条路径(0, 0)-(3, 5)-(2, 9),曼哈顿距离 = 13。
莫队算法
莫队算法只通过对排序简单的修改,就把暴力法的时间
O
(
n
m
)
O(nm)
O(nm)改进为了
O
(
n
n
)
O(n\sqrt n)
O(nn)。
(1)暴力法的排序:把查询的区间按左端点排序,如果左端点相同右端点。
(2)莫队算法的排序:把数组分块(分成
n
\sqrt n
n块),然后把查询的区间按左端点所在块的序号排序,如果左端点的块相同,再按右端点排序(注意不是右端点所在的块序)。
除了排序不一样,莫队与暴力一样。
那这个排序可以提高效率吗?下面从多种情况下莫队算法的复杂度。
(1)简单情况。区间交错,设区间
[
P
1
,
y
1
]
[P_1,y_1]
[P1,y1]、
[
P
2
,
y
2
]
[P_2,y_2]
[P2,y2]的关系是
P
1
<
P
2
,
y
1
≤
y
2
P_1<P_2,y_1\leq y_2
P1<P2,y1≤y2,其中P1、P2是左端点所在的块。L、R只需要从左到右扫描一次,m次查询的总复杂度是
O
(
n
)
O(n)
O(n)。
(2)复杂情况。区间包含,设两个区间查询
[
P
1
,
y
1
]
[P_1,y_1]
[P1,y1]、
[
P
2
,
y
2
]
[P_2,y_2]
[P2,y2]的关系是
P
1
=
P
2
P_1=P_2
P1=P2,
y
2
≤
y
1
y_2\leq y_1
y2≤y1如下图所示。
此时小区间
[
P
2
,
y
2
]
[P_2,y_2]
[P2,y2]排在大区间
[
P
1
,
y
1
]
[P_1,y_1]
[P1,y1]的前面,与暴力法正好相反。在区间内,右指针R从左到右单向移动,不在往复移动。而左指针L发生了回退移动,但是被限制在了一个大小
n
\sqrt n
n的块内,每次移动的复杂度是
O
(
n
)
O(\sqrt n)
O(n)的。m次查询总复杂度就是
O
(
m
n
)
O(m\sqrt n)
O(mn)。
(3)特殊情况:m个查询,端点都在不同的块上,此时莫队算法和暴力法是一样的。但此情况下
m
≤
n
m\leq \sqrt n
m≤n,总复杂度
O
(
m
n
)
=
O
(
n
n
)
O(mn)=O(n\sqrt n)
O(mn)=O(nn)。
莫队算法的几何解释
莫队算法的见图6,这张图透彻说明可莫队算法的原理。图中的每个黑点是一个查询。
图6(1)是暴力法排序后的路径,所有的点按x坐标排序,在复杂情况下,路径沿y方向来回往复,震荡幅度可能非常大(纵向震荡,幅度
O
(
n
)
O(n)
O(n)),导致路径很长。
图6(2)是莫队算法排序后的路径,它把x轴分成多个区(分块),每个区内的点按y坐标排序,在区内沿x方向来回往复,此时震荡幅度被限制在区内(横向震荡,幅度
O
(
n
)
O(\sqrt{n})
O(n),形成了一条比较短的路径,从而实现了较好的复杂度。
通过图6(2)可以更清晰地计算莫队算法的复杂度:
(1)x方向的复杂度。在一个区块内,沿着x方向一次移动最多
n
\sqrt{n}
n,所有区块共有m次移动,总复杂度
O
(
m
n
)
O(m\sqrt{n})
O(mn)。
(2)y方向的复杂度。在每个区块内,沿着y方向单向移动,整个区块的y方向长度是n,有
n
\sqrt{n}
n 个区块,总复杂度
O
(
n
n
)
O(n\sqrt{n})
O(nn)。
两者相加,总复杂度
O
(
m
n
+
n
n
)
O(m\sqrt{n}+n\sqrt{n})
O(mn+nn) ,一般情况下题目会给出
n
=
m
n = m
n=m。
根据图6总结出莫队算法的核心思想:把暴力法的y方向的O(n) 幅度的震荡,改为x方向的受限于区间的
O
(
n
)
O(\sqrt n)
O(n)幅度的震荡,从而减少了路径的长度,提高了效率。
前面曾提到排序问题,对区间排序是先按左端点所在块排序,再按右端点排序,不是按右端点所在的块排序。原因解释如下:如果右端点也按块排序,几何图就需要画成一个方格图,方格中的点无法排序,实际的结果就是乱序。那么同一个方格内的点,在y方向上就不再是一直往上的复杂度为
O
(
n
)
O(n)
O(n)的单向移动,而是忽上忽下的往复移动,导致路径更长,复杂度变差。见图7所演示的路径。
编码时,还可以对排序做一个小优化:奇偶性排序,让奇数块和偶数块的排序相反。例如左端点L都在奇数块,则对R从大到小排序;若L在偶数块,则对R从小到大排序(反过来也可以:奇数块从小到大,偶数块从大到小)。
这个小优化对照图6(2)很容易理解,图中路径在两个区块之间移动时,是从左边区块的最大y值点移动到右边区块的最小y值点,跨度很大。用奇偶性排序后,奇数块的点从最大y值到最小y值点排序,偶数块从最小y值点到最大y值点排序;那么奇数块最后遍历的点是最小y值点,然后右移到偶数块的最小y值点,这样移动的距离是最小的。从偶数块右移到奇数块的情况类似。
下面是洛谷P1972的代码。莫队算法和暴力法唯一不同的地方在比较函数cmp()中。
#include<bits/stdc++.h>
using namespace std;
const int maxn = 1e6;
struct node{ //离线记录查询操作
int L, R, k; //k:查询操作的原始顺序
}q[maxn];
int pos[maxn];
int ans[maxn];
int cnt[maxn]; //cnt[i]: 统计数字i出现了多少次
int a[maxn];
bool cmp(node a, node b){
//按块排序,就是莫队算法:
if(pos[a.L] != pos[b.L]) //按L所在的块排序,如果块相等,再按R排序
return pos[a.L] < pos[b.L];
if(pos[a.L] & 1) return a.R > b.R; //奇偶性优化,如果删除这一句,性能差一点
return a.R < b.R;
/*如果不按块排序,而是直接L、R排序,就是普通暴力法:
if(a.L==b.L) return a.R < b.R;
return a.L < b.L; */
}
int ANS = 0;
void add(int x){ //扩大区间时(L左移或R右移),增加数x出现的次数
cnt[a[x]]++;
if(cnt[a[x]]==1) ANS++; //这个元素第1次出现
}
void del(int x){ //缩小区间时(L右移或R左移),减少数x出现的次数
cnt[a[x]]--;
if(cnt[a[x]]==0) ANS--; //这个元素消失了
}
int main(){
int n; scanf("%d",&n);
int block = sqrt(n); //每块的大小
for(int i=1;i<=n;i++){
scanf("%d",&a[i]); //读第i个元素
pos[i]=(i-1)/block + 1; //第i个元素所在的块
}
int m; scanf("%d",&m);
for(int i=1;i<=m;i++){ //读取所有m个查询,离线处理
scanf("%d%d",&q[i].L, &q[i].R);
q[i].k = i; //记录查询的原始顺序
}
sort(q+1, q+1+m, cmp); //对所有查询排序
int L=1, R=0; //左右指针的初始值。思考为什么?
for(int i=1;i<=m;i++){
while(L<q[i].L) del(L++); //{del(L); L++;} //缩小区间:L右移
while(R>q[i].R) del(R--); //{del(R); R--;} //缩小区间:R左移
while(L>q[i].L) add(--L); //{L--; add(L);} //扩大区间:L左移
while(R<q[i].R) add(++R); //{R++; add(R);} //扩大区间:R右移
ans[q[i].k] = ANS;
}
for(int i=1;i<=m;i++) printf("%d\n",ans[i]); //按原顺序打印结果
return 0;
}