```mermaid graph LR A[Mermaid Keeper] --> B[保持激活] ```

前言

你玩过猜数字游戏吗?“我想了一个 1 到 100 之间的数,你来猜。“如果你从 1 开始一个个试,最坏要猜 100 次。但如果你每次猜中间的数——先猜 50,“大了”,再猜 25,“小了”,再猜 37……最多只需要 log2(100)7\log_2(100) \approx 7 次。

这就是二分搜索。它不只用于”在排序数组里找某个数”。更准确地说,任何可以二分答案空间的问题,都可以用二分。后面你会看到,很多时候我们甚至不是在数组中搜索,而是在二分”答案本身”。

问题的本质

二分搜索的核心前提是单调性:如果某个值 xx 满足条件,那么所有 x\ge x(或 x\le x)的值也满足。

举个例子:“数组中第一个 target\ge \text{target} 的位置”。如果 a[5]targeta[5] \ge \text{target},那 a[6],a[7],a[6], a[7], \ldotstarget\ge \text{target}(因为数组已排序)。这个单调性让我们可以每次把搜索空间砍一半——如果 a[mid]targeta[\text{mid}] \ge \text{target},答案在左半(含 mid);否则答案在右半(不含 mid)。

理论 + 代码

标准 lower_bound:找第一个 target\ge \text{target} 的位置

int lower_bound(int a[], int n, int target) {
    int lo = 0, hi = n;                  // ① 搜索范围 [lo, hi)
    while (lo < hi) {                    // ② 当范围不为空
        int mid = lo + (hi - lo) / 2;    // ③ 中点
        if (a[mid] < target)
            lo = mid + 1;                // ④ mid 不够大,答案在右半
        else
            hi = mid;                    // ⑤ mid 可能是答案,保留
    }
    return lo;                           // ⑥ lo == hi,就是答案
}
  • ① 搜索范围是 [lo,hi)[\text{lo}, \text{hi})——左闭右开。lo = 0, hi = n 表示”整个数组”。
  • lo + (hi - lo) / 2 而不是 (lo + hi) / 2——因为 lo + hi 可能溢出 int
  • hi = mid 而不是 hi = mid - 1——因为 mid 可能就是答案,不能排除。

走一遍过程:在 [1, 3, 5, 7, 9] 中找第一个 4\ge 4 的位置。

步骤lohimida[mid]判断新范围
10525545 \ge 4,hi=2[0,2)
202133<43 < 4,lo=2[2,2)

lo == hi == 2,答案是位置 2,即 a[2] = 5。手动验证:[1, 3, 5, 7, 9] 中第一个 4\ge 4 的确实是 5。✓

二分答案

很多时候我们不是在数组中搜索,而是二分答案本身。比如”最小的 xx 使得某条件成立”——对 xx 做二分,每次检查条件是否成立。

long long lo = 0, hi = 1e18;   // 答案范围
while (lo < hi) {
    long long mid = lo + (hi - lo) / 2;
    if (check(mid))             // ① mid 满足条件
        hi = mid;                // ② 答案在 [lo, mid] 中
    else
        lo = mid + 1;            // ③ 答案在 [mid+1, hi] 中
}
// lo 就是答案
  • check(mid) 是一个判断”mid 是否可行”的函数。
  • ② 如果可行,mid 可能就是答案,也可能还能更小,所以保留 mid。
  • ③ 如果不可行,mid 太小了,答案一定比 mid 大。

坐标压缩

有时候数据值域很大(比如 AiA_i 高达 10910^9),但数据量不大(N105N \le 10^5)。这种情况下,我们只关心数据之间的大小关系,不关心具体值。坐标压缩就是把原始值映射成它在排序后的排名:

比如 A=[46,80,11,77,46]A = [46, 80, 11, 77, 46]

  1. 排序去重得到 B=[11,46,77,80]B = [11, 46, 77, 80]
  2. 对每个 AiA_i,在 BBlower_bound 找到它的位置(从 1 开始):462,804,111,773,46246 \to 2, 80 \to 4, 11 \to 1, 77 \to 3, 46 \to 2
  3. 压缩结果:[2,4,1,3,2][2, 4, 1, 3, 2]
#include <algorithm>
using namespace std;

