目录
二分算法是一种高效的算法,如果判断某道题可以使用二分算法解,那么所需要的时间复杂度就会非常小,因为二分算法的时间复杂度是O(logn)
例如一个最简单的问题,我们需要在下面的数组中找到数字40,假设这个数组严格单调递增,我们可以直接遍历这个数组,直到找到40,只需要40次就可以找到,如果数据量再大一点,例如1000个数据查找400这个数字,此时就需要循环400次,时间复杂度为O(n)
但是如果使用二分算法,因为2的10次方是1024,所以最多10次就可以锁定我们需要的数字,所以很明显如果可以使用二分算法,解题的效率是非常高的,下面通过习题详细讲解

值得注意的一点是,二分算法实际上是存在模板的,但是使用模板的前提是对模板的理解,也就是为什么要这样套模板
1.二分查找
这是一道基础的二分算法的题目
这个数组严格递增,我们要在这个数组中查找一个目标值,那么我们就从中点开始一步一步分割数组,规定左端点left是0,右端点right是nums.size()-1,也就是数组的两端,我们取中点mid为(right+left)/2,然后判断中点和目标值的关系,因为数组严格递增,所以如果中点值小于目标值,说明要查找的值在中点右侧数组,那么移动左端点为mid+1,如果大于则反过来移动右端点

根据刚才的思想我们编写代码,确定左右端点和中间值,进行初始化,注意到我这里计算中点的方式有点不一样,我是left加上左右端点距离的一半,这样写是为了防止溢出,比如某一次计算中点时,左右端点的数字都很大,加起来可能超过int范围,但是如果用减法,因为最大值就是nums.size()-1,也就是一开始没有越界就不可能越界
然后就是中点每次和目标值比较,然后移动左右端点,如果找到了就返回目标值,跳出循环都没找到说明不存在,那么直接返回-1即可
cpp
class Solution {
public:
int search(vector<int>& nums, int target) {
int left=0;
int right=nums.size()-1;
int mid=left+(right-left)/2;
while(left<=right){
if(nums[mid]>target){
right=mid-1;
}
else if(nums[mid]<target){
left=mid+1;
}
else return mid;
mid=left+(right-left)/2;
}
return -1;
}
};
这样就引出了我们的第一个模板,这是非常朴素的二分题目的模板,后续较为复杂的二分题目就要换一个模板使用了

2.在排序数组中查找元素的第一个和最后一个位置
34. 在排序数组中查找元素的第一个和最后一个位置 - 力扣(LeetCode)
根据题目意思,我们知道现在是要查找一段区间,而朴素的二分模板只能查找到某一个值,并不能精确找到这段区间的左右端点,所以我们要稍微更改一下算法

我们每次查找时,都将数组区分为两个部分,假设此时要查找的是7区间,首先查找区间左端点
如果当前中点值小于目标,那么说明包括mid以及左侧的所有位置均小于目标值,那么就让left=mid+1,因为mid以及左侧已经全部排除
如果当前中点值大于或等于目标值,说明此时的中点至少是在左端点以及右侧的位置,但是不保证一定不是左端点,所以right=mid,不进行+-的操作

同样的,按照这样的思路查找右端点,如果当前中点值大于目标值,那么说明mid以及右侧区间全部不满足右端点的条件,所以将righ设置为mid-1
如果当前中点值小于等于目标值,说明mid至少是右端点以及左侧的位置,但是不确定是否为右端点,所以将left设置为mid
然后就是这个算法的关键点,循环的跳出以及mid的更新,这里我们将循环的条件为left<right,因为当left==right的时候,相当于就是找到了需要的端点,跳出循环后left就是等于right的
还有就是避免死循环的mid的更新方式,要进行调整,我们以查找左边界的情况作为例子,此时假设mid刚好在左端点,然后right更新到了mid的位置,并且left=right-1,那么这个时候mid的更新就有讲究,我们知道更新中点有两种写法
第一种:mid=(left+right)/2,第二种:mid=(left+right+1)/2
这两种方法在朴素二分都可以使用,当left和right之间的节点个数为偶数时,第一种求的是左侧中点,第二种求的是右侧中点,奇数计算出的中点就是中间位置,但是对于查找左边界的情况,我们要使用第一种,也就是左侧中点的计算方法
依旧是下面这个例子,当left和right相邻的时候,此时如果更新mid是右侧中点,相当于mid是不变的,又回到right的位置,根据算法,此时这个点的值大于等于目标值,所以right=mid,然后经过右侧中点的计算方式,mid还是会回到right的位置,此时我们就发现陷入了死循环,得出结论,应该使用左侧中点的计算方法,这时mid就会移动到left的位置,根据算法,left=mid+1,进入下一次循环时因为条件是left<right,此时跳出循环,正好left=right=左边界

同样的,如果我们查找的是右边界,就要使用右侧中点的算法
cpp
class Solution {
public:
vector<int> searchRange(vector<int>& nums, int target) {
if(nums.size()==0)return {-1,-1};
int left=0;
int right=nums.size()-1;
int begin=-1;
int end=-1;
int mid=0;
//查找左端点,right保证移动,选择左侧mid
while(left<right){
mid=left+(right-left)/2;
if(nums[mid]<target)left=mid+1;
else right=mid;
}
if(nums[left]==target)begin=left;
left=0;
right=nums.size()-1;
//查找右端点,left保证移动,选择右侧mid
while(left<right){
mid=left+(right-left+1)/2;
if(nums[mid]>target)right=mid-1;
else left=mid;
}
if(nums[right]==target)end=right;
return {begin,end};
}
};
这样就引申查找左右边界的两种模板,当我们找到数组的二分性,就可以使用这样的模板进行解题,模板也非常好背啊,当有mid+1时,就是使用左侧中点,当有mid-1时,就是使用右侧中点

