二分查找
问题
问题描述
有一个有序序列
样例输入
- 第一行两个整数
,表示序列元素的数量,和有多少询问 - 第二行,
个数表示序列的元素,保证有序 - 接下来
行,询问的值
5 3
1 2 5 9 100
2
3
20
样例输出
- 每行两个数,表示每个询问的第一个
的值和对应的位置
2 2
5 3
100 5
解析1: 暴力
本问题是:在一个单调序列上查找
显示对于每个询问,最简单的方法就是使用暴力查询,代码如下
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
#include <iostream>
using namespace std;
const int maxn=qe5+5;
int a[maxn];
int n,m;
int find(int val) {
for (int i = 1; i <= n; i++)
{
if (a[i] >= val)
return i;
}
return n+1;
}
int main() {
cin >> n >> m;
for(int i =1;i<=n;i++ )
cin >> a[i];
for(int i =1;i<=m;i++) {
int num;
cin >> num;
int pos = find(num);
if( pos == n+1) //没有找到
cout << "not found\n";
else
cout << a[pos] << " " << pos << "\n";
}
return 0;
}
上面的代码的时间复杂度为
解析2: 二分法
面对样例
我们可以利用元素有序这条性质来加快我们的查询呢?
一个朴素的想法如下
面对样例,我们查询的位置的范围是
同样,我们可以这样思考,我们把原序列末尾添加一个无穷大的值,改成
那么如果出现比最大的一个元素还大的查询,返回的位置就是not found
如果需要查询的元素是
- 我们成功的将问题缩小了一半,将问题变得到更简单了
- 新的问题与原问题是相似的,这显然是递归
- 小朋友法:如果找到足够多的小朋友来帮我们解题,一定可以解出来.所以问题有解.
下面采用更抽象的方法,来思考问题,设问题为
则得到公式如下
其中
于是我们写出如下代码:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
#include <iostream>
using namespace std;
const int maxn=1e5+5;
int a[maxn];
int n,m;
int mid(int l,int r) {
return (l+r) >> 1; //这是最快的写法
}
//检查pos位置的值是否符合要求
bool check(int pos,int val){
return a[pos] >= val;
}
//bs_find = binary search find
int bs_find(int l,int r,int val) {
while( l < r) {
int m = mid(l,r);
if( check(m,val)) //成立
r = m;
else //不成立,抛弃左半边
l = m+1;
}
return l ;
}
int main() {
cin >> n >> m;
for(int i =1;i<=n;i++ )
cin >> a[i];
for(int i =1;i<=m;i++) {
int num;
cin >> num;
int pos = bs_find(1,n+1,num);
if( pos == n+1) //没有找到
cout << "not found\n";
else
cout << a[pos] << " " << pos << "\n";
}
return 0;
}
总结
定义1 二分性
在一个序列
得到一个类似的如下的图:
如果序列b满足两个条件
- 只有两个值
- 相同值的位置都是连续
则说明序列
引理
一个具有二分性的序列不会出现蓝红蓝或红蓝红的连续三个相邻值
证明:
必要性显然.充分性: 根据描述,序列最多出现蓝红或红蓝,这种情况可以是边界,其它地方要么蓝,要么红,符合二分性的定义.
引理
一个序列
- 若某个位置
是 蓝,则所有小于位置的都是 蓝 - 若某个位置
是 红,则所有大于位置的都是 红
证明:
必要性显然. 充分性: 使用反证法: 假设序列蓝红蓝的情况,但此时违反了引理
推论1
具有二分性的序列可以使用二分查找算法来查找分界点的位置
推论2
对于序列
推论2证明:
二分查找的正确性证明
开始证明:
使用归纳法,证明bs_find(l,r,val)代码的正确性
只有1个元素的时候
分类讨论如下
- 情况1:
, check函数成立,则问题变成,得到正确答案 - 情况2:
, check函数不成立,则问题变成,得到正确答案
结论:当只有一个元素bs_find可以得到正确答案
有两个元素mid函数的运算后一定得到
分类讨论如下
- 情况1:
, check函数成立,则问题变成,得到正确答案 - 情况2:
, check函数不成立,则问题变成,得到正确答案
结论:当只有两个元素bs_find可以得到正确答案
同理,有bs_find(i,i+2,val)可以得到正确的答案
有
分类讨论如下
- 情况1:
, check函数成立,则问题变成, - 情况2:
, check函数不成立,则问题变成
于是问题就变成是一个长度为
同理,当有
这样一直到分解,最后剩余的元素个数,一定会变成
证明完毕.
一般化
TODO 没有解释清楚
具体什么样的情况下可以使用二分查询算法呢?
在一个序列
序列
得到一个类似的如下的图:
若序列 存在某个位置
什么是单调性?
单调性的性质:
在一个单调性的序列上
- 查询使函数
第一个成立的位置 - 查询使函数
最后一个失败的后一个的位置
可以使用二分查询法
什么题目使用
- 第一个
元素的位置 - 第一个
元素的位置 - 最后一个
元素的位置 - 求解数字
的数量 - 求解满足数字
的数量
经典模型
- 最小值最大,最大值最小问题
- 1.序列划分
二分的应用
二分查找应用分类详解
二分查找不仅仅是在有序数组中查找元素,它的本质是在具有单调性的空间中快速排除一半可能性来锁定目标。本文将二分查找的所有应用场景系统地分为五大类别。
一、基础定位:查位置与边界
核心操作: lower_bound、upper_bound
典型模式: 在有序序列中定位边界
1.1 基础查找与计数
在有序数组中进行各种查找操作:
- 查找一个特定的数字 - 判断元素是否存在
- 查找第一个大于或等于某个数的元素 -
lower_bound的经典应用 - 查找第一个大于某个数的元素 -
upper_bound的基础用法 - 查找最后一个小于或等于某个数的元素 - 前驱查找
- 统计某个数出现了多少次 - 利用上下界相减求频次
| 应用场景 | 经典题目 | 核心思路 |
|---|---|---|
| 元素查找 | Luogu P2249 | 查找第一次出现位置的标准模板 |
| 区间计数 | atcoder-abc248_d Range Count Query tags: [二分] | upper_bound(R) - lower_bound(L) 求区间内元素个数 |
| 数对统计 | Luogu P1102 | 统计满足 A-B=C 的数对,利用 map 或二分 |
1.2 边界定位
| 应用场景 | 经典题目 | 核心思路 |
|---|---|---|
| 最近元素 | Luogu P1678 | 找绝对值差最小的元素,比较 lower_bound 和前驱 |
| 首尾位置 | LeetCode 34 | 查找元素在数组中的第一个和最后一个位置 |
经典实现:Vector + 二分的区间计数
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// 预处理:为每个值建立位置列表
map<int, vector<int>> pos;
void preprocess(const vector<int>& arr) {
for (int i = 0; i < arr.size(); ++i) {
pos[arr[i]].push_back(i);
}
}
// 查询 x 在 [L,R] 中出现次数
int rangeCount(int L, int R, int x) {
if (pos.find(x) == pos.end()) return 0;
const vector<int>& idxs = pos[x];
return upper_bound(idxs.begin(), idxs.end(), R)
- lower_bound(idxs.begin(), idxs.end(), L);
}
二、变体结构:特殊数组的特征查找
核心思路: 利用局部单调性,判断 mid 落在哪个单调段
2.1 旋转与断点
| 应用场景 | 经典题目 | 核心思路 |
|---|---|---|
| 旋转数组搜索 | LeetCode 33 | 判断哪一半有序来缩小范围 |
| 旋转数组最小值 | LeetCode 153 | 找旋转点(断点)位置 |
2.2 峰值与山脉
寻找"山峰"数组的顶点:对于一个数组先递增后递减的情况,虽然整个数组不是单调的,但我们可以利用山峰的性质来确定二分的搜索方向。
核心思想: 每次取中间位置 mid,通过比较 A[mid] 和它右边紧邻的元素 A[mid+1] 的关系,判断出 mid 正处于上坡还是下坡,从而确定真正的山峰在它的左边还是右边。
具体步骤:
- 设置查找区间
[left, right]为整个数组的下标范围 - 取中间位置
mid - 比较
A[mid]和A[mid+1]:- 如果
A[mid] < A[mid+1]:说明mid正处在上坡阶段,山峰一定在mid的右边,更新left = mid + 1 - 如果
A[mid] > A[mid+1]:说明mid正处在下坡阶段,山峰就是mid本身或在mid的左边,更新right = mid
- 如果
- 当
left和right相遇时,那个位置就是山峰的顶点
示例代码:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
// 寻找山峰元素的下标
int findPeakElement(const std::vector<int>& nums) {
int left = 0;
int right = nums.size() - 1;
while (left < right) {
int mid = left + (right - left) / 2;
if (nums[mid] < nums[mid + 1]) {
// mid 在上坡,山峰一定在右侧
left = mid + 1;
} else {
// mid 在下坡或就是山峰,答案在左侧或就是 mid
right = mid;
}
}
// 循环结束时,left == right,指向山峰
return left;
}
| 应用场景 | 经典题目 | 核心思路 |
|---|---|---|
| 峰值查找 | LeetCode 162 | arr[i] > arr[i+1] 则左侧必有峰值 |
| 山脉数组 | LeetCode 1095 | 先找峰顶,再在左右两段分别二分 |
三、二分答案:解空间上的优化
核心思想: 答案具有单调性 —— 若答案 X 可行,则 X-1 也可行(或都不可行)
经典口诀: “最大化最小值”、“最小化最大值”
3.1 最值优化问题
猜答案(答案二分) 是处理优化问题的强大技巧:
-
解决"最大值最小化"问题
- 例子:把一根很长的木头切成
k段,怎么切才能让最长的那一段尽可能短? - 二分答案"最长段的长度",检查能否在该限制下分成 k 段
- 例子:把一根很长的木头切成
-
解决"最小值最大化"问题
- 例子:在一条路上装
k个路灯,怎么装才能让两盏灯之间的最小距离尽可能远? - 二分答案"最小距离",检查能否在该距离约束下放置 k 盏灯
- 例子:在一条路上装
| 问题类型 | 经典题目 | 核心建模 |
|---|---|---|
| 最大化最小值 | Luogu P1824 (进击的奶牛) | 二分最小距离,检查能否放下所有牛 |
| 最小化最大值 | Luogu P1182 (数列分段) | 二分最大段和,检查能否分成 M 段 |
| 最大化最短边 | Luogu P2678 (跳石头) | 移走石头让最短跳跃距离最大 |
3.2 第K值与统计
| 问题类型 | 经典题目 | 核心建模 |
|---|---|---|
| 第K小数值 | LeetCode 668 | 二分答案值,计算 ≤ 该值的元素个数 |
| 中位数最大化 | CF 1201C | 二分中位数值,检查操作次数是否足够 |
| 平均值最大化 | Luogu P1404 | 二分平均值,原数组减去该值后判断子段和 |
二分答案模板:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
bool check(int mid) {
// 检查 mid 是否可行
// 根据具体问题实现
}
int binarySearchAnswer(int left, int right) {
while (left < right) {
int mid = (left + right + 1) >> 1;
if (check(mid)) left = mid;
else right = mid - 1;
}
return left;
}
四、数学与函数:数值解求解
核心思路: 在连续空间或数学规律中进行二分
4.1 方程求根
在小数(实数)上查找:
- 计算一个数的平方根,精确到指定的小数位
- 求解方程的近似解
| 应用场景 | 经典题目 | 核心思路 |
|---|---|---|
| 一元三次方程 | Luogu P1024 | 利用零点存在性定理在给定范围内二分 |
| N次方根 | AcWing 790 | 浮点数二分求立方根等 |
4.2 复杂中位数
| 应用场景 | 经典题目 | 核心思路 |
|---|---|---|
| 两数组中位数 | LeetCode 4 | 对切分位置二分,要求 O(log(m+n)) |
五、算法加速组件:复合应用
核心思路: 二分作为优化其他算法的子程序
5.1 动态规划优化
加速某些算法:二分查找可以将某些算法的时间复杂度显著降低,其中最经典的就是**最长上升子序列(LIS)**的优化。
最长上升子序列优化原理:
- 传统 DP 方法:O(N²) 时间复杂度
- 二分优化方法:O(N log N) 时间复杂度
- 核心思想:维护一个贪心数组,用二分查找替代线性扫描
| 应用场景 | 经典题目 | 核心思路 |
|---|---|---|
| 最长上升子序列 | Luogu P1020 | 用二分维护贪心数组,O(N²) → O(N log N) |
5.2 高级技巧
| 应用场景 | 经典题目 | 核心思路 |
|---|---|---|
| 整体二分 | Luogu P3527 | 多个查询同时二分答案,通常结合树状数组 |
| 线段树上二分 | Luogu P3369 | 权值线段树中查找第K小值 |
LIS 二分优化实现:
1
2
3
4
5
6
7
8
9
10
vector<int> lis_binary(vector<int>& nums) {
vector<int> dp; // 贪心维护的单调数组
for (int x : nums) {
auto it = lower_bound(dp.begin(), dp.end(), x);
if (it == dp.end()) dp.push_back(x);
else *it = x;
}
return dp; // dp.size() 就是 LIS 长度
}
六、 高阶专题:二分与其他算法的深度结合
6.1 WQS 二分 (Alien’s Trick)
解决痛点: 求解"恰好选 K 个"的最值优化问题,通常配合 DP。 核心原理: 当答案关于 K 呈现凸性时,二分斜率(惩罚值)来切这个凸包。 经典题目: Luogu P2619
6.2 0/1 分数规划
核心模型: 最大化
6.3 交互题中的二分
核心场景: Codeforces 交互题,通过 query 询问来缩小范围。
应用分类总览
| 分类 | 核心特征 | 时间复杂度 | 适用场景 |
|---|---|---|---|
| 基础定位 | 有序数组边界查找 | O(log N) | 查找、计数、统计 |
| 变体结构 | 局部单调性 | O(log N) | 旋转数组、峰值、断点 |
| 二分答案 | 解的单调性 | O(log(答案范围) × 检查复杂度) | 优化问题、第K值 |
| 数学函数 | 连续性或数学规律 | O(log(精度)) | 方程求根、数值计算 |
| 算法加速 | 作为子程序优化 | 取决于主算法 | DP优化、复杂查询 |
练习建议
- 入门练习: Luogu P2249 → AtCoder ABC248 D
- 二分答案: Luogu P1824 → Luogu P1182
- 算法优化: Luogu P1020
二分查找的威力在于将线性问题转化为对数复杂度。掌握这五大分类,你就掌握了二分查找在算法竞赛中的精髓。
练习题目
- luogu P1083
- luogu P2678
- luogu P1314
- luogu P1868
- luogu P1493
- luogu-P1419 寻找段落 tags: [二分,单调队列]
- hdu 6231
- poj 3273
- poj 3258
- poj 1905
- poj 3122
- luogu P1419 二分+单调队列