胡小兔的良心莫隊教程:莫隊、帶修改莫隊、樹上莫隊


在開始學習莫隊之前,照例先甩一道例題:BZOJ 1878 HH的項鏈

題意:求區間內數的個數,相同的數只算一次。

我關於這道題的上一篇題解中,我使用了主席樹來在線做這道題;在洛谷的一道類似題中,我使用了分塊;而如果不要求在線,這道題還有一種極其好寫的方法——莫隊。

什么是莫隊?

莫隊不是一種叫做莫的隊列(我第一次聽到這個名字時竟然是這么理解的 -_-|||),它是以發明人前國家隊隊長莫濤——“莫隊”的名字命名的。

它是一種傳說中“能解決一切區間問題”的算法。首先,我們先來學習最簡單的莫隊——可離線、無修改的莫隊。

可離線、無修改的莫隊

莫隊算法的精髓就是通過合理地對詢問排序,然后以較優的順序暴力回答每個詢問。處理完一個詢問后,可以使用它的信息得到下一個詢問區間的答案。

考慮這個問題:對於上面這道題,已知區間 \([1, 5]\) 的答案,求 \([2, 6]\) 的答案,如何暴力求?

當然,可以將區間 \([2, 6]\) 從頭到尾掃一遍,直接求出答案,也可以在區間 \([1, 5]\) 的基礎上,去掉位置\(1\)(即將左端點右移一位),加上位置\(6\)(即將右端點右移一位),得到區間 \([2, 6]\) 的答案。

在莫隊算法中,我們可以使用第二種求答案的方法。至於為什么要用這個貌似與前面那種方法復雜度毫無區別的方法?當然是因為經過“合理的排序”后,這種方法可以被優化啦。

接下來我們還需要考慮一個問題:如何“合理地對詢問排序”?

莫隊提供了這樣一個排序方案:將原序列以\(\sqrt n\)為一塊進行分塊,排序第一關鍵字是詢問的左端點所在塊的編號,第二關鍵字是詢問的右端點本身的位置,都是升序。然后我們用上面提到的“移動當前區間左右端點”的方法,按順序求每個詢問區間的答案,移動每一個詢問區間左右端點可以求出下一個區間的答案。

具體的核心部分代碼:

sort(q + 1, q + m + 1); //將詢問排序
int ql = 1, qr = 0; //初始區間是一個空區間
for(int i = 1; i <= m; i++){
	while(pl < q[i].l) del(a[pl++]); // 
	while(pl > q[i].l) add(a[--pl]);
	while(pr < q[i].r) add(a[++pr]);
	while(pr > q[i].r) del(a[pr--]);
	ans[q[i].id] = sum;
}

這樣就可以求出答案了!

——可是,這樣做的復雜度是什么?

顯然,每次移動左端點(或右端點)的復雜度都是\(O(1)\)。那么只需要知道左右端點分別移動了多少次,就可以知道復雜度了!

對於右端點:當當前詢問的左端點在同一塊時,右端點都是有序的,那么右端點最多會從1一直移動到n;兩個詢問左端點在不同塊(即“跨塊”)時,最多從n一下子移回1。兩種都是\(O(n)\)的,總共有\(\sqrt n\)塊,所以復雜度是\(O(n \sqrt n)\)

對於左端點:當當前詢問的左端點在同一塊時,注意左端點不是有序的,那么一次最多從塊的一端移到另一端,復雜度\(O(\sqrt n)\),總共有n個詢問的話,復雜度是\(O(\sqrt n)\)。跨塊時也類似,移動距離也是\(O(\sqrt n)\)

綜上,莫隊的復雜度是\(O(\sqrt n)\)

莫隊的一大優點是:代碼思路極其簡單,尤其是序列上無需修改的莫隊,核心代碼只有五行:

while(pl < q[i].l) del(a[pl++]);
while(pl > q[i].l) add(a[--pl]);
while(pr < q[i].r) add(a[++pr]);
while(pr > q[i].r) del(a[pr--]);
ans[q[i].id] = sum;

其中 \(pl\)\(pr\)一開始是上一次詢問的左右端點,結束后變成了這一次詢問的左右端點。\(sum\)維護當前區間\([pl, pr]\)的答案。

