二 分

二分查找

一个经典的问题:如何在一个严格递增序列A中找出给定的数x。
最直接的办法是:
线性扫描序列中的所有元素,如果当前元素恰好为x,则表明查找成功;
如果扫描完整个序列都没有发现给定的数x,则表明查找失败,
说明序列中不存在数x.这种顺序查找的时间复杂度为0(n) (其中n为序列元素个数),
如果需要查询次数不多,则是很好的选择,但是如果有105个数需要查询,就不太能承受了。

更好的办法是使用二分查找。二分查找是基于有序序列的查找算法(以下以严格递增序列为例),
该算法一开始令[left, right]为整个序列的下标区间,然后每次测试当前[left, right]m中间位置mid =(left + right) /2,
判断A[mid]与欲查询的元素x的大小:

  1. 如果A[mid] ==x,说明查找成功,退出查询。
  2. 如果A[mid]>x,说明元素x在mid位置的左边,因此往左子区间[ left ,mid -1] 继续查找
  3. 如果A[mid]<x,说明元素x在mid位置的右边,因此往右子区间[ mid+1,right]继续查找。
  4. 如果left>right, 说明元素没有在里面,就退出循环。

二分查找的高效之处在于,每一步都可以去除当前区间中的一半元素,因此时间复杂度是O(longn),这是十分优秀的。

#include<cstdio>
int F(int a[],int left,int right,int x)
//a是严格的递增数列,left为二分下界 right为二分上界 x为欲查询的数 
{
    
    
	int avg;//avg为left和right的中点 
    while(left<=right)
    {
    
    
    	avg=(left+right)/2;
    	if(a[avg]==x)//找到了 
			return avg;
		if(a[avg]>x)//往子区间 [ left, avg-1]查找 
		{
    
    
			right=avg-1;
		} 
		else//往子区间[ avg+1,right] 查找 
		{
    
    
			left=avg+1;
		}
    }
    return -1;//查找失败 
}
int main(void)
{
    
    
	int a[10]={
    
    23,45,67,89,123,144,167,168,199,202};
	printf("%d %d",F(a,0,9,89),F(a,0,9,90));
	return 0;
}

那么,如果序列是递减的,又应当如何处理呢?
事实上只需要把上面代码中的a[avg] >x改为 a[avg]< x即可,读者可以试着理解一下。
需要注意的是,如果二分上界超过int型数据范围的一半,那么当欲查询元素在序列较靠后的位置时,
语句avg = (left + right) / 2中的left + right就有可能超过int而导致溢出,
此时一般使用avg =left + (right-left)/2这条等价语句作为代替以避免溢出。

另外,二分法可以使用递归进行实现,但是在程序设计时更多采用的是非递归的写法。

接下来探讨更进一步的问题: 如果递增序列A中的元素可能重复,那么如何对给定的欲查询元素x,
求出序列中第一个大于等于x的元素的位置L以及第一个大于x的元素的位置R,

这样元素x在序列中的存在区间就是左闭右开区间[L, R)。
例如: 对下标从0开始、有5个元素的序列{1,3,3,3,6)来说,如果要查询3,则应当得到L=1、R=4:
如果查询5,则应当得到L=R=4; 如果查询6,则应当得到L=4、R=5; 而如果查询8,则应当得到L=R=5。
显然,如果序列中没有x,那么L和R也可以理解为假设序列中存在x,则x应当在的位置。

先来考虑第一个小问:求序列中的第一个大于等于x的元素的位置。
做法其实和之前的问题很类似,下面来分析一下。假设当前的二分区间为左闭右闭区间 [ left , right ],那么可以根据mid位置处的元素与欲查询元素x的大小来判断应当往哪个子区间继续查找:

  • ①如果A[mid]≥x,说明第一个大于等于 x的元素的位置一定在 mid处 或 mid的左侧,
    应往子区间 [ left , mid ] 继续查询,即令 right = mid 。
  • ②如果A[mid]<x,说明第一个大于等于x的元素的位置一定在mid的右侧,应往右子区间 [ mid+1 , right ] 继续查询, 即令 left =mid+1。
    代码的写法如下:
int lower_bound(int a[],int left,int right,int x)
//a是严格的递增数列,left为二分下界, right为二分上界, x为欲查询的数 
//函数返回第一个大于等于x的元素的位置
//二分上下界为左闭右闭的[left,right],传入的初值为[0,n] 
{
    
    
	int mid;//mid为left和right的中点 
    while(left<right)//对于left==right来说,意味着找到了唯一的位置 
    {
    
    
    	mid=(left+right)/2;
		if(a[mid]>=x)//往子区间 [ left, mid]查找 
		{
    
    
			right=mid;
		} 
		else//往子区间 [mid+1,right] 查找 
		{
    
    
			left=mid+1;
		}
    }
    return left;//返回夹出来的位置 
}

上述代码有几个需要注意的地方:
①循环条件为left < right 而非之前的left≤right,这是由问题本身决定的。在上一个问题中,需要当元素不存在时返回-1,
这样当left > right时 [left, right] 就不再是闭区间,可以此作为元素不存在的判定原则,因此left≤right 满足时循环应当一直执行;
但是如果想要返回第一个大于等于x的元素的位置,就不需要判断元素x本身是否存在,因为就算它不存在,返回的也是“假设它存在,它应该在的位置”,于是当left == right时,[left, right]刚好能夹出唯的位置, 就是需要的结果,因此只需要当left < right 时让循环一 直执行即可。
②由于当left == right时while循环停止,因此最后的返回值既可以是left,也可以是right。
③二分的初始区间应当能覆盖到所有可能返回的结果。首先,二分下界是0是显然的,但是二分上界是n-1还是n呢?考虑到欲查询元素有可能比序列中的所有元素都要大,此时应当返回n (即假设它存在,它应该在的位置),因此二分上界是n,
故二分的初始区间为 [ left , right ]= [0, n]。
接下来解决第二小问:求序列中第一个大于x的元素的位置。
做法是类似的。假设当前区间为[ left, right] ,那么可以根据mid位置的元素与欲查询元素x的大小来判断应当往哪个子区间继续查找:

  • ①如果A[mid]>x,说明第一一个大于x的元素的位置定在mid处或mid的左侧,应往左子区间[left, mid]继续查询。
  • ②如果A[mid]≤x,说明第一个大于x的元素的位置一定 在mid的右侧,应往右子区间[mid + 1, right]继续查询。

于是可以写出代码,相信有了上面的基础,读者应该能够理解它:

#include<cstdio>
int upper_bound(int a[],int left,int right,int x)
//a是严格的递增数列,left为二分下界, right为二分上界, x为欲查询的数 
//函数返回第一个大于等于x的元素的位置
//二分上下界为左闭右闭的[left,right],传入的初值为[0,n] 
{
    
    
	int mid;//mid为left和right的中点 
    while(left<right)//对于left==right来说,意味着找到了唯一的位置 
    {
    
    
    	mid=(left+right)/2;
		if(a[mid]>x)//往子区间 [ left, mid]查找 
		{
    
    
			right=mid;
		} 
		else//往子区间 [mid+1,right] 查找 
		{
    
    
			left=mid+1;
		}
    }
    return left;//返回夹出来的位置 
}

读者会发现,和lower_bound函数的代码相比, upper_bound函数只是把代码中的A[mid] >x改成了A[mid] >x,其他完全相同,
这启发读者寻找它们的共同点。
通过思考会发现, lower_bound函数和upper_bound函数都在解决这样一个问题: 寻找有序序列中第一个满足某条件的元素的位置。这是一个非常重要且经典的问题,平时能碰到的大部分二分法问题都可以归结于这个问题。 例如对lower_bound函数来说,它寻找的就是第一个满足条件“值大于等于x"的元素的位置;而对upper_bound函数来说,它寻找的是第一个满足条件“值大于x"的元素的位置。此处总结了解决此类问题的固定模板,希望读者能仔细推敲并理解。显然,所谓的“某条件”在序列中一定是从左到右先不满足,然后满足的(否则把该条件取反即可)。

//解决“寻找有序序列第一个满足某条件的元素的位置" 问题的固定模板
//二分区间为左闭右闭的[left,right],初值必须能覆盖解的所有可能取值。
int solve(int left,int right)
{
    
    
	int mid;//mid为left和right的中点
	while(left<right)
	{
    
    
		mid=(left+right)/2;//取中点
		if(条件成立)
		{
    
    
			right=mid;//往区间[left,mid]中查找
		}
		else
		{
    
    
			left=mid-1;//往区间[mid+1,right]中查找
		}
	}
	return left;//返回夹出来的位置
}

