神奇的莫隊

SD!LTF發表於2020-09-20

Part -1: 參考資料

參考資料1
萬分感謝這個大佬,祝他報送清華北大!
本文同步發表於知乎


Part 0: 一些介紹

莫隊由莫濤神仙首次提出,是一種區間操作演算法。

即便是板子題,難度也很高(差評)

所以,在閱讀後文之前,請你先深呼吸,喝杯咖啡,吃點餅乾,聽聽自己喜歡的歌

然後,停止呼吸,放下杯子,扔開餅乾,摘下耳機,接受莫濤大神思想光輝的洗禮


Part 1:莫隊演算法的引入

先別談莫隊,我們來回顧一下,遇到區間問題一般怎麼解決?

很好,暴力線段樹

也就是說,我們一直在通過維護兩個序列——左序列\([l,mid]\)與右序列\([mid + 1,r]\),從而來維護\([l, r]\),當然,這個操作會一直遞迴下去

然而,當題目這麼問:

令陣列\(Q\)大小為\(n\)且每個元素\(Q_i < n\),有\(m\)個詢問,每次詢問給定\(l,r\),請找出\([l,r]\)中至少重複出現\(k\)此的數字的個數

換句話說:

\(Q_l\)\(Q_r\)內找出現次數多餘\(k\)的數字的個數

of course,你可以暴力,但你會暴零

那麼我們試著用線段樹,首先,你需要維護左邊的序列,然後你需要維護右邊的序列,然後……

然後你會發現很難做到短時間甚至\(O(1)\)的時間完成對線段樹單一節點的維護,因為你總是要層層遞進向上疊加。

淦!這不是欺負人嗎

我們先試試暴力吧,用個\(count\)記錄一下出現次數,然後在掃一遍

暴力是萬能的,答案當然正確,但是你的時間複雜度哭了——\(O(n^2)\)

那麼我們可以看看是否可以改進一下,用上\(t(wo)p(oints)\)演算法:

假設有兩個指標,\(l\)\(r\),每次詢問的時候用移動\(l\)\(r\)的方式來嘗試和要求區間重合

是不是有點蒙?我舉個例子

此圖中,兩個Q是待求的區間

初始化\(r = 0,l = 1\)

此時,發現\(l\)和要求的區間左端重合了,而\(r\)沒有,那麼我們把\(r\)往右邊移動一位

此時,\(r\)發現了一個新的值\(0\),總數記錄一下,繼續右移動

\(r\)又發現了一個新數值\(2\),總數記錄一下,繼續右移動

此處\(2\)被記錄過了,總數值不變

一直到\(r\)與右端點重合,得到下圖:

第一個區間就算處理完了,我們來看下一個

首先,\(l\)不在左端點,我們把它右移

這一次,\(l\)所遇到的數值在區間\([l, r]\)只能夠存在,總數不變

下一次也是如此,一直到

你會發現,這時,區間\([l,r]\)將(也就是在下一次移動後)不會有\(2\)存在了,那麼總數就一個\(-1\),而正好本題需要統計的就是區間內數值的個數,總數改變:

如此迴圈往復,得到最終答案,所以我們可以得出這個程式碼

int arr[maxn], cnt[maxn]   // 每個位置的數值、每個數值的計數器
int l = 1, r = 0, now = 0; // 左指標、右指標、當前統計結果(總數)
void add(int pos) {             // 新增一個數
    if(!cnt[arr[pos]]) ++ now;  // 在區間中新出現,總數要+1
    ++ cnt[arr[pos]];
}
void del(int pos) {             // 刪除一個數
    -- cnt[arr[pos]];
    if(!cnt[arr[pos]]) -- now;  // 在區間中不再出現,總數要-1
}
void work() {
    for(int i = 1; i <= q; i ++) {
        int ql, qr;
        scanf("%d%d", &ql, &qr);    
        while(l < ql) del(l++); // 左指標在查詢區間左方,左指標向右移直到與查詢區間左端點重合
        while(l > ql) add(--l); // 左指標在查詢區間左端點右方,左指標左移
        while(r < qr) add(++r); // 右指標在查詢區間右端點左方,右指標右移
        while(r > qr) del(r--); // 否則左移
        printf("%d\n", now);    // 輸出統計結果
    }
}

嗯,幹得漂亮,但是這是莫隊嗎?不是

如果區間特別多,\(l,r\)反覆橫跳,結果皮斷了腿,時間複雜度\(O(nm)\)

那麼現在的問題已經變成了:如何儘量減少\(l,r\)移動的次數


