二分查找背模板?我选择去理解它!:浮点篇

1,213 阅读9分钟

本文正在参加「金石计划」

二分查找背模板?我选择去理解它!:浮点篇

零、序言

在之前的基础篇里,我们讲过了最常用的两种二分查找:(没有看过的可以点进主页看看基础篇)

  • 找出数组中第一个大于等于x的数的位置;
  • 找出数组中最后一个小于等于x的数的位置;

一般来说,需要使用二分的情况也基本就是这两种了,二分查找类题也差不多都是由这两个模板伸延而来

但二分查找除此之外还有一种更特殊的用法,虽然用的地方不多,但是学了也没差嘛。

知识总不会害人的,而且指不定什么时候它就帮了你大忙呢?

今天我们的文章要提到的它就是——

一、浮点型二分

浮点型,通俗易懂的来说就是我们常见的小数,整数在计算机中被称为整型,小数就被称为浮点型。

我们之前基础篇学过的两种模板,都是整型二分,你看,不论是区间长度,还是枚举的下标,他都是整数。

而浮点二分就很神奇了:它的区间端点是带有小数,枚举的位置mid也是带有小数

此时你可能会觉得奇怪:

”枚举的位置mid带有小数?数组的下标不是只能是非负整数吗?居然还能用小数去指向数组吗?“

那答案当然是!——不能

数组的下标的的确确只能是非负整数,但是!——我什么时候说,浮点型二分是用在数组上面了?

QQ图片20230420211704.jpg

是的!浮点型二分并不是用于在数组中查找什么东西,在数组中查找东西用基础篇那两个就够够的了,我们浮点型二分可是要干神奇的事!

老规矩,举个栗子:

我给你一个整数x,让你输出它的平方根 x\sqrt{x} ,保留4位小数。

这个对我们大部分同学来说应该是比较简单的,因为大部分同学都知道我们有一个 sqrt(intx)sqrt(int x) 函数,它可以直接帮我们把一个数 xx 的平方根算出来。我们只要在输出的时候只保留四位小数就行了,很简单呀。

难度升级:

我给你一个整数x,让你输出它的立方根 x3\sqrt[3]{x} ,保留4位小数。

再来:

我给你一个整数x,让你输出它的7次方根 x7\sqrt[7]{x} ,保留4位小数。

再来!

我给你两个整数x和n,让你输出x的n次方根 xn\sqrt[n]{x} ,保留4位小数。

现在呢?现在你还能用sqrt()函数偷懒吗?

我们知道,二分查找的过程就是每次枚举区间的中间位置,然后根据该位置和正确答案的关系来不断地收缩左区间or右区间,最终找到正确答案的位置。

我们举的算n次方根的例子,其实也可以看作是在区间查找答案。

拿算x的立方根为例子,如果x的大小范围是 0010510^5 ,那么我们可以给区间的左端点 ll 设为0,右端点 rr 设为10^5(实际上100左右就够了)。

然后每次我们枚举中间的位置 midmid 那么当前mid和答案的关系就是,如果 midmidmidmid * mid * mid 的值大于x,就说明我们枚举的立方根过大了,右区间收缩;反之如果小于x,就说明枚举的立方根过小,左区间收缩。

重复这一过程,如果x有一个整数的立方根(比如8的立方根是2),那我们就一定可以根据这个方法,求出这个立方根

但是要注意,这里有一个很重要的条件:如果x有一个整数的立方根

是的,如果x没有整数的立方根,我们又怎么可以通过枚举整数来获得这么一个结果呢?

而事实上,具有整数立方根的数其实不算多,占大头的还是那些没有整数立方根的数,对于它们,我们再去用整型二分显然是不行的。

这时候就要浮点型二分出手了!它是怎么做到的呢?

让我们来看看——

二、好奇怪又好简单的实现过程

我们先看一下基础篇的整型写法:
//查找数组中第一个大于x的元素
while(l<r)
{
    int mid=(l+r)/2;
    if(a[mid]<x)l=mid+1;
    else r=mid;
}
​
//查找数组中最后一个小于等于x的元素
while(l<r)
{
    int mid=(l+r+1)/2;
    if(a[mid]<=x)l=mid;
    else r=mid-1;
}
再看看浮点二分的写法:
double l=0,r=100000;
while(r-l>1e-5)
{
    double mid=(l+r)/2;
    if(mid*mid*mid>=x)r=mid;
    else l=mid;
}

经过比较,不难看出浮点二分和整型二分有3个比较明显的区别:

  1. llrrmidmid 的数据类型不再是int而是改为了double;
  2. 循环继续的条件从 l<rl < r 改为了 rlr - l 大于一个很小的小数;
  3. 收缩区间时, llrrmidmid 不再有+1或-1操作;

我们一步步来解释:

llrrmidmid 的数据类型改为了double,这就是它被称之为浮点二分的原因:我们的区间端点以及枚举的位置都是浮点数,比较好理解。

为什么不用+1-1了呢?

可以想到,浮点二分区间收缩到后面时,整数部分基本已经确定下来了,只是在寻找准确的小数部分,如果我们在收缩的时候将端点+1-1,会修改整数的部分,那有可能直接将答案略过。所以我们不对枚举的位置和区间端点进行+1-1操作。

这也侧面说明了浮点二分没有边界问题,所以可以说它其实比整型二分还简单些。

至于循环结束条件,因为区间端点不再进行+1-1操作,也就是说 llrr 只能无限接近,虽然最后可以出现l==r的情况,但是这也需要循环一定的次数,太麻烦了。

