引入倍增:
所谓倍增,就是成倍增长。以2的次幂的方式增长。
我们在进行递推时,如果状态空间很大,线性递推无法满足时间与空间复杂度的要求,我们可以通过“成倍增长”的方式,只递推在2的整数次幂位置上的值作为代表。
当需要其他位置上的值时,也可以用这些2的幂次上的值所拼成,因为 “任意整数都可以表示成若干个2的次幂项的和”。
例题1:区间和
题目描述:
给定长度为
n
n
n 的数列,进行若干次询问。
给出整数
T
T
T,给出左端点
p
p
p,求出最大的
k
k
k,使得从
l
l
l 开始的
k
k
k 个位置元素之和不超过
T
T
T。
思路:
预处理出前缀和
s
[
i
]
s[i]
s[i]。
考虑暴力算法,依次往后枚举
k
k
k 的位置,时间复杂度
O
(
N
)
O(N)
O(N)。
由于前缀和满足单调性,所以可以二分
k
k
k 的位置。
但是,对于每次询问,二分的时间复杂度都为
O
(
N
l
o
g
N
)
O(NlogN)
O(NlogN)。如果当答案
k
k
k 很小的话,还不如直接枚举效率高。
那么是否找到一种方法,能够兼顾两者的优点呢?
倍增!
我们可以用2的幂次来判断
k
k
k 的位置。设立左端点
l
=
p
l = p
l=p,右端点
r
=
1
r =1
r=1,倍增长度
l
e
n
=
1
len =1
len=1。
- 如果
s[r+len] - s[l-1]≤ T
,说明当前长度可行,继续倍增,r+=len
,len*=2
; - 否则,说明倍增长度太长,就要缩减,
len/=2
。
重复上述操作,直到 l e n = 0 len=0 len=0了,那么当前 r r r 便是答案。
这样,如果答案
k
k
k 很小,这个算法的复杂度便也变小。
这个算法始终在答案大小的范围内实施“倍增”与“二进制划分”思想,通过若干长度为2的次幂的区间拼成最后的
k
k
k,时间复杂度级别为答案
k
k
k 的对数,能够应对
T
T
T 的各种大小的情况。
例题2:Genius ACM
题意:
给定一个整数
M
M
M,对于任意一个整数集合
S
S
S,定义“校验值”如下:
从集合
S
S
S 中取出
M
M
M 对数(即
2
∗
M
2*M
2∗M个数,不能重复使用集合中的数,如果
S
S
S 中的整数不够
M
M
M 对,则取到不能取为止),使得“每对数的差的平方”之和最大,这个最大值就称为集合
S
S
S 的“校验值”。
现在给定一个长度为
N
N
N 的数列
A
A
A 以及一个整数
T
T
T。我们要把
A
A
A 分成若干段,使得每一段的“校验值”都不超过
T
T
T。
求最少需要分成几段?
思路:
对于一个集合
S
S
S,为了使“每对数的差的平方”之和最大,只能最大值和最小值配对,次大值和次小值配对…
为了总的段数最小,需要让每一段的“检验值”不超过T的前提下尽量长。所以从从头开始对
A
A
A 分段,让每一段都尽量长,这样得到的就是最小分段数。
于是,需要解决的问题是,对于一个起点 l l l,最多往后延伸多少个位置,能够使得这一段区间的“检验值”不超过 T T T?
因为往后延伸的区间越长,其“检验值”越大,满足单调性,所以很容易想到二分右端点。
但是对于每一次二分,复杂度为O(logN),对于每一次check,需要排序O(NlogN),而最坏情况下,需要对每个位置二分右端点,所以整个复杂度为
O
(
N
2
l
o
g
2
N
)
O(N^2 log^2N)
O(N2log2N)。(其实真实是O(N^2 logN),证明)复杂度很高。
而用倍增,复杂度可以降到
O
(
N
l
o
g
2
N
)
O(N log^2N)
O(Nlog2N)。
对于一个起点位置
l
l
l,定义右端点
r
=
l
r=l
r=l,倍增长度
l
e
n
=
1
len=1
len=1。
- 如果区间
[
l
,
r
+
l
e
n
]
[l, r+len]
[l,r+len] 的“校验值”满足,那么说明当前倍增的长度是可以的,更新右端点
r+=len
,len*=2
; - 否则,说明倍增长度太长,
len/=2
。
重复上述操作,直到倍增长度 l e n = 0 len =0 len=0 ,此时的 r r r 便是最右端的位置。
考虑这种算法的复杂度:
上面的过程最多循环
O
(
l
o
g
N
)
O(logN)
O(logN) 次,每次循环求“检验值”
O
(
N
l
o
g
N
)
O(N logN)
O(NlogN),所以时间复杂度为
O
(
N
l
o
g
2
N
)
O(N log^2N)
O(Nlog2N)。
Code:
const int N = 500010, mod = 1e9+7;
ll T, n, m, a[N],b[N];
ll maxa;
bool pd(int l,int r){
if(r>n) return 0; //最右端不超过数组长度
int cnt=0;
for(int i=l;i<=r;i++) b[++cnt]=a[i];
sort(b+1,b+cnt+1);
ll sum=0;
for(int i=0;i<m;i++)
{
if(i+1>=cnt-i) break;
sum+=(b[cnt-i]-b[i+1])*(b[cnt-i]-b[i+1]);
}
if(sum<=maxa) return 1;
return 0;
}
signed main(){
Ios;
cin>>T;
while(T--)
{
int cnt=0;
cin>>n>>m>>maxa;
for(int i=1;i<=n;i++) cin>>a[i];
int st=1;
while(st<=n)
{
ll l=st,r=st,len=1; //设置左端点,右端点,倍增长度
while(len!=0) //当倍增长度为0的时候结束
{
if(pd(l,r+len)) r+=len,len*=2; //满足,倍增
else len/=2; //不满足,倍减
}
st=r+1;
cnt++;
}
cout<<cnt<<endl;
}
return 0;
}
对于每次求“检验值”,可以不用 s o r t sort sort 排序,而是采用归并排序,只对新增的长度排序,然后合并新旧两段,总体复杂度可以降到 O ( N l o g N ) O(N logN) O(NlogN)。
应用:
ST算法 求解 RMQ(区间最值问题)
RMQ问题:
给定一个长度为
n
n
n 的数列,每次给出一个区间,问这个区间中元素的最大值?
对于暴力,时间复杂度为
O
(
N
∗
M
)
O(N*M)
O(N∗M),
M
M
M 为询问次数。
而
S
T
ST
ST 算法能在
O
(
N
l
o
g
N
)
O(N logN)
O(NlogN) 时间的预处理之后,以
O
(
1
)
O(1)
O(1) 的时间复杂度在线回答
R
Q
M
RQM
RQM 问题。
定义 f [ i , j ] f[i,j] f[i,j] 表示数列中下标在区间 [ i , i + 2 j − 1 ] [i, i+2^j-1] [i,i+2j−1] 里的数的最大值,也就是从位置 i i i 开始的 2 j 2^j 2j 个数的最大值。
递推求出
f
[
i
,
j
]
f[i,j]
f[i,j]:
O
(
N
l
o
g
N
)
O(N logN)
O(NlogN)
递推边界:
f
[
i
]
[
0
]
=
a
[
i
]
f[i][0] = a[i]
f[i][0]=a[i],即数列a在子区间
[
i
,
i
]
[i,i]
[i,i] 里的最大值。
递推时,我们把子区间的长度成倍增长,长度为
2
j
2^j
2j 的子区间的最大值为左右两半长度为
2
j
−
1
2^{j-1}
2j−1 的子区间的最大值中较大的一个,即:f[i,j] = max(f[i, j-1], f[i + (1<<(j-1)),j-1]
。
考虑
j
j
j 的最大值,为使得
2
j
2^j
2j 不超过 n 的最大的 j,那么
j
=
l
o
g
2
n
j = log_2^n
j=log2n。
我们可以调用
<
c
m
a
t
h
>
<cmath>
<cmath>中的 log()
函数,
l
o
g
2
n
=
l
o
g
(
n
)
/
l
o
g
(
2
)
log_2^n = log(n)/log(2)
log2n=log(n)/log(2)。
(
l
o
g
2
n
=
l
o
g
10
n
/
l
o
g
10
2
)
(log_2^n = log_{10}^n / log_{10}^2)
(log2n=log10n/log102)。
考虑 i i i 的最大值,从 i i i 往右延伸的区间长度最大为 2 j 2^j 2j ,所以 i i i 最大只需要到 n − 2 j + 1 n-2^j+1 n−2j+1。
递推时,当前状态需要用到 前面 j − 1 j-1 j−1 状态的 i + 2 j − 1 i+2^{j-1} i+2j−1 ,所以需要先循环 j j j,再循环 i i i。
void RMQ()
{
for(int i=1;i<=n;i++) f[i][0]=a[i];
int t=log(n)/log(2); //t为 不超过n的,2^t的最大值 = log_2^n。
for(int j=1;j<=t;j++) //先遍历j,再遍历i。
{
for(int i=1;i<=n-(1<<j)+1;i++) //i位置最大为 n-2^j+1。
{
f[i][j]=max(f[i][j-1],f[i + (1<<(j-1))][j-1]);//max(最区间最大值,右区间最大值)
}
}
}
对于询问一个区间
[
l
,
r
]
[l,r]
[l,r] 的最大值:
O
(
1
)
O(1)
O(1)
我们需要先算出不超过这个区间长度的
2
t
2^t
2t 的
t
t
t 的最大值:
l
o
g
2
r
−
l
+
1
log_2^{r-l+1}
log2r−l+1。
那么这个区间的最大值就为 “从
l
l
l 开始的
2
t
2^t
2t 个数” 和 “以
r
r
r 结尾的
2
t
2^t
2t 个数” 这两段的最大值较大的一个。即 max(f[l][t], f[r-(1<<t)+1][t])
。
int query(int l,int r){
int t=log(r-l+1)/log(2); //这里是区间长度的对数,不是整个数组的对数
return max(f[l][t],f[r-(1<<t)+1][t]); //从后往前找的时候+1,从前往后不用加。
}
模板Code:
#include<iostream>
#include<cmath>
using namespace std;
const int N=100010;
int n,m,a[N];
int f[N][20];
void RMQ()
{
for(int i=1;i<=n;i++) f[i][0]=a[i];
int t=log(n)/log(2);
for(int j=1;j<=t;j++)
for(int i=1;i<=n-(1<<j)+1;i++)
f[i][j]=max(f[i][j-1],f[i + (1<<(j-1))][j-1]);
}
int query(int l,int r){
int t=log(r-l+1)/log(2);
return max(f[l][t],f[r-(1<<t)+1][t]);
}
int main(){
cin>>n>>m;
for(int i=1;i<=n;i++) cin>>a[i];
RMQ();
while(m--){
int x,y;cin>>x>>y;
cout<<query(x,y)<<endl;
}
return 0;
}
同理,把 m a x max max 换成 m i n min min ,我们可以求出一个区间的最小值。
练习题:
ST算法 维护区间最大公约数
和区间最值有相似性质的还有 最大公约数
g
c
d
gcd
gcd。
类似于更新区间最值的方法,对于一整个区间的 gcd 等于其两个子区间的 gcd 的 gcd。要求两个子区间覆盖住整个区间,允许有重合。
所以,和维护最值一样:
- 在更新的时候用两个半长区间更新;
- 查询的时候用两个2的次幂数长度的区间取 gcd。
void st(){
int t = log(n)/log(2);
for(int j=1;j<=t;j++)
for(int i=1;i<=n-(1<<j)+1;i++)
{
gcd[i][j] = __gcd(gcd[i][j-1], gcd[i+(1<<(j-1))][j-1]);
}
}
int query(int l, int r)
{
int t = log(r-l+1)/log(2);
return __gcd(gcd[l][t], gcd[r-(1<<t)+1][t]);
}
例题:Pair of Numbers
题意:
给定长度为 n 的数列,求出长度最长的满足下列条件的区间:
- 区间中存在一个数 x,能够被其他所有数除尽。
1 ≤ n ≤ 3 ∗ 1 0 5 1 ≤ n ≤ 3*10^5 1 ≤ n ≤ 3∗105
分析:
如果长度为 5 的区间满足上面条件的话,那么其长度为 3 的子区间也一定满足。所以区间长度满足单调性,可以二分最长长度。
对于每种长度,遍历起点位置。
如果说,区间所有数中的最小值等于这些数的最大公约数的话,那么这个最小值就能够被其他所有数除尽,就满足条件。
用ST表维护区间 gcd 和区间最小值,O(1) 查询。
Code:
#include<bits/stdc++.h>
using namespace std;
#define int long long
#define PII pair<int,int>
#define pb push_back
#define fi first
#define se second
#define endl '\n'
map<int,int> mp;
/**/
const int N = 300010, mod = 1e9+7;
int T, n, m;
int a[N];
int mina[N][30], gcd[N][30];
vector<int> ans[N];
void st(){
int t = log(n)/log(2);
for(int j=1;j<=t;j++)
{
for(int i=1;i<=n-(1<<j)+1;i++)
{
mina[i][j] = min(mina[i][j-1], mina[i+(1<<(j-1))][j-1]);
gcd[i][j] = __gcd(gcd[i][j-1], gcd[i+(1<<(j-1))][j-1]);
}
}
}
bool query(int l, int r)
{
int t = log(r-l+1)/log(2);
int minn = min(mina[l][t], mina[r-(1<<t)+1][t]);
int gcdd = __gcd(gcd[l][t], gcd[r-(1<<t)+1][t]);
return minn == gcdd;
}
bool check(int mid)
{
int flag = 0;
for(int i=1;i<=n-mid+1;i++)
{
if(query(i, i+mid-1)){
flag = 1;
ans[mid].pb(i);
}
}
if(flag) return 1;
return 0;
}
signed main(){
cin >> n;
for(int i=1;i<=n;i++) cin>>a[i], mina[i][0] = gcd[i][0] = a[i];
st();
int l = 0, r = n;
while(l<r)
{
int mid = l+r+1>>1;
if(check(mid)) l = mid;
else r = mid-1;
}
cout << ans[l].size() << " " <<l-1<<endl;
for(auto x:ans[l]) cout<<x<<" ";
return 0;
}
那么既然这样的话,或操作(|) 和 与操作(&) 也可以区间维护了。
参考来源: 《 算 法 竞 赛 进 阶 指 南 》 — — 李 煜 东 《算法竞赛进阶指南》 ——李煜东 《算法竞赛进阶指南》 ——李煜东
哪里有问题或者不明白的话欢迎留言评论~