莫队算法是用于处理区间查询问题特别是离线查询的算法,利用了分块、排序和移动指针的方式来优化时间复杂度。
首先,莫队算法适用的场景是什么样的呢?比如说,给定一个静态的数组,然后有很多查询,每个查询问的是某个区间内的某个值,比如区间内不同数字的个数,或者区间内的众数等等。这时候,莫队算法可以通过合理地处理这些查询顺序,使得总体的时间复杂度比直接暴力处理每个查询要低。
莫队的基本思想就是把原本无序的查询(比如[1,4] 、[5,8] 、 [2,6])变为相对有序([1,4]、[2, 6]、 [5,8])的查询,这样可以减少指针移动的浪费,就拿无序查询举例假如左指针从1-》5,之后又要从5-》2,这就有移动的浪费因为我们明明可以1-》2-》5这样移动的。
tip:不懂这里为什么要用双指针的同学可以先做这题做些准备:[P1972 SDOI2009] HH的项链 - 洛谷
那这时候有些心急的同学就说了难道莫队就是把区间按左边界排序吗?显然不是的,因为这样的排序规则只考虑了左边界的有序性而右边界完全没被考虑到,我举个例子大家就明白了[1, 100], [2, 3], [3, 99]....对于这三个查询虽然左指针的移动是0-》1-》2-》3,但是右指针的移动就很跳脱了0-》100-》3-》99(悲!)。由于右指针是无序的所以导致整体的复杂度飙升(因为很多样例都会针对这个特殊设计)。
前面铺垫了这么多所以莫队到底是怎么实现降低复杂度的?莫队的排序规则很特殊兼顾了左边界和右边界,它采用了分块+排序的思想将查询范围(1到n)划分为sqrt(n)个块,如果两个区间的左边界不在同一块中就按左边界排序,如果两个区间的左边界在同一块就按右边界排序,排完序后我们再进行左右指针跳来跳去的操作。
还是以[1, 100], [2, 3], [3, 99],[11,50],
只按左边界排序的移动是:左指针0-》1-》2-》3-》11,右指针0-》100-》3-》99-》50,指针总共移动11+342次。
按照莫队的排序规则是[2,3], [3, 99], [1, 100],[11,50], 左指针0-》2-》3-》1-》11,右指针0-》3-》99-》100-》50指针总共移动15+150次,很大程度优化了我们的暴力过程。
接着我们来试着证明一下为什么莫队可以减少复杂度:
排序的开销是sqrt(n) * n,对于同一个区间来说是以右边界排序的,所以我们处理这个区间最多只要sqrt(n)+ n步就可以(sqrt(n)可以遍历所有的左边界,右边界最大也是在n的范围内,n步肯定能遍历完)。然后我们分为了sqrt(n)个区间,所以我们处理所有区间的开销就是sqrt(n) *(sqrt(n) + n),也就是莫队的时间复杂度是sqrt(n) * n级别的。
好了相信你已经掌握基础莫队了接下来做到例题吧:P2709 小B的询问 - 洛谷
题解:
public class Main {
static class Query {
int l, r, id;//id是记录这是第几次查询,因为我们会打乱查询顺序
Query(int l, int r, int id) {
this.l = l;
this.r = r;
this.id = id;
}
}
public static void main(String[] args) {
Scanner sc = new Scanner(System.in);
int n = sc.nextInt();
int m = sc.nextInt();
int k = sc.nextInt();
int[] nums = new int[n];
ArrayList<Query> queries = new ArrayList<>();
for (int i = 0; i < n; i++) {
nums[i] = sc.nextInt();
}
int cnt = 0;
for(int i = 0; i < m; i++) {
int l = sc.nextInt();
int r = sc.nextInt();
queries.add(new Query(l, r, cnt++));
}
int block = (int)Math.sqrt(n);
//按照莫队规则排序
queries.sort(new Comparator<Query>() {
@Override
public int compare(Query o1, Query o2) {
if((o1.l - 1) / block == (o2.l - 1) / block) {//如果在一个块里,则按右端点排序
return o1.r - o2.r;
} else {//不在一个块里,则按左端点排序
return o1.l - o2.l;
}
}
});
// for(int i = 0; i < m; i++) {
// System.out.println(queries.get(i).l + " " + queries.get(i).r + " " + queries.get(i).id);
// }
int l = 0, r = -1, sum = 0;
int[] result = new int[m];
int[] bucket = new int[k + 1];//记录每个数字出现的次数
for(int i = 0; i < m; i++) {
Query q = queries.get(i);
//左指针向左移动/右指针向右移动,扩大区间
while(l > q.l - 1) {
l--;
sum = add(nums[l], sum, bucket);
}
while(r < q.r - 1) {
r++;
sum = add(nums[r], sum, bucket);
}
//左指针向右移动,右指针向左移动,缩小区间
while(l < q.l - 1) {
sum = sub(nums[l], sum, bucket);
l++;
}
while(r > q.r - 1) {
sum = sub(nums[r], sum, bucket);
r--;
}
result[q.id] = sum;//记录答案
}
for(int i = 0; i < m; i++) {
System.out.println(result[i]);
}
}
/**
* 添加一个数
* @param x 被添加进来的数
* @param preSum 上次得到的和
* @param bucket 记录每个数出现的次数
* @return 这次的和
* preSum - bucket[x]**2 代表上次和中除了x外其它数次数的平方的和,
* 这次的和为preSum - bucket[x]**2 + (bucket[x] + 1)**2 就可以快速得到,
*/
public static int add(int x, int preSum, int[] bucket) {
int sum = preSum + 2 * bucket[x] + 1;
bucket[x]++;
return sum;
}
public static int sub(int x, int preSum, int[] bucket) {
int sum = preSum - 2 * bucket[x] + 1;
bucket[x]--;
return sum;
}
}