1. 程式人生 > 其它 >莫隊演算法學習筆記

莫隊演算法學習筆記

普通莫隊

"莫隊演算法"是用於一類離線區間詢問問題的常用演算法,以適用性廣、程式碼量短、實際執行速度快、適合騙分等優點著稱。           ——莫濤

莫隊的基本操作基於暴力實現,其降低複雜度的突破口在於處理“詢問”。通過對詢問合理的排序,使得之後的詢問充分利用先前詢問得到的資訊,可以將 \(O(NM)\) 的複雜度顯著降低至 \(O(N \sqrt{M})\) 。確實是適合騙分的好演算法。

以下題為例:

Acwing2492 HH的項鍊
長度為 \(N\) 的序列,\(M\) 次詢問,每次詢問一段閉區間內有多少個不同的數。\(1≤N≤50000\)\(1≤M≤2×10^5\)

先考慮暴力做法。對於每次操作,從 \(L\)

\(R\) 掃一遍,統計個數。但我們想,對於一些左右端點都相近區間,這樣的做法顯然浪費了很多可以利用的資料。
於是再考慮另一種暴力做法。用兩個指標 \(i\),\(j\) 標記左右區間,對於一個新的查詢,只需移動這兩個指標到新的位置即可。這樣保留的可以利用的資料,不用重新掃描。但又很容易發現新的問題:對於兩個相距很遠的區間,移動指標仍然需要 \(O(N)\)。只需稍加構造,複雜度仍為 \(O(NM)\)。此時,先前區間維護的資訊也不能很好地傳遞給之後相近的區間,而是被中間的其他區間浪費掉了。
若我們將所有區間按右端點排序,則右端點指標僅會移動至多 \(N\) 次。這種單調性給我們啟發。如果能再將所有相近的左端點維護在一起,那麼不就解決了以上問題嗎?
莫隊維護相近左端點的方法是分塊
。設每塊長度為 \(S\),共 \(\frac{N}{S}\) 塊。將所有區間按照 \(L\) 所屬塊排序,\(L\)所屬塊相同時再按 \(R\) 遞增排序。在這樣的順序下,執行上述暴力做法。然後我們再來分析時間複雜度:

  • 左端點指標:塊內移動每次為 \(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})\)

。其中 \(N\) 一定小於 \(\frac{N^2}{S}\),可以忽略。利用基本不等式,\(SM+\frac{N^2}{S}≥\sqrt{N^2M}\),其中 \(N\),\(M\)是常數,故最小值在 \(SM\)\(\frac{N^2}{S}\) 相等時取得。於是得到 \(S=\sqrt{\frac{N^2}{M}}\) 時,複雜度取最小值,值為 \(O(N\sqrt{M})\)

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;
}