核心逻辑与数学原理
动态规划与倍增空间幂集
ST 表(Sparse Table)的本质是基于动态规划思想的倍增算法,核心用于解决静态区间最值查询(RMQ)问题。其数学基石在于覆盖可重复贡献性质(Idempotent Operations),即对于任意满足 $f(x, x) = x$ 的幂等运算符(如 $\text{max}, \text{min}, \text{gcd}$),多个重叠子区间的运算结果等于其并集的运算结果。
设 $f[i][j]$ 表示区间 $[i, i + 2^j - 1]$ 长度为 $2^j$ 的子区间最值。利用倍增思想,将长度为 $2^j$ 的区间等分为两段长度为 $2^{j-1}$ 的子区间:
- 左半区间:$[i, i + 2^{j-1} - 1]$
- 右半区间:$[i + 2^{j-1}, i + 2^j - 1]$
状态转移方程为:
$$f[i][j] = \text{max}(f[i][j-1], f[i + 2^{j-1}][j-1])$$
$O(1)$ 复杂度的空间覆盖
对于任意查询区间 $[L, R]$,其长度为 $len = R - L + 1$。令 $k = \lfloor \text{log}_2(len) \rfloor$。由于 $2^k \le len < 2^{k+1}$,区间 $[L, R]$ 必然能被两个长度为 $2^k$ 且分别以 $L$ 为起点、 $R$ 为终点的子区间完全覆盖,即:
$$[L, R] = [L, L + 2^k - 1] \bigcup [R - 2^k + 1, R]$$
由此,RMQ 查询退化为常数级的代数映射:
$$\text{RMQ}(L, R) = \text{max}(f[L][k], f[R - 2^k + 1][k])$$
状态设计与算法推导
空间与时间权衡
状态定义中,第一维 $i$ 映射序列下标,边界为 $N$;第二维 $j$ 映射 2 的幂次,边界为 $\lfloor \text{log}_2 N \rfloor$。预处理阶段在外层枚举幂次 $j$,内层枚举下标 $i$。若错误调换循环顺序,会导致未计算的状态被提前引用,破坏动态规划的拓扑序。
预处理时间复杂度:
$$\text{O}(N \text{ log } N)$$
查询阶段通过位运算或内联芯片指令(如 __builtin_clz)计算 $k$,消除循环迭代,达成 $O(1)$ 查询。
C++ 标准源码
#include <iostream>
#include <vector>
#include <algorithm>
using namespace std;
// 封装 ST 表结构体,规避全局变量命名污染
struct SparseTable {
int n;
vector<int> log_table;
vector<vector<int>> st;
void init(const vector<int>& arr) {
n = arr.size();
int max_log = 0;
while ((1 << max_log) <= n) max_log++;
st.assign(n, vector<int>(max_log));
log_table.assign(n + 1, 0);
// 预处理 log 表,严禁在查询时调用 std::log2() 引入浮点数常数开销
log_table[1] = 0;
for (int i = 2; i <= n; i++) {
log_table[i] = log_table[i >> 1] + 1;
}
// 基础状态填装
for (int i = 0; i < n; i++) {
st[i][0] = arr[i];
}
// 致命踩坑点:外层必须枚举幂次 j,内层枚举下标 i,严格保证 DP 状态转移的拓扑序
for (int j = 1; j < max_log; j++) {
for (int i = 0; i + (1 << j) - 1 < n; i++) {
st[i][j] = max(st[i][j - 1], st[i + (1 << (j - 1))][j - 1]);
}
}
}
int query(int l, int r) {
// 传入参数转换为 0-indexed 边界控制
int len = r - l + 1;
int k = log_table[len];
// 致命踩坑点:右半部分区间的起点是 r - (1 << k) + 1,位运算优先级低,必须加括号
return max(st[l][k], st[r - (1 << k) + 1][k]);
}
};
int main() {
// 强制解除输入输出流同步,加速 IO
ios_base::sync_with_stdio(false);
cin.tie(NULL);
int n, m;
if (!(cin >> n >> m)) return 0;
vector<int> arr(n);
for (int i = 0; i < n; i++) {
cin >> arr[i];
}
SparseTable table;
table.init(arr);
for (int i = 0; i < m; i++) {
int l, r;
cin >> l >> r;
// 转换为 0-indexed 传入
cout << table.query(l - 1, r - 1) << "\n";
}
return 0;
}
NOIP 实战避坑指南
- 循环顺序颠倒与边界溢出
预处理 ST 表时,部分选手习惯性将 $i$ 放在外层, $j$ 放在内层。由于 $st[i][j]$ 依赖于 $st[i + 2^{j-1}][j-1]$,在外层为 $i$ 的情况下,计算当前状态时所需要的 $i + 2^{j-1}$ 处的 $j-1$ 状态尚未被外层循环扫到,导致读入未初始化的垃圾值。此外,内层控制边界i + (1 << j) - 1 < n缺失会导致数组越界踩中段错误(SIGSEGV)。 - 位运算优先级与常数灾难
查询语句中r - (1 << k) + 1,如果写成r - 1 << k + 1,由于加减法运算符优先级高于移位运算符,实际执行逻辑变为(r - 1) << (k + 1),导致区间逻辑彻底崩溃。另一致命低级错误是在查询阶段或预处理阶段高频调用库函数std::log2(),该函数涉及浮点数计算与 FPU 指令转换,会带来高额常数开销,在 $10^6$ 级别查询下直接导致 TLE。必须使用递推数组或芯片内置汇编指令计算 $O(1)$ 的对数。
经典 NOIP/洛谷 真题
洛谷 P3865 【模板】ST 表
- 题意描述:给定一个长度为 $N$ 的序列及 $M$ 组询问,每组询问要求输出区间 $[L, R]$ 内的最大值。$N, M \le 10^5$。
- 问题本质与核心思路:静态区间最值查询的标准模板。任何线段树或树状数组的 $O(\text{log } N)$ 查询方案在此题标准时限下均面临卡常风险。解题本质是运用倍增法预处理出所有 $2^j$ 长度的区间最值,利用幂等性通过常数级空间重叠覆盖完成 $O(1)$ 响应。
洛谷 P2880 [USACO07JAN] Balanced Lineup G
- 题意描述:给定每天的奶牛身高序列,多次询问某个特定区间内最高奶牛与最矮奶牛的身高差。$N \le 5 \times 10^4$,$M \le 2 \times 10^5$。
- 问题本质与核心思路:多指标静态 RMQ 问题。问题的本质是同时维护区间最大值与区间最小值。由于静态不涉及修改操作,只需建立两个独立的 ST 表分别维护 $\text{max}$ 和 $\text{min}$。查询时分别以 $O(1)$ 读出两表结果,作差即可。