一.简介
二分搜索一般运用于搜索有序数组的目标值,属于常规算法,但在细节上是魔鬼。
此外,二分还有左右边界搜索的延伸应用。
二.简单二分搜索
二分搜索的搜索范围有左闭右闭,和左闭右开两种,
2.1左闭右开
先来讲讲左闭右开,也就是搜索区间为[left,right),二分搜索一般应用于数组,搜索范围是数组中还未被遍历过的元素,一开始数组没有一个元素是遍历过的,则搜索范围为 [0,len) (设数组arr的长度为len)。
右区间属于开区间,right下标应该指向已经遍历过的元素,那么在这种情况下while括号里应该写left<right。
常见求mid的方式是(left+right)/2,但这种方式存在一个问题,当left或right的值过大时,两者相加可能会越界,所以改用mid=left+(right-left)/2的方式来求mid值。
设要搜索的值为target。
- 当
arr[mid]<target时,左区间属于闭区间,left下标应该指向没有遍历过的元素,mid下标所对应的元素已经遍历过,所以在这种情况left=mid+1。
- 当
arr[mid]>target时,还是同样的道理,right下标应该指向已经遍历过的元素,mid下标所对应的元素已经遍历过,所以在这种情况right=mid。
- 当
arr[mid]=target时,直接返回mid值即可。
最终代码如下所示。
class Solution {
public int search(int[] nums, int target) {
int i=0;
int j=nums.length;
//这里i代表left,j代表right
while (i<j){
int mid=i+(j-i)/2;
if (nums[mid]==target){
return mid;
}else if (nums[mid]>target){
j=mid;
}else {
i=mid+1;
}
}
return -1;
}
}
2.2左闭右闭
左闭右闭和左闭右开的分析思路一致,搜索范围是数组中还未被遍历过的元素,由于一开始数组没有一个元素是遍历过的,则搜索范围为 [0,len-1] (设数组arr的长度为len)。
在左闭右闭的情况下,right下标指向还没遍历过的元素,所以while()里应该写left<=right。
- 当
arr[mid]<target时,左区间属于闭区间,left下标应该指向没有遍历过的元素,mid下标所对应的元素已经遍历过,所以在这种情况left=mid+1。
- 当
arr[mid]>target时,right下标应该指向还没遍历过的元素,mid下标所对应的元素已经遍历过,所以在这种情况right=mid-1。
- 当
arr[mid]=target时,直接返回mid值即可。
最终代码如下所示。
class Solution {
public int search(int[] nums, int target) {
int i=0;
int j=nums.length-1;
while (i<=j){
int mid=i+(j-i)/2;
if (nums[mid]==target){
return mid;
}else if (nums[mid]>target){
j=mid-1;
}else {
i=mid+1;
}
}
return -1;
}
}
三.二分边界搜索
二分边界搜索是要求在搜索的基础上,返回符合要求的最左边数值(下标最小),或者最右边数值(下标最大)。
不管是左边界还是右边界搜索,主要是针对arr[mid]=target的情况进行修改。
3.1二分左边界搜索
左闭右开的区间是[left,right),由于当搜索到目标值的时候,不是直接返回下标,而是继续向左搜索,代码如下所示。
class Solution {
public int search(int[] nums, int target) {
int i=0;
int j=nums.length-1;
while (i<=j){
int mid=i+(j-i)/2;
if (nums[mid]==target){
j=mid-1;
}else if (nums[mid]>target){
j=mid-1;
}else {
i=mid+1;
}
}
//1
if(left>=nums.length||nums[left]!=target){
return -1;
}
return left;
}
}
注释1处代码中,当left越界,或者left下标所对应的元素值不等于target时,返回-1。
可以看到,上面代码中nums[mid]==target和nums[mid]>target是可以合并的,合并之后的代码如下所示。
class Solution {
public int search(int[] nums, int target) {
int i=0;
int j=nums.length-1;
while (i<=j){
int mid=i+(j-i)/2;
if (nums[mid]>=target){
j=mid-1;
}else {
i=mid+1;
}
}
//1
if(left>=nums.length||nums[left]!=target){
return -1;
}
return left;
}
3.2二分右边界搜索
二分右边界搜索和二分左边界搜索的分析过程类似,这里就不再赘述,简单贴下代码。
class Solution {
public int search(int[] nums, int target) {
int i=0;
int j=nums.length-1;
while (i<=j){
int mid=i+(j-i)/2;
if (nums[mid]<=target){
i=mid+1;
}else {
j=mid-1;
}
}
//1
if(right<0||nums[right]!=target){
return -1;
}
return right;
}
四.二分边界搜索的延伸应用
前面代码块注释1处都对越界情况进行了判定,需要注意的是,这个越界情况是可以根据题意灵活改变的,下面的题目爱吃香蕉的珂珂就是一个例子。
是的,这道题是用二分边界搜索解。
先来定义一个时间函数time(),设速度为v,time(v)即为计算在某速度下吃完所有香蕉的时间。
private int time(int[] piles,int v){
int res=0;
for (int i=0;i<piles.length;i++){
res+=piles[i]/v;
if (piles[i]%h!=0){
//剩下的香蕉不需要吃一小时也当做要吃一小时
res++;
}
}
return res;
}
我们先把题目换一换,假设现在有一个V[]数组(V代表Velocity速度),该数组元素为{1,3,5,9,10},求该数组中能能在H小时吃完所有香蕉的最小速度。
不难看出,变换后的题目是在搜索左边界,那么我们同样可以用二分边界搜索去解决,左闭右闭。
- 当time(v[mid])>H,即吃完所有香蕉的所需要时间大于H,不符合题意,速度要加快,left=mid+1。
- 当time(v[mid])<H,即吃完所有香蕉的所需要时间小于H,符合题意,但题目求的是最小速度,也就是左边界,还需要往左搜索,right=mid-1。
- 当time(v[mid])=H,即吃完所有香蕉的所需要时间刚好等于H,符合题意,但题目求的是最小速度,也就是左边界,还需要往左搜索,right=mid-1。
现在把数组换成{1,2,3,4..MAX_VALUE},MAX_VALUE的值是不管堆有多少个香蕉,珂珂总是能在一小时吃完的速度,如下图所示,堆中香蕉的最大数量是10^9个。
- 当搜索区间是左闭右开时,MAX_VALUE的值取10^9+1。
- 当搜索区间是左闭右闭,MAX_VALUE的值取10^9。
最终代码如下所示。
class Solution {
public int minEatingSpeed(int[] piles, int h) {
if(piles==null||h<=0){
return -1;
}
//搜索区间为左闭右开
int left=1;
int right=1000000000+1;
while (left<right){
int mid=left+(right-left)/2;
int temp=count(piles,mid);
if (temp<=h){
right=mid;
}else {
left=mid+1;
}
}
//1
return left;
}
private int count(int[] piles,int h){
//计算时间
//h为珂珂吃香蕉的速度
int res=0;
for (int i=0;i<piles.length;i++){
res+=piles[i]/h;
if (piles[i]%h!=0){
res++;
}
}
return res;
}
}
需要注意的是,注释1处没有像往常一样进行越界判断,而是直接返回left,这是因为题目不需要吃完香蕉的时间刚好等于h,只需要无限接近于h即可。
题目是分析完了,但核心问题还没有解决,那就是为什么这道题可以用二分边界搜索?
先来分析计算吃香蕉所需时间的函数time(v),其图像如下图所示,可以发现,其呈现出了一种单调性,在一定范围内,速度越快,所需时间越短,但存在一定的”瓶颈期“,也就是图中横线所在位置。
而我们要求的就是上图虚线所对应的值,横线对应的所有值即为满足题目要求的速度,在这种情况下,不就是在进行左边界搜索吗?
当题目呈现出了像爱吃香蕉的珂珂一样的函数单调性,就可以尝试使用二分边界搜索进行解题。
最后再来总结一下此种类型的题目的做题步骤。
- 确定函数f(x)
- 确定搜索范围,也就是确定最小值,最大值。这两个值取决于题意的同时,也与搜索范围采用左闭右开还是采用左闭右闭区间有关。
参考资料
- 《labuladong的算法秘籍》