滑动窗口最大值这道题,很多人在初学算法时都刷过,最常见的解法是单调队列,一趟扫描搞定,时间复杂度 O(n)。但如果你只是背了个单调队列模板,往往会忽略一个同样经典,甚至在更多 RMQ(区间最值查询)场景里更通用的工具:ST 表(Sparse Table)。这篇指南我打算从零开始,把 ST 表的原理、预处理、查询、模板代码和滑动窗口最大值的完整例题解析一次讲透,顺便把我在实际写代码时踩过的坑和排查思路一起整理出来。不管你是刚学会数组和循环的新手,还是准备面试需要系统补数据结构的老手,跟着走一遍,都能写出能跑的代码,并且搞清楚它为什么这么写。
先说明白一点:滑动窗口最大值这道题,标准最优解确实不是 ST 表,而是单调队列。但这不妨碍我们认真学 ST 表,因为它解决的是"静态区间可重复贡献查询"这一类问题,滑动窗口最大值只是它的一个特例。学完它,区间最小值、区间 GCD、区间按位与这些问题你会一通百通。
1. 滑动窗口最大值的本质与解法选型
1.1 问题到底在问什么:暴力解法为什么不行
题目描述很朴素:给定一个长度为 n 的数组 a[1..n],以及一个固定大小的窗口 k,窗口从左往右每次滑动一格,要求输出每个窗口内的最大值。比如数组是 [1, 3, -1, -3, 5, 3, 6, 7],k = 3,那么第一个窗口 [1, 3, -1] 的最大值是 3,第二个窗口 [3, -1, -3] 的最大值是 3,依次类推,一共要输出 n - k + 1 个结果。
最直观的做法是暴力:每个窗口都扫一遍里面的 k 个元素,取最大值。这个思路完全正确,但复杂度是 O(nk)。假设 n = 100000,k = 50000,那就是 50 亿次比较,在绝大多数在线评测系统上都会超时。问题难在哪里?难在窗口每移动一次,旧的元素离开,新的元素进入,窗口内部的"最大值"不是简单的继承关系——离开的元素恰好是最大值,或者新进入的元素很大,都会让结果突变。你没法像维护前缀和那样,用一两个前缀值加减得出答案。
前缀和之所以不行,是因为它只支持"可逆"运算,即区间和可以通过两个前缀和相减得到。但最大值这个运算是不可逆的:你知道了 [1..r] 的最大值和 [1..l-1] 的最大值,并不能推出 [l..r] 的最大值。这就逼着我们换一种数据结构来思考。
1.2 五条主流路线,为什么单独把 ST 表拎出来讲
针对静态数组的区间最值查询,业界常见的解法有这么几类,我直接列个对比表:
| 解法 | 预处理复杂度 | 单次查询复杂度 | 支持动态修改 | 特点 |
|---|---|---|---|---|
| 暴力扫描 | O(1) | O(k) | 支持 | 思路最简单,只适合小数据 |
| 单调队列 | O(n) | 均摊 O(1) | 支持滑动窗口 | 滑动窗口场景的最优解 |
| 优先队列(堆) | O(n) | O(log n) | 支持 | 实现简单,但要配合懒删除 |
| 线段树 | O(n) | O(log n) | 支持 | 动态区间最值的通用解 |
| ST 表 | O(n log n) | O(1) | 不支持 | 静态 RMQ 查询速度无敌 |
看到这张表你可能会问:既然单调队列又快又简单,为什么还要花一篇的篇幅学 ST 表?原因有两个。第一,ST 表的 O(1) 查询在"大量离线查询"场景下是碾压级的优势。比如你拿到一万个随机区间 [l, r],要求每个区间的最大值,单调队列反而不好套,ST 表预处理完之后,每个查询就是一次常数级操作。第二,ST 表背后的倍增思想,是算法竞赛和高频面试里反复出现的基础思维,线段树的区间合并、LCA 的倍增跳法,全都和它同源。学一次 ST 表,相当于同时预习了五六个进阶知识点。
2. ST 表核心原理:倍增与重叠覆盖
2.1 预处理的是什么东西:st[i][j] 到底存了什么
ST 表的预处理思路用一句话概括就是:把数组切成大量"长度为 2 的幂"的区间,预先算出每个区间的最值,查询时用两个已知区间拼出目标区间。
具体定义是这样的:st[i][j]表示从下标 i 开始、长度为 2^j 的区间,即 [i, i + 2^j - 1] 这个范围内的最大值。注意这里 j 不表示终点,而是表示长度级别。比如 j = 0 时,st[i][0] 就是区间 [i, i] 的最大值,也就是 a[i] 本身;j = 1 时,st[i][1] 是 [i, i+1] 两个元素的最大值;j = 2 时,st[i][2] 是 [i, i+3] 四个元素的最大值。
你可以把它想象成一本"阶梯书签":第 0 层每个位置只管自己一个数,第 1 层每两个数共用一个结果,第 2 层每四个数共用一个结果。每一层的区间接力式翻倍。为什么偏偏选 2 的幂?因为任何一个正整数都可以表示成若干个 2 的幂的和,这保证了我们能用 O(log n) 个"标准长度区间"覆盖任意长度区间。ST 表在此基础上更进一步,利用"重叠无害"的性质,把 O(log n) 次合并压缩成了 O(1) 次,具体机理在 2.3 节讲。
2.2 状态转移方程:为什么必须把 j 放在外层循环
st[i][j] 的值不是凭空算出来的,它从两个长度为 2^(j-1) 的子区间拼接而来。把 [i, i + 2^j - 1] 从中间一分为二:
- 左半边:[i, i + 2^(j-1) - 1],对应 st[i][j-1]
- 右半边:[i + 2^(j-1), i + 2^j - 1],起点是 i + 2^(j-1),长度仍是 2^(j-1),对应 st[i + 2^(j-1)][j-1]
所以状态转移方程是:
st[i][j] = max(st[i][j-1], st[i + 2^(j-1)][j-1])初始条件就是 st[i][0] = a[i]。这段逻辑本身不难,难的是实现时的循环顺序。请看这个依赖关系:我们计算 st[i][j] 时,依赖的是第 j-1 层的两个值。也就是说,整张表必须一层一层地算——先把所有位置的 st[i][0] 填好,再填所有 st[i][1],然后才是 st[i][2]。如果把 i 放在外层、j 放在内层,那么算到 st[i][2] 时,它依赖的 st[i + 2][1] 可能还没被计算到,结果就会错得莫名其妙。
代码里的循环结构就应该是这样:
for (int j = 1; (1 << j) <= n; j++) { for (int i = 1; i + (1 << j) - 1 <= n; i++) { st[i][j] = max(st[i][j - 1], st[i + (1 << (j - 1))][j - 1]); } }内层循环的上界i + (1 << j) - 1 <= n保证了我们不会越界访问数组。很多新手会漏掉这个条件,或者写成i + (1 << j) <= n,差一个 1 就是数组越界和答案错误的分界线,务必看清楚。
2.3 O(1) 查询的数学基础:区间为什么允许重叠
ST 表最妙的地方在于查询这个环节。给定任意查询区间 [l, r],我们记区间长度 len = r - l + 1,然后取 k = floor(log2(len)),也就是小于等于 len 的最大 2 的幂所对应的指数。注意这里我们不需要把 len 拆成多个 2 的幂之和,只需要用两个长度为 2^k 的区间:
- 第一个区间从 l 开始:[l, l + 2^k - 1],对应 st[l][k]
- 第二个区间到 r 结束:[r - 2^k + 1, r],对应 st[r - 2^k + 1][k]
然后直接取这两个区间的最大值作为答案。为什么这样是对的?关键在于 k 的取值保证了两个区间必然覆盖整个 [l, r]。因为 2^k <= len,所以第二个区间的左端点 r - 2^k + 1 >= l,第一个区间和第二个区间要么刚好相邻,要么相互重叠,不会有空隙;又因为 len < 2^(k+1),所以第二个区间的左端点不会越过第一个区间的右端点,两个区间都不会超出 [l, r] 的边界。合在一起,它们的并集正好完整覆盖住 [l, r]。
那重叠的部分怎么办?这里就要引出一个非常重要的概念:ST 表只能处理"可重复贡献"问题。所谓可重复贡献,是指这个运算满足结合律,并且同一个元素被重复参与计算不影响最终结果。最大值就满足这一点:max(x, x) = x,就算重叠区域被算了两次,得出的最大值也不会变。最小值、最大公约数、按位与、按位或也都满足这个性质,所以 ST 表统统能处理。但区间求和不满足——重叠区域被加两次,结果就错了。这也是为什么 ST 表不能替代前缀和去算区间和,两者适用的运算是两个不同的集合。
3. 手写 ST 表模板:从预处理到查询
3.1 预处理细节:lg 数组、二维数组大小、初始化顺序
正式写代码之前,要先准备一个辅助数组 lg[x],用来快速求出 floor(log2(x))。注意这里不能用 C++ 的 log2 函数直接算整数,因为浮点误差在某些边界值上会翻车,而且每次查询都调用 log2 会有不必要的常数开销。预处理 lg 数组的经典写法是:
lg[1] = 0; for (int i = 2; i <= n; i++) { lg[i] = lg[i / 2] + 1; }为什么这样写?因为 floor(log2(i)) 和 floor(log2(i/2)) 之间恰好差 1。你可以拿几个数验证:lg[1] = 0,lg[2] = lg[1] + 1 = 1,lg[3] = lg[1] + 1 = 1,lg[4] = lg[2] + 1 = 2。完全正确。
二维数组的列数 LOGN 取多大?只要满足 2^LOGN > n 就行。绝大多数情况下 n 不超过 10 万,2^17 = 131072,所以 17 足够;如果 n 到了 100 万,那就要取 20。稳妥做法是开一个常量const int LOGN = 20;,或者用(n).bit_length()(C++ 里可以写成__lg(n) + 1)动态计算。内存方面,n = 10 万时,st 数组是 100000 x 17 个 int,大约 6.8MB,完全无压力;但 n 到 100 万时就是 80MB,在内存受限的环境里就要小心了,这种情况建议换单调队列或者分块。
3.2 查询模板与边界思考
查询函数的代码很短,但每一处边界都值得掰扯:
int queryMax(int l, int r) { int len = r - l + 1; int j = lg[len]; // 覆盖 len 的最大 2 的幂的指数 return max(st[l][j], st[r - (1 << j) + 1][j]); }r - (1 << j) + 1这个写法是最容易写错的地方,很多人会漏掉最后的 +1。我们推导一下:第二个区间长度是 2^j,终点是 r,所以起点就是 r - 2^j + 1。少一个 1,起点就会往左偏一位,查询结果可能来自窗口外的元素,答案错误。另外,这个查询模板适用于任何 [l, r],不需要管窗口大小是否正好是 2 的幂,也不需要管两个子区间是否重叠——这正是 2.3 节讲的"重叠无害"带来的便利。
3.3 模板的易错点速查
我把这个模板常见的坑整理成了一个小表,写代码之前对照一眼能省很多调试时间:
| 易错点 | 错误写法 | 正确做法 | 后果 |
|---|---|---|---|
| 预处理循环顺序 | i 在外层,j 在内层 | j 在外层,i 在内层 | 依赖值未算出,结果乱套 |
| 内层循环上界 | i + (1 << j) <= n | i + (1 << j) - 1 <= n | 数组越界或漏解 |
| 第二个区间起点 | r - (1 << j) | r - (1 << j) + 1 | 查询结果偏向左侧 |
| lg 数组初始化 | 从 i=1 开始lg[i]=lg[i/2]+1导致 lg[0] 参与 | 保证 lg[1]=0,从 i=2 开始 | lg 值偏大,k 取错 |
| LOGN 过小 | 固定 10 | 满足 2^LOGN > n | 高长度区间查询越界 |
4. 例题全解析:滑动窗口最大值的 ST 表解法
4.1 题目描述与破题思路
经典版题目是这样的:输入第一行给出两个整数 n 和 k,分别表示数组长度和窗口大小,第二行给出 n 个整数。要求输出 n - k + 1 个整数,分别是每个窗口中的最大值。这道题在大量在线评测系统里都有收录,也是很多公司面试题库里的常客,大家应该不陌生。
用 ST 表解这道题的破题思路其实非常直白:窗口所有可能的位置,也就是区间 [1, k]、[2, k+1]、...、[n-k+1, n],全都是事先确定的静态区间。这正好命中 ST 表的最佳场景——静态数组上的批量区间最值查询。我们只需要做一次 O(n log n) 的预处理,然后对每个窗口执行一次 O(1) 的 queryMax,就能把全部答案算出来。总复杂度 O(n log n + n),虽然不如单调队列的 O(n),但在常规数据范围下完全够用,而且代码的可推导性更强,不太容易写出隐蔽 bug。
4.2 C++ 完整实现与逐段讲解
下面是一份可以直接提交的 C++ 代码,我加了足够的注释:
#include <bits/stdc++.h> using namespace std; const int MAXN = 100005; const int LOGN = 17; // 2^17 = 131072 > 1e5 int n, k; int a[MAXN]; int st[MAXN][LOGN]; int lg[MAXN]; int queryMax(int l, int r) { int len = r - l + 1; int j = lg[len]; return max(st[l][j], st[r - (1 << j) + 1][j]); } int main() { ios::sync_with_stdio(false); cin.tie(nullptr); cin >> n >> k; for (int i = 1; i <= n; i++) cin >> a[i]; // 1. 预处理 lg for (int i = 2; i <= n; i++) lg[i] = lg[i / 2] + 1; // 2. ST 表第 0 层 for (int i = 1; i <= n; i++) st[i][0] = a[i]; // 3. 倍增递推,j 在外层 for (int j = 1; (1 << j) <= n; j++) { for (int i = 1; i + (1 << j) - 1 <= n; i++) { st[i][j] = max(st[i][j - 1], st[i + (1 << (j - 1))][j - 1]); } } // 4. 每个窗口一次查询 for (int i = 1; i + k - 1 <= n; i++) { int ans = queryMax(i, i + k - 1); cout << ans << (i + k - 1 == n ? '\n' : ' '); } return 0; }这里有三处实现细节值得说明。第一,数组下标统一从 1 开始,这样窗口区间 [i, i+k-1] 表达起来非常自然,不容易出现差一错误。第二,预处理的边界条件(1 << j) <= n和i + (1 << j) - 1 <= n是配套的,前者保证存在至少一个长度为 2^j 的合法区间,后者保证当前这个具体区间不越界。第三,查询循环里i + k - 1 == n用来控制换行,这只是输出格式要求,和算法本身无关。
4.3 Python 实现参考
如果平时主要用 Python 刷题,下面是等价实现。Python 的二维列表写法以及 bit_length 的用法稍有不同,我直接在代码里标注出来:
import sys def main(): data = sys.stdin.buffer.read().split() n = int(data[0]) k = int(data[1]) a = list(map(int, data[2:2 + n])) # n.bit_length() 保证 2^LOG 能覆盖 n LOG = n.bit_length() # st[i][j]:从 i 开始长度 2^j 的最大值 st = [[0] * LOG for _ in range(n)] for i in range(n): st[i][0] = a[i] # j 外层,和 C++ 同理 for j in range(1, LOG): step = 1 << (j - 1) limit = n - (1 << j) + 1 for i in range(limit): st[i][j] = max(st[i][j - 1], st[i + step][j - 1]) # lg 数组 lg = [0] * (n + 1) for i in range(2, n + 1): lg[i] = lg[i >> 1] + 1 ans = [] for l in range(n - k + 1): r = l + k - 1 length = r - l + 1 j = lg[length] ans.append(str(max(st[l][j], st[r - (1 << j) + 1][j]))) sys.stdout.write(" ".join(ans)) if __name__ == "__main__": main()Python 版本注意两点:一是n.bit_length()对于 n = 100000 会返回 17,正好覆盖到长度 2^16 = 65536 的预处理级别,查询时 lg[len] 最大也是 16,不会越界;二是如果 n = 1,bit_length 返回 1,内层 j 循环range(1, 1)直接不执行,st 只有第 0 层,查询依然正确。
4.4 这题的标准答案为什么是单调队列
我必须把话说透:纯粹为了 AC 这道滑动窗口最大值题,单调队列才是标准解法。它的核心思想是维护一个内部元素从队头到队尾单调递减的队列,窗口滑动时,新元素进队前先把队尾所有比它小的元素弹出,再把队列头部超出窗口范围的元素弹出,队头元素就是当前窗口的最大值。整个过程每个元素最多进队一次、出队一次,总复杂度 O(n),代码也不长。
那 ST 表的意义在哪里?在于它把这道题从"专门技巧"提升到了"通用模型"。窗口大小固定且连续滑动,用单调队列最舒服;但如果查询窗口的位置是任意的,甚至查询区间数量高达几十万个且相互之间没有任何滑动关系,单调队列就尴尬了,ST 表却能一视同仁地 O(1) 回答每个查询。我在实际做题中的体会是:这两者不是替代关系,而是互补关系。面试时如果能把两种方案都讲清楚,并指出各自的适用边界,比只背一个模板能拿到的印象分高得多。
5. 常见问题与排查经验
5.1 预处理循环顺序写反了,现象是什么
最经典的翻车现场就是把 i 放在外层、j 放在内层。错误代码长这样:
for (int i = 1; i <= n; i++) { for (int j = 1; (1 << j) <= n; j++) { st[i][j] = max(st[i][j - 1], st[i + (1 << (j - 1))][j - 1]); } }编译能通过,小数据偶尔也能跑对,但数据一大结果就错得离谱。原因就是计算 st[i][j] 的时候,需要依赖 st[i + 2^(j-1)][j-1],而内层循环从 j 从 1 开始变大的过程中,那一列的更小 j 值只算到了当前的 i,还没有算到更大的 i。换句话说,你用一个"还没填好"的格子去更新另一个格子,相当于站在半成品地基上盖楼。排查方法很简单:随便挑一个长度 5 以上的小区间,手算一遍正确结果对比输出,立刻就能暴露问题。
5.2 lg 数组求错的隐蔽场景
lg 数组的经典错误是写出lg[i] = lg[i - 1] + (i & (i - 1) == 0),这个写法的本意是遇到 2 的幂就加一,但位运算优先级容易踩坑,而且边界判断麻烦。我强烈建议统一用lg[i] = lg[i / 2] + 1这个版本。另外注意:lg[0] 在 C++ 全局数组里默认是 0,但理论上 lg[0] 是没有意义的,如果查询时错误地把长度为 0 的区间传进函数,就会用到这个 0,从而返回 st[l][0] 之类的错误结果。所以查询函数入口最好保证 len >= 1,在滑动窗口场景里这天然满足。
5.3 查询函数里那个 +1 为什么老是被漏
r - (1 << j) + 1里的 +1 是差一错误的重灾区。我的记忆方法是:区间 [l, r] 的长度是 r - l + 1,那么已知终点 r 和长度 len 时,起点就是 r - len + 1。这里 len = 2^j,所以起点 = r - 2^j + 1。你在草稿纸上推一次这个公式,把它和"长度公式里也有 +1"对应起来,就不容易忘了。如果真的漏了,症状也很典型:窗口较大时查询结果会整体偏向左边,而且越靠右的窗口偏差越大,非常难一眼看出来。
5.4 内存和常数的现实考量
ST 表空间是 O(n log n),这个代价在 n = 10 万时很轻松,但 n = 100 万时就到了 80MB 左右,很多平台的默认内存限制会顶不住。我在实际处理超大数据时,通常会先评估问题类型:如果只是单次滑动窗口,直接上单调队列;如果是静态离线批量查询,可以考虑基于分块的稀疏表(Sparse Table on Blocks)或者干脆用线段树。另外,查询量非常大时,ST 表的优势才会完全显现,因为预处理和查询的常数都很小,远优于线段树每次 O(log n) 的递归开销。
5.5 一个很容易被忽略的定位问题:ST 表不支持修改
ST 表构建完成后,数组里任何一个元素发生变化,理论上所有覆盖它的区间都要重新计算,相当于重新预处理一遍。所以它是彻底的静态数据结构。如果你的题目里有"单点更新 + 区间查询"这种动态操作,请直接放弃 ST 表,老实上线段树或树状数组。判断方法非常粗暴:题目里出现"修改""更新"字样,ST 表就要打个问号;出现"静态""离线""只查询"字样,ST 表就是首选之一。
5.6 我的一段调试心得
初学 ST 表时,我最常犯的错反而不是边界,而是"想当然地以为递推方向无关紧要"。为了彻底搞懂 j 外层循环的必要性,我在纸上给一个长度为 8 的数组完整手算了 st 表的前三层,然后用错误的循环顺序又算了一遍。两版结果一对比,墙面上的错误豁然开朗。我强烈建议你也这么做一次:拿 [2, 1, 4, 5, 3, 8, 6, 7] 这样的数组,手算 st[i][0]、st[i][1]、st[i][2],再照着这个手写过程对照你的代码,所有抽象的原理都会具体化。
6. 从滑动窗口到更广阔的 RMQ 世界
6.1 可重复贡献问题全家桶
前面反复强调"可重复贡献",这里总结一下 ST 表到底能处理哪些运算:最大值、最小值、最大公约数(GCD)、最小公倍数(LCM)、按位与(AND)、按位或(OR)、按位异或(XOR 其实不满足可重复贡献,因为 x^x = 0,重叠会把贡献抵消,所以要慎用)。这些运算都有一个共同点:它们都满足结合律,并且同一元素参与多次计算不会改变结果。比如求一个区间所有数的 GCD,重叠区域里的数被算了两次,但不影响整个区间的 GCD;但求区间和就不行,重叠意味着重复相加。
正因为如此,ST 表的能力边界非常清晰:它能一招通吃所有幂等结合运算的静态区间查询。你的知识库里只要有一个 ST 表模板,等于同时掌握了六七个问题的通用解法。这也是为什么我始终认为,即使知道单调队列是滑动窗口的最优解,ST 表依然值得投入时间去学透。
6.2 静态查询场景怎么选型
根据自己的刷题经验,我总结了一张简单的选型决策表:
| 场景特征 | 推荐方案 | 原因 |
|---|---|---|
| 窗口固定、连续滑动 | 单调队列 | O(n),无预处理负担 |
| 静态数组、海量随机区间查询 | ST 表 | O(1) 查询,预处理一次性摊薄 |
| 需要单点更新 | 线段树 / 树状数组 | ST 表无法承受频繁重建 |
| 数据量极大、内存吃紧 | 分块 + 预处理块内信息 | 空间降级到 O(n),查询 O(sqrt(n)) |
| 查询区间事先未知、动态在线 | 线段树 | 动态结构最稳定 |
ST 表还有一类衍生用法值得一提:在离线算法中配合其他思想使用。比如把原数组按某种规则排序后,把查询区间按照右端点排序,再用 ST 表辅助维护某些可合并信息,这已经进入了更高阶的算法设计范畴。学有余力的读者可以把 ST 表理解为"倍增思想的存储形态",后面学 LCA 的树上倍增、后缀数组的 RMQ 辅助时,你会发现全是老朋友。
6.3 我给新手的学习路线建议
如果这篇文章只能记住一句话,我想是这句:先暴力,再 ST,再单调队列。暴力让你理解问题本质,ST 表让你掌握通用模型,单调队列让你知道最优解如何诞生。按这个顺序学,每一步的动机都非常清楚,而不是一上来就背模板。
具体来说,拿到这类区间最值题,先确认是否静态、是否离线、是否有修改,再看查询数量和数据范围。遇到滑动窗口且 n 很大,默认往单调队列想;遇到静态数组加一堆随机区间查询,往 ST 表想;遇到动态更新,往线段树想。选型这关过了,题目已经解了一半。
我最后再分享一个小技巧:写 ST 表模板时,把 queryMax 单独抽成一个函数,并且在函数开头用 assert 保证 l >= 1 且 r <= n。调试阶段开着 assert,提交前再关掉,能帮你拦截掉绝大多数下标越界问题。这个习惯我从第一次手写 ST 表一直保留到现在,省下来的调试时间难以估量。