目录
- 二分查找算法基础
- [题目一:搜索旋转排序数组(LeetCode 33)](#题目一:搜索旋转排序数组(LeetCode 33))
- [题目二:在排序数组中查找元素的第一个和最后一个位置(LeetCode 34)](#题目二:在排序数组中查找元素的第一个和最后一个位置(LeetCode 34))
- [题目三:寻找旋转排序数组中的最小值(LeetCode 153)](#题目三:寻找旋转排序数组中的最小值(LeetCode 153))
- 二分查找变式与技巧总结
- 实战练习与扩展
1. 二分查找算法基础
二分查找(Binary Search)是计算机科学中最经典的算法之一,广泛应用于有序数组的高效查找。其核心思想是通过不断缩小搜索范围,将查找时间复杂度从线性 O ( n ) O(n) O(n) 优化到对数 O ( log n ) O(\log n) O(logn)。
1.1 基本原理与适用条件
二分查找的基本原理基于有序性 和单调性两个关键前提:
- 有序性:数组必须是有序的(通常为升序)
- 单调性:数组元素具有单调递增或递减的特性
算法通过以下步骤实现:
- 初始化左右指针:
left = 0,right = n-1 - 计算中间位置:
mid = left + (right - left) // 2(防止溢出) - 比较
nums[mid]与target:- 若相等,返回
mid - 若
nums[mid] < target,目标在右侧,更新left = mid + 1 - 若
nums[mid] > target,目标在左侧,更新right = mid - 1
- 若相等,返回
- 重复直到
left > right,返回 -1
1.2 时间复杂度分析
二分查找的时间复杂度为 O ( log n ) O(\log n) O(logn),其中 n n n 是数组长度。这是因为每次迭代都将搜索范围减半:
- 第1次迭代:范围 n n n
- 第2次迭代:范围 n / 2 n/2 n/2
- 第3次迭代:范围 n / 4 n/4 n/4
- ...
- 第 k k k 次迭代:范围 n / 2 k − 1 n/2^{k-1} n/2k−1
当范围缩小到1时停止,即 n / 2 k − 1 = 1 n/2^{k-1} = 1 n/2k−1=1,解得 k = log 2 n + 1 k = \log_2 n + 1 k=log2n+1,故时间复杂度为 O ( log n ) O(\log n) O(logn)。
空间复杂度为 O ( 1 ) O(1) O(1),仅使用常数个变量。
1.3 标准二分查找模板
二分查找有两种常见的区间定义方式,每种方式都有对应的模板:
模板一:闭区间 [left, right](直观易懂,推荐新手使用)
python
def binary_search(nums, target):
"""
在有序数组nums中查找target,返回其索引,若不存在返回-1
"""
left, right = 0, len(nums) - 1
while left <= right: # 闭区间:当left > right时区间为空
mid = left + (right - left) // 2 # 防止溢出
if nums[mid] == target:
return mid
elif nums[mid] < target:
left = mid + 1 # 目标在右侧,排除mid及左侧
else: # nums[mid] > target
right = mid - 1 # 目标在左侧,排除mid及右侧
return -1 # 未找到
模板二:左闭右开区间 [left, right)(防越界,推荐进阶使用)
python
def binary_search_open(nums, target):
"""
左闭右开区间实现
"""
left, right = 0, len(nums) # 注意:right初始为len(nums),不包含
while left < right: # 左闭右开:当left == right时区间为空
mid = left + (right - left) // 2
if nums[mid] == target:
return mid
elif nums[mid] < target:
left = mid + 1 # 目标在右侧
else:
right = mid # 目标在左侧,注意这里不是mid-1
return -1
注: 两种模板的核心区别在于区间定义和指针更新:
- 闭区间:
right = mid - 1排除mid - 左闭右开:
right = mid排除mid(mid不在新区间内)
选择哪种模板取决于个人习惯,关键在于保持一致性,不要混用。
1.4 适用场景与局限性
二分查找适用于以下场景:
- 静态有序数组:数组元素不频繁变动,且已排序
- 查找单个元素:返回目标值的位置或确认不存在
- 数据量较大:线性查找不可行时
局限性包括:
- 必须有序:对无序数组需要先排序
- 内存连续:要求数组在内存中连续存储(链表不适用)
- 查找单个值:不适用于区间查找、最值查找等变式(需改造算法)
2. 题目一:搜索旋转排序数组(LeetCode 33)
2.1 题目描述
题目链接 :LeetCode 33. Search in Rotated Sorted Array
题目要求:
- 给定一个升序排列的整数数组
nums,数组在某个未知下标k处进行了旋转 - 旋转后的数组形式为:
[nums[k], nums[k+1], ..., nums[n-1], nums[0], nums[1], ..., nums[k-1]] - 数组中没有重复元素
- 在 O ( log n ) O(\log n) O(logn) 时间复杂度内找到目标值
target的索引,不存在则返回 -1
示例:
输入:nums = [4,5,6,7,0,1,2], target = 0
输出:4
输入:nums = [4,5,6,7,0,1,2], target = 3
输出:-1
2.2 问题分析与核心思路
旋转排序数组的特点:整体无序但局部有序。具体来说:
- 数组被分成两个严格递增的子数组
- 左子数组的所有元素 > 右子数组的所有元素(因为原数组严格递增)
关键洞察 :在旋转数组中,对于任意中间位置 mid,其左侧或右侧必有一侧是完全有序的。
证明:
- 设旋转点为
k,数组分为[0, k-1]和[k, n-1]两段升序 - 任取
mid:- 若
mid < k:[0, mid]完全在左段,有序 - 若
mid >= k:[mid, n-1]完全在右段,有序
- 若
因此,二分查找仍适用,但判断逻辑需调整:
- 判断
mid所在侧是否有序 - 若有序,检查
target是否在该有序区间内 - 根据结果缩小搜索范围
2.3 算法步骤详解
步骤1:初始化指针
left = 0, right = len(nums) - 1
步骤2:进入循环(while left <= right)
- 计算
mid = left + (right - left) // 2 - 若
nums[mid] == target,直接返回mid - 判断左侧是否有序:
- 情况A :
nums[left] <= nums[mid](左侧有序)- 若
nums[left] <= target < nums[mid]:target在左侧有序区间内,更新right = mid - 1 - 否则:
target在右侧,更新left = mid + 1
- 若
- 情况B :
nums[left] > nums[mid](右侧有序)- 若
nums[mid] < target <= nums[right]:target在右侧有序区间内,更新left = mid + 1 - 否则:
target在左侧,更新right = mid - 1
- 若
- 情况A :
步骤3:循环结束未找到,返回 -1
2.4 边界条件处理
关键细节:
- 等号处理 :
- 判断左侧有序时用
nums[left] <= nums[mid],包含left == mid的情况 - 检查
target范围时,注意排除nums[mid](已判断不相等)
- 判断左侧有序时用
- 数组长度 :
- 空数组直接返回 -1
- 单元素数组直接比较
- 旋转点位置 :
- 旋转点在开头(完全有序):算法仍正确
- 旋转点在结尾(完全有序):算法仍正确
2.5 Python代码实现
python
def search_rotated(nums, target):
"""
在旋转排序数组中搜索目标值
时间复杂度:O(log n),空间复杂度:O(1)
"""
if not nums:
return -1
left, right = 0, len(nums) - 1
while left <= right:
mid = left + (right - left) // 2
if nums[mid] == target:
return mid
# 判断左侧是否有序
if nums[left] <= nums[mid]:
# 左侧有序
if nums[left] <= target < nums[mid]:
# target在左侧有序区间内
right = mid - 1
else:
# target在右侧
left = mid + 1
else:
# 右侧有序
if nums[mid] < target <= nums[right]:
# target在右侧有序区间内
left = mid + 1
else:
# target在左侧
right = mid - 1
return -1
注: 代码中 nums[left] <= target < nums[mid] 的等号处理:
nums[left] <= target:包含target == nums[left]的情况target < nums[mid]:排除target == nums[mid](已在前面的if判断)
2.6 复杂度分析
- 时间复杂度 : O ( log n ) O(\log n) O(logn)
- 每次迭代范围减半,最多 log 2 n \log_2 n log2n 次
- 空间复杂度 : O ( 1 ) O(1) O(1)
- 只使用常数个变量
2.7 测试用例验证
python
# 测试用例
test_cases = [
([4,5,6,7,0,1,2], 0, 4),
([4,5,6,7,0,1,2], 3, -1),
([1], 0, -1),
([1], 1, 0),
([1,3], 3, 1),
([3,1], 1, 1),
([1,2,3,4,5], 3, 2), # 未旋转
]
for nums, target, expected in test_cases:
result = search_rotated(nums, target)
assert result == expected, f"Failed: nums={nums}, target={target}, got {result}, expected {expected}"
3. 题目二:在排序数组中查找元素的第一个和最后一个位置(LeetCode 34)
3.1 题目描述
题目链接 :LeetCode 34. Find First and Last Position of Element in Sorted Array
题目要求:
- 给定一个按照非递减顺序排列的整数数组
nums,和一个目标值target - 找出目标值在数组中的开始位置和结束位置
- 若不存在,返回
[-1, -1] - 要求算法时间复杂度为 O ( log n ) O(\log n) O(logn)
示例:
输入:nums = [5,7,7,8,8,10], target = 8
输出:[3,4]
输入:nums = [5,7,7,8,8,10], target = 6
输出:[-1,-1]
输入:nums = [], target = 0
输出:[-1,-1]
3.2 问题分析与核心思路
本题本质上是查找左边界和右边界的问题:
- 左边界 :第一个等于
target的位置 - 右边界 :最后一个等于
target的位置
关键挑战:数组可能包含重复元素,普通二分查找找到任意一个匹配位置后不能直接返回,需要继续搜索边界。
解决方案:分别实现两个函数:
find_left_bound(nums, target):查找左边界find_right_bound(nums, target):查找右边界
两种实现思路:
- 思路一 :先找到任意一个匹配位置,然后向两侧扩展(最坏 O ( n ) O(n) O(n),不符合要求)
- 思路二 :改造二分查找,直接搜索边界( O ( log n ) O(\log n) O(logn),符合要求)
我们采用思路二,核心在于:
- 查找左边界:当
nums[mid] >= target时,收缩右边界 - 查找右边界:当
nums[mid] <= target时,收缩左边界
3.3 左边界查找算法
目标 :找到第一个等于 target 的位置,即最小的 i 使得 nums[i] >= target
算法步骤:
- 初始化:
left = 0,right = len(nums) - 1(闭区间) - 循环条件:
while left <= right - 计算
mid - 比较:
- 若
nums[mid] >= target:目标可能在左侧(包括mid),更新right = mid - 1 - 若
nums[mid] < target:目标一定在右侧,更新left = mid + 1
- 若
- 循环结束后:
- 检查
left是否越界:left >= len(nums) - 检查
nums[left]是否等于target - 若都满足,返回
left,否则返回 -1
- 检查
关键点:
- 循环结束时,
left指向第一个大于等于 target 的位置 - 需要验证该位置的值确实等于
target
3.4 右边界查找算法
目标 :找到最后一个等于 target 的位置,即最大的 i 使得 nums[i] <= target
算法步骤:
- 初始化:
left = 0,right = len(nums) - 1 - 循环条件:
while left <= right - 计算
mid - 比较:
- 若
nums[mid] <= target:目标可能在右侧(包括mid),更新left = mid + 1 - 若
nums[mid] > target:目标一定在左侧,更新right = mid - 1
- 若
- 循环结束后:
- 检查
right是否越界:right < 0 - 检查
nums[right]是否等于target - 若都满足,返回
right,否则返回 -1
- 检查
关键点:
- 循环结束时,
right指向最后一个小于等于 target 的位置 - 需要验证该位置的值确实等于
target
3.5 Python代码实现
python
def search_range(nums, target):
"""
查找目标值的起始和结束位置
时间复杂度:O(log n),空间复杂度:O(1)
"""
def find_left_bound(nums, target):
"""查找左边界"""
left, right = 0, len(nums) - 1
while left <= right:
mid = left + (right - left) // 2
if nums[mid] >= target:
# 收缩右边界,继续向左搜索
right = mid - 1
else:
# nums[mid] < target,目标在右侧
left = mid + 1
# 验证左边界
if left < len(nums) and nums[left] == target:
return left
return -1
def find_right_bound(nums, target):
"""查找右边界"""
left, right = 0, len(nums) - 1
while left <= right:
mid = left + (right - left) // 2
if nums[mid] <= target:
# 收缩左边界,继续向右搜索
left = mid + 1
else:
# nums[mid] > target,目标在左侧
right = mid - 1
# 验证右边界
if right >= 0 and nums[right] == target:
return right
return -1
# 处理空数组
if not nums:
return [-1, -1]
left_idx = find_left_bound(nums, target)
# 若左边界未找到,直接返回[-1, -1]
if left_idx == -1:
return [-1, -1]
right_idx = find_right_bound(nums, target)
return [left_idx, right_idx]
注: 代码的两个辅助函数采用对称设计:
find_left_bound:nums[mid] >= target时收缩右边界,最终返回leftfind_right_bound:nums[mid] <= target时收缩左边界,最终返回right
3.6 复杂度分析
- 时间复杂度 : O ( log n ) O(\log n) O(logn)
- 两次二分查找,每次 O ( log n ) O(\log n) O(logn)
- 总时间复杂度仍为 O ( log n ) O(\log n) O(logn)
- 空间复杂度 : O ( 1 ) O(1) O(1)
3.7 测试用例验证
python
# 测试用例
test_cases = [
([5,7,7,8,8,10], 8, [3,4]),
([5,7,7,8,8,10], 6, [-1,-1]),
([], 0, [-1,-1]),
([1], 1, [0,0]),
([1,1,1,1], 1, [0,3]),
([1,2,3,4,5], 3, [2,2]),
]
for nums, target, expected in test_cases:
result = search_range(nums, target)
assert result == expected, f"Failed: nums={nums}, target={target}, got {result}, expected {expected}"
4. 题目三:寻找旋转排序数组中的最小值(LeetCode 153)
4.1 题目描述
题目链接 :LeetCode 153. Find Minimum in Rotated Sorted Array
题目要求:
- 给定一个升序排列的整数数组
nums,数组在某个未知下标k处进行了旋转 - 数组中没有重复元素
- 在 O ( log n ) O(\log n) O(logn) 时间复杂度内找到数组中的最小元素
示例:
输入:nums = [3,4,5,1,2]
输出:1
输入:nums = [4,5,6,7,0,1,2]
输出:0
输入:nums = [11,13,15,17]
输出:11
4.2 问题分析与核心思路
旋转排序数组的特点:
- 数组被分成两个严格递增的子数组:左子数组和右子数组
- 左子数组的所有元素 > 右子数组的所有元素(关键性质)
- 最小值位于右子数组的第一个位置
关键洞察 :最小值将数组分为两个有序部分,且最小值小于其左侧和右侧的元素。
二分查找策略:
- 比较
nums[mid]与nums[right]:- 若
nums[mid] < nums[right]:最小值在左侧(包括mid),更新right = mid - 若
nums[mid] > nums[right]:最小值在右侧,更新left = mid + 1
- 若
- 当
left == right时,找到最小值
为什么比较 nums[mid] 与 nums[right]?
- 在旋转数组中,右子数组是有序的,且
nums[right]是右子数组的最大值 - 比较可以判断
mid位于哪个子数组:nums[mid] < nums[right]:mid在右子数组,最小值在mid左侧(包括mid)nums[mid] > nums[right]:mid在左子数组,最小值在mid右侧
4.3 算法步骤详解
步骤1:初始化指针
left = 0, right = len(nums) - 1
步骤2:进入循环(while left < right)
- 注意:这里使用
<而不是<=,因为当left == right时已找到最小值
- 计算
mid = left + (right - left) // 2 - 比较
nums[mid]与nums[right]:- 若
nums[mid] < nums[right]:最小值在[left, mid],更新right = mid - 若
nums[mid] > nums[right]:最小值在[mid+1, right],更新left = mid + 1
- 若
步骤3:循环结束,返回 nums[left]
- 此时
left == right,指向最小值
4.4 边界条件处理
关键细节:
- 循环条件 :
while left < right- 当
left == right时,区间只有一个元素,即为最小值 - 使用
<=会导致死循环,因为right = mid可能不缩小范围
- 当
- 完全有序数组 :
- 若数组未旋转(完全有序),算法仍正确
- 此时最小值在数组开头,二分查找会逐步向左收缩
- 单元素数组 :
- 直接返回该元素
4.5 Python代码实现
python
def find_min_rotated(nums):
"""
寻找旋转排序数组中的最小值
时间复杂度:O(log n),空间复杂度:O(1)
"""
if not nums:
return -1 # 根据题目要求调整
left, right = 0, len(nums) - 1
while left < right:
mid = left + (right - left) // 2
if nums[mid] < nums[right]:
# mid在右子数组,最小值在左侧(包括mid)
right = mid
else:
# nums[mid] > nums[right],mid在左子数组,最小值在右侧
left = mid + 1
return nums[left] # 或nums[right],此时left == right
注: 循环条件 left < right 确保了至少有两个元素时才进入循环。对于单元素数组,直接返回 nums[0]。
4.6 复杂度分析
- 时间复杂度 : O ( log n ) O(\log n) O(logn)
- 每次迭代范围减半
- 空间复杂度 : O ( 1 ) O(1) O(1)
4.7 测试用例验证
python
# 测试用例
test_cases = [
([3,4,5,1,2], 1),
([4,5,6,7,0,1,2], 0),
([11,13,15,17], 11),
([1], 1),
([2,1], 1),
([1,2,3], 1), # 未旋转
]
for nums, expected in test_cases:
result = find_min_rotated(nums)
assert result == expected, f"Failed: nums={nums}, got {result}, expected {expected}"
5. 二分查找变式与技巧总结
5.1 常见变式类型
二分查找不仅限于查找单个值,通过改造判断逻辑,可以解决多种变式问题:
1. 查找边界问题
- 左边界:第一个等于/大于等于目标值的位置
- 右边界:最后一个等于/小于等于目标值的位置
2. 旋转数组问题
- 搜索旋转排序数组
- 寻找旋转排序数组中的最小值
- 寻找旋转排序数组中的最大值
3. 峰值问题
- 寻找峰值元素(比相邻元素大)
4. 有序矩阵问题
- 在二维有序矩阵中搜索
5. 寻找重复数
- 在包含重复的有序数组中寻找目标
5.2 通用模板与技巧
技巧1:区间定义一致性
- 选择一种区间定义(闭区间或左闭右开)并贯穿始终
- 闭区间:
left = 0, right = len(nums)-1,循环条件while left <= right - 左闭右开:
left = 0, right = len(nums),循环条件while left < right
技巧2:中间值计算防溢出
- 永远使用:
mid = left + (right - left) // 2 - 避免:
mid = (left + right) // 2(可能溢出)
技巧3:指针更新逻辑
- 闭区间:排除
mid,更新left = mid + 1或right = mid - 1 - 左闭右开:右侧不包含
mid,更新right = mid;左侧包含mid,更新left = mid + 1
技巧4:目标值判断顺序
- 优先判断
nums[mid] == target的情况 - 然后根据问题调整其他判断逻辑
5.3 面试常见考点
考点1:边界条件处理
- 空数组、单元素数组
- 目标值不存在
- 目标值在数组两端
考点2:重复元素处理
- 当数组包含重复元素时,如何正确查找边界
- 最坏情况时间复杂度分析
考点3:算法正确性证明
- 如何证明二分查找的正确性
- 循环不变量的理解与应用
考点4:复杂度分析
- 时间复杂度的严格推导
- 空间复杂度的分析
5.4 调试技巧与常见错误
常见错误1:死循环
- 原因:指针更新不当,范围未缩小
- 检查:
left和right是否在每次迭代中变化
常见错误2:漏查元素
- 原因:循环条件不当,提前退出
- 检查:最后一个元素是否被检查
常见错误3:索引越界
- 原因:访问
nums[mid]时mid超出范围 - 检查:循环条件和指针更新逻辑
调试方法:
- 打印每次迭代的
left、right、mid和nums[mid] - 使用小规模测试用例验证
- 检查边界情况
6. 实战练习与扩展
6.1 推荐练习题
基础巩固:
-
LeetCode 35. 搜索插入位置
- 在排序数组中查找目标值,如果不存在则返回它将被插入的位置
- 链接:https://leetcode.com/problems/search-insert-position/
-
LeetCode 278. 第一个错误的版本
- 使用二分查找找到第一个出错的版本
- 链接:https://leetcode.com/problems/first-bad-version/
进阶挑战:
-
LeetCode 162. 寻找峰值
- 在数组中找到峰值元素(比相邻元素大)
- 链接:https://leetcode.com/problems/find-peak-element/
-
LeetCode 33 的变体:搜索旋转排序数组 II
综合应用:
-
LeetCode 74. 搜索二维矩阵
- 在二维有序矩阵中搜索目标值
- 链接:https://leetcode.com/problems/search-a-2d-matrix/
-
LeetCode 240. 搜索二维矩阵 II
- 在每行每列都有序的矩阵中搜索
- 链接:https://leetcode.com/problems/search-a-2d-matrix-ii/
6.2 扩展思考
思考1:二分查找的变式通用性
- 如何将二分查找应用于非数值搜索问题?
- 示例:在有序函数值中查找满足条件的输入
思考2:二分查找与分治法的关系
- 二分查找是分治法的一种特殊形式
- 比较:二分查找 vs 快速排序 vs 归并排序
思考3:现实应用场景
- 数据库索引的B+树搜索
- 操作系统中的内存分配算法
- 游戏AI中的最优决策搜索
6.3 学习路径建议
-
第一阶段:掌握基础
- 理解二分查找的原理和适用条件
- 熟练掌握两种区间定义的模板
- 完成LeetCode 704、35、278
-
第二阶段:攻克变式
- 学习边界查找的改造方法
- 理解旋转数组问题的特殊性
- 完成LeetCode 34、33、153
-
第三阶段:综合应用
- 解决二维矩阵搜索问题
- 理解峰值问题的变式
- 完成LeetCode 74、240、162
-
第四阶段:深入理解
- 学习二分查找的数学证明
- 分析最坏情况下的性能
- 探索现实世界中的应用
6.4 总结
二分查找是算法学习的基石之一,其核心价值在于:
- 高效性 : O ( log n ) O(\log n) O(logn) 的时间复杂度,远超线性搜索
- 简洁性:代码实现简洁,逻辑清晰
- 通用性:通过改造可解决多种变式问题
掌握二分查找的关键在于:
- 理解原理:有序性、单调性、分治思想
- 熟练模板:闭区间和左闭右开两种实现
- 灵活应用:根据问题特点调整判断逻辑