ST表--倍增思想
何为倍增思想?当我刚刚接触它的时候也是一脸的蒙圈,但是它确实是一个得学的算法啊,有些题目会卡时间复杂度,那么我们用倍增觉可以把时间复杂速度降下来。
引言
ST是一种用于解决静态区间最值查询的数据结构。
其特点有:
- 预处理 时间复杂度
O(n log n) - 查询 时间复杂度
O(1) - 空间复杂度
O(n log n) - 只支持静态数组(没有元素修改操作)
问题定义
给定一个长度为 n 的数组 a[0..n-1](或 1..n),需要回答若干次询问,每次询问给定区间 [L, R],求该区间内的最大值(或最小值、GCD、LCM 等可重复贡献运算)。
重复贡献是指:对于一个区间,将其分成两个重叠的子区间,分别计算结果,再将两个结果合并,不影响最终答案。比如:
max(a, b, c) = max( max(a, b), max(b, c) )
两个子区间[a,b]和[b,c]重叠了b,但两次计算b不影响最大值。- 同样,
min、gcd、lcm也都满足这个性质。
而 和 、异或 则不满足(重叠部分会被重复计算),因此 ST 表不适用于这些运算。
倍增思想的核心
ST 表利用 倍增 的思想:
任何整数都可以表示为若干个 2 的幂次之和。对于区间长度,我们预处理所有长度为 2 的整数次幂 的子区间的最值,查询时用两个预先计算好的区间合并来覆盖目标区间。
预备知识 :log2 函数。我们可以预计算一个数组 log2[i] 表示整数 i 向下取整的 log2 值,即最大的 k 满足 2^k ≤ i。这可以在 O(n) 时间内递推出来。
其核心
给定区间 [L, R],长度为 len = R - L + 1。
令 k = floor(log2(len)),即最大的 k 使得 2^k ≤ len。
那么区间 [L, R] 的最值 = max( st[L][k], st[R - 2^k + 1][k] )。
为什么可以这样?因为两个长度均为 2^k 的区间:
- 第一个从 L 开始:
[L, L+2^k-1] - 第二个从
R - 2^k + 1开始:[R-2^k+1, R]
由于 2^k 大于等于区间长度的一半,这两个区间一定会重叠(或恰好覆盖整个区间),且它们的并集正好覆盖 [L, R]。对于最大值/最小值这类可重复贡献(即重叠部分多次计算不影响结果)的问题,取两者的极值即可得到整个区间的极值。
示例 :查询 [1, 4],len=4,k=2(2^2=4),则答案为 max(st[1][2], st[4-4+1][2]) = max(st[1][2], st[1][2]),实际上两个区间重合,结果正确。
查询 [1, 5],len=5,k=2(2^2=4),则 max(st[1][2], st[5-4+1][2]) = max(st[1][2], st[2][2]),st[1][2] 覆盖 1,4,st[2][2] 覆盖 2,5,它们重叠 2,4,覆盖整个 1,5。

