📖 第 3.3 章 ⏱️ 约 60 分钟 🎯 中级

第 3.3 章:排序与搜索

📝 前置条件: 你应该熟悉数组、向量和基本循环(第 2.2–2.3 章)。了解第 3.1 章中的 std::sort 有帮助,但本章会深入讲解。

排序和搜索是计算机科学中最基础的两种操作。在 USACO 中,一旦把数据正确排序,大量问题就迎刃而解。而二分查找——在 O(log n) 时间内搜索有序数组——是你会反复用到的技术。


3.3.1 排序为什么重要

考虑这道题:「给定 N 头奶牛的身高,找出身高最接近的两头奶牛。」

  • 不排序的做法: 比较每一对 → O(N²)。对 N = 10^5,是 10^10 次操作,超时。
  • 排序的做法: 排序身高 → O(N log N)。然后最近的一对一定是相邻的!检查 N-1 对 → O(N)。总计:O(N log N)。✓

💡 核心思路: 排序能把很多 O(N²) 暴力解法变成 O(N log N)O(N) 解法。当你看到「找满足属性 X 的对」或「找涉及两个元素的最小/最大值」时,始终先考虑排序。

复杂度分析:

  • 排序:O(N log N) 时间,O(log N) 空间(递归栈深度;std::sort 使用 Introsort——快速排序 + 堆排序 + 插入排序的混合,三个分支最多使用 O(log N) 的栈空间)
  • 排序后:相邻比较或双指针技术是 O(N)

3.3.2 排序的工作原理(概念)

你不需要自己实现排序算法——std::sort 帮你做了。但理解背后的思想有助于你分析时间复杂度并选择正确的方法。

以下是四种经典排序算法,每种都配有交互式可视化帮助你理解。

算法时间复杂度空间稳定?核心思想
冒泡排序O(N²)O(1)交换相邻元素;大值「冒泡」到末尾
插入排序O(N²) / O(N) 最优O(1)将每个元素插入已排序区域的正确位置
归并排序O(N log N)O(N)分治:递归分割,然后合并
快速排序O(N log N) 平均O(log N)分治:以枢轴为界分区,递归

🫧 冒泡排序 —— O(N²)

反复扫描数组,交换顺序错误的相邻元素。每次遍历把当前最大值「冒泡」到未排序区域的末尾:

初始:   [64, 34, 25, 12, 22, 11, 90]
第1遍:  [34, 25, 12, 22, 11, 64, 90]   ← 64 冒泡到倒数第2位
第2遍:  [25, 12, 22, 11, 34, 64, 90]   ← 34 冒泡到倒数第3位
第3遍:  [12, 22, 11, 25, 34, 64, 90]   ← 25 冒泡到倒数第4位
...

📝 注意: 90 一开始就在正确位置,第1遍没有移动它——而是 64(次大值)冒泡到倒数第2位。每次遍历保证末尾多一个元素到达最终有序位置。

冒泡排序是 O(N²)竞赛编程中对大输入绝不要用它。 我们讲它只是因为概念上最简单。


🃏 插入排序 —— O(N²) / 最优 O(N)

把数组分为左侧「已排序区」和右侧「未排序区」。每步取未排序区的第一个元素,插入到已排序区的正确位置:

开始:  [64 | 34, 25, 12, 22, 11, 90]   ← | 左侧已排序
i=1:   [34, 64 | 25, 12, 22, 11, 90]   ← 34 插到 64 之前
i=2:   [25, 34, 64 | 12, 22, 11, 90]   ← 25 插到最前
i=3:   [12, 25, 34, 64 | 22, 11, 90]   ← 12 插到最前
...

💡 插入排序的优势:几乎已排序的数组非常快(接近 O(N))。std::sort 对小子数组会切换到插入排序。

查看参考实现
void insertionSort(vector<int>& a) {
    int n = a.size();
    for (int i = 1; i < n; i++) {
        int key = a[i];   // 要插入的元素
        int j = i - 1;
        // 将大于 key 的元素向右移动一位
        while (j >= 0 && a[j] > key) {
            a[j + 1] = a[j];
            j--;
        }
        a[j + 1] = key;  // 把 key 放到正确位置
    }
}

🔀 归并排序 —— 始终 O(N log N)

分治:递归地将数组分成两半,然后将两个已排序的半部合并:

[38, 27, 43, 3, 9, 82, 10]
        ↓ 递归分割
[38,27,43,3]    [9,82,10]
[38,27] [43,3]  [9,82] [10]
[38][27][43][3] [9][82][10]
        ↓ 自底向上合并
[27,38] [3,43]  [9,82] [10]
  [3,27,38,43]    [9,10,82]
      [3,9,10,27,38,43,82] ✓

归并排序在所有情况下都是 O(N log N),而且是稳定排序。

查看参考实现
void merge(vector<int>& a, int lo, int mid, int hi) {
    vector<int> tmp(a.begin() + lo, a.begin() + hi + 1);
    int i = lo, j = mid + 1, k = lo;
    while (i <= mid && j <= hi) {
        if (tmp[i - lo] <= tmp[j - lo]) {
            a[k++] = tmp[i - lo];  // 左半部分更小,优先放入以保持稳定性
            i++;
        } else {
            a[k++] = tmp[j - lo];
            j++;
        }
    }
    while (i <= mid) { a[k++] = tmp[i - lo]; i++; }
    while (j <= hi)  { a[k++] = tmp[j - lo]; j++; }
}

void mergeSort(vector<int>& a, int lo, int hi) {
    if (lo >= hi) return;
    int mid = lo + (hi - lo) / 2;
    mergeSort(a, lo, mid);
    mergeSort(a, mid + 1, hi);
    merge(a, lo, mid, hi);
}

⚡ 快速排序 —— 平均 O(N log N)

快速排序是 std::sort 底层的核心算法之一,关键思想是分治

  1. 选择一个枢轴元素(通常是最后一个元素)
  2. 分区: 将所有 ≤ 枢轴的元素移到左边,> 枢轴的移到右边;枢轴落到最终位置
  3. 对左右子数组递归
[8, 3, 6, 1, 9, 2, 7, 4]   ← 枢轴 = 4
         ↓ 分区
