二分法解析
参考:二分查找算法详解
二分法是一个看似简单实则细节中暗藏魔鬼的方法。避免二分法出错的关键是,把握住搜索区间
一、基本的二分法
常规的二分法套路如下:
int binarySearch(int[] nums, int target) {
int left = 0;
int right = nums.length - 1; // 注意 ①
while(left <= right) { // 注意 ②
int mid = (right + left) / 2;
if(nums[mid] == target)
return mid;
else if (nums[mid] < target)
left = mid + 1; // 注意
else if (nums[mid] > target)
right = mid - 1; // 注意
}
return -1;
}
1.为什么 while 循环的条件中是 <=,而不是 < ?
在二分法中,通常right的赋值是nums.length - 1,即搜索空间是闭区间(左右两端都可以遍历到)。
对于right=nums.length 的情况,我们称之为开区间。因为nums.length无法被索引的。二分法的终止条件可以看做是搜索区间为空。
例如:while(left <= right)的终止条件是 left == right + 1,写成区间的形式就是 [right + 1, right],或者带个具体的数字进去 [3, 2],可见这时候搜索区间为空,
若是,while(left < right)的终止条件是 left == right,写成区间的形式就是 [right, right],或者带个具体的数字进去 [2, 2],这时候搜索区间非空。此时就应当另外判别索引为2时的值,是否等于target,是就返回left,否就返回-1。
那么我们来分析一下,如果我们设置①的条件为right =nums.length,②的条件为left <= right。假定nums=[7,8,9,10],target=11。显然,最终mid=4,搜索范围超过实际长度。设置①的条件为right =nums.length-1,②的条件为left < right,最终left=right=3时,跳出,无法索引到3。
2.为什么 left = mid + 1,right = mid - 1?
因为mid被索引过后,就不需要再次被索引,对于初始区间闭区间的情况,只有将去[left, mid - 1] 或者 [mid + 1, right] 去搜索才能彻底避开mid。
二、寻找左侧边界的二分法
标记的是需要注意的细节
int left_bound(int[] nums, int target)
{
if (nums.length == 0)
return -1;
int left = 0;
int right = nums.length; // 注意
while (left < right) { // 注意
int mid = (left + right) / 2;
if (nums[mid] == target) {
right = mid;
} else if (nums[mid] < target) {
left = mid + 1;
} else if (nums[mid] > target) {
right = mid; // 注意
}
}
return left;
}
1.是while (left < right)?为何不是while (left <= right)
把握住搜索区间。当前搜索区间为左闭右开区间[left,rjght),终止条件为left==right,所以当达到终止条件时,[left,right),搜索区间为空。
2.为何left = mid + 1,right = mid ?和之前的算法不一样?
因为搜索区间[left,right)是左闭右开。当mid被索引过后,初始区间为左闭右开,所以应该将区间分割为[left, mid) 或 [mid + 1, right)两个左闭右开区间。对比(一)中,mid被检测后,分成两个闭合区间。
3.为何该算法可以搜索左侧边界?
关键在于对于 nums[mid] == target 这种情况的处理:
if (nums[mid] == target)
right = mid;
可见,找到 target 时不要立即返回,而是缩小「搜索区间」的上界 right,在区间 [left, mid) 中继续搜索,即不断向左收缩,达到锁定左侧边界的目的。
4.为什么没有返回 -1 的操作?如果 nums 中不存在 target 这个值,怎么办?
可以注意到,mid的搜索范围在[0, nums.length],所以我们简单添加两行代码就能在正确的时候 return -1;
:
while (left < right) {
//...}
// target 比所有数都大
if (left == nums.length)
return -1;
// 类似之前算法的处理方式
return nums[left] == target ? left : -1;
三、寻找右侧边界的二分法
int right_bound(int[] nums, int target) {
if (nums.length == 0)
return -1;
int left = 0, right = nums.length;
while (left < right) {
int mid = (left + right) / 2;
if (nums[mid] == target) {
left = mid + 1; // 注意
} else if (nums[mid] < target) {
left = mid + 1;
} else if (nums[mid] > target) {
right = mid;
}
}
return left - 1; // 注意
}
1.能找到右侧边界的原因。
if (nums[mid] == target) {
left = mid + 1;
当 nums[mid] == target 时,不要立即返回,而是增大「搜索区间」的下界 left,使得区间不断向右收缩,达到锁定右侧边界的目的。
2.为什么最后返回 left - 1
可以注意到根据迭代终止条件,最终结果为left = right,所以返回结果亦可是right-1。那为何要减一呢?因为left更新时必然是mid+1,也即我们终止条件时获得的left,满足条件的mid+1的值,为了得到满足条件的mid,需要减一。
下面以leetcode162题为例,说一说二分法的应用。
class Solution {
public int findPeakElement(int[] nums) {
if(nums.length<=1)
return 0;
int left=1,right=nums.length;
//int left=0,right=nums.length-1;
for(;left<right;){
int mid=(right+left)/2;
if(nums[mid-1]<nums[mid])
left=mid+1;//①
else{
right=mid;//②
}
//下面被注释部分是leetcode常见的题解
// if(nums[mid+1]<nums[mid])
// right=mid; ③
// else{
// left=mid+1; ④
// }
}
return left-1;
//return left;
}
}
可以看到,不注释的部分是搜索左边界。注释部分是搜索右边界。
下面对两部分代码做解释。
首先是不注释部分:
1.为何最后return left-1?
需要注意的是,当最后一次循环满足条件①时,mid是最大值,而left=mid+1,所以要返回left-1。同理,当满足条件二时,mid-1是最大值,left=right=mid-1,所以要返回left-1。
2.为何left设为1,而right设为nums.length?
我们可以做一个假设,如果峰值在最左侧,即索引为0的位置。当left设为0时,显然会出现mid=0的情况,此时mid-1的索引无效。这也就是为何原来会有判定mid为0的代码。那如果峰值在最右侧,则最终状态时,left=right=num.length,返回值为left-1,即数组的最后一个值,正确。
然后是注释部分:
1.为何返回return left?
首先,循环条件终止时,left=right,你想返回right亦可。分析过程与上面的第一个问题类似,若峰值在最右侧,循环终止前的最后一次迭代,峰值在mid+1处,left=mid+1,返回left,没错。若峰值在最左侧,同理分析。
2.为何left设为0,而right设为nums.length-1?
分析方式与上面第二个问题一样。