2026/8/18 4:31:07

二分查找从入门到精通:掌握边界查找与核心应用

二分查找从入门到精通:掌握边界查找与核心应用 1. 二分查找从“会写”到“精通”的必经之路如果你在准备技术面试或者在工作中处理过有序数据那么“二分查找”这个词对你来说一定不陌生。它几乎是算法世界里最经典、最优雅的入门课。很多人觉得二分查找很简单不就是个while (left right)然后判断mid吗但现实往往是当你面对“寻找第一个等于目标值的元素”或者“寻找最后一个等于目标值的元素”这类变种问题时代码里的和1、-1就开始打架边界条件怎么调都调不对最后只能靠“玄学调试法”。这正是我想和你聊的。基础的二分查找是“骨架”而寻找左右边界的二分查找才是它的“灵魂”。掌握后者你才算真正理解了二分查找的精髓——不仅仅是找到目标更是精确地定位目标在有序集合中的“领地”。无论是处理日志时间戳、用户评分数据还是实现一个高效的数据查询接口这种精确查找的能力都至关重要。今天我们就从最基础的实现开始一步步拆解直到你能够不假思索地写出无懈可击的边界查找代码并理解每一个细节背后的“为什么”。2. 二分查找的核心思想与基础实现拆解2.1 为什么是“二分”算法思想的直观理解二分查找之所以高效其核心思想源于“分而治之”和“利用有序性进行快速排除”。想象一下你在一本厚厚的、按字母顺序排列的电话簿里找一个人的号码。最笨的方法是从第一页开始一页页翻。聪明一点的做法是随机翻开一页根据这一页首字母和你要找的名字首字母比较决定是往前翻还是往后翻。而二分查找则将这种“聪明做法”做到了极致每次都翻到正中间的那一页。这个“正中间”就是关键。因为数据是有序的比较中间元素的值和目标值的大小关系后我们可以确定性地排除掉一半的搜索空间。如果目标值比中间值小那么目标值只可能存在于左半部分右半部分可以直接忽略反之亦然。每一次比较我们都将问题的规模缩小一半。这种指数级的缩减速度是其时间复杂度能达到 O(log n) 的根本原因。这里有一个非常重要的前提常常被初学者忽略有序性。如果数据是乱序的那么“中间值比目标值大目标值就一定在左边”这个推论就不成立。因此二分查找通常作用于数组这类支持随机访问、且已排序的数据结构上。2.2 基础二分查找的“标准模板”与细节剖析我们先来看最经典的基础二分查找在一个无重复元素的升序数组中判断目标值是否存在若存在则返回其索引。def binary_search(nums, target): 在有序数组 nums 中查找 target返回其索引未找到则返回 -1。 前提nums 为升序排列且元素无重复对于基础版本。 left, right 0, len(nums) - 1 # 初始化搜索区间为闭区间 [left, right] while left right: # 关键当区间有效时继续搜索 # 防止 left right 可能发生的整数溢出更安全的写法 mid left (right - left) // 2 if nums[mid] target: return mid # 找到目标直接返回索引 elif nums[mid] target: left mid 1 # 目标在右侧调整左边界 else: # nums[mid] target right mid - 1 # 目标在左侧调整右边界 return -1 # 搜索区间为空未找到目标这段代码简洁但几乎每一行都藏着需要理解的设计决策搜索区间初始化[left, right]我们选择闭区间。这意味着left和right所指向的元素都在本次搜索的考虑范围内。与之对应的还有左闭右开区间[left, right)。闭区间的优点是初始化和终止条件的语义非常清晰left 0, right len(nums)-1表示整个数组。循环条件while left right这是闭区间下的正确终止条件。当left right时区间[left, right]仍然包含一个元素我们还需要对这个元素进行检查。只有当left right时区间才变为空表示没有任何元素可供查找此时循环结束。如果错误地写成while left right就会漏掉left right的情况导致最后一个元素没有被检查。中间位置计算mid left (right - left) // 2这是计算中点索引的防溢出写法。更直观的(left right) // 2在绝大多数情况下没问题但如果left和right都是很大的整数接近编程语言中整型的最大值它们的和可能会溢出导致计算出错。left (right - left) // 2这个公式在数学上等价但避免了直接相加是更健壮的写法。边界更新left mid 1和right mid - 1这是二分查找避免死循环的关键。因为我们在nums[mid]不等于target时已经明确知道mid这个位置不是我们要找的。所以在调整搜索区间时应该将mid排除在外。如果更新为left mid或right mid那么当left和right相邻时mid可能会永远等于left导致区间无法继续缩小陷入无限循环。实操心得我强烈建议在初学阶段死记硬背这个“闭区间 left right 边界±1”的组合。这是最不容易出错的模板。很多边界错误都源于区间定义和循环条件的不匹配。2.3 基础实现的变体与常见误区有时你会看到一些不同的写法理解它们有助于加深认识左闭右开区间[left, right)left, right 0, len(nums) # 注意 right 初始为 len(nums) while left right: # 因为区间为空时 left right mid left (right - left) // 2 if nums[mid] target: return mid elif nums[mid] target: left mid 1 # mid 已检查从下一位开始 else: right mid # 注意右开区间所以 right 更新为 mid不包含 mid return -1这种写法下right初始指向的是“边界外”循环条件是left right更新右边界时是right mid。两种区间定义都可以但切忌混用。选定一种并保持所有操作初始化、循环条件、边界更新在其语义下一致。常见误区“差一错误”循环条件错误在闭区间模板中使用while left right会导致漏查。边界更新错误忘记1或-1导致死循环或漏查。返回值混淆在变种问题中不清楚循环结束时left或right指针的含义。基础二分查找是“靶心射击”要求一击即中。但当数组中存在重复元素时问题就变成了“找到靶子的左边缘或右边缘”。这才是真正考验对二分查找理解深度的时候。3. 寻找边界的二分查找从“找到”到“定位”当数组中存在重复的目标值时基础二分查找的行为是不确定的它可能返回其中任何一个等于目标值的索引。但在实际应用中我们往往需要更精确的信息寻找第一个等于目标值的位置左边界例如统计某个分数段有多少人需要找到第一个达到该分数的人的索引。寻找最后一个等于目标值的位置右边界例如查找某个时间点之前的最后一条日志记录。这两种需求催生了二分查找的两种高级变体。它们的核心思路不再是找到后立即返回而是在找到目标时不停止搜索而是继续收缩边界直到锁定边界点。3.1 寻找左边界锁定“第一个”寻找左边界的目标是返回第一个等于target的元素的索引如果不存在则返回一个“插入位置”即如果要把target插入数组并保持有序它应该被放置的位置。我们依然使用左闭右开区间的模板来实现因为这个模板在处理边界时非常清晰。def left_bound(nums, target): 寻找目标值 target 在有序数组 nums 中的左边界。 返回值含义 - 如果 target 存在返回其第一次出现的索引。 - 如果 target 不存在返回它应该被插入的位置保持数组有序。 left, right 0, len(nums) # 搜索区间为 [left, right) while left right: # 区间为空时终止 (left right) mid left (right - left) // 2 if nums[mid] target: right mid # 关键步骤不返回而是收缩右边界 elif nums[mid] target: left mid 1 # target 在右侧 else: # nums[mid] target right mid # target 在左侧收缩右边界 # 循环结束left 即为左边界或插入位置 # 需要检查 left 是否越界以及 nums[left] 是否等于 target如果存在 if left len(nums): return -1 # target 比所有数都大未找到 return left if nums[left] target else -1核心逻辑解析当nums[mid] target时我们找到了一个目标值但它不一定是第一个。为了继续寻找左边界我们不能返回而是将搜索区间的右边界right收缩到mid。因为左边界只可能在mid或其左侧闭区间下是mid-1但这里是右开区间所以是mid。这样我们保留了mid这个可能的位置并在更小的左半区间[left, mid)内继续搜索。循环终止条件当left right时[left, right)区间为空循环结束。此时left指针的位置具有重要含义如果target存在于数组中left指向的就是第一个target的位置。如果target不存在left指向的是第一个大于target的元素的位置也就是target应该被插入的位置。后处理循环结束后我们需要对left进行验证。首先检查left是否等于数组长度。如果是说明target比数组中所有元素都大搜索区间不断右移直到越界。然后检查nums[left]是否等于target。如果等于left就是左边界索引如果不等于说明数组中不存在target返回 -1。注意事项这里返回 -1 表示未找到。有些实现会返回left即插入位置即使未找到。这取决于你的API设计。明确返回值语义非常重要。3.2 寻找右边界锁定“最后一个”寻找右边界的思路与左边界对称但细节上稍有不同。目标是返回最后一个等于target的元素的索引。def right_bound(nums, target): 寻找目标值 target 在有序数组 nums 中的右边界。 返回值含义 - 如果 target 存在返回其最后一次出现的索引。 - 如果 target 不存在返回 -1。 left, right 0, len(nums) # 搜索区间 [left, right) while left right: mid left (right - left) // 2 if nums[mid] target: left mid 1 # 关键步骤不返回而是收缩左边界 elif nums[mid] target: left mid 1 # target 在右侧 else: # nums[mid] target right mid # target 在左侧 # 循环结束left 指向第一个大于 target 的元素 # 右边界应该是 left - 1 if left 0: return -1 # 所有数都大于 target未找到 # 检查前一个位置是否等于 target return left - 1 if nums[left - 1] target else -1核心逻辑解析当nums[mid] target时我们找到了一个目标值但它不一定是最后一个。为了寻找右边界我们收缩左边界left到mid 1。这样做的目的是让搜索区间向右移动因为右边界只可能在mid或其右侧。注意这里left mid 1意味着我们“抛弃”了当前的mid但由于我们记录的是mid所以最终需要left - 1。循环终止与指针含义循环同样在left right时结束。此时left指针指向的是第一个大于target的元素。为什么呢因为我们的更新策略是当nums[mid] target时left都会向右移动 (mid1)。所以循环结束时left停在了“大于”区域的第一位。后处理因此最后一个等于target的元素的位置就是left - 1。首先检查left是否为 0。如果是说明target比所有元素都小或者数组为空。然后检查nums[left - 1]是否等于target。如果等于left - 1就是右边界否则返回 -1。3.3 左右边界查找的统一理解与记忆技巧记忆这两个变体的关键在于理解nums[mid] target时的操作找左边界你希望right不断向左逼近所以让right mid。找右边界你希望left不断向右逼近所以让left mid 1。你可以这样形象地记忆“找左边界就动右指针找右边界就动左指针”。循环结束后left指针总是指向“第一个大于等于目标值”的位置对于左边界查找是等于对于右边界查找是大于。左边界就是left需验证右边界就是left - 1需验证。4. 二分查找的典型应用场景与实战解析理解了原理和模板我们来看看二分查找如何解决实际问题。它绝不仅仅是“在数组里找个数”。4.1 场景一数值的精确范围查询这是最直接的应用。假设你有一个按时间戳排序的用户登录记录数组logs每个记录是一个整数时间戳。现在需要查询在时间点T之后含的第一个登录记录。def first_greater_or_equal(logs, T): 返回 logs 中第一个时间戳 T 的记录的索引如果所有记录都小于 T返回 len(logs) left, right 0, len(logs) while left right: mid left (right - left) // 2 if logs[mid] T: # 条件满足可能是答案收缩右边界寻找更早的 right mid else: # logs[mid] T left mid 1 return left # left 指向第一个满足 logs[i] T 的位置这个函数其实就是寻找左边界的一个变体只不过判断条件从变成了。它非常适合处理“寻找第一个满足某条件的元素”这类问题。4.2 场景二在抽象函数上二分二分答案这是二分查找更高级、也更强大的应用。当问题的答案具有单调性并且我们可以用一个判别函数来检查某个候选答案是否可行时就可以使用二分法来搜索最优答案即使这个答案空间不是明显的数组。经典例题吃香蕉问题有n堆香蕉第i堆有piles[i]根香蕉。警卫将在h小时后回来。你可以决定每小时吃香蕉的速度k根/小时。每小时你选择一堆香蕉吃掉其中的k根。如果这堆香蕉少于k根你将吃掉这堆里所有的香蕉并且这一小时内不会吃更多的香蕉。你需要找到一个最小速度k使得你可以在h小时内吃掉所有香蕉。分析单调性吃香蕉的速度k越大所需的总时间time_needed(k)就越小。我们需要找到最小的k使得time_needed(k) h。这个“最小满足条件的k”是单调的。搜索空间k的最小值是 1一根一根吃最大值是max(piles)一次吃完最多的一堆。我们在这个范围[1, max_pile]内进行二分搜索。判别函数对于给定的速度k计算吃完所有香蕉需要的时间hours。def min_eating_speed(piles, h): def can_finish(k): 判断以速度 k 能否在 h 小时内吃完 hours 0 for pile in piles: # 每堆香蕉需要的小时数是 pile / k 向上取整 hours (pile k - 1) // k # 等价于 math.ceil(pile / k) if hours h: # 提前终止优化 return False return hours h left, right 1, max(piles) # 二分查找最小的满足条件的 k while left right: mid left (right - left) // 2 if can_finish(mid): # mid 速度可以完成尝试寻找更小的速度 right mid else: # mid 速度太慢需要提高速度 left mid 1 return left # 循环结束时 left 是最小的可行速度这个框架非常通用确定答案的搜索范围[left, right]。实现一个判别函数check(mid)判断mid这个候选答案是否“可行”或“满足条件”。根据check(mid)的结果决定如何收缩搜索边界。通常如果check(mid)为真说明答案可能是mid或更小所以right mid如果为假说明答案必须更大所以left mid 1。循环结束时left就是最小的满足条件的值。4.3 场景三在旋转排序数组中搜索这是一个经典的面试题它要求你在一个可能被旋转过的有序数组中进行搜索例如[4,5,6,7,0,1,2]。数组局部有序的性质依然可以被二分查找利用。核心思路是通过比较nums[mid]和nums[left]或nums[right]来判断mid位于哪个有序片段然后判断target位于哪个片段从而决定搜索方向。def search_in_rotated_array(nums, target): left, right 0, len(nums) - 1 while left right: mid left (right - left) // 2 if nums[mid] target: return mid # 判断 mid 位于左半段有序区间还是右半段 if nums[left] nums[mid]: # 左半段 [left, mid] 有序 if nums[left] target nums[mid]: # target 在有序的左半段内 right mid - 1 else: # target 在右半段 left mid 1 else: # 右半段 [mid, right] 有序 if nums[mid] target nums[right]: # target 在有序的右半段内 left mid 1 else: # target 在左半段 right mid - 1 return -1这个例子展示了二分查找的灵活性它依赖的并不是全局有序而是每次迭代中我们总能确定至少一半的区间是有序的并利用这个有序区间来做出决策。5. 避坑指南与调试技巧实录即使理解了所有原理亲手实现时还是容易掉进坑里。下面是我在无数次编码和调试中总结出的经验。5.1 常见错误类型与原因分析错误现象可能原因解决方案死循环边界更新不正确导致搜索区间无法缩小。例如在闭区间模板中left mid或right mid。牢记在闭区间中当nums[mid]不是目标时必须用mid1或mid-1将其排除。漏查元素循环条件与区间定义不匹配。例如在闭区间用while left right会漏掉left right的那个元素。统一区间定义和循环条件闭区间用左闭右开区间用。返回错误索引边界查找循环结束后未对指针进行有效性校验和值校验。在返回left或left-1前检查索引是否越界以及该索引处的值是否等于目标。结果总是-1或边界值在边界查找中nums[mid] target时的更新逻辑错误。找左边界却更新了left。用口诀记忆找左边界收缩右 (rightmid)找右边界收缩左 (leftmid1)。整数溢出在计算mid时使用(left right) // 2且left和right很大。始终使用mid left (right - left) // 2。5.2 实用的调试与验证方法小数据量手动模拟对于复杂的边界查找不要一上来就写完整代码。在纸上画一个包含重复元素的小数组比如[1, 2, 2, 2, 3]目标值是2。然后手动用你的算法逻辑一步一步推导left、right、mid的变化验证最终left是否指向第一个2right的更新是否正确。这是理解算法最有效的方式。设计全面的测试用例空数组[]单元素数组等于/不等于目标[5]目标为 5 和 3。双元素数组[1, 3]测试目标为 1、3、2、0、4 等情况。重复元素数组[1,2,2,2,3]测试目标为 2左/右边界、1、3、0、4。目标值位于两端数组[1,3,5]目标为 1 和 5。目标值不存在但处于范围内数组[1,3,5]目标为 2 或 4。目标值超出范围数组[1,3,5]目标为 0 或 6。打印关键变量在循环内部打印left、right、mid和nums[mid]的值。观察搜索区间是如何一步步缩小的。如果出现死循环打印日志会立刻让你发现left和right卡住不动了。理解循环不变式这是从根本上避免错误的高级思维。对于你选择的区间定义如[left, right)在循环开始时、每次迭代中、以及循环结束后明确什么性质是始终保持的。例如在左边界查找中可以定义不变式“target的左边界如果存在一定在区间[left, right)内”。每次更新left或right时都要确保这个不变式没有被破坏。5.3 选择与记忆模板的建议对于初学者我建议基础查找掌握一种即可推荐“闭区间”模板因其终止条件left right非常直观。边界查找掌握“左闭右开”区间模板。因为它处理边界时left指针的语义指向第一个大于等于目标的元素非常统一和有用很多问题都可以基于此模板解决。不要试图记忆所有变体。深入理解一个模板包括它的区间定义、循环条件、更新方式、终止后指针的含义然后通过大量练习将其内化远比死记硬背多个模板有效。当你真正理解后你可以从基础模板推导出任何变体。二分查找的代码虽然简短但它完美体现了算法设计中精确控制边界和循环不变式的思想。从“能写对”基础版到“能理解”边界版再到“能应用”解决复杂问题每一步都是对逻辑思维和编码能力的锤炼。下次当你遇到有序数据相关的搜索问题时不妨先想一想二分查找这把“利刃”是否就是最合适的工具。