演演算法學習筆記(3.1): ST演演算法

2023-10-14 06:01:15

ST表

在RMQ(區間最值)問題中,著名的ST演演算法就是倍增的產物。ST演演算法可以在 \(O(n \log n)\) 的時間複雜度能預處理後,以 \(O(1)\) 的複雜度線上回答區間 [l, r] 內的最值。

當然,ST表不支援動態修改,如果需要動態修改,線段樹是一種良好的解決方案,是 \(O(n)\) 的預處理時間複雜度,但是查詢需要 \(O(\log n)\) 的時間複雜度

那麼ST表中倍增的思想是如何體現的呢?

一個序列的子區間明顯有 \(n^2\) 個,根據倍增的思想,我們在這麼多個子區間中選擇一些長度為 \(2\) 的整數次冪的區間作為代表值。

\(st[i][j]\) 表示子區間 \([i, i+2^j)\) 裡最大的數

也可以表示為 \([i, i + 2^j -1 ]\),無論如何,其中有 \(2^j\) 個元素

下文中的 \(a\) 表示原序列

遞推邊界明顯是 \(st[i][0] = a[i]\)

於是,根據成倍增長的長度,有了遞推公式

\[st[i][j] = max(st[i][j-1],\;st[i+2^{j-1}][j-1]) \]

當詢問任意區間 \([l, r]\) 的最值時,我們先計算出一個最大的 \(k\) 滿足:\(2^k \le r - l + 1\),即需要不大於區間長度。那麼,由於二進位制劃分我們可以知道,這個最大的 k 一定滿足 \(2^{k+1}\ge r-l+1\),即我們只需要將兩個長度為 \(2^k\) 的區間合併即可。

又根據 max(a, a) = a 可以知道,重複計算區間是沒有任何問題的。

所以,在尋找最值的時候就有了以下公式:

\[max(a[l, r]) = max(st[l][k], st[r-2^k + 1][k]) \]

那麼這裡給出一種參考程式碼

// 啊,寫這種預處理以2位底的對數的整數值的方式
// 我主要是為了將程式碼模組化,做到低耦合度
// 完全是可以分開來寫的
class Log2Factory {
private:
    int lg2[N];
public:
    void init(int n) {
        for (int i = 2; i <= n; ++i) lg2[i] = lg2[i >> 1] + 1;
    }

    // 過載()運運算元
    int operator() (const int &i) {
        return lg2[i];
    }
};

template<typename T>
class STable {
private:
    typedef T(*OP_FUNC)(T, T);

    Log2Factory Log2;
    T f[N][17]; // maybe most of the times k=17 is ok, make sure 2^k greater than N;
    OP_FUNC op;
public:
    void setOp(OP_FUNC fc) {
        op = fc;
    }

    void init(T *a, int n) {
        for (int i = 1; i <= n; ++i)
            f[i][0] = *(++a);

        int t = Log2(n);
        // f[i][k] is the interval of [i, i + 2^k - 1]
        // so f[i][k] can equal to the op sum of [i, i^k - 1]
        // let r = i^k - 1
        // => f[r - (1^k) + 1][k] can equal to the op sum of [i][k]
        for (int k = 1; k <= t; ++k) {
            for (int i = 1; i + (1<<k) - 1 <= n; ++i)
                f[i][k] = op(f[i][k-1], f[i + (1<<(k-1))][k-1]);
        }
    }

    const T query(int l, int r) {
        int k = Log2(r - l + 1);
        return op(f[l][k], f[r - (1<<k) + 1][k]);
    }
};

這……寫法很神奇,注意修改!

擴充套件 - 運算

ST 演演算法不僅僅是可以求區間的最值的,只要是滿足靜態的,滿足區間加法的問題大多數情況都可以通過 ST 表實現。

那麼區間加法是什麼意思呢?

定義我們需要對數列的篩選函數為 op ,則需要 op 滿足以下性質

  • op(a, a) = a ,即重複參與運算不改變最終影響

  • op(a, b) = op(b, a) ,即滿足交換律

  • op(a, op(b, c)) = op(op(a, b), c) ,即滿足結合律

舉個例子,如果我們求區間是否有負數,可以將 op 設為如下邏輯:

bool op(bool a, bool b) {
    return a | b;
}

相應的,初始化的方式也需要更改

if (a[i] < 0) st[i][0] = true;
else st[i][0] = false;

再舉一個例子,如果我們需要求區間是否全為偶數時,則初始化為

if (a[i] % 2 == 0) st[i][0] = true;
else st[i][0] = false;

操作 op 定義為

bool op(bool a, bool b) {
    return a & b;
}

由此可見,其實ST演演算法可以做到的不僅僅是區間最值那麼普通的東西啊。

但是,由於 加法 不滿足性質一,所以,ST表通過這種方法並不能求得區間的所有滿足某種性質的元素的個數。但是,通過另外一種 query 方式,我們可以做到這樣。

擴充套件 - 區間

那麼這個部分我們將討論如何利用ST表做到上文例子中求區間偶數的個數。

同樣,由於我們可以通過二進位制劃分,所以可以將某一個區間長度轉化為多個長度為2的整數冪次方的子區間,並且可以保證這些區間不相互重疊。

所以我們可以利用這個處理 op(a, a) != a 的情況了。

其實這是借鑑了一點線段樹的思路

還不如直接用線段樹……

那麼可以寫出以下程式碼

int query(int l, int r) {
    if (l == r) return st[l][0];
    int k = log2(r - l + 1);
    return op(st[l][k], query(l + (1<<k), r))
}

這樣就滿足了區間不重疊

或許會有一個問題,為什麼初始化的時候不需要修改?

其實不難發現,初始化的合併是不會有重複貢獻的情況的,即是每一次合併的區間是不會重疊的