所以我们想,当 llrr 已经足够接近的时候,就结束循环,至于这个“足够接近”就和题目要求的精度有关了。

比如在我们之前求根问题时,就说过: “保留4位小数”

这个 “四位小数” 就是我们的精度。

如果要求一个绝对准确的答案,那我们就需要循环很多次,把两个端点的所有小数都保持相等。

但如果只要保留4位小数,那么我们只需要两个端点的前4位小数相等就行,后面的n多个小数位我们都不需要处理了。

至于保证两个端点的前4位小数相等,那右端点 rr 减去 ll 的小于0.00001时就可以,此时答案就满足题目的要求了。

所以我们的循环继续条件为: rl>精度r - l > 精度

一道题的精度一般会在题目说出,如果没说,我们一般默认是1e-6,即10^{-6}。

补充一下,除了利用while循环配合精度来进行浮点二分时,还有一种利用for循环的神奇的写法:

double l=0,r=100000;
for(int i=1;i<=500;i++)
{
    double mid=(l+r)/2;
    if(mid*mid*mid>=x)r=mid;
    else l=mid;
}

这样就是不管精度多少,固定循环500次后就结束二分。实际循环500次的精度就已经足够了,再写题时也可以适当的增加或减少循环次数。

至此,浮点二分的原理和实现我们都讲完了。

还是那句话,掌握一个知识点不是看看网课看看文章就能叫会了,而是通过写题积累经验,把这个知识点玩出花来。

趁热打铁,我们——

三、苏醒了,猎题时刻

790. 数的三次方根 - AcWing题库

问题解析

这题就是要我们求n的三次方根,n的范围是-10000到10000,那么l的初始值可以设为-100,r的初始值可以设为100,然后在之中寻找n的三次方根。要注意的是题目要求我们输出6位小数,那么就需要把精度设为1e-7。

AC代码

#include<iostream>
using namespace std;
int main()
{
    double x;
    scanf("%lf",&x);
    double l=-100,r=100, mid;
    while(r-l>1e-7)
    {
        mid=(l+r)/2;
        if(mid*mid*mid>=x)r=mid;
        else l=mid;
    }
    printf("%lf",l);
    return 0;
}

第十四届蓝桥杯C++B组:B、01串的熵

问题描述

屏幕截图 2023-04-08 194122.png

问题解析

答案:11027421

这题对我来说主要难点在于算log,说实话我之前真不知道有函数能直接算log,这题的log我是采用了浮点型二分的方法来算的。(所以说,多学一个知识总是好的,关键时候能救命呀)

并且也测试了下S=100的情况发现是正确的。然后我们就枚举0的个数就好了,1的个数就是23333333-0的个数。

不过我这做法有两点要注意的:

  1. 没必要从0开始枚举0的个数,实际上debug几遍后就能发现大约在11000000左右的时候算出来的值就很接近题目的这个值了。
  2. 二分的精度要高,一开始我习惯性的设置精度为1e-6,结果咋都跑不出正确结果,后来折磨了半小时后才发现精度要开到1e-15左右才能出正确结果。哭死。

AC代码

#include<iostream>
using namespace std;
#include<cstdio>
#include<algorithm>
#include<string>
#include<unordered_map>
#include<queue>
#include<vector>
#include<stack>
#include<map>
#include<string.h>
#include<math.h>
#include<iomanip>
​
​
#define int long long
#define endl '\n'
typedef long long ll;
typedef pair<ll,ll> PII;
const int N=1e5+10;
​
​
//算log2(a)的值(我不会用函数啥的算,也不知道有没有现成的函数,但我知道有现成的幂运算) 
double check(double a)
{
    double l=-10,r=0;
    //精度开小了会寄掉,一开始我写的1e-6,咋都不出结果 
    while(abs(r-l)>=1e-15)
    {
        double mid=(l+r)/2;
        if(pow(2,mid)>a)r=mid;
        else l=mid; 
    }
    return l;       
} 
​
//答案:11027421void solve()
{
    //double x=check(1.0/3),y=check(2.0/3);
    //测试后,答案等于1.3083,说明方法没问题 
    //cout<<fixed<<setprecision(5)<<-1.0/3*x+-2.0/3*y-2.0/3*y;
    
    
    //没必要从0开始枚举,算的太慢了 
    for(int i=11026000;i<=23333333/2;i++)
    {
        if(i>=23333333-i)continue;
        double a=i/23333333.0,b=(23333333-i)/23333333.0;
        double x=check(a),y=check(b);
        double z=i*(-1.0*i/23333333.0*x)+(23333333-i)*(-1.0*(23333333-i)/23333333.0*y);
​
        if(abs(11625907.5798-z)<=1e-4)
        {
            cout<<i<<endl;
            return;
        }
​
    }
    
}
​
signed main()
{
    ios_base::sync_with_stdio(false);
    cin.tie(0);
    cout.tie(0);
    
    int t=1;
    //cin>>t;
    while(t--)
    {
        solve();
    }
    
    return 0;
}

四、结尾

因为没有边界条件,所以浮点二分的模板并不难记。

整型二分之前都是在数组中寻找答案,而浮点二分比起 “寻找答案” ,更像是 “枚举答案”

本片文章在介绍浮点二分这一特殊的二分查找模板外,也是想让大家提前体验一下 “枚举答案” 的思想和巧妙之处。

这也是二分中,另一个很重要的用法——二分答案。,这也是我们下次文章将要讲到的一种神奇的算法。

最后如果本篇文章帮到了您,不知是否能点一个小小的赞呢。(拜托了!这对我真的很重要!)

QQ图片20230402115236.gif