二分法

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) 查找目标值第一次出现的索引

由上面的伪代码可知,我们需要维护两个指针 leftright(可以简写为 lr),通过这两个指针计算得出的 mid 指针查找目标元素。值得注意的是,当以 lr 指针确定的区间为左闭右开区间 [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
是否需要用临时变量记录答案
返回值 lr ans
  • Q1:r 指针的初始值为什么不同?
    • 对于左闭右开区间,因为右指针不被包含在查找范围内,所以要将指向 n 的右指针右移一位。
    • 对于闭区间,因为左右指针都被包含在查找范围内,所以右指针指向 n
  • Q2:在计算 mid 指针时,除法计算的规则是什么?
    • 在 C / C++ 中,整数除法计算的结果都向下取整。
  • Q3:r 指针的更新方法为什么不同?
    • 因为区间的开闭情况不同,所以更新方法就有所不同。
  • Q4:循环条件为什么不同?
    • 只有当区间范围的左边界与右边界不错开时才能执行循环体里的语句。根据区间的开闭情况,循环调节就要做适当更改。
  • Q5:为什么闭区间写法中需要用临时变量记录答案?
    • 在左闭右开区间写法中,当 mid 满足条件时,lr 的更新方法都是将其直接赋值为 mid,所以这两个指针已经记录了答案。
    • 在闭区间写法中,当 mid 满足条件时,lr 都会被赋值为 mid 的下一位置,这两个指针指向的索引并非目标值的索引,所以要使用一个临时变量 ans 记录答案。
    • 如果在闭区间写法中不准使用临时变量记录答案,循环体终止时 lr 错开,那么答案索引也有所不同(应为 l-1r+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
是否需要用临时变量记录答案
返回值 lr ans
  • Q1:为什么左闭右开区间写法中,mid 指针的计算方法为 (l+r+1)/2
    • 之前提到过,C / C++ 中的整数除法为向下取整。考虑区间内只有一个元素的情况,如果不加 1,计算后所得的区间仍有一个元素,死循环。加 1 可以使计算后循环终止,避免死循环。

4. 优化

(1) 位运算优化

如果变量 n 是有符号型,如果 n>=0,那么 n>>1n/2 指令数更少,且计算结果一致。值得注意的是,右移运算符 >> 的优先级低于加法运算符 +

因此在计算 mid 指针时,(l+r)/2 可以写成 l+r>>1注意,这对浮点型不适用。

(2) 防整型溢出优化

如果 lr 的值都很接近类型上限,那么在计算 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,维护两个指针 lr,分别记录 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 在该数组中第一次出现的索引。

我们维护指针 lr,检验中间值 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=5i=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 longans 初始值应为 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;
}
posted @ 2025-08-02 12:04  L-Coding  阅读(93)  评论(0)    收藏  举报