通俗二分查找(查找一个数)
前提条件:所需查找的序列(基本是数组)必须有序,且目标数唯一。
基本框架
int binarySearch(int[] nums, int target) {
int left = 0, right = nums.length - 1; //①
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 - 1; //⑤
}
}
return -1;
}
二分法的原理高中数学就已经接触过,比较容易理解,但是想要通过代码复现却并不简单,主要列出以下几个注意点:
-
①:这里右边界
right
既可以取nums.length - 1
,也可以取nums.length
,只需相应的对循环条件②做适当调整即可。 -
②:上述示例代码里循环条件既可以取
<=
,也可以取<
,只需牢记这是你设定的终止条件,只要它可以保证程序的正确终止且元素没有漏查即可。这里循环条件结合①的右边界初始条件极容易出错,在此做详细说明:
1、
<=
的查找区间为全闭区间[left, right]
,跳出条件为left > right
,这里均为整型,所以条件等同于left == right + 1
,此时查找区间为[right + 1, right]
,显然区间为空,正确跳出循环;这里一定要理解全闭区间的意思:可以这样想,二分查找是不断折半原区间直至结束的过程,这一过程中的“折半”是通过不断比较
nums[mid]
与target
的大小并重新将mid
赋予left
或right
来实现的,如果将mid
视作指针,则可以将这一过程看做该指针在left
和right
这一区间内反复横跳(并不断更新自己),由于循环条件为<=
,即mid
有可能取到这一区间内的任意值(包括两端端点),故查找区间为全闭区间[left, right]
。2、
<
的查找区间为左闭右开区间[left, right)
,跳出条件为left == right
,此时查找区间为[right,
right],显然区间不为空,但循环已经跳出,即此时nums[right]
仍未查找,所以循环结束之后要补充查找这个漏掉的元素,即return nums[left] == target ? left : -1;
;同理,这里的左闭右开区间可以这样理解:
mid
指针在left
和right
这一区间内反复横跳过程中,由于循环条件为<
,即mid
永远取不到这一区间内的右端点值(因为一旦left==right
(left
和right
就是“横跳”的mid
指针),就直接跳出循环),故查找区间为左闭右开区间[left, right)
。 -
③:一般情况下这里写
left + (right - left) / 2
就和(left + right) / 2
的结果相同,写后者通常也并不出错,但诸多大神及力扣官方解答里采用的都是前者的标准写法,这样可以避免数值太大相加导致溢出的风险。 -
④和⑤:这里的
left = mid + 1
,right = mid - 1
也可以写成right = mid
或者left = mid + 1
,取决于查找区间。
1、当查找区间为全闭区间[left, right]
时,当发现nums[mid]
与target
的大小并不相等时,则下一步应该查找的区间显然是[left, mid - 1]
和[mid + 1, right]
(因为mid
已经比较过了,需剔除)。2、同理,当查找区间为左闭右开区间
[left, right)
时,当发现nums[mid]
与target
的大小并不相等时,则下一步应该查找的区间显然是[left, mid)
和[mid + 1, right)
(因为mid
已经比较过了,需剔除)。
寻找左(右)侧边界的二分查找
直接考察基本二分搜索的情况一般不是太多,很多时候考察的是寻找边界的变体形式
前提条件:所需查找的数组必须有序,但不必严格单调,若目标数有多个,则找的是这一连续相等字串最左侧的数,若目标数仅有一个,则这里的“左(右)侧边界”要根据题意判断。
寻找左侧边界
int leftBoundSearch(int[] nums, int target) {
if (nums.length == 0) return -1;
int left = 0, right = nums.length - 1;
while (left <= right) {
int mid = left + (right - left) / 2;
if (nums[mid] == target) {
right = mid - 1; //※※※
} else if (nums[mid] < target) {
left = mid + 1;
} else if (nums[mid] > target) {
right = mid - 1;
}
}
//※※※
return left;
}
理解了前面的解释,则寻找左侧边界的代码不难写出,下面强调一下需要注意的地方,即上述代码中标记了※※※的位置。
1、在基本查找中,当nums[mid] == target
时,我们直接返回此下标,因为目的就是寻找那个相等的数(这个数是唯一的,找到就不用再继续了,直接跳出循环),而在左侧边界查找中,当nums[mid] == target
时,由于这个target
可能有多个,我们应该缩小区间继续寻找,这里默认有序是增序(降序换个方向即可),故显然右边界right
应该往左侧收缩,同时,此处的查找区间是全闭区间[left, right]
,故right = mid - 1
。
2、在基本查找中,循环外的程序最后是return -1
,这是因为这里的目的是找到唯一target
,如果找到了,则在循环中就直接返回索引程序结束了,只有找不到的情况下,才会触发循环终止条件,结束循环,此时并没有找到target
,因此return -1
表示未找到;而在左侧边界查找中,循环体内部并没有程序返回语句,因为我们要不断缩小区间直至找到那个最左侧target
,也就是说这里的循环查找要不断执行,直到区间全部查找完毕(亦即触发循环终止条件)才结束循环,最后,如果找到了,则return left
,
如果找不到,分为两种情况:
一种是target
数值大小处于[left, right]
之间,但是数组内并没有这个数,故要判断nums[left]
和target
是否相等;
还有一种是target
数值大于[left, right]
里的所有数,此时left == right + 1
(循环终止条件),由于target
数值大于[left, right]
里的所有数,因此right
始终等于nums.length - 1
,即循环结束时left = right + 1 = nums.length
,此时会出现索引越界,需要判断是否越界,故在return left
前应该补上
if (left > nums.length - 1 || nums[left] != target) {
return -1;
}
这里并未考虑target
数值小于[left, right]
里的所有数是否会导致数组越界,同理,后面的寻找右侧边界也没有考虑target
数值大于[left, right]
里的所有数是否会导致数组越界,其实,这是不需要考虑的,相信大家看完全文会有所体会,这里不再赘述
这里还有一个点需要注意:对于成功找到左边界的情况,最后是
return left
而不是return right
,按照前面的讲解,可能大家已经形成固定印象,认为当循环条件为left <= right
时,循环终止触发条件为left == right + 1
,这么写数学逻辑上当然没错,但是在此处还有另一层细节上的理解。
(讨论的大前提是可以找到左边界,即数组中存在一个或多个
target
)以寻找左边界为例,考虑两种情况:
- 数组里仅有一个
target
时,记其下标为i
,当循环至第一次nums[mid] == target
亦即nums[i] == target
时,此时右边界左缩,right
已经变成了i - 1
,由于仅有一个target
,同时数组单调,即i
左侧所有元素均小于target
,后续循环中mid
横跳范围为[left, i - 1]
,因此不会再出现下一次nums[mid] == target
,也不可能出现nums[mid] > target
,易知从此次循环直到结束,右边界right
不会再变,区间的折半由left
的不断右移来实现,直至left == right + 1
结束循环,此时left = (i - 1) + 1 = i
,最终return left
即为要找的左边界下标,无误。- 数组里有多个
target
时,由于实际数组的不同会有不同情况,一种会演变成上述情况;还有一种情况是,在right
的右移和left
的左移过程中,可能会出现right
和left
同时指向target
,有人可能会认为left
继续右移直至left == right + 1
结束循环,此时return left
就不是正确结果了,其实不然,若记多个target
的最左侧边界下标为i
,在left
第一次指向target
即i
后,left
就不会再变了,之后区间的折半由right
的不断左移来实现,直至right
指向i - 1
,此时的循环终止条件应理解为right == left - 1
,最终return left
即为要找的左边界下标,依旧无误。
因此完整的寻找左侧边界的代码如下:
int leftBoundSearch(int[] nums, int target) {
if (nums.length == 0) return -1;
int left = 0, right = nums.length - 1;
while (left <= right) {
int mid = left + (right - left) / 2;
if (nums[mid] == target) {
right = mid - 1;
} else if (nums[mid] < target) {
left = mid + 1;
} else if (nums[mid] > target) {
right = mid - 1;
}
}
if (left > nums.length - 1 || nums[left] != target) {
return -1;
}
return left;
}
寻找右侧边界
与上述同理,极易写出寻找右侧边界的二分查找代码如下:
int rightBoundSearch(int[] nums, int target) {
if (nums.length == 0) return -1;
int left = 0, right = nums.length - 1;
while (left <= right) {
int mid = left + (right - left) / 2;
if (nums[mid] == target) {
left = mid + 1; //此处应将左侧边界left往右缩
} else if (nums[mid] < target) {
left = mid + 1;
} else if (nums[mid] > target) {
right = mid - 1;
}
}
//右边界的左移可能会导致数组越界
if (right < 0 || nums[right] != target) {
return -1;
}
return right;
}
至此,二分查找的所有细节讲解完毕,注意,如果初始右边界选取
nums.length
,则循环条件必须是<
,以避免数组越界,相应的内部细节稍作修改即可,理解透彻原理后并不难。
建议初始右边界选取nums.length - 1
,循环条件取<=
,这样便于理解,也便于记忆,同时统一了三种不同形式的二分查找。
相信以上内容已经足够详细,希望能对大家透彻掌握二分法有所帮助,码字不易,请尊重原创。