[3, 1, 2, 4, 9, 6, 7, 8]   ← 4 在最终位置;左边 ≤ 4,右边 > 4
 ↑_______↑  ↑  ↑__________↑
 左子数组     右子数组

递归 [3,1,2] → [1,2,3]
递归 [9,6,7,8] → [6,7,8,9]

最终:[1, 2, 3, 4, 6, 7, 8, 9] ✓

Quicksort Partition

查看参考实现
// 对 arr[lo..hi] 用最后一个元素作枢轴进行分区。
// 返回枢轴的最终下标。
int partition(vector<int>& arr, int lo, int hi) {
    int pivot = arr[hi];   // 选最后一个元素为枢轴
    int i = lo - 1;        // i 指向「≤ 枢轴」区域的末尾

    for (int j = lo; j < hi; j++) {
        if (arr[j] <= pivot) {
            i++;
            swap(arr[i], arr[j]);  // 把 arr[j] 纳入 ≤ 枢轴区域
        }
    }
    swap(arr[i + 1], arr[hi]);  // 把枢轴放到最终位置
    return i + 1;               // 返回枢轴的下标
}

void quickSort(vector<int>& arr, int lo, int hi) {
    if (lo >= hi) return;           // 基础情况:子数组长度 ≤ 1
    int p = partition(arr, lo, hi); // p 是枢轴的最终位置
    quickSort(arr, lo, p - 1);      // 排序左子数组
    quickSort(arr, p + 1, hi);      // 排序右子数组
}

⚠️ 最坏情况: 若枢轴每次都是最大或最小值(例如已排序的输入),递归深度退化到 O(N),总时间变成 O(N²)。std::sort 通过随机选择枢轴三数取中来避免这一点,保证最坏 O(N log N)。

情况时间备注
平均O(N log N)枢轴大约将数组对半分
最坏O(N²)枢轴每次都是极端值(已排序输入)
空间O(log N)递归栈深度(平均);若枢轴每次极端则最坏 O(N)

🔢 计数排序 —— O(N + W)

适用场景: 元素是整数,值域范围 W 不太大(如 W ≤ 10^6)。

核心思想: 统计每个值出现的次数,再按值输出。

📄 C++ 完整代码
// 计数排序(稳定,O(N+W))
// 要求:所有元素在 [0, W) 范围内
void countingSort(vector<int>& a, int W) {
    int n = a.size();
    vector<int> cnt(W, 0), out(n);

    // 第一步:统计每个值的出现次数
    for (int x : a) cnt[x]++;

    // 第二步:变为前缀和(cnt[i] = 最终 <= i 的元素个数)
    for (int i = 1; i < W; i++) cnt[i] += cnt[i-1];

    // 第三步:从后向前放置(保证稳定性!)
    for (int i = n - 1; i >= 0; i--) {
        out[--cnt[a[i]]] = a[i];
    }
    a = out;
}

// 对键范围已知的使用示例:
// countingSort(arr, 100);  // 所有元素在 [0, 99]

💡 竞赛中的典型应用: 数组元素范围 ≤ 10^6 时比 std::sort 快;基数排序的子程序。


📦 基数排序 —— O(N × d)

适用场景: 整数排序,位数 d 固定(如 32 位整数 d=32/基数,十进制 d=10)。

核心思想: 对每一位(从最低位到最高位)进行一次计数排序。

📄 C++ 完整代码
// 基数排序(以 10 为基数的十进制版本)
// O(N * d),d 为最大数的十进制位数
void radixSort(vector<int>& a) {
    int maxVal = *max_element(a.begin(), a.end());
    int n = a.size();

    // 从最低位(个位)到最高位逐位排序
    for (int exp = 1; maxVal / exp > 0; exp *= 10) {
        vector<int> cnt(10, 0), out(n);

        // 统计当前位的频率
        for (int i = 0; i < n; i++)
            cnt[(a[i] / exp) % 10]++;

        // 前缀和
        for (int i = 1; i < 10; i++)
            cnt[i] += cnt[i-1];

        // 从后向前放置(稳定性保证)
        for (int i = n-1; i >= 0; i--) {
            int digit = (a[i] / exp) % 10;
            out[--cnt[digit]] = a[i];
        }
        a = out;
    }
}

追踪示例(对 [170, 45, 75, 90, 802, 24, 2, 66] 排序):

初始:[170, 45, 75, 90, 802, 24, 2, 66]

按个位排序:[170, 90, 802, 2, 24, 45, 75, 66]
按十位排序:[802, 2, 24, 45, 66, 170, 75, 90]
按百位排序:[2, 24, 45, 66, 75, 90, 170, 802]  ← 有序!

📊 排序算法完整对比

算法平均时间最坏时间空间稳定适用场景
冒泡排序O(N²)O(N²)O(1)教学用,不实用
插入排序O(N²)O(N²)O(1)小数组或近似有序
归并排序O(N log N)O(N log N)O(N)需要稳定排序
快速排序O(N log N)O(N²)O(log N)通用高效(std::sort)
堆排序O(N log N)O(N log N)O(1)内存受限场景
计数排序O(N+W)O(N+W)O(W)整数,值域有限
基数排序O(N×d)O(N×d)O(N+k)固定长度整数/字符串

如何选择?

  • 通用场景 → std::sort(底层是快速排序/堆排序混合)
  • 需要稳定性 → std::stable_sort(底层是归并排序)
  • 整数,值域 ≤ 10^6 → 计数排序
  • 大量整数,值域很大但位数固定 → 基数排序

3.3.3 std::sort 实战

⚠️ 稳定性说明: std::sort 不稳定——它使用 Introsort(快速排序 + 堆排序 + 插入排序的混合),不保留相等元素的相对顺序。如需稳定排序,改用 std::stable_sort

📄 C++ 完整代码
#include <bits/stdc++.h>
using namespace std;

int main() {
    ios_base::sync_with_stdio(false);
    cin.tie(NULL);

    int n;
    cin >> n;
    vector<int> v(n);
    for (int &x : v) cin >> x;

    // 升序排序
    sort(v.begin(), v.end());

    // 降序排序
    sort(v.begin(), v.end(), greater<int>());

    // 只排序向量的一部分(下标 2 到 5,含)
    sort(v.begin() + 2, v.begin() + 6);

    for (int x : v) cout << x << " ";
    cout << "\n";

    return 0;
}

