力扣二分查找避坑指南:从循环不变量到答案二分模板
2026/9/16 9:44:11 网站建设 项目流程

从真正开始认真刷力扣到现在,我最大的一个感触是:二分查找是所有“看似简单”的题里,翻车率最高的一类。LeetCode 上一堆简单题标注着“Easy”,但你要是直接上手写二分,边界稍微一含糊,不是死循环就是越界,要不就是结果差一位。尤其是热题 100 里那几道二分变种题,像 875 爱吃香蕉的狒狒、1011 在 D 天内送达包裹的能力,多少人在“答案二分”这一步卡住。

这篇文章我打算把自己刷二分查找力扣题的经验一次性倒出来,从最基础的循环不变量讲起,到左右边界模板、答案二分套路,再配合几道力扣高频题的完整推导过程,最后给一份我自己的调试方法和防坑清单。不管你是刚接触力扣的新手,还是刷了百来题但二分总是模棱两可的老手,这篇应该都能让你把二分这块一次性焊死。

1. 为什么二分查找代码看着简单,却总是写错

很多初学者背模板背得很熟:left = 0, right = len(nums) - 1,然后while left <= rightmid = (left + right) // 2。这代码看起来没毛病,可一旦换个题目,比如找左边界、右边界、找峰值、找旋转数组最小值,同样的模板改两行就开始出错。问题出在哪?出在大多数人没有真正理解“循环不变量”。

1.1 你背的是模板,不是边界规则

二分查找的核心不是“折半”这个动作,而是每一轮循环之后,你要找的目标值一定还在某个明确的区间里。这个区间就是循环不变量。

这里有两个常见区间定义:

  • 左闭右闭[left, right]:每一轮循环开始时,目标值如果存在,一定在nums[left]nums[right]之间(包含两端)。所以初始right = len(nums) - 1,循环条件是while left <= right。当nums[mid] > target时,说明目标值不可能在mid及右边,所以right = mid - 1
  • 左闭右开[left, right):每一轮循环开始时,目标值如果存在,一定在nums[left]nums[right-1]之间(包含左端,不包含右端)。初始right = len(nums),循环条件是while left < right。当nums[mid] > target时,right = mid而不是mid - 1,因为right本身就是开区间,不需要排除mid位置的元素。

这两种写法没有谁绝对好,但你必须选定一种并坚持到底。我最常见到的翻车场景是:初始化用的左闭右闭,收缩边界的时候却按左闭右开写,或者反过来。只要区间定义和收缩规则不匹配,结果必然错。

1.2 死循环的本质是区间没有严格缩小

还有一个高频翻车点:死循环。很多人以为死循环是while条件写反了,其实本质是某一轮循环之后区间大小没有变小

举个经典例子:

# 错误示范:left = mid 导致的死循环 def binary_search(nums, target): left, right = 0, len(nums) - 1 while left < right: mid = (left + right) // 2 if nums[mid] < target: left = mid # 问题在这:target 可能在 mid 位置吗? else: right = mid return left if nums[left] == target else -1

假设nums = [1, 3]target = 3。第一轮:left=0, right=1, mid=0nums[0]=1 < 3,于是left = mid = 0。第二轮:left=0, right=1, mid=0,又执行left = mid = 0。循环永远出不去。

这个例子的坑在于:当区间只剩两个元素,mid算出的是左元素,如果你把left更新成mid,而mid又恰好等于原来的left,那区间就原地踏步了。

那么正确做法是什么?得分析nums[mid] < target这个条件。如果nums[mid]严格小于target,那mid这个位置肯定不是目标值所在的位置,所以可以安全地left = mid + 1。这就是区间严格缩小的关键:每次更新边界时,必须把已经排除掉的 mid 位置剔除出区间

1.3 死循环自查方法

如果你写了一个二分,运行起来超时,先别急着查业务逻辑,直接看三件事:

  1. 循环条件用的是<还是<=,跟你的区间定义匹配吗?
  2. left的更新会不会出现left = midmid == left的情况?
  3. 区间长度为 0 或 1 时,你的代码能正确退出吗?

