问题

问题描述

有一个有序序列a1,a2,a3,,ana_1,a_2,a_3,\cdots ,a_n, 问第一个大于kk的元素与位置是哪里.

样例输入

  • 第一行两个整数n(105),m(105)n(\leqslant 10^5),m(\leqslant 10^5),表示序列元素的数量,和有多少询问
  • 第二行,nn个数表示序列的元素,保证有序
  • 接下来mm行,询问的值
5 3
1 2 5 9 100
2
3
20

样例输出

  • 每行两个数,表示每个询问的第一个\geqslant的值和对应的位置
2 2
5 3
100 5

解析1: 暴力

本问题是:在一个单调序列上查找xxxx的后缀(当xx不存在时)

显示对于每个询问,最简单的方法就是使用暴力查询,代码如下

cpp
copy
        
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; }

上面的代码的时间复杂度为O(n2)O(n^2),显然会超时.

解析2: 二分法

面对样例1,2,5,9,1001,2,5,9,100,这个序列是有序的

我们可以利用元素有序这条性质来加快我们的查询呢?

一个朴素的想法如下

面对样例,我们查询的位置的范围是[1,6][1,6],为什么最后一个位置是66,不应该是55吗?因为我们有可能会查询到比最大值100100还要大的元素,例如,200200,那这个时候,我们应该返回的位置是66,100100位置的后面一个位置.

同样,我们可以这样思考,我们把原序列末尾添加一个无穷大的值,改成

元素1259100位置123456 \begin{array}{lcccccc} \text{元素}& 1&2&5&9&100&\infin \\ \hline \text{位置}& \tt{1}&\tt2&\tt3&\tt4&\tt5&\tt6 \end{array}

那么如果出现比最大的一个元素还大的查询,返回的位置就是n+1n+1,同样如果得到返回值为n+1n+1则知道,没有查询到需要的值,输出not found

如果需要查询的元素是66, 可以描述我们的问题为:f(1,6,6)f(1,6,6),表示在区间[1,6][1,6]上找到第一个6\geqslant 6位置,我们挑选到中间位置(1+6)/2=3(1+6) / 2 = 3,值为55,又发则元素565 \leqslant 6,所以55和左边的元素都是比66小,所以范围[1,3][1,3]是对我们无用的范围,那么新的问就变成了:f(4,6,6)f(4,6,6),在区间[4,6][4,6]上查询第一个6\geqslant 6的位置

  • 我们成功的将问题缩小了一半,将问题变得到更简单了
  • 新的问题与原问题是相似的,这显然是递归
  • 小朋友法:如果找到足够多的小朋友来帮我们解题,一定可以解出来.所以问题有解.

下面采用更抽象的方法,来思考问题,设问题为f(l,r,val)f(l,r,val),表示在区间[l,r][l,r]上第一个val\geqslant val的位置 .条件:保证区间[l,r][l,r]上必然存在一个值val\geqslant val

则得到公式如下