按多个条件排序

经常需要先按一个字段排序,相同时用另一个字段打平。用 pair 时这是自动的(先按 .first,再按 .second):

vector<pair<int, string>> students;
students.push_back({85, "Alice"});
students.push_back({92, "Bob"});
students.push_back({85, "Charlie"});

sort(students.begin(), students.end());
// 结果:{85, "Alice"}, {85, "Charlie"}, {92, "Bob"}
// 先按成绩排,成绩相同时按姓名字典序

自定义比较器

比较器是一个函数,当第一个参数应该排在第二个参数前面时返回 true

最清晰的写法是独立函数:

📄 最清晰的写法是独立函数:
struct Cow {
    string name;
    int weight;
    int height;
};

// 按体重升序;体重相同时按身高降序
bool cmpCow(const Cow &a, const Cow &b) {
    if (a.weight != b.weight) return a.weight < b.weight;  // 较轻的优先
    return a.height > b.height;                             // 打平:较高的优先
}

int main() {
    vector<Cow> cows = {{"Bessie", 500, 140}, {"Elsie", 480, 135}, {"Moo", 500, 138}};

    sort(cows.begin(), cows.end(), cmpCow);

    for (auto &c : cows) {
        cout << c.name << " " << c.weight << " " << c.height << "\n";
    }
    // 输出:
    // Elsie 480 135
    // Bessie 500 140
    // Moo 500 138
    return 0;
}

💡 风格说明:cmp 定义为独立函数(而非内联 lambda)让排序逻辑更易读、测试和复用——特别是涉及多个字段时。

排序算法稳定性

⚠️ 重要: std::sort 不稳定——相等元素排序后可能以任意顺序出现。如需保留相等元素的相对顺序,用 std::stable_sort

排序算法稳定性对比

算法时间复杂度空间复杂度稳定?C++ 函数
std::sortO(N log N)O(log N)sort()
std::stable_sortO(N log² N)*O(N)stable_sort()
std::partial_sortO(N log K)O(1)partial_sort()
计数排序O(N+K)O(K)手写
基数排序O(d(N+K))O(N+K)手写

📝 说明: std::sort 使用 Introsort(快速排序 + 堆排序 + 插入排序的混合)。由于快速排序不稳定,std::sort 不保证相等元素的相对顺序。当你按成绩对学生排序时,若需要相同成绩的学生保持原来的顺序,使用 std::stable_sort

* std::stable_sort 在有足够额外内存(O(N))时是 O(N log N),只有在内存受限需要原地归并时才退化到 O(N log² N)

图示:排序算法对比

Sorting Algorithm Comparison

这张图对比了常见排序算法的时间复杂度、空间占用和稳定性,帮助你在不同场景选择合适的算法。

计数排序 —— 小值域的 O(N+K)

当值是小范围 [0, MAXVAL] 内的有界整数时,计数排序远优于 std::sort

// 计数排序:对范围 [0, MAXVAL] 内的整数
// 时间 O(N+MAXVAL),稳定排序
void countingSort(vector<int>& arr, int maxVal) {
    vector<int> cnt(maxVal + 1, 0);
    for (int x : arr) cnt[x]++;
    int idx = 0;
    for (int v = 0; v <= maxVal; v++)
        for (int i = 0; i < cnt[v]; i++) arr[idx++] = v;
}
// USACO 使用场景:值域小时(如奶牛 ID 1-1000)比 std::sort 更快

什么时候在 USACO 用计数排序:

  • 奶牛 ID 在 [1, 1000] 范围内,N = 10^6 → 计数排序是 O(N + 1000) vs O(N log N)
  • 成绩值 [0, 100] → 极快
  • 颜色类别 [0, 3] → 瞬间完成

注意: 若 MAXVAL 很大(如 10^9),计数排序需要 O(MAXVAL) 内存——不要用。先做坐标压缩(3.3.6 节),再计数。


3.3.4 二分查找

二分查找在已排序数组中以 O(log n) 查找目标——而线性搜索是 O(n)

类比: 在字典里查词。你不会从 A 开始逐条读——你翻到中间,判断你的词在前面还是后面,然后重复。每步将搜索空间减半:k 步后,从 N 个候选缩减到 N/2^k。当 N/2^k < 1 时结束——需要 k = log₂(N) 步。

💡 核心思路: 只要有单调谓词——一个「假假假…真真真」(或反过来)的条件,就可以用二分查找。你可以在 O(log N) 时间内二分找到假和真之间的边界

图示:二分查找示例

Binary Search

上图展示在 [1,3,5,7,9,11,13] 中单步二分查找 7。左(L)、右(R)和中(M)指针清晰可见。核心技巧:用 mid = left + (right - left) / 2 计算中间位置,避免 (left + right) / 2 的整数溢出。

手写二分查找

📄 查看代码:手写二分查找
// 二分查找 — O(log N)
#include <bits/stdc++.h>
using namespace std;

// 在有序 arr 中返回 target 的下标,未找到返回 -1
int binarySearch(const vector<int> &arr, int target) {
    int lo = 0, hi = (int)arr.size() - 1;

    while (lo <= hi) {
        int mid = lo + (hi - lo) / 2;  // ← 关键行:避免溢出(不要用 (lo+hi)/2)

        if (arr[mid] == target) {
            return mid;         // 找到!
        } else if (arr[mid] < target) {
            lo = mid + 1;       // 目标在右半部分
        } else {
            hi = mid - 1;       // 目标在左半部分
        }
    }

    return -1;  // 未找到
}

int main() {
    vector<int> v = {1, 3, 5, 7, 9, 11, 13, 15};
    cout << binarySearch(v, 7) << "\n";   // 3(下标)
    cout << binarySearch(v, 6) << "\n";   // -1(未找到)
    return 0;
}

[1, 3, 5, 7, 9, 11, 13, 15] 中搜索 7 的逐步追踪:

lo=0, hi=7:mid=3,arr[3]=7 → 找到,下标 3 ✓

搜索 6:
lo=0, hi=7:mid=3,arr[3]=7 > 6 → hi=2
lo=0, hi=2:mid=1,arr[1]=3 < 6 → lo=2
lo=2, hi=2:mid=2,arr[2]=5 < 6 → lo=3
lo=3 > hi=2:循环结束 → 返回 -1 ✓

