數據結構進階:ST表


簡介

ST 表是用於解決 可重復貢獻問題 的數據結構。

什么是可重復貢獻問題?

可重復貢獻問題 是指對於運算 \(\operatorname{opt}\) ,滿足 \(x\operatorname{opt} x=x\) ,則對應的區間詢問就是一個可重復貢獻問題。例如,最大值有 \(\max(x,x)=x\)gcd\(\operatorname{gcd}(x,x)=x\) ,所以 RMQ 和區間 GCD 就是一個可重復貢獻問題。像區間和就不具有這個性質,如果求區間和的時候采用的預處理區間重疊了,則會導致重疊部分被計算兩次,這是我們所不願意看到的。另外, \(\operatorname{opt}\) 還必須滿足結合律才能使用 ST 表求解。

什么是RMQ?

RMQ 是英文 Range Maximum/Minimum Query 的縮寫,表示區間最大(最小)值。解決 RMQ 問題有很多種方法,如 線段樹 、單調棧、ST表 和 Four Russian -- 基於 ST 表的算法。

引入

ST 表模板題

題目大意:給定 \(n\) 個數,有 \(m\) 個詢問,對於每個詢問,你需要回答區間 \([l,r]\) 中的最大值。

考慮暴力做法。每次都對區間 \([l,r]\) 掃描一遍,求出最大值。

顯然,這個算法會超時。

ST 表

ST 表基於 倍增 思想,可以做到 \(\Theta(n\log n)\) 預處理, \(\Theta(1)\) 回答每個詢問。但是不支持修改操作。

基於倍增思想,我們考慮如何求出區間最大值。可以發現,如果按照一般的倍增流程,每次跳 \(2^i\) 步的話,詢問時的復雜度仍舊是 \(\Theta(\log n)\) ,並沒有比線段樹更優,反而預處理一步還比線段樹慢。

我們發現 \(\max(x,x)=x\) ,也就是說,區間最大值是一個具有“可重復貢獻”性質的問題。即使用來求解的預處理區間有重疊部分,只要這些區間的並是所求的區間,最終計算出的答案就是正確的。

如果手動模擬一下,可以發現我們能使用至多兩個預處理過的區間來覆蓋詢問區間,也就是說詢問時的時間復雜度可以被降至 \(\Theta(1)\) ,在處理有大量詢問的題目時十分有效。

具體實現如下:

\(f(i,j)\) 表示區間 \([i,i+2^j-1]\) 的最大值。

顯然 \(f(i,0)=a_i\)

根據定義式,第二維就相當於倍增的時候“跳了 \(2^j-1\) 步”,依據倍增的思路,寫出狀態轉移方程: \(f(i,j)=\max(f(i,j-1),f(i+2^{j-1},j-1))\)

以上就是預處理部分。而對於查詢,可以簡單實現如下:

對於每個詢問 \([l,r]\) ,我們把它分成兩部分: \(f[l,l+2^s-1]\)\(f[r-2^s+1,r]\)

其中 \(s=\left\lfloor\log_2(r-l+1)\right\rfloor\)

根據上面對於“可重復貢獻問題”的論證,由於最大值是“可重復貢獻問題”,重疊並不會對區間最大值產生影響。又因為這兩個區間完全覆蓋了 \([l,r]\) ,可以保證答案的正確性。

小結:

稀疏表(SparseTable)算法是 \(O(nlogn)-O(1)\) 的,對於查詢很多大的情況下比較好。
ST算法預處理:用 \(dp[i,j]\) 表示從i開始的,長度為 $2^j $ 的區間的 RMQ ,則有遞推式

\[dp[i,j]=min{dp[i,j-1],dp[i+2j-1,j-1]} \]

即用兩個相鄰的長度為 \(2j-1\) 的塊,更新長度為 \(2j\) 的塊。因此,預處理時間復雜度為 \(O(nlogn)\)
這個算法記錄了所有長度形如 \(2k\) 的所有詢問的結果。
從這里可以看出,稀疏表算法的空間復雜度為 \(O(nlogn)\)

模板如下:

#include <cmath>
#include <iostream>
using namespace std;

int a[50001];
int f[50001][16];
int n;

