如果你打开 LeetCode 题库,按“二分查找”标签筛一遍,会发现题目数量不算夸张,但几乎每一道都能让不同水平的人卡住。我见过刷了 300 题的人还栽在选择 left = mid 还是 left = mid + 1 上,也见过刚入门的新手在 704 题就怀疑自己是不是不适合写代码。二分查找看起来只有十几行,真正写对却需要把“答案空间”“单调性”“边界收敛”这些概念揉碎了装进脑子里。
这篇文章就是想把二分查找这件事彻底讲透。我会从最简单的模板题说到“爱吃香蕉的狒狒”这类答案二分题,再聊旋转排序数组、边界判定、死循环的坑,以及最近几场周赛里二分的新玩法。无论你是刚开始刷 LeetCode 的初学者,还是想系统整理二分题型的老手,这篇文章应该都能给你一份可以直接落地的刷题路线和排错清单。
1. 力扣上的二分查找到底在考什么
1.1 先看 704:一道题看懂二分框架
LeetCode 704 题“二分查找”是所有二分题的地基。题目非常直白:给你一个升序整数数组和一个目标值,找到目标值的下标,找不到就返回 -1。我第一次刷这道题的时候想的是“这有什么好考的”,但后来在面试里让候选人手写,发现能一次写对的人真的不多。
标准解法长这样:
int search(vector<int>& nums, int target) { int left = 0, right = nums.size() - 1; while (left <= right) { int mid = left + (right - left) / 2; if (nums[mid] == target) return mid; else if (nums[mid] < target) left = mid + 1; else right = mid - 1; } return -1; }这个写法用的是“左闭右闭”区间,也就是 left 和 right 都包含在搜索范围内。循环条件是 left <= right,意味着区间里至少还有一个元素时就要继续查。mid 的计算不能写成 (left + right) / 2,因为 left 和 right 都很大的时候可能溢出,用 left + (right - left) / 2 更安全,这一点在后面的坑里会详细说。
这道题本身不难,但它把二分最核心的骨架给出来了:取中点、比较、缩区间、循环判断。后面所有变体,本质都是在这个骨架上换比较逻辑和区间更新规则。
1.2 二分的本质:不是“查数组”,而是“找分界点”
很多人对二分理解成“在有序数组里快速找目标值”,这个理解不完整。二分真正强大的地方,是在一个具备“单调性”的答案空间里找分界点。
我举个例子。假设你要猜一个 1 到 100 之间的数字,每猜一次对方会告诉你“大了”还是“小了”。最快的方法就是每次猜中间值,第一次猜 50,如果大了就猜 25,如果小了就猜 75。这个过程能把搜索范围每次缩小一半,最终在对数级别次数内猜中。这里 1 到 100 的整数就是“答案空间”,而“大了/小了”这个反馈,让这个空间具备了一种单调的判定性质。
再比如 LeetCode 875“爱吃香蕉的狒狒”。题目不会直接给你一个有序数组让你找目标,而是让你在“吃香蕉的速度”这个范围里找一个最小的速度,使得狒狒能在 H 小时内吃完所有香蕉。速度取 1 一定能吃完吗?不一定,但如果速度快到一定值,一定能吃完。随着速度增大,“是否能吃完”这个问题的答案从 false 变成 true,而且一旦变成 true 就一直是 true。这就是所谓的单调性,也是能用二分的根本原因。
可以说,顺序查找是在数据集合上线性扫描,而二分是在“答案的取值范围”上做折半搜索。能不能用二分,不取决于数据是不是有序,而取决于答案是否具备单调性,是否能快速写出 check 函数来判断某个候选答案是否可行。
1.3 为什么 O(log n) 这么重要
二分查找的时间复杂度是 O(log n)。看起来只是比 O(n) 好一点,但数据量一旦大起来,差距就是天壤之别。n 等于 10 亿的时候,顺序查找最坏要比较 10 亿次,二分只需要大约 30 次,因为 2 的 30 次方已经超过 10 亿了。
这个特性让二分成为处理大规模数据的利器。力扣上很多题目数据范围给到 10^9,就是在暗示你用 O(log n) 或 O(n log n) 的解法,O(n) 往往直接超时。当你见到“最大值最小化”“最小值最大化”“在范围内找到满足条件的最值”这类表述时,脑子里第一个就应该弹出二分。
我在实际刷题中,判断一道题是否用二分,就两步:第一,是否存在一个候选答案的连续取值区间;第二,对于区间里的每个值,是否能快速判断它满足条件。两步都成立,这道题基本就是在考二分。
2. 大多数人写不对二分,问题出在边界
2.1 左闭右闭和左闭右开:两套模板别混着用
二分查找的边界写法大体分两种:左闭右闭 [left, right] 和左闭右开 [left, right)。这两种都能写对,但最怕的是今天用这套,明天用那套,写着写着就乱了。
左闭右闭的写法就是前面 704 题的版本,right 初始化为 nums.size() - 1,循环条件是 left <= right,更新时 left = mid + 1 或 right = mid - 1,因为 mid 已经检查过了,所以要从区间里去掉。
左闭右开的版本更接近 C++ STL 里 lower_bound 的习惯,right 初始化为 nums.size(),循环条件是 left < right,更新时 left = mid + 1 或 right = mid。right = mid 意味着右边界是开区间,mid 没有被排除,只是不再包含在下一轮搜索里。
我自己偏向左闭右开,因为它跟 STL 的迭代器区间风格一致,而且死循环的概率略低。但没有任何一种写法是绝对正确的,关键在于你能不能讲清楚“当前区间包含哪些元素”和“下一次区间应该包含哪些元素”。面试时被追问边界问题,最怕的就是回答“我记得是这样写的”。
2.2 mid 取整方向与死循环的关系
很多人遇到二分题死循环,第一反应是“我是不是该改 right 的更新方式”,其实很多时候问题出在 mid 的取整方向上。
在 left 和 right 相邻的时候,如果区间是 [left, right] 且 left + 1 == right,那么 mid = left + (right - left) / 2 会等于 left。这时候如果你的更新逻辑是 left = mid,那么 left 会原地不动,区间永远不会缩小,于是死循环。
反过来,如果用向上取整 mid = left + (right - left + 1) / 2,那 mid 会等于 right,此时如果更新逻辑是 right = mid,同样会卡死。
所以核心原则是:当你的更新逻辑可能出现 mid 原地不动时,必须让 mid 偏向不会导致区间不缩小的一侧。如果你写的是 left = mid,那么 mid 至少要向 right 靠拢,也就是用向上取整;如果写的是 right = mid,那么 mid 就应该向下取整。这两条规律记住,能解决八成死循环问题。
// 向下取整,配合 left = mid + 1 / right = mid 使用 int mid = left + (right - left) / 2; // 向上取整,配合 left = mid / right = mid - 1 使用 int mid = left + (right - left + 1) / 2;很多模板题解里只用第一种,也没问题,因为题目要求的更新逻辑往往天然不会让 left = mid。但在“找最后一个满足条件的值”这类题里,经常需要 left = mid,这时候如果你还在用向下取整,就会陷入死循环。
2.3 找第一个/最后一个等于目标值的写法
力扣上 34 题“在排序数组中查找元素的第一个和最后一个位置”是二分变体里的经典。它不满足于“找到一个目标值”,而是要求找到目标值的左边界和右边界。
找左边界可以理解成“第一个大于等于 target 的位置”,这就是 C++ 里的 lower_bound。找右边界可以理解成“第一个大于 target 的位置再减一”,也就是 upper_bound - 1。
用左闭右开模板直接写:
int lower_bound(vector<int>& nums, int target) { int left = 0, right = nums.size(); while (left < right) { int mid = left + (right - left) / 2; if (nums[mid] >= target) right = mid; else left = mid + 1; } return left; }这里的关键是,当 nums[mid] >= target 时,mid 仍然可能是答案,所以 right = mid 而不是 right = mid - 1,保证答案不被丢掉。当 nums[mid] < target 时,mid 不可能是答案,所以 left = mid + 1。这个逻辑用左闭右开写起来非常顺畅,几乎不会出边界错误。
找最后一个位置,就是在 upper_bound 的结果上减一。你不需要再去写一套新的模板,只需要理解“左边界和右边界都是二分边界问题的特例”。
2.4 lower_bound 和 upper_bound 的另一种理解
如果你熟悉 C++ 的 STL,lower_bound 和 upper_bound 就是二分查找在有序数组上的标准答案。lower_bound 返回第一个不小于 target 的迭代器,upper_bound 返回第一个大于 target 的迭代器。两者之间的区间,恰好是所有等于 target 的元素。
这个视角非常有用,因为很多二分题其实是在问“这个分界点在哪里”,而不是“这个值存不存在”。比如 35 题“搜索插入位置”,本质上就是在求 lower_bound。你不需要单独背一道题的做法,理解了 lower_bound,这道题就是直接抄答案。
我还建议你用 Python 的 bisect 库来对照练习。Python 的 bisect_left 和 bisect_right 跟 C++ 的 lower_bound、upper_bound 语义几乎一致。当你把自己的模板跟这些标准库的结果对比验证多次之后,对边界的理解会从“背模板”变成“真的懂了”。
3. 力扣高频二分题型拆解:从模板题到周赛 430
3.1 搜索插入位置(35):返回值就是 lower_bound
35 题是 704 之外最建议先刷的二分题。题目要求在一个升序数组中找到目标值的插入位置,使得插入后数组仍然有序。如果目标值存在,返回它的下标;如果不存在,返回它应该被插入的位置。
这题的答案就是 lower_bound 的返回值。因为 lower_bound 的定义就是第一个不小于 target 的位置,这个位置自然就是插入位置。不论 target 在不在数组里,这个位置都能唯一确定。
int searchInsert(vector<int>& nums, int target) { int left = 0, right = nums.size(); while (left < right) { int mid = left + (right - left) / 2; if (nums[mid] >= target) right = mid; else left = mid + 1; } return left; }唯一的坑是:如果 target 比数组中所有元素都大,lower_bound 会返回 nums.size(),这个位置在数组末尾之后,但插入位置确实应该是这个位置,所以直接返回 left 是没问题的。你不需要特判,这就是开区间初始化的好处。
3.2 爱吃香蕉的狒狒(875):答案二分的典型
875 题是“答案空间二分”最经典的入门题。题目背景是狒狒要在 H 小时内吃完 N 堆香蕉,每堆香蕉有 pile[i] 根,狒狒每小时只能选择一堆,吃 K 根,如果这堆少于 K 根就吃光这堆,但一小时不会吃多堆。问最小的速度 K 是多少。
这道题的答案空间就是速度 K 的所有可能取值。理论上 K 可以从 1 取到最大堆的数量,因为速度再快也不会比一次吃光最大堆更有意义。排序后,我们可以在 [1, max(piles)] 这个区间做二分。
check 函数很直观:给定速度 K,计算吃完所有香蕉需要多少小时。对每一堆,耗时是 ceil(pile[i] / K),也就是 (pile[i] + K - 1) / K,然后累加。如果总耗时小于等于 H,说明这个速度可行,可以尝试更小的速度;否则说明速度不够,需要增大。
bool check(vector<int>& piles, int H, int K) { long long hours = 0; for (int p : piles) { hours += (p + K - 1) / K; } return hours <= H; } int minEatingSpeed(vector<int>& piles, int H) { int left = 1, right = *max_element(piles.begin(), piles.end()); while (left < right) { int mid = left + (right - left) / 2; if (check(piles, H, mid)) right = mid; else left = mid + 1; } return left; }这里用到的思想就是“二分答案,线性判定”。check 函数是核心,它把原问题转化成一个更容易验证的问题。很多难题难在 check 函数怎么写,而二分框架本身其实是固定套路。
3.3 旋转排序数组(33/153):二分在部分有序数组中的运用
旋转排序数组系列是二分查找里容易让人懵的题型。153 题要求找旋转数组中的最小值,33 题要求找旋转数组中的目标值。这两道题的数组都不是全局有序,而是“两段有序”拼接起来的。
以 153 题为例。数组是由一个升序数组旋转而来,比如 [4,5,6,7,0,1,2],最小元素出现在第二段有序区的开头。拿 nums[mid] 跟 nums[right] 比较,如果 nums[mid] > nums[right],说明最小值在右半段,因为中线左边可能是较大的那一段;如果 nums[mid] <= nums[right],说明右半段是连续上升的,最小值在左半段或者就是 mid 本身。
int findMin(vector<int>& nums) { int left = 0, right = nums.size() - 1; while (left < right) { int mid = left + (right - left) / 2; if (nums[mid] > nums[right]) left = mid + 1; else right = mid; } return nums[left]; }33 题在这个基础上增加了一个步骤:先判断 mid 落在左段还是右段,再判断 target 是否在那段区间内,从而决定舍弃哪一半。这里的核心是,虽然整个数组不是全局有序,但每次取中点后,至少有一半是有序的,我们可以在有序的那一半里做判断。这种“部分有序”里找规律的能力,是旋转数组类题目的考点。
3.4 最长回文子串(5):为什么它不是二分题
每次有热搜词提到“LeetCode 5: 最长回文子串”,都会有人问这题能不能用二分。我得说清楚:最长回文子串这道题本身是经典的动态规划题,也可以用中心扩展法或 Manacher 算法解决,并不是二分的典型应用。
不过这题确实可以衍生出一个二分思路:与其直接找最长回文子串,不如二分“长度”,然后判断是否存在长度为 len 的回文子串。判断是否存在的朴素做法是遍历每个起点,再 O(len) 验证回文,整体复杂度偏高,需要配合前缀哈希或滚动哈希优化。这个思路在竞赛里偶尔能见到,但在 LeetCode 常规解法里并不是最优选择,写起来复杂且容易出错。
我建议把 5 题放到“双指针/DP”分类下刷,不要硬套二分。识别一道题是不是真的二分题,比学会多少种技巧更重要。像“目标和”这类题目,看着可以被二分优化,实际上更合适的解法是 DFS 或动态规划,强行二分反而会把问题复杂化。
3.5 1896 这类“满足条件的数”:二分加区间合并的套路
像“1896 - 二分查找满足条件的数”这类题,在 PTA 风格里很常见,LeetCode 上也有很多类似的变体。它们通常给你一个数组和一个条件,让你统计满足条件的数的数量或位置。如果数组本身有序,直接 lower_bound/upper_bound 就能解决。如果条件不是单点比较,而是涉及两个数组的配对,就需要先排序再用二分。
一个典型场景是:给定两个数组 A 和 B,问有多少对 (i, j) 满足 A[i] + B[j] < target。朴素做法的复杂度是 O(n*m),当 n 和 m 都是 10^5 时完全不可行。优化思路是:先把 B 排序,然后遍历 A 的每个元素 a,在 B 中二分找到第一个大于等于 target - a 的位置,这个位置之前的所有 B[j] 都满足条件。总复杂度降到 O(n log m)。
这类题的关键是“排序 + 枚举一个数组 + 在另一个数组上二分”。你会发现,二分常常不是独立存在的,而是作为整体优化的一部分出现,它负责把二次方的枚举变成对数级。
3.6 LeetCode 周赛 430 里的二分趋势
最近几场力扣周赛,二分很少单独出现了,更多是作为一个模块嵌在复杂问题里。比如有些题目的 check 函数需要结合前缀和、差分数组或扫描线来判断,二分只是外层套了一个答案空间,真正难的是 check 的实现。
这就引出一个重要观念:刷二分题,不要只盯着“二分”本身,更重要的是锻炼写 check 函数的能力。check 函数往往涉及贪心、模拟、双指针或者数据结构,它决定了你的二分题能不能 AC。我之前就遇过一道周赛题,二分框架十分钟写完了,check 函数调了一个小时,原因是判断是否可行的时候没有考虑到区间合并的边界条件。
所以我的建议是:把二分当骨架,把 check 当灵魂。平时练习时,对于每一道二分题,都要专门把 check 函数单独拿出来看,想想它用到了什么算法,有没有优化空间。这也是有经验的选手和初学者之间的一道分水岭。
3.7 目标和(494):为什么它不是二分题
热词里还有“LeetCode 目标和”,我得特意辟个谣。目标和是在一个数组的每个元素前添加正号或负号,使得所有数字之和等于 target,问有多少种不同的符号方案。这题的本质是组合计数,可以用 DFS 加记忆化,或者转化为 0-1 背包问题求解,用 DP 做。
它表面上有个“和等于 target”的判定,但这里没有什么单调性,候选方案也不是一个连续的取值区间,因此二分完全用不上。如果你想在“目标和”上强行用二分,会发现 check 函数根本没法定义。
识别二分题有个很实用的总结:如果题目问的是“最小值中最大的”“最大值中最小的”“满足某条件的最小速度/容量/时间”这类,大概率是二分;如果题目问的是“有多少种方案”“组合成目标值”这类,基本是 DP、DFS 或者组合数学。把题目分类做对,比盲目刷题重要得多。
4. 实操过程:一套模板刷穿二分题
4.1 我的统一二分模板(C++ 实现)
我写二分题基本只用一套模板,也就是左闭右开版本,配合 lower_bound 的思想。这套模板被我验证过上百次,覆盖了 LeetCode 上九成的二分题。
// 找到第一个满足条件的位置。check(mid) 具备单调性:false...false true...true int binary_search(int left, int right) { while (left < right) { int mid = left + (right - left) / 2; if (check(mid)) { right = mid; // mid 可能是答案,保留 } else { left = mid + 1; // mid 不可能是答案,排除 } } return left; }这段模板的核心是 check 函数的单调性。我要找的就是“第一个 check 为 true 的位置”。如果 check(mid) 为 true,答案不会大于 mid,所以 right = mid;如果为 false,答案一定大于 mid,所以 left = mid + 1。循环结束时 left == right,就是答案。
这套模板适用于:找最小值、找第一个位置、找最小可行速度、找满足条件的最小值。如果要找最大值,只需要把判定的方向反过来:定义 check(mid) 表示“答案是否至少为 mid”,或者直接求“第一个不满足条件的位置减一”。
4.2 check 函数怎么写:从题目到判定的三步法
写 check 函数我基本遵循三步。第一步,确定当前候选答案 mid 代表什么;第二步,用 mid 去模拟或计算题目要求的结果;第三步,把这个结果跟题目限制比较,返回 true 或 false。
拿 875 题举例,mid 代表每小时吃香蕉的速度,模拟结果是总耗时 hours,限制条件是 H,比较方式是 hours <= H。再拿一个你可能会遇到的“小张的船运重量”类问题,mid 代表船的最大载重,模拟结果是把所有货物运完需要的天数,要求这个天数不能超过给定限制。
check 函数最容易犯的错误是贪心策略写错,尤其是在判断“能否在限定条件下完成任务”时。比如判断一条船一次最多载重 mid 时是否能运完所有货物,正确做法是按顺序往船上加货物,加不了就换一艘。这个模拟逻辑一旦写错,二分框架再对也白搭。
我建议每次写完 check 函数,先不要急着套二分,先在草稿纸上跑两个边界用例:mid 取最小值时是否明显 false,mid 取最大值时是否明显 true。如果这两个方向都不符合预期,说明 check 写反了,也说明你对题意的理解有偏差。
4.3 模板迁移:从 C++ 到 Python 的 bisect
Python 刷题时,很多人直接用 bisect 库,这当然可以。bisect_left(nums, target) 返回第一个 >= target 的位置,bisect_right 返回第一个 > target 的位置,跟 C++ 的 lower_bound、upper_bound 一一对应。
import bisect idx = bisect.bisect_left(nums, target) # 等价于 lower_bound idx = bisect.bisect_right(nums, target) # 等价于 upper_bound但我不建议在初学阶段完全依赖 bisect,因为它会掩盖你对边界理解的不足。等你把模板手写过几十遍、真正理解边界规则之后,再用 bisect 提速也不迟。面试的时候,还是建议手写二分,因为面试官大概率会追问里面的细节,只会调库容易被问住。
C++ 的 STL 也是一样,lower_bound、upper_bound 用得很爽,但你必须能手动实现一遍,并且在白板上讲清楚为什么 right = mid 而不是 right = mid - 1。
4.4 参数选择的决策树
二分题做多了,可以总结出一个决策树。
第一层:题目问的是“存在”“计数”还是“最值”。如果是“存在”,直接判断 target 是否在区间里;如果是“最值”,进第二层。
第二层:最值是否具备单调性,也就是是否可以通过一个 check 函数判断候选值是否可行。可行就进第三层。
第三层:题目是需要“最小可行值”还是“最大可行值”。最小可行值就用标准模板找第一个 true;最大可行值可以把问题转化成“找最后一个 true”,或者反过来找第一个 false 再减一。
这套决策树帮我避免了大量“拿到题不知道从哪下手”的情况。你也可以自己整理一套,把做过的二分题都放进去,看看它们落在哪个分支上。整理完之后,你会觉得二分题其实就那么几类。
5. 常见问题与排查技巧实录
5.1 死循环的经典场景与定位方法
死循环是二分题最常见的报错,而且代码写得不仔细的时候很难看出来。最常见的死循环就一种场景:left 和 right 相邻时,mid 取的是 left,而你的更新逻辑又写了 left = mid。
怎么快速定位?我建议在循环里打印 left、right、mid 三个值,跑一个最小规模的用例,比如长度为 2 的数组。一旦发现某轮 mid 等于 left 且更新后 left 没变,问题就找到了。
另一个技巧是:把循环退出条件从 left < right 改成 left <= right,观察是否退出。如果两种写法都会死循环,那大概率是更新逻辑里的边界漏了 +1 或 -1,而不是循环条件的问题。
5.2 mid 溢出与越界的隐患
mid 写成 (left + right) / 2 在极端数据下可能溢出,这是 LeetCode 老生常谈的坑。left + right 如果超过 int 最大值,会变成负数,mid 就错了。虽然很多题目数据范围不会触发,但面试的时候写这种代码很不好看。用 left + (right - left) / 2 是最稳妥的写法。
还有一种越界是数组下标越界。当你使用左闭右开区间时,right 初始值是 nums.size(),在循环里 nums[mid] 的 mid 最大是 size() - 1,因为有 left < right 的限制,不会越界。如果你把 right 初始化为 size() - 1 却用了开区间的更新,就可能在某个边界访问到 -1 或者 size(),这种错误很难排查,最好从一开始就确定一套区间的定义并遵守到底。
5.3 该写 left = mid 还是 left = mid + 1
很多人在“找最后一个满足条件的值”时会困惑,到底该写 left = mid 还是 left = mid + 1。我的判断方法是:先想清楚 mid 有没有可能成为最终答案。如果有可能,更新时就要保留 mid,也就是 left = mid 或 right = mid;如果 mid 已经确认不可能,就果断排除,也就是 left = mid + 1 或 right = mid - 1。
这句话听起来像废话,但真正写代码的时候,很多人会条件反射地统一写成 left = mid + 1 或 right = mid - 1,而忽略了 mid 可能是答案。比如找左边界时,nums[mid] >= target 时 mid 可能就是答案,这时候写成 right = mid - 1 就会漏掉正确答案。
5.4 答案卡在 0 或 n 的边界情况
二分题在返回答案时,经常需要检查答案是否真的在合法范围里。比如 lower_bound 在所有元素都比 target 小时会返回 nums.size(),这个位置是合法的插入位置,但如果你期望的是“找到目标值的下标”,就得额外判断这个返回值是否越界以及 nums[returned] 是否真的等于 target。
再比如 153 题找旋转数组最小值,如果数组本身没有旋转,也就是完全升序,二分会把区间一直缩到 0 位置,返回 nums[0]。这本身是对的,但如果你在左闭右闭模板里把 right 初始化为 size() - 2 之类的值,就会漏掉最后一个元素。写完之后一定要跑一次“数组未旋转”的用例。
5.5 二分题识别指南与反例速查
| 类型 | 典型特征 | 代表题号 | 核心算法 |
|---|---|---|---|
| 基本查找 | 有序数组找目标值 | 704、35 | 基础二分/lower_bound |
| 边界查找 | 找第一个/最后一个等于目标值 | 34 | lower_bound/upper_bound |
| 答案二分 | 最小速度/容量/时间 | 875、1011 | 二分答案+check |
| 部分有序 | 旋转数组查值/最值 | 33、153 | 分段二分 |
| 二分优化枚举 | 在另一个有序集合里找分界 | 1896、双数组配对 | 排序+二分 |
| 不是二分 | 组合计数/方案总数 | 494、5 | DP/DFS/中心扩展 |
这张表不是让你背题号,而是帮你建立分类直觉。看到一道题,先归归类,再决定用什么算法。归类正确,解题速度至少快一半。
6. 刷题方法论:从热门 100 题到稳定 AC 二分题
6.1 推荐的二分刷题顺序
如果你打算按 LeetCode 热门 100 题开始刷,我建议二分部分按这个顺序来:先刷 704 和 35 掌握基础模板;再刷 34 理解 lower_bound 和 upper_bound 的边界;然后跳到 875 和 1011 这类答案二分题,练习 check 函数的写法;接着做 33 和 153 理解部分有序数组;最后找一些二分优化枚举的题,比如“排序数组两数之和”类题目练手。
这个顺序的逻辑是从“已知答案在数组里”到“答案在连续区间里”再到“数组部分有序”再到“二分作为优化组件”,难度逐步上升,并且每一层都建立在前一层的基础之上。我见过很多人一上来就刷旋转数组,结果边界一塌糊涂,其实就是前面的基础模板没吃透。
6.2 如何整理自己的题解笔记
我刷题之后会做一个简单的题解模板,包含四部分:题目类型、核心思路、check 函数定义、边界坑点。每做完一道二分题,我都把这道题的 check 函数用一句话写出来,比如“判断速度为 mid 时是否能在 H 小时内吃完所有香蕉”。过一段时间再看,只要能凭着这句话回忆起整个解法,就算真的掌握了。
对于 PTA 风格里那些“二分查找满足条件的数”函数题,我还会额外记录输入数据的范围,因为这类题往往需要你把 lower_bound 封装成一个函数,如果返回值是位置而不是是否存在,边界逻辑会很不一样。这些笔记积累起来,就是属于你自己的“二分查找题解手册”,比任何现成题解都有用。
6.3 面试和竞赛里,二分题怎么答最加分
面试手写二分时,我建议你先把区间定义说清楚。跟面试官说“我准备用左闭右开区间,所以 right 初始化为 nums.size()”,这句话一出来,面试官就知道你不是在背模板,而是真的理解边界。然后写代码,再主动说一句“mid 我用了 left + (right - left) / 2 防止溢出”。这会给面试官留下很稳的印象。
竞赛里则相反,不用解释,速度优先。Python 直接用 bisect,C++ 直接用 STL 里的 lower_bound,只有在题目数据结构和库函数不匹配时才手写。竞赛比的是 AC 速度和代码稳定度,能少写代码就少写代码。
我自己的体会是:二分这个知识点,入门只需要一晚上,但真正能吃透,需要至少两周的刻意练习。每次刷题前先问自己三个问题:这是什么类型的二分?check 函数怎么定义?边界会不会死循环?把这几个问题养成习惯,你会发现 LeetCode 上二分相关的中等题,大部分都能在十五分钟内写出来。