为什么用 lo + (hi - lo) / 2 如果 lohi 都很大(接近 INT_MAX),lo + hi 会溢出!这个写法等价但安全。

STL 方式:lower_boundupper_bound

竞赛编程中你实际上几乎总是想用这两个:

📄 竞赛编程中你实际上几乎总是想用这两个:
// STL 二分操作 — 全部 O(log N)
#include <bits/stdc++.h>
using namespace std;

int main() {
    vector<int> v = {1, 3, 3, 5, 7, 9, 9, 11};

    // lower_bound:第一个 >= target 的迭代器
    auto lb = lower_bound(v.begin(), v.end(), 3);
    cout << *lb << "\n";                    // 3(第一个 3)
    cout << lb - v.begin() << "\n";         // 1(下标)

    // upper_bound:第一个 > target 的迭代器
    auto ub = upper_bound(v.begin(), v.end(), 3);
    cout << *ub << "\n";                    // 5(所有 3 后面的第一个元素)
    cout << ub - v.begin() << "\n";         // 3(下标)

    // 统计出现次数:upper_bound - lower_bound
    int count_of_3 = upper_bound(v.begin(), v.end(), 3)
                   - lower_bound(v.begin(), v.end(), 3);
    cout << count_of_3 << "\n";   // 2

    // 检查是否存在
    bool exists = binary_search(v.begin(), v.end(), 7);
    cout << exists << "\n";  // 1

    // 找 <= target 的最大值(向下取整)
    auto it = upper_bound(v.begin(), v.end(), 6);
    if (it != v.begin()) {
        --it;
        cout << *it << "\n";  // 5(<= 6 的最大值)
    }

    return 0;
}

⚠️ 常见错误:未排序的容器上用 lower_bound/upper_bound。这些函数假设已排序——对未排序的数据会给出错误结果,没有任何报错!


3.3.5 二分答案

这是 USACO Silver 中最强大、最常考的技术之一。核心思想:

不是在数组中搜索某个值,而是在答案空间本身进行二分查找。

什么情况下适用? 当:

  1. 答案是某个范围 [lo, hi] 内的数字
  2. 有一个 canAchieve(X) 函数检查 X 是否可行
  3. 该函数单调:若 X 可行,所有 ≤ X 的值也可行(或所有 ≥ X 的值可行)

💡 核心思路: 单调性意味着存在一个「阈值」将可行与不可行答案分开。二分查找以 O(log(hi-lo))canAchieve 调用找到这个阈值。若每次调用需 O(f(N)),总时间为 O(f(N) × log(答案范围))

经典示例:攻击性奶牛(SPOJ AGGRCOW / 经典题)

题目: N 个马厩位于位置 p[1..N],放置 C 头奶牛以最大化任意两头奶牛间的最小距离

为什么用二分答案? 若能以最小间距 D 放置奶牛,也能以 D-1 的间距放置。所以可行性是单调的:存在阈值 D*,≥ D* 不可行,< D* 可行。二分查找 D*。

canPlace(minDist) 函数: 在最左侧的马厩放第一头奶牛,然后贪心地选择下一个距离至少为 minDist 的马厩。统计能放多少头奶牛——若 ≥ C,返回 true。

📄 C++ 完整代码
// 二分答案 — O(N log N log(最大距离))
#include <bits/stdc++.h>
using namespace std;

int n, c;
vector<int> stalls;

// 能否放置 c 头奶牛使任意两头间距 >= minDist?
bool canPlace(int minDist) {
    int placed = 1;           // 第一头放在马厩 0
    int lastPos = stalls[0];  // 最后放置的奶牛的位置

    for (int i = 1; i < n; i++) {
        if (stalls[i] - lastPos >= minDist) {  // 这个马厩足够远
            placed++;
            lastPos = stalls[i];
        }
    }
    return placed >= c;  // 放了 c 头奶牛吗?
}

int main() {
    ios_base::sync_with_stdio(false);
    cin.tie(NULL);

    cin >> n >> c;
    stalls.resize(n);
    for (int &x : stalls) cin >> x;
    sort(stalls.begin(), stalls.end());  // 必须先排序!

    // 二分答案:最大可能的最小距离是多少?
    int lo = 1, hi = stalls.back() - stalls.front();
    int answer = 0;

    while (lo <= hi) {
        int mid = lo + (hi - lo) / 2;
        if (canPlace(mid)) {
            answer = mid;    // mid 可行,尝试更大的
            lo = mid + 1;
        } else {
            hi = mid - 1;    // mid 不可行,尝试更小的
        }
    }

    cout << answer << "\n";
    return 0;
}

对 stalls = [1, 2, 4, 8, 9], C = 3 的追踪:

📄 Code 完整代码
排序后:[1, 2, 4, 8, 9]
lo=1, hi=8

mid=4:canPlace(4)?
  在 1 放奶牛。下一个 ≥ 1+4=5 的:8,放在 8。
  下一个 ≥ 8+4=12:没有。总共放了 2 头 < 3。返回 false。
  → hi = 3

mid=2:canPlace(2)?
  在 1 放。下一个 ≥ 3:4,放在 4。
  下一个 ≥ 6:8,放在 8。总共 3 头 ≥ 3。返回 true。
  → answer=2, lo=3

mid=3:canPlace(3)?
  在 1 放。下一个 ≥ 4:4,放在 4。
  下一个 ≥ 7:8,放在 8。总共 3 头 ≥ 3。返回 true。
  → answer=3, lo=4

lo=4 > hi=3:结束。答案 = 3

另一个经典:最少时间完成任务(绳子切割)

题目: 给定 N 根长度为 L[i] 的绳子,切出 K 根等长的绳子。每段能切到的最大长度是多少?

📝 代码片段: 以下是代码片段——完整程序结构参考上面的攻击性奶牛示例。

📄 C++ 完整代码
// 代码片段 — 完整程序请参考攻击性奶牛示例
// 能否从绳子中切出 K 段长度 >= len 的?
bool canCut(vector<int> &ropes, long long len, int K) {
    long long count = 0;
    for (int r : ropes) count += r / len;  // 每根绳子能切出的段数
    return count >= K;
}

