🔍 二分查找算法详解

标准二分 · 左右边界 · 旋转数组 · 二分答案:细节最多的 O(log n) 算法

一句话总结

二分查找每次用中点把搜索空间砍掉一半,前提是搜索空间具有单调性(有序数组,或"答案越大越容易/越难满足"的问题)。思想简单,但边界条件极易写错——号称"十个二分九个错"。

⚖️

前提:单调性

数组有序,或判定函数随答案单调变化(二分答案)。

✂️

动作:折半

比较中点与目标,每次排除一半候选,log₂n 次出结果。

🧨

难点:边界

while 条件、mid 取法、收紧方向、返回值,差一个字符就死循环。

💡

升华:二分答案

不搜数组,直接对"答案的取值范围"二分。

一、标准二分:找等于 target 的位置

1.1 过程演示

有序数组中找 target = 23 3 9 15 23 31 42 57 66 71 88 ↑ low=0 ↑ mid=4:恰好是 23 ↑ high=9 如果找 target = 50(不存在) 3 9 15 23 31 42 57 66 71 88 mid=4 (23) < 50 → low=5 | mid=7 (66) > 50 → high=6 | mid=5 (42) < 50 → low=6 mid=6 (57) > 50 → high=5 | 此时 low > high,循环结束 → 未找到,返回 -1 搜索空间每轮减半:10 → 5 → 2 → 1 → 0,仅 4 次比较

1.2 代码模板

def binary_search(nums, target): low, high = 0, len(nums) - 1 # 闭区间 [low, high] while low <= high: # 注意是 <=,区间非空才继续 mid = low + (high - low) // 2 # 防溢出写法(等价 (low+high)//2) if nums[mid] == target: return mid # 找到 elif nums[mid] < target: low = mid + 1 # 目标在右半 else: high = mid - 1 # 目标在左半 return -1 # 不存在

二、找边界:左边界 & 右边界(重点)

2.1 为什么标准模板不够用?

数组有重复元素时(如 [1,2,2,2,3] 找 2),标准二分只返回"某一个 2",无法回答"第一个 2 / 最后一个 2 在哪"。而 34. 查找元素的第一个和最后一个位置、35. 搜索插入位置 这类题考的就是边界。

nums = [1, 2, 2, 2, 3],target = 2 1 2 2 2 3 ↑ 左边界 = 1(第一个 ≥ target 的位置) ↑ 右边界 = 3(最后一个 ≤ target 的位置) 技巧:把数组想象成 [ false, true, true, true, false ] 找左边界 = 找第一个 true;找右边界 = 找最后一个 true —— "找边界"统一成"找分界点"

2.2 左边界模板(找第一个 ≥ target 的位置)

def lower_bound(nums, target): low, high = 0, len(nums) # 注意:high 初始为 n(左闭右开区间) while low < high: # 区间 [low, high) 非空 mid = (low + high) // 2 if nums[mid] < target: # mid 一定不是答案 → 大胆排除 low = mid + 1 else: # nums[mid] >= target → mid 可能是答案 high = mid # 保留 mid,继续向左压缩 return low # 第一个 >= target 的下标(可能 = n) # 应用: # 35 搜索插入位置 = lower_bound(nums, target) # 34 第一个位置 = lower_bound(nums, target)(再检查是否等于 target) # 找 >= x 的最小元素 = nums[lower_bound(nums, x)]

2.3 右边界模板(找最后一个 ≤ target 的位置)

def upper_bound(nums, target): low, high = 0, len(nums) while low < high: mid = (low + high) // 2 if nums[mid] <= target: # mid 可能是答案 → 保留 low = mid + 1 else: high = mid return low - 1 # 最后一个 <= target 的下标(可能 = -1) # 记忆口诀: # lower_bound:< target 的都不要 → 找"第一个大于等于" # upper_bound:<= target 的都要 → 找"第一个大于",再减一
⚠️ 死循环经典坑:当 low = high - 1 时 mid = low。如果此时执行 high = mid 没问题;但若写成 low = mid(mid 不 +1),区间永远不缩小 → 死循环。
规则:mid 必须被排除——要么 low = mid + 1,要么 high = mid(mid 保留但区间仍缩小),绝不能 low = mid。

三、旋转数组二分(LeetCode 33 / 153)

3.1 核心洞察

