1. 题目解析与解题思路
在排序数组中查找元素的第一个和最后一个位置,这道题目看似简单,但隐藏着不少值得深入探讨的细节。作为一名经常刷题的开发者,我发现这道题是理解二分查找边界条件的绝佳案例。
题目要求我们找到一个已排序数组中特定目标值的起始和结束位置。如果目标值不存在于数组中,则返回[-1, -1]。关键在于时间复杂度必须为O(log n),这直接指向了二分查找算法。
1.1 为什么普通二分查找不够用?
传统的二分查找在找到目标值后会立即返回,但这道题需要找到目标值的边界。举个例子,对于数组[5,7,7,8,8,10]和目标值8,普通二分查找可能找到任意一个8的位置就返回了,而我们需要的是第一个8和最后一个8的位置。
提示:处理边界问题时,建议在纸上画出数组和指针移动的过程,这比单纯在脑海中想象要直观得多。
1.2 左右边界查找的核心思路
解决这个问题的关键在于分别实现查找左边界和右边界的函数:
- 查找左边界:即使找到目标值,也继续向左搜索
- 查找右边界:即使找到目标值,也继续向右搜索
这种"继续搜索"的思路是解决边界问题的核心。我在最初实现时也犯了一个常见错误——在找到目标值后就停止搜索,这导致无法正确处理重复元素的情况。
2. 代码实现与细节分析
2.1 右边界查找实现
让我们先看右边界的查找实现:
cpp复制int RightRange(vector<int>& nums, int target) {
int left = 0;
int right = nums.size()-1; // 采用闭区间[left,right]
int rightRange = -2; // 初始值设为-2,用于特殊判断
while(left <= right) {
int mid = left + (right - left)/2; // 防止溢出
if(nums[mid] <= target) {
left = mid + 1;
rightRange = left - 1; // 记录可能的右边界
}
else {
right = mid - 1;
}
}
return rightRange;
}
这段代码有几个关键点值得注意:
- 使用闭区间[left, right]而不是开区间,这样更直观且不易出错
- rightRange初始设为-2而不是-1,这样可以区分"未找到"和"找到但位置为-1"的情况
- 即使nums[mid] == target,也继续向右搜索(left = mid + 1)
2.2 左边界查找实现
左边界查找与右边界类似,但方向相反:
cpp复制int LeftRange(vector<int>& nums, int target) {
int left = 0;
int right = nums.size()-1;
int leftRange = -2;
while(left <= right) {
int mid = left + (right - left)/2;
if(nums[mid] >= target) {
right = mid - 1;
leftRange = right + 1; // 记录可能的左边界
}
else {
left = mid + 1;
}
}
return leftRange;
}
这里的关键区别在于当nums[mid] == target时,我们继续向左搜索(right = mid - 1),而不是向右。
2.3 边界条件处理
主函数需要处理各种边界情况:
cpp复制vector<int> searchRange(vector<int>& nums, int target) {
int n = nums.size();
int leftBorder = LeftRange(nums, target);
int rightBorder = RightRange(nums, target);
// 处理各种无效情况
if(leftBorder == -2 || rightBorder == -2 ||
leftBorder >= n || rightBorder >= n ||
nums[leftBorder] != target || nums[rightBorder] != target) {
return {-1, -1};
}
return {leftBorder, rightBorder};
}
这里检查的条件包括:
- 边界值仍为初始值-2(未找到)
- 边界值超出数组范围
- 边界位置的值不等于目标值
3. 常见问题与调试技巧
3.1 为什么我的代码在某些情况下会出错?
在实现这道题时,有几个常见的陷阱:
- 数组为空的情况:必须首先检查nums.size()是否为0
- 目标值不在数组中但位于数组值范围内:如[1,3,5,7], target=4
- 目标值小于数组最小值或大于最大值:如[1,3,5,7], target=0或8
注意:在提交代码前,务必测试这些边界情况。我在第一次提交时就因为没有处理空数组而失败了。
3.2 如何验证代码的正确性?
我推荐使用以下测试用例进行验证:
- 空数组:[], target=0 → [-1,-1]
- 单元素匹配:[5], target=5 → [0,0]
- 单元素不匹配:[5], target=3 → [-1,-1]
- 多元素无重复:[1,3,5,7], target=3 → [1,1]
- 多元素有重复:[1,3,3,5,7], target=3 → [1,2]
- 目标值在范围内但不匹配:[1,3,5,7], target=4 → [-1,-1]
- 目标值小于最小值:[1,3,5,7], target=0 → [-1,-1]
- 目标值大于最大值:[1,3,5,7], target=8 → [-1,-1]
3.3 调试技巧分享
当二分查找出现问题时,我通常会:
- 在循环中添加打印语句,输出left, right, mid的值
- 在纸上画出数组和指针位置
- 使用小数组(3-5个元素)进行手动模拟
- 特别注意循环终止条件(left <= right还是left < right)
4. 算法优化与变种思考
4.1 能否合并左右边界的查找?
理论上可以只写一个二分查找函数,通过参数控制查找左边界还是右边界。但实践中,分开写两个函数更清晰,也更容易调试。合并后的代码可能会变得复杂,增加出错概率。
4.2 其他实现方式
除了本文介绍的方法,还可以:
- 先找到任意一个目标值位置,然后向两边扩展查找边界
- 使用标准库的lower_bound和upper_bound函数(如果语言支持)
不过第一种方法在最坏情况下会退化为O(n),第二种方法可能不符合某些面试要求。
4.3 时间复杂度分析
我们的解法进行了两次二分查找(左边界和右边界),每次都是O(log n),因此总时间复杂度仍然是O(log n)。空间复杂度是O(1),只使用了常数个额外变量。
5. 实际应用与扩展
这道题看似简单,但它所涉及的边界处理思想在实际开发中非常有用。比如:
- 数据库查询中的范围查找
- 日志分析中查找特定时间范围的事件
- 游戏开发中查找满足条件的物品范围
理解并掌握这种精确控制二分查找行为的技巧,可以帮助我们解决更多类似的问题。例如,LeetCode上的"在排序数组中查找元素的第一个和最后一个位置"、"搜索插入位置"、"寻找峰值"等问题都可以用类似的思路解决。
我在实际工作中就曾用类似的技巧优化过一个日志分析工具的性能,将原本O(n)的查询优化到了O(log n),效果非常显著。