发明KMP算法的Knuth大佬说二分查找:思路很简单,细节是魔鬼 二分查找真正的坑在于mid到底是加一还是减一,以及while里面到底是 < 还是 <=.
下面根据常见的几个二分查找的场景尝试总结一下二分查找的编程框架,分为寻找一个数,寻找左侧边界,寻找右侧边界。在此过程中,对于mid以及while循环的细节问题进行编码检测,分析细节差异。
二分查找框架
int binarySearch(vector<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相加导致溢出的情况。
- … 标记的部分就是细节出现的地方,在下面的例子中会进行详细分析。
一、寻找一个数
这个场景最简单,在一个有序数组(从小到大)中搜索一个数字,存在返回索引,否则返回-1。
int binartSearch(vector<int> nums, int target)
{
int left = 0, right = nums.size() -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 right = mid-1;// 注意
}
return -1;// 注意
}
- while循环中使用 <= 的原因
while循环中如果没找到target一直会持续搜索,直到left > right才会结束,比如[3,2],此时搜索空间为空,返回-1。 而如果写成 <的情况,在left == right就会结束搜索,此时[2,2]区间内还有一个元素没有搜索到。
while(left < right)
return nums[left]==target? left:-1;
- left = mid +1, right = mid-1
从搜索区间来讲,此时mid元素已经搜索过,那么还剩下两段区间,分别为[left, mid-1]以及[mid+1, right]。left以及right表示的就是搜索边界。 - 缺陷
这个基础算法本身存在一定的局限性,当有序数组nums = [1,2,2,2,3],target为2,那么函数返回的index为2。 但是有时会遇到求出target的左侧边界,为1或者求出target的右侧边界,为3时,这个算法是无法处理的。
后续讨论这两种二分查找的算法。
二、寻找左侧边界的二分搜索
常见代码如下:
int left_bound(vector<int> nums, int target)
{
int len = nums.size();
if(len==0) return -1;
int left = 0, right = len;// 注意
while(left < right)
{
int mid = left + (right-left)/2;
if(nums[mid] == target) right = mid;
else if(nums[mid] < target) left = mid+1;
else if(nums[mid] > target) right = mid;
}
return left;
}
- 函数返回值
函数返回的值代表数组中小于target的元素数目。
在常用框架上,进行修改:
while(left < right)
{
...
}
// Left表示小于target的元素个数
if(left == nums.size()) return -1;
return nums[left] == target?left:-1;
- while循环中使用 < 而不是 <=
寻找边界的一般框架是将搜索区间写成左闭右开的形式,也就是[left, right)。当while退出循环的时候,left=right,此时区间为[left,right),搜索空间为空,可以正确终止。
后续会将所有方法写成统一的形式,两端都闭的写法。
- 搜索左侧边界的原理
关键在于 nums[left] == target的处理: right = mid
找到target元素不会返回,而是缩小搜索边界的右边界right,变为[left, mid),不断向左搜缩,从而找到左边界。 - 将搜索区间改为[left, right],继续使用两边都是闭的搜索区间?
首先如果使用闭区间,那么right = nums.size()-1,此时while应该是 <=。
其次,因为搜索区间都是封闭的,那么更新left,right也应该对应修改。
最后,由于while的退出条件是left = right +1, 那么可能存在target大于所有元素情况,打个补丁:
int left_bound(vector<int> nums, int target)
{
int left = 0, right = nums.size()-1;
while(left <= right)
{
int mid = left + (right-left)/2;
// 找左侧边界 返回小于target的数目
// 收缩右侧边界
if(nums[mid]==target) right = mid-1;
// [mid+1, right]
else if(nums[mid] < target) left = mid + 1;
// [left, mid-1]
else right = mid-1;
}
if(left>=nums.size() || nums[left]!=target) return -1;
return left;
}
三、搜索右侧边界
与左侧边界类似,
int rightbound(vector<int> nums, int target)
{
int len = nums.size();
if(len==0) return -1;
int left = 0, right = len;
while(left < right)
{
int mid = left + (right-left)/2;
if(nums[mid] == target) left = mid+1;
else if(nums[mid] < target) left = mid + 1;
else right = mid;
}
// return left - 1;// 注意
if(left ==0) return -1;
return nums[left-1] == target ? (left-1):-1;
}
- 退出循环时left表示大于target的第一个位置 ,因此最后需要判断left-1.
- 改为两端都闭的形式
int rightbound(vector<int> nums, int target)
{
int left = 0, right = nums.size() -1;
while(left <= right)
{
int mid = left + (right-left)/2;
if(nums[mid]==target) left= mid + 1;
else if(nums[mid] < target) left = mid + 1;
else right = mid-1;
}
// 注意这里改为检查right
if(right <0 || nums[right] !=target)
return -1;
return right;
}
三种算法统一
基本的二分查找算法:
right = nums.size() -1
决定了搜索区间是 [left, right]
决定了 while(left <= right)
也决定了 left = mid+1 与 right = mid - 1
因为只需要找到一个target的索引即可
当nums[mid] == target 立即返回
左侧边界的二分查找:
right = nums.size()
决定了搜索区间是[left, right)
决定了while(left < right)
同时也决定了left = mid+1, right = mid
因为需要找到target的最左侧索引
当nums[mid] == target时不要立即返回
收紧右侧边界以锁定左侧边界 right = mid
右侧边界的二分查找:
right = nums.size()
决定了搜索区间是[left, right)
决定了while(left < right)
同时也决定了left = mid+1, right = mid
因为需要找到target的最右侧索引
当nums[mid] == target时不要立即返回
收紧左侧边界以锁定右侧边界 left = mid+1
因为收紧左侧边界时 left = mid +1,最后返回的时候需要-1
如果需要将三种算法的搜索边界都改为两端都闭,只要修改两处即可
int binary_search(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) {
// 直接返回
return mid;
}
}
// 直接返回
return -1;
}
int left_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) {
// 别返回,锁定左侧边界
right = mid - 1;
}
}
// 最后要检查 left 越界的情况
if (left >= nums.length || nums[left] != target)
return -1;
return left;
}
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;
}
二分查找需要注意:
- 如需定义左闭右开的「搜索区间」搜索左右边界,==只要在 nums[mid] 等于 target 时做修改即可,搜索右侧时需要减一 ==。
- 如果将「搜索区间」全都统一成两端都闭,好记,只要稍改 nums[mid] == target 条件处的代码和返回的逻辑即可。