文章介绍了二分搜索最常见的几个场景的使用:寻找一个数、寻找左侧边界以及寻找右侧边界。阅读本文只需读者了解二分搜索的使用限制和基本原理即可。
我相信,友好的讨论交流会让彼此快速进步!文章难免有疏漏之处,十分欢迎大家在评论区中批评指正。
发现更多好文,请前往柿子先生的博客。
寻找一个数
搜索一个数,如果存在,返回其索引,否则返回 -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
的结果相同,但是有效防止了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,上述代码是无法处理的。
下面,我们来学习如何使用二分搜索找到目标值的左右边界。
寻找左侧边界的二分搜索
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 时),校验其是否合法没有意义,还会导致返回错误。
寻找右侧边界的二分查找
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
。
再次回顾之前的代码:
寻找一个数
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-二分查找
题目描述:
参考代码:
class Solution {
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-在排序数组中查找元素的第一个和最后一个位置
题目描述:
参考代码:
class Solution {
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;
}
}
剑指 Offer 53 - I. 在排序数组中查找数字 I
题目描述:
参考代码:
class Solution {
public int search(int[] nums, int target) {
int L = leftBound(nums, target);
int R = rightBound(nums, target);
if ( L == -1 || R == -1) {
return 0;
} else {
return R - L + 1;
}
}
int leftBound(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 rightBound(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;
}
}
写在最后
文章主体内容在《labuladong 的算法小抄》的基础上进行了个性化增删,文章围绕 「通解框架」 这一核心,删除了许多个人认为不必要的内容,大幅缩减了文章篇幅。同时,在使用二分搜索寻找左右侧边界问题上提出并回答了几个比较关键的问题,最后在逻辑统一部分提炼了几个关键点,更加有助于通解框架的理解与记忆。
参考资料
- labuladong 的算法小抄
- 力扣网