3.x的平方根
对于这道题,我们首先观察二分性,例如x=8,结果输出2,因为2*2=4<8,3*3=9>8,例如x=4,输出结果是2,因为1*1=1<4,2*2=4=4,所以我们可以得出,对于从1~x的所有数字构成的数组,我们要找到平方小于等于x的最大值,也就是t*t<=x的最大t

根据学过的模板,此时就是查找有边界,条件是mid*mid<=x(因为这里不存在已知数组,直接用数字进行计算),当mid*mid<=x时,说明mid以及左侧均成立,但是mid可能是最大值,所以left=mid,不进行多余移动,当mid*mid>x时,说明mid以及右侧均不成立,right=mid-1,并使用右侧中点的计算方法
注意为了避免结果溢出,使用longlong定义类型
cpp
class Solution {
public:
int mySqrt(int x) {
long long left=0;
long long right=x;
long long mid=left+(right-left+1)/2;
while(left<right){
if(mid*mid<=x)left=mid;
else right=mid-1;
mid=left+(right-left+1)/2;
}
return left;
}
};
4.搜索插入位置
已知这个数组是升序的,要我们在数组中查找一个目标值,如果目标值不存在,那么返回目标值应插入的位置,观察示例,1356数组中查找5,返回的是2,也就是5这个数字的下标,1356数组中查找2,因为2不存在,此时返回的是1,也就是数字3的下标,相当于插入2,数组变成12356,返回插入之后的下标
由此我们可以看出我们要查找的是大于等于目标值的第一个数字的下标,所以可以看出数组的二分性,将数组分为大于等于目标值和小于目标值两部分

经过前面几题的铺垫,具体过程就不再赘述了,代码部分如下
cpp
class Solution {
public:
int searchInsert(vector<int>& nums, int target) {
int left=0;
int right=nums.size()-1;
int mid=left+(right-left)/2;
while(left<right){
if(nums[mid]>=target)right=mid;
else left=mid+1;
mid=left+(right-left)/2;
}
if(nums[left]<target)left+=1;
return left;
}
};
5.寻找峰值
题目说假设num-1=numsn等于负无穷,意思是如果数组严格递增或者严格递减,保证数组一定会存在一个峰值,例如数组123456,那么因为num6=负无穷,数字6就是峰值

题目说可能存在多个峰值,但是我们依旧可以从数组的性质找出二分性,例如红色部分,每个元素都比前一个元素大,那么说明在红色右侧是不是一定存在一个峰值,对于蓝色部分,每个元素都比前一个元素小,那么说明在蓝色左侧是不是也一定存在一个峰值,所以这就是一个二分,当中点值比前一个元素大,那么说明峰值在右侧,中点值比前一个元素小,说明峰值在左侧
因为题目说了多个峰值返回一个即可,所以在第一次的查找时,如果查找到了红色类型,那么我们就在红色右侧去找我们的目标峰值,左侧不管存在多少峰值都不用管,同样的如果查找到蓝色类型那么就管好左边,右侧有多少峰值都不管

代码部分
cpp
class Solution {
public:
int findPeakElement(vector<int>& nums) {
int left=0;
int right=nums.size()-1;
int mid=left+(right-left)/2;
while(left<right){
if(nums[mid]>nums[mid+1])right=mid;
else left=mid+1;
mid=left+(right-left)/2;
}
return left;
}
};
6.寻找旋转排序数组中的最小值
153. 寻找旋转排序数组中的最小值 - 力扣(LeetCode)
这道题同样是找到数组的二分性

左侧是原来数组的严格递增,右侧是进行旋转之后的数组,CD部分是原来数组较小的那部分,AB部分是数组进行旋转之后将较大的部分移到前面
由于原数组严格递增,所以A一定大于D,也就是AB部分严格大于CD部分,题目要求是找到原数组最小的数字的下标,也就是图中C的位置,对于AB部分所有值均大于D,对于CD部分所有值均小于D,所以我们可以将D作为比较的条件
D也非常好找啊,D其实就是旋转完毕后,数组的最后一个元素,这样我们就完成了数组的二分


代码部分
cpp
class Solution {
public:
int findMin(vector<int>& nums) {
int len=nums.size()-1;
int left=0;
int right=nums.size()-1;
int mid=left+(right-left)/2;
while(left<right){
if(nums[mid]>nums[len])left=mid+1;
else right=mid;
mid=left+(right-left)/2;
}
return nums[left];
}
};
7点名
这道题我们需要在数组中找到缺的那个数字,那么我们将数组元素和下标一一列出来,很明显可以看出,从缺席的那位同学开始,所有元素的下标的值就不相等了
例如数组01235,下标为01234,那么元素5和下标不符合,下标值就是我们要找的那个缺少值

注意一个细节,如果所有元素都和下标匹配,也就是我们找不到那个不匹配的位置,说明是最后一个元素缺少了,特殊处理一下就行
cpp
class Solution {
public:
int takeAttendance(vector<int>& records) {
int left=0;
int right=records.size()-1;
int mid=left+(right-left)/2;
while(left<right){
if(records[mid]==mid)left=mid+1;
else right=mid;
mid=left+(right-left)/2;
}
if(records[left]==left)return left+1;
return left;
}
};