百日算法修炼 · Day 03

0 阅读14分钟

百日算法修炼 · Day 03|二分查找:不只是“找一个数”

今天的关键词:有序、单调性、边界、局部最小值
使用语言:Java
建议学习时间:60~90 分钟


今日路线

今天通过 4 道题,逐步掌握二分查找的常见模型:

题目核心目标时间复杂度
题目 1判断有序数组中是否存在某个数O(log N)
题目 2找到大于等于目标值的最左位置O(log N)
题目 3找到小于等于目标值的最右位置O(log N)
题目 4在相邻元素不相等的数组中找局部最小值O(log N)

[!TIP] 不要急着展开答案。先写出循环不变量,并回答:每次比较后,哪一半一定可以排除?


预备知识:二分查找到底在“二分”什么?

二分查找并不只是“在有序数组中找一个数”。它真正依赖的是:

在当前搜索区间内,可以根据某种规律,确定至少有一半区域不可能包含答案。

经典二分使用数组的有序性;边界二分使用“是否满足条件”的单调性;局部最小值问题则利用相邻元素的变化趋势。

推荐的区间写法

本文统一使用闭区间 [left, right]

  • 循环条件:left <= right
  • 排除中点及其左侧:left = mid + 1
  • 排除中点及其右侧:right = mid - 1
  • 中点计算:mid = left + ((right - left) >> 1)

为什么不直接写 (left + right) / 2?因为当两个下标都很大时,left + right 可能发生整数溢出。


题目 1:有序数组中是否存在目标值

给定一个按升序排列的整数数组 arr 和一个整数 target,判断 target 是否存在于数组中。

示例

输入:arr = [1, 3, 5, 7, 9, 11], target = 7
输出:true

输入:arr = [1, 3, 5, 7, 9, 11], target = 8
输出:false

请先思考

  1. arr[mid] < target 时,为什么可以排除 [left, mid]
  2. 循环应该写成 left < right 还是 left <= right
  3. null 和空数组应该怎样处理?
提示 1:先确定搜索区间

维护闭区间 [left, right],它表示目标值仍然可能出现的位置。

提示 2:一次比较后排除哪一半?
  • arr[mid] < target,目标只能在右半区;
  • arr[mid] > target,目标只能在左半区;
  • 若二者相等,直接返回 true

想好后查看答案 1


题目 2:大于等于目标值的最左位置

给定一个升序数组 arr 和整数 target,返回数组中大于等于 target 的最左位置;如果不存在,返回 -1

示例

输入:arr = [1, 2, 2, 2, 4, 6], target = 2
输出:1

输入:arr = [1, 2, 2, 2, 4, 6], target = 3
输出:4

输入:arr = [1, 2, 2, 2, 4, 6], target = 7
输出:-1

请先思考

找到一个 arr[mid] >= target 时,能立即返回吗?如果不能,应该先记录什么,再去哪里继续搜索?

提示:把条件写成一条分界线

在有序数组中,arr[i] >= target 的真假会呈现如下结构:

false false false | true true true
                  ↑
               找这里

想好后查看答案 2


题目 3:小于等于目标值的最右位置

给定一个升序数组 arr 和整数 target,返回数组中小于等于 target 的最右位置;如果不存在,返回 -1

示例

输入:arr = [1, 2, 2, 2, 4, 6], target = 2
输出:3

输入:arr = [1, 2, 2, 2, 4, 6], target = 3
输出:3

输入:arr = [1, 2, 2, 2, 4, 6], target = 0
输出:-1

请先思考

这道题与题目 2 是镜像问题。请尝试只改动题目 2 中的三个地方:

  1. 合法候选的判断条件;
  2. 找到候选后继续搜索的方向;
  3. 无法成为答案时排除的区间。

想好后查看答案 3


题目 4:寻找局部最小值

给定一个无序整数数组 arr,并保证任意两个相邻元素不相等。请返回任意一个局部最小值的下标。

局部最小值定义:

  • 若数组只有一个元素,下标 0 是局部最小值;
  • arr[0] < arr[1],下标 0 是局部最小值;
  • arr[n - 1] < arr[n - 2],下标 n - 1 是局部最小值;
  • 对于中间位置 i,若 arr[i] < arr[i - 1]arr[i] < arr[i + 1],则 i 是局部最小值。

示例

输入:arr = [9, 7, 3, 4, 8, 6, 5]
一个合法输出:2
解释:arr[2] = 3,同时小于 arr[1] 和 arr[3]。

请先思考

