专题 04二分查找
二分查找看着简单,却是面试里翻车率最高的基础算法——不是不会写,而是边界写错、死循环、左右边界搞混。 这一章先讲清一件事:二分不只是在有序数组里找数,它的本质是「在单调的答案空间上每次砍掉一半」。 把这个想通,旋转数组、第一个/最后一个位置这些「变体」都会变成同一棵树上的果子。模板只背一种(左闭右闭), 足以应付 99% 的题。
4.1二分的本质:单调性上的折半
多数初学者把二分理解成「在有序数组里找一个数」。这个理解太窄了,导致一遇到变形就懵。 真正的本质是:
为什么说「不只是有序数组」
例子(先不写代码,建立直觉):
- 有序数组找数:下标空间单调,
nums[mid]与target比大小就能判断往哪走。 - 找「第一个 ≥ target 的位置」:「某位置及之后都 ≥ target」这个性质,随下标增大从假变真,是单调的——也能二分。
- 答案在 [1, n] 里、且「mid 满足条件 → 答案 ≤ mid」:比如「最少切几刀」「最小速度」,这也是单调的,能二分答案(叫「二分答案」技巧)。
核心判断:拿到题先问——「我要求的东西,能不能排成一列,使得『越往一边越满足某条件』?」 能,就可以二分;不能(比如答案空间乱跳),二分就用不了。
| 场景 | 单调的「轴」 | 判断 mid 往哪走 |
|---|---|---|
| 有序数组找 target | 数组下标 | 比 nums[mid] 与 target |
| 第一个 ≥ target | 下标(性质从假到真) | nums[mid] >= target 则往左逼 |
| 旋转数组找数 | 下标(半边有序) | 先判哪半有序,再比范围 |
4.2三种边界写法,背一种就够
二分最容易乱的就是「区间怎么定义、循环条件写 <= 还是 <、mid 加减 1 怎么配」。
其实三种写法都等价,挑一种背熟即可。本站统一用左闭右闭。
写法一(本站主推):左闭右闭 while (left <= right)
初始 left = 0, right = n - 1,区间 [left, right] 两端都「包含」。因为包含右端,循环条件用 <=(只剩一个元素时也还要查);
每次缩小都 left = mid + 1 或 right = mid - 1(mid 已查过,下次不再包含)。
int left = 0, right = n - 1; // [left, right] 闭区间
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;
}
写法二:左闭右开 while (left < right)
初始 right = n(开区间,不含 n),循环条件 <(左右相等时区间已空,停)。
缩小右边界用 right = mid(因为 right 本就不含,mid 可能是答案,不能 -1)。常用于「找左边界」模板。
int left = 0, right = n; // [left, right) 左闭右开
while (left < right) {
int mid = left + (right - left) / 2; // 注意:这种写法 mid 取下整偏左
if (nums[mid] >= target) right = mid; // 含 mid
else left = mid + 1;
}
写法三:找左/右边界的专门模板
就是 4.5 节要用的:把「== 就返回」改成「== 也继续逼向边界」。找左边界时遇到 nums[mid] >= target 一律 right = mid - 1(往左逼);
找右边界时遇到 nums[mid] <= target 一律 left = mid + 1(往右逼)。循环结束后,left / right 恰好停在边界上。
4.3二分查找(简单,力扣 704)
简单 核心:左闭右闭主模板
题意:给定一个升序数组 nums 和目标值 target,若找到返回其下标,否则返回 -1。
例:nums = [-1,0,3,5,9,12], target = 9 → 返回 4;target = 2 → 返回 -1。
思路推导
第一步,为什么能二分。数组升序,下标空间单调:nums[mid] 比 target 大,说明目标只可能在左边;小则在右边。
每次比较能砍掉一半,O(log n)。
第二步,套主模板。左闭右闭区间,mid 用 left + (right - left) / 2(防溢出写法,见 4.7 节)。
nums[mid] == target 直接返回;< target 目标在右半,left = mid + 1;否则在左半,right = mid - 1。
第三步,退出循环意味着没找到。当 left > right,说明「还可能存在 target 的区间」已空,返回 -1。注意这里没有额外特判,
空数组 right = -1 时循环条件 left <= right 直接为假,安全返回 -1。
| 区间 | mid | nums[mid] | 动作 |
|---|---|---|---|
| [0,5] | 2 | 3 | 3 < 9,left=3 |
| [3,5] | 4 | 9 | 命中,返回 4 |
参考代码
class Solution {
public int search(int[] nums, int target) {
int left = 0, right = nums.length - 1; // 左闭右闭区间 [left, right]
while (left <= right) { // 区间还有元素就继续
int mid = left + (right - left) / 2; // 防溢出取中点
if (nums[mid] == target) return mid; // 命中
else if (nums[mid] < target) left = mid + 1; // 目标在右半,mid 已查剔除
else right = mid - 1; // 目标在左半,mid 已查剔除
}
return -1; // 区间空了还没找到
}
}
复杂度
O(log n) 时间(每次砍半),O(1) 空间。这是所有二分题的根模板。
left = mid + 1 / right = mid - 1(把 mid 踢出去)。
若写成 left = mid 或 right = mid,当区间缩到两个元素、mid 取左元素时,可能永远缩不动 → 死循环。
左闭右闭写法下, 「查过的 mid 一定要 ±1 移出去」是铁律。详见 4.7 节。
巩固练习(先自己想 15 分钟,卡住再看括号里的提示)
- 搜索插入位置(力扣 35,简单)——与本题共用「左闭右闭主模板」;差异在找不到 target 时不返回 -1,而是返回循环结束后的
left(提示:left 左侧全是 < target 的元素,left 停处即插入点)。 - 在排序数组中查找元素的第一个和最后一个位置(力扣 34,中等)——与本题共用「二分主模板」;差异在重复元素需分别求左右边界,遇到
==不立即返回而是继续逼向边界(提示:左边界right = mid - 1,右边界left = mid + 1)。
4.4搜索插入位置(简单,力扣 35)
简单 核心:找第一个 ≥ target 的位置
题意:给定升序数组 nums 和 target,若 target 存在返回其下标;
若不存在,返回它应该被插入的位置(插入后数组仍升序)。
例:nums = [1,3,5,6], target = 5 → 2;target = 2 → 1;target = 7 → 4。
思路推导
第一步,把问题翻译成二分。「插入位置」=「第一个 ≥ target 的下标」。如果 target 在数组里,这个位置恰好是它自己; 如果不在,它就是「所有比 target 小的元素之后」的位置。两者用同一个二分就能拿到。
第二步,复用主模板,只改返回。还是左闭右闭二分;找到相等直接返回(插入位置就是它自己)。
关键是没找到时:循环结束时 left > right,此时 left 恰好指向「第一个大于 target 的位置」——正是插入点。
所以直接 return left; 即可,不用任何额外逻辑。
第三步,为什么 left 就是插入点(直觉证明)。循环过程中,left 左边永远是「已确定 < target」的区域,right 右边永远是「已确定 > target」的区域。
当两者交错(left 越过 right),left 落在「> target 区域」的最左端,也就是该插入处。
可以拿 target = 7 推:全程 nums[mid] < 7,left 一直右移到 n,返回 n(插末尾)✓。
| target | 退出时 left | 含义 |
|---|---|---|
| 5(存在) | 2 | nums[2]==5,命中返回 2 |
| 2(不存在) | 1 | 插在 1 和 3 之间 |
| 7(不存在) | 4 | 插在末尾 |
| 0(不存在) | 0 | 插在开头 |
参考代码
class Solution {
public int searchInsert(int[] nums, int target) {
int left = 0, right = nums.length - 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 left; // 没找到:left 停在「第一个大于 target」处,即插入位置
}
}
复杂度
O(log n) 时间,O(1) 空间。本题是「左边界模板」的简化版(相等即返回,不等时 left 自然落在边界)。
left。记住「left 是二分结束后最有价值的那个位置」,
很多变形题的答案就藏在循环退出后的 left / right 上。
巩固练习(先自己想 15 分钟,卡住再看括号里的提示)
- 在排序数组中查找元素的第一个和最后一个位置(力扣 34,中等)——与本题共用「左闭右闭二分 + 循环结束后利用 left 定位」的技巧;差异在需对重复元素分别求左右边界(提示:把「== 就返回」改成「== 也继续逼向边界」,左逼返回 left、右逼返回 right)。
- 寻找旋转排序数组中的最小值(力扣 153,中等)——与本题共用「左闭右闭二分骨架」;差异在答案不在某元素下标、而在「断点最小值」,判断哪半有序后丢弃「全 > 已知最小值」的那半(提示:用
nums[right]作当前最小候选,左半有序且nums[left] < 候选时丢弃左半)。
4.5在排序数组中查找元素的第一个和最后一个位置(中等,力扣 34)
中等 核心:左右边界模板组合
题意:给定升序且可能含重复的数组 nums 和 target,
返回 target 出现的起始和结束下标(闭区间);若未出现返回 [-1, -1]。
例:nums = [5,7,7,8,8,10], target = 8 → [3, 4];target = 6 → [-1, -1]。
思路推导
第一步,暴力 vs 二分。线性扫描 O(n);但数组有序,重复元素必相邻,可以用两次二分分别定位「最左」和「最右」。
第二步,左边界模板。目标是「第一个 ≥ target 的位置」。把 704 的 == 就返回 改成「nums[mid] >= target 时也往左逼」:
if (nums[mid] >= target) right = mid - 1; else left = mid + 1;。循环结束后 left 就是第一个 ≥ target 的下标。
(它等于 target 才有效,否则说明数组里没有 target。)
第三步,右边界模板。目标是「最后一个 ≤ target 的位置」。对称地:if (nums[mid] <= target) left = mid + 1; else right = mid - 1;。
循环结束后 right 就是最后一个 ≤ target 的下标。
第四步,组合 + 判空。先求左边界 start;若 start 越界或 nums[start] != target,直接返回 [-1,-1];
否则求右边界 end,返回 [start, end]。两个子函数都复用左闭右闭骨架,区别只在「== 时往哪逼」。
| 步骤 | 对 nums=[5,7,7,8,8,10], t=8 | 结果 |
|---|---|---|
| 左边界 | 逼到第一个 ≥8 的位置 | start = 3(nums[3]=8) |
| 判存在 | nums[3]==8 ✓ | 继续 |
| 右边界 | 逼到最后一个 ≤8 的位置 | end = 4(nums[4]=8) |
参考代码
class Solution {
public int[] searchRange(int[] nums, int target) {
int start = leftBound(nums, target);
// start 越界 或 该位置不是 target → 说明数组里没有 target
if (start == nums.length || nums[start] != target) return new int[]{-1, -1};
int end = rightBound(nums, target);
return new int[]{start, end};
}
// 第一个 >= target 的位置
int leftBound(int[] nums, int target) {
int left = 0, right = nums.length - 1;
while (left <= right) {
int mid = left + (right - left) / 2;
if (nums[mid] >= target) right = mid - 1; // 找到或偏大,继续往左逼
else left = mid + 1;
}
return left;
}
// 最后一个 <= target 的位置
int rightBound(int[] nums, int target) {
int left = 0, right = nums.length - 1;
while (left <= right) {
int mid = left + (right - left) / 2;
if (nums[mid] <= target) left = mid + 1; // 找到或偏小,继续往右逼
else right = mid - 1;
}
return right;
}
}
复杂度
O(log n) 时间(两次二分),O(1) 空间。这是「边界模板」的标准组合,面试高频。
left、右边界返回 right,别写反(左逼结束 left 停边界、右逼结束 right 停边界)。
② 必须先判 nums[start] != target 再求右边界——若数组没有 target,左边界会落在「第一个比 target 大」处,直接返回 [-1,-1] 即可,
否则右边界算出来会是个错的位置。 ③ 全相同数组如 [8,8,8] 仍能正确返回 [0,2]。
巩固练习(先自己想 15 分钟,卡住再看括号里的提示)
- 搜索旋转排序数组(力扣 33,中等)——与本题共用「左闭右闭二分骨架」;差异在数组整体不单调,需先判「哪半有序」再在有序半里比范围(提示:
nums[left] <= nums[mid]说明左半有序,据此决定丢弃哪整半)。 - 寻找旋转排序数组中的最小值(力扣 153,中等)——与本题共用「二分 + 收缩时保留候选值」的技巧;差异在目标是「最小元素」而非某 target 的下标,用右端点作最小值候选逐步逼近(提示:左半有序时最小值在左半或就是
nums[left],否则去右半)。
4.6搜索旋转排序数组(中等,力扣 33)
中等 核心:半边有序的判断法
题意:一个原本升序、但在某个未知下标处被「旋转」过的数组(例如 [4,5,6,7,0,1,2]),
无重复元素。在 O(log n) 内找出 target 的下标,找不到返回 -1。
例:nums = [4,5,6,7,0,1,2], target = 0 → 返回 4。
思路推导
第一步,普通二分为什么不行。数组整体不升序(中间会「断」),直接比 nums[mid] 和 target 不能判断往哪走——
因为不知道 target 在断点的哪一侧。
第二步,关键性质——至少有一半是有序的。取 mid 后,区间被分成 [left, mid] 和 [mid, right] 两半。
因为只旋转了一次,这两半中必有一半是完全升序的。判断方法:若 nums[left] <= nums[mid],说明左半 [left, mid] 没越过断点、是有序的;否则断点在左半、右半有序。
第三步,在有序的那一半里判断 target 在不在。若左半有序:
看 target 是否落在 [nums[left], nums[mid]) 范围内——在,就只在左半继续二分(right = mid - 1);
不在,target 必在右半(left = mid + 1)。右半有序时对称处理。
第四步,为什么能砍掉一半。每一轮我们都确定了一整半是有序的,并在其中用「范围判断」决定丢弃哪一整半,所以每轮仍砍半,保持 O(log n)。
| 状态 | 对 [4,5,6,7,0,1,2], t=0 | 动作 |
|---|---|---|
| left=0,right=6,mid=3 | nums[3]=7;左半[4,5,6,7]有序;0 不在 [4,7) | left=4(去右半) |
| left=4,right=6,mid=5 | nums[5]=1;左半[0,1]有序;0 在 [0,1) | right=4 |
| left=4,right=4,mid=4 | nums[4]=0 == target | 返回 4 |
参考代码
class Solution {
public int search(int[] nums, int target) {
int left = 0, right = nums.length - 1;
while (left <= right) {
int mid = left + (right - left) / 2;
if (nums[mid] == target) return mid;
if (nums[left] <= nums[mid]) { // 左半 [left, mid] 有序
if (target >= nums[left] && target < nums[mid]) {
right = mid - 1; // target 在左半范围内
} else {
left = mid + 1; // 否则在右半
}
} else { // 右半 [mid, right] 有序
if (target > nums[mid] && target <= nums[right]) {
left = mid + 1; // target 在右半范围内
} else {
right = mid - 1; // 否则在左半
}
}
}
return -1;
}
}
复杂度
O(log n) 时间(每轮砍半),O(1) 空间。本题把「二分本质」体现得最充分:数组不整体单调,但局部单调就够用。
nums[mid] 与 target 决定方向;33 先判哪半有序,再在那个有序半里比范围。
模板骨架(左闭右闭、mid 防溢出、±1 收缩)完全一样。理解这一点,旋转数组变种(如找旋转点、含重复版)都能依样画葫芦。
巩固练习(先自己想 15 分钟,卡住再看括号里的提示)
- 搜索旋转排序数组 II(力扣 81,中等)——与本题共用「先判哪半有序、再在有序半里比范围」的技巧;差异在数组含重复元素,
nums[left] == nums[mid]时无法判断哪半有序,需left++跳过(提示:遇到相等先把左边界收缩一格,退化成 O(n) 最坏但常见写法)。 - 在排序数组中查找元素的第一个和最后一个位置(力扣 34,中等)——与本题共用「左闭右闭二分骨架」;差异在数组整体有序、不需判旋转,但要分别求左右边界(提示:左边界
nums[mid] >= target时right = mid - 1,右边界对称)。
4.7本专题常见坑:死循环
- 坑一:mid 取整方向与 left=mid 忘了 +1。左闭右闭写法下,每次缩小必须把 mid 踢出区间(
left = mid + 1或right = mid - 1)。 若误写成left = mid,当区间缩到[k, k+1]、mid取下整 =k时,left永远不前进 → 死循环。 本站主模板用mid + 1 / mid - 1,天然避开此坑。 - 坑二:整型溢出用
(left + right) / 2。当left + right超过Integer.MAX_VALUE时会溢出变负,mid 算错。 永远用left + (right - left) / 2(或(left + right) >>> 1无符号右移)。这是力扣/面试的送分点,务必养成习惯。 - 坑三:循环条件与收缩方式不匹配。
while (left <= right)必须配left = mid + 1 / right = mid - 1; 若混用左闭右开的while (left < right)却写了mid - 1,可能漏掉答案或越界。 一套写法一套配,别交叉混用。 - 坑四:边界模板里 == 时往错的边逼。找左边界遇到
nums[mid] == target还应right = mid - 1(继续往左找更小的下标); 找右边界应left = mid + 1。若写成「== 就返回」,得到的是「任意一个」而不是「最左/最右」,34 题会 WA。 - 坑五:旋转数组漏判空/单元素。
nums.length == 0时right = -1,主循环不进直接返回 -1,正确;但若有人先访问nums[0]会越界,记得先判空。 - 坑六:把 right 初值写成 n 却用了 <= 条件。左闭右闭
right初值是n - 1;若写成n又用<=,会访问越界。两种写法只差「right 初值」和「循环条件」这一组配对,别记串。
4.8自测题
Q1. 二分的本质是不是「数组必须有序」?请举一个数组不整体有序但仍能用二分的例子。
不是。二分本质是「答案空间单调 + 能 O(1) 判断往哪半走」。数组有序只是最常见的单调表现。
例子:旋转排序数组(33 题)整体不升序,但每次都能找到「有序的一半」,在那一半里判断 target 范围,照样 O(log n) 二分。 另一个例子是「二分答案」:如「使所有书包重量 ≤ mid 能否用 k 次运完」,答案在 [1, sum] 单调,也能二分 mid 而不是二分数组。
Q2. 搜索插入位置里,为什么循环结束后直接 return left 就是插入点?用 target=7 验证一下。
循环中 left 左侧永远是「已确认 < target」区,right 右侧永远是「已确认 > target」区。
当 left > right(交错),left 落在「> target 区」最左端,即该插入处。
验证 nums=[1,3,5,6], target=7:每步 nums[mid] < 7,left 一直右移,最终 left=4(=n),返回 4=插在末尾 ✓。
Q3. 34 题里,若先求左边界 start,且 nums[start]==target,还为什么要求右边界?直接返回 [start, start] 不行吗?
不行。start 只是「第一个」出现位置,数组可能含多个 target(如 [5,7,7,8,8,10] 中 8 出现两次)。
返回 [start, start] 会漏掉结束下标,正确应是 [3, 4]。
必须先判 nums[start] == target 确认存在(否则直接 [-1,-1]),再用右边界模板求「最后一个」位置 end。
Q4. 旋转数组题里,「至少有一半有序」是怎么保证的?为什么 nums[left] <= nums[mid] 就说明左半有序?
数组只在一个未知点断开(如 ...7 | 0...)。mid 把区间分成两半,断点最多落在其中一半, 因此另一半必然完整落在原升序段内、是有序的——所以「至少一半有序」必然成立。
若 nums[left] <= nums[mid],说明从 left 到 mid 没有越过断点(否则会出现「大→小」的逆序),故左半 [left, mid] 升序;
反之断点在左半,右半 [mid, right] 升序。据此选有序半做范围判断。
Q5.【动手题】不参考代码,写出「二分查找(704)」完整 Java 方法,并说明:若把 left = mid + 1 误写成 left = mid,
对输入 nums = [1,3], target = 3 会发生什么?如何避免?
方法见 4.3 节。若写成 left = mid:初始 left=0,right=1,mid=0,nums[0]=1 < 3 → left = mid = 0,
下一轮 left 仍=0、right 仍=1、mid 仍=0,无限循环(死循环),永远退不出。
避免:左闭右闭写法下,mid 查过就必须 ±1 移出区间(left = mid + 1 / right = mid - 1)。
同时 mid 用 left + (right - left) / 2 防溢出。写完后拿「两元素数组」过一遍边界,能立刻暴露这类死循环。
- 二分的本质是什么?为什么「数组有序」只是它的一个特例?
- 左闭右闭模板的四个要点(区间定义、循环条件、mid 防溢出、±1 收缩)能否默写?
- 搜索插入位置为什么 return left 就是答案?
- 34 题的左/右边界模板,== 时分别往哪边逼,结束后返回 left 还是 right?
- 33 题「至少一半有序」怎么判断、怎么据此丢弃一半?
- 能脱稿写出本章 4 道题中任意一道的 Java 代码,并通过空数组/单元素/全相同值等边界用例。