☰
搜索插入位置:二分查找边界条件与lowerBound实现详解
2026/10/15 15:36:26 网站建设 项目流程

1. 题目场景与核心思路拆解

LeetCode热题100里的“搜索插入位置”这道题,我刷了不下三遍,每一遍都有新体会。题目本身不长:给定一个排序数组和一个目标值,在数组中找到目标值并返回下标,如果不存在,则返回它会被顺序插入的位置。看起来就是个“查找 + 定位”的简单活,但真正写过的人都知道,这道题是二分查找变体里面最容易翻车的一道,没有之一。

先说这道题在考什么。它表面是在考“查找”,实际是在考你对区间收缩的把握程度。数组是排好序的,这给了你“可二分”的前提。目标值存在,就是标准的二分查找;目标值不存在,你还需要回答“它应该被插到哪个位置”这个额外问题。很多人在第二个问题上栽跟头——不是不会写二分,而是不知道循环结束之后,指针停在哪里、那个位置为什么恰好就是插入点。

这道题适合谁看?如果你刚开始刷LeetCode,它是你建立二分查找信心的第一道坎;如果你面试前想快速复习二分边界,它比单纯的二分查找更有区分度;如果你是老手,也可以看看后面的变体整理,很多边界题目都是从这一题衍生出去的。我个人觉得做题的价值不在AC本身,而在于能不能用一句话说清楚“为什么返回left而不是right”。

先说暴力解法为什么不值得推荐。有人觉得,反正数组有序,直接遍历一遍,找到就返回下标,遇到比目标值大的元素就返回当前下标,遍历完了还没找到就返回数组长度。这个解法能AC,时间复杂度O(n),在数组不大的时候完全没问题。但它没有用到“有序”这个关键条件,等于把送分题做成了送命题。面试官问一道排好序数组的查找题,不是想看你写for循环,而是想看你能不能说出二分查找的复杂度优势和一个完整循环不变量的推导过程。这也是LeetCode把这道题归为“二分查找”专题的原因。

二分查找的核心直觉其实很简单:你有一本按页码排好的词典,要找某个词。你不可能从第一页开始翻,而是每次翻到中间,判断这个词在左半边还是右半边,然后丢掉一半,继续在剩下那一半里面重复这个过程。每翻一次,搜索范围就缩一半。数组一旦有序,就能用同样的策略把一个O(n)的线性搜索压缩到O(log n)。

2. 二分查找必须理解的三件事

2.1 循环不变量才是二分的地基

我见过很多人写二分靠背模板,边界条件一改就崩。问题的根源在于不理解“循环不变量”——也就是在每一轮循环开始之前,你手里的搜索区间到底是什么。

假设我们维护的是一个左闭右闭区间[left, right],那么每一轮循环开始时,这个区间内包含的元素都是“尚未排除嫌疑”的候选元素。循环体里,计算出中点的mid,然后拿nums[mid]和目标值比较,根据大小关系把区间砍掉一半。砍完之后,新得到的区间依然满足同一个性质:所有可能包含答案的位置都还在这个区间里。这就是循环不变量。

用这个视角去看,很多边界问题会瞬间清晰。比如while (left <= right)这个条件,为什么是<=而不是<?因为在左闭右闭的模型下,当left == right时,区间里还有一个元素没有检查,你必须在下一轮循环里处理它。如果写成<,循环体永远不会处理left == right这个位置,结果就会漏掉边界元素的判断。

2.2 边界收缩靠的是“排除法”

很多人纠结mid + 1还是mid、mid - 1还是mid。这里有一个非常实用的判断方法:当你已经确定mid这个位置不可能是答案的时候,你才可以把它排除掉,也就是下一步搜索区间要跳过它。

如果nums[mid] < target,说明mid以及它左边的所有元素都比目标值小,它们都不可能是答案或者插入位置。所以下一步left = mid + 1,把mid本身也丢掉。

如果nums[mid] >= target,情况要稍微分一下。找普通二分查找(只找相等位置)时,mid有可能就是答案,所以你不能直接丢掉它,要让right = mid,让它留在下一轮的区间里。但如果像这道题一样找“第一个大于等于 target 的位置”,并且你已经用>=把右边全部排除了,那么下一步的right = mid - 1也是常见的写法,因为最后返回left时,left会自然停在你想要的位置。这里面的差异值得反复品味,也是后面两个版本的核心区别。

这套“排除法”比机械记忆模板可靠得多。每次写完二分,在心里过一遍“有没有可能把正确答案排除掉?”,如果有,边界就写错了。

2.3 三种模板各有各的脾气

LeetCode题解区常见的二分模板大致有三个。模板一用while (left <= right),循环结束时left > right,适用于简单的查找和插入位置;模板二用while (left < right),循环结束时left == right,经常配合right = mid使用,用于找左边界;模板三是left + 1 < right,保住两个候选位,循环结束后再判断两个位置,适合处理相对位置关系复杂的问题。

模板之间没有绝对的好坏,但对你能否一次写对影响很大。我的建议是:熟练使用一种,然后了解其他两种怎么转换。对搜索插入位置这道题,用模板一配合“返回 left”是最容易讲清楚的一种思路。后面我会给三个版本的Java代码,分别对应不同的习惯,你挑一个吃透就行。

3. Java实现:三个能直接跑的版本

3.1 版本A:经典三段式二分

先给出最直觉的版本——先按普通二分查找找目标,找到直接返回下标;找不到时,left停留的位置就是插入位置。

public int searchInsert(int[] nums, int target) { int left = 0; int right = nums.length - 1; while (left <= right) { int mid = left + ((right - left) >> 1); if (nums[mid] == target) { return mid; } else if (nums[mid] < target) { left = mid + 1; } else { right = mid - 1; } } return left; }

这段代码的思路很直白:left和right维护的是左闭右闭区间。循环里先看中间位置,命中就返回。如果不命中,根据大小关系排除掉一半区间。循环结束后,left指向第一个比target大的位置,right指向最后一个比target小的位置,插入位置自然是left。

我碰到不少人在这一步会问一句话:“为什么循环结束了不返回right + 1?”答案是一回事,但left更符合直觉。循环结束时left > right,left = right + 1,而right指向最后一个小于target的元素,所以left就是第一个大于等于target的位置。一旦你接受了这个解释,以后写所有 lowerBound 类型的题目都不容易懵。

3.2 版本B:统一lowerBound写法

这个版本的代码更短,但它引入了“第一个大于等于 target 的位置”这个抽象概念。理解了它,你就同时拿下了三四个变体题。

public int searchInsert(int[] nums, int target) { int left = 0; int right = nums.length - 1; while (left <= right) { int mid = left + ((right - left) >> 1); if (nums[mid] >= target) { right = mid - 1; } else { left = mid + 1; } } return left; }

核心区别在于条件被压缩成了二选一:nums[mid] >= target时移动右边界,否则移动左边界。这个写法的淘汰逻辑是:如果中间元素不小于目标值,那么中间元素可能是答案,但右边所有更大的元素都不可能是“第一个大于等于 target 的位置”,于是收缩右边界到mid - 1,注意这里没有丢掉mid,因为它可能成为最终答案,只不过要等left最后走过来撞上它。如果中间元素小于目标值,那它连候选的资格都没有,直接left = mid + 1。

在没有重复元素的有序数组里,这两个版本的行为完全一致。有重复元素时,版本B的返回结果会偏向左边界,这反而符合很多场景的需求。LeetCode官方题解给出的就是这种思路,因为它的可解释性更强:搜索插入位置本质上就是找lowerBound(target)。

3.3 版本C:递归版本(理解用)

递归版本的优点是代码结构清晰,缺点是栈开销和不容易看出边界逻辑。刷题阶段可以拿它练手,但不推荐在面试现场写。

public int searchInsert(int[] nums, int target) { return binarySearch(nums, target, 0, nums.length - 1); } private int binarySearch(int[] nums, int target, int left, int right) { if (left > right) { return left; } int mid = left + ((right - left) >> 1); if (nums[mid] >= target) { return binarySearch(nums, target, left, mid - 1); } else { return binarySearch(nums, target, mid + 1, right); } }

递归终止条件是left > right,返回left,和版本A的结束状态完全一致。递归版能帮你验证自己对循环不变量的理解是否到位——如果你能分析清楚每一层递归传入的区间代表什么,迭代版本基本也不会写错。我自己的学习路径是先写递归、再改迭代,因为递归强制你显式地写出区间的变化,不容易靠“背模板”蒙混过关。

4. 复杂度、运行对比与边界问题排查

4.1 时间与空间复杂度

二分查找的时间复杂度是O(log n)。推演过程不复杂:每轮循环把搜索范围砍成一半,从n到n/2到n/4,直到变成1,需要log2(n)次。空间复杂度方面,迭代版本只用了固定数量的变量,是O(1);递归版本由于栈深度为log n,是O(log n)。

拿一个实际数据对比会更直观:n = 100万时,线性查找最坏需要100万次比较,二分查找只需要20次。这个差距在面试里很能说明问题。LeetCode上这道题的数据规模不大,暴力解也能通过,但复杂度分析一定要写清楚,因为面试官考察的就是这个敏感度。

4.2 高频边界Bug实录

我统计过身边人写这道题最容易踩的三个坑,全部列在下面,每个都是真实现场。

第一个Bug是返回right而不是left。在目标值不存在的情况下,right指向的是最后一个小于target的元素,它不是插入位置。比如数组[1, 3, 5, 6],target = 2,循环结束后left = 1,right = 0,正确答案返回1,你返回0就错了。这类Bug错得毫无预兆,因为不是每轮测试都能发现,建议直接记结论:标准左闭右闭模板里,插入位置返回left。

第二个Bug是while (left < right)写成了<,然后在循环末尾又用了left = mid这种收缩方式。这种情况下,当区间只有两个元素时,mid可能等于left,nums[mid] < target时左边界不动,区间永远不缩小,程序就死循环了。LeetCode默认的题目没有超时阈值也扛不住这种写法,本地一跑就是卡死。

第三个Bug是计算mid时用了(left + right) / 2。在Java里,当left + right超过Integer.MAX_VALUE时会溢出成负数,导致数组下标越界。数组长度虽然是int,但理论上确实存在这个边界风险。我写的所有Java二分代码一律用left + ((right - left) >> 1),或者写成left + (right - left) / 2,彻底规避溢出。

4.3 死循环的根源逃不开mid的下取整

死循环的根源在于Java整数除法向下取整。当区间长度为2时,mid = (left + right) / 2会取到左边那个位置。如果这个分支恰好把left更新成了mid,区间长度就会一直保持2不变,永远缩不短。

避免死循环的方法有三个,按个人偏好排序:要么用while (left <= right)配合left = mid + 1或right = mid - 1,这两种收缩方式保证每一轮区间至少缩小1个元素;要么用while (left < right)配合right = mid和left = mid + 1,这种情况mid永远不会等于right,不会原地踏步;要么在left = mid那个分支里手动改成left = mid + 1,但这要求你确实能证明mid不可能是答案。

我建议把“每次循环都必须让区间长度减小”这条规则当作强制要求。写完二分以后,拿两个元素的数组手动跑一遍,只要两个元素时不会卡死,基本就不会死循环。

5. 从本题延伸出去的二分变体

5.1 找目标值出现的左右边界

LeetCode第34题“在排序数组中查找元素的第一个和最后一个位置”是这道题最直接的进阶版。解法就是两次二分:第一次找第一个大于等于target的位置,第二次找第一个大于target的位置,然后把两个位置夹出来的区间作为答案。

这题的代码跟版本B几乎一样,只是最后要额外判断一下找到的边界是否真的等于target。先说结论:用lowerBound函数找起始位置,再用upperBound找结束位置,两条二分写完,答案就出来了。如果你把搜索插入位置这题的逻辑彻底搞懂,第34题就是多写一个函数的事,完全不需要背新模板。

5.2 “第一个大于等于”和“第一个大于”的关系

“第一个大于等于 target 的位置”是lowerBound,“第一个大于 target 的位置”是upperBound。前者就是本题的答案。后者也可以通过同一个模板稍微改一下条件得到:把if (nums[mid] >= target)改成if (nums[mid] > target)。

这两个函数的返回值之间的关系很有用:[lowerBound(target), upperBound(target) - 1]就是目标值在数组中出现的完整区间。这个关系在刷题中反复出现,比如统计某个分数段的人数、寻找第一个坏了的天数,全都可以归约到这两个函数。

5.3 看起来不像二分但本质是二分的题

还有一类题,输入根本不是一个数组,但同样能用二分。比如“寻找峰值”和“山脉数组的峰顶索引”:虽然数组不是整体有序,但具备单调性变化的局部特征,二分依然成立。LeetCode 852题“山脉数组的峰顶索引”,只要判断nums[mid]和nums[mid + 1]的大小关系,决定往左还是往右缩小范围,代码量跟这道搜索插入位置不相上下。

我在实际工作中也用过这种思路:一个监控指标的曲线在某段时间内持续上升或下降,想找拐点,数据点很多,线性扫描太慢,用二分定位拐点就非常合适。LeetCode的二分专题练完之后,你对这些场景的嗅觉会明显变敏锐。

6. 面试复盘与实战心得

6.1 我给新手推荐的讲解顺序

面试现场写这道题,不推荐直接闷头写代码。我先做的第一件事是:说清“数组有序 + 查找”这两个关键词,主动抛出二分查找。然后分析边界条件:目标值存在与不存在,两种情况分别怎么处理。接着写代码,在写left和right的更新时,顺带解释为什么left = mid + 1而right = mid - 1。最后拿两个小例子[1, 3]、target = 0和target = 2手动推一遍,演示循环结束后left停在哪里。

这个顺序的好处是,每一个关键节点你都主动交代了思路,面试官能跟上你的节奏,自然也更容易给出正面反馈。很多人不是不会写,而是写完了不说话,面试官问“为什么返回 left”时支支吾吾,分数就掉了。

6.2 面试官常追问的几个点

追问一:如果数组里有重复元素,返回的位置还是不是唯一的?答案是仍然返回最左边的插入位置,因为版本B的写法本身就是找左边界,重复元素不会影响结果。追问二:如果target比数组中所有元素都大,结果是什么?循环结束后left = nums.length,这正好是数组最后一个位置的下一个位置,语义上完全正确。追问三:能不能用Arrays.binarySearch直接做?可以,Java自带的二分查找返回负数时,插入位置是-index - 1,但面试场景大多数时候希望看到你自己实现,而不是调库。

这道题我在本地最少跑了不下二十个测试用例,覆盖了目标值在最前面、在最后面、不存在于中间、数组只有一个元素、数组空(这个通常不考,但防御性编程加分)。数组为空的情况,代码里要提前判一下,否则直接访问nums[0]会越界。LeetCode题目默认数组长度大于等于1,但写防御性判断在面试里是加分项。

6.3 一些个人习惯

我个人的习惯是:每次写完二分,都强制自己回答三个问题。第一个问题,循环结束时left和right分别站在什么位置?第二个问题,下一次循环开始前,区间内还包含哪些候选元素?第三个问题,如果答案不在区间里,我是不是已经在某个分支把它排除掉了?这三个问题回答顺畅,代码基本不会错。

还有一个小技巧:在编辑器里写二分时,故意构造几个边界用例去跑。比如nums = [2, 3, 4, 5]时分别查1、2、6;nums = [1]时查0、1、2。这些用例跑过一轮,几乎所有边界Bug都会现形。LeetCode的测试数据覆盖确实全,但本地手动跑一遍能帮你把问题定位到具体是哪一步逻辑出了问题,这比盲试错误用例高效多了。

搜索插入位置这道题刷完之后,我的建议是立刻刷掉第34题和第852题,三题连刷能把二分从“背模板”提升到“设计算法”的层面。我自己当初就是这样做的,效果很明显——之后再碰到任何二分变体,不会再有那种“好像见过但又抓不住重点”的无力感。二分查找这个工具,排在有序数组的场景下就是一把万能钥匙,而这把钥匙的坯子,就是你现在正在打磨的这道搜索插入位置。

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

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

立即咨询