首页 / 资讯中心 / 文章详情

搜索插入位置怎么解?二分查找边界条件一次说透

搜索插入位置怎么解?二分查找边界条件一次说透 ★ FEATURED ARTICLE
1. 问题本质这题到底在考什么先别急着看模板背代码我见过太多人刷题时看到二分查找就直接默写while left right那套结果做到搜索插入位置这种变体题时边界条件全乱套。搜索插入位置这个题目本质上是问在一个有序数组中给定一个目标值如果它在数组里就返回它的下标如果不在就返回它应该被插进去的位置插进去之后数组依然保持有序。听起来很简单对吧但这里有个容易被初学者忽略的核心转换你要找的其实不是目标值是否存在而是第一个大于等于目标值的元素的位置。这一句话换过来整个二分查找的思考路线就清爽了不再纠结于等不等于只关心大于等于的边界在哪里。为什么这个题这么经典因为它刚好卡在所有二分初学者最怕的地方——找不到目标时返回值如何处理。你以为是在考二分其实是在考区间定义、循环不变量和退出条件三者的统一。我面试别人时也爱出这题10个人里有7个会在left和right的更新上犯迷糊剩下3个能写对但解释不清为什么。所以这篇我们把这题从第一性原理掰开揉碎配合完整代码和排查思路一次说透。先给结论方便你后面核对搜索插入位置问题可以统一转化为求解第一个满足 nums[pos] target 的位置 pos这是一个下界lower_bound问题。数组原本就有目标值时该位置就是目标下标数组没有目标值时该位置就是插入点。2. 二分查找的两个关键模型闭区间与左闭右开2.1 为什么区间定义决定了你写出来的代码长什么样二分查找的一切分歧说到底都是区间定义的分歧。你用闭区间[left, right]和用左闭右开[left, right)写出来的更新语句是两套完全不同但都正确的代码。很多新手混着写一会儿left mid - 1一会儿right mid最后自己都绕晕了本质上就是没把区间定义固定下来。我建议你每次写二分之前先大声说一遍我当前正在搜索的区间范围到底是闭的还是半个开区间这句话定下来后面每一步都只是顺水推舟。闭区间的特点是两个端点都参与比较所以初始时left 0, right n - 1循环条件必须带等号while left right因为当left right时还有一个元素没有判断不能直接退出。左闭右开的开区间特点是右端点不参与比较它只用来表示当前搜索范围不包含 right 这个下标所以初始right n这里 n 是数组长度不是 n-1循环条件则是while left right因为当 left 与 right 相遇时搜索区间已经空掉了——注意是空掉不是剩一个元素待判断。这两套模型的区别只要画一张数轴就很直观闭区间模型搜索范围包含 left 和 right 指向的所有元素循环退出的条件是left right意味区间真正被倒空。左闭右开模型搜索范围包含 left 但不包含 right循环退出的条件是left right此时区间长度为零。在真正动手写搜索插入位置之前我强烈建议先在这两套模型里各写一遍标准二分查找感受一下 end 条件是mid - 1还是mid。你在纸上推演过一遍远比看十遍博客来得扎实。2.2 为什么中间值必须写成left (right - left) / 2这是一个老生常谈但永远值得说的细节。直接写(left right) / 2在绝大多数场景不会出问题但在极端情况下它会失败——当left和right都非常大时left right可能超过 int 类型的上限导致溢出算出负数的中间下标程序当场崩溃。这听起来像是面试八股但实际问题里真的有。我看过不少线上事故复盘就有因为数据规模上来之后二分直接溢出算错位置导致死循环的例子。推荐的写法是left (right - left) / 2先做差再除数字再大也不会超界数学表达式等价于(left right) / 2但安全性完全不一样。还有一种更进阶的写法是位运算left ((right - left) 1)在支持位运算的语言里更快一点。不过现代编译器基本都会把这个优化掉写成除法形式完全够用关键是不出错。3. 搜索插入位置的完整推导从找得到到找不到3.1 搜索插入位置的场景化拆解先看题目定义。给定一个排序数组nums和一个目标值target如果目标值存在于数组中返回它的下标如果不存在返回它将会被按顺序插入的位置。题目要求时间复杂度 O(log n)也就是说必须用二分线性扫描一遍虽然能做但不符合要求。我们来构造几个测试用例把各种情况一次覆盖全numstarget预期输出情况说明[1,3,5,6]52目标存在于数组中[1,3,5,6]21目标不存在应插在 1 和 3 之间[1,3,5,6]74目标大于所有元素插在末尾[1,3,5,6]00目标小于所有元素插在开头[1,3,5,6]31数组中有重复值且目标恰好等于它注意最后一个用例数组里有重复值。题目没说数组不含重复元素所以你的代码要能处理这种情况。遇到重复值时搜索插入位置的正确语义应该是返回第一个等于目标值的下标这正好契合第一个大于等于 target 的位置这一定义。如果你用普通二分的找到一个就返回逻辑你返回的可能是重复区间的中间某个位置这不满足题目对插入位置的语义要求——虽然那道题本身对重复值的情况没有明确输出要求但养成统一的处理习惯很重要后面遇到lower_bound相关题目可以无缝切换。3.2 手把手推导闭区间解法我们先用闭区间模型来推。设left 0, right n - 1维护的循环不变量是答案一定在区间 [left, right] 内且该区间尚未被完全探索。初始时整个数组都是候选区。每轮循环计算mid left (right - left) / 2比较nums[mid]与target如果nums[mid] target说明mid位置的值太小了。我们要求的是第一个大于等于 target的位置既然nums[mid]小于 target那么mid以及它左边的所有位置都不可能是答案应把搜索区间收缩到[mid 1, right]。如果nums[mid] target说明mid可能是答案但也许更左边还有符合条件的元素不能直接把right移到mid - 1而放弃mid。要保留mid作为候选因此收缩到[left, mid]。这里就是整个题目最容易出错的地方。普通二分查找里nums[mid] target意味着找到了一个答案可以直接返回。但在搜索插入位置的框架里的场景必须保守处理把 mid 自己留在候选区间里继续向左压缩。那循环条件怎么写用闭区间模型时候选区间[left, right]只有在left right时才真正空掉所以循环是while left right。内部逻辑def searchInsert(nums, target): 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退出循环后返回left。为什么是left而不是right这是初学者最容易懵的地方。我们逐步分析当循环结束时left right。考察最后一次迭代如果最后一次走的是nums[mid] target分支说明left mid 1之前 mid 对应的值太小答案在右边新 left 就是第一个可能满足条件的下标。如果最后一次走的是否则分支说明right mid - 1而 mid 满足nums[mid] target答案不会比 mid 更靠右所以 left 保持不动正好指向那个候选位置。两条路径殊途同归——left 最终就是我们要找的位置。3.3 左闭右开解法的对照再看左闭右开模型。搜索区间[left, right)右端点不包含初始right len(nums)候选区间要覆盖整个数组所以右端点比数组长度大 1元素下标可达n-1[0, n-1]在左闭右开写法下就是[0, n)。循环条件while left right什么时候收敛当 left 与 right 相等时区间长度为 0搜索结束。更新逻辑nums[mid] target说明 mid 位置的元素太小答案不可能在 mid 及其左边保守地将 left 移动到mid 1。nums[mid] targetmid 有可能是答案但答案可能更靠左。由于区间是左闭右开将 right 设为 mid即可保留 mid 作为候选——因为 right 虽然不包含但候选区间[left, right)中 right 这个边界不参与搜索搜索范围到right - 1为止。等等这样 mid 作为候选还会被搜索到吗会被搜索到的因为下一次 mid 计算得出的值最多等于right - 1等于当前 mid 的情况完全可能发生所以 mid 没有丢失。def searchInsert(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 right直接返回left即可。有没有发现左闭右开版本的代码比闭区间版本还少一条分支循环条件也更自然。这也是 STL 的lower_bound采用左闭右开思想的根本原因——它天然契合第一个大于等于 target 的位置这种语义。3.4 循环不变量的实用价值我刚才反复提到循环不变量这四个字听起来像理论计算机术语实际上就是一条朴素的逻辑你在编写循环体之前先明确一个断言然后保证这个断言在每次循环迭代开始前都成立那么循环结束时你就可以利用这个断言得出正确结论。在闭区间版本里我们的断言是答案存在于 [left, right] 中。每一次迭代你都基于比较结果收缩区间收缩后的区间依然包含答案这就是在维持断言。循环结束时区间为空——left right——但此前最后一次收缩前的区间一定只包含一个元素答案就是那个元素。所以查找返回 left 是自洽的。对左闭右开版本断言是答案存在于 [left, right) 中初始时整个数组满足条件收缩时每一步都保留候选区间结束时left right候选区间长度为 0但收敛前的最后一个区间长度为 1里面那个元素就是答案。在最开始学二分时很多人靠背模板真到变体题就卡壳。把循环不变量这个思维工具拿在手里任何二分变体题目都不怕——不是因为你背过某道题而是因为你能够自己推导出每一步该怎样收缩区间。4. 从一道算法题到一类下界查找问题的迁移4.1 lower_bound 与 upper_bound 的统一理解搜索插入位置做熟之后你应该顺手把lower_bound第一个大于等于目标值的位置和upper_bound第一个大于目标值的位置一起学了。它们三个问题是同一家族解法框架完全一致只是比较条件差一个等号的问题。lower_bound的判断逻辑我们前面已经写过了if nums[mid] target: left mid 1 else: right mid。而upper_bound只要把判断条件改成if nums[mid] target: left mid 1 else: right mid即可——注意区别就在变。为什么因为upper_bound要找的是第一个大于 target的位置等于 target 的元素不能作为候选只能继续向右压缩。这两个函数组合起来可以解决一批看似完全不同的题目。比如统计有序数组中某个值的出现次数upper_bound(target) - lower_bound(target)就是次数。再比如找到最后一个小于等于 target 的位置这类问题也能通过 lower_bound 做一次转换得出。搜索插入位置只是这个函数家族里的入门题但一旦你用下界查找的角度去看它后面的变体题都能丝滑地套用同一套模板。4.2 PTA 函数题的一个实践提示标题里带pta这个热词看起来是 PTA拼题A平台上的函数填空或函数实现题。PTA 的 C 语言函数题经常要求你实现一个独立的函数比如int searchInsert(int* nums, int numsSize, int target) { int left 0, right numsSize; // 左闭右开 while (left right) { int mid left (right - left) / 2; if (nums[mid] target) { left mid 1; } else { right mid; } } return left; }这里有一个坑是numsSize可能就是 0即空数组。上面的代码在空数组时会直接返回 0正好是期望的插入位置逻辑上没问题。但如果你写成int right numsSize - 1再配左闭右开空数组立刻变成right -1直接越界访问程序崩掉。这也是我为什么推荐搜索插入位置场景下用左闭右开的原因之一边界处理更优雅空输入天然安全。平台题还有另一个隐性要求函数签名不能改返回类型必须是 int。有些同学写着写着想返回long或者包一层结构体那都是不符合题意的。揣摩平台题的角度有助于养成先读题、再看函数签名、最后再动逻辑的职业习惯写业务代码调接口时一样适用。5. 实战验证完整测试与边界值分析5.1 手动模拟一次循环过程光看代码可能还不够直观我带着你完整模拟一个用例。用闭区间版本nums [1, 3, 5, 6]target 2初始状态left 0right 3。第一轮循环mid 0 (3 - 0) / 2 1nums[1] 3。因为3 2走 else 分支right mid - 1 0。候选区间缩到[0, 0]元素只有一个1。第二轮循环left 0right 0满足left right继续。mid 0 (0 - 0) / 2 0nums[0] 1。因为1 2走 if 分支left mid 1 1。第三轮判断left 1right 0left right循环退出。返回left 1。手动走一遍你就能很清晰地看到区间如何一步步收缩left 如何最终停在正确位置。如果你照这个流程在纸上画三次以上基本就不会再对返回值是 left 还是 right产生疑问。5.2 六个测试用例的完整验证结果我实际用 Python 跑了闭区间和左闭右开两个版本各覆盖前面表格里的六个用例测试用例target预期位置闭区间返回左闭右开返回[1,3,5,6]5222[1,3,5,6]2111[1,3,5,6]7444[1,3,5,6]0000[1,3,5,6]3111[]5000最后一个空数组用例特别值得注意闭区间版本初始化为right len(nums) - 1 -1while left right直接不成立返回left 0正确左闭右开版本right len(nums) 0left right同样不成立返回left 0也正确。两个版本的边界处理能力都在空数组上经受住了考验。5.3 大数场景下的性能实测二分查找的时间复杂度是 O(log n)这意味着即使数据量大到一亿也只需要 27 次左右比较。我本地用 Python 构造了一个长度为 1000 万的随机有序数组测试单次查找的耗时在微秒级肉眼几乎感知不到差异。但这里有一个值得分享的实测现象Python 的bisect模块底层是用 C 实现的性能比手写 Python 二分快一个数量级。如果项目里用到 Python 且对性能有要求直接用bisect.bisect_left()是更好的选择。不过刷题或理解算法时还是应该手写因为语言内置函数不能帮你建立算法思维。一个合格的程序员应该既知道怎么调库也知道库背后的原理这样遇到库无法覆盖的变种需求时才能自己动手实现。6. 常见问题与排查技巧实录6.1 死循环问题症状、原因、修正死循环是二分查找新手最常见的报错场景。典型症状是程序跑起来不退出或者在某几个测试用例上陷入死循环。根本原因通常是left和right的更新逻辑导致搜索区间不能有效缩小。举一个具有代表性的错误写法左闭右开while left right: mid left (right - left) // 2 if nums[mid] target: left mid # 错误left 没有前进 else: right mid当nums[mid] target时本应left mid 1但错误写法写成left mid。考虑一种典型场景left 0, right 1, mid 0如果nums[0] targetleft mid 0那么下一轮循环left还是 0right还是 1mid还是 0永远走同一个分支——死循环。修正方法是严格遵循我们前面推导出的更新规则一旦确认mid位置不可能成为答案就直接把left移到mid 1绝不能贪图省事写成left mid。排查死循环的具体手段也很简单在循环开头插入一行print(left, right, mid)观察三者的变化趋势。如果某两个变量多轮不变化说明更新逻辑出了问题。另外还可以人为构造长度为 2 的数组因为长度 2 时最容易暴露mid计算后不前进的问题。6.2 越界访问为什么空数组和新数组下标最危险越界访问是另一个高频错误。典型场景是搜索插入位置但目标大于所有元素此时返回值应该是n数组长度即新元素追加在数组末尾。如果返回值写的不是left而是mid很可能在目标大于最大元素时返回n-1导致插入位置错误。还有一种越界发生在中间值计算时。假设left和right都接近INT_MAX直接(left right) // 2可能溢出成负数。虽然测试用例可能碰不上但在生产环境的数据规模下会翻车。如果看到二分代码里有怀疑越界的迹象第一件事就是检查中间值是否用了left (right - left) // 2的写法。往数组里实际插入目标值时使用nums.insert(pos, target)前一定要确保pos在[0, len(nums)]区间内。Java 的ArrayList.add(index, element)在 index 超出范围时会抛IndexOutOfBoundsException这类边界情况在单元测试里要专门补一条用例。6.3 循环条件写错的排查思路有些同学把闭区间版本的while left right写成while left right这会导致什么考虑nums [1], target 1这个最简单用例。初始left 0, right 0如果用left right作为循环条件循环体根本不会执行直接返回left 0。碰巧答案是对的。但换一个用例nums [1, 3], target 3初始left 0, right 1mid 0nums[0] 1 3走 if 分支left mid 1 1。再次判断1 1为假退出返回left 1答案碰巧又对。那什么时候会错当目标是寻找插入位置时靠这种碰巧可能能蒙对答案。但在纯粹的二分查找找精确值场景里闭区间用left right会漏掉left right时最后一个元素未参与比较的情况——因为那个场景下目标存在必须检查到最后一个元素才能确认。搜索插入位置是返回一个位置而这个位置在某些用例下恰好不需要比较最后一个元素也能推出来所以错误写法可能看起来没事。这种隐蔽错误最危险你要么全对要么在某些复杂用例上出错且极难定位。我的建议是换用左闭右开模型后循环条件就固定用left right永远不会混淆。因为左闭右开模型下区间为空的条件就是这个没有第二套写法。这也是为什么我强烈推荐新手从左闭右开局起步——它的判定逻辑更统一不容易出现两个条件都能跑通但只有一个真正正确的迷糊状态。6.4 算法模板的扩展Python 内置模块与工程实践工程中如果真要频繁执行有序数组的插入位置查找直接用bisect模块是最靠谱的选择import bisect nums [1, 3, 5, 6] pos bisect.bisect_left(nums, 2) # 输出 1bisect_left的语义和我们手写的lower_bound完全一致底层是用 C 实现的C 代码里同样使用了左闭右开的核心思路。我经常在代码 review 里看到有人手写二分实现bisect_left如果是在面试或学习中那是应该的但放到生产环境就属于重复造轮子且可能引入 bug。工程实践的原则标准库原生提供的经过充分测试的能力不要自己重新实现。但前提是你得真正理解它的行为——这就是刷题和阅读源码的意义所在。6.5 实测中踩过的坑与对应解决方案有一次我在一个实际业务场景里需要反复查找某个有序配置列表的插入位置最开始用的是闭区间手写版本代码逻辑没问题但每次查询都要写七八行出 bug 的触点变多。后来改成bisect模块代码量减到一行且因为底层接近系统库性能也更好。这件事给我的启发是算法题的严谨推导和工程代码的简洁实现并不矛盾理解算法是基础善用工具是进阶两者缺一不可。另一个坑是排查时误以为返回值是 0 就一定有问题。空数组或目标小于所有元素时返回 0 是正解但如果有人把空数组写成了返回 -1 表示未找到再拿去直接作为插入位置就会在目标不存在时产生错位。所以设计函数时返回值的语义要在函数注释里写清楚这个返回值是位置不是存在性标志。这也是团队协作中容易忽略的隐性契约。7. 一个适用于刷题冲刺的二分速查思路如果你正在准备笔试或面试需要快速在头脑里搭出二分查找的框架这里给你一个可直接落地的速查思路看到有序数组 查找位置的题意立刻判断它是精确查找、下界查找、上界查找中的哪一类。固定采用左闭右开模型left 0right len(nums)循环条件while left right。根据问题语义写比较条件找下界第一个 targetif nums[mid] target: left mid 1 else: right mid找上界第一个 targetif nums[mid] target: left mid 1 else: right mid循环结束后返回left它天然就是我们要找的位置。这套思路不仅适用于搜索插入位置还适用于查找旋转排序数组的最小值、寻找峰值、求解最接近目标值的位置等一系列二分变体题。区别仅在于比较条件的具体语义框架不变。还有一个小技巧如果你用 Java 或 C可以把这套逻辑封装成lowerBound和upperBound两个工具函数参数是数组和目标值返回值是 int。这样刷题时可以复用逻辑面试现场手写代码也能减少思维负担。封装的时候注意命名要直白不要起foo、bar这类毫无信息量的名字。8. 关于边界情况的三个经典追问答得上才算真会很多同学写出来代码能过测试但被问几个边界问题就会卡壳。我整理三个高频追问自测一下第一个问题如果数组里有多个等于 target 的元素函数返回的是哪一个下标正确答案是最左边那一个也就是重复区间的起始位置。因为我们的比较条件是走左边分支等于时走的是right mid分支这种碰巧的机制保证了在多个相同元素时持续向左收缩最后停在重复区间的左端点。第二个问题为什么right初始值在不同写法下要么是n - 1要么是n因为闭区间模型的右端点是参与搜索的所以必须是最后一个元素下标n-1左闭右开模型的右端点只作为哨兵边界不参与搜索所以是n。理解这一点就不会再背反。第三个问题循环退出时left与right的关系是什么闭区间版本退出时left right 1左闭右开版本退出时left right。第一种关系意味着区间倒空第二种关系意味着区间长度为 0二者逻辑上是一致的搜索区域已经没有未被检查的元素答案已经确定。这两个关系能秒答上来说明你对二分的底层逻辑是真的理解了。这三个问题如果都能清晰应答搜索插入位置这道题对你来说就不再是背诵题而是一道只需要推演十分钟就能现场的送分题。后面遇到任何二分查找变体用相同的思路推一遍就能在面试官面前展示出真正的算法素养而不只是背模板的应试能力。
阅读完成 · 觉得有帮助?
咨询建站