1. 题目解析与核心思路
1.1 题目要求拆解
LeetCode第34题"在排序数组中查找元素的第一个和最后一个位置"是二分查找算法的经典应用场景。题目给定一个按照升序排列的整数数组nums和一个目标值target,要求找出给定目标值在数组中的开始位置和结束位置。如果数组中不存在目标值,则返回[-1, -1]。
这个题目看似简单,但实际考察了以下几个核心能力:
- 对二分查找算法的深入理解
- 处理边界条件的严谨性
- 对有序数组特性的灵活运用
- 代码实现的简洁性和效率
1.2 输入输出示例分析
让我们通过几个典型示例来理解题目要求:
示例1:
输入:nums = [5,7,7,8,8,10], target = 8
输出:[3,4]
解释:数字8在数组中首次出现在索引3,最后一次出现在索引4
示例2:
输入:nums = [5,7,7,8,8,10], target = 6
输出:[-1,-1]
解释:数字6在数组中不存在
示例3:
输入:nums = [], target = 0
输出:[-1,-1]
解释:空数组自然不存在任何元素
1.3 算法复杂度要求
题目要求算法时间复杂度必须是O(log n)级别,这意味着我们必须使用二分查找或其变种来实现。暴力解法(线性扫描)虽然简单直观,但其O(n)的时间复杂度不符合题目要求。
2. 五种实现方案详解
2.1 标准二分查找+线性扩展
这是最容易想到的解决方案,分为两个步骤:
- 使用标准二分查找找到一个target的位置
- 从这个位置向左右线性扩展,找到边界
python复制def searchRange(nums, target):
# 标准二分查找
left, right = 0, len(nums)-1
pos = -1
while left <= right:
mid = (left + right) // 2
if nums[mid] == target:
pos = mid
break
elif nums[mid] < target:
left = mid + 1
else:
right = mid - 1
if pos == -1:
return [-1, -1]
# 线性扩展找边界
start = end = pos
while start > 0 and nums[start-1] == target:
start -= 1
while end < len(nums)-1 and nums[end+1] == target:
end += 1
return [start, end]
注意:这种方法在最坏情况下(整个数组都是target)会退化为O(n)时间复杂度,虽然平均表现不错,但严格来说不完全符合题目要求。
2.2 双二分查找法(最优解)
更优雅的解法是使用两次二分查找,分别寻找左边界和右边界。这是面试官最期望看到的解法。
python复制def searchRange(nums, target):
def find_left():
left, right = 0, len(nums)-1
res = -1
while left <= right:
mid = (left + right) // 2
if nums[mid] >= target:
right = mid - 1
else:
left = mid + 1
if nums[mid] == target:
res = mid
return res
def find_right():
left, right = 0, len(nums)-1
res = -1
while left <= right:
mid = (left + right) // 2
if nums[mid] <= target:
left = mid + 1
else:
right = mid - 1
if nums[mid] == target:
res = mid
return res
return [find_left(), find_right()]
这个实现的关键点在于:
- 查找左边界时,当nums[mid] == target时,我们继续向左搜索(right = mid - 1)
- 查找右边界时,当nums[mid] == target时,我们继续向右搜索(left = mid + 1)
- 两种情况下都记录最后一次匹配的位置
2.3 二分查找+边界收缩
这是一种变体,通过不断收缩边界来定位左右边界:
python复制def searchRange(nums, target):
def binary_search(left, right, find_left):
res = -1
while left <= right:
mid = (left + right) // 2
if nums[mid] == target:
res = mid
if find_left:
right = mid - 1
else:
left = mid + 1
elif nums[mid] < target:
left = mid + 1
else:
right = mid - 1
return res
left_pos = binary_search(0, len(nums)-1, True)
if left_pos == -1:
return [-1, -1]
right_pos = binary_search(left_pos, len(nums)-1, False)
return [left_pos, right_pos]
2.4 使用bisect模块(Python专属)
Python的标准库bisect提供了二分查找的实现,我们可以利用它:
python复制import bisect
def searchRange(nums, target):
left = bisect.bisect_left(nums, target)
if left == len(nums) or nums[left] != target:
return [-1, -1]
right = bisect.bisect_right(nums, target) - 1
return [left, right]
这种方法简洁高效,但仅限于Python环境,且面试时可能不允许使用内置库。
2.5 递归实现二分查找
为了展示二分查找的不同实现方式,这里给出递归版本的解决方案:
python复制def searchRange(nums, target):
def find_left(nums, target, left, right):
if left > right:
return -1
mid = (left + right) // 2
if nums[mid] == target and (mid == 0 or nums[mid-1] != target):
return mid
elif nums[mid] >= target:
return find_left(nums, target, left, mid-1)
else:
return find_left(nums, target, mid+1, right)
def find_right(nums, target, left, right):
if left > right:
return -1
mid = (left + right) // 2
if nums[mid] == target and (mid == len(nums)-1 or nums[mid+1] != target):
return mid
elif nums[mid] <= target:
return find_right(nums, target, mid+1, right)
else:
return find_right(nums, target, left, mid-1)
left_pos = find_left(nums, target, 0, len(nums)-1)
if left_pos == -1:
return [-1, -1]
right_pos = find_right(nums, target, left_pos, len(nums)-1)
return [left_pos, right_pos]
3. 关键点解析与边界处理
3.1 二分查找的变体理解
本题的关键在于理解标准二分查找的几种变体:
- 查找第一个等于target的元素
- 查找最后一个等于target的元素
- 查找第一个大于等于target的元素
- 查找第一个大于target的元素
在本题中,我们需要的是第1和第2种变体。理解这些变体的区别对于写出正确的二分查找代码至关重要。
3.2 边界条件处理
二分查找算法最容易出错的地方就是边界条件的处理。在本题中,需要特别注意以下几种边界情况:
- 空数组输入
- 数组中不存在target
- target是数组的第一个或最后一个元素
- 数组中所有元素都等于target
- 数组中只有一个元素且等于target
3.3 循环不变量的维护
在实现二分查找时,维护循环不变量是写出正确代码的关键。循环不变量指的是在循环的每次迭代中都保持为真的条件。例如:
- 搜索区间[left, right]始终包含可能的解
- 搜索区间在每次迭代后都会缩小
- 循环终止时,left和right的关系
4. 性能分析与优化
4.1 时间复杂度分析
所有基于二分查找的解决方案都具有O(log n)的时间复杂度,因为:
- 每次迭代都将搜索空间减半
- 最坏情况下需要进行⌈log₂n⌉次比较
- 即使进行两次二分查找(找左右边界),时间复杂度仍然是O(log n)
4.2 空间复杂度分析
迭代实现的二分查找空间复杂度是O(1),因为它只使用了固定数量的额外空间。递归实现的空间复杂度是O(log n),因为递归调用栈的深度与二分查找的深度相同。
4.3 实际运行效率比较
虽然所有二分查找变体的时间复杂度相同,但实际运行效率仍有差异:
- 双二分查找法(方案2)通常最快
- 使用bisect模块(方案4)在Python中也非常高效
- 递归实现(方案5)由于函数调用开销,通常较慢
5. 常见错误与调试技巧
5.1 典型错误模式
在解决这个问题时,常见的错误包括:
- 无限循环:由于边界条件处理不当导致循环无法终止
- 漏掉边界元素:没有正确处理数组的第一个或最后一个元素
- 错误的返回值:当target不存在时没有正确返回[-1, -1]
- 整数溢出:在计算mid时使用(left + right) // 2而不是left + (right - left) // 2
5.2 调试方法
调试二分查找算法时,可以采用以下方法:
- 打印每次迭代的left、right和mid值
- 使用小数组进行手动验证
- 检查循环不变式是否在每次迭代中都成立
- 特别注意循环终止条件
5.3 测试用例设计
为了全面测试代码的正确性,应该设计以下几类测试用例:
- 常规情况:target在数组中间
- 边界情况:target是第一个或最后一个元素
- 不存在情况:target不在数组中
- 全匹配情况:所有元素都等于target
- 空数组情况
- 单元素数组情况
6. 面试技巧与扩展思考
6.1 面试中的表达策略
在面试中解决这个问题时,建议采取以下策略:
- 先明确问题要求,确认输入输出格式
- 讨论暴力解法及其局限性
- 引出二分查找的优化思路
- 重点讨论边界条件的处理
- 逐步完善代码,边写边解释
6.2 相关问题扩展
掌握了这个问题后,可以尝试解决以下类似问题:
- 查找第一个大于target的元素
- 查找最后一个小于target的元素
- 在旋转排序数组中查找目标值
- 在二维矩阵中查找目标值
6.3 实际应用场景
二分查找的变体在实际中有广泛应用:
- 数据库索引查找
- 内存分配算法
- 游戏中的碰撞检测
- 科学计算中的根查找
在实际编码中,我发现处理二分查找边界条件最可靠的方法是:
- 明确循环不变量并始终坚持
- 使用小例子手动模拟算法执行
- 在代码中添加详细的注释说明每一步的意图
- 先写出标准二分查找,再根据需要修改为查找左右边界的变体
