百日算法修炼 · 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
请先思考
- 当
arr[mid] < target时,为什么可以排除[left, mid]? - 循环应该写成
left < right还是left <= right? null和空数组应该怎样处理?
提示 1:先确定搜索区间
维护闭区间 [left, right],它表示目标值仍然可能出现的位置。
提示 2:一次比较后排除哪一半?
- 若
arr[mid] < target,目标只能在右半区; - 若
arr[mid] > target,目标只能在左半区; - 若二者相等,直接返回
true。
题目 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
↑
找这里
题目 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 中的三个地方:
- 合法候选的判断条件;
- 找到候选后继续搜索的方向;
- 无法成为答案时排除的区间。
题目 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本身就是局部最小值。
答案与解析
建议先独立完成题目区,再阅读本节。
答案 1:有序数组中是否存在目标值
核心思路
始终维护一个闭区间 [left, right]:如果目标值存在,它就一定在这个区间内。
每次取中点 mid 后:
| 比较结果 | 可以得出的结论 | 下一步 |
|---|---|---|
arr[mid] == target | 已找到目标值 | 返回 true |
arr[mid] < target | mid 及其左侧都小于目标值 | left = mid + 1 |
arr[mid] > target | mid 及其右侧都大于目标值 | right = mid - 1 |
当 left > right 时,搜索区间为空,说明目标值不存在。
过程图解:查找 target = 7
数组:
| 下标 | 0 | 1 | 2 | 3 | 4 | 5 |
|---|---|---|---|---|---|---|
| 数值 | 1 | 3 | 5 | 7 | 9 | 11 |
第 1 轮:
left | right | mid | arr[mid] | 判断 | 新区间 |
|---|---|---|---|---|---|
| 0 | 5 | 2 | 5 | 5 < 7,排除下标 0~2 | [3, 5] |
下标 0 1 2 3 4 5
数值 1 3 5 7 9 11
└── 已排除 ──┘ L R
第 2 轮:
left | right | mid | arr[mid] | 判断 | 结果 |
|---|---|---|---|---|---|
| 3 | 5 | 4 | 9 | 9 > 7,排除下标 4~5 | 新区间 [3, 3] |
下标 0 1 2 3 4 5
数值 1 3 5 7 9 11
L/R └─ 已排除 ─┘
第 3 轮:
left | right | mid | arr[mid] | 判断 | 结果 |
|---|---|---|---|---|---|
| 3 | 3 | 3 | 7 | 7 == 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)
易错点
- 闭区间写法却使用
while (left < right),导致最后一个位置未检查; - 更新边界时写成
left = mid或right = mid,可能造成死循环; - 使用
(left + right) / 2,存在整数溢出的隐患; - 忘记处理
null或空数组。
答案 2:大于等于目标值的最左位置
核心思路
遇到 arr[mid] >= target 时,mid 是一个合法候选,但它的左侧可能还有更靠左的合法位置。因此:
- 用
answer记录mid; - 继续到左半区搜索;
- 若
arr[mid] < target,则去右半区。
推演:[1, 2, 2, 2, 4, 6] 中寻找 target = 2
| 轮次 | 区间 | mid | arr[mid] | answer | 下一步 |
|---|---|---|---|---|---|
| 1 | [0, 5] | 2 | 2 | 2 | 合法,继续向左 |
| 2 | [0, 1] | 0 | 1 | 2 | 太小,继续向右 |
| 3 | [1, 1] | 1 | 2 | 1 | 合法,继续向左 |
| 结束 | 空区间 | — | — | 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:小于等于目标值的最右位置
核心思路
遇到 arr[mid] <= target 时,mid 是合法候选,但右侧可能还有更靠右的合法位置。记录答案后,继续向右搜索。
与题目 2 对照
| 对比项 | 最左 >= target | 最右 <= target |
|---|---|---|
| 候选条件 | arr[mid] >= target | arr[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:寻找局部最小值
核心思路
这道题的数组无序,但仍具有可以淘汰一半区域的趋势信息。
先单独处理边界:
- 数组为空:返回
-1; - 只有一个元素:返回
0; - 左端比右邻居小:返回
0; - 右端比左邻居小:返回
n - 1。
若两端都不是局部最小值,中间区域必然至少存在一个局部最小值。此时搜索范围可收紧为 [1, n - 2],这样访问 mid - 1 和 mid + 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] | 3 | 3 / 4 / 8 | 4 > 3,向左搜索 |
| 2 | [1, 2] | 1 | 9 / 7 / 3 | 7 > 3,向右搜索 |
| 3 | [2, 2] | 2 | 7 / 3 / 4 | 3 小于两侧,返回 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;
}
随机测试的两个细节
Random对象应在循环外创建,不要在每轮测试中反复创建;target应按测试值域生成,而不是按数组下标范围生成,否则很多数组值永远不会成为目标值。
四种模型,一张表记住
| 模型 | 命中条件 | 命中后是否结束 | 命中后方向 |
|---|---|---|---|
| 是否存在 | arr[mid] == target | 是 | 直接返回 |
最左 >= target | arr[mid] >= target | 否 | 记录并向左 |
最右 <= target | arr[mid] <= target | 否 | 记录并向右 |
| 局部最小值 | 小于左右邻居 | 是 | 沿下降趋势缩小区间 |
自测环节
自测 1
有序数组 [2, 4, 4, 4, 7, 9] 中:
- 大于等于 4 的最左位置是多少?
- 小于等于 4 的最右位置是多少?
- 大于等于 5 的最左位置是多少?
查看答案
- 下标
1; - 下标
3; - 下标
4。
自测 2
为什么边界二分在找到合法位置后不能立刻返回?
查看答案
因为当前位置只证明“合法”,不能证明“最左”或“最右”。必须记录候选,并继续向目标边界搜索。
自测 3
数组 [6, 4, 2, 3, 5] 的一个局部最小值下标是多少?
查看答案
下标 2,因为 arr[2] = 2,它同时小于左右邻居 4 和 3。
今日挑战
尝试独立实现以下问题:
- 找到有序数组中第一个等于
target的位置; - 找到有序数组中最后一个等于
target的位置; - 统计
target在有序数组中的出现次数; - 在旋转有序数组中查找目标值。
提示:前 3 题都可以由今天的“最左边界”和“最右边界”模型组合得到。
今日复盘
- 我能解释为什么二分查找是
O(log N); - 我能正确处理闭区间的循环条件与边界更新;
- 我能写出“最左满足条件”和“最右满足条件”的模板;
- 我知道找到候选答案后为什么还要继续搜索;
- 我能解释无序数组为什么也可能使用二分;
- 我会用基准方法和随机测试验证算法。
一句话总结
二分查找的本质不是“数组有序”,而是利用可判断的规律,每次安全地排除一半不可能包含答案的区域。
完成 Day 03 后,建议不要死记代码。关掉答案,分别用“存在性、最左边界、最右边界、局部最小值”四种模型各手写一次,才算真正掌握。