[c++算法] 一文带你搞懂倍增和st表!
前排提醒:由于我使用了某不知名图床,把图片全给搞丢了,导致观感较差,有空我会补回来。
大家好啊,我是 doooge,今天给大家带来的是:倍增&st表的详解。
倍增 & s t 表详解 \Huge \sf 倍增\&st表 详解 倍增&st表详解
1.什么是倍增&位运算
介绍倍增之前,我还需要介绍一下位运算。这一章很简单,如果你是 dalao 你也可以不看。
1.1 位运算
位运算是 c++ 底层的逻辑运算,位运算速度非常快,很多算法都可以用位运算优化。这里我就不细讲了,但是还是要提一些方便快捷的位运算技巧:
- 判断
x的第i位是否为 1 1 1:x&(1<<i)。 - 判断
x的第i位是否为 0 0 0:!(x&(1<<i))。 -
2
i
2^i
2i:
1<<i。 - 将
x的第i位变为 1 1 1:x|=1<<i - 将
x的第i位变为 0 0 0:x&=~(1<<i) -
- 将
x的第i位 01 01 01 翻转:x^=1<<i
- 将
注意:位运算的优先级很小,例如 1<<4+1 的答案其实是 1<<5,因为先算的是加法。
1.2什么是倍增
倍增,顾名思义,即为成倍成倍的增加(一般是两倍两倍的增加)。比如说有一个数列 a = { 1 , 2 , 4 , 8 , 16 , ⋯ } a = \{ 1,2,4,8,16,\cdots \} a={1,2,4,8,16,⋯} ,我们就可以说这个序列里的数是依次倍增的。所以, 1 1 1 倍增 k k k 次也就等于 2 k 2^k 2k。
倍增在哪里能用的上呢?最明显的就是二进制。
我们都知道,二进制的第 i i i 位表示的是 2 i 2^i 2i,比如说数字 13 13 13 在二进制表示下是这样的:
| 位数 | 3 3 3 | 2 2 2 | 1 1 1 | 0 0 0 |
|---|---|---|---|---|
| 每一位所表示的数 | 2 3 2^3 23 | 2 2 2^2 22 | 2 1 2^1 21 | 2 0 2^0 20 |
| 这个二进制表示的数: 13 13 13 | 8 8 8 | 4 4 4 | 0 0 0 | 1 1 1 |
于是,倍增算法就有了二进制拆分这个概念,二进制拆分是整个 st表 的精华。如果有学过多重背包的同学,对它一定不陌生吧。不过,与多重背包的二进制不同的是,对于数 i i i ,要拆成若干个 2 k 2^k 2k 这样的数。什么?你说你听不懂?我们直接就来举个例子吧。
假设有一个数 x x x ,有两种对它进行二进制拆分的方法:
法一:我们设一个数 k = 1 k=1 k=1 进行拆分:
- 若 x ≤ k x \le k x≤k,则记下答案 k k k,将 x x x 减去 k k k,将 k × 2 k \times 2 k×2。
- 若 x < k x < k x<k,将 k ÷ 2 k \div 2 k÷2。
- 这样一直重复到 x = 0 x=0 x=0,拆分即完成。
法二:遍历 x x x 二进制下的每一位,若第 i i i 位在二进制下为 1 1 1,则记下这个 i i i 所表示的数(也就是 2 i 2^i 2i)。
两种拆法对于 13 13 13 拆分的结果:
法一:
{
1
,
2
,
4
,
4
,
2
}
\{1,2,4,4,2\}
{1,2,4,4,2}
法二:
{
8
,
4
,
1
}
\{8,4,1\}
{8,4,1}
两种方法的代码:
法一:
void chai(int x){
int k=1;//设数k进行拆分
while(x>0){
if(x>=k){
cout<<k<<endl;//记录答案
x-=k,k<<=1;//这里的<<=1跟*2相等
}
else{
k>>=1;//这里的>>=1跟/2相等
}
}
return;
}
法二:
void chai(int x){
for(int i=30;i>=0;i--){//注意i要到0
if(x&(1<<i)){//判断第i位是否有数
cout<<(1<<i)<<endl;//记录答案
}
}
return;
}
正片:2.st表的构造
st表是一个经典的倍增思想的数据结构,可以高效求解静态区间的问题,十分实用。
注意:st表 是一个预处理的数据结构,所以它是一个离线算法。
2.1 st表的思想
st表 是一种奇妙的数据结构,它用于求解静态区间最值问题(也叫 RMQ),它由一个二维数组构成。st表 有着非常多的种类,比如说求区间最大最小的 st表,求区间和的 st表,求区间 gcd \gcd gcd 的st 表。
s t i , j st_{i,j} sti,j 表示以 i i i 做起点以及它往后 2 j 2^j 2j 个数中间 a k a_k ak 的最值( i ≤ k ≤ i + 2 j − 1 i \le k \le i+2^j-1 i≤k≤i+2j−1)。如果你写的 st表 是求区间最大值的 st表,那么 s t i , j st_{i,j} sti,j 就是存储着以 i i i 做起点以及它往后 2 j 2^j 2j 个数的最大值。求最小值和求和的原理是一样的。
假设数组 a = { 0 , 13 , 14 , 4 , 13 , 1 , 5 , 7 } a=\{0,13,14,4,13,1,5,7\} a={0,13,14,4,13,1,5,7} 和一个最大值 st表。那么 s t 1 , 0 st_{1,0} st1,0 保存的就是 a 1 a_1 a1 的值 0 0 0, s t 1 , 1 st_{1,1} st1,1 保存的是 a 1 a_1 a1 至 a 2 a_2 a2 的最大值 13 13 13, s t 1 , 2 st_{1,2} st1,2 保存的是 a 1 a_1 a1 至 a 4 a_4 a4 的最大值 14 14 14,以此类推。

