C++ 竞赛入门

二分查找

有序区间上回答“是否存在”与两个边界:lower_bound、upper_bound。

二分查找

读完能做什么

  • 能在一段已经有序的数据里,用 binary_search 判断某个值在不在。
  • 能用 lower_bound 找出“第一个不小于 x 的位置”,用 upper_bound 找出“第一个大于 x 的位置”。
  • 能用 upper_bound - lower_bound 算出 x 出现了几次,并说出这两个边界为什么相减。
  • 能看懂查询值不存在、数据全部相等、区间为空这三种情况下两个边界落在哪里。

为什么需要它

find 要从头到尾一个个看,数据有 n 个就得比较 n 次。但如果数据已经排好序,可以更聪明地找:每次都看中间那个元素,比目标小就往右半段找,比目标大就往左半段找,一次砍掉一半。这样最多比较大约 log2(n) 次,n 是 100 万时也只要 20 次左右。

“砍一半”这个动作也有一套固定写法,标准库把它做成了几个函数:判断存在用 binary_search,找边界位置用 lower_bound 和 upper_bound。它们比 find 快得多,但代价是要求数据已经有序——前提不满足时结果不可靠。

它是什么

一句话:二分查找族在一段按同一规则有序的区间里,用“每次砍掉一半”的方式回答问题。

先分清三个函数各自回答什么:

函数 回答的问题 返回值
binary_search(first, last, x) x 在不在区间里? bool(真/假)
lower_bound(first, last, x) 第一个不小于 x 的元素在哪? 位置(迭代器)
upper_bound(first, last, x) 第一个大于 x 的元素在哪? 位置(迭代器)

几个必须就地看懂的概念:

“不小于”就是“大于或等于”。 lower_bound 找第一个满足“元素 >= x”的位置。如果 x 存在,它就是第一个 x 的位置。

“大于”是严格大于。 upper_bound 找第一个满足“元素 > x”的位置,也就是第一个 x 之后的位置。

位置与下标。 这三个函数返回的是迭代器。lo - v.begin() 把位置换算成下标,要求迭代器能相减(vector、deque、数组可以;list、set 不行)。

必须有序,且查询与排序用同一条规则。 默认用 < 比较,所以数据要从小到大排好。如果你用 greater<int>() 把数据排成降序,查询时也必须传同一个 greater<int>(),否则“砍一半”的方向就反了。

返回 end() 时不能取值。 所有元素都比 x 小时,lower_bound、upper_bound 都会返回区间的终点 last。这个位置指向的不是任何元素,只能拿来比较,不能解引用。

使用时要 #include <algorithm>,本页示例用 #include <bits/stdc++.h> 一次包含全部标准头文件。

最小可运行示例

数据 [1, 2, 2, 2, 5] 已经从小到大排好,查 2:

#include <bits/stdc++.h>
using namespace std;

int main() {
    vector<int> v = {1, 2, 2, 2, 5};      // 已经按从小到大排好

    cout << "存在 2 吗:" << binary_search(v.begin(), v.end(), 2) << '\n';

    auto lo = lower_bound(v.begin(), v.end(), 2);   // 第一个不小于 2 的位置
    auto hi = upper_bound(v.begin(), v.end(), 2);   // 第一个大于 2 的位置

    cout << "下界下标:" << (lo - v.begin()) << '\n';
    cout << "上界下标:" << (hi - v.begin()) << '\n';
    cout << "2 出现次数:" << (hi - lo) << '\n';
    return 0;
}

输出:

存在 2 吗:1
下界下标:1
上界下标:4
2 出现次数:3

逐行看:

  • binary_search(v.begin(), v.end(), 2) 返回 true,打印成 1(bool 输出默认是 0/1)。
  • lower_bound 返回第一个不小于 2 的位置。下标 0 的 1 小于 2,下标 1 的 2 不小于 2,所以下界停在下标 1。
  • upper_bound 返回第一个大于 2 的位置。下标 1、2、3 都是 2,下标 4 的 5 才是第一个大于 2 的,所以上界停在下标 4。
  • hi - lo 是两个位置相减,得到中间夹着几个位置:4 - 1 = 3,正好是 2 出现的次数。

一步步看变化