// 二分:最大化 len 使 canCut(len) 为真
long long lo = 1, hi = *max_element(ropes.begin(), ropes.end());
long long answer = 0;
while (lo <= hi) {
    long long mid = lo + (hi - lo) / 2;
    if (canCut(ropes, mid, K)) {
        answer = mid;
        lo = mid + 1;
    } else {
        hi = mid - 1;
    }
}

二分答案通用模板:

📄 C++ 完整代码
// 通用模板 — 根据题目调整 lo、hi 和 check()
long long lo = 最小可能答案;
long long hi = 最大可能答案;
long long answer = lo;  // 若无有效答案则为 -1

while (lo <= hi) {
    long long mid = lo + (hi - lo) / 2;
    if (check(mid)) {       // mid 可行
        answer = mid;       // 保存
        lo = mid + 1;       // 尝试更好的(取决于题目是更大还是更小)
    } else {
        hi = mid - 1;       // mid 不可行,降低
    }
}

🏆 USACO 技巧: 每当 USACO 题目问「满足某条件的最大 X 是多少」或「满足某条件的最小 X 是多少」,就考虑二分答案。这个技术频繁解决 USACO Silver 题目。


3.3.6 坐标压缩

有时值很大(最多 10^9),但不同的值不多。坐标压缩将它们映射到小下标(0, 1, 2, ...)。

📄 有时值很大(最多 10^9),但不同的值不多。**坐标压缩**将它们映射到小下标(0, 1, 2, ...)。
// 坐标压缩 — O(N log N)
#include <bits/stdc++.h>
using namespace std;

int main() {
    vector<int> A = {100, 500, 200, 100, 700, 200};

    // 第一步:获取有序唯一值
    vector<int> sorted_unique = A;
    sort(sorted_unique.begin(), sorted_unique.end());
    sorted_unique.erase(unique(sorted_unique.begin(), sorted_unique.end()),
                        sorted_unique.end());
    // sorted_unique = {100, 200, 500, 700}

    // 第二步:将每个原始值映射到压缩后的下标
    vector<int> compressed(A.size());
    for (int i = 0; i < (int)A.size(); i++) {
        compressed[i] = lower_bound(sorted_unique.begin(), sorted_unique.end(), A[i])
                        - sorted_unique.begin();
        // 100→0, 200→1, 500→2, 700→3
    }

    for (int x : compressed) cout << x << " ";
    cout << "\n";  // 0 2 1 0 3 1

    return 0;
}

3.3.7 进阶二分答案——三个示例

示例一:最少时间完成任务(参数搜索)

题目: N 个工人,M 个任务各有工作量 effort[i]。将连续的任务分配给工人(每个工人得到连续的任务),最小化任意工人的最大花费时间(最小化瓶颈)。

这是「画家分区」问题。对答案(最大时间 T)二分,检查 T 是否可行。

📝 模板切换说明: 这里用 while (lo < hi)hi = mid——与 3.3.5 节的 while (lo <= hi) 不同。我们切换是因为我们在最小化答案:canFinish(mid) 为真时,mid 本身是候选,所以设 hi = mid(而非 hi = mid - 1)避免错过它。循环结束时 lo == hi 就是答案,不需要单独的 answer 变量。详见 FAQ Q2。

📄 C++ 完整代码
// 检查:能否将任务分配给 K 个工人,使每人工作量 <= T?
bool canFinish(vector<int>& tasks, int K, long long T) {
    int workers = 1;
    long long current = 0;
    for (int t : tasks) {
        if (t > T) return false;  // 单个任务超过 T——不可能
        if (current + t > T) {
            workers++;             // 开始新工人
            current = t;
            if (workers > K) return false;
        } else {
            current += t;
        }
    }
    return true;
}

// 对 T 二分 — 用「lo < hi」模板(最小化答案)
long long lo = *max_element(tasks.begin(), tasks.end());  // T 的最小可能值
long long hi = accumulate(tasks.begin(), tasks.end(), 0LL);  // T 的最大值(1 个工人)

while (lo < hi) {
    long long mid = lo + (hi - lo) / 2;
    if (canFinish(tasks, K, mid)) hi = mid;  // mid 可行,尝试更小
    else lo = mid + 1;                        // mid 不可行,需要更大
}
cout << lo << "\n";  // 最小可能最大时间(循环结束时 lo == hi)

📝 注意: 这里二分查找最小可行 T,所以可行时用 hi = mid(不是 answer = mid; lo = mid+1)。两种模板是镜像关系。

示例二:乘法表中的第 K 小

题目: N×M 乘法表,找第 K 小的值。

表中的值是 1≤i≤N、1≤j≤M 的 i*j。对答案 X 二分:统计 ≤ X 的值有多少个。

📄 表中的值是 1≤i≤N、1≤j≤M 的 i*j。对答案 X 二分:统计 ≤ X 的值有多少个。
// 统计 N×M 乘法表中 <= X 的值的个数
long long countLE(long long X, int N, int M) {
    long long count = 0;
    for (int i = 1; i <= N; i++) {
        count += min((long long)M, X / i);
        // 第 i 行的值是 i, 2i, ..., Mi
        // 第 i 行中 <= X 的个数:min(M, floor(X/i))
    }
    return count;
}

// 二分查找第 K 小
long long lo = 1, hi = (long long)N * M;
while (lo < hi) {
    long long mid = lo + (hi - lo) / 2;
    if (countLE(mid, N, M) >= K) hi = mid;
    else lo = mid + 1;
}
cout << lo << "\n";

复杂度: O(N log(NM)) —— 每次检查 O(N),共 O(log(NM)) 次迭代。

示例三:USACO 风格电缆长度(受 Agri-Net 启发)

题目: 给定 N 个农场位置,用电缆连接所有农场,电缆长度不超过 L。找能形成生成树的最大 L(所有边 ≤ L)。

// 对最大电缆长度 L 二分
// 检查:只用 <= L 的边能否形成生成树?
// (等价于:限制到 <= L 的边后图是否连通?)
bool canConnect(vector<tuple<int,int,int>>& edges, int n, int L) {
    DSU dsu(n);
    for (auto [w, u, v] : edges) {
        if (w <= L) dsu.unite(u, v);
    }
    return dsu.components == 1;  // 所有节点都连通
}

