详解二分查找有序数组及其变种

179 阅读6分钟

开启掘金成长之旅!这是我参与「掘金日新计划 · 12 月更文挑战」的第1天,点击查看活动详情

二分查找也称折半查找(Binary Search),是一种在有序数组中查找某一特定元素的搜索算法。我们可以从定义可知,运用二分搜索的前提是数组必须是有序的。如果目标数组是无序的,我们首先可以对它进行排序操作后再进行二分查找去找出我们需要的目标值。

二分查找法的时间复杂度是O(logn)。首先,我们先来分析一下如何得到这个时间复杂度的。假设我们需要查找的这个有序数组vec的元素个数为n个。显而易见,如果我们用普通的暴力解法的话,我们需要遍历数组,让数组中的每个元素与目标值对比,这种方法的时间复杂度是O(n)。而二分查找的思想是用这个有序数组的中间值进行查找,要得到此有序数组的中间值,我们首先要得到这个有序数组首元素的数组下标left和最后一个元素的数组下标right。很简单,首元素的数组下标为left=0,尾元素的数组下标为right=vec.size()-1;我们可以得到此有序数组的中间元素vec(right-left)/2+left。我们用数组的中间元素vec[mid]与要查找的目标元素target作比较,如果等于vec[mid]等于target的话就返回true,如果vec[mid]大于target的话,说明target可能在这个有序数组的左边,我们就令right=mid-1,来缩小需要查找数组的范围,然后再进行mid=(right-left)/2+left操作。注意,我们已经将原来需要查找的n个元素缩小到n/2个元素;反之,vec[mid]小于target,我们去vec[mid]右边进行查找,继续进行mid=(right-left)/2+left,看vec[mid]和target的关系。

理解了二分查找的基本思路了吧!!!我们现在来想一下这个O(logn)是怎么来的。二分查找是我们用数组的首尾元素取中点,将范围缩小了n/2,然后再缩小现在的n/2,此时,当前元素只剩原来元素的n/4了,想一下如果我们一直没有找到,然后就一直二分下去,每一次都将数组查找范围的 范围的一半。我们列一个公式来分析一下:设一共查找了t次,则2的次方是我们查找元素的个数

image.png

我们就得到了二分法的时间复杂度O(logn)了。

1.基本的二分查找

我们来写一下这个代码:

//vec为要查找的有序数组,target为要查找的目标元素 
bool  binarySearch(vector<int>vec,int target)
{
        int left=0;//数组第一个元素的下标
        int right=vec.size()-1;//数组最后一个元素的下标
        while(left<=right)
        {
            int mid=(right-left)/2+left;//求中间元素的下标 
            if(vec[mid]==target)
                return true;
            else if(vec[mid]>target)//如果大于目标值,向左走,缩小查找范围
                right=mid-1;
            else//如果小于目标值,向右走,缩小查找范围
                left=mid+1;
        }
        return false;
} 

下面我们用图示来走一下这个代码的流程: image.png 上面是最基本的二分查找有序数组,二分查找有序数组还有许多的变种,大致可以分为三种完全有序、不完全有序、二维数组。 接下来我们根据这三种情况来逐一进行分析。

2.二分查找有序数组(存在重复值)

设想,我们要查找的目标值target在目标数组vec中是重复的,现在我们要查找target的第一次出现时的下标,我们依旧使用二分查找法的话要怎么去限定条件呢。很明显,在去像以前一样去改变left和right的值已经意义不大了,也不能当target==vec[mid]时去返回mid了,因为我们无法确定此时target是重复值当中的第几个。

上面我们说过二分查找法是一次一次缩小范围。同样的,我们也在查找重复值中也可以不断缩小范围,直至到边界失去target==vec[mid]这个条件时,我们返回这个mid下标。

查找重复值的第一个下标

int BinarySearchFirst(vector<int>&vec, int target)
{
	int left = 0;
	int right = vec.size() - 1;
	while (left <= right)
	{
		int mid = (right - left) / 2 + left;
		if (vec[mid] >= target)//不断缩小范围,当存在vec[mid]==target时,我们不断向左边界逼近
		{
			right = mid - 1;
		}
		else//当vec[mid]小于target时,移动left
			left = mid + 1;
	}
	if (left < vec.size()&&vec[left] == target)//在未越界的情况下,找到target时,返回下标left
		return left;
	else
		return -1;
		
}


由最基本的二分查找法知,当vec[mid]>target时,right=mid-1。不断向mid左边找。同样的,当vec[mid]==target时,我们也可以令right=mid-1,有重复值时也不断逼近。

同理,查找最后一个重复值的下标时,我们可以不断向右边逼近。

查找重复值的最后一个下标

int BinarySearchLast(vector<int>&vec, int target)
{
	int left = 0;
	int right = vec.size() - 1;
	while (left <= right)
	{
		int mid = (right - left) / 2 + left;
		if (vec[mid] <= target)//不断缩小范围,当存在vec[mid]==target时,我们不断向右边界逼近
		{
			left = mid + 1;
		}
		else
			right = mid - 1;
	}
	if (right <vec.size()&&vec[right] == target)//在未越界的情况下,找到target时,返回下标right
		return right;
	else
		return -1;
}