数组没有整体有序,为什么仍然可以二分?

提示 1:先处理两端

如果两端都不是局部最小值,那么一定有:

arr[0] > arr[1]             arr[n - 2] < arr[n - 1]
左端呈下降趋势               右端呈上升趋势

从下降变为上升的过程中,至少会出现一个“谷底”。

提示 2:观察 mid 两侧
  • arr[mid] > arr[mid - 1],左侧存在局部最小值;
  • arr[mid] > arr[mid + 1],右侧存在局部最小值;
  • 否则,mid 本身就是局部最小值。

想好后查看答案 4


答案与解析

建议先独立完成题目区,再阅读本节。


答案 1:有序数组中是否存在目标值

返回题目 1

核心思路

始终维护一个闭区间 [left, right]:如果目标值存在,它就一定在这个区间内。

每次取中点 mid 后:

比较结果可以得出的结论下一步
arr[mid] == target已找到目标值返回 true
arr[mid] < targetmid 及其左侧都小于目标值left = mid + 1
arr[mid] > targetmid 及其右侧都大于目标值right = mid - 1

left > right 时,搜索区间为空,说明目标值不存在。

过程图解:查找 target = 7

数组:

下标012345
数值1357911

第 1 轮:

leftrightmidarr[mid]判断新区间
05255 < 7,排除下标 0~2[3, 5]
下标       0     1     2     3     4     5
数值       1     3     5     7     9    11
          └── 已排除 ──┘    L           R

第 2 轮:

leftrightmidarr[mid]判断结果
35499 > 7,排除下标 4~5新区间 [3, 3]
下标       0     1     2     3     4     5
数值       1     3     5     7     9    11
                            L/R   └─ 已排除 ─┘

第 3 轮:

leftrightmidarr[mid]判断结果
33377 == 7返回 true

优化后的 Java 代码

public class Code01_BSExist {

    /**
     * 判断升序数组中是否存在 target。
     *
     * @param arr    升序数组
     * @param target 目标值
     * @return 存在返回 true,否则返回 false
     */
    public static boolean exists(int[] arr, int target) {
        // null 或空数组中不可能存在目标值
        if (arr == null || arr.length == 0) {
            return false;
        }

        int left = 0;
        int right = arr.length - 1;

        // 搜索区间采用闭区间 [left, right]
        while (left <= right) {
            // 这种写法可以避免 left + right 发生整数溢出
            int mid = left + ((right - left) >> 1);

            if (arr[mid] == target) {
                return true;
            } else if (arr[mid] < target) {
                // mid 及其左侧都不可能是答案
                left = mid + 1;
            } else {
                // mid 及其右侧都不可能是答案
                right = mid - 1;
            }
        }

        return false;
    }
}

正确性说明

循环开始前,整个数组都是候选区间。每轮比较后:

  • 若中间值偏小,根据升序性质,[left, mid] 中没有目标值;
  • 若中间值偏大,根据升序性质,[mid, right] 中没有目标值;
  • 每次只删除确定不含答案的区域,因此不会错过目标值。

当区间为空仍未返回,目标值一定不存在。

复杂度

  • 时间复杂度:O(log N)
  • 额外空间复杂度:O(1)

易错点

  1. 闭区间写法却使用 while (left < right),导致最后一个位置未检查;
  2. 更新边界时写成 left = midright = mid,可能造成死循环;
  3. 使用 (left + right) / 2,存在整数溢出的隐患;
  4. 忘记处理 null 或空数组。

答案 2:大于等于目标值的最左位置

返回题目 2

核心思路

遇到 arr[mid] >= target 时,mid 是一个合法候选,但它的左侧可能还有更靠左的合法位置。因此:

  1. answer 记录 mid
  2. 继续到左半区搜索;
  3. arr[mid] < target,则去右半区。

推演:[1, 2, 2, 2, 4, 6] 中寻找 target = 2

轮次区间midarr[mid]answer下一步
1[0, 5]222合法,继续向左
2[0, 1]012太小,继续向右
3[1, 1]121合法,继续向左
结束空区间1返回 1

优化后的 Java 代码

public class Code02_BSNearLeft {

    /**
     * 返回升序数组中大于等于 target 的最左下标。
     * 若不存在,返回 -1。
     */
    public static int nearestLeft(int[] arr, int target) {
        if (arr == null || arr.length == 0) {
            return -1;
        }

        int left = 0;
        int right = arr.length - 1;
        int answer = -1;

        while (left <= right) {
            int mid = left + ((right - left) >> 1);

            if (arr[mid] >= target) {
                // mid 是候选答案,但左侧可能还有更靠左的答案
                answer = mid;
                right = mid - 1;
            } else {
                // arr[mid] 太小,答案只能在右侧
                left = mid + 1;
            }
        }

        return answer;
    }
}

