系列文章目录
提示:写完文章后,目录可以自动生成,如何生成可参考右边的帮助文档
前言
之前刷了一段时间的Leetcode,当时似乎弄明白了一类题目,但是由于做的题目比较繁杂,而且没有进行总结,导致过一段时间之后有需要重新思考,又会花费大量的时间。所以我打算把之前刷过的题目类型进行一些总结与思考,以便我可以更好地掌握这些知识。今天,我打算从二分法开始进行总结。
提示:以下是本篇文章正文内容,下面案例可供参考
一、二分法的基本理解
首先,我想讲一下两个数的二分查找。例如给定数组[1,2],此时,left = 0,right = 1,mid = 0,如果我想要查找target为2的话,会进行一次判断nums[mid]<target,这时我们需要缩减区间,改变左边界,如果直接令left=mid,那么不会有变化,因为他俩本来就相等,区间范围还是[0,1],这样的话就会回到最初的情况,进行无限循环。所以需要设置left = mid+1;这样的话,区间就剩下一个数,如果这个数等于target,那么就可以返回,但是如果不相等,说明这个区间没有我们想要的数字,这样的话,会令left+1或者right-1,跳出循环。
再讲一下三个数的二分查找,[1,2,3],target = 1;最初情况下left = 0,mid = 1,right = 2;此时muns[mid]>target,区间右边界需要改进,如果此时令right =mid,之后会将区间缩减到[0,1],这样的话,就会变成区间是两个数的情况。如果令right = mid-1,则会节省一步,直接转化为一个数的情况。
为什么要讲这两种情况呢?因为二分查找的每次舍弃一半,最终都会演化为上面的情况。我们从上面可以得到一个重要的信息。剩两个数时候,left是等于mid的,因为(1-0)/2 = 0,但是只有left =right的时候(剩一个数),right才会等于mid,所以left=right=mid是整个循环的倒数第二步!! 这点很重要,会留在后面说明。
二、二分法的基本模板
首先,咱们看一下二分法两种最基本模板的写法。代码如下:
//模板1
/// <summary>
/// 1.最简单的二分查找[left, right]
/// </summary>
int binarySearch1(vector<int>& nums, int target) {
int len = nums.size();
int left = 0;
int right = len - 1; // [left, right]
while (left<=right)
{ int mid = left + (right - left) / 2;
if (nums[mid] == target)
return mid;
if (nums[mid] < target)
left = mid + 1;
else
right = mid - 1 ;
}
return -1;
}
上面的代码介绍的是一个左闭右闭区间的二分搜索写法。这里要解释一下这段代码的几个重点内容
- 先说循环里面的left与right的取值。由于刚开始就会进行mid的判断,所以在之后的验证中,就无需进行mid的判断了,继续判断的区间应该为[left,mid-1],[mid+1,right],这样的话就很好理解了。
- 进入循环的判断条件为left<=right,所以循环终止的条件为left>right,也就是left = right+1;这种情况。这时候搜索区间为[right+1,right],已经不符合区间的定义了,所以就会退出。也就是说,当left=right的时候,循环不会终止,而在进入一次循环,此时mid = left=right,要么返回mid,要么退出循环,返回-1。
接下来介绍第二种模板,左闭右开区间的写法。
//模板二 用于查找需要访问数组中当前索引及其直接右邻居索引的元素或条件。
int binarySearch2(vector<int>& nums, int target) {
if (nums.size() == 0)
return -1;
int left = 0, right = nums.size(); //注意! // [left, right)
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; }
}
// Post-processing:
// End Condition: left == right
if (left != nums.size() && nums[left] == target) return left;
return -1;
}
左闭右开写法最关键的是返回条件与闭区间不同,当left=right时候,循环就要退出了,这会出现一个问题,当通过判断条件循环到right=left时候,如果按照我们原来预想的情况,会在循环一次,使得mid =left=right,如果存在nums[mid]=target,就可以返回mid了。这少的一次循环我们需要在while循环外进行体现。所以需要增加这个后处理操作。后处理操作的功能包括:
- 如果nums[left]=target,则返回left
- 数组中间和两边找不到都会返回-1,
// End Condition: left == right
if (left != nums.size() && nums[left] == target) return left;
return -1;
这样就可以达到和模板一相同的效果了。
关于right = nums.size()还是nums.size()-1,我觉得并不是问题的关键,我觉得所有的代码都可以使用right=nums.size()-1来使用,这还能节省left!=nums.szie()这行代码。对于网上说的大部分开闭区间的不同导致循环的判断是否加等于号。我觉得并不是很对,因为对于一个题目,我们很难判断应该使用开区间还是闭区间,而我们要先确定区间才能确定模板,这显然是令人困惑的。 最关键的问题在于,在循环中,数组下标不能越界,因为这个原因才导致我们选取nums.size()还是nums.size()-1!!
我们直接做几道例题,练练手,感受一下。
1.x的平方根
这道题目相当于求小于等于x的最大平方数,也就是满足aa<=x这个条件最大的a,所以我们就有思路了,用二分法求解,当mid * mid>x的时候,就要缩小右边界了,当midmid<=x时候,这个值未必是我们想要的,虽然满足了条件,但是不是最大,要想找最大,也就是找右侧边界,这简直和下面3.2的类型一样,所以,当找到了mid * mid<=x的情况下,继续扩大左边界,这样就可以找到最大的满足条件数。然后看一下循环终止条件.在这个题不需要返回所在的位置,所以直接记录每次mid*mid<=x的答案就可以了。
int mySqrt(int x) {
if (x == 0) return 0;
int ans = 1;
int left = 1;
int right = x;
while (left < right) {
int mid = left+(right-left)/2;
if ((long long)mid * mid <= x) { ans = mid; left = mid + 1; }
else right = mid;
}
return ans;
}
2.有效完全平方数
这个简直是最基本的二分搜索,直接秒杀!有人可能会疑问,当选用闭区间模板的时候,right不是应该 = num-1吗?其实不是这样的,这跟开闭区间模板没有关系,而是和数组下标是否越界有关。本题中。num是一个数,不担心越界,直接取num即可。
bool isPerfectSquare(int num) {
if (num == 0 || num == 1) return true;
int left = 0;
int right = num;
while (left <= right)
{ int mid = left + (right - left) / 2;
if (mid*mid == num)
return true;
if (mid * mid < num)
left = mid + 1;
else
right = mid - 1;
}
return -1;
}
3.寻找峰值
对于本题来说,寻找峰值就是寻找第一个下降的点,因为我们默认数组是上升的,只要找到第一个下降点,就可以找到峰值。所以只需要比较nums[mid] 与nums[mid + 1],从而缩减区间即可。这里会出现nums[mid + 1],在循环中left会=mid=right-1,所以right = nums.size() - 1。
int findPeakElement(vector<int>& nums) {
if (nums.size() == 1)return 0;
int left = 0, right = nums.size() - 1;
while (left < right) {
int mid = left + (right - left) / 2;
if (nums[mid] > nums[mid + 1]) { right = mid-1; }
else if (nums[mid] < nums[mid + 1]) { left = mid + 1; }
}
return left;
}
4.寻找旋转排序数组中的最小值
旋转排列数组是一个常考的知识点,它的特点是半有序,也就是说,如果使用二分法将这个数组分为两个部分时候,其中一部分是递增的,而剩余一部分不是单调的。对于本道题来讲。我们要找的最小值一定在无序的那个部分。所以每次我们判断nums[mid] >= nums[left]的关系,缩减区间即可。但需要注意的是,我们需要首先判断nums[left] < nums[right],因为如果他是单调的,我们就不用分了。直接返回Nums[left]即可。由于循环中使用到了nums[right],为了避免数组越界,初始right = n-1.
int findMin(vector<int>& nums) {
int left = 0, right = nums.size()-1;
while (left < right) {
int mid = left + (right - left) / 2;
if (nums[left] < nums[right]) { return nums[left]; }
else if (nums[mid] >= nums[left])left = mid + 1;
else right = mid;
}
return nums[left];
}
三、二分法进阶情况
3.1.寻找左侧边界的二分查找
一般情况下,进行二分搜索的数组是有序且不同的,但是当有相同的元素时候,我们如果想要找到边界情况,就需要改进一下之前的代码,因为之前的代码是只要找到与目标值相同的元素,就进行返回,对于这个元素的边界是不敏感的,下面解决这个问题。
给定一个数组和目标值,找出该目标值最早出现的数组下标(也就是左边界),如果没有目标值,返回-1。
例如:nums = {1,2,3,3,3,3,4},target = 3,输出结果为2。
咱们写一下这个代码,一般情况下,找到目标值就会返回,但是我们现在不让他立刻返回,我们要找到左边界才能返回,这样的话,当nums[mid]==target的时候,我们继续想左查找,right =mid;然后我们看循环终止条件,当mid找到左边界时候,并不会停止,而是继续向左查找,此时,right指向做边界,left向右一步等于right,此时跳出循环,返回left,即可得到结果。最后几步的步骤和代码如下图所示。
int leftbound(vector<int>nums, int target) {
int l = 0, r = nums.size();
while (l<r)
{
int mid = (l + r) >> 1;
if (nums[mid] == target) r = mid;
else if (nums[mid] > target) r = mid;
else l = mid + 1;
}
if (l == nums.size()) return -1;
if (nums[l] == target) return l;
return -1;
}
3.2.寻找右侧边界的二分查找
有上面的左边界分析了,有边界代码简直是照猫画虎一样简单,但是我们还是要注意一下循环终止条件。当我们跳出循环的时候,left一定不等于我们想要的目标值,而是left的前一位才是目标值的右边界。所以要返回left-1才是最终结果。
int rightbound(vector<int>nums, int target) {
int n = nums.size();
int l = 0, r = n;
while (l < r)
{
int mid = (l + r) >> 1;
if (nums[mid] == target) l = mid+1;
else if (nums[mid] < target) l = mid+1;
else r = mid;
}
if (l == 0) return -1;
return nums[l - 1] == target ? (l - 1) : -1;
}
接下来做题,更好的感受一下。
在排序数组中查找元素的第一个和最后一个位置
这道题简直是为上面的左右边界良心定制的,思路一下就有了,先找左边界,再找右边界,就拿下了!代码如下,相当于对上面两种情况进行整合。
vector<int> searchRange(vector<int>& nums, int target) {
int n = nums.size();
vector<int>ans = { -1,-1 };
if(n==0) return ans;
int l = 0, r = n;
while (l<r)
{
int mid = (l + r) >> 1;
if (nums[mid] >= target) r = mid;
else l = mid + 1;
}
if(l == n) ans[0] = -1;
else if (nums[l] == target) ans[0] = l;
int c = 0, d = n ;
while (c < d)
{
int mid = (c + d) >> 1;
if (nums[mid] <= target) c = mid+1;
else d = mid;
}
if (c == 0) ans[1] = -1;
else if (nums[c-1] == target) ans[1] = c-1;
return ans;
}
四.二分法的更多习题
搜索旋转排序数组
搜索旋转排序数组的思路是,**先使用二分法区分出哪边是递增区间,之后再继续使用二分法进行查找目标值。**将数组一分为二,其中一定有一个是有序的,另一个可能是有序,也能是部分有序。此时有序部分用二分法查找。无序部分再一分为二,其中一个一定有序,另一个可能有序,可能无序。就这样进行循环即可。
int search(vector<int>& nums, int target) {
int l = 0, r = nums.size() - 1;
while (l<=r)
{
int mid = l + (r - l) / 2;
if (nums[mid] == target) return mid;
else if (nums[mid] >= nums[l]) {
if (nums[mid] > target&&nums[l]<=target) r = mid-1;
else l = mid + 1;
}
else if (nums[mid] < nums[l]) {
if (nums[mid] < target&&nums[r]>=target) l = mid + 1;
else r = mid-1;
}
}
return -1;
}
寻找旋转排序数组中的最小值2
本题属于寻找旋转排序数组中的最小值的进阶,区别就是有无重复元素,之前我们也分析过了,有无重复元素对于二分法还是非常有影响的。这里我们对之前的代码进行修改,主要看是否可以解决两端相等的情况,在下面代码中,我保持右端点不变,左端点的操作分为两种情况,分别是二分和加一。当相等的时候,就继续向前走加一,知道出现不同的元素,即可判断。不过这个方法最差情况是O(n),这还算不算二分法。。。
int findMin(vector<int>& nums) {
int left = 0, right = nums.size() - 1;
while (left < right) {
int mid = left + (right - left) / 2;
if (nums[left] < nums[right]) { return nums[left]; }
else if (nums[mid] > nums[left])left = mid + 1;
else left++;
}
return nums[left];
}