-
导入
莫队算法,也算是一个黑科技算法,能切掉好多区间题啊,和分块一样属于一个ACMer/IOer必备的算法,而由于莫队算法里面用到了一点点分块的内容,所以先弄懂分块还是有必要的。同时由于和分块扯上了关系,那么它的时间复杂度也就自然而然地是根号级别的了,能解决的数据规模还是挺大的。ok,那么莫队算法到底是什么呢,下面就来介绍这个黑科技算法。
-
普通莫队
先来直接看看莫队的思想:莫队算法实质是一个离线算法,甚至可以说是一个暴力算法。我们假定已经求得了区间[l,r]中的答案,然后如果借助该答案能够在O(1)的时间内得到区间[l,r+1]、[l-1,r]、[l,r-1]、[l+,r]的答案的话,要得到区间[L,R]的答案,我们是不是只用移动区间的端点:即从[l,r]移动到[L,R] 就能够得到最终的答案?而算法的时间复杂度就取决于端点移动的步数,如果我们把一段区间看成一个点,那么从一个区间移动到另一个区间的步数就等于这两点间的曼哈顿距离| L - l |+| R - r |。对于多个区间我们当然是要让它们之间转移的步数越短越好,而刚好我们有一个专门的算法可以解决这个问题,那就是曼哈顿最小生成树,建树时间复杂度仅为O(nlogn),这样我们就能在对数时间内解决这个问题了。可惜的是,曼哈顿最小生成树的程序十分复杂,在竞赛中较难编写代码(博主也不会)。
在这种情况下,一个简化版本的莫队就诞生了。这个版本的莫队,在区间转移的时候,并不根据曼哈顿最小生成树转移,而是运用了分块的思想,将给定序列分块,然后再将询问按一定次序排序即可。那么这个排序要如何排序呢?这里有一个简单的原则:将询问按左端点所在的块的序号从小到大排序,如果相等就按右端点从小到大排序。当然,还有另外的排序方式,而且更快,比如按奇偶排序,读者可以自行去搜索。
-
时间复杂度
那么为什么会是这样排序的呢?它的时间复杂度又是多少呢?下面来分析一下,分以下2种情况:
1. 在左端点所在的块相同的情况下转移:左端点始终在同一块内所以其一次转移步数不会超过块的大小,设为k,一个块里最多转移k次,所以最多移动步。而右端点其一次转移肯定不会超过整个序列的长度,设为n。但是我们进一步分析,在这个情况下右端点是有序排列的,不仅仅是一次转移不会超过序列长度,即便是任意次转移只要左端点还在同一块内,右端点移动次数都不会超过序列长度n。所以,这种情况至多会出现多少次呢?当然是取决于有多少块了,序列最多只会有n/k块,所以均摊下来,该种情况的移动次数至多为 步。
2. 在左端点所在的块不同的情况下转移:这种情况就是块与块之间的转移了,此时右端点可能会大幅度移动,最坏会移动n步(尽管我们知道还要减去前面几块的元素,但为方便计算就这样简化处理了),左端点仍然不会移动超过k步。但幸运的是,块与块之间转移的次数最多也不会超过块数即n/k次,所以均摊下来移动次数至多为 步。
综上所述,总移动次数为步,当k取时,取得最小值,所以最终时间复杂度为O()。
当然对于某些题目,要做到O(1)的转移很困难,所以有时我们也会降低要求,O(logn)的转移时间复杂度也是可以接受的。
-
例题
一个经典的莫队入门题:给定一行数字序列,询问区间[L,R]中有多少数字是不相同的。
莫队算法有着固定的模板,首先是预定义部分,基本上对于不同的题都是几乎一样的,无需大的修改,具体如下:
const int N = 数据规模;
//pos数组存放该位置所在块序号,num数组为原序列中的数据
int pos[N],num[N];
//将询问定义为结构体
struct Ask{
int l, r, id;//分别表示该询问的左端点,右端点,为第几个询问
Ask(int l, int r, int id): l(l), r(r), id(id){ }//构造函数
bool operator< (const Ask temp) const{//重载运算符,方便排序(可用sort直接排序了)
return pos[l] < pos[temp.l] || (pos[l] == pos[temp.l] && r < temp.r);
}
};
vector<Ask> ask;//用来存放询问
int cur; //当前答案值
int ans[N]; //用来存放答案
然后对于本题来说,我们要找到区间内有多少数字不同,那么肯定还需要定义一个cnt数组来存放每个数字出现的次数,下标代表对应数字,保存值代表出现次数。这样,预定义部分就结束了,接着就是算法的主体部分。
莫队算法的主体部分就是思考如何实现区间的转移,这里也就是莫队唯一需要自己思考的地方,你是否能做出一道莫队题关键就在于你是否能实现区间的转移。对于本题,实现O(1)的转移不算困难:每添加进来一个数就将其对应的cnt数组加1即可,而每剔除一个数就将其将其对应的cnt数组减1,而什么时候会出现不同的数呢?显然,如果新添加进来的数其对应的cnt数组是为0的,说明这个数从未在区间内出现过,自然当前答案cur就要加1;如果一个数在删除之后,它对应的cnt数组为0,说明这个数在区间内是没有相同的,而现在它被删除了自然当前答案cur就要减1。
int cnt[N]; //cnt数组存放数字出现的个数
//在区间内添加一个数
void insert(int k){
if(!cnt[num[k]]) cur++;//该数与区间内的数都不同,添加后答案+1
cnt[num[k]]++;
}
//从区间内删除一个数
void remove(int k){
cnt[num[k]]--;
if(!cnt[num[k]]) cur--;//该数在区间内唯一出现,删除后答案-1
}
最后是main函数部分,这部分都是模板,不同的题也几乎相同,同样无需大的修改,具体如下:
int main(){
int n,m;
scanf("%d",&n);
int size;
size=sqrt(n); //分块大小,为根号n
for(int i=1; i<=n; ++i)
scanf("%d",&num[i]);//读入数据
for(int i=1; i<=n; ++i)
pos[i] = (i-1)/size + 1; //得到每个点所在的块
ask.clear();
int l, r;
scanf("%d",&m);
for(int i=1;i<=m;++i){//读入询问
scanf("%d%d",&l,&r);
ask.push_back(Ask(l,r,i));
}
sort(ask.begin(),ask.end());
int L=1,R=0; //这里的初始值不要随意修改
for(int i=0; i<m; ++i){ //注意这里i从0开始,与上面不同
Ask temp = ask[i];
//自增自减运算符放置的位置固定,不要随意修改
while(temp.r > R) insert(++R);
while(temp.l < L) insert(--L);
while(temp.r < R) remove(R--);
while(temp.l > L) remove(L++);
ans[temp.id] = cur;
}
for(int i=1; i<=m; ++i)
printf("%d\n",ans[i]);
return 0;
}
以上,莫队算法的入门就完成了。