二分查找算法详解:从基础到边界查找的三种实现

📅 2026/8/18 1:24:05
二分查找算法详解:从基础到边界查找的三种实现
1. 从“找得到”到“找得准”二分查找的三种境界如果你写过代码或者刷过算法题那么“二分查找”这四个字对你来说一定不陌生。它几乎是算法入门的第一道坎也是面试官最爱考察的基础能力之一。很多人觉得不就是在一个有序数组里找个数嘛while (left right)然后根据mid的值调整左右边界这有什么难的但现实往往是当你自信满满地写下几行代码却发现要么陷入死循环要么漏掉边界条件要么在寻找“第一个等于目标值”或“最后一个等于目标值”这种变体时脑子突然一片空白只能靠试错来蒙对。这恰恰说明你只掌握了二分查找最基础的“形”而没有理解其在不同场景下精确控制搜索区间的“神”。今天我们不谈那些高深的理论就从最朴素的“基本的二分查找”出发一步步拆解到“寻找左边界”和“寻找右边界”这两个高频变种。我会结合自己无数次调试和教学的经验把那些容易让人栽跟头的细节掰开揉碎让你不仅知道代码怎么写更明白为什么这么写以及在不同场景下该如何选择最合适的写法。这不仅仅是应付面试更是培养一种严谨、精确的编程思维。2. 温故知新标准二分查找的“标准”在哪里我们从一个最简单的场景开始给定一个升序排列且元素互不重复的整数数组nums和一个目标值target请你编写一个函数返回target在数组中的索引如果不存在则返回-1。这是二分查找最经典、最纯粹的形式。它的核心思想是“减而治之”每次比较区间中间的元素根据比较结果将搜索范围缩小一半。听起来很简单但魔鬼藏在细节里。我们先来看一段最常见的实现代码def binary_search(nums, target): left, right 0, len(nums) - 1 # 初始化搜索区间为闭区间 [left, right] while left right: # 当区间不为空时继续搜索 mid left (right - left) // 2 # 防止(leftright)可能导致的溢出 if nums[mid] target: return mid # 找到目标直接返回索引 elif nums[mid] target: left mid 1 # 目标在右半部分调整左边界 else: # nums[mid] target right mid - 1 # 目标在左半部分调整右边界 return -1 # 搜索区间为空未找到目标这段代码简洁有力但其中每一个选择都值得深思。为什么是while (left right)而不是为什么更新边界时是mid 1和mid - 1我们来逐一拆解。2.1 搜索区间的定义开区间、闭区间与循环条件这是二分查找所有困惑的根源。你必须在一开始就明确你定义的搜索区间是什么。在上面的代码中我使用了闭区间[left, right]的定义。这意味着left和right指向的元素都是可能包含目标的。left right作为循环条件在闭区间定义下当left right时区间[left, right]仍然包含一个元素即nums[left]这个元素还没有被检查过因此循环必须继续。如果写成left right那么当left right时循环就会终止导致漏检这个唯一的元素。边界更新为mid 1和mid - 1因为nums[mid]已经被明确检查过并且不等于target所以在下一轮搜索中它应该被排除在新的搜索区间之外。所以如果目标在右侧新的左边界应该是mid 1如果在左侧新的右边界应该是mid - 1。这保证了搜索区间在每一步都严格缩小。注意另一种常见的定义是左闭右开区间[left, right)。在这种定义下right初始为len(nums)循环条件为while left right更新右边界时为right mid。两种定义逻辑上都正确但混用会导致错误。我强烈建议初学者尤其是面临高压面试时固定使用一种并彻底理解它。闭区间的定义在逻辑上更对称我个人更推荐。2.2 计算中点的技巧一个不起眼但至关重要的细节你可能注意到了mid left (right - left) // 2这种写法。为什么不直接用(left right) // 2呢这是为了防止整数溢出。在极端情况下如果left和right都是非常大的正数接近编程语言中整型的最大值那么left right可能会超出整型范围导致溢出错误。而left (right - left) // 2这个公式在数学上等价于(left right) // 2但通过先做减法避免了直接相加是一种更安全的写法。虽然在实际的算法题中数据范围通常不会触发这个问题但养成这个习惯是专业性的体现。2.3 标准二分的局限性当数组中有重复元素时标准二分查找在找到任意一个等于target的元素后就会立即返回。这在一个元素互不重复的数组中是完全正确的。但是如果数组中有重复元素而题目要求你找到第一个或最后一个出现的target标准二分法就无能为力了。例如在数组[1, 2, 2, 2, 3]中查找2标准二分可能返回索引1、2或3中的任意一个这具有不确定性。这时我们就需要进入二分查找的进阶形态寻找边界。3. 寻找左边界如何锁定“第一个”目标值假设我们有一个非递减数组即允许重复元素升序排列现在要找到target第一次出现的位置左边界。如果不存在则返回-1。这个问题的关键在于即使我们找到了一个nums[mid] target我们也不能立即返回因为mid左侧可能还有更早的target。我们的目标从“找到一个”变成了“找到最左边的那个”。因此算法需要持续向左收缩搜索区间直到无法再向左为止。3.1 左边界查找的核心逻辑与代码实现我们依然采用闭区间的定义但调整判断和更新逻辑def find_left_bound(nums, target): left, right 0, len(nums) - 1 while left right: mid left (right - left) // 2 if nums[mid] target: # 中间值小于目标目标一定在右侧 left mid 1 elif nums[mid] target: # 中间值大于目标目标一定在左侧 right mid - 1 else: # nums[mid] target # 关键找到目标但不返回。收缩右边界继续在左侧寻找 right mid - 1 # 循环结束后检查 left 是否越界以及 left 指向的是否是 target if left len(nums) or nums[left] ! target: return -1 return left让我们仔细分析nums[mid] target时的操作我们将right更新为mid - 1。这意味着我们承认在mid处找到了一个目标值但为了寻找可能存在的更靠左的目标值我们故意放弃了当前找到的这一个以及它右侧的所有区域因为它们索引更大将搜索区间聚焦到[left, mid-1]。这个操作是寻找左边界的精髓。3.2 循环结束后的处理为什么是检查left这是一个非常容易出错的地方。循环结束时left和right的关系是left right 1。我们来模拟一下搜索过程如果target存在于数组中循环会一直向左压缩right直到right指向第一个target的左边一个位置。最终left会恰好指向第一个target。如果target大于所有元素left会不断右移最终left会等于len(nums)即数组长度此时left越界。如果target小于所有元素right会不断左移最终right会等于-1此时left为0。但nums[0]并不等于target。因此循环结束后left的含义是数组中第一个大于等于target的元素的索引。如果target存在left就是其左边界。如果target不存在left可能是越界值或者指向一个大于target的元素。所以我们需要进行后置检查if left len(nums)处理target过大的情况。if nums[left] ! target处理target过小或存在于数组“间隙”中的情况例如在[1,3,5]中找2left会指向3但3 ! 2。3.3 一个常见的思维陷阱在循环内返回有些初学者可能会尝试在循环内这样写if nums[mid] target: # 错误写法试图向左线性搜索 while mid 0 and nums[mid-1] target: mid - 1 return mid这虽然能得到正确答案但破坏了二分查找O(log n)的时间复杂度。在最坏情况下比如整个数组都是target这会退化成O(n)的线性扫描。而我们上面介绍的“收缩右边界”的方法始终保持了二分的高效性。4. 寻找右边界如何锁定“最后一个”目标值理解了左边界右边界就顺理成章了。我们的目标变成找到target最后一次出现的位置。如果不存在返回-1。思路是对称的当nums[mid] target时我们不能返回因为右边可能还有。此时我们应该收缩左边界向右继续探索。4.1 右边界查找的实现与对称性分析def find_right_bound(nums, target): left, right 0, len(nums) - 1 while left right: mid left (right - left) // 2 if nums[mid] target: left mid 1 elif nums[mid] target: right mid - 1 else: # nums[mid] target # 关键找到目标但不返回。收缩左边界继续在右侧寻找 left mid 1 # 循环结束后检查 right 是否越界以及 right 指向的是否是 target if right 0 or nums[right] ! target: return -1 return right注意nums[mid] target时的操作我们将left更新为mid 1。这意味着我们放弃当前找到的mid及其左侧区域向右半部分[mid1, right]继续搜索最后一个target。4.2 循环结束后的处理为什么是检查right与左边界对称循环结束时left right 1。此时right的含义是数组中最后一个小于等于target的元素的索引。如果target存在right就是其右边界。如果target不存在right可能是-1过小或者指向一个小于target的元素。因此后置检查变为if right 0处理target过小的情况。if nums[right] ! target处理target过大或存在于数组“间隙”中的情况。4.3 左右边界查找的统一记忆法为了避免混淆你可以记住一个核心原则寻找哪边的边界就在找到目标时收缩相反方向的边界。找左边界找到target时收缩右边界 (right mid - 1)迫使搜索向左进行。最后检查left。找右边界找到target时收缩左边界 (left mid 1)迫使搜索向右进行。最后检查right。循环结束后的检查索引总是与你在循环中收缩的那个边界相反找左边界收缩right最后查left找右边界收缩left最后查right。5. 实战演练与深度避坑指南理论讲完了我们来看几个具体的例子和容易踩的坑。二分查找的代码虽然短但一个等号、一个加减号的错误就足以让程序逻辑完全崩溃。5.1 示例分析在重复数组中应用三种二分假设数组nums [1, 2, 2, 2, 3, 4]target 2。标准二分查找可能返回索引1、2或3中的任意一个。它只保证找到“一个”不保证是第几个。寻找左边界初始:[0,5], mid2, nums[2]2收缩右边界 right1。下一轮:[0,1], mid0, nums[0]12收缩左边界 left1。下一轮:[1,1], mid1, nums[1]2收缩右边界 right0。循环结束left1。检查 nums[1]2返回 1。正确第一个2的索引。寻找右边界初始:[0,5], mid2, nums[2]2收缩左边界 left3。下一轮:[3,5], mid4, nums[4]32收缩右边界 right3。下一轮:[3,3], mid3, nums[3]2收缩左边界 left4。循环结束right3。检查 nums[3]2返回 3。正确最后一个2的索引。5.2 高频易错点排查死循环通常是由于区间更新逻辑和循环条件不匹配造成的。场景在寻找左边界时如果nums[mid] target时错误地写成right mid而不是mid - 1并且循环条件是while left right那么当left和right相邻且nums[left] target,nums[right] target时mid left进入else分支right mid即right left区间无法缩小陷入死循环。检查务必确认你的边界更新能让区间严格缩小left增大或right减小。漏掉元素通常是因为循环条件过早结束。场景使用闭区间[left, right]却用了while left right作为条件。当区间只剩一个元素 (left right) 时循环直接结束这个元素根本没被检查。检查牢记你的区间定义并推导循环结束时left和right的关系。返回错误索引后置检查没做好。场景寻找左边界后直接返回left没有检查left是否越界或nums[left]是否等于target。在target大于所有元素时会返回len(nums)这是一个非法索引。检查画图模拟target不存在且偏大、偏小、在中间三种情况走一遍流程确定循环结束后left和right的位置以及它们的含义。混淆更新逻辑这是最致命的把找左边界和找右边界的逻辑写反了。对策用一句话口诀强化记忆“找左界动右界找右界动左界”。在写代码前先在心里默念一遍。5.3 调试技巧打印日志法当你对二分逻辑不确定时最有效的调试方法就是在循环内部打印关键变量。def find_left_bound_debug(nums, target): left, right 0, len(nums) - 1 print(f初始: left{left}, right{right}) while left right: mid left (right - left) // 2 print(f 循环: left{left}, right{right}, mid{mid}, nums[mid]{nums[mid]}) if nums[mid] target: left mid 1 print(f nums[mid] target - left{left}) elif nums[mid] target: right mid - 1 print(f nums[mid] target - right{right}) else: right mid - 1 print(f nums[mid] target - right{right}) print(f结束: left{left}, right{right}) # ... 后续检查通过观察每一轮循环中区间的变化你可以清晰地看到算法是如何一步步逼近答案或走向错误的。这是理解二分查找最直观的方式。6. 总结与升华二分查找的本质是“边界”的博弈走完这一趟我希望你收获的不仅仅是三段代码。二分查找的精髓在于对搜索区间和循环不变量的精确把控。标准二分在区间[left, right]内寻找一个确定存在或可判定不存在的目标。它的循环不变量是如果target存在那么它一定在当前搜索区间内。寻找左边界在区间[left, right]内寻找第一个满足nums[i] target的索引i。它的循环不变量更微妙每一轮循环后target的左边界如果存在仍在[left, right]内并且left左侧的元素都 targetright右侧的元素都 target这个性质在循环结束后用于定位。寻找右边界对称地寻找最后一个满足nums[i] target的索引i。所有的细节——循环条件、边界更新、后置检查——都是为维护这些“不变量”服务的。当你下次再面对二分查找的问题时不要急于动手写代码。先问自己几个问题我要找的是什么一个值第一个最后一个我定义的搜索区间是什么闭区间左闭右开我的循环条件如何保证区间有效性在nums[mid]等于、大于、小于target时我该如何更新边界以维护我要找的目标性质循环结束后left和right的关系是什么哪个指针指向了我想要的答案是否需要额外的检查把这些想清楚了代码自然水到渠成。二分查找不再是一道需要死记硬背的模板题而成为一种可以灵活运用于各种有序数据查询场景的强大思维工具。无论是查找插入位置、旋转数组搜索还是更复杂的值域二分问题其内核都是相通的。掌握了边界你就掌握了二分查找的灵魂。