二分查找算法思想
二分查找算法是经典的 「减而治之」 的思想。
这里的 「减」 是减少问题规模的意思,「治」 是解决问题的意思。「减」 和 「治」 结合起来的意思就是 「排除法解决问题」。即:每一次查找,排除掉一定不存在目标元素的区间,在剩下可能存在目标元素的区间中继续查找。
每一次通过一些条件判断,将待搜索的区间逐渐缩小,以达到「减少问题规模」的目的。而于问题的规模是有限的,经过有限次的查找,最终会查找到目标元素或者查找失败
二分查找细节
区间的开闭问题
左闭右闭区间、左闭右开区间指的是初始待查找区间的范围。
-
左闭右闭区间:初始化时,𝑙𝑒𝑓𝑡=0,𝑟𝑖𝑔ℎ𝑡=𝑙𝑒𝑛(𝑛𝑢𝑚𝑠)−1。
- 𝑙𝑒𝑓𝑡 为数组第一个元素位置,𝑟𝑖𝑔ℎ𝑡 为数组最后一个元素位置。
- 区间 [𝑙𝑒𝑓𝑡,𝑟𝑖𝑔ℎ𝑡] 左右边界上的点都能取到。
-
左闭右开区间:初始化时,𝑙𝑒𝑓𝑡=0,𝑟𝑖𝑔ℎ𝑡=𝑙𝑒𝑛(𝑛𝑢𝑚𝑠)。
- 𝑙𝑒𝑓𝑡 为数组第一个元素位置,𝑟𝑖𝑔ℎ𝑡 为数组最后一个元素的下一个位置。
- 区间 [𝑙𝑒𝑓𝑡,𝑟𝑖𝑔ℎ𝑡) 左边界点能取到,而右边界上的点不能取到。
关于二分查找算法的左闭右闭区间、左闭右开区间,其实在网上都有对应的代码。但是相对来说,左闭右开区间这种写法在解决问题的过程中,会使得问题变得复杂,需要考虑的情况更多,所以不建议使用左闭右开区间这种写法,而是建议:全部使用「左闭右闭区间」这种写法。
mid的取值问题
在二分查找的实际问题中,最常见的 𝑚𝑖𝑑 取值公式有两个:
mid = (left + right) // 2
。mid = (left + right + 1) // 2
。
式子中 //
所代表的含义是「中间数向下取整」。当待查找区间中的元素个数为奇数个,使用这两种取值公式都能取到中间元素的下标位置。
而当待查找区间中的元素个数为偶数时,使用 mid = (left + right) // 2
式子我们能取到中间靠左边元素的下标位置,使用 mid = (left + right + 1) // 2
式子我们能取到中间靠右边元素的下标位置。
二分查找算法的思路是:根据每次选择中间位置上的数值来决定下一次在哪个区间查找元素。每一次选择的元素位置可以是中间位置,但并不是一定非得是区间中间位置元素,靠左一些、靠右一些、甚至区间三分之一、五分之一处等等,都是可以的。比如说 mid = (left + right) * 1 // 5
也是可以的。
但一般来说,取区间中间位置在平均意义下所达到的效果最好。同时这样写最简单。而对于这两个取值公式,大多数时候是选择第一个公式。不过,有些情况下,是需要考虑第二个公式的,我们会在「4.2 排除法」中进行讲解。
除了上面提到的这两种写法,我们还经常能看到下面两个公式:
mid = left + (right - left) // 2
。mid = left + (right - left + 1) // 2
。
这两个公式其实分别等同于之前两个公式,可以看做是之前两个公式的另一种写法。这种写法能够防止整型溢出问题(Python 语言中整型不会溢出,其他语言可能会有整型溢出问题)。
在 𝑙𝑒𝑓𝑡+𝑟𝑖𝑔ℎ𝑡 的数据量不会超过整型变量最大值时,这两种写法都没有问题。在 𝑙𝑒𝑓𝑡+𝑟𝑖𝑔ℎ𝑡 的数据量可能会超过整型变量最大值时,最好使用第二种写法。所以,为了统一和简化二分查找算法的写法,建议统一写成第二种写法:
mid = left + (right - left) // 2
。mid = left + (right - left + 1) // 2
。
出界条件的判断
二分查找算法的写法中,while
语句出界判断条件通常有两种:
left <= right
。left < right
。
如果判断语句为 left <= right
,并且查找的元素不在有序数组中,则 while
语句的出界条件是 left > right
,也就是 left == right + 1
,写成区间形式就是 [𝑟𝑖𝑔ℎ𝑡+1,𝑟𝑖𝑔ℎ𝑡],此时待查找区间为空,待查找区间中没有元素存在,此时终止循环时,可以直接返回 −1。
- 比如说区间 [3,2], 此时左边界大于右边界,直接终止循环,返回 −1 即可
如果判断语句为left < right
,并且查找的元素不在有序数组中,则 while
语句出界条件是 left == right
,写成区间形式就是 [𝑟𝑖𝑔ℎ𝑡,𝑟𝑖𝑔ℎ𝑡]。此时区间不为空,待查找区间还有一个元素存在,我们并不能确定查找的元素不在这个区间中,此时终止循环时,如果直接返回 −1 就是错误的。
- 比如说区间 [2,2],如果元素 𝑛𝑢𝑚𝑠[2] 刚好就是目标元素 𝑡𝑎𝑟𝑔𝑒𝑡,此时终止循环,返回 −1 就漏掉了这个元素。
注意:while判断语句用 left < right
有一个好处,就是在跳出循环的时候,一定是 left == right
,我们就不用判断此时应该返回 𝑙𝑒𝑓𝑡 还是 𝑟𝑖𝑔ℎ𝑡 了。
搜索范围的选择
在进行区间范围选择的时候,通常有三种写法:
left = mid + 1
,right = mid - 1
。left = mid + 1
,right = mid
。left = mid
,right = mid - 1
。
- 思路 1:「直接法」—— 在循环体中找到元素后直接返回结果。
- 思路 2:「排除法」—— 在循环体中排除目标元素一定不存在区间。
练习
T0704. 二分查找
class Solution:
def search(self, nums: List[int], target: int) -> int:
left, right = 0, len(nums) - 1
# 在区间 [left, right] 内查找 target
while left <= right:
# 取区间中间节点
mid = (left + right) // 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
T0035. 搜索插入位置
class Solution:
def searchInsert(self, nums: List[int], target: int) -> int:
size = len(nums)
left, right = 0, size - 1
while left <= right:
mid = left + (right - left) // 2
if nums[mid] == target:
return mid
elif nums[mid] < target:
left = mid + 1
else:
right = mid - 1
return left
T0374. 猜数字大小
class Solution:
def guessNumber(self, n: int) -> int:
left = 1
right = n
while left <= right:
mid = left + (right - left) // 2
ans = guess(mid)
if ans == 1:
left = mid + 1
elif ans == -1:
right = mid - 1
else:
return mid
return 0
T0069. x 的平方根
class Solution:
def mySqrt(self, x: int) -> int:
left = 0
right = x
ans = -1
while left <= right:
mid = (left + right) // 2
if mid * mid <= x:
ans = mid
left = mid + 1
else:
right = mid - 1
return ans