前言
今天在力扣上的探索卡片中遇到了一个二分查找的题。
在排序数组中查找元素的第一个和最后一个位置
题目大意如下:
给定一个具有重复数字的排序数组和一个数target,在该数组中查找target是否存在于数组中,如果存在则返回第一次出现的位置和最后一次出现的位置,否则返回[-1,-1]。
对于这道题,第一反应就是用二分查找寻找左边界和右边界。但是思路很简单,细节却一点都不简单。写完了算法之后却因为各种边界值调整了半天,大概半个小时才AC,而且代码中充满了各种条件判断,既不优雅效率也不高。
所以痛定思痛,决定总结一下二分查找。
二分查找有几种写法
基本框架
首先我们给出一个二分查找的基本框架和参数:
int[] num = {5,7,7,,8,810};
target = 8;
int left =0;
int right = ...;
while(...){
int mid = ...;
if(mid > target){
...//中间数大于target
}else if(mid > target){
...//中间数小于target
}else if(mid == target){
...//中间数等于target。
}
return ...;
}
接下来就是根据不同的情况在框架中填充。
我们的目标是寻找一个二分查找的通解,使得代码量最少最不容易出错。
寻找一个数出现的位置
这个场景应用在不重复的排序数组中查找一个数,或者是在重复排序数组中查找一个数任意一个出现的位置。
这个问题最典型也最简单最不容易出错,我们给出如下代码:
int left = 0;
int rigth = nums.length;
while(left < right){
int mid = left + (right - left)/2;
if(nums[mid] == target){
return mid;
}else if(nums[mid] < target){
left= mid + 1;
}else if(nums[mid] > target){
right = mid;//因为右边界不会取到,所以right = mid 而非mid-1
}
return -1;//没找到
//return left; 第一个不小于target的元素
}
这种方法采用的是左开右闭的区间[left,right)
如果想用右开的区间则需要进行一些特殊处理,while(left <= right)
以及right = mid - 1
。
不过鉴于平时使用的for
循环中我总是习惯于写i<n
,所以这边还是使用左开右闭的区间好一些。
左侧边界
寻找左侧边界的意思是返回这个元素出现的第一个下标,没有的话则返回-1。
int left = 0;
int rigth = nums.length;
while(left < right){
int mid = left + (right - left)/2;
if(nums[mid] == target){
right = mid;
}else if(nums[mid] < target){
left= mid + 1;
}else if(nums[mid] > target){
right = mid;//因为右边界不会取到,所以right = mid 而非mid-1
}
//由于上面的while循环中没有返回mid,所以这边可不能直接返回-1
//返回left是指如果要在数组中插入该元素,应该插入在哪里。
//假设target=11,此时当循环结束的时候left==right==length
//所以在num[length]的位置插入11(虽然数组不够长)
return left;
}
这里的left返回的是插入的位置,但是我们还是想知道有没有找到元素,所以还需要一点处理。
if(left == length )return -1
return nums[left] == target?left:-1;
右侧边界
寻找右侧边界的意思是返回这个元素出现的最后一个下标,没有的话则返回-1。
int left = 0;
int rigth = nums.length;
while(left < right){
int mid = left + (right - left)/2;
if(nums[mid] == target){
left = mid + 1;
}else if(nums[mid] < target){
left= mid + 1;
}else if(nums[mid] > target){
right = mid;//因为右边界不会取到,所以right = mid 而非mid-1
}
//在寻找右边界的时候我们更加关心最后一个元素出现在哪
//相比较左边界而言,left依然是插入的位置,
//也就是如果该元素出现了,返回该元素的后面一位,显然和我们的预期不符
//所以返回left-1;
return left - 1;
}
那如果想要知道该元素是否存在于数组中,我们同样需要做一些特殊处理。
if(left == 0) return -1;
return nums[left-1] == target? (left-1):-1;
题34
那么让我们回到题目上来,这道题就是上面的集大成者,先找左边界再找右边界。
当然,向先随便找个数再向前后遍历也可以,只是容易被面试官鄙视而已。然后找左右边界找了半天还没过 。
class Solution {
public int[] searchRange(int[] nums, int target) {
int[] res = new int[2];
res[0] = -1;
res[1] = -1;
if(nums == null || nums.length == 0)
return res;
if(nums.length == 1){
if(nums[0] == target){
res[0] = 0;
res[1] = 0;
}
return res;
}
int left = 0;
int right = nums.length;
//先找左边界,左边界的话找到相同的值之后还是需要向前查找
while(left < right){
int mid = left + (right - left)/2;
if(nums[mid] < target){
left = mid + 1;
}else{
right = mid;
}
}
if(left != nums.length && nums[right] == target){
//左右相等 不必纠结
res[0] = right;
}
left = 0;
right = nums.length;
//先找右边界,右边界的话找到相同的值之后还是需要向后查找
while(left < right){
int mid = left + (right - left)/2;
if(nums[mid] > target){
right = mid;
}else{
left = mid + 1;
}
}
if(left != 0 && nums[left-1] == target){
res[1] = left-1;
}
return res;
}
}
总结
总结一下上面的代码,想要进行一个二分查找需要分4步:
- 初始化:采用左开右闭区间,如果给出的是数组,那么
right = length
,如果给出的是下标那么right = n+1
。 - 中间值:如果采用的是左开右闭区间,那么中间值将始终为
mid = left + (right - left)/2
。 - 缩进:当左侧缩进的时候
left= mid +1
,因为左侧是开区间,是可以取到的,已知mid
不符合条件的时候我们需要将其排除在数组外,但是右侧是闭区间,所以right = mid
就可以是的mid
被排除在外。 - 返回值:采用如上二分查找的返回值是如果向数组中插入该元素,应该插入在哪个位置。
返回值
刚才我们说了二分查找其实是查找如果插入该元素应该插入在哪个位置。所以根据题目不同需要把返回进行一些转化。
当查找左边界的时候,该元素应该插在第一个位置,然后所有的重复元素向后移动一位,所以这个时候left
是准确的下标。
但是右边界的时候,元素会插入在重复元素的后面一位,所以如果想要知道右边界需要返回left-1
。
然后需要处理一下特殊情况,对于左边界来说,如果插入的元素大于所有的元素,那么会在数组的最后一位的后面进行插入,所以在判断num[left] == target
之前要判断left == length
。
同样的,对于右边界来说,当插入元素小于所有元素的时候left==0
但为了获得元素的下标,所以我们返回的是left-1
,所以在判断num[left-1] == target
的时候首先要判断left==0
。
java源码中的二分
java中也提供了Arrays.binarySearch()
函数,不妨打开看一下java源码中二分是怎么写的。
// Like public version, but without range checks.
//这是整型数组的二分,还有其他的类型,但是都差不多。
//可以看到java中二分采用的是左开右开的区间
//并且使用了>>>无符号数右移来防止溢出,
//因为low、high都是下标并且进入该方法之前就校验了范围,所以不用考虑负数
private static int binarySearch0(int[] a, int fromIndex, int toIndex,
int key) {
int low = fromIndex;
int high = toIndex - 1;
while (low <= high) {
int mid = (low + high) >>> 1;
int midVal = a[mid];
if (midVal < key)
low = mid + 1;
else if (midVal > key)
high = mid - 1;
else
return mid; // key found
}
return -(low + 1); // key not found.
}
为什么要使用左闭右开的区间
其实这也不是我说的,是Dijkstra说的。
如果是使用的是while(left <= right)
那么终止的时候left == right + 1
。这个时候两者的值是不一样的,如果是返回mid
还好,要是返回的是左边界或者右边界就麻烦了,光是返回left
还是right
就要想半天。越通用的代码越不容易出错,所以还是使用终止条件为left == right
的左开右闭吧。
左闭右开还有一个好处,如果当一个数组为空集,也就是长度为0的时候,不同区间的初始化写法如下:
- 左闭右开:
left = 0,right = 0
- 左闭右闭:
left = 0, right = -1
- 左开右闭:
left = -1,right = -1
- 左开右开:
left = -1,right = 0
可以看到左闭右开是唯一不出现负数的,所以在讨论边界值的时候,可以放心大胆的使用num[left]
,如果不然的话万一循环过程中出现了负数可能又要分类讨论。
中位数的选择和缩进
其实在中位数的选择和缩进中也有一些选择。
为什么使用的是left = mid +1
而非left = mid
?
假设使用的是left = mid
那么相应的可以得出right = mid - 1
。
考虑一下当区间长度只有1的时候:
假设给出区间[0,1),此时left = 0,right = 1
,计算中位数mid = (0+1) /2
也就是mid=0
。
假设这个时候需要向后查找,那么left = mid
,left = 0
,right = 1
。
然后计算中位数。。。
然后我们就永远也到达不了二分查找的真实,在死循环中不断轮回。
那么如果对中位数进行补偿.
也就是int mid = left + (right - left + 1)/2
,看起来好像没什么问题。
但是别忘了数组的长度可能真的只有区间那么大。
所以当数组真的只有1长度的时候:
int mid =0 + (1 - 0 + 1) /2
也就是mid = 1
。
然后进行比较num[mid] > target
。
没想到吧JOJO,我溢出了。
后记
上面说了这么多,其实就是在写二分查找的时候犯过的错误。这么总结来看,其实所有的选择都是命运的选择。
最近会总结一下刷题中遇到的一些算法模板,包括快排、单调栈、快速幂之类的,总之代码越短越好,越通用越好,到时候背一背就能去接受面试官的毒打了呢。
然后简历都没过。
参考文章:
二分查找有几种写法