Welcome to 9ilk's Code World
(๑•́ ₃ •̀๑) 个人主页: 9ilk
(๑•́ ₃ •̀๑) 文章专栏: 算法Journey
从本博客开始,博主将开始分享二分查找算法的相关知识。
🏠 朴素二分模板 --- 二分查找
📌 题目内容
二分查找
📌 题目解析
本题是比较简单的二分查找,其中题中数组是有序的且元素不重复。
📌算法原理
✏️ 思路一:暴力解法
暴力解法思路很简单,其实就是遍历一遍数组找到target则退出,时间复杂度:O(N)
✏️ 思路二:二分查找
在暴力解法的基础上,我们发现,当我们找到target的时候,由于整个数组有序,target能把整个数组分成两段,一段是小于target的,另一段是大于target的,由此我们说数组具有“二段性”。
何为二段性:
当题目中能发现一个规律,根据这个规律选取某个区间或一个点,使得数组能够划分为两段区间,然后能舍弃一部分,这就是所谓的“二段性”。当我们发现具有二段性时,就可以使用二分查找。
划分方案的选择:
既然目的是为了把区间划分为两段,那么其实不同的划分方案也是行的通的。
但是数学证明,选取1/2处的划分方案是最快的,因此我们常用的是二分。
朴素二分核心:
- 当arr[mid] < target ,由于数组有序说明[left,target]区间的值都小于target,要向右查找
- 当arr[mid] > target, 由于数组有序说明[mid,right]区间的值都大于target,要向左查找
- 当arr[mid] == target时,此时找到返回结果。
细节问题:
- 循环结束条件:
当区间不断缩小到只有一个数时,也就是left == right时,由于在这个过程中我们虽然能肉眼看出这是我们要的结果,但是程序仍然是要进入循环才能做出判断的,因此left <= right时我们进行循环查找。
- 为什么二分查找是正确的
我们之前用暴力查找是正确的,这是毋庸置疑的,而我们的二分查找是利用了数组有序这个特性干着暴力查找的事,只不过我们不是一个一个查找,而是进行有效筛选后查找。
- 二分查找的时间复杂度
1次折半缩小到n/2范围
2次折半缩小到n/4范围
3次折半缩小到n/8范围
....
x次折半缩小到1个数
==>n = 2的x次方 ==> x = logn
==>时间复杂度为O(logn),查找了logn次。
- 两种不同的划分中点方式
//1 int mid = (left + right) / 2;
//2 int mid = left + (right - left) / 2;
//3 int mid = left + (right - left + 1) / 2;
对于第一种方式我们并不建议,如果两个数都很接近整形最大,此时相加可能会发生溢出。
对于第二种和第三种方式,如果数据个数是偶数个他们选取到的中点有所不同,但我们的目的是找到点划分范围,因此并不影响选哪种。
参考代码:
class Solution {
public:
int search(vector<int>& nums, int target)
{
int left = 0 ;
int right = nums.size() - 1;
int mid = 0;
while(left <= right)
{
mid = left + (right - left + 1) / 3;
if(nums[mid] < target)
{
left = mid + 1;
}
else if(nums[mid] > target)
{
right = mid - 1;
}
else
return mid;
}
return -1;
}
};
📌 总结朴素二分算法模板
while(left <= right)
{
int mid = left + (right - left) / 2;//也可以是left+(right-left+1)/2;
if(...) left = mid + 1;
else if(...) right = mid -1;
else return ...
}
注:...表示具体问题具体分析。
🏠 查找左边界及右边界的二分模板
📌题目内容
在排序数组中查找元素的第一个位置和最后一个位置
📌题目解析
- 题目中的数组是非递减的。
- 题目中的数组存在重复数字。
📌 算法原理
✏️ 思路一:朴素二分再分类讨论
我们朴素二分可以找到一个目标值,但题目要求我们找到两个相同的目标值,一个是第一次出现,另一个是第二次出现,此时我们可以先用二分不管在哪个位置找出一个再分类讨论位置找两个端点:
- target只出现一次,直接返回当前位置
- 找到的target是连续target中的左端点。
- 找到的target是连续target中的右端点
- 找到的target是连续target中的中间位置。
参考代码:
class Solution
{
public:
typedef long long ll;
vector<int> searchRange(vector<int>& nums, int target)
{
vector<int> del = {-1,-1};
if(nums.size() == 0)
{
return del;
}
int left = 0;
int right = nums.size()-1;
vector<int> v;
bool flag = false;
int mid = 0;
while(left <= right)
{
mid = left + (right-left) / 2;
if(nums[mid] < target)
{
left = mid + 1;
}
else if(nums[mid] > target)
{
right = mid - 1;
}
else
{
flag = true;
break;
}
}
if(!flag)
return del; //表示没找到
if(mid-1 > 0 && mid+1 <nums.size() &&nums[mid-1] != target && nums[mid+1] != target)
{
return vector<int>({mid,mid});
}
else if((mid-1 > 0 && mid+1 <nums.size() && nums[mid-1] != target && nums[mid+1] == target) || mid == 0)
{
int cur = mid+1;
while(cur < nums.size())
{
if(nums[cur] == target)
cur++;
else
break;
}
return vector<int>({mid,cur-1});
}
else if((mid-1 > 0 && mid+1 <nums.size() && nums[mid-1]== target && nums[mid+1] != target) || mid == nums.size() -1)
{
int cur = mid-1;
while(cur >= 0)
{
if(nums[cur] == target)
cur--;
else
break;
}
return vector<int>({cur+1,mid});
}
else
{
int cur1 = mid+1;
int cur2 = mid-1;
while(cur1 < nums.size())
{
if(nums[cur1] == target)
cur1++;
else
break;
}
while(cur2 >=0)
{
if(nums[cur2] == target)
cur2--;
else
break;
}
return vector<int>({cur2+1,cur1-1});
}
}
};
代码又臭又长,有没有办法先找出左端点再找出右端点呢?
✏️ 思路二:边界二分
📒 区间左端点
- 找区间左端点时我们仍可以利用二段性,我们发现左端点把整个数组划分为左边部分小于target,右边部分大于等于target.
- 当mid处的值小于target时,此时说明在小于target的区间内,我们需要向右寻找,因此是left = mid +1.
- 当mid处的值大于等于target时,此时说明在大于等于target的区间内,此时要找左端点我们需要向左边寻找,向左缩小范围,又由于x可能正好就是左端点,因此更新right时只能将right更新到mid的位置,如果是right = mid-1,[left,right]就没有左端点了,毕竟[mid,right]区间内的值都是大于等于target的。
细节处理:
- 循环条件
对于【left,right】区间内值的情况我们可以分下列三种情况:
1. 对于第一种情况,[left,right]区间内有我们要找的左端点,此时left每一次移动是为了跳出小于target的区间,而right每一次移动是为了逼近左端点,因此最终left == right时就是我们要找的左端点。
2.对于第二种情况,如果区间全是target的,此时right会一直向左逼近,因为mid都是大于target的,从而最后到达left的位置,如果是left <= right的话,right到达left的位置时,mid一直是left的位置从而导致死循环。
3.第三种情况类似第二种,left一直逼近right直到到达right的位置,如果判断条件是left<=right的话也会导致死循环。
因此得出:
- left == right时就是最终结果无需判断。
- 如果判断就会导致死循环。
- 求中点的操作
我们前面说明了求中点防溢出有两种求法
1.当采取第一种方式求中点时,此时mid求到的是left位置,由于left位置是小于target的,因此此时mid位置是小于target的,left会移动right处(mid+1)从而退出循环。
2.当采用第二种方式求中点时,此时mid求到的是right位置 ,由于right位置是大于等于target的,因此此时会一直right = mid陷入死循环。
因此得出:求左端点时,我们采用left + (right - left)/2的方式。
📒 区间右端点
- 找区间右端点时我们仍可以利用二段性,我们发现右端点把整个数组划分为左边部分小于等于target,右边部分大于target.
- 当mid处的值大于target时,此时说明在大于target的区间内,我们需要向左寻找,因此是right= mid - 1.
- 当mid处的值小于等于target时,此时说明在小于等于target的区间内,此时要找右端点我们需要向右边寻找,向右缩小范围,又由于x可能正好就是右端点,因此更新left时只能将left更新到mid的位置,如果是left = mid+1,[left,right]就没有左端点了毕竟[left,mid]区间内的值都是小于等于target的。
细节处理:
- 循环条件
同分析左端点一样,循环条件也需要是left < right否则陷入死循环。
- 求中点
1.当采取第一种方式求中点时,此时mid求到的是left位置,由于left位置是小于target的,因此此时mid位置是小于target的,left会一直left=mid陷入死循环。
2.当采用第二种方式求中点时,此时mid求到的是right位置 ,由于right位置是大于target的,此时mid位置大于target,right会移动到left位置从而退出循环.
因此得出:求区间右端点时,采用left +(right-left+1)/2的方式。
因此对于本道题我们可以分别用这两种方法求出左右边界,参考代码如下:
class Solution
{
public:
typedef long long ll;
vector<int> searchRange(vector<int>& nums, int target)
{
vector<int> del = { -1,-1 };
if (nums.size() == 0)
{
return del;
}
//求左端点
int left = 0;
int right = nums.size() - 1;
int leftmid = 0;
while (left < right)
{
leftmid = left + (right - left) / 2;
if (nums[leftmid] < target)
{
left = leftmid + 1;
}
else
{
right = leftmid;
}
}
if (nums[left] != target)
leftmid = -1;
else
leftmid = left;
//求右端点
left = 0;
right = nums.size() - 1;
int rightmid = 0;
while (left < right)
{
rightmid = left + (right - left + 1) / 2;
if (nums[rightmid] > target)
{
right = rightmid - 1;
cout << right << " " << endl;
}
else
{
left = rightmid;
}
}
if (nums[left] != target)
rightmid = -1;
else
rightmid = left;
return vector<int>({ leftmid,rightmid });
}
};
时间复杂度:O(logN)
📌 总结查找左边界及右边界二分模板
查找左边界:
while(left < right)
{
int mid = left + (right-left)/2;
if(...) left = mid+1;
else right = mid;
}
查找右边界:
while(left < right)
{
int mid = left + (right-left+1)/2;
if(...) left = mid;
else right = mid - 1;
}
总结:
本博客我们讲解了朴素二分模板以及边界二分模板,朴素二分模板应用比较局限,而对于边界二分模板我们更常用.对于边界二分模板,我们要处理好它的循环条件以及求中点,同时根据我们求的左端点还是右端点来更新left和right.