写在前面
从零开始的刷题之旅,首先是最基础的数组。
而在数组中,二分查找是一个非常常见的问题
除了我们常见的需要找到某一数之外,还有一些变体比如寻找边界,数组旋转等等。这次就将他们一网打尽。
首先根据一道题来摸清楚二分查找最基础的框架
这样的基础框架是很好写的,只是需要注意细节。
int search(vector<int>& nums,int target){
int left=0;
int right=nums.size()-1;
while(left<=right){
int middle = (left+right)/2;\
if(nums[middle]>target){
right=middle-1;
}
else if(nums[middle]<target){
left=middle+1;
}
else//nums[middle]==target
{
return middle;
}
}
return -1;
}
这就是一个二分查找的基本框架。但是有些细节需要明确。
问题讨论
根据上面的框架,有这么几个问题需要明确:
左闭右开or左闭右闭
可以看到我们的left是0,right是nums.size()-1,也就对应着数组的第一个元素和最后一个元素。
这样的区间就是左闭右闭的,即[left,right]
我们也可以写为左闭右开的,即[left,right),那么right就是nums.size()
这两种写法有什么区别呢?区别在于while里的内容。
如果是左闭右闭,那么while的条件是left<=right,因为right是存在的,所以在两者相等时[left,right]也是有效的。(当然对于这道题来说,由于是升序序列所以不存在相等)
如果是左闭右开,那么while的条件是left<right,当left=right的时候这个区间就没有意义了,所以退出循环。
while中“=”的影响
知道什么时候加等于什么时候不加之后,我们来看循环内的代码。
- middle在left和right的中间,需要注意一般写为left + ((right - left) / 2) 防止溢出。
- 如果middle正好是我们要找的target,那么直接返回即可。
- 如果要找的target在middle的左边,那么我们理所当然要缩小右边界,缩到middle这里
- 如果是左闭右闭,那么我们的缩小区间应该是[left,middle-1],所以right=middle-1
- 如果是左闭右开,那么我们的缩小区间应该是[left,middle),所以right=middle
- 如果要找的target在middle的右边,那么我们理所当然要缩小左边界,缩到middle这里
- 如果是左闭右闭,那么我们的缩小区间应该是[middle+1,right],所以left=middle+1
- 如果是左闭右开,那么我们的缩小区间应该是[middle+1,right),所以left=middle+1
所以到这里我们也应该会写第二种左闭右开的写法了:
int search(vector<int>& nums,int target){
int left=0;
int right=nums.size();
while(left<right){
int middle = (left+right)/2;\
if(nums[middle]>target){
right=middle;
}
else if(nums[middle]<target){
left=middle+1;
}
else//nums[middle]==target
{
return middle;
}
}
return -1;
}
右区间开还是不开的区别
从这道题来看,其实是没有区别的。因为是有序且不存在重复的元素。
但是如果有重复的元素,比如[3,3),那么这时如果左闭右开,那么在[3,3)时就会退出while循环,会漏到target是3这种情况。
这种情况下需要补充这个特殊情况。
return nums[left]==target?left:-1;
当然此时left=right,所以都可以。
巩固练习
掌握之后来看另一道类似的题目。
leetcode 35 搜索插入位置
左闭右闭写法:
int searchInsert(vector<int>& nums,int target){
int left=0;
int right=nums.size()-1;
while(left<=right){
int middle=left+(right-left)/2;
if(nums[middle]>target){
right=middle-1;
}
else if(nums[middle]<target){
left=middle+1;
}
else if(nums[middle]==target)
return middle;
}
return right+1;
}
区别在于当没有这个数时,需要插入到适当的位置。
对于左闭右闭的写法来说,循环退出的条件是left>right,而这时right+1就是正确的位置。(不清楚可以举个例子画一遍)
同理,对于左闭右开的写法来说,就是right的位置。
x的平方根
也有一些比较巧妙的数学问题。
这道题要返回x的平方根,只保留整数部分。
因为只需要整数部分,其实就相当于找0到x之间,哪个数的平方小于等于x并最接近x。
使用二分法可以提高效率,我们可以使用左边界逼近的方法来求得这个最值。
int muSqrt(int x){
int left=0;
int right=0;
int ans=-1;
while(left<=right){
int middle=left+(right-left)/2;
if(mid<=x/mid){
ans=mid;
left=mid+1;
}
else
right=mid-1;
}
return ans;
}
寻找边界
还有一类题目,需要在有重复的顺序数组中寻找某一个数的起始位置或结束位置。
leetcode 34 在排序数组中查找元素第一个和最后一个位置
这个题可以拆分为两边来看:
左边界
对于边界问题,用左闭右开和左闭右闭都是可以的,只是需要在元素重复时处理好退出循环的问题。
先拿左闭右闭来写:
int searchLeft(vector<int>&nums,int target){
int left=0;
int right=nums.size()-1;
while(left<=right){
int mid=left+(right-left)/2;
if(nums[mid]>target){
right=mid-1;
}
else if(nums[mid]<target){
left=mid+1;
}
else if(nums[mid]==target){
right=mid-1;
}
}
if(left>=nums.size()||nums[left]!=target) return -1;
return left;
}
有两个点需要想清楚:
- 当nums[mid]==target时,缩小了右边界,继续在左边搜索有没有这个值。
- 最后return left,这里画一下就明白了,可以理解为求左边界就返回左。
举个例子:[5,7,7,8,8,10],target是8,我们来求左边界。
- mid是(0+5)/2=2,也对应数字7,此时left=2+1,对应第一个数字8
- 第二次mid是(3+5)/2=4,对应第二个数字8,这时与target相等,缩小右边界。right指向第一个数字8
- 此时left和right重合,但是循环继续。mid对应的值还是与target相同,所以right指向最后一个7
- 这时退出循环,左边界就是left
左闭右开也是一样的,与target不相等则返回-1。
int searchLeft(vector<int>&nums,int target){
int left=0;
int right=nums.size();
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;
}
}
if(left>=nums.size()||nums[left]!=target) return -1;
return left;
}
右边界
同理,还是以左闭右闭开始:
int searchRight(vector<int>&nums,int target){
int left=0;
int right=nums.size()-1;
while(left<=right){
int mid=left+(right-left)/2;
if(nums[mid]>target){
right=mid-1;
}
else if(nums[mid]<target){
left=mid+1;
}
else if(nums[mid]==target){
left=mid+1;
}
}
if(right<0||nums[right]!=target) return -1;
return right;
}
应该很好理解,再来看左闭右开:
int searchRight(vector<int>&nums,int target){
int left=0;
int right=nums.size();
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){
left=mid+1;
}
}
if(right<0||nums[right-1]!=target) return -1;
return right-1;
}
因为这里的right是开区间,所以要-1才是有意义的。
right-1和left-1是一样的,因为此时left=right
注意防止越界的限定条件,不然会出现一些特殊情况无法通过。
旋转数组
还有一类题目,是把排序数组旋转后求解。
这个题就是[0,2,3,4,5,6,7]可能在某一处旋转,比如下标3处,就会变为[4,5,6,7,0,2,3]
在旋转后再找是否存在target
核心
因为是有序且升序排列,所以核心在于旋转之后,从中间分开,一部分是有序的,另一部分可能是有序的。
就像[4,5,6,0,1,2,3]从中间分开变成[4,5,6,0]和[1,2,3],那么其中一定有一部分是有序的,我们就可以对这部分进行二分查找。另一部分可以再分下去。
如何判断哪一部分一定有序呢?
我们只需要让middle和边界比较,如果比左边界left大,说明左边有序,反之则是右边有序。
这样我们可以写出步骤:
int search(vector<int>& nums, int target){
int left=0,right=nums.size()-1;
if(nums.size()==0)return -1;
while(left<=right){
int mid=left+(right-left)/2;
if(nums[mid]==target)
return mid;
if(nums[left]<=nums[mid]){//左半有序
if(target>=nums[left]&&target<nums[mid]){
right=mid-1;
}
else
left=mid+1;
}else{
if(target<=nums[right]&&target>nums[mid]){
left=mid+1;
}
else
right=mid-1;
}
}
}
简单总结:
- 如果mid比左边界大(或者等于,很重要,因为mid可能落在边界上),那么左边有序
- 如果target在这个范围,那么就进行二分查找
- 如果target不在,就去另一边一分为二,找另一个一定有序的部分
- 如果mid比左边界小,那么右边有序
- 如果target在这个范围,那么就进行二分查找
- 如果target不在,就去另一边一分为二,找另一个一定有序的部分
因为一分为二的过程和有序部分二分的过程一致,所以在代码上看不出区别。
另外注意一定要在判断有序时加上等于号(哪一边都可以)
有重复数的旋转数组
我们前面说的旋转数组是不会有重复数的,而这道题允许有重复。
题目说数组中的值不必互不相同。
这就出现一个问题,当边界值与mid值相等时,不一定能判断出那边是有序的,有可能都不是有序的。
比如[1,1,0,1,1]
需要对边界特殊处理,当遇到时可以右移左边界
int search(vector<int>& nums, int target){
int left=0,right=nums.size()-1;
if(nums.size()==0)return -1;
while(left<=right){
int mid=left+(right-left)/2;
if(nums[mid]==target)
return mid;
if(nums[mid]==nums[left])
left++;
else if(nums[left]<=nums[mid]){//左半有序
if(target>=nums[left]&&target<nums[mid]){
right=mid-1;
}
else
left=mid+1;
}else{
if(target<=nums[right]&&target>nums[mid]){
left=mid+1;
}
else
right=mid-1;
}
}
}
有两点需要注意:
- 上一道题在判断时也会有等于号,nums[left]<=nums[mid],但这种情况对于不同的数来说只有mid和left重合才会出现。这道题需要单独判断
- 为什么是左边界?其实左右边界都判断也可以,但是我们可以自行举例来看,其实是不存在右边界与mid相等,左边界与mid不等,并且无法判断哪边有序的情况。(但凡无法判断了都与左边界有关)
旋转数组最小值
这是一道变体,也是二分查找模板需要变通的一道题
首先明确了是一个元素互不相同的升序排列的数组。核心在于与右边边界比大小
经过多次旋转后,我们来举个例子看它的变化。
如果是[4,5,6,7,0,1,2],那么mid是7,最右边是2,因为>2,所以最小值一定在mid的右边。
如果是[5,6,0,1,2,3,4],那么mid是1,最右边是4,因为<4,所以最小值在mid或者mid的左边。
int findMin(vector<int>& nums) {
int left=0;
int right=nums.size()-1;
while(left<right){
int mid=left+(right-left)/2;
if(nums[mid]<nums[right]){
right=mid;
}
else
left=mid+1;
}
return nums[left];
}
这里就需要变通,明明我们写的是左闭右闭的区间定义,为什么while里面是<号?并且right的定义也很像左闭右开。
其实right这里需要思考。就像上面举的例子,当mid值小于右边界时,mid有可能是最小值,所以right要到mid的位置。而如果大于右边界则mid不可能是最小值,所以left到mid+1的位置。
至于while中没有=号,是因为我们可以画图看出来,在left=right时就找到了最小值。