还是这组数据,把两个边界标在同一行上:

下标 0 1 2 3 4 5(尾后)
元素 1 2 2 2 5 —
和 2 比 小于 2 等于 2 等于 2 等于 2 大于 2 —
边界 — ↑ 下界(第一个不小于 2) — — ↑ 上界(第一个大于 2) —

下界停在下标 1,上界停在下标 4,中间 [下界, 上界) 覆盖下标 1、2、3 这三个 2。所以:

graph LR
    p0["下标 0 值 1"] --> p1["下标 1 值 2"]
    p1 --> p2["下标 2 值 2"]
    p2 --> p3["下标 3 值 2"]
    p3 --> p4["下标 4 值 5"]
    L["下界 停在下标 1"] -.-> p1
    U["上界 停在下标 4"] -.-> p4

再用同一个数据查三个不同的值,看边界怎么动:

#include <bits/stdc++.h>
using namespace std;

int main() {
    vector<int> v = {1, 2, 2, 2, 5};
    vector<int> queries = {3, 6, 1};

    for (int x : queries) {
        auto lo = lower_bound(v.begin(), v.end(), x);
        auto hi = upper_bound(v.begin(), v.end(), x);
        cout << "查 " << x
             << ":存在吗 " << binary_search(v.begin(), v.end(), x)
             << ",下界 " << (lo - v.begin())
             << ",上界 " << (hi - v.begin())
             << ",次数 " << (hi - lo) << '\n';
    }
    return 0;
}

输出:

查 3:存在吗 0,下界 4,上界 4,次数 0
查 6:存在吗 0,下界 5,上界 5,次数 0
查 1:存在吗 1,下界 0,上界 1,次数 1
  • 查 3:数据里没有 3。第一个不小于 3 的是下标 4 的 5,第一个大于 3 的也是下标 4 的 5,两个边界重合,次数是 0。
  • 查 6:所有元素都小于 6,两个边界都跑到尾后位置,下标都是 5。这就是返回 end() 的情况,只能比较,不能取值。
  • 查 1:下标 0 的 1 就是它,下界停在下标 0;下标 1 的 2 是第一个大于 1 的,上界停在下标 1,次数是 1。

规律:查一个不存在的值时,下界和上界会重合(都停在“它本该插入的位置”),相减得 0。

逐项扩展核心操作

binary_search 返回的是真假,不是位置

binary_search 只回答“在不在”,它不告诉你“在第几个”。所以不能拿它的返回值当下标用:

#include <bits/stdc++.h>
using namespace std;

int main() {
    vector<int> v = {1, 2, 2, 2, 5};

    bool found = binary_search(v.begin(), v.end(), 2);
    cout << "存在 2 吗:" << found << '\n';

    // 想知道"第一个 2 在哪",要另外用 lower_bound
    auto lo = lower_bound(v.begin(), v.end(), 2);
    cout << "第一个 2 的下标:" << (lo - v.begin()) << '\n';
    return 0;
}

输出:

存在 2 吗:1
第一个 2 的下标:1

bool found = ... 接住的是一个真假值。想要位置就必须换 lower_bound。判断存在也可以用 lower_bound:lower_bound(...) 得到的下界如果指向一个值恰好等于 x,就说明存在,但更直接的写法是用 binary_search。

数据全部相等

#include <bits/stdc++.h>
using namespace std;

int main() {
    vector<int> v = {7, 7, 7, 7};      // 全部相等

    auto lo = lower_bound(v.begin(), v.end(), 7);
    auto hi = upper_bound(v.begin(), v.end(), 7);
    cout << "下界 " << (lo - v.begin()) << ",上界 " << (hi - v.begin())
         << ",次数 " << (hi - lo) << '\n';
    return 0;
}

输出:

下界 0,上界 4,次数 4

所有元素都是 7:下界停在第一个位置(下标 0),上界跑到尾后位置(下标 4),次数是 4,正好是元素个数。

空区间

#include <bits/stdc++.h>
using namespace std;

