目录
二分查找理论分析
听到二分查找我们自然会想到数学里面寻找函数图像零点的二分法。数学中类似于给出一个区间,然后把区间从中间分开,此时由于函数一般给出的是单调的,所以我们只需要判断中间那个函数值于零的大小关系,就可以进一步缩小区间,再继续进行二分及判断了,最后直至区间无限缩小逼近那个零点或者直接找出零点。
二分查找也是完全一样的,一定要保证数组的单调。我们首先要确定一个区间,一般有左闭右闭和左闭右开两种情况。我们就这两种情况依次进行分析。
给出一个长度为len单调递增数组,要求查找数组中的3这个元素,返回目标索引,若不存在,则返回-1。我们先以左闭右闭为例。
定义左右两个指针left和right(这里的指针其实是指索引值)分别指向0和len-1;我们取中间值mid为(left+right)/2,在这里得到mid=3,即中间的索引值是3,指向4这个元素。然后我们将4的大小与要寻找的3作比较,发现3比4小,在4的左边,那么为了进行下一次二分,我们需要移动右指针right来缩小区间。如何移动right便成为了关键。
左闭右闭区间
我们知道因为现在在讨论左闭右闭的情况,所以right初始指向的是数组中取得到的值的索引 len-1,那么现在我们猜猜right是应该指向mid,还是应该指向mid-1呢?应该是mid-1。其实不难理解,我们取到中间值mid的时候就已经将其对应于数组的值和3进行了一次比较,不相等才会有缩小区间的操作。所以right自然应该向左进一位。
接着我们再进行取mid,比较的操作。
mid对应的2比3小,接着我们就能顺其自然地让left右移至mid+1的位置。
此时mid指向了目标值3,那么我们直接返回其索引就便结束了这一系列操作。但最后我们还有一件事没考虑:不断地二分查找自然要通过循环进行实现,那么循环的出口应该怎么设定呢?上面的情况是我们mid直接能找到对应目标值而能返回索引,那如果找不到目标值,怎么退出循环返回-1呢?一种可行的方法就是while循环,条件设置为left<=right。为什么是<=呢?其实是遵循左闭右闭区间的原则。一方面既然left属于数组中的内容,right也属于数组中的内容,那么最后区间的确定是[2,2]呢,还是[2,2)呢?显然只有前者符合数学规定。另一方面,若设置为left<right,那么就忽略了left==right的情况,如果这时候mid==left==right时对应的数组值才是目标值呢?那显然就少了一种讨论的情况。
左闭右开区间
和左闭右闭很类似,我们现在只简单论述下这里和上面的不同之处。
首先left指针依然为0,但right要初始化为len。毕竟左闭右开嘛,说明右边的区间无法取到。
其次mid=(left+right)/2之后,若移动左区间,那么left=mid+1和上述类似,而若移动右区间,则right=mid而不是mid-1。因为right代表取不到的右区间。
最后循环条件要改成left<right。因为嘛,左闭右开。
局部优化
mid=(left+right)/2 与 mid=left+(right-left)/2 是一样的吗?
数学角度来说是完全一样的,化个简就能得到相同的结果。但是代码在实际运行上可能会有差异。
比如我们定义int类型的mid,但如果left+right的得数极大,可能会造成数据溢出的现象,影响结果,所以保险起见,我一般会用后者,另外位运算(right-left)>>1和(right-left)/2是等价的,但听说位运算更快一些,实际实现起来的话,我好像也没看到太大的效率优化。
计算时间复杂度
设数组有n个元素,搜索的最坏情况即一直二分直到left==right找到目标值。则区间逐步被缩小为n/2,n/4,n/8.....n/2^k(k为二分查找次数),最后区间只包含了目标值一个元素,即
1=n/2^k,计算得k=log2n(以2为底n的对数),则时间复杂度为O(logn)。
二分查找代码实现
//左闭右闭写法
int BinarySearch(int* nums, int target, int len){
int left = 0, right = len - 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{
return mid;//返回目标索引
}
}
return -1; //找不到返回-1
}
//左闭右开写法
int BinarySearch(int* nums, int target, int len){
int left = 0, right = len;//初始左右指针
while(left < right){
int mid = left + (right - left) / 2;
if(nums[mid] > target){
right = mid;//移动右区间
}
else if(nums[mid] < target){
left = mid + 1;//移动左区间
}
else{
return mid;//返回目标索引
}
}
return -1; //找不到返回-1
}
//递归写法(左闭右闭)
int BinarySearch(int* nums, int target, int left, int right){//left和right需要手动传参
while(left <= right){
int mid = left + (right - left) / 2;
if(nums[mid] > target){
return BinarySearch(nums, target, left, mid - 1);
}
else if(nums[mid] < target){
return BinarySearch(nums, target, mid + 1, right);
}
else{
return mid;
}
}
}
相关题目题解
1.搜索插入位置(力扣35)
给定一个排序数组(即单调数组)和一个目标值,在数组中找到目标值,并返回其索引。如果目标值不存在于数组中,返回它将会被按顺序插入的位置。
你可以假设数组中无重复元素。
示例 1:
- 输入: [1,3,5,6], 5
- 输出: 2
示例 2:
- 输入: [1,3,5,6], 2
- 输出: 1
示例 3:
- 输入: [1,3,5,6], 7
- 输出: 4
示例 4:
- 输入: [1,3,5,6], 0
- 输出: 0
请必须使用时间复杂度为 O(log n)
的算法。
思路
我们首先需要遍历整个数组搜索目标值,此题给出时间复杂度需为O(logn),那我们就可以用二分查找来遍历,关键点在于目标值不在数组中时如何返回该值要插入的位置。
根据例子我们不难发现:最后一次循环里面如果目标值大于mid对应值,插入位置就是mid+1,如果目标值小于mid对应值,插入位置就是mid。
若目标值不在数组内,最后一次进入循环时,left==right,mid==left==right,此时有两种情况,第一种:nums[mid]>target,那么说明目标值在mid索引的左边,进行了操作right=mid-1,left不变,插入位置应为当前的mid索引,即我们应该返回left或者right+1。第二种:nums[mid]<target,那么说明目标值在mid索引的右边,此时进行了操作left=mid+1,right不变,插入位置应为mid+1,即我们应该返回left或者right+1。综上所述,返回left和right+1都是可以的。
代码实现
int searchInsert(int* nums, int numsSize, int target) {
int left = 0, right = numsSize - 1;
while(left <= right){
int mid = (right + left) / 2;
if(nums[mid] > target){
right = mid - 1;
}
else if(nums[mid] < target){
left = mid + 1;
}
else{
return mid;
}
}
return left;
}
2.x的平方根(力扣69)
给你一个非负整数 x
,计算并返回 x
的 算术平方根 。
由于返回类型是整数,结果只保留 整数部分 ,小数部分将被 舍去 。
注意:不允许使用任何内置指数函数和算符,例如 pow(x, 0.5)
或者 x ** 0.5
。
示例 1:
输入:x = 4 输出:2
示例 2:
输入:x = 8 输出:2 解释:8 的算术平方根是 2.82842..., 由于返回类型是整数,小数部分将被舍去。
思路
这道题目看似和二分查找无关,但我们细细品一品:除开0和1,其他非负整数x的算数平方根取整后都在1和x之间吧(写更精准点是1到x/2),那么我们把非0和1的二分查找区间定为1到x,不断二分查找是不是也能得到最后的结果呢。然而难点依然在于如何返回值。如果要算4的算数平方根,在查找过程中直接能找到2*2==4而返回2,那么碰到3的算数平方根这种呢?
我们首先应该明白一个前提,取整的算数平方根就是其平方数小于等于目标值的最大的数,如根号3的取整算术平方根是1,而1*1<3,2*2>3,所以当取到2时我们应该减去1。最后一次循环代表区间与最终结果的最大误差都不小于1,所以我们从最后一次进入循环来分析。进入循环后left==right==mid。一种情况如果mid*mid>x,说明mid是所有平方数比x大的数中的最小数,程序进行操作right=mid-1,left不变,我们应该返回mid-1,即返回left-1或者right。另一种情况如果mid*mid<x,说明mid是平方数小于x的数中的最大值,程序进行left=mid+1,right不变,我们应该返回mid,即返回left-1或者right。
代码实现
int mySqrt(int x) {
if(x == 0 || x == 1){//0和1的算数平方根是它们本身
return x;
}
int left = 1, right = x;//区间更精确:right=x/2
while(left <= right){
int mid = left + (right - left) / 2;
if((long long)mid * mid == x){//强制类型转换也是为了防止数据过大溢出
return mid;
}
else if((long long)mid * mid < x){
left = mid + 1;
}
else{
right = mid - 1;
}
}
return left - 1;
}
3.有效的完全平方数(力扣367)
给你一个正整数 num
。如果 num
是一个完全平方数,则返回 true
,否则返回 false
。
完全平方数 是一个可以写成某个整数的平方的整数。换句话说,它可以写成某个整数和自身的乘积。
不能使用任何内置的库函数,如 sqrt
。
示例 1:
输入:num = 16 输出:true 解释:返回 true ,因为 4 * 4 = 16 且 4 是一个整数。
示例 2:
输入:num = 14 输出:false 解释:返回 false ,因为 3.742 * 3.742 = 14 但 3.742 不是一个整数。
思路
其实这题和上一题极其类似,仅仅是一题求解,一题判断。另外要注意把特殊情况单列出来,在某些数据的判断上可能会更快。这里我们就用right=x/2来写。
代码实现
bool isPerfectSquare(int num) {
if(num == 1){//单独列出特殊情况
return true;
}
int left = 0,right = num / 2;
while(left <= right){
int mid = left + (right - left) / 2;
if((long long)mid * mid == num){
return true;
}
else if((long long)mid * mid < num){
left = mid + 1;
}
else{
right = mid - 1;
}
}
return false;
}
4.在排序数组中查找元素的第一个和最后一个位置(力扣34)
给你一个按照非递减顺序(即递增顺序)排列的整数数组 nums
,和一个目标值 target
。请你找出给定目标值在数组中的开始位置和结束位置。
如果数组中不存在目标值 target
,返回 [-1, -1]
。
你必须设计并实现时间复杂度为 O(log n)
的算法解决此问题。
示例 1:
输入:nums = [5,7,7,8,8,10]
, target = 8
输出:[3,4]
示例 2:
输入:nums = [5,7,7,8,8,10]
, target = 6
输出:[-1,-1]
示例 3:
输入:nums = [], target = 0 输出:[-1,-1]
思路
初步审题,我们大概能知道需要通过遍历数组来获取数组中同一个数第一次和最后一次出现的位置,根据时间复杂度的要求,我们考虑二分查找法。不过我们已知的二分查找都是在数组中找到一个目标值的索引,那么如何记录目标值出现的那一段的头和尾呢?一种方法就是先正常使用二分查找找到这一段目标值中任意一个索引位置,然后以这一点为基准分别向左和向右不断二分寻找头和尾的端点。
代码实现
int* searchRange(int* nums, int numsSize, int target, int* returnSize) {
int* result = (int*)malloc(sizeof(int) * 2);//给result数组分配空间
int left = 0, right = numsSize - 1, first = -1, last = -1;//分别记录头和尾的位置
while(left <= right){
int mid = left + (right - left) / 2;
if(nums[mid] == target){
//重点:不断更新头的位置,并左移右区间,向左查找
first = mid;
right = mid - 1;
}
else if(nums[mid] > target){
right = mid - 1;
}
else{
left = mid + 1;
}
}
left = 0, right = numsSize - 1;
while(left <= right){
int mid = left + (right - left) / 2;
if(nums[mid] == target){
//重点:不断更新尾的位置,并右移左区间,向右查找
last = mid;
left = mid + 1;
}
else if(nums[mid] > target){
right = mid - 1;
}
else{
left = mid + 1;
}
}
*returnSize = 2;//返回数组的长度
result[0] = first;
result[1] = last;
return result;
}