你真的认为二分查找很简单吗

基本二分查找模板分析,参考了leetcode大神labuladong的题解

这里先介绍一下搜索区间的概念

标准二分模板

int a[] = {1, 2, 3, 4, 5, 6, 7, 8, 9, 10};
int target = 0;
cin>>target;
int left = 0;
int right = sizeof(a) / sizeof(a[0]) - 1;//
while(left <= right)//??
{
    int mid = (left + right) >> 1;
    if(a[mid] == target)
    {
        return mid;
    }
    else if(a[mid] < target)
    {
        left = mid + 1;//??
    }
    else
    {
        right = mid - 1;//??
    }
}
return -1;//没找到

1.while括号内的条件

我们这里采用的是[ ,]两边都闭的区间。也就right指向的是最后一共元素的下标。如果我们使用[ , ]的话,那么我们while里面的条件就必须写left <= right,而不可以是<,因为我们这里是闭区间就意味着循环结束的条件是[left + 1, left],这个区间不存在,因此没有遗漏任何一共元素。但是假如我们结束条件是left < right的话,那么结束条件是[left, left],我们可以发现里面还遗漏了一共元素,因此是错误的。这便是搜索区间。再举一个例子,假如我们写right = sizeof(a) / sizeof(a[0])的话,那么说明是[ ,),因为right实际上是不存在的嘛,假如我们写left <= right,那么结束条件是[left + 1, left),区间不存在,但是我们多循环了一次,而且这多循环的一次可能会让循环陷入一共无线的循环。如果写left < right,那么结束条件是[left , left),同样是没有任何遗漏的。

2.left = mid + 1?? right = mid - 1??

为什么要这么写呢?原因很简单,还是使用搜索区间,[ ,]的时候我们搜索的范围是[left, mid - 1]和[mid+ 1, right],因为mid本身我们已经找了,不需要再去找了,如果偏要想再把mid算进去可能会导致死循环,因为区间有交集,如果刚好最后的结果位于交集范围内的话会一直循环。但是如果我们使用[ , )的话,那么范围就是[left, mid)和[mid + 1, right),没错,就是这样。

搜索区间的方法介绍完了,接下里继续往下看。

#include<iostream>
using namespace std;
int main()
{
    int a[] = { 1,2,3,4,5,6,7,8,9,10 };
    int left = 0;
    int right = sizeof(a) / sizeof(a[0]) - 1;
    int target = 0;
    cin >> target;
    while (left <= right)
    {
        int mid = (left + right) / 2;
        if (a[mid] < target)
        {
            left = mid;
        }
        else if (a[mid] > target)
        {
            right = mid;
        }
        else
        {
            cout << mid;
            break;
        }
    }
    return 0;
}

当然对于这个标准模板,我们不完全按照规则,写left = mid,right = mid也可以,不过就是[left, mid]和[mid, right],只是循环次数可能多一点而已。

关于二分查找

传统的二分查找:有序,找到某一个数字的位置。但实际上二分查找没有那么死板,二分查找只是一种查找方式,其时间复杂度是o(logN),因为当题目要求时间复杂度必须是o(logN)的时候我们大概率会用到二分查找。作为一种查找方式,二分查找采取了每次砍掉一半的策略,这个每次缩减一半让我们一次性排序一半的数据,而不是一共或者几个数据,所以我们应该更关注的不是二分查找的传统使用条件,而是一次砍一半的这个策略。

 

首先,我们想要获得高效率,一次性砍掉一半,那么我们必须获得一次性砍掉一半的筹码,能让我们一次性砍掉一半的时候说明数据具有某些规律可言,有序是最简单的规律,这也是让我们无法正确认识二分查找的原因之一。我认为:如果一定可以找到,那么就可以用二分查找。二分查找实际上有一种逼近的意味在里面,我们先来查看一组最简单的二分查找的代码,来看一下二分查找的传统模型:

int a[] = {1, 2, 3, 4, 5, 6, 7, 8, 9, 10};
int target = 0;
cin>>target;
int left = 0;
int right = sizeof(a) / sizeof(a[0]) - 1;
while(left <= right)
{
    int mid = (left + right) >> 1;
    if(a[mid] == target)
    {
        cout<<mid<<endl;
        break;
    }
    else if(a[mid] < target)
    {
        left = mid + 1;
    }
    else
    {
        right = mid - 1;
    }
}

