眾所周知,莫隊是由莫濤大神提出的,一種玄學毒瘤暴力騙分區間操作算法,它以簡短的框架、簡單易記的板子和優秀的復雜度聞名於世。然而由於莫隊算法應用的毒瘤,很多可做的莫隊模板題都有着較高的難度評級,令很多初學者望而卻步。然而,如果你真正理解了莫隊的算法原理,那么它用起來還是很簡單的。當然某些套左套右的毒瘤除外
0、前置芝士:
莫隊算法還是比較獨立的。不過你還是得了解了解以下的一些知識:
\(1\)、分塊的基本思想(開根號等)
\(2\)、STL中sort
的用法(手寫cmp函數或重載運算符實現結構體的多關鍵字排序)
\(3\)、基(du)礎(liu)的卡常技巧(包含#pragma GCC optimize
系列)
\(4*\)、倍增/樹剖 求LCA(樹上莫隊所需)
\(5*\)、數值離散化(用於應付很多題目)
至此全部完畢。撒花~~(霧
誒,別走啊qwq,我可不是在勸退qwq,如果你認為自己不懂這些東西也沒關系,往下看吧qwq
1、莫隊算法是個啥
來歷:
前面已經介紹過了(逃
有興趣的同學可以看一下莫濤大神的知乎
然而這個算法到底是用來搞什么操作的呢?我們先看個例題:
Luogu P1972 [SDOI2009]HH的項鏈
題目描述
HH 有一串由各種漂亮的貝殼組成的項鏈。HH 相信不同的貝殼會帶來好運,所以每次散步完后,他都會隨意取出一段貝殼,思考它們所表達的含義。HH 不斷地收集新的貝殼,因此,他的項鏈變得越來越長。有一天,他突然提出了一個問題:某一段貝殼中,包含了多少種不同的貝殼?這個問題很難回答……因為項鏈實在是太長了。於是,他只好求助睿智的你,來解決這個問題。
輸入輸出格式
輸入格式:
第一行:一個整數N,表示項鏈的長度。
第二行:N 個整數,表示依次表示項鏈中貝殼的編號(編號為0 到1000000 之間的整數)。
第三行:一個整數M,表示HH 詢問的個數。
接下來M 行:每行兩個整數,L 和R(1 ≤ L ≤ R ≤ N),表示詢問的區間。
輸出格式:
M 行,每行一個整數,依次表示詢問對應的答案。
輸入輸出樣例
輸入樣例#1:
6
1 2 3 4 3 5
3
1 2
3 5
2 6
輸出樣例#1:
2
2
4
說明
數據范圍:
對於100%的數據,N <= 500000,M <= 500000。
題意簡明易懂:給你一個長度不大於\(n≤5×10^5\)的序列,其中數值都小於等於\(10^6\),有\(m≤5×10^5\)次詢問,每次詢問區間\([l,r]\)中數值個數(也就是去重后數字的個數)。
不過這個例題卡了莫隊,所以請左轉數據弱化版:SP3267 DQUERY - D-query
題目到手,我們開始分析本題的算法。這題最簡單做法無非暴力——用一個\(cnt\)數組記錄每個數值出現的次數,再暴力枚舉\(l\)到\(r\)統計次數,最后再掃一遍cnt數組,統計\(cnt\)不為零的數值個數,輸出答案即可。設最大數值為\(s\),那么這樣做的復雜度為\(O(m(n+s))∽O(n^2)\),對於本題實在跑不起。
我們可以嘗試優化一下:
優化1:每次枚舉到一個數值\(num\),增加出現次數時判斷一下\(cnt_{num}\)是否為0,如果為0,則這個數值之前沒有出現過,現在出現了,數值數當然要+1。反之在從區間中刪除\(num\)后也判斷一下\(cnt_{num}\)是否為0,如果為0數值總數-1。這樣我們優化掉了一個\(O(ms)\),但還是跑不起。
優化2:我們弄兩個指針 \(l\) 、\(r\) ,每次詢問不直接枚舉,而是移動 \(l\) 、\(r\) 指針到詢問的區間,直到\([l,r]\)與詢問區間重合。在統計答案時,我們也只在兩個指針處加減\(cnt\),然后我們就可以用優化1中的方法快速地統計答案啦\(qwq\)!
優化2具體步驟如下:
假設這個序列是這樣子的:(其中\(Q1\)、\(Q2\)是詢問區間)
我們初始化\(l=1\)、\(r=0\)(如果\(l=0\),那么我們還需要刪除一個數值\(0\),使其出現次數變成-1,導致一些奇奇怪怪錯誤),如下圖(由於畫圖軟件中\(l\)和\(1\)看不出區別,我只好在圖中使用\(L\)和\(R\)來表示qwq):
我們發現 \(l\) 已經是第一個查詢區間的左端點,無需移動。現在我們將 \(r\) 右移一位,發現新數值1:
\(r\) 繼續右移,發現新數值2:
繼續右移,發現新數值4:
當 \(r\) 再次右移時,發現此時的新位置中的數值2出現過,數值總數不增:
接下來是兩個7,由於7沒出現過,所以總數+1:
繼續右移發現3:
繼續右移,但接下來的兩個數值都出現過,總數不增。
至此,\(Q1\)區間所有數值統計完成,結果為5。
現在我們又看一下\(Q2\)區間的情況:
首先我們發現, \(l\) 指針在\(Q2\)區間左端點的左邊,我們需要將它右移,同時刪除原位置的統計信息。
將\(l\)右移一位到位置2,刪除位置1處的數值1。但由於操作后的區間中仍然有數值1存在,所以總數不減。
接下來的兩位也是如此,直接刪掉即可,總數不減。
當 \(l\) 指針繼續右移時,發現一個問題:原位置上的數值是2,但是刪除這個2后,此時的區間\([l,r]\)中再也沒有2了(回顧之前的內容,這種情況就是刪除后\(cnt_2 = 0\)),那么總數就要-1,因為有一個數值已經不在該區間內出現了,而本題需要統計的就是區間內的數值個數。此步驟如下圖:
再右移一位,發現無需減總數,而且\(l\)已經移到了\(Q2\)區間的左端點,無需繼續移下去(如下圖)。當然 \(r\) 還是要移動的,只不過沒圖了,我相信大家應該知道做法的\(qwq\)。
\(r\)的最后位置:
至於刪除操作,也是一樣的做法,只不過要先刪除當前位置的數值,才能移動指針。
有了以上的內容,這段代碼就可以很容易寫出啦qwq:
int aa[maxn], cnt[maxn], l = 1, r = 0, now = 0; //每個位置的數值、每個數值的計數器、左指針、右指針、當前統計結果(總數)
void add(int pos) {//添加一個數
if(!cnt[aa[pos]]) ++now;//在區間中新出現,總數要+1
++cnt[aa[pos]];
}
void del(int pos) {//刪除一個數
--cnt[aa[pos]];
if(!cnt[aa[pos]]) --now;//在區間中不再出現,總數要-1
}
void work() {//優化2主過程
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);//輸出統計結果
}
}
優化2完結撒花✿✿ヽ(°▽°)ノ✿\(qwq\)
誒等等,什么叫做“優化2完結撒花”??!
難道這不就是莫隊嗎??!
我會很嚴肅的告訴你:這還不是莫隊,但是看到這里,你已經把莫隊的基礎打好了。還請繼續看下去:
剛剛的優化2,在普通的情況下表現很好,但是如果區間是這樣:
優化2基本上就萎了\(qwq\)。此時\(l\)、\(r\)指針在整個序列中移來移去,從頭到尾,又從尾到頭。我們發現左右指針最壞情況下均移動了\(O(nm)\)次,\(O(1)\)更新答案,總時間復雜度仍然是\(O(nm)\),在最壞情況下跑得比慢的一批的優化1還慢。盡管如此,我們還是可以繼續優化。
繼續優化?怎么優化?
我們可以考慮把所有查詢區間按左端點排序,從而使左指針最多移動\(O(n)\)次。但這樣的話右端點又是無序的,右指針又讓整體復雜度打回原形。看上去,這個復雜度已經不能再優化了。在這個時候,莫隊算法的出現,給無數OIer帶來了光明(霧)。
至此,你可以把莫隊算法理解為一種暴力,優雅而不失復雜度的暴力,只不過它的剪枝極為巧妙,達到了理想的效果。
2、莫隊算法的基礎實現
1、預處理
莫隊算法優化的核心是分塊和排序。我們將大小為\(n\)的序列分為\(\sqrt{n}\)個塊,從\(1\)到\(\sqrt{n}\)編號,然后根據這個對查詢區間進行排序。一種方法是把查詢區間按照左端點所在塊的序號排個序,如果左端點所在塊相同,再按右端點排序。排完序后我們再進行左右指針跳來跳去的操作,雖然看似沒多大用,但帶來的優化實際上極大。
那么這樣做的實際復雜度是多少呢?下面瞎胡亂搞證明它的復雜度是\(O(n\sqrt{n})\):
0.區間排序
建個結構體,用sort
跑一遍即可。平均復雜度\(O(n\log n)\)。
1.左指針的移動
設每個塊 \(i\) 中分布有 \(x_i\)個左端點,由於莫隊的添加、刪除操作復雜度為\(O(1)\),那么處理塊\(i\)的最壞時間復雜度是\(O(x_i\sqrt{n})\),指針跨越整塊的時間復雜度為O(\sqrt{n}),最壞需要跨越\(n\)次;總復雜度\(O(\sum x_i \sqrt{n}+n\sqrt{n})=O(n\sqrt{n})\)。
2.右指針的移動
設每個塊 \(i\) 中分布有 \(x_i\)個左端點,由於左端點同塊的區間右端點有序,那么對於這\(x_i\)個區間,右端點最壞只需總共\(O(n)\)的時間跳(最壞需跳完整個序列),總共\(\sqrt{n}\)個塊,總復雜度\(O(n\sqrt{n})\);
至此可得出,莫隊算法的總時間復雜度為\(O(n\sqrt{n}) + O(n\sqrt{n}) + O(n\log n) = O(n\sqrt{n})\)。
可見,經過一番看似雞肋的排序之后,這個算法的復雜度猛降了一個根號之多,對於一些不需要寫大常數莫隊而數據范圍巨大的題目來說(如例題),整整一個根號的提升意味着運行時間質的飛躍。
不過經過排序打亂原序之后,這個算法就變成了典型的離線算法,而且這種算法不支持修改。如果遇到強制在線的題目,還要另尋他法。
參考代碼:
查詢區間結構體的排序函數:
int cmp(query a, query b) {
return belong[a.l] == belong[b.l] ? a.r < b.r : belong[a.l] < belong[b.l];
}
2、定策略
雖說莫隊實質是優化后的暴力,但有時候,有些用暴力枚舉很容易處理的數據用莫隊並不容易處理(只能在左右指針處更新),這時候就要我們定好一個更新策略。
一般來說,我們只要找到指針移動一位以后,統計數據與當前數據的差值,找出規律(可以用數學方法或打表),然后每次移動時用這個規律更新就行啦qwq。至於例題……在后面會有噠qwq!
3、碼代碼與查錯
莫隊代碼不長(或者說是很短),但很容易寫錯一些細節。比如自加自減運算符的優先級問題、排序關鍵字問題、分塊大小與sqrt精度問題、還有某些題目中用到的離散化的鍋。所以每次碼完莫隊都別先測樣例(甚至可以先不編譯),先靜態查錯一陣,真的可以幫助你大大減少錯誤的發生。
后面兩點都特別簡單但是沒有特定的模式,其重要性不容小覷。
3、(重點)莫隊的玄學卡常技巧
WARNING:以下內容可能引出大賢者模式,請謹慎思考。
1、#pragma GCC optimize(2)
可以用實踐證明,開了O2的莫隊簡直跑得飛快,連\(1e6\)都能無壓力跑過,甚至可以比不開O2的版本快上4~5倍乃至更多。然而部分OI比賽中O2是禁止的,如果不禁O2的話,那還是開着吧qwq
實在不行,就optimize(3)(逃
2、莫隊玄學奇偶性排序
這個是最玄學的……無力吐槽
這個和莫隊的主算法有異曲同工之妙……看起來卵用都沒有,實際上可以幫你每個點平均優化200ms(可怕)
主要操作:把查詢區間的排序函數
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);
}
也就是說,對於左端點在同一奇數塊的區間,右端點按升序排列,反之降序。這個東西也是看着沒用,但實際效果顯著。
它的主要原理便是右指針跳完奇數塊往回跳時在同一個方向能順路把偶數塊跳完,然后跳完這個偶數塊又能順帶把下一個奇數塊跳完。理論上主算法運行時間減半,實際情況有所偏差。(不過能優化得很爽就對了)
3、移動指針的常數壓縮
我們可以根據運算優先級的知識,把這個:
void add(int pos) {
if(!cnt[aa[pos]]) ++now;
++cnt[aa[pos]];
}
void del(int pos) {
--cnt[aa[pos]];
if(!cnt[aa[pos]]) --now;
}
和這個:
while(l < ql) del(l++);
while(l > ql) add(--l);
while(r < qr) add(++r);
while(r > qr) del(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--]];
能優化將近200ms(怎么又是這個數字)
而且這個優化看上去滿滿的不好搞,但實際上很有用。不過用它來優化千萬要建立在熟練的基礎上,不然會大大增強調試難度,不如不用。
4、手寫快讀、快輸
大多數莫隊題的輸入輸出量還是很大的……I/O優化與否,運行時間差異也很大。而且值得注意的是莫隊經典題中基本沒有輸入輸出負數的情況,不考慮負數又能優化一點小小的常數。
卡常部分到此結束,撒花✿✿ヽ(°▽°)ノ✿(腦補歡呼音效)
講到現在,例題的代碼已經不難寫出。下面給出參考代碼:
#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;
}
4、莫隊算法的擴展——帶修改的莫隊
拋出個例題:Luogu P1903 [國家集訓隊]數顏色 / 維護隊列
題目描述
墨墨購買了一套N支彩色畫筆(其中有些顏色可能相同),擺成一排,你需要回答墨墨的提問。墨墨會向你發布如下指令:
1、 \(Q\) \(L\) \(R\)代表詢問你從第\(L\)支畫筆到第\(R\)支畫筆中共有幾種不同顏色的畫筆。
2、 \(R\) \(P\) \(Col\) 把第\(P\)支畫筆替換為顏色\(Col\)。
為了滿足墨墨的要求,你知道你需要干什么了嗎?
輸入輸出格式
輸入格式:
第1行兩個整數\(N\),\(M\),分別代表初始畫筆的數量以及墨墨會做的事情的個數。
第2行N個整數,分別代表初始畫筆排中第i支畫筆的顏色。
第3行到第2+M行,每行分別代表墨墨會做的一件事情,格式見題干部分。
輸出格式:
對於每一個Query的詢問,你需要在對應的行中給出一個數字,代表第L支畫筆到第R支畫筆中共有幾種不同顏色的畫筆。
輸入輸出樣例
輸入樣例#1:
6 5
1 2 3 4 5 5
Q 1 4
Q 2 6
R 1 2
Q 1 4
Q 2 6
輸出樣例#1:
4
4
3
4
說明
對於\(100%\)的數據,\(N≤50000\),\(M≤50000\),所有的輸入數據中出現的所有整數均大於等於1且不超過\(10^6\)。
前面說過,莫隊算法是離線算法,不支持修改,強制在線需要另尋他法。的確,遇到強制在線的題目莫隊基本上萎了,但是對於某些允許離線的帶修改區間查詢來說,莫隊還是能大展拳腳的。做法就是把莫隊直接加上一維,變為帶修莫隊。
那么加上一維什么呢?具體怎么實現?我們的做法是把修改操作編號,稱為"時間戳",而查詢操作的時間戳沿用之前最近的修改操作的時間戳。跑主算法時定義當前時間戳為 \(t\) ,對於每個查詢操作,如果當前時間戳相對太大了,說明已進行的修改操作比要求的多,就把之前改的改回來,反之往后改。只有當當前區間和查詢區間左右端點、時間戳均重合時,才認定區間完全重合,此時的答案才是本次查詢的最終答案。
通俗地講,就是再弄一指針,在修改操作上跳來跳去,如果當前修改多了就改回來,改少了就改過去,直到次數恰當為止。
這樣,我們當前區間的移動方向從四個(\([l-1,r]\)、\([l+1,r]\)、\([l,r-1]\)、\([l,r+1]\))變成了六個(\([l-1,r,t]\)、\([l+1,r,t]\)、\([l,r-1,t]\)、\([l,r+1,t]\)、\([l,r,t-1]\)、\([l,r,t+1]\)),但是代碼並沒有增加多少,還是很好背的(霧
帶修改莫隊的排序:
其實排序的主要方法還是跟普通莫隊沒兩樣,只不過是加了個關鍵字而已。
排序函數:
int cmp(query a, query b) {
return (belong[a.l] ^ belong[b.l]) ? belong[a.l] < belong[b.l] : ((belong[a.r] ^ belong[b.r]) ? belong[a.r] < belong[b.r] : a.time < b.time);
}
但是實測有時排序寫錯還會快些。。。也許是評測機的鍋吧。
主算法中的修改操作
修改操作其實也沒啥值得注意的,就跟移\(l\)、\(r\)指針一樣,加個對總數的特判就行了。
不過有個代碼長度的小優化——移完 \(t\),做完一處修改后,有可能要改回來,所以我們還要把原值存好備用。但其實我們也可以不存,只要在修改后把修改操作的值和原值swap
一下,那么改回來時也只要swap
一下,swap
兩次相當於沒搞,就改回來了\(qwq\)(所以不還是存了嘛)
分塊大小和復雜度
有的\(dalao\)證明了當塊的大小設\(\sqrt[3]{n^4t}\)時理論復雜度達到最優,但是小蒟蒻我並不能推出來。不過可以證明,塊大小取\(n^{\frac{2}{3}}\)優於取\(\sqrt{n}\)的情況,總體復雜度\(O(n^{\frac{5}{3}})\)。而塊大小取\(\sqrt{n}\)時會退化成\(O(n^2)\),不建議使用。
例題參考代碼:
#include <cstdio>
#include <cstring>
#include <cmath>
#include <algorithm>
using namespace std;
#define maxn 50500
#define maxc 1001000
int a[maxn], cnt[maxc], ans[maxn], belong[maxn];
struct query {
int l, r, time, id;
} q[maxn];
struct modify {
int pos, color, last;
} c[maxn];
int cntq, cntc, n, m, size, bnum;
int cmp(query a, query b) {
return (belong[a.l] ^ belong[b.l]) ? belong[a.l] < belong[b.l] : ((belong[a.r] ^ belong[b.r]) ? belong[a.r] < belong[b.r] : a.time < b.time);
}
#define isdigit(x) ((x) >= '0' && (x) <= '9')
inline 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;
}
int main() {
n = read(), m = read();
size = pow(n, 2.0 / 3.0);
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)
a[i] = read();
for(int i = 1; i <= m; ++i) {
char opt[100];
scanf("%s", opt);
if(opt[0] == 'Q') {
q[++cntq].l = read();
q[cntq].r = read();
q[cntq].time = cntc;
q[cntq].id = cntq;
}
else if(opt[0] == 'R') {
c[++cntc].pos = read();
c[cntc].color = read();
}
}
sort(q + 1, q + cntq + 1, cmp);
int l = 1, r = 0, time = 0, now = 0;
for(int i = 1; i <= cntq; ++i) {
int ql = q[i].l, qr = q[i].r, qt = q[i].time;
while(l < ql) now -= !--cnt[a[l++]];
while(l > ql) now += !cnt[a[--l]]++;
while(r < qr) now += !cnt[a[++r]]++;
while(r > qr) now -= !--cnt[a[r--]];
while(time < qt) {
++time;
if(ql <= c[time].pos && c[time].pos <= qr) now -= !--cnt[a[c[time].pos]] - !cnt[c[time].color]++;
swap(a[c[time].pos], c[time].color);
}
while(time > qt) {
if(ql <= c[time].pos && c[time].pos <= qr) now -= !--cnt[a[c[time].pos]] - !cnt[c[time].color]++;
swap(a[c[time].pos], c[time].color);
--time;
}
ans[q[i].id] = now;
}
for(int i = 1; i <= cntq; ++i)
printf("%d\n", ans[i]);
return 0;
}
5、莫隊算法的擴展——樹上莫隊
前面我們所使用的莫隊都是在一維的序列上進行,即使加了一維的時間軸,但是主題還是一維序列。那么樹上統計問題能否用莫隊來處理呢?答案是肯定的。
不要認為這個東西很高級,實際上它還是個序列。
問題一:子樹統計
子樹統計算是這里面最簡單的了。在原樹上跑一遍dfs序,然后發現一顆子樹其實就是里面一段固定區間……
邊跑dfs邊弄子樹對應的左右端點即可。
這里序列的長度=結點的個數。
實際上子樹上的統計完全不需要莫隊,傳個標記就可以\(O(nlogn)\)了
問題二:路徑上的統計
比如說這個題目:SP10707 COT2 - Count on a tree II
題目描述
給定一個n個節點的樹,每個節點表示一個整數,問u到v的路徑上有多少個不同的整數。
輸入格式
第一行有兩個整數n和m(n=40000,m=100000)。
第二行有n個整數。第i個整數表示第i個節點表示的整數。
在接下來的n-1行中,每行包含兩個整數u v,描述一條邊(u,v)。
在接下來的m行中,每一行包含兩個整數u v,詢問u到v的路徑上有多少個不同的整數。
輸出格式
對於每個詢問,輸出結果。
輸入輸出樣例
輸入樣例#1:
8 2
105 2 9 3 8 5 7 7
1 2
1 3
1 4
3 5
3 6
3 7
4 8
2 5
7 8
輸出樣例#1:
4
4
這還不簡單嗎?dfs序一遍找區間……誒?區間呢qwq?
參照上圖,我可以負責地告訴你:普通dfs序是完全不行的(因為區間沒有對應關系)。
但是還好我們有歐拉序,這是一種特殊的dfs序,可以解決很多普通dfs序解決不了的問題(就比如我們的樹上莫隊)。
那歐拉序有什么特點呢?怎么求它?
還是那張圖,我們對它求一遍歐拉序:
這是個什么東西?!它怎么求得的暫且不談(不過你也應該已經知道了),先看看它的性質:
我們看一看每個編號出現的次數——兩次,無一例外。再看看它出現的兩個位置有什么特點:
我們以編號\(2\)為例,它出現在位置2和9,它中間的編號有\(4×2\)、\(7×2\)、\(5×2\)。
再觀察這棵樹,誒,這些編號不都是\(2\)的子樹上的結點嗎??!
就這樣,我們得出它的一條性質:樹的歐拉序上兩個相同編號(設為\(x\))之間的所有編號都出現兩次,且都位於\(x\)子樹上。(前半句話其實可以由后半句話間接證明)
它的求法也很簡單,在剛dfs到一個點時加入序列,最后退出時也加入一遍。現在知道這個性質的來源了吧qwq
那么為什么用歐拉序可以把路徑搬到區間上呢?我們來看一下這張圖:
我們在歐拉序中找到路徑\(1\rightarrow10\)起點(1)終點(10)的位置。我們發現,我們完全可以在找到對應的區間(綠色部分),而由於其中有一些點出現了兩次,這些出現了兩次的點可以證明不在路徑上(路徑不會經過一個點兩次,而如果只經過一次則不會出現兩個相同的編號),所以出現了兩次的點我們不予算入。
那我們嘗試找一下\(2\rightarrow 6\)對應的區間吧。唔,這還不簡單嗎,不就是2、4、7……3、6……嗯?1哪去了?1呢?^1可是他們的\(lca\)啊!!看來這樣單純的找區間還是不行的,還有其他特殊方法。
具體做法:設每個點的編號\(a\)首次出現的位置\(first[a]\),最后出現的位置為\(last[a]\),那么對於路徑\(x\rightarrow y\),設\(first[x]<=first[y]\)(不滿足則swap
,這個操作的意義在於,如果\(x\)、\(y\)在一條鏈上,則\(x\)一定是\(y\)的祖先或等於\(y\)),如果\(lca(x,y)=x\),則直接把\([first[x],first[y]]\)的區間扯過來用,反之使用\([last[x],first[y]]\)區間(為什么不用\([first[x],first[y]]\)?因為\((first[x],last[x])\)不會在路徑上,根據性質,里面的編號都會出現兩次,考慮了等於沒考慮),但這個區間內不包含\(x\)和\(y\)的最近公共祖先,查詢的時候加上即可。
注意,這里序列長度為\(2×n\),千萬不要在這T了啊……qwq
做完了這些,樹上莫隊的其他東西就和普通莫隊差不多啦。值得注意的是,我們又可以像上文的帶修莫隊那樣優化代碼長度——由於無需考慮的點會出現兩次,我們可以弄一個標記數組(標記結點是否被訪問),沒訪問就加,訪問過就刪,每次操作把標記·異或個1,完美解決所有添加、刪除、去雙問題。
例題參考代碼:
#include <cstdio>
#include <cstring>
#include <cmath>
#include <algorithm>
using namespace std;
#define maxn 200200
#define isdigit(x) ((x) >= '0' && (x) <= '9')
inline 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;
}
int aa[maxn], cnt[maxn], first[maxn], last[maxn], ans[maxn], belong[maxn], inp[maxn], vis[maxn], ncnt, l = 1, r, now, size, bnum; //莫隊相關
int ord[maxn], val[maxn], head[maxn], depth[maxn], fa[maxn][30], ecnt;
int n, m;
struct edge {
int to, next;
} e[maxn];
void adde(int u, int v) {
e[++ecnt] = (edge){v, head[u]};
head[u] = ecnt;
e[++ecnt] = (edge){u, head[v]};
head[v] = ecnt;
}
void dfs(int x) {
ord[++ncnt] = x;
first[x] = ncnt;
for(int k = head[x]; k; k = e[k].next) {
int to = e[k].to;
if(to == fa[x][0]) continue;
depth[to] = depth[x] + 1;
fa[to][0] = x;
for(int i = 1; (1 << i) <= depth[to]; ++i) fa[to][i] = fa[fa[to][i - 1]][i - 1];
dfs(to);
}
ord[++ncnt] = x;
last[x] = ncnt;
}
int getlca(int u, int v) {
if(depth[u] < depth[v]) swap(u, v);
for(int i = 20; i + 1; --i)
if(depth[u] - (1 << i) >= depth[v]) u = fa[u][i];
if(u == v) return u;
for(int i = 20; i + 1; --i)
if(fa[u][i] != fa[v][i]) u = fa[u][i], v = fa[v][i];
return fa[u][0];
}
struct query {
int l, r, lca, 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);
}
void work(int pos) {
vis[pos] ? now -= !--cnt[val[pos]] : now += !cnt[val[pos]]++;
vis[pos] ^= 1;
}
int main() {
n = read(); m = read();
for(int i = 1; i <= n; ++i)
val[i] = inp[i] = read();
sort(inp + 1, inp + n + 1);
int tot = unique(inp + 1, inp + n + 1) - inp - 1;
for(int i = 1; i <= n; ++i)
val[i] = lower_bound(inp + 1, inp + tot + 1, val[i]) - inp;
for(int i = 1; i < n; ++i) adde(read(), read());
depth[1] = 1;
dfs(1);
size = sqrt(ncnt), bnum = ceil((double) ncnt / size);
for(int i = 1; i <= bnum; ++i)
for(int j = size * (i - 1) + 1; j <= i * size; ++j) belong[j] = i;
for(int i = 1; i <= m; ++i) {
int L = read(), R = read(), lca = getlca(L, R);
if(first[L] > first[R]) swap(L, R);
if(L == lca) {
q[i].l = first[L];
q[i].r = first[R];
}
else {
q[i].l = last[L];
q[i].r = first[R];
q[i].lca = lca;
}
q[i].id = i;
}
sort(q + 1, q + m + 1, cmp);
for(int i = 1; i <= m; ++i) {
int ql = q[i].l, qr = q[i].r, lca = q[i].lca;
while(l < ql) work(ord[l++]);
while(l > ql) work(ord[--l]);
while(r < qr) work(ord[++r]);
while(r > qr) work(ord[r--]);
if(lca) work(lca);
ans[q[i].id] = now;
if(lca) work(lca);
}
for(int i = 1; i <= m; ++i) printf("%d\n", ans[i]);
return 0;
}
6、莫隊算法的擴展——回滾莫隊
莫隊維護區間統計信息雖然方便,但在某些場合下卻非常雞肋。比如如下這題:
AT1219 [JOI2013]歴史の研究
題目描述
IOI國歷史研究的第一人——JOI教授,最近獲得了一份被認為是古代IOI國的住民寫下的日記。JOI教授為了通過這份日記來研究古代IOI國的生活,開始着手調查日記中記載的事件。
日記中記錄了連續N天發生的時間,大約每天發生一件。
事件有種類之分。第i天\((1<=i<=N)\)發生的事件的種類用一個整數\(X_i\)表示,\(X_i\)越大,事件的規模就越大。
JOI教授決定用如下的方法分析這些日記:
\(1\).選擇日記中連續的一些天作為分析的時間段
\(2\).事件種類t的重要度為t×(這段時間內重要度為t的事件數)
\(3\).計算出所有事件種類的重要度,輸出其中的最大值
現在你被要求制作一個幫助教授分析的程序,每次給出分析的區間,你需要輸出重要度的最大值。
輸入格式
第一行兩個空格分隔的整數\(N\)和\(Q\),表示日記一共記錄了\(N\)天,詢問有\(Q\)次。
接下來一行N個空格分隔的整數\(X_1\)...\(X_N\),\(X_i\)表示第\(i\)天發生的事件的種類
接下來Q行,第i行\((1<=i<=Q)\)有兩個空格分隔整數\(A_i\)和\(B_i\),表示第i次詢問的區間為\([A_i,B_i]\)。
輸入輸出樣例
輸入樣例#1:
5 5
9 8 7 8 9
1 2
3 4
4 4
1 4
2 4
輸出樣例#1:
9
8
8
16
16
題目到手很快就能想到用莫隊維護這個最大值,添加值很好做,直接加個計數器,然后乘一下取個max就完事了。然后刪除……不會。想到的唯一辦法就是在當前計數器清零后往前枚舉,找到一個可行的最大值再替換。這樣的復雜度會多一維,達到\(O(n^2\sqrt{n})\),還不如直接n方暴力,說不定就能過百萬了呢
此時,由於莫隊的無敵(霧),有神犇發明了一個玄學高效的算法,復雜度最壞\(O(n\sqrt{n})\),而且常數碾壓同為\(O(n\sqrt{n})\)的塊狀數組做法。
我們觀察莫隊的性質:左端點在同一塊中的所有查詢區間右端點單調遞增。這樣,對於左端點在同一塊中的每個區間,我們都可以\(O(n)\)解決所有的右端點,且不用回頭刪除值(單調遞增)。考慮枚舉每個塊,總共需要枚舉\(\sqrt{n}\)個塊,這部分的總復雜度\(O(n\sqrt{n})\)。
又對於每個塊內的左端點:假設每個塊內的每個左端點都從塊右端開始統計,每次都重新開始暴力統計一次,做完每個左端點復雜度\(O(\sqrt{n})\),共\(n\)個左端點,總復雜度\(O(n\sqrt{n})\)。
我們發現這兩部分是很容易結合起來的。做法就是枚舉每個塊,每次把\(l\)、\(r\)指針置於塊尾+1的位置和塊尾(至於為什么+1還請看前面),先暴力處理掉左右端點在一塊的特殊情況(\(O(\sqrt{n})\)),然后右端點暴力向右推,左端點一個個解決,在移動左指針前紀錄一下當前狀態,移動保存值后復原即可,也無需刪除。以上的問題完美解決。(豈不美滋滋??#滑稽#)
注意暴力和正常推指針時的\(cnt\)不要共用,而且每做一個新塊都要把\(cnt\)清零。這樣回滾莫隊代碼不難寫出啦(難調啊):
例題參考代碼:
#include <cstdio>
#include <algorithm>
#include <cstring>
#include <cmath>
using namespace std;
#define maxn 100100
#define maxb 5050
#define ll long long
int aa[maxn], typ[maxn], cnt[maxn], cnt2[maxn], belong[maxn], lb[maxn], rb[maxn], inp[maxn];
ll ans[maxn];
struct query {
int l, r, id;
} q[maxn];
int n, m, size, bnum;
#define isdigit(x) ((x) >= '0' && (x) <= '9')
inline 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;
}
int cmp(query a, query b) {
return (belong[a.l] ^ belong[b.l]) ? belong[a.l] < belong[b.l] : a.r < b.r;
}
int main() {
n = read(), m = read();
size = sqrt(n);
bnum = ceil((double) n / size);
for(int i = 1; i <= bnum; ++i) {
lb[i] = size * (i - 1) + 1;
rb[i] = size * i;
for(int j = lb[i]; j <= rb[i]; ++j) belong[j] = i;
}
rb[bnum] = n;
for(int i = 1; i <= n; ++i) inp[i] = aa[i] = read();
sort(inp + 1, inp + n + 1);
int tot = unique(inp + 1, inp + n + 1) - inp - 1;
for(int i = 1; i <= n; ++i) typ[i] = lower_bound(inp + 1, inp + tot + 1, aa[i]) - inp;
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 i = 1;
for(int k = 0; k <= bnum; ++k) {
int l = rb[k] + 1, r = rb[k];
ll now = 0;
memset(cnt, 0, sizeof(cnt));
for( ; belong[q[i].l] == k; ++i) {
int ql = q[i].l, qr = q[i].r;
ll tmp;
if(belong[ql] == belong[qr]) {
tmp = 0;
for(int j = ql; j <= qr; ++j) cnt2[typ[j]] = 0;
for(int j = ql; j <= qr; ++j) {
++cnt2[typ[j]]; tmp = max(tmp, 1ll * cnt2[typ[j]] * aa[j]);
}
ans[q[i].id] = tmp;
continue;
}
while(r < qr) {
++r; ++cnt[typ[r]]; now = max(now, 1ll * cnt[typ[r]] * aa[r]);
}
tmp = now;
while(l > ql){
--l; ++cnt[typ[l]]; now = max(now, 1ll * cnt[typ[l]] * aa[l]);
}
ans[q[i].id] = now;
while(l < rb[k] + 1) {
--cnt[typ[l]];
l++;
}
now = tmp;
}
}
for(int i = 1; i <= m; ++i) printf("%lld\n", ans[i]);
return 0;
}
注意這里分塊時有個坑點:向上取整的ceil
不要寫成floor
,這樣在普通莫隊中會多出一個塊0,完全不影響AC,但在回滾莫隊中就是WA,WA到我渾身不得勁qwq
回滾莫隊完結撒花✿✿ヽ(°▽°)ノ✿qwq
7、練習題
這里放的主要是莫隊裸題,沒有與其他算法的綜合應用,但部分有思維難度。如需要綜合應用題請左轉【Luogu OJ】 右轉【BZOJ】
雖然莫隊算法思想很簡單,但與它有關的應用還是很經(du)典(liu)的。下面是一些經(du)典(liu)的例題:
1、【基礎題】Luogu P2709 小B的詢問
這題的話,手推公式很容易就能做出來,而且題目無坑點,甚至無需卡常
代碼不給了qwq,和例題差不了兩句話qwq
2、【進階題】Luogu P1494 [國家集訓隊]小Z的襪子
這題需要一些基礎但較為復雜的數學演算,打表基本不靠譜(另外還要注意約分)
留給大家自行推理(代碼還是很簡單噠qwq)
3、【進階題】Luogu P3709 大爺的字符串題
內個,題意別看了,題目要求的是區間眾數的出現次數(出題人語文不好)(當然你也可以直接把題意推出來qwq)
即便題意明了了,這題還是有點綜合性的(區間眾數這東西並不好求)
真是一道好(du)題(liu)(逃
4、【最終挑戰】Luogu P4074 [WC2013]糖果公園
承諾的黑題終於來啦,撒花✿✿ヽ(°▽°)ノ✿
然而這題並不難,樹上帶修莫隊模板題,相信你很快就能切掉它qwq
參考代碼:
#include <cstdio>
#include <cstring>
#include <cmath>
#include <algorithm>
using namespace std;
#define maxn 200200
#define ll long long
int cnt[maxn], aa[maxn], belong[maxn], inp[maxn], n, m, Q, ncnt, size, bnum, w[maxn], v[maxn], ccnt, qcnt;
int val[maxn], fa[maxn][30], depth[maxn], head[maxn], ecnt;
int fir[maxn], la[maxn], vis[maxn];
int l = 1, r = 0, t = 0;
ll now, ans[maxn];
struct edge {
int to, next;
} e[maxn];
void adde(int u, int v) {
e[++ecnt] = (edge){v, head[u]};
head[u] = ecnt;
e[++ecnt] = (edge){u, head[v]};
head[v] = ecnt;
}
void dfs(int x) {
aa[++ncnt] = x;
fir[x] = ncnt;
for(int k = head[x]; k; k = e[k].next) {
int to = e[k].to;
if(depth[to]) continue;
depth[to] = depth[x] + 1;
fa[to][0] = x;
for(int i = 1; (1 << i) <= depth[to]; ++i) fa[to][i] = fa[fa[to][i - 1]][i - 1];
dfs(to);
}
aa[++ncnt] = x;
la[x] = ncnt;
}
int getlca(int u, int v) {
if(depth[u] < depth[v]) swap(u, v);
for(int i = 20; i + 1; --i) if(depth[fa[u][i]] >= depth[v]) u = fa[u][i];
if(u == v) return u;
for(int i = 20; i + 1; --i) if(fa[u][i] != fa[v][i]) u = fa[u][i], v = fa[v][i];
return fa[u][0];
}
struct query {
int l, r, id, lca, t;
} q[maxn];
int cmp(query a, query b) {
return (belong[a.l] ^ belong[b.l]) ? belong[a.l] < belong[b.l] : ((belong[a.r] ^ belong[b.r]) ? belong[a.r] < belong[b.r] : a.t < b.t );
}
inline void add(int pos) {
now += 1ll * v[val[pos]] * w[++cnt[val[pos]]];
}
inline void del(int pos) {
now -= 1ll * v[val[pos]] * w[cnt[val[pos]]--];
}
inline void work(int pos) {
vis[pos] ? del(pos) : add(pos);
vis[pos] ^= 1;
}
struct change {
int pos, val;
} ch[maxn];
void modify(int x) {
if(vis[ch[x].pos]) {
work(ch[x].pos);
swap(val[ch[x].pos], ch[x].val);
work(ch[x].pos);
}
else swap(val[ch[x].pos], ch[x].val);
}
#define isdigit(x) ((x) >= '0' && (x) <= '9')
inline 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;
}
int main() {
n = read(), m = read(), Q = read();
for(int i = 1; i <= m; ++i) v[i] = read();
for(int i = 1; i <= n; ++i) w[i] = read();
for(int i = 1; i < n; ++i) {
int u = read(), v = read();
adde(u, v);
}
for(int i = 1; i <= n; ++i) val[i] = read();
depth[1] = 1;
dfs(1);
size = pow(ncnt, 2.0 / 3.0);
bnum = ceil((double)ncnt / size);
for(int i = 1; i <= bnum; ++i)
for(int j = size * (i - 1) + 1; j <= i * size; ++j) belong[j] = i;
for(int i = 1; i <= Q; ++i) {
int opt = read(), a = read(), b = read();
if(opt) {
int lca = getlca(a, b);
q[++qcnt].t = ccnt;
q[qcnt].id = qcnt;
if(fir[a] > fir[b]) swap(a, b);
if(a == lca) q[qcnt].l = fir[a], q[qcnt].r = fir[b];
else q[qcnt].l = la[a], q[qcnt].r = fir[b], q[qcnt].lca = lca;
}
else {
ch[++ccnt].pos = a;
ch[ccnt].val = b;
}
}
sort(q + 1, q + qcnt + 1, cmp);
for(int i = 1; i <= qcnt; ++i) {
int ql = q[i].l, qr = q[i].r, qt = q[i].t, qlca = q[i].lca;
while(l < ql) work(aa[l++]);
while(l > ql) work(aa[--l]);
while(r < qr) work(aa[++r]);
while(r > qr) work(aa[r--]);
while(t < qt) modify(++t);
while(t > qt) modify(t--);
if(qlca) work(qlca);
ans[q[i].id] = now;
if(qlca) work(qlca);
}
for(int i = 1; i <= qcnt; ++i) printf("%lld\n", ans[i]);
return 0;
}
5、其它
自己找qwq,百度是個很好的東西qwq
尾聲
耗時將近兩天的長篇大論終於要結束啦,再來無恥的求一波贊qwq(逃
感謝各位堅持着看過來(霧)的dalao,文章如有錯誤歡迎指出哦qwq