資料結構進階:ST表
阿新 • • 發佈:2020-08-05
## 簡介
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 問題有很多種方法,如 [線段樹](https://www.cnblogs.com/RioTian/p/13409694.html) 、單調棧、ST表 和 Four Russian -- 基於 ST 表的演算法。
## 引入
[ST 表模板題](https://www.luogu.com.cn/problem/P3865)
題目大意:給定 $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))$ 。
![](https://gitee.com//riotian/blogimage/raw/master/img/20200803194320.png)
以上就是預處理部分。而對於查詢,可以簡單實現如下:
對於每個詢問 $[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]$ ,可以保證答案的正確性。
## 模板程式碼
[ST 表模板題](https://www.luogu.com.cn/problem/P3865)
```cpp
#include
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]));
}
}
```
## 注意點
1. 輸入輸出資料一般很多,建議開啟輸入輸出優化。
2. 每次用 [std::log](https://en.cppreference.com/w/cpp/numeric/math/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 模板題](https://www.luogu.com.cn/problem/P3865)
[SCOI2007」降雨量](https://loj.ac/problem/2279)
[平衡的陣容 Balanced Lineup](https://www.luogu.com.cn/problem/P2880)
---
**以下摘自網路,僅作為學習演算法使用,侵權刪。**
## 附錄: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))$ 。
## 其它
文章開源在 [Github - blog-articles](https://github.com/RivTian/blog-articles),點選 Watch 即可訂閱本部落格。 若文章有錯誤,請在 [Issues](https://github.com/RivTian/blog-articles/issues) 中提出,我會及時回覆,謝謝。
如果您覺得文章不錯,或者在生活和工作中幫助到了您,不妨給個 Star,謝謝。
(