下面,我们来演示一下

image.png

3.二分查找不完全有序数组

1.不存在重复值

已知一个有序数组nums 在预先未知的某个下标 k(0 <= k < nums.length)上进行了 旋转,使数组变为 [nums[k], nums[k+1], ..., nums[n-1], nums[0], nums[1], ..., nums[k-1]](下标 从 0 开始 计数)。例如, [0,1,2,3,4,5,6,7] 在下标 3 处经旋转后可能变为 [4,5,6,7,0,1,2,3] 。现在我们要寻找此数组里面的一个元素target,并返回它的下标。

可以知道的是,目前这个数组是一个部分有序数组,前面4,5,6,7是有序的;后面1,2,3是有序的。按照二分法的思想,我们可以得到vec[mid],得到vec[mid]之后,我们可以判断vec[mid]是落在前一部分的有序数组还是后一部分的有序数组当中,从而对双指针的left和right进行操作,从而缩小范围。

我们可以得到数组的第一个元素值和最后一个元素值,从而判断vec[mid]落在了哪里。当vec[mid]>=vec[0]时,我们可以知道vec[mid]落在了前一部分的有序数组当中;而vec[mid]<vec[0]的时候,vec[mid]便落在了后一部分的有序数组当中。我们再使用target对这两部分中的数组进行判断操作left和right的值,从而得到下标。

那么我们如何操作呢?我们来举个例子。vec={5,6,7,8,0,1,2,3,4},target=7;

首先,我们可以知道vec[mid]落在了第二部分的有序数组当中,因为我们要的目标值target是7,它在第一部分,所以,我们在这一部分要做的就是想办法尽量讲范围向左边移,我们可以先判断target是否在vec[mid]到vec[n-1]之间,如果不在的话就将right=mid-1,自然而然的就移动到了第一部分。

现在,我们来到了第一部分的有序数组,假如target在vec[0]到vec[mid]之间的话,我们要做的就是将将范围继续向左缩小,即right=mid-1。

这样,我们便在不断缩小中找到了目标值。

int search(vector<int>& vec, int target) {
	int n = vec.size();
	int left = 0;
	int right = n - 1;
	while (left<=right)
	{
		int mid = (right - left) / 2 + left;
		if (vec[mid] == target)
			return mid;
		else if (vec[mid] >= vec[0])//判断vec[mid]落在了哪一部分
		{
			if (target < vec[mid]&&target>=vec[left])//判断target是否在目前范围当中
				right = mid - 1;
			else
				left = mid + 1;
		}
		else
		{
			if (target<=vec[right]&&target>vec[mid])
				 left = mid + 1;
			else
				right = mid - 1;
		}
	}
	return -1;
    }

2.存在重复值

当旋转数组中存在重复值时,上面那种情况就不适用了,具体是这种情况vec={1,0,1,1,1},target=0。所以我们只需跳过重复值即可。

当旋转数组中存在重复值时,上面那种情况就不适用了,具体是这种情况vec={1,0,1,1,1},target=0。所以我们只需跳过重复值即可。

    bool search(vector<int>& vec, int target) {
	int n = vec.size();
	int left = 0;
	int right = n - 1;
	while (left<=right)
	{
        while(left<right&&vec[left]==vec[left+1]) ++left;
        while(left<right&&vec[right]==vec[right-1]) --right;
		int mid = (right - left) / 2 + left;
		if (vec[mid] == target)
			return true;
		else if (vec[mid] >= vec[0])
		{
			if (target < vec[mid]&&target>=vec[left])
				right = mid - 1;
			else
				left = mid + 1;
		}
		else 
		{
			if (target<=vec[right]&&target>vec[mid])
				 left = mid + 1;
			else
				right = mid - 1;
		}
            
	}
	return false;
    }

3.二分查找二维数组(存在重复值)

一个 m x n 矩阵中,是否存在一个目标值。该矩阵具有如下特性:1.每行中的整数从左到右按升序排列。2.每行的第一个整数大于前一行的最后一个整数。

这个题,我们可以根据这个矩阵的性质进行操作。因为每行中的整数从左到右按升序排列且每行的第一个整数大于前一行的最后一个整数。这就说明每个二维数组的首元素也是从小到大依次排列的。所以,我们可以根据这个性质,先确定要查找的目标值target具体在哪个范围中。然后在对这个数组进行二分查找就行了。

    bool searchMatrix(vector<vector<int>>& matrix, int target) {
	int n = matrix.size();
	int m = matrix[0].size();
	int left = 0;
	int right = n - 1;
	int ans = 0;
	while (left <= right) {
		int mid = (left + right) / 2;
		if (target >=matrix[mid][0]) {
			left = mid + 1;
			ans = mid;
		}
		else {
			right = mid - 1;
		}
	}
	left = 0;
	right = m - 1;
	while (left <= right) {
		int mid = (left + right) / 2;
		if (target > matrix[ans][mid])
			left = mid + 1;
		else if (target < matrix[ans][mid])
			right = mid - 1;
		else
			return true;
	}
	return false;
	
    }

谢谢大家!!!