int main() {
    vector<int> v;                     // 空区间

    auto lo = lower_bound(v.begin(), v.end(), 5);
    auto hi = upper_bound(v.begin(), v.end(), 5);
    cout << "存在吗:" << binary_search(v.begin(), v.end(), 5) << '\n';
    cout << "下界就是 end() 吗:" << (lo == v.end()) << '\n';
    cout << "下界与上界相等吗:" << (lo == hi) << '\n';
    return 0;
}

输出:

存在吗:0
下界就是 end() 吗:1
下界与上界相等吗:1

空区间里没有任何元素,两个边界都落在 end() 上,binary_search 返回假,次数是 0。

按自定义规则查询:数据怎么排,就怎么查

如果数据是用 greater<int>() 从大到小排的,查询也必须传同一条规则:

#include <bits/stdc++.h>
using namespace std;

int main() {
    vector<int> v = {5, 2, 2, 2, 1};   // 已经按从大到小排好

    // 查询也必须用同一条比较规则 greater<int>()
    auto lo = lower_bound(v.begin(), v.end(), 2, greater<int>());
    auto hi = upper_bound(v.begin(), v.end(), 2, greater<int>());

    cout << "下界下标:" << (lo - v.begin()) << '\n';
    cout << "上界下标:" << (hi - v.begin()) << '\n';
    cout << "2 出现次数:" << (hi - lo) << '\n';
    return 0;
}

输出:

下界下标:1
上界下标:4
2 出现次数:3

在降序数据里,“第一个不小于 2”理解为“第一个不大于 2 的位置”(因为大的在前面),“第一个大于 2”理解为“第一个小于 2 的位置”。规则换了,含义跟着换,但 upper - lower 仍然是出现次数。关键是一致:排序用哪条规则,查询就用哪条规则。

什么时候选它、哪里容易错

前提是数据已经有序。 二分查找每次都要“看中间、砍一半”,数据没序就砍错了地方,结果没有意义:

// 错误示例:数据没有排序就用二分查找,结果不可靠
#include <bits/stdc++.h>
using namespace std;

int main() {
    vector<int> v = {3, 1, 4, 2};            // 没有排序

    cout << binary_search(v.begin(), v.end(), 2) << '\n';   // 结果不可靠,不要照着写
    return 0;
}

这段能编译,但打印出来的真假没有意义。要先用 sort 排好,再查。

排序规则和查询规则要一致。 用 sort(v.begin(), v.end(), greater<int>()) 排成降序,却用默认的 < 去 lower_bound,方向就反了:

// 错误示例:数据按从大到小排序,却用默认的 < 规则去查,边界没有意义
#include <bits/stdc++.h>
using namespace std;

int main() {
    vector<int> v = {5, 2, 2, 2, 1};         // 降序

    auto lo = lower_bound(v.begin(), v.end(), 2);   // 缺了 greater<int>(),规则不一致
    cout << (lo - v.begin()) << '\n';               // 打印出来的下标不可信
    return 0;
}

这段能编译,打印出来也有数字,但那个下标没有意义:数据是按从大到小排的,查询却按默认的 < 规则找,方向对不上,结果取决于具体实现。排序规则和查询规则必须成对出现(要么两边都用 greater<int>(),要么两边都用默认的 <,写成 lower_bound(v.begin(), v.end(), 2, greater<int>()))。

binary_search 返回 bool,不能当位置。 要位置就得用 lower_bound / upper_bound。

返回 end() 时不能取值。 查一个比所有元素都大的值时,两个边界都跑到 end(),*lo 是越界访问,结果不确定。用之前先和 v.end() 比较。

算下标要求迭代器能相减。 lo - v.begin() 在 vector、deque、数组上可以;list、set 上不行,得改用 distance(lo, v.begin())(选读,代价是逐个走)。

泛型算法和容器的成员查询不是一回事。 set、map 也有 find、lower_bound、upper_bound 成员函数。它们不是“从头走到位置”,而是沿着树一层层比较下去,所以访问元素本身就要 O(log n) 次,而泛型 lower_bound 在 vector 上是“每次随机跳到中间”,跳转是 O(1) 的。两者复杂度都写成 O(log n),但常数和适用场景不同:数据是静态、要按位置随机访问就用 vector + 泛型算法;要边插入边查询就用 set / map 的成员版本。set / map 见 set 有序集合。

