1. 程式人生 > >資料結構進階:ST表

資料結構進階: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 問題有很多種方法,如 [線段樹](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,謝謝。 (