3.3.8 lower_bound / upper_bound 完整速查表

📄 查看代码:3.3.8 lower_bound / upper_bound 完整速查表
// 注意:以下代码假设已定义:#define all(v) (v).begin(), (v).end()
vector<int> v = {1, 3, 3, 5, 7, 9, 9, 11};
//               0  1  2  3  4  5  6   7

// ── lower_bound:第一个 >= x 的位置 ──
lower_bound(all(v), 3)  → 下标 1  (第一个 3)
lower_bound(all(v), 4)  → 下标 3  (第一个 >= 4 的元素,即 5)
lower_bound(all(v), 12) → 下标 8  (越界:没有 >= 12 的元素)

// ── upper_bound:第一个 > x 的位置 ──
upper_bound(all(v), 3)  → 下标 3  (所有 3 之后的第一个元素)
upper_bound(all(v), 4)  → 下标 3  (与上同:没有 4)
upper_bound(all(v), 11) → 下标 8  (越界)

// ── 派生操作 ──
// 统计 x 的出现次数:
int cnt = upper_bound(all(v), 3) - lower_bound(all(v), 3);  // cnt = 2

// x 是否存在?
binary_search(all(v), x)  // O(log N),返回 bool

// <= x 的最大值(向下取整):
auto it = upper_bound(all(v), x);
if (it != v.begin()) cout << *prev(it);

// >= x 的最小值(向上取整):
auto it = lower_bound(all(v), x);
if (it != v.end()) cout << *it;

// < x 的最大值(严格向下取整):
auto it = lower_bound(all(v), x);
if (it != v.begin()) cout << *prev(it);

// < x 的元素个数:
lower_bound(all(v), x) - v.begin()

// <= x 的元素个数:
upper_bound(all(v), x) - v.begin()

// [a, b] 范围内的元素个数:
upper_bound(all(v), b) - lower_bound(all(v), a)
目标代码说明
第一个 >= x 的下标lower_bound(v.begin(), v.end(), x) - v.begin()若全都 < x 则等于 v.size()
第一个 > x 的下标upper_bound(v.begin(), v.end(), x) - v.begin()
x 的出现次数upper_bound(...,x) - lower_bound(...,x)
<= x 的最大值*prev(upper_bound(...,x))检查迭代器 ≠ begin
>= x 的最小值*lower_bound(...,x)检查迭代器 ≠ end
x 是否存在?binary_search(...)返回 bool

3.3.9 自定义谓词二分查找

对于非标准有序结构或自定义条件:

📄 对于非标准有序结构或自定义条件:
// 自定义谓词二分查找
// 在 [lo, hi] 范围内找第一个 pred(i) 为真的下标
// 假设:pred 单调,即 false...false, true...true

int lo = 0, hi = n - 1, answer = -1;
while (lo <= hi) {
    int mid = lo + (hi - lo) / 2;
    if (/* mid 上的某个条件 */) {
        answer = mid;
        hi = mid - 1;  // 找更小的下标
    } else {
        lo = mid + 1;
    }
}

// 示例:第一个 arr[i] - arr[0] >= D 的下标
// (对有序数组,arr[i] - arr[0] 单调非递减,
//  所以这个谓词是单调的:假...假,真...真)
// ⚠️ 关键要求:谓词必须单调,二分查找才有效!
{
    int lo = 0, hi = n - 1, firstFar = -1;
    while (lo <= hi) {
        int mid = lo + (hi - lo) / 2;
        if (arr[mid] - arr[0] >= D) {
            firstFar = mid;
            hi = mid - 1;
        } else {
            lo = mid + 1;
        }
    }
}

// 浮点数二分查找(基于精度)
double lo_f = 0.0, hi_f = 1e9;
for (int iter = 0; iter < 100; iter++) {  // 100 次迭代 → 误差 < 1e-30
    double mid = (lo_f + hi_f) / 2;
    if (check(mid)) hi_f = mid;
    else lo_f = mid;
}
// 答案:lo_f(或 hi_f,两者收敛到相同值)

🏆 USACO 专业技巧: 「二分答案」是最常见的 Silver 技术之一。当你看到「在约束条件下最大化/最小化 X」时,问自己:可行性函数是单调的吗? 如果是,就用二分查找。


3.3.10 三分查找——求单峰函数的极值 🔮 进阶 / Gold+

⚠️ 范围说明: 三分查找很少在 USACO Silver 中出现,偶尔出现在涉及几何优化或参数搜索的 Gold/Platinum 题目中。把本节当作补充知识——理解概念,但不要把它放在掌握二分查找之前。

二分查找需要单调谓词(假→真的边界)。对于单峰函数(先增后减),用三分查找找极大值。

💡 使用场景: 函数 f[lo, hi] 上单峰,即先严格递增后严格递减(或一直单方向)。三分查找以 O(log((hi-lo)/eps)) 次求值找到极大值点。

USACO 出现情况: 三分查找在 Silver 级别极少出现。在 Gold/Platinum 级别偶尔出现在涉及几何优化(如「找直线上的最优点以最小化到各点的距离之和」)或对连续单峰函数做参数搜索的题目中。

📄 C++ 完整代码
// 三分查找:求单峰函数 f 在 [lo, hi] 上的极大值
// 前提:f 先增后减(单峰)
// 时间:连续情况 O(log((hi-lo)/eps)),整数情况 O(log N)

// f 必须在调用前声明/定义
double ternarySearch(double lo, double hi) {
    for (int iter = 0; iter < 200; iter++) {
        double m1 = lo + (hi - lo) / 3;
        double m2 = hi - (hi - lo) / 3;
        if (f(m1) < f(m2)) lo = m1;  // 极大值在 [m1, hi] 中
        else hi = m2;                 // 极大值在 [lo, m2] 中
    }
    return (lo + hi) / 2;  // 极大值点(收敛后 lo ≈ hi)
}

