左右指针法:二分查找-寻找数
二分查找多用于数组和字符串之中。
二分查找原理简单但是细节很容易出错。
0.二分查找的框架
int binarySearch(int[] nums, int target) {
int left = 0, right = ...;
while(...) {
int mid = left + (right - left) / 2;
if (nums[mid] == target) {
...
} else if (nums[mid] < target) {
left = ...
} else if (nums[mid] > target) {
right = ...
}
}
return ...;
}
分析二分查找的一个技巧是:不要出现 else,而是把所有情况用 else if 写清楚,这样可以清楚地展现所有细节。
计算 mid 时需要防止溢出,代码中left + (right - left) / 2
就和(left + right) / 2
的结果相同,但是有效防止了left
和right
太大直接相加导致溢出。
1.寻找一个数(最基本的二分查找)
搜索一个数,如果存在,返回其索引,否则返回 -1
int binarySearch(int[] nums, int target) {
int left = 0;
int 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 if (nums[mid] > target)
right = mid - 1; // 注意
}
return -1;
}
为什么 while 循环的条件中是 <=,而不是 <?
因为初始化right
的赋值是nums.length - 1
,即最后一个元素的索引,而不是nums.length
。
这二者可能出现在不同功能的二分查找中,区别是:前者相当于两端都闭区间[left, right]
,后者相当于左闭右开区间[left, right)
,因为索引大小为nums.length
是越界的。
这个算法中使用的是前者[left, right]
两端都闭的区间。这个区间其实就是每次进行搜索的区间。什么时候应该停止搜索呢?当然,找到了目标值的时候可以终止:
if(nums[mid] == target)
return mid;
但如果没找到,就需要 while 循环终止,然后返回 -1。那 while 循环什么时候应该终止?搜索区间为空的时候应该终止,意味着你没得找了,就等于没找到嘛。
while(left <= right)
的终止条件是left == right + 1
,写成区间的形式就是[right + 1, right]
,或者带个具体的数字进去[3, 2]
,可见这时候区间为空,因为没有数字既大于等于 3 又小于等于 2 的吧。所以这时候 while 循环终止是正确的,直接返回 -1 即可。
为什么left = mid + 1
,right = mid - 1
?我看有的代码是right = mid
或者left = mid
,没有这些加加减减,到底怎么回事,怎么判断?
刚才明确了「搜索区间」这个概念,而且本算法的搜索区间是两端都闭的,即[left, right]
。那么当我们发现索引mid
不是要找的target
时,下一步应该去搜索哪里呢?当然是去搜索[left, mid-1]
或者[mid+1, right]
对不对?因为mid
已经搜索过,应该从搜索区间中去除。
此算法有什么缺陷?
比如说给你有序数组nums = [1,2,2,2,3]
,target
为 2,此算法返回的索引是 2,没错。但是如果我想得到target
的左侧边界,即索引 1,或者我想得到target
的右侧边界,即索引 3,这样的话此算法是无法处理的。这样的需求很常见,也许会说,找到一个 target,然后向左或向右线性搜索不行吗?可以,但是不好,因为这样难以保证二分查找对数级的复杂度了。
2.寻找左侧边界的二分法完整代码如下:
int left_bound(int[] nums, int target) {
int left = 0, right = nums.length - 1;
// 搜索区间为 [left, right]
while (left <= right) {
int mid = left + (right - left) / 2;
if (nums[mid] < target) {
// 搜索区间变为 [mid+1, right]
left = mid + 1;
} else if (nums[mid] > target) {
// 搜索区间变为 [left, mid-1]
right = mid - 1;
} else if (nums[mid] == target) {
// 收缩右侧边界
right = mid - 1;
}
}
// 检查出界情况
if (left >= nums.length || nums[left] != target)
return -1;
return left;
}
这里左侧边界的含义:
比如对于有序数组nums = [2,3,5,7]
,target = 1
,算法会返回 left=0,含义是:nums
中小于 1 的元素有 0 个。
再比如说nums = [2,3,5,7], target = 8,算法会返回 left=4,含义是:
nums`中小于 8 的元素有 4 个。
left即表示那个index值,也表示有多少比target小,这里可能会带来索引越界的问题。
为什么该算法能够搜索左侧边界?
关键在于对于nums[mid] == target
这种情况的处理:
if (nums[mid] == target)
right = mid;
可见,找到 target 时不要立即返回,而是缩小「搜索区间」的上界right
,在区间[left, mid)
中继续搜索,即不断向左收缩,达到锁定左侧边界的目的。
为什么要检查出界情况:
由于 while 的退出条件是left == right + 1
,所以当target
比nums
中所有元素都大时,会存在以下情况使得索引越界
越界条件之中有nums[left] != target,是表示找不到这个数返回-1。
3.寻找右侧边界的二分法
int right_bound(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 if (nums[mid] > target) {
right = mid - 1;
} else if (nums[mid] == target) {
// 这里改成收缩左侧边界即可
left = mid + 1;
}
}
// 这里改为检查 right 越界的情况,见下图
if (right < 0 || nums[right] != target)
return -1;
return right;
}
为什么这个算法能够找到右侧边界?
if (nums[mid] == target) {
left = mid + 1;
当nums[mid] == target
时,不要立即返回,而是增大「搜索区间」的下界left
,使得区间不断向右收缩,达到锁定右侧边界的目的。
当target
比所有元素都小时,right
会被减到 -1,所以需要在最后防止越界