我个人的习惯是:在 while 循环第一行打印left, right, mid,跑一个小样例,一眼就能看出区间有没有在缩小。真不是丢人的事,刷题阶段靠打印找 bug 比对着屏幕干瞪眼快十倍。

2. 左闭右开模板:一套代码适配力扣绝大多数二分题

前面说了两种区间定义,我推荐大家主用左闭右开。为什么?因为 Python 的bisect模块、C++ 的 STLlower_bound / upper_bound全是左闭右开语义,刷题用这套,和标准库保持一致,不容易记混。

2.1 标准模板:找第一个不小于 target 的位置

这是所有二分查找里最核心的模板,力扣的 35 题(搜索插入位置)就是直接考这个:

def lower_bound(nums, target): left, right = 0, len(nums) # 左闭右开:[0, len(nums)) while left < right: mid = left + (right - left) // 2 if nums[mid] < target: left = mid + 1 else: right = mid return left

这个函数返回什么?返回第一个满足nums[i] >= target的下标 i。如果所有元素都小于 target,返回len(nums)

你可能会问:为什么nums[mid] < target时是left = mid + 1,而nums[mid] >= target时是right = mid?这就是循环不变量的体现:

  • nums[mid] < target,说明包括mid在内的左边这一段都小于 target,不可能是第一个不小于 target 的位置,直接排除,所以left = mid + 1
  • nums[mid] >= targetmid这个位置可能是答案,也可能是答案右边,但答案绝不可能在mid右边,所以把右边界收到mid(注意是开区间,所以right = mid表示新的搜索范围是[left, mid))。

这个模板用熟了之后,很多题都只是在这个基础上套壳。

2.2 mid 计算的溢出问题

我看到网上很多题解写mid = (left + right) // 2,在 Python 里这没问题,因为 Python 整数是无界的。但如果你用 C++ 或 Java 刷题,left + right可能溢出 int 范围。所以我习惯统一写成:

mid = left + (right - left) // 2

这个写法在所有语言里都安全,而且跟(left + right) // 2算出来的结果完全一样,只是数学上等价变形了一下。养成这个习惯,以后写 C++ 的时候就不用特意改。

还有一个细节:mid到底是取左中位数还是右中位数。在左闭右开模板里,mid = left + (right - left) // 2取的是左中位数(即靠左的那一个)。当区间只有一个元素时,mid == left,这时候如果你写left = mid就会死循环。所以左闭右开模板里,凡是left的更新必须是mid + 1,只有right才能更新成mid。这个理解和前面 1.2 的死循环案例是对应的。

2.3 用 lower_bound 模板直接解决 35 题

力扣 35 题:给定一个排序数组和一个目标值,在数组中找到目标值,并返回其索引。如果目标值不存在于数组中,返回它将会被按顺序插入的位置。

这题就是裸的lower_bound

def searchInsert(nums, target): return lower_bound(nums, target)

比如nums = [1,3,5,6], target = 5lower_bound返回 2;target = 2,返回 1;target = 7,返回 4(插到末尾)。

这个题虽然简单,但它帮我们建立了一个很重要的思维:“查找”可以等价为“找第一个满足某条件的位置”。这个思维后面会反复使用。

3. 左右边界问题:从“找一个”到“找一段”

力扣 34 题(在排序数组中查找元素的第一个和最后一个位置)是二分查找里的一道关键分水岭题。很多人做过 704(裸二分)和 35(插入位置),但一到 34 就开始懵,因为目标值可能重复,需要同时找左边界和右边界。

3.1 左边界就是 lower_bound

找第一个等于 target 的位置,其实就是找第一个不小于 target 的位置:

def find_left(nums, target): left, right = 0, len(nums) while left < right: mid = left + (right - left) // 2 if nums[mid] < target: left = mid + 1 else: right = mid return left

之后判断一下left < len(nums) and nums[left] == target,如果不成立说明 target 不存在。

3.2 右边界:两个思路

