ST表--倍增思想

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 不影响最大值。
  • 同样,mingcdlcm 也都满足这个性质。

异或 则不满足(重叠部分会被重复计算),因此 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=4k=2(2^2=4),则答案为 max(st[1][2], st[4-4+1][2]) = max(st[1][2], st[1][2]),实际上两个区间重合,结果正确。

查询 [1, 5]len=5k=2(2^2=4),则 max(st[1][2], st[5-4+1][2]) = max(st[1][2], st[2][2])st[1][2] 覆盖 1,4st[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]);
}

例题

下面我们先来尝试一个基础的板子题

P3865 【模板】ST 表 & RMQ 问题 - 洛谷

本题就是一个经典的模板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表的倍增思想其实还有很多方面的应用,比如求公共祖先我们也是可以用到倍增来优化的,主包学识渐浅,只能说遇到慢慢学吧!

相关推荐
东华万里1 小时前
第40篇 C++核心基础与工程实践:从底层逻辑到避坑指南
开发语言·c++·大学生专区
AC赳赳老秦1 小时前
OpenClaw 多源采集公开行业数据:从原始信息到研究报告初稿的自动化实践
java·c语言·c++·python·php·deepseek·openclaw
程序员雷欧1 小时前
AQS深度解析
开发语言·python
玩三国杀玩的1 小时前
Pytorch-c++-CUDA
c++·pytorch·python·深度学习
马可家的菠萝1 小时前
Vue3 + Canvas 手绘笔记工程化实践:别把画布只当成一张 PNG
前端·vue.js·算法
watersink1 小时前
机器学习极大似然估计与EM算法
人工智能·算法·机器学习
叠层归一研究院2 小时前
如何用程序搭建一个 AGI 种子系统(一):从向量种子到无限生长引擎
人工智能·python·算法·机器学习·agi
科学实验家2 小时前
并 查集
算法
xiaohebang2 小时前
流失预测模型设计:行为特征分析算法选型
大数据·数据结构·经验分享