题目
给定一个按照非递减顺序排列的整数数组 nums,以及一个目标值 target,要求找出 target 在数组中的开始位置和结束位置。
如果数组中不存在 target,返回 [-1, -1]。
题目要求时间复杂度为 O(log n),因此不能线性扫描,应该使用二分查找。
例如:
nums = [5, 7, 7, 8, 8, 10]
target = 8
返回 [3, 4]
核心思路:把区间问题转成两个边界问题
要找到 target 的左右边界,可以分别寻找两个位置:
左边界 = 第一个 >= target 的位置
右边界 = 第一个 > target 的位置 - 1
以 nums = [5, 7, 7, 8, 8, 10],target = 8 为例:
第一个 >= 8 的位置是 3
第一个 > 8 的位置是 5
所以右边界是 5 - 1 = 4
因此答案是 [3, 4]。
这种思路本质上是同时使用了 lower_bound 和 upper_bound:
lower_bound(target):第一个>= target的位置。upper_bound(target):第一个> target的位置。
lower_bound 返回的是插入位置
二分查找中一个很容易出错的点是:边界函数返回的不是"找到元素的下标",而是"第一个满足条件的位置",也可以理解成插入位置。
例如:
nums = [1]
target = 2
第一个 >= 2 的位置并不存在于数组内部,但 2 按顺序应该插在数组末尾,所以返回值应该是:
nums.length = 1
这个 1 虽然不能用来访问 nums[1],但它是一个有意义的边界值。调用方可以用它判断 target 是否比所有元素都大:
if (l >= nums.length) {
return new int[] { -1, -1 };
}
如果在二分函数内部把 nums.length 改成 -1,主函数就无法正确判断这种情况,反而会破坏边界语义。
为什么不用 target + 1
一种常见写法是:
int left = lower(nums, target);
int right = lower(nums, target + 1) - 1;
这个思路在普通数据下可行,因为"第一个 >= target + 1 的位置"相当于"第一个 > target 的位置"。
但 Java 的 int 有上界。如果 target == Integer.MAX_VALUE,那么 target + 1 会发生溢出。因此,更稳的做法是直接写一个能区分两种边界的函数:
- 找第一个
>= target时,左侧条件是nums[mid] < target。 - 找第一个
> target时,左侧条件是nums[mid] <= target。
二分框架完全相同,只是判断条件不同。
代码实现
class Solution {
public int[] searchRange(int[] nums, int target) {
int n = nums.length;
if (n == 0) {
return new int[] { -1, -1 };
}
int left = boundary(nums, target, true);
if (left >= n || nums[left] != target) {
return new int[] { -1, -1 };
}
int right = boundary(nums, target, false) - 1;
return new int[] { left, right };
}
// isLowerBound = true -> 第一个 >= target 的位置
// isLowerBound = false -> 第一个 > target 的位置
public int boundary(int[] nums, int target, boolean isLowerBound) {
int left = -1;
int right = nums.length;
while (right > left + 1) {
int mid = left + (right - left) / 2;
if (isLowerBound ? nums[mid] < target : nums[mid] <= target) {
left = mid;
} else {
right = mid;
}
}
return right;
}
}
二分不变量
这份代码采用开区间哨兵写法:
left = -1
right = nums.length
left 和 right 可以理解为两个虚拟哨兵:
left左侧表示已经确认不满足目标边界的位置。right右侧表示已经确认满足目标边界的位置。
当寻找第一个 >= target 的位置时:
nums[left] < target
nums[right] >= target
当寻找第一个 > target 的位置时:
nums[left] <= target
nums[right] > target
注意:left = -1 和 right = nums.length 都是虚拟位置,不能直接访问。循环结束时 right == left + 1,此时 right 就是第一个满足条件的位置。
边界检查
拿到左边界后,必须先判断它是否是有效下标:
if (left >= n || nums[left] != target) {
return new int[] { -1, -1 };
}
这一步覆盖了两种情况:
left >= n:说明target比数组中所有元素都大。nums[left] != target:说明数组中不存在target,只是找到了一个更大的插入位置。
例如:
nums = [1], target = 2
左边界会返回 1,也就是 nums.length,因此直接返回 [-1, -1]。
复杂度
代码执行两次二分查找,每次时间复杂度为 O(log n),因此整体时间复杂度仍然是:
O(log n)
只使用了常量级变量,空间复杂度为:
O(1)
总结
这道题的关键不是单纯"写一个二分",而是明确边界函数的语义:
lower_bound返回第一个>= target的位置。upper_bound返回第一个> target的位置。- 返回值可以等于
nums.length,表示目标值应该插在数组末尾。 - 不要在边界函数内部把
nums.length改成-1,否则会破坏插入位置语义。 - 为避免
target + 1溢出,可以用一个布尔参数区分>= target和> target两种边界。
以后遇到"在有序数组中找某个值的范围"这类问题,可以优先考虑把范围拆成两个边界:左边界找第一个 >= target,右边界找第一个 > target 后再减一。