// 坐标压缩:把 a[0..n-1] 映射到 1~m(m 是不同值的个数)
void compress(long long a[], int n) {
    long long b[100010];
    for (int i = 0; i < n; i++) b[i] = a[i];
    sort(b, b + n);                              // ① 排序
    int m = unique(b, b + n) - b;                 // ② 去重
    for (int i = 0; i < n; i++)
        a[i] = lower_bound(b, b + m, a[i]) - b + 1;  // ③ 求排名(1-indexed)
}
  • unique 把相邻的重复元素移到末尾,返回去重后的末尾指针。前提是数组已排序
  • lower_bound(b, b+m, a[i]) - b 得到 aia_i 在排序去重数组中的下标(0-indexed),加 1 变成排名。比如 b=[11,46,77,80]b = [11, 46, 77, 80]lower_bound(b, b+4, 46) 指向 b[1]b[1],所以 461+1=246 \to 1 + 1 = 2

坐标压缩在后面会频繁用到——比如 03-04 树状数组 处理大值域时需要先压缩。

例题

例题 1:M&A 032 — Binary Search(★2)

题目:给定长度为 NN 的数组 AA 和一个整数 XX。判断 XX 是否存在于数组 AA 中。存在输出 Yes,否则输出 No

数据范围1N1051 \le N \le 10^51X,Ai1091 \le X, A_i \le 10^9

—— AtCoder M&A 032

思路:如果用线性搜索逐个检查,O(N)O(N) 也可以过(10510^5)。但二分搜索只需 O(logN)O(\log N)。先排序(O(NlogN)O(N \log N)),然后对有序数组做二分查找。

代码

#include <cstdio>
#include <algorithm>
using namespace std;

int main() {
    int N, X;
    scanf("%d%d", &N, &X);
    int A[100010];
    for (int i = 0; i < N; i++)
        scanf("%d", &A[i]);

    sort(A, A + N);    // ① 先排序

    // ② 二分查找:找到第一个 >= X 的位置
    int pos = lower_bound(A, A + N, X) - A;

    if (pos < N && A[pos] == X)    // ③ 检查该位置的值是否恰好等于 X
        printf("Yes\n");
    else
        printf("No\n");
}

逐行解析

  • ① 排序后才能用二分搜索。如果题目保证数组有序,就不需要这步。
  • lower_bound(A, A + N, X) 返回指向第一个 X\ge X 的元素的指针。减去 A 得到下标。
  • lower_bound 找的是 X\ge X,不一定是 =X= X。所以要检查 A[pos] == X

例题 2:Tessoku Book — A11 Binary Search 1

题目:给定已排序的数组 AAA1<A2<<ANA_1 < A_2 < \cdots < A_N)和整数 XX。已知 XX 一定存在于 AA 中,输出 XX 是第几个元素(1-indexed)。

数据范围1N1000001 \le N \le 1000001Ai1091 \le A_i \le 10^9

—— AtCoder Tessoku Book A11

思路:数组已有序,直接二分查找。和例题 1 类似,但题目保证 XX 存在,所以 lower_bound 的结果一定就是答案。

代码

#include <cstdio>
#include <algorithm>
using namespace std;

int main() {
    int N, X;
    scanf("%d%d", &N, &X);
    int A[100010];
    for (int i = 0; i < N; i++)
        scanf("%d", &A[i]);

    // ① 二分查找 X 的位置(1-indexed)
    int pos = lower_bound(A, A + N, X) - A;
    printf("%d\n", pos + 1);    // ② 转为 1-indexed
}

逐行解析

  • lower_bound 返回第一个 X\ge X 的位置。因为 XX 一定存在且数组严格递增,lower_bound 精确定位到 XX
  • ② AtCoder 用 1-indexed,所以 pos + 1

例题 3:Tessoku Book — A12 Printer

题目NN 台打印机,第 ii 台每隔 AiA_i 秒印一张传单(即在 Ai,2Ai,3Ai,A_i, 2A_i, 3A_i, \ldots 秒各印一张)。所有打印机同时启动,第 KK 张传单是什么时候印出来的?

数据范围1N1051 \le N \le 10^51K1091 \le K \le 10^91Ai1091 \le A_i \le 10^9

