数据结构------ST表和RMQ问题
- 数据结构ST表和RMQ问题
-
- ST表维护信息的方式
- [ST 表的查询](#ST 表的查询)
- [ST 表的实现 - 预处理](#ST 表的实现 - 预处理)
- ST表的应用
-
- [P3865 【模板】ST 表 & RMQ 问题 - 洛谷](#P3865 【模板】ST 表 & RMQ 问题 - 洛谷)
- [P1890 gcd 区间 - 洛谷](#P1890 gcd 区间 - 洛谷)
- [P2251 质量检测 - 洛谷](#P2251 质量检测 - 洛谷)
- [P2880 Balanced Lineup G - 洛谷](#P2880 Balanced Lineup G - 洛谷)
- [P1198 最大数 - 洛谷](#P1198 最大数 - 洛谷)
-
- 线段树解法
- [逆向 ST 表解法](#逆向 ST 表解法)
- [P7809 01 序列 - 洛谷](#P7809 01 序列 - 洛谷)
- [OJ 参考](#OJ 参考)
数据结构ST表和RMQ问题
RMQ 问题:区间内最大、最小值查询(Range Minimum/Maximum Query)。对于一个长度为 n n n 的序列,有 m m m 次查询操作,每次查询为一个区间 [ l , r ] [l, r] [l,r] 的最大值或最小值。
RMQ 问题可以用线段树 解决。对于这种只有查询操作没有修改操作的静态问题,还可以用代码量更少的 ST 表来解决。
ST 表(Sparse Table,稀疏表)也可以称呼为 ST 算法,是基于动态规划 ( 区间dp ) 和倍增 实现的数据结构,形式上是一张二维表格。ST 表通过预处理维护一些区间信息,从而快速处理区间查询。
类似前缀和数组。
其中预处理的时间复杂度为 O ( n log n ) \text{O}(n \log n) O(nlogn),查询操作为 O ( 1 ) \text{O}(1) O(1)。由于在查询前需要预处理,ST 表基本上只能解决静态问题。
ST 表维护的信息需要满足结合律 以及可重复贡献 。可重复贡献是某个操作, 2 个相同的数执行这个操作得到的结果还是这个数,例如区间最值 以及区间最大公约数就是可重复贡献的问题。如果不满足结合律以及可重复贡献,ST 表就不能解决,例如区间和以及区间乘积。
ST表维护信息的方式
ST 表常用于解决 RMQ 问题:对于一个长度为 n n n 的序列,有 m m m 次查询操作,每次查询为一个区间 [ l , r ] [l, r] [l,r] 的最大值。
由于区间最值不满足可差性,因此不能像前缀和数组一样,搞一张一维的表格来预处理某些区间的信息,尽管可以使用线段树结局,但同样希望能有更加轻量级的数据结构。
可以尝试用区间 dp 的 dp 表来预处理。
由于二维表格可以直接用来表示区间,那么一种直接的方式就是:使用区间 dp , d p [ i ] [ j ] dp[i][j] dp[i][j] 表示区间 [ i , j ] [i, j] [i,j] 的最值。
这种方式肯定是可以解决问题的。但是 RMQ 问题的数组一般都是 10 5 ∼ 10 6 10^5 \sim 10^6 105∼106 级别的长度,这张二维表压根创建不出来,更不用说潜在的遍历超时问题。
ST 表尝试用 2 j = 2 j − 1 + 2 j − 1 2^j = 2^{j-1} + 2^{j-1} 2j=2j−1+2j−1 优化区间 dp 的状态表示:
d p [ i ] [ j ] dp[i][j] dp[i][j] 代表的含义为:从 i i i 位置开始,长度为 2 j 2^j 2j 的区间中,所有元素的最值。
此时空间复杂度可压缩到 O ( n log n ) \text{O}(n\log n) O(nlogn) ,可以容纳 10 6 10^6 106 的数组,且求最值时可从 2 个长度为 2 j − 1 2^{j-1} 2j−1 的空间中求解(或进行状态转移)。
以数组 a = [ 5 , 2 , 4 , 6 , 1 , 7 , 5 , 0 , 9 , 3 ] a = [5, 2, 4, 6, 1, 7, 5, 0, 9, 3] a=[5,2,4,6,1,7,5,0,9,3] 为例,我们会用下述方式维护区间最大值信息:
开始时没有处理: 5 2 4 6 1 7 5 0 9 3 1 2 3 4 5 6 7 8 9 10 第0列长度为1: 5 2 4 6 1 7 5 0 9 3 第1列长度为 2: 5 4 6 6 7 7 5 9 9 第2列长度为 4: 6 6 7 7 7 9 9 第3列长度为8: 7 9 9 \begin{matrix} \text{开始时没有处理:} &\begin{array}{|c|c|}\hline5&2&4&6&1&7&5&0&9&3\\\hline1&2&3&4&5&6&7&8&9&10\\\hline\end{array}\\ \text{第0列长度为1:}&\begin{array}{|c|c|}\hline5&2&4&6&1&7&5&0&9&3\\\hline\end{array}\\ \text{第1列长度为 2:}&\begin{array}{|c|c|}\hline5&4&6&6&7&7&5&9&9&\ \ \\\hline\end{array}\\ \text{第2列长度为 4:}&\begin{array}{|c|c|}\hline6&6&7&7&7&9&9&\ \ &\ \ &\ \ \\\hline\end{array}\\ \text{第3列长度为8:}&\begin{array}{|c|c|}\hline7&9&9&\ \ &\ \ &\ \ &\ \ &\ \ &\ \ &\ \ \\\hline\end{array} \end{matrix} 开始时没有处理:第0列长度为1:第1列长度为 2:第2列长度为 4:第3列长度为8:5122436415765708993105246175093546677599 6677799 799
维护方式是在每个长度为 2 i 2^i 2i 的区间内求最值。

这就是稀疏表的由来,并不是把所有的区间信息存下来,这是暴力算法做的事, ST 表只保存长度为 2 j 2^j 2j 的区间信息。
ST 表的查询
对于每次查询 [ l , r ] [l, r] [l,r],可以把它分成两个区间 [ l , l + 2 k − 1 ] [l, l + 2^k - 1] [l,l+2k−1] 与 [ r − 2 k + 1 , r ] [r - 2^k + 1, r] [r−2k+1,r],其中 k = ⌊ log 2 ( r − l + 1 ) ⌋ ≤ r − l + 1 k = \lfloor\log_2(r - l + 1)\rfloor \leq r-l+1 k=⌊log2(r−l+1)⌋≤r−l+1 ,查询的结果就是这两个区间最大值的最大值,重叠部分并不影响。

在预处理的 d p dp dp 数组中,拿到 d p [ l ] [ k ] dp[l][k] dp[l][k] 和 d p [ r − ( 1 < < k ) + 1 ] [ k ] dp[r - (1 << k) + 1][k] dp[r−(1<<k)+1][k] 两个格子,取最大值即可,即 max [ l , r ] = max ( d p [ l , k ] , d p [ r − ( 1 < < k ) + 1 ] [ k ] ) \text{max}_{[l,r]}=\text{max}(dp[l,k],dp[r - (1 << k) + 1][k]) max[l,r]=max(dp[l,k],dp[r−(1<<k)+1][k]) 。
记忆区间起点和终点的技巧:
- 起点 + + + 区间长度 = = = 下一个区间的起点。即 l + l e n l+len l+len 即为第 2 个区间的左端点。
- 终点 − - − 区间长度 = = = 上一个区间的终点。即 r − l e n r-len r−len 即为第 1 个区间的右端点。
ST 表的实现 - 预处理
可以用动态规划的方式思考:
-
状态表示: d p [ i ] [ j ] dp[i][j] dp[i][j] 表示:从 i i i 位置开始,长度为 2 j 2^j 2j 的区间中,所有元素的最大值。
-
状态转移方程:
因为 2 j − 1 + 2 j − 1 = 2 j 2^{j-1} + 2^{j-1} = 2^j 2j−1+2j−1=2j ,所以长度为 2 j 2^j 2j 的区间,可以分成 2 个长度为 2 j − 1 2^{j-1} 2j−1 的区间。

因此, d p [ i ] [ j ] = max ( d p [ i ] [ j − 1 ] , d p [ i + ( 1 < < ( j − 1 ) ) ] [ j − 1 ] ) dp[i][j] = \max(dp[i][j-1], dp[i+(1<<(j-1))][j-1]) dp[i][j]=max(dp[i][j−1],dp[i+(1<<(j−1))][j−1]) 。这里使用位运算而不使用快速幂的原因是快速幂的时间复杂度是 O ( log j ) \text{O}(\log j) O(logj) ,而位运算在数据量不超过范围的情况下是 O ( 1 ) \text{O}(1) O(1) 。
- 初始化:
区间长度为 2 0 = 1 2^0 = 1 20=1 时,最大值就是数组本身,因此可以把第 0 列初始化为原始数组。
- 填表顺序:
通过小区间转移到大区间。因此第一层循环从小到大枚举 j j j,第二层循环从小到大枚举起点。
注意两个边界:
- 对于 j j j :枚举的区间长度不能超过 n n n,因此 j j j 的最大值应该为 log 2 n \log_2 n log2n。
- 对于 i i i :当区间长度为 2 j 2^j 2j 时,最后一个区间的右端点不能超过 n n n,因此 i + ( 1 < < j ) − 1 ≤ n i + (1 << j) - 1 \leq n i+(1<<j)−1≤n。
优化 :若查询次数过多,求对数时是会有一个 log \log log 级别的开销的。若把 log 2 1 ∼ log 2 n \log_2 1 \sim \log_2 n log21∼log2n 全部预处理出来,则查询操作的 k k k 就可以在 O ( 1 ) O(1) O(1) 时间得到。
对于 log 2 i \log_2 i log2i,容易得到一个关系式:
log 2 i = log 2 ( i 2 × 2 ) = log 2 i 2 + 1 \log_2 i = \log_2 \left( \frac{i}{2} \times 2 \right) = \log_2 \frac{i}{2} + 1 log2i=log2(2i×2)=log22i+1
其中 log 2 1 = 0 \log_2 1 = 0 log21=0,因此可以通过递推,预处理出来所有的 log 1 ∼ log n \log 1 \sim \log n log1∼logn。
ST 表参考(封装):
cpp
using vi = vector<int>;
using vvi = vector<vector<int>>;
struct ST {
vi lg2; // 2的若干次幂
vvi dp; // ST表本体
// 要求 log2(a.size()-1)<dp[0].size()
ST(const vi &a = vi()) {
if (a.size())
init(a);
}
void init(const vi &a) {
lg2.resize(a.size());
dp.resize(a.size(), vi(log2(a.size() - 1) + 1, 0));
// 原本对数函数的定义域是(0,正无穷),这里是方便初始化lg2[1]
lg2[0] = -1;
for (int i = 1; i < a.size(); i++) {
lg2[i] = lg2[i >> 1] + 1;
dp[i][0] = a[i];
}
// 区间dp
for (int j = 1; j <= lg2[a.size() - 1]; j++) // 枚举区间长
for (int i = 1; i + (1 << j) - 1 < a.size(); i++) // 右端点不越界
dp[i][j] = max(dp[i][j - 1], dp[i + (1 << (j - 1))][j - 1]);
}
int query(int l, int r) {
int k = lg2[r - l + 1];
return max(dp[l][k], dp[r - (1 << k) + 1][k]);
}
};
ST表的应用
P3865 【模板】ST 表 & RMQ 问题 - 洛谷
ST 表模板题,也可当成区间 dp 的题来分析,但也只是分析,不可能开 2 个维度都是 10 5 10^5 105 大小的数组。
cpp
#include <bits/stdc++.h>
using namespace std;
using vi = vector<int>;
using vvi = vector<vector<int>>;
struct ST {
vi lg2; // 2的若干次幂
vvi dp; // ST表本体
// 要求 log2(a.size()-1)<dp[0].size()
ST(const vi &a = vi()) {
if (a.size())
init(a);
}
void init(const vi &a) {
lg2.resize(a.size());
dp.resize(a.size(), vi(log2(a.size() - 1) + 1, 0));
// 原本对数函数的定义域是(0,正无穷),这里是方便初始化lg2[1]
lg2[0] = -1;
for (int i = 1; i < a.size(); i++) {
lg2[i] = lg2[i >> 1] + 1;
dp[i][0] = a[i];
}
// 区间dp
for (int j = 1; j <= lg2[a.size() - 1]; j++) // 枚举区间长
for (int i = 1; i + (1 << j) - 1 < a.size(); i++) // 右端点不越界
dp[i][j] = max(dp[i][j - 1], dp[i + (1 << (j - 1))][j - 1]);
}
int query(int l, int r) {
int k = lg2[r - l + 1];
return max(dp[l][k], dp[r - (1 << k) + 1][k]);
}
};
void IOinit() {
ios::sync_with_stdio(false);
cin.tie(0);
cout.tie(0);
}
int main() {
// freopen("in.in", "r", stdin);
IOinit(); // 没它过不了OJ
int n, m;
cin >> n >> m;
vi a(n + 1, 0);
for (int i = 1; i <= n; i++)
cin >> a[i];
ST st(a);
for (int i = 1; i <= m; i++) {
int l, r;
cin >> l >> r;
cout << st.query(l, r) << '\n';
}
return 0;
}
P1890 gcd 区间 - 洛谷
ST表解法
gcd 满足结合率: gcd ( a , b ) = gcd ( b , a ) \text{gcd}(a,b)=\text{gcd}(b,a) gcd(a,b)=gcd(b,a) ,同时也满足可重复贡献,所以可以用 ST 表存储,但初始化和查询的运算方式要从最值变成求 gcd 。
cpp
#include <bits/stdc++.h>
using namespace std;
using vi = vector<int>;
using vvi = vector<vector<int>>;
void IOinit() {
ios::sync_with_stdio(false);
cin.tie(0);
cout.tie(0);
}
struct ST {
vi lg2;
vvi dp;
ST(const vi &a = vi()) {
if (a.size())
init(a);
}
void init(const vi &a) {
lg2.resize(a.size(), 0);
dp.resize(a.size(), vi(log2(a.size() - 1) + 1, 0));
lg2[0] = -1;
for (int i = 1; i < a.size(); i++) {
lg2[i] = lg2[i >> 1] + 1;
dp[i][0] = a[i];
}
for (int j = 1; j <= lg2[a.size() - 1]; j++)
for (int i = 1; i + (1 << j) - 1 < a.size(); i++)
dp[i][j] = gcd(dp[i][j - 1], dp[i + (1 << (j - 1))][j - 1]);
}
int gcd(int a, int b) {
return b ? gcd(b, a % b) : a;
}
int query(int l, int r) {
int k = lg2[r - l + 1];
return gcd(dp[l][k], dp[r - (1 << k) + 1][k]);
}
};
int main() {
// freopen("in.in", "r", stdin);
IOinit();
int n, m;
cin >> n >> m;
vi a(n + 1, 0);
for (int i = 1; i <= n; i++)
cin >> a[i];
ST st(a);
for (int i = 1; i <= m; i++) {
int l, r;
cin >> l >> r;
cout << st.query(l, r) << '\n';
}
return 0;
}
区间dp解法
注意到这个题的初始序列长度只有 1000 ,每个数的最大值可达 10 9 10^9 109 ,算上使用欧几里得算法带来的额外耗时和 -O2 优化,勉强可用基于区间上的某个分界点的区间 dp 解决,转移方程为
d p [ i ] [ j ] = gcd ( d p [ i ] [ k ] , d p [ k + 1 ] [ j ] ) dp[i][j]=\text{gcd}(dp[i][k],dp[k+1][j]) dp[i][j]=gcd(dp[i][k],dp[k+1][j]) ,分界点 k = i + j 2 k=\frac{i+j}{2} k=2i+j 。
cpp
#include <bits/stdc++.h>
using namespace std;
using vi = vector<int>;
using vvi = vector<vi>;
int gcd(int a, int b) {
return b ? gcd(b, a % b) : a;
}
int main() {
// freopen("in.in", "r", stdin);
vvi dp;
int n, m;
cin >> n >> m;
dp.resize(n + 1, vi(n + 1, 0));
for (int i = 1; i <= n; i++)
cin >> dp[i][i];
// 区间dp
for (int len = 2; len <= n; len++)
for (int i = 1; i + len - 1 <= n; i++) {
int j = i + len - 1;
int k = (i + j) / 2; // 求gcd,分界点只需要1个
dp[i][j] = gcd(dp[i][k], dp[k + 1][j]);
}
for (int i = 1; i <= m; i++) {
int l, r;
cin >> l >> r;
cout << dp[l][r] << '\n';
}
return 0;
}
P2251 质量检测 - 洛谷
此题为单调队列模板题。核心依旧是 RMQ 问题,这里使用 ST 表解决。
cpp
#include <bits/stdc++.h>
using namespace std;
using vi = vector<int>;
using vvi = vector<vector<int>>;
void IOinit() {
ios::sync_with_stdio(false);
cin.tie(0);
cout.tie(0);
}
struct ST {
vi lg2;
vvi dp;
ST(const vi &a = vi()) {
if (!a.empty())
init(a);
}
void init(const vi &a) {
lg2.resize(a.size(), 0);
dp.resize(a.size(), vi(log2(a.size() - 1) + 1, 0));
lg2[0] = -1;
for (int i = 1; i < a.size(); i++) {
lg2[i] = lg2[i / 2] + 1;
dp[i][0] = a[i];
}
for (int j = 1; j <= lg2[a.size() - 1]; j++)
for (int i = 1; i + (1 << j) - 1 < a.size(); i++)
dp[i][j] = min(dp[i][j - 1], dp[i + (1 << (j - 1))][j - 1]);
}
int query(int l, int r) {
int k = lg2[r - l + 1];
return min(dp[l][k], dp[r - (1 << k) + 1][k]);
}
};
int main() {
// freopen("in.in", "r", stdin);
int n, m;
cin >> n >> m;
vi a(n + 1, 0);
for (int i = 1; i <= n; i++)
cin >> a[i];
ST st(a);
for (int i = 1; i + m - 1 <= n; i++)
cout << st.query(i, i + m - 1) << '\n';
return 0;
}
P2880 Balanced Lineup G - 洛谷
P2880 [USACO07JAN\] Balanced Lineup G - 洛谷](https://www.luogu.com.cn/problem/P2880)
区间查询时要求同时获得区间最大值和区间最小值,可通过 2 个 ST 表进行维护。这里用函数指针数组丰富 ST 表的功能。
```cpp
#include