前言
去年暑假我写了一篇文章:一文弄懂二分查找,主要阐述了二分查找的一种写法及其对应的理解方法。
那篇文章从发表到现在已经有七个月的时间了,这七个月的时间里,我在刷题的过程中,发现了之前那篇文章的些许疏漏之处,觉得有必要做一些补充,故提笔写了这篇文章。
上集回顾
一文弄懂二分查找 主要提出了一种二分查找的写法及理解方法。如查找升序数组中第一个大于target的位置,我们可以采用下面的写法:
public static int myBinary(int[] nums,int target) {
int low = 0,high = nums.length;
while (low < high) {
int mid = low + (high - low) / 2;
if (nums[mid] <= target) {
low = mid + 1;
}
else {
high = mid;
}
}
return low;
}
回顾一下这种方法如何理解:
nums为升序数组,若if (nums[mid] <= target)成立,则下标mid以及mid之前的元素肯定都是小于等于target的,故直接low = mid + 1;,那么low左边的元素全是不符合要求的;nums为升序数组,若if (nums[mid] > target)成立,那么mid以及mid之后的元素都是大于target的,由于找第一个大于target的元素,故high = mid;,high即是目前符合条件的最小的下标值。- 按照这种思路不断二分,
low不断增加,high不断减小,最终low == high时,即low,high左边都是不符合条件的元素,low,high及low,high右边都是符合条件的解,即此时的low,high就是符合条件的第一个解。
若是在升序数组中,找第一个大于等于target的元素,可以直接将if (nums[mid] <= target)改为if (nums[mid] < target)即可。用这种low控制不可行解的边界,high控制可行解的边界,不断逼近直至low,high相等的思路,可以写出查找第一个xxx的正确代码。
存在的问题
然而,在我刷题的过程中,发现了这种理解方法的一个疏漏之处,忘了是力扣哪个题了,当时题目抽象出来,就是要找升序数组中最后一个小于target的元素,鉴于之前总结的二分查找,我很容易写出了下面的代码:
public static int myBinary(int[] nums,int target) {
int low = 0,high = nums.length;
while (low < high) {
int mid = low + (high - low) / 2;
if (nums[mid] < target) {
low = mid;
}
else {
high = mid - 1;
}
}
return low;
}
这个代码和上面那个代码不一样的地方就是这里用high控制不可行解的边界,用low作为可行解的边界。看样子是没有问题的,然而大概只能过一半的用例,有些用例会超时。大伙也可以自行在IDE上试试:
public class Blog {
public static int myBinary(int[] nums,int target) {
int low = 0,high = nums.length;
while (low < high) {
int mid = low + (high - low) / 2;
if (nums[mid] < target) {
low = mid;
}
else {
high = mid - 1;
}
}
return low;
}
public static void main(String[] args) {
int[] nums = new int[] {1, 2, 4, 4, 4, 6, 7};
int target = 3;
System.out.println(myBinary(nums,target));
}
}
比方说把数组初始化为[1, 2, 4, 4, 4, 6, 7],target = 3时,就会产生死循环:
原因探寻
咱们走一遍那个例子:一开始,low为0,high为7,mid为3,那么经过一轮更新,low为0,high为2,mid为1,经过再一轮更新,low为1,high为2,mid为1,好了,发现问题了,此时,nums[mid]小于3,经过一轮更新之后,low,high和mid还是不变,程序僵持在这里了。
我们还可以发现,并不是所有情况都会死循环的,比如你把数组改为[1, 4, 4, 4, 4, 6, 7],target = 3,这时候可以成功找到,因为在low为0,high为2,mid为1这一轮更新会将high减一个,达到了while的终止条件。
那么啥时候会出现死循环的情况呢?不难发现,当某次更新之后,low和high相邻了,由于我们更新mid的语句为:int mid = low + (high - low) / 2;,此时,mid更新为low,由于在这个代码里,low控制可行解,故nums[mid]符合条件,再次进行low = mid,程序就卡死在这了。
这种情况下,死循环的根本原因即,low和high相邻时,mid的计算指向了low,而low代表可行解的边界,nums[mid] < target肯定是成立的,这导致程序进入了更新可行解边界也就是low的代码分支:low = mid,这相当于没有更新,low,high,mid均和之前一样,程序会一直进行无效的死循环。
改的方法也很简单了,low和high不相邻的时候,mid的更新是没啥问题的,咱们只要让low和high相邻时,mid指向high也就是不可行解边界就行了,这样不管是更新low还是更新high都可以到达终止条件,将mid的更新语句改为:int mid = high - (high - low) / 2即可;
一个疑问
那有人问了,为啥找第一个大于XXX的元素的时候,那个代码就不会死循环呢?注意到本文开头的代码,这种情况下,low控制的是不可行解,high控制的可行解,而每次mid的更新为:int mid = low + (high - low) / 2;,当low和high相邻时,mid指向low这个不可行解的边界,所以不管nums[mid]和target是何关系,high和low的更新均会导致while终止条件的到达,正确结束循环。
总结
本文对之前的博客:一文弄懂二分查找 进行了补充,当利用low,high一个控制可行解边界,一个控制不可行解边界的思想进行二分查找代码编写时,要注意当low和high相邻时,对mid的更新应保证mid落在不可行解那一边,否则,会导致代码一直无效的更新可行解边界,导致程序的死循环。以下两套代码应配套使用(只针对作者的二分查找写法,并不是说二分查找只有这么写才是对的):
// 查找升序数组里第一个大于 target 的元素下标
public static int myBinary(int[] nums,int target) {
int low = 0,high = nums.length;
while (low < high) {
// low 和 high 相邻时,保证 mid 落在不可行解那一边
int mid = low + (high - low) / 2;
if (nums[mid] <= target) {
low = mid + 1;
}
else {
high = mid;
}
}
return low;
}
// 查找升序数组里最后一个小于等于 target 的元素下标
public static int myBinary(int[] nums,int target) {
int low = 0,high = nums.length;
while (low < high) {
// low 和 high 相邻时,保证 mid 落在不可行解那一边
int mid = high - (high - low) / 2;
if (nums[mid] <= target) {
low = mid;
}
else {
high = mid - 1;
}
}
return low;
}
当然,在升序数组里查找最后一个小于等于target的元素也可以通过求在升序数组里找第一个大于target的元素下标之后再减1来得到。
P.S.
我在掘金的上一篇博客:Is Redlock Safe? 一场关于 Redlock 的辩论 是今年 1 月 5 号发的,本篇博客离上一篇也过了七八十天了。这两个多月因为找暑期实习和搞毕设,弄得我比较焦虑,所以一直没静下心来写博客,这篇博客也是这个阴历年自己写的第一篇博客,也在这里许愿自己今年一切顺利。