—— AtCoder Tessoku Book A12

思路:经典二分答案。问”第 KK 张什么时候印出”——如果假设答案是时间 tt,那么在 tt 秒内第 ii 台打印机印了 t/Ai\lfloor t / A_i \rfloor 张。如果总张数 K\ge K,说明 tt 可能大了或刚好;如果 <K< K,说明 tt 不够。

二分搜索 tt 的范围:最小 0,最大 K×max(Ai)K \times \max(A_i)(最坏情况只有一台最快的在印)。但题目保证答案 109\le 10^9

代码

#include <cstdio>
using namespace std;

int main() {
    int N;
    long long K;
    scanf("%d%lld", &N, &K);
    long long A[100010];
    for (int i = 0; i < N; i++)
        scanf("%%lld", &A[i]);

    // ① 二分答案:t 秒内能否印够 K 张
    long long lo = 0, hi = 1000000000LL;
    while (hi - lo > 1) {
        long long mid = (lo + hi) / 2;
        long long cnt = 0;
        for (int i = 0; i < N; i++)
            cnt += mid / A[i];    // ② 第 i 台在 mid 秒内印了多少张
        if (cnt >= K)    // ③ 印够了,答案可能更小
            hi = mid;
        else            // ④ 不够,答案更大
            lo = mid;
    }
    printf("%lld\n", hi);
}

逐行解析

  • lo 是”不够”的边界,hi 是”够”的边界。循环结束时 hi 就是最小的”够”的时间。
  • mid/Ai\lfloor mid / A_i \rfloor:第 ii 台在 midmid 秒内印了多少张。
  • ③④ 标准二分答案模式:如果够了就缩小上界,不够就增大下界。

例题 4:T90 001 — Yokan Party(★4)

题目:长度为 LL 的羊羹上有 NN 个切口(位置 A1<A2<<ANA_1 < A_2 < \cdots < A_N)。选 KK 个切口把羊羹分成 K+1K+1 段,使得最短的那段尽可能长。输出最短段的最大可能长度。

数据范围1KN1051 \le K \le N \le 10^51L1091 \le L \le 10^9

—— AtCoder Typical 90 001

思路:二分答案的典型题。设答案为 xx——问”能否让每段长度都 x\ge x“。如果能,xx 还可以更大;如果不能,xx 必须更小。

判定函数:从左到右贪心切。累计长度,只要当前段 x\ge x 就切一刀。最后看能否切出 K+1\ge K+1 段。

代码

#include <cstdio>
using namespace std;

int main() {
    int N, L, K;
    scanf("%d%d", &N, &L);
    scanf("%d", &K);
    int A[100010];
    for (int i = 0; i < N; i++)
        scanf("%d", &A[i]);

    // ① 二分答案:每段至少 x 厘米
    int lo = 1, hi = L;
    while (hi - lo > 1) {
        int mid = (lo + hi) / 2;
        int cnt = 0, prev = 0;    // ② 切了几刀,上一个切点
        for (int i = 0; i < N; i++) {
            if (A[i] - prev >= mid) {    // ③ 当前段 >= mid,可以切
                cnt++;
                prev = A[i];
            }
        }
        // ④ 检查最后一段(从最后一个切点到右端)
        if (L - prev >= mid)
            cnt++;    // 最后一段也算

        if (cnt >= K + 1)    // ⑤ 能切出 K+1 段
            lo = mid;
        else
            hi = mid;
    }
    printf("%d\n", lo);
}

逐行解析

  • ③ 从左到右贪心:只要当前段长度 mid\ge mid,就立刻切。贪心是对的——切得越早,后面留给其他段的空间越大。
  • ⑤ 能切出 K+1K+1 段说明 midmid 可行,尝试更大的值。

参考文献

教材讲解 — 競技プログラミングの鉄則 第 3 章

基础练习 — アルゴリズムと数学 演習問題集

系统练习 — 競技プログラミングの鉄則

实战练习 — 競プロ典型 90 問


系列索引

第零章 基础工具

第一章 搜索技术

第二章 数学基础

第三章 数据结构

第四章 图论

第五章 动态规划

第六章 贪心

第七章 字符串

第八章 进阶