【从零开始】二分法的妙用

本文从零开始介绍二分查找,详细探讨了二分查找的细节,包括左闭右开和左闭右闭的区别,以及在不同情况下的区间选择。文章通过分析LeetCode题目,如二分查找、搜索插入位置、平方根求解等,巩固了二分法的理解,并进一步讨论了在旋转数组和存在重复元素时如何应用二分查找。

摘要生成于 C知道 ,由 DeepSeek-R1 满血版支持, 前往体验 >

写在前面

从零开始的刷题之旅,首先是最基础的数组。

而在数组中,二分查找是一个非常常见的问题

除了我们常见的需要找到某一数之外,还有一些变体比如寻找边界,数组旋转等等。这次就将他们一网打尽。

首先根据一道题来摸清楚二分查找最基础的框架

leetcode 704 二分查找

这样的基础框架是很好写的,只是需要注意细节。

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的平方根

也有一些比较巧妙的数学问题。

leetocde 69 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;
}

有两个点需要想清楚:

  1. 当nums[mid]==target时,缩小了右边界,继续在左边搜索有没有这个值。
  2. 最后return left,这里画一下就明白了,可以理解为求左边界就返回左。

举个例子:[5,7,7,8,8,10],target是8,我们来求左边界。

  1. mid是(0+5)/2=2,也对应数字7,此时left=2+1,对应第一个数字8
  2. 第二次mid是(3+5)/2=4,对应第二个数字8,这时与target相等,缩小右边界。right指向第一个数字8
  3. 此时left和right重合,但是循环继续。mid对应的值还是与target相同,所以right指向最后一个7
  4. 这时退出循环,左边界就是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

注意防止越界的限定条件,不然会出现一些特殊情况无法通过。

旋转数组

还有一类题目,是把排序数组旋转后求解。

leetcode 33 搜索旋转排序数组

这个题就是[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;
		}
	}
}

简单总结:

  1. 如果mid比左边界大(或者等于,很重要,因为mid可能落在边界上),那么左边有序
    1. 如果target在这个范围,那么就进行二分查找
    2. 如果target不在,就去另一边一分为二,找另一个一定有序的部分
  2. 如果mid比左边界小,那么右边有序
    1. 如果target在这个范围,那么就进行二分查找
    2. 如果target不在,就去另一边一分为二,找另一个一定有序的部分

因为一分为二的过程和有序部分二分的过程一致,所以在代码上看不出区别。

另外注意一定要在判断有序时加上等于号(哪一边都可以)

有重复数的旋转数组

我们前面说的旋转数组是不会有重复数的,而这道题允许有重复。

leectode 81 搜索旋转数组2

题目说数组中的值不必互不相同。

这就出现一个问题,当边界值与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;
		}
	}
}

有两点需要注意:

  1. 上一道题在判断时也会有等于号,nums[left]<=nums[mid],但这种情况对于不同的数来说只有mid和left重合才会出现。这道题需要单独判断
  2. 为什么是左边界?其实左右边界都判断也可以,但是我们可以自行举例来看,其实是不存在右边界与mid相等,左边界与mid不等,并且无法判断哪边有序的情况。(但凡无法判断了都与左边界有关)

旋转数组最小值

这是一道变体,也是二分查找模板需要变通的一道题

leetcode 153 寻找旋转排序数组中的最小值

首先明确了是一个元素互不相同的升序排列的数组。核心在于与右边边界比大小

经过多次旋转后,我们来举个例子看它的变化。

如果是[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时就找到了最小值。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值