1. 程式人生 > 其它 >資料結構專題-學習筆記:莫隊#1(普通莫隊)

資料結構專題-學習筆記:莫隊#1(普通莫隊)

目錄

1.概述

莫隊演算法,是由莫濤隊長提出的一種,能夠以玄學的複雜度來處理區間查詢類的問題。

甲:區間查詢類的問題不是可以用線段樹等資料結構解決的嗎?
乙:那如果要求某個區間的區間眾數要怎麼辦呢?不準使用分塊。
甲:啊這。。。。。。

所以,莫隊演算法就是用來解決這種線段樹等資料結構不好維護的區間查詢問題。

在接觸莫隊之前,請先確保已經掌握分塊的基本思想。

我的部落格:分塊演算法總結&專題訓練

@Isaunoya 大佬的部落格:link

開始講解之前先安利幾個部落格,寫的非常好,建議各位讀者可以看一看,寫的比我好多了:

考慮到莫隊演算法有很多的擴充套件 ,因此莫隊分成三篇文章來講述:

莫隊演算法總結&專題訓練1:基礎的莫隊講解,普通莫隊。

莫隊演算法總結&專題訓練2:更進一步,講解 帶修莫隊、樹上莫隊、樹上帶修莫隊。

莫隊演算法總結&專題訓練3

:最後兩種莫隊,回滾莫隊/不刪除莫隊、莫隊二次離線。

接下來通過一道題目,來講述莫隊演算法的原理。

2.套路

實際上,莫隊演算法並沒有固定的模板,卻有一個非常好記的程式碼套路,比分塊好學一點。

丟擲例題->link1&link2

link1&2 是一個題目,不過:link2被資料加強狂魔 chen_zhe 加強了資料,莫隊過不去了。

So,這道題怎麼做呢?

直接暴力時間複雜度 \(O(m(n+s))\) ~ \(O(n^2)\)\(n\) 是序列長度, \(m\) 是詢問個數, \(s\) 是值域。但是,這簡直就是要 T 上天啊,因此我們需要考慮一定的優化。

優化1:

我們考慮記錄 \(cnt\)

陣列表示每一個數出現了幾次。

在暴力 for 迴圈中,每一次列舉時我們都讓 \(cnt_{a_i}++\) ,每一次加完之後我們看一眼,如果 \(cnt_{a_i}=1\) 那麼記錄答案 +1。在後面的操作中,假如我們需要刪除 \(a_i\) ,那麼讓 \(cnt_{a_i}--\) ,如果變成 0 了,那麼讓答案 -1。這樣,我們可以將時間複雜度從 \(O(m(n+s))\) 優化到 \(O(mn)\) ,刪掉了值域的影響。但是還是 TLE 啊!於是我們需要再次優化。

優化2:

接下來這個優化,將是莫隊演算法的一個重點!

還記得尺取法嗎?沒錯,就是那個兩個指標在兩個陣列上移來移去的東西。通過尺取法,我們可以將 \(O(n^2)\) 的程式碼優化到 \(O(n)\) ,那麼我們可不可以使用同樣的辦法來優化暴力呢?

答案是肯定的。只不過,由於只剩了一個數組,所以兩個指標需要在同一個陣列上移動。

具體做法如下:

假設當前序列為 5 6 8 2 5 2 1 3 6 9 7 5 2 1,指定詢問區間是 \([2,6],[5,7]\)

首先,我們規定兩個指標 \(l=1,r=0\) (為什麼?請見下文),一開始處於如下位置:

_ 5 6 8 2 5 2 1 3 6 9 7 5 2 1
r l

接下來,看到第一個區間左端點為 2,那麼我們將左端點右移一位,同時根據優化 1 ,刪除 5 ,記錄 \(cnt_5--\)

_ 5 6 8 2 5 2 1 3 6 9 7 5 2 1
r   l
cnt[5]=-1

接下來,發現右指標 \(r\) 太遠了,於是一步一步向右移動。

接下來放上移動的全過程:
r++,右移一位
_ 5 6 8 2 5 2 1 3 6 9 7 5 2 1
  r l