另外,如果想要寻找最后一个满足“条件C”的元素的位置,则可以先求第一个满足 “条件!C”的元素的位置,然后将该位置减1即可.
需要指出,虽然上面的模板使用了左闭右闭的二分区间来实现,但事实上使用左开右闭的写法也可以,并且与左闭右闭的写法等价,此处供有兴趣的读者了解。在这种做法下,二分区间是左开右闭区间(left, right],
因此循环条件应当是left+ 1 < right,这样当退出循环时有left+ 1 == right成立,使得(left, right]才是唯一位置。
而由于变成了左开,left 的初值要比解的最小取值小1 (例如对下标从0开始的序列来说,left 和right的取值应为-1 和n),
同时语句left= mid+ 1应当改为left=mid, 并且返回的应当是right而不是left.
代码如下:

//解决“寻找有序序列第一个满足某条件的元素的位置" 问题的固定模板
//二分区间为左开右闭的(left,right],初值必须能覆盖解的所有可能取值。
//初值必须能覆盖解的所有可能取值,并且left比最小值小1
int solve(int left,int right)
{
    
    
	int mid;//mid为left和right的中点
	while(left+1<right)(left,right],left+1==right意味着唯一位置
	{
    
    
		mid=(left+right)/2;//取中点
		if(条件成立)
		{
    
    
			right=mid;//往区间[left,mid]中查找
		}
		else
		{
    
    
			left=mid;//往区间[mid+1,right]中查找
		}
	}
	return left;//返回夹出来的位置
}

最后,如果目的是查找“序列中是否存在满足某条件的元素”,那么用本节的二分查找的方法最为合适。

二分扩展

在这里插入图片描述
在这里插入图片描述

const double eps=1e-5;//精度为10^-5
dluble f(double x) 
{
    
    
	return x*x;
}
double calSqrt()
{
    
    
	double left=1,right=2,mid;
	while(right-left>eps)
	{
    
    
		mid=(left+right)/2;
		if( f(mid)>2 )
		{
    
    
			right=mid;
		}
		else
		{
    
    
			left=mid;
		}
	}
	return mid;//返回mid即为sqrt(2)的近似值 
}

在这里插入图片描述
在这里插入图片描述

const double eps=1e-5;//精度为10^-5
dluble f(double x) //计算f(x) 
{
    
    
	return .....;
}
double solve(double left,double right)
{
    
    
	double mid;
	while(right-left>eps)
	{
    
    
		mid=(left+right)/2;
		if( f(mid)>0 )
		{
    
    
			right=mid;
		}
		else
		{
    
    
			left=mid;
		}
	}
	return mid;//返回mid即为f(x)=0的近似值 
}

接着来看木棒切割问题:
给出N根木棒,长度均已知,现在希望通过切割它们来得到至少K段长度相等的木棒(长度必须是整数),
问这些长度相等的木棒最长能有多长。
例如对三根长度分别为10、24、15的木棒来说,假设K=7,即需要至少7段长度相等的木棒,那么可以得到的最大长度为6,在这种情况下,第一根木棒可以提供10/6=1段、第二根木棒可以提供24/6-4段、第三根木棒可以提供15/6-2段,达到了7段的要求。

对这个问题来说,首先可以注意到一个结论:
如果长度相等的木棒的长度L越长,那么可以得到的木棒段数k越少,从这个角度出发便可以想到本题的算法,即二分答案(最大长度L),根据对当前长度L来说能得到的木棒段数k与K的大小关系来进行二分。
由于这个问题可以写成求解最后一个满足条件“k>K"的长度L,因此不妨转换为求解第一个满足条件“k<K"的长度L,然后减1即可。这个思路用4.5.1中介绍的模板便能解决,代码留给读者完成。
显然,木棒切割问题和前面的装水问题都属于二分答案的做法,即对题目所求的东西进行二分,来找到一个满足所需条件的解。
分析一下上面的问题:
大致的意思是你输入一个n代表有n个木棒
接着输入n个数字 代表的是各个木棒的长度
再输入一个k 代表的是最少的段数
问的的是分段后的最大的长是多少?

大致思路: 用二分法找到正好等于k时,的木棒长度。

