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

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.youkuaiyun.com/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
<think>好的,我现在需要解决用户关于二分法边界条件处理的问题。用户提到了参考的引用内容,涉及二分法的流程、中点计算以及不同情况下的边界处理。首先,我应该回顾二分法的基本概念,然后结合引用中的信息,详细分析边界条件的处理方法。 首先,二分法的关键在于确定搜索区间和终止条件。根据引用[1],区间可以是左闭右开[left, right),或者是闭区间[left, right]。不同的区间选择会影响循环条件和边界更新。例如,如果区间是左闭右开,那么终止条件应该是left < right,因为当left等于right时,区间已经没有元素了。而闭区间的话,终止条件应为left <= right,因为当left等于right时,区间还有一个元素需要检查。 接下来,中点mid的计算。引用[2]提到使用int mid = low + ((high - low) / 2);这种方法可以避免整数溢出的问题,尤其是在处理大数组时。这对应到Python的话,可能不需要担心,因为Python的int可以很大,但习惯上还是可以用类似的写法,比如mid = left + (right - left) // 2。同时,需要注意当区间长度为偶数时,mid是向下取整还是向上取整,这会影响后续的边界调整,特别是在处理左边界或右边界时。 然后,根据引用[3],二分法有多种情况,比如查找特定值、左边界或右边界。每种情况下的边界调整方式不同。例如,寻找目标值的精确匹配时,当arr[mid]等于目标值时直接返回mid;但如果是寻找左边界,当等于目标值时可能需要继续向左搜索,因此调整右边界为mid,而左边界的情况则相反。 此外,循环终止后的处理也很重要。例如,在闭区间的情况下,循环结束时left会大于right,这时候需要判断是否找到了目标值。如果是寻找插入位置的问题,可能需要返回left或right+1,具体取决于实际情况。 还需要考虑避免死循环的问题,特别是在更新left或right时是否应该mid+1或mid-1。比如,在左闭右闭区间中,如果arr[mid] < target,那么left应该更新为mid + 1,因为mid已经被检查过不满足条件;而如果arr[mid] > target,right更新为mid - 1。如果是左闭右开区间,当更新right时,可能需要保持右开,所以right = mid,而left更新为mid + 1。 最后,总结不同情况下的处理步骤,给出示例代码,并验证边界条件是否正确。例如,当数组长度为1时,是否能够正确处理;当目标值不在数组中时,是否能正确返回-1或插入位置。 可能遇到的问题包括:如何确保循环终止,如何处理重复元素,如何确定最终的返回值是否符合预期。需要结合具体应用场景来调整边界条件,比如寻找第一个出现的元素还是最后一个出现的元素,这时候边界的调整方式会有所不同。 总之,处理二分法的边界条件需要明确区间的定义,正确计算中点,合理调整边界,并在循环结束后进行必要的检查。通过分步骤的说明和示例代码,可以帮助用户更好地理解和应用不同的处理方法。</think>### 二分法边界条件处理方法 二分法的边界条件处理是确保算法正确性的关键,主要涉及**区间定义**、**循环终止条件**和**中点更新规则**三个核心要素。以下是具体处理步骤: --- #### 1. **明确区间定义** - **左闭右闭区间**:`[left, right]` 初始值:`left = 0`, `right = len(arr) - 1` 终止条件:`left > right`(即区间无元素)[^1] 适用场景:精确查找目标值或边界。 - **左闭右开区间**:`[left, right)` 初始值:`left = 0`, `right = len(arr)` 终止条件:`left == right`(右开意味着`right`本身不包含在区间内)[^3] 适用场景:插入位置或范围查询。 --- #### 2. **确定循环终止条件** - **左闭右闭区间**: ```python while left <= right: mid = left + (right - left) // 2 ``` 终止时`left > right`,表示区间已空。 - **左闭右开区间**: ```python while left < right: mid = left + (right - left) // 2 ``` 终止时`left == right`,区间`[left, right)`无元素[^3]。 --- #### 3. **中点计算与边界更新** - **中点计算**: 使用`mid = left + (right - left) // 2`避免整数溢出,且保证向下取整[^2]。 - **边界更新规则**: - **精确查找**(目标值唯一): ```python if arr[mid] == target: return mid elif arr[mid] < target: left = mid + 1 # 左闭右闭区间中,mid已检查,需排除 else: right = mid - 1 ``` - **左边界查找**(第一个等于目标的元素): ```python if arr[mid] >= target: right = mid # 保持左闭右闭或左闭右开的一致性 else: left = mid + 1 ``` 终止后需验证`left`是否越界或`arr[left] == target`[^3]。 - **右边界查找**(最后一个等于目标的元素): ```python if arr[mid] <= target: left = mid + 1 else: right = mid ``` 终止后需验证`right-1`是否为有效索引。 --- #### 4. **循环结束后的处理** - **精确查找**:若未找到,返回`-1`。 - **左边界查找**:检查`left`是否在数组范围内且`arr[left] == target`。 - **插入位置**:直接返回`left`(左闭右开区间)或`right + 1`(左闭右闭区间)。 --- #### 示例代码(左闭右闭区间) ```python def binary_search(arr, target): left, right = 0, len(arr) - 1 while left <= right: mid = left + (right - left) // 2 if arr[mid] == target: return mid elif arr[mid] < target: left = mid + 1 else: right = mid - 1 return -1 ``` --- #### 验证边界条件的典型场景 1. **空数组**:直接返回`-1`。 2. **单元素数组**:检查是否匹配目标。 3. **目标值为最小值或最大值**:确保不越界。 4. **重复元素**:左/右边界查找是否返回正确位置。 ---
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

打赏作者

_a_yang

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

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

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

打赏作者

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

抵扣说明:

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

余额充值