算法 - 二分查找专项
核心:红蓝染色法,通过每次排除一半元素,将 O(n) 的暴力搜索优化到 O(logn),是解决有序数组搜索问题的通解。
一、基础知识点(二分查找基础)
1. 二分查找模板(红蓝染色法)
题目描述
给定一个从小到大有序的数组 nums 和目标值 target,找到第一个大于等于 target 的元素下标。
暴力解法
思路:从左到右遍历数组,逐一比较每个元素与 target 的大小
最坏情况:target 在数组最右侧,需要遍历全部 n 个元素
复杂度:时间 O(n),空间 O(1)
优化动机:没有利用数组有序这一关键条件
优化解法(二分查找)
核心思想
利用有序性:数组有序,所以对于任意位置 mid:
若
nums[mid] < target,则 mid 左侧所有元素都< target(红色)若
nums[mid] >= target,则 mid 右侧所有元素都>= target(蓝色)
红蓝染色法:将数组想象成两部分:
红色区域:
L左侧(不含 L),所有元素 < target蓝色区域:
R右侧(不含 R),所有元素 >= target我们的目标:找到第一个蓝色元素的位置
循环不变量(核心!):
L左侧(索引 < L)→ 一定是红色(< target)R右侧(索引 > R)→ 一定是蓝色(>= target)每轮循环后,搜索区间
[L, R]缩小,但不变量保持不变
算法步骤
初始化:
L = 0,R = n - 1,搜索区间为[L, R](左闭右闭)取中点:
mid = (L + R) // 2(向下取整)判断并缩小区间:
若
nums[mid] < target:蓝色在右侧,L = mid + 1(mid 及其左侧都是红色)若
nums[mid] >= target:蓝色在当前位置或左侧,R = mid - 1
终止条件:
L > R时循环结束返回结果:
L(即R + 1)就是第一个蓝色元素的位置
为什么 L = mid + 1 而不是 L = mid?
关键:
nums[mid] < target时,mid 本身一定是红色(< target),所以可以直接跳过,搜索区间变为[mid+1, R]。如果写成L = mid,当区间只剩一个元素时会死循环。
图解示例
数组 nums = [1, 3, 5, 7],target = 6
初始状态:[1, 3, 5, 7] L=0 R=3
↑
mid=1, nums[1]=3 < 6 → L更新为2
状态2: [1, 3, 5, 7] L=2 R=3
↑
mid=2, nums[2]=5 < 6 → L更新为3
状态3: [1, 3, 5, 7] L=3 R=3
↑
mid=3, nums[3]=7 >= 6 → R更新为2
终止:L=3, R=2 → L > R,退出循环
返回 L=3,即第一个 >= 6 的元素是 nums[3]=7
代码实现
def lower_bound(nums, target):
"""
查找第一个 >= target 的元素下标
:param nums: 有序数组(从小到大)
:param target: 目标值
:return: 第一个 >= target 的下标,如果不存在返回数组长度
"""
n = len(nums)
# 初始化:L=0 指向搜索区间左端,R=n-1 指向右端
# 此时 L-1(索引-1,不存在)可视为红色边界
# R+1(索引n,不存在)可视为蓝色边界
left, right = 0, n - 1
# 循环条件:left <= right 表示搜索区间不为空
# 循环不变量始终成立:
# - 索引 < left 的元素都 < target(红色)
# - 索引 > right 的元素都 >= target(蓝色)
while left <= right:
# 防止 (left + right) 溢出的写法
# mid = (left + right) // 2 # 可能溢出
mid = left + (right - left) // 2 # 安全写法
if nums[mid] < target:
# nums[mid] < target,说明 mid 及左侧都是红色
# 蓝色区域一定在 [mid+1, right],更新 left
left = mid + 1
else:
# nums[mid] >= target,说明蓝色区域在 [left, mid]
# 更新 right 为 mid-1,继续在左半部分找第一个蓝色
right = mid - 1
# 循环结束后,left 指向第一个蓝色元素的位置
# 此时 left == right + 1
# 如果所有元素都 < target,left 会等于 n
return left
三种区间写法对比
推荐使用左闭右闭写法,最符合常规思维,不易出错。
四种类型转换(重要技巧)
核心思想:四种类型都可以统一用「找第一个 >= x 的位置」来解决。
复杂度
时间:O(logn),每次循环将搜索区间缩小一半
空间:O(1),仅使用常数个变量
关键细节
溢出问题:
mid = left + (right - left) // 2防止 left+right 溢出终止条件:
left <= right(左闭右闭)或left < right(左闭右开)返回值:循环结束后 L 指向第一个蓝色位置
空数组处理:n=0 时,直接返回 0(符合预期)
所有元素都 < target:L 会等于 n
所有元素都 >= target:R 会等于 -1
2. 在有序数组中找开始位置和结束位置
题目描述
给定一个有序数组,找到等于 target 的元素的开始位置和结束位置。如果不存在返回 [-1, -1]。
核心思路
这道题本质上是两次二分查找:
第一次:找第一个 >= target 的位置(start)
第二次:找第一个 > target 的位置,然后减 1(end)
代码实现
class Solution:
def searchRange(self, nums: List[int], target: int) -> List[int]:
n = len(nums)
# 第一次二分:找第一个 >= target 的位置(start)
start = 0
right = n - 1
while start <= right:
mid = start + (right - start) // 2
if nums[mid] < target:
start = mid + 1
else:
right = mid - 1
# start 就是第一个 >= target 的位置
# 但需要验证这个位置是否真的等于 target
if start == n or nums[start] != target:
return [-1, -1]
# 第二次二分:找第一个 > target 的位置(end+1)
end = 0
right = n - 1
while end <= right:
mid = end + (right - end) // 2
if nums[mid] <= target:
end = mid + 1
else:
right = mid - 1
# end 是第一个 > target 的位置,所以 end-1 是最后一个 = target 的位置
return [start, end - 1]
二、练习(3道二分查找题目)
1. 2529. 正整数和负整数的最大计数 🔢
题目描述(LeetCode 2529 - 简单)
给你一个按 非递减顺序 排列的数组 nums,返回正整数数目和负整数数目中的最大值。换句话说,如果 nums 中正整数的数目是 pos,而负整数的数目是 neg,返回 pos 和 neg 二者中的最大值。
注意:0 既不是正整数也不是负整数。
示例
输入:nums = [-2,-1,-1,1,2,3]
输出:3
解释:共有 3 个正整数和 3 个负整数。计数得到的最大值是 3。
解题思路
分别用二分查找找到:
第一个 >= 0 的位置 → 负数个数 = 该位置
第一个 > 0 的位置 → 正数个数 = n - 该位置
两次 lower_bound 取最大值
复杂度
时间:O(logn)(两次二分)
空间:O(1)
Python 代码
class Solution:
def maximumCount(self, nums: List[int]) -> int:
n = len(nums)
# 第一次二分:找第一个 >= 0 的位置(负数分界点)
l, r = 0, n - 1
pos1 = n # 默认值:如果所有数都 < 0,负数个数为 n
while l <= r:
mid = (l + r) // 2
if nums[mid] >= 0:
pos1 = mid # 记录第一个 >= 0 的位置
r = mid - 1 # 继续在左半部分找
else:
l = mid + 1
neg = pos1 # 索引 [0, pos1) 都是负数
# 第二次二分:找第一个 > 0 的位置(正数分界点)
l, r = 0, n - 1
pos2 = n # 默认值:如果所有数都 <= 0,正数个数为 0
while l <= r:
mid = (l + r) // 2
if nums[mid] > 0:
pos2 = mid # 记录第一个 > 0 的位置
r = mid - 1 # 继续在左半部分找
else:
l = mid + 1
pos = n - pos2 # 索引 [pos2, n) 都是正数
return max(neg, pos)
2. 2300. 咒语和药水的成功对数 🎯
题目描述(LeetCode 2300 - 中等)
给你两个正整数数组 spells 和 potions,长度分别为 n 和 m,其中 spells[i] 表示第 i 个咒语的能量强度,potions[j] 表示第 j 瓶药水的能量强度。
同时给你一个整数 success。一个咒语和药水的能量强度相乘如果 大于等于 success,那么它们视为一对成功的组合。
请你返回一个长度为 n 的整数数组 pairs,其中 pairs[i] 是能跟第 i 个咒语成功组合的药水数目。
示例
输入:spells = [5,1,3], potions = [1,2,3,4,5], success = 7
输出:[4,0,3]
解释:
第 0 个咒语:5 * [1,2,3,4,5] = [5,10,15,20,25],总共 4 个成功组合
第 1 个咒语:1 * [1,2,3,4,5] = [1,2,3,4,5],总共 0 个成功组合
第 2 个咒语:3 * [1,2,3,4,5] = [3,6,9,12,15],总共 3 个成功组合
解题思路
先对 potions 数组排序(为二分查找做准备)
对于每个咒语 spell,求最小药水强度满足
spell * potion >= success推导:
spell * potion >= success→potion >= success / spell
用二分查找找第一个大于等于
success / spell的 potions 位置成功组合数 =
m - 该位置索引(该位置之后都满足)
复杂度
时间:O((n+m) log m)
空间:O(m)(排序占用)
Python 代码
class Solution:
def successfulPairs(self, spells: List[int], potions: List[int], success: int) -> List[int]:
potions.sort() # 排序,为二分查找做准备
n = len(spells)
m = len(potions)
pairs = [0] * n
for x, y in enumerate(spells):
# 对于当前咒语 y,计算满足条件的最小药水强度
# spell * potion >= success → potion >= success / spell
# 找第一个 >= success/y 的位置
left, right = 0, m - 1
while left < right:
mid = (left + right) // 2
if potions[mid] * y >= success:
right = mid # 满足条件,继续在左半部分找更小的
else:
left = mid + 1 # 不满足,左移右边界
# 判断找到的位置是否真的满足条件
if potions[left] * y >= success:
pairs[x] = m - left # 从该位置到末尾都满足
else:
pairs[x] = 0
return pairs
3. 1385. 两个数组间的距离值 📐
题目描述(LeetCode 1385 - 简单)
给你两个整数数组 arr1,arr2 和一个整数 d,请你返回两个数组之间的距离值。
「距离值」定义为符合此距离要求的元素数目:对于元素 arr1[i],不存在任何元素 arr2[j] 满足 |arr1[i] - arr2[j]| <= d。
示例
输入:arr1 = [4,5,8], arr2 = [10,9,1,8], d = 2
输出:2
解释:
arr1[0]=4:|4-10|=6 > d, |4-9|=5 > d, |4-1|=3 > d, |4-8|=4 > d → 符合距离要求
arr1[1]=5:|5-10|=5 > d, |5-9|=4 > d, |5-1|=4 > d, |5-8|=3 > d → 符合距离要求
arr1[2]=8:|8-10|=2 ≤ d, |8-9|=1 ≤ d, |8-1|=7 > d, |8-8|=0 ≤ d → 不符合距离要求
故而只有 arr1[0] 和 arr1[1] 两个符合距离要求,距离值为 2。
解题思路
对两个数组分别排序
对于 arr1 中的每个元素 x,在 arr2 中找第一个 >= x-d 的位置
判断该位置是否满足
arr2[j] > x + d使用双指针:arr2 的指针 j 单调递增,整体 O(n+m)
复杂度
时间:O((n+m) log m)
空间:O(1)
Python 代码
class Solution:
def findTheDistanceValue(self, arr1: List[int], arr2: List[int], d: int) -> int:
arr1.sort() # 排序
arr2.sort()
ans = j = 0
for x in arr1:
# 在 arr2 中找第一个 >= x-d 的位置
# 利用 arr2 单调性,指针 j 只增不减
while j < len(arr2) and arr2[j] < x - d:
j += 1
# 判断 arr2[j] 是否超出 x+d 的范围
# 如果 j 已到末尾,或 arr2[j] > x+d,则 x 符合距离要求
if j == len(arr2) or arr2[j] > x + d:
ans += 1
return ans
三、二分查找算法通用解题总结 ✨
核心适用场景
有序数组中搜索第一个满足某条件的元素位置
统计满足条件的元素个数
统一解题步骤
明确搜索目标:找第一个 ≥x 的位置,还是找最后一个小于 x 的位置
确定循环不变量:L-1 一定是红色(不满足),R+1 一定是蓝色(满足)
选择区间写法:推荐左闭右闭或左闭右开,避免死循环
根据条件更新边界:mid 满足条件 → 更新 R;不满足 → 更新 L
返回 L(第一个蓝色位置)
复杂度速记
二分查找:时间 O(logn),空间 O(1)
关键技巧
四种类型互转:
> target→≥ (target+1);≤ target→≥ (target+1)再减 1乘法转除法:涉及乘法的二分,可将一边除过去(如 2300 题)
单调数组 + 双指针:距离值问题先排序,再用指针跳过不满足条件的区间
溢出处理:
mid = left + (right - left) // 2防止 left+right 溢出