二分法边界问题(通俗讲解)
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=mid−1 如果这样操作不是把等于正确答案的也排除在外了吗?
非也非也。当 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=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 + 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)) 1v(5+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 ] [l,r] 中。
- 当 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=mid−1
- 当 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+1,r] 这个区间内,所以置 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 ] [l,r]中。
- 当 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] [mid,r] 这个区间内(可能是 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 l、r 在某轮循环中分别是 2 、 3 2、3 2、3,则 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 总结
好了,以上就是笔者想要分享的关于二分法的全部内容了,若有错误,请大佬们不吝评论区赐教,小生当俯身倾耳以请😁。希望笔者的分享能够帮助到您!
参考
- https://zhuanlan.zhihu.com/p/275995132?utm_id=0
- https://zh.wikipedia.org/wiki/%E4%BA%8C%E5%88%86%E6%B3%95
- 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