Although the basic idea of binary search is comparatively straightforward, the details can be surprisingly tricky …
二分查找法的思想在 1946 年就被提出来了。但是第 1 个没有 Bug 的二分查找法在 1962 年才出现。
When Jon Bentley assigned binary search as a problem in a course for professional programmers, he found that ninety percent failed to provide a correct solution after several hours of working on it, mainly because the incorrect implementations failed to run or returned a wrong answer in rare edge cases.
left
和
right
都比较大的时候,
left + right
很有可能超过 int 类型能表示的最大值,即整型溢出,为了避免这个问题,应该写成:
int mid = left + (right - left) / 2
在
right
很大、
left
是负数且很小的时候,
right - left
也有可能超过
int
类型能表示的最大值,只不过一般情况下
left
和
right
表示的是数组索引值,
left
是非负数,因此
right - left
溢出的可能性很小。
使用“左边界索引 + 右边界索引”,然后“无符号右移 1 位”是推荐的写法。
1、如果目标值(严格)大于排序数组的最后一个数,返回这个排序数组的长度,否则进入第 2 点。2、返回排序数组从左到右,大于或者等于目标值的第 1 个数的索引。
while (left <= right)
时,在写最后一句
return
的时候,如果不假思索,把左边界
left
返回回去,虽然写对了,但可以思考一下为什么不返回右边界
right
呢?
left
是有一定道理的,如果题目换一种问法,你可能就要返回右边界
right
,这句话不太理解没有关系,我也不打算讲得很清楚(在上面代码的注释中我已经解释了原因),因为实在太绕了,这不是我要说的重点。
传统二分查找法模板,当退出while
循环的时候,在返回左边界还是右边界这个问题上,比较容易出错。
while(left < right)
,在退出循环的时候,一定有 left == right
成立,此时返回 left
或者 right
都可以“排除法”即:在每一轮循环中排除一半以上的元素,于是在对数级别的时间复杂度内,就可以把区间“夹逼” 只剩下 1 个数,而这个数是不是我们要找的数,单独做一次判断就可以了。
while (left < right)
模板写法的 2 段参考代码,以下代码的细节部分在后文中会讲到,因此一些地方不太明白没有关系,暂时跳过即可。
[0, size]
。
[0, size - 1]
内使用二分查找法进行搜索。
实现int sqrt(int x)
函数。计算并返回 x 的平方根,其中 x 是非负整数。由于返回类型是整数,结果只保留整数的部分,小数部分将被舍去。
给定一个包含 n + 1 个整数的数组 nums,其数字都在 1 到 n 之间(包括 1 和 n),可知至少存在一个重复的整数。假设只有一个重复的整数,找出这个重复的数。
left
和
right
表示的是数组的索引,就要考虑“索引是否有效” ,即“索引是否越界” 是重要的定界依据;
target
比数组中的最后一个数字还要大(不能等于)的时候,插入元素的位置就是数组的最后一个位置 + 1,即
(len - 1 + 1 =) len
,如果忽略掉这一点,把右边界定为
len - 1
,代码就不能通过在线测评。
int mid = left + (right - left) / 2 ;
得到左中位数的索引;
int mid = left + (right - left + 1) / 2 ;
得到右中位数的索引。
left = 3
,右边界索引
right = 4
的时候,
mid1
是索引
left
,右中位数
mid2
是索引
right
。
(right - left)
不加 1 选左中位数,加 1 选右中位数。
实现int sqrt(int x)
函数。计算并返回 x 的平方根,其中 x 是非负整数。由于返回类型是整数,结果只保留整数的部分,小数部分将被舍去。
不用在每次循环开始单独考虑中位数是否是目标元素,节约了时间,我们只要在退出循环的时候,即左右区间压缩成一个数(索引)的时候,去判断这个索引表示的数是否是目标元素,而不必在二分的逻辑中单独做判断。
left
就至少是
mid + 1
;
mid
排除,因此右边界
right
至多是
mid
,此时右边界不向左边收缩。
1、如果分支的逻辑,在选择左边界的时候,不能排除中位数,那么中位数就选“右中位数”,只有这样区间才会收缩,否则进入死循环;2、同理,如果分支的逻辑,在选择右边界的时候,不能排除中位数,那么中位数就选“左中位数”,只有这样区间才会收缩,否则进入死循环。
left = 3
,
right = 4
。此时左中位数就是左边界,如果你的逻辑执行到
left = mid
这个分支,且你选择的中位数是左中位数,此时左边界就不会得到更新,区间就不会再收缩(理解这句话是关键),从而进入死循环;
left = mid
这个分支的时候,因为你选择了右中位数,让逻辑可以转而执行到
right = mid - 1
让区间收缩,最终成为 1 个数,退出
while
循环。
left
或者
right
,无需再做判断;
nums[left]
或者
nums[right]
(此时
nums[left] == nums[right]
)单独作一次判断,看它是不是你要找的数即可,这一步操作常常叫做“后处理”。
实现int sqrt(int x)
函数。计算并返回 x 的平方根,其中 x 是非负整数。由于返回类型是整数,结果只保留整数的部分,小数部分将被舍去。
[0, x]
内一定存在,故退出
while (left < right)
循环以后,不必单独判断
left
或者
right
是否符合题意。
给定一个 n 个元素有序的(升序)整型数组 nums 和一个目标值 target ,写一个函数搜索 nums 中的 target,如果目标值存在返回下标,否则返回 -1。
int mid = (left + right) / 2;
的问题:在
left
和
right
很大的时候,
left + right
会发生整型溢出,变成负数,这是一个 bug ,得改!
int mid = left + (right - left) / 2;
在
right
很大、
left
是负数且很小的时候,
right - left
也有可能超过 int 类型能表示的最大值,只不过一般情况下
left
和
right
表示的是数组索引值,
left
是非负数,因此
right - left
溢出的可能性很小。因此,它是正确的写法。下面介绍推荐的写法。
int mid = (left + right) >>> 1;
如果这样写
, left + right
在发生整型溢出以后,会变成负数,此时如果除以
2 ,mid
是一个负数,但是经过无符号右移,可以得到在不溢出的情况下正确的结果。
>>>
和右移运算符
>>
的区别如下:
>>
在右移时,丢弃右边指定位数,左边补上符号位;
>>>
在右移时,丢弃右边指定位数,左边补上 0,也就是说,对于正数来说,二者一样,而负数通过
>>>
后能变成正数。
int mid = (left + right) / 2
与
int mid = left + (right - left) / 2
两种写法都有整型溢出的风险,没有哪一个是绝对安全的,注意:这里我们取平均值用的是除以 2,并且是整除:
int mid = (left + right) / 2
在
left
和
right
都很大的时候会溢出;
int mid = left + (right - left) / 2
在
right
很大,且
left
是负数且很小的时候会溢出;
left
和
right
一般都表示数组的索引,因此
left
在绝大多数情况下不会是负数并且很小,因此使用
int mid = left + (right - left) // 2
相对
int mid = (left + right) // 2
更安全一些,并且也能向别人展示我们注意到了整型溢出这种情况,但事实上,还有更好的方式;
int mid = (left + right) >>> 1
这种写法,其实是大有含义的:
JDK8 中采用int mid = (left + right) >>> 1
,重点不在+
,而在>>>
。
left
和
high
都是整型最大值的时候,注意,此时 32 位整型最大值它的二进制表示的最高位是 0,它们相加以后,最高位是 1 ,变成负数,但是再经过无符号右移
>>>
(
重点是忽略了符号位
,空位都以 0 补齐),就能保证使用
+
在整型溢出了以后结果还是正确的。
Collections
和
Arrays
提供的
binarySearch
方法,我们点进去看
left
和
right
都表示索引,使用无符号右移又不怕整型溢出,那就用
int mid = (left + right) >>> 1
好啦。位运算本来就比使用除法快,这样看来使用
+
和
<<<
真的是又快又好了。
int mid = (left + right) >>> 1
吧,反正更多的时候
left
和
right
表示索引。
无脑地写while left < right:
,这样你就不用判断,在退出循环的时候你应该返回left
还是right
,因为返回left
或者right
都对;
先写分支逻辑,并且先写排除中位数的逻辑分支(因为更多时候排除中位数的逻辑容易想,但是前面我也提到过,这并不绝对),另一个分支的逻辑你就不用想了,写出第 1 个分支的反面代码即可(下面的说明中有介绍),再根据分支的情况选择使用左中位数还是右中位数;
left = mid + 1
),那么第 2 个分支的逻辑就一定是“右边界不排除中位数”(
right = mid
),反过来也成立;
right = mid - 1
),那么第 2 个分支的逻辑就一定是“左边界不排除中位数”(
left = mid
),反之也成立。
分支条数只有 2 条,代码执行效率更高,不用在每一轮循环中单独判断中位数是否符合题目要求,写分支的逻辑的目的是尽量排除更多的候选元素,而判断中位数是否符合题目要求我们放在最后进行,这就是第 5 点;
左中位数还是右中位数选择的标准根据分支的逻辑而来,标准是每一次循环都应该让区间收缩,当候选区间只剩下 2 个元素的时候,为了避免死循环发生,选择正确的中位数类型。如果你实在很晕,不防就使用有 2 个元素的测试用例,就能明白其中的原因,另外在代码出现死循环的时候,建议你可以将左边界、右边界、你选择的中位数的值,还有分支逻辑都打印输出一下,出现死循环的原因就一目了然了;
如果能确定要找的数就在候选区间里,那么退出循环的时候,区间最后收缩成为 1 个数后,直接把这个数返回即可;如果你要找的数有可能不在候选区间里,区间最后收缩成为 1 个数后,还要单独判断一下这个数是否符合题意。
(right - left)
这个括号里面加 1 。
虽说是两个模板,区别在于选中位数,中位数根据分支逻辑来选,原则是区间要收缩,且不出现死循环,退出循环的时候,视情况,有可能需要对最后剩下的数单独做判断。
◆
精彩推荐
◆