莫隊演算法學習筆記
普通莫隊
"莫隊演算法"是用於一類離線區間詢問問題的常用演算法,以適用性廣、程式碼量短、實際執行速度快、適合騙分等優點著稱。 ——莫濤
莫隊的基本操作基於暴力實現,其降低複雜度的突破口在於處理“詢問”。通過對詢問合理的排序,使得之後的詢問充分利用先前詢問得到的資訊,可以將 \(O(NM)\) 的複雜度顯著降低至 \(O(N \sqrt{M})\) 。確實是適合騙分的好演算法。
以下題為例:
Acwing2492 HH的項鍊
長度為 \(N\) 的序列,\(M\) 次詢問,每次詢問一段閉區間內有多少個不同的數。\(1≤N≤50000\),\(1≤M≤2×10^5\)。
先考慮暴力做法。對於每次操作,從 \(L\)
於是再考慮另一種暴力做法。用兩個指標 \(i\),\(j\) 標記左右區間,對於一個新的查詢,只需移動這兩個指標到新的位置即可。這樣保留的可以利用的資料,不用重新掃描。但又很容易發現新的問題:對於兩個相距很遠的區間,移動指標仍然需要 \(O(N)\)。只需稍加構造,複雜度仍為 \(O(NM)\)。此時,先前區間維護的資訊也不能很好地傳遞給之後相近的區間,而是被中間的其他區間浪費掉了。
若我們將所有區間按右端點排序,則右端點指標僅會移動至多 \(N\) 次。這種單調性給我們啟發。如果能再將所有相近的左端點維護在一起,那麼不就解決了以上問題嗎?
莫隊維護相近左端點的方法是分塊
- 左端點指標:塊內移動每次為 \(O(S)\),移動 \(M\) 次;塊間移動每次為 \(O(S)\),移動 \(\frac{N}{S}\) 次。共 \(O(SM+N)\)。
- 右端點指標:對於左端點所屬的每個塊,每次 \(O(N)\),移動 \(\frac{N}{S}\) 次。共 \(O(\frac{N^2}{S})\)。
總時間複雜度為 \(O(SM+N+\frac{N^2}{S})\)
Code
#include<cstdio>
#include<cmath>
#include<algorithm>
using namespace std;
const int N = 5e4 + 5;
const int M = 2e5 + 5;
const int L = 1e6 + 5;
int n, a[N], m, len, pos[N];
int cnt[L], res, ans[M];
struct Query {
int id, l, r;
bool operator <(const Query &oth) const {
return pos[l] == pos[oth.l] ? r < oth.r : pos[l] < pos[oth.l];
}
} q[M];
void add(int x) {
if (!cnt[a[x]]) res++; cnt[a[x]]++;
}
void del(int x) {
cnt[a[x]]--; if (!cnt[a[x]]) res--;
}
int main() {
scanf("%d", &n);
for (int i = 1; i <= n; i++) scanf("%d", &a[i]);
scanf("%d", &m);
for (int i = 1; i <= m; i++) {
int l, r;
scanf("%d%d", &l, &r);
q[i] = (Query){i, l, r};
}
len = max(1, (int)sqrt((double)n * n / m));
for (int i = 1; i <= n; i++) pos[i] = (i - 1) / len + 1;
sort(q + 1, q + m + 1);
for (int k = 1, i = 0, j = 1; k <= m; k++) { //i右j左
while (i < q[k].r) add(++i);
while (i > q[k].r) del(i--);
while (j < q[k].l) del(j++);
while (j > q[k].l) add(--j); //這四個while十分濃縮,建議紙上推一遍
ans[q[k].id] = res;
}
for (int i = 1; i <= m; i++) printf("%d\n", ans[i]);
return 0;
}
帶修莫隊
顧名思義,就是可以修改序列數值的莫隊。
例:Acwing2521 數顏色
題意:就是上題+修改操作。
普通的莫隊,處理的是一維問題(序列)。我們在這裡增加一個維度表示時間,時間軸的單位為每次修改操作。即每次修改之後,都有一個新的序列與之對應。此時查詢操作為查詢特定時間時的區間資訊,顯然可以離線。
此時莫隊由一維變成了二維,我們也可以用相似的方法處理。先對序列分塊,每塊大小為 \(S\)。此時詢問有三元:(\(L,R,t\))。先將所有詢問按照 \(L\) 所屬塊排序,相同時按照 \(R\) 所屬塊排序,最後按照 \(t\) 遞增排序。此時設三個指標:右指標 \(i\),左指標 \(j\),時間指標 \(t\)。指標移動一單位均為 \(O(1)\)。分析複雜度:
- \(j\):與普通莫隊相似,\(O(SM+N)\)。
- \(i\):塊內移動 \(O(S)\),\(M\) 次;塊間移動 \(O(N)\),\(\frac{N}{S}\) 次。共 \(O(SM+\frac{N^2}{S})\)。
- \(t\):設共修改 \(T\) 次。\(i\) 塊間移動 \(\frac{N}{S}\) 次,\(j\) 塊間移動 \(\frac{N}{S}\) 次,每次 \(t\) 移動為 \(O(T)\)。共 \(O(\frac{N^2T}{S^2})\)。
相加,忽略 \(N\) 及常數,則為 \(O(MS+N^2S^{-1}+N^2TS^{-2})\)。數學不好,過程推不來……結論是,當 \(M\) 與 \(N\) 處於同一個數量級時,最小值為 \(O(\sqrt[3]{N^4T})\),當 \(S=\sqrt[3]{NT}\) 時取得。
另外,\(t\) 指標會上下移動,故要維護好修改操作,並支援向下移動。小技巧詳見程式碼。
Code
#include<cstdio>
#include<algorithm>
#include<cmath>
using namespace std;
const int N = 1e4 + 5;
const int L = 1e6 + 5;
int n, m, w[N], mc = 1, mq, pos[N];
int p[N], c[N], cnt[L], res, ans[N];
struct Query {
int id, l, r, t;
bool operator <(const Query &oth) const {
return pos[l] != pos[oth.l] ? pos[l] < pos[oth.l] : (pos[r] != pos[oth.r] ? pos[r] < pos[oth.r] : t < oth.t);
}
} q[N];
void add(int x) {
if (!cnt[x]) res++; cnt[x]++;
}
void del(int x) {
cnt[x]--; if (!cnt[x]) res--;
}
int main() {
scanf("%d%d", &n, &m);
for (int i = 1; i <= n; i++) scanf("%d", &w[i]);
while (m--) {
char opt[5];
int a, b;
scanf("%s%d%d", opt, &a, &b);
if (opt[0] == 'Q') q[++mq] = (Query){mq, a, b, mc};
else p[++mc] = a, c[mc] = b;
}
int len = max(1, (int)cbrt((double)n * mc)); //cbrt開三次根號。也可用pow(值,1.0/3)
for (int i = 1; i <= n; i++) pos[i] = (i - 1) / len + 1;
sort(q + 1, q + mq + 1);
for (int k = 1, i = 0, j = 1, t = 1; k <= mq; k++) {
int l = q[k].l, r = q[k].r, tt = q[k].t;
while (i < r) add(w[++i]);
while (i > r) del(w[i--]);
while (j < l) del(w[j++]);
while (j > l) add(w[--j]);
while (t < tt) {
++t;
if (j <= p[t] && p[t] <= i) {
del(w[p[t]]); add(c[t]);
}
swap(w[p[t]], c[t]); //小技巧來了
}
while (t > tt) {
if (j <= p[t] && p[t] <= i) {
del(w[p[t]]); add(c[t]);
}
swap(w[p[t]], c[t]);
--t;
}
ans[q[k].id] = res;
}
for (int i = 1; i <= mq; i++) printf("%d\n", ans[i]);
return 0;
}