二分法

二分法的概念

二分法(Bisection method),即一分为二的的方法。对于在区间$[a,b]$上连续不断且满足$f(a)*f(b)<0$的函数$y=f(x)$,通过不断地把函数$f(x)$的零点所在区间二等分,使区间两个端点逐步逼近零点,进而得到零点的近似值的方法。
简单来说就是
一般用于求解一个具有单调性的函数的分界点。

二分实际上也是一种枚举,只不过是使用的数学方法大大减少了无意义的枚举次数,时间复杂度也优化成$O(logn)$。

我们一般所说的二分是二分查找法,本文就用二分简说。

简单来说二分搜索法可以用来查找满足某种条件的最大(最小)的值。

单调性

在二分前,我们需要研究问题的单调性。
比如我们要求在正整数定义域内函数满足:
$$f(x) = x^2 ≥ k$$
的一个最小的x,其中k一般是一个定值。
我们很容易发现$x^2$的函数在正整数定义域内是单调递增的。

当然二分不止用在这么简单的场景,其他场景需要你自行分析。

二段性

集合中的元素有存在分界线,给定条件可以将集合中元素分为两部分,一部分满足条件,一部分不满足条件。
例如当我们要查找一个数组中小于$14$的最大的一个数:

$$1 ,3 ,8 ,13 ,15 ,20 ,23$$

这时可以发现从13和15中间就有一条分界线,左边是满足我们的条件的,右边是不满足的。

于是就可以通过这个将整个区间分为两个区间。具体分为哪段到哪段就看你的写法。
所以我们就可以确定二分的方式,以及代码编写的选择,当然,如果是实数二分就不存在这样的区别。

枚举区间

当我们使用二分的时候,还需要考虑的一点是,枚举的一个区间$[l,r]$。
这个区间一定是包含我们的最终答案的。

判断函数

1
bool check(int x) {/* ... */} // 检查x是否满足某种性质

就刚刚的例子而言,我们的判断函数$check()$即为$mid^2 ≥ k$ (mid即为二分时的中间点)
判断函数的编写也是二分的重中之重,稍微一步写错就有可能造成找不到答案或者死循环的情况。

确定答案

和题意结合,输出对应的答案,这一步尤其要注意,要理解清楚题目的意思,比如第一个大于等于的,最后一个小于的,最后一个小于等于的等等等等。需要自己多加小心(通常这些字眼就暗示了这一道题需要用二分来优化)

分区间

对应上文的分界线前后的元素,是属于整数二分的内容。

1
2
3
4
5
6
7
8
9
10
11
// 区间[l, r]被划分成[l, mid]和[mid + 1, r]时使用:
int binsearch(int l, int r)
{
while (l < r)
{
int mid = l + r >> 1;
if (check(mid)) r = mid; // check()判断mid是否满足性质
else l = mid + 1;
}
return l;
}
1
2
3
4
5
6
7
8
9
10
11
// 区间[l, r]被划分成[l, mid - 1]和[mid, r]时使用:
int binsearch(int l, int r)
{
while (l < r)
{
int mid = l + r + 1 >> 1;
if (check(mid)) l = mid;
else r = mid - 1;
}
return l;
}

实数二分

实数二分就不用考虑整数二分的困扰,直接二分即可,要考虑精度,可以用浮点数比较法,还有一种方法是限制二分次数。

1
2
3
4
5
6
7
8
9
10
11
12
13
bool check(double x) {/* ... */} // 检查x是否满足某种性质

double binsearch(double l, double r)
{
const double eps = 1e-6; // eps 表示精度,取决于题目对精度的要求
while (r - l > eps)
{
double mid = (l + r) / 2;
if (check(mid)) r = mid;
else l = mid;
}
return l;
}

库函数

除了手写二分,还有一些库函数可以直接使用。

  • lower_bound(x):返回大于等于 x 的第一个迭代器;
  • upper_bound(x):返回大于 x 的第一个迭代器;
  • binary_search(x):在容器中二分查找 x 是否存在。

lower_bound( begin,end,num):从数组的begin位置到end-1位置二分查找第一个大于或等于num的数字,找到返回该数字的地址,不存在则返回end。通过返回的地址减去起始地址begin,得到找到数字在数组中的下标。

upper_bound( begin,end,num):从数组的begin位置到end-1位置二分查找第一个大于num的数字,找到返回该数字的地址,不存在则返回end。通过返回的地址减去起始地址begin,得到找到数字在数组中的下标。

在从大到小的排序数组中,重载lower_bound()和upper_bound()

lower_bound( begin,end,num,greater() ):从数组的begin位置到end-1位置二分查找第一个小于或等于num的数字,找到返回该数字的地址,不存在则返回end。通过返回的地址减去起始地址begin,得到找到数字在数组中的下标。

upper_bound( begin,end,num,greater() ):从数组的begin位置到end-1位置二分查找第一个小于num的数字,找到返回该数字的地址,不存在则返回end。通过返回的地址减去起始地址begin,得到找到数字在数组中的下标。

binary_search (begin,end,num,sort_rule);
意思是,begin和end-1 的查找区间内,查找“等于”num的元素,返回值为1 (true 找到) 或0 (false 没找到)。

三分法

三分的一大运用便是求解函数的极值点,虽然很高大上但是实际写起来并不复杂,基本与二分一致。

整数凸函数

1
2
3
4
5
6
7
8
ll l, r;
while(l < r) {
ll lmid = l + (r - l) / 3;
ll rmid = r - (r - l) / 3;
if(calc(lmid) <= calc(rmid)) l = lmid + 1;
else r = rmid - 1;
}
printf("%lld\n", max(calc(l), calc(r)));

整数凹函数

1
2
3
4
5
6
7
8
ll l, r;
while(l < r) {
ll lmid = l + (r - l) / 3;
ll rmid = r - (r - l) / 3;
if(calc(rmid) >= calc(lmid)) r = mid - 1;
else l = mid + 1;
}
printf("%lld\n", min(calc(l), calc(r)));

实数

这里采用的是限制循环次数法。

1
2
3
4
5
6
7
8
double l, r;
for(int i = 0; i < 300; i++) {
double lmid = l + (r - l) / 3;
double rmid = r - (r - l) / 3;
if(calc(lmid) <= calc(rmid)) l = lmid;
else r = rmid;
}
printf("%.6f\n", calc(l));

1
2
3
4
5
6
7
8
double l, r;
for(int i = 0; i < 300; i++) {
double lmid = l + (r - l) / 3;
double rmid = r - (r - l) / 3;
if(calc(rmid) >= calc(lmid)) r = rmid;
else l = lmid;
}
printf("%.6f\n", calc(l));

题目练习

P1873 [COCI 2011/2012 #5] EKO / 砍树 - 洛谷 | 计算机科学教育新生态 (luogu.com.cn)

P2678 [NOIP2015 提高组] 跳石头 - 洛谷 | 计算机科学教育新生态 (luogu.com.cn)

P3382 三分 - 洛谷 | 计算机科学教育新生态 (luogu.com.cn)


二分法
http://pikachuxpf.github.io/posts/3510/
作者
Pikachu_fpx
发布于
2024年1月10日
许可协议