什么是二分查找?
二分查找(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
方法通常是更安全、更易读的选择。