C++ 竞赛编程:USACO 学习指南

🌐 English Version | 📖 当前:中文版

章节数 难度 语言

本书是专为参加 USACO(美国计算机奥林匹克竞赛) 的同学编写的 C++ 竞赛编程完整指南,覆盖从 Bronze 到 Gold 级别所需的全部核心知识。

📚 本书结构

部分内容目标等级
C++ 基础语法、控制流、函数、结构体入门
核心数据结构数组、排序、双指针、哈希、线段树、树状数组Bronze/Silver
贪心算法贪心策略与经典应用Silver
图论算法图的遍历、最短路Silver/Gold
动态规划DP 入门、经典模型、进阶模式Silver/Gold
USACO 竞赛指南竞赛流程、解题策略、Ad Hoc全级别
USACO Gold 专题MST、拓扑排序、树形DP、组合数学Gold

🚀 如何使用本书

  1. 入门学习者 — 从 C++ 基础 开始
  2. 有一定基础 — 直接进入 核心数据结构
  3. 备战 Gold — 重点学习 USACO Gold 专题

💡 学习建议

  • 每章都有配套练习题(🟢 简单 / 🟡 中等 / 🔴 困难 / 🏆 挑战)
  • 练习题附有完整题解,先尝试自己解答再参考
  • 代码示例均为 C++17,注意竞赛中常见的 I/O 优化写法

📝 翻译说明:本书中文版正在持续完善中。如遇内容缺失,可参考 英文原版

⚡ 第二部分:C++ 基础

掌握竞赛编程的 C++ 基石——从第一行 Hello World 到函数与数组。

📚 4 章 · ⏱️ 预计 1-2 周 · 🎯 目标:能编写并编译 C++ 程序

第二部分:C++ 基础

在解决算法题之前,你需要先学会「说这门语言」。第二部分是 C++ 速成课——从第一个程序开始,涵盖函数、数组和向量。你将建立起后续所有章节所需的基础技能。

你将学到什么

章节主题核心技能
第 2.1 章你的第一个 C++ 程序变量、输入输出、编译
第 2.2 章控制流if/else、循环、break/continue
第 2.3 章函数与数组可复用代码、数组、向量
第 2.4 章结构体与类自定义类型、运算符重载、结构体排序

为什么选择 C++?

竞赛选手绝大多数选择 C++,原因有两点:

  1. 速度 —— C++ 程序比 Python 或 Java 运行更快,而在时间限制严格(通常 10^8 次操作只有 1-2 秒)时,速度至关重要
  2. STL —— C++ 标准模板库提供了几乎所有你可能用到的数据结构和算法的现成实现

注意: USACO 接受 C++、Java 和 Python。但在顶级选手中 C++ 是最主流的选择,本书专注于 C++。

第二部分学习建议

  • 亲手敲代码。 不要复制粘贴。你的手需要熟悉语法。
  • 主动制造错误。 故意引入错误,看看会发生什么。读懂编译器报错本身也是一项技能。
  • 运行每一个示例。 亲眼看到输出出现在屏幕上,远比仅仅阅读更能加深理解。

出发!

📖 第 2.1 章 ⏱️ 约 60 分钟 🎯 入门

第 2.1 章:你的第一个 C++ 程序

📝 前置条件: 这是第一章——无需任何前置知识!你不需要任何编程经验。按顺序从头读到尾,完成本章后你将写出第一个真正的 C++ 程序。

欢迎!完成本章后,你将:

  • 搭建好可用的 C++ 开发环境(用在线编译器只需 5 分钟)
  • 编写、编译并运行第一个 C++ 程序
  • 理解代码中每一行的含义
  • 学会变量、数据类型和输入输出
  • 完成 13 道练习题并查看完整题解

2.1.0 搭建开发环境

写代码之前,你需要一个可以编写和运行代码的地方。有两种选择:在线编译器(推荐新手使用——无需安装)和本地开发环境(可选,适合离线工作)。

选项 A:在线编译器(推荐——从这里开始!)

只需一个浏览器,打开以下任意网站:

网站地址说明
Codeforces IDEcodeforces.com免费注册账号,在任意题目页面点击「Submit code」即可打开代码编辑器
Replitreplit.com新建「C++ project」,获得完整编辑器 + 终端
Ideoneideone.com粘贴代码,选 C++17,点「Run」——最简单的选项
OnlineGDBonlinegdb.com内置调试器,功能完善

使用 Ideone(新手最简单):

  1. 打开 ideone.com
  2. 在语言下拉框选择「C++17 (gcc 8.3)」
  3. 将代码粘贴到文本区
  4. 点击绿色「Run」按钮
  5. 在底部面板查看输出

就这么简单!无需安装,无需配置。

选项 B:使用 CLion(推荐本地 IDE)

如果你想在自己的电脑上离线编写和运行 C++ 代码,强烈推荐 CLion ——JetBrains 出品的专业 C/C++ IDE。它拥有智能代码补全、一键编译运行和内置调试器,能大幅提升你的效率。

💡 学生免费! CLion 是付费软件,但 JetBrains 为学生提供免费教育许可。用你的 .edu 邮箱在 JetBrains 学生许可页面 申请即可。

安装步骤:

第一步:安装 C++ 编译器(CLion 需要外部编译器)

操作系统安装方式
Windows安装 MSYS2。安装完成后在 MSYS2 终端运行:pacman -S mingw-w64-x86_64-gcc,然后将 C:\msys64\mingw64\bin 添加到系统 PATH
Mac打开终端运行:xcode-select --install,在弹出的对话框中点击「安装」,等待约 5 分钟
LinuxUbuntu/Debian:sudo apt install g++ cmake;Fedora:sudo dnf install gcc-c++ cmake

第二步:安装 CLion

  1. 前往 CLion 下载页面 下载对应系统的安装包
  2. 运行安装包并按提示完成安装(保持默认选项即可)
  3. 首次启动时选择**「激活」**→ 用 JetBrains 学生账号登录,或开始 30 天免费试用

第三步:创建第一个项目

  1. 打开 CLion,点击**「New Project」**
  2. 选择**「C++ Executable」,将语言标准设为C++17**
  3. 点击**「Create」**——CLion 会自动生成包含 main.cpp 的项目
  4. main.cpp 中编写代码,点击右上角绿色**▶ Run** 按钮即可编译运行
  5. 输出会出现在底部的**「Run」**面板中

🔧 CLion 自动检测编译器: 首次启动时,CLion 会自动扫描已安装的编译器(GCC / Clang / MSVC)。如果检测成功,你会在「Settings → Build → Toolchains」中看到绿色对勾 ✅。若未检测到,请确认第一步的编译器已正确安装并添加到 PATH。

CLion 竞赛编程实用功能:

  • 内置终端:底部的 Terminal 标签可以直接输入测试数据
  • 调试器:设置断点、逐行执行代码、查看变量值——追踪 bug 的必备工具
  • 代码格式化:Ctrl + Alt + L(Mac:Cmd + Option + L)自动整理代码缩进

如何编译和运行(本地)

安装好 g++ 后,编译和运行方法如下:

g++ -o hello hello.cpp -std=c++17

逐字分解这条命令:

部分含义
g++C++ 编译器程序名
-o hello-o 表示「输出文件名」;hello 是我们给程序起的名字
hello.cpp要编译的源文件(我们的 C++ 代码)
-std=c++17使用 C++17 版本(功能最丰富)

运行方法:

./hello        # Linux/Mac:./ 表示「在当前目录」
hello.exe      # Windows(.exe 会自动添加)

🤔 为什么是 ./hello 而不是直接 hello 在 Linux/Mac 上,系统默认不会从当前目录运行程序(出于安全考虑)。./ 明确告诉系统「在当前目录找这个程序」。


2.1.1 Hello, World!

每段编程之旅都从同一个地方开始。下面是最简单的完整 C++ 程序:

#include <iostream>    // 告诉编译器我们要使用输入/输出功能

int main() {           // 每个 C++ 程序都从 main() 开始执行
    std::cout << "Hello, World!" << std::endl;  // 打印到屏幕
    return 0;          // 0 = 成功,程序正常结束
}

运行后你应该看到:

Hello, World!

每一行的含义:

第 1 行:#include <iostream> 这是一条预处理指令——在正式编译之前执行的指令。它的意思是「把 iostream 库的内容复制粘贴到我的程序中」。iostream 库提供了 cin(读取输入)和 cout(打印输出)。没有这行,你的程序就无法输出任何内容。

可以这样理解:做饭之前,你需要先把食材拿进厨房。

第 3 行:int main() 这声明了 main 函数——每个 C++ 程序的起点。运行一个 C++ 程序时,计算机总是从 main() 内部的第一行开始执行。int 表示这个函数返回一个整数(退出码)。每个 C++ 程序必须有且仅有一个 main

第 4 行:std::cout << "Hello, World!" << std::endl; 这行打印文字。拆解来看:

  • std::cout —— 「控制台输出」流(可以理解为屏幕)
  • << —— 「放入」运算符;将数据送入流
  • "Hello, World!" —— 要打印的文字(引号本身不会被打印)
  • << std::endl —— 添加换行(相当于按回车)
  • ; —— C++ 中每条语句都以分号结束

第 5 行:return 0; 退出 main 并告诉操作系统程序成功结束。(非零返回值表示发生了错误。)

编译流程

图示:编译流程

C++ Compilation Pipeline

上图展示了从源代码到可执行文件的三个阶段:你的 .cpp 文件输入给 g++ 编译器,最终生成可运行的二进制文件。理解这个流程有助于在编译错误发生前就预防它们。


2.1.2 竞赛选手的标准模板

解 USACO 题目时,你会用到一套标准模板。下面是完整带注释的版本:

📄 解 USACO 题目时,你会用到一套标准模板。下面是完整带注释的版本:
#include <bits/stdc++.h>      // 「全包含」——一次性包含所有标准库
using namespace std;           // 让我们可以写 cout 而不是 std::cout

int main() {
    ios_base::sync_with_stdio(false);  // 禁用 C 和 C++ I/O 的同步(更快)
    cin.tie(NULL);                      // 解除 cin 和 cout 的绑定(输入更快)

    // 你的解题代码写在这里

    return 0;
}

为什么用 #include <bits/stdc++.h>

这是 GCC 专有的头文件,一次性包含所有标准库。不用再写:

#include <iostream>
#include <vector>
#include <algorithm>
#include <map>
// ... 还有 20 多行

一行搞定。在竞赛编程中,这是普遍做法,能节省时间。

注意: bits/stdc++.h 只在 GCC 编译器(USACO 评测机使用的编译器)下有效。竞赛编程中完全没问题,但不要在生产软件中使用。

为什么用 using namespace std;

标准库把所有内容放在名为 std命名空间中。没有这行,你需要到处写 std::coutstd::vectorstd::sort。有了 using namespace std;,直接写 coutvectorsort——简洁得多。

I/O 加速行

ios_base::sync_with_stdio(false);
cin.tie(NULL);

这两行让 cincout 快得多。没有它们,读取大量输入可能慢 10 倍,即使算法正确也可能导致「超时(TLE)」。每次都要加上它们。

🐛 常见错误: 加了这两行后,不要混用 cin/coutscanf/printf。选一种风格坚持用。


2.1.3 变量与数据类型

变量是内存中一个有名字的存储位置。C++ 中每个变量都有一个类型——类型告诉计算机需要分配多少内存,以及里面会存什么数据。

🧠 思维模型:变量就像带标签的盒子

当你写:   int score = 100;

计算机做了三件事:
  1. 创建一个足以容纳整数的盒子(4 字节)
  2. 在盒子上贴上标签 "score"
  3. 把数字 100 放进盒子

Variable Memory Box

竞赛编程必备类型

📄 查看代码:竞赛编程必备类型
#include <bits/stdc++.h>
using namespace std;

int main() {
    // int:整数,范围:-2,147,483,648 到 +2,147,483,647(约 ±20 亿)
    int apples = 42;
    int temperature = -5;

    // long long:大整数,范围:约 ±9.2 × 10^18
    long long population = 7800000000LL;  // LL 后缀表示「这是 long long 字面量」
    long long trillion = 1000000000000LL;

    // double:小数/分数
    double pi = 3.14159265358979;
    double percentage = 99.5;

    // bool:只有 true 或 false
    bool isRaining = true;
    bool finished = false;

    // char:单个字符(以 0-255 的数字存储)
    char grade = 'A';     // 单引号表示字符
    char newline = '\n';  // 特殊字符:换行符

    // string:字符序列
    string name = "Alice";         // 双引号表示字符串
    string greeting = "Hello!";

    // 打印所有变量:
    cout << "苹果数量: " << apples << "\n";
    cout << "人口: " << population << "\n";
    cout << "圆周率: " << pi << "\n";
    cout << "在下雨: " << isRaining << "\n";  // true 打印 1,false 打印 0
    cout << "成绩: " << grade << "\n";
    cout << "姓名: " << name << "\n";

    return 0;
}

图示:C++ 数据类型参考

C++ Variable Types

如何选择正确的类型

使用场景选用类型
计数、小数字int
可能超过 20 亿的数字long long
小数/分数答案double
是/否标志bool
单个字母或字符char
单词或句子string

变量命名规则

C++ 对变量名有严格规定。掌握这些规则很关键——命名不当会导致 bug,非法名字则无法编译。

形式规则(编译器强制执行)

合法名称必须:

  • 字母(a-z、A-Z)或下划线 _ 开头
  • 只包含字母、数字(0-9)和下划线
  • 不能是 C++ 保留关键字

以下名称无法编译:

非法名称错误原因
3apples以数字开头
my score包含空格
my-score包含连字符(会被解释为减号)
int保留关键字
class保留关键字
return保留关键字

⚠️ 区分大小写! scoreScoreSCORE 是三个完全不同的变量。这是常见 bug 来源——命名时保持一致。

常见命名风格

C++ 中有几种广泛使用的命名规范。竞赛编程中不必只选一种,但了解它们有助于读懂别人的代码:

风格示例通常用于
驼峰式(camelCase)numStudentstotalScore局部变量、函数参数
帕斯卡式(PascalCase)MyClassGraphNode类、结构体、类型名
下划线式(snake_case)num_studentstotal_score变量、函数(C/Python 风格)
全大写(ALL_CAPS)MAX_NMODINF常量、宏
单字母nmij循环下标、数学风格竞赛编程

竞赛编程中最常用驼峰式单字母名称。公司的生产代码中,根据风格指南通常用 snake_casecamelCase

命名最佳实践

1. 有描述性——从名字就能看出用途:

// ✅ 好——一眼就知道每个变量存什么
int numCows = 5;
long long totalMilk = 0;
string cowName = "Bessie";
int maxScore = 100;

// ❌ 差——语法正确但令人困惑
int x = 5;            // x 是什么?计数?下标?值?
long long t = 0;      // t 是什么?时间?总计?临时变量?
string n = "Bessie";  // n 通常表示「数字」——用来存名字很误导!

2. 只在含义显而易见时使用单字母名称:

// ✅ 可接受——这些是公认的惯例
for (int i = 0; i < n; i++) { ... }    // i、j、k 用于循环下标
int n, m;                                // n = 计数,m = 第二维
cin >> n >> m;                           // 竞赛编程中人人都这样写

// ❌ 令人困惑——单字母但没有明确惯例
int q = 5;   // q 是计数?查询数?系数?
char z = 'A'; // 为什么用 z?

3. 常量用全大写,方便识别:

const int MAX_N = 200005;        // 数组最大长度
const int MOD = 1000000007;      // 取模常量
const long long INF = 1e18;      // 用于比较的「无穷大」
const double PI = 3.14159265359; // 数学常数

4. 避免外形相似的名称:

// ❌ 容易混淆
int total1 = 10;
int totall = 20;  // 这是「total-L」还是手误的「total-1」?

int O = 0;        // 字母 O 看起来像数字 0
int l = 1;        // 小写 L 看起来像数字 1

// ✅ 更好的替代
int totalA = 10;
int totalB = 20;

5. 不要用下划线加大写字母开头:

// ❌ 能编译,但被 C++ 标准保留
int _Score = 100;   // _X 形式的名称被编译器/库保留
int __value = 42;   // 双下划线开头始终保留

// ✅ 安全替代
int score = 100;
int myValue = 42;

竞赛编程 vs 生产代码的命名对比

方面竞赛编程生产/学校项目
变量名长度简短即可:nmdpadj有描述性:numStudentsadjacencyList
循环变量永远用 ijkijk 也没问题
常量MAXNMODINFkMaxSizekModulus(Google 风格)
注释极少——速度优先详尽——可读性优先
目标快速编写、快速解题编写别人能维护的代码

💡 本书中: 我们会混用两种风格——讲解时用描述性名称保持清晰,解题时用简短名称。关键原则:看到变量名就应该立刻知道它存的是什么。

深入了解:charstring 与字符-整数转换

我们已经简要介绍了 charstring。由于许多 USACO 题目涉及字符处理、数字提取和字符串操作,让我们深入看看这两种重要类型。


char 与 ASCII——每个字符都是一个数字

C++ 中的 char1 字节整数(0-255)存储。每个字符按照 ASCII 表(美国信息交换标准代码)映射到一个数字。不需要背整张表,但记住几个关键范围非常有用:

ASCII Table Key Ranges

关键关系:
• 'a' - 'A' = 32     (大小写字母差值)
• '0' 的 ASCII 值是 48(不是 0!)
• 数字、大写字母、小写字母各自在连续范围内
📄 C++ 完整代码
#include <bits/stdc++.h>
using namespace std;

int main() {
    char ch = 'A';

    // char 本质上是整数——可以打印其数值
    cout << ch << "\n";        // 打印:A(字符形式)
    cout << (int)ch << "\n";   // 打印:65(ASCII 值)

    // 可以对 char 做算术!
    char next = ch + 1;       // 'A' + 1 = 66 = 'B'
    cout << next << "\n";     // 打印:B

    // 比较 char(比较 ASCII 值)
    cout << ('a' < 'z') << "\n";   // 1(真,因为 97 < 122)
    cout << ('A' < 'a') << "\n";   // 1(真,因为 65 < 97)

    return 0;
}

charint 转换——最常用的技巧

竞赛编程中,你需要频繁地在字符数字和整数值之间转换。以下是完整指南:

1. 数字字符 → 整数值(例如 '7'7

char ch = '7';
int digit = ch - '0';    // '7' - '0' = 55 - 48 = 7
cout << digit << "\n";   // 打印:7

// 这行得通是因为数字字符 '0'~'9' 的 ASCII 值是连续的:
// '0'=48, '1'=49, ..., '9'=57
// 所以 ch - '0' 正好是实际数值(0~9)

2. 整数值 → 数字字符(例如 7'7'

int digit = 7;
char ch = '0' + digit;   // 48 + 7 = 55 = '7'
cout << ch << "\n";      // 打印:7(字符 '7')

// 仅对数字 0~9 有效

3. 大小写互转

📄 C++ 完整代码
char upper = 'C';
char lower = upper + 32;           // 'C'(67) + 32 = 'c'(99)
cout << lower << "\n";            // 打印:c

// 更易读的写法:
char lower2 = upper - 'A' + 'a';  // 'C'-'A' = 2,'a'+2 = 'c'
cout << lower2 << "\n";           // 打印:c

// 反向:小写 → 大写
char ch = 'f';
char upper2 = ch - 'a' + 'A';    // 'f'-'a' = 5,'A'+5 = 'F'
cout << upper2 << "\n";           // 打印:F

// 使用内置函数(更推荐,可读性更好):
cout << (char)toupper('g') << "\n";  // 打印:G
cout << (char)tolower('G') << "\n";  // 打印:g

4. 判断字符类型(USACO 中非常实用)

📄 C++ 完整代码
char ch = '5';

// 判断是否为数字
if (ch >= '0' && ch <= '9') {
    cout << "是数字!\n";
}

// 判断是否为大写字母
if (ch >= 'A' && ch <= 'Z') {
    cout << "大写字母!\n";
}

// 判断是否为小写字母
if (ch >= 'a' && ch <= 'z') {
    cout << "小写字母!\n";
}

// 或使用内置函数:
// isdigit(ch), isupper(ch), islower(ch), isalpha(ch), isalnum(ch)
if (isdigit(ch)) cout << "数字!\n";
if (isalpha(ch)) cout << "字母!\n";

5. 经典模式:从字符串中提取数字

string s = "abc123def";
int sum = 0;
for (char ch : s) {
    if (ch >= '0' && ch <= '9') {
        sum += ch - '0';  // 将数字字符转为整数并累加
    }
}
cout << "各位数字之和:" << sum << "\n";  // 1+2+3 = 6

string 详细指南

string 是 C++ 的内置文本类型。与单个 char 不同,string 存储一串字符,并提供许多实用操作。

基本操作:

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

int main() {
    // 创建字符串
    string s1 = "Hello";
    string s2 = "World";
    string empty = "";           // 空字符串
    string repeated(5, 'x');     // "xxxxx"——5 个 'x'

    // 长度
    cout << s1.size() << "\n";   // 5(等同于 s1.length())

    // 拼接
    string s3 = s1 + " " + s2;  // "Hello World"
    s1 += "!";                   // s1 变为 "Hello!"

    // 访问单个字符(从 0 开始,与数组相同)
    cout << s3[0] << "\n";      // 'H'
    cout << s3[6] << "\n";      // 'W'

    // 修改单个字符
    s3[0] = 'h';                // "hello World"

    // 比较(字典序)
    cout << ("apple" < "banana") << "\n";   // 1(真)
    cout << ("abc" == "abc") << "\n";       // 1(真)
    cout << ("abc" < "abd") << "\n";        // 1(真,逐字符比较)

    return 0;
}

遍历字符串:

📄 C++ 完整代码
string s = "USACO";

// 方法一:下标循环
for (int i = 0; i < (int)s.size(); i++) {
    cout << s[i] << " ";  // U S A C O
}
cout << "\n";

// 方法二:范围 for 循环(更简洁)
for (char ch : s) {
    cout << ch << " ";    // U S A C O
}
cout << "\n";

// 方法三:引用范围 for(可就地修改)
for (char& ch : s) {
    ch = tolower(ch);     // 将每个字符转为小写
}
cout << s << "\n";        // "usaco"

常用 string 函数:

📄 C++ 完整代码
string s = "Hello, World!";

// 子串:s.substr(起始位置, 长度)
string sub = s.substr(7, 5);     // "World"(从第 7 个字符开始取 5 个)
string sub2 = s.substr(7);       // "World!"(从第 7 个到结尾)

// 查找:s.find("文本")——返回下标,找不到返回 string::npos
size_t pos = s.find("World");    // 7(注意类型是 size_t,不是 int!)
if (s.find("xyz") == string::npos) {
    cout << "未找到!\n";
}

// 追加
s.append(" Hi");                 // "Hello, World! Hi"
// 等价于:s += " Hi";

// 插入
s.insert(5, "!!");               // "Hello!!, World! Hi"

// 删除:s.erase(起始位置, 个数)
s.erase(5, 2);                   // 从第 5 位删除 2 个字符

// 替换:s.replace(起始位置, 个数, "新文本")
string msg = "I love cats";
msg.replace(7, 4, "dogs");       // "I love dogs"

从输入读取字符串:

📄 C++ 完整代码
// cin >> 读取一个单词(遇到空白字符停止)
string word;
cin >> word;    // 输入 "Hello World" → word = "Hello"

// getline 读取整行(包含空格)
string line;
getline(cin, line);   // 输入 "Hello World" → line = "Hello World"

// ⚠️ 注意:cin >> 之后,调用 getline 前先 cin.ignore()!
int n;
cin >> n;
cin.ignore();          // 消耗残留的 '\n'
string fullLine;
getline(cin, fullLine); // 现在可以正确读取

string 与数字互转:

📄 C++ 完整代码
// 字符串 → 整数
string numStr = "42";
int num = stoi(numStr);         // stoi = "string to int" → 42
long long big = stoll("123456789012345"); // stoll = "string to long long"

// 字符串 → 浮点数
double d = stod("3.14");       // stod = "string to double" → 3.14

// 整数 → 字符串
int x = 255;
string s = to_string(x);       // "255"
string s2 = to_string(3.14);   // "3.140000"

char 数组(C 风格字符串)——了解即可

在 C(以及旧式 C++ 代码)中,字符串以'\0' 结尾的 char 数组存储。竞赛编程中你很少需要用到它(改用 string),但应该能认出它:

// C 风格字符串(char 数组)
char greeting[] = "Hello";  // 实际存储:H e l l o \0(共 6 个字符!)
// '\0'(空字符)标记字符串结束

// 警告:必须确保数组足够大以容纳字符串 + '\0'
char name[20];              // 最多可存 19 个字符 + '\0'

// char 数组与 string 互转
string s = greeting;        // char 数组 → string(自动转换)
// string → char 数组:使用 s.c_str() 获得 const char*

为什么竞赛编程中 stringchar[] 更好:

特性char[](C 风格)string(C++)
大小需要预定义最大长度自动增长
拼接strcat()——手动、易出错s1 + s2——简单
比较strcmp()——返回整数s1 == s2——直观
长度strlen()——每次 O(N)s.size()——O(1)
安全性缓冲区溢出风险安全,由 C++ 管理

USACO 专业技巧: 除非题目明确要求 char 数组,否则始终用 string。字符串操作更简洁、更安全、更易调试。char 数组在竞赛编程中唯一常见的使用场景是用 scanf/printf 读取超大输入以提速——但加上 sync_with_stdio(false) 后,string + cin/cout 对 99% 的 USACO 题目已经足够快。


快速参考:字符/字符串速查表

操作代码示例
数字字符 → 整数ch - '0''7' - '0'7
整数 → 数字字符'0' + digit'0' + 3'3'
大写 → 小写ch - 'A' + 'a'tolower(ch)'C''c'
小写 → 大写ch - 'a' + 'A'toupper(ch)'f''F'
是数字?ch >= '0' && ch <= '9'isdigit(ch)'5' → true
是字母?isalpha(ch)'A' → true
字符串长度s.size()s.length()"abc" → 3
子串s.substr(起始, 长度)"Hello".substr(1,3)"ell"
查找s.find("文本")返回下标或 npos
字符串 → 整数stoi(s)stoi("42") → 42
整数 → 字符串to_string(n)to_string(42)"42"
遍历字符串for (char ch : s)逐字符遍历

⚠️ 整数溢出——竞赛编程中的头号 Bug

当一个数超过它所属类型的范围时会发生什么?

// 把 int 想象成一个从 -2,147,483,648 到 2,147,483,647 的表盘
// 当你超过最大值,它会回绕到最小值!

int x = 2147483647;  // int 的最大值
cout << x << "\n";   // 打印:2147483647
x++;                 // 加 1……会发生什么?
cout << x << "\n";   // 打印:-2147483648(溢出!回绕了!)

这就像老式汽车里程表到了 999999 又回到 000000。

如何避免溢出:

int a = 1000000000;    // 10 亿——int 能放下
int b = 1000000000;    // 10 亿——int 能放下
// int wrong = a * b;  // 溢出!a*b = 10^18,int 放不下

long long correct = (long long)a * b;  // 乘之前把其中一个转成 long long
cout << correct << "\n";  // 1000000000000000000 ✓

// 经验法则:如果 N 最大为 10^9,你又需要将两个这样的值相乘,就用 long long

专业技巧: 拿不准时,用 long long。它比 int 稍慢,但能防止难以发现的溢出 bug。


2.1.4 用 cincout 进行输入输出

cout 打印输出

int score = 95;
string name = "Alice";

cout << "分数:" << score << "\n";          // 分数:95
cout << name << " 得了 " << score << "\n"; // Alice 得了 95

// "\n" vs endl
cout << "第一行" << "\n";   // 快——只是一个换行符
cout << "第二行" << endl;   // 慢——清空缓冲区并换行

专业技巧: 始终用 "\n" 而不是 endlendl 会清空输出缓冲区,比 "\n" 慢得多。对于输出量大的题目,使用 endl 可能导致超时!

cin 读取输入

int n;
cin >> n;    // 从输入读取一个整数

string s;
cin >> s;    // 读取一个单词(遇到空白字符停止——空格、制表符、换行)

double x;
cin >> x;    // 读取一个小数

cin >> 会自动跳过空白字符。这意味着空格、制表符和换行都被同等对待。因此以下两种输入格式的处理方式完全相同:

输入格式一(同一行):  42 hello 3.14
输入格式二(分多行):
42
hello
3.14

两种都用以下代码处理:

int a; string b; double c;
cin >> a >> b >> c;  // 无论格式如何都能读取这三个值

读取多个值——最常见的 USACO 模式

USACO 题目几乎都以「读取 N,然后读取 N 个值」开头。方法如下:

典型 USACO 输入:
5          ← 第一行:N(元素个数)
10 20 30 40 50   ← 后续行:N 个值
int n;
cin >> n;              // 读取 N

for (int i = 0; i < n; i++) {
    int x;
    cin >> x;          // 读取每个元素
    cout << x * 2 << "\n";  // 处理它
}

复杂度分析:

  • 时间:O(N)——读取 N 个数,每个 O(1) 处理
  • 空间:O(1)——只有一个变量 x,不存储所有数据

对于输入 5\n10 20 30 40 50,会打印:

20
40
60
80
100

读取完整的一行(包含空格)

有时输入的一行包含多个单词。cin >> 每次只读一个单词,所以要用 getline

string fullName;
getline(cin, fullName);  // 读取整行,包括空格
cout << "姓名:" << fullName << "\n";

🐛 常见 Bug: 混用 cin >>getline 会出问题。cin >> n 之后,缓冲区里还残留一个 \n。这时调用 getline 会读到那个空行而不是下一行。修复方法:在 cin >> 之后、getline 之前调用 cin.ignore()

控制小数输出精度

double y = 3.14159;

cout << y << "\n";                            // 3.14159(默认)
cout << fixed << setprecision(2) << y << "\n"; // 3.14(恰好 2 位小数)
cout << fixed << setprecision(6) << y << "\n"; // 3.141590(6 位小数)

2.1.5 基本算术

📄 查看代码:2.1.5 基本算术
#include <bits/stdc++.h>
using namespace std;

int main() {
    int a = 17, b = 5;

    cout << a + b << "\n";   // 22  (加法)
    cout << a - b << "\n";   // 12  (减法)
    cout << a * b << "\n";   // 85  (乘法)
    cout << a / b << "\n";   // 3   (整数除法——截断向零!)
    cout << a % b << "\n";   // 2   (取模——整除后的余数)

    // 整数除法示例:
    // 17 ÷ 5 = 3 余 2
    // 因此:17 / 5 = 3,17 % 5 = 2

    double x = 17.0, y = 5.0;
    cout << x / y << "\n";   // 3.4(操作数是 double 时进行实数除法)

    // 复合赋值运算符:
    int n = 10;
    n += 5;    // 等同于:n = n + 5   → n 现在是 15
    n -= 3;    // 等同于:n = n - 3   → n 现在是 12
    n *= 2;    // 等同于:n = n * 2   → n 现在是 24
    n /= 4;    // 等同于:n = n / 4   → n 现在是 6
    n++;       // 等同于:n = n + 1   → n 现在是 7
    n--;       // 等同于:n = n - 1   → n 现在是 6

    cout << n << "\n";  // 6

    return 0;
}

🤔 为什么整数除法会截断?

当两个操作数都是整数时,C++ 执行整数除法——直接丢弃小数部分。17 / 53,不是 3.4。这是有意为之,而且非常有用(例如:找出某个东西属于哪个「组」)。

// 200 分钟等于几小时?
int minutes = 200;
int hours = minutes / 60;     // 200 / 60 = 3(不是 3.33…)
int remaining = minutes % 60; // 200 % 60 = 20
cout << hours << " 小时 " << remaining << " 分钟\n";  // 3 小时 20 分钟
// 要得到小数结果,至少一个操作数必须是 double:
int a = 7, b = 2;
cout << a / b << "\n";           // 3    (整数除法)
cout << (double)a / b << "\n";   // 3.5  (先把 a 转成 double)
cout << a / (double)b << "\n";   // 3.5  (先把 b 转成 double)
cout << 7.0 / 2 << "\n";        // 3.5  (字面量 7.0 是 double)

2.1.6 你的第一个 USACO 风格程序

让我们把所有内容综合起来,写一个读取输入、产生输出的完整程序——就像真正的 USACO 题目。

题目: 读取两个整数 N 和 M,打印它们的和、差、积、整数商和余数。

分析思路:

  1. 需要两个变量存储 N 和 M
  2. cin 读取
  3. cout 打印每个结果
  4. N 和 M 可能很大,应该用 long long 吗?安全起见用它。

💡 新手解题流程:

遇到题目时,别急着写代码。先用自然语言想清楚步骤:

  1. 理解题目:输入是什么?输出是什么?约束是什么?
  2. 手动推演样例:用样例输入,手算出输出,确认自己理解了题目
  3. 考虑数据范围:N 和 M 最大多少?会不会溢出?
  4. 写伪代码读取 → 计算 → 输出
  5. 翻译成 C++:将伪代码逐行转化为真实代码

本题:读取两个数 → 执行五种运算 → 输出五个结果。非常直接!

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

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

    long long n, m;
    cin >> n >> m;  // 在同一行读取两个数

    cout << n + m << "\n";  // 和
    cout << n - m << "\n";  // 差
    cout << n * m << "\n";  // 积
    cout << n / m << "\n";  // 整数商
    cout << n % m << "\n";  // 余数

    return 0;
}

复杂度分析:

  • 时间:O(1)——只有固定次数的算术运算
  • 空间:O(1)——只有两个变量

样例输入:

17 5

样例输出:

22
12
85
3
2

⚠️ 第 2.1 章常见错误

#错误示例错在哪里修复方法
1整数溢出int a = 1e9; int b = a*a;a*b = 10^18 超过 int 最大值约 2.1×10^9,结果「回绕」成错误值long long
2使用 endlcout << x << endl;endl 清空输出缓冲区,大量输出时比 "\n" 慢 10 倍以上,可能导致超时"\n"
3忘记 I/O 加速缺少 sync_with_stdiocin.tie默认情况下 cin/cout 与 C 的 scanf/printf 同步,大量输入时极慢始终加上这两行
4整数除法意外7/2 期望 3.5 却得到 3两个整数相除,C++ 截断小数部分强转 double:(double)7/2
5缺少分号cout << xC++ 每条语句必须以 ; 结尾,否则编译失败cout << x;
6混用 cin >>getlinecin >> n 然后 getline(cin, s)cin >> 在缓冲区留下 \ngetline 读到空行中间加 cin.ignore()

本章总结

📌 核心要点

概念要点为什么重要
#include <bits/stdc++.h>一次性包含所有标准库竞赛中节省时间,不用记每个头文件
using namespace std;省略 std:: 前缀代码更简洁,竞赛编程的通用做法
int main()程序唯一入口点每个 C++ 程序必须有且仅有一个 main
cin >> x / cout << x读取输入/写出输出USACO 的核心 I/O 方法
int vs long long约 ±2×10^9 vs 约 ±9.2×10^18类型用错 = 溢出 = 答案错误(竞赛中最常见的 bug)
"\n" vs endl"\n" 快 10 倍决定 AC 还是超时
a / ba % b整数除法和取模时间转换、分组等核心工具
I/O 加速行sync_with_stdio(false) + cin.tie(NULL)竞赛模板必备,忘加可能超时

❓ 常见问题

Q1:bits/stdc++.h 会拖慢编译速度吗?

A:是的,编译时间可能增加 1-2 秒。但竞赛中编译时间不计入时限,不影响结果。生产项目中不要用它。

Q2:默认用 int 还是 long long

A:经验法则——拿不准就用 long long。它比 int 稍慢(在现代 CPU 上几乎感受不到),但能防止溢出。特别注意:两个 int 相乘,结果可能需要 long long

Q3:USACO 里不能用 scanf/printf 吗?

A:可以用!但加了 sync_with_stdio(false) 后,不能混用 cin/coutscanf/printf。建议新手坚持用 cin/cout,更安全。

Q4:可以省略 return 0; 吗?

A:C++11 及以后,main() 执行到末尾时编译器自动返回 0,技术上可以省略。但写上更清晰。

Q5:代码本地运行正确,USACO 评测却 Wrong Answer,怎么回事?

A:最常见的三个原因:① 整数溢出(本该用 long long 却用了 int);② 没处理所有边界情况;③ 输出格式错误(多了或少了空格/换行)。

🔗 与后续章节的联系

  • 第 2.2 章(控制流)在本章基础上新增 if/else 条件和 for/while 循环,让你能处理「重复 N 次」的任务
  • 第 2.3 章(函数与数组)介绍函数(将代码组织成可复用的块)和数组(存储一组数据)——USACO 解题的核心工具
  • 第 3.1 章(STL 核心用法)介绍 vectorsort 等 STL 工具,大大简化本章手写的逻辑
  • 本章学到的整数溢出预防技巧会贯穿全书,特别在第 3.2 章(前缀和)和第 6.1-6.3 章(DP)中反复用到

练习题

按顺序完成所有题目——难度逐渐递增。每题都有完整题解,尝试自己解决后再查看。


🌡️ 热身题

每道题只需新增 1-3 行代码,帮助你练习输入 C++ 代码和运行程序。


热身 2.1.1 — 个人问候 编写一个程序,精确打印以下内容(换成你自己的名字):

Hello, Alice!
My favorite number is 7.
I am learning C++.

(所有值可以硬编码——不需要读取输入。)

💡 题解(点击展开)

思路:cout 打印三行。无需读取输入。

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

int main() {
    cout << "Hello, Alice!\n";
    cout << "My favorite number is 7.\n";
    cout << "I am learning C++.\n";
    return 0;
}

关键点:

  • 每条 cout 语句以 ;\n" 结尾——\n 产生换行
  • 也可以将多个 << 连在一条 cout
  • 没有输入时不需要 cin

热身 2.1.2 — 五行数字 打印数字 1 到 5,每行一个。用恰好 5 条独立的 cout 语句(还没学循环——第 2.2 章会讲)。

💡 题解(点击展开)

思路: 五条独立的 cout 语句,每条打印一个数字。

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

int main() {
    cout << 1 << "\n";
    cout << 2 << "\n";
    cout << 3 << "\n";
    cout << 4 << "\n";
    cout << 5 << "\n";
    return 0;
}

关键点:

  • cout << 1 << "\n" 打印数字 1 后跟换行
  • 第 2.2 章会学用循环来做这件事——但手动写对小数量完全没问题

热身 2.1.3 — 翻倍 从输入读取一个整数,打印它的 2 倍。

样例输入: 7 样例输出: 14

💡 题解(点击展开)

思路: 读入变量,乘以 2,打印。

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

int main() {
    int n;
    cin >> n;
    cout << n * 2 << "\n";
    return 0;
}

关键点:

  • cin >> n 读取一个整数存入 n
  • 可以直接在 cout 里做算术:n * 2 先计算,再打印
  • 若 n 可能很大(最大 10^9),用 long long n,因为 n * 2 可能溢出 int

热身 2.1.4 — 两数之和 读取同一行上的两个整数,打印它们的和。

样例输入: 15 27 样例输出: 42

💡 题解(点击展开)

思路: 读取两个整数,相加,打印。

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

int main() {
    int a, b;
    cin >> a >> b;
    cout << a + b << "\n";
    return 0;
}

关键点:

  • cin >> a >> b 在一条语句中读取两个值——无论它们在同一行还是不同行都有效
  • 在同一行声明两个变量:int a, b; 等价于 int a; int b;

热身 2.1.5 — 打招呼 读取一个单词(名字,不含空格),打印 Hi, [name]!

样例输入: Bob 样例输出: Hi, Bob!

💡 题解(点击展开)

思路: 读取字符串,嵌入问候语中打印。

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

int main() {
    string name;
    cin >> name;
    cout << "Hi, " << name << "!\n";
    return 0;
}

关键点:

  • string name; 声明一个存储文本的变量
  • cin >> name 读取一个单词(遇到第一个空格停止)
  • 注意 cout 的链式写法:字符串字面量 + 变量 + 字符串字面量

🏋️ 核心练习题

这些题需要综合运用输入、算术和输出。编码前先想清楚数学关系。


题目 2.1.6 — 年龄换算 读取一个人的整岁年龄,打印其大约的天数(按每年 365 天计算,不考虑闰年)。

样例输入: 15 样例输出: 5475

💡 题解(点击展开)

思路: 年龄乘以 365。由于年龄 × 365 不会超过 int 范围(最大约 150 岁 → 150×365 = 54750,远小于 int 上限),用 int 即可。

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

int main() {
    int years;
    cin >> years;
    cout << years * 365 << "\n";
    return 0;
}

关键点:

  • years * 365 按整数计算——这里不存在溢出风险
  • 如果还要换算成小时、分钟、秒,为安全起见改用 long long

题目 2.1.7 — 秒数转换 读取秒数 S(1 ≤ S ≤ 10^9),转换为小时、分钟和剩余秒数。

样例输入: 3661 样例输出:

1 hours
1 minutes
1 seconds
💡 题解(点击展开)

思路: 使用整数除法和取模。先除以 3600 得小时数,余数(mod 3600)再除以 60 得分钟数,最后剩余的是秒数。

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

int main() {
    long long s;
    cin >> s;

    long long hours = s / 3600;         // 每小时 3600 秒
    long long remaining = s % 3600;     // 去掉完整小时后剩余的秒数
    long long minutes = remaining / 60; // 每分钟 60 秒
    long long seconds = remaining % 60; // 去掉完整分钟后剩余的秒数

    cout << hours << " hours\n";
    cout << minutes << " minutes\n";
    cout << seconds << " seconds\n";

    return 0;
}

关键点:

  • long long 是因为 S 最大 10^9(int 也够,但 long long 是好习惯)
  • 核心思路:s % 3600 得到去掉完整小时后的秒数,再除以 60 得分钟
  • 验证:3661 → 3661/3600=1 小时,3661%3600=61,61/60=1 分钟,61%60=1 秒 ✓

题目 2.1.8 — 矩形 读取矩形的长 L 和宽 W,打印它的面积和周长。

样例输入: 6 4 样例输出:

Area: 24
Perimeter: 20
💡 题解(点击展开)

思路: 面积 = L × W,周长 = 2 × (L + W)。

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

int main() {
    long long L, W;
    cin >> L >> W;

    cout << "Area: " << L * W << "\n";
    cout << "Perimeter: " << 2 * (L + W) << "\n";

    return 0;
}

关键点:

  • 运算顺序:2 * (L + W)——括号确保先加 L+W,再乘以 2
  • 使用 long long 以防 L、W 较大(若 L、W 最大 10^9,L*W 最大 10^18)

题目 2.1.9 — 温度转换 读取摄氏温度,打印对应的华氏温度。公式:F = C × 9/5 + 32

样例输入: 100 样例输出: 212.00

💡 题解(点击展开)

思路: 套用公式。需要小数输出,用 double。陷阱:9/5 整数运算结果是 1,不是 1.8!

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

int main() {
    double celsius;
    cin >> celsius;

    double fahrenheit = celsius * 9.0 / 5.0 + 32.0;

    cout << fixed << setprecision(2) << fahrenheit << "\n";

    return 0;
}

关键点:

  • 9.0 / 5.0(或 9.0/5)而不是 9/5——后者是整数除法,结果是 1 而不是 1.8
  • fixed << setprecision(2) 强制输出恰好 2 位小数
  • 验证:100°C → 100 × 9.0/5.0 + 32 = 180 + 32 = 212 ✓

题目 2.1.10 — 硬币计数 读取四个整数:25 分硬币(quarters)、10 分硬币(dimes)、5 分硬币(nickels)和 1 分硬币(pennies)的数量,打印总面值(单位:分)。

样例输入:

3 2 1 4

(3 枚 25 分,2 枚 10 分,1 枚 5 分,4 枚 1 分)

样例输出: 104

💡 题解(点击展开)

思路: 每种硬币数量乘以面值,全部相加。

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

int main() {
    int quarters, dimes, nickels, pennies;
    cin >> quarters >> dimes >> nickels >> pennies;

    int total = quarters * 25 + dimes * 10 + nickels * 5 + pennies * 1;

    cout << total << "\n";

    return 0;
}

关键点:

  • 每种硬币乘以面值:quarters=25,dimes=10,nickels=5,pennies=1
  • 验证:3×25 + 2×10 + 1×5 + 4×1 = 75 + 20 + 5 + 4 = 104 ✓
  • 若硬币数量很大,改用 long long

🏆 挑战题

这些题需要更多思考——特别是数据类型和解题方法。


挑战 2.1.11 — 溢出探测器 读取两个整数 A 和 B(各最大 10^9),用两种方式计算它们的乘积:int 类型和 long long 类型,各打印一次结果。观察溢出时的差异。

样例输入: 1000000000 3 样例输出:

int product: -1294967296
long long product: 3000000000

int 结果因溢出而错误;long long 结果正确。)

💡 题解(点击展开)

思路:long long 读入两个数,然后两种方式各算一次乘积——一次强转为 int,一次用 long long。直观展示溢出效果。

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

int main() {
    long long a, b;
    cin >> a >> b;

    // 先转成 int 再乘,强制触发整数溢出
    int int_product = (int)a * (int)b;

    // long long 乘法——值最大 10^9 时不会溢出
    long long ll_product = a * b;

    cout << "int product: " << int_product << "\n";
    cout << "long long product: " << ll_product << "\n";

    return 0;
}

关键点:

  • (int)a * (int)b——两个操作数都转成 int 再乘,乘法在 int 范围内溢出
  • a * b(a、b 是 long long)——乘法在 long long 空间内进行,不溢出
  • 10^9 × 3 的实际结果是 3×10^9,但 int 最大约 2.147×10^9 < 3×10^9,溢出后得到 -1294967296
  • 教训: 当任意一个值可能达到约 10^5 或更大时,乘法时始终用 long long

挑战 2.1.12 — USACO 风格大数相乘 给定两个整数 N 和 M(1 ≤ N, M ≤ 10^9),打印它们的乘积。(看似简单,但必须用 long long。)

样例输入: 1000000000 1000000000 样例输出: 1000000000000000000

💡 题解(点击展开)

思路: N 和 M 各自能放进 int,但 N × M = 10^18——超出 int 上限(约 2.1×10^9),勉强放进 long long(上限约 9.2×10^18)。必须用 long long

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

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

    long long n, m;
    cin >> n >> m;

    cout << n * m << "\n";

    return 0;
}

关键点:

  • long long 变量读取是关键——cin >> n 可以处理最大 9.2×10^18 的值
  • 若用 int 变量:int n, m; cin >> n >> m; cout << n * m;——这会静默溢出,输出错误答案
  • 做 USACO 题时,始终检查约束:若 N 最大 10^9,且可能要算 N × N,就需要 long long

挑战 2.1.13 — 象限问题 (USACO 2016 February Bronze) 读取两个非零整数 x 和 y,判断点 (x, y) 在坐标平面的哪个象限:

  • 第一象限:x > 0 且 y > 0
  • 第二象限:x < 0 且 y > 0
  • 第三象限:x < 0 且 y < 0
  • 第四象限:x > 0 且 y < 0

只打印象限数字:1234

样例输入 1: 3 5输出: 1 样例输入 2: -1 2输出: 2 样例输入 3: -4 -7输出: 3 样例输入 4: 8 -3输出: 4

💡 题解(点击展开)

思路: 判断 x 和 y 的正负。x 和 y 正负的每种组合恰好对应一个象限。使用 if/else-if 链(第 2.2 章会完整讲,但这里很直接)。

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

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

    int x, y;
    cin >> x >> y;

    if (x > 0 && y > 0) {
        cout << 1 << "\n";
    } else if (x < 0 && y > 0) {
        cout << 2 << "\n";
    } else if (x < 0 && y < 0) {
        cout << 3 << "\n";
    } else {  // x > 0 && y < 0
        cout << 4 << "\n";
    }

    return 0;
}

关键点:

  • && 运算符表示「且」——两个条件都必须为真
  • 题目保证 x ≠ 0 且 y ≠ 0,无需处理这些边界情况
  • 四种情况互斥(对任意输入恰好一种为真),else-if 链完美适用
  • 可以用公式简化,但显式 if/else 更清晰,速度一样快
📖 第 2.2 章 ⏱️ 约 60 分钟 🎯 入门

第 2.2 章:控制流

📝 前置条件: 第 2.1 章(变量、cin/cout、基本算术)


2.2.0 什么是「控制流」?

到目前为止,我们写的每个程序都是从上到下执行的——第 1 行、第 2 行、第 3 行,结束。就像从头到尾读一本书。

但真正的程序需要做决策重复操作。这就是「控制流」的含义——控制执行的(顺序)。

想象一本「选择你的冒险」书:

  • 有时书上说「如果你想和龙战斗,翻到第 47 页;否则翻到第 52 页」
  • 有时书上说「重复这一段,直到你逃出地牢」

C++ 通过以下方式提供了恰好对应的功能:

  • if/else —— 根据条件做决策
  • for/while 循环 —— 重复执行一段代码

这是控制流的总览:

Control Flow Overview

在循环图示中:程序不断回到「步骤 2」,直到条件变为假,才退出到「步骤 3」。


2.2.1 if 语句

if 语句让你的程序做决策:「如果这个条件为真,就执行这件事。」

基本 if

📄 查看代码:基本 if
#include <bits/stdc++.h>
using namespace std;

int main() {
    int score;
    cin >> score;

    if (score >= 90) {
        cout << "优秀!\n";
    }

    cout << "完成。\n";  // 无论 score 是多少都会执行
    return 0;
}

score 为 95:打印「优秀!」然后「完成。」 score 为 80:只打印「完成。」(if 块被跳过)

if / else

int score;
cin >> score;

if (score >= 60) {
    cout << "通过\n";
} else {
    cout << "不通过\n";
}

else仅在 if 条件为假时执行。两个块中恰好只有一个会运行。

if / else if / else

需要检查多个条件时:

📄 需要检查多个条件时:
int score;
cin >> score;

if (score >= 90) {
    cout << "A\n";
} else if (score >= 80) {
    cout << "B\n";
} else if (score >= 70) {
    cout << "C\n";
} else if (score >= 60) {
    cout << "D\n";
} else {
    cout << "F\n";
}

C++ 从上到下按顺序检查这些条件,运行第一个为真的分支。一旦运行了一个块,就会跳过后面所有的 else if/else 块。

若 score = 85:

  1. 85 >= 90? → 跳过
  2. 85 >= 80? → 打印「B」,然后跳过后续所有 else-if

🤔 为什么这样有效? 当我们到达 else if (score >= 80) 时,我们已经知道 score < 90(如果它 ≥ 90,第一个条件早就捕获了)。每个 else if 隐式假设所有前面的条件都为假。

比较运算符

运算符含义示例
==等于a == b
!=不等于a != b
<小于a < b
>大于a > b
<=小于等于a <= b
>=大于等于a >= b

逻辑运算符(组合条件)

运算符含义示例
&&且——两者都必须为真x > 0 && y > 0
||或——至少一个为真x == 0 || y == 0
!非——将真翻转为假!finished
📄 C++ 完整代码
int x, y;
cin >> x >> y;

if (x > 0 && y > 0) {
    cout << "两者都为正数\n";
}

if (x < 0 || y < 0) {
    cout << "至少有一个是负数\n";
}

bool done = false;
if (!done) {
    cout << "还在进行中……\n";
}

🐛 常见 Bug:= vs ==

这是新手(甚至有经验的程序员)最常犯的错误之一:

📄 这是新手(甚至有经验的程序员)最常犯的错误之一:
int x = 5;

// 危险的 BUG:
if (x = 10) {   // 这是把 10 赋值给 x,不是比较!
                 // x 变为 10,由于 10 不为零,这个条件永远为真
    cout << "x 是 10\n";  // 总是执行,即使 x 最初是 5!
}

// 正确写法:
if (x == 10) {  // 这才是比较 x 和 10
    cout << "x 是 10\n";  // 只有 x 真的等于 10 时才执行
}

= 运算符是赋值(存储一个值);== 运算符是比较(检查两个值是否相等)。两者看起来相似,但功能完全不同。

专业技巧: 有些程序员写 10 == x 而不是 x == 10——如果不小心写了 = 而不是 ==,就变成了 10 = x,会是编译错误(不能对字面量赋值)。这叫「尤达条件式」。

嵌套 if 语句

可以在 if 语句里再放 if 语句:

📄 可以在 `if` 语句里再放 `if` 语句:
int age, income;
cin >> age >> income;

if (age >= 18) {
    cout << "成年人\n";
    if (income > 50000) {
        cout << "高收入成年人\n";
    } else {
        cout << "普通收入成年人\n";
    }
} else {
    cout << "未成年人\n";
}

注意:每个 else 匹配最近的、还没有 else if


2.2.2 while 循环

while 循环条件为真时一直重复执行一段代码。条件变为假后,执行继续到循环之后。

while (条件) {
    循环体(反复执行)
}
📄 C++ 完整代码
#include <bits/stdc++.h>
using namespace std;

int main() {
    int i = 1;             // 1. 循环前初始化
    while (i <= 5) {       // 2. 检查条件——若为假则跳过循环
        cout << i << "\n"; // 3. 执行循环体
        i++;               // 4. 更新——非常重要!忘了这步 → 死循环
    }
    // 循环后:i = 6,条件 6 <= 5 为假,循环退出
    return 0;
}

输出:

1
2
3
4
5

🐛 常见 Bug:死循环

如果忘了更新变量(上面的第 4 步),条件永远不会变为假,循环就会一直运行!

int i = 1;
while (i <= 5) {
    cout << i << "\n";
    // BUG:忘了 i++——这会永远打印「1」!
}

如果程序卡住了,按 Ctrl+C 强制停止。

什么时候用 while vs for

  • 事先不知道需要迭代多少次时,用 while
  • 知道次数时,用 for(下一节介绍)

while 的经典使用场景:读取直到满足某个条件。

// 常见 USACO 模式:读取直到输入结束
int x;
while (cin >> x) {    // cin >> x 在输入耗尽时返回 false
    cout << x * 2 << "\n";
}

do-while 循环

do-while 循环至少执行一次循环体,然后再检查条件:

int n;
do {
    cin >> n;
} while (n <= 0);   // 一直重新读,直到用户输入正数

当你想在判断是否重复之前先执行某件事时,这很有用。竞赛编程中不常见,但值得了解。


2.2.3 for 循环

for 循环是竞赛编程中最常用的循环。它将初始化、条件检查和更新打包在一行:

for (初始化; 条件; 更新) {
    循环体
}

等价于:

初始化;
while (条件) {
    循环体
    更新;
}

图示:for 循环流程图

For Loop Flowchart

流程图展示了执行过程:初始化只运行一次,之后每次迭代前检查条件,条件为假时退出循环。

常见 for 循环模式

📄 查看代码:常见 for 循环模式
// 从 0 数到 9(竞赛编程标准模式)
for (int i = 0; i < 10; i++) {
    cout << i << " ";
}
// 打印:0 1 2 3 4 5 6 7 8 9

// 从 1 数到 n(包含)
int n = 5;
for (int i = 1; i <= n; i++) {
    cout << i << " ";
}
// 打印:1 2 3 4 5

// 倒数
for (int i = 10; i >= 1; i--) {
    cout << i << " ";
}
// 打印:10 9 8 7 6 5 4 3 2 1

// 以 2 为步长
for (int i = 0; i <= 10; i += 2) {
    cout << i << " ";
}
// 打印:0 2 4 6 8 10

🧠 循环追踪:精确理解执行过程

学习循环时,手动追踪它们。方法如下:

代码: for (int i = 0; i < 4; i++) cout << i * i << " ";

Loop Trace Example

先在纸上追踪循环,再运行——这能建立直觉并帮你发现 bug。

最常见的 USACO 循环模式

读取 N 个数并逐一处理:

int n;
cin >> n;

for (int i = 0; i < n; i++) {
    int x;
    cin >> x;
    // 在这里处理 x
    cout << x * 2 << "\n";
}

专业技巧: 竞赛编程中,for (int i = 0; i < n; i++) 以 0 为起点是标准写法。这与数组的索引方式(第 2.3 章)一致,让一切都整齐地对应。

2.2.4 嵌套循环

可以在循环里再放一个循环。内层循环对外层循环的每一次迭代都会完整执行一遍

Nested Loop Clock Analogy

// 打印 4×4 乘法表
for (int i = 1; i <= 4; i++) {         // 外层:行
    for (int j = 1; j <= 4; j++) {     // 内层:列
        cout << i * j << "\t";          // \t = 制表符
    }
    cout << "\n";  // 每行结束后换行
}

输出:

1   2   3   4
2   4   6   8
3   6   9   12
4   8   12  16

追踪前两行:

i=1: j=1→打印1, j=2→打印2, j=3→打印3, j=4→打印4, 然后换行
i=2: j=1→打印2, j=2→打印4, j=3→打印6, j=4→打印8, 然后换行
...

⚠️ 嵌套循环的时间复杂度

💡 为什么需要关心循环次数? 竞赛中,你的程序通常需要在 1-2 秒内完成。现代计算机每秒可以执行约 10^8 到 10^9 次简单运算。因此,如果能估算出循环体总共执行多少次,就能判断是否会超时(TLE)。这是「时间复杂度分析」的核心思想——后续章节会深入学习。

单重 N 次循环执行 N 次操作;两层嵌套 N 次循环执行 N × N = N² 次操作。

循环层数操作次数N 的安全上限示例
1 层N~10^8遍历数组求和
2 层(嵌套)~10^4比较所有对
3 层(嵌套)~450枚举所有三元组

N = 1000 时两层嵌套是 10^6 次操作——没问题。但 N = 100,000 时是 10^10——太慢了!

🧠 快速经验法则: 看到 N 的范围后,反向利用上表判断最多能用几层嵌套循环。例如:N ≤ 10^5 → 只能用 O(N) 或 O(N log N) 算法;N ≤ 5000 → O(N²) 可以接受。这在 USACO 中极为实用!


2.2.5 switch 语句

当你需要检查一个变量的许多具体值时,switch 比一长串 if/else if 更简洁:

📄 当你需要检查一个变量的许多具体值时,`switch` 比一长串 `if`/`else if` 更简洁:
int day;
cin >> day;

switch (day) {
    case 1:
        cout << "星期一\n";
        break;   // 重要:break 退出 switch
    case 2:
        cout << "星期二\n";
        break;
    case 3:
        cout << "星期三\n";
        break;
    case 4:
        cout << "星期四\n";
        break;
    case 5:
        cout << "星期五\n";
        break;
    case 6:
    case 7:
        cout << "周末!\n";  // case 6 和 7 共用这段代码
        break;
    default:
        cout << "无效的日期\n";  // 没有 case 匹配时执行
}

什么时候用 switch vs if-else

switch 当……if-else 当……
检查一个变量的具体整数/字符值比较范围(x > 10、x < 5)
需要检查 3 个以上具体值只有 1-2 个条件
各情况互斥复杂的布尔逻辑

🐛 常见 Bug:忘记 break —— 没有 break,执行会「穿透」到下一个 case!

int x = 2;
switch (x) {
    case 1:
        cout << "one\n";
    case 2:
        cout << "two\n";   // 这会执行
    case 3:
        cout << "three\n"; // 这也会执行(穿透!因为 case 2 后没有 break)
}
// 输出:two\nthree\n(出乎意料!)

2.2.6 breakcontinue

break —— 立即退出循环

// 找 1 到 100 中第一个 7 的倍数
for (int i = 1; i <= 100; i++) {
    if (i % 7 == 0) {
        cout << "7 的第一个倍数:" << i << "\n";  // 打印 7
        break;  // 停止搜索——已找到
    }
}

continue —— 跳到下一次迭代

// 打印 1 到 10 中除了 3 的倍数之外的所有数
for (int i = 1; i <= 10; i++) {
    if (i % 3 == 0) {
        continue;  // 跳过本次迭代的剩余部分,直接到 i++
    }
    cout << i << " ";
}
// 输出:1 2 4 5 7 8 10

嵌套循环中的 break

break 只退出最内层的循环。要退出多层,用标志变量:

📄 `break` 只退出**最内层**的循环。要退出多层,用标志变量:
bool found = false;
int target = 25;

for (int i = 0; i < 10 && !found; i++) {    // 外层循环也检查 !found
    for (int j = 0; j < 10; j++) {
        if (i * j == target) {
            cout << i << " * " << j << " = " << target << "\n";
            found = true;
            break;   // 退出内层循环;外层循环因为 !found 也会退出
        }
    }
}

2.2.7 竞赛编程中的经典循环模式

这些模式几乎出现在每一道 USACO 题解中。熟记它们。

模式一:读取 N 个数,计算总和

int n;
cin >> n;

long long sum = 0;
for (int i = 0; i < n; i++) {
    int x;
    cin >> x;
    sum += x;
}
cout << sum << "\n";

复杂度分析:

  • 时间:O(N)——遍历 N 个数,每次 O(1) 处理
  • 空间:O(1)——只有一个累加变量 sum

模式二:找最大值(和最小值)

📄 查看代码:模式二:找最大值(和最小值)
int n;
cin >> n;

int maxVal, minVal;
cin >> maxVal;    // 读第一个元素
minVal = maxVal;  // 最大值和最小值都初始化为第一个元素

for (int i = 1; i < n; i++) {   // 从第二个元素开始(下标 1)
    int x;
    cin >> x;
    if (x > maxVal) maxVal = x;
    if (x < minVal) minVal = x;
}

cout << "最大值:" << maxVal << "\n";
cout << "最小值:" << minVal << "\n";

复杂度分析:

  • 时间:O(N)——遍历 N 个数,每次比较 O(1)
  • 空间:O(1)——只有 maxValminVal 两个变量

🤔 为什么初始化为第一个元素? 不要初始化为 0!万一所有数都是负数呢?初始化为第一个元素保证我们从输入中的真实值开始。

模式三:统计满足条件的数量

📄 查看代码:模式三:统计满足条件的数量
int n;
cin >> n;

int count = 0;
for (int i = 0; i < n; i++) {
    int x;
    cin >> x;
    if (x % 2 == 0) {   // 条件:偶数
        count++;
    }
}
cout << "偶数个数:" << count << "\n";

模式四:打印星号三角形

int n;
cin >> n;

for (int row = 1; row <= n; row++) {     // row 从 1 到 n
    for (int col = 1; col <= row; col++) { // 每行打印 row 个星号
        cout << "*";
    }
    cout << "\n";  // 每行结束后换行
}

n=4 时输出:

*
**
***
****

模式五:计算各位数字之和

int n;
cin >> n;

int digitSum = 0;
while (n > 0) {
    digitSum += n % 10;  // 最后一位数字
    n /= 10;             // 删除最后一位
}
cout << digitSum << "\n";

追踪 n = 12345:

n=12345: digitSum += 5, n 变为 1234
n=1234:  digitSum += 4, n 变为 123
n=123:   digitSum += 3, n 变为 12
n=12:    digitSum += 2, n 变为 1
n=1:     digitSum += 1, n 变为 0
n=0: 循环退出。digitSum = 15 ✓

2.2.8 完整示例:USACO 风格题目

题目: 你有 N 头奶牛,每头都有一个产奶评分。找出评分最高的奶牛的评分,并统计产奶量高于平均水平的奶牛数量。

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

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

    int n;
    cin >> n;

    // 我们需要存储所有值来与平均值比较
    //(第 2.3 章学数组/向量——这里先用 vector 预览)

    // 第一遍:找总和和最大值
    long long sum = 0;
    int maxMilk = 0;
    vector<int> milk(n);   // 存储所有值(第 2.3 章预览)

    for (int i = 0; i < n; i++) {
        cin >> milk[i];
        sum += milk[i];
        if (milk[i] > maxMilk) maxMilk = milk[i];
    }

    double avg = (double)sum / n;

    // 第二遍:统计高于平均值的数量
    int aboveAvg = 0;
    for (int i = 0; i < n; i++) {
        if (milk[i] > avg) aboveAvg++;
    }

    cout << "最大值:" << maxMilk << "\n";
    cout << "高于平均值:" << aboveAvg << "\n";

    return 0;
}

样例输入:

5
10 20 30 40 50

样例输出:

最大值:50
高于平均值:2

(平均值为 30;产奶量 40 和 50 的奶牛高于平均值 → 2 头)

复杂度分析:

  • 时间:O(N)——两遍(读取 + 统计),各 O(N),总计 O(2N) = O(N)
  • 空间:O(N)——使用 vector<int> milk(n) 存储所有数据

⚠️ 第 2.2 章常见错误

#错误示例错在哪里修复方法
1混淆 ===if (x = 10)= 是赋值而非比较;结果永远为真== 比较
2忘记 i++ 导致死循环while (i < n) { ... } 没有 i++条件永远为真,程序卡死确保循环变量被更新
3switch 中忘记 breakcase 2: cout << "two"; 没有 break执行「穿透」到下一个 case每个 case 末尾加 break;
4差一错误for (int i = 0; i <= n; i++) 本应是 < n多循环一次,可能越界或多计仔细核对 <<=
5最大值初始化为 0int maxVal = 0; 但所有数都是负数0 比所有输入都大,结果错误初始化为第一个元素或 INT_MIN
6嵌套循环用了相同的变量名外层 for (int i...) 和内层 for (int i...)内层 i 遮蔽外层 i,导致外层循环行为异常内外层循环用不同变量名(如 ij

本章总结

📌 核心要点

概念语法使用场景为什么重要
ifif (条件) { ... }条件为真时执行程序决策的基础;几乎每道题都用
if/elseif (...) {...} else {...}二选一处理是/否类型的决策
if/else if/else链式多选一评级系统、分类场景
whilewhile (条件) {...}次数未知时重复读取到输入结束、模拟过程
forfor (int i=0; i<n; i++) {...}次数已知时重复竞赛编程中最常用的循环
嵌套循环循环套循环需要遍历所有对注意 O(N²) 复杂度限制
breakbreak;找到目标后立即退出提前终止节省时间
continuecontinue;跳过当前迭代过滤掉不需要处理的元素
switchswitch(x) { case 1: ... }检查一个变量的多个精确值比长 if-else 链更简洁
&& / || / !逻辑运算符组合多个条件复杂决策的基础构件

🧩 五种经典循环模式快速参考

模式用途复杂度章节
读取 N 个数求和读取 N 个数计算总和O(N)2.2.7 模式一
找最大/最小值找最大/最小值O(N)2.2.7 模式二
统计满足条件的数量统计满足条件的元素个数O(N)2.2.7 模式三
星号三角形用嵌套循环打印图案O(N²)2.2.7 模式四
各位数字之和提取各位数字并求和O(log₁₀N)2.2.7 模式五

❓ 常见问题

Q1:forwhile 可以互换吗?什么时候该用哪个?

A:是的,任何 for 循环都可以改写成 while,反之亦然。经验法则:知道迭代次数(如「循环 N 次」)用 for不知道次数(如「读到输入结束」)用 while。竞赛中大约 90% 的情况用 for

Q2:嵌套循环可以套多少层?有上限吗?

A:语法上没有上限,但实际中超过 3 层就需要谨慎。两层嵌套是 O(N²),三层是 O(N³)。当 N ≥ 1000 时,三层嵌套很容易超时。如果发现需要超过 3 层嵌套,通常意味着需要更高效的算法(后续章节会讲)。

Q3:break 只退出最内层循环,怎么一次退出多层?

A:两种常用方法:① 用 bool found = false 标志变量,让外层循环也检查 !found;② 将嵌套循环放在函数里,用 return 直接退出。方法①更常见——参见 2.2.6 节的完整示例。

Q4:switchif-else if 哪个更快?

A:case 数量少(< 10)时,性能几乎一样。switch 的优势在于代码可读性,不是速度。竞赛中两种都可以自由选择。如果条件涉及范围比较(如 x > 10),必须用 if-else

Q5:程序本地输出正确,提交后却超时(TLE),怎么办?

A:第一步:估算算法复杂度。查看 N 的范围 → 用本章的「嵌套循环复杂度表」估算总操作次数 → 若超过 10^8,就需要优化。常见优化策略:减少循环层数、用排序+二分查找替代暴力搜索(第 3.3 章)、用前缀和替代重复求和(第 3.2 章)。

🔗 与后续章节的联系

  • 第 2.3 章(函数与数组)让你把本章的循环模式封装成函数,并用数组存储数据集合
  • 第 3.2 章(数组与前缀和)教你把 O(N²) 区间求和查询优化到 O(N) 预处理 + O(1) 每次查询——这是「嵌套循环太慢」问题的解决方案之一
  • 第 3.3 章(排序与搜索)教你二分查找,把本章 O(N) 线性搜索优化到 O(log N)
  • 本章学到的五种经典循环模式(求和、找最大/最小、计数、嵌套遍历、数字处理)是本书所有算法的基础构件
  • 嵌套循环复杂度分析是理解时间复杂度(贯穿全书的主题)的第一步

练习题


🌡️ 热身题


热身 2.2.1 — 数到十 打印 1 到 10,每行一个数字。用 for 循环。

💡 题解(点击展开)

思路: 从 1 到 10(包含)的 for 循环。

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

int main() {
    for (int i = 1; i <= 10; i++) {
        cout << i << "\n";
    }
    return 0;
}

关键点:

  • i <= 10(而不是 i < 10)因为我们要包含 10
  • 也可以写 for (int i = 1; i < 11; i++)——效果相同

热身 2.2.2 — 偶数 打印 2 到 20 的所有偶数,每行一个。

💡 题解(点击展开)

思路: 两种方案——步长为 2 循环,或每次循环检查是否为偶数。

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

int main() {
    // 方案一:步长为 2
    for (int i = 2; i <= 20; i += 2) {
        cout << i << "\n";
    }
    return 0;
}

关键点:

  • i += 2 每次递增 2 而不是通常的 1
  • 替代方案:for (int i = 1; i <= 20; i++) { if (i % 2 == 0) cout << i << "\n"; }

热身 2.2.3 — 正负零 读取一个整数,正数打印 Positive,负数打印 Negative,零打印 Zero

样例输入: -5输出: Negative

💡 题解(点击展开)

思路: 用三路 if/else if/else 覆盖所有情况。

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

int main() {
    int n;
    cin >> n;

    if (n > 0) {
        cout << "Positive\n";
    } else if (n < 0) {
        cout << "Negative\n";
    } else {
        cout << "Zero\n";
    }

    return 0;
}

关键点:

  • 末尾的 else 精确捕获 n == 0(因为上面两个条件覆盖了 n>0 和 n<0)

热身 2.2.4 — 3 的乘法表 打印 3 的前 10 个倍数(即 3、6、9、……、30),每行一个。

💡 题解(点击展开)

思路: 从 1 循环到 10,每次打印 i*3。

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

int main() {
    for (int i = 1; i <= 10; i++) {
        cout << i * 3 << "\n";
    }
    return 0;
}

关键点:

  • 替代方案:for (int i = 3; i <= 30; i += 3)——效果相同

热身 2.2.5 — 五数之和 读取恰好 5 个整数(可以在同一行或不同行),打印它们的和。

样例输入: 3 7 2 8 5输出: 25

💡 题解(点击展开)

思路: 循环读取 5 次,累加求和。

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

int main() {
    long long sum = 0;
    for (int i = 0; i < 5; i++) {
        int x;
        cin >> x;
        sum += x;
    }
    cout << sum << "\n";
    return 0;
}

关键点:

  • sumlong long 以防整数较大
  • 因为题目说「恰好 5 个整数」,循环读取恰好 5 次

🏋️ 核心练习题


题目 2.2.6 — FizzBuzz 经典编程挑战:打印 1 到 100 的数字,但:

  • 如果能被 3 整除,打印 Fizz
  • 如果能被 5 整除,打印 Buzz
  • 如果能同时被 3 和 5 整除,打印 FizzBuzz

输出开头几行:

📄 Code 完整代码
1
2
Fizz
4
Buzz
Fizz
7
8
Fizz
Buzz
11
Fizz
13
14
FizzBuzz
💡 题解(点击展开)

思路: 循环 1 到 100。对每个数检查整除性——检查同时整除的情况(被 3 和 5 整除),否则会被单独的 Fizz 或 Buzz 分支先捕获。

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

int main() {
    for (int i = 1; i <= 100; i++) {
        if (i % 3 == 0 && i % 5 == 0) {
            cout << "FizzBuzz\n";
        } else if (i % 3 == 0) {
            cout << "Fizz\n";
        } else if (i % 5 == 0) {
            cout << "Buzz\n";
        } else {
            cout << i << "\n";
        }
    }
    return 0;
}

关键点:

  • 检查 i % 3 == 0 && i % 5 == 0——如果先检查 i % 3 == 0,15 就会打印「Fizz」而永远到不了 FizzBuzz 分支
  • 同时被 3 和 5 整除的数就是被 15 整除:i % 15 == 0 也有效

题目 2.2.7 — N 中最小值 读取 N(1 ≤ N ≤ 1000),然后读取 N 个整数,打印最小值。

样例输入:

5
8 3 7 1 9

样例输出: 1

💡 题解(点击展开)

思路: 用读取的第一个值初始化最小值,每次看到更小的就更新。

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

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

    int n;
    cin >> n;

    int first;
    cin >> first;
    int minVal = first;  // 初始化为第一个元素

    for (int i = 1; i < n; i++) {   // 读取剩余 n-1 个元素
        int x;
        cin >> x;
        if (x < minVal) {
            minVal = x;
        }
    }

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

关键点:

  • minVal 初始化为读取的第一个元素(不是 0 或 INT_MAX),然后在循环中处理剩余元素
  • 替代方案:用 INT_MAX 作为初始值——保证大于任何 int,第一个元素一定会更新它

题目 2.2.8 — 统计正数 读取 N(1 ≤ N ≤ 1000),然后读取 N 个整数,打印其中严格正数(> 0)的个数。

样例输入:

6
3 -1 0 5 -2 7

样例输出: 3

💡 题解(点击展开)

思路: 维护一个计数器,满足条件(x > 0)时递增。

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

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

    int n;
    cin >> n;

    int count = 0;
    for (int i = 0; i < n; i++) {
        int x;
        cin >> x;
        if (x > 0) {
            count++;
        }
    }

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

关键点:

  • count 从 0 开始,只在 x > 0 时递增
  • 0 不是正数(也不是负数——它是零),所以 x > 0 正确地排除了它

题目 2.2.9 — 星号三角形 读取 N,打印一个 N 行的直角三角形,第 i 行有 i 个星号。

样例输入: 4

样例输出:

*
**
***
****
💡 题解(点击展开)

思路: 嵌套循环——外层遍历行,内层打印对应数量的星号。

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

int main() {
    int n;
    cin >> n;

    for (int row = 1; row <= n; row++) {
        for (int star = 1; star <= row; star++) {
            cout << "*";
        }
        cout << "\n";
    }

    return 0;
}

关键点:

  • 第 1 行 1 个星号,第 2 行 2 个,……,第 N 行 N 个
  • 内层循环对每个 row 值恰好执行 row
  • 使用 string 的替代方案:cout << string(row, '*') << "\n";——创建 row* 的字符串

题目 2.2.10 — 各位数字之和 读取正整数 N(1 ≤ N ≤ 10^9),打印其各位数字之和。

样例输入: 12345样例输出: 15 样例输入: 9999样例输出: 36

💡 题解(点击展开)

思路: 用取模技巧。N % 10 得到最后一位。N / 10 删除最后一位。重复直到 N 变为 0。

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

int main() {
    int n;
    cin >> n;

    int digitSum = 0;
    while (n > 0) {
        digitSum += n % 10;  // 加上最后一位
        n /= 10;             // 删除最后一位
    }

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

关键点:

  • n % 10 提取个位数(如 12345 % 10 = 5)
  • n /= 10 是整数除法,删除最后一位(如 12345 / 10 = 1234)
  • 循环持续到 n = 0(所有数字都已提取)
  • 追踪:12345 → +5 → 1234 → +4 → 123 → +3 → 12 → +2 → 1 → +1 → 0。总和 = 15 ✓

🏆 挑战题


挑战 2.2.11 — 考拉兹序列 从 N 开始的考拉兹序列规则如下:

  • N 是偶数:下一项 = N / 2
  • N 是奇数:下一项 = N × 3 + 1
  • N = 1 时停止

读取 N,打印整个序列(包括 N 和 1),以及到达 1 需要的步数。

样例输入: 6 样例输出:

6 3 10 5 16 8 4 2 1
Steps: 8
💡 题解(点击展开)

思路: 用 while 循环,不断应用规则直到到达 1,同时计数步数。

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

int main() {
    long long n;
    cin >> n;

    int steps = 0;
    cout << n;         // 打印起始数字

    while (n != 1) {
        if (n % 2 == 0) {
            n = n / 2;
        } else {
            n = n * 3 + 1;
        }
        cout << " " << n;  // 打印下一个数字
        steps++;
    }
    cout << "\n";
    cout << "Steps: " << steps << "\n";

    return 0;
}

关键点:

  • long long——即使从小数开始,序列中间值也可能很大(如 N=27 会到达 9232!)
  • 考拉兹猜想称这个序列总会到达 1,但对所有 N 尚未被证明
  • 循环前打印 N(作为起始值),之后每步打印新值

挑战 2.2.12 — 质数判断 读取 N(2 ≤ N ≤ 10^6),若 N 是质数打印 prime,否则打印 composite

质数是只有 1 和它本身两个因数的数。

样例输入: 17输出: prime 样例输入: 100输出: composite

💡 题解(点击展开)

思路: 试除法——检查 2 到 √N 中有没有能整除 N 的数。如果没有,N 是质数。只需检查到 √N,因为若 N = a×b 且 a > √N,则 b < √N(早就找到了)。

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

int main() {
    int n;
    cin >> n;

    bool isPrime = true;

    if (n < 2) {
        isPrime = false;
    } else {
        // 检查 2 到 sqrt(n) 的因数
        for (int i = 2; (long long)i * i <= n; i++) {
            if (n % i == 0) {
                isPrime = false;
                break;  // 找到因数,无需继续
            }
        }
    }

    cout << (isPrime ? "prime" : "composite") << "\n";
    return 0;
}

关键点:

  • i * i <= n 而不是 i <= sqrt(n),避免浮点精度问题(也稍快)
  • (long long)i * i 防止 i 较大时溢出(如 i = 1000000,i*i = 10^12)
  • break 找到任意因数后立即退出——无需继续检查
  • 时间复杂度:O(√N),处理 N 最大 10^6 非常轻松(√10^6 = 1000 次迭代)

挑战 2.2.13 — 评分最高的奶牛 读取 N(1 ≤ N ≤ 1000),然后读取 N 对(奶牛名字,评分)。找出评分最高的奶牛并打印其名字。

样例输入:

4
Bessie 95
Elsie 82
Moo 95
Daisy 88

样例输出: Bessie (若有并列,打印最先出现的那头。)

💡 题解(点击展开)

思路: 跟踪目前见过的最佳评分和名字,只在严格更高时更新。

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

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

    int n;
    cin >> n;

    string bestName;
    int bestRating = -1;  // 初始化为 -1,任何真实评分都高于它

    for (int i = 0; i < n; i++) {
        string name;
        int rating;
        cin >> name >> rating;

        if (rating > bestRating) {
            bestRating = rating;
            bestName = name;
        }
    }

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

关键点:

  • 初始化 bestRating = -1(或 INT_MIN),让第一头奶牛始终成为新的最佳
  • >(严格大于)而非 >=,这样并列时保留最先出现的(题目要求)
  • cin >> name >> rating 从同一行读取字符串和整数——完全有效
📖 第 2.3 章 ⏱️ 约 65 分钟 🎯 入门

第 2.3 章:函数与数组

📝 前置条件: 第 2.1 章和第 2.2 章(变量、循环、if/else)

随着程序越来越大,你需要组织代码的方式(函数)和存储数据集合的方式(数组和向量)。本章介绍这两者——竞赛编程中最重要的两个工具。


2.3.1 函数——是什么,为什么需要

🍕 食谱类比

📄 查看代码:🍕 食谱类比
函数就像一份披萨食谱:

- 输入(参数):   食材——面粉、奶酪、番茄
- 过程(函数体):烹饪步骤
- 输出(返回值):做好的披萨

就像你可以用一份食谱做很多张披萨,
你可以用不同的输入多次调用一个函数。

pizza("薄底", "培根")  → 一张披萨
pizza("厚底", "蘑菇")  → 另一张披萨

没有函数,如果你需要在程序的五个地方计算「这个数是不是质数」,你就得把同样的 10 行代码复制粘贴五次。然后如果发现了 bug,就要在五个地方全部修复!

什么时候写函数

以下情况使用函数:

  1. 程序中某段逻辑重复了 3 次以上
  2. 某段代码做的是一件清晰命名的事(如「检查是否质数」「计算距离」)
  3. 你的 main 变得太长、不好读

函数的基本语法

返回类型 函数名(参数1类型 参数1, 参数2类型 参数2, ...) {
    // 函数体
    return 值;  // 必须与返回类型匹配;void 函数可省略
}

你的第一批函数

📄 查看代码:你的第一批函数
#include <bits/stdc++.h>
using namespace std;

// ---- 函数定义(必须在使用前定义,或使用前置声明)----

// 接收一个整数,返回它的平方
int square(int x) {
    return x * x;
}

// 接收两个整数,返回较大的那个
int maxOf(int a, int b) {
    if (a > b) return a;
    else return b;
}

// void 函数:执行某件事但不返回值
void printSeparator() {
    cout << "====================\n";
}

// ---- 主函数 ----
int main() {
    cout << square(5) << "\n";       // 以 x=5 调用 square,打印 25
    cout << square(12) << "\n";      // 以 x=12 调用 square,打印 144

    cout << maxOf(7, 3) << "\n";     // 打印 7
    cout << maxOf(-5, -2) << "\n";   // 打印 -2

    printSeparator();                // 打印分隔线
    cout << "完成!\n";
    printSeparator();

    return 0;
}

🤔 为什么函数要在 main 之前?

C++ 从上到下读取你的文件。当它看到 square(5) 这样的调用时,需要已经知道 square 是什么。如果 squaremain 之后定义,编译器会说「我从没听说过 square!」

方案一: 把所有函数定义在 main 之上(最简单)。

方案二: 使用函数原型——前置声明,告诉编译器「这个函数存在,我之后再定义」:

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

int square(int x);       // 原型——只有签名,没有函数体
int maxOf(int a, int b); // 原型

int main() {
    cout << square(5) << "\n";   // OK!编译器知道 square 存在
    return 0;
}

// 完整定义可以在 main 之后
int square(int x) {
    return x * x;
}

int maxOf(int a, int b) {
    return (a > b) ? a : b;
}

2.3.2 void 函数 vs 返回值函数

void 函数:执行操作,不返回值

📄 查看代码:void 函数:执行操作,不返回值
// void 函数执行某个动作
void printLine(int n) {
    for (int i = 0; i < n; i++) {
        cout << "-";
    }
    cout << "\n";
}

// 调用 void 函数——直接调用,不要尝试接收返回值
printLine(10);    // 打印:----------
printLine(20);    // 打印:--------------------

返回值函数:计算并返回一个值

// 返回 x 的绝对值
int absoluteValue(int x) {
    if (x < 0) return -x;
    return x;
}

// 调用返回值函数——把结果存入变量或直接使用
int result = absoluteValue(-7);
cout << result << "\n";           // 7
cout << absoluteValue(-3) << "\n"; // 3(直接使用)

多个 return 语句

函数可以有多个 return 语句——执行到第一个就停止:

string classify(int n) {
    if (n < 0) return "负数";   // n < 0 时从这里退出
    if (n == 0) return "零";    // n == 0 时从这里退出
    return "正数";              // 其他情况从这里退出
}

cout << classify(-5) << "\n";   // 负数
cout << classify(0) << "\n";    // 零
cout << classify(3) << "\n";    // 正数

2.3.3 按值传递 vs 按引用传递

向函数传递变量时有两种方式。理解这一点至关重要。

按值传递(默认):函数得到一个副本

📄 查看代码:按值传递(默认):函数得到一个副本
void addOne_byValue(int x) {
    x++;  // 修改的是局部副本——原变量不变
    cout << "函数内部:" << x << "\n";  // 6
}

int main() {
    int n = 5;
    addOne_byValue(n);
    cout << "函数之后:" << n << "\n";   // 还是 5!原变量未改变
    return 0;
}

可以把它想成复印件:函数对复印件进行操作,对复印件的修改不影响原件。

按引用传递(&):函数操作原变量

📄 查看代码:按引用传递(&):函数操作原变量
void addOne_byRef(int& x) {  // & 表示「原变量的引用」
    x++;  // 直接修改原变量
    cout << "函数内部:" << x << "\n";  // 6
}

int main() {
    int n = 5;
    addOne_byRef(n);
    cout << "函数之后:" << n << "\n";   // 现在是 6!原变量被改变了
    return 0;
}

什么时候用哪种

用按传递,当……用按引用传递,当……
函数不应修改原变量函数需要修改原变量
小类型(int、double、char)需要返回多个值
你想要安全性(无副作用)大型对象(避免昂贵的复制)

通过引用返回多个值

C++ 函数只能 return 一个值。但可以通过引用参数「返回」多个值:

📄 C++ 函数只能 `return` 一个值。但可以通过引用参数「返回」多个值:
// 同时计算商和余数
void divmod(int a, int b, int& quotient, int& remainder) {
    quotient = a / b;
    remainder = a % b;
}

int main() {
    int q, r;
    divmod(17, 5, q, r);  // q 和 r 被函数修改
    cout << "17 / 5 = " << q << " 余 " << r << "\n";
    // 打印:17 / 5 = 3 余 2
    return 0;
}

2.3.4 递归

递归函数是调用自身的函数。它非常适合可以拆解成同类问题的更小版本的问题。

经典示例:阶乘

5! = 5 × 4 × 3 × 2 × 1 = 120
   = 5 × (4!)              ← 同样的问题,更小的输入!

💡 递归思维三步法:

  1. 找「自相似性」: 原问题能否拆解为同类型的更小问题?5! = 5 × 4!,4! 和 5! 是同一类型 ✓
  2. 确定边界条件: 最小的情况是什么?0! = 1,不能再拆解
  3. 写递推步骤: n! = n × (n-1)!,用更小的输入调用自身

这个思维过程在**图论算法(第 5.1 章)动态规划(第 6.1-6.3 章)**中会反复用到。

int factorial(int n) {
    if (n == 0) return 1;            // 边界条件:停止递归
    return n * factorial(n - 1);    // 递推步骤:缩小到更小的问题
}

追踪 factorial(4)

factorial(4)
= 4 * factorial(3)
= 4 * (3 * factorial(2))
= 4 * (3 * (2 * factorial(1)))
= 4 * (3 * (2 * (1 * factorial(0))))
= 4 * (3 * (2 * (1 * 1)))   ← 边界条件!
= 4 * (3 * (2 * 1))
= 4 * (3 * 2)
= 4 * 6
= 24  ✓

每个递归函数都需要:

  1. 边界条件 —— 停止递归(防止无限递归)
  2. 递推步骤 —— 用更小的输入调用自身

🐛 常见 Bug: 忘记边界条件 → 无限递归 → 「栈溢出」崩溃!


2.3.5 数组——固定大小的集合

🏠 邮箱类比

数组就像街道上一排邮箱:
- 所有邮箱大小相同(相同类型)
- 每个都有门号(下标,从 0 开始)
- 可以通过门号直接找到任意邮箱

Array Index Visual

图示:数组内存布局

Array Memory Layout

数组在内存中是连续存储的,每个元素紧挨着前一个,因此支持 O(1) 随机访问。

数组基础

📄 查看代码:数组基础
#include <bits/stdc++.h>
using namespace std;

int main() {
    // 声明一个有 5 个整数的数组(元素未初始化——是垃圾值!)
    int arr[5];

    // 逐一赋值
    arr[0] = 10;
    arr[1] = 20;
    arr[2] = 30;
    arr[3] = 40;
    arr[4] = 50;

    // 同时声明和初始化
    int nums[5] = {1, 2, 3, 4, 5};

    // 全部初始化为零
    int zeros[100] = {};          // 所有 100 个元素 = 0
    int zeros2[100];
    fill(zeros2, zeros2 + 100, 0); // 另一种方式

    // 访问和打印
    cout << arr[2] << "\n";       // 30

    // 遍历数组
    for (int i = 0; i < 5; i++) {
        cout << nums[i] << " ";   // 1 2 3 4 5
    }
    cout << "\n";

    return 0;
}

🐛 差一错误——头号数组 Bug

数组是从 0 开始索引的:如果你声明 int arr[5],合法下标是 0, 1, 2, 3, 4,不存在 arr[5]

📄 C++ 完整代码
int arr[5] = {10, 20, 30, 40, 50};

// 错误:循环从 i=0 到 i=5 包含——下标 5 不存在!
for (int i = 0; i <= 5; i++) {   // BUG:<= 5 应改为 < 5
    cout << arr[i];               // i=5 时崩溃或打印垃圾值
}

// 正确:循环从 i=0 到 i=4(i < 5 确保 i 永远到不了 5)
for (int i = 0; i < 5; i++) {    // i 的值:0, 1, 2, 3, 4 ✓
    cout << arr[i];               // 始终有效
}

这就是「差一错误」——越过末尾一个元素。这是竞赛编程中最常见的数组 bug。

🤔 为什么从 0 开始? C++ 从 C 继承了这个设计,而 C 的设计贴近硬件。下标实际上是从数组起点的偏移量。第一个元素在偏移量 0 处(从起点无偏移)。

大数组用全局变量

main 里的局部变量存在「栈」上,空间有限(约 1-8 MB)。竞赛编程中 N 最大可达 10^6,需要用全局数组(存在不同的内存区域,空间大得多):

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

const int MAXN = 1000001;  // 最大长度 + 1(常见惯例)
int arr[MAXN];              // 全局声明——大数组安全
// 全局数组会自动初始化为 0!

int main() {
    int n;
    cin >> n;
    for (int i = 0; i < n; i++) {
        cin >> arr[i];
    }
    return 0;
}

专业技巧: 全局数组会自动初始化为 0。局部数组不会——它们在赋值前包含垃圾值!


2.3.6 常见数组算法

求和、最大值、最小值

📄 查看代码:求和、最大值、最小值
int n;
cin >> n;

vector<int> arr(n);    // 马上学 vector;用法与数组相同
for (int i = 0; i < n; i++) cin >> arr[i];

// 求和
long long sum = 0;
for (int i = 0; i < n; i++) sum += arr[i];
cout << "总和:" << sum << "\n";

// 最大值(初始化为第一个元素!)
int maxVal = arr[0];
for (int i = 1; i < n; i++) {
    if (arr[i] > maxVal) maxVal = arr[i];
}
cout << "最大值:" << maxVal << "\n";

// 最小值(同理)
int minVal = arr[0];
for (int i = 1; i < n; i++) {
    minVal = min(minVal, arr[i]);  // min() 是内置函数
}
cout << "最小值:" << minVal << "\n";

复杂度分析:

  • 时间:O(N)——每个算法只需一次遍历
  • 空间:O(1)——只需几个额外变量(不计输入数组本身)

翻转数组

int arr[] = {1, 2, 3, 4, 5};
int n = 5;

// 从两端向中间交换元素
for (int i = 0, j = n - 1; i < j; i++, j--) {
    swap(arr[i], arr[j]);  // swap() 是内置函数
}
// arr 现在是 {5, 4, 3, 2, 1}

复杂度分析:

  • 时间:O(N)——每对元素交换一次,共 N/2 次
  • 空间:O(1)——原地交换,不需要额外数组

二维数组

二维数组就像一张表格或网格,非常适合地图、棋盘、矩阵:

📄 二维数组就像一张表格或网格,非常适合地图、棋盘、矩阵:
int grid[3][4];  // 3 行 4 列

// 填入 i * 10 + j
for (int r = 0; r < 3; r++) {
    for (int c = 0; c < 4; c++) {
        grid[r][c] = r * 10 + c;
    }
}

// 打印
for (int r = 0; r < 3; r++) {
    for (int c = 0; c < 4; c++) {
        cout << grid[r][c] << "\t";
    }
    cout << "\n";
}

输出:

0   1   2   3
10  11  12  13
20  21  22  23

2.3.7 向量(vector)——动态数组

数组有一个大限制:大小必须在编译时确定(或事先声明得足够大)。向量解决了这个问题——在程序运行时可以按需增减大小。

数组 vs 向量对比

特性数组向量
大小编译时固定运行时可增减
读 N 个元素必须硬编码或用 MAXNpush_back(x) 自然适用
内存位置栈(快速,有限)堆(稍慢,无限制)
语法int arr[5]vector<int> v(5)
竞赛编程首选固定大小的简单情况大多数问题

向量基础

📄 查看代码:向量基础
#include <bits/stdc++.h>
using namespace std;

int main() {
    // 创建空向量
    vector<int> v;

    // 用 push_back 在末尾添加元素
    v.push_back(10);    // v = [10]
    v.push_back(20);    // v = [10, 20]
    v.push_back(30);    // v = [10, 20, 30]

    // 按下标访问(与数组相同,从 0 开始)
    cout << v[0] << "\n";     // 10
    cout << v[1] << "\n";     // 20

    // 常用函数
    cout << v.size() << "\n"; // 3(元素个数)
    cout << v.front() << "\n"; // 10(第一个元素)
    cout << v.back() << "\n";  // 30(最后一个元素)
    cout << v.empty() << "\n"; // 0(false——不为空)

    // 删除最后一个元素
    v.pop_back();   // v = [10, 20]

    // 清空所有元素
    v.clear();      // v = []
    cout << v.empty() << "\n"; // 1(true——现在为空)

    return 0;
}

创建带初始值的向量

vector<int> zeros(10, 0);       // 十个 0:[0, 0, 0, 0, 0, 0, 0, 0, 0, 0]
vector<int> ones(5, 1);         // 五个 1:[1, 1, 1, 1, 1]
vector<int> primes = {2, 3, 5, 7, 11};  // 从列表初始化
vector<int> empty;              // 空向量

遍历向量

📄 查看代码:遍历向量
vector<int> v = {10, 20, 30, 40, 50};

// 方法一:下标循环(与数组相同)
for (int i = 0; i < (int)v.size(); i++) {
    cout << v[i] << " ";
}
cout << "\n";

// 方法二:范围 for 循环(更简洁,推荐)
for (int x : v) {
    cout << x << " ";
}
cout << "\n";

// 方法三:引用范围 for(需要修改时使用)
for (int& x : v) {
    x *= 2;  // 将每个元素就地翻倍
}

🤔 为什么下标循环用 (int)v.size() v.size() 返回的是无符号整数。把 int i 和无符号值比较,C++ 有时会产生意外行为(尤其是 i 为负时)。转成 (int) 是安全习惯。

USACO 中使用向量的标准模式

int n;
cin >> n;

vector<int> arr(n);         // 创建大小为 n 的向量
for (int i = 0; i < n; i++) {
    cin >> arr[i];          // 读入每个位置
}

// 现在处理 arr...
sort(arr.begin(), arr.end());  // 升序排序

二维向量

int rows = 3, cols = 4;
vector<vector<int>> grid(rows, vector<int>(cols, 0));  // 3×4 的全 0 网格

// 访问:grid[r][c]
grid[1][2] = 42;
cout << grid[1][2] << "\n";  // 42

2.3.8 向函数传递数组和向量

数组

向函数传递数组时,函数收到的是第一个元素的指针。函数内部的修改会影响原数组:

📄 向函数传递数组时,函数收到的是第一个元素的指针。**函数内部的修改会影响原数组:**
void fillSquares(int arr[], int n) {  // 数组参数的 arr[] 语法
    for (int i = 0; i < n; i++) {
        arr[i] = i * i;   // 修改的是原数组!
    }
}

int main() {
    int arr[5] = {0};
    fillSquares(arr, 5);
    // arr 现在是 {0, 1, 4, 9, 16}
    for (int i = 0; i < 5; i++) cout << arr[i] << " ";
    cout << "\n";
    return 0;
}

向量

向量传递给函数时默认是复制(大向量很慢!)。用 & 按引用传递:

📄 向量传递给函数时默认是**复制**(大向量很慢!)。用 `&` 按引用传递:
// 按值传递——复制整个向量(大向量时很慢)
void printVec(vector<int> v) {
    for (int x : v) cout << x << " ";
}

// 按引用传递——不复制,可修改原向量(用于输出参数)
void sortVec(vector<int>& v) {
    sort(v.begin(), v.end());
}

// 按 const 引用传递——不复制,不可修改(只读时最佳)
void printVecFast(const vector<int>& v) {
    for (int x : v) cout << x << " ";
}

专业技巧: 对于只读(不修改)的向量参数,始终写 const vector<int>&。这既避免了复制,也向读者表明函数不会修改向量。


⚠️ 第 2.3 章常见错误

#错误示例错在哪里修复方法
1数组越界差一数组大小为 n 时访问 arr[n]合法下标是 0 到 n-1,arr[n] 越界i < n 而不是 i <= n
2忘记递归边界条件int f(int n) { return n*f(n-1); }永不停止,导致栈溢出崩溃加上 if (n == 0) return 1;
3递归传入非法参数(如负数)factorial(-1)边界条件只处理 n == 0;负值导致无限递归 → 栈溢出调用前确保输入在合法范围;或在函数入口加防御:if (n < 0) return -1;
4向量按值传递性能问题void f(vector<int> v)复制整个向量,N 大时极慢const vector<int>& v
5局部数组未初始化int arr[100]; sum += arr[50];局部数组不会自动清零,包含垃圾值= {} 初始化或使用全局数组
6main 内数组太大int main() { int arr[1000000]; }超过栈内存限制(通常 1-8 MB),程序崩溃把大数组放在 main 外(全局)
7函数定义在调用之后main 调用 square(5)square 定义在 main 下面编译器无法识别未定义的函数在 main 之前定义函数,或使用函数原型

本章总结

📌 核心要点

概念要点为什么重要
函数定义一次,随处调用减少重复代码,提高可读性
返回类型intdoubleboolvoid不同场景用不同返回类型
按值传递函数得到副本,原变量不变安全,无副作用
按引用传递(&函数操作原变量可修改原变量,避免复制大对象
递归函数调用自身,必须有边界条件分治、回溯、DP 的基础
数组固定大小,从 0 开始,O(1) 随机访问竞赛编程中最基本的数据结构
全局数组避免栈溢出,自动初始化为 0N 超过 10^5 时必须用全局数组
vector<int>动态数组,可变大小竞赛编程首选数据容器
push_back / pop_back在末尾增/删O(1) 操作,构建动态集合的主要方式
前缀和O(N) 预处理,O(1) 查询区间求和的核心技巧,第 3.2 章深入讲解

❓ 常见问题

Q1:数组和向量哪个更好?

A:竞赛编程中两者都常用。经验法则:大小固定且已知时,全局数组最简单;大小动态变化或需要传给函数时,用 vector。很多选手默认用 vector,因为它更灵活、更不容易出错。

Q2:递归深度有限制吗?会崩溃吗?

A:有。每次函数调用都在栈上分配空间,默认栈大小约 1-8 MB。实际上,约 10^4 到 10^5 层递归是安全的。超出后程序会以「栈溢出」崩溃。竞赛中如果递归深度可能超过 10^4,考虑改用迭代(循环)方式。

Q3:什么时候用按引用传递(&)?

A:两种情况:① 需要在函数内部修改原变量;② 参数是大对象(如 vectorstring),想避免复制开销。对于 intdouble 这样的小类型,复制开销可忽略不计,按值传递即可。

Q4:函数可以返回数组或向量吗?

A:数组不能直接返回,但 vector 可以!vector<int> solve() { ... return result; } 完全有效。现代 C++ 编译器会优化返回过程(称为 RVO),实际上不会复制整个向量。

Q5:为什么前缀和数组多一个索引?prefix[n+1] 而不是 prefix[n]

A:prefix[0] = 0 是一个「哨兵值」,使得公式 prefix[R+1] - prefix[L] 在所有情况下都有效。没有这个哨兵,查询 [0, R] 时 L=0 需要特殊处理。这是一个非常常见的编程技巧:用额外的哨兵值简化边界处理。

🔗 与后续章节的联系

  • 第 3.1 章(STL 核心用法)将介绍 sortbinary_searchpair 等工具,让你用一行代码完成本章手动实现的操作
  • 第 3.2 章(前缀和)将深入探讨题目 2.3.10 中引入的前缀和技术,包括二维前缀和和差分数组
  • 第 5.1 章(图的基础)将以 2.3.4 节的递归基础为起点,讲解 DFS 和 BFS 等图遍历算法
  • 第 6.1-6.3 章(动态规划):「把大问题拆解成小问题」的核心思想与递归密切相关;本章的递归思维是重要的基础
  • 本章学到的函数封装数组/向量操作将在后续每一章中持续使用

练习题


🌡️ 热身题


热身 2.3.1 — 平方函数 编写一个函数 int square(int x),返回 x²。在 main 中读取一个整数并打印其平方。

样例输入: 7样例输出: 49

💡 题解(点击展开)

思路: 把函数写在 main 之上,用输入调用它。

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

int square(int x) {
    return x * x;
}

int main() {
    int n;
    cin >> n;
    cout << square(n) << "\n";
    return 0;
}

关键点:

  • 函数定义在 main 之上,编译器才能识别
  • return x * x;——C++ 计算 x * x 并返回结果
  • 若 x 可能很大(如最大 10^9),用 long long(x² 最大 10^18)

热身 2.3.2 — 两数之大 编写函数 int myMax(int a, int b),返回两个整数中较大的。在 main 中读取两个整数并打印较大值。

样例输入: 13 7样例输出: 13

💡 题解(点击展开)

思路: 比较 a 和 b,返回较大的那个。

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

int myMax(int a, int b) {
    if (a > b) return a;
    return b;
}

int main() {
    int a, b;
    cin >> a >> b;
    cout << myMax(a, b) << "\n";
    return 0;
}

关键点:

  • C++ 有内置的 max(a, b) 函数——但自己写一遍有助于理解概念
  • 三目运算符替代方案:return (a > b) ? a : b;

热身 2.3.3 — 倒序数组 声明一个恰好有 5 个整数的数组:{1, 2, 3, 4, 5}。倒序打印(不需要输入)。

期望输出:

5 4 3 2 1
💡 题解(点击展开)

思路: 从下标 4 循环到 0(倒序)。

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

int main() {
    int arr[5] = {1, 2, 3, 4, 5};

    for (int i = 4; i >= 0; i--) {
        cout << arr[i];
        if (i > 0) cout << " ";
    }
    cout << "\n";

    return 0;
}

关键点:

  • 从下标 n-1 = 4 循环到 0(包含),使用 i--
  • if (i > 0) cout << " " 避免末尾多一个空格——不过对 USACO 来说末尾空格通常可以接受

热身 2.3.4 — 向量求和 创建一个向量,用 push_back 依次加入 10、20、30、40、50,然后打印它们的总和。

期望输出: 150

💡 题解(点击展开)

思路: 创建空向量,push 5 个值,循环求和。

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

int main() {
    vector<int> v;
    v.push_back(10);
    v.push_back(20);
    v.push_back(30);
    v.push_back(40);
    v.push_back(50);

    long long sum = 0;
    for (int x : v) {
        sum += x;
    }

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

关键点:

  • 范围 for for (int x : v) 遍历所有元素
  • 一行替代方案:accumulate(v.begin(), v.end(), 0LL)

热身 2.3.5 — Hello N 次 编写一个 void 函数 sayHello(int n),打印「Hello!」恰好 n 次。读取 n 后从 main 调用它。

样例输入: 3 样例输出:

Hello!
Hello!
Hello!
💡 题解(点击展开)

思路: 一个内含 for 循环的 void 函数。

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

void sayHello(int n) {
    for (int i = 0; i < n; i++) {
        cout << "Hello!\n";
    }
}

int main() {
    int n;
    cin >> n;
    sayHello(n);
    return 0;
}

关键点:

  • void 表示函数不返回任何值——不需要 return 值;(可以用裸 return; 提前退出)
  • sayHello 参数中的 nmainn独立副本(按值传递)

🏋️ 核心练习题


题目 2.3.6 — 倒序输出 读取 N(1 ≤ N ≤ 100),然后读取 N 个整数,倒序打印。

样例输入:

5
1 2 3 4 5

样例输出: 5 4 3 2 1

💡 题解(点击展开)

思路: 存入向量,然后从最后一个下标到第一个倒序打印。

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

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

    int n;
    cin >> n;

    vector<int> arr(n);
    for (int i = 0; i < n; i++) {
        cin >> arr[i];
    }

    for (int i = n - 1; i >= 0; i--) {
        cout << arr[i];
        if (i > 0) cout << " ";
    }
    cout << "\n";

    return 0;
}

关键点:

  • vector<int> arr(n) 创建大小为 n 的向量(初始全为零)
  • 就像数组一样读入 arr[i]
  • n-1 循环到 0(包含)打印

题目 2.3.7 — 动态平均值 读取 N(1 ≤ N ≤ 100),然后逐个读取 N 个整数。每读入一个整数后,打印当前已读所有整数的平均值(保留 2 位小数)。

样例输入:

4
10 20 30 40

样例输出:

10.00
15.00
20.00
25.00
💡 题解(点击展开)

思路: 维护累计和,每次新读入后除以已读个数。

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

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

    int n;
    cin >> n;

    long long sum = 0;
    for (int i = 1; i <= n; i++) {
        int x;
        cin >> x;
        sum += x;
        double avg = (double)sum / i;
        cout << fixed << setprecision(2) << avg << "\n";
    }

    return 0;
}

关键点:

  • 每次新元素读入后更新 sumi 是已读元素个数
  • (double)sum / i——先强转为 double 再除,得到小数结果
  • fixed << setprecision(2) 强制输出恰好 2 位小数

题目 2.3.8 — 频率统计 读取 N(1 ≤ N ≤ 100)个整数,每个整数在 1 到 10 之间。打印 1 到 10 中每个值出现的次数。

样例输入:

7
3 1 2 3 3 1 7

样例输出:

1 appears 2 times
2 appears 1 times
3 appears 3 times
4 appears 0 times
5 appears 0 times
6 appears 0 times
7 appears 1 times
8 appears 0 times
9 appears 0 times
10 appears 0 times
💡 题解(点击展开)

思路: 用数组(或向量)作为「计数器」——下标 1 到 10 存储对应值的计数。

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

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

    int n;
    cin >> n;

    int freq[11] = {};  // 下标 0-10;我们用 1-10。全部初始化为 0。

    for (int i = 0; i < n; i++) {
        int x;
        cin >> x;
        freq[x]++;    // 值 x 的计数加一
    }

    for (int v = 1; v <= 10; v++) {
        cout << v << " appears " << freq[v] << " times\n";
    }

    return 0;
}

关键点:

  • freq[x]++ 是非常常见的模式——用作为频率数组的下标
  • 声明 freq[11],下标 0-10,使 freq[10](表示值 10)有效
  • int freq[11] = {}——= {} 将所有元素初始化为零

题目 2.3.9 — 两数之和 读取 N(1 ≤ N ≤ 100)个整数和目标值 T。如果数组中任意两个不同元素相加等于 T,打印 YES,否则打印 NO

样例输入:

5 9
1 4 5 6 3

(N=5,T=9,然后是数组) 样例输出: YES(因为 4+5=9 或 3+6=9)

💡 题解(点击展开)

思路: 检查所有满足 i < j 的对 (i, j),若任意一对之和等于 T,打印 YES。

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

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

    int n, t;
    cin >> n >> t;

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

    bool found = false;
    for (int i = 0; i < n && !found; i++) {
        for (int j = i + 1; j < n; j++) {
            if (arr[i] + arr[j] == t) {
                found = true;
                break;
            }
        }
    }

    cout << (found ? "YES" : "NO") << "\n";

    return 0;
}

关键点:

  • 内层循环从 j = i + 1 开始,避免同一元素使用两次,也避免重复检查相同的对
  • break + 外层循环的 && !found 条件确保找到匹配后立即停止
  • 这是 O(N²)——对 N ≤ 100 完全够用。N 最大 10^5 时,需要用集合(第 3.1 章)

题目 2.3.10 — 前缀和 读取 N(1 ≤ N ≤ 1000),然后读取 N 个整数。再读取 Q 个查询(1 ≤ Q ≤ 1000),每个查询有两个整数 L 和 R(0-indexed,包含两端)。对每个查询打印下标 L 到 R 的元素之和。

样例输入:

5
1 2 3 4 5
3
0 2
1 3
2 4

样例输出:

6
9
12
💡 题解(点击展开)

为什么不对每次查询直接求和? 暴力法:每次查询从 L 循环到 R,时间复杂度 O(N),所有查询总计 O(N×Q)。当 N=10^5,Q=10^5 时是 10^{10} 次操作——远超时限。

优化思路: 预处理一次,O(N);之后每次查询只需 O(1)。总计 O(N+Q),快得多!这就是前缀和的核心思想(第 3.2 章深入讲解)。

思路: 构建前缀和数组,prefix[i] = arr[0..i-1] 的和。则 L 到 R 的和 = prefix[R+1] - prefix[L],每次查询 O(1)。

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

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

    int n;
    cin >> n;

    vector<long long> arr(n), prefix(n + 1, 0);

    for (int i = 0; i < n; i++) {
        cin >> arr[i];
        prefix[i + 1] = prefix[i] + arr[i];  // 构建前缀和
    }
    // prefix[0] = 0
    // prefix[1] = arr[0]
    // prefix[2] = arr[0] + arr[1]
    // prefix[i] = arr[0] + arr[1] + ... + arr[i-1]

    int q;
    cin >> q;
    while (q--) {
        int l, r;
        cin >> l >> r;
        // l 到 r(含)的和 = prefix[r+1] - prefix[l]
        cout << prefix[r + 1] - prefix[l] << "\n";
    }

    return 0;
}

关键点:

  • prefix[i] = 前 i 个元素之和(prefix[0] = 0 是哨兵)
  • arr[L..R] 的和 = prefix[R+1] - prefix[L]——减去 L 之前的部分
  • 用样例验证:arr=[1,2,3,4,5],prefix=[0,1,3,6,10,15]。查询 [0,2]:prefix[3]-prefix[0]=6-0=6 ✓

复杂度分析:

  • 时间:O(N + Q)——预处理 O(N) + 每次查询 O(1) × Q
  • 空间:O(N)——前缀和数组占 N+1 的空间

💡 暴力 vs 优化: 暴力 O(N×Q) vs 前缀和 O(N+Q)。N=Q=10^5 时,前者需 10^{10} 次操作(超时),后者只需 2×10^5 次操作(瞬间)。


🏆 挑战题


挑战 2.3.11 — 数组旋转 读取 N(1 ≤ N ≤ 1000)和 K(0 ≤ K < N),再读取 N 个整数。打印向旋转 K 位后的数组(最后 K 个元素移到最前面)。

样例输入:

5 2
1 2 3 4 5

样例输出: 4 5 1 2 3

💡 题解(点击展开)

思路: 新数组在位置 i 的元素原来在位置 (i - K + N) % N。等价地,从下标 N-K 开始打印,循环回绕。

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

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

    int n, k;
    cin >> n >> k;

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

    // 从下标 (n - k) % n 开始打印 n 个元素,循环回绕
    for (int i = 0; i < n; i++) {
        int idx = (n - k + i) % n;
        cout << arr[idx];
        if (i < n - 1) cout << " ";
    }
    cout << "\n";

    return 0;
}

关键点:

  • 右旋 K 位:后 K 个元素先输出,再输出前 N-K 个
  • (n - k + i) % n 将新位置 i 映射到旧位置——% n 处理回绕
  • 验证:n=5,k=2。i=0: idx=(5-2+0)%5=3 → arr[3]=4。i=1: idx=4 → arr[4]=5。i=2: idx=0 → arr[0]=1。正确!

挑战 2.3.12 — 合并有序数组 读取 N₁,然后 N₁ 个已排序的整数;读取 N₂,然后 N₂ 个已排序的整数。打印合并后的有序数组。

样例输入:

3
1 3 5
4
2 4 6 8

样例输出: 1 2 3 4 5 6 8

💡 题解(点击展开)

思路: 双指针——一个指向每个数组的当前位置。每步取两个当前元素中较小的。

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

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

    int n1;
    cin >> n1;
    vector<int> a(n1);
    for (int i = 0; i < n1; i++) cin >> a[i];

    int n2;
    cin >> n2;
    vector<int> b(n2);
    for (int i = 0; i < n2; i++) cin >> b[i];

    // 双指针合并
    int i = 0, j = 0;
    vector<int> result;

    while (i < n1 && j < n2) {
        if (a[i] <= b[j]) {
            result.push_back(a[i++]);  // 取 a,i 前进
        } else {
            result.push_back(b[j++]);  // 取 b,j 前进
        }
    }
    // 某个数组可能还有剩余元素
    while (i < n1) result.push_back(a[i++]);
    while (j < n2) result.push_back(b[j++]);

    for (int idx = 0; idx < (int)result.size(); idx++) {
        cout << result[idx];
        if (idx < (int)result.size() - 1) cout << " ";
    }
    cout << "\n";

    return 0;
}

关键点:

  • 双指针 ij 同步扫描数组 ab
  • 始终取当前较小的元素——维持有序性
  • while 循环结束后,某个数组可能还有剩余元素——直接追加

挑战 2.3.13 — 气味距离 (USACO Bronze 风格)

N 头奶牛站成一排,每头奶牛有位置 p[i] 和气味半径 s[i]。如果两头奶牛之间的距离不超过它们半径之和,它们就能互相闻到对方。读取 N,然后 N 对(位置,半径),打印能互相闻到的奶牛对数。

样例输入:

4
1 2
5 1
8 3
15 1

样例输出: 1

(对 (1,2):距离=|5-8|=3,半径和=1+3=4,3≤4,YES。其余对距离均超过半径和,总计 1。)

💡 题解(点击展开)

思路: 检查所有满足 i < j 的对,对每对计算距离并与半径和比较。

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

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

    int n;
    cin >> n;

    vector<long long> pos(n), rad(n);
    for (int i = 0; i < n; i++) {
        cin >> pos[i] >> rad[i];
    }

    int count = 0;
    for (int i = 0; i < n; i++) {
        for (int j = i + 1; j < n; j++) {
            long long dist = abs(pos[i] - pos[j]);
            long long sumRad = rad[i] + rad[j];
            if (dist <= sumRad) {
                count++;
            }
        }
    }

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

关键点:

  • 检查满足 i < j 的对,避免同一对计数两次
  • abs(pos[i] - pos[j]) 计算位置间的绝对距离
  • 使用 long long 以防位置和半径较大
📖 第 2.4 章 ⏱️ 约 50 分钟 🎯 入门

第 2.4 章:结构体与类

📝 前置条件: 第 2.1–2.3 章(变量、控制流、函数、数组)

竞赛编程中,你经常需要把相关数据组合在一起——例如,一个点有 xy,一条边有两个端点和权重,一名学生有姓名和成绩。C++ 提供了 structclass 来把数据(和行为)绑定成单一类型。本章涵盖两者,重点关注竞赛编程中最重要的内容。


2.4.1 为什么要把数据组合在一起?

🎒 背包类比

📄 查看代码:🎒 背包类比
想象你要去旅行。你可以把每件东西分别拿着:
  - 左手:护照
  - 右手:手机
  - 口袋:钱包
  - 嘴里:机票 😬

或者,把所有东西放进一个背包:
  - backpack.passport ✅
  - backpack.phone ✅
  - backpack.wallet ✅
  - backpack.ticket ✅

struct/class 就是那个背包——把相关的东西组合在一个名字下。

没有结构体,如果你想存储 1000 名学生的名字和成绩,需要两个独立的数组:

string names[1000];
int scores[1000];
// 你必须手动保持下标同步——容易出错!

有了结构体,干净而安全:

struct Student {
    string name;
    int score;
};
Student students[1000];  // 每个学生自带名字和成绩

2.4.2 结构体基础

定义结构体

📄 查看代码:定义结构体
#include <bits/stdc++.h>
using namespace std;

struct Point {
    int x;
    int y;
};  // <-- 别忘了分号!

int main() {
    Point p;       // 声明一个 Point 变量
    p.x = 3;       // 用点号(.)访问成员
    p.y = 7;
    cout << "(" << p.x << ", " << p.y << ")" << endl;  // (3, 7)
    return 0;
}

初始化方式

// 方式一:聚合初始化(C++11,竞赛中最常用)
Point p1 = {3, 7};

// 方式二:指定初始化(C++20)
Point p2 = {.x = 3, .y = 7};

// 方式三:逐字段赋值
Point p3;
p3.x = 3;
p3.y = 7;

💡 竞赛技巧: 竞赛编程中,聚合初始化 {val1, val2, ...} 是最常用的风格——输入快,读起来简洁。

带构造函数的结构体

可以定义构造函数让创建实例更简洁:

📄 可以定义**构造函数**让创建实例更简洁:
struct Point {
    int x, y;

    // 构造函数
    Point(int _x, int _y) : x(_x), y(_y) {}
};

int main() {
    Point p(3, 7);  // 调用构造函数
    cout << p.x << " " << p.y << endl;  // 3 7
}

⚠️ 警告: 一旦定义了自定义构造函数,就不能再用 Point p;(无参数)了,除非同时提供默认构造函数或给参数设默认值。

struct Point {
    int x, y;

    Point() : x(0), y(0) {}            // 默认构造函数
    Point(int _x, int _y) : x(_x), y(_y) {}  // 有参构造函数
};

Point p1;       // OK——使用默认构造函数,x=0, y=0
Point p2(3, 7); // OK——使用有参构造函数

2.4.3 结构体在竞赛编程中的应用

存储图论问题中的边

📄 查看代码:存储图论问题中的边
struct Edge {
    int from, to, weight;
};

int main() {
    int n, m;
    cin >> n >> m;

    vector<Edge> edges(m);
    for (int i = 0; i < m; i++) {
        cin >> edges[i].from >> edges[i].to >> edges[i].weight;
    }
}

自定义比较来排序结构体

这在 USACO 题目中极为常见。你经常需要按某个特定字段排序。

方法一:在结构体内重载 operator<

📄 C++ 完整代码
struct Event {
    int start, end;

    // 按结束时间排序(贪心调度)
    bool operator<(const Event& other) const {
        return end < other.end;
    }
};

int main() {
    vector<Event> events = {{1, 4}, {3, 5}, {0, 6}, {5, 7}, {3, 8}, {5, 9}};
    sort(events.begin(), events.end());  // 自动使用 operator<

    for (auto& e : events) {
        cout << "[" << e.start << ", " << e.end << "] ";
    }
}

方法二:lambda 比较器(更灵活)

📄 C++ 完整代码
struct Event {
    int start, end;
};

int main() {
    vector<Event> events = {{1, 4}, {3, 5}, {0, 6}, {5, 7}};

    // 按开始时间排序
    sort(events.begin(), events.end(), [](const Event& a, const Event& b) {
        return a.start < b.start;
    });
}

方法三:编写比较函数

bool compareByEnd(const Event& a, const Event& b) {
    return a.end < b.end;
}

sort(events.begin(), events.end(), compareByEnd);

💡 竞赛技巧: 对大多数 USACO 题,方法一(运算符重载)在只有一种自然排序顺序时最简洁。当同一程序需要多种不同排序顺序时,用方法二(lambda)。

多关键字排序

有时需要先按一个字段排序,再用另一个字段打破平局:

struct Student {
    string name;
    int score;

    bool operator<(const Student& other) const {
        if (score != other.score) return score > other.score;  // 高分优先
        return name < other.name;  // 分数相同时按姓名字典序
    }
};

或用 tie() 写法更简洁:

struct Student {
    string name;
    int score;

    bool operator<(const Student& other) const {
        // 按分数降序,再按姓名升序
        return tie(other.score, name) < tie(score, other.name);
    }
};

💡 tie() 技巧: tie() 创建一个元组用于字典序比较。调换元素顺序可反转该字段的排序方向。这是竞赛编程中非常常见的技巧。

在集合和映射中使用结构体

如果想把结构体作为 setmap 的键,必须定义 operator<

📄 如果想把结构体作为 `set` 或 `map` 的键,**必须**定义 `operator<`:
struct Point {
    int x, y;
    bool operator<(const Point& other) const {
        return tie(x, y) < tie(other.x, other.y);
    }
};

set<Point> visited;
visited.insert({1, 2});
visited.insert({3, 4});

if (visited.count({1, 2})) {
    cout << "已访问过!" << endl;
}

在优先队列中使用结构体

📄 查看代码:在优先队列中使用结构体
struct State {
    int dist, node;

    // 最小堆:希望 dist 最小的在顶部
    // priority_queue 默认是最大堆,所以反转比较
    bool operator>(const State& other) const {
        return dist > other.dist;
    }
};

// 用 operator> 实现最小堆
priority_queue<State, vector<State>, greater<State>> pq;
pq.push({0, 1});   // 距离 0,节点 1
pq.push({5, 2});   // 距离 5,节点 2

auto top = pq.top();  // {0, 1}——距离最小

2.4.4 struct vs class——有什么区别?

实际情况是:struct 和 class 在 C++ 中几乎完全相同。唯一区别是默认访问级别

特性structclass
默认访问publicprivate
可以有方法?✅ 可以✅ 可以
可以有构造函数?✅ 可以✅ 可以
可以继承?✅ 可以✅ 可以
// 这两个功能上完全相同:

struct PointS {
    int x, y;  // 默认 public
};

class PointC {
public:          // 必须显式声明为 public
    int x, y;
};

什么时候用哪个?

struct 当……class 当……
简单数据容器有不变量的复杂对象
竞赛编程(几乎始终)面向对象设计项目
所有成员都是 public需要封装(私有数据)
想要最少的样板代码构建库或大型系统

💡 竞赛惯例: 竞赛编程中,始终用 struct。它更简单、更简短,而且几乎从不需要私有成员。几乎每个竞赛选手的代码里都是 struct


2.4.5 类——完整视角

尽管 struct 足以应付竞赛编程,理解 class 对更广泛的 C++ 知识很有价值。

访问修饰符

📄 查看代码:访问修饰符
class BankAccount {
private:    // 只能在类内部访问
    double balance;

public:     // 可以从任何地方访问
    BankAccount(double initial) : balance(initial) {}

    void deposit(double amount) {
        if (amount > 0) {
            balance += amount;
        }
    }

    void withdraw(double amount) {
        if (amount > 0 && amount <= balance) {
            balance -= amount;
        }
    }

    double getBalance() const {
        return balance;
    }
};

int main() {
    BankAccount acc(100.0);
    acc.deposit(50.0);
    acc.withdraw(30.0);
    cout << acc.getBalance() << endl;  // 120.0

    // acc.balance = 999999;  // 错误!balance 是私有的
}

为什么需要封装?

想象一台自动贩卖机:

- 公共接口:投币、按按钮、取饮料
- 私有内部:硬币计数器、库存、温控系统

你通过公共按钮与机器交互。
你不能直接伸手取饮料。

封装保护数据不被滥用。

竞赛编程中不需要这种级别的保护——代码速度更重要。但在软件工程中,它能防止大型代码库中的 bug。

成员函数(方法)

structclass 都可以有成员函数:

📄 `struct` 和 `class` 都可以有成员函数:
struct Rect {
    int width, height;

    int area() const {
        return width * height;
    }

    int perimeter() const {
        return 2 * (width + height);
    }

    bool contains(int x, int y) const {
        return x >= 0 && x < width && y >= 0 && y < height;
    }
};

int main() {
    Rect r = {10, 5};
    cout << "面积:" << r.area() << endl;      // 50
    cout << "周长:" << r.perimeter() << endl;  // 30
    cout << r.contains(3, 4) << endl;            // 1(true)
}

💡 方法名后的 const 方法名后的 const 关键字表示「这个方法不修改对象」。如果方法只读取数据,始终标记为 const——这是良好实践,在使用 const 引用时也是必须的。


2.4.6 竞赛编程中的进阶结构体模式

pair——内置的「两字段结构体」

C++ 提供了 pair 作为只需要两个字段时的轻量替代:

📄 C++ 提供了 `pair` 作为只需要两个字段时的轻量替代:
#include <bits/stdc++.h>
using namespace std;

int main() {
    pair<int, int> p = {3, 7};
    cout << p.first << " " << p.second << endl;  // 3 7

    // pair 有内置比较(字典序)
    vector<pair<int, int>> v = {{3, 1}, {1, 5}, {3, 0}, {1, 2}};
    sort(v.begin(), v.end());
    // 结果:{1, 2}, {1, 5}, {3, 0}, {3, 1}

    // 可以用 make_pair 或直接用花括号
    auto q = make_pair(10, 20);
}

什么时候用 pair vs 自定义 struct

pair用自定义 struct
只有 2 个字段3 个及以上字段
字段不需要有意义的名称需要描述性字段名
临时分组代码清晰度很重要

tuple——内置的「N 字段结构体」

tuple<int, string, double> t = {42, "Alice", 3.14};
cout << get<0>(t) << endl;  // 42
cout << get<1>(t) << endl;  // Alice

// 结构化绑定(C++17)——更简洁
auto [id, name, gpa] = t;
cout << name << " 的 GPA 是 " << gpa << endl;

💡 竞赛技巧: 超过 2 个字段时,带名称的 struct 几乎总是比 tuple 更易读。pair 可以随意用,但能用 struct 时就别用 tuple

arrayvector 成员的结构体

📄 查看代码:含 array 或 vector 成员的结构体
struct Graph {
    int n;
    vector<vector<int>> adj;

    Graph(int _n) : n(_n), adj(_n) {}

    void addEdge(int u, int v) {
        adj[u].push_back(v);
        adj[v].push_back(u);
    }
};

int main() {
    Graph g(5);
    g.addEdge(0, 1);
    g.addEdge(1, 2);

    for (int v : g.adj[1]) {
        cout << v << " ";  // 0 2
    }
}

2.4.7 常见错误

❌ 错误一:忘记 } 后的分号

struct Point {
    int x, y;
}   // ← 缺少分号!

int main() { ... }
// 编译器给出令人困惑的错误,指向 main()

修复: 结构体/类定义的右花括号后始终加 ;

❌ 错误二:运算符重载中忘记 const

struct Point {
    int x, y;
    // 错误——缺少 const
    bool operator<(const Point& other) {  // ← 在某些 STL 容器中无法工作
        return tie(x, y) < tie(other.x, other.y);
    }
};

修复: 比较运算符始终标记为 const

bool operator<(const Point& other) const {  // ✅
    return tie(x, y) < tie(other.x, other.y);
}

❌ 错误三:使用未初始化的结构体成员

struct State {
    int dist, node;
};

State s;
cout << s.dist;  // 未定义行为!可能是任意值

修复: 始终初始化,或提供默认值:

struct State {
    int dist = 0;
    int node = 0;
};

❌ 错误四:优先队列中 operator< 方向搞反

struct State {
    int dist;
    // 想实现最小堆,你可能会这样写:
    bool operator<(const State& other) const {
        return dist < other.dist;  // 这会得到最大堆!(与你想要的相反)
    }
};

修复:priority_queue 实现最小堆,要么反转比较,要么用 greater<>

📄 C++ 完整代码
// 方案 A:反转 operator<
bool operator<(const State& other) const {
    return dist > other.dist;  // dist 更大的优先级更低 → 最小堆
}
priority_queue<State> pq;

// 方案 B:定义 operator> 并使用 greater<>
bool operator>(const State& other) const {
    return dist > other.dist;
}
priority_queue<State, vector<State>, greater<State>> pq;

2.4.8 练习题

🟢 题目一:学生排名

读取 n 名学生(姓名和成绩),按成绩降序排序,打印排名。

输入:
3
Alice 85
Bob 92
Charlie 85

输出:
1. Bob 92
2. Alice 85
3. Charlie 85
💡 提示 定义一个带 operator< 的 struct,成绩不同时高分优先,相同时按姓名字典序。
✅ 题解
#include <bits/stdc++.h>
using namespace std;

struct Student {
    string name;
    int score;

    bool operator<(const Student& other) const {
        if (score != other.score) return score > other.score;
        return name < other.name;
    }
};

int main() {
    int n;
    cin >> n;
    vector<Student> students(n);
    for (int i = 0; i < n; i++) {
        cin >> students[i].name >> students[i].score;
    }
    sort(students.begin(), students.end());
    for (int i = 0; i < n; i++) {
        cout << i + 1 << ". " << students[i].name << " " << students[i].score << "\n";
    }
}

🟢 题目二:最近点对(一维)

给定数轴上 n 个点,找距离最小的点对。

输入:
5
7 1 4 9 2

输出:
1
(点 1 和点 2 之间)
💡 提示 排序后,答案是相邻元素的最小差值。
✅ 题解
#include <bits/stdc++.h>
using namespace std;

struct PointVal {
    int val, originalIndex;

    bool operator<(const PointVal& other) const {
        return val < other.val;
    }
};

int main() {
    int n;
    cin >> n;
    vector<PointVal> points(n);
    for (int i = 0; i < n; i++) {
        cin >> points[i].val;
        points[i].originalIndex = i;
    }
    sort(points.begin(), points.end());

    int minDist = INT_MAX;
    int bestI = 0, bestJ = 1;
    for (int i = 0; i + 1 < n; i++) {
        int d = points[i + 1].val - points[i].val;
        if (d < minDist) {
            minDist = d;
            bestI = i;
            bestJ = i + 1;
        }
    }
    cout << minDist << "\n";
    cout << "(点 " << points[bestI].val << " 和点 " << points[bestJ].val << " 之间)\n";
}

🟡 题目三:区间调度(贪心)

给定 n 个区间 [start, end],找不重叠区间的最大数量。

📄 给定 `n` 个区间 `[start, end]`,找不重叠区间的最大数量。
输入:
6
1 4
3 5
0 6
5 7
3 8
5 9

输出:
3
💡 提示 按结束时间排序区间。贪心地选择结束时间最早且与上一个已选区间不冲突的区间。
✅ 题解
#include <bits/stdc++.h>
using namespace std;

struct Interval {
    int start, end;

    bool operator<(const Interval& other) const {
        return end < other.end;
    }
};

int main() {
    int n;
    cin >> n;
    vector<Interval> intervals(n);
    for (int i = 0; i < n; i++) {
        cin >> intervals[i].start >> intervals[i].end;
    }
    sort(intervals.begin(), intervals.end());

    int count = 0, lastEnd = -1;
    for (auto& it : intervals) {
        if (it.start >= lastEnd) {
            count++;
            lastEnd = it.end;
        }
    }
    cout << count << "\n";
}

📋 本章总结

概念核心要点
struct组合相关数据;成员默认为 public
class与 struct 相同,但成员默认为 private
构造函数创建实例时调用的特殊函数
operator<sort()setmappriority_queue 支持你的类型
tie()简洁的多关键字比较技巧
pair内置的 2 字段结构体,支持字典序比较
const 方法标记不修改对象的方法

🎯 竞赛编程核心要点

  1. 竞赛中始终用 struct —— 更简单、更简短
  2. 掌握 operator< 重载 —— 几乎每道 USACO 题都会用到
  3. 多关键字排序用 tie() —— 简洁且不易出错
  4. 记得 const —— STL 兼容性的要求
  5. 初始化你的成员 —— 避免未定义行为
  6. 2 个字段用 pair,3 个及以上用自定义 struct —— 好的经验法则

✅ 第 2.4 章完成!
你现在知道如何创建自定义数据类型了——这是竞赛编程中组织数据的关键技能。接下来:强大的 STL 容器!

📖 第 2.5 章:分类讨论与矩形几何

⏱ 预计阅读时间:55 分钟 | 难度:🟢 入门(USACO Bronze 核心技能)


前置条件

  • 基本 C++ 语法(第 2.1~2.2 章)
  • if/else 条件语句
  • 会读入整数、输出结果
  • 知道平面直角坐标系中 x 表示横坐标、y 表示纵坐标

🎯 学习目标

学完本章后,你将能够:

  1. 识别需要分类讨论的题目,系统枚举所有情形
  2. 用「先画图、再列边界、最后写代码」的方法避免漏情况
  3. 处理坐标轴上的矩形交叉、覆盖、面积问题
  4. 判断两个矩形是否相交,计算交集面积
  5. 用差分思想处理网格上的矩形覆盖计数
  6. 读懂并实现 USACO Bronze 中常见的矩形覆盖题

本章学习路线

本章分成两条主线:

  1. 分类讨论:当题目存在多种情况时,如何不遗漏、不重复地处理。
  2. 矩形几何:当题目出现坐标、矩形、覆盖、相交时,如何把图形问题转成 min/max 和面积计算。

建议初学者按下面的顺序学习:

先画图理解题意
    ↓
找出关键边界:相等、刚好接触、完全覆盖、完全分离
    ↓
用 if/else 或 min/max 表达这些情况
    ↓
用样例和自己构造的边界数据检查代码

💡 本章的重点不是记住某个模板,而是学会把「看起来复杂的情况」拆成一组清晰、可验证的小情况。


2.5.1 分类讨论(Casework)

什么是分类讨论?

当问题的答案取决于若干个「互斥情形」时,需要逐一枚举每种情形,分别处理。

举几个简单例子:

  • 一个数是正数、负数还是零?
  • 两个区间是不相交、刚好相接,还是有重叠?
  • 一个点在矩形内部、边界上,还是外部?
  • 一个矩形是否被另一个矩形挡住了一部分?

这些问题都有一个共同点:不能只写一个公式就结束,而是要先判断当前输入属于哪一种情况。

核心原则:

  1. 完备性:不遗漏任何情形。
  2. 互斥性:不同情形之间最好不要重叠;如果会重叠,要明确优先级。
  3. 边界验证:特别检查 等于刚好接触刚好覆盖 这些边界值。

分类讨论的通用步骤

做分类讨论题时,可以按照下面四步来:

  1. 画图:把题目中的对象画出来,比如区间、矩形、运动方向。
  2. 找边界:思考什么时候答案会发生变化,例如两个区间端点相等时。
  3. 列情况:把所有可能情况列出来,尽量保证没有遗漏。
  4. 写条件:把每种情况翻译成 C++ 的 if / else if / else

例如:

if (第一种情况) {
    // 处理第一种情况
} else if (第二种情况) {
    // 处理第二种情况
} else {
    // 剩余情况
}

这里最后的 else 很重要,它经常用于兜底:当前面所有明确列出的情况都不满足时,就进入最后一种情况。

示例:一维区间分类

题目描述:

给定两个闭区间 [a, b][c, d],其中 a <= bc <= d。请判断这两个区间的关系:

  1. 完全不相交:两个区间之间有空隙。
  2. 仅端点相接:两个区间只有一个端点相同,没有长度上的重叠。
  3. 一个包含另一个:其中一个区间完全覆盖另一个区间。
  4. 部分重叠:两个区间有公共部分,但谁也没有完全包含谁。

先不要急着写代码,可以先画图:

情况 1:完全不相交
[a-----b]   [c-----d]
或
[c-----d]   [a-----b]

情况 2:仅端点相接
[a-----b]
      [c-----d]     其中 b == c

情况 3:一个区间包含另一个区间
[a-----------b]
   [c-----d]

情况 4:部分重叠
[a--------b]
     [c--------d]

接下来把图翻译成条件。

关键边界:<== 的区别

  • 如果 b < c,说明 [a,b][c,d] 左边,并且中间有空隙。
  • 如果 b == c,说明两个区间只在一个点相接。
  • 如果 b > c,说明它们至少在横轴上有重叠或包含关系。

因此,在分类时,通常要先处理更特殊的情况,比如「完全不相交」和「端点相接」。

💡 CPP 代码(31 行)
#include <bits/stdc++.h>
using namespace std;

int main() {
    long long a, b, c, d;
    cin >> a >> b >> c >> d;  // 区间 [a,b] 和 [c,d]

    // 如果输入不保证左端点 <= 右端点,可以先修正
    if (a > b) swap(a, b);
    if (c > d) swap(c, d);

    if (b < c || d < a) {
        cout << "完全不相交\n";
    } else if (b == c || d == a) {
        cout << "仅端点相接\n";
    } else if (a <= c && d <= b) {
        cout << "[c,d] 在 [a,b] 内部(或相等)\n";
    } else if (c <= a && b <= d) {
        cout << "[a,b] 在 [c,d] 内部(或相等)\n";
    } else {
        cout << "部分重叠\n";
    }
    return 0;
}

初学者常见疑问:为什么顺序很重要?

if / else if 中,程序会从上到下检查条件,遇到第一个满足的条件就执行,不会再继续检查后面的条件。

例如两个区间 [1, 5][1, 5]

  • 它既满足 [c,d] 在 [a,b] 内部
  • 也满足 [a,b] 在 [c,d] 内部

这种情况并不是错误,而是说明两个区间相等。我们只要在题目要求的分类里确定一个输出即可。

💡 如果题目对「完全相等」有单独要求,就应该在包含判断之前先写 if (a == c && b == d)

分类讨论的技巧

技巧 1:排序后减少情形数

对输入排序,可以将多种对称情形合并:

// 例:确保 a <= b(避免分别处理 a<b 和 a>b 两种情形)
if (a > b) swap(a, b);

技巧 2:用 min/max 简化区间操作

如果只关心两个区间的重叠长度,可以不必列出所有关系,而是直接算交集。

// 两区间 [a,b] 和 [c,d] 的交集长度
long long overlap = max(0LL, min(b, d) - max(a, c));

这个公式的含义是:

  • 交集左端点是两个左端点中较大的那个:max(a, c)
  • 交集右端点是两个右端点中较小的那个:min(b, d)
  • 如果右端点小于等于左端点,说明没有正长度重叠。

注意:上面公式适合把区间看成 [a,b)[c,d) 这种「左闭右开」长度模型。若是闭区间并且要统计整数点个数,公式会略有不同。

技巧 3:画图 + 列举边界

分类讨论题必须动手画图,列出所有边界情况逐一验证。至少要检查:

  • 完全分离
  • 刚好相接
  • 部分重叠
  • 一个完全包含另一个
  • 两者完全相等

技巧 4:优先写「容易判断的反面」

有些题目直接判断「相交」比较麻烦,但判断「不相交」很简单。

例如两个区间不相交只有两种情况:

b <= c || d <= a

那么相交就是它的反面:

!(b <= c || d <= a)

矩形相交也会用到同样的思想。

USACO Bronze 典型:方向判断

题目描述(USACO 风格):

一头奶牛站在二维平面上,初始位置是 (0, 0),初始方向朝北。接下来会给出 N 条指令,每条指令可能是下面三种之一:

  1. left:原地向左转 90 度。
  2. right:原地向右转 90 度。
  3. forward D:沿当前朝向前进 D 步。

请输出执行完所有指令后,奶牛的最终坐标。

这个题看起来像模拟题,但本质上也需要分类讨论:

  • 当前方向是北、东、南、西中的哪一个?
  • 当前指令是左转、右转,还是前进?
  • 如果前进,不同方向对应的 xy 变化不同。

朴素写法:直接分类讨论

最直观的写法是:

if (dir == "N") y += d;
else if (dir == "S") y -= d;
else if (dir == "E") x += d;
else if (dir == "W") x -= d;

这种写法容易理解,但如果转向逻辑也都用字符串分类,代码会比较长。

更简洁的写法:方向编号

我们可以把四个方向按顺时针编号:

0: North
1: East
2: South
3: West

这样右转就是编号 +1,左转就是编号 -1。为了避免出现负数,可以写成:

// 左转:dir - 1
// 加 4 是为了避免负数,再对 4 取模
(dir + 3) % 4
💡 CPP 代码(28 行)
#include <bits/stdc++.h>
using namespace std;

int main() {
    // 方向编码:0=N, 1=E, 2=S, 3=W
    // dx/dy 对应四个方向的位移
    int dx[] = {0, 1, 0, -1};
    int dy[] = {1, 0, -1, 0};
    
    int x = 0, y = 0, dir = 0;  // 初始位置和方向
    int n; cin >> n;
    
    while (n--) {
        string cmd; cin >> cmd;
        if (cmd == "left") {
            dir = (dir + 3) % 4;   // 左转 = (dir-1+4)%4
        } else if (cmd == "right") {
            dir = (dir + 1) % 4;   // 右转
        } else {
            int d; cin >> d;
            x += dx[dir] * d;
            y += dy[dir] * d;
        }
    }
    
    cout << x << " " << y << "\n";
    return 0;
}

2.5.2 矩形几何

坐标系中的矩形表示

竞赛中的矩形通常是轴对齐矩形,也就是矩形的边和坐标轴平行。它一般用两个对角顶点表示:

  • 左下角:(x1, y1)
  • 右上角:(x2, y2)

通常满足:

x1 < x2, y1 < y2

画出来是这样:

y
↑
y2 +----------+
   |          |
y1 +----------+→ x
   x1        x2

如果题目没有保证 x1 < x2y1 < y2,读入后要先修正:

if (x1 > x2) swap(x1, x2);
if (y1 > y2) swap(y1, y2);

面积为什么是 (x2 - x1) * (y2 - y1)

矩形的宽度是横坐标差:

width = x2 - x1

矩形的高度是纵坐标差:

height = y2 - y1

所以面积是:

area = (x2 - x1) * (y2 - y1)

例如矩形 (1, 2)(4, 6)

  • 宽度:4 - 1 = 3
  • 高度:6 - 2 = 4
  • 面积:3 * 4 = 12

一个重要习惯:把矩形看成半开区间

在很多竞赛题里,矩形 (x1, y1, x2, y2) 表示:

x1 <= x < x2
y1 <= y < y2

也就是左边界和下边界算进去,右边界和上边界不算进去。这样做的好处是:

  • 面积正好是 (x2 - x1) * (y2 - y1)
  • 两个矩形只在边界接触时,交集面积是 0
  • 用差分数组统计格子时更方便。

例如矩形 (0,0)-(2,2) 覆盖的是下面 4 个 1×1 小格子:

(0,1) (1,1)
(0,0) (1,0)

它的面积是 2 * 2 = 4

两矩形是否相交

关键规则: 两矩形不相交,当且仅当一个矩形完全在另一个的左边、右边、下边或上边。

也就是说,如果出现下面任意一种情况,它们就没有正面积交集:

  1. A 在 B 的左边:ax2 <= bx1
  2. B 在 A 的左边:bx2 <= ax1
  3. A 在 B 的下边:ay2 <= by1
  4. B 在 A 的下边:by2 <= ay1

用图理解其中一种:

A 在 B 左边:

A             B
+---+         +---+
|   |         |   |
+---+         +---+
ax2 <= bx1

所以判断相交时,可以先判断「不相交」,再取反。

// 矩形 A: (ax1,ay1)-(ax2,ay2)
// 矩形 B: (bx1,by1)-(bx2,by2)
bool intersects(long long ax1, long long ay1, long long ax2, long long ay2,
                long long bx1, long long by1, long long bx2, long long by2) {
    // A 完全在 B 左边 or 右边 or 下边 or 上边
    if (ax2 <= bx1 || bx2 <= ax1 || ay2 <= by1 || by2 <= ay1)
        return false;
    return true;
}

💡 这里使用 <=,表示两个矩形如果只是边贴边,没有面积重叠,就不算相交。

交集面积

计算两个矩形的交集,可以把问题拆成两个一维问题:

  • x 方向的重叠长度
  • y 方向的重叠长度

交集矩形的左下角和右上角分别是:

交集左下角:max(两个左边界), max(两个下边界)
交集右上角:min(两个右边界), min(两个上边界)

对应到代码就是:

long long intersection_area(long long ax1, long long ay1, long long ax2, long long ay2,
                            long long bx1, long long by1, long long bx2, long long by2) {
    long long ix1 = max(ax1, bx1);
    long long iy1 = max(ay1, by1);
    long long ix2 = min(ax2, bx2);
    long long iy2 = min(ay2, by2);

    if (ix2 <= ix1 || iy2 <= iy1) return 0;  // 不相交,或只是边界相接
    return (ix2 - ix1) * (iy2 - iy1);
}

逐步追踪示例:

矩形 A: (1,1)-(4,4)
矩形 B: (2,2)-(6,5)

第 1 步:求交集左下角
x = max(1,2) = 2
y = max(1,2) = 2
所以左下角是 (2,2)

第 2 步:求交集右上角
x = min(4,6) = 4
y = min(4,5) = 4
所以右上角是 (4,4)

第 3 步:求面积
宽 = 4 - 2 = 2
高 = 4 - 2 = 2
面积 = 2 * 2 = 4

再看一个没有面积重叠的例子:

矩形 A: (0,0)-(2,2)
矩形 B: (2,0)-(4,2)

它们只是边界接触。
交集宽度 = min(2,4) - max(0,2) = 2 - 2 = 0
所以交集面积是 0。

两矩形的并集面积

两个矩形的并集面积,指的是至少被其中一个矩形覆盖的面积。

如果直接写:

并集面积 = A 的面积 + B 的面积

会出现一个问题:重叠部分被算了两次

所以要把重叠部分减掉一次:

并集面积 = A 的面积 + B 的面积 - A 与 B 的交集面积

这就是最简单的容斥思想。

long long union_area(long long ax1, long long ay1, long long ax2, long long ay2,
                     long long bx1, long long by1, long long bx2, long long by2) {
    long long areaA = (ax2 - ax1) * (ay2 - ay1);
    long long areaB = (bx2 - bx1) * (by2 - by1);
    long long inter = intersection_area(ax1, ay1, ax2, ay2, bx1, by1, bx2, by2);
    return areaA + areaB - inter;
}

举例:

矩形 A 面积 = 12
矩形 B 面积 = 10
重叠面积 = 4

如果直接相加:12 + 10 = 22,重叠部分多算了一次。
正确答案:12 + 10 - 4 = 18

💡 对两个矩形求并集面积时,这个公式很好用;但如果是三个或更多矩形,重叠关系会更复杂,通常要用扫描线、差分数组或更系统的容斥方法。

矩形内点的判断

判断一个点和矩形的关系时,要先看题目要求:

  • 如果题目问点是否在矩形「内部或边界上」,使用 <=
  • 如果题目只问点是否在严格内部,使用 <
  • 如果题目要把边界单独分类,就要分别判断「边界」和「内部」。
bool point_in_rect(long long px, long long py,
                   long long x1, long long y1, long long x2, long long y2) {
    return x1 <= px && px <= x2 && y1 <= py && py <= y2;
}

例如矩形 (0,0)-(3,2)

  • (1,1) 在内部。
  • (0,1) 在左边界上。
  • (3,2) 在右上角边界上。
  • (4,1) 在外部。

进阶:N 个矩形的覆盖面积(差分法)

当有大量矩形叠加时,如果逐个格子暴力标记,有时会比较慢;如果坐标范围不大,可以用二维差分数组统计每个格子被覆盖的次数。

先理解一维差分

假设有一个数组,我们想让区间 [l, r) 全部加 1,可以这样做:

diff[l]++;
diff[r]--;

最后从左到右做前缀和,就能知道每个位置被加了多少次。

二维差分是同样的思想,只是从「线段」变成「矩形」。

二维差分如何给矩形加 1?

如果要给矩形 [x1, x2) × [y1, y2) 覆盖区域加 1,做四个角的修改:

diff[y1][x1]++;
diff[y1][x2]--;
diff[y2][x1]--;
diff[y2][x2]++;

可以把它理解为:

  • 从左下角开始加上影响。
  • 到右边界之后取消影响。
  • 到上边界之后取消影响。
  • 右上角被取消了两次,所以要补回来一次。
💡 C++ 代码(45 行)
#include <bits/stdc++.h>
using namespace std;

const int MAX_COORD = 1000;                 // 坐标范围假设为 0~1000
int diff[MAX_COORD + 2][MAX_COORD + 2];     // 多开空间,方便处理 x2/y2

// 给矩形 [x1,x2) × [y1,y2) 覆盖区域 +1
void add_rect(int x1, int y1, int x2, int y2) {
    diff[y1][x1]++;
    diff[y1][x2]--;
    diff[y2][x1]--;
    diff[y2][x2]++;
}

int main() {
    int n;
    cin >> n;

    while (n--) {
        int x1, y1, x2, y2;
        cin >> x1 >> y1 >> x2 >> y2;
        add_rect(x1, y1, x2, y2);
    }

    // 二维前缀和还原每个格子的覆盖次数
    for (int y = 0; y <= MAX_COORD; y++) {
        for (int x = 0; x <= MAX_COORD; x++) {
            if (y > 0) diff[y][x] += diff[y - 1][x];
            if (x > 0) diff[y][x] += diff[y][x - 1];
            if (y > 0 && x > 0) diff[y][x] -= diff[y - 1][x - 1];
        }
    }

    // 统计被至少一个矩形覆盖的 1×1 格子
    // 左下角最大只能到 (999,999),因为格子 [999,1000) 才是最后一格
    long long covered = 0;
    for (int y = 0; y < MAX_COORD; y++) {
        for (int x = 0; x < MAX_COORD; x++) {
            if (diff[y][x] > 0) covered++;
        }
    }

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

为什么统计的是格子,不是点?

坐标范围是 0~1000 时,格子通常是:

横坐标区间:[0,1), [1,2), ..., [999,1000)
纵坐标区间:[0,1), [1,2), ..., [999,1000)

所以共有 1000 × 10001×1 格子。数组下标 diff[y][x] 可以理解为左下角是 (x,y) 的那个小格子的覆盖次数。


⚠️ 常见错误

错误原因修复方案
边界情形遗漏只考虑「一般情况」,忽略端点相等逐一列出所有情形,验证 <= vs <
把相接误判为相交两个矩形只是边贴边,交集面积其实是 0判断不相交时使用 <=,不是 <
整数溢出坐标值大时 x * y 超过 int面积、答案统一用 long long
矩形方向假设错误没有保证 x1 < x2y1 < y2读入后必要时用 swap 修正
差分数组越界添加矩形时坐标超出范围,或没有给 x2/y2 多开空间数组通常多开一格,例如 1002 × 1002
把点和格子混淆坐标点数量和 1×1 格子数量不是一回事覆盖面积题统计的是格子,不是坐标点
行列下标写反diff[y][x]diff[x][y] 混用统一约定第一维是 y,第二维是 x,并全程保持一致

调试建议

写完矩形题后,可以自己构造下面几组数据检查:

  1. 完全不相交:答案应该是 0
  2. 刚好边界相接:交集面积也应该是 0
  3. 一个矩形完全包含另一个矩形:交集面积应该等于小矩形面积。
  4. 两个矩形完全相同:交集面积应该等于任意一个矩形的面积。
  5. 只有部分重叠:手算一次宽度、高度,再和程序输出对比。

本章小结

本章最重要的思想可以总结为三句话:

  1. 分类讨论先画图:不要凭感觉写条件,先把所有情况画出来。
  2. 矩形问题拆成两个方向:分别处理 x 方向和 y 方向,再把结果合起来。
  3. 覆盖问题优先想差分:当矩形很多、坐标范围不大时,二维差分比逐块暴力更清晰。

常用公式:

// 矩形面积
area = (x2 - x1) * (y2 - y1);

// 两矩形交集边界
ix1 = max(ax1, bx1);
iy1 = max(ay1, by1);
ix2 = min(ax2, bx2);
iy2 = min(ay2, by2);

// 两矩形交集面积
intersection = max(0LL, ix2 - ix1) * max(0LL, iy2 - iy1);

💪 练习题

🟢 题目 1:矩形相交判断

题目描述:

给定两个轴对齐矩形 A 和 B。每个矩形用四个整数表示:

x1 y1 x2 y2

其中 (x1, y1) 是矩形左下角,(x2, y2) 是矩形右上角,并且 x1 < x2y1 < y2

请判断两个矩形是否存在正面积交集

  • 如果存在,输出交集面积。
  • 如果不存在,输出 0

注意:如果两个矩形只是边界相接,例如一个矩形的右边界等于另一个矩形的左边界,那么交集面积为 0

输入格式:

ax1 ay1 ax2 ay2
bx1 by1 bx2 by2

输出格式:

一个整数,表示两个矩形的交集面积

样例输入:

1 1 4 4
2 2 6 5

样例输出:

4

样例解释:

两个矩形的交集是 (2,2)-(4,4),宽度为 2,高度为 2,面积为 4

✅ 完整解答
#include <bits/stdc++.h>
using namespace std;

int main() {
    long long ax1, ay1, ax2, ay2;
    long long bx1, by1, bx2, by2;
    cin >> ax1 >> ay1 >> ax2 >> ay2;
    cin >> bx1 >> by1 >> bx2 >> by2;

    long long ix1 = max(ax1, bx1);
    long long iy1 = max(ay1, by1);
    long long ix2 = min(ax2, bx2);
    long long iy2 = min(ay2, by2);

    if (ix2 <= ix1 || iy2 <= iy1) {
        cout << 0 << "\n";
    } else {
        cout << (ix2 - ix1) * (iy2 - iy1) << "\n";
    }

    return 0;
}

🟡 题目 2:奶牛放牧区域(USACO Bronze 风格)

题目描述:

农夫 John 有 N 块矩形牧场。每块牧场的边都与坐标轴平行,且坐标都是 0~1000 之间的整数。

每块牧场用左下角 (x1, y1) 和右上角 (x2, y2) 表示。它覆盖所有满足下面条件的 1×1 小格子:

x1 <= x < x2
y1 <= y < y2

如果多块牧场重叠,重叠部分只计算一次。请你求出被至少一块牧场覆盖的总格数,也就是覆盖总面积。

输入格式:

N
x1 y1 x2 y2
x1 y1 x2 y2
...

输出格式:

一个整数,表示被覆盖的 1×1 格子数量

样例输入:

3
0 0 2 2
1 1 3 3
4 4 5 5

样例输出:

8

样例解释:

  • 第一块牧场面积是 4
  • 第二块牧场面积是 4
  • 它们重叠了 1 个格子。
  • 第三块牧场面积是 1
  • 总覆盖面积是 4 + 4 - 1 + 1 = 8
✅ 完整解答

思路: 用差分数组标记每个 1×1 格子被覆盖的次数,最后统计覆盖次数 >= 1 的格子数。

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

int diff[1002][1002];

int main() {
    int n;
    cin >> n;

    while (n--) {
        int x1, y1, x2, y2;
        cin >> x1 >> y1 >> x2 >> y2;

        // 给矩形 [x1,x2) × [y1,y2) 加 1
        diff[y1][x1]++;
        diff[y1][x2]--;
        diff[y2][x1]--;
        diff[y2][x2]++;
    }

    long long ans = 0;
    for (int y = 0; y <= 1000; y++) {
        for (int x = 0; x <= 1000; x++) {
            if (y > 0) diff[y][x] += diff[y - 1][x];
            if (x > 0) diff[y][x] += diff[y][x - 1];
            if (y > 0 && x > 0) diff[y][x] -= diff[y - 1][x - 1];

            // 只统计左下角为 (x,y) 的 1×1 格子,最大到 (999,999)
            if (x < 1000 && y < 1000 && diff[y][x] > 0) ans++;
        }
    }

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

🔴 题目 3:矩形分类问题(综合)

题目描述:

给定一个轴对齐矩形和 N 个点。请判断每个点与矩形的关系:

  1. 如果点在矩形四条边上,输出 边界
  2. 如果点在矩形内部,但不在边界上,输出 内部
  3. 如果点不在矩形内部,也不在边界上,输出 外部

矩形由左下角 (x1, y1) 和右上角 (x2, y2) 给出,保证 x1 < x2y1 < y2

输入格式:

x1 y1 x2 y2
N
px py
px py
...

输出格式:

对每个点输出一行:边界内部外部

样例输入:

0 0 4 3
5
1 1
0 2
4 3
5 1
2 3

样例输出:

内部
边界
边界
外部
边界

样例解释:

  • (1,1) 的横纵坐标都严格在矩形范围内,所以是内部。
  • (0,2) 在左边界上。
  • (4,3) 是右上角顶点,属于边界。
  • (5,1) 在矩形外部。
  • (2,3) 在上边界上。
✅ 完整解答
#include <bits/stdc++.h>
using namespace std;

int main() {
    long long x1, y1, x2, y2;
    cin >> x1 >> y1 >> x2 >> y2;

    int n;
    cin >> n;

    while (n--) {
        long long px, py;
        cin >> px >> py;

        bool on_vertical_edge = (px == x1 || px == x2) && (y1 <= py && py <= y2);
        bool on_horizontal_edge = (py == y1 || py == y2) && (x1 <= px && px <= x2);
        bool on_edge = on_vertical_edge || on_horizontal_edge;

        bool inside = x1 < px && px < x2 && y1 < py && py < y2;

        if (on_edge) cout << "边界\n";
        else if (inside) cout << "内部\n";
        else cout << "外部\n";
    }

    return 0;
}

🔴 题目 4:USACO Bronze — 遮挡广告牌

题目描述:

一面墙上贴了两块广告牌,随后一辆卡车停在墙前,挡住了其中一部分区域。广告牌和卡车在墙上的投影都可以看作轴对齐矩形。

现在给出:

  1. 第一块广告牌的矩形坐标。
  2. 第二块广告牌的矩形坐标。
  3. 卡车遮挡区域的矩形坐标。

请计算两块广告牌仍然可见的总面积。

注意:

  • 两块广告牌之间不会相互遮挡。
  • 卡车可能不遮挡某块广告牌。
  • 卡车可能只遮挡广告牌的一部分。
  • 卡车也可能完全遮挡某块广告牌。
  • 如果卡车只和广告牌边界接触,不会减少可见面积。

输入格式:

三行,每行 4 个整数:

x1 y1 x2 y2
x1 y1 x2 y2
x1 y1 x2 y2

分别表示广告牌 1、广告牌 2、卡车遮挡区域。每个矩形都由左下角和右上角表示。

输出格式:

一个整数,表示两块广告牌仍然可见的总面积

样例输入:

1 2 3 5
6 0 10 4
2 1 8 3

样例输出:

17

样例解释:

广告牌 1 的面积是 (3 - 1) * (5 - 2) = 6。卡车与广告牌 1 的交集是 (2,2)-(3,3),面积是 1,所以广告牌 1 可见面积是 5

广告牌 2 的面积是 (10 - 6) * (4 - 0) = 16。卡车与广告牌 2 的交集是 (6,1)-(8,3),面积是 4,所以广告牌 2 可见面积是 12

总可见面积是 5 + 12 = 17

✅ 完整解答

核心思路: 分类讨论 + 容斥。总面积 = 广告牌1面积 + 广告牌2面积 - 广告牌1被卡车遮挡面积 - 广告牌2被卡车遮挡面积。每块广告牌与卡车的遮挡面积用矩形交集公式计算。

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

// 计算两个矩形的交集面积
ll inter_area(ll ax1, ll ay1, ll ax2, ll ay2,
              ll bx1, ll by1, ll bx2, ll by2) {
    ll ix1 = max(ax1, bx1), iy1 = max(ay1, by1);
    ll ix2 = min(ax2, bx2), iy2 = min(ay2, by2);
    if (ix2 <= ix1 || iy2 <= iy1) return 0;
    return (ix2 - ix1) * (iy2 - iy1);
}

int main() {
    ll a1x1, a1y1, a1x2, a1y2;  // 广告牌1
    ll a2x1, a2y1, a2x2, a2y2;  // 广告牌2
    ll tx1, ty1, tx2, ty2;       // 卡车
    cin >> a1x1 >> a1y1 >> a1x2 >> a1y2;
    cin >> a2x1 >> a2y1 >> a2x2 >> a2y2;
    cin >> tx1 >> ty1 >> tx2 >> ty2;

    ll area1 = (a1x2 - a1x1) * (a1y2 - a1y1);
    ll area2 = (a2x2 - a2x1) * (a2y2 - a2y1);
    ll covered1 = inter_area(a1x1, a1y1, a1x2, a1y2, tx1, ty1, tx2, ty2);
    ll covered2 = inter_area(a2x1, a2y1, a2x2, a2y2, tx1, ty1, tx2, ty2);

    cout << area1 + area2 - covered1 - covered2 << "\n";
    return 0;
}

复杂度分析: 时间 O(1),空间 O(1)。只需计算几个矩形的面积和交集,常数时间完成。


💡 章节联系: 矩形几何是 USACO Bronze 的高频题型之一(约占 15%),常与前缀和(差分数组)结合。掌握后可直接用于解决「覆盖面积」「重叠判断」等问题。

📖 第 2.6 章:位运算

⏱ 预计阅读时间:55 分钟 | 难度:🟢 入门(USACO Bronze/Silver 必备)


前置条件

  • 整数的二进制表示(知道什么是二进制即可)
  • 基本 C++ 语法

🎯 学习目标

学完本章后,你将能够:

  1. 看懂一个整数的二进制表示,并知道每一位像一个「开关」
  2. 使用六种位运算符进行高效整数操作
  3. 用位运算检查、设置、清除、翻转二进制位
  4. 用整数作为「集合」(状压表示)进行集合运算
  5. 枚举一个整数所有子集(状压 DP 基础)
  6. 理解常见的位运算技巧(lowbit、快速判断 2 的幂等)
给初学者的阅读路线:
如果你第一次接触位运算,不要急着背模板。请按下面顺序理解:
1. 先把二进制位想象成一排开关:每一位不是关(0)就是开(1)。
2. 再理解 `&`、`|`、`^` 是在「同一位置的两个开关」之间做判断。
3. 然后学习 `1 << k`:它的意思是在第 `k` 个位置放一个单独亮着的开关。
4. 最后再看集合、枚举子集和 DP。它们其实都是在操作这一排开关。

2.6.1 为什么要学位运算?

计算机存整数时,底层不是十进制的 0, 1, 2, 3...,而是一串二进制位。例如:

十进制 13 = 二进制 1101

可以把 1101 想成 4 个小灯泡:

位置:   3 2 1 0
二进制: 1 1 0 1
含义:   开 开 关 开

每一位都有自己的价值:

位置价值是否亮着贡献
第 0 位111
第 1 位200
第 2 位414
第 3 位818

所以 13 = 8 + 4 + 1

位运算就是直接操作这些小灯泡。普通加减乘除是对整个数字做运算;位运算是对数字内部的每一盏灯单独操作。

竞赛中的典型应用:

应用场景
状态压缩(状压 DP)用一个整数表示一个集合或状态
快速幂利用指数的二进制分解
枚举子集遍历 N 个元素的所有子集
树状数组的 lowbitx & (-x) 找最低有效位
奇偶判断x & 1x % 2 更快

一个最小例子:为什么 x & 1 能判断奇偶?

一个数是否为奇数,只看二进制的最后一位:

12 = 1100,最后一位是 0,所以是偶数
13 = 1101,最后一位是 1,所以是奇数

1 的二进制只有最后一位是 1:

13 & 1
1101
0001
----
0001  -> 结果不是 0,所以 13 是奇数

12 & 1
1100
0001
----
0000  -> 结果是 0,所以 12 是偶数

这就是位运算的思维方式:不看整个数,只盯住你关心的那一位


2.6.2 六种基本位运算符

运算符名称用法示例(二进制)
&按位与 (AND)a & b1100 & 1010 = 1000
|按位或 (OR)a | b1100 | 1010 = 1110
^按位异或 (XOR)a ^ b1100 ^ 1010 = 0110
~按位非 (NOT)~a~1100 = 0011...
<<左移a << k0001 << 2 = 0100
>>右移a >> k1100 >> 1 = 0110

直觉记忆

AND  &:两个都是1才得1(取交集:都有才保留)
OR   |:有一个是1就得1(取并集:有一个就保留)
XOR  ^:不同为1,相同为0(取差集/翻转:不一样才保留)
NOT  ~:0变1,1变0(取补集)

一位一位地算,别整串一起看

初学位运算时,最容易犯的错误是把 1100 & 1010 当成普通数字计算。实际上它是「同一列对同一列」计算:

  1100
& 1010
------
  1000

从右到左逐列看:

位置上面的位下面的位& 结果原因
第 0 位000不是两个 1
第 1 位010不是两个 1
第 2 位100不是两个 1
第 3 位111两个都是 1

同理,| 是「只要有一个 1 就亮」,^ 是「两边不一样才亮」。

左移和右移:把灯泡整体搬家

<<>> 不改变每一位本身,而是把整排二进制位往左或往右移动。

1       = 0001
1 << 1  = 0010 = 2
1 << 2  = 0100 = 4
1 << 3  = 1000 = 8

所以 1 << k 表示:只让第 k 位亮起来

这也是后面所有模板的基础:

1 << k

可以理解成一个「只选中第 k 位的小工具」。


2.6.3 常用位操作模板

先记住一个核心工具:

1 << k

它表示一个只有第 k 位是 1 的数字。注意第 k 位从右往左数,并且从 0 开始:

k:       3 2 1 0
1 << 0 = 0 0 0 1
1 << 1 = 0 0 1 0
1 << 2 = 0 1 0 0
1 << 3 = 1 0 0 0

假设 x = 13 = 1101,我们想操作第 2 位,可以先造出面具 1 << 2 = 0100。后面所有操作,都是让 x 和这个面具做运算。

这一节怎么读?
不要先背完整模板。我们会把每个函数单独拆开:先说它想解决什么问题,再看代码,最后用一个具体数字一步一步算。全部理解后,再把它们汇总成一张完整工具表。

1. 检查第 k 位是否为 1

问题: 给你一个整数 x,怎样知道它的第 k 位是不是亮着?

bool check(int x, int k) {
    return (x >> k) & 1;
}

这段代码分成两步:

  1. x >> k:把第 k 位移动到最右边。
  2. & 1:只检查最右边这一位是不是 1

为什么要移动到最右边?因为最右边最容易检查。只要和 1&,就能保留最后一位,其他位都会变成 0

假设:

x = 13 = 1101
k = 2

2 位是从右往左数的第三位:

位置:   3 2 1 0
x:      1 1 0 1
             ↑
          第 2 位

计算过程:

x >> 2:
1101 >> 2 = 0011

再 & 1:
0011
0001
----
0001

结果是 1,说明 13 的第 2 位是亮着的。

如果检查第 1 位:

x >> 1:
1101 >> 1 = 0110

再 & 1:
0110
0001
----
0000

结果是 0,说明第 1 位没有亮。

2. 将第 k 位设为 1(置位)

问题: 怎样把某一盏灯打开?如果它本来就是开的,就保持打开。

int set_bit(int x, int k) {
    return x | (1 << k);
}

这里的关键是 |:只要两边有一个是 1,结果就是 1

所以我们先用 1 << k 做一个只在第 k 位为 1 的面具,再用 | 把这一位打开。

假设:

x = 13 = 1101
k = 1
mask = 1 << 1 = 0010

计算:

  1101
| 0010
------
  1111 = 15

1 位原来是 0,现在被打开了。

如果这一位本来就是 1,也不会有问题:

x = 13 = 1101
k = 2
mask = 0100

  1101
| 0100
------
  1101

2 位本来就是 1,置位后仍然是 1

3. 将第 k 位设为 0(清位)

问题: 怎样把某一盏灯关掉?如果它本来就是关的,就保持关闭。

int clear_bit(int x, int k) {
    return x & ~(1 << k);
}

这段代码看起来比置位难一点,因为多了一个 ~。我们一步一步拆开:

  1. 1 << k:制造一个只有第 k 位是 1 的面具。
  2. ~(1 << k):把面具反过来,让第 k 位变成 0,其他位变成 1
  3. x & ~(1 << k):用 & 保留其他位,同时把第 k 位变成 0

假设:

x = 13 = 1101
k = 2

先得到面具:

1 << 2 = 0100

反过来:

~0100 = ...1011

为了方便观察,我们只看最低 4 位,就是:

1011

然后做 &

  1101
& 1011
------
  1001 = 9

2 位被关掉了,其他位保持原样。

如果第 k 位本来就是 0,清位也不会改变它:

x = 13 = 1101
k = 1
mask 反过来后最低 4 位是 1101

  1101
& 1101
------
  1101

4. 翻转第 k

问题: 怎样让某一盏灯“反过来”?开着就关掉,关着就打开。

int flip_bit(int x, int k) {
    return x ^ (1 << k);
}

这里用的是 ^。它的规则是:相同得 0,不同得 1

更适合记成:

某一位 ^ 0:保持不变
某一位 ^ 1:发生翻转

所以 x ^ (1 << k) 的意思是:只让第 k 位和 1 做异或,其他位和 0 做异或,因此只有第 k 位会翻转。

例 1:把第 1 位从 0 翻成 1

x = 13 = 1101
k = 1
mask = 0010

  1101
^ 0010
------
  1111 = 15

例 2:把第 2 位从 1 翻成 0

x = 13 = 1101
k = 2
mask = 0100

  1101
^ 0100
------
  1001 = 9

所以翻转不是固定变成 1,也不是固定变成 0,而是:原来是什么,就变成相反的状态

5. 判断一个数是否是 2 的幂

问题: 怎样快速判断 x 是不是 1, 2, 4, 8, 16... 这样的数?

bool is_power_of_two(int x) {
    return x > 0 && (x & (x - 1)) == 0;
}

先观察 2 的幂在二进制中的样子:

十进制二进制
10001
20010
40100
81000

它们都有一个共同特点:二进制里只有一个 1

再看 x - 1 会发生什么。以 x = 8 为例:

x     = 1000
x - 1 = 0111

&

  1000
& 0111
------
  0000

结果是 0,说明 8 只有一个 1,所以它是 2 的幂。

再看一个不是 2 的幂的数,比如 12

x     = 1100
x - 1 = 1011

  1100
& 1011
------
  1000

结果不是 0,说明 12 不只一个 1,所以不是 2 的幂。

为什么还要写 x > 0?因为 0 不是 2 的幂。如果不加这个条件,0 & -1 也会得到 0,容易误判。

6. 获取最低有效位:lowbit

问题: 怎样找到一个数从右往左数,第一盏亮着的灯?

int lowbit(int x) {
    return x & (-x);
}

lowbit(x) 返回的不是第几个位置,而是这一位对应的数值。

例如:

x = 12 = 1100

从右往左看:

位置:   3 2 1 0
x:      1 1 0 0
           ↑
      最右边的 1 在第 2 位

2 位的价值是 4,所以:

lowbit(12) = 4

用补码计算就是:

12 的二进制:  0000 1100
-12(补码):  1111 0100
12 & (-12):   0000 0100 = 4

这在树状数组中很常见。你可以暂时先记住:lowbit(x) 能拿到 x 最右边那个 1 代表的值。

7. 清除最低有效位

问题: 怎样把最右边那个 1 删除掉?

int clear_lowest(int x) {
    return x & (x - 1);
}

它和判断 2 的幂用的是同一个核心技巧。

假设:

x = 12 = 1100
x - 1 = 11 = 1011

计算:

  1100
& 1011
------
  1000 = 8

可以看到,x 最右边的那个 1 被清掉了。

再来一个例子:

x = 10 = 1010
x - 1 = 9 = 1001

  1010
& 1001
------
  1000 = 8

这件事有什么用?如果我们不断清除最低位的 1,就能统计一个数里面有多少个 1

8. 统计二进制中 1 的个数:popcount

问题: 怎样知道一个整数的二进制里有几盏灯亮着?

C++ 提供了内置函数:

int count_ones(int x) {
    return __builtin_popcount(x);
}

例如:

x = 13 = 1101

里面有三位是 1,所以:

count_ones(13) = 3

如果不用内置函数,也可以用刚才的 clear_lowest 思想手写:

int count_ones_manual(int x) {
    int cnt = 0;
    while (x) {
        x &= x - 1;
        cnt++;
    }
    return cnt;
}

x = 13 = 1101 为例:

第 1 次:1101 -> 1100,cnt = 1
第 2 次:1100 -> 1000,cnt = 2
第 3 次:1000 -> 0000,cnt = 3

循环结束时,说明所有 1 都被清掉了,所以原来一共有 31

常用模板汇总

前面我们已经把每个函数都单独拆开讲过了。真正写题时,可以把下面这组函数当成工具箱:

💡 CPP 代码(36 行)
// ===== 单个位的操作(第 k 位,从 0 开始计数)=====

// 检查第 k 位是否为 1
bool check(int x, int k) { return (x >> k) & 1; }

// 将第 k 位设为 1(置位)
int set_bit(int x, int k) { return x | (1 << k); }

// 将第 k 位设为 0(清位)
int clear_bit(int x, int k) { return x & ~(1 << k); }

// 翻转第 k 位(0→1,1→0)
int flip_bit(int x, int k) { return x ^ (1 << k); }


// ===== 常用技巧 =====

// 判断 x 是否为 2 的幂(且 x > 0)
bool is_power_of_two(int x) { return x > 0 && (x & (x - 1)) == 0; }

// 获取最低有效位(lowbit,树状数组核心)
int lowbit(int x) { return x & (-x); }

// 清除最低有效位
int clear_lowest(int x) { return x & (x - 1); }

// 统计 x 中 1 的个数(popcount)
int count_ones(int x) { return __builtin_popcount(x); }     // 内置函数
// 或手动:
int count_ones_manual(int x) {
    int cnt = 0;
    while (x) { x &= x - 1; cnt++; }  // 每次清除最低位的1
    return cnt;
}

本节总结

函数作用核心想法
check(x, k)检查第 k右移到最后,再 & 1
set_bit(x, k)把第 k 位设为 1用 `
clear_bit(x, k)把第 k 位设为 0& 配合反面具关掉开关
flip_bit(x, k)翻转第 k^ 让目标位反过来
is_power_of_two(x)判断是否是 2 的幂2 的幂只有一个 1
lowbit(x)找最低有效位的值x & -x 保留最右边的 1
clear_lowest(x)清除最右边的 1x & (x - 1)
count_ones(x)统计 1 的个数内置函数或反复清最低位

2.6.4 用整数表示集合(状压)

当元素数量 N ≤ 30 时,可以用一个 int 整数的 N 个二进制位表示一个集合。

编码规则: 第 i 个元素在集合中 ↔ 第 i 位为 1。

把集合想象成签到表

如果有 5 个学生,编号为 0, 1, 2, 3, 4,我们可以用 5 个开关表示他们有没有被选中:

学生编号: 4 3 2 1 0
是否选中: 1 0 1 0 1

这表示选中了学生 {0, 2, 4}。写成二进制就是 10101,十进制是 21

这样做的好处是:一个整数就能保存一个集合,而且加入、删除、检查元素都能用 O(1) 完成。

💡 CPP 代码(16 行)
// 集合 {0, 2, 4} 表示为:
// 二进制:10101 = 21

int S = 0;           // 空集
S = set_bit(S, 0);   // 加入元素 0:S = 00001 = 1
S = set_bit(S, 2);   // 加入元素 2:S = 00101 = 5
S = set_bit(S, 4);   // 加入元素 4:S = 10101 = 21

// 检查元素 2 是否在集合中
bool has2 = check(S, 2);  // true

// 删除元素 2
S = clear_bit(S, 2);      // S = 10001 = 17

// 集合大小
int size = __builtin_popcount(S);  // 2

集合运算

int A = 0b1100;  // {2, 3}
int B = 0b1010;  // {1, 3}

int inter = A & B;    // 交集:0b1000 = {3}
int unio  = A | B;    // 并集:0b1110 = {1, 2, 3}
int diff  = A & ~B;   // 差集 A-B:0b0100 = {2}
int xor_s = A ^ B;    // 对称差:0b0110 = {1, 2}

这些集合运算其实和数学里的集合完全对应:

数学集合操作位运算写法小学生理解
交集A & B两个集合都选中的人,才留下
并集`AB`
差集A & ~B在 A 里,但不在 B 里的人
对称差A ^ B只出现一次的人

例如 A = {2, 3}B = {1, 3}

A 和 B 都有 3,所以交集是 {3}
A 或 B 出现过 1、2、3,所以并集是 {1,2,3}
A 有但 B 没有的是 2,所以 A-B 是 {2}
只出现一次的是 1 和 2,所以对称差是 {1,2}

2.6.5 枚举所有子集

N 个元素的集合有 2^N 个子集,可以用 for (int s = 0; s < (1 << n); s++) 枚举。

为什么是 2^N?因为每个元素只有两种选择:

不选这个元素 -> 0
选择这个元素 -> 1

如果有 3 个元素,每个元素都有选/不选两种状态,总数就是:

2 × 2 × 2 = 8

也就是 2^3 个子集。

先看 n = 3 的完整例子

s二进制表示的子集
0000{}
1001{0}
2010{1}
3011{0,1}
4100{2}
5101{0,2}
6110{1,2}
7111{0,1,2}

所以枚举整数 02^n - 1,其实就是枚举了所有开关组合,也就是所有子集。

💡 CPP 代码(11 行)
// 枚举 n 个元素的所有子集
void enumerate_subsets(int n) {
    for (int s = 0; s < (1 << n); s++) {
        cout << "子集 " << s << "(二进制 " << bitset<4>(s) << "):{";
        for (int i = 0; i < n; i++) {
            if (check(s, i)) cout << i << " ";
        }
        cout << "}\n";
    }
}
// enumerate_subsets(3) 输出 2^3 = 8 个子集

枚举某集合 S 的所有子集

有时我们不是枚举全集 {0,1,2,...,n-1} 的所有子集,而是枚举一个已经给定的集合 S 的所有子集。

例如:

S = 10110 = {1,2,4}

我们只想枚举由 {1,2,4} 组成的子集,不能出现元素 03

// 枚举 S 的所有非空子集(时间:O(3^n),见下面说明)
for (int sub = S; sub > 0; sub = (sub - 1) & S) {
    // 处理子集 sub
    // ...
}
// 原理:sub = (sub - 1) & S 每次在 S 的范围内减 1,跳过不属于 S 的位

为什么 sub = (sub - 1) & S 不会跑出 S?

sub - 1 会让二进制发生变化,可能产生一些不属于 S 的 1。再 & S 一次,就像用 S 做过滤器:只有 S 里本来是 1 的位置才能保留下来。

S = 10110 为例,枚举过程大致是:

10110
10100
10010
10000
00110
00100
00010

每一步都只使用了 S 中允许的位。


2.6.6 实战例题:状压 + 位运算

前面我们学的是「工具」,这一节开始把工具放进题目里。做位运算题时,可以按照这个顺序思考:

  1. 每一位代表什么? 是一个元素、一个城市,还是一个开关?
  2. 1 表示什么,0 表示什么? 例如 1 表示已选择,0 表示未选择。
  3. 要做的操作对应哪个位运算? 加入用 |,删除用 & ~,检查用 & 或右移。
  4. 状态数量有多少? 如果有 n 个开关,通常会有 2^n 种状态。

例题:集合和问题

给定 N 个数字(N ≤ 20),找有多少个子集的和恰好等于目标值 target。

为什么可以暴力枚举?

N ≤ 20 时,一共有 2^20 = 1,048,576 个子集,大约一百万个。每个子集再扫一遍最多 20 个数,大约两千万次操作,C++ 通常可以接受。

状态设计:

  • s 是一个二进制集合。
  • s 的第 i 位为 1,表示选择了 a[i]
  • 枚举所有 s,就等于枚举所有可能的选择方案。
💡 CPP 代码(20 行)
#include <bits/stdc++.h>
using namespace std;

int main() {
    int n, target;
    cin >> n >> target;
    vector<int> a(n);
    for (int& x : a) cin >> x;
    
    int ans = 0;
    for (int s = 0; s < (1 << n); s++) {
        int sum = 0;
        for (int i = 0; i < n; i++)
            if (check(s, i)) sum += a[i];
        if (sum == target) ans++;
    }
    
    cout << ans << "\n";
}
// 复杂度:O(2^N × N),N=20 时约 2×10^7,可以通过

例题:旅行商问题(状压 DP 基础)

N 个城市(N ≤ 20),从城市 0 出发经过所有城市恰好一次回到 0,求最短路。

这个题比集合和更难,因为它不仅要记录「访问过哪些城市」,还要记录「现在停在哪个城市」。

状态设计:

dp[mask][v]

含义是:

  • mask 表示已经访问过的城市集合。
  • v 表示当前所在城市。
  • dp[mask][v] 表示在这种情况下的最短距离。

例如 mask = 01011 表示已经访问过城市 {0,1,3};如果 v = 3,就表示当前停在城市 3。

转移思路:

如果当前在城市 u,下一步可以去一个还没访问过的城市 v。这时:

new_mask = mask | (1 << v);

意思是把城市 v 标记为「已经访问」。

💡 CPP 代码(41 行)
#include <bits/stdc++.h>
using namespace std;

int n;
int dist[20][20];
int dp[1 << 20][20];  // dp[mask][v] = 访问了 mask 中的城市,当前在 v,的最短距离

int main() {
    cin >> n;
    for (int i = 0; i < n; i++)
        for (int j = 0; j < n; j++)
            cin >> dist[i][j];
    
    // 初始化
    for (auto& row : dp) fill(row, row + n, INT_MAX / 2);
    dp[1][0] = 0;  // 从城市 0 出发,mask=1(只访问了0)
    
    // 枚举所有状态
    for (int mask = 1; mask < (1 << n); mask++) {
        for (int u = 0; u < n; u++) {
            if (dp[mask][u] == INT_MAX / 2) continue;
            if (!check(mask, u)) continue;
            
            // 尝试移动到下一个未访问的城市 v
            for (int v = 0; v < n; v++) {
                if (check(mask, v)) continue;  // 已访问
                int new_mask = set_bit(mask, v);
                dp[new_mask][v] = min(dp[new_mask][v], dp[mask][u] + dist[u][v]);
            }
        }
    }
    
    // 所有城市都访问后(mask = (1<<n)-1),回到城市 0
    int ans = INT_MAX;
    int full = (1 << n) - 1;
    for (int u = 1; u < n; u++)
        ans = min(ans, dp[full][u] + dist[u][0]);
    
    cout << ans << "\n";
    // 复杂度:O(2^N × N^2),N=20 时约 4×10^8(偏大,N=15~18 通常可行)
}

⚠️ 常见错误

错误示例修复方案
移位超过类型宽度1 << 31 在 int 下溢出1LL << 31(long long)
运算符优先级混淆a & b == 0(先算==!)加括号:(a & b) == 0
状压数组开太大dp[1<<20] 占 4MB提前算好内存:2^20 × 4B = 4MB
枚举子集死循环for(sub=S; sub>=0; ...)条件用 sub > 0,0 表示空集
忘记第 k 位从 0 开始数把第 1 位当成最右边那位最右边是第 0 位
~ 产生很多前导 1直接打印 ~0 得到 -1只在配合 &、掩码时使用
示例代码里忘记定义辅助函数直接调用 check(s, i)完整程序中要写 ((s >> i) & 1) 或补上函数

💪 练习题

🟢 题目 1:奇偶统计

给定整数 x,输出它的二进制表示中 1 的个数(popcount),以及是否是 2 的幂。

✅ 完整解答
#include <bits/stdc++.h>
using namespace std;
int main() {
    int x; cin >> x;
    cout << __builtin_popcount(x) << "\n";
    cout << (x > 0 && (x & (x-1)) == 0 ? "是2的幂" : "不是2的幂") << "\n";
}

🟢 题目 2:位翻转

给定整数 x 和位置数组 k[],将 x 的这些位翻转后输出结果。

✅ 完整解答
#include <bits/stdc++.h>
using namespace std;
int main() {
    int x, m; cin >> x >> m;
    while (m--) {
        int k; cin >> k;
        x ^= (1 << k);  // XOR 翻转第 k 位
    }
    cout << x << "\n";
}

🟡 题目 3:子集枚举

给定 N 个数字(N ≤ 20),找所有和为偶数的子集数量。

✅ 完整解答
#include <bits/stdc++.h>
using namespace std;
int main() {
    int n; cin >> n;
    vector<int> a(n);
    for (int& x : a) cin >> x;
    
    int cnt = 0;
    for (int s = 0; s < (1 << n); s++) {
        int sum = 0;
        for (int i = 0; i < n; i++)
            if ((s >> i) & 1) sum += a[i];
        if (sum % 2 == 0) cnt++;
    }
    cout << cnt << "\n";
    // 答案总是 2^(n-1)(如果有奇数)
}

🔴 题目 4:最大异或子集

给定 N 个数字(N ≤ 20),选出一个非空子集,使子集内所有数字的异或和最大,输出该最大值。

✅ 完整解答

思路: 枚举所有非空子集,对每个子集计算异或和取最大。

#include <bits/stdc++.h>
using namespace std;
int main() {
    int n; cin >> n;
    vector<int> a(n);
    for (int& x : a) cin >> x;
    
    int ans = 0;
    for (int s = 1; s < (1 << n); s++) {
        int xor_sum = 0;
        for (int i = 0; i < n; i++)
            if ((s >> i) & 1) xor_sum ^= a[i];
        ans = max(ans, xor_sum);
    }
    cout << ans << "\n";
}

进阶(N 较大时): 用线性基(高斯消元)在 O(N × 30) 内找最大异或值。


🔴 题目 5:USACO Bronze 风格 — 奶牛体操(位运算版)

N 头奶牛(N ≤ 20),K 轮训练。每轮训练中,奶牛按排名从 1 到 N 排列。定义一对奶牛 (i, j) 是「一致的」,若在所有 K 轮中 i 都排在 j 前面。用位运算统计有多少对奶牛是一致的。

输入: 第一行 K N,接下来 K 行每行 N 个整数(1~N 的排列,表示该轮的排名顺序)。

样例输入:

3 4
4 1 2 3
4 1 3 2
4 2 1 3

样例输出: 4

✅ Full Solution

核心思路: 对每头奶牛 i,用一个 N 位整数 before[i] 记录「在某一轮中,哪些奶牛排在 i 前面」。遍历每轮排名,对奶牛 i,排在 i 前面的所有奶牛 j 的第 j 位置 1。K 轮结束后,before[i] 逐位 AND 所有轮的结果——某位保持 1 说明那头奶牛在所有轮中都排在 i 前面。统计所有 before[i] 的 popcount 之和。

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

int main() {
    ios_base::sync_with_stdio(false); cin.tie(NULL);
    int K, N;
    cin >> K >> N;

    // before[i] 的第 j 位 = 奶牛 j 在所有已完成轮次中是否始终排在 i 前面
    vector<int> before(N, (1 << N) - 1);  // 初始全1,逐轮 AND

    for (int round = 0; round < K; round++) {
        // 读取排名:rank[0]=第1名的奶牛编号...
        vector<int> ranking(N);
        for (int p = 0; p < N; p++) cin >> ranking[p];

        // mask 记录当前已出现的奶牛(排在前面)
        int mask = 0;
        for (int p = 0; p < N; p++) {
            int cow = ranking[p] - 1;  // 转 0-indexed
            before[cow] &= mask;       // AND 上当前排在前面的奶牛集合
            mask |= (1 << cow);        // 将当前奶牛加入已出现集合
        }
    }

    int ans = 0;
    for (int i = 0; i < N; i++)
        ans += __builtin_popcount(before[i]);
    cout << ans << "\n";
}

复杂度分析: 时间 O(K × N),空间 O(N)。每位用一个 int 做 K 次 AND 操作,非常高效。


💡 章节联系: 位运算是状压 DP(第 6.3 章进阶 DP)的基础工具,也用于树状数组的 lowbit 操作(第 5.7 章)。掌握本章后,你将能读懂绝大多数竞赛代码中的位运算技巧。

本章小结

  • 位就是开关:每一位只有 0 和 1 两种状态。
  • 1 << k 是核心工具:它能制造一个只选中第 k 位的掩码。
  • 单个位操作有固定套路:检查、置位、清位、翻转分别对应不同位运算。
  • 状压就是用整数存集合:第 i 位为 1 表示第 i 个元素被选中。
  • 枚举子集就是枚举开关组合0(1 << n) - 1 覆盖了所有选/不选方案。
  • 不要只背代码:做题时先问"每一位代表什么",再选择合适的位运算。

🏗️ 第三部分:核心数据结构

几乎出现在每一道 USACO Bronze 和 Silver 题目中的数据结构——前缀和、排序、双指针、栈、映射和线段树。

📚 10 章 · ⏱️ 预计 2-3 周 · 🎯 目标:解决 USACO Bronze 题目

第三部分:核心数据结构

预计用时:2-3 周

第三部分是竞赛编程开始变得有趣的地方。你将学习几乎出现在每一道 USACO Bronze 和 Silver 题目中的数据结构——以及能把 O(N²) 暴力解法变成 O(N) 优雅方案的技巧。


涵盖的主题

章节主题核心思想
第 3.1 章STL 核心用法掌握强大的内置容器:sort、map、set、queue、stack
第 3.2 章数组与前缀和O(N) 预处理后,O(1) 回答区间求和查询
第 3.3 章排序与搜索排序 + 二分查找,把很多 O(N²) 问题变成 O(N log N)
第 3.4 章双指针与滑动窗口用两个协调移动的指针高效处理子数组/对
第 3.5 章单调栈与单调队列O(N) 求下一个更大元素、滑动窗口最大/最小值
第 3.6 章栈、队列与双端队列LIFO/FIFO 处理的有序数据结构
第 3.7 章哈希技术快速键查找、多项式哈希、滚动哈希
第 3.8 章映射与集合O(log N) 查找、唯一元素集合、频率统计
第 3.9 章二分答案把枚举答案转化为「猜+验证」的二分问题
第 3.10 章字符串算法KMP 字符串匹配、Trie 树(字典树)、01-Trie

💡 树形数据结构(二叉树、并查集、线段树、树状数组)已归入第五部分:图论算法(第 5.5~5.8 章)。


学完本部分后能解决什么问题

完成第三部分后,你将能够挑战:

  • USACO Bronze: 大多数 Bronze 题目使用第三部分的技术

    • 区间查询(位置 L 到 R 之间 X 类型的奶牛有多少头?)
    • 排序问题(最近点对、排名、调度)
    • 频率统计(每个值出现多少次?)
    • 栈相关问题(括号匹配、单调处理)
  • USACO Silver 入门:

    • 二分答案(攻击性奶牛、绳子切割)
    • 滑动窗口最大/最小值
    • 差分数组实现区间更新

引入的关键算法

技术章节USACO 相关度
一维前缀和3.2品种统计、区间查询
二维前缀和3.2网格上的矩形区域求和
差分数组3.2区间更新、单点查询
带自定义比较器的 std::sort3.3几乎所有 Silver 题目
二分查找(lower_boundupper_bound3.3计数、区间查询
二分答案3.3攻击性奶牛、画家分区
单调栈3.5下一个更大元素、直方图
滑动窗口(单调队列)3.5窗口最小/最大值
频率映射(unordered_map3.7统计出现次数
有序集合操作3.8第 K 小元素、区间查询

前置条件

开始第三部分前,请确认你能做到:

  • 从零编写并编译 C++ 程序(第 2.1 章)
  • 正确使用 for 循环和嵌套循环(第 2.2 章)
  • 使用数组和 vector<int>(第 2.3 章)

注意: 第 3.1 章(STL 核心用法)是本部分的第一章,将在后续章节用到之前先教你 std::sortmapset 等关键 STL 容器。


本部分学习建议

  1. 第 3.2 章(前缀和) 是 Bronze 中测试最频繁的技术。确保你能在 5 分钟内从零实现它。
  2. 第 3.3 章(二分查找) 介绍「二分答案」——这是 Silver 级别的技术,是普通解法和优秀解法的分水岭。
  3. 不要跳过练习题。 每章的练习题都是专门为培养所需直觉而精选的。
  4. 完成第 3.3 章后,你已经具备解决大多数 USACO Bronze 题目的工具。在继续学习前,尝试解 5-10 道 Bronze 题目。

🏆 USACO 技巧: 在 USACO Bronze 级别,最常用的技术是:模拟(第 2.1–2.3 章)、排序(第 3.3 章)和前缀和(第 3.2 章)。掌握这三项,几乎可以解决任何 Bronze 题目。

出发!

📖 第 3.1 章 ⏱️ 约 70 分钟 🎯 入门

第 3.1 章:STL 核心用法

📝 前置条件: 第 2.1–2.3 章(变量、循环、函数、向量)

标准模板库(STL) 是 C++ 内置的现成数据结构和算法集合。不需要从零实现链表、哈希表或排序算法,直接用 STL——它快速、可靠,经过数百万程序员的检验。

学会为问题选择正确的 STL 容器是竞赛编程中最重要的技能之一。

本章你将学到:

  • sort —— 一行代码,按任意规则排序任意序列
  • pair —— 简洁地将两个值捆绑在一起
  • map / set —— 有序键值存储和唯一元素集合
  • stack / queue —— 用于经典算法的 LIFO 和 FIFO 容器
  • priority_queue —— 始终能 O(log N) 取得最大(或最小)值
  • unordered_map / unordered_set —— 基于哈希的 O(1) 查找
  • auto 和范围 for —— 写出更简洁的代码
  • 常用 STL 算法:binary_searchlower_boundaccumulate

3.1.0 STL 工具箱

把 STL 想象成一个工具箱,每种工具都为特定任务设计:

STL Toolbox

快速参考——该用哪个容器:

需求使用
有序列表,随机访问vector
两个值捆绑在一起pair
键 → 值映射(有序)map
唯一元素(有序)set
键 → 值映射(快速,无序)unordered_map
唯一元素(快速,无序)unordered_set
LIFO(后进先出)stack
FIFO(先进先出)queue
快速获取最大/最小值priority_queue

选对工具 = 竞赛编程中解题的一半!

「该用哪个容器?」决策树

STL Container Decision Tree

图示:STL 容器概览

STL Containers


3.1.1 sort —— 你唯一需要的排序

我们从 sort 开始,因为几乎每道题都会用到它。

是什么,为什么重要

排序将元素序列重新排列成有序状态。不用自己实现排序算法(容易出错且费时),C++ 的 sort 具有以下特点:

  • 快速:O(N log N) —— 基于比较的排序的理论最优
  • 易用:一行代码
  • 灵活:按任意你定义的规则排序

⚠️ 重要: sort 需要 #include <algorithm>(通过 #include <bits/stdc++.h> 自动包含)。

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

int main() {
    // 对向量排序——升序(默认)
    vector<int> v = {5, 2, 8, 1, 9, 3};
    sort(v.begin(), v.end());
    // v 现在是:{1, 2, 3, 5, 8, 9}

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

    // 降序排序
    sort(v.begin(), v.end(), greater<int>());
    // v 现在是:{9, 8, 5, 3, 2, 1}

    // 对数组排序
    int arr[] = {4, 2, 7, 1, 5};
    int n = 5;
    sort(arr, arr + n);  // 排序 arr[0..n-1]
    // arr 现在是:{1, 2, 4, 5, 7}

    return 0;
}

自定义排序(Lambda 函数)

如果想按非自然顺序排序?用 lambda —— 一个小的内联函数:

vector<int> v = {5, -3, 2, -8, 1};

// 按绝对值排序
sort(v.begin(), v.end(), [](int a, int b) {
    return abs(a) < abs(b);  // a 应该排在 b 前面时返回 true
});
// v 现在是:{1, 2, -3, 5, -8}(按 |值| 排序)

lambda [](int a, int b) { return ...; } 是比较规则。当 a 应该排在 b 前面时返回 true

🐛 常见错误:a == b 时永远不要返回 true——这违反了「严格弱序」规则,会导致未定义行为(崩溃或错误答案)。始终用 <>,绝不用 <=>=

// 对 pair 向量排序:按第二元素降序,相同时按第一元素升序
vector<pair<int,int>> pts = {{3,5},{1,7},{2,5},{4,3}};
sort(pts.begin(), pts.end(), [](pair<int,int> a, pair<int,int> b) {
    if (a.second != b.second) return a.second > b.second;  // 第二元素更大的优先
    return a.first < b.first;   // 打平:第一元素更小的优先
});
// 结果:{1,7}, {2,5}, {3,5}, {4,3}

3.1.2 pair —— 把两个值存在一起

pair 将两个值捆绑成一个对象,可以理解为两个值的「迷你结构体」。

为什么用 pair? 经常需要把两个相关的值放在一起——比如(值,下标)、(x 坐标,y 坐标)、(成绩,姓名)。pair 能简洁地做到这点。

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

int main() {
    // 创建 pair
    pair<int, int> point = {3, 5};         // (x, y)
    pair<string, int> student = {"Alice", 95};  // (姓名, 成绩)

    // 访问元素:.first 和 .second
    cout << point.first << " " << point.second << "\n";    // 3 5
    cout << student.first << ": " << student.second << "\n"; // Alice: 95

    // pair 支持比较:先比较 .first,相同时比较 .second
    pair<int,int> a = {1, 3};
    pair<int,int> b = {1, 5};
    cout << (a < b) << "\n";   // 1(真)—— .first 相同,比较 .second:3 < 5

    // 非常常见的模式:用 pair 按第二元素排序
    vector<pair<int,int>> v = {{3,9},{1,2},{4,1},{1,5}};
    sort(v.begin(), v.end());     // 先按 .first 排序,再按 .second 排序
    // 结果:{1,2}, {1,5}, {3,9}, {4,1}

    return 0;
}

专业技巧: 需要按某个值排序但又想保留原始下标时,把它们存成 pair<值, 下标> 后排序。排序后 .second 就是原始下标。

💡 make_pair vs 花括号: make_pair(3, 5){3, 5} 都能创建 pair。花括号语法 {3, 5} 更短,是现代 C++(C++11 及以后)的首选方式。


3.1.3 map —— 字典

map 存储键值对,就像真正的字典:给定一个单词(键),查找它的定义(值)。每个键最多出现一次

什么时候用 map

  • 频率统计:单词 → 计数,成绩 → 频率
  • ID 映射到名字:学生 ID → 姓名
  • 存储属性:奶牛名字 → 产奶量
📄 C++ 完整代码
#include <bits/stdc++.h>
using namespace std;

int main() {
    map<string, int> phoneBook;

    // 插入键值对
    phoneBook["Alice"] = 555001;
    phoneBook["Bob"] = 555002;
    phoneBook["Charlie"] = 555003;

    // 按键查找
    cout << phoneBook["Alice"] << "\n";  // 555001

    // 遍历(始终按键的**排序顺序**!)
    for (auto& entry : phoneBook) {
        cout << entry.first << " -> " << entry.second << "\n";
    }
    // 打印:
    // Alice -> 555001
    // Bob -> 555002
    // Charlie -> 555003

    // 删除键
    phoneBook.erase("Charlie");
    cout << phoneBook.size() << "\n";  // 2

    return 0;
}

常用操作及时间复杂度

操作代码时间
插入/更新m[key] = valueO(log n)
查找m[key]m.at(key)O(log n)
检查是否存在m.count(key)m.find(key)O(log n)
删除m.erase(key)O(log n)
大小m.size()O(1)
遍历全部范围 forO(n)

🐛 map 访问陷阱

这是 map 最常见的 bug 之一:

📄 这是 `map` 最常见的 bug 之一:
map<string, int> freq;

// 危险:访问不存在的键会以值 0 创建它!
cout << freq["apple"] << "\n";  // 打印 0,但现在 "apple" 已经在 map 里了!
cout << freq.size() << "\n";    // 1——即使我们只是「查了一下」,并没有「插入」!

// 安全方式一:先检查 count
if (freq.count("apple") > 0) {
    cout << freq["apple"] << "\n";  // 安全——键已存在
}

// 安全方式二:用 .find()
auto it = freq.find("apple");
if (it != freq.end()) {          // .end() 表示「未找到」
    cout << it->second << "\n";  // it->second 是值
}

频率统计——最常见的 map 模式

📄 查看代码:频率统计——最常见的 map 模式
vector<string> words = {"apple", "banana", "apple", "cherry", "banana", "apple"};
map<string, int> freq;

for (const string& w : words) {
    freq[w]++;  // 如果 "w" 不存在,以 0 创建,然后递增为 1
}

// freq: apple→3, banana→2, cherry→1
for (auto& p : freq) {
    cout << p.first << " 出现了 " << p.second << " 次\n";
}

💡 为什么 freq[w]++ 对新键也有效: map 对缺失的值进行默认初始化。对 int 来说默认值是 0。所以访问新键会以值 0 创建它,然后 ++ 使其变为 1。这是故意设计的,广泛用于计数。


3.1.4 set —— 唯一有序集合

set 以有序状态存储唯一元素。插入重复值——会被悄悄忽略。

什么时候用 set

  • 去除列表中的重复项
  • 快速检查成员资格:「我见过这个值吗?」
  • 获取动态集合的最小/最大值
📄 C++ 完整代码
#include <bits/stdc++.h>
using namespace std;

int main() {
    set<int> s;

    s.insert(5);
    s.insert(2);
    s.insert(8);
    s.insert(2);   // 重复——被忽略!set 仍是 {2, 5, 8}
    s.insert(1);

    // s 现在是:{1, 2, 5, 8}(自动排序,无重复)

    // 检查成员资格
    cout << s.count(2) << "\n";   // 1(存在)
    cout << s.count(7) << "\n";   // 0(不存在)

    // 删除
    s.erase(2);   // s = {1, 5, 8}

    // 遍历(始终排序)
    for (int x : s) cout << x << " ";
    cout << "\n";   // 1 5 8

    // 最小值和最大值
    cout << *s.begin() << "\n";   // 1(最小;* 解引用迭代器)
    cout << *s.rbegin() << "\n";  // 8(最大;r = 反向)

    cout << s.size() << "\n";  // 3

    return 0;
}

常用操作及时间复杂度

操作代码时间
插入s.insert(x)O(log n)
检查是否存在s.count(x)s.find(x)O(log n)
删除s.erase(x)O(log n)
最小值*s.begin()O(1)
最大值*s.rbegin()O(1)
大小s.size()O(1)

set 去重

vector<int> v = {3, 1, 4, 1, 5, 9, 2, 6, 5, 3, 5};
set<int> unique_set(v.begin(), v.end());  // 从向量构建 set
// unique_set = {1, 2, 3, 4, 5, 6, 9}

cout << "唯一值个数:" << unique_set.size() << "\n";   // 7

// 如需转回排序向量:
vector<int> deduped(unique_set.begin(), unique_set.end());

💡 set vs multiset set 每个值最多存一次。如果需要存重复值但仍想保持有序,用 multiset<int> —— 它允许重复元素。


3.1.5 stack —— 后进先出

栈就像一叠盘子:你只能在顶部添加或移除。最后加入的最先被移除(LIFO:Last In, First Out,后进先出)。

什么时候用 stack

  • 括号匹配
  • 撤销/重做历史
  • 深度优先搜索(DFS)——后续章节讲解
  • 反转序列
📄 C++ 完整代码
#include <bits/stdc++.h>
using namespace std;

int main() {
    stack<int> st;

    st.push(1);    // [1]         (顶部在右侧)
    st.push(2);    // [1, 2]
    st.push(3);    // [1, 2, 3]

    cout << st.top() << "\n";   // 3(查看顶部,不移除)
    st.pop();                    // 移除顶部 → [1, 2]
    cout << st.top() << "\n";   // 2

    cout << st.size() << "\n";  // 2
    cout << st.empty() << "\n"; // 0(不为空)

    return 0;
}

常用操作及时间复杂度

操作代码时间
压入顶部st.push(x)O(1)
弹出顶部st.pop()O(1)
查看顶部st.top()O(1)
检查是否为空st.empty()O(1)
大小st.size()O(1)

🐛 常见栈错误:不检查就弹出

stack<int> st;
// st.top();  // 崩溃!不能查看空栈的顶部
// st.pop();  // 崩溃!不能从空栈弹出

// 访问前始终检查:
if (!st.empty()) {
    cout << st.top() << "\n";
    st.pop();
}

经典栈问题:括号匹配

📄 查看代码:经典栈问题:括号匹配
string expr = "((a+b)*(c-d))";
stack<char> parens;
bool balanced = true;

for (char ch : expr) {
    if (ch == '(') {
        parens.push(ch);         // 开括号:压栈
    } else if (ch == ')') {
        if (parens.empty()) {    // 闭括号但没有对应的开括号
            balanced = false;
            break;
        }
        parens.pop();            // 找到匹配:弹出开括号
    }
}

if (!parens.empty()) balanced = false;  // 还有未匹配的开括号

cout << (balanced ? "匹配" : "不匹配") << "\n";

3.1.6 queue —— 先进先出

队列就像商店排队:你从后面加入,从前面离开。第一个加入的最先被服务(FIFO:First In, First Out,先进先出)。

什么时候用 queue

  • 模拟顾客、进程、任务的排队
  • 广度优先搜索(BFS)—— 竞赛编程中最重要的算法之一(第 5.2 章)
  • 按到达顺序处理元素
📄 C++ 完整代码
#include <bits/stdc++.h>
using namespace std;

int main() {
    queue<int> q;

    q.push(10);   // [10]
    q.push(20);   // [10, 20]
    q.push(30);   // [10, 20, 30]

    cout << q.front() << "\n";  // 10(排在最前——最先离开)
    cout << q.back() << "\n";   // 30(排在最后——最后离开)

    q.pop();                     // 从前面移除 → [20, 30]
    cout << q.front() << "\n";  // 20

    cout << q.size() << "\n";   // 2

    return 0;
}

常用操作及时间复杂度

操作代码时间
加入队尾q.push(x)O(1)
从队首移除q.pop()O(1)
查看队首q.front()O(1)
查看队尾q.back()O(1)
检查是否为空q.empty()O(1)

注意: 第 5.2 章中你会大量用到 queue 来实现 BFS —— USACO 中最重要的图算法之一。

🐛 常见错误: queue 没有 top() 方法(那是 stack 的)。用 front() 查看队首元素,用 back() 查看队尾。


3.1.7 priority_queue —— 堆

priority_queue 就像一个魔法队列:无论以什么顺序插入,它总是先给你最大的元素(默认最大堆)。

什么时候用 priority_queue

  • 需要快速获取最大(或最小)元素
  • 找第 K 大的数
  • Dijkstra 最短路算法(第 5.4 章)
  • 每次都处理「最优」元素的贪心算法
📄 C++ 完整代码
#include <bits/stdc++.h>
using namespace std;

int main() {
    // 最大堆:始终给你最大值
    priority_queue<int> maxPQ;

    maxPQ.push(5);
    maxPQ.push(1);
    maxPQ.push(8);
    maxPQ.push(3);

    // 按降序弹出
    while (!maxPQ.empty()) {
        cout << maxPQ.top() << " ";  // 始终是当前最大值
        maxPQ.pop();
    }
    cout << "\n";  // 打印:8 5 3 1

    return 0;
}

🐛 最小堆陷阱

默认情况下,priority_queue最大堆(优先给最大值)。要创建最小堆(优先给最小值),需要特殊语法:

📄 默认情况下,`priority_queue` 是**最大堆**(优先给最大值)。要创建**最小堆**(优先给最小值),需要特殊语法
// 最大堆(默认)——优先给最大值
priority_queue<int> maxPQ;

// 最小堆——优先给最小值(注意额外的模板参数!)
priority_queue<int, vector<int>, greater<int>> minPQ;

minPQ.push(5);
minPQ.push(1);
minPQ.push(8);
minPQ.push(3);

while (!minPQ.empty()) {
    cout << minPQ.top() << " ";
    minPQ.pop();
}
// 打印:1 3 5 8(最小值优先)

常用操作及时间复杂度

操作代码时间
插入pq.push(x)O(log n)
获取最大/最小值pq.top()O(1)
移除最大/最小值pq.pop()O(log n)
检查是否为空pq.empty()O(1)

💡 含 pair 的优先队列: 可以在优先队列中存储 pair<int, int>,先比较 .first 再比较 .second——用于 Dijkstra 算法存储 {距离, 节点} 时非常有用。

priority_queue<pair<int,int>, vector<pair<int,int>>, greater<pair<int,int>>> minPQ;
// (距离, 节点) 对的最小堆——用于 Dijkstra

3.1.8 unordered_mapunordered_set —— 基于哈希的速度

普通 mapset 是有序的(内部使用平衡二叉搜索树),操作是 O(log N)。unordered_ 变体使用哈希表,平均 O(1) —— 更快,但没有顺序保证。

💡 底层原理:为什么 map 是 O(log N) 而 unordered_map 是 O(1)?

  • map / set 内部使用红黑树(自平衡二叉搜索树)。每次插入、查找或删除都从根到叶遍历,树高约 log₂N,因此 O(log N)。优点:元素始终有序,支持 lower_bound 和范围查询。
  • unordered_map / unordered_set 内部使用哈希表。哈希函数直接计算存储位置,平均 O(1)。但不保证元素顺序,最坏情况(严重哈希碰撞)可能退化到 O(N)。
  • 竞赛经验:只需查找/插入而不需要有序遍历时,优先用 unordered_map。但如果因最坏情况被「hack」而 TLE,改回 map 是最安全的选择。
📄 C++ 完整代码
unordered_map<string, int> freq;
freq["apple"]++;
freq["banana"]++;
freq["apple"]++;

cout << freq["apple"] << "\n";   // 2(与 map 接口相同)

unordered_set<int> seen;
seen.insert(5);
seen.insert(10);
cout << seen.count(5) << "\n";   // 1(找到)
cout << seen.count(7) << "\n";   // 0(未找到)

防 Hack 的 unordered_map

竞赛中对手可以构造导致大量哈希碰撞的输入,使 unordered_map 每次操作退化到 O(N)。一个简单的防御方法是使用自定义哈希:

// 在 main() 之前添加,让 unordered_map 更难被 hack
struct custom_hash {
    size_t operator()(long long x) const {
        x = (x ^ (x >> 30)) * 0xbf58476d1ce4e5b9LL;
        x = (x ^ (x >> 27)) * 0x94d049bb133111ebLL;
        return x ^ (x >> 31);
    }
};
unordered_map<long long, int, custom_hash> safe_map;

大多数题目默认的 unordered_map 就够了。只有当你怀疑存在反哈希测试时才用自定义哈希。

什么时候用哪个

容器使用场景
map / set需要有序遍历;需要 lower_bound;N 较小;优先安全
unordered_map / unordered_set只需查找;N 较大(> 10^5);键是字符串或整数

专业技巧: 对于大量字符串键的输入,unordered_mapmap 快 5-10 倍。但它有极少数情况下可能被竞赛中利用的「最坏情况」行为——如果用 unordered_map TLE 且怀疑被 hack,改用 map


3.1.9 auto 关键字和范围 for

C++ 通常能自动推断变量的类型。auto 关键字告诉编译器:「你来决定类型。」

auto x = 42;          // x 是 int
auto y = 3.14;        // y 是 double
auto v = vector<int>{1, 2, 3};  // v 是 vector<int>

map<string, int> freq;
auto it = freq.find("cat");  // 类型本应是 map<string,int>::iterator——很长!
// auto 省去了写这么长类型名的麻烦

⚠️ auto 陷阱: auto 在编译时推断类型——它不会让变量变成动态类型。另外,auto x = 1000000000 * 2; 会推断出 int 并可能溢出;写 auto x = 1000000000LL * 2; 才能得到 long long

范围 for

对任何容器的简洁遍历:

📄 对任何容器的简洁遍历:
vector<int> nums = {10, 20, 30, 40, 50};

// 只读遍历(复制每个元素——对 int 没问题,对 string 浪费)
for (int x : nums) {
    cout << x << " ";
}

// 引用:不复制,可修改元素
for (int& x : nums) {
    x *= 2;   // 就地翻倍每个元素
}

// const 引用:不复制,只读(大类型的最佳方式)
for (const auto& x : nums) {
    cout << x << " ";
}

范围 for 经验法则:

  • 小类型(intchar):for (int x : v) —— 复制没问题
  • 大类型(stringpair、struct):for (const auto& x : v) —— 避免复制
  • 需要修改:for (auto& x : v)

3.1.10 常用 STL 算法

<algorithm><numeric> 中的这些函数适用于任何序列:

📄 `` 和 `` 中的这些函数适用于任何序列:
#include <bits/stdc++.h>
using namespace std;

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

    // 升序排序
    sort(v.begin(), v.end());
    // v = {1, 1, 2, 3, 4, 5, 6, 9}

    // 二分查找(只能用于**已排序**的序列!)
    cout << binary_search(v.begin(), v.end(), 5) << "\n";  // 1(找到)
    cout << binary_search(v.begin(), v.end(), 7) << "\n";  // 0(未找到)

    // lower_bound:第一个 >= 目标值的位置
    auto it = lower_bound(v.begin(), v.end(), 4);
    cout << *it << "\n";   // 4
    cout << (it - v.begin()) << "\n";  // 下标:3

    // upper_bound:第一个 > 目标值的位置
    auto it2 = upper_bound(v.begin(), v.end(), 4);
    cout << (it2 - v.begin()) << "\n";  // 下标:4(第一个 > 4 的元素)
    // [lo, hi] 范围内的元素个数:upper_bound(hi+1) - lower_bound(lo)

    // 最小值和最大值
    cout << *min_element(v.begin(), v.end()) << "\n";  // 1
    cout << *max_element(v.begin(), v.end()) << "\n";  // 9

    // 所有元素之和
    long long total = accumulate(v.begin(), v.end(), 0LL);
    cout << total << "\n";  // 31

    // 统计出现次数
    cout << count(v.begin(), v.end(), 1) << "\n";  // 2

    // 反转
    reverse(v.begin(), v.end());

    // 用一个值填充
    fill(v.begin(), v.end(), 0);  // 全为零

    return 0;
}

3.1.11 综合示例:词频统计器

让我们写一个综合运用 mapvectorsort 的完整小程序。

问题: 读取 N 个单词,统计每个单词出现次数,然后打印:

  1. 所有单词及其计数(按字母顺序)
  2. 出现次数最多的单词
📄 2. 出现次数最多的单词
#include <bits/stdc++.h>
using namespace std;

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

    int n;
    cin >> n;

    // 第一步:用 map 统计词频
    map<string, int> freq;
    for (int i = 0; i < n; i++) {
        string word;
        cin >> word;
        freq[word]++;   // 如果 word 是新的,以 0 创建,然后递增
    }

    // 第二步:按字母顺序打印所有单词(map 按键有序迭代)
    cout << "所有单词:\n";
    for (auto& entry : freq) {
        // entry.first = 单词,entry.second = 计数
        cout << entry.first << ": " << entry.second << "\n";
    }

    // 第三步:找最频繁的单词
    // 对 map 用 max_element:按值(.second)比较
    auto best = max_element(
        freq.begin(),
        freq.end(),
        [](const pair<string,int>& a, const pair<string,int>& b) {
            return a.second < b.second;  // 按计数比较
        }
    );

    cout << "\n最频繁的:\"" << best->first << "\"(出现了 "
         << best->second << " 次)\n";

    return 0;
}

样例输入:

10
the cat sat on the mat the cat sat on

样例输出:

所有单词:
cat: 2
mat: 1
on: 2
sat: 2
the: 3

最频繁的:"the"(出现了 3 次)

复杂度分析:

  • 时间:O(N log N)——每次 freq[word]++ 是 O(log N),共 N 次;max_element 遍历 map 是 O(M),M 为不重复单词数
  • 空间:O(M)——map 存储 M 个不重复单词

🤔 为什么遍历 map 是字母顺序? map 内部使用平衡 BST,保持键有序。遍历时自动按键的有序顺序输出——无需额外排序!

💡 寻找最大值的替代方案: 除了在 map 上用 max_element,也可以把条目转移到 vector<pair<string,int>>,按 .second 降序排序,取第一个元素。两种方法在计数后都是 O(M)。


本章总结

📌 核心要点

容器描述关键操作时间为什么重要
vector<T>动态数组push_back[]sizeO(1) 均摊最常用容器,默认选择
pair<A,B>存储两个值.first.secondO(1)图的边、坐标等
map<K,V>有序键值对[]findcountO(log n)频率统计、有序映射
set<T>有序唯一集合insertcounteraseO(log n)去重、范围查询
stack<T>后进先出pushpoptopO(1)括号匹配、DFS
queue<T>先进先出pushpopfrontO(1)BFS、模拟
priority_queue<T>最大堆pushpoptopO(log n)贪心最大/最小、Dijkstra
unordered_map<K,V>哈希映射(无序)[]findcountO(1) 均摊大数据快速查找
unordered_set<T>哈希集合(无序)insertcounteraseO(1) 均摊快速成员检查、去重

❓ 常见问题

Q1:vector 和普通数组有什么区别?什么时候用哪个?

A:vector 可以动态增长(push_back),知道自己的大小(.size()),可以安全传给函数。普通数组大小固定,但稍快(全局数组自动初始化为 0)。竞赛中大多数情况用 vector;全局大数组(如 int dp[100001])有时更方便。

Q2:什么时候选 map vs unordered_map

A:只需查找/插入/删除时,用 unordered_map(O(1))更快。需要有序遍历lower_bound/upper_bound 时,用 map(O(log N))。没有特殊要求的竞赛中,map 更安全(不会被 hack)。

Q3:priority_queue 默认是最大堆还是最小堆?

A:最大堆pq.top() 返回最大元素。最小堆需要声明为 priority_queue<int, vector<int>, greater<int>>

Q4:什么时候自定义 structpair 更好?

A:pair.first/.second 可读性差——三个月后你可能忘了 .first 代表什么。struct 让你给成员起有意义的名字(如 .weight.value)。字段有 3 个或以上时,必须用 struct

Q5:为什么对 vector<pair<int,int>> 排序时先按 .first

A:pair 有内置的 operator<,先比较 .first,相同时比较 .second。这叫字典序——与字典中单词的排序方式相同。可以放心依赖这个行为,无需自定义比较器。

Q6:s.count(x)s.find(x) != s.end() 对于 set 有什么区别?

A:对 setmap,两者都是 O(log N),在检查存在性方面功能等价。count 返回 0 或 1(集合无重复),find 返回一个可以直接访问元素的迭代器。需要读取值时用 find,只需是/否检查时用 count

🔗 与后续章节的联系

  • 第 3.4 章(单调栈与单调队列):用于下一个更大元素问题的单调栈;用于滑动窗口最大/最小的单调双端队列
  • 第 3.6 章(栈与队列):深入探讨 stackqueue 的算法应用——括号匹配、BFS
  • 第 3.8 章(映射与集合):map/set 的进阶用法——频率统计、multiset
  • 第 3.3 章(排序):带自定义比较器的 sortvectorpair 一起使用
  • priority_queue第 4.1 章(贪心)和第 5.5 章(Kruskal MST)中频繁出现
  • 本章的 STL 容器是本书所有后续章节的基础工具

练习题


🌡️ 热身题


热身 3.1.1 — 集合成员查询 读取 N 个整数,然后读取 Q 个查询。对每个查询读取一个整数,若它出现在原始 N 个整数中打印 YES,否则打印 NO

样例输入:

5
10 20 30 40 50
3
20
35
50

样例输出:

YES
NO
YES
💡 题解(点击展开)

思路: 把 N 个整数存入集合,对每个查询检查 s.count(x)

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

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

    int n;
    cin >> n;

    set<int> s;
    for (int i = 0; i < n; i++) {
        int x;
        cin >> x;
        s.insert(x);
    }

    int q;
    cin >> q;
    while (q--) {
        int x;
        cin >> x;
        cout << (s.count(x) ? "YES" : "NO") << "\n";
    }

    return 0;
}

关键点:

  • s.count(x) 找到时返回 1,未找到时返回 0
  • while (q--) 是常见惯用法:循环执行 q 次(每次 q 递减)
  • 使用 set 每次查询 O(log N),比线性搜索 O(N) 快得多

热身 3.1.2 — 字符频率 读取一个字符串(无空格),按字母顺序打印其中每个字符及其出现次数。

样例输入: hello 样例输出:

e: 1
h: 1
l: 2
o: 1
💡 题解(点击展开)

思路:map<char, int> 统计字符频率,遍历 map 自动按字母顺序输出。

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

int main() {
    string s;
    cin >> s;

    map<char, int> freq;
    for (char c : s) {
        freq[c]++;
    }

    for (auto& entry : freq) {
        cout << entry.first << ": " << entry.second << "\n";
    }

    return 0;
}

关键点:

  • for (char c : s) 遍历字符串中的每个字符
  • freq[c]++ 第一次访问时以值 0 创建条目,然后递增
  • map 遍历始终按键有序——字符自动按字母顺序输出

热身 3.1.3 — 栈实现反转 读取一个字符串(无空格),用 stack 将其反转打印。

样例输入: hello样例输出: olleh

💡 题解(点击展开)

思路: 把每个字符压栈,然后全部弹出——LIFO 顺序正好是倒序。

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

int main() {
    string s;
    cin >> s;

    stack<char> st;
    for (char c : s) {
        st.push(c);
    }

    while (!st.empty()) {
        cout << st.top();
        st.pop();
    }
    cout << "\n";

    return 0;
}

关键点:

  • 栈的 LIFO 特性:最后压入的最先弹出 = 反转
  • 访问 st.top() 或调用 st.pop() 前始终检查 !st.empty()
  • 注意:reverse(s.begin(), s.end()); cout << s; 更简单——但用栈实现能理解概念

热身 3.1.4 — 队列模拟 模拟 5 人排队:Alice、Bob、Charlie、Dave、Eve,按顺序加入。逐一服务(从队首弹出),打印每位被服务者的名字。

期望输出:

Serving: Alice
Serving: Bob
Serving: Charlie
Serving: Dave
Serving: Eve
💡 题解(点击展开)

思路: 把所有名字加入队列,然后逐一弹出并打印直到队列为空。

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

int main() {
    queue<string> line;
    line.push("Alice");
    line.push("Bob");
    line.push("Charlie");
    line.push("Dave");
    line.push("Eve");

    while (!line.empty()) {
        cout << "Serving: " << line.front() << "\n";
        line.pop();
    }

    return 0;
}

关键点:

  • queue.front() 不移除地访问第一个元素
  • queue.pop() 移除队首元素(无返回值——如需值,在 pop() 前用 front()
  • 队列保持插入顺序——先压入的先弹出

热身 3.1.5 — 前 3 大 读取 N 个整数,用 priority_queue 找出并打印最大的 3 个值(降序)。

样例输入:

7
5 1 9 3 7 2 8

样例输出:

9
8
7
💡 题解(点击展开)

思路: 全部压入最大堆优先队列,弹出 3 次得到最大的 3 个。

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

int main() {
    int n;
    cin >> n;

    priority_queue<int> pq;
    for (int i = 0; i < n; i++) {
        int x;
        cin >> x;
        pq.push(x);
    }

    for (int i = 0; i < 3 && !pq.empty(); i++) {
        cout << pq.top() << "\n";
        pq.pop();
    }

    return 0;
}

关键点:

  • priority_queue<int> 是最大堆——top() 始终给出最大值
  • 弹出 3 次按顺序得到最大的 3 个值
  • && !pq.empty() 防卫处理 N < 3 的边界情况

🏋️ 核心练习题


题目 3.1.6 — 唯一元素 读取 N 个整数,只打印唯一的值,按它们第一次出现的顺序(不排序)。一个值出现多次时,只在第一次出现时打印。

样例输入:

8
3 1 4 1 5 9 2 6

样例输出: 3 1 4 5 9 2 6

(注意:1 出现两次,但只在第一次位置打印一次。)

💡 题解(点击展开)

思路:unordered_set 追踪已见过的值。对每个元素,只有还没见过时才打印。

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

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

    int n;
    cin >> n;

    unordered_set<int> seen;
    bool first = true;

    for (int i = 0; i < n; i++) {
        int x;
        cin >> x;
        if (seen.count(x) == 0) {   // 还没见过 x
            seen.insert(x);
            if (!first) cout << " ";
            cout << x;
            first = false;
        }
    }
    cout << "\n";

    return 0;
}

关键点:

  • 不能用普通 set,因为 set 会排序输出——我们要保持原始顺序
  • unordered_set 提供 O(1) 均摊查找:比搜索向量快得多
  • first 标志处理间隔(不打印前导/尾随空格)

题目 3.1.7 — 出现最多的单词 读取 N 个单词,打印出现次数最多的单词。如果有平局,打印字典序最小的单词。

样例输入:

7
apple banana apple cherry banana apple cherry

样例输出: apple

💡 题解(点击展开)

思路:map 统计计数,然后找最大计数。在所有出现该最大计数的单词中,取字典序最小的(map 遍历自然给出)。

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

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

    int n;
    cin >> n;

    map<string, int> freq;
    for (int i = 0; i < n; i++) {
        string w;
        cin >> w;
        freq[w]++;
    }

    string bestWord;
    int bestCount = 0;

    // map 按字母顺序迭代——最先见到的达到最大计数的单词胜出
    for (auto& entry : freq) {
        if (entry.second > bestCount) {
            bestCount = entry.second;
            bestWord = entry.first;
        }
    }

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

关键点:

  • >(严格大于)意味着我们保留第一个达到最大计数的单词
  • 由于 map 按字母顺序迭代,第一个见到的最大计数单词就是字典序最小的
  • 得益于 map 的有序特性,平局处理自动完成

题目 3.1.8 — 配对求和 读取 N 个整数和目标 T。对于每对值 (a, b),a 在输入中排在 b 前面且 a + b = T,打印该对。用集合实现 O(N) 解法。

样例输入:

6 9
1 8 3 6 4 5

样例输出:

1 8
3 6
4 5
💡 题解(点击展开)

思路: 对每个元素 x,检查 T - x 是否已在已见元素的集合中。如果是,找到了一对。

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

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

    int n, t;
    cin >> n >> t;

    set<int> seen;
    vector<int> arr(n);
    for (int i = 0; i < n; i++) cin >> arr[i];

    for (int i = 0; i < n; i++) {
        int complement = t - arr[i];  // 需要 arr[i] + complement = t
        if (seen.count(complement)) {
            // complement 在 arr[i] 之前,按顺序打印:complement arr[i]
            cout << complement << " " << arr[i] << "\n";
        }
        seen.insert(arr[i]);
    }

    return 0;
}

关键点:

  • 对每个元素 x,需要的「补数」是 T - x
  • 如果补数已在「已见」集合中,它在数组前面出现 → 有效的对
  • 这是用 set 的 O(N log N)(或用 unordered_set 的 O(N)),优于暴力 O(N²)
  • 先打印 complement(它在输入中较早),再打印 arr[i]

题目 3.1.9 — 括号匹配 读取只含 ()[]{} 的字符串,若所有括号都正确匹配和嵌套打印 YES,否则打印 NO

样例输入 1: {[()]}输出: YES 样例输入 2: ([)]输出: NO 样例输入 3: ((()输出: NO

💡 题解(点击展开)

思路: 用栈。压入开括号;见到闭括号时,检查栈顶是否是对应的开括号。

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

int main() {
    string s;
    cin >> s;

    stack<char> st;
    bool ok = true;

    for (char ch : s) {
        if (ch == '(' || ch == '[' || ch == '{') {
            st.push(ch);    // 开括号:压栈
        } else {
            // 闭括号
            if (st.empty()) {
                ok = false;  // 没有对应的开括号
                break;
            }
            char top = st.top();
            st.pop();

            // 检查是否匹配
            if ((ch == ')' && top != '(') ||
                (ch == ']' && top != '[') ||
                (ch == '}' && top != '{')) {
                ok = false;
                break;
            }
        }
    }

    if (!st.empty()) ok = false;  // 还有未匹配的开括号

    cout << (ok ? "YES" : "NO") << "\n";
    return 0;
}

关键点:

  • 核心思路:最近打开的括号必须是下一个关闭的
  • 栈的 LIFO 特性完美模拟了「最近打开」的要求
  • 三种失败条件:(1) 空栈时遇到闭括号;(2) 括号类型不匹配;(3) 末尾还有未关闭的括号

题目 3.1.10 — 前 K 大 读取 N 个整数和 K,打印最大的 K 个值(降序)。

样例输入:

8 3
4 9 1 7 3 5 2 8

样例输出:

9
8
7
💡 题解(点击展开)

思路: 全部压入最大堆,弹出 K 次。

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

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

    int n, k;
    cin >> n >> k;

    priority_queue<int> pq;
    for (int i = 0; i < n; i++) {
        int x;
        cin >> x;
        pq.push(x);
    }

    for (int i = 0; i < k && !pq.empty(); i++) {
        cout << pq.top() << "\n";
        pq.pop();
    }

    return 0;
}

关键点:

  • 优先队列自动把最大值放在顶部
  • 每次 pop() 移除当前最大值,下一个最大值浮现
  • 替代方案:降序排序后取前 K 个——结果相同

🏆 挑战题


挑战 3.1.11 — 库存系统 处理库存的 M 条命令,每条是以下之一:

  • ADD name quantity —— 添加 quantity 单位的产品 name
  • REMOVE name quantity —— 移除 quantity 单位(若移除量超过库存,设为 0)
  • QUERY name —— 打印 name 的当前库存量(从未添加过则为 0)

样例输入:

6
ADD apple 10
ADD banana 5
QUERY apple
REMOVE apple 3
QUERY apple
QUERY grape

样例输出:

10
7
0
💡 题解(点击展开)

思路:map<string, long long> 作为库存,解析每条命令并相应更新。

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

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

    int m;
    cin >> m;

    map<string, long long> inventory;

    while (m--) {
        string cmd;
        cin >> cmd;

        if (cmd == "ADD") {
            string name;
            long long qty;
            cin >> name >> qty;
            inventory[name] += qty;
        } else if (cmd == "REMOVE") {
            string name;
            long long qty;
            cin >> name >> qty;
            inventory[name] -= qty;
            if (inventory[name] < 0) inventory[name] = 0;
        } else {  // QUERY
            string name;
            cin >> name;
            // 用 count 检查存在性,避免为缺失的商品创建条目
            if (inventory.count(name)) {
                cout << inventory[name] << "\n";
            } else {
                cout << 0 << "\n";
            }
        }
    }

    return 0;
}

关键点:

  • inventory[name] += qty——若 name 不存在,以 0 创建后加 qty(正确!)
  • QUERY 时用 inventory.count(name) 检查存在性,避免悄悄创建值为 0 的条目
  • 数量用 long long 以防较大

挑战 3.1.12 — 滑动窗口最大值 读取 N 个整数和窗口大小 K,打印每个连续 K 元素窗口的最大值。

样例输入:

8 3
1 3 -1 -3 5 3 6 7

样例输出:

3
3
5
5
6
7

(窗口:[1,3,-1]→3,[3,-1,-3]→3,[-1,-3,5]→5,[-3,5,3]→5,[5,3,6]→6,[3,6,7]→7)

💡 题解(点击展开)

思路:deque(双端队列)维护一个有用下标的窗口。双端队列按值的递减顺序存储下标——deque.front() 始终是当前窗口最大值的下标。

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

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

    int n, k;
    cin >> n >> k;

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

    deque<int> dq;  // 存储下标,队首 = 当前最大值的下标

    for (int i = 0; i < n; i++) {
        // 移除已超出当前窗口的下标
        while (!dq.empty() && dq.front() < i - k + 1) {
            dq.pop_front();
        }

        // 从队尾移除值小于 arr[i] 的下标
        //(只要 arr[i] 还在窗口里,它们就不可能成为最大值)
        while (!dq.empty() && arr[dq.back()] <= arr[i]) {
            dq.pop_back();
        }

        dq.push_back(i);

        // 从窗口满的位置(下标 k-1)开始打印最大值
        if (i >= k - 1) {
            cout << arr[dq.front()] << "\n";
        }
    }

    return 0;
}

关键点:

  • 双端队列维护「单调递减队列」的下标
  • 队首 = 当前窗口最大值的下标
  • 加入新元素 arr[i] 时:从队尾移除所有 ≤ arr[i] 的元素(它们无用——arr[i] 更大且在窗口中待的时间更长)
  • 当该下标不再在窗口内时(下标 < i - k + 1),从队首移除
  • 总计 O(N)——每个下标最多压入和弹出各一次

挑战 3.1.13 — 干草堆范围计数 (USACO Bronze 风格)

N 捆干草堆放在数轴上的不同位置,处理 Q 个查询:对每个查询 (L, R),打印位置在 [L, R] 范围内(包含端点)的干草堆数量。

样例输入:

5 4
3 1 7 5 2
1 3
2 6
4 8
1 10

样例输出:

3
3
2
5
💡 题解(点击展开)

思路: 对位置排序,对每个查询用 lower_boundupper_bound 在 O(log N) 内找出范围内的数量。

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

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

    int n, q;
    cin >> n >> q;

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

    sort(pos.begin(), pos.end());  // 二分查找前必须排序

    while (q--) {
        int l, r;
        cin >> l >> r;

        // lower_bound(l):第一个 >= l 的位置
        // upper_bound(r):第一个 > r 的位置
        // [l, r] 内的元素个数 = 这两个迭代器之间的距离
        auto lo = lower_bound(pos.begin(), pos.end(), l);
        auto hi = upper_bound(pos.begin(), pos.end(), r);

        cout << (hi - lo) << "\n";  // 迭代器间距离 = 个数
    }

    return 0;
}

关键点:

  • 先对位置排序——二分查找的前提条件
  • lower_bound(pos.begin(), pos.end(), l) 返回第一个 ≥ l 的元素的迭代器
  • upper_bound(pos.begin(), pos.end(), r) 返回第一个 > r 的元素的迭代器
  • [l, r] 内元素个数 = 两迭代器之间的距离 = hi - lo
  • 总复杂度:排序 O(N log N) + 查询 O(Q log N)——远优于暴力 O(N×Q)

展望:超越基础 STL

本章涵盖了 90% 题目中会用到的核心 STL 容器。随着进阶,你还会遇到更专业的结构:

  • deque<T> —— 双端队列;支持 O(1) 在两端压入/弹出。用于滑动窗口最大值(挑战 3.1.12)和单调队列(第 3.4 章)。
  • multiset<T> —— 类似 set 但允许重复元素。需要有序且有重复时使用。
  • bitset<N> —— 固定大小的位序列;处理子集/成员问题极快。
  • Trie(前缀树) —— 通过共享公共前缀存储字符串,支持 O(L) 查找(L 是字符串长度)。

图示:Trie 数据结构

Trie Structure

Trie(前缀树)通过共享公共前缀存储字符串。单词 "bat"、"car"、"card"、"care"、"cat" 高效共享前缀:"ca" 只存一次,分支到 "r" 和 "t"。双圈节点标记单词结尾。Trie 用于自动补全、拼写检查和字符串匹配。字符串哈希的替代方案参见第 3.7 章(哈希技术)。

📖 第 3.2 章 ⏱️ 约 70 分钟 🎯 中级

第 3.2 章:数组与前缀和

📝 前置条件: 确保你熟悉数组、向量和基本循环(第 2.2–2.3 章)。还需要理解 long long 溢出(第 2.1 章)。

设想你有一个 N 个数字的数组,有人问你 100,000 次:「从下标 L 到 R 的元素之和是多少?」朴素做法每次重新计算——每次查询 O(N),总计 O(N × Q)。当 N = Q = 10^5 时,是 10^10 次操作,远远太慢。

前缀和O(N) 预处理、每次查询 O(1) 解决这个问题。这是竞赛编程中最优雅、最实用的技术之一。

💡 核心思路: 前缀和将「区间查询」问题转化为一次减法。不用每次都从 L 到 R 求和,而是预计算累积和后做两次相减。这把 O(Q) 的重复计算换成了一次性的 O(N) 预处理。


3.2.1 前缀和的思想

数组的前缀和是一个新数组,其中每个元素存储到当前下标为止的累积和。

图示:前缀和数组

Prefix Sum Visualization

上图展示了如何从原始数组构建前缀和数组,以及如何用 sum(L, R) = P[R] - P[L-1]O(1) 时间内计算区间和。蓝色单元格标示查询范围,红绿单元格展示被相减的两个前缀值。

给定数组:A = [3, 1, 4, 1, 5, 9, 2, 6](使用 1-indexed 以便说明)

下标:  1  2  3  4  5  6  7  8
A:      3  1  4  1  5  9  2  6
P:      3  4  8  9  14 23 25 31

其中 P[i] = A[1] + A[2] + ... + A[i]

为什么用 1-indexed?

使用 1-indexed 数组让我们可以定义 P[0] = 0(「空前缀」和为零)。这使得查询公式 P[R] - P[L-1]L = 1 时也能正常工作——计算 P[R] - P[0] = P[R],这是正确的。

构建前缀和数组

📄 查看代码:构建前缀和数组
// 构建前缀和数组 — O(N)
#include <bits/stdc++.h>
using namespace std;

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

    int n;
    cin >> n;

    // 第一步:读取输入(1-indexed)
    vector<int> A(n + 1);
    for (int i = 1; i <= n; i++) cin >> A[i];

    // 第二步:构建前缀和
    vector<long long> P(n + 1, 0);  // P[0] = 0(基础情况)
    for (int i = 1; i <= n; i++) {
        P[i] = P[i - 1] + A[i];   // ← 关键行:每个 P[i] = 到 i 为止所有元素之和
    }

    return 0;
}

复杂度分析:

  • 时间: O(N) —— 遍历数组一次
  • 空间: O(N) —— 存储前缀数组

A = [3, 1, 4, 1, 5] 的逐步追踪:

i=1: P[1] = P[0] + A[1] = 0 + 3 = 3
i=2: P[2] = P[1] + A[2] = 3 + 1 = 4
i=3: P[3] = P[2] + A[3] = 4 + 4 = 8
i=4: P[4] = P[3] + A[4] = 8 + 1 = 9
i=5: P[5] = P[4] + A[5] = 9 + 5 = 14

3.2.2 O(1) 区间求和查询

有了前缀和数组,下标 L 到 R 的和就是:

sum(L, R) = P[R] - P[L-1]

为什么? P[R] = 1..R 的元素之和。P[L-1] = 1..(L-1) 的元素之和。两者之差 = L..R 的元素之和。

💡 核心思路: 把 P[i] 理解为「前 i 个元素的总和」。要得到窗口 [L, R] 的和,就从「到 R 的前缀」中减去「L 之前的前缀」。就像:大三角形减去小三角形 = 梯形。

📄 C++ 完整代码
// 区间求和查询 — 预处理 O(N),每次查询 O(1)
#include <bits/stdc++.h>
using namespace std;

const int MAXN = 100001;
long long A[MAXN];
long long P[MAXN];

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

    int n, q;
    cin >> n >> q;

    // 第一步:读取数组
    for (int i = 1; i <= n; i++) cin >> A[i];

    // 第二步:构建前缀和 — O(n)
    P[0] = 0;
    for (int i = 1; i <= n; i++) {
        P[i] = P[i - 1] + A[i];
    }

    // 第三步:回答 q 个区间求和查询 — 每次 O(1)
    for (int i = 0; i < q; i++) {
        int l, r;
        cin >> l >> r;
        cout << P[r] - P[l - 1] << "\n";  // ← 关键行:区间和公式
    }

    return 0;
}

样例输入:

8 3
3 1 4 1 5 9 2 6
1 4
3 7
2 6

样例输出:

9
21
20

验证:

  • sum(1,4) = P[4] - P[0] = 9 - 0 = 9 → A[1]+A[2]+A[3]+A[4] = 3+1+4+1 = 9 ✓
  • sum(3,7) = P[7] - P[2] = 25 - 4 = 21 → A[3]+...+A[7] = 4+1+5+9+2 = 21 ✓
  • sum(2,6) = P[6] - P[1] = 23 - 3 = 20 → A[2]+...+A[6] = 1+4+1+5+9 = 20 ✓

⚠️ 常见错误: 写成 P[R] - P[L] 而不是 P[R] - P[L-1]。公式包含 L 和 R 两个端点——你要减去 L 之前的和,不是 L 的和。

总复杂度: O(N + Q) —— 对 N、Q 最大 10^5 完全没问题。


3.2.3 USACO 示例:品种统计

这是一道经典的 USACO Bronze 题(2015 年 12 月)。

题目: N 头奶牛排成一列,每头的品种是 1、2 或 3。回答 Q 个查询:位置 L 到 R 中有多少头品种为 B 的奶牛?

解法: 每种品种维护一个前缀和数组。

📄 C++ 完整代码
// 多品种前缀和 — O(N + Q)
#include <bits/stdc++.h>
using namespace std;

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

    int n, q;
    cin >> n >> q;

    vector<int> breed(n + 1);
    vector<vector<long long>> P(4, vector<long long>(n + 1, 0));
    // P[b][i] = 位置 1..i 中品种 b 的奶牛数量

    // 第一步:为每种品种构建前缀和
    for (int i = 1; i <= n; i++) {
        cin >> breed[i];
        for (int b = 1; b <= 3; b++) {
            P[b][i] = P[b][i - 1] + (breed[i] == b ? 1 : 0);  // ← 关键行
        }
    }

    // 第二步:每次查询 O(1) 回答
    for (int i = 0; i < q; i++) {
        int l, r, b;
        cin >> l >> r >> b;
        cout << P[b][r] - P[b][l - 1] << "\n";
    }

    return 0;
}

🏆 USACO 技巧: 很多 USACO Bronze 题涉及「统计范围内满足属性 X 的元素个数」。如果 Q 较大,始终考虑前缀和。


3.2.4 USACO 风格题目详解:FJ 的草地

🔗 相关题目: 这是一道受「品种统计」和「最高奶牛」启发的虚构 USACO 风格题目——两者都是经典 Bronze 题。

题目: FJ 有 N 块连续的田地,第 i 块有 grass[i] 单位的草。他需要回答 Q 个查询:「第 L 块到第 R 块(含)的草总量是多少?」N、Q 最大 10^5,每次查询需要 O(1) 回答。

样例输入:

6 4
4 2 7 1 8 3
1 3
2 5
4 6
1 6

样例输出:

13
18
12
25

逐步解法:

第一步: 理解题目。我们有数组 [4, 2, 7, 1, 8, 3],需要区间求和。

第二步: 构建前缀和数组。

下标:  0  1  2  3  4  5  6
草量:  -  4  2  7  1  8  3
P:      0  4  6  13 14 22 25

第三步:P[R] - P[L-1] 回答查询:

  • 查询 (1,3):P[3] - P[0] = 13 - 0 = 13
  • 查询 (2,5):P[5] - P[1] = 22 - 4 = 18
  • 查询 (4,6):P[6] - P[3] = 25 - 13 = 12
  • 查询 (1,6):P[6] - P[0] = 25 - 0 = 25

完整 C++ 解法:

📄 C++ 完整代码
// FJ 的草地 — 前缀和解法 O(N + Q)
#include <bits/stdc++.h>
using namespace std;

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

    int n, q;
    cin >> n >> q;

    // 第一步:读取草量并同步构建前缀和
    vector<long long> P(n + 1, 0);
    for (int i = 1; i <= n; i++) {
        long long g;
        cin >> g;
        P[i] = P[i - 1] + g;   // ← 关键行:增量前缀和
    }

    // 第二步:每次查询 O(1) 回答
    while (q--) {
        int l, r;
        cin >> l >> r;
        cout << P[r] - P[l - 1] << "\n";
    }

    return 0;
}

为什么是 O(N + Q)

  • 构建前缀和:一个循环,N 次迭代 → O(N)
  • 每次查询:一次减法 → 每次 O(1),共 O(Q)
  • 总计:O(N + Q) —— 远好于暴力的 O(NQ)

⚠️ 常见错误: 前缀和用 int 而不是 long long。如果草量最大 10^9 且 N = 10^5,总和可达 10^14——远超 int 约 2×10^9 的范围。


3.2.5 二维前缀和

对于二维网格,可以扩展前缀和在 O(1) 时间内回答矩形区间查询。

给定 R×C 的网格,定义 P[r][c] = 从 (1,1) 到 (r,c) 矩形内所有元素之和。

构建二维前缀和

P[r][c] = A[r][c] + P[r-1][c] + P[r][c-1] - P[r-1][c-1]

减法是为了消除重叠(否则左上角矩形会被计算两次)。

💡 核心思路(容斥原理): 想象四个矩形:

  • P[r-1][c] = 「上方」矩形
  • P[r][c-1] = 「左方」矩形
  • P[r-1][c-1] = 「左上角」(在上面两个中都计算了——所以减去一次)
  • A[r][c] = 单个新单元格

二维前缀和逐步工作示例

追踪一个 4×4 网格:

原始网格 A:

     c=1  c=2  c=3  c=4
r=1:  1    2    3    4
r=2:  5    6    7    8
r=3:  9   10   11   12
r=4: 13   14   15   16

逐步构建 P(从左到右、从上到下):

📄 Code 完整代码
P[1][1] = A[1][1] = 1
P[1][2] = 2 + 0 + 1 - 0 = 3
P[1][3] = 3 + 0 + 3 - 0 = 6
P[1][4] = 4 + 0 + 6 - 0 = 10
P[2][1] = 5 + 1 + 0 - 0 = 6
P[2][2] = 6 + 3 + 6 - 1 = 14
P[2][3] = 7 + 6 + 14 - 3 = 24
P[2][4] = 8 + 10 + 24 - 6 = 36
P[3][1] = 9 + 6 + 0 - 0 = 15
P[3][2] = 10 + 14 + 15 - 6 = 33
P[3][3] = 11 + 24 + 33 - 14 = 54
P[3][4] = 12 + 36 + 54 - 24 = 78
P[4][1] = 13 + 15 + 0 - 0 = 28
P[4][2] = 14 + 33 + 28 - 15 = 60
P[4][3] = 15 + 54 + 60 - 33 = 96
P[4][4] = 16 + 78 + 96 - 54 = 136

前缀和网格 P:

     c=1  c=2  c=3  c=4
r=1:  1    3    6   10
r=2:  6   14   24   36
r=3: 15   33   54   78
r=4: 28   60   96  136

查询:子网格 (r1=2, c1=2) 到 (r2=3, c2=3) 的和:

ans = P[3][3] - P[1][3] - P[3][1] + P[1][1]
    = 54     -  6     -  15     +  1
    = 34

验证:A[2][2]+A[2][3]+A[3][2]+A[3][3] = 6+7+10+11 = 34 ✓

容斥原理图示:

2D Prefix Sum Inclusion-Exclusion

📄 C++ 完整代码
// 二维前缀和 — 构建 O(R×C),查询 O(1)
#include <bits/stdc++.h>
using namespace std;

const int MAXR = 1001, MAXC = 1001;
int A[MAXR][MAXC];
long long P[MAXR][MAXC];

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

    int R, C;
    cin >> R >> C;

    for (int r = 1; r <= R; r++)
        for (int c = 1; c <= C; c++)
            cin >> A[r][c];

    // 第一步:构建二维前缀和 — O(R × C)
    for (int r = 1; r <= R; r++) {
        for (int c = 1; c <= C; c++) {
            P[r][c] = A[r][c]
                    + P[r-1][c]    // 上方矩形
                    + P[r][c-1]    // 左方矩形
                    - P[r-1][c-1]; // ← 关键行:消除重叠(被计算了两次)
        }
    }

    // 第二步:每次查询 O(1) 回答
    int q;
    cin >> q;
    while (q--) {
        int r1, c1, r2, c2;
        cin >> r1 >> c1 >> r2 >> c2;
        long long ans = P[r2][c2]
                      - P[r1-1][c2]    // 减去上方条带
                      - P[r2][c1-1]    // 减去左方条带
                      + P[r1-1][c1-1]; // 加回左上角
        cout << ans << "\n";
    }

    return 0;
}

复杂度分析:

  • 构建时间: O(R × C)
  • 查询时间: 每次 O(1)
  • 空间: O(R × C)

⚠️ 常见错误: 查询公式中忘记加回 P[r1-1][c1-1]。上方条带和左方条带都包含左上角,所以被减了两次——需要加回一次!


3.2.6 差分数组

前面我们学习了前缀和:它擅长解决「很多次区间求和」问题。

现在来看一个看起来相反的问题:

有一个长度为 N 的数组,一开始全是 0。接下来有很多次操作,每次都要把一整段 [L, R] 全部加上一个值 V。最后输出整个数组。

如果直接模拟,每次操作都从 L 循环到 R

for (int i = L; i <= R; i++) {
    A[i] += V;
}

这当然能做对,但如果 N 和操作次数 M 都是 100000,最坏情况下就是 10^10 次加法,太慢。

差分数组解决的核心问题就是:

如何不用真的修改区间里的每一个元素,也能表达「整个区间都加了 V」?


先从一个生活例子理解

想象一条直线跑道,有 8 个位置:

位置: 1  2  3  4  5  6  7  8
数值: 0  0  0  0  0  0  0  0

现在要把 [3, 6] 全部加 5。

朴素做法是给 3、4、5、6 每个位置都写上 +5

但差分数组不这样做。它只记录两件事:

从位置 3 开始,加 5 生效
从位置 7 开始,加 5 失效

也就是:

diff[3] += 5;
diff[7] -= 5;

为什么这样就够了?因为最后我们会从左到右累加这些「变化标记」。

diff: 0  0 +5  0  0  0 -5  0
累加: 0  0  5  5  5  5  0  0
位置: 1  2  3  4  5  6  7  8

你会发现,位置 3 到 6 正好都变成了 5,位置 7 开始又恢复为 0。

这就是差分数组的全部直觉:

区间更新不直接改区间,而是在区间起点放一个「开始变化」标记,在区间终点后一个位置放一个「停止变化」标记。

Difference Array Range Update


差分数组到底存的是什么?

如果原数组是:

A:     2   5   5   9   7

那么差分数组 diff 存的是「当前位置相对于前一个位置变了多少」:

diff[1] = A[1] - 0    = 2
diff[2] = A[2] - A[1] = 3
diff[3] = A[3] - A[2] = 0
diff[4] = A[4] - A[3] = 4
diff[5] = A[5] - A[4] = -2

所以:

A:     2   5   5   9   7
diff:  2   3   0   4  -2

如果对 diff 做前缀和,就能还原 A

2
2 + 3 = 5
2 + 3 + 0 = 5
2 + 3 + 0 + 4 = 9
2 + 3 + 0 + 4 - 2 = 7

因此可以把差分数组理解成:

diff[i] 记录的是「从第 i 个位置开始,数值发生了多少变化」。


为什么区间加法只需要改两个位置?

假设要给 [L, R] 都加上 V

从位置 L 开始,后面的值都应该多 V,所以:

diff[L] += V;

但是这个影响只能持续到 R,从 R+1 开始就不应该再多 V,所以要抵消掉:

diff[R + 1] -= V;

合起来就是差分数组最重要的公式:

diff[L] += V;
diff[R + 1] -= V;

注意:这就是为什么 diff 通常要开到 n + 2。当 R = n 时,我们仍然会访问 diff[n + 1]


完整例子:三次区间更新

从长度为 5 的全零数组开始:

A:     0  0  0  0  0

执行三次操作:

1) [1, 3] 加 2
2) [2, 5] 加 3
3) [3, 4] 加 -1

我们不直接修改 A,只修改 diff

第 1 次操作:[1, 3] +2

diff[1] += 2;
diff[4] -= 2;
diff[1..6] = [2, 0, 0, -2, 0, 0]

含义是:从 1 开始多 2,从 4 开始取消这次 +2。

第 2 次操作:[2, 5] +3

diff[2] += 3;
diff[6] -= 3;
diff[1..6] = [2, 3, 0, -2, 0, -3]

第 3 次操作:[3, 4] -1

diff[3] += -1;
diff[5] -= -1;   // 等价于 diff[5] += 1
diff[1..6] = [2, 3, -1, -2, 1, -3]

现在对 diff 做前缀和,还原最终数组:

i=1: 2            -> A[1] = 2
i=2: 2 + 3 = 5    -> A[2] = 5
i=3: 5 - 1 = 4    -> A[3] = 4
i=4: 4 - 2 = 2    -> A[4] = 2
i=5: 2 + 1 = 3    -> A[5] = 3

最终结果:

A:2 5 4 2 3

Difference Array Reconstruction


完整 C++ 实现

📄 C++ 完整代码
// 差分数组实现区间更新 — O(N + M)
#include <bits/stdc++.h>
using namespace std;

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

    int n, m;
    cin >> n >> m;

    // diff[i] 表示从位置 i 开始,当前值发生了多少变化
    // 多开 1 位:当更新区间右端点 r = n 时,需要访问 diff[n + 1]
    vector<long long> diff(n + 2, 0);

    // 第一步:处理所有区间更新
    for (int i = 0; i < m; i++) {
        int l, r;
        long long v;
        cin >> l >> r >> v;

        diff[l] += v;      // 从 l 开始,增加 v 生效
        diff[r + 1] -= v;  // 从 r+1 开始,取消这次增加
    }

    // 第二步:对 diff 做前缀和,得到最终数组
    long long cur = 0;
    for (int i = 1; i <= n; i++) {
        cur += diff[i];
        cout << cur;
        if (i < n) cout << " ";
    }
    cout << "\n";

    return 0;
}

样例输入:

5 3
1 3 2
2 5 3
3 4 -1

样例输出:

2 5 4 2 3

什么时候用差分数组?

差分数组适合这种场景:

很多次区间修改,最后统一查询结果

例如:

  • [L, R] 所有元素加上 V
  • 多次刷墙,每次刷一段区间
  • 多次给道路区间增加交通量
  • 多次给一段时间内的在线人数加 1

但要注意:

普通差分数组不适合「一边修改、一边查询区间和」的动态问题。

如果题目要求更新和查询交替出现,通常需要树状数组或线段树。


差分数组常见错误

  1. 数组开小了: diff 应该开 n + 2,因为会访问 diff[r + 1]
  2. 忘记最后做前缀和: diff 本身不是答案,前缀和之后才是最终数组。
  3. diff[r + 1] -= v 写成 diff[r] -= v 这样会让位置 r 没有被加到。
  4. 数值可能溢出: 多次区间加法后数值可能很大,建议用 long long

一句话记忆:

左端点开始加,右端点后停止加;最后扫一遍,变化变成答案。


3.2.7 二维差分数组

一维差分数组解决的是「给一段连续区间加值」。

二维差分数组解决的是类似问题:

有一个 R × C 的网格,一开始全是 0。每次操作给一个矩形区域整体加上 V,最后输出整个网格。

例如:

给左上角 (r1, c1) 到右下角 (r2, c2) 的整个矩形都加 V

如果直接模拟,需要枚举矩形里的每一个格子。矩形很大、操作很多时会非常慢。

二维差分的思想和一维完全一样:

不要真的修改整个矩形,只在矩形的边界上做标记。最后通过二维前缀和把这些标记扩散成最终结果。


从一维公式推广到二维

一维区间 [L, R] + V 的标记是:

diff[L] += V;
diff[R + 1] -= V;

它的意思是:

从 L 开始生效,从 R+1 开始失效

二维矩形有两个方向:行方向和列方向。

如果只写:

diff[r1][c1] += V;

那么做二维前缀和时,V 会影响从 (r1,c1) 开始往右下方的整个大矩形,而不只是我们想要的 [r1,c1]..[r2,c2]

所以我们需要在右边和下边把影响取消掉,再把右下角被重复取消的部分加回来。

四个角的公式是:

diff[r1][c1]         += V;  // 从左上角开始生效
diff[r1][c2 + 1]     -= V;  // 到右边界之后,取消横向影响
diff[r2 + 1][c1]     -= V;  // 到下边界之后,取消纵向影响
diff[r2 + 1][c2 + 1] += V;  // 右下角被取消了两次,加回来一次

这四个标记就是二维差分的核心。

2D Difference Array Four Corners


用图理解四角标记

假设我们想给这个矩形加 V

          c1        c2
          ↓         ↓
      .   .   .   .   .
r1 -> .  +V  +V  +V   .
      .  +V  +V  +V   .
r2 -> .  +V  +V  +V   .
      .   .   .   .   .

我们放四个标记:

左上角:      +V   让影响开始
右上角外侧:  -V   让影响不要继续向右
左下角外侧:  -V   让影响不要继续向下
右下角外侧:  +V   修正右下方被减了两次的问题

也就是:

(r1, c1)       放 +V
(r1, c2 + 1)   放 -V
(r2 + 1, c1)   放 -V
(r2 + 1, c2+1) 放 +V

完整例子:两个矩形更新

一个 3 × 3 网格,初始全是 0。

执行两次更新:

1) update(1, 1, 2, 2, +5)
2) update(2, 2, 3, 3, +3)

第 1 次更新:左上 2 × 2 加 5

diff[1][1] += 5;
diff[1][3] -= 5;
diff[3][1] -= 5;
diff[3][3] += 5;

第 2 次更新:右下 2 × 2 加 3

diff[2][2] += 3;
diff[2][4] -= 3;
diff[4][2] -= 3;
diff[4][4] += 3;

所有标记完成后,diff 大致是:

       c=1  c=2  c=3  c=4
r=1:    5    0   -5    0
r=2:    0    3    0   -3
r=3:   -5    0    5    0
r=4:    0   -3    0    3

现在对它做二维前缀和:

diff[r][c] += diff[r-1][c] + diff[r][c-1] - diff[r-1][c-1];

恢复出的最终网格是:

       c=1  c=2  c=3
r=1:    5    5    0
r=2:    5    8    3
r=3:    0    3    3

中间的 (2,2) 等于 8,因为它同时被 +5+3 两个矩形覆盖。


完整 C++ 实现

📄 C++ 完整代码
// 二维差分数组 — 每次矩形更新 O(1),最终重建 O(R × C)
#include <bits/stdc++.h>
using namespace std;

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

    int R, C, M;
    cin >> R >> C >> M;

    // 多开一圈哨兵:当 r2=R 或 c2=C 时,会访问 r2+1 或 c2+1
    vector<vector<long long>> diff(R + 2, vector<long long>(C + 2, 0));

    // 第一步:每次 O(1) 标记一个矩形更新
    while (M--) {
        int r1, c1, r2, c2;
        long long v;
        cin >> r1 >> c1 >> r2 >> c2 >> v;

        diff[r1][c1]         += v;
        diff[r1][c2 + 1]     -= v;
        diff[r2 + 1][c1]     -= v;
        diff[r2 + 1][c2 + 1] += v;
    }

    // 第二步:二维前缀和重建最终网格
    for (int r = 1; r <= R; r++) {
        for (int c = 1; c <= C; c++) {
            diff[r][c] += diff[r - 1][c]
                        + diff[r][c - 1]
                        - diff[r - 1][c - 1];
        }
    }

    // 第三步:输出最终网格
    for (int r = 1; r <= R; r++) {
        for (int c = 1; c <= C; c++) {
            cout << diff[r][c];
            if (c < C) cout << " ";
        }
        cout << "\n";
    }

    return 0;
}

二维差分什么时候用?

二维差分适合:

很多次矩形区域加法,最后统一得到整个网格

常见题型包括:

  • 多次给矩形区域涂色,最后问每个格子的颜色或覆盖次数
  • 多个矩形广告牌叠加,统计最终亮度
  • 给地图上一块矩形区域增加高度、温度或权重
  • 离线处理大量矩形更新

二维差分常见错误

  1. 四角符号写反: 记住是 + - - +
  2. 数组开小: 要能访问 r2 + 1c2 + 1,所以至少开到 R + 2C + 2
  3. 忘记最后二维前缀和: 四角标记只是变化,不是最终网格。
  4. 重建公式写错: 应该是 上 + 左 - 左上,即:
diff[r][c] += diff[r-1][c] + diff[r][c-1] - diff[r-1][c-1];

一句话记忆:

矩形更新看四角:左上加,右上减,左下减,右下加;最后做二维前缀和。


3.2.8 USACO 示例:最大子数组和

题目(Kadane 算法的变体): 找连续子数组的最大和。

📄 C++ 完整代码
// Kadane 算法 — O(N) 时间,O(1) 空间
#include <bits/stdc++.h>
using namespace std;

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

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

    // Kadane 算法:O(n)
    long long maxSum = LLONG_MIN;  // 最小 long long
    long long current = 0;

    for (int i = 0; i < n; i++) {
        current += A[i];
        maxSum = max(maxSum, current);
        if (current < 0) current = 0;  // ← 关键行:和为负时重新开始
    }

    cout << maxSum << "\n";

    return 0;
}

💡 核心思路: 为什么当 current 为负时重置为 0?因为负的前缀和只会损害之后的任何子数组。如果当前运行和是 -5,任何从头开始(和为 0)的子数组都比继续 -5 要好。

用前缀和的替代方案: 最大子数组和等于 P[j] - P[i-1] 对所有对 (i,j) 的最大值。对于每个 j,当 P[i-1] 最小时取得最大。追踪运行中最小前缀和!

// 替代方案:最小前缀技巧 — 同样 O(N)
long long maxSum = LLONG_MIN, minPrefix = 0, prefix = 0;
for (int x : A) {
    prefix += x;
    maxSum = max(maxSum, prefix - minPrefix);  // 到此处结束的最优和
    minPrefix = min(minPrefix, prefix);         // 追踪目前见过的最小前缀
    // ⚠️ 注意:minPrefix 的更新必须在 maxSum 之后。
    // 若提前更新 minPrefix,相当于允许空子数组(长度为0)参与比较,
    // 会导致结果在全负数组时错误地返回 0 而非最大负数。
}

3.2.8 USACO 真题训练:把区间问题变成前缀差

前缀和/差分在 USACO 中通常不是以「请你写前缀和」出现,而是藏在下面这些题面信号里:

题面信号首选技术判断依据
多次询问 [L,R] 内某类元素数量/总和前缀和原数组不变,查询很多
多次给 [L,R] 加值,最后统一询问差分数组更新很多,但不穿插查询
网格中多次询问矩形区域二维前缀和矩形求和可用容斥
网格中多次给矩形加值二维差分四角标记,最后重建

真题 1:Breed Counting(USACO 2015 December Silver)— 多类别前缀和

题目链接: USACO 2015 December Silver P3: Breed Counting
对应模式: 多个前缀和数组
难度定位: Silver 入门

题干解读

N 头奶牛排成一行,每头奶牛的品种是 1、2、3 之一。接下来有 Q 次查询,每次给出区间 [L,R],要求输出这个区间里三种品种各有多少头。

关键条件:

  • 队伍顺序固定,不会修改。
  • 查询次数很多,不能每次扫描 [L,R]
  • 品种只有 3 类,可以为每一类单独建前缀和。

思路分析

对每个品种 b 建一个前缀数组:

prefix[b][i] = 前 i 头奶牛中,品种 b 出现的次数

区间 [L,R] 中品种 b 的数量就是:

prefix[b][R] - prefix[b][L-1]

这和普通前缀和完全一样,只是把「数值求和」变成了「某个类别是否出现」。

CPP 完整代码

✅ 完整代码:Breed Counting
#include <bits/stdc++.h>
using namespace std;

int main() {
    ios::sync_with_stdio(false);
    cin.tie(nullptr);

    // USACO 官方文件 I/O 可按需打开:
    // freopen("bcount.in", "r", stdin);
    // freopen("bcount.out", "w", stdout);

    int n, q;
    cin >> n >> q;

    vector<array<int, 4>> prefix(n + 1);  // 只使用下标 1..3

    for (int i = 1; i <= n; i++) {
        int breed;
        cin >> breed;
        prefix[i] = prefix[i - 1];        // 继承前 i-1 头的计数
        prefix[i][breed]++;               // 当前品种加 1
    }

    while (q--) {
        int l, r;
        cin >> l >> r;
        for (int b = 1; b <= 3; b++) {
            int count = prefix[r][b] - prefix[l - 1][b];
            cout << count << " \n"[b == 3];
        }
    }

    return 0;
}

复杂度: 预处理 O(N × 3),每次查询 O(3),总复杂度 O(N+Q),空间 O(3N)

易错点提醒

  1. prefix[i] = prefix[i-1] 漏掉。 只给当前品种加 1 会丢失之前所有计数。
  2. L-1 下标越界。 使用 1-indexed 前缀和时,prefix[0] 是哨兵,查询从 L=1 也安全。
  3. 输出格式错误。 每个查询输出 3 个数字,最后换行。
  4. 文件名混淆。 官方文件 I/O 是 bcount.in/out,不是 breed.in/out

拓展思考

如果品种数量从 3 变成 10^5,但每次只询问一种品种,可以改用 map<breed, vector<int>> 存每个品种出现位置,再用二分查找统计区间内出现次数。选择数据结构时始终看「类别数 × 查询模式」。


真题 2:Haybale Stacking(USACO 2012 January Bronze)— 区间加法的差分数组

题目链接: USACO 2012 January Bronze P2: Haybale Stacking
对应模式: 差分数组 + 最终重建
难度定位: Bronze/Silver 基础

题干解读

N 堆草,初始高度都是 0。接下来 K 条指令,每条指令给区间 [A,B] 内每一堆草加 1。所有操作完成后,输出草堆高度的中位数。

关键条件:

  • N 可到 10^6K 可到 25000
  • 每次直接遍历 [A,B] 可能达到 O(NK),太慢。
  • 题目只问所有操作完成后的结果,不需要中途查询。

思路分析

差分数组把一次区间加法变成两个端点标记:

diff[A] += 1
diff[B+1] -= 1

最后从左到右取前缀和,就能恢复每个位置最终高度。然后排序高度数组,取中位数。

CPP 完整代码

✅ 完整代码:Haybale Stacking
#include <bits/stdc++.h>
using namespace std;

int main() {
    ios::sync_with_stdio(false);
    cin.tie(nullptr);

    // USACO 官方文件 I/O 可按需打开:
    // freopen("stacking.in", "r", stdin);
    // freopen("stacking.out", "w", stdout);

    int n, k;
    cin >> n >> k;

    vector<int> diff(n + 2, 0);  // 需要写到 b+1,最大可能是 n+1

    for (int i = 0; i < k; i++) {
        int a, b;
        cin >> a >> b;
        diff[a] += 1;
        diff[b + 1] -= 1;
    }

    vector<int> height(n + 1);
    int current = 0;
    for (int i = 1; i <= n; i++) {
        current += diff[i];
        height[i] = current;
    }

    sort(height.begin() + 1, height.end());
    cout << height[(n + 1) / 2] << "\n";  // n 为奇数,中位数下标固定

    return 0;
}

复杂度: 区间更新 O(K),重建 O(N),排序 O(N log N),空间 O(N)

易错点提醒

  1. diff 数组开小。 必须开到 n+2,因为 B=n 时会写 diff[n+1]
  2. 忘记重建。 差分数组不是最终高度,必须再做一次前缀和。
  3. 中位数下标错。 本题 N 是奇数,用 1-indexed 高度数组时中位数是 (N+1)/2
  4. 误以为需要线段树。 没有中途查询,差分数组更简单也更快。

拓展思考

如果题目改成「每次区间加后立刻查询某个位置/区间」,差分数组就不够了,需要树状数组或线段树。这正是第 5.8 章和第 5.7 章要解决的问题。


⚠️ 第 3.2 章常见错误

  1. 区间查询差一:P[R] - P[L] 而不是 P[R] - P[L-1]。始终用小例子验证。
  2. 溢出: 大值的前缀和可能超过 int 范围(2×10^9)。即使元素是 int,前缀数组也要用 long long
  3. 二维查询公式: 忘了二维查询中的 +P[r1-1][c1-1] 项——非常容易疏忽。
  4. 差分数组大小: 声明 diff[n+1] 但需要 diff[n+2](因为要写入下标 r+1,可能是 n+1)。
  5. 1-indexed vs 0-indexed: 用 0-indexed 前缀和时,查询公式变为 P[R+1] - P[L]。在一道题内选定一种约定并坚持用。
  6. 二维差分数组大小: 声明 diff[R+1][C+1] 但需要 diff[R+2][C+2]——四角更新会写入 (r2+1, c2+1),必须在范围内。
  7. 二维差分重建顺序: 二维前缀和重建必须从左到右、从上到下处理单元格(与构建二维前缀和的顺序相同)。顺序混乱会产生错误结果。

本章总结

📌 核心要点

技术构建时间查询时间空间使用场景
一维前缀和O(N)O(1)O(N)一维数组的区间和
二维前缀和O(RC)O(1)O(RC)二维网格的矩形和
差分数组O(N+M)O(1)*O(N)区间加法更新
二维差分数组O(RC+M)O(1)*O(RC)二维网格上的矩形加法
Kadane 算法O(N)O(1)最大子数组和

*需要 O(N) 的重建遍历后才能读取所有值。

🧩 核心公式速查

操作公式备注
一维区间和P[R] - P[L-1]P[0] = 0 是哨兵值
二维矩形和P[r2][c2] - P[r1-1][c2] - P[r2][c1-1] + P[r1-1][c1-1]容斥:减两次,加一次
差分数组更新diff[L] += V; diff[R+1] -= V;数组大小应为 N+2
二维差分更新diff[r1][c1]+=V; diff[r1][c2+1]-=V; diff[r2+1][c1]-=V; diff[r2+1][c2+1]+=V四角标记
从差分恢复对 diff 取前缀和(一维或二维)结果是最终数组

❓ 常见问题

Q1:前缀和和差分数组有什么关系?

A:它们是逆运算。对数组取前缀和得到前缀和数组;对前缀和数组取差分(相邻元素差)则恢复原数组。之后对这些标记取前缀和,+V 和 -V 在 [L,R] 外相互抵消,净效果恰好是给 [L,R] 加了 V。

Q2:什么时候用前缀和 vs 差分数组?

A:经验法则——看操作类型:

  • 多次区间求和查询 → 前缀和(预处理 O(N),查询 O(1)
  • 多次区间加减操作 → 差分数组(更新 O(1),最后恢复 O(N)
  • 两种操作交替出现时,需要更高级的数据结构(如第 5.7 章的线段树)

Q3:前缀和能处理动态修改吗?(数组元素改变)

A:不能。前缀和是一次性预处理,之后数组不能改变。如果元素被修改,用树状数组(BIT)线段树,它们支持单点更新和 O(log N) 的区间查询。

Q4:为什么 Kadane 算法有两个版本(current=0 vs minPrefix)?

A:两者本质相同,都是 O(N)。第一种(经典 Kadane)更直觉:当前子数组和变负时重新开始。第二种(最小前缀法)用前缀和思维:最大子数组 = max(P[j] - P[i]) = max(P[j]) - min(P[i])。按个人喜好选择。

Q5:二维前缀和的空间限制是什么?

A:若 R、C 都最大 10^4,P 数组需要 10^8 个 long long(约 800MB)——超出内存限制。一般 R×C ≤ 10^6~10^7 是安全的。更大的网格考虑压缩或离线处理。

🔗 与后续章节的联系

  • 第 3.4 章(双指针):滑动窗口也能做区间查询,但只适用于固定大小或单调移动的窗口;前缀和更通用
  • 第 3.3 章(排序与搜索):二分查找可以与前缀和结合——例如在前缀和数组上二分查找第一个 ≥ 目标值的位置
  • 第 5.7 章(线段树):解决前缀和无法处理的「动态更新 + 区间查询」问题
  • 第 6.1–6.3 章(动态规划):很多状态转移涉及区间和;前缀和是优化 DP 的重要工具
  • 差分数组的思想(「在起点 +V,在终点后 -V」)在扫描线算法、事件排序等高级技术中反复出现

练习题

题目 3.2.1 — 区间求和 🟢 简单 读取 N 个整数和 Q 个查询,每个查询给出 L 和 R,打印下标 L 到 R(1-indexed)的元素之和。

提示 构建前缀和数组 P,其中 P[i] = A[1]+...+A[i],每次查询回答 P[R] - P[L-1]。
✅ 完整题解

核心思路: O(N) 预计算前缀和,每次查询 O(1) 回答 P[R] - P[L-1]

#include <bits/stdc++.h>
using namespace std;
int main() {
    ios_base::sync_with_stdio(false); cin.tie(NULL);
    int n, q; cin >> n >> q;
    vector<long long> P(n + 1, 0);
    for (int i = 1; i <= n; i++) {
        int x; cin >> x;
        P[i] = P[i-1] + x;
    }
    while (q--) {
        int l, r; cin >> l >> r;
        cout << P[r] - P[l-1] << "\n";
    }
}

复杂度: O(N + Q) —— 远好于朴素 O(N × Q)。


题目 3.2.2 — 区间加法,单点查询 🟢 简单 从 N 个零开始,处理 M 次操作:每次给 L 到 R 的所有位置加 V。所有操作后打印每个位置的值。

提示 对每次更新用 `diff[L]` += V,`diff[R+1]` -= V,然后对 diff 取前缀和。
✅ 完整题解

核心思路: 差分数组。每次区间加法只影响 diff 的 2 个位置,最终值通过前缀和得到。

#include <bits/stdc++.h>
using namespace std;
int main() {
    int n, m; cin >> n >> m;
    vector<long long> diff(n + 2, 0);
    while (m--) {
        int l, r; long long v; cin >> l >> r >> v;
        diff[l] += v;
        diff[r+1] -= v;
    }
    long long cur = 0;
    for (int i = 1; i <= n; i++) {
        cur += diff[i];
        cout << cur << " \n"[i == n];
    }
}

复杂度: O(N + M) —— 每次更新 O(1),最后扫描 O(N)。


题目 3.2.3 — 矩形求和 🟡 中等 读取 N×M 网格和 Q 个查询,每个查询给出 (r1,c1,r2,c2),打印子网格的和。

提示 二维前缀和。查询 = P[r2][c2] - P[r1-1][c2] - P[r2][c1-1] + P[r1-1][c1-1]。
✅ 完整题解

核心思路: 二维前缀和。P[i][j] = 从 (1,1) 到 (i,j) 的矩形和,对任意矩形查询用容斥相减。

#include <bits/stdc++.h>
using namespace std;
int main() {
    ios_base::sync_with_stdio(false); cin.tie(NULL);
    int n, m, q; cin >> n >> m >> q;
    vector<vector<long long>> P(n+1, vector<long long>(m+1, 0));
    for (int i = 1; i <= n; i++)
        for (int j = 1; j <= m; j++) {
            int x; cin >> x;
            P[i][j] = x + P[i-1][j] + P[i][j-1] - P[i-1][j-1];
        }
    while (q--) {
        int r1, c1, r2, c2; cin >> r1 >> c1 >> r2 >> c2;
        cout << P[r2][c2] - P[r1-1][c2] - P[r2][c1-1] + P[r1-1][c1-1] << "\n";
    }
}

复杂度: O(N × M + Q)。


题目 3.2.4 — USACO 2016 January Bronze:割草 🔴 困难 FJ 沿一条路径割草,被访问超过一次的格子构成「双重割草」区域,统计至少被访问两次的格子数。

提示 模拟路径,在二维访问计数中标记格子,统计值 ≥ 2 的格子。
✅ 完整题解

核心思路: 直接模拟——不需要复杂数据结构。沿路径走,对每个访问的格子递增二维计数器。

#include <bits/stdc++.h>
using namespace std;
int main() {
    int n; cin >> n;
    map<pair<int,int>, int> cnt;
    int x = 0, y = 0; cnt[{x,y}]++;
    while (n--) {
        char dir; int steps; cin >> dir >> steps;
        int dx = (dir=='E') - (dir=='W');
        int dy = (dir=='N') - (dir=='S');
        while (steps--) {
            x += dx; y += dy;
            cnt[{x,y}]++;
        }
    }
    int doubleMowed = 0;
    for (auto& [pos, c] : cnt) if (c >= 2) doubleMowed++;
    cout << doubleMowed << "\n";
}

复杂度: O(总步数 × log),受 map 操作主导。


题目 3.2.5 — 二维区间加法 🟡 中等 N×M 网格(初始全零),Q 次操作每次给矩形 [r1,c1][r2,c2] 加 V,输出最终网格。

提示 二维差分数组:每次更新标记 4 个角,然后通过二维前缀和重建。
✅ 完整题解

核心思路: 二维差分数组。每次矩形更新只触及 4 个角。最终网格 = 差分数组的二维前缀和。

#include <bits/stdc++.h>
using namespace std;
int main() {
    int n, m, q; cin >> n >> m >> q;
    vector<vector<long long>> D(n+2, vector<long long>(m+2, 0));
    while (q--) {
        int r1, c1, r2, c2; long long v;
        cin >> r1 >> c1 >> r2 >> c2 >> v;
        D[r1][c1] += v;
        D[r1][c2+1] -= v;
        D[r2+1][c1] -= v;
        D[r2+1][c2+1] += v;
    }
    for (int i = 1; i <= n; i++)
        for (int j = 1; j <= m; j++)
            D[i][j] += D[i-1][j] + D[i][j-1] - D[i-1][j-1];
    for (int i = 1; i <= n; i++)
        for (int j = 1; j <= m; j++)
            cout << D[i][j] << " \n"[j == m];
}

复杂度: O(Q + N × M)。


题目 3.2.6 — 最大子数组(Kadane 算法) 🟡 中等 读取 N 个整数(可能为负),找连续子数组的最大和。

提示 Kadane 算法。如果所有数都是负数,答案 = 最大的单个元素。
✅ 完整题解

核心思路: 在每个位置,要么开始新的子数组,要么延伸当前的。cur = max(A[i], cur + A[i]),追踪最优值。

#include <bits/stdc++.h>
using namespace std;
int main() {
    int n; cin >> n;
    long long best = LLONG_MIN, cur = 0;
    for (int i = 0; i < n; i++) {
        long long x; cin >> x;
        cur = max(x, cur + x);
        best = max(best, cur);
    }
    cout << best << "\n";
}

为什么当 cur+x < x 时从头开始? 因为负的运行和只会损害未来的项——丢弃它,从当前元素重新开始。

追踪 [-2, 1, -3, 4, -1, 2, 1, -5, 4]:

x=-2: cur=-2, best=-2
x=1:  cur=max(1,-2+1)=1, best=1
x=-3: cur=max(-3,1-3)=-2, best=1
x=4:  cur=max(4,-2+4)=4, best=4
x=-1: cur=max(-1,4-1)=3, best=4
x=2:  cur=5, best=5
x=1:  cur=6, best=6 ✓
x=-5: cur=1, best=6
x=4:  cur=5, best=6

复杂度: O(N) 时间,O(1) 空间。


差分数组专项练习

下面几道题专门训练差分数组。建议先判断题目属于哪一类:

  • 一维区间加法,最后输出数组:用一维差分。
  • 一维区间覆盖次数,最后统计答案:用一维差分 + 扫描统计。
  • 二维矩形加法或覆盖次数:用二维差分。
  • 有中途查询:普通差分通常不够,需要树状数组或线段树。

题目 3.2.7 — 区间加法后的最大值 🟢 简单

有一个长度为 N 的数组,初始全为 0。接下来有 M 次操作,每次给区间 [L,R] 内所有位置加上 V。所有操作结束后,输出数组中的最大值。

提示 不需要保存最终数组的每个值。用差分数组记录所有区间加法,最后从左到右做前缀和,同时维护最大值。
✅ 完整题解

核心思路: 每次区间加法只改两个位置:diff[L] += Vdiff[R+1] -= V。最后扫描时,当前前缀和就是当前位置的最终值。

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

int main() {
    ios::sync_with_stdio(false);
    cin.tie(nullptr);

    int n, m;
    cin >> n >> m;

    vector<long long> diff(n + 2, 0);

    while (m--) {
        int l, r;
        long long v;
        cin >> l >> r >> v;
        diff[l] += v;
        diff[r + 1] -= v;
    }

    long long cur = 0;
    long long best = LLONG_MIN;

    for (int i = 1; i <= n; i++) {
        cur += diff[i];
        best = max(best, cur);
    }

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

复杂度: O(N + M) 时间,O(N) 空间。


题目 3.2.8 — 被覆盖至少 K 次的位置数 🟡 中等

数轴上有 N 个位置,编号 1..N。给出 M 个区间 [L,R],表示这个区间被覆盖一次。请统计最后有多少个位置被覆盖次数至少为 K

提示 每个区间覆盖一次等价于区间加 `1`。先用差分数组求出每个位置的覆盖次数,再统计 `>= K` 的位置数量。
✅ 完整题解

核心思路: 这题不是要求输出最终数组,而是要求统计满足条件的位置数。差分数组恢复时顺手统计即可。

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

int main() {
    ios::sync_with_stdio(false);
    cin.tie(nullptr);

    int n, m, k;
    cin >> n >> m >> k;

    vector<int> diff(n + 2, 0);

    while (m--) {
        int l, r;
        cin >> l >> r;
        diff[l] += 1;
        diff[r + 1] -= 1;
    }

    int cur = 0;
    int answer = 0;

    for (int i = 1; i <= n; i++) {
        cur += diff[i];
        if (cur >= k) answer++;
    }

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

复杂度: O(N + M) 时间,O(N) 空间。


题目 3.2.9 — 原数组上的区间加法 🟡 中等

给定一个长度为 N 的原数组 A。接下来有 M 次操作,每次给区间 [L,R] 内所有元素加上 V。所有操作结束后,输出最终数组。

提示 这题初始数组不是全零。可以只用差分数组记录“额外增加量”,最后把增加量加回原数组;也可以先把原数组转成差分数组后直接修改。
✅ 完整题解

核心思路: 最简单的写法是:diff 只记录所有操作带来的增量。最后扫描 diff 得到当前位置总共增加了多少,再加到 A[i] 上。

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

int main() {
    ios::sync_with_stdio(false);
    cin.tie(nullptr);

    int n, m;
    cin >> n >> m;

    vector<long long> a(n + 1);
    for (int i = 1; i <= n; i++) {
        cin >> a[i];
    }

    vector<long long> diff(n + 2, 0);

    while (m--) {
        int l, r;
        long long v;
        cin >> l >> r >> v;
        diff[l] += v;
        diff[r + 1] -= v;
    }

    long long add = 0;
    for (int i = 1; i <= n; i++) {
        add += diff[i];
        cout << a[i] + add << " \n"[i == n];
    }

    return 0;
}

复杂度: O(N + M) 时间,O(N) 空间。


题目 3.2.10 — 矩形覆盖次数 🟡 中等

有一个 R × C 的网格,初始每个格子的覆盖次数都是 0。给出 M 个矩形,每个矩形由左上角 (r1,c1) 和右下角 (r2,c2) 表示。每个矩形会让内部所有格子的覆盖次数加 1。请统计最后有多少个格子的覆盖次数至少为 K

提示 这是二维差分数组。每个矩形更新四个角:左上 `+1`,右上外侧 `-1`,左下外侧 `-1`,右下外侧 `+1`。最后做二维前缀和并统计答案。
✅ 完整题解

核心思路: 二维差分的四角标记是 + - - +。恢复时使用二维前缀和公式:D[r][c] += D[r-1][c] + D[r][c-1] - D[r-1][c-1]

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

int main() {
    ios::sync_with_stdio(false);
    cin.tie(nullptr);

    int R, C, M, K;
    cin >> R >> C >> M >> K;

    vector<vector<int>> diff(R + 2, vector<int>(C + 2, 0));

    while (M--) {
        int r1, c1, r2, c2;
        cin >> r1 >> c1 >> r2 >> c2;

        diff[r1][c1] += 1;
        diff[r1][c2 + 1] -= 1;
        diff[r2 + 1][c1] -= 1;
        diff[r2 + 1][c2 + 1] += 1;
    }

    int answer = 0;

    for (int r = 1; r <= R; r++) {
        for (int c = 1; c <= C; c++) {
            diff[r][c] += diff[r - 1][c]
                        + diff[r][c - 1]
                        - diff[r - 1][c - 1];
            if (diff[r][c] >= K) answer++;
        }
    }

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

复杂度: O(R × C + M) 时间,O(R × C) 空间。


题目 3.2.11 — 判断是否需要差分数组 🟡 中等

下面四种题目分别应该优先考虑什么数据结构或算法?

  1. 数组不变,很多次询问 [L,R] 的区间和。
  2. 很多次给 [L,R] 加值,所有操作结束后输出最终数组。
  3. 很多次给 [L,R] 加值,并且每次操作后立刻询问某个位置的值。
  4. 很多次给矩形区域加值,所有操作结束后统计每个格子的最终值。
提示 区分关键点:是查询还是修改?是一维还是二维?是否有“中途查询”?
✅ 完整题解
  1. 前缀和。 数组不变,多次区间和查询,预处理后每次 O(1)
  2. 一维差分数组。 多次区间加法,最后统一恢复结果。
  3. 树状数组或线段树。 更新和查询交替出现,普通差分数组无法直接处理中途查询。
  4. 二维差分数组。 矩形区域批量加值,最后统一重建网格。

这道题的重点不是写代码,而是学会从题面信号中判断该用哪种工具。


🏆 挑战题:奶牛与油漆桶 N×M 网格中有油漆桶,每个有一个正值。选择任意矩形子网格。得分 = (子网格最大值) - (边界格子之和)。找最优矩形。(N, M ≤ 500)

✅ 解题思路

朴素枚举所有 O(N²M²) 矩形对 500² 来说太慢。改进方案:

  1. 用二维前缀和实现 O(1) 求和查询
  2. 对子网格中的最大值:预处理二维稀疏表(或每行 RMQ)实现 O(1) 最大值查询
  3. 边界和 = 总和 - 内部和(均通过前缀和)

总计:O(N²M²) 枚举 × O(1) 每次查询 = 对 N,M ≤ 500 在时间限制内。

📖 第 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.10 USACO 真题训练:排序之后,答案往往藏在相邻或边界中

排序与二分查找在 USACO 中最常见的作用不是「把数组排好看」,而是改变问题结构:

题面信号常见做法关键问题
找最接近/最小差值排序后看相邻为什么最优对一定相邻?
多次区间计数排序 + lower_bound/upper_bound左边界用谁?右边界用谁?
序列几乎有序排序副本对比有多少位置和有序状态不同?
大值域但少量点排序坐标而非建大数组值域 1e9 时不能开数组

真题 1:Out of Place(USACO 2018 January Bronze)— 排序副本找错位数量

题目链接: USACO 2018 January Bronze P3: Out of Place
对应模式: 排序副本 + 位置对比
难度定位: Bronze

题干解读

一列奶牛本来应该按身高非降序排列,但有一头奶牛站错了位置。每次只能交换相邻奶牛,问最少需要多少次交换才能恢复有序。

关键条件:

  • 只有一头奶牛错位,但她可能导致很多位置看起来不对。
  • 身高可能重复,不能只看「第一个不同的位置」。
  • N 很小,但这个题训练的是排序对比思维。

思路分析

把原数组复制一份并排序,得到正确的最终顺序。比较原数组和排序数组:

  • 如果有 m 个位置不同,答案是 m - 1
  • 原因:一头错位奶牛经过相邻交换穿过 m-1 个位置,每次交换能让她向正确位置移动一步。

CPP 完整代码

✅ 完整代码:Out of Place
#include <bits/stdc++.h>
using namespace std;

int main() {
    ios::sync_with_stdio(false);
    cin.tie(nullptr);

    // USACO 官方文件 I/O 可按需打开:
    // freopen("outofplace.in", "r", stdin);
    // freopen("outofplace.out", "w", stdout);

    int n;
    cin >> n;
    vector<int> original(n), sortedOrder(n);
    for (int i = 0; i < n; i++) {
        cin >> original[i];
        sortedOrder[i] = original[i];
    }

    sort(sortedOrder.begin(), sortedOrder.end());

    int different = 0;
    for (int i = 0; i < n; i++) {
        if (original[i] != sortedOrder[i]) different++;
    }

    cout << max(0, different - 1) << "\n";
    return 0;
}

复杂度: 排序 O(N log N),比较 O(N),空间 O(N)

易错点提醒

  1. 把不同位置数直接作为答案。 相邻交换次数是 different - 1,不是 different
  2. 忽略重复身高。 排序副本天然处理重复值,比手动找错位更稳。
  3. 以为必须模拟交换。 模拟容易错,排序对比直接给答案。

拓展思考

如果不再保证只有一头奶牛错位,而是任意排列,最少相邻交换次数就变成「逆序对数量」,需要归并排序计数或树状数组。


真题 2:Counting Haybales(USACO 2016 December Silver)— 有序数组上的区间计数

题目链接: USACO 2016 December Silver P1: Counting Haybales
对应模式: 排序 + lower_bound / upper_bound
难度定位: Silver 入门

题干解读

数轴上有 N 捆干草,位置可能很大。接下来 Q 次查询,每次给出区间 [A,B],问区间内有多少捆干草。

关键条件:

  • 位置值域很大,不能开频率数组或普通前缀和。
  • 干草位置不会改变,查询很多。
  • 只需要统计落在区间内的点数。

思路分析

排序所有位置。对于查询 [A,B]

left  = 第一个 >= A 的位置
right = 第一个 > B 的位置
answer = right - left

这正是 lower_boundupper_bound 的定义。

CPP 完整代码

✅ 完整代码:Counting Haybales
#include <bits/stdc++.h>
using namespace std;

int main() {
    ios::sync_with_stdio(false);
    cin.tie(nullptr);

    // USACO 官方文件 I/O 可按需打开:
    // freopen("haybales.in", "r", stdin);
    // freopen("haybales.out", "w", stdout);

    int n, q;
    cin >> n >> q;
    vector<int> position(n);
    for (int &x : position) cin >> x;

    sort(position.begin(), position.end());

    while (q--) {
        int a, b;
        cin >> a >> b;
        auto left = lower_bound(position.begin(), position.end(), a);
        auto right = upper_bound(position.begin(), position.end(), b);
        cout << (right - left) << "\n";
    }

    return 0;
}

复杂度: 排序 O(N log N),每次查询 O(log N),总复杂度 O((N+Q) log N),空间 O(N)

易错点提醒

  1. 右端点用错。 闭区间 [A,B] 的右边界要用 upper_bound(B),不是 lower_bound(B)
  2. 忘记排序。 lower_bound/upper_bound 只对有序区间有意义。
  3. 试图按坐标建数组。 坐标可到 10^9,会内存爆炸。
  4. 迭代器差值类型。 right-left 是距离,直接输出即可;若存入变量可用 int(本题 N≤1e5)或 long long

拓展思考

如果干草位置会动态添加/删除,静态排序数组不再适用;可以考虑 multiset、树状数组配合坐标压缩,或线段树维护动态计数。


⚠️ 第 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 级别的主题,现在思考它能帮助你建立直觉。


📖 第 3.4 章 ⏱️ 约 50 分钟 🎯 中级

第 3.4 章:双指针与滑动窗口

📝 前置条件: 你应该熟悉数组、向量和 std::sort(第 2.3–3.3 章)。经典双指针方法需要已排序的数组。

双指针和滑动窗口是竞赛编程中最优雅的技巧之一。它们通过利用单调性将朴素 O(N²) 解法转化为 O(N):当一个指针向前移动时,另一个指针无需回头。


3.4.1 双指针技术

核心思想:在有序数组中维护两个下标 leftright,根据当前和/窗口大小将它们相向(或同向)移动。

使用场景:

  • 有序数组中找满足给定和的对/三元组
  • 检查有序数组中是否存在满足特定关系的两个元素
  • 「若能用大小 k 的窗口完成 X,则用大小 k-1 的窗口也能完成」的问题

Two Pointer Technique

上图展示两个指针如何向中间收拢,每步都从待考虑的对中消除整行/整列。

滑动窗口变体让两个指针同向移动。满足条件时,从左侧收缩以找到最小窗口:

Sliding Window Shrink

题目:找出所有和为目标值的对

朴素 O(N²) 做法:

// O(N²):检查每一对
for (int i = 0; i < n; i++) {
    for (int j = i + 1; j < n; j++) {
        if (arr[i] + arr[j] == target) {
            cout << arr[i] << " + " << arr[j] << "\n";
        }
    }
}

双指针 O(N) 做法(需要排序):

📄 C++ 完整代码
// 双指针 — 排序 O(N log N) + 搜索 O(N)
#include <bits/stdc++.h>
using namespace std;

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

    int n, target;
    cin >> n >> target;
    vector<int> arr(n);
    for (int &x : arr) cin >> x;

    sort(arr.begin(), arr.end());  // 必须先排序

    int left = 0, right = n - 1;
    while (left < right) {
        int sum = arr[left] + arr[right];
        if (sum == target) {
            cout << arr[left] << " + " << arr[right] << " = " << target << "\n";
            left++;
            right--;  // 同时移动两个指针
        } else if (sum < target) {
            left++;   // 和太小:左指针右移(增大和)
        } else {
            right--;  // 和太大:右指针左移(减小和)
        }
    }

    return 0;
}

为什么这样有效?

核心思路: 排序后,若 arr[left] + arr[right] < target,则没有比 arr[right] 更小的元素能与 arr[left] 配对达到 target。所以安全地右移 left

类似地,若和太大,没有比 arr[left] 更大的元素能与 arr[right] 配对达到 target。所以安全地左移 right

每步至少消除一个元素 → 总计 O(N) 步。

完整追踪

数组 = [1, 2, 3, 4, 5, 6, 7, 8],target = 9:

📄 数组 = [1, 2, 3, 4, 5, 6, 7, 8],target = 9:
状态:left=0(1), right=7(8)
  和 = 1+8 = 9 ✓ → 打印 (1,8),left++,right--

状态:left=1(2), right=6(7)
  和 = 2+7 = 9 ✓ → 打印 (2,7),left++,right--

状态:left=2(3), right=5(6)
  和 = 3+6 = 9 ✓ → 打印 (3,6),left++,right--

状态:left=3(4), right=4(5)
  和 = 4+5 = 9 ✓ → 打印 (4,5),left++,right--

状态:left=4, right=3 → left >= right,停止

所有对:(1,8),(2,7),(3,6),(4,5)

三数之和扩展

找和为目标值的三元组:固定一个元素,对剩余两个用双指针。

📄 找和为目标值的三元组:固定一个元素,对剩余两个用双指针。
// O(N²) — 远优于 O(N³) 暴力
sort(arr.begin(), arr.end());
for (int i = 0; i < n - 2; i++) {
    int left = i + 1, right = n - 1;
    while (left < right) {
        int sum = arr[i] + arr[left] + arr[right];
        if (sum == target) {
            cout << arr[i] << " " << arr[left] << " " << arr[right] << "\n";
            left++; right--;
        } else if (sum < target) left++;
        else right--;
    }
}

3.4.2 滑动窗口——固定大小

固定大小 K 的滑动窗口在数组上滑动,维护一个运行聚合值(和、最大值、不同值计数等)。

题目: 找任意大小为 K 的连续子数组的最大和。

数组:[2, 1, 5, 1, 3, 2],K=3
窗口:[2,1,5]=8,[1,5,1]=7,[5,1,3]=9,[1,3,2]=6
答案:9

朴素 O(NK) 对每个窗口从头重新计算和。

滑动窗口 O(N) 加上进入窗口的新元素,减去离开窗口的旧元素。

📄 C++ 完整代码
// 固定大小滑动窗口 — O(N)
#include <bits/stdc++.h>
using namespace std;

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

    int n, k;
    cin >> n >> k;
    vector<int> arr(n);
    for (int &x : arr) cin >> x;

    // 计算第一个窗口的和
    long long windowSum = 0;
    for (int i = 0; i < k; i++) windowSum += arr[i];

    long long maxSum = windowSum;

    // 滑动窗口:加 arr[i],减 arr[i-k]
    for (int i = k; i < n; i++) {
        windowSum += arr[i];        // 新元素进入窗口
        windowSum -= arr[i - k];   // 旧元素离开窗口
        maxSum = max(maxSum, windowSum);
    }

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

对 [2, 1, 5, 1, 3, 2], K=3 的追踪:

初始窗口 [2,1,5]:sum=8,max=8
i=3:加 1,减 2 → sum=7,max=8
i=4:加 3,减 1 → sum=9,max=9
i=5:加 2,减 5 → sum=6,max=9
答案:9 ✓

3.4.3 滑动窗口——可变大小

最强大的变体:窗口在需要时扩大,违反约束时收缩

题目: 找和 ≥ target 的最短连续子数组。

📄 C++ 完整代码
// 可变大小窗口 — O(N)
#include <bits/stdc++.h>
using namespace std;

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

    int n, target;
    cin >> n >> target;
    vector<int> arr(n);
    for (int &x : arr) cin >> x;

    int left = 0;
    long long windowSum = 0;
    int minLen = INT_MAX;

    for (int right = 0; right < n; right++) {
        windowSum += arr[right];   // 扩张:加入右端元素

        // 满足约束时从左收缩
        while (windowSum >= target) {
            minLen = min(minLen, right - left + 1);
            windowSum -= arr[left];
            left++;                // 收缩:移除左端元素
        }
    }

    if (minLen == INT_MAX) cout << 0 << "\n";  // 不存在这样的子数组
    else cout << minLen << "\n";

    return 0;
}

为什么是 O(N) 每个元素被加入一次(right 经过时),最多被移除一次(left 经过时)。总操作:O(2N) = O(N)

题目:最多 K 个不同值的最长子数组

📄 查看代码:题目:最多 K 个不同值的最长子数组
// 可变窗口:最多 K 个不同值的最长子数组
int left = 0, maxLen = 0;
map<int, int> freq;  // 窗口内每个值的频率

for (int right = 0; right < n; right++) {
    freq[arr[right]]++;

    // 有 > k 个不同值时收缩
    while ((int)freq.size() > k) {
        freq[arr[left]]--;
        if (freq[arr[left]] == 0) freq.erase(arr[left]);
        left++;
    }

    maxLen = max(maxLen, right - left + 1);
}
cout << maxLen << "\n";

3.4.4 USACO 示例:最长满足条件的子数组

题目: 给定整数数组,找所有元素 ≥ K 的最长连续子数组。

// 双指针:所有元素 >= K 的最长连续子数组
int left = 0, maxLen = 0;
for (int right = 0; right < n; right++) {
    if (arr[right] < K) {
        left = right + 1;  // 重置窗口:当前元素违反约束
    } else {
        maxLen = max(maxLen, right - left + 1);
    }
}

3.4.5 USACO 真题训练:窗口、匹配与单调移动

双指针的本质是「指针不回头」。在 USACO 中常见两类题:

模式题面信号指针含义
排序后滑动窗口最大集合,任意两者差距不超过 K窗口左右端点
区间匹配每个资源只能用一次,最大匹配数量当前资源 + 可匹配候选集合

真题 1:Diamond Collector(USACO 2016 US Open Silver)— 排序后维护合法窗口

题目链接: USACO 2016 US Open Silver P2: Diamond Collector
对应模式: 排序 + 滑动窗口 + 前后缀最优
难度定位: Silver 中等

题干解读

N 颗钻石,每颗有一个大小。一个展示柜中任意两颗钻石大小差不能超过 K。你有两个展示柜,求最多能展示多少颗钻石。

关键条件:

  • 每个柜子对应排序数组中的一个连续窗口。
  • 两个柜子不能共用钻石。
  • 需要选两个不重叠窗口,使总长度最大。

思路分析

排序后,对每个左端点 left,用右指针扩张到最大 right,满足:

size[right] - size[left] <= K

得到从 left 开始的最大合法窗口长度。然后用后缀最大值 bestFrom[i] 表示从位置 i 之后能选到的最大窗口。枚举第一个柜子的左端点,第二个柜子从第一个窗口右端之后开始。

CPP 完整代码

✅ 完整代码:Diamond Collector
#include <bits/stdc++.h>
using namespace std;

int main() {
    ios::sync_with_stdio(false);
    cin.tie(nullptr);

    // USACO 官方文件 I/O 可按需打开:
    // freopen("diamond.in", "r", stdin);
    // freopen("diamond.out", "w", stdout);

    int n, k;
    cin >> n >> k;
    vector<int> diamond(n);
    for (int &x : diamond) cin >> x;
    sort(diamond.begin(), diamond.end());

    vector<int> length(n), endAt(n);
    int right = 0;
    for (int left = 0; left < n; left++) {
        while (right < n && diamond[right] - diamond[left] <= k) {
            right++;
        }
        length[left] = right - left;
        endAt[left] = right;  // 第二个柜子必须从 right 或之后开始
    }

    vector<int> bestFrom(n + 1, 0);
    for (int i = n - 1; i >= 0; i--) {
        bestFrom[i] = max(bestFrom[i + 1], length[i]);
    }

    int answer = 0;
    for (int first = 0; first < n; first++) {
        answer = max(answer, length[first] + bestFrom[endAt[first]]);
    }

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

复杂度: 排序 O(N log N),滑动窗口和后缀数组 O(N),空间 O(N)

易错点提醒

  1. 右指针回退。 滑动窗口的关键是 right 只增不减,否则退化成 O(N²)
  2. 两个柜子重叠。 第二个柜子必须从第一个窗口结束位置 endAt[first] 之后选。
  3. 只找一个最大窗口。 题目有两个展示柜,需要组合两个不重叠窗口。
  4. 没有排序。 合法窗口只在排序后才是连续区间。

拓展思考

如果展示柜数量从 2 变成 M,就变成「选 M 个不重叠窗口最大化总长度」的 DP:dp[i][j] 表示从第 i 个位置开始,用 j 个柜子的最大展示数量。


真题 2:Why Did the Cow Cross the Road(USACO 2017 February Silver)— 区间匹配的贪心

题目链接: USACO 2017 February Silver P1: Why Did the Cow Cross the Road
对应模式: 排序 + 贪心匹配 + 最小堆
难度定位: Silver 中等

题干解读

有若干只鸡,每只鸡在一个具体时间 T 可以帮忙;有若干头牛,每头牛只能在时间区间 [A,B] 内过马路。一只鸡最多帮一头牛,一头牛也最多被一只鸡帮助,求最大匹配数量。

关键条件:

  • 鸡是时间点,牛是时间区间。
  • 时间从小到大处理时,已经开始但还没过期的牛都可能匹配当前鸡。
  • 为了给未来留下更多选择,当前鸡应该匹配结束时间最早的可用牛。

思路分析

按时间扫描鸡:

  1. 将鸡时间排序。
  2. 将牛按开始时间 A 排序。
  3. 对每只鸡,把所有 A <= T 的牛加入最小堆,堆键是结束时间 B
  4. 弹出所有 B < T 的过期牛。
  5. 若堆非空,匹配 B 最小的牛。

这个贪心安全:结束越早的牛越紧急,当前不匹配它,未来更可能错过。

CPP 完整代码

✅ 完整代码:Why Did the Cow Cross the Road
#include <bits/stdc++.h>
using namespace std;

int main() {
    ios::sync_with_stdio(false);
    cin.tie(nullptr);

    // USACO 官方文件 I/O 可按需打开:
    // freopen("helpcross.in", "r", stdin);
    // freopen("helpcross.out", "w", stdout);

    int c, n;
    cin >> c >> n;

    vector<int> chicken(c);
    for (int &t : chicken) cin >> t;

    vector<pair<int, int>> cows(n);  // {开始时间, 结束时间}
    for (auto &[a, b] : cows) cin >> a >> b;

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

    priority_queue<int, vector<int>, greater<int>> availableEnd;
    int cowIndex = 0;
    int matched = 0;

    for (int t : chicken) {
        while (cowIndex < n && cows[cowIndex].first <= t) {
            availableEnd.push(cows[cowIndex].second);
            cowIndex++;
        }

        while (!availableEnd.empty() && availableEnd.top() < t) {
            availableEnd.pop();  // 已经过期,不能再匹配
        }

        if (!availableEnd.empty()) {
            availableEnd.pop();  // 当前鸡匹配结束最早的牛
            matched++;
        }
    }

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

复杂度: 排序 O((C+N) log(C+N)),每头牛最多进出堆一次,总复杂度 O((C+N) log N)

易错点提醒

  1. 按结束时间排序后直接双指针。 鸡时间点和牛区间交错,仅靠两个指针不够,需要堆维护当前可选牛。
  2. 忘记删除过期牛。 B < T 的牛无法被当前或未来鸡匹配,必须弹出。
  3. 匹配结束最晚的牛。 应匹配结束最早者,把更宽松的牛留给未来。
  4. 边界条件写错。A <= T <= B,则 B == T 仍然可匹配,不应弹出。

拓展思考

这类题属于「按时间扫描 + 最紧急优先」模型。若每只鸡有多个时间段、每头牛也需要多个资源,问题可能升级为区间调度或二分图匹配。


⚠️ 常见错误

  1. 双指针前忘排序: 配对求和的双指针技术只在有序数组上有效。不排序会遗漏一些对或得到错误答案。
  2. 找到对时只移动一个指针: 找到匹配的对时,必须同时移动 left++right--。只移动一个会遗漏一些对(除非不需要考虑重复)。
  3. 窗口大小差一: 窗口 [left, right] 的大小是 right - left + 1,不是 right - left
  4. 忘记处理空答案: 对「最小子数组」问题,将 minLen 初始化为 INT_MAX,输出前检查它是否改变了。

本章总结

📌 核心要点

技术前提条件时间空间核心思想
双指针(配对)有序数组O(N)O(1)从两端逼近,消除不可能的对
双指针(三数之和)有序数组O(N²)O(1)固定一个,对其余用双指针
滑动窗口(固定)任意O(N)O(1)加新元素,减旧元素
滑动窗口(可变)任意O(N)O(1~N)右端扩张,左端收缩

❓ 常见问题

Q1:双指针一定需要排序吗?

A:不一定。「反向双指针」(如配对求和)需要排序;「同向双指针」(如滑动窗口)不需要。关键是单调性——指针只朝一个方向移动。

Q2:滑动窗口和前缀和都能计算区间和——该用哪个?

A:固定大小窗口的和/最大值,滑动窗口更直观。任意区间查询,前缀和更通用。滑动窗口只能处理「连续移动的窗口」;前缀和可以回答任意 [L,R] 查询。

Q3:滑动窗口能同时处理「满足条件的最长子数组」和「满足条件的最短子数组」吗?

A:两者都可以,但逻辑略有不同。「最长」:右端扩张直到条件不满足,再从左收缩直到条件重新满足。「最短」:右端扩张直到条件满足,再从左收缩直到条件不再满足,全程记录最小长度。

Q4:双指针如何处理重复元素?

A:取决于题目。若需要「所有不同对的值」,找到对后做 left++; right-- 并跳过重复值。若需要「所有对的数量」,需要仔细统计重复项(可能需要额外的计数逻辑)。

🔗 与后续章节的联系

  • 第 3.2 章(前缀和):前缀和与滑动窗口互补——前缀和适合离线查询,滑动窗口适合在线处理
  • 第 3.3 章(排序):排序是双指针的前提——反向双指针需要有序数组
  • 第 3.5 章(单调性):单调双端队列可以增强滑动窗口——在 O(N) 时间内维护窗口最小/最大值
  • 第 6.1–6.3 章(DP):一些问题(如 LIS 变体)可以用双指针优化

练习题

题目 3.4.1 — 配对计数 🟢 简单 给定 N 个整数和目标值 T,统计满足 arr[i] + arr[j] = T 的对 (i < j) 的数量。

提示 先排序数组,从两端用双指针。
✅ 完整题解
#include <bits/stdc++.h>
using namespace std;
int main() {
    int n, T; cin >> n >> T;
    vector<int> a(n); for (int& x : a) cin >> x;
    sort(a.begin(), a.end());
    long long cnt = 0;
    int L = 0, R = n - 1;
    while (L < R) {
        int s = a[L] + a[R];
        if (s == T) {
            if (a[L] == a[R]) {
                // [L..R] 内所有对都有效
                long long len = R - L + 1;
                cnt += len * (len - 1) / 2;
                break;
            }
            // 统计两侧的重复值
            long long cl = 1, cr = 1;
            while (L+1 < R && a[L+1] == a[L]) { cl++; L++; }
            while (R-1 > L && a[R-1] == a[R]) { cr++; R--; }
            cnt += cl * cr;
            L++; R--;
        } else if (s < T) L++;
        else R--;
    }
    cout << cnt << "\n";
}

复杂度: O(N log N)。


题目 3.4.2 — 最大平均子数组 🟡 中等 找恰好长度为 K 的连续子数组,使其平均值最大。

提示 固定大小滑动窗口:维护运行和,每步加 A[i] 减 A[i-K]。
✅ 完整题解
#include <bits/stdc++.h>
using namespace std;
int main() {
    int n, k; cin >> n >> k;
    vector<double> a(n); for (double& x : a) cin >> x;
    double windowSum = 0;
    for (int i = 0; i < k; i++) windowSum += a[i];
    double maxSum = windowSum;
    for (int i = k; i < n; i++) {
        windowSum += a[i] - a[i-k];
        maxSum = max(maxSum, windowSum);
    }
    cout << fixed << setprecision(5) << maxSum / k << "\n";
}

复杂度: O(N)。


题目 3.4.3 — 最小覆盖子串 🔴 困难 给定字符串 S 和字符串 T,找 S 中包含 T 所有字符的最短子串。

提示 可变滑动窗口,用频率映射记录所需字符,满足所有 T 中字符时从左收缩。
✅ 完整题解
#include <bits/stdc++.h>
using namespace std;
int main() {
    string S, T; cin >> S >> T;
    unordered_map<char,int> need, have;
    for (char c : T) need[c]++;
    int formed = 0, required = need.size();
    int L = 0, minLen = INT_MAX, minL = 0;
    for (int R = 0; R < (int)S.size(); R++) {
        have[S[R]]++;
        if (need.count(S[R]) && have[S[R]] == need[S[R]]) formed++;
        while (formed == required) {
            if (R - L + 1 < minLen) { minLen = R-L+1; minL = L; }
            have[S[L]]--;
            if (need.count(S[L]) && have[S[L]] < need[S[L]]) formed--;
            L++;
        }
    }
    if (minLen == INT_MAX) cout << "no solution\n";
    else cout << S.substr(minL, minLen) << "\n";
}

样例: S="ADOBECODEBANC",T="ABC" → "BANC" 复杂度: O(|S| + |T|)。


🏆 挑战题:USACO 2017 February Bronze——为何奶牛过马路 给定网格中的奶牛和它们的目的地,找哪头奶牛最快到达目的地。对排序好的区间用双指针/贪心方法。

📖 第 3.5 章 ⏱️ 约 50 分钟 🎯 中级

第 3.5 章:单调栈与单调队列

📝 前置条件: 确保你熟悉双指针/滑动窗口(第 3.4 章)和基本的栈/队列操作(第 3.1 章)。本章直接建立在这些技术之上。

单调栈和单调队列是优雅的工具,能在 O(N) 时间内解决「最近更大/更小元素」和「滑动窗口极值」问题——而朴素做法需要 O(N²)。


3.5.1 单调栈:下一个更大元素

题目: 给定 N 个整数的数组 A,对每个元素 A[i],找下一个更大元素(NGE):i 右侧第一个大于 A[i] 的元素的下标。若不存在,输出 -1。

朴素做法: O(N²) —— 对每个 i,向右扫描直到找到更大的元素。

单调栈做法: O(N) —— 维护一个从底到顶始终递减的栈。压入新元素时,先弹出所有更小的元素(它们刚找到了自己的 NGE!)。

💡 核心思路: 栈中存放还未找到 NGE 的元素的下标。当 A[i] 到来时,栈中所有小于 A[i] 的元素都找到了 NGE(就是 i!)。弹出它们并记录答案。

单调栈状态变化——对 A = [2, 1, 5, 6, 2, 3] 逐步追踪:

Monotonic Stack NGE

📄 ![Monotonic Stack NGE](../images/monotonic_stack_nge.svg)
数组 A:[2, 1, 5, 6, 2, 3]
下标:    0  1  2  3  4  5

处理 i=0 (A[0]=2):栈为空 → 压入 0
栈:[0]          // 栈存放未解决元素的下标

处理 i=1 (A[1]=1):A[1]=1 < A[0]=2 → 直接压入
栈:[0, 1]

处理 i=2 (A[2]=5):
  A[2]=5 > A[1]=1 → 弹出 1,NGE[1] = 2  (A[2]=5 是 A[1] 的下一个更大元素)
  A[2]=5 > A[0]=2 → 弹出 0,NGE[0] = 2  (A[2]=5 是 A[0] 的下一个更大元素)
  栈为空 → 压入 2
栈:[2]

处理 i=3 (A[3]=6):
  A[3]=6 > A[2]=5 → 弹出 2,NGE[2] = 3
  压入 3
栈:[3]

处理 i=4 (A[4]=2):A[4]=2 < A[3]=6 → 直接压入
栈:[3, 4]

处理 i=5 (A[5]=3):
  A[5]=3 > A[4]=2 → 弹出 4,NGE[4] = 5
  A[5]=3 < A[3]=6 → 停止,压入 5
栈:[3, 5]

结束:栈中剩余 [3, 5] → NGE[3] = NGE[5] = -1(右侧无更大元素)

结果:NGE = [2, 2, 3, -1, 5, -1]
验证:
  A[0]=2,下一个更大是 A[2]=5 ✓
  A[1]=1,下一个更大是 A[2]=5 ✓
  A[2]=5,下一个更大是 A[3]=6 ✓
  A[3]=6,无更大 → -1 ✓

完整实现

📄 查看代码:完整实现
// 用单调栈求下一个更大元素 — O(N)
#include <bits/stdc++.h>
using namespace std;

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

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

    vector<int> nge(n, -1);   // nge[i] = 下一个更大元素的下标,不存在则为 -1
    stack<int> st;             // 单调递减栈(存下标)

    for (int i = 0; i < n; i++) {
        // 当栈顶元素小于 A[i] 时
        // → 当前元素 A[i] 是那些元素的 NGE
        while (!st.empty() && A[st.top()] < A[i]) {
            nge[st.top()] = i;  // ← 关键:记录栈顶的 NGE
            st.pop();
        }
        st.push(i);  // 压入当前下标(尚未解决)
    }
    // 栈中剩余元素没有 NGE → 已初始化为 -1

    for (int i = 0; i < n; i++) {
        cout << nge[i];
        if (i < n - 1) cout << " ";
    }
    cout << "\n";

    return 0;
}

复杂度分析:

  • 每个元素恰好被压入一次最多被弹出一次
  • 总操作:O(2N) = O(N)
  • 空间:O(N)(栈)

⚠️ 常见错误: 在栈中存值而非下标。始终存下标——你需要知道在数组的哪个位置记录答案。


3.5.2 变体:上一个更小、上一个更大

通过改变比较方向和遍历方向,可以得到四个相关问题:

问题栈类型方向使用场景
下一个更大元素(NGE)递减栈从左到右股票价格问题
下一个更小元素(NSE)递增栈从左到右直方图问题
上一个更大元素(PGE)递减栈从右到左区间问题
上一个更小元素(PSE)递增栈从右到左左侧最近更小元素

上一个更小元素的模板:

📄 C++ 完整代码
// 上一个更小元素:对每个 i,找最近的 j < i 使得 A[j] < A[i]
vector<int> pse(n, -1);  // pse[i] = 上一个更小元素的下标,不存在则为 -1
stack<int> st;

for (int i = 0; i < n; i++) {
    while (!st.empty() && A[st.top()] >= A[i]) {
        st.pop();  // 弹出 >= A[i] 的元素(它们不是「上一个更小」)
    }
    pse[i] = st.empty() ? -1 : st.top();  // 栈顶就是上一个更小
    st.push(i);
}

3.5.3 USACO 应用:直方图中的最大矩形

题目: 给定高度数组 H[0..N-1],找能放入直方图内的最大矩形面积。

核心思路: 对每根柱子 i,以 H[i] 为高度的最大矩形向左右延伸,直到遇到更矮的柱子。用单调栈求每个 i 的:

  • left[i] = 上一个更小元素的下标
  • right[i] = 下一个更小元素的下标

每根柱子的左右边界——H = [2, 1, 5, 6, 2, 3]:

Histogram Boundary Computation

💡 公式: 宽度 = right[i] - left[i] - 1面积 = H[i] × 宽度。左边界 = 上一个更小元素的下标;右边界 = 下一个更小元素的下标。

📄 C++ 完整代码
// 直方图中最大矩形 — O(N)
#include <bits/stdc++.h>
using namespace std;

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

    int n;
    cin >> n;
    vector<int> H(n);
    for (int& h : H) cin >> h;

    vector<int> left(n), right(n);
    stack<int> st;

    // 上一个更小(左边界)
    for (int i = 0; i < n; i++) {
        while (!st.empty() && H[st.top()] >= H[i]) st.pop();
        left[i] = st.empty() ? -1 : st.top();  // 矩形起点前的下标
        st.push(i);
    }

    while (!st.empty()) st.pop();

    // 下一个更小(右边界)
    for (int i = n - 1; i >= 0; i--) {
        while (!st.empty() && H[st.top()] >= H[i]) st.pop();
        right[i] = st.empty() ? n : st.top();  // 矩形终点后的下标
        st.push(i);
    }

    // 计算最大面积
    long long maxArea = 0;
    for (int i = 0; i < n; i++) {
        long long width = right[i] - left[i] - 1;  // 矩形宽度
        long long area = (long long)H[i] * width;
        maxArea = max(maxArea, area);
    }

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

对 H = [2, 1, 5, 6, 2, 3] 的追踪:

left  = [-1, -1, 1, 2, 1, 4]   (上一个更小的下标,-1 = 无)
right = [1, 6, 4, 4, 6, 6]     (下一个更小的下标,n=6 = 无)

宽度:  1-(-1)-1=1, 6-(-1)-1=6, 4-1-1=2, 4-2-1=1, 6-1-1=4, 6-4-1=1
面积:  2×1=2, 1×6=6, 5×2=10, 6×1=6, 2×4=8, 3×1=3

最大面积 = 10
  i=2:H[2]=5,left[2]=1,right[2]=4,宽度=4-1-1=2,面积=5×2=10 ✓
  (下标 2 和 3 的柱子高度都 ≥ 5,所以高度为 5 的矩形跨度为 2)

📌 学习建议: 提交前始终用样例输入手动追踪算法。下标边界的细微差一错误是单调栈问题最常见的 bug。


3.5.4 单调双端队列:滑动窗口最大值

题目: 给定 N 个整数的数组 A 和窗口大小 K,找每个大小为 K 的窗口从左向右滑动时的最大值,输出 N-K+1 个值。

朴素做法: O(NK) —— 对每个窗口扫描求最大值。

单调双端队列做法: O(N) —— 维护一个递减双端队列(队首 = 当前窗口最大值)。

💡 核心思路: 我们想要滑动窗口的最大值。维护一个下标的双端队列,使得:

  1. 双端队列中的值递减(队首始终是最大值)
  2. 双端队列只包含当前窗口内的下标

当新元素到来时:

  • 队尾移除所有更小的元素(只要这个新元素在窗口中,它们就不可能是最大值)
  • 队首已超出当前窗口则移除

逐步追踪

📄 查看代码:逐步追踪
数组 A:[1, 3, -1, -3, 5, 3, 6, 7],K = 3

窗口 [1,3,-1]:最大 = 3
窗口 [3,-1,-3]:最大 = 3
窗口 [-1,-3,5]:最大 = 5
窗口 [-3,5,3]:最大 = 5
窗口 [5,3,6]:最大 = 6
窗口 [3,6,7]:最大 = 7

i=0, A[0]=1:双端队列=[0]
i=1, A[1]=3:3>1 → 弹出 0;双端队列=[1]
i=2, A[2]=-1:-1<3 → 压入;双端队列=[1,2];窗口 [0..2]:最大=A[1]=3 ✓
i=3, A[3]=-3:-3<-1 → 压入;双端队列=[1,2,3];窗口 [1..3]:队首=1 仍在窗口,最大=A[1]=3 ✓
i=4, A[4]=5:5>-3→弹出 3;5>-1→弹出 2;5>3→弹出 1;双端队列=[4];窗口 [2..4]:最大=A[4]=5 ✓
i=5, A[5]=3:3<5→压入;双端队列=[4,5];窗口 [3..5]:队首=4 在窗口,最大=A[4]=5 ✓
i=6, A[6]=6:6>3→弹出 5;6>5→弹出 4;双端队列=[6];窗口 [4..6]:最大=A[6]=6 ✓
i=7, A[7]=7:7>6→弹出 6;双端队列=[7];窗口 [5..7]:最大=A[7]=7 ✓

完整实现

📄 查看代码:完整实现
// 用单调双端队列求滑动窗口最大值 — O(N)
#include <bits/stdc++.h>
using namespace std;

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

    int n, k;
    cin >> n >> k;
    vector<int> A(n);
    for (int& x : A) cin >> x;

    deque<int> dq;   // 单调递减双端队列,存下标
    vector<int> result;

    for (int i = 0; i < n; i++) {
        // 1. 移除超出当前窗口的元素
        while (!dq.empty() && dq.front() <= i - k) {
            dq.pop_front();   // ← 关键:弹出已过期的队首
        }

        // 2. 维护递减性质
        //    从队尾移除所有小于 A[i] 的元素
        //    (只要 A[i] 还在窗口中,它们就不可能是最大值)
        while (!dq.empty() && A[dq.back()] <= A[i]) {
            dq.pop_back();    // ← 关键:从队尾弹出更小的元素
        }

        dq.push_back(i);   // 加入当前元素

        // 3. 第一个完整窗口形成后开始记录最大值
        if (i >= k - 1) {
            result.push_back(A[dq.front()]);  // 队首 = 当前窗口最大值
        }
    }

    for (int i = 0; i < (int)result.size(); i++) {
        cout << result[i];
        if (i + 1 < (int)result.size()) cout << "\n";
    }
    cout << "\n";

    return 0;
}

复杂度:

  • 每个元素最多被压入/弹出双端队列一次 → 总计 O(N)
  • 空间:O(K)(双端队列)

⚠️ 常见错误 1: 忘记检查 dq.front() <= i - k 来判断窗口过期。双端队列只能包含 [i-k+1, i] 范围内的下标。

⚠️ 常见错误 2: 从队尾弹出时用 < 而非 <=。用 < 会保留相等元素,但重复值可能导致问题。用 <= 维护严格递减的双端队列。


3.5.5 USACO 题型:股票跨度(单调栈)

🔗 灵感: 这类题型在 USACO Bronze/Silver 中出现(「Haybale Stacking」风格)。

📄 C++ 完整代码
// 股票跨度问题:对每天 i,找在 i 之前有多少连续天价格 <= prices[i]
// (第 i 天的「跨度」)
vector<int> stockSpan(vector<int>& prices) {
    int n = prices.size();
    vector<int> span(n, 1);
    stack<int> st;  // 单调递减栈(存下标)

    for (int i = 0; i < n; i++) {
        while (!st.empty() && prices[st.top()] <= prices[i]) {
            st.pop();
        }
        span[i] = st.empty() ? (i + 1) : (i - st.top());
        st.push(i);
    }
    return span;
}
// span[i] = 到 i 为止(含)价格 <= prices[i] 的连续天数

⚠️ 第 3.5 章常见错误

  1. 存值而非下标 —— 始终存下标。你需要用它们检查窗口范围和记录答案。

  2. 双端队列中比较用 < 而非 <= —— 滑动窗口求最大值时,A[dq.back()] <= A[i] 时弹出(严格非增)。求最小值时,A[dq.back()] >= A[i] 时弹出。

  3. 忘记窗口过期检查 —— 滑动窗口双端队列中,记录最大值前始终检查 dq.front() < i - k + 1(或 <= i - k)。

  4. 栈的底顶方向搞混 —— 「单调」性质指:从底到顶,栈是递增的(用于 NGE)或递减的(用于 NSE)。搞混时画图辅助。

  5. NGE 和 PSE 的处理顺序:

    • 下一个更大元素:从左到右遍历
    • 上一个更大元素:从右到左遍历(或:从左到右遍历,在压入前记录 stack.top())

本章总结

📌 核心要点

问题数据结构时间复杂度关键操作
下一个更大元素(NGE)单调递减栈O(N)找到更大元素时弹出
上一个更小元素(PSE)单调递增栈O(N)压入前栈顶就是答案
直方图中最大矩形单调栈(两遍)O(N)左边界 + 右边界 + 宽度
滑动窗口最大值单调递减双端队列O(N)维护窗口范围 + 维护递减性质

🧩 模板速查

📄 查看代码:🧩 模板速查
// 单调递减栈(用于 NGE / 下一个更大元素)
stack<int> st;
for (int i = 0; i < n; i++) {
    while (!st.empty() && A[st.top()] < A[i]) {
        answer[st.top()] = i;  // i 是 st.top() 的 NGE
        st.pop();
    }
    st.push(i);
}

// 单调递减双端队列(滑动窗口最大值)
deque<int> dq;
for (int i = 0; i < n; i++) {
    while (!dq.empty() && dq.front() <= i - k) dq.pop_front();  // 移除过期元素
    while (!dq.empty() && A[dq.back()] <= A[i]) dq.pop_back();  // 维护单调性
    dq.push_back(i);
    if (i >= k - 1) ans.push_back(A[dq.front()]);
}

❓ 常见问题

Q1:单调栈应该存值还是下标?

A:始终存下标。即使只需要值,存下标更灵活——通过 A[idx] 可以取值,反之不行。特别是计算宽度时(如直方图问题),必须用下标。

Q2:如何判断用单调栈还是双指针?

A:观察问题结构——若需要「对每个元素找其左/右侧第一个更大/更小的元素」,用单调栈。若需要「维护滑动窗口的最大值」,用单调双端队列。若「两指针从两端相向移动」,用双指针。

Q3:为什么单调栈的时间复杂度是 O(N) 而不是 O(N²)?

A:均摊分析。每个元素最多被压入一次,最多被弹出一次,总共 2N 次操作,所以是 O(N)。虽然单次 while 循环可能弹出多个元素,但所有 while 循环的弹出总次数绝不超过 N。


练习题

题目 3.5.1 — 下一个更大元素 🟢 简单 对数组中每个元素,找其右侧第一个更大的元素。若不存在打印 -1。

提示 维护单调递减下标栈。处理 A[i] 时,弹出栈中所有更小的元素(它们找到了 NGE)。
✅ 完整题解
#include <bits/stdc++.h>
using namespace std;
int main() {
    int n; cin >> n;
    vector<int> a(n); for (int& x : a) cin >> x;
    vector<int> nge(n, -1);
    stack<int> st;
    for (int i = 0; i < n; i++) {
        while (!st.empty() && a[st.top()] < a[i]) {
            nge[st.top()] = a[i];
            st.pop();
        }
        st.push(i);
    }
    for (int x : nge) cout << x << " "; cout << "\n";
}

样例: [2,1,5,6,2,3][5,5,6,-1,3,-1] 复杂度: O(N) —— 每个元素最多被压入/弹出一次。


题目 3.5.2 — 每日温度 🟢 简单 对每一天,找还需等多少天才能迎来更高温度。(LeetCode 739 风格)

提示 这正是 NGE 问题。Answer[i] = NGE下标[i] - i。用单调递减栈。
✅ 完整题解
#include <bits/stdc++.h>
using namespace std;
int main() {
    int n; cin >> n;
    vector<int> T(n); for (int& x : T) cin >> x;
    vector<int> ans(n, 0);
    stack<int> st;
    for (int i = 0; i < n; i++) {
        while (!st.empty() && T[st.top()] < T[i]) {
            ans[st.top()] = i - st.top();  // 等待天数
            st.pop();
        }
        st.push(i);
    }
    for (int x : ans) cout << x << " "; cout << "\n";
}

样例: [73,74,75,71,69,72,76,73][1,1,4,2,1,1,0,0]


题目 3.5.3 — 滑动窗口最大值 🟡 中等 找每个大小为 K 的滑动窗口的最大值。

提示 用单调递减双端队列,维护下标范围在 [i-k+1, i] 内,队首 = 最大值。
✅ 完整题解
#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;
    deque<int> dq;
    for (int i = 0; i < n; i++) {
        while (!dq.empty() && dq.front() < i - k + 1) dq.pop_front();
        while (!dq.empty() && a[dq.back()] <= a[i]) dq.pop_back();
        dq.push_back(i);
        if (i >= k - 1) cout << a[dq.front()] << " \n"[i==n-1];
    }
}

样例: n=8,k=3,[1,3,-1,-3,5,3,6,7][3,3,5,5,6,7] 复杂度: O(N) 总计——每个元素进/出双端队列一次。


题目 3.5.4 — 直方图中最大矩形 🟡 中等 找直方图中最大矩形的面积。

提示 对每根柱子找上一个更小(左边界)和下一个更小(右边界)。宽度 = 右 - 左 - 1,面积 = 高度 × 宽度。
✅ 完整题解
#include <bits/stdc++.h>
using namespace std;
int main() {
    int n; cin >> n;
    vector<int> H(n); for (int& x : H) cin >> x;
    vector<int> left(n), right(n);
    stack<int> st;

    // 上一个更小(左边界)
    for (int i = 0; i < n; i++) {
        while (!st.empty() && H[st.top()] >= H[i]) st.pop();
        left[i] = st.empty() ? -1 : st.top();
        st.push(i);
    }
    while (!st.empty()) st.pop();

    // 下一个更小(右边界)
    for (int i = n-1; i >= 0; i--) {
        while (!st.empty() && H[st.top()] >= H[i]) st.pop();
        right[i] = st.empty() ? n : st.top();
        st.push(i);
    }

    long long ans = 0;
    for (int i = 0; i < n; i++)
        ans = max(ans, (long long)H[i] * (right[i] - left[i] - 1));
    cout << ans << "\n";
}

样例: [2,1,5,6,2,3]10(下标 2 的柱子,高度 5,宽度 2) 复杂度: O(N) —— 两次单调栈遍历。


题目 3.5.5 — 接雨水 🔴 困难 给定高度图,计算下雨后能接住多少水。

提示 对每个位置 i,接水量 = min(left_max[i], right_max[i]) - height[i]。
✅ 完整题解(双指针——O(N) 时间,O(1) 空间)
#include <bits/stdc++.h>
using namespace std;
int main() {
    int n; cin >> n;
    vector<int> h(n); for (int& x : h) cin >> x;
    int left = 0, right = n-1, maxL = 0, maxR = 0;
    long long ans = 0;
    while (left < right) {
        if (h[left] <= h[right]) {
            maxL = max(maxL, h[left]);
            ans += maxL - h[left];
            left++;
        } else {
            maxR = max(maxR, h[right]);
            ans += maxR - h[right];
            right--;
        }
    }
    cout << ans << "\n";
}

样例: [0,1,0,2,1,0,1,3,2,1,2,1]6 复杂度: O(N) 时间,O(1) 空间。


🏆 挑战题:USACO 2016 February Silver——围牛栏 给定一个多边形,判断一个点是否在多边形内部。使用射线投射法——实现时需要仔细处理边界情况。

📖 第 3.6 章 ⏱️ 约 50 分钟 🎯 中级

第 3.6 章:栈、队列与双端队列

📝 前置条件: 了解基本的 C++ 数组和循环(第 2.1–2.2 章)。无进阶前提——这些是竞赛编程中随处可见的基础构件。

这三种数据结构控制着元素被处理的顺序。各自独特的「个性」使它们在特定类型的问题中表现出色。

  • 栈(Stack): 后进先出(像一叠盘子)
  • 队列(Queue): 先进先出(像商店排队)
  • 双端队列(Deque): 两端均可插入/删除

3.6.1 栈深度解析

我们在第 3.1 章介绍过 stack,现在用它解决实际问题。

图示:栈的操作

Stack Operations

上图通过逐步压入和弹出操作展示了 LIFO(后进先出)性质。注意 pop() 总是移除最近压入的元素——这正是栈在括号匹配、DFS 和撤销操作中无可替代的原因。

以下是三种容器的对比——访问模式决定了各自适合不同的问题:

Stack vs Queue vs Deque

括号匹配问题

题目: 给定括号字符串 ()[]{}, 判断是否正确嵌套。

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

bool isBalanced(const string &s) {
    stack<char> st;

    for (char ch : s) {
        if (ch == '(' || ch == '[' || ch == '{') {
            st.push(ch);   // 开括号:压栈
        } else {
            // 闭括号:必须与最近的开括号匹配
            if (st.empty()) return false;   // 没有对应的开括号

            char top = st.top();
            st.pop();

            // 检查是否匹配
            if (ch == ')' && top != '(') return false;
            if (ch == ']' && top != '[') return false;
            if (ch == '}' && top != '{') return false;
        }
    }

    return st.empty();  // 栈为空说明所有括号都匹配了
}

int main() {
    cout << isBalanced("()[]{}") << "\n";    // 1(真)
    cout << isBalanced("([]){}") << "\n";    // 1(真)
    cout << isBalanced("([)]")   << "\n";    // 0(假)
    cout << isBalanced("(()")    << "\n";    // 0(假——未匹配的 '(')
    return 0;
}

下一个更大元素

题目: 对数组中每个元素,找其右侧第一个严格更大的元素,不存在则输出 -1。

这是经典的单调栈问题(第 3.5 章有详细讲解)。

📄 这是经典的**单调栈**问题(第 3.5 章有详细讲解)。
#include <bits/stdc++.h>
using namespace std;

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

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

    vector<int> answer(n, -1);  // 默认 -1(没有更大元素)
    stack<int> st;              // 存储等待答案的元素的下标

    for (int i = 0; i < n; i++) {
        // 当栈非空且当前元素 > 栈顶下标处的元素时
        while (!st.empty() && A[i] > A[st.top()]) {
            answer[st.top()] = A[i];  // A[i] 是 st.top() 的下一个更大元素
            st.pop();
        }
        st.push(i);  // 压入当前下标(等待后续更大的元素)
    }

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

    return 0;
}

对 [3, 1, 4, 1, 5, 9, 2, 6] 的追踪:

  • i=0:压入 0。栈:[0]
  • i=1:A[1]=1 ≤ A[0]=3,压入 1。栈:[0,1]
  • i=2:A[2]=4 > A[1]=1 → answer[1]=4,弹出。A[2]=4 > A[0]=3 → answer[0]=4,弹出。压入 2。
  • i=3:压入 3。栈:[2,3]
  • i=4:A[4]=5 > A[3]=1 → answer[3]=5。A[4]=5 > A[2]=4 → answer[2]=5。压入 4。
  • i=5:A[5]=9 > A[4]=5 → answer[4]=9。压入 5。栈:[5]
  • i=6:压入 6。栈:[5,6]
  • i=7:A[7]=6 > A[6]=2 → answer[6]=6。压入 7。
  • 栈中剩余(5, 7):answer 保持 -1。

输出:4 4 5 5 9 -1 6 -1

核心思路: 单调栈维护严格递增或递减顺序的元素。当新元素破坏该顺序时,它「解决了」所有它更大的元素。每个元素最多被压入和弹出一次,因此是 O(N)


3.6.2 队列与 BFS 准备

队列的 FIFO 性质使它非常适合广度优先搜索(BFS),我们在第 5.2 章详细讲解。这里先聚焦队列本身及相关模式。

图示:队列操作

Queue Operations

队列按到达顺序处理元素:队首元素始终最先出队,新元素从队尾加入。FIFO 性质保证 BFS 按层级访问节点,从而保证最短路径距离的正确性。

用队列模拟

题目: 游乐场过山车共有 N 组游客,每组人数为 size[i],每次运行最多容纳 M 人。模拟需要多少次运行才能送完所有人。

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

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

    int n, m;
    cin >> n >> m;

    queue<int> groups;
    for (int i = 0; i < n; i++) {
        int x;
        cin >> x;
        groups.push(x);
    }

    int runs = 0;
    while (!groups.empty()) {
        int capacity = m;   // 本次运行的剩余容量
        runs++;

        while (!groups.empty() && groups.front() <= capacity) {
            capacity -= groups.front();  // 这组能坐下
            groups.pop();
        }
    }

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

3.6.3 双端队列——两端都能操作

deque(发音「deck」)支持 O(1) 的两端插入和删除。

📄 `deque`(发音「deck」)支持 O(1) 的两端插入和删除。
#include <bits/stdc++.h>
using namespace std;

int main() {
    deque<int> dq;

    dq.push_back(1);    // [1]
    dq.push_back(2);    // [1, 2]
    dq.push_front(0);   // [0, 1, 2]
    dq.push_front(-1);  // [-1, 0, 1, 2]

    cout << dq.front() << "\n";  // -1
    cout << dq.back() << "\n";   // 2

    dq.pop_front();  // [-1 移除] → [0, 1, 2]
    dq.pop_back();   // [2 移除]  → [0, 1]

    cout << dq.front() << "\n";  // 0
    cout << dq.size() << "\n";   // 2

    // 随机访问(像向量一样)
    cout << dq[0] << "\n";  // 0
    cout << dq[1] << "\n";  // 1

    return 0;
}

3.6.4 单调双端队列——滑动窗口最大值

题目: 给定 N 个整数的数组 A 和大小为 K 的窗口,找每个窗口从左向右滑动时的最大值。

朴素做法:对每个窗口扫描全部 K 个元素 → O(N×K)。K 较大时太慢。

单调双端队列做法:O(N)

双端队列按值递减的顺序存储下标,队首始终是最大值。

📄 双端队列按**值递减**的顺序存储下标,队首始终是最大值。
#include <bits/stdc++.h>
using namespace std;

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

    int n, k;
    cin >> n >> k;
    vector<int> A(n);
    for (int &x : A) cin >> x;

    deque<int> dq;  // 存下标;A[dq[i]] 的值递减
    vector<int> maxInWindow;

    for (int i = 0; i < n; i++) {
        // 移除超出窗口的元素(队首过期)
        while (!dq.empty() && dq.front() <= i - k) {
            dq.pop_front();
        }

        // 从队尾移除所有小于 A[i] 的元素
        // (只要这个新元素在窗口中,它们就不可能是最大值)
        while (!dq.empty() && A[dq.back()] <= A[i]) {
            dq.pop_back();
        }

        dq.push_back(i);  // 加入当前下标

        // 从 i = k-1 开始,窗口已满
        if (i >= k - 1) {
            maxInWindow.push_back(A[dq.front()]);  // 队首始终是最大值
        }
    }

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

    return 0;
}

样例输入:

8 3
1 3 -1 -3 5 3 6 7

样例输出:

3 3 5 5 6 7

窗口:[1,3,-1]=3,[3,-1,-3]=3,[-1,-3,5]=5,[-3,5,3]=5,[5,3,6]=6,[3,6,7]=7。


3.6.5 基于栈的直方图最大矩形

竞赛编程经典题:给定 N 根高度为 h[0..N-1] 的柱子,找直方图内能放入的最大矩形面积。

📄 竞赛编程经典题:给定 N 根高度为 h[0..N-1] 的柱子,找直方图内能放入的最大矩形面积。
#include <bits/stdc++.h>
using namespace std;

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

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

    stack<int> st;   // 按高度递增存储下标
    long long maxArea = 0;

    for (int i = 0; i <= n; i++) {
        int currentH = (i == n) ? 0 : h[i];  // 末尾添加哨兵 0

        while (!st.empty() && h[st.top()] > currentH) {
            int height = h[st.top()];   // 矩形高度
            st.pop();
            int width = st.empty() ? i : i - st.top() - 1;  // 宽度
            maxArea = max(maxArea, (long long)height * width);
        }

        st.push(i);
    }

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

⚠️ 第 3.6 章常见错误

#错误错在哪里修复方法
1对空栈/队列调用 top()/front()未定义行为,程序崩溃先检查 !st.empty()
2单调栈中比较方向错误「下一个更大」需要 >,用了 < 变成「下一个更小」仔细阅读题意,用样例验证
3滑动窗口中忘记移除过期元素双端队列的队首下标超出窗口范围,结果错误while (dq.front() <= i - k)
4直方图最大矩形忘记哨兵栈中剩余元素未处理,遗漏最终答案i == n 时使用高度 0
5混淆 stackdequestack 只能访问顶部,不能遍历中间元素需要两端操作时改用 deque

本章总结

📌 核心要点

结构操作关键使用场景为什么重要
stack<T>push/pop/top — O(1)括号匹配、撤销/重做、DFSLIFO 逻辑的核心工具
queue<T>push/pop/front — O(1)BFS、模拟排队FIFO 逻辑的核心工具
deque<T>两端 push/pop — O(1)滑动窗口、BFS 变体支持两端访问的多功能容器
单调栈总计 O(N)下一个更大/更小元素USACO Silver 高频考点
单调双端队列总计 O(N)滑动窗口最大/最小值窗口极值的 O(N) 解法

❓ 常见问题

Q1:为什么单调栈是 O(N) 而不是 O(N²)?看起来有嵌套循环啊。

A:核心观察——每个元素最多被压入一次,最多被弹出一次。虽然内层 while 循环可能一次弹出多个元素,但全局弹出总次数 ≤ N。所以总操作 ≤ 2N = O(N)。这种分析方法叫均摊分析

Q2:什么时候用 stack vs deque

A:只需要 LIFO(单端访问),用 stack;需要两端操作(如滑动窗口需要队首删除 + 队尾添加),用 dequestack 内部其实是以 deque 实现的,只是把接口限制为只暴露顶部。

Q3:BFS 一定要用 queue 吗?能用 vector 吗?

A:技术上可以用 vector + 下标模拟,但 queue 更清晰不易出错。竞赛中直接用 queue。唯一例外是 0-1 BFS(边权只有 0 和 1 的最短路),需要用 deque

Q4:「最大矩形」问题为什么能用栈解决?

A:栈维护着高度递增的柱子序列。遇到更矮的柱子时,说明栈顶柱子「向右延伸」到此为止,此时可以计算以栈顶柱子为高度的矩形面积。每根柱子各压入弹出一次,总复杂度 O(N)

🔗 与后续章节的联系

  • 第 5.2 章(图 BFS/DFS):queue 是 BFS 的核心容器,stack 可用于迭代式 DFS
  • 第 3.4 章(双指针):滑动窗口技术与本章的单调双端队列完美结合
  • 第 6.1–6.3 章(DP):某些优化技术(如 DP 的滑动窗口极值优化)直接使用本章的单调双端队列
  • 单调栈也可以作为第 5.7 章(线段树)的替代——许多能用线段树解决的问题也可以用单调栈以 O(N) 解决

练习题

题目 3.6.1 — 股票跨度 🟢 简单 对每一天,找价格 ≤ 今日价格的连续天数(包含今天)。

✅ 完整题解
#include <bits/stdc++.h>
using namespace std;
int main() {
    int n; cin >> n;
    vector<int> P(n); for (int& x : P) cin >> x;
    vector<int> span(n);
    stack<int> st;
    for (int i = 0; i < n; i++) {
        while (!st.empty() && P[st.top()] <= P[i]) st.pop();
        span[i] = st.empty() ? (i + 1) : (i - st.top());
        st.push(i);
    }
    for (int x : span) cout << x << " "; cout << "\n";
}

样例: [100,80,60,70,60,75,85][1,1,1,2,1,4,6] 复杂度: O(N)。


题目 3.6.2 — 循环队列 🟡 中等 实现大小为 K 的循环队列,处理 PUSH/POP 操作并检测 OVERFLOW/UNDERFLOW。

✅ 完整题解
#include <bits/stdc++.h>
using namespace std;
int main() {
    int K, Q; cin >> K >> Q;
    deque<int> q;
    while (Q--) {
        string op; cin >> op;
        if (op == "PUSH") {
            int x; cin >> x;
            if ((int)q.size() == K) cout << "OVERFLOW\n";
            else q.push_back(x);
        } else {
            if (q.empty()) cout << "UNDERFLOW\n";
            else { cout << q.front() << "\n"; q.pop_front(); }
        }
    }
}

复杂度: O(Q)。


题目 3.6.3 — 滑动窗口最小值 🟡 中等 找每个大小为 K 的滑动窗口的最小值。

✅ 完整题解
#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;
    deque<int> dq;  // 单调递增,队首 = 最小值
    for (int i = 0; i < n; i++) {
        while (!dq.empty() && dq.front() < i - k + 1) dq.pop_front();
        while (!dq.empty() && a[dq.back()] >= a[i]) dq.pop_back();
        dq.push_back(i);
        if (i >= k - 1) cout << a[dq.front()] << " \n"[i==n-1];
    }
}

与滑动窗口最大值结构相同,但维护递增双端队列(弹出 ≥ 新元素的值)。


题目 3.6.4 — 表达式求值 🟡 中等 计算只含整数和 +- 运算符(无括号)的简单表达式。

✅ 完整题解
#include <bits/stdc++.h>
using namespace std;
int main() {
    string expr; cin >> expr;
    stack<long long> nums;
    long long cur = 0; bool neg = false;
    for (int i = 0; i < (int)expr.size(); i++) {
        if (isdigit(expr[i])) cur = cur*10 + (expr[i]-'0');
        else {
            nums.push(neg ? -cur : cur);
            cur = 0; neg = (expr[i] == '-');
        }
    }
    nums.push(neg ? -cur : cur);
    long long ans = 0;
    while (!nums.empty()) { ans += nums.top(); nums.pop(); }
    cout << ans << "\n";
}

题目 3.6.5 — 干草堆模拟 🟡 中等 N 堆干草,每天从最高的那堆取走一捆。D 天后打印剩余捆数。

✅ 完整题解
#include <bits/stdc++.h>
using namespace std;
int main() {
    int n, D; cin >> n >> D;
    multiset<long long, greater<long long>> ms;
    for (int i = 0; i < n; i++) { long long x; cin >> x; ms.insert(x); }
    for (int d = 0; d < D && !ms.empty(); d++) {
        auto it = ms.begin();
        long long v = *it; ms.erase(it);
        if (v > 1) ms.insert(v-1);
    }
    long long rem = 0;
    for (long long x : ms) rem += x;
    cout << rem << "\n";
}
📖 第 3.7 章 ⏱️ 约 50 分钟 🎯 中级

第 3.7 章:哈希技术

📝 前置条件: 了解 STL 容器(第 3.1 章)和字符串基础(第 2.3 章)。本章涵盖哈希原理和竞赛编程的进阶用法。

哈希是竞赛编程中最重要的「工具」之一:它能把复杂的比较问题变成 O(1) 的数值比较。但哈希也是最容易被「hack」的技术——本章既教你如何用好哈希,也教你如何防止被 hack。


3.7.1 unordered_map vs map:内部实现与性能

内部实现对比

特性mapunordered_map
内部结构红黑树(平衡 BST)哈希表
查找时间O(log N)平均 O(1),最坏 O(N)
插入时间O(log N)平均 O(1),最坏 O(N)
遍历顺序有序(按键升序)无序
内存占用O(N),常数较小O(N),常数较大
最坏情况O(log N)(稳定)O(N)(哈希碰撞)
📄 C++ 完整代码
#include <bits/stdc++.h>
using namespace std;

int main() {
    // map:有序,O(log N)
    map<int, int> m;
    m[3] = 30; m[1] = 10; m[2] = 20;
    for (auto [k, v] : m) cout << k << ":" << v << " ";
    // 输出:1:10 2:20 3:30  ← 有序!

    // unordered_map:无序,平均 O(1)
    unordered_map<int, int> um;
    um[3] = 30; um[1] = 10; um[2] = 20;
    // 遍历顺序不确定,但查找非常快

    // 性能差异:N=10^6 次操作
    // map: ~300ms;unordered_map: ~80ms(大概)
}

怎么选?

  • map 需要有序遍历、需要 lower_bound/upper_bound、键范围极端(高哈希碰撞风险)
  • unordered_map 只需查找/插入、键是整数或字符串、N 较大(> 10^5)

3.7.2 防 Hack:自定义哈希

问题: unordered_map 的默认整数哈希本质上是 hash(x) = x,攻击者可以构造大量哈希碰撞,使操作退化到 O(N) 从而 TLE。

在 Codeforces 等平台上,这是常见的 hack 技术

解决方案:splitmix64 哈希

📄 查看代码:解决方案:splitmix64 哈希
// 防 hack 自定义哈希器 — 使用 splitmix64
struct custom_hash {
    static uint64_t splitmix64(uint64_t x) {
        x += 0x9e3779b97f4a7c15;
        x = (x ^ (x >> 30)) * 0xbf58476d1ce4e5b9;
        x = (x ^ (x >> 27)) * 0x94d049bb133111eb;
        return x ^ (x >> 31);
    }

    size_t operator()(uint64_t x) const {
        static const uint64_t FIXED_RANDOM =
            chrono::steady_clock::now().time_since_epoch().count();
        return splitmix64(x + FIXED_RANDOM);
    }
};

// 用法:
unordered_map<int, int, custom_hash> safe_map;
unordered_set<int, custom_hash> safe_set;

⚠️ 竞赛技巧: 在 Codeforces 上用 unordered_map 时,始终加上 custom_hash。USACO 测试数据不会故意构造 hack,但这是个好习惯。


3.7.3 字符串哈希(多项式哈希)

字符串哈希将字符串映射为整数,把字符串比较变成数值比较(O(1))。

核心公式

对字符串 s[0..n-1],定义哈希值为:

hash(s) = s[0]·B^(n-1) + s[1]·B^(n-2) + ... + s[n-1]·B^0  (mod M)

其中 B底数(通常取 131 或 131117),M大质数(通常取 10⁹+7 或 10⁹+9)。

前缀哈希 + O(1) 子串哈希

📄 查看代码:前缀哈希 + O(1) 子串哈希
// 字符串哈希:O(N) 预处理,O(1) 子串哈希查询
#include <bits/stdc++.h>
using namespace std;
typedef unsigned long long ull;

const ull BASE = 131;
// 使用 unsigned long long 自然溢出(等价于 mod 2^64)

struct StringHash {
    int n;
    vector<ull> h, pw;

    StringHash(const string& s) : n(s.size()), h(n + 1, 0), pw(n + 1, 1) {
        for (int i = 0; i < n; i++) {
            h[i + 1] = h[i] * BASE + (s[i] - 'a' + 1);  // 1-indexed 前缀哈希
            pw[i + 1] = pw[i] * BASE;                      // BASE^(i+1)
        }
    }

    // 获取子串 s[l..r](0-indexed)的哈希值
    ull get(int l, int r) {
        return h[r + 1] - h[l] * pw[r - l + 1];  // ← 关键公式
    }
};

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

    string s = "abcabc";
    StringHash sh(s);

    // 比较两个子串是否相等
    // s[0..2] = "abc",s[3..5] = "abc"
    cout << (sh.get(0, 2) == sh.get(3, 5) ? "相等" : "不相等") << "\n";  // 相等

    // 比较 s[0..1] = "ab" vs s[3..4] = "ab"
    cout << (sh.get(0, 1) == sh.get(3, 4) ? "相等" : "不相等") << "\n";  // 相等
}

公式推导:

h[r+1] = s[0]*B^r + s[1]*B^(r-1) + ... + s[r]*B^0
h[l]   = s[0]*B^(l-1) + ... + s[l-1]*B^0

h[r+1] - h[l] * B^(r-l+1)
= s[l]*B^(r-l) + s[l+1]*B^(r-l-1) + ... + s[r]*B^0
= hash(s[l..r]) ✓

下图直观展示了前缀哈希数组的构建过程,以及如何用 get(l, r) 公式在 O(1) 内提取任意子串的哈希值:

String Polynomial Hash


3.7.4 双重哈希(避免碰撞)

单重哈希(mod M)的碰撞概率约 1/M。对 N 次子串比较,预期碰撞次数约 N²/(2M)

下图展示了两种经典的碰撞处理方式——链式法(unordered_map 内部使用)和线性探测:

Hash Collision Resolution

  • M = 10⁹+7,N = 10⁶:碰撞概率约 10¹²/(2×10⁹) = 500 次!不安全。
  • 解决方案:双重哈希,同时使用两对不同的 (B, M),碰撞概率降至 1/(M₁×M₂) ≈ 10⁻¹⁸
📄 C++ 完整代码
// 双重哈希:同时用两对 (BASE, MOD),碰撞概率极低
struct DoubleHash {
    static const ull B1 = 131, M1 = 1e9 + 7;
    static const ull B2 = 137, M2 = 1e9 + 9;

    int n;
    vector<ull> h1, h2, pw1, pw2;

    DoubleHash(const string& s) : n(s.size()),
        h1(n+1,0), h2(n+1,0), pw1(n+1,1), pw2(n+1,1) {
        for (int i = 0; i < n; i++) {
            ull c = s[i] - 'a' + 1;
            h1[i+1] = (h1[i] * B1 + c) % M1;
            h2[i+1] = (h2[i] * B2 + c) % M2;
            pw1[i+1] = pw1[i] * B1 % M1;
            pw2[i+1] = pw2[i] * B2 % M2;
        }
    }

    // 返回 pair<ull,ull> 作为子串 s[l..r] 的哈希「指纹」
    pair<ull,ull> get(int l, int r) {
        ull v1 = (h1[r+1] - h1[l] * pw1[r-l+1] % M1 + M1) % M1;
        ull v2 = (h2[r+1] - h2[l] * pw2[r-l+1] % M2 + M2) % M2;
        return {v1, v2};
    }
};

3.7.5 应用:字符串匹配(Rabin-Karp)

📄 查看代码:3.7.5 应用:字符串匹配(Rabin-Karp)
// Rabin-Karp 字符串匹配:找 T 中所有 P 的出现位置
// 时间:平均 O(N+M),最坏 O(NM)(实际非常快)
#include <bits/stdc++.h>
using namespace std;
typedef unsigned long long ull;

vector<int> rabinKarp(const string& T, const string& P) {
    int n = T.size(), m = P.size();
    if (m > n) return {};

    const ull BASE = 131;
    ull patHash = 0, textHash = 0, pow_m = 1;

    // 计算 BASE^m(自然溢出)
    for (int i = 0; i < m - 1; i++) pow_m *= BASE;

    // 初始哈希
    for (int i = 0; i < m; i++) {
        patHash = patHash * BASE + P[i];
        textHash = textHash * BASE + T[i];
    }

    vector<int> result;
    for (int i = 0; i + m <= n; i++) {
        if (textHash == patHash) {
            // 哈希匹配时验证(避免碰撞导致的误判)
            if (T.substr(i, m) == P) result.push_back(i);
        }
        if (i + m < n) {
            // 滚动哈希:去掉最左边的字符,加入最右边的字符
            textHash = textHash - T[i] * pow_m;   // 移除最左
            textHash = textHash * BASE + T[i + m]; // 加入最右
        }
    }
    return result;
}

3.7.6 应用:最长公共子串

题目: 给定字符串 S 和 T,找最长公共子串的长度。

做法: 二分答案(最长公共子串的长度 L),然后用哈希集合检查是否有长度为 L 的子串同时出现在两个字符串中。

📄 C++ 完整代码
// 最长公共子串:O(N log N) — 二分查找 + 哈希
int longestCommonSubstring(const string& S, const string& T) {
    StringHash hs(S), ht(T);
    int ns = S.size(), nt = T.size();

    auto check = [&](int len) -> bool {
        unordered_set<ull> setS;
        for (int i = 0; i + len <= ns; i++)
            setS.insert(hs.get(i, i + len - 1));
        for (int j = 0; j + len <= nt; j++)
            if (setS.count(ht.get(j, j + len - 1)))
                return true;
        return false;
    };

    int lo = 0, hi = min(ns, nt);
    while (lo < hi) {
        int mid = (lo + hi + 1) / 2;
        if (check(mid)) lo = mid;
        else hi = mid - 1;
    }
    return lo;
}

⚠️ 常见错误

  1. 模数选择不当: 不要用 10⁹+7 以外的数;尤其避免非质数模数(碰撞率高)。推荐:10⁹+710⁹+9 作为双重哈希对。

  2. unordered_map 被 hack: 在 Codeforces 等平台上,默认哈希可被攻击。始终使用 custom_hash

  3. 子串哈希相减下溢: h[r+1] - h[l] * pw[r-l+1] 在有符号整数下可能为负。使用 unsigned long long 自然溢出,或用 (... % M + M) % M 确保非负。

  4. 底数与字符集不匹配: 对仅含小写字母(26 种)的情况,BASE 必须 > 26(通常用 31 或 131)。对全 ASCII 字符(128 种),BASE 必须 > 128(用 131 或 137)。

  5. 哈希碰撞导致 WA: 即使双重哈希,理论上仍可能碰撞。不确定时,哈希匹配后加直接字符串比较。


本章总结

📌 核心对比表

工具时间复杂度使用场景
map<K,V>O(log N)需要有序性,需要范围查询
unordered_map<K,V>O(1) 均摊只需查找/插入,不需要键的顺序
字符串哈希(单重)O(N) 预处理,O(1) 查询子串比较、模式匹配
字符串哈希(双重)O(N) 预处理,O(1) 查询高精度场景,避免碰撞

❓ 常见问题

Q1:ull 自然溢出双重哈希和手动 mod 哈希哪个更好?

A:ull 自然溢出(等价于 mod 2⁶⁴)代码更简单,2⁶⁴ 足够大以至于单重哈希碰撞概率已经极低(约 10⁻¹⁸)。但精心构造的数据可以故意触发碰撞——此时双重哈希更安全。两种方式在竞赛中都有效;ull 更常见。

Q2:字符串哈希能做什么 KMP 做不到的事?

A:字符串哈希擅长多字符串比较(如最长公共子串、回文子串),而 KMP 只擅长单模式匹配。哈希 + 二分查找可以用 O(N log N) 解决许多需要更复杂 KMP 实现的字符串问题。

Q3:底数该用 31 还是 131?

A:只有小写字母时用 31(小于 37 的质数,避免哈希空间太小)。混合大小写或含数字时用 131(大于 128 的质数,覆盖完整 ASCII)。关键是:BASE 必须大于字符集大小,最好是质数。


练习题

题目 3.7.1 — 哈希两数之和 🟢 简单 给定数组 A,判断是否存在两个不同元素之和等于目标值 X。

提示 对每个 A[i],检查 (X - A[i]) 是否已经在哈希集合中。
✅ 完整题解
#include <bits/stdc++.h>
using namespace std;
int main() {
    int n, X; cin >> n >> X;
    vector<int> a(n); for (int& x : a) cin >> x;
    unordered_set<int> seen;
    for (int x : a) {
        if (seen.count(X - x)) { cout << "YES\n"; return 0; }
        seen.insert(x);
    }
    cout << "NO\n";
}

复杂度: O(N) 均摊。


题目 3.7.2 — 子串查找 🟢 简单 给定文本 T 和模式 P,打印 P 在 T 中所有出现位置的起始下标。

提示 滚动哈希:用前缀哈希在 O(1) 计算 T 的每个 |P| 长度窗口的哈希值。
✅ 完整题解(滚动哈希)
#include <bits/stdc++.h>
using namespace std;
typedef unsigned long long ull;
const ull BASE = 131, MOD = (1ULL<<61)-1;
int main() {
    string T, P; cin >> T >> P;
    int n = T.size(), m = P.size();
    vector<ull> h(n+1,0), pw(n+1,1);
    for(int i=0;i<n;i++) { h[i+1]=(h[i]*BASE+T[i])%MOD; pw[i+1]=pw[i]*BASE%MOD; }
    ull hp=0; for(char c:P) hp=(hp*BASE+c)%MOD;
    for(int i=0;i+m<=n;i++){
        ull wh=(h[i+m]-h[i]*pw[m]%MOD+MOD*2)%MOD;
        if(wh==hp) cout<<i<<"\n";
    }
}

复杂度: O(N + M)。


题目 3.7.3 — 最长回文子串 🟡 中等 找最长回文子串的长度。

提示 对长度二分查找。s[l..r] 是回文串当且仅当 hash(s[l..r]) == hash(rev(s)[n-1-r..n-1-l])。
✅ 完整题解(哈希 + 二分查找)
#include <bits/stdc++.h>
using namespace std;
typedef unsigned long long ull;
const ull BASE=131;
struct Hasher {
    vector<ull> h,pw;
    Hasher(const string&s){
        int n=s.size(); h.resize(n+1,0); pw.resize(n+1,1);
        for(int i=0;i<n;i++){h[i+1]=h[i]*BASE+s[i];pw[i+1]=pw[i]*BASE;}
    }
    ull get(int l,int r){return h[r+1]-h[l]*pw[r-l+1];}
};
int main(){
    string s; cin>>s;
    string r(s.rbegin(),s.rend());
    Hasher hs(s),hr(r);
    int n=s.size(), ans=1;
    auto check=[&](int len)->bool{
        for(int i=0;i+len<=n;i++){
            int j=i+len-1;
            if(hs.get(i,j)==hr.get(n-1-j,n-1-i)) return true;
        }
        return false;
    };
    int lo=1,hi=n;
    while(lo<=hi){int mid=(lo+hi)/2;if(check(mid)){ans=mid;lo=mid+1;}else hi=mid-1;}
    cout<<ans<<"\n";
}

复杂度: O(N log N)。


题目 3.7.4 — 统计不同子串 🟡 中等 给定长度为 N 的字符串 S(N ≤ 5000),统计不同子串的个数。

提示 将所有 O(N²) 个子串的哈希值插入 unordered_set,用双重哈希避免碰撞。
✅ 完整题解
#include <bits/stdc++.h>
using namespace std;
typedef unsigned long long ull;
int main(){
    string s; cin>>s;
    int n=s.size();
    const ull B1=131,B2=137,M1=1e9+7,M2=1e9+9;
    unordered_set<ull> seen;
    for(int i=0;i<n;i++){
        ull h1=0,h2=0;
        for(int j=i;j<n;j++){
            h1=(h1*B1+s[j])%M1;
            h2=(h2*B2+s[j])%M2;
            seen.insert(h1*M2+h2);  // 合并两个哈希值
        }
    }
    cout<<seen.size()<<"\n";
}

复杂度: O(N²) 时间和空间(适用于 N ≤ 5000)。


题目 3.7.5 — 字符串周期 🔴 困难 找字符串 S 的最小周期(最小的 k 整除 n,使得 S = S[0..k-1] 的重复)。

提示 对 n 的每个因数 k,验证 s[0..k-1] 重复后是否等于 s,用哈希比较,每次检查 O(n/k)。
✅ 完整题解
#include <bits/stdc++.h>
using namespace std;
typedef unsigned long long ull;
const ull BASE=131,MOD=1e9+7;
int main(){
    string s; cin>>s;
    int n=s.size();
    vector<ull> h(n+1,0),pw(n+1,1);
    for(int i=0;i<n;i++){h[i+1]=(h[i]*BASE+s[i])%MOD;pw[i+1]=pw[i]*BASE%MOD;}
    auto getHash=[&](int l,int r){return (h[r+1]-h[l]*pw[r-l+1]%MOD+MOD*2)%MOD;};

    vector<int> divs;
    for(int i=1;i*i<=n;i++) if(n%i==0){divs.push_back(i);if(i!=n/i)divs.push_back(n/i);}
    sort(divs.begin(),divs.end());

    for(int k:divs){
        bool ok=true;
        for(int i=0;i+k<=n&&ok;i+=k)
            if(getHash(i,i+k-1)!=getHash(0,k-1)) ok=false;
        if(ok){cout<<k<<"\n";return 0;}
    }
}

复杂度: O(d(N) × N) ≈ 典型输入 O(N log N)。

📖 第 3.8 章 ⏱️ 约 55 分钟 🎯 中级

第 3.8 章:映射与集合

映射和集合是频率统计、查找和跟踪唯一元素的主力工具。本章深入探讨它们在 USACO 题目中的实际应用。

📝 前置条件: 熟悉数组和基础 C++ STL(第 2.4 章)。了解哈希表概念(第 3.7 章)有帮助,但不是严格要求——mapset 基于树结构,不依赖哈希。


3.8.1 map vs unordered_map —— 明智地选择

图示:Map 内部结构(BST)

Map Structure

std::map 将键值对存储在平衡 BST(红黑树)中,所有操作 O(log N) 且键自动有序——当你需要 lower_bound/upper_bound 查询时非常有用。只需 O(1) 查找且不需要顺序时,用 unordered_map

mapunordered_map 的关键结构差异:

map vs unordered_map

特性mapunordered_map
底层结构红黑树哈希表
插入/查找时间O(log n)平均 O(1),最坏 O(n)
遍历顺序按键有序任意顺序
最小/最大键通过 .begin()/.rbegin() 获取不支持
键的要求可比较(有 <可哈希
使用场景需要有序键或最大最小键时需要最快查找时

大多数 USACO 题目两者都能用。键是整数或字符串时用 unordered_map 求速度,需要有序遍历时用 map

示例:频率映射

📄 查看代码:示例:频率映射
#include <bits/stdc++.h>
using namespace std;

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

    int n;
    cin >> n;

    unordered_map<int, int> freq;
    for (int i = 0; i < n; i++) {
        int x;
        cin >> x;
        freq[x]++;   // 计数加一;若不存在则以 0 创建
    }

    // 找出现频率最高的元素
    int maxFreq = 0, maxVal = INT_MIN;
    for (auto &[val, count] : freq) {   // 结构化绑定(C++17)
        if (count > maxFreq || (count == maxFreq && val < maxVal)) {
            maxFreq = count;
            maxVal = val;
        }
    }

    cout << "最频繁:" << maxVal << "(" << maxFreq << " 次)\n";

    return 0;
}

3.8.2 Map 操作——完整参考

📄 查看代码:3.8.2 Map 操作——完整参考
#include <bits/stdc++.h>
using namespace std;

int main() {
    map<string, int> scores;

    // 插入
    scores["Alice"] = 95;
    scores["Bob"] = 87;
    scores["Charlie"] = 92;
    scores.insert({"Dave", 78});    // 另一种方式
    scores.emplace("Eve", 88);      // 最高效的方式

    // 查找
    cout << scores["Alice"] << "\n";  // 95
    // 警告:scores["Unknown"] 会以值 0 创建该项!

    // 安全查找
    if (scores.count("Frank")) {
        cout << scores["Frank"] << "\n";
    } else {
        cout << "Frank not found\n";
    }

    // 使用 find() — 返回迭代器
    auto it = scores.find("Bob");
    if (it != scores.end()) {
        cout << it->first << ": " << it->second << "\n";  // Bob: 87
    }

    // 更新
    scores["Alice"] += 5;    // Alice 现在是 100

    // 删除
    scores.erase("Charlie");

    // 按键有序遍历(map 始终保证有序)
    for (const auto &[name, score] : scores) {
        cout << name << ": " << score << "\n";
    }
    // Alice: 100
    // Bob: 87
    // Dave: 78
    // Eve: 88

    // 大小和空检查
    cout << scores.size() << "\n";   // 4
    cout << scores.empty() << "\n";  // 0(假)

    // 清空所有条目
    scores.clear();

    return 0;
}

3.8.3 Set 操作——完整参考

📄 查看代码:3.8.3 Set 操作——完整参考
#include <bits/stdc++.h>
using namespace std;

int main() {
    set<int> s = {5, 3, 8, 1, 9, 2};
    // s = {1, 2, 3, 5, 8, 9}(始终有序!)

    // 插入
    s.insert(4);   // s = {1, 2, 3, 4, 5, 8, 9}
    s.insert(3);   // 已存在,无变化

    // 删除
    s.erase(8);    // s = {1, 2, 3, 4, 5, 9}

    // 查找
    cout << s.count(3) << "\n";  // 1(存在)
    cout << s.count(7) << "\n";  // 0(未找到)

    // 基于迭代器的查询
    auto it = s.lower_bound(4);  // 第一个 >= 4 的元素
    cout << *it << "\n";         // 4

    auto it2 = s.upper_bound(4); // 第一个 > 4 的元素
    cout << *it2 << "\n";        // 5

    // 最小值和最大值
    cout << *s.begin() << "\n";   // 1(最小)
    cout << *s.rbegin() << "\n";  // 9(最大)

    // 移除最小值
    s.erase(s.begin());   // 移除 1
    cout << *s.begin() << "\n";  // 2

    // 遍历
    for (int x : s) cout << x << " ";
    cout << "\n";  // 2 3 4 5 9

    return 0;
}

3.8.4 USACO 题目:奶牛 ID

题目(USACO 2017 February Bronze): 给定一组「已占用」的 ID 集合和 N,找第 N 个可用的 ID。

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

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

    int n, q;
    cin >> n >> q;

    set<int> taken;
    for (int i = 0; i < n; i++) {
        int x; cin >> x;
        taken.insert(x);
    }

    // 对每个查询 q,找第 q 个不在 taken 中的正整数
    while (q--) {
        int k; cin >> k;

        // 二分查找:找最小的 x 使得 x - (x 以内已占用的数量) >= k
        int lo = 1, hi = 2e9;
        while (lo < hi) {
            int mid = lo + (hi - lo) / 2;
            // [1, mid] 中可用数量 = mid - (≤ mid 的已占用数量)
            int taken_count = (int)(taken.lower_bound(mid + 1) - taken.begin());
            int available = mid - taken_count;
            if (available >= k) hi = mid;
            else lo = mid + 1;
        }

        cout << lo << "\n";
    }

    return 0;
}

3.8.5 Multiset——允许重复的有序集合

multiset 类似于 set,但允许重复值:

📄 `multiset` 类似于 set,但允许重复值:
#include <bits/stdc++.h>
using namespace std;

int main() {
    multiset<int> ms;
    ms.insert(3);
    ms.insert(1);
    ms.insert(3);   // 允许重复
    ms.insert(5);
    ms.insert(1);

    // ms = {1, 1, 3, 3, 5}

    cout << ms.count(3) << "\n";  // 2(有几个 3)
    cout << ms.count(2) << "\n";  // 0

    // 只移除一个 3
    ms.erase(ms.find(3));  // 只移除一个 3
    // ms = {1, 1, 3, 5}

    // 移除所有的 1
    ms.erase(1);  // 移除所有 1
    // ms = {3, 5}

    cout << *ms.begin() << "\n";   // 3(最小)
    cout << *ms.rbegin() << "\n";  // 5(最大)

    return 0;
}

用两个 Multiset 维护动态中位数

用最大 multiset(下半部分)和最小 multiset(上半部分)跟踪数据流的中位数:

📄 用最大 multiset(下半部分)和最小 multiset(上半部分)跟踪数据流的中位数:
#include <bits/stdc++.h>
using namespace std;

int main() {
    multiset<int> lo;  // 下半部分(rbegin() = 最大值)
    multiset<int> hi;  // 上半部分(begin() = 最小值)

    int n;
    cin >> n;

    for (int i = 0; i < n; i++) {
        int x;
        cin >> x;

        // 加入合适的半部分
        if (lo.empty() || x <= *lo.rbegin()) {
            lo.insert(x);
        } else {
            hi.insert(x);
        }

        // 重新平衡:大小差不超过 1
        while (lo.size() > hi.size() + 1) {
            hi.insert(*lo.rbegin());
            lo.erase(lo.find(*lo.rbegin()));
        }
        while (hi.size() > lo.size()) {
            lo.insert(*hi.begin());
            hi.erase(hi.begin());
        }

        // 打印中位数
        if (lo.size() == hi.size()) {
            // 偶数个:两个中间值的平均
            double median = (*lo.rbegin() + *hi.begin()) / 2.0;
            cout << fixed << setprecision(1) << median << "\n";
        } else {
            // 奇数个:中间值在 lo 中
            cout << *lo.rbegin() << "\n";
        }
    }

    return 0;
}

3.8.6 实用模式

模式一:统计不同元素

vector<int> data = {1, 5, 3, 1, 2, 5, 5, 3};
set<int> distinct(data.begin(), data.end());
cout << "不同值的个数:" << distinct.size() << "\n";  // 4

模式二:按频率分组,按值排序

📄 查看代码:模式二:按频率分组,按值排序
vector<int> nums = {3, 1, 4, 1, 5, 9, 2, 6, 5, 3, 5};
map<int, int> freq;
for (int x : nums) freq[x]++;

// 按频率分组
map<int, vector<int>> byFreq;
for (auto &[val, cnt] : freq) {
    byFreq[cnt].push_back(val);
}

// 按频率顺序打印
for (auto &[cnt, vals] : byFreq) {
    for (int v : vals) cout << v << "(×" << cnt << ")\n";
}

模式三:离线查询与排序结合

将查询与事件一起排序,以 O((N+Q) log N) 一起处理:

// 示例:对每个查询点,统计有多少事件的值 <= 查询点
// 对两个数组排序,用双指针扫描

⚠️ 第 3.8 章常见错误

#错误错在哪里修复方法
1map[key] 访问不存在的键自动创建值为 0 的条目,污染数据先用 m.count(key)m.find(key) 检查
2multiset::erase(value) 删除所有相等值本想删一个,却删光了ms.erase(ms.find(value)) 只删一个
3遍历时修改 map/set 大小迭代器失效,崩溃或跳过元素it = m.erase(it) 安全删除
4unordered_map 被 hack 退化到 O(N)攻击者构造哈希碰撞数据,TLE换用 map 或使用自定义哈希函数
5忘记 set 不存储重复值插入重复后 size() 不增,计数出错需要重复时用 multiset

本章总结

📌 核心要点

结构有序?允许重复?关键特性为什么重要
map<K,V>是(有序)否(唯一键)键值映射,O(log N)频率统计、ID→属性映射
unordered_map<K,V>平均 O(1) 查找大数据下比 map 快 5-10 倍
set<T>是(有序)有序唯一集合去重,范围查询(lower_bound
unordered_set<T>O(1) 成员检测只需检查「是否见过」
multiset<T>是(有序)有序多集合动态中位数、滑动窗口

🧩 「用哪个容器」速查

需求推荐容器原因
统计每个元素出现次数map / unordered_map一行 freq[x]++
去重并排序set自动去重 + 自动排序
检查元素是否已见unordered_setO(1) 查找
动态有序集合 + 求极值set / multisetO(1) 访问最小/最大值
需要 lower_bound / upper_boundset / map只有有序容器支持
值→下标映射map / unordered_map坐标压缩等场景

❓ 常见问题

Q1:map[] 运算符和 find 有什么区别?

A:m[key] 当键不存在时会自动创建默认值(int 的默认值为 0);m.find(key) 只查找,不创建。如果只想检查键是否存在,用 m.count(key)m.find(key) != m.end()

Q2:multisetpriority_queue 都能取极值——用哪个?

A:priority_queue 只能取极值并删除,不支持按值删除。multiset 支持查找并删除任意值,更灵活。只需反复取极值时,priority_queue 更简单;需要删除特定元素(如滑动窗口移除离开的元素)时,用 multiset

Q3:unordered_map 什么时候比 map 慢?

A:两种情况:① 哈希碰撞严重时(多个键哈希到同一桶),退化到 O(N);② 竞赛中攻击者故意构造数据 hack unordered_map。解决方案:使用自定义哈希函数,或切换到 map

Q4:C++17 结构化绑定 auto &[key, val] 安全吗?竞赛中能用吗?

A:USACO 和大多数竞赛平台支持 C++17,for (auto &[key, val] : m) 可以安全使用。比 entry.first/entry.second 更简洁。

🔗 与后续章节的联系

  • 第 3.3 章(排序与搜索):坐标压缩常与 map 结合(值 → 压缩后下标)
  • 第 5.7 章(线段树):有序 setlower_bound 可以替代简单的线段树查询
  • 第 5.1–5.2 章(图):map 常用于存储稀疏图的邻接表
  • 第 4.1 章(贪心):multiset 结合贪心策略可以高效维护动态最优选择
  • map 频率统计模式贯穿全书,是竞赛编程中最基础的工具之一

练习题

题目 3.8.1 — 两数之和 🟢 简单 读取 N 个整数和目标 T,找两个相加等于 T 的值,打印它们的下标(1-indexed)。

✅ 完整题解
#include <bits/stdc++.h>
using namespace std;
int main() {
    int n, T; cin >> n >> T;
    map<int, int> seen;  // 值 → 下标
    for (int i = 1; i <= n; i++) {
        int x; cin >> x;
        if (seen.count(T - x)) {
            cout << seen[T - x] << " " << i << "\n";
            return 0;
        }
        seen[x] = i;
    }
    cout << "no solution\n";
}

复杂度:map O(N log N),用 unordered_map O(N)。


题目 3.8.2 — 字母异位词分组 🟡 中等 将 N 个单词按其字母排序后的形式分组。

✅ 完整题解
#include <bits/stdc++.h>
using namespace std;
int main() {
    int n; cin >> n;
    map<string, vector<string>> groups;
    for (int i = 0; i < n; i++) {
        string w; cin >> w;
        string key = w; sort(key.begin(), key.end());
        groups[key].push_back(w);
    }
    for (auto& [key, words] : groups) {
        sort(words.begin(), words.end());
        for (const string& w : words) cout << w << " ";
        cout << "\n";
    }
}

复杂度: O(N × K log K),K = 平均单词长度。


题目 3.8.3 — 区间最大重叠数 🟡 中等 统计 N 个区间在点 1..M 上的最大重叠数。

✅ 完整题解
#include <bits/stdc++.h>
using namespace std;
int main() {
    int n, M; cin >> n >> M;
    vector<int> diff(M + 2, 0);
    for (int i = 0; i < n; i++) {
        int l, r; cin >> l >> r;
        diff[l]++; diff[r+1]--;
    }
    int cur = 0, ans = 0;
    for (int i = 1; i <= M; i++) {
        cur += diff[i];
        ans = max(ans, cur);
    }
    cout << ans << "\n";
}

复杂度: O(N + M)。


题目 3.8.4 — 奶牛摄影 🔴 困难 找与所有 N 个列表(每个都是 ID 的一个排列)一致的顺序。

✅ 完整题解

核心思路: 对每对 (a, b),统计 a 在多少个列表中排在 b 之前。若 a 在超过一半的列表中排在 b 前面,则 a 在真实顺序中排在 b 前面。用这个成对比较来排序。

#include <bits/stdc++.h>
using namespace std;
int main() {
    int n, k; cin >> n >> k;
    vector<vector<int>> pos(k, vector<int>(n+1));
    for (int i = 0; i < k; i++)
        for (int j = 0; j < n; j++) {
            int x; cin >> x; pos[i][x] = j;
        }
    vector<int> cows(n); iota(cows.begin(), cows.end(), 1);
    sort(cows.begin(), cows.end(), [&](int a, int b){
        int before = 0;
        for (int i = 0; i < k; i++) before += (pos[i][a] < pos[i][b]);
        return before > k / 2;
    });
    for (int c : cows) cout << c << "\n";
}

题目 3.8.5 — 实时不同值计数 🟢 简单 每读入一个新整数后,打印到目前为止见过的不同值的个数。

✅ 完整题解
#include <bits/stdc++.h>
using namespace std;
int main() {
    int n; cin >> n;
    unordered_set<int> seen;
    for (int i = 0; i < n; i++) {
        int x; cin >> x;
        seen.insert(x);
        cout << seen.size() << "\n";
    }
}

复杂度: O(N) 均摊。

📖 第 3.5b 章:二分答案

⏱ 预计阅读时间:40 分钟 | 难度:🟡 中等


前置条件

在学习本章之前,请确保你已掌握:

  • 数组与循环(第 2.2 章)
  • lower_bound / upper_bound 的基本用法(第 3.3 章)

🎯 学习目标

学完本章后,你将能够:

  1. 识别「可以用二分答案」的题目特征(三要素)
  2. 区分「找第一个满足条件」和「找最后一个满足条件」两种模板
  3. 设计正确的 check 函数
  4. 处理浮点二分和三分法求极值
  5. 避免整数二分中的边界死循环

3.5b.1 什么是二分答案?

从枚举到二分

考虑这个问题:

你有 N 根木头,每根长度不同。需要切出 K 根长度相等(都为 x)的木棍,问 x 的最大值是多少?

朴素思路: 枚举所有可能的 x,检查是否满足条件。

  • 若 x 能切出 ≥ K 根 → 合法
  • 若 x 太大,切不出 → 不合法

这需要 O(10^9) 次检查,太慢了。

二分答案的洞察:
如果 x = 5 可以切出 ≥ K 根,那 x = 4 肯定也可以(每根切的数量只会更多)。
也就是说,满足条件的 x 构成一个连续区间 [0, 某个上界]。
我们可以对这个区间二分,每次检查中点是否合法。


3.5b.2 二分答案的三要素

使用二分答案必须满足以下三个条件:

条件说明验证方法
答案在固定区间内能确定答案的上下界分析题目数据范围
check 函数易于实现给定一个答案值,能快速判断是否合法通常用贪心或 O(N) 扫描
单调性若 x 合法,则更小(或更大)的值也合法手动举反例验证

单调性的两种形式

形式 1:「最大值最小化」
合法区间在左侧:0 0 0 [1 1 1 1 1]
→ 找最后一个合法值(最右侧的 1)

形式 2:「最小值最大化」
合法区间在右侧:[1 1 1 1 1] 0 0 0
→ 找第一个合法值(最左侧的 1)


3.5b.3 整数二分模板

模板 A:找满足条件的最大值(最大化)

📄 查看代码:模板 A:找满足条件的最大值(最大化)
// 找最大的 x,使 check(x) == true
// 前提:check 形如 [true...true, false...false]

bool check(int x) {
    // 判断 x 是否满足条件
    // ...
}

int binary_search_max() {
    int lo = 可能的最小值;
    int hi = 可能的最大值 + 1;  // hi 是第一个不合法的值
    
    while (lo + 1 < hi) {
        int mid = lo + (hi - lo) / 2;  // 防溢出写法
        if (check(mid))
            lo = mid;   // mid 合法,尝试更大的
        else
            hi = mid;   // mid 不合法,答案在左侧
    }
    
    return lo;  // lo 是最大的合法值
}

模板 B:找满足条件的最小值(最小化)

📄 查看代码:模板 B:找满足条件的最小值(最小化)
// 找最小的 x,使 check(x) == true
// 前提:check 形如 [false...false, true...true]

int binary_search_min() {
    int lo = 可能的最小值 - 1;  // lo 是第一个不合法的值
    int hi = 可能的最大值;
    
    while (lo + 1 < hi) {
        int mid = lo + (hi - lo) / 2;
        if (check(mid))
            hi = mid;   // mid 合法,尝试更小的
        else
            lo = mid;   // mid 不合法,答案在右侧
    }
    
    return hi;  // hi 是最小的合法值
}

为什么用 lo + 1 < hi 而不是 lo < hi

📄 查看代码:为什么用 lo + 1 < hi 而不是 lo < hi?
终止条件分析:
  循环条件 lo + 1 < hi,即 lo 和 hi 不相邻时继续
  
  终止时:lo + 1 == hi
  此时 lo 是最大的合法值,hi 是最小的不合法值
  
  若用 lo < hi,则 mid = (lo+hi)/2 = lo,会死循环!
  
示例:lo=4, hi=5
  mid = 4 + (5-4)/2 = 4
  若 check(4)=true,lo = mid = 4(没有变化!死循环)

3.5b.4 完整例题:切木头

有 N 根木头,长度为 a[1..N],需要切出 K 根长度为 H 的木棍。
每切一根,从长度 L > H 的木头中截取一段 H,剩余部分保留。
求 H 的最大值(整数)。

分析

  • 答案范围: H ∈ [1, max(a)]
  • check(H): 扫描所有木头,统计能切出多少根长 H 的木棍,是否 ≥ K
  • 单调性: H 越大,切出的根数越少(check 从 true 变 false),满足最大化模式
📄 C++ 完整代码
#include <bits/stdc++.h>
using namespace std;

int n, k;
vector<int> a;

// 检查高度 H 能否切出 >= k 根
bool check(long long H) {
    long long total = 0;
    for (int x : a) {
        total += x / H;  // 每根木头能切出几段
    }
    return total >= k;
}

int main() {
    cin >> n >> k;
    a.resize(n);
    for (int& x : a) cin >> x;
    
    long long lo = 1, hi = *max_element(a.begin(), a.end()) + 1;
    
    while (lo + 1 < hi) {
        long long mid = lo + (hi - lo) / 2;
        if (check(mid))
            lo = mid;   // 可以切出够多,尝试更高
        else
            hi = mid;   // 切得太短,上界降低
    }
    
    cout << lo << endl;  // 最大的合法高度
    return 0;
}

追踪示例:
木头长度 = [8, 5, 3, 2],K = 5

lo=1, hi=9

mid=5:check(5) = 8/5+5/5+3/5+2/5 = 1+1+0+0 = 2 < 5 → false,hi=5
mid=3:check(3) = 8/3+5/3+3/3+2/3 = 2+1+1+0 = 4 < 5 → false,hi=3
mid=2:check(2) = 8/2+5/2+3/2+2/2 = 4+2+1+1 = 8 >= 5 → true,lo=2

lo+1 = 3 = hi,循环结束

答案:lo = 2

3.5b.5 例题:最大化最小值(安置奶牛)

USACO 经典题:N 个牛栏位于 1D 数轴上(坐标已知),放 C 头奶牛,
使得相邻奶牛之间的最小距离最大,求这个最大值。

分析

  • 答案范围: [1, max_coord - min_coord]
  • check(D): 贪心地从左到右放置奶牛,若相邻奶牛距离 ≥ D,则放下一头
  • 单调性: D 越大越难满足(最小化模式的反向)
📄 C++ 完整代码
#include <bits/stdc++.h>
using namespace std;

int n, c;
vector<int> pos;

// 检查:最小距离至少为 D,能否放 >= c 头奶牛?
bool check(int D) {
    int cows = 1;          // 第一头放在最左边
    int last = pos[0];     // 上一头奶牛的位置
    
    for (int i = 1; i < n; i++) {
        if (pos[i] - last >= D) {
            // 距离足够,放一头奶牛
            cows++;
            last = pos[i];
            if (cows >= c) return true;
        }
    }
    return cows >= c;
}

int main() {
    cin >> n >> c;
    pos.resize(n);
    for (int& x : pos) cin >> x;
    sort(pos.begin(), pos.end());   // 按位置排序
    
    int lo = 0, hi = pos.back() - pos.front() + 1;
    
    while (lo + 1 < hi) {
        int mid = lo + (hi - lo) / 2;
        if (check(mid))
            lo = mid;   // 间距 mid 可行,尝试更大
        else
            hi = mid;
    }
    
    cout << lo << endl;
    return 0;
}

3.5b.6 浮点二分

当答案是实数时,用「循环足够多次」代替「两点相邻」:

// 浮点二分(精度 1e-7)
double lo = 0.0, hi = 1e9;
for (int iter = 0; iter < 100; iter++) {  // 100次足够精确到 1e-30
    double mid = (lo + hi) / 2.0;
    if (check(mid))
        lo = mid;
    else
        hi = mid;
}
// (lo + hi) / 2 即为答案,精度约 1e-30

也可以用 while (hi - lo > 1e-7) 作终止条件,但循环次数不固定(约 50 次)。

注意: 浮点比较需留意精度,check 中不要用 ==


3.5b.7 三分法(求单峰函数极值)

当需要求「单峰函数」的最大/最小值时,使用三分法

单峰函数: 存在一个极值点,左侧单调递增,右侧单调递减(或相反)。

f(x):  2 4 7 9 8 6 3
           ↑
          极值点(峰值 = 9)

三分法思路

在区间 [lo, hi] 中取两个三等分点 lmidrmid

  • f(lmid) < f(rmid):极值点在 lmid 右侧,lo = lmid
  • f(lmid) > f(rmid):极值点在 rmid 左侧,hi = rmid
📄 C++ 完整代码
// 三分法(浮点版,求单峰函数最大值点)
double ternary_search(double lo, double hi) {
    for (int iter = 0; iter < 200; iter++) {
        double m1 = lo + (hi - lo) / 3.0;
        double m2 = hi - (hi - lo) / 3.0;
        if (f(m1) < f(m2))
            lo = m1;
        else
            hi = m2;
    }
    return (lo + hi) / 2.0;
}

// 三分法(整数版,数组中的单峰)
int ternary_search_int(vector<int>& a) {
    int lo = 0, hi = a.size() - 1;
    while (hi - lo > 2) {
        int m1 = lo + (hi - lo) / 3;
        int m2 = hi - (hi - lo) / 3;
        if (a[m1] < a[m2])
            lo = m1;
        else
            hi = m2;
    }
    // 在 [lo, hi] 中线性查找最大值
    int best = lo;
    for (int i = lo + 1; i <= hi; i++)
        if (a[i] > a[best]) best = i;
    return best;
}

3.5b.8 二分答案 vs 二分查找

对比项二分查找(Binary Search)二分答案(Binary on Answer)
作用在有序数组中找特定值在答案空间中枚举猜测值
数据结构需要有序数组不需要数组,只需 check 函数
check数组下标的比较通常是 O(N) 或 O(N log N) 的验证
常见题型lower_bound, upper_bound最大化最小值、最小化最大值
总复杂度O(log N)O(log(答案范围) × check 复杂度)

3.5b.9 USACO 真题训练:把「最优值」改成「可行性判断」

二分答案的核心不是二分模板,而是把原问题改写成:

给定一个候选答案 x,我能否在 O(N)O(N log N) 时间内判断它是否可行?

识别框架

题面问法二分方向check(x) 通常做什么
最大化最小值找最大的可行 x贪心检查能否保持至少 x 的距离/容量
最小化最大值找最小的可行 x贪心检查能否把代价控制在 x 内
最小舞台/机器数量找最小的可行 x模拟 x 个资源是否足够
最短时间/最大等待找最小的可行 x按时间排序后贪心分组

真题 1:Convention(USACO 2018 December Silver)— 最小化最大等待时间

题目链接: USACO 2018 December Silver P1: Convention
对应模式: 二分答案 + 贪心装车
难度定位: Silver 标准

题干解读

N 头奶牛按不同时间到达机场,有 M 辆巴士,每辆最多坐 C 头。你需要安排每辆车的发车时间,使得所有奶牛中的最大等待时间最小。

关键条件:

  • 奶牛可以按到达时间排序。
  • 给定最大等待时间 x 后,可以贪心判断需要多少辆车。
  • x 越大,越容易安排;x 越小,越难安排,满足单调性。

思路分析

把问题改成判定:

如果允许每头奶牛最多等 x 分钟,能否用不超过 M 辆车送走所有奶牛?

check(x)

  1. 按到达时间排序。
  2. 当前车从第一头未安排奶牛开始。
  3. 只要车未满且当前奶牛等待时间 arrival[i] - firstArrival <= x,就放入当前车。
  4. 否则开新车。
  5. 最后判断用车数是否 <= M

CPP 完整代码

✅ 完整代码:Convention
#include <bits/stdc++.h>
using namespace std;

int main() {
    ios::sync_with_stdio(false);
    cin.tie(nullptr);

    // USACO 官方文件 I/O 可按需打开:
    // freopen("convention.in", "r", stdin);
    // freopen("convention.out", "w", stdout);

    int n, m, c;
    cin >> n >> m >> c;
    vector<int> arrival(n);
    for (int &x : arrival) cin >> x;
    sort(arrival.begin(), arrival.end());

    auto can = [&](int maxWait) {
        int buses = 1;
        int first = arrival[0];
        int count = 0;

        for (int time : arrival) {
            if (count == c || time - first > maxWait) {
                buses++;
                first = time;
                count = 0;
            }
            count++;
        }
        return buses <= m;
    };

    int lo = 0, hi = arrival.back() - arrival.front();
    while (lo < hi) {
        int mid = lo + (hi - lo) / 2;
        if (can(mid)) hi = mid;
        else lo = mid + 1;
    }

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

复杂度: 排序 O(N log N),每次 checkO(N),二分约 log V 次,总复杂度 O(N log N + N log V)

易错点提醒

  1. 忘记排序到达时间。 贪心装车只在时间有序时成立。
  2. 车满和等待超限两个条件缺一不可。 任意一个触发都要开新车。
  3. first 没有更新。 新车的等待基准必须是这辆车第一头奶牛的到达时间。
  4. 二分方向写反。 can(mid) 为真时说明答案可以更小,应移动 hi = mid

拓展思考

Convention 的 check 是「按顺序分组」模型。很多 Silver 题都类似:固定一个最大允许代价后,贪心地尽量把更多元素放进当前组,放不下再开新组。


真题 2:Cow Dance Show(USACO 2017 January Silver)— 最小舞台大小

题目链接: USACO 2017 January Silver P1: Cow Dance Show
对应模式: 二分答案 + 最小堆模拟
难度定位: Silver 进阶

题干解读

N 头奶牛按固定顺序上台,每头跳舞时间不同。舞台容量为 K,同时最多有 K 头奶牛在台上。当前有奶牛跳完后,下一头立刻上台。给定总时间上限 Tmax,求最小的 K

关键条件:

  • K 越大,总演出时间越短或不变,存在单调性。
  • 给定 K,可以用最小堆模拟每头牛结束时间。

思路分析

check(K)

  1. K 头牛先上台,结束时间就是自己的持续时间。
  2. 每次取最早结束的时间 t
  3. 下一头牛在 t 时刻上台,结束时间为 t + duration[i]
  4. 所有牛处理完后,堆中最大结束时间就是总演出时间。

若总时间 <= Tmax,说明容量 K 可行。

CPP 完整代码

✅ 完整代码:Cow Dance Show
#include <bits/stdc++.h>
using namespace std;

int main() {
    ios::sync_with_stdio(false);
    cin.tie(nullptr);

    // USACO 官方文件 I/O 可按需打开:
    // freopen("cowdance.in", "r", stdin);
    // freopen("cowdance.out", "w", stdout);

    int n, tMax;
    cin >> n >> tMax;
    vector<int> duration(n);
    for (int &x : duration) cin >> x;

    auto can = [&](int stageSize) {
        priority_queue<int, vector<int>, greater<int>> finishTime;

        for (int i = 0; i < n; i++) {
            if ((int)finishTime.size() == stageSize) {
                int earliest = finishTime.top();
                finishTime.pop();
                finishTime.push(earliest + duration[i]);
            } else {
                finishTime.push(duration[i]);
            }
        }

        int totalTime = 0;
        while (!finishTime.empty()) {
            totalTime = max(totalTime, finishTime.top());
            finishTime.pop();
        }
        return totalTime <= tMax;
    };

    int lo = 1, hi = n;
    while (lo < hi) {
        int mid = lo + (hi - lo) / 2;
        if (can(mid)) hi = mid;
        else lo = mid + 1;
    }

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

复杂度: 每次 check(K)O(N log K),二分 O(log N) 次,总复杂度 O(N log N log N),足以通过 Silver 数据。

易错点提醒

  1. 把舞台当成普通队列。 应该总是让最早结束的牛离开,所以要用最小堆。
  2. 二分边界从 0 开始。 舞台大小至少为 1。
  3. 只看最后弹出的值。 堆中结束时间不一定按插入顺序,稳妥做法是取所有结束时间最大值。
  4. check 中修改原数组。 模拟只应使用堆,不要改变 duration

拓展思考

如果舞台容量固定,要求最短总时间,这就是多机调度模拟;如果牛可以任意重排,问题会变成另一类调度优化,不再是本题的固定顺序模型。


⚠️ 常见错误

错误原因修复方案
死循环while(lo < hi) + lo = mid,当 lo+1==himid = lo 死循环改用 while(lo + 1 < hi)
答案差 1hi 设置为 max_val 而非 max_val + 1hi = 答案上界 + 1(左闭右开)
check 溢出check 中累加超过 int 范围使用 long long
浮点精度不足迭代次数太少设为 100 次,不必再少
三分法用于非单峰函数只有单峰函数才能三分先确认函数的单峰性质

💪 练习题(共 8 道,全部含完整解答)

🟢 基础练习(1~3)

题目 1:切绳子(浮点二分)
N 根绳子,长度已知,要切出 K 根相同长度的绳子,求最大长度(精确到 0.01m)。

示例:

N=4, K=11
绳子长度:8.02 7.43 4.57 5.39
答案:2.00
✅ 完整解答
#include <bits/stdc++.h>
using namespace std;

int n, k;
vector<double> a;

bool check(double len) {
    int total = 0;
    for (double x : a) total += (int)(x / len);
    return total >= k;
}

int main() {
    cin >> n >> k;
    a.resize(n);
    for (double& x : a) cin >> x;
    
    double lo = 0, hi = *max_element(a.begin(), a.end());
    for (int iter = 0; iter < 100; iter++) {
        double mid = (lo + hi) / 2.0;
        if (check(mid)) lo = mid;
        else hi = mid;
    }
    
    // 保留两位小数
    printf("%.2f\n", lo);
    return 0;
}

关键: 浮点二分用「循环固定次数(100次)」而非「精度判断」,避免精度问题导致死循环。100 次后误差约 (hi-lo)/2^100,远小于题目要求。


题目 2:数组分组(最大值最小化)
将长度 N 的数组分为恰好 K 组连续子数组,使各组元素之和的最大值最小,输出该最小最大值。

示例:

N=5, K=3, 数组=[1,2,3,4,5]
最优分法:[1,2],[3],[4,5] → 最大组和 = max(3,3,9)=9?
不对,试 [1,2,3],[4],[5] → max(6,4,5)=6 ✓
输出:6
✅ 完整解答
#include <bits/stdc++.h>
using namespace std;

int n, k;
vector<long long> a;

bool check(long long maxSum) {
    for (long long x : a) if (x > maxSum) return false;  // 单元素超限
    int groups = 1; long long cur = 0;
    for (long long x : a) {
        if (cur + x > maxSum) { groups++; cur = x; }
        else cur += x;
    }
    return groups <= k;
}

int main() {
    cin >> n >> k;
    a.resize(n);
    long long total = 0;
    for (long long& x : a) { cin >> x; total += x; }
    
    long long lo = *max_element(a.begin(), a.end()) - 1;
    long long hi = total + 1;
    while (lo + 1 < hi) {
        long long mid = lo + (hi - lo) / 2;
        if (check(mid)) hi = mid;
        else lo = mid;
    }
    cout << hi << "\n";
    return 0;
}

追踪(a=[1,2,3,4,5], k=3):

lo=4(max元素-1), hi=16(总和+1)
mid=10 → check: [1..5]=15>10→[1,2,3,4]=10✓,[5] → 2组≤3 ✓ → hi=10
mid= 7 → check: [1,2,3]=6,[4]=4,[5]=5 → 3组≤3 ✓ → hi=7
mid= 5 → check: [1,2]=3,[3]=3,[4]=4→加5=9>5→新组[5] → 4组>3 ✗ → lo=5
mid= 6 → check: [1,2,3]=6,[4]=4,[5]=5 → 3组≤3 ✓ → hi=6

lo+1=6=hi,输出 hi=6 ✓

题目 3:查找有序矩阵第 K 小元素
N×N 的矩阵,每行每列均递增。找第 K 小的元素。

提示: 对答案二分,check(x) = 矩阵中 ≤ x 的元素个数 ≥ K。

✅ 完整解答
#include <bits/stdc++.h>
using namespace std;

int n, k;
vector<vector<int>> mat;

// 统计矩阵中 <= x 的元素个数
// 利用行有序性:每行二分
int count_le(int x) {
    int cnt = 0;
    for (auto& row : mat) {
        // upper_bound 返回第一个 > x 的迭代器
        cnt += (int)(upper_bound(row.begin(), row.end(), x) - row.begin());
    }
    return cnt;
}

int main() {
    cin >> n >> k;
    mat.assign(n, vector<int>(n));
    for (auto& row : mat) for (int& x : row) cin >> x;
    
    int lo = mat[0][0] - 1;
    int hi = mat[n-1][n-1];
    
    while (lo + 1 < hi) {
        int mid = lo + (hi - lo) / 2;
        if (count_le(mid) >= k) hi = mid;  // mid 可能是答案,尝试更小
        else lo = mid;
    }
    
    cout << hi << "\n";  // 最小的使 count_le >= k 的值
    return 0;
}

复杂度: O(N log N log(max-min)),N=300, max=10^9 时约 300×8×30 ≈ 72000 次操作,极快。


🟡 进阶练习(4~6)

题目 4:安置奶牛(最大化最小值)
N 个牛栏分布在数轴上(坐标已知),放 C 头奶牛,使任意两头奶牛之间的距离尽可能大
求最大的「最小相邻距离」。

✅ 完整解答
#include <bits/stdc++.h>
using namespace std;

int n, c;
vector<int> pos;

bool check(int D) {
    int cows = 1, last = pos[0];
    for (int i = 1; i < n; i++) {
        if (pos[i] - last >= D) { cows++; last = pos[i]; }
        if (cows >= c) return true;
    }
    return cows >= c;
}

int main() {
    cin >> n >> c;
    pos.resize(n);
    for (int& x : pos) cin >> x;
    sort(pos.begin(), pos.end());
    
    int lo = 0, hi = pos.back() - pos.front() + 1;
    while (lo + 1 < hi) {
        int mid = lo + (hi - lo) / 2;
        if (check(mid)) lo = mid;
        else hi = mid;
    }
    cout << lo << "\n";
    return 0;
}

追踪(pos=[1,2,4,8,9], c=3):

排序后:[1,2,4,8,9],lo=0,hi=9

mid=4:check(4):1放1,下一个≥1+4=5→放8,下一个≥8+4=12>9→只放2头 < 3 ✗ → lo=4
mid=6:check(6):放1,≥7→放8,≥14>9→2头 < 3 ✗ → lo=6(⚠️等等)

重新算:
mid=4:1(place)→2(dist=1<4跳)→4(dist=3<4跳)→8(dist=4✓,place)→9(dist=1<4跳) → 2头 < 3 ✗ → lo=4?

不对,重新算 c=3:
mid=3:1(place)→2(1<3)→4(3✓,place)→8(4✓,place) → 3头 ≥ 3 ✓ → lo=3
mid=4:1(place)→4(3<4)→8(4✓,place)→9(1<4) → 2头 < 3 ✗ → hi=4

lo+1=4=hi,答案 lo=3

题目 5:跳石头(NOIP 2015)
河中有 N 块石头,起点到终点距离 L。可以移除最多 M 块石头,使最短跳跃距离最大。
求这个最大的「最短跳跃距离」。

✅ 完整解答
#include <bits/stdc++.h>
using namespace std;

int l, n, m;
vector<int> pos;  // 包含起点0和终点l

bool check(int minDist) {
    // 统计需要移除多少块石头,使所有相邻距离 >= minDist
    int removed = 0, prev = 0;
    for (int i = 1; i < (int)pos.size(); i++) {
        if (pos[i] - prev < minDist) removed++;  // 这块石头太近,移除
        else prev = pos[i];
    }
    return removed <= m;
}

int main() {
    cin >> l >> n >> m;
    pos.push_back(0);  // 起点
    for (int i = 0; i < n; i++) {
        int x; cin >> x;
        pos.push_back(x);
    }
    pos.push_back(l);  // 终点
    sort(pos.begin(), pos.end());
    
    int lo = 0, hi = l + 1;
    while (lo + 1 < hi) {
        int mid = lo + (hi - lo) / 2;
        if (check(mid)) lo = mid;
        else hi = mid;
    }
    cout << lo << "\n";
    return 0;
}

关键: check(D) 采用贪心——从左到右扫描,遇到距离 < D 的石头就移除(因为移除它一定不差于移除其他石头)。统计移除数量是否 ≤ M。


题目 6:最优比率(分数规划)
给定 N 个任务,每个任务有完成时间 t[i] 和价值 v[i]
选恰好 K 个任务,最大化 总价值 / 总时间。输出最大比率(精度 1e-6)。

✅ 完整解答

核心转化(0-1 分数规划):
二分比率 λ,检查是否存在选 K 个任务使得 Σv[i] / Σt[i] ≥ λ
等价于 Σ(v[i] - λ*t[i]) ≥ 0
w[i] = v[i] - λ*t[i] 取最大的 K 个,若其和 ≥ 0,则 λ 可行。

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

int n, k;
vector<double> t_arr, v_arr;

bool check(double lam) {
    // 计算每个任务的 "净收益" w[i] = v[i] - lam * t[i]
    vector<double> w(n);
    for (int i = 0; i < n; i++) w[i] = v_arr[i] - lam * t_arr[i];
    
    // 取最大的 k 个净收益之和
    sort(w.begin(), w.end(), greater<double>());
    double sum = 0;
    for (int i = 0; i < k; i++) sum += w[i];
    
    return sum >= 0;  // 能否达到比率 lam?
}

int main() {
    cin >> n >> k;
    t_arr.resize(n); v_arr.resize(n);
    for (int i = 0; i < n; i++) cin >> t_arr[i] >> v_arr[i];
    
    double lo = 0, hi = 1e6;  // 比率的上下界
    for (int iter = 0; iter < 100; iter++) {
        double mid = (lo + hi) / 2.0;
        if (check(mid)) lo = mid;
        else hi = mid;
    }
    
    printf("%.6f\n", lo);
    return 0;
}

为什么这样转化正确?
若最优比率为 λ*,则对任意 λ < λ*,都能选到 K 个任务满足 Σ(v[i]-λt[i]) ≥ 0
对任意 λ > λ*,不可能满足。这正是「单调性」的体现。


🔴 挑战练习(7~8)

题目 7:最小化最大花费(二分 + 图)
N 个城市,M 条带权无向边。需要选一些边,连通所有城市,使最大边权最小。
(即最小生成树的最大边权最小化。)

✅ 完整解答

二分视角: 二分「允许的最大边权」= W,check(W) = 只用权重 ≤ W 的边,是否能连通所有城市。
等价于:Kruskal 算法跑到权重 W 时,是否已经形成生成树。

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

struct DSU {
    vector<int> pa, sz;
    explicit DSU(int n) : pa(n + 1), sz(n + 1, 1) { iota(pa.begin(), pa.end(), 0); }
    int find(int x) { return pa[x] == x ? x : pa[x] = find(pa[x]); }
    bool unite(int x, int y) {
        x = find(x); y = find(y);
        if (x == y) return false;
        if (sz[x] < sz[y]) swap(x, y);
        pa[y] = x; sz[x] += sz[y];
        return true;
    }
};

int n, m;
vector<tuple<int,int,int>> edges;  // {w, u, v}

bool check(int maxW) {
    DSU dsu(n);
    int cnt = 0;
    for (auto& [w, u, v] : edges) {
        if (w > maxW) break;
        if (dsu.unite(u, v)) cnt++;
    }
    return cnt == n - 1;
}

int main() {
    cin >> n >> m;
    for (int i = 0; i < m; i++) {
        int u, v, w; cin >> u >> v >> w;
        edges.push_back({w, u, v});
    }
    sort(edges.begin(), edges.end());
    
    int lo = 0, hi = get<0>(edges.back()) + 1;
    while (lo + 1 < hi) {
        int mid = lo + (hi - lo) / 2;
        if (check(mid)) hi = mid;
        else lo = mid;
    }
    
    cout << (check(hi) ? hi : -1) << "\n";  // -1 表示无法连通
    return 0;
}

注: 实际上这等价于直接跑 Kruskal 的最大边权,无需二分——但本题作为二分思维的练习,两种做法均正确。


题目 8:最小化等待时间(二分 + 贪心)
N 个顾客到达超市,每人需要服务时间 s[i],超市有 K 个服务台。
每个服务台每次服务一人,服务完后立即接待下一人。
合理安排顾客顺序,使所有顾客等待时间之和最小

提示: 先证明「等待时间之和最小」等价于「将服务时间最短的顾客优先」,然后贪心排序直接求答案(无需二分)。
挑战版:若每个顾客有到达时刻限制 a[i],需要对答案二分「最大等待时间」。

✅ 完整解答(贪心版)

证明: 若服务台同时开始,将顾客按服务时间升序排列,使等待时间之和最小(SPT规则)。

K 个服务台版本(贪心 + 优先队列):
维护 K 个服务台的当前空闲时刻,每次将下一位顾客分配到最早空闲的服务台。

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

int main() {
    int n, k;
    cin >> n >> k;
    vector<int> s(n);
    for (int& x : s) cin >> x;
    sort(s.begin(), s.end());  // 按服务时间升序(SPT 规则)
    
    // K 个服务台的空闲时刻(最小堆)
    priority_queue<long long, vector<long long>, greater<long long>> free_time;
    for (int i = 0; i < k; i++) free_time.push(0);
    
    long long total_wait = 0;
    for (int i = 0; i < n; i++) {
        long long earliest = free_time.top(); free_time.pop();
        total_wait += earliest;  // 顾客 i 的等待时间 = 服务台空闲时刻
        free_time.push(earliest + s[i]);
    }
    
    cout << total_wait << "\n";
    return 0;
}

示例(n=4, k=2, s=[3,1,4,2]):

排序后 s = [1,2,3,4]
服务台:[0,0](初始空闲时刻)

顾客0(s=1):分配到台0,台0变为0+1=1,wait=0
服务台:[0,1]
顾客1(s=2):分配到台0(最早空闲),台0变为0+2=2,wait=0
服务台:[1,2]
顾客2(s=3):分配到台0(空闲时=1),wait=1,台变为1+3=4
服务台:[2,4]
顾客3(s=4):分配到台0(空闲时=2),wait=2,台变为2+4=6

总等待时间 = 0+0+1+2 = 3

💡 章节联系: 二分答案是竞赛中最实用的思想之一,贯穿 USACO Bronze→Gold 的所有级别。它通常与贪心(check 函数的核心工具)和前缀和(快速验证)结合使用,是「思想轻巧、代码简洁」的典范。

📖 第 3.10 章:字符串算法

⏱ 预计阅读时间:60 分钟 | 难度:🟡 中等


前置条件

在学习本章之前,请确保你已掌握:

  • 数组的基本操作(第 3.1 章)
  • string 类型的使用(第 2.3 章)

🎯 学习目标

学完本章后,你将能够:

  1. 理解字符串匹配的朴素算法并分析其效率瓶颈
  2. 掌握 KMP 算法的核心思想——「前缀函数」的构建
  3. 用 KMP 在 O(N + M) 时间内解决字符串匹配问题
  4. 理解 Trie 树(字典树)的结构与操作
  5. 运用 Trie 树高效检索字符串集合

3.10.1 为什么需要专门的字符串算法?

问题引入

给定一段文本(长度 N)和一个模式串(长度 M),请找出模式串在文本中所有出现的位置。

文本:  "ababcabcabababd"
模式:  "abab"
结果:  位置 0, 8, 10

这是字符串匹配问题,也是竞赛中最常见的字符串题型之一。

朴素算法的问题

最直觉的做法——枚举文本的每个起始位置,逐字符比较:

📄 最直觉的做法——枚举文本的每个起始位置,逐字符比较:
// 朴素字符串匹配 — O(N * M)
vector<int> naive_search(string text, string pattern) {
    int n = text.size(), m = pattern.size();
    vector<int> result;
    for (int i = 0; i <= n - m; i++) {
        bool match = true;
        for (int j = 0; j < m; j++) {
            if (text[i + j] != pattern[j]) {
                match = false;
                break;
            }
        }
        if (match) result.push_back(i);
    }
    return result;
}

最坏情况举例:

文本:  "aaaaaa...a"(N 个 a)
模式:  "aaa...ab"(M-1 个 a + 1 个 b)
每次匹配都要比较 M 次才发现不匹配
总操作数:N * M

当 N = M = 10^5 时,这就是 10^10 次操作,根本无法通过竞赛题目。

KMP 的核心洞察

朴素算法的浪费在于:每次失配后,从头重新开始匹配,丢弃了已经比较过的信息

KMP 的思想是:失配时,利用已经匹配的部分跳回到合适位置,而不是从头开始。


3.10.2 前缀函数(π 数组)

KMP 的核心是「前缀函数」,也叫「next 数组」或「失配函数」。

定义

对于字符串 s,前缀函数 π[i] 定义为:

子串 s[0..i] 中,最长的相等真前缀与真后缀的长度

「真前缀」:不包含整个字符串的前缀。
「真后缀」:不包含整个字符串的后缀。

直觉理解

字符串:  a  b  c  a  b  c  d
下标:    0  1  2  3  4  5  6
π 值:    0  0  0  1  2  3  0

逐个分析:

  • π[0] = 0:单个字符 "a",无真前后缀,规定为 0
  • π[1] = 0:字符串 "ab",前缀 {a},后缀 {b},没有相等的,π = 0
  • π[2] = 0:字符串 "abc",前缀 {a, ab},后缀 {c, bc},没有相等的,π = 0
  • π[3] = 1:字符串 "abca",前缀 {a, ab, abc},后缀 {a, ca, bca},相等的最长为 "a",π = 1
  • π[4] = 2:字符串 "abcab",最长相等真前后缀为 "ab",π = 2
  • π[5] = 3:字符串 "abcabc",最长相等真前后缀为 "abc",π = 3
  • π[6] = 0:字符串 "abcabcd",末尾 d 打破了规律,π = 0

π 值的用途

π[i] = k 意味着:如果当前字符(位置 i+1)不匹配,
我们可以安全地退回到模式串的第 k 个位置继续尝试,
因为前 k 个字符肯定已经匹配了(它们是当前已匹配部分的前缀 = 后缀)。


3.10.3 前缀函数的高效构建

朴素方法:O(N²) 或 O(N³)

对每个位置枚举所有可能的长度,逐字符比较,效率很低。

KMP 的关键观察

观察 1:相邻的 π 值最多增加 1。
π[i+1] ≤ π[i] + 1

为什么?若 π[i+1] > π[i] + 1,说明存在更长的相等前后缀,那 π[i] 就不是最大的了,矛盾。

观察 2:失配时如何跳转?

s[π[i]] == s[i+1],则 π[i+1] = π[i] + 1(前缀直接延长)。
若不等,利用 π[π[i]-1] 跳转(尝试更短的前缀),直到找到匹配或退到 0。

图解失配跳转

模式串:  a b c a b d
           ↑     ↑
           已匹配到位置4("abcab"),π[4]=2
           现在第5个字符 d ≠ c(s[2])
           
           跳转:j = π[4] = 2,尝试 s[2]='c' vs 'd' → 还是不匹配
           跳转:j = π[1] = 0,尝试 s[0]='a' vs 'd' → 还是不匹配
           j = 0 且失配,π[5] = 0

最终算法实现

📄 查看代码:最终算法实现
#include <bits/stdc++.h>
using namespace std;

// 构建前缀函数(KMP 的核心)
// 时间复杂度:O(N)
vector<int> prefix_function(const string& s) {
    int n = (int)s.length();
    vector<int> pi(n, 0);   // pi[0] = 0 是规定
    
    for (int i = 1; i < n; i++) {
        // j 从上一个 π 值开始尝试
        int j = pi[i - 1];
        
        // 失配时,利用已算出的 π 值回退(关键步骤!)
        while (j > 0 && s[i] != s[j])
            j = pi[j - 1];     // 跳到下一个候选长度
        
        // 匹配则长度加一
        if (s[i] == s[j])
            j++;
        
        pi[i] = j;
    }
    return pi;
}

为什么时间复杂度是 O(N)?

关键在于 j 的变化:

  • j 每次最多加 1(通过 j++
  • j 每次至少减 1(通过 j = pi[j-1]
  • j 的总增加量 ≤ N,所以总减少量也 ≤ N
  • while 循环的总执行次数 ≤ N

整个循环的总操作数是 O(N)!


3.10.4 KMP 字符串匹配

核心技巧:拼接字符串

pattern + '#' + text 拼在一起,计算整体的前缀函数:

pattern = "abab",长度 4
text    = "ababcababd",长度 9

拼接后:  a b a b # a b a b c a b a b d
下标:    0 1 2 3 4 5 6 7 8 9 0 1 2 3 4
                              ↑ 1 ↑ 1 ↑ 1
π 值:    0 0 1 2 0 1 2 3 4 0 1 2 3 4 0
                              ↑         ↑
         当 π[i] = 4(= len(pattern)),说明找到匹配!

分隔符 # 的作用是防止模式串的后缀与文本的前缀在拼接处意外匹配。

完整实现

📄 查看代码:完整实现
// KMP 字符串匹配
// 返回 pattern 在 text 中所有匹配的起始下标(0-indexed)
// 时间复杂度:O(N + M),N = len(text),M = len(pattern)
vector<int> kmp_search(const string& text, const string& pattern) {
    if (pattern.empty()) return {};
    
    // 第一步:拼接,用不在字符集中的分隔符
    string combined = pattern + '#' + text;
    int m = pattern.size(), n = text.size();
    
    // 第二步:计算整体前缀函数
    vector<int> pi = prefix_function(combined);
    
    // 第三步:收集匹配位置
    vector<int> result;
    for (int i = m + 1; i <= m + n; i++) {
        if (pi[i] == m) {
            // i 在拼接串中的位置,换算回 text 中的起始位置
            result.push_back(i - 2 * m);
        }
    }
    return result;
}

int main() {
    string text    = "ababcabcabababd";
    string pattern = "abab";
    
    auto pos = kmp_search(text, pattern);
    cout << "模式串 \"" << pattern << "\" 出现在位置:";
    for (int p : pos) cout << p << " ";
    // 输出:0 8 10
    cout << endl;
    
    // 验证
    for (int p : pos) {
        cout << "  text[" << p << ".." << p+pattern.size()-1 << "] = "
             << text.substr(p, pattern.size()) << endl;
    }
    return 0;
}

详细追踪示例

📄 查看代码:详细追踪示例
text = "ababcabab", pattern = "abab"(长度4)

拼接串:  a b a b # a b a b c a b a b
π 值:    0 0 1 2 0 1 2 3 4 0 1 2 3 4
                         ↑           ↑
                       i=8          i=13
                    π[8]=4=m ✓    π[13]=4=m ✓

匹配位置(text 中 0-indexed):
  i=8:  8 - 2*4 = 0  → text[0..3] = "abab" ✓
  i=13: 13 - 2*4 = 5 → text[5..8] = "abab" ✓

3.10.5 KMP 的进阶应用

应用 1:求字符串的最短周期

📄 查看代码:应用 1:求字符串的最短周期
// 若字符串 s 能被前 k 个字符重复构成,则 k 是周期
// 最短周期 = n - π[n-1]
// 条件:n 能被 (n - π[n-1]) 整除

int min_period(const string& s) {
    int n = s.size();
    auto pi = prefix_function(s);
    int period = n - pi[n - 1];
    if (n % period == 0) return period;
    return n;  // 无法压缩,周期为 n
}

// 示例:
// "abcabc" → π[5]=3,period = 6-3 = 3,6%3=0 ✓,最短周期 3
// "abcab"  → π[4]=2,period = 5-2 = 3,5%3≠0,最短周期 5(整串)

应用 2:统计每个前缀的出现次数

📄 查看代码:应用 2:统计每个前缀的出现次数
// 统计模式串的每个前缀在整个串中出现的次数
vector<int> count_prefixes(const string& s) {
    int n = s.size();
    auto pi = prefix_function(s);
    vector<int> cnt(n + 1, 0);
    
    for (int i = 0; i < n; i++) cnt[pi[i]]++;    // 每个位置贡献
    for (int i = n - 1; i > 0; i--) cnt[pi[i-1]] += cnt[i]; // 传播
    for (int i = 0; i <= n; i++) cnt[i]++;        // 加上自身
    
    return cnt;  // cnt[k] = 长度为 k 的前缀在 s 中出现的次数
}

算法对比

算法时间复杂度空间复杂度适用场景
朴素匹配O(N × M)O(1)小数据,代码简单
KMPO(N + M)O(M)单模式匹配(推荐)
Rabin-Karp(字符串哈希)O(N + M) 平均O(1)多模式或哈希方便的场景
AC 自动机O(N + ΣM)O(ΣM)多模式同时匹配

3.10.6 Trie 树(字典树)

什么是 Trie?

Trie(发音:try)是一种树形数据结构,用来存储和查询字符串集合。

核心思想:用从根节点到某节点的路径来表示一个字符串。每条边对应一个字符,相同前缀的字符串共享路径。

可视化

存入字符串集合 {"cat", "car", "bat", "bar", "can"}:

📄 存入字符串集合 {"cat", "car", "bat", "bar", "can"}:
        根节点
       /      \
      c         b
     / \       / \
    a   ?     a   ?
   /|\        |
  t r n       r  t("bat"和"bar"共享"ba"前缀)
             / \
            ★   ★
         "bar" "bat"

※ ★ 表示在该节点结尾的字符串存在("end" 标记)

同一前缀(如 "ca")只存储一次,节省空间。

Trie 的数组实现

📄 查看代码:Trie 的数组实现
#include <bits/stdc++.h>
using namespace std;

// Trie 树(数组版,只支持小写字母)
const int MAXN = 500005;  // 最大节点数 = 总字符数

struct Trie {
    int ch[MAXN][26];  // ch[u][c] = 节点 u 的第 c 个子节点编号
    bool exist[MAXN];  // exist[u] = 以节点 u 结尾的字符串是否存在
    int cnt;           // 节点计数器(根节点为 0)
    
    Trie() : cnt(0) {
        memset(ch[0], 0, sizeof(ch[0]));  // 初始化根节点
        exist[0] = false;
    }
    
    // 插入字符串
    // 时间复杂度:O(|s|)
    void insert(const string& s) {
        int p = 0;  // 从根节点开始
        for (char c : s) {
            int ci = c - 'a';
            if (!ch[p][ci]) {
                ch[p][ci] = ++cnt;           // 新建节点
                memset(ch[cnt], 0, sizeof(ch[cnt]));
                exist[cnt] = false;
            }
            p = ch[p][ci];                    // 移动到下一个节点
        }
        exist[p] = true;  // 标记字符串末尾
    }
    
    // 查询字符串是否存在
    // 时间复杂度:O(|s|)
    bool find(const string& s) {
        int p = 0;  // 从根节点开始
        for (char c : s) {
            int ci = c - 'a';
            if (!ch[p][ci]) return false;  // 路径断了,不存在
            p = ch[p][ci];
        }
        return exist[p];  // 必须到达结尾标记的节点
    }
    
    // 查询是否有以 prefix 为前缀的字符串
    bool startsWith(const string& prefix) {
        int p = 0;
        for (char c : prefix) {
            int ci = c - 'a';
            if (!ch[p][ci]) return false;
            p = ch[p][ci];
        }
        return true;  // 路径存在即有此前缀(不需要 exist 标记)
    }
};

int main() {
    Trie trie;
    
    // 插入一些单词
    trie.insert("apple");
    trie.insert("app");
    trie.insert("apply");
    trie.insert("banana");
    
    // 查询
    cout << trie.find("apple")       << endl;  // 1(存在)
    cout << trie.find("app")         << endl;  // 1(存在)
    cout << trie.find("ap")          << endl;  // 0(路径存在,但没有标记)
    cout << trie.startsWith("ap")    << endl;  // 1(有以 "ap" 开头的字符串)
    cout << trie.startsWith("ban")   << endl;  // 1
    cout << trie.startsWith("cat")   << endl;  // 0
    
    return 0;
}

详细追踪:插入 "app" 和 "apple"

📄 查看代码:详细追踪:插入 "app" 和 "apple"
初始:只有根节点 0

插入 "app":
  节点0 --'a'--> 节点1
  节点1 --'p'--> 节点2
  节点2 --'p'--> 节点3,exist[3] = true("app" 结束)

插入 "apple":
  节点0 --'a'--> 节点1(已存在,复用)
  节点1 --'p'--> 节点2(已存在,复用)
  节点2 --'p'--> 节点3(已存在,复用)
  节点3 --'l'--> 节点4(新建)
  节点4 --'e'--> 节点5,exist[5] = true("apple" 结束)

现在 "app" 和 "apple" 共享前缀 "app"

3.10.7 Trie 树的经典应用

应用 1:单词频率统计(带计数的 Trie)

📄 查看代码:应用 1:单词频率统计(带计数的 Trie)
// 增加 count 数组记录每个节点被经过的次数
// count[u] = 以 u 为前缀的字符串的总插入次数

int count_prefix[MAXN];  // count[u] = 经过节点 u 的次数

void insert_with_count(const string& s) {
    int p = 0;
    for (char c : s) {
        int ci = c - 'a';
        if (!ch[p][ci]) {
            ch[p][ci] = ++cnt;
            count_prefix[cnt] = 0;
        }
        p = ch[p][ci];
        count_prefix[p]++;  // 每经过一个节点,计数 +1
    }
    exist[p] = true;
}

// 查询有多少个已插入字符串以 prefix 为前缀
int count_starts_with(const string& prefix) {
    int p = 0;
    for (char c : prefix) {
        int ci = c - 'a';
        if (!ch[p][ci]) return 0;
        p = ch[p][ci];
    }
    return count_prefix[p];
}

应用 2:01-Trie 求最大异或值

当数字以二进制形式插入 Trie 时,可以高效求「与某数异或值最大的数」。

原理:从最高位开始,贪心地走与当前位不同的那条路(使该位异或为 1)。

📄 C++ 完整代码
// 01-Trie:维护整数集合,支持查询"与 x 异或最大的数"
// 按二进制位(从第30位到第0位)建树
const int BIT = 30;
const int MAXN_01 = 3000005;

int ch01[MAXN_01][2];  // 只有 0/1 两个子节点
int cnt01 = 0;

// 将整数 x 插入 01-Trie
void insert01(int x) {
    int p = 0;
    for (int i = BIT; i >= 0; i--) {
        int bit = (x >> i) & 1;       // 取第 i 位
        if (!ch01[p][bit]) ch01[p][bit] = ++cnt01;
        p = ch01[p][bit];
    }
}

// 查询与 x 异或值最大的数,返回最大异或值
int max_xor(int x) {
    int p = 0, res = 0;
    for (int i = BIT; i >= 0; i--) {
        int bit = (x >> i) & 1;
        int want = 1 - bit;            // 想要走与 bit 不同的方向(使异或为1)
        if (ch01[p][want]) {
            p = ch01[p][want];
            res |= (1 << i);           // 这一位异或为 1
        } else {
            p = ch01[p][bit];          // 没有理想方向,只能走 bit 方向
        }
    }
    return res;
}

// 示例:
// 集合 {3, 7, 9, 12},查询与 6(二进制 110)异或最大
// 6 XOR 9 = 15(1111),是最大值
int main() {
    int arr[] = {3, 7, 9, 12};
    for (int x : arr) insert01(x);
    cout << max_xor(6) << endl;  // 输出 15
    return 0;
}

追踪 max_xor(6) 的过程(6 = 0...0110):

第30~4位:全 0,Trie 中无这些位,跳过
第3位(bit=0):want=1,ch[p][1] 存在(9=1001 的第3位=1)→ 走1,res |= 8
第2位(bit=1):want=0,ch[p][0] 存在(9=1001 的第2位=0)→ 走0,res |= 4
第1位(bit=1):want=0,ch[p][0] 存在(9=1001 的第1位=0)→ 走0,res |= 2
第0位(bit=0):want=1,ch[p][1] 存在(9=1001 的第0位=1)→ 走1,res |= 1

最终 res = 8+4+2+1 = 15
对应的数是 9(9 XOR 6 = 15 ✓)

⚠️ 常见错误

错误原因修复方案
KMP 匹配位置计算错误i - 2*m 忘记两个 m(模式串 + 分隔符)画图确认拼接串的下标
π[0] 不初始化为 0忘记规定vector<int> pi(n, 0)
Trie 节点数组太小最大节点数 = 所有字符串的总字符数MAXN = 所有字符串长度之和 + 1
忘记初始化新建节点的子指针ch[cnt] 未清零新建节点时 memset(ch[cnt], 0, sizeof(ch[cnt]))
01-Trie 位数设置错误BIT 设小了导致数据丢失根据题目数值范围设置,整数通常 BIT = 30

💪 练习题(共 8 道,全部含完整解答)

🟢 基础练习(1~3)

题目 1:实现 strStr
在字符串 haystack 中找到 needle 第一次出现的起始位置(若不存在返回 -1)。
要求使用 KMP 实现,时间复杂度 O(N+M)。

示例:

haystack = "hello", needle = "ll"  → 2
haystack = "aaaaa", needle = "bba" → -1
✅ 完整解答

思路: 拼接 needle + '#' + haystack,计算前缀函数,找 π[i] == len(needle) 的位置。

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

vector<int> prefix_function(const string& s) {
    int n = s.size();
    vector<int> pi(n, 0);
    for (int i = 1; i < n; i++) {
        int j = pi[i - 1];
        while (j > 0 && s[i] != s[j]) j = pi[j - 1];
        if (s[i] == s[j]) j++;
        pi[i] = j;
    }
    return pi;
}

int strStr(string haystack, string needle) {
    if (needle.empty()) return 0;
    int m = needle.size(), n = haystack.size();
    auto pi = prefix_function(needle + '#' + haystack);
    for (int i = m + 1; i <= m + n; i++)
        if (pi[i] == m) return i - 2 * m;
    return -1;
}

int main() {
    cout << strStr("hello",  "ll")  << "\n";  // 2
    cout << strStr("aaaaa",  "bba") << "\n";  // -1
    cout << strStr("sadbutsad", "sad") << "\n";  // 0(第一次出现)
    return 0;
}

追踪(haystack="hello", needle="ll"):

拼接串:  l l # h e l l o
π 值:    0 1 0 0 0 1 2 0
                        ↑ i=6,π[6]=2=len("ll") ✓
位置 = 6 - 2×2 = 2

题目 2:单词检索
给定 N 个单词,Q 次查询,每次询问某单词是否在词典中。要求插入和查询都用 Trie,总复杂度 O(总字符数)。

✅ 完整解答
#include <bits/stdc++.h>
using namespace std;

const int MAXN = 1000005;
int ch[MAXN][26];
bool exist[MAXN];
int tot = 0;

void insert(const string& s) {
    int p = 0;
    for (char c : s) {
        int ci = c - 'a';
        if (!ch[p][ci]) {
            ch[p][ci] = ++tot;
            fill(ch[tot], ch[tot] + 26, 0);
            exist[tot] = false;
        }
        p = ch[p][ci];
    }
    exist[p] = true;
}

bool query(const string& s) {
    int p = 0;
    for (char c : s) {
        int ci = c - 'a';
        if (!ch[p][ci]) return false;
        p = ch[p][ci];
    }
    return exist[p];
}

int main() {
    ios_base::sync_with_stdio(false); cin.tie(NULL);
    int n, q;
    cin >> n;
    for (int i = 0; i < n; i++) {
        string w; cin >> w;
        insert(w);
    }
    cin >> q;
    while (q--) {
        string w; cin >> w;
        cout << (query(w) ? "YES" : "NO") << "\n";
    }
    return 0;
}

题目 3:最长公共前缀
给定 N 个字符串,求它们的最长公共前缀(LCP)。

示例:

输入:["flower","flow","flight"]
输出:"fl"
✅ 完整解答

方法 1(排序法): 排序后只比较第一个和最后一个字符串的前缀,因为它们字典序差距最大,LCP 最短。

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

string longestCommonPrefix(vector<string>& strs) {
    if (strs.empty()) return "";
    sort(strs.begin(), strs.end());
    string& first = strs.front();
    string& last  = strs.back();
    int i = 0;
    while (i < (int)first.size() && i < (int)last.size() && first[i] == last[i])
        i++;
    return first.substr(0, i);
}

int main() {
    vector<string> v = {"flower", "flow", "flight"};
    cout << longestCommonPrefix(v) << "\n";  // "fl"
    v = {"dog", "racecar", "car"};
    cout << longestCommonPrefix(v) << "\n";  // ""
    return 0;
}

方法 2(Trie法): 将所有字符串插入 Trie,从根节点出发沿唯一路径走,直到遇到分叉或终止标记。

string lcp_trie(vector<string>& strs) {
    // 插入所有字符串后,从根出发找只有一个非空子节点且不是末尾的最长路径
    for (auto& s : strs) insert(s);
    
    string result = "";
    int p = 0;
    while (true) {
        if (exist[p]) break;  // 某字符串在此结束,不能继续
        int next = -1, cnt = 0;
        for (int i = 0; i < 26; i++) {
            if (ch[p][i]) { next = i; cnt++; }
        }
        if (cnt != 1) break;  // 有分叉,停止
        result += (char)('a' + next);
        p = ch[p][next];
    }
    return result;
}

🟡 进阶练习(4~6)

题目 4:字符串最小周期
给定字符串 s,求其最短重复周期的长度 T。
即找最小的 T,使得 s 可以由 s[0..T-1] 重复若干次(或截断)构成。

示例:

"abcabc"  → T = 3("abc" 重复 2 次)
"abababab" → T = 2("ab" 重复 4 次)
"abcd"    → T = 4(整串本身)
✅ 完整解答

核心公式: T = n - π[n-1],当 n % T == 0 时字符串可被整除,否则最小周期为 n。

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

vector<int> prefix_function(const string& s) {
    int n = s.size();
    vector<int> pi(n, 0);
    for (int i = 1; i < n; i++) {
        int j = pi[i - 1];
        while (j > 0 && s[i] != s[j]) j = pi[j - 1];
        if (s[i] == s[j]) j++;
        pi[i] = j;
    }
    return pi;
}

int min_period(const string& s) {
    int n = s.size();
    auto pi = prefix_function(s);
    int T = n - pi[n - 1];
    return (n % T == 0) ? T : n;
}

int main() {
    cout << min_period("abcabc")   << "\n";  // 3
    cout << min_period("abababab") << "\n";  // 2
    cout << min_period("abcd")     << "\n";  // 4
    cout << min_period("aaaaaa")   << "\n";  // 1
    return 0;
}

原理解释: π[n-1] = k 意味着字符串前 k 个字符 = 后 k 个字符。
若 s 有周期 T,则 π[n-1] ≥ n-T,等价于 T ≤ n - π[n-1]
n - π[n-1] 正是最小的满足条件的 T。


题目 5:数组中任意两数的最大异或值
给定整数数组 nums,求任意两个元素异或的最大值。

示例:

nums = [3, 10, 5, 25, 2, 8]
最大异或:5 XOR 25 = 28
输出:28
✅ 完整解答

思路: 将所有数插入 01-Trie,然后对每个数 x 查询「与 x 异或最大的数」并取全局最大值。

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

const int BIT = 30;
const int MAXN = 3200005;
int ch[MAXN][2];
int tot = 0;

void insert(int x) {
    int p = 0;
    for (int i = BIT; i >= 0; i--) {
        int b = (x >> i) & 1;
        if (!ch[p][b]) ch[p][b] = ++tot;
        p = ch[p][b];
    }
}

int max_xor_with(int x) {
    int p = 0, res = 0;
    for (int i = BIT; i >= 0; i--) {
        int b = (x >> i) & 1;
        int want = 1 - b;  // 想走异位,使该位异或为 1
        if (ch[p][want]) {
            res |= (1 << i);
            p = ch[p][want];
        } else {
            p = ch[p][b];
        }
    }
    return res;
}

int main() {
    ios_base::sync_with_stdio(false); cin.tie(NULL);
    
    int n; cin >> n;
    vector<int> nums(n);
    for (int& x : nums) { cin >> x; insert(x); }
    
    int ans = 0;
    for (int x : nums)
        ans = max(ans, max_xor_with(x));
    cout << ans << "\n";
    return 0;
}

追踪(nums = [3, 10, 5, 25, 2, 8],查询 5 = 0b000101):

插入所有数后对 5 查询:
位30~5:全0,Trie也全0,只能往0走
位4(x=0):want=1,Trie中有1(25=11001的第4位=1)→ 走1,res+=16
位3(x=0):want=1,Trie中有1(25的第3位=1)→ 走1,res+=8
位2(x=1):want=0,Trie中有0(25的第2位=0)→ 走0,res+=4
位1(x=0):want=1,无1路 → 走0
位0(x=1):want=0,有0路(25的第0位=1,走不了) → 走1

res = 16+8+4 = 28 = 5 XOR 25 ✓

题目 6:电话号码查找(前缀冲突检测)
给定 N 个电话号码,判断是否存在某号码是另一个号码的前缀。若存在输出 NO,否则输出 YES

示例:

号码:["911","9116","91125"]
"911" 是 "9116" 的前缀 → 输出 NO
✅ 完整解答

思路: 将所有号码插入 Trie,若插入过程中遇到已有终止标记的节点(当前号码是已插入号码的延伸),或插入完后该节点还有子节点(已插入号码是当前号码的前缀),则存在冲突。

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

const int MAXN = 1000005;
int ch[MAXN][10];  // 数字 0~9
bool is_end[MAXN];
int tot = 0;

// 插入号码,返回是否发现冲突
bool insert_check(const string& s) {
    int p = 0;
    for (char c : s) {
        int d = c - '0';
        if (is_end[p]) return true;   // 已有号码是当前号码的前缀
        if (!ch[p][d]) {
            ch[p][d] = ++tot;
            fill(ch[tot], ch[tot] + 10, 0);
            is_end[tot] = false;
        }
        p = ch[p][d];
    }
    // 插入完成,检查该节点是否已有子节点(当前号码是已有号码的前缀)
    for (int i = 0; i < 10; i++)
        if (ch[p][i]) return true;
    is_end[p] = true;
    return false;
}

int main() {
    int n; cin >> n;
    bool conflict = false;
    for (int i = 0; i < n; i++) {
        string s; cin >> s;
        if (insert_check(s)) conflict = true;
    }
    cout << (conflict ? "NO" : "YES") << "\n";
    return 0;
}

🔴 挑战练习(7~8)

题目 7:统计所有出现次数 ≥ 2 的子串数量
给定字符串 s(长度 ≤ 1000),统计在 s 中出现次数 ≥ 2 的不同子串的数量。

提示: 枚举每个可能的子串(起点 i,长度 len),用字符串哈希去重并统计出现次数。

✅ 完整解答

思路: 枚举所有子串,用 set<pair<哈希值, 长度>> 标记已统计过的,对每个子串用 KMP 统计出现次数。

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

vector<int> prefix_function(const string& s) {
    int n = s.size();
    vector<int> pi(n, 0);
    for (int i = 1; i < n; i++) {
        int j = pi[i - 1];
        while (j > 0 && s[i] != s[j]) j = pi[j - 1];
        if (s[i] == s[j]) j++;
        pi[i] = j;
    }
    return pi;
}

int count_occurrences(const string& text, const string& pattern) {
    if (pattern.empty()) return 0;
    int m = pattern.size();
    auto pi = prefix_function(pattern + '#' + text);
    int cnt = 0;
    for (int i = m + 1; i < (int)pi.size(); i++)
        if (pi[i] == m) cnt++;
    return cnt;
}

int main() {
    string s; cin >> s;
    int n = s.size();
    
    set<string> counted;  // 已统计的子串
    int ans = 0;
    
    for (int i = 0; i < n; i++) {
        for (int len = 1; len <= n - i; len++) {
            string sub = s.substr(i, len);
            if (counted.count(sub)) continue;
            counted.insert(sub);
            if (count_occurrences(s, sub) >= 2) ans++;
        }
    }
    
    cout << ans << "\n";
    return 0;
}

注: 上面方法对小数据(n ≤ 1000)可行,大数据需用后缀数组后缀自动机优化至 O(N log N)。


题目 8:最大数组异或路径(树上路径 + 01-Trie)
给定一棵有 N 个节点的树,每条边有权值。求树上任意一条路径,使路径上所有边权的异或和最大

提示: 利用「两点路径的异或 = 两点到根的异或路径的异或」,先 DFS 求所有节点到根的异或距离,再用 01-Trie 求最大异或对。

✅ 完整解答
#include <bits/stdc++.h>
using namespace std;

const int MAXN = 100005;
const int BIT = 30;
const int TRIE_MAXN = MAXN * 31 * 2;

// 01-Trie
int ch_trie[TRIE_MAXN][2];
int tot_trie = 0;

void insert_trie(int x) {
    int p = 0;
    for (int i = BIT; i >= 0; i--) {
        int b = (x >> i) & 1;
        if (!ch_trie[p][b]) ch_trie[p][b] = ++tot_trie;
        p = ch_trie[p][b];
    }
}

int max_xor_trie(int x) {
    int p = 0, res = 0;
    for (int i = BIT; i >= 0; i--) {
        int b = (x >> i) & 1;
        int want = 1 - b;
        if (ch_trie[p][want]) { res |= (1 << i); p = ch_trie[p][want]; }
        else p = ch_trie[p][b];
    }
    return res;
}

// 树 DFS
vector<pair<int,int>> adj[MAXN];
int dist[MAXN];  // dist[i] = 根到 i 的异或距离

void dfs(int u, int parent) {
    insert_trie(dist[u]);
    for (auto [v, w] : adj[u]) {
        if (v == parent) continue;
        dist[v] = dist[u] ^ w;
        dfs(v, u);
    }
}

int main() {
    int n; cin >> n;
    for (int i = 0; i < n - 1; i++) {
        int u, v, w; cin >> u >> v >> w;
        adj[u].push_back({v, w});
        adj[v].push_back({u, w});
    }
    
    dist[1] = 0;
    dfs(1, -1);  // 从节点 1 出发 DFS
    
    // 对每个节点,在 Trie 中找与其异或最大的节点
    int ans = 0;
    for (int i = 1; i <= n; i++)
        ans = max(ans, max_xor_trie(dist[i]));
    
    cout << ans << "\n";
    return 0;
}

关键原理:
d[u] = 根到 u 的边权异或和。
路径 u → v 的异或和 = d[u] XOR d[v](因为公共前缀部分异或两次等于 0)。
所以只需在所有 d[i] 中找最大异或对,01-Trie 做到 O(N log W)。


💡 章节联系: 字符串算法是 USACO Silver/Gold 的常见考点。KMP 解决单模式匹配,Trie 解决多字符串的集合查询和异或问题。学完本章后,可以进一步探索字符串哈希(第 3.7 章)和 AC 自动机(多模式同时匹配,竞赛进阶)。

⚡ 第四部分:贪心算法

无需复杂递推式的优雅算法——只需一个精妙的观察。学习贪心何时奏效、如何证明,以及强大的贪心 + 二分搜索组合。

📚 2 章 · ⏱️ 预计 1-2 周 · 🎯 目标:活动选择、调度、二分答案 + 贪心

第四部分:贪心算法

预计用时:1-2 周

贪心算法非常优雅:无需复杂的递推式,无需状态爆炸——只需一个精妙的观察,让一切迎刃而解。难点在于知道何时贪心可行,并在可行时能证明它的正确性。


涵盖的主题

章节主题核心思想
第 4.1 章贪心基础贪心何时奏效;交换论证法证明
第 4.2 章USACO 中的贪心用贪心解决真实的 USACO 题目

学完本部分后能解决什么问题

完成第四部分后,你将能够挑战:

  • USACO Bronze:

    • 带贪心决策的模拟(最优地处理事件)
    • 简单的基于排序的贪心
  • USACO Silver:

    • 活动选择(不重叠区间最大化)
    • 调度问题(最早截止日期优先,最小化最大延迟)
    • 贪心 + 二分答案
    • Huffman 风格的合并问题(优先队列)

关键贪心模式

模式排序依据应用
活动选择结束时间升序最大不重叠区间数
最早截止日期优先截止时间升序最小化最大延迟
区间刺穿结束时间升序最少点覆盖所有区间
区间覆盖开始时间升序最少区间覆盖范围
分数背包价值/重量降序最大化带容量限制的价值
Huffman 合并用最小堆最小化编码代价

前置条件

开始第四部分前,请确认你能做到:

  • 用自定义比较器排序(第 3.3 章)
  • 使用 priority_queue(第 3.1 章)
  • 二分答案(第 3.3 章)—— 第 4.2 章中使用

贪心思维方式

编写贪心解法前,先问自己:

  1. 每一步「显然最优」的选择是什么?
  2. 能做交换论证吗? 把贪心选择与任何其他选择互换,结果只会变差(或保持不变)吗?
  3. 能找到反例吗? 试一些贪心可能失败的小例子。

若能回答 (1) 和 (2) 且对 (3) 找不到反例,你的贪心很可能是正确的。


本部分学习建议

  1. 贪心最难「验证」。 不像 DP 只需要正确的递推式,贪心需要正确性论证。多练习交换论证证明的草稿。
  2. 贪心失败时,DP 通常是修复方案。 硬币找零(第 4.1 章)完美地展示了这一点。
  3. 第 4.2 章有真实的 USACO 题目 —— 仔细研究代码,不只是高层次的想法。
  4. 贪心 + 二分搜索(第 4.2 章)是频繁出现在 Silver 的强力组合。贪心解决「检查」函数,二分查找最优答案。

💡 核心思路: 排序是大多数贪心算法的引擎,排序标准体现了「贪心选择」——优先选最好的元素。交换论证证明了这个标准是最优的。

🏆 USACO 技巧: USACO Silver 中,若题目问「在约束 Y 下最大化 X」或「达成 Z 的最小代价」,先尝试带贪心检查的二分答案。这个组合解决了相当大比例的 Silver 题目。

📖 第 4.1 章 ⏱️ 约 120 分钟 🎯 中级

第 4.1 章:贪心基础

📝 前置条件: 熟悉排序(第 3.3 章)和基本的 priority_queue 用法(第 3.1 章)。部分题目还涉及区间推理。

贪心算法就像一个旅行者,永远选择最近的绿洲——没有地图,没有计划,只看当下最好的移动。对于合适的问题,这总是奏效的;对于其他问题,它会带来灾难。


📚 目录

小节主题难度
§4.1.1什么样的问题可以用贪心解决?🟢 基础
§4.1.2交换论证法(证明技巧)🟡 核心
§4.1.3活动选择问题🟡 核心
§4.1.4区间调度:最大化 vs 最小化变体🟡 核心
§4.1.5调度问题:最小化最大延迟(EDF)🟡 核心
§4.1.6Huffman 编码——贪心建树🟡 核心
§4.1.7排列贪心:自定义排序标准🟡 核心
§4.1.8任务分配:双序列匹配🟡 核心
§4.1.9区间合并🟢 标准
§4.1.10数字与字符串的贪心🟡 标准
§4.1.11后悔贪心(用堆实现撤销)🔴 进阶
§4.1.12对抗匹配(田忌赛马)🔴 进阶
§4.1.13前缀/后缀贪心与位运算贪心🔴 进阶
§4.1.14USACO 真题训练:从题面识别贪心🟡 Silver
练习题5 道练习题 + 1 道挑战题🟡–🔴

💡 建议阅读路径: 初次阅读应按顺序学习 §4.1.1–4.1.5;§4.1.6–4.1.9 可以任意顺序阅读;§4.1.11–4.1.13 是 USACO Gold 及以上的进阶技术。


4.1.1 什么样的问题可以用贪心解决?

贪心方法在问题具有贪心选择性质时奏效:每一步做出局部最优选择,最终得到全局最优解。

与 DP 的对比

考虑用硬币凑出 11 分:

  • 硬币:{1, 5, 6, 9}
  • 贪心:9 + 1 + 1 = 3 枚
  • 最优:6 + 5 = 2 枚

这里贪心失败了。贪心选择(每次选最大的硬币)没有达到全局最优。

但对于美国硬币 {1, 5, 10, 25, 50}:

  • 41 分:贪心 → 25 + 10 + 5 + 1 = 4 枚 ✓(最优)

美国硬币有特殊结构使贪心可行。始终要验证!

完整演示:硬币找零——贪心 vs DP

让我们详细追踪硬币找零例子,看清楚贪心在哪里出错。

题目: 用硬币 {1, 5, 6, 9} 凑出 11 分,最少需要几枚?

贪心做法(每次选 ≤ 剩余金额的最大硬币):

剩余=11 → 选 9(≤11 最大)。剩余=2。已用:[9]
剩余=2  → 选 1(≤2 最大)。剩余=1。已用:[9, 1]
剩余=1  → 选 1(≤1 最大)。剩余=0。已用:[9, 1, 1]
结果:3 枚 ✗

最优(DP)做法:

6 + 5 = 11。已用:[6, 5]
结果:2 枚 ✓

为什么贪心失败了? 贪心立即抓取最大的硬币(9),留下的余数(2)只能用 1 分硬币填满。它「看不出来」跳过 9 用 6+5 会更好。

Coin Change: Greedy vs Optimal

现在对比用美国硬币 {1, 5, 10, 25} 凑 41 分:

剩余=41 → 选 25。剩余=16。已用:[25]
剩余=16 → 选 10。剩余=6。 已用:[25, 10]
剩余=6  → 选 5。 剩余=1。 已用:[25, 10, 5]
剩余=1  → 选 1。 剩余=0。 已用:[25, 10, 5, 1]
结果:4 枚 ✓(最优!)

美国硬币有效是因为每个面额至少是前一个的两倍——永远不需要「撤销」贪心选择。而 {1, 5, 6, 9} 中 5 和 6 太接近,会产生贪心选择阻碍更好组合的情况。

⚠️ 结论: 硬币找零是看起来可以用贪心但并非总是如此的经典例子。除非硬币面额有特殊结构(如美国硬币),否则需要 DP。有疑问时,试一个小反例!

💡 核心思路: 贪心在有「无悔」性质时奏效——一旦做出贪心选择,永远不需要撤销。如果总能用贪心选择替换任何非贪心选择而不使情况变差,贪心就是最优的。

贪心 vs DP 决策路径对比:

Greedy vs DP Decision Path

🔍 如何识别贪心问题

看到新题时,按以下清单检查:

📄 看到新题时,按以下清单检查:
1. 处理元素时有没有自然的「顺序」或「优先级」?
   (如:按截止时间、结束时间、比率、大小排序……)
        ↓ 有
2. 能证明局部最优选择在全局上是安全的吗?
   (交换论证:把贪心选择与任何其他选择互换,永远不会更好)
        ↓ 能
3. 能找到贪心失败的小反例吗?
        ↓ 找不到反例
   → 贪心很可能正确。实现并验证。
        ↓ 找到反例
   → 贪心失败。考虑 DP 或其他方法。

贪心可行的三个信号:

  • ① 排序后,有明确的「按此顺序处理」规则
  • ② 题目要求一遍扫描最大化/最小化计数或代价
  • ③ 子问题独立——选择一个元素不影响剩余选择的「形状」

改用 DP 的三个信号:

  • ① 选择之间有交互(选 A 会改变 B 的可用性)
  • ② 需要考虑多个未来状态
  • ③ 对你尝试的任何贪心规则都能找到反例

🧭 贪心证明与反例模板

在 USACO 中,写出贪心代码之前,建议先写 3 行「证据链」:

步骤你要回答的问题如果答不上来
1. 排序/选择规则我为什么按这个量排序,或为什么每次选这个元素?说明贪心标准还不清楚
2. 安全性证明任意最优解能否替换为我的选择,且答案不变差?需要交换论证或反例搜索
3. 失败场景哪种小数据会让这个规则出错?若能构造,立刻改用 DP/搜索/堆

反例搜索技巧: 不要从大样例开始。先用 3–5 个元素构造极端情况:一个很大的早期收益、两个中等收益组合、一个边界相等的区间。大多数错误贪心都能在小数据里暴露。

候选贪心:每次选当前收益最大的元素
小反例模板:
- 选最大元素后,会不会阻止两个中等元素同时被选?
- 选结束最晚/开始最早的区间,会不会占掉大量空间?
- 选单价最高的资源,会不会让高产对象没有容量可用?

🏆 竞赛建议: 真正可靠的贪心题解通常同时具备「排序标准」「交换论证」「小反例排除」三件事。缺任何一个,都要谨慎。


4.1.2 交换论证法

交换论证是贪心算法的标准证明技术,回答「怎么证明贪心正确?」这个问题。几乎所有 USACO 的贪心正确性证明都用这个技术。

工作原理

证明模板分四步:

  1. 假设存在一个在某一步做出与我们的贪心算法不同选择的最优解 O。
  2. 找到 O 和贪心第一次不同的位置。
  3. 交换——把贪心的选择放入 O 在该位置的解中。证明结果至少同样好(代价不增,或数量不减)。
  4. 重复直到 O 完全变换成贪心解。由于每次交换维持或改善解,贪心解必然是最优的。

💡 核心思路: 需要证明贪心是唯一最优的——只需证明没有交换可以改善它。即使多个解都达到相同最优,贪心也能找到其中一个。

📋 交换论证证明模板

给定: 贪心规则 G,最优解 O。

第一步——找差异: 设 i 是 O 和 G 第一个不同的下标。

第二步——交换: 构造 O',将位置 i 的 O 的选择替换为 G 的选择。

第三步——比较: 证明 cost(O') ≤ cost(O)(或 count(O') ≥ count(O))。

第四步——结论: 归纳地,反复交换把 O 变成 G 而不恶化解。因此 G 是最优的。

为什么「相邻交换」就够了

一个关键观察:若能证明交换任意两个顺序不对的相邻元素不会恶化解,那么通过标准的「冒泡排序」论证,可以将任意解重排为贪心顺序而不使情况变差。

这就是为什么交换论证几乎总是聚焦于只交换两个相邻元素——完整证明由归纳法得出。

具体示例:调度最小化加权和

题目: 有 N 个作业,作业 i 的处理时间为 t[i],权重为 w[i]。所有作业在一台机器上顺序运行。作业 i 的加权完成时间 = w[i] × (包含作业 i 在内的所有作业处理时间之和)。最小化总加权完成时间。

样例输入:

3
2 3
1 5
4 2

(格式:每行 t[i] w[i])

样例输出:

37

什么顺序最优? 用交换论证:

考虑两个相邻作业 A(处理时间 a,权重 w_A)和 B(处理时间 b,权重 w_B),设 S 是这两个作业之前所有作业的总处理时间:

顺序A 的加权代价B 的加权代价这两个作业的总代价
A → Bw_A × (S + a)w_B × (S + a + b)w_A·a + w_B·b + (w_A + w_B)·S + w_B·a
B → Aw_B × (S + b)w_A × (S + b + a)w_B·b + w_A·a + (w_A + w_B)·S + w_A·b

A → B 更好当:w_B·a < w_A·b,即 w_A/t_A > w_B/t_B权重/时间比更高的先做)。

贪心规则:w[i]/t[i] 降序排序。

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

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

    int n;
    cin >> n;

    vector<pair<int,int>> jobs(n);  // {t, w}
    for (auto &[t, w] : jobs) cin >> t >> w;

    // 按 w/t 比值降序排序(比值更高的先做)
    sort(jobs.begin(), jobs.end(), [](const auto &a, const auto &b) {
        // a.second/a.first > b.second/b.first  →  a.second * b.first > b.second * a.first
        return (long long)a.second * b.first > (long long)b.second * a.first;
    });

    long long total = 0, curTime = 0;
    for (auto [t, w] : jobs) {
        curTime += t;
        total += (long long)w * curTime;
    }

    cout << total << "\n";
    return 0;
}
// 时间复杂度:O(N log N)

图示:贪心交换论证

Greedy Exchange Argument

上图说明了交换论证:若两个相邻元素相对于贪心标准「顺序不对」,交换它们会产生至少同样好的解。通过反复交换,可以把任何解变换成贪心解而不损失价值。

交换论证失败的情况

有时找不到有效的交换——这说明贪心不适用:

  • 0/1 背包: 无法用一件物品的一部分替换另一件物品,所以交换不保持约束。
  • 任意面额的硬币找零: 交换硬币选择实际上可能在其他位置强制使用更多硬币。
  • 一般加权区间调度: 选择一个高利润的短作业可能阻塞两个中等利润但合计超过它的作业。

在所有这些情况下,交换论证失败,需要用 DP


4.1.3 活动选择问题

题目: 给定 N 个活动,每个活动有开始时间 s[i] 和结束时间 f[i]。每次只能进行一个活动。若两个活动有重叠(一个在另一个结束前开始),则它们冲突。选择最多数量的不重叠活动

样例输入:

6
1 3
2 5
3 9
6 8
5 7
8 11

样例输出:

3

(最优选择是活动 (1,3)、(6,8)、(8,11)——或等价地 (1,3)、(5,7)、(8,11))


为什么可以用贪心解决

直觉上:在所有从上一个选定活动之后开始的活动中,接下来该选哪个?结束最早的那个——它「占用」最少的未来时间,为后续活动留下最大空间。

任何其他选择(选结束更晚的活动)只会有害:它阻塞的未来活动至少与结束最早的选择一样多,甚至更多。

这就是贪心选择性质: 局部最优选择(选结束最早的相容活动)导致全局最优解。

图示:活动选择甘特图

Activity Selection

甘特图在时间轴上展示所有活动。选中的活动(绿色)不重叠且数量最多,被拒绝的活动(灰色)因与已选活动重叠而跳过。贪心规则是:始终选结束时间最早且不冲突的活动。

贪心算法:

  1. 结束时间排序活动
  2. 每次选择与已选活动相容且结束时间最早的活动

Activity Selection Greedy Process

💡 为什么按结束时间排序? 选择结束最早的活动为后续活动留下最多时间。按开始时间排序可能选到开始很早但结束很晚的活动,占用大量时间。

📄 C++ 完整代码
// 活动选择 — O(N log N)
#include <bits/stdc++.h>
using namespace std;

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

    int n;
    cin >> n;

    vector<pair<int,int>> activities(n);  // {结束时间, 开始时间}
    for (int i = 0; i < n; i++) {
        int s, f;
        cin >> s >> f;
        activities[i] = {f, s};  // 按结束时间排序
    }

    sort(activities.begin(), activities.end());  // ← 关键:按结束时间排序

    int count = 0;
    int lastEnd = -1;  // 最后一个选定活动的结束时间

    for (auto [f, s] : activities) {
        if (s >= lastEnd) {      // 该活动在上一个结束后才开始
            count++;
            lastEnd = f;         // 更新最后结束时间
        }
    }

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

完整演示

活动: [(1,3), (2,5), (3,9), (6,8), (5,7), (8,11), (10,12)](格式:开始, 结束)

第一步——按结束时间排序:

活动:   A      B      C      D      E      F      G
(s,e):(1,3)  (2,5)  (5,7)  (6,8)  (3,9)  (8,11) (10,12)

排序后:A(1,3), B(2,5), C(5,7), D(6,8), E(3,9), F(8,11), G(10,12)

第二步——贪心选择(初始 lastEnd = -1):

活动 A (1,3):  start=1 ≥ lastEnd=-1 ✓ 选中。lastEnd=3。count=1
活动 B (2,5):  start=2 ≥ lastEnd=3?否(2 < 3)。跳过。
活动 C (5,7):  start=5 ≥ lastEnd=3 ✓ 选中。lastEnd=7。count=2
活动 D (6,8):  start=6 ≥ lastEnd=7?否(6 < 7)。跳过。
活动 E (3,9):  start=3 ≥ lastEnd=7?否(3 < 7)。跳过。
活动 F (8,11):start=8 ≥ lastEnd=7 ✓ 选中。lastEnd=11。count=3
活动 G (10,12):start=10 ≥ lastEnd=11?否(10 < 11)。跳过。

结果:选中 3 个活动——A(1,3), C(5,7), F(8,11)

时间轴(A~G 表示活动):
时间:  0  1  2  3  4  5  6  7  8  9  10 11 12
       |  |  |  |  |  |  |  |  |  |  |  |  |
A:       [===]                                   ✓ 选中
B:          [======]                             ✗ 与 A 重叠
C:                  [======]                     ✓ 选中
D:                     [======]                  ✗ 与 C 重叠
E:             [============]                    ✗ 与 A 和 C 重叠
F:                           [======]            ✓ 选中
G:                              [======]         ✗ 与 F 重叠

4.1.4 区间调度:最大化 vs 最小化变体

本节涵盖三个相关的区间问题,看起来相似但需要微妙不同的贪心策略。

图示:数轴上的区间调度

Interval Scheduling


最大化:最多不重叠区间

这正是 §4.1.3 的活动选择问题。按结束时间排序,贪心选择如上所述。


最小化:「刺穿」所有区间所需最少点数

题目: 给定数轴上 N 个区间,找最少数量的「点」(每个点是一个实数),使每个区间至少包含一个点。仅共享端点的两个区间都被视为包含该端点。

贪心策略:右端点升序排序。维护 lastPoint(最后放置的点)。对每个区间:

  • lastPoint 已在该区间内(lastPoint >= l[i]):该区间已被覆盖,跳过。
  • 否则:在 r[i] 放一个新点(尽量靠右,最大化对后续区间的覆盖),计数加一。
📄 C++ 完整代码
#include <bits/stdc++.h>
using namespace std;

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

    int n;
    cin >> n;
    vector<pair<int,int>> intervals(n);  // {右端点, 左端点}
    for (auto &[r, l] : intervals) cin >> l >> r;

    sort(intervals.begin(), intervals.end());  // 按右端点排序

    int points = 0;
    long long lastPoint = LLONG_MIN;

    for (auto [r, l] : intervals) {
        if (lastPoint < l) {          // 当前点不覆盖该区间
            lastPoint = r;            // 在右端放新点
            points++;
        }
    }

    cout << points << "\n";
    return 0;
}
// 时间复杂度:O(N log N)

最小化:最少区间覆盖一段范围

题目: 给定 N 个区间和目标范围 [0, T],从集合中选最少数量的区间使其并集完全覆盖 [0, T]。不可能时输出「Impossible」。

贪心策略:左端点升序排序。维护 covered(当前已覆盖到的位置,初始为 0)。每步在所有满足 l[i] ≤ covered 的区间中(它们可以延伸覆盖),选右端点最大的farthest)。将 covered 推进到 farthest,计数加一。

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

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

    int T, n;
    cin >> T >> n;

    vector<pair<int,int>> intervals(n);
    for (auto &[l, r] : intervals) cin >> l >> r;

    sort(intervals.begin(), intervals.end());  // 按左端点排序

    int covered = 0;    // 当前已覆盖到 'covered'
    int count = 0;
    int i = 0;

    while (covered < T) {
        int farthest = covered;

        // 在左端点 <= covered 的所有区间中,找最远的右端点
        while (i < n && intervals[i].first <= covered) {
            farthest = max(farthest, intervals[i].second);
            i++;
        }

        if (farthest == covered) {
            // 没有区间能延伸覆盖——不可能
            cout << "Impossible\n";
            return 0;
        }

        covered = farthest;
        count++;
    }

    cout << count << "\n";
    return 0;
}
// 时间复杂度:O(N log N)

⚠️ 与刺穿的关键区别: 刺穿按端点排序(尽量宽地覆盖当前区间);覆盖按端点排序(从停下的地方尽量向右延伸覆盖)。


4.1.5 调度问题:最小化最大延迟(EDF)

题目: 一台机器,N 个作业。作业 i 有:

  • 处理时间 t[i]——运行所需时长
  • 截止时间 d[i]——理想上应完成的时间

机器按顺序运行作业(无重叠,无空闲)。作业 i 的延迟量max(0, 完成时间[i] − d[i])——超出截止时间的量(按时完成则为 0)。最小化所有作业中最大延迟量

样例输入:

4
3 6
2 8
1 4
4 9

样例输出:

1

解释: 按截止时间升序排序:job3(t=1,d=4), job1(t=3,d=6), job2(t=2,d=8), job4(t=4,d=9)。

作业 3:运行 [0,1],  完成于 1。延迟量 = max(0, 1-4)  = 0
作业 1:运行 [1,4],  完成于 4。延迟量 = max(0, 4-6)  = 0
作业 2:运行 [4,6],  完成于 6。延迟量 = max(0, 6-8)  = 0
作业 4:运行 [6,10],完成于 10。延迟量 = max(0, 10-9) = 1
最大延迟量 = 1 ✓

贪心策略:最早截止日期优先(EDF)

规则: 按截止时间升序排列作业,按该顺序无间隙运行。

为什么 EDF 最优——交换论证:

设最优调度中两个相邻作业 A 和 B,d[A] > d[B](A 截止更晚但先运行)。设 S 是这两个作业之前所有作业的完成时间:

调度A 的延迟量B 的延迟量
A → Bmax(0, S + t[A] − d[A])max(0, S + t[A] + t[B] − d[B])
B → Amax(0, S + t[B] − d[B])max(0, S + t[B] + t[A] − d[A])

由于 d[A] > d[B],B 更紧迫。A→B 顺序中:B 在 S + t[A] + t[B] 完成,与 B→A 顺序相同——但 B 的截止时间更早,可能延迟更多。交换到 B→A 永远不会增加最大延迟量。因此,任何非 EDF 调度都可以通过交换改善或维持,EDF 是最优的。

EDF Scheduling — Minimize Maximum Lateness

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

int main() {
    int n;
    cin >> n;

    vector<pair<int,int>> jobs(n);  // {截止时间, 处理时间}
    for (int i = 0; i < n; i++) cin >> jobs[i].second >> jobs[i].first;

    sort(jobs.begin(), jobs.end());  // 按截止时间排序

    int time = 0;
    int maxLateness = 0;

    for (auto [deadline, proc] : jobs) {
        time += proc;                          // 该作业的完成时间
        int lateness = max(0, time - deadline); // 延迟多久?
        maxLateness = max(maxLateness, lateness);
    }

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

4.1.6 Huffman 编码(贪心建树)

题目: 有 N 个符号,每个出现频率为 freq[i]。想为每个符号分配一个二进制码字(0 和 1 的字符串),使没有码字是另一个的前缀(前缀无关码)。总编码代价 = 所有符号的 freq[i] × depth[i] 之和,其中 depth[i] 是符号 i 码字的长度。最小化总代价。

贪心规则: 始终合并频率最小的两个节点(用最小堆)。

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

int main() {
    int n;
    cin >> n;

    priority_queue<long long, vector<long long>, greater<long long>> pq;  // 最小堆
    for (int i = 0; i < n; i++) {
        long long f; cin >> f;
        pq.push(f);
    }

    long long totalCost = 0;
    while (pq.size() > 1) {
        long long a = pq.top(); pq.pop();
        long long b = pq.top(); pq.pop();
        totalCost += a + b;  // 合并 a 和 b 的代价
        pq.push(a + b);      // 合并后的组频率为 a+b
    }

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

为什么总是合并最小的两个? 频率最低的两个符号应该在树中最深(码字最长),因为罕见符号的长码字对总代价的贡献较小。通过总是合并当前最小的两个,确保使用最频繁的符号保留在根附近。

USACO 中的实际应用: Huffman 算法出现在「最小代价合并 N 堆」问题中。每次需要合并两堆,支付合并后大小之和,答案就是所有合并操作的总和——由 Huffman 贪心算法计算。


4.1.7 排列贪心:自定义排序标准

经典题一:最小化总完成时间(最短作业优先)

贪心策略: 按处理时间升序排序(最短作业优先,SJF)。

为什么 SJF 最优?(交换论证)

设最优顺序中两个相邻作业 A(处理时间 a)和 B(处理时间 b),a > b(B 更短但排在 A 后面)。设 T 是这两个作业之前的累计完成时间:

顺序A 的完成时间B 的完成时间两者之和
A → BT + aT + a + b2T + 2a + b
B → AT + bT + b + a2T + a + 2b

由于 a > b,2T + 2a + b > 2T + a + 2b,所以 B→A 给出更小的和。

📄 由于 a > b,`2T + 2a + b > 2T + a + 2b`,所以 B→A 给出更小的和。
#include <bits/stdc++.h>
using namespace std;

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

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

    sort(t.begin(), t.end());  // SJF:按处理时间升序

    long long totalCompletion = 0;
    long long curTime = 0;
    for (int i = 0; i < n; i++) {
        curTime += t[i];
        totalCompletion += curTime;
    }

    cout << totalCompletion << "\n";
    return 0;
}
// 时间复杂度:O(N log N)

SJF — Minimize Total Completion Time


经典题二:最大数(拼接贪心)

题目: 给定 N 个非负整数,将它们排列后拼接成最大的数。输出为字符串。

贪心策略: 自定义比较器:对两个数 a 和 b(作为字符串),若 str(a) + str(b) > str(b) + str(a) 则 a 排在 b 前面。

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

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

    int n;
    cin >> n;
    vector<string> nums(n);
    for (string &s : nums) cin >> s;

    // 自定义排序:a+b > b+a 时 a 排在前面
    sort(nums.begin(), nums.end(), [](const string &a, const string &b) {
        return a + b > b + a;
    });

    // 边界情况:全为零
    if (nums[0] == "0") {
        cout << "0\n";
        return 0;
    }

    string result = "";
    for (const string &s : nums) result += s;
    cout << result << "\n";
    return 0;
}
// 时间复杂度:O(N log N · L),L = 最大数字位数

逐步追踪(nums = ["3", "30", "34", "5", "9"]):

比较部分对:
"3"+"30"="330" vs "30"+"3"="303"  →  "3" 排前面
"9"+"5"="95"  vs "5"+"9"="59"    →  "9" 排前面
"34"+"3"="343" vs "3"+"34"="334" →  "34" 排前面

排序后:["9", "5", "34", "3", "30"]
结果:  "9534330" ✓

⚠️ 警告: 不能简单地按数值大小排序!例如 "3" > "30" 按数值,但拼接 "330" > "303",所以 "3" 应排前面。始终用拼接比较器。


经典题三:最大化(或最小化)内积

贪心规则:

  • 最大化 ∑ A[i] × B[i]:A 和 B 都按升序排序(同方向配对)
  • 最小化 ∑ A[i] × B[i]:A 按升序,B 按降序排序(反方向配对)

这是排列不等式:大配大、小配小最大化;大配小、小配大最小化。

📄 这是**排列不等式**:大配大、小配小最大化;大配小、小配大最小化。
// 最大化 sum(A[i] * B[i])
sort(A.begin(), A.end());
sort(B.begin(), B.end());
long long maxSum = 0;
for (int i = 0; i < n; i++) maxSum += (long long)A[i] * B[i];

// 最小化 sum(A[i] * B[i])
sort(A.begin(), A.end());
sort(B.begin(), B.end(), greater<int>());
long long minSum = 0;
for (int i = 0; i < n; i++) minSum += (long long)A[i] * B[i];

4.1.8 任务分配:双序列匹配

任务分配问题涉及将两个有序序列的元素匹配以优化某个目标。关键模式:对两个序列排序,然后用双指针扫描或直接下标配对。

最大化完成任务数(双指针)

贪心: 对两个序列排序,用双指针贪心匹配。

📄 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> ability(n), difficulty(n);
    for (int &x : ability) cin >> x;
    for (int &x : difficulty) cin >> x;

    sort(ability.begin(), ability.end());
    sort(difficulty.begin(), difficulty.end());

    // 双指针:贪心地把最弱的有能力工人分配给每个任务
    int completed = 0;
    int i = 0, j = 0;  // i:工人指针,j:任务指针

    while (i < n && j < n) {
        if (ability[i] >= difficulty[j]) {
            // 工人 i 能完成任务 j——匹配
            completed++;
            i++;
            j++;
        } else {
            // 工人 i 太弱——尝试更强的工人
            i++;
        }
    }

    cout << completed << "\n";
    return 0;
}
// 时间复杂度:O(N log N)

4.1.9 区间合并

区间合并是另一种经典贪心:将所有重叠区间合并为一组不重叠的区间。

贪心算法:

  1. 左端点升序排序区间
  2. 维护当前合并区间 [curL, curR]
  3. 对每个新区间 [l, r]:
    • 若 l ≤ curR(重叠或相邻):延伸 curR = max(curR, r)
    • 否则:完成当前合并区间,开始新的

Interval Merging — Step-by-Step

📄 ![Interval Merging — Step-by-Step](../images/interval_merging.svg
#include <bits/stdc++.h>
using namespace std;

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

    int n;
    cin >> n;

    vector<pair<int,int>> intervals(n);
    for (auto &[l, r] : intervals) cin >> l >> r;

    sort(intervals.begin(), intervals.end());  // 按左端点排序

    vector<pair<int,int>> merged;

    for (auto [l, r] : intervals) {
        if (merged.empty() || l > merged.back().second) {
            // 无重叠——开始新的合并区间
            merged.push_back({l, r});
        } else {
            // 重叠——延伸右端点
            merged.back().second = max(merged.back().second, r);
        }
    }

    cout << merged.size() << " 个合并区间:\n";
    for (auto [l, r] : merged) {
        cout << "[" << l << "," << r << "] ";
    }
    cout << "\n";
    return 0;
}
// 时间复杂度:O(N log N)

逐步追踪(输入已排序:[1,3],[2,6],[8,10],[15,18]):

[1,3]:   merged 为空 → 直接加入。merged=[[1,3]]
[2,6]:   2 <= 3(重叠)→ 延伸:[1, max(3,6)]=[1,6]。merged=[[1,6]]
[8,10]:  8 > 6(不重叠)→ 加新区间。merged=[[1,6],[8,10]]
[15,18]: 15 > 10(不重叠)→ 加新区间。merged=[[1,6],[8,10],[15,18]]

输出:[[1,6],[8,10],[15,18]] ✓

4.1.10 数字与字符串的贪心

经典题:删除 K 个数字得到最小数

题目: 给定一串数字(表示一个大整数),恰好删除 K 个数字(保持剩余数字的原始顺序)以形成最小的整数。

例子:

"1432219",K=3  →  "1219"

贪心思路: 维护单调栈。从左到右扫描:

  • 若栈顶 > 当前数字且还有删除机会:弹出栈顶(删除较大的数字)
  • 否则:压入当前数字
  • 扫描完后若还有删除机会:从栈的右端删除

Monotone Stack — Remove K Digits

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

int main() {
    string num;
    int k;
    cin >> num >> k;

    string stk = "";  // 用字符串作单调栈

    for (char c : num) {
        // 当栈顶大于当前数字且还有删除机会时弹出
        while (k > 0 && !stk.empty() && stk.back() > c) {
            stk.pop_back();
            k--;
        }
        stk.push_back(c);
    }

    // 若还有删除机会,从右端删除
    stk.resize(stk.size() - k);

    // 移除前导零
    int start = 0;
    while (start < (int)stk.size() - 1 && stk[start] == '0') start++;

    cout << stk.substr(start) << "\n";
    return 0;
}
// 时间复杂度:O(N)

经典题:最少跳跃次数

题目: 给定数组 A[0..n-1]A[i] 是从位置 i 可以跳跃的最大步数。从索引 0 出发,用最少次数到达最后一个索引。

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

int main() {
    int n;
    cin >> n;
    vector<int> A(n);
    for (int &x : A) cin >> x;

    int jumps = 0;
    int curEnd = 0;    // 当前跳跃能到达的最远位置
    int farthest = 0;  // 目前可到达的最远位置

    for (int i = 0; i < n - 1; i++) {
        farthest = max(farthest, i + A[i]);  // 更新最远可达
        if (i == curEnd) {                   // 到达当前跳跃范围的末尾
            jumps++;
            curEnd = farthest;               // 跳到最远位置
            if (curEnd >= n - 1) break;      // 已经可以到达末尾
        }
    }

    cout << jumps << "\n";
    return 0;
}
// 时间复杂度:O(N)

经典题:买卖股票最大利润(贪心版)

题目: 给定每日股价,可以无限次买卖(但每次只能持有一股),最大化总利润。

贪心思路: 只要明天价格高于今天,就「今天买明天卖」。等价于累加所有正日差。

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

int main() {
    int n;
    cin >> n;
    vector<int> prices(n);
    for (int &x : prices) cin >> x;

    int profit = 0;
    for (int i = 1; i < n; i++) {
        if (prices[i] > prices[i - 1]) {
            profit += prices[i] - prices[i - 1];  // 捕获每次上涨
        }
    }

    cout << profit << "\n";
    return 0;
}
// 时间复杂度:O(N)

⚠️ 注意: 这是「无限次交易」版本。「最多一次交易」→ 追踪最低买入价的单次扫描。「最多两次/K 次交易」→ 需要 DP。


4.1.11 后悔贪心

后悔贪心是最强大也最容易被忽视的贪心技术。核心思路:

做出贪心决策,但同时保留「撤销」它的能力——如果该决策后来不是最优的,用堆(优先队列)以 O(log N) 时间撤销它。

经典题:K 次操作获得最大利润(支持撤销)

贪心 + 后悔做法:

  1. 维护最大堆
  2. 每步:取堆顶 x(最大收益),然后插入 -x(「后悔节点」——撤销此操作的代价)
  3. 若之后从堆中取出 -x,相当于「取消」之前的操作
📄 3. 若之后从堆中取出 `-x`,相当于「取消」之前的操作
#include <bits/stdc++.h>
using namespace std;

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

    int n, k;
    cin >> n >> k;

    priority_queue<long long> pq;  // 最大堆
    for (int i = 0; i < n; i++) {
        long long x; cin >> x;
        pq.push(x);
    }

    long long total = 0;
    for (int i = 0; i < k; i++) {
        long long top = pq.top(); pq.pop();
        if (top <= 0) break;  // 取该元素会损失——停止
        total += top;
        pq.push(-top);        // 插入后悔节点:撤销此操作的代价
    }

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

后悔的魔力: 取出 x 并插入 -x 后,若堆顶变为 y,我们可以选择:

  • 取 y(正常贪心)
  • -x(相当于「取消 x,用下一个可用元素替换」)

这自动找到了 K 次操作的最优序列。


最小化 K 台机器的最大完工时间(LPT)

贪心:最长处理时间优先(LPT)

降序排列处理时间,将每个作业分配给完成时间最早的机器(用最小堆维护)。

📄 按**降序**排列处理时间,将每个作业分配给完成时间最早的机器(用最小堆维护)。
#include <bits/stdc++.h>
using namespace std;

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

    int n, k;
    cin >> n >> k;

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

    sort(t.begin(), t.end(), greater<int>());  // 降序:最长作业优先

    // 最小堆:存储每台机器的当前完成时间(初始全为 0)
    priority_queue<long long, vector<long long>, greater<long long>> machines;
    for (int i = 0; i < k; i++) machines.push(0);

    for (int i = 0; i < n; i++) {
        long long earliest = machines.top(); machines.pop();
        machines.push(earliest + t[i]);  // 将作业分配给最早空闲的机器
    }

    long long makespan = 0;
    while (!machines.empty()) {
        makespan = max(makespan, machines.top());
        machines.pop();
    }

    cout << makespan << "\n";
    return 0;
}
// 时间复杂度:O(N log N + N log K)

4.1.12 对抗匹配(田忌赛马)

经典的**「田忌赛马」**问题是对抗贪心匹配的原型:双方各有 N 匹马;你可以自由选择出场顺序,对手顺序已知。最大化你赢得的比赛数。

策略(双指针,O(N)):

对你的马 A(升序)和对手的马 B(升序)排序:

  • 若你最强的马 A[hi] > 对手最强的马 B[bhi] → 用自己最强的打对手最强的(赢一场)
  • 若你最强的 A[hi] ≤ 对手最强的 B[bhi] → 用自己最弱的消耗对手最强的(策略性认输,保留更强的马)

Adversarial Matching — Tian Ji's Horse Racing

📄 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> A(n), B(n);
    for (int &x : A) cin >> x;
    for (int &x : B) cin >> x;

    sort(A.begin(), A.end());
    sort(B.begin(), B.end());

    int wins = 0;
    int lo = 0, hi = n - 1;    // A 的双端指针(弱端和强端)
    int blo = 0, bhi = n - 1;  // B 的双端指针

    while (lo <= hi) {
        if (A[hi] > B[bhi]) {
            // A 最强打赢 B 最强——赢一场
            wins++;
            hi--;
            bhi--;
        } else {
            // A 最强打不赢 B 最强——用 A 最弱消耗 B 最强
            lo++;
            bhi--;
        }
    }

    cout << wins << "\n";
    return 0;
}
// 时间复杂度:O(N log N)

4.1.13 前缀/后缀贪心与位运算贪心

前缀/后缀贪心

许多问题可以通过**一次从左扫描(前缀)和一次从右扫描(后缀)**来解决,然后合并结果。

经典应用:分发糖果(双遍扫描)

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

int main() {
    int n;
    cin >> n;
    vector<int> rating(n);
    for (int &x : rating) cin >> x;

    vector<int> candy(n, 1);  // 每人至少 1 颗糖

    // 第一遍:满足「比左邻居评分高则多拿」
    for (int i = 1; i < n; i++) {
        if (rating[i] > rating[i - 1])
            candy[i] = candy[i - 1] + 1;
    }

    // 第二遍:满足「比右邻居评分高则多拿」
    for (int i = n - 2; i >= 0; i--) {
        if (rating[i] > rating[i + 1])
            candy[i] = max(candy[i], candy[i + 1] + 1);
    }

    cout << accumulate(candy.begin(), candy.end(), 0) << "\n";
    return 0;
}
// 时间复杂度:O(N)

位运算贪心

经典题:最大化数组中两个元素的异或值

贪心(字典树): 将所有数字插入二进制字典树。对每个数字 x,在字典树中每层贪心地走「对立」分支(从高位到低位),逐位最大化 XOR 值。

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

const int MAXBIT = 30;

struct Trie {
    int ch[2];
    Trie() { ch[0] = ch[1] = -1; }
};

vector<Trie> trie(1);

void insert(int x) {
    int node = 0;
    for (int i = MAXBIT; i >= 0; i--) {
        int bit = (x >> i) & 1;
        if (trie[node].ch[bit] == -1) {
            trie[node].ch[bit] = trie.size();
            trie.push_back(Trie());
        }
        node = trie[node].ch[bit];
    }
}

int maxXOR(int x) {
    int node = 0, res = 0;
    for (int i = MAXBIT; i >= 0; i--) {
        int bit = (x >> i) & 1;
        int want = 1 - bit;  // 贪心:尝试对立位使 XOR = 1
        if (trie[node].ch[want] != -1) {
            res |= (1 << i);
            node = trie[node].ch[want];
        } else {
            node = trie[node].ch[bit];
        }
    }
    return res;
}

int main() {
    int n;
    cin >> n;
    vector<int> nums(n);
    for (int &x : nums) cin >> x;

    for (int x : nums) insert(x);

    int ans = 0;
    for (int x : nums) ans = max(ans, maxXOR(x));

    cout << ans << "\n";
    return 0;
}
// 时间复杂度:O(N × MAXBIT) = O(32N)

💡 通用位运算贪心模式: 从高位到低位处理。在每一位,贪心地选择使结果该位等于 1(或 0,取决于目标)的分支。字典树支持每次查询 O(MAXBIT) 时间。


4.1.14 USACO 真题训练:从题面识别贪心

本节把前面的贪心模式放进真实 USACO 题目中。做真题时不要先问「这是不是贪心」,而要先问:

  1. 排序依据是什么? 题目是否给了时间、价格、产量、资历、结束点等天然优先级?
  2. 局部选择是否安全? 当前最优选择会不会破坏未来更优选择?
  3. 如果局部选择错了,能否后悔? 若需要撤销,通常要用堆维护「后悔节点」。

真题 1:Convention II(USACO 2018 December Silver)— 排队中的「资历优先」

题目链接: USACO 2018 December Silver P2: Convention II
对应模式: 排序 + 优先队列贪心
难度定位: Silver 中等

题干解读

N 头奶牛,每头奶牛有:

  • 到达时间 arrival
  • 吃草所需时间 duration
  • 资历 seniority:输入越早,资历越高

同一时刻只有一头奶牛能吃草。如果草地空闲且有奶牛等待,就选择资历最高的等待奶牛。要求输出所有奶牛中的最大等待时间。

关键点是:

  • 到达顺序由 arrival 决定,所以要先按到达时间排序。
  • 等待队列中的选择由 seniority 决定,所以要用优先队列按输入编号最小者优先。
  • 当前没有等待奶牛时,时间要直接跳到下一头奶牛的到达时间,不能一秒一秒模拟。

思路分析

这不是普通的「先来先服务」。如果多头奶牛都已经到达,USACO 规则要求资历最高者先吃草。因此需要两个顺序:

事件依据数据结构
下一头奶牛何时进入等待队列到达时间升序排序数组
当前从等待队列选谁吃草输入编号升序最小堆

贪心的安全性来自题目规则本身:当草地空闲时,所有已经到达的奶牛中,资历最高者必须被选择;其他选择都会违反规则。我们只需要高效模拟这个规则。

CPP 完整代码

✅ 完整代码:Convention II
#include <bits/stdc++.h>
using namespace std;

struct Cow {
    long long arrive;
    long long eat;
    int id;  // 输入顺序,id 越小资历越高
};

int main() {
    ios::sync_with_stdio(false);
    cin.tie(nullptr);

    // USACO 官方文件 I/O 可按需打开:
    // freopen("convention2.in", "r", stdin);
    // freopen("convention2.out", "w", stdout);

    int n;
    cin >> n;
    vector<Cow> cows(n);
    for (int i = 0; i < n; i++) {
        cin >> cows[i].arrive >> cows[i].eat;
        cows[i].id = i;
    }

    sort(cows.begin(), cows.end(), [](const Cow& a, const Cow& b) {
        if (a.arrive != b.arrive) return a.arrive < b.arrive;
        return a.id < b.id;
    });

    // 等待队列:资历最高者优先,也就是 id 最小者优先
    priority_queue<pair<int, int>, vector<pair<int, int>>, greater<pair<int, int>>> waiting;

    long long currentTime = 0;
    long long maxWait = 0;
    int nextCow = 0;

    while (nextCow < n || !waiting.empty()) {
        // 如果没人等待,时间直接跳到下一头奶牛到达时刻
        if (waiting.empty() && currentTime < cows[nextCow].arrive) {
            currentTime = cows[nextCow].arrive;
        }

        // 把所有已经到达的奶牛加入等待队列
        while (nextCow < n && cows[nextCow].arrive <= currentTime) {
            waiting.push({cows[nextCow].id, nextCow});
            nextCow++;
        }

        auto [id, idx] = waiting.top();
        waiting.pop();

        maxWait = max(maxWait, currentTime - cows[idx].arrive);
        currentTime += cows[idx].eat;
    }

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

复杂度: 排序 O(N log N),每头奶牛进出堆一次,总复杂度 O(N log N),空间 O(N)

易错点提醒

  1. 把等待队列按到达时间排序。 到达时间只决定「能否进入队列」,进入后选择谁取决于资历。
  2. 忘记时间跳跃。 如果当前没有等待奶牛,应令 currentTime = next arrival,否则会错误地处理空闲时间。
  3. 等待时间计算位置错。 奶牛开始吃草前计算 currentTime - arrive;吃完后再算会多加自己的吃草时间。
  4. 使用 int 存时间。 到达时间与吃草时间相加可能较大,建议用 long long

拓展思考

如果规则改成「等待时间最长者优先」,优先队列的键就会变为等待时长;但等待时长随时间变化,堆中键会动态改变,模拟会更复杂。这类题常提示你:优先级必须是静态或可懒更新的,才能直接用堆贪心。


真题 2:Rental Service(USACO 2018 January Silver)— 「高产牛卖奶,低产牛出租」

题目链接: USACO 2018 January Silver P2: Rental Service
对应模式: 排序 + 前缀收益 + 枚举分界点
难度定位: Silver 进阶

题干解读

N 头奶牛,每头每天产若干加仑牛奶。每头牛只能做一件事:

  • 卖牛奶给商店:商店有购买上限和单价。
  • 出租整头牛:邻居给定固定租金。

目标是最大化总收入。

题目隐藏的关键结构是:高产牛更适合卖奶,低产牛更适合出租。我们不需要枚举每头牛的所有选择,只要枚举「前 k 头高产牛卖奶,其余低产牛出租」。

思路分析

先排序:

  • 奶牛产量降序:高产牛排前面。
  • 商店价格降序:先卖给单价最高的商店。
  • 租金降序:低产牛若出租,也优先拿最高租金。

然后预处理两个数组:

数组含义
sellProfit[k]k 头高产牛全部卖奶的最大收入
rentProfit[x]x 头低产牛全部出租的最大收入

最后枚举 k:卖奶 k 头,出租 N-k 头,答案是 max(sellProfit[k] + rentProfit[N-k])

CPP 完整代码

✅ 完整代码:Rental Service
#include <bits/stdc++.h>
using namespace std;

using ll = long long;

int main() {
    ios::sync_with_stdio(false);
    cin.tie(nullptr);

    // USACO 官方文件 I/O 可按需打开:
    // freopen("rental.in", "r", stdin);
    // freopen("rental.out", "w", stdout);

    int n, m, r;
    cin >> n >> m >> r;

    vector<ll> cows(n);
    for (ll &x : cows) cin >> x;

    vector<pair<ll, ll>> stores(m);  // {价格, 数量}
    for (int i = 0; i < m; i++) {
        ll quantity, price;
        cin >> quantity >> price;
        stores[i] = {price, quantity};
    }

    vector<ll> rent(r);
    for (ll &x : rent) cin >> x;

    sort(cows.rbegin(), cows.rend());      // 高产牛优先卖奶
    sort(stores.rbegin(), stores.rend());  // 高价商店优先购买
    sort(rent.rbegin(), rent.rend());      // 高租金优先出租

    vector<ll> sellProfit(n + 1, 0);
    int shop = 0;
    ll remaining = (m > 0 ? stores[0].second : 0);

    for (int i = 1; i <= n; i++) {
        sellProfit[i] = sellProfit[i - 1];
        ll milk = cows[i - 1];

        while (milk > 0 && shop < m) {
            ll take = min(milk, remaining);
            sellProfit[i] += take * stores[shop].first;
            milk -= take;
            remaining -= take;

            if (remaining == 0) {
                shop++;
                if (shop < m) remaining = stores[shop].second;
            }
        }
    }

    vector<ll> rentProfit(n + 1, 0);
    for (int x = 1; x <= n; x++) {
        rentProfit[x] = rentProfit[x - 1];
        if (x <= r) rentProfit[x] += rent[x - 1];
    }

    ll answer = 0;
    for (int sellCount = 0; sellCount <= n; sellCount++) {
        int rentCount = n - sellCount;
        answer = max(answer, sellProfit[sellCount] + rentProfit[rentCount]);
    }

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

复杂度: 排序 O((N+M+R) log(N+M+R)),预处理和枚举 O(N+M),空间 O(N)

易错点提醒

  1. 商店输入顺序是数量再价格。 代码中转成 {price, quantity} 是为了按价格降序排序。
  2. 出租的是低产牛。 若把高产牛出租、低产牛卖奶,会破坏贪心结构。
  3. 收益必须用 long long N、产量、价格都可能很大,int 会溢出。
  4. 枚举分界点时不要超过租客数量。 rentProfit[x] 中超过 R 的部分收益为 0。

拓展思考

这道题的本质是「排序后枚举分界点」。如果题目要求每头牛还带有不同的出租限制,简单分界点就不再成立,可能需要 DP 或最小费用流。看到 Silver 题中同时出现「排序 + 两种收益方式」时,优先尝试这种前缀收益模型。


真题 3:Rest Stops(USACO 2018 February Silver)— 从右往左选「未来最高价值」

题目链接: USACO 2018 February Silver P1: Rest Stops
对应模式: 后缀最大值 + 贪心选择
难度定位: Silver 中等

题干解读

FJ 和 Bessie 沿一条长度为 L 的路前进。Bessie 走得慢一些,但可以在休息站吃草获得快乐值。每个休息站有位置 x 和美味值 c。FJ 不停走,Bessie 可以停留吃草,问最大快乐值。

关键条件:

  • Bessie 比 FJ 慢,因此每走一段距离都会产生「可用于吃草的时间差」。
  • 如果后面有一个更美味的休息站,当前较低美味值的站通常不值得停。
  • 应只在从右往左看时美味值创新高的休息站停留。

思路分析

把休息站按位置升序。若某个休息站右侧存在更高美味值的站,那么把时间留到右侧更高美味值处吃草一定不差。因此真正需要停的站,是从右往左扫描时遇到的「后缀最大美味值」站。

算法步骤:

  1. 从右往左标记所有 c 大于右侧最大值的休息站。
  2. 再从左到右经过这些被选中的站。
  3. 上一个停留点到当前停留点的距离为 delta
  4. Bessie 比 FJ 多出来的可吃草时间是 delta * (rF - rB)
  5. 快乐值增加 time * c

CPP 完整代码

✅ 完整代码:Rest Stops
#include <bits/stdc++.h>
using namespace std;

using ll = long long;

struct Stop {
    ll position;
    ll tastiness;
};

int main() {
    ios::sync_with_stdio(false);
    cin.tie(nullptr);

    // USACO 官方文件 I/O 可按需打开:
    // freopen("reststops.in", "r", stdin);
    // freopen("reststops.out", "w", stdout);

    ll trailLength, n, farmerSpeed, bessieSpeed;
    cin >> trailLength >> n >> farmerSpeed >> bessieSpeed;

    vector<Stop> stops(n);
    for (auto &s : stops) cin >> s.position >> s.tastiness;

    vector<bool> chosen(n, false);
    ll bestTastiness = -1;

    // 从右往左选出所有「比右侧都更好」的休息站
    for (int i = n - 1; i >= 0; i--) {
        if (stops[i].tastiness > bestTastiness) {
            chosen[i] = true;
            bestTastiness = stops[i].tastiness;
        }
    }

    ll answer = 0;
    ll lastPosition = 0;
    ll speedGap = farmerSpeed - bessieSpeed;

    for (int i = 0; i < n; i++) {
        if (!chosen[i]) continue;
        ll distance = stops[i].position - lastPosition;
        ll extraTime = distance * speedGap;
        answer += extraTime * stops[i].tastiness;
        lastPosition = stops[i].position;
    }

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

复杂度: 两次线性扫描,O(N);空间 O(N)。如果输入不保证按位置升序,先排序,复杂度为 O(N log N)

易错点提醒

  1. 从左往右贪心选当前最高。 这会错过后面更高美味值的站;正确视角是从右往左看后缀最大。
  2. 把速度差写反。 可吃草时间来自 farmerSpeed - bessieSpeed,因为 FJ 更快。
  3. 忘记更新 lastPosition 每一段额外时间只来自上一次停留点到当前停留点的距离。
  4. 使用 int 存答案。 距离、速度差、美味值相乘可能很大,必须用 long long

拓展思考

Rest Stops 是「未来更优选择支配当前选择」的典型题。只要当前选择的收益率低于未来某个选择,并且时间资源可以转移到未来,就应该跳过当前选择。这类题常用后缀最大值预处理。


⚠️ 第 4.1 章常见错误

  1. 把贪心用在 DP 问题上: 贪心更简单不代表它正确。始终用小反例测试。任意面额的硬币找零是经典陷阱。

  2. 排序标准用错: 活动选择时按开始时间而非结束时间排序是经典 bug。为什么这样排序的论证(交换论证)才告诉你正确的标准。

  3. 重叠判断差一: s >= lastEnd(允许相邻活动)vs s > lastEnd(要求有间隔)。检查题目要求哪种。

  4. 不证明就假设贪心有效: 始终用小例子验证,或简短地交换论证一下。若找不到反例且能草拟贪心选择「安全」的理由,大概率是正确的。

  5. 忘记排序: 贪心算法几乎总是从排序开始。忘记排序意味着贪心「顺序」不存在。

  6. 比较器中整数溢出: 按比率 w/t 排序时,避免浮点比较。用交叉乘法:w_A * t_B > w_B * t_A。乘法前始终强制转换为 long long

  7. 在错误的子问题上贪心: 有些问题看起来像「每次选最优元素」,但「最优」取决于未来上下文。若你在第 i 步的贪心选择改变了第 i+1 步的最优性,很可能需要 DP。


本章总结

📌 核心要点

题目类型贪心策略排序依据时间识别信号
最多不重叠区间选结束最早的区间右端点升序O(N log N)「最多活动/会议」
最少点刺穿所有区间在每个未覆盖区间的右端放点右端点升序O(N log N)「最少箭/传感器覆盖所有」
最少区间覆盖范围每步选最远延伸的左端点升序O(N log N)「最少线段覆盖 [L,R]」
区间合并按左端点排序,扫描合并左端点升序O(N log N)「合并重叠范围」
最小化最大延迟(EDF)最早截止日期优先截止时间升序O(N log N)「最小化最大延迟」
Huffman 编码合并两个最小频率最小堆O(N log N)「最小代价合并 N 堆」
最小化总完成时间(SJF)最短作业优先处理时间升序O(N log N)「最小化加权完成时间总和」
最大数(拼接)比较器:a+b vs b+a自定义比较器O(N log N·L)「排列数字/字符串得最大数」
排列不等式同向最大化,反向最小化两数组都排序O(N log N)「最大/最小化两数组的点积」
双序列匹配两数组排序后双指针匹配两数组都排序O(N log N)「匹配 A[i] 和 B[j] 最大化满足对数」
删除 K 个数字(最小结果)单调栈——栈顶大于当前时弹出不需要排序O(N)「删 K 个数字得最小数」
股票交易(无限次)累加每个正日差不需要排序O(N)「无限买卖,最大利润」
后悔贪心贪心选取 + 在堆中插入后悔节点最大/最小堆O(N log N)「K 次操作,可隐式撤销」
多机调度(LPT)最长作业优先 + 最小堆分配处理时间降序O(N log K)「N 个作业,K 台机器,最小完工时间」
对抗匹配(田忌赛马)最强打最强;否则最弱消耗最强双端指针O(N log N)「双方各自最优分配,最大赢场数」
前缀/后缀双遍扫描从两侧分别扫描,取最大合并无/自定义O(N)「每个元素依赖左侧最小和右侧最大」
位运算贪心(字典树+逐位)在每层贪心选对立位O(N·MAXBIT)「数组中两个元素的最大异或值」

❓ 常见问题

Q1:怎么判断一个问题能不能用贪心解?

A:三个信号:① 排序后有清晰的处理顺序;② 可以用交换论证证明贪心选择永远不比其他选择差;③ 找不到反例。若找到了(如硬币找零 {1,5,6,9}),贪心失败——改用 DP。

Q2:贪心和 DP 的真正区别是什么?

A:贪心在每步做出局部最优选择且从不回头。DP 考虑所有可能的选择,从子问题解构建全局最优。贪心是 DP 的特例——当局部最优恰好等于全局最优时可以用贪心。

Q3:「二分答案 + 贪心检查」模式是什么?

A:当题目问「最小化最大值」或「最大化最小值」时,对答案 X 二分查找,用贪心的 check(X) 验证可行性。参见第 4.2 章的 Convention 题目。

Q4:活动选择为什么按结束时间而非开始时间排序?

A:按结束时间排序确保我们总是选择最早「释放资源」的活动,为后续活动留下最大空间。按开始时间排序可能选到开始很早但结束很晚的活动,阻挡后续所有活动。

Q5:什么时候用后悔贪心而不是普通贪心?

A:当:① 题目允许最多 K 次操作且 K 较小;② 每次操作可以用反向操作「撤销」;③ 普通贪心给出次优答案,因为早期选择阻碍了后来更好的选择。关键思路是在堆中插入 -x 后悔节点,让你以 O(log N) 隐式撤销任何之前的选择。


练习题

题目关键技术难度
4.1.1 会议室 II区间调度 + 最小堆🟡 中等
4.1.2 加油站环形贪心 + 前缀和🔴 困难
4.1.3 最少站台数事件扫描🟡 中等
4.1.4 分数背包比率贪心🟢 简单
4.1.5 跳跃游戏可达性贪心🟡 中等
🏆 挑战题区间刺穿(USACO Silver)🔴 困难

题目 4.1.1 — 会议室 II 🟡 中等

题目: N 场会议,各有开始时间 start[i] 和结束时间 end[i]。找最少需要多少间会议室使所有会议都能无重叠进行。

输入:

3
0 30
5 10
15 20

输出: 2

💡 提示

最少会议室数 = 任意时刻最多重叠的会议数。用最小堆追踪结束时间(每间会议室何时空闲)。对每场新会议,检查最早空闲的会议室能否复用。

✅ 完整题解

核心思路:

开始时间排序会议,最小堆存储每间会议室的结束时间。对每场新会议:

  • 若堆顶(最早结束的会议室)≤ 新会议开始时间 → 复用该会议室
  • 否则 → 开新会议室

堆的最终大小就是答案。

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

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

    int n;
    cin >> n;

    vector<pair<int,int>> meetings(n);
    for (int i = 0; i < n; i++)
        cin >> meetings[i].first >> meetings[i].second;

    sort(meetings.begin(), meetings.end());  // 按开始时间排序

    // 最小堆:存储每间使用中会议室的结束时间
    priority_queue<int, vector<int>, greater<int>> pq;

    for (auto [start, end] : meetings) {
        if (!pq.empty() && pq.top() <= start) {
            // 复用最早空闲的会议室
            pq.pop();
        }
        pq.push(end);  // 该会议室被占用到 'end' 时刻
    }

    cout << pq.size() << "\n";
    return 0;
}
// 时间复杂度:O(N log N)

题目 4.1.2 — 加油站 🔴 困难

题目: N 个加油站排成圆圈。加油站 i 有 gas[i] 升油;从 i 到 i+1 消耗 cost[i] 升。油箱初始为空、容量无限。能否完成整圈?如果能,输出起始站下标(答案唯一)。

示例:

gas  = [1, 2, 3, 4, 5]
cost = [3, 4, 5, 1, 2]

输出:3(从第 3 站出发可以完成整圈)

💡 提示

核心思路:若总油量 ≥ 总耗油量,则恰好存在一个有效起始站。贪心扫描:每当累计油箱降至零以下,将起始站重置为下一站。

✅ 完整题解

两个关键定理:

  1. 可行性:sum(gas) < sum(cost),无解。
  2. 唯一起始站定理: 若有解,每当从站 s 出发的运行油箱变为负数,s 到故障点之间的任何站都不是有效起始点。因此下一个候选站必须紧接在故障点之后。
#include <bits/stdc++.h>
using namespace std;

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

    int n;
    cin >> n;

    vector<int> gas(n), cost(n);
    for (int &x : gas) cin >> x;
    for (int &x : cost) cin >> x;

    int totalTank = 0;  // 总净油量(决定可行性)
    int tank = 0;       // 从 'start' 出发的当前油量
    int start = 0;      // 当前候选起始站

    for (int i = 0; i < n; i++) {
        int gain = gas[i] - cost[i];
        tank += gain;
        totalTank += gain;

        if (tank < 0) {
            // 从 'start' 无法到达 i+1 — 重置
            start = i + 1;
            tank = 0;
        }
    }

    if (totalTank < 0) {
        cout << -1 << "\n";  // 无解
    } else {
        cout << start << "\n";
    }
    return 0;
}
// 时间复杂度:O(N)——单次扫描

题目 4.1.3 — 最少站台数 🟡 中等

题目: N 辆火车,各有到达时间 arr[i] 和离开时间 dep[i]。若火车到达时所有站台都被占用,它必须等待。找最少需要多少个站台使没有火车需要等待。

示例:

arr = [9:00, 9:40, 9:50, 11:00, 15:00, 18:00]
dep = [9:10, 12:00, 11:20, 11:30, 19:00, 20:00]

输出:3

💡 提示

双指针/事件扫描:将所有到达(+1)和出发(-1)事件合并为一个排序列表,扫描时维护当前站台数。峰值就是答案。注意:相同时间时,出发事件先于到达事件处理(离开的火车先让出站台再占用)。

✅ 完整题解

方法一:事件扫描(推荐)

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

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

    int n;
    cin >> n;

    vector<pair<int,int>> events;  // {时间, 类型}:类型=0 出发,类型=1 到达
    for (int i = 0; i < n; i++) {
        int a, d;
        cin >> a >> d;
        events.push_back({a, 1});   // 到达
        events.push_back({d, 0});   // 出发(类型=0 < 1,相同时间出发先处理)
    }

    sort(events.begin(), events.end());

    int platforms = 0, maxPlatforms = 0;
    for (auto [time, type] : events) {
        if (type == 1) platforms++;   // 火车到达
        else platforms--;             // 火车出发
        maxPlatforms = max(maxPlatforms, platforms);
    }

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

方法二:双指针(经典)

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

int main() {
    int n;
    cin >> n;

    vector<int> arr(n), dep(n);
    for (int &x : arr) cin >> x;
    for (int &x : dep) cin >> x;

    sort(arr.begin(), arr.end());
    sort(dep.begin(), dep.end());

    int platforms = 1, maxPlatforms = 1;
    int i = 1, j = 0;

    while (i < n && j < n) {
        if (arr[i] <= dep[j]) {
            platforms++;
            i++;
        } else {
            platforms--;
            j++;
        }
        maxPlatforms = max(maxPlatforms, platforms);
    }

    cout << maxPlatforms << "\n";
    return 0;
}
// 时间复杂度:O(N log N)

题目 4.1.4 — 分数背包 🟢 简单

题目: N 件物品,物品 i 重量 w[i]、价值 v[i],背包容量 W。可以取任意分量。最大化总价值。

示例:

N=3, W=50
物品:(w=10, v=60), (w=20, v=100), (w=30, v=120)

输出:240.0

💡 提示

贪心有效是因为允许取分量。按价值/重量比(单位价值)降序排序,尽可能多地取比率最高的物品直到背包装满。

✅ 完整题解
#include <bits/stdc++.h>
using namespace std;

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

    int n;
    double W;
    cin >> n >> W;

    vector<pair<double,double>> items(n);  // {价值, 重量}
    for (int i = 0; i < n; i++)
        cin >> items[i].second >> items[i].first;

    // 按单位价值(v/w)降序排序
    sort(items.begin(), items.end(), [](const auto &a, const auto &b) {
        return a.first / a.second > b.first / b.second;
    });

    double totalValue = 0.0;
    double remaining = W;

    for (auto [v, w] : items) {
        if (remaining <= 0) break;
        if (w <= remaining) {
            totalValue += v;
            remaining -= w;
        } else {
            totalValue += v * (remaining / w);
            remaining = 0;
        }
    }

    cout << fixed << setprecision(2) << totalValue << "\n";
    return 0;
}
// 时间复杂度:O(N log N)

题目 4.1.5 — 跳跃游戏 🟡 中等

题目: 给定非负整数数组 A,从索引 0 出发,在位置 i 可以向前跳最多 A[i] 步。判断是否能到达最后一个索引(n-1)。

示例:

A = [2, 3, 1, 1, 4] → true  (0→1→4)
A = [3, 2, 1, 0, 4] → false (过不了位置 3)
💡 提示

维护 farthest = 目前可到达的最远下标。扫描每个可达位置,更新 farthest。若某时刻 i > farthest,位置 i 不可达——返回 false。

✅ 完整题解
#include <bits/stdc++.h>
using namespace std;

bool canJump(vector<int>& A) {
    int n = A.size();
    int farthest = 0;

    for (int i = 0; i < n; i++) {
        if (i > farthest) return false;
        farthest = max(farthest, i + A[i]);
        if (farthest >= n - 1) return true;
    }
    return true;
}

int main() {
    int n;
    cin >> n;
    vector<int> A(n);
    for (int &x : A) cin >> x;
    cout << (canJump(A) ? "true" : "false") << "\n";
}

为什么贪心正确: 每步不选具体跳跃——只是追踪从当前所有可达位置出发,所有可能跳跃的并集。这等价于同时考虑所有可能的跳跃路径。


🏆 挑战题:USACO 2016 February Silver——区间刺穿

题目: FJ 有 N 段围栏,各定义为数轴上的 [L_i, R_i]。找最少的「锚点」数,使每段围栏都至少包含一个锚点。

✅ 完整题解

贪心策略:右端点升序排序。维护 lastPoint(最后一个锚点的位置,初始 −∞)。对每段:若 lastPoint 不在 [L_i, R_i] 内(即 lastPoint < L_i),在 R_i 放一个新锚点(尽量靠右以覆盖更多后续区间)。

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

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

    int n;
    cin >> n;

    vector<pair<int,int>> segs(n);  // {右端点, 左端点}
    for (int i = 0; i < n; i++) {
        int l, r;
        cin >> l >> r;
        segs[i] = {r, l};
    }

    sort(segs.begin(), segs.end());  // 按右端点排序

    int count = 0;
    long long lastPoint = LLONG_MIN;

    for (auto [r, l] : segs) {
        if (lastPoint < l) {
            // 当前锚点不覆盖该段——放新锚点
            lastPoint = r;   // 放在右端以覆盖尽量多的后续区间
            count++;
        }
    }

    cout << count << "\n";
    return 0;
}
// 时间复杂度:O(N log N)

与活动选择的联系: 区间刺穿和最多不重叠区间是对偶问题:最少刺穿点数 = 最多不重叠区间数(区间调度的 König 定理)。两者都按右端点排序,代码结构几乎相同。

📖 第 4.2 章 ⏱️ 约 60 分钟 🎯 进阶

第 4.2 章:USACO 中的贪心

能用贪心解决的 USACO 题目是最令人满足的——一旦看到那个洞察,代码几乎自己就写出来了。本章通过几道以贪心为关键的 USACO 风格题目来实战演练。


4.2.1 模式识别:这是贪心题吗?

识别贪心问题是最难的部分——它看起来像 DP,闻起来像 DP,但有特殊结构让你能做出局部决策。以下是实用框架。

三问检验法

编码前问自己:

  1. 能用某种聪明的方式对输入排序吗? 大多数贪心算法从排序开始。若能找到一个自然顺序(按截止时间、结束时间、比率、自定义比较器),你很可能在贪心的正确轨道上。

  2. 每一步有「自然的」贪心选择吗? 总能找到一个「当下显然最好」的元素/决策,并能论证选它不会关闭更好的未来选项吗?

  3. 能构造交换论证吗? 若任意两个相邻选择「不符合贪心顺序」,交换它们不会使解变差吗?如果是,通过冒泡排序推理,贪心顺序是最优的。

三个都是 → 尝试贪心。若找到反例 → 改用 DP


USACO 贪心模式分类

理解一道题属于哪个模式通常是关键洞察:

模式触发词/结构排序依据例子
活动选择「最多不重叠区间」右端点升序USACO Bronze 调度
EDF 调度「最小化最大延迟/截止时间」截止时间升序Convention II(变体)
SJF / 完成时间「最小化总等待/完成时间」处理时间升序奶牛排序(相邻交换)
贪心 + 二分「最小化最大值」或「最大化最小值」二分答案USACO Convention、Haybales
双指针匹配两个有序数组,最大化匹配对数两数组都排序Paired Up、分发饼干
扫描线/模拟带时间戳的事件、容量约束事件时间奶牛信号、会议室
后悔贪心「选 K 个元素且决策可取消」最大堆 + 后悔节点USACO Gold 进阶题
自定义比较器「按最优顺序排列 N 个元素」a+b vs b+a,w/t 比率最大数,SJF

红色警报:贪心失败的信号

警惕这些表明贪心不奏效的迹象:

  • 物品/选择有权重: 若选一个物品会排除多个有不同合并价值的其他物品,贪心往往失败。用 DP。(0/1 背包,加权区间调度)
  • 决策非局部交互: 若现在选元素 i 会以非平凡的方式影响两步之后哪些元素可用。(最长递增子序列——贪心给出错误答案)
  • 你能构造 3 元素反例: 始终在小输入上测试:N=2 和 N=3。若 N=3 破坏了你的贪心,它就是错的。

⚠️ USACO 竞赛提示: Silver/Gold 级别的贪心题几乎总是需要正确性证明草稿——无论是交换论证还是二分搜索的单调性论证。若你无法用 2 句话草拟贪心的有效性,要更加谨慎。


4.2.2 USACO Bronze:奶牛排序

题目: N 头奶牛站成一排,奶牛 i 的「暴躁值」为 g[i]。想排序这一排使暴躁值严格递增。唯一允许的操作是交换两头相邻的奶牛。交换第 i 和 j 位置的奶牛(相邻)时,代价为 g[i] + g[j]。求排序所需的最小总代价

样例输入:

3
3 1 2

样例输出:

9

逆序对方案(O(N²)):

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

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

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

    // 总代价 = 每对逆序对 (i,j)(i<j,g[i]>g[j])的 g[i]+g[j] 之和
    long long totalCost = 0;
    for (int i = 0; i < n; i++) {
        for (int j = i + 1; j < n; j++) {
            if (g[i] > g[j]) {
                totalCost += g[i] + g[j];  // 这个逆序对的代价
            }
        }
    }

    cout << totalCost << "\n";
    return 0;
}
// 时间:O(N²) — N ≤ 10^5 时用归并排序逆序对计数(O(N log N))

验证: 对 [3,1,2] 的冒泡排序:

  • 交换(3,1) = 代价 4 → [1,3,2]
  • 交换(3,2) = 代价 5 → [1,2,3]
  • 总计 = 9

4.2.3 USACO Bronze:奶牛信号(贪心模拟)

许多 USACO Bronze 题是带贪心技巧的纯模拟:按时间顺序处理事件,在每步贪心维护最优状态。关键是确定模拟什么以及按什么顺序

题目: N 头奶牛各想从牛棚出发到达牧场。奶牛 i 准备在时间 t[i] 离开。牛棚和牧场之间的路同时最多容纳 C 头奶牛,过路恰好需要 1 个时间单位。路满时奶牛在牛棚等待。假设尽早送奶牛出发,最后一头奶牛何时到达

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

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

    int n, c;
    cin >> n >> c;

    vector<int> t(n);
    for (int &x : t) cin >> x;
    sort(t.begin(), t.end());  // 按出发时间顺序处理

    int ans = 0;
    // 每 c 头奶牛一组处理
    for (int i = 0; i < n; i += c) {
        // 这批奶牛中最早的是 t[i]
        ans = max(ans, t[i]);  // 这批出发时间至少要等最早的奶牛准备好
        ans++;  // 过路需要 1 个时间单位
    }

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

4.2.4 USACO Silver:配对

题目: 你有 A 组 N 头奶牛和 B 组 N 头奶牛。必须将 A 中每头奶牛与 B 中恰好一头配对(一对一)。奶牛 a 与奶牛 b 配对的利润min(a, b)。最大化 N 对的总利润

样例输入:

3
1 3 5
2 4 6

样例输出:

9

追踪(两数组升序排序后按下标配对):

A 排序后:[1, 3, 5]
B 排序后:[2, 4, 6]

配对 (1,2):min=1
配对 (3,4):min=3
配对 (5,6):min=5
总计 = 1+3+5 = 9 ✓

为什么同向排序? 交换论证:若某个配对中 a₁ < a₂b₁ > b₂ 配对(A 升序但 B 不是),交换到同向配对总是不差的(见第 4.1.7 节排列不等式)。

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

int main() {
    int n;
    cin >> n;

    vector<int> A(n), B(n);
    for (int &x : A) cin >> x;
    for (int &x : B) cin >> x;

    sort(A.begin(), A.end());
    sort(B.begin(), B.end());

    long long total = 0;
    for (int i = 0; i < n; i++) {
        total += min(A[i], B[i]);  // 第 i 小的与第 i 小的配对
    }

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

4.2.5 USACO Silver:Convention(二分 + 贪心)

题目(USACO 2018 February Silver): N 头奶牛在时间 t[1..N] 到达公共汽车站。有 M 辆公共汽车,每辆最多容纳 C 头奶牛。将奶牛分配给公共汽车,最小化任意奶牛的最大等待时间。

做法:二分答案 + 贪心检查。

Convention — Binary Search + Greedy Check

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

int n, m, c;
vector<long long> cows;   // 按到达时间排序

// 最大等待时间 <= maxWait 时能否调度所有奶牛?
bool canDo(long long maxWait) {
    int busesUsed = 0;
    int i = 0;  // 当前奶牛下标

    while (i < n) {
        busesUsed++;
        if (busesUsed > m) return false;  // 公共汽车不够用了

        // 该车从奶牛 i 开始服务
        // 车必须在 cows[i] + maxWait 之前出发
        long long depart = cows[i] + maxWait;

        // 尽量多地装奶牛(容量 c,到达时间 ≤ 出发时间)
        int count = 0;
        while (i < n && count < c && cows[i] <= depart) {
            i++;
            count++;
        }
    }

    return true;
}

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

    cin >> n >> m >> c;
    cows.resize(n);
    for (long long &x : cows) cin >> x;
    sort(cows.begin(), cows.end());

    // 对最大等待时间二分
    long long lo = 0, hi = 1e14;
    while (lo < hi) {
        long long mid = lo + (hi - lo) / 2;
        if (canDo(mid)) hi = mid;
        else lo = mid + 1;
    }

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

4.2.6 USACO Bronze:放牧(贪心观察)

题目: 三头奶牛站在数轴上不同的整数位置 abc。每次移动可以选任意一头奶牛,将它传送到任意空的整数位置。找让三头奶牛处于三个连续整数位置(如 {k, k+1, k+2})所需的最少移动次数

样例输入: 4 7 9输出: 1(将 9 移到 5 或 6,或将 4 移到 8:{7,8,9})

样例输入 2: 1 10 100输出: 2

贪心洞察: 排序后 a ≤ b ≤ c

  • 0 次移动当且仅当 c - a == 2(已是连续)
  • 2 次移动始终够用(上界)
  • 1 次移动可行当以下任一成立:
    • c - b == 1c - b == 2(b 和 c 相邻或差 1)
    • b - a == 1b - a == 2(a 和 b 相邻或差 1)
    • c - a == 3(移动 b 到 a+1 或 c-1 使三者连续)
📄 C++ 完整代码
#include <bits/stdc++.h>
using namespace std;

int main() {
    long long a, b, c;
    cin >> a >> b >> c;

    long long pos[3] = {a, b, c};
    sort(pos, pos + 3);
    a = pos[0]; b = pos[1]; c = pos[2];

    // 0 次移动:已是连续
    if (c - a == 2) { cout << 0; return 0; }

    // 1 次移动:检查各情况
    bool one_move = false;

    // (b,c) 对保留:移动 a
    if (c - b == 1 || c - b == 2) one_move = true;
    // (a,b) 对保留:移动 c
    if (b - a == 1 || b - a == 2) one_move = true;
    // (a,c) 对保留:移动 b(需 c-a==3)
    if (c - a == 3) one_move = true;

    if (one_move) { cout << 1; return 0; }

    cout << 2;
    return 0;
}

4.2.7 USACO 中常见的贪心模式

模式描述排序依据
活动选择最多不重叠区间结束时间
调度最小化完成时间/延迟截止时间或比率
贪心 + 二分检查可行性,用二分找最优各种
配对最优匹配两个有序列表两个数组
模拟按时间顺序处理事件事件时间
扫描线扫描时维护活跃集合开始/结束事件

本章总结

📌 核心要点

USACO 中的贪心算法通常涉及:

  1. 排序输入(以某种聪明的方式)
  2. 用简单的更新规则扫描一次(或两次)
  3. 偶尔与二分答案结合使用

❓ 常见问题

Q1:「二分答案 + 贪心检查」的模板是什么?

A:外层:对答案 X 二分(lo=最小可能,hi=最大可能)。内层:编写 check(X) 函数,用贪心策略验证 X 是否可行,根据结果调整 lo/hi。关键要求是 check 必须是单调的(若 X 可行,X+1 也可行,或反过来)。

Q2:USACO 贪心题和 LeetCode 贪心题有什么不同?

A:USACO 贪心题通常需要正确性证明(交换论证),且常与二分搜索和排序结合。LeetCode 倾向于更简单的「总是选最大/最小」贪心。USACO Silver 贪心题明显比 LeetCode Medium 更难。

Q3:什么时候用 priority_queue 辅助贪心?

A:当需要反复提取「当前最优」元素时(如 Huffman 编码、最小会议室数、反复取最大/最小值)。priority_queue 将「找最优」从 O(N) 降到 O(log N)。

🔗 与其他章节的联系

  • 第 4.1 章涵盖了贪心的理论和交换论证;本章将它们应用到真实的 USACO 题目
  • 第 3.3 章(二分搜索)介绍了直接在 Convention 题中用到的「二分答案」模式
  • 第 7.1 章(理解 USACO)和第 7.2 章(解题策略)将进一步讨论如何在竞赛中识别贪心 vs DP
  • 第 3.1 章(STL)介绍了 priority_queue,在本章的贪心模拟中频繁出现

练习题


题目 4.2.1 — USACO 2016 December Bronze:统计干草堆

题目: N 捆干草放在数轴上(位置可重复)。Q 次查询:范围 [L, R] 内有多少捆干草?

示例:

N=7, Q=4
位置:6 3 2 7 5 1 4
查询:2 5 / 1 1 / 4 8 / 10 15

输出:

4
1
4
0
💡 提示

排序位置,然后用 lower_bound / upper_bound 二分搜索统计 [L, R] 内的元素。这道题练习「排序 + 二分搜索」思维——大多数贪心算法的第一步。

✅ 完整题解

排序后,[L, R] 内的数量 = upper_bound(R) - lower_bound(L)

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

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

    int n, q;
    cin >> n >> q;

    vector<int> pos(n);
    for (int &x : pos) cin >> x;
    sort(pos.begin(), pos.end());  // 关键:先排序以支持二分搜索

    while (q--) {
        int l, r;
        cin >> l >> r;

        auto lo = lower_bound(pos.begin(), pos.end(), l);
        auto hi = upper_bound(pos.begin(), pos.end(), r);

        cout << (hi - lo) << "\n";
    }

    return 0;
}
// 时间复杂度:O(N log N + Q log N)

题目 4.2.2 — USACO 2019 February Bronze:睡觉奶牛排序

题目: N 头奶牛编号 1~N 以随机顺序站成一排。每次操作:取出排尾的奶牛,将其插入任意位置。最少几次操作能让队伍变为 1, 2, ..., N 的顺序?

示例:

N=5
顺序:1 4 2 5 3

输出:4

💡 提示

核心思路: 找队尾已经有序的最长后缀(连续的 k, k+1, ..., N)。这些奶牛不需要移动。答案 = N − 后缀长度。

✅ 完整题解
#include <bits/stdc++.h>
using namespace std;

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

    int n;
    cin >> n;

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

    // 找最长已排序后缀:cows[i], cows[i+1], ..., cows[n-1]
    // 条件:cows[i] + 1 == cows[i+1](连续递增)
    int keep = 1;  // 至少最后一头奶牛留下
    for (int i = n - 2; i >= 0; i--) {
        if (cows[i] + 1 == cows[i + 1]) {
            keep++;
        } else {
            break;  // 后缀必须连续——遇到第一个断点就停
        }
    }

    cout << n - keep << "\n";
    return 0;
}
// 时间复杂度:O(N)

题目 4.2.3 — 任务调度器 🟡 中等

题目: N 个标有 A~Z 的任务,每个需要 1 个时间单位。执行标有 X 的任务后,CPU 必须等待至少 k 个时间单位才能再次执行 X(期间可运行其他任务或空闲)。找完成所有任务的最短总时间。

示例:

tasks = [A, A, A, B, B, B], k = 2

输出:8(A→B→空闲→A→B→空闲→A→B)

💡 提示

关键公式:ans = max(任务总数, (maxCount-1)*(k+1) + countMax)

其中 maxCount = 出现次数最多的任务的频率。

贪心策略:每个「帧」(每 k+1 个时间单位)先填最频繁的剩余任务。

✅ 完整题解
#include <bits/stdc++.h>
using namespace std;

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

    int n, k;
    cin >> n >> k;

    vector<int> freq(26, 0);
    for (int i = 0; i < n; i++) {
        char c; cin >> c;
        freq[c - 'A']++;
    }

    int maxCount = *max_element(freq.begin(), freq.end());
    int countMax = count(freq.begin(), freq.end(), maxCount);

    int ans = max(n, (maxCount - 1) * (k + 1) + countMax);

    cout << ans << "\n";
    return 0;
}
// 时间复杂度:O(N)

题目 4.2.4 — USACO 2018 February Silver:Convention II 🔴 困难

题目: N 头奶牛按资历排序(下标越小资历越高)。奶牛 i 在时间 a[i] 到达饮水处,喝水需要 t[i] 个时间单位(设备每次服务一头奶牛)。设备空闲时,等待的奶牛中资历最高(下标最小)的先喝水。求所有奶牛中最大等待时间。

💡 提示

用优先队列(按资历下标最小堆)模拟。维护已到达但未喝水的奶牛的「等待队列」。每次设备空闲时,从等待队列中取资历最高(下标最小)的奶牛。

✅ 完整题解
#include <bits/stdc++.h>
using namespace std;

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

    int n;
    cin >> n;

    // {到达时间, 原始下标(资历), 喝水时长}
    vector<tuple<int,int,int>> cows(n);
    for (int i = 0; i < n; i++) {
        int a, t;
        cin >> a >> t;
        cows[i] = {a, i, t};  // 原始下标 i = 资历(0 = 最高资历)
    }

    sort(cows.begin(), cows.end());  // 按到达时间排序

    // 最小堆:{资历下标, 到达时间, 喝水时长}——下标最小优先级最高
    priority_queue<tuple<int,int,int>, vector<tuple<int,int,int>>, greater<>> waiting;

    int curTime = 0;  // 设备下次空闲的时间
    int maxWait = 0;
    int idx = 0;

    while (idx < n || !waiting.empty()) {
        // 将所有在 curTime 之前到达的奶牛加入等待队列
        while (idx < n && get<0>(cows[idx]) <= curTime) {
            auto [a, seniority, t] = cows[idx];
            waiting.push({seniority, a, t});
            idx++;
        }

        if (waiting.empty()) {
            curTime = get<0>(cows[idx]);
            continue;
        }

        // 服务资历最高(下标最小)的等待奶牛
        auto [seniority, arrTime, drinkTime] = waiting.top();
        waiting.pop();

        int waitTime = curTime - arrTime;
        maxWait = max(maxWait, waitTime);
        curTime += drinkTime;
    }

    cout << maxWait << "\n";
    return 0;
}
// 时间复杂度:O(N log N)

题目 4.2.5 — 加权工作调度 🔴 困难(贪心失败→用 DP)

题目: N 个工作,各有开始时间 s[i]、结束时间 e[i] 和利润 p[i]。选择一组不重叠的工作以最大化总利润。

示例:

N=4
工作:(s=1,e=3,p=50), (s=2,e=5,p=10), (s=4,e=6,p=40), (s=6,e=7,p=70)

输出:160(工作 1 + 3 + 4)

✅ 完整题解(包含贪心失败分析)

为什么贪心失败?

  • 按利润排序取最大值?不行。反例:(s=1,e=10,p=100) vs (s=1,e=3,p=50)+(s=4,e=7,p=60)——后者总计 110。
  • 按最早结束时间?不行。贪心可能选利润为 1 的短工作,错过利润 100 的长工作。
  • 贪心无法同时优化「早结束」和「最大利润」。这是加权区间调度——DP 问题。

DP 做法:

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

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

    int n;
    cin >> n;

    vector<tuple<int,int,int>> jobs(n);  // {结束时间, 开始时间, 利润}
    for (int i = 0; i < n; i++) {
        int s, e, p;
        cin >> s >> e >> p;
        jobs[i] = {e, s, p};
    }

    sort(jobs.begin(), jobs.end());  // 按结束时间排序

    vector<int> ends;
    for (auto [e, s, p] : jobs) ends.push_back(e);

    vector<long long> dp(n + 1, 0);  // dp[i]:前 i 个工作的最大利润(1-indexed)

    for (int i = 1; i <= n; i++) {
        auto [e, s, p] = jobs[i - 1];

        // 二分搜索:找最后一个结束时间 <= s[i] 的工作(不重叠)
        int lo = 0, hi = i - 1;
        while (lo < hi) {
            int mid = (lo + hi + 1) / 2;
            if (ends[mid - 1] <= s) lo = mid;
            else hi = mid - 1;
        }

        dp[i] = max(dp[i - 1], dp[lo] + p);  // 跳过 vs 选取
    }

    cout << dp[n] << "\n";
    return 0;
}
// 时间复杂度:O(N log N)

教训: 选择不重叠区间时,若区间有权重(利润),贪心不起作用——用 DP。只有当所有权重相等(最大化数量)时,才能简化为贪心。

🕸️ 第五部分:图论算法

学会在题目中看见图并高效解决它。BFS、DFS、树、并查集和 Kruskal 最小生成树——USACO Silver 的核心。

📚 4 章 · ⏱️ 预计 2-3 周 · 🎯 目标:达到 USACO Silver 水平

第五部分:图论算法

预计用时:2-3 周

图在竞赛编程中无处不在:迷宫、网络、家族树、城市地图。第五部分教你看见题目中的图并高效解决它们。


涵盖的主题

章节主题核心思想
第 5.1 章图的基础表示图;邻接表;图的类型
第 5.2 章BFS 与 DFS遍历、最短路径、洪水填充、连通分量、10 种变种
第 5.3 章最短路径Dijkstra、Bellman-Ford、Floyd-Warshall、SPFA、Johnson
第 5.4 章二叉树与树算法BST、遍历、LCA(朴素+倍增)、欧拉序、树的直径
第 5.5 章并查集路径压缩+按秩合并、Kruskal MST、带权并查集、种类并查集
第 5.6 章线段树区间查询/更新、懒惰传播、动态开点
第 5.7 章树状数组(BIT)前缀和、区间查询、权值BIT

学完本部分后能解决什么问题

完成第五部分后,你将能够挑战:

  • USACO Bronze:

    • 洪水填充(统计网格中连通区域数量)
    • 可达性问题(奶牛 A 能到达奶牛 B 吗?)
    • 网格/图中的简单 BFS 最短路径
  • USACO Silver:

    • 隐式图上的 BFS/DFS(状态节点而非显式节点)
    • 多源 BFS(到最近障碍物/火焰的距离)
    • 动态连通性的并查集
    • 边添加下的图连通性
    • 树的问题(子树求和、深度、LCA)

引入的关键算法

技术章节时间复杂度USACO 相关度
DFS(递归和迭代)5.2O(V + E)连通性、环检测
BFS5.2O(V + E)最短路径(无权)
网格 BFS5.2O(R × C)迷宫问题、洪水填充
多源 BFS5.2O(V + E)到最近源点的距离
连通分量5.2O(V + E)统计不连通区域数量
树的遍历(前序/后序)5.3O(N)子树聚合
并查集(DSU)5.3O(α(N)) ≈ O(1)动态连通性
Kruskal 最小生成树5.3O(E log E)最小生成树
Dijkstra 算法5.4O((V + E) log V)非负权图的单源最短路
Bellman-Ford5.4O(V × E)含负边的单源最短路;负环检测
Floyd-Warshall5.4O(V³)小图的全对最短路
SPFA5.4O(V × E) 最坏有队列优化的实用 Bellman-Ford

前置条件

开始第五部分前,请确认你能做到:

  • 使用 vector<vector<int>> 存储邻接表(第 2.3–3.1 章)
  • 使用 STL 中的 queuestack(第 3.1、3.5 章)
  • 处理二维数组和网格遍历(第 2.3 章)
  • 理解基本的嵌套循环(第 2.2 章)
  • 使用 priority_queue(第 3.1 章)——第 5.3 章(Dijkstra)需要

本部分学习建议

  1. 第 5.1 章主要是准备工作——阅读以了解图的表示,但真正的算法从第 5.2 章开始。
  2. 第 5.2 章(BFS) 是 USACO Silver 最重要的章节之一。约 1/3 的 Silver 题目涉及网格 BFS。
  3. BFS 中 dist[v] == -1 表示未访问的模式是关键。永远不要在弹出时标记访问——要在压入时标记。
  4. 第 5.5 章的并查集对连通性问题比 BFS 更快编码。记住那个 15 行的模板——你会经常用到它。
  5. 第 5.3 章(Dijkstra) 对加权最短路径问题至关重要。用带 priority_queue<pair<int,int>> 的标准模板——这是 Silver/Gold 最常见的图算法。

💡 核心思路: 大多数 USACO 图论题实际上是伪装成网格题。网格单元 (r,c) 变成图节点;相邻单元变成边。对这个隐式图做 BFS 就能找到最短路径。

🏆 USACO 技巧: 每当在题目中看到「最短路径」「最少步数」或「最少移动次数」,立刻想到 BFS。每当看到「这两个连通吗?」或「有多少组?」,想到 DSU。

📖 第 5.1 章 ⏱️ 约 75 分钟 🎯 中级

第 5.1 章:图的基础

📝 前置条件: 熟悉数组、向量和基础 C++(第 2–4 章)。了解 struct(第 2.4 章)和 vectorpair 等 STL 容器(第 3.1 章)有帮助。

把图想象成一张地图:城市是节点,城市间的道路是。图是竞赛编程中最通用的数据结构——它可以模拟人与人之间的友谊、任务间的依赖关系、迷宫中的单元格等等。在 USACO 中,Silver 级别及以上的几乎每道题都以某种形式涉及图。

本章教你如何用图的视角思考,更重要的是用代码存储图。学完后,你能读取任何 USACO 图的输入,毫不犹豫地选择正确的表示方式。


5.1.1 什么是图?

G = (V, E) 由两个集合组成:

  • 顶点 V(也称节点):「东西」——城市、奶牛、单元格、状态
  • E:它们之间的连接——道路、友谊、转换

|V| = N 表示顶点数,|E| = M 表示边数。

图如何存储?

在深入术语之前,先快速预览图在代码中如何存储。有两种主要方式(详见 §5.1.2):

邻接表 —— 对每个顶点,存储其邻居列表。这是最常见的表示方式:

adj[0] = {1, 2}        ← 节点 0 连接到 1 和 2
adj[1] = {0, 2, 3}     ← 节点 1 连接到 0、2 和 3
adj[2] = {0, 1, 4}     ← 节点 2 连接到 0、1 和 4
adj[3] = {1, 4}         ← 节点 3 连接到 1 和 4
adj[4] = {2, 3}         ← 节点 4 连接到 2 和 3

邻接矩阵 —— 用 V×V 的网格,adj[u][v] = 1 表示「u 和 v 之间有边」:

adj:   0  1  2  3  4
  0  [ 0  1  1  0  0 ]     ← 节点 0 连接到 1 和 2
  1  [ 1  0  1  1  0 ]     ← 节点 1 连接到 0、2 和 3
  2  [ 1  1  0  0  1 ]     ← 节点 2 连接到 0、1 和 4
  3  [ 0  1  0  0  1 ]     ← 节点 3 连接到 1 和 4
  4  [ 0  0  1  1  0 ]     ← 节点 4 连接到 2 和 3

💡 快速对比: 邻接表内存更少、遍历更快——95% 的情况用它。邻接矩阵支持 O(1) 边查询——V 较小(≤ 1500)或用于 Floyd-Warshall 等算法时有用。

以下两张图展示了同一个 5 节点图用邻接表和邻接矩阵存储的方式:

Graph Basics — Adjacency List

Graph Adjacency List Detail

Graph Basics — Adjacency Matrix

矩阵中,绿色 1 = 边存在,灰色 0 = 无边。对角线上的格子始终为 0(无自环)。注意矩阵是对称的——因为这是无向图。

关键术语

现在不必全部记住——快速浏览,需要时回头查看。

术语定义示例
度(Degree)连接到一个顶点的边数节点 2 的度为 3
路径(Path)由边连接的顶点序列1 → 2 → 4 → 6
环(Cycle)起点和终点相同的路径1 → 2 → 3 → 1
连通(Connected)每个顶点都能到达其他所有顶点一个连通分量
分量(Component)最大连通子图节点的「簇」
稀疏(Sparse)边少:M = O(V)道路网络
稠密(Dense)边多:M = O(V²)完全图

握手引理

所有顶点度数之和等于 2M(边数的两倍)。

证明:每条边 (u, v) 对 deg(u) 和 deg(v) 各贡献 +1。

推论: 任何图中奇数度顶点的数量始终是偶数。这可以立即排除题目中的不可能情况。

图的类型

图有多种形式,以下是最常见的:

类型描述USACO 频率
无向图A–B 边意味着 B–A 也存在;道路、牧场连接⭐⭐⭐ 非常常见
有向图(Digraph)A→B 不意味着 B→A;依赖关系、流⭐⭐ 常见(Gold+)
加权图每条边有数值代价;道路距离⭐⭐⭐ 常见(Silver+)
连通、无环、恰好 N−1 条边⭐⭐⭐ 各级别非常常见
DAG有向无环图;存在拓扑序⭐⭐ DP 状态常见
网格图单元格为节点,4 方向边;迷宫⭐⭐⭐ Bronze/Silver 最常见
完全图 K_n每对顶点都连接;N(N−1)/2 条边⭐ 罕见;通常是理论题
二部图两色顶点,边只在组间⭐⭐ 匹配、2-染色

USACO 实际情况: Bronze/Silver 主要用无权无向图和网格图。加权图出现在 Silver 最短路中。树出现在所有级别。Gold 引入 DAG、有向图和稠密图。

有向图 vs 无向图对比

DAG 有向无环图与拓扑排序

拓扑排序:Kahn 算法(BFS 入度法)

二部图 2-染色验证


5.1.2 图的表示

现在你知道图是什么了,接下来的问题是:如何用代码存储它? 这是图问题中最关键的编码决策。有三种主要表示方式,各有不同的权衡。选错会导致 TLE 或 MLE。


表示方式一:邻接表——默认选择

对每个顶点,存储其邻居列表。这是 95% 的 USACO 题目的首选

📄 对每个顶点,存储其邻居列表。这是 **95% 的 USACO 题目的首选**。
#include <bits/stdc++.h>
using namespace std;

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

    int n, m;          // n 个顶点(1..n),m 条边
    cin >> n >> m;

    // adj[u] = 与 u 直接相连的顶点列表
    vector<vector<int>> adj(n + 1);  // 大小 n+1 以使用 1-indexed 顶点

    for (int i = 0; i < m; i++) {
        int u, v;
        cin >> u >> v;
        adj[u].push_back(v);  // 无向图:两个方向都要加
        adj[v].push_back(u);
    }

    // 遍历顶点 u 的所有邻居:O(deg(u))——最优
    for (int u = 1; u <= n; u++) {
        cout << u << " -> ";
        for (int v : adj[u]) cout << v << " ";
        cout << "\n";
    }

    return 0;
}

属性:

  • 空间: O(V + E) —— 最优。对 V = 10^5、E = 2×10^5 轻松放进 256 MB。
  • 遍历邻居: O(deg(u)) —— 只访问实际邻居,没有浪费工作。
  • 检查边 (u, v): O(deg(u)) —— 必须扫描邻居列表。(这是弱点。)
  • 缓存性能: vector 使用连续内存 → 比链表快 5–10 倍。

表示方式二:邻接矩阵

邻接矩阵将图表示为二维数组,条目 adj[u][v] 回答:「从 u 到 v 的边存在吗?」

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

const int MAXV = 1001;

// 关键:声明为全局变量!
// 局部的 bool[1001][1001] 在栈上占 ~1 MB → 栈溢出崩溃。
// 全局变量存储在 BSS 段,自动初始化为零。
bool adj[MAXV][MAXV];

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

    int n, m;
    cin >> n >> m;

    for (int i = 0; i < m; i++) {
        int u, v;
        cin >> u >> v;
        adj[u][v] = true;   // 无向图:两个方向都要设置
        adj[v][u] = true;
    }

    // O(1) 边查询——邻接矩阵的杀手特性
    if (adj[2][3]) {
        cout << "边 2-3 存在\n";
    }

    return 0;
}

⚠️ 关键:始终使用全局数组。 局部的 bool adj[1001][1001] 消耗约 1 MB 栈空间——通常会崩溃。全局数组在 BSS/数据段中,没有栈大小限制,自动初始化为零。

空间:什么时候可以用邻接矩阵?

V =   100  →  bool[100][100]   =   10 KB   ✅ 没问题
V =   500  →  bool[500][500]   =  250 KB   ✅ 没问题
V =  1000  →  bool[1000][1000] =    1 MB   ✅ 没问题
V =  1500  →  bool[1500][1500] = 2.25 MB   ✅ 没问题
V =  3000  →  bool[3000][3000] =    9 MB   ⚠️ 临界(256 MB 限制)
V = 10000  →  bool[10k][10k]   =  100 MB   ❌ 超出典型限制
V = 10^5   →  bool[10^5][10^5] =   10 GB   ❌ 不可能

经验法则: V ≤ ~1500 时安全。V > 2000 时切换到邻接表。

完整对比

操作邻接矩阵邻接表
空间O(V²)O(V + E)
检查边 (u, v)O(1)O(deg(u))
遍历 u 的所有邻居O(V) 扫整行O(deg(u))
添加边O(1)O(1) 均摊
V ≤ 1000 时最佳✅ 若需 O(1) 边查询✅ 始终可用
V = 10^5 时最佳❌ 内存太大✅ 必须用
Floyd-Warshall✅ 自然格式❌ 不能用
Kruskal / BFS / DFS❌ 邻居遍历慢✅ 必须用

表示方式三:边列表

将图存储为 (u, v)(u, v, w) 元组的普通数组。

📄 将图存储为 `(u, v)` 或 `(u, v, w)` 元组的普通数组。
// 加权图的边结构体
struct Edge {
    int u, v, w;
    // 按权重升序排序:
    bool operator<(const Edge& other) const {
        return w < other.w;
    }
};

vector<Edge> edges;

// 读取输入:
for (int i = 0; i < m; i++) {
    int u, v, w;
    cin >> u >> v >> w;
    edges.push_back({u, v, w});
}

// 按权重排序——Kruskal MST 需要:
sort(edges.begin(), edges.end());

什么时候用边列表:

算法原因
Kruskal MST需要按权重排序的边;贪心处理
Bellman-Ford对所有 M 条边迭代 N 次
全局处理所有边不需要顶点结构时

什么时候不用:

  • BFS/DFS:无法按顶点查询邻居
  • 检查「边 (u, v) 是否存在」:O(M) 扫描

如何选择表示方式

V ≤ 1500?
├── 是 → 需要 O(1) 边查询或 Floyd-Warshall?
│         ├── 是 → 邻接矩阵
│         └── 否 → 邻接表
└── 否 → 邻接表(始终)
            └── 还需要 Kruskal?→ 同时维护边列表

默认规则: 从邻接表开始。只在 V 明确较小(≤ 1500)或需要 Floyd-Warshall 时才切换到邻接矩阵。


5.1.3 读取图的输入

你已经了解了数据结构——现在把它们与真实的 USACO 输入联系起来。立即识别输入格式可以节省宝贵的竞赛时间。以下是你会遇到的五种格式:

格式一:标准边列表(最常见)

5 4        ← n 个顶点,m 条边(第一行)
1 2        ← 之后每行:一条无向边
2 3
3 4
4 5
int n, m;
cin >> n >> m;
vector<vector<int>> adj(n + 1);
for (int i = 0; i < m; i++) {
    int u, v;
    cin >> u >> v;
    adj[u].push_back(v);
    adj[v].push_back(u);   // 有向图省略此行
}

格式二:加权边列表

4 5        ← n 个顶点,m 条边
1 2 10     ← 边 1-2,权重 10
1 3 5      ← 边 1-3,权重 5
2 3 3
2 4 8
3 4 2
📄 C++ 完整代码
int n, m;
cin >> n >> m;
vector<vector<pair<int,int>>> adj(n + 1);
// adj[u] = {邻居, 权重} 对的列表
for (int i = 0; i < m; i++) {
    int u, v, w;
    cin >> u >> v >> w;
    adj[u].push_back({v, w});
    adj[v].push_back({u, w});  // 无向加权图
}

// C++17 结构化绑定遍历:
for (auto& [v, w] : adj[1]) {
    cout << "1 -> " << v << "(权重 " << w << ")\n";
}

格式三:通过父节点数组表示树

5          ← n 个节点;根始终是节点 1
2 3 1 1    ← 节点 2、3、4、5 的父节点
int n;
cin >> n;
vector<vector<int>> children(n + 1);
vector<int> par(n + 1, 0);
for (int i = 2; i <= n; i++) {
    cin >> par[i];
    children[par[i]].push_back(i);   // 有向:父节点 -> 子节点
}
// par[1] = 0(根节点无父节点)

格式四:网格图(USACO Bronze/Silver 非常常见)

4 5        ← R 行,C 列
.....      ← '.' = 可通行,'#' = 墙/障碍
.##..
.....
.....

单元格是节点;相邻的可通行单元格共享一条边。不需要显式邻接表——使用方向 delta 数组:

📄 单元格是节点;相邻的可通行单元格共享一条边。不需要显式邻接表——使用方向 delta 数组:
int R, C;
cin >> R >> C;
vector<string> grid(R);
for (int r = 0; r < R; r++) cin >> grid[r];

// 4 方向:上、下、左、右
const int dr[] = {-1,  1,  0, 0};
const int dc[] = { 0,  0, -1, 1};

// 在单元格 (r, c) 处,遍历有效的可通行邻居:
auto neighbors = [&](int r, int c) {
    vector<pair<int,int>> result;
    for (int d = 0; d < 4; d++) {
        int nr = r + dr[d];
        int nc = c + dc[d];
        if (nr >= 0 && nr < R && nc >= 0 && nc < C && grid[nr][nc] != '#') {
            result.push_back({nr, nc});
        }
    }
    return result;
};

专业技巧: 8 方向移动(包括对角线):

const int dr[] = {-1,-1,-1, 0, 0, 1, 1, 1};
const int dc[] = {-1, 0, 1,-1, 1,-1, 0, 1};

将单元格压缩为一个整数(适合 visited 数组):

// 单元格 (r, c) → 整数 ID:r * C + c(0-indexed)
int cellId(int r, int c, int C) { return r * C + c; }
// 反向:id → (id / C, id % C)

格式五:含自环和重边的边列表

📄 查看代码:格式五:含自环和重边的边列表
// 跳过自环(若题目中无意义):
for (int i = 0; i < m; i++) {
    int u, v;
    cin >> u >> v;
    if (u == v) continue;    // 自环:跳过
    adj[u].push_back(v);
    adj[v].push_back(u);
}

// 去除重边(只构建简单图):
set<pair<int,int>> seen;
for (int i = 0; i < m; i++) {
    int u, v;
    cin >> u >> v;
    if (u > v) swap(u, v);         // 规范化:始终 u < v
    if (seen.insert({u, v}).second) {
        // .second = true 意味着是新插入的(非重复)
        adj[u].push_back(v);
        adj[v].push_back(u);
    }
}

5.1.4 树——图的特殊类型

树是图最重要的特殊情况。它出现在 USACO 的每个级别——Bronze 到 Platinum。掌握树等于掌握了图论的一半。

是满足以下所有条件的图(这些条件互相等价):

  1. 连通且恰好有 N − 1 条边
  2. 连通且无环
  3. 任意两个顶点之间恰好有一条简单路径
  4. 最小连通:去掉任意一条边就会断开

Tree Structure

         1          ← 根节点(深度 0)
        / \
       2   3        ← 深度 1
      / \   \
     4   5   6      ← 深度 2(4、5、6 是叶节点)

树的术语

术语定义示例(上图)
指定的顶部节点(深度 0)节点 1
父节点直接在上方的唯一节点parent(4) = 2
子节点直接在下方的节点children(2) = {4, 5}
叶节点没有子节点的节点节点 4、5、6
深度到根的距离depth(6) = 2
节点 u 的高度从 u 到叶节点的最长路径height(2) = 1
树的高度任意节点的最大深度2
子树(u)节点 u 及其所有后代subtree(2) = {2,4,5}
u 的祖先从 u 到根路径上的任意节点ancestors(6) = {3, 1}
LCA(u, v)最近公共祖先LCA(4, 6) = 1

给树确定根(标准 DFS 模板)

几乎所有树问题都需要选定一个根并计算父子关系。标准做法:将树作为无向图读取,然后用 DFS 确定根:

📄 几乎所有树问题都需要选定一个根并计算父子关系。标准做法:将树作为无向图读取,然后用 DFS 确定根:
int n;
cin >> n;
vector<vector<int>> adj(n + 1);
for (int i = 0; i < n - 1; i++) {
    int u, v;
    cin >> u >> v;
    adj[u].push_back(v);
    adj[v].push_back(u);   // 无向树边
}

vector<int> parent(n + 1, 0);
vector<int> depth(n + 1, 0);
vector<vector<int>> children(n + 1);

// 以节点 1 为根,用迭代 DFS(N 最大 10^5 时安全)
// 递归 DFS 对链(N = 10^5 深的路径)可能栈溢出
function<void(int, int)> rootTree = [&](int u, int par) {
    parent[u] = par;
    for (int v : adj[u]) {
        if (v != par) {                // v != par 避免向上回溯
            children[u].push_back(v);
            depth[v] = depth[u] + 1;
            rootTree(v, u);
        }
    }
};
rootTree(1, 0);   // 根 = 1,哨兵父节点 = 0

rootTree(1, 0) 完成后:

  • parent[u] = 节点 u 的父节点(若 u 是根则为 0)
  • children[u] = 有根树中 u 的子节点列表
  • depth[u] = u 从根开始的深度

⚠️ 深树的栈溢出警告: 对 10^5 长度的链图做递归 DFS 会溢出默认栈(通常 1–8 MB)。对于大型树,使用带显式栈的迭代 DFS 或增大栈大小。

迭代(栈安全)版本:

📄 C++ 完整代码
// 迭代 rootTree:BFS 顺序,任何树形状都安全
vector<int> order;
queue<int> bfsQ;
vector<bool> visited(n + 1, false);
bfsQ.push(1);
visited[1] = true;
parent[1] = 0;
depth[1] = 0;

while (!bfsQ.empty()) {
    int u = bfsQ.front(); bfsQ.pop();
    order.push_back(u);
    for (int v : adj[u]) {
        if (!visited[v]) {
            visited[v] = true;
            parent[v] = u;
            depth[v] = depth[u] + 1;
            children[u].push_back(v);
            bfsQ.push(v);
        }
    }
}
// order[] = BFS 遍历顺序(适用于自底向上 DP)

5.1.5 加权图——存储边的代价

目前为止,我们的边是「裸」的——只是说「A 连接到 B」。但许多题目给每条边赋予一个代价(距离、行程时间、容量)。以下是如何存储这些额外信息:

📄 C++ 完整代码
// 方式一:pair<int,int>——紧凑、常用
vector<vector<pair<int,int>>> adj(n + 1);
// adj[u] 存储 {v, w} 对

// 添加无向加权边 u–v,权重 w:
adj[u].push_back({v, w});
adj[v].push_back({u, w});

// 用 C++17 结构化绑定遍历:
for (auto& [v, w] : adj[u]) {
    cout << u << " -> " << v << "(代价 " << w << ")\n";
}
📄 C++ 完整代码
// 方式二:struct Edge——对复杂图更清晰
struct Edge {
    int to;       // 目标顶点
    int weight;   // 边代价
};
vector<vector<Edge>> adj(n + 1);

// 添加边:
adj[u].push_back({v, w});

// 遍历:
for (const Edge& e : adj[u]) {
    relax(u, e.to, e.weight);
}

什么时候用 long long 权重

若边权最大 10^9 且路径可能包含多条边,累积和可能溢出 32 位 int(最大约 2.1×10^9):

最坏情况:N = 10^5 个节点,所有边权 = 10^9
最长路径和 ≈ 10^5 × 10^9 = 10^14 → int 溢出!

Dijkstra / Bellman-Ford 的安全模板:

const long long INF = 2e18;   // long long 距离的安全哨兵
vector<vector<pair<int, long long>>> adj(n + 1);
//                         ^^^^^^^^^^^ long long 权重
vector<long long> dist(n + 1, INF);
dist[src] = 0;

规则: 若边权超过 10^4 且路径可能超过几百条边,用 long long。拿不准时用 long long——性能差异可以忽略不计。


5.1.6 常见错误

这些是初学者最常犯的 bug。记住它们——能节省大量调试时间。

⚠️ Bug #1——无向图缺少反向边最常见!

// 错误:只加一个方向
adj[u].push_back(v);    // 忘了 adj[v].push_back(u) !

// 正确:无向图 = 两个方向
adj[u].push_back(v);
adj[v].push_back(u);

症状: BFS/DFS 只访问半个图;某些顶点看起来无法到达。

⚠️ Bug #2——邻接表大小差一

// 错误:大小 n,但顶点下标是 1..n → adj[n] 越界!
vector<vector<int>> adj(n);

// 正确:大小 n+1,用于 1-indexed 顶点
vector<vector<int>> adj(n + 1);

⚠️ Bug #3——局部邻接矩阵崩溃(栈溢出)

// 错误:~1 MB 在栈上 → 栈溢出
int main() {
    bool adj[1001][1001];   // 局部变量在栈上!
}

// 正确:全局数组(BSS 段,自动初始化为零)
bool adj[1001][1001];   // 在 main() 之外
int main() { ... }

⚠️ Bug #4——V 很大时用邻接矩阵(MLE)

// 错误:V = 100,000 → 10 GB 内存!
bool adj[100001][100001];

// 正确:V > 1500 时用邻接表
vector<vector<int>> adj(n + 1);

⚠️ Bug #5——访问网格前未检查边界

// 错误:可能访问 grid[-1][c] → 未定义行为!
if (grid[nr][nc] != '#') { ... }

// 正确:先检查边界
if (nr >= 0 && nr < R && nc >= 0 && nc < C && grid[nr][nc] != '#') { ... }

⚠️ Bug #6——加权图中距离整数溢出

// 错误:边权 = 10^9,路径有 10^5 条边 → 溢出
int dist[MAXN];

// 正确:距离数组用 long long
long long dist[MAXN];
const long long INF = 2e18;

本章总结

核心要点

概念核心规则为什么重要
无向边同时加 adj[u]←vadj[v]←u忘一个方向 = Bug #1
有向边只加 adj[u]←v与无向图不同!
邻接表vector<vector<int>> adj(n+1)默认;O(V+E) 空间
邻接矩阵全局 bool adj[MAXV][MAXV]只在 V ≤ 1500 时;O(1) 边查询
加权邻接表vector<pair<int,int>> 或结构体Dijkstra、Bellman-Ford
加权矩阵int dist[MAXV][MAXV],INF 哨兵Floyd-Warshall
边列表vector<{u,v,w}> 按权重排序Kruskal MST 算法
网格图dr[]/dc[] 方向数组无需显式邻接表
连通 + N−1 条边 + 无环支持高效子树 DP
long long 权重当和可能超过 2×10^9防止路径和溢出

与后续章节的联系

章节使用本章什么内容
5.2 BFS & DFS本章构建的邻接表;本章是硬前置条件
5.3 树与 DSU树的表示 + 添加并查集数据结构
5.4 最短路径Dijkstra 用加权邻接表;Floyd 用加权矩阵
6.x 动态规划网格图支持网格 DP;DAG 支持 DAG 上的 DP
8.1 MSTKruskal 用边列表;Prim 用邻接表
8.3 树形 DP§5.1.4 的有根树结构;children[] 数组模式
8.4 欧拉序/LCA在 §5.1.4 的 depth[] 和 parent[] 上构建倍增

常见问题

Q:应该用 0-indexed 还是 1-indexed 顶点?

USACO 输入几乎总是 1-indexed(顶点标记为 1 到 N)。用 vector<vector<int>> adj(n + 1),槽位 0 留空不用。这直接与输入对应,避免差一错误。

Q:网格图需要显式邻接表吗?

不需要。网格邻居通过 dr[]/dc[] 数组即时计算——内存效率更高,代码通常也更简洁。

Q:什么时候对边权用 long long

当权重可达 10^9 且可能要对多条边求和时(最短路径、总代价)。10^9 × 路径长度很容易超过 2^31 − 1 ≈ 2.1×10^9。拿不准就用 long long


练习题


题目 5.1.1 — 度数统计 🟢 简单

题目: 读取一个有 N 个顶点和 M 条边的无向图,打印每个顶点的度数(与它相连的边数)。

样例输入 1:

4 4
1 2
2 3
3 4
4 1

样例输出 1: 2 2 2 2

样例输入 2:

5 3
1 2
1 3
1 4

样例输出 2: 3 1 1 1 0

💡 提示

维护 degree[] 数组。对每条边 (u, v),做 degree[u]++degree[v]++

✅ 完整题解
#include <bits/stdc++.h>
using namespace std;

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

    int n, m;
    cin >> n >> m;

    vector<int> degree(n + 1, 0);

    for (int i = 0; i < m; i++) {
        int u, v;
        cin >> u >> v;
        degree[u]++;
        degree[v]++;   // 无向图:两个端点各加 +1
    }

    for (int u = 1; u <= n; u++) {
        cout << degree[u];
        if (u < n) cout << " ";
    }
    cout << "\n";

    return 0;
}
// 时间:O(N + M),空间:O(N)

题目 5.1.2 — 是树吗? 🟢 简单

题目: 给定一个有 N 个顶点和 M 条边的连通无向图,判断它是否是一棵树。

样例输入 1:

5 4
1 2
1 3
3 4
3 5

样例输出 1: YES

样例输入 2:

4 4
1 2
2 3
3 4
4 1

样例输出 2: NO

💡 提示

连通图:当且仅当 M = N − 1 时是树。不需要环检测——对连通图,边数本身就够了。

✅ 完整题解
#include <bits/stdc++.h>
using namespace std;

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

    int n, m;
    cin >> n >> m;

    for (int i = 0; i < m; i++) {
        int u, v;
        cin >> u >> v;   // 读边(此题不需要用它们)
    }

    cout << (m == n - 1 ? "YES" : "NO") << "\n";
    return 0;
}
// 时间:O(M),空间:O(1)

⚠️ 注意: 这只在图被保证连通时有效。对可能不连通的图,还需用 BFS/DFS 验证连通性(第 5.2 章)。有 N−1 条边的不连通图是森林(多棵树),不是单棵树。


题目 5.1.3 — 有向图可达性 🟡 中等

题目: 给定有 N 个顶点、M 条有向边和两个顶点 S、T 的有向图,沿有向边走,T 从 S 可达吗?

样例输入 1:

5 4 1 4
1 2
2 3
3 4
1 5

样例输出 1: YES(路径:1 → 2 → 3 → 4)

样例输入 2:

4 3 4 1
1 2
2 3
3 4

样例输出 2: NO(边只向前 1→2→3→4,不能反向)

💡 提示

构建有向邻接表(只加 adj[u].push_back(v),不加反向边)。从 S 运行 BFS,若 T 被访问则输出 YES。

✅ 完整题解
#include <bits/stdc++.h>
using namespace std;

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

    int n, m, S, T;
    cin >> n >> m >> S >> T;

    vector<vector<int>> adj(n + 1);
    for (int i = 0; i < m; i++) {
        int u, v;
        cin >> u >> v;
        adj[u].push_back(v);   // 有向:只加一个方向
    }

    vector<bool> visited(n + 1, false);
    queue<int> q;
    visited[S] = true;
    q.push(S);

    while (!q.empty()) {
        int u = q.front(); q.pop();
        for (int v : adj[u]) {
            if (!visited[v]) {
                visited[v] = true;
                q.push(v);
            }
        }
    }

    cout << (visited[T] ? "YES" : "NO") << "\n";
    return 0;
}
// 时间:O(V + E),空间:O(V + E)

题目 5.1.4 — 叶节点计数 🟢 简单

题目: 有根树共 N 个节点,根 = 节点 1,通过父节点数组给出。统计叶节点(无子节点的节点)数量。

样例输入:

5
1 1 2 2

样例输出: 3(树:1→{2,3},2→{4,5}。叶节点:3、4、5)

💡 提示

若一个节点从未作为父节点出现,它就是叶节点。追踪 hasChild[u]hasChild[u] = false 的节点就是叶节点。

✅ 完整题解
#include <bits/stdc++.h>
using namespace std;

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

    int n;
    cin >> n;

    vector<bool> hasChild(n + 1, false);

    for (int i = 2; i <= n; i++) {
        int parent;
        cin >> parent;
        hasChild[parent] = true;   // 父节点至少有一个子节点
    }

    int leaves = 0;
    for (int u = 1; u <= n; u++) {
        if (!hasChild[u]) leaves++;
    }

    cout << leaves << "\n";
    return 0;
}
// 时间:O(N),空间:O(N)

题目 5.1.5 — 网格边数统计 🟡 中等

题目: 读取一个 N×M 的网格,. = 可通行,# = 墙。统计隐式无向图中边的数量(两个相邻可通行单元格共享一条边)。

样例输入 1:

3 3
...
.#.
...

样例输出 1: 8

💡 提示

对每个可通行单元格,只检查其邻居,避免重复计数。若两个单元格都可通行,计一条边。

✅ 完整题解
#include <bits/stdc++.h>
using namespace std;

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

    int n, m;
    cin >> n >> m;

    vector<string> grid(n);
    for (int r = 0; r < n; r++) cin >> grid[r];

    int edges = 0;
    for (int r = 0; r < n; r++) {
        for (int c = 0; c < m; c++) {
            if (grid[r][c] == '#') continue;
            // 检查右邻居
            if (c + 1 < m && grid[r][c+1] == '.') edges++;
            // 检查下邻居
            if (r + 1 < n && grid[r+1][c] == '.') edges++;
        }
    }

    cout << edges << "\n";
    return 0;
}
// 时间:O(N*M),空间:O(N*M)

题目 5.1.6 — 构建邻接矩阵并查询 🟢 简单

题目: 给定有 N 个顶点(N ≤ 500)和 M 条边的无向无权图,构建邻接矩阵,回答 Q 次查询:对每次查询 (u, v),若边 u–v 存在打印 1,否则打印 0

样例输入:

4 4
1 2
1 3
2 4
3 4

3
1 2
2 3
1 4

样例输出:

1
0
0
💡 提示

O(M) 构建邻接矩阵,每次查询 O(1)。展示了当 Q 大而 N 小时邻接矩阵优于邻接表(每次查询 O(deg))的场景。

✅ 完整题解
#include <bits/stdc++.h>
using namespace std;

const int MAXV = 501;
bool adj[MAXV][MAXV];   // 全局:自动初始化为零,不会栈溢出

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

    int n, m;
    cin >> n >> m;

    for (int i = 0; i < m; i++) {
        int u, v;
        cin >> u >> v;
        adj[u][v] = true;
        adj[v][u] = true;   // 无向图
    }

    int q;
    cin >> q;
    while (q--) {
        int a, b;
        cin >> a >> b;
        cout << (adj[a][b] ? 1 : 0) << "\n";   // 每次查询 O(1)!
    }

    return 0;
}
// 构建:O(M),每次查询:O(1),总计:O(M + Q)
📖 第 5.2 章 ⏱️ 约 120 分钟 🎯 中级

第 5.2 章:BFS 与 DFS

📝 前置条件: 确保理解图的表示(第 5.1 章)、队列和栈(第 3.6 章)以及基本的二维数组遍历(第 2.3 章)。

图遍历算法探索从起点可到达的每个节点,是数十种图算法的基础。DFS(深度优先搜索)在回溯前尽量深入;BFS(广度优先搜索)逐层探索。知道何时用哪种是你在竞赛编程生涯中不断积累的技能。


5.2.0 BFS / DFS 选择框架:先判断目标,再选工具

BFS 和 DFS 都能遍历图,但它们回答的问题不同。做 USACO 图论题时,先不要急着写模板,先问题目要的到底是什么。

题目目标优先选择原因典型 USACO 场景
求无权图最短步数BFS按层扩展,第一次到达就是最短迷宫、网格最短路、最少操作次数
求连通块/洪水填充DFS 或 BFS只需要访问所有可达点Counting Rooms、岛屿计数
枚举路径或回溯方案DFS递归天然保存当前路径搜索排列、路径构造、回溯剪枝
检测有向图环DFS 三色标记能区分正在访问和已完成依赖关系、函数调用图
树的子树信息DFS后序返回子树结果子树大小、树形 DP 入门
多个起点到最近源点距离多源 BFS所有源点同时入队,距离自然最小最近出口、最近危险点、扩散模型

💡 一句话判断: 题目出现「最少步数 / 最短操作次数 / 最近」时,优先想 BFS;题目出现「递归结构 / 子树 / 枚举路径 / 回溯」时,优先想 DFS。

四类易错点预警

后文会逐一展开代码实现,但先建立错误地图:

  1. 访问标记错误:BFS 应在入队时标记,DFS 应在进入节点时标记。
  2. 边界检查错误:网格题必须先判越界,再访问 grid[nr][nc]
  3. 数据结构选错:BFS 用队列,DFS 用栈/递归;用 DFS 求最短路通常是错的。
  4. 递归深度风险:大网格或链状图递归 DFS 可能栈溢出,需改迭代 DFS/BFS。

5.2.1 深度优先搜索(DFS)

DFS 就像探索迷宫:一直向前走直到遇到死路,然后回溯尝试另一条路。它是最自然的图遍历——递归完成了大部分工作。

核心思想

想象你站在迷宫中的十字路口。DFS 说:选一条路,尽可能走到底。遇到死路(所有邻居都已访问),回溯到上一个十字路口,尝试下一条路。重复直到访问了所有可达节点。

这种「深入后回溯」的行为与递归完美对应:每次递归调用深入一步,从调用中返回就是回溯。

从节点 1 开始的 DFS:

    1 ──── 2 ──── 4
    |      |
    3      5 ──── 6

访问顺序:1 → 2 → 4(死路,回溯)→ 5 → 6(死路,回溯×2)→ 3

图示:DFS 遍历顺序

DFS Traversal

DFS 在回溯前尽量深入。带编号的圆圈展示访问顺序,红色虚线箭头展示回溯路径。右侧的调用栈说明了递归如何自然地实现 DFS 所需的 LIFO 行为。

递归 DFS

📄 查看代码:递归 DFS
#include <bits/stdc++.h>
using namespace std;

const int MAXN = 100001;
vector<int> adj[MAXN];
bool visited[MAXN];

void dfs(int u) {
    visited[u] = true;           // 标记当前节点为已访问
    cout << u << " ";            // 处理 u(本例中打印它)

    for (int v : adj[u]) {       // 对每个邻居 v
        if (!visited[v]) {       // 若尚未访问
            dfs(v);              // 递归探索 v
        }
    }
}

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

    int n, m;
    cin >> n >> m;

    for (int i = 0; i < m; i++) {
        int u, v;
        cin >> u >> v;
        adj[u].push_back(v);
        adj[v].push_back(u);
    }

    // 从节点 1 开始 DFS
    dfs(1);
    cout << "\n";

    return 0;
}

逐步追踪:递归 DFS 如何工作

给定图(节点:1,2,3,4,5,6;边:1-2,1-3,2-4,2-5,5-6):

📄 给定图(节点:1,2,3,4,5,6;边:1-2,1-3,2-4,2-5,5-6):
DFS 从节点 1 开始——带调用栈的完整追踪:

调用:dfs(1)
  visited[1] = true.  打印:1
  节点 1 的邻居:[2, 3]
  ├── v=2:未访问 → 调用 dfs(2)
  │   调用:dfs(2)
  │     visited[2] = true.  打印:2
  │     节点 2 的邻居:[1, 4, 5]
  │     ├── v=1:已访问 → 跳过
  │     ├── v=4:未访问 → 调用 dfs(4)
  │     │   调用:dfs(4)
  │     │     visited[4] = true.  打印:4
  │     │     节点 4 的邻居:[2]
  │     │     └── v=2:已访问 → 跳过
  │     │   从 dfs(4) 返回  ← 回溯!
  │     ├── v=5:未访问 → 调用 dfs(5)
  │     │   调用:dfs(5)
  │     │     visited[5] = true.  打印:5
  │     │     节点 5 的邻居:[2, 6]
  │     │     ├── v=2:已访问 → 跳过
  │     │     └── v=6:未访问 → 调用 dfs(6)
  │     │         dfs(6):打印 6
  │     │         从 dfs(6) 返回  ← 回溯!
  │     │   从 dfs(5) 返回  ← 回溯!
  │   从 dfs(2) 返回  ← 回溯!
  ├── v=3:未访问 → 调用 dfs(3)
  │   dfs(3):打印 3,无未访问邻居
  │   从 dfs(3) 返回  ← 回溯!
从 dfs(1) 返回

输出:1 2 4 5 6 3

💡 关键观察: 递归深度等于 DFS 树中从起始节点出发的最长路径。对于路径图 1→2→3→...→N,深度为 N——这就是栈溢出风险出现的时候。

重要: 始终在递归前而非之后标记节点为已访问!这能防止在环上无限循环。

复杂度分析

  • 时间:O(V + E) —— 每个顶点恰好访问一次(visited[] 检查),每条边恰好检查两次(无向图中从两端各一次),总计 O(V + E)。
  • 空间:O(V) —— visited[] 数组使用 O(V),递归调用栈最坏情况深度 O(V)。

迭代 DFS(使用栈)

对于非常大的图,递归 DFS 可能导致栈溢出(递归太深)。默认栈大小通常为 1–8 MB,每个递归层次使用约 100–200 字节。当图深度超过约 10^4–10^5 时会崩溃。

迭代版本用显式的 stack<int> 替换系统调用栈:

📄 迭代版本用显式的 `stack` 替换系统调用栈:
void dfs_iterative(int start, int n) {
    vector<bool> visited(n + 1, false);
    stack<int> st;

    st.push(start);

    while (!st.empty()) {
        int u = st.top();
        st.pop();

        if (visited[u]) continue;  // 可能被多次压入
        visited[u] = true;
        cout << u << " ";

        for (int v : adj[u]) {
            if (!visited[v]) {
                st.push(v);
            }
        }
    }
}

⚠️ 注意: 迭代 DFS 访问节点的顺序可能与递归 DFS 不同。对大多数题目这无所谓——两者都访问所有可达节点。

何时用迭代 DFS:

  • 图深度可能超过约 10^4(如路径图、链)
  • N×M ≥ 10^6 格子的网格问题
  • 任何担心栈溢出的时候

5.2.2 连通分量

连通分量是顶点的最大集合,其中每个顶点都能通过边到达其他所有顶点。把它想象成连接节点的「岛屿」——从分量中任意节点开始 DFS,会访问同一分量中的所有其他节点,但不会访问外面的节点。

DFS 连通分量:找出所有连通块

算法:用 DFS 给每个分量标记

策略很简单:

  1. 从 1 到 N 扫描所有节点
  2. 找到未访问节点时,这是一个新分量的起点
  3. 从该节点运行 DFS,给所有可达节点打上相同的分量 ID
  4. 重复直到所有节点都被标记
📄 4. 重复直到所有节点都被标记
#include <bits/stdc++.h>
using namespace std;

const int MAXN = 100001;
vector<int> adj[MAXN];
int comp[MAXN];   // comp[v] = 顶点 v 的分量 ID(0 = 未访问)

void dfs(int u, int id) {
    comp[u] = id;
    for (int v : adj[u]) {
        if (comp[v] == 0) {   // 0 表示未访问
            dfs(v, id);
        }
    }
}

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

    int n, m;
    cin >> n >> m;

    for (int i = 0; i < m; i++) {
        int u, v;
        cin >> u >> v;
        adj[u].push_back(v);
        adj[v].push_back(u);
    }

    int numComponents = 0;
    for (int u = 1; u <= n; u++) {
        if (comp[u] == 0) {
            numComponents++;
            dfs(u, numComponents);  // 分配分量 ID
        }
    }

    cout << "分量数量:" << numComponents << "\n";

    // 打印各分量大小
    vector<int> sz(numComponents + 1, 0);
    for (int u = 1; u <= n; u++) sz[comp[u]]++;
    for (int i = 1; i <= numComponents; i++) {
        cout << "分量 " << i << ":" << sz[i] << " 个节点\n";
    }

    return 0;
}

常见 USACO 应用

连通分量以多种形式出现:

  • 「有多少组?」 —— 统计分量数
  • 「A 和 B 连通吗?」 —— 检查 comp[A] == comp[B]
  • 「最大组的大小?」 —— 找节点最多的分量
  • 「加 K 条边能使图连通吗?」 —— 需要恰好 numComponents - 1 条边

💡 替代方案:并查集(DSU) 也可以找连通分量,并支持动态添加边。我们将在第 5.5 章介绍 DSU。


5.2.3 广度优先搜索(BFS)

BFS 先探索所有距离为 1 的节点,再探索距离为 2 的,然后 3,以此类推。这使它非常适合在无权图中寻找最短路径。DFS 深入,BFS 扩张——就像池塘里的涟漪。

核心思想

BFS 使用队列(FIFO:先进先出)按距离源点的顺序处理节点:

  1. 从源节点(距离 0)开始
  2. 访问其所有邻居(距离 1)
  3. 访问未访问的邻居(距离 2)
  4. 继续直到访问了所有可达节点

队列确保距离 d 的所有节点在距离 d+1 的任何节点之前被处理。这种逐层扩展保证了最短路径。

图示:BFS 逐层遍历

BFS Traversal

BFS 像池塘里的涟漪向外扩散。每「层」节点颜色不同,展示了距源点距离 d 的所有节点在距离 d+1 的任何节点之前被发现。底部的队列展示了处理顺序。

BFS 模板

以下 BFS 模板是本章最重要的代码模式,你会在数十道题中用到它(或它的变体)。

📄 以下 BFS 模板是本章**最重要的代码模式**,你会在数十道题中用到它(或它的变体)。
// BFS 最短路径 — O(V + E)
#include <bits/stdc++.h>
using namespace std;

const int MAXN = 100001;
vector<int> adj[MAXN];

// 返回从源点到所有顶点的最短距离数组
// dist[v] = -1 表示不可达
vector<int> bfs(int source, int n) {
    vector<int> dist(n + 1, -1);   // -1 = 未访问(也作为「已访问」标记)
    queue<int> q;

    dist[source] = 0;     // 到源点的距离为 0
    q.push(source);       // 用源点初始化队列

    while (!q.empty()) {
        int u = q.front();        // 取出最早发现的节点
        q.pop();

        for (int v : adj[u]) {           // 对 u 的每个邻居
            if (dist[v] == -1) {          // 若 v 尚未访问
                dist[v] = dist[u] + 1;   // ← 关键行:v 比 u 多一跳
                q.push(v);                // 将 v 加入队列以供未来处理
            }
        }
    }

    return dist;
}

关键部分逐行分析:

作用为什么重要
dist(n+1, -1)将所有距离初始化为 -1-1 表示「尚未到达」——同时作为已访问标记
dist[source] = 0源点到自身的距离为 0BFS 的起始点
q.push(source)初始化队列BFS 需要至少一个节点才能开始
u = q.front(); q.pop()处理最早发现的节点FIFO 顺序保证逐层处理
if (dist[v] == -1)只访问未访问的节点防止重复访问和无限循环
dist[v] = dist[u] + 1关键行 —— 距离加 1无权图中每条边权重为 1
q.push(v)将 v 排队等待处理v 的邻居将在后续探索

BFS 为什么找到最短路径

BFS 按距离源点的顺序处理节点。第一次访问一个节点时,一定是通过最短路径。这是因为 BFS 在访问距离 d+1 的任何节点之前,会访问所有距离 d 的节点。

💡 核心思路: 把 BFS 想象成在水中投石——涟漪一层层向外扩散。距离 d 的所有单元格在距离 d+1 的任何单元格之前被处理。这种逐层处理保证了第一次到达任何节点都是通过最短路径。

BFS vs DFS 最短路径:

  • BFS:保证无权图的最短路径 ✓
  • DFS:不保证最短路径 ✗

5.2.4 网格 BFS——最常见的 USACO 模式

许多 USACO 题目给你一个有可通行(.)和阻塞(#)格子的网格。BFS 找从一个格子到另一个格子的最短路径。

图示:网格 BFS 距离洪水填充

Grid BFS

从中心格子(距离 0)开始,BFS 扩展到所有可达格子,记录到达每个格子所需的最少步数。颜色越蓝表示越远。这正是 USACO 洪水填充和网格最短路径题目的工作方式。

USACO 风格的网格 BFS 题目:迷宫最短路

题目: 给定一个有墙(#)和开放格子(.)的 5×5 迷宫,找从左上角 (0,0) 到右下角 (4,4) 的最短路径。打印长度,若无路径输出 -1。

📄 C++ 完整代码
// 网格 BFS 最短路径 — O(R × C)
#include <bits/stdc++.h>
using namespace std;

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

    int R, C;
    cin >> R >> C;
    vector<string> grid(R);
    for (int r = 0; r < R; r++) cin >> grid[r];

    // 找起点 (S) 和终点 (E),或使用固定角落
    int sr = 0, sc = 0, er = R-1, ec = C-1;

    // BFS 距离数组:-1 = 未访问
    vector<vector<int>> dist(R, vector<int>(C, -1));
    queue<pair<int,int>> q;

    // 第一步:从源点初始化 BFS
    dist[sr][sc] = 0;
    q.push({sr, sc});

    // 第二步:方向数组(上、下、左、右)
    int dr[] = {-1, 1, 0, 0};
    int dc[] = {0, 0, -1, 1};

    // 第三步:BFS 扩展
    while (!q.empty()) {
        auto [r, c] = q.front();
        q.pop();

        for (int d = 0; d < 4; d++) {
            int nr = r + dr[d];
            int nc = c + dc[d];

            if (nr >= 0 && nr < R           // 行在界内
                && nc >= 0 && nc < C        // 列在界内
                && grid[nr][nc] != '#'       // 不是墙
                && dist[nr][nc] == -1) {     // ← 关键行:尚未访问

                dist[nr][nc] = dist[r][c] + 1;
                q.push({nr, nc});
            }
        }
    }

    // 第四步:输出结果
    if (dist[er][ec] == -1) {
        cout << -1 << "\n";   // 无路径
    } else {
        cout << dist[er][ec] << "\n";
    }

    return 0;
}

⚠️ 常见错误: 在迷宫最短路中用 DFS 代替 BFS。DFS 可能找到一条路,但不是最短路。始终用 BFS 求无权网格的最短距离。


5.2.5 USACO 示例:洪水填充

USACO 喜欢「洪水填充」题目:找所有连通的同类型格子,或统计连通区域数。洪水填充本质上是网格上的 DFS/BFS——从种子格子开始「绘制」所有可达的同类型格子。

题目:统计连通区域

题目: 统计网格中「.」格子的不同连通区域数量。

. . # # .
. . # . .
# # # . .
. . . # #
. . . # .

「.」格子的区域:

区域 1:   区域 2:   区域 3:
. .       . .       
. .       . .       . .
          . .       . .

答案:3 个区域。

完整代码(带详细注释)

📄 查看代码:完整代码(带详细注释)
#include <bits/stdc++.h>
using namespace std;

int R, C;
vector<string> grid;
vector<vector<bool>> visited;

void floodFill(int r, int c) {
    // 基础情况:越界时停止递归
    if (r < 0 || r >= R || c < 0 || c >= C) return;  // 越界
    if (visited[r][c]) return;                          // 已访问
    if (grid[r][c] == '#') return;                      // 墙(不是目标类型)

    // 标记此格子为已访问(当前区域的一部分)
    visited[r][c] = true;

    // 向 4 个方向递归
    floodFill(r - 1, c);  // 上
    floodFill(r + 1, c);  // 下
    floodFill(r, c - 1);  // 左
    floodFill(r, c + 1);  // 右
}

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

    cin >> R >> C;
    grid.resize(R);
    visited.assign(R, vector<bool>(C, false));

    for (int r = 0; r < R; r++) cin >> grid[r];

    int regions = 0;
    for (int r = 0; r < R; r++) {
        for (int c = 0; c < C; c++) {
            if (!visited[r][c] && grid[r][c] == '.') {
                regions++;           // 找到新的未访问「.」格子 → 新区域!
                floodFill(r, c);     // 将此区域的所有格子标记为已访问
            }
        }
    }

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

变体:BFS 洪水填充(避免栈溢出)

对于大网格(R × C ≥ 10^6),递归洪水填充可能栈溢出。改用 BFS:

📄 对于大网格(R × C ≥ 10^6),递归洪水填充可能栈溢出。改用 BFS:
void floodFillBFS(int sr, int sc) {
    queue<pair<int,int>> q;
    visited[sr][sc] = true;
    q.push({sr, sc});

    int dr[] = {-1, 1, 0, 0};
    int dc[] = {0, 0, -1, 1};

    while (!q.empty()) {
        auto [r, c] = q.front(); q.pop();
        for (int d = 0; d < 4; d++) {
            int nr = r + dr[d], nc = c + dc[d];
            if (nr >= 0 && nr < R && nc >= 0 && nc < C
                && !visited[nr][nc] && grid[nr][nc] == '.') {
                visited[nr][nc] = true;
                q.push({nr, nc});
            }
        }
    }
}

💡 USACO 技巧: 洪水填充在 Bronze 和 Silver 级别非常常见。常见变体包括:统计区域数、找最大区域、检查两个格子是否在同一区域以及计算区域的周长。


5.2.6 多源 BFS

有时需要计算每个格子到最近特殊格子的距离——例如「每个空格子到最近火焰有多远?」从每个火焰格子分别启动 BFS 是 O(K × R × C)(K 是火焰数量)——太慢了。多源 BFS 一次 O(R × C) 的遍历就能解决。

多源 BFS:同时从多个起点出发

核心思想

不是从一个源点运行 BFS,而是在开始 BFS 之前将所有源点格子压入队列,距离为 0。然后正常运行 BFS。每个格子都被分配到其最近源点的距离——由 BFS 的逐层性质保证。

为什么有效? 想象一个虚拟「超级源点」S* 通过代价为 0 的边连接到所有真实源点。从 S* 做 BFS 会先访问所有真实源点(距离 0),然后是其邻居(距离 1),以此类推。多源 BFS 正是如此——不需要真正创建虚拟节点。

代码模板

📄 查看代码:代码模板
// 多源 BFS:同时从所有火焰格子出发
queue<pair<int,int>> q;
vector<vector<int>> dist(R, vector<int>(C, -1));

int dr[] = {-1, 1, 0, 0};
int dc[] = {0, 0, -1, 1};

// 第一步:在开始 BFS 之前将所有源点以距离 0 压入队列
for (int r = 0; r < R; r++) {
    for (int c = 0; c < C; c++) {
        if (grid[r][c] == 'F') {  // 火焰格子 = 源点
            dist[r][c] = 0;
            q.push({r, c});
        }
    }
}

// 第二步:从所有源点同时运行 BFS
while (!q.empty()) {
    auto [r, c] = q.front();
    q.pop();
    for (int d = 0; d < 4; d++) {
        int nr = r + dr[d], nc = c + dc[d];
        if (nr >= 0 && nr < R && nc >= 0 && nc < C
            && grid[nr][nc] != '#' && dist[nr][nc] == -1) {
            dist[nr][nc] = dist[r][c] + 1;
            q.push({nr, nc});
        }
    }
}
// BFS 后:dist[r][c] = (r,c) 到最近火焰格子的最小距离

💡 核心思路: 队列中源点的顺序无关紧要。BFS 先处理所有距离 0 的格子,再处理距离 1 的格子,以此类推。每个格子保证被最近的源点首先到达。


5.2.7 DFS vs BFS——何时用哪种

这是图问题中最重要的决策之一。以下是综合指南:

快速参考表

任务原因
最短路径(无权)BFS ✓逐层保证最短
连通性/连通分量任意两者都行;DFS 递归更简单
环检测(有向)DFS ✓三色方案追踪当前路径
环检测(无向)任意DFS 加父节点检查,或 DSU
拓扑排序DFS ✓后序给出逆拓扑顺序
洪水填充任意(DFS 更简单)DFS 递归简洁
二部图检查BFS 或 DFS用任意方法 2-染色
到所有节点的距离BFS ✓BFS 自然计算所有距离
树遍历(前/中/后序)DFS ✓递归自然映射到树结构
路径是否存在(是/否)任意两者都找所有可达节点
最近源点(多源)BFS ✓多源 BFS 是标准做法

决策流程图

需要最短路径/最少步数吗?
├── 是 → 用 BFS(始终!)
└── 否 → 需要探索路径/检测回边吗?
          ├── 是 → 用 DFS(递归追踪当前路径)
          └── 否 → 任意都行,DFS 代码通常更短

💡 核心思路: 需要「最少步数」时用 BFS。只需要访问所有节点或检查路径属性(环、拓扑顺序、子树性质)时用 DFS。

USACO 经验法则:

  • Bronze/Silver 网格题: BFS 最短路,DFS 洪水填充
  • Silver 图论题: BFS 求距离,DFS 求分量
  • Gold: DFS 拓扑排序、环检测;BFS 多源距离

⚠️ 第 5.2 章常见错误

错误一:用 DFS 求最短路径

DFS 深入探索一条路径,不保证最少步数。无权图的最短路径始终用 BFS。

错误二:网格 BFS 忘记边界检查

nr >= 0 && nr < R && nc >= 0 && nc < C —— 缺少其中任意一个条件都会导致越界崩溃。

// ✅ 正确:先检查边界,再检查网格
if (nr >= 0 && nr < R && nc >= 0 && nc < C && grid[nr][nc] != '#')

错误三:弹出时而非压入时标记已访问

如果弹出时才标记已访问,同一个节点可能被多次压入,导致 O(V²) 的时间而非 O(V+E)。

为什么会这样——场景: 考虑一个节点 X,它有三个邻居 ABC,且这三个邻居此时都已经在队列中(同一 BFS 层)。当我们依次把它们出队时,每一个都会查看 X 并问:"X 被访问过了吗?"

BFS 弹出时 vs 压入时标记对比

💡 CPP 代码(22 行)
// ❌ 错误:弹出时才标记 → 同一节点会被多次压入
while (!q.empty()) {
    auto [r, c] = q.front(); q.pop();
    if (visited[r][c]) continue;   // 浪费:队列中已经有很多份了
    visited[r][c] = true;
    for (...) {
        if (!visited[nr][nc]) {
            q.push({nr, nc});      // 可能被多个邻居重复压入!
        }
    }
}

// ✅ 正确:压入时立即标记 → 每个节点恰好压入一次
while (!q.empty()) {
    auto [r, c] = q.front(); q.pop();
    for (...) {
        if (dist[nr][nc] == -1) {
            dist[nr][nc] = dist[r][c] + 1;  // 立即标记
            q.push({nr, nc});                // 恰好压入一次
        }
    }
}

错误版本的执行追踪(A、B、C 依次出队,X 是它们共同的邻居):

步骤出队visited[X]对 X 的操作本步后队列
1Afalse压入 X[B, C, X]
2B仍是 false!再次压入 X[C, X, X]
3C仍是 false!再次压入 X[X, X, X]
4Xfalse → 置为 true[X, X]
5Xtrue → 跳过[X]
6Xtrue → 跳过[]

X 被入队 3 次、出队 3 次;只有第 4 步真正处理了它,第 5、6 步都是无用功。

影响: 在 1000×1000 的网格上(每格约 4 个邻居),错误版本可能将 多达 4 倍 的条目压入队列(400 万而非 100 万)——导致 TLE 或 MLE。最坏情况下(V 个节点、E = O(V²) 边的稠密图),错误版本的队列操作达 O(V + E) = O(V²);正确版本保证恰好 V 次压入。稀疏图(E = O(V))下两者都是 O(V),但错误版本常数更大。

错误四:递归 DFS 栈溢出

对 N×M = 10^6 的网格,递归 DFS 可能超出默认栈大小(通常 1–8 MB)。改用迭代 BFS 或带显式栈的迭代 DFS。

错误五:0-indexed vs 1-indexed 起点用错

确保从正确的格子开始 BFS。USACO 题目有时用 1-indexed 网格,有时 0-indexed。


本章总结

📌 核心要点

算法数据结构时间空间最适合
DFS(递归)调用栈O(V+E)O(V)连通性、环检测、树问题
DFS(迭代)显式栈O(V+E)O(V)同上,避免栈溢出
BFS队列O(V+E)O(V)最短路径、逐层遍历
多源 BFS队列(多源预填充)O(V+E)O(V)到最近源点的距离
三色 DFS颜色数组O(V+E)O(V)有向图环检测
拓扑排序DFS/BFS(Kahn)O(V+E)O(V)DAG 上的排序/DP

❓ 常见问题

Q1:BFS 和 DFS 的时间复杂度都是 O(V+E),为什么 BFS 能找最短路径而 DFS 不能?

A:关键在访问顺序。BFS 用队列保证「先处理距离 d 的节点,再处理距离 d+1 的节点」,所以第一次到达一个节点始终是通过最短路径。DFS 用栈(或递归),可能经过长路到达节点,错过更短的路。

Q2:什么时候递归 DFS 会栈溢出?怎么修复?

A:默认栈大小约 1-8 MB,每次递归层次约用 100-200 字节。图深度超过约 10^4–10^5 时可能溢出。解决方案:① 切换到迭代 DFS(显式栈);② 编译时增加栈大小。

Q3:网格 BFS 中为什么用 dist == -1 表示未访问而不是 visited 数组?

A:用 dist[r][c] == -1 一举两得:同时记录「访问了没」和「到达的距离」。少一个数组,代码更简洁。

Q4:DFS 拓扑排序和 Kahn's BFS 拓扑排序,什么时候用哪个?

A:DFS 拓扑排序代码更短(直接反转后序),但 Kahn's 更直观,能检测环(若最终排序长度 < N,有环)。竞赛中两者都常见,选用你更熟悉的。

🔗 与后续章节的联系

  • 第 5.5 章(二叉树与树算法):树遍历(前/后序)本质上就是 DFS;第 5.6 章(并查集)处理动态连通性
  • 第 6.1–6.3 章(DP):「DAG 上的 DP」需要先做拓扑排序,再按拓扑顺序计算 DP
  • BFS 最短路径是 Dijkstra(Gold 级别)的简化版——Dijkstra 处理加权图,BFS 处理无权图
  • 多源 BFS 在 USACO Silver 中极为常见,是必须掌握的核心技术

练习题


题目 5.2.1 — 岛屿计数 🟢 简单

题目: 给定 N×M 网格,每个格子是 .(水)或 #(陆地)。水平或垂直相邻的陆地格子属于同一岛屿。统计不同岛屿的总数。

样例输入 1:

4 5
.###.
.#.#.
.###.
.....

样例输出 1: 1(所有 # 格子相连——一个岛屿)

样例输入 2:

3 5
#.#.#
.....
#.#.#

样例输出 2: 6(六个孤立的陆地格子,各自是一个岛屿)

💡 提示

扫描每个格子。找到未访问的 # 格子时,岛屿数加一,运行 DFS/BFS 标记所有相连的 # 格子为已访问。

✅ 完整题解
#include <bits/stdc++.h>
using namespace std;

int R, C;
vector<string> grid;
vector<vector<bool>> visited;

int dr[] = {-1, 1, 0, 0};
int dc[] = {0, 0, -1, 1};

void dfs(int r, int c) {
    if (r < 0 || r >= R || c < 0 || c >= C) return;
    if (visited[r][c] || grid[r][c] == '.') return;

    visited[r][c] = true;
    for (int d = 0; d < 4; d++)
        dfs(r + dr[d], c + dc[d]);
}

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

    cin >> R >> C;
    grid.resize(R);
    visited.assign(R, vector<bool>(C, false));
    for (int r = 0; r < R; r++) cin >> grid[r];

    int islands = 0;
    for (int r = 0; r < R; r++)
        for (int c = 0; c < C; c++)
            if (!visited[r][c] && grid[r][c] == '#') {
                islands++;
                dfs(r, c);
            }

    cout << islands << "\n";
    return 0;
}
// 时间:O(N×M),空间:O(N×M)

题目 5.2.2 — 迷宫最短路 🟢 简单

题目: 给定带 S(起点)、E(终点)、.(可通行)和 #(墙)的 N×M 迷宫,找从 S 到 E 的最少步数(只能上下左右移动)。若无路径输出 −1。

样例输入 1:

5 5
S...#
####.
....E
.####
.....

样例输出 1: 10

样例输入 2:

3 3
S#E
.#.
...

样例输出 2: -1

💡 提示

S 开始 BFS。BFS 第一次到达 E 时,dist[E] 就是最少步数。BFS 结束仍未到达 E 则输出 −1。

✅ 完整题解
#include <bits/stdc++.h>
using namespace std;

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

    int R, C;
    cin >> R >> C;
    vector<string> grid(R);
    for (int r = 0; r < R; r++) cin >> grid[r];

    int sr, sc, er, ec;
    for (int r = 0; r < R; r++)
        for (int c = 0; c < C; c++) {
            if (grid[r][c] == 'S') { sr = r; sc = c; }
            if (grid[r][c] == 'E') { er = r; ec = c; }
        }

    vector<vector<int>> dist(R, vector<int>(C, -1));
    queue<pair<int,int>> q;
    dist[sr][sc] = 0;
    q.push({sr, sc});

    int dr[] = {-1, 1, 0, 0};
    int dc[] = {0, 0, -1, 1};

    while (!q.empty()) {
        auto [r, c] = q.front(); q.pop();
        for (int d = 0; d < 4; d++) {
            int nr = r + dr[d], nc = c + dc[d];
            if (nr >= 0 && nr < R && nc >= 0 && nc < C
                && grid[nr][nc] != '#' && dist[nr][nc] == -1) {
                dist[nr][nc] = dist[r][c] + 1;
                q.push({nr, nc});
            }
        }
    }

    cout << dist[er][ec] << "\n";
    return 0;
}
// 时间:O(N×M),空间:O(N×M)

题目 5.2.3 — 二部图检查 🟡 中等

题目: 若能将每个节点染成黑色或白色,且每条边都连接黑色节点和白色节点,则图是二部图。给定无向图,判断是否是二部图,打印 "BIPARTITE" 或 "NOT BIPARTITE"。

样例输入 1: 4 个节点,边:1-2, 2-3, 3-4, 4-1 → BIPARTITE(4-环:1,3 黑,2,4 白)

样例输入 2: 3 个节点,边:1-2, 2-3, 3-1 → NOT BIPARTITE(三角形——奇数环不是二部图)

💡 提示

BFS 加 2-染色。给源点染色 0,对每个未染色的邻居染反色(1 - 当前颜色)。若邻居已有与当前节点相同的颜色,图不是二部图。

✅ 完整题解
#include <bits/stdc++.h>
using namespace std;

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

    int n, m;
    cin >> n >> m;

    vector<vector<int>> adj(n + 1);
    for (int i = 0; i < m; i++) {
        int u, v; cin >> u >> v;
        adj[u].push_back(v);
        adj[v].push_back(u);
    }

    vector<int> color(n + 1, -1);
    bool bipartite = true;

    for (int start = 1; start <= n && bipartite; start++) {
        if (color[start] != -1) continue;

        queue<int> q;
        color[start] = 0;
        q.push(start);

        while (!q.empty() && bipartite) {
            int u = q.front(); q.pop();
            for (int v : adj[u]) {
                if (color[v] == -1) {
                    color[v] = 1 - color[u];
                    q.push(v);
                } else if (color[v] == color[u]) {
                    bipartite = false;
                }
            }
        }
    }

    cout << (bipartite ? "BIPARTITE" : "NOT BIPARTITE") << "\n";
    return 0;
}

题目 5.2.4 — 多源 BFS:最近火焰 🟡 中等

题目: 给定有火焰格子 F、可通行空格 . 和墙 # 的 N×M 网格,对每个空格打印到最近火焰格子的最小距离。墙不可穿越。若空格无法到达任何火焰,打印 −1。

样例输入:

3 4
.F..
.#.F
....

样例输出:

1 0 1 1
2 # 1 0
3 2 2 1
💡 提示

多源 BFS:在开始 BFS 之前,将所有 F 格子以距离 0 压入队列。然后 BFS 自然地给每个格子分配到最近火源的最小距离。

✅ 完整题解
#include <bits/stdc++.h>
using namespace std;

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

    int R, C;
    cin >> R >> C;
    vector<string> grid(R);
    for (auto& row : grid) cin >> row;

    vector<vector<int>> dist(R, vector<int>(C, -1));
    queue<pair<int,int>> q;

    // 关键:将所有火源以距离 0 压入
    for (int r = 0; r < R; r++)
        for (int c = 0; c < C; c++)
            if (grid[r][c] == 'F') {
                dist[r][c] = 0;
                q.push({r, c});
            }

    int dr[] = {-1, 1, 0, 0};
    int dc[] = {0, 0, -1, 1};

    while (!q.empty()) {
        auto [r, c] = q.front(); q.pop();
        for (int d = 0; d < 4; d++) {
            int nr = r + dr[d], nc = c + dc[d];
            if (nr >= 0 && nr < R && nc >= 0 && nc < C
                && grid[nr][nc] != '#' && dist[nr][nc] == -1) {
                dist[nr][nc] = dist[r][c] + 1;
                q.push({nr, nc});
            }
        }
    }

    for (int r = 0; r < R; r++) {
        for (int c = 0; c < C; c++) {
            if (grid[r][c] == '#') cout << "# ";
            else cout << dist[r][c] << " ";
        }
        cout << "\n";
    }
    return 0;
}

题目 5.2.5 — 经典问题:倒水问题(Water Jugs) 🔴 困难

题目: 有两个空桶,容量分别为 X 和 Y。可用操作:将任意一桶装满、倒空任意一桶、将一桶倒入另一桶(直到其中一个满或空)。找到任意一桶中恰好有 M 升的最少操作次数

样例输入: 3 5 4输出: 6

💡 提示

建模为状态 BFS:每个状态是一对 (a, b),其中 a ∈ [0,X]b ∈ [0,Y] 是当前数量。应用 6 种操作生成邻居状态。BFS 找从 (0,0) 到满足 a==Mb==M 的任意状态的最少操作。

✅ 完整题解
#include <bits/stdc++.h>
using namespace std;

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

    int X, Y, M;
    cin >> X >> Y >> M;

    vector<vector<int>> dist(X + 1, vector<int>(Y + 1, -1));
    queue<pair<int,int>> q;

    dist[0][0] = 0;
    q.push({0, 0});

    while (!q.empty()) {
        auto [a, b] = q.front(); q.pop();

        // 生成所有 6 种可能操作
        vector<pair<int,int>> next = {
            {X, b},             // 装满桶 A
            {a, Y},             // 装满桶 B
            {0, b},             // 倒空桶 A
            {a, 0},             // 倒空桶 B
            {max(0, a+b-Y), min(Y, a+b)},  // 将 A 倒入 B
            {min(X, a+b), max(0, a+b-X)}   // 将 B 倒入 A
        };

        for (auto [na, nb] : next) {
            if (dist[na][nb] == -1) {
                dist[na][nb] = dist[a][b] + 1;
                q.push({na, nb});
            }
        }
    }

    int ans = INT_MAX;
    for (int a = 0; a <= X; a++)
        if (dist[a][M] != -1) ans = min(ans, dist[a][M]);
    for (int b = 0; b <= Y; b++)
        if (dist[M][b] != -1) ans = min(ans, dist[M][b]);

    cout << (ans == INT_MAX ? -1 : ans) << "\n";
    return 0;
}
// 时间:O(X×Y × 6) = O(X×Y),空间:O(X×Y)

5.2.8 DFS 环检测——白/灰/黑染色

有向图环检测:三色 DFS

对于有向图,使用三色方案追踪 DFS 过程中每个节点的状态:

  • 白色(0): 尚未访问
  • 灰色(1): 当前在 DFS 调用栈中——已开始处理但尚未完成
  • 黑色(2): 完全处理完——所有后代都已探索

核心思路: 当且仅当 DFS 遇到回边(当前节点到灰色节点的边)时,存在环。灰色节点在当前 DFS 路径上,所以指回它的边创建了环。

📄 C++ 完整代码
// 有向图环检测 — O(V+E)
vector<int> color;   // 0=白, 1=灰, 2=黑
bool hasCycle = false;

void dfs(int u) {
    color[u] = 1;  // 标记为「处理中」(灰色)

    for (int v : adj[u]) {
        if (color[v] == 0) {
            dfs(v);              // 未访问:递归
        } else if (color[v] == 1) {
            hasCycle = true;     // ← 回边:v 是 u 的祖先 → 有环!
        }
        // color[v] == 2:已完全处理,安全跳过
    }

    color[u] = 2;  // 标记为「完成」(黑色)
}

无向图环检测(更简单!)

对于无向图,不需要三色。规则更简单:DFS 过程中,若遇到已访问的节点且不是当前节点的父节点,就有环。

📄 对于**无向图**,不需要三色。规则更简单:DFS 过程中,若遇到已访问的节点且**不是当前节点的父节点**,就有环。
// 无向图环检测 — O(V+E)
void dfs(int u, int parent) {
    visited[u] = true;
    for (int v : adj[u]) {
        if (!visited[v]) {
            dfs(v, u);                    // v 的父节点是 u
        } else if (v != parent) {
            hasCycle = true;              // 已访问且不是父节点 → 有环!
        }
    }
}
// 调用:dfs(1, -1);  // 从节点 1 开始,无父节点(用 -1 作哨兵)

5.2.9 拓扑排序

拓扑排序对**有向无环图(DAG)**的节点排序,使对每条边 u → v,u 在排序中排在 v 之前。

方法一:基于 DFS 的拓扑排序

📄 查看代码:方法一:基于 DFS 的拓扑排序
// 拓扑排序(DFS)— O(V+E)
vector<int> topoOrder;

void dfs(int u) {
    visited[u] = true;
    for (int v : adj[u]) {
        if (!visited[v]) dfs(v);
    }
    topoOrder.push_back(u);  // ← 所有子节点处理完后加入(后序)
}

// 使用:
for (int u = 1; u <= n; u++)
    if (!visited[u]) dfs(u);

reverse(topoOrder.begin(), topoOrder.end());  // 逆后序 = 拓扑顺序

方法二:Kahn 算法(基于 BFS 的拓扑排序)

📄 查看代码:方法二:Kahn 算法(基于 BFS 的拓扑排序)
// Kahn 算法:先处理入度为 0 的节点 — O(V+E)
vector<int> inDeg(n + 1, 0);
for (int u = 1; u <= n; u++)
    for (int v : adj[u])
        inDeg[v]++;

queue<int> q;
for (int u = 1; u <= n; u++)
    if (inDeg[u] == 0) q.push(u);  // 从无前置条件的节点开始

vector<int> order;
while (!q.empty()) {
    int u = q.front(); q.pop();
    order.push_back(u);
    for (int v : adj[u]) {
        inDeg[v]--;
        if (inDeg[v] == 0) q.push(v);
    }
}

// 若 order.size() != n,有环(不是 DAG)
if ((int)order.size() != n) cout << "有环\n";
else for (int u : order) cout << u << " ";

💡 关键应用: 拓扑排序是 DAG 上 DP 的基础。若依赖关系图是 DAG,按拓扑顺序处理节点——每个节点的 DP 状态只依赖之前处理过的节点。


5.2.10 变种一:0-1 BFS(权重只有 0 和 1)

问题引入

普通 BFS 只能处理无权图(每步代价相同)。当图的边权只有 01 时,用 Dijkstra 太重(O(E log V)),普通 BFS 又不对——但我们有更简单的方案:0-1 BFS,仍然 O(V+E)。

核心思想

双端队列(deque) 代替普通队列:

  • 经过权重为 0 的边 → 新节点从队首插入(代价不增,优先处理)
  • 经过权重为 1 的边 → 新节点从队尾插入(代价 +1)

这样队列始终保持「距离单调不减」,与 BFS 的层序性质等价。

普通 BFS 队列(所有边权=1):
  队首 [d=0, d=1, d=1, d=2, d=2] 队尾

0-1 BFS 双端队列(边权0从队首,边权1从队尾):
  队首 [d=0, d=0, d=1, d=1, d=2] 队尾
         ↑ 权重0的邻居插到前面

完整实现

📄 查看代码:完整实现
#include <bits/stdc++.h>
using namespace std;
typedef pair<int,int> pii;

// 0-1 BFS — O(V+E)
// adj[u] = {v, w},w 只有 0 或 1
vector<pii> adj[MAXN];

vector<int> bfs_01(int src, int n) {
    vector<int> dist(n + 1, INT_MAX);
    deque<int> dq;
    
    dist[src] = 0;
    dq.push_front(src);
    
    while (!dq.empty()) {
        int u = dq.front(); dq.pop_front();
        
        for (auto [v, w] : adj[u]) {
            if (dist[u] + w < dist[v]) {
                dist[v] = dist[u] + w;
                if (w == 0)
                    dq.push_front(v);   // 代价不增,放队首
                else
                    dq.push_back(v);    // 代价 +1,放队尾
            }
        }
    }
    return dist;
}

典型应用场景

场景0-1 BFS 怎么建模
网格中某些格子可以免费穿越免费格子边权 = 0,普通格子 = 1
换一次道路方向不花费用反向走 = 0,顺向 = 1
可以携带有限数量的道具扩展状态,使用道具时边权 = 0

5.2.11 变种二:双向 BFS(Bidirectional BFS)

问题引入

普通 BFS 从起点向外扩展,当图很大时,搜索空间是 O(b^d)(b = 分支因子,d = 距离)。
若从起点和终点同时扩展,总搜索空间变为 O(2 × b^(d/2)) = O(b^(d/2)),指数级加速!

核心思路

维护两个 BFS 前沿(frontier):

  • 正向前沿:从起点向外扩展
  • 反向前沿:从终点向外扩展

每次扩展较小的前沿(减少总节点数)。当某个节点同时出现在两个前沿中,找到了最短路径。

📄 每次扩展**较小的前沿**(减少总节点数)。当某个节点同时出现在两个前沿中,找到了最短路径。
#include <bits/stdc++.h>
using namespace std;

// 双向 BFS — 适用于无权无向图,O(b^(d/2)) 而非 O(b^d)
// 返回 src 到 dst 的最短路径长度,-1 表示不可达
int bidir_bfs(int src, int dst, int n, vector<vector<int>>& adj) {
    if (src == dst) return 0;
    
    // 两个方向的距离数组,-1 = 未访问
    vector<int> dist_f(n + 1, -1), dist_b(n + 1, -1);
    queue<int> qf, qb;
    
    dist_f[src] = 0; qf.push(src);
    dist_b[dst] = 0; qb.push(dst);
    
    while (!qf.empty() || !qb.empty()) {
        // 每次扩展较小的前沿
        if (qf.size() <= qb.size()) {
            // 扩展正向一层
            int sz = qf.size();
            while (sz--) {
                int u = qf.front(); qf.pop();
                for (int v : adj[u]) {
                    if (dist_f[v] == -1) {
                        dist_f[v] = dist_f[u] + 1;
                        qf.push(v);
                    }
                    // 若 v 已被反向 BFS 访问 → 找到!
                    if (dist_b[v] != -1)
                        return dist_f[v] + dist_b[v];
                }
            }
        } else {
            // 扩展反向一层
            int sz = qb.size();
            while (sz--) {
                int u = qb.front(); qb.pop();
                for (int v : adj[u]) {
                    if (dist_b[v] == -1) {
                        dist_b[v] = dist_b[u] + 1;
                        qb.push(v);
                    }
                    if (dist_f[v] != -1)
                        return dist_f[v] + dist_b[v];
                }
            }
        }
    }
    return -1;  // 不可达
}

速度对比

图类型BFS 节点数双向 BFS 节点数
分支因子 b=10,距离 d=610^6 = 1,000,0002 × 10^3 = 2,000
分支因子 b=4,距离 d=204^20 ≈ 10^122 × 4^10 ≈ 2×10^6

5.2.12 变种三:DFS 回溯与剪枝

什么是回溯?

回溯是 DFS 的一种应用模式:系统地枚举所有可能的选择,发现不合法时撤销(回溯)

三要素:

  1. 选择:在当前状态下做一个决定
  2. 递归:进入下一层状态
  3. 撤销:从递归返回后,撤销这个决定(恢复现场)

模板框架

📄 查看代码:模板框架
void backtrack(状态) {
    if (达到终止条件) {
        记录结果;
        return;
    }
    
    for (每个可能的选择) {
        if (选择不合法) continue;    // 剪枝:提前排除无效分支
        
        做选择;                        // 修改状态
        backtrack(新状态);            // 递归
        撤销选择;                      // 恢复状态(回溯!)
    }
}

经典例题一:全排列

📄 查看代码:经典例题一:全排列
// 生成 [1..n] 的所有排列
#include <bits/stdc++.h>
using namespace std;

vector<vector<int>> result;
vector<int> perm;
vector<bool> used;

void backtrack(int n) {
    if ((int)perm.size() == n) {
        result.push_back(perm);
        return;
    }
    
    for (int i = 1; i <= n; i++) {
        if (used[i]) continue;  // 剪枝:已使用过,跳过
        
        used[i] = true;
        perm.push_back(i);      // 做选择
        
        backtrack(n);           // 递归
        
        perm.pop_back();        // 撤销选择
        used[i] = false;
    }
}

int main() {
    int n = 3;
    used.assign(n + 1, false);
    backtrack(n);
    for (auto& p : result) {
        for (int x : p) cout << x << " ";
        cout << "\n";
    }
    // 输出全部 6 种排列:1 2 3 / 1 3 2 / 2 1 3 / 2 3 1 / 3 1 2 / 3 2 1
    return 0;
}

经典例题二:N 皇后

在 N×N 棋盘上放置 N 个皇后,使任意两个皇后不在同一行、列、对角线。

📄 在 N×N 棋盘上放置 N 个皇后,使任意两个皇后不在同一行、列、对角线。
#include <bits/stdc++.h>
using namespace std;

int n;
int ans = 0;
vector<int> col;      // col[r] = 第 r 行皇后所在的列

bool is_valid(int row, int c) {
    for (int r = 0; r < row; r++) {
        if (col[r] == c) return false;               // 同列
        if (abs(col[r] - c) == abs(r - row)) return false; // 对角线
    }
    return true;
}

void solve(int row) {
    if (row == n) { ans++; return; }
    
    for (int c = 0; c < n; c++) {
        if (!is_valid(row, c)) continue;  // 剪枝
        
        col[row] = c;     // 做选择:第 row 行放在列 c
        solve(row + 1);   // 递归下一行
        // 无需显式撤销:下次赋值会覆盖 col[row]
    }
}

int main() {
    cin >> n;
    col.resize(n);
    solve(0);
    cout << ans << "\n";  // n=8 输出 92
    return 0;
}

更快的 N 皇后(位运算剪枝)

📄 查看代码:更快的 N 皇后(位运算剪枝)
// 用位运算表示列/主对角线/副对角线的占用情况,O(n!) 但常数极小
int n, ans = 0;

void solve(int row, int cols, int diag1, int diag2) {
    if (row == n) { ans++; return; }
    
    // 所有被占用的位置的掩码
    int occupied = cols | diag1 | diag2;
    // 可以放置的列:取反后取低 n 位
    int available = (~occupied) & ((1 << n) - 1);
    
    while (available) {
        int bit = available & (-available);  // 取最低的可用位
        available -= bit;
        solve(row + 1,
              cols | bit,
              (diag1 | bit) << 1,
              (diag2 | bit) >> 1);
    }
}

int main() {
    cin >> n;
    solve(0, 0, 0, 0);
    cout << ans << "\n";
}

5.2.13 变种四:割点与桥(Tarjan 算法)

问题引入

在一个通信网络中,哪个节点被摧毁会导致网络分裂?哪条线路被切断会导致网络不连通?

  • 割点(Articulation Point): 删除该节点后图的连通分量数增加
  • 桥(Bridge): 删除该边后图的连通分量数增加

核心思想:DFS 序 + low 值

DFS 时给每个节点分配一个时间戳 disc[u](进入时间)。
再定义 low[u] = 通过 u 的子树(含至多一条回边)能到达的最早时间戳。

low[u] = min(
    disc[u],                      // u 自身的时间戳
    min(disc[v] for all v: (u,v) 是回边),  // 直接回边
    min(low[v] for all v: v 是 u 的树边子节点)  // 子节点的 low 值
)

判断割点:

  • 根节点:有 ≥ 2 个子树子节点
  • 非根节点 u:存在子节点 v 使得 low[v] >= disc[u](v 的子树无法绕过 u 连到 u 的祖先)

判断桥: 边 (u, v) 是桥当且仅当 low[v] > disc[u](v 的子树严格不能绕过这条边)

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

const int MAXN = 100005;
vector<int> adj[MAXN];
int disc[MAXN], low[MAXN], timer_val = 0;
bool visited[MAXN], is_cut[MAXN];
vector<pair<int,int>> bridges;

void dfs(int u, int parent) {
    visited[u] = true;
    disc[u] = low[u] = ++timer_val;
    int child_cnt = 0;  // u 作为根时的子树数量
    
    for (int v : adj[u]) {
        if (!visited[v]) {
            child_cnt++;
            dfs(v, u);
            low[u] = min(low[u], low[v]);
            
            // 判断割点(非根)
            if (parent != -1 && low[v] >= disc[u])
                is_cut[u] = true;
            
            // 判断桥
            if (low[v] > disc[u])
                bridges.push_back({u, v});
                
        } else if (v != parent) {
            // 回边(不走父节点方向)
            low[u] = min(low[u], disc[v]);
        }
    }
    
    // 判断割点(根节点:有 >= 2 个子树)
    if (parent == -1 && child_cnt >= 2)
        is_cut[u] = true;
}

int main() {
    int n, m;
    cin >> n >> m;
    for (int i = 0; i < m; i++) {
        int u, v; cin >> u >> v;
        adj[u].push_back(v);
        adj[v].push_back(u);
    }
    
    for (int i = 1; i <= n; i++)
        if (!visited[i]) dfs(i, -1);
    
    cout << "割点:";
    for (int i = 1; i <= n; i++)
        if (is_cut[i]) cout << i << " ";
    cout << "\n桥:";
    for (auto [u, v] : bridges)
        cout << u << "-" << v << " ";
    cout << "\n";
    return 0;
}

完整追踪示例

📄 查看代码:完整追踪示例
图:1-2-3-4,另有 2-4
    
DFS 从 1 开始:disc[1]=1, low[1]=1
  → 访问 2:disc[2]=2, low[2]=2
    → 访问 3:disc[3]=3, low[3]=3
      → 访问 4:disc[4]=4, low[4]=4
        → 邻居 2 已访问(回边):low[4] = min(4, disc[2]) = 2
      回退到 3:low[3] = min(3, low[4]) = 2
    回退到 2:low[2] = min(2, low[3]) = 2
      low[3]=2 >= disc[2]=2? YES → 2 是割点(删2后3-4孤立)
    注意:low[3]=2 不严格大于 disc[2]=2,所以 2-3 不是桥
    
最终:割点={2},桥={}

5.2.14 变种五:迭代加深 DFS(IDDFS)

问题引入

如果你需要找最短路径,但图太大无法用 BFS(内存不足),或者需要找有限步数内的解,可以使用 迭代加深 DFS(Iterative Deepening DFS,IDDFS)

特点:

  • 用 DFS 的空间效率(O(d),只记当前路径)
  • 用 BFS 的最优性(逐层探索)

原理: 从深度限制 limit=1 开始,不断增加限制,每次做一次深度受限的 DFS:

limit=1 → DFS 最多走 1 步
limit=2 → DFS 最多走 2 步
limit=3 → DFS 最多走 3 步
...
直到找到目标

每次重新从根出发,看起来浪费,但由于树的节点数指数增长,最深一层的节点占大部分,总代价只比 BFS 多约 35%(当 b=2 时),却省去了 BFS 的大量内存。

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

// 迭代加深 DFS 框架
bool dfs_limited(int node, int depth, int limit, int parent,
                 vector<vector<int>>& adj) {
    if (depth == limit) {
        // 在最大深度检查是否是目标
        return is_goal(node);
    }
    for (int v : adj[node]) {
        if (v == parent) continue;
        if (dfs_limited(v, depth + 1, limit, node, adj))
            return true;
    }
    return false;
}

int iddfs(int src, int max_depth, vector<vector<int>>& adj) {
    for (int limit = 0; limit <= max_depth; limit++) {
        if (dfs_limited(src, 0, limit, -1, adj))
            return limit;  // 找到目标,返回最小步数
    }
    return -1;  // 未找到
}

A* 搜索简介

IDDFS 的升级版是 IDA*(Iterative Deepening A*):在深度限制的基础上加入启发函数 h(n)(对剩余距离的估计),直接剪掉 depth + h(n) > limit 的分支。

📄 C++ 完整代码
// IDA* 框架
int threshold;
int target;

int h(int node) {
    return /* 到目标的估计距离(必须是下界)*/;
}

int search(int node, int g, int parent, vector<vector<int>>& adj) {
    int f = g + h(node);
    if (f > threshold) return f;  // 超过阈值,剪枝并返回 f
    if (node == target) return -1; // 找到!-1 作为成功标志
    
    int min_t = INT_MAX;
    for (int v : adj[node]) {
        if (v == parent) continue;
        int t = search(v, g + 1, node, adj);
        if (t == -1) return -1;
        min_t = min(min_t, t);
    }
    return min_t;
}

int ida_star(int src) {
    threshold = h(src);
    while (true) {
        int t = search(src, 0, -1, adj);
        if (t == -1) return threshold;  // 找到
        if (t == INT_MAX) return -1;    // 无解
        threshold = t;                   // 更新阈值
    }
}

5.2.15 变种六:BFS 分层遍历

二叉树的层序遍历

BFS 的一个重要变种是精确追踪当前在第几层(第几步),常用于树的层序遍历、按层输出节点等。

BFS 层次遍历:逐层扩展

📄 BFS 的一个重要变种是**精确追踪当前在第几层**(第几步),常用于树的层序遍历、按层输出节点等。
// 层序遍历二叉树,每层单独输出
#include <bits/stdc++.h>
using namespace std;

struct TreeNode { int val; TreeNode* left, *right; };

vector<vector<int>> level_order(TreeNode* root) {
    if (!root) return {};
    
    vector<vector<int>> result;
    queue<TreeNode*> q;
    q.push(root);
    
    while (!q.empty()) {
        int sz = q.size();         // 当前层的节点数
        vector<int> level;
        
        for (int i = 0; i < sz; i++) {
            TreeNode* node = q.front(); q.pop();
            level.push_back(node->val);
            if (node->left)  q.push(node->left);
            if (node->right) q.push(node->right);
        }
        
        result.push_back(level);  // 一整层
    }
    return result;
}

图的 BFS 按层处理

在图中,有时需要「先处理完所有距离 d 的节点,再处理距离 d+1 的」。方法完全相同:用 sz = q.size() 快照当前层大小。

📄 C++ 完整代码
// 图 BFS 按层处理模板
void bfs_by_layer(int src, vector<vector<int>>& adj, int n) {
    vector<int> dist(n + 1, -1);
    queue<int> q;
    dist[src] = 0;
    q.push(src);
    
    int layer = 0;
    while (!q.empty()) {
        int sz = q.size();         // 当前层的节点数量
        cout << "第 " << layer << " 层:";
        
        for (int i = 0; i < sz; i++) {
            int u = q.front(); q.pop();
            cout << u << " ";
            
            for (int v : adj[u]) {
                if (dist[v] == -1) {
                    dist[v] = dist[u] + 1;
                    q.push(v);
                }
            }
        }
        cout << "\n";
        layer++;
    }
}

5.2.16 变种七:记忆化 DFS(DFS + 动态规划)

DFS 经常需要处理重叠子问题——相同状态被多次访问。用记忆化(memoization)避免重复计算,这正是 DP 的核心。

记忆化 DFS 模板

📄 查看代码:记忆化 DFS 模板
// 有向图上,从节点 u 出发能到达的最大价值(记忆化 DFS)
#include <bits/stdc++.h>
using namespace std;

int n;
vector<int> adj[MAXN];
int val[MAXN];             // 每个节点的价值
int memo[MAXN];            // memo[u] = 从 u 出发的最大价值(-1=未计算)

int dfs(int u) {
    if (memo[u] != -1) return memo[u];  // 已算过,直接返回
    
    memo[u] = val[u];  // 至少获得当前节点的价值
    for (int v : adj[u]) {
        memo[u] = max(memo[u], val[u] + dfs(v));
    }
    return memo[u];
}

int main() {
    cin >> n;
    fill(memo, memo + n + 1, -1);
    // ... 读入图和价值 ...
    
    int ans = 0;
    for (int i = 1; i <= n; i++)
        ans = max(ans, dfs(i));
    cout << ans << "\n";
    return 0;
}

记忆化 DFS vs 标准 DP

对比记忆化 DFS标准 DP(递推)
编写方式递归(自顶向下)迭代(自底向上)
何时计算用到时才算按顺序全部算
适用场景状态转移顺序复杂时状态转移顺序清晰时
栈溢出风险有(深度过大时)

5.2.17 BFS/DFS 变种速查表

变种场景数据结构时间复杂度
普通 BFS无权最短路、连通性队列O(V+E)
普通 DFS连通性、环检测、拓扑排序栈/递归O(V+E)
0-1 BFS边权 0 或 1 的最短路双端队列O(V+E)
双向 BFS大图中点对最短路两个队列O(b^(d/2))
多源 BFS到多个源点的最短距离队列(多源初始化)O(V+E)
回溯 DFS枚举所有合法方案递归+恢复现场问题相关
割点/桥找关键节点/边DFS+disc/lowO(V+E)
IDDFS内存受限的最短路递归(限深)O(b^d),空间 O(d)
BFS 分层按层处理节点队列+层快照O(V+E)
记忆化 DFSDAG 上 DP递归+备忘录O(状态数×转移数)

BFS Grid Distances


变种专项练习题(共 8 道,全部含完整解答)

题目 5.2.6 — 0-1 BFS:网格最少翻转 🟡 中等

题目: N×M 网格,每格是 0(免费通过)或 1(需花费 1 元)。从左上角到右下角,求最少花费。

示例:

输入:3 3
      0 1 0
      0 0 1
      1 0 0
输出:0
(路径 (0,0)→(1,0)→(1,1)→(2,1)→(2,2),全走 0 格,花费 0)
✅ 完整解答

思路: 经过 0 格子边权为 0,经过 1 格子边权为 1,使用 0-1 BFS(双端队列)。

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

int main() {
    ios_base::sync_with_stdio(false); cin.tie(NULL);
    int R, C; cin >> R >> C;
    vector<vector<int>> grid(R, vector<int>(C));
    for (auto& row : grid) for (int& x : row) cin >> x;
    
    vector<vector<int>> dist(R, vector<int>(C, INT_MAX));
    deque<pair<int,int>> dq;
    dist[0][0] = grid[0][0];
    dq.push_back({0, 0});
    
    int dr[] = {-1, 1, 0, 0};
    int dc[] = {0, 0, -1, 1};
    
    while (!dq.empty()) {
        auto [r, c] = dq.front(); dq.pop_front();
        
        for (int d = 0; d < 4; d++) {
            int nr = r + dr[d], nc = c + dc[d];
            if (nr < 0 || nr >= R || nc < 0 || nc >= C) continue;
            
            int nd = dist[r][c] + grid[nr][nc];
            if (nd < dist[nr][nc]) {
                dist[nr][nc] = nd;
                if (grid[nr][nc] == 0)
                    dq.push_front({nr, nc});   // 免费:队首
                else
                    dq.push_back({nr, nc});    // 花费:队尾
            }
        }
    }
    cout << dist[R-1][C-1] << "\n";
    return 0;
}

追踪:

dist[0][0]=0(grid=0,放队首)
处理(0,0):扩展(1,0) grid=0→dist=0,放队首;(0,1) grid=1→dist=1,放队尾
处理(1,0):扩展(1,1) grid=0→dist=0;(2,0) grid=1→dist=1
...
最终 dist[2][2] = 0

题目 5.2.7 — 回溯:子集枚举 🟢 简单

题目: 给定长度为 N 的数组,输出所有子集(包含空集),每行一个子集,元素从小到大,子集按字典序排列。

示例:

📄 Code 完整代码
输入:3
     1 2 3
输出:
(空行,空集)
1
1 2
1 2 3
1 3
2
2 3
3
✅ 完整解答

思路: 对每个元素,递归时选择「选」或「不选」(二叉回溯树)。

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

int n;
vector<int> a, cur;

void backtrack(int idx) {
    // 输出当前子集
    for (int i = 0; i < (int)cur.size(); i++) {
        if (i) cout << " ";
        cout << cur[i];
    }
    cout << "\n";
    
    for (int i = idx; i < n; i++) {
        cur.push_back(a[i]);   // 选 a[i]
        backtrack(i + 1);
        cur.pop_back();         // 不选 a[i](回溯)
    }
}

int main() {
    cin >> n;
    a.resize(n);
    for (int& x : a) cin >> x;
    sort(a.begin(), a.end());  // 保证字典序
    backtrack(0);
    return 0;
}

理解回溯树(N=2,a=[1,2]):

backtrack(0):输出[]
  选1 → backtrack(1):输出[1]
    选2 → backtrack(2):输出[1,2]
    不选2
  不选1
  选2 → backtrack(2):输出[2]
  不选2

题目 5.2.8 — 回溯+剪枝:数字组合求和 🟡 中等

题目: 给定不含重复元素的正整数数组 candidates 和目标值 target,找出所有和为 target 的组合(每个数可以重复使用,组合内数字升序排列,不同组合间无重复)。

示例:

candidates=[2,3,6,7], target=7
输出:[2,2,3], [7]
✅ 完整解答
#include <bits/stdc++.h>
using namespace std;

vector<int> cands;
vector<vector<int>> result;
vector<int> cur;

void backtrack(int idx, int remain) {
    if (remain == 0) {
        result.push_back(cur);
        return;
    }
    
    for (int i = idx; i < (int)cands.size(); i++) {
        if (cands[i] > remain) break;   // 剪枝:已排序,超过 remain 的后续都不用看
        
        cur.push_back(cands[i]);
        backtrack(i, remain - cands[i]);  // i 而非 i+1,允许重复使用
        cur.pop_back();
    }
}

int main() {
    int n, target;
    cin >> n >> target;
    cands.resize(n);
    for (int& x : cands) cin >> x;
    sort(cands.begin(), cands.end());  // 排序,为了剪枝和去重
    
    backtrack(0, target);
    
    for (auto& v : result) {
        for (int i = 0; i < (int)v.size(); i++) {
            if (i) cout << " ";
            cout << v[i];
        }
        cout << "\n";
    }
    return 0;
}

关键剪枝: 数组已排序,若 cands[i] > remain,后续更大的数也一定超过,直接 break。


题目 5.2.9 — 双向 BFS:单词接龙 🟡 中等

题目: 给定起始单词 beginWord、目标单词 endWord 和单词列表。每次只能改变一个字母,且改变后的单词必须在列表中。求从 beginWordendWord 的最短转换路径长度,若不存在返回 0。

示例:

beginWord = "hit", endWord = "cog"
wordList = ["hot","dot","dog","lot","log","cog"]
输出:5(hit→hot→dot→dog→cog)
✅ 完整解答

普通 BFS 版:

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

int word_ladder(string begin, string end, vector<string>& wordList) {
    unordered_set<string> words(wordList.begin(), wordList.end());
    if (!words.count(end)) return 0;
    
    queue<string> q;
    unordered_map<string, int> dist;
    dist[begin] = 1;
    q.push(begin);
    
    while (!q.empty()) {
        string cur = q.front(); q.pop();
        
        // 枚举所有相差一个字母的单词
        for (int i = 0; i < (int)cur.size(); i++) {
            string next = cur;
            for (char c = 'a'; c <= 'z'; c++) {
                next[i] = c;
                if (next == cur) continue;
                if (next == end) return dist[cur] + 1;
                if (words.count(next) && !dist.count(next)) {
                    dist[next] = dist[cur] + 1;
                    q.push(next);
                }
            }
        }
    }
    return 0;
}

int main() {
    string begin, end; int n;
    cin >> begin >> end >> n;
    vector<string> wordList(n);
    for (auto& w : wordList) cin >> w;
    cout << word_ladder(begin, end, wordList) << "\n";
    return 0;
}

双向 BFS 优化版(大词表时更快):

int word_ladder_bidir(string begin, string end, vector<string>& wordList) {
    unordered_set<string> words(wordList.begin(), wordList.end());
    if (!words.count(end)) return 0;
    
    // 两个方向的已访问集合
    unordered_set<string> front_visited = {begin};
    unordered_set<string> back_visited  = {end};
    int step = 1;
    
    while (!front_visited.empty() && !back_visited.empty()) {
        // 每次扩展较小的前沿(提高效率)
        if (front_visited.size() > back_visited.size())
            swap(front_visited, back_visited);
        
        unordered_set<string> next_front;
        for (const string& word : front_visited) {
            string next = word;
            for (int i = 0; i < (int)next.size(); i++) {
                char orig = next[i];
                for (char c = 'a'; c <= 'z'; c++) {
                    next[i] = c;
                    if (back_visited.count(next)) return step + 1;  // 相遇!
                    if (words.count(next) && !front_visited.count(next))
                        next_front.insert(next);
                    next[i] = orig;
                }
            }
        }
        front_visited = next_front;
        step++;
    }
    return 0;
}

题目 5.2.10 — 割点与桥:关键服务器 🔴 困难

题目: 有 N 台服务器和 M 条连接。若断开某条连接后,某些服务器无法相互通信,则这条连接是关键连接(即桥)。找出所有关键连接。

示例:

输入:4 4
     1-2, 1-3, 2-3, 3-4
输出:3-4
(删去 1-2 或 1-3 或 2-3,{1,2,3} 仍然连通;只有 3-4 是桥)
✅ 完整解答

直接使用 5.2.13 节的 Tarjan 桥检测算法:

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

const int MAXN = 100005;
vector<pair<int,int>> adj[MAXN];  // {邻居, 边编号}
int disc[MAXN], low[MAXN], timer_val = 0;
bool visited[MAXN];
vector<pair<int,int>> bridges;

void dfs(int u, int par_edge) {
    visited[u] = true;
    disc[u] = low[u] = ++timer_val;
    
    for (auto [v, eid] : adj[u]) {
        if (eid == par_edge) continue;  // 不走来时的边(用边ID区分,处理重边)
        
        if (!visited[v]) {
            dfs(v, eid);
            low[u] = min(low[u], low[v]);
            
            if (low[v] > disc[u])       // 桥的判断
                bridges.push_back({u, v});
        } else {
            low[u] = min(low[u], disc[v]);
        }
    }
}

int main() {
    ios_base::sync_with_stdio(false); cin.tie(NULL);
    int n, m; cin >> n >> m;
    
    for (int i = 0; i < m; i++) {
        int u, v; cin >> u >> v;
        adj[u].push_back({v, i});
        adj[v].push_back({u, i});
    }
    
    for (int i = 1; i <= n; i++)
        if (!visited[i]) dfs(i, -1);
    
    cout << "关键连接数:" << bridges.size() << "\n";
    for (auto [u, v] : bridges)
        cout << u << " - " << v << "\n";
    return 0;
}

注意用「边 ID」而非「父节点」 来避免走回来时的边,这样可以正确处理重边(两个节点之间有多条边的情况)。


题目 5.2.11 — 记忆化 DFS:最长递增路径 🟡 中等

题目: 给定 N×M 矩阵,每次可以向上下左右移动到严格更大的相邻格子。求矩阵中最长递增路径的长度。

示例:

输入:3 3
     9 9 4
     6 6 8
     2 1 1
输出:4(路径 1→2→6→9)
✅ 完整解答

思路: 因为只能往更大的格子走,不会有环,图是 DAG。用记忆化 DFS:memo[r][c] = 从 (r,c) 出发的最长路径长度。

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

int R, C;
vector<vector<int>> mat;
vector<vector<int>> memo;

int dr[] = {-1, 1, 0, 0};
int dc[] = {0, 0, -1, 1};

int dfs(int r, int c) {
    if (memo[r][c] != 0) return memo[r][c];  // 已计算过
    
    memo[r][c] = 1;  // 至少长度为 1(只含自身)
    for (int d = 0; d < 4; d++) {
        int nr = r + dr[d], nc = c + dc[d];
        if (nr < 0 || nr >= R || nc < 0 || nc >= C) continue;
        if (mat[nr][nc] > mat[r][c]) {             // 严格更大才能走
            memo[r][c] = max(memo[r][c], 1 + dfs(nr, nc));
        }
    }
    return memo[r][c];
}

int main() {
    ios_base::sync_with_stdio(false); cin.tie(NULL);
    cin >> R >> C;
    mat.assign(R, vector<int>(C));
    memo.assign(R, vector<int>(C, 0));
    for (auto& row : mat) for (int& x : row) cin >> x;
    
    int ans = 0;
    for (int r = 0; r < R; r++)
        for (int c = 0; c < C; c++)
            ans = max(ans, dfs(r, c));
    
    cout << ans << "\n";
    return 0;
}

为什么不需要 visited 数组? 因为路径单调递增,不可能访问同一个格子两次,天然无环。memo[r][c]!=0 就代表已经算过了。


题目 5.2.12 — 多源 BFS:地图离源距离 🟡 中等

题目: N×M 网格,有些格子是障碍 #,有些是出口 E,其余是空格 .
对所有空格,输出到最近出口的步数(不能穿越障碍),若无法到达输出 -1。

✅ 完整解答

思路: 多源 BFS——把所有出口以距离 0 同时压入队列,BFS 向外扩散。

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

int main() {
    ios_base::sync_with_stdio(false); cin.tie(NULL);
    int R, C; cin >> R >> C;
    vector<string> grid(R);
    for (auto& row : grid) cin >> row;
    
    vector<vector<int>> dist(R, vector<int>(C, -1));
    queue<pair<int,int>> q;
    
    // 所有出口以距离 0 入队
    for (int r = 0; r < R; r++)
        for (int c = 0; c < C; c++)
            if (grid[r][c] == 'E') {
                dist[r][c] = 0;
                q.push({r, c});
            }
    
    int dr[] = {-1,1,0,0}, dc[] = {0,0,-1,1};
    while (!q.empty()) {
        auto [r, c] = q.front(); q.pop();
        for (int d = 0; d < 4; d++) {
            int nr = r+dr[d], nc = c+dc[d];
            if (nr<0||nr>=R||nc<0||nc>=C) continue;
            if (grid[nr][nc]=='#') continue;
            if (dist[nr][nc] != -1) continue;
            dist[nr][nc] = dist[r][c] + 1;
            q.push({nr, nc});
        }
    }
    
    for (int r = 0; r < R; r++) {
        for (int c = 0; c < C; c++) {
            if (grid[r][c] == '#') cout << "#  ";
            else if (grid[r][c] == 'E') cout << "E  ";
            else cout << dist[r][c] << "  ";
        }
        cout << "\n";
    }
    return 0;
}

题目 5.2.13 — 综合挑战:迷宫中的钥匙 🔴 困难

题目: N×M 迷宫,包含:

  • S:起点
  • E:终点
  • #:墙
  • .:空地
  • a~f:钥匙(小写字母)
  • A~F:锁住的门(大写字母,需要对应钥匙才能进入)

从 S 走到 E,求最少步数。钥匙捡起后一直携带。

提示: 状态需要扩展为 (行, 列, 已携带的钥匙集合),用 BFS 搜索最短路。

✅ 完整解答

关键:状态 = (位置 + 钥匙集合)。钥匙只有 6 把,用 6 位二进制整数表示集合(0~63)。

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

int main() {
    ios_base::sync_with_stdio(false); cin.tie(NULL);
    int R, C; cin >> R >> C;
    vector<string> grid(R);
    for (auto& row : grid) cin >> row;
    
    int sr, sc, er, ec;
    for (int r = 0; r < R; r++)
        for (int c = 0; c < C; c++) {
            if (grid[r][c] == 'S') { sr=r; sc=c; }
            if (grid[r][c] == 'E') { er=r; ec=c; }
        }
    
    // dist[r][c][keys] = 步数,keys 是 6 位位掩码
    vector<vector<vector<int>>> dist(R, vector<vector<int>>(C, vector<int>(64, -1)));
    queue<tuple<int,int,int>> q;
    
    dist[sr][sc][0] = 0;
    q.push({sr, sc, 0});
    
    int dr[] = {-1,1,0,0}, dc[] = {0,0,-1,1};
    
    while (!q.empty()) {
        auto [r, c, keys] = q.front(); q.pop();
        
        if (r == er && c == ec) {
            cout << dist[er][ec][keys] << "\n";
            return 0;
        }
        
        for (int d = 0; d < 4; d++) {
            int nr = r+dr[d], nc = c+dc[d];
            if (nr<0||nr>=R||nc<0||nc>=C) continue;
            char cell = grid[nr][nc];
            if (cell == '#') continue;
            
            // 门:需要对应钥匙
            if (isupper(cell) && !(keys & (1 << (cell-'A')))) continue;
            
            // 捡钥匙
            int nkeys = keys;
            if (islower(cell)) nkeys |= (1 << (cell-'a'));
            
            if (dist[nr][nc][nkeys] == -1) {
                dist[nr][nc][nkeys] = dist[r][c][keys] + 1;
                q.push({nr, nc, nkeys});
            }
        }
    }
    
    cout << -1 << "\n";  // 无法到达
    return 0;
}

状态空间大小: R × C × 64 = 最多 50×50×64 = 160,000 个状态,BFS 可以高效处理。

示例追踪:

起点 S(0,0),keys=0
→ 走到 a(0,1):keys = 0 | (1<<0) = 1(拿到钥匙 a)
→ 走到 A(1,1):keys=1,A 对应位 0,keys&1=1 ✓,可以通过
→ 最终到达 E

题目 5.2.14 — 拓扑排序:课程先修 🟡 中等

题目: 有 N 门课程和 M 条先修关系 (u, v) 表示课程 u 必须在 v 之前完成。判断是否存在合法的课程修读顺序,若存在则输出其中一种。

示例:

输入:4 4
     1 2
     1 3
     2 4
     3 4
输出:1 2 3 4(或其他合法拓扑序)
✅ 完整解答

核心思路: Kahn 算法(BFS 拓扑排序)。维护每个节点的入度,每次取入度为 0 的节点加入排序,将其所有邻居入度减 1。若最终排序长度 < N,说明图中有环。

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

int main() {
    ios_base::sync_with_stdio(false); cin.tie(NULL);
    int n, m; cin >> n >> m;

    vector<vector<int>> adj(n + 1);
    vector<int> inDeg(n + 1, 0);

    for (int i = 0; i < m; i++) {
        int u, v; cin >> u >> v;
        adj[u].push_back(v);
        inDeg[v]++;
    }

    queue<int> q;
    for (int u = 1; u <= n; u++)
        if (inDeg[u] == 0) q.push(u);

    vector<int> order;
    while (!q.empty()) {
        int u = q.front(); q.pop();
        order.push_back(u);
        for (int v : adj[u]) {
            inDeg[v]--;
            if (inDeg[v] == 0) q.push(v);
        }
    }

    if ((int)order.size() != n) {
        cout << "IMPOSSIBLE\n";
    } else {
        for (int i = 0; i < n; i++)
            cout << order[i] << " \n"[i == n-1];
    }
    return 0;
}

复杂度分析: 时间 O(N + M),空间 O(N + M)。


题目 5.2.15 — 有向图环检测 🟡 中等

题目: 给定 N 个节点、M 条有向边的图,判断是否存在环。若存在输出 "CYCLE",否则输出 "DAG"。

示例:

输入1:3 3  1→2, 2→3, 3→1  → CYCLE
输入2:3 2  1→2, 1→3       → DAG
✅ 完整解答

核心思路: 三色 DFS。白色(0)=未访问,灰色(1)=在当前路径上,黑色(2)=已完成。遇到灰色邻居则发现回边,存在环。

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

int main() {
    ios_base::sync_with_stdio(false); cin.tie(NULL);
    int n, m; cin >> n >> m;

    vector<vector<int>> adj(n + 1);
    for (int i = 0; i < m; i++) {
        int u, v; cin >> u >> v;
        adj[u].push_back(v);
    }

    vector<int> color(n + 1, 0);  // 0=白, 1=灰, 2=黑
    bool hasCycle = false;

    function<void(int)> dfs = [&](int u) {
        color[u] = 1;  // 灰色:正在处理
        for (int v : adj[u]) {
            if (color[v] == 0) dfs(v);
            else if (color[v] == 1) hasCycle = true;  // 回边
        }
        color[u] = 2;  // 黑色:处理完成
    };

    for (int u = 1; u <= n && !hasCycle; u++)
        if (color[u] == 0) dfs(u);

    cout << (hasCycle ? "CYCLE" : "DAG") << "\n";
    return 0;
}

复杂度分析: 时间 O(N + M),空间 O(N + M)。


题目 5.2.16 — IDDFS:限定步数的可达性 🔴 困难

题目: 给定 N 个节点的无向图和起点 S、终点 T,求从 S 到 T 的最短路径长度。但图的节点数可能极大(N ≤ 10^6),内存有限,无法存储完整的 BFS 距离数组。保证答案 d ≤ 20。

✅ 完整解答

核心思路: 迭代加深 DFS(IDDFS)。从深度限制 limit=0 开始递增,每次做一次深度不超过 limit 的 DFS。DFS 只需要 O(d) 的栈空间,而不需要 O(N) 的 BFS 距离数组。找到目标时当前 limit 就是最短路径。

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

int main() {
    ios_base::sync_with_stdio(false); cin.tie(NULL);
    int n, m, s, t;
    cin >> n >> m >> s >> t;

    vector<vector<int>> adj(n + 1);
    for (int i = 0; i < m; i++) {
        int u, v; cin >> u >> v;
        adj[u].push_back(v);
        adj[v].push_back(u);
    }

    vector<bool> on_path(n + 1, false);  // 当前路径上的节点(防止重复访问)

    // 深度受限的 DFS,返回是否找到目标
    function<bool(int, int)> dfs_limited = [&](int u, int depth) -> bool {
        if (u == t) return true;
        if (depth == 0) return false;

        on_path[u] = true;
        for (int v : adj[u]) {
            if (on_path[v]) continue;
            if (dfs_limited(v, depth - 1)) {
                on_path[u] = false;
                return true;
            }
        }
        on_path[u] = false;
        return false;
    };

    int ans = -1;
    for (int limit = 0; limit <= 20; limit++) {
        if (dfs_limited(s, limit)) {
            ans = limit;
            break;
        }
    }
    cout << ans << "\n";
    return 0;
}

复杂度分析: 每轮 DFS 时间 O(V+E),最多 d+1 轮。但第 i 轮只搜索深度 ≤ i 的节点,实际总代价为 O(b^d)(b 为分支因子),与 BFS 相同量级。空间仅 O(d)(路径栈),远优于 BFS 的 O(V)。

📖 第 5.3 章:函数图(Functional Graphs)

⏱ 预计阅读时间:35 分钟 | 难度:🟡 中等(USACO Silver 重要考点)


前置条件

  • BFS 与 DFS(第 5.2 章)
  • 图的基本表示(第 5.1 章)

🎯 学习目标

学完本章后,你将能够:

  1. 识别函数图的特征结构(每节点恰好一条出边)
  2. 找到函数图中的所有环(环检测)
  3. 计算图中每个节点距离其所在环的步数
  4. 解决"从节点 x 出发走 k 步到哪里"的跳跃问题(含快速幂加速)
  5. 应用到 USACO Silver 中的函数图专题

5.3.1 什么是函数图?

定义

函数图(Functional Graph) 是每个节点恰好有一条出边的有向图。

node[i] → next[i],每个节点有且仅有一个后继节点。

等价地,函数图就是一个映射 f: {1..N} → {1..N},从 i 走一步到 f(i)。

结构特征:ρ 形(rho 形)

由于每个节点只有一条出边,从任意节点出发最终必然进入(因为节点有限,走无限步必然重复)。

💡 Code 代码(12 行)
典型结构(ρ 形):

1 → 2 → 3 → 4 → 5
                ↑   ↓
                8 ← 6
                ↑
                7

节点 5, 6, 8 构成环
节点 1, 2, 3 是"尾巴"(最终进入环)
节点 4 是环的入口
节点 7 指向 8(悬挂在环上)

关键结论:

  • 每个连通分量恰好包含一个环
  • 其他节点都是"树"状悬挂在环上的

5.3.2 找环(Floyd 判环 / 着色法)

方法一:着色法(推荐,适合 USACO)

用三种颜色追踪 DFS 状态(与有向图环检测完全相同):

💡 CPP 代码(80 行)
#include <bits/stdc++.h>
using namespace std;

const int MAXN = 200005;
int nxt[MAXN];      // nxt[i] = i 的后继节点
int color[MAXN];    // 0=未访问, 1=访问中, 2=已完成
int on_cycle[MAXN]; // on_cycle[i] = i 是否在环上
int cycle_id[MAXN]; // 所在环的编号(-1表示不在环上)
int dist_to_cycle[MAXN]; // 到所在环的距离(环上节点为0)

int n;
int num_cycles = 0;

void find_cycle(int start) {
    // 沿着出边走,找到环的起点
    vector<int> path;
    int cur = start;
    
    while (color[cur] == 0) {
        color[cur] = 1;   // 标记为"访问中"
        path.push_back(cur);
        cur = nxt[cur];
    }
    
    if (color[cur] == 1) {
        // cur 是环的入口,标记整个环
        int cycle = num_cycles++;
        int v = cur;
        do {
            on_cycle[v] = true;
            cycle_id[v] = cycle;
            dist_to_cycle[v] = 0;
            v = nxt[v];
        } while (v != cur);
    }
    
    // 将路径上的节点标记为"已完成"
    for (int v : path) color[v] = 2;
}

int main() {
    cin >> n;
    fill(cycle_id, cycle_id + n + 1, -1);
    fill(color, color + n + 1, 0);
    
    for (int i = 1; i <= n; i++) cin >> nxt[i];
    
    for (int i = 1; i <= n; i++)
        if (color[i] == 0) find_cycle(i);
    
    // 计算非环节点到环的距离(BFS 从环向外)
    queue<int> q;
    // 建反向图
    vector<vector<int>> rev(n + 1);
    for (int i = 1; i <= n; i++) rev[nxt[i]].push_back(i);
    
    for (int i = 1; i <= n; i++)
        if (on_cycle[i]) { dist_to_cycle[i] = 0; q.push(i); }
    
    while (!q.empty()) {
        int u = q.front(); q.pop();
        for (int v : rev[u]) {
            if (!on_cycle[v] && dist_to_cycle[v] == 0 && v != u) {
                // 尚未计算距离
                dist_to_cycle[v] = dist_to_cycle[u] + 1;
                cycle_id[v] = cycle_id[u];  // 继承所在环
                q.push(v);
            }
        }
    }
    
    // 输出结果
    for (int i = 1; i <= n; i++) {
        cout << "节点 " << i
             << (on_cycle[i] ? " [在环上]" : " [不在环上]")
             << ",到环距离=" << dist_to_cycle[i]
             << ",所在环=" << cycle_id[i] << "\n";
    }
    return 0;
}

方法二:Floyd 判环(快慢指针)

适合判断单条路径是否有环(不需要找具体哪个节点在环上):

💡 CPP 代码(26 行)
// Floyd 快慢指针判环
// 从节点 start 出发,判断是否存在环
bool has_cycle_floyd(int start) {
    int slow = start, fast = start;
    do {
        slow = nxt[slow];
        fast = nxt[nxt[fast]];
    } while (slow != fast);
    return true;  // 函数图一定有环(节点有限)
}

// 找环的入口节点(环起点)
int find_cycle_entry(int start) {
    int slow = start, fast = start;
    do {
        slow = nxt[slow];
        fast = nxt[nxt[fast]];
    } while (slow != fast);
    // 相遇后,slow 从 start 重新出发,fast 留在原地,同速走
    slow = start;
    while (slow != fast) {
        slow = nxt[slow];
        fast = nxt[fast];
    }
    return slow;  // 环的入口
}

5.3.3 跳 k 步问题(倍增加速)

问题: 从节点 x 出发,走 k 步后到哪里?

朴素做法:循环 k 次,O(k) 可能很慢(k ≤ 10^18)。

倍增加速: 预处理 jump[v][j] = 从 v 出发跳 2^j 步后的节点,类似 LCA 倍增。

💡 CPP 代码(40 行)
#include <bits/stdc++.h>
using namespace std;

const int MAXN = 200005;
const int LOG = 40;  // log2(10^18) ≈ 60,按需调整

int n;
long long nxt_arr[MAXN];
long long jump[MAXN][LOG];  // jump[v][j] = 从 v 走 2^j 步

void preprocess() {
    // 第 0 层:直接后继
    for (int v = 1; v <= n; v++) jump[v][0] = nxt_arr[v];
    // 倍增构建
    for (int j = 1; j < LOG; j++)
        for (int v = 1; v <= n; v++)
            jump[v][j] = jump[jump[v][j-1]][j-1];
}

// 从节点 v 出发,走 k 步
long long walk(long long v, long long k) {
    for (int j = 0; j < LOG; j++)
        if ((k >> j) & 1)
            v = jump[v][j];
    return v;
}

int main() {
    cin >> n;
    for (int i = 1; i <= n; i++) cin >> nxt_arr[i];
    preprocess();
    
    int q; cin >> q;
    while (q--) {
        long long v, k;
        cin >> v >> k;
        cout << walk(v, k) << "\n";
    }
    return 0;
}

5.3.4 USACO Silver 典型题型

题型 1:函数图的连通性

问题模式: 判断两个节点是否能相互到达,或最终是否落在同一环中。

解法: 找出每个节点所属的环(cycle_id[]),同一环 = 可以相互到达。

// 两节点 u, v 是否最终落在同一环?
bool same_cycle(int u, int v) {
    return cycle_id[u] == cycle_id[v] && cycle_id[u] != -1;
}

题型 2:从每个节点走恰好 k 步后的分布

// 统计走 k 步后,每个节点各有多少节点落在上面
vector<int> count_after_k_steps(int n, int k) {
    vector<int> cnt(n + 1, 0);
    for (int i = 1; i <= n; i++) {
        cnt[walk(i, k)]++;
    }
    return cnt;
}

5.3.5 完整例题:奶牛跳跃(USACO Silver 风格)

题目: N 头奶牛编号 1~N,每头奶牛 i 喜欢奶牛 nxt[i](构成函数图)。
定义「喜欢链」:从 i 出发沿喜欢关系走 k 步。
Q 次查询,每次给出 (i, k),输出走 k 步后到达的奶牛编号。

💡 CPP 代码(25 行)
#include <bits/stdc++.h>
using namespace std;

const int MAXN = 100005, LOG = 20;
int n, q;
int jump[MAXN][LOG];

int main() {
    ios_base::sync_with_stdio(false); cin.tie(NULL);
    
    cin >> n >> q;
    for (int i = 1; i <= n; i++) cin >> jump[i][0];
    
    for (int j = 1; j < LOG; j++)
        for (int i = 1; i <= n; i++)
            jump[i][j] = jump[jump[i][j-1]][j-1];
    
    while (q--) {
        int v, k; cin >> v >> k;
        for (int j = 0; j < LOG; j++)
            if ((k >> j) & 1) v = jump[v][j];
        cout << v << "\n";
    }
    return 0;
}

⚠️ 常见错误

错误原因修复方案
死循环朴素走 k 步但不考虑环倍增法;或找到环后取模
jump 数组越界jump[0][j] 未初始化(若节点编号从0开始)确保哨兵节点正确
循环检测不完整只检测从某个起点出发的路径对所有未访问节点都执行 find_cycle
有向图判环用无向图方法函数图是有向图不能用「父节点排除法」

💪 练习题

🟢 题目 1:找所有环

给定函数图,输出所有环的节点集合(每行一个环,节点按访问顺序)。

✅ 完整解答
#include <bits/stdc++.h>
using namespace std;

int main() {
    int n; cin >> n;
    vector<int> nxt(n + 1);
    for (int i = 1; i <= n; i++) cin >> nxt[i];
    
    vector<int> color(n + 1, 0);
    vector<bool> on_cycle(n + 1, false);
    
    for (int start = 1; start <= n; start++) {
        if (color[start] != 0) continue;
        
        vector<int> path;
        unordered_map<int,int> pos;  // 节点在 path 中的位置
        int cur = start;
        
        while (color[cur] == 0) {
            color[cur] = 1;
            pos[cur] = path.size();
            path.push_back(cur);
            cur = nxt[cur];
        }
        
        if (color[cur] == 1 && pos.count(cur)) {
            // 找到环:从 cur 开始到 path 末尾
            cout << "环:";
            for (int i = pos[cur]; i < (int)path.size(); i++) {
                on_cycle[path[i]] = true;
                cout << path[i] << " ";
            }
            cout << "\n";
        }
        
        for (int v : path) color[v] = 2;
    }
    return 0;
}

🟡 题目 2:走 k 步

给定函数图和 Q 次查询 (v, k),每次输出从 v 出发走 k 步后的节点(k ≤ 10^9)。

✅ 完整解答

核心思路: 使用倍增法预处理 jump[v][j](从 v 跳 2^j 步到达的节点),查询时按 k 的二进制分解跳步。k ≤ 10^9 需要 LOG=30。

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

const int MAXN = 200005, LOG = 30;
int n, q;
int jump[MAXN][LOG];

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

    cin >> n >> q;
    for (int i = 1; i <= n; i++) cin >> jump[i][0];

    // 倍增预处理
    for (int j = 1; j < LOG; j++)
        for (int i = 1; i <= n; i++)
            jump[i][j] = jump[jump[i][j-1]][j-1];

    while (q--) {
        int v; long long k;
        cin >> v >> k;
        // 按 k 的二进制分解跳步
        for (int j = 0; j < LOG; j++)
            if ((k >> j) & 1)
                v = jump[v][j];
        cout << v << "\n";
    }
    return 0;
}

复杂度分析: 预处理时间 O(N × LOG),每次查询时间 O(LOG),空间 O(N × LOG)。


🔴 题目 3:最远公共祖先(函数图上的 LCA)

函数图中,定义节点 u 和 v 的「公共后继」为第一个同时是 u 的后继和 v 的后继的节点。
给 Q 次查询,找 (u, v) 的最近公共后继。

✅ 完整解答

核心思路: 在函数图中,每个节点沿出边走最终一定进入环。两节点的"最近公共后继"只在两者属于同一连通分量时存在。算法分三步:(1) 着色法找出每个节点所在环及到环的距离;(2) 先让距环更远的节点走到与另一节点到环距离相同的位置(保证同步);(3) 用倍增法从高到低跳,找到第一个公共节点。

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

const int MAXN = 200005, LOG = 20;
int n, q;
int nxt[MAXN];
int jump[MAXN][LOG];
int color[MAXN], on_cycle[MAXN], cycle_id[MAXN];
int dist_to_cycle[MAXN];

void find_cycle(int start, int& num_cycles) {
    vector<int> path;
    unordered_map<int,int> pos;
    int cur = start;
    while (color[cur] == 0) {
        color[cur] = 1;
        pos[cur] = path.size();
        path.push_back(cur);
        cur = nxt[cur];
    }
    if (color[cur] == 1 && pos.count(cur)) {
        int cid = num_cycles++;
        int v = cur;
        do {
            on_cycle[v] = true;
            cycle_id[v] = cid;
            dist_to_cycle[v] = 0;
            v = nxt[v];
        } while (v != cur);
    }
    for (int v : path) color[v] = 2;
}

// 从 v 走 k 步
int walk(int v, int k) {
    for (int j = 0; j < LOG; j++)
        if ((k >> j) & 1) v = jump[v][j];
    return v;
}

int main() {
    ios_base::sync_with_stdio(false); cin.tie(NULL);
    cin >> n >> q;
    for (int i = 1; i <= n; i++) cin >> nxt[i];

    // 找环
    fill(cycle_id, cycle_id + n + 1, -1);
    int num_cycles = 0;
    for (int i = 1; i <= n; i++)
        if (color[i] == 0) find_cycle(i, num_cycles);

    // BFS 从环向外计算 dist_to_cycle
    vector<vector<int>> rev(n + 1);
    for (int i = 1; i <= n; i++) rev[nxt[i]].push_back(i);
    queue<int> bfs;
    for (int i = 1; i <= n; i++)
        if (on_cycle[i]) { dist_to_cycle[i] = 0; bfs.push(i); }
    while (!bfs.empty()) {
        int u = bfs.front(); bfs.pop();
        for (int v : rev[u]) {
            if (!on_cycle[v] && dist_to_cycle[v] == 0 && v != u) {
                dist_to_cycle[v] = dist_to_cycle[u] + 1;
                cycle_id[v] = cycle_id[u];
                bfs.push(v);
            }
        }
    }

    // 倍增预处理
    for (int i = 1; i <= n; i++) jump[i][0] = nxt[i];
    for (int j = 1; j < LOG; j++)
        for (int i = 1; i <= n; i++)
            jump[i][j] = jump[jump[i][j-1]][j-1];

    while (q--) {
        int u, v; cin >> u >> v;
        // 不同连通分量 → 无公共后继
        if (cycle_id[u] != cycle_id[v] || cycle_id[u] == -1) {
            cout << -1 << "\n";
            continue;
        }
        // 让到环距离大的先走,使两者到环距离相同
        if (dist_to_cycle[u] < dist_to_cycle[v]) swap(u, v);
        if (dist_to_cycle[u] != dist_to_cycle[v])
            u = walk(u, dist_to_cycle[u] - dist_to_cycle[v]);
        // 倍增找最近公共后继
        if (u == v) { cout << u << "\n"; continue; }
        for (int j = LOG - 1; j >= 0; j--)
            if (jump[u][j] != jump[v][j]) {
                u = jump[u][j];
                v = jump[v][j];
            }
        cout << jump[u][0] << "\n";
    }
    return 0;
}

复杂度分析: 预处理(找环 + BFS + 倍增)时间 O(N × LOG),每次查询时间 O(LOG),空间 O(N × LOG)。


💡 章节联系: 函数图是 USACO Silver 的独特题型,每季赛约出现 1 道。它结合了图遍历(第 5.2 章)和 LCA 倍增(第 5.4 章)的思想,是 Gold 拓扑排序(第 8.2 章)的前置知识。

📖 第 5.4 章 ⏱️ 约 80 分钟 🎯 进阶

第 5.4 章:最短路径

在节点间寻找最短路径是图论中最基础的问题之一。它出现在 GPS 导航、网络路由、游戏 AI 中,对我们来说最重要的是——USACO 题目。本章涵盖四种算法(Dijkstra、Bellman-Ford、Floyd-Warshall、SPFA),并解释何时使用哪种。


5.4.1 问题定义

单源最短路径(SSSP)

给定加权图 G = (V, E) 和源节点 s,找从 s所有其他节点的最短距离。

SSSP Example Graph

从源点 A:

  • dist[A] = 0
  • dist[B] = 1
  • dist[C] = 5
  • dist[D] = 5(A→B→D = 1+4)
  • dist[E] = 8(A→B→D→E = 1+4+3)

全对最短路径(APSP)

所有节点对之间的最短距离。

为什么不直接用 BFS?

BFS 找无权图的最短路径(每条边 = 距离 1)。有了权重:

  • 有些路径有很多短权重的边
  • 另一些有少量大权重的边
  • BFS 完全忽略权重 → 答案错误

5.4.2 Dijkstra 算法

最重要的最短路径算法。 用于约 90% 涉及加权最短路径的 USACO 题目。

时间
O((V+E) log V)
空间
O(V + E)
限制
非负权重
类型
单源

核心思想:贪心 + 优先队列

Dijkstra 是一个贪心算法

  1. 维护一个「已确定」节点集合(最短距离已最终确定)
  2. 每次处理当前距离最小的未访问节点
  3. 处理节点时,尝试松弛其邻居(如果找到更短路径则更新距离)

为什么贪心有效: 若所有边权非负,当前距离最小的节点不可能通过其他节点得到改善(所有替代路径 ≥ 当前距离)。

逐步追踪

Dijkstra Trace Graph

起点: 节点 0 | 初始: dist = [0, ∞, ∞, ∞, ∞]

步骤处理节点松弛操作dist 数组队列
1节点 0(dist=0)0→1: min(∞, 0+4)=4; 0→2: min(∞, 0+2)=2; 0→3: min(∞, 0+5)=5[0, 4, 2, 5, ∞]{(2,2),(4,1),(5,3)}
2节点 2(dist=2)2→3: min(5, 2+1)=3 ← 改善![0, 4, 2, 3, ∞]{(3,3),(4,1),(5,3_旧)}
3节点 3(dist=3)3→1: min(4, 3+1)=4(无变化); 3→4: min(∞, 3+3)=6[0, 4, 2, 3, 6]{(4,1),(6,4),(5,3_旧)}
4节点 1(dist=4)无可松弛[0, 4, 2, 3, 6]{(6,4)}
5节点 4(dist=6)完成![0, 4, 2, 3, 6]{}

最终: dist = [0, 4, 2, 3, 6]

完整 Dijkstra 实现

📄 查看代码:完整 Dijkstra 实现
// Dijkstra 算法(优先队列)— O((V+E) log V)
#include <bits/stdc++.h>
using namespace std;

typedef pair<int, int> pii;   // {距离, 节点}
typedef long long ll;

const ll INF = 1e18;          // 使用 long long 避免 int 溢出!
const int MAXN = 100005;

// 邻接表:adj[u] = {权重, v} 列表
vector<pii> adj[MAXN];

vector<ll> dijkstra(int src, int n) {
    vector<ll> dist(n + 1, INF);   // dist[i] = 到节点 i 的最短距离
    dist[src] = 0;

    // 最小堆:{距离, 节点}
    priority_queue<pii, vector<pii>, greater<pii>> pq;
    pq.push({0, src});

    while (!pq.empty()) {
        auto [d, u] = pq.top(); pq.pop();  // 取距离最小的节点

        // 关键:若已找到到 u 的更好路径则跳过(过期条目)
        if (d > dist[u]) continue;

        // 松弛 u 的所有邻居
        for (auto [w, v] : adj[u]) {
            ll newDist = dist[u] + w;
            if (newDist < dist[v]) {
                dist[v] = newDist;          // 更新距离
                pq.push({newDist, v});       // 将更新后的条目加入队列
            }
        }
    }
    return dist;
}

Dijkstra 的关键要点

🚫 关键:Dijkstra 对负边权不起作用! 若存在负边权,Dijkstra 可能产生错误结果。算法正确性依赖于贪心假设:一旦节点从优先队列弹出(已确定),其距离就是最终的——负边破坏这个假设。对含负边权的图,改用 Bellman-FordSPFA

Dijkstra 负边权失败原因

  • 只对非负权重有效。 负边破坏贪心假设。
  • 当边权较大时,距离用 long longdist[u] + w 可能溢出 int
  • greater<pii>priority_queue 成为最小堆。
  • if (d > dist[u]) continue; 检查对正确性和性能至关重要。

5.4.3 Bellman-Ford 算法

单源最短路径算法,比 Dijkstra 慢,但支持负权边,还能检测负环

时间
O(V × E)
负边
✓ 支持
负环
✓ 可检测

核心思想

Bellman-Ford 只做一件事,反复做:

遍历所有边,如果经过边 u→v 能让 dist[v] 变小,就更新 dist[v]。 这个操作叫松弛

if (dist[u] != INF && dist[u] + w < dist[v]) {
    dist[v] = dist[u] + w;  // 松弛
}

松弛操作示意图

松弛很简单:已知到 u 的距离,尝试"先到 u 再走到 v",如果比当前到 v 更短就更新。

为什么重复 V-1 轮?

一轮松弛只能把已知距离沿边向外传播一层

  • 第 1 轮后:找到所有最多用 1 条边能到的最短路
  • 第 2 轮后:找到所有最多用 2 条边能到的最短路
  • 第 k 轮后:找到所有最多用 k 条边能到的最短路

没有负环时,最短路径不会重复经过节点,最多使用 V-1 条边。所以 V-1 轮一定够

为什么需要 V-1 轮

为什么不用 Dijkstra?

Dijkstra 一旦确定某个点的距离就不再修改。但负权边意味着"绕远路可能更短",这个假设被打破。

A → B 权重 10,A → C 权重 5,C → B 权重 -3

A 到 B 的最短路是 A→C→B = 2,而不是直接的 A→B = 10。Dijkstra 可能提前锁定 B=10,错过更短路线。

Bellman-Ford 从不提前锁定任何点,每轮都重新检查所有边,因此能正确处理负权。

逐步追踪

Bellman-Ford 逐步追踪

边:A→B(4), A→C(2), A→F(15), C→B(1), B→D(5), C→D(8), B→E(7), D→E(-6), D→F(4), E→F(2),源点 A

轮次dist[A]dist[B]dist[C]dist[D]dist[E]dist[F]关键变化
初始0
第1轮04215直达 B,C,F
第2轮03291115C→B 改进 B
第3轮0328213B→D, D→E(-6) 连锁
第4轮032824E→F 最终改进
第5轮032824无变化,收敛

最终最短路:A→C→B→D→E→F = 2+1+5+(-6)+2 = 4

要点:

  • 直达不代表最短:F 初始为 15,最终变成 4
  • 负边放大改进:D 变短后,D→E(-6) 让 E 大幅下降
  • V-1 轮是上限:6 个节点最多 5 轮,实际第 4 轮就收敛了

算法流程

Bellman-Ford(src):
  1. dist[src] = 0, 其余 = ∞
  2. 重复 V-1 轮:遍历所有边,尝试松弛
  3. 第 V 次检查:如果还能松弛 → 存在负环

负环检测

负环是总权重为负的环,每绕一圈距离就减小,最短路不存在。

A→B(2), B→C(-5), C→A(1) → 环总权 = -2

负环检测示意图

检测方法:V-1 轮后所有最短路应已稳定。如果第 V 次遍历还能松弛,说明存在从源点可达的负环。

for (auto& [u, v, w] : edges) {
    if (dist[u] != INF && dist[u] + w < dist[v]) {
        return {};  // 负环!
    }
}

⚠️ 此法只检测从源点可达的负环。要检测全图负环,需用"超级源点"技巧(见 SPFA 章节)。

完整实现

📄 查看代码:Bellman-Ford 实现
// Bellman-Ford 算法 — O(V * E)
#include <bits/stdc++.h>
using namespace std;

typedef long long ll;
typedef tuple<int, int, int> Edge;  // {起点, 终点, 权重}

const ll INF = 1e18;

// 返回最短距离数组,若检测到负环则返回空数组
vector<ll> bellmanFord(int src, int n, vector<Edge>& edges) {
    vector<ll> dist(n + 1, INF);
    dist[src] = 0;

    for (int iter = 0; iter < n - 1; iter++) {
        bool updated = false;
        for (auto& [u, v, w] : edges) {
            if (dist[u] != INF && dist[u] + w < dist[v]) {
                dist[v] = dist[u] + w;
                updated = true;
            }
        }
        if (!updated) break;  // 提前收敛
    }

    // 第 V 次检查:检测从源点可达的负环
    for (auto& [u, v, w] : edges) {
        if (dist[u] != INF && dist[u] + w < dist[v]) {
            return {};  // 存在负环
        }
    }

    return dist;
}
关键代码作用
dist[u] != INF只从已可达节点扩展,避免 INF + w 溢出
updated 标记本轮无更新则提前终止
第 V 次遍历还能松弛 = 存在负环

什么时候用 Bellman-Ford?

  • 没有负边 → Dijkstra 更快
  • 有负边但无负环,求单源最短路 → Bellman-Ford 或 SPFA
  • 需要检测从源点可达的负环 → Bellman-Ford
  • 图很大 → O(VE) 可能 TLE,考虑 SPFA 或其他方法

💡 一句话总结: 每轮遍历所有边做松弛,第 k 轮保证找到最多用 k 条边的最短路,V-1 轮一定收敛;第 V 轮还能松弛则存在负环。

5.4.4 Floyd-Warshall 算法

一次算出所有节点对之间的最短路径。

时间
O(V³)
空间
O(V²)
负边
✓ 支持
类型
全对

核心思想:试试每个节点当中转站

想象你要查全国所有城市之间的最短路线。最朴素的想法:对每对城市 (i, j),直接看有没有边相连。但这只考虑了直达路线。

关键洞察:i 到 j 的最短路,要么直达,要么经过某个中转站 k:

最短路 i→j = min( 直达 i→j,  经过中转 i→k→j )

Floyd-Warshall 就把这个想法系统化:依次尝试每个节点 k 当中转站,看能不能让 i→j 更短。

Floyd-Warshall DP 状态转移

if (dist[i][k] + dist[k][j] < dist[i][j]) {
    dist[i][j] = dist[i][k] + dist[k][j];  // 经过 k 更短,更新
}

逐步追踪

4 个节点(0-3),5 条无向边:0-1(3), 0-2(8), 1-2(2), 1-3(5), 2-3(1)

Floyd-Warshall 示例图

初始距离矩阵(只填直达边,无直达 = ∞):

dist0123
0038
13025
28201
3510

k=0:让节点 0 当中转站。检查所有 (i, j),看 i→0→j 是否比 i→j 更短。

iji→0→j当前 dist[i][j]结果
123+8=11211 ≥ 2,不更新
218+3=11211 ≥ 2,不更新
其他节点 3 到 0 不可达,跳过

k=0 无更新。当前矩阵不变。

k=1:让节点 1 当中转站。

iji→1→j当前 dist[i][j]结果
023+2=585 < 8,更新! dist[0][2]=5
033+5=88 < ∞,更新! dist[0][3]=8
202+3=58更新(对称)
305+3=8更新(对称)
232+5=717 ≥ 1,不更新

k=1 后矩阵:

dist0123
00358
13025
25201
38510

k=2:让节点 2 当中转站。

iji→2→j当前 dist[i][j]结果
035+1=686 < 8,更新! dist[0][3]=6
132+1=353 < 5,更新! dist[1][3]=3
015+2=737 ≥ 3,不更新

k=2 后矩阵:

dist0123
00356
13023
25201
36310

k=3:让节点 3 当中转站。所有检查都没有更短的路径,矩阵不变。

最终结果:dist[0][3]=6(路径 0→1→2→3),dist[1][3]=3(路径 1→2→3)

Floyd-Warshall 矩阵变化过程

为什么 k 必须是最外层循环?

这是 Floyd-Warshall 最常见的实现错误

理解关键:当处理中间节点 k 时,dist[i][k]dist[k][j] 必须是还没尝试过 k 当中转站时的值(即只用 {0..k-1} 当中转站算出的最短路)。

  • k 在最外层 ✅:处理 k 时,i 和 j 的双层循环只读取 dist[i][k]dist[k][j],这两个值在当前 k 这轮不会被修改(因为 k 行和 k 列的值只取决于 {0..k-1},不受新的更新影响),所以是安全的
  • k 在最内层 ❌:同一个 k 轮中,前面刚更新的 dist[i][k] 会被后面的 (i', j') 读取,相当于"同一轮内重复使用 k 当中转站",结果不对

一句话记忆:k 在最外层,i 和 j 在内层——顺序不能换!

完整实现

📄 查看代码:Floyd-Warshall 实现
// Floyd-Warshall 全对最短路径 — O(V^3)
#include <bits/stdc++.h>
using namespace std;

typedef long long ll;
const ll INF = 1e18;
const int MAXV = 505;

ll dist[MAXV][MAXV];

void floydWarshall(int n) {
    // 初始化:dist[i][i]=0, 有边设为边权, 无边设为 INF
    // ...(读入时已完成)

    // ⚠️ 关键:k 必须是最外层循环!
    for (int k = 1; k <= n; k++) {         // 中转站
        for (int i = 1; i <= n; i++) {     // 起点
            for (int j = 1; j <= n; j++) { // 终点
                if (dist[i][k] != INF && dist[k][j] != INF) {
                    dist[i][j] = min(dist[i][j], dist[i][k] + dist[k][j]);
                }
            }
        }
    }
    // 若 dist[i][i] < 0,则节点 i 在负环上
}
关键代码作用
k 在最外层保证 DP 不变量:处理 k 时只用 {1..k-1} 当中转站的结果
dist[i][k] != INF防止 INF + w 溢出
dist[i][i] < 0负环检测:正常情况自己到自己距离为 0,< 0 说明有负环

什么时候用 Floyd-Warshall?

  • 需要所有节点对之间最短路径,且 V ≤ 500(V³ ≈ 1.25×10⁸ 勉强可行)
  • V > 500 时,对每个节点跑 Dijkstra 更快:O(V × (V+E) log V)
  • 需要检测全图负环:跑完后检查 dist[i][i] < 0
  • 代码极短:只有 5 行核心逻辑,适合 V 小的题目

💡 一句话总结: 依次让每个节点 k 当中转站,更新所有 (i,j) 对——如果 i→k→j 比当前 i→j 更短就更新。三重循环,k 必须在最外层。


5.4.5 算法对比表

算法时间复杂度负边负环检测多源最适合
BFSO(V + E)✗ 否✗ 否✓ 是(多源 BFS)无权图
DijkstraO((V+E) log V)✗ 否✗ 否✗(每源运行一次)加权非负边图
Bellman-FordO(V × E)✓ 是✓ 是负边、检测负环
SPFA最坏 O(V × E),平均 O(E)✓ 是✓ 是稀疏图含负边
Floyd-WarshallO(V³)✓ 是✓ 是(对角线)✓ 是(全对)稠密图、全对查询

如何选择?

图有负边吗?
├── 有 → Bellman-Ford、SPFA(或全对用 Floyd-Warshall)
└── 没有 → V ≤ 500 且需要全对最短路?
          ├── 是 → Floyd-Warshall  O(V³)
          └── 否 → 无权图(所有边 = 1)?
                    ├── 是 → BFS  O(V+E)
                    └── 否 → 边权只有 0 或 1?
                              ├── 是 → 0-1 BFS  O(V+E)
                              └── 否 → Dijkstra  O((V+E) log V)

5.4.6 SPFA——带队列优化的 Bellman-Ford

上一节学完了 Bellman-Ford:每轮扫一遍所有边,重复 V-1 轮。你可能会想——大多数边一轮下来根本不会产生松弛,扫它们纯属浪费。SPFA 就是来解决这个问题。

从 Bellman-Ford 到 SPFA:一个直觉

Bellman-Ford 每轮做的事:遍历所有 E 条边,看哪些能松弛。

但想想看:只有 dist[u] 刚刚变小的节点 u,它的出边才有可能松弛邻居 v。如果 dist[u] 这一轮没变,那它的邻居们也不会因此变好。

SPFA 的核心思路: 谁的距离刚更新,就把谁加入队列;从队列中取出节点时,只松弛它的邻居。没更新的节点根本不用管。

SPFA vs Bellman-Ford

左边 Bellman-Ford 每轮扫 6 条边,5 轮共 30 次检查;右边 SPFA 只处理更新节点的邻居,总共约 12 次检查——结果完全相同,效率提升显著。

队列的工作流程

SPFA 的结构与 BFS 几乎一模一样,唯一的区别是:BFS 的节点只进队一次,而 SPFA 的节点可以反复进队(因为距离可能被多次改善)。

SPFA Queue Flow

关键细节:

要点说明
队列里放什么距离刚被更新的节点
出队后做什么遍历它的所有出边,尝试松弛
inQueue 标记如果节点已在队列中,就不重复入队(但 dist 已经更新了,出队时自然使用新值)
为什么可以反复入队因为负边可能让同一条路多次变短;不同于 Dijkstra "锁定"节点,SPFA 允许反复改进

逐步追踪

下面用和 Bellman-Ford 完全相同的图来跑 SPFA,方便你对比两种算法的过程。

SPFA Step Trace

出队松弛操作dist 数组队列
初始:dist[A]=0,A 入队A=0, B=∞, C=∞, D=∞, E=∞, F=∞{A}
AA→B(4)✓ A→C(2)✓ A→F(15)✓A=0, B=4, C=2, D=∞, E=∞, F=15{C, B, F}
CC→B(1)✓ B:4→3; C→D(8)✓A=0, B=3, C=2, D=10, E=∞, F=15{B, F, D}
BB→D(5)✓ D:10→8; B→E(7)✓A=0, B=3, C=2, D=8, E=10, F=15{F, D, E}
FF 的邻居无法改善不变{D, E}
DD→E(-6)✓ E:10→2; D→F(4)✓ F:15→12A=0, B=3, C=2, D=8, E=2, F=12{E, F}
EE→F(2)✓ F:12→4A=0, B=3, C=2, D=8, E=2, F=4{F}
F无改善不变{} ← 空,结束!

最终结果: A→C→B→D→E→F = 2+1+5+(-6)+2 = 4 ——与 Bellman-Ford 完全一致。

💡 对比 Bellman-Ford:BF 跑了 5 轮 × 10 条边 = 50 次边检查;SPFA 只做了约 18 次。节点越多、图越稀疏,SPFA 的优势越明显。

负环检测

和 Bellman-Ford 类似,SPFA 也能检测负环:

  • 原理:没有负环时,一个节点最多进队 V-1 次(对应最短路最多 V-1 条边)
  • 判据:如果某节点进队次数 ≥ V,说明存在负环
cnt[v]++;
if (cnt[v] >= n) return {};  // 负环!

⚠️ 上面代码检测的是从 src 出发可达的负环。若要判断全图是否存在负环(包含从 src 不可达的部分),需建立超级源点——见下方。

完整实现

📄 C++ 完整代码
// SPFA(Bellman-Ford + 队列优化)
vector<ll> spfa(int src, int n) {
    vector<ll> dist(n + 1, INF);
    vector<bool> inQueue(n + 1, false);
    vector<int> cnt(n + 1, 0);   // cnt[v] = v 进入队列的次数

    queue<int> q;
    dist[src] = 0;
    q.push(src);
    inQueue[src] = true;

    while (!q.empty()) {
        int u = q.front(); q.pop();
        inQueue[u] = false;

        for (auto [w, v] : adj[u]) {
            if (dist[u] + w < dist[v]) {
                dist[v] = dist[u] + w;

                if (!inQueue[v]) {
                    q.push(v);
                    inQueue[v] = true;
                    cnt[v]++;

                    // 负环检测:若节点进入队列 >= n 次
                    if (cnt[v] >= n) return {};  // 负环!
                }
            }
        }
    }
    return dist;
}
关键代码作用
inQueue 数组避免同一节点重复入队,保持队列精简
cnt[v]++统计入队次数,用于负环检测
inQueue[u] = false出队时清除标记,允许后续再次入队

负环检测:判断全图是否存在负环

上面的 SPFA 检测的是从 src 出发可达的负环。若要判断全图是否存在负环(包含从 src 不可达的部分),需建立超级源点:

📄 C++ 完整代码
// 判断全图负环(包含不可达部分)
// 建立超级源点 0,向所有节点连 0 权边,从 0 跑 Bellman-Ford
bool hasNegativeCycle(int n) {
    // 原图节点 1..n,超级源点 0
    vector<ll> dist(n + 1, 0);  // 全部初始化为 0(等价于超级源点到所有节点距离为 0)
    vector<bool> inQueue(n + 1, true);
    vector<int> cnt(n + 1, 0);
    queue<int> q;

    // 所有节点入队(超级源点效果)
    for (int i = 1; i <= n; i++) q.push(i);

    while (!q.empty()) {
        int u = q.front(); q.pop();
        inQueue[u] = false;

        for (auto [w, v] : adj[u]) {
            if (dist[u] + w < dist[v]) {
                dist[v] = dist[u] + w;
                if (!inQueue[v]) {
                    q.push(v); inQueue[v] = true;
                    if (++cnt[v] >= n) return true;  // 负环!
                }
            }
        }
    }
    return false;
}

何时使用 SPFA

  • 所有边非负 → Dijkstra 更稳定
  • 有负边、无负环、单源最短路 → SPFA 是首选(比 Bellman-Ford 快得多)
  • 需要检测可达负环 → SPFA(入队次数判据)
  • 需要检测全图负环 → 超级源点 + SPFA

⚠️ SPFA 最坏情况: 最坏时间复杂度是 O(V × E)——与朴素 Bellman-Ford 相同。在精心构造的图(竞赛中常见的「反 SPFA」数据)上,SPFA 会退化并 TLE。所以:能用 Dijkstra 就用 Dijkstra;只有存在负边时才考虑 SPFA。

💡 一句话总结: Bellman-Ford 每轮扫所有边,SPFA 只松弛"刚更新节点的邻居"——用队列跳过无用边,结果相同,通常快得多。


5.4.7 Johnson 算法——全源最短路

Floyd-Warshall 是 O(N³),跑 N 次 Bellman-Ford 是 O(N²M)。Johnson 算法通过重新标注边权,将 N 次 Dijkstra 应用于无负权的图,复杂度 O(NM log M),在稀疏图上优于 Floyd。

算法步骤

  1. 建超级源点 0,向所有节点连 0 权边
  2. 用 Bellman-Ford 求 0 到所有点的最短路 h[i](若存在负环则无解)
  3. 重新标注边权: w'(u,v) = w(u,v) + h[u] - h[v](保证非负)
  4. 以每个点为源点跑 N 次 Dijkstra
  5. 还原答案: 实际最短路 = Dijkstra 结果 - h[s] + h[t]
📄 5. **还原答案:** 实际最短路 = Dijkstra 结果 - `h[s]` + `h[t]`
// Johnson 全源最短路 — O(NM log M)
#include <bits/stdc++.h>
using namespace std;
typedef long long ll;
const ll INF = 1e18;

int n, m;
// adj[u] = {v, w},edges 存所有边用于 Bellman-Ford
vector<pair<int,ll>> adj[505];
struct Edge { int u, v; ll w; };
vector<Edge> edges;

vector<ll> bellman_ford(int s) {
    vector<ll> dist(n + 1, INF);
    dist[s] = 0;
    for (int i = 0; i < n; i++) {
        for (auto [u, v, w] : edges) {
            if (dist[u] != INF && dist[u] + w < dist[v])
                dist[v] = dist[u] + w;
        }
    }
    return dist;
}

vector<ll> dijkstra(int s, int n) {
    vector<ll> dist(n + 1, INF);
    priority_queue<pair<ll,int>, vector<pair<ll,int>>, greater<>> pq;
    dist[s] = 0; pq.push({0, s});
    while (!pq.empty()) {
        auto [d, u] = pq.top(); pq.pop();
        if (d > dist[u]) continue;
        for (auto [v, w] : adj[u]) {
            if (dist[u] + w < dist[v]) {
                dist[v] = dist[u] + w;
                pq.push({dist[v], v});
            }
        }
    }
    return dist;
}

// 返回 dist[i][j] = i 到 j 的真实最短路
// 若 i 到 j 不可达返回 INF
vector<vector<ll>> johnson() {
    // 超级源点 n+1 向所有节点连 0 权边
    for (int i = 1; i <= n; i++)
        edges.push_back({n + 1, i, 0});

    // Bellman-Ford 求势函数 h[]
    vector<ll> h = bellman_ford(n + 1);

    // 重新标注边权(保证非负)
    for (auto& e : edges) {
        if (e.u != n + 1)  // 不处理超级源点的虚拟边
            e.w += h[e.u] - h[e.v];
    }
    // 同步更新邻接表
    for (int u = 1; u <= n; u++)
        for (auto& [v, w] : adj[u])
            w += h[u] - h[v];

    // N 次 Dijkstra
    vector<vector<ll>> ans(n + 1, vector<ll>(n + 1, INF));
    for (int s = 1; s <= n; s++) {
        auto d = dijkstra(s, n);
        for (int t = 1; t <= n; t++) {
            if (d[t] != INF)
                ans[s][t] = d[t] - h[s] + h[t];  // 还原真实距离
        }
    }
    return ans;
}

💡 为什么重标后边权非负? 三角不等式保证 h[v] ≤ h[u] + w(u,v),因此 w'(u,v) = w(u,v) + h[u] - h[v] ≥ 0


5.4.8 输出最短路径方案

pre[] 数组记录前驱节点,松弛时同步更新:

📄 用 `pre[]` 数组记录前驱节点,松弛时同步更新:
// Dijkstra 带路径输出
vector<int> dijkstra_with_path(int src, int dst, int n) {
    vector<ll> dist(n + 1, INF);
    vector<int> pre(n + 1, -1);  // pre[v] = v 的前驱
    priority_queue<pair<ll,int>, vector<pair<ll,int>>, greater<>> pq;

    dist[src] = 0; pq.push({0, src});
    while (!pq.empty()) {
        auto [d, u] = pq.top(); pq.pop();
        if (d > dist[u]) continue;
        for (auto [v, w] : adj[u]) {
            if (dist[u] + w < dist[v]) {
                dist[v] = dist[u] + w;
                pre[v] = u;          // 记录前驱
                pq.push({dist[v], v});
            }
        }
    }

    // 根据 pre[] 还原路径(逆序)
    vector<int> path;
    for (int v = dst; v != -1; v = pre[v])
        path.push_back(v);
    reverse(path.begin(), path.end());

    // 检查路径是否从 src 出发
    if (path.empty() || path[0] != src) return {};  // 不可达
    return path;  // src → ... → dst
}

当所有边权为 1(无权图)时,BFS 就是用简单队列的 Dijkstra。

0-1 BFS: 当边权只有 0 或 1 时,用双端队列代替队列的强力技巧:

0-1 BFS 双端队列工作原理

双端队列:[队首 → 距离最小 ... → 队尾 → 距离最大]

松弛邻居 v(经由权重 w 的边 u→v):
  w = 0 → push_front(v)   (与 u 距离相同——放在前面)
  w = 1 → push_back(v)    (多一步——放在后面)

💡 效率: 0-1 BFS 运行 O(V+E)——比 Dijkstra 的 O((V+E) log V) 更快。当边权只有 0 和 1 时,始终优先用 0-1 BFS。

📄 C++ 完整代码
// 0-1 BFS — O(V + E),处理 {0,1} 权重边
vector<int> bfs01(int src, int n) {
    vector<int> dist(n + 1, INT_MAX);
    deque<int> dq;

    dist[src] = 0;
    dq.push_front(src);

    while (!dq.empty()) {
        int u = dq.front(); dq.pop_front();

        for (auto [w, v] : adj[u]) {
            if (dist[u] + w < dist[v]) {
                dist[v] = dist[u] + w;
                if (w == 0) dq.push_front(v);   // 0 权重:加到前面
                else        dq.push_back(v);    // 1 权重:加到后面
            }
        }
    }
    return dist;
}

5.4.8 网格上的 Dijkstra

许多 USACO 题目涉及网格最短路径,图是隐式的:

📄 许多 USACO 题目涉及网格最短路径,图是隐式的:
// 网格 Dijkstra — 找从 (0,0) 到 (R-1,C-1) 的最短路径
// 每个格子有进入代价
#include <bits/stdc++.h>
using namespace std;

typedef long long ll;
typedef tuple<ll,int,int> tli;

const ll INF = 1e18;
int dx[] = {0,0,1,-1};
int dy[] = {1,-1,0,0};

ll dijkstraGrid(vector<vector<int>>& grid) {
    int R = grid.size(), C = grid[0].size();
    vector<vector<ll>> dist(R, vector<ll>(C, INF));
    priority_queue<tli, vector<tli>, greater<tli>> pq;

    dist[0][0] = grid[0][0];
    pq.push({grid[0][0], 0, 0});

    while (!pq.empty()) {
        auto [d, r, c] = pq.top(); pq.pop();
        if (d > dist[r][c]) continue;

        for (int k = 0; k < 4; k++) {
            int nr = r + dx[k], nc = c + dy[k];
            if (nr < 0 || nr >= R || nc < 0 || nc >= C) continue;

            ll newDist = dist[r][c] + grid[nr][nc];
            if (newDist < dist[nr][nc]) {
                dist[nr][nc] = newDist;
                pq.push({newDist, nr, nc});
            }
        }
    }
    return dist[R-1][C-1];
}

5.4.9 USACO 真题训练:先判断边权,再选择最短路工具

最短路题的第一步不是写 Dijkstra,而是判断图的边权结构:

边权类型首选算法典型信号
所有边权相同BFS最少步数、无权道路
非负不同权重Dijkstra路径代价、时间、距离不同
只有 0/1 权重0-1 BFS免费边/付费边、翻转代价 0 或 1
有负边Bellman-Ford/SPFA返利、负代价、差分约束
多源最近距离多源 BFS / Dijkstra最近出口、最近危险点

真题 1:Piggy Back(USACO 2014 December Silver)— 三次 BFS 拼出最优会合点

题目链接: USACO 2014 December Silver P1: Piggy Back
对应模式: 无权图最短路 + 枚举会合点
难度定位: Silver 标准

题干解读

Bessie 从节点 1 出发,Elsie 从节点 2 出发,目标都是到达节点 N。她们可以单独走,也可以在某个节点会合后一起走。单独走和一起走的单位边代价不同,要求最小总代价。

关键条件:

  • 图是无权图,边表示一步道路。
  • 会合点未知,但可以枚举。
  • 需要知道三个来源到每个点的最短步数:1、2、N。

思路分析

分别从 1、2、N 做 BFS:

d1[x] = Bessie 从 1 到 x 的最少边数
d2[x] = Elsie 从 2 到 x 的最少边数
dn[x] = 从 x 到 N 的最少边数

若在点 x 会合,总代价为:

B * d1[x] + E * d2[x] + P * dn[x]

枚举所有 x 取最小值即可。

CPP 完整代码

✅ 完整代码:Piggy Back
#include <bits/stdc++.h>
using namespace std;

const int INF = 1e9;

vector<int> bfs(int start, const vector<vector<int>>& adj) {
    int n = (int)adj.size() - 1;
    vector<int> dist(n + 1, INF);
    queue<int> q;
    dist[start] = 0;
    q.push(start);

    while (!q.empty()) {
        int u = q.front();
        q.pop();
        for (int v : adj[u]) {
            if (dist[v] == INF) {
                dist[v] = dist[u] + 1;
                q.push(v);
            }
        }
    }
    return dist;
}

int main() {
    ios::sync_with_stdio(false);
    cin.tie(nullptr);

    // USACO 官方文件 I/O 可按需打开:
    // freopen("piggyback.in", "r", stdin);
    // freopen("piggyback.out", "w", stdout);

    long long bCost, eCost, togetherCost;
    int n, m;
    cin >> bCost >> eCost >> togetherCost >> n >> m;

    vector<vector<int>> adj(n + 1);
    for (int i = 0; i < m; i++) {
        int a, b;
        cin >> a >> b;
        adj[a].push_back(b);
        adj[b].push_back(a);
    }

    vector<int> d1 = bfs(1, adj);
    vector<int> d2 = bfs(2, adj);
    vector<int> dn = bfs(n, adj);

    long long answer = LLONG_MAX;
    for (int meet = 1; meet <= n; meet++) {
        if (d1[meet] == INF || d2[meet] == INF || dn[meet] == INF) continue;
        long long cost = bCost * d1[meet] + eCost * d2[meet] + togetherCost * dn[meet];
        answer = min(answer, cost);
    }

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

复杂度: 三次 BFS,总时间 O(N+M),空间 O(N+M)

易错点提醒

  1. 用 Dijkstra 过度复杂化。 本题所有边长度相同,BFS 更简单更快。
  2. 只枚举两人路径上的交点。 最优会合点可能不是你直观看到的某条路径节点,直接枚举所有点最稳。
  3. 忘记不可达判断。 若某个距离是 INF,不能参与计算。
  4. 总代价用 int。 边数和费用相乘,建议用 long long

拓展思考

这道题体现一个重要技巧:多次最短路 + 枚举中转点。在加权图中,同样思路可以改为从多个源跑 Dijkstra。


真题 2:Shortcut(USACO 2019 January Gold)— Dijkstra 后构造最短路树

题目链接: USACO 2019 January Gold P3: Shortcut
对应模式: Dijkstra + 最短路树 + 子树贡献
难度定位: Gold

题干解读

农场是带权无向图,所有奶牛每天都沿着到 1 号谷仓的最短路回家。你可以在某个节点修一条到谷仓的捷径,耗时固定为 T,问最多能减少多少总通勤时间。

关键条件:

  • 需要先知道每个节点到 1 的最短距离。
  • 若有多条最短路,题目要求按编号较小父节点确定路径树。
  • 某个节点修捷径影响的是其最短路树子树中的所有奶牛。

思路分析

步骤:

  1. 从节点 1 跑 Dijkstra,得到 dist[x]
  2. 同时记录最短路树父节点 parent[x]:若距离更短,更新;若距离相同,选编号更小的父节点。
  3. parent 建树。
  4. DFS 统计每个节点子树内奶牛数量 subtreeCows[x]
  5. 若在 x 修捷径,节省为:
(dist[x] - T) * subtreeCows[x]

取最大值。

CPP 完整代码

✅ 完整代码:Shortcut
#include <bits/stdc++.h>
using namespace std;

using ll = long long;
const ll INF = (ll)4e18;

struct Edge {
    int to;
    int w;
};

int main() {
    ios::sync_with_stdio(false);
    cin.tie(nullptr);

    // USACO 官方文件 I/O 可按需打开:
    // freopen("shortcut.in", "r", stdin);
    // freopen("shortcut.out", "w", stdout);

    int n, m;
    ll t;
    cin >> n >> m >> t;

    vector<ll> cows(n + 1);
    for (int i = 1; i <= n; i++) cin >> cows[i];

    vector<vector<Edge>> adj(n + 1);
    for (int i = 0; i < m; i++) {
        int a, b, w;
        cin >> a >> b >> w;
        adj[a].push_back({b, w});
        adj[b].push_back({a, w});
    }

    vector<ll> dist(n + 1, INF);
    vector<int> parent(n + 1, 0);
    priority_queue<pair<ll, int>, vector<pair<ll, int>>, greater<pair<ll, int>>> pq;

    dist[1] = 0;
    parent[1] = 0;
    pq.push({0, 1});

    while (!pq.empty()) {
        auto [d, u] = pq.top();
        pq.pop();
        if (d != dist[u]) continue;

        for (const Edge &e : adj[u]) {
            int v = e.to;
            ll nd = d + e.w;
            if (nd < dist[v] || (nd == dist[v] && u < parent[v])) {
                dist[v] = nd;
                parent[v] = u;
                pq.push({nd, v});
            }
        }
    }

    vector<vector<int>> tree(n + 1);
    for (int v = 2; v <= n; v++) {
        tree[parent[v]].push_back(v);
    }

    vector<ll> subtreeCows = cows;
    vector<int> order;
    order.reserve(n);
    stack<int> st;
    st.push(1);
    while (!st.empty()) {
        int u = st.top();
        st.pop();
        order.push_back(u);
        for (int v : tree[u]) st.push(v);
    }

    for (int i = (int)order.size() - 1; i >= 0; i--) {
        int u = order[i];
        for (int v : tree[u]) {
            subtreeCows[u] += subtreeCows[v];
        }
    }

    ll answer = 0;
    for (int u = 1; u <= n; u++) {
        answer = max(answer, (dist[u] - t) * subtreeCows[u]);
    }

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

复杂度: Dijkstra O((N+M) log N),建树和统计 O(N),空间 O(N+M)

易错点提醒

  1. 忽略字典序父节点。 距离相同时必须选编号更小的父节点,否则最短路树错误。
  2. 只看单个节点奶牛数。 捷径影响整棵子树中的奶牛,不只是当前节点。
  3. 递归 DFS 栈风险。 N 可较大,用迭代顺序统计更稳。
  4. 节省值可能为负。max 时从 0 开始即可,不修捷径相当于节省 0。

拓展思考

Shortcut 是 Gold 中典型的「最短路结果不是终点,而是后续结构的输入」。跑完 Dijkstra 后,还要把最短路关系转成树,再在树上做统计。


⚠️ 五大经典 Dijkstra Bug

  1. int 而不是 long long —— 距离和溢出 → 静默的错误答案
  2. 最大堆而非最小堆 —— 忘记 greater<pii> → 优先处理错误的节点
  3. 缺少过期条目检查if (d > dist[u]) continue)—— 不是错误但慢约 10 倍
  4. 忘记 dist[src] = 0 —— 所有距离保持为 INF
  5. 对负边用 Dijkstra —— 未定义行为,可能无限循环或给出错误答案

本章总结

📌 核心要点

算法复杂度处理负边使用场景
BFSO(V+E)无权图
DijkstraO((V+E) log V)非负权重加权 SSSP
Bellman-FordO(VE)负边、检测负环
SPFA最坏 O(VE),平均快稀疏图含负边
Floyd-WarshallO(V³)全对、V ≤ 500
0-1 BFSO(V+E)不适用只有 0 或 1 权重的边

❓ 常见问题

Q1:为什么 Dijkstra 对负边不起作用?

A:Dijkstra 的贪心假设是「当前距离最短的节点不能通过后续路径改善」。有了负边,这个假设失败——通过负边的较长路径最终可能更短。结论:有负边必须用 Bellman-Ford(O(VE))或 SPFA(平均 O(E),最坏 O(VE))。

Q2:SPFA 和 Bellman-Ford 有什么区别?

A:SPFA 是队列优化版的 Bellman-Ford。Bellman-Ford 每轮遍历所有边;SPFA 只更新距离被改善的节点的邻居,用队列追踪哪些节点需要处理。实践中 SPFA 快得多(平均 O(E)),但理论最坏情况相同(O(VE))。

Q3:Floyd-Warshall 中为什么 k 循环必须在最外层?

A:这是最常见的 Floyd-Warshall 实现错误! DP 不变量是:处理第 k 次外层循环后,dist[i][j] 表示只用 {1, 2, ..., k} 作中间节点时从 i 到 j 的最短路径。处理中间节点 k 时,dist[i][k]dist[k][j] 必须已经基于 {1..k-1} 完整计算好。若 k 在内层,dist[i][k] 可能在同一轮刚被更新,导致错误结果。记住:k 在最外层,i 和 j 在内层——顺序很重要!

Q4:USACO 题目如何判断用 Dijkstra 还是 BFS?

A:关键问题:边是否有权重?

  • 无权图(边权=1 或求最少边数)→ BFS,O(V+E),更快更简单
  • 加权图(不同的非负权重)→ Dijkstra
  • 边权只有 0 或 1 → 0-1 BFS(比 Dijkstra 快,O(V+E))
  • 有负边 → Bellman-Ford/SPFA

Q5:什么时候用 Floyd-Warshall?

A:需要所有节点对之间的最短距离,且 V ≤ 500(O(V³) ≈ 1.25×10^8 在 V=500 时勉强可行)。典型场景:给定多个源和目标,查询任意对之间的距离。V > 500 时,对每个节点运行 Dijkstra(O(V × (V+E) log V))更快。

🔗 与其他章节的联系

  • 第 5.2 章(BFS 与 DFS):BFS 是「无权图的 Dijkstra」;本章是 BFS 的直接扩展
  • 第 5.5 章(二叉树与树算法):树上的最短路径就是唯一的根到节点路径(DFS/BFS 够用)
  • 第 6.1 章(DP 入门):Floyd-Warshall 本质上是 DP(状态 = 「使用前 k 个节点」);很多最短路变体可以用 DP 建模
  • USACO Gold:最短路 + DP 的组合、最短路 + 二分搜索、最短路 + 数据结构优化

练习题


题目 5.4.1 — 经典 Dijkstra 🟢 简单

题目: 给定 N 座城市和 M 条双向道路,各有行驶时间。找从城市 1 到城市 N 的最短行驶时间,若不可达输出 −1。

样例输入 1:

5 6
1 2 2
1 3 4
2 3 1
2 4 7
3 5 3
4 5 1

样例输出 1: 6(最短路:1→2→3→5,代价 2+1+3=6)

样例输入 2: 3 个城市,节点 3 不可达 → 输出: -1

💡 提示

从节点 1 做标准 Dijkstra。距离用 long long——最大路径 = N × 最大权重 = 10^5 × 10^9 = 10^14,int 会溢出。

✅ 完整题解
#include <bits/stdc++.h>
using namespace std;
typedef long long ll;
typedef pair<ll,int> pli;

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

    int n, m;
    cin >> n >> m;

    vector<vector<pair<int,int>>> adj(n + 1);
    for (int i = 0; i < m; i++) {
        int u, v, w; cin >> u >> v >> w;
        adj[u].push_back({v, w});
        adj[v].push_back({u, w});
    }

    vector<ll> dist(n + 1, LLONG_MAX);
    priority_queue<pli, vector<pli>, greater<pli>> pq;

    dist[1] = 0;
    pq.push({0, 1});

    while (!pq.empty()) {
        auto [d, u] = pq.top(); pq.pop();
        if (d > dist[u]) continue;

        for (auto [v, w] : adj[u]) {
            if (dist[u] + w < dist[v]) {
                dist[v] = dist[u] + w;
                pq.push({dist[v], v});
            }
        }
    }

    cout << (dist[n] == LLONG_MAX ? -1 : dist[n]) << "\n";
    return 0;
}
// 时间:O((N + M) log N),空间:O(N + M)

题目 5.4.2 — 网格 BFS 🟢 简单

题目: 机器人从 R×C 网格的格子 (0,0) 出发,部分格子是墙(#),其余可通行(.)。找到达 (R-1, C-1)最少步数,不可能时输出 −1。

✅ 完整题解
#include <bits/stdc++.h>
using namespace std;

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

    int R, C;
    cin >> R >> C;

    vector<string> grid(R);
    for (auto& row : grid) cin >> row;

    vector<vector<int>> dist(R, vector<int>(C, -1));
    queue<pair<int,int>> q;

    if (grid[0][0] != '#') {
        dist[0][0] = 0;
        q.push({0, 0});
    }

    int dr[] = {-1, 1, 0, 0};
    int dc[] = {0, 0, -1, 1};

    while (!q.empty()) {
        auto [r, c] = q.front(); q.pop();
        for (int d = 0; d < 4; d++) {
            int nr = r + dr[d], nc = c + dc[d];
            if (nr >= 0 && nr < R && nc >= 0 && nc < C
                && grid[nr][nc] != '#' && dist[nr][nc] == -1) {
                dist[nr][nc] = dist[r][c] + 1;
                q.push({nr, nc});
            }
        }
    }

    cout << dist[R-1][C-1] << "\n";
    return 0;
}

题目 5.4.3 — 负边检测 🟡 中等

题目: 给定有 N 个节点、M 条边(可能有负权重)的有向图,找从节点 1 到节点 N 的最短距离。若存在可从节点 1 到达且能到达节点 N 的负环,输出 "NEGATIVE CYCLE"。若节点 N 不可达,输出 "UNREACHABLE"。

✅ 完整题解
#include <bits/stdc++.h>
using namespace std;
typedef long long ll;

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

    int n, m;
    cin >> n >> m;

    vector<tuple<int,int,ll>> edges(m);
    for (auto& [u, v, w] : edges) cin >> u >> v >> w;

    const ll INF = 1e18;
    vector<ll> dist(n + 1, INF);
    dist[1] = 0;

    // Bellman-Ford:V-1 遍
    for (int iter = 0; iter < n - 1; iter++) {
        for (auto [u, v, w] : edges) {
            if (dist[u] != INF && dist[u] + w < dist[v]) {
                dist[v] = dist[u] + w;
            }
        }
    }

    // 第 V 遍:检测负环
    vector<bool> inNegCycle(n + 1, false);
    for (int iter = 0; iter < n; iter++) {
        for (auto [u, v, w] : edges) {
            if (dist[u] != INF && dist[u] + w < dist[v]) {
                dist[v] = dist[u] + w;
                inNegCycle[v] = true;
            }
            if (inNegCycle[u]) inNegCycle[v] = true;
        }
    }

    if (dist[n] == INF) cout << "UNREACHABLE\n";
    else if (inNegCycle[n]) cout << "NEGATIVE CYCLE\n";
    else cout << dist[n] << "\n";

    return 0;
}

题目 5.4.4 — 多源 BFS:僵尸爆发 🟡 中等

题目: K 座已感染城市同时爆发僵尸。每个时间单位,僵尸扩散到所有相邻(未感染)城市。找僵尸到达每座可达城市的最少时间,永远无法到达的城市输出 −1。

💡 提示

多源 BFS:将所有 K 座感染城市以时间 0 加入队列。然后正常运行 BFS。这等价于添加一个虚拟「超级源点」通过 0 代价边连接到所有 K 座城市。

✅ 完整题解
#include <bits/stdc++.h>
using namespace std;

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

    int n, m, k;
    cin >> n >> m >> k;

    vector<vector<int>> adj(n + 1);
    for (int i = 0; i < m; i++) {
        int u, v; cin >> u >> v;
        adj[u].push_back(v);
        adj[v].push_back(u);
    }

    vector<int> dist(n + 1, -1);
    queue<int> q;

    // 将所有 K 个僵尸源以时间 0 压入
    for (int i = 0; i < k; i++) {
        int z; cin >> z;
        dist[z] = 0;
        q.push(z);
    }

    // 从所有源同时做标准 BFS
    while (!q.empty()) {
        int u = q.front(); q.pop();
        for (int v : adj[u]) {
            if (dist[v] == -1) {
                dist[v] = dist[u] + 1;
                q.push(v);
            }
        }
    }

    for (int u = 1; u <= n; u++) {
        cout << dist[u];
        if (u < n) cout << " ";
    }
    cout << "\n";
    return 0;
}

题目 5.4.5 — Floyd 全对最短路 🟡 中等

题目: 给定 N 座城市(N ≤ 300)和 M 条道路,回答 Q 次查询:「城市 u 到城市 v 的距离在 D 以内吗?」

✅ 完整题解
#include <bits/stdc++.h>
using namespace std;
typedef long long ll;

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

    int n, m;
    cin >> n >> m;

    const ll INF = 1e18;
    vector<vector<ll>> dist(n + 1, vector<ll>(n + 1, INF));

    for (int i = 1; i <= n; i++) dist[i][i] = 0;

    for (int i = 0; i < m; i++) {
        int u, v; ll w;
        cin >> u >> v >> w;
        dist[u][v] = min(dist[u][v], w);
        dist[v][u] = min(dist[v][u], w);
    }

    // Floyd-Warshall:O(N³)
    for (int k = 1; k <= n; k++)
        for (int i = 1; i <= n; i++)
            for (int j = 1; j <= n; j++)
                if (dist[i][k] != INF && dist[k][j] != INF)
                    dist[i][j] = min(dist[i][j], dist[i][k] + dist[k][j]);

    int q;
    cin >> q;
    while (q--) {
        int u, v; ll D;
        cin >> u >> v >> D;
        cout << (dist[u][v] <= D ? "YES" : "NO") << "\n";
    }

    return 0;
}
// 时间:O(N³ + Q),空间:O(N²)

题目 5.4.6 — 最大瓶颈路径 🔴 困难

题目: 给定 N 座城市和 M 条道路,每条道路有重量限制(能通过的最大货物重量)。找从城市 1 到城市 N 最大化路径最小边权的路径——即一趟能运送的最重货物。

💡 提示

修改版 Dijkstra: 不是最小化总代价,而是最大化瓶颈。dist[v] = 到 v 的任意路径的最大最小边权。用最大堆。松弛:dist[v] = max(dist[v], min(dist[u], weight(u,v)))

✅ 完整题解
#include <bits/stdc++.h>
using namespace std;
typedef pair<int,int> pii;

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

    int n, m;
    cin >> n >> m;

    vector<vector<pii>> adj(n + 1);
    for (int i = 0; i < m; i++) {
        int u, v, w; cin >> u >> v >> w;
        adj[u].push_back({v, w});
        adj[v].push_back({u, w});
    }

    // 修改版 Dijkstra:最大化瓶颈
    vector<int> dist(n + 1, 0);
    priority_queue<pii> pq;  // 最大堆:{瓶颈, 节点}

    dist[1] = INT_MAX;
    pq.push({INT_MAX, 1});

    while (!pq.empty()) {
        auto [d, u] = pq.top(); pq.pop();
        if (d < dist[u]) continue;

        for (auto [v, w] : adj[u]) {
            int newBottleneck = min(dist[u], w);  // ← 关键:取路径上的最小值
            if (newBottleneck > dist[v]) {
                dist[v] = newBottleneck;
                pq.push({dist[v], v});
            }
        }
    }

    cout << dist[n] << "\n";
    return 0;
}
// 时间:O((N + M) log N),空间:O(N + M)

第 5.4 章结束 — 下一章:第 6.1 章:动态规划入门

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

第 5.5 章:二叉树与树算法

前置条件 你应该熟悉:递归(第 2.3 章)、C++ 中的指针/结构体,以及基本的图概念(邻接关系、节点、边)。本章综合了二叉树基础与进阶树算法(LCA 倍增、欧拉序),是 USACO Silver/Gold 的核心。

🎯 本章目标: 从零开始理解二叉树,先掌握节点、递归和遍历,再学习 BST、LCA、欧拉序等竞赛常用技巧。每一步都会用直觉、图示和代码解释,确保你不仅会写代码,更理解背后的「为什么」。

本章学习路线:
1. 先认识树的基本结构:根、父子关系、深度、高度。
2. 再学习树的表示方式与递归思维:为什么树天然适合递归。
3. 接着学习遍历:前序、中序、后序、层序,这是后面所有算法的共同基础。
4. 然后进入第一个应用:二叉搜索树(BST),理解有序结构如何支持搜索、插入、删除。
5. 最后扩展到竞赛树算法:LCA 二进制倍增与欧拉序,把树上查询优化到 O(log N) 或更快。

🧭 阅读建议: 如果你第一次学树,请按顺序阅读;如果你已经会遍历,可以直接跳到 BST 或 LCA。但不要跳过遍历思想——LCA、欧拉序、树形 DP 本质上都建立在 DFS 遍历之上。


5.5.1 从树到二叉树:先建立模型

用生活直觉理解二叉树

想象一个公司的组织架构:CEO 在最顶端,他直接管理两个部门(左:技术部,右:市场部);每个部门经理又各管两个小组…… 这种每个节点最多分两叉的层级结构,就是二叉树。

更准确地定义:

二叉树是一种层级数据结构:

  • 每个节点最多有 2 个子节点:左子节点和右子节点
  • 恰好有一个根节点(无父节点)
  • 每个非根节点恰好有一个父节点

先分清 3 个概念

很多初学者会把「二叉树」「二叉搜索树」「普通树」混在一起。它们的关系是:

名称限制典型用途
普通树每个节点可以有任意多个子节点家族关系、文件夹、通用树题
二叉树每个节点最多 2 个子节点遍历、表达式树、堆、线段树基础
二叉搜索树(BST)二叉树 + 左小右大有序集合、搜索、插入、删除

🔑 关键区别: 二叉树只限制「最多两个孩子」,并不要求左边小、右边大;BST 才有排序性质。先理解二叉树的结构与遍历,再理解 BST 的有序性,会更自然。

🌳
核心术语
根节点 — 最顶层的节点(深度 0)
叶节点 — 没有子节点的节点
内部节点 — 至少有一个子节点的节点
高度 — 从根到任意叶节点的最长路径(边数)
深度 — 从根到该节点的距离(边数)
子树 — 一个节点及其所有后代

💡 深度 vs 高度的区别: 深度是从根往下看,高度是从叶往上量。就像一栋楼——你住的楼层是"深度",这栋楼的总层数是"高度"。

图示

Binary Tree Structure

在这棵树中:

  • 高度 = 2(最长的根到叶路径:A → B → D,经过了 2 条边)
  • 根 = A叶节点 = D, E, F
  • B 是 D 和 E 的父节点D 是 B 的左子节点E 是 B 的右子节点

在程序里如何表示二叉树

二叉树常见有两种表示方式:

  1. 指针表示:每个节点保存 leftright 指针,最贴近树的结构,适合讲解 BST、递归遍历、构造/删除节点。
  2. 数组表示:如果树接近完全二叉树,可以用下标表示父子关系,例如堆中常用 left = 2*iright = 2*i+1

本章前半部分用指针表示,因为它最容易看清楚「节点—左子树—右子树」的递归关系。后面的 LCA 和欧拉序会改用邻接表,因为竞赛题通常给的是普通树,而不是指针二叉树。

C++ 节点定义

本章使用统一的 struct TreeNode

📄 本章使用统一的 `struct TreeNode`:
#include <bits/stdc++.h>
using namespace std;

struct TreeNode {
    int val;
    TreeNode* left;
    TreeNode* right;

    // 构造函数:用值初始化,无子节点
    TreeNode(int v) : val(v), left(nullptr), right(nullptr) {}
};

💡 为什么用裸指针? 竞赛编程中为速度通常手动管理内存。nullptr(C++11)始终比未初始化的指针安全——务必初始化 left = right = nullptr


5.5.2 树的遍历:先学会如何访问节点

遍历 = 恰好访问每个节点一次。学习树算法时,遍历是第一块地基:后面的 BST 中序输出、树高计算、LCA 预处理、欧拉序,本质上都依赖遍历。

三种 DFS 遍历访问的是同一棵树,但「处理根节点」的时机不同:

Binary Tree Traversals

递归遍历的统一模板

看到树的递归代码时,可以固定问自己 3 个问题:

  1. 当前节点为空怎么办? 这是递归的基础情况。
  2. 当前节点要做什么? 比如输出值、统计答案、更新深度。
  3. 左右子树什么时候处理? 根在前就是前序,根在中间就是中序,根在最后就是后序。
void dfs(TreeNode* root) {
    if (root == nullptr) return;   // 1. 空节点直接返回
    // 2. 在这里处理 root,就是前序
    dfs(root->left);               // 3. 递归处理左子树
    // 2. 在这里处理 root,就是中序
    dfs(root->right);              // 3. 递归处理右子树
    // 2. 在这里处理 root,就是后序
}

有 4 种基本遍历:

遍历顺序使用场景
前序根 → 左 → 右复制树、前缀表达式
中序左 → 根 → 右BST 有序输出
后序左 → 右 → 根删除树、后缀表达式
层序BFS 按层最短路、层级操作

💡 记忆口诀: 前序的"根"在最前,中序的"根"在中间,后序的"根"在最后。名字就暗示了顺序!

5.5.2.1 前序遍历

前序遍历就像领导视察:先看当前部门,再去左边,再去右边

📄 查看代码:5.5.3.1 前序遍历
// 前序遍历 — O(N) 时间,O(H) 空间(H = 高度)
// 访问顺序:根,左子树,右子树
void preorder(TreeNode* root) {
    if (root == nullptr) return;   // 基础情况
    cout << root->val << " ";      // 先处理根
    preorder(root->left);          // 然后左子树
    preorder(root->right);         // 然后右子树
}

// 对于树:    [5]
//             /    \
//           [3]    [8]
//          /   \
//        [1]   [4]
// 前序:5 3 1 4 8

迭代版前序(用栈):

📄 C++ 完整代码
// 前序遍历迭代版
void preorderIterative(TreeNode* root) {
    if (root == nullptr) return;
    stack<TreeNode*> stk;
    stk.push(root);

    while (!stk.empty()) {
        TreeNode* node = stk.top(); stk.pop();
        cout << node->val << " ";    // 处理当前节点

        // 先压右子节点(这样左子节点先被处理——LIFO!)
        if (node->right) stk.push(node->right);
        if (node->left)  stk.push(node->left);
    }
}

5.5.2.2 中序遍历

中序遍历就像读一本书:先读左边页,再读当前页,再读右边页。对于 BST,中序遍历会产生有序序列——这是 BST 最重要的性质。

📄 查看代码:5.5.3.2 中序遍历
// 中序遍历 — O(N) 时间
// 访问顺序:左子树,根,右子树
// 关键性质:BST 的中序遍历产生有序输出!
void inorder(TreeNode* root) {
    if (root == nullptr) return;
    inorder(root->left);           // 先左子树
    cout << root->val << " ";      // 然后根
    inorder(root->right);          // 然后右子树
}

// 对 BST(值 {1, 3, 4, 5, 8}):
// 中序:1 3 4 5 8  ← 有序!这是 BST 最重要的性质

🔑 核心思路: 任何 BST 的中序遍历始终产生有序序列。这就是为什么 std::set 能按有序迭代——内部使用中序遍历。

迭代版中序(稍复杂):

📄 C++ 完整代码
// 中序遍历迭代版
void inorderIterative(TreeNode* root) {
    stack<TreeNode*> stk;
    TreeNode* curr = root;

    while (curr != nullptr || !stk.empty()) {
        // 尽量向左走
        while (curr != nullptr) {
            stk.push(curr);
            curr = curr->left;
        }
        // 处理最左侧未处理的节点
        curr = stk.top(); stk.pop();
        cout << curr->val << " ";

        // 转向右子树
        curr = curr->right;
    }
}

5.5.2.3 后序遍历

后序遍历就像收拾房间:先收拾左边,再收拾右边,最后收拾当前这块。最适合用来释放内存(先删子节点,最后删根节点,安全!)。

📄 查看代码:5.5.3.3 后序遍历
// 后序遍历 — O(N) 时间
// 访问顺序:左子树,右子树,根
// 用于:删除树、求值表达式树
void postorder(TreeNode* root) {
    if (root == nullptr) return;
    postorder(root->left);         // 先左子树
    postorder(root->right);        // 然后右子树
    cout << root->val << " ";      // 根最后
}

// ── 用后序遍历清理内存 ──
void deleteTree(TreeNode* root) {
    if (root == nullptr) return;
    deleteTree(root->left);   // 先删左子树
    deleteTree(root->right);  // 再删右子树
    delete root;              // 最后删这个节点(安全:子节点已删)
}

5.5.2.4 层序遍历(BFS)

层序遍历就像从上往下扫描一栋楼:先看第一层,再看第二层……逐层往下

📄 查看代码:5.5.3.4 层序遍历(BFS)
// 层序遍历(BFS)— O(N) 时间,O(W) 空间(W = 最大层宽)
// 使用队列:逐层处理节点
void levelOrder(TreeNode* root) {
    if (root == nullptr) return;

    queue<TreeNode*> q;
    q.push(root);

    while (!q.empty()) {
        int levelSize = q.size();  // 当前层的节点数

        for (int i = 0; i < levelSize; i++) {
            TreeNode* node = q.front(); q.pop();
            cout << node->val << " ";

            if (node->left)  q.push(node->left);
            if (node->right) q.push(node->right);
        }
        cout << "\n";  // 层间换行
    }
}

// 对 BST [5, 3, 8, 1, 4]:
// 第 0 层:5
// 第 1 层:3 8
// 第 2 层:1 4

遍历汇总

树:            [5]
               /    \
             [3]    [8]
            /   \   /
          [1]  [4] [7]

前序:   5 3 1 4 8 7
中序:   1 3 4 5 7 8    ← 有序!
后序:   1 4 3 7 8 5
层序:   5 | 3 8 | 1 4 7

5.5.3 树的高度、规模与平衡

5.5.3.1 计算树的高度

学完遍历后,最自然的第一个问题是:这棵树有多深?高度、节点数、叶子数都可以用同一种思想解决:先解决子树,再合并答案。这就是后序思想的典型应用。

树的高度就像量一栋楼有多少层:最高的那栋楼的高度就是整栋楼的层数。对树来说,就是从根到最远叶节点的边数。

📄 查看代码:5.5.4.1 计算树的高度
// 树的高度 — O(N) 时间,O(H) 递归栈空间
// 高度 = 最长的根到叶路径的长度
// 约定:空树高度 = -1,叶节点高度 = 0
int height(TreeNode* root) {
    if (root == nullptr) return -1;  // 空子树高度 -1

    int leftHeight  = height(root->left);   // 左子树高度
    int rightHeight = height(root->right);  // 右子树高度

    return 1 + max(leftHeight, rightHeight);  // +1 表示当前节点
}

5.5.3.2 检查平衡

平衡二叉树要求每个节点的左右子树高度差不超过 1。

为什么需要平衡?因为不平衡的 BST 会退化(像前面说的链表),操作变慢。就像一栋楼如果左边盖了 100 层,右边只有 1 层——这不只是不好看,更会影响效率。

📄 C++ 完整代码
// 检查平衡 BST — O(N) 时间
// 不平衡时返回 -1,否则返回子树高度
int checkBalanced(TreeNode* root) {
    if (root == nullptr) return 0;  // 空树平衡,高度 0

    int leftH = checkBalanced(root->left);
    if (leftH == -1) return -1;     // 左子树不平衡

    int rightH = checkBalanced(root->right);
    if (rightH == -1) return -1;    // 右子树不平衡

    // 检查当前节点的平衡:高度差不超过 1
    if (abs(leftH - rightH) > 1) return -1;  // 不平衡!

    return 1 + max(leftH, rightH);   // 平衡时返回高度
}

bool isBalanced(TreeNode* root) {
    return checkBalanced(root) != -1;
}

5.5.3.3 节点计数

📄 查看代码:5.5.4.3 节点计数
// 节点计数 — O(N)
int countNodes(TreeNode* root) {
    if (root == nullptr) return 0;
    return 1 + countNodes(root->left) + countNodes(root->right);
}

// 专门统计叶节点
int countLeaves(TreeNode* root) {
    if (root == nullptr) return 0;
    if (root->left == nullptr && root->right == nullptr) return 1;  // 叶节点!
    return countLeaves(root->left) + countLeaves(root->right);
}

5.5.4 二叉搜索树(BST):让树支持有序搜索

前面我们只把树当作一种层级结构。现在给二叉树加上一条排序规则,就得到 BST。BST 的核心价值是:每次比较都能决定下一步往左还是往右

用查字典的直觉理解 BST

想象你在一本按字母顺序排列的字典里查 "mango":

  • 翻到中间,看到 "lemon" —— M 在 L 后面,往后翻
  • 再翻一半,看到 "papaya" —— M 在 P 前面,往前翻
  • 再翻一半,看到 "mango" —— 找到了!

每次比较都能排除一半的范围,这就是 BST 搜索的核心思想。

二叉搜索树是带有关键排序性质的二叉树:

BST 性质
左子树 < 节点 < 右子树
搜索
平均 O(log N)
插入
平均 O(log N)
删除
平均 O(log N)
最坏情况
O(N)

BST 性质: 对每个节点 v

  • 左子树中所有值都严格小于 v.val
  • 右子树中所有值都严格大于 v.val
       [5]          ← 有效的 BST
      /    \
    [3]    [8]
   /   \   /  \
  [1] [4] [7] [10]

  5 的左边 = {1, 3, 4} — 都 < 5  ✓
  5 的右边 = {7, 8, 10} — 都 > 5  ✓

💡 为什么这很重要? BST 性质保证了中序遍历必然输出有序序列——这是 BST 最强大的性质,后面会反复用到。

5.5.4.1 BST 搜索

BST 搜索就像查字典:每次比较后,目标值要么更小(向左),要么更大(向右),每次都能排除半个搜索空间。

📄 查看代码:5.5.2.1 BST 搜索
// BST 搜索 — 平均 O(log N),最坏 O(N)
// 返回值为 'target' 的节点指针,找不到返回 nullptr
TreeNode* search(TreeNode* root, int target) {
    // 基础情况:空树或找到目标
    if (root == nullptr || root->val == target) {
        return root;
    }
    // BST 性质:target 更小则向左
    if (target < root->val) {
        return search(root->left, target);
    }
    // target 更大则向右
    return search(root->right, target);
}

迭代版本(避免大树时的栈溢出):

// BST 搜索迭代版
TreeNode* searchIterative(TreeNode* root, int target) {
    while (root != nullptr) {
        if (target == root->val) return root;       // 找到
        else if (target < root->val) root = root->left;   // 向左
        else root = root->right;                     // 向右
    }
    return nullptr;  // 未找到
}

5.5.4.2 BST 插入

插入就是在搜索路径的末端创建一个新节点——就像字典里加一个新词,你需要顺着字母顺序找到正确的空位。

📄 查看代码:5.5.2.2 BST 插入
// BST 插入 — 平均 O(log N)
// 返回子树(可能是新的)的根节点
TreeNode* insert(TreeNode* root, int val) {
    // 到达空位时,在此创建新节点
    if (root == nullptr) {
        return new TreeNode(val);
    }
    if (val < root->val) {
        root->left = insert(root->left, val);   // 递归向左
    } else if (val > root->val) {
        root->right = insert(root->right, val); // 递归向右
    }
    // val == root->val:重复值,忽略(或按需处理)
    return root;
}

// 用法:
// TreeNode* root = nullptr;
// root = insert(root, 5);
// root = insert(root, 3);
// root = insert(root, 8);

5.5.4.3 BST 删除

删除是最复杂的 BST 操作。为什么?因为删掉一个节点后,树可能会「断裂」,你需要用一个合适的节点来「补位」。有 3 种情况,由简到繁:

情况 1:节点是叶节点(无子节点) —— 最简单,直接删掉就行。

    [5]                [5]
   /   \     →       /   \
  [3]  [8]          [3]  [8]
      /
    [7]  ← 删除这个叶节点

情况 2:节点只有一个子节点 —— 用子节点「顶替」它。

    [5]                [5]
   /   \     →       /   \
  [3]  [8]          [3]  [7]
      /                   \
    [7]  ← 删除 [8],让 [7] 顶替它的位置
      \
      [9]  ← [7] 的子树跟着 [7] 一起过去

情况 3:节点有两个子节点 —— 最复杂。用一个中序后继(右子树中的最小值)来替换,然后删除后继节点。

为什么用中序后继?因为中序后继是比被删节点大的最小值——用它来替换,BST 性质仍然成立。

      [5]                    [5]
     /   \       →          /   \
   [3]   [8]             [3]   [7]
   / \   / \             / \     \
 [1] [4][7][10]       [1] [4]  [10]

 删除 [8]:中序后继是 [7](右子树中最小)
 把 [8] 的值替换为 [7],然后删掉原来的 [7](叶节点,情况 1)
📄 完整代码:BST 删除
// BST 删除 — 平均 O(log N)
// 辅助函数:找子树中最小节点
TreeNode* findMin(TreeNode* node) {
    while (node->left != nullptr) node = node->left;
    return node;
}

// 从以 'root' 为根的树中删除值为 'val' 的节点
TreeNode* deleteNode(TreeNode* root, int val) {
    if (root == nullptr) return nullptr;  // 值未找到

    if (val < root->val) {
        // 情况:目标在左子树
        root->left = deleteNode(root->left, val);
    } else if (val > root->val) {
        // 情况:目标在右子树
        root->right = deleteNode(root->right, val);
    } else {
        // 找到要删除的节点!

        // 情况一:无子节点(叶节点)
        if (root->left == nullptr && root->right == nullptr) {
            delete root;
            return nullptr;
        }
        // 情况二A:只有右子节点
        else if (root->left == nullptr) {
            TreeNode* temp = root->right;
            delete root;
            return temp;
        }
        // 情况二B:只有左子节点
        else if (root->right == nullptr) {
            TreeNode* temp = root->left;
            delete root;
            return temp;
        }
        // 情况三:有两个子节点——用中序后继替换
        else {
            TreeNode* successor = findMin(root->right);  // 右子树中最小
            root->val = successor->val;                  // 复制后继的值
            root->right = deleteNode(root->right, successor->val);  // 删除后继
        }
    }
    return root;
}

5.5.4.4 BST 退化问题

⚠️ 关键问题: 如果按有序顺序插入(1, 2, 3, 4, 5...),BST 会退化为链表

[1]
  \
  [2]
    \
    [3]        ← 每次操作 O(N),不是 O(log N)!
      \
      [4]
        \
        [5]

想象一个极端的字典:如果所有词都按字母顺序从 A 到 Z 排列,你每次查 Z 都要从头翻到尾——这就失去了"每次排除一半"的优势。

这就是平衡 BST(AVL 树、红黑树)存在的原因。C++ 中 std::setstd::map 用红黑树实现——始终保证 O(log N)

AVL 树旋转:左旋 & 右旋

🔗 关键结论:竞赛编程中,用 std::set / std::map 代替手写 BST。它们始终保持平衡。学习 BST 基础是为了理解它们为什么有效,竞赛中直接用 STL(见第 3.8 章)。

5.5.5 完整 BST 实现:把操作组合起来

这是完整的、可直接用于竞赛的 BST:

📄 这是完整的、可直接用于竞赛的 BST:
#include <bits/stdc++.h>
using namespace std;

struct TreeNode {
    int val;
    TreeNode* left;
    TreeNode* right;
    TreeNode(int v) : val(v), left(nullptr), right(nullptr) {}
};

struct BST {
    TreeNode* root;
    BST() : root(nullptr) {}

    // ── 插入 ──
    TreeNode* _insert(TreeNode* node, int val) {
        if (!node) return new TreeNode(val);
        if (val < node->val) node->left  = _insert(node->left,  val);
        else if (val > node->val) node->right = _insert(node->right, val);
        return node;
    }
    void insert(int val) { root = _insert(root, val); }

    // ── 搜索 ──
    bool search(int val) {
        TreeNode* curr = root;
        while (curr) {
            if (val == curr->val) return true;
            curr = (val < curr->val) ? curr->left : curr->right;
        }
        return false;
    }

    // ── 中序遍历(有序输出) ──
    void _inorder(TreeNode* node, vector<int>& result) {
        if (!node) return;
        _inorder(node->left, result);
        result.push_back(node->val);
        _inorder(node->right, result);
    }
    vector<int> getSorted() {
        vector<int> result;
        _inorder(root, result);
        return result;
    }

    // ── 高度 ──
    int _height(TreeNode* node) {
        if (!node) return -1;
        return 1 + max(_height(node->left), _height(node->right));
    }
    int height() { return _height(root); }
};

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

    BST bst;
    vector<int> vals = {5, 3, 8, 1, 4, 7, 10};
    for (int v : vals) bst.insert(v);

    cout << "有序输出:";
    for (int v : bst.getSorted()) cout << v << " ";
    cout << "\n";
    // 输出:1 3 4 5 7 8 10

    cout << "高度:" << bst.height() << "\n";  // 2
    cout << "搜索 4:" << bst.search(4) << "\n";  // 1(真)
    cout << "搜索 6:" << bst.search(6) << "\n";  // 0(假)

    return 0;
}

5.5.6 从遍历序列重建树

经典题:给定前序中序遍历,重建原始树。

核心思路:

  • 前序数组的第一个元素始终是
  • 中序数组中,根将其分为左右子树

让我们一步步来看:

💡 Code 代码(12 行)
前序:[3, 9, 20, 15, 7]
中序:[9, 3, 15, 20, 7]

第一步:前序第一个 = 3,所以根是 3
第二步:在中序里找 3,把它左边和右边分开
        [9, | 3 |, 15, 20, 7]
         ↑左子树  ↑  ↑右子树↑

第三步:递归处理左子树和右子树
        左子树:前序 [9],中序 [9] → 叶节点 9
        右子树:前序 [20, 15, 7],中序 [15, 20, 7]
                → 根 20,左 15,右 7
📄 C++ 完整代码
// 从前序 + 中序重建树 — O(N^2) 朴素版
TreeNode* build(vector<int>& pre, int preL, int preR,
                vector<int>& in,  int inL,  int inR) {
    if (preL > preR) return nullptr;

    int rootVal = pre[preL];  // 前序第一个 = 根
    TreeNode* root = new TreeNode(rootVal);

    // 在中序数组中找根
    int rootIdx = inL;
    while (in[rootIdx] != rootVal) rootIdx++;

    int leftSize = rootIdx - inL;  // 左子树节点数

    // 递归构建左右子树
    root->left  = build(pre, preL+1, preL+leftSize, in, inL, rootIdx-1);
    root->right = build(pre, preL+leftSize+1, preR, in, rootIdx+1, inR);

    return root;
}

TreeNode* buildTree(vector<int>& preorder, vector<int>& inorder) {
    int n = preorder.size();
    return build(preorder, 0, n-1, inorder, 0, n-1);
}

5.5.7 最近公共祖先(LCA)——从暴力方法开始

前面的内容都围绕二叉树展开。竞赛中的树题通常是普通树:每个节点可能有很多个孩子,输入也常常是边列表。LCA 是普通树上最重要的查询之一。我们先从最直觉的暴力方法学起,再升级到二进制倍增。

用家族族谱理解 LCA

LCA 就是找两个人的最近共同祖先。比如:

  • 你和表哥的 LCA 是你们的爷爷(而不是太爷爷)
  • 你和亲兄弟的 LCA 是你们的爸爸

有根树中两个节点 uv 的 LCA 是它们的最深公共祖先

📄 有根树中两个节点 `u` 和 `v` 的 **LCA** 是它们的最深公共祖先。
          [1]
         /    \
       [2]    [3]
      /   \      \
    [4]   [5]   [6]
   /
  [7]

LCA(4, 5) = 2     (4 和 5 都是 2 的后代)
LCA(4, 6) = 1     (最深公共祖先是根 1)
LCA(2, 4) = 2     (节点 2 是 4 的祖先,也是自身的祖先)

O(N) 暴力 LCA

暴力法的思路非常直觉:从根分别走到 u 和 v,记录路径,然后找两条路径的最后一个公共点。就像两个人从北京出发,一个人去了上海,一个人去了广州,他们的 LCA 就是路径分开前最后经过的共同城市。

📄 查看代码:O(N) 暴力 LCA
// LCA 暴力法 — 每次查询 O(N)
// 策略:找从根到每个节点的路径,然后找最后一个公共节点

// 第一步:找从根到目标节点的路径
bool findPath(TreeNode* root, int target, vector<int>& path) {
    if (root == nullptr) return false;

    path.push_back(root->val);  // 将当前节点加入路径

    if (root->val == target) return true;  // 找到目标!

    // 先尝试左子树,再尝试右子树
    if (findPath(root->left, target, path)) return true;
    if (findPath(root->right, target, path)) return true;

    path.pop_back();  // 回溯:目标不在这个子树中
    return false;
}

// 第二步:用两条路径找 LCA
int lca(TreeNode* root, int u, int v) {
    vector<int> pathU, pathV;

    findPath(root, u, pathU);   // 从根到 u 的路径
    findPath(root, v, pathV);   // 从根到 v 的路径

    // 找两条路径的最后一个公共节点
    int result = root->val;
    int minLen = min(pathU.size(), pathV.size());

    for (int i = 0; i < minLen; i++) {
        if (pathU[i] == pathV[i]) {
            result = pathU[i];  // 仍然公共
        } else {
            break;  // 分叉了
        }
    }
    return result;
}
暴力法
每次查询 O(N)
二进制倍增
每次查询 O(log N)
构建时间
O(N log N)

💡 USACO 说明: 对于 USACO Silver 题目,O(N) 暴力 LCA 并非总是够用。N ≤ 10^5 个节点且 Q ≤ 10^5 次查询时,总计 O(NQ) = O(10^10)——太慢了。只在 N, Q ≤ 5000 时使用暴力。本章 §5.5.8 讲解 O(log N) 的二进制倍增 LCA 用于更难的题目。


5.5.8 LCA 进阶:二进制倍增(O(log N))

本节将 §5.5.7 的朴素 LCA 升级为 O(N log N) 预处理 + O(log N) 查询,是 USACO Gold 的必备技术。

从暴力法到倍增法

暴力 LCA 慢在一个地方:每次查询都可能从节点一路爬到根。如果查询很多,重复爬同样的祖先链会浪费大量时间。倍增法的做法是:提前记住每个节点向上跳 1、2、4、8…… 步分别会到哪里

核心思想

朴素 LCA 每次查询最多爬 O(N) 步太慢。二进制倍增预先存储:

anc[v][k] = v 的第 2^k 个祖先

将 N 步拆成最多 log N 次「跳跃」,每次跳 2 的幂次。

直觉理解: 想象你要爬 1000 级楼梯。朴素法是一步步走(1000 步)。倍增法是:先跳 512 级,再跳 256 级,再跳 128 级…… 总共不到 10 次跳跃就到了!这就是二进制倍增的威力——用二进制的思想把线性步数压缩到对数级。

构建 anc 表:

  • anc[v][0] = v 的直接父节点(DFS 时记录)
  • anc[v][k] = anc[anc[v][k-1]][k-1](跳 2^k = 跳两次 2^(k-1))

完整实现

📄 查看代码:LCA 二进制倍增完整实现
#include <bits/stdc++.h>
using namespace std;

const int MAXN = 500005, LOG = 20;
vector<int> adj[MAXN];
int depth[MAXN], anc[MAXN][LOG];

// DFS 建树,同时计算 anc[v][k]
void dfs(int u, int par, int d) {
    depth[u] = d;
    anc[u][0] = par;  // 直接父节点
    for (int k = 1; k < LOG; k++)
        anc[u][k] = anc[anc[u][k-1]][k-1];  // 倍增构建
    for (int v : adj[u])
        if (v != par) dfs(v, u, d + 1);
}

// O(log N) LCA 查询
int lca(int u, int v) {
    // 步骤1:把深度更大的节点提升到与另一个相同深度
    if (depth[u] < depth[v]) swap(u, v);
    int diff = depth[u] - depth[v];
    for (int k = 0; k < LOG; k++)
        if ((diff >> k) & 1) u = anc[u][k];
    
    // 步骤2:两个在相同深度的节点同步上跳,直到相遇
    if (u == v) return u;  // 其中一个本来就是另一个的祖先
    for (int k = LOG - 1; k >= 0; k--)
        if (anc[u][k] != anc[v][k]) {
            u = anc[u][k];
            v = anc[v][k];
        }
    return anc[u][0];  // 此时 u, v 的父节点就是 LCA
}

int main() {
    ios_base::sync_with_stdio(false); cin.tie(NULL);
    int n, q; cin >> n >> q;
    for (int i = 0; i < n - 1; i++) {
        int u, v; cin >> u >> v;
        adj[u].push_back(v);
        adj[v].push_back(u);
    }
    dfs(1, 1, 0);  // 根为1,根的父节点设为自身
    while (q--) {
        int u, v; cin >> u >> v;
        cout << lca(u, v) << "\n";
    }
    return 0;
}

步骤 2 的关键理解: 从高位到低位枚举,若跳 2^k 后仍不同,就跳(否则可能跳过 LCA)。最终 u 和 v 停在 LCA 的直接子节点上,anc[u][0] 即为 LCA。

复杂度对比

方法预处理单次查询适用场景
朴素爬树(§5.5.5)O(N)O(N)N ≤ 5000,代码简单
二进制倍增O(N log N)O(log N)N, Q ≤ 5×10^5,USACO Gold
Euler Tour + RMQO(N log N)O(1)超高频查询(超竞赛范围)

5.5.9 欧拉序(DFS 时间戳):把子树变成区间

LCA 解决的是两个点之间的祖先关系;欧拉序解决的是另一个常见问题:如何快速处理某个节点的整棵子树。欧拉序将树「展平」为一个线性数组,将子树查询转化为区间查询,从而用线段树或树状数组 O(log N) 回答。

核心思想

DFS 时给每个节点记录进入时间 in[u]退出时间 out[u]

💡 Code 代码(12 行)
          1
         / \
        2   3
       / \
      4   5

DFS 顺序:1(in=1) → 2(in=2) → 4(in=3,out=3) → 5(in=4,out=4) → 2(out=4) → 3(in=5,out=5) → 1(out=5)

in  = [_, 1, 2, 5, 3, 4]  (节点1~5的进入时间)
out = [_, 5, 4, 5, 3, 4]  (节点1~5的退出时间)

节点2的子树 = [in[2], out[2]] = [2, 4] = {节点2, 4, 5} ✓

关键性质: 节点 u 的子树 = 欧拉序数组中下标 [in[u], out[u]] 的连续区间。

💡 直觉理解: 想象你在树上走了一圈又回到起点。你进入一个房间时记录「进入时间」,离开时记录「退出时间」。一个房间的子树(所有它内部的房间)恰好在你进入和离开它之间的这段时间被访问到——所以它们在时间线上形成一个连续区间。

📄 查看代码:欧拉序完整实现
#include <bits/stdc++.h>
using namespace std;

const int MAXN = 100005;
vector<int> children[MAXN];
int val[MAXN];
int in_time[MAXN], out_time[MAXN], timer_val = 0;
int euler_arr[MAXN];   // euler_arr[in_time[u]] = val[u]

void dfs_euler(int u, int parent) {
    in_time[u] = ++timer_val;       // 进入:记录时间戳
    euler_arr[timer_val] = val[u];  // 在展平数组中记录值
    
    for (int v : children[u]) {
        if (v != parent) dfs_euler(v, u);
    }
    
    out_time[u] = timer_val;        // 退出:记录最终时间戳
}

// 查询节点 u 的子树中所有值的和
// 用前缀和数组 prefix 预处理 euler_arr
int subtree_sum(int u, int prefix[]) {
    return prefix[out_time[u]] - prefix[in_time[u] - 1];
}

int main() {
    int n; cin >> n;
    for (int i = 0; i < n - 1; i++) {
        int u, v; cin >> u >> v;
        children[u].push_back(v);
        children[v].push_back(u);
    }
    for (int i = 1; i <= n; i++) cin >> val[i];
    
    dfs_euler(1, -1);  // 从根 1 开始
    
    // 构建前缀和
    int prefix[MAXN] = {};
    for (int i = 1; i <= n; i++)
        prefix[i] = prefix[i-1] + euler_arr[i];
    
    // 查询节点 u 的子树和
    int u; cin >> u;
    cout << subtree_sum(u, prefix) << "\n";
    return 0;
}

实际应用:子树更新 + 子树查询

需求工具替换欧拉序后的复杂度
静态子树求和前缀和O(1) 查询
动态单点修改 + 子树求和树状数组(BIT)O(log N)
区间修改 + 子树查询线段树(懒惰传播)O(log N)

5.5.10 USACO 风格入门例题

题目:「奶牛家族树」(USACO Bronze 风格)

题目说明:

FJ 有 N 头奶牛,编号 1 到 N。奶牛 1 是所有奶牛的祖先(「根」)。对每头奶牛 i(2 ≤ i ≤ N),其父节点是 parent[i]。奶牛的深度定义为从根(奶牛 1)到该奶牛的边数(奶牛 1 的深度为 0)。

给定树和 M 次查询,每次查询「奶牛 x 的深度是多少?」

📄 给定树和 M 次查询,每次查询「奶牛 x 的深度是多少?」
// 奶牛家族树 — 深度查询
#include <bits/stdc++.h>
using namespace std;

const int MAXN = 100005;
vector<int> children[MAXN];  // 邻接表:children[i] = i 的子节点列表
int depth[MAXN];             // depth[i] = 节点 i 的深度

// DFS 计算深度
void dfs(int node, int d) {
    depth[node] = d;
    for (int child : children[node]) {
        dfs(child, d + 1);  // 子节点深度 +1
    }
}

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

    int n, m;
    cin >> n >> m;

    for (int i = 2; i <= n; i++) {
        int par;
        cin >> par;
        children[par].push_back(i);  // par 是 i 的父节点
    }

    dfs(1, 0);  // 从根(奶牛 1)以深度 0 开始 DFS

    while (m--) {
        int x;
        cin >> x;
        cout << depth[x] << "\n";
    }

    return 0;
}
// 时间:O(N + M)
// 空间:O(N)

⚠️ 常见错误

空指针崩溃:

📄 C++ 完整代码
// ❌ 错误:没有空指针检查!
void inorder(TreeNode* root) {
    inorder(root->left);  // root 为空时崩溃
    cout << root->val;
    inorder(root->right);
}

// ✅ 正确:始终先检查空指针
void inorder(TreeNode* root) {
    if (root == nullptr) return;  // ← 关键!
    inorder(root->left);
    cout << root->val;
    inorder(root->right);
}

大输入时的栈溢出:

📄 C++ 完整代码
// ❌ 危险:10^5 个节点的退化树(倾斜)
// 递归深度 = 10^5,默认栈 ~8MB,约 10^4~10^5 时溢出!

// ✅ 安全:大树用迭代
void dfsIterative(TreeNode* root) {
    stack<TreeNode*> stk;
    if (root) stk.push(root);
    while (!stk.empty()) {
        TreeNode* node = stk.top(); stk.pop();
        process(node);
        if (node->right) stk.push(node->right);
        if (node->left)  stk.push(node->left);
    }
}

5 大 BST/树 Bug

  1. 忘记 nullptr 基础情况 —— 立即导致段错误
  2. 插入/删除后没有返回(可能是新的)根节点 —— 树结构损坏
  3. 栈溢出 —— N > 10^5 时用迭代遍历
  4. 内存泄漏 —— 始终 delete 删除的节点(或用智能指针)
  5. STL set 够用时却手写 BST —— 竞赛中直接用 std::set

本章总结

📌 核心要点

概念要点时间复杂度
BST 搜索根据比较结果向左/向右走平均 O(log N),最坏 O(N)
BST 插入找到正确位置,在空处插入平均 O(log N)
BST 删除3 种情况:叶节点、单子节点、双子节点平均 O(log N)
中序左 → 根 → 右O(N)
前序根 → 左 → 右O(N)
后序左 → 右 → 根O(N)
层序BFS 按层O(N)
高度max(左高, 右高) + 1O(N)
LCA(暴力)找路径再比较每次查询 O(N)
LCA(二进制倍增)预处理 2^k 祖先预处理 O(N log N),查询 O(log N)
欧拉序DFS 时间戳展平树预处理 O(N),子树查询 O(1)~O(log N)

🧩 从浅到深的完整脉络

  1. 二叉树基础:先理解根、父子、叶子、深度、高度。
  2. 遍历:学会前序、中序、后序、层序,这是所有树算法的基本动作。
  3. 高度/平衡/计数:用递归合并子树答案。
  4. BST:给二叉树加上排序规则,得到高效搜索结构。
  5. LCA:从两个节点向上找最近公共祖先,暴力法先建立直觉。
  6. 二进制倍增:把一步步爬祖先优化成按 2 的幂跳跃。
  7. 欧拉序:把树的子树变成数组区间,连接线段树和树状数组。

❓ 常见问题

Q1:什么时候用 BST vs std::set?

A:竞赛编程中几乎始终用 std::setstd::set 由红黑树(平衡 BST)支持,保证 O(log N);手写 BST 可能退化到 O(N)。只在需要自定义 BST 行为时(如追踪子树大小来查询「第 K 大」)才考虑手写,或使用 __gnu_pbds::tree(策略树)。

Q2:线段树和 BST 是什么关系?

A:线段树(第 5.7 章)是完全二叉树,但不是 BST——节点存储区间聚合值(如区间和),而不是有序的键。两者都是二叉树,结构相似,但目的完全不同。理解 BST 的指针/递归操作使线段树代码更容易理解。

Q3:前序/中序/后序遍历,竞赛中最常用哪种?

A:中序最重要——它输出 BST 的有序序列。后序常用于树形 DP(先处理子节点再处理父节点)。**层序(BFS)**用于按层处理。前序较少用,但对树的序列化/反序列化有用。

Q4:递归和迭代实现哪个更好?

A:递归代码简洁易懂(竞赛中首选)。但 N ≥ 10^5 且树可能退化时,递归有栈溢出风险(默认栈 ~8MB,支持约 10^4~10^5 层)。USACO 题目通常用非退化树,所以递归通常没问题;但不确定时,迭代更安全。

Q5:LCA 在竞赛编程中有多重要?

A:非常重要!LCA 是树形 DP 和路径查询的基础。在 USACO Silver 偶尔出现,USACO Gold 几乎必考。本章 §5.5.5 的暴力 LCA 处理 N ≤ 5000 的情况;§5.5.8 的二进制倍增 LCA 处理 N, Q ≤ 5×10^5 的大型树,是竞赛必备。

🔗 与其他章节的联系

  • 第 2.3 章(函数与数组):递归基础——二叉树遍历是递归的完美应用
  • 第 3.8 章(映射与集合):std::set / std::map 由平衡 BST 支持;理解 BST 能更好地使用它们
  • 第 5.7 章(线段树):线段树是完全二叉树;build/query/update 的递归结构与 BST 遍历完全相同
  • 第 5.2 章(图论算法):树是特殊的无向图(连通无环);所有树算法都是图算法的特例
  • §5.5.8 LCA 倍增 + §5.5.9 欧拉序:直接建立在本章树遍历基础上,是 Gold 级核心技术

基础练习题

题目 5.5.1 — BST 验证 🟢 简单 给定一棵二叉树(不一定是 BST),判断它是否满足 BST 性质。

提示 常见错误:只检查 `root->left->val < root->val` 是不够的。需要向下传递允许的 (minVal, maxVal) 范围。
✅ 完整题解

核心思路: 向下传递允许的 (min, max) 范围,每个节点必须严格在其范围内。

#include <bits/stdc++.h>
using namespace std;
struct TreeNode { int val; TreeNode *left, *right; };

bool isValidBST(TreeNode* root, long long lo, long long hi) {
    if (!root) return true;
    if (root->val <= lo || root->val >= hi) return false;
    return isValidBST(root->left, lo, root->val)
        && isValidBST(root->right, root->val, hi);
}
// 用法:isValidBST(root, LLONG_MIN, LLONG_MAX);

为什么需要最小/最大边界? 因为根的右子树中的某个节点,即使是某个祖先的左子节点,也必须 > 根。只传直接父节点不够。

复杂度: O(N) 时间,O(H) 递归栈。


题目 5.5.2 — BST 中序第 K 小 🟢 简单 找 BST 中第 K 小的元素。

提示 中序遍历按有序访问节点,访问到第 K 个节点时停止。
✅ 完整题解
int kthSmallest(TreeNode* root, int k) {
    stack<TreeNode*> st;
    TreeNode* cur = root;
    while (cur || !st.empty()) {
        while (cur) { st.push(cur); cur = cur->left; }
        cur = st.top(); st.pop();
        if (--k == 0) return cur->val;
        cur = cur->right;
    }
    return -1;
}

复杂度: O(H + K) —— 对小 K 远优于 O(N)。


题目 5.5.3 — 树的直径 🟡 中等 找任意两个节点间的最长路径(不需要经过根)。

提示 对每个节点,经过它的最长路径 = 左高度 + 右高度。单次 DFS:返回高度,同时更新全局直径。
✅ 完整题解

核心思路: 后序 DFS。每个节点计算:(a) 供父节点使用的自身高度;(b) 经过它的最优路径(更新全局答案)。

int diameter = 0;
int height(TreeNode* root) {
    if (!root) return 0;
    int L = height(root->left);
    int R = height(root->right);
    diameter = max(diameter, L + R);  // 经过该节点的路径:左边 L 条边 + 右边 R 条边
    return 1 + max(L, R);              // 返回给父节点的高度
}
// 答案:diameter(边数)。若要节点数,diameter+1。

为什么有效? 直径必然经过某个「顶点」节点——路径上的最高节点。该顶点的贡献 = height(左) + height(右)。我们把每个节点都当作潜在的顶点来访问。

复杂度: O(N)。


题目 5.5.4 — BST 展平/中位数 🟡 中等 给定有 N 个节点的 BST,找奶牛成绩的中位数(第 ⌈N/2⌉ 小的值)。

提示 中序遍历得到有序数组,返回下标 (N-1)/2 处的元素。
✅ 完整题解
void inorder(TreeNode* root, vector<int>& arr) {
    if (!root) return;
    inorder(root->left, arr);
    arr.push_back(root->val);
    inorder(root->right, arr);
}

int findMedian(TreeNode* root) {
    vector<int> arr;
    inorder(root, arr);
    return arr[(arr.size() - 1) / 2];  // 偶数 N 时取下中位数
}

大树优化: 用题目 5.5.2 的第 K 小方法直接查找——无需展平:kthSmallest(root, (n+1)/2),节省 O(N) 内存。

复杂度: O(N) 时间和空间(或用第 K 小方法 O(H + N/2))。


题目 5.5.5 — 最大路径和 🔴 困难 节点可能有负值,找任意两个节点间路径和最大的路径。

提示 对每个节点 v:经过它的最优路径 = max(0, left_max_down) + max(0, right_max_down) + v->val。负值分支夹在 0 处。
✅ 完整题解

核心思路: DFS 返回「从该节点向下出发的最优单侧路径」。全局答案考虑「以该节点为顶点的最优双侧路径」。负值子路径夹在 0 处(不包含它们)。

int bestSum = INT_MIN;
int maxGain(TreeNode* root) {
    if (!root) return 0;
    // 夹到 0:可以选择不包含子树(如果它是负的)
    int L = max(0, maxGain(root->left));
    int R = max(0, maxGain(root->right));

    // 以 root 为转折点的最优路径
    bestSum = max(bestSum, root->val + L + R);

    // 向父节点返回单侧路径(只能选一个分支)
    return root->val + max(L, R);
}
// 答案:调用 maxGain(root) 后的 bestSum

关键思路: 路径是「V」形——先上到某个顶点,再下来。每个节点恰好作为顶点考虑一次。

复杂度: O(N)。



综合补充练习题

题目 5.5.1 — 子树求和(通用树) 🟢 简单

题目: 读取一棵有根树(根 = 节点 1,N 个节点),每个节点有一个值。输出每个节点子树(包含自身)的值之和。

样例:

输入:5 个节点,值=[1,2,3,4,5],父节点数组=[_, 1,1,2,2]
输出:15 11 3 4 5
(节点1子树和=1+2+3+4+5=15;节点2子树=2+4+5=11;...)
✅ 完整题解

思路: 后序 DFS,从叶节点向上累加。

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

int main() {
    ios_base::sync_with_stdio(false); cin.tie(NULL);
    int n; cin >> n;
    vector<long long> val(n + 1);
    for (int i = 1; i <= n; i++) cin >> val[i];
    
    vector<vector<int>> children(n + 1);
    for (int i = 2; i <= n; i++) {
        int p; cin >> p;
        children[p].push_back(i);
    }
    
    vector<long long> sub(n + 1);
    function<void(int)> dfs = [&](int u) {
        sub[u] = val[u];
        for (int v : children[u]) { dfs(v); sub[u] += sub[v]; }
    };
    dfs(1);
    
    for (int i = 1; i <= n; i++) cout << sub[i] << " \n"[i==n];
    return 0;
}

复杂度: O(N) 时间与空间。


题目 5.5.2 — 树的直径(通用树,两次 BFS) 🟡 中等

题目: 给定一棵 N 个节点的无权无向树,求树的直径(任意两点间最长路径的长度)。
注: 题目 5.5.3 只处理二叉树结构。本题处理通用树(每个节点可有任意多个子节点)。

样例:

输入:5
     1 2 / 1 3 / 3 4 / 3 5
输出:3(路径 2-1-3-4 或 2-1-3-5)

思路: 两次 BFS——第一次从任意节点找最远点 u,第二次从 u 出发找直径。

直觉上:树的直径是一条最长的「链」,它的两个端点一定是叶子。从任意点出发,BFS 找到的最远点一定是直径的一个端点;再从这个端点出发,BFS 找到的最远点就是另一个端点。

✅ 完整题解
#include <bits/stdc++.h>
using namespace std;

int n;
vector<int> adj[100005];

pair<int,int> bfs_far(int src) {
    vector<int> dist(n + 1, -1);
    queue<int> q;
    dist[src] = 0; q.push(src);
    int far = src;
    while (!q.empty()) {
        int u = q.front(); q.pop();
        for (int v : adj[u]) {
            if (dist[v] == -1) {
                dist[v] = dist[u] + 1;
                q.push(v);
                if (dist[v] > dist[far]) far = v;
            }
        }
    }
    return {far, dist[far]};
}

int main() {
    ios_base::sync_with_stdio(false); cin.tie(NULL);
    cin >> n;
    for (int i = 0; i < n - 1; i++) {
        int u, v; cin >> u >> v;
        adj[u].push_back(v); adj[v].push_back(u);
    }
    auto [u, _] = bfs_far(1);
    auto [v, d] = bfs_far(u);
    cout << d << "\n";
    return 0;
}

题目 5.5.3 — LCA 查询(二进制倍增) 🟡 中等

题目: 给定有根树(根为 1,N 个节点)和 Q 次查询,每次给出两个节点 u、v,输出它们的 LCA 编号。N, Q ≤ 5×10^5。

✅ 完整题解

直接使用 §5.5.8 的 LCA 二进制倍增实现:

// 见 §5.5.8 完整实现(dfs 预处理 + lca 查询函数)
// main() 中:
int n, q; cin >> n >> q;
// 读入树,dfs(1, 1, 0),然后 q 次查询 lca(u, v)

追踪(树:1-2-3-4 链,查询 lca(4,1)):

depth = [_, 0, 1, 2, 3]
anc[4][0]=3, anc[4][1]=1(3的父的父), anc[3][0]=2, ...

lca(4, 1): depth[4]=3 > depth[1]=0
  diff=3=0b11, k=0时(diff>>0)&1=1, u=anc[4][0]=3
  k=1时(diff>>1)&1=1, u=anc[3][1]=1
  现在 depth[1]=depth[1]=0, u==v=1, 返回 1 ✓

题目 5.5.4 — 欧拉序子树求和(静态) 🟡 中等

题目: N 个节点的有根树,每个节点有一个值。Q 次查询,每次询问以节点 u 为根的子树中所有值之和。

✅ 完整题解

思路: 构建欧拉序后,用前缀和数组 O(1) 回答每次查询。

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

const int MAXN = 100005;
vector<int> adj[MAXN];
long long val[MAXN];
int in_t[MAXN], out_t[MAXN], timer_v = 0;
long long ea[MAXN];  // 欧拉序展平后的值数组

void dfs(int u, int par) {
    in_t[u] = ++timer_v;
    ea[timer_v] = val[u];
    for (int v : adj[u])
        if (v != par) dfs(v, u);
    out_t[u] = timer_v;
}

int main() {
    ios_base::sync_with_stdio(false); cin.tie(NULL);
    int n, q; cin >> n;
    for (int i = 1; i <= n; i++) cin >> val[i];
    for (int i = 0; i < n - 1; i++) {
        int u, v; cin >> u >> v;
        adj[u].push_back(v); adj[v].push_back(u);
    }
    cin >> q;
    
    dfs(1, -1);
    
    // 前缀和
    long long prefix[MAXN] = {};
    for (int i = 1; i <= n; i++) prefix[i] = prefix[i-1] + ea[i];
    
    while (q--) {
        int u; cin >> u;
        cout << prefix[out_t[u]] - prefix[in_t[u]-1] << "\n";
    }
    return 0;
}

为什么正确? 欧拉序保证 u 的子树节点恰好占据 [in_t[u], out_t[u]] 区间,前缀和 O(1) 回答区间和。


题目 5.5.5 — 最小生成树(Kruskal) 🔴 困难

题目: 读取 N 个节点 M 条边的带权无向图,求最小生成树权重之和(若不连通输出 IMPOSSIBLE)。

✅ 完整题解

用 Kruskal 算法(并查集详见第 5.6 章):

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

vector<int> par, rnk;
int find(int x) { return par[x]==x ? x : par[x]=find(par[x]); }
bool unite(int x, int y) {
    x=find(x); y=find(y);
    if (x==y) return false;
    if (rnk[x]<rnk[y]) swap(x,y);
    par[y]=x; if(rnk[x]==rnk[y]) rnk[x]++;
    return true;
}

int main() {
    int n, m; cin >> n >> m;
    par.resize(n+1); rnk.assign(n+1,0);
    iota(par.begin(),par.end(),0);
    
    vector<tuple<int,int,int>> edges(m);
    for (auto& [w,u,v] : edges) cin >> u >> v >> w;
    sort(edges.begin(), edges.end());
    
    long long ans = 0; int cnt = 0;
    for (auto [w,u,v] : edges)
        if (unite(u,v)) { ans+=w; if(++cnt==n-1) break; }
    
    cout << (cnt==n-1 ? to_string(ans) : "IMPOSSIBLE") << "\n";
    return 0;
}

第 5.5 章(树算法完整版)结束 — 下一章:第 5.6 章:并查集

📖 第 5.6 章:并查集

⏱ 预计阅读时间:60 分钟 | 难度:🟡 中等


前置条件

在学习本章之前,请确保你已掌握:

  • 数组与函数(第 2.3 章)
  • 图的基本概念——节点、边、连通(第 5.1 章)

🎯 学习目标

学完本章后,你将能够:

  1. 用「路径压缩 + 按秩合并」实现 O(α(n)) 的并查集
  2. 用并查集判断图的连通性和环
  3. 实现带权并查集解决差值/关系问题
  4. 用种类并查集解决多关系分组问题
  5. 独立完成 10 道从基础到挑战的练习题

5.6.1 从一道真实问题出发

问题:网络连通

你负责管理一个由 N 台服务器(编号 1~N)构成的数据中心网络。网络工程师陆续在两台服务器之间建立直连链路(无向),你需要随时回答:服务器 A 和服务器 B 目前是否可以互相通信?

初始状态:每台服务器各自孤立
                1    2    3    4    5

建立链路 (1,2):1——2    3    4    5
建立链路 (3,4):1——2    3——4    5
建立链路 (2,3):1——2——3——4    5

查询 (1,4):可以通信 ✓(1→2→3→4)
查询 (1,5):不可通信 ✗(5 是孤岛)

朴素解法的瓶颈:

做法查询时间合并时间
暴力 BFS/DFSO(N+M)O(1)
维护 group[] 数组O(1)O(N)(需遍历更新)
并查集O(α(N)) ≈ O(1)O(α(N)) ≈ O(1)

当 N、M 高达 10^5,且操作交替出现时,并查集是唯一实用的选择。


5.6.2 核心思想:用「树」表示一个集合

关键洞察: 把同一个连通块里的服务器,组织成一棵树。树的根节点作为这个集合的「代表」。

  • 判断两台服务器是否连通:看它们是否在同一棵树(根节点相同)
  • 合并两个连通块:把一棵树的根,指向另一棵树的根

用一个 pa[](parent,父节点)数组来表示这片森林:

并查集森林构建过程:三次 unite 后的结构

关键观察:

  • 每一次 unite 都是把一棵树的根指向另一棵树的根,而不是任意两个节点直接相连
  • unite(2, 3) 实际执行的是 pa[find(3)] ← find(2),即 pa[3] ← 1,所以合并后 4 仍然挂在 3 下(而不是直接挂到 1 下)
  • 要让 4 直接挂到根 1 下,需要借助 5.6.4 节的路径压缩

5.6.3 两个核心操作

Find(查找根节点)

沿着 pa[] 指针向上爬,找到根:

int find(int x) {
    while (pa[x] != x)
        x = pa[x];
    return x;  // pa[x] == x 时 x 就是根
}

判断 A、B 是否连通:find(A) == find(B)

Unite(合并两个集合)

将两棵树的根连起来:

void unite(int x, int y) {
    int rx = find(x);
    int ry = find(y);
    if (rx != ry)
        pa[rx] = ry;  // 把 rx 的树接到 ry 下面
}

5.6.4 优化一:路径压缩

问题: 若一直把新树接到旧树下面,树可能变成一条长链:

1 ← 2 ← 3 ← 4 ← 5 ← 6

find(1) 需要爬 5 步,时间 O(N)

路径压缩:find(x) 的过程中,把路径上所有节点直接连到根节点

并查集路径压缩前后对比

int find(int x) {
    // 若 x 不是根,先递归找到根 root
    // 然后顺手把 x 的父亲改为 root("压扁")
    return pa[x] == x ? x : pa[x] = find(pa[x]);
}

5.6.5 优化二:按节点数合并

问题: 合并时如果总把大树接到小树下,大树变得更高,find 更慢。

按节点数合并:小树接到大树下,保证树高 ≤ O(log N)。

按节点数合并:小树接大树

📄 C++ 完整代码
struct DSU {
    vector<int> pa, sz;   // sz[i] = i 为根时,这棵树的节点总数
    int groups;           // 当前连通块数量
    
    explicit DSU(int n) : pa(n + 1), sz(n + 1, 1), groups(n) {
        iota(pa.begin(), pa.end(), 0);  // pa[i] = i
    }
    
    // 路径压缩查根
    int find(int x) {
        return pa[x] == x ? x : pa[x] = find(pa[x]);
    }
    
    // 按节点数合并,返回 true 表示两者原本不连通(发生了合并)
    bool unite(int x, int y) {
        x = find(x); y = find(y);
        if (x == y) return false;       // 已在同一集合
        if (sz[x] < sz[y]) swap(x, y); // x 是大树
        pa[y] = x;                      // 小树接到大树
        sz[x] += sz[y];
        groups--;
        return true;
    }
    
    // 判断是否连通
    bool connected(int x, int y) { return find(x) == find(y); }
    
    // 所在连通块大小
    int size(int x) { return sz[find(x)]; }
};

复杂度:

优化单次操作
无优化O(N)
仅路径压缩均摊 O(α(N))
仅按节点数O(log N)
两者同时(推荐)均摊 O(α(N)) ≈ O(1)

α(N) 是反 Ackermann 函数,增长极其缓慢,α(10^80) < 5。实践中视为常数。


5.6.6 回到网络连通问题:完整代码

现在用并查集完整解决开头的问题:

📄 现在用并查集完整解决开头的问题:
#include <bits/stdc++.h>
using namespace std;

struct DSU {
    vector<int> pa, sz;
    int groups;
    explicit DSU(int n) : pa(n + 1), sz(n + 1, 1), groups(n) {
        iota(pa.begin(), pa.end(), 0);
    }
    int find(int x) { return pa[x] == x ? x : pa[x] = find(pa[x]); }
    bool unite(int x, int y) {
        x = find(x); y = find(y);
        if (x == y) return false;
        if (sz[x] < sz[y]) swap(x, y);
        pa[y] = x; sz[x] += sz[y]; groups--;
        return true;
    }
    bool connected(int x, int y) { return find(x) == find(y); }
};

int main() {
    int n, q;
    cin >> n >> q;
    DSU dsu(n);
    
    while (q--) {
        int op, a, b;
        cin >> op >> a >> b;
        if (op == 1) {
            // 建立链路
            if (dsu.unite(a, b))
                cout << "新增链路:" << a << " - " << b << "\n";
            else
                cout << "已连通,无需新增\n";
        } else {
            // 查询
            cout << (dsu.connected(a, b) ? "可以通信" : "无法通信") << "\n";
        }
    }
    return 0;
}

示例追踪:

输入:5 6
      1 1 2    → 建立链路 1-2,new(原本不通)
      1 3 4    → 建立链路 3-4,new
      1 2 3    → 建立链路 2-3,new
      2 1 4    → 查询 1 和 4 → 可以通信(1→2→3→4)
      2 1 5    → 查询 1 和 5 → 无法通信(5 是孤岛)
      1 1 4    → 建立链路 1-4,已连通(无需新增)

5.6.7 进阶:带权并查集

问题引入

有 N 名学生,班主任陆续告诉你:「B 同学比 A 同学高 D 厘米(即 height[B] - height[A] = D)」。

你需要回答:

  1. B 比 A 高多少厘米?
  2. 某条信息是否与之前的矛盾?

朴素思路: 用图建模,但查询每次都要 BFS 遍历路径,O(N) 每次查询太慢。

带权并查集的思路: 在每个节点存储「它到根节点的高度差 dist[x]」,查询时直接用 dist 相减。

带权并查集 dist[] 示意图

核心设计

  • dist[x] = height[x] - height[find(x)](x 的身高减去根的身高)
  • 路径压缩时,累加路径上的 dist,把 x 直接连到根:
压缩前:x → p → root
  dist[x] = height[x] - height[p]
  dist[p] = height[p] - height[root]

压缩后:x → root
  新的 dist[x] 应该 = height[x] - height[root]
                    = dist[x] + dist[p]
int find(int x) {
    if (pa[x] == x) return x;
    int root = find(pa[x]);
    dist[x] += dist[pa[x]];  // 压缩同时累加路径权值
    pa[x] = root;
    return root;
}

合并时计算新边权值

「声明 height[y] - height[x] = d」:

📄 「声明 height[y] - height[x] = d」:
我们有:dist[x] = height[x] - height[rx]
        dist[y] = height[y] - height[ry]

若把 ry 接到 rx 下,则需要 dist[ry] 满足:
    height[ry] - height[rx] = ?
    
由 height[y] - height[x] = d 推导:
    (dist[y] + height[ry]) - (dist[x] + height[rx]) = d
    height[ry] - height[rx] = d + dist[x] - dist[y]

所以 dist[ry] = d + dist[x] - dist[y]
📄 C++ 完整代码
// 完整带权并查集
#include <bits/stdc++.h>
using namespace std;

const int MAXN = 100005;
int pa[MAXN], sz[MAXN];
long long dist[MAXN];  // dist[x] = height[x] - height[find(x)]

void init(int n) {
    for (int i = 1; i <= n; i++) { pa[i] = i; sz[i] = 1; dist[i] = 0; }
}

int find(int x) {
    if (pa[x] == x) return x;
    int root = find(pa[x]);
    dist[x] += dist[pa[x]];
    pa[x] = root;
    return root;
}

// 声明 height[y] - height[x] = d
// 返回 true = 无矛盾;false = 与已知矛盾
bool unite(int x, int y, long long d) {
    int rx = find(x), ry = find(y);
    long long dx = dist[x], dy = dist[y];
    
    if (rx == ry) {
        // 已在同集合,验证是否矛盾
        return (dy - dx == d);
    }
    
    // 合并:小树接大树
    if (sz[rx] < sz[ry]) {
        swap(rx, ry); swap(dx, dy); d = -d;
    }
    pa[ry] = rx;
    dist[ry] = d + dx - dy;
    sz[rx] += sz[ry];
    return true;
}

long long query(int x, int y) {
    find(x); find(y);
    return dist[y] - dist[x];  // height[y] - height[x]
}

int main() {
    int n = 5;
    init(n);
    
    unite(1, 2, 3);   // height[2] - height[1] = 3(2 比 1 高 3cm)
    unite(2, 3, 5);   // height[3] - height[2] = 5
    
    // 查询 1 和 3 的差
    cout << "3 比 1 高 " << query(1, 3) << " cm\n";  // 输出 8
    
    // 添加矛盾信息
    cout << (unite(1, 3, 10) ? "一致" : "矛盾") << "\n";  // 矛盾(应该是8)
    cout << (unite(1, 3, 8)  ? "一致" : "矛盾") << "\n";  // 一致
    
    return 0;
}

5.6.8 进阶:种类并查集

问题引入

经典题: 动物王国中有三类动物 A、B、C,满足:A 吃 B,B 吃 C,C 吃 A。

依次输入 N 条信息,格式为:

  • 1 X Y:X 和 Y 是同类
  • 2 X Y:X 吃 Y

若某条信息与前面的所有真实信息矛盾,则它是「假话」。求假话总数。

关键难点: 需要同时追踪「是否同类」「是否捕食关系」两种关系,普通并查集只能处理一种等价关系。

种类并查集:动物三角关系

解法:把每个节点拆成三份

将每个动物 x 扩展为三个虚拟节点:

节点含义
x(原始)与 x 同类的集合
x + n被 x 吃的集合(x 的猎物)
x + 2n吃 x 的集合(x 的天敌)

处理「X 和 Y 同类」:

x 的同类 = y 的同类    → unite(x, y)
x 的猎物 = y 的猎物    → unite(x+n, y+n)
x 的天敌 = y 的天敌    → unite(x+2n, y+2n)

处理「X 吃 Y」(X 的猎物就是 Y 的同类):

x 的猎物 = y 的同类    → unite(x+n, y)
x 的天敌 = y 的猎物    → unite(x+2n, y+n)
x 的同类 = y 的天敌    → unite(x, y+2n)

判断矛盾「X 和 Y 同类」时:

若 connected(x, y+n) → 矛盾(x 和 y 一个吃另一个,不可能同类)
若 connected(x, y+2n) → 矛盾

判断矛盾「X 吃 Y」时:

若 connected(x, y) → 矛盾(同类不可能有捕食关系)
若 connected(x, y+n) → 矛盾(y 吃 x,但说 x 吃 y)
📄 C++ 完整代码
#include <bits/stdc++.h>
using namespace std;

const int MAXN = 150005;
int pa[MAXN * 3], sz[MAXN * 3];

void init(int n) {
    for (int i = 0; i < 3 * (n + 1); i++) { pa[i] = i; sz[i] = 1; }
}
int find(int x) { return pa[x] == x ? x : pa[x] = find(pa[x]); }
bool same(int x, int y) { return find(x) == find(y); }
void unite(int x, int y) {
    x = find(x); y = find(y);
    if (x == y) return;
    if (sz[x] < sz[y]) swap(x, y);
    pa[y] = x; sz[x] += sz[y];
}

int main() {
    int n, k;
    cin >> n >> k;
    init(n);
    
    int lies = 0;
    while (k--) {
        int t, x, y;
        cin >> t >> x >> y;
        
        // 编号越界直接判假
        if (x < 1 || x > n || y < 1 || y > n) { lies++; continue; }
        
        if (t == 1) {
            // 声明 x 和 y 同类
            if (same(x, y + n) || same(x, y + 2 * n)) {
                lies++;  // 矛盾:x 和 y 之间有捕食关系
            } else {
                unite(x, y);
                unite(x + n, y + n);
                unite(x + 2 * n, y + 2 * n);
            }
        } else {
            // 声明 x 吃 y
            if (x == y) { lies++; continue; }  // 自己不能吃自己
            if (same(x, y) || same(x, y + n)) {
                lies++;  // 矛盾
            } else {
                unite(x + n, y);
                unite(x + 2 * n, y + n);
                unite(x, y + 2 * n);
            }
        }
    }
    
    cout << lies << "\n";
    return 0;
}

5.6.9 USACO 真题训练:连通性问题的两种反向思维

并查集擅长处理「只加边」的连通性。一旦题目出现删点/删边,通常要考虑离线反向处理:把删除倒过来看成添加。

题面信号DSU 思路关键转换
动态添加边后问连通直接 unite每次合并维护连通块数
按顺序关闭节点反向打开节点删除变添加
最大化最小边权使排列可达二分答案 + DSU只保留权值 ≥ x 的边
判断两个点是否在同一集合find 比较根find(a) == find(b)

真题 1:Closing the Farm(USACO 2016 US Open Silver)— 反向开点维护连通性

题目链接: USACO 2016 US Open Silver P3: Closing the Farm
对应模式: 离线反向处理 + 并查集
难度定位: Silver 标准

题干解读

N 个农场和 M 条双向道路。农场会按给定顺序一个个关闭,每关闭一个后,问剩余开放农场是否仍然连通。

关键难点:

  • 正向看是「删点 + 删边」,普通 DSU 不支持删除。
  • 但如果倒着看,就是「逐个打开农场 + 添加边」,DSU 正好擅长。

思路分析

反向处理关闭顺序:

  1. 一开始所有农场都关闭。
  2. 按关闭顺序的反序逐个打开农场。
  3. 每打开一个农场,连通块数 components++
  4. 与所有已经打开的邻居 unite,每成功合并一次 components--
  5. 若当前开放农场数量大于 0 且 components == 1,说明所有开放农场连通。

CPP 完整代码

✅ 完整代码:Closing the Farm
#include <bits/stdc++.h>
using namespace std;

struct DSU {
    vector<int> parent, size;
    DSU(int n) : parent(n + 1), size(n + 1, 1) {
        iota(parent.begin(), parent.end(), 0);
    }
    int find(int x) {
        return parent[x] == x ? x : parent[x] = find(parent[x]);
    }
    bool unite(int a, int b) {
        a = find(a);
        b = find(b);
        if (a == b) return false;
        if (size[a] < size[b]) swap(a, b);
        parent[b] = a;
        size[a] += size[b];
        return true;
    }
};

int main() {
    ios::sync_with_stdio(false);
    cin.tie(nullptr);

    // USACO 官方文件 I/O 可按需打开:
    // freopen("closing.in", "r", stdin);
    // freopen("closing.out", "w", stdout);

    int n, m;
    cin >> n >> m;

    vector<vector<int>> adj(n + 1);
    for (int i = 0; i < m; i++) {
        int a, b;
        cin >> a >> b;
        adj[a].push_back(b);
        adj[b].push_back(a);
    }

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

    DSU dsu(n);
    vector<bool> open(n + 1, false);
    vector<string> answer(n);
    int components = 0;

    for (int i = n - 1; i >= 0; i--) {
        int barn = closeOrder[i];
        open[barn] = true;
        components++;

        for (int neighbor : adj[barn]) {
            if (open[neighbor] && dsu.unite(barn, neighbor)) {
                components--;
            }
        }

        answer[i] = (components == 1 ? "YES" : "NO");
    }

    for (const string &s : answer) cout << s << "\n";
    return 0;
}

复杂度: 每条边最多被检查两次,DSU 操作近似 O(1),总复杂度 O((N+M) α(N))

易错点提醒

  1. 正向删除硬做。 DSU 不支持删除,正向维护会非常麻烦。
  2. 和未打开邻居合并。 只能合并 open[neighbor] == true 的邻居。
  3. 答案顺序反了。 反向计算得到的是原问题每一步的答案,要写回 answer[i]
  4. 连通块计数忘记加一。 每打开一个新点,先形成一个新连通块。

拓展思考

这类「删除变添加」是 USACO Silver/Gold 常见技巧。如果操作可以完全离线读取,并且删除操作很难维护,优先尝试倒序处理。


真题 2:Wormhole Sort(USACO 2020 January Silver)— 二分答案 + DSU 连通块

题目链接: USACO 2020 January Silver P3: Wormhole Sort
对应模式: 二分答案 + 按边权建 DSU
难度定位: Silver 进阶

题干解读

N 头奶牛,每头当前位置上有一头目标不一定正确的奶牛。虫洞连接两个位置,每条虫洞有宽度。两头奶牛可以通过虫洞交换位置。要求找到最大宽度 W,使得只使用宽度至少为 W 的虫洞,也能把所有奶牛放回正确位置。

关键条件:

  • 若宽度阈值 W 可行,那么更小的阈值也可行,因为可用边更多。
  • 给定阈值 W,只保留宽度 ≥ W 的边,用 DSU 判断每头奶牛当前位置和目标位置是否连通。

思路分析

check(W)

  1. 初始化 DSU。
  2. 对每条虫洞 (a,b,width),若 width >= W,合并 ab
  3. 对每个位置 i,如果当前位置上的奶牛应该去 p[i],则必须满足 find(i) == find(p[i])
  4. 全部满足则可行。

然后对 W 二分,找最大的可行宽度。

CPP 完整代码

✅ 完整代码:Wormhole Sort
#include <bits/stdc++.h>
using namespace std;

struct DSU {
    vector<int> parent, size;
    DSU(int n) : parent(n + 1), size(n + 1, 1) {
        iota(parent.begin(), parent.end(), 0);
    }
    int find(int x) {
        return parent[x] == x ? x : parent[x] = find(parent[x]);
    }
    void unite(int a, int b) {
        a = find(a);
        b = find(b);
        if (a == b) return;
        if (size[a] < size[b]) swap(a, b);
        parent[b] = a;
        size[a] += size[b];
    }
};

struct Edge {
    int a, b, width;
};

int main() {
    ios::sync_with_stdio(false);
    cin.tie(nullptr);

    // USACO 官方文件 I/O 可按需打开:
    // freopen("wormsort.in", "r", stdin);
    // freopen("wormsort.out", "w", stdout);

    int n, m;
    cin >> n >> m;

    vector<int> target(n + 1);
    bool alreadySorted = true;
    for (int i = 1; i <= n; i++) {
        cin >> target[i];
        if (target[i] != i) alreadySorted = false;
    }

    vector<Edge> edges(m);
    int maxWidth = 0;
    for (auto &e : edges) {
        cin >> e.a >> e.b >> e.width;
        maxWidth = max(maxWidth, e.width);
    }

    if (alreadySorted) {
        cout << -1 << "\n";
        return 0;
    }

    auto can = [&](int minWidth) {
        DSU dsu(n);
        for (const Edge &e : edges) {
            if (e.width >= minWidth) dsu.unite(e.a, e.b);
        }
        for (int i = 1; i <= n; i++) {
            if (dsu.find(i) != dsu.find(target[i])) return false;
        }
        return true;
    };

    int lo = 1, hi = maxWidth + 1;  // [可行, 不可行)
    while (lo + 1 < hi) {
        int mid = lo + (hi - lo) / 2;
        if (can(mid)) lo = mid;
        else hi = mid;
    }

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

复杂度: 每次检查 O((N+M) α(N)),二分 O(log W) 次,总复杂度 O((N+M) log W)

易错点提醒

  1. 方向理解错。 阈值越小,可用虫洞越多,所以可行性是「大到小」单调。
  2. 检查位置错。 要判断位置 itarget[i] 是否连通,不是判断奶牛编号和位置编号的其他组合。
  3. 已经有序的特殊情况。 官方要求输出 -1
  4. 边权等于阈值可用。 条件是 width >= minWidth,不要写成 >

拓展思考

也可以把边按宽度从大到小排序,逐步加入 DSU,直到所有错位奶牛都连通;这能避免二分的重复建图,但实现需要维护未满足位置集合。


⚠️ 常见错误

错误原因修复方案
带权并查集路径压缩后权值错误没在递归中累加 distdist[x] += dist[pa[x]] 在改 pa[x] 前执行
合并时没更新 sz只改了 pa,忘记维护大小sz[rx] += sz[ry]
种类并查集数组开小需要 3N 个节点int pa[MAXN * 3]
先 unite 再判矛盾合并后信息已融合,无法判断必须先判矛盾,再 unite
find 递归爆栈极长链时(N > 10^5)递归深度溢出用迭代版路径压缩替代

💪 练习题(共 10 道,全部含完整解答)

🟢 基础练习(1~4)

题目 1:朋友圈计数
给 N 个人(编号 1~N)和 M 对朋友关系,同一朋友圈里的人可以互相联系。
求最终共有多少个朋友圈。

输入: N M,然后 M 行每行 A B 表示 A 和 B 是朋友。
输出: 朋友圈数量。

示例:

输入:5 3
     1 2
     2 3
     4 5

输出:2
({1,2,3} 和 {4,5})
✅ 完整解答

思路: 每次合并时若两人原本不在同一集合(unite 返回 true),连通块数 -1。最终 dsu.groups 就是答案。

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

struct DSU {
    vector<int> pa, sz;
    int groups;
    explicit DSU(int n) : pa(n + 1), sz(n + 1, 1), groups(n) {
        iota(pa.begin(), pa.end(), 0);
    }
    int find(int x) { return pa[x] == x ? x : pa[x] = find(pa[x]); }
    bool unite(int x, int y) {
        x = find(x); y = find(y);
        if (x == y) return false;
        if (sz[x] < sz[y]) swap(x, y);
        pa[y] = x; sz[x] += sz[y]; groups--;
        return true;
    }
};

int main() {
    ios_base::sync_with_stdio(false); cin.tie(NULL);
    int n, m;
    cin >> n >> m;
    DSU dsu(n);
    while (m--) {
        int a, b; cin >> a >> b;
        dsu.unite(a, b);
    }
    cout << dsu.groups << "\n";
    return 0;
}

追踪(N=5, M=3):

初始 groups = 5
unite(1,2) → 不同集合,groups = 4,pa[2]=1
unite(2,3) → 不同集合,groups = 3,pa[3]=1
unite(4,5) → 不同集合,groups = 2,pa[5]=4
输出:2 ✓

题目 2:判断图中是否有环
给 N 个节点和 M 条无向边,判断这个图是否包含环。

输入: N M,然后 M 行每行 U V 表示一条边。
输出: YES(有环)或 NO(无环)。

✅ 完整解答

思路: 加一条边 (u, v) 时,若 u 和 v 已经连通(find(u)==find(v)),则这条边构成环。

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

struct DSU {
    vector<int> pa, sz;
    explicit DSU(int n) : pa(n + 1), sz(n + 1, 1) {
        iota(pa.begin(), pa.end(), 0);
    }
    int find(int x) { return pa[x] == x ? x : pa[x] = find(pa[x]); }
    bool unite(int x, int y) {
        x = find(x); y = find(y);
        if (x == y) return false;  // 已连通 = 加边后形成环
        if (sz[x] < sz[y]) swap(x, y);
        pa[y] = x; sz[x] += sz[y];
        return true;
    }
};

int main() {
    ios_base::sync_with_stdio(false); cin.tie(NULL);
    int n, m;
    cin >> n >> m;
    DSU dsu(n);
    bool has_cycle = false;
    while (m--) {
        int u, v; cin >> u >> v;
        if (!dsu.unite(u, v)) has_cycle = true;
    }
    cout << (has_cycle ? "YES" : "NO") << "\n";
    return 0;
}

关键点: unite 返回 false 代表两端已连通 → 这条边是多余边 → 存在环。


题目 3:最大连通块
给 N 个节点和 M 条边,输出最大连通块包含的节点数。

✅ 完整解答

思路: 用带 sz[] 的并查集,合并结束后遍历所有节点,找 sz[find(i)] 的最大值。

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

struct DSU {
    vector<int> pa, sz;
    explicit DSU(int n) : pa(n + 1), sz(n + 1, 1) {
        iota(pa.begin(), pa.end(), 0);
    }
    int find(int x) { return pa[x] == x ? x : pa[x] = find(pa[x]); }
    void unite(int x, int y) {
        x = find(x); y = find(y);
        if (x == y) return;
        if (sz[x] < sz[y]) swap(x, y);
        pa[y] = x; sz[x] += sz[y];
    }
    int size(int x) { return sz[find(x)]; }
};

int main() {
    int n, m;
    cin >> n >> m;
    DSU dsu(n);
    while (m--) {
        int u, v; cin >> u >> v;
        dsu.unite(u, v);
    }
    int ans = 0;
    for (int i = 1; i <= n; i++)
        ans = max(ans, dsu.size(i));
    cout << ans << "\n";
    return 0;
}

题目 4:Kruskal 最小生成树
给 N 个节点和 M 条带权无向边,求最小生成树的总权重。若图不连通输出 -1。

✅ 完整解答

思路: Kruskal 算法:将所有边按权重从小到大排序,依次尝试加入。若两端不在同一集合(不会成环),则加入 MST。最终若 MST 边数 = N-1,则图连通。

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

struct DSU {
    vector<int> pa, sz;
    explicit DSU(int n) : pa(n + 1), sz(n + 1, 1) {
        iota(pa.begin(), pa.end(), 0);
    }
    int find(int x) { return pa[x] == x ? x : pa[x] = find(pa[x]); }
    bool unite(int x, int y) {
        x = find(x); y = find(y);
        if (x == y) return false;
        if (sz[x] < sz[y]) swap(x, y);
        pa[y] = x; sz[x] += sz[y];
        return true;
    }
};

int main() {
    int n, m;
    cin >> n >> m;
    
    vector<tuple<int,int,int>> edges(m);
    for (auto& [w, u, v] : edges) cin >> u >> v >> w;
    sort(edges.begin(), edges.end());  // 按权重排序
    
    DSU dsu(n);
    long long total = 0;
    int cnt = 0;  // MST 中的边数
    
    for (auto& [w, u, v] : edges) {
        if (dsu.unite(u, v)) {
            total += w;
            cnt++;
            if (cnt == n - 1) break;  // 找够 n-1 条边
        }
    }
    
    cout << (cnt == n - 1 ? total : -1) << "\n";
    return 0;
}

追踪示例(N=4, 边:1-2权1, 2-3权2, 1-3权3, 3-4权4):

排序后:(1,1,2), (2,2,3), (3,1,3), (4,3,4)

加边(1,2)权1 → unite成功,cnt=1,total=1
加边(2,3)权2 → unite成功,cnt=2,total=3
加边(1,3)权3 → find(1)=find(3),已连通,跳过
加边(3,4)权4 → unite成功,cnt=3=n-1,total=7

输出:7

🟡 进阶练习(5~8)

题目 5:网络连通性查询
给 N 台服务器,M 次操作:

  • connect A B:在 A 和 B 之间建立链路
  • query A B:询问 A 和 B 是否可以通信
  • block A B:断开 A 和 B 之间的直连链路(注意:不是断开连通性!

输出所有 query 的结果。

提示: 普通并查集不支持「断边」。解决方案:离线倒序处理——将操作逆序执行,把「断边」变成「加边」。

✅ 完整解答

核心思路:

  1. 先记录所有操作
  2. 从后向前处理:block 变成 connect,正向的 connect 但时间上在 block 之前需要排除
  3. 并查集 + 离线逆序处理,输出时逆序输出 query 结果

更简洁的方案:预处理每条边「实际存在的时间段」,再用离线并查集。

以下给出离线逆序 + 回收操作的简化版本(假设每对服务器最多断开一次):

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

struct DSU {
    vector<int> pa, sz;
    explicit DSU(int n) : pa(n + 1), sz(n + 1, 1) {
        iota(pa.begin(), pa.end(), 0);
    }
    int find(int x) { return pa[x] == x ? x : pa[x] = find(pa[x]); }
    bool unite(int x, int y) {
        x = find(x); y = find(y);
        if (x == y) return false;
        if (sz[x] < sz[y]) swap(x, y);
        pa[y] = x; sz[x] += sz[y];
        return true;
    }
    bool connected(int x, int y) { return find(x) == find(y); }
};

int main() {
    int n, m;
    cin >> n >> m;
    
    vector<tuple<int,int,int>> ops(m);  // {type, a, b},type: 0=connect,1=query,2=block
    set<pair<int,int>> blocked;         // 已断开的边
    
    for (auto& [t, a, b] : ops) {
        string op; cin >> op >> a >> b;
        if (op == "connect") t = 0;
        else if (op == "query") t = 1;
        else { t = 2; blocked.insert({min(a,b), max(a,b)}); }
    }
    
    // 逆序处理
    DSU dsu(n);
    // 先加入所有「最终状态下存在」的边(connect 且未被 block 的)
    for (auto& [t, a, b] : ops) {
        if (t == 0) {
            auto key = make_pair(min(a,b), max(a,b));
            if (!blocked.count(key)) dsu.unite(a, b);
        }
    }
    
    vector<string> answers;
    for (int i = m - 1; i >= 0; i--) {
        auto [t, a, b] = ops[i];
        if (t == 2) {
            // 逆序时 block 变 connect
            dsu.unite(a, b);
        } else if (t == 1) {
            answers.push_back(dsu.connected(a, b) ? "YES" : "NO");
        }
        // connect 在逆序中不处理(已在初始化时加入)
    }
    
    reverse(answers.begin(), answers.end());
    for (auto& s : answers) cout << s << "\n";
    return 0;
}

题目 6:身高差查询(带权并查集)
N 名学生,M 条信息。每条信息格式为 A B D 表示「B 比 A 高 D cm(可为负)」。
然后 Q 次查询,每次询问「B 比 A 高多少 cm」,若无法推断输出 unknown,若已知矛盾输出 conflict

✅ 完整解答
#include <bits/stdc++.h>
using namespace std;

const int MAXN = 100005;
int pa[MAXN], sz_arr[MAXN];
long long dist[MAXN];

void init(int n) {
    for (int i = 1; i <= n; i++) { pa[i] = i; sz_arr[i] = 1; dist[i] = 0; }
}

int find(int x) {
    if (pa[x] == x) return x;
    int root = find(pa[x]);
    dist[x] += dist[pa[x]];
    pa[x] = root;
    return root;
}

// 声明 height[b] - height[a] = d,返回 false 表示矛盾
bool add_info(int a, int b, long long d) {
    int ra = find(a), rb = find(b);
    long long da = dist[a], db = dist[b];
    if (ra == rb) return (db - da == d);
    if (sz_arr[ra] < sz_arr[rb]) { swap(ra, rb); swap(da, db); d = -d; }
    pa[rb] = ra;
    dist[rb] = d + da - db;
    sz_arr[ra] += sz_arr[rb];
    return true;
}

int main() {
    int n, m, q;
    cin >> n >> m;
    init(n);
    
    bool global_conflict = false;
    for (int i = 0; i < m; i++) {
        int a, b; long long d;
        cin >> a >> b >> d;
        if (!add_info(a, b, d)) global_conflict = true;
    }
    
    cin >> q;
    while (q--) {
        int a, b; cin >> a >> b;
        int ra = find(a), rb = find(b);
        if (ra != rb) cout << "unknown\n";
        else if (global_conflict) cout << "conflict\n";
        else cout << dist[b] - dist[a] << "\n";
    }
    return 0;
}

输入示例:

5 3
1 2 3    → height[2] - height[1] = 3
2 3 5    → height[3] - height[2] = 5
1 3 8    → height[3] - height[1] = 8(与前两条一致)

2
1 3      → 输出 8
1 4      → 输出 unknown

题目 7:方格染色(种类并查集变形)
N × N 方格,每格初始为白色。M 次操作,每次给某行或某列的所有格子染色(黑←→白 翻转)。
操作结束后,询问 Q 个格子的颜色(黑或白)。

提示: 用「行并查集」和「列并查集」分别维护,结合奇偶性(翻转次数的奇偶)跟踪颜色。

✅ 完整解答(简化版:只处理行翻转)

用带权并查集,dist[x] 记录奇偶性(0=未翻转,1=翻转过奇数次):

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

const int MAXN = 200005;
int pa[MAXN];
int flip[MAXN];  // flip[x] = x 相对于根的翻转次数(mod 2)

void init(int n) {
    for (int i = 0; i <= n; i++) { pa[i] = i; flip[i] = 0; }
}

int find(int x) {
    if (pa[x] == x) return x;
    int root = find(pa[x]);
    flip[x] ^= flip[pa[x]];  // 路径上翻转次数的奇偶叠加
    pa[x] = root;
    return root;
}

// 声明「x 和 y 属于同组,且翻转关系为 d(0=同色,1=不同色)」
void unite(int x, int y, int d) {
    int rx = find(x), ry = find(y);
    int fx = flip[x], fy = flip[y];
    if (rx == ry) return;
    pa[ry] = rx;
    flip[ry] = d ^ fx ^ fy;
}

// 查询 x 的颜色相对于根的偏移
int query(int x) {
    find(x);
    return flip[x];
}

此模板适用于所有「奇偶关系」类型的种类并查集问题。


题目 8:标准并查集模板(Luogu P3367)
即标准并查集模板题:M 次操作,每次 1 X Y(合并 X 和 Y 所在的集合)或 2 X Y(查询 X 和 Y 是否在同一集合,输出 YN)。

✅ 完整解答

这是标准并查集裸题,直接套模板:

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

struct DSU {
    vector<int> pa, sz;
    explicit DSU(int n) : pa(n + 1), sz(n + 1, 1) {
        iota(pa.begin(), pa.end(), 0);
    }
    int find(int x) { return pa[x] == x ? x : pa[x] = find(pa[x]); }
    void unite(int x, int y) {
        x = find(x); y = find(y);
        if (x == y) return;
        if (sz[x] < sz[y]) swap(x, y);
        pa[y] = x; sz[x] += sz[y];
    }
    bool connected(int x, int y) { return find(x) == find(y); }
};

int main() {
    ios_base::sync_with_stdio(false); cin.tie(NULL);
    int n, m;
    cin >> n >> m;
    DSU dsu(n);
    while (m--) {
        int op, x, y;
        cin >> op >> x >> y;
        if (op == 1) dsu.unite(x, y);
        else cout << (dsu.connected(x, y) ? "Y" : "N") << "\n";
    }
    return 0;
}

🔴 挑战练习(9~10)

题目 9:食物链(NOIP 2001 P2024)
N 种动物,三类关系(A 吃 B,B 吃 C,C 吃 A 循环)。K 条信息,格式同 5.6.8 节。
求假话数量。

✅ 完整解答

直接使用 5.6.8 节的「种类并查集」代码,注意细节:

  • 自己吃自己(x == y 且 type=2):假话
  • 编号越界(x > n 或 y > n):假话
  • 矛盾检测先于合并
// 直接使用 5.6.8 节代码即可
// 关键测试:
// N=100, K=7
// 1 101 1    → x=101 > N=100,假话,lies=1
// 2 1 2      → 声明 1 吃 2,无矛盾,合并
// 2 2 3      → 声明 2 吃 3,无矛盾,合并  
// 2 3 1      → 1 吃 2 吃 3 吃 1 ← 合法循环
// 1 1 3      → 1 和 3 同类?但 1 吃 3,矛盾,lies=2
// 2 3 3      → x==y,自己吃自己,lies=3
// 1 1 2      → 1 和 2 同类?但 1 吃 2,矛盾,lies=4
// 输出:4

完整代码见 5.6.8 节,直接提交即可。


题目 10:可持久化并查集(综合应用)
给 N 个元素和 M 次操作,每次操作为「合并两个集合」或「回滚到历史的某一版本」。每次操作后回答若干查询(例如某两个元素在当前版本下是否连通)。

提示: 需要「可持久化并查集」——用按秩合并(不用路径压缩)+ 线段树维护历史版本的 pa[] 数组。

✅ 核心思路(框架代码)

为什么不能路径压缩? 路径压缩会改变树的结构,版本回滚后结构会乱。只用按秩合并(树高 O(log N)),每次 find 最多 O(log N) 步,可接受。

// 可持久化并查集框架(用持久化线段树维护 pa[])
// 每次 unite 操作只改动 2 个节点(rx 和 ry),
// 对线段树做单点修改,生成新版本

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

const int MAXN = 100005;
const int MAXLOG = 20;

struct Node {
    int left, right, val, rnk;  // rnk = 树的秩
} tr[MAXN * MAXLOG * 4];

int root[MAXN], cnt = 0;  // root[i] = 第 i 个版本的根节点

// 建初始线段树(所有节点 pa[i] = i)
int build(int l, int r) {
    int node = ++cnt;
    if (l == r) {
        tr[node].val = l;  // pa[l] = l(自己是根)
        tr[node].rnk = 0;
        return node;
    }
    int mid = (l + r) / 2;
    tr[node].left = build(l, mid);
    tr[node].right = build(mid + 1, r);
    return node;
}

// 持久化单点修改 pa[pos] = new_val
int update(int prev, int l, int r, int pos, int new_val, int new_rnk = -1) {
    int node = ++cnt;
    tr[node] = tr[prev];  // 复制上一版本
    if (l == r) {
        tr[node].val = new_val;
        if (new_rnk >= 0) tr[node].rnk = new_rnk;
        return node;
    }
    int mid = (l + r) / 2;
    if (pos <= mid)
        tr[node].left = update(tr[prev].left, l, mid, pos, new_val, new_rnk);
    else
        tr[node].right = update(tr[prev].right, mid + 1, r, pos, new_val, new_rnk);
    return node;
}

// 查询 pa[pos]
int query(int node, int l, int r, int pos) {
    if (l == r) return tr[node].val;
    int mid = (l + r) / 2;
    if (pos <= mid) return query(tr[node].left, l, mid, pos);
    return query(tr[node].right, mid + 1, r, pos);
}

int n;

// 在版本 ver 中查找 x 的根(不做路径压缩!)
int find(int ver, int x) {
    int pa = query(root[ver], 1, n, x);
    if (pa == x) return x;
    return find(ver, pa);
}

int main() {
    int m;
    cin >> n >> m;
    root[0] = build(1, n);
    
    for (int i = 1; i <= m; i++) {
        int op; cin >> op;
        if (op == 1) {
            // 合并
            int x, y; cin >> x >> y;
            int rx = find(i - 1, x), ry = find(i - 1, y);
            // ... 按秩合并,生成 root[i]
        } else if (op == 2) {
            // 回滚到第 k 版本
            int k; cin >> k;
            root[i] = root[k];
        } else {
            // 查询
            int x; cin >> x;
            cout << find(i - 1, x) << "\n";
            root[i] = root[i - 1];
        }
    }
    return 0;
}

完整实现参考:Luogu P3402 可持久化并查集


💡 章节联系: 并查集是图论最基础的工具之一——判连通性(第 5.2 章)、Kruskal MST(第 8.1 章)都依赖并查集。带权并查集在 USACO Gold 和信奥中频繁出现,建议重点掌握。

📖 第 5.7 章 ⏱️ 约 70 分钟 🎯 进阶

第 5.7 章:线段树入门

📝 前置条件: 理解前缀和(第 3.2 章)、数组和递归(第 2.3 章)。线段树是较进阶的数据结构——确保熟悉递归后再深入学习。

线段树是竞赛编程中最强大的数据结构之一,解决了前缀和无法处理的根本问题:带更新的区间查询


5.7.1 问题:为什么需要线段树

思考这个挑战:

  • 数组 A,共 N 个整数
  • Q1: A[l..r] 的和是多少?(区间求和查询)
  • Q2: 更新 A[i] = x(单点更新)

前缀和方案: 区间查询 O(1),但更新需要重新计算所有前缀和,O(N)。对于 M 次混合查询,总计 O(NM) —— N,M = 10^5 时太慢了。

线段树方案: 查询和更新都是 O(log N)。M 次混合查询总计:O(M log N)

数据结构构建查询更新最适合
简单数组O(N)O(N)O(1)只有更新
前缀和O(N)O(1)O(N)只有查询
线段树O(N)O(log N)O(log N)查询 + 更新
树状数组(BIT)O(N log N)O(log N)O(log N)代码更简单,仅限前缀和

上图展示了在数组 [1, 3, 5, 7, 9, 11] 上构建的线段树。每个内部节点存储其区间的和。对区间 [2,4] 的查询(和=21)只需组合 2 个节点——O(log N) 而不是 O(N)


5.7.2 结构:什么是线段树?

线段树是一棵完全二叉树

  • 每个叶节点对应一个数组元素
  • 每个内部节点存储其区间的聚合值(和、最小值、最大值等)
  • 根节点覆盖整个数组 [0..N-1]
  • 覆盖 [l..r] 的节点有两个子节点:[l..mid] 和 [mid+1..r]

对 N 个元素的数组,树最多有 4N 个节点(我们使用大小为 4N 的 1-indexed 树数组作为安全上界)。

数组:[1, 3, 5, 7, 9, 11](下标 0..5)

树(1-indexed,节点 i 的子节点是 2i 和 2i+1):
         [0..5]=36
        /          \
  [0..2]=9       [3..5]=27
   /     \        /      \
[0..1]=4 [2]=5  [3..4]=16  [5]=11
  /   \          /    \
[0]=1 [1]=3   [3]=7  [4]=9

下图展示了线段树的完整结构,以及查询 sum([2,4]) 时蓝色高亮的访问路径:

Segment Tree Structure


5.7.3 构建线段树

📄 查看代码:5.7.3 构建线段树
// 构建线段树 — O(N)
#include <bits/stdc++.h>
using namespace std;

const int MAXN = 100005;
int tree[4 * MAXN];  // 线段树数组(大小为 4 倍数组长度)
int arr[MAXN];       // 原始数组

// 构建:递归填充 tree[]
// node = 当前树节点下标(从 1 开始)
// start, end = 该节点覆盖的范围
void build(int node, int start, int end) {
    if (start == end) {
        // 叶节点:存储数组元素
        tree[node] = arr[start];
    } else {
        int mid = (start + end) / 2;
        // 先构建左右子节点
        build(2 * node, start, mid);        // 左子节点
        build(2 * node + 1, mid + 1, end);  // 右子节点
        // 内部节点:子节点之和
        tree[node] = tree[2 * node] + tree[2 * node + 1];
    }
}

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

    int n;
    cin >> n;
    for (int i = 0; i < n; i++) cin >> arr[i];

    build(1, 0, n - 1);  // 从节点 1 开始构建,覆盖 [0..n-1]

    return 0;
}

对 [1, 3, 5, 7, 9, 11] 的构建追踪:

📄 Code 完整代码
build(1, 0, 5):
  build(2, 0, 2):
    build(4, 0, 1):
      build(8, 0, 0): tree[8] = arr[0] = 1
      build(9, 1, 1): tree[9] = arr[1] = 3
      tree[4] = tree[8] + tree[9] = 4
    build(5, 2, 2): tree[5] = arr[2] = 5
    tree[2] = tree[4] + tree[5] = 9
  build(3, 3, 5):
    ...
    tree[3] = 27
  tree[1] = 9 + 27 = 36

5.7.4 区间查询

查询 arr[l..r] 的和:

核心思路: 递归下降树,在每个覆盖 [start..end] 的节点处:

  • 若 [start..end] 完全在 [l..r] 内:直接返回该节点的值(完成!)
  • 若 [start..end] 完全在 [l..r] 外:返回 0(无贡献)
  • 否则:递归进入两个子节点,将结果求和
📄 C++ 完整代码
// 区间查询:arr[l..r] 的和 — O(log N)
// node = 当前树节点,[start, end] = 覆盖范围
// [l, r] = 查询范围
int query(int node, int start, int end, int l, int r) {
    if (r < start || end < l) {
        // 情况一:当前区间完全在查询范围外
        return 0;   // 求和的单位元(求最小值用 INT_MAX)
    }
    if (l <= start && end <= r) {
        // 情况二:当前区间完全在查询范围内
        return tree[node];   // ← 关键行:直接使用该节点!
    }
    // 情况三:部分重叠——递归进入子节点
    int mid = (start + end) / 2;
    int leftSum  = query(2 * node, start, mid, l, r);
    int rightSum = query(2 * node + 1, mid + 1, end, l, r);
    return leftSum + rightSum;
}

// 用法:arr[2..4] 的和
int result = query(1, 0, n - 1, 2, 4);
cout << result << "\n";  // 5 + 7 + 9 = 21

在 [1,3,5,7,9,11] 的树上查询 [2..4] 的追踪:

query(1, 0, 5, 2, 4):
  query(2, 0, 2, 2, 4): [0..2] 与 [2..4] 部分重叠
    query(4, 0, 1, 2, 4): [0..1] 在 [2..4] 外 → 返回 0
    query(5, 2, 2, 2, 4): [2..2] 在 [2..4] 内 → 返回 5
    返回 0 + 5 = 5
  query(3, 3, 5, 2, 4): [3..5] 与 [2..4] 部分重叠
    query(6, 3, 4, 2, 4): [3..4] 在 [2..4] 内 → 返回 16
    query(7, 5, 5, 2, 4): [5..5] 在 [2..4] 外 → 返回 0
    返回 16 + 0 = 16
  返回 5 + 16 = 21 ✓

只访问了 4 个节点——O(log N)

下图展示了哪些节点被访问以及原因——绿色节点直接返回其值,橙色节点递归进入子节点,灰色节点立即被剪枝:

Segment Tree Query Visualization


5.7.5 单点更新

更新 arr[i] = x(修改单个元素):

📄 更新 `arr[i] = x`(修改单个元素):
// 单点更新:设置 arr[idx] = val — O(log N)
void update(int node, int start, int end, int idx, int val) {
    if (start == end) {
        // 叶节点:更新值
        arr[idx] = val;
        tree[node] = val;
    } else {
        int mid = (start + end) / 2;
        if (idx <= mid) {
            update(2 * node, start, mid, idx, val);      // 在左子树中更新
        } else {
            update(2 * node + 1, mid + 1, end, idx, val); // 在右子树中更新
        }
        // 子节点更改后,更新这个内部节点
        tree[node] = tree[2 * node] + tree[2 * node + 1];
    }
}

// 用法:设置 arr[2] = 10
update(1, 0, n - 1, 2, 10);

单点更新只修改从更新的叶节点到根节点路径上的节点——仅 O(log N) 个节点,其他所有分支保持不变:

Segment Tree Point Update


5.7.6 完整实现

这是完整的、可直接用于竞赛的线段树:

📄 这是完整的、可直接用于竞赛的线段树:
// 线段树 — O(N) 构建,O(log N) 查询/更新
#include <bits/stdc++.h>
using namespace std;

const int MAXN = 100005;
long long tree[4 * MAXN];

void build(int node, int start, int end, long long arr[]) {
    if (start == end) {
        tree[node] = arr[start];
        return;
    }
    int mid = (start + end) / 2;
    build(2 * node, start, mid, arr);
    build(2 * node + 1, mid + 1, end, arr);
    tree[node] = tree[2 * node] + tree[2 * node + 1];
}

long long query(int node, int start, int end, int l, int r) {
    if (r < start || end < l) return 0;
    if (l <= start && end <= r) return tree[node];
    int mid = (start + end) / 2;
    return query(2 * node, start, mid, l, r)
         + query(2 * node + 1, mid + 1, end, l, r);
}

void update(int node, int start, int end, int idx, long long val) {
    if (start == end) {
        tree[node] = val;
        return;
    }
    int mid = (start + end) / 2;
    if (idx <= mid) update(2 * node, start, mid, idx, val);
    else update(2 * node + 1, mid + 1, end, idx, val);
    tree[node] = tree[2 * node] + tree[2 * node + 1];
}

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

    int n, q;
    cin >> n >> q;
    long long arr[MAXN];
    for (int i = 0; i < n; i++) cin >> arr[i];

    build(1, 0, n - 1, arr);

    while (q--) {
        int type;
        cin >> type;
        if (type == 1) {
            // 单点更新:设置 arr[i] = v
            int i; long long v;
            cin >> i >> v;
            update(1, 0, n - 1, i, v);
        } else {
            // 区间查询:arr[l..r] 的和
            int l, r;
            cin >> l >> r;
            cout << query(1, 0, n - 1, l, r) << "\n";
        }
    }

    return 0;
}

样例输入:

6 5
1 3 5 7 9 11
2 2 4
1 2 10
2 2 4
2 0 5
1 0 0

样例输出:

21
26
41

(第1次查询 [2,4] = 5+7+9 = 21;执行 update arr[2]=10 后,第2次查询 [2,4] = 10+7+9 = 26;第3次查询 [0,5] = 1+3+10+7+9+11 = 41)


5.7.7 线段树 vs 树状数组(BIT)

特性线段树树状数组(BIT)
代码复杂度中等(约 30 行)简单(约 15 行)
区间查询任意结合性操作仅限前缀和
区间更新可以(需懒惰传播)可以(需技巧)
单点更新O(log N)O(log N)
空间O(4N)O(N)
使用场景区间最小/最大、复杂查询带更新的前缀和

💡 核心思路: 若需要带更新的区间和,树状数组更简单。若需要区间最小值、区间最大值,或任何非前缀操作,用线段树。


5.7.8 区间最小值查询变体

只需将聚合操作从 + 改为 min

📄 只需将聚合操作从 `+` 改为 `min`:
// 区间最小线段树 — 结构相同,操作不同
void build_min(int node, int start, int end, int arr[]) {
    if (start == end) { tree[node] = arr[start]; return; }
    int mid = (start + end) / 2;
    build_min(2*node, start, mid, arr);
    build_min(2*node+1, mid+1, end, arr);
    tree[node] = min(tree[2*node], tree[2*node+1]);  // ← 改为 min
}

int query_min(int node, int start, int end, int l, int r) {
    if (r < start || end < l) return INT_MAX;   // ← min 的单位元
    if (l <= start && end <= r) return tree[node];
    int mid = (start + end) / 2;
    return min(query_min(2*node, start, mid, l, r),
               query_min(2*node+1, mid+1, end, l, r));
}

5.7.9 带懒惰传播的区间更新

前面的线段树处理单点更新。对于区间更新:「给 [L, R] 的所有元素加 V」怎么办?

没有懒惰传播,需要 O(N) 次更新(每个元素一次)。有了懒惰传播,实现 O(log N) 区间更新

线段树懒惰传播(Lazy Propagation)

💡 核心思路: 不立即更新所有受影响的叶节点,而是「懒惰地」推迟更新——在适用的最高节点存储更新,只在真正需要子节点时才向下传递。

每个节点现在存储两个值

  • tree[node]:该区间的实际聚合值(区间和)
  • lazy[node]:尚未向子节点传递的待处理更新

向下传递规则: 访问有待处理懒惰更新的节点时:

  1. 将懒惰更新应用到该节点的值
  2. 将懒惰更新传递给两个子节点(向下传递)
  3. 清除该节点的懒惰值
📄 3. 清除该节点的懒惰值
// 带懒惰传播的线段树
// 支持:区间加法更新、区间求和查询 — 各 O(log N)
#include <bits/stdc++.h>
using namespace std;

typedef long long ll;
const int MAXN = 100005;

ll tree[4 * MAXN];   // tree[node] = 区间和
ll lazy[4 * MAXN];   // lazy[node] = 待处理的加法值(0 表示无待处理)

// ── 向下传递:将待处理懒惰传递给子节点 ──
void pushDown(int node, int start, int end) {
    if (lazy[node] == 0) return;  // 无待处理更新

    int mid = (start + end) / 2;
    int left = 2 * node, right = 2 * node + 1;

    // 更新左子节点的和:加 lazy * (左子节点元素数量)
    tree[left]  += lazy[node] * (mid - start + 1);
    tree[right] += lazy[node] * (end - mid);

    // 将懒惰传递给子节点
    lazy[left]  += lazy[node];
    lazy[right] += lazy[node];

    // 清除当前节点的懒惰(已向下传递)
    lazy[node] = 0;
}

// ── 构建 ──
void build(int node, int start, int end, ll arr[]) {
    lazy[node] = 0;
    if (start == end) {
        tree[node] = arr[start];
        return;
    }
    int mid = (start + end) / 2;
    build(2*node, start, mid, arr);
    build(2*node+1, mid+1, end, arr);
    tree[node] = tree[2*node] + tree[2*node+1];
}

// ── 区间更新:给 [l, r] 内所有元素加 val ──
void update(int node, int start, int end, int l, int r, ll val) {
    if (r < start || end < l) return;  // 范围外:无操作

    if (l <= start && end <= r) {
        // 当前区间完全在 [l, r] 内:应用懒惰,不递归
        tree[node] += val * (end - start + 1);  // ← 关键:乘以区间长度
        lazy[node] += val;                        // 存储给子节点的待处理值
        return;
    }

    // 部分重叠:先向下传递已有懒惰,再递归
    pushDown(node, start, end);  // ← 关键:递归前必须先 pushDown!

    int mid = (start + end) / 2;
    update(2*node,   start, mid, l, r, val);
    update(2*node+1, mid+1, end, l, r, val);

    // 从子节点更新当前节点
    tree[node] = tree[2*node] + tree[2*node+1];
}

// ── 区间查询:[l, r] 内元素之和 ──
ll query(int node, int start, int end, int l, int r) {
    if (r < start || end < l) return 0;  // 范围外

    if (l <= start && end <= r) {
        return tree[node];  // 完全在内:返回存储的和(已包含懒惰!)
    }

    // 部分重叠:先向下传递,再递归
    pushDown(node, start, end);  // ← 关键:递归前必须先 pushDown!

    int mid = (start + end) / 2;
    ll leftSum  = query(2*node,   start, mid, l, r);
    ll rightSum = query(2*node+1, mid+1, end, l, r);
    return leftSum + rightSum;
}

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

    int n, q;
    cin >> n >> q;

    ll arr[MAXN];
    for (int i = 0; i < n; i++) cin >> arr[i];

    build(1, 0, n-1, arr);

    while (q--) {
        int type;
        cin >> type;

        if (type == 1) {
            // 区间更新:给 [l, r] 加 val
            int l, r; ll val;
            cin >> l >> r >> val;
            update(1, 0, n-1, l, r, val);
        } else {
            // 区间查询:[l, r] 之和
            int l, r;
            cin >> l >> r;
            cout << query(1, 0, n-1, l, r) << "\n";
        }
    }

    return 0;
}

⚠️ 懒惰传播常见错误

4 大懒惰传播 Bug:

  1. 递归前忘记 pushDown —— 子节点会在父节点的懒惰之上再接收子节点自己的,导致查询结果错误
  2. 大小乘数用错 —— 写 tree[node] += val 而非 tree[node] += val * (end - start + 1)。节点存的是,给 (end-start+1) 个元素各加 val 意味着和增加 val×(大小)
  3. 未将 lazy[] 初始化为 0 —— 用 memset(lazy, 0, sizeof(lazy)) 或在 build() 中初始化
  4. 混合不同操作的懒惰 —— 若同时有「区间加」和「区间乘」两种懒惰,顺序很重要,需要两个独立的懒惰数组和仔细处理的 pushDown

懒惰传播通用化

该模式适用于任何满足以下条件的操作:

  • 聚合是结合性操作(和、最小值、最大值、XOR……)
  • 更新在聚合上分配(给 n 个元素各加 k,和增加 k*n
更新查询懒惰存储pushDown 公式
区间加区间和加法增量tree[child] += lazy * size; lazy[child] += lazy
区间赋值区间和赋值tree[child] = lazy * size; lazy[child] = lazy
区间加区间最小加法增量tree[child] += lazy; lazy[child] += lazy
区间赋值区间最小赋值tree[child] = lazy; lazy[child] = lazy

5.7.10 区间赋值(第二类懒惰)

区间加是最常见的懒惰操作,但竞赛中还有另一类:把 [L, R] 内所有元素设为同一个值 V

区别在于 pushDown 逻辑:区间加的懒惰是「增量叠加」,区间赋值的懒惰是「直接覆盖」。

📄 区别在于 pushDown 逻辑:区间加的懒惰是「增量叠加」,区间赋值的懒惰是「直接覆盖」。
// 带区间赋值懒惰的线段树
// tree[i] = 区间和,lazy[i] = 赋值标记(-1 表示无标记)
#include <bits/stdc++.h>
using namespace std;
typedef long long ll;
const int MAXN = 100005;

ll tree[4*MAXN];
ll lazy[4*MAXN];  // -1 = 无标记

void build(int s, int t, int p, ll a[]) {
    lazy[p] = -1;
    if (s == t) { tree[p] = a[s]; return; }
    int m = s + ((t - s) >> 1);
    build(s, m, p*2, a);
    build(m+1, t, p*2+1, a);
    tree[p] = tree[p*2] + tree[p*2+1];
}

// ── pushDown:区间赋值 ──
void pushDown(int p, int s, int t) {
    if (lazy[p] == -1) return;
    int m = s + ((t - s) >> 1);
    // 左子树所有元素赋值为 lazy[p],共 m-s+1 个
    tree[p*2]   = lazy[p] * (m - s + 1);
    tree[p*2+1] = lazy[p] * (t - m);
    lazy[p*2]   = lazy[p];   // 覆盖(不是叠加!)
    lazy[p*2+1] = lazy[p];
    lazy[p] = -1;             // 清除标记
}

// ── 区间赋值更新 ──
void update(int l, int r, ll c, int s, int t, int p) {
    if (l <= s && t <= r) {
        tree[p] = c * (t - s + 1);  // 整段赋值
        lazy[p] = c;
        return;
    }
    pushDown(p, s, t);
    int m = s + ((t - s) >> 1);
    if (l <= m) update(l, r, c, s, m, p*2);
    if (r > m)  update(l, r, c, m+1, t, p*2+1);
    tree[p] = tree[p*2] + tree[p*2+1];
}

// ── 区间求和查询(同区间加版本,先 pushDown) ──
ll query(int l, int r, int s, int t, int p) {
    if (l <= s && t <= r) return tree[p];
    pushDown(p, s, t);
    int m = s + ((t - s) >> 1);
    ll res = 0;
    if (l <= m) res += query(l, r, s, m, p*2);
    if (r > m)  res += query(l, r, m+1, t, p*2+1);
    return res;
}

⚠️ 区间赋值 vs 区间加的关键区别:pushDown 时,赋值用覆盖lazy[child] = val),加法用叠加lazy[child] += val)。若两种操作混用,必须维护两个独立的 lazy 数组,处理优先级。


5.7.11 动态开点线段树

使用场景

当值域极大(如 $10^9$)时,无法预先开 4N 的数组。但如果操作次数 M 较少(如 $10^5$),实际被访问到的节点只有 $O(M \log V)$ 个。

核心思路: 节点只在访问到时才创建,用 ls[p]rs[p] 记录左右子节点编号(替代 2p/2p+1)。

📄 C++ 完整代码
// 动态开点线段树(权值线段树 / 值域线段树)
// 典型应用:区间统计、求第 k 小
#include <bits/stdc++.h>
using namespace std;
const int MAXN = 2e6 + 5;  // 节点池上限(M * log V)

int ls[MAXN], rs[MAXN];     // 左右子节点编号
long long sum[MAXN];
int cnt, root;              // 节点计数器,根节点编号

// 单点加 1(用于插入值 x 到权值线段树)
void update(int &p, int s, int t, int x) {
    if (!p) p = ++cnt;          // 节点不存在则动态创建
    sum[p]++;
    if (s == t) return;
    int m = s + ((t - s) >> 1);
    if (x <= m) update(ls[p], s, m, x);
    else        update(rs[p], m+1, t, x);
}

// 区间求和
long long query(int p, int s, int t, int l, int r) {
    if (!p) return 0;           // 节点不存在,该区间无元素
    if (l <= s && t <= r) return sum[p];
    int m = s + ((t - s) >> 1);
    long long res = 0;
    if (l <= m) res += query(ls[p], s, m, l, r);
    if (r > m)  res += query(rs[p], m+1, t, l, r);
    return res;
}

// 用法示例:插入若干值后查询 [l, r] 内有多少个
// update(root, 1, 1e9, val);
// query(root, 1, 1e9, l, r);

空间复杂度: M 次操作后节点数为 $O(M \log V)$,远小于 $4V$。


5.7.12 线段树优化建图

使用场景

图论中,若需要「一个点向一段区间内所有点连边」或「一段区间内所有点向一个点连边」,朴素建图边数为 $O(N^2)$,用线段树可降到 $O(N \log N)$。

做法

建两棵线段树:

方向说明
出树(区间→点)子节点→父节点(0 权边)叶节点连原图点,区间节点汇聚到父节点
入树(点→区间)父节点→子节点(0 权边)父节点分发到叶节点,叶节点连原图点
区间 [2,4] → 点 u 连边:
  在入树中,对应 [2,4] 区间节点连一条权为 w 的边到 u
  入树内部父→子连 0 权边,叶节点与原图点重合

点 u → 区间 [2,4] 连边:
  在出树中,u 连一条权为 w 的边到对应 [2,4] 区间节点
  出树内部子→父连 0 权边,叶节点与原图点重合

建好后,以每个原图点为源点跑 Dijkstra 即可在 $O((N \log N + M) \log N)$ 内解决区间连边的最短路问题。

🔗 参考题目: CF786B Legacy(点→区间、区间→点、点→点混合连边最短路)


线段树变体速览

变体用途复杂度
基础线段树单点更新 + 区间查询O(log N)
懒惰传播(区间加)区间更新 + 区间查询O(log N)
懒惰传播(区间赋值)区间赋值 + 区间查询O(log N)
动态开点线段树值域大但操作少O(M log V) 空间
权值线段树全局第 k 小、逆序对O(log V) 查询
线段树优化建图区间连边的最短路O(N log N) 建图
可持久化线段树维护历史版本O(log N) 每版本

⚠️ 常见错误

  1. 数组大小太小: 始终分配 tree[4 * MAXN]。对非 2 的幂次方大小的数组,用 2 * MAXN 会越界。
  2. 范围外的单位元用错: 求和查询返回 0;求最小查询返回 INT_MAX;求最大查询返回 INT_MIN
  3. 忘记更新父节点: 更新子节点后,必须重新计算父节点:tree[node] = tree[2*node] + tree[2*node+1]
  4. 0-indexed vs 1-indexed 混淆: 本实现使用 0-indexed 数组但 1-indexed 树节点,保持一致性。
  5. 前缀和足够时用线段树: 若没有更新操作,前缀和(O(1) 查询)优于线段树(O(log N) 查询)。合适时用更简单的工具。

本章总结

📌 核心要点

操作时间关键代码行
构建O(N)tree[node] = tree[2*node] + tree[2*node+1]
单点更新O(log N)递归到叶节点,向上更新
区间查询O(log N)完全在内/完全在外时提前返回
空间O(4N)分配 tree[4 * MAXN]

❓ 常见问题

Q1:什么时候选线段树 vs 前缀和?

A:简单规则——若数组从不改变,前缀和更好(O(1) 查询 vs O(log N))。若数组被修改(单点更新),用线段树或 BIT。若需要区间更新(给一段区间加值),用带懒惰传播的线段树。

Q2:为什么树数组大小需要 4N?

A:线段树是完全二叉树。当 N 不是 2 的幂次方时,最后一层可能不完整但仍需空间。最坏情况下需要约 4N 个节点。用 4*MAXN 是安全上界。

Q3:树状数组(BIT)和线段树哪个更好?

A:BIT 代码更短(约 15 行 vs 30 行),常数更小,但只能处理「可前缀分解」的操作(如求和)。线段树更通用(可以处理区间最小/最大、GCD 等),支持更复杂的操作(如懒惰传播)。竞赛中:能用 BIT 就用 BIT,BIT 不够用时切换到线段树。

Q4:线段树能处理哪些类型的查询?

A:任何满足结合律的操作:求和(+)、最小值(min)、最大值(max)、GCD、XOR、乘积等。关键是有「单位元」(如求和的 0、最小值的 INT_MAX、最大值的 INT_MIN)。

Q5:什么是懒惰传播?什么时候需要?

A:当需要「给区间 [L,R] 的每个元素加 V」(区间更新)时,朴素做法从 L 到 R 逐个更新叶节点(O(N)),太慢。懒惰传播将更新「懒惰地」存储在内部节点,只在子节点实际需要被查询时才向下传递,将区间更新也优化为 O(log N)

🔗 与后续章节的联系

  • 第 3.2 章(前缀和):线段树的「简化版」——没有更新操作时用前缀和
  • 第 5.1–5.2 章(图):欧拉序 + 线段树可以高效处理树上路径查询
  • 第 6.1–6.3 章(DP):某些 DP 优化需要线段树维护 DP 值的区间最小/最大
  • 线段树是 USACO Gold 级别的核心数据结构,掌握它能解决大量 Gold 题目

练习题

题目 5.7.1 — 经典区间和 🟢 简单 实现线段树,处理 N 个元素和 Q 次查询:单点更新或区间求和。

提示 使用 5.7.6 节的完整实现,用标志区分查询类型(1 = 更新,2 = 查询)。
✅ 完整题解
#include <bits/stdc++.h>
using namespace std;
const int MAXN = 100005;
long long tree[4*MAXN];
int n, q;

void build(int node, int s, int e, int arr[]) {
    if (s==e) { tree[node]=arr[s]; return; }
    int mid=(s+e)/2;
    build(2*node,s,mid,arr); build(2*node+1,mid+1,e,arr);
    tree[node]=tree[2*node]+tree[2*node+1];
}
void update(int node,int s,int e,int idx,long long val) {
    if (s==e) { tree[node]=val; return; }
    int mid=(s+e)/2;
    if (idx<=mid) update(2*node,s,mid,idx,val);
    else update(2*node+1,mid+1,e,idx,val);
    tree[node]=tree[2*node]+tree[2*node+1];
}
long long query(int node,int s,int e,int l,int r) {
    if (r<s||e<l) return 0;
    if (l<=s&&e<=r) return tree[node];
    int mid=(s+e)/2;
    return query(2*node,s,mid,l,r)+query(2*node+1,mid+1,e,l,r);
}
int arr[MAXN];
int main() {
    ios_base::sync_with_stdio(false); cin.tie(NULL);
    cin>>n>>q;
    for(int i=1;i<=n;i++) cin>>arr[i];
    build(1,1,n,arr);
    while(q--) {
        int t; cin>>t;
        if(t==1) { int i; long long v; cin>>i>>v; update(1,1,n,i,v); }
        else { int l,r; cin>>l>>r; cout<<query(1,1,n,l,r)<<"\n"; }
    }
}

复杂度: O(N) 构建,每次查询/更新 O(log N)。


题目 5.7.2 — 区间最小值 🟡 中等 同上,但查询区间最小值,处理单点更新。

提示 将树操作中的 `+` 改为 `min`,范围外返回 `INT_MAX`。
✅ 完整题解

在上面的解法中修改两行:

// 在 build/update 中:
tree[node] = min(tree[2*node], tree[2*node+1]);
// 在 query 中——范围外的单位元:
if (r < s || e < l) return INT_MAX;
// 合并:
return min(query(2*node,s,mid,l,r), query(2*node+1,mid+1,e,l,r));

初始化:tree[叶节点] = arr[s](相同)。唯一改变的是聚合函数和单位元。


题目 5.7.3 — 逆序对计数 🔴 困难 统计满足 i < j 且 arr[i] > arr[j] 的对 (i,j) 的个数。

提示 从左到右处理元素。对每个元素 x,查询已插入的元素中 > x 的个数。
✅ 完整题解

核心思路: 将值坐标压缩到 [1..N]。对每个元素 x(从左到右),逆序对数 += (已插入元素数)- (已插入的 ≤ x 的数量)= query(N) - query(x)。然后插入 x。

#include <bits/stdc++.h>
using namespace std;
const int MAXN = 300005;
int tree[MAXN], n;
void update(int i){for(;i<=n;i+=i&-i) tree[i]++;}
int query(int i){int s=0;for(;i>0;i-=i&-i)s+=tree[i];return s;}

int main(){
    cin>>n;
    vector<int> a(n);
    for(int&x:a)cin>>x;
    // 坐标压缩
    vector<int> sorted=a; sort(sorted.begin(),sorted.end());
    sorted.erase(unique(sorted.begin(),sorted.end()),sorted.end());
    for(int&x:a) x=lower_bound(sorted.begin(),sorted.end(),x)-sorted.begin()+1;
    int m=sorted.size();

    long long inv=0;
    for(int i=0;i<n;i++){
        inv += (i - query(a[i]));  // 已见过的中大于 a[i] 的个数
        update(a[i]);
    }
    cout<<inv<<"\n";
}

复杂度: O(N log N),用 BIT(对此问题比线段树更合适)。


🏆 挑战题:USACO 2016 February Gold:围牛栏 需要带更新的区间最大查询的题目。尝试分别用树状数组和线段树解决,理解两者的权衡。

📖 第 5.8 章 ⏱️ 约 60 分钟 🎯 进阶

第 5.8 章:树状数组(BIT)

📝 前置条件: 了解前缀和(第 3.2 章)和位运算。本章与线段树(第 5.7 章)互补——BIT 代码更短,常数更小,但支持的操作更少。

树状数组(又名二进制索引树 / BIT)是竞赛编程中最常用的数据结构之一:不到 15 行代码,却能在 O(log N) 时间内支持单点更新和前缀查询。

本章学习路线:
1. 先从一个很朴素的问题出发:为什么普通前缀和遇到修改会变慢?
2. 再认识 lowbit:它告诉我们每个小盒子应该管理几个数。
3. 然后看懂 tree[i] 到底存的是哪一段和。
4. 接着分别拆开讲查询和更新:先手算,再看代码。
5. 最后学习差分 BIT、逆序对、权值 BIT 等进阶用法。

想象一排同学每人手里有一些糖。老师经常问“前 7 个同学一共有多少糖?”,也经常让“第 3 个同学多拿 5 颗糖”。如果每次都从第 1 个数到第 7 个,会很慢;如果每次有人变了都重算所有前缀和,也很慢。树状数组就是把同学分成许多大小不同的小组,每个小组长帮忙记录一段糖果总数,这样查询和修改都不用找太多人。


5.8.1 先从问题出发:为什么需要树状数组?

在学习树状数组前,我们先回忆两个最简单的方法。

方法一:直接数组

如果数组是:

A = [3, 1, 4, 1, 5, 9, 2, 6]

想求 A[1..7] 的和,就从头加到尾:

3 + 1 + 4 + 1 + 5 + 9 + 2 = 25
  • 优点:修改很快,比如 A[3] += 10,直接改一个数。
  • 缺点:查询慢,问一次前缀和可能要加很多个数。

方法二:前缀和数组

前缀和把“从开头到当前位置的总和”提前存起来:

prefix[i] = A[1] + A[2] + ... + A[i]

这样查询 A[1..7] 很快,直接看 prefix[7]

但如果 A[3] 变了,会发生什么?

prefix[3], prefix[4], prefix[5], ..., prefix[n]

后面很多前缀和都要跟着改。

所以前缀和的矛盾是:

操作直接数组前缀和数组
修改一个位置快,O(1)慢,可能要改很多个
查询前缀和慢,可能要加很多个快,O(1)

树状数组想解决的就是这个矛盾:

能不能让修改不要太慢,查询也不要太慢?

答案是可以。BIT 让这两个操作都变成 O(log N)

直接数组:修改快,查询慢
前缀和:查询快,修改慢
树状数组:修改和查询都比较快

5.8.2 核心积木:什么是 lowbit

树状数组最重要的积木只有一个:lowbit

int lowbit(int x) {
    return x & (-x);
}

先不要急着背公式。我们先弄懂它在回答什么问题:

从右往左看,x 的第一个 1 代表多大的数?

例如 x = 6

6 的二进制 = 0110
从右往左看,第一个 1 在第 1 位
这一位代表 2
所以 lowbit(6) = 2

再看几个例子:

lowbit(1) = lowbit(0001) = 1
lowbit(2) = lowbit(0010) = 2
lowbit(3) = lowbit(0011) = 1
lowbit(4) = lowbit(0100) = 4
lowbit(6) = lowbit(0110) = 2
lowbit(8) = lowbit(1000) = 8

lowbit 的位运算原理

对任意正整数 xlowbit(x) = x & (-x) 返回 x 的二进制表示中最低位 1 所代表的值

x = 6 为例:

x  =  6  →  二进制:0110
-x = -6  →  补码:1010(按位取反 + 1)
x & (-x) = 0010 = 2   ← 最低位 1 对应 2^1 = 2

如果你现在还不熟悉补码,也没关系。先把 lowbit(x) 当作一个小工具:

它能帮我们找到 x 最右边那个 1 对应的数值。

示例:

x二进制lowbit(x)小学生版理解
100011最右边的 1 代表 1
200102最右边的 1 代表 2
300111最右边的 1 代表 1
401004最右边的 1 代表 4
601102最右边的 1 代表 2
810008最右边的 1 代表 8

为什么 lowbit 对 BIT 很重要?

在 BIT 里,lowbit(i) 不只是一个位运算结果,它还表示:

tree[i] 这个小盒子要管理几个原数组元素。

例如:

lowbit(6) = 2  →  tree[6] 管 2 个数
lowbit(8) = 8  →  tree[8] 管 8 个数
lowbit(3) = 1  →  tree[3] 管 1 个数

所以可以先记住一句话:

lowbit(i) 决定 tree[i] 这个盒子的大小。


5.8.3 tree[i] 到底存什么?

BIT 的精妙之处:tree[i] 不存储单个元素,而是存储一段区间的和

更准确地说:

tree[i] = A[i - lowbit(i) + 1] + ... + A[i]

也就是说,tree[i] 管的是:

以 i 结尾,长度为 lowbit(i) 的一段

如果把 tree[i] 想成一个小盒子,那么:

  • 盒子的右端点:永远是 i
  • 盒子的长度:由 lowbit(i) 决定。
  • 盒子里装的东西:这一段 A 的总和。

用 n = 8 看完整盒子分工

假设原数组下标从 18,那么每个盒子负责的范围是:

下标 i:  1    2    3    4    5    6    7    8
tree[i] 管理的范围:
  tree[1] = A[1]            (长度 lowbit(1)=1)
  tree[2] = A[1]+A[2]       (长度 lowbit(2)=2)
  tree[3] = A[3]            (长度 lowbit(3)=1)
  tree[4] = A[1]+...+A[4]   (长度 lowbit(4)=4)
  tree[5] = A[5]            (长度 lowbit(5)=1)
  tree[6] = A[5]+A[6]       (长度 lowbit(6)=2)
  tree[7] = A[7]            (长度 lowbit(7)=1)
  tree[8] = A[1]+...+A[8]   (长度 lowbit(8)=8)

BIT 结构(n=8):每个 tree[i] 覆盖恰好 lowbit(i) 个以下标 i 结尾的元素。

BIT Tree Structure

为什么这样分组有用?

因为这些盒子大小不一样:有的管 1 个数,有的管 2 个数,有的管 4 个数,有的管 8 个数。

就像班级里有:

1 人小组、2 人小组、4 人小组、8 人小组

老师问“前 7 个人一共有多少糖”时,不需要一个一个问,可以直接问几个组长:

前 7 个 = [1..4] + [5..6] + [7]

这正好对应:

tree[4] + tree[6] + tree[7]

5.8.4 前缀查询:怎样快速算 A[1..i]

BIT 的查询函数通常叫 query(i),意思是求:

A[1] + A[2] + ... + A[i]

核心动作是:

i -= lowbit(i);

这句话的意思是:

我已经拿到了以 i 结尾的一段和,下一步就跳到这段的前面继续拿。

手算 query(7)

我们要算:

A[1] + A[2] + A[3] + A[4] + A[5] + A[6] + A[7]

i = 7 开始:

当前 ilowbit(i)加哪个盒子这个盒子负责下一步
71tree[7]A[7]7 - 1 = 6
62tree[6]A[5] + A[6]6 - 2 = 4
44tree[4]A[1] + A[2] + A[3] + A[4]4 - 4 = 0

所以:

query(7) = tree[7] + tree[6] + tree[4]
         = A[7] + (A[5]+A[6]) + (A[1]+A[2]+A[3]+A[4])
         = A[1] + A[2] + ... + A[7]

这三段刚好不重不漏:

[1..4] + [5..6] + [7]

查询 prefix(7) 的跳转路径(i -= lowbit(i) 向下跳):

Fenwick Query Path

对应代码:

long long query(int i) {
    long long sum = 0;
    while (i > 0) {
        sum += tree[i];
        i -= lowbit(i);
    }
    return sum;
}

💡 记忆口诀: 查询是“往前跳”,每跳一次就拿走一整盒已经算好的和。


5.8.5 单点更新:为什么要一路向上改?

如果 A[3] 增加了 val,哪些 tree[i] 会受影响?

答案是:所有负责范围里包含 A[3] 的盒子都要跟着加 val

从前面的盒子分工可以看到:

tree[3] = A[3]
tree[4] = A[1]+A[2]+A[3]+A[4]
tree[8] = A[1]+...+A[8]

所以更新 A[3] 时,要改:

tree[3], tree[4], tree[8]

核心动作是:

i += lowbit(i);

这句话的意思是:

当前盒子改完后,跳到下一个更大的、也包含当前位置的盒子。

手算 update(3, val)

当前 ilowbit(i)修改哪个盒子下一步
31tree[3] += val3 + 1 = 4
44tree[4] += val4 + 4 = 8
88tree[8] += val8 + 8 = 16,超过 n,停止

更新位置 3 的跳转路径(i += lowbit(i) 向上跳):

Fenwick Update Path

对应代码:

void update(int i, long long val) {
    while (i <= n) {
        tree[i] += val;
        i += lowbit(i);
    }
}

💡 记忆口诀: 更新是“往后跳”,每跳一次就告诉一个受影响的组长:“你管的总和变了!”

查询和更新的方向对比

操作跳转方向跳转代码直觉
查询 query(i)往前跳i -= lowbit(i)拿走当前盒子,继续拿前面的盒子
更新 update(i,val)往后跳i += lowbit(i)通知所有包含这个位置的大盒子

每次跳转都会让二进制发生明显变化,所以最多跳大约 log N 次。


5.8.6 把查询和更新写成完整代码

前面我们已经分别手算了 queryupdate。现在把它们合在一起,就是最经典的树状数组模板。

先抓住三个重点:

  1. 数组从 1 开始编号:因为 lowbit(0)=0,用 0 会卡住。
  2. 查询往前跳i -= lowbit(i)
  3. 更新往后跳i += lowbit(i)
📄 查看代码:5.8.6 把查询和更新写成完整代码
// ══════════════════════════════════════════════════════════════
// 树状数组(BIT)—— 经典实现
// 支持:单点更新 O(log N),前缀和查询 O(log N)
// 数组必须使用 1-indexed(关键!)
// ══════════════════════════════════════════════════════════════
#include <bits/stdc++.h>
using namespace std;

const int MAXN = 300005;

int n;
long long tree[MAXN];  // BIT 数组,1-indexed

// ── lowbit:返回最低位 1 的值 ──
inline int lowbit(int x) {
    return x & (-x);
}

// ── 更新:给位置 i 加 val ──
// 向上遍历:i += lowbit(i)
// 覆盖位置 i 的每个祖先节点都被更新
void update(int i, long long val) {
    for (; i <= n; i += lowbit(i))
        tree[i] += val;
    // 时间:O(log N) — 最多 log2(N) 次迭代
}

// ── 查询:返回前缀和 A[1..i] ──
// 向下遍历:i -= lowbit(i)
// 将 [1..i] 分解为 O(log N) 个不重叠的区间
long long query(int i) {
    long long sum = 0;
    for (; i > 0; i -= lowbit(i))
        sum += tree[i];
    return sum;
    // 时间:O(log N) — 最多 log2(N) 次迭代
}

// ── 构建:从已有数组 A[1..n] 初始化 BIT ──
// 方法一:N 次单独更新 — O(N log N)
void build_slow(long long A[]) {
    fill(tree + 1, tree + n + 1, 0LL);
    for (int i = 1; i <= n; i++)
        update(i, A[i]);
}

// 方法二:O(N) 构建(利用「直接父节点」关系)
void build_fast(long long A[]) {
    for (int i = 1; i <= n; i++) {
        tree[i] += A[i];
        int parent = i + lowbit(i);  // BIT 中的直接父节点
        if (parent <= n)
            tree[parent] += tree[i];
    }
}

// 方法三:O(N) 构建(利用前缀和)
// 原理:tree[i] = sum(A[i-lowbit(i)+1 .. i])
//      = prefix[i] - prefix[i - lowbit(i)]
void build_prefix(long long A[], long long prefix[]) {
    // 先求前缀和
    for (int i = 1; i <= n; i++) prefix[i] = prefix[i-1] + A[i];
    // 利用前缀和直接计算每个节点
    for (int i = 1; i <= n; i++)
        tree[i] = prefix[i] - prefix[i - lowbit(i)];
}

// ── 完整示例 ──
int main() {
    ios_base::sync_with_stdio(false);
    cin.tie(NULL);

    int q;
    cin >> n >> q;

    long long A[MAXN] = {};
    for (int i = 1; i <= n; i++) cin >> A[i];
    build_fast(A);  // O(N) 初始化

    while (q--) {
        int type;
        cin >> type;
        if (type == 1) {
            // 单点更新:A[i] += val
            int i; long long val;
            cin >> i >> val;
            update(i, val);
        } else {
            // 前缀查询:A[1..r] 的和
            int r;
            cin >> r;
            cout << query(r) << "\n";
        }
    }
    return 0;
}

5.8.7 区间查询:用两个前缀和相减

会了前缀查询后,区间查询就很自然了。

如果要算 A[l..r],可以先算 A[1..r],再减掉前面不需要的 A[1..l-1]

A[l..r] = A[1..r] - A[1..l-1]

这和前缀和数组的思想完全一样,只是这里的 query 由 BIT 在 O(log N) 内完成:

📄 会了前缀查询后,区间查询就很自然了。

如果要算 A[l..r],可以先算 A[1..r],再减掉前面不需要的 A[1..l-1]

A[l..r] = A[1..r] - A[1..l-1]

这和前缀和数组的思想完全一样,只是这里的 query 由 BIT 在 O(log N) 内完成:

// 区间求和:A[l..r] 的和
// 时间:O(log N) — 两次前缀查询
long long range_query(int l, int r) {
    return query(r) - query(l - 1);
    // query(r)   = A[1] + A[2] + ... + A[r]
    // query(l-1) = A[1] + A[2] + ... + A[l-1]
    // 差值        = A[l] + A[l+1] + ... + A[r]
}

// 示例:
// A = [3, 1, 4, 1, 5, 9, 2, 6]  (1-indexed)
// range_query(3, 6) = query(6) - query(2)
//                  = (3+1+4+1+5+9) - (3+1)
//                  = 23 - 4 = 19
// 验证:A[3]+A[4]+A[5]+A[6] = 4+1+5+9 = 19 ✓

5.8.8 什么时候用 BIT?和前缀和、线段树对比

学到这里,你可能会问:既然有前缀和,也有线段树,为什么还要学 BIT?

简单说:

  • 前缀和:适合不修改的数组。
  • BIT:适合“单点修改 + 区间求和”。
  • 线段树:适合更复杂的区间操作。
操作前缀和数组树状数组(BIT)线段树
构建O(N)O(N)O(N log N)O(N)
前缀查询O(1)O(log N)O(log N)
区间查询O(1)O(log N)O(log N)
单点更新O(N) 重建O(log N)O(log N)
区间更新O(N)O(log N)(差分 BIT)O(log N)(懒惰标记)
区间最小/最大O(1)(稀疏表)❌ 不支持✓ 支持
代码复杂度极简简单(10 行)复杂(50+ 行)
常数因子最小非常小较大
空间O(N)O(N)O(4N)

什么时候选 BIT?

  • ✅ 只需前缀/区间和 + 单点更新
  • ✅ 需要极简代码(竞赛中减少 bug)
  • ✅ 逆序对计数、归并排序计数问题
  • ❌ 需要区间最小/最大 → 用线段树
  • ❌ 需要复杂区间操作(区间乘法等)→ 用线段树

5.8.9 交互式可视化:BIT 更新过程


5.8.10 区间更新 + 单点查询(差分 BIT)

前面讲的是标准 BIT:

单点更新 + 前缀查询

现在往前走一步:如果题目经常说“把一整段都加上 val”,怎么办?

直接逐个位置修改太慢。这里可以借用差分数组,让 BIT 维护差分数组。这样就能支持:

区间更新 + 单点查询

原理

设差分数组 D[i] = A[i] - A[i-1]D[1] = A[1])。

你可以把差分数组理解成“变化通知单”:

  • D[l] += val:从 l 开始,每个位置都多 val
  • D[r+1] -= val:从 r+1 开始,把刚才的增加取消掉。

所以给 A[l..r] 全部加 val,只需要改两个位置:

D[l] += val
D[r+1] -= val

A[i] 等于差分数组前缀和:

A[i] = D[1] + D[2] + ... + D[i]
📄 C++ 完整代码
// ══════════════════════════════════════════════════════════════
// 差分 BIT:区间更新 + 单点查询
// ══════════════════════════════════════════════════════════════
#include <bits/stdc++.h>
using namespace std;

const int MAXN = 300005;
int n;
long long diff_bit[MAXN];  // 差分数组 D[] 上的 BIT

inline int lowbit(int x) { return x & (-x); }

// 更新差分 BIT 的位置 i:D[i] += val
void diff_update(int i, long long val) {
    for (; i <= n; i += lowbit(i))
        diff_bit[i] += val;
}

// 查询 A[i] = D[1..i] 的和 = 差分 BIT 的前缀查询
long long diff_query(int i) {
    long long s = 0;
    for (; i > 0; i -= lowbit(i))
        s += diff_bit[i];
    return s;
}

// 区间更新:给 A[l..r] 全部加 val
// 等价于:D[l] += val,D[r+1] -= val
void range_update(int l, int r, long long val) {
    diff_update(l, val);       // D[l] += val
    diff_update(r + 1, -val);  // D[r+1] -= val
}

// 单点查询:返回 A[i] 的当前值
// A[i] = D[1] + D[2] + ... + D[i] = prefix_sum(D, i)
long long point_query(int i) {
    return diff_query(i);
}

进阶:区间更新 + 区间查询(双 BIT)

同时支持区间更新和区间查询,使用两个 BIT:

📄 同时支持区间更新和区间查询,使用两个 BIT:
// ══════════════════════════════════════════════════════════════
// 双 BIT:区间更新 + 区间查询
// 公式:sum(1..r) = B1[r] * r - B2[r]
// 其中 B1 是 D[] 上的 BIT,B2 是 (i-1)*D[i] 上的 BIT
// ══════════════════════════════════════════════════════════════
long long B1[MAXN], B2[MAXN];

inline int lowbit(int x) { return x & (-x); }

void add(long long* b, int i, long long v) {
    for (; i <= n; i += lowbit(i)) b[i] += v;
}
long long sum(long long* b, int i) {
    long long s = 0;
    for (; i > 0; i -= lowbit(i)) s += b[i];
    return s;
}

// 区间更新:给 A[l..r] 加 val
void range_add(int l, int r, long long val) {
    add(B1, l, val);
    add(B1, r + 1, -val);
    add(B2, l, val * (l - 1));     // 补偿前缀公式
    add(B2, r + 1, -val * r);
}

// 前缀和 A[1..r]
long long prefix_sum(int r) {
    return sum(B1, r) * r - sum(B2, r);
}

// 区间和 A[l..r]
long long range_sum(int l, int r) {
    return prefix_sum(r) - prefix_sum(l - 1);
}

5.8.11 USACO 风格题:用 BIT 统计逆序对

题目描述

统计逆序对(O(N log N))

给定长度为 N 的整数数组 A(元素不同,范围 1..N),统计逆序对的数量。

逆序对:一对下标 (i, j),满足 i < j 但 A[i] > A[j]。

样例输入:

5
3 1 4 2 5

样例输出:

3

解释: 逆序对是 (3,1)、(3,2)、(4,2),共 3 对。

先用小例子理解逆序对

数组:

[3, 1, 4, 2, 5]

从左到右看:

  • 31 前面,而且 3 > 1,所以 (3,1) 是逆序对。
  • 32 前面,而且 3 > 2,所以 (3,2) 是逆序对。
  • 42 前面,而且 4 > 2,所以 (4,2) 是逆序对。

一共 3 对。

为什么 BIT 能帮忙?

处理到当前数 a 时,左边的数都已经出现过。我们只需要知道:

左边已经出现的数里面,有多少个 > a?

如果用 BIT 维护“每个值出现过几次”,就可以快速得到:

已经出现的总数 - 已经出现且 <= a 的数量

这就是代码里的:

(i - 1) - query(a)

解法:BIT 逆序对计数

📄 查看代码:解法:BIT 逆序对计数
// ══════════════════════════════════════════════════════════════
// 用树状数组统计逆序对 — O(N log N)
//
// 核心思路:
//   从左到右处理 A[i]。
//   对每个 A[i],以 A[i] 为右端点的逆序对数
//   = 已处理过的值中大于 A[i] 的数量
//   = (目前处理的元素数) - (已处理的 <= A[i] 的元素数)
//   = i-1 - prefix_query(A[i])
//   对所有 i 求和即为总逆序对数。
//
// BIT 的作用:追踪已见过的值的频率。
//   见到值 v 后:update(v, +1)
//   查询 <= x 的值的数量:query(x)
// ══════════════════════════════════════════════════════════════
#include <bits/stdc++.h>
using namespace std;

typedef long long ll;
const int MAXN = 300005;

int n;
int bit[MAXN];  // 频率计数 BIT

inline int lowbit(int x) { return x & (-x); }

// 在位置 v 加 1(见到了值 v)
void update(int v) {
    for (; v <= n; v += lowbit(v))
        bit[v]++;
}

// 统计已见过的 [1..v] 中的值的数量
int query(int v) {
    int cnt = 0;
    for (; v > 0; v -= lowbit(v))
        cnt += bit[v];
    return cnt;
}

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

    cin >> n;

    ll inversions = 0;

    for (int i = 1; i <= n; i++) {
        int a;
        cin >> a;

        // 统计以 a 为右端点的逆序对:
        // 已见过的值中大于 a 的数量
        // = (目前已见 i-1 个元素) - (已见的 <= a 的数量)
        int less_or_equal = query(a);          // [1..a] 中已见的数量
        int greater = (i - 1) - less_or_equal; // [a+1..n] 中已见的数量
        inversions += greater;

        // 标记我们已见过值 a
        update(a);
    }

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

/*
对 A = [3, 1, 4, 2, 5] 的追踪:

i=1, a=3:已见=[],query(3)=0,greater=0-0=0。逆序对=0。update(3)。
i=2, a=1:已见=[3],query(1)=0,greater=1-0=1。逆序对=1。update(1)。
           (3 > 1:1 个逆序对:(3,1) ✓)
i=3, a=4:已见=[3,1],query(4)=2,greater=2-2=0。逆序对=1。update(4)。
           (没有已见的元素 > 4)
i=4, a=2:已见=[3,1,4],query(2)=1,greater=3-1=2。逆序对=3。update(2)。
           (3>2 且 4>2:2 个逆序对:(3,2),(4,2) ✓)
i=5, a=5:已见=[3,1,4,2],query(5)=4,greater=4-4=0。逆序对=3。update(5)。

最终:3 ✓
*/

复杂度分析:

  • 时间:O(N log N) —— N 次迭代,每次 O(log N) 的更新 + 查询
  • 空间:O(N)(BIT)

扩展: 若数组元素不在 1..N 范围内,先做坐标压缩再使用 BIT:

📄 C++ 完整代码
// 任意值的坐标压缩
vector<int> A(n);
for (int i = 0; i < n; i++) cin >> A[i];

// 步骤一:排序去重
vector<int> sorted_A = A;
sort(sorted_A.begin(), sorted_A.end());
sorted_A.erase(unique(sorted_A.begin(), sorted_A.end()), sorted_A.end());

// 步骤二:将每个值替换为它的排名(1-indexed)
for (int i = 0; i < n; i++) {
    A[i] = lower_bound(sorted_A.begin(), sorted_A.end(), A[i]) - sorted_A.begin() + 1;
    // A[i] 现在在 [1..M],M = sorted_A.size()
}
// 现在用 n = sorted_A.size() 使用 BIT

5.8.12 权值树状数组:全局第 k 小

权值 BIT 是 BIT 的另一种常见用法。

前面的 BIT 通常把下标当作数组位置:

位置 1、位置 2、位置 3、...

权值 BIT 把下标当作“数值大小”:

值 1、值 2、值 3、...

也就是说,bit[v] 表示值 v 出现了多少次。这样我们就能高效查询「序列中第 k 小的元素」。

例如当前数列是:

[1, 2, 2, 4, 7]

那么频率是:

值 1 出现 1 次
值 2 出现 2 次
值 4 出现 1 次
值 7 出现 1 次

3 小是 2,因为排好序后是:

1, 2, 2, 4, 7
      ↑
    第 3 小

朴素做法:二分 + 前缀查询,O(log² N)

📄 查看代码:朴素做法:二分 + 前缀查询,O(log² N)
// 在值域 [1..MAXV] 上的 BIT 中,找第 k 小的值
int kth_binary_search(int k) {
    int lo = 1, hi = MAXV;
    while (lo < hi) {
        int mid = (lo + hi) / 2;
        if (query(mid) >= k)
            hi = mid;
        else
            lo = mid + 1;
    }
    return lo;
}

倍增优化:O(log N)

二分法已经够好理解,但每次二分都要调用一次 query,所以是 O(log² N)

借助 BIT 的树形结构,倍增法可以在 O(log N) 内完成第 k 小查询:

📄 二分法已经够好理解,但每次二分都要调用一次 `query`,所以是 `O(log² N)`。

借助 BIT 的树形结构,倍增法可以在 O(log N) 内完成第 k 小查询:

// 全局第 k 小(倍增法)— O(log N)
// 前提:BIT 维护值域频率,bit[v] = v 的出现次数
int kth(int k) {
    int sum = 0, x = 0;
    // 从最高位开始,逐位确定答案
    for (int i = (int)log2(MAXV); i >= 0; --i) {
        int nx = x + (1 << i);
        if (nx <= MAXV && sum + bit[nx] < k) {
            x = nx;          // 这一段全选,继续向右扩展
            sum += bit[nx];
        }
        // 否则答案在 [x+1, x + 2^(i-1)] 范围内,不扩展
    }
    return x + 1;  // x 是最后一个 sum < k 的位置,答案是 x+1
}

// 完整示例:动态维护序列,支持插入和第 k 小查询
// 插入值 v:update(v, 1)
// 删除值 v:update(v, -1)
// 查询第 k 小:kth(k)

💡 原理解析: BIT 的树形态使得 bit[x] 正好是以 x 为根的子树之和(x 的二进制最低位之前的区间)。倍增时,每次尝试将 x 的某一位设为 1:若该位为 1 时的前缀和仍 < k,说明答案在右侧,就扩展;否则缩小在左侧查找。共 O(log V) 步。


💡 章节联系: BIT 和线段树是 USACO 中最常配合使用的两个数据结构。BIT 用 1/5 的代码量处理 80% 的场景。掌握 BIT 后,回到第 5.7 章学习线段树懒惰传播——那是 BIT 无法触达的领域。

5.8.13 常见错误

❌ 错误一:lowbit 实现有误

// ❌ 错误 — 常见笔误
int lowbit(int x) { return x & (x - 1); }  // 这会清除最低位,而非返回它!
// x=6 (0110):x&(x-1) = 0110&0101 = 0100 = 4(错误,应为 2)

// ✅ 正确
int lowbit(int x) { return x & (-x); }
// x=6:-6 = ...11111010(补码)
// 0110 & 11111010 = 0010 = 2 ✓

记忆口诀: x & (-x) 读作「x 与负 x 相与」。-x 是按位取反加 1,保留最低位的 1,清除其下所有位,反转其上所有位,相与只保留最低位。

❌ 错误二:0-indexed 数组(0-indexed 陷阱)

BIT 必须使用 1-indexed 数组。0-indexed 会导致死循环!

// ❌ 错误 — 0-indexed 导致死循环!
// 如果 i = 0:query 循环:i -= lowbit(0) = 0 - 0 = 0 → 死循环!

// ✅ 正确 — 转换为 1-indexed
for (int i = 0; i < n; i++) {
    update(i + 1, arr[i]);  // 将 0-indexed 的 i 转换为 1-indexed 的 i+1
}
// 注意:对 0-indexed 范围 [l, r] 的查询用 query(r+1) - query(l)

❌ 错误三:大和的整数溢出

// ❌ 错误 — tree[] 对大和应该用 long long
int tree[MAXN];   // 和超过 2^31 时溢出

// ✅ 正确
long long tree[MAXN];

// 还有:统计逆序对时,逆序对数最多 N*(N-1)/2 ≈ 4.5×10^10(N=3×10^5)
// 结果计数器始终用 long long!
long long inversions = 0;  // ✅ 不是 int!

❌ 错误四:多组测试数据间忘记清空 BIT

📄 查看代码:❌ 错误四:多组测试数据间忘记清空 BIT
// ❌ 错误 — 多组测试数据时
int T; cin >> T;
while (T--) {
    // 忘记清空 tree[]!
    // 上一组测试数据的旧数据污染结果
    solve();
}

// ✅ 正确 — 每组测试数据前重置
int T; cin >> T;
while (T--) {
    fill(tree + 1, tree + n + 1, 0LL);  // 清空 BIT
    solve();
}

5.8.14 本章总结

📋 公式速查

操作代码描述
lowbitx & (-x)x 的最低位 1 的值
单点更新for(;i<=n;i+=lowbit(i)) t[i]+=v向上传播
前缀查询for(;i>0;i-=lowbit(i)) s+=t[i]向下分解
区间查询query(r) - query(l-1)差值公式
区间更新(差分 BIT)upd(l,+v); upd(r+1,-v)差分数组
逆序对计数(i-1) - query(a[i])处理每个元素时计数
权值 BIT 第 k 小在频率 BIT 上找最小的 x,使 query(x) >= k把值域当下标
数组必须1-indexed0-indexed → 死循环

🧩 从浅到深的完整脉络

  1. 先看矛盾:普通数组查询慢,前缀和修改慢。
  2. 再学 lowbit:它决定每个 tree[i] 管几个数。
  3. 理解盒子分工tree[i] 存的是以 i 结尾的一段和。
  4. 掌握两个方向:查询往前跳,更新往后跳。
  5. 套用区间查询sum(l,r)=query(r)-query(l-1)
  6. 进阶到差分 BIT:把区间修改变成两个差分点修改。
  7. 应用到计数问题:逆序对、频率统计、第 k 小都可以用 BIT。

❓ 常见问题

Q1:BIT 和线段树都支持前缀和 + 单点更新,该选哪个?

A:尽可能用 BIT。BIT 只有 10 行代码,常数更小(实测快 2-3 倍),出错概率更低。只有需要区间最小/最大(RMQ)、区间赋色或更复杂区间操作时才选线段树。竞赛中,BIT 是「默认武器」,线段树是「重型火炮」。

Q2:BIT 能支持区间最小查询(RMQ)吗?

A:标准 BIT 不能支持 RMQ,因为最小值运算没有「逆运算」(无法像减法那样「撤销」一次最小合并)。区间最小/最大需要用线段树或稀疏表。有一种「静态 BIT RMQ」技术,但只在无更新情况下有效,实际用处有限。

Q3:BIT 能做二维(2D BIT)吗?

A:可以!二维 BIT 解决二维前缀和 + 单点更新问题,复杂度 O(log N × log M)。代码结构使用两层嵌套循环:

// 二维 BIT 更新
void update2D(int x, int y, long long v) {
    for (int i = x; i <= N; i += lowbit(i))
        for (int j = y; j <= M; j += lowbit(j))
            bit[i][j] += v;
}

USACO 中不常见,但偶尔会在二维坐标计数题中用到。

二维树状数组(2D BIT)


5.8.15 练习题

🟢 简单一:区间求和(单点更新) 给定长度为 N 的数组,支持两种操作:

  1. 1 i x:A[i] 加 x
  2. 2 l r:查询 A[l] + A[l+1] + ... + A[r]

提示: BIT 的直接应用。用 update(i, x)query(r) - query(l-1)


🟢 简单二:小于 K 的元素个数 给定 N 次操作,每次要么插入一个整数(范围 1..10^6),要么查询「当前已插入的整数中有多少个 ≤ K?」

提示: BIT 维护值域上的频率数组。update(v, 1) 插入值 v,query(K) 是答案。


🟡 中等一:区间加法,单点查询 给定长度为 N 的数组(初始全零),支持两种操作:

  1. 1 l r x:给 A[l..r] 的每个元素加 x
  2. 2 i:查询 A[i] 的当前值

提示: 使用差分 BIT(第 5.8.10 节)。


🟡 中等二:逆序对计数(含坐标压缩) 给定长度为 N 的数组,元素范围 1..10^9(可能有重复),统计逆序对数量。

提示: 先坐标压缩,再用 BIT 计数(第 5.8.11 节的变体)。注意相等元素:(i,j) 满足 i<j 且 A[i]>A[j](严格大于)才算逆序对。


🔴 困难:区间加法,区间求和(双 BIT) 给定长度为 N 的数组,支持两种操作:

  1. 1 l r x:给 A[l..r] 的每个元素加 x
  2. 2 l r:查询 A[l] + ... + A[r]

提示: 用双 BIT。公式:prefix_sum(r) = B1[r] * r - B2[r],其中 B1 维护差分数组,B2 维护加权差分数组。

✅ 全部 BIT 练习题完整题解

🟢 简单一:区间求和

#include <bits/stdc++.h>
using namespace std;
const int MAXN = 100005;
int n, q;
long long tree[MAXN];
int lowbit(int x) { return x & (-x); }
void update(int i, long long val) { for (; i <= n; i += lowbit(i)) tree[i] += val; }
long long query(int i) { long long s=0; for (; i > 0; i -= lowbit(i)) s += tree[i]; return s; }
int main() {
    ios_base::sync_with_stdio(false); cin.tie(NULL);
    cin >> n >> q;
    while (q--) {
        int t; cin >> t;
        if (t == 1) { int i; long long x; cin >> i >> x; update(i, x); }
        else { int l, r; cin >> l >> r; cout << query(r) - query(l-1) << "\n"; }
    }
}

🟡 中等一:区间加法,单点查询(差分 BIT) 核心思路:在 BIT 中维护差分数组。range_add(l,r,x) = update(l,x) + update(r+1,-x)。单点查询 = query(i)

void range_add(int l, int r, long long x) { update(l, x); update(r+1, -x); }
long long point_query(int i) { return query(i); }

🟡 中等二:逆序对计数

// 先坐标压缩,然后对每个元素 x:
// 逆序对 += (已插入的元素数) - query(压缩后的 x)
// 然后插入 x:update(压缩后的 x, 1)

🔴 困难:区间加法,区间求和(双 BIT)

// prefix_sum(r) = (r+1)*sum(D[1..r]) - sum(i*D[i], i=1..r)
// = (r+1)*B1.query(r) - B2.query(r)
// 其中 B1 存 D[i],B2 存 i*D[i]
struct DoubleBIT {
    long long B1[MAXN], B2[MAXN];
    int n;
    DoubleBIT(int n) : n(n) { memset(B1,0,sizeof(B1)); memset(B2,0,sizeof(B2)); }
    void add(int i, long long v) {
        for (int x=i; x<=n; x+=x&-x) { B1[x]+=v; B2[x]+=v*i; }
    }
    void range_add(int l, int r, long long v) { add(l,v); add(r+1,-v); }
    long long prefix(int i) {
        long long s=0; for(int x=i;x>0;x-=x&-x) s+=(i+1)*B1[x]-B2[x]; return s;
    }
    long long range_query(int l, int r) { return prefix(r)-prefix(l-1); }
};

🧠 第六部分:动态规划

竞赛编程中最强大也最需要训练的主题。掌握记忆化、递推、经典模型、折半枚举与数位计数,逐步跨过 USACO Silver 到 Gold 的门槛。

📚 5 章 · ⏱️ 预计 4-6 周 · 🎯 目标:USACO Silver → Gold

第六部分:动态规划

预计用时:4-6 周

动态规划不是背公式,而是把一个大问题拆成许多会重复出现的小问题。你需要学会定义状态、设计转移、处理边界,并判断什么时候 DP 不是最好的工具。本部分从最基础的一维 DP 开始,逐步过渡到背包、LIS、状压、区间、树形、折半搜索和数位 DP。


涵盖的主题

章节主题核心思想目标层级
第 6.1 章DP 入门记忆化、递推、DP 四步法Bronze/Silver
第 6.2 章经典 DP 问题LIS、背包、网格路径、方案数Silver
第 6.3 章进阶 DP 模式状压 DP、区间 DP、树形 DPSilver/Gold
第 6.4 章折半搜索2^N 拆成两个 2^(N/2) 再合并Gold
第 6.5 章数位 DP按位填数,统计 [L, R] 内满足数字性质的数Gold

学完本部分后能解决什么问题

完成第六部分后,你将能够挑战:

  • USACO Bronze:

    • 简单计数问题(做某件事有多少种方法?)
    • 基本优化问题(最小代价、最大收益)
  • USACO Silver:

    • 最长递增子序列及其变体
    • 0/1 背包、完全背包、二维费用背包
    • 网格路径计数与最大价值路径
    • 需要精确定义状态的一维/二维 DP
  • USACO Gold:

    • 状压 DP、区间 DP、树形 DP
    • N≈40 的指数枚举题:使用折半搜索把复杂度减半
    • [L, R] 内数字性质计数题:使用数位 DP 处理 10^18 级别范围
    • 多状态组合:数字和、余数、上一位、出现次数、上下界约束

需要掌握的关键 DP 模式

模式章节示例题目关键提醒
一维 DP(顺序)6.1斐波那契、爬楼梯先写状态含义,再写转移
一维 DP(优化)6.1硬币找零(最少硬币)最小值初始化为 INF
一维 DP(计数)6.1硬币找零(方案数)初始方案数通常是 dp[0]=1
二维 DP6.20/1 背包、网格路径明确每一维代表什么
LIS6.2O(N²) 与 O(N log N) LIStails 数组不是实际 LIS
状压 DP6.3TSP、任务分配mask 表示集合状态
区间 DP6.3矩阵链乘法、合并石子按区间长度递增填表
树形 DP6.3树上独立集后序遍历先算子树
折半搜索6.4子集和、最大不超过 X 的子集和2^40 不可做,两个 2^20 可做
数位 DP6.5不含某数字、数字和、整除性tightstarted 是核心

前置条件

开始第六部分前,请确认你能做到:

  • 编写递归函数并理解调用栈(第 2.3 章)
  • 熟练使用数组、二维向量和简单结构体(第 2.3 章)
  • 理解排序、二分查找和 lower_bound / upper_bound(第 3.3 章)
  • 理解位运算与 bitmask 枚举(第 2.6 章)
  • 能解决基础 DFS/BFS 题目(第 5.2 章)——DP 和图搜索都在探索状态空间

DP 思维方式

DP 不是死记公式,而是反复问四个问题:

  1. 状态是什么? 描述一个子问题需要哪些信息?
  2. 转移是什么? 当前状态能从哪些更小或更简单的状态得到?
  3. 初始条件是什么? 哪些状态不用计算就知道答案?
  4. 计算顺序是什么? 依赖必须先于被依赖者计算。

💡 核心思路: 如果暴力搜索中同一个子问题被反复计算,DP 就是把它缓存起来;如果暴力枚举太大但可以分成两半独立处理,折半搜索可能比 DP 更自然;如果范围大到不能枚举每个整数,但限制只和数字有关,数位 DP 往往是答案。


本部分学习建议

  1. 第 6.1 章不要跳。 你要真正理解“状态”和“转移”,而不是只记代码。
  2. 每个经典题至少写两遍。 一遍记忆化搜索,一遍递推填表;两种视角互相验证。
  3. 第 6.2 章重点练背包和 LIS。 它们是很多更难题的底层模型。
  4. 第 6.3 章开始进入 Silver/Gold。 状压、区间、树形 DP 都需要更强的状态设计能力。
  5. 第 6.4 章不是传统 DP,但和 DP 同样重要。 当你看到 N≤40 和子集枚举时,应立即想到折半搜索。
  6. 第 6.5 章要画状态。 数位 DP 的 tightstartedpos 最容易混,建议用小数字手工追踪。
  7. 所有 DP 都要检查边界。 空集、0、负数、L=0、数组维度、INF 溢出,都是常见陷阱。

⚠️ 警告: DP 第 1 号 bug:在最小化 DP 中使用 dp[w-c] 前忘记检查 dp[w-c] != INFINF + 1 会溢出!

DP 第 2 号 bug:0/1 背包 vs 完全背包的循环顺序搞错了。倒序迭代 = 每件物品最多用一次;正序迭代 = 无限次使用。

Gold DP 第 1 号 bug:状态维度缺了一维。比如数位 DP 少了 tightstarted,答案通常会在边界样例上错。

📖 第 6.1 章 ⏱️ 约 65 分钟 🎯 中级

第 6.1 章:动态规划入门

📝 前置条件: 确保理解递归(第 2.3 章)、数组/向量(第 2.3–3.1 章)和基本循环模式(第 2.2 章)。DP 直接建立在递归概念之上。

动态规划(DP)常被描述为「带记忆的聪明递归」。让我们从最简单的例子——斐波那契数列——从零建立这种直觉。

💡 核心思路: DP 解决具有两个性质的问题:

  1. 重叠子问题 —— 相同的子计算出现多次
  2. 最优子结构 —— 大问题的最优解可以由小问题的最优解构建

两者同时成立时,DP 将指数时间转化为多项式时间。


📚 本章学习地图

小节主题你会学到什么
§6.1.1朴素递归的问题为什么暴力递归会指数爆炸
§6.1.2记忆化如何用缓存消除重复子问题
§6.1.3递推如何把递归改写成 DP 表
§6.1.4DP 四步法状态、转移、初始条件、填表顺序
§6.1.5最少硬币找零从「选择最后一步」推导递推式
§6.1.6方法数变体为什么循环顺序决定排列/组合
§6.1.7状态设计四问如何从题面条件设计 dp 状态
§6.1.8USACO 真题训练HPS 与 Teamwork 的状态设计实战

🧭 建议阅读方式: 初学者先完整读 §6.1.1–§6.1.4,确认能解释「为什么要缓存」和「表格怎么填」。如果你已经会基础 DP,可以直接跳到 §6.1.7–§6.1.8,重点训练从题面设计状态。


6.1.1 朴素递归的问题

斐波那契数列:0, 1, 1, 2, 3, 5, 8, 13, 21, ...

定义: F(0) = 0,F(1) = 1,F(n) = F(n-1) + F(n-2)(n ≥ 2)。

图示:斐波那契递归树和记忆化

fib(5) 的递归树暴露了问题:fib(3) 被计算了两次(红色节点)。记忆化在第一次计算时缓存每个结果,将 2^N 次调用减少到仅 N 次唯一调用——这是动态规划背后的基本洞察。

Fibonacci Memoization

朴素递归实现:

int fib(int n) {
    if (n == 0) return 0;
    if (n == 1) return 1;
    return fib(n-1) + fib(n-2);  // 递归
}

这是正确的,但慢得可怕

📄 这是正确的,但**慢得可怕**:
fib(5)
├── fib(4)
│   ├── fib(3)
│   │   ├── fib(2)
│   │   │   ├── fib(1) = 1
│   │   │   └── fib(0) = 0
│   │   └── fib(1) = 1
│   └── fib(2)           ← 再次计算!
│       ├── fib(1) = 1
│       └── fib(0) = 0
└── fib(3)               ← 再次计算!
    ├── fib(2)            ← 再次计算!
    │   ├── fib(1) = 1
    │   └── fib(0) = 0
    └── fib(1) = 1

fib(3) 被计算了两次,fib(2) 三次。对 fib(50),调用次数超过 10^10。这是指数时间:O(2^n)

核心洞察:我们在一遍遍重复计算相同的子问题。DP 解决了这个问题。


6.1.2 记忆化(自顶向下 DP)

记忆化 = 递归 + 缓存。计算之前,检查是否已经计算过这个值。若是,返回缓存的结果;若否,计算它、缓存它、返回它。

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

const int MAXN = 100;
long long memo[MAXN];

long long fib(int n) {
    if (n <= 1) return n;
    if (memo[n] != -1) return memo[n];
    return memo[n] = fib(n-1) + fib(n-2);
}

int main() {
    fill(memo, memo + MAXN, -1LL);  // 将所有值初始化为 -1(「未计算」标记)
    cout << fib(50) << "\n";        // 12586269025
    return 0;
}

现在每个值被计算恰好一次。时间复杂度:O(N)。🎉


6.1.3 递推(自底向上 DP)

递推从头开始构建答案——先计算小子问题,用它们计算更大的问题。

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

int main() {
    int n = 50;
    vector<long long> dp(n + 1);

    // 初始条件
    dp[0] = 0;
    dp[1] = 1;

    // 自底向上填表
    for (int i = 2; i <= n; i++) {
        dp[i] = dp[i-1] + dp[i-2];  // 使用已计算的值
    }

    cout << dp[n] << "\n";  // 12586269025
    return 0;
}

手动追踪:dp 数组如何变化

只看代码时,初学者很容易知道“循环写对了”,但不知道数组到底怎么被填出来。以 n = 6 为例,dp 数组会这样变化:

步骤新算出的值dp[0..6]
初始dp[0] = 0, dp[1] = 1[0, 1, _, _, _, _, _]
i = 2dp[2] = dp[1] + dp[0] = 1[0, 1, 1, _, _, _, _]
i = 3dp[3] = dp[2] + dp[1] = 2[0, 1, 1, 2, _, _, _]
i = 4dp[4] = dp[3] + dp[2] = 3[0, 1, 1, 2, 3, _, _]
i = 5dp[5] = dp[4] + dp[3] = 5[0, 1, 1, 2, 3, 5, _]
i = 6dp[6] = dp[5] + dp[4] = 8[0, 1, 1, 2, 3, 5, 8]

这个表格就是递推代码的真实执行过程:每次循环只负责填一个新格子,并且只依赖已经填好的旧格子。

甚至可以优化空间:由于每个斐波那契数只依赖前两个,只需 O(1) 空间:

long long a = 0, b = 1;
for (int i = 2; i <= n; i++) {
    long long c = a + b;
    a = b;
    b = c;
}
cout << b << "\n";

记忆化 vs 递推

对 fib(4) 两种方式对比:

Memoization vs Tabulation

💡 核心区别: 自顶向下按需计算(只算用到的子问题),自底向上全量填表(按顺序算所有子问题)。两者时间复杂度相同,但自底向上没有递归栈开销。

方面记忆化(自顶向下)递推(自底向上)
方式递归加缓存迭代填表
内存使用只有已计算的状态所有状态(包括未用到的)
实现通常更直观可能需要想清楚填充顺序
栈溢出风险有(深度递归)
速度稍慢(函数调用开销)稍快
USACO 偏好适合理解和思考适合最终提交

🏆 USACO 技巧: 竞赛中自底向上递推略有优势,因为它避免了潜在的栈溢出(在 N = 10^5 的题目中很关键),通常也更快。但若难以看清递推关系,先用自顶向下——这是一种很好的思考方式。


6.1.4 DP 四步法

每道 DP 题都遵循相同的做法:

DP 四步法——从状态定义到空间优化:

DP 4-Step Recipe

  1. 定义状态: 什么信息能唯一描述一个子问题?
  2. 定义递推: dp[状态] 如何依赖更小的状态?
  3. 确定初始条件: 最简单子问题的答案是什么?
  4. 确定顺序: 以什么顺序填表?

应用到斐波那契:

  1. 状态: dp[i] = 第 i 个斐波那契数
  2. 递推: dp[i] = dp[i-1] + dp[i-2]
  3. 初始条件: dp[0] = 0dp[1] = 1
  4. 顺序: i 从 2 到 n(每个依赖更小的 i)

学 DP 时一定要补上的第 5 步:手动追踪

很多 DP 题看懂状态和转移后,真正卡住的地方并不是代码语法,而是:不知道循环执行时 dp 数组到底怎样一步步变化。

因此每学一道新 DP 题,都建议拿一个很小的样例,手动画出:

  1. 初始数组是什么样。 哪些位置是 0,哪些位置是 INF 或“不可能”。
  2. 外层循环每走一步,新增或更新了哪些状态。 例如填完 i = 3 后,dp[0..3] 分别是多少。
  3. 某个格子为什么变成这个值。 它来自哪个旧状态?有没有和其他候选值取 min / max
  4. 最终答案从哪里取。dp[n],还是要在一组状态里再取最大值/最小值。

后面的例题会加入更多这样的“手动追踪表”,帮助你把递推式和代码执行过程对应起来。


6.1.5 硬币找零——经典 DP

题目: 有面额为 coins[] 的硬币,凑出金额 W 最少需要多少枚?每种面额可以无限次使用。

示例: coins = [1, 5, 6, 9],W = 11

先试试贪心(每次选最大的 ≤ 剩余金额):

  • 贪心:9 + 1 + 1 = 3 枚 ← 不是最优!
  • 最优:5 + 6 = 2 枚 ← DP 能找到

这就是为什么贪心在这里失败,需要 DP。

图示:硬币找零 DP 表

DP 表展示了 dp[i](凑出金额 i 的最少硬币数)从左到右的填写过程。对硬币 {1,3,4},注意 dp[3]=1(直接用硬币 3)和 dp[6]=2(用两个 3)。

Coin Change DP

DP 定义

对 coins = {1, 5, 6} 的状态转移:

Coin Change State Transitions

  • 状态: dp[w] = 凑出恰好金额 w 的最少硬币数
  • 递推: dp[w] = 1 + min(对所有 c ≤ w 的硬币 c:dp[w - c])(使用硬币 c,然后最优地解决剩余的 w-c)
  • 初始条件: dp[0] = 0(凑出金额 0 需要 0 枚)
  • 答案: dp[W]
  • 顺序: w 从 1 到 W

完整演示:coins = [1, 5, 6, 9],W = 11

📄 查看代码:完整演示:coins = [1, 5, 6, 9],W = 11
dp[0] = 0 (初始条件)

dp[1]:用硬币 1:dp[0]+1=1          → dp[1] = 1
dp[2]:用硬币 1:dp[1]+1=2          → dp[2] = 2
...
dp[5]:用硬币 1:dp[4]+1=5
        用硬币 5:dp[0]+1=1          → dp[5] = 1  ← 用 5 分硬币!
dp[6]:用硬币 1:dp[5]+1=2
        用硬币 5:dp[1]+1=2
        用硬币 6:dp[0]+1=1          → dp[6] = 1  ← 用 6 分硬币!
...
dp[11]:用硬币 5:dp[6]+1=2
         用硬币 6:dp[5]+1=2          → dp[11] = 2  ← 5+6 或 6+5!

dp 表:[0, 1, 2, 3, 4, 1, 1, 2, 3, 1, 2, 2]

答案:dp[11] = 2(硬币 5 和 6)✓

手动追踪:每一轮金额填完后的 dp 数组

下面这张表展示 coins = [1, 5, 6, 9]W = 11 时,外层循环每处理完一个金额 w 后,dp[0..11] 的变化。 表示当前还凑不出来。

已处理金额dp[0..11]
初始[0, ∞, ∞, ∞, ∞, ∞, ∞, ∞, ∞, ∞, ∞, ∞]
w = 1[0, 1, ∞, ∞, ∞, ∞, ∞, ∞, ∞, ∞, ∞, ∞]
w = 2[0, 1, 2, ∞, ∞, ∞, ∞, ∞, ∞, ∞, ∞, ∞]
w = 3[0, 1, 2, 3, ∞, ∞, ∞, ∞, ∞, ∞, ∞, ∞]
w = 4[0, 1, 2, 3, 4, ∞, ∞, ∞, ∞, ∞, ∞, ∞]
w = 5[0, 1, 2, 3, 4, 1, ∞, ∞, ∞, ∞, ∞, ∞]
w = 6[0, 1, 2, 3, 4, 1, 1, ∞, ∞, ∞, ∞, ∞]
w = 7[0, 1, 2, 3, 4, 1, 1, 2, ∞, ∞, ∞, ∞]
w = 8[0, 1, 2, 3, 4, 1, 1, 2, 3, ∞, ∞, ∞]
w = 9[0, 1, 2, 3, 4, 1, 1, 2, 3, 1, ∞, ∞]
w = 10[0, 1, 2, 3, 4, 1, 1, 2, 3, 1, 2, ∞]
w = 11[0, 1, 2, 3, 4, 1, 1, 2, 3, 1, 2, 2]

注意 w = 5w = 6w = 9 这些位置会突然变小,因为它们可以直接使用面额为 569 的硬币。

📄 C++ 完整代码
// 最少硬币找零 — O(N × W)
#include <bits/stdc++.h>
using namespace std;

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

    int n, W;
    cin >> n >> W;

    vector<int> coins(n);
    for (int &c : coins) cin >> c;

    const int INF = 1e9;
    vector<int> dp(W + 1, INF);  // dp[w] = 凑出 w 的最少硬币数
    dp[0] = 0;                    // 初始条件

    for (int w = 1; w <= W; w++) {
        for (int c : coins) {
            if (c <= w && dp[w - c] != INF) {
                dp[w] = min(dp[w], dp[w - c] + 1);  // ← 关键行
            }
        }
    }

    if (dp[W] == INF) {
        cout << "Impossible\n";
    } else {
        cout << dp[W] << "\n";
    }

    return 0;
}

复杂度分析:

  • 时间: O(N × W) —— 对每个金额 w(1..W),尝试所有 N 种硬币
  • 空间: O(W) —— 只需 dp 数组

6.1.6 方法数——硬币找零变体

题目: 用给定硬币凑出金额 W 有多少种不同方法?每种硬币可以使用无限次。

这一类题和“最少硬币数”很像,但目标变了:

  • 最少硬币数:求 min
  • 方法数:求 sum

先定义状态:ways[w] 表示什么?

ways[w] = 凑出金额 w 的方法数

最重要的初始条件是:

ways[0] = 1;

这句话一开始很反直觉:金额 0 为什么有 1 种方法?

因为它表示“什么都不选”这一种空方案。它的作用是给后面的转移提供起点。例如如果有一枚硬币 5,那么:

ways[5] += ways[5 - 5]
ways[5] += ways[0]

如果 ways[0] 不是 1,就无法正确产生“只用一枚 5 元硬币”这 1 种方法。


情况一:有序方法数(排列)

有序表示顺序重要:[1, 5][5, 1] 是两种不同方法。

这时我们按“最后一枚硬币是什么”来思考。

如果要凑出金额 w,最后一枚硬币可能是任意 c。那么前面的部分必须凑出 w - c

ways[w] += ways[w - c]

代码如下:

vector<long long> ways(W + 1, 0);
ways[0] = 1;  // 凑出 0:一种方法(不用硬币)

for (int w = 1; w <= W; w++) {
    for (int c : coins) {
        if (c <= w) {
            ways[w] += ways[w - c];
        }
    }
}

注意外层循环是金额 w。这代表:我们依次计算 ways[1]ways[2]ways[3]……每个 ways[w] 都会尝试把每种硬币放在最后。

手动追踪:有序方法数

coins = [1, 2]W = 4 为例。

当前金额 w转移过程更新后的 ways[0..4]
初始ways[0] = 1[1, 0, 0, 0, 0]
w = 1最后一枚只能是 1ways[1] += ways[0] = 1[1, 1, 0, 0, 0]
w = 2最后是 1:接在凑出 1 的方法后;最后是 2:接在凑出 0 的方法后[1, 1, 2, 0, 0]
w = 3最后是 1ways[2] = 2;最后是 2ways[1] = 1;总共 3[1, 1, 2, 3, 0]
w = 4最后是 1ways[3] = 3;最后是 2ways[2] = 2;总共 5[1, 1, 2, 3, 5]

所以有序时,凑出 4 的 5 种方法是:

1 + 1 + 1 + 1
1 + 1 + 2
1 + 2 + 1
2 + 1 + 1
2 + 2

可以看到,1 + 1 + 21 + 2 + 12 + 1 + 1 被算作三种不同方法,因为顺序不同。


情况二:无序方法数(组合)

无序表示顺序不重要:[1, 5][5, 1] 是同一种方法。

这时不能再按“最后一枚硬币”来算,因为这样会把不同顺序重复统计。我们要换一种思路:按硬币种类逐个引入。

代码如下:

vector<long long> ways(W + 1, 0);
ways[0] = 1;

for (int c : coins) {               // 外层循环:硬币种类
    for (int w = c; w <= W; w++) {   // 内层循环:金额
        ways[w] += ways[w - c];
    }
}

这段代码的含义是:

  • 处理硬币 1 后,ways[w] 表示“只使用硬币 1”能凑出 w 的方法数。
  • 再处理硬币 2 后,ways[w] 表示“只使用硬币 12”能凑出 w 的方法数。
  • 如果还有硬币 5,处理完后,ways[w] 表示“只使用硬币 125”能凑出 w 的方法数。

因为硬币种类是按固定顺序引入的,所以同一组硬币不会因为排列顺序不同而被重复计算。

手动追踪:无序方法数

仍然看 coins = [1, 2]W = 4

已处理硬币更新过程更新后的 ways[0..4]
初始ways[0] = 1[1, 0, 0, 0, 0]
处理硬币 1ways[1] += ways[0]ways[2] += ways[1]ways[3] += ways[2]ways[4] += ways[3][1, 1, 1, 1, 1]
处理硬币 2w = 2新增 2ways[2] += ways[0][1, 1, 2, 1, 1]
处理硬币 2w = 3新增 1 + 2ways[3] += ways[1][1, 1, 2, 2, 1]
处理硬币 2w = 4新增 2 + 21 + 1 + 2ways[4] += ways[2][1, 1, 2, 2, 3]

所以无序时,凑出 4 的 3 种方法是:

1 + 1 + 1 + 1
1 + 1 + 2
2 + 2

这里不会再单独统计 1 + 2 + 12 + 1 + 1,因为它们和 1 + 1 + 2 使用的是同一组硬币。


为什么只是交换两层循环,答案就不同?

关键在于 ways[w] 在循环中代表的含义不同。

写法外层循环ways[w] 的含义统计结果
有序金额 w当前金额的所有排列方法数排列数,顺序重要
无序硬币 c只使用已经处理过的硬币种类时的方法数组合数,顺序不重要

可以用一句话记住:

金额在外层,枚举的是“最后一步”,所以会统计顺序;硬币在外层,枚举的是“使用哪些硬币种类”,所以不会统计顺序。

[1, 5][5, 1] 理解重复统计

假设 coins = [1, 5],要凑出 6

有序写法中,计算 ways[6] 时:

  • 最后一枚是 1:来自 ways[5],其中包含 [5],于是得到 [5, 1]
  • 最后一枚是 5:来自 ways[1],其中包含 [1],于是得到 [1, 5]

所以 [1, 5][5, 1] 会被分开统计。

无序写法中,先处理硬币 1,再处理硬币 5。当处理硬币 5 时,只会把 5 加到“已经由硬币 1 组成的方法”后面,于是得到的是同一种组合 {1, 5},不会再倒过来统计一次。


易错点提醒

  1. 忘记 ways[0] = 1 这样所有方法数都会变成 0,因为没有起点。
  2. 把排列和组合的循环顺序写反。 如果题目说“顺序不同算不同方法”,金额放外层;如果题目说“硬币组合”,硬币放外层。
  3. 方法数可能很大。 题目通常会要求对某个数取模,例如 1e9 + 7,这时每次加法后都要 % MOD

6.1.7 状态设计四问:从题目到 DP 表

很多同学觉得 DP 难,不是因为代码难,而是因为不知道 dp[...] 到底该表示什么。遇到新题时,先不要急着写递推式,按下面四个问题拆开:

问题你要找什么常见信号
1. 处理到哪里?前 i 个元素、前 i 天、前 i 轮序列、天数、轮次、位置
2. 还需要记住什么?当前状态、剩余次数、上一次选择「最多换 K 次」「当前手势」「背包容量」
3. 当前一步有哪些选择?选/不选、换/不换、分组长度「可以」「最多」「任意连续一段」
4. 答案怎么从状态中取出?最大值、最小值、方案数max / min / sum

💡 状态设计口诀: dp 状态必须刚好包含「决定未来所需的信息」。少了会无法转移,多了会爆内存或超时。

6.1.8 USACO 真题训练:状态设计实战

真题 1:Hoof, Paper, Scissors(USACO 2017 January Gold)— 三维状态 DP

题目链接: USACO 2017 January Gold P2: Hoof, Paper, Scissors
对应模式: 序列 DP + 有限次状态切换
难度定位: Gold 入门

题干解读

Bessie 和 FJ 玩 N 轮 Hoof, Paper, Scissors。三种手势分别是 H(Hoof)、P(Paper)、S(Scissors),胜负关系为:HSPHSP

FJ 接下来 N 轮会出的手势已经提前给出。Bessie 可以根据这份完整序列来安排自己每一轮的手势,但她最多只能改变手势 K 次。也就是说,Bessie 第 1 轮前选择初始手势不算改变;之后只有当第 i 轮和第 i-1 轮使用的手势不同时,才算改变 1 次。目标是让 Bessie 赢的轮数尽可能多。

例如,如果 K = 0,Bessie 只能整场都出同一种手势;如果 K = 1,她最多可以先连续出一种手势,再在某一轮之后切换成另一种手势。

关键条件:

  • N 很大,不能枚举所有手势序列。
  • K 较小,说明「换了几次」适合作为 DP 维度。
  • 当前出什么手势会影响下一轮是否算「改变」,所以当前手势也必须记住。

思路分析

状态定义:

dp[i][j][g] = 处理前 i 轮,已经换了 j 次,当前手势为 g 的最多胜场

i 轮可以:

  • 继续用手势 g:不增加切换次数。
  • 从其他手势切换到 g:切换次数 +1。

每轮还要加上 g 是否能赢 FJ 第 i 轮手势的得分。

手动追踪:三维状态如何变化

三维数组不适合完整画出来,可以把 dp[i][used][g]used 分层看。下面用一个小样例:

N = 4, K = 1
FJ = H S H P

表中 [H, P, S] 表示当前手势分别为 H/P/S 时的最多胜场,- 表示这个状态不可能出现。

已处理轮数FJ 当前手势used = 0[H, P, S]used = 1[H, P, S]
i = 1H[0, 1, 0][-, -, -]
i = 2S[1, 1, 0][2, 0, 1]
i = 3H[1, 2, 0][2, 2, 1]
i = 4P[1, 2, 1][2, 2, 3]

读这张表时,可以抓住两个动作:

  • 不换手势:例如 i = 3, used = 0, P 来自上一轮 used = 0, P,再加上 PH 的 1 分,所以从 1 变成 2
  • 换手势:例如 i = 4, used = 1, S 可以从上一轮 used = 0, P 切换而来。上一轮 P 的最好成绩是 2,这一轮 SP 再加 1 分,所以得到 3

最终答案是所有 used <= K、所有当前手势中的最大值,也就是 3

CPP 完整代码

✅ 完整代码:Hoof, Paper, Scissors Gold
#include <bits/stdc++.h>
using namespace std;

int gestureId(char c) {
    if (c == 'H') return 0;
    if (c == 'P') return 1;
    return 2;  // 'S'
}

bool win(int bessie, int fj) {
    // H beats S, P beats H, S beats P
    return (bessie == 0 && fj == 2) ||
           (bessie == 1 && fj == 0) ||
           (bessie == 2 && fj == 1);
}

int main() {
    ios::sync_with_stdio(false);
    cin.tie(nullptr);

    // USACO 官方文件 I/O 可按需打开:
    // freopen("hps.in", "r", stdin);
    // freopen("hps.out", "w", stdout);

    int n, k;
    cin >> n >> k;
    vector<int> fj(n + 1);
    for (int i = 1; i <= n; i++) {
        char c;
        cin >> c;
        fj[i] = gestureId(c);
    }

    const int NEG = -1e9;
    vector dp(n + 1, vector(k + 1, vector<int>(3, NEG)));

    // 第 1 轮可以直接选择任意手势,不算切换
    for (int g = 0; g < 3; g++) {
        dp[1][0][g] = win(g, fj[1]) ? 1 : 0;
    }

    for (int i = 2; i <= n; i++) {
        for (int used = 0; used <= k; used++) {
            for (int cur = 0; cur < 3; cur++) {
                int score = win(cur, fj[i]) ? 1 : 0;

                // 不换手势
                dp[i][used][cur] = max(dp[i][used][cur], dp[i - 1][used][cur] + score);

                // 从其他手势切换到 cur
                if (used > 0) {
                    for (int prev = 0; prev < 3; prev++) {
                        if (prev == cur) continue;
                        dp[i][used][cur] = max(dp[i][used][cur], dp[i - 1][used - 1][prev] + score);
                    }
                }
            }
        }
    }

    int answer = 0;
    for (int used = 0; used <= k; used++) {
        for (int g = 0; g < 3; g++) {
            answer = max(answer, dp[n][used][g]);
        }
    }

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

复杂度: O(N × K × 3 × 3),由于手势只有 3 种,可视为 O(NK);空间 O(NK),可用滚动数组优化到 O(K)

易错点提醒

  1. 把「最多 K 次」写成「恰好 K 次」。 最后答案要枚举 used = 0..K
  2. 初始状态错误。 第 1 轮可以直接选择任意手势,不算切换,所以 dp[1][0][g] 应初始化为第 1 轮得分。
  3. 胜负关系写反。 H/P/S 与石头剪刀布类似,但名字不同,建议写独立 win() 函数。
  4. 状态缺少当前手势。 如果只写 dp[i][j],无法判断下一轮是否发生切换。

拓展思考

如果手势种类从 3 种变成 M 种,复杂度会变为 O(NKM^2)。此时可以维护每个 used 层的最大值和次大值,将切换转移优化到 O(NKM)


真题 2:Teamwork(USACO 2018 December Gold)— 枚举最后一组

题目链接: USACO 2018 December Gold P3: Teamwork
对应模式: 一维 DP + 枚举最后一段
难度定位: Gold 入门

题干解读

N 头奶牛排成一行,每头有技能值。可以把连续奶牛分成若干组,每组长度不超过 K。一组中所有奶牛的贡献都变成该组最大技能值。求最大总贡献。

关键条件:

  • 分组必须是连续段。
  • 每组长度最多 K
  • 每一段的贡献是 长度 × 段内最大值

思路分析

从前往后考虑。设:

dp[i] = 前 i 头奶牛分组后的最大总贡献

最后一组可能长度为 len = 1..K,覆盖 [i-len+1, i]。只要枚举最后一组长度,就能把问题拆成:

dp[i] = max(dp[i-len] + len * max(skill[i-len+1..i]))

枚举 len 时同步维护当前段最大值,避免每次重新扫描。

手动追踪:dp[i] 如何一步步更新

用一个小样例观察代码执行过程:

N = 5, K = 3
skill = [1, 15, 7, 9, 2]

dp[i] 表示前 i 头奶牛已经分好组后的最大总贡献。每一行都在枚举“最后一组”的长度。

i枚举最后一组候选值dp[i]当前 dp[0..i]
1[1]dp[0] + 1×1 = 11[0, 1]
2[15], [1,15]dp[1]+15×1=16, dp[0]+15×2=3030[0, 1, 30]
3[7], [15,7], [1,15,7]37, 31, 4545[0, 1, 30, 45]
4[9], [7,9], [15,7,9]54, 48, 4654[0, 1, 30, 45, 54]
5[2], [9,2], [7,9,2]56, 63, 5763[0, 1, 30, 45, 54, 63]

这张表对应代码里的内层循环:len1 增加到 KgroupMax 一边向左扩展最后一组,一边维护这组里的最大技能值。

CPP 完整代码

✅ 完整代码:Teamwork
#include <bits/stdc++.h>
using namespace std;

int main() {
    ios::sync_with_stdio(false);
    cin.tie(nullptr);

    // USACO 官方文件 I/O 可按需打开:
    // freopen("teamwork.in", "r", stdin);
    // freopen("teamwork.out", "w", stdout);

    int n, k;
    cin >> n >> k;
    vector<int> skill(n + 1);
    for (int i = 1; i <= n; i++) cin >> skill[i];

    vector<int> dp(n + 1, 0);

    for (int i = 1; i <= n; i++) {
        int groupMax = 0;
        for (int len = 1; len <= k && i - len + 1 >= 1; len++) {
            groupMax = max(groupMax, skill[i - len + 1]);
            dp[i] = max(dp[i], dp[i - len] + groupMax * len);
        }
    }

    cout << dp[n] << "\n";
    return 0;
}

复杂度: 外层 N,内层最多 K,总时间 O(NK);空间 O(N)

易错点提醒

  1. 最后一组左端点越界。 条件必须包含 i - len + 1 >= 1
  2. 每次重新求区间最大值。 会变成 O(NK^2);应在枚举 len 时维护 groupMax
  3. 状态定义不清。 dp[i] 必须表示「前 i 头已经完全分好组」,这样 dp[i-len] 才能无缝接上最后一组。
  4. 误以为是贪心。 每次把强奶牛尽量扩组不一定最优,因为会影响后面组的边界。

拓展思考

Teamwork 是「枚举最后一段」DP 的典型例子。类似模式还会出现在:分割数组、文本换行、区间分段最优化中。识别信号是:答案由若干连续段组成,每段有独立贡献,段长有限制。


⚠️ 第 6.1 章常见错误

  1. 最小化问题用 0 而非 INF 初始化 dp: dp[w] = 0 表示「0 枚硬币」,永远不会被改善。用 dp[w] = INF,只有 dp[0] = 0
  2. 使用 dp[w-c] 前不检查 dp[w-c] != INF INF + 1 会溢出!始终检查子问题是否可解。
  3. 背包变体的循环顺序错误: 无界背包(硬币无限),金额正向循环;0/1 背包(每个只用一次),金额反向循环。搞错这一点会给出静默的错误答案。
  4. INT_MAX 作为 INF 然后加 1: INT_MAX + 1 溢出成负数。用 1e91e18 作为 INF。
  5. 忘记初始条件: dp[0] = 0 至关重要,没有它什么都设不好。

本章总结

📌 核心要点

概念要点何时使用
重叠子问题相同计算指数级重复递归树中有重复调用
记忆化(自顶向下)缓存递归结果;易于编写递归结构清晰时
递推(自底向上)迭代填表;无栈溢出最终竞赛提交;大 N
DP 状态唯一标识子问题的信息仔细定义——决定了一切
DP 递推dp[状态] 如何依赖更小状态「转移方程」
初始条件最简单子问题的已知答案通常 dp[0] = 某个平凡值

🧩 DP 四步法速查

步骤问题斐波那契示例
1. 定义状态"dp[i] 代表什么?"dp[i] = 第 i 个斐波那契数
2. 写递推"dp[i] 依赖哪些更小的状态?"dp[i] = dp[i-1] + dp[i-2]
3. 确定初始条件"最小子问题的答案是什么?"dp[0]=0,dp[1]=1
4. 确定填充顺序"i 从小到大?从大到小?"i 从 2 到 n

❓ 常见问题

Q1:怎么判断一道题是 DP 题?

A:两个信号:① 题目问「最优值」或「方法数」(不是「输出具体方案」);② 存在重叠子问题(暴力递归中相同子问题被计算多次)。若贪心能被证明正确,通常不需要 DP;否则很可能是 DP。

Q2:应该用自顶向下还是自底向上?

A:学习时用自顶向下(更自然地表达递归思维);竞赛提交用自底向上(更快,无栈溢出)。两者都正确。若能快速写出自底向上,直接用它。

Q3:什么是「最优子结构」(无后效性)?

A:DP 的核心前提条件——一旦 dp[i] 确定,后续计算不会「回来」修改它。换句话说,dp[i] 的值只依赖于「过去」(更小的状态),而不是「未来」。若违反这个性质,不能用 DP。

Q4:INF 应该设为多少?

A:int 类型用 1e9(= 10^9),long long 类型用 1e18(= 10^18)。不要用 INT_MAX,因为 INT_MAX + 1 溢出成负数。

🔗 与后续章节的联系

  • 第 6.2 章(经典 DP):扩展到 LIS、背包、网格路径——都是本章四步 DP 法的应用
  • 第 6.3 章(进阶 DP):进入状压 DP、区间 DP、树形 DP——更复杂的状态定义,但思路相同
  • 第 3.2 章(前缀和):差分数组有时可以替代简单 DP,前缀和数组可以加速 DP 中的区间计算
  • 第 4.1 章(贪心)vs DP:贪心可解的问题是 DP 的特例(每步局部最优 = 全局最优);贪心失败时需要 DP

练习题

题目 6.1.1 — 爬楼梯 🟢 简单 每次可以爬 1 或 2 级台阶,有多少种方法爬 N 级台阶?

提示 这就是斐波那契!ways[1]=1,ways[2]=2。或从 ways[0]=1, ways[1]=1 开始,then ways[n] = ways[n-1] + ways[n-2]。
✅ 完整题解

核心思路: ways[n] = 到达台阶 n 的方法数。你从台阶 n-1(1 步)或台阶 n-2(2 步)到达。

#include <bits/stdc++.h>
using namespace std;
int main() {
    int n; cin >> n;
    if (n == 1) { cout << 1; return 0; }
    vector<long long> dp(n + 1);
    dp[1] = 1; dp[2] = 2;
    for (int i = 3; i <= n; i++)
        dp[i] = dp[i-1] + dp[i-2];
    cout << dp[n] << "\n";
}

复杂度: O(N) 时间,O(N) 空间(可用两个变量降为 O(1))。


题目 6.1.2 — 最少硬币找零 🟡 中等 给定硬币面额 [1, 3, 4] 和目标 6,找最少硬币数。(期望答案:2 枚——用 3+3)

✅ 完整题解
#include <bits/stdc++.h>
using namespace std;
int main() {
    int n, W; cin >> n >> W;
    vector<int> coins(n);
    for (int& c : coins) cin >> c;
    const int INF = 1e9;
    vector<int> dp(W + 1, INF);
    dp[0] = 0;
    for (int w = 1; w <= W; w++) {
        for (int c : coins) {
            if (c <= w && dp[w - c] != INF)
                dp[w] = min(dp[w], dp[w - c] + 1);
        }
    }
    cout << (dp[W] == INF ? -1 : dp[W]) << "\n";
}

贪心选 4 → 4+1+1 = 3 枚;DP 找 3+3 = 2 枚。复杂度: O(N × W)。


题目 6.1.3 — 瓷砖铺设 🟡 中等 用 1×2 多米诺骨牌(水平或垂直放置)铺满 2×N 的棋盘,有多少种方法?

提示 递推与斐波那契相同!关键洞察:在第 N 列放一块竖排骨牌,递归到 n-1;在第 N-1 和 N 列放两块横排骨牌,递归到 n-2。
✅ 完整题解
#include <bits/stdc++.h>
using namespace std;
const long long MOD = 1e9 + 7;
int main() {
    int n; cin >> n;
    if (n == 1) { cout << 1; return 0; }
    vector<long long> dp(n + 1);
    dp[1] = 1; dp[2] = 2;
    for (int i = 3; i <= n; i++)
        dp[i] = (dp[i-1] + dp[i-2]) % MOD;
    cout << dp[n] << "\n";
}

复杂度: O(N)。


题目 6.1.4 — 有限次使用的硬币找零 🔴 困难 与硬币找零相同,但每种硬币最多用一次(0/1 背包),找最少硬币数。

提示 0/1 背包变体,关键技巧:将 w 从 W 反向迭代到 coins[i],防止重复使用同一枚硬币。
✅ 完整题解
#include <bits/stdc++.h>
using namespace std;
int main() {
    int n, W; cin >> n >> W;
    vector<int> coins(n);
    for (int& c : coins) cin >> c;
    const int INF = 1e9;
    vector<int> dp(W + 1, INF);
    dp[0] = 0;
    for (int i = 0; i < n; i++) {
        // 反向顺序:防止硬币 i 被用超过一次
        for (int w = W; w >= coins[i]; w--) {
            if (dp[w - coins[i]] != INF)
                dp[w] = min(dp[w], dp[w - coins[i]] + 1);
        }
    }
    cout << (dp[W] == INF ? -1 : dp[W]) << "\n";
}

为什么反向? 正向时可能用更新过的 dp[w] 来更新自身——等于把同一枚硬币用了两次。复杂度: O(N × W)。


题目 6.1.5 — USACO Bronze:干草堆叠放 🔴 困难 N 次操作「给位置 L 到 R 的所有位置加 1」,求每个位置的最终值。

提示 差分数组:`diff[L]++`,`diff[R+1]--`,然后取前缀和得到最终值。
✅ 完整题解
#include <bits/stdc++.h>
using namespace std;
int main() {
    ios_base::sync_with_stdio(false); cin.tie(NULL);
    int n, q; cin >> n >> q;
    vector<long long> diff(n + 2, 0);
    while (q--) {
        int l, r; cin >> l >> r;
        diff[l]++;
        diff[r + 1]--;
    }
    long long cur = 0;
    for (int i = 1; i <= n; i++) {
        cur += diff[i];
        cout << cur << " \n"[i == n];
    }
}

复杂度: O(N + Q)。


🏆 挑战题:有障碍的唯一路径 N×M 网格有「.」格子和「#」障碍,统计从 (1,1) 到 (N,M) 只向右或向下移动的路径数,答案对 10^9+7 取模。(N, M ≤ 1000)

✅ 完整题解
#include <bits/stdc++.h>
using namespace std;
const long long MOD = 1e9 + 7;
int main() {
    int n, m; cin >> n >> m;
    vector<string> grid(n);
    for (auto& row : grid) cin >> row;
    vector<vector<long long>> dp(n, vector<long long>(m, 0));
    if (grid[0][0] == '.') dp[0][0] = 1;
上图展示了 fib(6) 的朴素递归。红色虚线节点是**重复子问题**——被多次计算。绿色节点展示记忆化缓存结果的位置。不用记忆化:`O(2^N)`;用记忆化:`O(N)`。这是动态规划背后的基本洞察。
📖 第 6.2 章 ⏱️ 约 110 分钟 🎯 进阶

第 6.2 章:经典 DP 问题

📝 前置条件: 确保掌握了第 6.1 章的核心 DP 概念——状态、递推和初始条件。你应该能从零实现斐波那契和基本硬币找零。

本章我们处理竞赛编程中最重要、应用最广泛的三个 DP 问题。掌握这些模式将帮助你识别并解决数十道 USACO 题目。


6.2.1 最长递增子序列(LIS)

题目: 给定 N 个整数的数组 A,找最长的严格递增子序列的长度。子序列不需要连续。

示例: A = [3, 1, 8, 2, 5]

  • LIS:[1, 2, 5] → 长度 3
  • 或:[3, 8] → 长度 2(不是最长)

💡 核心思路: 子序列可以跳过元素,但必须保持相对顺序。关键 DP 洞察:对每个下标 i,问「以 A[i] 结尾的最长递增子序列是什么?」然后对所有 i 取最大值就是答案。

LIS 状态转移——A = [3, 1, 8, 2, 5]:

LIS State Transitions

💡 转移规则: dp[i] = 1 + max(dp[j])(对所有 j < i 且 A[j] < A[i])。每条箭头表示「以 j 结尾的子序列可以延伸到包含 i」。

LIS Visualization

O(N²) DP 解法

  • 状态: dp[i] = 以下标 i 结尾的最长递增子序列长度
  • 递推: dp[i] = 1 + max(对所有 j < i 且 A[j] < A[i] 的 dp[j])
  • 初始条件: dp[i] = 1(只含 A[i] 自身的子序列)
  • 答案: max(dp[0], dp[1], ..., dp[N-1])

对 A = [3, 1, 8, 2, 5] 的逐步追踪:

dp[0] = 1  (以 3 结尾的 LIS:只有 [3])
dp[1] = 1  (以 1 结尾的 LIS:只有 [1])
dp[2] = 2  (以 8 结尾的 LIS:[3,8] 或 [1,8])
dp[3] = 2  (以 2 结尾的 LIS:[1,2])
dp[4] = 3  (以 5 结尾的 LIS:[1,2,5])

LIS 长度 = max(dp) = 3
📄 C++ 完整代码
// LIS O(N²) — 简单但 N > 5000 时太慢
#include <bits/stdc++.h>
using namespace std;

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

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

    vector<int> dp(n, 1);  // 每个元素单独是长度为 1 的子序列

    for (int i = 1; i < n; i++) {
        for (int j = 0; j < i; j++) {
            if (A[j] < A[i]) {              // A[j] 可以延伸到 A[i]
                dp[i] = max(dp[i], dp[j] + 1);  // ← 关键行
            }
        }
    }

    cout << *max_element(dp.begin(), dp.end()) << "\n";
    return 0;
}

样例输入: 5 / 3 1 8 2 5输出: 3

复杂度分析:

  • 时间: O(N²) —— 双重循环
  • 空间: O(N) —— dp 数组

N ≤ 5000 时 O(N²) 够快,N 最大 10^5 时需要 O(N log N) 方案。


O(N log N) LIS(耐心排序)

关键思路:维护 tails 数组,其中 tails[k] = 迄今为止任意长度为 k+1 的递增子序列中最小可能的尾部元素

💡 核心思路(耐心排序): 想象把牌发到若干叠(像接龙游戏)。每叠是递减序列,一张牌放到顶牌 ≥ 它的最左侧那叠。若无这样的叠,开一叠新的。牌的叠数就等于 LIS 长度!tails 数组正是这些叠的顶牌。

对 A = [3, 1, 8, 2, 5] 的逐步追踪:

处理 3:tails=[],无元素 ≥ 3,追加:tails=[3]
处理 1:tails=[3],lower_bound(1) 在下标 0(3 ≥ 1),替换:tails=[1]
处理 8:tails=[1],lower_bound(8) 到末尾,追加:tails=[1,8]
处理 2:tails=[1,8],lower_bound(2) 在下标 1(8 ≥ 2),替换:tails=[1,2]
处理 5:tails=[1,2],lower_bound(5) 到末尾,追加:tails=[1,2,5]
答案 = tails.size() = 3
📄 C++ 完整代码
// LIS O(N log N) — N 最大 10^5 时够快
#include <bits/stdc++.h>
using namespace std;

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

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

    vector<int> tails;  // tails[i] = 长度为 i+1 的 IS 的最小尾元素

    for (int x : A) {
        // 找第一个 tails[pos] >= x(严格递增用 lower_bound)
        auto it = lower_bound(tails.begin(), tails.end(), x);

        if (it == tails.end()) {
            tails.push_back(x);   // x 延伸了最长子序列
        } else {
            *it = x;              // ← 关键行:替换以保持最小可能尾部
        }
    }

    cout << tails.size() << "\n";
    return 0;
}

⚠️ 注意: tails 不存储实际的 LIS 元素,只存储其长度lower_bound 给出严格递增的 LIS(A[j] < A[i]);若需不减序列(A[j] ≤ A[i]),改用 upper_bound

复杂度: O(N log N) 时间,O(N) 空间。


6.2.2 0/1 背包问题

题目: 有 N 件物品,物品 i 的重量为 w[i],价值为 v[i]。背包最多承重 W,选择物品使总价值最大,每件物品最多选一次(0/1 = 拿或不拿)。

图示:背包 DP 表

二维表展示了 dp[物品][容量],每行加入一件物品,答案在右下角。

Knapsack DP Table

DP 公式

0/1 背包决策——拿或不拿物品 i:

Knapsack Decision

💡 与无界背包的关键区别: 因为每件物品只能用一次,「拿」时从行 dp[i-1] 读取,而不是当前行。这就是为什么一维优化版本要反向迭代重量。

  • 状态: dp[i][w] = 使用物品 1..i 且总重量 ≤ w 时的最大价值
  • 递推:
    • 不拿物品 i:dp[i][w] = dp[i-1][w]
    • 拿物品 i(仅在 w[i] ≤ w 时):dp[i][w] = dp[i-1][w - weight[i]] + value[i]
    • 取最大值
📄 C++ 完整代码
#include <bits/stdc++.h>
using namespace std;

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

    int n, W;
    cin >> n >> W;

    vector<int> weight(n + 1), value(n + 1);
    for (int i = 1; i <= n; i++) cin >> weight[i] >> value[i];

    vector<vector<int>> dp(n + 1, vector<int>(W + 1, 0));

    for (int i = 1; i <= n; i++) {
        for (int w = 0; w <= W; w++) {
            dp[i][w] = dp[i-1][w];  // 选项一:不拿物品 i

            if (weight[i] <= w) {    // 选项二:拿物品 i(如果放得下)
                dp[i][w] = max(dp[i][w], dp[i-1][w - weight[i]] + value[i]);
            }
        }
    }

    cout << dp[n][W] << "\n";
    return 0;
}

空间优化的 0/1 背包——O(W) 空间

只需要上一行 dp[i-1],可以用一维数组。关键: w 从 W 倒序迭代(否则物品 i 会被使用多次):

vector<int> dp(W + 1, 0);

for (int i = 1; i <= n; i++) {
    // 倒序迭代,防止物品 i 被使用多次
    for (int w = W; w >= weight[i]; w--) {
        dp[w] = max(dp[w], dp[w - weight[i]] + value[i]);
    }
}

cout << dp[W] << "\n";

为什么倒序? 计算 dp[w] 时,需要上一件物品行dp[w - weight[i]]。倒序迭代确保 dp[w - weight[i]] 还没被当前物品 i 更新过。

无界背包(物品无限次可用)

若每件物品可以使用多次,改为正序迭代:

for (int i = 1; i <= n; i++) {
    for (int w = weight[i]; w <= W; w++) {  // 正序——允许重复使用
        dp[w] = max(dp[w], dp[w - weight[i]] + value[i]);
    }
}

6.2.3 网格路径计数

题目: 统计从网格左上角 (1,1) 到右下角 (N,M) 只向右或向下移动的路径数,部分格子被堵塞。

图示:网格路径 DP 值

Grid DP

每个格子展示了从 (0,0) 到该格子的路径数。递推 dp[i][j] = dp[i-1][j] + dp[i][j-1] 叠加了从上方和左方到达的路径。

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

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

    int n, m;
    cin >> n >> m;

    vector<string> grid(n);
    for (int r = 0; r < n; r++) cin >> grid[r];

    // dp[r][c] = 到达 (r, c) 的路径数
    vector<vector<long long>> dp(n, vector<long long>(m, 0));

    // 初始条件:起始格子(若不堵塞)
    if (grid[0][0] != '#') dp[0][0] = 1;

    // 填充第一行(只能从左来)
    for (int c = 1; c < m; c++) {
        if (grid[0][c] != '#') dp[0][c] = dp[0][c-1];
    }

    // 填充第一列(只能从上来)
    for (int r = 1; r < n; r++) {
        if (grid[r][0] != '#') dp[r][0] = dp[r-1][0];
    }

    // 填充其余格子
    for (int r = 1; r < n; r++) {
        for (int c = 1; c < m; c++) {
            if (grid[r][c] == '#') {
                dp[r][c] = 0;  // 堵塞——无路径经过此处
            } else {
                dp[r][c] = dp[r-1][c] + dp[r][c-1];  // 从上 + 从左
            }
        }
    }

    cout << dp[n-1][m-1] << "\n";
    return 0;
}

网格最大价值路径

题目: 找从 (1,1) 到 (N,M)(只向右或向下)最大化路径上值之和的路径。

📄 C++ 完整代码
// ...读取 val[r][c]...
vector<vector<long long>> dp(n, vector<long long>(m, 0));
dp[0][0] = val[0][0];

for (int c = 1; c < m; c++) dp[0][c] = dp[0][c-1] + val[0][c];
for (int r = 1; r < n; r++) dp[r][0] = dp[r-1][0] + val[r][0];

for (int r = 1; r < n; r++) {
    for (int c = 1; c < m; c++) {
        dp[r][c] = max(dp[r-1][c], dp[r][c-1]) + val[r][c];
    }
}

cout << dp[n-1][m-1] << "\n";

6.2.4 USACO DP 示例:牛蹄剪刀布

题目(USACO 2019 January Silver): Bessie 玩 N 局牛蹄剪刀布(类似石头剪刀布)。她事先知道对手的出法,可以最多换 K 次手势,最大化获胜局数。

状态: dp[j][g] = 前 i 局换了 j 次、当前出手势 g 时的最大获胜数。

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

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

    int n, k;
    cin >> n >> k;

    // 0=牛蹄, 1=纸, 2=剪刀
    vector<int> opp(n + 1);
    for (int i = 1; i <= n; i++) {
        char c; cin >> c;
        if (c == 'H') opp[i] = 0;
        else if (c == 'P') opp[i] = 1;
        else opp[i] = 2;
    }

    const int NEG_INF = -1e9;
    vector<vector<int>> dp(k + 1, vector<int>(3, NEG_INF));

    // 初始化:第 1 局前,换了 0 次,任何起始手势
    for (int g = 0; g < 3; g++) dp[0][g] = 0;

    for (int i = 1; i <= n; i++) {
        vector<vector<int>> ndp(k + 1, vector<int>(3, NEG_INF));

        for (int j = 0; j <= k; j++) {
            for (int g = 0; g < 3; g++) {
                if (dp[j][g] == NEG_INF) continue;

                int win = (g == opp[i]) ? 1 : 0;

                // 选项一:不换手势
                ndp[j][g] = max(ndp[j][g], dp[j][g] + win);

                // 选项二:换手势(消耗 1 次)
                if (j < k) {
                    for (int ng = 0; ng < 3; ng++) {
                        if (ng != g) {
                            int nwin = (ng == opp[i]) ? 1 : 0;
                            ndp[j+1][ng] = max(ndp[j+1][ng], dp[j][g] + nwin);
                        }
                    }
                }
            }
        }

        dp = ndp;
    }

    int ans = 0;
    for (int j = 0; j <= k; j++)
        for (int g = 0; g < 3; g++)
            ans = max(ans, dp[j][g]);

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

6.2.5 区间 DP——矩阵链乘法与气球爆破模式

区间 DP 是一种强大的 DP 技术,状态代表连续的子数组或子范围,我们将更小区间的解组合来解决更大的区间。

💡 核心思路: 当区间 [l, r] 的最优解依赖于如何在某个点 k 分割该区间,且子问题 [l, k][k+1, r] 相互独立时,适用区间 DP。

区间 DP 框架

区间 DP 填充顺序——必须按区间长度递增填充:

Interval DP Fill Order

💡 填充顺序很关键: 必须按区间长度递增填充。计算 dp[l][r] 时,所有更短的子区间 dp[l][k]dp[k+1][r] 必须已经计算好。

状态:    dp[l][r] = 区间 [l, r] 上子问题的最优解
初始条件:dp[i][i] = 单个元素的代价/价值(通常为 0 或平凡值)
顺序:    按区间长度递增填充(len = 1, 2, 3, ..., n)
          确保 dp[l][k] 和 dp[k+1][r] 在 dp[l][r] 之前计算
转移:    dp[l][r] = 对 [l, r-1] 中所有分割点 k 的 min/max:
                    dp[l][k] + dp[k+1][r] + cost(l, k, r)
答案:    dp[1][n](0-indexed 则为 dp[0][n-1])

经典示例:矩阵链乘法

题目: 给定 N 个矩阵 A₁, A₂, ..., Aₙ,矩阵 Aᵢ 维度为 dim[i-1] × dim[i],找最小化标量乘法次数的括号化方案。

状态: dp[l][r] = 计算乘积 Aₗ × Aₗ₊₁ × ... × Aᵣ 的最少乘法次数

转移: 尝试每个分割点 k ∈ [l, r-1]:

  • 左乘积 Aₗ...Aₖ 代价 dp[l][k],结果维度 dim[l-1] × dim[k]
  • 右乘积 Aₖ₊₁...Aᵣ 代价 dp[k+1][r],结果维度 dim[k] × dim[r]
  • 两结果相乘代价 dim[l-1] × dim[k] × dim[r]
📄 C++ 完整代码
// 矩阵链乘法 — O(N³) 时间,O(N²) 空间
#include <bits/stdc++.h>
using namespace std;

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

    int n;
    cin >> n;

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

    vector<vector<long long>> dp(n + 1, vector<long long>(n + 1, 0));
    const long long INF = 1e18;

    // 按区间长度递增填充
    for (int len = 2; len <= n; len++) {
        for (int l = 1; l + len - 1 <= n; l++) {
            int r = l + len - 1;
            dp[l][r] = INF;

            for (int k = l; k < r; k++) {
                long long cost = dp[l][k]
                               + dp[k+1][r]
                               + (long long)dim[l-1] * dim[k] * dim[r];
                dp[l][r] = min(dp[l][r], cost);
            }
        }
    }

    cout << dp[1][n] << "\n";
    return 0;
}

复杂度: O(N³) 时间,O(N²) 空间。

区间 DP 通用模板

📄 查看代码:区间 DP 通用模板
// 通用区间 DP 模板
void intervalDP(int n) {
    vector<vector<int>> dp(n + 1, vector<int>(n + 1, 0));

    // 初始条件:长度为 1 的区间
    for (int i = 1; i <= n; i++) dp[i][i] = base_case(i);

    // 按长度递增填充
    for (int len = 2; len <= n; len++) {
        for (int l = 1; l + len - 1 <= n; l++) {
            int r = l + len - 1;
            dp[l][r] = INF;

            for (int k = l; k < r; k++) {
                int val = dp[l][k] + dp[k+1][r] + cost(l, k, r);
                dp[l][r] = min(dp[l][r], val);
            }
        }
    }
}

⚠️ 常见错误: 以左端点 l 为外层循环、长度为内层循环——这是错误的!计算 dp[l][r] 时,子区间 dp[l][k]dp[k+1][r] 必须已经计算好。始终以长度为外层循环。


6.2.6 分组背包

题目: N 组物品,第 i 组有 cnt[i] 件物品,每组最多选一件(或不选)。在重量 W 内最大化总价值。

💡 与 0/1 背包的关键区别: 0/1 背包逐件物品做决策;分组背包逐组做决策——每组中选哪件(如果选的话)。

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

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

    int n, W;
    cin >> n >> W;

    vector<vector<pair<int,int>>> groups(n);
    for (int i = 0; i < n; i++) {
        int cnt; cin >> cnt;
        groups[i].resize(cnt);
        for (auto& [w, v] : groups[i]) cin >> w >> v;
    }

    vector<int> dp(W + 1, 0);

    for (int i = 0; i < n; i++) {          // 对每组
        for (int w = W; w >= 0; w--) {     // 容量**降序**迭代
            for (auto [wi, vi] : groups[i]) { // 尝试组内每件物品
                if (w >= wi)
                    dp[w] = max(dp[w], dp[w - wi] + vi);
            }
        }
    }

    cout << dp[W] << "\n";
    return 0;
}

复杂度: O(N × W × 平均组大小)。

循环顺序说明

正确——物品循环在容量循环内部:
  for w = W..0:
    尝试 A:dp[w] = max(dp[w], dp[w-2]+3)
    尝试 B:dp[w] = max(dp[w], dp[w-3]+5)
  → 每个容量下只从该组选最优的一件

错误——容量循环在物品循环内部:
  尝试 A:for w = W..0: dp[w] = max(dp[w], dp[w-2]+3)
  尝试 B:for w = W..0: dp[w] = max(dp[w], dp[w-3]+5)
  → A 和 B 可能都被选中,违反「每组最多一件」

6.2.7 多重背包

题目: N 种物品,每种有 cnt[i] 件(不是无限个)。在重量 W 内最大化总价值。

方法一:二进制拆分 — O(N log C × W)

核心思路: 0 到 cnt[i] 之间的任意数 k 都可以表示为 2 的幂次之和加余数。将 cnt[i] 件拆分为大小为 1, 2, 4, 8, ..., 余数的组,每组作为一件「超级物品」。

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

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

    int n, W;
    cin >> n >> W;

    // 将每种物品按二进制拆分为「超级物品」
    vector<pair<int,int>> items;

    for (int i = 0; i < n; i++) {
        int wi, vi, ci;
        cin >> wi >> vi >> ci;

        for (int k = 1; ci > 0; k *= 2) {
            int take = min(k, ci);
            items.push_back({take * wi, take * vi});
            ci -= take;
        }
    }

    // 对扩展后的物品做标准 0/1 背包
    vector<int> dp(W + 1, 0);
    for (auto [w, v] : items) {
        for (int cap = W; cap >= w; cap--)
            dp[cap] = max(dp[cap], dp[cap - w] + v);
    }

    cout << dp[W] << "\n";
    return 0;
}

复杂度: O(Σ log(cnt[i]) × W) ≈ O(N log C × W)。

方法二:单调队列优化 — O(N × W)

对 cnt 很大(最大 10^6)的情况,二进制拆分仍然太慢。最优解使用单调队列。

核心思路:w[i] 的余数对 DP 数组分组,在每个余数类内,转移变成一个滑动窗口最大值问题。

📄 C++ 完整代码
// 多重背包(单调队列优化)— O(N * W)
void bounded_knapsack_deque(vector<int>& dp, int wi, int vi, int ci, int W) {
    vector<int> prev = dp;
    for (int r = 0; r < wi; r++) {
        deque<int> dq;
        int max_k = (W - r) / wi;
        for (int k = 0; k <= max_k; k++) {
            int idx = r + k * wi;
            int val = prev[idx] - k * vi;
            while (!dq.empty() && dq.front() < k - ci) dq.pop_front();
            if (!dq.empty()) {
                int j = dq.front();
                dp[idx] = max(dp[idx], prev[r + j * wi] + (k - j) * vi);
            }
            while (!dq.empty() && prev[r + dq.back() * wi] - dq.back() * vi <= val)
                dq.pop_back();
            dq.push_back(k);
        }
    }
}

💡 何时用哪种方法:

  • cnt ≤ 1000,W ≤ 10^5 → 二进制拆分(实现更简单)
  • cnt 最大 10^6,W 最大 10^5 → 单调队列(只有 O(NW))

6.2.8 完全背包——每种物品可选无限次

核心区别: 枚举顺序从「倒序」改为「正序」。

📄 C++ 完整代码
// 完全背包 — O(N × W)
// 正序枚举允许同一物品被多次选取
vector<int> unbounded_knapsack(int n, int W,
    vector<int>& wt, vector<int>& val) {
    vector<int> dp(W + 1, 0);
    for (int i = 0; i < n; i++) {
        for (int w = wt[i]; w <= W; w++) {   // ← 正序!
            dp[w] = max(dp[w], dp[w - wt[i]] + val[i]);
        }
    }
    return dp;
}
背包类型每件可选次数内层循环顺序
0/1 背包最多 1 次倒序(W → w[i])
完全背包无限次正序(w[i] → W)
多重背包最多 cnt[i] 次拆分后按 0/1 处理

6.2.9 二维费用背包

物品同时有两种费用(如重量 + 体积),背包有两个限制。只需多一维状态。

📄 物品同时有两种费用(如重量 + 体积),背包有两个限制。只需多一维状态。
// 二维费用 0/1 背包 — O(N × V × M)
// 物品 i 有重量 w[i]、体积 v[i]、价值 c[i]
// 背包容量 V(重量)和 M(体积)
void two_dim_knapsack(int n, int V, int M,
    vector<int>& w, vector<int>& v, vector<int>& c) {
    vector<vector<int>> dp(V + 1, vector<int>(M + 1, 0));

    for (int i = 0; i < n; i++) {
        // 两个维度都倒序!保证 0/1 约束
        for (int j = V; j >= w[i]; j--) {
            for (int k = M; k >= v[i]; k--) {
                dp[j][k] = max(dp[j][k], dp[j - w[i]][k - v[i]] + c[i]);
            }
        }
    }
    // dp[V][M] 即为答案
}

⚠️ 关键: 两个维度都必须倒序枚举,否则同一物品会被多次计入。


6.2.10 背包方案数

将求最大值改为求满足条件的方案数:把 max 替换为累加,把初始值 dp[0] = 1 作为基础情况。

📄 将求最大值改为求**满足条件的方案数**:把 `max` 替换为累加,把初始值 `dp[0] = 1` 作为基础情况。
// 方案数背包:恰好装满 W 的方案数(对 MOD 取模)
// 物品无限次可选(完全背包版)
const int MOD = 1e9 + 7;

long long count_ways(int n, int W, vector<int>& wt) {
    vector<long long> dp(W + 1, 0);
    dp[0] = 1;  // 空背包:1 种方案(不选任何物品)

    for (int i = 0; i < n; i++) {
        for (int w = wt[i]; w <= W; w++) {   // 正序 = 完全背包
            dp[w] = (dp[w] + dp[w - wt[i]]) % MOD;
        }
    }
    return dp[W];
}

// 0/1 背包版:恰好装满的方案数
long long count_ways_01(int n, int W, vector<int>& wt) {
    vector<long long> dp(W + 1, 0);
    dp[0] = 1;

    for (int i = 0; i < n; i++) {
        for (int w = W; w >= wt[i]; w--)    // 倒序 = 0/1 背包
            dp[w] = (dp[w] + dp[w - wt[i]]) % MOD;
    }
    return dp[W];
}

典型应用:

  • 「正好装满 W」的方案数 → 初始 dp[0]=1dp[1..W]=0
  • 「不超过 W」的方案数 → 初始全部为 1(任何子集都是合法方案)

6.2.11 背包问题决策对照表

需求关键变化
求最大价值(0/1)dp[w] = max(dp[w], dp[w-wi]+vi)倒序
求最大价值(完全)dp[w] = max(dp[w], dp[w-wi]+vi)正序
求方案数(0/1)dp[w] += dp[w-wi]倒序,初始 dp[0]=1
求方案数(完全)dp[w] += dp[w-wi]正序,初始 dp[0]=1
求第 k 优解每个状态存前 k 大的值,转移时用双指针合并
恰好装满dp[0]=1/0dp[1..W]=-INF/0
至多装满dp[0..W] 全初始化为 0

⚠️ 第 6.2 章常见错误

  1. LIS:严格递增用 upper_bound 严格递增用 lower_bound;不减序列用 upper_bound。搞错会使 LIS 长度差 1。
  2. 0/1 背包:正向迭代重量: 正向迭代允许物品 i 被多次使用——那是无界背包,不是 0/1。0/1 背包始终倒序迭代。
  3. 网格路径:忘记处理堵塞格子:grid[r][c] == '#',设 dp[r][c] = 0(不是 dp[r-1][c] + dp[r][c-1])。
  4. 网格路径计数中溢出: 路径数可能极大,用 long long 或模运算。
  5. LIS:以为 tails 存储实际 LIS: 不是!tails 存储各长度子序列的最小可能尾元素。实际 LIS 需要单独重建。
  6. 分组背包:物品循环在容量外层: 物品循环必须在容量循环内部。若物品在外层,每件物品被当作独立的 0/1 物品处理,允许同组多件被选中。
  7. 多重背包二进制拆分后正向迭代: 拆分后超级物品仍是 0/1 约束——倒序迭代重量。正向迭代允许重用同一超级物品,结果错误。
  8. 二维背包只有一个维度倒序: 二维 0/1 背包中,重量和体积两个约束都需要其循环倒序迭代。

本章总结

📌 核心要点

问题状态定义递推复杂度
LIS(O(N²))dp[i] = 以 A[i] 结尾的 LIS 长度dp[i] = max(dp[j]+1),j<i 且 A[j]<A[i]O(N²)
LIS(O(N log N))tails[k] = 长度 k+1 的 IS 的最小尾部二分查找 + 替换O(N log N)
0/1 背包(一维)dp[w] = 容量 ≤ w 时的最大价值倒序迭代 wO(NW)
无界背包dp[w] = 容量 ≤ w 时的最大价值正序迭代 wO(NW)
分组背包dp[w] = 最大价值,每组最多选 1 件w 降序,物品循环在 w 循环内部O(N×W×组大小)
多重背包同 0/1二进制拆分 → 0/1 背包O(N log C × W)
网格路径dp[r][c] = 到达 (r,c) 的路径数dp[r-1][c] + dp[r][c-1]O(RC)

❓ 常见问题

Q1:O(N log N) LIS 中 tails 数组存储的是实际 LIS 吗?

A:不是! tails 存储的是「各长度递增子序列的最小尾元素」。其长度等于 LIS 长度,但元素本身可能不构成合法的递增子序列。要重建实际 LIS,需要记录每个元素的「前驱」。

Q2:0/1 背包为什么需要倒序迭代 w?

A:因为 dp[w] 需要上一件物品行dp[w - weight[i]]。正向迭代时,dp[w - weight[i]] 可能已被当前行(当前物品 i)更新,等于物品 i 被使用了多次。倒序迭代确保每件物品最多被使用一次。

Q3:无界背包(物品无限次可用)和 0/1 背包的代码只有什么区别?

A:只是内层循环方向。0/1 背包:w 从 W 降到 weight[i](倒序);无界背包:w 从 weight[i] 升到 W(正序)。

Q4:如果网格路径还可以向上或向左移动呢?

A:那么简单的网格 DP 就不再适用(因为会有环)。需要 BFS/DFS 或更复杂的 DP。标准网格路径 DP 只适用于「只向右/向下」的移动。

🔗 与后续章节的联系

  • 第 3.3 章(排序与二分):二分搜索是 O(N log N) LIS 的核心——对 tails 数组用 lower_bound
  • 第 6.3 章(进阶 DP):将背包扩展到状压 DP(物品集合 → 位掩码),将网格 DP 扩展到区间 DP
  • 第 4.1 章(贪心):区间调度问题有时可以转化为 LIS(通过 Dilworth 定理)
  • LIS 在 USACO Silver 中极为常见——二维 LIS、带权 LIS、LIS 计数变体频繁出现

练习题

题目 6.2.1 — LIS 长度 🟢 简单 读取 N 个整数,找最长严格递增子序列的长度。

✅ 完整题解
#include <bits/stdc++.h>
using namespace std;
int main() {
    int n; cin >> n;
    vector<int> a(n);
    for (int& x : a) cin >> x;

    vector<int> tails;
    for (int x : a) {
        auto it = lower_bound(tails.begin(), tails.end(), x);
        if (it == tails.end()) tails.push_back(x);
        else *it = x;
    }
    cout << tails.size() << "\n";
}

复杂度: O(N log N)。


题目 6.2.2 — LIS 计数 🔴 困难 读取 N 个整数,找最长递增子序列的数量(对 10^9+7 取模)。

✅ 完整题解
#include <bits/stdc++.h>
using namespace std;
const int MOD = 1e9 + 7;
int main() {
    int n; cin >> n;
    vector<int> a(n);
    for (int& x : a) cin >> x;

    vector<int> len(n, 1);
    vector<long long> cnt(n, 1);

    for (int i = 1; i < n; i++) {
        for (int j = 0; j < i; j++) {
            if (a[j] < a[i]) {
                if (len[j] + 1 > len[i]) {
                    len[i] = len[j] + 1;
                    cnt[i] = cnt[j];
                } else if (len[j] + 1 == len[i]) {
                    cnt[i] = (cnt[i] + cnt[j]) % MOD;
                }
            }
        }
    }

    int maxLen = *max_element(len.begin(), len.end());
    long long ans = 0;
    for (int i = 0; i < n; i++)
        if (len[i] == maxLen) ans = (ans + cnt[i]) % MOD;
    cout << ans << "\n";
}

复杂度: O(N²)。


题目 6.2.3 — 0/1 背包 🟡 中等 N 件物品,各有重量和价值,容量 W,找最大价值。(N, W ≤ 1000)

✅ 完整题解
#include <bits/stdc++.h>
using namespace std;
int main() {
    int n, W; cin >> n >> W;
    vector<int> wt(n), val(n);
    for (int i = 0; i < n; i++) cin >> wt[i] >> val[i];

    vector<int> dp(W + 1, 0);
    for (int i = 0; i < n; i++) {
        for (int w = W; w >= wt[i]; w--)  // 倒序:防止重复使用
            dp[w] = max(dp[w], dp[w - wt[i]] + val[i]);
    }
    cout << dp[W] << "\n";
}

复杂度: O(N × W)。


题目 6.2.4 — 收集星星 🟡 中等 N×M 网格有星星('*')和障碍('#'),只能向右或向下从 (1,1) 移动到 (N,M),最多能收集多少星星?

✅ 完整题解
#include <bits/stdc++.h>
using namespace std;
int main() {
    int n, m; cin >> n >> m;
    vector<string> g(n);
    for (auto& row : g) cin >> row;

    const int NEG = -1e9;
    vector<vector<int>> dp(n, vector<int>(m, NEG));

    dp[0][0] = (g[0][0] == '*') ? 1 : 0;

    for (int i = 0; i < n; i++) {
        for (int j = 0; j < m; j++) {
            if (i == 0 && j == 0) continue;
            if (g[i][j] == '#') continue;
            int star = (g[i][j] == '*') ? 1 : 0;
            int best = NEG;
            if (i > 0 && dp[i-1][j] != NEG) best = max(best, dp[i-1][j]);
            if (j > 0 && dp[i][j-1] != NEG) best = max(best, dp[i][j-1]);
            if (best != NEG) dp[i][j] = best + star;
        }
    }
    cout << max(0, dp[n-1][m-1]) << "\n";
}

复杂度: O(N × M)。


题目 6.2.5 — 恰好填满背包 🔴 困难 背包变体:必须恰好用满容量 W(不是最多)。

✅ 完整题解

核心思路: 与标准 0/1 背包相同,但初始化 dp[w] = -INF(w > 0),只有 dp[0] = 0。只有从 dp[0]=0 可达的状态才有有限值。

#include <bits/stdc++.h>
using namespace std;
int main() {
    int n, W; cin >> n >> W;
    vector<int> wt(n), val(n);
    for (int i = 0; i < n; i++) cin >> wt[i] >> val[i];

    const int NEG = -1e9;
    vector<int> dp(W + 1, NEG);
    dp[0] = 0;

    for (int i = 0; i < n; i++) {
        for (int w = W; w >= wt[i]; w--) {
            if (dp[w - wt[i]] != NEG)
                dp[w] = max(dp[w], dp[w - wt[i]] + val[i]);
        }
    }
    if (dp[W] == NEG) cout << "impossible\n";
    else cout << dp[W] << "\n";
}

复杂度: O(N × W)。


图示:LIS 耐心排序

LIS Patience Sort

上图用耐心排序类比展示 LIS。每「叠」表示一个潜在的子序列终点,叠的数量等于 LIS 长度。二分搜索以 O(log N) 找到每张牌的位置,总体 O(N log N) 算法。

图示:背包 DP 表

Knapsack DP Table

0/1 背包 DP 表:行 = 已考虑的物品,列 = 容量,每格展示可实现的最大价值。蓝色格子展示单件物品的贡献,绿色格子展示组合,带星号的格子是最优答案。

📖 第 6.3 章 ⏱️ 约 55 分钟 🎯 进阶

第 6.3 章:进阶 DP 模式

📝 前置条件: 必须完成第 6.1 章(DP 入门)和第 6.2 章(经典 DP 问题)。进阶模式建立在记忆化、递推和经典 DP 问题(LIS、背包、网格路径)之上。

本章涵盖 USACO Silver 及以上出现的 DP 技术:状压 DP、区间 DP、树形 DP 和数位 DP。每种都有特征性结构,一旦识别出来,问题就变得容易处理。


6.3.1 状压 DP

使用场景: 涉及小集合(N ≤ 20)的子集问题,状态包含「已选了哪些元素」。

核心思路: 用位掩码(整数)表示已选元素的集合。第 i 位为 1 表示元素 i 已选入。

{0, 2, 3} 在 5 个元素的集合中 → 位掩码 = 0b01101 = 13
第 0 位 = 1(元素 0 ∈ 集合)
第 1 位 = 0(元素 1 ∉ 集合)
第 2 位 = 1(元素 2 ∈ 集合)
第 3 位 = 1(元素 3 ∈ 集合)
第 4 位 = 0(元素 4 ∉ 集合)

基本位操作

📄 查看代码:基本位操作
int mask = 0;
mask |= (1 << i);      // 将元素 i 加入集合
mask &= ~(1 << i);     // 从集合中移除元素 i
bool has_i = (mask >> i) & 1;  // 检查元素 i 是否在集合中

// 枚举 mask 的所有子集
for (int sub = mask; sub > 0; sub = (sub - 1) & mask) {
    // 处理子集 'sub'
}
// 若需要包含空集,在循环后再处理 sub=0

// 统计置位数(集合中的元素数)
int count = __builtin_popcount(mask);   // int 类型
int count = __builtin_popcountll(mask); // long long 类型

经典题:旅行商问题(TSP)— O(2^N × N²)

题目: N 座城市,完全加权图,找访问每座城市恰好一次的最小代价哈密顿路径。

状态: dp[mask][u] = 恰好访问了 mask 中城市、当前在城市 u 时的最小代价。

Bitmask DP State Space

转移: 扩展到 mask 中没有的城市 v

dp[mask | (1<<v)][v] = min(dp[mask|(1<<v)][v], dp[mask][u] + dist[u][v])
📄 C++ 完整代码
// TSP 状压 DP — O(2^N × N²)
// N ≤ 20 时可用(2^20×400 ≈ 4×10^8,较紧;N≤18 更安全)
#include <bits/stdc++.h>
using namespace std;

typedef long long ll;
const ll INF = 1e18;

int n;
int dist[20][20];
ll dp[1 << 20][20];

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

    cin >> n;
    for (int i = 0; i < n; i++)
        for (int j = 0; j < n; j++)
            cin >> dist[i][j];

    // 初始化:全部为 INF
    for (int mask = 0; mask < (1 << n); mask++)
        fill(dp[mask], dp[mask] + n, INF);

    // 初始条件:从城市 0 出发,只访问了城市 0
    dp[1][0] = 0;  // mask=1(第 0 位置 1),在城市 0,代价=0

    for (int mask = 1; mask < (1 << n); mask++) {
        for (int u = 0; u < n; u++) {
            if (!(mask & (1 << u))) continue;  // u 不在当前集合中
            if (dp[mask][u] == INF) continue;

            // 尝试扩展到尚未访问的城市 v
            for (int v = 0; v < n; v++) {
                if (mask & (1 << v)) continue;  // v 已访问
                int newMask = mask | (1 << v);
                dp[newMask][v] = min(dp[newMask][v], dp[mask][u] + dist[u][v]);
            }
        }
    }

    int fullMask = (1 << n) - 1;  // 所有城市都已访问
    ll ans = INF;
    for (int u = 1; u < n; u++) {
        ans = min(ans, dp[fullMask][u] + dist[u][0]);  // 返回城市 0 形成环
    }

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

⚠️ 内存警告: dp[1<<20][20] 使用约 168MB。N=20 时接近典型 256MB 内存限制。若距离用 int 而非 long long,内存减半约 84MB。


6.3.2 区间 DP

使用场景: 较大区间的答案可以由较小区间的答案构建。关键词:「合并」「分割」「爆破」「矩阵链」。

核心结构:

dp[l][r] = 区间 [l, r] 上子问题的最优答案
初始条件:dp[i][i] = 平凡值(单个元素)
转移:dp[l][r] = 对 k ∈ [l, r-1] 的 min/max:
        dp[l][k] + dp[k+1][r] + cost(l, k, r)
填充顺序:按区间长度递增(len = r - l + 1)

经典题:矩阵链乘法 — O(N³)

题目: N 个矩阵依次相乘,矩阵 i 维度为 dims[i] × dims[i+1],找最小化标量乘法次数的括号化方案。

状态: dp[l][r] = 计算矩阵 l 到 r 乘积的最少乘法次数。

Interval DP Fill Order

📄 ![Interval DP Fill Order](../images/interval_dp_fill_order.svg)
// 矩阵链乘法 — O(N³),O(N²) 空间
#include <bits/stdc++.h>
using namespace std;

typedef long long ll;
const ll INF = 1e18;

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

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

    vector<vector<ll>> dp(n + 1, vector<ll>(n + 1, 0));

    for (int len = 2; len <= n; len++) {
        for (int l = 1; l + len - 1 <= n; l++) {
            int r = l + len - 1;
            dp[l][r] = INF;

            for (int k = l; k < r; k++) {
                ll cost = dp[l][k] + dp[k+1][r]
                        + (ll)dims[l-1] * dims[k] * dims[r];
                dp[l][r] = min(dp[l][r], cost);
            }
        }
    }

    cout << dp[1][n] << "\n";
    return 0;
}

工作示例: 3 个矩阵 A(10×30),B(30×5),C(5×60)

dp[1][2] = 10×30×5 = 1500
dp[2][3] = 30×5×60 = 9000
dp[1][3]:k=2 → dp[1][2] + dp[3][3] + 10×5×60 = 1500+0+3000 = 4500 ← 最小!
答案:4500(括号化为 (A×B)×C)

经典题:气球爆破

📄 查看代码:经典题:气球爆破
// dp[l][r] = 只爆破 (l, r) 中所有气球的最大金币
// 关键洞察:考虑 [l, r] 中**最后**爆破的气球 k
vector<int> val(n + 2);
val[0] = val[n + 1] = 1;
for (int i = 1; i <= n; i++) cin >> val[i];

vector<vector<ll>> dp(n + 2, vector<ll>(n + 2, 0));

for (int len = 1; len <= n; len++) {
    for (int l = 1; l + len - 1 <= n; l++) {
        int r = l + len - 1;
        for (int k = l; k <= r; k++) {
            // k 是 [l, r] 中最后爆破的气球
            ll cost = dp[l][k-1] + dp[k+1][r]
                    + (ll)val[l-1] * val[k] * val[r+1];
            dp[l][r] = max(dp[l][r], cost);
        }
    }
}
cout << dp[1][n] << "\n";

6.3.3 树形 DP

使用场景: 在树上做 DP,节点的状态依赖其子树(后序)或其祖先(前序)。

模式:子树 DP(后序)

树形 DP 总是自底向上运行——叶节点是基础情况,每个内部节点汇总其子节点的结果:

Tree DP Bottom-Up Flow

经典题:树上最大独立集

题目: N 个节点,各有价值 val[u],选一个子集 S 最大化总价值,约束:若 u ∈ S,则 u 的子节点都不在 S 中。

状态: dp[u][0] = u 选时 u 子树的最大价值;dp[u][1] = u 时 u 子树的最大价值。

📄 C++ 完整代码
// 树上最大独立集 — O(N)
#include <bits/stdc++.h>
using namespace std;

const int MAXN = 100005;
vector<int> children[MAXN];
int val[MAXN];
long long dp[MAXN][2];

// DFS 后序:先计算所有子节点的 dp,再计算 dp[u]
void dfs(int u) {
    dp[u][1] = val[u];  // 选 u:得到 val[u]
    dp[u][0] = 0;        // 不选 u:这个节点得 0

    for (int v : children[u]) {
        dfs(v);  // ← 先处理子节点(后序)

        // 若选 u:子节点必须不选
        dp[u][1] += dp[v][0];

        // 若不选 u:子节点可以选也可以不选
        dp[u][0] += max(dp[v][0], dp[v][1]);
    }
}

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

    int n, root;
    cin >> n >> root;
    for (int i = 1; i <= n; i++) cin >> val[i];

    for (int i = 0; i < n - 1; i++) {
        int u, v;
        cin >> u >> v;
        children[u].push_back(v);
    }

    dfs(root);
    cout << max(dp[root][0], dp[root][1]) << "\n";
    return 0;
}

树的直径(两次 DFS)

📄 查看代码:树的直径(两次 DFS)
// 树的直径:任意两个节点间的最长路径
// 两次 DFS 法
// 1. 从任意节点 u 做 DFS → 找最远节点 v
// 2. 从 v 做 DFS → 找最远节点 w
// dist(v, w) = 直径

int farthest_node, max_dist;

void dfs_diameter(int u, int parent, int d, vector<int> adj[]) {
    if (d > max_dist) {
        max_dist = d;
        farthest_node = u;
    }
    for (int v : adj[u]) {
        if (v != parent) dfs_diameter(v, u, d + 1, adj);
    }
}

int tree_diameter(int n, vector<int> adj[]) {
    max_dist = 0; farthest_node = 1;
    dfs_diameter(1, -1, 0, adj);

    int v = farthest_node;
    max_dist = 0;
    dfs_diameter(v, -1, 0, adj);

    return max_dist;
}

6.3.4 数位 DP

使用场景: 统计 [1, N] 范围内满足某个与数字有关的性质的数。

核心思路: 从左到右逐位构建数字,维护「tight」约束(是否仍受 N 的各位限制)。

状态: dp[位置][tight][...其他状态...]

  • 位置:当前决策的是哪一位(0 = 最左位)
  • tight:是否仍受 N 约束(1 = 是,不能超过 N 对应的位;0 = 否,可以自由使用 0-9)
  • 其他状态:追踪的任何性质(各位之和、零的个数等)

经典题:统计 [1, N] 中各位数字之和能被 K 整除的数

📄 查看代码:经典题:统计 [1, N] 中各位数字之和能被 K 整除的数
// 数位 DP — O(|digits| × 10 × K) 时间,O(|digits| × K) 空间
#include <bits/stdc++.h>
using namespace std;

string num;
int K;
map<tuple<int,int,int>, long long> memo;

// pos:当前数位位置(0-indexed)
// tight:是否受 num[pos] 约束
// rem:当前各位之和 mod K
long long solve(int pos, bool tight, int rem) {
    if (pos == (int)num.size()) {
        return rem == 0 ? 1 : 0;  // 完整数字:有效当且仅当各位之和 ≡ 0(mod K)
    }

    auto key = make_tuple(pos, tight, rem);
    if (memo.count(key)) return memo[key];

    int limit = tight ? (num[pos] - '0') : 9;  // 这一位最大能放的数字
    long long result = 0;

    for (int d = 0; d <= limit; d++) {
        bool new_tight = tight && (d == limit);
        result += solve(pos + 1, new_tight, (rem + d) % K);
    }

    return memo[key] = result;
}

// 统计 [1, N] 中各位之和能被 K 整除的数
long long count_up_to(long long N) {
    num = to_string(N);
    memo.clear();
    long long ans = solve(0, true, 0);
    // 减 1 是因为 0 本身的各位之和为 0(能被 K 整除)
    // 但我们要统计 [1, N],不是 [0, N]
    return ans - 1;
}

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

    long long L, R;
    cin >> L >> R >> K;

    cout << count_up_to(R) - count_up_to(L - 1) << "\n";
    return 0;
}

💡 核心思路: tight 标志至关重要。tight=true 时,这一位只能使用 ≤ num[pos] 的数字。一旦放了比 num[pos] 小的数字,后面所有位都自由了(tight 变为 false)。这种「剥离」上界的方式是数位 DP 正确的关键。


本章总结

📌 模式识别指南

模式题目中的线索状态转移
状压 DP「子集」,N ≤ 20,分配任务dp[mask][last]翻转位,尝试下一个元素
区间 DP「合并」「分割」「加括号」dp[l][r]在 k 处分割,组合
树形 DP「树」,子树性质dp[节点][状态]从子节点汇总
数位 DP「统计具有某性质的数」dp[位置][tight][...]尝试每个数字 d

🧩 核心框架速查

📄 查看代码:🧩 核心框架速查
// 状压 DP 框架
for (int mask = 0; mask < (1<<n); mask++)
    for (int u = 0; u < n; u++) if (mask & (1<<u))
        for (int v = 0; v < n; v++) if (!(mask & (1<<v)))
            dp[mask|(1<<v)][v] = min(dp[mask|(1<<v)][v], dp[mask][u] + cost[u][v]);

// 区间 DP 框架
for (int len = 2; len <= n; len++)           // 枚举区间长度
    for (int l = 1; l+len-1 <= n; l++) {     // 枚举左端点
        int r = l + len - 1;
        for (int k = l; k < r; k++)           // 枚举分割点
            dp[l][r] = min(dp[l][r], dp[l][k] + dp[k+1][r] + cost(l,k,r));
    }

// 树形 DP 框架(后序遍历)
void dfs(int u, int parent) {
    for (int v : adj[u]) if (v != parent) {
        dfs(v, u);
        dp[u] = update(dp[u], dp[v]);  // 用子节点信息更新当前节点
    }
}

// 数位 DP 框架
long long solve(int pos, bool tight, int state) {
    if (pos == len) return (state == target) ? 1 : 0;
    if (memo[pos][tight][state] != -1) return memo[pos][tight][state];
    int lim = tight ? (num[pos]-'0') : 9;
    long long res = 0;
    for (int d = 0; d <= lim; d++)
        res += solve(pos+1, tight && (d==lim), next_state(state, d));
    return memo[pos][tight][state] = res;
}

❓ 常见问题

Q1:区间 DP 为什么必须先按长度枚举?

A:因为 dp[l][r] 依赖 dp[l][k]dp[k+1][r],两者的长度都小于 r-l+1。所以所有更短的区间必须在 dp[l][r] 之前计算。按长度从小到大枚举满足这个要求。若直接枚举 l 和 r,可能在依赖还没准备好时就计算 dp[l][r]

Q2:树形 DP 中,如何处理无根树(给出无向边)?

A:选任意节点为根(通常是节点 1),然后用 DFS 将无向边变为有向边(父 → 子方向)。在 DFS 中传递 parent 参数以避免回到父节点。

void dfs(int u, int par) {
    for (int v : adj[u]) {
        if (v != par) {  // 只访问子节点,不访问父节点
            dfs(v, u);
            // 更新 dp[u]
        }
    }
}

Q3:数位 DP 中 tight=truetight=false 能共用同一个记忆化数组吗?

A:可以,这正是为什么 tight 是状态的一部分。dp[pos][1][rem]dp[pos][0][rem] 是不同的状态,分别记录「有上界约束时的计数」和「自由时的计数」。注意 tight=false 的状态可以在多次调用间复用(一旦 tight 变为 false,后面的位不受约束)。


练习题

题目 6.3.1 — 状压 DP:任务分配 🟡 中等 N 名工人,N 项任务,工人 i 完成任务 j 需要 time[i][j] 小时。将每项任务恰好分配给一名工人,最小化总时间。(N ≤ 15)

提示 `dp[mask]` = 分配了 `mask` 中各任务时的最少总时间。工人下标 = 分配新任务前的 popcount(mask)。
✅ 完整题解
#include <bits/stdc++.h>
using namespace std;
int main() {
    int n; cin >> n;
    vector<vector<int>> t(n, vector<int>(n));
    for (auto& row : t) for (int& x : row) cin >> x;

    vector<long long> dp(1 << n, 1e18);
    dp[0] = 0;
    for (int mask = 0; mask < (1 << n); mask++) {
        if (dp[mask] >= (long long)1e18) continue;
        int worker = __builtin_popcount(mask);
        if (worker == n) continue;
        for (int task = 0; task < n; task++) {
            if (mask & (1 << task)) continue;
            dp[mask | (1 << task)] = min(dp[mask | (1 << task)],
                                          dp[mask] + t[worker][task]);
        }
    }
    cout << dp[(1 << n) - 1] << "\n";
}

复杂度: O(2^N × N) 时间和空间,轻松处理 N ≤ 20。


题目 6.3.2 — 区间 DP:回文分割 🟡 中等 找将字符串分割成回文子串的最少切割次数。

提示 先用区间 DP 预计算 isPalin[l][r],再用 cuts[i] = s[0..i] 的最少切割次数。
✅ 完整题解
#include <bits/stdc++.h>
using namespace std;
int main() {
    string s; cin >> s;
    int n = s.size();

    // 第一阶段:回文检查
    vector<vector<bool>> pal(n, vector<bool>(n, false));
    for (int i = n-1; i >= 0; i--)
        for (int j = i; j < n; j++)
            pal[i][j] = (s[i]==s[j]) && (j-i < 2 || pal[i+1][j-1]);

    // 第二阶段:最少切割
    vector<int> cuts(n, n);
    for (int i = 0; i < n; i++) {
        if (pal[0][i]) { cuts[i] = 0; continue; }
        for (int j = 1; j <= i; j++)
            if (pal[j][i]) cuts[i] = min(cuts[i], cuts[j-1] + 1);
    }
    cout << cuts[n-1] << "\n";
}

复杂度: O(N²)。


题目 6.3.3 — 树形 DP:最大匹配 🔴 困难 在树上找最大匹配(共享顶点最少的最大边集合)。

提示 dp[u][0] = u 不匹配时 u 子树的最大匹配数;dp[u][1] = u 与某个子节点匹配时 u 子树的最大匹配数。
✅ 完整题解
#include <bits/stdc++.h>
using namespace std;
const int MAXN = 100005;
vector<int> adj[MAXN];
int dp[MAXN][2];

void dfs(int u, int par) {
    dp[u][0] = dp[u][1] = 0;
    for (int v : adj[u]) {
        if (v == par) continue;
        dfs(v, u);
        dp[u][0] += max(dp[v][0], dp[v][1]);
    }
    for (int v : adj[u]) {
        if (v == par) continue;
        int gain = 1 + dp[v][0] - max(dp[v][0], dp[v][1]);
        dp[u][1] = max(dp[u][1], dp[u][0] + gain);
    }
}

int main() {
    int n; cin >> n;
    for (int i = 0; i < n-1; i++) {
        int u, v; cin >> u >> v;
        adj[u].push_back(v); adj[v].push_back(u);
    }
    dfs(1, 0);
    cout << max(dp[1][0], dp[1][1]) << "\n";
}

复杂度: O(N)。


题目 6.3.4 — 数位 DP:统计幸运数 🟡 中等 「幸运数」只包含数字 4 和 7,统计 [1, N] 中的幸运数数量。

提示 用 BFS 枚举所有幸运数(4, 7, 44, 47, 74, 77, ...),与 N 比较。
✅ 完整题解
#include <bits/stdc++.h>
using namespace std;
int main() {
    long long n; cin >> n;
    int count = 0;
    queue<long long> q;
    q.push(4); q.push(7);
    while (!q.empty()) {
        long long x = q.front(); q.pop();
        if (x > n) continue;
        count++;
        if (x <= n / 10) {
            q.push(x * 10 + 4);
            q.push(x * 10 + 7);
        }
    }
    cout << count << "\n";
}

复杂度: O(2^digits) ≈ O(2^18) 最坏情况。


⚠️ 进阶 DP 常见错误

展开——竞赛前必读

状压 DP 陷阱:

  • mask >> i & 1 被解析为 mask >> (i & 1)——始终写 (mask >> i) & 1
  • ❌ 枚举子掩码:for (sub=mask; sub>0; sub=(sub-1)&mask) 跳过了 sub=0——若空集有效,手动添加 sub=0
  • ❌ 忘记 __builtin_popcount 统计的是置位数,不是 0..n-1 中的数

区间 DP 陷阱:

  • ❌ 按 (l, r) 顺序而非按区间长度填充——dp[l][k] 可能还没计算好
  • ❌ 分割点范围:k 应该从 lr-1,不是 lr
  • ❌ 初始化错误:dp[i][i] = 0(初始条件),不是 INF

树形 DP 陷阱:

  • ❌ 栈溢出:N > 10^5 时,将递归改为迭代 DFS
  • ❌ 忘记 if (v == parent) continue——在无向边上会无限循环
  • ❌ 换根 DP 中,换根前忘记减去子节点的贡献

数位 DP 陷阱:

  • tight 标志未传递:若 tight=true,下一位 ≤ N 对应位的数字
  • ❌ 前导零:追踪 started 标志,避免「007」和「7」被重复计数
  • tight=true 的记忆化条目不能复用——tight=false 的状态可以复用

📖 第 6.4 章:折半搜索(Meet in the Middle)

⏱ 预计阅读时间:40 分钟 | 难度:🟡 中等(USACO Gold 必备)


前置条件

  • DFS 回溯(第 5.2.12 章)
  • 排序与二分查找(第 3.3 章)
  • 位运算(第 2.6 章)

🎯 学习目标

学完本章后,你将能够:

  1. 理解折半搜索的核心思想:将 O(2^N) 降为 O(2^(N/2) × log)
  2. 解决「子集和」「N 个数中选 k 个」类的大规模枚举问题
  3. 将线性搜索空间拆成两半分别处理,再合并答案

6.4.1 问题引入:子集和

原始问题

给定 N 个整数(N ≤ 40),找是否存在一个非空子集,其和恰好等于目标值 target。

朴素暴力: 枚举所有 2^N 个子集,时间 O(2^40) ≈ 10^12,完全不可行。

关键观察: 40 个元素太多,但 20 个元素的 2^20 = 1,048,576 完全可行!


6.4.2 折半搜索的核心思想

将 N 个元素分成两半(各 N/2 个),分别枚举,然后合并。

原数组:[a1, a2, ..., a40]
         ↓ 分成两半
左半边:[a1, ..., a20]  →  枚举所有子集和 → 2^20 个值
右半边:[a21, ..., a40] →  枚举所有子集和 → 2^20 个值

查找:对于左半边的每个子集和 s,
      在右半边查找是否有子集和等于 (target - s)
      用排序 + 二分查找:O(2^(N/2) × log(2^(N/2))) = O(2^(N/2) × N/2)

时间复杂度: O(2^(N/2) × N),N=40 时约 20 × 10^6,完全可行!


6.4.3 完整实现:子集和判断

💡 CPP 代码(44 行)
#include <bits/stdc++.h>
using namespace std;

int main() {
    ios_base::sync_with_stdio(false); cin.tie(NULL);
    
    int n; long long target;
    cin >> n >> target;
    vector<long long> a(n);
    for (long long& x : a) cin >> x;
    
    int half = n / 2;
    int left_n = half, right_n = n - half;
    
    // 枚举左半边所有子集和
    vector<long long> left_sums;
    for (int mask = 0; mask < (1 << left_n); mask++) {
        long long s = 0;
        for (int i = 0; i < left_n; i++)
            if ((mask >> i) & 1) s += a[i];
        left_sums.push_back(s);
    }
    sort(left_sums.begin(), left_sums.end());  // 排序以便二分
    
    // 枚举右半边所有子集和,查找配对
    bool found = false;
    for (int mask = 0; mask < (1 << right_n); mask++) {
        long long s = 0;
        for (int i = 0; i < right_n; i++)
            if ((mask >> i) & 1) s += a[left_n + i];
        
        // 在左半边查找 target - s
        long long need = target - s;
        if (binary_search(left_sums.begin(), left_sums.end(), need)) {
            found = true;
            break;
        }
    }
    
    cout << (found ? "YES" : "NO") << "\n";
    return 0;
}
// 时间:O(2^(N/2) × N),N=40 时约 4×10^7
// 空间:O(2^(N/2)) 存储左半边子集和

追踪示例(a = [1, 5, 3, 8], target = 9):

左半边:[1, 5]
  子集和:0(空), 1, 5, 6
  排序:[0, 1, 5, 6]

右半边:[3, 8]
  mask=01(只含3):s=3,need=9-3=6,在左半边找 6 → 找到!
  
输出:YES(子集 {5,3+1=不对...实际是 {1,5} 和 {3})
验证:1+5+3 = 9 ✓(左={1,5},右={3})

6.4.4 计数变体:恰好等于目标的子集数

💡 CPP 代码(33 行)
// 统计和恰好为 target 的子集数量
long long count_subsets(vector<long long>& a, long long target) {
    int n = a.size(), half = n / 2;
    int left_n = half, right_n = n - half;
    
    // 枚举左半边
    vector<long long> left_sums;
    for (int mask = 0; mask < (1 << left_n); mask++) {
        long long s = 0;
        for (int i = 0; i < left_n; i++)
            if ((mask >> i) & 1) s += a[i];
        left_sums.push_back(s);
    }
    sort(left_sums.begin(), left_sums.end());
    
    // 枚举右半边,用 lower_bound/upper_bound 统计配对数量
    long long ans = 0;
    for (int mask = 0; mask < (1 << right_n); mask++) {
        long long s = 0;
        for (int i = 0; i < right_n; i++)
            if ((mask >> i) & 1) s += a[left_n + i];
        
        long long need = target - s;
        auto lo = lower_bound(left_sums.begin(), left_sums.end(), need);
        auto hi = upper_bound(left_sums.begin(), left_sums.end(), need);
        ans += hi - lo;  // 等于 need 的个数
    }
    
    // 减去空集(若 target == 0,空集被算了一次)
    if (target == 0) ans--;
    
    return ans;
}

6.4.5 最大子集和变体

问题: 找和不超过 target 的子集中,和最大的那个。

💡 CPP 代码(34 行)
long long max_subset_sum_le_target(vector<long long>& a, long long target) {
    int n = a.size(), half = n / 2;
    int left_n = half, right_n = n - half;
    
    // 左半边所有子集和
    vector<long long> left_sums;
    for (int mask = 0; mask < (1 << left_n); mask++) {
        long long s = 0;
        for (int i = 0; i < left_n; i++)
            if ((mask >> i) & 1) s += a[i];
        left_sums.push_back(s);
    }
    sort(left_sums.begin(), left_sums.end());
    // 去重(同一和值只需保留一个,降低后续查找复杂度)
    left_sums.erase(unique(left_sums.begin(), left_sums.end()), left_sums.end());
    
    long long ans = 0;
    for (int mask = 0; mask < (1 << right_n); mask++) {
        long long s = 0;
        for (int i = 0; i < right_n; i++)
            if ((mask >> i) & 1) s += a[left_n + i];
        
        if (s > target) continue;  // 右半边超限,不用找左半边
        
        // 在左半边找最大的 ≤ (target - s) 的值
        long long need = target - s;
        auto it = upper_bound(left_sums.begin(), left_sums.end(), need);
        if (it != left_sums.begin()) {
            --it;
            ans = max(ans, s + *it);
        }
    }
    return ans;
}

6.4.6 折半搜索 vs 其他方法

方法时间复杂度适用场景
暴力枚举O(2^N)N ≤ 20
折半搜索O(2^(N/2) × N)N ≤ 40
动态规划(背包)O(N × target)target 不大时
二维 DPO(N^2)特殊结构

折半搜索的适用条件:

  1. 问题可以拆成两个独立的「半问题」
  2. 两个半问题的结果可以快速合并(通常用排序+二分)
  3. 总量 N ≤ 40(每半不超过 20)

⚠️ 常见错误

错误原因修复方案
左半边子集和溢出N=40 且元素值大时,子集和超 intlong long
空集未处理mask=0 对应空集,和=0,target=0 时多计若 target=0 且要求非空子集,减 1
分割不均一半 20 一半 20 vs 1 和 39 效率差很多尽量均分:half = n/2
二分边界错误lower_bound vs upper_bound 混用lower_bound 找第一个 ≥,upper_bound 找第一个 >

💪 练习题

🟢 题目 1:子集和存在性

给定 N(≤40)个整数和 target,判断是否存在非空子集和恰好等于 target。

✅ 完整解答

直接使用 6.4.3 节的代码。


🟡 题目 2:子集和计数

给定 N(≤40)个整数和 target,统计和恰好为 target 的非空子集数量(答案可能很大,对 10^9+7 取模)。

✅ 完整解答

使用 6.4.4 节的计数代码,注意模运算(统计时就取模)。

#include <bits/stdc++.h>
using namespace std;
const long long MOD = 1e9 + 7;

int main() {
    int n; long long target;
    cin >> n >> target;
    vector<long long> a(n);
    for (long long& x : a) cin >> x;
    
    int half = n / 2, left_n = half, right_n = n - half;
    
    map<long long, long long> left_cnt;  // 用 map 存和→出现次数(便于取模)
    for (int mask = 0; mask < (1 << left_n); mask++) {
        long long s = 0;
        for (int i = 0; i < left_n; i++)
            if ((mask >> i) & 1) s += a[i];
        left_cnt[s]++;
    }
    
    long long ans = 0;
    for (int mask = 0; mask < (1 << right_n); mask++) {
        long long s = 0;
        for (int i = 0; i < right_n; i++)
            if ((mask >> i) & 1) s += a[left_n + i];
        
        long long need = target - s;
        if (left_cnt.count(need))
            ans = (ans + left_cnt[need]) % MOD;
    }
    
    if (target == 0) ans = (ans - 1 + MOD) % MOD;  // 去掉全空集
    cout << ans << "\n";
}

🔴 题目 3:最接近目标的子集和

给定 N(≤40)个正整数和 target,找非空子集中和最大但不超过 target 的那个,输出这个最大和。

✅ 完整解答

直接使用 6.4.5 节的 max_subset_sum_le_target 函数。

追踪(a=[3,5,7,2], target=10):

左半边 [3,5]:子集和 = [0,3,5,8],排序后 [0,3,5,8]

右半边 [7,2]:
  mask=00 (空):s=0,need=10,在左找≤10的最大=8 → 答案候选 0+8=8
  mask=01 (7):s=7,need=3,在左找≤3的最大=3 → 答案候选 7+3=10 ✓
  mask=10 (2):s=2,need=8,在左找≤8的最大=8 → 答案候选 2+8=10 ✓
  mask=11 (9):s=9,need=1,在左找≤1的最大=0 → 答案候选 9+0=9

最大答案:10

🔴 题目 4:USACO Gold 风格 — 最优配对

给定 N(N ≤ 40,偶数)个整数,将它们两两配对(共 N/2 对),每对的代价为两数之积,求所有配对代价之和的最小值。

输入: 第一行 N,第二行 N 个整数 a_i(|a_i| ≤ 10^9)。

样例输入:

4
1 -2 3 -4

样例输出: -10(配对 (1,-4) + (-2,3) = -4 + (-6) = -10)

✅ Full Solution

核心思路: N ≤ 40 太大无法暴力枚举所有配对方案。但将 N 个数分成两半(各 N/2 ≤ 20),分别枚举每半内部的配对方案及剩余未配对元素,再用折半搜索合并。

具体地:左半边 N/2 个数中,枚举哪些元素在左半内部配对(用位掩码表示),剩余未配对的元素需要与右半边的未配对元素配对。由于两半的未配对数量必须相同,可以用排序 + DP 合并。

更简洁的做法:因为 N ≤ 40 是偶数,直接枚举所有配对方式用 O((N-1)!!) 仍然太大。改用状压 DP:dp[mask] = 集合 mask 中元素两两配对的最小代价。但 2^40 太大。

折半搜索做法: 将 N 个数分成 A(前 N/2 个)和 B(后 N/2 个)。用状压 DP 分别对 A 和 B 求内部配对的最小代价,再枚举跨组配对。对于跨组配对,枚举 A 中有 k 个元素要与 B 中 k 个元素配对,枚举 A 中选哪 k 个、B 中选哪 k 个,用最小权匹配求最优配对。当 k ≤ 20/2 = 10 时,枚举量可控。

这里给出一个更实际的 O(2^N × N) 状压 DP 解法(N ≤ 20 可行),对 N ≤ 40 需要折半。

折半实现: 把 N 个元素分成两半,对每半用状压 DP 求内部配对最优值。然后枚举两半中哪些元素不参与内部配对(跨组配对),对跨组元素用最优匹配。

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

const ll INF = 1e18;

// 对 half 个元素做状压 DP:dp[mask] = mask 中元素两两配对的最小代价
// mask 必须有偶数个 1
vector<ll> calc_dp(vector<ll>& a) {
    int n = a.size();
    vector<ll> dp(1 << n, INF);
    dp[0] = 0;
    for (int mask = 0; mask < (1 << n); mask++) {
        if (dp[mask] == INF) continue;
        // 找第一个未配对的元素
        int i = -1;
        for (int j = 0; j < n; j++)
            if (!((mask >> j) & 1)) { i = j; break; }
        if (i == -1) continue;  // 全部已配对
        // 枚举与 i 配对的元素 j
        for (int j = i + 1; j < n; j++) {
            if ((mask >> j) & 1) continue;
            int nmask = mask | (1 << i) | (1 << j);
            dp[nmask] = min(dp[nmask], dp[mask] + a[i] * a[j]);
        }
    }
    return dp;
}

int main() {
    ios_base::sync_with_stdio(false); cin.tie(NULL);
    int n; cin >> n;
    vector<ll> a(n);
    for (ll& x : a) cin >> x;

    int half = n / 2;
    vector<ll> A(a.begin(), a.begin() + half);
    vector<ll> B(a.begin() + half, a.end());
    int an = A.size(), bn = B.size();

    auto dpA = calc_dp(A);
    auto dpB = calc_dp(B);

    // 枚举 A 中哪些元素不参与内部配对(要跨组配对)
    // 跨组元素数必须为偶数,且不超过 bn
    ll ans = INF;
    for (int maskA = 0; maskA < (1 << an); maskA++) {
        if (dpA[maskA] >= INF) continue;
        int crossA = an - __builtin_popcount(maskA);  // A 中未配对数
        if (crossA > bn) continue;
        if (crossA % 2 != 0) continue;

        // B 中也必须有 crossA 个未配对
        // 枚举 B 中哪些元素不参与内部配对
        for (int maskB = 0; maskB < (1 << bn); maskB++) {
            if (dpB[maskB] >= INF) continue;
            int crossB = bn - __builtin_popcount(maskB);
            if (crossB != crossA) continue;

            // 收集 A 和 B 中未配对的元素,做最优配对
            vector<ll> unA, unB;
            for (int i = 0; i < an; i++)
                if (!((maskA >> i) & 1)) unA.push_back(A[i]);
            for (int i = 0; i < bn; i++)
                if (!((maskB >> i) & 1)) unB.push_back(B[i]);

            // 对未配对元素做最小权匹配(元素少,可以状压 DP)
            int k = crossA;
            vector<ll> match_dp(1 << k, INF);
            match_dp[0] = 0;
            for (int m = 0; m < (1 << k); m++) {
                if (match_dp[m] >= INF) continue;
                int idx = __builtin_popcount(m);  // 当前配对第 idx 个 A 元素
                if (idx >= k) continue;
                for (int j = 0; j < k; j++) {
                    if ((m >> j) & 1) continue;
                    int nm = m | (1 << j);
                    match_dp[nm] = min(match_dp[nm], match_dp[m] + unA[idx] * unB[j]);
                }
            }

            ans = min(ans, dpA[maskA] + dpB[maskB] + match_dp[(1 << k) - 1]);
        }
    }
    cout << ans << "\n";
}

复杂度分析: 每半状压 DP 时间 O(2^(N/2) × N),合并时枚举两边掩码 O(2^(N/2) × 2^(N/2)) = O(2^N),内部匹配 O(2^k × k)。当 N=20 时(每半 10),2^10=1024,合并 1024×1024 ≈ 10^6 可行。对于 N=40 需要更精细的优化(如只枚举 popcount 合法的掩码对)。


💡 章节联系: 折半搜索是 USACO Gold 的独特技巧,每年约出现 1 道。它本质上是「暴力搜索 + 聪明合并」,将指数复杂度减半。与状压 DP(第 6.3 章)都处理 2^N 的搜索空间,但折半搜索不需要 DP 递推关系。

📖 第 6.5 章:数位 DP(Digit DP)

⏱ 预计阅读时间:50 分钟 | 难度:🟡 中等(USACO Gold 高频考点)


前置条件

  • DP 入门(第 6.1 章)
  • 经典 DP 问题(第 6.2 章)

🎯 学习目标

学完本章后,你将能够:

  1. 理解数位 DP 的核心框架:按位从高到低填数
  2. 掌握「tight(是否贴上界)」标志的作用
  3. 处理「前导零」问题
  4. 解决「区间 [L, R] 内满足某数字性质的数的个数」类问题

6.5.1 问题引入

一类常见问题

统计 1 到 N 中,满足某种「数字属性」的整数个数。

示例:

  • [1, N] 中各位数字之和等于 S 的数的个数
  • [1, N] 中不含数字 4 的数的个数
  • [1, N] 中各位数字单调不降的数的个数

为什么不能暴力? N 可能高达 10^18,逐一枚举完全不可能。

核心思路: 像「填数字」一样,从最高位到最低位逐位枚举,用 DP 记录状态,避免重复计算。


6.5.2 数位 DP 的框架

核心状态

状态量含义
pos当前填到第几位(从高位到低位)
tight当前选的数字是否恰好贴着 N 的对应位(是否受上界约束)
...其他属性...问题特定的属性(如各位之和、上一位的值等)

关键逻辑

如果 tight == true:
    当前位只能填 0 ~ digit[pos](digit[pos] 是 N 的第 pos 位)
    填 digit[pos] 时,下一位仍然 tight
    填 < digit[pos] 时,下一位不再 tight(自由了!)

如果 tight == false:
    当前位可以填 0~9(自由枚举)
    下一位也不 tight

6.5.3 完整例题一:各位数字之和

问题: 统计 1 到 N 中,各位数字之和恰好等于 S 的正整数个数。

💡 CPP 代码(52 行)
#include <bits/stdc++.h>
using namespace std;

string num_str;  // N 的字符串表示
int target_sum;  // 目标数字和 S

// dp[pos][sum_so_far][tight][started]
// 记忆化,避免重复计算相同状态
map<tuple<int,int,bool,bool>, long long> memo;

// pos: 当前位(从 0 开始,0 是最高位)
// sum: 已填数字的和
// tight: 是否贴上界
// started: 是否已经开始(用于处理前导零)
long long solve(int pos, int sum, bool tight, bool started) {
    if (sum > target_sum) return 0;  // 剪枝:和已超出
    
    if (pos == (int)num_str.size()) {
        // 填完所有位
        return (started && sum == target_sum) ? 1 : 0;
    }
    
    auto key = make_tuple(pos, sum, tight, started);
    if (memo.count(key)) return memo[key];
    
    int limit = tight ? (num_str[pos] - '0') : 9;
    long long result = 0;
    
    for (int d = 0; d <= limit; d++) {
        bool new_tight = tight && (d == limit);
        bool new_started = started || (d != 0);
        int new_sum = (new_started ? sum + d : 0);  // 前导零不计入和
        result += solve(pos + 1, new_sum, new_tight, new_started);
    }
    
    return memo[key] = result;
}

long long count_up_to(long long N, int S) {
    num_str = to_string(N);
    target_sum = S;
    memo.clear();
    return solve(0, 0, true, false);
}

int main() {
    long long L, R; int S;
    cin >> L >> R >> S;
    // 区间 [L, R] = f(R) - f(L-1)
    cout << count_up_to(R, S) - count_up_to(L - 1, S) << "\n";
    return 0;
}

追踪(N=20, S=2):

💡 Code 代码(14 行)
填第 0 位(最高位,limit=2):
  d=0(未 started):进入 (1, 0, false, false)
    填第 1 位(1-9):
      d=2:(ended, sum=2, tight=false) → 1(即数字 02 = 2)
      其余不满足 sum=2
  d=1(started=true):进入 (1, 1, false, true)
    填第 1 位(0-9):
      d=1:(ended, sum=2) → 1(即 11)
  d=2(tight):进入 (1, 2, false, true)
    填第 1 位(0-9):
      d=0:(ended, sum=2) → 1(即 20)
      其他 sum > 2 → 0

合计:2(数字 2 和 11 和 20)

6.5.4 数位 DP 通用模板(更简洁版)

竞赛中通常用数组代替 map 做记忆化:

💡 CPP 代码(42 行)
#include <bits/stdc++.h>
using namespace std;
typedef long long ll;

int digits[20];         // N 的各位数字(从高位到低位)
int n_digits;           // 总位数
ll dp[20][200][2][2];   // dp[pos][sum][tight][started]
                        // 维度根据问题调整
bool computed[20][200][2][2];
int S;  // 目标数字和

ll solve(int pos, int sum, bool tight, bool started) {
    if (sum > S) return 0;
    if (pos == n_digits) return (started && sum == S) ? 1 : 0;
    
    ll& ret = dp[pos][sum][tight][started];
    if (computed[pos][sum][tight][started]) return ret;
    computed[pos][sum][tight][started] = true;
    
    int lim = tight ? digits[pos] : 9;
    ret = 0;
    for (int d = 0; d <= lim; d++) {
        ret += solve(pos + 1,
                     (started || d > 0) ? sum + d : 0,
                     tight && (d == lim),
                     started || (d > 0));
    }
    return ret;
}

ll count_up_to(ll N) {
    n_digits = 0;
    while (N > 0) { digits[n_digits++] = N % 10; N /= 10; }
    reverse(digits, digits + n_digits);
    memset(computed, 0, sizeof(computed));
    return solve(0, 0, true, false);
}

int main() {
    ll L, R; cin >> L >> R >> S;
    cout << count_up_to(R) - count_up_to(L - 1) << "\n";
}

6.5.5 例题二:不含连续相同数字

问题: 统计 [L, R] 中,没有两个相邻数字相同的整数个数。
(例:123、145 满足,122、344 不满足)

额外状态: last_digit(上一位填的数字)

💡 CPP 代码(23 行)
ll dp2[20][11][2][2];  // [pos][last_digit][tight][started]
// last_digit: 0~9 表示上一位数字,10 表示"还没开始"

ll solve2(int pos, int last, bool tight, bool started) {
    if (pos == n_digits) return started ? 1 : 0;
    
    ll& ret = dp2[pos][last][tight][started];
    if (computed[pos][last][tight][started]) return ret;
    computed[pos][last][tight][started] = true;
    
    int lim = tight ? digits[pos] : 9;
    ret = 0;
    for (int d = 0; d <= lim; d++) {
        // 已 started 时,禁止 d == last(相邻相同)
        if (started && d == last) continue;
        
        ret += solve2(pos + 1,
                      (started || d > 0) ? d : 10,   // 前导零时 last 不更新
                      tight && (d == lim),
                      started || (d > 0));
    }
    return ret;
}

6.5.6 例题三:各位数字单调不降

问题: 统计 [1, N] 中,各位数字从左到右单调不降的整数个数。
(例:1359、2233 满足,132、231 不满足)

额外状态: min_digit(当前允许填的最小数字)

💡 CPP 代码(18 行)
ll dp3[20][10][2][2];  // [pos][min_allowed][tight][started]

ll solve3(int pos, int min_d, bool tight, bool started) {
    if (pos == n_digits) return started ? 1 : 0;
    
    ll& ret = dp3[pos][min_d][tight][started];
    // ... 记忆化判断 ...
    
    int lim = tight ? digits[pos] : 9;
    ret = 0;
    for (int d = (started ? min_d : 0); d <= lim; d++) {
        ret += solve3(pos + 1,
                      d,           // 下一位不能小于 d
                      tight && (d == lim),
                      true);
    }
    return ret;
}

6.5.7 区间查询:f(R) - f(L-1)

几乎所有数位 DP 都满足区间可减性

$$\text{count}[L, R] = \text{count}[1, R] - \text{count}[1, L-1]$$

所以 count_up_to(N) 函数是核心,用它两次就能回答区间查询。

注意: 当 L = 0 时,L - 1 = -1 需要特殊处理(count_up_to(-1) = 0)。


6.5.8 常见数位 DP 问题类型

问题类型额外状态示例
各位和 = Ssum本章 6.5.3
不含特定数字无(限制在 limit 里)无 4 的数
相邻不同last_digit本章 6.5.5
单调不降min_digit本章 6.5.6
恰好 k 个某数字count_of_digit恰好含 3 个 7
整除性余数 mod m能被 7 整除的数

⚠️ 常见错误

错误原因修复方案
忘记前导零处理把 0007 当 7 位数处理started 标志区分
记忆化键不完整缺少 tightstarted4 个维度都要包含
区间端点错误查 [L, R] 时用 f(L) 而非 f(L-1)count(R) - count(L-1)
dp 数组大小不够sum 最大可达 9×18=162dp[20][163][2][2]

💪 练习题

🟢 题目 1:不含数字 4

统计 [1, N] 中不含数字 4 的整数个数(N ≤ 10^15)。

✅ 完整解答

思路: 数位 DP,遇到 d=4 时跳过。

#include <bits/stdc++.h>
using namespace std;
typedef long long ll;
int digits[20], n_digits;
ll dp[20][2][2];
bool vis[20][2][2];

ll solve(int pos, bool tight, bool started) {
    if (pos == n_digits) return started ? 1 : 0;
    ll& ret = dp[pos][tight][started];
    if (vis[pos][tight][started]) return ret;
    vis[pos][tight][started] = true;
    int lim = tight ? digits[pos] : 9;
    ret = 0;
    for (int d = 0; d <= lim; d++) {
        if (d == 4) continue;  // 不含4
        ret += solve(pos + 1, tight && (d == lim), started || (d > 0));
    }
    return ret;
}

ll count_up_to(ll N) {
    if (N <= 0) return 0;
    n_digits = 0;
    ll tmp = N;
    while (tmp) { digits[n_digits++] = tmp % 10; tmp /= 10; }
    reverse(digits, digits + n_digits);
    memset(vis, 0, sizeof(vis));
    return solve(0, true, false);
}

int main() {
    ll L, R; cin >> L >> R;
    cout << count_up_to(R) - count_up_to(L - 1) << "\n";
}

🟡 题目 2:各位数字和为 S

统计 [L, R] 中各位数字之和恰好为 S 的整数个数(L, R ≤ 10^18,S ≤ 162)。

✅ 完整解答

核心思路: 直接使用 6.5.4 节的通用模板,设置目标和 S。状态 (pos, sum, tight, started) 记忆化搜索。

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

int digits[20], n_digits, S;
ll dp[20][200][2][2];
bool vis[20][200][2][2];

ll solve(int pos, int sum, bool tight, bool started) {
    if (sum > S) return 0;
    if (pos == n_digits) return (started && sum == S) ? 1 : 0;
    ll& ret = dp[pos][sum][tight][started];
    if (vis[pos][sum][tight][started]) return ret;
    vis[pos][sum][tight][started] = true;
    int lim = tight ? digits[pos] : 9;
    ret = 0;
    for (int d = 0; d <= lim; d++)
        ret += solve(pos + 1,
                     (started || d > 0) ? sum + d : 0,
                     tight && (d == lim),
                     started || (d > 0));
    return ret;
}

ll count_up_to(ll N) {
    if (N <= 0) return 0;
    n_digits = 0; ll tmp = N;
    while (tmp) { digits[n_digits++] = tmp % 10; tmp /= 10; }
    reverse(digits, digits + n_digits);
    memset(vis, 0, sizeof(vis));
    return solve(0, 0, true, false);
}

int main() {
    ll L, R; cin >> L >> R >> S;
    cout << count_up_to(R) - count_up_to(L - 1) << "\n";
}

复杂度分析: 状态数 O(18 × 163 × 2 × 2),每次转移 O(10),总时间 O(18 × 163 × 4 × 10) ≈ 10^5,空间同状态数。


🔴 题目 3:各位数字单调不降 + 数字和 ≤ K

统计 [1, N] 中,各位数字单调不降且数字之和不超过 K 的整数个数(N ≤ 10^15,K ≤ 100)。

✅ 完整解答

状态: (pos, last_digit, sum, tight, started)

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

int n_digits, K;
int digits[20];
ll dp[20][10][101][2][2];
bool vis[20][10][101][2][2];

ll solve(int pos, int last, int sum, bool tight, bool started) {
    if (sum > K) return 0;
    if (pos == n_digits) return started ? 1 : 0;
    
    ll& ret = dp[pos][last][sum][tight][started];
    if (vis[pos][last][sum][tight][started]) return ret;
    vis[pos][last][sum][tight][started] = true;
    
    int lim = tight ? digits[pos] : 9;
    ret = 0;
    for (int d = (started ? last : 0); d <= lim; d++) {
        ret += solve(pos + 1, d, (started || d > 0) ? sum + d : 0,
                     tight && (d == lim), started || (d > 0));
    }
    return ret;
}

ll count_up_to(ll N) {
    if (N <= 0) return 0;
    n_digits = 0; ll tmp = N;
    while (tmp) { digits[n_digits++] = tmp % 10; tmp /= 10; }
    reverse(digits, digits + n_digits);
    memset(vis, 0, sizeof(vis));
    return solve(0, 0, 0, true, false);
}

int main() {
    ll L, R; cin >> L >> R >> K;
    cout << count_up_to(R) - count_up_to(L - 1) << "\n";
}

🔴 题目 4:USACO Gold 风格 — 能被 M 整除的数

统计 [L, R] 中各位数字之和恰好为 S 且能被 M 整除的整数个数(L, R ≤ 10^18,M ≤ 100,S ≤ 162)。

输入: 一行四个整数 L R S M。

样例输入: 1 1000 10 7 样例输出: 13

✅ Full Solution

核心思路: 在标准数位 DP 的基础上增加「余数 mod M」状态。状态 (pos, sum, rem, tight, started),其中 rem 是当前已填数字组成的数对 M 取模的余数。填入数字 d 时,新余数 = (rem * 10 + d) % M。终点条件:started && sum == S && rem == 0

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

int digits[20], n_digits, S, M;
ll dp[20][163][100][2][2];
bool vis[20][163][100][2][2];

ll solve(int pos, int sum, int rem, bool tight, bool started) {
    if (sum > S) return 0;
    if (pos == n_digits) return (started && sum == S && rem == 0) ? 1 : 0;

    ll& ret = dp[pos][sum][rem][tight][started];
    if (vis[pos][sum][rem][tight][started]) return ret;
    vis[pos][sum][rem][tight][started] = true;

    int lim = tight ? digits[pos] : 9;
    ret = 0;
    for (int d = 0; d <= lim; d++) {
        bool ns = started || (d > 0);
        int nsum = ns ? sum + d : 0;
        int nrem = ns ? (rem * 10 + d) % M : 0;
        ret += solve(pos + 1, nsum, nrem, tight && (d == lim), ns);
    }
    return ret;
}

ll count_up_to(ll N) {
    if (N <= 0) return 0;
    n_digits = 0; ll tmp = N;
    while (tmp) { digits[n_digits++] = tmp % 10; tmp /= 10; }
    reverse(digits, digits + n_digits);
    memset(vis, 0, sizeof(vis));
    return solve(0, 0, 0, true, false);
}

int main() {
    ios_base::sync_with_stdio(false); cin.tie(NULL);
    ll L, R; cin >> L >> R >> S >> M;
    cout << count_up_to(R) - count_up_to(L - 1) << "\n";
}

复杂度分析: 状态数 O(18 × 163 × 100 × 2 × 2) ≈ 2.3 × 10^6,每次转移 O(10),总时间约 2.3 × 10^7,空间同状态数。


💡 章节联系: 数位 DP 是 USACO Gold 每年必出的题型之一。它将「计数问题」转化为「逐位填数的决策 DP」,是 DP 思想的精华体现。掌握后可进一步学习「数位 + 组合数学」的双重计数技巧。

🏆 第七部分:USACO 竞赛指南

不只讲算法,更讲如何在真实竞赛中把已有知识转化为分数:读题、选题、估复杂度、拿部分分、调试、复盘与长期训练。

📚 3 章 · ⏱️ 随时可读 · 🎯 目标:从第一次参赛稳步走向 Bronze → Silver → Gold

第七部分:USACO 竞赛指南

随时可读——不要求你已经学完本书全部算法。

USACO 与普通刷题最大的区别是:你不是在无限时间里追求一道题的完美题解,而是在 4 小时内对 3 道题做最优资源分配。因此,真正的能力不只是“会不会某个算法”,还包括:

  • 读题能力:能从故事中抽出精确模型。
  • 复杂度判断:能根据约束快速排除不可能的做法。
  • 策略性得分:不会满分时也能拿到小数据或特殊性质分。
  • 稳定实现:少犯越界、溢出、输入输出格式错误。
  • 复盘训练:把每场比赛变成下一次晋级的素材。

本部分把你带入 USACO 选手的思维方式:先理解比赛,再建立赛时流程,最后专门攻克最容易让 Bronze/Silver 选手卡住的 Ad Hoc 题型


本部分如何学习

建议按下面顺序阅读:

阶段阅读章节你应该带走什么
第一次了解 USACO第 7.1 章:了解 USACO赛制、分级、评分、部分分、常见错误
准备正式参赛第 7.2 章:解题策略读题流程、算法识别、调试、对拍、训练计划
想突破 Bronze/Silver 难题第 7.3 章:Ad Hoc 题型小例子、规律、不变量、构造、模拟捷径

如果你只剩几天就要比赛,优先读:

  1. 第 7.1 章的 时间管理与部分分策略
  2. 第 7.2 章的 竞赛解题流程与提交前清单
  3. 第 7.3 章的 Ad Hoc 检查清单

第 7.1 章:了解 USACO

这一章回答“比赛到底怎么玩”。你会学到:

  • USACO 四个级别:Bronze、Silver、Gold、Platinum 各自考什么。
  • 比赛结构:每场 3 题、通常 4 小时,US Open 通常更长更难。
  • 评分机制:通过测试点得分,满分不是唯一目标。
  • 部分分策略:小数据暴力、特殊情况、退化版本都可能有价值。
  • 赛时节奏:什么时候读题,什么时候写代码,什么时候停止冒险。
  • 常见失误:越界、溢出、差一、未初始化、输出格式错误。

教练视角: Bronze 到 Silver 的晋级并不要求你每道题都一眼看穿。更现实的目标是:至少稳定拿下一道题,第二道题尽量满分或高分,第三道题争取部分分。


第 7.2 章:解题策略

这一章回答“面对新题我该怎么想”。核心不是背模板,而是建立一套稳定流程:

  1. 先读约束,再猜复杂度:N 决定你能不能暴力。
  2. 先写模型,再写代码:输入是什么,状态是什么,答案是什么。
  3. 用小例子验证理解:样例之外自己造 N=1、N=2、全相同、极端值。
  4. 不会最优先写暴力:暴力既能拿部分分,也能作为对拍基准。
  5. 调试要有方法cerrassert、编译警告、地址消毒器、对拍。
  6. 赛后必须复盘:记录关键洞察,而不是只记录 AC 与否。

这一章还会给出从 Bronze 迈向 Silver 的能力清单:前缀和、排序、二分、BFS/DFS、DSU、基础 DP、STL 容器与复杂度判断。


第 7.3 章:Ad Hoc 题型

Ad Hoc 题没有固定模板,常见于 Bronze,也会在 Silver 中作为“观察题”出现。你会学习如何从看似混乱的题目中找出关键性质:

  • 小例子找规律:N=1、2、3、4 往往暴露结构。
  • 不变量:奇偶性、总和模 K、逆序对奇偶性、颜色差。
  • 构造思维:不是搜索所有答案,而是直接构造一个合法答案。
  • 模拟捷径:状态有限则找循环,大 T 用取模跳过。
  • 重新表述:把故事题改写成区间、图、排列、计数或几何问题。

教练视角: Ad Hoc 的训练重点不是“记住答案”,而是每题结束后问:我为什么没看到这个性质?下次怎样更早想到?


竞赛三阶段清单

赛前一周

  • 从头重写 3–5 个常用模板:快速 I/O、排序、BFS、DFS、DSU、前缀和。
  • 复做以前错过的 5–10 道题,重点练速度和稳定性。
  • 准备本地编译命令,例如 g++ -std=c++17 -O2 -Wall -Wextra sol.cpp -o sol
  • 熟悉 USACO 提交界面、输入输出形式和题面阅读方式。
  • 睡眠正常;不要在赛前最后一天硬学大量新算法。

比赛中

  • 先快速浏览全部 3 道题,不要马上扎进第一题。
  • 标出每题的 N、值域、时间限制和特殊条件。
  • 从最有把握的题开始,先拿稳定分。
  • 卡住 30–40 分钟没有进展时,切换题目或写部分分。
  • 提交前检查样例、边界、溢出、数组大小、输出格式。
  • 最后 30 分钟以测试和修 bug 为主,不轻易重写大段代码。

赛后复盘

  • 每题写一句“关键洞察”。
  • 记录卡住原因:读错题、复杂度错、算法不会、实现 bug、心态问题。
  • 看题解后重新独立实现,不复制代码。
  • 一周后再复做一次错题,检验是否真正掌握。

USACO 训练路线图

当前状态训练重点推荐目标
刚会 C++输入输出、循环、数组、字符串、函数能独立完成简单模拟题
Bronze 初期暴力、模拟、排序、计数、网格、简单几何30–60 分钟完成 Bronze 简单题
Bronze 冲 Silver前缀和、二分、BFS/DFS、贪心观察、Ad Hoc一场比赛稳定 700+
Silver 初期图论、DSU、双指针、基础 DP、复杂度优化能看懂并实现 Silver 题解
Silver 冲 GoldDijkstra、树、线段树、更多 DP、证明能力能在赛中识别题型并稳定实现

本部分的核心原则

  • 先求理解,再求代码:没理解题意时写代码只会制造 bug。
  • 先求可得分,再求满分:竞赛不是作业,部分分很重要。
  • 先证复杂度,再开始实现:错误复杂度的代码写得再漂亮也会超时。
  • 先写简单正确,再优化:暴力解常常是最好的调试工具。
  • 先复盘错误,再刷下一题:不复盘的刷题很容易原地打转。

🐄 最后提醒: USACO 是一项长期训练。一次比赛没晋级并不说明你不适合算法;它只是在告诉你下一阶段最该补哪块能力。把每次卡住都转化为具体训练项,你就会稳步变强。

📖 第 7.1 章 ⏱️ 约 45 分钟 🎯 各级别适用

第 7.1 章:了解 USACO

如果你第一次参加 USACO,最重要的不是马上学习高级算法,而是先弄清楚这场比赛如何给分、如何晋级、如何安排时间、如何避免低级失误

很多同学明明会写代码,却在第一次比赛中因为以下原因丢分:

  • 只读了第一题,没有比较三题难度。
  • 看到不会的题就放弃,没有写小数据部分分。
  • 算法复杂度估错,写了必定超时的 O(N²)。
  • 样例过了就提交,没有测边界情况。
  • int 存了会超过 (2^{31}-1) 的答案。

本章会从零讲清 USACO 的规则,并站在教练角度告诉你:怎样把 4 小时变成尽可能多的分数


7.1.1 USACO 是什么

USACO(USA Computing Olympiad,美国计算机奥林匹克竞赛)是面向中学生的算法竞赛体系,也是美国选拔 IOI(国际信息学奥林匹克竞赛)选手的重要渠道。

对普通参赛者来说,你可以把 USACO 理解为:

  • 一套免费、在线、长期开放的算法竞赛。
  • 一条从基础编程到高水平算法的分级路线。
  • 一个非常适合训练 C++、数据结构、图论、动态规划和竞赛思维的平台。

USACO 的特点

特点含义
免费开放不限国籍,注册账号即可参加公开比赛
在线参赛在比赛窗口内任选连续 4 小时开始比赛
自动评测提交代码后由隐藏测试点评分
分级晋升从 Bronze 开始,达到晋级线后进入下一等级
重视综合能力算法、实现、读题、调试、时间管理都重要

教练提醒: USACO 不是“只要知道算法就能赢”的比赛。许多 Bronze/Silver 题真正考的是建模、细节和稳定性。


7.1.2 比赛形式

比赛日程

USACO 每个赛季通常有 4 场比赛:

  • December Contest:12 月赛。
  • January Contest:1 月赛。
  • February Contest:2 月赛。
  • US Open:通常在 3/4 月,时间更长,难度也常常更高。

下图展示了一个典型赛季的节奏:

USACO Contest Timeline

比赛时长与题数

项目通常情况
每场题数3 道题
普通月赛时长4 小时
US Open 时长通常 5 小时
提交次数一般不限制,以最后有效提交为准
评分方式隐藏测试点,通过多少测试点得多少分

比赛通常在一个多天窗口内开放。你可以在窗口内选择一个合适时间开始,但一旦开始,计时就连续进行。

输入输出形式

USACO 早期题目常使用文件输入输出,例如从 problem.in 读入,输出到 problem.out。较新的题目更多使用标准输入输出。

一定以题面说明为准。

文件 I/O 模板如下:

📄 C++ 文件 I/O 模板
#include <bits/stdc++.h>
using namespace std;

int main() {
    freopen("problem.in", "r", stdin);
    freopen("problem.out", "w", stdout);

    ios::sync_with_stdio(false);
    cin.tie(nullptr);

    // solution code

    return 0;
}

标准 I/O 模板如下:

📄 C++ 标准 I/O 模板
#include <bits/stdc++.h>
using namespace std;

int main() {
    ios::sync_with_stdio(false);
    cin.tie(nullptr);

    // solution code

    return 0;
}

7.1.3 四个级别

USACO 有四个主要级别:

USACO Divisions

Bronze:基础编程与细节

Bronze 适合刚开始算法竞赛的同学。它主要考:

  • 循环、数组、字符串、结构体。
  • 模拟、枚举、计数、排序。
  • 简单几何、网格、区间处理。
  • Ad Hoc 观察题。

Bronze 的难点常常不是算法高级,而是:

  • 能否读懂题目真正要求。
  • 能否不漏情况地实现模拟。
  • 能否根据数据范围写出够快的暴力。

典型复杂度: O(N)、O(N log N)、O(N²)、有时 O(N³)。

Silver:标准算法入门

Silver 开始大量出现标准算法:

  • 排序 + 扫描。
  • 前缀和、差分。
  • 二分查找、二分答案。
  • BFS、DFS、连通块。
  • 并查集(DSU)。
  • 双指针、滑动窗口。
  • 基础动态规划。

Silver 的核心变化是:N 经常达到 (10^5),这意味着你不能再随便写 O(N²)。

典型复杂度: O(N)、O(N log N)、O(N + M)。

Gold:算法组合与证明能力

Gold 对算法熟练度和证明能力要求明显提高。常见主题包括:

  • Dijkstra、最小生成树、拓扑排序。
  • 树形 DP、LCA、欧拉序。
  • 线段树、树状数组。
  • 更复杂的 DP。
  • 数学、组合计数、状态设计。

Gold 题往往不只问“会不会模板”,而是要求你把多个思想组合起来。

Platinum:高阶竞赛水平

Platinum 是 USACO 的最高公开级别,题目接近高水平竞赛难度,可能涉及:

  • 高级数据结构。
  • 复杂图论。
  • 高阶动态规划。
  • 组合数学与构造。
  • 需要较强证明能力的原创题。

7.1.4 晋级与评分

基本评分方式

每道题由多个隐藏测试点组成。通过一个测试点就得到相应分数,没通过则不得该测试点分数。

项目说明
每场总分通常约 1000 分
每题分值通常约 333 分,但具体取决于测试点
晋级线常见为 750 左右,但每场可能变化
是否必须满分不必须,部分分很重要

为什么部分分很重要

很多选手的错误心态是:

“我不会完整解法,所以这题没法做。”

这是不对的。USACO 很多题会包含小数据测试点或特殊结构测试点。例如:

  • 完整数据 N ≤ 100000,但部分分 N ≤ 1000。
  • 完整图是一般图,但部分分图是一条链或一棵树。
  • 完整值域很大,但部分分值域很小。
  • 完整要求最优解,但部分分允许暴力枚举。

如果你能在一题上拿 100%,另一题拿 80%,第三题拿 30%,总分就可能非常接近晋级线。

部分分策略

情况可尝试做法
N 很小的子任务写暴力、枚举、回溯、全排列、子集搜索
输入有特殊结构单独处理链、树、全相等、已排序、无重复等情况
不会优化先写 O(N²) 或 O(N³),至少通过小测试点
不确定公式用小例子验证后提交,再继续寻找反例
时间快结束提交稳定可运行的部分解,不要空着

教练提醒: 错误答案通常没有额外罚分。只要代码能编译运行,部分解就值得提交。


7.1.5 用约束判断算法复杂度

USACO 题面的约束不是装饰,它几乎直接告诉你要用什么复杂度。

输入规模大致可接受复杂度常见做法
N ≤ 8O(N!)全排列、暴力搜索
N ≤ 20O(2^N · N)子集枚举、状压 DP
N ≤ 100O(N³)Floyd、区间 DP、三重循环
N ≤ 1000O(N²)双重循环、基础 DP
N ≤ 5000O(N²) 需谨慎优化常数、简单循环体
N ≤ 100000O(N log N) 或 O(N)排序、二分、图遍历、DSU
N ≤ 1000000O(N) 或轻量 O(N log N)线性扫描、前缀和
值域 ≤ 10^9通常不能按值开数组排序、离散化、map
值域 ≤ 10^18必须用 long long数学、二分、避免乘法溢出

经验估算:C++ 每秒大约能执行 (10^8) 次非常简单的操作。若 N = 100000,O(N²) 约为 (10^{10}),通常必定超时。

判断流程

  1. 看 N 的最大值。
  2. 估算你的循环次数。
  3. 若超过 (10^8) 到 (10^9) 级别,基本危险。
  4. 再看值域,判断是否需要 long long 或坐标压缩。
  5. 最后再决定具体算法。

7.1.6 竞赛时间管理

一场 USACO 通常 4 小时,看似很长,实际很快。推荐按阶段使用时间。

前 15 分钟:浏览全部题

不要一开始就写第一题。先快速阅读三题:

  • 每题问什么?
  • N 和值域多大?
  • 看起来像什么题型?
  • 哪题最有把握?
  • 哪题可能有部分分?

给每题做一个初步标记:

标记含义
A很有把握,优先做
B有思路但需要推导
C暂时不会,之后拿部分分

第 15–90 分钟:拿下第一道题

优先选择最稳的题,而不一定是题号最小的题。

目标:拿到至少一道满分或接近满分的题。

这能稳定心态,也能避免整场比赛颗粒无收。

第 90–180 分钟:冲第二道题

第二道题是决定晋级的关键。此时你应该:

  • 先写清楚思路和复杂度。
  • 如果最优解不确定,先写暴力版本。
  • 用样例和自造边界测试。
  • 尽早提交一次稳定版本。

最后 60 分钟:取舍

最后一小时要非常务实:

  • 若第二题还有 bug,优先修它。
  • 若第三题完全没思路,写小数据部分分。
  • 若已有两题高分,避免重写大段代码导致崩盘。
  • 最后 30 分钟主要做测试和格式检查。

教练经验: 许多选手不是输在不会第三题,而是输在第二题明明会却因为赶第三题留下 bug。


7.1.7 读题方法

USACO 题面常有故事背景。故事可以帮助理解,但你必须把它翻译成精确模型。

读题四步

  1. 抽目标:最终要输出什么?最大值、最小值、数量、是否可行、构造方案?
  2. 抽输入:有哪些对象?数组、点、边、区间、字符串、网格?
  3. 抽限制:N、M、值域、是否有特殊性质?
  4. 抽操作:允许做什么?每次操作改变哪些量?

题面中最重要的词

关键词可能提示
minimum / maximumDP、贪心、二分答案、最短路
number of waysDP、组合计数、前缀和
connected / componentDFS、BFS、DSU
shortestBFS、Dijkstra
at least / at most二分答案、滑动窗口、贪心
range / interval排序、扫描线、前缀和、差分
repeated many times循环检测、快速幂、数学规律
any valid arrangement构造题

手动推演样例

样例不是用来“跑一下代码”的,而是用来确认你理解题意的。写代码前你应该能手算出样例输出。

如果你手算样例都算不出来,说明你还没理解题目,不应该开始编码。


7.1.8 Bronze 题型地图

Bronze 不是“简单题”的同义词,而是“主要用基础编程解决”的级别。常见题型如下:

类别典型能力常见坑点
模拟按规则逐步执行漏步骤、顺序错、状态更新时机错
枚举尝试所有可能复杂度估错、重复计数
计数统计满足条件的对象边界、去重、频率统计
排序 + 扫描排序后线性处理比较器写错、相等情况
字符串下标、字符映射、模式匹配0-index/1-index 混用
网格二维数组、方向数组行列搞反、越界
区间重叠、覆盖、合并闭区间/半开区间混淆
整数几何点、矩形、面积、距离浮点误差、坐标边界
简单数学取模、奇偶、公式溢出、负数取模
Ad Hoc特殊观察、不变量想套模板,忽略题目特性

Bronze 训练重点

要晋级 Silver,Bronze 选手应优先训练:

  • 读题后能准确复述问题。
  • 根据 N 选择 O(N)、O(N log N)、O(N²) 或 O(N³)。
  • 能写稳定的模拟。
  • 能独立处理边界情况。
  • 能在没有完美思路时写部分分。

7.1.9 Silver 题型地图

Silver 需要你把基础编程升级为标准算法思维。

类别核心算法典型复杂度
排序 + 贪心排序后选择或扫描O(N log N)
前缀和/差分快速区间统计O(N) 或 O(N + Q)
二分查找在有序结构中查找O(log N)
二分答案单调性 + check 函数O(N log V)
双指针/滑动窗口连续区间维护O(N)
BFS/DFS图遍历、最短步数、连通块O(N + M)
DSU动态连通、合并集合接近 O(N)
基础 DP状态转移、最优子结构O(N)、O(N²)

Silver 与 Bronze 的区别

维度BronzeSilver
数据规模较小,暴力常可行常见 (10^5),需要优化
算法模拟、枚举、排序图、二分、DSU、DP
证明简单直觉需要说明为什么贪心/二分/DP 正确
实现细节为主模板 + 建模 + 复杂度控制

7.1.10 常见错误与修正

1. 差一错误

// 错误:漏掉最后一个元素
for (int i = 0; i < n - 1; i++) {
    // ...
}

// 错误:访问 a[n]
for (int i = 0; i <= n; i++) {
    cout << a[i] << "\n";
}

// 正确:0-indexed 数组
for (int i = 0; i < n; i++) {
    // ...
}

2. 整数溢出

int a = 1000000000;
int b = 1000000000;
long long wrong = a * b;              // 先以 int 相乘,已经溢出
long long right = 1LL * a * b;        // 正确

3. 未初始化变量

int ans;          // 错误:值不确定
int best = 0;     // 正确:根据题意初始化

若要求最大值,常用:

int best = INT_MIN;

若要求最小值,常用:

int best = INT_MAX;

4. 输入输出格式错误

常见错误包括:

  • 少输出换行。
  • 多输出调试信息。
  • 文件 I/O 和标准 I/O 用错。
  • 没读完整个输入。
  • 多组测试时忘记循环。

调试输出应使用 cerr,不要用 cout

5. 数组大小不够

若题目 N ≤ 100000,数组至少应开到 100000,若你用 1-indexed,最好开到 100005。

const int MAXN = 100000 + 5;
int a[MAXN];

6. 忽略边界情况

提交前至少检查:

  • N = 1。
  • 所有值相同。
  • 已经满足条件。
  • 完全不满足条件。
  • 最大值导致溢出。
  • 图不连通。
  • 字符串长度为 1。

7.1.11 参赛模板与本地测试

推荐编译命令

g++ -std=c++17 -O2 -Wall -Wextra sol.cpp -o sol

调试阶段可以使用:

g++ -std=c++17 -g -fsanitize=address,undefined -Wall -Wextra sol.cpp -o sol

-fsanitize=address,undefined 能帮助发现越界、未定义行为等问题。正式提交前一般不需要保留这些选项。

最小模板

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

using ll = long long;

int main() {
    ios::sync_with_stdio(false);
    cin.tie(nullptr);

    return 0;
}

常用方向数组

int dr[4] = {-1, 1, 0, 0};
int dc[4] = {0, 0, -1, 1};

网格题中统一用 r 表示行、c 表示列,能减少行列混淆。


7.1.12 如何补题

补题是 USACO 进步最快的环节。正确补题不是“看懂题解就算了”,而是经历下面过程。

补题五步

  1. 重新读题:确认比赛时有没有读错。
  2. 自己再想 30 分钟:尤其是看约束和小例子。
  3. 只看提示:例如“这题用二分答案”或“先排序”。
  4. 读完整题解:重点理解关键观察,不是代码细节。
  5. 关掉题解,从零实现:能独立写出来才算掌握。

复盘问题清单

每道错题问自己:

  • 我卡在题意、算法、证明还是实现?
  • 约束给了什么提示,我当时忽略了吗?
  • 有没有小例子可以更早看出规律?
  • 我的复杂度估算是否正确?
  • 如果比赛再遇到类似题,我的第一反应应该是什么?

把答案记录在题目日志中。长期来看,这比机械刷题更重要。


本章总结

核心要点

主题你应该记住
赛制每场通常 3 题、4 小时,US Open 更长
分级Bronze → Silver → Gold → Platinum
评分通过测试点得分,部分分非常重要
晋级常见晋级线约 750,但会随比赛变化
复杂度N 决定算法上限,先估复杂度再编码
时间管理先浏览三题,优先拿稳定分,最后测试
常见错误溢出、越界、差一、未初始化、I/O 错误

赛时快速清单

  • 已读完三道题。
  • 已记录每题 N、值域、特殊性质。
  • 已估算复杂度。
  • 已选择最稳题目先做。
  • 不会满分时已考虑部分分。
  • 提交前测试样例和边界。
  • 检查 long long、数组大小、输出格式。

常见问题

Q1:第一次参加 USACO 应该期待什么?

不要期待第一次就一定晋级。你的目标应该是熟悉流程、至少认真完成一题,并在赛后完整复盘。

Q2:Bronze 晋级 Silver 最需要补什么?

最需要补的是稳定实现和复杂度意识。会写模拟、枚举、排序、计数,并能避免低级错误,比学很高级的算法更重要。

Q3:比赛中可以查资料吗?

通常可以查通用资料,例如 C++ 文档、算法说明,但不能查本场题解或寻求他人帮助。具体以官方规则为准。

Q4:是否应该用 Python?

USACO 支持多语言,但本书推荐 C++。C++ 速度快,STL 强大,也最适合学习主流竞赛算法。

Q5:我应该什么时候放弃一道题?

如果 30–40 分钟没有新进展,先提交已有部分解,然后切换题目。放弃不是失败,而是比赛资源管理。


下一章会进入更具体的解题流程:如何从题面约束推算法,如何测试,如何调试,以及如何把 Bronze 能力训练到 Silver 水平。

📖 第 7.2 章 ⏱️ 约 55 分钟 🎯 Bronze → Silver

第 7.2 章:解题策略

会算法不等于会比赛。真正的 USACO 能力,是在一题从未见过的情况下,仍然能有条理地完成以下动作:

  1. 读懂题意。
  2. 抽象模型。
  3. 根据约束判断复杂度。
  4. 选择可能的算法。
  5. 先写可验证的版本。
  6. 调试并提交。
  7. 赛后复盘。

本章给你一套可以直接在比赛中使用的流程。它不保证你每题都会,但能显著减少“脑子一片空白”和“明明会却写挂”的情况。


7.2.1 一道题的完整处理流程

下图概括了竞赛解题流程:

Problem Solving Flow

把它拆成实际动作,就是下面 8 步。

Step 1:读目标

先回答一句话:

这题最终要我输出什么?

常见目标包括:

  • 最大值或最小值。
  • 满足条件的数量。
  • 是否可行。
  • 最短步数。
  • 任意一个合法构造。
  • 每个询问的答案。

如果这句话说不清楚,不要写代码。

Step 2:读输入对象

把故事翻译成数据结构:

故事对象可能模型
奶牛排成一行数组、字符串、序列
农场与道路图:点和边
牧场格子网格、二维数组
时间段区间
朋友关系图、并查集
操作过程状态转移、模拟

Step 3:读约束

约束决定你可以使用什么复杂度。

Complexity Table

快速判断:

N 范围第一反应
N ≤ 8全排列可能可行
N ≤ 20子集枚举、状压、回溯
N ≤ 100O(N³) 可以考虑
N ≤ 1000O(N²) 通常可行
N ≤ 100000需要 O(N log N) 或 O(N)
N ≤ 10^9不能按 N 遍历,考虑数学或二分

Step 4:手算样例

不要只看样例输入输出。你要能解释:

  • 为什么样例答案是这个?
  • 每一步中间状态是什么?
  • 如果把样例改小一点,答案如何变化?

Step 5:先想暴力

每道题都先问:最直接的做法是什么?

暴力解的价值有三点:

  • 可能已经足够通过 Bronze。
  • 可以拿小数据部分分。
  • 可以作为对拍的正确基准。

Step 6:优化瓶颈

找到暴力慢在哪里:

暴力瓶颈常见优化
反复求区间和前缀和
每次查找最近/最大/最小排序、二分、set、priority_queue
枚举所有对双指针、哈希、排序扫描
重复计算子问题DP、记忆化搜索
图上反复搜索预处理、一次 BFS/DFS、DSU
答案范围大但有单调性二分答案

Step 7:写前先列边界

在代码旁边写下要测的情况:

  • N = 1。
  • 所有值相同。
  • 已经满足条件。
  • 完全不满足条件。
  • 最大值。
  • 空图或不连通图。
  • 区间刚好接触。

Step 8:实现、测试、提交

先写清晰版本,不要过早追求短代码。通过样例后,再测自造边界。若时间允许,用暴力对拍。


7.2.2 算法识别:从约束到方法

问题一:能不能直接模拟

如果题目只是让你按规则执行,且操作次数不大,直接模拟通常最稳。

但要小心:如果 T 很大,例如 (10^9),朴素模拟会超时。这时要考虑:

  • 是否存在循环?
  • 是否能用数学公式跳过?
  • 是否能把多次操作合并?

问题二:能不能枚举

枚举是 Bronze 的核心能力。关键是枚举对象要选对。

枚举对象适用场景
枚举一个点找最优位置、检查每个元素
枚举一对点统计 pair,N ≤ 5000 或更小
枚举区间端点N ≤ 1000 时常见
枚举子集N ≤ 20
枚举排列N ≤ 8 或 9

优秀的枚举题常常不是完全不能暴力,而是要找到合适的暴力维度

问题三:是否有图或网格

如果对象之间有连接关系,立刻考虑图。

题面特征常见算法
最少步数、无权移动BFS
连通块、区域大小DFS/BFS
动态合并集合DSU
有权最短路Dijkstra
树上子树信息DFS、树形 DP
依赖关系无环拓扑排序、DAG DP

网格题也可以看作图:每个格子是点,相邻格子之间有边。

问题四:是否有区间或前缀

出现区间和、连续段、矩形区域时,优先考虑:

  • 一维前缀和。
  • 二维前缀和。
  • 差分数组。
  • 滑动窗口。
  • 排序后区间合并。

问题五:是否有单调性

如果题目问:

  • 最大化最小值。
  • 最小化最大值。
  • 至少能否达到 X。
  • 最多能否控制在 X。

通常可以考虑 二分答案

二分答案的关键是定义:

check(x) = 是否能做到答案至少/至多为 x

并证明 check(x) 随 x 单调。

问题六:是否有最优子结构

若一个最优解可以由更小问题的最优解转移而来,考虑 DP。

常见信号:

  • “最大/最小代价”。
  • “方案数”。
  • “从前 i 个中选择”。
  • “走到第 i 个位置”。
  • “状态由上一步决定”。

DP 的核心不是套模板,而是定义状态:

dp[i] = 处理到第 i 个对象时的最优答案

或:

dp[i][j] = 到达状态 (i, j) 的最优答案/方案数

7.2.3 算法决策树

当你不知道从哪开始时,可以按下面顺序问自己:

📄 解题决策树
1. N 是否很小?
   ├─ N ≤ 8:试全排列
   ├─ N ≤ 20:试子集枚举 / 状压
   └─ 否:继续

2. 题目是否是图或网格?
   ├─ 最短步数:BFS
   ├─ 连通性:DFS / BFS / DSU
   ├─ 有权最短路:Dijkstra
   └─ 树结构:DFS / 树形 DP

3. 是否有区间、连续段、范围查询?
   ├─ 区间和:前缀和
   ├─ 多次区间修改:差分
   ├─ 连续窗口:双指针 / 滑动窗口
   └─ 区间重叠:排序 + 扫描

4. 是否在有序结构中查找?
   ├─ 找位置:二分查找
   ├─ 最大化最小值:二分答案
   └─ 找最近元素:排序 + lower_bound

5. 是否是最优决策序列?
   ├─ 有重叠子问题:DP
   ├─ 局部选择可证明:贪心
   └─ 状态很少:BFS 状态搜索

6. 都不像?
   ├─ 试小例子找规律
   ├─ 找不变量
   ├─ 重新表述题目
   └─ 可能是 Ad Hoc

教练提醒: 决策树不是绝对规则,而是帮助你在紧张时不乱。真正比赛中,一题可能同时用到排序 + 前缀和 + 二分答案。


7.2.4 部分分策略

不会满分时,目标不是放弃,而是寻找能稳定通过的子任务。

常见部分分方案

原题难点部分分做法
N 太大写小 N 暴力
图太复杂处理链、树、无环、完全图等特殊结构
值域太大处理值域小的情况
操作次数太多模拟前若干步,或找简单循环
最优解太难写贪心或局部搜索,可能过部分测试
证明不会先提交观察做法,再找反例或补证明

小数据暴力模板思维

如果 N ≤ 20,考虑子集:

for (int mask = 0; mask < (1 << n); mask++) {
    // check subset
}

如果 N ≤ 8,考虑排列:

sort(a.begin(), a.end());
do {
    // check permutation
} while (next_permutation(a.begin(), a.end()));

如果 N ≤ 1000,很多 O(N²) 做法都值得一试。

比赛中如何提交部分分

  1. 先保证代码对样例正确。
  2. 对不支持的大数据也要输出合法格式,不能崩溃。
  3. 在时间允许时,逐步扩展更多情况。
  4. 每次较大修改前,保存或保留上一版思路。

7.2.5 测试方法

样例测试不够

样例只验证最基本理解,不能覆盖所有边界。

你应该主动构造测试。

边界测试清单

类型示例
最小规模N = 1,M = 0
最大规模N 取最大,值取最大
全相同所有元素相等
单调已排序、逆序
极端结构链、星形图、完全图、不连通图
临界区间区间刚好相交、刚好不相交
无解明确不可能的情况
多解有多个合法答案

对拍:最强调试工具

对拍适用于:你有一个暴力解和一个优化解,但不确定优化解是否正确。

工作流程:

  1. brute.cpp:小数据正确暴力。
  2. sol.cpp:你的优化解。
  3. gen.pygen.cpp:随机生成小数据。
  4. 多次运行并比较输出。
📄 对拍脚本示例
for i in {1..1000}; do
    python3 gen.py > test.in
    ./brute < test.in > expected.out
    ./sol < test.in > got.out
    if ! diff -q expected.out got.out > /dev/null; then
        echo "Wrong answer on test $i"
        cat test.in
        echo "Expected:"
        cat expected.out
        echo "Got:"
        cat got.out
        break
    fi
done

对拍尤其适合排序、贪心、DP、图论中容易漏边界的题。


7.2.6 调试方法

cerr 而不是 cout

cout 是答案输出,调试信息会污染评测结果。调试时用 cerr

cerr << "i = " << i << ", value = " << a[i] << "\n";

assert 检查不变量

assert(0 <= r && r < n);
assert(0 <= c && c < m);
assert(dist[v] == -1 || dist[v] == dist[u] + 1);

如果断言失败,说明程序某处违反了你认为一定成立的条件。

开启编译警告

g++ -std=c++17 -Wall -Wextra sol.cpp -o sol

认真读 warning。很多 warning 对应真实 bug。

使用地址消毒器

g++ -std=c++17 -g -fsanitize=address,undefined sol.cpp -o sol

它能发现:

  • 数组越界。
  • 使用未定义行为。
  • 某些整数溢出。
  • 访问无效内存。

缩小失败样例

如果某个大测试错了,不要盲目盯代码。尝试删掉输入中的元素,直到得到一个仍然错误的最小样例。越小的反例越容易看出问题。


7.2.7 常见思维误区

误区一:一上来就写代码

如果你没有完整思路,代码只会把混乱固化下来。至少先写出:

  • 状态是什么。
  • 转移或操作是什么。
  • 复杂度是多少。
  • 边界是什么。

误区二:只看题号判断难度

通常题 1 简单,题 3 难,但不是绝对。比赛开始时浏览三题,可以避免在不适合自己的题上浪费太久。

误区三:样例过了就认为正确

样例过了只能说明你没有完全误解题目。真正的正确性需要边界测试和逻辑证明。

误区四:认为暴力没价值

暴力是:

  • 部分分来源。
  • 正确性基准。
  • 帮你理解题目的工具。

误区五:把模板当答案

模板只解决“怎么写”,不解决“为什么这样建模”。USACO 常常考的是把题面变成模板能处理的形式。


7.2.8 Bronze 到 Silver 能力清单

算法能力

  • 能熟练写一维/二维前缀和。
  • 能写 sort 自定义比较器。
  • 能使用 lower_bound / upper_bound
  • 能写二分答案框架。
  • 能写 BFS、DFS 和网格 BFS。
  • 能写 DSU。
  • 能使用 mapsetpriority_queue
  • 能写基础 DP。
  • 能进行简单贪心证明。

实现能力

  • 30 秒内写出快速 I/O 模板。
  • 5 分钟内写出方向数组和网格遍历。
  • 10 分钟内写出 BFS。
  • 5 分钟内写出 DSU。
  • 能正确处理 0-index 与 1-index。
  • 知道什么时候必须用 long long

竞赛能力

  • 15 分钟内浏览完三题并排序难度。
  • 能对每题估计复杂度。
  • 卡住时能主动寻找部分分。
  • 提交前有固定检查清单。
  • 每场后写复盘记录。

7.2.9 四周训练计划

第 1 周:稳定 Bronze 基础

目标:减少低级错误。

  • 每天 1 道 Bronze 模拟/枚举题。
  • 每题写完后列出边界测试。
  • 复习数组、字符串、排序、结构体。

第 2 周:复杂度与优化

目标:从暴力走向 O(N log N) 或 O(N)。

  • 练习排序 + 扫描。
  • 练习前缀和与差分。
  • 练习双指针与滑动窗口。
  • 每题写出暴力解和优化解的复杂度对比。

第 3 周:图与二分

目标:进入 Silver 常见题型。

  • 练习网格 BFS。
  • 练习 DFS 连通块。
  • 练习 DSU。
  • 练习二分答案。

第 4 周:模拟比赛与复盘

目标:提升赛时表现。

  • 每周至少做 1 场 4 小时模拟赛。
  • 比赛中严格执行先读三题的流程。
  • 赛后补完所有题。
  • 记录每题关键洞察和失败原因。

7.2.10 题目日志模板

建议每道题后记录:

题目:
级别:Bronze / Silver / Gold
标签:排序、BFS、DP、Ad Hoc、二分答案……
我一开始的想法:
正确关键洞察:
复杂度:
我犯的错误:
下次遇到类似题要想到:

长期坚持题目日志,会让你形成自己的“题型雷达”。


本章总结

核心要点

技能训练目标
读题能一句话说清目标
建模能把故事翻译成数组、图、区间、状态
复杂度能根据 N 排除不可能做法
暴力能快速写小数据解法
优化能识别前缀和、排序、二分、图、DP 等模式
调试能用 cerrassert、对拍定位 bug
复盘能总结关键洞察并迁移到下一题

竞赛中最实用的五句话

  1. 先读完三题。
  2. 先看 N,再想算法。
  3. 先写暴力,再优化。
  4. 样例过了不代表正确。
  5. 不会满分也要拿部分分。

常见问题

Q1:我读题后完全没思路怎么办?

先写最直接的暴力,再手算小例子。若仍没有思路,检查是否是图、区间、排序、DP、二分答案或 Ad Hoc。30–40 分钟无进展就切题。

Q2:什么时候应该写对拍?

当你有一个简单但慢的暴力解,以及一个复杂但快的优化解时。尤其是贪心、DP、双指针、排序统计题,对拍非常有用。

Q3:怎么提高算法识别能力?

每道题做完后记录标签和关键洞察。不要只记录“AC”,要记录“为什么这题是二分答案/为什么这题能贪心”。

Q4:比赛中应该追求代码短吗?

不应该。比赛中更重要的是清晰、稳定、可调试。短代码适合熟练后自然形成,不应作为初学目标。


下一章会专门讨论 Ad Hoc 题型。它们没有固定模板,但有一套寻找规律、不变量、构造和模拟捷径的方法。

📖 第 7.3 章 ⏱️ 约 70 分钟 🎯 Bronze → Silver

第 7.3 章:Ad Hoc 题型

Ad hoc 的意思是“为此目的专门设计”。在竞赛编程里,Ad Hoc 题通常没有一个现成模板可以直接套用;你必须观察题目的特殊结构,设计一个专门解法。

很多同学害怕 Ad Hoc,因为它不像 BFS、前缀和、DSU 那样有固定代码。但从教练角度看,Ad Hoc 并不是“靠灵感玄学做题”。它有可以训练的步骤:

  1. 做小例子:从 N=1、2、3、4 开始手算。
  2. 找结构:观察奇偶、单调、对称、循环、极端情况。
  3. 提出猜想:把规律写成一句话。
  4. 证明猜想:用不变量、交换论证、反证或构造证明。
  5. 实现最简版本:关键洞察出现后,代码往往很短。

本章会把 Ad Hoc 从“凭感觉”变成“有步骤的观察训练”。


7.3.1 什么是 Ad Hoc 题

定义

Ad Hoc 题通常具备以下特征:

  • 题目不直接对应标准算法模板。
  • 难点在于发现某个特殊性质,而不是写复杂数据结构。
  • 一旦发现性质,实现常常很简单。
  • 题面可能像模拟、数学、构造、几何或贪心,但真正解法依赖特定观察。

一个典型例子

题目:给定 N 个整数,每次可以把一个数加 2。问能否让所有数变成相等?

初看像模拟或搜索,但关键观察是:

  • 加 2 不改变一个数的奇偶性。
  • 因此所有数最终相等时,它们必须有相同奇偶性。
  • 如果初始奇偶性不全相同,不可能。
  • 如果奇偶性全相同,总可以把较小的数加 2 追上较大的数。

这就是典型 Ad Hoc:关键在奇偶不变量,代码只需检查 parity。

Ad Hoc 与其他题型的区别

题型解题核心典型问题
模拟忠实执行规则“照题意做 T 步”
BFS/DFS图或状态空间遍历“最少步数”“连通块”
DP重叠子问题“前 i 个的最优值”
贪心局部选择可证明最优“每次选最早结束的区间”
Ad Hoc特殊观察或重新表述“这个题真正不变量是什么?”

注意:Ad Hoc 可以和标准算法组合。例如“先发现只需考虑相邻元素,再排序扫描”,这就是 Ad Hoc 观察 + 排序


7.3.2 如何识别 Ad Hoc

当你读完题后,如果出现以下感觉,很可能是 Ad Hoc:

  • “这不像我学过的任何模板。”
  • “N 不大,但直接暴力又不够优雅。”
  • “题目有很强的特殊规则。”
  • “操作看起来复杂,但可能存在不变量。”
  • “题目问的是能否、最少几步、任意构造。”
  • “样例似乎暗示某个规律。”

常见信号

信号可能方向
操作反复进行很多次找循环、找不变量、找最终稳定状态
问能否从 A 到 B找不变量、模数条件、连通性
问最少操作次数找每次操作最多贡献多少,或构造达到下界
问任意合法方案构造、排序、分组、贪心观察
值域很大但状态变化简单数学公式、取模、压缩状态
网格黑白格、相邻移动奇偶性、棋盘染色
圆环结构断环成链、枚举切点、复制数组
看起来要模拟 (10^9) 步状态循环、周期、快速跳转

7.3.3 Ad Hoc 的五步训练法

第一步:把题目降到最小

不要从最大数据开始想。先问:

  • N = 1 时答案是什么?
  • N = 2 时有哪些情况?
  • N = 3 时是否出现新现象?
  • 所有元素相等时怎样?
  • 已经满足条件时怎样?
  • 完全相反的极端情况怎样?

很多规律只在小例子中最清楚。

第二步:记录表格

对小数据列一个表:

输入答案你观察到什么
1......
2......
3......

如果答案序列是 1, 2, 4, 8,可能是指数或子集。 如果是 1, 3, 6, 10,可能是三角数。 如果只和奇偶有关,可能有 parity 不变量。

第三步:找“不变”和“单调”

Ad Hoc 中最重要的两个问题:

  1. 什么永远不会变?
  2. 什么每次都朝一个方向变化?

常见不变量:

  • 总和。
  • 总和模 K。
  • 奇偶性。
  • 黑白格数量差的奇偶性。
  • 排列逆序对奇偶性。
  • 连通块数量的某种限制。

常见单调量:

  • 未完成元素数量减少。
  • 最大值不增或最小值不减。
  • 区间长度变短。
  • 距离目标越来越近。

第四步:提出一句话猜想

不要让规律停留在模糊感觉中。把它写成一句话:

  • “答案只取决于最大值和最小值。”
  • “只需要检查相邻排序后的元素。”
  • “如果总和不是 K 的倍数,一定不可能。”
  • “最优解一定可以调整成先做 A 再做 B。”
  • “每个状态最多出现一次,重复后进入循环。”

一句话猜想越清晰,越容易证明和实现。

第五步:证明或找反例

证明前先尝试找反例。若找不到,再考虑证明。

常见证明方式:

证明方法适用场景
不变量证明能否到达、操作变换
下界 + 构造最少操作次数、最大收益
交换论证贪心排序、选择顺序
反证法“最优解一定具有某性质”
归纳法递推结构、逐步构造
分类讨论Bronze 常见,情况数量有限

7.3.4 核心技术一:不变量

不变量是 Ad Hoc 中最强的工具之一。它回答:无论怎么操作,什么量不会变?

例 1:奇偶不变量

题目:有一个整数 x,每次可以加 2 或减 2。能否从 a 变成 b?

观察:每次操作都不改变奇偶性。

因此:

  • 如果 a 和 b 奇偶性不同,不可能。
  • 如果奇偶性相同,可以通过反复加减 2 到达。

代码:

if ((a % 2 + 2) % 2 == (b % 2 + 2) % 2) {
    cout << "YES\n";
} else {
    cout << "NO\n";
}

例 2:总和模 K

题目:有 N 个数,每次选择一个数加 K。能否让所有数相等?

每个数对 K 取模的值不会变。所以所有数最终相等的必要条件是:所有数模 K 相同。

如果都模 K 相同,也可以把较小的数不断加 K 追到最大值或更高,因此条件也是充分的。

bool ok = true;
for (int i = 1; i < n; i++) {
    if (((a[i] - a[0]) % k + k) % k != 0) {
        ok = false;
    }
}
cout << (ok ? "YES\n" : "NO\n");

例 3:网格 2×2 翻转

题目:从全 0 的 N×M 网格出发,每次可以选择一个 2×2 方块,并把其中 4 个格子全部翻转(0 变 1,1 变 0)。给定目标网格,问能否达到?

错误的想法是只看黑白格数量奇偶性。更精确的不变量是:

  • 每次翻转某个 2×2,会在两行中各翻转 2 个格子,因此每一行的 1 的个数奇偶性不变
  • 同理,每一列也翻转 2 个格子,因此每一列的 1 的个数奇偶性不变
  • 初始全 0,所以每行、每列的 1 的个数都必须是偶数。

对于这个操作,这个条件也是充分的:可以从左上到右下贪心消去所有 1,最后只需检查最后一行和最后一列。

📄 C++ 示例:判断目标是否可由 2×2 翻转得到
#include <bits/stdc++.h>
using namespace std;

int main() {
    int n, m;
    cin >> n >> m;
    vector<string> g(n);
    for (string& row : g) cin >> row;

    vector<int> rowParity(n, 0), colParity(m, 0);
    for (int r = 0; r < n; r++) {
        for (int c = 0; c < m; c++) {
            if (g[r][c] == '1') {
                rowParity[r] ^= 1;
                colParity[c] ^= 1;
            }
        }
    }

    bool ok = true;
    for (int x : rowParity) ok &= (x == 0);
    for (int x : colParity) ok &= (x == 0);

    cout << (ok ? "YES\n" : "NO\n");
    return 0;
}

7.3.5 核心技术二:下界 + 构造

很多“最少操作次数”题可以这样解:

  1. 证明答案至少是多少(下界)。
  2. 给出一种方法恰好达到这个下界(构造)。
  3. 因此答案就是这个数。

示例:把所有数变成最大值

题目:给定数组,每次可以把一个元素加 1。问最少多少次让所有元素相等?

下界:最终值至少是当前最大值 mx,每个 a[i] 至少要增加 mx - a[i] 次。

构造:把每个元素都加到 mx,正好需要这些次数。

所以答案:

long long ans = 0;
int mx = *max_element(a.begin(), a.end());
for (int x : a) ans += mx - x;

示例:圆桌换座

题目:N 头奶牛坐成一圈,其中 K 头是 A 型。每次可以交换任意两个相邻奶牛。问最少交换多少次,让所有 A 型连续。

一个简化版本中,如果允许“任意交换”而不是相邻交换,那么答案是:选一个长度 K 的连续窗口,让窗口内 B 的数量最少。这些 B 需要被换出去。

这类题的思路是:

  • 下界:最终 A 连续时,一定有某个长度 K 的窗口全是 A。原来窗口内有多少 B,就至少要换出多少个。
  • 构造:把窗口外的 A 与窗口内的 B 交换,正好这么多次。

于是答案是所有长度 K 窗口内 B 数的最小值。


7.3.6 核心技术三:循环与周期

当题目要求模拟很多次操作,T 可能达到 (10^9) 或 (10^{18}),直接模拟不可能。此时问:状态会不会重复?

若状态空间有限,重复后就进入循环。

循环检测模板

📄 C++:用 map 记录状态出现时间
#include <bits/stdc++.h>
using namespace std;

vector<int> nextState(vector<int> state) {
    // 根据题意生成下一个状态
    return state;
}

int main() {
    long long T;
    int n;
    cin >> n >> T;
    vector<int> state(n);
    for (int& x : state) cin >> x;

    map<vector<int>, long long> seen;
    long long step = 0;

    while (step < T) {
        if (seen.count(state)) {
            long long cycleStart = seen[state];
            long long cycleLen = step - cycleStart;
            long long remaining = (T - step) % cycleLen;
            for (long long i = 0; i < remaining; i++) {
                state = nextState(state);
            }
            break;
        }

        seen[state] = step;
        state = nextState(state);
        step++;
    }

    for (int x : state) cout << x << " ";
    cout << "\n";
    return 0;
}

什么时候适合找循环

  • 状态数量有限。
  • 每一步由当前状态唯一决定下一状态。
  • T 很大。
  • 题目类似“重复操作直到第 K 次”。

函数图、排列反复应用、有限状态模拟,都经常用循环。


7.3.7 核心技术四:重新表述

Ad Hoc 题常常被故事包装得很复杂。重新表述能把它变成你熟悉的问题。

例:从“奶牛移动”到“区间覆盖”

题面可能说:奶牛在数轴上走来走去,经过的草地都会被踩踏。问最终有多少长度被踩过。

重新表述:每段行走是一段区间,问题是求多个区间的并集长度。

此时可以:

  • 坐标小:用布尔数组标记。
  • 坐标大:排序区间后合并。

例:从“照片顺序”到“排列”

题面说:有 N 头奶牛,每头有唯一编号,照片中顺序被打乱,问最少交换多少次恢复顺序。

重新表述:这是一个排列排序问题。

如果允许任意交换,最少交换次数 = N - 循环个数。

如果只允许相邻交换,最少交换次数 = 逆序对数。


7.3.8 核心技术五:分类讨论

Bronze 中大量 Ad Hoc 题可以通过仔细分类解决。分类讨论不是低级技巧,而是严谨处理小规模结构的方法。

分类讨论原则

  • 分类必须覆盖所有情况。
  • 不同类别之间最好互斥。
  • 每类内部逻辑要简单。
  • 写代码时顺序要与分类一致。

示例:两个区间的关系

两个区间 [a,b][c,d] 的关系只有几类:

  1. 完全不相交:b < cd < a
  2. 部分相交。
  3. 一个包含另一个。
  4. 端点刚好接触。

如果使用半开区间 [l, r),许多情况可以统一为:

int overlap = max(0, min(b, d) - max(a, c));

这就是从分类讨论升级为公式。


7.3.9 核心技术六:交换论证

有些 Ad Hoc 看起来像贪心,但需要证明为什么某种顺序是最优。

交换论证的基本形式:

  1. 假设存在一个最优解,但其中有两个相邻选择顺序“不符合贪心规则”。
  2. 交换这两个选择后,答案不会变差。
  3. 不断交换,最终得到一个符合贪心规则的最优解。
  4. 因此贪心正确。

示例:按结束时间选择最多不重叠区间

要选最多不重叠活动。贪心选择结束最早的活动。

证明思路:

  • 设某个最优解第一个选择的活动不是结束最早的。
  • 用结束最早的活动替换它,不会影响后面活动,因为结束更早只会留下更多空间。
  • 所以存在一个最优解以结束最早活动开头。
  • 递归处理剩余部分。

虽然这是标准贪心,但很多 Ad Hoc 贪心观察都可以用类似方法证明。


7.3.10 工作示例一:区间涂色

题目

一条围栏被编号为整数位置。FJ 先把区间 [a,b) 涂成红色,再把 [c,d) 涂成蓝色。蓝色会覆盖红色。问最终红色长度和蓝色长度。

思考过程

这题看似模拟涂色,但本质是两个半开区间的覆盖关系。

  • 蓝色长度一定是 d - c
  • 红色原本长度是 b - a
  • 被蓝色覆盖的红色部分是两个区间交集。
  • 最终红色长度 = 红色原长 - 交集长度。

交集长度:

max(0, min(b, d) - max(a, c))

代码

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

int main() {
    int a, b, c, d;
    cin >> a >> b >> c >> d;

    int red = b - a;
    int blue = d - c;
    int overlap = max(0, min(b, d) - max(a, c));

    cout << red - overlap << " " << blue << "\n";
    return 0;
}

教练点评

这题的关键不是代码,而是统一使用半开区间 [l,r)。半开区间能避免端点是否重复计算的混乱。


7.3.11 工作示例二:丢失的奶牛式模拟

题目模型

从位置 x 出发,要到达位置 y。第 1 次向右走 1,第 2 次向左走 2,第 3 次向右走 4,第 4 次向左走 8……方向交替,距离每次翻倍。问第一次经过 y 时走过的总距离。

为什么是 Ad Hoc

它看起来只是模拟,但有两个细节:

  • 每一步是一段线段,不是只检查终点。
  • 目标 y 可能在当前移动路径中间被经过。

解法

每次移动从 curnext。如果 y 在这段区间内,就加上 abs(y - cur) 后结束;否则加上整段距离并继续。

代码

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

int main() {
    long long x, y;
    cin >> x >> y;

    long long cur = x;
    long long ans = 0;

    for (int k = 0; ; k++) {
        long long offset = 1LL << k;
        if (k % 2 == 1) offset = -offset;
        long long nxt = x + offset;

        long long lo = min(cur, nxt);
        long long hi = max(cur, nxt);

        if (lo <= y && y <= hi) {
            ans += llabs(y - cur);
            break;
        }

        ans += llabs(nxt - cur);
        cur = nxt;
    }

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

教练点评

这类题的陷阱是把“到达终点”误写成“终点等于 y”。实际上,只要移动线段经过 y 就结束。


7.3.12 工作示例三:最少交换恢复排列

题目

给定 1..N 的一个排列。每次可以交换任意两个位置。问最少多少次交换能把排列变成升序。

小例子

排列 2 1 3:交换 1 次。

排列 2 3 1

  • 1 应到位置 1,当前位置 3。
  • 2 应到位置 2,当前位置 1。
  • 3 应到位置 3,当前位置 2。

形成一个长度 3 的循环,需要 2 次交换。

关键观察

一个长度为 L 的循环,需要 L-1 次交换修好。

如果排列分解成 C 个循环,总交换次数:

N - C

代码

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

int main() {
    int n;
    cin >> n;
    vector<int> p(n);
    for (int& x : p) cin >> x;

    vector<bool> vis(n, false);
    int cycles = 0;

    for (int i = 0; i < n; i++) {
        if (!vis[i]) {
            cycles++;
            int cur = i;
            while (!vis[cur]) {
                vis[cur] = true;
                cur = p[cur] - 1;
            }
        }
    }

    cout << n - cycles << "\n";
    return 0;
}

教练点评

同样是“排序”,不同操作导致不同答案:

  • 任意交换:循环分解。
  • 相邻交换:逆序对数量。

读题时必须先确认操作类型。


7.3.13 工作示例四:第一行决定全部

题目

5×5 灯阵,每盏灯为开或关。按一个格子会切换它自己和上下左右相邻灯。问最少按几次能让所有灯关闭。

暴力想法

每个格子按或不按,共 (2^{25}) 种,太多。

关键观察

每个格子按两次等于没按,所以只需考虑按 0 或 1 次。

更重要的是:只要第一行怎么按确定了,后面每一行都被迫确定。

原因:

  • 第一行处理完后,如果第 0 行某个灯还亮,那么唯一能影响它的未决定按钮是它正下方的按钮。
  • 所以第 1 行对应按钮必须按。
  • 依次类推,每一行都由上一行是否熄灭决定。

因此只需枚举第一行 (2^5=32) 种情况。

代码

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

int originalGrid[5][5];
int dr[5] = {0, -1, 1, 0, 0};
int dc[5] = {0, 0, 0, -1, 1};

int solve(int firstMask) {
    int g[5][5];
    memcpy(g, originalGrid, sizeof(originalGrid));
    int presses = 0;

    auto press = [&](int r, int c) {
        presses++;
        for (int k = 0; k < 5; k++) {
            int nr = r + dr[k];
            int nc = c + dc[k];
            if (0 <= nr && nr < 5 && 0 <= nc && nc < 5) {
                g[nr][nc] ^= 1;
            }
        }
    };

    for (int c = 0; c < 5; c++) {
        if (firstMask & (1 << c)) {
            press(0, c);
        }
    }

    for (int r = 1; r < 5; r++) {
        for (int c = 0; c < 5; c++) {
            if (g[r - 1][c] == 1) {
                press(r, c);
            }
        }
    }

    for (int c = 0; c < 5; c++) {
        if (g[4][c] == 1) return INT_MAX;
    }
    return presses;
}

int main() {
    for (int r = 0; r < 5; r++) {
        for (int c = 0; c < 5; c++) {
            cin >> originalGrid[r][c];
        }
    }

    int best = INT_MAX;
    for (int mask = 0; mask < (1 << 5); mask++) {
        best = min(best, solve(mask));
    }

    if (best == INT_MAX) cout << "impossible\n";
    else cout << best << "\n";

    return 0;
}

教练点评

这类题的关键词是“局部操作影响邻居”。常见突破口是:枚举边界,然后内部被迫确定。


7.3.14 Ad Hoc 模式速查表

模式典型问题第一反应
奇偶性能否到达、棋盘移动检查 parity
模 K每次加 K、总和变化固定检查余数
循环重复操作很多次记录状态、找周期
构造输出任意合法答案从必要条件反推构造
下界 + 构造最少操作次数证明至少 X,再做到 X
排列循环任意交换排序分解循环
逆序对相邻交换排序统计逆序对
区间关系覆盖、相交、合并半开区间、排序扫描
圆环环形数组复制数组或枚举断点
网格染色相邻移动、翻转黑白染色、不变量
第一行枚举灯阵、局部翻转枚举边界,后续被迫
补集思维直接数困难总数 - 不合法数
反向思考正向操作复杂从目标倒推
规范化等价状态很多排序、旋转到最小表示

7.3.15 如何训练 Ad Hoc

训练一:每题写“关键观察”

不要只写“这题 AC 了”。要写:

关键观察:每次操作不改变所有数模 K 的余数。
因此只需检查所有数是否同余。

长期积累后,你会拥有自己的观察库。

训练二:强制手算小例子

每道 Ad Hoc 题至少手算 3 个小例子。即使你已经有思路,也要验证。

建议格式:

N=1:...
N=2:...
N=3:...
极端情况:...

训练三:先写暴力找规律

如果你猜不出公式,可以写小范围暴力,然后打印答案表。

例如:

for (int n = 1; n <= 10; n++) {
    cout << n << " " << brute(n) << "\n";
}

答案表经常会暴露规律。

训练四:对每个操作问三个问题

遇到操作题时,问:

  1. 这个操作改变了什么?
  2. 这个操作不改变什么?
  3. 这个操作能不能反过来做?

这三个问题常常直接导向不变量或逆向思考。


7.3.16 练习题

下面练习按难度排列。建议你先只看提示,独立完成后再看题解。

🟢 P1. 同余变换

给定 N 个整数和 K。每次可以选择一个数加 K。问能否让所有数最终相等。

💡 提示

每个数对 K 的余数不会改变。

✅ 题解

所有数必须模 K 相同。若相同,则可以把较小的数不断加 K 追到足够大的同一个值。

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

int main() {
    int n;
    long long k;
    cin >> n >> k;
    vector<long long> a(n);
    for (long long& x : a) cin >> x;

    long long r = ((a[0] % k) + k) % k;
    bool ok = true;
    for (long long x : a) {
        if (((x % k) + k) % k != r) ok = false;
    }

    cout << (ok ? "YES\n" : "NO\n");
    return 0;
}

🟢 P2. 两个矩形覆盖面积

给定两个轴对齐矩形,坐标均为整数,求它们覆盖的总面积。

💡 提示

总面积 = 面积1 + 面积2 - 重叠面积。重叠矩形的宽和高都要用 max(0, ...)

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

struct Rect {
    long long x1, y1, x2, y2;
};

long long area(Rect r) {
    return (r.x2 - r.x1) * (r.y2 - r.y1);
}

int main() {
    Rect a, b;
    cin >> a.x1 >> a.y1 >> a.x2 >> a.y2;
    cin >> b.x1 >> b.y1 >> b.x2 >> b.y2;

    long long ix = max(0LL, min(a.x2, b.x2) - max(a.x1, b.x1));
    long long iy = max(0LL, min(a.y2, b.y2) - max(a.y1, b.y1));
    long long overlap = ix * iy;

    cout << area(a) + area(b) - overlap << "\n";
    return 0;
}

🟡 P3. 重复函数应用

有函数 f,把 1..N 中每个数映射到 1..N。从 x 出发,反复应用 f,问 K 步后在哪里。K 最大 (10^{18})。

💡 提示

序列一定进入循环。记录每个位置第一次出现的步数。

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

int main() {
    int n, x;
    long long k;
    cin >> n >> x >> k;

    vector<int> f(n + 1);
    for (int i = 1; i <= n; i++) cin >> f[i];

    vector<int> order;
    vector<long long> first(n + 1, -1);

    int cur = x;
    long long step = 0;
    while (first[cur] == -1) {
        first[cur] = step++;
        order.push_back(cur);
        cur = f[cur];
    }

    long long cycleStart = first[cur];
    long long cycleLen = step - cycleStart;

    if (k < (long long)order.size()) {
        cout << order[k] << "\n";
    } else {
        long long idx = cycleStart + (k - cycleStart) % cycleLen;
        cout << order[idx] << "\n";
    }

    return 0;
}

🟡 P4. 任意交换排序

给定 1..N 的排列,每次可以交换任意两个位置。求最少交换次数排序。

💡 提示

排列分解成若干循环。长度 L 的循环需要 L-1 次交换。


🟡 P5. 相邻交换排序

给定 1..N 的排列,每次只能交换相邻两个位置。求最少交换次数排序。

💡 提示

答案等于逆序对数量。N 小可 O(N²),N 大用归并排序或树状数组。


🔴 P6. 2×2 翻转可达性

从全 0 的 N×M 网格出发,每次翻转一个 2×2 方块。给定目标网格,问能否达到。

💡 提示

每一行和每一列的 1 的个数奇偶性都不变。初始全为偶数。


🔴 P7. 灯阵

N×M 灯阵,每次按一个灯会翻转自己和四邻。N 很小,M 可以较大。问能否全部关闭或求最少次数。

💡 提示

若 N 很小,可以枚举第一列或第一行的按法,后面被迫确定。


🏆 P8. 构造排列

给定 N 和 K,构造一个 1..N 的排列,使它恰好有 K 个逆序对;若不可能输出 -1

💡 提示

最大逆序对数是 N(N-1)/2。构造时可以从大到小插入,控制每个数贡献多少逆序对。


本章总结

核心要点

概念要点
Ad Hoc不是固定算法,而是题目特定观察
小例子N=1、2、3、4 是最好的老师
不变量找操作永远不改变的量
下界 + 构造证明至少需要 X,再构造 X 步方案
循环状态有限且重复操作很多次时找周期
重新表述把故事改写成区间、排列、图或网格
分类讨论Bronze 中非常常见,关键是完整且不重叠
交换论证贪心观察的常用证明方式

Ad Hoc 检查清单

当你怀疑一道题是 Ad Hoc 时:

  • 我是否手算了最小样例?
  • 我是否列出了极端情况?
  • 每个操作改变了什么?不改变什么?
  • 是否存在奇偶性或模 K 不变量?
  • 是否可以从目标反向思考?
  • 是否可以把问题重新表述为区间、排列、图或网格?
  • 是否能先证明一个下界,再构造达到它?
  • 是否有循环或周期?
  • 是否可以枚举边界,让内部被迫确定?
  • 我的猜想有没有被小反例推翻?

常见问题

Q1:Ad Hoc 题是不是只能靠天赋?

不是。Ad Hoc 的“灵感”通常来自大量见过的不变量、构造、分类讨论和小例子训练。你见过的模式越多,新题越容易触发联想。

Q2:我找到规律但不会证明,比赛中怎么办?

比赛中可以先提交,尤其是在 Bronze/Silver。提交后继续找反例或补证明。但平时训练必须补上证明,否则下次容易被相似但不同的题坑到。

Q3:怎么判断我的不变量够不够?

不变量通常先给必要条件。你还要问:这个条件是否充分?如果不充分,还需要补充什么条件或给出构造。

Q4:为什么我看题解觉得简单,自己却想不到?

因为题解只展示最终洞察,隐藏了试错过程。训练时不要只记答案,要记录“我应该通过什么信号想到这个观察”。


🐄 教练寄语: Ad Hoc 是 USACO 中最能区分“会背模板”和“会思考”的题型。每次做完一题,都把关键观察写成一句话。几个月后,你会发现自己并不是更有天赋了,而是拥有了更大的观察工具箱。

第八部分

🥇 USACO Gold 专题

USACO Gold 级别考察的算法与技术。在 Silver 基础上更进一步,深入挑战难度更高的图论问题、高级树型算法与组合数学。

5
章节
约 5 周
预计学习时长
Gold
USACO 级别

第八部分:USACO Gold 专题

📝 前置要求: 学习第八部分前,请确保已熟练掌握第 2~7 部分的内容,尤其是:

  • 图论算法: BFS/DFS、Dijkstra、Bellman-Ford、并查集(第 5.1~5.4 章)
  • 动态规划: 记忆化搜索、递推、状压 DP、区间 DP(第 6.1~6.3 章)
  • 数据结构: 线段树、树状数组、单调结构(第 3.x 章)

USACO Gold 不再有"套用算法 X 即可"的清晰模式,需要你判断适用哪种技术、组合多个思路,并在竞赛压力下高效实现。

本部分覆盖 USACO Gold 中出现频率最高的五大核心类别。


📚 章节概览

章节主题核心技术难度
第 8.1 章:最小生成树以最小总权重连接所有节点Kruskal(DSU)、Prim(优先队列)、MST 性质、Kruskal 式贪心🟡 中等
第 8.2 章:拓扑排序与 DAG DP有向无环图中的排序;DAG 上的 DP;强连通分量Kahn 算法、DFS 拓扑排序、最长路、Tarjan/Kosaraju SCC、缩点 DAG、2-SAT、差分约束🔴 困难
第 8.3 章:树形 DP 与换根树上 DP;高效处理所有根节点的情况;树背包子树 DP、换根技术(求和 + 最大值)、直径、树背包 O(NW)🔴 困难
第 8.4 章:欧拉游览与树的展开将树展开为数组以支持区间查询欧拉游览、DFS 进出时间戳、倍增 LCA、路径查询🔴 困难
第 8.5 章:组合数学与数论计数、模运算、数的性质C(n,r) mod p、快速幂、容斥原理、筛法、欧拉 φ 函数中国剩余定理🔴 困难

🗺️ 依赖关系图

第五部分(图论)──────────────────► 第 8.1 章 最小生成树
                                      │
                                      └──► 第 8.2 章 拓扑排序 & DAG DP
                                                │
第 5.3 节(树)──────────────────► 第 8.3 章 树形 DP & 换根
                                      │
                                      └──► 第 8.4 章 欧拉游览 & LCA
                                                │
第二部分(数学)+ 第 3.x 章(数据结构)── ► 第 8.5 章 组合数学 & 数论

🎯 Gold 与 Silver 的区别

Silver 级别,大多数题目对应一种明确技术:"这是 BFS 题""这是前缀和题"。

Gold 级别,挑战在于:

  1. 识别 — 判断哪种技术适用,题目叙述往往加以掩盖
  2. 组合 — 结合两种或多种技术(如 DSU + 排序构造 MST,欧拉游览 + BIT 处理树上查询)
  3. 效率 — Silver 中 O(N²) 的思路在 Gold 中需要优化到 O(N log N)
  4. 证明 — Gold 题目常需要在编码前验证贪心策略的正确性

💡 Gold 解题策略: 遇到 Gold 题目时,逐一自问:

  • 有图结构吗?→ 想到 MST、最短路、拓扑排序
  • 有树结构吗?→ 想到树形 DP、换根、欧拉游览 + 数据结构
  • 答案是计数吗?→ 想到组合数学、带计数状态的 DP
  • 能排序后贪心选取吗?→ 想到 Kruskal 式贪心

📈 USACO Gold 题目分布

根据近几届 USACO 竞赛统计,各主题出现频率大致如下:

主题频率备注
图论算法(MST、最短路、并查集)~30%几乎每场竞赛都有
DP(树形 DP、状压 DP、区间 DP)~35%最常见的单一主题
数据结构(线段树、BIT、有序集合)~20%常与其他主题结合
组合数学 / 数学~10%通常出现在一月份竞赛
Ad Hoc / 构造题~5%难以针对性备考

🔗 本部分与 Platinum 的衔接

Gold 之后,USACO Platinum 会引入:

  • 线段树势能Li Chao 树(高级数据结构)
  • 重心剖分(树算法)
  • 后缀数组(字符串算法)
  • 最大流 / 最小割(网络流)

第八部分的所有内容都是 Platinum 的先修知识。欧拉游览(第 8.4 章)尤为重要——几乎每道 Platinum 树题都会用到。

📖 第 8.1 章 ⏱️ 约 50 分钟 🎯 Gold

第 8.1 章:最小生成树

📝 前置要求: 本章需要第 5.1~5.3 章(图、BFS/DFS、并查集/DSU)的知识。阅读 Kruskal 算法前,必须理解 DSU 的 findunion 操作。

带权无向图的**最小生成树(MST)**是满足以下条件的边的子集:

  1. 连通所有 N 个顶点(生成)
  2. 不包含环(树)
  3. 边权总和尽可能小(最小)

USACO Gold 中的 MST 题目常以各种形式出现:如"以最低成本建立网络"、"求连通所有节点的最小代价",或需要考虑哪些边是必须保留的。

学习目标:

  • 理解什么是生成树,以及 MST 的用途
  • 用 DSU 实现 Kruskal 算法,时间复杂度 O(E log E)
  • 用优先队列实现 Prim 算法,时间复杂度 O(E log V)
  • 识别 USACO 中的 MST 问题,并运用割边性质与环性质

8.1.0 什么是生成树?

对于一个有 N 个顶点和 E 条边的连通图,生成树是满足以下条件的边的子集:

  • 连通所有 N 个顶点
  • 恰好使用 N−1 条边
  • 不包含环

一个图可以有许多生成树。最小生成树是边权之和最小的那棵。

图:                    某棵生成树:               MST:
  1                         1                          1
 / \                       / \                        / \
2   3   边权:             2   3                      2   3
|\ /|   1-2: 4             |                          |
| X |   1-3: 2             4                          4
|/ \|   2-3: 5       总权=4+2+3=9              总权=4+2+1=7 ← 最小
4   5   2-4: 3
        3-4: 1
        4-5: 6

💡 为什么恰好是 N−1 条边? N 个节点的树恰好有 N−1 条边。少了则不连通;多了则有环。


8.1.1 割边性质与环性质

这两个基本性质是 MST 算法正确性的理论基础:

割边性质(Cut Property): 对图的任意划分(将顶点分为两组 S 和 V−S),连接两组的最小权重边一定属于某棵 MST。

环性质(Cycle Property): 对图中任意一个环,环上最大权重的边一定不属于任何 MST(权重有并列时除外)。

这两个性质同时证明了 Kruskal 算法和 Prim 算法的正确性。


8.1.2 Kruskal 算法

核心思想: 将所有边按权重排序,贪心地加入不构成环的最便宜边。用 DSU 以 O(α(N)) ≈ O(1) 的时间检测是否成环。

算法流程:

  1. 将所有边按权重从小到大排序
  2. 用 N 个分量初始化 DSU(每个顶点自成一组)
  3. 对每条边 (u, v, w)(按排序顺序):
    • find(u) ≠ find(v):将此边加入 MST,调用 union(u, v)
    • 否则:跳过(会构成环)
  4. 当 MST 包含 N−1 条边时停止
📄 4. 当 MST 包含 N−1 条边时停止
#include <bits/stdc++.h>
using namespace std;

// ── 并查集(DSU)──────────────────────────────────
struct DSU {
    vector<int> parent, rank_;
    int components;

    DSU(int n) : parent(n), rank_(n, 0), components(n) {
        iota(parent.begin(), parent.end(), 0); // parent[i] = i
    }

    int find(int x) {
        if (parent[x] != x)
            parent[x] = find(parent[x]);  // 路径压缩
        return parent[x];
    }

    bool unite(int x, int y) {
        x = find(x); y = find(y);
        if (x == y) return false;           // 已在同一分量中
        if (rank_[x] < rank_[y]) swap(x, y);
        parent[y] = x;                      // 将秩小的挂到秩大的下面
        if (rank_[x] == rank_[y]) rank_[x]++;
        components--;
        return true;
    }

    bool connected(int x, int y) { return find(x) == find(y); }
};

// ── Kruskal MST ────────────────────────────────────
int main() {
    ios_base::sync_with_stdio(false);
    cin.tie(NULL);

    int n, m;
    cin >> n >> m;  // n 个顶点,m 条边

    // edges[i] = {权重, u, v}
    vector<tuple<int,int,int>> edges(m);
    for (auto& [w, u, v] : edges) {
        cin >> u >> v >> w;
        u--; v--;  // 0-indexed
    }

    sort(edges.begin(), edges.end());  // 按权重排序(第一个元素)

    DSU dsu(n);
    long long mst_weight = 0;
    int edges_added = 0;
    vector<pair<int,int>> mst_edges;

    for (auto& [w, u, v] : edges) {
        if (dsu.unite(u, v)) {      // 仅在不构成环时加边
            mst_weight += w;
            mst_edges.push_back({u, v});
            edges_added++;
            if (edges_added == n - 1) break;  // MST 构建完成
        }
    }

    if (edges_added < n - 1) {
        cout << "图不连通——MST 不存在\n";
    } else {
        cout << "MST 权重:" << mst_weight << "\n";
    }

    return 0;
}

复杂度: 排序 O(E log E) + DSU 操作 O(E · α(N)) ≈ O(E log E)

Kruskal 算法追踪示例

顶点:4 个(0, 1, 2, 3)
边(已排序):(0,1,1)、(1,2,2)、(2,3,3)、(0,2,5)、(1,3,6)

第 1 步:边 (0,1,w=1) → find(0)=0 ≠ find(1)=1 → 加入  DSU: {0,1},{2},{3}
第 2 步:边 (1,2,w=2) → find(1)=0 ≠ find(2)=2 → 加入  DSU: {0,1,2},{3}
第 3 步:边 (2,3,w=3) → find(2)=0 ≠ find(3)=3 → 加入  DSU: {0,1,2,3}
        edges_added = 3 = n-1 → 完成

MST 权重 = 1 + 2 + 3 = 6

8.1.3 Prim 算法

核心思想: 从一个起始顶点出发逐步扩张 MST。每一步,加入将 MST 内某顶点与 MST 外某顶点相连的最小权重边。用小根堆(优先队列)高效选取最便宜的边。

Prim vs Kruskal 的选择: 对于稠密图(E ≈ V²),Prim 算法更优——邻接表 + 堆的实现为 O(E log V),而 Kruskal 需要排序 E 条边,复杂度为 O(E log E)。在稠密图上 E log V < E log E,但在竞赛实践中两者通常都能满足约束。

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

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

    int n, m;
    cin >> n >> m;

    // 邻接表:adj[u] = {边权, 邻居} 的列表
    vector<vector<pair<int,int>>> adj(n);
    for (int i = 0; i < m; i++) {
        int u, v, w;
        cin >> u >> v >> w;
        u--; v--;
        adj[u].push_back({w, v});
        adj[v].push_back({w, u});  // 无向图
    }

    // 用小根堆实现 Prim
    vector<bool> in_mst(n, false);
    long long mst_weight = 0;
    int edges_added = 0;

    // 小根堆:{边权, 顶点}
    priority_queue<pair<int,int>, vector<pair<int,int>>, greater<>> pq;
    pq.push({0, 0});  // 从顶点 0 出发,代价 0

    while (!pq.empty() && edges_added < n) {
        auto [w, u] = pq.top(); pq.pop();

        if (in_mst[u]) continue;  // 已加入 MST,跳过(懒惰删除)
        in_mst[u] = true;
        mst_weight += w;
        edges_added++;

        for (auto [edge_w, v] : adj[u]) {
            if (!in_mst[v]) {
                pq.push({edge_w, v});  // 候选扩展边
            }
        }
    }

    if (edges_added < n) {
        cout << "图不连通\n";
    } else {
        cout << "MST 权重:" << mst_weight << "\n";
    }

    return 0;
}

复杂度: 用二叉堆时为 O(E log V)。


8.1.4 MST 性质在解题中的应用

除了直接计算 MST,以下几个性质在 USACO 中非常实用:

性质 1:唯一性

若所有边权互不相同,则 MST 唯一。若存在相同边权,可能有多棵总权重相等的 MST。

性质 2:瓶颈生成树

MST 最小化了任意两顶点之间路径上的最大边权。即:MST 上 u 到 v 的路径具有最小可能的"瓶颈"边

💡 USACO 应用: "u 到 v 路径上最大边权的最小值是多少?"→ 答案是 MST 上 u 到 v 路径中的最大边权。

性质 3:MST 作为贪心框架

很多 USACO Gold 题目可以归结为带有变形的 Kruskal 算法:

  • 按某种代价对"连接"排序
  • 贪心合并各组,只要合并合法
  • DSU 追踪哪些组已连通

非标准"边"上的 Kruskal 算法

经典 USACO 模式:边不是显式给出的——你需要自己分析排序的依据和"合并"的含义。

示例模式(USACO 2016 February Gold — Fencing the Cows):

  • 奶牛分布在各个牧场中;连接两头奶牛有代价
  • 目标:以最小总代价将所有奶牛连通
  • 解法:建图后运行 Kruskal 算法

8.1.5 Kruskal 重构树

Kruskal 重构树(Kruskal 树)是在 Kruskal 算法过程中构建的一种强大结构,它编码了连通分量的"合并历史"。

构建方法: 当 Kruskal 算法通过权重为 w 的边合并包含 u 和 v 的分量时:

  • 创建一个新节点 x,值为 w
  • 将 u 所在分量和 v 所在分量的根分别设为 x 的子节点
  • 用 x 替代这两个分量成为新的根

构建完毕后,该树具有以下结构:

  • N 个叶节点(原始顶点)
  • N-1 个内部节点(每条 MST 边对应一个,值 = 边权)
  • 共 2N-1 个节点
📄 Code 完整代码
示例 MST 边(已排序):(0,1,w=1)、(1,2,w=2)、(2,3,w=3)

合并 (0,1,w=1) 后:  节点 4(w=1)
                      / \
                     0   1

合并 (1,2,w=2) 后:  节点 5(w=2)
                      / \
                节点4    2
                (w=1)
                 / \
                0   1

合并 (2,3,w=3) 后:  节点 6(w=3)
                      / \
                  节点5   3
                  (w=2)
                   / \
                节点4  2
                (w=1)
                 / \
                0   1

关键性质:LCA 即瓶颈边

Kruskal 树中 u 和 v 的 LCA 的值 = MST 上 u 到 v 路径中最大边权 = u 和 v 之间最小可能瓶颈。

这意味着:

  • 查询"u 和 v 之间的最小瓶颈"→ 在 Kruskal 树中求 LCA
  • 查询"使 u 和 v 连通的最小边权是多少?"→ 同上
📄 C++ 完整代码
#include <bits/stdc++.h>
using namespace std;

struct DSU {
    vector<int> parent, rank_, root;  // root[i] = 以 i 为根的分量在 Kruskal 树中的根节点
    DSU(int n) : parent(n), rank_(n, 0), root(n) {
        iota(parent.begin(), parent.end(), 0);
        iota(root.begin(), root.end(), 0);
    }
    int find(int x) {
        return parent[x] == x ? x : parent[x] = find(parent[x]);
    }
    // 返回此次合并创建的新 Kruskal 树节点
    int unite(int x, int y, int new_node) {
        x = find(x); y = find(y);
        if (x == y) return -1;
        if (rank_[x] < rank_[y]) swap(x, y);
        parent[y] = x;
        if (rank_[x] == rank_[y]) rank_[x]++;
        root[x] = new_node;   // 新 Kruskal 树节点成为合并后分量的根
        return new_node;
    }
    int get_root(int x) { return root[find(x)]; }
};

// 构建 Kruskal 重构树
// 返回:Kruskal 树的邻接表和节点值
// 叶节点 0..n-1 为原始顶点;节点 n..2n-2 为内部节点(MST 边)
void build_kruskal_tree(
        int n,
        vector<tuple<int,int,int>>& edges,   // {权重, u, v}——必须已排序
        vector<vector<int>>& ktree,          // ktree[node] = Kruskal 树中的子节点
        vector<int>& node_val                // node_val[node] = 边权(内部节点)或 0(叶节点)
) {
    ktree.assign(2 * n, {});
    node_val.assign(2 * n, 0);

    DSU dsu(n);
    int next_node = n;  // 下一个内部节点的 ID

    for (auto [w, u, v] : edges) {
        int ru = dsu.find(u), rv = dsu.find(v);
        if (ru == rv) continue;  // 同一分量,跳过

        // 创建新内部节点
        int x = next_node++;
        node_val[x] = w;

        // 添加子节点:u 和 v 所在分量的 Kruskal 树根
        ktree[x].push_back(dsu.get_root(u));
        ktree[x].push_back(dsu.get_root(v));

        dsu.unite(u, v, x);
    }
}

// 构建完毕后,在 ktree 上用倍增 LCA 回答瓶颈查询
// ktree 中的 lca(u, v) 即为 MST 上 u 和 v 之间的瓶颈边权

USACO Gold 应用

题型:"对于每个查询 (u, v, k),统计从 u 出发仅使用权重 ≤ k 的边可以到达的顶点数"

在 Kruskal 树中,阈值 k 下以某个 LCA 为根的子树,恰好包含当只允许使用权重 ≤ node_val[LCA] 的边时,从 u 可以到达的所有顶点。

// 查询:仅使用权重 <= threshold 的边时,
// 与 u 在同一连通分量中的顶点数是多少?
// → 在 Kruskal 树中找 u 的最深祖先 x,满足 node_val[x] <= threshold
// → 答案 = sz[x](子树大小,计算的是叶节点数 = 原始顶点数)

💡 原因: Kruskal 树精确记录了边的合并顺序。内部节点 x 的子树包含了在权重为 node_val[x] 的边加入时同属一个连通分量的所有顶点。


8.1.6 USACO Gold 题型模式

模式 1:直接求 MST

"以最小总连接代价连通所有 N 个节点。"

直接应用 Kruskal 或 Prim 算法。

模式 2:排序 + DSU(Kruskal 式贪心)

"按某种顺序处理事件/对;合并分组;查询连通性。"

这本质是 Kruskal 算法,只是没有明确称其为 MST。核心思路:按某种标准排序,再用 DSU 合并。

// 模板:Kruskal 式贪心
sort(events.begin(), events.end(), comparator);
DSU dsu(n);
for (auto& event : events) {
    if (dsu.unite(event.u, event.v)) {
        // 处理合并操作
    }
}

模式 3:MST + 附加查询

"求 MST,然后对 MST 上的路径回答查询。"

构建 MST,再由 MST 的边构造出一棵树,最后回答路径查询(通常与第 8.4 章的欧拉游览结合使用)。


💡 思路陷阱

陷阱 1:把"最小瓶颈路"误当"最短路"

错误判断: "求 u 到 v 路径上最大边权的最小值,用 Dijkstra 最短路"
实际情况: 最短路最小化边权之和;最小瓶颈路最小化路径上的最大边 — 这是 MST 问题

图:u→A(w=1), u→B(w=5), A→v(w=10), B→v(w=6)
Dijkstra 最短路(u→v):u→A→v,总权=11,最大边=10
MST 瓶颈路(u→v):    u→B→v,总权=11,最大边=6  ← 最小瓶颈

关键:最小瓶颈路 = MST 上 u→v 的路径(由割边性质保证)

识别信号: 题目要求"最小化路径上的最大/最重边" → MST + 树上路径,而非 Dijkstra


陷阱 2:贪心合并时忘记"Kruskal 视角"

错误判断: "按某种顺序处理操作,感觉像贪心,写个模拟"
实际情况: 操作可以排序 + 用 DSU 合并 → 本质是 Kruskal 的变体

典型题:N 个集合,每次可以合并代价最小的两个集合(权重 = 两集合大小之积)
错误:模拟优先队列,每次弹出最小的两个合并 → 复杂度 O(N² log N)
正确:认识到"按代价排序 + DSU 合并"就是 Kruskal-style greedy → O(N log N)

识别信号: "处理 N 个对象,按某种代价合并,最终连通" → 先想 Kruskal 框架


⚠️ 常见错误

  1. 忘记判断连通性: 不是所有图都连通。Kruskal 结束后,验证 edges_added == n - 1;Prim 结束后,验证 edges_added == n

  2. DSU 实现有误(未路径压缩或未按秩合并): 朴素 DSU 不加优化时每次操作 O(N),导致 Kruskal 整体为 O(E·N) 而非 O(E log E)。

  3. 边数差一: MST 有 N−1 条边。若停在 N 条边,则多加了一条。

  4. 将 Kruskal 用于有向图: 两种算法都假设无向边。有向图需要不同方法(最小树形图 / 朱-刘/Edmonds 算法——USACO Gold 不考)。

  5. 整数溢出: 若边权最大 10⁹ 且 N = 10⁵,MST 总权重可达约 10¹⁴。请使用 long long


📋 章节小结

📌 核心要点

概念说明
MST 定义N−1 条边连通所有 N 个顶点,总权重最小
Kruskal 算法排序边,贪心加入不构成环的边(DSU);O(E log E)
Prim 算法从源点出发用小根堆扩张;O(E log V)
割边性质任意割的最小边一定在所有 MST 中
环性质任意环的最大边不在任何 MST 中
瓶颈路径MST 路径最小化了任意两顶点间的最大边
USACO 题型排序 + DSU 就是伪装后的 Kruskal——要认出来!

❓ 常见问题

Q:Kruskal 和 Prim 各适用什么场景?
A:竞赛中几乎总是优先选 Kruskal + DSU——实现更简洁,对稀疏图(USACO 的典型场景)效果好。只有在 E ≈ V² 的稠密图时才考虑 Prim。

Q:对所有边权加同一个常数,MST 会变吗?
A:不会——给所有边加常数不改变 MST 包含哪些边(只影响总权重)。

Q:一个图能有多棵 MST 吗?
A:可以,当存在相等边权时。但 MST 的总权重(总和)始终唯一。

Q:图不连通怎么办?
A:此时不存在生成树(无法连通所有顶点)。改为计算最小生成森林——每个连通分量各自的 MST。

Q:我的 Kruskal 在 USACO 评测机上答案错误——哪里出问题了?
A:检查:① 顶点编号是 1-indexed 还是 0-indexed 是否一致?② DSU 的路径压缩是否正确?③ 总权重是否使用了 long long

🔗 与后续章节的联系

  • 第 8.3 章(树形 DP): 构建 MST 后它就是一棵树,可以在 MST 上做树形 DP 来回答路径查询。
  • 第 8.4 章(欧拉游览): 欧拉游览可以在 MST 树结构上支持区间查询。
  • 第 5.3 节(DSU): Kruskal 算法大量依赖 DSU,请准备好第 5.3 节中带路径压缩的 DSU 模板。

🏋️ 练习题

🟢 简单

8.1-E1. 经典 MST
给定 N 个城市和 M 条有权道路,求连通所有城市的最小总权重。
(标准 Kruskal——热身题)

提示

按权重排序边,用 DSU 应用 Kruskal 算法。输出 MST 的边权之和。

解题模板
#include <bits/stdc++.h>
using namespace std;
// ...(使用 8.1.2 中的 Kruskal 模板)

8.1-E2. 是否连通?
给定 N 个节点和 M 条边,判断图是否连通。若连通,输出 MST 权重;若不连通,输出连通分量的数量。

提示

Kruskal 算法结束后,检查 edges_added == n - 1。若不满足,图不连通。连通分量的数量为 dsu.components


🟡 中等

8.1-M1. 最小瓶颈路径 (USACO 风格)
给定 N 个节点、M 条有权边以及 Q 个查询 (u, v),对每个查询求从 u 到 v 的任意路径上最大边权的最小值。

提示

核心思路:查询 (u, v) 的答案就是 MST 上 u 到 v 路径中的最大边权。

构建 MST。对每个查询,在 MST 树上找路径并返回最大边。(朴素:对每个查询做 DFS/BFS,O(N·Q)。高效:LCA + 倍增,见第 8.4 章。)

竞赛中,若 Q ≤ 1000,朴素的 O(N·Q) 方案通常能通过。


8.1-M2. Kruskal 式贪心 USACO 2016 February Gold — Fencing the Cows)
N 头奶牛,每头在某个牧场中。在牧场 i 和 j 之间移动一头奶牛的代价为 |i - j|。用最小总代价将所有牧场连成一组。

提示

这本质是在完全图上求 MST,但排序所有 O(N²) 条边太慢。核心观察:对于数轴上的点,MST 始终只使用相邻边!将奶牛按位置排序,只在相邻牧场之间添加边。


🔴 困难

8.1-H1. 动态连通性 (进阶)
给定 N 个节点,处理 Q 次操作:"添加边 (u,v,w)"或"查询:到目前为止所有已添加边的 MST 权重是多少?"

提示

维护一个有序的边集合,增量运行 Kruskal 算法。当新增边 (u,v,w) 时:若 u 和 v 在当前 MST 中已连通,只有当 w 小于 MST 路径上的最大边权时,才用新边替换。

这需要找到树上路径中的最大边——使用欧拉游览 + 线段树,或倍增。


🏆 挑战

8.1-C1. Steiner 树(近似)
给定一个图和一组"必选"顶点集合 S,求连通 S 中所有顶点的最小权重子树(Steiner 树问题)。当 |S| ≤ 15 时,用状压 DP 精确求解。

提示

这不是 MST 问题——而是状压 DP 与最短路的结合。定义 dp[mask][v] = 以顶点 v 为节点、生成 mask 中所有顶点的最小代价树。

📖 第 8.2 章 ⏱️ 约 80 分钟 🎯 Gold

第 8.2 章:拓扑排序与 DAG DP

📝 前置要求: 本章需要第 5.1 章(图的表示)、第 5.2 章(BFS/DFS)及第 6.1~6.2 章(DP 基础)。学习前应熟悉邻接表和基本记忆化搜索。

有向无环图(DAG) 是没有环的有向图。DAG 能建模依赖关系——任务、先修课程、构建系统、谜题状态——并支持一种特殊算法:拓扑排序,它将顶点排成一条线,使得每条有向边都从前面的顶点指向后面的顶点。

学习目标:

  • 用 Kahn 算法(BFS)和 DFS 实现拓扑排序
  • 检测有向图中的环
  • 在 DAG 上应用 DP:最长路径、路径计数、关键路径分析
  • 用 Tarjan 或 Kosaraju 算法求强连通分量(SCC)
  • 构建缩点 DAG,并在一般有向图上做 DAG DP
  • 将 2-SAT 问题规约为 SCC 问题求解
  • 识别"在约束下排序"的问题本质是拓扑排序

8.2.0 什么是 DAG?

有向无环图的边有方向且不含环:

DAG(合法):         不是 DAG(含环):
  A → B → D              A → B
  ↓       ↓              ↑   ↓
  C ──────►E              D ← C

DAG 在以下场景中自然出现:

  • 先修课程: 课程 A 必须在课程 B 之前修
  • 构建系统: 模块 A 必须在模块 B 之前编译
  • 状态机: 谜题状态,不能返回之前的状态
  • 任务调度: 事件 A 必须在事件 B 之前发生

DAG 的核心操作是拓扑排序:将所有顶点排成一条线,使得对于每条有向边 (u → v),u 都出现在 v 之前。

DAG:                  合法的拓扑序:
A → B → D             A, C, B, D, E
↓       ↑             A, B, C, D, E
C ──────►             (可能存在多个合法排序)

8.2.1 Kahn 算法(BFS 拓扑排序)

核心思想: 反复移除入度为 0(没有前置依赖)的顶点。移除一个顶点后,其后继顶点的入度各减 1;任何入度降至 0 的顶点加入队列。

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

// 返回拓扑序,若检测到环则返回空 vector
vector<int> topoSort(int n, vector<vector<int>>& adj) {
    // 第 1 步:计算入度
    vector<int> indegree(n, 0);
    for (int u = 0; u < n; u++)
        for (int v : adj[u])
            indegree[v]++;

    // 第 2 步:将所有源点(入度为 0 的顶点)加入队列
    queue<int> q;
    for (int i = 0; i < n; i++)
        if (indegree[i] == 0)
            q.push(i);

    vector<int> order;
    while (!q.empty()) {
        int u = q.front(); q.pop();
        order.push_back(u);

        for (int v : adj[u]) {
            indegree[v]--;             // 删除边 u → v
            if (indegree[v] == 0)      // v 的最后一个前置完成
                q.push(v);
        }
    }

    // 若输出不包含所有顶点,说明有环
    if ((int)order.size() != n) return {};  // 检测到环
    return order;
}

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

    int n, m;
    cin >> n >> m;

    vector<vector<int>> adj(n);
    for (int i = 0; i < m; i++) {
        int u, v;
        cin >> u >> v;
        u--; v--;
        adj[u].push_back(v);
    }

    vector<int> order = topoSort(n, adj);
    if (order.empty()) {
        cout << "检测到环——不存在拓扑序\n";
    } else {
        for (int v : order) cout << v + 1 << " ";
        cout << "\n";
    }

    return 0;
}

复杂度: O(V + E)

💡 环检测: 若 Kahn 算法的输出顶点数少于 N,则存在环。那些"卡住"的顶点(入度始终无法降至 0 的)就是环的组成部分。

Kahn 算法追踪示例

📄 查看代码:Kahn 算法追踪示例
图:0→1, 0→2, 1→3, 2→3, 3→4

初始入度:[0, 1, 1, 2, 1]
队列:[0]

弹出 0 → order=[0],入度 1→0, 2→0
队列:[1, 2]

弹出 1 → order=[0,1],入度 3→1
弹出 2 → order=[0,1,2],入度 3→0
队列:[3]

弹出 3 → order=[0,1,2,3],入度 4→0
队列:[4]

弹出 4 → order=[0,1,2,3,4]

5 个顶点全部处理完 → 合法的拓扑序!

8.2.2 基于 DFS 的拓扑排序

另一种方法:DFS 按完成时间的逆序输出结果。

📄 另一种方法:DFS 按**完成时间的逆序**输出结果。
#include <bits/stdc++.h>
using namespace std;

vector<int> adj_list[100001];
vector<int> topo_order;
int color[100001];  // 0=白色(未访问), 1=灰色(栈中), 2=黑色(已完成)
bool has_cycle = false;

void dfs(int u) {
    color[u] = 1;  // 标记为"处理中"
    for (int v : adj_list[u]) {
        if (color[v] == 1) {
            has_cycle = true;  // 后向边 → 有环!
            return;
        }
        if (color[v] == 0)
            dfs(v);
    }
    color[u] = 2;             // 标记为"已完成"
    topo_order.push_back(u);  // 完成子树后再加入结果
}

int main() {
    int n, m;
    cin >> n >> m;
    // ... 读取边 ...

    for (int i = 0; i < n; i++)
        if (color[i] == 0)
            dfs(i);

    reverse(topo_order.begin(), topo_order.end());  // ← 关键:反转完成顺序
    // topo_order 现在是合法的拓扑序
}

为什么要反转完成顺序? DFS 中,一个顶点完成(加入结果)的时间晚于其所有可达顶点完成的时间。因此 DFS 中较晚完成的顶点应排在最前面;取反转后即得拓扑序。

⚠️ Kahn vs DFS: 两者都有效。Kahn 更直观,且能自然地通过计数检测环。DFS 版对于递归实现有时更简洁。


8.2.3 DAG 上的 DP

一旦得到拓扑序,就可以高效地在 DAG 上运行 DP:按拓扑序处理顶点,根据前驱顶点的状态更新每个顶点的状态。

核心思路: 按拓扑序处理顶点 v 时,v 的所有前驱都已处理完毕。因此可以用 dp[前驱] 来计算 dp[v]

DAG 中的最长路径

📄 查看代码:DAG 中的最长路径
// 到达每个顶点的最长路径
vector<int> dp(n, 0);  // dp[v] = 到达 v 的最长路径

// 按拓扑序处理
for (int u : topo_order) {
    for (int v : adj[u]) {
        dp[v] = max(dp[v], dp[u] + edge_weight[u][v]);
        //           ↑ 当前最优    ↑ 通过 u→v 延伸路径
    }
}

int ans = *max_element(dp.begin(), dp.end());

从源点到各顶点的路径计数

vector<long long> cnt(n, 0);
cnt[source] = 1;  // 到达源点的方案数为 1

for (int u : topo_order) {
    for (int v : adj[u]) {
        cnt[v] += cnt[u];  // 加上到达 u 的所有路径,经 u→v 延伸
        cnt[v] %= MOD;     // 若需要取模
    }
}
// cnt[t] = 从 source 到 t 的路径数

USACO 风格示例:关键路径(最早完成时间)

任务 1..N,各有执行时长。任务 v 在所有前置任务完成后才能开始。求每个任务最早的开始时间。

📄 C++ 完整代码
// earliest_start[v] = max(earliest_start[u] + duration[u]) 对所有前驱 u 取最大
vector<int> earliest(n, 0);

for (int u : topo_order) {
    for (int v : adj[u]) {
        earliest[v] = max(earliest[v], earliest[u] + duration[u]);
    }
}

// 项目总完成时间 = max(earliest[v] + duration[v]) 对所有 v
int finish_time = 0;
for (int v = 0; v < n; v++)
    finish_time = max(finish_time, earliest[v] + duration[v]);

8.2.4 DAG DP 在 USACO Gold 中的题型模式

模式 1:将状态转移视为 DAG

很多 DP 问题可以可视化为 DAG:

  • 顶点 = DP 状态
  • = 状态之间的转移
  • DAG 性质 = 转移只向"前方"进行(无环)

认识到这一点,可以将 DP 递推式转化为显式图问题。

模式 2:排序/调度

"N 个任务有先后依赖关系(任务 A 必须在任务 B 之前完成)。求调度顺序 / 最少执行阶段数 / 关键路径。"

直接应用拓扑排序 + DAG DP。

模式 3:带约束的路径计数

"在给定约束(选择 B 不能跟在选择 A 之后)下,有多少种合法的选择序列?"

将选择建模为顶点,约束建模为有向边(A → B 表示"A 后面接 B 不合法"),然后在结果 DAG 中计路径数。

模式 4:DAG 中的最短/最长路径

DAG 的最短路可以用拓扑排序 + DP 在 O(V+E) 内解决——比 Dijkstra 的 O(E log V) 更快。若图无负边且同时是 DAG,优先选这种方法。

// DAG 中从源点 s 出发的最短路(支持负权!)
vector<int> dist(n, INT_MAX);
dist[s] = 0;

for (int u : topo_order) {
    if (dist[u] == INT_MAX) continue;
    for (auto [v, w] : adj[u]) {
        dist[v] = min(dist[v], dist[u] + w);
    }
}

8.2.5 强连通分量(SCC)

有向图的**强连通分量(SCC)**是顶点的极大子集,子集内任意两个顶点互相可达。

示例:
  0 → 1 → 2
  ↑   ↓   ↓
  └── 3   4

SCC:{0, 1, 3}、{2}、{4}
- 0→1→3→0:互相可达 → 同一个 SCC
- 2 可从 SCC{0,1,3} 到达,但无法返回 → 独立的 SCC
- 4 同理孤立

SCC 在 USACO Gold 中的意义:

  • 缩点 DAG: 将每个 SCC 收缩为单个节点后,结果必然是 DAG。这使得可以在有环图上做 DAG DP。
  • 2-SAT: 每子句含 2 个字面量的布尔可满足性问题可以规约为 SCC。
  • 可达性查询: "u 能到达 v 吗?" → 判断它们是否在同一 SCC,或 SCC(u) 是否在缩点 DAG 中可达 SCC(v)。

Tarjan SCC 算法

核心思想: 一次 DFS,维护一个栈和两个数组:

  • disc[v]:v 的 DFS 发现时间
  • low[v]:从 v 的子树出发,经过至多一条"后向边"可达的最小发现时间

low[v] == disc[v] 时,v 是某个 SCC 的根——弹出栈中直到 v 为止的所有顶点。

📄 当 `low[v] == disc[v]` 时,v 是某个 SCC 的根——弹出栈中直到 v 为止的所有顶点。
#include <bits/stdc++.h>
using namespace std;

const int MAXN = 100001;
vector<int> adj[MAXN];

int disc[MAXN], low[MAXN], timer_val = 0;
bool on_stack[MAXN];
stack<int> stk;

int scc_id[MAXN];   // 每个顶点属于哪个 SCC?
int scc_count = 0;

void dfs(int u) {
    disc[u] = low[u] = ++timer_val;
    stk.push(u);
    on_stack[u] = true;

    for (int v : adj[u]) {
        if (disc[v] == 0) {          // 树边:v 未访问
            dfs(v);
            low[u] = min(low[u], low[v]);
        } else if (on_stack[v]) {    // 后向边,属于当前 SCC
            low[u] = min(low[u], disc[v]);
        }
        // 交叉边/前向边(on_stack[v]==false 且 disc[v]!=0):忽略
    }

    // 若 u 是某个 SCC 的根
    if (low[u] == disc[u]) {
        scc_count++;
        while (true) {
            int v = stk.top(); stk.pop();
            on_stack[v] = false;
            scc_id[v] = scc_count;
            if (v == u) break;
        }
    }
}

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

    int n, m;
    cin >> n >> m;
    for (int i = 0; i < m; i++) {
        int u, v; cin >> u >> v; u--; v--;
        adj[u].push_back(v);
    }

    for (int i = 0; i < n; i++)
        if (disc[i] == 0)
            dfs(i);

    cout << "SCC 数量:" << scc_count << "\n";
    for (int i = 0; i < n; i++)
        cout << "顶点 " << i << " → SCC " << scc_id[i] << "\n";

    return 0;
}

复杂度: O(V + E)——一次 DFS。

💡 SCC 编号说明: Tarjan 算法以缩点 DAG拓扑序的逆序给 SCC 编号。编号为 1 的 SCC 是 DAG 的汇点;编号最大的 SCC 是源点。


Kosaraju SCC 算法

核心思想: 两次 DFS。

  1. 第一遍: 在原图上运行 DFS,按完成时间将顶点压入栈。
  2. 第二遍:转置图(所有边反向)上,按完成时间的逆序(弹栈顺序)处理顶点。第二遍中每棵 DFS 树恰好是一个 SCC。
📄 C++ 完整代码
#include <bits/stdc++.h>
using namespace std;

const int MAXN = 100001;
vector<int> adj[MAXN];      // 原图
vector<int> radj[MAXN];     // 转置图(边反向)
bool visited[MAXN];
int scc_id[MAXN];
stack<int> finish_order;

// 第一遍:在原图上 DFS,记录完成顺序
void dfs1(int u) {
    visited[u] = true;
    for (int v : adj[u])
        if (!visited[v])
            dfs1(v);
    finish_order.push(u);   // 完全处理后压栈
}

// 第二遍:在转置图上 DFS,标记 SCC
void dfs2(int u, int id) {
    visited[u] = true;
    scc_id[u] = id;
    for (int v : radj[u])
        if (!visited[v])
            dfs2(v, id);
}

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

    int n, m;
    cin >> n >> m;
    for (int i = 0; i < m; i++) {
        int u, v; cin >> u >> v; u--; v--;
        adj[u].push_back(v);
        radj[v].push_back(u);   // 反向边
    }

    // 第一遍:填充 finish_order
    fill(visited, visited + n, false);
    for (int i = 0; i < n; i++)
        if (!visited[i])
            dfs1(i);

    // 第二遍:在转置图上按逆完成顺序处理
    fill(visited, visited + n, false);
    int scc_count = 0;
    while (!finish_order.empty()) {
        int u = finish_order.top(); finish_order.pop();
        if (!visited[u])
            dfs2(u, ++scc_count);
    }

    cout << "SCC 数量:" << scc_count << "\n";
    return 0;
}

复杂度: O(V + E)——两次 DFS。

Tarjan vs Kosaraju 对比

TarjanKosaraju
DFS 次数1 次2 次
空间栈 + 数组原图 + 转置图
SCC 编号顺序拓扑逆序拓扑正序
竞赛推荐更简洁,竞赛首选更易理解和调试

💡 USACO 中: Tarjan 更简洁,是竞赛编程的首选。Kosaraju 更易于理解和调试。


缩点 DAG + DP

找到 SCC 后,可以构建缩点 DAG,并在其上运行 DP。

📄 找到 SCC 后,可以构建缩点 DAG,并在其上运行 DP。
// 用 Tarjan 算法找 SCC 后,构建缩点 DAG
// scc_id[v] = 顶点 v 所在的 SCC(1-indexed,拓扑逆序)
// scc_count = SCC 总数

vector<int> scc_adj[MAXN];   // 缩点 DAG 中的边
set<pair<int,int>> seen;      // 避免重复边

for (int u = 0; u < n; u++) {
    for (int v : adj[u]) {
        if (scc_id[u] != scc_id[v]) {
            // 不同 SCC 之间的边
            auto e = make_pair(scc_id[u], scc_id[v]);
            if (!seen.count(e)) {
                seen.insert(e);
                scc_adj[scc_id[u]].push_back(scc_id[v]);
            }
        }
    }
}

// 现在 scc_adj 是 DAG——对其做 DAG DP
// 示例:缩点 DAG 按 SCC 大小加权的最长路径
vector<int> scc_size(scc_count + 1, 0);
for (int i = 0; i < n; i++) scc_size[scc_id[i]]++;

// DAG DP:dp[v] = 以 SCC v 为结尾的路径上的最大顶点数
vector<int> dp(scc_count + 1, 0);
// 按拓扑序处理(Tarjan 给出拓扑逆序,因此从 1 到 scc_count 遍历)
for (int u = 1; u <= scc_count; u++) {
    dp[u] += scc_size[u];
    for (int v : scc_adj[u]) {
        dp[v] = max(dp[v], dp[u]);
    }
}
int ans = *max_element(dp.begin() + 1, dp.end());

8.2.6 差分约束(补充内容)

差分约束系统是一组形如以下的不等式:

x_j - x_i ≤ w_{ij}

当 USACO 题目要求"给变量赋值,使所有成对差值约束满足,并找到最小/最大赋值"时,就会出现这类问题。

核心思路: 差分约束系统等价于最短路问题!

将每个约束 x_j - x_i ≤ w 转化为有向边 i → j,权重为 w

则:

  • 若约束图无负环 → 存在可行解
  • 最紧的可行解由从虚源出发的最短路给出
📄 C++ 完整代码
// 求解差分约束系统
// 约束:x[b] - x[a] <= w  →  添加边 a→b,权重 w
// 返回最小合法赋值(x[i] = 从虚源到 i 的最短距离)
// 若不可行(含负环)则返回空 vector

vector<long long> solve_difference_constraints(
        int n,                         // n 个变量 x[0..n-1]
        vector<tuple<int,int,int>>& constraints  // {a, b, w}: x[b]-x[a]<=w
) {
    // 添加虚源 s = n,添加边 s→i 权重 0(对所有 i)
    // (使 x[i] >= 0 并提供公共参考点)
    int s = n;
    vector<tuple<int,int,int>> edges = constraints;
    for (int i = 0; i < n; i++)
        edges.push_back({s, i, 0});   // x[i] - x[s] <= 0 → x[i] <= 0

    // 从源点 s 运行 Bellman-Ford
    vector<long long> dist(n + 1, 0);  // 源点距离 = 0

    for (int iter = 0; iter < n; iter++) {
        for (auto [u, v, w] : edges) {
            if (dist[u] + w < dist[v])
                dist[v] = dist[u] + w;
        }
    }

    // 检测负环
    for (auto [u, v, w] : edges) {
        if (dist[u] + w < dist[v])
            return {};  // 含负环 → 无可行解
    }

    return vector<long long>(dist.begin(), dist.begin() + n);
}

USACO 题型: "给时间/位置赋值,使 A 在 B 之后至少 D 时间发生"的问题可以直接对应差分约束。


8.2.7 2-SAT(二元可满足性问题)

2-SAT 是 Tarjan SCC 最重要的应用之一。它解决这样的问题:给 N 个布尔变量赋值(真/假),使得一组每条包含 2 个字面量的子句的合取为真。

问题形式

有 N 个布尔变量 x₁, x₂, ..., xₙ(每个可以是真或假)。给定 M 个子句,每条形如:

(xᵢ = aᵢ) OR (xⱼ = aⱼ)

其中 aᵢ, aⱼ ∈ {true, false}。

目标: 找到满足所有子句的赋值,或报告无解。

💡 USACO 伪装: "对每组选择 A 或 B。若从第 i 组选了 A,则必须从第 j 组选 B。"这就是 2-SAT!

构建蕴含图

核心转换: OR 子句 (p OR q) 等价于两条蕴含:

¬p → q      (若 p 为假,则 q 必为真)
¬q → p      (若 q 为假,则 p 必为真)

对每个变量 xᵢ,创建两个节点:2i(xᵢ = true)和 2i+1(xᵢ = false,即 ¬xᵢ)。

变量 xᵢ → 节点 2i   (xᵢ 为 TRUE)
           节点 2i+1  (xᵢ 为 FALSE,¬xᵢ)

对子句 (xᵢ = a) OR (xⱼ = b)

  • 设 p = xᵢ = a 对应的节点,¬p = xᵢ = ¬a 对应的节点
  • 设 q = xⱼ = b 对应的节点,¬q = xⱼ = ¬b 对应的节点
  • 添加边:¬p → q 以及 ¬q → p

2-SAT 实现

📄 查看代码:2-SAT 实现
#include <bits/stdc++.h>
using namespace std;

struct TwoSat {
    int n;
    vector<vector<int>> adj, radj;
    vector<int> order, comp;
    vector<bool> visited;

    TwoSat(int n) : n(n), adj(2*n), radj(2*n), comp(2*n), visited(2*n) {}

    // 添加子句:(变量 u 取值 val_u) OR (变量 v 取值 val_v)
    // val = true  → 使用节点 2*var
    // val = false → 使用节点 2*var+1
    void add_clause(int u, bool val_u, int v, bool val_v) {
        // ¬(u=val_u) → (v=val_v)
        adj[2*u + !val_u].push_back(2*v + val_v);
        radj[2*v + val_v].push_back(2*u + !val_u);
        // ¬(v=val_v) → (u=val_u)
        adj[2*v + !val_v].push_back(2*u + val_u);
        radj[2*u + val_u].push_back(2*v + !val_v);
    }

    // 强制变量 u 取值 val(添加单元子句:u=val 被强制)
    // 等价于:add_clause(u, val, u, val)
    // 即:要么 u=val 要么 u=val → u 必须为 val
    void force(int u, bool val) {
        // ¬val → val(即若 ¬val 则 val,强制 val 为真)
        adj[2*u + !val].push_back(2*u + val);
        radj[2*u + val].push_back(2*u + !val);
    }

    void dfs1(int v) {
        visited[v] = true;
        for (int u : adj[v])
            if (!visited[u]) dfs1(u);
        order.push_back(v);
    }

    void dfs2(int v, int c) {
        comp[v] = c;
        for (int u : radj[v])
            if (comp[u] == -1) dfs2(u, c);
    }

    // 若可满足返回 true,并将解填入 result[]
    bool solve(vector<bool>& result) {
        // 在蕴含图上运行 Kosaraju SCC
        fill(visited.begin(), visited.end(), false);
        for (int v = 0; v < 2*n; v++)
            if (!visited[v]) dfs1(v);

        fill(comp.begin(), comp.end(), -1);
        int c = 0;
        for (int i = (int)order.size()-1; i >= 0; i--) {
            if (comp[order[i]] == -1)
                dfs2(order[i], c++);
        }

        result.resize(n);
        for (int i = 0; i < n; i++) {
            // 若 xᵢ 和 ¬xᵢ 在同一个 SCC → 矛盾 → 无解
            if (comp[2*i] == comp[2*i+1]) return false;
            // 选择:若 SCC(xᵢ) 的编号大于 SCC(¬xᵢ),则 xᵢ = true
            // (Kosaraju 给拓扑序较后的 SCC 分配更大的编号)
            result[i] = comp[2*i] > comp[2*i+1];
        }
        return true;
    }
};

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

    int n, m;
    cin >> n >> m;  // n 个变量,m 个子句

    TwoSat sat(n);

    for (int i = 0; i < m; i++) {
        // 读取子句:(x[u] = a) OR (x[v] = b)
        // 其中 u,v 为 0-indexed,a,b 为 0 或 1
        int u, a, v, b;
        cin >> u >> a >> v >> b;
        sat.add_clause(u, a, v, b);
    }

    vector<bool> result;
    if (sat.solve(result)) {
        for (int i = 0; i < n; i++)
            cout << "x[" << i << "] = " << result[i] << "\n";
    } else {
        cout << "不可满足\n";
    }

    return 0;
}

复杂度: O(N + M)——在蕴含图上运行 Kosaraju SCC。

为什么这个方法有效

核心洞见:若 xᵢ¬xᵢ同一个 SCC 中,则蕴含图中存在从 xᵢ¬xᵢ 的路径,以及从 ¬xᵢxᵢ 的路径。这意味着 xᵢ 强迫 ¬xᵢ,¬xᵢ 也强迫 xᵢ——产生矛盾,不存在合法赋值。

若没有变量与其否定落在同一 SCC 中,则始终可以根据拓扑序构造合法赋值。

USACO 2-SAT 题型模式

模式 1:"每组恰好选择 A 或 B"

对第 i 组:变量 xᵢ = true 表示"选 A",false 表示"选 B"
约束"若第 i 组选 A,则第 j 组必须选 B":
  add_clause(i, false, j, false)  // ¬(i=A) OR ¬(j=A)

模式 2:"A、B、C、D 中至多有一个为真"(n 变量的至多一个约束)

// 链式编码:若 xᵢ 为真,则 x_{i+1}..x_{n-1} 都为假
// 朴素方法:对每对 (i, j)(i < j)add_clause(i, false, j, false),O(n²) 条子句
// 链式技巧 O(n):引入辅助变量 yᵢ = "x₀..xᵢ 中至少有一个为真"

模式 3:"将 N 个元素分到 L 侧或 R 侧,有约束"

xᵢ = true → 元素 i 在左侧
xᵢ = false → 元素 i 在右侧
约束"i 和 j 不能同时在左侧":add_clause(i, false, j, false)
约束"若 j 在右侧则 i 必须在左侧":add_clause(i, true, j, true)

💡 思路陷阱

陷阱 1:把含环的有向图当 DAG 处理

错误判断: "这题有拓扑顺序,用 toposort + DP 就行了"
实际情况: 图中可能有环(SCC),直接 toposort 会漏掉部分节点

反例:有向图 A→B→C→A→D
错误:toposort 输出 [D](只有 3 个节点在环外)
正确:先用 Tarjan 找 SCC,把 {A,B,C} 缩成一个超级节点,再在缩点 DAG 上做 DP

识别信号: 题目说"有向图"但没说"无环" → 先检测环或求 SCC,再决定是否能用 toposort


陷阱 2:2-SAT 误认为是普通贪心

错误判断: "每个位置选 A 或 B,有约束,贪心从左到右扫一遍"
实际情况: 约束之间有传递性,贪心无法保证全局一致

反例:5 个组,约束如下(若选 A₁ 则必须选 B₂,若选 A₂ 则必须选 B₃...)
贪心:组 1 选 A₁ → 组 2 选 B₂ → 组 3 选 A₃ → 组 4 选 B₄ → 组 5 可能无解
2-SAT:建立完整蕴含图,SCC 分析一次性得出全局一致解

识别信号: 每个位置/元素有两种选择 + 成对约束("如果...则...")→ 考虑 2-SAT


⚠️ 常见错误

  1. 混淆有向图与无向图的环: 拓扑排序只适用于有向图。无向图中,任意连通分量都有生成树——不需要"环检测"。

  2. DP 初始化差一: 对"从源点出发的路径计数",初始化 cnt[source] = 1,而非 0。对"最长路径",若计边数则初始化 dp[v] = 0;若路径可能不存在,需正确处理 -∞。

  3. 忽略不可达顶点: DAG 最短路中若 dist[u] == INT_MAX,跳过该顶点——从不可达顶点出发会得到错误值。

  4. 大图 DFS 拓扑排序栈溢出: N = 10⁵ 且有深链时,递归 DFS 可能栈溢出。对大输入优先使用 Kahn 算法(BFS)。

  5. 在有环图上使用拓扑排序: Kahn 会静默返回部分排序。务必检查 order.size() == n


📋 章节小结

📌 核心要点

概念说明
DAG无环的有向图;建模依赖/排序关系
拓扑排序所有边从左指向右的线性序;O(V+E)
Kahn 算法BFS 基于入度;自然检测环
DFS 拓扑排序DFS 完成后加入结果;最后反转
环检测Kahn:输出数 < N 有环;DFS:灰→灰边有环
DAG DP按拓扑序处理;dp[v] 仅依赖 dp[前驱]
最长路径dp[v] = max(dp[u] + 权重),对所有前驱 u
路径计数cnt[v] = sum(cnt[u]),对所有前驱 u
SCC(Tarjan)一次 DFS;disc[]/low[]/栈;O(V+E);拓扑逆序
SCC(Kosaraju)G 和 Gᵀ 各一次 DFS;O(V+E);拓扑正序
缩点 DAG每个 SCC 收缩为一个节点;结果必然是 DAG
2-SATN 个布尔变量 + 2 字面量子句;建蕴含图 → SCC;O(N+M)
差分约束x[j]-x[i]≤w → 边 i→j;可行 = 无负环(Bellman-Ford)

❓ 常见问题

Q:每棵树都是 DAG 吗?
A:有根树(边从父节点指向子节点)是 DAG。无根树没有方向,不适用此问题。若将树根化,则是 DAG。

Q:拓扑排序可以有多个合法序吗?
A:可以。若两个顶点之间没有依赖关系,顺序可互换。只有当 DAG 是一条简单路径(链)时,拓扑序才唯一。

Q:Dijkstra 与 DAG 最短路——分别在什么时候用?
A:若图是 DAG,用拓扑排序 + DP:O(V+E),支持负权,更简单。若图有环但无负边,用 Dijkstra:O(E log V)。若有环且有负边,用 Bellman-Ford:O(VE)。

Q:如何求"完成所有任务的最少轮次/阶段数"?
A:这就是 DAG 中的"最长路径"(关键路径)。最少阶段数 = 1 + 最长路径的长度。

🔗 与后续章节的联系

  • 第 8.3 章(树形 DP): 树形 DP 是在特殊 DAG(有根树)上的 DP,本章技术直接适用。
  • 第 6.3 章(进阶 DP): 状压 DP 的状态通常构成 DAG(转移只从子集到超集)。
  • 第 5.2 章(BFS/DFS): 本章的两种算法都是对第 5.2 章 BFS/DFS 的扩展。

🏋️ 练习题

🟢 简单

8.2-E1. 课程表 (等价于 LeetCode 207)
N 门课程,M 个先修要求。给定先修对 (a, b),表示"必须先修 b 才能修 a",判断能否完成所有课程(即是否无环)。

提示

运行 Kahn 算法。若输出包含所有 N 门课程,则无环 → 可以完成。否则有环 → 无法完成。


8.2-E2. DAG 中的最长路径
给定 N 个顶点、M 条有权有向边和源点 S,求从 S 出发的最长路径。

提示

拓扑排序,然后按拓扑序处理顶点。初始化 dp[S] = 0dp[其他] = -∞。对每条边 u→v:dp[v] = max(dp[v], dp[u] + w)


🟡 中等

8.2-M1. 网格路径计数 (网格 DP 作为 DAG)
在 N×M 的网格中,每步只能向右或向下走。某些格子被封锁。统计从 (1,1) 到 (N,M) 的路径总数。

提示

网格是 DAG(移动只向右/向下)。按行主序处理格子(已是拓扑序)。cnt[i][j] = cnt[i-1][j] + cnt[i][j-1],若 (i,j) 未被封锁。


8.2-M2. 带依赖的任务调度 (USACO 风格)
N 个任务各有执行时长,M 条依赖边。每个任务只有在其所有前置任务完成后才能开始。所有可并行的任务同时进行。求完成所有任务的最短总时间。

提示

这是关键路径法(CPM)。运行 Kahn 拓扑排序。对拓扑序中的每个顶点,计算 earliest_start[v] = max(earliest_start[u] + duration[u]) 对所有前驱 u 取最大。答案 = max(earliest_start[v] + duration[v])。


🔴 困难

8.2-H1. 路径计数取模 (USACO Gold 2012 — Cow Rectangles)
给定有 N 个顶点(至多 10⁵)和 M 条边的 DAG,源点 S 到目标 T。统计 S 到 T 的路径总数对 10⁹+7 取模的结果。某些顶点被"标记";只统计经过至少一个标记顶点的路径。

提示

用容斥原理:(经过 ≥1 个标记顶点的路径数)=(所有路径数)-(不经过任何标记顶点的路径数)。对"不经过任何标记顶点"的情形,将标记顶点从图中移除后重新计数。


🏆 挑战

8.2-C1. 缩点 DAG + DP (困难)
给定可能含环的有向图。在缩点 DAG(每个 SCC 收缩为单个顶点)中,求路径上经过的最大顶点数。

提示

运行 Tarjan 或 Kosaraju 求 SCC 及其大小。构建缩点 DAG。在缩点 DAG 上做最长路径 DP,每个顶点的"权值"为该 SCC 的大小。答案为 max dp[v]

📖 第 8.3 章 ⏱️ 约 60 分钟 🎯 Gold / 困难

第 8.3 章:树形 DP 与换根

📝 前置要求: 本章需要第 5.4 章(二叉树与树算法:遍历、LCA)、第 5.5 章(并查集)、第 6.1~6.2 章(DP 基础)及第 8.2 章(DAG DP)。阅读树形 DP 前,必须理解 DFS 后序遍历。

树形 DP 在有根树上运行动态规划,以每棵子树作为子问题。这是 USACO Gold 中最重要的技术之一,几乎出现在所有 Gold/Platinum 树题中。

换根技术(Rerooting)将树形 DP 扩展到能在 O(N) 时间内处理以每个节点为根的查询——无需对每个根单独运行一次 DFS。

学习目标:

  • 编写基于子树的树形 DP 模板
  • 计算树的直径、最长路径、子树和
  • 运用换根技术,以 O(N) 时间回答"以某节点为根时的结果"
  • 识别 USACO Gold 树题中的树形 DP 模式

8.3.0 为什么树适合 DP?

有根树本质上是一个 DAG(边从根指向子节点)。这意味着:

  • 无环: DP 的转移方向始终是从父到子(无回溯)
  • 自然子问题: 以 v 为根的子树构成一个完整、独立的子问题
  • 简洁递推: dp[v] 只依赖 dp[v 的子节点]

核心模式: 后序 DFS——先处理子节点,再处理父节点。

树:          DFS 后序:        DP 顺序:
    1             4, 5, 2       先计算 dp[4]、dp[5],
   / \            6, 3          再用它们计算 dp[2]
  2   3           2, 3
 / \   \           1             dp[v] 在所有子节点后才计算
4   5   6                        → 父节点可以直接使用子节点的 dp 值

8.3.1 树形 DP 模板

标准树形 DP 模板:带父节点参数的 DFS,避免回溯。

📄 标准树形 DP 模板:带父节点参数的 DFS,避免回溯。
#include <bits/stdc++.h>
using namespace std;

const int MAXN = 100001;
vector<int> adj[MAXN];
int dp[MAXN];  // 根据问题定义 dp 数组

void dfs(int u, int parent) {
    // 初始化 dp[u](基础情况:叶节点)
    dp[u] = /* 初始值 */ 0;

    for (int v : adj[u]) {
        if (v == parent) continue;  // 不回溯到父节点

        dfs(v, u);  // ← 先对子节点递归(后序)

        // 现在 dp[v] 已计算好 → 用它更新 dp[u]
        dp[u] = /* 合并 dp[u] 和 dp[v] */;
    }
}

int main() {
    int n;
    cin >> n;
    for (int i = 0; i < n - 1; i++) {
        int u, v;
        cin >> u >> v;
        u--; v--;
        adj[u].push_back(v);
        adj[v].push_back(u);
    }

    dfs(0, -1);  // 以顶点 0 为根,无父节点

    cout << /* 用 dp 值计算答案 */ "\n";
    return 0;
}

8.3.2 经典树形 DP 问题

问题 1:子树大小

sz[v] = 以 v 为根的子树中节点数。

int sz[MAXN];

void dfs(int u, int par) {
    sz[u] = 1;  // 计入 u 自身
    for (int v : adj[u]) {
        if (v == par) continue;
        dfs(v, u);
        sz[u] += sz[v];  // 加上子节点的子树大小
    }
}

问题 2:子树最大深度

depth[v] = 从 v 到其子树中任意叶节点的最大距离。

int depth[MAXN];

void dfs(int u, int par) {
    depth[u] = 0;
    for (int v : adj[u]) {
        if (v == par) continue;
        dfs(v, u);
        depth[u] = max(depth[u], depth[v] + 1);  // 经过 v 延伸路径
    }
}

问题 3:树的直径

树的直径是任意两节点之间的最长路径。核心思路:最长路径要么经过根节点,要么完全在某棵子树内。

方法一:两次 DFS(最简洁)

📄 C++ 完整代码
int farthest_node, max_dist;

void dfs_farthest(int u, int par, int dist) {
    if (dist > max_dist) {
        max_dist = dist;
        farthest_node = u;
    }
    for (int v : adj[u]) {
        if (v != par)
            dfs_farthest(v, u, dist + 1);
    }
}

int treeDiameter(int n) {
    // 第 1 步:找直径的一个端点(离节点 0 最远的节点)
    max_dist = 0;
    dfs_farthest(0, -1, 0);
    int endpoint1 = farthest_node;

    // 第 2 步:找另一个端点(离 endpoint1 最远的节点)
    max_dist = 0;
    dfs_farthest(endpoint1, -1, 0);

    return max_dist;  // 直径长度
}

方法二:DP(更通用)

📄 C++ 完整代码
int diameter = 0;
int max_down[MAXN];  // max_down[v] = 从 v 向下延伸的最长路径

void dfs(int u, int par) {
    max_down[u] = 0;
    vector<int> child_depths;

    for (int v : adj[u]) {
        if (v == par) continue;
        dfs(v, u);
        child_depths.push_back(max_down[v] + 1);
    }

    // 经过 u 的直径 = 最长的两条向下路径之和
    sort(child_depths.rbegin(), child_depths.rend());
    if (child_depths.size() >= 1) max_down[u] = child_depths[0];
    if (child_depths.size() >= 2) {
        diameter = max(diameter, child_depths[0] + child_depths[1]);
    }
    diameter = max(diameter, max_down[u]);
}

问题 4:树上最大独立集

选出尽可能多的节点,使得任意两个被选节点均不相邻(不通过一条边相连)。

📄 选出尽可能多的节点,使得任意两个被选节点均不相邻(不通过一条边相连)。
int dp[MAXN][2];
// dp[v][0] = v 未被选中时,v 子树中的最大节点数
// dp[v][1] = v 被选中时,v 子树中的最大节点数

void dfs(int u, int par) {
    dp[u][0] = 0;
    dp[u][1] = 1;  // v 自身被选中

    for (int v : adj[u]) {
        if (v == par) continue;
        dfs(v, u);

        dp[u][0] += max(dp[v][0], dp[v][1]);  // v 未选:子节点可选可不选
        dp[u][1] += dp[v][0];                  // v 已选:子节点必须不选
    }
}

// 答案 = max(dp[root][0], dp[root][1])

8.3.3 换根技术

问题: 对树中每个顶点 v,计算以 v 为根时的某个值。朴素做法需要 N 次 DFS → O(N²)。换根技术只需两次 DFS:O(N)

核心思路:

  • DFS 1(向下传): 计算 down[v] = 以原始根为根时,v 子树的答案
  • DFS 2(向上传): 计算 up[v] = 树中"v 子树以外的部分"对应的答案
  • v 为根的最终答案: 合并 down[v]up[v]

换根模板:距离之和

问题: 对每个顶点 v,求 v 到其他所有顶点的距离之和。输出 N 个值。

这是经典 Gold 题。核心递推关系:

若已知 dist_sum[root](根到所有顶点的距离之和),则对根的子节点 c:

dist_sum[c] = dist_sum[root] - sz[c] + (n - sz[c])
            = dist_sum[root] + (n - 2 * sz[c])

原因: 从根移动到 c 时,c 子树中的 sz[c] 个节点各近了 1,子树外的 (n - sz[c]) 个节点各远了 1。

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

const int MAXN = 100001;
vector<int> adj[MAXN];
long long sz[MAXN];      // 子树大小
long long down[MAXN];    // v 到其子树中所有节点的距离之和
long long ans[MAXN];     // 最终答案:v 到所有节点的距离之和

int n;

// DFS 1:计算 sz[] 和 down[](向下传递距离和)
void dfs1(int u, int par) {
    sz[u] = 1;
    down[u] = 0;
    for (int v : adj[u]) {
        if (v == par) continue;
        dfs1(v, u);
        sz[u] += sz[v];
        down[u] += down[v] + sz[v];  // v 子树中的所有节点多走了一步
    }
}

// DFS 2:向下传播答案(换根)
void dfs2(int u, int par) {
    for (int v : adj[u]) {
        if (v == par) continue;
        // ans[v] = ans[u] - sz[v] + (n - sz[v])
        //        = ans[u] + (n - 2 * sz[v])
        ans[v] = ans[u] + (n - 2 * sz[v]);
        dfs2(v, u);
    }
}

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

    cin >> n;
    for (int i = 0; i < n - 1; i++) {
        int u, v;
        cin >> u >> v;
        u--; v--;
        adj[u].push_back(v);
        adj[v].push_back(u);
    }

    dfs1(0, -1);
    ans[0] = down[0];  // 从根 0 出发到其他所有节点的距离之和
    dfs2(0, -1);

    for (int i = 0; i < n; i++)
        cout << ans[i] << "\n";

    return 0;
}

复杂度: O(N)——两次 DFS,各 O(N)。

换根的直觉理解

🤔 为什么有效?

可以理解为将"视角"从父节点转移到子节点。当我们把根从 u 移动到其子节点 v 时:

  • v 子树中的节点:每个近了 1 → 减去 sz[v]
  • 不在 v 子树中的节点:每个远了 1 → 加上 (n - sz[v])
  • 净变化:+(n - sz[v]) - sz[v] = +(n - 2·sz[v])

8.3.4 换根的一般模式

换根技术可以推广到许多问题。关键是找到:

  1. 原始根定下来后,down[v] 表示什么?
  2. 将根从父节点 u "换"到子节点 v 时,答案如何变化?
  3. ans[u] 计算 ans[v] 的公式是什么?
一般结构:

DFS 1(后序):
    down[v] = combine(down[child_1], down[child_2], ..., sz[v])

DFS 2(前序):
    ans[v] = combine(down[v], up[v])
    对 v 的每个子节点 c:
        up[c] = f(ans[v], down[c], sz[c], n)
        // "up[c]" 是 c 子树之外所有内容的贡献

8.3.4b 换根示例 2:每个节点的最大距离

问题: 对每个顶点 v,求 v 到任意其他顶点的最大距离(v 的"离心率")。输出 N 个值。

这比距离之和更难,因为 max 操作不像 sum 那样可以干净地分解。

核心思路: 对每个顶点 v,最远的顶点要么:

  1. 在 v 的子树中(由 DFS 1 中的 down[] 计算)
  2. 经过 v 的父节点可达(由 DFS 2 中向下传播的 up[] 计算)
📄 2. 经过 v 的父节点可达(由 DFS 2 中向下传播的 `up[]` 计算)
#include <bits/stdc++.h>
using namespace std;

const int MAXN = 100001;
vector<int> adj[MAXN];
int n;

int down[MAXN];   // down[v] = 向下进入 v 子树的最大深度
int up[MAXN];     // up[v]   = 经 v 的父节点向上的最大深度
int ans[MAXN];    // ans[v]  = v 的离心率(到任意节点的最大距离)

// DFS 1:计算 down[](v 子树中的最大深度)
void dfs1(int u, int par) {
    down[u] = 0;
    for (int v : adj[u]) {
        if (v == par) continue;
        dfs1(v, u);
        down[u] = max(down[u], down[v] + 1);
    }
}

// DFS 2:向下传播 up[](往"上方"走的最远距离是多少?)
// up[v] = 从 v 经过父节点能到达的最大距离
void dfs2(int u, int par) {
    ans[u] = max(down[u], up[u]);

    // 计算 up[子节点] 时,需要 u 的第一深和第二深子树
    // (若子节点恰好是最深路径上的节点,则用第二深;否则用最深)
    int best1 = -1, best2 = -1;
    int best1_child = -1;

    for (int v : adj[u]) {
        if (v == par) continue;
        int d = down[v] + 1;
        if (d > best1) { best2 = best1; best1 = d; best1_child = v; }
        else if (d > best2) { best2 = d; }
    }

    for (int v : adj[u]) {
        if (v == par) continue;
        // up[v] = 从 u 向上走或进入兄弟子树的最大距离
        int sibling_best = (v == best1_child) ? best2 : best1;
        int through_parent = up[u] + 1;  // 经 u 的父节点向上,再向下

        up[v] = max(through_parent, (sibling_best >= 0 ? sibling_best + 1 : 0));

        dfs2(v, u);
    }
}

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

    cin >> n;
    for (int i = 0; i < n - 1; i++) {
        int u, v; cin >> u >> v; u--; v--;
        adj[u].push_back(v);
        adj[v].push_back(u);
    }

    dfs1(0, -1);
    up[0] = 0;   // 根节点没有父节点
    dfs2(0, -1);

    for (int i = 0; i < n; i++)
        cout << ans[i] << "\n";

    return 0;
}

💡 关键技巧: 分别追踪最深的两棵子树。如果我们要计算 up[] 的子节点恰好是最深的子树,就用第二深的作为兄弟贡献。


8.3.4c 树背包(子树选择 DP)

问题: 给定有根树,每个顶点 v 有重量 w[v] 和价值 b[v]。选取顶点使总重量 ≤ W,约束条件:若选取 v,则必须同时选取其父节点(从根出发的连通子集)。最大化总价值。

这是经典的树背包(群组背包在树上的变体)。

朴素 O(N²W) 方法

📄 查看代码:朴素 O(N²W) 方法
// dp[v][j] = 从 v 的子树中恰好选取 j 重量(v 包含其中)时的最大价值
// 基础:dp[v][w[v]] = b[v](只选 v,子树中无其他节点)

const int MAXN = 501, MAXW = 501;
int dp[MAXN][MAXW];
int sz[MAXN];    // 子树 DP 中已使用的"容量"
int w[MAXN], b[MAXN];

void dfs(int u, int par) {
    fill(dp[u], dp[u] + MAXW, -1);  // -1 = 不可行
    dp[u][w[u]] = b[u];
    sz[u] = w[u];

    for (int v : adj[u]) {
        if (v == par) continue;
        dfs(v, u);

        // 通过背包卷积将 dp[v] 合并到 dp[u]
        // 逆序处理,避免重复计数
        for (int j = min(sz[u] + sz[v], W); j >= w[u]; j--) {
            for (int k = w[v]; k <= min(j - w[u], sz[v]); k++) {
                if (dp[u][j - k] != -1 && dp[v][k] != -1) {
                    dp[u][j] = max(dp[u][j], dp[u][j - k] + dp[v][k]);
                }
            }
        }
        sz[u] = min(sz[u] + sz[v], W);
    }
}

// 答案 = max(dp[root][j]) 对 j = 0..W

为什么实际上是 O(NW)——合并分析

关键思路: 总工作量受到限制——每对来自不同子树的顶点 (u, v) 只在它们的 LCA 合并时被比较一次。由于每对比较代价为 O(1),且每对最多被计一次,总工作量为 min(O(N²), O(NW))。

实用说明: 对于 USACO,N ≤ 300、W ≤ 300 是典型约束,O(N²W) 或 O(NW) 都很容易通过。

"从根出发的连通子集"背包模板

📄 查看代码:"从根出发的连通子集"背包模板
#include <bits/stdc++.h>
using namespace std;

const int MAXN = 305;
vector<int> adj[MAXN];
int w[MAXN], b[MAXN];
int dp[MAXN][MAXN];   // dp[v][j] = 在 v 的子树中选 j 重量(包含 v)的最大价值
int subtree_w[MAXN];  // 子树总重量

int n, W;

void dfs(int u, int par) {
    fill(dp[u], dp[u] + W + 1, 0);
    dp[u][w[u]] = b[u];
    subtree_w[u] = w[u];

    for (int v : adj[u]) {
        if (v == par) continue;
        dfs(v, u);

        // 将 dp[v] 合并到 dp[u]
        int cap = min(subtree_w[u] + subtree_w[v], W);
        for (int j = cap; j >= w[u]; j--) {
            for (int k = w[v]; k <= min(j - w[u], subtree_w[v]); k++) {
                if (dp[v][k] > 0)
                    dp[u][j] = max(dp[u][j], dp[u][j - k] + dp[v][k]);
            }
        }
        subtree_w[u] = min(subtree_w[u] + subtree_w[v], W);
    }
}

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

    cin >> n >> W;
    for (int i = 0; i < n; i++) cin >> w[i] >> b[i];
    for (int i = 0; i < n - 1; i++) {
        int u, v; cin >> u >> v; u--; v--;
        adj[u].push_back(v);
        adj[v].push_back(u);
    }

    dfs(0, -1);

    int ans = 0;
    for (int j = 0; j <= W; j++)
        ans = max(ans, dp[0][j]);

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

8.3.5 USACO Gold 树形 DP 题型模式

模式 1:选取子树节点

"选择部分顶点激活。激活顶点 v 的代价为 c[v],收益为 b[v]。约束:只有父节点激活后才能激活 v。最大化净收益。"

这是"依赖背包"树形 DP:

// dp[v][j] = 从 v 的子树中选取 j 个顶点的最大收益
// 转移:通过背包合并 dp[v] 与 dp[子节点]

模式 2:树上路径查询

"对每个顶点 v,求最远顶点,或距离之和。"

换根模板——正如 8.3.3 节所示。

模式 3:树上匹配/配对

"在树上配对顶点(每对由一条路径连接)。最大化不相交的配对数。"

dp[v][0/1] = v 未被匹配(0)或已被匹配(1)时,v 子树中的最大配对数。


💡 思路陷阱

陷阱 1:换根题用 O(N²) 暴力 DFS

错误判断: "对每个节点做一次 DFS 计算它当根时的答案,O(N²) 应该够"
实际情况: N=10⁵ 时 O(N²)=10¹⁰,稳定 TLE;此类题几乎都是换根 DP 的典型形态

题目特征(三选一即触发):
  1. "对每个节点 v,求以 v 为根时..."
  2. "输出 N 个值,每个值对应某节点当根的结果"
  3. "求使某个全局指标最小/最大的根节点"

正确:两次 DFS(dfs1 下行 + dfs2 上行)→ O(N)

识别信号: 输出 N 个值且每个值依赖"以该节点为根的某属性" → 换根 DP,而非 N 次 DFS


陷阱 2:树形 DP 中忘记 if (v == par) continue

错误判断: "树是无向图,从 u 出发访问邻居,DFS 会自动不走回头路"
实际情况: 无向图的邻接表中父节点也在邻居列表里,不加 par 检查会死循环/重复计数

📄 C++ 完整代码
// 错误:没有 parent 检查
void dfs(int u) {
    for (int v : adj[u]) {
        dfs(v);              // 若 u 的父节点是 v,这里会死循环!
        dp[u] += dp[v];
    }
}

// 正确:传入 parent 参数
void dfs(int u, int par) {
    for (int v : adj[u]) {
        if (v == par) continue;   // ← 这一行至关重要
        dfs(v, u);
        dp[u] += dp[v];
    }
}

识别信号: 树的 DFS 运行时栈溢出或结果明显偏大 → 先检查有无 parent 防回溯


陷阱 3:用单次 DFS 求"树直径",结果错误

错误判断: "直径就是最深叶子到根的距离,单次 DFS 记录最大深度即可"
实际情况: 直径的两端点不一定经过根,单次 DFS 只算了"经过根的最长路径"

📄 Code 完整代码
树:  1
     / \
    2   3
   /     \
  4       5
  |       |
  6       7

从根 1 出发最大深度 = 3(到 6 或 7),但直径 = 6→4→2→1→3→5→7 = 6 条边
单次 DFS from root 得到 3(错),正确做法:
  方法1:两次 DFS/BFS(从任意点找最远点 A,再从 A 找最远点 B)
  方法2:树 DP,每个节点维护最深和次深子树,直径 = 全局最大的 (最深+次深)

识别信号: "树上最长路径"题 → 直径,要么两次 BFS/DFS,要么树 DP 维护双最深


⚠️ 常见错误

  1. 缺少 if (v == par) continue 没有这个检查,DFS 会沿着边回到父节点,导致无限递归。每棵树的 DFS 都必须有这个保护。

  2. 叶节点基础情况初始化错误: 叶节点没有子节点,循环体不会执行。确保循环之前 dp[leaf] 已正确初始化。

  3. 换根的 dfs2 忘了用前序: dfs2 必须从父节点向子节点传播(自顶向下),所以 ans[u] 必须在 ans[u 的子节点] 之前计算。不要不小心用成后序。

  4. 子树和的整数溢出: 若 N = 10⁵ 且每个顶点贡献最多 N,总和可达 10¹⁰。使用 long long

  5. "距离之和"的差一错误: 公式 down[u] += down[v] + sz[v] 为 v 子树中的每个节点加了 sz[v](它们各多走了一步)。理解为什么是 sz[v] 而非 sz[v]-1


📋 章节小结

📌 核心要点

概念说明
树形 DP后序 DFS;dp[v] 在所有子节点之后计算;O(N)
子树大小sz[v] = 1 + sum(sz[子节点])
树的直径最长路径;两次 DFS 或追踪最深和次深子节点的 DP
最大独立集dp[v][0/1] 分别对应未选/已选;经典树形 DP
换根两次 DFS:先向下计算,再向上传播;O(N) 处理所有根
距离之和经典换根:ans[子节点] = ans[父节点] + n - 2*sz[子节点]

❓ 常见问题

Q:如何判断问题是否需要换根?
A:若问题对每个顶点(以该顶点为根时)请求相同的计算——"对每个顶点,求以它为根时的 X"——那就需要换根。若只对一个固定根请求,标准树形 DP 就够了。

Q:如果边权不为 1 怎么办?
A:调整公式即可。对于带权边,若边 (u,v) 的权重为 w,则 down[u] += down[v] + sz[v] * w[u][v]。换根公式相应调整。

Q:可以用迭代 DFS 替代递归吗?
A:可以,对于大 N 更应该这样做(避免栈溢出)。用显式栈将 DFS 改为迭代,按与入栈相反的顺序处理顶点(后序)。

🔗 与后续章节的联系

  • 第 8.4 章(欧拉游览): 当树形 DP 本身对树上区间查询的效率不够时,欧拉游览 + BIT/线段树是首选方案。
  • 第 8.1 章(MST): 从一般图构建 MST 后,MST 就是一棵树,可以对其做树形 DP。
  • 第 6.3 章(进阶 DP): 树形 DP 是 DAG 上 DP(第 8.2 章)的特例,技术可以互相推广。

🏋️ 练习题

🟢 简单

8.3-E1. 子树查询
给定有根树,对每个顶点 v,输出其子树中的节点数。

提示

标准 sz[] 计算。sz[v] = 1 + sum(sz[子节点])


8.3-E2. 树的直径
求 N 个顶点、单位边权的树的直径(最长路径)。

提示

两次 BFS/DFS:从任意顶点 BFS 找最远顶点 A;再从 A BFS 找最远顶点 B。A 到 B 的距离即为直径。


🟡 中等

8.3-M1. 最大独立集 (USACO 风格)
给定 N 个顶点的树,每个顶点有一个值。选出总价值最大的顶点子集,使得任意两个被选顶点之间没有边相连。

提示

经典 dp[v][0/1]dp[v][1] = val[v] + sum(dp[子节点][0])dp[v][0] = sum(max(dp[子节点][0], dp[子节点][1]))


8.3-M2. 距离之和 (LeetCode 834 / USACO Gold 风格)
对树中每个顶点,求它到所有其他顶点的距离之和。输出 N 个值。

提示

使用 8.3.3 节的换根模板。两次 DFS,总复杂度 O(N)。


🔴 困难

8.3-H1. 牛聚集 USACO 2019 February Gold
N 头奶牛在一棵树上。每头牛有"幸福值"。当牛的父节点离开时,该牛变得更幸福。模拟移除操作,最大化总幸福值。(简化版:找到移除奶牛的顺序,使每次移除时的累积幸福值之和最大。)

提示

建模为树形 DP:对每棵子树计算按最优顺序移除顶点的"收益"。通过换根来回答所有可能起始顶点的情况。


🏆 挑战

8.3-C1. 树背包 (困难)
给定 N 个顶点的有根树,每个顶点 v 有重量 w[v] 和价值 b[v]。选取顶点子集 S,总重量 ≤ W,且若 v ∈ S 则 parent(v) ∈ S(从根出发的连通子集)。最大化总价值。

提示

dp[v][j] = 从 v 的子树(包含 v)中恰好选取 j 重量的最大价值。通过卷积风格的背包合并子节点。用仔细的合并策略可以达到 O(N·W)——关键思路是每对顶点只被比较一次,因此总复杂度通过 DFS 顺序达到 O(N·W)。

📖 第 8.4 章 ⏱️ 约 65 分钟 🎯 Gold / 困难

第 8.4 章:欧拉游览与树的展开

📝 前置要求: 本章需要第 5.4 章(二叉树与树算法:遍历、欧拉序)、第 5.6~5.7 章(线段树和树状数组)以及第 8.3 章(树形 DP 基础)。欧拉游览将树的问题转化为数组问题——必须先熟练掌握区间查询数据结构。

欧拉游览(又称 DFS 序或重链剖分线性化)是将树展开为线性数组的技术。展开后,子树查询变为数组上的区间查询——用树状数组或线段树可在 O(log N) 内求解。

本章还介绍用于 LCA(最近公共祖先)的倍增,它能在 O(log N) 时间内解决树上路径查询。

学习目标:

  • 实现带进/出时间戳的 DFS 序欧拉游览
  • 用游览结果将子树查询转化为数组区间查询
  • 实现倍增 LCA:O(N log N) 预处理 + 每次查询 O(log N)
  • 结合欧拉游览 + LCA 高效回答路径查询

8.4.0 动机:为什么要展开树?

假设有一棵树,需要:

  • 更新 v 子树中所有顶点的值
  • 查询 v 子树中所有顶点的值之和

仅用 DFS,最坏情况下每次操作需 O(N)。但若将 v 的子树转化为连续的数组区间 [in[v], out[v]],就可以用 BIT 或线段树,每次操作 O(log N)。

欧拉游览恰好提供了这种能力: 子树与连续区间之间的一一映射。


8.4.1 DFS 进/出时间戳(欧拉游览)

为每个顶点分配两个时间戳:

  • in[v]:DFS 第一次访问 v 时的时间("进入时刻")
  • out[v]:DFS 完成 v 的子树时的时间("退出时刻")

关键性质: 顶点 u 在顶点 v 的子树中,当且仅当 in[v] ≤ in[u] ≤ out[v]

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

const int MAXN = 100001;
vector<int> adj[MAXN];
int in_time[MAXN], out_time[MAXN];
int order[MAXN];       // order[i] = 第 i 个被访问的顶点
int timer_val = 0;

void dfs(int u, int par) {
    in_time[u] = ++timer_val;   // 记录进入时刻
    order[timer_val] = u;        // 记录该位置对应的顶点

    for (int v : adj[u]) {
        if (v != par)
            dfs(v, u);
    }

    out_time[u] = timer_val;    // 记录退出时刻(等于或晚于 in_time)
}

示例:

📄 Code 完整代码
树(以 1 为根):         DFS 顺序:          进/出时刻:
       1                 访问 1             in[1]=1, out[1]=7
      /|\                访问 2             in[2]=2, out[2]=4
     2  5  7             访问 4             in[4]=3, out[4]=3
    /|   \               返回 2             
   4  3   6              访问 3             in[3]=4, out[3]=4
                         返回 1             
                         访问 5             in[5]=5, out[5]=6
                         访问 6             in[6]=6, out[6]=6
                         返回 1             
                         访问 7             in[7]=7, out[7]=7

顶点 2 的子树 = {2, 4, 3} → 区间 [2, 4]  ✓ (in[2]=2, out[2]=4)
顶点 5 的子树 = {5, 6}    → 区间 [5, 6]  ✓ (in[5]=5, out[5]=6)
顶点 1 的子树 = 全部       → 区间 [1, 7]  ✓ (in[1]=1, out[1]=7)

8.4.2 用 BIT/线段树处理子树查询

得到欧拉游览结果后,可以将顶点值映射到数组位置,并用树状数组处理区间查询。

📄 得到欧拉游览结果后,可以将顶点值映射到数组位置,并用树状数组处理区间查询。
#include <bits/stdc++.h>
using namespace std;

const int MAXN = 100001;
vector<int> adj[MAXN];
int in_time[MAXN], out_time[MAXN];
int val[MAXN];       // 顶点值
int flat[MAXN];      // flat[i] = 欧拉游览第 i 个位置对应顶点的值
int timer_val = 0;

// ── 树状数组(BIT),支持区间求和 ─────────────────
long long bit[MAXN];
int n;

void bit_update(int i, long long delta) {
    for (; i <= n; i += i & (-i))
        bit[i] += delta;
}

long long bit_query(int i) {
    long long s = 0;
    for (; i > 0; i -= i & (-i))
        s += bit[i];
    return s;
}

long long bit_range(int l, int r) {
    return bit_query(r) - bit_query(l - 1);
}

// ── 欧拉游览 DFS ────────────────────────────────────
void dfs(int u, int par) {
    in_time[u] = ++timer_val;
    flat[timer_val] = val[u];        // 将顶点值放到游览对应位置

    for (int v : adj[u]) {
        if (v != par) dfs(v, u);
    }

    out_time[u] = timer_val;
}

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

    cin >> n;
    for (int i = 1; i <= n; i++) cin >> val[i];

    for (int i = 0; i < n - 1; i++) {
        int u, v;
        cin >> u >> v;
        adj[u].push_back(v);
        adj[v].push_back(u);
    }

    dfs(1, 0);

    // 用 flat 数组初始化 BIT
    for (int i = 1; i <= n; i++)
        bit_update(i, flat[i]);

    // 查询示例:
    // 顶点 v 的子树和:
    int v;
    cin >> v;
    cout << bit_range(in_time[v], out_time[v]) << "\n";

    // 将顶点 u 的值增加 delta:
    int u; long long delta;
    cin >> u >> delta;
    bit_update(in_time[u], delta);

    return 0;
}

复杂度:

  • 预处理:O(N) DFS + O(N log N) BIT 初始化
  • 子树查询:O(log N)
  • 单点更新(顶点 v):O(log N)——在位置 in_time[v] 更新
  • 子树更新(给 v 子树中所有顶点加 delta):用差分 BIT,在 in_time[v]out_time[v]+1 处更新

8.4.3 最近公共祖先(LCA)

两个顶点 u 和 v 的 LCA 是同时为它们祖先的最深顶点。

树:           LCA(4, 6) = 2
    1          LCA(4, 7) = 1
   / \         LCA(5, 3) = 2
  2   7
 / \
3   4
   /
  5
  |
  6

LCA 有许多应用:

  • u 和 v 之间的距离: dist(u, v) = depth[u] + depth[v] - 2·depth[LCA(u,v)]
  • 路径查询: u 到 v 路径上的查询可以用 LCA + 欧拉游览来回答

倍增 LCA

预处理: 对每个顶点 v,预计算 up[v][k] = v 的第 2^k 个祖先。

up[v][0] = parent(v)         (直接父节点)
up[v][1] = parent(parent(v)) (祖父节点)
up[v][2] = 往上 2 步
...
up[v][k] = up[up[v][k-1]][k-1]
📄 C++ 完整代码
#include <bits/stdc++.h>
using namespace std;

const int MAXN = 100001;
const int LOG = 17;  // 2^17 > 10^5

vector<int> adj[MAXN];
int up[MAXN][LOG];   // up[v][k] = v 的第 2^k 个祖先
int depth[MAXN];

void dfs(int u, int par, int d) {
    depth[u] = d;
    up[u][0] = par;  // 直接父节点

    // 填充倍增表
    for (int k = 1; k < LOG; k++) {
        up[u][k] = up[up[u][k-1]][k-1];
        // 第 2^k 个祖先 = 第 2^(k-1) 个祖先的第 2^(k-1) 个祖先
    }

    for (int v : adj[u]) {
        if (v != par)
            dfs(v, u, d + 1);
    }
}

// 求 u 和 v 的 LCA
int lca(int u, int v) {
    // 第 1 步:将 u 和 v 提升到同一深度
    if (depth[u] < depth[v]) swap(u, v);
    int diff = depth[u] - depth[v];

    for (int k = 0; k < LOG; k++) {
        if ((diff >> k) & 1)   // 若 diff 的第 k 位为 1
            u = up[u][k];      // 向上跳 2^k 步
    }

    // 现在 depth[u] == depth[v]
    if (u == v) return u;      // u 在 v 的子树中(或反之)

    // 第 2 步:同时对 u 和 v 二分倍增,找 LCA
    for (int k = LOG - 1; k >= 0; k--) {
        if (up[u][k] != up[v][k]) {  // 若祖先不同则向上跳
            u = up[u][k];
            v = up[v][k];
        }
    }

    return up[u][0];  // 当前位置再往上一步 = LCA
}

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

    int n, q;
    cin >> n >> q;

    for (int i = 0; i < n - 1; i++) {
        int u, v;
        cin >> u >> v;
        adj[u].push_back(v);
        adj[v].push_back(u);
    }

    // 初始化:根 = 1,根的父节点指向自身(哨兵)
    up[1][0] = 1;
    dfs(1, 1, 0);

    while (q--) {
        int u, v;
        cin >> u >> v;
        cout << lca(u, v) << "\n";
    }

    return 0;
}

复杂度: O(N log N) 预处理 + 每次 LCA 查询 O(log N)。

两顶点之间的距离

int dist(int u, int v) {
    return depth[u] + depth[v] - 2 * depth[lca(u, v)];
}

8.4.4 欧拉游览 + LCA 处理路径查询

问题: 每个顶点有一个值,回答 Q 次查询:"从 u 到 v 路径上所有顶点的值之和"。

方法: 定义 prefix[v] = 根到 v 路径上所有顶点的值之和,则:

path_sum(u, v) = prefix[u] + prefix[v] - prefix[LCA(u,v)] - prefix[parent(LCA(u,v))]

当值需要动态更新时,需要以 DFS 序为下标的 BIT——这正是欧拉游览的作用。

// 用前缀和 + LCA 求 path_sum(u, v)
// 定义:prefix[v] = 根到 v 的路径上所有顶点值之和(含两端)
// 则:path_sum(u,v) = prefix[u] + prefix[v] - prefix[lca] - prefix[parent[lca]]

long long path_sum(int u, int v) {
    int l = lca(u, v);
    return prefix[u] + prefix[v] - prefix[l] - prefix[up[l][0]];
    //                                                  ↑ LCA 的父节点
}

8.4.5 USACO Gold 欧拉游览题型模式

模式 1:子树更新 + 查询

"对 v 子树中的所有顶点值加 x。查询所有顶点的总和。"

欧拉游览将子树映射到区间 [in[v], out[v]]。使用区间更新的 BIT(懒惰传播或 BIT 差分数组)。

模式 2:带更新的路径和

"更新顶点 v 的值。查询 u 到 w 路径上的总和。"

LCA + 前缀和 + 以 DFS 序为下标的 BIT。

模式 3:基于 LCA 区间的欧拉游览

在欧拉游览中,当 in[u] ≤ in[v] 时,区间 [in[u], in[v]] 恰好包含 u 到 v 路径上的顶点(还有一些额外的,取决于 u 是否是 v 的祖先)。用于高级 LCA 变体。


8.4.6 预览:重链剖分(HLD)

欧拉游览 + LCA 的组合能处理大多数 Gold 级别的树题。但有一类问题需要对路径进行区间更新和查询——而不仅仅是子树。标准的欧拉游览对此不够用。

重链剖分(HLD) 是 Platinum 级别处理此类问题的技术,它直接建立在欧拉游览的概念上。

HLD 解决的问题

"给定 N 个顶点的树,处理 Q 次查询,每次为以下两种之一:

  • update(u, v, delta):给 u 到 v 路径上所有顶点加 delta
  • query(u, v):返回 u 到 v 路径上所有顶点的总和"

欧拉游览能高效处理子树更新/查询,但跨树的路径查询需要 HLD 的 O(log²N)。

核心思路

通过始终沿"重子节点"(子树最大的子节点)将树分解为重链,可以保证:

  • 任意根到叶路径最多经历 O(log N) 次链的切换
  • 每条链在 DFS 序中是连续区间
  • 路径查询 = 对各段链的 O(log N) 次区间查询 → 配合线段树为 O(log²N)
📄 Code 完整代码
重子节点:子树最大的子节点
重路径:从某顶点沿重子节点向下到叶节点的链

树:            sz[] 值:       重边(→):
    1  (sz=7)      7
   / \            / \
  2   3 (sz=4)   3   4
 / \ / \        / \ / \
4  5 6  7      1 1 2  1

重子节点:1→3 (sz=4 > sz=3), 3→6 (sz=2 > sz=1)
重链:{1, 3, 6}、{2}、{4}、{5}、{7}

HLD 实现草图(仅供参考)

📄 查看代码:HLD 实现草图(仅供参考)
// 重链剖分 — O(N log N) 预处理,O(log²N) 路径查询
// 完整实现是 Platinum 级别;以下是概念性草图

int heavy[MAXN];   // heavy[v] = v 的重子节点(叶节点为 -1)
int head[MAXN];    // head[v] = v 所在重链的顶端节点
int pos[MAXN];     // pos[v] = v 在 HLD 展开数组中的位置
int cur_pos = 0;

// 第 1 步:找重子节点(需先用树形 DP 计算 sz[])
void find_heavy(int u, int par) {
    sz[u] = 1;
    heavy[u] = -1;
    int max_sz = 0;
    for (int v : adj[u]) {
        if (v == par) continue;
        find_heavy(v, u);
        sz[u] += sz[v];
        if (sz[v] > max_sz) {
            max_sz = sz[v];
            heavy[u] = v;   // v 是重子节点
        }
    }
}

// 第 2 步:沿重链分配位置
void decompose(int u, int par, int h) {
    head[u] = h;            // 链头
    pos[u] = cur_pos++;     // 在展开数组中的位置

    if (heavy[u] != -1)
        decompose(heavy[u], u, h);      // 延续重链

    for (int v : adj[u]) {
        if (v == par || v == heavy[u]) continue;
        decompose(v, u, v);             // 从 v 开始新链
    }
}

// 第 3 步:利用 LCA + 链跳转进行路径查询
long long path_query(int u, int v) {
    long long result = 0;
    while (head[u] != head[v]) {
        if (depth[head[u]] < depth[head[v]]) swap(u, v);
        // u 的链头更深;查询 pos[head[u]]..pos[u]
        result += seg_query(pos[head[u]], pos[u]);
        u = parent[head[u]];  // 跳到链头的父节点
    }
    // 现在 u 和 v 在同一条链上
    if (depth[u] > depth[v]) swap(u, v);
    result += seg_query(pos[u], pos[v]);  // 查询链上该段
    return result;
}

复杂度:

  • 预处理:find_heavy O(N) + decompose O(N) = O(N)
  • 路径查询:O(log N) 次链切换 × O(log N) 线段树 = O(log²N)

📘 何时用 HLD vs 欧拉游览:

  • 子树查询/更新 → 欧拉游览 + BIT(O(log N))
  • 路径查询/更新,无区间更新 → LCA + 前缀和(O(log N))
  • 路径区间更新 + 区间查询 → HLD + 线段树(O(log²N)) ← Platinum 级别

关键结论:本章的所有内容(欧拉游览、LCA、倍增)都是 HLD 的先修知识。掌握第 8.4 章后,HLD 是自然而然的延伸。


💡 思路陷阱

陷阱 1:把"路径查询"误用欧拉序(应用 LCA)

错误判断: "欧拉序把树压成数组,路径查询 u→v 就是 [in[u], in[v]] 区间查询"
实际情况: 欧拉序只保证子树对应连续区间,路径不是连续区间

树:1-2-3-4(链),欧拉序:in[1]=1, in[2]=2, in[3]=3, in[4]=4
查询路径 1→4 看起来是 [1,4],但路径 2→4 是 [2,4] ——偶然正确
查询路径 3→1(往上走):in[3]=3, in[1]=1,区间 [1,3] 包含节点 2,
  而 2 不在 3→1 的路径上 ← 错误!

正确方法:path_sum(u,v) = prefix[u] + prefix[v] - prefix[LCA] - prefix[parent(LCA)]

识别信号: 查询涉及两点之间的路径(非子树) → 必须用 LCA 分解,不能直接用欧拉游览区间


陷阱 2:LCA 倍增时 LOG 取值过小

错误判断: "树最多 10⁴ 个节点,LOG=13 够了(2^13=8192 > 10⁴/2)"
实际情况: 需要 2^LOG > N,对于 N=10⁴ 需要 LOG=14(2^14=16384)

// 安全做法:LOG 永远用 ceil(log2(N)) + 1,或直接用 20 覆盖 10^6
const int LOG = 20;  // 2^20 = 1048576,覆盖 N≤10^6 的所有情形
// 不要"精确计算",用大一点的常数不会影响复杂度

识别信号: LCA 在深链上得到错误答案 → 检查 LOG 是否足够大


⚠️ 常见错误

  1. LOG 取值有误: N ≤ 10⁵ 时用 LOG = 17(2^17 = 131072 > 10⁵);N ≤ 10⁶ 时用 LOG = 20

  2. 根节点的父节点哨兵: 根节点没有父节点。将 up[root][0] = root(指向自身),避免倍增时越界。

  3. 欧拉游览计时器的差一: 若 BIT 是 1-indexed,则计时器从 1 开始(而非 0)。

  4. 路径和公式出错: 注意要减去 prefix[parent(LCA)],而非 prefix[LCA]。LCA 顶点本身在路径上,应被计入一次。

  5. LCA 算法假设树有根: 倍增是在固定根的基础上建立的。若题目没有指定根,选一个(通常是顶点 1)。


📋 章节小结

📌 核心要点

概念说明
欧拉游览DFS 时间戳 in[v], out[v];v 的子树 = 区间 [in[v], out[v]]
子树查询映射为数组区间查询;用 BIT/线段树;O(log N)
倍增up[v][k] = v 的第 2^k 个祖先;O(N log N) 预处理
LCA深度对齐后二分查找分叉点;O(log N)
距离dist(u,v) = depth[u] + depth[v] - 2·depth[LCA(u,v)]
路径和prefix[u] + prefix[v] - prefix[LCA] - prefix[parent(LCA)]

❓ 常见问题

Q:存在 O(1) 的 LCA 算法吗?
A:有——对特殊欧拉游览应用 RMQ(区间最值查询)可以在 O(N log N) 预处理后实现 O(1) 查询。但 USACO Gold 中 O(log N) 的倍增已完全够用。

Q:如果树以无向图形式给出(没有指定根)怎么办?
A:以顶点 1 为根。根的选择不影响 LCA 算法的正确性,只影响 depth[] 的值。

Q:欧拉游览可以用于边权树吗?
A:可以。将边权赋给下方端点(子节点)。路径查询方式相同,但公式改为 prefix[u] + prefix[v] - 2*prefix[LCA](不减 parent(LCA),因为 LCA 顶点承载的是它到父节点的边权)。

🔗 与后续章节的联系

  • Platinum:重链剖分(HLD): HLD 将树分解为链,再用欧拉游览 + 线段树实现 O(log²N) 的带区间更新的路径查询。
  • 第 8.3 章(树形 DP): LCA 允许"在线"回答树形 DP 查询——无需离线处理即可处理路径查询。
  • 第 5.7 章(线段树): 当 BIT 不支持带懒惰传播的区间更新时,用懒惰线段树替代 BIT。

🏋️ 练习题

🟢 简单

8.4-E1. 子树和查询
给定每个顶点有值的树,回答 Q 次查询:"顶点 v 的子树中所有顶点的值之和是多少?"

提示

计算欧拉游览(进/出时刻)。对 DFS 序建立前缀和数组。查询为 prefix[out[v]] - prefix[in[v] - 1],复杂度 O(N + Q)。


8.4-E2. LCA 基础
给定 N 个顶点的树,回答 Q 次查询:"u 和 v 的 LCA 是什么?"

提示

使用 8.4.3 节的倍增模板。O(N log N) 预处理,每次查询 O(log N)。


🟡 中等

8.4-M1. 距离查询
给定 N 个顶点、单位边权的树,回答 Q 次查询:"顶点 u 和顶点 v 之间的距离是多少?"

提示

dist(u, v) = depth[u] + depth[v] - 2 * depth[LCA(u, v)]。用倍增求 LCA。


8.4-M2. 子树更新 + 查询 (USACO 风格)
给定一棵树,处理 Q 次操作:

  • update v delta:给 v 子树中所有顶点加 delta
  • query v:返回顶点 v 的当前值
提示

欧拉游览将 v 的子树映射为区间 [in[v], out[v]]。使用差分 BIT:O(log N) 区间更新,O(log N) 单点查询。


🔴 困难

8.4-H1. 带更新的路径查询 (USACO Gold 难度)
给定带权树,处理 Q 次操作:

  • update u val:将顶点 u 的值设为 val
  • query u v:u 到 v 路径上所有顶点的值之和
提示

LCA + 欧拉游览路径和。对于动态更新,用以 DFS 序为下标的 BIT 维护前缀和。path_sum(u, v) = bit_prefix(in[u]) + bit_prefix(in[v]) - bit_prefix(in[lca]) - bit_prefix(in[parent(lca)])


🏆 挑战

8.4-C1. 统计经过某顶点的路径数 (困难)
给定一棵树。对每个顶点 v,统计满足"v 在 u 到 w 路径上"的路径 (u, w) 的数量(含 u=v 或 w=v 的情形)。

提示

对固定的 v,路径 (u, w) 经过 v 当且仅当 LCA(u, w) = v,或 LCA(u, v) = v,或 LCA(v, w) = v。使用欧拉游览 + 计数:v 的答案等于 C(sz[v], 2) 减去完全在 v 某棵子树内的路径数。这需要对 v 各子节点的子树大小进行仔细的容斥。

📖 第 8.5 章 ⏱️ 约 55 分钟 🎯 Gold / 困难

第 8.5 章:组合数学与数论

📝 前置要求: 本章需要基础代数知识以及附录 E(数学基础)中的模运算内容。第 6.1 章(DP)也很有帮助,因为许多组合问题是通过 DP 解决的。

组合数学与数论题目在 USACO Gold 中定期出现——通常作为每套题的"数学题"。它们一般涉及计数(有多少种合法方案?)或整除性(哪些数满足某个性质?),答案通常需要对一个素数取模(通常为 10⁹+7)。

学习目标:

  • 正确实现模运算(加、乘、快速幂、逆元)
  • 高效计算 C(n, k) mod p
  • 应用容斥原理
  • 使用埃氏筛分解质因数
  • 识别并解决 USACO 计数问题

8.5.0 为什么需要模运算?

组合数学的答案可能极其庞大。C(100, 50) 有 30 位数字!USACO 题目始终要求输出答案某个素数 p(通常 p = 10⁹+7 = 1,000,000,007)取模的结果。

模运算的核心规则:

  • (a + b) mod p = ((a mod p) + (b mod p)) mod p ✓
  • (a × b) mod p = ((a mod p) × (b mod p)) mod p ✓
  • (a − b) mod p = ((a mod p) − (b mod p) + p) mod p ✓(加 p 防止负数)
  • (a / b) mod p = (a mod p) × (b⁻¹ mod p) mod p ← 需要模逆元
const long long MOD = 1e9 + 7;

long long mod_add(long long a, long long b) { return (a + b) % MOD; }
long long mod_sub(long long a, long long b) { return (a - b + MOD) % MOD; }
long long mod_mul(long long a, long long b) { return (a % MOD) * (b % MOD) % MOD; }

8.5.1 快速幂(二进制取幂)

用反复平方法在 O(log n) 内计算 a^n mod p:

📄 用反复平方法在 O(log n) 内计算 a^n mod p:
// 返回 a^n mod p
long long power(long long a, long long n, long long p = MOD) {
    a %= p;
    long long result = 1;
    while (n > 0) {
        if (n & 1)             // 若 n 当前位为 1
            result = result * a % p;
        a = a * a % p;         // 底数平方
        n >>= 1;               // 移动到下一位
    }
    return result;
}

示例: 2^10 = 2^(1010₂) = 2^8 × 2^2 = 256 × 4 = 1024


8.5.2 模逆元

要计算 a/b mod p,需要 b 的模逆元:一个值 b⁻¹ 满足 b × b⁻¹ ≡ 1 (mod p)。

何时存在? 仅当 gcd(b, p) = 1 时。若 p 为素数且 0 < b < p,逆元始终存在。

方法一:费马小定理(p 必须为素数)

费马小定理:对素数 p 且 gcd(a,p)=1,有 a^(p−1) ≡ 1 (mod p)。

因此:a^(p−2) ≡ a⁻¹ (mod p)。

long long mod_inv(long long a, long long p = MOD) {
    return power(a, p - 2, p);  // O(log p)
}

// 模意义下的除法:
long long mod_div(long long a, long long b) {
    return mod_mul(a, mod_inv(b));
}

方法二:扩展欧几里得算法(适用于非素数模数)

📄 C++ 完整代码
// 返回 x 使得 a*x ≡ 1 (mod m)
// 用扩展 GCD:找 x, y 满足 a*x + m*y = gcd(a, m)
long long ext_gcd(long long a, long long b, long long& x, long long& y) {
    if (b == 0) { x = 1; y = 0; return a; }
    long long x1, y1;
    long long g = ext_gcd(b, a % b, x1, y1);
    x = y1;
    y = x1 - (a / b) * y1;
    return g;
}

long long mod_inv_general(long long a, long long m) {
    long long x, y;
    long long g = ext_gcd(a, m, x, y);
    if (g != 1) return -1;  // 逆元不存在
    return (x % m + m) % m;
}

8.5.3 C(n, k) mod p 的计算

二项式系数 C(n, k) = n! / (k! × (n−k)!) 计算从 n 个中选取 k 个的方案数。

预处理阶乘(适用于多次查询,n ≤ 10⁶)

📄 查看代码:预处理阶乘(适用于多次查询,n ≤ 10⁶)
const int MAXN = 1000001;
const long long MOD = 1e9 + 7;

long long fact[MAXN], inv_fact[MAXN];

void precompute_factorials(int n) {
    fact[0] = 1;
    for (int i = 1; i <= n; i++)
        fact[i] = fact[i-1] * i % MOD;

    inv_fact[n] = power(fact[n], MOD - 2);  // 费马小定理
    for (int i = n - 1; i >= 0; i--)
        inv_fact[i] = inv_fact[i+1] * (i+1) % MOD;
    // inv_fact[i] = 1/i! mod p,倒序计算
}

long long C(int n, int k) {
    if (k < 0 || k > n) return 0;
    return fact[n] % MOD * inv_fact[k] % MOD * inv_fact[n-k] % MOD;
}

帕斯卡三角 DP(适用于 n, k ≤ 2000 的小规模情形)

long long dp[2001][2001];  // dp[n][k] = C(n, k) mod p

void precompute_pascal(int maxn) {
    for (int i = 0; i <= maxn; i++) {
        dp[i][0] = 1;
        for (int j = 1; j <= i; j++)
            dp[i][j] = (dp[i-1][j-1] + dp[i-1][j]) % MOD;
    }
}
// C(n, k) = dp[n][k]

8.5.4 常用组合公式

公式数值含义
C(n, k)n! / (k!(n-k)!)从 n 个中无序选 k 个(不重复)
P(n, k)n! / (n-k)!从 n 个中有序选 k 个(不重复)
n^kn^k将 k 个不同物品放入 n 个不同盒子
C(n+k-1, k)(n+k-1)! / (k!(n-1)!)隔板法:k 个物品放入 n 个盒子(可重复)
n! / (a! b! c! ...)多项式:排列 a 种类型的物品
C(2n, n) / (n+1)卡特兰数二叉树、合法括号序列

卡特兰数(在 USACO 中出现频率出人意料地高):

long long catalan(int n) {
    // C_n = C(2n, n) / (n+1)
    return C(2*n, n) % MOD * mod_inv(n+1) % MOD;
}
// C_0=1, C_1=1, C_2=2, C_3=5, C_4=14, C_5=42, ...

8.5.5 容斥原理

容斥原理通过交替加减计算集合并集的大小:

|A₁ ∪ A₂ ∪ ... ∪ Aₙ| = Σ|Aᵢ| − Σ|Aᵢ ∩ Aⱼ| + Σ|Aᵢ ∩ Aⱼ ∩ Aₖ| − ...

2~3 个集合的模板:

|A ∪ B| = |A| + |B| − |A ∩ B|
|A ∪ B ∪ C| = |A| + |B| + |C| − |A∩B| − |A∩C| − |B∩C| + |A∩B∩C|

USACO 题型:统计"至少满足一个条件"的序列

"统计长度为 N 的序列(每个元素取 1..M),使得 1..K 中每个值至少出现一次的方案数。"

对"缺失值"做容斥:

总数 = Σ_{j=0}^{K} (-1)^j × C(K, j) × (M-j)^N
  • 选 j 个值排除(C(K, j) 种方案)
  • 用剩余 M-j 个值填满 N 个位置:(M-j)^N 种序列
  • 容斥的交替符号
long long count_surjective(int n, int m, int k) {
    // 统计每个 K 值都至少出现一次的 N 元序列数
    long long ans = 0;
    for (int j = 0; j <= k; j++) {
        long long term = C(k, j) * power(m - j, n) % MOD;
        if (j % 2 == 0) ans = (ans + term) % MOD;
        else             ans = (ans - term + MOD) % MOD;
    }
    return ans;
}

8.5.6 埃氏筛

O(N log log N) 时间内找出 N 以内所有质数:

📄 O(N log log N) 时间内找出 N 以内所有质数:
const int MAXN = 1000001;
bool is_prime[MAXN];
vector<int> primes;

void sieve(int n) {
    fill(is_prime, is_prime + n + 1, true);
    is_prime[0] = is_prime[1] = false;

    for (int i = 2; i <= n; i++) {
        if (is_prime[i]) {
            primes.push_back(i);
            for (long long j = (long long)i * i; j <= n; j += i)
                is_prime[j] = false;
        }
    }
}

线性筛 O(N)——用于质因数分解

📄 查看代码:线性筛 O(N)——用于质因数分解
int min_prime[MAXN];  // 每个数的最小质因子

void linear_sieve(int n) {
    for (int i = 2; i <= n; i++) {
        if (min_prime[i] == 0) {  // i 是质数
            min_prime[i] = i;
            primes.push_back(i);
        }
        for (int p : primes) {
            if (p > min_prime[i] || (long long)i * p > n) break;
            min_prime[i * p] = p;
        }
    }
}

// 用 min_prime[] 在 O(log n) 内分解质因数
vector<pair<int,int>> factorize(int n) {
    vector<pair<int,int>> factors;
    while (n > 1) {
        int p = min_prime[n], cnt = 0;
        while (n % p == 0) { n /= p; cnt++; }
        factors.push_back({p, cnt});
    }
    return factors;
}

因子个数

若 n = p₁^a₁ × p₂^a₂ × ... × pₖ^aₖ,则因子个数为 (a₁+1)(a₂+1)...(aₖ+1)。

int count_divisors(int n) {
    auto factors = factorize(n);
    int cnt = 1;
    for (auto [p, e] : factors)
        cnt *= (e + 1);
    return cnt;
}

8.5.7 欧拉 φ 函数

φ(n)(欧拉 φ 函数)统计 [1, n] 中与 n 互质(即 gcd(k, n) = 1)的整数个数。

φ(1) = 1
φ(2) = 1  (只有 1 与 2 互质)
φ(6) = 2  (1 和 5 与 6 互质)
φ(12) = 4 (1, 5, 7, 11)
φ(p) = p-1,对任意质数 p(1..p-1 均与 p 互质)

计算公式

若 n = p₁^a₁ × p₂^a₂ × ... × pₖ^aₖ,则:

φ(n) = n × (1 - 1/p₁) × (1 - 1/p₂) × ... × (1 - 1/pₖ)

单值计算

📄 查看代码:单值计算
int euler_phi(int n) {
    int result = n;
    for (int p = 2; (long long)p * p <= n; p++) {
        if (n % p == 0) {
            while (n % p == 0) n /= p;  // 去掉所有 p 因子
            result -= result / p;        // result *= (1 - 1/p)
        }
    }
    if (n > 1) result -= result / n;     // n 本身是剩余的质因子
    return result;
}

筛求 φ(1..N)——O(N log log N)

📄 查看代码:筛求 φ(1..N)——O(N log log N)
const int MAXN = 1000001;
int phi[MAXN];

void phi_sieve(int n) {
    // 初始化 phi[i] = i(乘法单位元步骤)
    iota(phi, phi + n + 1, 0);

    for (int p = 2; p <= n; p++) {
        if (phi[p] == p) {   // p 是质数(尚未被修改)
            for (int j = p; j <= n; j += p) {
                phi[j] -= phi[j] / p;  // phi[j] *= (1 - 1/p)
            }
        }
    }
}
// 调用 phi_sieve(n) 后,phi[i] = φ(i) 对所有 i ∈ [1, n]

φ 函数在 USACO/组合数学中的应用

  1. 费马小定理的推广: 对满足 gcd(a, n) = 1 的任意 a:a^φ(n) ≡ 1 (mod n)。这就是欧拉定理。

  2. 原根/乘法阶: a 的阶整除 φ(n)。

  3. 项链计数(Burnside): 公式中用到 N 的每个因子 d 对应的 φ(d)。

  4. φ 的求和: Σ_{d|n} φ(d) = n。在对因子做容斥时非常有用。

// 示例:统计满足 1<=a<=b<=n 且 gcd(a,b)=1 的对数
// 答案 = 1 + Σ_{i=2}^{n} φ(i)   ("+1" 对应 (1,1))
phi_sieve(n);
long long count = 1;
for (int i = 2; i <= n; i++)
    count += phi[i];

8.5.8 中国剩余定理(CRT)

中国剩余定理指出:若有两两互质模数的同余方程组:

x ≡ r₁ (mod m₁)
x ≡ r₂ (mod m₂)
...
x ≡ rₖ (mod mₖ)

则在模 M = m₁ × m₂ × ... × mₖ 的意义下存在唯一解 x。

两方程 CRT

x ≡ r₁ (mod m₁)x ≡ r₂ (mod m₂)(其中 gcd(m₁, m₂) = 1):

📄 对 `x ≡ r₁ (mod m₁)` 和 `x ≡ r₂ (mod m₂)`(其中 gcd(m₁, m₂) = 1):
// 返回 x 使得 x ≡ r1 (mod m1) 且 x ≡ r2 (mod m2)
// 要求 gcd(m1, m2) = 1
// 解在 mod (m1 * m2) 意义下唯一
long long crt(long long r1, long long m1, long long r2, long long m2) {
    // x = r1 + m1 * k,对某个 k
    // r1 + m1 * k ≡ r2 (mod m2)
    // m1 * k ≡ r2 - r1 (mod m2)
    // k ≡ (r2 - r1) * inv(m1) (mod m2)
    long long k = (r2 - r1 % m2 + m2) % m2 * mod_inv(m1 % m2, m2) % m2;
    return r1 + m1 * k;
    // 结果在 [0, m1*m2) 范围内,若 m1*m2 > 10^18 可能溢出
    // 必要时使用 __int128
}

广义 CRT(模数非互质)

模数非互质时,解可能不存在,用扩展 GCD 判断:

📄 模数**非互质**时,解可能不存在,用扩展 GCD 判断:
// 返回 {x, lcm(m1,m2)} 使得 x ≡ r1 (mod m1) 且 x ≡ r2 (mod m2)
// 无解时返回 {-1, -1}
// 即使 gcd(m1, m2) > 1 也适用
pair<long long, long long> crt_general(long long r1, long long m1, long long r2, long long m2) {
    long long g = __gcd(m1, m2);
    if ((r2 - r1) % g != 0) return {-1, -1};  // 无解

    long long lcm = m1 / g * m2;
    long long diff = (r2 - r1) / g;
    long long m2g = m2 / g;

    // k ≡ diff * inv(m1/g) (mod m2/g)
    long long k = diff % m2g * mod_inv(m1 / g % m2g, m2g) % m2g;
    long long x = (r1 + m1 * k) % lcm;
    if (x < 0) x += lcm;
    return {x, lcm};
}

多方程 CRT(迭代求解)

📄 查看代码:多方程 CRT(迭代求解)
// 求解方程组:x ≡ r[i] (mod m[i]),i = 0..k-1
// 返回 {x, M},其中 M = 所有模数的 lcm
// 无解时返回 {-1, -1}
pair<long long, long long> crt_multi(vector<long long>& r, vector<long long>& m) {
    long long cur_r = r[0], cur_m = m[0];
    for (int i = 1; i < (int)r.size(); i++) {
        auto [x, M] = crt_general(cur_r, cur_m, r[i], m[i]);
        if (x == -1) return {-1, -1};
        cur_r = x;
        cur_m = M;
    }
    return {cur_r, cur_m};
}

USACO CRT 题型模式

"分别每 A₁ 步、B₁ 天、C₁ 小时发生一次的事件,何时三者同时发生?"

x ≡ r₁ (mod A₁)
x ≡ r₂ (mod B₁)
x ≡ r₃ (mod C₁)
→ 用 crt_multi 迭代求解

8.5.9 USACO Gold 数学题型模式

模式 1:用 DP 计数

"统计长度为 N 的合法序列数,每个元素从 1..M 中选取并满足约束。"

建模为 DP:dp[i][状态] = 以某个状态结尾的长度为 i 的序列数。答案通常需要取模。

模式 2:整除约束

"1 到 N 中有多少数至少能被 {a₁, a₂, ..., aₖ} 之一整除?"

容斥:Σ|aᵢ 的倍数| − Σ|lcm(aᵢ, aⱼ) 的倍数| + ...

// 统计 n 以内 m 的倍数:
int count_multiples(long long n, long long m) {
    return n / m;
}

模式 3:隔板法(Stars and Bars)

"将 N 个相同小球分配到 K 个盒子,满足某些约束。"

无约束:C(N+K-1, K-1)。有"每盒至多 X 个"的约束:使用容斥。

模式 4:对称性 / Burnside 引理

"统计不同的项链/着色方案数(考虑旋转/翻转的等价性)。"

Burnside 引理:所有群元素的不动点数的平均值。在 USACO 中不常见但印象深刻。


💡 思路陷阱

陷阱 1:对非素数模数使用费马小定理求逆元

错误判断: "求 a 的逆元就是 power(a, MOD-2, MOD)"
实际情况: 费马小定理要求 MOD 是素数,且 gcd(a, MOD)=1;若 MOD 不是素数(如 MOD=10⁶)则结果错误

// 错误:MOD = 10^6(不是素数)
long long inv = power(6, 1e6 - 2, 1e6);  // 6^(10^6-2) mod 10^6 ≠ 6⁻¹

// 正确:MOD 不是素数时,用扩展欧几里得
long long inv = mod_inv_general(6, 1000000);  // ext_gcd 方法
// 大多数 USACO 题的 MOD=10⁹+7(素数),直接用 Fermat 没问题

识别信号: 题目给的模数不是 10⁹+7 或 998244353 → 先验证是否为素数再决定求逆方法


陷阱 2:容斥原理的符号方向搞反

错误判断: "偶数大小子集加,奇数大小子集减"(或者反过来)
实际情况: 容斥公式:|A₁∪...∪Aₙ| = Σ|单集合| - Σ|二元交| + Σ|三元交| - ...
奇数大小子集,偶数大小子集(包含-排除交替)

📄 奇数大小子集**加**,偶数大小子集**减**(包含-排除交替)
// 常见题:N 个元素,统计"至少满足 k 个条件之一"
// 错误:把 + - 方向搞反
long long ans = 0;
for (int mask = 1; mask < (1<<k); mask++) {
    int bits = __builtin_popcount(mask);
    long long term = compute_intersection(mask);
    if (bits % 2 == 0) ans += term;  // ← 错误!偶数应该减
    else               ans -= term;  // ← 错误!奇数应该加
}

// 正确
    if (bits % 2 == 1) ans += term;  // 奇数大小交集:加
    else               ans -= term;  // 偶数大小交集:减

识别信号: 容斥答案出现负数或明显偏大 → 检查 +/- 符号是否与 popcount % 2 对应正确


陷阱 3:C(n, k) 中 k < 0 或 k > n 时未做边界检查

错误判断: "公式直接套,fact[n] * inv_fact[k] * inv_fact[n-k]"
实际情况: 当 k < 0 或 k > n 时,inv_fact 数组越界或数学上 C(n,k) 应为 0

📄 C++ 完整代码
// 错误:没有边界检查
long long C(int n, int k) {
    return fact[n] * inv_fact[k] % MOD * inv_fact[n-k] % MOD;
    // 若 k=-1 或 k=n+1,访问 inv_fact[-1] 是野指针访问
}

// 正确:加边界保护
long long C(int n, int k) {
    if (k < 0 || k > n || n < 0) return 0;  // ← 必须有!
    return fact[n] * inv_fact[k] % MOD * inv_fact[n-k] % MOD;
}

识别信号: 组合数计算在某些特殊输入下崩溃或返回极大值 → 检查边界条件


⚠️ 常见错误

  1. a * b % MOD 中的整数溢出: 若 a, b ≈ 10⁹,则 a * b 可能溢出 int 甚至 long long。务必先转换:(long long)a * b % MOD

  2. 减法结果为负: (a - b) % MOD 在 C++ 中可能为负数。始终写成 (a - b + MOD) % MOD

  3. inv_fact[0] = 1 确保 inv_fact[0] = 1(因为 0! = 1)。precompute_factorials 中的倒序循环会处理此问题。

  4. C(n, k) 当 k > n 或 k < 0 时为 0: 始终检查这些边界情况。

  5. MOD 不是素数: 费马小定理要求 p 为素数。若题目使用非素数模数(罕见),用 ext_gcd 求模逆元。

  6. 大 n 下的 Lucas 定理: 当 n 极大(10¹²+)但素数模数 p 较小(< 10⁶)时,使用 Lucas 定理:C(n, k) mod p = C(n mod p, k mod p) × C(n/p, k/p) mod p。在 USACO Gold 中罕见,但在 Platinum 中会出现。


📋 章节小结

📌 核心要点

概念说明
模逆元a⁻¹ mod p = a^(p-2) mod p(费马,p 为素数);O(log p)
阶乘表预处理 fact[], inv_fact[] 到 10⁶;O(N) 空间
C(n, k) mod pfact[n] * inv_fact[k] * inv_fact[n-k] mod p;每次查询 O(1)
容斥原理对约束的子集交替求和
筛法O(N log log N) 找出 N 以内所有质数;O(log N) 分解质因数
卡特兰数C(2n,n)/(n+1);统计二叉树、合法括号序列数

❓ 常见问题

Q:USACO 中最常见的模数是什么?
A:10⁹+7(1,000,000,007),是素数。偶尔用 998,244,353(也是素数,用于 NTT)。

Q:怎么判断一道题需要组合数学还是 DP?
A:若问题有"漂亮的"封闭形式答案(如 C(n,k)),用组合数学。若约束有复杂依赖,可能需要 DP。通常两者结合:先用 DP 建立表格,再用组合数学求和。

Q:GCD 是什么?什么时候需要用它?
A:gcd(a, b) = 最大公因数。C++ 中用 __gcd(a, b)。用途:化简分数、检验整除性、计算 lcm = a*b/gcd(a,b)。

Q:什么时候用 Lucas 定理?
A:当 n 极大(10¹²+)但素数模数 p 较小(< 10⁶)时。在 USACO Gold 中罕见,但在 Platinum 中出现。

🔗 与后续章节的联系

  • 附录 E(数学基础): 本章是附录 E 的延伸——模运算、质数和组合数学都在那里介绍。
  • 第 6.3 章(进阶 DP): 数位 DP(统计满足数位约束的整数)结合了数论与 DP。
  • 第 8.2 章(DAG DP): DAG 中的路径计数通常需要对结果取模 p。

🏋️ 练习题

🟢 简单

8.5-E1. 模意义快速幂
给定 a, n, p(p 为素数,n ≤ 10¹⁸),计算 a^n mod p。

提示

二进制取幂(power(a, n, p))。注意溢出:若 a, p ≈ 10¹⁸,使用 __int128 或谨慎处理乘法。


8.5-E2. 网格路径计数
统计在 N×M 网格中从 (0,0) 到 (n,m) 的单调路径数(只能向右或向下)。输出 mod 10⁹+7 的结果。

提示

答案为 C(n+m, n) = (n+m)! / (n! × m!)。用预处理的阶乘和模逆元计算。


🟡 中等

8.5-M1. 序列计数 (USACO 风格)
统计长度为 N 的序列数,每个元素取自 {1, 2, ..., M},且 K 个"特殊值"都至少出现一次。输出 mod 10⁹+7 的结果。

提示

容斥:使用 8.5.5 节中的 count_surjective(N, M, K)。枚举 j 个被排除的值。


8.5-M2. 因子和
给定 N 个数 a₁, a₂, ..., aₙ,对每个 aᵢ 输出其所有因子之和对 10⁹+7 取模的结果。

提示

用线性筛预计算最小质因子并分解每个 aᵢ。若 aᵢ = p₁^e₁ × ... × pₖ^eₖ,因子和 = 各 (1 + pᵢ + pᵢ² + ... + pᵢ^eᵢ) 的乘积 = 各 (pᵢ^(eᵢ+1) - 1) / (pᵢ - 1) 的乘积。用模逆元处理除法。


🔴 困难

8.5-H1. 项链计数 (Burnside 引理)
统计由 N 颗珠子组成、每颗用 K 种颜色之一着色的不同项链数(旋转等价的视为相同)。

提示

Burnside 引理:答案 = (1/N) × Σ_{d|N} φ(N/d) × K^d,其中 φ 为欧拉 φ 函数,求和对 N 的因子 d 进行。需要 GCD、模逆元和欧拉 φ 函数的计算。


🏆 挑战

8.5-C1. 树上期望值 (接近 USACO Platinum 难度)
给定 N 个顶点的树,每个顶点初始无色。以 1/2 的概率将每个顶点染成红色,以 1/2 的概率染成蓝色。求两端颜色相同的边的期望数。将答案表示为最简分数 p/q,输出 p × q⁻¹ mod 10⁹+7。

提示

由期望的线性性:E[同色边数] = 边数 × P(两端同色)。对每条边,P(同色) = 1/4(均红)+ 1/4(均蓝)= 1/2。所以答案为 (N-1)/2。输出 (N-1) × mod_inv(2) % MOD。

(有趣的变体是给每个顶点不同的着色概率——用相同思路推广即可。)

附录 A:C++ 速查手册

本附录是你的备忘单,在练习时随时参考。这里的所有内容在书中都已涵盖,这是精简的参考形式。


A.1 竞赛模板

📄 查看代码:A.1 竞赛模板
#include <bits/stdc++.h>
using namespace std;

int main() {
    ios_base::sync_with_stdio(false);
    cin.tie(NULL);
    // freopen("problem.in", "r", stdin);   // 文件 I/O 时取消注释(使用实际题目名称)
    // freopen("problem.out", "w", stdout);  // 文件 I/O 时取消注释

    // 你的代码

    return 0;
}

A.2 常用数据类型

类型大小范围使用场景
int32 位±2.1 × 10^9默认整数
long long64 位±9.2 × 10^18大数、乘积
double64 位~15 位有效数字小数
bool1 字节true/false标志
char8 位-128 到 127单字符
string可变任意长度文本

安全最大值:

INT_MAX   = 2,147,483,647   ≈ 2.1 × 10^9
LLONG_MAX = 9,223,372,036,854,775,807 ≈ 9.2 × 10^18

A.3 STL 容器——操作速查

vector<T>

📄 查看代码:vector
vector<int> v;              // 空向量
vector<int> v(n, 0);        // n 个零
vector<int> v = {1,2,3};    // 从列表初始化

v.push_back(x);     // 尾部追加——O(1) 均摊
v.pop_back();       // 删除最后——O(1)
v[i]                // 访问下标 i——O(1)
v.front()           // 第一个元素
v.back()            // 最后一个元素
v.size()            // 元素个数
v.empty()           // 空则返回 true
v.clear()           // 删除所有元素
v.resize(k, val)    // 调整为 k 个,新元素填充 val
v.insert(v.begin()+i, x)  // 在下标 i 处插入——O(n)
v.erase(v.begin()+i)      // 删除下标 i——O(n)

pair<A,B>

pair<int,int> p = {3, 5};
p.first             // 3
p.second            // 5
make_pair(a, b)     // 创建 pair
// 比较:先比 .first,再比 .second

map<K,V>

map<string,int> m;
m[key] = val;           // 插入/更新——O(log n)
m[key]                  // 访问(不存在时创建!)——O(log n)
m.find(key)             // 迭代器;未找到返回 .end()——O(log n)
m.count(key)            // 0 或 1——O(log n)
m.erase(key)            // 删除——O(log n)
m.size()                // 条目数
for (auto &[k,v] : m)   // 按键有序遍历

set<T>

set<int> s;
s.insert(x)             // 添加——O(log n)
s.erase(x)              // 删除所有 x——O(log n)
s.count(x)              // 0 或 1——O(log n)
s.find(x)               // 迭代器——O(log n)
s.lower_bound(x)        // 第一个 >= x 的元素
s.upper_bound(x)        // 第一个 > x 的元素
*s.begin()              // 最小元素
*s.rbegin()             // 最大元素

stack<T>

stack<int> st;
st.push(x)      // 压入——O(1)
st.pop()        // 弹出(无返回值!)——O(1)
st.top()        // 查看顶部——O(1)
st.empty()      // 空则返回 true
st.size()       // 元素数

queue<T>

queue<int> q;
q.push(x)       // 入队——O(1)
q.pop()         // 出队(无返回值!)——O(1)
q.front()       // 队首元素——O(1)
q.back()        // 队尾元素——O(1)
q.empty()
q.size()

priority_queue<T>(最大堆)

priority_queue<int> pq;                               // 最大堆
priority_queue<int, vector<int>, greater<int>> pq2;   // 最小堆

pq.push(x)      // 插入——O(log n)
pq.pop()        // 删除顶部——O(log n)
pq.top()        // 查看顶部(最大值)——O(1)
pq.empty()
pq.size()

unordered_map<K,V> / unordered_set<T>

接口与 map/set 相同,但平均 O(1)(无有序迭代)。


A.4 STL 算法速查

📄 查看代码:A.4 STL 算法速查
// 全部假设 #include <bits/stdc++.h>

// 排序
sort(v.begin(), v.end());                          // 升序
sort(v.begin(), v.end(), greater<int>());          // 降序
sort(v.begin(), v.end(), [](int a, int b){...});   // 自定义

// 二分查找(需要已排序容器)
binary_search(v.begin(), v.end(), x)               // bool:存在吗?
lower_bound(v.begin(), v.end(), x)                 // 第一个 >= x 的迭代器
upper_bound(v.begin(), v.end(), x)                 // 第一个 > x 的迭代器

// 最小/最大值
min(a, b)               // 两者最小
max(a, b)               // 两者最大
min({a, b, c})          // 多个中最小(C++11)
*min_element(v.begin(), v.end())   // 容器最小
*max_element(v.begin(), v.end())   // 容器最大

// 累加
accumulate(v.begin(), v.end(), 0LL)   // 求和(long long 用 0LL)

// 填充
fill(v.begin(), v.end(), x)           // 全部填充 x
memset(arr, 0, sizeof(arr))           // 清零 C 数组(快速)

// 反转
reverse(v.begin(), v.end())           // 原地反转

// 统计
count(v.begin(), v.end(), x)          // 统计 x 的出现次数

// 去重(删除连续重复——先排序!)
auto it = unique(v.begin(), v.end());
v.erase(it, v.end());

// 交换
swap(a, b)              // 交换两个值

// 全排列(暴力时有用)
sort(v.begin(), v.end());
do {
    // 处理当前排列
} while (next_permutation(v.begin(), v.end()));

// GCD / LCM(C++17)
gcd(a, b)                           // 最大公约数——来自 <numeric> 的 std::gcd
lcm(a, b)                           // 最小公倍数——来自 <numeric> 的 std::lcm
// 旧版(C++17 之前):__gcd(a, b)  // 仍然有效,但推荐用 std::gcd

A.5 时间复杂度参考表

图示:复杂度与 N 参考

Complexity Table

上面的彩色表格能让你一眼看出可行性。读题时,找到列中的 N 和行中的算法复杂度,看它是否能在 1 秒内通过。

N最大可行复杂度算法层级
N ≤ 12O(N! × N)所有排列
N ≤ 20O(2^N × N)所有子集 + 线性操作
N ≤ 500O(N³)3 重嵌套循环、区间 DP
N ≤ 5000O(N²)2 重嵌套循环、O(N²) DP
N ≤ 10^5O(N log N)排序、BFS、二分查找
N ≤ 10^6O(N)线性扫描、前缀和
N ≤ 10^8O(N)O(N / 32)纯循环或位集

A.6 常见陷阱

整数溢出

📄 查看代码:整数溢出
// 错误
int a = 1e9, b = 1e9;
int product = a * b;  // 溢出!

// 正确
long long product = (long long)a * b;

// 错误
int n = 1e5;
int arr[n * n];  // n*n = 10^10,太大了

// 检查:若中间值可能超过 2 × 10^9,用 long long

差一错误

// 错误:访问 arr[n]
for (int i = 0; i <= n; i++) cout << arr[i];

// 正确
for (int i = 0; i < n; i++) cout << arr[i];   // 0-indexed
for (int i = 1; i <= n; i++) cout << arr[i];  // 1-indexed

// 前缀和:P[i] = 前 i 个元素之和
// 查询 [L, R] 的和(1-indexed):P[R] - P[L-1]
// 不是 P[R] - P[L]  ← 差一!

遍历时修改容器

// 错误
for (auto it = s.begin(); it != s.end(); ++it) {
    if (*it % 2 == 0) s.erase(it);  // 迭代器失效!
}

// 正确
set<int> toErase;
for (int x : s) if (x % 2 == 0) toErase.insert(x);
for (int x : toErase) s.erase(x);

map 访问时创建条目

map<string,int> m;
if (m["missing_key"])  // 创建值为 0 的 "missing_key"!

// 正确:先检查
if (m.count("missing_key") && m["missing_key"])  // 安全
// 或:
auto it = m.find("missing_key");
if (it != m.end() && it->second) { ... }

浮点数比较

double a = 0.1 + 0.2;
if (a == 0.3)  // 因浮点精度可能为 false!

// 正确:用 epsilon 比较
const double EPS = 1e-9;
if (abs(a - 0.3) < EPS) { ... }

深度递归导致栈溢出

// 大图上的 DFS 可能导致栈溢出
// 对 N = 10^5 个节点排成链的树,递归深度 = 10^5
// 修复:增大栈大小,或用迭代 DFS

// 在 Linux/Mac 上增大栈:
// ulimit -s unlimited
// 或编译时:g++ -DLOCAL ... 并手动设置栈大小

A.7 常用 #definetypedef

📄 查看代码:A.7 常用 #define 和 typedef
// 常用缩写(个人喜好——不要过度使用)
typedef long long ll;
typedef pair<int,int> pii;
typedef vector<int> vi;

#define pb push_back
#define all(v) (v).begin(), (v).end()
#define sz(v) ((int)(v).size())

// 用法示例:
ll x = 1e18;
pii p = {3, 5};
vi v = {1, 2, 3};
sort(all(v));

A.8 C++17 实用特性

📄 查看代码:A.8 C++17 实用特性
// 结构化绑定——简洁地解包 pair/tuple
auto [x, y] = make_pair(3, 5);
for (auto [key, val] : mymap) { ... }

// 带初始化器的 if
if (auto it = m.find(key); it != m.end()) {
    // 使用 it->second
}

// gcd 和 lcm
int g = gcd(12, 8);   // C++17:使用 <numeric> 中的 std::gcd
int l = lcm(4, 6);    // C++17:使用 <numeric> 中的 std::lcm

// 编译:g++ -std=c++17 -O2 -o sol sol.cpp

附录 B:USACO 题目集

本附录提供了按主题分类的 20 道 USACO 精选题目,这些题目经过精心挑选以巩固本书中涵盖的技术。所有题目都可以在 usaco.org 上免费获取。


如何使用本题目集

大致按顺序做这些题。对每道题:

  1. 仔细读题,独立尝试解题至少 1-2 小时
  2. 若卡住,看下面的提示(不是完整题解)
  3. 若再过 30 分钟仍卡住,在 USACO 网站上读题解
  4. 解完(或读完题解)后,从零自行实现解法

当你挣扎后再理解时学习最多,而不是被动地读解法。


按学习阶段使用这份题单

这份附录不只是题目列表,更应该当作一张「从章节到真题」的训练地图。建议按下面顺序推进:

阶段目标推荐章节代表题训练重点
Bronze 入门熟悉输入、模拟、边界第 2.1–2.6 章1, 2, 3, 5读懂题意,减少实现错误
Bronze → Silver排序、二分、前缀和第 3.2–3.9 章4, 6, 8, 9, 10把暴力优化到 O(N log N)O(N)
Silver 图论BFS/DFS、并查集第 5.1–5.6 章11, 12, 13, 14建图、连通性、最短步数
Silver DP状态设计与转移第 6.1–6.2 章7, 15, 18明确 dp 状态含义
Silver/Gold 综合贪心、DP、二分组合第 4.1、6.3、8.x 章16, 20识别混合算法与证明正确性

做题时的五段复盘法

每道题做完后,不管是否 AC,都用下面五个问题复盘:

  1. 题干解读: 题目真正要求的量是什么?哪些约束决定算法复杂度?
  2. 思路分析: 为什么这个题对应某个章节的算法?暴力会在哪里超时?
  3. CPP 实现: 用到了哪些容器、排序规则、下标约定、快速 I/O?
  4. 易错点: 哪个边界最容易错?有没有溢出、越界、重复计数?
  5. 拓展思考: 如果数据范围扩大、限制改变、目标函数改变,算法是否还成立?

第一节:模拟与暴力(Bronze)

题目 1:遮挡广告牌

竞赛: USACO 2017 December Bronze | 主题: 二维几何,矩形

描述: 两块广告牌和一辆卡车(都是矩形),求广告牌未被卡车遮挡的面积。

关键洞察: 计算卡车与每块广告牌的交集。广告牌面积 - 交集面积 = 可见面积。

技术: 二维矩形求交、仔细算术 | 难度: ⭐⭐


题目 2:奶牛信号

竞赛: USACO 2016 February Bronze | 主题: 二维数组操作

描述: 给定 K×L 字符网格中的图案,按因子 R「放大」它(每个方向重复每个字符 R 次)。

关键洞察: 输出位置 (i,j) 的字符来自输入的 ((i-1)/R + 1, (j-1)/R + 1)。

技术: 二维数组索引、整数除法 | 难度:


题目 3:套球游戏

竞赛: USACO 2016 January Bronze | 主题: 模拟

描述: 一个套球游戏,追踪球在一系列交换后的位置。

关键洞察: 追踪球在每次交换中的位置,尝试球在三个起始位置下的情况。

技术: 模拟、对起始位置的暴力 | 难度:


题目 4:统计干草堆

竞赛: USACO 2016 November Bronze | 主题: 排序、搜索

描述: N 捆干草在特定位置,Q 次查询问 [A, B] 范围内有多少捆。

关键洞察: 排序干草堆位置,然后对每次查询用二分查找(lower_bound/upper_bound)。

技术: 排序、二分查找 | 难度: ⭐⭐


题目 5:割草

竞赛: USACO 2016 January Bronze | 主题: 网格模拟

描述: FJ 按 N 条指令割草,统计他割了不止一次的格子数。

关键洞察: 在集合/映射中追踪所有访问过的位置,再次访问格子时就是双重割草。

技术: 用集合/映射追踪已访问格子、方向模拟 | 难度: ⭐⭐


第二节:数组与前缀和(Bronze/Silver)

题目 6:品种统计

竞赛: USACO 2015 December Bronze | 主题: 前缀和

描述: N 头奶牛各有品种 1、2 或 3,Q 次查询问 [L, R] 范围内 B 品种有多少头。

关键洞察: 为 3 种品种各建一个前缀和数组,每次查询 O(1) 回答。

技术: 前缀和、多数组 | 难度: ⭐⭐


题目 7:牛蹄剪刀布

竞赛: USACO 2017 January Gold | 主题: DP

描述: Bessie 玩 N 局,最多换 K 次手势,最大化获胜局数。

关键洞察: DP 状态:(轮次,已用次数,当前手势)。完整解法见第 6.2 章。

技术: 三维 DP | 难度: ⭐⭐⭐


第三节:排序与二分查找(Bronze/Silver)

题目 8:愤怒的奶牛

竞赛: USACO 2016 February Bronze | 主题: 排序、模拟

描述: 数轴上的奶牛,一头奶牛发射「爆炸」向外蔓延,引发其他奶牛。找能引发所有奶牛的最小初始爆炸半径。

关键洞察: 对爆炸半径二分答案,对给定半径模拟哪些奶牛被引发。

技术: 二分答案、排序、模拟 | 难度: ⭐⭐⭐


题目 9:攻击性奶牛

竞赛: USACO 2011 March Silver | 主题: 二分答案

描述: N 个位置,放置 C 头奶牛使任意两头奶牛间的最小距离最大。

关键洞察: 对答案(最小距离)二分,对每个候选距离贪心检查能否放 C 头奶牛。

技术: 二分答案、贪心检查 | 难度: ⭐⭐⭐


题目 10:Convention

竞赛: USACO 2018 February Silver | 主题: 二分答案 + 贪心

描述: N 头奶牛在时间 t[i] 到达,乘坐 M 辆容量为 C 的公共汽车,最小化最大等待时间。

关键洞察: 对最大等待时间二分,对每个候选值贪心将奶牛分配到汽车。

技术: 二分答案、贪心模拟、排序 | 难度: ⭐⭐⭐


第四节:图论算法(Silver)

题目 11:关闭农场

竞赛: USACO 2016 January Silver | 主题: DSU、离线处理

描述: 农场有 N 块田地和 M 条路径,逐一移除田地,每次移除后判断剩余田地是否仍全部连通。

关键洞察: 逆向处理——按逆序添加田地,用 DSU 追踪添加田地时的连通性。

技术: DSU、逆向处理 | 难度: ⭐⭐⭐


题目 12:Moocast

竞赛: USACO 2016 February Silver | 主题: DSU / BFS

描述: 田地上 N 头奶牛,各有对讲机范围 p[i]。找使所有奶牛能通信(直接或通过中继)的最小范围。

关键洞察: 对最小范围二分答案,对给定范围建图并检查连通性。

技术: 二分答案、BFS/DFS 连通性,或 Kruskal MST | 难度: ⭐⭐⭐


题目 13:BFS 最短路

竞赛: 经典问题(倒水问题)| 主题: 状态空间 BFS

描述: 容量为 X 和 Y 的两个桶,填/倒/出操作,找使任意一桶恰好有 M 升的最少操作次数。

关键洞察: 将(桶1中的量,桶2中的量)建模为图状态,BFS 找最少操作次数。

技术: 状态图 BFS | 难度: ⭐⭐⭐


题目 14:草地鉴赏家

竞赛: USACO 2015 December Silver | 主题: SCC(强连通分量)、DAG 上的 BFS

描述: 牧场有向图,Bessie 可以免费反转一条边,找从牧场 1 出发的环游能访问的最多牧场数。

关键洞察: 将 SCC 收缩为超级节点,在 DAG 上做 BFS,对每条可反转的边检查改善情况。

技术: SCC、BFS、图收缩 | 难度: ⭐⭐⭐⭐(Gold 级别思维,Silver 竞赛题)


第五节:动态规划(Silver)

题目 15:矩形牧场

竞赛: USACO 2021 January Silver | 主题: 二维前缀和、DP

描述: N 头奶牛在二维网格上(横纵坐标各不同),统计恰好包含 K 头奶牛的轴对齐矩形数量。

关键洞察: 按 x 排序,对每对列,在行上做 DP,二维前缀和快速统计矩形。

技术: 二维前缀和、组合数学 | 难度: ⭐⭐⭐


题目 16:柠檬水队伍

竞赛: USACO 2017 February Bronze | 主题: 贪心

描述: N 头奶牛,奶牛 i 在队伍中已有 ≤ p[i] 头奶牛时才会加入,求队伍中奶牛的最大数量。

关键洞察: 按耐心(p[i])降序排序,贪心地尽可能添加每头奶牛。

技术: 排序、贪心 | 难度: ⭐⭐


题目 17:最高奶牛

竞赛: USACO 2016 February Silver | 主题: 差分数组

描述: N 头奶牛排成一排,给定对 (A, B) 表示奶牛 A 能看到 B(即两者之间的奶牛都更矮),求每头奶牛的最大可能身高。

关键洞察: 用差分数组追踪身高约束,对每对 (A, B),A 和 B 之间的奶牛必须比两者都矮。

技术: 差分数组、前缀和 | 难度: ⭐⭐⭐


第六节:混合(Silver)

题目 18:平衡行动

竞赛: USACO 2018 January Silver | 主题: 树形 DP、质心

描述: 找树的「质心」——移除后创建最平衡划分的节点(最大化最小剩余分量大小)。

关键洞察: 通过 DFS 计算子树大小。移除某节点时最大分量是 max(各子节点子树大小, N - 该节点子树大小)。

技术: 树形 DP、子树大小 | 难度: ⭐⭐⭐


题目 19:拼接国家

竞赛: USACO 2016 January Bronze | 主题: 字符串操作、排序

描述: 给定 N 个字符串,对每对 (i, j)(i < j)形成字符串 s_i + s_j,统计有多少个这样的拼接字符串是回文。

关键洞察: 检查每一对,O(N² × L),N ≤ 1000 时可行。

技术: 字符串操作、回文检查 | 难度: ⭐⭐


题目 20:摘浆果

竞赛: USACO 2020 January Silver | 主题: 贪心、DP

描述: Bessie 从 N 棵树上摘浆果,有 K 个篮子,每个篮子只能装一棵树的浆果,在同组篮子必须装相同数量的约束下最大化总浆果数。

关键洞察: 最优:K/2 个篮子给 Bessie,K/2 个给 Elsie。排序树,对 Elsie 篮子的每种可能大小,二分查找找 Bessie 的最优分配。

技术: 排序、二分查找、贪心 | 难度: ⭐⭐⭐⭐


代表题深度拆解:如何把真题映射回章节

下面选取几道代表题,示范如何用「题干解读 → 思路分析 → CPP 实现要点 → 易错点 → 拓展思考」复盘。完整代码建议回到对应章节阅读或自行实现。

代表题 A:统计干草堆(题目 4)

  • 对应章节: 第 3.3 章(排序与搜索)、第 3.9 章(二分答案的前置思想)
  • 题干解读: 多次询问区间 [A,B] 内有多少个点。Q 很大,不能每次扫描所有干草堆。
  • 思路分析: 排序位置数组后,用 lower_bound(A) 找第一个 ≥ A 的位置,用 upper_bound(B) 找第一个 > B 的位置,两者下标差就是答案。
  • CPP 实现要点: auto left = lower_bound(pos.begin(), pos.end(), A);auto right = upper_bound(pos.begin(), pos.end(), B);,答案是 right - left
  • 易错点: upper_bound(B) 不是 lower_bound(B);区间端点是闭区间 [A,B]
  • 拓展思考: 如果位置会动态新增/删除,静态数组二分不够,需要 set、树状数组或线段树。

代表题 B:关闭农场(题目 11)

  • 对应章节: 第 5.6 章(并查集)、第 5.1–5.2 章(图的连通性)
  • 题干解读: 农场按给定顺序关闭,每次问剩余开放节点是否连通。正向删除很难维护连通性。
  • 思路分析: 反向思考:关闭顺序反过来就是逐个打开节点。并查集擅长维护「新增边后的连通块数量」。
  • CPP 实现要点: 反向遍历打开节点;每打开一个节点,连通块数 components++;对已打开邻居执行 union,成功合并则 components--
  • 易错点: 只和已经打开的邻居合并;答案要反向填回原顺序;节点编号通常是 1-indexed。
  • 拓展思考: 如果题目要求支持任意时刻删除和新增边,普通 DSU 不支持删除,需要离线处理、分治回滚 DSU 或动态连通性结构。

代表题 C:牛蹄剪刀布(题目 7)

  • 对应章节: 第 6.1 章(DP 入门)、第 6.2 章(经典 DP 问题)
  • 题干解读: 已知对手每轮手势,Bessie 可改变手势的次数有限,目标最大化胜场。
  • 思路分析: 当前最优不仅取决于第几轮,还取决于已经换了几次、当前手势是什么,因此状态应包含 (轮次, 切换次数, 当前手势)
  • CPP 实现要点: dp[i][k][g] 表示前 i 轮、用了 k 次切换、当前手势 g 的最多胜场;转移分「不换」和「从其他手势换来」。
  • 易错点: 最终答案是使用 0..K 次切换的最大值,不是恰好 K 次;初始选择手势不算切换。
  • 拓展思考: 手势种类增加时,朴素转移会出现 M^2,可以维护最大/次大优化。

代表题 D:摘浆果(题目 20)

  • 对应章节: 第 4.1 章(贪心)、第 3.9 章(二分/枚举答案思想)
  • 题干解读: 需要在篮子容量和分配之间取最大收益,表面像 DP,但关键是枚举篮子大小后贪心填充。
  • 思路分析: 固定 Elsie 篮子的大小后,剩下分配可以排序贪心;难点是把「容量」作为枚举对象。
  • CPP 实现要点: 枚举每个可能篮子大小,统计能装满多少篮子,再考虑剩余浆果对 Bessie 的贡献。
  • 易错点: 只考虑装满篮子会漏掉 Bessie 的半满篮;排序方向和取前 K/2 个收益容易写错。
  • 拓展思考: 这类题体现 USACO Silver/Gold 常见技巧:不直接枚举方案,而是枚举一个关键参数,把剩余部分变成贪心。

快速参考:按技术分类的题目

技术题目编号
模拟1, 2, 3, 5
排序4, 8, 9, 10, 16
前缀和6, 17
二分查找4, 8, 9, 10, 12
BFS / DFS13, 14
并查集11, 12
动态规划7, 15, 18, 20
贪心16, 20
字符串 / Ad hoc19

练习建议

  1. train.usaco.org 上使用 USACO 训练门户自动评测
  2. 每道题后都读题解(在 usaco.org 上)——哪怕是你解出来的题
  3. 保持题目日志——写下每道题的关键洞察
  4. 难度进阶:从近年简单题做起,再做老年份的中等题

其他题目来源

来源网址最适合
USACO 题库usaco.orgUSACO 专项练习
USACO Guideusaco.guide带题目的结构化课程
Codeforcescodeforces.com大量练习、多样题目
AtCoder Beginneratcoder.jp高质量入门题
LeetCodeleetcode.com数据结构基础
CSEScses.fi/problemset经典算法题

CSES 题目集cses.fi/problemset)特别推荐——约 300 道精心策划的题目,涵盖所有 USACO Silver 主题,自动评测,免费。

附录 C:C++ 竞赛编程技巧

本附录收集了竞赛程序员每天使用的最有用的 C++ 技巧、宏、模板和代码片段,可以在竞赛中节省大量时间并让代码运行更快。


C.1 快速 I/O

对 I/O 密集型题目最重要的性能优化:

// 始终在 main() 开头加上这两行
ios_base::sync_with_stdio(false);  // 断开 C 和 C++ I/O 流的同步
cin.tie(NULL);                      // 解除 cin 和 cout 的绑定

// 为什么有效:
// sync_with_stdio(false):默认情况下 C++ 与 C I/O(printf/scanf)
//   同步以保持兼容性,关闭后 cin/cout 快得多。
// cin.tie(NULL):默认情况下 cin 在每次读取前清空 cout,
//   解除绑定消除这个不必要的清空。

性能差异很显著——这两行应该出现在每个解法中:

Fast I/O Speed Comparison

文件 I/O(USACO 传统题):

freopen("problem.in",  "r", stdin);   // 将 cin 重定向到文件(将 "problem" 替换为实际名称)
freopen("problem.out", "w", stdout);  // 将 cout 重定向到文件
// 这两行之后 cin/cout 正常使用但读写文件

C.2 常用宏和 typedef

📄 查看代码:C.2 常用宏和 typedef
// 较短的类型名
typedef long long ll;
typedef unsigned long long ull;
typedef long double ld;
typedef pair<int,int> pii;
typedef pair<ll,ll> pll;
typedef vector<int> vi;
typedef vector<ll> vll;

// 缩写操作
#define pb push_back
#define pf push_front
#define all(v) (v).begin(), (v).end()
#define rall(v) (v).rbegin(), (v).rend()
#define sz(v) ((int)(v).size())
#define fi first
#define se second

// 循环宏(谨慎使用——可能影响可读性)
#define FOR(i, a, b) for(int i = (a); i < (b); i++)
#define REP(i, n) FOR(i, 0, n)

// 最小/最大值缩写
#define chmin(a, b) a = min(a, b)
#define chmax(a, b) a = max(a, b)

// 使用示例:
// vi v; v.pb(5);        → v.push_back(5)
// sort(all(v));         → sort(v.begin(), v.end())
// cout << sz(v) << "\n";→ cout << (int)v.size() << "\n"
// FOR(i, 1, n+1) { ... }→ for(int i = 1; i < n+1; i++) { ... }

C.3 GCC 编译指令提速

📄 查看代码:C.3 GCC 编译指令提速
// 这些 pragma 在 GCC 编译器(USACO 评测机使用)上可提速 2-4 倍
#pragma GCC optimize("O3,unroll-loops")
#pragma GCC target("avx2,bmi,bmi2,popcnt")

// 放在 #include 之前
// 警告:O3 和 avx2 可能导致微妙的数值差异
//   (整数题通常没问题,浮点数要小心)

// 更安全的版本(只有 O2,无向量指令):
#pragma GCC optimize("O2")

// 带 pragma 的完整竞赛模板:
#pragma GCC optimize("O3,unroll-loops")
#pragma GCC target("avx2")
#include <bits/stdc++.h>
using namespace std;
// ... 你的代码

C.4 实用数学:GCD、LCM、模运算

📄 查看代码:C.4 实用数学:GCD、LCM、模运算
#include <bits/stdc++.h>
using namespace std;

// ─── GCD 和 LCM ──────────────────────────────────────────────────────────────

// C++17:来自 <numeric> 的 std::gcd 和 std::lcm
#include <numeric>
int g = gcd(12, 8);            // 4
int l = lcm(4, 6);             // 12

// C++14 及以前:来自 <algorithm> 的 __gcd
int g2 = __gcd(12, 8);         // 4

// 自定义 GCD(辗转相除法):
ll mygcd(ll a, ll b) { return b ? mygcd(b, a%b) : a; }
ll mylcm(ll a, ll b) { return a / mygcd(a,b) * b; }  // 先除防溢出!

// ─── 模运算 ──────────────────────────────────────────────────────────────────

const ll MOD = 1e9 + 7;  // 标准 USACO/Codeforces 模数

// 加法:(a + b) % MOD
ll addmod(ll a, ll b) { return (a + b) % MOD; }

// 减法:(a - b + MOD) % MOD  ← 取模前总要加 MOD 防负数
ll submod(ll a, ll b) { return (a - b + MOD) % MOD; }

// 乘法:(a * b) % MOD
ll mulmod(ll a, ll b) { return (a % MOD) * (b % MOD) % MOD; }

// 快速幂:a^b mod MOD,O(log b)
ll power(ll base, ll exp, ll mod = MOD) {
    ll result = 1;
    base %= mod;
    while (exp > 0) {
        if (exp & 1) result = result * base % mod;  // 奇数指数
        base = base * base % mod;                    // 平方
        exp >>= 1;                                   // 减半指数
    }
    return result;
}

// 模逆元(a^{-1} mod p,p 为质数):
ll modinv(ll a, ll mod = MOD) { return power(a, mod-2, mod); }
// 这使用费马小定理:a^{p-1} ≡ 1 (mod p),p 为质数
// 所以 a^{-1} ≡ a^{p-2} (mod p)

// 模除法:(a / b) mod p = (a * b^{-1}) mod p
ll divmod(ll a, ll b) { return mulmod(a, modinv(b)); }

// 预计算阶乘后的组合数 C(n, k) mod p
const int MAXN = 200001;
ll fact[MAXN], inv_fact[MAXN];

void precompute_factorials() {
    fact[0] = 1;
    for (int i = 1; i < MAXN; i++) fact[i] = fact[i-1] * i % MOD;
    inv_fact[MAXN-1] = modinv(fact[MAXN-1]);
    for (int i = MAXN-2; i >= 0; i--) inv_fact[i] = inv_fact[i+1] * (i+1) % MOD;
}

ll C(int n, int k) {
    if (k < 0 || k > n) return 0;
    return fact[n] * inv_fact[k] % MOD * inv_fact[n-k] % MOD;
}

C.5 实用代码片段

并查集(DSU)模板

📄 查看代码:并查集(DSU)模板
// DSU——带大小追踪的完整模板
struct DSU {
    vector<int> parent, sz;

    DSU(int n) : parent(n+1), sz(n+1, 1) {
        iota(parent.begin(), parent.end(), 0);
    }

    int find(int x) {
        if (parent[x] != x) parent[x] = find(parent[x]);  // 路径压缩
        return parent[x];
    }

    bool unite(int x, int y) {
        x = find(x); y = find(y);
        if (x == y) return false;
        if (sz[x] < sz[y]) swap(x, y);   // 按大小合并
        parent[y] = x;
        sz[x] += sz[y];
        return true;
    }

    bool connected(int x, int y) { return find(x) == find(y); }
    int size(int x) { return sz[find(x)]; }   // x 所在分量的大小
};

// 使用:
DSU dsu(n);
dsu.unite(1, 2);
cout << dsu.connected(1, 3) << "\n";   // 0(假)
cout << dsu.size(1) << "\n";           // 2

线段树(单点更新,区间查询)

📄 查看代码:线段树(单点更新,区间查询)
// 线段树——支持:
//   point_update(i, val):设置位置 i 为 val
//   query(l, r):[l, r] 的和
// 所有操作 O(log N)

struct SegTree {
    int n;
    vector<ll> tree;

    SegTree(int n) : n(n), tree(4*n, 0) {}

    void update(int node, int start, int end, int idx, ll val) {
        if (start == end) {
            tree[node] = val;
            return;
        }
        int mid = (start + end) / 2;
        if (idx <= mid) update(2*node, start, mid, idx, val);
        else            update(2*node+1, mid+1, end, idx, val);
        tree[node] = tree[2*node] + tree[2*node+1];
    }

    ll query(int node, int start, int end, int l, int r) {
        if (r < start || end < l) return 0;
        if (l <= start && end <= r) return tree[node];
        int mid = (start + end) / 2;
        return query(2*node, start, mid, l, r)
             + query(2*node+1, mid+1, end, l, r);
    }

    void update(int i, ll val) { update(1, 1, n, i, val); }
    ll query(int l, int r) { return query(1, 1, n, l, r); }
};

BFS 模板

📄 查看代码:BFS 模板
// 网格 BFS——无权网格中的最短路径
int bfs_grid(vector<string>& grid, int sr, int sc, int er, int ec) {
    int R = grid.size(), C = grid[0].size();
    vector<vector<int>> dist(R, vector<int>(C, -1));
    queue<pair<int,int>> q;
    int dr[] = {-1, 1, 0, 0};
    int dc[] = {0, 0, -1, 1};

    dist[sr][sc] = 0;
    q.push({sr, sc});

    while (!q.empty()) {
        auto [r, c] = q.front(); q.pop();
        for (int d = 0; d < 4; d++) {
            int nr = r + dr[d], nc = c + dc[d];
            if (nr >= 0 && nr < R && nc >= 0 && nc < C
                && grid[nr][nc] != '#' && dist[nr][nc] == -1) {
                dist[nr][nc] = dist[r][c] + 1;
                q.push({nr, nc});
            }
        }
    }
    return dist[er][ec];
}

二分答案模板

📄 查看代码:二分答案模板
// 二分答案——最大化满足 check(X) 为真的 X
// 前提:check 是单调的(false...false...true...true)
template<typename T, typename F>
T binary_search_ans(T lo, T hi, F check) {
    T ans = lo;
    while (lo <= hi) {
        T mid = lo + (hi - lo) / 2;
        if (check(mid)) { ans = mid; lo = mid + 1; }
        else { hi = mid - 1; }
    }
    return ans;
}

// 使用示例:找最大 D 使 canPlace(D) 为真
int result = binary_search_ans(1, maxDist, canPlace);

C.6 值得了解的内置函数

📄 查看代码:C.6 值得了解的内置函数
// ─── 整数操作 ─────────────────────────────────────────────────────────────────

__builtin_popcount(x)      // 统计 x 中置位数(int)
__builtin_popcountll(x)    // 统计 x 中置位数(long long)
__builtin_clz(x)           // 统计前导零数(int,x > 0)
__builtin_ctz(x)           // 统计尾零数(int,x > 0)

// 示例:
__builtin_popcount(0b1011) == 3    // 三个 1 位
__builtin_ctz(0b1000)      == 3    // 三个尾零
(31 - __builtin_clz(x))            // floor(log2(x))

// ─── 位运算技巧 ──────────────────────────────────────────────────────────────

// 检查 x 是否是 2 的幂:
bool isPow2 = (x > 0) && !(x & (x-1));

// 提取最低置位:
int lsb = x & (-x);

// 清除最低置位:
x = x & (x-1);

// 枚举位掩码的所有子集(用于状压 DP):
for (int sub = mask; sub > 0; sub = (sub-1) & mask) {
    // 处理 mask 的子集 sub
}

// ─── 实用 STL 函数 ────────────────────────────────────────────────────────────

// next_permutation:遍历所有排列
sort(v.begin(), v.end());    // 从有序开始
do {
    // v 是当前排列
} while (next_permutation(v.begin(), v.end()));

C.7 完整竞赛模板

📄 查看代码:C.7 完整竞赛模板
// ────────────────────────────────────────────────────────────────────────────
// 竞赛编程模板——C++17
// ────────────────────────────────────────────────────────────────────────────
#pragma GCC optimize("O2")
#include <bits/stdc++.h>
using namespace std;

// 类型别名
typedef long long ll;
typedef pair<int,int> pii;
typedef vector<int> vi;

// 便捷宏
#define pb push_back
#define all(v) (v).begin(), (v).end()
#define sz(v) ((int)(v).size())
#define fi first
#define se second

// 常量
const ll MOD = 1e9 + 7;
const ll INF = 1e18;
const int MAXN = 200005;

// 快速幂取模
ll power(ll base, ll exp, ll mod = MOD) {
    ll res = 1; base %= mod;
    for (; exp > 0; exp >>= 1) {
        if (exp & 1) res = res * base % mod;
        base = base * base % mod;
    }
    return res;
}

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

    // 文件 I/O 时取消注释:
    // freopen("problem.in", "r", stdin);
    // freopen("problem.out", "w", stdout);

    // ── 你的解法 ──

    return 0;
}

C.8 常用模式和惯用法

📄 查看代码:C.8 常用模式和惯用法
// ─── 读取 N 个整数到向量 ──────────────────────────────────────────────────────
int n; cin >> n;
vi a(n);
for (int &x : a) cin >> x;

// ─── 二维向量初始化 ──────────────────────────────────────────────────────────
int R, C;
vector<vector<int>> grid(R, vector<int>(C, 0));

// ─── 自定义条件排序 ──────────────────────────────────────────────────────────
sort(all(v), [](const auto &a, const auto &b) {
    return a.weight < b.weight;  // 按重量升序排序
});

// ─── 带下标的最小/最大值 ─────────────────────────────────────────────────────
auto maxIt = max_element(all(v));
int maxVal = *maxIt;
int maxIdx = maxIt - v.begin();

// ─── 从有序向量删除重复 ──────────────────────────────────────────────────────
sort(all(v));
v.erase(unique(all(v)), v.end());

// ─── 整数平方根(精确,无浮点问题)──────────────────────────────────────────
ll isqrt(ll n) {
    ll r = sqrtl(n);
    while (r*r > n) r--;
    while ((r+1)*(r+1) <= n) r++;
    return r;
}

// ─── 检查数是否为质数 ─────────────────────────────────────────────────────────
bool isPrime(ll n) {
    if (n < 2) return false;
    if (n == 2) return true;
    if (n % 2 == 0) return false;
    for (ll i = 3; i * i <= n; i += 2) {
        if (n % i == 0) return false;
    }
    return true;
}

// ─── 埃拉托斯特尼筛法(N 以内所有质数)───────────────────────────────────────
vector<bool> sieve(int N) {
    vector<bool> is_prime(N+1, true);
    is_prime[0] = is_prime[1] = false;
    for (int i = 2; i * i <= N; i++) {
        if (is_prime[i]) {
            for (int j = i*i; j <= N; j += i)
                is_prime[j] = false;
        }
    }
    return is_prime;
}

C.9 调试技巧

📄 查看代码:C.9 调试技巧
// 用 cerr 调试输出(评测机通常忽略标准错误)
#ifdef DEBUG
    #define dbg(x) cerr << #x << " = " << x << "\n"
    #define dbgv(v) cerr << #v << ": "; for(auto x:v) cerr << x << " "; cerr << "\n"
#else
    #define dbg(x)
    #define dbgv(v)
#endif
// 调试模式编译:g++ -DDEBUG -o sol sol.cpp  (启用调试输出)
// 正常编译:    g++ -o sol sol.cpp  (移除调试输出)

// 使用:
int x = 42;
dbg(x);         // 打印:x = 42(仅调试模式)
vi v = {1,2,3};
dbgv(v);        // 打印:v: 1 2 3(仅调试模式)

// 用消毒器检测内存错误和未定义行为:
// g++ -fsanitize=address,undefined -O1 -o sol sol.cpp
// 非常适合发现:
//   - 数组越界访问
//   - 整数溢出(带 -fsanitize=signed-integer-overflow)
//   - 使用未初始化内存
//   - 空指针解引用

树状数组(BIT)——带更新的前缀和

Binary Indexed Tree

树状数组使用最低置位技巧实现 O(log N) 的前缀和查询和更新。下标 i 负责范围 [i - lowbit(i) + 1, i],其中 lowbit(i) = i & (-i)

📄 C++ 完整代码
// 树状数组 / BIT——O(log N) 更新和前缀查询
struct BIT {
    int n;
    vector<long long> tree;
    BIT(int n) : n(n), tree(n + 1, 0) {}

    // 在位置 i 添加 val(1-indexed)
    void update(int i, long long val) {
        for (; i <= n; i += i & (-i))
            tree[i] += val;
    }

    // 前缀和 [1..i]
    long long query(int i) {
        long long sum = 0;
        for (; i > 0; i -= i & (-i))
            sum += tree[i];
        return sum;
    }

    // 区间和 [l..r]
    long long query(int l, int r) { return query(r) - query(l - 1); }
};

附录 D:竞赛常用算法模板

🏆 快速参考: 这些模板经过实战验证,可直接复制粘贴使用,专为算法竞赛场景设计。每个模板均注明了时间复杂度和典型应用场景。

在深入模板之前,先用这棵决策树,根据数据规模 N 选择合适的算法:

算法选择决策树


D.1 并查集(DSU / Union-Find)

适用场景: 动态连通性、Kruskal 最小生成树、环检测、元素分组。

复杂度: 每次操作 O(α(N))O(1)(阿克曼函数的反函数,极小的常数)。

📄 C++ 完整代码
// =============================================================
// DSU(并查集):路径压缩 + 按秩合并
// =============================================================
struct DSU {
    vector<int> parent, rank_;
    int components;  // 连通分量数

    DSU(int n) : parent(n), rank_(n, 0), components(n) {
        iota(parent.begin(), parent.end(), 0);  // parent[i] = i
    }

    // 带路径压缩的 find
    int find(int x) {
        if (parent[x] != x)
            parent[x] = find(parent[x]);  // 路径压缩
        return parent[x];
    }

    // 按秩合并:若真正合并了(两者在不同集合)则返回 true
    bool unite(int x, int y) {
        x = find(x); y = find(y);
        if (x == y) return false;  // 已连通
        if (rank_[x] < rank_[y]) swap(x, y);
        parent[y] = x;
        if (rank_[x] == rank_[y]) rank_[x]++;
        components--;
        return true;
    }

    bool connected(int x, int y) { return find(x) == find(y); }
};

// 使用示例:
int main() {
    int n = 5;
    DSU dsu(n);
    dsu.unite(0, 1);
    dsu.unite(2, 3);
    cout << dsu.connected(0, 1) << "\n";  // 1(连通)
    cout << dsu.connected(0, 2) << "\n";  // 0(不连通)
    cout << dsu.components << "\n";       // 3
    return 0;
}

D.2 线段树(单点更新,区间求和)

适用场景: 支持单点更新的区间求和/最大/最小查询。

复杂度: 建树 O(N),每次查询/更新 O(log N)

📄 C++ 完整代码
// =============================================================
// 线段树:单点更新,区间求和
// =============================================================
struct SegTree {
    int n;
    vector<long long> tree;

    SegTree(int n) : n(n), tree(4 * n, 0) {}

    void build(vector<long long>& arr, int node, int start, int end) {
        if (start == end) { tree[node] = arr[start]; return; }
        int mid = (start + end) / 2;
        build(arr, 2*node, start, mid);
        build(arr, 2*node+1, mid+1, end);
        tree[node] = tree[2*node] + tree[2*node+1];
    }
    void build(vector<long long>& arr) { build(arr, 1, 0, n-1); }

    void update(int node, int start, int end, int idx, long long val) {
        if (start == end) { tree[node] = val; return; }
        int mid = (start + end) / 2;
        if (idx <= mid) update(2*node, start, mid, idx, val);
        else update(2*node+1, mid+1, end, idx, val);
        tree[node] = tree[2*node] + tree[2*node+1];
    }
    // 将 arr[idx] 更新为 val
    void update(int idx, long long val) { update(1, 0, n-1, idx, val); }

    long long query(int node, int start, int end, int l, int r) {
        if (r < start || end < l) return 0;  // 求和的单位元
        if (l <= start && end <= r) return tree[node];
        int mid = (start + end) / 2;
        return query(2*node, start, mid, l, r)
             + query(2*node+1, mid+1, end, l, r);
    }
    // 查询 arr[l..r] 的区间和
    long long query(int l, int r) { return query(1, 0, n-1, l, r); }
};

// 使用示例:
int main() {
    vector<long long> arr = {1, 3, 5, 7, 9, 11};
    SegTree st(arr.size());
    st.build(arr);
    cout << st.query(2, 4) << "\n";   // 5+7+9 = 21
    st.update(2, 10);                 // arr[2] = 10
    cout << st.query(2, 4) << "\n";   // 10+7+9 = 26
    return 0;
}

D.3 BFS 模板

适用场景: 无权图/网格中的最短路径、层序遍历、多源距离。

复杂度: O(V + E)

📄 C++ 完整代码
// =============================================================
// BFS:无权图最短路径
// =============================================================
#include <bits/stdc++.h>
using namespace std;

// 返回 dist[],其中 dist[v] = src 到 v 的最短距离
// dist[v] = -1 表示不可达
vector<int> bfs(int src, int n, vector<vector<int>>& adj) {
    vector<int> dist(n, -1);
    queue<int> q;
    dist[src] = 0;
    q.push(src);
    while (!q.empty()) {
        int u = q.front(); q.pop();
        for (int v : adj[u]) {
            if (dist[v] == -1) {
                dist[v] = dist[u] + 1;
                q.push(v);
            }
        }
    }
    return dist;
}

// 网格 BFS(四方向)
const int dr[] = {-1, 1, 0, 0};
const int dc[] = {0, 0, -1, 1};

int gridBFS(vector<string>& grid, int sr, int sc, int er, int ec) {
    int R = grid.size(), C = grid[0].size();
    vector<vector<int>> dist(R, vector<int>(C, -1));
    queue<pair<int,int>> q;
    dist[sr][sc] = 0;
    q.push({sr, sc});
    while (!q.empty()) {
        auto [r, c] = q.front(); q.pop();
        for (int d = 0; d < 4; d++) {
            int nr = r + dr[d], nc = c + dc[d];
            if (nr >= 0 && nr < R && nc >= 0 && nc < C
                && grid[nr][nc] != '#' && dist[nr][nc] == -1) {
                dist[nr][nc] = dist[r][c] + 1;
                q.push({nr, nc});
            }
        }
    }
    return dist[er][ec];  // 不可达时返回 -1
}

D.4 DFS 模板

适用场景: 连通分量、环检测、拓扑排序、洪水填充。

复杂度: O(V + E)

📄 C++ 完整代码
// =============================================================
// DFS:迭代版与递归版模板
// =============================================================

vector<vector<int>> adj;
vector<int> color;  // 0=白(未访问),1=灰(栈中),2=黑(已完成)

// 递归 DFS + 环检测(有向图)
bool hasCycle = false;
void dfs(int u) {
    color[u] = 1;  // 标记为"进行中"
    for (int v : adj[u]) {
        if (color[v] == 0) dfs(v);
        else if (color[v] == 1) hasCycle = true;  // 后向边 → 有环!
    }
    color[u] = 2;  // 标记为"已完成"
}

// 利用 DFS 后序做拓扑排序
vector<int> topoOrder;
void dfsToposort(int u) {
    color[u] = 1;
    for (int v : adj[u]) {
        if (color[v] == 0) dfsToposort(v);
    }
    color[u] = 2;
    topoOrder.push_back(u);  // 处理完所有子节点后再加入
}
// 最后将 topoOrder 反转即得拓扑序列

// 迭代 DFS(避免大图递归时栈溢出)
void dfsIterative(int src, int n) {
    vector<bool> visited(n, false);
    stack<int> st;
    st.push(src);
    while (!st.empty()) {
        int u = st.top(); st.pop();
        if (visited[u]) continue;
        visited[u] = true;
        // 在此处理 u
        for (int v : adj[u]) {
            if (!visited[v]) st.push(v);
        }
    }
}

D.5 Dijkstra 算法

适用场景: 边权非负的有权图最短路径。

复杂度: O((V + E) log V)

📄 C++ 完整代码
// =============================================================
// Dijkstra 最短路 — O((V+E) log V)
// =============================================================
#include <bits/stdc++.h>
using namespace std;

typedef pair<long long, int> pli;  // {距离, 节点}
const long long INF = 1e18;

vector<long long> dijkstra(int src, int n,
                            vector<vector<pair<int,int>>>& adj) {
    // adj[u] = { {v, 边权}, ... }
    vector<long long> dist(n, INF);
    priority_queue<pli, vector<pli>, greater<pli>> pq;  // 小根堆

    dist[src] = 0;
    pq.push({0, src});

    while (!pq.empty()) {
        auto [d, u] = pq.top(); pq.pop();

        if (d > dist[u]) continue;  // ← 关键:跳过过时条目

        for (auto [v, w] : adj[u]) {
            if (dist[u] + w < dist[v]) {
                dist[v] = dist[u] + w;
                pq.push({dist[v], v});
            }
        }
    }

    return dist;  // dist[v] = src → v 的最短距离,INF 表示不可达
}

// 使用示例:
int main() {
    int n = 5;
    vector<vector<pair<int,int>>> adj(n);
    // 添加无向边 u-v,权重 w:
    auto addEdge = [&](int u, int v, int w) {
        adj[u].push_back({v, w});
        adj[v].push_back({u, w});
    };
    addEdge(0, 1, 4);
    addEdge(0, 2, 1);
    addEdge(2, 1, 2);
    addEdge(1, 3, 1);
    addEdge(2, 3, 5);

    auto dist = dijkstra(0, n, adj);
    cout << dist[3] << "\n";  // 4(路径 0→2→1→3,代价 1+2+1=4)
    return 0;
}

D.6 二分查找模板

适用场景: 在有序数组中搜索,或"对答案二分"(参数搜索)。

复杂度: 每次搜索 O(log N),对答案二分 O(f(N) × log V)

📄 C++ 完整代码
// =============================================================
// 二分查找模板
// =============================================================

// 1. 查找精确值(返回下标或 -1)
int binarySearch(vector<int>& arr, int target) {
    int lo = 0, hi = (int)arr.size() - 1;
    while (lo <= hi) {
        int mid = lo + (hi - lo) / 2;
        if (arr[mid] == target) return mid;
        else if (arr[mid] < target) lo = mid + 1;
        else hi = mid - 1;
    }
    return -1;
}

// 2. 第一个 arr[i] >= target 的下标(lower_bound)
int lowerBound(vector<int>& arr, int target) {
    int lo = 0, hi = (int)arr.size();
    while (lo < hi) {
        int mid = lo + (hi - lo) / 2;
        if (arr[mid] < target) lo = mid + 1;
        else hi = mid;
    }
    return lo;  // 若所有元素 < target 则返回 arr.size()
}

// 3. 第一个 arr[i] > target 的下标(upper_bound)
int upperBound(vector<int>& arr, int target) {
    int lo = 0, hi = (int)arr.size();
    while (lo < hi) {
        int mid = lo + (hi - lo) / 2;
        if (arr[mid] <= target) lo = mid + 1;
        else hi = mid;
    }
    return lo;
}

// 4. 对答案二分——找到最大的 X 使得 check(X) 为 true
// 模板:根据题目调整 lo、hi 和 check() 函数
long long bsOnAnswer(long long lo, long long hi,
                     function<bool(long long)> check) {
    long long answer = lo - 1;  // 哨兵:无合法答案
    while (lo <= hi) {
        long long mid = lo + (hi - lo) / 2;
        if (check(mid)) {
            answer = mid;
            lo = mid + 1;  // 尝试更大的答案
        } else {
            hi = mid - 1;
        }
    }
    return answer;
}

// STL 封装(实际使用中优先采用):
// lower_bound(v.begin(), v.end(), x) → 指向第一个 >= x 的迭代器
// upper_bound(v.begin(), v.end(), x) → 指向第一个 >  x 的迭代器
// binary_search(v.begin(), v.end(), x) → bool,判断 x 是否存在

lower_bound / upper_bound 速查表:

目标代码
第一个 ≥ x 的下标lower_bound(v.begin(), v.end(), x) - v.begin()
第一个 > x 的下标upper_bound(v.begin(), v.end(), x) - v.begin()
x 的出现次数upper_bound(..., x) - lower_bound(..., x)
最大的 ≤ x 的值prev(upper_bound(..., x)) (需确认存在)
最小的 ≥ x 的值*lower_bound(..., x) (需确认 < end)

D.7 模运算模板

适用场景: 大数运算、组合数学、大值 DP。

复杂度: 基本运算 O(1),快速幂 O(log exp)

📄 C++ 完整代码
// =============================================================
// 模运算模板
// =============================================================
const long long MOD = 1e9 + 7;  // 或 998244353(NTT 友好素数)

long long mod(long long x) { return ((x % MOD) + MOD) % MOD; }
long long add(long long a, long long b) { return (a + b) % MOD; }
long long sub(long long a, long long b) { return mod(a - b); }
long long mul(long long a, long long b) { return a % MOD * (b % MOD) % MOD; }

// 快速幂:base^exp mod MOD — O(log exp)
long long power(long long base, long long exp, long long mod = MOD) {
    long long result = 1;
    base %= mod;
    while (exp > 0) {
        if (exp & 1) result = result * base % mod;  // 当前位为 1
        base = base * base % mod;                    // 底数平方
        exp >>= 1;                                   // 右移
    }
    return result;
}

// 模逆元(base^(MOD-2) mod MOD,仅当 MOD 为素数时成立)
long long inv(long long x) { return power(x, MOD - 2); }

// 模意义下的除法
long long divide(long long a, long long b) { return mul(a, inv(b)); }

// 预处理阶乘,用于组合数
const int MAXN = 200005;
long long fact[MAXN], inv_fact[MAXN];

void precompute_factorials() {
    fact[0] = 1;
    for (int i = 1; i < MAXN; i++) fact[i] = fact[i-1] * i % MOD;
    inv_fact[MAXN-1] = inv(fact[MAXN-1]);
    for (int i = MAXN-2; i >= 0; i--) inv_fact[i] = inv_fact[i+1] * (i+1) % MOD;
}

// C(n, k) = 组合数 mod MOD
long long C(int n, int k) {
    if (k < 0 || k > n) return 0;
    return fact[n] * inv_fact[k] % MOD * inv_fact[n-k] % MOD;
}

D.8 快速幂(二进制取幂)

适用场景: 计算大指数 a^b(独立版或模运算版)。

复杂度: O(log b)

📄 C++ 完整代码
// =============================================================
// 二进制取幂:O(log b) 计算 a^b
// =============================================================

// 整数幂(无取模)——大 a、b 时注意溢出
long long fastPow(long long a, long long b) {
    long long result = 1;
    while (b > 0) {
        if (b & 1) result *= a;  // 当前位为 1
        a *= a;                   // 底数平方
        b >>= 1;                  // 下一位
    }
    return result;
}

// 模意义快速幂:a^b mod m
long long modPow(long long a, long long b, long long m) {
    long long result = 1;
    a %= m;
    while (b > 0) {
        if (b & 1) result = result * a % m;
        a = a * a % m;
        b >>= 1;
    }
    return result;
}

// 矩阵快速幂:用于 O(log N) 计算 Fibonacci 等
typedef vector<vector<long long>> Matrix;
// 注意:使用 D.7 中定义的 MOD(const long long MOD = 1e9 + 7)

Matrix multiply(const Matrix& A, const Matrix& B) {
    int n = A.size();
    Matrix C(n, vector<long long>(n, 0));
    for (int i = 0; i < n; i++)
        for (int k = 0; k < n; k++)
            if (A[i][k])
                for (int j = 0; j < n; j++)
                    C[i][j] = (C[i][j] + A[i][k] * B[k][j]) % MOD;
    return C;
}

Matrix matPow(Matrix M, long long b) {
    int n = M.size();
    Matrix result(n, vector<long long>(n, 0));
    for (int i = 0; i < n; i++) result[i][i] = 1;  // 单位矩阵
    while (b > 0) {
        if (b & 1) result = multiply(result, M);
        M = multiply(M, M);
        b >>= 1;
    }
    return result;
}

// 示例:O(log N) 计算 Fibonacci(N)
// [F(n+1)]   [1 1]^n   [F(1)]
// [F(n)  ] = [1 0]   * [F(0)]
long long fibonacci(long long n) {
    if (n <= 1) return n;
    Matrix M = {{1, 1}, {1, 0}};
    Matrix result = matPow(M, n - 1);
    return result[0][0];  // F(n)
}

D.9 其他实用模板

前缀和(一维与二维)

📄 查看代码:前缀和(一维与二维)
// 一维前缀和
vector<long long> prefSum(n + 1, 0);
for (int i = 1; i <= n; i++) prefSum[i] = prefSum[i-1] + arr[i];
// 查询 arr[l..r] 的区间和(1-indexed):prefSum[r] - prefSum[l-1]

// 二维前缀和
long long psum[N+1][M+1] = {};
for (int i = 1; i <= N; i++)
    for (int j = 1; j <= M; j++)
        psum[i][j] = grid[i][j] + psum[i-1][j] + psum[i][j-1] - psum[i-1][j-1];
// 查询矩形 [r1,c1]..[r2,c2] 的区间和:
// psum[r2][c2] - psum[r1-1][c2] - psum[r2][c1-1] + psum[r1-1][c1-1]

竞赛编程头文件模板

📄 查看代码:竞赛编程头文件模板
// 竞赛编程标准头文件模板
#include <bits/stdc++.h>
using namespace std;

typedef long long ll;
typedef pair<int,int> pii;
typedef vector<int> vi;
typedef vector<ll> vll;

#define all(x) x.begin(), x.end()
#define sz(x) (int)(x).size()
#define pb push_back
#define mp make_pair

const int INF = 1e9;
const ll LINF = 1e18;
const int MOD = 1e9 + 7;

int main() {
    ios_base::sync_with_stdio(false);
    cin.tie(NULL);
    // 在此填写你的解答
    return 0;
}

速查卡片

算法复杂度需要包含的头文件
并查集(DSU)O(α(N)) 每次操作
线段树O(N) 建树,O(log N) 每次操作
BFSO(V+E)<queue>
DFSO(V+E)<stack>
DijkstraO((V+E) log V)<queue>
二分查找O(log N)<algorithm>
排序O(N log N)<algorithm>
模意义快速幂O(log exp)
lower/upper_boundO(log N)<algorithm>

所有示例均在 C++17(-std=c++17 -O2)下编译通过并经过验证。

📎 附录 E ⏱️ 约 50 分钟 🎯 参考资料 数学

附录 E:竞赛编程数学基础

💡 关于本附录: 算法竞赛中经常需要用到基础算术之外的数学工具。本附录涵盖 USACO Bronze、Silver、Gold 中常见的核心数学知识,并为每个主题提供可直接用于竞赛的代码模板。


E.1 模运算

为什么需要模运算?

很多题目要求输出"答案对 10⁹ + 7 取模"的结果。这并非随意为之——它是为了在答案极其庞大时防止整数溢出

举个例子:"N 个元素的排列有多少种?"答案是 N!。当 N = 20 时,N! = 2,432,902,008,176,640,000,已超过 long long 的最大值(约 9.2 × 10¹⁸)。当 N = 100 时,根本无法表示。

解决方案: 对一个素数 M(通常取 10⁹ + 7)取模,将所有运算在模意义下进行。

(a + b) mod M = ((a mod M) + (b mod M)) mod M (a × b) mod M = ((a mod M) × (b mod M)) mod M (a - b) mod M = ((a mod M) - (b mod M) + M) mod M ← 注意加上 M!

时钟类比与关键性质——记住每次算术运算后都要取模:

模运算性质

常用模数

常量数值为何选这个值?
1e9 + 71,000,000,007素数,适合 int(< 2³¹),使用最广泛
1e9 + 91,000,000,009素数,1e9+7 的备选
998244353998,244,353NTT 友好素数(用于多项式运算)

基础模运算模板

📄 查看代码:基础模运算模板
// 解答:模运算基础
#include <bits/stdc++.h>
using namespace std;

typedef long long ll;
const ll MOD = 1e9 + 7;  // 竞赛编程标准模数

// 安全加法:(a + b) % MOD
ll addMod(ll a, ll b) {
    return (a % MOD + b % MOD) % MOD;
}

// 安全减法:(a - b + MOD) % MOD(处理负数结果)
ll subMod(ll a, ll b) {
    return ((a % MOD) - (b % MOD) + MOD) % MOD;  // 加 MOD 防止负数!
}

// 安全乘法:(a * b) % MOD
// 关键:a 和 b 最大为 MOD-1 ≈ 10^9,乘积 ≈ 10^18 在 long long 范围内
ll mulMod(ll a, ll b) {
    return (a % MOD) * (b % MOD) % MOD;
}

// 示例:计算前 N 个正整数之和 mod MOD
ll sumFirstN(ll n) {
    // 公式 n*(n+1)/2,但除法需要用模逆元
    // 暂时用逐步累加:
    ll result = 0;
    for (ll i = 1; i <= n; i++) {
        result = addMod(result, i);
    }
    return result;
}

⚠️ 致命 Bug: 在 C++ 中,若 a < b(a - b) % MOD 可能为负数!请始终写成 (a - b + MOD) % MOD

E.1.1 快速幂(二进制取幂)

朴素方式计算 a^n mod M 需要 O(N) 次乘法。快速幂(反复平方法)只需 O(log N) 次。

核心思路:a^n = a^(n/2) × a^(n/2)          若 n 为偶数
              a^n = a × a^((n-1)/2) × a^((n-1)/2)  若 n 为奇数

示例:a^13 = a^(1101₂)
           = a^8 × a^4 × a^1
           = 3 次乘法,而非 12 次!
📄 C++ 完整代码
// 解答:模意义快速幂 — O(log n)
// 计算 (base^exp) % mod
ll power(ll base, ll exp, ll mod = MOD) {
    ll result = 1;
    base %= mod;                  // 先对底数取模
    
    while (exp > 0) {
        if (exp & 1) {            // 若当前位为 1
            result = result * base % mod;
        }
        base = base * base % mod; // 底数平方
        exp >>= 1;                // 移动到下一位
    }
    return result;
}

// 使用示例:
// power(2, 10) = 1024 % MOD = 1024
// power(2, 100, MOD) = 2^100 mod (10^9+7)

E.1.2 模逆元(费马小定理)

a 对模 M模逆元是一个数 a⁻¹,满足 a × a⁻¹ ≡ 1 (mod M)

这让我们可以进行模意义下的除法a / b mod M = a × b⁻¹ mod M

费马小定理: 若 M 为素数且 gcd(a, M) = 1,则:

a^(M-1) ≡ 1 (mod M) ⟹ a^(M-2) ≡ a⁻¹ (mod M)
📄
// 解答:利用费马小定理求模逆元
// 仅在 MOD 为素数且 gcd(a, MOD) = 1 时适用
ll modInverse(ll a, ll mod = MOD) {
    return power(a, mod - 2, mod);
}

// 模意义下的除法:
ll divMod(ll a, ll b) {
    return mulMod(a, modInverse(b));
}

// 示例:(n! / k!) mod MOD
// = n! × (k!)^(-1) mod MOD
// = n! × modInverse(k!) mod MOD

E.1.3 预处理阶乘与逆元

对于需要多次计算组合数 C(n, k) 的题目:

📄 对于需要多次计算组合数 `C(n, k)` 的题目:
// 解答:预处理阶乘,O(1) 查询组合数
const int MAXN = 1000005;
ll fact[MAXN], inv_fact[MAXN];

void precompute() {
    fact[0] = 1;
    for (int i = 1; i < MAXN; i++) {
        fact[i] = fact[i-1] * i % MOD;
    }
    inv_fact[MAXN-1] = modInverse(fact[MAXN-1]);
    for (int i = MAXN-2; i >= 0; i--) {
        inv_fact[i] = inv_fact[i+1] * (i+1) % MOD;
    }
}

// C(n, k) = n! / (k! * (n-k)!)
ll C(int n, int k) {
    if (k < 0 || k > n) return 0;
    return fact[n] * inv_fact[k] % MOD * inv_fact[n-k] % MOD;
}

// 用法:调用一次 precompute(),之后 C(n, k) 均为 O(1)

E.2 GCD 与 LCM

欧几里得算法

两个数的最大公因数(GCD)是能同时整除两者的最大整数。

欧几里得算法: 基于 gcd(a, b) = gcd(b, a % b)

每次递归调用都会缩小问题规模,逐步推导的过程一目了然:

GCD 欧几里得算法

📄 ![GCD 欧几里得算法](../images/gcd_euclidean.svg)
// 解答:GCD — O(log(min(a,b)))
int gcd(int a, int b) {
    while (b != 0) {
        a %= b;
        swap(a, b);
    }
    return a;
}
// 递归写法:
// int gcd(int a, int b) { return b == 0 ? a : gcd(b, a % b); }

// C++17:<numeric> 中的 std::gcd
// int g = gcd(a, b);           // std::gcd,C++17(推荐)
// int g = __gcd(a, b);         // 旧版 GCC 内建函数,仍可用

追踪示例: gcd(48, 18)

gcd(48, 18) → gcd(18, 48%18=12) → gcd(12, 18%12=6) → gcd(6, 0) = 6

LCM 与溢出陷阱

📄 查看代码:LCM 与溢出陷阱
// 解答:LCM — 注意溢出!

// 错误写法:大数相乘时溢出
long long lcmWrong(long long a, long long b) {
    return a * b / gcd(a, b);  // a*b 即使是 long long 也可能溢出!
}

// 正确写法:先除后乘
long long lcm(long long a, long long b) {
    return a / gcd(a, b) * b;  // 先除再乘
}
// a / gcd(a,b) 一定是整数,不损失精度
// 然后乘以 b:最大约 10^18,在 long long 范围内
lcm(a, b) = a × b / gcd(a, b) = (a / gcd(a, b)) × b

⚠️ 始终先除后乘,避免溢出!

扩展欧几里得算法

找整数 x, y 满足 ax + by = gcd(a, b)——在 MOD 不是素数时计算模逆元非常有用:

📄 找整数 x, y 满足 `ax + by = gcd(a, b)`——在 MOD 不是素数时计算模逆元非常有用:
// 解答:扩展欧几里得算法 — O(log(min(a,b)))
// 返回 gcd(a,b),并设置 x,y 使得 a*x + b*y = gcd(a,b)
long long extgcd(long long a, long long b, long long &x, long long &y) {
    if (b == 0) { x = 1; y = 0; return a; }
    long long x1, y1;
    long long g = extgcd(b, a % b, x1, y1);
    x = y1;
    y = x1 - (a / b) * y1;
    return g;
}

// 使用 extgcd 求模逆元(即使 MOD 不是素数也适用):
long long modInverseExtGcd(long long a, long long mod) {
    long long x, y;
    long long g = extgcd(a, mod, x, y);
    if (g != 1) return -1;  // 无逆元(gcd != 1)
    return (x % mod + mod) % mod;
}

E.3 质数与筛法

试除法

📄 查看代码:试除法
// 解答:试除法判素 — O(sqrt(N))
bool isPrime(long long n) {
    if (n < 2) return false;
    if (n == 2) return true;
    if (n % 2 == 0) return false;
    for (long long i = 3; i * i <= n; i += 2) {
        if (n % i == 0) return false;
    }
    return true;
}
// 高效原因:若 n 有大于 sqrt(n) 的因子,必然也有小于等于 sqrt(n) 的因子
// 只检查 2 之后的奇数(迭代次数减半)

埃拉托色尼筛(埃筛)

高效找出 N 以内的所有质数:

📄 高效找出 N 以内的所有质数:
// 解答:埃筛 — O(N log log N) 时间,O(N) 空间
// 运行后,isPrime[i] = true 当且仅当 i 是质数
const int MAXN = 1000005;
bool isPrime[MAXN];

void sieve(int n) {
    fill(isPrime, isPrime + n + 1, true);  // 初始假设全是质数
    isPrime[0] = isPrime[1] = false;        // 0 和 1 不是质数
    
    for (int i = 2; (long long)i * i <= n; i++) {
        if (isPrime[i]) {
            // 标记 i 的所有倍数为合数
            for (int j = i * i; j <= n; j += i) {
                isPrime[j] = false;
                // 从 i*i 开始(更小的倍数已被更小的素数标记过)
            }
        }
    }
}

// 统计 N 以内的质数个数:
void countPrimes(int n) {
    sieve(n);
    int count = 0;
    for (int i = 2; i <= n; i++) {
        if (isPrime[i]) count++;
    }
    cout << count << "\n";
}

为什么内层循环从 i² 开始? i 的所有小于 i² 的倍数(如 2i, 3i, ..., (i-1)i)已被更小的质数(2, 3, ..., i-1)标记过。从 i² 开始可以避免冗余操作。

线性筛(欧拉筛)— O(N)

欧拉筛确保每个合数只被标记一次:

📄 欧拉筛确保每个合数只被标记一次:
// 解答:线性筛(欧拉筛)— O(N) 时间
// 同时计算每个数的最小质因子(SPF)
const int MAXN = 1000005;
int spf[MAXN];      // 最小质因子
vector<int> primes;

void linearSieve(int n) {
    fill(spf, spf + n + 1, 0);
    for (int i = 2; i <= n; i++) {
        if (spf[i] == 0) {          // i 是质数
            spf[i] = i;
            primes.push_back(i);
        }
        for (int j = 0; j < (int)primes.size() && primes[j] <= spf[i] && (long long)i * primes[j] <= n; j++) {
            spf[i * primes[j]] = primes[j];  // 标记合数
        }
    }
}

// 利用 SPF 快速分解质因数:
// 每次分解 O(log N)
vector<int> factorize(int n) {
    vector<int> factors;
    while (n > 1) {
        factors.push_back(spf[n]);
        n /= spf[n];
    }
    return factors;
}

E.4 二进制表示与位运算

基本位操作

📄 查看代码:基本位操作
// 解答:常用位运算参考
int n = 42;   // 二进制:101010

// ── AND(&):两位都为 1 才为 1 ──
int a = 6 & 3;     // 110 & 011 = 010 = 2

// ── OR(|):至少一位为 1 则为 1 ──
int b = 6 | 3;     // 110 | 011 = 111 = 7

// ── XOR(^):恰好一位为 1 才为 1 ──
int c = 6 ^ 3;     // 110 ^ 011 = 101 = 5

// ── NOT(~):翻转所有位(补码) ──
int d = ~6;        // = -7(补码表示)

// ── 左移(<<):乘以 2^k ──
int e = 1 << 4;    // = 16 = 2^4

// ── 右移(>>):除以 2^k(算术右移) ──
int f = 32 >> 2;   // = 8 = 32/4

竞赛常用位运算技巧

📄 查看代码:竞赛常用位运算技巧
// 解答:竞赛编程位运算技巧

// ── 判断 n 是否为奇数 ──
bool isOdd(int n) { return n & 1; }  // 最低位为 1 当且仅当 n 为奇数

// ── 判断 n 是否为 2 的幂次 ──
bool isPow2(int n) { return n > 0 && (n & (n-1)) == 0; }
// 原因:2 的幂次:1=001, 2=010, 4=100。n-1 会翻转所有低位。
// 4 & 3 = 100 & 011 = 000。非 2 的幂次:6 & 5 = 110 & 101 = 100 ≠ 0。

// ── 取第 k 位(从右 0 开始计数) ──
bool getBit(int n, int k) { return (n >> k) & 1; }

// ── 将第 k 位置 1 ──
int setBit(int n, int k) { return n | (1 << k); }

// ── 将第 k 位清零 ──
int clearBit(int n, int k) { return n & ~(1 << k); }

// ── 翻转第 k 位 ──
int toggleBit(int n, int k) { return n ^ (1 << k); }

// ── lowbit:最低位的 1(树状数组中常用!) ──
int lowbit(int n) { return n & (-n); }
// 示例:lowbit(12) = lowbit(1100) = 0100 = 4

// ── 统计置位数(popcount) ──
int popcount(int n) { return __builtin_popcount(n); }   // 使用内建函数!
// long long 版本:__builtin_popcountll(n)

// ── 不用临时变量交换两数 ──
void swapXOR(int &a, int &b) {
    a ^= b;
    b ^= a;
    a ^= b;
}
// (通常直接用 std::swap——这主要是一个技巧性写法)

// ── 找最低位 1 的位置 ──
int lowestBitPos(int n) { return __builtin_ctz(n); }  // 统计尾部零个数
// __builtin_clz(n) = 统计前导零个数

子集枚举

一个强大的技巧:用位掩码枚举集合的所有子集。

📄 一个强大的技巧:用位掩码枚举集合的所有子集。
// 解答:位掩码子集枚举
// 枚举 N 元素集合的所有子集

void enumerateAllSubsets(int n) {
    // 共 2^n 个子集
    for (int mask = 0; mask < (1 << n); mask++) {
        // mask 表示一个子集:第 i 位为 1 表示包含元素 i
        cout << "子集: {";
        for (int i = 0; i < n; i++) {
            if (mask & (1 << i)) {
                cout << i << " ";
            }
        }
        cout << "}\n";
    }
}

// 枚举给定集合 S 的所有非空子集
void enumerateSubsetsOf(int S) {
    for (int sub = S; sub > 0; sub = (sub - 1) & S) {
        // 处理子集 sub
        // 技巧:(sub-1) & S 得到 S 的"下一个更小"子集
        // 摊还 O(1) 步枚举 S 的全部 2^|S| 个子集
    }
}

// 经典应用:状压 DP
// dp[mask] = 访问 mask 所表示的城市集合所需的最小代价
// dp[0] = 0(初始:没有访问任何城市)
// dp[mask | (1 << v)] = min(dp[mask | (1 << v)], dp[mask] + cost[last][v])

E.5 组合数学基础

计数公式

排列数:P(n, k) = n! / (n-k)! — 从 n 个中有序选 k 个 组合数:C(n, k) = n! / (k! × (n-k)!) — 从 n 个中无序选 k 个
📄
// 解答:带模运算的组合数学
// 假设已调用 E.1.3 中的 precompute()

// C(n, k) = n! / (k! * (n-k)!)
ll combination(int n, int k) {
    if (k < 0 || k > n) return 0;
    return fact[n] * inv_fact[k] % MOD * inv_fact[n-k] % MOD;
}

// P(n, k) = n! / (n-k)!
ll permutation(int n, int k) {
    if (k < 0 || k > n) return 0;
    return fact[n] * inv_fact[n-k] % MOD;
}

// 隔板法(星与条):将 n 个相同球放入 k 个不同盒的方案数
// = C(n + k - 1, k - 1)
ll starsAndBars(int n, int k) {
    return combination(n + k - 1, k - 1);
}

帕斯卡三角——无需预处理直接计算 C(n, k)

当 n 较小(n ≤ 2000)时,帕斯卡三角更简洁:

📄 当 n 较小(n ≤ 2000)时,帕斯卡三角更简洁:
// 解答:帕斯卡三角 DP — O(n^2) 预处理
const int MAXN = 2005;
ll C[MAXN][MAXN];

void buildPascal() {
    for (int i = 0; i < MAXN; i++) {
        C[i][0] = C[i][i] = 1;
        for (int j = 1; j < i; j++) {
            C[i][j] = (C[i-1][j-1] + C[i-1][j]) % MOD;
        }
    }
}
// 之后 C[n][k] 即为 0 <= k <= n < MAXN 时的组合数
// 完全避免了模逆元——当 MOD 可能不是素数时特别有用

帕斯卡恒等式: C(n, k) = C(n-1, k-1) + C(n-1, k)

含义:"从 n 个中选 k 个" = "包含第 n 个元素,从 n-1 个中选 k-1 个" + "不包含第 n 个元素,从 n-1 个中选 k 个"。

常用组合恒等式

📄 查看代码:常用组合恒等式
// 竞赛中常用的恒等式:

// 曲棍球恒等式:sum_{k=0}^{n} C(r+k, k) = C(n+r+1, n)
// 用途:二维前缀和、多项式求值

// 范德蒙德恒等式:sum_k C(m,k)*C(n,r-k) = C(m+n, r)
// 用途:涉及两组元素的计数问题

// 容斥原理:
// |A ∪ B| = |A| + |B| - |A ∩ B|
// |A ∪ B ∪ C| = |A| + |B| + |C| - |A∩B| - |A∩C| - |B∩C| + |A∩B∩C|
// 推广到 n 个集合时有 2^n 项(或用位掩码枚举)

E.6 常用数学结论(复杂度分析)

调和级数

1 + 1/2 + 1/3 + ... + 1/N ≈ ln(N) ≈ 0.693 × log₂(N)

这解释了为何埃筛运行时间为 O(N log log N)

以及为何树状数组操作是 O(log N):lowbit 操作每次前进 1、2、4...位。

关键估算值

表达式近似值含义
log₂(10⁵)≈ 1710⁵ 个元素的 BST/线段树深度
log₂(10⁹)≈ 30在 10⁹ 范围内的二分查找步数
√(10⁶)= 1000N ≤ 10⁶ 时试除法的上界
2²⁰≈ 10⁶状压 DP 上限(20 个元素)
20!≈ 2.4 × 10¹⁸勉强放进 long long
13!≈ 6 × 10⁹略超 int 上限

每秒操作数估算

时间限制安全操作数上限
1 秒~10⁸ 次简单操作
2 秒~2 × 10⁸
3 秒~3 × 10⁸

据此可以估算算法是否够快:


E.7 完整数学模板

以下是整合了本附录所有模板的单文件版本:

📄 以下是整合了本附录所有模板的单文件版本:
// 解答:竞赛编程完整数学模板
#include <bits/stdc++.h>
using namespace std;
typedef long long ll;
typedef unsigned long long ull;

// ═══════════════════════════════════════════════
// 模运算
// ═══════════════════════════════════════════════
const ll MOD = 1e9 + 7;

ll power(ll base, ll exp, ll mod = MOD) {
    ll result = 1;
    base %= mod;
    while (exp > 0) {
        if (exp & 1) result = result * base % mod;
        base = base * base % mod;
        exp >>= 1;
    }
    return result;
}

ll modInverse(ll a, ll mod = MOD) {
    return power(a, mod - 2, mod);
}

// ═══════════════════════════════════════════════
// 阶乘(预处理至 MAXN)
// ═══════════════════════════════════════════════
const int MAXN = 1000005;
ll fact[MAXN], inv_fact[MAXN];

void precomputeFactorials() {
    fact[0] = 1;
    for (int i = 1; i < MAXN; i++) fact[i] = fact[i-1] * i % MOD;
    inv_fact[MAXN-1] = modInverse(fact[MAXN-1]);
    for (int i = MAXN-2; i >= 0; i--) inv_fact[i] = inv_fact[i+1] * (i+1) % MOD;
}

ll C(int n, int k) {
    if (k < 0 || k > n) return 0;
    return fact[n] * inv_fact[k] % MOD * inv_fact[n-k] % MOD;
}

// ═══════════════════════════════════════════════
// GCD / LCM
// ═══════════════════════════════════════════════
ll gcd(ll a, ll b) { return b == 0 ? a : gcd(b, a % b); }
ll lcm(ll a, ll b)  { return a / gcd(a, b) * b; }

// ═══════════════════════════════════════════════
// 质数筛
// ═══════════════════════════════════════════════
const int MAXP = 1000005;
bool notPrime[MAXP];
vector<int> primes;

void sieve(int n = MAXP - 1) {
    notPrime[0] = notPrime[1] = true;
    for (int i = 2; i <= n; i++) {
        if (!notPrime[i]) {
            primes.push_back(i);
            for (long long j = (long long)i*i; j <= n; j += i)
                notPrime[j] = true;
        }
    }
}

bool isPrime(int n) { return n >= 2 && !notPrime[n]; }

// ═══════════════════════════════════════════════
// 位运算技巧
// ═══════════════════════════════════════════════
bool isOdd(int n)       { return n & 1; }
bool isPow2(int n)      { return n > 0 && !(n & (n-1)); }
int  lowbit(int n)      { return n & (-n); }
int  popcount(int n)    { return __builtin_popcount(n); }
int  ctz(int n)         { return __builtin_ctz(n); }  // 尾部零个数

// ═══════════════════════════════════════════════
// 扩展 GCD
// ═══════════════════════════════════════════════
ll extgcd(ll a, ll b, ll &x, ll &y) {
    if (!b) { x = 1; y = 0; return a; }
    ll x1, y1, g = extgcd(b, a%b, x1, y1);
    x = y1; y = x1 - a/b * y1;
    return g;
}

int main() {
    ios_base::sync_with_stdio(false);
    cin.tie(NULL);
    
    precomputeFactorials();
    sieve();
    
    // 测试:C(10, 3) = 120
    cout << C(10, 3) << "\n";
    
    // 测试:2^100 mod (10^9+7)
    cout << power(2, 100) << "\n";
    
    // 测试:输出前 10 个质数
    for (int i = 0; i < 10; i++) cout << primes[i] << " ";
    cout << "\n";
    
    return 0;
}

E.8 数论快速参考

整除规则(手工验算用)

除数规则
2末位为偶数
3各位数字之和能被 3 整除
4末两位组成的数能被 4 整除
5末位为 0 或 5
9各位数字之和能被 9 整除
10末位为 0
11各位数字的交替和能被 11 整除

整数平方根

// 安全的整数平方根(避免浮点误差)
ll isqrt(ll n) {
    ll x = sqrtl(n);              // 浮点近似值
    while (x * x > n) x--;        // 若偏大则向下修正
    while ((x+1) * (x+1) <= n) x++; // 若偏小则向上修正
    return x;
}

向上取整除法

// 正整数的向上取整除法:ceil(a/b)
ll ceilDiv(ll a, ll b) {
    return (a + b - 1) / b;
    // 等价写法:(a - 1) / b + 1(a > 0 时相同)
}

❓ 常见问题

Q1:什么时候应该用 long long

A:当数值可能超过 2 × 10⁹(大约是 int 的上限)时。典型场景:① 两个大 int 相乘(10⁹ × 10⁹ = 10¹⁸);② 累加路径权重(N 条边,每条权重最大 10⁶,合计最大 10¹¹);③ 阶乘/组合数(即使取模,中间计算也用 long long)。经验法则:竞赛代码中只要有乘法,就用 long long

Q2:为什么用 10⁹ + 7 而非 10⁹

A:10⁹ 不是素数(= 2⁹ × 5⁹),无法用费马小定理求模逆元。10⁹ + 7 = 1,000,000,007 是素数,且 (10⁹ + 7)² < 2⁶³long long 的上限),因此取模后的两数相乘不会溢出 long long

Q3:快速幂中的位运算技巧是怎么工作的?

A:将指数 n 写成二进制:n = b_k × 2^k + ... + b_1 × 2 + b_0。那么 a^n = a^(b_k × 2^k) × ... × a^(b_1 × 2) × a^b_0。每次循环将底数平方(代表 a 的 2^k 次幂),当前位为 1 时乘入结果。只需 log₂(n) 次乘法。

Q4:埃筛的内层循环为什么从 i×i 开始?

A:i 的倍数 2i, 3i, ..., (i-1)i 已被更小的质数 2, 3, ..., i-1 标记过。例如,6 = 2×3 已被 2 标记;7×5=35 已被 5 标记。从 i×i 开始可以避免冗余,优化常数因子。

Q5:为什么 n & (n-1) 能检测 n 是否为 2 的幂次?

A:2 的幂次在二进制中只有一个 1 位(如 8 = 1000)。减 1 会把最低的 1 位变为 0,并把其下所有 0 位翻转为 1(如 7 = 0111)。因此 n & (n-1) 清除了最低 1 位。若 n 是 2 的幂次(只有一个 1 位),结果为 0;否则不为 0。


附录 E 结束 — 另请参阅:算法模板库 | 竞赛编程技巧

📖 附录 G:数学算法基础

⏱ 预计阅读时间:50 分钟 | 难度:🟡 中等


前置条件

在学习本附录之前,请确保你已掌握:


🎯 学习目标

学完本附录后,你将能够:

  1. 用埃氏筛和欧拉线性筛高效枚举质数
  2. 用快速幂在 O(log N) 内计算大幂次取模
  3. 用扩展欧几里得求逆元
  4. 理解区间 DP 的核心框架并解决石子合并类问题
  5. 运用矩阵快速幂加速线性递推

G.1 质数筛法

为什么需要筛法?

判断一个数是否是质数,朴素做法是枚举 [2, √N],时间 O(√N)。
但若要一次性求出 [2, N] 内所有质数,朴素做法是 O(N√N),当 N = 10^7 时太慢。

筛法 利用「质数的倍数是合数」这一事实,批量标记合数。


G.1.1 埃拉托斯特尼筛(埃氏筛)

核心思想:
从 2 开始,每发现一个质数,就把它的所有倍数标记为合数。

📄 从 2 开始,每发现一个质数,就把它的所有倍数标记为合数。
筛选 N = 20 的过程:

初始:2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20

标记 2 的倍数(从 2*2=4 开始):
  删除 4, 6, 8, 10, 12, 14, 16, 18, 20

标记 3 的倍数(从 3*3=9 开始):
  删除 9, 15  (12, 18 已经被 2 删除)

4 已被删除,跳过

标记 5 的倍数(从 5*5=25 > 20,停止)

最终质数:2, 3, 5, 7, 11, 13, 17, 19

从 ii 而不是 2i 开始的原因:
2×i, 3×i, ..., (i-1)×i 这些倍数,已经被更小的质数筛过了(因为它们有更小的质因子)。

📄 2×i, 3×i, ..., (i-1)×i 这些倍数,已经被更小的质数筛过了(因为它们有更小的质因子)。
#include <bits/stdc++.h>
using namespace std;

const int MAXN = 1e7 + 5;
bool is_prime[MAXN];   // is_prime[i] = true 表示 i 是质数
vector<int> primes;    // 所有质数列表

// 埃氏筛:筛出 [2, n] 内的所有质数
// 时间复杂度:O(N log log N)
void sieve_eratosthenes(int n) {
    fill(is_prime + 2, is_prime + n + 1, true);  // 初始全是质数
    
    for (int i = 2; (long long)i * i <= n; i++) {
        if (is_prime[i]) {
            // 从 i*i 开始标记(更小的倍数已经被筛过)
            for (int j = i * i; j <= n; j += i)
                is_prime[j] = false;
        }
    }
    
    // 收集质数
    for (int i = 2; i <= n; i++)
        if (is_prime[i]) primes.push_back(i);
}

int main() {
    sieve_eratosthenes(100);
    
    cout << "100 以内的质数:";
    for (int p : primes) cout << p << " ";
    cout << endl;
    cout << "质数个数:" << primes.size() << endl;
    // 输出:2 3 5 7 11 13 17 19 23 29 31 37 41 43 47 53 59 61 67 71 73 79 83 89 97
    // 个数:25
    return 0;
}

G.1.2 欧拉线性筛(最优筛法)

埃氏筛的问题:一个合数可能被多个质数重复标记(如 12 被 2 和 3 各标记一次)。

欧拉线性筛 保证每个合数只被其最小质因子筛一次,时间复杂度严格 O(N)。

核心规则: 对于每个 i,只用「i 乘以不超过 i 的最小质因子的那些质数」来筛合数。

📄 C++ 完整代码
const int MAXN = 1e7 + 5;
int min_prime[MAXN];   // min_prime[i] = i 的最小质因子
vector<int> primes;

// 欧拉线性筛:O(N)
void sieve_linear(int n) {
    fill(min_prime, min_prime + n + 1, 0);  // 0 表示还没被筛
    
    for (int i = 2; i <= n; i++) {
        if (!min_prime[i]) {
            // i 没有被任何更小的数筛过 → i 是质数
            min_prime[i] = i;   // 质数的最小质因子是自身
            primes.push_back(i);
        }
        
        // 用 i 去筛 i 的倍数
        for (int p : primes) {
            if ((long long)i * p > n) break;
            min_prime[i * p] = p;   // p 是 i*p 的最小质因子
            if (i % p == 0) {
                // p 是 i 的最小质因子
                // 若继续用更大的质数 q 筛 i*q,
                // i*q 的最小质因子仍是 p(因为 p | i | i*q)
                // 所以 i*q 会被 (i/p)*p*q 中较小的部分筛到
                // 此处终止,保证每个合数只被筛一次
                break;
            }
        }
    }
}

int main() {
    sieve_linear(30);
    
    cout << "30 以内的质数:";
    for (int p : primes) cout << p << " ";
    cout << endl;
    
    // 利用最小质因子做质因数分解
    auto factorize = [&](int x) {
        cout << x << " = ";
        while (x > 1) {
            int p = min_prime[x];
            int cnt = 0;
            while (x % p == 0) { x /= p; cnt++; }
            cout << p << "^" << cnt << " ";
        }
        cout << endl;
    };
    factorize(360);  // 360 = 2^3 3^2 5^1
    return 0;
}

线性筛的额外能力: 同时计算积性函数(欧拉函数 φ、莫比乌斯函数 μ、约数个数等),这在数论题中非常有用。


G.1.3 筛法对比

方法时间复杂度空间优势
朴素判质数O(√N) 每个O(1)单个数判断
埃氏筛O(N log log N)O(N)代码简单,实际快
欧拉线性筛O(N)O(N)同时记录最小质因子
Bitset 埃氏筛O(N log log N / 64)O(N/8)超大 N(≥ 10^8)时更快

G.2 快速幂

问题

计算 $a^n \mod p$,其中 n 可能高达 $10^{18}$。

朴素方法(循环乘 n 次)需要 O(N) 次运算,根本不可行。

核心思想:二进制分解指数

将 n 写成二进制形式,利用递推:

$$a^n = \begin{cases} (a^{n/2})^2 & n \text{ 为偶数} \ (a^{(n-1)/2})^2 \times a & n \text{ 为奇数} \end{cases}$$

示例: 计算 $3^{13}$

📄 Code 完整代码
13 = 1101₂ = 8 + 4 + 1

3^13 = 3^8 × 3^4 × 3^1

计算过程(从低位到高位):
  base = 3, exp = 13, result = 1

  exp & 1 = 1 → result = 1 × 3 = 3
  base = 3 × 3 = 9,exp = 6

  exp & 1 = 0 → result 不变(= 3)
  base = 9 × 9 = 81,exp = 3

  exp & 1 = 1 → result = 3 × 81 = 243
  base = 81 × 81 = 6561,exp = 1

  exp & 1 = 1 → result = 243 × 6561 = 1594323
  base = ..., exp = 0(结束)

验证:3^13 = 1594323 ✓

完整实现

📄 查看代码:完整实现
// 快速幂:计算 base^exp % mod
// 时间复杂度:O(log exp)
// 空间复杂度:O(1)
long long fast_pow(long long base, long long exp, long long mod) {
    long long result = 1;
    base %= mod;          // 先对底数取模
    
    while (exp > 0) {
        // 若当前位(最低位)为 1,乘入结果
        if (exp & 1)
            result = result * base % mod;
        
        // 底数平方,指数右移一位
        base = base * base % mod;
        exp >>= 1;
    }
    return result;
}

// 使用示例
int main() {
    long long MOD = 1e9 + 7;
    
    cout << fast_pow(2, 10, MOD)  << endl;  // 1024
    cout << fast_pow(3, 100, MOD) << endl;  // 981350898
    cout << fast_pow(2, 1e18, MOD) << endl; // 通过快速幂计算,不会超时
    
    return 0;
}

模逆元(求 a 关于 mod 的逆元)

当 mod 是质数时,由费马小定理 $a^{p-1} \equiv 1 \pmod{p}$,得 $a^{-1} \equiv a^{p-2} \pmod{p}$。

📄 C++ 完整代码
// 求 a 关于质数 mod 的模逆元
long long mod_inv(long long a, long long mod) {
    return fast_pow(a, mod - 2, mod);
}

// 应用:计算组合数 C(n, k) mod p
long long C(int n, int k, long long mod) {
    if (k > n || k < 0) return 0;
    
    // 预处理阶乘和逆阶乘
    vector<long long> fact(n + 1), inv_fact(n + 1);
    fact[0] = 1;
    for (int i = 1; i <= n; i++) fact[i] = fact[i-1] * i % mod;
    inv_fact[n] = mod_inv(fact[n], mod);
    for (int i = n - 1; i >= 0; i--) inv_fact[i] = inv_fact[i+1] * (i+1) % mod;
    
    return fact[n] % mod * inv_fact[k] % mod * inv_fact[n-k] % mod;
}

G.3 区间动态规划(区间 DP)

什么是区间 DP?

区间 DP 是一类特殊的 DP,状态定义在区间上

$$dp[i][j] = \text{将区间 } [i, j] \text{ 处理后的最优值}$$

关键特征:

状态转移框架

dp[i][j] = min/max over all k in [i, j-1]:
              dp[i][k] + dp[k+1][j] + cost(i, j)

遍历顺序:按区间长度

📄 查看代码:遍历顺序:按区间长度
// 区间 DP 标准模板
int n;
int dp[305][305];  // dp[i][j] = 区间 [i,j] 的最优值

// 初始化:单个元素
for (int i = 1; i <= n; i++)
    dp[i][i] = 基础情况;

// 按区间长度从小到大
for (int len = 2; len <= n; len++) {          // 区间长度
    for (int i = 1; i + len - 1 <= n; i++) {  // 左端点
        int j = i + len - 1;                   // 右端点
        dp[i][j] = INF;  // 初始为极值
        
        // 枚举分割点 k
        for (int k = i; k < j; k++) {
            dp[i][j] = min(dp[i][j], dp[i][k] + dp[k+1][j] + cost(i, j));
        }
    }
}

// 答案
return dp[1][n];

G.3.1 经典例题:石子合并

N 堆石子排成一列,每次合并相邻两堆,代价为合并后石子总数,求合并所有石子的最小(或最大)代价。

状态定义: dp[i][j] = 合并 [i, j] 堆石子的最优代价
转移: 枚举最后一次合并的分割点 k,即 [i,k] 先合并完,[k+1,j] 先合并完,再把这两堆合并

$$dp[i][j] = \min_{i \le k < j} {dp[i][k] + dp[k+1][j] + sum[j] - sum[i-1]}$$

其中 sum[j] - sum[i-1] 是区间 [i,j] 的石子总数(也是最后合并这步的代价)。

📄 其中 `sum[j] - sum[i-1]` 是区间 [i,j] 的石子总数(也是最后合并这步的代价)。
#include <bits/stdc++.h>
using namespace std;

int n;
int a[305];
int sum[305];      // 前缀和
int dp[305][305];  // dp[i][j] = 合并 [i,j] 的最小代价

int main() {
    cin >> n;
    for (int i = 1; i <= n; i++) {
        cin >> a[i];
        sum[i] = sum[i-1] + a[i];
    }
    
    // 单堆:代价 = 0(已经"合并完了")
    // dp[i][i] = 0(默认初始化)
    
    // 区间 DP
    for (int len = 2; len <= n; len++) {
        for (int i = 1; i + len - 1 <= n; i++) {
            int j = i + len - 1;
            dp[i][j] = INT_MAX;
            
            for (int k = i; k < j; k++) {
                int cost = sum[j] - sum[i-1];          // 最后合并这步的代价
                dp[i][j] = min(dp[i][j], dp[i][k] + dp[k+1][j] + cost);
            }
        }
    }
    
    cout << dp[1][n] << endl;
    return 0;
}

追踪示例(a = [3, 5, 2, 1]):

📄 Code 完整代码
前缀和:sum = [0, 3, 8, 10, 11]

初始:dp[1][1]=0, dp[2][2]=0, dp[3][3]=0, dp[4][4]=0

len=2:
  dp[1][2] = dp[1][1] + dp[2][2] + sum[2]-sum[0] = 0+0+8 = 8
  dp[2][3] = 0+0+7 = 7
  dp[3][4] = 0+0+3 = 3

len=3:
  dp[1][3]:
    k=1: dp[1][1]+dp[2][3]+(sum[3]-sum[0]) = 0+7+10 = 17
    k=2: dp[1][2]+dp[3][3]+(sum[3]-sum[0]) = 8+0+10 = 18
    dp[1][3] = min(17,18) = 17
  dp[2][4]:
    k=2: dp[2][2]+dp[3][4]+(sum[4]-sum[1]) = 0+3+8 = 11
    k=3: dp[2][3]+dp[4][4]+(sum[4]-sum[1]) = 7+0+8 = 15
    dp[2][4] = 11

len=4:
  dp[1][4]:
    k=1: dp[1][1]+dp[2][4]+11 = 0+11+11 = 22
    k=2: dp[1][2]+dp[3][4]+11 = 8+3+11 = 22
    k=3: dp[1][3]+dp[4][4]+11 = 17+0+11 = 28
    dp[1][4] = 22

答案:22

G.3.2 变形:括号匹配

给定一个括号序列,求添加最少多少个括号使其合法。

状态定义: dp[i][j] = 使 s[i..j] 合法需要添加的最少括号数

转移:

  1. 若 s[i] 和 s[j] 匹配(一对括号):dp[i][j] = dp[i+1][j-1]
  2. 枚举分割点:dp[i][j] = min(dp[i][k] + dp[k+1][j])
  3. 单个字符:dp[i][i] = 1(必须添加一个配对括号)
📄 3. 单个字符:`dp[i][i] = 1`(必须添加一个配对括号)
#include <bits/stdc++.h>
using namespace std;

bool match(char a, char b) {
    return (a == '(' && b == ')') ||
           (a == '[' && b == ']') ||
           (a == '{' && b == '}');
}

int main() {
    string s;
    cin >> s;
    int n = s.size();
    
    vector<vector<int>> dp(n, vector<int>(n, 0));
    
    // 单个字符需要 1 个括号配对
    for (int i = 0; i < n; i++) dp[i][i] = 1;
    
    for (int len = 2; len <= n; len++) {
        for (int i = 0; i + len - 1 < n; i++) {
            int j = i + len - 1;
            dp[i][j] = INT_MAX;
            
            // 两端匹配
            if (len >= 2 && match(s[i], s[j]))
                dp[i][j] = min(dp[i][j], (len == 2 ? 0 : dp[i+1][j-1]));
            
            // 枚举分割点
            for (int k = i; k < j; k++)
                dp[i][j] = min(dp[i][j], dp[i][k] + dp[k+1][j]);
        }
    }
    
    cout << dp[0][n-1] << endl;
    return 0;
}

G.4 矩阵快速幂(加速线性递推)

适用场景

如果一个递推关系的形式是:

$$\begin{pmatrix} f(n) \ f(n-1) \end{pmatrix} = M \times \begin{pmatrix} f(n-1) \ f(n-2) \end{pmatrix}$$

则可以用矩阵快速幂,在 O(K³ log N) 内计算第 N 项(K 为状态向量大小)。

以 Fibonacci 数列为例

$$f(n) = f(n-1) + f(n-2)$$

转化为矩阵形式:

$$\begin{pmatrix} f(n) \ f(n-1) \end{pmatrix} = \begin{pmatrix} 1 & 1 \ 1 & 0 \end{pmatrix} \times \begin{pmatrix} f(n-1) \ f(n-2) \end{pmatrix}$$

因此:

$$\begin{pmatrix} f(n) \ f(n-1) \end{pmatrix} = \begin{pmatrix} 1 & 1 \ 1 & 0 \end{pmatrix}^{n-1} \times \begin{pmatrix} f(1) \ f(0) \end{pmatrix}$$

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

const ll MOD = 1e9 + 7;
const int K = 2;  // 状态维度

// 矩阵乘法
Matrix mat_mul(const Matrix& A, const Matrix& B) {
    int n = A.size();
    Matrix C(n, vector<ll>(n, 0));
    for (int i = 0; i < n; i++)
        for (int k = 0; k < n; k++)
            for (int j = 0; j < n; j++)
                C[i][j] = (C[i][j] + A[i][k] * B[k][j]) % MOD;
    return C;
}

// 矩阵快速幂:M^n
Matrix mat_pow(Matrix M, ll n) {
    int sz = M.size();
    // 初始化为单位矩阵
    Matrix result(sz, vector<ll>(sz, 0));
    for (int i = 0; i < sz; i++) result[i][i] = 1;
    
    while (n > 0) {
        if (n & 1) result = mat_mul(result, M);
        M = mat_mul(M, M);
        n >>= 1;
    }
    return result;
}

// 计算第 n 个 Fibonacci 数 mod MOD
ll fibonacci(ll n) {
    if (n <= 1) return n;
    
    // 转移矩阵
    Matrix trans = {{1, 1}, {1, 0}};
    Matrix result = mat_pow(trans, n - 1);
    
    // 初始状态 [f(1), f(0)] = [1, 0]
    // 结果 = result * [1, 0]^T 的第一个元素
    return result[0][0];  // result[0][0]*f(1) + result[0][1]*f(0) = result[0][0]
}

int main() {
    for (int i = 0; i <= 10; i++)
        cout << "f(" << i << ") = " << fibonacci(i) << endl;
    
    // 大数:f(10^18) mod (10^9+7)
    cout << "f(10^18) = " << fibonacci(1e18) << endl;
    return 0;
}

⚠️ 常见错误

错误原因修复方案
埃氏筛 i*i 溢出i 较大时 i*i 超 int 范围(long long)i*i <= ni <= n/i
快速幂底数未取模第一行漏了 base %= mod总是先 base %= mod
区间 DP 初始化错误忘记 dp[i][i] = 基础情况单元素的基础情况必须手动设置
区间 DP 遍历顺序错误没有按区间长度从小到大最外层循环必须是 len
矩阵乘法模运算位置累加时溢出再取模每次乘加后立即 % MOD

💪 练习题(共 8 道,全部含完整解答)

🟢 基础练习(1~3)

题目 1:质因数分解
利用欧拉线性筛的 min_prime 数组,在 O(log N) 时间内对任意正整数做质因数分解,输出格式为 2^3 * 3^2 * 5

✅ 完整解答
#include <bits/stdc++.h>
using namespace std;

const int MAXN = 1e6 + 5;
int min_prime[MAXN];
vector<int> primes;

void sieve(int n) {
    for (int i = 2; i <= n; i++) {
        if (!min_prime[i]) { min_prime[i] = i; primes.push_back(i); }
        for (int p : primes) {
            if ((long long)i * p > n) break;
            min_prime[i * p] = p;
            if (i % p == 0) break;
        }
    }
}

void factorize(int x) {
    cout << x << " = ";
    bool first = true;
    while (x > 1) {
        int p = min_prime[x], cnt = 0;
        while (x % p == 0) { x /= p; cnt++; }
        if (!first) cout << " * ";
        cout << p << "^" << cnt;
        first = false;
    }
    cout << "\n";
}

int main() {
    sieve(1e6);
    factorize(360);      // 2^3 * 3^2 * 5^1
    factorize(1000000);  // 2^6 * 5^6
    factorize(97);       // 97^1(质数)
    return 0;
}

关键: min_prime[x] 是 x 的最小质因子,每次除尽后继续处理 x/p^k,循环 O(log x) 次。


题目 2:组合数取模
计算 C(N, K) mod (10^9+7),N 和 K 可高达 10^6。
要求预处理阶乘和逆元,查询 O(1)。

✅ 完整解答
#include <bits/stdc++.h>
using namespace std;
typedef long long ll;

const ll MOD = 1e9 + 7;
const int MAXN = 1e6 + 5;
ll fact[MAXN], inv_fact[MAXN];

ll fast_pow(ll base, ll exp, ll mod) {
    ll result = 1; base %= mod;
    while (exp > 0) {
        if (exp & 1) result = result * base % mod;
        base = base * base % mod;
        exp >>= 1;
    }
    return result;
}

void preprocess(int n) {
    fact[0] = 1;
    for (int i = 1; i <= n; i++) fact[i] = fact[i-1] * i % MOD;
    inv_fact[n] = fast_pow(fact[n], MOD - 2, MOD);  // 费马小定理
    for (int i = n - 1; i >= 0; i--) inv_fact[i] = inv_fact[i+1] * (i+1) % MOD;
}

ll C(int n, int k) {
    if (k < 0 || k > n) return 0;
    return fact[n] % MOD * inv_fact[k] % MOD * inv_fact[n-k] % MOD;
}

int main() {
    preprocess(1e6);
    cout << C(10, 3)    << "\n";   // 120
    cout << C(1000000, 500000) << "\n";  // 大数取模
    cout << C(5, 0)     << "\n";   // 1
    return 0;
}

预处理流程:

1. 正向递推 fact[0..N]:O(N)
2. 用快速幂求 fact[N] 的逆元:O(log MOD)
3. 反向递推所有逆阶乘:inv_fact[i] = inv_fact[i+1] * (i+1),O(N)
4. 查询 C(n,k):O(1)

题目 3:石子合并(区间 DP 基础)
N 堆石子排成一排,每次合并相邻两堆,代价为合并后的总石子数。求总代价最小值。

✅ 完整解答
#include <bits/stdc++.h>
using namespace std;

int main() {
    int n; cin >> n;
    vector<int> a(n + 1);
    vector<int> sum(n + 1, 0);
    for (int i = 1; i <= n; i++) { cin >> a[i]; sum[i] = sum[i-1] + a[i]; }
    
    vector<vector<int>> dp(n + 1, vector<int>(n + 1, 0));
    
    // len = 区间长度,从 2 开始(长度 1 代价为 0)
    for (int len = 2; len <= n; len++) {
        for (int i = 1; i + len - 1 <= n; i++) {
            int j = i + len - 1;
            dp[i][j] = INT_MAX;
            for (int k = i; k < j; k++) {
                int cost = sum[j] - sum[i-1];  // 合并 [i..j] 的代价
                dp[i][j] = min(dp[i][j], dp[i][k] + dp[k+1][j] + cost);
            }
        }
    }
    
    cout << dp[1][n] << "\n";
    return 0;
}

输入示例:

4
3 5 2 1
输出:22

追踪: 见 G.3.1 节的详细分步追踪。


🟡 进阶练习(4~6)

题目 4:矩阵链乘(经典区间 DP)
给 N 个矩阵,第 i 个的维度为 p[i-1] × p[i](共 N+1 个维度值)。
求计算连乘 M1 × M2 × ... × MN 的最少乘法次数(通过改变括号顺序)。

提示: dp[i][j] = 计算第 i 到第 j 个矩阵乘积的最少乘法次数。
转移:枚举最后一次分割点 k,代价为 dp[i][k] + dp[k+1][j] + p[i-1]*p[k]*p[j]

✅ 完整解答
#include <bits/stdc++.h>
using namespace std;

int main() {
    int n; cin >> n;
    vector<int> p(n + 1);
    for (int& x : p) cin >> x;
    
    // dp[i][j] = 计算矩阵 i..j 的最少乘法次数(1-indexed)
    vector<vector<long long>> dp(n + 1, vector<long long>(n + 1, 0));
    
    for (int len = 2; len <= n; len++) {
        for (int i = 1; i + len - 1 <= n; i++) {
            int j = i + len - 1;
            dp[i][j] = LLONG_MAX;
            for (int k = i; k < j; k++) {
                long long cost = (long long)p[i-1] * p[k] * p[j];
                dp[i][j] = min(dp[i][j], dp[i][k] + dp[k+1][j] + cost);
            }
        }
    }
    
    cout << dp[1][n] << "\n";
    return 0;
}

示例(N=3,矩阵维度 10×30,30×5,5×60):

p = [10, 30, 5, 60]

dp[1][2] = 10×30×5 = 1500
dp[2][3] = 30×5×60 = 9000
dp[1][3]:
  k=1: dp[1][1] + dp[2][3] + 10×30×60 = 0 + 9000 + 18000 = 27000
  k=2: dp[1][2] + dp[3][3] + 10×5×60 = 1500 + 0 + 3000 = 4500
  dp[1][3] = 4500

输出:4500(先算 M2×M3,再算 M1×(M2M3))

题目 5:Fibonacci 第 N 项(矩阵快速幂)
求斐波那契数列第 N 项 f(N) mod (10^9+7),N 可高达 10^18。

✅ 完整解答
#include <bits/stdc++.h>
using namespace std;
typedef long long ll;
typedef vector<vector<ll>> Matrix;

const ll MOD = 1e9 + 7;

Matrix mat_mul(const Matrix& A, const Matrix& B) {
    int n = A.size();
    Matrix C(n, vector<ll>(n, 0));
    for (int i = 0; i < n; i++)
        for (int k = 0; k < n; k++)
            for (int j = 0; j < n; j++)
                C[i][j] = (C[i][j] + A[i][k] * B[k][j]) % MOD;
    return C;
}

Matrix mat_pow(Matrix M, ll n) {
    int sz = M.size();
    Matrix result(sz, vector<ll>(sz, 0));
    for (int i = 0; i < sz; i++) result[i][i] = 1;  // 单位矩阵
    while (n > 0) {
        if (n & 1) result = mat_mul(result, M);
        M = mat_mul(M, M);
        n >>= 1;
    }
    return result;
}

ll fibonacci(ll n) {
    if (n <= 1) return n;
    // [f(n), f(n-1)] = [[1,1],[1,0]]^(n-1) * [f(1), f(0)]
    Matrix trans = {{1, 1}, {1, 0}};
    auto R = mat_pow(trans, n - 1);
    // R * [1, 0]^T = R[0][0]*1 + R[0][1]*0 = R[0][0]
    return R[0][0];
}

int main() {
    cout << fibonacci(1)  << "\n";   // 1
    cout << fibonacci(10) << "\n";   // 55
    cout << fibonacci(50) << "\n";   // 586268941
    
    ll n = (ll)1e18;
    cout << fibonacci(n)  << "\n";   // 某个大数 mod 10^9+7
    return 0;
}

为什么递推不行? f(10^18) 需要 10^18 步,即便每步只是加法也需要 ~32 年(按 10^9 步/秒)。矩阵快速幂只需 ~120 次矩阵乘法。


题目 6:牛买卖(区间 DP 变形)
N 个人依次报价(整数),你可以买入卖出,每次操作只涉及相邻时刻。
合并相邻两个操作(买-卖一对),代价为两个价格之差的绝对值。求将所有操作合并成一笔交易的最小总代价。
(即:括号匹配最少插入次数的数值版本)

提示: 这与石子合并类似,用区间 DP,dp[i][j] = 将第 i 到第 j 笔操作合并完毕的最小代价。

✅ 完整解答

本质是:对区间 [i,j] 枚举分割点 k,左右子区间各自合并后,再将两段合并,追加代价 |price[i] - price[j]|(合并时左端与右端的价差)。

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

int main() {
    int n; cin >> n;
    vector<int> p(n + 1);
    for (int i = 1; i <= n; i++) cin >> p[i];
    
    vector<vector<int>> dp(n + 1, vector<int>(n + 1, 0));
    
    for (int len = 2; len <= n; len++) {
        for (int i = 1; i + len - 1 <= n; i++) {
            int j = i + len - 1;
            dp[i][j] = INT_MAX;
            for (int k = i; k < j; k++) {
                // 左边合并后最终价格是 p[k](向左看),右边是 p[k+1](向右看)
                // 合并两段的额外代价:两段之间的「接口代价」= |p[i] - p[j]|
                // (用石子合并的模型,cost = 区间端点差值)
                dp[i][j] = min(dp[i][j],
                    dp[i][k] + dp[k+1][j] + abs(p[i] - p[j]));
            }
        }
    }
    
    cout << dp[1][n] << "\n";
    return 0;
}

此题展示了区间 DP 的灵活性:只需修改「合并代价」的计算方式,即可处理不同的区间合并问题。


🔴 挑战练习(7~8)

题目 7:1000 以内所有质数之和
用埃氏筛求出 1000 以内的所有质数,输出它们的个数和总和。
再用线性筛改写,比较结果是否一致。

✅ 完整解答
#include <bits/stdc++.h>
using namespace std;

// 埃氏筛版本
void eratosthenes(int n) {
    vector<bool> is_prime(n + 1, true);
    is_prime[0] = is_prime[1] = false;
    for (int i = 2; (long long)i * i <= n; i++)
        if (is_prime[i])
            for (int j = i * i; j <= n; j += i)
                is_prime[j] = false;
    
    int cnt = 0; long long sum = 0;
    for (int i = 2; i <= n; i++)
        if (is_prime[i]) { cnt++; sum += i; }
    cout << "埃氏筛:" << cnt << " 个质数,总和 = " << sum << "\n";
}

// 欧拉线性筛版本
void euler(int n) {
    vector<int> min_p(n + 1, 0);
    vector<int> primes;
    for (int i = 2; i <= n; i++) {
        if (!min_p[i]) { min_p[i] = i; primes.push_back(i); }
        for (int p : primes) {
            if ((long long)i * p > n) break;
            min_p[i * p] = p;
            if (i % p == 0) break;
        }
    }
    long long sum = 0;
    for (int p : primes) sum += p;
    cout << "线性筛:" << primes.size() << " 个质数,总和 = " << sum << "\n";
}

int main() {
    eratosthenes(1000);
    euler(1000);
    // 两者输出应完全一致:168 个质数,总和 = 76127
    return 0;
}

题目 8:字符串加密(快速幂综合应用)
RSA 加密中,加密公式为:密文 = 明文^e mod n。
给定明文 M(一个整数)、指数 e、模数 n,计算密文。
其中 M, e, n 可高达 10^18。

✅ 完整解答

直接套用快速幂模板。注意当 M 和 n 都接近 10^18 时,M * M 会溢出 long long,需要用 __int128 或「龟速乘(二进制分解乘法)」。

#include <bits/stdc++.h>
using namespace std;
typedef long long ll;
typedef unsigned long long ull;
typedef __int128 lll;

// 快速幂(支持 base 和 mod 都高达 10^18)
ll fast_pow(ll base, ll exp, ll mod) {
    ll result = 1;
    base %= mod;
    while (exp > 0) {
        if (exp & 1)
            result = (lll)result * base % mod;  // __int128 避免溢出
        base = (lll)base * base % mod;
        exp >>= 1;
    }
    return result;
}

// 龟速乘(不用 __int128 的替代方案)
ll safe_mul(ll a, ll b, ll mod) {
    ll result = 0;
    a %= mod;
    while (b > 0) {
        if (b & 1) result = (result + a) % mod;
        a = (a + a) % mod;
        b >>= 1;
    }
    return result;
}

ll fast_pow_safe(ll base, ll exp, ll mod) {
    ll result = 1; base %= mod;
    while (exp > 0) {
        if (exp & 1) result = safe_mul(result, base, mod);
        base = safe_mul(base, base, mod);
        exp >>= 1;
    }
    return result;
}

int main() {
    ll M, e, n;
    cin >> M >> e >> n;
    cout << fast_pow(M, e, n) << "\n";  // 密文
    
    // 测试:M=2, e=10, n=1000 → 2^10=1024 mod 1000 = 24
    cout << fast_pow(2, 10, 1000) << "\n";  // 24
    
    return 0;
}

为什么用 __int128
long long 最大约 9.2×10^18,但 base * base 可达 (10^18)^2 = 10^36,远超范围。
__int128 支持到约 1.7×10^38,足以处理中间计算。


💡 章节联系: 质数筛是数论题的基础工具;快速幂是「取模运算」场景(组合数、大幂次、RSA)的标配;区间 DP 是 USACO Gold 的高频题型,掌握了石子合并后还可以扩展到括号匹配、矩阵链乘、凸包剖分等。

📖 附录 F ⏱️ 约 30 分钟 🎯 全部级别

附录 F:调试指南——常见 Bug 及修复方法

💡 为什么要有这份附录? 即便算法思路完全正确,只要一个 Bug 溜进代码,结果就会出错。本指南系统地整理了竞赛 C++ 代码中最常见的 Bug,按类别分类,方便快速定位。当你的解答出现 WA(Wrong Answer)、TLE(Time Limit Exceeded)、RE(Runtime Error)或 MLE(Memory Limit Exceeded)时,请先来这里查找原因。

用下面这张分类图快速判断你的 Bug 属于哪个类别:

竞赛编程 Bug 分类

拿到错误判决后,按照这个系统化的调试流程来排查:

调试流程


F.1 整数溢出

C++ 中 Wrong Answer 最常见的根源。

问题:int 太小了

int 的最大值约为 2.1 × 10⁹(≈ 2 × 10⁹)。很多题目的中间值会超出这个范围。

// ❌ 错误:n=10^5 时,n*n 会溢出
int n = 100000;
int result = n * n;  // = 10^10 → 超出 int 范围(最大 ~2×10^9)!

// ✅ 正确:乘之前先转成 long long
long long result = (long long)n * n;  // = 10^10,在 long long 范围内
// 或:
long long n_ll = n;
long long result2 = n_ll * n_ll;

什么时候该用 long long

场景需要 long long 吗?
数组元素最大 10⁹,需要区间求和✅ 需要(和最大 10⁹ × 10⁵ = 10¹⁴)
最多 10⁵ 个元素的前缀和✅ 需要(安全默认选择)
矩阵元素、DP 中间值✅ 需要
Dijkstra 中的距离✅ 需要(dist[u] + w 可能溢出 int
简单计数器(0 到 10⁶ 以内的 N)int 足够
下标和循环变量int 足够

危险操作举例

📄 查看代码:危险操作举例
// ❌ 溢出示例:
int a = 1e9, b = 1e9;
cout << a + b;     // 溢出(结果 > INT_MAX)
cout << a * 2;     // 溢出
cout << a * a;     // 灾难性溢出

// ❌ 比较时溢出:
if (a * b > 1e18) ...  // a*b 本身可能已经溢出了!

// ✅ 安全版本:
cout << (long long)a + b;
cout << (long long)a * 2;
cout << (long long)a * a;
if ((long long)a * b > (long long)1e18) ...  // 用 long long 比较

INF 值的选择

// ❌ 错误:在 Dijkstra 中用 INT_MAX 作为无穷大
const int INF = INT_MAX;
if (dist[u] + w < dist[v]) ...  // dist[u] + w 在 dist[u]=INT_MAX 时溢出!

// ✅ 正确:使用安全的哨兵值
const long long INF = 1e18;   // 用于 long long 距离
const int INF_INT = 1e9;       // 用于 int 距离(留有加法空间)

F.2 差一错误(Off-By-One)

WA 第二常见的根源。

数组下标

// ❌ 错误:访问 A[n] 越界
int A[n];
for (int i = 0; i <= n; i++) cout << A[i];  // A[n] 未定义!

// ✅ 正确
for (int i = 0; i < n; i++) cout << A[i];   // 下标 0..n-1
// 或(1-indexed):
for (int i = 1; i <= n; i++) cout << A[i];  // 下标 1..n

前缀和公式

// ❌ 错误:区间和差一
// sum(L, R) 应该是 P[R] - P[L-1],而不是 P[R] - P[L]
cout << P[R] - P[L];    // 少了 A[L]!

// ✅ 正确
cout << P[R] - P[L-1];  // P[0]=0 正确处理 L=1 的边界情况

二分查找边界

📄 查看代码:二分查找边界
// 查找第一个 A[i] >= target 的下标(lower_bound 行为):

// ❌ 错误:常见二分查找写法错误
int lo = 0, hi = n - 1;
while (lo < hi) {
    int mid = (lo + hi) / 2;
    if (A[mid] < target) lo = mid;      // BUG:应为 lo = mid + 1
    else hi = mid - 1;                   // BUG:应为 hi = mid
}

// ✅ 正确:标准 lower_bound 模板
int lo = 0, hi = n;  // hi = n(而非 n-1),以允许"未找到"的情况
while (lo < hi) {
    int mid = (lo + hi) / 2;
    if (A[mid] < target) lo = mid + 1;  // target 在 [mid+1, hi]
    else hi = mid;                       // target 在 [lo, mid]
}
// lo = hi = 第一个 A[i] >= target 的下标;lo=n 表示未找到

循环边界

📄 查看代码:循环边界
// ❌ 常见错误:循环多跑或少跑一次
for (int i = 1; i < n; i++) ...    // 若本意是 i=0 到 n-1,则漏了 i=0
for (int i = 0; i <= n-1; i++) ... // 正确但写法混乱;推荐 i < n

// DP 表格填充:注意递推是否访问了 i-1
// ❌ 如果 dp[i] 用到 dp[i-1],而 i 从 0 开始,则 dp[-1] 未定义!
for (int i = 0; i <= n; i++) {
    dp[i] = dp[i-1] + ...;  // BUG:i=0 时访问 dp[-1]!
}

// ✅ 从 i=1 开始,或单独初始化 dp[0] 作为边界条件
dp[0] = BASE_CASE;
for (int i = 1; i <= n; i++) {
    dp[i] = dp[i-1] + ...;  // 安全:dp[i-1] 始终有效
}

F.3 未初始化的变量

📄 查看代码:F.3 未初始化的变量
// ❌ 错误:dp 数组未初始化
int dp[1005][1005];  // 在 C++ 中包含垃圾值!
// dp[i][j] 可能因上一个测试用例或操作系统内存而非零

// ✅ 正确方式:
// 方式一:memset(按字节填充,用 0 或 0x3f 模拟正无穷)
memset(dp, 0, sizeof(dp));          // 全部置 0
memset(dp, 0x3f, sizeof(dp));       // 置为 ~1.06e9(适合用作 int 的"无穷大")

// 方式二:vector 显式初始化
vector<vector<int>> dp(n+1, vector<int>(m+1, 0));

// 方式三:fill
fill(dp, dp + n, 0);

// ⚠️ 警告:memset(dp, -1, sizeof(dp)) 将每个字节置为 0xFF
// 对 int:0xFFFFFFFF = -1(可用于"未访问"标记)
// 对 long long:0xFFFFFFFFFFFFFFFF = -1(同样有效)
// 但 memset(dp, 1, sizeof(dp)) 得到 0x01010101 = 16843009,而不是 1!

全局变量 vs 局部变量

📄 查看代码:全局变量 vs 局部变量
// C++ 中全局数组默认初始化为零
// 局部(栈)数组则不会初始化

int globalArr[100005];     // ✅ 初始化为 0
int globalDP[1005][1005];  // ✅ 初始化为 0

int main() {
    int localArr[1000];    // ❌ 未初始化(含垃圾值)
    int localDP[100][100]; // ❌ 未初始化
    
    // 建议:将大数组声明为全局变量,既避免栈溢出又保证初始化
}

F.4 栈溢出(递归过深)

📄 查看代码:F.4 栈溢出(递归过深)
// C++ 默认栈大小通常为 1~8 MB
// 递归层数过深会超出限制 → 运行时错误(段错误)

// ❌ 危险:在深度为 10^5 的树上递归 DFS
void dfs(int u) { dfs(children[u]); }  // 长链场景下会栈溢出!

// ✅ 解法一:用显式栈改写为迭代
void dfs_iterative(int start) {
    stack<int> st;
    st.push(start);
    while (!st.empty()) {
        int u = st.top(); st.pop();
        for (int v : children[u]) st.push(v);
    }
}

// ✅ 解法二:增大栈大小(平台相关,竞赛评测机通常允许)
// Linux 下编译并运行:ulimit -s unlimited && ./sol

// 经验法则:
// 递归深度 ≤ ~10^4:通常安全
// 递归深度 ~10^5:有风险,考虑迭代
// 递归深度 ~10^6:几乎必定栈溢出 → 必须用迭代

F.5 模运算 Bug

📄 查看代码:F.5 模运算 Bug
// 当题目要求输出 mod 10^9+7 时:
const int MOD = 1e9 + 7;

// ❌ 错误:忘记取模,long long 溢出
long long dp = 1;
for (int i = 0; i < n; i++) dp *= A[i];  // ~18 次大数乘法后溢出!

// ❌ 错误:减法下溢(结果为负的模)
long long ans = (a - b) % MOD;  // a < b 时,C++ 返回负数!

// ✅ 正确:减法取模前加上 MOD
long long ans = ((a - b) % MOD + MOD) % MOD;  // 保证非负

// ❌ 错误:DP 中忘记对中间值取模
dp[i][j] = dp[i-1][j] + dp[i][j-1];  // 迭代次数多时可能溢出

// ✅ 正确:每次加法后立即取模
dp[i][j] = (dp[i-1][j] + dp[i][j-1]) % MOD;

// ✅ 正确的模意义快速幂:
long long modpow(long long base, long long exp, long long mod) {
    long long result = 1;
    base %= mod;
    while (exp > 0) {
        if (exp & 1) result = result * base % mod;  // ← 每次乘法后取模!
        base = base * base % mod;
        exp >>= 1;
    }
    return result;
}

F.6 图/BFS/DFS Bug

📄 查看代码:F.6 图/BFS/DFS Bug
// ❌ BFS:忘记在入队之前标记已访问
// 这会导致同一节点被处理多次!
queue<int> q;
q.push(src);
while (!q.empty()) {
    int u = q.front(); q.pop();
    visited[u] = true;  // ❌ 出队后才标记 → 同一节点可能多次入队
    for (int v : adj[u]) if (!visited[v]) q.push(v);
}

// ✅ 正确:入队时立即标记已访问
visited[src] = true;
queue<int> q;
q.push(src);
while (!q.empty()) {
    int u = q.front(); q.pop();
    for (int v : adj[u]) {
        if (!visited[v]) {
            visited[v] = true;  // ✅ 入队前标记
            q.push(v);
        }
    }
}

// ❌ DFS:多个测试用例之间忘记重置 visited
// 多测问题中,每次测试用例开始前必须重新初始化 visited[]!
memset(visited, false, sizeof(visited));

// ❌ Dijkstra:距离数组用 int 而非 long long
int dist[MAXN];  // ❌ 边权最大 10^9 时,累加后溢出!
long long dist[MAXN];  // ✅

F.7 I/O Bug

📄 查看代码:F.7 I/O Bug
// ❌ 错误:大量输入时未加 ios_base::sync_with_stdio(false)
// 不加此行,cin/cout 与 C 标准 I/O 同步 → 速度极慢!
// N = 10^6 的输入量下,可能是 AC 与 TLE 的差距。

// ✅ 每道竞赛题的 main() 开头都应加上:
ios_base::sync_with_stdio(false);
cin.tie(NULL);

// ❌ 错误:使用 endl(每行都刷新缓冲区 → 很慢)
for (int i = 0; i < n; i++) cout << ans[i] << endl;  // 慢!

// ✅ 正确:用 "\n" 代替
for (int i = 0; i < n; i++) cout << ans[i] << "\n";  // 快

// ❌ 错误:关闭同步后混用 cin 与 scanf/printf
ios_base::sync_with_stdio(false);
scanf("%d", &n);  // BUG:解除同步后混用 C 和 C++ I/O!

// ✅ 正确:选一种方式并坚持使用
// 要么只用 cin/cout,要么只用 scanf/printf

// USACO 文件 I/O(有时题目要求):
freopen("problem.in", "r", stdin);
freopen("problem.out", "w", stdout);
// 加上这两行后,cin/cout 会自动读写文件

F.8 二维数组越界与方向

📄 查看代码:F.8 二维数组越界与方向
// 网格 BFS:边界检查差一错误
int dx[] = {0, 0, 1, -1};
int dy[] = {1, -1, 0, 0};

// ❌ 错误(实际上下面这种写法是正确的——只是确保四个条件都写到)
for (int d = 0; d < 4; d++) {
    int nx = x + dx[d], ny = y + dy[d];
    if (nx >= 0 && ny >= 0 && nx < n && ny < m) // ✅ 这个边界检查是正确的!
    // 确保四个条件都写全
}

// ❌ 错误:弄混行列(转置了行和列)
// 若网格是 N 行 × M 列:
// A[row][col]:row 取值 0..N-1,col 取值 0..M-1
// 边界条件:row < N,col < M(不能写成 row < M!)

// ❌ 错误:多源 BFS 计算距离时,同一格子被多次访问(忘记距离检查)
if (!visited[nx][ny]) {  // ✅ 只访问未访问的格子
    visited[nx][ny] = true;
    dist[nx][ny] = dist[x][y] + 1;
    q.push({nx, ny});
}

F.9 DP 专项 Bug

📄 查看代码:F.9 DP 专项 Bug
// ❌ 错误:0/1 背包内层循环方向错了
// 必须从高到低遍历容量,防止物品被重复使用!
for (int i = 0; i < n; i++) {
    for (int j = W; j >= weight[i]; j--) {  // ✅ 从高到低
        dp[j] = max(dp[j], dp[j - weight[i]] + value[i]);
    }
}
// 若从低到高遍历:
for (int j = weight[i]; j <= W; j++) {  // ❌ 从低到高 = 完全背包!
    dp[j] = max(dp[j], dp[j - weight[i]] + value[i]);
}

// ❌ 错误:LIS 二分查找时混淆 upper_bound 与 lower_bound
// 严格递增 LIS:用 lower_bound(找第一个 >= x 的位置,替换)
// 非严格递减 LIS:用 upper_bound(找第一个 > x 的位置,替换)
auto it = lower_bound(tails.begin(), tails.end(), x);  // 严格递增
auto it = upper_bound(tails.begin(), tails.end(), x);  // 非严格递减

// ❌ 错误:忘记初始化边界条件
// dp[0] 或 dp[i][0] 或 dp[0][j] 必须在主循环之前显式赋值
dp[0][0] = 0;  // 始终初始化边界条件!

F.10 内存超限(MLE)

📄 查看代码:F.10 内存超限(MLE)
// 常见的 MLE 原因:

// ❌ 数组过大
int dp[10005][10005];  // = 10^8 个 int = 400MB → 超出典型的 256MB 限制!

// 计算方式:N × M × sizeof(类型) 字节
// int:4 字节,long long:8 字节
// 256MB = 256 × 10^6 字节
// int 数组最多约 6400 万个元素
// long long 数组最多约 3200 万个元素

// ✅ 一维 DP 的空间优化:
// 若 dp[i] 只依赖 dp[i-1],使用滚动数组:
vector<long long> dp(2, 0);  // dp[cur] 和 dp[prev]
for (int i = 0; i < n; i++) {
    dp[1 - cur] = f(dp[cur]);  // 在 0 和 1 之间交替
    cur = 1 - cur;
}

// ✅ 二维 DP 的空间优化(背包类):
// 若 dp[i][j] 只依赖 dp[i-1][...],只保留两行
vector<int> prev_row(W+1, 0), curr_row(W+1, 0);

快速诊断清单

拿到 WA/RE/TLE 时,按这个清单逐项检查:

Wrong Answer(WA):

Runtime Error(RE):

Time Limit Exceeded(TLE):

Memory Limit Exceeded(MLE):


💡 专业建议: 打印中间值!cerr << "DEBUG: dp[3] = " << dp[3] << "\n"; cerr 输出到标准错误流(而非标准输出),不会影响竞赛评测机上的输出。提交前记得删除所有 cerr 调试行。

竞赛编程术语词汇表

本词汇表定义了贯穿全书及竞赛编程领域常用的 35+ 个核心术语。遇到不熟悉的术语时,请先在这里查阅。


A

算法(Algorithm) 解决问题的分步骤过程。算法必须:正确(给出正确答案)、有限(最终终止)、确定(每步无歧义)。示例:二分查找、BFS、归并排序。

邻接表(Adjacency List) 图的一种表示方式:每个顶点存储其邻居列表。空间:O(V + E)。竞赛编程中的标准表示方式。

邻接矩阵(Adjacency Matrix) 二维数组,matrix[u][v] = 1 表示 u 到 v 有边。空间:O(V²)。仅在 V ≤ 1000 的稠密图中使用。

摊还时间(Amortized Time) 一系列操作的每次操作平均时间。示例:vector::push_back 的摊还时间为 O(1),即使偶尔的扩容需要 O(N)。


B

边界条件(Base Case) 递归和 DP 中,具有已知答案的最简子问题(无需进一步递归)。示例:fib(0) = 0fib(1) = 1

BFS(广度优先搜索,Breadth-First Search) 逐层探索节点的图遍历(先遍历所有距离为 1 的节点,再遍历距离为 2 的节点……)。使用队列。在无权图中保证最短路径。时间复杂度:O(V + E)。

大 O 表示法(Big-O Notation) 描述算法时间或空间增长上界的数学表示。"O(N log N)"表示"对某常数 c,操作次数至多为 c × N × log(N)"。用于比较算法效率。

二分查找(Binary Search) 有序数组上的 O(log N) 搜索算法。每步与中点比较,将候选范围减半。最重要的应用:"对答案二分"用于优化问题。

暴力枚举(Brute Force) 尝试所有可能情况的朴素解法。通常为 O(N²) 或 O(2^N)。正确但对大规模输入太慢。用途:部分得分、验证优化解、小测试用例。


C

比较器(Comparator) 定义排序顺序的函数。接收两个元素,若第一个应排在第二个前面则返回 true。与 std::sort 配合使用。

算法竞赛(Competitive Programming) 在时间限制内解决算法问题的编程竞赛。USACO、Codeforces、LeetCode 和 IOI 是热门平台。

连通分量(Connected Component) 任意两顶点之间都有路径相连的极大子图。可用 DFS/BFS 或并查集找连通分量。

坐标压缩(Coordinate Compression) 将大范围的值(如最大 10^9)映射为小的连续下标(0, 1, 2, ...)而不改变相对顺序。使得可以用数组代替哈希表。


D

DAG(有向无环图,Directed Acyclic Graph) 没有环的有向图。关键性质:存在拓扑排序。示例:依赖关系图、任务调度。

DFS(深度优先搜索,Depth-First Search) 在回溯前尽可能深地探索的图遍历。使用栈(或递归)。适用于:连通性判断、环检测、拓扑排序。时间复杂度:O(V + E)。

差分数组(Difference Array) O(1) 区间更新的技术。存储相邻元素之差;区间加 [L,R] 变为 diff[L]++ 和 diff[R+1]--。用前缀和还原原数组。

DP(动态规划,Dynamic Programming) 通过将问题分解为有重叠的子问题并缓存结果来优化求解的技术。需要两个性质:最优子结构 + 重叠子问题。另见:记忆化、递推。

DSU(并查集,Disjoint Set Union) 见"并查集(Union-Find)"。


E

边(Edge) 图中两顶点之间的连接。可以是有向的(单向)或无向的(双向),可以有权重。

交换论证(Exchange Argument) 贪心算法的证明技术。证明将贪心选择与其他选择交换后,解不会变得更差。


F

洪水填充(Flood Fill) 一种算法(通常用 DFS 或 BFS),标记网格中所有相同"颜色"的连通格子。用于统计连通区域数。


G

图(Graph) 由顶点(节点)和边(连接)组成的数据结构。建模关系、网络、地图等。

贪心算法(Greedy Algorithm) 在每步做出局部最优选择,期望得到全局最优结果的算法。当"贪心选择性质"成立时有效。示例:活动选择、哈夫曼编码、Kruskal MST。


H

哈希表(Hash Map / unordered_map) 以 O(1) 平均时间存储和查找键值对的数据结构。用哈希表实现。没有顺序保证。当需要快速查找但不需要有序键时使用。


I

区间 DP(Interval DP) 以子数组 [l, r] 为状态,尝试所有分割点的 DP 模式。经典示例:矩阵链乘法、回文划分。时间复杂度:O(N³)。


K

背包问题(Knapsack Problem) DP 问题:给定有重量和价值的物品,在重量限制内最大化价值。"0/1 背包"指每件物品最多使用一次,"完全背包"指可以无限使用。


L

LIS(最长递增子序列,Longest Increasing Subsequence) 数组中每个元素都严格大于前一个元素的最长子序列。O(N²) DP 或带二分查找的 O(N log N)。

LCA(最近公共祖先,Lowest Common Ancestor) 有根树中同时是 u 和 v 的祖先的最深节点。朴素做法:每次查询 O(深度)。倍增:O(log N)。


M

记忆化(Memoization) 缓存递归函数调用结果以避免重复计算。"自顶向下 DP"。备忘录表存储已计算的值;计算前先检查答案是否已知。

MST(最小生成树,Minimum Spanning Tree) 带权图中总边权最小的生成树。Kruskal 算法:排序边 + 并查集。Prim 算法:优先队列 + 已访问集合。两者都是 O(E log E)。

单调(Monotone / Monotonic) 持续递增或递减。函数单调意味着方向从不反转。对答案二分的关键:可行性函数必须是单调的。


O

差一错误(Off-By-One Error) 下标或计数恰好差 1 的 Bug。在循环(< n vs <= n)、二分查找、前缀和(P[L-1] vs P[L])中非常常见。

最优子结构(Optimal Substructure) 一种性质:问题的最优解可以由其子问题的最优解构建。DP 正常工作的必要条件。

溢出(Overflow) 值超过类型所能表示的最大值。int 最大值约为 2×10^9;long long 最大值约为 9.2×10^18。两个 10^9 的 int 相乘会溢出 int——先强制转换为 long long


P

前缀和(Prefix Sum) P[i] = 从下标 0(或 1)到 i 所有元素之和的数组。支持 O(1) 区间查询:sum(L,R) = P[R] - P[L-1]


R

递推关系(Recurrence Relation) 将 DP 值表示为更小 DP 值的公式。示例:fib(n) = fib(n-1) + fib(n-2)。定义了 DP 的状态转移。


S

线段树(Segment Tree) 支持 O(log N) 区间查询和更新的数据结构。比前缀和更强大(支持更新)。Gold/Platinum 级别话题。

稀疏图(Sparse Graph) 边数相对于 V² 较少的图。实践中:E = O(V)。使用邻接表。

状态(DP State) 唯一标识 DP 子问题的信息集合。背包中的示例:(物品下标, 剩余容量)。选择合适的状态是 DP 的核心技能。

子树(Subtree) 树中某节点的所有后代节点(含该节点本身)。树形 DP 通常计算子树上的聚合值。


T

递推(Tabulation) 从边界条件到更大子问题迭代构建 DP 表格。"自底向上 DP"。无递归,无栈溢出风险。

超时(Time Limit Exceeded / TLE) 评测结果之一,表示解法正确但速度太慢。USACO 中大多数题目的时间限制为 2~4 秒。遇到 TLE,优化算法——而不只是优化常数因子。

拓扑排序(Topological Sort) DAG 中顶点的一种排序方式,使得对每条有向边 u→v,u 都排在 v 前面。可用 DFS(逆后序)或 Kahn 算法(基于 BFS)计算。

双指针(Two Pointers) 使用两个下标遍历数组(通常同向移动)的技术。将 O(N²) 的配对搜索转化为 O(N)。适用于有序数组或条件单调的情形。


U

并查集(Union-Find / DSU) 支持两种操作的数据结构:find(x)(x 属于哪个集合?)和 union(x,y)(合并 x 和 y 所在的集合)。带路径压缩 + 按秩合并:每次操作 O(α(N)) ≈ O(1)。用于动态连通性、Kruskal MST、环检测。


V

顶点(Vertex / Node) 图的基本单元。顶点有编号(USACO 中通常从 1 开始)。


W

答案错误(Wrong Answer / WA) 评测结果之一,表示程序运行了但输出了错误结果。检查边界情况、差一错误和整数溢出。

📊 Knowledge Dependency Map

This interactive map shows prerequisite relationships between all chapters. Click any node to highlight its prerequisites (red) and dependent chapters (green).


Foundation
Data Structures
Graph Algorithms
Dynamic Programming
Greedy
← Prerequisite (red)
→ Unlocks (green)
Click a chapter node to see dependencies


How to Read This Map

ColorMeaning
🔵 Blue nodesC++ Foundation chapters (Ch.2.1–3.1)
🟢 Green nodesCore Data Structure chapters
🟠 Orange nodesGraph Algorithm chapters
🟣 Purple nodesDynamic Programming chapters
🔴 Red nodesGreedy Algorithm chapters
Red highlighted edgesPrerequisites of the selected chapter
Green highlighted edgesChapters unlocked by the selected chapter

Tip: Click any node to reveal its full dependency chain. Click again (or press "↺ Clear Selection") to reset.