☰
ST表详解:倍增思想与RMQ区间最值查询O(1)模板
2026/10/10 15:22:55 网站建设 项目流程

最近在带几个学弟练算法题,发现大家一遇到“多次区间查询最大值/最小值”的题目,第一反应就是线段树,然后就开始敲一堆复杂代码。其实很多场景下没那么大动干戈,ST表就是那种“杀鸡用砍刀,偏要选对刀”的思路:预处理花点时间,之后每次查询都是O(1)。想快速理解倍增思想、想直接抄C++模板的,这篇文章应该能帮你省下不少时间。

我也见过不少同学把ST表代码背得滚瓜烂熟,可一旦让他解释f[i][j]到底存的啥、为什么查询要拆成两段覆盖,就支支吾吾。所以这篇文章不想只给代码,我想把整个设计思路、边界处理、复杂度来源都拆开讲清楚,顺便回忆一下自己当初是怎么踩坑的。

1. ST表是啥:一个查区间最值只要O(1)的“预计算查表法”

ST表(Sparse Table)的核心解决场景很简单:给一个固定不变的数组,后面有大量查询,每次问某个区间[l, r]的最大值或最小值是多少。注意“固定不变”这四个字很关键,因为ST表不支持修改——要是数组中途会被改值,那就要考虑线段树了。但如果是纯查询、尤其是查询次数特别多的场景,ST表的速度可以做到每次查询只做几次位运算和比较,这比线段树的O(logn)快了不少。

它的底层套路就是倍增思想,搭配动态规划先做预处理。通俗点说,我先把所有“长度为2的幂”的区间最值全部算出来存好。比如长度为2的区间最大值、长度为4的区间最大值、长度为8的区间最大值……都提前算好存表。等查询时,任意一个区间[l, r],它的长度len不一定恰好是2的幂,那也没关系,我可以把整个区间拆成两个“有重叠部分”的长度为2的幂的区间,各取最值再比一下,就能得到正确答案。这就是ST表最神奇的地方:两个子区间可以重叠,不会影响最值结果。

这个设计背后看中的是“重复贡献”类问题的特点。什么是可重复贡献?比如最大值、最小值、最大公因数,这类操作的特点是:在同一个集合里,同一元素被算两次不影响最终结果。max([a,b,c])和max(max([a,b]), max([b,c]))结果一样,中间的b被算了两次,没任何影响。ST表正是利用了这一点,才允许查询时拆出去的两个子区间重叠。如果换成sum或xor这类操作,那就不行了,因为你不能把同一个元素重复加进去。

所以说,ST表非常适合RMQ(Range Maximum/Minimum Query)类问题。很多经典题目里,比如区间最大差值、滑动窗口变体、离线后批量查询,其实都可以先用ST表稳住O(1)查询,再配合其他算法使用。

2. 核心推导:f[i][j]的递推公式,以及倍增思想怎么落地

ST表的预处理核心是一个二维数组f[i][j]。定义很简单:f[i][j]表示从下标i开始,长度为2^j的这段区间内的最大值。那么显然有:

  • f[i][0] = 原数组第i个元素本身,因为长度为2^0 = 1的区间就是它自己。
  • 当j > 0时,长度为2^j的区间可以均匀拆成两半:左半边从i开始长度为2^(j-1),右半边从i + 2^(j-1)开始长度也是2^(j-1)。于是就有了递推公式:f[i][j] = max(f[i][j-1], f[i + (1 << (j-1))][j-1])。

这个式子展开看挺直观。比如f[0][3]表示从0开始长度为8的区间最值,它就可以由两个长度为4的区间最值合并而来:f[0][2]和f[4][2]。整个过程从j=0一层层往上推,因为每一层依赖的区间长度刚好是下一层的一半,所以必须先算完小长度,再算大长度。

为什么想到用2的幂,而不是3的幂、10的幂?主要是计算机里位运算太友好了。2的幂意味着可以用1 << k来快速计算长度,用k = log2(len)来定位幂次,而且递推的时候每个数字都还能保持整数幂次,不会出现长度除不尽的情况。从工程角度来说,倍增配合二进制天然就是一体。

查询的时候,我们要算任意区间[l, r]的最值。设区间长度len = r - l + 1,然后取k = floor(log2(len))。那么答案就是max(f[l][k], f[r - (1 << k) + 1][k])。这里两个子区间长度都是2^k,第一个以l为左端点,第二个以r为右端点,两者由于2^k <= len,所以它们拼起来一定能完整覆盖整个[l, r]。为什么允许重叠?因为最大值是“可重复贡献”的操作,重复覆盖不影响最终答案。

