![90e407f9cc202d04f0e04655aa969cbc.png](https://i-blog.csdnimg.cn/blog_migrate/aa40a3a4b677bd4934636843a4f9206a.jpeg)
程序员编程艺术第二十五章:Jon Bentley:90%以上的程序员无法正确无误的写出二分查找代码
面试或者考研上机考试中二分是基础以及常考的题目,考察的面试者对二分的理解以及对边界的理解能力。
本文主要讲解的是二分的本质是什么?如何理解二分?提供一个模版如何高效的写出二分的代码?
二分的本质是什么
可能很多人想到二分就会想到binary search,但是数学上有另外一个名词叫dichotomy(二分)。在维基百科上是怎么定义二分法的?
二分法(dichotomy)指的是将一个整体事物分割成两部分。也即是说,这两部分必须是互补事件,即所有事物必须属于双方中的一方,且互斥,即没有事物可以同时属于双方。
许多人认为,一个问题是否能够二分,本质是因为问题具有单调性(我们为什么会有这种想法是因为,上课时候老师讲二分的时候通常都会讲解一个问题从一个有序数组中找到某个特定的target的值
,让我们认为单调性是二分的本质)。其实单调性不是二分的本质,也就是说问题不具有单调性的也是可以进行二分的(之后会举出例子)。 二分的本质枚举的答案空间是否可以分解成性质不同的两个部分。 这两个部分必须满足两个性质,如维基百科所说:「互补」「互斥」。即所有事物必须属于双方中的一方,且没有事物可以同时属于双方。
上面一段不懂没有关系,现在用一道例题leetcode34题来讲解一下二分。
LeetCode 34leetcode-cn.com题意是在logn的时间内在非递减数组中返回一个给定target值的起始位置和终点位置。
对于这个问题,我们暴力的做法可以是使用O(n)的时间复杂度来线性扫描这个数组,找到第一个target和最后一个target。
输入: nums = [1,5,7,7,8,8,8,9,10], target = 8
输出: [4,6]
而如何用二分法来解决这个问题呢?
首先我们看如何找到target的第一个点:
我们需要找到一个性质,这一对性质可以是我们把答案放在二分的边界上面。我们可以使左边红色部分元素的性质为<target, 而右边蓝色部分就是>=target。 右边蓝色部分的第一个位置就是答案要求target的第一个位置,即蓝色箭头的位置。
![596de750e27cd6d7a11d6fe7de842eca.png](https://i-blog.csdnimg.cn/blog_migrate/aaa5ff8ab6134cbe8dd1bc64dc0f96f4.png)
然后我们看看如何找到target的最后一个点:
我们可以找到另外一组性质,左边红色部分的性质为<=target, 而右边蓝色性质是>target。左边红色部分的最后一个位置就是答案要求target的最后一个位置,即橙色箭头位置。
![0a36128300f090f1918d4ba9d20199df.png](https://i-blog.csdnimg.cn/blog_migrate/07ff881e432c439243c2ab379605d7ac.png)
所以,二分的答案永远在红蓝(左右)两个部分的中间边界上面,要不就是找到左边红色部分的最后一个点,要不就是找到右边蓝色部分的第一个点。
所以就引出了下面两种最常用的二分法的模版,可以应对所有的二分题目。(妈妈再也不用担心我会写死循环和数组越界了)
下面就是对应的两种方式的二分模版(python)。
左边(红)的最后一个点
def red(context, mid):
# mid这个答案满足红色部分性质返回true,否则false
def ceil(context, l: int, r: int) -> int:
while l < r:
mid = (l + r + 1) // 2
if red(context, mid):
l = mid
else:
r = mid - 1
# check r is valid
return r
右边(蓝)的第一个点
def blue(context, mid):
# mid这个答案满足蓝色部分性质返回true,否则false
def floor(context, l: int, r: int) -> int:
while l < r:
mid = (l + r) // 2
if blue(context, mid):
r = mid
else:
l = mid + 1
# check r is valid
return r
之所以一个叫ceil,一个叫floor。ceil代表天花板,就是左边red的最后一个元素。 floor代表地板,就是右边blue的第一个元素。
根据模版,LeetCode34题代码完整代码如下:
class Solution:
def floor(self, nums: List[int], target: int) -> int:
l, r = 0, len(nums) - 1
while l < r:
mid = (l + r) // 2
if nums[mid] >= target: # 符合blue性质返回true
r = mid # 说明答案在mid的左边
else:
l = mid + 1
if nums[r] != target: # 有可能target不存在
return -1
return r
def ceil(self, nums: List[int], target: int) -> int:
l, r = 0, len(nums) - 1
while l < r:
mid = (l + r + 1) // 2
if nums[mid] <= target: # 符合red性质返回true
l = mid # 说明答案在mid的右边
else:
r = mid - 1
if nums[l] != target:
return -1
return l
def searchRange(self, nums: List[int], target: int) -> List[int]:
# 如果不存在返回-1
if len(nums) == 0:
return [-1, -1]
return [self.floor(nums, target), self.ceil(nums, target)]
这两个模版的有几个注意的点:
- mid 是向上取整还是向下取整
l = m
orr = m
- 最终的返回值一定收敛在初始[l, r]的范围内,最后check一下是否为答案。返回l, r都是可以的。
以floor例,做了一个gif,看看如何收敛到第一个元素8所在位置的。(大家可以,自己模拟一下ceil)
![0474daef402c610463b03bcb52d0527c.gif](https://i-blog.csdnimg.cn/blog_migrate/c4452bf4aca0ee01c1d7ed3abb621da3.gif)
有人会问,不具有单调性的是否可以二分,答案是可以的。我们看看另外一道这次秋招一道问了好多遍的题目。
LeetCode153leetcode-cn.com在这题上,数组可能会发生旋转。我们按照二分的套路,可以在logn的时间内把问题解决。 首先当数组发生旋转会发生什么。我们需要找到一个性质能够使得数组能够分成左右红蓝两个部分,从而使得答案在数组的边界上面(红区的最后一个,或者蓝区的第一个)。 幸运的是,我们可以找到这样一个性质划分数组使得答案在边界上面。你会发现最小元素及其右边所有元素都是小于等于最后一个元素的,而最小元素的左边是大于最后一个元素的。所以左边红色部分的性质为>最后一个元素
, 右边蓝色元素性质为<=最后一个元素
![44456b8072507772c8fabcda0a2853f8.png](https://i-blog.csdnimg.cn/blog_migrate/631a1ac82061ef0cc7d7e2e533e02611.png)
我们就可以把数组划分成两个部分,使用floor二分(蓝色部分的第一个点为答案),即找小于等于最后一个元素第一个点。 代码如下:
class Solution:
def findMin(self, nums: List[int]) -> int:
l, r = 0, len(nums)-1
while (l < r):
m = (l + r) // 2
if nums[m] <= nums[-1]: # 满足蓝色部分
r = m
else:
l = m + 1
return nums[r]
性质也有好坏之分,因为好的性质可以一步到位,没有这么好的性质可能需要做一些特判,但是也是可以做的。如,可以使用另外一个性质就是红色部分都是大于等于第一个元素的,蓝色部分都是小于第一个元素的。这种写法大家也可以尝试一下。
总结
二分的问题一般是为了更高效地枚举答案,需要找到一个性质,来去进行二分搜索:
- 看到问题,先问题的答案的左右两边能不能找到一个互补且互斥的性质
- 通过floor二分或者ceil二分把答案枚举出来
- 判断标准:
floor
orceil
r = m
orl = m
右边的第一个点
or左边的最后一个点
- 判断标准:
- 得到结果
- check一下答案是否是正确的。(因为可能没有答案)
二分的模版其实有很多变种,这个模版对我个人而言理解的比较透彻,也比较方便讲解二分的原理。
个人其实不反对算法模版的,我反对的是在不理解算法背后原理,纯背答案的做法。我觉得是要在充分理解算法的本质,以及推理出属于自己的模版的做题思路,在面试的时候能够很好的做到bug free而且减少思维(cpu)占用量。(想想数学定理,谁会在考场上再推一边在做呀。 假如高考现场推,就慢了呀,而且还容易错)
一起加油!~