二分法边界问题(通俗讲解)

0 前言

  本文分享了二分法的原理及左闭右闭和左闭右开两种不同写法,看完本文您将对二分法有一个全新的认识。(ps: 有二分基础的可以直接看二分法的两种写法,里面分享的是二分的边界处理细节问题)

1 二分法原理

  说到原理,其实我们在上初中的时候就已经接触过它的思想了。

  莫慌莫慌😁我放一张图你就会想起来

对于区间[a,b]上连续不断且f(a)·f(b)<0的函数y=f(x),通过不断地把函数f(x)的零点所在的区间一分为二,使区间的两个端点逐步逼近零点,进而得到零点近似值的方法叫二分法。

  相信阁下已经想起来第一次接触二分思想的时候了哈哈!

1.1 定义一

其中维基百科上是这样定义二分的

二分法(dichotomy)指的是将一个整体事物分割成两部分。也即是说,这两部分必须是互补事件,即所有事物必须属于双方中的一方,且互斥,即没有事物可以同时属于双方。

1.2 定义二

百度百科是这样定义二分的

二分法(Bisection method) 即一分为二的方法. 设[a,b]为R的闭区间. 逐次二分法就是造出如下的区间序列([an,bn]):a0=a,b0=b,且对任一自然数n,[an+1,bn+1]或者等于[an,cn],或者等于[cn,bn],其中cn表示[an,bn]的中点。

  相信读者朋友可以看出来维基百科上的定义更具有普遍性。我们不妨以猜数字游戏引出计算机领域的二分法的用处。

假如这里有1-10的数字,游戏玩家预先抽出一张数字,要求是使用最少的次数猜中抽出的数字 (ps:在猜测期间,游戏玩家会得到所猜数字是猜大了还是猜小了.)

  这样我们猜测的时候就可以使用二分的思想进行猜测

  • 首先,猜测 1-10 的中间数字 5 ,如果猜大了, 6-10 的数字就不可能是答案,答案只可能存在于 1-4 ,反之,答案存在于 6-10 .
  • 接着,我们继续猜测 1-5 (6-10) ,选择 1-5 的中间值 3 ,如果猜大了, 4-5 的数字就不可能是答案,答案就只可能存在于 1-2 中
  • 以相同的方式就可以寻找到答案.
      下面用图解释一下

  从上面猜数字游戏中我们可以尝试使用维基百科上的定义套一下
为什么我们可以折半猜测数字呢? 在猜测 mid == 5 时,若猜大了,为什么 5-10 的数字一定不是答案,其实这里面隐含着一个东西 : 有序 ,维基百科上说的互斥且互补,因为答案一定在 1-5 或 6-10 中,且不会同时存在两个区间当中,1-10 这十个数字是有序排列的,有序满足了互补且互斥,所以可以使用二分猜测数字.

在其他博主中也有这样描述二分法的

  • 满足二段性
  • 答案在二段性的分界点

其实本质上是一样的.

  分享到这,我想说的是二分法的思想很简单,但难在细节的处理上,边界问题,这关系到区间收敛的成功与否,在写本片博客时,我也刚刚认真对待二分的边界问题,之前就是学个思想,只记了一种闭区间二分写法,几乎没有二分边界的意识.下面我就深入分享一下二分的边界问题,这也是二分的精髓所在.

2 二分法的两种写法

Leetcode T34 为例.

  • 非递减(说明是有序的,可以使用二分来做)
  • 寻找区间(目标值在数组中的开始位置和结束位置)

  相信读者见过有些代码的while循环终止条件是 l e f t < = r i g h t , [ l e f t , r i g h t ] left <= right, [left,right] left<=right,[left,right]; 有的是 l e f t < r i g h t , [ l e f t , r i g h t ) left < right,[left,right) left<right,[left,right) , 其实这就是今天要分享的重点之一 收敛点的处理方式 .

2.1 写法一 闭区间 l e f t < = r i g h t left <= right left<=right

 def binarySearch(nums:List[int], target:int) -> int:
        left, right = 0, len(nums)-1
        while left <= right: # 不变量:左闭右闭区间
            mid = left + (right-left) //2 
            if nums[mid] < target: 
                left = mid + 1
            else: 
                right = mid - 1
        return left  # 若存在target,则返回第一个等于target的值 
  • 为什么 l e f t < = r i g h t left <= right left<=right 时就是闭区间呢?
    因为当 l e f t = r i g h t left = right left=right 时仍然有意义, 此时 m i d mid mid 被排除在区间意外,不会包括。
  • 为什么 n u m s [ m i d ] > = t a r g e t nums[mid] >= target nums[mid]>=target r i g h t = m i d − 1 right = mid - 1 right=mid1 如果这样操作不是把等于正确答案的也排除在外了吗?
    非也非也。当 n u m s [ m i d ] = t a r g e t nums[mid] = target nums[mid]=target 那一刻,$ right $ 确实变成 $ mid - 1 $了,这也就意味着 r i g h t right right 此时在 t a r g e t target target 的左边,紧挨着 t a r g e t target target ,此时 r i g h t right right 就不会在动了, l e f t left left 会一直向 r i g h t right right 赶来,直到二者相遇,又因为 l e f t = r i g h t left = right left=right 有意义,比较完之后,因为此时的 n u m s [ r i g h t ] < t a r g e t nums[right] < target nums[right]<target ,故 l e f t = m i d + 1 left = mid + 1 left=mid+1 也就是 l e f t left left 再次向前移动一位,移到了 t a r g e t target target 位置,此时 l e f t < = r i g h t left <= right left<=right 不成立, r e t u r n return return l e f t left left 即是正确答案。