Part 2:莫隊的正確開啟方式

首先,看到儘量減少\(l,r\)移動的次數,我們會想到排個序

排序排什麼的順序呢?是排端點嗎?顯然不是,哪怕左端點有序,右端點就會雜亂無章;右端點有序,左端點就會雜亂無章……

這裡,我們運用一下分塊的思想,把序列分為\(\sqrt{n}\)塊,把查詢區間按照左端點所在塊的序號排個序,如果左端點所在塊相同,再按右端點排序。

這個演算法需要的時間複雜度為\(sort+move_{\texttt{左指標}}\)

由於\(sort\)的時間複雜度為\(O(n\log n)\)\(move_{\texttt{做指標}}\)的時間複雜度為\(O(n\sqrt{n})\),那麼總的時間複雜度為\(O(n\sqrt{n})\)

好耶!降了一個根號!鼓掌!

其次,我們需要考慮一下更新的策略

一般來說,我們只要找到指標移動一位以後,統計資料與當前資料的差值,找出規律(可以用數學方法或打表),然後每次移動時用這個規律更新就行

最後給出總程式碼:

#include <cstdio>
#include <cstring>
#include <cmath>
#include <algorithm>
using namespace std;

#define maxn 1010000
#define maxb 1010
int aa[maxn], cnt[maxn], belong[maxn];
int n, m, size, bnum, now, ans[maxn];
struct query {
	int l, r, id;
} q[maxn];

int cmp(query a, query b) {
	return (belong[a.l] ^ belong[b.l]) ? belong[a.l] < belong[b.l] : ((belong[a.l] & 1) ? a.r < b.r : a.r > b.r);
}
#define isdigit(x) ((x) >= '0' && (x) <= '9')
int read() {
	int res = 0;
	char c = getchar();
	while(!isdigit(c)) c = getchar();
	while(isdigit(c)) res = (res << 1) + (res << 3) + c - 48, c = getchar();
	return res;
}
void printi(int x) {
	if(x / 10) printi(x / 10);
	putchar(x % 10 + '0');
}

int main() {
	scanf("%d", &n);
	size = sqrt(n);
	bnum = ceil((double)n / size);
	for(int i = 1; i <= bnum; ++i) 
		for(int j = (i - 1) * size + 1; j <= i * size; ++j) {
			belong[j] = i;
		}
	for(int i = 1; i <= n; ++i) aa[i] = read(); 
	m = read();
	for(int i = 1; i <= m; ++i) {
		q[i].l = read(), q[i].r = read();
		q[i].id = i;
	}
	sort(q + 1, q + m + 1, cmp);
	int l = 1, r = 0;
	for(int i = 1; i <= m; ++i) {
		int ql = q[i].l, qr = q[i].r;
		while(l < ql) now -= !--cnt[aa[l++]];
		while(l > ql) now += !cnt[aa[--l]]++;
		while(r < qr) now += !cnt[aa[++r]]++;
		while(r > qr) now -= !--cnt[aa[r--]];
		ans[q[i].id] = now;
	}
	for(int i = 1; i <= m; ++i) printi(ans[i]),putchar('\n');
	return 0;
}

Part 3:關於莫隊的一些卡常數

卡常數作為OIer的家常便飯,相信大家一定不陌生了

卡常數包括:

  • 位運算
  • O2
  • 快讀
  • ……

而莫隊的神奇之處在於他的獨特優化:奇偶性排序
原始碼:

int cmp(query a, query b) {
    return belong[a.l] == belong[b.l] ? a.r < b.r : belong[a.l] < belong[b.l];
}

改為

int cmp(query a, query b) {
	return (belong[a.l] ^ belong[b.l]) ? belong[a.l] < belong[b.l] : ((belong[a.l] & 1) ? a.r < b.r : a.r > b.r);
}

別人說跑的很快我還不信,自己跑了一下才知道……

真的跑的很快啊……


Part 4: 能修改的莫隊

我知道,你拿著上面別個大佬寫的程式碼(再次膜拜寫這個程式碼的大佬orz)興沖沖的去刷題,一路上披荊斬棘,直到你看到了Luogu1903——國家集訓隊-數顏色,你徹底傻了眼

媽耶,他要是這麼一修改我豈不是要重新sort?跑了跑了

由於莫隊本身就是離線的,而你需要修改,得想個辦法讓他線上,具體做法是:“就是再弄一指標,在修改操作上跳來跳去,如果當前修改多了就改回來,改少了就改過去,直到次數恰當為止。”
(再次感謝這個大佬,,好喜歡這個解釋)

相關文章