复杂度。 binary_search、lower_bound、upper_bound 的比较次数都是 O(log n),与迭代器种类无关;但在 list 这类不能随机跳的容器上,走到中间要一步步移动,定位的代价加起来是 O(n),所以这族函数在 vector 上才划算。

操作速查表

写法 作用 备注
binary_search(first, last, x) 判断 x 在不在 返回 bool,要求有序
lower_bound(first, last, x) 第一个不小于 x 的位置 要求有序,返回位置
upper_bound(first, last, x) 第一个大于 x 的位置 要求有序,返回位置
upper_bound(...) - lower_bound(...) x 出现的次数 两个位置相减
lo - v.begin() 把位置换算成下标 要求迭代器能相减
lo == v.end() 判断边界是否到区间末尾 到末尾时不能解引用
lower_bound(first, last, x, comp) 按自定义规则查下界 排序规则必须一致
sort(v.begin(), v.end()) 查询前先排好序 见 sort 排序
s.lower_bound(x) set / map 的成员版本 在树里查找,见关联容器页

动手检查

第 1 题:预测输出

下面是一段片段(省略了头文件和 main),数据已经有序。请先手推出输出,再运行核对:

vector<int> v = {1, 2, 2, 2, 5};
auto lo = lower_bound(v.begin(), v.end(), 5);
auto hi = upper_bound(v.begin(), v.end(), 5);
cout << (lo - v.begin()) << ' ' << (hi - v.begin()) << '\n';

第 2 题:修改代码

下面这段片段读入一组已经排好序的数和一个目标值,请补上计算“目标值出现次数”的语句,让输出是出现次数:

int n;
cin >> n;
vector<int> v(n);
for (int i = 0; i < n; i++) cin >> v[i];
int x;
cin >> x;

// 在这里补上计算 x 出现次数的语句,并输出

用这组输入核对:第一行 5,第二行 1 2 2 2 5,第三行 2,输出应该是 3。

验证条件:先说出 lower_bound 和 upper_bound 分别停在下标几,再运行核对。

参考结果与解释

第 1 题:输出是 4 5。数据里只有一个 5,在下标 4。lower_bound 找第一个不小于 5 的位置,就是下标 4;upper_bound 找第一个大于 5 的位置,没有更大的元素,所以跑到尾后位置下标 5。完整程序与实测输出:

#include <bits/stdc++.h>
using namespace std;

int main() {
    vector<int> v = {1, 2, 2, 2, 5};
    auto lo = lower_bound(v.begin(), v.end(), 5);
    auto hi = upper_bound(v.begin(), v.end(), 5);
    cout << (lo - v.begin()) << ' ' << (hi - v.begin()) << '\n';
    return 0;
}

输出:

4 5

顺便提醒:hi 等于 v.end(),它指向的不是任何元素,所以这里只做了相减,没有解引用。

第 2 题:用两个边界相减即可。

#include <bits/stdc++.h>
using namespace std;

int main() {
    int n;
    cin >> n;
    vector<int> v(n);
    for (int i = 0; i < n; i++) cin >> v[i];
    int x;
    cin >> x;

    auto lo = lower_bound(v.begin(), v.end(), x);
    auto hi = upper_bound(v.begin(), v.end(), x);
    cout << (hi - lo) << '\n';
    return 0;
}

输入:

5
1 2 2 2 5
2

输出:

3

lower_bound 停在下标 1,upper_bound 停在下标 4,相减得 3。这两个边界夹住的就是所有等于 2 的位置。

立即练习

难度 题目 题解 训练目标
普及- 洛谷 P2249:【深基13.例1】查找 题解 在一段有序数据里回答“某值第一次出现的位置,没有则输出 -1”:练习 lower_bound 的返回值判断,注意返回 end() 时要输出 -1,另外题目要的是从 1 开始的编号,下标要 +1。
普及- 洛谷 P1678:烦恼的高考志愿 题解 对每个分数在有序的录取线里找最接近的一档:用 lower_bound 定位后比较左右两个候选,注意处理边界(下界落在区间开头或末尾)。不满意度要累加,最大值会超过 int 的范围,累加变量用 long long。

两道题都只需要数组、循环、排序和本页的二分查找函数。做完后请说明:第一题为什么不能直接用 binary_search?