算法篇----二分查找
由于我们之前已经在学习C/C++过程中写过一些二分查找的代码,这里不在赘述其定义
1、综述
实不相瞒的说,二分查找是所有算法中最恶心,细节最多,最容易写出死循环的算法,但是当你熟练掌握后,这也是最简单的算法
之前或许网上听到有人说,这种算法只能用于数组有序的情况,实际上则不然,具体应用场景我会从例子中抽象出来,之后便是其有固定的模板,但是建议大家不要死记硬背,要理解之后在记忆!模板总共可以划分为三大类:朴素的二分模板,查找左边界的二分模板,以及查找右边界的二分模板,其中第一种很简单,但是局限性比较大,后两者是万能的,但是细节会比较多!
2.例题详解
2.1 二分查找
这道题要求很简单,就是要求我们在一个数组里面找到指定的数值,相比大家看到这个题应该就有解法了吧?
方法一:暴力破解
我们就遍历数组,值为targrt就返回下标,走了一圈啥也没找到就返回-1
方法二:二分查找
由于已经是升序了,我们就不用再排了
我们还是先设置两个指针,一个指向第一个数,叫left,另一个指向最后一个,叫right,随后我们再设置一个指针mid指向数组的中间位置,这个时候,问题来了,数组元素有可能是奇数也有可能是偶数,那怎么办呢?这个问题我们稍后再讲,留个悬念~
我们先完成主线任务,当我们的arr[mid]<target时,说明我们的mid所指元素比target小,那么mid左侧的数我们是不是就不用看了?直接让left=mid+1就好了,之后再对[left,right]区间重复操作!
当我们的arr[mid]>target时,说明我们的mid所指元素比target大,那么mid右侧的一坨数我们是不是就不用看了?直接让right=mid-1就好了,之后再对[left,right]区间重复操作!
当我们的arr[mid]==target时,说明我们的mid所指元素等于target,那直接返回下标Mid就欧克了。
现在我们看一下mid怎么求,如果你用(left+right+1)/2的话可能会有溢出,这里不建议这样做,这里推荐一种防止溢出的方法,即让Left向右移动一半的数组长度的距离不就可以了吗?所以我们的Mid=left+(right-left)/2.由于这里是朴素的模板写法,所以mid=left+(right-left+1)/2也可以
注意:我们每一次查找的小区间都是未知的,所以循环条件应该是left<=right
代码示例:
class Solution {
public:int search(vector<int>& nums, int target) {int left=0,right=nums.size()-1;while(left<=right){int mid =left+(right-left)/2; //防溢出if(nums[mid]<target) left=mid+1;else if(nums[mid]>target) right=mid-1;else return mid;}return -1;}
};
为此,我们抽象出朴素的二分查找模板:
朴素的二分查找模板:
while(left<=right)
{int mid =left+(right-left)/2; //防溢出if(...) left=mid+1;else if(...) right=mid-1;elsereturn ...;
}
2.2 查找指定元素的第一个和最后一个位置
34. 在排序数组中查找元素的第一个和最后一个位置 - 力扣(LeetCode)
这道题给定我们一个非降序的数组,让我们找两个位置,使其满足题目条件,首先我们要理解好什么是非降序,他与升序有什么区别!!!
解法一)暴力破解:遍历数组元素
我们可以从左到右遍历数组,遇到值为target的数,就返回下标,并求得下标的最小值和最大值就Ok,但是此种方法的时间复杂度为O(N).
解法二)二分查找:朴素版
我们能否使用上一题的朴素解法来解决呢?可以是可以,但是在极端情况下,时间复杂度还是O(N),因为当我们的数组元素要都是target的话,那我们就相当于要遍历一遍整个数组了。
解法三)二分查找:左边界+右边界
题目既然要求我们找左边界和右边界,那我们换一个想法,不就是相当于找最边界的两个数吗?那要是这样的话,我们为什么不把题目一分为二呢?一个去二分找左边界,另一个二分去找右边界!
我们来分析左边界的情况:
我们先来分析一下总体的情况,假设我们找到了数组的中间部位,并且其指向元素为x,那么无非会分为以下几种情况:
情况一)x<t,这种情况下,我们的中间值的值要小于target,所以Mid左边的就不用看了,直接让left=mid+1就好,没有必要让left=mid,因为Mid这里的也不符合,会多此一举!
情况二)x>=t,这种情况下,我们的中间值的值要大于等于target,那说明我们mid右侧的元素就不用看了,直接让right=mid就好,注意,这里不能是mid-1,因为我们的mid有可能也是等于target的,这一点要区别于朴素版!
首先就是前面的问题,由朴素版可知,中点有两个公式可以求:
公式一)mid=left+(right-left)/2
公式二)mid=left+(right-left+1)/2
那我们用哪个公式好呢?
我们可以验证一下:
当数组为偶数时,公式一的mid偏左,公式二的mid偏右
假设此时数组里面只有两个元素的时候
倘若用公式一,当x==target的时候,mid指的是1位置,之后直接让right=mid,left直接跳过了mid,不会造成死循环,
但是倘若用公式二,当x==target的时候,mid指的是2位置,本身也是right指向的位置,之后你再让right=mid,相当于你没动,卡死了!
因此在左边界的选择中,我们选公式一!
同理,在分析一下右边界的情况,
我们先来分析一下总体的情况,假设我们找到了数组的中间部位,并且其指向元素为x,那么无非会分为以下几种情况:
情况一)x<=t,这种情况下,我们的中间值的值要小于target,所以Mid左边的就不用看了,直接让left=mid就好,不能让left=mid+1,因为Mid可能也符合!
情况二)x>t,这种情况下,我们的中间值的值要大于target,那说明我们mid右侧的元素就不用看了,直接让right=mid-1就好,注意,这里没有必要是mid,因为我们的mid也是大于target的
首先还是前面的问题,由朴素版可知,中点有两个公式可以求:
公式一)mid=left+(right-left)/2
公式二)mid=left+(right-left+1)/2
那我们用哪个公式好呢?
我们可以验证一下:
当数组为偶数时,公式一的mid偏左,公式二的mid偏右
假设此时数组里面只有两个元素的时候
倘若用公式一,当x==target的时候,mid指的是1位置,本身也是left指向的位置,之后你再让left=mid,相当于你没动,卡死了!
但是倘若用公式二,当x==target的时候,mid指的是2位置,之后直接让right=mid-1,right直接跳过了mid,不会造成死循环,
因此在左边界的选择中,我们选公式二!
参考代码:
class Solution {
public:vector<int> searchRange(vector<int>& nums, int target) {//处理边界情况if(nums.size()==0) return {-1,-1};int begin=0;//1.二分左端点int left=0,right=nums.size()-1;while(left<right){int mid=left+(right-left)/2;if(nums[mid]<target) left=mid+1;else right=mid;}//判断是否有结果if(nums[left]!=target) return {-1,-1};else begin=left; //标记一下左端点//2.二分右端点left=0,right=nums.size()-1;while(left<right){int mid=left+(right-left+1)/2;if(nums[mid]<=target) left=mid;else right=mid-1;}return {begin,right};}
};
左、右边界的二分查找模板:
更正:当下面出现-1的时候,上面就+1
2.3 x的平方根
69. x 的平方根 - 力扣(LeetCode)
这道题要求我们找一个数的算术平方根,并返回整数部分即可,并且题目明确指出,不能使用库函数例如pow之类的,那我们应该怎么解决呢?
解法一)暴力破解
这道题的暴力破解方法应该还是比较容易想到的,我们就1开始一一例举每个数的平方就好了,找到符合的数就返回就ok
解法二)二分查找
题目已经给定了我们数x,那么我们只需要在区间[1,x]内寻找即可,二分后无非就是有两种情况,<=x和>x,对于情况一,说明我们二分的这个点的平方小于等于x,那我们就让left=mid就好,之后继续找,对于情况二,说明我们二分的这个点的平方大于x,那我们就让right=mid-1就好,之后继续找
参考代码:
class Solution {
public:int mySqrt(int x) {if(x<1) return 0; //处理边界情况int left=1,right=x;while(left<right){long long mid=left+(right-left+1)/2; //防溢出if(mid*mid<=x) left=mid;else right=mid-1;}return left;}
};
2.4 插入、查找数据
35. 搜索插入位置 - 力扣(LeetCode)
这道题题目要求很简单,找到目标值就返回下标,找不到就按照其应该在的顺序返回下标
解题思路:
题目都指定说时间复杂度要O(log n),那明摆着就是要二分查找了,我们还是分析情况,假设Mid指向的元素为x
情况一:x<t -> left=mid+1
情况二:x>=t ->right =mid
问题解决!套代码!
参考代码:
暴力破解:
class Solution {
public:int searchInsert(vector<int>& nums, int target){int flag=0;int b= nums.back();if(b<target){return nums.size();}else{for(int i=0;i<nums.size();i++){if(nums[i]==target){flag=i;break;}else{if(target>nums[i]&&target<nums[i+1]){flag= i+1;}}}}return flag;}
};
二分查找:
class Solution {
public:int searchInsert(vector<int>& nums, int target) {int left=0,right=nums.size()-1;while(left<right){int mid =left+(right-left)/2;if(nums[mid]<target) left=mid+1;else right=mid;}if(nums[left]<target) return right+1;return left;}
};
2.5 山峰数组峰顶索引
852. 山脉数组的峰顶索引 - 力扣(LeetCode)
这道题要求我们找山峰数组的峰顶索引,我们还是有两种方法来解决这个题目
解题思路:
方法一)暴力破解
这道题的解决方法也很简单,就是看arr[i]与arr[i+1]的关系,如果arr[i]>arr[i-1]&&arr[i]<arr[i-1],那么他就是峰顶,我们返回下标就可以了
方法二)二分查找
我们还是先找到中间点,之后判断arr[mid]与arr[mid-1]的关系,
如果arr[mid]>arr[mid-1] -> left=mid
如果arr[mid]<arr[mid-1] -> right=mid-1
参考代码:
class Solution {
public:int peakIndexInMountainArray(vector<int>& arr) {int left=1,right=arr.size()-2; //最边上的两个一定不是峰顶while(left<right){int mid=left+(right-left+1)/2;if(arr[mid]>arr[mid-1]) left=mid;else right=mid-1;}return left;}
};
2.6 寻找峰顶
162. 寻找峰值 - 力扣(LeetCode)
这个题也是呀求我们去找一个峰值,但是与上一题不同的是,这道题的山峰可能是”重峦叠嶂“的,那么我们应该怎么解决这道题吗?
解题思路:
方法一)暴力破解
这道题的暴力解法比较纯粹,就是从第一个位置开始,一直向后走,分情况讨论即可,我们总共可以分为如下三种情况:
方法二)二分查找
这道题说时间复杂度要O(log N),那无疑就是在暗示你用二分查找,通过这个题也证明了一件事,就是二分查找的使用前提条件并不是只能用于有序的数组,像这种无序的也是可以的!
好,我们具体看一下怎么解决这道题:
肯定还是要从山峰的特点下手,假设我们取得了中点Mid,可能就会有两种情况:
1、arr[mid]>arr[mid+1] -->right=mid
2、arr[mid]<arr[mid+1] -->left=mid+1
可以参考下图理解:
说明m往右的局部都不符合了,缩小右端点范围
说明m+1往左的局部都不符合了,缩小左端点范围
参考代码:
class Solution {
public:int findPeakElement(vector<int>& nums) {int left=0,right=nums.size()-1;while(left<right){int mid = left+(right-left)/2;if(nums[mid]>nums[mid+1]) right=mid;else left=mid+1;}return left;}
};
2.6搜索旋转排序数组中的最小值
153. 寻找旋转排序数组中的最小值 - 力扣(LeetCode)
这道题要我们找最小值的下标,解决方法如下:
方法一)暴力破解
就是挨个访问数组,找出最小值返回下标
方法二)二分查找
旋转后的数组是这样的:
其中 C 点就是我们要求的点因此,当 mid 在 [A,B] 区间的时候,也就是 mid 位置的值严格大于 D 点的值,下一次查询区间在 [mid + 1,right] 上; 当 mid 在 [C,D] 区间的时候,也就是 mid 位置的值严格小于等于 D 点的值,下次查询区间在 [left,mid] 上。
参考代码:
class Solution {
public:int findMin(vector<int>& nums) {int left=0,right=nums.size()-1;int x=nums[right];while(left<right){int mid=left+(right-left)/2;if(nums[mid]>x) left=mid+1;else right=mid;}return nums[left];}
};
2.7 缺失的数字
LCR 173. 点名 - 力扣(LeetCode)
这道题解题方法有很多种
解题思路
方法一)哈希表
直接把数组元素放到哈希表里面,看哪个位置是0就完事了,之后进行返回
方法二)直接遍历找结果
正常都是后一个比前一个大1,要是突然大2那就是这里有缺失,返回数值就好
方法三)位运算
由C语言我们可知,a^a==0,那么我们造一个完整的数组,让他们一起进行位运算,剩的就是那个缺失的数
方法四)高斯求和
我们可以先求一个完整的数组的和,在挨个减去这个已知数组的每个元素,剩下的就是缺失的值
方法五)二分查找
实际上,这个数组是有二段性的,我们不妨画一下图:
我们发现在缺之前,都是数值和下标相等的,但是缺之后就不相等了,因此我们可以使用二分查找算法,具体操作如下:
当mid落在左区间时,即arr[mid]==mid 时,说明我们要向右查找,因此让left==mid+1
当mid落在右区间时,即arr[mid]!=mid 时,说明我们要向左查找,因此让right==mid
最后还有一个小的细节,就是假设[0,1,2,3,4]缺的是4,如下图:
这种情况我们在代码里面用一个小的三目表达式就能处理好了!
参考代码:
class Solution {
public:int takeAttendance(vector<int>& nums) {//解法5int left=0,right=nums.size()-1;while(left<right){int mid=left+(right-left)/2;if(nums[mid]==mid) left=mid+1;else right=mid;}//处理细节问题return nums[left]==left?left+1:left;}
};
二分查找到此结束,接下我们将更新前缀和算法!