🎯 算法效率神器 | 二分查找实战:从入门到"二分答案"!
导语 :
面对海量数据,你还在用 O(n) 的暴力循环一个个遍历吗?那你可就亏大了!今天带大家通过三道经典的洛谷真题,彻底掌握算法界的效率神器------二分查找(Binary Search)。学会它,轻松将时间复杂度压缩到 O(log n),在算法竞赛和面试中快人一步!🚀
01 📖 基础入门:模拟二分查找的全过程
二分查找的核心思想是"二段性":每次取中间值进行比较,直接排除掉一半不可能包含答案的区间。我们先通过一道纯模拟的题目,来感受它的区间收缩逻辑。
🔍 oj-P1833 简单的二分查找
题目描述
众所周知,二分查找是在一个有序的序列中进行快速查找的手段,方法是将待查找内容与位于序列中间为止的元素进行比较,以便快速排除掉一半的带查找内容。
现在我们来简化一下这个问题,假如序列由连续的正整数构成,遵照以下规则进行查找:
假设序列的第一个元素为 x,最后一个元素为 y,那么序列的总长度为 y-x+1
如果 x+y 为偶数,序列的中间元素 z 为 z=(x+y)/2;如果 x+y 为奇数,那么中间元素就是 (x+y-1)/2
如果待查找元素小于中间元素,那么从中间元素往后的所有内容将被移除,新的查找区间为 x,z-1,此时输出字符 'L', 继续查找
如果待查找元素大于中间元素,那么从中间元素往前的所有内容将被移除,新的查找区间为 z+1,y, 此时输出字符 'R', 继续查找
如果待查找元素等于中间元素,查找过程结束,此时输出字符 'G'
现在请你模拟这个过程并输出由 'L','R' 和 'G' 组成的过程序列。
输入描述
输入包含两行
第一行包含两个整数 x 和 y,用空格隔开,代表初始序列的起点和终点。
第二行包含一个待查找数 s (x<=s<=y)
对于一部分输入,(1<=x<=y<=10000)
对于全部输入,(1<=x<=y<=2^50)
输出描述
输出包括一行,包括查找过程中所形成的字符序列
样例输入
text
1 8
5
样例输出
text
RLG
💡 思路解析
这道题不需要数组,只需要用两个变量 l 和 r 维护当前的查找区间即可。由于 x,yx, yx,y 的范围高达 2502^{50}250,我们必须使用 long long 类型来防止溢出。我们只需按照题目给定的规则,不断计算中间值 zzz,并根据 sss 与 zzz 的大小关系收缩区间、输出对应字符,直到找到目标为止。
💻 代码实现
cpp
#include<bits/stdc++.h>
using namespace std;
typedef long long ll;
// 封装计算中间值的函数,让主逻辑更清晰
ll f(ll x, ll y){
ll z;
if((x+y)%2==0){
z = (x+y)/2;
}
else z = (x+y-1)/2;
return z;
}
int main(){
ll x, y, s;
cin >> x >> y >> s;
ll l = x, r = y;
ll z = f(l, r);
// 当中间值不等于目标值时,持续收缩区间
while(f(l, r) != s){
if(s < f(l, r)){
r = z - 1; // 目标在左边,收缩右边界
z = f(l, r);
cout << "L";
}
else if(s > f(l, r)){
l = z + 1; // 目标在右边,收缩左边界
z = f(l, r);
cout << "R";
}
}
// 找到目标,输出 G
if(z == s){
cout << "G";
}
return 0;
}
02 🛠️ 进阶实战:STL库函数与边界处理
在真实的算法竞赛中,我们很少手写基础二分,而是更多地利用二分查找在有序数据中"定位"最优解。C++ STL 库中的 lower_bound 等函数是极其强大的工具。
🎓 P1678 烦恼的高考志愿
题目背景
计算机竞赛小组的神牛 V 神终于结束了高考,然而作为班长的他还不能闲下来,班主任老 t 给了他一个艰巨的任务:帮同学找出最合理的大学填报方案。可是 V 神太忙了,身后还有一群小姑娘等着和他约会,于是他想到了同为计算机竞赛小组的你,请你帮他完成这个艰巨的任务。
题目描述
现有 mmm 所学校,其中第 iii 所学校的预计分数线为 aia_iai。有 nnn 位学生,其中第 iii 位学生的估分为 bib_ibi。
根据 nnn 位学生的估分情况,分别给每位学生推荐一所学校,要求学校的预计分数线和学生的估分相差最小(可高可低,毕竟是估分嘛),这个最小值为这位学生的不满意度。求所有学生的不满意度的和。
输入格式
第一行包含两个正整数 m,nm,nm,n,分别表示学校数和学生数。
第二行包含 mmm 个非负整数 a1,a2,...,ama_1,a_2,\dots,a_ma1,a2,...,am,分别表示 mmm 所学校的预计分数线。
第三行包含 nnn 个非负整数 b1,b2,...,bnb_1,b_2,\dots,b_nb1,b2,...,bn,分别表示 nnn 位学生的估分。
输出格式
输出一行一个非负整数,表示所有学生的不满意度的和。
输入输出样例 #1
输入 #1
text
4 3
513 598 567 689
500 600 550
输出 #1
text
32
说明/提示
数据范围:
对于 30% 的数据,1≤n,m≤1031\le n,m\le{10}^31≤n,m≤103,0≤ai,bi≤1040\le a_i,b_i\le{10}^40≤ai,bi≤104;
对于 100% 的数据,1≤n,m≤1051\le n,m\le{10}^51≤n,m≤105,0≤ai,bi≤1060\le a_i,b_i\le{10}^60≤ai,bi≤106。
💡 思路解析
为了让不满意度最小,我们需要为每个学生找到一个分数线最接近的学校。
- 预处理:先将学校的分数线数组排序,这是使用二分查找的前提。
- 核心定位 :对于每个学生的估分 xxx,使用
lower_bound找到第一个大于等于 xxx 的学校位置mid。 - 边界判断 :最接近的分数线要么是
mid位置的学校,要么是mid-1位置的学校。我们需要特别注意mid==m(估分比所有学校都高)和mid==0(估分比所有学校都低)这两种边界情况,避免数组越界。
💻 代码实现
cpp
#include<bits/stdc++.h>
using namespace std;
typedef long long ll;
int main(){
ll m, n;
cin >> m >> n;
vector<ll> school(m);
for(ll i = 0; i < m; i++){
cin >> school[i];
}
// 必须先排序才能使用二分查找
sort(school.begin(), school.end());
ll ans = 0;
ll x;
while(n--){
cin >> x;
// 找到第一个 >= x 的学校位置
ll mid = lower_bound(school.begin(), school.end(), x) - school.begin();
if(mid == m){
// 估分比所有学校都高,只能选最后一个
ans += x - school[mid - 1];
}
else if(mid == 0){
// 估分比所有学校都低,只能选第一个
ans += school[mid] - x;
}
else{
// 比较左边和右边,取差值最小的
ans += min(school[mid] - x, x - school[mid - 1]);
}
}
cout << ans << endl;
return 0;
}
03 🚀 高阶思维:二分答案与贪心Check
当题目要求"求最大值的最小值"或"最小值的最大值"时,直接求解往往非常困难。这时我们可以使用"二分答案":把求最优解转化为判断某个值是否可行。
🪨 P2678 NOIP 2015 提高组 跳石头
题目背景
NOIP2015 Day2T1
题目描述
一年一度的"跳石头"比赛又要开始了!
这项比赛将在一条笔直的河道中进行,河道中分布着一些巨大岩石。组委会已经选择好了两块岩石作为比赛起点和终点。在起点和终点之间,有 NNN 块岩石(不含起点和终点的岩石)。在比赛过程中,选手们将从起点出发,每一步跳向相邻的岩石,直至到达终点。
为了提高比赛难度,组委会计划移走一些岩石,使得选手们在比赛过程中的最短跳跃距离尽可能长。由于预算限制,组委会至多从起点和终点之间移走 MMM 块岩石(不能移走起点和终点的岩石)。
输入格式
第一行包含三个整数 L,N,ML,N,ML,N,M,分别表示起点到终点的距离,起点和终点之间的岩石数,以及组委会至多移走的岩石数。保证 L≥1L \geq 1L≥1 且 N≥M≥0N \geq M \geq 0N≥M≥0。
接下来 NNN 行,每行一个整数,第 iii 行的整数 Di (0<Di<L)D_i\,( 0 < D_i < L)Di(0<Di<L), 表示第 iii 块岩石与起点的距离。这些岩石按与起点距离从小到大的顺序给出,且不会有两个岩石出现在同一个位置。
输出格式
一个整数,即最短跳跃距离的最大值。
输入输出样例 #1
输入 #1
text
25 5 2
2
11
14
17
21
输出 #1
text
4
说明/提示
输入输出样例 1 说明
将与起点距离为 222 和 141414 的两个岩石移走后,最短的跳跃距离为 444(从与起点距离 171717 的岩石跳到距离 212121 的岩石,或者从距离 212121 的岩石跳到终点)。
数据规模与约定
对于 20% 的数据,0≤M≤N≤100 \le M \le N \le 100≤M≤N≤10。
对于 50% 的数据,0≤M≤N≤1000 \le M \le N \le 1000≤M≤N≤100。
对于 100% 的数据,0≤M≤N≤50000,1≤L≤1090 \le M \le N \le 50000, 1 \le L \le 10^90≤M≤N≤50000,1≤L≤109。
💡 思路解析
直接思考"移走哪些石头能让最短距离最大"非常复杂。我们转换思路:
- 二分答案 :我们二分"最短跳跃距离"
dist。如果dist可行,我们就尝试更大的距离;如果不可行,就尝试更小的距离。 - 编写 Check 函数 :如何判断一个距离
dist是否可行?使用贪心 思想:从起点开始遍历石头,如果当前石头与上一块保留石头的距离小于dist,说明这块石头必须移走;否则保留这块石头。最后判断移走的石头总数是否 ≤M\le M≤M。
💻 代码实现
cpp
#include <bits/stdc++.h>
using namespace std;
// 全局变量,方便 check 函数访问
int L, N, M;
vector<int> d; // 存放石头到起点的距离
// check函数:判断最短距离为 dist 时,需要移走的石头是否 <= M
bool check(int dist) {
int cnt = 0; // 记录需要移走的石头数量
int last = 0; // 上一块保留的石头的位置(初始为起点0)
for (int i = 1; i <= N; i++) {
if (d[i] - last < dist) {
// 距离太近了,当前这块石头必须移走
cnt++;
} else {
// 距离足够,保留这块石头,更新上一块的位置
last = d[i];
}
}
// 如果移走的石头数量在预算 M 之内,说明这个 dist 是可行的
return cnt <= M;
}
int main() {
// 优化输入输出
ios::sync_with_stdio(false);
cin.tie(nullptr);
cin >> L >> N >> M;
d.resize(N + 1);
for (int i = 1; i <= N; i++) {
cin >> d[i];
}
// 二分答案的边界
int left = 1; // 最短距离最小可能是1
int right = L; // 最短距离最大可能是L(直接跳到终点)
int ans = 0;
while (left <= right) {
int mid = left + (right - left) / 2; // 防溢出写法
if (check(mid)) {
ans = mid; // mid 可行,先记录下来
left = mid + 1; // 尝试更大的最短距离(向右找)
} else {
right = mid - 1; // mid 不可行,距离太大了(向左找)
}
}
cout << ans << endl;
return 0;
}
⚠️ 避坑指南(重点!)
在使用二分查找时,有几个极易踩的"坑",大家一定要拿小本本记下来:
- 数据范围与溢出 :当数据范围达到 10910^9109 甚至 2502^{50}250 时,一定要使用
long long!另外,计算中间值mid时,推荐使用left + (right - left) / 2的防溢出写法。 - 边界处理 :在使用
lower_bound等库函数时,务必判断返回的迭代器是否指向了begin()或end(),防止访问非法内存。 - 二分答案的单调性 :使用二分答案的前提是问题具有"单调性"(即:如果距离 X 可行,那么比 X 小的距离一定也可行)。在写
check函数时,要确保逻辑严密。
📌 总结
通过这三道题,我们可以看到二分查找的强大之处:
- oj-P1833 告诉我们:理解区间收缩的逻辑是掌握二分的基础。
- P1678 告诉我们:结合排序和 STL 库函数,二分查找能高效解决最优匹配问题。
- P2678 告诉我们:面对复杂的最值问题,巧妙使用"二分答案 + 贪心Check"能化繁为简。
下次遇到有序数据查找或最值判定问题,别再暴力遍历啦,直接二分走起!🚀
(觉得有用的话,别忘了点赞、在看、转发三连哦!) ❤️