找最后一个等于 target 的位置,有两个主流思路:

思路一:找第一个大于 target 的位置,减一。

def find_right(nums, target): left, right = 0, len(nums) while left < right: mid = left + (right - left) // 2 if nums[mid] <= target: # 注意这个 <= left = mid + 1 else: right = mid return left - 1

这里把条件改成nums[mid] <= target,意味着我们找的是“第一个大于 target 的位置”,这个位置的前一个就是最后一个等于 target 的元素。

思路二:分别用两个模板,一个找下界,一个找上界。

有的同学喜欢把“最后一个等于 target”也用一个独立的模板来写,这也没问题,但我觉得“找大于 target 的位置再减一”更不容易记混,因为它还是在用同一个 lower_bound 核心。

3.3 34 题完整代码(左闭右开)

def searchRange(nums, target): def lower_bound(nums, target): left, right = 0, len(nums) while left < right: mid = left + (right - left) // 2 if nums[mid] < target: left = mid + 1 else: right = mid return left start = lower_bound(nums, target) if start == len(nums) or nums[start] != target: return [-1, -1] end = lower_bound(nums, target + 1) - 1 return [start, end]

看到了吧,找右边界根本不需要再写一个上界模板,直接对target + 1lower_bound,然后减一就是右边界。这个技巧我在很多题里都用到过,比如统计有序数组中某个数出现的次数,就是lower_bound(target + 1) - lower_bound(target)

这就是为什么我前面强调“一套模板打天下”——你不需要背三个四个模板,把lower_bound这一个吃透,左右边界问题只是它的组合应用。

4. 从“查找”到“判定”:答案二分才是二分的高阶用法

刷到力扣热题 100 的后半段,你会遇到一类很特别的二分题:题目里根本没有给你一个有序数组让你查找,而是让你求一个“最小速度”“最少天数”“最小力气”。这类题的典型代表就是 875(爱吃香蕉的狒狒)和 1011(在 D 天内送达包裹的能力)。

我第一次看到 875 题时完全没意识到这是二分,因为题目描述是一个猴子吃香蕉的场景:有一堆香蕉piles[i],狒狒每小时最多吃k根,如果一堆少于 k 根就吃完这一堆然后等下一小时,问最少用多大的速度能在h小时内吃完。

直觉上这是一个模拟题,但hpiles[i]的范围都是 10 的 9 次方级别,显然不能一个一个速度去试。这里就需要一个思维跃迁:把“求最优解”变成“验证某个解是否可行”,然后在可行解的范围内二分

4.1 先把判定函数写出来

所谓二分答案,核心是两步:

  1. 先确定答案的单调范围。比如 875 题,速度k的范围是[1, max(piles)],因为速度至少是 1,最多是最大那一堆的香蕉数(如果比最大一堆还大,每小时也只能吃一堆,再大没有意义)。
  2. 写一个判定函数can_finish(k),判断在速度k下能不能在h小时内吃完。

875 题的判定函数长这样:

def can_finish(piles, k, h): hours = 0 for p in piles: hours += (p + k - 1) // k # 上取整 if hours > h: return False return hours <= h

注意这里(p + k - 1) // k是“p 除以 k 后向上取整”的经典写法。因为狒狒吃一堆香蕉,如果一根都没剩下也要算一个完整的小时,所以必须上取整。

4.2 在答案空间里做二分

判定函数写好之后,主函数就非常套路了:

def minEatingSpeed(piles, h): left, right = 1, max(piles) while left < right: mid = left + (right - left) // 2 if can_finish(piles, mid, h): right = mid # 能吃完,尝试更小的速度 else: left = mid + 1 # 吃不完,必须加速 return left

这里你发现没有:整个二分查找的对象不再是数组里的元素,而是一个“速度值”的连续整数区间。我们把“速度 k 能否在 h 小时内吃完”看成一种单调的布尔函数:k 越大越可能吃完。于是问题就变成了“找第一个满足 can_finish(k) 为 True 的 k”——这本质上就是 lower_bound。

4.3 1011 题的套路完全一样

