二分法(整数二分/实数二分,二分答案/二分查找)

一、二分法的理论背景

求解非线性方程的一种简单高效的方法:二分法

非线性方程是指 f (x)中含有三角函数、指数函数或其他超越函数(如对数函数,反三角函数,指数函数等)。这些方程很难求得精确解,不过在实际应用中,只要满足一定精度要求的近似解就可以了。此时需要考虑以下两个问题:

(1)根的存在性。定理:函数 f (x) 在闭区间 [a,b] 上连续,且 f (a) · f (b) < 0 ,则 f(x)存在根。

(2)求根。一般有两种方法,搜索法和二分法,这边只介绍二分法。

        二分法:如果确定 f (x) 在闭区间 [a,b] 上连续,且 f (a) · f (b) < 0(也就是根存在的情况下),把 [a,b] 逐次分半,检查每次分半后区间两端点函数值符号的变化,确定有根的区间。

        注意:使用二分法,需要函数满足两个条件:上下界 [a,b] 确定;且函数在 [a,b] 内单调

算法竞赛中有两种二分题型:整数二分和实数二分。整数二分要注意边界问题,避免漏掉答案或者陷入死循环;实数二分要注意精度问题。

二、整数二分

1.基本代码

        通过两个问题来给出整数二分的基本代码:在单调递增序列中找x或x的后继;在单调递增序列中找x或x的前驱

  • 在单调递增序列中找x或x的后继:在单调递增序列a [ ] 中,如果有x,找第一个x所在位置;如果没有x,找第一个比x大的数的位置。如下图所示。

  • 在单调递增序列中找x或x的前驱:在单调递增序列a [ ] 中,如果有x,找最后一个x所在位置;如果没有x,找第一个比x小的数的位置

2.整数二分查找

例题1:数的范围

 

分析:题目中要求我们寻找一个单调递增数列中的x的起始位置和终止位置。我们可以分为两种情况讨论:

寻找x的起始位置,即找第一个x所在位置,也就是在单调递增序列中找x或x的后继;寻找x的终止位置,即找最后一个x所在位置,也就是在单调递增序列中找x或x的前驱

下面给出“在单调递增序列中找x或x的后继”的过程模拟

代码如下: 

//a表示有n个元素的单调递增数组,x为将要查找的目标元素 
int find(int a,int n,int x){
	int l=0,r=n-1;           //定义左右边界 
	while(l<r){
		int mid=l+r>>1;
		if(a[mid]>=x)r=mid;  //说明目标元素不在mid位置的右边,搜索区间更新为[l,mid]
		else l=mid+1;        //说明目标元素不在mid位置以及mid位置的左边,搜索区间更新为[mid+1,r]
	}                        //终止于l=r
	return l;
}

在单调递增序列中找x或x的后继中,注意:

(1)对mid的计算

处理使用场合可能出现的问题
mid=(l+r)/2l,r>=0;且 l+r 无溢出l+r 可能溢出;负数情况下有向0取整问题
mid=l+(r-l)/2l-r 无溢出若 l,r 一正一负,l-r 可能溢出
mid=l+r>>1l+r 无溢出l+r 可能溢出

(2)对mid的处理

  • 代码中的 l=mid+1 能否写成 l=mid?

        整数二分中存在取整的问题。例如当 l=2,r=3 时,mid=l+r>>1=2,若取 l=mid=2,那么更新后的 l、r 的值仍然不变,while()陷入死循环。所以不能写成 l=mid。

  • mid能否向上取整?

        不能。例如当 l=2,r=3 时,mid=l+r+1>>1=3,若取 r=mid=3,那么更新后的 l、r 的值仍然不变,while()陷入死循环。

结合以上两条,我们可以发现,当 l =mid时,mid不能向 l 取整,而必须向 r 取整;当 r =mid时,mid不能向 r 取整,而必须向 l 取整。否则将有可能出现死循环。

  • 谨慎使用mid=(l+r)/2

若(l+r)是正数,那么mid向下(向 l)取整;若(l+r)是负数,那么mid向上(向 r)取整。所以能在正区间正确运行的代码,在负区间可能出现死循环。

(3)在二分之前需要进行排序(虽然本题不用进行排序)

下面给出“在单调递增序列中找x或x的前驱”的代码:

int find(int a,int n,int x){
	int l=0,r=n-1;
	while(l<r){
		int mid=l+r+1>>1;  //注意:由于l=mid,所以mid必须向上取整
		if(a[mid]<=x)l=mid;
		else r=mid-1;
	}
	return r;
}

结合在单调递增序列中找x或x的后继,以及在单调递增序列中找x或x的前驱的代码,本题代码如下:

#include<bits/stdc++.h>
using namespace std;
const int N=100010;
int a[N];
int main(){
	int n,q;
	cin>>n>>q;
	for(int i=0;i<n;i++)cin>>a[i];
	while(q--){
		int k;
		cin>>k;
		
		//查找x与x的后继 
		int l=0,r=n-1;
		while(l<r){
			int mid=l+r>>1;
			if(a[mid]>=k)r=mid;
			else l=mid+1;
		}
		if(a[l]!=k){
			cout<<"-1 -1"<<endl;
			continue;
		}else cout<<l<<' ';
		
		//查找x与x的前驱
		l=0,r=n-1;
		while(l<r){
			int mid=l+r+1>>1;
			if(a[mid]<=k)l=mid;
			else r=mid-1;
		}
		cout<<r<<endl;
	}
	return 0;
}

例题2:A-B 数对 - 洛谷

 分析:

C已知,要找到A-B=C

那么问题可以转化为:对于数列中的每一个元素A,是否存在一个数B,使得A-B=C。若存在,求出B的个数。

显然,我们的目标是,从数列中查找一个数,那么可以考虑用二分法

代码如下:

#include<iostream>
#include<algorithm>
using namespace std;
const int N=200010;
int a[N];  //存储正整数数列
int n,c;
long long res; //注意,长度为n的正整数列中,数对最多有n^2/4个,res>2e9,超出int数据范围 

int main(){
	cin>>n>>c;
	for(int i=0;i<n;i++)cin>>a[i];
	sort(a,a+n);                  //先对数列进行排序 
	
	//遍历A的值 
	for(int i=0;i<n;i++){
		int b=a[i]-c;     //需要查找的目标元素 
		int st;           //储存目标元素的起始位置 
		 
		int l=0,r=n-1;
//由于c为正整数,所以b一定小于a[i],又因为a数列由小到大排序
//所以目标元素b一定在 [0,i)区间内,所以可以取 l=0,r=i
		while(l<r){
			int mid=l+r>>1;
			if(a[mid]>=b)r=mid;
			else l=mid+1;
		}
		if(a[l]!=b)continue; //如果找不到目标元素,则进入下一个循环
		st=l;
		
		l=0,r=n-1;
//由于起始位置st已知,所以目标元素b一定在(st,i)区间内,所以可以取 l=st,r=i 
		while(l<r){
			int mid=l+r+1>>1;
			if(a[mid]<=b)l=mid;
			else r=mid-1;
		}
		res+=r-st+1;    //注意要+1	
	}
	cout<<res;
	return 0;
} 

例题3:烦恼的高考志愿 - 洛谷

 代码如下:

#include<iostream>
#include<algorithm> 
using namespace std;
const int N=100010;
int a[N],b[N];     //a[]为学校预计分数线;b[]为学生估分
int n,m;
long long res;

int main(){
	cin>>m>>n;   //m为学校数,n为学生数
	for(int i=0;i<m;i++)cin>>a[i];
	for(int i=0;i<n;i++)cin>>b[i];
	sort(a,a+m);  //对学校分数线进行排序
	
	for(int i=0;i<n;i++){  //遍历每个学生的估分 

		int l=0,r=m-1;
		while(l<r){
			int mid=l+r>>1;
			if(a[mid]>=b[i])r=mid;
			else l=mid+1;
			}
			
	//当b[i]小于所有学校分数线时,l=r=0,此时l-1越界,所以要特判 
		if(b[i]<=a[0])res+=a[0]-b[i];	
		else res+=min(abs(a[l]-b[i]),abs(a[l-1]-b[i]));
	} 
	cout<<res;
	return 0;
} 

3.整数二分答案

特征1:整数二分法的两个经典模型:最大值最小化(最大值尽量小),最小值最大化(最小值尽量大)

特征2:答案在一个区间内

最大值最小化:

例题1:数列分段 Section II - 洛谷

 

分析:

求最大值的最小化,考虑整数二分答案的方法来解题。

该题涉及到求区间的和,考虑使用前缀和数组(前缀和数组的下标最好从1开始)

1.输入数列的同时,初始化前缀和数组,找到前缀和数组中的最大值sum_max,

2.定义二分答案所在的区间 [l,r],其中 r=sum_max

3.取mid=l+r>>1

4.对于每次二分出的mid,判断mid是否满足条件(check函数判断,若数组每一段的和都<=mid,那么一共需要分成多少段,若分成的段数<=m,说明mid满足条件,反之则不满足)

过程模拟:

 

代码如下:
#include<iostream>
using namespace std;
const int N=100010;
int a[N];
long long sum[N];
int n,m,a_max,sum_max;      //a_max存储数列a中的最大值 

bool check(int x){ 
	int k=0; 
	if(x<a_max)return false; 
	for(int i=1,j=0;i<=n;i++){
		if(sum[i]-sum[j]>x){
			j=i-1;
			k++;
		}
	}
	if(k<m)return true;
	else return false;
}

