什么是二分查找?
二分查找(Binary Search)是一种在 有序数组 中查找某一特定元素的搜索算法。其工作原理是:
- 从数组的中间元素开始,如果中间元素正好是目标值,则搜索过程结束。
- 如果目标值大于或小于中间元素,则在数组大于或小于中间元素的那一半中查找,而且同样从中间元素开始比较。
- 如果在某一步骤数组为空,则代表找不到。
- 重复以上过程,直到找到目标值或者确定目标值不存在于数组中。
这种搜索算法每一次比较都使搜索范围缩小一半,因此其时间复杂度为 O(log n)。
不同实现版本的分析
以下是您提供的几种 Java 二分查找实现的详细分析:
核心变量:
a: 输入的有序数组。target: 要查找的目标值。i: 搜索区间的左边界(起始索引)。j: 搜索区间的右边界(结束索引)。m: 搜索区间的中间索引。candidate: 用于记录潜在匹配目标值的索引(主要用于查找边界元素)。
注意: >>> 1 是无符号右移一位,等价于 / 2,但可以避免整数溢出问题 (i + j) 可能超过 Integer.MAX_VALUE。
1. binarySearch (基础版本 - while (i <= j))
public static int binarySearch(int[] a, int target) {
int i = 0, j = a.length - 1; // [i, j]
while (i <= j) {
int m = (i + j) >>> 1;
if (target < a[m]) { // 目标在左侧
j = m - 1; // 新区间 [i, m-1]
} else if (a[m] < target) { // 目标在右侧
i = m + 1; // 新区间 [m+1, j]
} else { // 找到了
return m;
}
}
return -1; // 未找到
}
- 区间定义: 闭区间
[i, j]。 - 循环条件:
i <= j,表示当i == j时,区间[i, i]仍然有效,包含一个元素,需要检查。 - 边界更新:
target < a[m]: 目标在左侧,m以及m右边的元素肯定不是,所以j变为m - 1。a[m] < target: 目标在右侧,m以及m左边的元素肯定不是,所以i变为m + 1。a[m] == target: 直接返回m。
- 未找到: 当循环结束时(
i > j),表示区间为空,目标不存在,返回-1。
示例: a = [2, 5, 7, 8, 11, 12], target = 13
| i | j | m | a[m] | 条件 | 新 i | 新 j | 区间 |
|---|---|---|---|---|---|---|---|
| 0 | 5 | 2 | 7 | 13 > 7 |
3 | 5 | [3, 5] |
| 3 | 5 | 4 | 11 | 13 > 11 |
5 | 5 | [5, 5] |
| 5 | 5 | 5 | 12 | 13 > 12 |
6 | 5 | [6, 5] |
| 6 | 5 | - | - | i > j 退出 |
- | - | - |
| 返回 | -1 |
示例: a = [2, 5, 7, 8, 11, 12], target = 8
| i | j | m | a[m] | 条件 | 新 i | 新 j | 区间 |
|---|---|---|---|---|---|---|---|
| 0 | 5 | 2 | 7 | 8 > 7 |
3 | 5 | [3, 5] |
| 3 | 5 | 4 | 11 | 8 < 11 |
3 | 3 | [3, 3] |
| 3 | 3 | 3 | 8 | 8 == 8 |
- | - | - |
| 返回 | 3 |
2. binarySearch (变种 - while (i < j))
public static int binarySearch(int[] a, int target) {
int i = 0, j = a.length; // [i, j)
while (i < j) {
int m = (i + j) >>> 1;
if (target < a[m]) { // 目标在左侧
j = m; // 新区间 [i, m)
} else if (a[m] < target) { // 目标在右侧
i = m + 1; // 新区间 [m+1, j)
} else { // 找到了
return m;
}
}
return -1; // 未找到 (循环结束时 i == j)
}
- 区间定义: 左闭右开区间
[i, j)。j初始化为a.length,表示不包含索引j。 - 循环条件:
i < j,表示当i == j时,区间[i, i)为空,循环结束。 - 边界更新:
target < a[m]: 目标在左侧(可能等于a[m]左边的元素),m可能是目标,但m右边的肯定不是。新区间是[i, m),所以j = m。a[m] < target: 目标在右侧,m以及m左边的元素肯定不是,所以i变为m + 1。新区间[m+1, j)。a[m] == target: 直接返回m。
- 未找到: 当循环结束时(
i == j),表示区间[i, i)为空,目标不存在,返回-1。
示例: a = [2, 5, 7, 8, 11, 12], target = 13
| i | j | m | a[m] | 条件 | 新 i | 新 j | 区间 |
|---|---|---|---|---|---|---|---|
| 0 | 6 | 3 | 8 | 13 > 8 |
4 | 6 | [4, 6) |
| 4 | 6 | 5 | 12 | 13 > 12 |
6 | 6 | [6, 6) |
| 6 | 6 | - | - | i == j 退出 |
- | - | - |
| 返回 | -1 |
示例: a = [2, 5, 7, 8, 11, 12], target = 8
| i | j | m | a[m] | 条件 | 新 i | 新 j | 区间 |
|---|---|---|---|---|---|---|---|
| 0 | 6 | 3 | 8 | 8 == 8 |
- | - | - |
| 返回 | 3 |
3. binarySearchBalance (平衡变种 - while (1 < j - i))
public static int binarySearchBalance(int[] a, int target) {
int i = 0, j = a.length; // (i, j) - 搜索范围在开区间? 实际更像是 [i, j)
while (1 < j - i) { // 循环直到区间大小 <= 1
int m = (i + j) >>> 1;
if (target < a[m]) {
j = m; // 新区间 [i, m)
} else {
i = m; // 新区间 [m, j)
}
}
// 循环结束时 j - i <= 1,区间为 [i, i+1) 或 [i, i)
// 此时 potential target 在索引 i
return (i < a.length && a[i] == target) ? i : -1;
}
- 区间定义: 看起来是左闭右开
[i, j)。j初始化为a.length。 - 循环条件:
1 < j - i,意味着循环在区间大小 (j - i) 大于 1 时继续。当j - i <= 1(即区间大小为 0 或 1) 时退出。 - 边界更新:
target < a[m]: 目标在m左侧,更新右边界j = m。target >= a[m]: 目标在m或m右侧,更新左边界i = m。注意这里包含了等于的情况。
- 后处理: 循环结束后,区间大小为 0 或 1。如果目标存在,它必然在索引
i处(因为else分支将i更新为m)。需要检查a[i]是否确实等于target。同时要检查i是否越界(例如在空数组或查找比所有元素都小的目标时)。 - 未找到: 如果最后
a[i] != target或i越界,返回-1。
示例: a = [2, 5, 7, 8, 11, 12], target = 13
| i | j | j-i | m | a[m] | 条件 | 新 i | 新 j | 区间 |
|---|---|---|---|---|---|---|---|---|
| 0 | 6 | 6 | 3 | 8 | 13 >= 8 |
3 | 6 | [3, 6) |
| 3 | 6 | 3 | 4 | 11 | 13 >= 11 |
4 | 6 | [4, 6) |
| 4 | 6 | 2 | 5 | 12 | 13 >= 12 |
5 | 6 | [5, 6) |
| 5 | 6 | 1 | - | - | j-i <= 1 退出 |
- | - | - |
检查: i=5, a[5]=12. a[5] != 13. 返回 -1 |
示例: a = [2, 5, 7, 8, 11, 12], target = 8
| i | j | j-i | m | a[m] | 条件 | 新 i | 新 j | 区间 |
|---|---|---|---|---|---|---|---|---|
| 0 | 6 | 6 | 3 | 8 | 8 >= 8 |
3 | 6 | [3, 6) |
| 3 | 6 | 3 | 4 | 11 | 8 < 11 |
3 | 4 | [3, 4) |
| 3 | 4 | 1 | - | - | j-i <= 1 退出 |
- | - | - |
检查: i=3, a[3]=8. a[3] == 8. 返回 3 |
4. binarySearchLeftmost1 (查找最左目标 - candidate 记录)
public static int binarySearchLeftmost1(int[] a, int target) {
int i = 0, j = a.length - 1; // [i, j]
int candidate = -1;
while (i <= j) {
int m = (i + j) >>> 1;
if (target < a[m]) {
j = m - 1; // [i, m-1]
} else if (a[m] < target) {
i = m + 1; // [m+1, j]
} else { // a[m] == target
candidate = m; // 记录候选位置
j = m - 1; // 继续向左查找看是否有更左的 [i, m-1]
}
}
return candidate;
}
- 目标: 找到数组中等于
target的最左边的元素的索引。如果不存在,返回-1。 - 区间定义: 闭区间
[i, j]。 - 核心逻辑: 使用
candidate变量记录最后一次找到target的位置。当a[m] == target时,不立即返回,而是记录下m作为潜在的最左索引,然后继续在左半部分[i, m-1]搜索,看是否存在更靠左的target。 - 边界更新:
- 当
a[m] == target时,将candidate更新为m,并将j更新为m - 1,试图在左侧找到更小的索引。
- 当
- 未找到: 如果从未找到
target,candidate保持初始值-1。
示例: a = [1, 2, 3, 3, 3, 4, 5], target = 3
| i | j | m | a[m] | 条件 | 新 i | 新 j | 区间 | candidate |
|---|---|---|---|---|---|---|---|---|
| 0 | 6 | 3 | 3 | 3 == 3 |
0 | 2 | [0, 2] |
3 |
| 0 | 2 | 1 | 2 | 3 > 2 |
2 | 2 | [2, 2] |
3 |
| 2 | 2 | 2 | 3 | 3 == 3 |
2 | 1 | [2, 1] |
2 |
| 2 | 1 | - | - | i > j 退出 |
- | - | - | 2 |
| 返回 | 2 |
5. binarySearchRightmost1 (查找最右目标 - candidate 记录)
public static int binarySearchRightmost1(int[] a, int target) {
int i = 0, j = a.length - 1; // [i, j]
int candidate = -1;
while (i <= j) {
int m = (i + j) >>> 1;
if (target < a[m]) {
j = m - 1; // [i, m-1]
} else if (a[m] < target) {
i = m + 1; // [m+1, j]
} else { // a[m] == target
candidate = m; // 记录候选位置
i = m + 1; // 继续向右查找看是否有更右的 [m+1, j]
}
}
return candidate;
}
- 目标: 找到数组中等于
target的最右边的元素的索引。如果不存在,返回-1。 - 区间定义: 闭区间
[i, j]。 - 核心逻辑: 与
Leftmost1类似,使用candidate记录。当a[m] == target时,记录下m,然后继续在右半部分[m+1, j]搜索,看是否存在更靠右的target。 - 边界更新:
- 当
a[m] == target时,将candidate更新为m,并将i更新为m + 1,试图在右侧找到更大的索引。
- 当
- 未找到: 如果从未找到
target,candidate保持初始值-1。
示例: a = [1, 2, 3, 3, 3, 4, 5], target = 3
| i | j | m | a[m] | 条件 | 新 i | 新 j | 区间 | candidate |
|---|---|---|---|---|---|---|---|---|
| 0 | 6 | 3 | 3 | 3 == 3 |
4 | 6 | [4, 6] |
3 |
| 4 | 6 | 5 | 4 | 3 < 4 |
4 | 4 | [4, 4] |
3 |
| 4 | 4 | 4 | 3 | 3 == 3 |
5 | 4 | [5, 4] |
4 |
| 5 | 4 | - | - | i > j 退出 |
- | - | - | 4 |
| 返回 | 4 |
6. binarySearchRightmost (查找最右目标的变种 - 返回 i-1)
public static int binarySearchRightmost(int[] a, int target) {
int i = 0, j = a.length - 1; // [i, j]
while (i <= j) {
int m = (i + j) >>> 1;
if (target < a[m]) {
j = m - 1; // [i, m-1]
} else { // target >= a[m]
i = m + 1; // [m+1, j]
}
}
// 循环结束时, i 是第一个 > target 的元素的索引 (或 a.length)
// 因此 i-1 是最后一个 <= target 的元素的索引
// 如果 a[i-1] == target, 则 i-1 就是最右侧的 target
// 需要检查 i-1 是否有效且 a[i-1] == target (虽然代码没显式检查,但这是其含义)
return i - 1;
}
- 目标: 找到小于或等于
target的最右元素的索引。如果该元素恰好等于target,则它就是最右边的target。 - 区间定义: 闭区间
[i, j]。 - 核心逻辑: 这个版本的关键在于
else分支的处理:当target >= a[m]时,将i更新为m + 1。这意味着我们总是在寻找第一个大于target的元素的位置。 - 循环结束: 当循环结束时 (
i > j),i指向的是第一个大于target的元素的位置(或者是a.length如果target比所有元素都大或相等)。因此,i-1就是最后一个小于或等于target的元素的位置。 - 返回值: 返回
i - 1。- 如果数组中存在
target,并且a[i-1] == target,那么i-1就是最右边的target的索引。 - 如果
target比所有元素都小,i最终为0,返回-1。 - 如果
target比所有元素都大,i最终为a.length,返回a.length - 1(数组最后一个元素的索引)。 - 如果
target介于数组元素之间但不存在,i-1指向小于target的最大元素的索引。
- 如果数组中存在
- 注意: 这个实现直接返回
i-1,它不保证返回的索引处的元素一定等于target。它返回的是 小于或等于target的最右元素的索引。如果要确保找到的是target,调用者需要额外检查a[i-1] == target并处理i=0(即i-1 = -1) 的情况。
示例: a = [1, 2, 3, 3, 3, 4, 5], target = 3
| i | j | m | a[m] | 条件 | 新 i | 新 j | 区间 |
|---|---|---|---|---|---|---|---|
| 0 | 6 | 3 | 3 | 3 >= 3 |
4 | 6 | [4, 6] |
| 4 | 6 | 5 | 4 | 3 < 4 |
4 | 4 | [4, 4] |
| 4 | 4 | 4 | 3 | 3 >= 3 |
5 | 4 | [5, 4] |
| 5 | 4 | - | - | i > j 退出 |
- | - | - |
返回: i - 1 = 5 - 1 = 4 (索引 4 的值是 3,是最右边的 3) |
示例: a = [1, 2, 4, 5], target = 3
| i | j | m | a[m] | 条件 | 新 i | 新 j | 区间 |
|---|---|---|---|---|---|---|---|
| 0 | 3 | 1 | 2 | 3 >= 2 |
2 | 3 | [2, 3] |
| 2 | 3 | 2 | 4 | 3 < 4 |
2 | 1 | [2, 1] |
| 2 | 1 | - | - | i > j 退出 |
- | - | - |
返回: i - 1 = 2 - 1 = 1 (索引 1 的值是 2,是 <= 3 的最右元素) |
7. binarySearchLeftmost (查找最左目标的变种 - 返回 i)
public static int binarySearchLeftmost(int[] a, int target) {
int i = 0, j = a.length - 1; // [i, j]
while (i <= j) {
int m = (i + j) >>> 1;
if (target <= a[m]) { // target <= a[m]
j = m - 1; // [i, m-1]
} else { // target > a[m]
i = m + 1; // [m+1, j]
}
}
// 循环结束时, i 是第一个 >= target 的元素的索引 (或 a.length)
// 如果 a[i] == target, 则 i 就是最左侧的 target
// 需要检查 i 是否越界且 a[i] == target (虽然代码没显式检查,但这是其含义)
return i;
}
- 目标: 找到大于或等于
target的最左元素的索引。如果该元素恰好等于target,则它就是最左边的target。 - 区间定义: 闭区间
[i, j]。 - 核心逻辑: 这个版本的关键在于
if分支的处理:当target <= a[m]时,将j更新为m - 1。这意味着我们总是在寻找第一个大于等于target的元素的位置。 - 循环结束: 当循环结束时 (
i > j),j指向的是最后一个小于target的元素的位置(或者是-1如果target比所有元素都小或相等)。i指向的是第一个大于或等于target的元素的位置(或者是a.length如果target比所有元素都大)。 - 返回值: 返回
i。- 如果数组中存在
target,并且a[i] == target,那么i就是最左边的target的索引。 - 如果
target比所有元素都小,i最终为0,返回0。 - 如果
target比所有元素都大,i最终为a.length,返回a.length。 - 如果
target介于数组元素之间但不存在,i指向第一个大于target的元素的索引。
- 如果数组中存在
- 注意: 这个实现直接返回
i,它不保证返回的索引处的元素一定等于target。它返回的是 大于或等于target的最左元素的索引。如果要确保找到的是target,调用者需要额外检查i是否在数组界限内 (i < a.length) 并且a[i] == target。
示例: a = [1, 2, 3, 3, 3, 4, 5], target = 3
| i | j | m | a[m] | 条件 | 新 i | 新 j | 区间 |
|---|---|---|---|---|---|---|---|
| 0 | 6 | 3 | 3 | 3 <= 3 |
0 | 2 | [0, 2] |
| 0 | 2 | 1 | 2 | 3 > 2 |
2 | 2 | [2, 2] |
| 2 | 2 | 2 | 3 | 3 <= 3 |
2 | 1 | [2, 1] |
| 2 | 1 | - | - | i > j 退出 |
- | - | - |
返回: i = 2 (索引 2 的值是 3,是最左边的 3) |
示例: a = [1, 2, 4, 5], target = 3
| i | j | m | a[m] | 条件 | 新 i | 新 j | 区间 |
|---|---|---|---|---|---|---|---|
| 0 | 3 | 1 | 2 | 3 > 2 |
2 | 3 | [2, 3] |
| 2 | 3 | 2 | 4 | 3 <= 4 |
2 | 1 | [2, 1] |
| 2 | 1 | - | - | i > j 退出 |
- | - | - |
返回: i = 2 (索引 2 的值是 4,是 >= 3 的最左元素) |
边界问题说明
二分查找的边界问题主要体现在:
- 区间定义:
[i, j](闭区间): 循环条件通常是i <= j。当i == j时,区间还有一个元素,需要检查。更新时,如果不匹配m,则新区间排除m(j = m - 1或i = m + 1)。[i, j)(左闭右开): 循环条件通常是i < j。当i == j时,区间为空,循环结束。j初始化为a.length。更新时,如果target < a[m],则m可能是目标,新右边界为m(j = m);如果target > a[m],则m不可能是目标,新左边界为m + 1(i = m + 1)。
- 循环条件:
i <= j: 允许i和j相等,处理单元素区间。i < j: 当i和j相等时退出,适用于[i, j)区间。1 < j - i: 退出时区间大小为 0 或 1,需要后处理。
- 中间值计算:
m = (i + j) >>> 1是防止i + j溢出的标准做法。如果写成m = i + (j - i) / 2也可以。 - 目标未找到:
- 返回
-1: 明确表示未找到。适用于基础查找和使用candidate的版本。 - 返回
i或i-1: 这些版本返回的是一个边界索引(第一个>= target的或最后一个<= target的),即使target不存在,也会返回一个有意义的索引(表示插入点或邻近值)。调用者需要根据i的值和a[i]或a[i-1]的值来判断target是否真的存在。
- 返回
- 数组为空: 需要在函数入口处处理,否则
a.length - 1会出错 (对于j = a.length - 1的初始化)。j = a.length的初始化方式对空数组更健壮。 - 查找边界 (Leftmost/Rightmost):
candidate方法: 逻辑相对清晰,找到一个匹配项后,记录下来并继续向一个方向搜索。- 返回
i/i-1方法: 代码更简洁,但需要理解循环不变式以及i在循环结束时的确切含义。需要调用者进行后处理(检查索引有效性及值是否匹配)。
各自的优劣情况
| 实现 | 优点 | 缺点 | 适用场景 |
|---|---|---|---|
binarySearch (i <= j) |
直观,易于理解闭区间概念。 | 需要正确处理 j = m - 1 和 i = m + 1。 |
基础的查找是否存在 target。 |
binarySearch (i < j) |
j 初始化为 length,处理空数组更自然。与 STL lower_bound 风格类似。 |
区间 [i, j) 可能不如闭区间直观。需要注意 j=m 和 i=m+1 的更新逻辑。 |
基础查找,或作为实现 lower_bound 的基础。 |
binarySearchBalance |
循环中只比较一次,可能略微减少比较次数?(现代 CPU 分支预测下不一定) | 循环条件 1 < j - i 不太常见。需要额外的后处理步骤来检查 a[i]。 |
对循环不变式和后处理有深入理解时使用。 |
binarySearchLeftmost1 |
逻辑清晰,明确使用 candidate 记录最左目标。 |
需要额外变量 candidate。 |
需要精确找到 target 的最左索引,不存在返回 -1。 |
binarySearchRightmost1 |
逻辑清晰,明确使用 candidate 记录最右目标。 |
需要额外变量 candidate。 |
需要精确找到 target 的最右索引,不存在返回 -1。 |
binarySearchRightmost |
代码简洁,无需额外变量。利用循环结束时 i 的特性。 |
返回 i-1,不直接保证 a[i-1] == target。需要调用者理解并后处理。对边界情况 (i=0) 需要小心。 |
需要找到 <= target 的最大元素的索引。 |
binarySearchLeftmost |
代码简洁,无需额外变量。利用循环结束时 i 的特性。 |
返回 i,不直接保证 a[i] == target。需要调用者理解并后处理。对边界情况 (i=a.length) 需要小心。 |
需要找到 >= target 的最小元素的索引 (类似 lower_bound)。 |
总结:
- 对于基础查找(判断是否存在),
binarySearch (i <= j)或binarySearch (i < j)都可以,前者可能更符合初学者的直觉。 - 对于查找最左/最右边界,使用
candidate的Leftmost1/Rightmost1方法逻辑最清晰明确,不易出错。 Leftmost(返回i) 和Rightmost(返回i-1) 的变种更简洁,性能可能微乎其微地好一点(少了变量赋值),但需要调用者更深入地理解其返回值含义并进行必要的后处理检查,它们分别对应于查找 第一个大于等于 target 的位置和 最后一个小于等于 target 的位置,常用于实现类似 C++ STL 中的lower_bound和upper_bound(或其变种)的功能。binarySearchBalance相对不常用,其优势不明显。
选择哪种实现取决于具体需求(是精确查找、找边界还是找插入点)以及个人或团队的编码风格偏好。对于需要精确找到最左/最右 target 并处理不存在情况的场景,candidate 方法通常是更安全、更易读的选择。