二分查找:每次排除一半,log n 解决问题
一、核心概念
1.1 什么是二分查找?
本质: 在有序数组中,每次取中间元素比较,根据结果排除一半元素。
前提: 数组必须有序
复杂度: o(log n)
直观对比:
线性查找100万个数:最多100万次 二分查找100万个数:最多20次(log₂1000000 ≈ 20)
示例:
数组:[1, 3, 5, 7, 9, 11, 13, 15] 查找:11 第1次:mid=7,11>7,搜索右半边 第2次:mid=11,找到!
二、基础模板
2.1 标准二分查找
leetcode 704: 在有序数组中查找目标值,返回索引,不存在返回-1。
代码:
public int binarysearch(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 -1;
}
关键细节:
防溢出:
mid = left + (right - left) / 2// 错误:left + right 可能溢出 int mid = (left + right) / 2; // 正确:不会溢出 int mid = left + (right - left) / 2;
循环条件:
while (left <= right)vswhile (left < right)// left <= right:适用于查找具体值 // 区间 [left, right],闭区间,包含两端 // 当 left == right 时,还有1个元素要检查 // left < right:适用于缩小范围找唯一解 // 区间 [left, right],但循环结束时 left == right // 常用于找边界、找峰值等问题
边界更新:
left = mid + 1和right = mid - 1// nums[mid] 已检查过,不是目标值 // 所以下次搜索要排除 mid
2.2 查找左边界
问题: 在有序数组中查找第一个等于 target 的位置。
示例: nums = [1,2,2,2,3], target = 2 → 返回 1
思路: 即使找到 target,也继续向左搜索。
代码(闭区间写法):
public int searchleft(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 {
// 关键:即使 nums[mid] == target,也继续向左
right = mid - 1;
}
}
// 检查 left 是否越界,以及是否等于 target
if (left >= nums.length || nums[left] != target) {
return -1;
}
return left;
}
代码(左闭右开写法):
public int searchleft(int[] nums, int target) {
int left = 0, right = nums.length; // 注意:right = length
while (left < right) { // 注意:left < right
int mid = left + (right - left) / 2;
if (nums[mid] < target) {
left = mid + 1;
} else {
right = mid; // 注意:right = mid,不是 mid - 1
}
}
// 检查边界
if (left >= nums.length || nums[left] != target) {
return -1;
}
return left;
}
推导过程:
数组:[1, 2, 2, 2, 3],查找 2 的左边界 初始:left=0, right=4 第1轮:mid=2, nums[2]=2 >= 2, right=1 第2轮:mid=0, nums[0]=1 < 2, left=1 第3轮:left=1, right=1, mid=1, nums[1]=2 >= 2, right=0 第4轮:left > right,退出 返回 left=1
2.3 查找右边界
问题: 在有序数组中查找最后一个等于 target 的位置。
示例: nums = [1,2,2,2,3], target = 2 → 返回 3
代码(闭区间写法):
public int searchright(int[] nums, int target) {
int left = 0, right = nums.length - 1;
while (left <= right) {
int mid = left + (right - left) / 2;
if (nums[mid] <= target) {
// 关键:即使 nums[mid] == target,也继续向右
left = mid + 1;
} else {
right = mid - 1;
}
}
// 检查 right 是否越界,以及是否等于 target
if (right < 0 || nums[right] != target) {
return -1;
}
return right;
}
代码(左闭右开写法):
public int searchright(int[] nums, int target) {
int left = 0, right = nums.length;
while (left < right) {
int mid = left + (right - left) / 2;
if (nums[mid] <= target) {
left = mid + 1;
} else {
right = mid;
}
}
// 注意:返回 left - 1
if (left - 1 < 0 || nums[left - 1] != target) {
return -1;
}
return left - 1;
}
2.4 查找插入位置
leetcode 35: 在有序数组中查找 target 的插入位置(保持有序)。
示例: nums = [1,3,5,7], target = 4 → 返回 2
代码:
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) {
left = mid + 1;
} else {
right = mid - 1;
}
}
// 循环结束时,left 就是插入位置
return left;
}
三、经典题目
3.1 搜索旋转排序数组(leetcode 33)
题目: 在旋转后的有序数组中查找目标值。
示例: nums = [4,5,6,7,0,1,2], target = 0 → 返回 4
思路: 数组被旋转后,至少有一半是有序的,判断 target 在哪一半。
代码:
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 (nums[left] <= target && target < nums[mid]) {
right = mid - 1; // target 在左半边
} else {
left = mid + 1; // target 在右半边
}
} else {
// 右半边有序 [mid, right]
if (nums[mid] < target && target <= nums[right]) {
left = mid + 1; // target 在右半边
} else {
right = mid - 1; // target 在左半边
}
}
}
return -1;
}
3.2 在排序数组中查找元素的第一个和最后一个位置(leetcode 34)
题目: 找到 target 的左右边界。
示例: nums = [5,7,7,8,8,10], target = 8 → 返回 [3,4]
代码:
public int[] searchrange(int[] nums, int target) {
int left = searchleft(nums, target);
int right = searchright(nums, target);
return new int[]{left, right};
}
// 使用前面的 searchleft 和 searchright 方法
3.3 寻找峰值(leetcode 162)
题目: 找到数组中的峰值(比相邻元素都大)。
示例: nums = [1,2,3,1] → 返回 2
思路: 如果 nums[mid] < nums[mid+1],峰值一定在右边。
代码:
public int findpeakelement(int[] nums) {
int left = 0, right = nums.length - 1;
while (left < right) {
int mid = left + (right - left) / 2;
if (nums[mid] < nums[mid + 1]) {
left = mid + 1; // 峰值在右边
} else {
right = mid; // 峰值在左边或就是 mid
}
}
return left;
}
3.4 搜索二维矩阵(leetcode 74)
题目: 在二维矩阵中查找目标值(每行有序,每行第一个元素大于上一行最后一个)。
思路: 把二维矩阵看成一维数组,用二分查找。
代码:
public boolean searchmatrix(int[][] matrix, int target) {
int m = matrix.length, n = matrix[0].length;
int left = 0, right = m * n - 1;
while (left <= right) {
int mid = left + (right - left) / 2;
// 将一维索引转换为二维坐标
int row = mid / n;
int col = mid % n;
int num = matrix[row][col];
if (num == target) {
return true;
} else if (num < target) {
left = mid + 1;
} else {
right = mid - 1;
}
}
return false;
}
3.5 寻找旋转排序数组中的最小值(leetcode 153)
题目: 找到旋转后数组的最小值。
示例: nums = [3,4,5,1,2] → 返回 1
代码:
public int findmin(int[] nums) {
int left = 0, right = nums.length - 1;
while (left < right) {
int mid = left + (right - left) / 2;
if (nums[mid] > nums[right]) {
left = mid + 1; // 最小值在右边
} else {
right = mid; // 最小值在左边或就是 mid
}
}
return nums[left];
}
四、进阶技巧
4.1 二分答案
核心思想: 当问题的答案具有单调性时,可以二分答案,然后验证。
适用场景:
- 求"最大值的最小"或"最小值的最大"
- 答案在某个范围内,且具有单调性
例题1:分割数组的最大值(leetcode 410)
题目: 将数组分成 m 个非空连续子数组,使这 m 个子数组各自和的最大值最小。
示例:
输入:nums = [7,2,5,10,8], m = 2 输出:18 解释:分成 [7,2,5] 和 [10,8],最大和为 18
思路:
- 答案范围:[max(nums), sum(nums)]
- 二分答案 mid,检查能否在 mid 限制下分成 m 个子数组
- 如果可以,说明答案可能更小,right = mid - 1
- 如果不行,说明答案太小,left = mid + 1
代码:
public int splitarray(int[] nums, int m) {
int left = 0, right = 0;
// 确定答案范围
for (int num : nums) {
left = math.max(left, num); // 最小可能:数组最大值
right += num; // 最大可能:数组总和
}
// 二分答案
while (left <= right) {
int mid = left + (right - left) / 2;
if (cansplit(nums, m, mid)) {
right = mid - 1; // 可以分,尝试更小的答案
} else {
left = mid + 1; // 不能分,答案太小
}
}
return left;
}
// 检查能否在 maxsum 限制下分成 m 个子数组
private boolean cansplit(int[] nums, int m, int maxsum) {
int count = 1; // 至少需要1个子数组
int sum = 0;
for (int num : nums) {
if (sum + num > maxsum) {
count++; // 需要新的子数组
sum = num;
} else {
sum += num;
}
}
return count <= m;
}
时间复杂度: o(n × log(sum))
例题2:在 d 天内送达包裹的能力(leetcode 1011)
题目: 传送带上的包裹必须在 d 天内送达,求传送带的最低运载能力。
示例:
输入:weights = [1,2,3,4,5,6,7,8,9,10], d = 5 输出:15 解释:每天运载能力为 15,可以在 5 天内送完
代码:
public int shipwithindays(int[] weights, int days) {
int left = 0, right = 0;
for (int w : weights) {
left = math.max(left, w); // 最小运载能力:最重的包裹
right += w; // 最大运载能力:所有包裹总重
}
while (left <= right) {
int mid = left + (right - left) / 2;
if (canship(weights, days, mid)) {
right = mid - 1;
} else {
left = mid + 1;
}
}
return left;
}
private boolean canship(int[] weights, int days, int capacity) {
int needdays = 1, load = 0;
for (int w : weights) {
if (load + w > capacity) {
needdays++;
load = w;
} else {
load += w;
}
}
return needdays <= days;
}
4.2 区间写法对比
二分查找有两种常见的区间写法,各有优劣。
闭区间 [left, right]
特点:
right = nums.length - 1while (left <= right)right = mid - 1或left = mid + 1
优点:
- 直观,容易理解
- 边界条件清晰
- 适合查找具体值
缺点:
- 需要注意
<=和-1 - 容易写错边界更新
示例:
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;
}
}
左闭右开 [left, right)
特点:
right = nums.lengthwhile (left < right)right = mid或left = mid + 1
优点:
- 不容易死循环
- 适合找边界、找峰值
- 循环结束时
left == right
缺点:
- 不够直观
- 返回值需要注意(可能是
left - 1)
示例:
int left = 0, right = nums.length;
while (left < right) {
int mid = left + (right - left) / 2;
if (nums[mid] < target) {
left = mid + 1;
} else {
right = mid; // 注意:不是 mid - 1
}
}
return left; // 或 left - 1,取决于具体问题
如何选择?
| 场景 | 推荐写法 | 原因 |
|---|---|---|
| 查找具体值 | 闭区间 | 直观,容易理解 |
| 查找左边界 | 两种都可以 | 闭区间更常见 |
| 查找右边界 | 两种都可以 | 闭区间更常见 |
| 找峰值、最小值 | 左闭右开 | 不容易死循环 |
| 二分答案 | 闭区间 | 边界条件清晰 |
建议:
- 初学者:统一用闭区间,容易理解
- 熟练后:根据场景选择,左闭右开在某些题目中更简洁
4.3 循环条件详解
while (left <= right) vs while (left < right)
这是二分查找中最容易混淆的点,理解它们的区别非常重要。
while (left <= right)- 查找具体值
适用场景: 查找数组中是否存在某个具体值
特点:
- 区间 [left, right],闭区间
- 当
left == right时,还有1个元素要检查 - 循环结束时,
left > right
示例:
// 查找 target 是否存在
int left = 0, right = nums.length - 1;
while (left <= right) { // 包含 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; // 没找到
为什么用 <=?
假设 nums = [5], target = 5 初始:left = 0, right = 0 如果用 left < right: 循环不执行,直接返回 -1(错误!) 如果用 left <= right: 第1轮:left = 0, right = 0, mid = 0 nums[0] = 5 == target,返回 0(正确!)
while (left < right)- 缩小范围找唯一解
适用场景: 缩小范围直到找到唯一解(边界、峰值、最小值等)
特点:
- 循环结束时,
left == right,指向唯一解 - 不需要判断
nums[mid] == target - 常用于找边界、找峰值
示例1:寻找峰值
int left = 0, right = nums.length - 1;
while (left < right) { // 缩小范围
int mid = left + (right - left) / 2;
if (nums[mid] < nums[mid + 1]) {
left = mid + 1; // 峰值在右边
} else {
right = mid; // 峰值在左边或就是 mid
}
}
return left; // left == right,指向峰值
示例2:寻找旋转数组最小值
int left = 0, right = nums.length - 1;
while (left < right) {
int mid = left + (right - left) / 2;
if (nums[mid] > nums[right]) {
left = mid + 1; // 最小值在右边
} else {
right = mid; // 最小值在左边或就是 mid
}
}
return nums[left]; // left == right,指向最小值
为什么用 <?
因为我们不是在找具体值,而是在缩小范围 循环结束时: left == right,指向唯一解 如果用 left <= right: 可能会死循环(当 right = mid 时)
对比总结
| 对比项 | left <= right | left < right |
|---|---|---|
| 适用场景 | 查找具体值 | 缩小范围找唯一解 |
| 循环结束条件 | left > right | left == right |
| 是否需要判断相等 | 需要 | 不需要 |
| 边界更新 | right = mid - 1 | right = mid |
| 典型题目 | 二分查找、查找插入位置 | 寻找峰值、旋转数组最小值 |
记忆技巧:
left <= right:找具体值,要检查所有元素 left < right:找范围,缩小到唯一解
4.4 第 k 小/大元素
核心: 二分答案,统计小于等于 mid 的元素个数。
例题:有序矩阵中第 k 小的元素(leetcode 378)
题目: 在 n×n 矩阵中(每行每列都升序),找第 k 小的元素。
示例:
matrix = [ [1, 5, 9], [10, 11, 13], [12, 13, 15] ] k = 8 输出:13
思路: 二分答案,统计矩阵中小于等于 mid 的元素个数。
代码:
public int kthsmallest(int[][] matrix, int k) {
int n = matrix.length;
int left = matrix[0][0];
int right = matrix[n-1][n-1];
while (left < right) {
int mid = left + (right - left) / 2;
int count = countlessequal(matrix, mid);
if (count < k) {
left = mid + 1;
} else {
right = mid;
}
}
return left;
}
// 统计矩阵中小于等于 target 的元素个数
private int countlessequal(int[][] matrix, int target) {
int n = matrix.length;
int count = 0;
int row = n - 1, col = 0; // 从左下角开始
while (row >= 0 && col < n) {
if (matrix[row][col] <= target) {
count += row + 1; // 这一列前 row+1 个元素都 <= target
col++;
} else {
row--;
}
}
return count;
}
时间复杂度: o(n × log(max - min))
4.5 最小化最大值/最大化最小值
这类问题的特征:
- 求"最大值的最小"
- 求"最小值的最大"
- 答案具有单调性
通用思路:
- 确定答案范围 [left, right]
- 二分答案 mid
- 检查 mid 是否满足条件
- 根据结果调整范围
例题:磁力石(leetcode 1552)
题目: 在 position 数组中选 m 个位置放球,使任意两球间的最小磁力最大。
示例:
输入:position = [1,2,3,4,7], m = 3 输出:3 解释:放在 1, 4, 7,最小距离为 3
代码:
public int maxdistance(int[] position, int m) {
arrays.sort(position);
int left = 1;
int right = position[position.length - 1] - position[0];
while (left <= right) {
int mid = left + (right - left) / 2;
if (canplace(position, m, mid)) {
left = mid + 1; // 可以放,尝试更大的距离
} else {
right = mid - 1; // 不能放,距离太大
}
}
return right;
}
// 检查能否以 mindist 的最小距离放 m 个球
private boolean canplace(int[] position, int m, int mindist) {
int count = 1; // 第一个位置必放
int lastpos = position[0];
for (int i = 1; i < position.length; i++) {
if (position[i] - lastpos >= mindist) {
count++;
lastpos = position[i];
}
}
return count >= m;
}
五、通用模板总结
模板1:标准二分查找(闭区间)
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; // 注意:mid - 1
}
}
return -1;
模板2:查找左边界(闭区间)
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 (left < nums.length && nums[left] == target) ? left : -1;
模板3:查找右边界(闭区间)
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 >= 0 && nums[right] == target) ? right : -1;
模板4:缩小范围找唯一解(左闭右开)
int left = 0, right = nums.length; // 注意:right = length
while (left < right) { // 注意:<
int mid = left + (right - left) / 2;
if (check(mid)) {
right = mid; // 注意:mid,不是 mid - 1
} else {
left = mid + 1;
}
}
return left; // left == right
模板5:二分答案
int left = minanswer, right = maxanswer;
while (left <= right) {
int mid = left + (right - left) / 2;
if (check(mid)) {
right = mid - 1; // 答案可能更小
} else {
left = mid + 1; // 答案太小
}
}
return left;
六、常见错误与避坑
错误1:死循环
// 错误:使用 left < right,但更新时用 left = mid
while (left < right) {
int mid = left + (right - left) / 2;
if (nums[mid] < target) {
left = mid; // 当 left=mid 时会死循环
} else {
right = mid - 1;
}
}
// 正确:要么用 left <= right + left = mid + 1
// 要么用 left < right + right = mid
错误2:边界检查遗漏
// 错误:返回前没有检查边界
return left; // left 可能越界
// 正确:检查边界
if (left >= nums.length || nums[left] != target) {
return -1;
}
return left;
错误3:整数溢出
// 错误:left + right 可能溢出 int mid = (left + right) / 2; // 正确:不会溢出 int mid = left + (right - left) / 2;
错误4:循环条件与边界更新不匹配
// 错误:用 left < right,但更新时用 right = mid - 1
while (left < right) {
int mid = left + (right - left) / 2;
if (nums[mid] < target) {
left = mid + 1;
} else {
right = mid - 1; // 错误!应该是 right = mid
}
}
// 正确:left < right 配合 right = mid
while (left < right) {
int mid = left + (right - left) / 2;
if (nums[mid] < target) {
left = mid + 1;
} else {
right = mid; // 正确
}
}
错误5:混淆闭区间和左闭右开
// 错误:初始化用 length - 1,但循环用 left < right
int left = 0, right = nums.length - 1; // 闭区间
while (left < right) { // 左闭右开的循环条件
// ...
}
// 正确:统一使用闭区间
int left = 0, right = nums.length - 1;
while (left <= right) {
// ...
}
// 或统一使用左闭右开
int left = 0, right = nums.length;
while (left < right) {
// ...
}
七、题目推荐
| 题号 | 题目 | 难度 | 类型 |
|---|---|---|---|
| 704 | 二分查找 | 简单 | 标准模板 |
| 35 | 搜索插入位置 | 简单 | 插入位置 |
| 34 | 在排序数组中查找元素的第一个和最后一个位置 | 中等 | 左右边界 |
| 33 | 搜索旋转排序数组 | 中等 | 旋转数组 |
| 81 | 搜索旋转排序数组ii | 中等 | 旋转数组+重复 |
| 153 | 寻找旋转排序数组中的最小值 | 中等 | 旋转数组 |
| 154 | 寻找旋转排序数组中的最小值ii | 困难 | 旋转数组+重复 |
| 162 | 寻找峰值 | 中等 | 峰值 |
| 74 | 搜索二维矩阵 | 中等 | 二维转一维 |
| 240 | 搜索二维矩阵ii | 中等 | 从右上角开始 |
| 410 | 分割数组的最大值 | 困难 | 二分答案 |
| 1011 | 在d天内送达包裹的能力 | 中等 | 二分答案 |
| 875 | 爱吃香蕉的珂珂 | 中等 | 二分答案 |
| 1552 | 两球之间的磁力 | 中等 | 最大化最小值 |
| 378 | 有序矩阵中第k小的元素 | 中等 | 第k小 |
| 69 | x的平方根 | 简单 | 整数二分 |
八、总结
核心要点
二分查找 = 每次排除一半
- 时间复杂度:o(log n)
- 前提:数组有序
循环条件的选择(重要!)
left <= right:查找具体值,检查所有元素left < right:缩小范围找唯一解,循环结束时 left == right
区间写法的选择
- 闭区间 [left, right]:直观,适合查找具体值
- 左闭右开 [left, right):不易死循环,适合找边界
三种基础模板
- 标准二分:查找目标值
- 左边界:查找第一个等于 target 的位置
- 右边界:查找最后一个等于 target 的位置
进阶技巧
- 二分答案:答案具有单调性
- 第 k 小/大:二分答案 + 统计
- 最大化最小值/最小化最大值
关键细节
mid = left + (right - left) / 2(防溢出)- 循环条件与边界更新要匹配
- 注意边界检查,避免越界
学习建议
理解循环条件的本质
left <= right:查找具体值left < right:缩小范围- 这是二分查找最重要的知识点
选择一种区间写法并坚持
- 初学者:统一用闭区间 [left, right]
- 熟练后:根据场景灵活选择
刷题顺序
- 先刷 leetcode 704(标准二分)
- 再刷 leetcode 34(左右边界)
- 然后刷 leetcode 162、153(缩小范围)
- 最后刷 leetcode 410、1011(二分答案)
多画图理解
- 每次循环画出 left、mid、right
- 理解为什么这样更新边界
- 理解循环结束时的状态
总结
到此这篇关于java二分查找之循环条件与区间写法详解的文章就介绍到这了,更多相关java二分查找循环条件与区间内容请搜索代码网以前的文章或继续浏览下面的相关文章希望大家以后多多支持代码网!
发表评论