一般的二分查找应用的地方都是在一个单调有序序列里面进行值的搜索,,用中间点进行区域划分,当中间值大于目标值target,说明目标值在左区域,反之则在右区域。这样不断缩小区域,每次搜索区域都只要当前范围的一半,所以称为二分法。
但是并不是只有单调的序列才能使用二分法,二分的本质是边界的划分,只要集合/序列上有这么一种性质,这个性质能够在当前区域进行区域划分,使得左区域满足,但右区域不满足;或者是左区域不满足,右区域满足。二分法就是一种可以找到上述区域边界的方法。
目录
整数二分
因为整数进行区间划分时,如果边界没确定好,容易造成死循环,所以需要更特别的考虑,根据不同的边界划分方式,有两种二分法的代码模板:
版本1
当我们将区间[l, r]划分成[l, mid - 1]和[mid, r]时,其更新操作是r = mid - 1或者l = mid;,此时为了防止死循环,计算mid时需要加1。
C++ 代码模板:
int bsearch_1(int l, int r)
{
while (l < r)
{
int mid = l + r + 1 >> 1;
if (check(mid)) l = mid;
else r = mid - 1;
}
return l;
}
版本2
当我们将区间[l, r]划分成[l, mid]和[mid + 1, r]时,其更新操作是r = mid或者l = mid + 1;,计算mid时不需要加1。
C++ 代码模板:
int bsearch_2(int l, int r)
{
while (l < r)
{
int mid = l + r >> 1;
if (check(mid)) r = mid;
else l = mid + 1;
}
return l;
}
选择模板的流程如下图:
mid是等于(l+r+1)/2还是等于(l+r)/2其实是取决于if(check(mid))
为true时,是l=mid还是r=mid。
- 当
if(check(mid))l=mid
时(即check(mid)=true
),前面的mid应当为(l+r+1)/2
,因为(l+r)/2
是向下取整,l=mid
时,如果l和r的下标只相差1,那么mid=(l+r)/2=l
,在这种情况下,下一个二分区间[mid,r]–>[l,r],会重复进入[l,r]区间,造成死循环,所以l=mid
这样的代码模板,需要使mid=(l+r+1)/2
。 - 在
check(mid)=true
时,是采用l=mid还是r=mid,就可以区分采用何种代码模板了,check(mid)=false
时,只要根据前面的区间划分进行另一个区间的边界赋值即可。比如:check(mid)=true
时,l=mid
,是使区间从[l,r]-->[mid,r]
,那么check(mid)=false
时,就要进入左区间,即r=mid-1
,使区间从[l,r]-->[l,mid-1]
。
这里还有一篇文章对二分需要注意的一些细节进行了总结,可以参考:二分法的细节
AcWing 789. 数的范围
这题因为是找目标元素x在升序数组q中的起始和终止坐标,可以比较好的应用二分法的两种代码模板。
代码如下:
import java.util.*;
public class Main{
public static void main(String[] args){
Scanner sc=new Scanner(System.in);
int n=sc.nextInt();
int m=sc.nextInt();
int[] q=new int[n];
for(int i=0;i<n;i++)q[i]=sc.nextInt();
//m个询问
while(m-->0){
int x=sc.nextInt();
int l=0,r=n-1;
while(l<r){
int mid=l+r>>1;
//寻找x的起始坐标,当中间元素大于等于x
//说明x的起始索引在中间点左边(包括q[mid]),下次范围限定在左区间
if(q[mid]>=x)r=mid;//选择模板2
else l=mid+1;
}
//也可能找不到x,这时候就输出-1,-1
if(q[l]!=x)System.out.println("-1 -1");
else{//找到了x的起始坐标后,就要找终止坐标了
System.out.print(l+" ");
l=0;r=n-1;
while(l<r){
int mid=l+r+1>>1;
//寻找x的终止坐标,中间元素小于等于x
//说明x的终止坐标在中间点右边(包括q[mid]),下次范围限定在右区间
if(q[mid]<=x)l=mid;//提示采用模板1
else r=mid-1;
}
System.out.println(l);
}
}
}
}
LeetCode 1201. 丑数 III
主要思路:设计一个函数count(num,a,b,c),功能是计算1~num之间有多少个数能被a或b或c整除,计算“1-num之间能被a整除的数的个数”是num/a,同理b、c是num/b,num/c,但是“能被a或b或c整除”的数不能将它们简单地进行相加,这里面涉及容斥原理:
在1-num区间,设能被a整除的数的集合为集合setA,同理能被b整除的数的集合为setB,能被c整除的为setC,既能被a也能被b整除的数的集合为setAB,同理有setAC、setBC、setABC。
由容斥原理可得:能被a或b或c整除的个数setA+setB+setC-setAB-setAC-setBC+setABC。
显然这个函数count的返回值会随着num的增加而单调递增,符合二分法适用范围,只要检查count的返回值与n 的关系,当count(num,a,b,c)<n时,说明1~num的范围中丑数的个数还不够,需要向右区间搜索,反之则向左区间搜索。二分范围依题意可以缩小到l=Math.min(a,Math.min(b,c)),r=min*1L*n+1;
确定采用二分法之后,需要解决的是如何计算能被整除的数的个数,setA、setB、setC的求法已知为num/a,setAB表示同时可以被a和b整除的元素个数,也就是A∩B,A ∩ B 的元素个数就是 n / lcm(a, b)
,其中 lcm 是计算最小公倍数(Least Common Multiple)的函数,类似的,A ∩ B ∩ C 的元素个数就是 n / lcm(lcm(a, b), c)
的值。
那么最小公倍数怎么求呢?
定理:lcm(a, b) = a * b / gcd(a, b
),其中 gcd 是计算最大公因数(Greatest Common Divisor)的函数。
那么最大公因数呢?可以采用辗转相除法(欧几里得算法),具体思想可以自行百度。
代码如下:
class Solution {
public int nthUglyNumber(int n, int a, int b, int c) {
int min=Math.min(a,Math.min(b,c));
long l=min,r=min*1L*n+1;//二分初始区间优化
while(l<r){
long mid=l+r>>1;//防止爆int
if(count(mid,a,b,c)<n)l=mid+1;//说明mid不符合,要在mid右边开始找
else r=mid;
}
return (int)l;
}
//计算1~num中有多少个数能被a、b、c整除(容斥原理)
private long count(long num,long a,long b,long c){
long setA=num/a,setB=num/b,setC=num/c;
long setAB=num/lcm(a,b);
long setAC=num/lcm(a,c);
long setBC=num/lcm(b,c);
long setABC=num/lcm(a,lcm(b,c));
return setA+setB+setC-setAB-setAC-setBC+setABC;
}
//求最小公倍数(Least Common Multiple)
private long lcm(long a,long b){
return a*b/gcd(a,b);
}
//求最大公因数(Greatest CommonDivisor)--辗转相除法
private long gcd(long a,long b){
long big=Math.max(a,b);
long small=Math.min(a,b);
if(small==0)return big;
return gcd(small,big%small);
}
}
LeetCode 878. 第 N 个神奇数字
弄懂了LeetCode 1201. 丑数 III,再来做这题就很容易了,其实就是1201题的简易版本。
代码如下:
//时间复杂度O(log(min(a,b)*n))
class Solution {
public int nthMagicalNumber(int n, int a, int b) {
//所有取long类型的都是为了防止爆int
long l=Math.min(a,b),r=l*n;
while(l<r){
long mid=l+r>>1;
if(count(mid,a,b)<n)l=mid+1;
else r=mid;
}
return (int)(r%(1e9+7));
}
long count(long num,int a,int b){
return num/a+num/b-num/lcm(a,b);
}
int lcm(int a,int b){
return a*b/gcd(a,b);
}
int gcd(int a,int b){
int big=Math.max(a,b);
int small=Math.min(a,b);
if(small==0)return big;
return gcd(small,big%small);
}
}
LeetCode 69. x 的平方根
可以采用二分法去逼近那个整数,需要注意的是,需要舍去小数部分,相当于优先找左边界。
代码如下:
class Solution {
public int mySqrt(int x) {
if(x==0)return 0;
int l=1,r=x;
while(l<r){
int mid=l+(r-l+1)/2;
if(mid<=x/mid)l=mid;
else if(mid>x/mid)r=mid-1;
}
return l;
}
}
LeetCode 367. 有效的完全平方数
代码如下:
class Solution {
public boolean isPerfectSquare(int num) {
int l=1,r=num/2+1;
while(l<r){
int mid=l+(r-l)/2;
long square=(long)mid*mid;
//里面如果写成mid<num/mid这样形式,num/mid会导致向下取整
//比如5/2=2,这样就会导致当mid为2时,无法满足2<5/2
//而是满足2=5/2=2,直接return true,造成错误
if(square<num)l=mid+1;
else if(square>num)r=mid-1;
else return true;
}//
//退出时l=r,最后一次mid*mid与num的关系还没检查过,需要补充
if(l*l==num)return true;
return false;
}
}
浮点数二分
AcWing 790. 数的三次方根
浮点数的二分法
代码如下:
import java.util.*;
public class Main{
public static void main(String[] args){
Scanner sc=new Scanner(System.in);
double n=sc.nextDouble();
double l=-10000,r=10000;
while(r-l>=1e-8){
double mid=(l+r)/2;
if(mid*mid*mid>=n)r=mid;
else l=mid;
}
System.out.printf("%.6f",l);
}
}