本文假设读者已理解并会写二分算法,因此聚焦于解决边界问题。
希望本文能够帮助你学会设计二分算法的方法,写出自信能够正常运行的二分代码。
前言
二分作为最 useful 的经典算法,可以说是所有计算机学生必会的算法之一。然而,边界问题却总是令人破防:
- 答案是
left还是right? mid到底应该等于(left + right)/2还是(left + right + 1)/2?- 收缩区间时
left和right到底等于mid,还是mid - 1,mid + 1? while的条件到底是left < right还是left <= right?
诸如此类问题导致了我们写的二分往往不能符合预期,得不到预期的答案,甚至老是死循环。一种解决方式是尝试,把所有写法全试一遍。另一种解决方式是背模版,将各种可能的写法全背下来。但我们都没有真正解决问题,即真正会写二分。其实,二分的边界问题并不复杂,也并不需要一堆证明和讨论,只要我们掌握了正确的思考方式,即从搜索区间入手。
解析
基本定义
让我们先抛弃对二分已有的混乱认识。最基本的,二分是要在一堆 尚待确定 的元素里找到我们需要的那个,并且它们满足某种性质,让我们每次都可以排除掉一半。由于元素往往是以顺序表的形式存储,所以我们可以将搜索空间定义为 搜索区间,将搜索区间中的元素定义为 候选元素。
接下来,让我们看一段常见的二分代码:
while(left <= right){
int mid = (left + right) / 2;
if (check(mid)) left = mid + 1;
else right = mid - 1;
}
一共 5 行代码,可以拆解为三步:
- 当搜索区间不为空,循环执行下面步骤
- 找出一个基准元素
mid,将区间划分为左右两部分 - 根据
mid是否满足条件,缩小搜索区间
下面,我们就从这三步入手,看看代码到底该怎么写。
定义搜索区间
最常见的定义方式有两种:闭区间 [left, right] 和 左闭右开区间 [left, right)。在闭区间下,left 是第一个候选元素,right 是最后一个候选元素;在左闭右开区间下,left 同样是第一个候选元素,但 right 是 最后一个候选元素的下一个元素。
由此定义,易得循环条件:
- 闭区间:当
left <= right,搜索区间就不为空。循环结束时,left = right + 1,right是最后一个候选元素,left是 最后一个候选元素的下一个元素。 - 左闭右开区间:当
left < right,搜索区间就不为空。循环结束时,left = right,left和right都是 最后一个候选元素的下一个元素。
记得定义完区间后,给 left 和 right 赋初值就需要满足定义。
缩小搜索区间
由于候选元素存在二分性质,答案只会存在于左半边或右半边,我们要通过调整 left 或 right 来缩小搜索区间。这里有两个原则:
- 保证搜索区间缩小(否则会死循环)
- 保证缩小后的区间仍满足定义(否则无法保证随后的循环中使用相同的缩小策略是正确的)
让我们分两种情况来看:
答案在左半边(
mid不满足条件):此时应该舍弃右半边,即移动right。如果区间定义为闭区间,那么mid显然不应该包含在缩小后的区间中,right = mid - 1。如果区间定义为左闭右开区间,那么right应该等于 最后一个候选元素的下一个元素,即mid。答案在右半边(
mid满足条件):此时应该舍弃左半边,即移动left。这种情况下,无论使用哪种定义,都有left = mid + 1。这里,你可能会问:
mid是满足条件的,为什么要把它丢掉?查看上面关于候选元素的定义:搜索区间中 尚待确定 的元素。mid已经被确定,所以不能包含在缩小后的搜索区间中。另一个问题是:如果
mid是最终的答案,它会不会被丢掉?由于左闭右开区间下,最后的结果是left和right都是 最后一个候选元素的下一个元素,那么mid作为答案被丢弃也是正常的事情。我们可以保证left和right最终会收敛到mid + 1,读者可以结合下面的代码思考。
由此,我们已经能够写出左闭右开定义版本的二分:
while(left < right){
int mid = (left + right) / 2;
if (check(mid)) left = mid + 1;
else right = mid;
}
在我们的定义下,mid 无论如何都会被舍弃。事实上,并不是所有二分都必须把 mid 丢弃,我们这里采用的定义能够让我们使用统一的定义和写法来避免错误。如果我们在移动 left 的时候保留 mid,为了保证区间一定缩小,在找 mid 的时候就需要“偏置”。如果写成 left = mid,那么 mid 必须偏右,常见实现是向上取整 mid = (left + right + 1)/2。
返回结果
最后,我们要的答案是谁?基于上面的讨论,不难得出:
- 如果我们需要最后一个满足条件的元素:
- 闭区间返回
right或者left - 1 - 左闭右开区间返回
left - 1或者right - 1
- 闭区间返回
- 如果我们需要第一个满足条件的元素,那么我们应该修改条件,例如我们要找第一个大于等于
target的元素,就等价于找到最后一个小于target的元素的下一个元素,将问题转化为上一种情况
此外,还存在一种在二分过程中记录答案的写法:
int ans = 0;
while (left <= right)
{
int mid = (left + right) / 2;
if (check(mid))
{
ans = mid;
right = mid - 1;
}
else left = mid + 1;
}
区间的收缩方式与我们上面讨论的并无差别,不同在于增加了一个 ans 变量记录中间结果。这种写法的思路是:已知 mid 是一个满足条件的答案,我们将其保留,如果遇到更好的答案就进行替换。
我们上面的写法是基于分界的思想,通过调整二分条件让区间收敛到所需要的答案。而这里的写法不依赖收敛点,而是不断更新最优答案。看起来这种写法具有更强的泛用性,也因此被很多人喜爱,但它无法替代我们上面对于搜索区间的思考。
总结
其实,只要搜索区间的定义决定了,那么二分的写法就是唯一确定的,因为我们要在整个过程中保持搜索区间的定义不变,这样它才能按照固定的方式缩小。
推荐现在就去完成这道力扣题目:34. 在排序数组中查找元素的第一个和最后一个位置,按照上文所述的思考方式,相信你可以一发 AC。