二分法的边界问题详细分析

二分法中最痛苦的问题:确定边界条件。

下面从一个最简单的例子说起:
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

上面是二分法的标准流程:

  • 确定左右边界,[left, right)
  • 确定终止条件
  • 获得中点
  • 根据比较的值,收缩可行的区间

1、确定左右边界

一个 n n n长有序序列的最小索引是 0 0 0,最大索引是 n − 1 n-1 n1
那么边界有两种选择:
1、左闭右开 [ 0 , n ) [0, n) [0,n)
2、左闭右闭 [ 0 , n − 1 ] [0, n-1] [0,n1]
两种方式都可以,为了与python的默认用法相一致,我通常使用左闭右开,原因参考为什么区间个切片要忽略最后一个元素。

2、 确定终止条件

首先要明确的是,我们的目标是遍历整个数组。因此终止条件要确保数组的所有元素都可以被包含。
假如我们使用 [ 0 , n ) [0, n) [0,n)。此时右值是不可取的,因此终止条件是

while left < right:

如果使用 [ 0 , n − 1 ] [0, n-1] [0,n1], 此时右值 n − 1 n-1 n1是可取的, 终止条件必须包含。

while left <= right:

上面的说法可能不容易想象,可以想一个简单例子,假设数组只有一个元素,也就是 n = 1 n=1 n=1
左闭右开 [ 0 , 1 ) [0, 1) [0,1) ,不取右值
左闭右闭 [ 0 , 0 ] [0, 0] [0,0], 取右值
为了将所有元素都包括到终止条件当中,左闭右闭的时候left==right 时必须能够进入while
所以终止条件可以概括为:取右值用<=, 不取右值用<。

3、获得中点

中点不管哪种区间取法都是一样的

mid = left + (right - left) // 2 #写法1
mid = (left + right) // 2 #写法2

写法1是通常采用的,因为这避免直接相加导致的数值溢出
需要明确一点,上述表达式计算出来的 m i d mid mid必然是合法的索引, 而且不可能等与right,除非 left == right

4、收缩区间

为了避免写出死循环,必须保证每次进入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 指向哪儿

在while left < right 的终止条件下,退出时 left == right。
先明确一个问题,left和target的关系是未知的, right和target的关系是已知的。
简单解释下:left = mid + 1。我们知道的是nums[mid] 但是right = mid,我们知道nums[mid] > target, 所以nums[right]是已知的。除了一种情况,right == n,这种情况下不存在大于target的数字。
因为退出while时候,left == right, 而 nums[right] > target,所以nums[left]>target。实际上left会指向nums中第一个大于target的数字。
已知nums[left] > target,在来看下nums[left-1]和target的关系。
假设left-1存在:

  • 如果执行过赋值left = mid + 1, 那么nums[mid] < target。所以nums[left-1] < target。nums[left-1] < target < nums[left],显然nums[left]是nums中大于target的最小值。
  • 如果left = mid + 1没有执行过, 那么说明left == 0,没有移动过,这就相当于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 = mid + 1, 那么left == 0,是第一个索引,所以left仍然是第一个大于target的数字。

综上所以不管哪种情况,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的数字

你可能感兴趣的:(leetcode)