2.2 左闭右开区间 l e f t < r i g h t left < right left<right

 def binarySearch(nums:List[int], target:int) -> int:
        left, right = 0, len(nums)-1
        while left < right: # 不变量:左闭右闭区间
            mid = left + (right-left) //2 
            if nums[mid] < target: 
                left = mid + 1
            else: 
                right = mid
        return left  # 若存在target,则返回第一个等于target的值 
  • 为什么 l e f t < r i g h t left < right left<right 时就是左闭右开区间呢?
    因为当 l e f t = r i g h t left = right left=right 时没有意义(不满足while循环条件), 此时 m i d mid mid 包含在区间内,即 n u m s [ m i d ] nums[mid] nums[mid] 有可能是答案。

n u m s [ m i d ] = t a r g e t nums[mid] = target nums[mid]=target 那一刻,$ right $ 确实变成 $ mid$ 了,这也就意味着 r i g h t right right 此时在 t a r g e t target target 的位置(可能是第一个,可能不是),此时如果再遇到 n u m s [ m i d ] = t a r g e t nums[mid] = target nums[mid]=target r i g h t right right 还会再动, l e f t left left r i g h t right right 会一直靠近,直到二者相遇

if nums[mid] < target: 
      left = mid + 1

大家再看这一句,$ left $ 把边界把的死死的,只要是小于 t a r g e t target target 的值一律排除在外,所以当 l e f t left left r i g h t right right 相遇时,所指一定是 t a r g e t target target (当然,前提是数组里面存在 t a r g e t target target

大家会发现笔者是这样判断的

if nums[mid] < target: 
     left = mid + 1
else: 
     right = mid
  • 即当 n u m s [ m i d ] < t a r g e t nums[mid] < target nums[mid]<target 时, l e f t = m i d + 1 left = mid + 1 left=mid+1 ,大家会发现 $ left $ 把边界把的死死的,只要是小于 t a r g e t target target 的值一律排除在外。
  • n u m s [ m i d ] > = t a r g e t nums[mid] >= target nums[mid]>=target 时,$right = mid $,大家会发现 $ right $ 更加宽容一些,相等时也会移动,这就意味着 $ right $ 在向着 $ left $ 靠近,没错,这其实倾向于寻找存在重复元素时,第一个出现的元素。

如果找最后一个呢?
  莫慌莫慌,这可以转换为寻找 t a r g e t + 1 target + 1 target+1 的第一个出现的位置,然后下标减一,这不就是 t a r g e t target target 的最后一个元素了吗哈哈哈🌞

end = binarySearch(nums, target + 1) - 1

2.3 我们再来反证下面两个问题

2.3.1 为什么 w h i l e while while l e f t < = r i g h t : left <= right: left<=right: 搭配 r i g h t = m i d + 1 right = mid + 1 right=mid+1

  如果使用循环终止条件 w h i l e while while l e f t < = r i g h t : left <= right: left<=right:, 且 r i g h t = m i d right = mid right=mid ,当 l e f t left left r i g h t right right 同时指向同一个 t a r g e t target target 的时候,就死循环了。

2.3.2 为什么 w h i l e while while l e f t < r i g h t : left < right: left<right: 搭配 r i g h t = m i d − 1 right = mid - 1 right=mid1

  如果使用循环终止条件 w h i l e while while l e f t < r i g h t : left < right: left<right:, 且 r i g h t = m i d + 1 right = mid + 1 right=mid+1 ,当出现下图所示的情况时,就把 t a r g e t target target 完美漏掉了。而且由于循环终止条件是 w h i l e while while l e f t < r i g h t : left < right: left<right:,无论如何 l e f t left left 也不会跑到 r i g h t right right 右边去,因为二者相等时就 r e t u r n return return 了。

2.4 当使用左闭右开区间 l e f t < r i g h t left < right left<right 时,向上(下)取整问题

  下面以两种情况为例。

2.4.1 求“满足条件”的最小值

把一些会玩王者荣耀的小朋友按照水平由低到高排好序,想要从中找到能够打败小明的水平最差的那个小朋友。(这里假设水平高一些的一定可以打败水平低一些的,大家都知道,队友坑人的话,那 5 v 5 5v5 5v5哪是 5 v 5 5v5 5v5 啊,那分明是 1 v ( 5 + n )) 1 v(5+n)) 1v5+n))

while l < r :
    mid = (l+r) >> 1
    if check(mid) :
        r = mid
    else :
        l = mid + 1

