1. ST 表完全指南:原理、实现与实战解析
ST 表(Sparse Table,稀疏表)是解决静态区间最值查询(RMQ)问题的利器。作为算法竞赛和面试中的常客,它能在O(nlogn)预处理后,以O(1)时间复杂度回答任意区间的最值查询。本文将带你深入理解ST表的工作原理,并通过完整代码实现和典型例题,掌握这一高效算法的核心要点。
提示:本文代码示例均采用C++实现,但算法思想适用于任何编程语言。建议读者边阅读边动手实践,以加深理解。
1.1 ST 表的核心思想
ST表的本质是通过预处理构建一个二维数组f[i][j],表示从位置i开始,长度为2^j的区间内的最值。这种设计基于动态规划中的倍增思想——将大问题分解为若干可重叠的小问题。
为什么选择2的幂次作为区间长度?这源于计算机的二进位特性:
- 任意整数都能表示为2的幂次之和
- 通过log运算可快速确定合适的区间划分
- 两个2^k长度的区间可以完美覆盖任意长度的查询区间
1.2 与其他RMQ算法的对比
| 算法 | 预处理时间 | 查询时间 | 空间复杂度 | 适用场景 |
|---|---|---|---|---|
| 朴素遍历 | O(1) | O(n) | O(n) | 查询极少的情况 |
| 线段树 | O(n) | O(logn) | O(n) | 需要动态更新的场景 |
| ST表 | O(nlogn) | O(1) | O(nlogn) | 静态数据、频繁查询 |
| 单调队列 | O(n) | O(1) | O(k) | 滑动窗口类问题 |
从表格可见,ST表在静态数据且需要频繁查询时具有明显优势,这也是它成为算法竞赛必备技能的原因。
2. ST 表的实现细节
2.1 预处理阶段实现
预处理是ST表构建的核心,其关键在于正确填充二维数组f[i][j]。以下是带详细注释的实现:
cpp复制const int N = 1e5 + 10; // 根据问题规模调整
int a[N], f[N][20]; // f[i][j]表示从i开始,长度2^j的区间最值
int lg[N]; // 预处理log2值,加速查询
void init(int n) {
// 预处理log2值,避免重复计算
lg[0] = -1;
for (int i = 1; i <= n; i++) {
lg[i] = lg[i >> 1] + 1; // 利用位运算优化
f[i][0] = a[i]; // 初始化长度为1的区间
}
// 动态规划填充ST表
for (int j = 1; j <= lg[n]; 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]);
}
}
}
预处理过程中的几个关键点:
- log2预处理:通过lg数组存储每个数的log2下取整值,避免查询时重复计算
- 边界处理:f[i][0] = a[i]表示单个元素的最值就是它本身
- 递推关系:f[i][j] = max(f[i][j-1], f[i + (1 << (j-1))][j-1]),将大区间拆分为两个有重叠的小区间
2.2 查询函数实现
查询函数需要处理任意区间[l, r]的最值请求:
cpp复制int query(int l, int r) {
int k = lg[r - l + 1]; // 计算区间长度的log2
return max(f[l][k], f[r - (1 << k) + 1][k]);
}
查询原理说明:
- 计算k = floor(log2(r-l+1)),确定最大的2^k不超过区间长度
- 取两个可能重叠的区间:f[l][k]和f[r-2^k+1][k]
- 这两个区间的并集正好覆盖整个查询区间
注意:ST表要求查询的区间必须满足可重叠性质(如max/min),对于不可重叠的操作(如求和),ST表不适用。
3. 经典例题实战
3.1 静态区间最大值查询
问题描述:给定一个静态数组,处理多个查询,每个查询要求返回区间[l, r]的最大值。
解决方案:这正是ST表的典型应用场景。完整实现如下:
cpp复制#include <iostream>
using namespace std;
const int N = 1e5 + 10;
int n, m;
int f[N][20], lg[N];
void init() {
lg[0] = -1;
for (int i = 1; i <= n; i++) {
lg[i] = lg[i >> 1] + 1;
f[i][0] = a[i];
}
for (int j = 1; j <= lg[n]; 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 query(int l, int r) {
int k = lg[r - l + 1];
return max(f[l][k], f[r - (1 << k) + 1][k]);
}
int main() {
scanf("%d%d", &n, &m);
for (int i = 1; i <= n; i++) scanf("%d", &f[i][0]);
init();
while (m--) {
int l, r;
scanf("%d%d", &l, &r);
printf("%d\n", query(l, r));
}
return 0;
}
性能分析:
- 预处理:O(nlogn)
- 每个查询:O(1)
- 空间复杂度:O(nlogn)
3.2 滑动窗口最大值问题
问题描述:给定一个数组nums和窗口大小k,窗口从数组最左端滑动到最右端,返回每个窗口中的最大值。
3.2.1 ST表解法
虽然ST表不是最优解,但可以解决这个问题:
cpp复制class Solution {
public:
static const int N = 1e5 + 10;
int a[N], f[N][20];
int n;
void init() {
for (int i = 1; i <= n; i++) f[i][0] = a[i];
for (int j = 1; (1 << j) <= n; 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 query(int l, int r) {
int k = log2(r - l + 1);
return max(f[l][k], f[r - (1 << k) + 1][k]);
}
vector<int> maxSlidingWindow(vector<int>& nums, int k) {
if (nums.empty() || k == 0) return {};
n = nums.size();
for (int i = 0; i < n; i++) a[i+1] = nums[i];
init();
vector<int> res;
for (int i = 1; i + k - 1 <= n; i++) {
res.push_back(query(i, i + k - 1));
}
return res;
}
};
复杂度分析:
- 时间复杂度:O(nlogn)预处理 + O(n)查询 = O(nlogn)
- 空间复杂度:O(nlogn)
3.2.2 单调队列优化解法
对于滑动窗口问题,单调队列是更优的选择:
cpp复制class Solution {
public:
vector<int> maxSlidingWindow(vector<int>& nums, int k) {
if (nums.empty() || k == 0) return {};
vector<int> res;
deque<int> dq; // 存储的是下标
for (int i = 0; i < nums.size(); i++) {
// 移除队尾小于当前元素的元素,保持队列单调递减
while (!dq.empty() && nums[i] >= nums[dq.back()]) {
dq.pop_back();
}
dq.push_back(i);
// 移除超出窗口范围的队首元素
if (dq.front() <= i - k) {
dq.pop_front();
}
// 当窗口形成后开始记录结果
if (i >= k - 1) {
res.push_back(nums[dq.front()]);
}
}
return res;
}
};
单调队列的优势:
- 时间复杂度:O(n),每个元素入队出队各一次
- 空间复杂度:O(k),只需存储窗口内的元素
- 更适合滑动窗口这类连续移动的场景
4. ST表的变种与应用技巧
4.1 二维ST表
ST表可以扩展到二维情况,用于解决矩阵中的子矩阵最值查询:
cpp复制int f[N][N][10][10]; // f[i][j][k][l]表示从(i,j)开始,2^k行2^l列的子矩阵最值
void init_2d() {
// 初始化单点
for (int i = 1; i <= n; i++)
for (int j = 1; j <= m; j++)
f[i][j][0][0] = a[i][j];
// 预处理行方向
for (int k = 1; (1 << k) <= n; k++)
for (int i = 1; i + (1 << k) - 1 <= n; i++)
for (int j = 1; j <= m; j++)
f[i][j][k][0] = max(f[i][j][k-1][0], f[i + (1 << (k-1))][j][k-1][0]);
// 预处理列方向
for (int l = 1; (1 << l) <= m; l++)
for (int i = 1; i <= n; i++)
for (int j = 1; j + (1 << l) - 1 <= m; j++)
f[i][j][0][l] = max(f[i][j][0][l-1], f[i][j + (1 << (l-1))][0][l-1]);
// 预处理行列
for (int k = 1; (1 << k) <= n; k++)
for (int l = 1; (1 << l) <= m; l++)
for (int i = 1; i + (1 << k) - 1 <= n; i++)
for (int j = 1; j + (1 << l) - 1 <= m; j++)
f[i][j][k][l] = max(
max(f[i][j][k-1][l-1], f[i + (1 << (k-1))][j][k-1][l-1]),
max(f[i][j + (1 << (l-1))][k-1][l-1],
f[i + (1 << (k-1))][j + (1 << (l-1))][k-1][l-1])
);
}
int query_2d(int x1, int y1, int x2, int y2) {
int kx = log2(x2 - x1 + 1);
int ky = log2(y2 - y1 + 1);
return max(
max(f[x1][y1][kx][ky], f[x2 - (1 << kx) + 1][y1][kx][ky]),
max(f[x1][y2 - (1 << ky) + 1][kx][ky],
f[x2 - (1 << kx) + 1][y2 - (1 << ky) + 1][kx][ky])
);
}
4.2 ST表处理其他运算
虽然ST表最常用于max/min,但也可以用于其他满足结合律且幂等的运算:
- 区间GCD:因为gcd(a,b,c) = gcd(gcd(a,b), gcd(b,c))
- 区间AND/OR:位运算满足结合律和幂等性
- 区间重叠检测:如判断区间内是否有True值
注意:ST表不适用于求和、求积等不可重叠的运算,因为两个子区间的重叠部分会被重复计算。
5. 常见问题与调试技巧
5.1 ST表常见错误排查
-
数组越界:
- 检查f数组的第二维大小是否足够(通常取20足够处理1e6规模的数据)
- 确保查询时l和r在有效范围内
-
预处理不完整:
- 确认j的循环上限是log2(n)而不是n
- 检查i + (1 << j) - 1 <= n的条件
-
查询结果错误:
- 验证lg数组预处理是否正确
- 检查查询时k的计算是否正确
5.2 性能优化技巧
-
log2预处理优化:
cpp复制lg[0] = -1; for (int i = 1; i <= N; i++) { lg[i] = lg[i >> 1] + 1; } -
循环顺序优化:
- 将j循环放在外层,i循环放在内层,提高缓存命中率
-
内存优化:
- 对于极大数组,可以考虑分块处理
- 使用更紧凑的数据类型(如short)如果值域允许
5.3 ST表与线段树的对比选择
| 考虑因素 | ST表 | 线段树 |
|---|---|---|
| 预处理时间 | O(nlogn) | O(n) |
| 查询时间 | O(1) | O(logn) |
| 更新操作 | 不支持 | O(logn) |
| 空间复杂度 | O(nlogn) | O(n) |
| 适用场景 | 静态数据、频繁查询 | 动态数据、需要更新 |
选择建议:
- 数据静态不变且查询频繁 → ST表
- 数据需要动态更新 → 线段树
- 内存紧张 → 线段树
- 需要极快查询 → ST表
6. 扩展应用与练习题
6.1 推荐练习题
-
基础应用:
- LeetCode 239. 滑动窗口最大值
- POJ 3264 Balanced Lineup (区间最大最小值差)
- SPOJ RMQSQ - Range Minimum Query
-
进阶挑战:
- Codeforces 475D CGCDSSQ (区间GCD查询)
- HDU 5289 Assignment (ST表+二分)
- UVa 11235 Frequent values (处理频率查询)
6.2 实际应用场景
- 基因组分析:查找DNA序列片段中的最大/最小表达值
- 金融分析:计算股票价格在特定时间窗口内的极值
- 图像处理:快速获取图像局部区域的最大/最小像素值
- 网络监控:分析网络流量在时间窗口内的峰值
6.3 ST表在竞赛中的特殊技巧
- 离线处理:当查询可以全部预先获取时,有时可以优化预处理过程
- 分层处理:对极大数组分块建立ST表,平衡时间和空间
- 结合二分:使用ST表加速二分查找过程,如寻找满足条件的最大子区间
- 多维度结合:将ST表与其他数据结构(如并查集、前缀和)结合解决复杂问题
ST表作为经典RMQ解决方案,其思想可以扩展到许多相似问题。理解其核心原理后,读者可以尝试实现支持其他运算的变种,或者将其与其他算法结合解决更复杂的问题。在算法竞赛和面试中,能够根据问题特点在ST表、线段树和单调队列等方案中做出正确选择,是衡量算法能力的重要标准之一。