一、二分法的理论背景
求解非线性方程的一种简单高效的方法:二分法
非线性方程是指 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)/2 | l,r>=0;且 l+r 无溢出 | l+r 可能溢出;负数情况下有向0取整问题 |
mid=l+(r-l)/2 | l-r 无溢出 | 若 l,r 一正一负,l-r 可能溢出 |
mid=l+r>>1 | l+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}
- 首先,二分的区间必须是有序的,所以第一步应该将数组a进行排序。于是 a[0]=124 , a[1]=232, a[2]=456。且 l 在[0,456]这个区间内。
- 定义 l =0 ,r = a [n-1] //即排序后的数组中最大的元素,在区间 [l,r]内不断二分,直到找到最大值,此时最大值为l=r
- 对于每次二分,取mid=l + r +1>>1(判断向上取整和向下取整可以参考第四步。由于 l=mid,所以必须向上取整)
- 判断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.实数二分查找
代码如下:
#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)