1011 题(在 D 天内送达包裹的能力):给定一个包裹重量数组weights,传送带每天最多装一定载重capacity的货品,求能在 D 天内送完的最小载重。

这个题的答案空间是[max(weights), sum(weights)],因为载重至少得能装下最重的一个包裹,至多一天把所有包裹全装走。判定函数也很直观:

def can_ship(weights, capacity, days): cur = 0 d = 1 for w in weights: if cur + w > capacity: d += 1 cur = 0 if d > days: return False cur += w return d <= days

主函数同样是一个 lower_bound 模板:

def shipWithinDays(weights, days): left, right = max(weights), sum(weights) while left < right: mid = left + (right - left) // 2 if can_ship(weights, mid, days): right = mid else: left = mid + 1 return left

这类题的共同特征非常明显:题目要求的是一个“最小可能值”,而这个“值”的可行性是单调的。你只要把判定函数写对,二分骨架根本不用动。这就是“答案二分”这个套路最大的价值——它的代码骨架高度统一,唯一要动脑的地方全在判定函数里。

5. 旋转数组与峰值:二分不只用在有序数组上

很多教材告诉你“二分查找只能用在有序数组”,这其实是个巨大的误解。力扣里有两类高频题,专门打破这个认知:搜索旋转排序数组(33 题)和寻找峰值(162 题)。

5.1 搜索旋转排序数组:利用部分有序收缩区间

33 题的场景是:一个升序数组在某个未知位置旋转了,比如[4,5,6,7,0,1,2],让你在这个数组里找 target。

这题乍一看没有全序关系,怎么二分?关键洞察是:旋转数组从中间切开,至少有一半是严格升序的

def search(nums, target): 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]: right = mid - 1 else: left = mid + 1 else: # 右半部分有序 if nums[mid] < target <= nums[right]: left = mid + 1 else: right = mid - 1 return -1

判断nums[left] <= nums[mid]为 True,说明从leftmid是严格递增的,这时候只需要看 target 在不在这个递增区间里。如果在,就按普通二分收缩;如果不在,那 target 只可能在另外一半。这个做法的核心,还是利用“局部有序”来排除一半的搜索空间。

注意:这个判断里用的是nums[left] <= nums[mid],不是<。原因是当数组只有两个元素时,mid可能等于left,用等号保证进入左半有序的逻辑分支,避免漏判。

5.2 寻找峰值:比较相邻元素决定往哪走

162 题要求在一个数组中找一个峰值(即nums[i] > nums[i+1]nums[i] > nums[i-1]),数组两端默认为负无穷。

这题在很多人的直觉里根本不该用二分,因为数组无序。但题目有个隐含条件:任意相邻元素都不相等。考虑mid位置的元素:

  • 如果nums[mid] < nums[mid + 1],说明mid处于一个上升段,峰值一定在mid右边(因为右边至少有一个上升趋势,最终要么遇到下降,要么到达数组末尾,而末尾视为负无穷,所以一定会出现峰值)。
  • 如果nums[mid] > nums[mid + 1],说明mid处于一个下降段,峰值可能在mid左边,也可能就是mid自己。

于是可以写出:

def findPeakElement(nums): left, right = 0, len(nums) - 1 while left < right: mid = left + (right - left) // 2 if nums[mid] < nums[mid + 1]: left = mid + 1 else: right = mid return left

这个写法就是标准的 lower_bound 模板,只不过“判定条件”从nums[mid] < target换成了nums[mid] < nums[mid + 1]。这再次印证了前面说的:模板不重要,重要的是你理解每一步收缩背后的单调性依据

5.3 LeetCode 热题 100 里的二分类题目该怎么刷

如果你在按“力扣热题 100”刷题,我建议按下面这个顺序来,逻辑上是层层递进的:

题目核心考点难度
704. 二分查找最基础的二分模板,热身简单
35. 搜索插入位置lower_bound 模板简单
34. 在排序数组中查找元素的第一个和最后一个位置左右边界组合应用中等
33. 搜索旋转排序数组部分有序区间的二分中等
162. 寻找峰值通过趋势判断收缩方向中等
153. 寻找旋转排序数组中的最小值部分有序的另一种形式中等
875. 爱吃香蕉的狒狒答案二分 / 判定函数中等
1011. 在 D 天内送达包裹的能力答案二分 / 判定函数中等

这个顺序的好处是:前四题帮你把“查找型二分”的边界彻底搞明白,第五第六题让你理解二分对“部分有序”同样有效,最后两题带你跨入“答案二分”的门槛。我见过很多刷题群的人一上来就刷 875,结果判定函数写不明白,卡了一下午,最后连二分模板都开始怀疑。但如果你先把 35 和 34 写透,875 其实就是套一层壳。

6. 二分查找调试三板斧:打印、断言、边界样本

这一章我不讲算法,讲点实打实的“工作流”。刷题和写工程代码不一样,你没有足够的日志系统和单测框架,但二分这种代码恰恰最容易因为边界问题翻车。我在刷题初期踩坑无数,后来总结了一套自己的调试三板斧,分享给大家。

6.1 打印三元组:left、right、mid

当你怀疑自己的二分死循环或者边界不对时,第一步就是在 while 循环开头打印:

while left < right: mid = left + (right - left) // 2 print(f"left={left}, right={right}, mid={mid}, nums[mid]={nums[mid]}")

用一个最小样例跑一遍。比如nums = [1, 3]。如果打印出来发现某两轮leftright完全没变化,那基本就是死循环,你立刻能看到是哪一行赋值导致区间没有缩小。

这个打印方法对答案二分的题目同样适用,你可以打印 mid 和can_finish(mid)的结果,能直观看到判定函数返回值的单调性是否符合预期。

6.2 断言区间单调性

如果你用的是左闭右开模板,有一个铁律可以写成断言放进去辅助验证:

# 左闭右开模板中,left 每次更新后必须满足 left <= right assert left <= right

很多时候二分 bug 不是方向搞反,而是某次更新后 left 跑到了 right 右边,程序直接返回一个越界或者错误结果。加一行断言,立刻暴露问题。

6.3 边界测试样本清单

每次写完二分,无论题目多简单,我都会用下面这些样本自测一遍:

  • 空数组:nums = []
  • 单元素数组:nums = [5]
  • 两元素数组:nums = [1, 3]
  • 目标值在开头:target = nums[0]
  • 目标值在结尾:target = nums[-1]
  • 目标值不存在(比所有元素都小 / 大 / 介于中间)
  • 全相同元素:nums = [2, 2, 2, 2, 2]

这些测试用例基本覆盖了所有边界分支。尤其是全相同元素这个用例,很多人会漏测,一旦漏了,34 题那种左右边界题很容易写错。

6.4 我踩过的二分大坑

最后分享两个我自己真实踩过的坑,给大家打个预防针。

第一个坑:right = midright = mid - 1搞混。在左闭右开模板里,right是开区间边界,它本身指向的元素不会参与下一轮搜索,所以当nums[mid] >= target时应该right = mid,把mid排除出去。但如果你还在用左闭右闭的习惯写right = mid - 1,就会把mid这个可能是答案的位置也丢掉了,结果多搜一圈,返回错误下标。

第二个坑:答案二分的范围没取对。875 题有人把right设成max(piles) + 1或者干脆设一个 10 的 9 次方,导致多算了 log(10^9) 轮循环,虽然不至于超时,但代码缺乏解释力。更严重的是 1011 题有人把left设成 1,然后判定函数里对小于最大包裹重量的 capacity 还要做特殊处理,这完全是给自己挖坑。记住一句话:答案范围的下界和上界,必须是判定函数的天然边界,而不是拍脑袋来的

7. 二分查找的进阶思考与刷题心得

写到这里,二分查找的基本框架、模板应用、变体套路都已经覆盖了。最后我想聊一点更深的东西,也是我刷完这些题之后对二分本身的理解。

7.1 二分本质是“单调性搜索”,不是“有序数组搜索”