記錄cnt[5]++,此時 cnt[5]=0;
接下來記錄 total 為答案。total=0;
r++,右移一位
_ 5 6 8 2 5 2 1 3 6 9 7 5 2 1
    l
    r
記錄cnt[6]++,此時 cnt[6]=1;由優化一,total++;(total=1)
r++,右移一位
_ 5 6 8 2 5 2 1 3 6 9 7 5 2 1
	l r
記錄cnt[8]++,此時 cnt[6]=1,cnt[8]=1;由優化一,total++;(total=2)
r++,右移一位
_ 5 6 8 2 5 2 1 3 6 9 7 5 2 1
	l	r
記錄cnt[2]++,此時 cnt[6]=cnt[8]=cnt[2]=1;由優化一,total++;(total=3)
r++,右移一位
_ 5 6 8 2 5 2 1 3 6 9 7 5 2 1
	l	  r
記錄cnt[5]++,此時 cnt[6]=cnt[8]=cnt[2]=cnt[5]=1;由優化一,total++;(total=4)
r++,右移一位
_ 5 6 8 2 5 2 1 3 6 9 7 5 2 1
	l		r
記錄cnt[2]++,此時 cnt[6]=cnt[8]=cnt[5]=1,cnt[2]=2;

此時,由優化一, \(total\) 需要加 1 嗎?

不需要!根據優化一的理論,此時 2 已經出現過,所以不能 +1。

此時 \(l=2,r=6\) ,詢問結束,答案為 4。

接下來看詢問 \([5,7]\)

初始序列:_ 5 6 8 2 5 2 1 3 6 9 7 5 2 1
發現 l<5 ,於是把 l 右移到 5
_ 5 6 8 2 5 2 1 3 6 9 7 5 2 1
		  l r
其中,cnt[6]=cnt[8]=0,cnt[5]=cnt[2]=1;
然後將 r 右移到 7
_ 5 6 8 2 5 2 1 3 6 9 7 5 2 1
		  l   r
此時,cnt[5]=cnt[2]=cnt[1]=1,total=3,模仿上述過程不難想出。

理解優化二了嗎?有尺取法的感覺。

接下來說一下為什麼要初始化 \(l=1,r=0\)

是這樣的,因為在處理第一個詢問的時候,我們需要將 \(l,r\) 右移,而 \(l\) 右移的時候認為這個數是需要被刪除的(刪除操作),而 \(r\) 右移的時候認為這個數是需要被加入的(增加操作),所以初始化 \(l=1,r=0\) 就可以完美解決這個問題——刪除一次+增加一次=啥都沒幹

那麼如果此時又需要詢問區間 \([3,5]\) 呢?我們只需要將 \(l,r\) 往回移即可。

所以,在加入優化二之後,程式的效率被大大提升,在某些測試點上取得了不錯的效果。

P.S. 通過在上面的操作我們可以發現,雙指標處理答案時先加一個數再刪一個數是對答案沒有影響的。(這點性質在後面的講解中會用到)

但是!這還不是莫隊!

優化3:

剛才的優化二,在隨機資料上取得了很好的效果,但是如果詢問區間是這樣的:

[1,2][1e5-1,1e5][2,3][1e5-2,1e5-1]......

此時,優化二直接就沒了,因為此時 \(l,r\) 從頭到尾,又從尾到頭移來移去,直接使得時間複雜度飆升至 \(O(nm)\) ,甚至跑的比暴力還慢。

看起來似乎不能再優化了,但是我們還有最後一招——排序!

如果我們能夠通過合理的排序,使得 \(l,r\) 的移動次數儘量少,那麼就可以解決 TLE 的問題。

此時,莫濤隊長想出了一種優化的方法:分塊!

將左端點分成若干塊,排序的時候按照左端點所在的塊為第一關鍵字,右端點為第二關鍵字排序,取塊長為 \(\sqrt{n}\) ,那麼可以證明時間複雜度為 \(O(n\sqrt{n})\)