void rmq_init()  //建立: dp(i,j) = min{dp(i,j-1),dp(i+2^(j-1),j-1)   O(nlogn)
{
    for (int i = 1; i <= n; i++)
        f[i][0] = a[i];
    int k = floor(log((double)n) / log(2.0));  // C/C++取整函數ceil()大,floor()小
    for (int j = 1; j <= k; j++)
        for (int i = n; i >= 1; i--) {
            if (i + (1 << (j - 1)) <=
                n)  // f(i,j) = min{f(i,j-1),f(i+2^(j-1),j-1)
                f[i][j] = min(f[i][j - 1], f[i + (1 << (j - 1))][j - 1]);
        }
}

int rmq(int i, int j)  //查詢:返回區間[i,j]的最小值     O(1)
{
    int k = floor(log((double)(j - i + 1)) / log(2.0));
    return min(f[i][k], f[j - (1 << k) + 1][k]);
}

int main() {
    scanf("%d", &n);
    for (int i = 1; i <= n; i++)
        scanf("%d", &a[i]);
    rmq_init();
    printf("%d\n", rmq(2, 5));
}

例題

#include<bits/stdc++.h>
using namespace std;
const int logn = 21;
const int maxn = 2000001;
int Logn[maxn], f[maxn][logn];
int n, m;

inline int read(){
	int x = 0, f = 1; char ch = getchar();
	while (!isdigit(ch)) { if (ch == '-') f = -1; ch = getchar(); }
	while (isdigit(ch)) { x = x * 10 + ch - 48; ch = getchar(); }
	return x * f;
}

void pre() {
	Logn[1] = 0, Logn[2] = 1;
	for (int i = 3; i < maxn; ++i) 
		Logn[i] = Logn[i / 2] + 1;
}
int main() {
	//freopen("in.txt", "r", stdin);
	//ios::sync_with_stdio(false), cin.tie(0);
	n = read(), m = read();
	for (int i = 1; i <= n; ++i)f[i][0] = read();
	pre();
	//f(i,j) = max(f(i,j - 1),f(i + 1 << (j - 1),j - 1))
	for (int j = 1; j <= logn; j++)
		for (int i = 1; i + (1 << j) - 1 <= n; i++)
			f[i][j] = max(f[i][j - 1], f[i + (1 << (j - 1))][j - 1]);
	int x, y;
	while (m--) {
		x = read(), y = read();
		int s = Logn[y - x + 1];
		printf("%d\n", max(f[x][s], f[y - (1 << s) + 1][s]));
	}
}
  • poj 3264 Balanced Lineup
      題意:求區間的最大值和最小值。
      這道題有很多種方法(比如線段樹),用ST表代碼簡潔,詳細代碼如下:
#include <algorithm>
#include <cmath>
#include <cstdio>
#include <cstring>
#include <iostream>
#define N 100005
using namespace std;
int n, q, a[N];
int mx[N][18], mn[N][18];
void Rmq_Init() {
    int m = floor(log((double)n) / log(2.0));
    for (int i = 1; i <= n; i++)
        mx[i][0] = mn[i][0] = a[i];
    for (int i = 1; i <= m; i++)
        for (int j = n; j; j--) {
            mx[j][i] = mx[j][i - 1];
            mn[j][i] = mn[j][i - 1];
            if (j + (1 << (i - 1)) <= n) {
                mx[j][i] = max(mx[j][i], mx[j + (1 << (i - 1))][i - 1]);
                mn[j][i] = min(mn[j][i], mn[j + (1 << (i - 1))][i - 1]);
            }
        }
}
int Rmq_Query(int l, int r) {
    int m = floor(log((double)(r - l + 1)) / log(2.0));
    int Max = max(mx[l][m], mx[r - (1 << m) + 1][m]);
    int Min = min(mn[l][m], mn[r - (1 << m) + 1][m]);
    return Max - Min;
}
int main() {
    while (scanf("%d%d", &n, &q) != EOF) {
        for (int i = 1; i <= n; i++)
            scanf("%d", &a[i]);
        Rmq_Init();
        while (q--) {
            int l, r;
            scanf("%d%d", &l, &r);
            printf("%d\n", Rmq_Query(l, r));
        }
    }
    return 0;
}

注意點

  1. 輸入輸出數據一般很多,建議開啟輸入輸出優化。

  2. 每次用 std::log 重新計算 log 函數值並不值得,建議進行如下的預處理:

\[\left\{\begin{aligned} Logn[1] &=0, \\ Logn\left[i\right] &=Logn[\frac{i}{2}] + 1. \end{aligned}\right. \]

ST 表維護其他信息

除 RMQ 以外,還有其它的“可重復貢獻問題”。例如“區間按位和”、“區間按位或”、“區間 GCD”,ST 表都能高效地解決。

需要注意的是,對於“區間 GCD”,ST 表的查詢復雜度並沒有比線段樹更優(令值域為 \(w\) ,ST 表的查詢復雜度為 \(\Theta(\log w)\) ,而線段樹為 \(\Theta(\log n+\log w)\) ,且值域一般是大於 \(n\) 的),但是 ST 表的預處理復雜度也沒有比線段樹更劣,而編程復雜度方面 ST 表比線段樹簡單很多。

如果分析一下,“可重復貢獻問題”一般都帶有某種類似 RMQ 的成分。例如“區間按位與”就是每一位取最小值,而“區間 GCD”則是每一個質因數的指數取最小值。

總結

ST 表能較好的維護“可重復貢獻”的區間信息(同時也應滿足結合律),時間復雜度較低,代碼量相對其他算法很小。但是,ST 表能維護的信息非常有限,不能較好地擴展,並且不支持修改操作。

練習

RMQ 模板題

SCOI2007」降雨量

平衡的陣容 Balanced Lineup


以下摘自網絡,僅作為學習算法使用,侵權刪。

附錄:ST 表求區間 GCD 的時間復雜度分析

在算法運行的時候,可能要經過 \(\Theta(\log n)\) 次迭代。每一次迭代都可能會使用 GCD 函數進行遞歸,令值域為 \(w\) ,GCD 函數的時間復雜度最高是 \(\Omega(\log w)\) 的,所以總時間復雜度看似有 \(O(n\log n\log w)\)

但是,在 GCD 的過程中,每一次遞歸(除最后一次遞歸之外)都會使數列中的某個數至少減半,而數列中的數最多減半的次數為 \(\log_2 (w^n)=\Theta(n\log w)\) ,所以,GCD 的遞歸部分最多只會運行 \(O(n\log w)\) 次。再加上循環部分(以及最后一層遞歸)的 \(\Theta(n\log n)\) ,最終時間復雜度則是 \(O(n(\log w+\log x))\) ,由於可以構造數據使得時間復雜度為 \(\Omega(n(\log w+\log x))\) ,所以最終的時間復雜度即為 \(\Theta(n(\log w+\log x))\)

而查詢部分的時間復雜度很好分析,考慮最劣情況,即每次詢問都詢問最劣的一對數,時間復雜度為 \(\Theta(\log w)\) 。因此,ST 表維護“區間 GCD”的時間復雜度為預處理 \(\Theta(n(\log n+\log w))\) ,單次查詢 \(\Theta(\log w)\)

線段樹的相應操作是預處理 \(\Theta(n\log x)\) ,查詢 \(\Theta(n(\log n+\log x))\)

這並不是一個嚴謹的數學論證,更為嚴謹的附在下方:

更嚴謹的證明
理解本段,可能需要具備 時間復雜度 的關於“勢能分析法”的知識。

先分析預處理部分的時間復雜度:

設“待考慮數列”為在預處理 ST 表的時候當前層循環的數列。例如,第零層的數列就是原數列,第一層的數列就是第零層的數列經過一次迭代之后的數列,即 st[1..n][1] ,我們將其記為 \(A\)

而勢能函數就定義為“待考慮數列”中所有數的累乘的以二為底的對數。即: \(\Phi(A)=\log_2\left(\prod\limits_{i=1}^n A_i\right)\)

在一次迭代中,所花費的時間相當於迭代循環所花費的時間與 GCD 所花費的時間之和。其中,GCD 花費的時間有長有短。最短可能只有兩次甚至一次遞歸,而最長可能有 \(O(\log w)\) 次遞歸。但是,GCD 過程中,除最開頭一層與最末一層以外,每次遞歸都會使“待考慮數列”中的某個結果至少減半。即, \(\Phi(A)\) 會減少至少 \(1\) ,該層遞歸所用的時間可以被勢能函數均攤。

同時,我們可以看到, \(\Phi(A)\) 的初值最大為 \(\log_2 (w^n)=\Theta(n\log w)\) ,而 \(\Phi(A)\) 不增。所以,ST 表預處理部分的時間復雜度為 \(O(n(\log w+\log n))\)


免責聲明!

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



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