Files
wehub-resource-sync bdaa917396
Docs Test / Test docs (push) Failing after 1s
chore: import upstream snapshot with attribution
2026-07-13 11:57:49 +08:00

319 lines
13 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
---
title: 二分查找面试题总结:左右边界、答案二分与 Java 模板
description: 二分查找面试题总结,系统讲解基础二分、左边界、右边界、答案二分、Java 手写模板、复杂度分析和 LeetCode 高频题。
category: 计算机基础
tag:
- 算法
head:
- - meta
- name: keywords
content: 二分查找,二分查找模板,左右边界,答案二分,Java二分查找,LeetCode二分查找,算法面试题
---
二分查找最容易让人翻车的地方不是思想,而是边界。`left``right``mid`、循环条件、返回值,只要有一个含义没想清楚,就很容易写出死循环或者漏掉答案。
面试里判断能不能用二分,先看一句话:**答案所在空间是否有单调性**。数组有序只是最直观的一种情况,最小速度、最小容量、最小天数这类题,也可以在答案范围上二分。
## 面试考察重点
- 能写出基础二分模板。
- 能处理左边界、右边界。
- 能识别答案二分,而不是只会在数组里找数。
- 能解释为什么循环会结束,为什么不会漏答案。
- 能说出时间复杂度是 `O(logn)`,空间复杂度通常是 `O(1)`
## 什么时候想到二分?
不要把二分查找理解成“只能在有序数组里找数字”。它真正依赖的是 **单调性**
常见单调性有两类:
| 类型 | 例子 | 判断方式 |
| -------- | ------------------------------ | ------------------------------------------ |
| 数组单调 | 有序数组中找 `target` | `nums[mid]``target` 比较后能排除一半 |
| 答案单调 | 求最小速度、最小容量、最少天数 | 某个答案可行时,更大的答案也可行,或反过来 |
比如“爱吃香蕉的珂珂”里,吃香蕉速度越快,越容易在规定时间内吃完。这里数组本身不需要有序,单调的是“速度”和“是否能吃完”之间的关系。
面试时可以这样判断:
1. 题目是否在找一个位置、边界或最小/最大可行值?
2. 如果猜一个答案 `x`,能不能在 `O(n)` 或更低复杂度内判断它是否可行?
3. `x` 变大或变小时,可行性是否单调变化?
三个问题都能回答上来,基本就可以尝试二分。
## 基础二分模板
适合在有序数组中查找一个确定值:
```java
int binarySearch(int[] nums, int target) {
int left = 0;
int 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 -1;
}
```
这个模板里,搜索区间是闭区间 `[left, right]`,所以循环条件是 `left <= right`。每次排除 `mid`,因此更新成 `mid + 1``mid - 1`
用一句话记这个模板:**区间里每个位置都还可能是答案,循环结束时区间为空。**
举个例子,数组 `[1, 3, 5, 7, 9]` 中找 `7`
1. `left = 0``right = 4``mid = 2``nums[mid] = 5`,目标在右侧。
2. 更新 `left = mid + 1 = 3`
3. `mid = 3`,找到 `7`
如果查找 `6`,最后会出现 `left > right`,说明闭区间已经被排空,返回 `-1`
## 左边界模板
找第一个大于等于 `target` 的位置:
```java
int lowerBound(int[] nums, int target) {
int left = 0;
int right = nums.length;
while (left < right) {
int mid = left + (right - left) / 2;
if (nums[mid] >= target) {
right = mid;
} else {
left = mid + 1;
}
}
return left;
}
```
这个模板的搜索区间是左闭右开 `[left, right)``right` 初始化为 `nums.length`,返回值可能等于 `nums.length`,表示数组中不存在大于等于 `target` 的位置。
左边界模板的关键不是“找到 target”,而是“找到第一个满足条件的位置”。这个写法能自然处理目标不存在的情况。
比如数组 `[1, 2, 2, 2, 4]`,找第一个大于等于 `2` 的位置:
-`nums[mid] >= 2``mid` 可能就是答案,所以不能排除 `mid`,更新 `right = mid`
-`nums[mid] < 2``mid` 和它左边都不可能是答案,更新 `left = mid + 1`
循环结束时,`left == right`,这个位置就是第一个满足条件的位置。
## 右边界模板
找最后一个小于等于 `target` 的位置,可以先找第一个大于 `target` 的位置,再减 1
```java
int upperBound(int[] nums, int target) {
int left = 0;
int right = nums.length;
while (left < right) {
int mid = left + (right - left) / 2;
if (nums[mid] > target) {
right = mid;
} else {
left = mid + 1;
}
}
return left - 1;
}
```
这种写法的好处是左右边界只记一套思路:找第一个满足条件的位置。
右边界容易写错,推荐转化成左边界问题:
- 最后一个小于等于 `target` 的位置 = 第一个大于 `target` 的位置 - 1。
- 最后一个小于 `target` 的位置 = 第一个大于等于 `target` 的位置 - 1。
这样不需要维护两套模板,面试手写时更稳。
## 答案二分
答案二分不是在数组里找元素,而是在答案范围里找最小可行值或最大可行值。
典型问题:给定若干堆香蕉和总时间 `h`,求最小吃香蕉速度。速度越快,越容易在 `h` 小时内吃完,这就是单调性。
这类题通常分两步:
1. 确定答案范围。比如速度最小是 `1`,最大不超过最大那堆香蕉数。
2.`check` 函数。给定一个速度,判断能不能在 `h` 小时内吃完。
这个上界成立依赖题目约束:`h >= piles.length`。因为速度等于最大堆大小时,每堆香蕉最多 1 小时吃完,总耗时不会超过堆数。
```java
int minEatingSpeed(int[] piles, int h) {
int left = 1;
int right = 0;
for (int pile : piles) {
right = Math.max(right, pile);
}
while (left < right) {
int mid = left + (right - left) / 2;
if (canFinish(piles, h, mid)) {
right = mid;
} else {
left = mid + 1;
}
}
return left;
}
boolean canFinish(int[] piles, int h, int speed) {
long hours = 0;
for (int pile : piles) {
hours += (pile + speed - 1) / speed;
}
return hours <= h;
}
```
这里为什么返回 `left`?因为循环一直在找“第一个可行速度”。当 `canFinish(mid)` 为 true,说明 `mid` 可行,但可能还有更小的速度也可行,所以收缩右边界。最后左右边界重合的位置,就是最小可行速度。
答案二分的 `check` 函数往往比二分本身更重要。面试时建议先把 `check` 的含义说清楚,再写二分框架。
## 三类二分怎么选?
| 目标 | 推荐模板 | 返回值 |
| ---------------------------- | -------- | ----------------------------- |
| 找到某个等于 `target` 的下标 | 基础二分 | 找到返回下标,找不到返回 `-1` |
| 找第一个满足条件的位置 | 左边界 | 返回 `left`,可能等于数组长度 |
| 找最小可行答案 | 答案二分 | 返回最终的 `left` |
如果题目里有“第一个”“最后一个”“最小可行”“最大可行”,不要急着写基础二分,先判断是不是边界问题。
## 面试手写路径
二分题的代码不长,面试里更容易被追问的是“你为什么敢丢掉一半”。手写时可以按这个顺序来:
1. 先说明搜索空间:是在数组下标里找,还是在答案范围里找。
2. 再说明单调性:`mid` 左右两侧为什么可以排除一边。
3. 明确区间含义:闭区间 `[left, right]` 还是左闭右开 `[left, right)`
4. 写更新规则:`mid` 还能不能成为答案,决定写 `right = mid` 还是 `right = mid - 1`
5. 最后说返回值:循环结束时 `left``right` 分别代表什么。
一个很实用的自检问题是:**当 `nums[mid]` 正好满足条件时,我有没有把可能的答案删掉?** 左边界、答案二分里,`mid` 经常仍然可能是答案,所以不能随手写成 `right = mid - 1`
## 代表题精讲:查找第一个和最后一个位置
[34. 在排序数组中查找元素的第一个和最后一个位置](https://leetcode.cn/problems/find-first-and-last-position-of-element-in-sorted-array/) 是边界二分的典型题。题目要求返回 `target` 的起始和结束位置,如果不存在返回 `[-1, -1]`
这题不要写成“找到一个 target 后向两边扫描”。虽然能过一些用例,但最坏情况下会退化成 `O(n)`。更稳的写法是做两次边界查找:
- 第一次找第一个大于等于 `target` 的位置。
- 第二次找第一个大于 `target` 的位置,再减 1。
下面两个辅助方法与上文模板一致,这里保留完整代码,方便把返回值含义和主逻辑放在一起对照。
```java
int[] searchRange(int[] nums, int target) {
int left = lowerBound(nums, target);
if (left == nums.length || nums[left] != target) {
return new int[] {-1, -1};
}
int right = upperBound(nums, target) - 1;
return new int[] {left, right};
}
int lowerBound(int[] nums, int target) {
int left = 0;
int right = nums.length;
while (left < right) {
int mid = left + (right - left) / 2;
if (nums[mid] >= target) {
right = mid;
} else {
left = mid + 1;
}
}
return left;
}
int upperBound(int[] nums, int target) {
int left = 0;
int right = nums.length;
while (left < right) {
int mid = left + (right - left) / 2;
if (nums[mid] > target) {
right = mid;
} else {
left = mid + 1;
}
}
return left;
}
```
面试里这题常见追问是:如果数组中全是 `target` 怎么办?如果 `target` 不存在但应该插在中间怎么办?这两个问题其实都在考返回值含义。`lowerBound` 返回的是第一个满足条件的位置,不保证这个位置上的值一定等于 `target`,所以返回前要再检查一次。
## 过程示意和边界样例
以左边界模板为例,数组 `[1, 2, 2, 2, 4]` 中找第一个大于等于 `2` 的位置:
| 轮次 | `left` | `right` | `mid` | 判断 | 下一步 |
| ---- | ------ | ------- | ----- | --------------- | ----------- |
| 1 | 0 | 5 | 2 | `nums[2] >= 2` | `right = 2` |
| 2 | 0 | 2 | 1 | `nums[1] >= 2` | `right = 1` |
| 3 | 0 | 1 | 0 | `nums[0] < 2` | `left = 1` |
| 结束 | 1 | 1 | - | `left == right` | 返回 1 |
几个边界样例建议手写前先过一遍:
| 输入 | 目标 | 预期 |
| ----------- | ---------- | ------------------------------------ |
| `[]` | `1` | 返回 `-1` 或插入位置 `0`,看题目要求 |
| `[1]` | `1` | 能命中唯一元素 |
| `[1, 1, 1]` | 左边界 `1` | 返回 `0` |
| `[1, 3, 5]` | 左边界 `4` | 返回 `2` |
| `[1, 3, 5]` | 左边界 `6` | 返回 `3` |
常见错误写法:
```java
while (left < right) {
int mid = (left + right) / 2;
if (nums[mid] >= target) {
right = mid - 1; // 错:mid 可能就是左边界,不能直接排除
} else {
left = mid + 1;
}
}
```
左边界里,当 `nums[mid] >= target` 时,`mid` 仍然可能是答案,所以应该写 `right = mid`
## 易错点
- `mid = (left + right) / 2` 可能整数溢出,推荐写成 `left + (right - left) / 2`
- 不要混用闭区间和左闭右开区间的更新方式。
- 找边界时,命中目标后通常不能直接返回,还要继续收缩区间。
- 答案二分要先证明单调性,不能看到“最小值”就硬套。
- `canFinish` 这类判断函数里可能需要 `long`,避免累计值溢出。
## 高频问题自测
- `left < right``left <= right` 有什么区别?
- 二分查找为什么是 `O(logn)`
- 找左边界时,为什么命中后要移动 `right`
- 什么是答案二分?它和普通二分有什么区别?
- 二分查找一定要求数组有序吗?
## 推荐练习题
- [704. 二分查找](https://leetcode.cn/problems/binary-search/)
- [35. 搜索插入位置](https://leetcode.cn/problems/search-insert-position/)
- [34. 在排序数组中查找元素的第一个和最后一个位置](https://leetcode.cn/problems/find-first-and-last-position-of-element-in-sorted-array/)
- [875. 爱吃香蕉的珂珂](https://leetcode.cn/problems/koko-eating-bananas/)
- [1011. 在 D 天内送达包裹的能力](https://leetcode.cn/problems/capacity-to-ship-packages-within-d-days/)
<!-- @include: @article-footer.snippet.md -->