关于二分查找,我有话说

互联网侦察

共 954字,需浏览 2分钟

 ·

2021-10-09 03:44



前言


写这篇文章前,我看了很多讲二分查找的文章,包括公众号和知乎的,在我看来,讲得都比较差,说几点原因:
1、要么只讲最简单的情况
2、要么讲了稍复杂的情况,但是一会儿闭区间一会儿开区间,一会儿加一减一,一会儿又不加减,一会儿还要打个补丁
二分法细节确实比较多,但是真正理解之后,是可以有一个通用模板的,根本不需要考虑到底是开区间还是闭区间,加一还是减一还是不加减,更不需要打补丁。

本文只讲最基础的情况,但是我认为最基础的情况,至少也包括五种,而我看到的目前市面上的文章,很少讲全了五种。

我们先假设数组是升序排列,再给你一个数target。
题目总共可能有五种情况,这五种情况分别是:
1、最基础版,只要找到target就行,返回下标,找不到返回-1
2、进阶版一,找到等于target的左边界(数组中可能有多个数都等于target),找不到返回-1
3、进阶版二,找到等于target的右边界(数组中可能有多个数都等于target),找不到返回-1
4、进阶版三,找到小于target的最大值,找不到返回-1
5、进阶版四,找到大于target的最小值,找不到返回-1

总体原则,进阶版一和进阶版二应该尽量用同一种方法解决,而不需要考虑太多不一样的细节。而在我这篇文章中,进阶版一二三四都是同一个套路,各位看好。


最基础版二分法


首先你需要知道最基础版应该怎么写,二分法的思想其实很简单,就是先取中间的数,然后和target比较,如果小了就往右找,大了就往左找。然后再取中间数,如此循环,直到中间没有数了。

思路我相信大家都能理解,但是在写法上,实际上还有一个双指针的思想,也就是我们需要left和right两个指针,然后不断调整两个指针的位置,最终得到答案。

废话不多说,我们看一下代码。

public static int commonBinarySearch(int[] arr, int target) {
int left = 0, right = arr.length - 1;
while(left <= right) {
int mid = left + (right - left) / 2;
if(arr[mid] == target) {
return mid;
}
if(arr[mid] < target) {
left = mid + 1;
} else {
right = mid - 1;
}
}
return -1;
}

代码其实很简单我就不多解释了。

我相信可能有同学看过其他版本,比如所谓的开区间版本,但是我告诉大家,没有必要去看去记这么多版本,你只要会一种就能行走天下,而我建议大家就写上面这种

这种也叫闭区间版本,每一个数都会搜索到,需要注意的几个点
1、right初始是arr.length-1,很好理解,因为这是数组右边界下标
2、while里面是left<=right,也好理解,因为我们每个数都要搜索,当left=right时,我们当然要进里面再判断一下
3、区间范围缩小时,left=mid+1或者right=mid-1,也好理解,因为arr[mid]已经不是我们要找的数,所以范围需要加一或者减一

之所以要推荐闭区间版本,是因为它最符合我们的直观逻辑,而且它是对称的,是把left和right一视同仁,而不会像有的版本left需要加一,但是right却不需要减一。


进阶版


有了基础模板,我们怎么去做进阶版呢?大家可以先思考一下第二个问题。

说实话,如果是第一次碰到这个问题,还真不一定能找到最优解,第一个直观感受可能是我找到mid后,在往左一个一个地看,看看哪里是边界,但是这样,时间复杂度可能退化成o(n)。

其实还是应该用二分的思想找,关键在于arr[mid]==target时候的处理。

我们来看一下我的解法。

public static int firstBinarySearch(int[] arr, int target) {
int left = 0, right = arr.length - 1;
int res = -1;
while(left <= right) {
int mid = left + (right - left) / 2;
if(arr[mid] == target) {
res = mid;
right = mid - 1;
} else if(arr[mid] < target) {
left = mid + 1;
} else {
right = mid - 1;
}
}
return res;
}

这里的一个技巧就是当arr[mid]==target时,因为要找左边界,我们把right=mid-1,也就是改变右边界从而缩小范围。这时候其实存在两种情况,要么答案已经是res(左边界),[left,right]里面已经没有答案,while再也不会更新res,最终返回res是正确的,要么res还不是最终答案,那么答案就会在[left,right]里面,而在while中,我们总能找到它从而更新res,最终返回res也是正确的。

这里我们额外定义了一个res表示结果,这一步是整个解法的精髓。相对于很多其他教程在考虑到底是返回left还是left+1还是right还是right-1,直接定义一个res要好理解得多。

这里也要注意几点:
1、res一定是符合条件的,比如在这里,我们只有当arr[mid]==target的时候,我们才会更新res。
2、res会随着搜索范围减小越来越接近答案,当搜索结束时,res就是答案。
3、如果一次都没有更新res,说明整个数组里没有满足条件的数,res应该为-1,也就是初始值。

理解了第二问你再做第三问,我相信你直接看代码就行,都不需要我解释。

public static int lastBinarySearch(int[] arr, int target) {
int left = 0, right = arr.length - 1;
int res = -1;
while(left <= right) {
int mid = left + (right - left) / 2;
if(arr[mid] == target) {
res = mid;
left = mid + 1;
} else if(arr[mid] < target) {
left = mid + 1;
} else {
right = mid - 1;
}
}
return res;
}

同样,你做第四问和第五问,只需要考虑什么时候更新res,因为第四问要找的数是小于target的,所以应该在arr[mid]target时更新res。

第四问:

public static int lowerBiggest(int[] arr, int target) {
int left = 0, right = arr.length - 1;
int res = -1;
while(left <= right) {
int mid = left + (right - left) / 2;
if(arr[mid] < target) {
res = mid;
left = mid + 1;
} else {
right = mid - 1;
}
}
return res;
}

第五问:

public static int higherSmallest(int[] arr, int target) {
int left = 0, right = arr.length - 1;
int res = -1;
while(left <= right) {
int mid = left + (right - left) / 2;
if(arr[mid] <= target) {
left = mid + 1;
} else {
res = mid;
right = mid - 1;
}
}
return res;
}


题外话


通过这个问题,我也说说题外话。我记得曾经看过linus的一个视频(视频可以点击阅读原文打开),其中一个点我印象很深刻,他举了一个单链表删除节点的例子,他说所有的教科书都会告诉我们要分情况,删除头节点和删除一个中间节点是不一样的逻辑。但是他觉得不是,他说要从另外一个角度看,这两种情况可以统一,而一个逻辑统一的代码才是好代码


练习题


学习完二分查找,大家可以去做一下以下的习题:
leetcode704 二分查找
leetcode34 在排序数组中查找第一个和最后一个元素
leetcode35 搜索插入位置
leetcode33 搜索旋转排序数组
leetcode162 寻找峰值


还不会的话关注我,后续讲解。



浏览 29
点赞
评论
收藏
分享

手机扫一扫分享

分享
举报
评论
图片
表情
推荐
点赞
评论
收藏
分享

手机扫一扫分享

分享
举报