1. 问题分析与算法选择
遇到这道题时,我第一反应是:这不就是二分查找的变种吗?但仔细一看题目要求,发现事情没那么简单。题目要求我们找出目标值在排序数组中的起始和结束位置,而且必须用O(log n)的时间复杂度解决。这意味着我们不能简单地遍历数组,必须充分利用数组已排序的特性。
为什么选择二分查找?因为对于一个有序数组,二分查找可以在O(log n)时间内定位元素。但标准的二分查找只能找到一个匹配项的位置,而我们需要找到所有连续匹配项的边界。这让我想到可以分别用两次二分查找来定位左右边界。
2. 二分查找边界定位原理
2.1 左边界查找算法
查找左边界的核心思路是:找到第一个等于target的元素,并且这个元素的前一个元素小于target(或者它就是数组的第一个元素)。
具体实现时,我们需要注意几个关键点:
- 当nums[mid] == target时,不能立即返回,还需要检查它是否是第一个等于target的元素
- 如果nums[mid] < target,说明目标在右半部分
- 如果nums[mid] > target,说明目标在左半部分
python复制def searchLeft(nums, target):
left, right = 0, len(nums) - 1
while left <= right:
mid = (left + right) // 2
if nums[mid] == target:
if mid == 0 or nums[mid-1] < target:
return mid
else:
right = mid - 1
elif nums[mid] < target:
left = mid + 1
else:
right = mid - 1
return -1
2.2 右边界查找算法
查找右边界的思路类似,但要找的是最后一个等于target的元素,并且它的后一个元素大于target(或者它是数组的最后一个元素)。
实现时的注意事项:
- 当nums[mid] == target时,需要检查它是否是最后一个等于target的元素
- 移动指针的逻辑与左边界查找类似,但处理方式稍有不同
python复制def searchRight(nums, target):
left, right = 0, len(nums) - 1
while left <= right:
mid = (left + right) // 2
if nums[mid] == target:
if mid == len(nums)-1 or nums[mid+1] > target:
return mid
else:
left = mid + 1
elif nums[mid] < target:
left = mid + 1
else:
right = mid - 1
return -1
3. 完整解决方案实现
将左右边界的查找函数组合起来,就得到了完整的解决方案:
python复制class Solution:
def searchRange(self, nums: List[int], target: int) -> List[int]:
def searchLeft(nums, target):
left, right = 0, len(nums) - 1
while left <= right:
mid = (left + right) // 2
if nums[mid] == target:
if mid == 0 or nums[mid-1] < target:
return mid
else:
right = mid - 1
elif nums[mid] < target:
left = mid + 1
else:
right = mid - 1
return -1
def searchRight(nums, target):
left, right = 0, len(nums) - 1
while left <= right:
mid = (left + right) // 2
if nums[mid] == target:
if mid == len(nums)-1 or nums[mid+1] > target:
return mid
else:
left = mid + 1
elif nums[mid] < target:
left = mid + 1
else:
right = mid - 1
return -1
return [searchLeft(nums, target), searchRight(nums, target)]
4. 算法复杂度分析
这个解决方案的时间复杂度是O(log n),因为我们进行了两次二分查找,每次都是O(log n)的时间复杂度。空间复杂度是O(1),因为我们只使用了常数级别的额外空间。
为什么不是O(2 log n)?因为在时间复杂度分析中,常数因子通常被忽略,所以仍然是O(log n)。
5. 边界条件与特殊测试用例
在实际编码中,我们需要特别注意以下几种边界情况:
- 空数组:nums = [], target = 0 → 返回[-1, -1]
- 数组中只有一个元素且等于target:nums = [1], target = 1 → 返回[0, 0]
- 数组中所有元素都等于target:nums = [2,2,2], target = 2 → 返回[0, 2]
- 目标值不在数组中:nums = [1,3,5], target = 2 → 返回[-1, -1]
- 目标值比所有元素都大/小:nums = [1,3,5], target = 0/6 → 返回[-1, -1]
6. 常见错误与调试技巧
在实现这个算法时,我遇到过几个典型的错误:
-
无限循环:由于边界条件处理不当,导致while循环无法终止。解决方法是在每次迭代中确保搜索区间在缩小。
-
边界判断错误:比如在查找左边界时,忘记检查mid == 0的情况。解决方法是在纸上画出各种可能的数组情况,确保所有边界都被覆盖。
-
整数溢出:在计算mid时使用(left + right) // 2,对于非常大的数组可能会导致整数溢出。更安全的写法是left + (right - left) // 2。
调试技巧:
- 使用小数组手动模拟算法执行过程
- 打印每次循环的left, right, mid值
- 针对特定测试用例进行单步调试
7. 算法优化与变种
虽然这个解决方案已经满足题目要求,但我们还可以考虑一些优化:
-
合并部分逻辑:可以尝试将左右边界的查找合并到一个函数中,通过参数控制查找方向。
-
提前终止:如果在查找左边界时已经返回-1,可以直接返回[-1, -1],无需再查找右边界。
-
模板化二分查找:可以建立一个通用的二分查找模板,适用于各种变种问题。
对于类似的变种问题,比如:
- 查找第一个大于等于target的元素
- 查找最后一个小于等于target的元素
- 统计target在数组中出现的次数
都可以使用类似的二分查找变种来解决。
8. 实际应用场景
这种查找元素范围的算法在实际开发中有很多应用场景:
- 日志分析:查找特定时间范围内的日志条目
- 数据库查询:在有序索引中快速定位数据范围
- 统计分析:计算某个值在有序数据集中的分布情况
- 版本控制系统:查找特定版本号范围内的变更记录
理解这种算法的核心思想,可以帮助我们在面对类似问题时快速找到解决方案。
9. 其他解法比较
除了二分查找,这个问题还有其他解法:
- 线性扫描:时间复杂度O(n),不符合题目要求
- 标准二分查找+线性扩展:先找到一个target位置,然后向两边扩展,最坏情况下时间复杂度O(n)
- 三分查找:理论上可以同时查找左右边界,但实现复杂且优势不明显
相比之下,两次二分查找的方法在时间复杂度和实现难度上达到了很好的平衡。
10. 编码实践建议
在实现这类算法时,我有几点建议:
- 先写伪代码,理清思路再编码
- 使用有意义的变量名,如leftIdx, rightIdx等
- 添加详细的注释,特别是边界条件的处理
- 编写完备的测试用例,覆盖各种边界情况
- 考虑使用递归或迭代两种方式实现,比较优缺点
对于二分查找这类基础算法,建议多练习几种不同的实现方式,直到能够快速准确地写出无bug的代码。