二分查找法(Binary Search)算法,也叫折半查找算法。二分查找针对的是一个有序的数据集合,查找思想有点类似于分治思想。每次都通过跟区间的中间元素对比,将带查找的区间缩小为之前的一半,知道找到要查找的元素,或者区间被缩小为0。二分查找是一种非常非常高效的查询算法,时间复杂度未O(logn)。
基本算法思想:先确定待查找元素所在的区间范围,在逐步缩小范围,直到找到元素或找不到该元素为止。
二分查找算法的过程如下所示:
举个例子来说,给定一个有序数组 [0, 1, 2, 3, 4, 5, 6, 7, 8]
。如果我们希望查找 5
是否在这个数组中。
[0, 1, 2, 3, 4, 5, 6, 7, 8]
,中位数是 4
,因为 4
小于 5
,所以如果 5
存在在这个数组中,那么 5
一定在 4
右边的这一半区间中。于是我们的查找范围变成了 [4, 5, 6, 7, 8]
。[4, 5, 6, 7, 8]
,中位数是 6
,因为 5
小于 6
,所以如果 5
存在在这个数组中,那么 5
一定在 6
左边的这一半区间中。于是我们的查找范围变成了 [4, 5, 6]
。[4, 5, 6]
,中位数是 5
,正好是我们需要查找的数字。于是我们发现,对于一个长度为 9
的有序数组,我们只进行了 3
次查找就找到了我们需要查找的数字。而如果是依次遍历数组,则最坏情况下,我们需要查找 9
次。
【LeetCode】704.二分查找
给定一个 n
个元素有序的(升序)整型数组 nums
和一个目标值 target
,写一个函数搜索 nums
中的 target
,如果目标值存在返回下标,否则返回 -1
。
示例:
输入: nums = [-1,0,3,5,9,12], target = 9 输出: 4 解释: 9 出现在 nums 中并且下标为 4
二分查找解题:
class Solution:
def search(self, nums: List[int], target: int) -> int:
l,r = 0,len(nums)-1
while l <= r:
mid = (l+r)//2
if nums[mid] > target:
r = mid - 1
elif nums[mid] < target:
l = mid + 1
else:
return mid
return -1
从上面的例子中我们了解了二分查找的思路和具体代码。但是真正在解决二分查找题目的时候还是需要考虑很多细节的。比如说以下几个问题:
mid
的取值问题:mid = (left + right) // 2
,还是 mid = (left + right + 1) // 2
?left <= right
,还是 left < right
?left = mid + 1
、right = mid - 1
、 left = mid
、right = mid
应该怎么写?下面一一讲解。
区间的左闭右闭、左闭右开指的是初始待查找区间的范围。
left = 0
,right = len(nums) - 1
,left
为数组第一个元素位置,right
为数组最后一个元素位置,从而区间 [left, right]
左右边界上的点都能取到。left = 0
,right = len(nums)
,left
为数组第一个元素位置,right
为数组最后一个元素的下一个位置,从而区间 [left, right)
左边界点能取到,而右边界上的点不能取到。关于左闭右闭、左闭右开,其实在网上都有对应的代码和解法。但是相对来说,左闭右开这种写法在解决问题的过程中,需要考虑的情况更加复杂,所以建议 全部使用「左闭右闭」区间。
mid
的取值问题在二分查找的实际问题中,最常见的 mid
取值就是 mid = (left + right) // 2
或者 mid = left + (right - left) // 2
。前者是最常见写法,后者是为了防止整型溢出。式子中 // 2
就代表的含义是中间数「向下取整」。当待查找区间中有偶数个元素个数时,则位于最中间的数为 2
个,这时候使用上面式子只能取到中间靠左边那个数,而取不到中间靠右边的那个数。那么,右边的那个数到底能取吗?
其实,右边的数也是可以取的,令 mid = (left + right + 1) // 2
,或者 mid = left + (right - left + 1) // 2
。这样如果待查找区间的元素为偶数个,就能取到中间靠右边的那个数了。
这是因为二分查找的思路是根据每次选择中间位置上的数值来决定下一次在哪个区间查找元素。每一次选择的元素位置可以是中间位置,但并不是一定非得是区间中间位置元素,靠左一些、靠右一些、甚至区间三分之一、五分之一处等等,都是可以的。比如说 mid = left + (right - left + 1) * 1 // 5
也是可以的。
但一般来说,取中间位置元素在平均意义下所达到的效果最好。同时这样写最简单。而对于 mid
值是向下取整还是向上取整,大多数时候是选择不加 1
。但有些写法中,是需要考虑加 1
的,这个后面会说这种写法。
我们经常看到二分查找算法的写法中,while
语句出界判断的语句有left <= right
和 left < right
两种写法。那我们究竟应该在什么情况用什么写法呢?
left <= right
,且查找的元素不存在,则 while
判断语句出界条件是 left == right + 1
,写成区间形式就是 [right + 1, right]
,此时待查找区间为空,待查找区间中没有元素存在,所以此时终止循环可以直接返回 -1
是正确的。
[3, 2]
,不可能存在一个元素既大于等于 3
又小于等于 2
,此时直接终止循环,返回 -1
即可。left < right
,且查找的元素不存在,则 while
判断语句出界条件是 left == right
,写成区间形式就是 [right, right]
。此时区间不为空,待查找区间还有一个元素存在,并不能确定查找的元素不在这个区间中,此时终止循环返回 -1
是错误的。
[2, 2]
,元素 2
就属于这个区间,此时终止循环,返回 -1
就漏掉了这个元素。但是如果我们还是想要使用 left < right
的话,怎么办?
可以在返回的时候需要增加一层判断,判断 left
所指向位置是否等于目标元素,如果是的话就返回 left
,如果不是的话返回 -1
。即:
# ...
while left < right:
# ...
return left if nums[left] == target else -1
此外,用 left < right
的话还有一个好处,就是退出循环的时候 left == right
成立,就不用判断应该返回 left
还是 right
了。
在进行区间范围选择的时候,有时候是 left = mid + 1
、right = mid - 1
,还有的时候是 left = mid + 1
、right = mid
,还有的时候是 left = mid
、right = mid - 1
。那么我们到底应该如何确定搜索区间范围呢?
这是二分查找的一个难点,写错了很容易造成死循环,或者得不到正确结果。
这其实跟二分查找算法的两种不同思路有关。
第 1 种思路比较简单,一旦我们在循环体中找到元素就直接返回结果。其实我们在上边 「3. 简单二分查找 - [704. 二分查找」 中就已经用过了。这里再看一下思路和代码:
mid
,先看中心位置值 nums[mid]
。
nums[mid]
与目标值 target
相等,则 直接返回 这个中心位置元素的下标。nums[mid]
小于目标值 target
,则将左节点设置为 mid + 1
,然后继续在右区间 [mid + 1, right]
搜索。nums[mid]
大于目标值 target
,则将右节点设置为 mid - 1
,然后继续在左区间 [left, mid - 1]
搜索。class Solution:
def search(self, nums: List[int], target: int) -> int:
left = 0
right = len(nums) - 1
# 在区间 [left, right] 内查找 target
while left <= right:
# 取区间中间节点
mid = left + (right - left) // 2
# 如果找到目标值,则直接范围中心位置
if nums[mid] == target:
return mid
# 如果 nums[mid] 小于目标值,则在 [mid + 1, right] 中继续搜索
elif nums[mid] < target:
left = mid + 1
# 如果 nums[mid] 大于目标值,则在 [left, mid - 1] 中继续搜索
else:
right = mid - 1
# 未搜索到元素,返回 -1
return -1
left <= right
。第 2 种思路在循环体中排除目标元素一定不存在区间。
mid
,根据判断条件先将目标元素一定不存在的区间排除。根据第二种排除法的思路,我们可以写出来两种代码。
第一种代码:
class Solution:
def search(self, nums: List[int], target: int) -> int:
left = 0
right = len(nums) - 1
# 在区间 [left, right] 内查找 target
while left < right:
# 取区间中间节点
mid = left + (right - left) // 2
# nums[mid] 小于目标值,排除掉不可能区间 [left, mid],在 [mid + 1, right] 中继续搜索
if nums[mid] < target:
left = mid + 1
# nums[mid] 大于等于目标值,目标元素可能在 [left, mid] 中,在 [left, mid] 中继续搜索
else:
right = mid
# 判断区间剩余元素是否为目标元素,不是则返回 -1
return left if nums[left] == target else -1
第二种代码:
class Solution:
def search(self, nums: List[int], target: int) -> int:
left = 0
right = len(nums) - 1
# 在区间 [left, right] 内查找 target
while left < right:
# 取区间中间节点
mid = left + (right - left + 1) // 2
# nums[mid] 大于目标值,排除掉不可能区间 [mid, right],在 [left, mid - 1] 中继续搜索
if nums[mid] > target:
right = mid - 1
# nums[mid] 小于等于目标值,目标元素可能在 [mid, right] 中,在 [mid, right] 中继续搜索
else:
left = mid
# 判断区间剩余元素是否为目标元素,不是则返回 -1
return left if nums[left] == target else -1
left < right
。这样在退出循环时,一定有left == right
成立,就不用判断应该返回 left
还是 right
了。同时方便定位查找元素的下标。但是一定要注意最后要对区间剩余的元素进行一次判断。nums[mid]
在什么情况下一定不是目标元素,排除掉不可能区间,然后再从剩余区间中确定下一次查找区间的范围。nums[mid]
在什么情况下一定不是目标元素之后,它的对立面(即 else
部分)一般就不需要再考虑区间范围了,直接取上一个区间的反面区间。如果上一个区间是 [mid + 1, right]
,那么相反面就是 [left, mid]
。如果上一个区间是 [left, mid - 1]
,那么相反面就是 [mid, right]
。[left, mid - 1]
与 [mid, right]
时,mid 取值要向上取整。即 mid = left + (right - left + 1) // 2
。因为如果当区间中只剩下两个元素时(此时 right = left + 1
),一旦进入 left = mid
分支,区间就不会再缩小了,下一次循环的查找区间还是 [left, right]
,就陷入了死循环。left = mid
就向上取整。或者记为:
left = mid + 1
和 right = mid
和 mid = left + (right - left) // 2
一定是配对出现的。right = mid - 1
和 left = mid
和 mid = left + (right - left + 1) // 2
一定是配对出现的。left <= right
,有时候要考虑返回是 left
还是 right
。循环体内有 3 个分支,并且一定有一个分支用于退出循环或者直接返回。这种思路适合解决简单题目。即要查找的元素性质简单,数组中都是非重复元素,且 ==
、>
、<
的情况非常好写的时候。【LeetCode】374. 猜数字大小
猜数字游戏的规则如下:
你可以通过调用一个预先定义好的接口 int guess(int num)
来获取猜测结果,返回值一共有 3 种可能的情况(-1
,1
或 0
):
pick < num
pick > num
pick == num
返回我选出的数字。
示例:
输入:n = 10, pick = 6 输出:6
题解:
class Solution:
def guessNumber(self, n: int) -> int:
left = 1
right = n
while left <= right:
mid = (left + right) // 2
ret = guess(mid)
if ret == 0:
return mid
elif ret == -1:
right = mid - 1
else:
left = mid + 1
【LeetCode】69. x 的平方根
给你一个非负整数 x
,计算并返回 x
的 算术平方根 。
由于返回类型是整数,结果只保留 **整数部分 **,小数部分将被 舍去 。
**注意:**不允许使用任何内置指数函数和算符,例如 pow(x, 0.5)
或者 x ** 0.5
。
示例:
输入:x = 4 输出:2
题解:
class Solution:
def mySqrt(self, x: int) -> int:
l,r = 1,x/2 + 1
while l <= r:
mid = (l+r)//2
if mid > x/mid:
r = mid - 1
elif mid < x/mid:
l = mid + 1
else:
return int(mid)
return int(r)
思考:对于二分查找的使用主要还是在范围的定界和判断的条件处存在问题