本文正在参加「金石计划」
二分查找背模板?我选择去理解它!:浮点篇
零、序言
在之前的基础篇里,我们讲过了最常用的两种二分查找:(没有看过的可以点进主页看看基础篇)
- 找出数组中第一个大于等于x的数的位置;
- 找出数组中最后一个小于等于x的数的位置;
一般来说,需要使用二分的情况也基本就是这两种了,二分查找类题也差不多都是由这两个模板伸延而来。
但二分查找除此之外还有一种更特殊的用法,虽然用的地方不多,但是学了也没差嘛。
知识总不会害人的,而且指不定什么时候它就帮了你大忙呢?
今天我们的文章要提到的它就是——
一、浮点型二分
浮点型,通俗易懂的来说就是我们常见的小数,整数在计算机中被称为整型,小数就被称为浮点型。
我们之前基础篇学过的两种模板,都是整型二分,你看,不论是区间长度,还是枚举的下标,他都是整数。
而浮点二分就很神奇了:它的区间端点是带有小数,枚举的位置mid也是带有小数。
此时你可能会觉得奇怪:
”枚举的位置mid带有小数?数组的下标不是只能是非负整数吗?居然还能用小数去指向数组吗?“
那答案当然是!——不能。
数组的下标的的确确只能是非负整数,但是!——我什么时候说,浮点型二分是用在数组上面了?
是的!浮点型二分并不是用于在数组中查找什么东西,在数组中查找东西用基础篇那两个就够够的了,我们浮点型二分可是要干神奇的事!
老规矩,举个栗子:
我给你一个整数x,让你输出它的平方根 ,保留4位小数。
这个对我们大部分同学来说应该是比较简单的,因为大部分同学都知道我们有一个 函数,它可以直接帮我们把一个数 的平方根算出来。我们只要在输出的时候只保留四位小数就行了,很简单呀。
难度升级:
我给你一个整数x,让你输出它的立方根 ,保留4位小数。
再来:
我给你一个整数x,让你输出它的7次方根 ,保留4位小数。
再来!
我给你两个整数x和n,让你输出x的n次方根 ,保留4位小数。
现在呢?现在你还能用sqrt()函数偷懒吗?
我们知道,二分查找的过程就是每次枚举区间的中间位置,然后根据该位置和正确答案的关系来不断地收缩左区间or右区间,最终找到正确答案的位置。
我们举的算n次方根的例子,其实也可以看作是在区间查找答案。
拿算x的立方根为例子,如果x的大小范围是 到 ,那么我们可以给区间的左端点 设为0,右端点 设为10^5(实际上100左右就够了)。
然后每次我们枚举中间的位置 那么当前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个比较明显的区别:
- 、 、 的数据类型不再是int而是改为了double;
- 循环继续的条件从 改为了 大于一个很小的小数;
- 收缩区间时, 、 和 不再有+1或-1操作;
我们一步步来解释:
、 、 的数据类型改为了double,这就是它被称之为浮点二分的原因:我们的区间端点以及枚举的位置都是浮点数,比较好理解。
为什么不用+1-1了呢?
可以想到,浮点二分区间收缩到后面时,整数部分基本已经确定下来了,只是在寻找准确的小数部分,如果我们在收缩的时候将端点+1-1,会修改整数的部分,那有可能直接将答案略过。所以我们不对枚举的位置和区间端点进行+1-1操作。
这也侧面说明了浮点二分没有边界问题,所以可以说它其实比整型二分还简单些。
至于循环结束条件,因为区间端点不再进行+1-1操作,也就是说 和 只能无限接近,虽然最后可以出现l==r的情况,但是这也需要循环一定的次数,太麻烦了。
所以我们想,当 和 已经足够接近的时候,就结束循环,至于这个“足够接近”就和题目要求的精度有关了。
比如在我们之前求根问题时,就说过: “保留4位小数” 。
这个 “四位小数” 就是我们的精度。
如果要求一个绝对准确的答案,那我们就需要循环很多次,把两个端点的所有小数都保持相等。
但如果只要保留4位小数,那么我们只需要两个端点的前4位小数相等就行,后面的n多个小数位我们都不需要处理了。
至于保证两个端点的前4位小数相等,那右端点 减去 的小于0.00001时就可以,此时答案就满足题目的要求了。
所以我们的循环继续条件为: ;
一道题的精度一般会在题目说出,如果没说,我们一般默认是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串的熵
问题描述
问题解析
答案:11027421
这题对我来说主要难点在于算log,说实话我之前真不知道有函数能直接算log,这题的log我是采用了浮点型二分的方法来算的。(所以说,多学一个知识总是好的,关键时候能救命呀)
并且也测试了下S=100的情况发现是正确的。然后我们就枚举0的个数就好了,1的个数就是23333333-0的个数。
不过我这做法有两点要注意的:
- 没必要从0开始枚举0的个数,实际上debug几遍后就能发现大约在11000000左右的时候算出来的值就很接近题目的这个值了。
- 二分的精度要高,一开始我习惯性的设置精度为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;
}
//答案:11027421
void 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;
}
四、结尾
因为没有边界条件,所以浮点二分的模板并不难记。
整型二分之前都是在数组中寻找答案,而浮点二分比起 “寻找答案” ,更像是 “枚举答案” 。
本片文章在介绍浮点二分这一特殊的二分查找模板外,也是想让大家提前体验一下 “枚举答案” 的思想和巧妙之处。
这也是二分中,另一个很重要的用法——二分答案。,这也是我们下次文章将要讲到的一种神奇的算法。