BZOJ 1878 HH的項鏈我的AC代碼:

#include <cstdio>
#include <cstring>
#include <algorithm>
#include <set>
using namespace std;
typedef long long ll;
#define space putchar(' ')
#define enter putchar('\n')
template <class T>
void read(T &x){
    char c;
    bool op = 0;
    while(c = getchar(), c < '0' || c > '9')
	if(c == '-') op = 1;
    x = c - '0';
    while(c = getchar(), c >= '0' && c <= '9')
	x = x * 10 + c - '0';
    if(op) x = -x;
}
template <class T>
void write(T x){
    if(x < 0) x = -x, putchar('-');
    if(x >= 10) write(x / 10);
    putchar('0' + x % 10);
}
const int N = 50005, M = 200005, B = 233;
int n, m, a[N], sum, ans[M], cnt[1000005];
#define bel(x) ((x - 1) / B + 1)
struct query {
    int id, l, r;
    bool operator < (const query &b) const{
	return bel(l) == bel(b.l) ? r < b.r : l < b.l;
    }
} q[M];
void add(int x){
    if(!cnt[x]) sum++;
    cnt[x]++;
}
void del(int x){
    cnt[x]--;
    if(!cnt[x]) sum--;
}
int main(){
    read(n);
    for(int i = 1; i <= n; i++) read(a[i]);
    read(m);
    for(int i = 1; i <= m; i++)
	q[i].id = i, read(q[i].l), read(q[i].r);
    sort(q + 1, q + m + 1);
    int pl = 1, pr = 0;
    for(int i = 1; i <= m; i++){
	while(pl < q[i].l) del(a[pl++]);
	while(pl > q[i].l) add(a[--pl]);
	while(pr < q[i].r) add(a[++pr]);
	while(pr > q[i].r) del(a[pr--]);
	ans[q[i].id] = sum;
    }
    for(int i = 1; i <= m; i++)
	write(ans[i]), enter;
    return 0;
}

可以單點修改的莫隊

寫完了上面這道題,可以發現:普通的莫隊算法沒有支持修改。那么如何改造該算法使它支持修改呢?

莫隊算法被稱為“優雅的暴力”,那么我們改造莫隊算法的思路也只有一個:改造詢問排序的方式,然后繼續暴力。

這一次,排序的方式是:以\(n^{\frac{2}{3}}\)為一塊,一共將序列分為\(n^{\frac{1}{3}}\)塊。排序第一關鍵字是左端點所在塊編號,第二關鍵字是右端點所在塊編號,第三關鍵字是時間。

每次回答詢問時,先從上一個詢問的時間“穿越”到當前詢問的時間:如果當前詢問的時間更靠后,則順序執行所有修改,直到達到當前詢問時間;如果當前詢問的時間更靠前,則“時光倒流”,還原所有多余的修改。進行推移時間的操作時,如果涉及到當前區間內的位置的修改,要對答案進行相應的維護。

接下來我們來簡要地證明一下復雜度!(不是非常關心證明的同學,可以直接跳到結論部分……)

推移時間、移動左端點、移動右端點的操作都是\(O(1)\)的。

對於時間的移動,對於左右端點所在塊不變的情況,時間是單調向右移的,總共\(O(n)\); 左右端點之一所在塊改變,時間最多從\(n\)直接移回\(1\),復雜度\(O(n)\);左右端點所在塊各有\(O(n^{\frac{1}{3}})\)種,兩兩組合有\(O(n^{\frac{2}{3}})\)種,每種都是\(O(n)\),總復雜度是\(O(n^{\frac{5}{3}})\)

對於右端點的移動,在左右端點所在塊不變時,每次最多移動\(n^{\frac{2}{3}}\),一共最多有\(n\)次右端點的移動,復雜度是\(O(n^{\frac{5}{3}})\);當左端點所在塊改變時,右端點最多從\(n\)一直移動到\(1\),距離是\(n\),最多有\(n^{\frac{1}{3}}\)次這樣的移動,復雜度是\(O(n^{\frac{4}{3}})\);總共右端點移動的復雜度是\(O(n^{\frac{5}{3}})\)

