最近一周算法群里一直在刷二分的题目。发现了很多同学对于二分还是有很多没有摸清的套路。所以我承诺写一篇关于二分的方法汇总。
数组查值
这是被教材讲烂的一种类型,也是很多教科书上的一种误区。也许算法很多程度上都会让自身的算法场景变成了算法本身。例如我们学会了可以解决区间和的线段树,就总会把线段树局限在区间和的问题上。求解区间和只是利用线段树来解决的场景之一,更重要的是线段树的这个数据结构以及它所描述的父子结点的关系。
言归正传,我们继续来说二分查找,并且我们将二分上升到一种解题思路来看这个最基础的问题。
精准查值问题
给定长度为
n
的单调递减且互不相同数列a0, a1 ... an
和一个数k
,求满足ai = k
条件的 i。
这是我们最常见的,也是二分查找的引出问题:数组查值。一般教科书上都会从 O(n)
遍历来查找一遍,再引入 left
和 right
两个游标来卡范围,然后反复的试探 mid
中点,根据大小来逐渐缩小范围。这些我相信大家都明白,所以思路就不再赘述。
其中比较麻烦的是,我们如何实现这个过程?二分查找的代码上网搜可以搜出很多种,但是当我们自己写代码的时候为什么总是处理不好循环的判断条件和大小判断的条件?其实是没有去发现其中的规律。
在解题之前,我们来确定一下求解目标值 Target 的关系式。给定范围的 x
存在:
关系式的结果表示,我们所求的 Target 与 x
呈递增关系,所以在给定范围后可以通过二分来求解。
以下是瓜最常用的精准查值的二分代码:
def binary_search(nums: list, target: int):
l, r = 0, len(nums) - 1
# 1. 用 l <= r 来作为判断条件
while l <= r:
# 2. 使用 l + (r - l) 来防溢出
mid = l + (r - l) // 2
# 3. 虽然有三个分支的 if 但是肯定不会写错
if nums[mid] < target:
l = mid + 1
elif nums[mid] > target:
r = mid - 1
elif nums[mid] == target:
return mid
return -1
print(binary_search([1, 2, 5, 7, 19, 29], 5)) # 2
print(binary_search([1, 2, 5, 7, 19, 29], 6)) # -1
来讲一下代码:
while l <= r
这个循环的判断,决定了查询区间是[l, r]
这个左闭右闭区间;很多题解写作while l < r
其实是[l, r)
这个左闭右开区间,例如 C++ 的algorithm.h
系统库中引入的几个二分查找方法都是这样;使用
l + (r - l) // 2
是为了防止,l + r
溢出,虽然一般在我们的计算范围内不大可能出现溢出情况,但是可以避免特殊情况踩坑;为什么要三种条件都判断?因为这样就可以避免无谓的机械记忆。我们只要记住小于目标值,向右移动左坐标;大于目标值,向左移动右坐标;等于目标值,直接返回结果。
lower_bound
问题
给定长度为
n
的单调不下降数列a0, a1 ... an
和一个数k
,求满足ai ≥ k
条件的最小的i
。
这个问题大家都不会陌生,就是有序数组查第一个比 k
大的数。既然我们在查精准值的例子中已经掌握了二分左闭右闭的方法,那么我们继续修改上述代码来实现 lower_bound
问题。
def binary_search(nums: list, target: int):
l, r = 0, len(nums) - 1
# 1. 用 l <= r 来作为判断条件
while l <= r:
# 2. 使用 l + (r - l) 来防溢出
mid = l + (r - l) // 2
# 3. 虽然有三个分支的 if 但是肯定不会写错
if nums[mid] < target:
l = mid + 1
elif nums[mid] > target:
r = mid - 1
elif nums[mid] == target:
return mid
return l
print(binary_search([1, 2, 5, 7, 19, 29], 5)) # 2
print(binary_search([1, 2, 5, 7, 19, 29], 6)) # 7
我们在精准差值的时候,为了判断“是否存在”这个命题,所以在未找到值的时候,便返回 -1
来代表这个值不存在。所以当需要查找 lower_bound
下边界的时候,只要返回未找到值时左边界 l
的末状态即可完成。
对应的,upper_bound
你可以自己考虑一下,使用这种方式要怎么实现。
解的可行性判断
这是精准查值的一种变体,往往题目会直接或者间接给你一个解的范围,让你求出在这个范围内是否有可行的解。给出一道例题:
有 N 条绳子,它们的长度分别为 Li。如果从它们中割出 K 条长度相同的绳子的话,这 K 条绳子每条最长有多长?答案保留到小数点后两位。(POJ 1064)
给出一组样例,N = 4, K = 11, L = {8.02, 7.43, 4.57, 5.39}
,输出 2.00
。代表每条绳子分割后可以得到:4条、3条、2条、2条,共计 11 条绳子。
这个问题我们同样可以使用二分来求解出答案,接下来我们来套用二分搜索模型来尝试解决并给出实现方案。
同样的,我们设 x
为我们需要求的值,即可以得到 K
条长度为 x
的绳子。接下来我们来确定长度 x
的上下界,为了可以泛化这类问题,我们将上下界设置为自然数上下界:[0, INF]
。
然后我们列出 K = T(x)
的表达式:
此时我们发现,K
与 x
是呈反比关系的,即确定了 x
是随着 K
的增加而单调递减的。所以当确定了 x
的范围,便可以使用二分来确定 x
的可行性解。
#include
#include
#include
#include
using namespace std;
#define EPS 1e-6
int N, K;
double L[10005];
int tot(double x) {
int num = 0;
for (int i = 0; i < N; ++ i) {
num += (int)(L[i] / x);
}
return num;
}
int main() {
scanf("%d %d", &N, &K);
double l = 0.0, r = 0.0;
for (int i = 0; i < N; ++ i) {
scanf("%lf", &L[i]);
r = max(r, L[i]);
}
r += 1;
while (l <= r) {
double mid = (l + r) / 2;
if (fabs(l - r) < EPS) {
break;
}
int x = tot(mid);
if (x >= K) {
l = mid;
}
else {
r = mid;
}
}
printf("%.2lf\n", floor(r * 100) / 100);
}
可行性解的问题归纳一下其实有以下特点:
有且只有一个待确定的变量,其他都是给定值,但是由于数据的输入问题,无法通过数学推导式立即求解;
题目数据中的输入变量与待确定解呈现单调性关系,确定范围后即可通过二分逐渐缩小可行解的范围;
二分的结束判定中的 EPS 精度
在上面的例题中,我们在判断左右边界的结束条件时,使用 EPS
这个精度下限来控制范围。实际上在处理小数的问题中,这是一种判断浮点数相等的惯用方法。
浮点数为什么会有精度问题?
这里顺便带大家复习一些 C 的基础知识。我们以 double
双精度浮点数为例,其表示的数值范围在 [-1.7e-308, 1.7e308]
,十进制精度位数 14-15
位。但是在浮点数运算的时候,其记过的精度一般只有 10-12
位左右。所以最低几位的数值我们就不可预料了。
上面这个描述会给我们带来这样的问题:即使是理论上相同的值,由于经过不同的运算工程而得到的理论上相同的结果值,由于运算的过程不同以及表示的精度不够,从而使得 ==
的操作符所返回的结果不符合预期。而这个问题在上述的带有浮点数比较的二分搜索是致命的!由于计算结果的精度小于浮点数所能表示的精度,造成 while (l <= r)
会发生死循环的现象。
这里给大家举一个例子,大家都知道三角函数,所以我们用三角函数来计算这个两个式子:
#include
#include
int main() {
double a = asin(sqrt(2.0) / 2) * 4.0;
double b = acos(-1.0);
printf( "a = %.20lf\n", a); // a = 3.14159265358979356009
printf( "b = %.20lf\n", b); // b = 3.14159265358979311600
printf( "a - b = %.20lf\n", a - b); // 0.00000000000000044409
printf( "a == b = %d\n", a == b); // a == b = 0
return 0;
}
从这个例子就可以看出,原本相同的 pi 值,却因为计算精度问题,造成了浮点数误差
EPS 处理方法
EPS
缩写自 epsilon
,表示一个小量,但这个小量又要确保远大于浮点运算结果的不确定量。EPS
最常见的取值是 1e-8
左右,当然根据题目需要,你可以自行控制精度范围。引入 EPS
后,我们判断两浮点数 a、b
相等的方式如下:
传统意义 | EPS 修正写法 |
---|---|
a == b | fabs(a – b) < eps |
a != b | fabs(a – b) > eps |
a < b | a – b < -eps |
a <= b | a – b < eps |
a > b | a – b > eps |
a >= b | a – b > -eps |
当然,在解题的时候,对于不等式的比较,其实还是可以延用传统写法,在判等的时候,最好加上 EPS
从而在精度范围内控制相等即可。
总结
这篇文章讲述了二分的两种类型:数组查值和解的可行性判断。另外也讨论了在二分上下界的时候,出现浮点数以及浮点数如何比较的情况。另外的两种题型将在下篇中继续讲述,尽情期待~