很多人学二分时被“有序数组”四个字限制住了,导致遇到旋转数组、峰值、答案二分这类题时完全反应不过来。但一旦你意识到二分的本质是在一个存在“序关系”的搜索空间里,通过排除一半来逼近答案,你就能在更多场景下使用它。

什么是“序关系”?不一定是数值升序,也可以是“条件满足与否”的单调变化。比如“速度 k 能否吃完所有香蕉”,这个布尔值随 k 增大从 False 变 True,是单调的;“天数 d 内能否运完所有包裹”,也是单调的。只要你的搜索空间具备这种“单调布尔性质”,二分就可以用。

7.2 单调性与 log 的直觉

为什么二分能到 O(log n)?因为每轮都排除一半。这个直觉很多人有,但没有真正内化。当你遇到一个问题,搜索空间有 n 个可能答案,如果你能设计出一个单调判定函数,那么你不需要逐个尝试,只需要 log(n) 次判定。在很多真实的工程场景里,这个优化是数量级的差距。

我记得自己做 875 题时的第一版暴力解法,是从速度 1 一直试到 max(piles),最坏情况要试 10^9 次。改成二分后,最多 log2(10^9) ≈ 30 次判定,每次判定扫一遍 piles 数组。如果 piles 有 10^4 个元素,那就是 30 万次操作,暴力却要 10^13 次,完全不是一个量级。

7.3 力扣周赛中的二分规律

如果你关注力扣周赛(比如最近的热搜词里有“leetcode周赛430”),你会发现二分在周赛里的出现频率相当高。它很少单独出现,更多是作为某个大问题的一个子步骤。比如一道题需要你求“最小满足 xx 条件的值”,前面铺垫了几百字的场景,最后的数学模型就是二分答案。这正是为什么很多刷题经验贴都强调“二分是必须掌握的底层能力”——它不是难点,但它是一道难题的骨架。骨架上要长什么血肉(贪心、动态规划、DFS),那是另一回事,但骨架本身立不住,后面全白搭。

我个人刷题的习惯是,每遇到一个二分题,不管 AC 不 AC,都会把它的“判定函数”单独摘出来看看:这个判定函数是什么样的扫描逻辑?它的复杂度是多少?如果我能把判定函数从 O(n) 优化到 O(log n),那整个算法的复杂度会降多少?这个习惯帮助我建立了很多“算法直觉”,后来做工程时,遇到性能问题也能更快定位到“这里可以用单调性做二分”的机会。

7.4 相关高频题的横向对比

除了上面详细展开的题目,还有一些 LeetCode 高频二分题值得大家自己动手推一遍:

  • 153. 寻找旋转排序数组中的最小值:本质是找“第一个小于等于末尾元素的位置”,和 33 题共享一套“部分有序”的推理逻辑。
  • 4. 寻找两个正序数组的中位数:这题偏难,但它的二分思路是“在两个数组里分别排除前 k/2 个元素”,属于二分的高级变体,面试里遇到概率不低。
  • 69. x 的平方根:裸的答案二分,搜索范围[0, x],判定条件是mid * mid <= x。适合拿来巩固答案二分的基本功。
  • 278. 第一个错误的版本:纯 lower_bound 模板,连判定函数都是现成的isBadVersion(mid)。很适合作为入门练习。

如果你能把 875、1011、153、33 这四道题吃透,再去碰 4 题(中位数),会比直接硬啃轻松得多。因为你的心里已经有了“单调判定”“部分有序”“排除一半”这些思维框架,剩下的只是把它们组合起来。

我自己刷二分最大的体会是:这玩意儿不能靠记忆,得靠推导。每次写的时候,在心里把循环不变量的区间画一遍,比背十套模板都管用。尤其是当你从简单题过渡到中等题、从查找型过渡到答案二分型时,你会发现所有题目殊途同归——无非就是回答三个问题:搜索范围是什么?判定函数是什么?答案落在哪个边界上?把这三个问题回答清楚了,代码自然就出来了。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询