算法解释
二分查找也常被称为二分法或者折半查找,每次查找时通过将待查找区间分成两部分并只取
一部分继续查找,将查找的复杂度大大减少。对于一个长度为 O(n) 的数组,二分查找的时间复
杂度为 O(logn)。
举例来说,给定一个排好序的数组 {3,4,5,6,7},我们希望查找 4 在不在这个数组内。第一次
折半时考虑中位数 5,因为 5 大于 4, 所以如果 4 存在于这个数组,那么其必定存在于 5 左边这一
半。于是我们的查找区间变成了 {3,4,5}。(注意,根据具体情况和您的刷题习惯,这里的 5 可以
保留也可以不保留,并不影响时间复杂度的级别。)第二次折半时考虑新的中位数 4,正好是我们
需要查找的数字。于是我们发现,对于一个长度为 5 的数组,我们只进行了 2 次查找。如果是遍
历数组,最坏的情况则需要查找 5 次。
具体到代码上,二分查找时区间的左右端取开区间还是闭区间在绝大多数时候都可以,因此
会容易搞不清楚如何定义区间开闭性。这里提供两个小诀窍:
- 第一是尝试熟练使用一种写法,比如左闭右开(满足 C++、Python 等语言的习惯)或左闭右闭(便于处理边界条件),尽量只保持这一种写法;
-第二是在刷题时思考如果最后区间只剩下一个数或者两个数,自己的写法是否会陷入死循环,如果某种写法无法跳出死循环,则考虑尝试另一种写法。下面有一个例子后面专门写了一个标题来讨论这个问题。
二分查找也可以看作双指针的一种特殊情况,但我们一般会将二者区分。双指针类型的题,
指针通常是一步一步移动的,而在二分查找里,指针每次移动半个区间长度。
例1:69 x 的平方根
实现 int sqrt(int x) 函数。
计算并返回 x 的平方根,其中 x 是非负整数。
由于返回类型是整数,结果只保留整数的部分,小数部分将被舍去。
分析:
这题让我实现sqrt这个函数,可以把这道题想象成,给定一个非负整数 a,求 f(x) = x 2 − a = 0 的解。因为我们只考虑 x ≥ 0,所以 f(x) 在定义域上是单调递增的。考虑到 f(0) = −a ≤ 0,f(a) = a 2 − a ≥ 0,我们
可以对 [0,a] 区间使用二分法找到 f(x) = 0 的解。
注意:
- 这一题的问题在于,我们要求的解和真实的平方根是不一样的,比如例2 ,8开根号应该是2.xxx,但是我们要返回2,而2的平方==4,而不等于8。
- 题目给出的数字比较大的话,midmid可能溢出了int型,所以我们在前面加个(long)midmid
class Solution {
public:
int mySqrt(int x) {
if(x==0) return 0;
int l=0,r=x,mid;
while(l<=r)
{
mid=(l+r)/2;
if((long) mid*mid==x) return mid;
else if((long) mid*mid>x) r=mid-1;
else l=mid+1;
}
return r;
}
};
34 在排序数组中查找元素的第一个和最后一个位置
给定一个按照升序排列的整数数组 nums,和一个目标值 target。找出给定目标值在数组中的开始位置和结束位置.
如果数组中不存在目标值 target,返回 [-1, -1]。
设计并实现时间复杂度为 O(log n) 的算法解决此问题吗?
分析:
首先以下情况应该返回[-1,-1]
-
target比nums[nums.size()-1]大
-
target比nums[0]小
-
nums.size()==0
-
nums里没找到target
返回两个-1的情况是很多的,初步的设想,找出正常返回两个数值的,其他全部返回-1.
整理一下返回-1的情况应该是这样的,只有两种: -
如果是空的
-
或者连第一个都没找到
写两个函数,一个用来找第一个,一个用来找最后一个,找第一个的如果没有找到就返回-1
class Solution {
public:
vector<int> searchRange(vector<int>& nums, int target)
{
if (nums.empty()) return vector<int>{-1, -1};
int lower=findfirst(nums,target);
int upper=findlast(nums,target);
if(nums[lower]!=target) return vector<int>{-1,-1};
return vector<int>{lower,upper};
}
int findfirst(vector<int>& nums,int target)
{
int l=0,r=nums.size()-1,mid;
while(l<r)
{
mid=(l+r)/2;
if(nums[mid]>=target) r=mid;
else l=mid+1;
}
return l;
}
int findlast(vector<int>& nums,int target)
{
int l=0,r=nums.size(),mid;
while(l<r)
{
mid=(l+r)/2;
if(nums[mid]>target) r=mid;
else l=mid+1;
}
return l-1;
}
};
上述写的可能思路有点乱,不是很好懂,我明天再写一版思路清晰一些的。
下面这一版备注十分清晰:
class Solution {
public:
vector<int> searchRange(vector<int>& nums, int target)
{
if(nums.empty()) return vector<int>{-1,-1} ;//如果是一个空数组,返回-1
int first=findfirst(nums,target);
if(first==-1) return vector<int>{-1,-1} ;//表示该数不存在,第一个都没有找到
int last=findlast(nums,target);
return vector<int>{first,last};
}
int findfirst(vector<int>& nums,int target) //找一个出现target的位置,如果没有就返回-1
{
int l=0,r=nums.size()-1,mid;
while(l<r)
{
mid=(l+r)/2;
if(nums[mid]>target) r=mid-1;//如果中位数比target大,下一个循环找的区间为【l,mid-1】
else if(nums[mid]==target) r=mid;//如果中位数恰好等于target,那第一个target一定出现在左边,即区间为【l,mid】
else l=mid+1;
}
if(nums[l]==target) return l; //说明找到有target存在
else return -1;
}
int findlast(vector<int>& nums,int target)//找最后一个出现target的位置
{
int l=0,r=nums.size()-1,mid;
while(l<r)
{
mid=(l+r+1)/2;
if(nums[mid]>target) r=mid-1;//如果中位数比target大,下一个循环找的区间为【l,mid-1】
else if(nums[mid]==target) l=mid;//如果中位数恰好等于target,那最后一个target一定出现在右边,即区间为【mid,r】
else l=mid+1;
}
return l;
}
};
承上 ——二分查找边界加一问题
好,上面两个函数有些许的不同,我们可以看见,我们在找第一个数的时候,中位数用的是:
mid=(l+r)/2;
而我们在找最后一个数时,中位数我们用的是:
mid=(l+r+1)/2;
与上面不同的是我们+1了。为什么要加这个1.举例说明一下:
假设数组nums是这样的【1,1】,我们想找1
在findfirst这个函数里,我们第一个中位数求的是(0+1)/2=0,按照这个函数内发生==1的情况,else if(nums[mid]==target) r=mid;
应该把r=mid,即r=0,此时l和r相等了跳出了这个循环,且我们找到的是第一个1,没有问题。
但是,如果我们找最后一个1也用mid=(l+r)/2来求中位数,会发生什么情况。else if(nums[mid]==target) l=mid;
mid和l会一直是0,r永远也不被更新,陷入死循环。
我们发现区别就在于,在中位数始终不变的情况下,后面更新的是r还是l,如果更新了r就会跳出循环,如果更新的是l,l就始终不会变了,它永远也跑不到能和r相等的情况。
所以我们在第二个情况的右边界先加一,mid=(l+r+1)/2;
这样第一次求出的mid就会是1,此时我们更新左边界l就会跳出循环,成功找到最后一个1.
81 旋转数组查找数字
假设按照升序排序的数组在预先未知的某个点上进行了旋转。
( 例如,数组 [0,0,1,2,2,5,6] 可能变为 [2,5,6,0,0,1,2] )。
编写一个函数来判断给定的目标值是否存在于数组中。若存在返回 true,否则返回 false。
分析:
相当于这个数组,由几段单调递增的子数组组成,但是断点应该只有一个。(注意可能出现重复数字情况)
即使是这样,我们仍然可以利用这种递增性,使用二分查找。
我们先找出nums[mid],这个值
- 如果小于右边界值,那么,从mid到r之间是单调的。断点在左边。
- 如果等于,说明从mid到r都是一个数,所以我们讨论到了Mid和左右可能相等的情况(因为数组中可能出现重复数),这个情况我们可以简单的把左端点右移一位,一直到最后一个重复数,出现mid不等于左右端点的情况。
- else 就是大于,说明左边是单调的,右边存在断点
那么目标值如果存在于单调区间内,直接二分查找即可
如果不在,在另一半继续二分查找找单调区间。
class Solution {
public:
bool search(vector<int>& nums, int target)
{
int l=0,r=nums.size()-1,mid;
while(l<r)
{
mid=(l+r)/2;
if(nums[mid]==target) return true; //必须mid跑到target位置结束
if(nums[mid]==nums[r]) --r;// 如果mid和右边界相等,无法判断,右边界减一
else if(nums[mid]<nums[r]) //如果mid比右边界小,[mid,r]单调增(准确说是不减)
{
//里面就存在两种情况,target要么在单调区间内,要么不在
if(nums[mid]<target && target<=nums[r]) //在单调区间内
l=mid+1;
else //不在单调区间内
r=mid-1;
}
else //[l,mid]单调
{
//同上
if(nums[l]<=target && target<nums[mid])
r=mid-1;
else
l=mid+1;
}
}
if(nums[l]==target) return true;
return false;
}
};
解释:因为退出循环的时候l=r,但是此时没有判断这个nums[l]是不是我们要找的,所以最后补充一个判断。
练习
154 寻找旋转排序数组中的最小值 II
假设按照升序排序的数组在预先未知的某个点上进行了旋转。
( 例如,数组 [0,1,2,4,5,6,7] 可能变为 [4,5,6,7,0,1,2] )。
请找出其中最小的元素。
注意数组中可能存在重复的元素。
垃圾解法(暴力法):
找断崖下跌的那个点,没找到的话就返回第一个:
class Solution {
public:
int findMin(vector<int>& nums)
{
int i=0;
while(i<nums.size()-1)
{
if(nums[i]>nums[i+1]) return nums[i+1];
++i;
}
return nums[0];
}
};
二分法:
class Solution {
public:
int findMin(vector<int>& nums)
{
int l=0,r=nums.size()-1,mid;
while(l<r)
{
mid=(l+r)/2;
if(nums[mid]==nums[r]) --r;
else if(nums[mid]<nums[r]) r=mid;
else l=mid+1;
}
return nums[l];
}
};
540 有序数组中的单一元素
给定一个只包含整数的有序数组,每个元素都会出现两次,唯有一个数只会出现一次,找出这个数。
分析:
1.s=nums.size()首先必定是一个奇数,mid=s/2必定指在正中间,左右两部分个数相等
2.if (nums[mid-1]<nums[mid]<nums[mid+1] )那这个数必定是我们要找的
那么只剩下两种情况,这个nums[mid]要么和nums[mid-1]等,要么和nums[mid+1] 等
单出来的这个数必定存在于两部分中,数目为奇数的那一部分。
class Solution {
public:
int singleNonDuplicate(vector<int>& nums)
{
int l=0,r=nums.size()-1,mid;
while(l<r)
{
mid=(l+r)/2;
if(nums[mid-1]<nums[mid] && nums[mid]<nums[mid+1] ) return nums[mid];
else if(nums[mid-1]==nums[mid])//和左边等
{
if((mid-2-l+1)%2==1) //左边是奇数
r=mid-2;
else //右边是奇数
l=mid+1;
}
else//和右边等
{
if((mid-1-l+1)%2==1)//左边是奇数
r=mid-1;
else //右边是奇数
l=mid+2;
}
}
return nums[l];
}
};
1482制作 m 束花所需的最少天数
class Solution {
public:
int minDays(vector<int>& bloomDay, int m, int k)
{
int num=bloomDay.size();
if(k>num ||num<m*k) return -1; //花不够的情况
int minday=INT_MAX,maxday=INT_MIN;//分别为blooday中的最小与最大值
for(auto i:bloomDay)
{
minday=min(i,minday);
maxday=max(i,maxday);
}
//用二分查找,找到minday 和maxday中间的某个数,l和r二分查找,相等时使其刚好能找到m束花
int l=minday,r=maxday,miday;
while(l<r)
{
miday=(l+r)/2;
if(canmiday_collect_m(miday,bloomDay,m,k)) //说明miday大了
{
r=miday;
}
else //说明l小了
{
l=miday+1;
}
}
return l;
}
bool canmiday_collect_m(int day,vector<int> bloomDay,int m, int k)
{
int continum=0;
for(auto i:bloomDay)
{
if(i<=day)
{
++continum;
if(continum==k)
{
--m;
continum=0;
}
}
else
{
continum=0;
}
}
if(m<=0) return true;
return false;
}
};
2021.8.18心得加更
- 推荐只使用一种自己习惯的写法,比如我习惯写while(l<r)这样循环出来的l和r必定是相等的
- 要注意我们二分出来的结果不一定真的找到了,所以我们在return 的时候,要这样写 return nums[l]==target? l:-1;意思是即使二分收敛了,我们也可能没有找到目标。
2021.8.31心得加更
又做了几题,切实感觉到,当你判断一个题目部分有序用二分却不知道如何下手的时候,仔细想想这句话:
将数组一分为二,其中一定有一个是有序的,另一个可能是有序,也能是部分有序。此时有序部分用二分法查找。无序部分再一分为二,其中一个一定有序,另一个可能有序,可能无序。就这样循环.
2022.3.7 力扣加更4 寻找两个正序数组的中位数
因为题目限制时间复杂度,所以归并首先被pass了。
log(m+n)的时间复杂度肯定是用二分了。
思路线索:
- 对于一个数组长度为m,他的中位数取决于奇偶性,奇数(eg:9)的中位数为第(m+1)/2个数。偶数(eg:10)的中位数为(m+1)/2和(m+2)/2这两个数的平均值。但是,其实对于任意一个奇数来说(m+1)/2和(m+2)/2这两个数是相等的,因此不论奇偶,一个数组的中位数都是((m+1)/2+(m+2)/2)/2.
- 两个数组也是一样的,无非是把m变成了(m+n),所以我们的目标是找到合并后两个数组中第 (m+n+1) / 2 和 (m+n+2) / 2大的数的平均值。
- 由此这个问题扩展成一个通用的问题,在两个有序数组中找到第k大的值。只不过我们的k现在是(m+n+1) / 2 ,如果是偶数的话还需要找第 (m+n+2) / 2大的。
- 讨论极端情况,如果m或者n为0,那么可以直接返回另一个数组的中位数。
- 对两个数组找第k大的数,其实可以转化为在每个数组中找到第k/2大的数。然后取两者之间的较小值。
- 那么问题又来了,每个数组的长度是不确定的,你不能保证k/2一定小于m或者n。所以对于这种情况,无法判度中位数在哪。举个例子,比如A数组长度m=2.而B数组长度是12,此时我们应该找第7和第8大的数求平均值。具体到每个数组,我们应该找到第3和第4大的数。但是现在m只有2,找不出第3大的数。此时虽然无法判度出这m个数里有没有中位数,但是却能知道,另一个数组的前三个数肯定不是中位数,因为目前最多2+3只有5个数,必定到不了第7个数。可以理解么?
- 对于上述情况,我们要将A数组中查找结果赋值INT_MAX,并且保证B数组从第4个数开始重新找,即从j+k/2位置开始找。(这个i,j为我们目前排除掉的左边的区间位置,即我们的查找范围始终从i或者j到数组结束)
class Solution {
public:
//本质是找到两个有序数组的第k大元素
double findMedianSortedArrays(vector<int>& nums1, vector<int>& nums2) {
int m=nums1.size(),n =nums2.size();
//不管合并后m+n和的奇偶性质如何,他们的中位数,都是(m+n+1)/2 和(m+n+2)/2这连个数的平均值。只不过对于奇数来说,这两个数是同一个数
int left= (m+n+1)/2,right= (m+n+2)/2;
if((m+n)&1) return(find_kth(nums1,0,nums2,0,left,m,n));
return (find_kth(nums1,0,nums2,0,left,m,n)+find_kth(nums1,0,nums2,0,right,m,n))/2.0;
}
int find_kth(vector<int>& nums1,int i, vector<int>& nums2,int j,int k,const int &m,const int &n)
{
//i,j表示切割点在两个数组的起始位置
if(i>=m) return nums2[j+k-1];//排除m,n为0的情况
if(j>=n) return nums1[i+k-1];
if(k==1) return min(nums1[i],nums2[j]); //正常最终中止条件
int mid_val_1 = i+k/2-1<m ? nums1[i+k/2-1]: INT_MAX;//如果第一个数组不存在第k/2大的数,那么另一个数组的第k/2必定没有意义,因为要想有意义必须两个数组都能取到k/2个数才行,将其置为最大数,进行下一次循环再看
int mid_val_2 = j+k/2-1<n ? nums2[j+k/2-1]: INT_MAX;
if(mid_val_1<mid_val_2)
{
return find_kth(nums1,i+k/2,nums2,j,k-k/2,m,n);
}
else
{
return find_kth(nums1,i,nums2,j+k/2,k-k/2,m,n);
}
}
};