有同学可能会疑惑,为什么不直接用一个恰好覆盖整个长区间的幂次?因为不是所有len都是整数幂,所以只能选不超过len的最大幂,然后左右各覆盖一段,确保所有元素都被包含在两段中。这两个区间可能存在交集,交集部分会被重复比较,但这完全没问题。

从这个推导也可以看出,ST表的时间复杂度是O(nlogn)预处理、O(1)查询。空间复杂度也是O(nlogn)。如果我们用log数组提前把每个数字的floor(log2(index))算好,查询时连math库函数都不用调,那速度还能更快一些。

3. C++代码实战:预处理、查询函数与完整可运行模板

下面我直接把代码模板写出来,并且逐段解释每一行在做什么。这段代码处理的是区间最大值问题,最小值就把max换成min,其他地方完全不用动。

3.1 预处理部分:log数组和f数组怎么填

首先我们需要一个log数组,专门记录log2的整数下取整结果。使用数组方式可以避免反复调用log2函数,在大量查询时会快很多,也更准确。通常我会这样写:

vector<int> lg(n + 1); lg[1] = 0; for (int i = 2; i <= n; i++) { lg[i] = lg[i / 2] + 1; }

这里lg[i]表示不超过i的最大整数k,满足2^k <= i。例如lg[7] = 2,因为2^2 = 4 <= 7,而2^3 = 8 > 7。这种递推写法其实就是在反复用“整除以2”来抵消掉一个2因子,直到不能再除为止。

然后定义f数组:

vector<vector<int>> f(n + 1, vector<int>(lg[n] + 1, 0));

注意f的第二维大小至少要lg[n] + 1,因为索引从0到lg[n]都要用。f[i][j]中,i从0或1开始都可以,只要和数组下标约定一致。接下来先初始化j = 0的情况:

for (int i = 0; i < n; i++) { f[i][0] = arr[i]; }

再循环填表。外层循环j从1做到lg[n],内层循环i从0开始,在i + (1 << j)不越界的情况下赋值:

for (int j = 1; j <= lg[n]; j++) { for (int i = 0; i + (1 << j) - 1 < n; i++) { f[i][j] = max(f[i][j - 1], f[i + (1 << (j - 1))][j - 1]); } }

这里循环边界必须写成i + (1 << j) - 1 < n,表示从i开始长度2^j的区间最后一个下标要小于n。很多新手会写i < n,这样在j比较大的时候直接越界访问,程序崩溃或者返回乱值。填完以后,f[i][j]就是刚才推导的那个含义,你可以随时抽查验证:随便取一个i和j,手算一下区间最大值,对比程序输出的结果。

3.2 查询部分:O(1)组合两个子区间

查询代码比预处理简单很多,核心就三步:

int query(int l, int r) { int len = r - l + 1; int k = lg[len]; return max(f[l][k], f[r - (1 << k) + 1][k]); }

第一次看这个代码的人可能会愣住:明明一个区间,为什么要取f[l][k]和f[r - (1<<k) + 1][k]?因为保证覆盖。举个例子,如果len = 5,那么k = floor(log2(5)) = 2,两个子区间长度都是4。第一个覆盖的是[l, l+3],第二个覆盖的是[r-3, r],也就是[l+1, l+4](如果l=0,r=4)。两者拼接覆盖了[0, 4]整个区间,中间有重叠,但最终答案不会受影响。

要注意:查询的时候,两个子区间的长度都是2^k,其中2^k <= len,所以它们一定各自落在[l, r]范围之内,不会越界。这依赖于lg[len]的取整逻辑,所以预处理lg数组必须保证正确。

3.3 完整代码模板:可直接粘贴运行

组合起来,我给一个能直接跑的完整样例。这个样例固定数组、多次询问,演示ST表的最基本用法。

#include <bits/stdc++.h> using namespace std; const int N = 100005; int arr[N], lg[N]; int f[N][20]; int main() { ios::sync_with_stdio(false); cin.tie(nullptr); int n, m; cin >> n >> m; for (int i = 0; i < n; i++) { cin >> arr[i]; } lg[1] = 0; for (int i = 2; i <= n; i++) { lg[i] = lg[i / 2] + 1; } for (int i = 0; i < n; i++) { f[i][0] = arr[i]; } for (int j = 1; j <= lg[n]; j++) { for (int i = 0; i + (1 << j) - 1 < n; i++) { f[i][j] = max(f[i][j - 1], f[i + (1 << (j - 1))][j - 1]); } } while (m--) { int l, r; cin >> l >> r; // 如果题目的下标从0开始,直接用 int len = r - l + 1; int k = lg[len]; cout << max(f[l][k], f[r - (1 << k) + 1][k]) << '\n'; } return 0; }

