二分法
L-Coding于2025.8.1作。
至于8.2发的原因:笔者去配置Arch去了(
一、引言
Although the basic idea of binary search is comparatively straightforward, the details can be surprisingly tricky. ——Donald Ervin Knuth
考虑如下情景:假设A和B正在玩一种游戏,游戏规则是这样的:A心里想一个1~100之间的数,B负责猜这个数;对于B所猜的答案,A只能回答“太大”“太小”或是“正好”。
如果B的答案x
“太大”,则B必须猜比x
更小的数,即从x-1
往下猜。同样地,如果B的答案y
“太小”,则B必须猜比y
更大的数,即从y+1
往上猜。用这种方式,如果B每次取猜数范围的中间值,并获取这个中间值与答案的大小关系,那么每次B猜数的范围都会被缩小到原范围的一半。
因为A设定答案的范围(1~100之间的数)是递增排列的,所以B可以看做在一有序数组内查找答案。
像这样,在一个有序范围内中查找某一目标值的算法,叫做二分。查找这一目标值的过程叫做二分查找。
二、二分查找
1. 解决方法
考虑在一个升序数组中查找某一目标元素的场景,每次都查找范围内的中间元素。显然,二分查找的过程中可能会产生以下三种局面:
- 如果中间元素等于目标值,那么返回中间元素的索引,并结束程序;
- 如果中间元素小于目标值,那么将查找范围缩小至中间元素的右边;
- 如果中间元素大于目标值,那么将查找范围缩小至中间元素的左边。
用伪代码可大致表示为:
left ← 1
right ← n
while left < right then
mid ← (left + right) / 2
if arr[mid] = target then
return mid
else if arr[mid] > target then
right ← mid - 1
else
left ← mid + 1
return -1
当然,这只是伪代码,不代表真实算法设计方案。
2. 评估
(1) 复杂度
因为二分查找中,每次都会把查找的范围减半,所以对于一个长度为n
的有序数组,查找某个目标值,使用二分查找的平均时间复杂度为O(log n)
,空间复杂度为O(1)
。
(2) 优缺点
-
优点:速度快,比较次数少,效率高(42亿个数据二分查找最多仅需32次)。
-
缺点:查找区间必须有序。
- 这意味着每次使用二分查找时必须将待查找区间排序。
(3) 应用场景
- 在顺序数据结构(如数组)中查找。
- 查找范围单调。
- 至于单调性的判定方法,可参见下文"二分答案"部分。
3. 实现
(1) 查找目标值第一次出现的索引
由上面的伪代码可知,我们需要维护两个指针left
和right
(可以简写为l
和r
),通过这两个指针计算得出的mid
指针查找目标元素。值得注意的是,当以l
和r
指针确定的区间为左闭右开区间[l,r)
以及闭区间[l,r]
时,代码的写法有很大不同,请读者着重关注。
i. 左闭右开区间写法
int binary(int x){
int l=1,r=n+1;
while(l<r){
int mid=(l+r)/2;
if(a[mid]>=x){
r=mid;
}else{
l=mid+1;
}
}
return l;
}
ii. 闭区间写法
int binary(int x){
int l=1,r=n,ans=-1;
while(l<=r){
int mid=(l+r)/2;
if(a[mid]>=x){
r=mid-1;
ans=mid;
}else{
l=mid+1;
}
}
return ans;
}
iii. 细节对比与说明
(以下若无特殊说明,数组索引均从1开始)
左闭右开区间写法 | 闭区间写法 | |
---|---|---|
l 指针的初始值 |
1 |
1 |
r 指针的初始值 |
n+1 |
n |
mid 指针的计算方法 |
(l+r)/2 |
(l+r)/2 |
l 指针的更新方法(a[mid]>=x ) |
l=mid+1 |
l=mid+1 |
r 指针的更新方法 |
r=mid |
r=mid-1 |
循环条件 | l<r |
l<=r |
是否需要用临时变量记录答案 | 否 | 是 |
返回值 | l 或r |
ans |
- Q1:
r
指针的初始值为什么不同?- 对于左闭右开区间,因为右指针不被包含在查找范围内,所以要将指向
n
的右指针右移一位。 - 对于闭区间,因为左右指针都被包含在查找范围内,所以右指针指向
n
。
- 对于左闭右开区间,因为右指针不被包含在查找范围内,所以要将指向
- Q2:在计算
mid
指针时,除法计算的规则是什么?- 在C/C++中,整数除法计算的结果都向下取整。
- Q3:
r
指针的更新方法为什么不同?- 因为区间的开闭情况不同,所以更新方法就有所不同。
- Q4:循环条件为什么不同?
- 只有当区间范围的左边界与右边界不错开时才能执行循环体里的语句。根据区间的开闭情况,循环调节就要做适当更改。
- Q5:为什么闭区间写法中需要用临时变量记录答案?
- 在左闭右开区间写法中,当
mid
满足条件时,l
和r
的更新方法都是将其直接赋值为mid
,所以这两个指针已经记录了答案。 - 在闭区间写法中,当
mid
满足条件时,l
和r
都会被赋值为mid
的下一位置,这两个指针指向的索引并非目标值的索引,所以要使用一个临时变量ans
记录答案。 - 如果在闭区间写法中不准使用临时变量记录答案,循环体终止时
l
与r
错开,那么答案索引也有所不同(应为l-1
或r+1
)。
- 在左闭右开区间写法中,当
- Q6:为什么闭区间写法中临时变量要赋初始值
-1
?- 如果找到了目标值,临时变量的值会有所更改;反之,如果没找到目标值,临时变量的值没被更改,仍为
-1
,程序返回-1
便于表示没找到。
- 如果找到了目标值,临时变量的值会有所更改;反之,如果没找到目标值,临时变量的值没被更改,仍为
(2) 查找目标值最后一次出现的索引
i. 左闭右开区间写法
int binary(int x){
int l=1,r=n+1;
while(l<r){
int mid=(l+r+1)/2;
if(a[mid]<=x){
l=mid;
}else{
r=mid-1;
}
}
return l;
}
ii. 闭区间写法
int binary(x){
int l=1,r=n,ans=-1;
while(l<=r){
int mid=(l+r)/2;
if(a[mid]<=x){
l=mid+1;
ans=mid;
}else{
r=mid-1;
}
}
return ans;
}
iii. 细节对比与说明
左闭右开区间写法 | 闭区间写法 | |
---|---|---|
l 指针的初始值 |
1 |
1 |
r 指针的初始值 |
n+1 |
n |
mid 指针的计算方法 |
(l+r+1)/2 |
(l+r)/2 |
l 指针的更新方法(a[mid]<=x ) |
l=mid |
l=mid+1 |
r 指针的更新方法 |
r=mid-1 |
r=mid-1 |
循环条件 | l<r |
l<=r |
是否需要用临时变量记录答案 | 否 | 是 |
返回值 | l 或r |
ans |
- Q1:为什么左闭右开区间写法中,
mid
指针的计算方法为(l+r+1)/2
?- 之前提到过,C/C++中的整数除法为向下取整。考虑区间内只有一个元素的情况,如果不加
1
,计算后所得的区间仍有一个元素,死循环。加1
可以使计算后循环终止,避免死循环。
- 之前提到过,C/C++中的整数除法为向下取整。考虑区间内只有一个元素的情况,如果不加
4. 优化
(1) 位运算优化
如果变量n
是有符号型,如果n>=0
,那么n>>1
比n/2
指令数更少,且计算结果一致。值得注意的是,右移运算符>>
的优先级低于加法运算符+
。
因此在计算mid
指针时,(l+r)/2
可以写成l+r>>1
。注意,这对浮点型不适用。
(2) 防整型溢出优化
如果l
和r
的值都很接近类型上限,那么在计算mid
指针时,计算l+r
时可能会超出类型上限。
因此在计算mid
指针时,(l+r)/2
可以写成l+(r-l)*2
。
结合位运算优化,计算mid
指针时,最好的方法应为mid=l+(r-l>>1)
(指针均为整型)。
5. 库中的二分查找函数
使用库中封装好的函数,可以减少代码量。C++标准头文件<algorithm>
中提供了下面两个二分查找函数:
lower_bound(begin,end,val)
- 在有序数组连续左闭右开地址区间
[begin,end)
找到第一个使得值val插入在前面还能使原数组保持有序的地址并返回。
- 在有序数组连续左闭右开地址区间
upper_bound(begin,end,val)
- 在有序数组连续左闭右开地址区间
[begin,end)
找到最后一个使得值val插入在前面还能使原数组保持有序的地址并返回。
- 在有序数组连续左闭右开地址区间
注意,这两个函数返回的都是地址,如果想获得索引,那么就需要减去数组名(即数组第一个元素的地址)。
例:有序数组a[]
,查找2
第一次出现和最后一次出现的索引。
#include <bits/stdc++.h>
using namespace std;
int a[]={0,1,1,2,2,2,2,8,9},n=8;
int main(){
cout<<lower_bound(a+1,a+1+n,2)-a<<endl;//这个不用-1
cout<<upper_bound(a+1,a+1+n,2)-a-1<<endl;//注意这个要-1
return 0;
}
输出:
3
6
请注意,使用库函数查找目标值时,如果原数组内没有目标值,那么函数将不会返回-1
,而会根据其规则返回一个索引。
至于实际操作选用手写二分还是库函数,依情况而定。
6. 例题
(1) P2249 【深基13.例1】查找
板子题,手写二分查找实现。这是纯查找题,不用排序。
#include <bits/stdc++.h>
using namespace std;
const int N=1e6+10;
int n,m,q,a[N];
int bs(int x){
int l=1,r=n+1;
while(l<r){
int mid=l+(r-l>>1);
if(a[mid]>=x){
r=mid;
}else{
l=mid+1;
}
}
return (a[l]==x)?l:-1;
}
int main(){
ios::sync_with_stdio(0);
cin.tie(0);
cout.tie(0);
cin>>n>>m;
for(int i=1;i<=n;i++){
cin>>a[i];
}
while(m--){
cin>>q;
cout<<bs(q)<<" ";
}
return 0;
}
(2) P1102 A-B 数对
i. 库函数解法
考虑枚举数组中的每个元素a
,则只需统计数列中a+c
出现的次数。排序,用库函数找出a+c
出现的左右端点索引,相减即为个数。注意开long long
。
时间复杂度为O(nlogn)
。
#include <bits/stdc++.h>
#define int long long
using namespace std;
const int N=2e5+10;
int a[N],n,c,ans;
signed main(){
cin>>n>>c;
for(int i=1;i<=n;i++){
cin>>a[i];
}
sort(a+1,a+1+n);
for(int i=1;i<=n;i++){
ans+=upper_bound(a+1,a+1+n,a[i]+c)-lower_bound(a+1,a+1+n,a[i]+c);
}
cout<<ans;
return 0;
}
ii. 双指针解法
枚举数组中的每个元素a
,维护两个指针l
和r
,分别记录a+c
出现的左右索引。因为数组是连续递增的,所以这两个指针总在向后移动,移动次数最大才是n
。
时间复杂度为O(n)
。
#include <bits/stdc++.h>
#define int long long
using namespace std;
const int N=2e5+10;
int a[N],n,c,ans;
signed main(){
cin>>n>>c;
for(int i=1;i<=n;i++){
cin>>a[i];
}
sort(a+1,a+1+n);
for(int i=1,l=1,r=1;i<=n;i++){
while(l<=n && a[l]<a[i]+c){
l++;
}
while(r<=n && a[r]<=a[i]+c){
r++;
}
ans+=r-l;
}
cout<<ans;
return 0;
}
(3) P1678 烦恼的高考志愿
警示后人:十年OI一场空,不开long long
见祖宗。
枚举每位学生的分数,在学校列表里找到大于这个分数的最低分数线,累加左右元素与原分数之差的最小值。特别地,如果某位学生的分数低于所有学校,那么需要特判。
#include <bits/stdc++.h>
#define int long long
using namespace std;
const int N=1e5+10;
int n,m,a[N],b,ans;
signed main(){
cin>>n>>m;
for(int i=1;i<=n;i++){
cin>>a[i];
}
sort(a+1,a+1+n);
for(;m--;){
cin>>b;
int l=1,r=n+1;
while(l<r){
int mid=l+r>>1;
if(a[mid]>=b){
r=mid;
}else{
l=mid+1;
}
}
ans+=(b<=a[1])?(a[1]-b):(min(abs(a[l-1]-b),abs(a[l]-b)));
}
cout<<ans;
return 0;
}
三、二分答案
1. 单调性
(1) 单调性的概念与判定
高中数学《函数》一章中明确指出:
一般地,设一连续函数\(f(x)\)的定义域为\(D\),则:
- 如果对于属于定义域\(D\)内某个区间上的任意两个自变量的值\(x_1,x_2∈D\)且\(x_1>x_2\),都有\(f(x_1)>f(x_2)\),即在\(D\)上具有单调性且单调增加,那么就说\(f(x)\)在这个区间上是增函数;
- 相反地,如果对于属于定义域\(D\)内某个区间上的任意两个自变量的值\(x_1,x_2∈D\)且\(x_1>x_2\),都有\(f(x_1)<f(x_2)\),即在\(D\)上具有单调性且单调减少,那么就说\(f(x)\)在这个区间上是减函数。
增函数和减函数统称单调函数。
之前提到,二分查找的范围必须单调,此处的单调是指:如果把数组索引看作自变量,值看作因变量,那么形如a[i]=x
的语句就可被看做一个连续函数(忽略数组索引因整型限制而导致的不连续性,我们暂且把数组索引看作连续的)。如果这个连续函数的某部分符合上面对单调函数的定义,那么称这一部分是单调的,也就可以被二分查找。
比如一个升序数组a[]={0,1,4,7,8,9}
,对于每两个索引i,j(i>j)
一定有a[i]>a[j]
,那么这个数组即是单调(递增)的。
(2) 单调性在二分查找问题中的应用
回想二分查找问题:
现给定升序数组
a[]
,目标值为x
,求x
在该数组中第一次出现的索引。
我们维护指针l
与r
,检验中间值mid
,如果a[mid]>=x
,则查找左半边;反之查找右半边。
这个问题也可以换一个角度思考:
现给定升序数组
a[]
,目标值为x
,求最小的数组元素,使得该元素大于或等于x
。
我们可以把a[mid]>=x
看做一个函数check(mid)
,返回布尔类型。
- 如果
check(mid)
成立,那么根据数组的单调性,所有大于等于mid
、小于r
的数组值都可以让check(mid)
成立; - 反之,所有小于等于
mid
、大于l
的数组值都可以让check(mid)
不成立。
因为我们要找最小的a[mid]
,使得check(mid)
成立,所以:
- 如果
check(mid)
成立,那么就去查找左半边,看还有没有比mid
更小的mid
,使得check(mid)
也成立。 - 反之就去查找右半边,看有没有能使
check(mid)
成立的mid
。
这样不断查找,就能得到答案。
2. 关于二分答案
(1) 从二分查找到二分答案
再次回顾二分查找问题,我们发现,二分查找问题是在一个已知的单调区间内进行二分查找的问题,所得答案一定在这个区间内。
广而言之,问题的答案在某一单调区间内,在这个区间内不断二分最终找到答案的算法叫做二分答案。二分查找问题的解决也用到了二分答案。
(2) 应用场景
满足以下场景的问题求解一般使用二分答案:
-
数据范围较大,直接遍历容易超时;
-
容易判断某个答案是否可行;
-
对于所有答案,一定存在某个分界线,使得它一侧的答案全部可行,另一侧的答案全部不可行。
对于场景三,若记存储所有答案的数组为ans[]
,判断答案是否可行的布尔函数为check()
,则它们的关系一定形如下表:
i |
1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 |
---|---|---|---|---|---|---|---|---|---|
check(ans[i]) |
0 | 0 | 0 | 0 | 0 | 1 | 1 | 1 | 1 |
所谓的分割线,也就是i=5
与i=6
之间的一条假想的线,使得这条线左侧的check(ans[i])
全部为0
,右侧的全部为1
。
从某种角度上来说,如果存在一个存储check(ans[i])
值的数组,那么这个数组就是单调的。这样一来,二分答案求解就可以被看做在假想的存储check(ans[i])
值的数组中二分查找,直到找到答案。
我们总结二分查找的问题类型时,一共总结出了两类:查找目标值第一次出现的索引,以及查找目标值最后一次出现的索引。由上,类比到二分答案可知,二分答案类型题也应被分为两类:求最大的最小值,以及求最小的最大值。
(3) 伪代码
define check(x):
if ... then
return true
else
return false
define binary_answer():
l ← 1
r ← ANS_MAX
ans ← -1
while l <= r then
mid ← (l + r) / 2
if check(mid) = true then
r ← mid - 1
ans ← mid
else
l ← mid + 1
return ans
推荐使用闭区间写法。
3. 例题
二分答案算法,练习重于说理。接下来我会展出一些具有代表性的二分答案练习题。
(1) P1873 [COCI 2011/2012 #5] EKO / 砍树
这道题数据范围较大,但容易判断当锯片在某一高度时答案是否可行,所求答案也是最小的最大答案,所以考虑用二分答案。
#include <bits/stdc++.h>
#define int long long
using namespace std;
const int N=1e6+10;
int a[N],n,m;
bool check(int h){
int tot=0;
for(int i=1;i<=n;i++){
if(a[i]>h){
tot+=a[i]-h;
}
}
return tot>=m;
}
signed main(){
cin>>n>>m;
for(int i=1;i<=n;i++){
cin>>a[i];
}
int l=0,r=2e9,ans;
while(l<=r){
int mid=l+r>>1;
if(check(mid)){
ans=mid;
l=mid+1;
}else{
r=mid-1;
}
}
cout<<ans;
return 0;
}
(2) P1824 [USACO05FEB] 进击的奶牛 Aggressive Cows G
先判断单调性:
- 如果
check(x)
成立,那么判断x
左边的答案能不能使check(x)
成立; - 如果
check(x)
不成立,那么判断x
右边的答案能不能使check(x)
成立。
对于所有的候选答案,一定存在一个分界线,使得这条件左边的答案都不能使check(x)
成立,右边的答案都能使check(x)
成立。
检验某一最小距离x
合法性的方法是:从最左端开始,每隔大于等于x
的距离就安置一头牛,最后判断所安置牛的数量是否大于等于原有牛的数量。
#include <bits/stdc++.h>
using namespace std;
const int N=1e6+10;
int a[N],n,m,ans;
bool check(int x){
int cnt=0,temp;
for(int i=1;i<=n;i++){
if(a[i]-temp>=x || i==1){
temp=a[i];
cnt++;
}
}
return cnt>=m;
}
int main(){
cin>>n>>m;
for(int i=1;i<=n;i++){
cin>>a[i];
}
sort(a+1,a+1+n);
int l=1,r=1e9+1;
while(l<=r){
int mid=l+r>>1;
if(check(mid)){
l=mid+1;
ans=mid;
}else{
r=mid-1;
}
}
cout<<ans;
return 0;
}
(3) P2440 木材加工
如果切割出来的小段最长为ans
,那么长度大于ans
的小段肯定切不出来,小于ans
的小段肯定能切出来。用一个check
函数判定,二分答案即可。需要开long long
。ans
初始值应为0
。
#include <bits/stdc++.h>
#define int long long
using namespace std;
const int N=1e5+10;
int n,k,a[N];
bool check(int m){
int cnt=0;
for(int i=1;i<=n;i++){
cnt+=a[i]/m;
}
return cnt>=k;
}
signed main(){
cin>>n>>k;
for(int i=1;i<=n;i++){
cin>>a[i];
}
int l=1,r=INT_MAX,ans=0;
while(l<=r){
int mid=r+(l-r>>1);
if(check(mid)){
l=mid+1;
ans=mid;
}else{
r=mid-1;
}
}
cout<<ans;
return 0;
}
(4) P2678 [NOIP 2015 提高组] 跳石头
同(2)。
#include <bits/stdc++.h>
#define int long long
using namespace std;
const int N=50010;
int d,n,m,a[N];
bool check(int x){
int ans=0,s=0;
for(int i=1;i<=n+1;i++){
if(a[i]-s<x){
ans++;
}else{
s=a[i];
}
}
return ans<=m;
}
signed main(){
cin>>d>>n>>m;
for(int i=1;i<=n;i++){
cin>>a[i];
}
a[n+1]=d;
int l=1,r=d;
while(l<=r){
int mid=l+r>>1;
if(check(mid)){
l=mid+1;
}else{
r=mid-1;
}
}
cout<<r;
return 0;
}