倍增

倍增在思想上和分治是类似的,主要利用的思想是"分而治之"。

分治

分治就是把一个复杂的问题分成两个或更多的相同或相似的子问题,再把子问题分成更小的子问题。

直到最后子问题可以简单的直接求解,原问题的解即子问题的解的合并

归并排序

主要思想是:先将数组从中间拆分,然后两两继续拆分,直到每个部分都被分成了最小单位 (就是一个数)。

如下图:

二分是一种特殊的分治

有一类问题,我们很难直接从正面求解,但它的答案范围有边界,即具有有边界界性,且至少在一定范围内具有单调性(最多局部不变)。

这样,我们可以拓展一下分治思想,从答案这边二分,不断逼近正解,直到找到正解(整数)或逼近到精度内(实数)。

例题及解法

\(NOIP \ 2010\) 关押罪犯:二分+染色。

\(NOIP \ 2011\) 聪明的质检员:二分。

\(NOIP \ 2012\) 借教室:二分+前缀和。

遇到此类题需要认真思考,辨别二分答案特征,用二分答案更好地解决问题。

倍增

倍增,就是把一个数据规模为 \(n\) 的问题分解成若干个 \(2^{a_i}\) 的和,预处理数据范内所有 \(2^{a_i}\) 的情况,再将这些规模为 \(2^{a_i}\) 的问题通过一定的方法合并,得出原问题的解。

分治与倍增的相似之处:

  • 分治是把整个问题分成几个互不重复的子问题,合并求解。
  • 倍增是找互为倍数关系的子问题之间的联系,再合并求解。

所以我们可以认为:倍增也体现了分治思想

从上图中不难看出,每次操作所分的长度是以 \(2\)指数倍进行增长的,从而让我们的操作更快。

常见倍增

(1)快速幂运算

基本的快速幂想必大家都掌握了,代码如下:

ll power (ll a, ll b, ll p) {    //a^b%p
	ll ans = 1%p;
	while (b) {    //如果b还有位数,执行循环,直到b==0
		if (b&1) ans = ans*a%p;  //若b在二进制表示下的最后一位是1,则ans加幂
		a = a*a%p;
		b >>= 1;    //b右移1
	}
	return ans;
}

那么如何用倍增来优化快速幂呢?

举个例子:

我们将 \(a^{19}\) 次方分解,就会变成:\(a*a^2*a^{16}\),这里 \(a^{16}\) 次方是由 \(a^8\)\(a^4\)\(a^2\) 一点点累乘积累来的。

而看到 \(2^n\),我们就想到了二进制,即:\({(19)}_{10}={(10110)}_{2}\)

我们在转化二进制时就可以求出 \(a^2\)\(a^4\)\(a^8\)\(a^{16}···\) 分别等于多少。

所以对于 \(a^n\),我们有如下代码进行分解:

s=1;
while(n>0){
   if(n%2==1) s=s*a;
   a=a*a;
   n=n/2;
}

这样快速幂的的实现可以采用下列代码:

int fast(int a,int n) {
	int s=1;
	while(n) {
		if(n%2==1) s=s*a;
		a=a*a;
		n=n/2;
	}
	return s;
}

我们想要让代码更快,就可以使用位运算的优化手段。

  • \(n \ \& \ 1\):取 \(n\) 的二进制最末位。
  • \(n >> 1\):右移 \(1\) 位,相当于去 \(n\) 掉的二进制最末位。

优化后的代码:

int fast(int a,int n) {
	int s=1;
	while(n) {
		if((n&1)==1) s=s*a;
		a=a*a;
		n=n>>1;
	}
	return s;
}

这里也提供一种运用分治的递归方法:

int fast(int a,int n){
    if(n==1) return a;
    int s=fast(a,n/2);
    s=s*s;
    if((n&1)==1) s=s*a;
    return s;
}

(2) 区间倍增(ST表)

求区间的最值问题:

对于长度为 \(n\) 的数列 \(A\),回答 \(Q\) 个询问\(RMQ \ (A,i,j) \ (i,j \leq n)\)
返回数列A中下标在 \([i,j]\) 里的最小值或最大值。

\(ST\) 算法,可以做到 \(\Theta(n*logn)\) 的预处理

例如数列 \(a[10]\)

1 2 3 4 5 6 7 8 9 10
4 1 3 5 6 3 8 7 9 12

\(f[i][j]\) 表示从 \(a[i]\) 开始连续 \(2*j\) 个元素中的最大值。
显然有 \(f[i][0]=a[i]\) 作为初始的边界状态。

如上图,我们可以得到状态转移方程:

\(f[i][j]=max(f[i][j-1],f[i+2^{j-1}][j-1])\)

