二分法的本质
二分法是有序集合中搜索目标值的方法,与遍历查找不同的是,二分法通过不断缩小搜索空间来提高查询效率,每一次的搜索空间是上一次的搜索空间的一半,所以单纯的二分法的时间复杂度是logN
二分法的关键就在于如何缩小搜索空间,解决了这个问题,那么代码也就基本上出来了。
下面整理三种常用的二分法的模板代码,可以解决99%的二分题目。
两个数的中位数
在学习二分法之前,我们先来看如何计算两个数的中位数。
最常见的方法如下:
int mid = (hi + lo) / 2;
这种方法是数学上的求均值的算法,但在程序计算中由于精度的原因,有可能会溢出导致计算错误。
int lo = Integer.MAX_VALUE - 1;
int hi = Integer.MAX_VALUE;
int mid = (hi + lo) / 2;
// 最后结果 mid=-1
所以一般的会采用先减后加的方式:
int mid = (hi - lo) / 2 + lo;
int lo = Integer.MAX_VALUE - 1;
int hi = Integer.MAX_VALUE;
int mid = (hi - lo) / 2 + lo;
// mid=2147483646
如果熟悉位运算,还可以用更高级的方法:
int mid = (hi + lo) >>> 1;//无符号右移
注意:中位数有左中位数和右中位数之分,上面的方法取到的都是左中位数。
如下,左中位数是2,右中位数时3。
1 2 3 4
当数组长度是奇数时,左右中位数是同一个。
获取右中位数的表达式如下
int mid = (hi - lo + 1) / 2 + lo;
模板一
该模板是最基础和最基本的二分法,使用场景是在一个数组中查找某个元素,查找不到返回-1。
public int binarySearch(int[] nums, int target) {
if (nums == null || nums.length < 1) {
return -1;
}
int lo = 0;
int hi = nums.length - 1;
while (lo <= hi) {
int mid = (lo + hi) >>> 1;
int midValue = nums[mid];
if (midValue == target) {
return mid;
} else if (midValue > target) {
hi = mid - 1;
}else{
lo = mid + 1;
}
}
return -1;
}
这个模板的思路非常清晰,首先lo和hi分别表示数组的第一个和最后一个元素,然后找到这两个元素的中间元素,
使用int mid = (lo + hi) >>> 1查找到两个元素的中间元素,在中间元素与目标元素比较时,会有下面三种情况:
- midValue == target 恭喜你,直接找到了对应元素,返回即可。
- midValue < target 说明target在数组的后半段,那么我们更新lo=mid+1
- midValue > target 说明target在数组的前半段,那么我们更新hi=mid-1
循环终止的条件是lo > hi。
这里面有几个细节
-
起始的lo和hi分别是第一个和最后一个元素
-
循环终止条件是lo>hi,也就是lo<=hi时可以继续查找 注意当lo=hi时,计算的mid=lo,这样可以校验lo是否是target,从而不会遗漏
-
在midValue!=target时,会缩小搜索空间范围,因为mid已经校验过,所以会在mid基础上左移或右移一位。
模板2
对于一个数组,我们希望找到最左侧的target,如果没有,返回-1。
public int binarySearchLeftMost(int[] nums,int target) {
int lo = 0;
int hi = nums.length;
while (lo < hi) {
int mid = (lo + hi) >>> 1;
int midValue = nums[mid];
if (midValue < target) {
lo = mid + 1;
} else if(midValue == target){
hi = mid;
}else{
hi = mid;
}
}
//后处理
//如果lo越界了 说明没有找到
if(lo == nums.length){
return -1;
}
//如果nums[lo] 不等于target,说明没有找到
if(nums[lo] != target){
return -1;
}
return lo;
}
1 为什么索引区间是[0,nums.length)
因为有了后处理,实际上使用[0,nums.length-1]也是可以的,后处理的代码会校验这种情况。
不过左闭右开的对有些题目是必须的,比如要返回数组中小于target的元素数量。
2 为什么使用 while(lo < hi)
语句的结束时刻是lo == hi,所以无需去考虑最后返回lo还是hi。
3 为什么是 lo = mid + 1和 hi = mid
因为我们实际查找的是最左侧的target或者刚刚大于target的值,
当midValue <target时,此时的midValue肯定不是要找的,至少要右移一位,所以需要lo = mid + 1。
当midValue == target时,我们需要找最左侧的,此时不确定是不是最左侧的,那么我们就设置hi = mid,表示此时的mid可能是最左侧的,可以一直收缩右侧空间,最后到达最左侧。
当midValue > target时,但可能mid就是最后的值,所以我们设置hi = mid。
后两种情况不使用hi = mid - 1而是hi = mid的原则就是此时的mid有可能是最终结果,不能提前排除掉。
4 为什么可以找到左侧边界
关键是下面的分支
else if(midValue == target){
hi = mid;
}
当midValue == target时,我们不立即返回,而是收缩右侧空间,最后到达最左侧。
5 为什么要有后处理
后处理是对可能的异常情况进行校验,比如当target比所有的元素都小或者都大,需要特殊处理。
模板3
对于一个数组,我们希望找到最右侧的target,如果没有,返回-1。
public int binarySearchRightMost(int[] nums,int target) {
int lo = 0;
int hi = nums.length;
while (lo < hi) {
int mid = (lo + hi) >>> 1;
int midValue = nums[mid];
if (midValue < target) {
lo = mid + 1;
}else if(midValue == target){
lo = mid + 1;
}else{
hi = mid;
}
}
if(lo == nums.length && nums[lo - 1] != target){
return -1;
}
return lo - 1;
}
参考资料