旋转有序数组(如 [4,5,6,7,0,1,2])整体无序,但从任意中点切开,必有一半是有序的。判断 target 是否落在有序的那一半内,即可决定往哪边二分。

nums = [4, 5, 6, 7, 0, 1, 2],target = 0 4 5 6 7 0 1 2 ↑ low ↑ mid ↑ high 左半 [4..7] 有序(nums[low] ≤ nums[mid]) target=0 不在 [4,7] 范围内 → 放弃左半,去右半找:low = mid+1 右半 [0,1,2] 本身有序,普通二分即可命中 target=0 每轮仍能排除一半 → 依然 O(log n)

3.2 代码模板

def search_rotated(nums, target): low, high = 0, len(nums) - 1 while low <= high: mid = (low + high) // 2 if nums[mid] == target: return mid if nums[low] <= nums[mid]: # 左半段有序(关键判断,含等号) if nums[low] <= target < nums[mid]: high = mid - 1 # target 在有序的左半 else: low = mid + 1 # 去右半 else: # 右半段有序 if nums[mid] < target <= nums[high]: low = mid + 1 # target 在有序的右半 else: high = mid - 1 return -1 # 153 寻找旋转排序数组中的最小值:只需判断最小值在哪一侧 def find_min(nums): low, high = 0, len(nums) - 1 while low < high: mid = (low + high) // 2 if nums[mid] < nums[high]: # 右半有序 → 最小值不可能在 mid 右侧 high = mid # 保留 mid 本身 else: # nums[mid] > nums[high] → 最小值在 mid 右侧 low = mid + 1 return nums[low]

四、二分答案:二分的真正威力

4.1 什么时候用二分答案?

当题目问"在满足条件的前提下,最小/最大的那个值是多少",并且"值越大越容易满足"(或越难满足)——即可行性关于答案单调——就可以不去搜原数组,直接对答案的取值范围做二分。

💡 典型案例:410 分割数组的最大值。把数组分成 m 段,让"各段和的最大值"尽可能小。
如果允许的最大段和是 maxSum:maxSum 越大,越容易满足(分得越少)。所以 maxSum 从 max(nums)(理论下界)到 sum(nums)(上界)之间,可行性单调 → 二分找最小的可行值。

4.2 通用模板

def split_array_largest_sum(nums, m): def feasible(max_sum): """判断:每段和都不超过 max_sum 时,能否分成不超过 m 段""" cnt, cur = 1, 0 for x in nums: if cur + x > max_sum: cnt += 1 cur = 0 if cnt > m: return False cur += x return True low, high = max(nums), sum(nums) # 答案的取值区间 while low < high: mid = (low + high) // 2 if feasible(mid): # 可行 → 说明还能更小 high = mid # 保留 mid,向左压缩 else: # 不可行 → 必须放宽 low = mid + 1 return low

4.3 二分答案题目清单

题目二分的"答案"是什么可行判定
410 分割数组的最大值各段和的最大值贪心分段,看能否 ≤ m 段
875 爱吃香蕉的珂珂每小时吃 k 根Σ⌈pile/k⌉ ≤ h 小时
1011 在 D 天内送达包裹船的载重能力贪心装载,看是否 ≤ D 天
278 第一个错误的版本第一个出错的版本号isBadVersion(mid)
4 两个正序数组的中位数分割线的位置左半最大值 ≤ 右半最小值

五、避坑清单与复杂度

5.1 常见错误

坑表现修复
while 条件写错闭区间写 < 会漏掉最后一个元素闭区间 [l,r] 配 <=;开区间 [l,r) 配 <,保持一致
mid 未排除low = mid 导致死循环用 low = mid + 1(mid 已证不是答案)
整数溢出(low+high) 超 int 上界(C/Java)写 low + (high-low)/2
忘记排序数组无序直接二分先确认单调性;必要时先 sort
返回值越界lower_bound 返回 n 被当索引用使用前检查 idx < len(nums)

5.2 复杂度

时间复杂度:每轮排除一半 → O(log n)。二分答案时复杂度为 O(log(答案范围) × 判定代价)。
空间复杂度:迭代写法 O(1);递归写法 O(log n) 栈空间。
与双指针的关系:两者都通过"单调排除"减少搜索量。双指针靠一次排除一个元素(O(n)),二分靠一次排除一半(O(log n))。当数组有序且只需"查找/判定"而非"枚举所有区间"时,二分永远优于线性扫描。