【ps】本篇有 8 道 LeetCode OJ
目录
[.1- 题目解析](#.1- 题目解析)
[.2- 代码编写](#.2- 代码编写)
[.1- 题目解析](#.1- 题目解析)
[.2- 代码编写](#.2- 代码编写)
[.1- 题目解析](#.1- 题目解析)
[.2- 代码编写](#.2- 代码编写)
[4)x 的平方根](#4)x 的平方根)
[.1- 题目解析](#.1- 题目解析)
[.2- 代码编写](#.2- 代码编写)
[.1- 题目解析](#.1- 题目解析)
[.2- 代码编写](#.2- 代码编写)
[.1- 题目解析](#.1- 题目解析)
[.2- 代码编写](#.2- 代码编写)
[.1- 题目解析](#.1- 题目解析)
[.2- 代码编写](#.2- 代码编写)
[.1- 题目解析](#.1- 题目解析)
[.2- 代码编写](#.2- 代码编写)
一、算法简介
二分查找是一种高效的查找方法,其时间复杂度为 O(logN),主要针对于有序的顺序存储结构(无序有时也行,但是要有二段性),使用时的细节较多,但使用的套路相比其他算法更容易被摸清。
二、相关题目
1)基础的二分查找
.1- 题目解析
这是一道基础的二分查找题目,我们借由这道题来详细讲述二分查找的细节,和总结二分查找的算法模板。
如果使用暴力解法,将所有数组元素跟 target 比较,确实能够解出答案,但其时间复杂度为 O(n)。通过观察不难发现,要在这个升序数组中查找 target ,target 就会将这个升序数组分为两部分,target 的左边全是比 target 小的数,target 的右边全是比 target 大的数,此时这个升序数组就具备了二段性,也就是二分查找所必要的性质。
所谓二分,也就是将一个区间不断地分成两部分,使其中某一部分不断逼近期望的结果。
要实现对一个区间进行划分的操作,一般要用到三个指针:
- left:位于区间左端。
- right:位于区间右端。
- mid:始终位于 left 和 right 的中间位置。
对于本题而言,这三个指针会将升序数组划分成两个子数组,其中,mid 的左侧都是比 mid 小的数,mid 的右侧都是比 mid 大的数,此时只需确定 target 是比 mid 小,还是比 mid 大,即可进行下一步划分------如果 target 比 mid 小,说明 target 在 mid 左侧的子区间中,就需要继续对 mid 左侧的子区间进行划分,具体的方式是让此时的 mid 作为新的 right,left 不变,然后用 left 和 right来确定新的 mid;如果 target 比 mid 大,说明 target 在 mid 右侧的子区间中,就需要继续对 mid 右侧的子区间进行划分,具体的方式是让此时的 mid 作为新的 left,right 不变,然后用 left 和 right来确定新的 mid------从而逐步找到 target。
【Tips】基础的二分模板
cppwhile(left<=right) //二分的结束条件 { int mid=left+(right-left)/2; //防溢出的写法 if(...) left=mid+1; else if(...) right=mid-1; else return ...; }
【Tips】关键步骤:
- 找出区间的二段性;
- 根据二段性,确定 left 和 right 的调整条件与其相应的调整方式;
- 确定 mid 的计算式;
- 确定二分的结束条件。
.2- 代码编写
cpp
class Solution {
public:
int search(vector<int>& nums, int target) {
//1.定义分别指向区间两段的 left 和 right 指针
int left=0,right=nums.size()-1;
//2.进行二分查找
while(left<=right) //二分的结束条件
{
//1)确定新的mid
int mid=left+(right-left)/2;
//2)根据mid所指的值和target的大小关系,确定要继续二分的子区间
if(nums[mid]<target)left=mid+1;
else if(nums[mid]>target)right=mid-1;
else return mid; //当mid所指的值和target相等时,则说明此时target的下标即为mid
}
return -1;
}
};
2)在排序数组中查找元素的第一个和最后一个位置
34. 在排序数组中查找元素的第一个和最后一个位置 - 力扣(LeetCode)
.1- 题目解析
由题,要返回一个升序数组中所有值为 target 的元素区间的起始下标和结束下标。
由于这是一个升序数组,那么所有值为 target 的元素会在数组中构成一个子区间,因此,本题既需要在找到子区间的左端点,又要找到子区间的右端点。
对于子区间的左端点,升序数组能够被 target 分为两部分,一部分是小于 target 的数,另一部分是大于等于 target 的数。
由此,继续划分子区间时,left 和 right 的调整方式应为;
那么, mid 的计算式其实应为 left + (right - left) / 2,这样,当数组长度为偶数时,可以保证 mid 落在所有值为 target 的元素区间的左端点上。
而二分的结束条件也应为 left < right,而非 left <= right。这是因为,在 left 和 right 的调整方式中,left 的移动始终会跳出小于 target 的区间,right 的移动始终在大于等于 target 的区间上,当 left == right, left 和 right 会指向同一个位置,即 target 的位置。
对于子区间的右端点,同理,升序数组能够被 target 分为两部分,一部分是小于等于 target 的数,另一部分是大于 target 的数。
由此,继续划分子区间时,left 和 right 的调整方式应为;
同理,mid 的计算式应为 left + (right - left + 1) / 2 ,而二分的结束条件也应为 left < right。
.2- 代码编写
cpp
class Solution {
public:
vector<int> searchRange(vector<int>& nums, int target) {
if(nums.size()==0) return {-1,-1};
//二分查找左端点
int left=0,right=nums.size()-1,begin=0;
while(left<right)
{
int mid=left+(right-left)/2;
if(nums[mid]<target) left=mid+1;
else right=mid;
}
//保存左端点
if(nums[left]!=target)return {-1,-1};
else begin=left;
//二分查找右端点
left=0,right=nums.size()-1;
while(left<right)
{
int mid=left+(right-left+1)/2;
if(nums[mid]<=target) left=mid;
else right=mid-1;
}
//返回左右端点
return {begin,left};
}
};
3)搜索插入位置
. - 力扣(LeetCode)35. 搜索插入位置. - 力扣(LeetCode)
.1- 题目解析
由题,这个升序数组可以被 target 划分为两部分,一部分是小于 target 的数,另一部分是大于等于 target 的数。
由此可得:
- left 和 right 的调整条件:target 在 mid 的右边,则调整 left;target 位于 mid 或在 mid 的左边,则调整 right。
- left 和 right 的调整方式:left = mid + 1;right = mid。
- mid 的计算式:left + (right - left) / 2,落在左端点。
- 二分的结束条件:left < right;当 left == right 时,left、right、mid 重叠位于 target 或 target 左侧。
.2- 代码编写
cpp
class Solution {
public:
int searchInsert(vector<int>& nums, int target) {
int left=0,right=nums.size()-1;
while(left<right)
{
int mid=left+(right-left)/2;
if(nums[mid]<target) left=mid+1;
else right=mid;
}
if(nums[left]<target)return left+1;//特别处理
return left;
}
};
4)x 的平方根
.1- 题目解析
求非负整数 x 的平方根,其实可以转化为求一个数的平方等于 x。
假设 x 的平方根为 mid,那么 mid 一定是一个在 [0,x] 区间上的数,且这个区间天然是升序的,mid 也能够将这个区间划分为 [0,mid) 和 (mid,x] 两个子区间。
至此,本题就转化成了,在 [0,x] 区间上找一个数的平方等于 x,可以使用二分查找来解题。
这里,0 的平方根一定是 0,0 的平方也一定是 0,因此 0 作特别处理。
由题目示例,mid 的平方一定是小于等于 x 的,因此,mid 的平方可以将 [1,x] 区间划分为两个部分,一部分是小于等于 mid 的平方的数,另一部分是大于 mid 的平方的数。
由此可得:
- left 和 right 的调整条件:x 位于 mid 的平方或在 mid 平方的右边,则调整 left;x 在 mid 的平方的左边,则调整 right。
- left 和 right 的调整方式:left = mid;right = mid - 1。
- mid 的计算式:left + (right - left + 1) / 2,落在右端点。
- 二分的结束条件:left < right;当 left == right 时,left、right、mid 重叠。
.2- 代码编写
cpp
class Solution {
public:
int mySqrt(int x) {
if(x<1)return 0; //特别处理
int left=1,right=x;
while(left<right)
{
long long mid=left+(right-left+1)/2; //防溢出
if(mid*mid<=x) left=mid;
else right=mid-1;
}
return left;
}
};
5)山脉数组的峰顶索引
.1- 题目解析
由题,在一个数组中,存在一个数是数组中最大的,且它左边的数是升序排列的,它右边的数则是降序排列的,题目要求找出这个数并返回这个数的下标。
显然,这样一个数组是具有二段性的,可以被其中的最大值分为两个部分,一部分是小于等于它的数,另一部分是大于它的数。
设 left 为升序序列的左端点,right 为降序序列的右端点,mid 是 left 和 right 的中间位置,由此可得:
- left 和 right 的调整条件:mid 的值大于 mid - 1,说明 mid 处于升序序列中,则需调整 left 来继续划分;mid 的值小于 mid - 1,说明 mid 处于降序序列中,则需调整 right 来继续划分。
- left 和 right 的调整方式:left = mid;right = mid - 1。
- mid 的计算式:left + (right - left + 1) / 2,落在右端点。
- 二分的结束条件:left < right;当 left == right 时,left、right、mid 重叠。
.2- 代码编写
cpp
class Solution {
public:
int peakIndexInMountainArray(vector<int>& arr) {
int left=0,right=arr.size()-1;
while(left<right)
{
int mid=left+(right-left+1)/2;
if(arr[mid]>arr[mid-1])left=mid;
else right=mid-1;
}
return left;
}
};
6)寻找峰值
.1- 题目解析
符合题目要求的峰值一共有三种情况:
- 峰值位于一共升序序列和降序序列之间;
- 峰值位于一个升序序列的末尾;
- 峰值位于一个降序序列的起始。
对这三种情况进一步分析,就会发现它们都存在二段性:
对于"峰值位于一共升序序列和降序序列之间"的情况,自然不必多讨论,一定是具有二段性的;对于一个升序序列,序列的右侧一定存在峰值,左侧则不一定存在峰值;而对于一个降序序列,序列的左侧一定存在峰值,右侧则不一定存在峰值。
总结一下:升序序列的右侧一定存在峰值,降序序列的左侧一定存在峰值。
那么,我们就可以用二分查找算法来解题了。当前值大于后值时,即序列为降序,则降序序列左侧有峰值,继续去序列左侧进行二分;当前值小于后值时,即序列为升序,则升序序列右侧有峰值,继续去序列右侧进行二分。
.2- 代码编写
cpp
class Solution {
public:
int findPeakElement(vector<int>& nums) {
int left=0,right=nums.size()-1;
while(left<right)
{
int mid=left+(right-left)/2;
if(nums[mid]>nums[mid+1])right=mid;
else left=mid+1;
}
return left;
}
};
【Tips】 mid 的计算式中是否 + 1,与 left 调整时是否 + 1、right 调整时是否 - 1 有关。一般可以记忆为,若 left = mid + 1 则计算 mid 不 + 1,若 right = mid - 1 则计算 mid 要 + 1。
7)寻找旋转排序数组中的最小值
153. 寻找旋转排序数组中的最小值 - 力扣(LeetCode)
.1- 题目解析
所谓的旋转,其实就是依此将原数组末尾的数放到数组的头部。
由于原数组是升序的,因此旋转后的数组中将包含两个升序子数组,即具有了二段性,且特别的,前一个升序的子数组中所有数,都大于后一个升序的子数组中所有数。
而这个旋转数组中的最小值,其实就是后一个升序子数组中的第一个数,因此只需用二分查找找到后一个升序子数组中的第一个数即可。
.2- 代码编写
cpp
class Solution {
public:
int findMin(vector<int>& nums) {
int n=nums.size();
int left=0,right=n-1;
while(left<right)
{
int mid=left+(right-left)/2;
if(nums[mid]>nums[n-1])left=mid+1;
else right=mid;
}
return nums[left];
}
};
8)点名
.1- 题目解析
由题,缺席的同学,其学号没有在升序数组中,在缺席的学号之前,都是数组元素的值等于元素下标,也就是说,学号其实是数组元素的值,只要元素的值等于元素下标,就说明该学号没有缺席。
而从缺席的学号开始,所有元素的值都不等于元素下标了,也就是说,如果元素的值不等于元素下标,就说明这个元素下标对应的学号是缺席的,且缺席的学号应该是第一个不等于元素值的元素下标。
因此,这个升序数组其实是具有二段性的,它被缺席的学号划分为两个子数组,前一个子数组的元素值都等于元素下标,后一个数组的元素值都不等于元素下标。那么,我们只需找到后一个子数组中的首元素,即只需找到第一个不等于元素值的元素下标。
但本题存在一个特殊情况,那就是所有学号都未缺席,例如:
此时应返回的是,数组尾部的下一个位置,因为数组尾部的下一个位置在数组中是缺席的。
.2- 代码编写
cpp
class Solution {
public:
int takeAttendance(vector<int>& records) {
int left=0,right=records.size()-1;
while(left<right)
{
int mid=left+(right-left)/2;
if(mid==records[mid])left=mid+1;
else right=mid;
}
if(left==records[left])return left+1;//如果 left==right,说明当前整个数组中的学号都没缺席,因此要返回下一个位置
return left;
}
};