一文带你彻底掌握二分查找

1. 认识二分查找

二分查找也被称为折半查找,他是一种查询效率较高的查找方式,普通查找的方式通常是从头到尾遍历一遍数组,二分查找的方式是找到数组中间的那个元素mid与目标值target进行比较,比target小就去前半段找,大则往后半段,所以每次都能舍去一半的空间,也就是指数级别的。二分查找的效率是O(logN)。

如下是二分查找和直接遍历的演示。

最终我们发现二分查找用了3步找到了37,而直接遍历用了11步。你可能会觉得差距不大,那只是因为数据量太小了,如果现在有2^32个数,考虑最坏情况,直接遍历需要2^32次,而二分查找只需要32次。

2. 二分查找的条件

网上很多文章说二分查找的前提是数组有序,这种说法是不严谨的。

其实二分查找的条件是:数组中任意一个数,可以满足:以这个数为边界,可以将数组分成两个部分,将这个数与目标值进行比较,每次都可以舍去其中一个部分,选择另外一个区域继续查找

也就是说只要满足二段性就可以二分

而数组有序就是满足二段性的最常见的场景,数组中任意一个数都可以满足,这个数之前的所有数都是<当前数,这个数后面的所有数都是>=当前数的。

3. 二分查找模板

为什么要学习二分查找的模板,因为二分查找如果不注意非常容易写出死循环,而我们如果记住了二分的模板,可以有效避免这个问题。对于二分的模板,最好是理解性记忆,知道这个模板怎么来的,在这个基础上去记忆。

3.1 朴素二分

二分中最简单的一种情况,也就是上面动图所演示的。朴素二分指的就是要找到数组当中确定的一个数target

以一道例题带大家了解一下,题目链接:704. 二分查找 - 力扣(LeetCode)

首先,这题是满足二段性的(数组有序),所以我们可以使用二分,第二点就是我们要找的是具体一个数,所以采用朴素二分的办法。

朴素二分的步骤:

  • 1. 设置两个指针left,right(这里的指针不是C语言中的指针,只要是可以用来标记区域的都可以称之为指针)。
  • 2. 让mid = (left + right) / 2,即找到left和right的中间点,此时mid对应的值x有三种情况x < target, x > target, x = target。
  • 3. 如果x < target,那么说明target在的位置是一定在mid的右边的,那我们可以让left = mid + 1;如果x > target,那么说明target的位置是一定在mid左边的,我们可以让right = mid - 1;如果x = target,说明mid位置的值就是我们要找的,直接返回即可

注意事项:

  1. 循环结束条件是left <= right,而不是left < right。因为可能存在数组只有一个数,而left,right都为0,此时不会进入二分查找的判断逻辑中。
  2. mid = (left + right) / 2的写法是是向下取整,例如有4个数,进行计算后,mid为第二个数;在朴素二分中,也可以写成向上取整,即mid = (left + right + 1) / 2。
  3. 强调上面两点主要是为了后面两个模板做铺垫。

那我们可以很轻松的写出朴素二分的代码:

while (left <= right)
{
    int mid = left + (right - left >> 1);
    if (nums[mid] > target)
        right = mid - 1;
    else if (nums[mid] < target)
        left = mid + 1;
    else 
        return mid;
}

注意这里的mid的写法,如果mid写成 mid = (left + right) / 2的方式,那么如果left和right比较大的话,mid可能会出现越界的情况,为了避免这种方式,我写成了 mid = left + (right - left) / 2; left其实就是两个数中较小的那个,right - left是两个数据的差值,再除2,就相当于是将right比left多的那一半给了left,这种写法是不会越界的。还有一点就是 / 2可以替换成 >> 1,>>是位运算,>> 1相当与 / 2,<< 1相当于 * 2,位运算的速度是比乘除法快的,不习惯的同学直接写成mid = left + (right - left) / 2。

我们可以开始尝试解决上面那个题目了

class Solution 
{
public:
    int search(vector<int>& nums, int target) 
    {
        int left = 0;
        int right = nums.size() - 1;
        while (left <= right)
        {
            int mid = left + (right - left >> 1);
            if (nums[mid] > target)
                right = mid - 1;
            else if (nums[mid] < target)
                left = mid + 1;
            else 
                return mid;
        }

        //没有找到目标值target
        return -1;
    }
};

3.2 查找左端点和右端点

大部分二分的题目其实是查找左端点以及右端点这类题的,左端点的意思就是一段区域的起始点,右端点和左端点类似,就是一段区域的终止点。