int main(){
	cin>>n>>m;
	for(int i=1;i<=n;i++){
		cin>>a[i];
		sum[i]=a[i]+sum[i-1];     //初始化前缀和数组 
		if(a[i]>a_max)a_max=a[i]; //找到数列中的最大值 
		if(sum[i]>sum_max)sum_max=sum[i]; //找到数列中的最大值 
	}
	
	int l=0,r=sum_max;
	while(l<r){
		int mid=l+r>>1;
		if(check(mid))r=mid;
		else l=mid+1;
	}
	cout<<l;
	return 0;
}

 其他例题:[TJOI2007] 路标设置 - 洛谷

最小值最大化:

例题1:木材加工 - 洛谷

 分析:

题目要求我们求出 l 的最大值,且 l 在一个区间内,于是可以考虑用二分答案的方法来求解:从区间中找到一个最优答案。

对于样例:n=3 , k=7 , a[ ]={232,124,456}

  1. 首先,二分的区间必须是有序的,所以第一步应该将数组a进行排序。于是 a[0]=124 , a[1]=232,  a[2]=456。且 l 在[0,456]这个区间内。
  2. 定义 l =0 ,r = a [n-1] //即排序后的数组中最大的元素,在区间 [l,r]内不断二分,直到找到最大值,此时最大值为l=r
  3. 对于每次二分,取mid=l + r +1>>1(判断向上取整和向下取整可以参考第四步。由于 l=mid,所以必须向上取整
  4. 判断mid是否满足条件(也就是当木头被切成长度为mid的小段木头时,是否能成功切出k段),若不满足条件(说明小段木头长度为mid的情况下,原木不足以被切成k段),则r=mid-1(因为mid不满足条件,所以不取到mid,于是右边界要-1);若满足条件,则 l=mid(由于mid满足条件,所以能取到mid)

代码如下:

#include<iostream>
#include<algorithm>
using namespace std;
const int N=100010;
int a[N];
int n,k;

bool check(int x){
	int res=0;       //记录把木头切割为长度均为x的小段木头,可以切出res段 
	for(int i=0;i<n;i++){
		res+=a[i]/x;
	}
	if(res>=k)return true;
	else return false;
}


int main(){
	cin>>n>>k;
	for(int i=0;i<n;i++)cin>>a[i];
	sort(a,a+n);
	
	int l=0,r=a[n-1];
	while(l<r){
		int mid=l+r+1>>1;
		if(check(mid))l=mid;
		else r=mid-1;
	}
	cout<<l<<endl;
	return 0;
} 
例题2:[NOIP2015 提高组] 跳石头 - 洛谷

 

 代码如下:

#include<iostream>
using namespace std;
const int N=50010;
int a[N]; //岩石的位置
int d,n,m;//d表示起点到终点的距离 

bool check(int x){
	int j=0,k=0;               //j表示目前所在位置,k表示目前已经移走的岩石数量 
	for(int i=1;i<=n+1;i++){   //易错点!!i<=n+1
//i表示将要跳的岩石位置,要考虑终点。若j位置无法跳到终点,则把j位置的岩石移走 
		if(a[i]-a[j]<x)k++;    //如果由j位置无法跳到i位置,那么把i位置的岩石移走 
		else j=i;              //如果由j位置能够跳到i位置,那么更新当前位置j 
	}
	if(k<=m)return true;       //如果移走的岩石总数<=m,说明该跳跃距离满足条件 
	return false;
}

int main(){
	cin>>d>>n>>m;                   
	for(int i=1;i<=n;i++)cin>>a[i];
	a[n+1]=d;    //a[n+1]表示位于终点的石头 
	
	int l=1,r=d; //最短跳跃距离,即最优解所在区间 
	while(l<r){
		int mid=l+r+1>>1;
		if(check(mid))l=mid; //因为要求最大值,所以尽可能往右走 
		else r=mid-1;
	}
	cout<<l;
	return 0;
} 

其他例题:进击的奶牛 - 洛谷

三.实数二分

1.实数二分查找

790. 数的三次方根 - AcWing题库

 

代码如下:

#include<iostream>
using namespace std;
int main(){
	double n;         
	cin>>n;                     //输入一个浮点数
	double l=-10000,r=10000;    //定义左右边界

//题目要求保留n-2位小数时,写1e-n(记得多2)
//比如说这道题目中,要求保留六位小数,那么写成1e-8
	while(r-l>=1e-8){         //也可以直接循环100次
		double mid=(l+r)/2;
		if(mid*mid*mid>n)r=mid; //若mid的三次方大于n,说明n的三次方根在mid的左侧,所以令r=mid
		else l=mid;
	} 
	printf("%.6lf",l);
	return 0;
}

四.二分查找的时间复杂度

假设有n个元素,那么二分后每次查找的区间大小为 n , n/2 ,… n/2^k。其中k是循环的次数。

最坏的情况是k次二分之后,每个区间的大小为1时,找到目标元素。令n/2^k=1,得到k=logn

所以二分查找的时间复杂度为O(logn)

评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值