算法——二分查找算法

目录

1. 二分算法是什么?

2. 朴素二分

朴素二分的模板

3. 查找左边界二分

查找左边界二分的模板

4. 查找右边界二分

查找右边界二分的模板

5. 小结

6. 应用实例

1. X的平方根

2. 搜索插入位置

3. 山脉数组的峰顶索引

4. 寻找峰值

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

6. 点名


1. 二分算法是什么?

简单来说,"二分"指的是将查找的区间一分为二,通过比较目标值与中间元素的大小关系,确定目标值可能在哪一半区间内,从而缩小查找范围。这个过程不断重复,每次都将当前区间二分,直到找到目标值或确定目标值不存在为止。这种分而治之的策略使得二分查找算法具有较高的效率,时间复杂度为O(log n)。

大致图解如下

即通过二段性,在每次判断过后可以一次性减少将近一半的数据,然后通过不断的挪移左右区间来筛选出最后的结果。

2. 朴素二分

在这里我们通过一个例题来讲解:704. 二分查找 - 力扣(LeetCode)

题目描述如下

看到这个题目之后我们首先想到的一定是暴力解法:

从头遍历数组,将每个值与target比较,若遍历到结束还没有找到就返回-1, 否则返回对应下标

我们稍加分析可以发现这个解法的时间复杂度是O(N),我们没有使用到数组升序的性质,我们可以在暴力解法上稍作优化,修改为二分查找:

定义左右指针left, right,然后计算中间值,将其与target比较,由于升序,若中间值小于target,则表明此时中间值及其左边的值均小于target,此时target理应存在于[mid+1, right],因此令left = mid+1; 若中间值大于target,则表明此时中间值及其右边的值均小于target, 此时target理应存在于[left, mid-1],因此令right = mid-1;相等时返回mid下标即可。

大致图解如下 

代码如下

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

        while (left <= right)
        {
            // int mid = (left + right) / 2;
            int mid = left + (right - left) / 2; // ֹ避免整型溢出
            if (nums[mid] < target) left = mid + 1;
            else if (nums[mid] > target) right = mid - 1;
            else return mid;
        }

        return -1;
    }
};

在这里有两个值得关注的细节,其中一个是while循环的结束条件,在这里由于left与right的变化始终是在mid的基础上+1或-1,因此在left==right的时候,会因为边界的变化而导致退出循环,因此退出的条件是left > right;另一个是mid的计算方式,在计算mid时我们有两种计算方式:一种是mid = left + (right - left) / 2,另一种是mid = left + (right - left + 1) / 2,这两种方式在具体的过程中体现为

可以看到两种计算方式只有在数据个数为偶数时才会发生变化,意为分别取到中左与中右的下标。

朴素二分的模板

模板如下

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

3. 查找左边界二分

讲解 查找左边界二分与查找右边界二分 时,我们使用例题:34. 在排序数组中查找元素的第一个和最后一个位置 - 力扣(LeetCode)

题目描述如下

简单分析后我们可以得出一个简单的暴力解法:

从头到尾遍历一遍数组,使用begin与end分别标识一下这个元素第一次出现与最后一次出现的位置并返回,否则返回{-1, -1}

我们可以在此基础上优化:

    定义左右指针left, right与标识符begin, end,寻找元素的第一次出现位置本质就是查找左边界,而寻找元素的最后一次出现位置本质就是查找右边界。

    在查找左边界时,计算出中间值并将其与target比较,如果中间值<target,说明左边界理应存在于[mid+1, right]区间中,因此left = mid+1,如果中间值>=target,说明左边界理应存在于[left, mid]区间中,因此right = mid;

查找左边界图解如下

在查找左边界时,我们同样需要关注两个细节:

1. while 循环的退出条件:在上面的查找过程中我们可以发现查找到最后left与right可能会指向同一个位置,此时如果使用while (left <= right)则会陷入死循环,因此退出条件为left>=right

2. 中点下标的选取方式:在朴素二分那里我们知道选取方式有两种,在这里我们选取左边中点,其图解如下

可以看到,如果选取右边的中点可能会导致死循环或下标进入不合理区间

因此我们可以得到查找左边界代码如下

// 查找左端点
while (left < right)
{
    int mid = left + (right - left) / 2;
    if (nums[mid] < target) left = mid + 1;
    else right = mid;
}
if (nums[left] == target) begin = left; // 确认是否找到左边界,并标记左边界

查找左边界二分的模板

模版如下

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

4. 查找右边界二分

    在查找右边界时,计算出中间值并将其与target比较,如果中间值>target,说明右边界理应存在于[left, mid-1]区间中,因此right = mid-1,如果中间值<=target,说明右边界理应存在于[mid, right]区间中,因此left = mid;

查找右边界图解如下

与查找左边界类似,我们同样需要关注两个细节

1. while 循环的退出条件:同上,在查找过程中我们可以发现查找到最后left与right可能会指向同一个位置,此时如果使用while (left <= right)则会陷入死循环,因此退出条件为left>=right

2. 中点下标的选取方式:在朴素二分那里我们知道选取方式有两种,在这里我们选取右边中点,其图解如下

可以看到,如果选取左边的中点可能会导致死循环或下标进入不合理区间

因此我们可以得到查找右边界代码如下

left = 0, right = nums.size() - 1;// 重置下标
// 查找右端点
while (left < right)
{
    int mid = left + (right - left + 1) / 2;
    if (nums[mid] > target) right = mid - 1;
    else left = mid;
}
if (nums[right] == target) end = right;// 标识位

查找右边界二分的模板

模板如下


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

解决问题完整代码如下

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

        int begin = -1, end = -1;
        int left = 0, right = nums.size() - 1;

        // 查找左端点
        while (left < right)
        {
            int mid = left + (right - left) / 2;
            if (nums[mid] < target) left = mid + 1;
            else right = mid;
        }
        if (nums[left] == target) begin = left;

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

        return { begin, end };
    }
};

5. 小结

二分查找算法的细节比较多,但是当我们真正把它分析透彻后,我们仅需要结合理解背住模板,即

对于分类讨论的代码,我们具体情景具体实现

对于中点的选取,我们为了快捷可以记:分类讨论出现 -1 的时候上面就 +1 

6. 应用实例

1. X的平方根

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

解决思路:我们可以将从1到x的所有数的平方枚举出来,并将该平方数与x作比较,这就会天然的把所有平方数分成两个区间,分别是 当前数>=x 和 当前数<x 两个区间,这样就具有了二段性,即

结合模板我们可以得到如下代码

class Solution
{
public:
    int mySqrt(int x)
    {
        if (x < 1) return 0;

        int left = 1, right = x;
        while (left < right)
        {
            // 使用long long避免整型溢出
            long long mid = left + (right - left + 1) / 2;
            if (mid * mid <= x) left = mid;
            else right = mid - 1;
        }
        return left;
    }
};

2. 搜索插入位置

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

解析:根据示例1与示例2我们可以发现,目标索引左边的均<target,右边的均>=target,那么根据二段性有

我们就可以得到如下代码

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

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

3. 山脉数组的峰顶索引

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

解析:根据题目我们可以知道,存在一个山顶它的左边均满足arr[mid] > arr[mid-1],它的右边均满足arr[mid] < arr[mid-1],因此它满足二段性,即

结合模板可以得到

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

        while (left < right)
        {
            int mid = left + (right - left + 1) / 2;
            if (arr[mid] < arr[mid-1]) right = mid - 1;
            else left = mid;
        }

        return left;
    }
};

4. 寻找峰值

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

解析:这道题与上面的第三题类似,但是却又有些不同,由于这里是找到任意一个峰顶,因此我们还是可以如下分析

结合模板有

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

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

        return left;
    }
};

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

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

解析:我们简要分析一下可以发现,这个数组整体呈现先上升,然后最低,再然后上升的趋势,由整个趋势我们可以看出,我们可以以最右边的数据做基准值,前一段上升趋势的数值均大于此基准值,而后一段上升趋势的数值均小于等于此基准值,即

结合模板有

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

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

        return nums[left];
    }
};

同样的我们也可以使用第一个元素作为基准值,即

代码如下

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

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

        return nums[left];
    }
};

6. 点名

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

解析:我们稍加分析可以发现,在缺席的同学处之前mid==arr[mid],在缺席的同学之后mid>arr[mid],即

结合模板有

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

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

        return left;
    }
};
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值