循环不变量

answer 始终是目前找到的、满足 arr[index] >= target 的最左候选位置。搜索结束时,不可能再有更靠左的合法位置,因此它就是最终答案。

复杂度

  • 时间复杂度:O(log N)
  • 额外空间复杂度:O(1)

答案 3:小于等于目标值的最右位置

返回题目 3

核心思路

遇到 arr[mid] <= target 时,mid 是合法候选,但右侧可能还有更靠右的合法位置。记录答案后,继续向右搜索。

与题目 2 对照

对比项最左 >= target最右 <= target
候选条件arr[mid] >= targetarr[mid] <= target
记录候选后继续向左继续向右
非候选时向右向左

优化后的 Java 代码

public class Code03_BSNearRight {

    /**
     * 返回升序数组中小于等于 target 的最右下标。
     * 若不存在,返回 -1。
     */
    public static int nearestRight(int[] arr, int target) {
        if (arr == null || arr.length == 0) {
            return -1;
        }

        int left = 0;
        int right = arr.length - 1;
        int answer = -1;

        while (left <= right) {
            int mid = left + ((right - left) >> 1);

            if (arr[mid] <= target) {
                // mid 是候选答案,但右侧可能还有更靠右的答案
                answer = mid;
                left = mid + 1;
            } else {
                // arr[mid] 太大,答案只能在左侧
                right = mid - 1;
            }
        }

        return answer;
    }
}

复杂度

  • 时间复杂度:O(log N)
  • 额外空间复杂度:O(1)

延伸:统计目标值出现次数

在有序数组中,可以分别找到:

  • target 的最左位置:第一个 >= target 的位置,并确认值等于 target
  • target 的最右位置:最后一个 <= target 的位置,并确认值等于 target

若二者都存在,则出现次数为:

rightIndex - leftIndex + 1

答案 4:寻找局部最小值

返回题目 4

核心思路

这道题的数组无序,但仍具有可以淘汰一半区域的趋势信息。

先单独处理边界:

  1. 数组为空:返回 -1
  2. 只有一个元素:返回 0
  3. 左端比右邻居小:返回 0
  4. 右端比左邻居小:返回 n - 1

若两端都不是局部最小值,中间区域必然至少存在一个局部最小值。此时搜索范围可收紧为 [1, n - 2],这样访问 mid - 1mid + 1 始终安全。

方向判断

中点附近的关系说明搜索方向
arr[mid] > arr[mid - 1]mid 向左下降,左侧有谷底左半区
arr[mid] > arr[mid + 1]mid 向右下降,右侧有谷底右半区
同时小于左右邻居mid 就是局部最小值返回 mid

推演:[9, 7, 3, 4, 8, 6, 5]

两端都不是局部最小值,初始搜索中间区间 [1, 5]

轮次区间mid左 / 中 / 右结论
1[1, 5]33 / 4 / 84 > 3,向左搜索
2[1, 2]19 / 7 / 37 > 3,向右搜索
3[2, 2]27 / 3 / 43 小于两侧,返回 2

优化后的 Java 代码

public class Code04_BSAwesome {

    /**
     * 在相邻元素不相等的数组中,返回任意一个局部最小值下标。
     *
     * @return 局部最小值下标;数组为空时返回 -1
     */
    public static int getLessIndex(int[] arr) {
        if (arr == null || arr.length == 0) {
            return -1;
        }

        int n = arr.length;
        if (n == 1) {
            return 0;
        }

        // 先判断左边界
        if (arr[0] < arr[1]) {
            return 0;
        }

        // 再判断右边界
        if (arr[n - 1] < arr[n - 2]) {
            return n - 1;
        }

        // 两端已排除,只搜索内部位置,避免访问相邻下标时越界
        int left = 1;
        int right = n - 2;

        while (left <= right) {
            int mid = left + ((right - left) >> 1);

            if (arr[mid] > arr[mid - 1]) {
                // mid 左侧呈下降趋势,左半区必有局部最小值
                right = mid - 1;
            } else if (arr[mid] > arr[mid + 1]) {
                // mid 右侧呈下降趋势,右半区必有局部最小值
                left = mid + 1;
            } else {
                // 相邻元素不相等;能走到这里,mid 必然小于左右邻居
                return mid;
            }
        }

        // 在题目给定的前提下不会执行到这里
        return -1;
    }
}