每次排除一半的数据,直到区间不断逼近正确答案,找到正确答案。

二分查找的变式

这里的二分查找变式仍然停留在传统二分查找的基础之上,只是稍作了修改,这里的前提条件仍然是有序的,本质上没有发生改变,注意,实际上有序作为一种规律,我们只要有可以代替有序的规律就可以的,前提是这个规律必须可以起到一次性排除一半数据的效果,我后面会去逐一进行讲解。

寻找target >= x的数字的最左边界

利用二分查找的逼近属性,但是不同的是我们这次把二分查找全部找完,中途不给退出出口,先看代码:

这里先展示一种网上最流传的解法,过后我会对它进行升级:

int a[] = {1, 2, 2, 2, 3, 4, 5};
int target = 2;//这里直接给出我们要找的边界是2
int left = 0;
int right = sizeof(a) / sizeof(a[0]) - 1;
while(left <= right)
{
    int mid = (right + left) / 2;
    if(target == a[mid])
    {
        right = mid - 1;//找到的时候不进行退出,而是让right继续进行逼近,为什么是right,因为说明找的是数字的最左边界
    }
    else if(target > a[mid])
    {
        left = mid + 1;
    }
    else
    {
        right = mid - 1;
    }
}
cout<<left<<endl;//找到边界,打印或者如果是函数的话就返回
//或者 cout<<right + 1<<endl;

这里和二分查找的标准模板是差不多的,如果你想用[ , )当然也是可以的改一下就行。重点是这样是怎么样得到边界的?

    if(target == a[mid])
    {
        right = mid - 1;//找到的时候不进行退出,而是让right继续进行逼近,为什么是right,因为说明找的是数字的最左边界
    }

这就是关键,我们找到答案的时候不让二分查找停止,而是让它不断进行逼近,直到找到正确答案为止。

为什么最后left或者right + 1是结果

我们的left和right是什么意思?left和right表示的是区间的左边界和右边界,我们为什么可以使用二分查找?答案是用左边界和右边界不但压缩和缩小,最后锁定到一个具体的数字,这里也是一样的,我们的目的是求出target >= x的最左边界,所以left是主动方,left是左边界,这个左边界最终一定会压到target的位置,为什么right不一定是正确答案,因为right是被动者,它作为右边界是无法站在左边界的立场上的。如下图

 

那么为什么right + 1也可以是答案呢?因为循环结束的时候left = right + 1,就这么简单。

没找到返回-1吗?

这里跟找数字不一样,因为我们是放任二分查找完成的,所以最后假如我们这个数组里面没有我们想要的边界的话,那么很可能left或者right直接不在下标的范围之内。所以要进行验证之后再决定是返回left还是-1

这里检查left的越界情况,因为left是主动地位

if(left > sizeof(a) / sizeof(a[0]) || a[left] != target)//第一个条件是target比所有的都大,第二个是target比所有的都小,left仍然在0,所以只需要判断0是不是我们想要的。

寻找target <= x的数字的最右边界

我们换个写法

int a[] = {1, 2, 2, 2, 3, 4, 5};
int target = 2;//这里直接给出我们要找的边界是2
int left = 0;
int right = sizeof(a) / sizeof(a[0]);
while(left < right)
{
    if(a[left] == target)
    {
        left = mid + 1;//必须写mid + 1,搜索区间前面讲了,
    }
    else if(a[left] < target)
    {
        left = mid + 1;
    }
    else
    {
        right = mid;
    }
}
//检查right的越界情况
if(right < 0 || a[right] != target)
{
    return -1;
}
return right - 1;//这里返回right必对,因为循环结束条件是left = right,所以返回left - 1也可以

为什么这里要减一?

因为我右边是开区间,最终right必定在区间的正确位置,但是因为开区间,所以right - 1才是target的位置。如果写闭区间就直接写right就可以了。

改良:我们使用一个变量来保存边界值,最后返回变量的下标,这样应对多种情况

int a[] = {1, 2, 2, 2, 3, 4, 5};
int target = 2;//这里直接给出我们要找的边界是2
int left = 0;
int right = sizeof(a) / sizeof(a[0]) - 1;
int temp;//定义临时存储的变量
while(left <= right)
{
    int mid = (right + left) / 2;
    if(a[mid] >= target)
    {
        temp = mid;
        right = mid - 1;
    }
    else
    {
        left = mid + 1;
    }
}
return temp;

