第23课 - 二分查找变体
1. 学习目标
- 理解三种二分查找变体:掌握
lower_bound(下界)、upper_bound(上界)和bisect(二分插入点)的核心思想 - 掌握实现方法:能够独立编写基于二分查找的边界查找函数
- 学会应用场景:识别并应用边界查找解决实际问题(如统计元素出现次数、寻找插入位置等)
- 熟练使用标准库:熟练运用 Python 的
bisect模块高效解决问题
2. 核心概念
基础回顾
标准二分查找告诉我们目标值是否存在,但有时我们需要更多信息:
- 目标值第一次出现的位置?
- 目标值最后一次出现的位置?
- 目标值如果不存在,应该插入到哪里?
这就是二分查找变体要解决的问题。
三种变体解析
lower_bound(下界)
通俗解释:在有序数组中找到第一个大于或等于目标值的位置。
想象在电影院找座位:票上写的是“第5排”,你需要找到第一个空闲或可用的第5排座位。
def lower_bound(nums, target):
# 返回第一个 >= target 的位置
left, right = 0, len(nums)
while left < right:
mid = (left + right) // 2
if nums[mid] < target:
left = mid + 1 # 目标值在右半部分
else:
right = mid # 可能是答案,继续向左搜索
return left
upper_bound(上界)
通俗解释:在有序数组中找到第一个严格大于目标值的位置。
继续电影院的例子:现在要找第一个完全不在第5排的座位(即第6排或更后排的开始)。
def upper_bound(nums, target):
# 返回第一个 > target 的位置
left, right = 0, len(nums)
while left < right:
mid = (left + right) // 2
if nums[mid] <= target:
left = mid + 1 # 包含等于时,也要向右找
else:
right = mid
return left
bisect(二分插入点)
通俗解释:结合下界和上界,决定元素插入位置以保持数组有序。
Python 的 bisect 模块提供了两个核心函数:
bisect_left:等同于lower_boundbisect_right:等同于upper_bound
import bisect
# 示例:在数组中查找
nums = [1, 2, 2, 2, 3]
target = 2
# 查找下界(第一个 >= 2 的位置)
pos_left = bisect.bisect_left(nums, target) # 输出: 1
# 查找上界(第一个 > 2 的位置)
pos_right = bisect.bisect_right(nums, target) # 输出: 4
关键区别图示
数组: [1, 2, 2, 2, 3, 4]
目标值: 2
lower_bound: 位置 1 (第一个2的位置)
upper_bound: 位置 4 (最后一个2的下一个位置)
目标值2的范围: [1, 4) 左闭右开区间
3. 代码示例
from bisect import bisect_left, bisect_right
def count_occurrences(nums, target):
"""使用二分查找变体统计目标值出现次数"""
# 找到第一个出现位置
left_index = bisect_left(nums, target)
# 如果目标不存在,返回0
if left_index == len(nums) or nums[left_index] != target:
return 0
# 找到最后一个出现位置的下一个位置
right_index = bisect_right(nums, target)
# 出现次数 = 右边界 - 左边界
return right_index - left_index
def find_insert_position(nums, target):
"""找到元素应该插入的位置,保持数组有序"""
return bisect_left(nums, target)
def find_first_and_last(nums, target):
"""找到目标值的第一次和最后一次出现的位置"""
left = bisect_left(nums, target)
right = bisect_right(nums, target) - 1
if left <= right and nums[left] == target:
return (left, right)
return (-1, -1)
# 测试示例
if __name__ == "__main__":
nums = [1, 2, 2, 2, 3, 4, 5]
target = 2
print(f"数组: {nums}")
print(f"目标值: {target}")
print(f"出现次数: {count_occurrences(nums, target)}")
print(f"插入位置: {find_insert_position(nums, target)}")
first, last = find_first_and_last(nums, target)
print(f"第一次出现: 索引{first}, 最后一次出现: 索引{last}")
# 测试目标值不存在的情况
target_missing = 6
print(f"\n目标值 {target_missing} 出现次数: {count_occurrences(nums, target_missing)}")
print(f"目标值 {target_missing} 插入位置: {find_insert_position(nums, target_missing)}")
输出结果:
数组: [1, 2, 2, 2, 3, 4, 5]
目标值: 2
出现次数: 3
插入位置: 1
第一次出现: 索引1, 最后一次出现: 索引3
目标值 6 出现次数: 0
目标值 6 插入位置: 7
4. 实践练习
练习1:寻找插入位置(简单)
题目:给定一个排序数组和一个目标值,返回目标值应该插入的位置,使得数组仍然有序。
def search_insert(nums, target):
# 你的代码在这里
pass
# 测试用例
print(search_insert([1, 3, 5, 6], 5)) # 输出: 2
print(search_insert([1, 3, 5, 6], 2)) # 输出: 1
print(search_insert([1, 3, 5, 6], 7)) # 输出: 4
print(search_insert([1, 3, 5, 6], 0)) # 输出: 0
练习2:统计元素出现次数(中等)
题目:给定一个排序数组和一个目标值,统计目标值在数组中出现的次数。要求时间复杂度为 O(log n)。
def count_target(nums, target):
# 你的代码在这里
pass
# 测试用例
print(count_target([5, 7, 7, 8, 8, 10], 8)) # 输出: 2
print(count_target([5, 7, 7, 8, 8, 10], 6)) # 输出: 0
print(count_target([2, 2, 2, 2, 2], 2)) # 输出: 5
练习3:寻找第一个错误版本(挑战)
题目:假设你有多个版本的代码 [1, 2, ..., n],其中从某个版本开始出现错误。给定一个函数 isBadVersion(version) 判断某个版本是否错误,找到第一个错误的版本。
# 假设的API
def isBadVersion(version):
# 模拟:从版本4开始都是错误的
return version >= 4
def first_bad_version(n):
# 你的代码在这里
pass
# 测试用例
print(first_bad_version(5)) # 输出: 4
print(first_bad_version(10)) # 输出: 4
5. 常见错误
错误1:混淆下界和上界
# 错误写法:当目标存在时,upper_bound返回的是最后一个匹配元素的下一个位置
# 误以为upper_bound直接返回最后一个匹配位置
last_pos = upper_bound(nums, target) # 这是错误的!
# 正确写法:
last_pos = upper_bound(nums, target) - 1 # 需要减1
错误2:返回值索引越界
# 错误:未检查索引有效性
def find_target(nums, target):
index = lower_bound(nums, target)
return nums[index] # 可能越界!当target > 所有元素时
# 正确:先检查边界
def find_target(nums, target):
index = lower_bound(nums, target)
if index < len(nums) and nums[index] == target:
return nums[index]
return None # 或抛出异常
错误3:循环条件设置错误
# 错误:使用 left <= right 会导致死循环
while left <= right:
# 对于变体查找,通常使用 left < right
# 因为变体查找的是边界,而不是确切元素
# 正确:
while left < right:
mid = (left + right) // 2
# 注意更新逻辑的细微差别
错误4:忘记处理空数组
# 错误:未检查空数组情况
def lower_bound(nums, target):
left, right = 0, len(nums)
# 当nums为空时,len(nums)=0,right=0
while left < right: # 0 < 0 为False,不会进入循环
# 这是正确的处理
return left # 返回0
6. 小结
关键要点回顾
-
三种变体核心区别:
lower_bound:找第一个 ≥ target 的位置upper_bound:找第一个 > target 的位置bisect:结合两者,确定插入位置或统计范围
-
重要公式:
- 元素出现次数 =
upper_bound(target) - lower_bound(target) - 插入位置 =
lower_bound(target) - 最后一个出现位置 =
upper_bound(target) - 1
- 元素出现次数 =
-
Python 标准库:
bisect.bisect_left等价于lower_boundbisect.bisect_right等价于upper_boundbisect.insort可以在保持有序的情况下插入元素
-
应用场景:
- 统计有序数组中元素出现次数
- 查找元素的插入位置
- 解决区间查询问题
- 处理旋转排序数组等变种问题
与上下文关联
- 上接:第22课的基础二分查找,需要理解标准二分查找的原理
- 下启:第24课的插值搜索,将在二分查找的基础上学习如何优化搜索策略
进阶思考
当你完全掌握这些变体后,可以尝试解决:
- 在旋转排序数组中查找元素
- 查找元素在排序数组中的第一个和最后一个位置(LeetCode 34题)
- 在排序数组中查找元素的第一个和最后一个位置的变种问题
掌握二分查找变体是解决复杂搜索问题的关键钥匙!
练习编辑器
rust
Loading...