(来源oiwiki,侵删)
知道了这个概念,我们就来手推一下 s t i , j st_{i,j} sti,j 的值吧!
| j = 0 j=0 j=0 | 0 0 0 | 13 13 13 | 14 14 14 | 4 4 4 | 13 13 13 | 1 1 1 | 5 5 5 | 7 7 7 |
|---|---|---|---|---|---|---|---|---|
| j = 1 j=1 j=1 | 13 13 13 | 14 14 14 | 14 14 14 | 13 13 13 | 13 13 13 | 5 5 5 | 7 7 7 | 无 |
| j = 2 j=2 j=2 | 14 14 14 | 14 14 14 | 14 14 14 | 13 13 13 | 13 13 13 | 无 | 无 | 无 |
| j = 3 j=3 j=3 | 14 14 14 | 无 | 无 | 无 | 无 | 无 | 无 | 无 |
由于 st表 中
j
j
j 表示的是长度为
2
j
2^j
2j 的区间,所以
j
j
j 的范围只有
0
0
0 到
⌊
log
n
⌋
\lfloor \log n \rfloor
⌊logn⌋,而
i
i
i 表示的是
1
1
1 到
n
n
n 中间的任意一个起点,所以 st表的空间复杂度就是:
O
(
n
log
n
)
O(n \log n)
O(nlogn)
2.2 st表的构造
我们要怎样快速的求出 st表 中间的值呢?请看这张图:

