目录
算法引入
倍增法和二分法是“相反”的算法,效率都很高。二分法是每次缩小一半,从而以O(log2n)的速度极快地缩小定位到解;倍增法是每次扩大一倍,从而以O(2的n次方)的速度极快的扩展到极大的空间。
二分法是从大区间缩小到小区间,最后定位到一个极小的区间,小到这个区间的左右端点重合,从而得到解,解就是最后这个极小区间的值。所以,二分法的适用场是一个有序的序列,或者一个单调的曲线。通过二分法缩小查询区间,其目的是找到一个特定的数值。
倍增法有两种主要应用场合,一种是小区间扩大到大区间,另外一种是从小数值倍增到大数值。
(1)在区间问题中,是从小区间倍增到大区间,求解和区间查询有关的问题,如求区间的最大值或最小值。这种应用有 ST 算法、后缀数组等。
(2)除了区间上的应用,倍增法也能用于数值的精确计算。如果空间内的元素满足倍增关系,或者能借助倍增法计算,那么也能用倍增法达到求解这些元素的精确值的目的。这种应用有快速幂、最近公共祖先等。
倍增法
算法原理
倍增就是成倍增长。如何实现倍增,是每步乘以2吗?有时确实可以,如后缀数组每次扩展字符长度,就简单的乘以2.不过,在大多数题目中有更好的实现方法,即利用二进制本身的倍增特性,把一个数N用二进制展开即:
例如35,它的二进制是100011,第5、1、0位是1,即a5=a1=a0=1,把这几位的权值相加,有
数的二进制划分反映了一种快速增长的特性,第i位的权值2的i次方等于前面所有权值的和加1:
一个整数n,它的二进制表示只有logn位。如果要从0增长到n,可以用1、2、4、…、2的k次方为“跳板”,快速跳到n,这些跳板只有k = logn个。倍增法的特点是需要提前计算出第1、2、4、…、2的k次方个跳板,这要求数据是静态不变的,不是动态变化的。如果数据发生了变化,所有跳板要重新计算,跳板就失去了意义。
倍增法的经典应用有:矩阵快速幂、后缀数组、ST算法,LCA(最近公共祖先)。
算法实践
国旗计划
题目描述:边境上有m个边防站围成一圈,顺时针编号1到m。有n个战士,每个战士常驻2个站,能在2个站之间移动。局长有个国旗计划,让边防战士举着国旗环绕一圈。局长想知道至少需要多少战士才能完成国旗计划,并且他想知道,在某个战士必须参加的情况下,至少需要多少边防战士。
输入:第一行是两个正整数n,m,表示战士数量和边防站数量。后面n行,每行有两个正整数,第i行的ci、di表示i号边防战士常驻的两个边防站编号,沿顺时针从ci边防站到di是他的移动区间。数据保证整个边境线是可被覆盖的。所有战士的移动区间互相不包含。
输出:输出一行,包含n个正整数,其中第j个正整数表示j号战士必须参加的前提下至少需要多少边防战士才能顺利完成国旗计划。
数据范围:n≤2×105, m<109, 1≤ci,di≤m
题目解析:
题目的要求很清晰:计算能覆盖整个圆环的最少区间(战士)。
题目给定的所有区间互相不包含,那么按区间的左端点排序后,区间的右端点也是单调增加的。这
样情况下能用贪心来选择区间。
解题用到的技术有:断环成链、贪心、倍增。
(1)断环成链路。把题目给的环断开变成一条链,更方便处理。注意环是首尾相接的,断开后为了保持原来的首尾的关系,需要把原来的环复制再相接。
(2)贪心。首先考虑从一个区间出发,如何选择所有的区间。选择一个区间i后,下一个区间只能
从左端点小于等于i的右端点的那些区间中选,在这些区间中选右端点最大的那个区间,是最优
的。例如下图选择区间i后,下一个区间可以从A、B、C中选,它们的左端点都在i内部。C是最优
的,因为它的右端点最远。选定C之后,再用贪心策略找下一个区间。这样找下去,就得到了所需
的最少区间。
以i为起点,用贪心查询一次,要遍历所有的区间,复杂度O(n)。题目要求以每个区间为起点,共
做n次查询,总复杂度是O(n2),超时。
(3)倍增。为了进行高效的n次查询,可以用类似ST算法中的倍增,预计算出一些“跳板”,快速找到后面的区间。
定义go[s][i]:表示从第s个区间出发,走2i个最优区间后到达的区间。例如 go [s][4],是从s出发到达的第2i =16个最优的区间,s和go[s][4]之间的区间也都是最优的。
预计算出从所有的区间出发的go[][],以它们为“跳板”,就能快速跳到目的地。
注意,跳的时候先用大数再用小数。以从s跳到后面第27个区间为例:
1)从s跳16步,到达s后的第16个区间f1;
2)从f1跳8步,到达f1后的第8个区间f2;
3)从f2跳2步到达f3;
4)从f3跳1步到达终点f4。
共跳了16+8+2+1=27步。这个方法利用了二进制的特征,27的二进制是11011,其中的4个“1”的权
值就是16、8、2、1。把一个数转换为二进制数时,是从最高位往最低位转换的,这就是为什么要
先用大数再用小数的原因。
复杂度是多少?查询一次,用倍增法从s跳到终点复杂度是O(logn)的。共有n次查询,总复杂度O(nlogn)。
剩下的问题是如何快速预计算出go[][]。有以下非常巧妙的递推关系:
go[s][i] = go[go[s][i-1]][i-1]
递推式的右边这样理解:
1)go[s][i-1]。从s起跳,先跳2i−1步到了区间z = go[s][i-1];
2)go[go[s][i-1]][i-1] = go[z][i-1]。再从z跳2i−1步到了区间go[z][i-1]。
一共跳了2i−1+2i−1=2i步。公式右边实现了从s起跳,跳到了s的第2i个区间,这就是递推式左边的go[s][i]。
特别地,go[s][0]是x后面第20 = 1个区间(用贪心算出的下一个最优区间),go[s][0]是递推式的初
始条件,从它递推出了所有的go[][]。递推的计算量有多大?从任意一个s到末尾,最多只有logn个
go[s][],所以只需要递推O(logn)次。计算n个结点的go[][],共计算O(nlogn)次。
以上所有的计算,包括预计算go[][]和n次查询,总复杂度是O(nlogn) + O(nlogn)。
代码:
#include<bits/stdc++.h>
using namespace std;
const int maxn = 4e5+1;
int n, m;
struct warrior{
int id, L, R; //id:战士的编号;L、R,战士的左右区间
bool operator < (const warrior b) const{return L < b.L;}
}w[maxn*2];
int n2;
int go[maxn][20];
void init(){ //贪心 + 预计算倍增
int nxt = 1;
for(int i=1;i<=n2;i++){ //用贪心求每个区间的下一个区间
while(nxt<=n2 && w[nxt].L<=w[i].R) //每个区间的下一个是右端点最大的那个区间
nxt++;
go[i][0]=nxt-1; //区间i的下一个区间
}
for(int i=1;(1<<i)<=n;++i) //倍增:i=1,2,4,8,... 共log(n)次
for(int s=1;s<=n2;s++) //每个区间后的第2^i个区间
go[s][i] = go[go[s][i-1]][i-1];
}
int res[maxn];
void getans(int x){ //从第x个战士出发
int len=w[x].L+m, cur=x, ans=1;
for(int i=log2(maxn);i>=0;i--){ //从最大的i开始找:2^i = maxn
int pos = go[cur][i];
if(pos && w[pos].R < len){
ans += 1<<i; //累加跳过的区
cur = pos; //从新位置继续开始
}
}
res[w[x].id] = ans+1;
}
int main(){
scanf("%d%d",&n,&m);
for(int i=1;i<=n;i++){
w[i].id = i; //记录战士的顺序
scanf("%d%d",&w[i].L, &w[i].R);
if(w[i].R < w[i].L) //把环变成链
w[i].R += m;
}
sort(w+1, w+n+1); //按左端点排序
n2 = n;
for(int i=1;i<=n;i++){ //拆环加倍成一条链
n2++; w[n2]=w[i]; w[n2].L=w[i].L+m; w[n2].R=w[i].R+m;
}
init();
for(int i=1;i<=n;i++) getans(i); //逐个计算每个战士
for(int i=1;i<=n;i++) printf("%d ",res[i]);
return 0;
}
ST算法
算法原理
ST算法是求解区间最值查询(RMQ)问题的优秀算法,它适用于静态空间的RMQ查询。静态空间的RMQ问题(Range Minimum Query,区间最大最小值问题):给定长度为n的静态数列,做m次询问,每次给定L, R ≤ n,查询区间[L, R]内的最值。
下面都以最小值为例。
用暴力搜区间[L, R]的最小值,即逐一比较区间内的每个数,复杂度是O(n)的;m次查询,复杂度O(mn)。暴力法的效率很低。
ST算法源于这样一个原理:
一个大区间若能被两个小区间覆盖,那么大区间的最值等于两个小区间的最值。例如下图中,大区间{4, 7, 9, 6, 3, 6, 4, 8, 7, 5}被两个小区间{4, 7, 9, 6, 3, 6, 4, 8}、{4, 8, 7, 5}覆盖,大区间的最小值3,等于两个小区间的最小值,min{3, 4}=3。这个例子特意让两个小区间有部分重合,因为重合不影响结果。
从以上原理得到ST算法的基本思路,包括两个步骤:
(1)把整个数列分为很多小区间,并提前计算出每个小区间的最值;
(2)对任意一个区间最值查询,找到覆盖它的两个小区间,用两个小区间的最值算出答案。
如何设计出这2个步骤的高效算法?
对于(1),简单的方法是把数列分为固定大小的小区间,即“分块”,它把数列分为√n块,每块有√n个数,提前计算这√n个小区间的最值,复杂度是O(n√n)。然后对于(2)的最值查询,每次计算量约为O(1)。这种算法的效率比暴力法强很多,但是还不够好。
下面用“倍增”的方法来分块,它的效率非常高:(1)的复杂度是O(nlogn),(2)的复杂度是O(1)。
1. 把数列按倍增分成小区间
对数列的每个元素,把从它开始的数列分成长度为1、2、4、8、…的小区间。
下图给出了一个分区的例子,它按小区间的长度分成了很多组。
第1组是长度为1的小区间,有n个小区间,每个小区间有1个元素;
第2组是长度为2的小区间,有n个小区间,每个小区间有2个元素;
第3组是长度为4的小区间,有n个小区间,每个小区间有4个元素;
…
共有logn组。
可以发现,每组的小区间的最值,可以从前一组递推而来。例如第3组{4, 7, 9, 6}的最值,从第2组{4, 7}、{9, 6}的最值递推得到。
定义dp[s][k],表示左端点是s,区间长度为2k的区间最值。递推关系是:
dp[s][k] = min{dp[s][k-1], dp[s + 1<<(k-1)][k-1]}
其中1<<(k-1)等于2的k次方−1。
计算所有小区间的最值,即计算出所有的dp[][],复杂度是多少?图中的每一组都需计算n次,共logn组,总计算量是O(nlogn)。
2. 查询任意区间的最值
根据上面的分区方法,有以下结论:以任意元素为起点,有长度为1、2、4、…的小区;以任意元素为终点,它前面也有长度为1、2、4、…的小区间。
根据这个结论,可以把需要查询的区间[L, R]分为2个小区间:以L为起点的小区间、以R为终点的小区间,让这两个小区间首尾相接覆盖[L, R],区间最值从两个小区间的最值求得。一次查询的计算复杂度是O(1)。
区间[L, R]的长度是len = R-L+1。两个小区间的长度是x,令x是比len小的最大2的倍数,有2*x ≥len,这样保证能覆盖。另外需要计算dp[][],根据dp[s][k]的定义,有2k =x。例如len = 19,x =16,2k = 16,k = 4。
已知len如何求k?计算公式是k=log2(len)= log(len)/log(2),向下取整。以下两种代码都行:
int k=(int)(log(double(R-L+1)) / log(2.0)); //以10为底的库函数log()
int k=log2(R-L+1); //以2为底的库函数log2()
如果觉得库函数log2()比较慢,可以自己提前算出LOG2,LOG2[i]的值与向下取整的log2(i)相等:
LOG2[0] = -1;
for(int i=1;i<=maxn;i++)
LOG2[i] = LOG2[i>>1]+1;
最后给出区间[L, R]最小值的计算公式,等于覆盖它的两个小区间的最小值:
min(dp[L][k],dp[R-(1<<k)+1][k]);
用这个公式做一次最值查询,计算复杂度是O(1)。
算法实践
Balanced Lineup
题目描述:给定一个包含n个整数的数列,和q个区间询问,询问区间内最大值和最小值的差。
输入:第一行是2个整数,n和q。接下来n行,每行一个整数hi。再后面q行,每行2个整数a、b,表示一个区间询问。
输出:对每个区间询问,返回区间内最大值和最小值的差。
数据范围:1≤n≤5×104,1≤q≤1.8×105,1≤a≤b≤n。
代码:
#include<stdio.h>
#include<math.h>
#define maxn 50005
int a[maxn],dp_max[maxn][22],dp_min[maxn][21],n,m;
int LOG2[maxn]; //自己计算以2为底的对数,向下取整
void st_init(){
LOG2[0]=-1;
for(int i = 1;i<=maxn;i++) //不用系统的log()函数,自己算
LOG2[i] = LOG2[i>>1]+1;
for(int i=1;i<=n;i++)
{ //初始化区间长度为1时的值
dp_min[i][0]=a[i];
dp_max[i][0]=a[i];
}
//int p=log2(n); //可倍增区间的最大次方: 2^p <= n
int p= (int)(log(double(n)) / log(2.0)); //两者写法都行
for(int k=1;k<=p;k++) //倍增计算小区间。先算小区间,再算大区间,逐步递推
for(int s=1;s+(1<<k)<=n+1;s++){
dp_max[s][k]=max(dp_max[s][k-1], dp_max[s+(1<<(k-1))][k-1]);
dp_min[s][k]=min(dp_min[s][k-1], dp_min[s+(1<<(k-1))][k-1]);
}
}
int st_query(int L,int R){
//int k=log2(R-L+1); //3种方法求k
int k=(int)(log(double(R-L+1)) / log(2.0));
//int k=LOG2[R-L+1]; //用自己算的LOG2
int x=max(dp_max[L][k],dp_max[R-(1<<k)+1][k]);//区间最大
int y=min(dp_min[L][k],dp_min[R-(1<<k)+1][k]);//区间最小
return x-y; //返回差值
}
int main(){
scanf("%d%d",&n,&m);//输入
for(int i=1;i<=n;i++) scanf("%d",&a[i]);
st_init();
for(int i=1;i<=m;i++)
{
int L,R; scanf("%d%d",&L,&R);
printf("%d\n",st_query(L,R));
}
return 0;
}
拓展:
1. ST算法与线段树
求解RMQ问题更常用的是线段树,它求RMQ的时间复杂度与ST差不多,但是两者有很大区别:线段树用于动态数组,ST用于静态数组。
ST算法用O(nlogn)时间来预处理数组,预处理之后每次区间查询是O(1)的。如果数组是动态改变的,改变一次就需要用O(nlogn)预处理一次,导致效率很低。线段树适用于动态维护(添加或删除)的数组,它每次维护数组和每次查询都是O(logn)的。从数组是否能动态维护这个角度来说ST是离线算法,线段树是在线算法。ST的优势是编码简单,如果数组是静态的,就用ST。
2. ST算法的适用场合
从ST算法的原理看,它的核心思想是“大区间被两个小区间覆盖、小区间的重复覆盖不影响结果”,然后用倍增法划分小区间和计算小区间的最值。最大值和最小值符合这种场景,类似的有RGQ问题(Range GCD Query,区间最大公约数问题):查询给定区间的GCD。
附录: