算法日记 | 二分查找

🎯 算法效率神器 | 二分查找实战:从入门到"二分答案"!

导语

面对海量数据,你还在用 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
💡 思路解析

这道题不需要数组,只需要用两个变量 lr 维护当前的查找区间即可。由于 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。

💡 思路解析

为了让不满意度最小,我们需要为每个学生找到一个分数线最接近的学校。

  1. 预处理:先将学校的分数线数组排序,这是使用二分查找的前提。
  2. 核心定位 :对于每个学生的估分 xxx,使用 lower_bound 找到第一个大于等于 xxx 的学校位置 mid
  3. 边界判断 :最接近的分数线要么是 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。

💡 思路解析

直接思考"移走哪些石头能让最短距离最大"非常复杂。我们转换思路:

  1. 二分答案 :我们二分"最短跳跃距离"dist。如果 dist 可行,我们就尝试更大的距离;如果不可行,就尝试更小的距离。
  2. 编写 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;
}

⚠️ 避坑指南(重点!)

在使用二分查找时,有几个极易踩的"坑",大家一定要拿小本本记下来:

  1. 数据范围与溢出 :当数据范围达到 10910^9109 甚至 2502^{50}250 时,一定要使用 long long!另外,计算中间值 mid 时,推荐使用 left + (right - left) / 2 的防溢出写法。
  2. 边界处理 :在使用 lower_bound 等库函数时,务必判断返回的迭代器是否指向了 begin()end(),防止访问非法内存。
  3. 二分答案的单调性 :使用二分答案的前提是问题具有"单调性"(即:如果距离 X 可行,那么比 X 小的距离一定也可行)。在写 check 函数时,要确保逻辑严密。

📌 总结

通过这三道题,我们可以看到二分查找的强大之处:

  • oj-P1833 告诉我们:理解区间收缩的逻辑是掌握二分的基础。
  • P1678 告诉我们:结合排序和 STL 库函数,二分查找能高效解决最优匹配问题。
  • P2678 告诉我们:面对复杂的最值问题,巧妙使用"二分答案 + 贪心Check"能化繁为简。

下次遇到有序数据查找或最值判定问题,别再暴力遍历啦,直接二分走起!🚀


(觉得有用的话,别忘了点赞、在看、转发三连哦!) ❤️