还是以一道例题来说明,题目链接:34. 在排序数组中查找元素的第一个和最后一个位置 - 力扣(LeetCode)

先考虑能否使用二分,我们发现数组是有序的,满足二段性,可以使用二分。我们就以示例一为例子,如果我们要找到8的开始位置和结束位置,最快的办法就是找到第一个8的位置(左端点)和最后一个8的位置(右端点)。

我们先考虑,如何找到左端点,也就是第一个8的位置。

  • 1. 设置两个指针left, right。
  • 2. mid = (left + right) / 2,找到中间点,同样中间点mid对应的值x有两种情况,第一种x < target,那么target肯定在x的右边,我们让left = mid + 1;第二种x >= target,这种情况下,左端点可能就是mid,也有可能在mid左边,我们让right = mid。

对于x >= target为什么要将两种情况合成一种情况,在这道题中确实可以分成两种,因为当 x > target的时候,mid的位置必定不可能是左端点,也就是说左端点一定在mid的左边,可以让right = mid - 1,而x = target的时候,mid位置可能是左端点,也可能不是左端点(例如,mid可能指向的是第二个8),此时我们让right = mid。但是对于大部分的找左端点题目来说,x > target不能说明要找的位置在mid左边,为了普适模板,所以我们合并成两种情况。

注意事项:

  • 1. 循环条件要写成left < right,如果写成left <= right,那么mid = left = right时,如果此时的判断条件是x >= target,那么right = mid,也就是说right值不变,下一次还是会经历上面的步骤,最终导致死循环。
  • 2. 求mid要写成mid = (left + right) / 2,而不是mid = (left + right + 1) / 2。如果写成后面那种,同样也会死循环,比如[1, 2], target = 2,left=0,right=1,计算后mid=1也就是2的位置,进行判断,x >= target,也就是让right = mid。right值不变,发生死循环。

根据上面内容,我们可以写成二分查找左端点的模板:

while (left < right)
{
    int mid = left + (right - left>> 1);
    if (....)
        left = mid + 1;
    else 
        right = mid;
}

其中的....需要根据不同的题目来填写。

同样的查找右端点也是同样的道理。

  • 1. 设置两个指针left, right。
  • 2. mid = (left + right) / 2,找到中间点,同样中间点mid对应的值x有两种情况,第一种x <= target,那么mid位置可能就是右端点,也可能不是,让left = mid,第二种情况,x > target,那么右端点一定在mid的左边,让right = mid - 1。

注意事项:

  • 1. 循环条件要写成left < right,如果写成left <= right,那么mid = left = right时,如果此时的判断条件是x >= target,那么right = mid,也就是说right值不变,下一次还是会经历上面的步骤,最终导致死循环。
  • 2. 求mid要写成mid = (left + right + 1) / 2,而不是mid = (left + right) / 2。如果写成后面那种,同样也会死循环,比如[1, 2], target = 1,left=0,right=1,计算后mid=0也就是1的位置,进行判断,x <= target,也就是让left = mid。left值不变,发生死循环。

二分查找右端点代码:

while (left < right)
{
    int mid = left + (right - left + 1 >> 1);
    if (....)
        right = mid - 1;
    else  
        left = mid;
}

其中的....需要根据不同的题目来填写。

这两个模板看着非常相似,主要的区别在于mid是否要+1,我们可以记一下,只要下面出现mid - 1,上面mid就+1

现在有了具体的模板就可以开始写题了,如果套模板,二分只要能找到二段性就非常简单,而且不用担心死循环。

class Solution 
{
public:
    vector<int> searchRange(vector<int>& nums, int target) 
    {
        if (nums.size() == 0)
            return {-1, -1};

        int left = 0;
        int right = nums.size() - 1;
        //先查找区间的左端点
        while (left < right)
        {
            int mid = left + (right - left >> 1);
            if (nums[mid] < target)
                left = mid + 1;
            else 
                right = mid;
        }

        if (nums[left] != target)
            return {-1, -1};
        int begin = left;

        //查找区间右端点
        right = nums.size() - 1;
        while (left < right)
        {
            int mid = left + (right - left + 1 >> 1);
            if (nums[mid] > target)
                right = mid - 1;
            else  
                left = mid;
        }
        
        return {begin, right};
    }
};

4. 二分例题

下面将用几道例题带大家彻底掌握二分。

4.1 x的平方根

题目链接:69. x 的平方根 - 力扣(LeetCode)

这题乍一看和二分没什么关系,那我们可以先考虑暴力解法,就是从1-x进行遍历,依次判断,直到出现满足题意的数。

那么这道题也就转化成了从1-x中查找一个数,因为是有序的,具有二段性,所以我们可以使用二分查找,又因为这道题是要找到最后一个数,这个数的下一个数的平方就大于x了,也就是说我们需要找区间右端点,我们回忆一下右端点的模板。

while (left < right)
{
    int mid = left + (right - left + 1 >> 1);
    if (....)
        right = mid - 1;
    else  
        left = mid;
}

判断条件只需要改成mid * mid > x即可,因为mid位置的平方大于x,那说明mid位置肯定不是平方小于x的最后一个数。最终代码如下:

class Solution 
{
public:
    int mySqrt(int x) 
    {
        int left = 1;
        int right = x;

        while (left < right)
        {
            long long mid = left + (right - left + 1 >> 1);
            if (mid * mid > x)
                right = mid - 1;
            else 
                left = mid;
        }

        return right;
    }
};

4.2 搜索插入位置

题目链接:LCR 068. 搜索插入位置 - 力扣(LeetCode)

在一个有序数组中查找某个值,我们最先想到的就是二分,这道题也是符合二段性的,任意一个数x都能将数组分成两个区间,其中一个区间<x, 另一个区间>=x。这道题的二分判断条件就是找到第一个>=target的数,这个数所在的位置就是答案,返回他的下标即可,也就是找区间的左端点,那我们直接套模板。

需要注意的是,如果target的位置在数组中最后一个位置的后面,这种情况要特殊处理,因为left和right指针只能在数组范围内移动,不能超出数组范围。

class Solution 
{
public:
    int searchInsert(vector<int>& nums, int target) 
    {   
        int left = 0;
        int right = nums.size() - 1;
        //找 >= target的第一个数
        while (left < right)
        {
            int mid = left + (right - left>> 1);
            if (nums[mid] < target)
                left = mid + 1;
            else 
                right = mid;
        }
        
        //如果插入的位置在最后一个位置之后
        if (nums[right] < target)
            return right + 1;
        else 
            return right;
    }
};

4.3 山脉数组的峰顶索引

题目链接:LCR 069. 山脉数组的峰顶索引 - 力扣(LeetCode)

由题目意思可以得出,数组中有且只有一个峰顶,并且峰顶不是数组的第一个和最后一个,我们先考虑这题是否满足二段性,我们随意取一个值x,有三种情况,1.x比右边的值小,那么x肯定不是峰顶元素,也就是说峰顶一定在x的右边,2.x比左边的值小,x肯定不是峰顶,峰顶在x左边,3.x比左右两边的值都大,那么x就是峰顶。

我们发现这道题的数组虽然无序,但是满足二段性,所以我们依然可以使用二分查找来处理问题。

根据上面总结的,可以看出这题是一个朴素二分,直接套模板即可。

class Solution 
{
public:
    int peakIndexInMountainArray(vector<int>& arr) 
    {
        int left = 0;
        int right = arr.size() - 1;
        while (left <= right)
        {
            int mid = left + (right - left >> 1);
            if (arr[mid] < arr[mid + 1])
                left = mid + 1;
            else if (arr[mid] < arr[mid - 1])
                right = mid - 1;
            else    
                return mid;
        }
        return -1;
    }
};

4.4 寻找峰值

题目链接:162. 寻找峰值 - 力扣(LeetCode)

这题和上一题相似,但是不同的是,这题有多个峰值,并且数组的第一个和最后一个元素是可以为峰值的。

那么这题如何得出二段性呢,二段性指的是数组中任意一个数都能满足,这个数能将数组分成两部分,我们可以舍弃一部分,转而去另一部分继续查找。

我们随便取一个数x,设y为x的下一个数,这个数有两种情况,如果x > y,说明x可能是山顶,那我们可以舍弃x右边的那个部分,去x的左边进一步判断;如果x < y,说明x不可能是山顶,并且x右边区域一定有一个山顶,那我们可以舍弃x左边,去右边进一步判断,综上,这题也是具有二段性的。

 

如何编写代码呢,我们先看一下这个属于哪种类型的二分,当x < y时,峰顶在x右边,当x > y时,x可能是峰顶,也可能不是,根据这个逻辑,我们可以看出和查找左端点的逻辑是相同的,尝试一下编写代码。

class Solution 
{
public:
    int findPeakElement(vector<int>& nums) 
    {
        int left = 0;
        int right = nums.size() - 1;

        while (left < right)
        {
            int mid = left + (right - left >> 1);
            if (nums[mid] > nums[mid + 1])
                right = mid;
            else 
                left = mid + 1; 
        }

        return left;
    }
};

这个代码不用担心mid指向最后一个数时,与mid+1进行比较会发生越界访问,因为mid指向最后一个数时,只有可能left和right都指向最后一个数,那么这时不满足循环条件,直接退出了。

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

题目链接:153. 寻找旋转排序数组中的最小值 - 力扣(LeetCode)

这道题也是二分中比较经典的一题,也很好证明了即使数组不是有序的也能二分。

首先,我们先证明一下这题可以二分,也就是具有二段性。

根据题目所示,将数组分成两个部分,AB和CD,其中AB中任意一点都比CD中任意一点要大,我们最终要找的点是C点。其中有两个比较特殊的点,A和D,我们以D为例,我们可以发现一个现象,AB中任意一个数都比D要大,CD中任意一个数都小于等于D(可能为D本身)

现在我们任意取一个数x,如果x是AB中的某一个数(x > D),那么说明x的左边肯定是没有结果的,我们可以舍弃左边取x的右边查找,同理,如果x是CD中的某一个数(x <= D),那么说明接下来要去x的左边查找。

综上,我们可以得到二段性,也就满足了二分条件。这题很明显是找区间左端点,那么套模板,很容易就能写出代码。

class Solution 
{
public:
    int findMin(vector<int>& nums)
    {
        int n = nums.size() - 1;
        int left = 0;
        int right = n;

        //当mid > nums[n]时,那么mid肯定不是最小的那个数,所以让left = mid + 1
        //当mid < nums[n]时,mid可能是最小的那个值,让right = mid
        while (left < right)
        {
            int mid = left + (right - left >> 1);
            if (nums[mid] > nums[n])
                left = mid + 1;
            else 
                right = mid;
        }
        return nums[left];
    }
};

4.6 0~n-1中缺失的那个数

题目链接:LCR 173. 点名 - 力扣(LeetCode)

这题也是剑指offer中的一题,有很多种解法,例如:

  • 1.哈希表(创建一个大小为n+1的哈希表,遍历一遍数组,将出现过的数填入哈希表中,然后再次遍历找到哈希表中没有出现过的数)
  • 2.直接遍历(使用一个i从0到n,如果records[i] != i,那么就是缺失的那个数)
  • 3.等差数列求和(使用等差数列求和公式求出首项为1,尾项为n的值,再减去数组中每一个数就能得出缺失的那个数)
  • 4.使用位运算(位运算的特点是a ^ a = a,a ^ 0 = 0,根据这个特点,我们将0~n-1全部异或一遍,再和数组中的值全部异或一遍,就能得出结果)

但是这道题目最优的解法其实是二分查找,只不过二分条件不容易想到,其实只要仔细观察我们就会发现,数组和下标一样从0开始,并且每次+1,也就是说,缺失的那个数开始,数组和下标就对应不上了。

二段性也比较好证明,如果一个数组值和下标值相同,那么这个数的左边就可以舍去了,转而去这个数的右边查找,如果一个数组值和下标值不同,说明这个数左边或者这个数本身出问题了,舍弃这个数右边的部分,去查找左边。

这题很明显是找左端点,因为找的是第一个数组值和下标值不同的,所以套左端点模板即可。

需要注意的是,如果0~n-1的数在数组中全部出现了,也就是说缺失的其实是n,也就超出了数组范围,我们需要特殊处理一下。

class Solution 
{
public:
    int takeAttendance(vector<int>& records) 
    {
        //如果要找的数不在数组[rescords[left], rescords[right]]范围内
        //直接返回结果
        int n = records.size();
        if (n != records[n - 1])
            return n;

        //二分规律:[0,1,2,3,5]
        //对应下标: [0,1,2,3,4]
        //我们发现缺少的那个数就是数值与下标不同的第一个数,满足二段性
        int left = 0;
        int right = n - 1;
        while (left < right)
        {
            int mid = left + (right - left >> 1);
            if (records[mid] == mid)
                left = mid + 1;
            else 
                right = mid;
        }

        return left;
    }
};

5. 总结

二分是查找算法中比较高效的,时间复杂度是O(logN),当面临需要查找的问题时,如果满足二段性,我们就可以使用二分查找来提高查找效率。

模板最好也要记住,因为二分非常容易写出死循环,在查找左右端点的那里,我们也分析了循环条件不对,mid是否需要+1都会导致死循环的出现,在实际写代码时,我们需要注意避免这些问题。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值