(自己画的,很丑见谅)
我们会发现, s t i , j st_{i,j} sti,j 由 s t i , j − 1 st_{i,j-1} sti,j−1 和 s t i + 2 j − 1 , j − 1 st_{i+2^{j-1},j-1} sti+2j−1,j−1 组成。也就是说,假设有一个 st表 是求区间最大值的,那么从 a i a_i ai 到 a i + 2 j − 1 a_{i+2^j-1} ai+2j−1 的最大值可以拆分成从 a i a_i ai 到 a i + 2 j − 1 − 1 a_{i+2^{j-1}-1} ai+2j−1−1 的最大值和从 a i + 2 j − 1 a_{i+2^{j-1}} ai+2j−1 到 a i + 2 j − 1 a_{i+2^j-1} ai+2j−1 的最大值,而然,拆分出的两个区间刚好对应着 s t i , j − 1 st_{i,j-1} sti,j−1 和 s t i + 2 j − 1 , j − 1 st_{i+2^{j-1},j-1} sti+2j−1,j−1 这两个区间(仔细想想)。
所以,我们就不难得到一个构建 st表 的代码:
for(int j=1;j<=lg[n];j++){
//st[i][0]直接用a[i]进行赋值或者直接输入
for(int i=1;i<=n-(1<<j)+1;i++){
//如果i循环到n的话,i+2j-1的区间范围就会越界(RE),所以这里要控制区间范围为n-2^j+1
st[i][j]=max(st[i][j-1],st[i+(1<<(j-1))][j-1]);//注意这里求的是最大值
}
}
至于构建 st表 的复杂度,根据你的求区间最值的操作有关。如果要求的是区间加和或最大最小值,st表 的时间复杂度就是 st表 要遍历的项数,也就是:
O
(
n
log
n
)
O(n \log n)
O(nlogn)
如果是要求区间
gcd
\gcd
gcd,那么因为求两个数的
gcd
\gcd
gcd 本来就要
log
w
\log w
logw 来求(
w
w
w 表示两个数的值域),所以构建的时间复杂度就是:
O
(
n
log
n
log
w
)
O(n \log n \log w)
O(nlognlogw)
2.3 st表构造的其他代码
在这里附上求区间和和区间 gcd \gcd gcd 的 st表 的构建代码:
区间和:
for(int j=1;j<=log2(n);j++){
for(int i=1;i<=n-(1<<j)+1;i++){
st[i][j]=st[i][j-1]+st[i+(1<<(j-1))][j-1];
}
}
区间 gcd \gcd gcd:
for(int j=1;j<=log2(n);j++){
for(int i=1;i<=n-(1<<j)+1;i++){
st[i][j]=__gcd(st[i][j-1],st[i+(1<<(j-1))][j-1]);
}
}
3.st表的查询
读到这里,聪明的读者不禁会问:“求 st表 个东西要干嘛?”别急,等会你就知道了。
考虑对 l l l 到 r r r 的区间求和,我们可以把它拆分成若干个长度为 2 k 2^k 2k 的区间。这是你发现了什么?诶,这不就是二进制拆分吗?没错,我们来举一个例子:
区间 [ 5 , 18 ] [5,18] [5,18]:拆成 [ 5 , 5 ] [5,5] [5,5], [ 6 , 7 ] [6,7] [6,7], [ 8 , 11 ] [8,11] [8,11], [ 12 , 15 ] [12,15] [12,15], [ 16 , 17 ] [16,17] [16,17] 和 [ 18 , 18 ] [18,18] [18,18]。
值得一提的是,这里的二进制拆分与第一章说的有所不同,这里求的是区间值和:
int query(int l,int r){
int k=0,ans=0;//与之前不同的是,之前的数2^k被如今的k取代
while(l<r){
if(l+(1<<k)<=r){
//这里的k代表的是2^k,所以写1<<k
ans+=st[l][k],l+=(1<<k);//更新答案和左端点
k++//k++=2^k*2
}
else{
k--;//k--=2^k/2
}
}
return ans;
}
这样的查询的时间 O ( log n ) O(\log n) O(logn),但是这并不是 st表 的重点,重点是将查询优化至 O ( 1 ) O(1) O(1)。
4.st表优化查询
st表 的查询并不都是 O ( log n ) O(\log n) O(logn) 部分查询其实是可以优化的,比如说求区间最大值。
4.1 查询优化的思路
考虑找出一个最大的 2 2 2 的正整数次方为 l e n len len,使得两个长度为 l e n len len 的区间能覆盖 l l l 到 r r r 这个区间。
显然:
l
e
n
=
2
⌊
log
2
(
r
−
l
+
1
)
⌋
len = 2^{\lfloor \log_2 (r-l+1) \rfloor}
len=2⌊log2(r−l+1)⌋
且:
2
×
l
e
n
≥
r
−
l
+
1
2 \times len \ge r-l+1
2×len≥r−l+1
可以看下图理解:

我们会发现,这两个长度为 l e n len len 的区间分别覆盖在 [ l , l + 2 l e n − 1 ] [l,l+2^{len}-1] [l,l+2len−1] 和 [ r − 2 l e n + 1 , r ] [r-2^{len}+1,r] [r−2len+1,r] 这两个区间是一定能够完全覆盖住 [ l , r ] [l,r] [l,r] 区间的。
所以!我们就可以通过这两个区间来求出区间最大值。也就是:
k
=
⌊
log
2
(
r
−
l
+
1
)
⌋
k=\lfloor \log_2 (r-l+1) \rfloor
k=⌊log2(r−l+1)⌋
max
[
l
,
r
]
=
max
(
s
t
l
,
k
,
s
t
r
−
2
k
+
1
,
k
)
\max [l,r] = \max(st_{l,k},st_{r-2^k+1,k})
max[l,r]=max(stl,k,str−2k+1,k)
这时聪明的读者就会发现,这两个小的区间中间相交了。所以,区间求和并不能让查询优化成 O ( 1 ) O(1) O(1)。
4.2 查询优化后代码
区间最大值:
int query(int l,int r){
int len=log2(y-x+1);
return max(st[x][len],st[y-(1<<len)+1][len]);
}
区间最小值:
int query(int l,int r){
int len=log2(y-x+1);
return min(st[x][len],st[y-(1<<len)+1][len]);
}
区间 gcd \gcd gcd:
int query(int l,int r){
int len=log2(y-x+1);
return __gcd(st[x][len],st[y-(1<<len)+1][len]);
}
注意:由于求 gcd \gcd gcd 本来就需要 O ( log w ) O(\log w) O(logw) 的时间复杂度,所以优化后的查询的时间复杂度一样是 O ( log w ) O(\log w) O(logw) 的。
4.3 优化 log 2 \log_2 log2 的常数
在前面的代码中,
log
2
\log_2
log2 这个函数我都用了 c++内置的 log2() 函数来写,但是 c++ 的 log2() 函数时间上来说的常数很大,所以我们可以把它预处理出来,下面代码中的 lg[i] 就表示着
⌊
log
2
i
⌋
\lfloor \log_2 i \rfloor
⌊log2i⌋ 的值。
for(int i=1;i<=n;i++){
lg[i]=log2(i);
}
或者是这个代码:
for(int i=2;i<=n;i++){
lg[i]=lg[i>>1]+1;
}
论常数,第二个方法的代码的时间会快一点,但也没有多大的区别。
5.st表的完整代码(区间最大值)
#include<bits/stdc++.h>
using namespace std;
int st[100010][30],lg[100010];
int query(int l,int r){
int len=lg[r-l+1];
return max(st[l][len],st[r-(1<<len)+1][len]);
}
int main(){
ios::sync_with_stdio(0);
cin.tie(0);
cout.tie(0);
int n,m;
cin>>n>>m;
for(int i=1;i<=n;i++){
cin>>st[i][0];
}
for(int i=2;i<=n;i++){
lg[i]=lg[i>>1]+1;
}
for(int j=1;j<=lg[n];j++){
for(int i=1;i<=n-(1<<j)+1;i++){
st[i][j]=max(st[i][j-1],st[i+(1<<(j-1))][j-1]);
}
}
while(m--){
int x,y;
cin>>x>>y;
cout<<query(x,y)<<'\n';
}
return 0;
}
6.例题
6.1 T1-P1198 [JSOI2008] 最大数
6.1.1 题目内容
现在请求你维护一个数列,要求提供以下两种操作:
Q L,查询当前数列中末尾 L L L 个数中的最大的数。A n,将 n n n 加上 t t t,其中 t t t 是最近一次查询操作的答案(如果还未执行过查询操作,则 t = 0 t=0 t=0),并将所得结果对一个固定的常数 D D D 取模,将所得答案插入到数列的末尾。
6.1.2 思路&代码:
这里只考虑 st表 的做法。查询就不讲了,这里重点将插入操作。
考虑插入数字到最后一个(记这里的下标为
n
n
n,数字为
k
k
k)会对
s
t
st
st 数组造成什么影响。我们发现将数插入在数组末尾,只会对:
s
t
n
−
2
j
+
1
,
j
(
j
≤
⌊
l
o
g
2
n
⌋
)
st_{n-2^j+1,j} (j \le \lfloor log_2 n \rfloor)
stn−2j+1,j(j≤⌊log2n⌋)
造成影响。所以我们只要遍历
j
j
j 然后修改就可以了。
部分代码:
st[++n][0]=(x+t)%P;//j=0的情况直接处理
for(int i=1;i<=lg[n];i++){
int pos=n-(1<<i)+1;//对于每一个j,会造成影响的只有st[n-2^i+1][j]
st[pos][i]=max(st[pos][i-1],st[pos+(1<<(i-1))][i-1]);
}
完整代码:
#include<bits/stdc++.h>
#define int long long
using namespace std;
int st[200010][30],lg[200010];
signed main(){
int n=0,t=0,m,P;
cin>>m>>P;
for(int i=2;i<=m;i++){
lg[i]=lg[i>>1]+1;
}
while(m--){
char op;
int x;
cin>>op>>x;
if(op=='A'){
st[++n][0]=(x+t)%P;
for(int i=1;i<=lg[n];i++){
int pos=n-(1<<i)+1;
st[pos][i]=max(st[pos][i-1],st[pos+(1<<(i-1))][i-1]);
}
}
else{
int len=lg[x];
t=max(st[n-x+1][len],st[n-(1<<len)+1][len]);
cout<<t<<'\n';
}
}
return 0;
}
6.2 T2-P9032 [COCI2022-2023#1] Neboderi
6.2.1 题目内容
[COCI2022-2023#1] Neboderi
有 n n n 个数 a 1 , a 2 , a 3 , ⋯ , a n a_1,a_2,a_3,\cdots,a_n a1,a2,a3,⋯,an,现在请你求出一个长度不小于 k k k 的区间 [ l , r ] [l,r] [l,r],使得 ∑ i = l r a i × gcd ( a l , a l + 1 , a + l + 2 , ⋯ , a r ) \sum_{i=l}^{r} a_i \times \gcd(a_l,a_{l+1},a+{l+2},\cdots,a_r) ∑i=lrai×gcd(al,al+1,a+l+2,⋯,ar) 最大。
6.2.2 思路&代码
本蒟蒻并不会写 O ( n ( log n ) 2 ) O(n (\log n)^2) O(n(logn)2) 的时间复杂度的代码,用 O ( n ( log n ) 3 ) O(n (\log n)^3) O(n(logn)3) 卡过去的。
首先,我们要明白两个特性:
对于若干个区间 [ l i , r i ] [l_i,r_i] [li,ri],如果这些区间的左端点都固定到 l l l上,那么随着 r r r 的增加, gcd ( l , ⋯ , r ) \gcd(l,\cdots,r) gcd(l,⋯,r) 单调不增。

)
而且,对于这样的固定 l l l 区间,区间的 gcd \gcd gcd 最多只有 log n \log n logn 种。这个是可以证明的,但篇幅较长,我这里就不多提了。
于是!我们就可以遍历左端点 i i i,把 [ i , i + 1 ] , [ i , i + 2 ] , ⋯ , [ i , n ] [i,i+1],[i,i+2],\cdots,[i,n] [i,i+1],[i,i+2],⋯,[i,n] 这些区间给分成若干块,每个块中的任意一个下标 x x x, [ i , x ] [i,x] [i,x] 中 a i gcd a_i \gcd aigcd 都相等,且总块数不会超过 log n \log n logn。
假设有一段区间: [ 12 , 12 , 16 , 8 , 4 , 2 , 6 , 3 , 1 ] [12,12,16,8,4,2,6,3,1] [12,12,16,8,4,2,6,3,1]。我们可以把它分成 [ 12 , 12 ] [12,12] [12,12] 一块, [ 16 , 8 , 4 ] [16,8,4] [16,8,4] 一块, [ 2 , 6 ] [2,6] [2,6] 一块, [ 3 , 1 ] [3,1] [3,1] 一块。(这里可以仔细想想)
我们又知道,每个块中每个下标 x x x, [ i , x ] [i,x] [i,x] 中 a i gcd a_i \gcd aigcd 都相等,而且 x x x 越大, ∑ j = i x a j \sum_{j=i}^{x} a_j ∑j=ixaj 也会越来越大,那么每块中只有一个最优的答案,就是这个块的最后一个 x x x,因为此时 a i a_i ai 至 a x a_x ax 的和最大。
我们可以枚举 i i i ( 1 ≤ i ≤ n 1 \le i \le n 1≤i≤n)作为左端点,然后寻找不同的块,找到每一个块端点,记录最佳答案。
至于 [ l , r ] [l,r] [l,r] 的和,我们可以使用前缀和进行优化。
代码:
#include<bits/stdc++.h>
using namespace std;
int a[1000010],lg[1000010],st[1000010][30];
long long s[1000010];
int query(int l,int r){
int len=lg[r-l+1];
return __gcd(st[l][len],st[r-(1<<len)+1][len]);
}
int mid_check(int i,int l2,int n,int x){
int l=l2,r=n;
while(l<r){
int mid=(l+r+1)>>1;
if(query(i,mid)==x)l=mid;
else r=mid-1;
}
return l;
}
signed main(){
ios::sync_with_stdio(0);
cin.tie(0);cout.tie(0);
long long n,m,ans=0;
cin>>n>>m;
for(int i=1;i<=n;i++){
cin>>a[i];
st[i][0]=a[i],s[i]=s[i-1]+a[i];
}
for(int i=2;i<=n;i++)lg[i]=lg[i>>1]+1;
for(int j=1;j<=lg[n];j++){
for(int i=1;i<=n-(1<<j)+1;i++){
st[i][j]=__gcd(st[i][j-1],st[i+(1<<(j-1))][j-1]);
}
}
for(int i=1;i<=n;i++){
if(i+m-1>n)break;
int r=mid_check(i,i+m-1,n,a[i]);
ans=max(ans,1ll*query(i,r)*(s[r]-s[i-1]));
while(r<n){
int l=r+1;
r=mid_check(i,l,n,query(i,l));
ans=max(ans,1ll*query(i,r)*(s[r]-s[i-1]));
}
}
cout<<ans<<'\n';
}
6.作业
7.闲话
蒟蒻不才,膜拜大佬,文章如果有错字或别的错误,欢迎各位大佬在评论区指出。
更多推荐
所有评论(0)