离线区间的神奇——莫队算法
前言
莫队算法是由莫涛提出的算法。在莫涛提出莫队算法之前,莫队算法已经在 Codeforces 的高手圈里小范围流传,但是莫涛是第一个对莫队算法进行详细归纳总结的人。
一、什么是莫队算法?
莫队算法是一种基于分块思想的优化暴力算法,莫队算法主要的应用是用于离线解决通常不带修改只有查询的一类区间问题。莫队算法分为普通莫队、树上莫队、带修莫队,今天主要分享普通莫队算法。
同时莫队算法处理的查询问题必须是离线问题,如果是强制在线则不能使用莫队算法。
什么是强制在线?即,上次查询作为下一次查询的条件或者内容,此时不能使用莫队算法。
二、例题分析:
1.相关例题
题目描述
HH 有一串由各种漂亮的贝壳组成的项链。HH 相信不同的贝壳会带来好运,所以每次散步完后,他都会随意取出一段贝壳,思考它们所表达的含义。HH 不断地收集新的贝壳,因此,他的项链变得越来越长。
有一天,他突然提出了一个问题:某一段贝壳中,包含了多少种不同的贝壳?这个问题很难回答…… 因为项链实在是太长了。于是,他只好求助睿智的你,来解决这个问题。
输入格式
一行一个正整数 n,表示项链长度。
第二行 n 个正整数 ai1-ai,表示项链中第 ii 个贝壳的种类。
第三行一个整数 m,表示 HH 询问的个数。
接下来 m 行,每行两个整数 l-r,表示询问的区间。
输出格式
输出 m 行,每行一个整数,依次表示询问对应的答案。
输入输出样例
简单分析:
该题大概意思是在一段给定一段序列,同时m个询问:特定区间内有多少不重复数的个数。这题最简单做法无非暴力——用一个cnt 数组记录每个数值出现的次数,再暴力枚举l到r统计次数,最后再扫描一边cnt数组,统计不为0的个数,输出答案即可。设数组数值最大值为s,那么暴力解法的时间复杂度为 O(m(n+s))~O(n2) ,1e12的数据量大概率会超时。莫队算法在该类问题上能够有效地优化时间复杂度至 O(n*sqrt(n)) !
三、莫队算法思想:
两个询问的l和r之间的跳转可能会产生很大的开销,如果我们能够有效的利用上一次的询问结果,来对当前的询问查找进行优化那对我们的整体的计算量能够有效的缩减。一般来说,如果可以在 O(1) 内从 [L , R] 的答案转移到 [L+1/-1 , R+1/-1] 这四个与之紧邻的区间的答案,则可以考虑使用莫队,这也是莫队算法的出发点。
那么要怎么做(预处理):莫队算法优化的核心是分块和排序
1.首先对整个序列进行分块,分块数为sprt(n)块;我们将大小为n的序列分为根号n个块,从1到根号n编号
2.对m个查询操作进行排序sort() ,以查询操作L所在的块 bel[L] 为第一关键词,R所在的块 bel[R] 为第二关键词对m个查询操作进行升序排序;排完序后我们再进行左右指针跳来跳去的操作,来实现查询操作**(相关细节优化奇偶化排序,进一步减少双指针的跳转)**。
莫队的精髓就在于,离线得到了一堆需要处理的区间后,合理的安排这些区间计算的次序以得到一个较优的复杂度。
举个例子:求给定区间内不重复数的个数
给输入序列长度为 n 9
序列:2 5 6 1 2 3 5 2 1 sqrt(9)==3 将序列分成 3块
给定查询区间:[1 , 4] [2 , 3] [4 , 7] [7 , 8]
对应所属分块bel[i] = [1 , 2]、[1 , 1]、[2 , 3]、[ 3 , 3]
那么对查询操作进行排序[2 , 3] [1 , 4] [4 , 7] [7 , 8]
时间复杂度简单分析
通过维护双指针(主要包括删除和增加的操作),对询问进行回答输出。
接下来我们对维护双指针的时间复杂度(约等于整体算法的时间复杂度)进行分析,对于询问的跳转(双指针在询问转换时的维护)通常有两种情况,一是不在块内、二是在块内。
首先如果不在块内(上例中查询2->3):对于L我们每次跳转距离最多是 sqrt(n) (分块数) , 对于R来说每次跳转最多距离为 n ,所以时间复杂度是nsqrt(n)。
二在块内的块内:同理分析可得时间复杂度为 nsqrt(n) ;
所以我们发现所有操作(查询操作快排+维护)在极限情况下的时间复杂度是O(nlogn)+2O(nsqrt(n)) 所以整体的算法时间复杂度也是O(n*sqrt(n)) ;
莫队算法JAVA代码实现
根据开头例子洛谷 P1972题,给出JAVA代码
import java.util.*;
public class Main{
final static int maxn = 1010000;
static int[] aa=new int[maxn], cnt=new int[maxn], belong=new int[maxn];
static int n, m, size, bnum;
static long curAns;
static long[] ans;
static query[] queries;
public static class query{
int l, r, id;
}
public static void main(String[] args) {
Scanner in = new Scanner(System.in);
n = in.nextInt(); // 序列长度
size = (int) Math.sqrt(n);
bnum = (int)Math.ceil((double)n/size); // block size 块数
for(int i = 1; i <= n; i++) aa[i] = in.nextInt(); // 整个序列读入
m = in.nextInt(); // 询问次数
ans = new long[m+1];
queries = new query[m+1];
for(int i = 1; i <= m; i++){
queries[i] = new query();
queries[i].l = in.nextInt();
queries[i].r = in.nextInt();
queries[i].id = i;
//System.out.println(queries[i].l + "-" + queries[i].r + "-" + queries[i].id);
}
for(int i = 1; i <= bnum; ++i) // 预处理序列索引分属的块
for(int j = (i - 1) * size + 1; j <= i * size; ++j) {
belong[j] = i;
}
// 对查询操作进行快速排序
// 但在此之上,我们还可以进行常数优化:奇偶化排序。意为:如果belong[l] 是奇数,则将r顺序排序,否则将r逆序排序。
Arrays.sort(queries,1,m+1,((a, b) ->
(belong[a.l] ^ belong[b.l])!=0 ? belong[a.l] - belong[b.l] : ((belong[a.l] & 1)!=0 ? a.r - b.r : b.r - a.r)));
//for(int i = 1; i <= m; i++)System.out.println(queries[i].l + "-" + queries[i].r + "-" + queries[i].id);
int l = 1, r = 0;
for(int i= 1; i <= m; i++){
int qr = queries[i].r, ql = queries[i].l;
// while(l < ql) del(l++); // 如左指针在查询区间左方,左指针向右移直到与查询区间左端点重合
// while(l > ql) add(--l); // 如左指针在查询区间左端点右方,左指针左移
// while(r < qr) add(++r); // 右指针在查询区间右端点左方,右指针右移
// while(r > qr) del(r--); // 否则左移
while(l < ql) curAns -= --cnt[aa[l++]]!=0 ? 0 : 1;
while(l > ql) curAns += cnt[aa[--l]]++==0 ? 1 : 0;
while(r < qr) curAns += cnt[aa[++r]]++==0 ? 1 : 0;
while(r > qr) curAns -= --cnt[aa[r--]]!=0 ? 0 : 1;
ans[queries[i].id] = curAns;
}
for(int i = 1; i <= m; i++)
System.out.println(ans[i]);
}
}
四、总结
使用该方法能通过上例大部分数据集(听说最近数据集加强了,所以java只能通过一半,不过这也可以当作一道莫队算法模板练习题)
这就是我个人对该算法的思路分享啦~
以上是个人的拙见,欢迎大家一起交流学习~