本篇仅分析二分查找的细节问题,在阅读前请确保已经对“二分查找”概念与基本应用有初步了解。
二分查找的三个常用搜索区间
| 搜索区间 | 终止条件 | 左右指针初始赋值 | 左右指针赋值 | 循环条件 |
|---|---|---|---|---|
| 左闭右闭[l,r] | 相错终止 | l = 0 r = nums.length - 1 | l = mid + 1 r = mid - 1 | l <= r |
| 左闭右开[l,r) | 相交终止 | l = 0 r = nums.length | l = mid + 1 r = mid | l < r |
| 左开右开(l,r) | 相邻终止 | l = -1 r = nums.length | l = mid r = mid | l + 1 < r |
搜索区间是指在实践中需要考虑的范围。
以上文为例,左闭右开区间时,右边界指针所指向的元素不在考虑范围之内(即已经事实上排除)。
非一般实践中还有左开右闭等搜索区间,本文不再赘述。
以 LeetCode704.二分查找 为例:
左闭右闭
public int search(int[] nums, int target) {
int l = 0;
int r = nums.length - 1;
while (l <= r) {
int mid = l + (r - l) / 2;
if (nums[mid] < target) {
l = mid + 1;
} else if (nums[mid] > target) {
r = mid - 1;
} else {
return mid;
}
}
return -1;
}
左闭右开
public int search(int[] nums, int target) {
int l = 0;
int r = nums.length;
while (l < r) {
int mid = l + (r - l) / 2;
if (nums[mid] > target) {
r = mid;
} else if (nums[mid] < target) {
l = mid + 1;
} else {
return mid;
}
}
return -1;
}
左开右开
public int search(int[] nums, int target) {
int l = -1;
int r = nums.length;
while (l + 1 < r) {
int mid = l + (r - l) / 2;
if (nums[mid] > target) {
r = mid;
} else if (nums[mid] < target) {
l = mid;
} else {
return mid;
}
}
return -1;
}
二分查找变体
查找左闭合边界
左闭合边界是指在有序数组中,被搜索数的第一次出现下标。
例如,在数组[5,7,7,8,8,10]中,7 的左边界是 1; 8 的左边界是 3。
而根据搜索区间,我们可以得到上述三种写法的查找左边界。
下面以左闭右闭为例:
private int getLeft(int[] nums, int target) {
int l = 0;
int r = nums.length - 1;
// int leftBoard = -2;
while (l <= r) {
int mid = l + (r - l) / 2;
if (nums[mid] < target) {
l = mid + 1;
} else {
r = mid - 1;
// leftBoard = r;
}
}
// 检查出界情况
if (l >= nums.length || nums[l] != target) {
return -1;
}
return l;
// return leftBoard;
}
在实践中,一般将nums[mid] >= target一起讨论,但实际上两者原理不一。
这其中核心问题是,将 mid 排除出搜索区间后,根据 l 返回答案的正确性能否获得保证。答案是肯定的。
对于左右闭合边界,需要重点讨论:为什么当 nums[mid] >= target 时,r = mid - 1,其中主要问题是为什么要将等于的情况与大于一起讨论,如何证明其正确性。
从搜索区间来看,每次搜索时,搜索闭区间[l, r]。nums[mid] >= target 时,mid 可能是左边界,也可能不是左边界,此时要迁移右指针,把 mid 排除出搜索区间。
根据相错终止的思路,l 会停留在 r 的右侧,即 l = r + 1,当 mid 是搜索区间的左边界,而暂时地将 mid 排除出搜索区间后,最终 r 会在 mid - 1 不再移动,最终在终止时 l 会落在当时被排除的 mid 位置,可断言:l 是左边界。正确性可以保证。
相应的,可以推理如果写判断条件 nums[mid] == target 时,l = mid + 1,此时 l 必定会越过正确结果 。所以我们要根据右指针返回结果:
private int getLeft(int[] nums, int target) {
int l = 0;
int r = nums.length - 1;
while (l <= r) {
int mid = l + (r - l) / 2;
if (nums[mid] < target) {
l = mid + 1;
} else {
r = mid - 1;
}
}
// 检查出界情况
if (r + 1 == nums.length || nums[r + 1] != target) {
return -1;
}
return r + 1;
}
但此种方法过于繁琐,所以使用 l 返回即可。
左闭右开写法:
private int getLeft(int[] nums, int target) {
int l = 0;
int r = nums.length;
while (l < r) {
int mid = l + (r - l) / 2;
if (nums[mid] < target) {
l = mid + 1;
} else {
r = mid;
}
}
// 检查出界情况
if (l >= nums.length || nums[l] != target) {
return -1;
}
return l;
}
查找右闭合边界
相似地,我们也可以反向推得到右边界写法:
左闭右闭写法:
private int getRight(int[] nums, int target) {
int l = 0;
int r = nums.length - 1;
while (l <= r) {
int mid = l + (r - l) / 2;
if (nums[mid] <= target) {
l = mid + 1;
} else {
r = mid - 1;
}
}
if (r == -1 || nums[r] != target) {
return -1;
}
return r;
}
左开右闭写法:
private int getRight(int[] nums, int target) {
int l = 0;
int r = nums.length;
while (l < r) {
int mid = l + (r - l) / 2;
if (nums[mid] <= target) {
l = mid + 1;
} else {
r = mid;
}
}
// 注意:因为这里是左闭右"开" 所以r指针指向的并不是实际的位置,r-1才是。
if (r == 0 || nums[r - 1] != target) {
return -1;
}
return r - 1;
}
查找插入位置
以 LeetCode.35搜索插入位置 为例:
搜索插入位置实际上和查找左闭合边界非常类似,如果能搜索到与结果相等的下标,自然搜索到的位置就是插入位置。
如果数组中不存在与 target 相同的元素,那么该问题实际上变成了查找左开放边界。
在实践中,实际上是将nums[mid] > target与nums[mid] < target分类讨论。
左闭右闭写法:
public int searchInsert(int[] nums, int target) {
int l = 0;
int r = nums.length - 1;
while (l <= r) {
int mid = l + (r - l) / 2;
if (nums[mid] < target) {
l = mid + 1;
} else if (nums[mid] > target) {
r = mid - 1;
} else {
return mid;
}
}
return l;
}
左闭右开写法:
public int searchInsert(int[] nums, int target) {
int l = 0;
int r = nums.length;
while (l < r) {
int mid = l + (r - l) / 2;
if (nums[mid] < target) {
l = mid + 1;
} else if (nums[mid] > target) {
r = mid;
} else {
return mid;
}
}
return l;
}
其他问题
中指针整型溢出问题
中指针溢出问题指的是在静态类型语言下,mid = (l + r) / 2可能会造成整型溢出,本质上是l + r大小超过整数范围。
在JDK下,该bug存在了 9 年之久。
一般有两种解决方案:
- 改写为先减再加的方式:
mid = l + (r - l) / 2。可以避免l + r溢出。 - 右移替代除法,效率会高一点点:
mid = l + ((r - l) >> 1)
特别的,在 JDK 中如此计算 mid:mid = (l + r) >>> 1。
>>>是无符号右移运算符,与>>的区别是不考虑符号位,总是往左侧补0。l + r溢出的时候最高符号位从0变成了1,而用>>>又变成了0,所以可以解决溢出问题。
但是用这种写法要保证l + r >= 0,如果l + r为负数,高位补 0 就会得到错误的正数。
在一般情况下,l 与 r 代表下标,相加恒为正数,但部分题目的搜索的不是下标,而是数值,相加可能为 0,需要特别注意,例如:LeetCode.462最小操作次数使数组元素相等II。
二分查找的二段适用性
二分查找不一定只能用在已经排序的数组(即:单调性)环境下,可以用于任何具有二段性的环境中。
例如:LeetCode.162寻找峰值。
在该题中,只需要返回一个峰值,而可以思考如下:
代码如下:
public int findPeakElement(int[] nums) {
if (nums.length == 1) return 0;
if (nums[0] > nums[1]) return 0;
if (nums[nums.length - 1] > nums[nums.length - 2]) return nums.length - 1;
// [0,1] 上升,[len - 2,len - 1] 下降 二分搜索
int l = 1;
int r = nums.length - 2;
while (l <= r) {
int mid = (l + r) >>> 1;
// 符合条件的峰值
if (nums[mid] > nums[mid - 1] && nums[mid] > nums[mid + 1]) return mid;
// [mid - 1, mid] 上升,搜索右边
if (nums[mid] > nums[mid - 1]) l = mid + 1;
// [mid - 1, mid] 下降,搜索左边
else r = mid - 1;
}
return 0;
}