二分查找算法详解:边界条件、模板代码与力扣经典题型攻关
2026/9/8 10:23:02 网站建设 项目流程

要是给 LeetCode 里的基础算法排一个榜单,二分查找绝对能进“看着最简单、翻车率最高”的前三名。力扣热门 100 题里有它,周赛 430 里也出现了二分查找的变体,很多人的题单里从 704 二分查找一直做到 073 爱吃香蕉的狒狒,但真正把边界条件讲清楚的文章其实不多。这篇文章我想用自己刷题和带新人的经验,把二分查找这套东西从底层逻辑讲到实战落地,顺便把经典题型、模板代码、常见 Bug 全部整理出来。适合刚开始刷 LeetCode 的算法新手,也适合面试前想系统过一遍二分查找的选手。

1. 二分查找题为什么总在力扣热门 100 题里出现

1.1 看似简单,但大多数人卡在边界条件

二分查找的原理一句话就能说完:在一个有序数组里,每次把搜索区间砍掉一半,时间复杂度从 O(n) 降到 O(log n)。我记得自己第一次在力扣上做 704 二分查找的时候,五分钟就写完了,一提交直接报错,明明思路是天衣无缝的。后来才发现问题出在left <= right还是left < rightright = mid还是right = mid - 1这种最基础的细节上。

这个现象在算法题里特别典型。二分查找的结构太简单了,简单到很多人不会认真去验证边界,但恰恰是这些边界决定了你能不能通过。力扣经典的 34 题“在排序数组中查找元素的第一个和最后一个位置”,要求用 O(log n) 复杂度,很多人第一反应是“我找到 target 之后向左右线性扩展不就行了”,但最坏情况下这样会退化到 O(n)。这就是二分查找题和普通查找题的分水岭:它考察的不是你会不会查,而是你能不能精确控制查找的区间。

1.2 面试和竞赛里对二分查找的考察层次

从力扣的题目分布来看,二分查找的题目数量不算多,但出现频率非常高。简单题有 704 二分查找、35 搜索插入位置,中等题有 34 找边界、33 搜索旋转排序数组、153 寻找旋转排序数组中的最小值,难题和各种变体就更不用说了,比如 875 爱吃香蕉的狒狒、1011 在 D 天内送达包裹的能力、410 分割数组的最大值。

这些题表面上长相完全不同,但核心套路是同一个:先判断问题是否具备单调性,再设计一个check(mid)函数,最后把“求最优解”变成“判断某个候选值是否可行”。我在准备面试的过程中发现,真正把这一套理解透了,是可以在 10 分钟内搞定 875 这类二分答案题的。这就是为什么值得单独为二分查找写一篇题解向的总结。

2. 两套二分查找模板,记住循环不变量就不会错

2.1 左闭右闭区间写法 [left, right]

我最早使用的模板是左闭右闭区间,这也是很多人入门时最常见的写法。核心思路是:leftright都指向当前可能包含答案的区间内元素,所以循环条件要用left <= right,因为当left == right时,这个位置仍然有可能是答案,不能提前退出。

def binary_search(nums, target): 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

这套模板的循环不变量很好记:每次循环开始时,target 一定在[left, right]里(如果存在的话)。所以当nums[mid] < target时,说明 target 在 mid 的右边,mid 本身不可能,直接把左边界挪到mid + 1;反之把右边界挪到mid - 1。我见过很多人在写这一版时,把right = mid错写成right = mid - 1或者反过来,这都是因为没有严格遵守“mid 已经检查过,不可能再是答案”这一事实。

2.2 左闭右开区间写法 [left, right)

另一种常见模板是左闭右开区间,也就是right本身不包含在搜索范围内。这种写法在 C++ 的标准库里有直接对应,lower_boundupper_bound用的就是这种语义。循环条件写作left < right,因为当left == right时区间已经空了。

def binary_search_left(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

这套模板的亮点在于:它天然就是“找第一个大于等于 target 的位置”,也就是lower_bound。你把return left改为return left if left < len(nums) and nums[left] == target else -1,就变成了标准的二分查找;如果直接返回 left,就能解决 35 题“搜索插入位置”。我自己实际用下来,边界题用左闭右开写起来最不容易乱,因为right = mid这种写法保持了右边界的不变性,区间始终是合法的[left, right)

2.3 模板的选择标准与记忆技巧

很多初学者会纠结“到底用哪套模板”,我的建议是:日常刷题先固定一套,标准查找就用左闭右闭,边界查找和二分答案就用左闭右开。这不是因为哪套更好,而是因为你需要把一套逻辑的“循环不变量”吃透,形成肌肉记忆。面试时最怕的就是临时切换模板,然后在leftrightmid的赋值上精神内耗。

记忆技巧就一个:弄清楚你的right到底指向“普通元素”还是“哨兵位置”。如果是普通元素,right = len(nums) - 1,对应的就是左闭右闭;如果是哨兵位置,right = len(nums),对应的就是左闭右开。同理,mid已经判断过就排除它,left = mid + 1right = mid - 1mid可能成为答案就保留它,left = midright = mid。这两个判断背后就是区间不变性的核心。

3. 力扣二分题三大题型拆解:标准型、边界型、答案型

3.1 标准查找型:704 二分查找与 35 搜索插入位置

标准查找型是二分查找最直接的形态。704 题就是给你一个有序数组和一个 target,找到就返回下标,找不到返回 -1。这题用左闭右闭模板 10 分钟就能写完,但它真正的价值在于让你验证自己对循环不变量的理解。我在带人刷题时发现,很多人写 704 能过,但把 target 换成“找第一个大于等于它的位置”就懵了,这说明他对模板是背下来的,不是理解下来的。

35 题搜索插入位置就比 704 多了一层思考:如果 target 不存在,返回它应该插入的位置。这个位置其实就是第一个大于等于 target 的下标,也就是lower_bound。用左闭右开模板可以直接得到答案:

def search_insert(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

如果你能理解这段代码为什么对,说明你已经理解了“区间收窄”的本质。顺便说一句,35 题虽然标的是简单题,但在面实际开发岗位的候选人里,能一次写对的人并不算多,足见边界问题的杀伤力。

3.2 边界查找型:34 找第一个和最后一个位置、33 搜索旋转排序数组

34 题的要求是在一个可能包含重复元素的有序数组里,找出 target 的起始下标和结束下标。如果找不到就返回 [-1, -1]。我先说一个最蠢但很多人最先想到的办法:先用二分找到任意一个 target,然后从它左右线性扫。这个做法在面试场景里基本等于送命,因为最坏情况数组全等于 target,线性扫描会让 O(log n) 变成 O(n),根本不符合题目对复杂度的要求。

正确做法是分别找左边界和右边界。找左边界就用lower_bound模板,找右边界可以做一个等价转化:找“第一个大于 target 的位置”再减一,也就是upper_bound。写成代码就是:

def search_range(nums, target): def lower_bound(): 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 def upper_bound(): 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 start = lower_bound() if start == len(nums) or nums[start] != target: return [-1, -1] return [start, upper_bound() - 1]

这里的关键点是upper_bound的判断条件从<变成了<=,意味着等于 target 的元素也会被跳过,最后返回的是第一个大于 target 的位置。这个转化思路在力扣里反复出现,必须熟练掌握。

33 题搜索旋转排序数组是另一类经典变体。数组是“部分有序”的:比如 [4,5,6,7,0,1,2],整个数组不是单调的,但在任意 mid 处,至少有一半是有序的。因此每次比较nums[left]nums[mid]来决定哪边有序,再判断 target 是否落在有序区间内,从而选择收缩方向。这题的难点不是二分本身,而是判断“哪半边有序”以及“target 在不在这个区间里”的逻辑组合。建议自己手写一遍,而不是直接背答案,因为面试时问得很细。

3.3 二分答案型:875 爱吃香蕉的狒狒、1011 在 D 天内送达包裹的能力

二分答案型是二分查找里最值钱、也最容易被忽略的形态。它把“求最值”变成“判断某个值是否可行”,核心前提是:答案本身具有单调性。875 爱吃香蕉的狒狒就是这个类型的经典代表。

题目大意是:狒狒要在 h 小时内吃完若干堆香蕉,每堆有piles[i]根,它每小时能吃 K 根,如果某一堆不够 K 根,它就吃光这一堆然后这小时结束,不会去别的堆。求最小的 K,使得能在 h 小时内吃完。暴力做法是从 K=1 开始逐个试,复杂度太高;更好的做法是观察单调性:K 越大,吃完所有香蕉所需时间越少,反之越多。于是可以在[1, max(piles)]这个区间里二分 K,每次用check(mid)计算“如果速度是 mid,需要几小时”,如果时间 <= h,说明速度可能还能更慢,往左搜;否则说明速度不够,往右搜。

def min_eating_speed(piles, h): left, right = 1, max(piles) def check(k): hours = 0 for p in piles: hours += (p + k - 1) // k return hours <= h while left < right: mid = left + (right - left) // 2 if check(mid): right = mid else: left = mid + 1 return left

1011 题“在 D 天内送达包裹的能力”思路完全一样:包裹重量数组是顺序的,船运载能力越大,需要的天数越少。二分运载能力,每次模拟一遍,看看按顺序装船能否在 D 天内装完。这类题的推荐解法是“最小化最大值”或“最大化最小值”,是贪心和二分的组合题,在力扣热门 100 题里出现频率很高。

4. 实操复盘:从读题到 AC 的五个关键步骤

4.1 第一步:确认问题具备单调性

不是所有题目都能用二分查找。你拿到一道题,第一反应应该是问自己:如果我把答案设为 X,那么“答案小于 X 时是否一定不可行,答案大于 X 时是否一定可行”?如果存在这种单调关系,二分就成立。以 875 题为例,K=1 时耗时最长,K=无穷大时耗时最短,耗时段随着 K 单调递减,这就是单调性。

如果是 704 这种直接查找题,单调性体现在数组本身有序上。如果是旋转数组 33 题,数组不是全局有序,但仍然可以借助“任意 mid 至少一边有序”的性质进行局部排除。总之,二分不是一种搜索技巧,而是一种“利用单调性收缩搜索空间”的思维方式。

4.2 第二步:确定二分的左右边界

边界定得太宽会导致二分次数多,但一般问题不大;定得太窄会漏解,这才是致命的。标准查找题里,left从 0 开始,right根据模板选len(nums)-1len(nums)。二分答案题里,下限通常是 1 或min(...),上限通常是max(...)sum(...)。比如 875 题,速度上限就是max(piles),因为比这再大的速度一定满足要求,且答案不可能超过它。

我见过有人把 875 题的上限写成sum(piles),虽然也能 AC,但会白白多二分两三次,而且逻辑上不干净。在力扣里,二分次数多一点少一点不影响复杂度级别,但正确的边界能让你的代码更好理解,也更容易向面试官解释。

4.3 第三步:设计 check 函数,注意数据类型

check 函数是二分答案题的灵魂。它接收一个候选答案 mid,返回 bool 值。设计 check 时要注意的是:循环内是否会产生大数,是否需要用 long long。以 875 题为例,piles 的单堆数量可能很大,h 也可能很大,(p + k - 1) // k这种向上取整的写法本身没问题,但累加的 hours 要小心,如果题目数据范围超过 int,建议在 C++ 里用long long,在 Python 里则不用太担心。

再比如 1011 题,模拟装船时对重量求和,如果 weights 的元素是 10^4 级别,D 天数是 10^5 级别,那么累加结果有可能超过 int 范围,写成int会在边界用例爆掉。这类问题在力扣的隐藏测试用例里经常出现,检验的就是你埋没在细节里的功力。

4.4 第四步:根据 check 结果收缩区间

写二分答案模板时,我强烈建议用左闭右开区间,因为check(mid)为真就能收缩right = mid,否则left = mid + 1,逻辑非常干净。它对应的不变量是:[left, right)之间始终包含可能的答案,并且left左侧都是不可行的、right右侧都是可行的。这就是经典二分答案题的最终形态。

一个小技巧:如果在写的过程中不确定leftright应该怎么更新,就停下来手推一个只有 3 个元素的样例,把 left、right、mid 都写出来走一遍。这个动作会帮你发现赋值方向是不是写反了,比在脑子里空想要高效得多。

4.5 第五步:提交前用三种样例自测

我提交之前习惯用三种样例过一遍:第一个是最普通的正常用例,第二个是只有一个元素的极端用例,第三个是找不到 target 或不满足条件的用例。这三个样例能覆盖掉大部分边界问题。比如 704 题,你至少试一下nums=[1], target=1target=0这两种情况;如果是 34 题,再试一下所有元素都等于 target 的情况。这一步看起来简单,但能帮你省下好几轮提交被报错的尴尬。

在力扣上刷题,很多人喜欢写完就提交,靠评测结果反推错误,我不太推荐这个习惯。实际情况是,每次提交之间等评测反馈的时间,足够你心算完一整个边界用例了。培养“提交前手测”的习惯,对你的面试也有好处,因为面试现场是没有评测机给你试错的。

5. 典型案例:073 爱吃香蕉的狒狒的二分答案解法

5.1 题干还原与问题转化

先看原题描述:狒狒有 N 堆香蕉,第 i 堆有piles[i]根香蕉,警卫会在 h 小时后回来。狒狒每小时选择一堆香蕉,以速度 K 根/小时开始吃。如果这堆香蕉少于 K 根,狒狒就会把这堆全部吃完,然后这一小时内不再吃其他堆。求在 h 小时内吃完所有香蕉的最小速度 K。

这题的核心难点是你不能直接算出一个公式,因为每小时只能吃一堆这个约束导致无法简单分配。换个角度看:给定速度 K,我们可以很容易地算出吃完所有香蕉需要的小时数。这个“小时数”关于 K 是单调递减的。于是题目转化为:在 K 的取值范围里找到最小的 K,使得耗时不超过 h。

5.2 为什么用二分答案而不是直接模拟

很多第一次看到这题的人会问:为什么不用贪心或模拟?假设你想直接模拟最优策略,你必须在每个小时决定吃哪一堆,这是一个非常复杂的决策过程,而且题目要求的是最小速度而不是策略。二分答案的优雅之处在于,它把困难的“求最优”问题,变成了“多次验证一个确定值是否可行”的问题,验证的复杂度只有 O(n)。

这种思想在力扣其他题目里也反复出现。比如 410 题“分割数组的最大值”和 1482 题“制作 m 束花所需的最少天数”,本质都是在二分答案。所以说 875 题不仅是力扣热门 100 题里的常客,更是理解二分答案模型的最好入门题。

5.3 完整代码与复杂度分析

完整代码就是我在 3.3 节给的实现。我再补充几个细节:

  • left从 1 开始,因为吃香蕉速度必须大于等于 1。
  • rightmax(piles)开始,因为速度再快也不可能比一次吃光最大堆更高效。
  • 计算耗时用(p + k - 1) // k,这是向上取整的标准写法,避免浮点数误差。
  • 如果check(mid)为真,说明 mid 可行,尝试找更小的 K,于是right = mid;否则left = mid + 1
  • 最终返回的left就是答案。

时间复杂度是 O(n log max(piles)),其中 n 是堆数。每次 check 要遍历一遍 piles,二分次数不超过 log(max(piles)) 次。这个复杂度在力扣的要求下完全没有问题。

我自己第一次做这题的时候,卡在了一个容易忽略的地方:循环条件是left < right还是left <= right。因为用的是左闭右开模板,所以循环内要保证区间左闭右开,循环条件必然是left < right。一旦把<=写进去,在left == right时还会再循环一次,可能把 left 推到 right 右侧,导致下标越界。

5.4 从 875 引申:周赛 430 里的二分变体

周赛 430 的那道题也落在二分查找这个家族里,只是场景换了。看完题目你会发现,解题框架和 875 一模一样:找一个变量,它从小到大变化,check 结果从 false 变成 true(或反过来),然后二分找临界点。区别在于 check 函数本身更复杂,可能包含贪心模拟、前缀和预处理或者数据结构维护。

这类变体题的通用解题流程是:先看答案变量是什么,再想 check 怎么写,最后确认边界范围和初始值。很多人刷题只记住了模板,遇到变体就不会套了,其实是因为没有把自己的思维固化到“先 check 后收缩”这个层面。如果你能把 875 题的代码吃透,周赛里再出现二分答案基本就是换个场景复现同一件事。

6. 二分查找常见 Bug 排查清单与避坑实录

6.1 死循环:left = mid 导致的无限循环

这是我踩过最多次的坑。当你在左闭右闭区间里,把更新写成left = mid而不是left = mid + 1时,一旦区间只剩下两个元素,mid会等于left,导致 left 永远不变,程序死循环。比如nums=[1,3]target=3left=0, right=1, mid=0,如果nums[0] < target后你写left = mid,left 还是 0,就永远退出不了。

排查方法很简单:如果提交后超时,优先检查是不是这类死循环。解决思路是记住一条铁律:mid 检查过就不保留,所以 left 的更新必须是mid + 1;反过来,如果 check 表示 mid 可能是答案,那 right 的更新可以是mid,但 left 不能直接等于mid除非使用特殊的取上整写法。在二分答案里,如果确实想用left = mid,那么mid要写成(left + right + 1) // 2,取上整防止死循环。

6.2 溢出:left + right 的防溢出写法

很多人求 mid 时写(left + right) // 2,这在 left 和 right 很小时没问题,但在某些题目数据范围大的场景下,left + right 可能直接溢出。比如数组长度接近 2^31-1 时,left + right 超过 int 范围。虽然力扣的很多题不会卡这么大的数组,但面试官确实喜欢问这个点。

正确写法是left + (right - left) // 2。这个表达式先把两个端点的差值算出来,再除以 2,最后加到 left 上,全程不会出现超过 right 的数量级。这个写法面试官通常默认你会,写错扣分是小,被误以为基础不扎实才是大问题。

6.3 返回 left 还是返回 right

这是二分查找题最常见的困惑之一。用左闭右开模板时,循环结束时left == right,返回哪个都一样。但如果你中途不小心换了模板或改了 right 的初始值,返回 left 和返回 right 就可能有差异。我的习惯是:只要循环结束,统一返回 left。因为左闭右开模板里,left 才是“第一个满足/不满足条件的位置”。这样你在写二分答案时也不用纠结,check 为真往左缩,最后 return left,永远是对的。

6.4 check 函数里不开 long long

二分答案题里最容易忽视的 bug 是数据类型。以 1011 题为例,weights 的单个元素可能是 10^4,总共可能有 5 * 10^4 个包裹,sum 累加可能到 5 * 10^8,int 勉强够;但如果你在 check 里写int capacity = 0,再往里面加 weights[i] 时,在某些 C++ 环境下会溢出。Python 没有这个问题,但 C++ 和 Java 都要注意。

建议在 check 函数里把累加变量声明为long long,或者直接看题目数据范围,有 10^9 级别的数一律用 64 位整型。这个习惯能帮你避开力扣评测里大量隐蔽的失败用例。

6.5 重复元素和 lower_bound/upper_bound 的语义

处理重复元素时,很多人会搞混两个边界。力扣 34 题非常典型,我建议你在本地把 lower_bound 和 upper_bound 的实现各写一遍,再用一个全是相同元素的数组做测试。比如nums=[2,2,2,2],lower_bound 返回 0,upper_bound 返回 4,所以 target 2 的区间是 [0, 3]。如果你把 upper_bound 的判断条件写成< target,就会得到错误的结果 0,导致区间变成 [0, -1]。这种错误很难一眼看出来,但通过小样例手推基本都能发现。

6.6 二分答案题边界条件的速查表

为了方便后续复习,我整理了一张我在实际刷题时会拿出来对照的表:

场景初始 left初始 right循环条件check 为真时check 为假时
基础查找(左闭右闭)0len(nums)-1left <= right直接返回 mid收缩对应边界
插入位置(左闭右开)0len(nums)left < rightright = midleft = mid + 1
找第一个等于 target0len(nums)left < rightright = midleft = mid + 1
二分答案求最小可行值1 或 minmax 或 sumleft < rightright = midleft = mid + 1
二分答案求最大可行值1 或 minmax 或 sumleft < rightleft = mid + 1(注意取上整)right = mid

我个人在实际刷题时发现,这张表的最后两行特别有用。很多人刷完 875 题后去做 1482 题,会觉得套路变了,其实只是 check 的判断方向变了。求“最小可行值”时,check 为真就收缩右边界;求“最大可行值”时,check 为真就收缩左边界。想清楚这一层,二分答案题基本就通了一半。

最后再分享一点经验:二分查找的题目数量在力扣里不算最多,但它是“一通则百通”的代表。把 704、35、34、33、875 这五题吃透,再去碰其他二分变体题,你会发现所有题目都在围着一个核心转:单调性 + check 函数 + 边界收缩。刷完这五题之后,我建议你手动在编辑环境里把左闭右开模板默写五遍,每一遍都口述清楚循环不变量是什么。这个过程有点机械,但效果出奇地好,能帮你把模板从“背下来的代码”变成“理解透的思路”。

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

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

立即咨询