二分法的基本思路流程
用流程图总结二分查找的主要思路和类型:
graph TD
A[二分查找] --> B[确定查找类型]
B --> C[标准二分查找]
B --> D[变种二分查找]
B --> E[二分答案]
C --> C1[有序数组中找特定值]
C --> C2[判断是否存在]
D --> D1[找最左位置]
D --> D2[找最右位置]
D --> D3[找局部最小]
E --> E1[确定答案范围]
E --> E2[二分猜测答案]
E --> E3[检验答案是否可行]
C1 --> F[套用二分模板]
D1 --> F
D2 --> F
F --> G[初始化左右边界]
G --> H[计算中点 mid]
H --> I{判断条件}
I -->|目标值在左边| J[右边界左移]
I -->|目标值在右边| K[左边界右移]
I -->|找到目标值| L[返回结果]
J --> H
K --> H
二分查找的核心步骤说明:
-
确定查找类型
- 标准二分:在有序数组中查找值
- 变种二分:查找最左/最右位置
- 二分答案:在答案范围内二分
-
二分模板四要素
- 初始化:
left = 0, right = n-1 - 循环条件:
while(left <= right) - 中点计算:
mid = left + ((right-left) >> 1) - 边界更新:
left = mid + 1或right = mid - 1
- 初始化:
-
常见变种特点
- 找最左位置:当找到目标时继续往左找
- 找最右位置:当找到目标时继续往右找
- 二分答案:通过check函数验证答案可行性
-
注意事项
- 边界条件的处理
- 防止整数溢出
- 循环退出条件
- 无解情况的处理
具体题目逐个分析
我们从最基础的二分搜索开始讲起。
1. 在有序数组中找数
这是最基础的二分查找,目标是在一个排序好的数组中找到指定的数。
public static boolean exist(int[] sortedArr, int num) {
if (sortedArr == null || sortedArr.length == 0) {
return false;
}
int L = 0;
int R = sortedArr.length - 1;
while (L <= R) {
int mid = L + ((R - L) >> 1); // 等同于 (L + R) / 2,但更安全,避免溢出
if (sortedArr[mid] == num) {
return true;
} else if (sortedArr[mid] > num) {
R = mid - 1;
} else {
L = mid + 1;
}
}
return false;
}
核心要点解释:
L + ((R - L) >> 1)比(L + R) / 2更安全,避免了 L + R 可能的溢出- 循环条件是
L <= R,因为当 L == R 时还需要检查这个位置 - 时间复杂度:O(logN),每次排除一半的区域
2. 在有序数组中找>=num最左的位置。
问题描述
给定一个有序数组 arr,和一个数 num,找到 arr 中大于等于 num 的最左位置。
示例
输入:arr = [1,2,2,2,3,4,5,6,7], num = 2
输出:1 (因为从左往右第一个大于等于2的位置是index=1)
输入:arr = [1,2,2,2,3,4,5,6,7], num = 8
输出:-1 (因为数组中没有大于等于8的数)
解题思路
graph TD
A[开始] --> B[初始化: L=0, R=N-1, ans=-1]
B --> C[mid = L + R >> 1]
C --> D{arr[mid] >= num ?}
D -->|是| E[记录答案: ans = mid]
E --> F[继续往左找: R = mid - 1]
D -->|否| G[往右找: L = mid + 1]
F --> H{L <= R ?}
G --> H
H -->|是| C
H -->|否| I[返回ans]
| 步骤 | 操作 | 说明 | ||||
|---|---|---|---|---|---|---|
| 1 | 初始化 | L=0, R=N-1, ans=-1 | ||||
| 2 | 二分查找 | 当 L<=R 时循环 | ||||
| 3 | 计算中点 | mid = L+(R-L)/2 | ||||
| 4 | 判断大小 | arr[mid] >= num ? | ||||
| 5a | 是 | 记录ans=mid,R=mid-1 | ||||
| 5b | 否 | L=mid+1 | 6 | 结束 | 返回ans |
代码实现
public static int findLeftMostIndex(int[] arr, int num) {
if (arr == null || arr.length == 0) {
return -1;
}
int L = 0;
int R = arr.length - 1;
int ans = -1; // 记录最终答案
while (L <= R) {
int mid = L + ((R - L) >> 1);
if (arr[mid] >= num) {
// 找到一个大于等于num的数
ans = mid; // 记录当前位置
R = mid - 1; // 继续往左找,看有没有更左的
} else {
// 当前数小于num,需要往右找
L = mid + 1;
}
}
return ans;
}
关键点解析
-
初始化答案为-1
- 为什么:防止数组中没有找到>=num的数
-
找到>=num的数后不要立即返回
- 为什么:可能左边还有相等的数
- 做法:记录当前位置,继续往左找
-
向左找的操作
- 当 arr[mid] >= num 时
- 记录当前mid到ans
- 让R = mid - 1继续往左找
-
向右找的操作
- 当 arr[mid] < num 时
- L = mid + 1
复杂度分析
- 时间复杂度:O(logN)
- 空间复杂度:O(1)
易错点
- 循环条件是
L <= R,不是L < R - 更新边界时是
R = mid - 1和L = mid + 1,不是mid - 别忘了处理边界情况(空数组等)
3. 在有序数组中找<=num最右的位置
与找最左位置类似,但是方向相反。
public static int findRightMostIndex(int[] arr, int num) {
if (arr == null || arr.length == 0) {
return -1;
}
int L = 0;
int R = arr.length - 1;
int ans = -1;
while (L <= R) {
int mid = L + ((R - L) >> 1);
if (arr[mid] <= num) {
ans = mid; // 记录当前找到的位置
L = mid + 1; // 继续往右找
} else {
R = mid - 1;
}
}
return ans;
}
4. 局部最小值问题
1. 首位置(0位置)局部最小:
[1, 3, 4, 5]
↑
比右边小就是局部最小
2. 中间位置局部最小:
[4, 3, 2, 5, 7]
↑
比左右都小就是局部最小
3. 末尾位置(n-1位置)局部最小:
[5, 4, 3, 1]
↑
比左边小就是局部最小
记住这三种情况的判断条件:
- 0位置:只需要
arr[0] < arr[1] - 中间位置i:需要
arr[i] < arr[i-1] && arr[i] < arr[i+1] - 最后位置:只需要
arr[n-1] < arr[n-2]
这就是为什么在代码中要先特判首尾位置,然后才进行中间位置的二分查找。
1. 问题描述
给定一个数组arr,要找到一个局部最小值的位置。局部最小值定义如下:
- 对于0位置:如果arr[0] < arr[1],则0位置是局部最小
- 对于n-1位置:如果arr[n-1] < arr[n-2],则n-1位置是局部最小
- 对于其他位置i:如果arr[i] < arr[i-1] && arr[i] < arr[i+1],则i位置是局部最小
2. 示例
输入:arr = [9,6,3,4,5]
输出:2 (因为arr[2]=3小于左边的6和右边的4)
输入:arr = [4,3,2,1]
输出:3 (因为arr[3]=1小于arr[2]=2,是最后一个位置)
3. 解题思路
| 步骤 | 操作 | 说明 |
|---|---|---|
| 1 | 检查边界 | 检查0位置和N-1位置是否是局部最小 |
| 2 | 二分查找 | L=1, R=N-2 |
| 3 | 计算中点 | mid = L+(R-L)/2 |
| 4 | 判断局部最小 | 比较mid位置与左右相邻位置的大小 |
| 5a | 找到局部最小 | 返回mid |
| 5b | 未找到 | 根据趋势继续查找 |
4. 代码实现
public static int findLocalMinimum(int[] arr) {
if (arr == null || arr.length == 0) {
return -1;
}
int N = arr.length;
// 特殊情况处理
if (N == 1) {
return 0;
}
// 检查0位置
if (arr[0] < arr[1]) {
return 0;
}
// 检查N-1位置
if (arr[N-1] < arr[N-2]) {
return N-1;
}
// 二分查找
int L = 1;
int R = N-2;
while (L <= R) {
int mid = L + ((R-L) >> 1);
// 找到局部最小
if (arr[mid] < arr[mid-1] && arr[mid] < arr[mid+1]) {
return mid;
}
// 根据趋势继续查找
if (arr[mid] > arr[mid-1]) {
R = mid - 1;
} else {
L = mid + 1;
}
}
return -1;
}
5. 关键点解析
-
为什么可以用二分?
- 如果mid不是局部最小,那么一定至少有一侧存在局部最小
- 通过比较mid与相邻位置的大小关系可以确定搜索方向
-
边界情况处理
- 数组长度为1时,0位置就是局部最小
- 检查0位置和N-1位置是否是局部最小
-
二分查找的方向选择
- 如果arr[mid] > arr[mid-1],说明左边一定有局部最小
- 否则右边一定有局部最小
测试用例
public static void main(String[] args) {
// 测试用例1:中间位置局部最小
int[] arr1 = {9,6,3,4,5};
System.out.println(findLocalMinimum(arr1)); // 输出2
// 测试用例2:最后位置局部最小
int[] arr2 = {4,3,2,1};
System.out.println(findLocalMinimum(arr2)); // 输出3
// 测试用例3:第一个位置局部最小
int[] arr3 = {1,2,3,4,5};
System.out.println(findLocalMinimum(arr3)); // 输出0
}
复杂度分析
- 时间复杂度:O(logN)
- 空间复杂度:O(1)
易错点
- 忘记处理边界情况(数组为空、长度为1)
- 忘记检查0位置和N-1位置
- 二分方向的选择错误
补充说明
这个问题的关键在于理解:
- 为什么可以用二分
- 如何确定二分的方向
- 如何处理边界情况
5.附加问题
int mid = L + ((R - L) >> 1); // 等同于 (L + R) / 2,但更安全,避免溢出
为什么不直接写 (L + R) / 2 ?
假设有一个非常大的数组,L 和 R 都很大:
L = 2000000000 // 20亿
R = 2000000000 // 20亿
// 如果直接计算 (L + R) / 2
2000000000 + 2000000000 = 4000000000 // 这个数超过了int的最大值(约21亿)
这时会发生整数溢出,导致计算错误。
改进方式一:L + (R - L) / 2
// 方式一:避免溢出的数学变换
(L + R) / 2
= L/2 + R/2
= L + (R-L)/2
例如:L = 1, R = 5
(1 + 5) / 2 = 3
1 + (5-1)/2 = 1 + 2 = 3
改进方式二:L + ((R - L) >> 1)
// 方式二:用位运算代替除以2
x / 2 等价于 x >> 1(右移一位)
例如:数字6的二进制
6 = 110
6 >> 1 = 011 (等于3)
所以:
L + (R-L)/2 等价于 L + ((R-L) >> 1)
具体例子
L = 1, R = 5
方式一:L + (R-L)/2
1 + (5-1)/2
= 1 + 4/2
= 1 + 2
= 3
方式二:L + ((R-L) >> 1)
1 + ((5-1) >> 1)
= 1 + (4 >> 1)
= 1 + 2
= 3
为什么用位运算?
- 更安全:避免了大数相加的溢出风险
- 更高效:计算机进行位运算比除法运算更快
记忆方式
- 除以2 等价于 右移1位(>>1)
- 乘以2 等价于 左移1位(<<1)
比如:
8 >> 1 = 4 (除以2)
8 << 1 = 16 (乘以2)
6. 加餐- 二进制-十进制互相转化
让我用简单的方式来讲解二进制和十进制的转换。
1. 十进制转二进制
想象一个不断除以2的过程,记录余数,从下往上读就是二进制数。
以十进制 13 转二进制为例:
13 ÷ 2 = 6 余 1 ↓
6 ÷ 2 = 3 余 0 ↓
3 ÷ 2 = 1 余 1 ↓
1 ÷ 2 = 0 余 1 ↓
从下往上读余数:1101
所以 13 的二进制是 1101
更多例子:
十进制 8 的转换过程:
8 ÷ 2 = 4 余 0
4 ÷ 2 = 2 余 0
2 ÷ 2 = 1 余 0
1 ÷ 2 = 0 余 1
结果:1000
十进制 5 的转换过程:
5 ÷ 2 = 2 余 1
2 ÷ 2 = 1 余 0
1 ÷ 2 = 0 余 1
结果:101
2. 二进制转十进制
使用"权重法",每一位乘以对应的2的幂,然后相加。
以二进制 1101 转十进制为例:
1101 = 1×2³ + 1×2² + 0×2¹ + 1×2⁰
= 1×8 + 1×4 + 0×2 + 1×1
= 8 + 4 + 0 + 1
= 13
更多例子:
二进制 1000:
1×2³ + 0×2² + 0×2¹ + 0×2⁰
= 8 + 0 + 0 + 0
= 8
二进制 101:
1×2² + 0×2¹ + 1×2⁰
= 4 + 0 + 1
= 5
3. 快速记忆2的幂
2⁰ = 1
2¹ = 2
2² = 4
2³ = 8
2⁴ = 16
2⁵ = 32
2⁶ = 64
2⁷ = 128
2⁸ = 256
4. 实用小技巧
- 判断奇偶数:
// 看二进制的最后一位
1101 (13) 最后一位是1 -> 奇数
1000 (8) 最后一位是0 -> 偶数
- 除以2和乘以2:
左移一位(<<1) = 乘以2
右移一位(>>1) = 除以2
例如:
5 (101) << 1 = 1010 (10)
8 (1000) >> 1 = 100 (4)
5. 形象记忆法
想象二进制就像开关:
0 = 关
1 = 开
1101 可以想象成四个开关:
开-开-关-开
6. Java中的进制转换
// 十进制转二进制字符串
String binary = Integer.toBinaryString(13); // "1101"
// 二进制字符串转十进制
int decimal = Integer.parseInt("1101", 2); // 13