二分查找的应用,二分查找很死板吗?

34. 在排序数组中查找元素的第一个和最后一个位置

模型套用:寻找一个区间范围内的数字:

class Solution {
public:
    int searchRangeleft(vector<int>& nums, int target)
    {
        int left = 0;
        int right = nums.size() - 1;
        while(left <= right)
        {
            int mid = (left + right) / 2;
            if(nums[mid] < target)
            {
                left = mid + 1;
            }
            else
            {
                right = mid - 1;
            }
        }
        if(left > nums.size() - 1 || nums[left] != target)
        {
            return -1;
        }
        else
        {
            return left;
        }
    }
    int searchRangeright(vector<int>& nums, int target)
    {
        int left = 0;
        int right = nums.size() - 1;
        while(left <= right)
        {
            int mid = (left + right) / 2;
            if(nums[mid] > target)
            {
                right = mid - 1;
            }
            else
            {
                left = mid + 1;
            }
        }
        if(right < 0 || nums[right] != target)
        {
            return -1;
        }
        else
        {
            return right;
        }
    }
    vector<int> searchRange(vector<int>& nums, int target) {
        if(nums.size() == 0)
        {
            vector<int> result{-1, -1};
            return result;
        }
        vector<int> result;//用于保存结果的东西
        int lefttemp = searchRangeleft(nums, target);
        int righttemp = searchRangeright(nums, target);
        result.push_back(lefttemp);
        result.push_back(righttemp);
        return result;
    }
};

这道题没什么好解释的其实。准确来说还是挺传统的。

问题可以转换:

class Solution {
public:
    int helper(vector<int>& nums, int target)//这里是搜索右边界
    {
        int left = 0;
        int right = nums.size() - 1;
        while(left <= right)
        {
            int mid = (left + right) / 2;
            if(nums[mid] <= target)
            {
                left = mid + 1;
            }
            else
            {
                right = mid - 1;
            }
        }
        return right;
    }
    int search(vector<int>& nums, int target) {
        //代码优化,使用一个函数进行统一,没必要写2个
        return helper(nums, target) - helper(nums, target - 1);//右边界和右边界 - 1
    }
};

35. 搜索插入位置

class Solution {
public:
    int searchInsert(vector<int>& nums, int target) {
        //问题转换,转换成搜索大于等于target的一个下标,也就是大于等于target的边界
        int left = 0;
        int right = nums.size() - 1;
        while(left <= right)
        {
            int mid = (left + right) / 2;
            if(nums[mid] >= target)
            {
                right = mid - 1;
            }
            else
            {
                left = mid + 1;
            }
        }
        return left;
    }
};

852. 山脉数组的峰顶索引

山峰数组问题,局部最大问题

class Solution {
public:
    int peakIndexInMountainArray(vector<int>& arr) {
        //这里是完全一样的
        int left = 1;
        int right = arr.size() - 2;
        while(left <= right)//这里的意思是我们不可以从头和尾开始,这是由山峰数组的定义决定的
        {
            int mid = (left + right) / 2;
            if(arr[mid] >= arr[mid + 1])
            {
                right = mid - 1;
            }
            else
            {
                left = mid + 1;
            }
        }
        return left;
    }
};

局部最小问题

在完全无序的数组中找到一个局部最小数就可以了

局部最小数定义:a[mid - 1] < a[mid] < a[mid + 1],我们承诺一定给出长度大于3的数组

int left = 1;
int right = nums.size() - 2;
while(left <= right)
{
    int mid = (left + right) / 2;
    if(nums[mid] < nums[mid + 1] && nums[mid] < nums[mid - 1])
    {
        return mid;
    }
    else
    {
        if(nums[mid] > nums[mid - 1])
        {
            left = mid + 1;
        }
        else
        {
            right = mid + 1;
        }
    }
}

三分法

三分法不是把区间分为三份,而是每次否决三分之一,通常应用于求顶峰,峰值问题

852. 山脉数组的峰顶索引

往常我们使用「二分」进行查值,需要确保序列本身满足「二段性」:当选定一个端点(基准值)后,结合「一段满足 & 另一段不满足」的特性来实现“折半”的查找效果。

顾名思义,「三分」就是使用两个端点将区间分成三份,然后通过每次否决三分之一的区间来逼近目标值。