这里f的第二维我用的是20,因为2^20已经超过一百万,对多数题目够用。如果你处理的数据到10^6级别,第二维就要开到21或更大,具体看lg[n]的值。稳妥一点,也可以动态分配vector确保维度完全匹配,避免开小了出现越界。模板里的lg[1]单独赋值是因为没有比1再小的正整数能凑成powers,如果n为0或1时需要特判,不过实际题目中n基本都大于1。

4. 查询细节与边界参数:k的公式为什么必须这样写

到了实战环节,很多同学明明逻辑会了,一写就各种bug。我先列几个最常踩的坑。

第一个坑:log2的长度取整。直接用cmath的log2函数返回值是浮点数,直接赋给整数会发生截断,这没问题,但C++的浮点计算有时可能会因为精度问题,导致log2(8)返回2.9999999而不是3.0,一旦发生赋值截断就变成2,结果就错了。所以我在很多代码里宁可用lg数组递推,也不用log2函数,主要是为了避开精度坑。如果非要用函数,记得自己加一个非常小的epsilon,或者写floor(log2(len)),但这里的风险只能说“大多数时候没问题”。

第二个坑:预处理的循环边界。刚才强调过,填f[i][j]时一定要保证右端点不超出数组范围,也就是i + (1 << j) - 1 < n。如果把条件写成i <= n - (1 << j),也差不多,但表达不够直观,容易漏掉减1。我见过有同学写i + (1 << j) < n,结果右边界刚好落在数组最后一个元素上时就跳过不填了,查询某些数据时答案正好差在这个边界上,非常难排查。

第三个坑:下标从0开始还是从1开始。ST表对两种方式都兼容,但你的查询和预处理必须保持一致。假如你输入的l和r是1-based(很多人习惯题目的下标从1开始),且数组也存成1到n,那查询时直接调用query(1, n)即可,因为f[1][k]没问题。但如果用0-based存储却按照1-based输入,那就要把读进来的l和r都减1再传给查询函数。最忌讳的是预处理用0-based、查询用1-based,直接错乱。

第四个坑:区间长度len可能出现0吗?如果l > r,len <= 0,那lg[len]完全是未定义行为。题目通常保证l <= r,但如果自己写测试用例时乱给,就可能爆出随机值。稳健的做法是查询函数开头先assert(l <= r),或者直接返回一个极小值之类的错误码。

第五个坑:两个子区间重叠会不会导致重复计算把结果搞大?对于max和min不会。你要记住这个前提。ST表并不适合求区间和、区间乘积,除非换一种预处理方式变成f[i][j]存从i开始的连续整数次幂的某种组合,但那就不是ST表了。

第六个坑:每次输出后要不要清空数组?多组测试样例时,如果你通过new或vector复用f,记得重置f数组。如果不重置,上一组样例的残留数据也会参与后续查询,尤其是当f[i][j]代表长度超过当前n时,那些没被覆盖的位置可能还留着旧数据,导致越界访问后还能返回看似正常的值。最简单的是每次新建vector,或者用memset清零后再重新填充。

第七个坑:数据范围超过2^20时怎么办?你需要让第二维根据n动态调整。比如n = 200000,lg[n]大约是17,第二维开到18或19就够。如果n = 10^6,第二维就需要20或21。不要图省事开一个固定值,结果数组越界,调试时既看不到数组内容又找不到错在哪里。

所以说,写ST表千万别觉得“核心就两个函数”,实际边界和初始化稍不注意就能引爆。我一般建议初学者新建一个测试函数,把暴力遍历方法写出来,用随机小规模数据对拍一下,确保模板准确无误后,再把暴力代码删掉换成ST表。这个习惯能省下巨量排查时间。

5. 复杂度与适用边界:什么时候选ST表,什么时候别选

从复杂度的角度说,ST表预处理O(nlogn)、查询O(1)、空间O(nlogn)。很多低估ST表的人会说,“反正都要O(nlogn)预处理,为什么不用线段树?”其实道理很简单:线段树单次查询是O(logn),虽然数据规模不大时两者看起来差不多,但一旦查询次数到10^6、10^7级别,那个logn的倍数就很明显了。ST表查询的常数特别小,就是两次取数组下标加一次比较,几乎可以认为是纯O(1)。