#include<iostream>
#include<cstdio>
#include<algorithm>
using namespace std;
int n;
int solve(int left,int right,int k,int a[])
{
    
    
	int mid;
	int sum=0;
	while(left<right)
	{
    
    
		sum=0;
		mid=(left+right)/2;
		for(int i=0;i<n;i++)
		{
    
    
			sum+=a[i]/mid;
		}
		if(sum>k)
		{
    
    
			left=mid;
		}
		if(sum<k)
		{
    
    
			right=mid;
		}
		if(sum==k)
			return  mid;
	}
}
int main(void)
{
    
    
	int a[10];
	cin>>n;
	for(int i=0;i<n;i++)
	{
    
    
		cin>>a[i];
	}
	sort(a,a+n);//排序找到最长的木棒
	int k;
	cout<<"请输入k的值:";
	cin>>k;
	printf("%d\n",solve(1,a[n-1],k,a));
	return 0;
}

在这里插入图片描述

快速幂

先来看一题:
给定三个正整数 a,b,m ( a<109, b<106 , 1<m<109),求 ab % m
首先你要知道 a2%m= ((a%m)*a ) %m

#include<cstdio>
typedef long long LL;
long long int LLpow(LL a,LL b,LL m)
{
    
    
	LL ans=1;
	for(int i=0;i<b;i++)
	{
    
    
		ans=ans*a%m;
	}
	return ans;
}
int main(void)
{
    
    
	long long int a,b,m; 
	scanf("%lld %lld %lld",&a,&b,&m);
	printf("%lld\n",LLpow(a,b,m));
	return 0;
}

上面代码的时间复杂度是 O(b) ,如果b很大那么时间是非常的慢的。
这里要使用快速幂的做法,它基于二分的思想,因此也常称为二分幂。
快速幂基于以下事实:

  1. 如果b是奇数,那么有ab= a * ab
  2. 如果b是偶数,那么有a= ab/2+ab/2

显然, b是奇数的情况总可以在下一步转换为b是偶数的情况,
而b是偶数的情况总可以在下一步转换为b/2的情况。
这样,在log(b)级别次数的转换后,就可以把b变为0,而任何正整数的0次方都是1.
举个例子,如果需要求1010.

  1. 对210来说,由于幂次10为偶数,因此需要先求25,然后有210=25 * 25
  2. 对25来说,由于幂次5为奇数,因此需要先求24,然后有25=2 * 24
  3. 对24来说,由于幂次4为偶数,因此需要先求22,然后有24=22 * 22
  4. 对22来说,由于幂次2为偶数,因此需要先求21,然后有22=21 * 2 1
  5. 对21来说,由于幂次1为奇数,因此需要先求20,然后有21=2*20
  6. 由于20=1,然后从下往上依次回退计算即可。

这显然是递归的思想,于是可以得到快速幂的递归写法 , 时间复杂度为 O(logb)

#include<cstdio>
typedef long long LL;
LL binarypow(LL a,LL b,LL m)
{
    
    
	if(b==0)//递归边界
		return 1;
	if(b%2==1)
		return a*binarypow(a,b-1,m)%m;
	else
	{
    
    
		LL m1=binarypow(a,b/2,m);
		return m1*m1%m;
	}
}
int main(void)
{
    
    
	long long int a,b,m; 
	scanf("%lld %lld %lld",&a,&b,&m);
	printf("%lld\n",binarypow(a,b,m));
	return 0;
}

上面代码中,条件 if(b%2==1) 可以用 if(b&1)代替。
这是因为b&1 进行位运算,判断b的末位是否为1,因此当b为奇数时 b&1 返回 1, if条件成立。

这样写执行速度会快一点。
在这里插入图片描述
快速幂的迭代方法:

#include<cstdio>
typedef long long LL;
LL binarypow(LL a,LL b,LL m)
{
    
    
	LL ans=1;
	while(b>0)
	{
    
    
		if(b&1)
		{
    
    
			ans=ans*a%m;
		}
		a=a*a%m;//另a平方
		b>>=1;//将b的二进制位右移一位
	}
	return ans;
}
int main(void)
{
    
    
	long long int a,b,m; 
	scanf("%lld %lld %lld",&a,&b,&m);
	printf("%lld\n",binarypow(a,b,m));
	return 0;
}

猜你喜欢

转载自blog.csdn.net/qq_46527915/article/details/114878832