具体的,由于峰顶元素为全局最大值,因此我们可以每次将当前区间分为 l, m1m1, m2m2, r 三段,如果满足 arr[m1] > arr[m2]arr[m1]>arr[m2],说明峰顶元素不可能存在与 m2, r 中,让 r = m2 - 1r=m2−1 即可。另外一个区间分析同理。

(思路来自于:宫水三叶)

class Solution {
public:
    int peakIndexInMountainArray(vector<int>& arr) {
        int left = 0;
        int right = arr.size() - 1;//这里可以这么写,因为/3的性质让我们不需要舍弃头和尾
        while(left <= right)
        {
            int third1 = left + (right - left) / 3;
            int third2 = right - (right - left) / 3;
            if(arr[third1] > arr[third2])
            {
                right = third2 - 1;
            }
            else//这里的隐含条件是arr[third1] <= arr[third2],这里可以看出我们是以右为主导的,所以应该返回右
            {
                left = third1 + 1;
            }
        }
        return right;
    }
};

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

思路:数组因为旋转分为了两段:但是我们这两端还是具有二分性,也就是说,仍然可以用二分查找进行寻找。

class Solution {
public:
    int findMin(vector<int>& nums) {
        int left = 0;
        int right = nums.size() - 1;
        if(nums[left] <= nums[right])
        {
            return nums[0];
        }
        while(left <= right)
        {
            if(right - left == 1)
            {
                return nums[right];
            }
            int mid = (left + right) / 2;
            if(nums[mid] > nums[left])
            {
                left = mid;//这种反套路的写法,反而保证了left一定不会穿过去,right也不会
            }
            else
            {
                right = mid;
            }
        }
        return -1;
    }
};

深度总结:

我们有点时候,要跳出传统的模板,但是又要把模板牢记在心里,比如上面这一道题,我没有使用模板,而且写成了left = mid,和right = mid,这样会导致的结果是left永远不会出去它的领域,right也不会,不断的循环会导致最终他们相差一个,然后再返回。利用这个原理,也就是使用left = mid的时候,我们是处于一个被动的状态的,这个时候我们逃离不出某一个领域,而是只能等别人来碰撞我们。

class Solution {
public:
    int findMin(vector<int>& nums) {
        int low = 0;
        int high = nums.size() - 1;
        while (low < high) {
            int pivot = low + (high - low) / 2;
            if (nums[pivot] < nums[high]) {
                high = pivot;
            }
            else {
                low = pivot + 1;
            }
        }
        return nums[high];//循环的结束条件是high = low,所以我们写high或者low都是可以
    }
};

这个代码也是上面那一道题的代码,但是这个代码越隐藏了非常多的信息。

1.我们使用了[ , ],但是while里面却是<,这样的目的是我们结束循环的时候是low = high的,而且一定是偏向于high的位置,因为high = pivot,而且if里面的条件说明了一定不会越界到left那边。这是由这一道题的性质导致的。

2.我们的对比需要一个标准,可以是high,也可以是low,为什么我们偏偏选择了high?。对于这道题而言,这种写法是逼近式的,while循环中没有中途可以退出的口,而上面的方法就无所谓,因为没有出口,所以也就是我们必须要不满足while里面的条件才可以退出,但是这里正巧是退出条件是相等的时候,而且这个时候由搜索区间可以得到那个漏掉的元素就是答案本身,但是我们如果不写low = pivot + 1的话,我们永远无法得到low = high,因为它们无法跨越他们的领域,所以下面这个low是必须要这么写的,是无法改变的,所以这个时候我们要以high作为标准,原因很简单,因为以high作为标准high永远不会越界,这个时候以low作为标准如果时机得当low会跑到奇怪的地方去。所以high = pivot和以high作为标准是一个连环条件,不可分离

总而言之:我们最好使用右值进行比较,一定一定一定!!!为什么用high?再次强调,4 5 1 2 3,如果我们用left,nums[pivot] < nums[left],你可以成果判断是哪一边吗?左边部分所有元素都一定比右边部分要大,所以假如你判断某一个元素比右边的某一个值,且右边的这个值永远在右边要小的话,那么这个值也一定在右边,如过你用左边判断的话,左边的值,也可能比左边的值要小。

再看:

class Solution {
public:
    int findMin(vector<int>& nums) {
        int low = 0;
        int high = nums.size() - 1;
        while (low <= high) {
            int pivot = low + (high - low) / 2;
            if (nums[pivot] <= nums[high]) {
                high = pivot;
            }
            else {
                low = pivot + 1;
            }
        }
        return nums[high];//循环的结束条件是high = low,所以我们写high或者low都是可以
    }
};

在计算机世界里面,÷一个整数就是往小的那边去

这段代码会死循环

[3,4,5,1,2],因为当我们low = high的时候,循环还没有结束,这个时候求pivot必定还是再原地,然后再次发生high = pivot,不断循环,无法打破平衡。问题出在哪里了? if (nums[pivot] <= nums[high])。因为有这个=和high = pivot这个被动状态进行了重合,所以会发生这个情况,这里我们其实也得到了一些启示,那就是当你的循环条件在low == high的时候还没有结束时,如果你下面写了一个被动的状态,那么这个被动的状态的条件一定不可以是=

我们去掉这个=,

class Solution {
public:
    int findMin(vector<int>& nums) {
        int low = 0;
        int high = nums.size() - 1;
        while (low <= high) {
            int pivot = low + (high - low) / 2;
            if (nums[pivot] < nums[high]) {
                high = pivot;
            }
            else {
                low = pivot + 1;
            }
        }
        return nums[high];//循环的结束条件是high = low,所以我们写high或者low都是可以
    }
};

可以发现oj过了。return nums[low - 1]也可以,可以从两个方面去看:1.循环的结束条件 2.要想在两者相等的状态下结束僵直,只能是low = pivot + 1,反之,如果你写的条件,在这个关键的节点无法到的话,那么就一定会出现问题。那如果我们改while里面的条件的话也不会出现死循环:

class Solution {
public:
    int findMin(vector<int>& nums) {
        int low = 0;
        int high = nums.size() - 1;
        while (low < high) {
            int pivot = low + (high - low) / 2;
            if (nums[pivot] < nums[high]) {//这个时候,这里加不加等号都可以
                high = pivot;
            }
            else {
                low = pivot + 1;
            }
        }
        return nums[high];//循环的结束条件是high = low,所以我们写high或者low都是可以
    }
};

(图片出自leetcode官方题解)

关于while的括号里面加不加等于号,我的建议是不加,因为加了之后你还有更大的可能性导致死循环,所以说不加等,这道题遗漏的一个就是答案,就可以了。我们前面把加等的讲解只是为了让题目更深刻。

难度再次增加:

154. 寻找旋转排序数组中的最小值 II

这道题和上一道题的区别就是:这里是允许重复的,我们其实只需要在原来的基础上加上一小段代码就可以了

class Solution {
public:
    int findMin(vector<int>& nums) {
        //这个方法不需要像上面一样考虑这么多,因为我们在循环内部有可以判出的条件
        int left = 0;
        int right = nums.size() - 1;
        if(nums.size() == 1)//这里必须要判断,因为下面不能写=
        {
            return nums[0];
        }
        if(nums[left] < nums[right])//这里不可以写=[3, 1, 3]
        {
            return nums[left];
        }
        while(left <= right)
        {
            if(right - left == 1)
            {
                return nums[right];
            }
            int mid = (left + right) / 2;
            if(nums[mid] == nums[left] && nums[left] == nums[right])//说明我们无法进行很好的判断,这个时候采取线性的方法
            {
                int result = nums[left];
                for(int i = left + 1; i < right; ++i)//这里我们已经不需要判断left和right的位置的
                {
                    if(nums[i] < result)
                    {
                        return nums[i];
                    }
                }
            }
            if(nums[mid] >= nums[left])
            {
                left = mid;
            }
            else
            {
                right = mid;
            }
        }
        return -1;
    }
};

题目的整体框架还是以前那个样子,没有发生本质改变

或者这样:

class Solution {
public:
    int findMin(vector<int>& nums) {
        int left = 0;
        int right = nums.size() - 1;
        while(left < right)
        {
            int mid = (left + right) / 2;
            if(nums[mid] < nums[right])
            {
                right = mid;
            }
            else if(nums[mid] > nums[right])
            {
                left = mid + 1;
            }
            else
            {
                right--;//缩小问题规模
            }
        }
        return nums[left];
    }
};

未完待续.....  

  • 5
    点赞
  • 3
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 4
    评论
评论 4
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

胡桃姓胡,蝴蝶也姓胡

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值