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 |
🚀 如何使用本书
- 入门学习者 — 从 C++ 基础 开始
- 有一定基础 — 直接进入 核心数据结构
- 备战 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++,原因有两点:
- 速度 —— C++ 程序比 Python 或 Java 运行更快,而在时间限制严格(通常 10^8 次操作只有 1-2 秒)时,速度至关重要
- STL —— C++ 标准模板库提供了几乎所有你可能用到的数据结构和算法的现成实现
注意: USACO 接受 C++、Java 和 Python。但在顶级选手中 C++ 是最主流的选择,本书专注于 C++。
第二部分学习建议
- 亲手敲代码。 不要复制粘贴。你的手需要熟悉语法。
- 主动制造错误。 故意引入错误,看看会发生什么。读懂编译器报错本身也是一项技能。
- 运行每一个示例。 亲眼看到输出出现在屏幕上,远比仅仅阅读更能加深理解。
出发!
第 2.1 章:你的第一个 C++ 程序
📝 前置条件: 这是第一章——无需任何前置知识!你不需要任何编程经验。按顺序从头读到尾,完成本章后你将写出第一个真正的 C++ 程序。
欢迎!完成本章后,你将:
- 搭建好可用的 C++ 开发环境(用在线编译器只需 5 分钟)
- 编写、编译并运行第一个 C++ 程序
- 理解代码中每一行的含义
- 学会变量、数据类型和输入输出
- 完成 13 道练习题并查看完整题解
2.1.0 搭建开发环境
写代码之前,你需要一个可以编写和运行代码的地方。有两种选择:在线编译器(推荐新手使用——无需安装)和本地开发环境(可选,适合离线工作)。
选项 A:在线编译器(推荐——从这里开始!)
只需一个浏览器,打开以下任意网站:
| 网站 | 地址 | 说明 |
|---|---|---|
| Codeforces IDE | codeforces.com | 免费注册账号,在任意题目页面点击「Submit code」即可打开代码编辑器 |
| Replit | replit.com | 新建「C++ project」,获得完整编辑器 + 终端 |
| Ideone | ideone.com | 粘贴代码,选 C++17,点「Run」——最简单的选项 |
| OnlineGDB | onlinegdb.com | 内置调试器,功能完善 |
使用 Ideone(新手最简单):
- 打开 ideone.com
- 在语言下拉框选择「C++17 (gcc 8.3)」
- 将代码粘贴到文本区
- 点击绿色「Run」按钮
- 在底部面板查看输出
就这么简单!无需安装,无需配置。
选项 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 分钟 |
| Linux | Ubuntu/Debian:sudo apt install g++ cmake;Fedora:sudo dnf install gcc-c++ cmake |
第二步:安装 CLion
- 前往 CLion 下载页面 下载对应系统的安装包
- 运行安装包并按提示完成安装(保持默认选项即可)
- 首次启动时选择**「激活」**→ 用 JetBrains 学生账号登录,或开始 30 天免费试用
第三步:创建第一个项目
- 打开 CLion,点击**「New Project」**
- 选择**「C++ Executable」,将语言标准设为C++17**
- 点击**「Create」**——CLion 会自动生成包含
main.cpp的项目 - 在
main.cpp中编写代码,点击右上角绿色**▶ Run** 按钮即可编译运行 - 输出会出现在底部的**「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 并告诉操作系统程序成功结束。(非零返回值表示发生了错误。)
编译流程
图示:编译流程
上图展示了从源代码到可执行文件的三个阶段:你的 .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::cout、std::vector、std::sort。有了 using namespace std;,直接写 cout、vector、sort——简洁得多。
I/O 加速行
ios_base::sync_with_stdio(false);
cin.tie(NULL);
这两行让 cin 和 cout 快得多。没有它们,读取大量输入可能慢 10 倍,即使算法正确也可能导致「超时(TLE)」。每次都要加上它们。
🐛 常见错误: 加了这两行后,不要混用
cin/cout和scanf/printf。选一种风格坚持用。
2.1.3 变量与数据类型
变量是内存中一个有名字的存储位置。C++ 中每个变量都有一个类型——类型告诉计算机需要分配多少内存,以及里面会存什么数据。
🧠 思维模型:变量就像带标签的盒子
当你写: int score = 100;
计算机做了三件事:
1. 创建一个足以容纳整数的盒子(4 字节)
2. 在盒子上贴上标签 "score"
3. 把数字 100 放进盒子
竞赛编程必备类型
📄 查看代码:竞赛编程必备类型
#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++ 数据类型参考
如何选择正确的类型
| 使用场景 | 选用类型 |
|---|---|
| 计数、小数字 | 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 | 保留关键字 |
⚠️ 区分大小写!
score、Score和SCORE是三个完全不同的变量。这是常见 bug 来源——命名时保持一致。
常见命名风格
C++ 中有几种广泛使用的命名规范。竞赛编程中不必只选一种,但了解它们有助于读懂别人的代码:
| 风格 | 示例 | 通常用于 |
|---|---|---|
| 驼峰式(camelCase) | numStudents、totalScore | 局部变量、函数参数 |
| 帕斯卡式(PascalCase) | MyClass、GraphNode | 类、结构体、类型名 |
| 下划线式(snake_case) | num_students、total_score | 变量、函数(C/Python 风格) |
| 全大写(ALL_CAPS) | MAX_N、MOD、INF | 常量、宏 |
| 单字母 | n、m、i、j | 循环下标、数学风格竞赛编程 |
竞赛编程中最常用驼峰式和单字母名称。公司的生产代码中,根据风格指南通常用 snake_case 或 camelCase。
命名最佳实践
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 生产代码的命名对比
| 方面 | 竞赛编程 | 生产/学校项目 |
|---|---|---|
| 变量名长度 | 简短即可:n、m、dp、adj | 有描述性:numStudents、adjacencyList |
| 循环变量 | 永远用 i、j、k | i、j、k 也没问题 |
| 常量 | MAXN、MOD、INF | kMaxSize、kModulus(Google 风格) |
| 注释 | 极少——速度优先 | 详尽——可读性优先 |
| 目标 | 快速编写、快速解题 | 编写别人能维护的代码 |
💡 本书中: 我们会混用两种风格——讲解时用描述性名称保持清晰,解题时用简短名称。关键原则:看到变量名就应该立刻知道它存的是什么。
深入了解:char、string 与字符-整数转换
我们已经简要介绍了 char 和 string。由于许多 USACO 题目涉及字符处理、数字提取和字符串操作,让我们深入看看这两种重要类型。
char 与 ASCII——每个字符都是一个数字
C++ 中的 char 以 1 字节整数(0-255)存储。每个字符按照 ASCII 表(美国信息交换标准代码)映射到一个数字。不需要背整张表,但记住几个关键范围非常有用:
关键关系:
• '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;
}
char ↔ int 转换——最常用的技巧
竞赛编程中,你需要频繁地在字符数字和整数值之间转换。以下是完整指南:
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*
为什么竞赛编程中 string 比 char[] 更好:
| 特性 | 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 用 cin 和 cout 进行输入输出
用 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"而不是endl。endl会清空输出缓冲区,比"\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 / 5 得 3,不是 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,打印它们的和、差、积、整数商和余数。
分析思路:
- 需要两个变量存储 N 和 M
- 用
cin读取 - 用
cout打印每个结果 - N 和 M 可能很大,应该用
long long吗?安全起见用它。
💡 新手解题流程:
遇到题目时,别急着写代码。先用自然语言想清楚步骤:
- 理解题目:输入是什么?输出是什么?约束是什么?
- 手动推演样例:用样例输入,手算出输出,确认自己理解了题目
- 考虑数据范围:N 和 M 最大多少?会不会溢出?
- 写伪代码:
读取 → 计算 → 输出- 翻译成 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 | 使用 endl | cout << x << endl; | endl 清空输出缓冲区,大量输出时比 "\n" 慢 10 倍以上,可能导致超时 | 用 "\n" |
| 3 | 忘记 I/O 加速 | 缺少 sync_with_stdio 和 cin.tie | 默认情况下 cin/cout 与 C 的 scanf/printf 同步,大量输入时极慢 | 始终加上这两行 |
| 4 | 整数除法意外 | 7/2 期望 3.5 却得到 3 | 两个整数相除,C++ 截断小数部分 | 强转 double:(double)7/2 |
| 5 | 缺少分号 | cout << x | C++ 每条语句必须以 ; 结尾,否则编译失败 | cout << x; |
| 6 | 混用 cin >> 和 getline | cin >> n 然后 getline(cin, s) | cin >> 在缓冲区留下 \n,getline 读到空行 | 中间加 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 / b 和 a % 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/cout和scanf/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 核心用法)介绍
vector和sort等 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
只打印象限数字:1、2、3 或 4。
样例输入 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 章:控制流
📝 前置条件: 第 2.1 章(变量、cin/cout、基本算术)
2.2.0 什么是「控制流」?
到目前为止,我们写的每个程序都是从上到下执行的——第 1 行、第 2 行、第 3 行,结束。就像从头到尾读一本书。
但真正的程序需要做决策并重复操作。这就是「控制流」的含义——控制执行的流(顺序)。
想象一本「选择你的冒险」书:
- 有时书上说「如果你想和龙战斗,翻到第 47 页;否则翻到第 52 页」
- 有时书上说「重复这一段,直到你逃出地牢」
C++ 通过以下方式提供了恰好对应的功能:
if/else—— 根据条件做决策for/while循环 —— 重复执行一段代码
这是控制流的总览:
在循环图示中:程序不断回到「步骤 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:
- 85 >= 90?否 → 跳过
- 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 循环模式
📄 查看代码:常见 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 << " ";
先在纸上追踪循环,再运行——这能建立直觉并帮你发现 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 嵌套循环
可以在循环里再放一个循环。内层循环对外层循环的每一次迭代都会完整执行一遍。
// 打印 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 层(嵌套) | N² | ~10^4 | 比较所有对 |
| 3 层(嵌套) | N³ | ~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 break 和 continue
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)——只有
maxVal和minVal两个变量
🤔 为什么初始化为第一个元素? 不要初始化为 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++ | 条件永远为真,程序卡死 | 确保循环变量被更新 |
| 3 | switch 中忘记 break | case 2: cout << "two"; 没有 break | 执行「穿透」到下一个 case | 每个 case 末尾加 break; |
| 4 | 差一错误 | for (int i = 0; i <= n; i++) 本应是 < n | 多循环一次,可能越界或多计 | 仔细核对 < 和 <= |
| 5 | 最大值初始化为 0 | int maxVal = 0; 但所有数都是负数 | 0 比所有输入都大,结果错误 | 初始化为第一个元素或 INT_MIN |
| 6 | 嵌套循环用了相同的变量名 | 外层 for (int i...) 和内层 for (int i...) | 内层 i 遮蔽外层 i,导致外层循环行为异常 | 内外层循环用不同变量名(如 i 和 j) |
本章总结
📌 核心要点
| 概念 | 语法 | 使用场景 | 为什么重要 |
|---|---|---|---|
if | if (条件) { ... } | 条件为真时执行 | 程序决策的基础;几乎每道题都用 |
if/else | if (...) {...} else {...} | 二选一 | 处理是/否类型的决策 |
if/else if/else | 链式 | 多选一 | 评级系统、分类场景 |
while | while (条件) {...} | 次数未知时重复 | 读取到输入结束、模拟过程 |
for | for (int i=0; i<n; i++) {...} | 次数已知时重复 | 竞赛编程中最常用的循环 |
| 嵌套循环 | 循环套循环 | 需要遍历所有对 | 注意 O(N²) 复杂度限制 |
break | break; | 找到目标后立即退出 | 提前终止节省时间 |
continue | continue; | 跳过当前迭代 | 过滤掉不需要处理的元素 |
switch | switch(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:for 和 while 可以互换吗?什么时候该用哪个?
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:switch 和 if-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;
}
关键点:
sum用long 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 章:函数与数组
📝 前置条件: 第 2.1 章和第 2.2 章(变量、循环、if/else)
随着程序越来越大,你需要组织代码的方式(函数)和存储数据集合的方式(数组和向量)。本章介绍这两者——竞赛编程中最重要的两个工具。
2.3.1 函数——是什么,为什么需要
🍕 食谱类比
📄 查看代码:🍕 食谱类比
函数就像一份披萨食谱:
- 输入(参数): 食材——面粉、奶酪、番茄
- 过程(函数体):烹饪步骤
- 输出(返回值):做好的披萨
就像你可以用一份食谱做很多张披萨,
你可以用不同的输入多次调用一个函数。
pizza("薄底", "培根") → 一张披萨
pizza("厚底", "蘑菇") → 另一张披萨
没有函数,如果你需要在程序的五个地方计算「这个数是不是质数」,你就得把同样的 10 行代码复制粘贴五次。然后如果发现了 bug,就要在五个地方全部修复!
什么时候写函数
以下情况使用函数:
- 程序中某段逻辑重复了 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 是什么。如果 square 在 main 之后定义,编译器会说「我从没听说过 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!) ← 同样的问题,更小的输入!
💡 递归思维三步法:
- 找「自相似性」: 原问题能否拆解为同类型的更小问题?5! = 5 × 4!,4! 和 5! 是同一类型 ✓
- 确定边界条件: 最小的情况是什么?0! = 1,不能再拆解
- 写递推步骤: 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 ✓
每个递归函数都需要:
- 边界条件 —— 停止递归(防止无限递归)
- 递推步骤 —— 用更小的输入调用自身
🐛 常见 Bug: 忘记边界条件 → 无限递归 → 「栈溢出」崩溃!
2.3.5 数组——固定大小的集合
🏠 邮箱类比
数组就像街道上一排邮箱:
- 所有邮箱大小相同(相同类型)
- 每个都有门号(下标,从 0 开始)
- 可以通过门号直接找到任意邮箱
图示:数组内存布局
数组在内存中是连续存储的,每个元素紧挨着前一个,因此支持 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 个元素 | 必须硬编码或用 MAXN | push_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]; | 局部数组不会自动清零,包含垃圾值 | 用 = {} 初始化或使用全局数组 |
| 6 | main 内数组太大 | int main() { int arr[1000000]; } | 超过栈内存限制(通常 1-8 MB),程序崩溃 | 把大数组放在 main 外(全局) |
| 7 | 函数定义在调用之后 | main 调用 square(5) 但 square 定义在 main 下面 | 编译器无法识别未定义的函数 | 在 main 之前定义函数,或使用函数原型 |
本章总结
📌 核心要点
| 概念 | 要点 | 为什么重要 |
|---|---|---|
| 函数 | 定义一次,随处调用 | 减少重复代码,提高可读性 |
| 返回类型 | int、double、bool、void | 不同场景用不同返回类型 |
| 按值传递 | 函数得到副本,原变量不变 | 安全,无副作用 |
按引用传递(&) | 函数操作原变量 | 可修改原变量,避免复制大对象 |
| 递归 | 函数调用自身,必须有边界条件 | 分治、回溯、DP 的基础 |
| 数组 | 固定大小,从 0 开始,O(1) 随机访问 | 竞赛编程中最基本的数据结构 |
| 全局数组 | 避免栈溢出,自动初始化为 0 | N 超过 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:两种情况:① 需要在函数内部修改原变量;② 参数是大对象(如
vector或string),想避免复制开销。对于int、double这样的小类型,复制开销可忽略不计,按值传递即可。
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 核心用法)将介绍
sort、binary_search、pair等工具,让你用一行代码完成本章手动实现的操作 - 第 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参数中的n是main中n的独立副本(按值传递)
🏋️ 核心练习题
题目 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;
}
关键点:
- 每次新元素读入后更新
sum;i是已读元素个数 (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;
}
关键点:
- 双指针
i和j同步扫描数组a和b - 始终取当前较小的元素——维持有序性
- 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 章:结构体与类
📝 前置条件: 第 2.1–2.3 章(变量、控制流、函数、数组)
竞赛编程中,你经常需要把相关数据组合在一起——例如,一个点有 x 和 y,一条边有两个端点和权重,一名学生有姓名和成绩。C++ 提供了 struct 和 class 来把数据(和行为)绑定成单一类型。本章涵盖两者,重点关注竞赛编程中最重要的内容。
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()创建一个元组用于字典序比较。调换元素顺序可反转该字段的排序方向。这是竞赛编程中非常常见的技巧。
在集合和映射中使用结构体
如果想把结构体作为 set 或 map 的键,必须定义 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++ 中几乎完全相同。唯一区别是默认访问级别:
| 特性 | struct | class |
|---|---|---|
| 默认访问 | public | private |
| 可以有方法? | ✅ 可以 | ✅ 可以 |
| 可以有构造函数? | ✅ 可以 | ✅ 可以 |
| 可以继承? | ✅ 可以 | ✅ 可以 |
// 这两个功能上完全相同:
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。
成员函数(方法)
struct 和 class 都可以有成员函数:
📄 `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。
含 array 或 vector 成员的结构体
📄 查看代码:含 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()、set、map、priority_queue 支持你的类型 |
| tie() | 简洁的多关键字比较技巧 |
| pair | 内置的 2 字段结构体,支持字典序比较 |
| const 方法 | 标记不修改对象的方法 |
🎯 竞赛编程核心要点
- 竞赛中始终用
struct—— 更简单、更简短 - 掌握
operator<重载 —— 几乎每道 USACO 题都会用到 - 多关键字排序用
tie()—— 简洁且不易出错 - 记得
const—— STL 兼容性的要求 - 初始化你的成员 —— 避免未定义行为
- 2 个字段用
pair,3 个及以上用自定义 struct —— 好的经验法则
你现在知道如何创建自定义数据类型了——这是竞赛编程中组织数据的关键技能。接下来:强大的 STL 容器!
📖 第 2.5 章:分类讨论与矩形几何
⏱ 预计阅读时间:35 分钟 | 难度:🟢 入门(USACO Bronze 核心技能)
前置条件
- 基本 C++ 语法(第 2.1~2.2 章)
if/else条件语句
🎯 学习目标
学完本章后,你将能够:
- 识别需要分类讨论的题目,系统枚举所有情形
- 处理坐标轴上的矩形交叉、覆盖、面积问题
- 判断两个矩形是否相交,计算交集面积
- 用差分思想处理网格上的矩形覆盖计数
2.5.1 分类讨论(Casework)
什么是分类讨论?
当问题的答案取决于若干个「互斥情形」时,需要逐一枚举每种情形,分别处理。
核心原则:
- 完备性:不遗漏任何情形
- 互斥性:情形之间没有重叠(或显式处理重叠)
- 验证:对每种情形验证边界值
示例:一维区间分类
问题: 给定两个区间 [a, b] 和 [c, d],判断它们的关系(不相交、相接、相交、包含)。
情形1:b < c 或 d < a → 完全不相交
[a...b] [c...d] 或 [c...d] [a...b]
情形2:b == c 或 a == d → 仅一个端点相接
[a...b=c...d]
情形3:a <= c 且 b >= d → [c,d] 在 [a,b] 内部
情形4:c <= a 且 d >= b → [a,b] 在 [c,d] 内部
情形5:其他 → 部分重叠
#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 (b < c || d < a) {
cout << "不相交\n";
} else if (a <= c && b >= d) {
cout << "[c,d] 在 [a,b] 内部(或相等)\n";
} else if (c <= a && d >= b) {
cout << "[a,b] 在 [c,d] 内部(或相等)\n";
} else {
cout << "部分重叠\n";
}
return 0;
}
分类讨论的技巧
技巧 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));
// 若结果 <= 0,说明不相交
技巧 3:画图 + 列举边界
分类讨论题必须动手画图,列出所有边界情况逐一验证。
USACO Bronze 典型:方向判断
问题(USACO 风格): 奶牛从 (x, y) 出发,面朝 North/South/East/West。
给出「左转/右转/前进 D 步」指令,输出最终位置。
#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
两矩形是否相交
关键规则: 两矩形不相交,当且仅当一个矩形完全在另一个的左/右/上/下。
// 矩形 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;
}
交集面积
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)
交集左下:max(1,2)=2, max(1,2)=2
交集右上:min(4,6)=4, min(4,5)=4
交集面积:(4-2) × (4-2) = 4
两矩形的并集面积
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; // 容斥原理
}
矩形内点的判断
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;
}
进阶:N 个矩形的覆盖面积(差分法)
当有大量矩形叠加时,可以用二维差分数组统计每个格子被覆盖的次数。
#include <bits/stdc++.h>
using namespace std;
const int MAXN = 1005;
int diff[MAXN][MAXN]; // 差分数组
// 矩形左下 (x1,y1),右上 (x2,y2),覆盖 +1
void add_rect(int x1, int y1, int x2, int y2) {
diff[y1][x1]++;
diff[y2][x1]--;
diff[y1][x2]--;
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 i = 1; i < MAXN; i++)
for (int j = 1; j < MAXN; j++)
diff[i][j] += diff[i-1][j] + diff[i][j-1] - diff[i-1][j-1];
// 统计覆盖面积
long long covered = 0;
for (int i = 1; i < MAXN; i++)
for (int j = 1; j < MAXN; j++)
if (diff[i][j] > 0) covered++;
cout << covered << "\n";
return 0;
}
⚠️ 常见错误
| 错误 | 原因 | 修复方案 |
|---|---|---|
| 边界情形遗漏 | 只考虑「一般情况」,忽略端点相等 | 逐一列出所有情形,验证 <= vs < |
| 整数溢出 | 坐标值大时 x * y 超过 int | 用 long long |
| 矩形方向假设错误 | 没有保证 x1<x2 y1<y2 | 读入后 if(x1>x2) swap(x1,x2) |
| 差分数组越界 | 添加矩形时坐标超出范围 | 检查坐标是否在数组范围内 |
💪 练习题
🟢 题目 1:矩形相交判断
给定两个矩形,判断它们是否有公共区域(交集面积 > 0),输出交集的坐标和面积,若不相交输出 0。
✅ 完整解答
#include <bits/stdc++.h>
using namespace std;
int main() {
long long ax1,ay1,ax2,ay2,bx1,by1,bx2,by2;
cin >> ax1 >> ay1 >> ax2 >> ay2;
cin >> bx1 >> by1 >> bx2 >> by2;
long long ix1=max(ax1,bx1), iy1=max(ay1,by1);
long long ix2=min(ax2,bx2), iy2=min(ay2,by2);
if (ix2 <= ix1 || iy2 <= iy1) cout << 0;
else cout << (ix2-ix1)*(iy2-iy1);
cout << "\n";
}
🟡 题目 2:奶牛放牧区域(USACO Bronze 风格)
有 N 块矩形牧场(坐标均为 0~1000 的整数),求被至少一块牧场覆盖的总格数(每格 1×1)。
✅ 完整解答
思路: 用差分数组标记每个 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-1, y1~y2-1)
diff[y1][x1]++; diff[y2][x1]--;
diff[y1][x2]--; diff[y2][x2]++;
}
long long ans = 0;
for (int i = 0; i <= 1000; i++)
for (int j = 0; j <= 1000; j++) {
if (i > 0) diff[i][j] += diff[i-1][j];
if (j > 0) diff[i][j] += diff[i][j-1];
if (i > 0 && j > 0) diff[i][j] -= diff[i-1][j-1];
if (diff[i][j] > 0) ans++;
}
cout << ans << "\n";
}
🔴 题目 3:矩形分类问题(综合)
给定一个矩形和 N 个点,将每个点分类:在矩形内部、在矩形边界上、在矩形外部。
✅ 完整解答
#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_edge = (px==x1||px==x2) && (y1<=py&&py<=y2)
|| (py==y1||py==y2) && (x1<=px&&px<=x2);
bool inside = x1<px && px<x2 && y1<py && py<y2;
if (on_edge) cout << "边界\n";
else if (inside) cout << "内部\n";
else cout << "外部\n";
}
}
💡 章节联系: 矩形几何是 USACO Bronze 的高频题型之一(约占 15%),常与前缀和(差分数组)结合。掌握后可直接用于解决「覆盖面积」「重叠判断」等问题。
📖 第 2.6 章:位运算
⏱ 预计阅读时间:40 分钟 | 难度:🟢 入门(USACO Bronze/Silver 必备)
前置条件
- 整数的二进制表示(知道什么是二进制即可)
- 基本 C++ 语法
🎯 学习目标
学完本章后,你将能够:
- 使用六种位运算符进行高效整数操作
- 用位运算检查、设置、清除、翻转二进制位
- 用整数作为「集合」(状压表示)进行集合运算
- 枚举一个整数所有子集(状压 DP 基础)
- 理解常见的位运算技巧(lowbit、快速判断 2 的幂等)
2.6.1 为什么要学位运算?
位运算让你在 O(1) 时间内对整数的二进制位做操作。竞赛中的典型应用:
| 应用 | 场景 |
|---|---|
| 状态压缩(状压 DP) | 用一个整数表示一个集合或状态 |
| 快速幂 | 利用指数的二进制分解 |
| 枚举子集 | 遍历 N 个元素的所有子集 |
| 树状数组的 lowbit | x & (-x) 找最低有效位 |
| 奇偶判断 | x & 1 比 x % 2 更快 |
2.6.2 六种基本位运算符
| 运算符 | 名称 | 用法 | 示例(二进制) |
|---|---|---|---|
& | 按位与 (AND) | a & b | 1100 & 1010 = 1000 |
| | 按位或 (OR) | a | b | 1100 | 1010 = 1110 |
^ | 按位异或 (XOR) | a ^ b | 1100 ^ 1010 = 0110 |
~ | 按位非 (NOT) | ~a | ~1100 = 0011... |
<< | 左移 | a << k | 0001 << 2 = 0100 |
>> | 右移 | a >> k | 1100 >> 1 = 0110 |
直觉记忆
AND &:两个都是1才得1(取交集:都有才保留)
OR |:有一个是1就得1(取并集:有一个就保留)
XOR ^:不同为1,相同为0(取差集/翻转:不一样才保留)
NOT ~:0变1,1变0(取补集)
2.6.3 常用位操作模板
// ===== 单个位的操作(第 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;
}
逐步追踪 lowbit(12):
12 的二进制: 0000 1100
-12(补码): 1111 0100
12 & (-12): 0000 0100 = 4(最低有效位)
2.6.4 用整数表示集合(状压)
当元素数量 N ≤ 30 时,可以用一个 int 整数的 N 个二进制位表示一个集合。
编码规则: 第 i 个元素在集合中 ↔ 第 i 位为 1。
// 集合 {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}
2.6.5 枚举所有子集
N 个元素的集合有 2^N 个子集,可以用 for (int s = 0; s < (1 << n); s++) 枚举。
// 枚举 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 的所有子集
// 枚举 S 的所有非空子集(时间:O(3^n),见下面说明)
for (int sub = S; sub > 0; sub = (sub - 1) & S) {
// 处理子集 sub
// ...
}
// 原理:sub = (sub - 1) & S 每次在 S 的范围内减 1,跳过不属于 S 的位
为什么是 O(3^n)? 每个位有三种情况:在 S 中但不在 sub 中(1)、在 S 中且在 sub 中(2)、不在 S 中(自动跳过,只 1 种)。总计 2^(|S|) × 1^(n-|S|) 求和 = 3^n。
2.6.6 实战例题:状压 + 位运算
例题:集合和问题
给定 N 个数字(N ≤ 20),找有多少个子集的和恰好等于目标值 target。
#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,求最短路。
#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 表示空集 |
💪 练习题
🟢 题目 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) 内找最大异或值。
💡 章节联系: 位运算是状压 DP(第 6.3 章进阶 DP)的基础工具,也用于树状数组的 lowbit 操作(第 5.7 章)。掌握本章后,你将能读懂绝大多数竞赛代码中的位运算技巧。
🏗️ 第三部分:核心数据结构
几乎出现在每一道 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.4~5.7 章)。
学完本部分后能解决什么问题
完成第三部分后,你将能够挑战:
-
USACO Bronze: 大多数 Bronze 题目使用第三部分的技术
- 区间查询(位置 L 到 R 之间 X 类型的奶牛有多少头?)
- 排序问题(最近点对、排名、调度)
- 频率统计(每个值出现多少次?)
- 栈相关问题(括号匹配、单调处理)
-
USACO Silver 入门:
- 二分答案(攻击性奶牛、绳子切割)
- 滑动窗口最大/最小值
- 差分数组实现区间更新
引入的关键算法
| 技术 | 章节 | USACO 相关度 |
|---|---|---|
| 一维前缀和 | 3.2 | 品种统计、区间查询 |
| 二维前缀和 | 3.2 | 网格上的矩形区域求和 |
| 差分数组 | 3.2 | 区间更新、单点查询 |
带自定义比较器的 std::sort | 3.3 | 几乎所有 Silver 题目 |
二分查找(lower_bound、upper_bound) | 3.3 | 计数、区间查询 |
| 二分答案 | 3.3 | 攻击性奶牛、画家分区 |
| 单调栈 | 3.5 | 下一个更大元素、直方图 |
| 滑动窗口(单调队列) | 3.5 | 窗口最小/最大值 |
频率映射(unordered_map) | 3.7 | 统计出现次数 |
| 有序集合操作 | 3.8 | 第 K 小元素、区间查询 |
前置条件
开始第三部分前,请确认你能做到:
- 从零编写并编译 C++ 程序(第 2.1 章)
-
正确使用
for循环和嵌套循环(第 2.2 章) -
使用数组和
vector<int>(第 2.3 章)
注意: 第 3.1 章(STL 核心用法)是本部分的第一章,将在后续章节用到之前先教你
std::sort、map、set等关键 STL 容器。
本部分学习建议
- 第 3.2 章(前缀和) 是 Bronze 中测试最频繁的技术。确保你能在 5 分钟内从零实现它。
- 第 3.3 章(二分查找) 介绍「二分答案」——这是 Silver 级别的技术,是普通解法和优秀解法的分水岭。
- 不要跳过练习题。 每章的练习题都是专门为培养所需直觉而精选的。
- 完成第 3.3 章后,你已经具备解决大多数 USACO Bronze 题目的工具。在继续学习前,尝试解 5-10 道 Bronze 题目。
🏆 USACO 技巧: 在 USACO Bronze 级别,最常用的技术是:模拟(第 2.1–2.3 章)、排序(第 3.3 章)和前缀和(第 3.2 章)。掌握这三项,几乎可以解决任何 Bronze 题目。
出发!
第 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_search、lower_bound、accumulate等
3.1.0 STL 工具箱
把 STL 想象成一个工具箱,每种工具都为特定任务设计:
快速参考——该用哪个容器:
| 需求 | 使用 |
|---|---|
| 有序列表,随机访问 | vector |
| 两个值捆绑在一起 | pair |
| 键 → 值映射(有序) | map |
| 唯一元素(有序) | set |
| 键 → 值映射(快速,无序) | unordered_map |
| 唯一元素(快速,无序) | unordered_set |
| LIFO(后进先出) | stack |
| FIFO(先进先出) | queue |
| 快速获取最大/最小值 | priority_queue |
选对工具 = 竞赛编程中解题的一半!
「该用哪个容器?」决策树
图示:STL 容器概览
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_pairvs 花括号: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] = value | O(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) |
| 遍历全部 | 范围 for | O(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());
💡
setvsmultiset: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_map 和 unordered_set —— 基于哈希的速度
普通 map 和 set 是有序的(内部使用平衡二叉搜索树),操作是 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_map比map快 5-10 倍。但它有极少数情况下可能被竞赛中利用的「最坏情况」行为——如果用unordered_mapTLE 且怀疑被 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 经验法则:
- 小类型(
int、char):for (int x : v)—— 复制没问题 - 大类型(
string、pair、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 综合示例:词频统计器
让我们写一个综合运用 map、vector 和 sort 的完整小程序。
问题: 读取 N 个单词,统计每个单词出现次数,然后打印:
- 所有单词及其计数(按字母顺序)
- 出现次数最多的单词
📄 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、[]、size | O(1) 均摊 | 最常用容器,默认选择 |
pair<A,B> | 存储两个值 | .first、.second | O(1) | 图的边、坐标等 |
map<K,V> | 有序键值对 | []、find、count | O(log n) | 频率统计、有序映射 |
set<T> | 有序唯一集合 | insert、count、erase | O(log n) | 去重、范围查询 |
stack<T> | 后进先出 | push、pop、top | O(1) | 括号匹配、DFS |
queue<T> | 先进先出 | push、pop、front | O(1) | BFS、模拟 |
priority_queue<T> | 最大堆 | push、pop、top | O(log n) | 贪心最大/最小、Dijkstra |
unordered_map<K,V> | 哈希映射(无序) | []、find、count | O(1) 均摊 | 大数据快速查找 |
unordered_set<T> | 哈希集合(无序) | insert、count、erase | O(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:什么时候自定义 struct 比 pair 更好?
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:对
set和map,两者都是 O(log N),在检查存在性方面功能等价。count返回 0 或 1(集合无重复),find返回一个可以直接访问元素的迭代器。需要读取值时用find,只需是/否检查时用count。
🔗 与后续章节的联系
- 第 3.4 章(单调栈与单调队列):用于下一个更大元素问题的单调栈;用于滑动窗口最大/最小的单调双端队列
- 第 3.6 章(栈与队列):深入探讨
stack和queue的算法应用——括号匹配、BFS - 第 3.8 章(映射与集合):
map/set的进阶用法——频率统计、multiset - 第 3.3 章(排序):带自定义比较器的
sort与vector和pair一起使用 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,未找到时返回 0while (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单位的产品nameREMOVE 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_bound 和 upper_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(前缀树)通过共享公共前缀存储字符串。单词 "bat"、"car"、"card"、"care"、"cat" 高效共享前缀:"ca" 只存一次,分支到 "r" 和 "t"。双圈节点标记单词结尾。Trie 用于自动补全、拼写检查和字符串匹配。字符串哈希的替代方案参见第 3.7 章(哈希技术)。
第 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 前缀和的思想
数组的前缀和是一个新数组,其中每个元素存储到当前下标为止的累积和。
图示:前缀和数组
上图展示了如何从原始数组构建前缀和数组,以及如何用 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 ✓
容斥原理图示:
📄 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 差分数组
既然你已经看到二维前缀和如何将一维思想扩展到网格,让我们来看对偶操作:差分数组。就像微积分中微分是积分的逆运算,差分数组是前缀和的逆运算——前缀和将点数据累积成区间数据,差分数组将区间操作分解为点标记。
| 方向 | 操作 | 类比 |
|---|---|---|
| 正向:前缀和 | 点值 → 区间和 | 积分 ∫ |
| 逆向:差分数组 | 区间更新 → 点标记 | 微分 d/dx |
这种对偶性很强大:要高效地执行区间更新,只需在差分数组中标记边界,最后取前缀和恢复最终结果。
问题: 从全零开始,执行 M 次更新:「给位置 L 到 R 的所有元素加 V」,然后打印最终数组。
朴素做法每次更新是 O(R-L+1)。用差分数组,每次更新是 O(1),重建是 O(N)。
💡 核心思路: 不是给 [L, R] 中每个位置加 V(慢),而是记录「在位置 L 加 V」和「在位置 R+1 减 V」(快)。之后对这些标记取前缀和,+V 和 -V 在 [L,R] 外相互抵消,净效果恰好是给 [L,R] 加了 V。
📄 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;
vector<long long> diff(n + 2, 0); // 差分数组(多一位空间用于 R+1 情况)
// 第一步:每次 O(1) 处理所有区间更新
for (int i = 0; i < m; i++) {
int l, r, v;
cin >> l >> r >> v;
diff[l] += v; // ← 关键行:标记区间起点
diff[r + 1] -= v; // ← 关键行:标记终点+1 以撤销加法
}
// 第二步:对 diff 取前缀和,重建最终数组
long long running = 0;
for (int i = 1; i <= n; i++) {
running += diff[i];
cout << running;
if (i < n) cout << " ";
}
cout << "\n";
return 0;
}
样例输入:
5 3
1 3 2
2 5 3
3 4 -1
逐步追踪:
📝 索引说明: 以下追踪中 diff 数组使用 1-indexed(即 diff[1]..diff[n+1]),与代码一致。
📄 Code 完整代码
初始状态: diff[1..6] = [0, 0, 0, 0, 0, 0]
更新(1,3,+2): diff[1]+=2, diff[4]-=2
diff[1..6] = [2, 0, 0, -2, 0, 0]
更新(2,5,+3): diff[2]+=3, diff[6]-=3
diff[1..6] = [2, 3, 0, -2, 0, -3]
更新(3,4,-1): diff[3]-=1, diff[5]+=1
diff[1..6] = [2, 3, -1, -2, 1, -3]
前缀和重建:
i=1: running = 0+2 = 2 → result[1] = 2
i=2: running = 2+3 = 5 → result[2] = 5
i=3: running = 5-1 = 4 → result[3] = 4
i=4: running = 4-2 = 2 → result[4] = 2
i=5: running = 2+1 = 3 → result[5] = 3
样例输出:
2 5 4 2 3
复杂度分析:
- 时间:
O(N + M)—— 每次更新O(1),重建O(N) - 空间:
O(N)—— 只需差分数组
⚠️ 常见错误: 将
diff声明为 N+1 而非 N+2。当 R=N 时,需要写入diff[R+1] = diff[N+1],它必须存在!
3.2.7 二维差分数组
就像一维差分数组是一维前缀和的逆,二维差分数组是二维前缀和的逆。它可以在 O(1) 时间内给整个矩形子网格 [r1,c1]..[r2,c2] 加一个值 V。
四角更新
给矩形 [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 // 加回被双重取消的角
这是一维技巧 diff[L] += V; diff[R+1] -= V 的二维类比。所有更新后,对差分数组取二维前缀和以恢复最终值。
💡 核心思路: 四角标记正好是二维前缀和查询公式的逆运算。查询中我们减去两个条带然后加回一个角;更新中我们加上两个条带然后减去一个角。它们是镜像操作!
完整 C++ 实现
📄 查看代码:完整 C++ 实现
// 二维差分数组 — 每次更新 O(1),重建 O(RC)
#include <bits/stdc++.h>
using namespace std;
const int MAXR = 1002, MAXC = 1002;
long long diff[MAXR][MAXC]; // 额外行列用于哨兵
void update(int r1, int c1, int r2, int c2, long long V) {
diff[r1][c1] += V; // ← 左上角
diff[r1][c2+1] -= V; // ← 右上+1
diff[r2+1][c1] -= V; // ← 下+1左
diff[r2+1][c2+1] += V; // ← 下+1右+1(加回)
}
int main() {
ios_base::sync_with_stdio(false);
cin.tie(NULL);
int R, C, M;
cin >> R >> C >> M;
memset(diff, 0, sizeof diff);
// 第一步:每次 O(1) 应用所有矩形更新
for (int i = 0; i < M; i++) {
int r1, c1, r2, c2;
long long V;
cin >> r1 >> c1 >> r2 >> c2 >> V;
update(r1, c1, r2, c2, V);
}
// 第二步:通过二维前缀和重建 — O(R × C)
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];
// 现在 diff[r][c] 存储 (r,c) 处的最终值
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;
}
工作示例
一个 3×3 网格,初始全零,两次更新:
update(1,1, 2,2, +5)—— 给左上 2×2 块加 5update(2,2, 3,3, +3)—— 给右下 2×2 块加 3
标记 diff[][] 后:
c=0 c=1 c=2 c=3 c=4
r=0: 0 0 0 0 0
r=1: 0 +5 0 -5 0
r=2: 0 0 +3 0 -3
r=3: 0 -5 0 +5 0
r=4: 0 0 -3 0 +3
二维前缀和重建后:
c=1 c=2 c=3
r=1: 5 5 0
r=2: 5 8 3
r=3: 0 3 3
验证:单元格 (2,2) = 5+3 = 8 ✓(被两次更新都覆盖)。
复杂度分析:
- 更新时间: 每次矩形
O(1)—— 只有 4 次加法 - 重建时间:
O(R × C)—— 一次二维前缀和遍历 - 空间:
O(R × C)
⚠️ 常见错误: 将差分数组声明为
diff[R+1][C+1]而非diff[R+2][C+2]。当r2=R、c2=C时,需要写入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 章常见错误
- 区间查询差一: 写
P[R] - P[L]而不是P[R] - P[L-1]。始终用小例子验证。 - 溢出: 大值的前缀和可能超过
int范围(2×10^9)。即使元素是int,前缀数组也要用long long。 - 二维查询公式: 忘了二维查询中的
+P[r1-1][c1-1]项——非常容易疏忽。 - 差分数组大小: 声明
diff[n+1]但需要diff[n+2](因为要写入下标r+1,可能是n+1)。 - 1-indexed vs 0-indexed: 用 0-indexed 前缀和时,查询公式变为
P[R+1] - P[L]。在一道题内选定一种约定并坚持用。 - 二维差分数组大小: 声明
diff[R+1][C+1]但需要diff[R+2][C+2]——四角更新会写入(r2+1, c2+1),必须在范围内。 - 二维差分重建顺序: 二维前缀和重建必须从左到右、从上到下处理单元格(与构建二维前缀和的顺序相同)。顺序混乱会产生错误结果。
本章总结
📌 核心要点
| 技术 | 构建时间 | 查询时间 | 空间 | 使用场景 |
|---|---|---|---|---|
| 一维前缀和 | 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:它们是逆运算。对数组取前缀和得到前缀和数组;对前缀和数组取差分(相邻元素差)则恢复原数组。反过来,对差分数组取前缀和也能恢复原数组。这类似于数学中的积分和微分。
Q2:什么时候用前缀和 vs 差分数组?
A:经验法则——看操作类型:
- 多次区间求和查询 → 前缀和(预处理
O(N),查询O(1))- 多次区间加减操作 → 差分数组(更新
O(1),最后恢复O(N))- 两种操作交替出现时,需要更高级的数据结构(如第 3.9 章的线段树)
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 章(排序与搜索):二分查找可以与前缀和结合——例如在前缀和数组上二分查找第一个 ≥ 目标值的位置
- 第 3.9 章(线段树):解决前缀和无法处理的「动态更新 + 区间查询」问题
- 第 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) 空间。
🏆 挑战题:奶牛与油漆桶 N×M 网格中有油漆桶,每个有一个正值。选择任意矩形子网格。得分 = (子网格最大值) - (边界格子之和)。找最优矩形。(N, M ≤ 500)
✅ 解题思路
朴素枚举所有 O(N²M²) 矩形对 500² 来说太慢。改进方案:
- 用二维前缀和实现 O(1) 求和查询
- 对子网格中的最大值:预处理二维稀疏表(或每行 RMQ)实现 O(1) 最大值查询
- 边界和 = 总和 - 内部和(均通过前缀和)
总计:O(N²M²) 枚举 × O(1) 每次查询 = 对 N,M ≤ 500 在时间限制内。
第 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 底层的核心算法之一,关键思想是分治:
- 选择一个枢轴元素(通常是最后一个元素)
- 分区: 将所有 ≤ 枢轴的元素移到左边,> 枢轴的移到右边;枢轴落到最终位置
- 对左右子数组递归
[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] ✓
查看参考实现
// 对 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::sort | O(N log N) | O(log N) | ❌ | sort() |
| std::stable_sort | O(N log² N)* | O(N) | ✅ | stable_sort() |
| std::partial_sort | O(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)。
图示:排序算法对比
这张图对比了常见排序算法的时间复杂度、空间占用和稳定性,帮助你在不同场景选择合适的算法。
计数排序 —— 小值域的 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)时间内二分找到假和真之间的边界。
图示:二分查找示例
上图展示在 [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? 如果 lo 和 hi 都很大(接近 INT_MAX),lo + hi 会溢出!这个写法等价但安全。
STL 方式:lower_bound 和 upper_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 中最强大、最常考的技术之一。核心思想:
不是在数组中搜索某个值,而是在答案空间本身进行二分查找。
什么情况下适用? 当:
- 答案是某个范围 [lo, hi] 内的数字
- 有一个
canAchieve(X)函数检查 X 是否可行 - 该函数单调:若 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 章常见错误
- 比较器方向错误: lambda 必须在
a应该排在b前面时返回true。若a == b时返回true,会导致未定义行为(严格弱序违规)。 - 在未排序数组上二分查找:
lower_bound和upper_bound假设已排序。对未排序数据,结果毫无意义。 - 二分查找差一错误:
lo <= hi和lo < hi有区别。拿不准时,在 1 个和 2 个元素的数组上测试你的二分查找。 - 「二分答案」中答案范围错误: 若答案可能是 0,设
lo = 0而非lo = 1。若可能很大,确保hi足够大(必要时用long long)。 - 中间值计算整数溢出: 始终写
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_search | O(log N) | 返回 bool |
| 第一个 >= x 的下标 | lower_bound | O(log N) | 返回迭代器 |
| 第一个 > x 的下标 | upper_bound | O(log N) | 返回迭代器 |
| 统计值 x 的个数 | ub - lb | O(log N) | |
| 二分答案 | 手写 BS + check() | O(f(N) log V) | V = 答案范围 |
| 坐标压缩 | sort + unique + lower_bound | O(N log N) | 将大值映射到小下标 |
🧩 二分查找模板速查
| 场景 | 循环条件 | lo/hi 初值 | 更新规则 | 答案 | 参考小节 |
|---|---|---|---|---|---|
| 最大化满足条件的值 | while (lo <= hi) | lo=最小,hi=最大 | check(mid) → ans=mid, lo=mid+1 | ans | §3.3.5 |
| 最小化满足条件的值 | while (lo < hi) | lo=最小,hi=最大 | check(mid) → hi=mid | lo(循环结束时) | §3.3.7 |
| 浮点数二分查找 | 循环 100 次 | lo=最小,hi=最大 | check(mid) → hi=mid 否则 lo=mid | lo ≈ 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 <= hi 和 lo < 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 章:双指针与滑动窗口
📝 前置条件: 你应该熟悉数组、向量和
std::sort(第 2.3–3.3 章)。经典双指针方法需要已排序的数组。
双指针和滑动窗口是竞赛编程中最优雅的技巧之一。它们通过利用单调性将朴素 O(N²) 解法转化为 O(N):当一个指针向前移动时,另一个指针无需回头。
3.4.1 双指针技术
核心思想:在有序数组中维护两个下标 left 和 right,根据当前和/窗口大小将它们相向(或同向)移动。
使用场景:
- 在有序数组中找满足给定和的对/三元组
- 检查有序数组中是否存在满足特定关系的两个元素
- 「若能用大小 k 的窗口完成 X,则用大小 k-1 的窗口也能完成」的问题
上图展示两个指针如何向中间收拢,每步都从待考虑的对中消除整行/整列。
滑动窗口变体让两个指针同向移动。满足条件时,从左侧收缩以找到最小窗口:
题目:找出所有和为目标值的对
朴素 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);
}
}
⚠️ 常见错误
- 双指针前忘排序: 配对求和的双指针技术只在有序数组上有效。不排序会遗漏一些对或得到错误答案。
- 找到对时只移动一个指针: 找到匹配的对时,必须同时移动
left++和right--。只移动一个会遗漏一些对(除非不需要考虑重复)。 - 窗口大小差一: 窗口
[left, right]的大小是right - left + 1,不是right - left。 - 忘记处理空答案: 对「最小子数组」问题,将
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 章:单调栈与单调队列
📝 前置条件: 确保你熟悉双指针/滑动窗口(第 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] 逐步追踪:
📄 
数组 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]:
💡 公式:
宽度 = 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) —— 维护一个递减双端队列(队首 = 当前窗口最大值)。
💡 核心思路: 我们想要滑动窗口的最大值。维护一个下标的双端队列,使得:
- 双端队列中的值递减(队首始终是最大值)
- 双端队列只包含当前窗口内的下标
当新元素到来时:
- 从队尾移除所有更小的元素(只要这个新元素在窗口中,它们就不可能是最大值)
- 若队首已超出当前窗口则移除
逐步追踪
📄 查看代码:逐步追踪
数组 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 章常见错误
-
存值而非下标 —— 始终存下标。你需要用它们检查窗口范围和记录答案。
-
双端队列中比较用
<而非<=—— 滑动窗口求最大值时,A[dq.back()] <= A[i]时弹出(严格非增)。求最小值时,A[dq.back()] >= A[i]时弹出。 -
忘记窗口过期检查 —— 滑动窗口双端队列中,记录最大值前始终检查
dq.front() < i - k + 1(或<= i - k)。 -
栈的底顶方向搞混 —— 「单调」性质指:从底到顶,栈是递增的(用于 NGE)或递减的(用于 NSE)。搞混时画图辅助。
-
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 章:栈、队列与双端队列
📝 前置条件: 了解基本的 C++ 数组和循环(第 2.1–2.2 章)。无进阶前提——这些是竞赛编程中随处可见的基础构件。
这三种数据结构控制着元素被处理的顺序。各自独特的「个性」使它们在特定类型的问题中表现出色。
- 栈(Stack): 后进先出(像一叠盘子)
- 队列(Queue): 先进先出(像商店排队)
- 双端队列(Deque): 两端均可插入/删除
3.6.1 栈深度解析
我们在第 3.1 章介绍过 stack,现在用它解决实际问题。
图示:栈的操作
上图通过逐步压入和弹出操作展示了 LIFO(后进先出)性质。注意 pop() 总是移除最近压入的元素——这正是栈在括号匹配、DFS 和撤销操作中无可替代的原因。
以下是三种容器的对比——访问模式决定了各自适合不同的问题:
括号匹配问题
题目: 给定括号字符串 ()[]{}, 判断是否正确嵌套。
📄 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 章详细讲解。这里先聚焦队列本身及相关模式。
图示:队列操作
队列按到达顺序处理元素:队首元素始终最先出队,新元素从队尾加入。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 | 混淆 stack 和 deque | stack 只能访问顶部,不能遍历中间元素 | 需要两端操作时改用 deque |
本章总结
📌 核心要点
| 结构 | 操作 | 关键使用场景 | 为什么重要 |
|---|---|---|---|
stack<T> | push/pop/top — O(1) | 括号匹配、撤销/重做、DFS | LIFO 逻辑的核心工具 |
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;需要两端操作(如滑动窗口需要队首删除 + 队尾添加),用deque。stack内部其实是以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 的滑动窗口极值优化)直接使用本章的单调双端队列
- 单调栈也可以作为第 3.9 章(线段树)的替代——许多能用线段树解决的问题也可以用单调栈以
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 章:哈希技术
📝 前置条件: 了解 STL 容器(第 3.1 章)和字符串基础(第 2.3 章)。本章涵盖哈希原理和竞赛编程的进阶用法。
哈希是竞赛编程中最重要的「工具」之一:它能把复杂的比较问题变成 O(1) 的数值比较。但哈希也是最容易被「hack」的技术——本章既教你如何用好哈希,也教你如何防止被 hack。
3.7.1 unordered_map vs map:内部实现与性能
内部实现对比
| 特性 | map | unordered_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) 内提取任意子串的哈希值:
3.7.4 双重哈希(避免碰撞)
单重哈希(mod M)的碰撞概率约 1/M。对 N 次子串比较,预期碰撞次数约 N²/(2M)。
下图展示了两种经典的碰撞处理方式——链式法(unordered_map 内部使用)和线性探测:
- 若
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;
}
⚠️ 常见错误
-
模数选择不当: 不要用
10⁹+7以外的数;尤其避免非质数模数(碰撞率高)。推荐:10⁹+7和10⁹+9作为双重哈希对。 -
unordered_map被 hack: 在 Codeforces 等平台上,默认哈希可被攻击。始终使用custom_hash。 -
子串哈希相减下溢:
h[r+1] - h[l] * pw[r-l+1]在有符号整数下可能为负。使用unsigned long long自然溢出,或用(... % M + M) % M确保非负。 -
底数与字符集不匹配: 对仅含小写字母(26 种)的情况,BASE 必须 > 26(通常用 31 或 131)。对全 ASCII 字符(128 种),BASE 必须 > 128(用 131 或 137)。
-
哈希碰撞导致 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 章:映射与集合
映射和集合是频率统计、查找和跟踪唯一元素的主力工具。本章深入探讨它们在 USACO 题目中的实际应用。
📝 前置条件: 熟悉数组和基础 C++ STL(第 2.4 章)。了解哈希表概念(第 3.7 章)有帮助,但不是严格要求——
map和set基于树结构,不依赖哈希。
3.8.1 map vs unordered_map —— 明智地选择
图示:Map 内部结构(BST)
std::map 将键值对存储在平衡 BST(红黑树)中,所有操作 O(log N) 且键自动有序——当你需要 lower_bound/upper_bound 查询时非常有用。只需 O(1) 查找且不需要顺序时,用 unordered_map。
map 和 unordered_map 的关键结构差异:
| 特性 | map | unordered_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 章常见错误
| # | 错误 | 错在哪里 | 修复方法 |
|---|---|---|---|
| 1 | map[key] 访问不存在的键 | 自动创建值为 0 的条目,污染数据 | 先用 m.count(key) 或 m.find(key) 检查 |
| 2 | multiset::erase(value) 删除所有相等值 | 本想删一个,却删光了 | 用 ms.erase(ms.find(value)) 只删一个 |
| 3 | 遍历时修改 map/set 大小 | 迭代器失效,崩溃或跳过元素 | 用 it = m.erase(it) 安全删除 |
| 4 | unordered_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_set | O(1) 查找 |
| 动态有序集合 + 求极值 | set / multiset | O(1) 访问最小/最大值 |
需要 lower_bound / upper_bound | set / map | 只有有序容器支持 |
| 值→下标映射 | map / unordered_map | 坐标压缩等场景 |
❓ 常见问题
Q1:map 的 [] 运算符和 find 有什么区别?
A:
m[key]当键不存在时会自动创建默认值(int 的默认值为 0);m.find(key)只查找,不创建。如果只想检查键是否存在,用m.count(key)或m.find(key) != m.end()。
Q2:multiset 和 priority_queue 都能取极值——用哪个?
A:
priority_queue只能取极值并删除,不支持按值删除。multiset支持查找并删除任意值,更灵活。只需反复取极值时,priority_queue更简单;需要删除特定元素(如滑动窗口移除离开的元素)时,用multiset。
Q3:unordered_map 什么时候比 map 慢?
A:两种情况:① 哈希碰撞严重时(多个键哈希到同一桶),退化到
O(N);② 竞赛中攻击者故意构造数据 hackunordered_map。解决方案:使用自定义哈希函数,或切换到map。
Q4:C++17 结构化绑定 auto &[key, val] 安全吗?竞赛中能用吗?
A:USACO 和大多数竞赛平台支持 C++17,
for (auto &[key, val] : m)可以安全使用。比entry.first/entry.second更简洁。
🔗 与后续章节的联系
- 第 3.3 章(排序与搜索):坐标压缩常与
map结合(值 → 压缩后下标) - 第 3.9 章(线段树):有序
set的lower_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 章)
🎯 学习目标
学完本章后,你将能够:
- 识别「可以用二分答案」的题目特征(三要素)
- 区分「找第一个满足条件」和「找最后一个满足条件」两种模板
- 设计正确的
check函数 - 处理浮点二分和三分法求极值
- 避免整数二分中的边界死循环
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] 中取两个三等分点 lmid 和 rmid:
- 若
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 复杂度) |
⚠️ 常见错误
| 错误 | 原因 | 修复方案 |
|---|---|---|
| 死循环 | while(lo < hi) + lo = mid,当 lo+1==hi 时 mid = lo 死循环 | 改用 while(lo + 1 < hi) |
| 答案差 1 | hi 设置为 max_val 而非 max_val + 1 | hi = 答案上界 + 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.12 章:字符串算法
⏱ 预计阅读时间:60 分钟 | 难度:🟡 中等
前置条件
在学习本章之前,请确保你已掌握:
- 数组的基本操作(第 3.1 章)
string类型的使用(第 2.3 章)
🎯 学习目标
学完本章后,你将能够:
- 理解字符串匹配的朴素算法并分析其效率瓶颈
- 掌握 KMP 算法的核心思想——「前缀函数」的构建
- 用 KMP 在 O(N + M) 时间内解决字符串匹配问题
- 理解 Trie 树(字典树)的结构与操作
- 运用 Trie 树高效检索字符串集合
3.12.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.12.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.12.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.12.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.12.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) | 小数据,代码简单 |
| KMP | O(N + M) | O(M) | 单模式匹配(推荐) |
| Rabin-Karp(字符串哈希) | O(N + M) 平均 | O(1) | 多模式或哈希方便的场景 |
| AC 自动机 | O(N + ΣM) | O(ΣM) | 多模式同时匹配 |
3.12.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.12.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) 找不到反例,你的贪心很可能是正确的。
本部分学习建议
- 贪心最难「验证」。 不像 DP 只需要正确的递推式,贪心需要正确性论证。多练习交换论证证明的草稿。
- 贪心失败时,DP 通常是修复方案。 硬币找零(第 4.1 章)完美地展示了这一点。
- 第 4.2 章有真实的 USACO 题目 —— 仔细研究代码,不只是高层次的想法。
- 贪心 + 二分搜索(第 4.2 章)是频繁出现在 Silver 的强力组合。贪心解决「检查」函数,二分查找最优答案。
💡 核心思路: 排序是大多数贪心算法的引擎,排序标准体现了「贪心选择」——优先选最好的元素。交换论证证明了这个标准是最优的。
🏆 USACO 技巧: USACO Silver 中,若题目问「在约束 Y 下最大化 X」或「达成 Z 的最小代价」,先尝试带贪心检查的二分答案。这个组合解决了相当大比例的 Silver 题目。
第 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.6 | Huffman 编码——贪心建树 | 🟡 核心 |
| §4.1.7 | 排列贪心:自定义排序标准 | 🟡 核心 |
| §4.1.8 | 任务分配:双序列匹配 | 🟡 核心 |
| §4.1.9 | 区间合并 | 🟢 标准 |
| §4.1.10 | 数字与字符串的贪心 | 🟡 标准 |
| §4.1.11 | 后悔贪心(用堆实现撤销) | 🔴 进阶 |
| §4.1.12 | 对抗匹配(田忌赛马) | 🔴 进阶 |
| §4.1.13 | 前缀/后缀贪心与位运算贪心 | 🔴 进阶 |
| 练习题 | 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 会更好。
现在对比用美国硬币 {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 决策路径对比:
🔍 如何识别贪心问题
看到新题时,按以下清单检查:
📄 看到新题时,按以下清单检查:
1. 处理元素时有没有自然的「顺序」或「优先级」?
(如:按截止时间、结束时间、比率、大小排序……)
↓ 有
2. 能证明局部最优选择在全局上是安全的吗?
(交换论证:把贪心选择与任何其他选择互换,永远不会更好)
↓ 能
3. 能找到贪心失败的小反例吗?
↓ 找不到反例
→ 贪心很可能正确。实现并验证。
↓ 找到反例
→ 贪心失败。考虑 DP 或其他方法。
贪心可行的三个信号:
- ① 排序后,有明确的「按此顺序处理」规则
- ② 题目要求一遍扫描最大化/最小化计数或代价
- ③ 子问题独立——选择一个元素不影响剩余选择的「形状」
改用 DP 的三个信号:
- ① 选择之间有交互(选 A 会改变 B 的可用性)
- ② 需要考虑多个未来状态
- ③ 对你尝试的任何贪心规则都能找到反例
4.1.2 交换论证法
交换论证是贪心算法的标准证明技术,回答「怎么证明贪心正确?」这个问题。几乎所有 USACO 的贪心正确性证明都用这个技术。
工作原理
证明模板分四步:
- 假设存在一个在某一步做出与我们的贪心算法不同选择的最优解 O。
- 找到 O 和贪心第一次不同的位置。
- 交换——把贪心的选择放入 O 在该位置的解中。证明结果至少同样好(代价不增,或数量不减)。
- 重复直到 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 → B | w_A × (S + a) | w_B × (S + a + b) | w_A·a + w_B·b + (w_A + w_B)·S + w_B·a |
| B → A | w_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)
图示:贪心交换论证
上图说明了交换论证:若两个相邻元素相对于贪心标准「顺序不对」,交换它们会产生至少同样好的解。通过反复交换,可以把任何解变换成贪心解而不损失价值。
交换论证失败的情况
有时找不到有效的交换——这说明贪心不适用:
- 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))
为什么可以用贪心解决
直觉上:在所有从上一个选定活动之后开始的活动中,接下来该选哪个?结束最早的那个——它「占用」最少的未来时间,为后续活动留下最大空间。
任何其他选择(选结束更晚的活动)只会有害:它阻塞的未来活动至少与结束最早的选择一样多,甚至更多。
这就是贪心选择性质: 局部最优选择(选结束最早的相容活动)导致全局最优解。
图示:活动选择甘特图
甘特图在时间轴上展示所有活动。选中的活动(绿色)不重叠且数量最多,被拒绝的活动(灰色)因与已选活动重叠而跳过。贪心规则是:始终选结束时间最早且不冲突的活动。
贪心算法:
- 按结束时间排序活动
- 每次选择与已选活动相容且结束时间最早的活动
💡 为什么按结束时间排序? 选择结束最早的活动为后续活动留下最多时间。按开始时间排序可能选到开始很早但结束很晚的活动,占用大量时间。
📄 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 最小化变体
本节涵盖三个相关的区间问题,看起来相似但需要微妙不同的贪心策略。
图示:数轴上的区间调度
最大化:最多不重叠区间
这正是 §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 → B | max(0, S + t[A] − d[A]) | max(0, S + t[A] + t[B] − d[B]) |
| B → A | max(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 是最优的。
📄 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 → B | T + a | T + a + b | 2T + 2a + b |
| B → A | T + b | T + b + a | 2T + 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)
经典题二:最大数(拼接贪心)
题目: 给定 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 区间合并
区间合并是另一种经典贪心:将所有重叠区间合并为一组不重叠的区间。
贪心算法:
- 按左端点升序排序区间
- 维护当前合并区间 [curL, curR]
- 对每个新区间 [l, r]:
- 若 l ≤ curR(重叠或相邻):延伸 curR = max(curR, r)
- 否则:完成当前合并区间,开始新的
📄  {
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"
贪心思路: 维护单调栈。从左到右扫描:
- 若栈顶 > 当前数字且还有删除机会:弹出栈顶(删除较大的数字)
- 否则:压入当前数字
- 扫描完后若还有删除机会:从栈的右端删除
📄 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 次操作获得最大利润(支持撤销)
贪心 + 后悔做法:
- 维护最大堆
- 每步:取堆顶 x(最大收益),然后插入
-x(「后悔节点」——撤销此操作的代价) - 若之后从堆中取出
-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] → 用自己最弱的消耗对手最强的(策略性认输,保留更强的马)
📄 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 章常见错误
-
把贪心用在 DP 问题上: 贪心更简单不代表它正确。始终用小反例测试。任意面额的硬币找零是经典陷阱。
-
排序标准用错: 活动选择时按开始时间而非结束时间排序是经典 bug。为什么这样排序的论证(交换论证)才告诉你正确的标准。
-
重叠判断差一:
s >= lastEnd(允许相邻活动)vss > lastEnd(要求有间隔)。检查题目要求哪种。 -
不证明就假设贪心有效: 始终用小例子验证,或简短地交换论证一下。若找不到反例且能草拟贪心选择「安全」的理由,大概率是正确的。
-
忘记排序: 贪心算法几乎总是从排序开始。忘记排序意味着贪心「顺序」不存在。
-
比较器中整数溢出: 按比率
w/t排序时,避免浮点比较。用交叉乘法:w_A * t_B > w_B * t_A。乘法前始终强制转换为long long。 -
在错误的子问题上贪心: 有些问题看起来像「每次选最优元素」,但「最优」取决于未来上下文。若你在第 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 站出发可以完成整圈)
💡 提示
核心思路:若总油量 ≥ 总耗油量,则恰好存在一个有效起始站。贪心扫描:每当累计油箱降至零以下,将起始站重置为下一站。
✅ 完整题解
两个关键定理:
- 可行性: 若
sum(gas) < sum(cost),无解。 - 唯一起始站定理: 若有解,每当从站 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 章:USACO 中的贪心
能用贪心解决的 USACO 题目是最令人满足的——一旦看到那个洞察,代码几乎自己就写出来了。本章通过几道以贪心为关键的 USACO 风格题目来实战演练。
4.2.1 模式识别:这是贪心题吗?
识别贪心问题是最难的部分——它看起来像 DP,闻起来像 DP,但有特殊结构让你能做出局部决策。以下是实用框架。
三问检验法
编码前问自己:
-
能用某种聪明的方式对输入排序吗? 大多数贪心算法从排序开始。若能找到一个自然顺序(按截止时间、结束时间、比率、自定义比较器),你很可能在贪心的正确轨道上。
-
每一步有「自然的」贪心选择吗? 总能找到一个「当下显然最好」的元素/决策,并能论证选它不会关闭更好的未来选项吗?
-
能构造交换论证吗? 若任意两个相邻选择「不符合贪心顺序」,交换它们不会使解变差吗?如果是,通过冒泡排序推理,贪心顺序是最优的。
三个都是 → 尝试贪心。若找到反例 → 改用 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 头奶牛。将奶牛分配给公共汽车,最小化任意奶牛的最大等待时间。
做法:二分答案 + 贪心检查。
📄 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:放牧(贪心观察)
题目: 三头奶牛站在数轴上不同的整数位置 a、b、c。每次移动可以选任意一头奶牛,将它传送到任意空的整数位置。找让三头奶牛处于三个连续整数位置(如 {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 == 1或c - b == 2(b 和 c 相邻或差 1)b - a == 1或b - 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 中的贪心算法通常涉及:
- 排序输入(以某种聪明的方式)
- 用简单的更新规则扫描一次(或两次)
- 偶尔与二分答案结合使用
❓ 常见问题
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.2 | O(V + E) | 连通性、环检测 |
| BFS | 5.2 | O(V + E) | 最短路径(无权) |
| 网格 BFS | 5.2 | O(R × C) | 迷宫问题、洪水填充 |
| 多源 BFS | 5.2 | O(V + E) | 到最近源点的距离 |
| 连通分量 | 5.2 | O(V + E) | 统计不连通区域数量 |
| 树的遍历(前序/后序) | 5.3 | O(N) | 子树聚合 |
| 并查集(DSU) | 5.3 | O(α(N)) ≈ O(1) | 动态连通性 |
| Kruskal 最小生成树 | 5.3 | O(E log E) | 最小生成树 |
| Dijkstra 算法 | 5.4 | O((V + E) log V) | 非负权图的单源最短路 |
| Bellman-Ford | 5.4 | O(V × E) | 含负边的单源最短路;负环检测 |
| Floyd-Warshall | 5.4 | O(V³) | 小图的全对最短路 |
| SPFA | 5.4 | O(V × E) 最坏 | 有队列优化的实用 Bellman-Ford |
前置条件
开始第五部分前,请确认你能做到:
-
使用
vector<vector<int>>存储邻接表(第 2.3–3.1 章) -
使用 STL 中的
queue和stack(第 3.1、3.5 章) - 处理二维数组和网格遍历(第 2.3 章)
- 理解基本的嵌套循环(第 2.2 章)
-
使用
priority_queue(第 3.1 章)——第 5.3 章(Dijkstra)需要
本部分学习建议
- 第 5.1 章主要是准备工作——阅读以了解图的表示,但真正的算法从第 5.2 章开始。
- 第 5.2 章(BFS) 是 USACO Silver 最重要的章节之一。约 1/3 的 Silver 题目涉及网格 BFS。
- BFS 中
dist[v] == -1表示未访问的模式是关键。永远不要在弹出时标记访问——要在压入时标记。 - 第 5.5 章的并查集对连通性问题比 BFS 更快编码。记住那个 15 行的模板——你会经常用到它。
- 第 5.3 章(Dijkstra) 对加权最短路径问题至关重要。用带
priority_queue<pair<int,int>>的标准模板——这是 Silver/Gold 最常见的图算法。
💡 核心思路: 大多数 USACO 图论题实际上是伪装成网格题。网格单元 (r,c) 变成图节点;相邻单元变成边。对这个隐式图做 BFS 就能找到最短路径。
🏆 USACO 技巧: 每当在题目中看到「最短路径」「最少步数」或「最少移动次数」,立刻想到 BFS。每当看到「这两个连通吗?」或「有多少组?」,想到 DSU。
第 5.1 章:图的基础
📝 前置条件: 熟悉数组、向量和基础 C++(第 2–4 章)。了解
struct(第 2.4 章)和vector、pair等 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 节点图用邻接表和邻接矩阵存储的方式:
矩阵中,绿色 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、有向图和稠密图。
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。掌握树等于掌握了图论的一半。
树是满足以下所有条件的图(这些条件互相等价):
- 连通且恰好有 N − 1 条边
- 连通且无环
- 任意两个顶点之间恰好有一条简单路径
- 最小连通:去掉任意一条边就会断开
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]←v 和 adj[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 MST | Kruskal 用边列表;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 章:BFS 与 DFS
📝 前置条件: 确保理解图的表示(第 5.1 章)、队列和栈(第 3.6 章)以及基本的二维数组遍历(第 2.3 章)。
图遍历算法探索从起点可到达的每个节点,是数十种图算法的基础。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 在回溯前尽量深入。带编号的圆圈展示访问顺序,红色虚线箭头展示回溯路径。右侧的调用栈说明了递归如何自然地实现 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 给每个分量标记
策略很简单:
- 从 1 到 N 扫描所有节点
- 找到未访问节点时,这是一个新分量的起点
- 从该节点运行 DFS,给所有可达节点打上相同的分量 ID
- 重复直到所有节点都被标记
📄 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:先进先出)按距离源点的顺序处理节点:
- 从源节点(距离 0)开始
- 访问其所有邻居(距离 1)
- 访问其未访问的邻居(距离 2)
- 继续直到访问了所有可达节点
队列确保距离 d 的所有节点在距离 d+1 的任何节点之前被处理。这种逐层扩展保证了最短路径。
图示:BFS 逐层遍历
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 | 源点到自身的距离为 0 | BFS 的起始点 |
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 距离洪水填充
从中心格子(距离 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 之前将所有源点格子压入队列,距离为 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,它有三个邻居 A、B、C,且这三个邻居此时都已经在队列中(同一 BFS 层)。当我们依次把它们出队时,每一个都会查看 X 并问:"X 被访问过了吗?"
// ❌ 错误:弹出时才标记 → 同一节点会被多次压入
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 的操作 | 本步后队列 |
|---|---|---|---|---|
| 1 | A | false | 压入 X | [B, C, X] |
| 2 | B | 仍是 false! | 再次压入 X | [C, X, X] |
| 3 | C | 仍是 false! | 再次压入 X | [X, X, X] |
| 4 | X | false → 置为 true | — | [X, X] |
| 5 | X | true → 跳过 | — | [X] |
| 6 | X | true → 跳过 | — | [] |
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.4 章(二叉树与树算法):树遍历(前/后序)本质上就是 DFS;第 5.5 章(并查集)处理动态连通性
- 第 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 — USACO 2016 February Bronze:牛奶桶 🔴 困难
题目: 有两个空桶,容量分别为 X 和 Y。可用操作:将任意一桶装满、倒空任意一桶、将一桶倒入另一桶(直到其中一个满或空)。找到任意一桶中恰好有 M 升的最少操作次数。
样例输入: 3 5 4 → 输出: 6
💡 提示
建模为状态 BFS:每个状态是一对 (a, b),其中 a ∈ [0,X]、b ∈ [0,Y] 是当前数量。应用 6 种操作生成邻居状态。BFS 找从 (0,0) 到满足 a==M 或 b==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 只能处理无权图(每步代价相同)。当图的边权只有 0 或 1 时,用 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=6 | 10^6 = 1,000,000 | 2 × 10^3 = 2,000 |
| 分支因子 b=4,距离 d=20 | 4^20 ≈ 10^12 | 2 × 4^10 ≈ 2×10^6 |
5.2.12 变种三:DFS 回溯与剪枝
什么是回溯?
回溯是 DFS 的一种应用模式:系统地枚举所有可能的选择,发现不合法时撤销(回溯)。
三要素:
- 选择:在当前状态下做一个决定
- 递归:进入下一层状态
- 撤销:从递归返回后,撤销这个决定(恢复现场)
模板框架
📄 查看代码:模板框架
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 的一个重要变种是**精确追踪当前在第几层**(第几步),常用于树的层序遍历、按层输出节点等。
// 层序遍历二叉树,每层单独输出
#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/low | O(V+E) |
| IDDFS | 内存受限的最短路 | 递归(限深) | O(b^d),空间 O(d) |
| BFS 分层 | 按层处理节点 | 队列+层快照 | O(V+E) |
| 记忆化 DFS | DAG 上 DP | 递归+备忘录 | O(状态数×转移数) |
变种专项练习题(共 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 和单词列表。每次只能改变一个字母,且改变后的单词必须在列表中。求从 beginWord 到 endWord 的最短转换路径长度,若不存在返回 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.2b 章:函数图(Functional Graphs)
⏱ 预计阅读时间:35 分钟 | 难度:🟡 中等(USACO Silver 重要考点)
前置条件
- BFS 与 DFS(第 5.2 章)
- 图的基本表示(第 5.1 章)
🎯 学习目标
学完本章后,你将能够:
- 识别函数图的特征结构(每节点恰好一条出边)
- 找到函数图中的所有环(环检测)
- 计算图中每个节点距离其所在环的步数
- 解决"从节点 x 出发走 k 步到哪里"的跳跃问题(含快速幂加速)
- 应用到 USACO Silver 中的函数图专题
5.2b.1 什么是函数图?
定义
函数图(Functional Graph) 是每个节点恰好有一条出边的有向图。
node[i] → next[i],每个节点有且仅有一个后继节点。
等价地,函数图就是一个映射 f: {1..N} → {1..N},从 i 走一步到 f(i)。
结构特征:ρ 形(rho 形)
由于每个节点只有一条出边,从任意节点出发最终必然进入环(因为节点有限,走无限步必然重复)。
典型结构(ρ 形):
1 → 2 → 3 → 4 → 5
↑ ↓
8 ← 6
↑
7
节点 5, 6, 8 构成环
节点 1, 2, 3 是"尾巴"(最终进入环)
节点 4 是环的入口
节点 7 指向 8(悬挂在环上)
关键结论:
- 每个连通分量恰好包含一个环
- 其他节点都是"树"状悬挂在环上的
5.2b.2 找环(Floyd 判环 / 着色法)
方法一:着色法(推荐,适合 USACO)
用三种颜色追踪 DFS 状态(与有向图环检测完全相同):
#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 判环(快慢指针)
适合判断单条路径是否有环(不需要找具体哪个节点在环上):
// 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.2b.3 跳 k 步问题(倍增加速)
问题: 从节点 x 出发,走 k 步后到哪里?
朴素做法:循环 k 次,O(k) 可能很慢(k ≤ 10^18)。
倍增加速: 预处理 jump[v][j] = 从 v 出发跳 2^j 步后的节点,类似 LCA 倍增。
#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.2b.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.2b.5 完整例题:奶牛跳跃(USACO Silver 风格)
题目: N 头奶牛编号 1~N,每头奶牛 i 喜欢奶牛 nxt[i](构成函数图)。
定义「喜欢链」:从 i 出发沿喜欢关系走 k 步。
Q 次查询,每次给出 (i, k),输出走 k 步后到达的奶牛编号。
#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)。
✅ 完整解答
直接使用 5.2b.3 的倍增模板(LOG=30 处理 k≤10^9)。
🔴 题目 3:最远公共祖先(函数图上的 LCA)
函数图中,定义节点 u 和 v 的「公共后继」为第一个同时是 u 的后继和 v 的后继的节点。
给 Q 次查询,找 (u, v) 的最近公共后继。
✅ 解题思路
- 先找每个节点所属的环及到环的距离
dist_to_cycle[v] - 若 u, v 在同一个环:最近公共后继就在环上,用倍增在环上找
- 若在不同环:无公共后继(输出 -1)
- 若一个在环外,先让环外的走
dist_to_cycle[v]步进入环,再求环上的公共后继
// 核心:把两节点都走到相同位置后,找第一个相同节点
int lca_in_func_graph(int u, int v) {
// 让 u 和 v 距离环相同(先走到距离较大的那个)
if (dist_to_cycle[u] < dist_to_cycle[v]) swap(u, v);
u = walk(u, dist_to_cycle[u] - dist_to_cycle[v]); // 使两者到环距离相同
// 同步走直到相遇
for (int j = LOG - 1; j >= 0; j--)
if (jump[u][j] != jump[v][j]) { u = jump[u][j]; v = jump[v][j]; }
if (u == v) return u;
return jump[u][0]; // 下一步相遇
}
💡 章节联系: 函数图是 USACO Silver 的独特题型,每季赛约出现 1 道。它结合了图遍历(第 5.2 章)和 LCA 倍增(第 5.4 章)的思想,是 Gold 拓扑排序(第 8.2 章)的前置知识。
第 5.4 章:最短路径
在节点间寻找最短路径是图论中最基础的问题之一。它出现在 GPS 导航、网络路由、游戏 AI 中,对我们来说最重要的是——USACO 题目。本章涵盖四种算法(Dijkstra、Bellman-Ford、Floyd-Warshall、SPFA),并解释何时使用哪种。
5.4.1 问题定义
单源最短路径(SSSP)
给定加权图 G = (V, E) 和源节点 s,找从 s 到所有其他节点的最短距离。
从源点 A:
dist[A] = 0dist[B] = 1dist[C] = 5dist[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 题目。
核心思想:贪心 + 优先队列
Dijkstra 是一个贪心算法:
- 维护一个「已确定」节点集合(最短距离已最终确定)
- 每次处理当前距离最小的未访问节点
- 处理节点时,尝试松弛其邻居(如果找到更短路径则更新距离)
为什么贪心有效: 若所有边权非负,当前距离最小的节点不可能通过其他节点得到改善(所有替代路径 ≥ 当前距离)。
逐步追踪
起点: 节点 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-Ford 或 SPFA。
- 只对非负权重有效。 负边破坏贪心假设。
- 当边权较大时,距离用
long long。dist[u] + w可能溢出int。 - 用
greater<pii>让priority_queue成为最小堆。 if (d > dist[u]) continue;检查对正确性和性能至关重要。
5.4.3 Bellman-Ford 算法
当边权可以是负数时,Dijkstra 失败。Bellman-Ford 处理负边权,甚至能检测负环。
核心思想:松弛 V-1 次
关键洞察:有 V 个节点的图中,任意最短路径最多使用 V-1 条边(不重复节点)。所以若松弛所有边 V-1 次,保证能找到正确的最短路径。
算法:
1. dist[src] = 0,所有其他节点 = INF
2. 重复 V-1 次:
对每条边 (u, v, w):
若 dist[u] + w < dist[v]:
dist[v] = dist[u] + w (松弛!)
3. 检测负环:
若还有任何边能被松弛 → 存在负环!
Bellman-Ford 实现
📄 查看代码: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;
// 松弛所有边 V-1 次
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; // 提前终止:已收敛
}
// 检测负环(再做一次松弛)
for (auto [u, v, w] : edges) {
if (dist[u] != INF && dist[u] + w < dist[v]) {
// 从源点可达的负环!
return {}; // 表示:存在负环
}
}
return dist;
}
负环检测: 负环意味着可以无限减少距离。若第 V 次松弛仍能改善某个距离,该节点在负环上或可从负环到达。
5.4.4 Floyd-Warshall 算法
用于找所有节点对之间的最短路径。
核心思想:通过中间节点的 DP
dp[k][i][j] = 只使用节点 {1, 2, ..., k} 作为中间节点时,从 i 到 j 的最短距离。
递推:
dp[k][i][j] = min(dp[k-1][i][j], // 不经过节点 k
dp[k-1][i][k] + dp[k-1][k][j]) // 经过节点 k
由于只需要上一层,可以折叠为二维:
📄 由于只需要上一层,可以折叠为二维:
// 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) {
// ⚠️ 关键:k 必须是最外层循环!
// 不变量:处理 k 之后,dist[i][j] = 只使用 {1..k} 作中间节点时 i 到 j 的最短路
for (int k = 1; k <= n; k++) { // ← 外层:中间节点
for (int i = 1; i <= n; i++) { // ← 中层:源
for (int j = 1; j <= n; j++) { // ← 内层:目标
// i→k→j 比直接 i→j 更快吗?
if (dist[i][k] != INF && dist[k][j] != INF) {
dist[i][j] = min(dist[i][j], dist[i][k] + dist[k][j]);
}
}
}
}
// Floyd-Warshall 后,若 dist[i][i] < 0 则节点 i 在负环上
}
💡 为什么 k 必须是最外层循环? 当处理中间节点 k 时,
dist[i][k]和dist[k][j]必须已经用中间节点 {1..k-1} 完整计算好。若 k 在内层,这些值可能在同一轮中被更新,破坏 DP 的正确性。记住:k 是最外层,i 和 j 是内层——顺序很重要!
5.4.5 算法对比表
| 算法 | 时间复杂度 | 负边 | 负环检测 | 多源 | 最适合 |
|---|---|---|---|---|---|
| BFS | O(V + E) | ✗ 否 | ✗ 否 | ✓ 是(多源 BFS) | 无权图 |
| Dijkstra | O((V+E) log V) | ✗ 否 | ✗ 否 | ✗(每源运行一次) | 加权非负边图 |
| Bellman-Ford | O(V × E) | ✓ 是 | ✓ 是 | ✗ | 负边、检测负环 |
| SPFA | 最坏 O(V × E),平均 O(E) | ✓ 是 | ✓ 是 | ✗ | 稀疏图含负边 |
| Floyd-Warshall | O(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
SPFA(最短路径快速算法) 是优化版的 Bellman-Ford,只在节点距离更新时才将其加入队列,避免冗余松弛。
⚠️ SPFA 最坏情况: SPFA 最坏时间复杂度是 O(V × E)——与朴素 Bellman-Ford 相同。在精心构造的图(竞赛中常见的「反 SPFA」测试数据)上,SPFA 会退化到 O(VE) 并 TLE。大多数随机/实际情况下很快(平均 O(E)),但对于 USACO,当所有权重非负时优先用 Dijkstra。
📄 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;
}
负环检测:判断全图是否存在负环
上面的 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;
}
5.4.7 Johnson 算法——全源最短路
Floyd-Warshall 是 O(N³),跑 N 次 Bellman-Ford 是 O(N²M)。Johnson 算法通过重新标注边权,将 N 次 Dijkstra 应用于无负权的图,复杂度 O(NM log M),在稀疏图上优于 Floyd。
算法步骤
- 建超级源点 0,向所有节点连 0 权边
- 用 Bellman-Ford 求 0 到所有点的最短路
h[i](若存在负环则无解) - 重新标注边权:
w'(u,v) = w(u,v) + h[u] - h[v](保证非负) - 以每个点为源点跑 N 次 Dijkstra
- 还原答案: 实际最短路 = 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 时,用双端队列代替队列的强力技巧:
双端队列:[队首 → 距离最小 ... → 队尾 → 距离最大]
松弛邻居 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];
}
⚠️ 五大经典 Dijkstra Bug
- 用
int而不是long long—— 距离和溢出 → 静默的错误答案 - 最大堆而非最小堆 —— 忘记
greater<pii>→ 优先处理错误的节点 - 缺少过期条目检查(
if (d > dist[u]) continue)—— 不是错误但慢约 10 倍 - 忘记
dist[src] = 0—— 所有距离保持为 INF - 对负边用 Dijkstra —— 未定义行为,可能无限循环或给出错误答案
本章总结
📌 核心要点
| 算法 | 复杂度 | 处理负边 | 使用场景 |
|---|---|---|---|
| BFS | O(V+E) | ✗ | 无权图 |
| Dijkstra | O((V+E) log V) | ✗ | 非负权重加权 SSSP |
| Bellman-Ford | O(VE) | ✓ | 负边、检测负环 |
| SPFA | 最坏 O(VE),平均快 | ✓ | 稀疏图含负边 |
| Floyd-Warshall | O(V³) | ✓ | 全对、V ≤ 500 |
| 0-1 BFS | O(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.4 章(二叉树与树算法):树上的最短路径就是唯一的根到节点路径(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 章:动态规划入门
第 3.11 章:二叉树
二叉树是竞赛编程中一些最重要数据结构的基础——从二叉搜索树(BST)到线段树再到堆。深刻理解它们将使图论算法、树上 DP 和 USACO Gold 题目变得更容易上手。
3.11.1 二叉树基础
二叉树是一种层级数据结构:
- 每个节点最多有 2 个子节点:左子节点和右子节点
- 恰好有一个根节点(无父节点)
- 每个非根节点恰好有一个父节点
叶节点 — 没有子节点的节点
内部节点 — 至少有一个子节点的节点
高度 — 从根到任意叶节点的最长路径
深度 — 从根到该节点的距离
子树 — 一个节点及其所有后代
图示
在这棵树中:
- 高度 = 2(最长的根到叶路径:A → B → D)
- 根 = A,叶节点 = D, E, F
- B 是 D 和 E 的父节点;D 是 B 的左子节点,E 是 B 的右子节点
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。
三种遍历顺序访问相同的树,但顺序完全不同——各有独特的使用场景:
3.11.2 二叉搜索树(BST)
二叉搜索树是带有关键排序性质的二叉树:
BST 性质: 对每个节点 v:
- 左子树中所有值都严格小于
v.val - 右子树中所有值都严格大于
v.val
[5] ← 有效的 BST
/ \
[3] [8]
/ \ / \
[1] [4] [7] [10]
5 的左边 = {1, 3, 4} — 都 < 5 ✓
5 的右边 = {7, 8, 10} — 都 > 5 ✓
3.11.2.1 BST 搜索
📄 查看代码:3.11.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; // 未找到
}
3.11.2.2 BST 插入
📄 查看代码:3.11.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);
3.11.2.3 BST 删除
删除是最复杂的 BST 操作,有 3 种情况:
- 节点无子节点(叶节点):直接删除
- 节点有一个子节点:用子节点替换该节点
- 节点有两个子节点:用中序后继(右子树中最小值)替换,然后删除后继
📄 3. **节点有两个子节点**:用**中序后继**(右子树中最小值)替换,然后删除后继
// 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;
}
3.11.2.4 BST 退化问题
下图展示了 BST 插入操作——搜索路径在每个节点处遵循 BST 性质,直到找到空位:
⚠️ 关键问题: 如果按有序顺序插入(1, 2, 3, 4, 5...),BST 会退化为链表:
[1]
\
[2]
\
[3] ← 每次操作 O(N),不是 O(log N)!
\
[4]
\
[5]
这就是平衡 BST(AVL 树、红黑树)存在的原因。C++ 中 std::set 和 std::map 用红黑树实现——始终保证 O(log N)。
std::set / std::map 代替手写 BST。它们始终保持平衡。学习 BST 基础是为了理解它们为什么有效,竞赛中直接用 STL(见第 3.8 章)。
3.11.3 树的遍历
遍历 = 恰好访问每个节点一次。有 4 种基本遍历:
| 遍历 | 顺序 | 使用场景 |
|---|---|---|
| 前序 | 根 → 左 → 右 | 复制树、前缀表达式 |
| 中序 | 左 → 根 → 右 | BST 有序输出 |
| 后序 | 左 → 右 → 根 | 删除树、后缀表达式 |
| 层序 | BFS 按层 | 最短路、层级操作 |
3.11.3.1 前序遍历
📄 查看代码:3.11.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);
}
}
3.11.3.2 中序遍历
📄 查看代码:3.11.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;
}
}
3.11.3.3 后序遍历
📄 查看代码:3.11.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; // 最后删这个节点(安全:子节点已删)
}
3.11.3.4 层序遍历(BFS)
📄 查看代码:3.11.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
3.11.4 树的高度与平衡
3.11.4.1 计算树的高度
📄 查看代码:3.11.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 表示当前节点
}
3.11.4.2 检查平衡
平衡二叉树要求每个节点的左右子树高度差不超过 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;
}
3.11.4.3 节点计数
📄 查看代码:3.11.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);
}
3.11.5 最近公共祖先(LCA)——暴力方法
有根树中两个节点 u 和 v 的 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
📄 查看代码: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;
}
💡 USACO 说明: 对于 USACO Silver 题目,O(N) 暴力 LCA 并非总是够用。N ≤ 10^5 个节点且 Q ≤ 10^5 次查询时,总计 O(NQ) = O(10^10)——太慢了。只在 N, Q ≤ 5000 时使用暴力。本章 §5.5.1 讲解 O(log N) 的二进制倍增 LCA 用于更难的题目。
3.11.6 完整 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;
}
3.11.7 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)
3.11.8 从遍历序列重建树
经典题:给定前序和中序遍历,重建原始树。
核心思路:
- 前序数组的第一个元素始终是根
- 在中序数组中,根将其分为左右子树
📄 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);
}
⚠️ 常见错误
空指针崩溃:
📄 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
- 忘记
nullptr基础情况 —— 立即导致段错误 - 插入/删除后没有返回(可能是新的)根节点 —— 树结构损坏
- 栈溢出 —— N > 10^5 时用迭代遍历
- 内存泄漏 —— 始终
delete删除的节点(或用智能指针) - 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(左高, 右高) + 1 | O(N) |
| LCA(暴力) | 找路径再比较 | 每次查询 O(N) |
| LCA(二进制倍增) | 预处理 2^k 祖先 | 预处理 O(N log N),查询 O(log N) |
| 欧拉序 | DFS 时间戳展平树 | 预处理 O(N),子树查询 O(1)~O(log N) |
❓ 常见问题
Q1:什么时候用 BST vs std::set?
A:竞赛编程中几乎始终用
std::set。std::set由红黑树(平衡 BST)支持,保证O(log N);手写 BST 可能退化到O(N)。只在需要自定义 BST 行为时(如追踪子树大小来查询「第 K 大」)才考虑手写,或使用__gnu_pbds::tree(策略树)。
Q2:线段树和 BST 是什么关系?
A:线段树(第 3.9 章)是完全二叉树,但不是 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 几乎必考。本章 §3.11.5 的暴力 LCA 处理 N ≤ 5000 的情况;§5.5.1 的二进制倍增 LCA 处理 N, Q ≤ 5×10^5 的大型树,是竞赛必备。
🔗 与其他章节的联系
- 第 2.3 章(函数与数组):递归基础——二叉树遍历是递归的完美应用
- 第 3.8 章(映射与集合):
std::set/std::map由平衡 BST 支持;理解 BST 能更好地使用它们 - 第 3.9 章(线段树):线段树是完全二叉树;build/query/update 的递归结构与 BST 遍历完全相同
- 第 5.2 章(图论算法):树是特殊的无向图(连通无环);所有树算法都是图算法的特例
- §5.5.1 LCA 倍增 + §5.5.2 欧拉序:直接建立在本章树遍历基础上,是 Gold 级核心技术
练习题
题目 3.11.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) 递归栈。
题目 3.11.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)。
题目 3.11.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)。
题目 3.11.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 时取下中位数
}
大树优化: 用题目 3.11.2 的第 K 小方法直接查找——无需展平:kthSmallest(root, (n+1)/2),节省 O(N) 内存。
复杂度: O(N) 时间和空间(或用第 K 小方法 O(H + N/2))。
题目 3.11.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 LCA 进阶:二进制倍增(O(log N))
本节将 §3.11.5 的朴素 LCA 升级为 O(N log N) 预处理 + O(log N) 查询,是 USACO Gold 的必备技术。
核心思想
朴素 LCA 每次查询最多爬 O(N) 步太慢。二进制倍增预先存储:
anc[v][k] = v 的第 2^k 个祖先
将 N 步拆成最多 log N 次「跳跃」,每次跳 2 的幂次。
构建 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。
复杂度对比
| 方法 | 预处理 | 单次查询 | 适用场景 |
|---|---|---|---|
| 朴素爬树(§3.11.5) | O(N) | O(N) | N ≤ 5000,代码简单 |
| 二进制倍增 | O(N log N) | O(log N) | N, Q ≤ 5×10^5,USACO Gold |
| Euler Tour + RMQ | O(N log N) | O(1) | 超高频查询(超竞赛范围) |
5.5.2 欧拉序(DFS 时间戳)
欧拉序将树「展平」为一个线性数组,将子树查询转化为区间查询,从而用线段树或树状数组 O(log N) 回答。
核心思想
DFS 时给每个节点记录进入时间 in[u] 和退出时间 out[u]:
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 补充练习题
题目 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 个节点的无权无向树,求树的直径(任意两点间最长路径的长度)。
注: 题目 3.11.3 只处理二叉树结构。本题处理通用树(每个节点可有任意多个子节点)。
样例:
输入:5
1 2 / 1 3 / 3 4 / 3 5
输出:3(路径 2-1-3-4 或 2-1-3-5)
✅ 完整题解
思路: 两次 BFS——第一次找最远点 u,第二次从 u 出发找直径。
#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.1 的 LCA 二进制倍增实现:
// 见 §5.5.1 完整实现(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 章:并查集
📖 第 3.13 章:并查集
⏱ 预计阅读时间:60 分钟 | 难度:🟡 中等
前置条件
在学习本章之前,请确保你已掌握:
- 数组与函数(第 2.3 章)
- 图的基本概念——节点、边、连通(第 5.1 章)
🎯 学习目标
学完本章后,你将能够:
- 用「路径压缩 + 按秩合并」实现 O(α(n)) 的并查集
- 用并查集判断图的连通性和环
- 实现带权并查集解决差值/关系问题
- 用种类并查集解决多关系分组问题
- 独立完成 10 道从基础到挑战的练习题
3.13.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/DFS | O(N+M) | O(1) |
维护 group[] 数组 | O(1) | O(N)(需遍历更新) |
| 并查集 | O(α(N)) ≈ O(1) | O(α(N)) ≈ O(1) |
当 N、M 高达 10^5,且操作交替出现时,并查集是唯一实用的选择。
3.13.2 核心思想:用「树」表示一个集合
关键洞察: 把同一个连通块里的服务器,组织成一棵树。树的根节点作为这个集合的「代表」。
- 判断两台服务器是否连通:看它们是否在同一棵树(根节点相同)
- 合并两个连通块:把一棵树的根,指向另一棵树的根
用一个 pa[](parent,父节点)数组来表示这片森林:
关键观察:
- 每一次
unite都是把一棵树的根指向另一棵树的根,而不是任意两个节点直接相连 unite(2, 3)实际执行的是pa[find(3)] ← find(2),即pa[3] ← 1,所以合并后4仍然挂在3下(而不是直接挂到1下)- 要让
4直接挂到根1下,需要借助 3.13.4 节的路径压缩
3.13.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 下面
}
3.13.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]);
}
3.13.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。实践中视为常数。
3.13.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,已连通(无需新增)
3.13.7 进阶:带权并查集
问题引入
有 N 名学生,班主任陆续告诉你:「B 同学比 A 同学高 D 厘米(即 height[B] - height[A] = D)」。
你需要回答:
- B 比 A 高多少厘米?
- 某条信息是否与之前的矛盾?
朴素思路: 用图建模,但查询每次都要 BFS 遍历路径,O(N) 每次查询太慢。
带权并查集的思路: 在每个节点存储「它到根节点的高度差 dist[x]」,查询时直接用 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;
}
3.13.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;
}
⚠️ 常见错误
| 错误 | 原因 | 修复方案 |
|---|---|---|
| 带权并查集路径压缩后权值错误 | 没在递归中累加 dist | dist[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 的结果。
提示: 普通并查集不支持「断边」。解决方案:离线倒序处理——将操作逆序执行,把「断边」变成「加边」。
✅ 完整解答
核心思路:
- 先记录所有操作
- 从后向前处理:
block变成connect,正向的connect但时间上在block之前需要排除 - 用并查集 + 离线逆序处理,输出时逆序输出 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 是否在同一集合,输出 Y 或 N)。
✅ 完整解答
这是标准并查集裸题,直接套模板:
#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 条信息,格式同 3.13.8 节。
求假话数量。
✅ 完整解答
直接使用 3.13.8 节的「种类并查集」代码,注意细节:
- 自己吃自己(x == y 且 type=2):假话
- 编号越界(x > n 或 y > n):假话
- 矛盾检测先于合并
// 直接使用 3.13.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
完整代码见 3.13.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 和信奥中频繁出现,建议重点掌握。
第 3.9 章:线段树入门
📝 前置条件: 理解前缀和(第 3.2 章)、数组和递归(第 2.3 章)。线段树是较进阶的数据结构——确保熟悉递归后再深入学习。
线段树是竞赛编程中最强大的数据结构之一,解决了前缀和无法处理的根本问题:带更新的区间查询。
3.9.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)。
3.9.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]) 时蓝色高亮的访问路径:
3.9.3 构建线段树
📄 查看代码:3.9.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
3.9.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)!
下图展示了哪些节点被访问以及原因——绿色节点直接返回其值,橙色节点递归进入子节点,灰色节点立即被剪枝:
3.9.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) 个节点,其他所有分支保持不变:
3.9.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)
3.9.7 线段树 vs 树状数组(BIT)
| 特性 | 线段树 | 树状数组(BIT) |
|---|---|---|
| 代码复杂度 | 中等(约 30 行) | 简单(约 15 行) |
| 区间查询 | 任意结合性操作 | 仅限前缀和 |
| 区间更新 | 可以(需懒惰传播) | 可以(需技巧) |
| 单点更新 | O(log N) | O(log N) |
| 空间 | O(4N) | O(N) |
| 使用场景 | 区间最小/最大、复杂查询 | 带更新的前缀和 |
💡 核心思路: 若需要带更新的区间和,树状数组更简单。若需要区间最小值、区间最大值,或任何非前缀操作,用线段树。
3.9.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));
}
3.9.9 带懒惰传播的区间更新
前面的线段树处理单点更新。对于区间更新:「给 [L, R] 的所有元素加 V」怎么办?
没有懒惰传播,需要 O(N) 次更新(每个元素一次)。有了懒惰传播,实现 O(log N) 区间更新。
💡 核心思路: 不立即更新所有受影响的叶节点,而是「懒惰地」推迟更新——在适用的最高节点存储更新,只在真正需要子节点时才向下传递。
每个节点现在存储两个值:
tree[node]:该区间的实际聚合值(区间和)lazy[node]:尚未向子节点传递的待处理更新
向下传递规则: 访问有待处理懒惰更新的节点时:
- 将懒惰更新应用到该节点的值
- 将懒惰更新传递给两个子节点(向下传递)
- 清除该节点的懒惰值
📄 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:
- 递归前忘记
pushDown—— 子节点会在父节点的懒惰之上再接收子节点自己的,导致查询结果错误 - 大小乘数用错 —— 写
tree[node] += val而非tree[node] += val * (end - start + 1)。节点存的是和,给(end-start+1)个元素各加 val 意味着和增加val×(大小) - 未将
lazy[]初始化为 0 —— 用memset(lazy, 0, sizeof(lazy))或在build()中初始化 - 混合不同操作的懒惰 —— 若同时有「区间加」和「区间乘」两种懒惰,顺序很重要,需要两个独立的懒惰数组和仔细处理的 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 |
3.9.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 数组,处理优先级。
3.9.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$。
3.9.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) 每版本 |
⚠️ 常见错误
- 数组大小太小: 始终分配
tree[4 * MAXN]。对非 2 的幂次方大小的数组,用2 * MAXN会越界。 - 范围外的单位元用错: 求和查询返回 0;求最小查询返回
INT_MAX;求最大查询返回INT_MIN。 - 忘记更新父节点: 更新子节点后,必须重新计算父节点:
tree[node] = tree[2*node] + tree[2*node+1]。 - 0-indexed vs 1-indexed 混淆: 本实现使用 0-indexed 数组但 1-indexed 树节点,保持一致性。
- 前缀和足够时用线段树: 若没有更新操作,前缀和(
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)查询 vsO(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 题目
练习题
题目 3.9.1 — 经典区间和 🟢 简单 实现线段树,处理 N 个元素和 Q 次查询:单点更新或区间求和。
提示
使用 3.9.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)。
题目 3.9.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](相同)。唯一改变的是聚合函数和单位元。
题目 3.9.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:围牛栏 需要带更新的区间最大查询的题目。尝试分别用树状数组和线段树解决,理解两者的权衡。
第 3.10 章:树状数组(BIT)
📝 前置条件: 了解前缀和(第 3.2 章)和位运算。本章与线段树(第 3.9 章)互补——BIT 代码更短,常数更小,但支持的操作更少。
树状数组(又名二进制索引树 / BIT)是竞赛编程中最常用的数据结构之一:不到 15 行代码,却能在 O(log N) 时间内支持单点更新和前缀查询。
3.10.1 核心思想:什么是 lowbit?
lowbit 的位运算原理
对任意正整数 x,lowbit(x) = x & (-x) 返回 x 的二进制表示中最低位 1 所代表的值。
x = 6 → 二进制:0110
-x = -6 → 补码:1010(按位取反 + 1)
x & (-x) = 0010 = 2 ← 最低位 1 对应 2^1 = 2
示例:
| x | 二进制 | -x(补码) | x & (-x) | 含义 |
|---|---|---|---|---|
| 1 | 0001 | 1111 | 0001 = 1 | 管理 1 个元素 |
| 2 | 0010 | 1110 | 0010 = 2 | 管理 2 个元素 |
| 3 | 0011 | 1101 | 0001 = 1 | 管理 1 个元素 |
| 4 | 0100 | 1100 | 0100 = 4 | 管理 4 个元素 |
| 6 | 0110 | 1010 | 0010 = 2 | 管理 2 个元素 |
| 8 | 1000 | 1000 | 1000 = 8 | 管理 8 个元素 |
BIT 树结构直觉
BIT 的精妙之处:tree[i] 不存储单个元素,而是存储一段区间的和,长度恰好是 lowbit(i)。
BIT 结构(n=8):每个 tree[i] 覆盖恰好 lowbit(i) 个以下标 i 结尾的元素。
查询 prefix(7) 的跳转路径(i -= lowbit(i) 向下跳):
💡 跳转规律: 查询时
i -= lowbit(i)(向下跳),更新时i += lowbit(i)(向上跳)。每次跳转消除最低位的 1,最多 log N 步。
下标 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)
更新位置 3 的跳转路径(i += lowbit(i) 向上跳):
查询 prefix(7) 时,通过 i -= lowbit(i) 向下跳:
i=7:加tree[7](管理 A[7]),然后7 - lowbit(7) = 7 - 1 = 6i=6:加tree[6](管理 A[5..6]),然后6 - lowbit(6) = 6 - 2 = 4i=4:加tree[4](管理 A[1..4]),然后4 - lowbit(4) = 4 - 4 = 0,停止
共 3 步 = O(log 7) ≈ 3 步。
更新位置 3 时,通过 i += lowbit(i) 向上跳:
i=3:更新tree[3],然后3 + lowbit(3) = 3 + 1 = 4i=4:更新tree[4],然后4 + lowbit(4) = 4 + 4 = 8i=8:更新tree[8],8 > n,停止
3.10.2 单点更新 + 前缀查询——完整代码
📄 查看代码:3.10.2 单点更新 + 前缀查询——完整代码
// ══════════════════════════════════════════════════════════════
// 树状数组(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;
}
3.10.3 区间查询 = prefix(r) - prefix(l-1)
区间查询 sum(l, r) 与前缀和技术完全一致:
📄 区间查询 `sum(l, r)` 与前缀和技术完全一致:
// 区间求和: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 ✓
3.10.4 对比:前缀和 vs BIT vs 线段树
| 操作 | 前缀和数组 | 树状数组(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)
- ✅ 逆序对计数、归并排序计数问题
- ❌ 需要区间最小/最大 → 用线段树
- ❌ 需要复杂区间操作(区间乘法等)→ 用线段树
3.10.5 交互式可视化:BIT 更新过程
3.10.6 区间更新 + 单点查询(差分 BIT)
标准 BIT 支持「单点更新 + 前缀查询」。利用差分数组技术,可以改为支持「区间更新 + 单点查询」。
原理
设差分数组 D[i] = A[i] - A[i-1](D[1] = A[1]),则:
A[i] = D[1] + D[2] + ... + D[i](即 A[i] 是 D 的前缀和)- 给 A[l..r] 全部加 val 等价于:
D[l] += val; D[r+1] -= val
📄 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);
}
3.10.7 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 对。
解法: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
3.10.8 常见错误
❌ 错误一: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();
}
3.10.9 本章总结
📋 公式速查
| 操作 | 代码 | 描述 |
|---|---|---|
| lowbit | x & (-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]) | 处理每个元素时计数 |
| 数组必须 | 1-indexed | 0-indexed → 死循环 |
❓ 常见问题
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 中不常见,但偶尔会在二维坐标计数题中用到。
3.10.10 练习题
🟢 简单一:区间求和(单点更新) 给定长度为 N 的数组,支持两种操作:
1 i x:A[i] 加 x2 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 l r x:给 A[l..r] 的每个元素加 x2 i:查询 A[i] 的当前值
提示: 使用差分 BIT(第 3.10.6 节)。
🟡 中等二:逆序对计数(含坐标压缩) 给定长度为 N 的数组,元素范围 1..10^9(可能有重复),统计逆序对数量。
提示: 先坐标压缩,再用 BIT 计数(第 3.10.7 节的变体)。注意相等元素:(i,j) 满足 i<j 且 A[i]>A[j](严格大于)才算逆序对。
🔴 困难:区间加法,区间求和(双 BIT) 给定长度为 N 的数组,支持两种操作:
1 l r x:给 A[l..r] 的每个元素加 x2 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); }
};
3.10.11 权值树状数组:全局第 k 小
权值 BIT 维护的是值域频率数组:bit[v] 表示值 v 在序列中出现了多少次。可以高效查询「序列中第 k 小的元素」。
朴素做法:二分 + 前缀查询,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)
借助 BIT 的树形结构,倍增法可以在 O(log N) 内完成第 k 小查询:
📄 借助 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 后,回到第 3.9 章学习线段树懒惰传播——那是 BIT 无法触达的领域。
🧠 第六部分:动态规划
竞赛编程中最强大也最让人头疼的主题。掌握记忆化、递推以及 USACO Silver 的经典 DP 模式。
📚 3 章 · ⏱️ 预计 3-4 周 · 🎯 目标:达到 USACO Silver 水平
第六部分:动态规划
预计用时:3-4 周
动态规划是竞赛编程中最强大也最让人头疼的主题。一旦掌握,你就能解决暴力法看似不可能解决的问题。这部分值得你慢慢来——真的值得。
涵盖的主题
| 章节 | 主题 | 核心思想 |
|---|---|---|
| 第 6.1 章 | DP 入门 | 记忆化、递推、DP 四步法 |
| 第 6.2 章 | 经典 DP 问题 | LIS、0/1 背包、网格路径计数 |
| 第 6.3 章 | 进阶 DP 模式 | 状压 DP、区间 DP、树形 DP |
学完本部分后能解决什么问题
完成第六部分后,你将能够挑战:
-
USACO Bronze:
- 简单计数问题(做某件事有多少种方法?)
- 基本优化(做某件事的最小代价是多少?)
-
USACO Silver:
- 最长递增子序列(及其变体)
- 背包类资源分配
- 网格路径问题(最大价值路径、路径计数)
- 精心定义状态的一维 DP(牛蹄剪刀布等)
- 区间 DP 或树形 DP(第 6.3 章)
需要掌握的关键 DP 模式
| 模式 | 章节 | 示例题目 |
|---|---|---|
| 一维 DP(顺序) | 6.1 | 斐波那契、爬楼梯 |
| 一维 DP(优化) | 6.1 | 硬币找零(最少硬币) |
| 一维 DP(计数) | 6.1 | 硬币找零(方法数) |
| 二维 DP | 6.2 | 0/1 背包、网格路径 |
| LIS(O(N²)) | 6.2 | 最长递增子序列 |
| LIS(O(N log N)) | 6.2 | 用二分搜索加速 LIS |
| 状压 DP | 6.3 | TSP、任务分配问题 |
| 区间 DP | 6.3 | 矩阵链乘法 |
| 树形 DP | 6.3 | 树上独立集 |
前置条件
开始第六部分前,请确认你能做到:
- 编写递归函数并理解调用栈(第 2.3 章)
- 熟练使用二维向量(第 2.3 章)
- 理解二分搜索(第 3.3 章)——O(N log N) LIS 需要
- 能解决基础 BFS 题目(第 5.2 章)——DP 和 BFS 共享「状态空间探索」的直觉
DP 思维方式
DP 不是死记公式——而是问对问题:
- 「状态」是什么? 描述一个子问题需要哪些信息?
- 「转移」是什么? 更大状态的答案如何依赖更小状态?
- 「初始条件」是什么? 最简单的子问题答案是什么?
- 填表的顺序是什么? 依赖关系必须在被使用之前先计算。
💡 核心思路: 如果你发现自己在递归解法中多次写相同的计算,DP 就是解药。第一次计算时缓存结果,之后每次直接复用。
本部分学习建议
- 仔细学第 6.1 章。 不要在真正理解斐波那契 DP 之前急着学背包。DP 的「为什么」比「是什么」更重要。
- 对同一道题同时写记忆化和递推两种实现。 在两者之间转换能加深理解。
- 第 6.2 章的 LIS 有两种实现:O(N²)(易理解)和 O(N log N)(快速,大 N 时需要)。两种都要学。
- 第 6.3 章是 Silver/Gold 级别。 如果目标是 Bronze,可以先跳过第 6.3 章,之后再回来。
- 大多数 DP bug 来自错误的初始化。 最小代价问题初始化为
INF,不是 0;计数问题把初始条件初始化为 1,不是 0。
⚠️ 警告: DP 第 1 号 bug:在最小化 DP 中使用
dp[w-c]前忘记检查dp[w-c] != INF。INF + 1会溢出!DP 第 2 号 bug:0/1 背包 vs 完全背包的循环顺序搞错了。倒序迭代 = 每件物品最多用一次。正序迭代 = 无限次使用。
第 6.1 章:动态规划入门
📝 前置条件: 确保理解递归(第 2.3 章)、数组/向量(第 2.3–3.1 章)和基本循环模式(第 2.2 章)。DP 直接建立在递归概念之上。
动态规划(DP)常被描述为「带记忆的聪明递归」。让我们从最简单的例子——斐波那契数列——从零建立这种直觉。
💡 核心思路: DP 解决具有两个性质的问题:
- 重叠子问题 —— 相同的子计算出现多次
- 最优子结构 —— 大问题的最优解可以由小问题的最优解构建
两者同时成立时,DP 将指数时间转化为多项式时间。
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 次唯一调用——这是动态规划背后的基本洞察。
朴素递归实现:
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;
}
甚至可以优化空间:由于每个斐波那契数只依赖前两个,只需 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) 两种方式对比:
💡 核心区别: 自顶向下按需计算(只算用到的子问题),自底向上全量填表(按顺序算所有子问题)。两者时间复杂度相同,但自底向上没有递归栈开销。
| 方面 | 记忆化(自顶向下) | 递推(自底向上) |
|---|---|---|
| 方式 | 递归加缓存 | 迭代填表 |
| 内存使用 | 只有已计算的状态 | 所有状态(包括未用到的) |
| 实现 | 通常更直观 | 可能需要想清楚填充顺序 |
| 栈溢出风险 | 有(深度递归) | 无 |
| 速度 | 稍慢(函数调用开销) | 稍快 |
| USACO 偏好 | 适合理解和思考 | 适合最终提交 |
🏆 USACO 技巧: 竞赛中自底向上递推略有优势,因为它避免了潜在的栈溢出(在 N = 10^5 的题目中很关键),通常也更快。但若难以看清递推关系,先用自顶向下——这是一种很好的思考方式。
6.1.4 DP 四步法
每道 DP 题都遵循相同的做法:
DP 四步法——从状态定义到空间优化:
- 定义状态: 什么信息能唯一描述一个子问题?
- 定义递推:
dp[状态]如何依赖更小的状态? - 确定初始条件: 最简单子问题的答案是什么?
- 确定顺序: 以什么顺序填表?
应用到斐波那契:
- 状态:
dp[i]= 第 i 个斐波那契数 - 递推:
dp[i] = dp[i-1] + dp[i-2] - 初始条件:
dp[0] = 0,dp[1] = 1 - 顺序: i 从 2 到 n(每个依赖更小的 i)
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)。
DP 定义
对 coins = {1, 5, 6} 的状态转移:
- 状态:
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)✓
📄 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 有多少种不同方法?
有序(排列——顺序重要):[1,5] 和 [5,1] 是不同的
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]; // ← 关键行
}
}
}
无序(组合——顺序不重要):[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];
}
}
💡 核心思路: 循环顺序决定了计数的是组合还是排列!硬币在外层循环时,每种硬币只被「引入」一次,忽略了顺序。金额在外层循环时,每个金额每次重新形成,允许所有排列。
⚠️ 第 6.1 章常见错误
- 最小化问题用 0 而非 INF 初始化 dp:
dp[w] = 0表示「0 枚硬币」,永远不会被改善。用dp[w] = INF,只有dp[0] = 0。 - 使用
dp[w-c]前不检查dp[w-c] != INF:INF + 1会溢出!始终检查子问题是否可解。 - 背包变体的循环顺序错误: 无界背包(硬币无限),金额正向循环;0/1 背包(每个只用一次),金额反向循环。搞错这一点会给出静默的错误答案。
- 用
INT_MAX作为 INF 然后加 1:INT_MAX + 1溢出成负数。用1e9或1e18作为 INF。 - 忘记初始条件:
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;
for (int i = 0; i < n; i++) {
for (int j = 0; j < m; j++) {
if (grid[i][j] == '#') { dp[i][j] = 0; continue; }
if (i > 0) dp[i][j] = (dp[i][j] + dp[i-1][j]) % MOD;
if (j > 0) dp[i][j] = (dp[i][j] + dp[i][j-1]) % MOD;
}
}
cout << dp[n-1][m-1] << "\n";
}
复杂度: O(N × M) 时间和空间。
图示:斐波那契递归树
上图展示了 fib(6) 的朴素递归。红色虚线节点是重复子问题——被多次计算。绿色节点展示记忆化缓存结果的位置。不用记忆化:O(2^N);用记忆化:O(N)。这是动态规划背后的基本洞察。
第 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]:
💡 转移规则:
dp[i] = 1 + max(dp[j])(对所有 j < i 且 A[j] < A[i])。每条箭头表示「以 j 结尾的子序列可以延伸到包含 i」。
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[物品][容量],每行加入一件物品,答案在右下角。
DP 公式
0/1 背包决策——拿或不拿物品 i:
💡 与无界背包的关键区别: 因为每件物品只能用一次,「拿」时从行
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] - 取最大值
- 不拿物品 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 值
每个格子展示了从 (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 填充顺序——必须按区间长度递增填充:
💡 填充顺序很关键: 必须按区间长度递增填充。计算
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]=1,dp[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/0,dp[1..W]=-INF/0 |
| 至多装满 | dp[0..W] 全初始化为 0 |
⚠️ 第 6.2 章常见错误
- LIS:严格递增用
upper_bound: 严格递增用lower_bound;不减序列用upper_bound。搞错会使 LIS 长度差 1。 - 0/1 背包:正向迭代重量: 正向迭代允许物品 i 被多次使用——那是无界背包,不是 0/1。0/1 背包始终倒序迭代。
- 网格路径:忘记处理堵塞格子: 若
grid[r][c] == '#',设dp[r][c] = 0(不是dp[r-1][c] + dp[r][c-1])。 - 网格路径计数中溢出: 路径数可能极大,用
long long或模运算。 - LIS:以为
tails存储实际 LIS: 不是!tails存储各长度子序列的最小可能尾元素。实际 LIS 需要单独重建。 - 分组背包:物品循环在容量外层: 物品循环必须在容量循环内部。若物品在外层,每件物品被当作独立的 0/1 物品处理,允许同组多件被选中。
- 多重背包二进制拆分后正向迭代: 拆分后超级物品仍是 0/1 约束——倒序迭代重量。正向迭代允许重用同一超级物品,结果错误。
- 二维背包只有一个维度倒序: 二维 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 时的最大价值 | 倒序迭代 w | O(NW) |
| 无界背包 | dp[w] = 容量 ≤ w 时的最大价值 | 正序迭代 w | O(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。每「叠」表示一个潜在的子序列终点,叠的数量等于 LIS 长度。二分搜索以 O(log N) 找到每张牌的位置,总体 O(N log N) 算法。
图示:背包 DP 表
0/1 背包 DP 表:行 = 已考虑的物品,列 = 容量,每格展示可实现的最大价值。蓝色格子展示单件物品的贡献,绿色格子展示组合,带星号的格子是最优答案。
第 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 时的最小代价。
转移: 扩展到 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 乘积的最少乘法次数。
📄 
// 矩阵链乘法 — 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 总是自底向上运行——叶节点是基础情况,每个内部节点汇总其子节点的结果:
经典题:树上最大独立集
题目: 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=true 和 tight=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 应该从
l到r-1,不是l到r - ❌ 初始化错误:
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 章)
🎯 学习目标
学完本章后,你将能够:
- 理解折半搜索的核心思想:将 O(2^N) 降为 O(2^(N/2) × log)
- 解决「子集和」「N 个数中选 k 个」类的大规模枚举问题
- 将线性搜索空间拆成两半分别处理,再合并答案
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 完整实现:子集和判断
#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 计数变体:恰好等于目标的子集数
// 统计和恰好为 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 的子集中,和最大的那个。
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 不大时 |
| 二维 DP | O(N^2) | 特殊结构 |
折半搜索的适用条件:
- 问题可以拆成两个独立的「半问题」
- 两个半问题的结果可以快速合并(通常用排序+二分)
- 总量 N ≤ 40(每半不超过 20)
⚠️ 常见错误
| 错误 | 原因 | 修复方案 |
|---|---|---|
| 左半边子集和溢出 | N=40 且元素值大时,子集和超 int | 用 long 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
💡 章节联系: 折半搜索是 USACO Gold 的独特技巧,每年约出现 1 道。它本质上是「暴力搜索 + 聪明合并」,将指数复杂度减半。与状压 DP(第 6.3 章)都处理 2^N 的搜索空间,但折半搜索不需要 DP 递推关系。
📖 第 6.5 章:数位 DP(Digit DP)
⏱ 预计阅读时间:50 分钟 | 难度:🟡 中等(USACO Gold 高频考点)
前置条件
- DP 入门(第 6.1 章)
- 经典 DP 问题(第 6.2 章)
🎯 学习目标
学完本章后,你将能够:
- 理解数位 DP 的核心框架:按位从高到低填数
- 掌握「tight(是否贴上界)」标志的作用
- 处理「前导零」问题
- 解决「区间 [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 的正整数个数。
#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):
填第 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 做记忆化:
#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(上一位填的数字)
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(当前允许填的最小数字)
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 问题类型
| 问题类型 | 额外状态 | 示例 |
|---|---|---|
| 各位和 = S | sum | 本章 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 标志区分 |
| 记忆化键不完整 | 缺少 tight 或 started | 4 个维度都要包含 |
| 区间端点错误 | 查 [L, R] 时用 f(L) 而非 f(L-1) | count(R) - count(L-1) |
| dp 数组大小不够 | sum 最大可达 9×18=162 | dp[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。
🔴 题目 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";
}
💡 章节联系: 数位 DP 是 USACO Gold 每年必出的题型之一。它将「计数问题」转化为「逐位填数的决策 DP」,是 DP 思想的精华体现。掌握后可进一步学习「数位 + 组合数学」的双重计数技巧。
🏆 第七部分:USACO 竞赛指南
不讲算法——讲竞赛策略。学会如何参赛:读题、管理时间、在压力下调试、以及策略性地获取部分分。
📚 3 章 · ⏱️ 随时可读 · 🎯 目标:从 Bronze 晋级 Silver
第七部分:USACO 竞赛指南
随时可读——无前置条件
第七部分与本书其他部分不同,不是教算法,而是教你如何参赛——如何读题、管理时间、在压力下调试,以及策略性地思考如何得分。
涵盖的主题
| 章节 | 主题 | 核心思想 |
|---|---|---|
| 第 7.1 章 | 了解 USACO | 竞赛形式、分级、评分、部分分 |
| 第 7.2 章 | 解题策略 | 如何面对从未见过的问题 |
| 第 7.3 章 | Ad Hoc 题型 | 无标准算法、靠观察的题目 |
什么时候读这部分
- 第一次参加 USACO 之前: 读第 7.1 章了解形式
- 练习题做不动时: 第 7.2 章的算法决策树有帮助
- 完成第 2-6 部分后: 第 7.2 章的清单告诉你是否准备好了 Silver
本部分关键内容
第 7.1 章:了解 USACO
- 竞赛日程(每年 4 场:12 月、1 月、2 月、US Open)
- 级别结构:Bronze → Silver → Gold → Platinum
- 评分:约 1000 分,750+ 晋级
- 部分分策略: 如何在没有完美解法时也得分
- 常见错误及如何避免
第 7.2 章:解题策略
- 算法决策树: 根据约束选择合适的算法
- N ≤ 20 → 暴力/状压
- N ≤ 1000 → O(N²)
- N ≤ 10^5 → O(N log N)
- 网格 + 最短路 → BFS
- 最优决策 → DP 或贪心
- 测试方法: 样例、边界情况、对拍
- 调试技巧:
cerr、assert、AddressSanitizer - Bronze → Silver 检查清单
第 7.3 章:Ad Hoc 题型
- 什么是 ad hoc: 无标准算法;需要特定于题目的洞察
- ad hoc 思维方式: 小例子 → 找规律 → 证明不变量 → 实现
- 6 个类别: 观察/规律、模拟捷径、构造、不变量/不可能性、贪心观察、几何/网格
- 核心技术: 奇偶论证、鸽巢原理、坐标压缩、对称化简、逆向思考
- 9 道练习题(简单 → 困难 → 挑战)含提示
竞赛日检查清单
竞赛当天参考:
- 模板已编译并测试通过
- 在编写任何代码之前先读完全部三道题
- 手动推演样例
- 确认约束条件和相应的算法层级
- 先解最简单的题
- 提交前用样例测试
- 若卡住:为小数据范围编写暴力以获取部分分
- 剩 30 分钟时:停止添加代码,专注于测试
-
再检查一遍:需要
long long的地方用了吗?数组边界正确吗?
🏆 USACO 技巧: 竞赛前一周最值得做的事是从记忆中重新解 5-10 道你以前做过的题。速度和准确性与知识积累同样重要。
第 7.1 章:了解 USACO
在能够赢得竞赛之前,你需要了解它的运作方式。本章涵盖 USACO 结构、规则和评分的所有你需要知道的内容,帮你有效参赛。
7.1.1 USACO 是什么?
美国计算机奥林匹克竞赛(USACO)是美国大学前学生最顶尖的竞赛编程赛事,1993 年创立,负责选拔参加**国际信息学奥林匹克竞赛(IOI)**的美国队员。
关键事实:
- 完全免费,对任何人开放
- 在家用自己的电脑参加
- 题目涉及算法和数据结构
- 不是数学竞赛,不是知识竞赛——纯粹的算法思维
7.1.2 竞赛形式
日程
USACO 每年举办 4 场竞赛:
- 12 月赛(通常第一或第二周)
- 1 月赛
- 2 月赛
- US Open(3/4 月)——稍难,5 小时而非 4 小时
下图展示了完整的竞赛赛季和关键日期:
竞赛在周五开放,实际竞赛时间 4 小时(在 3 天的时间窗口内自行选择开始时间)。
题目
每场竞赛有 3 道题。时间限制为 4 小时(US Open:5 小时)。
输入/输出
- 题目使用文件 I/O 或标准 I/O(新版竞赛多用标准 I/O)
- 文件 I/O:从
problem.in读输入,向problem.out写输出 - 文件 I/O 模板:
📄 C++ 完整代码
#include <bits/stdc++.h>
using namespace std;
int main() {
// 将 cin/cout 重定向到文件
freopen("problem.in", "r", stdin);
freopen("problem.out", "w", stdout);
ios_base::sync_with_stdio(false);
cin.tie(NULL);
// 你的解题代码
return 0;
}
重要: 2020 年起,大多数 USACO 题目使用标准 I/O。始终查看题目说明!
7.1.3 四个级别
USACO 有四个竞赛级别,各有不同难度:
图示:USACO 级别金字塔
金字塔展示了 USACO 从底部入门级 Bronze 到顶部精英级 Platinum 的四个级别,每个层级都需要掌握其下方的概念。
🥉 Bronze
- 受众: 有基础编程知识的初学者
- 算法: 模拟、暴力、基本循环、简单数组
- 典型复杂度: 对小 N 是 O(N²) 或 O(N³),有时凭借洞察是 O(N)
- N 约束: 通常 ≤ 1000 或非常小
- 晋级阈值: 得分 750/1000 或以上(具体阈值因赛而异)
🥈 Silver
- 受众: 中级程序员
- 算法: 排序、二分查找、BFS/DFS、前缀和、基础 DP、贪心
- 典型复杂度: O(N log N) 或 O(N)
- N 约束: 最大 10^5
- 晋级阈值: 750+/1000
🥇 Gold
- 受众: 进阶程序员
- 算法: Dijkstra、线段树、进阶 DP、网络流、LCA
- 典型复杂度: O(N log N) 到 O(N log² N)
- N 约束: 最大 10^5 到 10^6
💎 Platinum
- 受众: 顶尖选手
- 算法: 高难组合数学、进阶数据结构、计算几何
- 顶尖选手有资格参加 USACO 决赛营,并可能入选 IOI 队伍(每年选 4 人)
7.1.4 评分
评分规则
每道题有多个测试点(通常 10–15 个),通过每个测试点得到部分分。
- 每道题约 333 分
- 总分:每场约 1000 分
- 具体细分因赛而异
「全对才行」的误区
人们以为必须完美解法才行,不是的! 较简单情况(较小 N、特殊结构)的部分分可以让你达到晋级所需的 750+。尤其在 Bronze,有很多部分分策略。
部分分策略
若无法完全解决一道题:
- 解决小数据: 若 N ≤ 20,O(N!) 或 O(2^N) 的暴力通常能通过几个测试点
- 解决特殊情况: 若图是树,或所有值相等,先解决这些
- 始终输出某个答案: 若认为答案总是「YES」或某个常数,试几个测试点看看
- 优雅地超时: 确保部分解法不崩溃——TLE 比运行时错误好
7.1.5 竞赛时间管理
4 小时策略
前 30 分钟: 读完全部 3 道题。先别写代码,只是理解题目并思考。
- 确认哪道题看起来最简单
- 记下边界情况或特殊条件
- 开始在脑中形成思路
第 1-2 小时: 解最简单的题(通常是题 1 或题 2)。
- 实现、用样例测试、调试
- 争取至少一道题 100%
第 2-3 小时: 攻第二简单的题。
- 若卡住,考虑部分分做法
最后一小时: 要么完成第三道题,要么整合/调试已有的解法。
- 剩 30 分钟时:停止加新代码,专注测试和修 bug
读题
在写任何代码之前,花 5-10 分钟读每道题:
- 再读一遍约束条件(N、值的范围、特殊条件)
- 在纸上手动推演样例
- 思考:「这让我想到什么算法?」
卡住了怎么办
- 手动试小例子——能发现什么规律?
- 考虑更简单的版本:N=1 时怎么样?N=2?N=10?
- 想想:这是图论题吗?DP?排序/贪心题?
- 先写暴力——它可能够快,或帮助你理解结构
7.1.6 常见错误模式
1. 差一错误
// 错误:漏掉最后一个元素
for (int i = 0; i < n - 1; i++) { ... }
// 错误:访问 arr[n]——越界!
for (int i = 0; i <= n; i++) { cout << arr[i]; }
// 正确
for (int i = 0; i < n; i++) { ... } // 0-indexed
for (int i = 1; i <= n; i++) { ... } // 1-indexed
2. 整数溢出
int a = 1e9, b = 1e9;
int wrong = a * b; // 溢出
long long right = (long long)a * b; // 正确
3. 未初始化变量
int ans; // 未初始化——有垃圾值!
// 始终初始化:
int ans = 0;
int best = INT_MIN;
4. 空输入/边界情况答案错误
// 若 n = 0 怎么办?
int maxVal = arr[0]; // n = 0 时崩溃!
// 检查:if (n == 0) { cout << 0; return 0; }
5. 用 endl 代替 "\n"
// 慢(每次都清空缓冲区)
for (int i = 0; i < n; i++) cout << arr[i] << endl;
// 快
for (int i = 0; i < n; i++) cout << arr[i] << "\n";
6. 未处理所有情况
仔细读题。「所有奶牛高度相同怎么办?」「N=1 怎么办?」测试这些边界情况。
7.1.7 Bronze 题型速查表
| 类别 | 描述 | 关键技术 |
|---|---|---|
| 模拟 | 按步骤执行指令 | 仔细实现;用数组/映射 |
| 计数 | 统计满足条件的元素数 | 循环、前缀和、哈希映射 |
| 几何 | 网格上的点、矩形 | 仔细处理索引,避免浮点误差 |
| 基于排序 | 排序并检查性质 | std::sort + 扫描 |
| 字符串处理 | 操作字符序列 | 字符串索引、映射 |
| Ad Hoc | 巧妙观察,无标准算法 | 仔细读题,找规律(见第 7.3 章) |
7.1.8 Bronze 题目完整分类
Bronze 题目分为以下 10 类,了解分类能帮你立即识别模式:
| # | 类别 | 描述 | 关键做法 |
|---|---|---|---|
| 1 | 模拟 | 按给定规则逐步执行 | 仔细实现,用数组 |
| 2 | 计数/迭代 | 统计满足条件的元素数 | 嵌套循环、前缀和 |
| 3 | 排序 + 扫描 | 排序,再用简单检查扫描 | std::sort + 线性扫描 |
| 4 | 网格/二维数组 | 处理二维网格中的格子 | 仔细索引,BFS/DFS |
| 5 | 字符串处理 | 操作字符序列 | 字符串索引、映射 |
| 6 | 暴力搜索 | 尝试所有可能 | 对小 N 用嵌套循环 |
| 7 | 几何(整数) | 网格上的点、矩形 | 整数运算,不用浮点 |
| 8 | 数学/取模 | 数论、规律 | 取模运算、公式 |
| 9 | 数据结构 | 用合适的容器 | Map、set、优先队列 |
| 10 | Ad Hoc/观察 | 巧妙洞察,无标准算法 | 仔细读题,找规律——见第 7.3 章深入讲解 |
7.1.9 Silver 题目分类
Silver 题目需要更复杂的算法,以下是主要类别:
| 类别 | 关键算法 | N 约束 | 所需时间 |
|---|---|---|---|
| 排序 + 贪心 | 排序 + 扫描、区间调度 | N ≤ 10^5 | O(N log N) |
| 二分查找 | 二分答案、参数搜索 | N ≤ 10^5 | O(N log N) 或 O(N log² N) |
| BFS/DFS | 最短路、分量、洪水填充 | N ≤ 10^5 | O(N + M) |
| 前缀和 | 一维/二维区间查询、差分数组 | N ≤ 10^5 | O(N) |
| 基础 DP | 一维 DP、LIS、背包、网格路径 | N ≤ 5000 | O(N²) 或 O(N log N) |
| DSU | 动态连通性、Kruskal MST | N ≤ 10^5 | O(N α(N)) |
| 图 + DP | 树上 DP、DAG 路径 | N ≤ 10^5 | O(N) 或 O(N log N) |
USACO 的时间复杂度限制
这很关键:USACO 题目时间限制严格(通常 2-4 秒)。用这张表确定所需算法复杂度:
| N(输入规模) | 所需复杂度 | 允许的算法 |
|---|---|---|
| N ≤ 10 | O(N!) | 排列暴力 |
| N ≤ 20 | O(2^N × N) | 状压 DP、全搜索 |
| N ≤ 100 | O(N³) | Floyd-Warshall、区间 DP |
| N ≤ 1,000 | O(N²) | 标准 DP、逐对处理 |
| N ≤ 10,000 | O(N² / 常数) | 有时优化后的 O(N²) 可行 |
| N ≤ 100,000 | O(N log N) | 排序、BFS、二分查找、DSU |
| N ≤ 1,000,000 | O(N) | 线性算法、前缀和 |
| N ≤ 10^9 | O(log N) | 二分查找、数学公式 |
⚠️ 经验法则: 每秒约 10^8 次简单运算。N=10^5 时,O(N²) = 10^10 次运算 → 超时。需要 O(N log N) 或更好。
7.1.10 如何补题——卡住时
「补题」是指竞赛中解不出的题,在看提示或题解后重新解。这是提高 USACO 水平最重要的技能。
补题步骤
第一步:先自己挣扎(30-60 分钟)
- 不要立即看题解,挣扎能建立直觉
- 试小例子(N=2, N=3),有什么规律?
- 想:「这道题闻起来像什么算法?」
第二步:获取提示,不是答案
- 只看题解的第一行:「这是 BFS 题」或「先排序」
- 只靠那个提示再试一次
第三步:读完整题解
- 慢慢读,理解算法为什么有效,不只是是什么
- 问自己:「我缺少了什么洞察?我为什么没想到?」
第四步:从零实现
- 不要复制题解代码,自己写
- 这才是真正学习发生的地方
第五步:找到自己的差距
- 问题是不认识算法类型?→ 多学题型模式
- 问题是实现?→ 练习更快编码,更好地学习 STL
- 问题是观察/洞察?→ 练习思考性质和不变量
7.1.11 USACO 模式速查表
| 模式 | 识别关键词 | 算法 |
|---|---|---|
| 网格最短路 | 「最少步数」「迷宫」「BFS」 | BFS |
| 每个格子到最近X的距离 | 「最近的火」「到最近X的距离」 | 多源 BFS |
| 排序 + 扫描 | 「相近」「最大间隔」 | 排序后检查相邻对 |
| 二分答案 | 「最大化最小距离」「最小化最大值」 | 二分 + 检查 |
| 滑动窗口 | 「子数组和」「连续」「窗口」 | 双指针 |
| 连通分量 | 「区域」「岛屿」「组」 | DFS/BFS 洪水填充 |
| 动态连通性 | 「合并组」「添加连接」 | DSU |
| 最小生成树 | 「最便宜地连接」「道路网络」 | Kruskal |
| 统计对数 | 「满足条件的对有多少」 | 排序 + 双指针或二分 |
| 一维 DP | 「决策的最优序列」 | DP 数组 |
| 网格 DP | 「网格中的路径」「矩形区域」 | 二维 DP |
| 活动选择 | 「最多不重叠事件」 | 按结束时间排序,贪心 |
| 前缀和区间查询 | 「[l,r] 的和」「二维矩形和」 | 前缀和 |
| 拓扑顺序 | 「先修课」「依赖顺序」 | 拓扑排序 |
| 二部图检查 | 「能否 2-染色」「有奇数环吗」 | BFS 2-染色 |
7.1.12 精炼的竞赛策略
前 5 分钟至关重要
在写第一行代码之前:
- 读完全部 3 道题(先看标题和约束)
- 估计难度: 哪道最简单?(Bronze/Silver 通常是题 1)
- 注意关键约束: N ≤ ?时间限制,特殊条件
- 在脑中分类每道题(用上面的分类法)
部分分策略
即使无法完全解一道题,也要争取部分分:
📄 即使无法完全解一道题,也要争取部分分:
Bronze(N ≤ ~1000):
- 暴力 O(N²) 或 O(N³) 通常能通过几个测试点
- 「解决小数据」做法:N ≤ 20 → 暴力
Silver(N ≤ 10^5):
- O(N²) 解法通常能通过 4-6/15 个测试点(部分分!)
- 先实现暴力,再优化
始终:
- 确保代码能编译并运行(无运行时错误)
- 对每个测试点都输出一些东西,即使是错的
- 错误答案好过崩溃
提交前调试清单
- 所有给定样例输出正确?
- 边界情况:N=1?
-
整数溢出?(值 > 10^9 时用
long long) - 数组越界?(仔细设定数组大小)
- 循环中差一错误?
-
用的是
"\n"而非endl? - 读取了正确数量的测试点?
本章总结
📌 核心要点
| 主题 | 要点 |
|---|---|
| 形式 | 每年 4 场,各 4 小时,3 道题 |
| 级别 | Bronze → Silver → Gold → Platinum |
| 评分 | 每场约 1000 分,晋级需 750+ |
| 部分分 | 对小数据的暴力也能得分 |
| 时间管理 | 先读完所有题,从最简单的开始 |
| 常见 bug | 溢出、差一、未初始化变量 |
❓ 常见问题
Q1:USACO 使用什么语言?推荐 C++ 吗?
A:USACO 支持 C++、Java、Python。强烈推荐 C++——速度最快(Python 慢 10-50 倍),STL 丰富。Java 也可以,但比 C++ 慢约 2 倍且更冗长。本书全程使用 C++。
Q2:从 Bronze 晋级 Silver 需要多久?
A:因人而异。有编程背景的学生通常 2-6 个月(每周练习 5-10 小时)。完全初学者可能需要 6-12 个月。关键不是时间,而是有效练习——做题 + 读题解 + 反思。
Q3:竞赛期间可以上网查资料吗?
A:可以查通用参考资料(如 C++ 参考文档、算法教程),但不能查现成的 USACO 题解或获得他人帮助。USACO 是开放资源但独立完成的。
Q4:答案错误有罚分吗?
A:没有。USACO 允许无限次重新提交,只有最后一次提交算数。所以先提交部分正确的解法、再优化是明智的策略。
Q5:什么时候应该放弃一道题转向下一道?
A:若已经卡了 40+ 分钟且没有新思路,考虑转向下一道。但切换前先提交当前代码以获取部分分。若最后有时间再回来。
🔗 与其他章节的联系
- 第 2.1-2.3 章(第二部分)涵盖 Bronze 所需的所有 C++ 知识
- 第 3.1-3.11 章(第三部分)涵盖 Silver 的核心数据结构和算法
- 第 5.1-5.4 章(第五部分)涵盖 Silver/Gold 边界的图论
- 第 4.1-4.2、6.1-6.3 章(第四、六部分)涵盖 Silver/Gold 的贪心和 DP
- 第 7.2 章延续本章,深入讲解解题策略和思维方法
- 第 7.3 章深入讲解 ad hoc 题目——Bronze 中 10-15% 需要创造性观察而非标准算法的题
第 7.2 章:解题策略
了解算法是必要条件,但还不够。你还需要知道面对从未见过的题目时如何思考。本章教给你一套系统的方法。
7.2.1 如何读竞赛编程题
USACO 题目有一致的结构,学会高效解析它。
题目结构
- 故事/背景 —— 一个主题(通常是奶牛 🐄)。大多是润色文字——不要分心。
- 任务/目标 —— 实际的问题。仔细阅读这部分。
- 输入格式 —— 如何读取数据。
- 输出格式 —— 精确地打印什么。
- 样例输入/输出 —— 示例。
- 约束条件 —— 选择算法最重要的部分。
读题纪律
第一步: 先读任务/目标,再读输入/输出格式。 第二步: 读约束条件。这些告诉你:
- N ≤ 20 → 可能 O(2^N) 或 O(N!)
- N ≤ 1000 → 可能 O(N²) 或 O(N² log N)
- N ≤ 10^5 → 必须是 O(N log N) 或 O(N)
- N ≤ 10^6 → 必须是 O(N) 或 O(N log N)
- 值最大 10^9 → 可能需要
long long - 值最大 10^18 → 一定需要
long long
第三步: 手动推演样例,验证自己对题目的理解。
第四步: 寻找隐藏约束。「所有值不同。」「图是一棵树。」「N 是偶数。」这些往往能解锁更简单的解法。
7.2.2 识别算法类型
读完题后,按顺序问自己这些问题:
图示:解题流程图
上图捕捉了完整的竞赛工作流程。关键步骤是将输入约束映射到算法复杂度——用下面的复杂度表来快速做出这个决策。
图示:复杂度与输入规模
这张参考表能立刻告诉你所选算法是否能通过。N = 10^5 时有 O(N²) 解法,就会超时。这张表应该是你设计方案时的第一个心智检查。
问题一:能暴力吗?
- 若 N ≤ 15,暴力所有子集:O(2^N)
- 若 N ≤ 8,试所有排列:O(N!)
- 即使暴力太慢无法满分,也适合拿部分分和验证正确解
问题二:涉及网格或图吗?
- 带最短路问题的网格 → BFS
- 涉及连通性的网格/图 → DFS 或并查集
- 有加权边的图,最短路 → Dijkstra(Gold 主题)
- 树结构 → 树形 DP 或 LCA
问题三:涉及已排序数据吗?
- 找最近元素 → 排序 + 相邻扫描
- 区间查询 → 二分查找或前缀和
- 「能否实现值 X?」类型 → 二分答案
问题四:涉及序列上的最优决策吗?
- 「最大/最小代价路径」→ DP
- 「最多不重叠区间」→ 贪心
- 「X 变 Y 的最少操作」→ BFS(状态空间小)或 DP
问题五:涉及计数吗?
- 统计子集 → 状压 DP(小 N)或组合数学
- 统计 DAG 中的路径 → DP
- 元素频率 → 哈希映射
算法决策树
📄 查看代码:算法决策树
N ≤ 20?
├── 是 → 尝试暴力(O(2^N) 或 O(N!))
└── 否
是图/网格题吗?
├── 是
│ 是最短路问题吗?
│ ├── 是(无权) → BFS
│ ├── 是(有权) → Dijkstra(Gold)
│ └── 否(连通性) → DFS / 并查集
└── 否
排序有用吗?
├── 是 → 排序 + 扫描 / 二分查找
└── 否
有「重叠子问题」吗?
├── 是 → 动态规划
└── 否 → 贪心 / 模拟
7.2.3 用样例测试
始终先测试给定样例
提交前,验证你的解法对所有给定样例都能产生完全正确的输出。
# 编译
g++ -o sol solution.cpp -std=c++17
# 用样例输入测试
echo "5
3 1 4 1 5" | ./sol
# 或从文件
./sol < sample.in
自己创造测试用例
给定的样例很简单。自己创造:
- 最小情况: N=1,N=0,空输入
- 最大情况: N 取最大约束,所有值取最大
- 所有值相同: N 个元素全部相等
- 已排序/逆序排列
- 特殊结构: 完全图、路径图、星形图(图论题)
对拍
为小 N 写一个暴力解法,然后与你的优化解法对比随机输入:
📄 为小 N 写一个暴力解法,然后与你的优化解法对比随机输入:
// brute.cpp — 简单 O(N^3) 解法
// sol.cpp — 你的 O(N log N) 解法
// stress_test.sh:
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 "第 $i 个测试不一致"
cat test.in
break
fi
done
echo "所有测试通过!"
对拍能发现样例漏掉的微妙 bug。
7.2.4 C++ 调试技巧
策略一:打印一切
出问题时,加 cerr 语句追踪程序执行。cerr 输出到标准错误(与标准输出分离):
cerr << "在节点 " << u << ",dist = " << dist[u] << "\n";
cerr << "数组状态:";
for (int x : arr) cerr << x << " ";
cerr << "\n";
为什么用
cerr不用cout?cout输出到标准输出,评测机在那里检查你的答案。cerr输出到标准错误,评测机通常忽略。所以调试输出不会污染你的答案。
策略二:用 assert 检查不变量
assert(n >= 1 && n <= 100000); // 条件失败时崩溃并给出信息
assert(dist[v] >= 0); // 检查 BFS 不变量
策略三:检查数组边界
int arr[100];
arr[100] = 5; // Bug!合法下标是 0-99
// 调试时用地址消毒器检测边界问题:
// g++ -fsanitize=address,undefined -o sol sol.cpp
策略四:小黄鸭调试法
逐行大声解释你的代码(或写下来),解释的行为迫使你注意不一致之处,很多 bug 就是这样发现的——不是盯着屏幕,而是阐明每行代码应该做什么。
策略五:缩小问题
若代码在大输入上失败,手动创建最小的仍然失败的输入,修复它,重复。
策略六:读编译器警告
g++ -Wall -Wextra -o sol sol.cpp
-Wall -Wextra 启用所有警告,读它们!未初始化变量、未使用变量、有符号/无符号不匹配——都是常见的 USACO bug。
7.2.5 USACO 特有调试
检查 I/O
正确算法答案错误的第 1 原因:错误的输入/输出格式。
- 你读取了正确数量的值吗?
- 你打印了正确数量的行吗?
- 有多余的空格或缺少换行吗?
测试运行时间
检查你的解法是否够快:
time ./sol < large_input.in
USACO 通常允许 2-4 秒。若你的解法本地需要 10 秒,会超时。
先估算复杂度
编码前计算:「我的算法是 O(N²),N = 10^5,那是 10^10 次运算,太慢了。」
C++ 中 1 秒大概能运行的操作数:
- 10^8 次简单操作
- 10^7 次复杂操作(如 map 查询)
- 10^5 × 10^3 = 10^8,嵌套循环带简单循环体
7.2.6 Bronze 到 Silver 检查清单
用这个清单评估你是否准备好了 Silver:
需要掌握的算法
- 前缀和(一维和二维)
- 二分查找(包括二分答案)
- 图和网格上的 BFS 和 DFS
- 并查集(DSU)
- 带自定义比较器的排序
- 基础 DP(一维 DP、二维 DP、背包)
-
STL:
map、set、priority_queue、vector、sort
解题技能
- 能判断一道题需要 BFS vs DFS vs DP vs 贪心
- 能在 10 分钟内从零实现 BFS
- 能在 5 分钟内从零实现 DSU
- 能把网格题建模成图
- 知道如何对答案二分
- 熟练使用二维数组和网格遍历
竞赛技能
- 能在 30 秒内写出带快速 I/O 的清晰模板
-
需要时从不忘记
long long - 提交前始终用样例测试
- 能快速读懂并理解约束条件
- 至少练习过 20 道 Bronze 题
- 至少解过 5 道 Silver 题(哪怕借助提示)
练习计划
- 解所有容易找到的 USACO Bronze 题(2016–2024)
- 每道 2 小时内解不出的题:读题解,从零实现
- 解 30+ 道 Bronze 后,挑战 Silver:从 2016–2018 Silver 开始
- 保持题目日志:题目名称、用到的技术、关键洞察
7.2.7 资源
官方
- USACO 官网: usaco.org —— 竞赛档案、题解
- USACO 训练营: train.usaco.org —— 旧但好的结构化课程
非官方
- USACO Guide: usaco.guide —— 优秀的社区编写指南,强烈推荐
- Codeforces: codeforces.com —— 更多题目和竞赛
- AtCoder: atcoder.jp —— 高质量教学题
书籍
- Competitive Programmer's Handbook by Antti Laaksonen —— 免费 PDF,优秀
- Introduction to Algorithms(CLRS)—— 理论圣经(读起来较重)
本章总结
📌 核心要点
| 技能 | 练习直到…… |
|---|---|
| 读题 | 3 分钟内理解题目 |
| 算法识别 | 70%+ 的情况猜对正确方案 |
| 实现 | 标准题在 30 分钟内完成 |
| 调试 | 30 分钟内定位并修复 bug |
| 测试 | 养成提交前测试边界情况的习惯 |
🧩 「解题思维」快速检查清单
| 步骤 | 问自己的问题 |
|---|---|
| 1. 检查 N 范围 | N ≤ 20 → 暴力/状压;N ≤ 10^5 → O(N log N) |
| 2. 图/网格? | 是 → BFS/DFS/DSU |
| 3. 优化某个值? | 「最大化最小值」或「最小化最大值」→ 二分答案 |
| 4. 重叠子问题? | 是 → DP |
| 5. 排序后贪心? | 是 → 贪心 |
| 6. 区间查询? | 是 → 前缀和 / 线段树 |
❓ 常见问题
Q1:遇到完全陌生的题型怎么办?
A:① 先写小数据的暴力获取部分分;② 画图、手动计算小例子找规律;③ 试着简化题目(如果是二维,先想一维版本);④ 若仍卡住,转向下一道题,稍后回来。
Q2:如何提高「题型识别」能力?
A:有意识地分类练习。每道题做完后记录其「标签」(BFS、DP、贪心、二分等)。练习足够多后,会立即把类似的约束和关键词与正确的算法联系起来。本书第 7.1 章的模式速查表是个好的起点。
Q3:竞赛中应该先写暴力还是直接写最优解?
A:先写暴力。暴力代码通常只需 5 分钟,有三个用途:① 获取部分分;② 帮助理解题目;③ 用于对拍验证最优解。即使对自己的解法有把握,也建议先写暴力。
Q4:如何用对拍高效调试?
A:写三个程序:
brute.cpp(正确的暴力)、sol.cpp(你的优化解法)、gen.cpp(随机数据生成器)。循环运行并比较输出,发现不一致时那个小测试就是调试线索。这是竞赛编程中最强大的调试技术。
🔗 与其他章节的联系
- 本章的算法决策树涵盖了本书所有章节的核心算法
- 第 7.1 章涵盖 USACO 竞赛规则和题目分类;本章涵盖「如何解题」
- Bronze 到 Silver 检查清单总结了第 2.1–6.3 章的所有知识点
- 本章的对拍技术可以应用于所有章节的练习题
从 Bronze 到 Silver 的旅程是大量练习加上有意识的反思。每道你解过(或没解出)的题后,问自己:「关键洞察是什么?下次怎么更快地认出这类题型?」
祝你好运,享受和奶牛们在一起的时光。🐄
第 7.3 章:Ad Hoc 题型
「Ad hoc」 是拉丁语,意为「为此目的」。Ad hoc 题目没有标准算法——你必须专门为这道题发明一个解法。
Ad hoc 题目是竞赛编程中最有创意、也往往最令人沮丧的类别,它们不能整齐地归入「BFS」「DP」或「贪心」,而是要求你观察到题目的某个关键性质并直接利用它。
在 USACO Bronze 中,大约 10–15% 的题目是 ad hoc。在 Silver 中出现频率较低,但往往是当场最难的一道。学会识别和解决它们是一项关键技能。
7.3.1 什么是 Ad Hoc 题目?
定义
Ad hoc 题目具有以下特点:
- 没有标准算法(BFS、DP、贪心等)直接适用
- 解法依赖于特定于这道题的巧妙观察或数学洞察
- 一旦看到关键洞察,实现通常很简单
如何识别 Ad Hoc 题目
读题时,如果你问自己「这是什么算法?」而答案是「……以上都不是」,那它很可能是 ad hoc。
常见信号:
- 题目涉及小的特定结构(如 3×3 网格、长度 ≤ 10 的序列)
- 题目询问看似难以直接计算的性质
- 约束条件不寻常(如 N ≤ 50,或值很小)
- 题目有一个让它比看起来简单得多的「技巧」
- 题目看起来是模拟,但有隐藏的捷径
Ad Hoc vs 其他类别
| 类别 | 关键特征 | 示例 |
|---|---|---|
| 模拟 | 逐步按规则执行;不需要捷径 | 「模拟 N 头奶牛移动 T 步」 |
| 贪心 | 局部最优选择导致全局最优 | 「排期工作最小化延迟」 |
| DP | 重叠子问题,最优子结构 | 「最少硬币找零」 |
| Ad Hoc | 巧妙观察消除暴力 | 「找规律;直接实现」 |
💡 关键区别: 模拟题在精神上也是「ad hoc」的,但一旦理解就很直接实现。真正的 ad hoc 题目需要从题目描述中并不明显的洞察。
7.3.2 Ad Hoc 思维方式
解 ad hoc 题目需要与算法题不同的心理方法。
第一步:深入理解题目
不要急着编码。花 5-10 分钟只是思考题目:
- 题目真正问的是什么?
- 什么让这道题难?
- 什么会让它变简单?
第二步:试小例子
手动推演 N = 2, 3, 4 的例子,寻找规律:
- 答案遵循某个公式吗?
- 有对称性或不变量吗?
- 能把题目化简为更简单的形式吗?
第三步:寻找不变量
不变量是随问题演化而不变化的性质。找到不变量通常能解锁 ad hoc 解法。
示例: 在能交换相邻元素的题目中,逆序对数量的奇偶性是不变量。若初始和目标配置奇偶性不同,答案是「不可能」。
第四步:考虑极端情况
- 所有值相等时会怎样?
- N = 1 时会怎样?
- 所有值都取最大时会怎样?
极端情况往往揭示解法的结构。
第五步:思考你真正在计算什么
有时题目描述掩盖了更简单的底层计算。问:「这有公式吗?」
7.3.3 Ad Hoc 题目类别
USACO Bronze/Silver 的 ad hoc 题目分为几种重复出现的模式:
类别一:观察/找规律
关键是找到数学规律或公式。
典型结构: 给定某个序列或结构,找一个可以直接计算的性质。
示例题目: N 头奶牛围成一圈,每头朝左或朝右,若朝向与两个邻居相同则「开心」,有多少头开心?
暴力: 检查每头牛的邻居——O(N)。这已经是最优了,洞察是认识到只需数「相同-相同-相同」三元组。
类别二:有捷径的模拟
题目看起来是模拟,但朴素模拟太慢,有数学捷径。
典型结构: 「重复这个操作 T 次」,T 很大(最大 10^9)。
关键洞察: 状态空间有限,所以序列最终必定循环。找到循环长度,然后用模运算。
📄 C++ 完整代码
// 朴素:模拟 T 步——O(T),T = 10^9 时太慢
// 聪明:找循环长度 C,然后模拟 T % C 步——O(C)
int simulate(vector<int> state, int T) {
map<vector<int>, int> seen;
int step = 0;
while (step < T) {
if (seen.count(state)) {
int cycle_start = seen[state];
int cycle_len = step - cycle_start;
int remaining = (T - step) % cycle_len;
for (int i = 0; i < remaining; i++) {
state = next_state(state);
}
return answer(state);
}
seen[state] = step;
state = next_state(state);
step++;
}
return answer(state);
}
类别三:构造/直接建立答案
不是搜索答案,而是构造它。
典型结构: 「找任何满足这些约束的配置」或「能否达到 X?」
关键洞察: 思考必须满足哪些约束,然后构建满足它们的解。
类别四:不变量/不可能性
通过找到目标状态违反的不变量来证明某件事不可能。
典型结构: 「能否用这些操作将状态 A 变换为状态 B?」
关键洞察: 找一个在每次操作下保持(或以可预测方式变化)的量。若 A 和 B 的这个量不同,变换不可能。
经典示例: 15 拼图(滑动方块),可解性取决于排列的奇偶性加上空格位置。
类别五:贪心观察
题目看起来需要 DP,但一个简单的贪心观察让它变得平凡。
典型结构: 贪心选择不显然的优化问题。
示例: 有 N 件物品,价值 v[i],最多取 K 件,最大化总价值。
显然的贪心: 按价值降序排,取前 K 件。(一旦看到就很平凡,但题目可能伪装得很好。)
类别六:几何/网格观察
网格或有几何约束的题目往往有优雅的观察。
典型结构: 统计网格上的某物,或确定配置是否可达。
关键洞察: 通常涉及奇偶性(棋盘染色)、对称性或巧妙的坐标变换。
7.3.4 工作示例
示例一:围栏涂色
题目: FJ 有长度为 N 的围栏,先将 a 到 b 涂成红色,再将 c 到 d 涂成蓝色(蓝色覆盖红色)。多少根栏杆是红色?蓝色?
Ad hoc 洞察: 涂色区域是两个区间的并集,用容斥原理:
- 涂色 = |[a,b]| + |[c,d]| - |[a,b] ∩ [c,d]|
📄 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 inter_start = max(a, c);
int inter_end = min(b, d);
int overlap = max(0, inter_end - inter_start);
cout << red + blue - overlap << "\n";
return 0;
}
示例二:循环检测
题目: 从 X 开始,反复用各位数字之和替换 X,直到 X < 10。需要多少步?(X 最大 10^18)
朴素做法: 逐步模拟。但若需要数百万步怎么办?
Ad hoc 洞察: 10^18 的各位数字之和最多是 9×18 = 162。一步后值 ≤ 162。两步后 ≤ 9+9 = 18。三步后是个位数。所以任意起始值最多 3 步!
📄 C++ 完整代码
#include <bits/stdc++.h>
using namespace std;
long long digit_sum(long long x) {
long long s = 0;
while (x > 0) { s += x % 10; x /= 10; }
return s;
}
int main() {
long long x;
cin >> x;
int steps = 0;
while (x >= 10) {
x = digit_sum(x);
steps++;
}
cout << steps << "\n";
return 0;
}
示例三:网格染色不变量
题目: N×M 网格,可以翻转任意 2×2 方块(切换全部 4 个格子的 0/1)。从全零开始,能否达到目标配置?
Ad hoc 洞察: 考虑「棋盘奇偶性」。把网格染成棋盘格(黑/白)。每次 2×2 翻转恰好切换 2 个黑格和 2 个白格。因此,为 1 的黑格数量和为 1 的白格数量始终有相同的奇偶性(两者从 0 开始,每次翻转都变化 ±2 或 0)。
若目标有奇数个为 1 的黑格或奇数个为 1 的白格,则不可能。
📄 若目标有奇数个为 1 的黑格或奇数个为 1 的白格,则**不可能**。
#include <bits/stdc++.h>
using namespace std;
int main() {
int n, m;
cin >> n >> m;
vector<string> grid(n);
for (auto& row : grid) cin >> row;
int black_ones = 0, white_ones = 0;
for (int i = 0; i < n; i++) {
for (int j = 0; j < m; j++) {
if (grid[i][j] == '1') {
if ((i + j) % 2 == 0) black_ones++;
else white_ones++;
}
}
}
if (black_ones % 2 == 0 && white_ones % 2 == 0) {
cout << "YES\n";
} else {
cout << "NO\n";
}
return 0;
}
7.3.5 常用 Ad Hoc 技术
技术一:奇偶论证
很多不可能性来自奇偶性。若一个操作总是将某个量改变偶数,则该量的奇偶性是不变量。
使用场景:「能否将 A 变换为 B?」类题目。
应用方法:
- 确定每个操作对某个量 Q 做了什么
- 若每个操作将 Q 改变偶数,则 Q mod 2 是不变量
- 若 A 和 B 的 Q mod 2 不同,答案是「不可能」
技术二:鸽巢原理
N+1 件物品在 N 个类别中,至少有一个类别有 ≥ 2 件物品。
使用场景:「证明某物必然存在」或「找一个必然的碰撞」。
技术三:坐标压缩
当值很大但不同值的数量少时,将值映射到下标 0, 1, 2, ...
vector<int> vals = {1000000, 3, 999, 42, 1000000};
sort(vals.begin(), vals.end());
vals.erase(unique(vals.begin(), vals.end()), vals.end());
// vals 现在是 {3, 42, 999, 1000000}
auto compress = [&](int x) {
return lower_bound(vals.begin(), vals.end(), x) - vals.begin();
};
技术四:对称化简
若题目有对称性,只需要考虑每个等价类的一个代表元。
技术五:逆向思考
有时从目标状态反向到初始状态更容易。
技术六:重新表述题目
用不同形式重新陈述题目,揭示结构。
7.3.6 USACO Bronze Ad Hoc 示例
以下是来自实际 USACO Bronze 题目的模式(已改写):
模式:最少操作排序
题型: 给定一个序列,求排序所需的最少交换/移动次数。
关键洞察: 答案通常是 N 减去最长已排序子序列的长度,或与排列中的循环数有关。
循环分解方法:
📄 C++ 完整代码
// 用最少交换次数排序排列:
// 答案 = N - (排列中的循环数)
vector<int> perm = {3, 1, 4, 2}; // 1-indexed 值
int n = perm.size();
vector<bool> visited(n, false);
int cycles = 0;
for (int i = 0; i < n; i++) {
if (!visited[i]) {
cycles++;
int j = i;
while (!visited[j]) {
visited[j] = true;
j = perm[j] - 1; // 跟随排列(0-indexed)
}
}
}
cout << n - cycles << "\n"; // 最少交换次数
模式:有约束的可达性
题型: 用给定的移动规则能否从 A 到达 B?
关键洞察: 通常化简为奇偶性或模运算条件。
示例: 在数轴上可以移动 +3 或 -5,能否从 0 到达位置 T?
洞察: 能到达 gcd(3, 5) = 1 的任意倍数,所以能到达任意整数。但若移动是 +4 和 +6,只能到达 gcd(4, 6) = 2 的倍数。
📄 C++ 完整代码
#include <bits/stdc++.h>
using namespace std;
int main() {
int a, b, target;
cin >> a >> b >> target;
if (target % __gcd(a, b) == 0) {
cout << "YES\n";
} else {
cout << "NO\n";
}
return 0;
}
7.3.7 练习题
🟢 简单
P1. 围栏涂色 (USACO 2012 November Bronze) FJ 先把 a 到 b 的栏杆涂成红色,再把 c 到 d 涂成蓝色(蓝色覆盖红色),多少根是红色?蓝色?
💡 提示
用大小为 100 的数组(栏杆编号 1–100),先标记红色,再标记蓝色(覆盖),统计各颜色。
✅ 完整题解
#include <bits/stdc++.h>
using namespace std;
int main() {
int a, b, c, d; cin >> a >> b >> c >> d;
vector<char> post(101, '.');
for (int i = a; i <= b; i++) post[i] = 'R';
for (int i = c; i <= d; i++) post[i] = 'B';
int R = 0, B = 0;
for (int i = 1; i <= 100; i++) {
if (post[i] == 'R') R++;
else if (post[i] == 'B') B++;
}
cout << R << " " << B << "\n";
}
复杂度: O(100)——直接模拟。
P2. 各位数字之和步骤 从整数 X(1 ≤ X ≤ 10^9)开始,反复将 X 替换为各位数字之和,直到 X < 10,需要多少步?
💡 提示
直接模拟!值下降如此之快(9 位数的各位数字之和最多 81),最多 3 步就能到达个位数。
✅ 完整题解
#include <bits/stdc++.h>
using namespace std;
int main() {
long long x; cin >> x;
int steps = 0;
while (x >= 10) {
long long s = 0;
while (x > 0) { s += x % 10; x /= 10; }
x = s;
steps++;
}
cout << steps << "\n";
}
为什么这么快? 9 位数的各位数字之和 ≤ 9×9 = 81(2 位数),第二步 ≤ 8+1 = 9(1 位数),最多 3 步。
P3. 奶牛棋盘格 (ad hoc 网格) N×N 网格(N ≤ 100)像棋盘一样染色,可以交换任意两个相邻格子(水平或垂直),能否将初始配置变换为目标配置?
💡 提示
统计两个配置中为「1」的黑格数和为「1」的白格数。每次交换使两个计数各变化相同的量(各±1),所以差值(black_ones - white_ones)是不变量。若初始和目标差值不同,不可能。
🟡 中等
P4. 排列排序 给定 1..N 的一个排列,找将其排序所需的最少相邻交换次数。
💡 提示
最少相邻交换次数等于排列中的逆序对数(满足 i < j 但 perm[i] > perm[j] 的对数),用归并排序或树状数组在 O(N log N) 内统计。
✅ 完整题解(归并排序)
#include <bits/stdc++.h>
using namespace std;
long long mergeCount(vector<int>& a, int l, int r) {
if (l >= r) return 0;
int mid = (l + r) / 2;
long long inv = mergeCount(a, l, mid) + mergeCount(a, mid+1, r);
vector<int> tmp;
int i = l, j = mid + 1;
while (i <= mid && j <= r) {
if (a[i] <= a[j]) tmp.push_back(a[i++]);
else { tmp.push_back(a[j++]); inv += mid - i + 1; }
}
while (i <= mid) tmp.push_back(a[i++]);
while (j <= r) tmp.push_back(a[j++]);
for (int k = 0; k < (int)tmp.size(); k++) a[l+k] = tmp[k];
return inv;
}
int main() {
int n; cin >> n;
vector<int> a(n); for (int& x : a) cin >> x;
cout << mergeCount(a, 0, n-1) << "\n";
}
为什么逆序对 = 最少交换次数? 每次相邻交换恰好消除一个逆序对。复杂度: O(N log N)。
P5. 循环模拟 (USACO 风格) 函数 f 将 {1, ..., N} 映射到自身,从位置 1 开始反复应用 f,恰好 K 步后(K 最大 10^18)在哪里?
💡 提示
从 1 开始的序列最终必定循环(状态空间有限),用弗洛伊德算法或 visited 数组找循环起点和长度,然后用模运算找 K 步后的位置。
P6. 矩形并集面积 给定 M 个轴对齐矩形(M ≤ 100,坐标 ≤ 1000),求总覆盖面积(重叠区域只计一次)。
💡 提示
坐标 ≤ 1000,用 1000×1000 布尔网格,标记至少被一个矩形覆盖的每个格子,统计标记格子数。
🔴 困难
P7. 环面上的可达性 (不变量题) N×M 网格(带环绕——环面),从 (0,0) 出发,每步移动 (+a, 0) 或 (0, +b)(模 N 和模 M)。能否到达每个格子?
💡 提示
当且仅当 gcd(a, N) = 1 且 gcd(b, M) = 1 时,能到达每个格子。
P8. 最少交换次数分组 (USACO 2016 February Bronze) N 头奶牛站成一圈,每头是 A 型或 B 型,想让所有 A 型奶牛相邻,最少需要多少次相邻交换?
💡 提示
设 K = A 型奶牛数,对圆形排列中大小为 K 的所有窗口,统计每个窗口内的 B 型奶牛数(这些需要被换出),答案是所有窗口的最小值。用滑动窗口 O(N) 解决。
✅ 完整题解
#include <bits/stdc++.h>
using namespace std;
int main() {
int n; cin >> n;
string s; cin >> s;
int K = count(s.begin(), s.end(), 'A');
if (K == 0 || K == n) { cout << 0 << "\n"; return 0; }
string d = s + s;
int curB = 0;
for (int i = 0; i < K; i++) if (d[i] == 'B') curB++;
int best = curB;
for (int i = K; i < (int)d.size(); i++) {
if (d[i] == 'B') curB++;
if (d[i-K] == 'B') curB--;
best = min(best, curB);
}
cout << best << "\n";
}
复杂度: O(N)。
🏆 挑战
P9. 关灯 (经典 ad hoc) 5×5 的灯光网格,每个格子开或关。按下一个灯会切换它和所有正交邻居的状态,从初始配置开始,找让所有灯熄灭的最少按键次数,或报告不可能。
💡 提示
关键洞察:按一个灯两次等于没按,所以每个灯按 0 或 1 次,有 2^25 ≈ 3300 万种可能——直接暴力太多。
更好的洞察:一旦决定第一行的按键(2^5 = 32 种),其余行是被迫的(后续每行的按键由上一行是否全灭决定)。尝试所有 32 种第一行配置,检查最后一行是否全灭。
✅ 完整题解
#include <bits/stdc++.h>
using namespace std;
int grid[5][5];
int solve(int firstRow) {
int g[5][5]; memcpy(g, grid, sizeof(grid));
int presses = 0;
auto toggle = [&](int i, int j) {
if (i>=0 && i<5 && j>=0 && j<5) g[i][j] ^= 1;
};
auto press = [&](int i, int j) {
presses++;
toggle(i,j); toggle(i-1,j); toggle(i+1,j); toggle(i,j-1); toggle(i,j+1);
};
for (int j = 0; j < 5; j++)
if (firstRow & (1 << j)) press(0, j);
for (int i = 1; i < 5; i++)
for (int j = 0; j < 5; j++)
if (g[i-1][j] == 1) press(i, j);
for (int j = 0; j < 5; j++) if (g[4][j] == 1) return INT_MAX;
return presses;
}
int main() {
for (int i = 0; i < 5; i++)
for (int j = 0; j < 5; j++) cin >> grid[i][j];
int best = INT_MAX;
for (int mask = 0; mask < 32; mask++)
best = min(best, solve(mask));
if (best == INT_MAX) cout << "impossible\n";
else cout << best << "\n";
}
为什么有效: 一旦选定第一行的按键,后续每行的按键被迫:当且仅当上一行某格仍亮时才按下面的格。检查最后一行的可行性。复杂度: 32 × O(25) ≈ O(800)。
7.3.8 USACO Silver 中的 Ad Hoc
Silver 级别的 ad hoc 题目更少但更难,通常将观察与标准算法结合。
Silver Ad Hoc 模式
| 模式 | 描述 | 示例 |
|---|---|---|
| 观察 + BFS | 关键洞察缩小状态空间,然后 BFS | 「奶牛只能移动到同色格子」→ 在化简后的图上 BFS |
| 观察 + DP | 洞察揭示 DP 结构 | 「最优解总有这个性质」→ 带该性质的 DP |
| 观察 + 二分 | 洞察让检查函数变简单 | 「答案是单调的」→ 二分答案 |
| 纯粹观察 | 不需要标准算法 | 「答案总是 ⌈N/2⌉」 |
如何处理 Silver Ad Hoc
- 无法识别算法类型时不要慌
- 试小例子 — N=2, 3, 4 — 寻找规律
- 问:这道题特别在哪里? — 是什么性质让它与一般版本不同?
- 考虑:如果能解决更简单的版本怎么办? — 然后推广
- 相信你的观察 — 若在小例子中发现了规律,大概率是正确的
本章总结
📌 核心要点
| 概念 | 要点 |
|---|---|
| 定义 | Ad hoc = 无标准算法;需要特定于题目的洞察 |
| 识别 | 无法识别算法类型 → 可能是 ad hoc |
| 方法 | 小例子 → 找规律 → 证明 → 实现 |
| 不变量 | 找操作保持的量 → 证明不可能性 |
| 模拟捷径 | T 很大 → 找循环 → 用模运算 |
| 奇偶性 | 很多不可能性来自奇偶论证 |
| 构造 | 直接建立答案而非搜索 |
🧩 Ad Hoc 解题检查清单
当你怀疑一道题是 ad hoc 时:
- 试 N = 1, 2, 3, 4 — 手动计算答案
- 寻找公式 — 答案遵循简单规律吗?
- 检查奇偶性 — 有排除某些配置的不变量吗?
- 寻找循环 — 若在模拟,状态会重复吗?
- 考虑极端情况 — 所有值相等怎么样?全部最大?
- 重新表述 — 能把题目改写成更简单的形式吗?
- 逆向思考 — 逆问题更简单吗?
- 相信小例子的规律 — 若 N=2,3,4,5 都成立,一般情况大概率也成立
❓ 常见问题
Q1:怎么判断一道题是 ad hoc 还是我还没学到的标准算法?
A:这确实很难判断。一个好的经验法则:若题目约束小(N ≤ 100)且没有明显涉及图、DP 或排序,很可能是 ad hoc。若 N ≤ 10^5 且无法识别算法,可能是你遗漏了某个标准技术——解完后检查题目标签。
Q2:我在小例子中找到了规律但无法证明,应该提交吗?
A:竞赛中,应该——提交后继续。实际练习中,尝试理解为什么规律成立。未经证明的规律有时在边界情况下失败。但基于规律的解法获得部分分好过什么都没有。
Q3:Ad hoc 题感觉不可能解,如何提高?
A:只有练习才能提高。解 20-30 道 ad hoc 题,每道之后写下:「关键洞察是什么?我怎么能更快找到它?」随着时间推移,你会积累一个技术库(奇偶性、循环、不变量等),在新题中能识别出来。
Q4:有没有系统的方法找不变量?
A:有。对题目中的每个操作,问:「这个操作对某个量 Q 做了什么?变化了多少?」若一个操作总是将 Q 改变 K 的倍数,则 Q mod K 是不变量。常见不变量:奇偶性(mod 2)、总和 mod K、逆序对数 mod 2。
🔗 与其他章节的联系
- 第 7.1 章(了解 USACO):Ad hoc 是 10 个 Bronze 题目类别之一;本章给了它应得的深度
- 第 7.2 章(解题策略):算法决策树以「贪心/模拟」结尾——ad hoc 题完全在树之外
- 第 3.4 章(双指针):滑动窗口技术出现在几道 ad hoc 题中(如上面的 P8)
- 第 3.2 章(前缀和):很多 ad hoc 计数题以前缀和为子步骤
- 附录 E(数学基础):GCD、模运算和数论是很多 ad hoc 洞察的基础
🐄 最后的想法: Ad hoc 题是竞赛编程成为一门艺术的地方。没有公式——只有细心的观察、创造性的思考,以及找到看似不可能的题目的优雅解法时的满足感。拥抱这种挣扎。
第八部分: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 级别,挑战在于:
- 识别 — 判断哪种技术适用,题目叙述往往加以掩盖
- 组合 — 结合两种或多种技术(如 DSU + 排序构造 MST,欧拉游览 + BIT 处理树上查询)
- 效率 — Silver 中 O(N²) 的思路在 Gold 中需要优化到 O(N log N)
- 证明 — 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 章:最小生成树
📝 前置要求: 本章需要第 5.1~5.3 章(图、BFS/DFS、并查集/DSU)的知识。阅读 Kruskal 算法前,必须理解 DSU 的
find和union操作。
带权无向图的**最小生成树(MST)**是满足以下条件的边的子集:
- 连通所有 N 个顶点(生成)
- 不包含环(树)
- 边权总和尽可能小(最小)
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) 的时间检测是否成环。
算法流程:
- 将所有边按权重从小到大排序
- 用 N 个分量初始化 DSU(每个顶点自成一组)
- 对每条边 (u, v, w)(按排序顺序):
- 若
find(u) ≠ find(v):将此边加入 MST,调用union(u, v) - 否则:跳过(会构成环)
- 若
- 当 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 框架
⚠️ 常见错误
-
忘记判断连通性: 不是所有图都连通。Kruskal 结束后,验证
edges_added == n - 1;Prim 结束后,验证edges_added == n。 -
DSU 实现有误(未路径压缩或未按秩合并): 朴素 DSU 不加优化时每次操作 O(N),导致 Kruskal 整体为 O(E·N) 而非 O(E log E)。
-
边数差一: MST 有 N−1 条边。若停在 N 条边,则多加了一条。
-
将 Kruskal 用于有向图: 两种算法都假设无向边。有向图需要不同方法(最小树形图 / 朱-刘/Edmonds 算法——USACO Gold 不考)。
-
整数溢出: 若边权最大 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 章:拓扑排序与 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。
- 第一遍: 在原图上运行 DFS,按完成时间将顶点压入栈。
- 第二遍: 在转置图(所有边反向)上,按完成时间的逆序(弹栈顺序)处理顶点。第二遍中每棵 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 对比
| Tarjan | Kosaraju | |
|---|---|---|
| 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
⚠️ 常见错误
-
混淆有向图与无向图的环: 拓扑排序只适用于有向图。无向图中,任意连通分量都有生成树——不需要"环检测"。
-
DP 初始化差一: 对"从源点出发的路径计数",初始化
cnt[source] = 1,而非 0。对"最长路径",若计边数则初始化dp[v] = 0;若路径可能不存在,需正确处理 -∞。 -
忽略不可达顶点: DAG 最短路中若
dist[u] == INT_MAX,跳过该顶点——从不可达顶点出发会得到错误值。 -
大图 DFS 拓扑排序栈溢出: N = 10⁵ 且有深链时,递归 DFS 可能栈溢出。对大输入优先使用 Kahn 算法(BFS)。
-
在有环图上使用拓扑排序: 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-SAT | N 个布尔变量 + 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] = 0,dp[其他] = -∞。对每条边 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 章:树形 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 换根的一般模式
换根技术可以推广到许多问题。关键是找到:
- 原始根定下来后,
down[v]表示什么? - 将根从父节点 u "换"到子节点 v 时,答案如何变化?
- 从
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,最远的顶点要么:
- 在 v 的子树中(由 DFS 1 中的
down[]计算) - 经过 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 维护双最深
⚠️ 常见错误
-
缺少
if (v == par) continue: 没有这个检查,DFS 会沿着边回到父节点,导致无限递归。每棵树的 DFS 都必须有这个保护。 -
叶节点基础情况初始化错误: 叶节点没有子节点,循环体不会执行。确保循环之前
dp[leaf]已正确初始化。 -
换根的 dfs2 忘了用前序: dfs2 必须从父节点向子节点传播(自顶向下),所以
ans[u]必须在ans[u 的子节点]之前计算。不要不小心用成后序。 -
子树和的整数溢出: 若 N = 10⁵ 且每个顶点贡献最多 N,总和可达 10¹⁰。使用
long long。 -
"距离之和"的差一错误: 公式
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 章:欧拉游览与树的展开
📝 前置要求: 本章需要第 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 路径上所有顶点加 deltaquery(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_heavyO(N) +decomposeO(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 是否足够大
⚠️ 常见错误
-
LOG 取值有误: N ≤ 10⁵ 时用
LOG = 17(2^17 = 131072 > 10⁵);N ≤ 10⁶ 时用LOG = 20。 -
根节点的父节点哨兵: 根节点没有父节点。将
up[root][0] = root(指向自身),避免倍增时越界。 -
欧拉游览计时器的差一: 若 BIT 是 1-indexed,则计时器从 1 开始(而非 0)。
-
路径和公式出错: 注意要减去
prefix[parent(LCA)],而非prefix[LCA]。LCA 顶点本身在路径上,应被计入一次。 -
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 查询——无需离线处理即可处理路径查询。
- 第 3.9 章(线段树): 当 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 子树中所有顶点加deltaquery v:返回顶点 v 的当前值
提示
欧拉游览将 v 的子树映射为区间 [in[v], out[v]]。使用差分 BIT:O(log N) 区间更新,O(log N) 单点查询。
🔴 困难
8.4-H1. 带更新的路径查询 (USACO Gold 难度)
给定带权树,处理 Q 次操作:
update u val:将顶点 u 的值设为 valquery 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 章:组合数学与数论
📝 前置要求: 本章需要基础代数知识以及附录 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^k | n^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/组合数学中的应用
-
费马小定理的推广: 对满足 gcd(a, n) = 1 的任意 a:a^φ(n) ≡ 1 (mod n)。这就是欧拉定理。
-
原根/乘法阶: a 的阶整除 φ(n)。
-
项链计数(Burnside): 公式中用到 N 的每个因子 d 对应的 φ(d)。
-
φ 的求和: Σ_{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;
}
识别信号: 组合数计算在某些特殊输入下崩溃或返回极大值 → 检查边界条件
⚠️ 常见错误
-
a * b % MOD中的整数溢出: 若 a, b ≈ 10⁹,则a * b可能溢出int甚至long long。务必先转换:(long long)a * b % MOD。 -
减法结果为负:
(a - b) % MOD在 C++ 中可能为负数。始终写成(a - b + MOD) % MOD。 -
inv_fact[0] = 1: 确保inv_fact[0] = 1(因为 0! = 1)。precompute_factorials中的倒序循环会处理此问题。 -
C(n, k) 当 k > n 或 k < 0 时为 0: 始终检查这些边界情况。
-
MOD 不是素数: 费马小定理要求 p 为素数。若题目使用非素数模数(罕见),用 ext_gcd 求模逆元。
-
大 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 p | fact[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 常用数据类型
| 类型 | 大小 | 范围 | 使用场景 |
|---|---|---|---|
int | 32 位 | ±2.1 × 10^9 | 默认整数 |
long long | 64 位 | ±9.2 × 10^18 | 大数、乘积 |
double | 64 位 | ~15 位有效数字 | 小数 |
bool | 1 字节 | true/false | 标志 |
char | 8 位 | -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 参考
上面的彩色表格能让你一眼看出可行性。读题时,找到列中的 N 和行中的算法复杂度,看它是否能在 1 秒内通过。
| N | 最大可行复杂度 | 算法层级 |
|---|---|---|
| N ≤ 12 | O(N! × N) | 所有排列 |
| N ≤ 20 | O(2^N × N) | 所有子集 + 线性操作 |
| N ≤ 500 | O(N³) | 3 重嵌套循环、区间 DP |
| N ≤ 5000 | O(N²) | 2 重嵌套循环、O(N²) DP |
| N ≤ 10^5 | O(N log N) | 排序、BFS、二分查找 |
| N ≤ 10^6 | O(N) | 线性扫描、前缀和 |
| N ≤ 10^8 | O(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 常用 #define 和 typedef
📄 查看代码: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-2 小时
- 若卡住,看下面的提示(不是完整题解)
- 若再过 30 分钟仍卡住,在 USACO 网站上读题解
- 解完(或读完题解)后,从零自行实现解法
当你挣扎后再理解时学习最多,而不是被动地读解法。
第一节:模拟与暴力(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 2019 January Silver | 主题: 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 最短路
竞赛: USACO 2016 February Bronze:牛奶桶(改编)| 主题: 状态空间 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 的最优分配。
技术: 排序、二分查找、贪心 | 难度: ⭐⭐⭐⭐
快速参考:按技术分类的题目
| 技术 | 题目编号 |
|---|---|
| 模拟 | 1, 2, 3, 5 |
| 排序 | 4, 8, 9, 10, 16 |
| 前缀和 | 6, 17 |
| 二分查找 | 4, 8, 9, 10, 12 |
| BFS / DFS | 13, 14 |
| 并查集 | 11, 12 |
| 动态规划 | 7, 15, 18, 20 |
| 贪心 | 16, 20 |
| 字符串 / Ad hoc | 19 |
练习建议
- 在 train.usaco.org 上使用 USACO 训练门户自动评测
- 每道题后都读题解(在 usaco.org 上)——哪怕是你解出来的题
- 保持题目日志——写下每道题的关键洞察
- 难度进阶:从近年简单题做起,再做老年份的中等题
其他题目来源
| 来源 | 网址 | 最适合 |
|---|---|---|
| USACO 题库 | usaco.org | USACO 专项练习 |
| USACO Guide | usaco.guide | 带题目的结构化课程 |
| Codeforces | codeforces.com | 大量练习、多样题目 |
| AtCoder Beginner | atcoder.jp | 高质量入门题 |
| LeetCode | leetcode.com | 数据结构基础 |
| CSES | cses.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,
// 解除绑定消除这个不必要的清空。
性能差异很显著——这两行应该出现在每个解法中:
文件 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)——带更新的前缀和
树状数组使用最低置位技巧实现 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) 每次操作 | — |
| BFS | O(V+E) | <queue> |
| DFS | O(V+E) | <stack> |
| Dijkstra | O((V+E) log V) | <queue> |
| 二分查找 | O(log N) | <algorithm> |
| 排序 | O(N log N) | <algorithm> |
| 模意义快速幂 | O(log exp) | — |
| lower/upper_bound | O(log N) | <algorithm> |
✅ 所有示例均在 C++17(
-std=c++17 -O2)下编译通过并经过验证。
附录 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)取模,将所有运算在模意义下进行。
时钟类比与关键性质——记住每次算术运算后都要取模:
常用模数
| 常量 | 数值 | 为何选这个值? |
|---|---|---|
1e9 + 7 | 1,000,000,007 | 素数,适合 int(< 2³¹),使用最广泛 |
1e9 + 9 | 1,000,000,009 | 素数,1e9+7 的备选 |
998244353 | 998,244,353 | NTT 友好素数(用于多项式运算) |
基础模运算模板
📄 查看代码:基础模运算模板
// 解答:模运算基础
#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,则: