一、题目描述
给定一个按照非递减顺序排列的整数数组 nums,以及一个目标值 target,要求找出目标值在数组中的开始位置和结束位置。
如果数组中不存在 target,返回 [-1,-1]。题目要求算法的时间复杂度必须为 O(log n)。
例如:
输入:nums = [5,7,7,8,8,10], target = 8
输出:[3,4]
数字 8 出现了两次,第一次出现的位置是下标 3,最后一次出现的位置是下标 4。
如果目标值为 6,数组中不存在该数字,则返回:
[-1,-1]
数组已经有序,并且时间复杂度要求为 O(log n),因此这道题应该使用二分查找。问题在于:普通二分查找只能找到目标值的某一个位置,如何进一步找到它的左右边界?
二、普通二分查找为什么不够
标准二分查找在发现 nums[mid] == target 时会立即返回 mid:
if (nums[mid] == target) {
return mid;
}
但是,当数组中存在多个相同元素时,这个 mid 不一定是第一个位置,也不一定是最后一个位置。
例如:
nums = [5,7,7,8,8,10]
target = 8
二分查找可能先找到下标 4,但我们还不能确定下标 3 是否也是 8。同理,即使第一次找到的是下标 3,也不能确定它右侧是否还有目标值。
因此,本题找到 target 后不能立即返回,而是应该:
-
先记录当前找到的下标;
-
根据要查找的边界,继续向左或向右搜索;
-
直到搜索区间为空,最后一次记录的位置就是对应边界。
三、整体思路:执行两次二分查找
目标值的开始位置和结束位置是两个不同的问题,可以分别执行一次二分查找:
-
第一次查找左边界,即目标值第一次出现的位置;
-
第二次查找右边界,即目标值最后一次出现的位置。
两个搜索过程的大部分逻辑完全相同,只有在命中目标值后的搜索方向不同。因此,可以编写一个 findMost 方法,并通过布尔参数 isLeft 区分搜索目标:
findMost(nums, target, true); // 查找左边界
findMost(nums, target, false); // 查找右边界
最终将两次搜索的结果组合起来:
return new int[] {
findMost(nums, target, true),
findMost(nums, target, false)
};
如果目标值不存在,两次搜索都会返回初始值 -1,自然得到 [-1,-1]。
四、tempIndex 为什么必不可少
在二分查找中定义一个临时变量:
int tempIndex = -1;
每当发现 nums[mid] == target 时,就把当前下标记录下来:
tempIndex = mid;
之所以只记录而不立即返回,是因为当前的 mid 只是一个候选边界,真正的左边界可能还在左侧,真正的右边界也可能还在右侧。
如果后续搜索又找到了更靠近目标方向的相同元素,就再次更新 tempIndex。当循环结束时,tempIndex 保存的就是最终边界。
如果整个搜索过程中一次都没有找到 target,tempIndex 会保持为 -1。
五、如何查找左边界
查找左边界时,遇到 nums[mid] == target,说明当前下标可能是第一个位置,但它的左边仍可能存在相同元素。
因此先记录当前位置,再继续搜索左半部分:
tempIndex = mid;
right = mid - 1;
例如:
nums = [5,7,7,8,8,10]
target = 8
如果当前找到下标 4,先把 4 记录下来,然后将右边界移动到 3,继续检查左侧。若之后发现下标 3 也是 8,就用 3 更新 tempIndex。
循环结束后,得到左边界 3。
可以把查找左边界的规则记为:
命中后记录答案,并继续向左搜索。
六、如何查找右边界
查找右边界的逻辑与左边界相反。
当 nums[mid] == target 时,当前下标可能是最后一个位置,但右侧仍可能存在相同元素。因此先记录当前位置,再继续搜索右半部分:
tempIndex = mid;
left = mid + 1;
如果当前先找到下标 3,就将 3 记录下来,然后继续搜索它的右侧。后续找到下标 4 时,再将 tempIndex 更新为 4。
循环结束后,得到右边界 4。
对应的记忆规则是:
命中后记录答案,并继续向右搜索。
七、未命中时如何更新区间
除了命中目标值后的特殊处理,其余情况与标准二分查找完全相同。
如果中间元素小于目标值,目标值只能出现在右侧:
if (nums[mid] < target) {
left = mid + 1;
}
如果中间元素大于目标值,目标值只能出现在左侧:
else if (nums[mid] > target) {
right = mid - 1;
}
只有在 nums[mid] == target 时,才需要根据 isLeft 决定继续搜索的方向。
八、完整 Java 代码
下面的代码严格使用同一个 findMost 方法完成左右边界查找:
class Solution {
public int[] searchRange(int[] nums, int target) {
return new int[] {
findMost(nums, target, true),
findMost(nums, target, false)
};
}
// isLeft 为 true 时查找左边界,否则查找右边界
private int findMost(int[] nums, int target, boolean isLeft) {
int left = 0;
int right = nums.length - 1;
int tempIndex = -1;
while (left <= right) {
int mid = left + ((right - left) >>> 1);
if (nums[mid] < target) {
left = mid + 1;
} else if (nums[mid] > target) {
right = mid - 1;
} else {
// 先记录当前命中的位置
tempIndex = mid;
if (isLeft) {
// 查找左边界:继续向左搜索
right = mid - 1;
} else {
// 查找右边界:继续向右搜索
left = mid + 1;
}
}
}
return tempIndex;
}
}
这里使用下面的方式计算中间下标:
int mid = left + ((right - left) >>> 1);
它与 (left + right) / 2 的作用相同,但可以避免 left + right 过大时出现整数溢出。
九、示例推演
以 nums = [5,7,7,8,8,10]、target = 8 为例。
1. 查找左边界
初始区间为 [0,5]:
mid = 2,nums[2] = 7 < 8
left = 3
搜索区间变为 [3,5]:
mid = 4,nums[4] = 8
tempIndex = 4
right = 3
由于查找左边界,命中后继续向左。此时区间为 [3,3]:
mid = 3,nums[3] = 8
tempIndex = 3
right = 2
循环结束,左边界为 3。
2. 查找右边界
前两步同样会找到下标 4:
tempIndex = 4
left = 5
因为查找右边界,命中后继续向右。接下来 nums[5] = 10 > 8,搜索结束,右边界为 4。
最终返回:
[3,4]
十、复杂度分析
查找左边界和右边界分别执行一次二分查找,每次的时间复杂度都是 O(log n)。两次相加仍然是:
O(log n)
算法只使用了 left、right、mid 和 tempIndex 等变量,没有创建与数组长度相关的额外空间,因此空间复杂度为:
O(1)
十一、常见错误
1. 找到目标值后立即返回
这样只能得到目标值的任意一个位置,无法保证它是左边界或右边界。
2. 命中后没有保存当前位置
继续搜索可能会导致最终区间为空,因此必须先使用 tempIndex 保存当前候选答案。
3. 左右边界的搜索方向写反
查找左边界时应执行 right = mid - 1;查找右边界时应执行 left = mid + 1。
4. 使用线性扫描寻找边界
先二分找到目标值,再向左右逐个扫描,最坏情况下需要遍历整个数组,时间复杂度会退化为 O(n),不符合题目要求。
5. 使用 left < right 配合闭区间边界
本文采用闭区间 [left,right],因此循环条件必须是 left <= right。如果混用不同二分模板,容易漏掉只剩一个元素的情况。
十二、总结
这道题是在标准二分查找基础上增加了"边界搜索"。由于数组中可能出现多个连续的目标值,命中目标值后不能立即返回,而要记录当前位置并继续向对应方向搜索。
为了避免编写两套重复代码,可以通过 isLeft 参数复用一个 findMost 方法:isLeft 为 true 时继续向左收缩,寻找第一次出现的位置;为 false 时继续向右收缩,寻找最后一次出现的位置。