算法基础详解(五)二分算法——二分查找与二分答案

欢迎来到我的频道[【点击跳转专栏】]

作者说:我想说 基础 不等于 简单 ;算法能力不是一蹴而就的,而是来自日积月累的积累和练习!积小流终成江海,诸君 加油!!

文章目录

  • [1. 二分算法](#1. 二分算法)
    • [1.1 案例:在排序数组中查找元素的第⼀个和最后⼀个位置(超级详细~)](#1.1 案例:在排序数组中查找元素的第⼀个和最后⼀个位置(超级详细~))
    • [1.2 模版总结](#1.2 模版总结)
    • [1.3 二分算法的时间复杂度](#1.3 二分算法的时间复杂度)
  • [2. ⼆分查找](#2. ⼆分查找)
    • [2.1 ⽜可乐和魔法封印](#2.1 ⽜可乐和魔法封印)
    • [2.2 A-B 数对](#2.2 A-B 数对)
    • [2.3 烦恼的高考志愿](#2.3 烦恼的高考志愿)
  • [3. 二分答案](#3. 二分答案)
    • [3.1 木材加工(二分答案模版题)](#3.1 木材加工(二分答案模版题))
    • [3.2 砍树](#3.2 砍树)
    • [3.3 跳石头(挺难的 难在找二分性~)](#3.3 跳石头(挺难的 难在找二分性~))

1. 二分算法

二分算法是基础算法中最难的算法。二分算法的原理以及模板其实是很简单的,主要的难点在于问题中的各种各样的细节问题。因此,大多数情况下,只是背会二分模板并不能解决题目,还要去处理各种乱七八糟的边界问题。

1.1 案例:在排序数组中查找元素的第⼀个和最后⼀个位置(超级详细~)

https://leetcode.cn/problems/find-first-and-last-position-of-element-in-sorted-array/description/


解法:二分算法 契机:发现解集中,存在二段性

当我们的解具有二段性时,就可以使用二分算法找出答案:

  • 根据待查找区间的中点位置,分析答案会出现在哪一侧;
  • 接下来舍弃一半的待查找区间,转而在有答案的区间内继续使用二分算法查找结果。

如:定义两个指针 left 和 right 分别指向解空间的起始位置和终止位置!然后再求一下中点位置mid,假如我要寻找的是2 mid指向3 那么此时我们就可以舍弃掉【mid,right】这个区间的值!

下面我会很细节的讲解该如何找到2的起始位置(3号位置的2)2的终止位置(5号位置的2)
查找起始位置:

结合上面的图 我们发现初始位置可以划分为 小于2 和大于等于2这两个部分

关于这个特性,起始位置二分算法接下来如何操作(答案在大于等于2的部分 这么做可以准确找到这个点 可以试着自己画画),如图:


细节问题:

  1. while循环里面的判断怎么写?

不可以while(left<=right) 会导致死循环!

此时 命中第一个条件 a[mid]>=2 然后right==mid 此时mid=(0+0)/2=0 你会发现直接死循环了!


while循环要写成 while(left<right) 这样我们会发现 当变成如图的时候 就已经跳出循环,并且该结果就是我们所要的最终结果!


  1. 求中点的方式

方式1:(left+right)/2

方式2:(left+right+1)/2

(当总数为奇数的时候 两种方式都是求得 正中间的那一个!)

(但当为偶数的时候 两种求法的方式就变了!)


我们要用方式1而不是方式2 因为(left+right+1)/2这种方式可能造成死循环!

此时我们命中 a[mid]>=t 这个操作 此时mid=right 又陷入死循环!

当我们使用方式一 的话就不会造成死循环 具体我就不画了 可以自己画一画试试!


  1. 二分结束后,相遇点会是何种情况?

循环结束后 我们需要判断一下,是否是我们想要的结果!比如我们要找2但是如图按照我们上述规则进行循环结束后 并没有我们想要的值。

查找终止位置

结合上面的图 我们发现初始位置可以划分为 小于等于2 和大于2这两个部分

关于这个特性,起始位置二分算法接下来如何操作,如图:


细节问题(这一块类比一下求初始位置的方式 可以自己举个例子看看 我就不详细分析了):

  1. 求中点的方式

方式1:(left+right)/2❌ 会死循环

这么做会命中条件1 a[mid]<=t 此时left=mid 死循环了!


如果是第二种方式(left+right+1)/2 就不会造成死循环!

此时 mid==right 满足条件1a[mid]<=t 此时letf=mid 就不会造成死循环了(结果如下图


  1. 接上面例子 while该怎么写?

如果while(left<=right) 看上图 死循环了 不想多解释 所以必须要while(left<right)


  1. 最后老规矩 判断一下相遇点的情况就行了!(其实不写也没事 因为起始点已经判断出有没有了
cpp 复制代码
class Solution {
public:
    vector<int> searchRange(vector<int>& nums, int target) 
    {
       int n=nums.size();

       //1.求起始位置
       //这里发生了隐式类型转换。n 是 size_t(无符号整数 vector.size()的返回类型),0 - 1 不会变成 -1,而是变成了一个巨大的正整数
       //虽然变量 n 是 int,但在计算 n - 1 时,编译器为了"安全",偷偷把 n 又变回了 size_t这个值  也就是"类型穿透"现象
       //然后当你访问num[mid]就会越界!因为此时nums是空数组!
       //记得要处理边界情况
       if(n==0) return{-1,-1};
       int left=0,right=n-1;
       while(left<right)
       {
        int mid=(left+right)/2;
        if(nums[mid]>=target) right=mid;
        else left=mid+1;
       }
       //判断一下所指位置是否正确
       if(nums[left]!=target) return{-1,-1};
       int retleft=left;//记录一下起始位置

       //2.求终止位置
       left=0,right=n-1;
       while(left<right)
       {
        int mid=(left+right+1)/2;
        if(nums[mid]<=target) left=mid;
        else right=mid-1;
       }
       return {retleft,left};
    }
};

1.2 模版总结

cpp 复制代码
// ⼆分查找区间左端点
int l = 1, r = n;
while(l < r)
{
int mid = (l + r) / 2;
//check表示 如果左端点[left,mid]这个区间
if(check(mid)) r = mid;
else l = mid + 1;
}
// ⼆分结束之后可能需要判断是否存在结果
cpp 复制代码
// ⼆分查找区间右端点
int l = 1, r = n;
while(l < r)
{
int mid = (l + r + 1) / 2;
//check表示 如果右端点在[mid,right]这个区间
if(check(mid)) l = mid;
else r = mid - 1;
}
// ⼆分结束之后可能需要判断是否存在结果

在工程代码中,为了防止 left + right 溢出(虽然 int 很难溢出,但在极大数组下可能),更严谨的写法通常是

cpp 复制代码
int mid = left + (right - left) / 2;

【模板记忆方式】

  1. 不用死记硬背,算法原理搞清楚之后,在分析题目的时候自然而然就知道要怎么写二分的代码;
  2. 仅需记住一点,if/else 中出现 -1 的时候,求 mid+1 就够了。

【二分问题解决流程】

  1. 先画图分析,确定使用左端点模板还是右端点模板,还是两者配合一起使用;
  2. 二分出结果之后,不要忘记判断结果是否存在,二分问题细节众多,一定要分析全面。

【STL 中的二分查找】
<algorithm>

  1. lower_bound:大于等于 x 的最小元素,返回的是迭代器;时间复杂度: O ( log ⁡ N ) O(\log N) O(logN)。
  2. upper_bound:大于 x 的最小元素,返回的是迭代器。时间复杂度: O ( log ⁡ N ) O(\log N) O(logN)。

二者均采用二分实现。但是 STL 中的二分查找只能适用于"在有序的数组中查找",如果是二分答案就不能使用。因此还是需要记忆二分模板。

cpp 复制代码
#include <bits/stdc++.h>
using namespace std;

class Solution {
public:
    vector<int> searchRange(vector<int>& nums, int target) 
    {
        // 1. 特判空数组(依然需要,防止后面 nums[0] 越界)
        if(nums.empty()) return {-1, -1};

        // 2. 找左边界:第一个 >= target 的位置
        // lower_bound 返回的是迭代器,减去 begin() 得到下标
        int left = lower_bound(nums.begin(), nums.end(), target) - nums.begin();

        // 3. 找右边界:
        // 技巧:找第一个 > target 的位置,然后下标 - 1
        int right = upper_bound(nums.begin(), nums.end(), target) - nums.begin() - 1;

        // 4. 检查找到的范围是否合法
        // 如果 left 越界了,或者 left 指向的值不是 target,说明没找到
        if(left >= nums.size() || nums[left] != target) {
            return {-1, -1};
        }

        return {left, right};
    }
};

1.3 二分算法的时间复杂度

2. ⼆分查找

2.1 ⽜可乐和魔法封印

https://ac.nowcoder.com/acm/problem/235558



⼆分查找算法练手题,直接上⼿模版即可。

cpp 复制代码
#include<bits/stdc++.h>
using namespace std;
const int N=1e5+10;
int a[N];

int main()
{
    int n;
    cin>>n;
    for(int i=1;i<=n;i++)
    {
        cin>>a[i];
    }
    int T;
    cin>>T;
    while(T--)
    {
        int x,y;
        cin>>x>>y;
        int l=1;
        int r=n;
        while(l<r)
        {
            int mid=l+(r-l)/2;
            if(a[mid]>=x)
            {
                r=mid;
            }
            else
            {
                l=mid+1;
            }
        }
        //最小范围都大于输入元素的最大值
        if(a[l]<x)
        {
            cout<<0<<endl;
            continue;
        }
        int tl=l;
        l=1,r=n;
        while(l<r)
        {
            int mid=l+(r-l+1)/2;
            if(a[mid]<=y)
            {
                l=mid;
            }
            else
            {
                r=mid-1;
            }
        }
        //最大范围的数都小于输入元素的最小值
        if(a[l]>y)
        {
            cout<<0<<endl;
            continue;
        }
        cout<<l-tl+1<<endl;
    }
}

2.2 A-B 数对

https://www.luogu.com.cn/problem/P1102

这道题的最优解其实是用哈希表 之前的博客写过这种解法 点击转跳

这里提供二分的解法思路 希望可以拓展大家解题的思路和想法


题目性质:元素的顺序是不影响最终结果的!

  1. 把整个数组排序

  2. 通过B=A-C 即枚举A 然后利用二分查找(同时B肯定是小于A的)有多少个B!
    【STL 的使⽤】

  3. lower_bound:传入要查询区间的左右迭代器(注意是左闭右开的区间,如果是数组就是左右指针)以及要查询的值 (k),然后返回该数组中 (>= k) 的第一个位置;

  4. upper_bound:传入要查询区间的左右迭代器(注意是左闭右开的区间,如果是数组就是左右指针)以及要查询的值 (k),然后返回该数组中 (> k) 的第一个位置;

比如:(a = [10, 20, 20, 20, 30, 40]),设下标从 1 开始计数,在整个数组中查询 20:

  1. lower_bound(a + 1, a + 1 + 6, 20),返回 (a + 2) 位置的指针;
  2. upper_bound(a + 1, a + 1 + 6, 20),返回 (a + 5) 位置的指针;
  3. 然后两个指针相减,就是包含 20 这个数区间的长度。
cpp 复制代码
#include<bits/stdc++.h>
using namespace std;
typedef long long LL;
const int N=2e5+10;
LL n, c;
LL a[N];

int main()
{
    cin >> n >> c;
    for(int i = 1; i <= n; i++) cin >> a[i];
    sort(a + 1, a + 1 + n);
    LL ret = 0;
    for(int i=2;i<=n;i++)
    {
        LL b = a[i] - c;
        //利用了b绝对不会大于a[i]的特性
        ret += upper_bound(a + 1, a + i, b) - lower_bound(a + 1, a + i, b);
    }
    cout << ret << endl;
}

2.3 烦恼的高考志愿

https://www.luogu.com.cn/problem/P1678


先把学校的录取分数「排序」,然后针对每一个学生的成绩 b b b,在「录取分数」中二分出 ≥ b \ge b ≥b 的「第一个」位置 p o s pos pos(大于等于 归属于查找起始位置的模版),那么差值最小的结果要么在 p o s pos pos 位置,要么在 p o s − 1 pos - 1 pos−1 位置。

取 a b s ( a [ p o s ] − b ) abs(a[pos] - b) abs(a[pos]−b) 与 a b s ( a [ p o s − 1 ] − b ) abs(a[pos - 1] - b) abs(a[pos−1]−b) 两者的「最小值」即可。

细节问题:

  • 如果所有元素都大于 b b b 的时候, p o s − 1 pos - 1 pos−1 会在 0 下标的位置,有可能结果出错;

解决方法:1. 所有元素大于b的时候单独考虑就行

复制代码
        if(x<=a[1])
     {
       ret+=abs(a[1]-x);
      continue;
     }

  1. 加个左右护法
    a[0] = -1e7 + 10; 这样哪怕是求min值 abs(a[0]-x)绝对大于abs(a[1]-x)!
  • 如果所有元素都小于 b b b 的时候, p o s pos pos 会在 n n n 的位置,此时结果倒不会出错,(因为比如说100 200 300 你估分500 min(500-300,500-200)最后肯定是500-300值小 最后结果不会有影响!)但是我们要想到这个细节问题,这道题不出错不代表下一道题不出错。
cpp 复制代码
#include<bits/stdc++.h>
using namespace std;
typedef long long LL;
const int N=1e5+10;
int n,m;
LL a[N];

int main()
{
    cin>>m>>n;
    for(int i=1;i<=m;i++)
    {
        cin>>a[i];
    }
    sort(a+1,a+1+m);

    LL ret=0;
    while(n--)
    {
        int x;
        cin>>x;
        int left=1;int right=m;
        
         if(x<=a[1])
        {
            ret+=abs(a[1]-x);
             continue;
        }

        while(left<right)
        {
            int mid=left+(right-left)/2;
            if(a[mid]>=x) right=mid;
            else left=mid+1;
        }
        ret+=min(abs(a[left]-x),abs(a[left-1]-x));

    }
    cout<<ret;
}

3. 二分答案

准确来说,应该叫做 二分答案 + 判断

二分答案可以处理大部分 最大值最小 以及 最小值最大 的问题。如果 解空间 在从小到大的变化 过程中,判断答案的结果出现二段性,此时我们就可以二分这个解空间,通过判断,找出最优解。

刚接触的时候,可能觉得这个算法原理很抽象。没关系,3 道例题的详细解析后,你会发现这个二分答案的原理其实很容易理解,重点是如何去判断答案的可行性。

看不懂定义也没事 咱们直接从例题中讲解算法!

3.1 木材加工(二分答案模版题)

https://www.luogu.com.cn/problem/P2440


x表示:切割出来的小段长度
c表示:x的基础下,最多能切出来多少段
k表示:最终要切割的段数


一. 先从暴力解法开始想(例子就是样例):

  1. 枚举所有的切割长度x(我能从0枚举到456),然后求出在x的情况下,能够切出来的段数c
  2. 我们用a={232,124,456} 然后直接通过循环 c+=a[i]/x 即可 如果c>=7则符合要求,我么只需要找出最大的那个x即可!

在暴力解的过程中 我们很容易能找到以下规律:

随着x的增大,c一定是在减小的。

结合这个性质,我们可以倒着枚举x,当碰到第一个c>=7时,就是这道题目的答案!


二:解法二:

利用二分进行优化!(从后往前枚举!

我们假设一个点x=ret 在这个点我们切出来的最大段数是大于等于k 的(即此时ret点为最终结果)!如果枚举的长度大于ret 那么此时我么能切出来的最大段数一定是 小于k的!那么包括ret这个点往左的区域 我们能切割出来的段数 一定是大于等于k的!

此时 我们可以说 这道题目是有二段性质的!


  1. 我们定义两个变量left=0 right=maxlen 然后定一个函数calc(x) 用来计算能切出来多少段!
  2. 如果我们发现 calc(mid)>=k 说明此时我们的mid落在了左边的区域:

    所以此时 left=mid
  3. calc(mid)<k的时候 此时mid落在右边区域:

    所以,此时right=mid-1

细节问题,此时求的mid-1 中点就用mid=(left+right+1)/2就行了,详情可以看1.1部分 或者自己举个简单例子就行 因为会造成死循环

cpp 复制代码
#include<bits/stdc++.h>
using namespace std;
typedef long long LL;

const int N=1e5+10;
LL n,k;
LL a[N];

LL calc(LL x)
{
    LL cnt=0;
    for(int i=1;i<=n;i++)
    {
        cnt+=a[i]/x;
    }
    return cnt;
}

int main()
{
  cin>>n>>k;
    for(int i=1;i<=n;i++)
    {
        cin>>a[i];
    }
//注意 left必须从0开始 因为可能总长度达不到需要的段数(比如共100cm木头 要砍101份)那么此时的c>=k时 x只能等于0!!!
    LL left=0,right=1e8;
    while(left<right)
    {
        LL mid=(left+right+1)/2;
        if(calc(mid)>=k) left=mid;
        else right = mid-1;
    }
    cout<<left<<endl;
    return 0;
}

二分答案算法小总结

之前看不懂二分答案的定义是啥 现在解决完3.1 这里的二分指的就是答案的二分!可以处理大部分 最大值最小 以及 最小值最大 的问题!结合上面的例题 其实就是在一堆偏小的结果中(比如说分7段可以切1cm 也可2cm 但是3cm最大)找到那个最大值的问题!

最小中最大 最大中最小!我们可以切割答案 找到二段性即可!

3.2 砍树

https://www.luogu.com.cn/problem/P1873


练手题 如果3.1 知识点掌握 那么这道题很简单!

设伐木机的高度为 H,能得到的木材为 C。根据题意,我们可以发现如下性质,:

  • 当 H 增大的时候,C 在减小;
  • 当 H 减小的时候,C 在增大。

那么在整个「解空间」里面,设最终的结果是 ret,于是有:

  • 当 H ≤ ret 时,C ≥ M。也就是「伐木机的高度」大于等于「最优高度」时,能得到的木材「小于等于」M;

  • 当 H > ret 时,C < M。也就是「伐木机的高度」小于「最优高度」时,能得到的木材「大于」M。

在解空间中,根据 ret 的位置,可以将解集分成两部分,具有「二段性」,那么我们就可以「二分答案」。

cpp 复制代码
#include<bits/stdc++.h>
using namespace std;
typedef long long LL;
const int N=1e6+10;
LL a[N];
int n,m;
LL sum(int mid)
{
    LL sum=0;
    for(int i=1;i<=n;i++)
    {
        if(mid<a[i])
        {
            sum+=(a[i]-mid);
        }
    }
    return sum;
}
int main()
{
    cin>>n>>m;
    for(int i=1;i<=n;i++)
    {
        cin>>a[i];
    }
    LL l=0,r=1e9;
    while(l<r)
    {
       LL mid=(l+r+1)/2;
       if(sum(mid)>=m) l=mid;
       else r=mid-1;
    }
    cout<<l;
}

3.3 跳石头(挺难的 难在找二分性~)

https://www.luogu.com.cn/problem/P2678


x:表示最短的跳跃距离(范围是[1,L]
c表示:在跳跃距离为x的情况下移走的岩石数


寻找二段性:

首先需要发现一个规律

  • 当 (x) 增大的时候,(c) 也在增大;
  • 当 (x) 减小的时候,(c) 也在减小。

那么在整个「解空间」里面,设最终的结果是 r e t ret ret,于是有:

  • 当 x ≤ r e t x \le ret x≤ret 时, c ≤ M c \le M c≤M。也就是「每次跳的最短距离」小于等于「最优距离」时,移走的石头块数「小于等于」 M M M;
  • 当 x > r e t x > ret x>ret 时, c > M c > M c>M。也就是「每次跳的最短距离」大于「最优距离」时,移走的石头块数「大于」 M M M。

在解空间中,根据 r e t ret ret 的位置,可以将解集分成两部分,具有「二段性」,那么我们就可以「二分答案」。


我们假设一个函数calc(x) 表示当跳跃距离为x的情况下,移走的岩石个数。


calc这个函数怎么实现呢?(这个其实也不简单

  • 定义前后两个指针 i , j i, j i,j 遍历整个数组,设 i ≤ j i \le j i≤j,每次 j j j 从 i i i 的位置开始向后移动;
  • 当第一次发现 a [ j ] − a [ i ] ≥ x a[j] - a[i] \ge x a[j]−a[i]≥x 时,说明 [ i + 1 , j − 1 ] [i + 1, j - 1] [i+1,j−1] 之间的石头都可以移走;
  • 然后将 i i i 更新到 j j j 的位置,继续重复上面两步。
cpp 复制代码
#include <iostream>
using namespace std;
typedef long long LL;
const int N = 5e4 + 10;
LL l, n, m;
LL a[N];
// 当最短跳跃距离为 x 时,移⾛的岩⽯数⽬
LL calc(LL x)
{
LL ret = 0;
for(int i = 0; i <= n; i++)
{
int j = i + 1;
while(j <= n && a[j] - a[i] < x) j++;
ret += j - i - 1;
i = j-1 ;//因为for循环有个i++ 所以更新到j-1的位置就行
}
return ret;
}
int main()
{
cin >> l >> n >> m;
for(int i = 1; i <= n; i++) cin >> a[i];
a[n + 1] = l;
n++;
LL left = 1, right = l;
while(left < right)
{
LL mid = (left + right + 1) / 2;
if(calc(mid) <= m) left = mid;
else right = mid - 1;
}
cout << left << endl;
return 0;
}
相关推荐
SteveSenna2 小时前
强化学习4.1:基于价值——Q-learning
人工智能·学习·算法·机器人
少许极端2 小时前
算法奇妙屋(四十四)-贪心算法学习之路11
java·学习·算法·贪心算法
子琦啊2 小时前
【算法复习】数组与双指针篇
javascript·算法
ambition202422 小时前
斐波那契取模问题的深入分析:为什么提前取模是关键的
c语言·数据结构·c++·算法·图论
逆境不可逃2 小时前
LeetCode 热题 100 之 230. 二叉搜索树中第 K 小的元素 199. 二叉树的右视图 114. 二叉树展开为链表
算法·leetcode·职场和发展
一个有温度的技术博主2 小时前
Redis Cluster 核心原理:哈希槽与数据路由实战
redis·算法·缓存·哈希算法
wfbcg3 小时前
每日算法练习:LeetCode 15. 三数之和 ✅
算法·leetcode·职场和发展
2301_822703203 小时前
开源鸿蒙跨平台Flutter开发:跨端图形渲染引擎的类型边界与命名空间陷阱:以多维雷达图绘制中的 dart:ui 及 StrokeJoin 异常为例
算法·flutter·ui·开源·图形渲染·harmonyos·鸿蒙
y = xⁿ3 小时前
【LeetCode Hot100】双指针:分离指针
算法·leetcode