0x06 倍增
倍增,字面意思就是"成倍增长"。我们在递推时,如果状态空间很大,通常的线性递推无法满足时间与空间复杂度的要求,那么我们可以使用成倍增长的方式,只递推状态空间在2的整数次幂上的值作为代表。当需要其他位置上的值时,我们通过"任意整数可以表示成若干个2的次幂项的和"这一性质,使用之前求出的代表值拼成所需的值。所以使用倍增算法也要求我们递推的问题的状态空间关于2的次幂具有可划分性。
"倍增"与"二进制划分"两个思想相互结合,降低了很多问题的时间与空间复杂度。我们之前学习的快速幂其实就是"倍增"与"二进制划分"思想的一种体现。
试想这样一个问题:
给定一个长度为N的数组A,然后进行若干次询问,每次给定一个整数T,求出最大的k,满足 ∑ i = 1 k A i ≤ T \sum_{i=1}^{k}Ai\leq T ∑i=1kAi≤T。你的算法必须是在线的(必须及时回答每一个询问),假设 0 ≤ T ≤ ∑ i = 1 N A i 0\leq T \leq \sum_{i=1}^{N}Ai 0≤T≤∑i=1NAi。
最朴素的做法是从前往后枚举k,每次询问花费的时间与答案的大小有关,最坏情况下为O(n)。
我们可以花费O(n)的时间预处理数组A,得到前缀和序列S,就可以二分k的位置,每次询问花费的时间为O(logn)。这个算法在平均情况下表现得非常好,但它的缺点是当每次给定的T都很小,造成答案的k值也很小,那么该算法可能还不如从前往后枚举更优。
我们可以设计一种倍增算法:
1.令p=1,k=0,sum=0;
2.比较"A数组k之后的p个数之和与"与T的关系,也就是说当 s u m + S k + p − S k ≤ T sum+Sk+p-Sk\leq T sum+Sk+p−Sk≤T ,则令 s u m + = S k + p − S k , k + = p , p ∗ = 2 sum+=Sk+p-Sk,k+=p,p*=2 sum+=Sk+p−Sk,k+=p,p∗=2,即累加上这p个数之和,然后把p的跨度增加一倍。如果 s u m + S k + p − S k > T sum+Sk+p-Sk>T sum+Sk+p−Sk>T,则令 p / = 2 p/=2 p/=2。
3.重复上一步直到p=0,此时k就是答案。
这个算法始终在答案范围内进行实施"倍增"和"二进制划分"思想,通过若干长度为2的次幂的区间拼成最后的k,时间复杂度为答案的对数,能够对应T的各种情况大小。
很多二分答案的想法都可以转化成倍增。
1. ST算法
在 R M Q RMQ RMQ问题中(区间最值问题),著名的ST算法就是倍增的产物。给定一个长度为N的序列A,ST算法能在O(NlogN)时间的预处理后,以O(1)的时间时间复杂度在线回答"数列A中下标在 l ∼ r l\sim r l∼r之间的最大值是多少"这样的区间最值问题。
一个序列的子区间有 N ∗ ( N + 1 ) 2 \frac{N*(N+1)}{2} 2N∗(N+1)个,根据倍增思想,我们首先在这个规模为 O ( N 2 ) O(N^2) O(N2)的状态空间中选择一些2的整数次幂的位置作为代表值。
设 F i j Fij Fij表示数列A中下标在子区间 i , i + 2 j − 1 i,i+2\^j-1 i,i+2j−1里的最大值,也就是从 i i i开始的 2 j 2^j 2j个数中的最大值。递推边界显然是 F i 0 = A i Fi0=Ai Fi0=Ai。
在递推时,我们把区间的长度成倍增长,有公式 F i j = m a x ( F i j − 1 , F i + 2 j − 1 j − 1 ) Fij=max(Fij-1,Fi+2\^{j-1}j-1) Fij=max(Fij−1,Fi+2j−1j−1),即长度为 2 j 2^j 2j的子区间的最大值是左右两个长度为 2 j − 1 2^{j-1} 2j−1的子区间的最大值中较大的一个。
c++
void ST_prework()
{
for (int i = 1; i <= N; ++i)
f[i][0] = a[i];
int t = log(N) / log(2) + 1;
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]);
}
当询问任意区间 l , r l,r l,r的最值时,我们先计算出一个k,满足 2 k ≤ r − l + 1 < 2 k + 1 2^k\leq r-l+1< 2^{k+1} 2k≤r−l+1<2k+1 ,也就是使2的k次幂小于等于区间长度前提下的最大k值。
c++
int ST_query(int l, int r)
{
int k = log(r - l + 1) / log(2);
return max(f[l][k], f[r - (1 << k) + 1][k]);
}
简便起见,我们在代码中使用了cmath库中的log函数。该函数效率较高,一般来说对程序性能影响不大。更严格地讲,为了保证复杂度在 O ( 1 ) O(1) O(1),应该 O ( N ) O(N) O(N)预处理出 1 ∼ N 1\sim N 1∼N这 N N N种区间长度各自对应的 k k k值,在询问时直接使用。