拿一个真实场景来说:给你一个固定数组,随机出10万次查询,暴力遍历每次O(n),整体就是10万*n,n稍微十万级直接超时。线段树每次查询O(logn),大约17次比较,10万次就是一百七十万次比较,也很快。但ST表每次查询差不多只做3个整数操作,差距在实际跑批任务里仍然显得很干净利落。尤其在做离线题目时,如果查询数量极大,ST表基本是压倒性选择。

不过ST表也有明显的软肋。第一,数组不支持修改。一旦你要在查询中穿插单点更新,ST表就不合适了,你必须要用线段树或树状数组。第二,空间复杂度较高。对于1e6级别的数组,f数组要存大约1e6*20个int,也就是80MB左右,有的平台内存限制只有64MB,那就必须谨慎优化。我见过有人把第二维按照n的log大小严格控制,并用vector动态分配,硬是在64MB内存限制下把1e6数据过了。第三,ST表只能处理“可重复贡献”性质的操作。我刚才说过,max、min、gcd这类可以重叠,但sum不行。如果你想区间求和,只能另想办法,比预处理前缀和简单得多,也就不需要ST表。

所以总结成一句话:当数组不变、查询极多、且操作满足可重复贡献性质时,ST表就是最优选择之一。如果数组变化频繁,那就不要硬套ST表模板,果断换成线段树。实际刷题时,常常可以把ST表和二分、双指针组合在一起,比如配合二分答案去快速检测某个区间的最大/最小值是否满足条件,这样能节省很多不必要的线段树代码。

6. 避坑经验库:那些年我调ST表踩过的雷,整理成速查表

这里我把自己调试ST表时总结的避坑心得都分享出来,绝大多数是常规博客不会写的琐碎问题。

6.1 数组越界的两种隐蔽形态

第一种是在预处理循环里用i < n而当i较大时继续访问f[i + (1 << (j-1))]导致越界。第二种是在动态vector中只给第二维开了lg[n] + 1的大小,但在某些极端情况比如n = 1时,lg[1] = 0,你后续又写了j = 1的分支,实际上这个分支不会进入,但如果你手写for (int j = 1; j <= lg[n]; j++)就没事,但一旦你写了< lg[n]或<=某个固定常数,就会白白浪费很多操作,或者访问不到足够的j值。

我推荐的做法是预处理之前先打印一下lg[n]的值,确保n和log维度对应上。之前帮A同学调代码,他困惑预处理时间有点久,后来发现他把第二维开到了25,所有n最大才5000,整体浪费很多内存和循环次数,虽然逻辑没错,但效率其实并不理想。如果是1e6的数据,这种浪费可能就导致MLE或TLE了。

6.2 log2函数与位运算的冲突

如果你使用cmath中的log2,并且用int k = log2(len),一定注意浮点截断。例如len=8时,标准库算出来的log2未必是精确整数,有的编译器在某些优化等级下确实出现过没对齐的情况。最好是自己写一个整数版的“手动取幂次”递推,也就是lg数组。我还见过另一种写法:用__lg等编译器内置函数,也能避免浮点误差,但可读性差一点,建议不依赖它。

6.3 区间右端点开闭习惯不一致

ST表里用闭区间表示法最省脑。但如果你习惯了左闭右开的写法,比如[l, r)表示查询[l, r-1]区间,那就很容易出现f[r - (1<<k)][k]这种下标少1的错误。我建议所有模板统一写成闭区间l和r,并在查询函数注释里写明“注意:l和r是下标,包含两端值”。这点在多人协作或者从其他代码抄模板时尤其容易出岔子。

6.4 多个测试样例时忘记重置数组

如果你用静态数组而不是vector,跑多组样例时记得重设f数组。很多竞赛选手习惯用memset(f, 0, sizeof(f))来重置二维数组,但f是int二维数组时,memset本身没问题,可如果f是vector,不能用memset,要用fill或重新assign。我个人的习惯是把整个ST表封装成一个类,内部用vector存储,每次调用init(n, arr)时重新assign数组大小,这样就自动覆盖旧数据。

6.5 使用最小值时也要对称处理

ST表求最小值和最大值写法完全对称,不需要额外改逻辑,但注意数组类型要统一。如果是求最大公约数(GCD),那么递推公式改为__gcd(f[i][j-1], f[i + (1 << (j-1))][j-1])即可。这个场景常出现在某些题目要求区间公约数尤其需要注意能否重叠这个问题,gcd和max一样可重复贡献,所以ST表也适配。求异或最大值就不可能直接用ST表,因为异或满足非幂等,重叠区间会导致重复计算。

6.6 常见问题速查表