證明過程:

  1. 首先,對於每一塊內的元素而言,左端點至多移動 \(\sqrt{n}\) ,右端點移動 \(n\) ,複雜度 \(n\sqrt{n}\)
  2. 而後,由於塊與塊的轉移是 \(O(n)\) 的,因此可以做到時間複雜度 \(O(n\sqrt{n}+n)\) (注意不是相乘),即為 \(O(n\sqrt{n})\)

於是,我們就證完了~~~

不過由於莫隊需要對詢問進行排序,因此莫隊就變成了離線演算法。因此我們需要事先存下詢問的順序 \(id\) ,最後根據 \(id\) 輸出。

update 2020/12/13 這裡需要強調一下:莫隊排序時是以左端點為第一關鍵字!而不是以它的 \(id\) 為第一關鍵字!千萬不能在這裡 TLE 了!!!

程式碼實現:

  • 刪除&增加操作:
    仿照優化一敲程式碼即可。
    void Delete(int x)
    {
    	cnt[a[x]]--;
    	if(cnt[a[x]]==0) sum--;
    }
    void Add(int x)
    {
    	if(cnt[a[x]]==0) sum++;
    	cnt[a[x]]++;
    }
    
  • 排序實現
    bool cmp(const node &fir,const node &sec)
    {
    	if(fir.b!=sec.b) return fir.b<sec.b;
    	return fir.r<sec.r;
    }
    
  • 詢問實現
    for(int i=1;i<=m;i++) {q[i].l=read();q[i].r=read();q[i].id=i;q[i].b=(q[i].l-1)/block+1;}//由於我們進行了排序,所以需要存一個 id ,方便輸答案使用
    sort(q+1,q+m+1,cmp);
    int l=1,r=0;
    for(int i=1;i<=m;i++)
    {
    	while(l<q[i].l) Delete(l++);
    	while(l>q[i].l) Add(--l);
    	while(r>q[i].r) Delete(r--);
    	while(r<q[i].r) Add(++r);
    	ans[q[i].id]=sum;
    }
    

update 2020/12/11:這裡的寫法實際上是有問題的,因為我們先動 \(l\) 可能會導致出現 \(l>r\) 的情況,這樣在某些題目當中會導致一些神奇的錯誤。具體詳見 關於莫隊的區間端點移動順序 這篇文章,有詳細的解說,不過本文裡面給出的題單與例題沒有這個問題,各位讀者注意一下。

update 2020/12/11:推薦的寫法是 l--,r--,r++,l++

講到這裡,程式碼已經不難寫出,下面給出程式碼:

#include<bits/stdc++.h>
using namespace std;

const int MAXN=1e6+10;
int n,a[MAXN],ans[MAXN],m,cnt[MAXN],sum,block;
struct node
{
	int l,r,id,b;
}q[MAXN];

bool cmp(const node &fir,const node &sec)
{
	if(fir.b!=sec.b) return fir.b<sec.b;
	return fir.r<sec.r;
}

int read()
{
	int sum=0,fh=1;char ch=getchar();
	while(ch<'0'&&ch>'9') {if(ch=='-') fh=-1;ch=getchar();}
	while(ch>='0'&&ch<='9') {sum=(sum<<3)+(sum<<1)+(ch^48);ch=getchar();}
	return sum*fh;
}
void print(int x,char tail=0)
{
	if(x<0) {putchar('-');x=-x;}
	if(x>9) {print(x/10);x%=10;}
	putchar(x|48);
	if(tail) putchar(tail);
}

void Delete(int x)
{
	cnt[a[x]]--;
	if(cnt[a[x]]==0) sum--;
}
void Add(int x)
{
	if(cnt[a[x]]==0) sum++;
	cnt[a[x]]++;
}

int main()
{
	n=read();block=2135;
	for(int i=1;i<=n;i++) a[i]=read();
	m=read();
	for(int i=1;i<=m;i++) {q[i].l=read();q[i].r=read();q[i].id=i;q[i].b=(q[i].l-1)/block+1;}
	sort(q+1,q+m+1,cmp);
	int l=1,r=0;
	for(int i=1;i<=m;i++)
	{
		while(l<q[i].l) Delete(l++);
		while(l>q[i].l) Add(--l);
		while(r>q[i].r) Delete(r--);
		while(r<q[i].r) Add(++r);
		ans[q[i].id]=sum;
	}
	for(int i=1;i<=m;i++) print(ans[i],'\n');
	return 0;
}