// 整数三分查找(f 定义在整数上时):
int ternarySearchInt(int lo, int hi) {
    // 用 > 2 而非 >= 2:保留至少 3 个候选值再暴力枚举。
    // 范围缩至 2 时,m1 == m2(因为 (hi-lo)/3 == 0),会死循环。
    while (hi - lo > 2) {
        int m1 = lo + (hi - lo) / 3;
        int m2 = hi - (hi - lo) / 3;
        if (f(m1) < f(m2)) lo = m1 + 1;
        else hi = m2 - 1;
    }
    // 检查剩余候选值 [lo, hi](最多 3 个元素)
    int best = lo;
    for (int x = lo + 1; x <= hi; x++)
        if (f(x) > f(best)) best = x;
    return best;
}

与二分查找的对比:

二分查找三分查找
要求单调谓词单峰函数
寻找边界(假→真)极值(极大/极小)
每步消除一半范围三分之一范围
达到 ε 精度的迭代次数log₂(range/ε)log₃/₂(range/ε) ≈ 多 2.4 倍

⚠️ 注意: 整数三分查找需要小心——用 while (hi - lo > 2) 避免范围缩到 2 或 3 个元素时的死循环,然后暴力检查剩余候选。


⚠️ 第 3.3 章常见错误

  1. 比较器方向错误: lambda 必须在 a 应该排在 b 前面时返回 true。若 a == b 时返回 true,会导致未定义行为(严格弱序违规)。
  2. 在未排序数组上二分查找: lower_boundupper_bound 假设已排序。对未排序数据,结果毫无意义。
  3. 二分查找差一错误: lo <= hilo < hi 有区别。拿不准时,在 1 个和 2 个元素的数组上测试你的二分查找。
  4. 「二分答案」中答案范围错误: 若答案可能是 0,设 lo = 0 而非 lo = 1。若可能很大,确保 hi 足够大(必要时用 long long)。
  5. 中间值计算整数溢出: 始终写 mid = lo + (hi - lo) / 2,绝不用 (lo + hi) / 2

本章总结

📌 核心要点

操作方法时间复杂度备注
升序排序sort(v.begin(), v.end())O(N log N)使用 IntroSort
降序排序sort(..., greater<int>())O(N log N)
自定义排序lambda 比较器O(N log N)必须是严格弱序
查找确切值binary_searchO(log N)返回 bool
第一个 >= x 的下标lower_boundO(log N)返回迭代器
第一个 > x 的下标upper_boundO(log N)返回迭代器
统计值 x 的个数ub - lbO(log N)
二分答案手写 BS + check()O(f(N) log V)V = 答案范围
坐标压缩sort + unique + lower_boundO(N log N)将大值映射到小下标

🧩 二分查找模板速查

场景循环条件lo/hi 初值更新规则答案参考小节
最大化满足条件的值while (lo <= hi)lo=最小,hi=最大check(mid) → ans=mid, lo=mid+1ans§3.3.5
最小化满足条件的值while (lo < hi)lo=最小,hi=最大check(mid) → hi=midlo(循环结束时)§3.3.7
浮点数二分查找循环 100 次lo=最小,hi=最大check(mid) → hi=mid 否则 lo=midlo ≈ hi§3.3.9

❓ 常见问题

Q1:sort 的时间复杂度是 O(N log N) 还是 O(N²)

A:C++ 的 std::sort 使用 Introsort(快速排序 + 堆排序 + 插入排序的混合),保证最坏 O(N log N)。不需要担心退化到 O(N²)。但注意:若自定义比较器不满足严格弱序,行为未定义(可能死循环或崩溃)。

Q2:二分查找中 lo <= hilo < hi 有什么区别?

A:两种风格对应不同模板:

  • while (lo <= hi):循环结束时 lo > hi,答案存在 answer 变量中。适合「查找目标值」或「最大化满足条件的值」。
  • while (lo < hi):循环结束时 lo == hi,答案就是 lo。适合「最小化满足条件的值」。 两种都能解决所有问题,关键是搭配正确的更新规则。新手选一种坚持用。

Q3:「二分答案」适用于什么题目?怎么识别?

A:三个信号:① 题目问「满足……条件的最大/最小 X」;② 存在一个决策函数 check(X) 能在多项式时间内判断可行性;③ 决策函数单调(X 可行 → X-1 也可行,或反过来)。三者都满足,就能用二分答案。

Q4:坐标压缩实际上有什么用?

A:当值域很大(如 10^9)但不同的值很少(如 10^5)时,坐标压缩将大值映射到小下标 0~N-1。这让你可以用数组而不是 map(更快),或对值域做前缀和/BIT 操作。USACO Silver 中频繁需要。

Q5:为什么 sort 的比较器不能用 <=

A:C++ 排序要求比较器满足严格弱序:当 a == b 时,comp(a,b) 必须返回 false。<= 在 a==b 时返回 true,违反了这条规则。结果是未定义行为——可能死循环、崩溃或产生错误排序。

🔗 与后续章节的联系

  • 第 3.4 章(双指针):双指针技术经常在排序后使用——先排序 O(N log N),再双指针 O(N)
  • 第 3.2 章(前缀和):前缀和数组本身有序,可以对其二分查找(如找第一个前缀和 >= 目标的位置)
  • 第 4.1 章和 5.4 章(贪心 + 最短路):Dijkstra 内部使用优先队列 + 贪心策略,与排序有根本关联
  • 第 6.2 章(DP):最长递增子序列(LIS)可以用二分查找优化到 O(N log N)
  • **「二分答案」**是 USACO Silver 最核心的技术之一,在第 4.1 章(贪心)中也常常结合使用

练习题

题目 3.3.1 — 最近点对 🟢 简单 读取 N 个整数,找差值最小的一对。

提示 排序数组,最近的一对排序后一定相邻。
✅ 完整题解

核心思路: 排序后,相似的值相邻。最近的一对总是排序后的相邻元素。

#include <bits/stdc++.h>
using namespace std;
int main() {
    int n; cin >> n;
    vector<int> a(n); for (int& x : a) cin >> x;
    sort(a.begin(), a.end());
    int best = INT_MAX;
    for (int i = 1; i < n; i++)
        best = min(best, a[i] - a[i-1]);
    cout << best << "\n";
}

为什么是相邻的? 若 |a[i] - a[j]| 最小且 j > i+1,则 a[i+1] 在它们之间,所以 a[i+1]-a[i] ≤ a[j]-a[i],矛盾。

复杂度: O(N log N)。