對於左端點的移動,在左端點塊不變時,一次移動距離最多\(n^{\frac{2}{3}}\),總共\(O(n^{\frac{5}{3}})\)。而跨塊時,由於左端點所在塊是單調向右移動的,復雜度最大的情況就是每跨一個塊都是從前一個塊的最左側跑到后一個塊的最右側,距離\(O(n^{\frac{1}{3}})\),總復雜度\(O(n)\)。所以總共左端點移動的復雜度是\(O(n^{\frac{5}{3}})\)

結論:上述排序方法實現的莫隊復雜度是\(O(n^{\frac{5}{3}})\)

想試一試?可以做一下這道題:BZOJ 2120 數顏色。這道題也可以使用分塊+塊內排序二分來做,我也用這個方法寫過一篇題解,歡迎閱讀。

這道題我的莫隊AC代碼:

#include <cstdio>
#include <cstring>
#include <algorithm>
using namespace std;
typedef long long ll;
#define space putchar(' ')
#define enter putchar('\n')
template <class T>
void read(T &x){
    char c;
    bool op = 0;
    while(c = getchar(), c < '0' || c > '9')
        if(c == '-') op = 1;
    x = c - '0';
    while(c = getchar(), c >= '0' && c <= '9')
        x = x * 10 + c - '0';
    if(op) x = -x;
}
template <class T>
void write(T x){
    if(x < 0) x = -x, putchar('-');
    if(x >= 10) write(x / 10);
    putchar('0' + x % 10);
}
const int N = 10005, M = 1000005, B = 464;
int n, m, pl = 1, pr = 0, cur, res, ans[N], a[N], cnt[M];
int idxC, idxQ, tim[N], pos[N], val[N], pre[N];
#define bel(x) (((x) - 1) / B + 1)
struct query {
    int id, tim, l, r;
    bool operator < (const query &b) const {
        if(bel(l) != bel(b.l)) return l < b.l;
        if(bel(r) != bel(b.r)) return r < b.r;
        return id < b.id;
    }
} q[N];
void change_add(int cur){
    if(pos[cur] >= pl && pos[cur] <= pr){
        cnt[a[pos[cur]]]--;
        if(!cnt[a[pos[cur]]]) res--;
    }
    pre[cur] = a[pos[cur]];
    a[pos[cur]] = val[cur];
    if(pos[cur] >= pl && pos[cur] <= pr){
        if(!cnt[a[pos[cur]]]) res++;
        cnt[a[pos[cur]]]++;
    }
}
void change_del(int cur){
    if(pos[cur] >= pl && pos[cur] <= pr){
        cnt[a[pos[cur]]]--;
        if(!cnt[a[pos[cur]]]) res--;
    }
    a[pos[cur]] = pre[cur];
    if(pos[cur] >= pl && pos[cur] <= pr){
        if(!cnt[a[pos[cur]]]) res++;
        cnt[a[pos[cur]]]++;
    }
}
void change(int now){
    while(cur < idxC && tim[cur + 1] <= now) change_add(++cur);
    while(cur && tim[cur] > now) change_del(cur--);
}
void add(int p){
    if(!cnt[a[p]]) res++;
    cnt[a[p]]++;
}
void del(int p){
    cnt[a[p]]--;
    if(!cnt[a[p]]) res--;
}
bool isQ(){
    char op[2];
    scanf("%s", op);
    return op[0] == 'Q';
}
int main(){
    read(n), read(m);
    for(int i = 1; i <= n; i++) read(a[i]);
    for(int i = 1; i <= m; i++){
        if(isQ()) idxQ++, q[idxQ].id = idxQ, q[idxQ].tim = i, read(q[idxQ].l), read(q[idxQ].r);
        else tim[++idxC] = i, read(pos[idxC]), read(val[idxC]);
    }
    sort(q + 1, q + idxQ + 1);
    for(int i = 1; i <= idxQ; i++){
        change(q[i].tim);
        while(pl > q[i].l) add(--pl);
        while(pr < q[i].r) add(++pr);
        while(pl < q[i].l) del(pl++);
        while(pr > q[i].r) del(pr--);
        ans[q[i].id] = res;
    }
    for(int i = 1; i <= idxQ; i++)
        write(ans[i]), enter;
    return 0;
}

樹上莫隊

