二分查找边界问题深度解析:区间定义与循环不变量 1. 二分查找的核心思想与适用场景《代码随想录》二分查找这一章我前前后后刷了三遍每次以为自己懂了换一道变体题又卡住。后来才发现问题不在思路而在区间定义和边界更新的细节没真正吃透。二分查找的思想可以用一句生活话概括在一本有序的字典里查单词你永远不会从第一页翻起而是直接翻到中间页根据字母范围决定往前翻还是往后翻每翻一次就排除一半的页面。这个翻中间页的动作就是二分查找的每一次迭代。但二分查找不是万能的它有三个隐藏前提缺一个都不能保证正确必须能随机访问数组可以链表不行。链表取中间元素是O(n)的二分查找的优势就被抵消了。数据必须有序至少具备某种单调性。严格递增、非递减都行但必须能通过中间值判定目标值在哪一侧。区间边界必须明确这是在代码层面最容易翻车的地方后面会重点展开。适用场景上除了最常用的有序数组找目标值还有几个高频变体查找第一个等于目标值的下标、查找最后一个等于目标值的下标、查找第一个不小于目标值的位置即lower_bound、在旋转有序数组中查找、在二维矩阵中查找。LeetCode上对应的经典题就是 704、35、34、33、74 这几道。我认为学习二分查找最忌讳的事情就是背模板。很多人把while (left right)和while (left right)当成两条口诀来背却完全不知道它们分别对应什么区间定义。一旦遇到变体题背模板的人必挂真正理解区间定义的人才能举一反三。2. 边界问题的本质区间定义决定一切二分查找之所以让无数人栽跟头根源只有一个你初始化的时候怎么定义 left 和 right决定了后续所有循环条件和边界更新的写法。这三者必须自洽不能混搭。常见的区间定义有两种左闭右闭[left, right]和左闭右开[left, right)。2.1 左闭右闭区间 [left, right]这是最容易理解也最推荐新手使用的定义方式。它意味着left 指向的元素和 right 指向的元素都在当前搜索区间内。在这种定义下初始化时right nums.length - 1因为数组最后一个下标是可以取到的。循环条件必须是while (left right)。为什么允许等于因为当left right时nums[left]这个元素还在区间内还没有被检查过必须再进循环体判断一次。更新时如果nums[mid] target说明 target 在右侧且mid位置的元素已经排除所以left mid 1如果nums[mid] target说明 target 在左侧且mid已排除所以right mid - 1。2.2 左闭右开区间 [left, right)这种定义下right 指向的元素不在搜索区间内或者说 right 更像是一个哨兵边界。对应地初始化时right nums.length因为nums.length本身不是一个合法下标它只是一个开区间终点。循环条件必须是while (left right)。当left right时区间为空再进去没有意义。更新时如果nums[mid] target左侧的 mid 已被排除left mid 1如果nums[mid] target因为右侧是开区间right mid就能把 mid 以及 mid 右侧的元素全部排除不需要mid - 1。这两种写法没有任何孰优孰劣之分但绝对禁止混用。比如初始化用right nums.length开区间循环条件却写while (left right)那必然出现数组越界访问初始化用right nums.length - 1闭区间循环条件却写while (left right)最终你会漏掉一个元素——也就是当 left 和 right 指向同一个元素时循环提前退出target 恰好是这个元素就找不到了。我见过太多人在这里出错包括我自己早期刷题时也干过这事。我的建议是先选定一种区间定义把它的初始化、循环条件、边界更新三条全部写死形成自己的肌肉记忆。我自己常年在代码中统一使用左闭右闭任何变体都先归约到这个区间上来思考这样一致性最强。3. 代码模板与逐行解析说完了理论直接上代码。下面我用 Python 给出两种区间下的标准模板并逐行解释关键逻辑。3.1 左闭右闭标准模板def binary_search(nums: list[int], target: int) - int: left, right 0, len(nums) - 1 # 闭区间 [0, n-1] while left right: # 当区间不为空 mid left (right - left) // 2 # 防止溢出的写法 if nums[mid] target: return mid # 找到返回下标 elif nums[mid] target: left mid 1 # target 在右侧排除 mid else: right mid - 1 # target 在左侧排除 mid return -1 # 没找到很多初学者喜欢写mid (left right) // 2在 Python 里其实没问题因为 Python 的整数没有溢出概念。但如果你写 C 或 Java当left和right都接近 2^31 数量级时left right可能直接溢出成负数。所以我一律推荐left (right - left) // 2这种写法它用减法替代加法从根本上规避了溢出问题。养成这个习惯面试写 C 时就不用临时想。3.2 左闭右开标准模板def binary_search(nums: list[int], target: int) - int: left, right 0, len(nums) # 左闭右开 [0, n) while left right: # 区间非空条件 mid left (right - left) // 2 if nums[mid] target: return mid elif nums[mid] target: left mid 1 # 排除左侧 else: right mid # 排除了 mid 及右侧 return -1注意这两处差异循环条件从变成了右侧更新从mid - 1变成了mid。原因就在于右边界是否是开区间right mid这个操作实际上已经排除mid本身了不需要画蛇添足再减一。3.3 统一记忆方法三个问题自检每当你写完一个二分查找别急着提交先自己回答三个问题初始化的right到底是有效下标还是哨兵位置当前区间为空时left 和 right 满足什么关系循环条件是否与之对应更新边界时mid 这个位置是否已经被比较过、可以安全排除如果三个问题的答案自洽代码基本就对了。如果发现循环条件与初始化不匹配或者更新边界时对 mid 的处理前后矛盾那八成就是混用区间导致的。4. 三道必刷题实操35、34、704二分查找面试考得最多的就是这三道题全部吃透才算真正掌握。4.1 LeetCode 704二分查找最基础这就是上面的标准模板直接套左闭右闭即可。class Solution: def search(self, nums: List[int], target: int) - int: left, right 0, len(nums) - 1 while left right: mid left (right - left) // 2 if nums[mid] target: return mid elif nums[mid] target: left mid 1 else: right mid - 1 return -1这道题没什么好说的唯一值得注意的是输入可能为空数组。好在while left right面对left0, right-1时循环根本不会进入直接返回 -1不会出问题。但如果用左闭右开写也要保证len(nums)为 0 时同样能走到return -1。4.2 LeetCode 35搜索插入位置题目要求在有序数组中找 target如果找不到返回它应该被插入的位置。这个插入位置本质上就是第一个大于等于 target 的下标。思路可以这样理解二分查找结束时如果有插入位置一定是left停留在的位置。因为我们的更新规则是当nums[mid] target时left mid 1也就是说一旦发现某个位置的值比 target 小left 就移到它后面。循环结束后left 指向的正是第一个不小于 target 的位置。class Solution: def searchInsert(self, nums: List[int], target: int) - int: left, right 0, len(nums) - 1 while left right: mid left (right - left) // 2 if nums[mid] target: left mid 1 else: right mid - 1 return left看到区别了吗这里把nums[mid] target的分支并入到了else中如果nums[mid] target就把搜索区间往左收缩。这样循环结束时left 天然指向第一个不小于 target 的位置。target 存在时left 就是目标下标target 不存在时left 就是插入位置。这种写法比额外判断相等更简洁也更通用。4.3 LeetCode 34查找 target 的左右边界题目要求返回 target 在有序数组中出现的第一个下标和最后一个下标不存在则返回 [-1, -1]。很多人一看到这题就慌其实拆解成两个独立的二分查找就好做了左边界第一个等于 target 的下标用lower_bound(target)实现。右边界第一个大于 target 的下标减一用lower_bound(target 1) - 1实现。这样就不需要在一个二分里同时维护左右两个边界逻辑清晰太多。当然直接用lower_bound(target 1)有个前提数组元素是整数target 的下一个整数必然比 target 大。这个技巧在整数数组里是通用的。class Solution: def searchRange(self, nums: List[int], target: int) - List[int]: def lower_bound(t: int) - int: left, right 0, len(nums) - 1 while left right: mid left (right - left) // 2 if nums[mid] t: left mid 1 else: right mid - 1 return left start lower_bound(target) # 如果 start 越界或者 start 位置的值不等于 target说明不存在 if start len(nums) or nums[start] ! target: return [-1, -1] end lower_bound(target 1) - 1 return [start, end]这里有一个最容易忽略的细节拿到 start 之后必须先检查它是否越界以及它指向的值是不是 target。因为 lower_bound 返回的是第一个大于等于 target 的位置如果 target 根本不存在它返回的是某个比 target 大的元素的下标或者是len(nums)。我个人在第一次写这题时漏掉了start len(nums)这个越界检查导致数组下标越界排查了半天。这个教训值得写在这里所有涉及 lower_bound 的场景拿到结果后第一件事是验证 target 是否真的存在。4.4 三道题的递进关系这三道题其实是一个由浅入深的递进过程704 是标准二分35 是把查找泛化为查找插入位置34 则是把二分的结果从一个点扩展到一个区间。它们的共同底层逻辑都是维护区间、循环不变量、正确收缩边界。能把 34 顺利写出来的人对二分的理解已经超过大多数面试候选人了。5. 进阶变体旋转有序数组与二维矩阵5.1 LeetCode 33搜索旋转排序数组题目给的是一个有序数组经过一次旋转后的结果比如[4,5,6,7,0,1,2]要求在 O(log n) 时间内找到 target。这题的核心洞察是虽然整个数组不是完全有序的但mid 以左和以右至少有一半是有序的。利用这一点我们可以先判断哪一半有序再判断 target 落在哪里。class Solution: def search(self, nums: List[int], target: int) - int: 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]而不是。原因是数组中可能有重复元素当left mid时区间只剩下两个元素nums[left] nums[mid]也属于有序情况如果写成会把这个情况误判到右半有序的分支。5.2 LeetCode 74搜索二维矩阵题目给了一个每行从左到右递增、每行的第一个元素大于上一行的最后一个元素的矩阵要求判断目标值是否存在。本质上这就是一个展开后的一维有序数组唯一多的事情是做一次坐标映射把一维下标mid映射成(row, col)。class Solution: def searchMatrix(self, matrix: List[List[int]], target: int) - bool: m, n len(matrix), len(matrix[0]) left, right 0, m * n - 1 while left right: mid left (right - left) // 2 val matrix[mid // n][mid % n] if val target: return True elif val target: left mid 1 else: right mid - 1 return Falsemid // n是行号mid % n是列号这两个公式不需要死记推导思路就是把一个 mn 的矩阵拍扁成一个长度为 mn 的数组。5.3 变体题的通用心法做变体题时我自己的思维路径永远是固定的这个题能不能归约为有序空间上的查找如果能区间是什么单调性体现在哪里如果不能直接二分能不能先通过一次判断缩小到一个有序子区间旋转数组的解法本质就是第 3 条先利用至少一半有序这个性质把搜索空间缩小到真正有序的那一半再套标准二分。所以变体题不是对基础二分的否定而是基础二分的组合应用。6. 常见问题与排查技巧二分查找的坑数来数去就那么几个但每个都能让人debug好一阵子。我把这些坑整理成一张速查表每个都是实测踩过的。症状可能原因正确做法循环不结束程序卡死mid 更新没有排除当前元素或者 left/right 更新逻辑导致区间不缩小检查是否应该用mid 1/mid - 1确保每次循环区间长度严格减小数组越界初始化了错误的 right或者 lower_bound 返回值未做越界检查闭区间用len - 1开区间用len使用 lower_bound 结果前先判断是否等于len漏掉目标值循环条件用了但区间实际是闭区间确保循环条件和区间定义匹配闭区间必须用结果差一位对第一个大于等于和第一个大于理解混了区分lower_bound(target)与lower_bound(target 1)的语义Java/C 中 mid 溢出直接写(left right) / 2用left (right - left) / 2有重复元素时边界不对没考虑nums[mid] target时该如何收缩找左边界时right mid - 1找右边界时left mid 16.1 死循环的根本原因死循环几乎都是因为区间没有严格缩小。比如左闭右闭区间中你写了left mid而不是left mid 1当区间长度为 1 时mid left更新后 left 还是原值于是无限循环。关于这个问题我自己的检查方式是故意构造一个只有两个元素的数组手动模拟一遍循环体。比如nums [1, 3]target 3走一遍左闭右闭的过程如果某一步出现了 left 或 right 不移动的情况代码必有 bug。6.2 调试二分查找的实用技巧不要一上来就全靠 print 看中间值那太低效了。我推荐两个方法第一在每个循环的入口处打印 left、right、mid 三个值观察区间是否在持续收缩。一旦发现连续两次循环 left/right 没有变化那 bug 基本就在边界更新语句上。第二写一个小的验证函数在二分查找结束后检查返回的下标如果返回 i则验证nums[i] target如果返回 -1则验证 target 不在数组中。这种自动化校验在本地跑几十组随机数据比肉眼盯着代码效率高得多。6.3 我对参数选择的习惯最后分享一个我自己的习惯所有二分查找我统一使用左闭右闭区间。原因无他就是一致性。无论是最简单的 704还是复杂的 lower_bound、旋转数组我都能先用左闭右闭推一遍再决定细节。左闭右开的规则我也理解但我不会在同一个项目里混用两种风格因为一旦混用思考负担成倍增加。如果你正在初学阶段我的建议也是选定一种区间定义先刷三五十道题把这一种写法练到肌肉记忆。等你对区间的理解足够深了再尝试另一种体会两者的不同。千万不要今天看一个模板用左闭右闭明天看另一个模板用左闭右开那只会让你在边界问题上反复摇摆。7. 写在最后的实操体会我个人在实际刷题过程中最大的体会是二分查找从来不是算法问题而是状态管理问题。只要你把区间状态定义清楚循环条件就自然写对边界更新就自然写对mid 的取法也自然写对。反过来如果状态没定义清背再多模板也只是表面会做。还有一个值得分享的小技巧把 lower_bound 这个函数单独抽出来当成一个查找第一个大于等于 target 的位置的通用工具。我在很多题目里都在复用这个函数——求插入位置用它求左右边界用它在二维矩阵里也用它的思想。把它练熟之后很多二分题都能快速化成对 lower_bound 的一两次调用。内容写到这里其实已经涵盖了二分查找从基础到进阶的绝大部分考察点。如果你现在能把 704、35、33、34、74 这几道题全部独立、流畅地写出来并且能向别人清楚地解释为什么循环条件是而不是那你的二分查找就可以算真正过关了。