为什么一定能找到?

排除两端之后,左端方向是“向右下降”,右端方向是“向右上升”。无论中间如何起伏,从下降趋势过渡到上升趋势时,都必然经过至少一个谷底。

二分时,如果中点某侧更低,就沿着下降方向继续寻找。由于该方向上存在谷底,因此不会把所有答案排除。

复杂度

  • 时间复杂度:O(log N)
  • 额外空间复杂度:O(1)

适用前提

题目要求相邻元素不相等。若允许相邻元素相等,平台区间可能让单次比较无法确定哪一侧必然存在严格局部最小值,必须重新定义局部最小值或调整算法。


对数器:让随机测试替你找错

“写完能跑”不等于“所有边界都正确”。可以用简单方法作为基准,与二分方法进行大量随机对比。

题目 1~3 的基准方法

public class BinarySearchVerifier {

    public static boolean linearExists(int[] arr, int target) {
        if (arr == null) {
            return false;
        }
        for (int value : arr) {
            if (value == target) {
                return true;
            }
        }
        return false;
    }

    public static int linearNearestLeft(int[] arr, int target) {
        if (arr == null) {
            return -1;
        }
        for (int i = 0; i < arr.length; i++) {
            if (arr[i] >= target) {
                return i;
            }
        }
        return -1;
    }

    public static int linearNearestRight(int[] arr, int target) {
        if (arr == null) {
            return -1;
        }
        for (int i = arr.length - 1; i >= 0; i--) {
            if (arr[i] <= target) {
                return i;
            }
        }
        return -1;
    }
}

局部最小值结果校验

局部最小值可能不唯一,因此不能要求两个方法返回相同下标,只需判断返回的位置是否合法。

public static boolean isLocalMinimum(int[] arr, int index) {
    if (arr == null || arr.length == 0) {
        return index == -1;
    }
    if (index < 0 || index >= arr.length) {
        return false;
    }

    boolean lessThanLeft = index == 0 || arr[index] < arr[index - 1];
    boolean lessThanRight = index == arr.length - 1
            || arr[index] < arr[index + 1];
    return lessThanLeft && lessThanRight;
}

随机测试的两个细节

  1. Random 对象应在循环外创建,不要在每轮测试中反复创建;
  2. target 应按测试值域生成,而不是按数组下标范围生成,否则很多数组值永远不会成为目标值。

四种模型,一张表记住

模型命中条件命中后是否结束命中后方向
是否存在arr[mid] == target直接返回
最左 >= targetarr[mid] >= target记录并向左
最右 <= targetarr[mid] <= target记录并向右
局部最小值小于左右邻居沿下降趋势缩小区间

自测环节

自测 1

有序数组 [2, 4, 4, 4, 7, 9] 中:

  1. 大于等于 4 的最左位置是多少?
  2. 小于等于 4 的最右位置是多少?
  3. 大于等于 5 的最左位置是多少?
查看答案
  1. 下标 1
  2. 下标 3
  3. 下标 4

自测 2

为什么边界二分在找到合法位置后不能立刻返回?

查看答案

因为当前位置只证明“合法”,不能证明“最左”或“最右”。必须记录候选,并继续向目标边界搜索。

自测 3

数组 [6, 4, 2, 3, 5] 的一个局部最小值下标是多少?

查看答案

下标 2,因为 arr[2] = 2,它同时小于左右邻居 4 和 3。


今日挑战

尝试独立实现以下问题:

  1. 找到有序数组中第一个等于 target 的位置;
  2. 找到有序数组中最后一个等于 target 的位置;
  3. 统计 target 在有序数组中的出现次数;
  4. 在旋转有序数组中查找目标值。

提示:前 3 题都可以由今天的“最左边界”和“最右边界”模型组合得到。


今日复盘

  • 我能解释为什么二分查找是 O(log N)
  • 我能正确处理闭区间的循环条件与边界更新;
  • 我能写出“最左满足条件”和“最右满足条件”的模板;
  • 我知道找到候选答案后为什么还要继续搜索;
  • 我能解释无序数组为什么也可能使用二分;
  • 我会用基准方法和随机测试验证算法。

一句话总结

二分查找的本质不是“数组有序”,而是利用可判断的规律,每次安全地排除一半不可能包含答案的区域。

完成 Day 03 后,建议不要死记代码。关掉答案,分别用“存在性、最左边界、最右边界、局部最小值”四种模型各手写一次,才算真正掌握。