二分法中最痛苦的问题:确定边界条件。
下面从一个最简单的例子说起:
LeetCode 704. 二分查找
class Solution:
def search(self, nums: List[int], target: int) -> int:
left, right = 0, len(nums) # 确定左右边界
while left < right: # 终止条件
mid = left + (right-left)//2 #得到中间点
if nums[mid] == target: return mid
# 收缩可行的区间
if nums[mid] < target: left = mid+1
else: right = mid
return -1
上面是二分法的标准流程:
一个 n n n长有序序列的最小索引是 0 0 0,最大索引是 n − 1 n-1 n−1。
那么边界有两种选择:
1、左闭右开 [ 0 , n ) [0, n) [0,n)
2、左闭右闭 [ 0 , n − 1 ] [0, n-1] [0,n−1]
两种方式都可以,为了与python的默认用法相一致,我通常使用左闭右开,原因参考为什么区间个切片要忽略最后一个元素。
首先要明确的是,我们的目标是遍历整个数组。因此终止条件要确保数组的所有元素都可以被包含。
假如我们使用 [ 0 , n ) [0, n) [0,n)。此时右值是不可取的,因此终止条件是
while left < right:
如果使用 [ 0 , n − 1 ] [0, n-1] [0,n−1], 此时右值 n − 1 n-1 n−1是可取的, 终止条件必须包含。
while left <= right:
上面的说法可能不容易想象,可以想一个简单例子,假设数组只有一个元素,也就是 n = 1 n=1 n=1。
左闭右开 [ 0 , 1 ) [0, 1) [0,1) ,不取右值
左闭右闭 [ 0 , 0 ] [0, 0] [0,0], 取右值
为了将所有元素都包括到终止条件当中,左闭右闭的时候left==right 时必须能够进入while
所以终止条件可以概括为:取右值用<=, 不取右值用<。
中点不管哪种区间取法都是一样的
mid = left + (right - left) // 2 #写法1
mid = (left + right) // 2 #写法2
写法1是通常采用的,因为这避免直接相加导致的数值溢出
需要明确一点,上述表达式计算出来的 m i d mid mid必然是合法的索引, 而且不可能等与right,除非 left == right
为了避免写出死循环,必须保证每次进入while之后,可行区间必然会缩小。也就是每进入一次while,要么left变大,要么right变小, 如果有不收缩的情况就会死循环。
还假设使用左闭右开 [ 0 , n ) [0, n) [0,n)
当nums[mid] < target 的时候,应该将left往右移,
先来看下能想到的几种情况,
1、left = mid
2、left = mid + 1
首先明确mid对应值不可能是解,所以mid值要被抛弃。
如果用第方式1,因为left是可以取到的,如果mid 本来就等于left,那么区间没有收缩。所以为了抛弃mid, 必须要mid+1
当nums[mid] > target 时, right要左移。
也看下几种情况
1、right = mid
2、right = mid - 1
还是要明确mid这个值要被抛掉。
如果用方式1、我们知道right这个索引是取不到的,除非left==right, 如果left==right,那么根本进不来while, 所以mid这个索引被扔掉了。方式1可行
方式2, mid这个索引取不到,但是mid-1这个索引也取不到。还不知道mid-1是否是解就被扔掉了,所以方式2不可行。
再看下左闭右闭的情况。原则还是每次都要收缩区间。
两者的差异在右值上面,因为右闭情况下mid == right是有可能的,只要left==right, 而这个条件在while left<=right 情况下是可以满足的。
1、right = mid
2、right = mid - 1
方式1、right是可取到,所以mid没被扔掉
方式2、mid被扔掉了,mid-1是可取的。
总结一下: 每次进入while都要收缩区间, 收缩的办法就是扔掉mid值,那么要显式的抛掉mid,比如left = mid+1, right=mid-1(左闭右闭),要么是隐式收缩,right=mid(左闭右开)
在while left < right 的终止条件下,退出时 left == right。
先明确一个问题,left和target的关系是未知的, right和target的关系是已知的。
简单解释下:left = mid + 1。我们知道的是nums[mid]
因为退出while时候,left == right, 而 nums[right] > target,所以nums[left]>target。实际上left会指向nums中第一个大于target的数字。
已知nums[left] > target,在来看下nums[left-1]和target的关系。
假设left-1存在:
如果left-1不存在, left-1不存在说明left == 0, 因为0是第一个索引,所以nums[left]还是第一个大于target的数。
在while left <= right的终止条件下,退出时left == right + 1
这个时候left与target的关系不确定,right和target的关系也不确定。
但是如果执行过赋值 right = mid - 1, nums[mid] > target, 换句话说nums[right+1] == nums[mid] > target。left = right + 1所以nums[left] > target。
如果没执行过right = mid-1, 那么right == n-1, left == right+1 == n。也就是说不存在大于target的数字。
如果执行过赋值left = mid + 1, nums[mid]
综上所以不管哪种情况,left总是指向第一个大于target的数字,或者不存在大于target的数字,此时left == n。
下面是一个示例。
LeetCode 35. 搜索插入位置
class Solution:
def searchInsert(self, nums: List[int], target: int) -> int:
left, right = 0, len(nums)
while left < right:
mid = left + (right-left)//2
if nums[mid] == target: return mid
if nums[mid] < target: left = mid+1
else: right = mid
return left # left指向第一个大于target的数字