必刷算法之:二分查找(Binary search)

610 阅读4分钟

什么是二分查找?

关于什么是二分查找, 我们可以通过一个简单的问题开始:从给定的有序数组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:

  • 我们是学算法,不是发明算法,所以初学时还是以看例题学习为主,一个类型刷三两题找找感觉,再尝试做题目,千万别在初期死磕题目, 费时且效率也不高。
  • 光说不练假把式,我们可以多写一下, 不然就会“一看就会,一写就废”~ 加油~