症状可能原因解决办法
预处理时程序崩溃循环越界访问f[i + (1 << (j-1))]检查i + (1<<j) - 1 < n条件
查询结果偏小k取长了,子区间超出实际区间范围确认len = r-l+1,k=lg[len]
查询结果偏大可能有旧数据没清空init时重新分配或清零数组
多组样例数据错乱f数组未重置对每组样例重新assign
查询时下标出现负数r < (1<<k)或l为负数检查l和r是否合法,确保l <= r且非负
用log2函数结果偶错浮点精度问题改用lg递推数组
空间超限数组维度开太死动态计算lg[n],开恰好大小的vector
普通数据跑的过但边界卡住循环边界漏掉减1暴力对拍最小数据,如n=2、n=区间长度为2^k+1

这张表你可以打印出来贴在旁边,当你调ST表相关题目时,先对照一下优先级最高的三个问题:是否越界、是否用错下标起始方式、是否需要重置数组。很多时候百思不得其解的bug,最后都出在这三条上。

7. 扩展思考:ST表能跟哪些算法组合出更强的解法

理解了ST表以后,可以玩的花样其实不少。

比较常见的是把ST表和二分答案结合起来。比如给定一个长度为n的数组,有m个询问,想判断某个区间是否存在至少一个不小于x的数,那直接查询区间最大值再与x比较就行。如果有大量类似判断,并且x也随二分而动态变化,那么ST表的O(1)查询就能把二分过程变成O(logn)次O(1)查询,整体非常高效。

还有一种组合是ST表和分治思想结合。比如在做最近公共祖先(LCA)的离线RMQ转化时,可以把树转成欧拉序列,然后用ST表维护深度序列的最小值位置。这其实也是RMQ问题的一种经典套用。LCA用ST表实现的话可以做到预处理O(nlogn)、查询O(1),虽然空间占用大一些,但查询性能在密集场景中很突出。

ST表也可以扩展到二维数组,处理二维RMQ问题。比如给定一个n行m列的矩阵,多次询问子矩形内的最大值。此时我们需要一个f[i][j][k1][k2]的四维定义,复杂度会更高,实现也更麻烦。不过它依旧用的是倍增思想:先在行方向上预处理,再在列方向上合并。一般不推荐普通人从零手写,但了解它的存在有助于理解ST表的拓展性。

另外,动态ST表(支持尾部追加元素)在某些场合也有变体,不过那不是主流,实现的代码复杂度也高。我更推荐初学者先把静态ST表吃透,再去看更复杂的扩展话题。只要f数组的定义和预处理递推搞明白了,其他变体无非是在变换层数或增加维度,底层逻辑不会变。

我个人在实际做题中喜欢把ST表封装成一个结构体,内部包括数组引用、lg预处理、init方法和query方法,避免频繁函数传参和全局变量混乱。尤其到了写长代码的题目时,全局变量满天飞容易让后续调试崩溃。封装带query的对象后,主函数逻辑可以非常干净,也方便在多个测试样例之间复用。

8. 个人经验结尾:别把ST表当“死模板”,想清楚原理以后它只是工具

最后分享一点实际感受。我帮助零基础的朋友学习ST表时,发现最容易卡住的不是代码本身,而是“为什么查询可以重叠区间”这个反直觉的点。很多人被线段树的严格划分思想绑住了,总觉得区间查必须不重不漏。但ST表明确告诉我们是可重复贡献操作,所以允许两个查询区间相交。一旦想明白这个点,他对f[i][j]的理解会突然通透很多。

还有一个比较隐蔽的经验:做题时如果遇到多组查询,别总是急着追求代码的炫技。ST表的预处理是O(nlogn),很多新手容易把log数组也放在init函数里重复计算,但lg只依赖n,跟arr无关。可以在全局只对每个n预计算一遍,并将lg数组设为静态缓存,这样多组样例之间可以复用,能省下不少时间。

写代码时候我习惯按这个顺序检查:先确认n的范围,再确认第二维log的上限,再写lg数组,再写f[][0]初始化,再写j循环,最后写query函数。这个顺序能天然把边界问题稳定住。如果你发现自己在写的过程中频繁改下标,多半是还没想清楚当前用的是开区间还是闭区间,我建议先把注释写在代码里,然后再动手,看似多打几个字,实际能少踩很多坑。

ST表是个很经典但很小的知识点,掌握它以后再看树上倍增、LCA倍增、快速幂这些倍增思想的东西就顺多了。学算法忌讳“背模板”,你只要能像我上面这样把f[i][j]的推导写清楚,把两个子区间为什么能覆盖原区间的原因画出来,ST表就能变成顺手而用的常规工具,而不是需要考前死记硬背的陌生代码。希望这些分享能帮你在调试时少走点弯路。

(完)

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询