题目 3.3.2 — 房间分配 🟡 中等 N 个事件,各有开始/结束时间,求任意时刻最多有多少个事件重叠。

提示 创建事件:开始时 +1,结束时 -1,按时间排序,扫描追踪最大计数。
✅ 完整题解

核心思路: 扫描线。每个开始 +1,每个结束 -1,运行和 = 当前重叠数。

#include <bits/stdc++.h>
using namespace std;
int main() {
    int n; cin >> n;
    vector<pair<int,int>> evs;
    for (int i = 0; i < n; i++) {
        int s, e; cin >> s >> e;
        evs.push_back({s, +1});
        evs.push_back({e, -1});
    }
    // 相同时间时先处理结束(delta=-1),「相切」的区间不计为重叠
    sort(evs.begin(), evs.end(),
         [](auto& a, auto& b){ return a.first != b.first ? a.first < b.first : a.second < b.second; });
    int cur = 0, best = 0;
    for (auto& [t, d] : evs) { cur += d; best = max(best, cur); }
    cout << best << "\n";
}

对区间 [(1,4), (2,6), (3,5)] 的追踪:

事件:(1,+1), (2,+1), (3,+1), (4,-1), (5,-1), (6,-1)
扫描:  1       2       3       2       1       0
最大:3(时刻 3 时三个区间同时活跃)

复杂度: O(N log N)。


题目 3.3.3 — 第 K 小 🟡 中等 找数组中第 K 小的元素。

提示 简单方案:排序后直接返回。进阶练习:尝试 nth_element(平均 O(N))或二分答案。
✅ 完整题解(使用 nth_element — O(N) 平均)
#include <bits/stdc++.h>
using namespace std;
int main() {
    int n, k; cin >> n >> k;
    vector<int> a(n); for (int& x : a) cin >> x;
    // nth_element 重新排列,使 a[k-1] 到达正确的有序位置
    nth_element(a.begin(), a.begin() + (k-1), a.end());
    cout << a[k-1] << "\n";
}

替代方案:二分答案

int lo = *min_element(a.begin(), a.end());
int hi = *max_element(a.begin(), a.end());
while (lo < hi) {
    int mid = lo + (hi - lo) / 2;
    int cnt = count_if(a.begin(), a.end(), [&](int x){ return x <= mid; });
    if (cnt >= k) hi = mid;
    else lo = mid + 1;
}
cout << lo << "\n";

复杂度: nth_element 平均 O(N),最坏 O(N²);二分答案 O(N log(最大值))。


题目 3.3.4 — 攻击性奶牛 🔴 困难 N 个马厩位于位置 p[1..N],放 C 头奶牛以最大化最小间距。

提示 对最小距离 D 二分答案,O(N) 贪心检查可行性。
✅ 完整题解

核心思路: 对答案 D 二分。可行性:贪心地从最左侧马厩开始放奶牛,每次跳到距离 ≥ D 的下一个。

#include <bits/stdc++.h>
using namespace std;
int N, C;
vector<int> p;

bool canPlace(int D) {
    int placed = 1, last = p[0];
    for (int i = 1; i < N; i++) {
        if (p[i] - last >= D) { placed++; last = p[i]; }
        if (placed >= C) return true;
    }
    return false;
}

int main() {
    cin >> N >> C;
    p.resize(N); for (int& x : p) cin >> x;
    sort(p.begin(), p.end());

    int lo = 1, hi = p.back() - p.front(), ans = 0;
    while (lo <= hi) {
        int mid = lo + (hi - lo) / 2;
        if (canPlace(mid)) { ans = mid; lo = mid + 1; }
        else hi = mid - 1;
    }
    cout << ans << "\n";
}

复杂度: O(N log N + N log(最大距离))。


题目 3.3.5 — 画家分区 🔴 困难 N 块画板各有宽度,K 名画家,每人画连续的画板,单位时间画一单位宽度。最小化总绘画时间。

提示 对最大时间 T 二分答案,贪心分配画板检查可行性。
✅ 完整题解

核心思路: 对答案 T 二分。可行性:贪心为每个画家填充画板,超过 T 时换下一个画家,若 ≤ K 个画家就够,T 可行。

#include <bits/stdc++.h>
using namespace std;
int N, K;
vector<long long> W;

bool canFinish(long long T) {
    int painters = 1;
    long long cur = 0;
    for (long long w : W) {
        if (w > T) return false;  // 单块画板超出预算
        if (cur + w > T) { painters++; cur = w; }
        else cur += w;
    }
    return painters <= K;
}

int main() {
    cin >> N >> K;
    W.resize(N); for (long long& x : W) cin >> x;
    long long lo = *max_element(W.begin(), W.end());  // 下界:最宽的画板
    long long hi = accumulate(W.begin(), W.end(), 0LL);  // 上界:总宽度
    while (lo < hi) {
        long long mid = lo + (hi - lo) / 2;
        if (canFinish(mid)) hi = mid;
        else lo = mid + 1;
    }
    cout << lo << "\n";
}

复杂度: O(N log(总宽度))。


⚠️ 排序与搜索常见错误

展开——频繁出现的陷阱

排序陷阱:

  • ❌ 比较器用 > 代替 <(排序需要严格弱序)
  • ❌ 比较器返回 a <= b——违反严格弱序,可能导致未定义行为
  • ❌ 比较器有副作用或随机性——必须是确定性的

二分查找陷阱:

  • mid = (lo + hi) / 2——lo+hi 很大时溢出,用 lo + (hi - lo) / 2
  • ❌ 死循环:未找到目标时 lo = mid(而不是 mid+1
  • ❌ 「第一个/最后一个位置」变体中的边界错误——先画出不变量
  • ❌ 浮点数二分:用精度终止条件 while (hi - lo > 1e-9)

二分答案陷阱:

  • ❌ 检查函数不单调——二分查找无效!验证:若 D 可行,D-1 也可行吗?
  • ❌ 边界太紧(遗漏边界情况):将 lo 设为最小可能答案,hi 设为明确可行的上界

🏆 挑战题:USACO 2016 February Silver:围牛栏 用最少的围栏围住所有 N 个点形成凸区域。这是凸包问题——查阅 Graham 扫描或 Jarvis 步进算法。虽然是 Gold 级别的主题,现在思考它能帮助你建立直觉。