文章介绍了二分搜索最常见的几个场景的使用:寻找一个数、寻找左侧边界以及寻找右侧边界。文章内容既适合完全不知道二分搜索是什么的新同学,也适合老同学复习拓展。
考察过该题目的公司有:拼多多、美团、腾讯、阿里巴巴、百度、华为等大厂。
我相信,友好的讨论交流会让彼此快速进步!文章难免有疏漏之处,十分欢迎大家在评论区中批评指正。
寻找一个数
题目描述
在有序数组中搜索一个数,如果存在,返回其索引,否则返回 -1。
思路
二分查找的思路非常简单,利用了数组的有序性。
每次取数组中点的元素值与目标值比大小,每次共有三种情况:
- 如果找到了就直接返回索引;
- 如果中点值比目标值小,说明中点值之前的值都比目标值小,因此目标值在右半区间;
- 如果中点值比目标值大,说明中点值之后的值都比目标值大,因此目标值一定在左半区间。
如果遍历完整个数组后都没找到目标值,那就说明没找到,返回 -1。
参考代码
int binarySearch(int[] nums, int target) {
// 搜索区间 [left, right]
int left = 0, right = nums.length - 1;
while (left <= right) { // !!!
int mid = left + ((right - left) >> 1);
if (nums[mid] == target) {
return mid; // !!!
} else if (nums[mid] < target) { // 搜索区间变为 [mid+1, right]
left = mid + 1; // !!!
} else { // 搜索区间变为 [left, mid-1]
right = mid - 1; // !!!
}
}
return -1; // !!!
}
深入分析
计算
mid
时需要防止溢出,left + ((right - left) >> 1)
和(left + right) / 2
的结果相同(>> 1
相当于/ 2
),但是有效防止了left
和right
太大,直接相加导致溢出的情况。
-
搜索区间:
[left, right]
,因为right
初始化时是nums.length - 1
,即最后一个元素的索引。 -
停止搜索:
nums[mid] == target
,找到目标值即停止。如果没有找到,while 循环终止,并返回 -1。 -
循环终止: 搜索区间为空的时候,循环终止。
while (left <= right)
的循环终止条件是当left
的值为right + 1
时,写成区间形式就是 [right + 1, right],此时搜索区间为空。此时,while 循环终止是正确的,直接返回 -1 即可。 -
区间移动: 在搜索区间为
[left, right]
时,若索引mid
上的元素不是要找的target
时,要去[left, mid - 1]
或[mid + 1, right]
区间上搜索,因为 mid 已经被搜索过了,应当从搜索区间中删除。 -
代码缺陷: 无法找到升序数组中存在多个目标值的左右边界索引情况。假设有升序数组 nums = [1, 2, 3, 3, 3, 3, 3],target 为 3,该算法返回的索引是 3。如果此时,我想得到 target 的左侧边界索引,即 2,或者想得到 target 的右侧边界,即 6,上述代码是无法处理的。
时间复杂度
对长度为 n 的数组进行二分,最坏情况就是取 2 的对数。
空间复杂度
常数级变量,无额外辅助空间。
为了解决上述的代码缺陷,我们来学习如何使用二分搜索找到目标值的左右边界。
寻找左侧边界的二分搜索
题目描述
在一个有序数组中,存在多个大小为 target
值的元素,请找到目标值在数组中的开始位置,即左侧边界。
思路
这道题整体的思路与二分搜索寻找一个数基本是一致的,只有几处不同,且看我娓娓道来。
我们以输入数组为 [1, 3, 4, 5, 5, 5, 5, 5, 9]
,目标值为 5 为例进行讲解。
一开始,我们的中点值就是目标值(nums[mid] == target
),我们可以直接返回索引 4 吗?当然不可以啦,我们要找的是第一个 5 出现的索引 3,所以,我们应该继续缩小搜索区间,左移右指针(right = mid - 1
,right
此时为 3)。
此时,新的
mid
值为 1,该位置元素值小于目标值(nums[mid] < target
),需要右移 left
指针(left = mid + 1
,left
为 2)。
新的 mid
值为 2,该位置元素值小于目标值,继续右移 left
指针(left
为 3)。
新的 mid
值为 3,该位置元素值与目标值相等,左移 right
指针(right
为 2),跳出循环,返回 left
。
完整的过程如下:
所以,不同的地方就是,与目标值相等时,不能直接返回索引,而是收缩右边界,也就是左移右指针(
right = mid - 1
),最后返回左指针即可
参考代码
int searchLeftBound(int[] nums, int target) {
int left = 0, right = nums.length - 1;
while (left <= right) {
int mid = left + ((right - left) >> 1);
if (nums[mid] == target) {
right = mid - 1; // 收缩右边界
} else if (nums[mid] < target) {
left = mid + 1;
} else {
right = mid - 1;
}
}
// 越界检查,不存在检查
if (left >= nums.length || nums[left] != target) {
return -1;
}
return left;
}
深入分析
1. 为什么能够搜索左侧边界?
关键点在于 nums[mid] == target
时的处理。
if (nums[mid] == target) {
right = mid - 1; // 收缩右边界
}
当找到 target
时,不立即返回,而是缩小搜索区间的右边界 right
,在区间 [left, mid - 1]
中继续搜索,也就是不断向左靠拢,达到锁定左侧边界的目的。
2. 为什么最终返回的是 left
而不是 right
?
while (left <= right)
的循环终止条件是 left == right + 1
,因此左边界的索引值一定是在 left == right
时出现的。然而此时,循环无法停止,right
还要继续收缩,因此只能返回左边界的索引值 left
。
3. 为什么越界检查只检查左边界 left
?
我们最终返回的是左边界索引 left
,因此只需校验左边界 left
最终是否合法即可,另一方面,由于 nums[mid] == target
时,right = mid - 1;
这样 right 在很多情况下都会越界(比如,左边界的索引为 0 时),校验其是否合法没有意义,还会导致返回错误。
时间复杂度
空间复杂度
常数级变量,无额外辅助空间。
寻找右侧边界的二分查找
题目描述
在一个有序数组中,存在多个大小为 target
值的元素,请找到目标值在数组中的结束位置,即右侧边界。
思路
和寻找左侧边界相反,找到目标值的时候收缩左边界,也就是右移左指针。最终返回右指针索引。
两者的不同如下。
寻找左侧边界 | 寻找右侧边界 | |
---|---|---|
nums[mid] == target | mid = right - 1 | mid = left + 1 |
返回值 | left | right |
越界判断 | left >= nums.length || nums[left] != target | right < 0 || nums[right] != target |
参考代码
int searchRightBound(int[] nums, int target) {
int left = 0, right = nums.length - 1;
while (left <= right) {
int mid = left + ((right - left) >> 1);
if (nums[mid] == target) {
left = mid + 1; // 收缩左边界
} else if (nums[mid] < target) {
left = mid + 1;
} else {
right = mid - 1;
}
}
if (right < 0 || nums[right] != target) {
return -1;
}
return right;
}
深入分析
1. 为什么能够搜索右侧边界?
关键点在于 nums[mid] == target
时的处理。
if (nums[mid] == target) {
left = mid + 1; // 收缩左边界
}
当找到 target
时,不立即返回,而是增大搜索区间的左边界 left
,在区间 [mid + 1, right]
中继续搜索,也就是不断向右靠拢,达到锁定右侧边界的目的。
2. 为什么最终返回的是 right
而不是 left
?
while (left <= right)
的循环终止条件是 left == right + 1
,而右边界的索引值一定是在 left == right
时出现的。然而此时,循环无法停止,left
还要继续增大,因此只能返回右边界的索引值 right
。
3. 为什么越界检查只检查右边界 right
?
我们最终返回的是右边界索引 right
,因此只需校验右边界 right 最终是否合法即可,另一方面,由于 nums[mid] == target
时,left = mid + 1;
这样 left
在很多情况下都会越界(比如,右边界的索引为 0 时),校验其是否合法没有意义,还会导致返回错误。
总结
首先,我们先来梳理汇总一下上面的内容。
1. 搜索区间:[left, right]
,即搜索区间左右皆闭合。
2. 循环条件:left <= right
,因此,终止条件为 left == right + 1
。
3. 收缩区间: 寻找左边界,找到目标值后,收缩 right
,即 right = mid - 1
;寻找右边界,找到目标值后,扩大 left
,即 left = mid + 1
。
4. 越界校验: 寻找左边界,跳出循环后,校验 left
;寻找右边界,跳出循环后,校验 right
。
5. 返回值: 寻找左边界,返回 left
;寻找右边界,返回 right
。
寻找一个数 | 寻找左侧边界 | 寻找右侧边界 | |
---|---|---|---|
搜索区间 | [left, right] | [left, right] | [left, right] |
循环条件 | left <= right | left <= right | left <= right |
循环终止条件 | left == right + 1 | left == right + 1 | left == right + 1 |
nums[mid] == target 时,收缩区间 | return -1; | right = mid - 1; | left = mid + 1; |
越界检验 | 检验 left | 校验 right | |
返回值 | 返回 mid | 返回 left | 返回 right |
再次回顾之前的代码:
寻找一个数
int binarySearch(int[] nums, int target) {
int left = 0, right = nums.length - 1; // !!!
while (left <= right) { // !!!
int mid = left + ((right - left) >> 1);
if (nums[mid] == target) {
return mid; // !!!
} else if (nums[mid] < target) {
left = mid + 1;
} else {
right = mid - 1;
}
}
return -1; // !!!
}
寻找左边界
int searchLeftBound(int[] nums, int target) {
int left = 0, right = nums.length - 1; // !!!
while (left <= right) { // !!!
int mid = left + ((right - left) >> 1);
if (nums[mid] == target) {
right = mid - 1; // !!!
} else if (nums[mid] < target) {
left = mid + 1;
} else {
right = mid - 1;
}
}
if (left >= nums.length || nums[left] != target) { // !!!
return -1;
}
return left; // !!!
}
寻找右边界
int searchRightBound(int[] nums, int target) {
int left = 0, right = nums.length - 1; // !!!
while (left <= right) { // !!!
int mid = left + ((right - left) >> 1);
if (nums[mid] == target) {
left = mid + 1; // !!!
} else if (nums[mid] < target) {
left = mid + 1;
} else {
right = mid - 1;
}
}
if (right < 0 || nums[right] != target) { // !!!
return -1;
}
return right; // !!!
}
写在最后,二分搜索最有价值的思想在于,通过已知信息尽可能多地收缩(折半)搜索空间,从而提高穷举效率,快速找到目标。
实战一下
二分查找(力扣704)
题目描述:
参考代码:
public int search(int[] nums, int target) {
int left = 0, right = nums.length - 1;
while (left <= right) {
int mid = left + ((right - left) >> 1);
if (nums[mid] == target)
return mid;
else if (nums[mid] < target)
left = mid + 1;
else
right = mid - 1;
}
return -1;
}
在排序数组中查找元素的第一个和最后一个位置(力扣34)
题目描述:
参考代码:
public int[] searchRange(int[] nums, int target) {
return new int[] {searchLeftBound(nums, target), searchRightBound(nums, target)};
}
int searchLeftBound(int[] nums, int target) {
int left = 0, right = nums.length - 1;
while (left <= right) {
int mid = left + ((right - left) >> 1);
if (nums[mid] == target) {
right = mid - 1;
} else if (nums[mid] < target) {
left = mid + 1;
} else {
right = mid - 1;
}
}
if (left >= nums.length || nums[left] != target) {
return -1;
}
return left;
}
int searchRightBound(int[] nums, int target) {
int left = 0, right = nums.length - 1;
while (left <= right) {
int mid = left + ((right - left) >> 1);
if (nums[mid] == target) {
left = mid + 1;
} else if (nums[mid] < target) {
left = mid + 1;
} else {
right = mid - 1;
}
}
if (right < 0 || nums[right] != target) {
return -1;
}
return right;
}
在排序数组中查找数字I(剑指Offer53-I)
题目描述:
参考代码:
public int search(int[] nums, int target) {
int L = searchLeftBound(nums, target);
int R = searchRightBound(nums, target);
if ( L == -1 || R == -1) {
return 0;
} else {
return R - L + 1;
}
}
int searchLeftBound(int[] nums, int target) {
int left = 0, right = nums.length - 1;
while (left <= right) {
int mid = left + ((right - left) >> 1);
if (nums[mid] == target) {
right = mid - 1;
} else if (nums[mid] < target) {
left = mid + 1;
} else {
right = mid - 1;
}
}
if (left >= nums.length || nums[left] != target)
return -1;
return left;
}
int searchRightBound(int[] nums, int target) {
int left = 0, right = nums.length - 1;
while (left <= right) {
int mid = left + ((right - left) >> 1);
if (nums[mid] == target) {
left = mid + 1;
} else if (nums[mid] < target) {
left = mid + 1;
} else {
right = mid - 1;
}
}
if (right < 0 || nums[right] != target)
return -1;
return right;
}
参考资料
- 程序基地 - github
- 牛客网
- labuladong 的算法小抄
- 算法(4th)
- 力扣网