f(l,r,val)={f(l,mid,val),a[mid]>=valf(mid+1,r,val),a[mid]<val(1) f(l,r,val) = \begin{cases} f(l,mid,val) ,& a[mid] >= val\\ f(mid+1,r,val) ,& a[mid] < val \end{cases} \tag1

其中mid=(l+r)÷2mid = \lfloor(l+r) \div 2\rfloor,且显然f(l,l,val)=lf(l,l,val) = l

于是我们写出如下代码:

cpp
copy
        
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 二分性

在一个序列A=a1,a2,,anA = a_1,a_2,\cdots,a_n上,有一个函数叫做check(ai)check(a_i),针对序列AA形成的新的序列B=b1,b2,,bnB = b_1,b_2,\cdots,b_n,其中bi=check(ai){true,false}b_i = check(a_i) \in \{true,false\},且序列bb的相同值的位置都是连续的,如下所示:

b1,b2,b3,,bmcheck=false,bm+1,,bncheck=true \color{blue}{ \underbrace{b_1,b_2,b_3,\cdots,b_m}_{check = false}}, \color{red}{ \underbrace{b_{m+1},\cdots,b_n}_{check = true} }

得到一个类似的如下的图:

如果序列b满足两个条件

  • 只有两个值
  • 相同值的位置都是连续

则说明序列AA在函数checkcheck是具有二分性.其中bm,bm+1b_m,b_{m+1}称为分界点

引理11 二分性等价定义1

一个具有二分性的序列不会出现蓝红蓝红蓝红的连续三个相邻值

证明:

必要性显然.充分性: 根据描述,序列最多出现蓝红红蓝,这种情况可以是边界,其它地方要么蓝,要么红,符合二分性的定义.

引理22 二分性等价定义2

一个序列AA符合下面两条性质,则序列AA与二分性等价.

  1. 若某个位置ii,则所有小于位置ii的都是
  2. 若某个位置ii,则所有大于位置ii的都是

证明:

必要性显然. 充分性: 使用反证法: 假设序列AA不符合引理11的描述,也就是会出现蓝红蓝的情况,但此时违反了引理22的描述,所以序列AA符合引理11,引理22可以推出引理11.证毕.

推论1

具有二分性的序列可以使用二分查找算法来查找分界点的位置

推论2

对于序列AA,若函数f(ai)f(a_i)成立,则f(aj),jif(a_j),j \geqslant i都成立,则会序列AA满足二分性.

推论2证明:

二分查找的正确性证明

开始证明:Proof.\cal{Proof}.

使用归纳法,证明bs_find(l,r,val)代码的正确性

只有1个元素的时候a1a_1,需要创建一个元素a2=a_2 = \infin, 问题就变成求f(1,2,val)f(1,2,val),取m=(1+2)1==1m = (1+2) \gg 1 == 1

分类讨论如下

  1. 情况1: check(1,val)=true    a1valcheck(1,val) = true \iff a_1 \geqslant val,check函数成立,则问题变成f(1,1,val)==1f(1,1,val) == 1,得到正确答案
  2. 情况2: check(1,val)=false    a1valcheck(1,val) = false \iff a_1 \ngeqslant val,check函数不成立,则问题变成f(2,2,val)==2f(2,2,val) == 2,得到正确答案

结论:当只有一个元素a1a_1时,函数bs_find可以得到正确答案

有两个元素ai,ai+1a_i,a_{i+1},且保证check(i+1,val)=truecheck(i+1,val) = true成立,取m=(i+(i+1)/2=im = (i+(i+1) / 2 = i,说明当两个相当位置i,i+1i,i+1,进行mid函数的运算后一定得到ii,也就是较小的那个位置.

分类讨论如下

  1. 情况1: check(m=i,val)=true    aivalcheck(m=i,val) = true \iff a_i \geqslant val,check函数成立,则问题变成f(i,i,val)==if(i,i,val) == i,得到正确答案
  2. 情况2: check(m=i,val)=false    aivalcheck(m=i,val) = false \iff a_i \ngeqslant val,check函数不成立,则问题变成f(i+1,i+1,val)==i+1f(i+1,i+1,val) == i+1,得到正确答案

结论:当只有两个元素ai,ai+1a_i,a_{i+1}时,且check(i+1,val)=truecheck(i+1,val)=true成立时,函数bs_find可以得到正确答案

同理,有33个元素:ai,ai+1,ai+1a_i,a_{i+1},a_{i+1},且保证check(i+2,val)=truecheck(i+2,val)=true成立时,函数bs_find(i,i+2,val)可以得到正确的答案

2k2k(偶数)个元素的时候a1,a2,,a2ka_1,a_2,\cdots,a_{2k}, 且保证check(2k,val)=truecheck(2k,val)=true成立时,取m=(1+2k)1=km = (1+2k) \gg 1 = k

分类讨论如下

  1. 情况1: check(m=k,val)=true    akvalcheck(m=k,val) = true \iff a_k \geqslant val,check函数成立,则问题变成f(1,k,val)f(1,k,val),
  2. 情况2: check(m=k,val)=false    akvalcheck(m=k,val) = false \iff a_k \ngeqslant val,check函数不成立,则问题变成f(k+1,2k,val)f(k+1,2k,val)

于是问题就变成是一个长度为kk的子问题,问题正确性由子问题的正确性保证

同理,当有2k+12k+1(奇数)个元素的时候a1,a2,,a2ka_1,a_2,\cdots,a_{2k}, 且保证check(2k+1,val)=truecheck(2k+1,val)=true成立时,取m=(1+2k+1)1=k+1m = (1+2k+1) \gg 1 = k+1,问题就变成是一个长度为k+1k+1,或kk的子问题,问题正确性由子问题的正确性保证

这样一直到分解,最后剩余的元素个数,一定会变成2233,而这两个长度,已经验证成功.

证明完毕.Q.E.D\cal{Q.E.D}

一般化

TODO 没有解释清楚

具体什么样的情况下可以使用二分查询算法呢?

在一个序列A=a1,a2,,anA = a_1,a_2,\cdots,a_n上,有一个函数叫做check(ai)check(a_i),针对序列AA形成的新的序列B=b1,b2,,bnB = b_1,b_2,\cdots,b_n,其中bi=check(ai){true,false}b_i = check(a_i) \in \{true,false\}

序列

b1,b2,b3,,bmcheck=false,bm+1,,bncheck=true \color{blue}{ \underbrace{b_1,b_2,b_3,\cdots,b_m}_{check = false}}, \color{red}{ \underbrace{b_{m+1},\cdots,b_n}_{check = true} }

得到一个类似的如下的图:

若序列 存在某个位置

什么是单调性?

单调性的性质:

在一个单调性的序列上

  1. 查询使函数check(pos)check(pos)第一个成立的位置
  2. 查询使函数check(pos)check(pos)最后一个失败的后一个的位置

可以使用二分查询法

什么题目使用

  1. 第一个x\geqslant x元素的位置
  2. 第一个=x= x元素的位置
  3. 最后一个x\leqslant x元素的位置
  4. 求解数字xx的数量
  5. 求解满足数字val[l,r]val \in [l,r]的数量

经典模型

  • 最小值最大,最大值最小问题
    • 1.序列划分

二分的应用

二分查找应用分类详解

二分查找不仅仅是在有序数组中查找元素,它的本质是在具有单调性的空间中快速排除一半可能性来锁定目标。本文将二分查找的所有应用场景系统地分为五大类别。

一、基础定位:查位置与边界

核心操作: lower_boundupper_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 + 二分的区间计数

cpp
copy
        
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 正处于上坡还是下坡,从而确定真正的山峰在它的左边还是右边。

具体步骤:

  1. 设置查找区间 [left, right] 为整个数组的下标范围
  2. 取中间位置 mid
  3. 比较 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
  4. leftright 相遇时,那个位置就是山峰的顶点

示例代码:

cpp
copy
        
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 二分平均值,原数组减去该值后判断子段和

二分答案模板:

cpp
copy
        
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 二分优化实现:

cpp
copy
        
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 分数规划

核心模型: 最大化 aibi\frac{\sum a_i}{\sum b_i} 通用解法: 二分答案 valval,检查 max((aival×bi))0\max(\sum (a_i - val \times b_i)) \ge 0

6.3 交互题中的二分

核心场景: Codeforces 交互题,通过 query 询问来缩小范围。

应用分类总览

分类 核心特征 时间复杂度 适用场景
基础定位 有序数组边界查找 O(log N) 查找、计数、统计
变体结构 局部单调性 O(log N) 旋转数组、峰值、断点
二分答案 解的单调性 O(log(答案范围) × 检查复杂度) 优化问题、第K值
数学函数 连续性或数学规律 O(log(精度)) 方程求根、数值计算
算法加速 作为子程序优化 取决于主算法 DP优化、复杂查询

练习建议

  1. 入门练习: Luogu P2249AtCoder ABC248 D
  2. 二分答案: Luogu P1824Luogu P1182
  3. 算法优化: Luogu P1020

二分查找的威力在于将线性问题转化为对数复杂度。掌握这五大分类,你就掌握了二分查找在算法竞赛中的精髓。

练习题目