其中 $check(mid)¥是一个返回值为 b o o l bool bool类型的函数,当序号为 m i d mid mid 的小朋友可以打败小明时,返回为 $ true$ ;否则返回 f a l s e false false
我们仔细分析一下这几行代码,首先是第一条原则:答案一定在 [ l , r ] [ l,r ] [lr] 中。

  • c h e c k ( m i d ) 为 t r u e check(mid)为true check(mid)true 时,说明 m i d mid mid 可以打败小明,那么序号大于 m i d mid mid 的一定也可以,而题目要找的是能打败他的水平最差的,所以正确答案一定在序号小于等于 m i d mid mid(可能是 m i d mid mid)的一侧,因此令 r = m i d r = mid r=mid 而不是 r = m i d − 1 r = mid - 1 r=mid1
  • c h e c k ( m i d ) 为 f a l s e check(mid)为false check(mid)false 时,说明 $ mid$ 已经无法打败小明,那么序号小于等于mid的小朋友都无法打败小明,正确答案一定在 [ m i d + 1 , r ] [mid + 1,r] [mid+1r] 这个区间内,所以置 l = m i d + 1 l = mid + 1 l=mid+1,而不是 l = m i d l = mid l=mid.

2.4.2 求“满足条件”的最大值

把一些会玩王者荣耀的小朋友按照水平由低到高排好序,想要从中找到不能打败小明的水平最高的那个小朋友。(这里仍然假设水平高一些的一定可以打败水平低一些的)

while l < r :
    mid = (l + r + 1) >> 1
    if check(mid) :
        r = mid - 1
    else :
        l = mid

注意其中的改变了的地方,我们来分析为什么要变为这样,首先仍然是第一条原则:答案一定在 [ l , r ] [ l,r ] [lr]中。

  • c h e c k ( m i d ) 为 t r u e check(mid)为true check(mid)true时,说明 m i d mid mid 可以打败小明,那么序号大于 m i d mid mid 的一定也可以,而题目要找的是不能打败他的水平最高的,所以正确答案一定在序号小于mid(不包括mid)的一侧,因此令 $ r = mid - 1$ 而不是 $ r = mid$.
  • c h e c k ( m i d ) 为 f a l s e check(mid)为false check(mid)false 时,说明 m i d mid mid 已经无法打败小明,那么序号小于等于 m i d mid mid 的小朋友都无法打败小明,但是我们又要找不能打败他的水平最高的那个,所以正确答案一定在 [ m i d , r ] [mid,r] [midr] 这个区间内(可能是 m i d mid mid),所以置 l = m i d l = mid l=mid,而不是 l = m i d + 1 l = mid + 1 l=mid+1.

注意这里变成了向上取整的方式 m i d = ( l + r + 1 ) > > 1 mid = (l + r + 1) >> 1 mid=(l+r+1)>>1 , 原因如下:
如果 l 、 r l、r lr 在某轮循环中分别是 2 、 3 2、3 23,则 m i d = 2 mid = 2 mid=2 ,若 c h e c k ( m i d ) 为 f a l s e check(mid)为false check(mid)false,则 l = m i d l = mid l=mid,你会发现,咦?此轮循环后, l l l r r r 的值都没变,不仅如此,从此往后每一次循环它们都不会再变了!死循环他来了!

其实不仅是第二种情况不正确的mid取值方式可能会陷入死循环,第一种也会,如果我们让第一种的mid取值方式改为 m i d = ( l + r + 1 ) > > 1 mid = (l + r + 1) >> 1 mid=(l+r+1)>>1,同样存在陷入死循环的可能,仍然用上面2、3的例子,此时 m i d = ( 2 + 3 + 1 ) > > 1 mid =(2 + 3 + 1)>> 1 mid=2+3+1>>1,即 m i d = 3 mid = 3 mid=3,而如果 c h e c k ( 3 ) 为 t r u e check(3)为true check(3)true,则 r = m i d = 3 r = mid = 3 r=mid=3,再次陷入死循环。

  也就是说,如果是r=mid,则mid应该向下取整;如果是l=mid,则要向上取整.

3 总结

  好了,以上就是笔者想要分享的关于二分法的全部内容了,若有错误,请大佬们不吝评论区赐教,小生当俯身倾耳以请😁。希望笔者的分享能够帮助到您!

参考

  1. https://zhuanlan.zhihu.com/p/275995132?utm_id=0
  2. https://zh.wikipedia.org/wiki/%E4%BA%8C%E5%88%86%E6%B3%95
  3. https://blog.csdn.net/qq_45734984/article/details/120331469?spm=1001.2101.3001.6650.1&utm_medium=distribute.pc_relevant.none-task-blog-2%7Edefault%7ECTRLIST%7ERate-1-120331469-blog-122775097.235%5Ev38%5Epc_relevant_anti_t3&depth_1-utm_source=distribute.pc_relevant.none-task-blog-2%7Edefault%7ECTRLIST%7ERate-1-120331469-blog-122775097.235%5Ev38%5Epc_relevant_anti_t3&utm_relevant_index=2
  • 4
    点赞
  • 17
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 1
    评论
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

_a_yang

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值