给你一个按照非递减顺序排列的整数数组 nums,和一个目标值 target。请你找出给定目标值在数组中的开始位置和结束位置。
如果数组中不存在目标值 target,返回 [-1, -1]。
你必须设计并实现时间复杂度为 O(log n) 的算法解决此问题。
示例 1:
输入: nums = [5,7,7,8,8,10], target = 8
输出: [3,4]
示例 2:
输入: nums = [5,7,7,8,8,10], target = 6
输出: [-1,-1]
示例 3:
输入: nums = [], target = 0
输出: [-1,-1]
提示:
0 <= nums.length <= 105-109 <= nums[i] <= 109nums是一个非递减数组-109 <= target <= 109
1. 生活案例:在学生名册里找“姓张的”范围
想象你手里有一份按姓氏拼音排好序的学生名册,里面有一群姓“张”的同学。
-
任务:老板想知道姓“张”的同学从第几个人开始,到第几个人结束。
-
过程:
- 你翻开名册中间,发现是个姓“张”的。
- 找起点:虽然你找到了一个姓张的,但你不知道他是不是第一个。所以你先拿个本子记下他的位置,然后继续往名册的左边翻,看看前面还有没有姓张的。
- 找终点:同样的,你记下那个位置后,再往名册的右边翻,看看后面还有没有姓张的。
最后,你记下的两个最极端的“张”的位置,就是我们要的区间。
2. 代码实现与详细注释
这是你图片中的代码,我为你添加了详细的中文注释。这段代码复用了同一个二分查找函数 findBound,通过一个开关 isFirst 来控制是找左边界还是右边界。
JavaScript
/**
* @param {number[]} nums
* @param {number} target
* @return {number[]}
*/
var searchRange = function(nums, target) {
// 这是一个辅助函数:用来找“边界”
// isFirst 为 true 找第一个位置,false 找最后一个位置
let findBound = (isFirst) => {
let left = 0;
let right = nums.length - 1;
let bound = -1; // 记录找到的最新边界
while (left <= right) {
let mid = Math.floor(left + (right - left) / 2);
if (nums[mid] === target) {
// 【核心逻辑】:找到了目标,先存起来
bound = mid;
if (isFirst) {
// 如果我们要找第一个,那就继续往左边切 (收缩右边界)
right = mid - 1;
} else {
// 如果我们要找最后一个,那就继续往右边切 (收缩左边界)
left = mid + 1;
}
} else if (nums[mid] < target) {
left = mid + 1;
} else {
right = mid - 1;
}
}
return bound;
}
// 调用两次:一次找头,一次找尾
let first = findBound(true);
let last = findBound(false);
return [first, last];
};
3. 核心原理解析
为什么不直接用 indexOf 和 lastIndexOf?
虽然 JavaScript 自带这两个方法,但在最坏情况下(比如数组里全是 8),它们的效率是 。题目明确要求 ,这就逼着我们必须使用二分查找来跳跃式搜索。
关键点:找到 target 后的“贪婪”搜索
正常的二分查找在 nums[mid] === target 时就直接返回了。但这道题我们需要:
- 找左边界:即便找到了,也要假设左边可能还有,所以
right = mid - 1。 - 找右边界:即便找到了,也要假设右边可能还有,所以
left = mid + 1。
这种思路被称为“二分查找左/右侧边界”,是处理重复元素排序数组的标准套路。
复杂度分析
- 时间复杂度:。我们执行了两遍二分查找,总时间仍然是对数级别的。
- 空间复杂度:。只使用了常数个指针变量。
总结
这道题教会我们:二分查找不仅能找“存在”,还能找“范围” 。只要在相等时调整左右指针的收缩方向,就能像挤牙膏一样挤出目标元素的精准边界。