二分查找法是我们学习编程时大多数人遇到的第一个算法,这种算法是适用于数量极大的,有序排列的数字的情况。时间复杂度为 O(log(n)),简单来说就是一次可以扔掉一半的数据,不要小看每次的折半当次数非常多时这个数值可以是极其庞大的。网上有个经典的案例就是 一张纸的对折次数,理论上一张纸折叠42次可达到月球。可想而知这就是指数的增长的可怕,同样相反的对数的减少也是巨变的。 如下图所示
从函数图的角度来看,指数增长的斜率是越来越陡峭的,也就是增长速率是越来越大的。相反对数是越来越平缓的,这就说明当数量越大时我们这个算法的效果越明显。
之所以要单独总结下二分查找,是因为本人在最近刷算法题时发现二分查找也有很多细节的地方值得关注,例如 while的判断条件什么时候是 lift<right 什么时候是 lift<=right ,还有返回值什么时候是 left+1 什么时候是 left 什么时候是 mid,诸如此类的问题值得探讨,这也是很多人关于边界处理不清楚的地方。还有就是左区间与右区间,左闭右开还是左闭右闭……
常见的二分查找算法如下所示:我这里有个数组内容为 1, 2, 3, 4, 15, 64, 78, 109 需要在其中查找 target =15的值
int banarySort(vector<int>& nums,int target) { /*1, 2, 3, 4, 15, 64, 78, 109*/
int l = 0;
int r = nums.size() - 1;
while (l<r) {
int mid = (l + r) / 2;
if (nums[mid]<target) { //比目标值小
l = mid + 1;
}
else {
r = mid;
}
}
return l;
}
算法模拟如下所示:
第一步是线计算出 mid的位置 然后和 target比较,之后再将left指针移动到mid的右侧 然后再计算mid值 以此类推,之所以最后双指针会重合是因为while的循环条件是 (left<right)
如果将要找的值改为 16 那么就会进行如下操作,返回值为 5 因此需要加上判断条件就可以知道是否查找到了 我们需要的值并且位置是否正确。
接下来 我们将对这里的不同条件进行剖析,保证做到对边界的完全可控。如下所示:
left &right 的值
通常我们把 左右边界设置在数组两端级 left=0,right=nums.length-1 区间范围为 [0,nums.length-1] 左闭左开区间,还有一种写法是left=0,right=nums.length 这时候的区间就是 [0,nums.length)左闭右开
这个地方主要是区间的区别,正常使用right=nums.length-1就可以了 ,这个地方我用两种方法测试了很多个函数发现结果并没有什么区别,区别主要在于过程。如果有知道区别的可以评论区留言。
取中位的写法和注意事项
mid通常是用来进行作为寻找中间值的,通常写法为 (left+right)/2 其实这种写法基本没啥问题,但如果left或right给出数值非常大的数,这样很有可能就会越界,因此会采用以下的写法 移位运算效率是最高的
//不建议
mid =(left+right)/2
//建议
mid =left+(right-left)/2
//移位运算
mid = (l + r+1)>>1
这里还有个比较重要的地方就是 是否要+1 ,接下来简单剖析一下mid每次执行的结果
当遇到小数时,前者采用的是向下取整,即数值为3.5会被看做是 3 ,后者向上取整 会被看做是 4,那这有什么作用呢?
这个地方实际上非常重要,与while循环判定条件,结束条件,mid是否加减- 有着千丝万缕的联系。这也是会导致while无限循环的可能点之一。
简单来说当使用向下取整后 我们计算的mid值用远会选择左面的值,向上取整则是选择右面的值。如下图所示如果使用向上取整,那么后面再计算mid的值 就一定是16 ,反之则是4.
这里的用处之一就是选择最左侧值和最右侧值,如下图所示,若我们数组内并不是只有一个15,那么我们如何精确的选择最左侧的或是最右侧的呢?
如上图所示,如果mid始终偏向 右侧,那么right就绝不可能 等于 mid-1,否则就跑出去了。这里如果要设置 while的条件时,还要看left的设置,如果是left=mid那么当left==right的时候就可以退出循环了。即 while(left<right) 注意这里不是等于,因为最后还要再执行一次。
那么如何获取最右侧的15呢? 想要获取最右侧的15,就要将区间锁定在 右侧,即 left要定在最后一个15的位置,由right进行变化
这里只是举个示例实际上要结合while,mid一起来判断。
while的判断条件
这里 while的判断条件通常为 while (left<right) 或 while (left<=right) 主要区别在于结束时 l和r 的状态,及返回值。
以下只是演示一种情况,只有在mid等条件都确定才能决定最终结果,这里可以看到,小于时最后结束两个指针会 停止在left==right的状态,而下面则是在 == 之后再循环一次,也就是多了一次循环的区别
!
判断条件和内容
这里指的是判断主体如下所示部分,这里主要需要知道 什么时候直接 =mid 什么时候 =mid-1 什么时候 =mid+1
这里mid决定的是边界!!!
如果要进行 mid+1或 mid-1 必然 left和right 每次都取不到mid 的值 。
第一种情况:left 和 right 都取不到 mid
这种情况一般是把结束条件放在了 判断语句之上了,先写==mid的情况 再写不等于的两种情况,
while (l <= r) {
int mid = l + (r - l) / 2;
if (nums[mid] == target) return mid; //看这里
if (target < nums[mid]{
r = mid -1;
}
else {
l = mid + 1;
}
}
}
第二种情况:left 或 right 其中一个取不到 mid
这种一般来说 取得到的一方通常是边界,例如上述的 三个15 如果我只想取最左面或最右面那么就需要确定区间了。口诀就是边界不变的 ==mid 剩下那个无所谓,他如果恒在左侧,我们可以通过改 mid的向上下取整 来改变位置。
这里比较经典的题目就是 34. 在排序数组中查找元素的第一个和最后一个位置
这道题将左区间和右区间都考察到了,个人认为想要锻炼区间方面的题这道是首选
给定一个按照升序排列的整数数组 nums,和一个目标值 target。找出给定目标值在数组中的开始位置和结束位置。
如果数组中不存在目标值 target,返回 [-1, -1]。
进阶:
你可以设计并实现时间复杂度为 O(log n) 的算法解决此问题吗?
示例 1:
输入:nums = [5,7,7,8,8,10], target = 8 输出:[3,4]
class Solution {
public:
vector<int> searchRange(vector<int>& nums, int target) {
int l=0;int r=nums.size()-1;
int mid=0;
vector<int> a(2,-1);
if(nums.empty()){return a;};
//左边界
while(l<r){
mid=l+(r-l)/2;
if(nums[mid]<target){
l=mid+1;
}else{
r=mid;
}
}
//这个地方注意 l和r要归原
if(nums[r]==target){a[0]=r;l=0,r=nums.size()-1;}else{return a;};
//查找右边界
while(l<r){
mid=l+(r-l+1)/2;
if(nums[mid]>target){
r=mid-1;
}else{
l=mid;
}
}
if(nums[l]==target){a[1]=l;}else{return a;};
return a;
}
};
返回值
关于返回值,实际上要看判断条件和while()条件来判断返回值的内容 一般以 left 为主,如果 ==mid的条件放在上面,基本就是两个返回值,一个是当找到了mid 就返回了 另一个就是没有找到 返回false等 如下所示
leetcode 35. 搜索插入位置
给定一个排序数组和一个目标值,在数组中找到目标值,并返回其索引。如果目标值不存在于数组中,返回它将会被按顺序插入的位置。
请必须使用时间复杂度为 O(log n) 的算法。
输入: nums = [1,3,5,6], target = 5 输出: 2
这道题注意题目里的一句话,**如果目标值不存在于数组中,返回它将会被按顺序插入的位置。**这句话就是我上面说的,如果存在==mid的值 那就返回mid,不存在就返回其他值,这里是 要插入的位置。
代码如下所示:
class Solution {
public:
int searchInsert(vector<int>& nums, int target) {
int front=0, rear=nums.size()-1,mid=0;
while(front<=rear){
mid=(front+rear)/2;
if (target < nums[mid]) {
rear = mid - 1;
}
else if (target > nums[mid]) {
front = mid + 1;
} else{
return mid; //mid结果
}
}
return front; //其他结果
}
};
二分查找常见模板:
class Solution {
public:
int searchInsert(vector<int>& nums, int target) {
int left = 0, right = nums.length - 1;
while(left <= right) {
int mid = (left + right) / 2;
if(nums[mid] == target) {
// 相关逻辑
} else if(nums[mid] < target) {
left = mid + 1; // 注意
} else {
right = mid - 1; // 注意
}
}
// 其他值
return 0;
}
}
class Solution {
public:
int searchInsert(vector<int>& nums, int target) {
int left = 0, right = nums.length - 1;
while(left <= right) {
int mid = (left + right) / 2;
if(nums[mid] == target) {
return mid;
} else if(nums[mid] < target) {
left = mid + 1;
} else {
right = mid - 1;
}
}
return left;
}
}
总结:二分查找的注意点比较多,如果记住模板可以解决掉很多问题并且不用担心边界问题,但我个人还是不太支持这种做法,如果时间长了可能就忘记了,不如直接研究透了,即便前期会遇到各种问题,但熟能生巧对付各种问题都能应对知道该哪个参数就能解决问题这才是刷题真正的意义。
一般来说题目中给出 有序、O(logn)的解法 那多半是考察二分查找,所谓的十个二分九个错 是没错的,但正是算法灵活多变才显得有意思。