基本思想
1)首先把循环可以进行的条件写成 while(left < right),在退出循环的时候,一定有 left == right 成立,此时返回 left 或者 right 都可以
(2)“神奇的”二分查找法模板的基本思想(特别重要)
“排除法”即:在每一轮循环中排除一半以上的元素,于是在对数级别的时间复杂度内,就可以把区间“夹逼” 只剩下 1 个数,而这个数是不是我们要找的数,单独做一次判断就可以了。
“夹逼法”或者“排除法”是二分查找算法的基本思想,“二分”是手段,在目标元素不确定的情况下,“二分” 也是“最大熵原理”告诉我们的选择。
LeetCode 第 35 题,下面给出使用 while (left < right) 模板写法的 2 段参考代码,以下代码的细节部分在后文中会讲到,因此一些地方不太明白没有关系,暂时跳过即可。
参考代码 1:重点理解为什么候选区间的索引范围是 [0, size]。
from typing import List
class Solution:
def searchInsert(self, nums: List[int], target: int) -> int:
# 返回大于等于 target 的索引,有可能是最后一个
size = len(nums)
# 特判
if size == 0:
return 0
left = 0
# 如果 target 比 nums里所有的数都大,则最后一个数的索引 + 1 就是候选值,因此,右边界应该是数组的长度
right = size
# 二分的逻辑一定要写对,否则会出现死循环或者数组下标越界
while left < right:
mid = left + (right - left) // 2
if nums[mid] < target:
left = mid + 1
else:
assert nums[mid] >= target
# [1,5,7] 2
right = mid
# 调试语句
# print('left = {}, right = {}, mid = {}'.format(left, right, mid))
return left
参考代码 2:对于是否接在原有序数组后面单独判断,不满足的时候,再在候选区间的索引范围 [0, size - 1] 内使用二分查找法进行搜索。
from typing import List
class Solution:
def searchInsert(self, nums: List[int], target: int) -> int:
# 返回大于等于 target 的索引,有可能是最后一个
size = len(nums)
# 特判 1
if size == 0:
return 0
# 特判 2:如果比最后一个数字还要大,直接接在它后面就可以了
if target > nums[-1]:
return size
left = 0
right = size - 1
# 二分的逻辑一定要写对,否则会出现死循环或者数组下标越界
while left < right:
mid = left + (right - left) // 2
if nums[mid] < target:
left = mid + 1
else:
assert nums[mid] >= target
right = mid
return left
3)细节、注意事项、调试方法
1、前提:思考左、右边界,如果左、右边界不包括目标数值,会导致错误结果
例:LeetCode 第 69 题:x 的平方根
实现 int sqrt(int x) 函数。
计算并返回 x 的平方根,其中 x 是非负整数。
由于返回类型是整数,结果只保留整数的部分,小数部分将被舍去。
分析:一个非负整数的平方根最小可能是 0 ,最大可能是它自己。 因此左边界可以取 0 ,右边界可以取 x。 可以分析得再细一点,但这道题没有必要,因为二分查找法会帮你排除掉不符合的区间元素。
例:LeetCode 第 287 题:寻找重复数
给定一个包含 n + 1 个整数的数组 nums,其数字都在 1 到 n 之间(包括 1 和 n),可知至少存在一个重复的整数。假设只有一个重复的整数,找出这个重复的数。
分析:题目告诉我们“其数字都在 1 到 n 之间(包括 1 和 n)”。因此左边界可以取 1 ,右边界可以取 n。
2、要注意 2 点:
如果 left 和 right 表示的是数组的索引,就要考虑“索引是否有效” ,即“索引是否越界” 是重要的定界依据;
左右边界一定要包括目标元素,例如 LeetCode 第 35 题:“搜索插入位置” ,当 target 比数组中的最后一个数字还要大(不能等于)的时候,插入元素的位置就是数组的最后一个位置 + 1,即 (len - 1 + 1 =) len,如果忽略掉这一点,把右边界定为 len - 1 ,代码就不能通过在线测评
中位数先写 int mid = (left + right) >>> 1 ; 根据循环里分支的编写情况,再做调整
理解这一点,首先要知道:当数组的元素个数是偶数的时候,中位数有左中位数和右中位数之分。
当数组的元素个数是偶数的时候:
使用 int mid = left + (right - left) / 2 ; 得到左中位数的索引;
使用 int mid = left + (right - left + 1) / 2 ; 得到右中位数的索引。
当数组的元素个数是奇数的时候,以上二者都能选到最中间的那个中位数。
其次,
int mid = left + (right - left) / 2 ; 等价于 int mid = (left + right) >>> 1;
int mid = left + (right - left + 1) / 2 ; 等价于 int mid = (left + right + 1) >>> 1 。
我们使用一个具体的例子来验证:当左边界索引 left = 3,右边界索引 right = 4 的时候,
mid1 = left + (right - left) // 2 = 3 + (4 - 3) // 2 = 3 + 0 = 3,
mid2 = left + (right - left + 1) // 2 = 3 + (4 - 3 + 1) // 2 = 3 + 1 = 4。
左中位数 mid1 是索引 left,右中位数 mid2 是索引 right。
记忆方法:
(right - left) 不加 1 选左中位数,加 1 选右中位数。
那么,什么时候使用左中位数,什么时候使用右中位数呢?选中位数的依据是为了避免死循环,得根据分支的逻辑来选择中位数,而分支逻辑的编写也有技巧,下面具体说。
3、先写逻辑上容易想到的分支逻辑,这个分支逻辑通常是排除中位数的逻辑;
在逻辑上,“可能是也有可能不是”让我们感到犹豫不定,但**“一定不是”是我们非常坚决的,通常考虑的因素特别单一,因此“好想” **。在生活中,我们经常听到这样的话:找对象时,“有车、有房,可以考虑,但没有一定不要”;找工作时,“事儿少、离家近可以考虑,但是钱少一定不去”,就是这种思想的体现。
例:LeetCode 第 69 题:x 的平方根
实现 int sqrt(int x) 函数。
计算并返回 x 的平方根,其中 x 是非负整数。
由于返回类型是整数,结果只保留整数的部分,小数部分将被舍去。
分析:因为题目中说“返回类型是整数,结果只保留整数的部分,小数部分将被舍去”。例如 5 的平方根约等于 2.236,在这道题应该返回2。因此如果一个数的平方小于或者等于 x,那么这个数有可能是也有可能不是 x 的平方根,但是能很肯定的是,如果一个数的平方大于 x ,这个数肯定不是 x 的平方根。
注意:先写“好想”的分支,排除了中位数之后,通常另一个分支就不排除中位数,而不必具体考虑另一个分支的逻辑的具体意义,且代码几乎是固定的。
4、循环内只写两个分支,一个分支排除中位数,另一个分支不排除中位数,循环中不单独对中位数作判断
既然是“夹逼”法,没有必要在每一轮循环开始前单独判断当前中位数是否是目标元素,因此分支数少了一支,代码执行效率更高。
以下是“排除中位数的逻辑”思考清楚以后,可能出现的两个模板代码
可以排除“中位数”的逻辑,通常比较好想,但并不绝对,这一点视情况而定。
分支条数变成 2 条,比原来 3 个分支要考虑的情况少,好处是:
不用在每次循环开始单独考虑中位数是否是目标元素,节约了时间,我们只要在退出循环的时候,即左右区间压缩成一个数(索引)的时候,去判断这个索引表示的数是否是目标元素,而不必在二分的逻辑中单独做判断。
这一点很重要,每次循环开始的时候都单独做一次判断,在统计意义上看,二分时候的中位数恰好是目标元素的概率并不高,并且即使要这么做,也不是普适性的,不能解决绝大部分的问题。
还以 LeetCode 第 35 题为例,通过之前的分析,我们需要找到“大于或者等于目标值的第 1 个数的索引”。对于这道题而言:
(1)如果中位数小于目标值,它就应该被排除,左边界 left 就至少是 mid + 1;
(2)如果中位数大于等于目标值,还不能够肯定它就是我们要找的数,因为要找的是等于目标值的第 111 个数的索引,中位数以及中位数的左边都有可能是符合题意的数,因此右边界就不能把 mid 排除,因此右边界 right 至多是 mid,此时右边界不向左边收缩。
下一点就更关键了。
5、根据分支逻辑选择中位数的类型,可能是左中位数,也可能是右位数,选择的标准是避免死循环
1、如果分支的逻辑,在选择左边界的时候,不能排除中位数,那么中位数就选“右中位数”,只有这样区间才会收缩,否则进入死循环;
2、同理,如果分支的逻辑,在选择右边界的时候,不能排除中位数,那么中位数就选“左中位数”,只有这样区间才会收缩,否则进入死循环。
理解上面的这个规则可以通过具体的例子。针对以上规则的第 1 点:如果分支的逻辑,在选择左边界的时候不能排除中位数,例如:
Python 伪代码:
while left < right:
# 不妨先写左中位数,看看你的分支会不会让你代码出现死循环,从而调整
mid = left + (right - left) // 2
# 业务逻辑代码
if (check(mid)):
# 选择右边界的时候,可以排除中位数
right = mid - 1
else:
# 选择左边界的时候,不能排除中位数
left = mid
6、退出循环的时候,可能需要对“夹逼”剩下的那个数单独做一次判断,这一步称之为“后处理”。
二分查找法之所以高效,是因为它利用了数组有序的特点,在每一次的搜索过程中,都可以排除将近一半的数,使得搜索区间越来越小,直到区间成为一个数。回到这一节最开始的疑问:“区间左右边界相等(即收缩成 1 个数)时,这个数是否会漏掉”,解释如下:
1、如果你的业务逻辑保证了你要找的数一定在左边界和右边界所表示的区间里出现,那么可以放心地返回 left 或者 right,无需再做判断;
2、如果你的业务逻辑不能保证你要找的数一定在左边界和右边界所表示的区间里出现,那么只要在退出循环以后,再针对 nums[left] 或者 nums[right] (此时 nums[left] == nums[right])单独作一次判断,看它是不是你要找的数即可,这一步操作常常叫做“后处理”。
如果你能确定候选区间里目标元素一定存在,则不必做“后处理”。
例:LeetCode 第 69 题:x 的平方根
实现 int sqrt(int x) 函数。
计算并返回 x 的平方根,其中 x 是非负整数。
由于返回类型是整数,结果只保留整数的部分,小数部分将被舍去。
分析:非负实数 x 的平方根在 [0, x] 内一定存在,故退出 while (left < right) 循环以后,不必单独判断 left 或者 right 是否符合题意。
如果你不能确定候选区间里目标元素一定存在,需要单独做一次判断。
例:LeetCode 第 704 题:二分查找
给定一个 n 个元素有序的(升序)整型数组 nums 和一个目标值 target ,写一个函数搜索 nums 中的 target,如果目标值存在返回下标,否则返回 -1。
分析:因为目标数有可能不在数组中,当候选区间夹逼成一个数的时候,要单独判断一下这个数是不是目标数,如果不是,返回 -1。
7、取中位数的时候,要避免在计算上出现整型溢出;
8、编码一旦出现死循环,输出必要的变量值、分支逻辑是调试的重要方法。