什么是二分查找?
关于什么是二分查找, 我们可以通过一个简单的问题开始:从给定的有序数组nums中,找到目标值的位置。输入nums为[0, 2, 3, 4, 5, 7, 8, 9], 目标值target为7。
在有序数组中查找目标值,可以通过普通的遍历来查找目标值。但是相信大家都可以想到更快速的方法。即先确定一个中点, 比较中点与目标值的大小,从而确定目标值在左侧或右侧。 重复此步骤,我们可以在O(logn)的时间内找到目标元素。
这便是二分查找算法的核心思想。相对于普通遍历,时间复杂度实现了对数级的下降。这样的性能提升, 在数据量较大时,体验会非常明显。
二分查找用代码怎么表达?
通过上面的思考, 相信大家对二分查找这个算法都有了概念上的认知。我们梳理下二分查找的思路。如下图:
我们可以大概整理下我们的代码, 整理伪代码如下:
//pre-processing
left = 0; right = length - 1;
while(...) { //有效循环条件
int mid = (right + left) / 2;
if(nums[mid] == target) { //找到的情况
...
}else if(target > nums[mid]) { //目标值比中点大,在右侧
...
}else if (target < nums[mid]){//目标值比中点小,在左侧
...
}
}
大家可以根据各自的语言做下实现。可以在leetcode中,看看自己实现的代码是否能通过所有测试用例。这里给出golang的实现
func BinarySearch(nums []int, target int) int {
left := 0
right := len(nums)-1
for left<=right {
mid := (left + right) / 2
if nums[mid] == target {
return mid
}else if target < nums[mid] {
right = mid-1
}else if target > nums[mid] {
left = mid+1
}
}
return -1
}
最让人迷糊的「边界、循环条件」问题
二分查找的思想非常简单、自然, 但是往往简单的东西,越是容易忽视细节而造成问题。 大家在实现上述思路的时候,是否出现过循环超时、漏找的情况呢😄。 这里,我们有些需要着重注意的地方:
right初始化为
length还是length-1? 我们需要理解, left和right的含义。left和right包含的区间为搜索区间。
这里我们rigth初始化的值为length-1,搜索区间可以表示为 [0, length-1]。
当right初始化为length时,搜索区间可表示为 [0, length]。
基于这个理解,我们初始化搜索区间为[0,length-1],因为nums[len]会发生越界。
left<=right还是left<right?
循环条件为left<=right时:left=right+1时循环终止。此时left在right右侧,是个空区间,这意味着所有区间已经搜索完成。
但是当left==right时,依旧在循环中,此时若初始化搜索区间为 [0, length],当left、right同时指向lenght时,数组访问发生越界~
循环条件为left<right时:left=right时循环终止。这意味着可能会有一个区间[point,point]没有被搜索到!这种情况的话,我们需要在循环外边判断nums[left](或nums[right])是否等于target。
为什么是
mid+1,mid-1? nums[mid]已经找过了,区间转移的时候肯定不需要再次比较啦~
变形:找左右边界
我看再看下二分查找涉及的其他问题,比如:查找有序数组中目标值第一次出现和最后一次出现的位置,leetcode.34。
根据我们刚掌握的技巧,我们可以找到目标值之后往左右遍历查找边界。但这样的话在极端情况下, 就退化为O(n)复杂度了。我们能继续使用二分查找的思想出来吗?
其实处理方法比较简单, 找左边界的话, 我们只需要在找到中点mid时
将mid于mid-1的值比较一下就好了 因为当mid-1也等于target时,mid肯定不是左边界,而且左边界肯定在mid左边。 我们更新查找区间为[left, mid-1],继续使用刚实现的二分查找算法即可找到目标位置。实现代码如下:
func findLeft(nums []int, target int) int {
left := 0
right := len(nums)-1
for left<=right {
mid := (right+left) / 2
if nums[mid]==target {
if mid != 0 && nums[mid-1] == target {//判断是否在0处,防止mid-1越界
right = mid-1
}else{
return mid
}
}else if target > nums[mid] {
left = mid+1
}else if target < nums[mid] {
right = mid-1
}
}
return -1
}
掌握找左边界的方法之后, 找右边界的问题也是一样的逻辑啦~。即将mid与右侧元素比较一下,若右侧元素也等于target,那右边界肯定在右边,然后我们将搜索区间移至右侧部分。实现代码几乎与上边一致:
func findRight(nums []int, target int) int {
left := 0
right := len(nums)-1
for left <= right {
mid := (left+right)/2
if nums[mid] == target {
if mid!=len(nums)-1 && nums[mid+1] == target{
left = mid+1
}else{
return mid
}
}else if target > nums[mid] {
left = mid+1
}else{
right = mid-1
}
}
return -1
}
其实这个方法也适用与查找最接近target元素,大家可以自己思考下具体实现,这里不做赘述。
总结
几乎每个人在脑海里都有二分查找的思想,二分查找的也非常自然、简单。
- 我们可以很自然的想到中点划分搜索区间后的三种情况:找到中点;在中点左侧;在中点右侧。
- 左右指针意味这搜索区间[left,right],(左闭右闭),意味着我们初始化搜索区间为[0, len-1]防止访问nums[len]发生越界。
- 循环条件为
left<=right,因为当left==right时,我们依旧要进行搜索,不能漏掉~ - 找边界问题只需要在找到目标值时与相邻元素比较一下就好了。因为相邻元素等于target时,当前当前位置mid肯定不是边界。
当然,也建议各位同学可以也可以在leetcode中做下二分查找的专项练习~,面试频率还是蛮高的。 最后,和大家说一些学算法的两个tips:
- 我们是学算法,不是发明算法,所以初学时还是以看例题学习为主,一个类型刷三两题找找感觉,再尝试做题目,千万别在初期死磕题目, 费时且效率也不高。
- 光说不练假把式,我们可以多写一下, 不然就会“一看就会,一写就废”~ 加油~