然而,莫隊因為時間複雜度帶了一個根號,因此很容易被卡常 (比如 chen_zhe 就到處卡莫隊) ,所以接下來講幾個卡常技巧。

卡常技巧:

1.#pragma GCC optimize系列

包括 O2,O3,Ofast。

實際上,通過測試可以發現,吸了氧氣的莫隊跑的非常之快,甚至 1e6 都能無壓力碾過。因此,只要比賽不禁 O2 ,那麼寫莫隊時就最好開著。(當然 NOI 系列比賽千萬不要開)

2.奇偶性排序

這個是比較強力的一個排序,而程式碼只需要這麼改動(規定初始塊為 1):

bool cmp(const node &fir,const node &sec)
{
	if(fir.b!=sec.b) return fir.b<sec.b;
	if(fir.b&1) return fir.r<sec.r;
	return fir.r>sec.r;//請注意這兩行
}

這兩行的主要作用就是奇數塊右端點從小到大排序,偶數塊右端點從大到小排序,可以優化將近 200ms,那麼原理是什麼呢?

原理就是一開始 \(r\) 往右移時,能夠將 1 號塊全部做完,移回去時又能順便把 2 號塊給解決了,然後往右移時又能做完 3 號塊······就這樣,優化了大量的常數。

3.壓縮程式碼:

啥意思?

我們可以將這段程式碼:

void Delete(int x)
{
	cnt[a[x]]--;
	if(cnt[a[x]]==0) sum--;
}
void Add(int x)
{
	if(cnt[a[x]]==0) sum++;
	cnt[a[x]]++;
}

和這一段程式碼:

while(l<q[i].l) del(l++);
while(l>q[i].l) add(--l);
while(r>q[i].r) del(r--);
while(r<q[i].r) add(++r);
//del=Delete函式,add=Add函式

活生生的壓縮成這一段:

while(l<q[i].l) sum-=!--cnt[a[l++]];
while(l>q[i].l) sum+=!cnt[a[--l]]++;
while(r>q[i].r) sum-=!--cnt[a[r--]];
while(r<q[i].r) sum+=!cnt[a[++r]]++;

這樣,又能優化 200ms,並且非常有用。不過使用時一定要建立在熟練的基礎上,不然會大大增加除錯難度。

4.手打快讀/快輸

其實莫隊題目的輸入輸出量還是非常大的,因此建議手打快讀/快輸,能夠省下不少時間。(但是為什麼我的快輸比 printf 還慢啊?)

總結:

莫隊的一般套路:

void del(int x){/*do sth.*/}
void add(int x){/*do sth.*/}

sort(q+1,q+m+1,cmp);
int l=1,r=0;
for(int i=1;i<=m;i++)
{
	while(l<q[i].l) del(l++);
	while(l>q[i].l) add(--l);
	while(r>q[i].r) del(r--);
	while(r<q[i].r) add(++r);
	ans[q[i].id]=sum;
}

其實也還是很好 理解的。

如果你理解了上述程式碼,那麼恭喜你,學會了普通莫隊!

不過要注意:莫隊只適合離線,如果要線上基本上就沒有用了,需要另尋他法(洛穀日報 #183 期除外)。

3.練習題

接下來,你將見到各路莫隊用法(包括帶修莫隊,樹上莫隊,樹上帶修莫隊(前兩者的結合體),回滾莫隊/不刪除莫隊,莫隊二次分塊/第十四分塊(前體)),共 9 題。

題單:

篇幅有限,這裡無法寫出題解(其實是因為還有好幾種莫隊沒有講),因此莫隊練習題與莫隊的總結請見這兩篇文章->莫隊演算法總結&專題訓練2莫隊演算法總結&專題訓練3