根据这个我们可以很清楚的知道,如果需要查询区间L,R的话,我们只需要O(1),而前面的预处理,也只是需要O(n log n)
模板
这是一个构架ST表和查询的板子,有兴趣者可以自取
cpp
const int MAXN = 100005;
const int LOG = 17; // 因为 2^17 > 1e5
int st[MAXN][LOG]; // st[i][j] 表示从 i 开始长度为 2^j 的最大值
int lg[MAXN];
void init(int a[], int n) {
lg[1] = 0;
for (int i = 2; i <= n; i++) lg[i] = lg[i/2] + 1;
for (int i = 0; i < n; i++) st[i][0] = a[i];
for (int j = 1; (1 << j) <= n; 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) { // 闭区间 [L,R], 0-based
int j = lg[R - L + 1];
return max(st[L][j], st[R - (1 << j) + 1][j]);
}
例题
下面我们先来尝试一个基础的板子题
本题就是一个经典的模板ST表倍增问题,我们可以根据上面所说构建ST表,然后查询区间的最值问题
cpp
#include<bits/stdc++.h>
using namespace std;
#define IOS ios::sync_with_stdio(0),cin.tie(0),cout.tie(0)
#define endl '\n'
// #define int long long
#define pii pair<int,int>
#define fi first
#define se second
#define YES cout<<"YES"<<endl;
#define NO cout<<"NO"<<endl;
const int INF=1e6+5;
int lg[INF];
int st[INF][30];
void solve()
{
int n,m;
cin>>n>>m;
vector<int> a(n);
for(int i=0;i<n;i++) cin>>a[i];
lg[1]=0;
for(int i=2;i<=n;i++) lg[i]=lg[i/2]+1;
for(int i=0;i<n;i++) st[i][0]=a[i];
for(int j=1;(1<<j)<=n;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]);
}
}
while(m--)
{
int L,R;
cin>>L>>R;
L--;
R--;
int j=lg[R-L+1];
int ans=max(st[L][j],st[R-(1<<j)+1][j]);
cout<<ans<<endl;
}
// cout<<fixed<<setprecision(x)<<
}
signed main()
{
IOS;
int _=1;
// cin>>_;
while(_--)
solve();
return 0;
}
现在来一道进阶版本的
这个题可以先尝试着做一下
P7167 eJOI 2020] Fountain (Day1) - 洛谷
这个题我们乍一看,其实是一个很简单的题目啊,我们把每一个水池他的下一个水池连接起来,每一个都进行操作,然后Q次询问的话我们进行Q次查询,那不就是通过单调栈实现连接,然后通过遍历来实现每一次查询的访问嘛!
单调栈连接
cpp
void find_max(){
for(ri i=1;i<=n;i++){
while(stk.empty()==0&&d[i]>d[stk.top()]){
b[stk.top()]=i;//记录每个盘子往右第一个直径比它大的盘子编号。
stk.pop();
}
stk.push(i);
}
while(stk.empty()==0){
b[stk.top()]=0;//0代表水池。
stk.pop();
}
}
查询
cpp
for(ri i=1;i<=q;i++){
scanf("%d%d",&r,&v);
int tmp=r;
while(tmp!=0){
v-=c[tmp];
if(v<=0)break;
tmp=b[tmp];
}
printf("%d\n",tmp);
}
这样就结束了吗?
实际上这个代码的时间复杂度是远远超出1s的,他的时间复杂度是O(n^2),那么对于这个题的数据是肯定会TLE的,基本只能拿一个30分
那么怎么优化呢?
优化
首先我们知道这个单调栈是无法避免的必须有的程序,而且它的时间复杂度也不高。
那么我们就只能着手在下面的遍历了,两层便利太慢了。
那么我们首先考虑到二分?时间复杂度上面确实是降下来了,但是我们对于任意一段距离进行计算,实际上也是不太能算,那么肯定也是不能计算的
如果二分可以优化时间复杂度,那么我们就想这个倍增的原理是不是也是log n的跳跃?对,它也可以进行时间复杂度的优化。那么对于倍增来说,我们进行预处理,那么对于Q次询问我们就可以实现O(1)的查询了。合理
那么我们知道方法是倍增了,那怎么实现呢?它和我们板子倍增又是不太一样的。但是呢其实也是差不太多的
首先我们进行单调栈维护递减区间,然后返回一个每一个圆盘的下一个圆盘是谁。
cpp
stack<int> st;
for(int i=1;i<=n;++i) up[i][0]=0; //初始化每一层的下一级是地面(值为0表示地面)
for(int i=1;i<=n;++i){
while(!st.empty()&&D[i]>D[st.top()]) {
up[st.top()][0]=i; //表示st.top()的下一个圆盘(能兜底)是i。这里表示的是st.top()为起点长度为2的0次方的盘子是i
st.pop();
}
st.push(i);
}
while(!st.empty()) {
up[st.top()][0] = 0;//最后剩下在栈里面的表示i没人给它们兜底,所以它们的下一个盘子都是地面
st.pop();
}
预处理
单调栈完成之后,我们就要对ST表进行预处理了。
首先就是对sum数组的初始化,由于我们没进行预处理呢,所以开始每一个圆盘的前缀和都是自己
cpp
for(int i=1;i<=n;++i) sum[i][0]=C[i];
然后我们进行预处理
首先对于每一个圆盘的指数级别,我们正常是这样遍历的2的k次方,那么我们就便利其2的k次方个,但是i这样一层一层便利太慢了,所以我们可以这样优化,遍历这一层2的k次方,我们先遍历2的k-1次方次方这一个,然后另外一半就等于以它为起点然后向右走2^k-1长度,那么我们就可以写成
cpp
int mid=up[i][j-1];
up[i][j]=up[mid][j-1];
前缀和写成
cpp
sum[i][j]=sum[i][j-1]+sum[mid][j-1];
由于很多数值都是未知的,所以我们正序遍历的情况下就会一个一个把未知的数据补充上。
完整的预处理代码
cpp
for(int j=1;j<20;++j) {
for(int i=0;i<=n;++i) {
int mid=up[i][j-1];
up[i][j]=up[mid][j-1];
sum[i][j]=sum[i][j-1]+sum[mid][j-1];
}
}
接下来就是对q次询问了,我们只需要对sum数组倒序遍历,对这个容量所比较和差值。
这个写法有两种:其中一种是没注释的,另外一种是注释掉的
查询代码
cpp
while(q--){
int R,V;
cin>>R>>V;
int cur=R;
int rem=V;
for (int j=19;j>=0;--j) {
if (sum[cur][j]<rem) {
rem-=sum[cur][j];
cur=up[cur][j];
}
// if (up[cur][j]!=0&&sum[cur][j]<rem) {
// rem-=sum[cur][j];
// cur=up[cur][j];
// }
}
if(cur==0) {
cout<<0<<endl;
} else{
cout<<cur<<endl;
}
// if(rem<=C[cur]){
// cout<<cur<<endl;
// }else{
// cout<<0<<endl;
// }
}
最后,呈上完整代码
cpp
#include<bits/stdc++.h>
using namespace std;
#define IOS ios::sync_with_stdio(0),cin.tie(0),cout.tie(0)
#define endl '\n'
#define int long long
#define pii pair<int,int>
#define fi first
#define se second
#define YES cout<<"YES"<<endl;
#define NO cout<<"NO"<<endl;
const int MAXN=1e6+5;
int D[MAXN],C[MAXN];
int nxt_[MAXN];
int up[MAXN][20];
int sum[MAXN][20];
void solve()
{
int n,q;
cin>>n>>q;
for(int i=1;i<=n;++i) {
cin>>D[i]>>C[i];
}
stack<int> st;
for(int i=1;i<=n;++i) up[i][0]=0;
for(int i=1;i<=n;++i){
while(!st.empty()&&D[i]>D[st.top()]) {
up[st.top()][0]=i;
st.pop();
}
st.push(i);
}
while(!st.empty()) {
up[st.top()][0] = 0;
st.pop();
}
for(int i=1;i<=n;++i) sum[i][0]=C[i];
for(int j=1;j<20;++j) {
for(int i=0;i<=n;++i) {
int mid=up[i][j-1];
up[i][j]=up[mid][j-1];
sum[i][j]=sum[i][j-1]+sum[mid][j-1];
}
}
while(q--){
int R,V;
cin>>R>>V;
int cur=R;
int rem=V;
for (int j=19;j>=0;--j) {
if (sum[cur][j]<rem) {
rem-=sum[cur][j];
cur=up[cur][j];
}
// if (up[cur][j]!=0&&sum[cur][j]<rem) {
// rem-=sum[cur][j];
// cur=up[cur][j];
// }
}
if(cur==0) {
cout<<0<<endl;
} else{
cout<<cur<<endl;
}
// if(rem<=C[cur]){
// cout<<cur<<endl;
// }else{
// cout<<0<<endl;
// }
}
// cout<<fixed<<setprecision(x)<<
}
signed main()
{
IOS;
int _=1;
// cin>>_;
while(_--)
solve();
return 0;
}
ST表的倍增思想其实还有很多方面的应用,比如求公共祖先我们也是可以用到倍增来优化的,主包学识渐浅,只能说遇到慢慢学吧!