假如我们要求区间 \([L,R]\) 中的最大值,最大值范围为 \([10,36]\)

如图,我们只需分成等长 \(16\) 的两段即可。

所以可以得到:对于任意的 \([L,R]\),一定能拆分成不超过 \(log(R-L+1)\)

根据 \(2k \leq R-L+1\),得到最大的 \(k=(log(R-L+1)/log(2))\)

区间就被我们分成了 \([L,L+2^k-1]\)\([R-2^k+1,R]\) 两部分。

状态转移方程为:

\(Q[L,R]=max(f[L][k],f[R-2^k-1][k])\)

\(ST\) 表的实现

  • 生成 \(ST\) 表:
void st(){
	int k=log(n)/log(2);
	for(int i=1;i<=n;++i){
		f[i][0]=a[i];
	}
	for(int j=1;j<=k;++j){
		for(int i=1;i+(1<<j)-1<=n;++i){
			f[i][j]=max(f[i][j-1],f[i+(1<<(j-1))][j-1]);
		}
	}
} 
  • 查询区间 \([L,R]\) 中的最大值:
int rmq(int l,int r){
	int k=log(r-l+1)/log(2);
	return max(f[l][k],f[r-(1<<k)+1][k]);
}

(3)树上倍增(LCA)

考虑树上 \(u\)\(v\) 两点间的路线问题。

\(LCA\),即最近公共祖先,是指在有根树中,找出某两个结点 \(u\)\(v\) 最近的公共祖先。

https://img2.baidu.com/it/u=386199919,1470663429&fm=253&fmt=auto&app=138&f=JPEG?w=600&h=300

记为:\(LCA(u,v)\)

方法1:暴力求解

\(father[i]\) 记录 \(i\) 的父亲结点。

首先将 \(u\)\(v\) 中深度较深的那个点跳到和较浅的点同样的深度。

然后两个点一起一步一步向上跳,直到跳到同一个点 \(p\),就是它们的 \(LCA\)

复杂度:最坏情况 \(\Theta(n)\)

适合只计算一次 \(LCA(u,v)\)

实现方法:

\(u\)\(v\) 深度大的向上走 \(|deep[u]-deep[v]|\)

再一起一步一步向上走,直到走到同一个结点 \(p\)

时间:\(\Theta(deep[u]+deep[v])\)

这里我就直接贴求 \(LCA\) 的代码了:

int LCA(int u,int v) {
	while(deep[u]>deep[v]) u=father[u];
	while(deep[u]<deep[v]) v=father[v];
	while(u!=v) {
		u=father[u];
		v=father[v];
	}
	return u;
}

方法2:利用倍增思想求LCA(u,v)

确定深度 \(deep[i]\),表示 \(i\) 到根节点的距离或者说是深度。

\(father[i][j]\) 表示 \(i\) 向上的第\(2*j\)个祖先。

\(1.\) \(dfs\) 求出\(deep[i]\)\(father[i][0]\)

\(2.\) 预处理\(father[i][j]\):利用递推式 \(father[i][j]= father[father[i][j-1]][j-1]\)

\(3.\) 如果 \(u=v\)\(LCA=u \ or \ v\)

如果 \(u\)\(v\) 的深度不同,则调整到同一深度,以便求 \(LCA\)

\(u\)\(v\) 的深度相同了,再开始向上走,每次两个点同时走相同的步数,在公共祖先中找最近的。

预处理代码:

void init(){
	maxh=log(n)/log(2)+1;
	for(int j=1;j<=maxh;++j){
		for(int i=1;i<=n;++i){
			father[i][j]=father[father[i][j-1]][j-1];
		}
	}
}

\(LCA\) 代码:

int LCA(int u,int v) {
	if(depp[u]<depp[v]) {
		swap(u,v);
	}
	int d=depp[u]-depp[v];
	for(int i=0; i<=maxh; ++i) {
		if(1<<i&d) {
			u=father[u][i];
		}
	}
	if(u==v) {
		return 0;
	}
	for(int i=maxh; i>=0; --i) {
		if(father[u][i]!=father[v][i]) {
			u=father[u][i];
			v=father[v][i];
		}
	}
	return father[u][0];
}

结尾

对于倍增,我觉得只是一个编程技巧,它可以与很多算法搭配使用。而倍增延申的东西,还是很复杂的。

分治和倍增其实是用的一种方法,只是倍增的效率要比分治高,希望大家尽量掌握我所提的这些东西,这几个算法其实就是关于倍增的一些比较常用的算法。

posted @ 2023-02-27 20:51  abc_mx  阅读(102)  评论(0)    收藏  举报