在序列中,莫隊算法號稱“可以解決一切區間問題”;而把莫隊算法搬到樹上,它在某種程度上也可以“解決一切樹上路徑問題”。

學習樹上莫隊,需要以下預備知識:

首先,我們要對樹進行分塊!如何分塊?請參考 BZOJ 1086 的分塊方法,歡迎參考我的題解。這個方法可以保證每一塊的大小都在\([B, 3B]\)之間。(我不知道為什么世界上會有這樣一道題,簡直是出題人為以后打算學樹上莫隊的選手提供完美的練習題啊……)

然后我們還是要對所有詢問進行排序。排序依據是(假設我們做的是帶修改樹上莫隊,一塊的大小是\(n^{\frac{2}{3}}\))左端點所在塊的編號、右端點所在塊的編號、時間。

與上面的“數顏色”這道題類似,這道題也是使用“時間推移”和“時間倒流”的技能實現修改操作。序列莫隊中的“左右端點”的概念,在樹上莫隊中對應着“起點終點”。

最后一個重要的問題就是:如何移動起點終點?

在序列中,左右端點的移動方式是顯然的,一個端點的移動只有兩個方向——左和右,而它們帶來的影響也是顯然的——區間增加一個元素或刪除一個元素。

然而樹上莫隊卻不是非常顯然……最佳的方案是:維護一個\(vis\)布爾數組,記錄每個節點是否在當前處理的路徑上(LCA非常難辦,我們在維護路徑上的點時不包括LCA,求答案的時候臨時把LCA加上)。每次從上一個詢問\((u_s, v_s)\)轉移到當前詢問\((u_t, v_t)\)時,我們要做的是——把路徑\((u_s, u_t)\)\((v_s, v_t)\)上的點的vis逐個取反,同時對應地維護答案。

對於上面的做法的正確性,VFleaKing的博客中有證明,證明部分摘錄如下(Xor表示類似異或的操作,即節點出現兩側會消掉):

T(v, u) = S(root, v) xor S(root, u)(摘者注:顯然等式右側是u到v的路徑上除lca以外的點)
觀察將curV移動到targetV前后T(curV, curU)變化:
T(curV, curU) = S(root, curV) xor S(root, curU)
T(targetV, curU) = S(root, targetV) xor S(root, curU)
取對稱差:
T(curV, curU) xor T(targetV, curU)= (S(root, curV) xor S(root, curU)) xor (S(root, targetV) xor S(root, curU))
由於對稱差的交換律、結合律:
T(curV, curU) xor T(targetV, curU)= S(root, curV) xor S(root, targetV)
兩邊同時xor T(curV, curU):
T(targetV, curU)= T(curV, curU) xor S(root, curV) xor S(root, targetV)
發現最后兩項很爽……哇哈哈
T(targetV, curU)= T(curV, curU) xor T(curV, targetV)

當然,你也可以畫個圖,感性地理解一下這種操作……

那么接下來我們看一道例題!WC2013, BZOJ3052, UOJ58 糖果公園

我在這里貼一下我的代碼供大家參考。

#include <cstdio>
#include <cmath>
#include <cstring>
#include <algorithm>
using namespace std;
typedef long long ll;
template <class T>
void read(T &x){
    char c;
    bool op = 0;
    while(c = getchar(), c < '0' || c > '9')
	if(c == '-') op = 1;
    x = c - '0';
    while(c = getchar(), c >= '0' && c <= '9')
	x = x * 10 + c - '0';
    if(op) x = -x;
}
template <class T>
void write(T x){
    if(x < 0) putchar('-'), x = -x;
    if(x >= 10) write(x / 10);
    putchar('0' + x % 10);
}
#define space putchar(' ')
#define enter putchar('\n')

const int N = 200005, B = 2005;
int n, maxcol, m, bel[N], idx, stk[N], top, pu, pv, cur;
int cntQ, cntC, tim[N], pos[N], newx[N], col[N], pre[N], cnt[N];
ll val[N], wei[N], res, ans[N];
int ecnt, go[2*N], nxt[2*N], adj[N];
int fa[N], lg[2*N], dep[N], seq[2*N], seq_cnt, seq_pos[N], mi[2*N][20];
bool vis[N];
struct query {
    int id, tim, u, v;
    bool operator < (const query &b) const {
	if(bel[u] != bel[b.u]) return bel[u] < bel[b.u];
	if(bel[v] != bel[b.v]) return bel[v] < bel[b.v];
	return tim < b.tim;
    }
} q[N];

void add(int u, int v){
    go[++ecnt] = v;
    nxt[ecnt] = adj[u];
    adj[u] = ecnt;
}
void dfs(int u, int pre){
    dep[u] = dep[pre] + 1, fa[u] = pre;
    seq[++seq_cnt] = u, seq_pos[u] = seq_cnt;
    int st = top;
    for(int e = adj[u], v; e; e = nxt[e])
	if(v = go[e], v != pre){
	    dfs(v, u);
	    seq[++seq_cnt] = u;
	    if(top - st > B){
		idx++;
		while(top > st) bel[stk[top--]] = idx;
	    }
	}
    stk[++top] = u;
}
int Min(int a, int b){
    return dep[a] < dep[b] ? a : b;
}
void lca_init(){
    for(int i = 1, j = 0; i <= seq_cnt; i++)
	lg[i] = i == (1 << (j + 1)) ? ++j : j;
    for(int i = 1; i <= seq_cnt; i++) mi[i][0] = seq[i];
    for(int j = 1; (1 << j) <= seq_cnt; j++)
	for(int i = 1; i + (1 << j) - 1 <= seq_cnt; i++)
	    mi[i][j] = Min(mi[i][j - 1], mi[i + (1 << (j - 1))][j - 1]);
}
int lca(int u, int v){
    u = seq_pos[u], v = seq_pos[v];
    if(u > v) swap(u, v);
    int j = lg[v - u + 1];
    return Min(mi[u][j], mi[v - (1 << j) + 1][j]);
}
void reverse(int u){
    if(vis[u]) res -= wei[cnt[col[u]]] * val[col[u]], cnt[col[u]]--;
    else cnt[col[u]]++, res += wei[cnt[col[u]]] * val[col[u]];
    vis[u] ^= 1;
}
void move(int u, int v){
    int w = lca(u, v);
    while(u != w) reverse(u), u = fa[u];
    while(v != w) reverse(v), v = fa[v];
}
void travel_ahead(){
    bool flag = 0;
    if(vis[pos[cur]]) flag = 1, reverse(pos[cur]);
    pre[cur] = col[pos[cur]];
    col[pos[cur]] = newx[cur];
    if(flag) reverse(pos[cur]);
}
void travel_back(){
    bool flag = 0;
    if(vis[pos[cur]]) flag = 1, reverse(pos[cur]);
    col[pos[cur]] = pre[cur];
    if(flag) reverse(pos[cur]);
}
void time_travel(int tar){
    while(cur < cntC && tim[cur + 1] <= tar) cur++, travel_ahead();
    while(cur && tim[cur] > tar) travel_back(), cur--;
}

int main(){
    read(n), read(maxcol), read(m);
    for(int i = 1; i <= maxcol; i++) read(val[i]);
    for(int i = 1; i <= n; i++) read(wei[i]);
    for(int i = 1, u, v; i < n; i++)
	read(u), read(v), add(u, v), add(v, u);
    for(int i = 1; i <= n; i++) read(col[i]);
    for(int i = 1, op; i <= m; i++){
	read(op);
	if(op) q[++cntQ].tim = i, q[cntQ].id = cntQ, read(q[cntQ].u), read(q[cntQ].v);
	else tim[++cntC] = i, read(pos[cntC]), read(newx[cntC]);
    }
    dfs(1, 0);
    lca_init();
    while(top) bel[stk[top--]] = idx;
    sort(q + 1, q + cntQ + 1);
    pu = pv = 1;
    for(int i = 1; i <= cntQ; i++){
	time_travel(q[i].tim);
	move(pu, q[i].u), pu = q[i].u;
	move(pv, q[i].v), pv = q[i].v;
	reverse(lca(pu, pv));
	ans[q[i].id] = res;
	reverse(lca(pu, pv));
    }
    for(int i = 1; i <= cntQ; i++)
	write(ans[i]), enter;
    return 0;
}


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM