力扣算法解析按专题吃透,不做题海 速查 90 天路线 ⌂ 全部教程

专题 04二分查找

二分查找看着简单,却是面试里翻车率最高的基础算法——不是不会写,而是边界写错、死循环、左右边界搞混。 这一章先讲清一件事:二分不只是在有序数组里找数,它的本质是「在单调的答案空间上每次砍掉一半」。 把这个想通,旋转数组、第一个/最后一个位置这些「变体」都会变成同一棵树上的果子。模板只背一种(左闭右闭), 足以应付 99% 的题。

学习目标
理解二分本质、背熟左闭右闭模板、独立写 4 题
前置要求
专题 01 双指针、Java 数组与循环
本章产出
4 道题的推导 + 可提交 Java 代码
建议学时
5–6 小时

4.1二分的本质:单调性上的折半

多数初学者把二分理解成「在有序数组里找一个数」。这个理解太窄了,导致一遇到变形就懵。 真正的本质是:

二分的本质:只要某个「待求的东西」的取值空间具有单调性,并且你能在 O(1) 内判断「目标在左半还是右半」, 就能每次砍掉一半,得到 O(log n)。数组有序只是「单调性」最常见的一种表现,不是必要条件。

为什么说「不只是有序数组」

例子(先不写代码,建立直觉):

核心判断:拿到题先问——「我要求的东西,能不能排成一列,使得『越往一边越满足某条件』?」 能,就可以二分;不能(比如答案空间乱跳),二分就用不了。

场景单调的「轴」判断 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 恰好停在边界上。

本站建议:初学只背写法一(左闭右闭)。它最直观:「区间里还有数就继续,mid 查过就把它踢出区间」。 写法二、三都是在写法一基础上微调,理解了单调性后自然能推导,不必死记。

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。

区间midnums[mid]动作
[0,5]233 < 9,left=3
[3,5]49命中,返回 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(存在)2nums[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 自然落在边界)。

和 4.3 的唯一差别:704 找不到返回 -1,35 找不到返回 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=3nums[3]=7;左半[4,5,6,7]有序;0 不在 [4,7)left=4(去右半)
left=4,right=6,mid=5nums[5]=1;左半[0,1]有序;0 在 [0,1)right=4
left=4,right=4,mid=4nums[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) 空间。本题把「二分本质」体现得最充分:数组不整体单调,但局部单调就够用。

和 704 的区别只有一句:704 直接比 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 代码,并通过空数组/单元素/全相同值等边界用例。