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

🌐 English Version | 📖 当前:中文版

章节数 难度 语言

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

📚 本书结构

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

🚀 如何使用本书

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

💡 学习建议

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

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

⚡ 第二部分:C++ 基础

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

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

第二部分:C++ 基础

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

你将学到什么

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

为什么选择 C++?

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

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

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

第二部分学习建议

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

出发!

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

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

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

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

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

2.1.0 搭建开发环境

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

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

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

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

使用 Ideone(新手最简单):

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

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

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

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

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

安装步骤:

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

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

第二步:安装 CLion

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

第三步:创建第一个项目

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

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

CLion 竞赛编程实用功能:

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

如何编译和运行(本地)

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

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

逐字分解这条命令:

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

运行方法:

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

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


2.1.1 Hello, World!

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

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

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

运行后你应该看到:

Hello, World!

每一行的含义:

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

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

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

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

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

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

编译流程

图示:编译流程

C++ Compilation Pipeline

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


2.1.2 竞赛选手的标准模板

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

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

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

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

    return 0;
}

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

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

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

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

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

为什么用 using namespace std;

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

I/O 加速行

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

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

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


2.1.3 变量与数据类型

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

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

当你写:   int score = 100;

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

Variable Memory Box

竞赛编程必备类型

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

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

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

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

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

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

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

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

    return 0;
}

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

C++ Variable Types

如何选择正确的类型

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

变量命名规则

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

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

合法名称必须:

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

以下名称无法编译:

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

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

常见命名风格

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

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

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

命名最佳实践

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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


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

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

ASCII Table Key Ranges

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

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

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

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

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

    return 0;
}

charint 转换——最常用的技巧

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

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

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

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

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

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

// 仅对数字 0~9 有效

3. 大小写互转

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

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

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

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

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

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

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

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

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

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

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

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

string 详细指南

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

基本操作:

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

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

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

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

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

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

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

    return 0;
}

遍历字符串:

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

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

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

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

常用 string 函数:

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

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

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

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

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

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

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

从输入读取字符串:

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

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

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

string 与数字互转:

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

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

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

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

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

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

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

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

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

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

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


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

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

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

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

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

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

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

如何避免溢出:

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

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

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

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


2.1.4 用 cincout 进行输入输出

cout 打印输出

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

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

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

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

cin 读取输入

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

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

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

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

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

两种都用以下代码处理:

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

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

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

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

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

复杂度分析:

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

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

20
40
60
80
100

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

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

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

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

控制小数输出精度

double y = 3.14159;

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

2.1.5 基本算术

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

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

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

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

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

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

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

    return 0;
}

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

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

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

2.1.6 你的第一个 USACO 风格程序

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

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

分析思路:

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

💡 新手解题流程:

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

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

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

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

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

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

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

    return 0;
}

复杂度分析:

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

样例输入:

17 5

样例输出:

22
12
85
3
2

⚠️ 第 2.1 章常见错误

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

本章总结

📌 核心要点

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

❓ 常见问题

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

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

Q2:默认用 int 还是 long long

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

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

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

Q4:可以省略 return 0; 吗?

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

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

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

🔗 与后续章节的联系

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

练习题

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


🌡️ 热身题

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


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

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

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

💡 题解(点击展开)

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

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

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

关键点:

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

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

💡 题解(点击展开)

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

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

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

关键点:

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

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

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

💡 题解(点击展开)

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

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

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

关键点:

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

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

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

💡 题解(点击展开)

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

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

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

关键点:

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

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

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

💡 题解(点击展开)

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

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

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

关键点:

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

🏋️ 核心练习题

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


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

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

💡 题解(点击展开)

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

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

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

关键点:

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

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

样例输入: 3661 样例输出:

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

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

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

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

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

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

    return 0;
}

关键点:

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

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

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

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

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

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

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

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

    return 0;
}

关键点:

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

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

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

💡 题解(点击展开)

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

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

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

    double fahrenheit = celsius * 9.0 / 5.0 + 32.0;

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

    return 0;
}

关键点:

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

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

样例输入:

3 2 1 4

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

样例输出: 104

💡 题解(点击展开)

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

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

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

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

    cout << total << "\n";

    return 0;
}

关键点:

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

🏆 挑战题

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


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

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

int product: -1294967296
long long product: 3000000000

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

💡 题解(点击展开)

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

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

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

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

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

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

    return 0;
}

关键点:

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

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

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

💡 题解(点击展开)

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

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

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

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

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

    return 0;
}

关键点:

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

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

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

只打印象限数字:1234

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

💡 题解(点击展开)

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

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

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

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

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

    return 0;
}

关键点:

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

第 2.2 章:控制流

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


2.2.0 什么是「控制流」?

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

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

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

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

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

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

这是控制流的总览:

Control Flow Overview

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


2.2.1 if 语句

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

基本 if

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

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

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

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

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

if / else

int score;
cin >> score;

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

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

if / else if / else

需要检查多个条件时:

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

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

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

若 score = 85:

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

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

比较运算符

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

逻辑运算符(组合条件)

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

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

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

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

🐛 常见 Bug:= vs ==

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

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

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

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

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

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

嵌套 if 语句

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

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

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

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


2.2.2 while 循环

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

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

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

输出:

1
2
3
4
5

🐛 常见 Bug:死循环

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

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

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

什么时候用 while vs for

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

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

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

do-while 循环

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

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

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


2.2.3 for 循环

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

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

等价于:

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

图示:for 循环流程图

For Loop Flowchart

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

常见 for 循环模式

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

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

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

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

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

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

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

Loop Trace Example

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

最常见的 USACO 循环模式

读取 N 个数并逐一处理:

int n;
cin >> n;

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

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

2.2.4 嵌套循环

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

Nested Loop Clock Analogy

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

输出:

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

追踪前两行:

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

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

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

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

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

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

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


2.2.5 switch 语句

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

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

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

什么时候用 switch vs if-else

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

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

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

2.2.6 breakcontinue

break —— 立即退出循环

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

continue —— 跳到下一次迭代

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

嵌套循环中的 break

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

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

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

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

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

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

int n;
cin >> n;

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

复杂度分析:

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

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

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

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

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

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

复杂度分析:

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

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

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

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

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

模式四:打印星号三角形

int n;
cin >> n;

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

n=4 时输出:

*
**
***
****

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

int n;
cin >> n;

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

追踪 n = 12345:

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

2.2.8 完整示例:USACO 风格题目

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

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

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

    int n;
    cin >> n;

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

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

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

    double avg = (double)sum / n;

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

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

    return 0;
}

样例输入:

5
10 20 30 40 50

样例输出:

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

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

复杂度分析:

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

⚠️ 第 2.2 章常见错误

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

本章总结

📌 核心要点

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

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

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

❓ 常见问题

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

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

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

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

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

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

Q4:switchif-else if 哪个更快?

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

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

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

🔗 与后续章节的联系

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

练习题


🌡️ 热身题


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

💡 题解(点击展开)

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

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

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

关键点:

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

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

💡 题解(点击展开)

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

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

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

关键点:

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

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

样例输入: -5输出: Negative

💡 题解(点击展开)

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

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

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

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

    return 0;
}

关键点:

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

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

💡 题解(点击展开)

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

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

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

关键点:

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

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

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

💡 题解(点击展开)

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

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

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

关键点:

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

🏋️ 核心练习题


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

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

输出开头几行:

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

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

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

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

关键点:

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

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

样例输入:

5
8 3 7 1 9

样例输出: 1

💡 题解(点击展开)

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

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

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

    int n;
    cin >> n;

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

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

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

关键点:

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

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

样例输入:

6
3 -1 0 5 -2 7

样例输出: 3

💡 题解(点击展开)

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

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

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

    int n;
    cin >> n;

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

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

关键点:

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

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

样例输入: 4

样例输出:

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

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

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

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

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

    return 0;
}

关键点:

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

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

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

💡 题解(点击展开)

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

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

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

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

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

关键点:

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

🏆 挑战题


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

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

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

样例输入: 6 样例输出:

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

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

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

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

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

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

    return 0;
}

关键点:

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

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

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

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

💡 题解(点击展开)

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

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

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

    bool isPrime = true;

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

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

关键点:

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

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

样例输入:

4
Bessie 95
Elsie 82
Moo 95
Daisy 88

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

💡 题解(点击展开)

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

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

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

    int n;
    cin >> n;

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

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

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

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

关键点:

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

第 2.3 章:函数与数组

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

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


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

🍕 食谱类比

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

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

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

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

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

什么时候写函数

以下情况使用函数:

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

函数的基本语法

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

你的第一批函数

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

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

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

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

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

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

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

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

    return 0;
}

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

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

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

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

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

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

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

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

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

2.3.2 void 函数 vs 返回值函数

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

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

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

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

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

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

多个 return 语句

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

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

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

2.3.3 按值传递 vs 按引用传递

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

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

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

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

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

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

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

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

什么时候用哪种

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

通过引用返回多个值

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

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

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

2.3.4 递归

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

经典示例:阶乘

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

💡 递归思维三步法:

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

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

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

追踪 factorial(4)

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

每个递归函数都需要:

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

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


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

🏠 邮箱类比

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

Array Index Visual

图示:数组内存布局

Array Memory Layout

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

数组基础

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

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

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

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

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

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

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

    return 0;
}

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

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

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

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

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

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

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

大数组用全局变量

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

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

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

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

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


2.3.6 常见数组算法

求和、最大值、最小值

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

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

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

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

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

复杂度分析:

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

翻转数组

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

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

复杂度分析:

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

二维数组

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

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

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

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

输出:

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

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

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

数组 vs 向量对比

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

向量基础

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

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

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

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

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

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

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

    return 0;
}

创建带初始值的向量

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

遍历向量

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

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

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

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

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

USACO 中使用向量的标准模式

int n;
cin >> n;

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

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

二维向量

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

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

2.3.8 向函数传递数组和向量

数组

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

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

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

向量

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

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

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

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

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


⚠️ 第 2.3 章常见错误

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

本章总结

📌 核心要点

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

❓ 常见问题

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

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

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

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

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

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

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

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

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

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

🔗 与后续章节的联系

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

练习题


🌡️ 热身题


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

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

💡 题解(点击展开)

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

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

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

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

关键点:

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

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

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

💡 题解(点击展开)

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

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

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

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

关键点:

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

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

期望输出:

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

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

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

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

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

    return 0;
}

关键点:

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

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

期望输出: 150

💡 题解(点击展开)

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

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

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

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

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

关键点:

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

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

样例输入: 3 样例输出:

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

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

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

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

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

关键点:

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

🏋️ 核心练习题


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

样例输入:

5
1 2 3 4 5

样例输出: 5 4 3 2 1

💡 题解(点击展开)

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

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

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

    int n;
    cin >> n;

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

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

    return 0;
}

关键点:

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

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

样例输入:

4
10 20 30 40

样例输出:

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

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

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

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

    int n;
    cin >> n;

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

    return 0;
}

关键点:

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

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

样例输入:

7
3 1 2 3 3 1 7

样例输出:

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

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

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

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

    int n;
    cin >> n;

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

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

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

    return 0;
}

关键点:

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

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

样例输入:

5 9
1 4 5 6 3

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

💡 题解(点击展开)

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

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

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

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

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

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

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

    return 0;
}

关键点:

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

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

样例输入:

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

样例输出:

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

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

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

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

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

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

    int n;
    cin >> n;

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

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

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

    return 0;
}

关键点:

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

复杂度分析:

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

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


🏆 挑战题


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

样例输入:

5 2
1 2 3 4 5

样例输出: 4 5 1 2 3

💡 题解(点击展开)

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

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

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

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

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

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

    return 0;
}

关键点:

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

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

样例输入:

3
1 3 5
4
2 4 6 8

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

💡 题解(点击展开)

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

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

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

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

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

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

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

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

    return 0;
}

关键点:

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

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

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

样例输入:

4
1 2
5 1
8 3
15 1

样例输出: 1

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

💡 题解(点击展开)

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

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

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

    int n;
    cin >> n;

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

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

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

关键点:

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

第 2.4 章:结构体与类

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

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


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

🎒 背包类比

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

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

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

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

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

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

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

2.4.2 结构体基础

定义结构体

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

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

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

初始化方式

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

// 方式二:指定初始化(C++20)
Point p2 = {.x = 3, .y = 7};

// 方式三:逐字段赋值
Point p3;
p3.x = 3;
p3.y = 7;

💡 竞赛技巧: 竞赛编程中,聚合初始化 {val1, val2, ...} 是最常用的风格——输入快,读起来简洁。

带构造函数的结构体

可以定义构造函数让创建实例更简洁:

📄 可以定义**构造函数**让创建实例更简洁:
struct Point {
    int x, y;

    // 构造函数
    Point(int _x, int _y) : x(_x), y(_y) {}
};

int main() {
    Point p(3, 7);  // 调用构造函数
    cout << p.x << " " << p.y << endl;  // 3 7
}

⚠️ 警告: 一旦定义了自定义构造函数,就不能再用 Point p;(无参数)了,除非同时提供默认构造函数或给参数设默认值。

struct Point {
    int x, y;

    Point() : x(0), y(0) {}            // 默认构造函数
    Point(int _x, int _y) : x(_x), y(_y) {}  // 有参构造函数
};

Point p1;       // OK——使用默认构造函数,x=0, y=0
Point p2(3, 7); // OK——使用有参构造函数

2.4.3 结构体在竞赛编程中的应用

存储图论问题中的边

📄 查看代码:存储图论问题中的边
struct Edge {
    int from, to, weight;
};

int main() {
    int n, m;
    cin >> n >> m;

    vector<Edge> edges(m);
    for (int i = 0; i < m; i++) {
        cin >> edges[i].from >> edges[i].to >> edges[i].weight;
    }
}

自定义比较来排序结构体

这在 USACO 题目中极为常见。你经常需要按某个特定字段排序。

方法一:在结构体内重载 operator<

📄 C++ 完整代码
struct Event {
    int start, end;

    // 按结束时间排序(贪心调度)
    bool operator<(const Event& other) const {
        return end < other.end;
    }
};

int main() {
    vector<Event> events = {{1, 4}, {3, 5}, {0, 6}, {5, 7}, {3, 8}, {5, 9}};
    sort(events.begin(), events.end());  // 自动使用 operator<

    for (auto& e : events) {
        cout << "[" << e.start << ", " << e.end << "] ";
    }
}

方法二:lambda 比较器(更灵活)

📄 C++ 完整代码
struct Event {
    int start, end;
};

int main() {
    vector<Event> events = {{1, 4}, {3, 5}, {0, 6}, {5, 7}};

    // 按开始时间排序
    sort(events.begin(), events.end(), [](const Event& a, const Event& b) {
        return a.start < b.start;
    });
}

方法三:编写比较函数

bool compareByEnd(const Event& a, const Event& b) {
    return a.end < b.end;
}

sort(events.begin(), events.end(), compareByEnd);

💡 竞赛技巧: 对大多数 USACO 题,方法一(运算符重载)在只有一种自然排序顺序时最简洁。当同一程序需要多种不同排序顺序时,用方法二(lambda)。

多关键字排序

有时需要先按一个字段排序,再用另一个字段打破平局:

struct Student {
    string name;
    int score;

    bool operator<(const Student& other) const {
        if (score != other.score) return score > other.score;  // 高分优先
        return name < other.name;  // 分数相同时按姓名字典序
    }
};

或用 tie() 写法更简洁:

struct Student {
    string name;
    int score;

    bool operator<(const Student& other) const {
        // 按分数降序,再按姓名升序
        return tie(other.score, name) < tie(score, other.name);
    }
};

💡 tie() 技巧: tie() 创建一个元组用于字典序比较。调换元素顺序可反转该字段的排序方向。这是竞赛编程中非常常见的技巧。

在集合和映射中使用结构体

如果想把结构体作为 setmap 的键,必须定义 operator<

📄 如果想把结构体作为 `set` 或 `map` 的键,**必须**定义 `operator<`:
struct Point {
    int x, y;
    bool operator<(const Point& other) const {
        return tie(x, y) < tie(other.x, other.y);
    }
};

set<Point> visited;
visited.insert({1, 2});
visited.insert({3, 4});

if (visited.count({1, 2})) {
    cout << "已访问过!" << endl;
}

在优先队列中使用结构体

📄 查看代码:在优先队列中使用结构体
struct State {
    int dist, node;

    // 最小堆:希望 dist 最小的在顶部
    // priority_queue 默认是最大堆,所以反转比较
    bool operator>(const State& other) const {
        return dist > other.dist;
    }
};

// 用 operator> 实现最小堆
priority_queue<State, vector<State>, greater<State>> pq;
pq.push({0, 1});   // 距离 0,节点 1
pq.push({5, 2});   // 距离 5,节点 2

auto top = pq.top();  // {0, 1}——距离最小

2.4.4 struct vs class——有什么区别?

实际情况是:struct 和 class 在 C++ 中几乎完全相同。唯一区别是默认访问级别

特性structclass
默认访问publicprivate
可以有方法?✅ 可以✅ 可以
可以有构造函数?✅ 可以✅ 可以
可以继承?✅ 可以✅ 可以
// 这两个功能上完全相同:

struct PointS {
    int x, y;  // 默认 public
};

class PointC {
public:          // 必须显式声明为 public
    int x, y;
};

什么时候用哪个?

struct 当……class 当……
简单数据容器有不变量的复杂对象
竞赛编程(几乎始终)面向对象设计项目
所有成员都是 public需要封装(私有数据)
想要最少的样板代码构建库或大型系统

💡 竞赛惯例: 竞赛编程中,始终用 struct。它更简单、更简短,而且几乎从不需要私有成员。几乎每个竞赛选手的代码里都是 struct


2.4.5 类——完整视角

尽管 struct 足以应付竞赛编程,理解 class 对更广泛的 C++ 知识很有价值。

访问修饰符

📄 查看代码:访问修饰符
class BankAccount {
private:    // 只能在类内部访问
    double balance;

public:     // 可以从任何地方访问
    BankAccount(double initial) : balance(initial) {}

    void deposit(double amount) {
        if (amount > 0) {
            balance += amount;
        }
    }

    void withdraw(double amount) {
        if (amount > 0 && amount <= balance) {
            balance -= amount;
        }
    }

    double getBalance() const {
        return balance;
    }
};

int main() {
    BankAccount acc(100.0);
    acc.deposit(50.0);
    acc.withdraw(30.0);
    cout << acc.getBalance() << endl;  // 120.0

    // acc.balance = 999999;  // 错误!balance 是私有的
}

为什么需要封装?

想象一台自动贩卖机:

- 公共接口:投币、按按钮、取饮料
- 私有内部:硬币计数器、库存、温控系统

你通过公共按钮与机器交互。
你不能直接伸手取饮料。

封装保护数据不被滥用。

竞赛编程中不需要这种级别的保护——代码速度更重要。但在软件工程中,它能防止大型代码库中的 bug。

成员函数(方法)

structclass 都可以有成员函数:

📄 `struct` 和 `class` 都可以有成员函数:
struct Rect {
    int width, height;

    int area() const {
        return width * height;
    }

    int perimeter() const {
        return 2 * (width + height);
    }

    bool contains(int x, int y) const {
        return x >= 0 && x < width && y >= 0 && y < height;
    }
};

int main() {
    Rect r = {10, 5};
    cout << "面积:" << r.area() << endl;      // 50
    cout << "周长:" << r.perimeter() << endl;  // 30
    cout << r.contains(3, 4) << endl;            // 1(true)
}

💡 方法名后的 const 方法名后的 const 关键字表示「这个方法不修改对象」。如果方法只读取数据,始终标记为 const——这是良好实践,在使用 const 引用时也是必须的。


2.4.6 竞赛编程中的进阶结构体模式

pair——内置的「两字段结构体」

C++ 提供了 pair 作为只需要两个字段时的轻量替代:

📄 C++ 提供了 `pair` 作为只需要两个字段时的轻量替代:
#include <bits/stdc++.h>
using namespace std;

int main() {
    pair<int, int> p = {3, 7};
    cout << p.first << " " << p.second << endl;  // 3 7

    // pair 有内置比较(字典序)
    vector<pair<int, int>> v = {{3, 1}, {1, 5}, {3, 0}, {1, 2}};
    sort(v.begin(), v.end());
    // 结果:{1, 2}, {1, 5}, {3, 0}, {3, 1}

    // 可以用 make_pair 或直接用花括号
    auto q = make_pair(10, 20);
}

什么时候用 pair vs 自定义 struct

pair用自定义 struct
只有 2 个字段3 个及以上字段
字段不需要有意义的名称需要描述性字段名
临时分组代码清晰度很重要

tuple——内置的「N 字段结构体」

tuple<int, string, double> t = {42, "Alice", 3.14};
cout << get<0>(t) << endl;  // 42
cout << get<1>(t) << endl;  // Alice

// 结构化绑定(C++17)——更简洁
auto [id, name, gpa] = t;
cout << name << " 的 GPA 是 " << gpa << endl;

💡 竞赛技巧: 超过 2 个字段时,带名称的 struct 几乎总是比 tuple 更易读。pair 可以随意用,但能用 struct 时就别用 tuple

arrayvector 成员的结构体

📄 查看代码:含 array 或 vector 成员的结构体
struct Graph {
    int n;
    vector<vector<int>> adj;

    Graph(int _n) : n(_n), adj(_n) {}

    void addEdge(int u, int v) {
        adj[u].push_back(v);
        adj[v].push_back(u);
    }
};

int main() {
    Graph g(5);
    g.addEdge(0, 1);
    g.addEdge(1, 2);

    for (int v : g.adj[1]) {
        cout << v << " ";  // 0 2
    }
}

2.4.7 常见错误

❌ 错误一:忘记 } 后的分号

struct Point {
    int x, y;
}   // ← 缺少分号!

int main() { ... }
// 编译器给出令人困惑的错误,指向 main()

修复: 结构体/类定义的右花括号后始终加 ;

❌ 错误二:运算符重载中忘记 const

struct Point {
    int x, y;
    // 错误——缺少 const
    bool operator<(const Point& other) {  // ← 在某些 STL 容器中无法工作
        return tie(x, y) < tie(other.x, other.y);
    }
};

修复: 比较运算符始终标记为 const

bool operator<(const Point& other) const {  // ✅
    return tie(x, y) < tie(other.x, other.y);
}

❌ 错误三:使用未初始化的结构体成员

struct State {
    int dist, node;
};

State s;
cout << s.dist;  // 未定义行为!可能是任意值

修复: 始终初始化,或提供默认值:

struct State {
    int dist = 0;
    int node = 0;
};

❌ 错误四:优先队列中 operator< 方向搞反

struct State {
    int dist;
    // 想实现最小堆,你可能会这样写:
    bool operator<(const State& other) const {
        return dist < other.dist;  // 这会得到最大堆!(与你想要的相反)
    }
};

修复:priority_queue 实现最小堆,要么反转比较,要么用 greater<>

📄 C++ 完整代码
// 方案 A:反转 operator<
bool operator<(const State& other) const {
    return dist > other.dist;  // dist 更大的优先级更低 → 最小堆
}
priority_queue<State> pq;

// 方案 B:定义 operator> 并使用 greater<>
bool operator>(const State& other) const {
    return dist > other.dist;
}
priority_queue<State, vector<State>, greater<State>> pq;

2.4.8 练习题

🟢 题目一:学生排名

读取 n 名学生(姓名和成绩),按成绩降序排序,打印排名。

输入:
3
Alice 85
Bob 92
Charlie 85

输出:
1. Bob 92
2. Alice 85
3. Charlie 85
💡 提示 定义一个带 operator< 的 struct,成绩不同时高分优先,相同时按姓名字典序。
✅ 题解
#include <bits/stdc++.h>
using namespace std;

struct Student {
    string name;
    int score;

    bool operator<(const Student& other) const {
        if (score != other.score) return score > other.score;
        return name < other.name;
    }
};

int main() {
    int n;
    cin >> n;
    vector<Student> students(n);
    for (int i = 0; i < n; i++) {
        cin >> students[i].name >> students[i].score;
    }
    sort(students.begin(), students.end());
    for (int i = 0; i < n; i++) {
        cout << i + 1 << ". " << students[i].name << " " << students[i].score << "\n";
    }
}

🟢 题目二:最近点对(一维)

给定数轴上 n 个点,找距离最小的点对。

输入:
5
7 1 4 9 2

输出:
1
(点 1 和点 2 之间)
💡 提示 排序后,答案是相邻元素的最小差值。
✅ 题解
#include <bits/stdc++.h>
using namespace std;

struct PointVal {
    int val, originalIndex;

    bool operator<(const PointVal& other) const {
        return val < other.val;
    }
};

int main() {
    int n;
    cin >> n;
    vector<PointVal> points(n);
    for (int i = 0; i < n; i++) {
        cin >> points[i].val;
        points[i].originalIndex = i;
    }
    sort(points.begin(), points.end());

    int minDist = INT_MAX;
    int bestI = 0, bestJ = 1;
    for (int i = 0; i + 1 < n; i++) {
        int d = points[i + 1].val - points[i].val;
        if (d < minDist) {
            minDist = d;
            bestI = i;
            bestJ = i + 1;
        }
    }
    cout << minDist << "\n";
    cout << "(点 " << points[bestI].val << " 和点 " << points[bestJ].val << " 之间)\n";
}

🟡 题目三:区间调度(贪心)

给定 n 个区间 [start, end],找不重叠区间的最大数量。

📄 给定 `n` 个区间 `[start, end]`,找不重叠区间的最大数量。
输入:
6
1 4
3 5
0 6
5 7
3 8
5 9

输出:
3
💡 提示 按结束时间排序区间。贪心地选择结束时间最早且与上一个已选区间不冲突的区间。
✅ 题解
#include <bits/stdc++.h>
using namespace std;

struct Interval {
    int start, end;

    bool operator<(const Interval& other) const {
        return end < other.end;
    }
};

int main() {
    int n;
    cin >> n;
    vector<Interval> intervals(n);
    for (int i = 0; i < n; i++) {
        cin >> intervals[i].start >> intervals[i].end;
    }
    sort(intervals.begin(), intervals.end());

    int count = 0, lastEnd = -1;
    for (auto& it : intervals) {
        if (it.start >= lastEnd) {
            count++;
            lastEnd = it.end;
        }
    }
    cout << count << "\n";
}

📋 本章总结

概念核心要点
struct组合相关数据;成员默认为 public
class与 struct 相同,但成员默认为 private
构造函数创建实例时调用的特殊函数
operator<sort()setmappriority_queue 支持你的类型
tie()简洁的多关键字比较技巧
pair内置的 2 字段结构体,支持字典序比较
const 方法标记不修改对象的方法

🎯 竞赛编程核心要点

  1. 竞赛中始终用 struct —— 更简单、更简短
  2. 掌握 operator< 重载 —— 几乎每道 USACO 题都会用到
  3. 多关键字排序用 tie() —— 简洁且不易出错
  4. 记得 const —— STL 兼容性的要求
  5. 初始化你的成员 —— 避免未定义行为
  6. 2 个字段用 pair,3 个及以上用自定义 struct —— 好的经验法则

✅ 第 2.4 章完成!
你现在知道如何创建自定义数据类型了——这是竞赛编程中组织数据的关键技能。接下来:强大的 STL 容器!

📖 第 2.5 章:分类讨论与矩形几何

⏱ 预计阅读时间:35 分钟 | 难度:🟢 入门(USACO Bronze 核心技能)


前置条件

  • 基本 C++ 语法(第 2.1~2.2 章)
  • if/else 条件语句

🎯 学习目标

学完本章后,你将能够:

  1. 识别需要分类讨论的题目,系统枚举所有情形
  2. 处理坐标轴上的矩形交叉、覆盖、面积问题
  3. 判断两个矩形是否相交,计算交集面积
  4. 用差分思想处理网格上的矩形覆盖计数

2.5.1 分类讨论(Casework)

什么是分类讨论?

当问题的答案取决于若干个「互斥情形」时,需要逐一枚举每种情形,分别处理。

核心原则:

  1. 完备性:不遗漏任何情形
  2. 互斥性:情形之间没有重叠(或显式处理重叠)
  3. 验证:对每种情形验证边界值

示例:一维区间分类

问题: 给定两个区间 [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 超过 intlong 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++ 语法

🎯 学习目标

学完本章后,你将能够:

  1. 使用六种位运算符进行高效整数操作
  2. 用位运算检查、设置、清除、翻转二进制位
  3. 用整数作为「集合」(状压表示)进行集合运算
  4. 枚举一个整数所有子集(状压 DP 基础)
  5. 理解常见的位运算技巧(lowbit、快速判断 2 的幂等)

2.6.1 为什么要学位运算?

位运算让你在 O(1) 时间内对整数的二进制位做操作。竞赛中的典型应用:

应用场景
状态压缩(状压 DP)用一个整数表示一个集合或状态
快速幂利用指数的二进制分解
枚举子集遍历 N 个元素的所有子集
树状数组的 lowbitx & (-x) 找最低有效位
奇偶判断x & 1x % 2 更快

2.6.2 六种基本位运算符

运算符名称用法示例(二进制)
&按位与 (AND)a & b1100 & 1010 = 1000
|按位或 (OR)a | b1100 | 1010 = 1110
^按位异或 (XOR)a ^ b1100 ^ 1010 = 0110
~按位非 (NOT)~a~1100 = 0011...
<<左移a << k0001 << 2 = 0100
>>右移a >> k1100 >> 1 = 0110

直觉记忆

AND  &:两个都是1才得1(取交集:都有才保留)
OR   |:有一个是1就得1(取并集:有一个就保留)
XOR  ^:不同为1,相同为0(取差集/翻转:不一样才保留)
NOT  ~:0变1,1变0(取补集)

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::sort3.3几乎所有 Silver 题目
二分查找(lower_boundupper_bound3.3计数、区间查询
二分答案3.3攻击性奶牛、画家分区
单调栈3.5下一个更大元素、直方图
滑动窗口(单调队列)3.5窗口最小/最大值
频率映射(unordered_map3.7统计出现次数
有序集合操作3.8第 K 小元素、区间查询

前置条件

开始第三部分前,请确认你能做到:

  • 从零编写并编译 C++ 程序(第 2.1 章)
  • 正确使用 for 循环和嵌套循环(第 2.2 章)
  • 使用数组和 vector<int>(第 2.3 章)

注意: 第 3.1 章(STL 核心用法)是本部分的第一章,将在后续章节用到之前先教你 std::sortmapset 等关键 STL 容器。


本部分学习建议

  1. 第 3.2 章(前缀和) 是 Bronze 中测试最频繁的技术。确保你能在 5 分钟内从零实现它。
  2. 第 3.3 章(二分查找) 介绍「二分答案」——这是 Silver 级别的技术,是普通解法和优秀解法的分水岭。
  3. 不要跳过练习题。 每章的练习题都是专门为培养所需直觉而精选的。
  4. 完成第 3.3 章后,你已经具备解决大多数 USACO Bronze 题目的工具。在继续学习前,尝试解 5-10 道 Bronze 题目。

🏆 USACO 技巧: 在 USACO Bronze 级别,最常用的技术是:模拟(第 2.1–2.3 章)、排序(第 3.3 章)和前缀和(第 3.2 章)。掌握这三项,几乎可以解决任何 Bronze 题目。

出发!

📖 第 3.1 章 ⏱️ 约 70 分钟 🎯 入门

第 3.1 章:STL 核心用法

📝 前置条件: 第 2.1–2.3 章(变量、循环、函数、向量)

标准模板库(STL) 是 C++ 内置的现成数据结构和算法集合。不需要从零实现链表、哈希表或排序算法,直接用 STL——它快速、可靠,经过数百万程序员的检验。

学会为问题选择正确的 STL 容器是竞赛编程中最重要的技能之一。

本章你将学到:

  • sort —— 一行代码,按任意规则排序任意序列
  • pair —— 简洁地将两个值捆绑在一起
  • map / set —— 有序键值存储和唯一元素集合
  • stack / queue —— 用于经典算法的 LIFO 和 FIFO 容器
  • priority_queue —— 始终能 O(log N) 取得最大(或最小)值
  • unordered_map / unordered_set —— 基于哈希的 O(1) 查找
  • auto 和范围 for —— 写出更简洁的代码
  • 常用 STL 算法:binary_searchlower_boundaccumulate

3.1.0 STL 工具箱

把 STL 想象成一个工具箱,每种工具都为特定任务设计:

STL Toolbox

快速参考——该用哪个容器:

需求使用
有序列表,随机访问vector
两个值捆绑在一起pair
键 → 值映射(有序)map
唯一元素(有序)set
键 → 值映射(快速,无序)unordered_map
唯一元素(快速,无序)unordered_set
LIFO(后进先出)stack
FIFO(先进先出)queue
快速获取最大/最小值priority_queue

选对工具 = 竞赛编程中解题的一半!

「该用哪个容器?」决策树

STL Container Decision Tree

图示:STL 容器概览

STL Containers


3.1.1 sort —— 你唯一需要的排序

我们从 sort 开始,因为几乎每道题都会用到它。

是什么,为什么重要

排序将元素序列重新排列成有序状态。不用自己实现排序算法(容易出错且费时),C++ 的 sort 具有以下特点:

  • 快速:O(N log N) —— 基于比较的排序的理论最优
  • 易用:一行代码
  • 灵活:按任意你定义的规则排序

⚠️ 重要: sort 需要 #include <algorithm>(通过 #include <bits/stdc++.h> 自动包含)。

📄 C++ 完整代码
#include <bits/stdc++.h>
using namespace std;

int main() {
    // 对向量排序——升序(默认)
    vector<int> v = {5, 2, 8, 1, 9, 3};
    sort(v.begin(), v.end());
    // v 现在是:{1, 2, 3, 5, 8, 9}

    for (int x : v) cout << x << " ";
    cout << "\n";

    // 降序排序
    sort(v.begin(), v.end(), greater<int>());
    // v 现在是:{9, 8, 5, 3, 2, 1}

    // 对数组排序
    int arr[] = {4, 2, 7, 1, 5};
    int n = 5;
    sort(arr, arr + n);  // 排序 arr[0..n-1]
    // arr 现在是:{1, 2, 4, 5, 7}

    return 0;
}

自定义排序(Lambda 函数)

如果想按非自然顺序排序?用 lambda —— 一个小的内联函数:

vector<int> v = {5, -3, 2, -8, 1};

// 按绝对值排序
sort(v.begin(), v.end(), [](int a, int b) {
    return abs(a) < abs(b);  // a 应该排在 b 前面时返回 true
});
// v 现在是:{1, 2, -3, 5, -8}(按 |值| 排序)

lambda [](int a, int b) { return ...; } 是比较规则。当 a 应该排在 b 前面时返回 true

🐛 常见错误:a == b 时永远不要返回 true——这违反了「严格弱序」规则,会导致未定义行为(崩溃或错误答案)。始终用 <>,绝不用 <=>=

// 对 pair 向量排序:按第二元素降序,相同时按第一元素升序
vector<pair<int,int>> pts = {{3,5},{1,7},{2,5},{4,3}};
sort(pts.begin(), pts.end(), [](pair<int,int> a, pair<int,int> b) {
    if (a.second != b.second) return a.second > b.second;  // 第二元素更大的优先
    return a.first < b.first;   // 打平:第一元素更小的优先
});
// 结果:{1,7}, {2,5}, {3,5}, {4,3}

3.1.2 pair —— 把两个值存在一起

pair 将两个值捆绑成一个对象,可以理解为两个值的「迷你结构体」。

为什么用 pair? 经常需要把两个相关的值放在一起——比如(值,下标)、(x 坐标,y 坐标)、(成绩,姓名)。pair 能简洁地做到这点。

📄 C++ 完整代码
#include <bits/stdc++.h>
using namespace std;

int main() {
    // 创建 pair
    pair<int, int> point = {3, 5};         // (x, y)
    pair<string, int> student = {"Alice", 95};  // (姓名, 成绩)

    // 访问元素:.first 和 .second
    cout << point.first << " " << point.second << "\n";    // 3 5
    cout << student.first << ": " << student.second << "\n"; // Alice: 95

    // pair 支持比较:先比较 .first,相同时比较 .second
    pair<int,int> a = {1, 3};
    pair<int,int> b = {1, 5};
    cout << (a < b) << "\n";   // 1(真)—— .first 相同,比较 .second:3 < 5

    // 非常常见的模式:用 pair 按第二元素排序
    vector<pair<int,int>> v = {{3,9},{1,2},{4,1},{1,5}};
    sort(v.begin(), v.end());     // 先按 .first 排序,再按 .second 排序
    // 结果:{1,2}, {1,5}, {3,9}, {4,1}

    return 0;
}

专业技巧: 需要按某个值排序但又想保留原始下标时,把它们存成 pair<值, 下标> 后排序。排序后 .second 就是原始下标。

💡 make_pair vs 花括号: make_pair(3, 5){3, 5} 都能创建 pair。花括号语法 {3, 5} 更短,是现代 C++(C++11 及以后)的首选方式。


3.1.3 map —— 字典

map 存储键值对,就像真正的字典:给定一个单词(键),查找它的定义(值)。每个键最多出现一次

什么时候用 map

  • 频率统计:单词 → 计数,成绩 → 频率
  • ID 映射到名字:学生 ID → 姓名
  • 存储属性:奶牛名字 → 产奶量
📄 C++ 完整代码
#include <bits/stdc++.h>
using namespace std;

int main() {
    map<string, int> phoneBook;

    // 插入键值对
    phoneBook["Alice"] = 555001;
    phoneBook["Bob"] = 555002;
    phoneBook["Charlie"] = 555003;

    // 按键查找
    cout << phoneBook["Alice"] << "\n";  // 555001

    // 遍历(始终按键的**排序顺序**!)
    for (auto& entry : phoneBook) {
        cout << entry.first << " -> " << entry.second << "\n";
    }
    // 打印:
    // Alice -> 555001
    // Bob -> 555002
    // Charlie -> 555003

    // 删除键
    phoneBook.erase("Charlie");
    cout << phoneBook.size() << "\n";  // 2

    return 0;
}

常用操作及时间复杂度

操作代码时间
插入/更新m[key] = valueO(log n)
查找m[key]m.at(key)O(log n)
检查是否存在m.count(key)m.find(key)O(log n)
删除m.erase(key)O(log n)
大小m.size()O(1)
遍历全部范围 forO(n)

🐛 map 访问陷阱

这是 map 最常见的 bug 之一:

📄 这是 `map` 最常见的 bug 之一:
map<string, int> freq;

// 危险:访问不存在的键会以值 0 创建它!
cout << freq["apple"] << "\n";  // 打印 0,但现在 "apple" 已经在 map 里了!
cout << freq.size() << "\n";    // 1——即使我们只是「查了一下」,并没有「插入」!

// 安全方式一:先检查 count
if (freq.count("apple") > 0) {
    cout << freq["apple"] << "\n";  // 安全——键已存在
}

// 安全方式二:用 .find()
auto it = freq.find("apple");
if (it != freq.end()) {          // .end() 表示「未找到」
    cout << it->second << "\n";  // it->second 是值
}

频率统计——最常见的 map 模式

📄 查看代码:频率统计——最常见的 map 模式
vector<string> words = {"apple", "banana", "apple", "cherry", "banana", "apple"};
map<string, int> freq;

for (const string& w : words) {
    freq[w]++;  // 如果 "w" 不存在,以 0 创建,然后递增为 1
}

// freq: apple→3, banana→2, cherry→1
for (auto& p : freq) {
    cout << p.first << " 出现了 " << p.second << " 次\n";
}

💡 为什么 freq[w]++ 对新键也有效: map 对缺失的值进行默认初始化。对 int 来说默认值是 0。所以访问新键会以值 0 创建它,然后 ++ 使其变为 1。这是故意设计的,广泛用于计数。


3.1.4 set —— 唯一有序集合

set 以有序状态存储唯一元素。插入重复值——会被悄悄忽略。

什么时候用 set

  • 去除列表中的重复项
  • 快速检查成员资格:「我见过这个值吗?」
  • 获取动态集合的最小/最大值
📄 C++ 完整代码
#include <bits/stdc++.h>
using namespace std;

int main() {
    set<int> s;

    s.insert(5);
    s.insert(2);
    s.insert(8);
    s.insert(2);   // 重复——被忽略!set 仍是 {2, 5, 8}
    s.insert(1);

    // s 现在是:{1, 2, 5, 8}(自动排序,无重复)

    // 检查成员资格
    cout << s.count(2) << "\n";   // 1(存在)
    cout << s.count(7) << "\n";   // 0(不存在)

    // 删除
    s.erase(2);   // s = {1, 5, 8}

    // 遍历(始终排序)
    for (int x : s) cout << x << " ";
    cout << "\n";   // 1 5 8

    // 最小值和最大值
    cout << *s.begin() << "\n";   // 1(最小;* 解引用迭代器)
    cout << *s.rbegin() << "\n";  // 8(最大;r = 反向)

    cout << s.size() << "\n";  // 3

    return 0;
}

常用操作及时间复杂度

操作代码时间
插入s.insert(x)O(log n)
检查是否存在s.count(x)s.find(x)O(log n)
删除s.erase(x)O(log n)
最小值*s.begin()O(1)
最大值*s.rbegin()O(1)
大小s.size()O(1)

set 去重

vector<int> v = {3, 1, 4, 1, 5, 9, 2, 6, 5, 3, 5};
set<int> unique_set(v.begin(), v.end());  // 从向量构建 set
// unique_set = {1, 2, 3, 4, 5, 6, 9}

cout << "唯一值个数:" << unique_set.size() << "\n";   // 7

// 如需转回排序向量:
vector<int> deduped(unique_set.begin(), unique_set.end());

💡 set vs multiset set 每个值最多存一次。如果需要存重复值但仍想保持有序,用 multiset<int> —— 它允许重复元素。


3.1.5 stack —— 后进先出

栈就像一叠盘子:你只能在顶部添加或移除。最后加入的最先被移除(LIFO:Last In, First Out,后进先出)。

什么时候用 stack

  • 括号匹配
  • 撤销/重做历史
  • 深度优先搜索(DFS)——后续章节讲解
  • 反转序列
📄 C++ 完整代码
#include <bits/stdc++.h>
using namespace std;

int main() {
    stack<int> st;

    st.push(1);    // [1]         (顶部在右侧)
    st.push(2);    // [1, 2]
    st.push(3);    // [1, 2, 3]

    cout << st.top() << "\n";   // 3(查看顶部,不移除)
    st.pop();                    // 移除顶部 → [1, 2]
    cout << st.top() << "\n";   // 2

    cout << st.size() << "\n";  // 2
    cout << st.empty() << "\n"; // 0(不为空)

    return 0;
}

常用操作及时间复杂度

操作代码时间
压入顶部st.push(x)O(1)
弹出顶部st.pop()O(1)
查看顶部st.top()O(1)
检查是否为空st.empty()O(1)
大小st.size()O(1)

🐛 常见栈错误:不检查就弹出

stack<int> st;
// st.top();  // 崩溃!不能查看空栈的顶部
// st.pop();  // 崩溃!不能从空栈弹出

// 访问前始终检查:
if (!st.empty()) {
    cout << st.top() << "\n";
    st.pop();
}

经典栈问题:括号匹配

📄 查看代码:经典栈问题:括号匹配
string expr = "((a+b)*(c-d))";
stack<char> parens;
bool balanced = true;

for (char ch : expr) {
    if (ch == '(') {
        parens.push(ch);         // 开括号:压栈
    } else if (ch == ')') {
        if (parens.empty()) {    // 闭括号但没有对应的开括号
            balanced = false;
            break;
        }
        parens.pop();            // 找到匹配:弹出开括号
    }
}

if (!parens.empty()) balanced = false;  // 还有未匹配的开括号

cout << (balanced ? "匹配" : "不匹配") << "\n";

3.1.6 queue —— 先进先出

队列就像商店排队:你从后面加入,从前面离开。第一个加入的最先被服务(FIFO:First In, First Out,先进先出)。

什么时候用 queue

  • 模拟顾客、进程、任务的排队
  • 广度优先搜索(BFS)—— 竞赛编程中最重要的算法之一(第 5.2 章)
  • 按到达顺序处理元素
📄 C++ 完整代码
#include <bits/stdc++.h>
using namespace std;

int main() {
    queue<int> q;

    q.push(10);   // [10]
    q.push(20);   // [10, 20]
    q.push(30);   // [10, 20, 30]

    cout << q.front() << "\n";  // 10(排在最前——最先离开)
    cout << q.back() << "\n";   // 30(排在最后——最后离开)

    q.pop();                     // 从前面移除 → [20, 30]
    cout << q.front() << "\n";  // 20

    cout << q.size() << "\n";   // 2

    return 0;
}

常用操作及时间复杂度

操作代码时间
加入队尾q.push(x)O(1)
从队首移除q.pop()O(1)
查看队首q.front()O(1)
查看队尾q.back()O(1)
检查是否为空q.empty()O(1)

注意: 第 5.2 章中你会大量用到 queue 来实现 BFS —— USACO 中最重要的图算法之一。

🐛 常见错误: queue 没有 top() 方法(那是 stack 的)。用 front() 查看队首元素,用 back() 查看队尾。


3.1.7 priority_queue —— 堆

priority_queue 就像一个魔法队列:无论以什么顺序插入,它总是先给你最大的元素(默认最大堆)。

什么时候用 priority_queue

  • 需要快速获取最大(或最小)元素
  • 找第 K 大的数
  • Dijkstra 最短路算法(第 5.4 章)
  • 每次都处理「最优」元素的贪心算法
📄 C++ 完整代码
#include <bits/stdc++.h>
using namespace std;

int main() {
    // 最大堆:始终给你最大值
    priority_queue<int> maxPQ;

    maxPQ.push(5);
    maxPQ.push(1);
    maxPQ.push(8);
    maxPQ.push(3);

    // 按降序弹出
    while (!maxPQ.empty()) {
        cout << maxPQ.top() << " ";  // 始终是当前最大值
        maxPQ.pop();
    }
    cout << "\n";  // 打印:8 5 3 1

    return 0;
}

🐛 最小堆陷阱

默认情况下,priority_queue最大堆(优先给最大值)。要创建最小堆(优先给最小值),需要特殊语法:

📄 默认情况下,`priority_queue` 是**最大堆**(优先给最大值)。要创建**最小堆**(优先给最小值),需要特殊语法
// 最大堆(默认)——优先给最大值
priority_queue<int> maxPQ;

// 最小堆——优先给最小值(注意额外的模板参数!)
priority_queue<int, vector<int>, greater<int>> minPQ;

minPQ.push(5);
minPQ.push(1);
minPQ.push(8);
minPQ.push(3);

while (!minPQ.empty()) {
    cout << minPQ.top() << " ";
    minPQ.pop();
}
// 打印:1 3 5 8(最小值优先)

常用操作及时间复杂度

操作代码时间
插入pq.push(x)O(log n)
获取最大/最小值pq.top()O(1)
移除最大/最小值pq.pop()O(log n)
检查是否为空pq.empty()O(1)

💡 含 pair 的优先队列: 可以在优先队列中存储 pair<int, int>,先比较 .first 再比较 .second——用于 Dijkstra 算法存储 {距离, 节点} 时非常有用。

priority_queue<pair<int,int>, vector<pair<int,int>>, greater<pair<int,int>>> minPQ;
// (距离, 节点) 对的最小堆——用于 Dijkstra

3.1.8 unordered_mapunordered_set —— 基于哈希的速度

普通 mapset 是有序的(内部使用平衡二叉搜索树),操作是 O(log N)。unordered_ 变体使用哈希表,平均 O(1) —— 更快,但没有顺序保证。

💡 底层原理:为什么 map 是 O(log N) 而 unordered_map 是 O(1)?

  • map / set 内部使用红黑树(自平衡二叉搜索树)。每次插入、查找或删除都从根到叶遍历,树高约 log₂N,因此 O(log N)。优点:元素始终有序,支持 lower_bound 和范围查询。
  • unordered_map / unordered_set 内部使用哈希表。哈希函数直接计算存储位置,平均 O(1)。但不保证元素顺序,最坏情况(严重哈希碰撞)可能退化到 O(N)。
  • 竞赛经验:只需查找/插入而不需要有序遍历时,优先用 unordered_map。但如果因最坏情况被「hack」而 TLE,改回 map 是最安全的选择。
📄 C++ 完整代码
unordered_map<string, int> freq;
freq["apple"]++;
freq["banana"]++;
freq["apple"]++;

cout << freq["apple"] << "\n";   // 2(与 map 接口相同)

unordered_set<int> seen;
seen.insert(5);
seen.insert(10);
cout << seen.count(5) << "\n";   // 1(找到)
cout << seen.count(7) << "\n";   // 0(未找到)

防 Hack 的 unordered_map

竞赛中对手可以构造导致大量哈希碰撞的输入,使 unordered_map 每次操作退化到 O(N)。一个简单的防御方法是使用自定义哈希:

// 在 main() 之前添加,让 unordered_map 更难被 hack
struct custom_hash {
    size_t operator()(long long x) const {
        x = (x ^ (x >> 30)) * 0xbf58476d1ce4e5b9LL;
        x = (x ^ (x >> 27)) * 0x94d049bb133111ebLL;
        return x ^ (x >> 31);
    }
};
unordered_map<long long, int, custom_hash> safe_map;

大多数题目默认的 unordered_map 就够了。只有当你怀疑存在反哈希测试时才用自定义哈希。

什么时候用哪个

容器使用场景
map / set需要有序遍历;需要 lower_bound;N 较小;优先安全
unordered_map / unordered_set只需查找;N 较大(> 10^5);键是字符串或整数

专业技巧: 对于大量字符串键的输入,unordered_mapmap 快 5-10 倍。但它有极少数情况下可能被竞赛中利用的「最坏情况」行为——如果用 unordered_map TLE 且怀疑被 hack,改用 map


3.1.9 auto 关键字和范围 for

C++ 通常能自动推断变量的类型。auto 关键字告诉编译器:「你来决定类型。」

auto x = 42;          // x 是 int
auto y = 3.14;        // y 是 double
auto v = vector<int>{1, 2, 3};  // v 是 vector<int>

map<string, int> freq;
auto it = freq.find("cat");  // 类型本应是 map<string,int>::iterator——很长!
// auto 省去了写这么长类型名的麻烦

⚠️ auto 陷阱: auto 在编译时推断类型——它不会让变量变成动态类型。另外,auto x = 1000000000 * 2; 会推断出 int 并可能溢出;写 auto x = 1000000000LL * 2; 才能得到 long long

范围 for

对任何容器的简洁遍历:

📄 对任何容器的简洁遍历:
vector<int> nums = {10, 20, 30, 40, 50};

// 只读遍历(复制每个元素——对 int 没问题,对 string 浪费)
for (int x : nums) {
    cout << x << " ";
}

// 引用:不复制,可修改元素
for (int& x : nums) {
    x *= 2;   // 就地翻倍每个元素
}

// const 引用:不复制,只读(大类型的最佳方式)
for (const auto& x : nums) {
    cout << x << " ";
}

范围 for 经验法则:

  • 小类型(intchar):for (int x : v) —— 复制没问题
  • 大类型(stringpair、struct):for (const auto& x : v) —— 避免复制
  • 需要修改:for (auto& x : v)

3.1.10 常用 STL 算法

<algorithm><numeric> 中的这些函数适用于任何序列:

📄 `` 和 `` 中的这些函数适用于任何序列:
#include <bits/stdc++.h>
using namespace std;

int main() {
    vector<int> v = {3, 1, 4, 1, 5, 9, 2, 6};

    // 升序排序
    sort(v.begin(), v.end());
    // v = {1, 1, 2, 3, 4, 5, 6, 9}

    // 二分查找(只能用于**已排序**的序列!)
    cout << binary_search(v.begin(), v.end(), 5) << "\n";  // 1(找到)
    cout << binary_search(v.begin(), v.end(), 7) << "\n";  // 0(未找到)

    // lower_bound:第一个 >= 目标值的位置
    auto it = lower_bound(v.begin(), v.end(), 4);
    cout << *it << "\n";   // 4
    cout << (it - v.begin()) << "\n";  // 下标:3

    // upper_bound:第一个 > 目标值的位置
    auto it2 = upper_bound(v.begin(), v.end(), 4);
    cout << (it2 - v.begin()) << "\n";  // 下标:4(第一个 > 4 的元素)
    // [lo, hi] 范围内的元素个数:upper_bound(hi+1) - lower_bound(lo)

    // 最小值和最大值
    cout << *min_element(v.begin(), v.end()) << "\n";  // 1
    cout << *max_element(v.begin(), v.end()) << "\n";  // 9

    // 所有元素之和
    long long total = accumulate(v.begin(), v.end(), 0LL);
    cout << total << "\n";  // 31

    // 统计出现次数
    cout << count(v.begin(), v.end(), 1) << "\n";  // 2

    // 反转
    reverse(v.begin(), v.end());

    // 用一个值填充
    fill(v.begin(), v.end(), 0);  // 全为零

    return 0;
}

3.1.11 综合示例:词频统计器

让我们写一个综合运用 mapvectorsort 的完整小程序。

问题: 读取 N 个单词,统计每个单词出现次数,然后打印:

  1. 所有单词及其计数(按字母顺序)
  2. 出现次数最多的单词
📄 2. 出现次数最多的单词
#include <bits/stdc++.h>
using namespace std;

int main() {
    ios_base::sync_with_stdio(false);
    cin.tie(NULL);

    int n;
    cin >> n;

    // 第一步:用 map 统计词频
    map<string, int> freq;
    for (int i = 0; i < n; i++) {
        string word;
        cin >> word;
        freq[word]++;   // 如果 word 是新的,以 0 创建,然后递增
    }

    // 第二步:按字母顺序打印所有单词(map 按键有序迭代)
    cout << "所有单词:\n";
    for (auto& entry : freq) {
        // entry.first = 单词,entry.second = 计数
        cout << entry.first << ": " << entry.second << "\n";
    }

    // 第三步:找最频繁的单词
    // 对 map 用 max_element:按值(.second)比较
    auto best = max_element(
        freq.begin(),
        freq.end(),
        [](const pair<string,int>& a, const pair<string,int>& b) {
            return a.second < b.second;  // 按计数比较
        }
    );

    cout << "\n最频繁的:\"" << best->first << "\"(出现了 "
         << best->second << " 次)\n";

    return 0;
}

样例输入:

10
the cat sat on the mat the cat sat on

样例输出:

所有单词:
cat: 2
mat: 1
on: 2
sat: 2
the: 3

最频繁的:"the"(出现了 3 次)

复杂度分析:

  • 时间:O(N log N)——每次 freq[word]++ 是 O(log N),共 N 次;max_element 遍历 map 是 O(M),M 为不重复单词数
  • 空间:O(M)——map 存储 M 个不重复单词

🤔 为什么遍历 map 是字母顺序? map 内部使用平衡 BST,保持键有序。遍历时自动按键的有序顺序输出——无需额外排序!

💡 寻找最大值的替代方案: 除了在 map 上用 max_element,也可以把条目转移到 vector<pair<string,int>>,按 .second 降序排序,取第一个元素。两种方法在计数后都是 O(M)。


本章总结

📌 核心要点

容器描述关键操作时间为什么重要
vector<T>动态数组push_back[]sizeO(1) 均摊最常用容器,默认选择
pair<A,B>存储两个值.first.secondO(1)图的边、坐标等
map<K,V>有序键值对[]findcountO(log n)频率统计、有序映射
set<T>有序唯一集合insertcounteraseO(log n)去重、范围查询
stack<T>后进先出pushpoptopO(1)括号匹配、DFS
queue<T>先进先出pushpopfrontO(1)BFS、模拟
priority_queue<T>最大堆pushpoptopO(log n)贪心最大/最小、Dijkstra
unordered_map<K,V>哈希映射(无序)[]findcountO(1) 均摊大数据快速查找
unordered_set<T>哈希集合(无序)insertcounteraseO(1) 均摊快速成员检查、去重

❓ 常见问题

Q1:vector 和普通数组有什么区别?什么时候用哪个?

A:vector 可以动态增长(push_back),知道自己的大小(.size()),可以安全传给函数。普通数组大小固定,但稍快(全局数组自动初始化为 0)。竞赛中大多数情况用 vector;全局大数组(如 int dp[100001])有时更方便。

Q2:什么时候选 map vs unordered_map

A:只需查找/插入/删除时,用 unordered_map(O(1))更快。需要有序遍历lower_bound/upper_bound 时,用 map(O(log N))。没有特殊要求的竞赛中,map 更安全(不会被 hack)。

Q3:priority_queue 默认是最大堆还是最小堆?

A:最大堆pq.top() 返回最大元素。最小堆需要声明为 priority_queue<int, vector<int>, greater<int>>

Q4:什么时候自定义 structpair 更好?

A:pair.first/.second 可读性差——三个月后你可能忘了 .first 代表什么。struct 让你给成员起有意义的名字(如 .weight.value)。字段有 3 个或以上时,必须用 struct

Q5:为什么对 vector<pair<int,int>> 排序时先按 .first

A:pair 有内置的 operator<,先比较 .first,相同时比较 .second。这叫字典序——与字典中单词的排序方式相同。可以放心依赖这个行为,无需自定义比较器。

Q6:s.count(x)s.find(x) != s.end() 对于 set 有什么区别?

A:对 setmap,两者都是 O(log N),在检查存在性方面功能等价。count 返回 0 或 1(集合无重复),find 返回一个可以直接访问元素的迭代器。需要读取值时用 find,只需是/否检查时用 count

🔗 与后续章节的联系

  • 第 3.4 章(单调栈与单调队列):用于下一个更大元素问题的单调栈;用于滑动窗口最大/最小的单调双端队列
  • 第 3.6 章(栈与队列):深入探讨 stackqueue 的算法应用——括号匹配、BFS
  • 第 3.8 章(映射与集合):map/set 的进阶用法——频率统计、multiset
  • 第 3.3 章(排序):带自定义比较器的 sortvectorpair 一起使用
  • priority_queue第 4.1 章(贪心)和第 5.5 章(Kruskal MST)中频繁出现
  • 本章的 STL 容器是本书所有后续章节的基础工具

练习题


🌡️ 热身题


热身 3.1.1 — 集合成员查询 读取 N 个整数,然后读取 Q 个查询。对每个查询读取一个整数,若它出现在原始 N 个整数中打印 YES,否则打印 NO

样例输入:

5
10 20 30 40 50
3
20
35
50

样例输出:

YES
NO
YES
💡 题解(点击展开)

思路: 把 N 个整数存入集合,对每个查询检查 s.count(x)

#include <bits/stdc++.h>
using namespace std;

int main() {
    ios_base::sync_with_stdio(false);
    cin.tie(NULL);

    int n;
    cin >> n;

    set<int> s;
    for (int i = 0; i < n; i++) {
        int x;
        cin >> x;
        s.insert(x);
    }

    int q;
    cin >> q;
    while (q--) {
        int x;
        cin >> x;
        cout << (s.count(x) ? "YES" : "NO") << "\n";
    }

    return 0;
}

关键点:

  • s.count(x) 找到时返回 1,未找到时返回 0
  • while (q--) 是常见惯用法:循环执行 q 次(每次 q 递减)
  • 使用 set 每次查询 O(log N),比线性搜索 O(N) 快得多

热身 3.1.2 — 字符频率 读取一个字符串(无空格),按字母顺序打印其中每个字符及其出现次数。

样例输入: hello 样例输出:

e: 1
h: 1
l: 2
o: 1
💡 题解(点击展开)

思路:map<char, int> 统计字符频率,遍历 map 自动按字母顺序输出。

#include <bits/stdc++.h>
using namespace std;

int main() {
    string s;
    cin >> s;

    map<char, int> freq;
    for (char c : s) {
        freq[c]++;
    }

    for (auto& entry : freq) {
        cout << entry.first << ": " << entry.second << "\n";
    }

    return 0;
}

关键点:

  • for (char c : s) 遍历字符串中的每个字符
  • freq[c]++ 第一次访问时以值 0 创建条目,然后递增
  • map 遍历始终按键有序——字符自动按字母顺序输出

热身 3.1.3 — 栈实现反转 读取一个字符串(无空格),用 stack 将其反转打印。

样例输入: hello样例输出: olleh

💡 题解(点击展开)

思路: 把每个字符压栈,然后全部弹出——LIFO 顺序正好是倒序。

#include <bits/stdc++.h>
using namespace std;

int main() {
    string s;
    cin >> s;

    stack<char> st;
    for (char c : s) {
        st.push(c);
    }

    while (!st.empty()) {
        cout << st.top();
        st.pop();
    }
    cout << "\n";

    return 0;
}

关键点:

  • 栈的 LIFO 特性:最后压入的最先弹出 = 反转
  • 访问 st.top() 或调用 st.pop() 前始终检查 !st.empty()
  • 注意:reverse(s.begin(), s.end()); cout << s; 更简单——但用栈实现能理解概念

热身 3.1.4 — 队列模拟 模拟 5 人排队:Alice、Bob、Charlie、Dave、Eve,按顺序加入。逐一服务(从队首弹出),打印每位被服务者的名字。

期望输出:

Serving: Alice
Serving: Bob
Serving: Charlie
Serving: Dave
Serving: Eve
💡 题解(点击展开)

思路: 把所有名字加入队列,然后逐一弹出并打印直到队列为空。

#include <bits/stdc++.h>
using namespace std;

int main() {
    queue<string> line;
    line.push("Alice");
    line.push("Bob");
    line.push("Charlie");
    line.push("Dave");
    line.push("Eve");

    while (!line.empty()) {
        cout << "Serving: " << line.front() << "\n";
        line.pop();
    }

    return 0;
}

关键点:

  • queue.front() 不移除地访问第一个元素
  • queue.pop() 移除队首元素(无返回值——如需值,在 pop() 前用 front()
  • 队列保持插入顺序——先压入的先弹出

热身 3.1.5 — 前 3 大 读取 N 个整数,用 priority_queue 找出并打印最大的 3 个值(降序)。

样例输入:

7
5 1 9 3 7 2 8

样例输出:

9
8
7
💡 题解(点击展开)

思路: 全部压入最大堆优先队列,弹出 3 次得到最大的 3 个。

#include <bits/stdc++.h>
using namespace std;

int main() {
    int n;
    cin >> n;

    priority_queue<int> pq;
    for (int i = 0; i < n; i++) {
        int x;
        cin >> x;
        pq.push(x);
    }

    for (int i = 0; i < 3 && !pq.empty(); i++) {
        cout << pq.top() << "\n";
        pq.pop();
    }

    return 0;
}

关键点:

  • priority_queue<int> 是最大堆——top() 始终给出最大值
  • 弹出 3 次按顺序得到最大的 3 个值
  • && !pq.empty() 防卫处理 N < 3 的边界情况

🏋️ 核心练习题


题目 3.1.6 — 唯一元素 读取 N 个整数,只打印唯一的值,按它们第一次出现的顺序(不排序)。一个值出现多次时,只在第一次出现时打印。

样例输入:

8
3 1 4 1 5 9 2 6

样例输出: 3 1 4 5 9 2 6

(注意:1 出现两次,但只在第一次位置打印一次。)

💡 题解(点击展开)

思路:unordered_set 追踪已见过的值。对每个元素,只有还没见过时才打印。

#include <bits/stdc++.h>
using namespace std;

int main() {
    ios_base::sync_with_stdio(false);
    cin.tie(NULL);

    int n;
    cin >> n;

    unordered_set<int> seen;
    bool first = true;

    for (int i = 0; i < n; i++) {
        int x;
        cin >> x;
        if (seen.count(x) == 0) {   // 还没见过 x
            seen.insert(x);
            if (!first) cout << " ";
            cout << x;
            first = false;
        }
    }
    cout << "\n";

    return 0;
}

关键点:

  • 不能用普通 set,因为 set 会排序输出——我们要保持原始顺序
  • unordered_set 提供 O(1) 均摊查找:比搜索向量快得多
  • first 标志处理间隔(不打印前导/尾随空格)

题目 3.1.7 — 出现最多的单词 读取 N 个单词,打印出现次数最多的单词。如果有平局,打印字典序最小的单词。

样例输入:

7
apple banana apple cherry banana apple cherry

样例输出: apple

💡 题解(点击展开)

思路:map 统计计数,然后找最大计数。在所有出现该最大计数的单词中,取字典序最小的(map 遍历自然给出)。

#include <bits/stdc++.h>
using namespace std;

int main() {
    ios_base::sync_with_stdio(false);
    cin.tie(NULL);

    int n;
    cin >> n;

    map<string, int> freq;
    for (int i = 0; i < n; i++) {
        string w;
        cin >> w;
        freq[w]++;
    }

    string bestWord;
    int bestCount = 0;

    // map 按字母顺序迭代——最先见到的达到最大计数的单词胜出
    for (auto& entry : freq) {
        if (entry.second > bestCount) {
            bestCount = entry.second;
            bestWord = entry.first;
        }
    }

    cout << bestWord << "\n";
    return 0;
}

关键点:

  • >(严格大于)意味着我们保留第一个达到最大计数的单词
  • 由于 map 按字母顺序迭代,第一个见到的最大计数单词就是字典序最小的
  • 得益于 map 的有序特性,平局处理自动完成

题目 3.1.8 — 配对求和 读取 N 个整数和目标 T。对于每对值 (a, b),a 在输入中排在 b 前面且 a + b = T,打印该对。用集合实现 O(N) 解法。

样例输入:

6 9
1 8 3 6 4 5

样例输出:

1 8
3 6
4 5
💡 题解(点击展开)

思路: 对每个元素 x,检查 T - x 是否已在已见元素的集合中。如果是,找到了一对。

#include <bits/stdc++.h>
using namespace std;

int main() {
    ios_base::sync_with_stdio(false);
    cin.tie(NULL);

    int n, t;
    cin >> n >> t;

    set<int> seen;
    vector<int> arr(n);
    for (int i = 0; i < n; i++) cin >> arr[i];

    for (int i = 0; i < n; i++) {
        int complement = t - arr[i];  // 需要 arr[i] + complement = t
        if (seen.count(complement)) {
            // complement 在 arr[i] 之前,按顺序打印:complement arr[i]
            cout << complement << " " << arr[i] << "\n";
        }
        seen.insert(arr[i]);
    }

    return 0;
}

关键点:

  • 对每个元素 x,需要的「补数」是 T - x
  • 如果补数已在「已见」集合中,它在数组前面出现 → 有效的对
  • 这是用 set 的 O(N log N)(或用 unordered_set 的 O(N)),优于暴力 O(N²)
  • 先打印 complement(它在输入中较早),再打印 arr[i]

题目 3.1.9 — 括号匹配 读取只含 ()[]{} 的字符串,若所有括号都正确匹配和嵌套打印 YES,否则打印 NO

样例输入 1: {[()]}输出: YES 样例输入 2: ([)]输出: NO 样例输入 3: ((()输出: NO

💡 题解(点击展开)

思路: 用栈。压入开括号;见到闭括号时,检查栈顶是否是对应的开括号。

#include <bits/stdc++.h>
using namespace std;

int main() {
    string s;
    cin >> s;

    stack<char> st;
    bool ok = true;

    for (char ch : s) {
        if (ch == '(' || ch == '[' || ch == '{') {
            st.push(ch);    // 开括号:压栈
        } else {
            // 闭括号
            if (st.empty()) {
                ok = false;  // 没有对应的开括号
                break;
            }
            char top = st.top();
            st.pop();

            // 检查是否匹配
            if ((ch == ')' && top != '(') ||
                (ch == ']' && top != '[') ||
                (ch == '}' && top != '{')) {
                ok = false;
                break;
            }
        }
    }

    if (!st.empty()) ok = false;  // 还有未匹配的开括号

    cout << (ok ? "YES" : "NO") << "\n";
    return 0;
}

关键点:

  • 核心思路:最近打开的括号必须是下一个关闭的
  • 栈的 LIFO 特性完美模拟了「最近打开」的要求
  • 三种失败条件:(1) 空栈时遇到闭括号;(2) 括号类型不匹配;(3) 末尾还有未关闭的括号

题目 3.1.10 — 前 K 大 读取 N 个整数和 K,打印最大的 K 个值(降序)。

样例输入:

8 3
4 9 1 7 3 5 2 8

样例输出:

9
8
7
💡 题解(点击展开)

思路: 全部压入最大堆,弹出 K 次。

#include <bits/stdc++.h>
using namespace std;

int main() {
    ios_base::sync_with_stdio(false);
    cin.tie(NULL);

    int n, k;
    cin >> n >> k;

    priority_queue<int> pq;
    for (int i = 0; i < n; i++) {
        int x;
        cin >> x;
        pq.push(x);
    }

    for (int i = 0; i < k && !pq.empty(); i++) {
        cout << pq.top() << "\n";
        pq.pop();
    }

    return 0;
}

关键点:

  • 优先队列自动把最大值放在顶部
  • 每次 pop() 移除当前最大值,下一个最大值浮现
  • 替代方案:降序排序后取前 K 个——结果相同

🏆 挑战题


挑战 3.1.11 — 库存系统 处理库存的 M 条命令,每条是以下之一:

  • ADD name quantity —— 添加 quantity 单位的产品 name
  • REMOVE name quantity —— 移除 quantity 单位(若移除量超过库存,设为 0)
  • QUERY name —— 打印 name 的当前库存量(从未添加过则为 0)

样例输入:

6
ADD apple 10
ADD banana 5
QUERY apple
REMOVE apple 3
QUERY apple
QUERY grape

样例输出:

10
7
0
💡 题解(点击展开)

思路:map<string, long long> 作为库存,解析每条命令并相应更新。

#include <bits/stdc++.h>
using namespace std;

int main() {
    ios_base::sync_with_stdio(false);
    cin.tie(NULL);

    int m;
    cin >> m;

    map<string, long long> inventory;

    while (m--) {
        string cmd;
        cin >> cmd;

        if (cmd == "ADD") {
            string name;
            long long qty;
            cin >> name >> qty;
            inventory[name] += qty;
        } else if (cmd == "REMOVE") {
            string name;
            long long qty;
            cin >> name >> qty;
            inventory[name] -= qty;
            if (inventory[name] < 0) inventory[name] = 0;
        } else {  // QUERY
            string name;
            cin >> name;
            // 用 count 检查存在性,避免为缺失的商品创建条目
            if (inventory.count(name)) {
                cout << inventory[name] << "\n";
            } else {
                cout << 0 << "\n";
            }
        }
    }

    return 0;
}

关键点:

  • inventory[name] += qty——若 name 不存在,以 0 创建后加 qty(正确!)
  • QUERY 时用 inventory.count(name) 检查存在性,避免悄悄创建值为 0 的条目
  • 数量用 long long 以防较大

挑战 3.1.12 — 滑动窗口最大值 读取 N 个整数和窗口大小 K,打印每个连续 K 元素窗口的最大值。

样例输入:

8 3
1 3 -1 -3 5 3 6 7

样例输出:

3
3
5
5
6
7

(窗口:[1,3,-1]→3,[3,-1,-3]→3,[-1,-3,5]→5,[-3,5,3]→5,[5,3,6]→6,[3,6,7]→7)

💡 题解(点击展开)

思路:deque(双端队列)维护一个有用下标的窗口。双端队列按值的递减顺序存储下标——deque.front() 始终是当前窗口最大值的下标。

#include <bits/stdc++.h>
using namespace std;

int main() {
    ios_base::sync_with_stdio(false);
    cin.tie(NULL);

    int n, k;
    cin >> n >> k;

    vector<int> arr(n);
    for (int i = 0; i < n; i++) cin >> arr[i];

    deque<int> dq;  // 存储下标,队首 = 当前最大值的下标

    for (int i = 0; i < n; i++) {
        // 移除已超出当前窗口的下标
        while (!dq.empty() && dq.front() < i - k + 1) {
            dq.pop_front();
        }

        // 从队尾移除值小于 arr[i] 的下标
        //(只要 arr[i] 还在窗口里,它们就不可能成为最大值)
        while (!dq.empty() && arr[dq.back()] <= arr[i]) {
            dq.pop_back();
        }

        dq.push_back(i);

        // 从窗口满的位置(下标 k-1)开始打印最大值
        if (i >= k - 1) {
            cout << arr[dq.front()] << "\n";
        }
    }

    return 0;
}

关键点:

  • 双端队列维护「单调递减队列」的下标
  • 队首 = 当前窗口最大值的下标
  • 加入新元素 arr[i] 时:从队尾移除所有 ≤ arr[i] 的元素(它们无用——arr[i] 更大且在窗口中待的时间更长)
  • 当该下标不再在窗口内时(下标 < i - k + 1),从队首移除
  • 总计 O(N)——每个下标最多压入和弹出各一次

挑战 3.1.13 — 干草堆范围计数 (USACO Bronze 风格)

N 捆干草堆放在数轴上的不同位置,处理 Q 个查询:对每个查询 (L, R),打印位置在 [L, R] 范围内(包含端点)的干草堆数量。

样例输入:

5 4
3 1 7 5 2
1 3
2 6
4 8
1 10

样例输出:

3
3
2
5
💡 题解(点击展开)

思路: 对位置排序,对每个查询用 lower_boundupper_bound 在 O(log N) 内找出范围内的数量。

#include <bits/stdc++.h>
using namespace std;

int main() {
    ios_base::sync_with_stdio(false);
    cin.tie(NULL);

    int n, q;
    cin >> n >> q;

    vector<int> pos(n);
    for (int i = 0; i < n; i++) cin >> pos[i];

    sort(pos.begin(), pos.end());  // 二分查找前必须排序

    while (q--) {
        int l, r;
        cin >> l >> r;

        // lower_bound(l):第一个 >= l 的位置
        // upper_bound(r):第一个 > r 的位置
        // [l, r] 内的元素个数 = 这两个迭代器之间的距离
        auto lo = lower_bound(pos.begin(), pos.end(), l);
        auto hi = upper_bound(pos.begin(), pos.end(), r);

        cout << (hi - lo) << "\n";  // 迭代器间距离 = 个数
    }

    return 0;
}

关键点:

  • 先对位置排序——二分查找的前提条件
  • lower_bound(pos.begin(), pos.end(), l) 返回第一个 ≥ l 的元素的迭代器
  • upper_bound(pos.begin(), pos.end(), r) 返回第一个 > r 的元素的迭代器
  • [l, r] 内元素个数 = 两迭代器之间的距离 = hi - lo
  • 总复杂度:排序 O(N log N) + 查询 O(Q log N)——远优于暴力 O(N×Q)

展望:超越基础 STL

本章涵盖了 90% 题目中会用到的核心 STL 容器。随着进阶,你还会遇到更专业的结构:

  • deque<T> —— 双端队列;支持 O(1) 在两端压入/弹出。用于滑动窗口最大值(挑战 3.1.12)和单调队列(第 3.4 章)。
  • multiset<T> —— 类似 set 但允许重复元素。需要有序且有重复时使用。
  • bitset<N> —— 固定大小的位序列;处理子集/成员问题极快。
  • Trie(前缀树) —— 通过共享公共前缀存储字符串,支持 O(L) 查找(L 是字符串长度)。

图示:Trie 数据结构

Trie Structure

Trie(前缀树)通过共享公共前缀存储字符串。单词 "bat"、"car"、"card"、"care"、"cat" 高效共享前缀:"ca" 只存一次,分支到 "r" 和 "t"。双圈节点标记单词结尾。Trie 用于自动补全、拼写检查和字符串匹配。字符串哈希的替代方案参见第 3.7 章(哈希技术)。

📖 第 3.2 章 ⏱️ 约 70 分钟 🎯 中级

第 3.2 章:数组与前缀和

📝 前置条件: 确保你熟悉数组、向量和基本循环(第 2.2–2.3 章)。还需要理解 long long 溢出(第 2.1 章)。

设想你有一个 N 个数字的数组,有人问你 100,000 次:「从下标 L 到 R 的元素之和是多少?」朴素做法每次重新计算——每次查询 O(N),总计 O(N × Q)。当 N = Q = 10^5 时,是 10^10 次操作,远远太慢。

前缀和O(N) 预处理、每次查询 O(1) 解决这个问题。这是竞赛编程中最优雅、最实用的技术之一。

💡 核心思路: 前缀和将「区间查询」问题转化为一次减法。不用每次都从 L 到 R 求和,而是预计算累积和后做两次相减。这把 O(Q) 的重复计算换成了一次性的 O(N) 预处理。


3.2.1 前缀和的思想

数组的前缀和是一个新数组,其中每个元素存储到当前下标为止的累积和。

图示:前缀和数组

Prefix Sum Visualization

上图展示了如何从原始数组构建前缀和数组,以及如何用 sum(L, R) = P[R] - P[L-1]O(1) 时间内计算区间和。蓝色单元格标示查询范围,红绿单元格展示被相减的两个前缀值。

给定数组:A = [3, 1, 4, 1, 5, 9, 2, 6](使用 1-indexed 以便说明)

下标:  1  2  3  4  5  6  7  8
A:      3  1  4  1  5  9  2  6
P:      3  4  8  9  14 23 25 31

其中 P[i] = A[1] + A[2] + ... + A[i]

为什么用 1-indexed?

使用 1-indexed 数组让我们可以定义 P[0] = 0(「空前缀」和为零)。这使得查询公式 P[R] - P[L-1]L = 1 时也能正常工作——计算 P[R] - P[0] = P[R],这是正确的。

构建前缀和数组

📄 查看代码:构建前缀和数组
// 构建前缀和数组 — O(N)
#include <bits/stdc++.h>
using namespace std;

int main() {
    ios_base::sync_with_stdio(false);
    cin.tie(NULL);

    int n;
    cin >> n;

    // 第一步:读取输入(1-indexed)
    vector<int> A(n + 1);
    for (int i = 1; i <= n; i++) cin >> A[i];

    // 第二步:构建前缀和
    vector<long long> P(n + 1, 0);  // P[0] = 0(基础情况)
    for (int i = 1; i <= n; i++) {
        P[i] = P[i - 1] + A[i];   // ← 关键行:每个 P[i] = 到 i 为止所有元素之和
    }

    return 0;
}

复杂度分析:

  • 时间: O(N) —— 遍历数组一次
  • 空间: O(N) —— 存储前缀数组

A = [3, 1, 4, 1, 5] 的逐步追踪:

i=1: P[1] = P[0] + A[1] = 0 + 3 = 3
i=2: P[2] = P[1] + A[2] = 3 + 1 = 4
i=3: P[3] = P[2] + A[3] = 4 + 4 = 8
i=4: P[4] = P[3] + A[4] = 8 + 1 = 9
i=5: P[5] = P[4] + A[5] = 9 + 5 = 14

3.2.2 O(1) 区间求和查询

有了前缀和数组,下标 L 到 R 的和就是:

sum(L, R) = P[R] - P[L-1]

为什么? P[R] = 1..R 的元素之和。P[L-1] = 1..(L-1) 的元素之和。两者之差 = L..R 的元素之和。

💡 核心思路: 把 P[i] 理解为「前 i 个元素的总和」。要得到窗口 [L, R] 的和,就从「到 R 的前缀」中减去「L 之前的前缀」。就像:大三角形减去小三角形 = 梯形。

📄 C++ 完整代码
// 区间求和查询 — 预处理 O(N),每次查询 O(1)
#include <bits/stdc++.h>
using namespace std;

const int MAXN = 100001;
long long A[MAXN];
long long P[MAXN];

int main() {
    ios_base::sync_with_stdio(false);
    cin.tie(NULL);

    int n, q;
    cin >> n >> q;

    // 第一步:读取数组
    for (int i = 1; i <= n; i++) cin >> A[i];

    // 第二步:构建前缀和 — O(n)
    P[0] = 0;
    for (int i = 1; i <= n; i++) {
        P[i] = P[i - 1] + A[i];
    }

    // 第三步:回答 q 个区间求和查询 — 每次 O(1)
    for (int i = 0; i < q; i++) {
        int l, r;
        cin >> l >> r;
        cout << P[r] - P[l - 1] << "\n";  // ← 关键行:区间和公式
    }

    return 0;
}

样例输入:

8 3
3 1 4 1 5 9 2 6
1 4
3 7
2 6

样例输出:

9
21
20

验证:

  • sum(1,4) = P[4] - P[0] = 9 - 0 = 9 → A[1]+A[2]+A[3]+A[4] = 3+1+4+1 = 9 ✓
  • sum(3,7) = P[7] - P[2] = 25 - 4 = 21 → A[3]+...+A[7] = 4+1+5+9+2 = 21 ✓
  • sum(2,6) = P[6] - P[1] = 23 - 3 = 20 → A[2]+...+A[6] = 1+4+1+5+9 = 20 ✓

⚠️ 常见错误: 写成 P[R] - P[L] 而不是 P[R] - P[L-1]。公式包含 L 和 R 两个端点——你要减去 L 之前的和,不是 L 的和。

总复杂度: O(N + Q) —— 对 N、Q 最大 10^5 完全没问题。


3.2.3 USACO 示例:品种统计

这是一道经典的 USACO Bronze 题(2015 年 12 月)。

题目: N 头奶牛排成一列,每头的品种是 1、2 或 3。回答 Q 个查询:位置 L 到 R 中有多少头品种为 B 的奶牛?

解法: 每种品种维护一个前缀和数组。

📄 C++ 完整代码
// 多品种前缀和 — O(N + Q)
#include <bits/stdc++.h>
using namespace std;

int main() {
    ios_base::sync_with_stdio(false);
    cin.tie(NULL);

    int n, q;
    cin >> n >> q;

    vector<int> breed(n + 1);
    vector<vector<long long>> P(4, vector<long long>(n + 1, 0));
    // P[b][i] = 位置 1..i 中品种 b 的奶牛数量

    // 第一步:为每种品种构建前缀和
    for (int i = 1; i <= n; i++) {
        cin >> breed[i];
        for (int b = 1; b <= 3; b++) {
            P[b][i] = P[b][i - 1] + (breed[i] == b ? 1 : 0);  // ← 关键行
        }
    }

    // 第二步:每次查询 O(1) 回答
    for (int i = 0; i < q; i++) {
        int l, r, b;
        cin >> l >> r >> b;
        cout << P[b][r] - P[b][l - 1] << "\n";
    }

    return 0;
}

🏆 USACO 技巧: 很多 USACO Bronze 题涉及「统计范围内满足属性 X 的元素个数」。如果 Q 较大,始终考虑前缀和。


3.2.4 USACO 风格题目详解:FJ 的草地

🔗 相关题目: 这是一道受「品种统计」和「最高奶牛」启发的虚构 USACO 风格题目——两者都是经典 Bronze 题。

题目: FJ 有 N 块连续的田地,第 i 块有 grass[i] 单位的草。他需要回答 Q 个查询:「第 L 块到第 R 块(含)的草总量是多少?」N、Q 最大 10^5,每次查询需要 O(1) 回答。

样例输入:

6 4
4 2 7 1 8 3
1 3
2 5
4 6
1 6

样例输出:

13
18
12
25

逐步解法:

第一步: 理解题目。我们有数组 [4, 2, 7, 1, 8, 3],需要区间求和。

第二步: 构建前缀和数组。

下标:  0  1  2  3  4  5  6
草量:  -  4  2  7  1  8  3
P:      0  4  6  13 14 22 25

第三步:P[R] - P[L-1] 回答查询:

  • 查询 (1,3):P[3] - P[0] = 13 - 0 = 13
  • 查询 (2,5):P[5] - P[1] = 22 - 4 = 18
  • 查询 (4,6):P[6] - P[3] = 25 - 13 = 12
  • 查询 (1,6):P[6] - P[0] = 25 - 0 = 25

完整 C++ 解法:

📄 C++ 完整代码
// FJ 的草地 — 前缀和解法 O(N + Q)
#include <bits/stdc++.h>
using namespace std;

int main() {
    ios_base::sync_with_stdio(false);
    cin.tie(NULL);

    int n, q;
    cin >> n >> q;

    // 第一步:读取草量并同步构建前缀和
    vector<long long> P(n + 1, 0);
    for (int i = 1; i <= n; i++) {
        long long g;
        cin >> g;
        P[i] = P[i - 1] + g;   // ← 关键行:增量前缀和
    }

    // 第二步:每次查询 O(1) 回答
    while (q--) {
        int l, r;
        cin >> l >> r;
        cout << P[r] - P[l - 1] << "\n";
    }

    return 0;
}

为什么是 O(N + Q)

  • 构建前缀和:一个循环,N 次迭代 → O(N)
  • 每次查询:一次减法 → 每次 O(1),共 O(Q)
  • 总计:O(N + Q) —— 远好于暴力的 O(NQ)

⚠️ 常见错误: 前缀和用 int 而不是 long long。如果草量最大 10^9 且 N = 10^5,总和可达 10^14——远超 int 约 2×10^9 的范围。


3.2.5 二维前缀和

对于二维网格,可以扩展前缀和在 O(1) 时间内回答矩形区间查询。

给定 R×C 的网格,定义 P[r][c] = 从 (1,1) 到 (r,c) 矩形内所有元素之和。

构建二维前缀和

P[r][c] = A[r][c] + P[r-1][c] + P[r][c-1] - P[r-1][c-1]

减法是为了消除重叠(否则左上角矩形会被计算两次)。

💡 核心思路(容斥原理): 想象四个矩形:

  • P[r-1][c] = 「上方」矩形
  • P[r][c-1] = 「左方」矩形
  • P[r-1][c-1] = 「左上角」(在上面两个中都计算了——所以减去一次)
  • A[r][c] = 单个新单元格

二维前缀和逐步工作示例

追踪一个 4×4 网格:

原始网格 A:

     c=1  c=2  c=3  c=4
r=1:  1    2    3    4
r=2:  5    6    7    8
r=3:  9   10   11   12
r=4: 13   14   15   16

逐步构建 P(从左到右、从上到下):

📄 Code 完整代码
P[1][1] = A[1][1] = 1
P[1][2] = 2 + 0 + 1 - 0 = 3
P[1][3] = 3 + 0 + 3 - 0 = 6
P[1][4] = 4 + 0 + 6 - 0 = 10
P[2][1] = 5 + 1 + 0 - 0 = 6
P[2][2] = 6 + 3 + 6 - 1 = 14
P[2][3] = 7 + 6 + 14 - 3 = 24
P[2][4] = 8 + 10 + 24 - 6 = 36
P[3][1] = 9 + 6 + 0 - 0 = 15
P[3][2] = 10 + 14 + 15 - 6 = 33
P[3][3] = 11 + 24 + 33 - 14 = 54
P[3][4] = 12 + 36 + 54 - 24 = 78
P[4][1] = 13 + 15 + 0 - 0 = 28
P[4][2] = 14 + 33 + 28 - 15 = 60
P[4][3] = 15 + 54 + 60 - 33 = 96
P[4][4] = 16 + 78 + 96 - 54 = 136

前缀和网格 P:

     c=1  c=2  c=3  c=4
r=1:  1    3    6   10
r=2:  6   14   24   36
r=3: 15   33   54   78
r=4: 28   60   96  136

查询:子网格 (r1=2, c1=2) 到 (r2=3, c2=3) 的和:

ans = P[3][3] - P[1][3] - P[3][1] + P[1][1]
    = 54     -  6     -  15     +  1
    = 34

验证:A[2][2]+A[2][3]+A[3][2]+A[3][3] = 6+7+10+11 = 34 ✓

容斥原理图示:

2D Prefix Sum Inclusion-Exclusion

📄 C++ 完整代码
// 二维前缀和 — 构建 O(R×C),查询 O(1)
#include <bits/stdc++.h>
using namespace std;

const int MAXR = 1001, MAXC = 1001;
int A[MAXR][MAXC];
long long P[MAXR][MAXC];

int main() {
    ios_base::sync_with_stdio(false);
    cin.tie(NULL);

    int R, C;
    cin >> R >> C;

    for (int r = 1; r <= R; r++)
        for (int c = 1; c <= C; c++)
            cin >> A[r][c];

    // 第一步:构建二维前缀和 — O(R × C)
    for (int r = 1; r <= R; r++) {
        for (int c = 1; c <= C; c++) {
            P[r][c] = A[r][c]
                    + P[r-1][c]    // 上方矩形
                    + P[r][c-1]    // 左方矩形
                    - P[r-1][c-1]; // ← 关键行:消除重叠(被计算了两次)
        }
    }

    // 第二步:每次查询 O(1) 回答
    int q;
    cin >> q;
    while (q--) {
        int r1, c1, r2, c2;
        cin >> r1 >> c1 >> r2 >> c2;
        long long ans = P[r2][c2]
                      - P[r1-1][c2]    // 减去上方条带
                      - P[r2][c1-1]    // 减去左方条带
                      + P[r1-1][c1-1]; // 加回左上角
        cout << ans << "\n";
    }

    return 0;
}

复杂度分析:

  • 构建时间: O(R × C)
  • 查询时间: 每次 O(1)
  • 空间: O(R × C)

⚠️ 常见错误: 查询公式中忘记加回 P[r1-1][c1-1]。上方条带和左方条带都包含左上角,所以被减了两次——需要加回一次!


3.2.6 差分数组

既然你已经看到二维前缀和如何将一维思想扩展到网格,让我们来看对偶操作:差分数组。就像微积分中微分是积分的逆运算,差分数组是前缀和的逆运算——前缀和将点数据累积成区间数据,差分数组将区间操作分解为点标记。

方向操作类比
正向:前缀和点值 → 区间和积分 ∫
逆向:差分数组区间更新 → 点标记微分 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 块加 5
  • update(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=Rc2=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 章常见错误

  1. 区间查询差一:P[R] - P[L] 而不是 P[R] - P[L-1]。始终用小例子验证。
  2. 溢出: 大值的前缀和可能超过 int 范围(2×10^9)。即使元素是 int,前缀数组也要用 long long
  3. 二维查询公式: 忘了二维查询中的 +P[r1-1][c1-1] 项——非常容易疏忽。
  4. 差分数组大小: 声明 diff[n+1] 但需要 diff[n+2](因为要写入下标 r+1,可能是 n+1)。
  5. 1-indexed vs 0-indexed: 用 0-indexed 前缀和时,查询公式变为 P[R+1] - P[L]。在一道题内选定一种约定并坚持用。
  6. 二维差分数组大小: 声明 diff[R+1][C+1] 但需要 diff[R+2][C+2]——四角更新会写入 (r2+1, c2+1),必须在范围内。
  7. 二维差分重建顺序: 二维前缀和重建必须从左到右、从上到下处理单元格(与构建二维前缀和的顺序相同)。顺序混乱会产生错误结果。

本章总结

📌 核心要点

技术构建时间查询时间空间使用场景
一维前缀和O(N)O(1)O(N)一维数组的区间和
二维前缀和O(RC)O(1)O(RC)二维网格的矩形和
差分数组O(N+M)O(1)*O(N)区间加法更新
二维差分数组O(RC+M)O(1)*O(RC)二维网格上的矩形加法
Kadane 算法O(N)O(1)最大子数组和

*需要 O(N) 的重建遍历后才能读取所有值。

🧩 核心公式速查

操作公式备注
一维区间和P[R] - P[L-1]P[0] = 0 是哨兵值
二维矩形和P[r2][c2] - P[r1-1][c2] - P[r2][c1-1] + P[r1-1][c1-1]容斥:减两次,加一次
差分数组更新diff[L] += V; diff[R+1] -= V;数组大小应为 N+2
二维差分更新diff[r1][c1]+=V; diff[r1][c2+1]-=V; diff[r2+1][c1]-=V; diff[r2+1][c2+1]+=V四角标记
从差分恢复对 diff 取前缀和(一维或二维)结果是最终数组

❓ 常见问题

Q1:前缀和和差分数组有什么关系?

A:它们是逆运算。对数组取前缀和得到前缀和数组;对前缀和数组取差分(相邻元素差)则恢复原数组。反过来,对差分数组取前缀和也能恢复原数组。这类似于数学中的积分和微分。

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² 来说太慢。改进方案:

  1. 用二维前缀和实现 O(1) 求和查询
  2. 对子网格中的最大值:预处理二维稀疏表(或每行 RMQ)实现 O(1) 最大值查询
  3. 边界和 = 总和 - 内部和(均通过前缀和)

总计:O(N²M²) 枚举 × O(1) 每次查询 = 对 N,M ≤ 500 在时间限制内。

📖 第 3.3 章 ⏱️ 约 60 分钟 🎯 中级

第 3.3 章:排序与搜索

📝 前置条件: 你应该熟悉数组、向量和基本循环(第 2.2–2.3 章)。了解第 3.1 章中的 std::sort 有帮助,但本章会深入讲解。

排序和搜索是计算机科学中最基础的两种操作。在 USACO 中,一旦把数据正确排序,大量问题就迎刃而解。而二分查找——在 O(log n) 时间内搜索有序数组——是你会反复用到的技术。


3.3.1 排序为什么重要

考虑这道题:「给定 N 头奶牛的身高,找出身高最接近的两头奶牛。」

  • 不排序的做法: 比较每一对 → O(N²)。对 N = 10^5,是 10^10 次操作,超时。
  • 排序的做法: 排序身高 → O(N log N)。然后最近的一对一定是相邻的!检查 N-1 对 → O(N)。总计:O(N log N)。✓

💡 核心思路: 排序能把很多 O(N²) 暴力解法变成 O(N log N)O(N) 解法。当你看到「找满足属性 X 的对」或「找涉及两个元素的最小/最大值」时,始终先考虑排序。

复杂度分析:

  • 排序:O(N log N) 时间,O(log N) 空间(递归栈深度;std::sort 使用 Introsort——快速排序 + 堆排序 + 插入排序的混合,三个分支最多使用 O(log N) 的栈空间)
  • 排序后:相邻比较或双指针技术是 O(N)

3.3.2 排序的工作原理(概念)

你不需要自己实现排序算法——std::sort 帮你做了。但理解背后的思想有助于你分析时间复杂度并选择正确的方法。

以下是四种经典排序算法,每种都配有交互式可视化帮助你理解。

算法时间复杂度空间稳定?核心思想
冒泡排序O(N²)O(1)交换相邻元素;大值「冒泡」到末尾
插入排序O(N²) / O(N) 最优O(1)将每个元素插入已排序区域的正确位置
归并排序O(N log N)O(N)分治:递归分割,然后合并
快速排序O(N log N) 平均O(log N)分治:以枢轴为界分区,递归

🫧 冒泡排序 —— O(N²)

反复扫描数组,交换顺序错误的相邻元素。每次遍历把当前最大值「冒泡」到未排序区域的末尾:

初始:   [64, 34, 25, 12, 22, 11, 90]
第1遍:  [34, 25, 12, 22, 11, 64, 90]   ← 64 冒泡到倒数第2位
第2遍:  [25, 12, 22, 11, 34, 64, 90]   ← 34 冒泡到倒数第3位
第3遍:  [12, 22, 11, 25, 34, 64, 90]   ← 25 冒泡到倒数第4位
...

📝 注意: 90 一开始就在正确位置,第1遍没有移动它——而是 64(次大值)冒泡到倒数第2位。每次遍历保证末尾多一个元素到达最终有序位置。

冒泡排序是 O(N²)竞赛编程中对大输入绝不要用它。 我们讲它只是因为概念上最简单。


🃏 插入排序 —— O(N²) / 最优 O(N)

把数组分为左侧「已排序区」和右侧「未排序区」。每步取未排序区的第一个元素,插入到已排序区的正确位置:

开始:  [64 | 34, 25, 12, 22, 11, 90]   ← | 左侧已排序
i=1:   [34, 64 | 25, 12, 22, 11, 90]   ← 34 插到 64 之前
i=2:   [25, 34, 64 | 12, 22, 11, 90]   ← 25 插到最前
i=3:   [12, 25, 34, 64 | 22, 11, 90]   ← 12 插到最前
...

💡 插入排序的优势:几乎已排序的数组非常快(接近 O(N))。std::sort 对小子数组会切换到插入排序。

查看参考实现
void insertionSort(vector<int>& a) {
    int n = a.size();
    for (int i = 1; i < n; i++) {
        int key = a[i];   // 要插入的元素
        int j = i - 1;
        // 将大于 key 的元素向右移动一位
        while (j >= 0 && a[j] > key) {
            a[j + 1] = a[j];
            j--;
        }
        a[j + 1] = key;  // 把 key 放到正确位置
    }
}

🔀 归并排序 —— 始终 O(N log N)

分治:递归地将数组分成两半,然后将两个已排序的半部合并:

[38, 27, 43, 3, 9, 82, 10]
        ↓ 递归分割
[38,27,43,3]    [9,82,10]
[38,27] [43,3]  [9,82] [10]
[38][27][43][3] [9][82][10]
        ↓ 自底向上合并
[27,38] [3,43]  [9,82] [10]
  [3,27,38,43]    [9,10,82]
      [3,9,10,27,38,43,82] ✓

归并排序在所有情况下都是 O(N log N),而且是稳定排序。

查看参考实现
void merge(vector<int>& a, int lo, int mid, int hi) {
    vector<int> tmp(a.begin() + lo, a.begin() + hi + 1);
    int i = lo, j = mid + 1, k = lo;
    while (i <= mid && j <= hi) {
        if (tmp[i - lo] <= tmp[j - lo]) {
            a[k++] = tmp[i - lo];  // 左半部分更小,优先放入以保持稳定性
            i++;
        } else {
            a[k++] = tmp[j - lo];
            j++;
        }
    }
    while (i <= mid) { a[k++] = tmp[i - lo]; i++; }
    while (j <= hi)  { a[k++] = tmp[j - lo]; j++; }
}

void mergeSort(vector<int>& a, int lo, int hi) {
    if (lo >= hi) return;
    int mid = lo + (hi - lo) / 2;
    mergeSort(a, lo, mid);
    mergeSort(a, mid + 1, hi);
    merge(a, lo, mid, hi);
}

⚡ 快速排序 —— 平均 O(N log N)

快速排序是 std::sort 底层的核心算法之一,关键思想是分治

  1. 选择一个枢轴元素(通常是最后一个元素)
  2. 分区: 将所有 ≤ 枢轴的元素移到左边,> 枢轴的移到右边;枢轴落到最终位置
  3. 对左右子数组递归
[8, 3, 6, 1, 9, 2, 7, 4]   ← 枢轴 = 4
         ↓ 分区
[3, 1, 2, 4, 9, 6, 7, 8]   ← 4 在最终位置;左边 ≤ 4,右边 > 4
 ↑_______↑  ↑  ↑__________↑
 左子数组     右子数组

递归 [3,1,2] → [1,2,3]
递归 [9,6,7,8] → [6,7,8,9]

最终:[1, 2, 3, 4, 6, 7, 8, 9] ✓

Quicksort Partition

查看参考实现
// 对 arr[lo..hi] 用最后一个元素作枢轴进行分区。
// 返回枢轴的最终下标。
int partition(vector<int>& arr, int lo, int hi) {
    int pivot = arr[hi];   // 选最后一个元素为枢轴
    int i = lo - 1;        // i 指向「≤ 枢轴」区域的末尾

    for (int j = lo; j < hi; j++) {
        if (arr[j] <= pivot) {
            i++;
            swap(arr[i], arr[j]);  // 把 arr[j] 纳入 ≤ 枢轴区域
        }
    }
    swap(arr[i + 1], arr[hi]);  // 把枢轴放到最终位置
    return i + 1;               // 返回枢轴的下标
}

void quickSort(vector<int>& arr, int lo, int hi) {
    if (lo >= hi) return;           // 基础情况:子数组长度 ≤ 1
    int p = partition(arr, lo, hi); // p 是枢轴的最终位置
    quickSort(arr, lo, p - 1);      // 排序左子数组
    quickSort(arr, p + 1, hi);      // 排序右子数组
}

⚠️ 最坏情况: 若枢轴每次都是最大或最小值(例如已排序的输入),递归深度退化到 O(N),总时间变成 O(N²)。std::sort 通过随机选择枢轴三数取中来避免这一点,保证最坏 O(N log N)。

情况时间备注
平均O(N log N)枢轴大约将数组对半分
最坏O(N²)枢轴每次都是极端值(已排序输入)
空间O(log N)递归栈深度(平均);若枢轴每次极端则最坏 O(N)

🔢 计数排序 —— O(N + W)

适用场景: 元素是整数,值域范围 W 不太大(如 W ≤ 10^6)。

核心思想: 统计每个值出现的次数,再按值输出。

📄 C++ 完整代码
// 计数排序(稳定,O(N+W))
// 要求:所有元素在 [0, W) 范围内
void countingSort(vector<int>& a, int W) {
    int n = a.size();
    vector<int> cnt(W, 0), out(n);

    // 第一步:统计每个值的出现次数
    for (int x : a) cnt[x]++;

    // 第二步:变为前缀和(cnt[i] = 最终 <= i 的元素个数)
    for (int i = 1; i < W; i++) cnt[i] += cnt[i-1];

    // 第三步:从后向前放置(保证稳定性!)
    for (int i = n - 1; i >= 0; i--) {
        out[--cnt[a[i]]] = a[i];
    }
    a = out;
}

// 对键范围已知的使用示例:
// countingSort(arr, 100);  // 所有元素在 [0, 99]

💡 竞赛中的典型应用: 数组元素范围 ≤ 10^6 时比 std::sort 快;基数排序的子程序。


📦 基数排序 —— O(N × d)

适用场景: 整数排序,位数 d 固定(如 32 位整数 d=32/基数,十进制 d=10)。

核心思想: 对每一位(从最低位到最高位)进行一次计数排序。

📄 C++ 完整代码
// 基数排序(以 10 为基数的十进制版本)
// O(N * d),d 为最大数的十进制位数
void radixSort(vector<int>& a) {
    int maxVal = *max_element(a.begin(), a.end());
    int n = a.size();

    // 从最低位(个位)到最高位逐位排序
    for (int exp = 1; maxVal / exp > 0; exp *= 10) {
        vector<int> cnt(10, 0), out(n);

        // 统计当前位的频率
        for (int i = 0; i < n; i++)
            cnt[(a[i] / exp) % 10]++;

        // 前缀和
        for (int i = 1; i < 10; i++)
            cnt[i] += cnt[i-1];

        // 从后向前放置(稳定性保证)
        for (int i = n-1; i >= 0; i--) {
            int digit = (a[i] / exp) % 10;
            out[--cnt[digit]] = a[i];
        }
        a = out;
    }
}

追踪示例(对 [170, 45, 75, 90, 802, 24, 2, 66] 排序):

初始:[170, 45, 75, 90, 802, 24, 2, 66]

按个位排序:[170, 90, 802, 2, 24, 45, 75, 66]
按十位排序:[802, 2, 24, 45, 66, 170, 75, 90]
按百位排序:[2, 24, 45, 66, 75, 90, 170, 802]  ← 有序!

📊 排序算法完整对比

算法平均时间最坏时间空间稳定适用场景
冒泡排序O(N²)O(N²)O(1)教学用,不实用
插入排序O(N²)O(N²)O(1)小数组或近似有序
归并排序O(N log N)O(N log N)O(N)需要稳定排序
快速排序O(N log N)O(N²)O(log N)通用高效(std::sort)
堆排序O(N log N)O(N log N)O(1)内存受限场景
计数排序O(N+W)O(N+W)O(W)整数,值域有限
基数排序O(N×d)O(N×d)O(N+k)固定长度整数/字符串

如何选择?

  • 通用场景 → std::sort(底层是快速排序/堆排序混合)
  • 需要稳定性 → std::stable_sort(底层是归并排序)
  • 整数,值域 ≤ 10^6 → 计数排序
  • 大量整数,值域很大但位数固定 → 基数排序

3.3.3 std::sort 实战

⚠️ 稳定性说明: std::sort 不稳定——它使用 Introsort(快速排序 + 堆排序 + 插入排序的混合),不保留相等元素的相对顺序。如需稳定排序,改用 std::stable_sort

📄 C++ 完整代码
#include <bits/stdc++.h>
using namespace std;

int main() {
    ios_base::sync_with_stdio(false);
    cin.tie(NULL);

    int n;
    cin >> n;
    vector<int> v(n);
    for (int &x : v) cin >> x;

    // 升序排序
    sort(v.begin(), v.end());

    // 降序排序
    sort(v.begin(), v.end(), greater<int>());

    // 只排序向量的一部分(下标 2 到 5,含)
    sort(v.begin() + 2, v.begin() + 6);

    for (int x : v) cout << x << " ";
    cout << "\n";

    return 0;
}

按多个条件排序

经常需要先按一个字段排序,相同时用另一个字段打平。用 pair 时这是自动的(先按 .first,再按 .second):

vector<pair<int, string>> students;
students.push_back({85, "Alice"});
students.push_back({92, "Bob"});
students.push_back({85, "Charlie"});

sort(students.begin(), students.end());
// 结果:{85, "Alice"}, {85, "Charlie"}, {92, "Bob"}
// 先按成绩排,成绩相同时按姓名字典序

自定义比较器

比较器是一个函数,当第一个参数应该排在第二个参数前面时返回 true

最清晰的写法是独立函数:

📄 最清晰的写法是独立函数:
struct Cow {
    string name;
    int weight;
    int height;
};

// 按体重升序;体重相同时按身高降序
bool cmpCow(const Cow &a, const Cow &b) {
    if (a.weight != b.weight) return a.weight < b.weight;  // 较轻的优先
    return a.height > b.height;                             // 打平:较高的优先
}

int main() {
    vector<Cow> cows = {{"Bessie", 500, 140}, {"Elsie", 480, 135}, {"Moo", 500, 138}};

    sort(cows.begin(), cows.end(), cmpCow);

    for (auto &c : cows) {
        cout << c.name << " " << c.weight << " " << c.height << "\n";
    }
    // 输出:
    // Elsie 480 135
    // Bessie 500 140
    // Moo 500 138
    return 0;
}

💡 风格说明:cmp 定义为独立函数(而非内联 lambda)让排序逻辑更易读、测试和复用——特别是涉及多个字段时。

排序算法稳定性

⚠️ 重要: std::sort 不稳定——相等元素排序后可能以任意顺序出现。如需保留相等元素的相对顺序,用 std::stable_sort

排序算法稳定性对比

算法时间复杂度空间复杂度稳定?C++ 函数
std::sortO(N log N)O(log N)sort()
std::stable_sortO(N log² N)*O(N)stable_sort()
std::partial_sortO(N log K)O(1)partial_sort()
计数排序O(N+K)O(K)手写
基数排序O(d(N+K))O(N+K)手写

📝 说明: std::sort 使用 Introsort(快速排序 + 堆排序 + 插入排序的混合)。由于快速排序不稳定,std::sort 不保证相等元素的相对顺序。当你按成绩对学生排序时,若需要相同成绩的学生保持原来的顺序,使用 std::stable_sort

* std::stable_sort 在有足够额外内存(O(N))时是 O(N log N),只有在内存受限需要原地归并时才退化到 O(N log² N)

图示:排序算法对比

Sorting Algorithm Comparison

这张图对比了常见排序算法的时间复杂度、空间占用和稳定性,帮助你在不同场景选择合适的算法。

计数排序 —— 小值域的 O(N+K)

当值是小范围 [0, MAXVAL] 内的有界整数时,计数排序远优于 std::sort

// 计数排序:对范围 [0, MAXVAL] 内的整数
// 时间 O(N+MAXVAL),稳定排序
void countingSort(vector<int>& arr, int maxVal) {
    vector<int> cnt(maxVal + 1, 0);
    for (int x : arr) cnt[x]++;
    int idx = 0;
    for (int v = 0; v <= maxVal; v++)
        for (int i = 0; i < cnt[v]; i++) arr[idx++] = v;
}
// USACO 使用场景:值域小时(如奶牛 ID 1-1000)比 std::sort 更快

什么时候在 USACO 用计数排序:

  • 奶牛 ID 在 [1, 1000] 范围内,N = 10^6 → 计数排序是 O(N + 1000) vs O(N log N)
  • 成绩值 [0, 100] → 极快
  • 颜色类别 [0, 3] → 瞬间完成

注意: 若 MAXVAL 很大(如 10^9),计数排序需要 O(MAXVAL) 内存——不要用。先做坐标压缩(3.3.6 节),再计数。


3.3.4 二分查找

二分查找在已排序数组中以 O(log n) 查找目标——而线性搜索是 O(n)

类比: 在字典里查词。你不会从 A 开始逐条读——你翻到中间,判断你的词在前面还是后面,然后重复。每步将搜索空间减半:k 步后,从 N 个候选缩减到 N/2^k。当 N/2^k < 1 时结束——需要 k = log₂(N) 步。

💡 核心思路: 只要有单调谓词——一个「假假假…真真真」(或反过来)的条件,就可以用二分查找。你可以在 O(log N) 时间内二分找到假和真之间的边界

图示:二分查找示例

Binary Search

上图展示在 [1,3,5,7,9,11,13] 中单步二分查找 7。左(L)、右(R)和中(M)指针清晰可见。核心技巧:用 mid = left + (right - left) / 2 计算中间位置,避免 (left + right) / 2 的整数溢出。

手写二分查找

📄 查看代码:手写二分查找
// 二分查找 — O(log N)
#include <bits/stdc++.h>
using namespace std;

// 在有序 arr 中返回 target 的下标,未找到返回 -1
int binarySearch(const vector<int> &arr, int target) {
    int lo = 0, hi = (int)arr.size() - 1;

    while (lo <= hi) {
        int mid = lo + (hi - lo) / 2;  // ← 关键行:避免溢出(不要用 (lo+hi)/2)

        if (arr[mid] == target) {
            return mid;         // 找到!
        } else if (arr[mid] < target) {
            lo = mid + 1;       // 目标在右半部分
        } else {
            hi = mid - 1;       // 目标在左半部分
        }
    }

    return -1;  // 未找到
}

int main() {
    vector<int> v = {1, 3, 5, 7, 9, 11, 13, 15};
    cout << binarySearch(v, 7) << "\n";   // 3(下标)
    cout << binarySearch(v, 6) << "\n";   // -1(未找到)
    return 0;
}

[1, 3, 5, 7, 9, 11, 13, 15] 中搜索 7 的逐步追踪:

lo=0, hi=7:mid=3,arr[3]=7 → 找到,下标 3 ✓

搜索 6:
lo=0, hi=7:mid=3,arr[3]=7 > 6 → hi=2
lo=0, hi=2:mid=1,arr[1]=3 < 6 → lo=2
lo=2, hi=2:mid=2,arr[2]=5 < 6 → lo=3
lo=3 > hi=2:循环结束 → 返回 -1 ✓

为什么用 lo + (hi - lo) / 2 如果 lohi 都很大(接近 INT_MAX),lo + hi 会溢出!这个写法等价但安全。

STL 方式:lower_boundupper_bound

竞赛编程中你实际上几乎总是想用这两个:

📄 竞赛编程中你实际上几乎总是想用这两个:
// STL 二分操作 — 全部 O(log N)
#include <bits/stdc++.h>
using namespace std;

int main() {
    vector<int> v = {1, 3, 3, 5, 7, 9, 9, 11};

    // lower_bound:第一个 >= target 的迭代器
    auto lb = lower_bound(v.begin(), v.end(), 3);
    cout << *lb << "\n";                    // 3(第一个 3)
    cout << lb - v.begin() << "\n";         // 1(下标)

    // upper_bound:第一个 > target 的迭代器
    auto ub = upper_bound(v.begin(), v.end(), 3);
    cout << *ub << "\n";                    // 5(所有 3 后面的第一个元素)
    cout << ub - v.begin() << "\n";         // 3(下标)

    // 统计出现次数:upper_bound - lower_bound
    int count_of_3 = upper_bound(v.begin(), v.end(), 3)
                   - lower_bound(v.begin(), v.end(), 3);
    cout << count_of_3 << "\n";   // 2

    // 检查是否存在
    bool exists = binary_search(v.begin(), v.end(), 7);
    cout << exists << "\n";  // 1

    // 找 <= target 的最大值(向下取整)
    auto it = upper_bound(v.begin(), v.end(), 6);
    if (it != v.begin()) {
        --it;
        cout << *it << "\n";  // 5(<= 6 的最大值)
    }

    return 0;
}

⚠️ 常见错误:未排序的容器上用 lower_bound/upper_bound。这些函数假设已排序——对未排序的数据会给出错误结果,没有任何报错!


3.3.5 二分答案

这是 USACO Silver 中最强大、最常考的技术之一。核心思想:

不是在数组中搜索某个值,而是在答案空间本身进行二分查找。

什么情况下适用? 当:

  1. 答案是某个范围 [lo, hi] 内的数字
  2. 有一个 canAchieve(X) 函数检查 X 是否可行
  3. 该函数单调:若 X 可行,所有 ≤ X 的值也可行(或所有 ≥ X 的值可行)

💡 核心思路: 单调性意味着存在一个「阈值」将可行与不可行答案分开。二分查找以 O(log(hi-lo))canAchieve 调用找到这个阈值。若每次调用需 O(f(N)),总时间为 O(f(N) × log(答案范围))

经典示例:攻击性奶牛(SPOJ AGGRCOW / 经典题)

题目: N 个马厩位于位置 p[1..N],放置 C 头奶牛以最大化任意两头奶牛间的最小距离

为什么用二分答案? 若能以最小间距 D 放置奶牛,也能以 D-1 的间距放置。所以可行性是单调的:存在阈值 D*,≥ D* 不可行,< D* 可行。二分查找 D*。

canPlace(minDist) 函数: 在最左侧的马厩放第一头奶牛,然后贪心地选择下一个距离至少为 minDist 的马厩。统计能放多少头奶牛——若 ≥ C,返回 true。

📄 C++ 完整代码
// 二分答案 — O(N log N log(最大距离))
#include <bits/stdc++.h>
using namespace std;

int n, c;
vector<int> stalls;

// 能否放置 c 头奶牛使任意两头间距 >= minDist?
bool canPlace(int minDist) {
    int placed = 1;           // 第一头放在马厩 0
    int lastPos = stalls[0];  // 最后放置的奶牛的位置

    for (int i = 1; i < n; i++) {
        if (stalls[i] - lastPos >= minDist) {  // 这个马厩足够远
            placed++;
            lastPos = stalls[i];
        }
    }
    return placed >= c;  // 放了 c 头奶牛吗?
}

int main() {
    ios_base::sync_with_stdio(false);
    cin.tie(NULL);

    cin >> n >> c;
    stalls.resize(n);
    for (int &x : stalls) cin >> x;
    sort(stalls.begin(), stalls.end());  // 必须先排序!

    // 二分答案:最大可能的最小距离是多少?
    int lo = 1, hi = stalls.back() - stalls.front();
    int answer = 0;

    while (lo <= hi) {
        int mid = lo + (hi - lo) / 2;
        if (canPlace(mid)) {
            answer = mid;    // mid 可行,尝试更大的
            lo = mid + 1;
        } else {
            hi = mid - 1;    // mid 不可行,尝试更小的
        }
    }

    cout << answer << "\n";
    return 0;
}

对 stalls = [1, 2, 4, 8, 9], C = 3 的追踪:

📄 Code 完整代码
排序后:[1, 2, 4, 8, 9]
lo=1, hi=8

mid=4:canPlace(4)?
  在 1 放奶牛。下一个 ≥ 1+4=5 的:8,放在 8。
  下一个 ≥ 8+4=12:没有。总共放了 2 头 < 3。返回 false。
  → hi = 3

mid=2:canPlace(2)?
  在 1 放。下一个 ≥ 3:4,放在 4。
  下一个 ≥ 6:8,放在 8。总共 3 头 ≥ 3。返回 true。
  → answer=2, lo=3

mid=3:canPlace(3)?
  在 1 放。下一个 ≥ 4:4,放在 4。
  下一个 ≥ 7:8,放在 8。总共 3 头 ≥ 3。返回 true。
  → answer=3, lo=4

lo=4 > hi=3:结束。答案 = 3

另一个经典:最少时间完成任务(绳子切割)

题目: 给定 N 根长度为 L[i] 的绳子,切出 K 根等长的绳子。每段能切到的最大长度是多少?

📝 代码片段: 以下是代码片段——完整程序结构参考上面的攻击性奶牛示例。

📄 C++ 完整代码
// 代码片段 — 完整程序请参考攻击性奶牛示例
// 能否从绳子中切出 K 段长度 >= len 的?
bool canCut(vector<int> &ropes, long long len, int K) {
    long long count = 0;
    for (int r : ropes) count += r / len;  // 每根绳子能切出的段数
    return count >= K;
}

// 二分:最大化 len 使 canCut(len) 为真
long long lo = 1, hi = *max_element(ropes.begin(), ropes.end());
long long answer = 0;
while (lo <= hi) {
    long long mid = lo + (hi - lo) / 2;
    if (canCut(ropes, mid, K)) {
        answer = mid;
        lo = mid + 1;
    } else {
        hi = mid - 1;
    }
}

二分答案通用模板:

📄 C++ 完整代码
// 通用模板 — 根据题目调整 lo、hi 和 check()
long long lo = 最小可能答案;
long long hi = 最大可能答案;
long long answer = lo;  // 若无有效答案则为 -1

while (lo <= hi) {
    long long mid = lo + (hi - lo) / 2;
    if (check(mid)) {       // mid 可行
        answer = mid;       // 保存
        lo = mid + 1;       // 尝试更好的(取决于题目是更大还是更小)
    } else {
        hi = mid - 1;       // mid 不可行,降低
    }
}

🏆 USACO 技巧: 每当 USACO 题目问「满足某条件的最大 X 是多少」或「满足某条件的最小 X 是多少」,就考虑二分答案。这个技术频繁解决 USACO Silver 题目。


3.3.6 坐标压缩

有时值很大(最多 10^9),但不同的值不多。坐标压缩将它们映射到小下标(0, 1, 2, ...)。

📄 有时值很大(最多 10^9),但不同的值不多。**坐标压缩**将它们映射到小下标(0, 1, 2, ...)。
// 坐标压缩 — O(N log N)
#include <bits/stdc++.h>
using namespace std;

int main() {
    vector<int> A = {100, 500, 200, 100, 700, 200};

    // 第一步:获取有序唯一值
    vector<int> sorted_unique = A;
    sort(sorted_unique.begin(), sorted_unique.end());
    sorted_unique.erase(unique(sorted_unique.begin(), sorted_unique.end()),
                        sorted_unique.end());
    // sorted_unique = {100, 200, 500, 700}

    // 第二步:将每个原始值映射到压缩后的下标
    vector<int> compressed(A.size());
    for (int i = 0; i < (int)A.size(); i++) {
        compressed[i] = lower_bound(sorted_unique.begin(), sorted_unique.end(), A[i])
                        - sorted_unique.begin();
        // 100→0, 200→1, 500→2, 700→3
    }

    for (int x : compressed) cout << x << " ";
    cout << "\n";  // 0 2 1 0 3 1

    return 0;
}

3.3.7 进阶二分答案——三个示例

示例一:最少时间完成任务(参数搜索)

题目: N 个工人,M 个任务各有工作量 effort[i]。将连续的任务分配给工人(每个工人得到连续的任务),最小化任意工人的最大花费时间(最小化瓶颈)。

这是「画家分区」问题。对答案(最大时间 T)二分,检查 T 是否可行。

📝 模板切换说明: 这里用 while (lo < hi)hi = mid——与 3.3.5 节的 while (lo <= hi) 不同。我们切换是因为我们在最小化答案:canFinish(mid) 为真时,mid 本身是候选,所以设 hi = mid(而非 hi = mid - 1)避免错过它。循环结束时 lo == hi 就是答案,不需要单独的 answer 变量。详见 FAQ Q2。

📄 C++ 完整代码
// 检查:能否将任务分配给 K 个工人,使每人工作量 <= T?
bool canFinish(vector<int>& tasks, int K, long long T) {
    int workers = 1;
    long long current = 0;
    for (int t : tasks) {
        if (t > T) return false;  // 单个任务超过 T——不可能
        if (current + t > T) {
            workers++;             // 开始新工人
            current = t;
            if (workers > K) return false;
        } else {
            current += t;
        }
    }
    return true;
}

// 对 T 二分 — 用「lo < hi」模板(最小化答案)
long long lo = *max_element(tasks.begin(), tasks.end());  // T 的最小可能值
long long hi = accumulate(tasks.begin(), tasks.end(), 0LL);  // T 的最大值(1 个工人)

while (lo < hi) {
    long long mid = lo + (hi - lo) / 2;
    if (canFinish(tasks, K, mid)) hi = mid;  // mid 可行,尝试更小
    else lo = mid + 1;                        // mid 不可行,需要更大
}
cout << lo << "\n";  // 最小可能最大时间(循环结束时 lo == hi)

📝 注意: 这里二分查找最小可行 T,所以可行时用 hi = mid(不是 answer = mid; lo = mid+1)。两种模板是镜像关系。

示例二:乘法表中的第 K 小

题目: N×M 乘法表,找第 K 小的值。

表中的值是 1≤i≤N、1≤j≤M 的 i*j。对答案 X 二分:统计 ≤ X 的值有多少个。

📄 表中的值是 1≤i≤N、1≤j≤M 的 i*j。对答案 X 二分:统计 ≤ X 的值有多少个。
// 统计 N×M 乘法表中 <= X 的值的个数
long long countLE(long long X, int N, int M) {
    long long count = 0;
    for (int i = 1; i <= N; i++) {
        count += min((long long)M, X / i);
        // 第 i 行的值是 i, 2i, ..., Mi
        // 第 i 行中 <= X 的个数:min(M, floor(X/i))
    }
    return count;
}

// 二分查找第 K 小
long long lo = 1, hi = (long long)N * M;
while (lo < hi) {
    long long mid = lo + (hi - lo) / 2;
    if (countLE(mid, N, M) >= K) hi = mid;
    else lo = mid + 1;
}
cout << lo << "\n";

复杂度: O(N log(NM)) —— 每次检查 O(N),共 O(log(NM)) 次迭代。

示例三:USACO 风格电缆长度(受 Agri-Net 启发)

题目: 给定 N 个农场位置,用电缆连接所有农场,电缆长度不超过 L。找能形成生成树的最大 L(所有边 ≤ L)。

// 对最大电缆长度 L 二分
// 检查:只用 <= L 的边能否形成生成树?
// (等价于:限制到 <= L 的边后图是否连通?)
bool canConnect(vector<tuple<int,int,int>>& edges, int n, int L) {
    DSU dsu(n);
    for (auto [w, u, v] : edges) {
        if (w <= L) dsu.unite(u, v);
    }
    return dsu.components == 1;  // 所有节点都连通
}

3.3.8 lower_bound / upper_bound 完整速查表

📄 查看代码:3.3.8 lower_bound / upper_bound 完整速查表
// 注意:以下代码假设已定义:#define all(v) (v).begin(), (v).end()
vector<int> v = {1, 3, 3, 5, 7, 9, 9, 11};
//               0  1  2  3  4  5  6   7

// ── lower_bound:第一个 >= x 的位置 ──
lower_bound(all(v), 3)  → 下标 1  (第一个 3)
lower_bound(all(v), 4)  → 下标 3  (第一个 >= 4 的元素,即 5)
lower_bound(all(v), 12) → 下标 8  (越界:没有 >= 12 的元素)

// ── upper_bound:第一个 > x 的位置 ──
upper_bound(all(v), 3)  → 下标 3  (所有 3 之后的第一个元素)
upper_bound(all(v), 4)  → 下标 3  (与上同:没有 4)
upper_bound(all(v), 11) → 下标 8  (越界)

// ── 派生操作 ──
// 统计 x 的出现次数:
int cnt = upper_bound(all(v), 3) - lower_bound(all(v), 3);  // cnt = 2

// x 是否存在?
binary_search(all(v), x)  // O(log N),返回 bool

// <= x 的最大值(向下取整):
auto it = upper_bound(all(v), x);
if (it != v.begin()) cout << *prev(it);

// >= x 的最小值(向上取整):
auto it = lower_bound(all(v), x);
if (it != v.end()) cout << *it;

// < x 的最大值(严格向下取整):
auto it = lower_bound(all(v), x);
if (it != v.begin()) cout << *prev(it);

// < x 的元素个数:
lower_bound(all(v), x) - v.begin()

// <= x 的元素个数:
upper_bound(all(v), x) - v.begin()

// [a, b] 范围内的元素个数:
upper_bound(all(v), b) - lower_bound(all(v), a)
目标代码说明
第一个 >= x 的下标lower_bound(v.begin(), v.end(), x) - v.begin()若全都 < x 则等于 v.size()
第一个 > x 的下标upper_bound(v.begin(), v.end(), x) - v.begin()
x 的出现次数upper_bound(...,x) - lower_bound(...,x)
<= x 的最大值*prev(upper_bound(...,x))检查迭代器 ≠ begin
>= x 的最小值*lower_bound(...,x)检查迭代器 ≠ end
x 是否存在?binary_search(...)返回 bool

3.3.9 自定义谓词二分查找

对于非标准有序结构或自定义条件:

📄 对于非标准有序结构或自定义条件:
// 自定义谓词二分查找
// 在 [lo, hi] 范围内找第一个 pred(i) 为真的下标
// 假设:pred 单调,即 false...false, true...true

int lo = 0, hi = n - 1, answer = -1;
while (lo <= hi) {
    int mid = lo + (hi - lo) / 2;
    if (/* mid 上的某个条件 */) {
        answer = mid;
        hi = mid - 1;  // 找更小的下标
    } else {
        lo = mid + 1;
    }
}

// 示例:第一个 arr[i] - arr[0] >= D 的下标
// (对有序数组,arr[i] - arr[0] 单调非递减,
//  所以这个谓词是单调的:假...假,真...真)
// ⚠️ 关键要求:谓词必须单调,二分查找才有效!
{
    int lo = 0, hi = n - 1, firstFar = -1;
    while (lo <= hi) {
        int mid = lo + (hi - lo) / 2;
        if (arr[mid] - arr[0] >= D) {
            firstFar = mid;
            hi = mid - 1;
        } else {
            lo = mid + 1;
        }
    }
}

// 浮点数二分查找(基于精度)
double lo_f = 0.0, hi_f = 1e9;
for (int iter = 0; iter < 100; iter++) {  // 100 次迭代 → 误差 < 1e-30
    double mid = (lo_f + hi_f) / 2;
    if (check(mid)) hi_f = mid;
    else lo_f = mid;
}
// 答案:lo_f(或 hi_f,两者收敛到相同值)

🏆 USACO 专业技巧: 「二分答案」是最常见的 Silver 技术之一。当你看到「在约束条件下最大化/最小化 X」时,问自己:可行性函数是单调的吗? 如果是,就用二分查找。


3.3.10 三分查找——求单峰函数的极值 🔮 进阶 / Gold+

⚠️ 范围说明: 三分查找很少在 USACO Silver 中出现,偶尔出现在涉及几何优化或参数搜索的 Gold/Platinum 题目中。把本节当作补充知识——理解概念,但不要把它放在掌握二分查找之前。

二分查找需要单调谓词(假→真的边界)。对于单峰函数(先增后减),用三分查找找极大值。

💡 使用场景: 函数 f[lo, hi] 上单峰,即先严格递增后严格递减(或一直单方向)。三分查找以 O(log((hi-lo)/eps)) 次求值找到极大值点。

USACO 出现情况: 三分查找在 Silver 级别极少出现。在 Gold/Platinum 级别偶尔出现在涉及几何优化(如「找直线上的最优点以最小化到各点的距离之和」)或对连续单峰函数做参数搜索的题目中。

📄 C++ 完整代码
// 三分查找:求单峰函数 f 在 [lo, hi] 上的极大值
// 前提:f 先增后减(单峰)
// 时间:连续情况 O(log((hi-lo)/eps)),整数情况 O(log N)

// f 必须在调用前声明/定义
double ternarySearch(double lo, double hi) {
    for (int iter = 0; iter < 200; iter++) {
        double m1 = lo + (hi - lo) / 3;
        double m2 = hi - (hi - lo) / 3;
        if (f(m1) < f(m2)) lo = m1;  // 极大值在 [m1, hi] 中
        else hi = m2;                 // 极大值在 [lo, m2] 中
    }
    return (lo + hi) / 2;  // 极大值点(收敛后 lo ≈ hi)
}

// 整数三分查找(f 定义在整数上时):
int ternarySearchInt(int lo, int hi) {
    // 用 > 2 而非 >= 2:保留至少 3 个候选值再暴力枚举。
    // 范围缩至 2 时,m1 == m2(因为 (hi-lo)/3 == 0),会死循环。
    while (hi - lo > 2) {
        int m1 = lo + (hi - lo) / 3;
        int m2 = hi - (hi - lo) / 3;
        if (f(m1) < f(m2)) lo = m1 + 1;
        else hi = m2 - 1;
    }
    // 检查剩余候选值 [lo, hi](最多 3 个元素)
    int best = lo;
    for (int x = lo + 1; x <= hi; x++)
        if (f(x) > f(best)) best = x;
    return best;
}

与二分查找的对比:

二分查找三分查找
要求单调谓词单峰函数
寻找边界(假→真)极值(极大/极小)
每步消除一半范围三分之一范围
达到 ε 精度的迭代次数log₂(range/ε)log₃/₂(range/ε) ≈ 多 2.4 倍

⚠️ 注意: 整数三分查找需要小心——用 while (hi - lo > 2) 避免范围缩到 2 或 3 个元素时的死循环,然后暴力检查剩余候选。


⚠️ 第 3.3 章常见错误

  1. 比较器方向错误: lambda 必须在 a 应该排在 b 前面时返回 true。若 a == b 时返回 true,会导致未定义行为(严格弱序违规)。
  2. 在未排序数组上二分查找: lower_boundupper_bound 假设已排序。对未排序数据,结果毫无意义。
  3. 二分查找差一错误: lo <= hilo < hi 有区别。拿不准时,在 1 个和 2 个元素的数组上测试你的二分查找。
  4. 「二分答案」中答案范围错误: 若答案可能是 0,设 lo = 0 而非 lo = 1。若可能很大,确保 hi 足够大(必要时用 long long)。
  5. 中间值计算整数溢出: 始终写 mid = lo + (hi - lo) / 2,绝不用 (lo + hi) / 2

本章总结

📌 核心要点

操作方法时间复杂度备注
升序排序sort(v.begin(), v.end())O(N log N)使用 IntroSort
降序排序sort(..., greater<int>())O(N log N)
自定义排序lambda 比较器O(N log N)必须是严格弱序
查找确切值binary_searchO(log N)返回 bool
第一个 >= x 的下标lower_boundO(log N)返回迭代器
第一个 > x 的下标upper_boundO(log N)返回迭代器
统计值 x 的个数ub - lbO(log N)
二分答案手写 BS + check()O(f(N) log V)V = 答案范围
坐标压缩sort + unique + lower_boundO(N log N)将大值映射到小下标

🧩 二分查找模板速查

场景循环条件lo/hi 初值更新规则答案参考小节
最大化满足条件的值while (lo <= hi)lo=最小,hi=最大check(mid) → ans=mid, lo=mid+1ans§3.3.5
最小化满足条件的值while (lo < hi)lo=最小,hi=最大check(mid) → hi=midlo(循环结束时)§3.3.7
浮点数二分查找循环 100 次lo=最小,hi=最大check(mid) → hi=mid 否则 lo=midlo ≈ hi§3.3.9

❓ 常见问题

Q1:sort 的时间复杂度是 O(N log N) 还是 O(N²)

A:C++ 的 std::sort 使用 Introsort(快速排序 + 堆排序 + 插入排序的混合),保证最坏 O(N log N)。不需要担心退化到 O(N²)。但注意:若自定义比较器不满足严格弱序,行为未定义(可能死循环或崩溃)。

Q2:二分查找中 lo <= hilo < hi 有什么区别?

A:两种风格对应不同模板:

  • while (lo <= hi):循环结束时 lo > hi,答案存在 answer 变量中。适合「查找目标值」或「最大化满足条件的值」。
  • while (lo < hi):循环结束时 lo == hi,答案就是 lo。适合「最小化满足条件的值」。 两种都能解决所有问题,关键是搭配正确的更新规则。新手选一种坚持用。

Q3:「二分答案」适用于什么题目?怎么识别?

A:三个信号:① 题目问「满足……条件的最大/最小 X」;② 存在一个决策函数 check(X) 能在多项式时间内判断可行性;③ 决策函数单调(X 可行 → X-1 也可行,或反过来)。三者都满足,就能用二分答案。

Q4:坐标压缩实际上有什么用?

A:当值域很大(如 10^9)但不同的值很少(如 10^5)时,坐标压缩将大值映射到小下标 0~N-1。这让你可以用数组而不是 map(更快),或对值域做前缀和/BIT 操作。USACO Silver 中频繁需要。

Q5:为什么 sort 的比较器不能用 <=

A:C++ 排序要求比较器满足严格弱序:当 a == b 时,comp(a,b) 必须返回 false。<= 在 a==b 时返回 true,违反了这条规则。结果是未定义行为——可能死循环、崩溃或产生错误排序。

🔗 与后续章节的联系

  • 第 3.4 章(双指针):双指针技术经常在排序后使用——先排序 O(N log N),再双指针 O(N)
  • 第 3.2 章(前缀和):前缀和数组本身有序,可以对其二分查找(如找第一个前缀和 >= 目标的位置)
  • 第 4.1 章和 5.4 章(贪心 + 最短路):Dijkstra 内部使用优先队列 + 贪心策略,与排序有根本关联
  • 第 6.2 章(DP):最长递增子序列(LIS)可以用二分查找优化到 O(N log N)
  • **「二分答案」**是 USACO Silver 最核心的技术之一,在第 4.1 章(贪心)中也常常结合使用

练习题

题目 3.3.1 — 最近点对 🟢 简单 读取 N 个整数,找差值最小的一对。

提示 排序数组,最近的一对排序后一定相邻。
✅ 完整题解

核心思路: 排序后,相似的值相邻。最近的一对总是排序后的相邻元素。

#include <bits/stdc++.h>
using namespace std;
int main() {
    int n; cin >> n;
    vector<int> a(n); for (int& x : a) cin >> x;
    sort(a.begin(), a.end());
    int best = INT_MAX;
    for (int i = 1; i < n; i++)
        best = min(best, a[i] - a[i-1]);
    cout << best << "\n";
}

为什么是相邻的? 若 |a[i] - a[j]| 最小且 j > i+1,则 a[i+1] 在它们之间,所以 a[i+1]-a[i] ≤ a[j]-a[i],矛盾。

复杂度: O(N log N)。


题目 3.3.2 — 房间分配 🟡 中等 N 个事件,各有开始/结束时间,求任意时刻最多有多少个事件重叠。

提示 创建事件:开始时 +1,结束时 -1,按时间排序,扫描追踪最大计数。
✅ 完整题解

核心思路: 扫描线。每个开始 +1,每个结束 -1,运行和 = 当前重叠数。

#include <bits/stdc++.h>
using namespace std;
int main() {
    int n; cin >> n;
    vector<pair<int,int>> evs;
    for (int i = 0; i < n; i++) {
        int s, e; cin >> s >> e;
        evs.push_back({s, +1});
        evs.push_back({e, -1});
    }
    // 相同时间时先处理结束(delta=-1),「相切」的区间不计为重叠
    sort(evs.begin(), evs.end(),
         [](auto& a, auto& b){ return a.first != b.first ? a.first < b.first : a.second < b.second; });
    int cur = 0, best = 0;
    for (auto& [t, d] : evs) { cur += d; best = max(best, cur); }
    cout << best << "\n";
}

对区间 [(1,4), (2,6), (3,5)] 的追踪:

事件:(1,+1), (2,+1), (3,+1), (4,-1), (5,-1), (6,-1)
扫描:  1       2       3       2       1       0
最大:3(时刻 3 时三个区间同时活跃)

复杂度: O(N log N)。


题目 3.3.3 — 第 K 小 🟡 中等 找数组中第 K 小的元素。

提示 简单方案:排序后直接返回。进阶练习:尝试 nth_element(平均 O(N))或二分答案。
✅ 完整题解(使用 nth_element — O(N) 平均)
#include <bits/stdc++.h>
using namespace std;
int main() {
    int n, k; cin >> n >> k;
    vector<int> a(n); for (int& x : a) cin >> x;
    // nth_element 重新排列,使 a[k-1] 到达正确的有序位置
    nth_element(a.begin(), a.begin() + (k-1), a.end());
    cout << a[k-1] << "\n";
}

替代方案:二分答案

int lo = *min_element(a.begin(), a.end());
int hi = *max_element(a.begin(), a.end());
while (lo < hi) {
    int mid = lo + (hi - lo) / 2;
    int cnt = count_if(a.begin(), a.end(), [&](int x){ return x <= mid; });
    if (cnt >= k) hi = mid;
    else lo = mid + 1;
}
cout << lo << "\n";

复杂度: nth_element 平均 O(N),最坏 O(N²);二分答案 O(N log(最大值))。


题目 3.3.4 — 攻击性奶牛 🔴 困难 N 个马厩位于位置 p[1..N],放 C 头奶牛以最大化最小间距。

提示 对最小距离 D 二分答案,O(N) 贪心检查可行性。
✅ 完整题解

核心思路: 对答案 D 二分。可行性:贪心地从最左侧马厩开始放奶牛,每次跳到距离 ≥ D 的下一个。

#include <bits/stdc++.h>
using namespace std;
int N, C;
vector<int> p;

bool canPlace(int D) {
    int placed = 1, last = p[0];
    for (int i = 1; i < N; i++) {
        if (p[i] - last >= D) { placed++; last = p[i]; }
        if (placed >= C) return true;
    }
    return false;
}

int main() {
    cin >> N >> C;
    p.resize(N); for (int& x : p) cin >> x;
    sort(p.begin(), p.end());

    int lo = 1, hi = p.back() - p.front(), ans = 0;
    while (lo <= hi) {
        int mid = lo + (hi - lo) / 2;
        if (canPlace(mid)) { ans = mid; lo = mid + 1; }
        else hi = mid - 1;
    }
    cout << ans << "\n";
}

复杂度: O(N log N + N log(最大距离))。


题目 3.3.5 — 画家分区 🔴 困难 N 块画板各有宽度,K 名画家,每人画连续的画板,单位时间画一单位宽度。最小化总绘画时间。

提示 对最大时间 T 二分答案,贪心分配画板检查可行性。
✅ 完整题解

核心思路: 对答案 T 二分。可行性:贪心为每个画家填充画板,超过 T 时换下一个画家,若 ≤ K 个画家就够,T 可行。

#include <bits/stdc++.h>
using namespace std;
int N, K;
vector<long long> W;

bool canFinish(long long T) {
    int painters = 1;
    long long cur = 0;
    for (long long w : W) {
        if (w > T) return false;  // 单块画板超出预算
        if (cur + w > T) { painters++; cur = w; }
        else cur += w;
    }
    return painters <= K;
}

int main() {
    cin >> N >> K;
    W.resize(N); for (long long& x : W) cin >> x;
    long long lo = *max_element(W.begin(), W.end());  // 下界:最宽的画板
    long long hi = accumulate(W.begin(), W.end(), 0LL);  // 上界:总宽度
    while (lo < hi) {
        long long mid = lo + (hi - lo) / 2;
        if (canFinish(mid)) hi = mid;
        else lo = mid + 1;
    }
    cout << lo << "\n";
}

复杂度: O(N log(总宽度))。


⚠️ 排序与搜索常见错误

展开——频繁出现的陷阱

排序陷阱:

  • ❌ 比较器用 > 代替 <(排序需要严格弱序)
  • ❌ 比较器返回 a <= b——违反严格弱序,可能导致未定义行为
  • ❌ 比较器有副作用或随机性——必须是确定性的

二分查找陷阱:

  • mid = (lo + hi) / 2——lo+hi 很大时溢出,用 lo + (hi - lo) / 2
  • ❌ 死循环:未找到目标时 lo = mid(而不是 mid+1
  • ❌ 「第一个/最后一个位置」变体中的边界错误——先画出不变量
  • ❌ 浮点数二分:用精度终止条件 while (hi - lo > 1e-9)

二分答案陷阱:

  • ❌ 检查函数不单调——二分查找无效!验证:若 D 可行,D-1 也可行吗?
  • ❌ 边界太紧(遗漏边界情况):将 lo 设为最小可能答案,hi 设为明确可行的上界

🏆 挑战题:USACO 2016 February Silver:围牛栏 用最少的围栏围住所有 N 个点形成凸区域。这是凸包问题——查阅 Graham 扫描或 Jarvis 步进算法。虽然是 Gold 级别的主题,现在思考它能帮助你建立直觉。


📖 第 3.4 章 ⏱️ 约 50 分钟 🎯 中级

第 3.4 章:双指针与滑动窗口

📝 前置条件: 你应该熟悉数组、向量和 std::sort(第 2.3–3.3 章)。经典双指针方法需要已排序的数组。

双指针和滑动窗口是竞赛编程中最优雅的技巧之一。它们通过利用单调性将朴素 O(N²) 解法转化为 O(N):当一个指针向前移动时,另一个指针无需回头。


3.4.1 双指针技术

核心思想:在有序数组中维护两个下标 leftright,根据当前和/窗口大小将它们相向(或同向)移动。

使用场景:

  • 有序数组中找满足给定和的对/三元组
  • 检查有序数组中是否存在满足特定关系的两个元素
  • 「若能用大小 k 的窗口完成 X,则用大小 k-1 的窗口也能完成」的问题

Two Pointer Technique

上图展示两个指针如何向中间收拢,每步都从待考虑的对中消除整行/整列。

滑动窗口变体让两个指针同向移动。满足条件时,从左侧收缩以找到最小窗口:

Sliding Window Shrink

题目:找出所有和为目标值的对

朴素 O(N²) 做法:

// O(N²):检查每一对
for (int i = 0; i < n; i++) {
    for (int j = i + 1; j < n; j++) {
        if (arr[i] + arr[j] == target) {
            cout << arr[i] << " + " << arr[j] << "\n";
        }
    }
}

双指针 O(N) 做法(需要排序):

📄 C++ 完整代码
// 双指针 — 排序 O(N log N) + 搜索 O(N)
#include <bits/stdc++.h>
using namespace std;

int main() {
    ios_base::sync_with_stdio(false);
    cin.tie(NULL);

    int n, target;
    cin >> n >> target;
    vector<int> arr(n);
    for (int &x : arr) cin >> x;

    sort(arr.begin(), arr.end());  // 必须先排序

    int left = 0, right = n - 1;
    while (left < right) {
        int sum = arr[left] + arr[right];
        if (sum == target) {
            cout << arr[left] << " + " << arr[right] << " = " << target << "\n";
            left++;
            right--;  // 同时移动两个指针
        } else if (sum < target) {
            left++;   // 和太小:左指针右移(增大和)
        } else {
            right--;  // 和太大:右指针左移(减小和)
        }
    }

    return 0;
}

为什么这样有效?

核心思路: 排序后,若 arr[left] + arr[right] < target,则没有比 arr[right] 更小的元素能与 arr[left] 配对达到 target。所以安全地右移 left

类似地,若和太大,没有比 arr[left] 更大的元素能与 arr[right] 配对达到 target。所以安全地左移 right

每步至少消除一个元素 → 总计 O(N) 步。

完整追踪

数组 = [1, 2, 3, 4, 5, 6, 7, 8],target = 9:

📄 数组 = [1, 2, 3, 4, 5, 6, 7, 8],target = 9:
状态:left=0(1), right=7(8)
  和 = 1+8 = 9 ✓ → 打印 (1,8),left++,right--

状态:left=1(2), right=6(7)
  和 = 2+7 = 9 ✓ → 打印 (2,7),left++,right--

状态:left=2(3), right=5(6)
  和 = 3+6 = 9 ✓ → 打印 (3,6),left++,right--

状态:left=3(4), right=4(5)
  和 = 4+5 = 9 ✓ → 打印 (4,5),left++,right--

状态:left=4, right=3 → left >= right,停止

所有对:(1,8),(2,7),(3,6),(4,5)

三数之和扩展

找和为目标值的三元组:固定一个元素,对剩余两个用双指针。

📄 找和为目标值的三元组:固定一个元素,对剩余两个用双指针。
// O(N²) — 远优于 O(N³) 暴力
sort(arr.begin(), arr.end());
for (int i = 0; i < n - 2; i++) {
    int left = i + 1, right = n - 1;
    while (left < right) {
        int sum = arr[i] + arr[left] + arr[right];
        if (sum == target) {
            cout << arr[i] << " " << arr[left] << " " << arr[right] << "\n";
            left++; right--;
        } else if (sum < target) left++;
        else right--;
    }
}

3.4.2 滑动窗口——固定大小

固定大小 K 的滑动窗口在数组上滑动,维护一个运行聚合值(和、最大值、不同值计数等)。

题目: 找任意大小为 K 的连续子数组的最大和。

数组:[2, 1, 5, 1, 3, 2],K=3
窗口:[2,1,5]=8,[1,5,1]=7,[5,1,3]=9,[1,3,2]=6
答案:9

朴素 O(NK) 对每个窗口从头重新计算和。

滑动窗口 O(N) 加上进入窗口的新元素,减去离开窗口的旧元素。

📄 C++ 完整代码
// 固定大小滑动窗口 — O(N)
#include <bits/stdc++.h>
using namespace std;

int main() {
    ios_base::sync_with_stdio(false);
    cin.tie(NULL);

    int n, k;
    cin >> n >> k;
    vector<int> arr(n);
    for (int &x : arr) cin >> x;

    // 计算第一个窗口的和
    long long windowSum = 0;
    for (int i = 0; i < k; i++) windowSum += arr[i];

    long long maxSum = windowSum;

    // 滑动窗口:加 arr[i],减 arr[i-k]
    for (int i = k; i < n; i++) {
        windowSum += arr[i];        // 新元素进入窗口
        windowSum -= arr[i - k];   // 旧元素离开窗口
        maxSum = max(maxSum, windowSum);
    }

    cout << maxSum << "\n";
    return 0;
}

对 [2, 1, 5, 1, 3, 2], K=3 的追踪:

初始窗口 [2,1,5]:sum=8,max=8
i=3:加 1,减 2 → sum=7,max=8
i=4:加 3,减 1 → sum=9,max=9
i=5:加 2,减 5 → sum=6,max=9
答案:9 ✓

3.4.3 滑动窗口——可变大小

最强大的变体:窗口在需要时扩大,违反约束时收缩

题目: 找和 ≥ target 的最短连续子数组。

📄 C++ 完整代码
// 可变大小窗口 — O(N)
#include <bits/stdc++.h>
using namespace std;

int main() {
    ios_base::sync_with_stdio(false);
    cin.tie(NULL);

    int n, target;
    cin >> n >> target;
    vector<int> arr(n);
    for (int &x : arr) cin >> x;

    int left = 0;
    long long windowSum = 0;
    int minLen = INT_MAX;

    for (int right = 0; right < n; right++) {
        windowSum += arr[right];   // 扩张:加入右端元素

        // 满足约束时从左收缩
        while (windowSum >= target) {
            minLen = min(minLen, right - left + 1);
            windowSum -= arr[left];
            left++;                // 收缩:移除左端元素
        }
    }

    if (minLen == INT_MAX) cout << 0 << "\n";  // 不存在这样的子数组
    else cout << minLen << "\n";

    return 0;
}

为什么是 O(N) 每个元素被加入一次(right 经过时),最多被移除一次(left 经过时)。总操作:O(2N) = O(N)

题目:最多 K 个不同值的最长子数组

📄 查看代码:题目:最多 K 个不同值的最长子数组
// 可变窗口:最多 K 个不同值的最长子数组
int left = 0, maxLen = 0;
map<int, int> freq;  // 窗口内每个值的频率

for (int right = 0; right < n; right++) {
    freq[arr[right]]++;

    // 有 > k 个不同值时收缩
    while ((int)freq.size() > k) {
        freq[arr[left]]--;
        if (freq[arr[left]] == 0) freq.erase(arr[left]);
        left++;
    }

    maxLen = max(maxLen, right - left + 1);
}
cout << maxLen << "\n";

3.4.4 USACO 示例:最长满足条件的子数组

题目: 给定整数数组,找所有元素 ≥ K 的最长连续子数组。

// 双指针:所有元素 >= K 的最长连续子数组
int left = 0, maxLen = 0;
for (int right = 0; right < n; right++) {
    if (arr[right] < K) {
        left = right + 1;  // 重置窗口:当前元素违反约束
    } else {
        maxLen = max(maxLen, right - left + 1);
    }
}

⚠️ 常见错误

  1. 双指针前忘排序: 配对求和的双指针技术只在有序数组上有效。不排序会遗漏一些对或得到错误答案。
  2. 找到对时只移动一个指针: 找到匹配的对时,必须同时移动 left++right--。只移动一个会遗漏一些对(除非不需要考虑重复)。
  3. 窗口大小差一: 窗口 [left, right] 的大小是 right - left + 1,不是 right - left
  4. 忘记处理空答案: 对「最小子数组」问题,将 minLen 初始化为 INT_MAX,输出前检查它是否改变了。

本章总结

📌 核心要点

技术前提条件时间空间核心思想
双指针(配对)有序数组O(N)O(1)从两端逼近,消除不可能的对
双指针(三数之和)有序数组O(N²)O(1)固定一个,对其余用双指针
滑动窗口(固定)任意O(N)O(1)加新元素,减旧元素
滑动窗口(可变)任意O(N)O(1~N)右端扩张,左端收缩

❓ 常见问题

Q1:双指针一定需要排序吗?

A:不一定。「反向双指针」(如配对求和)需要排序;「同向双指针」(如滑动窗口)不需要。关键是单调性——指针只朝一个方向移动。

Q2:滑动窗口和前缀和都能计算区间和——该用哪个?

A:固定大小窗口的和/最大值,滑动窗口更直观。任意区间查询,前缀和更通用。滑动窗口只能处理「连续移动的窗口」;前缀和可以回答任意 [L,R] 查询。

Q3:滑动窗口能同时处理「满足条件的最长子数组」和「满足条件的最短子数组」吗?

A:两者都可以,但逻辑略有不同。「最长」:右端扩张直到条件不满足,再从左收缩直到条件重新满足。「最短」:右端扩张直到条件满足,再从左收缩直到条件不再满足,全程记录最小长度。

Q4:双指针如何处理重复元素?

A:取决于题目。若需要「所有不同对的值」,找到对后做 left++; right-- 并跳过重复值。若需要「所有对的数量」,需要仔细统计重复项(可能需要额外的计数逻辑)。

🔗 与后续章节的联系

  • 第 3.2 章(前缀和):前缀和与滑动窗口互补——前缀和适合离线查询,滑动窗口适合在线处理
  • 第 3.3 章(排序):排序是双指针的前提——反向双指针需要有序数组
  • 第 3.5 章(单调性):单调双端队列可以增强滑动窗口——在 O(N) 时间内维护窗口最小/最大值
  • 第 6.1–6.3 章(DP):一些问题(如 LIS 变体)可以用双指针优化

练习题

题目 3.4.1 — 配对计数 🟢 简单 给定 N 个整数和目标值 T,统计满足 arr[i] + arr[j] = T 的对 (i < j) 的数量。

提示 先排序数组,从两端用双指针。
✅ 完整题解
#include <bits/stdc++.h>
using namespace std;
int main() {
    int n, T; cin >> n >> T;
    vector<int> a(n); for (int& x : a) cin >> x;
    sort(a.begin(), a.end());
    long long cnt = 0;
    int L = 0, R = n - 1;
    while (L < R) {
        int s = a[L] + a[R];
        if (s == T) {
            if (a[L] == a[R]) {
                // [L..R] 内所有对都有效
                long long len = R - L + 1;
                cnt += len * (len - 1) / 2;
                break;
            }
            // 统计两侧的重复值
            long long cl = 1, cr = 1;
            while (L+1 < R && a[L+1] == a[L]) { cl++; L++; }
            while (R-1 > L && a[R-1] == a[R]) { cr++; R--; }
            cnt += cl * cr;
            L++; R--;
        } else if (s < T) L++;
        else R--;
    }
    cout << cnt << "\n";
}

复杂度: O(N log N)。


题目 3.4.2 — 最大平均子数组 🟡 中等 找恰好长度为 K 的连续子数组,使其平均值最大。

提示 固定大小滑动窗口:维护运行和,每步加 A[i] 减 A[i-K]。
✅ 完整题解
#include <bits/stdc++.h>
using namespace std;
int main() {
    int n, k; cin >> n >> k;
    vector<double> a(n); for (double& x : a) cin >> x;
    double windowSum = 0;
    for (int i = 0; i < k; i++) windowSum += a[i];
    double maxSum = windowSum;
    for (int i = k; i < n; i++) {
        windowSum += a[i] - a[i-k];
        maxSum = max(maxSum, windowSum);
    }
    cout << fixed << setprecision(5) << maxSum / k << "\n";
}

复杂度: O(N)。


题目 3.4.3 — 最小覆盖子串 🔴 困难 给定字符串 S 和字符串 T,找 S 中包含 T 所有字符的最短子串。

提示 可变滑动窗口,用频率映射记录所需字符,满足所有 T 中字符时从左收缩。
✅ 完整题解
#include <bits/stdc++.h>
using namespace std;
int main() {
    string S, T; cin >> S >> T;
    unordered_map<char,int> need, have;
    for (char c : T) need[c]++;
    int formed = 0, required = need.size();
    int L = 0, minLen = INT_MAX, minL = 0;
    for (int R = 0; R < (int)S.size(); R++) {
        have[S[R]]++;
        if (need.count(S[R]) && have[S[R]] == need[S[R]]) formed++;
        while (formed == required) {
            if (R - L + 1 < minLen) { minLen = R-L+1; minL = L; }
            have[S[L]]--;
            if (need.count(S[L]) && have[S[L]] < need[S[L]]) formed--;
            L++;
        }
    }
    if (minLen == INT_MAX) cout << "no solution\n";
    else cout << S.substr(minL, minLen) << "\n";
}

样例: S="ADOBECODEBANC",T="ABC" → "BANC" 复杂度: O(|S| + |T|)。


🏆 挑战题:USACO 2017 February Bronze——为何奶牛过马路 给定网格中的奶牛和它们的目的地,找哪头奶牛最快到达目的地。对排序好的区间用双指针/贪心方法。

📖 第 3.5 章 ⏱️ 约 50 分钟 🎯 中级

第 3.5 章:单调栈与单调队列

📝 前置条件: 确保你熟悉双指针/滑动窗口(第 3.4 章)和基本的栈/队列操作(第 3.1 章)。本章直接建立在这些技术之上。

单调栈和单调队列是优雅的工具,能在 O(N) 时间内解决「最近更大/更小元素」和「滑动窗口极值」问题——而朴素做法需要 O(N²)。


3.5.1 单调栈:下一个更大元素

题目: 给定 N 个整数的数组 A,对每个元素 A[i],找下一个更大元素(NGE):i 右侧第一个大于 A[i] 的元素的下标。若不存在,输出 -1。

朴素做法: O(N²) —— 对每个 i,向右扫描直到找到更大的元素。

单调栈做法: O(N) —— 维护一个从底到顶始终递减的栈。压入新元素时,先弹出所有更小的元素(它们刚找到了自己的 NGE!)。

💡 核心思路: 栈中存放还未找到 NGE 的元素的下标。当 A[i] 到来时,栈中所有小于 A[i] 的元素都找到了 NGE(就是 i!)。弹出它们并记录答案。

单调栈状态变化——对 A = [2, 1, 5, 6, 2, 3] 逐步追踪:

Monotonic Stack NGE

📄 ![Monotonic Stack NGE](../images/monotonic_stack_nge.svg)
数组 A:[2, 1, 5, 6, 2, 3]
下标:    0  1  2  3  4  5

处理 i=0 (A[0]=2):栈为空 → 压入 0
栈:[0]          // 栈存放未解决元素的下标

处理 i=1 (A[1]=1):A[1]=1 < A[0]=2 → 直接压入
栈:[0, 1]

处理 i=2 (A[2]=5):
  A[2]=5 > A[1]=1 → 弹出 1,NGE[1] = 2  (A[2]=5 是 A[1] 的下一个更大元素)
  A[2]=5 > A[0]=2 → 弹出 0,NGE[0] = 2  (A[2]=5 是 A[0] 的下一个更大元素)
  栈为空 → 压入 2
栈:[2]

处理 i=3 (A[3]=6):
  A[3]=6 > A[2]=5 → 弹出 2,NGE[2] = 3
  压入 3
栈:[3]

处理 i=4 (A[4]=2):A[4]=2 < A[3]=6 → 直接压入
栈:[3, 4]

处理 i=5 (A[5]=3):
  A[5]=3 > A[4]=2 → 弹出 4,NGE[4] = 5
  A[5]=3 < A[3]=6 → 停止,压入 5
栈:[3, 5]

结束:栈中剩余 [3, 5] → NGE[3] = NGE[5] = -1(右侧无更大元素)

结果:NGE = [2, 2, 3, -1, 5, -1]
验证:
  A[0]=2,下一个更大是 A[2]=5 ✓
  A[1]=1,下一个更大是 A[2]=5 ✓
  A[2]=5,下一个更大是 A[3]=6 ✓
  A[3]=6,无更大 → -1 ✓

完整实现

📄 查看代码:完整实现
// 用单调栈求下一个更大元素 — O(N)
#include <bits/stdc++.h>
using namespace std;

int main() {
    ios_base::sync_with_stdio(false);
    cin.tie(NULL);

    int n;
    cin >> n;
    vector<int> A(n);
    for (int& x : A) cin >> x;

    vector<int> nge(n, -1);   // nge[i] = 下一个更大元素的下标,不存在则为 -1
    stack<int> st;             // 单调递减栈(存下标)

    for (int i = 0; i < n; i++) {
        // 当栈顶元素小于 A[i] 时
        // → 当前元素 A[i] 是那些元素的 NGE
        while (!st.empty() && A[st.top()] < A[i]) {
            nge[st.top()] = i;  // ← 关键:记录栈顶的 NGE
            st.pop();
        }
        st.push(i);  // 压入当前下标(尚未解决)
    }
    // 栈中剩余元素没有 NGE → 已初始化为 -1

    for (int i = 0; i < n; i++) {
        cout << nge[i];
        if (i < n - 1) cout << " ";
    }
    cout << "\n";

    return 0;
}

复杂度分析:

  • 每个元素恰好被压入一次最多被弹出一次
  • 总操作:O(2N) = O(N)
  • 空间:O(N)(栈)

⚠️ 常见错误: 在栈中存值而非下标。始终存下标——你需要知道在数组的哪个位置记录答案。


3.5.2 变体:上一个更小、上一个更大

通过改变比较方向和遍历方向,可以得到四个相关问题:

问题栈类型方向使用场景
下一个更大元素(NGE)递减栈从左到右股票价格问题
下一个更小元素(NSE)递增栈从左到右直方图问题
上一个更大元素(PGE)递减栈从右到左区间问题
上一个更小元素(PSE)递增栈从右到左左侧最近更小元素

上一个更小元素的模板:

📄 C++ 完整代码
// 上一个更小元素:对每个 i,找最近的 j < i 使得 A[j] < A[i]
vector<int> pse(n, -1);  // pse[i] = 上一个更小元素的下标,不存在则为 -1
stack<int> st;

for (int i = 0; i < n; i++) {
    while (!st.empty() && A[st.top()] >= A[i]) {
        st.pop();  // 弹出 >= A[i] 的元素(它们不是「上一个更小」)
    }
    pse[i] = st.empty() ? -1 : st.top();  // 栈顶就是上一个更小
    st.push(i);
}

3.5.3 USACO 应用:直方图中的最大矩形

题目: 给定高度数组 H[0..N-1],找能放入直方图内的最大矩形面积。

核心思路: 对每根柱子 i,以 H[i] 为高度的最大矩形向左右延伸,直到遇到更矮的柱子。用单调栈求每个 i 的:

  • left[i] = 上一个更小元素的下标
  • right[i] = 下一个更小元素的下标

每根柱子的左右边界——H = [2, 1, 5, 6, 2, 3]:

Histogram Boundary Computation

💡 公式: 宽度 = right[i] - left[i] - 1面积 = H[i] × 宽度。左边界 = 上一个更小元素的下标;右边界 = 下一个更小元素的下标。

📄 C++ 完整代码
// 直方图中最大矩形 — O(N)
#include <bits/stdc++.h>
using namespace std;

int main() {
    ios_base::sync_with_stdio(false);
    cin.tie(NULL);

    int n;
    cin >> n;
    vector<int> H(n);
    for (int& h : H) cin >> h;

    vector<int> left(n), right(n);
    stack<int> st;

    // 上一个更小(左边界)
    for (int i = 0; i < n; i++) {
        while (!st.empty() && H[st.top()] >= H[i]) st.pop();
        left[i] = st.empty() ? -1 : st.top();  // 矩形起点前的下标
        st.push(i);
    }

    while (!st.empty()) st.pop();

    // 下一个更小(右边界)
    for (int i = n - 1; i >= 0; i--) {
        while (!st.empty() && H[st.top()] >= H[i]) st.pop();
        right[i] = st.empty() ? n : st.top();  // 矩形终点后的下标
        st.push(i);
    }

    // 计算最大面积
    long long maxArea = 0;
    for (int i = 0; i < n; i++) {
        long long width = right[i] - left[i] - 1;  // 矩形宽度
        long long area = (long long)H[i] * width;
        maxArea = max(maxArea, area);
    }

    cout << maxArea << "\n";
    return 0;
}

对 H = [2, 1, 5, 6, 2, 3] 的追踪:

left  = [-1, -1, 1, 2, 1, 4]   (上一个更小的下标,-1 = 无)
right = [1, 6, 4, 4, 6, 6]     (下一个更小的下标,n=6 = 无)

宽度:  1-(-1)-1=1, 6-(-1)-1=6, 4-1-1=2, 4-2-1=1, 6-1-1=4, 6-4-1=1
面积:  2×1=2, 1×6=6, 5×2=10, 6×1=6, 2×4=8, 3×1=3

最大面积 = 10
  i=2:H[2]=5,left[2]=1,right[2]=4,宽度=4-1-1=2,面积=5×2=10 ✓
  (下标 2 和 3 的柱子高度都 ≥ 5,所以高度为 5 的矩形跨度为 2)

📌 学习建议: 提交前始终用样例输入手动追踪算法。下标边界的细微差一错误是单调栈问题最常见的 bug。


3.5.4 单调双端队列:滑动窗口最大值

题目: 给定 N 个整数的数组 A 和窗口大小 K,找每个大小为 K 的窗口从左向右滑动时的最大值,输出 N-K+1 个值。

朴素做法: O(NK) —— 对每个窗口扫描求最大值。

单调双端队列做法: O(N) —— 维护一个递减双端队列(队首 = 当前窗口最大值)。

💡 核心思路: 我们想要滑动窗口的最大值。维护一个下标的双端队列,使得:

  1. 双端队列中的值递减(队首始终是最大值)
  2. 双端队列只包含当前窗口内的下标

当新元素到来时:

  • 队尾移除所有更小的元素(只要这个新元素在窗口中,它们就不可能是最大值)
  • 队首已超出当前窗口则移除

逐步追踪

📄 查看代码:逐步追踪
数组 A:[1, 3, -1, -3, 5, 3, 6, 7],K = 3

窗口 [1,3,-1]:最大 = 3
窗口 [3,-1,-3]:最大 = 3
窗口 [-1,-3,5]:最大 = 5
窗口 [-3,5,3]:最大 = 5
窗口 [5,3,6]:最大 = 6
窗口 [3,6,7]:最大 = 7

i=0, A[0]=1:双端队列=[0]
i=1, A[1]=3:3>1 → 弹出 0;双端队列=[1]
i=2, A[2]=-1:-1<3 → 压入;双端队列=[1,2];窗口 [0..2]:最大=A[1]=3 ✓
i=3, A[3]=-3:-3<-1 → 压入;双端队列=[1,2,3];窗口 [1..3]:队首=1 仍在窗口,最大=A[1]=3 ✓
i=4, A[4]=5:5>-3→弹出 3;5>-1→弹出 2;5>3→弹出 1;双端队列=[4];窗口 [2..4]:最大=A[4]=5 ✓
i=5, A[5]=3:3<5→压入;双端队列=[4,5];窗口 [3..5]:队首=4 在窗口,最大=A[4]=5 ✓
i=6, A[6]=6:6>3→弹出 5;6>5→弹出 4;双端队列=[6];窗口 [4..6]:最大=A[6]=6 ✓
i=7, A[7]=7:7>6→弹出 6;双端队列=[7];窗口 [5..7]:最大=A[7]=7 ✓

完整实现

📄 查看代码:完整实现
// 用单调双端队列求滑动窗口最大值 — O(N)
#include <bits/stdc++.h>
using namespace std;

int main() {
    ios_base::sync_with_stdio(false);
    cin.tie(NULL);

    int n, k;
    cin >> n >> k;
    vector<int> A(n);
    for (int& x : A) cin >> x;

    deque<int> dq;   // 单调递减双端队列,存下标
    vector<int> result;

    for (int i = 0; i < n; i++) {
        // 1. 移除超出当前窗口的元素
        while (!dq.empty() && dq.front() <= i - k) {
            dq.pop_front();   // ← 关键:弹出已过期的队首
        }

        // 2. 维护递减性质
        //    从队尾移除所有小于 A[i] 的元素
        //    (只要 A[i] 还在窗口中,它们就不可能是最大值)
        while (!dq.empty() && A[dq.back()] <= A[i]) {
            dq.pop_back();    // ← 关键:从队尾弹出更小的元素
        }

        dq.push_back(i);   // 加入当前元素

        // 3. 第一个完整窗口形成后开始记录最大值
        if (i >= k - 1) {
            result.push_back(A[dq.front()]);  // 队首 = 当前窗口最大值
        }
    }

    for (int i = 0; i < (int)result.size(); i++) {
        cout << result[i];
        if (i + 1 < (int)result.size()) cout << "\n";
    }
    cout << "\n";

    return 0;
}

复杂度:

  • 每个元素最多被压入/弹出双端队列一次 → 总计 O(N)
  • 空间:O(K)(双端队列)

⚠️ 常见错误 1: 忘记检查 dq.front() <= i - k 来判断窗口过期。双端队列只能包含 [i-k+1, i] 范围内的下标。

⚠️ 常见错误 2: 从队尾弹出时用 < 而非 <=。用 < 会保留相等元素,但重复值可能导致问题。用 <= 维护严格递减的双端队列。


3.5.5 USACO 题型:股票跨度(单调栈)

🔗 灵感: 这类题型在 USACO Bronze/Silver 中出现(「Haybale Stacking」风格)。

📄 C++ 完整代码
// 股票跨度问题:对每天 i,找在 i 之前有多少连续天价格 <= prices[i]
// (第 i 天的「跨度」)
vector<int> stockSpan(vector<int>& prices) {
    int n = prices.size();
    vector<int> span(n, 1);
    stack<int> st;  // 单调递减栈(存下标)

    for (int i = 0; i < n; i++) {
        while (!st.empty() && prices[st.top()] <= prices[i]) {
            st.pop();
        }
        span[i] = st.empty() ? (i + 1) : (i - st.top());
        st.push(i);
    }
    return span;
}
// span[i] = 到 i 为止(含)价格 <= prices[i] 的连续天数

⚠️ 第 3.5 章常见错误

  1. 存值而非下标 —— 始终存下标。你需要用它们检查窗口范围和记录答案。

  2. 双端队列中比较用 < 而非 <= —— 滑动窗口求最大值时,A[dq.back()] <= A[i] 时弹出(严格非增)。求最小值时,A[dq.back()] >= A[i] 时弹出。

  3. 忘记窗口过期检查 —— 滑动窗口双端队列中,记录最大值前始终检查 dq.front() < i - k + 1(或 <= i - k)。

  4. 栈的底顶方向搞混 —— 「单调」性质指:从底到顶,栈是递增的(用于 NGE)或递减的(用于 NSE)。搞混时画图辅助。

  5. NGE 和 PSE 的处理顺序:

    • 下一个更大元素:从左到右遍历
    • 上一个更大元素:从右到左遍历(或:从左到右遍历,在压入前记录 stack.top())

本章总结

📌 核心要点

问题数据结构时间复杂度关键操作
下一个更大元素(NGE)单调递减栈O(N)找到更大元素时弹出
上一个更小元素(PSE)单调递增栈O(N)压入前栈顶就是答案
直方图中最大矩形单调栈(两遍)O(N)左边界 + 右边界 + 宽度
滑动窗口最大值单调递减双端队列O(N)维护窗口范围 + 维护递减性质

🧩 模板速查

📄 查看代码:🧩 模板速查
// 单调递减栈(用于 NGE / 下一个更大元素)
stack<int> st;
for (int i = 0; i < n; i++) {
    while (!st.empty() && A[st.top()] < A[i]) {
        answer[st.top()] = i;  // i 是 st.top() 的 NGE
        st.pop();
    }
    st.push(i);
}

// 单调递减双端队列(滑动窗口最大值)
deque<int> dq;
for (int i = 0; i < n; i++) {
    while (!dq.empty() && dq.front() <= i - k) dq.pop_front();  // 移除过期元素
    while (!dq.empty() && A[dq.back()] <= A[i]) dq.pop_back();  // 维护单调性
    dq.push_back(i);
    if (i >= k - 1) ans.push_back(A[dq.front()]);
}

❓ 常见问题

Q1:单调栈应该存值还是下标?

A:始终存下标。即使只需要值,存下标更灵活——通过 A[idx] 可以取值,反之不行。特别是计算宽度时(如直方图问题),必须用下标。

Q2:如何判断用单调栈还是双指针?

A:观察问题结构——若需要「对每个元素找其左/右侧第一个更大/更小的元素」,用单调栈。若需要「维护滑动窗口的最大值」,用单调双端队列。若「两指针从两端相向移动」,用双指针。

Q3:为什么单调栈的时间复杂度是 O(N) 而不是 O(N²)?

A:均摊分析。每个元素最多被压入一次,最多被弹出一次,总共 2N 次操作,所以是 O(N)。虽然单次 while 循环可能弹出多个元素,但所有 while 循环的弹出总次数绝不超过 N。


练习题

题目 3.5.1 — 下一个更大元素 🟢 简单 对数组中每个元素,找其右侧第一个更大的元素。若不存在打印 -1。

提示 维护单调递减下标栈。处理 A[i] 时,弹出栈中所有更小的元素(它们找到了 NGE)。
✅ 完整题解
#include <bits/stdc++.h>
using namespace std;
int main() {
    int n; cin >> n;
    vector<int> a(n); for (int& x : a) cin >> x;
    vector<int> nge(n, -1);
    stack<int> st;
    for (int i = 0; i < n; i++) {
        while (!st.empty() && a[st.top()] < a[i]) {
            nge[st.top()] = a[i];
            st.pop();
        }
        st.push(i);
    }
    for (int x : nge) cout << x << " "; cout << "\n";
}

样例: [2,1,5,6,2,3][5,5,6,-1,3,-1] 复杂度: O(N) —— 每个元素最多被压入/弹出一次。


题目 3.5.2 — 每日温度 🟢 简单 对每一天,找还需等多少天才能迎来更高温度。(LeetCode 739 风格)

提示 这正是 NGE 问题。Answer[i] = NGE下标[i] - i。用单调递减栈。
✅ 完整题解
#include <bits/stdc++.h>
using namespace std;
int main() {
    int n; cin >> n;
    vector<int> T(n); for (int& x : T) cin >> x;
    vector<int> ans(n, 0);
    stack<int> st;
    for (int i = 0; i < n; i++) {
        while (!st.empty() && T[st.top()] < T[i]) {
            ans[st.top()] = i - st.top();  // 等待天数
            st.pop();
        }
        st.push(i);
    }
    for (int x : ans) cout << x << " "; cout << "\n";
}

样例: [73,74,75,71,69,72,76,73][1,1,4,2,1,1,0,0]


题目 3.5.3 — 滑动窗口最大值 🟡 中等 找每个大小为 K 的滑动窗口的最大值。

提示 用单调递减双端队列,维护下标范围在 [i-k+1, i] 内,队首 = 最大值。
✅ 完整题解
#include <bits/stdc++.h>
using namespace std;
int main() {
    int n, k; cin >> n >> k;
    vector<int> a(n); for (int& x : a) cin >> x;
    deque<int> dq;
    for (int i = 0; i < n; i++) {
        while (!dq.empty() && dq.front() < i - k + 1) dq.pop_front();
        while (!dq.empty() && a[dq.back()] <= a[i]) dq.pop_back();
        dq.push_back(i);
        if (i >= k - 1) cout << a[dq.front()] << " \n"[i==n-1];
    }
}

样例: n=8,k=3,[1,3,-1,-3,5,3,6,7][3,3,5,5,6,7] 复杂度: O(N) 总计——每个元素进/出双端队列一次。


题目 3.5.4 — 直方图中最大矩形 🟡 中等 找直方图中最大矩形的面积。

提示 对每根柱子找上一个更小(左边界)和下一个更小(右边界)。宽度 = 右 - 左 - 1,面积 = 高度 × 宽度。
✅ 完整题解
#include <bits/stdc++.h>
using namespace std;
int main() {
    int n; cin >> n;
    vector<int> H(n); for (int& x : H) cin >> x;
    vector<int> left(n), right(n);
    stack<int> st;

    // 上一个更小(左边界)
    for (int i = 0; i < n; i++) {
        while (!st.empty() && H[st.top()] >= H[i]) st.pop();
        left[i] = st.empty() ? -1 : st.top();
        st.push(i);
    }
    while (!st.empty()) st.pop();

    // 下一个更小(右边界)
    for (int i = n-1; i >= 0; i--) {
        while (!st.empty() && H[st.top()] >= H[i]) st.pop();
        right[i] = st.empty() ? n : st.top();
        st.push(i);
    }

    long long ans = 0;
    for (int i = 0; i < n; i++)
        ans = max(ans, (long long)H[i] * (right[i] - left[i] - 1));
    cout << ans << "\n";
}

样例: [2,1,5,6,2,3]10(下标 2 的柱子,高度 5,宽度 2) 复杂度: O(N) —— 两次单调栈遍历。


题目 3.5.5 — 接雨水 🔴 困难 给定高度图,计算下雨后能接住多少水。

提示 对每个位置 i,接水量 = min(left_max[i], right_max[i]) - height[i]。
✅ 完整题解(双指针——O(N) 时间,O(1) 空间)
#include <bits/stdc++.h>
using namespace std;
int main() {
    int n; cin >> n;
    vector<int> h(n); for (int& x : h) cin >> x;
    int left = 0, right = n-1, maxL = 0, maxR = 0;
    long long ans = 0;
    while (left < right) {
        if (h[left] <= h[right]) {
            maxL = max(maxL, h[left]);
            ans += maxL - h[left];
            left++;
        } else {
            maxR = max(maxR, h[right]);
            ans += maxR - h[right];
            right--;
        }
    }
    cout << ans << "\n";
}

样例: [0,1,0,2,1,0,1,3,2,1,2,1]6 复杂度: O(N) 时间,O(1) 空间。


🏆 挑战题:USACO 2016 February Silver——围牛栏 给定一个多边形,判断一个点是否在多边形内部。使用射线投射法——实现时需要仔细处理边界情况。

📖 第 3.6 章 ⏱️ 约 50 分钟 🎯 中级

第 3.6 章:栈、队列与双端队列

📝 前置条件: 了解基本的 C++ 数组和循环(第 2.1–2.2 章)。无进阶前提——这些是竞赛编程中随处可见的基础构件。

这三种数据结构控制着元素被处理的顺序。各自独特的「个性」使它们在特定类型的问题中表现出色。

  • 栈(Stack): 后进先出(像一叠盘子)
  • 队列(Queue): 先进先出(像商店排队)
  • 双端队列(Deque): 两端均可插入/删除

3.6.1 栈深度解析

我们在第 3.1 章介绍过 stack,现在用它解决实际问题。

图示:栈的操作

Stack Operations

上图通过逐步压入和弹出操作展示了 LIFO(后进先出)性质。注意 pop() 总是移除最近压入的元素——这正是栈在括号匹配、DFS 和撤销操作中无可替代的原因。

以下是三种容器的对比——访问模式决定了各自适合不同的问题:

Stack vs Queue vs Deque

括号匹配问题

题目: 给定括号字符串 ()[]{}, 判断是否正确嵌套。

📄 C++ 完整代码
#include <bits/stdc++.h>
using namespace std;

bool isBalanced(const string &s) {
    stack<char> st;

    for (char ch : s) {
        if (ch == '(' || ch == '[' || ch == '{') {
            st.push(ch);   // 开括号:压栈
        } else {
            // 闭括号:必须与最近的开括号匹配
            if (st.empty()) return false;   // 没有对应的开括号

            char top = st.top();
            st.pop();

            // 检查是否匹配
            if (ch == ')' && top != '(') return false;
            if (ch == ']' && top != '[') return false;
            if (ch == '}' && top != '{') return false;
        }
    }

    return st.empty();  // 栈为空说明所有括号都匹配了
}

int main() {
    cout << isBalanced("()[]{}") << "\n";    // 1(真)
    cout << isBalanced("([]){}") << "\n";    // 1(真)
    cout << isBalanced("([)]")   << "\n";    // 0(假)
    cout << isBalanced("(()")    << "\n";    // 0(假——未匹配的 '(')
    return 0;
}

下一个更大元素

题目: 对数组中每个元素,找其右侧第一个严格更大的元素,不存在则输出 -1。

这是经典的单调栈问题(第 3.5 章有详细讲解)。

📄 这是经典的**单调栈**问题(第 3.5 章有详细讲解)。
#include <bits/stdc++.h>
using namespace std;

int main() {
    ios_base::sync_with_stdio(false);
    cin.tie(NULL);

    int n;
    cin >> n;
    vector<int> A(n);
    for (int &x : A) cin >> x;

    vector<int> answer(n, -1);  // 默认 -1(没有更大元素)
    stack<int> st;              // 存储等待答案的元素的下标

    for (int i = 0; i < n; i++) {
        // 当栈非空且当前元素 > 栈顶下标处的元素时
        while (!st.empty() && A[i] > A[st.top()]) {
            answer[st.top()] = A[i];  // A[i] 是 st.top() 的下一个更大元素
            st.pop();
        }
        st.push(i);  // 压入当前下标(等待后续更大的元素)
    }

    for (int x : answer) cout << x << " ";
    cout << "\n";

    return 0;
}

对 [3, 1, 4, 1, 5, 9, 2, 6] 的追踪:

  • i=0:压入 0。栈:[0]
  • i=1:A[1]=1 ≤ A[0]=3,压入 1。栈:[0,1]
  • i=2:A[2]=4 > A[1]=1 → answer[1]=4,弹出。A[2]=4 > A[0]=3 → answer[0]=4,弹出。压入 2。
  • i=3:压入 3。栈:[2,3]
  • i=4:A[4]=5 > A[3]=1 → answer[3]=5。A[4]=5 > A[2]=4 → answer[2]=5。压入 4。
  • i=5:A[5]=9 > A[4]=5 → answer[4]=9。压入 5。栈:[5]
  • i=6:压入 6。栈:[5,6]
  • i=7:A[7]=6 > A[6]=2 → answer[6]=6。压入 7。
  • 栈中剩余(5, 7):answer 保持 -1。

输出:4 4 5 5 9 -1 6 -1

核心思路: 单调栈维护严格递增或递减顺序的元素。当新元素破坏该顺序时,它「解决了」所有它更大的元素。每个元素最多被压入和弹出一次,因此是 O(N)


3.6.2 队列与 BFS 准备

队列的 FIFO 性质使它非常适合广度优先搜索(BFS),我们在第 5.2 章详细讲解。这里先聚焦队列本身及相关模式。

图示:队列操作

Queue Operations

队列按到达顺序处理元素:队首元素始终最先出队,新元素从队尾加入。FIFO 性质保证 BFS 按层级访问节点,从而保证最短路径距离的正确性。

用队列模拟

题目: 游乐场过山车共有 N 组游客,每组人数为 size[i],每次运行最多容纳 M 人。模拟需要多少次运行才能送完所有人。

📄 C++ 完整代码
#include <bits/stdc++.h>
using namespace std;

int main() {
    ios_base::sync_with_stdio(false);
    cin.tie(NULL);

    int n, m;
    cin >> n >> m;

    queue<int> groups;
    for (int i = 0; i < n; i++) {
        int x;
        cin >> x;
        groups.push(x);
    }

    int runs = 0;
    while (!groups.empty()) {
        int capacity = m;   // 本次运行的剩余容量
        runs++;

        while (!groups.empty() && groups.front() <= capacity) {
            capacity -= groups.front();  // 这组能坐下
            groups.pop();
        }
    }

    cout << runs << "\n";
    return 0;
}

3.6.3 双端队列——两端都能操作

deque(发音「deck」)支持 O(1) 的两端插入和删除。

📄 `deque`(发音「deck」)支持 O(1) 的两端插入和删除。
#include <bits/stdc++.h>
using namespace std;

int main() {
    deque<int> dq;

    dq.push_back(1);    // [1]
    dq.push_back(2);    // [1, 2]
    dq.push_front(0);   // [0, 1, 2]
    dq.push_front(-1);  // [-1, 0, 1, 2]

    cout << dq.front() << "\n";  // -1
    cout << dq.back() << "\n";   // 2

    dq.pop_front();  // [-1 移除] → [0, 1, 2]
    dq.pop_back();   // [2 移除]  → [0, 1]

    cout << dq.front() << "\n";  // 0
    cout << dq.size() << "\n";   // 2

    // 随机访问(像向量一样)
    cout << dq[0] << "\n";  // 0
    cout << dq[1] << "\n";  // 1

    return 0;
}

3.6.4 单调双端队列——滑动窗口最大值

题目: 给定 N 个整数的数组 A 和大小为 K 的窗口,找每个窗口从左向右滑动时的最大值。

朴素做法:对每个窗口扫描全部 K 个元素 → O(N×K)。K 较大时太慢。

单调双端队列做法:O(N)

双端队列按值递减的顺序存储下标,队首始终是最大值。

📄 双端队列按**值递减**的顺序存储下标,队首始终是最大值。
#include <bits/stdc++.h>
using namespace std;

int main() {
    ios_base::sync_with_stdio(false);
    cin.tie(NULL);

    int n, k;
    cin >> n >> k;
    vector<int> A(n);
    for (int &x : A) cin >> x;

    deque<int> dq;  // 存下标;A[dq[i]] 的值递减
    vector<int> maxInWindow;

    for (int i = 0; i < n; i++) {
        // 移除超出窗口的元素(队首过期)
        while (!dq.empty() && dq.front() <= i - k) {
            dq.pop_front();
        }

        // 从队尾移除所有小于 A[i] 的元素
        // (只要这个新元素在窗口中,它们就不可能是最大值)
        while (!dq.empty() && A[dq.back()] <= A[i]) {
            dq.pop_back();
        }

        dq.push_back(i);  // 加入当前下标

        // 从 i = k-1 开始,窗口已满
        if (i >= k - 1) {
            maxInWindow.push_back(A[dq.front()]);  // 队首始终是最大值
        }
    }

    for (int x : maxInWindow) cout << x << " ";
    cout << "\n";

    return 0;
}

样例输入:

8 3
1 3 -1 -3 5 3 6 7

样例输出:

3 3 5 5 6 7

窗口:[1,3,-1]=3,[3,-1,-3]=3,[-1,-3,5]=5,[-3,5,3]=5,[5,3,6]=6,[3,6,7]=7。


3.6.5 基于栈的直方图最大矩形

竞赛编程经典题:给定 N 根高度为 h[0..N-1] 的柱子,找直方图内能放入的最大矩形面积。

📄 竞赛编程经典题:给定 N 根高度为 h[0..N-1] 的柱子,找直方图内能放入的最大矩形面积。
#include <bits/stdc++.h>
using namespace std;

int main() {
    ios_base::sync_with_stdio(false);
    cin.tie(NULL);

    int n;
    cin >> n;
    vector<int> h(n);
    for (int &x : h) cin >> x;

    stack<int> st;   // 按高度递增存储下标
    long long maxArea = 0;

    for (int i = 0; i <= n; i++) {
        int currentH = (i == n) ? 0 : h[i];  // 末尾添加哨兵 0

        while (!st.empty() && h[st.top()] > currentH) {
            int height = h[st.top()];   // 矩形高度
            st.pop();
            int width = st.empty() ? i : i - st.top() - 1;  // 宽度
            maxArea = max(maxArea, (long long)height * width);
        }

        st.push(i);
    }

    cout << maxArea << "\n";
    return 0;
}

⚠️ 第 3.6 章常见错误

#错误错在哪里修复方法
1对空栈/队列调用 top()/front()未定义行为,程序崩溃先检查 !st.empty()
2单调栈中比较方向错误「下一个更大」需要 >,用了 < 变成「下一个更小」仔细阅读题意,用样例验证
3滑动窗口中忘记移除过期元素双端队列的队首下标超出窗口范围,结果错误while (dq.front() <= i - k)
4直方图最大矩形忘记哨兵栈中剩余元素未处理,遗漏最终答案i == n 时使用高度 0
5混淆 stackdequestack 只能访问顶部,不能遍历中间元素需要两端操作时改用 deque

本章总结

📌 核心要点

结构操作关键使用场景为什么重要
stack<T>push/pop/top — O(1)括号匹配、撤销/重做、DFSLIFO 逻辑的核心工具
queue<T>push/pop/front — O(1)BFS、模拟排队FIFO 逻辑的核心工具
deque<T>两端 push/pop — O(1)滑动窗口、BFS 变体支持两端访问的多功能容器
单调栈总计 O(N)下一个更大/更小元素USACO Silver 高频考点
单调双端队列总计 O(N)滑动窗口最大/最小值窗口极值的 O(N) 解法

❓ 常见问题

Q1:为什么单调栈是 O(N) 而不是 O(N²)?看起来有嵌套循环啊。

A:核心观察——每个元素最多被压入一次,最多被弹出一次。虽然内层 while 循环可能一次弹出多个元素,但全局弹出总次数 ≤ N。所以总操作 ≤ 2N = O(N)。这种分析方法叫均摊分析

Q2:什么时候用 stack vs deque

A:只需要 LIFO(单端访问),用 stack;需要两端操作(如滑动窗口需要队首删除 + 队尾添加),用 dequestack 内部其实是以 deque 实现的,只是把接口限制为只暴露顶部。

Q3:BFS 一定要用 queue 吗?能用 vector 吗?

A:技术上可以用 vector + 下标模拟,但 queue 更清晰不易出错。竞赛中直接用 queue。唯一例外是 0-1 BFS(边权只有 0 和 1 的最短路),需要用 deque

Q4:「最大矩形」问题为什么能用栈解决?

A:栈维护着高度递增的柱子序列。遇到更矮的柱子时,说明栈顶柱子「向右延伸」到此为止,此时可以计算以栈顶柱子为高度的矩形面积。每根柱子各压入弹出一次,总复杂度 O(N)

🔗 与后续章节的联系

  • 第 5.2 章(图 BFS/DFS):queue 是 BFS 的核心容器,stack 可用于迭代式 DFS
  • 第 3.4 章(双指针):滑动窗口技术与本章的单调双端队列完美结合
  • 第 6.1–6.3 章(DP):某些优化技术(如 DP 的滑动窗口极值优化)直接使用本章的单调双端队列
  • 单调栈也可以作为第 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 章 ⏱️ 约 50 分钟 🎯 中级

第 3.7 章:哈希技术

📝 前置条件: 了解 STL 容器(第 3.1 章)和字符串基础(第 2.3 章)。本章涵盖哈希原理和竞赛编程的进阶用法。

哈希是竞赛编程中最重要的「工具」之一:它能把复杂的比较问题变成 O(1) 的数值比较。但哈希也是最容易被「hack」的技术——本章既教你如何用好哈希,也教你如何防止被 hack。


3.7.1 unordered_map vs map:内部实现与性能

内部实现对比

特性mapunordered_map
内部结构红黑树(平衡 BST)哈希表
查找时间O(log N)平均 O(1),最坏 O(N)
插入时间O(log N)平均 O(1),最坏 O(N)
遍历顺序有序(按键升序)无序
内存占用O(N),常数较小O(N),常数较大
最坏情况O(log N)(稳定)O(N)(哈希碰撞)
📄 C++ 完整代码
#include <bits/stdc++.h>
using namespace std;

int main() {
    // map:有序,O(log N)
    map<int, int> m;
    m[3] = 30; m[1] = 10; m[2] = 20;
    for (auto [k, v] : m) cout << k << ":" << v << " ";
    // 输出:1:10 2:20 3:30  ← 有序!

    // unordered_map:无序,平均 O(1)
    unordered_map<int, int> um;
    um[3] = 30; um[1] = 10; um[2] = 20;
    // 遍历顺序不确定,但查找非常快

    // 性能差异:N=10^6 次操作
    // map: ~300ms;unordered_map: ~80ms(大概)
}

怎么选?

  • map 需要有序遍历、需要 lower_bound/upper_bound、键范围极端(高哈希碰撞风险)
  • unordered_map 只需查找/插入、键是整数或字符串、N 较大(> 10^5)

3.7.2 防 Hack:自定义哈希

问题: unordered_map 的默认整数哈希本质上是 hash(x) = x,攻击者可以构造大量哈希碰撞,使操作退化到 O(N) 从而 TLE。

在 Codeforces 等平台上,这是常见的 hack 技术

解决方案:splitmix64 哈希

📄 查看代码:解决方案:splitmix64 哈希
// 防 hack 自定义哈希器 — 使用 splitmix64
struct custom_hash {
    static uint64_t splitmix64(uint64_t x) {
        x += 0x9e3779b97f4a7c15;
        x = (x ^ (x >> 30)) * 0xbf58476d1ce4e5b9;
        x = (x ^ (x >> 27)) * 0x94d049bb133111eb;
        return x ^ (x >> 31);
    }

    size_t operator()(uint64_t x) const {
        static const uint64_t FIXED_RANDOM =
            chrono::steady_clock::now().time_since_epoch().count();
        return splitmix64(x + FIXED_RANDOM);
    }
};

// 用法:
unordered_map<int, int, custom_hash> safe_map;
unordered_set<int, custom_hash> safe_set;

⚠️ 竞赛技巧: 在 Codeforces 上用 unordered_map 时,始终加上 custom_hash。USACO 测试数据不会故意构造 hack,但这是个好习惯。


3.7.3 字符串哈希(多项式哈希)

字符串哈希将字符串映射为整数,把字符串比较变成数值比较(O(1))。

核心公式

对字符串 s[0..n-1],定义哈希值为:

hash(s) = s[0]·B^(n-1) + s[1]·B^(n-2) + ... + s[n-1]·B^0  (mod M)

其中 B底数(通常取 131 或 131117),M大质数(通常取 10⁹+7 或 10⁹+9)。

前缀哈希 + O(1) 子串哈希

📄 查看代码:前缀哈希 + O(1) 子串哈希
// 字符串哈希:O(N) 预处理,O(1) 子串哈希查询
#include <bits/stdc++.h>
using namespace std;
typedef unsigned long long ull;

const ull BASE = 131;
// 使用 unsigned long long 自然溢出(等价于 mod 2^64)

struct StringHash {
    int n;
    vector<ull> h, pw;

    StringHash(const string& s) : n(s.size()), h(n + 1, 0), pw(n + 1, 1) {
        for (int i = 0; i < n; i++) {
            h[i + 1] = h[i] * BASE + (s[i] - 'a' + 1);  // 1-indexed 前缀哈希
            pw[i + 1] = pw[i] * BASE;                      // BASE^(i+1)
        }
    }

    // 获取子串 s[l..r](0-indexed)的哈希值
    ull get(int l, int r) {
        return h[r + 1] - h[l] * pw[r - l + 1];  // ← 关键公式
    }
};

int main() {
    ios_base::sync_with_stdio(false);
    cin.tie(NULL);

    string s = "abcabc";
    StringHash sh(s);

    // 比较两个子串是否相等
    // s[0..2] = "abc",s[3..5] = "abc"
    cout << (sh.get(0, 2) == sh.get(3, 5) ? "相等" : "不相等") << "\n";  // 相等

    // 比较 s[0..1] = "ab" vs s[3..4] = "ab"
    cout << (sh.get(0, 1) == sh.get(3, 4) ? "相等" : "不相等") << "\n";  // 相等
}

公式推导:

h[r+1] = s[0]*B^r + s[1]*B^(r-1) + ... + s[r]*B^0
h[l]   = s[0]*B^(l-1) + ... + s[l-1]*B^0

h[r+1] - h[l] * B^(r-l+1)
= s[l]*B^(r-l) + s[l+1]*B^(r-l-1) + ... + s[r]*B^0
= hash(s[l..r]) ✓

下图直观展示了前缀哈希数组的构建过程,以及如何用 get(l, r) 公式在 O(1) 内提取任意子串的哈希值:

String Polynomial Hash


3.7.4 双重哈希(避免碰撞)

单重哈希(mod M)的碰撞概率约 1/M。对 N 次子串比较,预期碰撞次数约 N²/(2M)

下图展示了两种经典的碰撞处理方式——链式法(unordered_map 内部使用)和线性探测:

Hash Collision Resolution

  • M = 10⁹+7,N = 10⁶:碰撞概率约 10¹²/(2×10⁹) = 500 次!不安全。
  • 解决方案:双重哈希,同时使用两对不同的 (B, M),碰撞概率降至 1/(M₁×M₂) ≈ 10⁻¹⁸
📄 C++ 完整代码
// 双重哈希:同时用两对 (BASE, MOD),碰撞概率极低
struct DoubleHash {
    static const ull B1 = 131, M1 = 1e9 + 7;
    static const ull B2 = 137, M2 = 1e9 + 9;

    int n;
    vector<ull> h1, h2, pw1, pw2;

    DoubleHash(const string& s) : n(s.size()),
        h1(n+1,0), h2(n+1,0), pw1(n+1,1), pw2(n+1,1) {
        for (int i = 0; i < n; i++) {
            ull c = s[i] - 'a' + 1;
            h1[i+1] = (h1[i] * B1 + c) % M1;
            h2[i+1] = (h2[i] * B2 + c) % M2;
            pw1[i+1] = pw1[i] * B1 % M1;
            pw2[i+1] = pw2[i] * B2 % M2;
        }
    }

    // 返回 pair<ull,ull> 作为子串 s[l..r] 的哈希「指纹」
    pair<ull,ull> get(int l, int r) {
        ull v1 = (h1[r+1] - h1[l] * pw1[r-l+1] % M1 + M1) % M1;
        ull v2 = (h2[r+1] - h2[l] * pw2[r-l+1] % M2 + M2) % M2;
        return {v1, v2};
    }
};

3.7.5 应用:字符串匹配(Rabin-Karp)

📄 查看代码:3.7.5 应用:字符串匹配(Rabin-Karp)
// Rabin-Karp 字符串匹配:找 T 中所有 P 的出现位置
// 时间:平均 O(N+M),最坏 O(NM)(实际非常快)
#include <bits/stdc++.h>
using namespace std;
typedef unsigned long long ull;

vector<int> rabinKarp(const string& T, const string& P) {
    int n = T.size(), m = P.size();
    if (m > n) return {};

    const ull BASE = 131;
    ull patHash = 0, textHash = 0, pow_m = 1;

    // 计算 BASE^m(自然溢出)
    for (int i = 0; i < m - 1; i++) pow_m *= BASE;

    // 初始哈希
    for (int i = 0; i < m; i++) {
        patHash = patHash * BASE + P[i];
        textHash = textHash * BASE + T[i];
    }

    vector<int> result;
    for (int i = 0; i + m <= n; i++) {
        if (textHash == patHash) {
            // 哈希匹配时验证(避免碰撞导致的误判)
            if (T.substr(i, m) == P) result.push_back(i);
        }
        if (i + m < n) {
            // 滚动哈希:去掉最左边的字符,加入最右边的字符
            textHash = textHash - T[i] * pow_m;   // 移除最左
            textHash = textHash * BASE + T[i + m]; // 加入最右
        }
    }
    return result;
}

3.7.6 应用:最长公共子串

题目: 给定字符串 S 和 T,找最长公共子串的长度。

做法: 二分答案(最长公共子串的长度 L),然后用哈希集合检查是否有长度为 L 的子串同时出现在两个字符串中。

📄 C++ 完整代码
// 最长公共子串:O(N log N) — 二分查找 + 哈希
int longestCommonSubstring(const string& S, const string& T) {
    StringHash hs(S), ht(T);
    int ns = S.size(), nt = T.size();

    auto check = [&](int len) -> bool {
        unordered_set<ull> setS;
        for (int i = 0; i + len <= ns; i++)
            setS.insert(hs.get(i, i + len - 1));
        for (int j = 0; j + len <= nt; j++)
            if (setS.count(ht.get(j, j + len - 1)))
                return true;
        return false;
    };

    int lo = 0, hi = min(ns, nt);
    while (lo < hi) {
        int mid = (lo + hi + 1) / 2;
        if (check(mid)) lo = mid;
        else hi = mid - 1;
    }
    return lo;
}

⚠️ 常见错误

  1. 模数选择不当: 不要用 10⁹+7 以外的数;尤其避免非质数模数(碰撞率高)。推荐:10⁹+710⁹+9 作为双重哈希对。

  2. unordered_map 被 hack: 在 Codeforces 等平台上,默认哈希可被攻击。始终使用 custom_hash

  3. 子串哈希相减下溢: h[r+1] - h[l] * pw[r-l+1] 在有符号整数下可能为负。使用 unsigned long long 自然溢出,或用 (... % M + M) % M 确保非负。

  4. 底数与字符集不匹配: 对仅含小写字母(26 种)的情况,BASE 必须 > 26(通常用 31 或 131)。对全 ASCII 字符(128 种),BASE 必须 > 128(用 131 或 137)。

  5. 哈希碰撞导致 WA: 即使双重哈希,理论上仍可能碰撞。不确定时,哈希匹配后加直接字符串比较。


本章总结

📌 核心对比表

工具时间复杂度使用场景
map<K,V>O(log N)需要有序性,需要范围查询
unordered_map<K,V>O(1) 均摊只需查找/插入,不需要键的顺序
字符串哈希(单重)O(N) 预处理,O(1) 查询子串比较、模式匹配
字符串哈希(双重)O(N) 预处理,O(1) 查询高精度场景,避免碰撞

❓ 常见问题

Q1:ull 自然溢出双重哈希和手动 mod 哈希哪个更好?

A:ull 自然溢出(等价于 mod 2⁶⁴)代码更简单,2⁶⁴ 足够大以至于单重哈希碰撞概率已经极低(约 10⁻¹⁸)。但精心构造的数据可以故意触发碰撞——此时双重哈希更安全。两种方式在竞赛中都有效;ull 更常见。

Q2:字符串哈希能做什么 KMP 做不到的事?

A:字符串哈希擅长多字符串比较(如最长公共子串、回文子串),而 KMP 只擅长单模式匹配。哈希 + 二分查找可以用 O(N log N) 解决许多需要更复杂 KMP 实现的字符串问题。

Q3:底数该用 31 还是 131?

A:只有小写字母时用 31(小于 37 的质数,避免哈希空间太小)。混合大小写或含数字时用 131(大于 128 的质数,覆盖完整 ASCII)。关键是:BASE 必须大于字符集大小,最好是质数。


练习题

题目 3.7.1 — 哈希两数之和 🟢 简单 给定数组 A,判断是否存在两个不同元素之和等于目标值 X。

提示 对每个 A[i],检查 (X - A[i]) 是否已经在哈希集合中。
✅ 完整题解
#include <bits/stdc++.h>
using namespace std;
int main() {
    int n, X; cin >> n >> X;
    vector<int> a(n); for (int& x : a) cin >> x;
    unordered_set<int> seen;
    for (int x : a) {
        if (seen.count(X - x)) { cout << "YES\n"; return 0; }
        seen.insert(x);
    }
    cout << "NO\n";
}

复杂度: O(N) 均摊。


题目 3.7.2 — 子串查找 🟢 简单 给定文本 T 和模式 P,打印 P 在 T 中所有出现位置的起始下标。

提示 滚动哈希:用前缀哈希在 O(1) 计算 T 的每个 |P| 长度窗口的哈希值。
✅ 完整题解(滚动哈希)
#include <bits/stdc++.h>
using namespace std;
typedef unsigned long long ull;
const ull BASE = 131, MOD = (1ULL<<61)-1;
int main() {
    string T, P; cin >> T >> P;
    int n = T.size(), m = P.size();
    vector<ull> h(n+1,0), pw(n+1,1);
    for(int i=0;i<n;i++) { h[i+1]=(h[i]*BASE+T[i])%MOD; pw[i+1]=pw[i]*BASE%MOD; }
    ull hp=0; for(char c:P) hp=(hp*BASE+c)%MOD;
    for(int i=0;i+m<=n;i++){
        ull wh=(h[i+m]-h[i]*pw[m]%MOD+MOD*2)%MOD;
        if(wh==hp) cout<<i<<"\n";
    }
}

复杂度: O(N + M)。


题目 3.7.3 — 最长回文子串 🟡 中等 找最长回文子串的长度。

提示 对长度二分查找。s[l..r] 是回文串当且仅当 hash(s[l..r]) == hash(rev(s)[n-1-r..n-1-l])。
✅ 完整题解(哈希 + 二分查找)
#include <bits/stdc++.h>
using namespace std;
typedef unsigned long long ull;
const ull BASE=131;
struct Hasher {
    vector<ull> h,pw;
    Hasher(const string&s){
        int n=s.size(); h.resize(n+1,0); pw.resize(n+1,1);
        for(int i=0;i<n;i++){h[i+1]=h[i]*BASE+s[i];pw[i+1]=pw[i]*BASE;}
    }
    ull get(int l,int r){return h[r+1]-h[l]*pw[r-l+1];}
};
int main(){
    string s; cin>>s;
    string r(s.rbegin(),s.rend());
    Hasher hs(s),hr(r);
    int n=s.size(), ans=1;
    auto check=[&](int len)->bool{
        for(int i=0;i+len<=n;i++){
            int j=i+len-1;
            if(hs.get(i,j)==hr.get(n-1-j,n-1-i)) return true;
        }
        return false;
    };
    int lo=1,hi=n;
    while(lo<=hi){int mid=(lo+hi)/2;if(check(mid)){ans=mid;lo=mid+1;}else hi=mid-1;}
    cout<<ans<<"\n";
}

复杂度: O(N log N)。


题目 3.7.4 — 统计不同子串 🟡 中等 给定长度为 N 的字符串 S(N ≤ 5000),统计不同子串的个数。

提示 将所有 O(N²) 个子串的哈希值插入 unordered_set,用双重哈希避免碰撞。
✅ 完整题解
#include <bits/stdc++.h>
using namespace std;
typedef unsigned long long ull;
int main(){
    string s; cin>>s;
    int n=s.size();
    const ull B1=131,B2=137,M1=1e9+7,M2=1e9+9;
    unordered_set<ull> seen;
    for(int i=0;i<n;i++){
        ull h1=0,h2=0;
        for(int j=i;j<n;j++){
            h1=(h1*B1+s[j])%M1;
            h2=(h2*B2+s[j])%M2;
            seen.insert(h1*M2+h2);  // 合并两个哈希值
        }
    }
    cout<<seen.size()<<"\n";
}

复杂度: O(N²) 时间和空间(适用于 N ≤ 5000)。


题目 3.7.5 — 字符串周期 🔴 困难 找字符串 S 的最小周期(最小的 k 整除 n,使得 S = S[0..k-1] 的重复)。

提示 对 n 的每个因数 k,验证 s[0..k-1] 重复后是否等于 s,用哈希比较,每次检查 O(n/k)。
✅ 完整题解
#include <bits/stdc++.h>
using namespace std;
typedef unsigned long long ull;
const ull BASE=131,MOD=1e9+7;
int main(){
    string s; cin>>s;
    int n=s.size();
    vector<ull> h(n+1,0),pw(n+1,1);
    for(int i=0;i<n;i++){h[i+1]=(h[i]*BASE+s[i])%MOD;pw[i+1]=pw[i]*BASE%MOD;}
    auto getHash=[&](int l,int r){return (h[r+1]-h[l]*pw[r-l+1]%MOD+MOD*2)%MOD;};

    vector<int> divs;
    for(int i=1;i*i<=n;i++) if(n%i==0){divs.push_back(i);if(i!=n/i)divs.push_back(n/i);}
    sort(divs.begin(),divs.end());

    for(int k:divs){
        bool ok=true;
        for(int i=0;i+k<=n&&ok;i+=k)
            if(getHash(i,i+k-1)!=getHash(0,k-1)) ok=false;
        if(ok){cout<<k<<"\n";return 0;}
    }
}

复杂度: O(d(N) × N) ≈ 典型输入 O(N log N)。

📖 第 3.8 章 ⏱️ 约 55 分钟 🎯 中级

第 3.8 章:映射与集合

映射和集合是频率统计、查找和跟踪唯一元素的主力工具。本章深入探讨它们在 USACO 题目中的实际应用。

📝 前置条件: 熟悉数组和基础 C++ STL(第 2.4 章)。了解哈希表概念(第 3.7 章)有帮助,但不是严格要求——mapset 基于树结构,不依赖哈希。


3.8.1 map vs unordered_map —— 明智地选择

图示:Map 内部结构(BST)

Map Structure

std::map 将键值对存储在平衡 BST(红黑树)中,所有操作 O(log N) 且键自动有序——当你需要 lower_bound/upper_bound 查询时非常有用。只需 O(1) 查找且不需要顺序时,用 unordered_map

mapunordered_map 的关键结构差异:

map vs unordered_map

特性mapunordered_map
底层结构红黑树哈希表
插入/查找时间O(log n)平均 O(1),最坏 O(n)
遍历顺序按键有序任意顺序
最小/最大键通过 .begin()/.rbegin() 获取不支持
键的要求可比较(有 <可哈希
使用场景需要有序键或最大最小键时需要最快查找时

大多数 USACO 题目两者都能用。键是整数或字符串时用 unordered_map 求速度,需要有序遍历时用 map

示例:频率映射

📄 查看代码:示例:频率映射
#include <bits/stdc++.h>
using namespace std;

int main() {
    ios_base::sync_with_stdio(false);
    cin.tie(NULL);

    int n;
    cin >> n;

    unordered_map<int, int> freq;
    for (int i = 0; i < n; i++) {
        int x;
        cin >> x;
        freq[x]++;   // 计数加一;若不存在则以 0 创建
    }

    // 找出现频率最高的元素
    int maxFreq = 0, maxVal = INT_MIN;
    for (auto &[val, count] : freq) {   // 结构化绑定(C++17)
        if (count > maxFreq || (count == maxFreq && val < maxVal)) {
            maxFreq = count;
            maxVal = val;
        }
    }

    cout << "最频繁:" << maxVal << "(" << maxFreq << " 次)\n";

    return 0;
}

3.8.2 Map 操作——完整参考

📄 查看代码:3.8.2 Map 操作——完整参考
#include <bits/stdc++.h>
using namespace std;

int main() {
    map<string, int> scores;

    // 插入
    scores["Alice"] = 95;
    scores["Bob"] = 87;
    scores["Charlie"] = 92;
    scores.insert({"Dave", 78});    // 另一种方式
    scores.emplace("Eve", 88);      // 最高效的方式

    // 查找
    cout << scores["Alice"] << "\n";  // 95
    // 警告:scores["Unknown"] 会以值 0 创建该项!

    // 安全查找
    if (scores.count("Frank")) {
        cout << scores["Frank"] << "\n";
    } else {
        cout << "Frank not found\n";
    }

    // 使用 find() — 返回迭代器
    auto it = scores.find("Bob");
    if (it != scores.end()) {
        cout << it->first << ": " << it->second << "\n";  // Bob: 87
    }

    // 更新
    scores["Alice"] += 5;    // Alice 现在是 100

    // 删除
    scores.erase("Charlie");

    // 按键有序遍历(map 始终保证有序)
    for (const auto &[name, score] : scores) {
        cout << name << ": " << score << "\n";
    }
    // Alice: 100
    // Bob: 87
    // Dave: 78
    // Eve: 88

    // 大小和空检查
    cout << scores.size() << "\n";   // 4
    cout << scores.empty() << "\n";  // 0(假)

    // 清空所有条目
    scores.clear();

    return 0;
}

3.8.3 Set 操作——完整参考

📄 查看代码:3.8.3 Set 操作——完整参考
#include <bits/stdc++.h>
using namespace std;

int main() {
    set<int> s = {5, 3, 8, 1, 9, 2};
    // s = {1, 2, 3, 5, 8, 9}(始终有序!)

    // 插入
    s.insert(4);   // s = {1, 2, 3, 4, 5, 8, 9}
    s.insert(3);   // 已存在,无变化

    // 删除
    s.erase(8);    // s = {1, 2, 3, 4, 5, 9}

    // 查找
    cout << s.count(3) << "\n";  // 1(存在)
    cout << s.count(7) << "\n";  // 0(未找到)

    // 基于迭代器的查询
    auto it = s.lower_bound(4);  // 第一个 >= 4 的元素
    cout << *it << "\n";         // 4

    auto it2 = s.upper_bound(4); // 第一个 > 4 的元素
    cout << *it2 << "\n";        // 5

    // 最小值和最大值
    cout << *s.begin() << "\n";   // 1(最小)
    cout << *s.rbegin() << "\n";  // 9(最大)

    // 移除最小值
    s.erase(s.begin());   // 移除 1
    cout << *s.begin() << "\n";  // 2

    // 遍历
    for (int x : s) cout << x << " ";
    cout << "\n";  // 2 3 4 5 9

    return 0;
}

3.8.4 USACO 题目:奶牛 ID

题目(USACO 2017 February Bronze): 给定一组「已占用」的 ID 集合和 N,找第 N 个可用的 ID。

📄 C++ 完整代码
#include <bits/stdc++.h>
using namespace std;

int main() {
    ios_base::sync_with_stdio(false);
    cin.tie(NULL);

    int n, q;
    cin >> n >> q;

    set<int> taken;
    for (int i = 0; i < n; i++) {
        int x; cin >> x;
        taken.insert(x);
    }

    // 对每个查询 q,找第 q 个不在 taken 中的正整数
    while (q--) {
        int k; cin >> k;

        // 二分查找:找最小的 x 使得 x - (x 以内已占用的数量) >= k
        int lo = 1, hi = 2e9;
        while (lo < hi) {
            int mid = lo + (hi - lo) / 2;
            // [1, mid] 中可用数量 = mid - (≤ mid 的已占用数量)
            int taken_count = (int)(taken.lower_bound(mid + 1) - taken.begin());
            int available = mid - taken_count;
            if (available >= k) hi = mid;
            else lo = mid + 1;
        }

        cout << lo << "\n";
    }

    return 0;
}

3.8.5 Multiset——允许重复的有序集合

multiset 类似于 set,但允许重复值:

📄 `multiset` 类似于 set,但允许重复值:
#include <bits/stdc++.h>
using namespace std;

int main() {
    multiset<int> ms;
    ms.insert(3);
    ms.insert(1);
    ms.insert(3);   // 允许重复
    ms.insert(5);
    ms.insert(1);

    // ms = {1, 1, 3, 3, 5}

    cout << ms.count(3) << "\n";  // 2(有几个 3)
    cout << ms.count(2) << "\n";  // 0

    // 只移除一个 3
    ms.erase(ms.find(3));  // 只移除一个 3
    // ms = {1, 1, 3, 5}

    // 移除所有的 1
    ms.erase(1);  // 移除所有 1
    // ms = {3, 5}

    cout << *ms.begin() << "\n";   // 3(最小)
    cout << *ms.rbegin() << "\n";  // 5(最大)

    return 0;
}

用两个 Multiset 维护动态中位数

用最大 multiset(下半部分)和最小 multiset(上半部分)跟踪数据流的中位数:

📄 用最大 multiset(下半部分)和最小 multiset(上半部分)跟踪数据流的中位数:
#include <bits/stdc++.h>
using namespace std;

int main() {
    multiset<int> lo;  // 下半部分(rbegin() = 最大值)
    multiset<int> hi;  // 上半部分(begin() = 最小值)

    int n;
    cin >> n;

    for (int i = 0; i < n; i++) {
        int x;
        cin >> x;

        // 加入合适的半部分
        if (lo.empty() || x <= *lo.rbegin()) {
            lo.insert(x);
        } else {
            hi.insert(x);
        }

        // 重新平衡:大小差不超过 1
        while (lo.size() > hi.size() + 1) {
            hi.insert(*lo.rbegin());
            lo.erase(lo.find(*lo.rbegin()));
        }
        while (hi.size() > lo.size()) {
            lo.insert(*hi.begin());
            hi.erase(hi.begin());
        }

        // 打印中位数
        if (lo.size() == hi.size()) {
            // 偶数个:两个中间值的平均
            double median = (*lo.rbegin() + *hi.begin()) / 2.0;
            cout << fixed << setprecision(1) << median << "\n";
        } else {
            // 奇数个:中间值在 lo 中
            cout << *lo.rbegin() << "\n";
        }
    }

    return 0;
}

3.8.6 实用模式

模式一:统计不同元素

vector<int> data = {1, 5, 3, 1, 2, 5, 5, 3};
set<int> distinct(data.begin(), data.end());
cout << "不同值的个数:" << distinct.size() << "\n";  // 4

模式二:按频率分组,按值排序

📄 查看代码:模式二:按频率分组,按值排序
vector<int> nums = {3, 1, 4, 1, 5, 9, 2, 6, 5, 3, 5};
map<int, int> freq;
for (int x : nums) freq[x]++;

// 按频率分组
map<int, vector<int>> byFreq;
for (auto &[val, cnt] : freq) {
    byFreq[cnt].push_back(val);
}

// 按频率顺序打印
for (auto &[cnt, vals] : byFreq) {
    for (int v : vals) cout << v << "(×" << cnt << ")\n";
}

模式三:离线查询与排序结合

将查询与事件一起排序,以 O((N+Q) log N) 一起处理:

// 示例:对每个查询点,统计有多少事件的值 <= 查询点
// 对两个数组排序,用双指针扫描

⚠️ 第 3.8 章常见错误

#错误错在哪里修复方法
1map[key] 访问不存在的键自动创建值为 0 的条目,污染数据先用 m.count(key)m.find(key) 检查
2multiset::erase(value) 删除所有相等值本想删一个,却删光了ms.erase(ms.find(value)) 只删一个
3遍历时修改 map/set 大小迭代器失效,崩溃或跳过元素it = m.erase(it) 安全删除
4unordered_map 被 hack 退化到 O(N)攻击者构造哈希碰撞数据,TLE换用 map 或使用自定义哈希函数
5忘记 set 不存储重复值插入重复后 size() 不增,计数出错需要重复时用 multiset

本章总结

📌 核心要点

结构有序?允许重复?关键特性为什么重要
map<K,V>是(有序)否(唯一键)键值映射,O(log N)频率统计、ID→属性映射
unordered_map<K,V>平均 O(1) 查找大数据下比 map 快 5-10 倍
set<T>是(有序)有序唯一集合去重,范围查询(lower_bound
unordered_set<T>O(1) 成员检测只需检查「是否见过」
multiset<T>是(有序)有序多集合动态中位数、滑动窗口

🧩 「用哪个容器」速查

需求推荐容器原因
统计每个元素出现次数map / unordered_map一行 freq[x]++
去重并排序set自动去重 + 自动排序
检查元素是否已见unordered_setO(1) 查找
动态有序集合 + 求极值set / multisetO(1) 访问最小/最大值
需要 lower_bound / upper_boundset / map只有有序容器支持
值→下标映射map / unordered_map坐标压缩等场景

❓ 常见问题

Q1:map[] 运算符和 find 有什么区别?

A:m[key] 当键不存在时会自动创建默认值(int 的默认值为 0);m.find(key) 只查找,不创建。如果只想检查键是否存在,用 m.count(key)m.find(key) != m.end()

Q2:multisetpriority_queue 都能取极值——用哪个?

A:priority_queue 只能取极值并删除,不支持按值删除。multiset 支持查找并删除任意值,更灵活。只需反复取极值时,priority_queue 更简单;需要删除特定元素(如滑动窗口移除离开的元素)时,用 multiset

Q3:unordered_map 什么时候比 map 慢?

A:两种情况:① 哈希碰撞严重时(多个键哈希到同一桶),退化到 O(N);② 竞赛中攻击者故意构造数据 hack unordered_map。解决方案:使用自定义哈希函数,或切换到 map

Q4:C++17 结构化绑定 auto &[key, val] 安全吗?竞赛中能用吗?

A:USACO 和大多数竞赛平台支持 C++17,for (auto &[key, val] : m) 可以安全使用。比 entry.first/entry.second 更简洁。

🔗 与后续章节的联系

  • 第 3.3 章(排序与搜索):坐标压缩常与 map 结合(值 → 压缩后下标)
  • 第 3.9 章(线段树):有序 setlower_bound 可以替代简单的线段树查询
  • 第 5.1–5.2 章(图):map 常用于存储稀疏图的邻接表
  • 第 4.1 章(贪心):multiset 结合贪心策略可以高效维护动态最优选择
  • map 频率统计模式贯穿全书,是竞赛编程中最基础的工具之一

练习题

题目 3.8.1 — 两数之和 🟢 简单 读取 N 个整数和目标 T,找两个相加等于 T 的值,打印它们的下标(1-indexed)。

✅ 完整题解
#include <bits/stdc++.h>
using namespace std;
int main() {
    int n, T; cin >> n >> T;
    map<int, int> seen;  // 值 → 下标
    for (int i = 1; i <= n; i++) {
        int x; cin >> x;
        if (seen.count(T - x)) {
            cout << seen[T - x] << " " << i << "\n";
            return 0;
        }
        seen[x] = i;
    }
    cout << "no solution\n";
}

复杂度:map O(N log N),用 unordered_map O(N)。


题目 3.8.2 — 字母异位词分组 🟡 中等 将 N 个单词按其字母排序后的形式分组。

✅ 完整题解
#include <bits/stdc++.h>
using namespace std;
int main() {
    int n; cin >> n;
    map<string, vector<string>> groups;
    for (int i = 0; i < n; i++) {
        string w; cin >> w;
        string key = w; sort(key.begin(), key.end());
        groups[key].push_back(w);
    }
    for (auto& [key, words] : groups) {
        sort(words.begin(), words.end());
        for (const string& w : words) cout << w << " ";
        cout << "\n";
    }
}

复杂度: O(N × K log K),K = 平均单词长度。


题目 3.8.3 — 区间最大重叠数 🟡 中等 统计 N 个区间在点 1..M 上的最大重叠数。

✅ 完整题解
#include <bits/stdc++.h>
using namespace std;
int main() {
    int n, M; cin >> n >> M;
    vector<int> diff(M + 2, 0);
    for (int i = 0; i < n; i++) {
        int l, r; cin >> l >> r;
        diff[l]++; diff[r+1]--;
    }
    int cur = 0, ans = 0;
    for (int i = 1; i <= M; i++) {
        cur += diff[i];
        ans = max(ans, cur);
    }
    cout << ans << "\n";
}

复杂度: O(N + M)。


题目 3.8.4 — 奶牛摄影 🔴 困难 找与所有 N 个列表(每个都是 ID 的一个排列)一致的顺序。

✅ 完整题解

核心思路: 对每对 (a, b),统计 a 在多少个列表中排在 b 之前。若 a 在超过一半的列表中排在 b 前面,则 a 在真实顺序中排在 b 前面。用这个成对比较来排序。

#include <bits/stdc++.h>
using namespace std;
int main() {
    int n, k; cin >> n >> k;
    vector<vector<int>> pos(k, vector<int>(n+1));
    for (int i = 0; i < k; i++)
        for (int j = 0; j < n; j++) {
            int x; cin >> x; pos[i][x] = j;
        }
    vector<int> cows(n); iota(cows.begin(), cows.end(), 1);
    sort(cows.begin(), cows.end(), [&](int a, int b){
        int before = 0;
        for (int i = 0; i < k; i++) before += (pos[i][a] < pos[i][b]);
        return before > k / 2;
    });
    for (int c : cows) cout << c << "\n";
}

题目 3.8.5 — 实时不同值计数 🟢 简单 每读入一个新整数后,打印到目前为止见过的不同值的个数。

✅ 完整题解
#include <bits/stdc++.h>
using namespace std;
int main() {
    int n; cin >> n;
    unordered_set<int> seen;
    for (int i = 0; i < n; i++) {
        int x; cin >> x;
        seen.insert(x);
        cout << seen.size() << "\n";
    }
}

复杂度: O(N) 均摊。

📖 第 3.5b 章:二分答案

⏱ 预计阅读时间:40 分钟 | 难度:🟡 中等


前置条件

在学习本章之前,请确保你已掌握:

  • 数组与循环(第 2.2 章)
  • lower_bound / upper_bound 的基本用法(第 3.3 章)

🎯 学习目标

学完本章后,你将能够:

  1. 识别「可以用二分答案」的题目特征(三要素)
  2. 区分「找第一个满足条件」和「找最后一个满足条件」两种模板
  3. 设计正确的 check 函数
  4. 处理浮点二分和三分法求极值
  5. 避免整数二分中的边界死循环

3.5b.1 什么是二分答案?

从枚举到二分

考虑这个问题:

你有 N 根木头,每根长度不同。需要切出 K 根长度相等(都为 x)的木棍,问 x 的最大值是多少?

朴素思路: 枚举所有可能的 x,检查是否满足条件。

  • 若 x 能切出 ≥ K 根 → 合法
  • 若 x 太大,切不出 → 不合法

这需要 O(10^9) 次检查,太慢了。

二分答案的洞察:
如果 x = 5 可以切出 ≥ K 根,那 x = 4 肯定也可以(每根切的数量只会更多)。
也就是说,满足条件的 x 构成一个连续区间 [0, 某个上界]。
我们可以对这个区间二分,每次检查中点是否合法。


3.5b.2 二分答案的三要素

使用二分答案必须满足以下三个条件:

条件说明验证方法
答案在固定区间内能确定答案的上下界分析题目数据范围
check 函数易于实现给定一个答案值,能快速判断是否合法通常用贪心或 O(N) 扫描
单调性若 x 合法,则更小(或更大)的值也合法手动举反例验证

单调性的两种形式

形式 1:「最大值最小化」
合法区间在左侧:0 0 0 [1 1 1 1 1]
→ 找最后一个合法值(最右侧的 1)

形式 2:「最小值最大化」
合法区间在右侧:[1 1 1 1 1] 0 0 0
→ 找第一个合法值(最左侧的 1)


3.5b.3 整数二分模板

模板 A:找满足条件的最大值(最大化)

📄 查看代码:模板 A:找满足条件的最大值(最大化)
// 找最大的 x,使 check(x) == true
// 前提:check 形如 [true...true, false...false]

bool check(int x) {
    // 判断 x 是否满足条件
    // ...
}

int binary_search_max() {
    int lo = 可能的最小值;
    int hi = 可能的最大值 + 1;  // hi 是第一个不合法的值
    
    while (lo + 1 < hi) {
        int mid = lo + (hi - lo) / 2;  // 防溢出写法
        if (check(mid))
            lo = mid;   // mid 合法,尝试更大的
        else
            hi = mid;   // mid 不合法,答案在左侧
    }
    
    return lo;  // lo 是最大的合法值
}

模板 B:找满足条件的最小值(最小化)

📄 查看代码:模板 B:找满足条件的最小值(最小化)
// 找最小的 x,使 check(x) == true
// 前提:check 形如 [false...false, true...true]

int binary_search_min() {
    int lo = 可能的最小值 - 1;  // lo 是第一个不合法的值
    int hi = 可能的最大值;
    
    while (lo + 1 < hi) {
        int mid = lo + (hi - lo) / 2;
        if (check(mid))
            hi = mid;   // mid 合法,尝试更小的
        else
            lo = mid;   // mid 不合法,答案在右侧
    }
    
    return hi;  // hi 是最小的合法值
}

为什么用 lo + 1 < hi 而不是 lo < hi

📄 查看代码:为什么用 lo + 1 < hi 而不是 lo < hi?
终止条件分析:
  循环条件 lo + 1 < hi,即 lo 和 hi 不相邻时继续
  
  终止时:lo + 1 == hi
  此时 lo 是最大的合法值,hi 是最小的不合法值
  
  若用 lo < hi,则 mid = (lo+hi)/2 = lo,会死循环!
  
示例:lo=4, hi=5
  mid = 4 + (5-4)/2 = 4
  若 check(4)=true,lo = mid = 4(没有变化!死循环)

3.5b.4 完整例题:切木头

有 N 根木头,长度为 a[1..N],需要切出 K 根长度为 H 的木棍。
每切一根,从长度 L > H 的木头中截取一段 H,剩余部分保留。
求 H 的最大值(整数)。

分析

  • 答案范围: H ∈ [1, max(a)]
  • check(H): 扫描所有木头,统计能切出多少根长 H 的木棍,是否 ≥ K
  • 单调性: H 越大,切出的根数越少(check 从 true 变 false),满足最大化模式
📄 C++ 完整代码
#include <bits/stdc++.h>
using namespace std;

int n, k;
vector<int> a;

// 检查高度 H 能否切出 >= k 根
bool check(long long H) {
    long long total = 0;
    for (int x : a) {
        total += x / H;  // 每根木头能切出几段
    }
    return total >= k;
}

int main() {
    cin >> n >> k;
    a.resize(n);
    for (int& x : a) cin >> x;
    
    long long lo = 1, hi = *max_element(a.begin(), a.end()) + 1;
    
    while (lo + 1 < hi) {
        long long mid = lo + (hi - lo) / 2;
        if (check(mid))
            lo = mid;   // 可以切出够多,尝试更高
        else
            hi = mid;   // 切得太短,上界降低
    }
    
    cout << lo << endl;  // 最大的合法高度
    return 0;
}

追踪示例:
木头长度 = [8, 5, 3, 2],K = 5

lo=1, hi=9

mid=5:check(5) = 8/5+5/5+3/5+2/5 = 1+1+0+0 = 2 < 5 → false,hi=5
mid=3:check(3) = 8/3+5/3+3/3+2/3 = 2+1+1+0 = 4 < 5 → false,hi=3
mid=2:check(2) = 8/2+5/2+3/2+2/2 = 4+2+1+1 = 8 >= 5 → true,lo=2

lo+1 = 3 = hi,循环结束

答案:lo = 2

3.5b.5 例题:最大化最小值(安置奶牛)

USACO 经典题:N 个牛栏位于 1D 数轴上(坐标已知),放 C 头奶牛,
使得相邻奶牛之间的最小距离最大,求这个最大值。

分析

  • 答案范围: [1, max_coord - min_coord]
  • check(D): 贪心地从左到右放置奶牛,若相邻奶牛距离 ≥ D,则放下一头
  • 单调性: D 越大越难满足(最小化模式的反向)
📄 C++ 完整代码
#include <bits/stdc++.h>
using namespace std;

int n, c;
vector<int> pos;

// 检查:最小距离至少为 D,能否放 >= c 头奶牛?
bool check(int D) {
    int cows = 1;          // 第一头放在最左边
    int last = pos[0];     // 上一头奶牛的位置
    
    for (int i = 1; i < n; i++) {
        if (pos[i] - last >= D) {
            // 距离足够,放一头奶牛
            cows++;
            last = pos[i];
            if (cows >= c) return true;
        }
    }
    return cows >= c;
}

int main() {
    cin >> n >> c;
    pos.resize(n);
    for (int& x : pos) cin >> x;
    sort(pos.begin(), pos.end());   // 按位置排序
    
    int lo = 0, hi = pos.back() - pos.front() + 1;
    
    while (lo + 1 < hi) {
        int mid = lo + (hi - lo) / 2;
        if (check(mid))
            lo = mid;   // 间距 mid 可行,尝试更大
        else
            hi = mid;
    }
    
    cout << lo << endl;
    return 0;
}

3.5b.6 浮点二分

当答案是实数时,用「循环足够多次」代替「两点相邻」:

// 浮点二分(精度 1e-7)
double lo = 0.0, hi = 1e9;
for (int iter = 0; iter < 100; iter++) {  // 100次足够精确到 1e-30
    double mid = (lo + hi) / 2.0;
    if (check(mid))
        lo = mid;
    else
        hi = mid;
}
// (lo + hi) / 2 即为答案,精度约 1e-30

也可以用 while (hi - lo > 1e-7) 作终止条件,但循环次数不固定(约 50 次)。

注意: 浮点比较需留意精度,check 中不要用 ==


3.5b.7 三分法(求单峰函数极值)

当需要求「单峰函数」的最大/最小值时,使用三分法

单峰函数: 存在一个极值点,左侧单调递增,右侧单调递减(或相反)。

f(x):  2 4 7 9 8 6 3
           ↑
          极值点(峰值 = 9)

三分法思路

在区间 [lo, hi] 中取两个三等分点 lmidrmid

  • f(lmid) < f(rmid):极值点在 lmid 右侧,lo = lmid
  • f(lmid) > f(rmid):极值点在 rmid 左侧,hi = rmid
📄 C++ 完整代码
// 三分法(浮点版,求单峰函数最大值点)
double ternary_search(double lo, double hi) {
    for (int iter = 0; iter < 200; iter++) {
        double m1 = lo + (hi - lo) / 3.0;
        double m2 = hi - (hi - lo) / 3.0;
        if (f(m1) < f(m2))
            lo = m1;
        else
            hi = m2;
    }
    return (lo + hi) / 2.0;
}

// 三分法(整数版,数组中的单峰)
int ternary_search_int(vector<int>& a) {
    int lo = 0, hi = a.size() - 1;
    while (hi - lo > 2) {
        int m1 = lo + (hi - lo) / 3;
        int m2 = hi - (hi - lo) / 3;
        if (a[m1] < a[m2])
            lo = m1;
        else
            hi = m2;
    }
    // 在 [lo, hi] 中线性查找最大值
    int best = lo;
    for (int i = lo + 1; i <= hi; i++)
        if (a[i] > a[best]) best = i;
    return best;
}

3.5b.8 二分答案 vs 二分查找

对比项二分查找(Binary Search)二分答案(Binary on Answer)
作用在有序数组中找特定值在答案空间中枚举猜测值
数据结构需要有序数组不需要数组,只需 check 函数
check数组下标的比较通常是 O(N) 或 O(N log N) 的验证
常见题型lower_bound, upper_bound最大化最小值、最小化最大值
总复杂度O(log N)O(log(答案范围) × check 复杂度)

⚠️ 常见错误

错误原因修复方案
死循环while(lo < hi) + lo = mid,当 lo+1==himid = lo 死循环改用 while(lo + 1 < hi)
答案差 1hi 设置为 max_val 而非 max_val + 1hi = 答案上界 + 1(左闭右开)
check 溢出check 中累加超过 int 范围使用 long long
浮点精度不足迭代次数太少设为 100 次,不必再少
三分法用于非单峰函数只有单峰函数才能三分先确认函数的单峰性质

💪 练习题(共 8 道,全部含完整解答)

🟢 基础练习(1~3)

题目 1:切绳子(浮点二分)
N 根绳子,长度已知,要切出 K 根相同长度的绳子,求最大长度(精确到 0.01m)。

示例:

N=4, K=11
绳子长度:8.02 7.43 4.57 5.39
答案:2.00
✅ 完整解答
#include <bits/stdc++.h>
using namespace std;

int n, k;
vector<double> a;

bool check(double len) {
    int total = 0;
    for (double x : a) total += (int)(x / len);
    return total >= k;
}

int main() {
    cin >> n >> k;
    a.resize(n);
    for (double& x : a) cin >> x;
    
    double lo = 0, hi = *max_element(a.begin(), a.end());
    for (int iter = 0; iter < 100; iter++) {
        double mid = (lo + hi) / 2.0;
        if (check(mid)) lo = mid;
        else hi = mid;
    }
    
    // 保留两位小数
    printf("%.2f\n", lo);
    return 0;
}

关键: 浮点二分用「循环固定次数(100次)」而非「精度判断」,避免精度问题导致死循环。100 次后误差约 (hi-lo)/2^100,远小于题目要求。


题目 2:数组分组(最大值最小化)
将长度 N 的数组分为恰好 K 组连续子数组,使各组元素之和的最大值最小,输出该最小最大值。

示例:

N=5, K=3, 数组=[1,2,3,4,5]
最优分法:[1,2],[3],[4,5] → 最大组和 = max(3,3,9)=9?
不对,试 [1,2,3],[4],[5] → max(6,4,5)=6 ✓
输出:6
✅ 完整解答
#include <bits/stdc++.h>
using namespace std;

int n, k;
vector<long long> a;

bool check(long long maxSum) {
    for (long long x : a) if (x > maxSum) return false;  // 单元素超限
    int groups = 1; long long cur = 0;
    for (long long x : a) {
        if (cur + x > maxSum) { groups++; cur = x; }
        else cur += x;
    }
    return groups <= k;
}

int main() {
    cin >> n >> k;
    a.resize(n);
    long long total = 0;
    for (long long& x : a) { cin >> x; total += x; }
    
    long long lo = *max_element(a.begin(), a.end()) - 1;
    long long hi = total + 1;
    while (lo + 1 < hi) {
        long long mid = lo + (hi - lo) / 2;
        if (check(mid)) hi = mid;
        else lo = mid;
    }
    cout << hi << "\n";
    return 0;
}

追踪(a=[1,2,3,4,5], k=3):

lo=4(max元素-1), hi=16(总和+1)
mid=10 → check: [1..5]=15>10→[1,2,3,4]=10✓,[5] → 2组≤3 ✓ → hi=10
mid= 7 → check: [1,2,3]=6,[4]=4,[5]=5 → 3组≤3 ✓ → hi=7
mid= 5 → check: [1,2]=3,[3]=3,[4]=4→加5=9>5→新组[5] → 4组>3 ✗ → lo=5
mid= 6 → check: [1,2,3]=6,[4]=4,[5]=5 → 3组≤3 ✓ → hi=6

lo+1=6=hi,输出 hi=6 ✓

题目 3:查找有序矩阵第 K 小元素
N×N 的矩阵,每行每列均递增。找第 K 小的元素。

提示: 对答案二分,check(x) = 矩阵中 ≤ x 的元素个数 ≥ K。

✅ 完整解答
#include <bits/stdc++.h>
using namespace std;

int n, k;
vector<vector<int>> mat;

// 统计矩阵中 <= x 的元素个数
// 利用行有序性:每行二分
int count_le(int x) {
    int cnt = 0;
    for (auto& row : mat) {
        // upper_bound 返回第一个 > x 的迭代器
        cnt += (int)(upper_bound(row.begin(), row.end(), x) - row.begin());
    }
    return cnt;
}

int main() {
    cin >> n >> k;
    mat.assign(n, vector<int>(n));
    for (auto& row : mat) for (int& x : row) cin >> x;
    
    int lo = mat[0][0] - 1;
    int hi = mat[n-1][n-1];
    
    while (lo + 1 < hi) {
        int mid = lo + (hi - lo) / 2;
        if (count_le(mid) >= k) hi = mid;  // mid 可能是答案,尝试更小
        else lo = mid;
    }
    
    cout << hi << "\n";  // 最小的使 count_le >= k 的值
    return 0;
}

复杂度: O(N log N log(max-min)),N=300, max=10^9 时约 300×8×30 ≈ 72000 次操作,极快。


🟡 进阶练习(4~6)

题目 4:安置奶牛(最大化最小值)
N 个牛栏分布在数轴上(坐标已知),放 C 头奶牛,使任意两头奶牛之间的距离尽可能大
求最大的「最小相邻距离」。

✅ 完整解答
#include <bits/stdc++.h>
using namespace std;

int n, c;
vector<int> pos;

bool check(int D) {
    int cows = 1, last = pos[0];
    for (int i = 1; i < n; i++) {
        if (pos[i] - last >= D) { cows++; last = pos[i]; }
        if (cows >= c) return true;
    }
    return cows >= c;
}

int main() {
    cin >> n >> c;
    pos.resize(n);
    for (int& x : pos) cin >> x;
    sort(pos.begin(), pos.end());
    
    int lo = 0, hi = pos.back() - pos.front() + 1;
    while (lo + 1 < hi) {
        int mid = lo + (hi - lo) / 2;
        if (check(mid)) lo = mid;
        else hi = mid;
    }
    cout << lo << "\n";
    return 0;
}

追踪(pos=[1,2,4,8,9], c=3):

排序后:[1,2,4,8,9],lo=0,hi=9

mid=4:check(4):1放1,下一个≥1+4=5→放8,下一个≥8+4=12>9→只放2头 < 3 ✗ → lo=4
mid=6:check(6):放1,≥7→放8,≥14>9→2头 < 3 ✗ → lo=6(⚠️等等)

重新算:
mid=4:1(place)→2(dist=1<4跳)→4(dist=3<4跳)→8(dist=4✓,place)→9(dist=1<4跳) → 2头 < 3 ✗ → lo=4?

不对,重新算 c=3:
mid=3:1(place)→2(1<3)→4(3✓,place)→8(4✓,place) → 3头 ≥ 3 ✓ → lo=3
mid=4:1(place)→4(3<4)→8(4✓,place)→9(1<4) → 2头 < 3 ✗ → hi=4

lo+1=4=hi,答案 lo=3

题目 5:跳石头(NOIP 2015)
河中有 N 块石头,起点到终点距离 L。可以移除最多 M 块石头,使最短跳跃距离最大。
求这个最大的「最短跳跃距离」。

✅ 完整解答
#include <bits/stdc++.h>
using namespace std;

int l, n, m;
vector<int> pos;  // 包含起点0和终点l

bool check(int minDist) {
    // 统计需要移除多少块石头,使所有相邻距离 >= minDist
    int removed = 0, prev = 0;
    for (int i = 1; i < (int)pos.size(); i++) {
        if (pos[i] - prev < minDist) removed++;  // 这块石头太近,移除
        else prev = pos[i];
    }
    return removed <= m;
}

int main() {
    cin >> l >> n >> m;
    pos.push_back(0);  // 起点
    for (int i = 0; i < n; i++) {
        int x; cin >> x;
        pos.push_back(x);
    }
    pos.push_back(l);  // 终点
    sort(pos.begin(), pos.end());
    
    int lo = 0, hi = l + 1;
    while (lo + 1 < hi) {
        int mid = lo + (hi - lo) / 2;
        if (check(mid)) lo = mid;
        else hi = mid;
    }
    cout << lo << "\n";
    return 0;
}

关键: check(D) 采用贪心——从左到右扫描,遇到距离 < D 的石头就移除(因为移除它一定不差于移除其他石头)。统计移除数量是否 ≤ M。


题目 6:最优比率(分数规划)
给定 N 个任务,每个任务有完成时间 t[i] 和价值 v[i]
选恰好 K 个任务,最大化 总价值 / 总时间。输出最大比率(精度 1e-6)。

✅ 完整解答

核心转化(0-1 分数规划):
二分比率 λ,检查是否存在选 K 个任务使得 Σv[i] / Σt[i] ≥ λ
等价于 Σ(v[i] - λ*t[i]) ≥ 0
w[i] = v[i] - λ*t[i] 取最大的 K 个,若其和 ≥ 0,则 λ 可行。

#include <bits/stdc++.h>
using namespace std;

int n, k;
vector<double> t_arr, v_arr;

bool check(double lam) {
    // 计算每个任务的 "净收益" w[i] = v[i] - lam * t[i]
    vector<double> w(n);
    for (int i = 0; i < n; i++) w[i] = v_arr[i] - lam * t_arr[i];
    
    // 取最大的 k 个净收益之和
    sort(w.begin(), w.end(), greater<double>());
    double sum = 0;
    for (int i = 0; i < k; i++) sum += w[i];
    
    return sum >= 0;  // 能否达到比率 lam?
}

int main() {
    cin >> n >> k;
    t_arr.resize(n); v_arr.resize(n);
    for (int i = 0; i < n; i++) cin >> t_arr[i] >> v_arr[i];
    
    double lo = 0, hi = 1e6;  // 比率的上下界
    for (int iter = 0; iter < 100; iter++) {
        double mid = (lo + hi) / 2.0;
        if (check(mid)) lo = mid;
        else hi = mid;
    }
    
    printf("%.6f\n", lo);
    return 0;
}

为什么这样转化正确?
若最优比率为 λ*,则对任意 λ < λ*,都能选到 K 个任务满足 Σ(v[i]-λt[i]) ≥ 0
对任意 λ > λ*,不可能满足。这正是「单调性」的体现。


🔴 挑战练习(7~8)

题目 7:最小化最大花费(二分 + 图)
N 个城市,M 条带权无向边。需要选一些边,连通所有城市,使最大边权最小。
(即最小生成树的最大边权最小化。)

✅ 完整解答

二分视角: 二分「允许的最大边权」= W,check(W) = 只用权重 ≤ W 的边,是否能连通所有城市。
等价于:Kruskal 算法跑到权重 W 时,是否已经形成生成树。

#include <bits/stdc++.h>
using namespace std;

struct DSU {
    vector<int> pa, sz;
    explicit DSU(int n) : pa(n + 1), sz(n + 1, 1) { iota(pa.begin(), pa.end(), 0); }
    int find(int x) { return pa[x] == x ? x : pa[x] = find(pa[x]); }
    bool unite(int x, int y) {
        x = find(x); y = find(y);
        if (x == y) return false;
        if (sz[x] < sz[y]) swap(x, y);
        pa[y] = x; sz[x] += sz[y];
        return true;
    }
};

int n, m;
vector<tuple<int,int,int>> edges;  // {w, u, v}

bool check(int maxW) {
    DSU dsu(n);
    int cnt = 0;
    for (auto& [w, u, v] : edges) {
        if (w > maxW) break;
        if (dsu.unite(u, v)) cnt++;
    }
    return cnt == n - 1;
}

int main() {
    cin >> n >> m;
    for (int i = 0; i < m; i++) {
        int u, v, w; cin >> u >> v >> w;
        edges.push_back({w, u, v});
    }
    sort(edges.begin(), edges.end());
    
    int lo = 0, hi = get<0>(edges.back()) + 1;
    while (lo + 1 < hi) {
        int mid = lo + (hi - lo) / 2;
        if (check(mid)) hi = mid;
        else lo = mid;
    }
    
    cout << (check(hi) ? hi : -1) << "\n";  // -1 表示无法连通
    return 0;
}

注: 实际上这等价于直接跑 Kruskal 的最大边权,无需二分——但本题作为二分思维的练习,两种做法均正确。


题目 8:最小化等待时间(二分 + 贪心)
N 个顾客到达超市,每人需要服务时间 s[i],超市有 K 个服务台。
每个服务台每次服务一人,服务完后立即接待下一人。
合理安排顾客顺序,使所有顾客等待时间之和最小

提示: 先证明「等待时间之和最小」等价于「将服务时间最短的顾客优先」,然后贪心排序直接求答案(无需二分)。
挑战版:若每个顾客有到达时刻限制 a[i],需要对答案二分「最大等待时间」。

✅ 完整解答(贪心版)

证明: 若服务台同时开始,将顾客按服务时间升序排列,使等待时间之和最小(SPT规则)。

K 个服务台版本(贪心 + 优先队列):
维护 K 个服务台的当前空闲时刻,每次将下一位顾客分配到最早空闲的服务台。

#include <bits/stdc++.h>
using namespace std;

int main() {
    int n, k;
    cin >> n >> k;
    vector<int> s(n);
    for (int& x : s) cin >> x;
    sort(s.begin(), s.end());  // 按服务时间升序(SPT 规则)
    
    // K 个服务台的空闲时刻(最小堆)
    priority_queue<long long, vector<long long>, greater<long long>> free_time;
    for (int i = 0; i < k; i++) free_time.push(0);
    
    long long total_wait = 0;
    for (int i = 0; i < n; i++) {
        long long earliest = free_time.top(); free_time.pop();
        total_wait += earliest;  // 顾客 i 的等待时间 = 服务台空闲时刻
        free_time.push(earliest + s[i]);
    }
    
    cout << total_wait << "\n";
    return 0;
}

示例(n=4, k=2, s=[3,1,4,2]):

排序后 s = [1,2,3,4]
服务台:[0,0](初始空闲时刻)

顾客0(s=1):分配到台0,台0变为0+1=1,wait=0
服务台:[0,1]
顾客1(s=2):分配到台0(最早空闲),台0变为0+2=2,wait=0
服务台:[1,2]
顾客2(s=3):分配到台0(空闲时=1),wait=1,台变为1+3=4
服务台:[2,4]
顾客3(s=4):分配到台0(空闲时=2),wait=2,台变为2+4=6

总等待时间 = 0+0+1+2 = 3

💡 章节联系: 二分答案是竞赛中最实用的思想之一,贯穿 USACO Bronze→Gold 的所有级别。它通常与贪心(check 函数的核心工具)和前缀和(快速验证)结合使用,是「思想轻巧、代码简洁」的典范。

📖 第 3.12 章:字符串算法

⏱ 预计阅读时间:60 分钟 | 难度:🟡 中等


前置条件

在学习本章之前,请确保你已掌握:

  • 数组的基本操作(第 3.1 章)
  • string 类型的使用(第 2.3 章)

🎯 学习目标

学完本章后,你将能够:

  1. 理解字符串匹配的朴素算法并分析其效率瓶颈
  2. 掌握 KMP 算法的核心思想——「前缀函数」的构建
  3. 用 KMP 在 O(N + M) 时间内解决字符串匹配问题
  4. 理解 Trie 树(字典树)的结构与操作
  5. 运用 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)小数据,代码简单
KMPO(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. 能找到反例吗? 试一些贪心可能失败的小例子。

若能回答 (1) 和 (2) 且对 (3) 找不到反例,你的贪心很可能是正确的。


本部分学习建议

  1. 贪心最难「验证」。 不像 DP 只需要正确的递推式,贪心需要正确性论证。多练习交换论证证明的草稿。
  2. 贪心失败时,DP 通常是修复方案。 硬币找零(第 4.1 章)完美地展示了这一点。
  3. 第 4.2 章有真实的 USACO 题目 —— 仔细研究代码,不只是高层次的想法。
  4. 贪心 + 二分搜索(第 4.2 章)是频繁出现在 Silver 的强力组合。贪心解决「检查」函数,二分查找最优答案。

💡 核心思路: 排序是大多数贪心算法的引擎,排序标准体现了「贪心选择」——优先选最好的元素。交换论证证明了这个标准是最优的。

🏆 USACO 技巧: USACO Silver 中,若题目问「在约束 Y 下最大化 X」或「达成 Z 的最小代价」,先尝试带贪心检查的二分答案。这个组合解决了相当大比例的 Silver 题目。

📖 第 4.1 章 ⏱️ 约 120 分钟 🎯 中级

第 4.1 章:贪心基础

📝 前置条件: 熟悉排序(第 3.3 章)和基本的 priority_queue 用法(第 3.1 章)。部分题目还涉及区间推理。

贪心算法就像一个旅行者,永远选择最近的绿洲——没有地图,没有计划,只看当下最好的移动。对于合适的问题,这总是奏效的;对于其他问题,它会带来灾难。


📚 目录

小节主题难度
§4.1.1什么样的问题可以用贪心解决?🟢 基础
§4.1.2交换论证法(证明技巧)🟡 核心
§4.1.3活动选择问题🟡 核心
§4.1.4区间调度:最大化 vs 最小化变体🟡 核心
§4.1.5调度问题:最小化最大延迟(EDF)🟡 核心
§4.1.6Huffman 编码——贪心建树🟡 核心
§4.1.7排列贪心:自定义排序标准🟡 核心
§4.1.8任务分配:双序列匹配🟡 核心
§4.1.9区间合并🟢 标准
§4.1.10数字与字符串的贪心🟡 标准
§4.1.11后悔贪心(用堆实现撤销)🔴 进阶
§4.1.12对抗匹配(田忌赛马)🔴 进阶
§4.1.13前缀/后缀贪心与位运算贪心🔴 进阶
练习题5 道练习题 + 1 道挑战题🟡–🔴

💡 建议阅读路径: 初次阅读应按顺序学习 §4.1.1–4.1.5;§4.1.6–4.1.9 可以任意顺序阅读;§4.1.11–4.1.13 是 USACO Gold 及以上的进阶技术。


4.1.1 什么样的问题可以用贪心解决?

贪心方法在问题具有贪心选择性质时奏效:每一步做出局部最优选择,最终得到全局最优解。

与 DP 的对比

考虑用硬币凑出 11 分:

  • 硬币:{1, 5, 6, 9}
  • 贪心:9 + 1 + 1 = 3 枚
  • 最优:6 + 5 = 2 枚

这里贪心失败了。贪心选择(每次选最大的硬币)没有达到全局最优。

但对于美国硬币 {1, 5, 10, 25, 50}:

  • 41 分:贪心 → 25 + 10 + 5 + 1 = 4 枚 ✓(最优)

美国硬币有特殊结构使贪心可行。始终要验证!

完整演示:硬币找零——贪心 vs DP

让我们详细追踪硬币找零例子,看清楚贪心在哪里出错。

题目: 用硬币 {1, 5, 6, 9} 凑出 11 分,最少需要几枚?

贪心做法(每次选 ≤ 剩余金额的最大硬币):

剩余=11 → 选 9(≤11 最大)。剩余=2。已用:[9]
剩余=2  → 选 1(≤2 最大)。剩余=1。已用:[9, 1]
剩余=1  → 选 1(≤1 最大)。剩余=0。已用:[9, 1, 1]
结果:3 枚 ✗

最优(DP)做法:

6 + 5 = 11。已用:[6, 5]
结果:2 枚 ✓

为什么贪心失败了? 贪心立即抓取最大的硬币(9),留下的余数(2)只能用 1 分硬币填满。它「看不出来」跳过 9 用 6+5 会更好。

Coin Change: Greedy vs Optimal

现在对比用美国硬币 {1, 5, 10, 25} 凑 41 分:

剩余=41 → 选 25。剩余=16。已用:[25]
剩余=16 → 选 10。剩余=6。 已用:[25, 10]
剩余=6  → 选 5。 剩余=1。 已用:[25, 10, 5]
剩余=1  → 选 1。 剩余=0。 已用:[25, 10, 5, 1]
结果:4 枚 ✓(最优!)

美国硬币有效是因为每个面额至少是前一个的两倍——永远不需要「撤销」贪心选择。而 {1, 5, 6, 9} 中 5 和 6 太接近,会产生贪心选择阻碍更好组合的情况。

⚠️ 结论: 硬币找零是看起来可以用贪心但并非总是如此的经典例子。除非硬币面额有特殊结构(如美国硬币),否则需要 DP。有疑问时,试一个小反例!

💡 核心思路: 贪心在有「无悔」性质时奏效——一旦做出贪心选择,永远不需要撤销。如果总能用贪心选择替换任何非贪心选择而不使情况变差,贪心就是最优的。

贪心 vs DP 决策路径对比:

Greedy vs DP Decision Path

🔍 如何识别贪心问题

看到新题时,按以下清单检查:

📄 看到新题时,按以下清单检查:
1. 处理元素时有没有自然的「顺序」或「优先级」?
   (如:按截止时间、结束时间、比率、大小排序……)
        ↓ 有
2. 能证明局部最优选择在全局上是安全的吗?
   (交换论证:把贪心选择与任何其他选择互换,永远不会更好)
        ↓ 能
3. 能找到贪心失败的小反例吗?
        ↓ 找不到反例
   → 贪心很可能正确。实现并验证。
        ↓ 找到反例
   → 贪心失败。考虑 DP 或其他方法。

贪心可行的三个信号:

  • ① 排序后,有明确的「按此顺序处理」规则
  • ② 题目要求一遍扫描最大化/最小化计数或代价
  • ③ 子问题独立——选择一个元素不影响剩余选择的「形状」

改用 DP 的三个信号:

  • ① 选择之间有交互(选 A 会改变 B 的可用性)
  • ② 需要考虑多个未来状态
  • ③ 对你尝试的任何贪心规则都能找到反例

4.1.2 交换论证法

交换论证是贪心算法的标准证明技术,回答「怎么证明贪心正确?」这个问题。几乎所有 USACO 的贪心正确性证明都用这个技术。

工作原理

证明模板分四步:

  1. 假设存在一个在某一步做出与我们的贪心算法不同选择的最优解 O。
  2. 找到 O 和贪心第一次不同的位置。
  3. 交换——把贪心的选择放入 O 在该位置的解中。证明结果至少同样好(代价不增,或数量不减)。
  4. 重复直到 O 完全变换成贪心解。由于每次交换维持或改善解,贪心解必然是最优的。

💡 核心思路: 需要证明贪心是唯一最优的——只需证明没有交换可以改善它。即使多个解都达到相同最优,贪心也能找到其中一个。

📋 交换论证证明模板

给定: 贪心规则 G,最优解 O。

第一步——找差异: 设 i 是 O 和 G 第一个不同的下标。

第二步——交换: 构造 O',将位置 i 的 O 的选择替换为 G 的选择。

第三步——比较: 证明 cost(O') ≤ cost(O)(或 count(O') ≥ count(O))。

第四步——结论: 归纳地,反复交换把 O 变成 G 而不恶化解。因此 G 是最优的。

为什么「相邻交换」就够了

一个关键观察:若能证明交换任意两个顺序不对的相邻元素不会恶化解,那么通过标准的「冒泡排序」论证,可以将任意解重排为贪心顺序而不使情况变差。

这就是为什么交换论证几乎总是聚焦于只交换两个相邻元素——完整证明由归纳法得出。

具体示例:调度最小化加权和

题目: 有 N 个作业,作业 i 的处理时间为 t[i],权重为 w[i]。所有作业在一台机器上顺序运行。作业 i 的加权完成时间 = w[i] × (包含作业 i 在内的所有作业处理时间之和)。最小化总加权完成时间。

样例输入:

3
2 3
1 5
4 2

(格式:每行 t[i] w[i])

样例输出:

37

什么顺序最优? 用交换论证:

考虑两个相邻作业 A(处理时间 a,权重 w_A)和 B(处理时间 b,权重 w_B),设 S 是这两个作业之前所有作业的总处理时间:

顺序A 的加权代价B 的加权代价这两个作业的总代价
A → Bw_A × (S + a)w_B × (S + a + b)w_A·a + w_B·b + (w_A + w_B)·S + w_B·a
B → Aw_B × (S + b)w_A × (S + b + a)w_B·b + w_A·a + (w_A + w_B)·S + w_A·b

A → B 更好当:w_B·a < w_A·b,即 w_A/t_A > w_B/t_B权重/时间比更高的先做)。

贪心规则:w[i]/t[i] 降序排序。

📄 C++ 完整代码
#include <bits/stdc++.h>
using namespace std;

int main() {
    ios_base::sync_with_stdio(false);
    cin.tie(NULL);

    int n;
    cin >> n;

    vector<pair<int,int>> jobs(n);  // {t, w}
    for (auto &[t, w] : jobs) cin >> t >> w;

    // 按 w/t 比值降序排序(比值更高的先做)
    sort(jobs.begin(), jobs.end(), [](const auto &a, const auto &b) {
        // a.second/a.first > b.second/b.first  →  a.second * b.first > b.second * a.first
        return (long long)a.second * b.first > (long long)b.second * a.first;
    });

    long long total = 0, curTime = 0;
    for (auto [t, w] : jobs) {
        curTime += t;
        total += (long long)w * curTime;
    }

    cout << total << "\n";
    return 0;
}
// 时间复杂度:O(N log N)

图示:贪心交换论证

Greedy Exchange Argument

上图说明了交换论证:若两个相邻元素相对于贪心标准「顺序不对」,交换它们会产生至少同样好的解。通过反复交换,可以把任何解变换成贪心解而不损失价值。

交换论证失败的情况

有时找不到有效的交换——这说明贪心不适用:

  • 0/1 背包: 无法用一件物品的一部分替换另一件物品,所以交换不保持约束。
  • 任意面额的硬币找零: 交换硬币选择实际上可能在其他位置强制使用更多硬币。
  • 一般加权区间调度: 选择一个高利润的短作业可能阻塞两个中等利润但合计超过它的作业。

在所有这些情况下,交换论证失败,需要用 DP


4.1.3 活动选择问题

题目: 给定 N 个活动,每个活动有开始时间 s[i] 和结束时间 f[i]。每次只能进行一个活动。若两个活动有重叠(一个在另一个结束前开始),则它们冲突。选择最多数量的不重叠活动

样例输入:

6
1 3
2 5
3 9
6 8
5 7
8 11

样例输出:

3

(最优选择是活动 (1,3)、(6,8)、(8,11)——或等价地 (1,3)、(5,7)、(8,11))


为什么可以用贪心解决

直觉上:在所有从上一个选定活动之后开始的活动中,接下来该选哪个?结束最早的那个——它「占用」最少的未来时间,为后续活动留下最大空间。

任何其他选择(选结束更晚的活动)只会有害:它阻塞的未来活动至少与结束最早的选择一样多,甚至更多。

这就是贪心选择性质: 局部最优选择(选结束最早的相容活动)导致全局最优解。

图示:活动选择甘特图

Activity Selection

甘特图在时间轴上展示所有活动。选中的活动(绿色)不重叠且数量最多,被拒绝的活动(灰色)因与已选活动重叠而跳过。贪心规则是:始终选结束时间最早且不冲突的活动。

贪心算法:

  1. 结束时间排序活动
  2. 每次选择与已选活动相容且结束时间最早的活动

Activity Selection Greedy Process

💡 为什么按结束时间排序? 选择结束最早的活动为后续活动留下最多时间。按开始时间排序可能选到开始很早但结束很晚的活动,占用大量时间。

📄 C++ 完整代码
// 活动选择 — O(N log N)
#include <bits/stdc++.h>
using namespace std;

int main() {
    ios_base::sync_with_stdio(false);
    cin.tie(NULL);

    int n;
    cin >> n;

    vector<pair<int,int>> activities(n);  // {结束时间, 开始时间}
    for (int i = 0; i < n; i++) {
        int s, f;
        cin >> s >> f;
        activities[i] = {f, s};  // 按结束时间排序
    }

    sort(activities.begin(), activities.end());  // ← 关键:按结束时间排序

    int count = 0;
    int lastEnd = -1;  // 最后一个选定活动的结束时间

    for (auto [f, s] : activities) {
        if (s >= lastEnd) {      // 该活动在上一个结束后才开始
            count++;
            lastEnd = f;         // 更新最后结束时间
        }
    }

    cout << count << "\n";
    return 0;
}

完整演示

活动: [(1,3), (2,5), (3,9), (6,8), (5,7), (8,11), (10,12)](格式:开始, 结束)

第一步——按结束时间排序:

活动:   A      B      C      D      E      F      G
(s,e):(1,3)  (2,5)  (5,7)  (6,8)  (3,9)  (8,11) (10,12)

排序后:A(1,3), B(2,5), C(5,7), D(6,8), E(3,9), F(8,11), G(10,12)

第二步——贪心选择(初始 lastEnd = -1):

活动 A (1,3):  start=1 ≥ lastEnd=-1 ✓ 选中。lastEnd=3。count=1
活动 B (2,5):  start=2 ≥ lastEnd=3?否(2 < 3)。跳过。
活动 C (5,7):  start=5 ≥ lastEnd=3 ✓ 选中。lastEnd=7。count=2
活动 D (6,8):  start=6 ≥ lastEnd=7?否(6 < 7)。跳过。
活动 E (3,9):  start=3 ≥ lastEnd=7?否(3 < 7)。跳过。
活动 F (8,11):start=8 ≥ lastEnd=7 ✓ 选中。lastEnd=11。count=3
活动 G (10,12):start=10 ≥ lastEnd=11?否(10 < 11)。跳过。

结果:选中 3 个活动——A(1,3), C(5,7), F(8,11)

时间轴(A~G 表示活动):
时间:  0  1  2  3  4  5  6  7  8  9  10 11 12
       |  |  |  |  |  |  |  |  |  |  |  |  |
A:       [===]                                   ✓ 选中
B:          [======]                             ✗ 与 A 重叠
C:                  [======]                     ✓ 选中
D:                     [======]                  ✗ 与 C 重叠
E:             [============]                    ✗ 与 A 和 C 重叠
F:                           [======]            ✓ 选中
G:                              [======]         ✗ 与 F 重叠

4.1.4 区间调度:最大化 vs 最小化变体

本节涵盖三个相关的区间问题,看起来相似但需要微妙不同的贪心策略。

图示:数轴上的区间调度

Interval Scheduling


最大化:最多不重叠区间

这正是 §4.1.3 的活动选择问题。按结束时间排序,贪心选择如上所述。


最小化:「刺穿」所有区间所需最少点数

题目: 给定数轴上 N 个区间,找最少数量的「点」(每个点是一个实数),使每个区间至少包含一个点。仅共享端点的两个区间都被视为包含该端点。

贪心策略:右端点升序排序。维护 lastPoint(最后放置的点)。对每个区间:

  • lastPoint 已在该区间内(lastPoint >= l[i]):该区间已被覆盖,跳过。
  • 否则:在 r[i] 放一个新点(尽量靠右,最大化对后续区间的覆盖),计数加一。
📄 C++ 完整代码
#include <bits/stdc++.h>
using namespace std;

int main() {
    ios_base::sync_with_stdio(false);
    cin.tie(NULL);

    int n;
    cin >> n;
    vector<pair<int,int>> intervals(n);  // {右端点, 左端点}
    for (auto &[r, l] : intervals) cin >> l >> r;

    sort(intervals.begin(), intervals.end());  // 按右端点排序

    int points = 0;
    long long lastPoint = LLONG_MIN;

    for (auto [r, l] : intervals) {
        if (lastPoint < l) {          // 当前点不覆盖该区间
            lastPoint = r;            // 在右端放新点
            points++;
        }
    }

    cout << points << "\n";
    return 0;
}
// 时间复杂度:O(N log N)

最小化:最少区间覆盖一段范围

题目: 给定 N 个区间和目标范围 [0, T],从集合中选最少数量的区间使其并集完全覆盖 [0, T]。不可能时输出「Impossible」。

贪心策略:左端点升序排序。维护 covered(当前已覆盖到的位置,初始为 0)。每步在所有满足 l[i] ≤ covered 的区间中(它们可以延伸覆盖),选右端点最大的farthest)。将 covered 推进到 farthest,计数加一。

📄 C++ 完整代码
#include <bits/stdc++.h>
using namespace std;

int main() {
    ios_base::sync_with_stdio(false);
    cin.tie(NULL);

    int T, n;
    cin >> T >> n;

    vector<pair<int,int>> intervals(n);
    for (auto &[l, r] : intervals) cin >> l >> r;

    sort(intervals.begin(), intervals.end());  // 按左端点排序

    int covered = 0;    // 当前已覆盖到 'covered'
    int count = 0;
    int i = 0;

    while (covered < T) {
        int farthest = covered;

        // 在左端点 <= covered 的所有区间中,找最远的右端点
        while (i < n && intervals[i].first <= covered) {
            farthest = max(farthest, intervals[i].second);
            i++;
        }

        if (farthest == covered) {
            // 没有区间能延伸覆盖——不可能
            cout << "Impossible\n";
            return 0;
        }

        covered = farthest;
        count++;
    }

    cout << count << "\n";
    return 0;
}
// 时间复杂度:O(N log N)

⚠️ 与刺穿的关键区别: 刺穿按端点排序(尽量宽地覆盖当前区间);覆盖按端点排序(从停下的地方尽量向右延伸覆盖)。


4.1.5 调度问题:最小化最大延迟(EDF)

题目: 一台机器,N 个作业。作业 i 有:

  • 处理时间 t[i]——运行所需时长
  • 截止时间 d[i]——理想上应完成的时间

机器按顺序运行作业(无重叠,无空闲)。作业 i 的延迟量max(0, 完成时间[i] − d[i])——超出截止时间的量(按时完成则为 0)。最小化所有作业中最大延迟量

样例输入:

4
3 6
2 8
1 4
4 9

样例输出:

1

解释: 按截止时间升序排序:job3(t=1,d=4), job1(t=3,d=6), job2(t=2,d=8), job4(t=4,d=9)。

作业 3:运行 [0,1],  完成于 1。延迟量 = max(0, 1-4)  = 0
作业 1:运行 [1,4],  完成于 4。延迟量 = max(0, 4-6)  = 0
作业 2:运行 [4,6],  完成于 6。延迟量 = max(0, 6-8)  = 0
作业 4:运行 [6,10],完成于 10。延迟量 = max(0, 10-9) = 1
最大延迟量 = 1 ✓

贪心策略:最早截止日期优先(EDF)

规则: 按截止时间升序排列作业,按该顺序无间隙运行。

为什么 EDF 最优——交换论证:

设最优调度中两个相邻作业 A 和 B,d[A] > d[B](A 截止更晚但先运行)。设 S 是这两个作业之前所有作业的完成时间:

调度A 的延迟量B 的延迟量
A → Bmax(0, S + t[A] − d[A])max(0, S + t[A] + t[B] − d[B])
B → Amax(0, S + t[B] − d[B])max(0, S + t[B] + t[A] − d[A])

由于 d[A] > d[B],B 更紧迫。A→B 顺序中:B 在 S + t[A] + t[B] 完成,与 B→A 顺序相同——但 B 的截止时间更早,可能延迟更多。交换到 B→A 永远不会增加最大延迟量。因此,任何非 EDF 调度都可以通过交换改善或维持,EDF 是最优的。

EDF Scheduling — Minimize Maximum Lateness

📄 C++ 完整代码
#include <bits/stdc++.h>
using namespace std;

int main() {
    int n;
    cin >> n;

    vector<pair<int,int>> jobs(n);  // {截止时间, 处理时间}
    for (int i = 0; i < n; i++) cin >> jobs[i].second >> jobs[i].first;

    sort(jobs.begin(), jobs.end());  // 按截止时间排序

    int time = 0;
    int maxLateness = 0;

    for (auto [deadline, proc] : jobs) {
        time += proc;                          // 该作业的完成时间
        int lateness = max(0, time - deadline); // 延迟多久?
        maxLateness = max(maxLateness, lateness);
    }

    cout << maxLateness << "\n";
    return 0;
}

4.1.6 Huffman 编码(贪心建树)

题目: 有 N 个符号,每个出现频率为 freq[i]。想为每个符号分配一个二进制码字(0 和 1 的字符串),使没有码字是另一个的前缀(前缀无关码)。总编码代价 = 所有符号的 freq[i] × depth[i] 之和,其中 depth[i] 是符号 i 码字的长度。最小化总代价。

贪心规则: 始终合并频率最小的两个节点(用最小堆)。

📄 C++ 完整代码
#include <bits/stdc++.h>
using namespace std;

int main() {
    int n;
    cin >> n;

    priority_queue<long long, vector<long long>, greater<long long>> pq;  // 最小堆
    for (int i = 0; i < n; i++) {
        long long f; cin >> f;
        pq.push(f);
    }

    long long totalCost = 0;
    while (pq.size() > 1) {
        long long a = pq.top(); pq.pop();
        long long b = pq.top(); pq.pop();
        totalCost += a + b;  // 合并 a 和 b 的代价
        pq.push(a + b);      // 合并后的组频率为 a+b
    }

    cout << totalCost << "\n";
    return 0;
}

为什么总是合并最小的两个? 频率最低的两个符号应该在树中最深(码字最长),因为罕见符号的长码字对总代价的贡献较小。通过总是合并当前最小的两个,确保使用最频繁的符号保留在根附近。

USACO 中的实际应用: Huffman 算法出现在「最小代价合并 N 堆」问题中。每次需要合并两堆,支付合并后大小之和,答案就是所有合并操作的总和——由 Huffman 贪心算法计算。


4.1.7 排列贪心:自定义排序标准

经典题一:最小化总完成时间(最短作业优先)

贪心策略: 按处理时间升序排序(最短作业优先,SJF)。

为什么 SJF 最优?(交换论证)

设最优顺序中两个相邻作业 A(处理时间 a)和 B(处理时间 b),a > b(B 更短但排在 A 后面)。设 T 是这两个作业之前的累计完成时间:

顺序A 的完成时间B 的完成时间两者之和
A → BT + aT + a + b2T + 2a + b
B → AT + bT + b + a2T + a + 2b

由于 a > b,2T + 2a + b > 2T + a + 2b,所以 B→A 给出更小的和。

📄 由于 a > b,`2T + 2a + b > 2T + a + 2b`,所以 B→A 给出更小的和。
#include <bits/stdc++.h>
using namespace std;

int main() {
    ios_base::sync_with_stdio(false);
    cin.tie(NULL);

    int n;
    cin >> n;
    vector<int> t(n);
    for (int &x : t) cin >> x;

    sort(t.begin(), t.end());  // SJF:按处理时间升序

    long long totalCompletion = 0;
    long long curTime = 0;
    for (int i = 0; i < n; i++) {
        curTime += t[i];
        totalCompletion += curTime;
    }

    cout << totalCompletion << "\n";
    return 0;
}
// 时间复杂度:O(N log N)

SJF — Minimize Total Completion Time


经典题二:最大数(拼接贪心)

题目: 给定 N 个非负整数,将它们排列后拼接成最大的数。输出为字符串。

贪心策略: 自定义比较器:对两个数 a 和 b(作为字符串),若 str(a) + str(b) > str(b) + str(a) 则 a 排在 b 前面。

📄 C++ 完整代码
#include <bits/stdc++.h>
using namespace std;

int main() {
    ios_base::sync_with_stdio(false);
    cin.tie(NULL);

    int n;
    cin >> n;
    vector<string> nums(n);
    for (string &s : nums) cin >> s;

    // 自定义排序:a+b > b+a 时 a 排在前面
    sort(nums.begin(), nums.end(), [](const string &a, const string &b) {
        return a + b > b + a;
    });

    // 边界情况:全为零
    if (nums[0] == "0") {
        cout << "0\n";
        return 0;
    }

    string result = "";
    for (const string &s : nums) result += s;
    cout << result << "\n";
    return 0;
}
// 时间复杂度:O(N log N · L),L = 最大数字位数

逐步追踪(nums = ["3", "30", "34", "5", "9"]):

比较部分对:
"3"+"30"="330" vs "30"+"3"="303"  →  "3" 排前面
"9"+"5"="95"  vs "5"+"9"="59"    →  "9" 排前面
"34"+"3"="343" vs "3"+"34"="334" →  "34" 排前面

排序后:["9", "5", "34", "3", "30"]
结果:  "9534330" ✓

⚠️ 警告: 不能简单地按数值大小排序!例如 "3" > "30" 按数值,但拼接 "330" > "303",所以 "3" 应排前面。始终用拼接比较器。


经典题三:最大化(或最小化)内积

贪心规则:

  • 最大化 ∑ A[i] × B[i]:A 和 B 都按升序排序(同方向配对)
  • 最小化 ∑ A[i] × B[i]:A 按升序,B 按降序排序(反方向配对)

这是排列不等式:大配大、小配小最大化;大配小、小配大最小化。

📄 这是**排列不等式**:大配大、小配小最大化;大配小、小配大最小化。
// 最大化 sum(A[i] * B[i])
sort(A.begin(), A.end());
sort(B.begin(), B.end());
long long maxSum = 0;
for (int i = 0; i < n; i++) maxSum += (long long)A[i] * B[i];

// 最小化 sum(A[i] * B[i])
sort(A.begin(), A.end());
sort(B.begin(), B.end(), greater<int>());
long long minSum = 0;
for (int i = 0; i < n; i++) minSum += (long long)A[i] * B[i];

4.1.8 任务分配:双序列匹配

任务分配问题涉及将两个有序序列的元素匹配以优化某个目标。关键模式:对两个序列排序,然后用双指针扫描或直接下标配对。

最大化完成任务数(双指针)

贪心: 对两个序列排序,用双指针贪心匹配。

📄 C++ 完整代码
#include <bits/stdc++.h>
using namespace std;

int main() {
    ios_base::sync_with_stdio(false);
    cin.tie(NULL);

    int n;
    cin >> n;

    vector<int> ability(n), difficulty(n);
    for (int &x : ability) cin >> x;
    for (int &x : difficulty) cin >> x;

    sort(ability.begin(), ability.end());
    sort(difficulty.begin(), difficulty.end());

    // 双指针:贪心地把最弱的有能力工人分配给每个任务
    int completed = 0;
    int i = 0, j = 0;  // i:工人指针,j:任务指针

    while (i < n && j < n) {
        if (ability[i] >= difficulty[j]) {
            // 工人 i 能完成任务 j——匹配
            completed++;
            i++;
            j++;
        } else {
            // 工人 i 太弱——尝试更强的工人
            i++;
        }
    }

    cout << completed << "\n";
    return 0;
}
// 时间复杂度:O(N log N)

4.1.9 区间合并

区间合并是另一种经典贪心:将所有重叠区间合并为一组不重叠的区间。

贪心算法:

  1. 左端点升序排序区间
  2. 维护当前合并区间 [curL, curR]
  3. 对每个新区间 [l, r]:
    • 若 l ≤ curR(重叠或相邻):延伸 curR = max(curR, r)
    • 否则:完成当前合并区间,开始新的

Interval Merging — Step-by-Step

📄 ![Interval Merging — Step-by-Step](../images/interval_merging.svg
#include <bits/stdc++.h>
using namespace std;

int main() {
    ios_base::sync_with_stdio(false);
    cin.tie(NULL);

    int n;
    cin >> n;

    vector<pair<int,int>> intervals(n);
    for (auto &[l, r] : intervals) cin >> l >> r;

    sort(intervals.begin(), intervals.end());  // 按左端点排序

    vector<pair<int,int>> merged;

    for (auto [l, r] : intervals) {
        if (merged.empty() || l > merged.back().second) {
            // 无重叠——开始新的合并区间
            merged.push_back({l, r});
        } else {
            // 重叠——延伸右端点
            merged.back().second = max(merged.back().second, r);
        }
    }

    cout << merged.size() << " 个合并区间:\n";
    for (auto [l, r] : merged) {
        cout << "[" << l << "," << r << "] ";
    }
    cout << "\n";
    return 0;
}
// 时间复杂度:O(N log N)

逐步追踪(输入已排序:[1,3],[2,6],[8,10],[15,18]):

[1,3]:   merged 为空 → 直接加入。merged=[[1,3]]
[2,6]:   2 <= 3(重叠)→ 延伸:[1, max(3,6)]=[1,6]。merged=[[1,6]]
[8,10]:  8 > 6(不重叠)→ 加新区间。merged=[[1,6],[8,10]]
[15,18]: 15 > 10(不重叠)→ 加新区间。merged=[[1,6],[8,10],[15,18]]

输出:[[1,6],[8,10],[15,18]] ✓

4.1.10 数字与字符串的贪心

经典题:删除 K 个数字得到最小数

题目: 给定一串数字(表示一个大整数),恰好删除 K 个数字(保持剩余数字的原始顺序)以形成最小的整数。

例子:

"1432219",K=3  →  "1219"

贪心思路: 维护单调栈。从左到右扫描:

  • 若栈顶 > 当前数字且还有删除机会:弹出栈顶(删除较大的数字)
  • 否则:压入当前数字
  • 扫描完后若还有删除机会:从栈的右端删除

Monotone Stack — Remove K Digits

📄 C++ 完整代码
#include <bits/stdc++.h>
using namespace std;

int main() {
    string num;
    int k;
    cin >> num >> k;

    string stk = "";  // 用字符串作单调栈

    for (char c : num) {
        // 当栈顶大于当前数字且还有删除机会时弹出
        while (k > 0 && !stk.empty() && stk.back() > c) {
            stk.pop_back();
            k--;
        }
        stk.push_back(c);
    }

    // 若还有删除机会,从右端删除
    stk.resize(stk.size() - k);

    // 移除前导零
    int start = 0;
    while (start < (int)stk.size() - 1 && stk[start] == '0') start++;

    cout << stk.substr(start) << "\n";
    return 0;
}
// 时间复杂度:O(N)

经典题:最少跳跃次数

题目: 给定数组 A[0..n-1]A[i] 是从位置 i 可以跳跃的最大步数。从索引 0 出发,用最少次数到达最后一个索引。

📄 C++ 完整代码
#include <bits/stdc++.h>
using namespace std;

int main() {
    int n;
    cin >> n;
    vector<int> A(n);
    for (int &x : A) cin >> x;

    int jumps = 0;
    int curEnd = 0;    // 当前跳跃能到达的最远位置
    int farthest = 0;  // 目前可到达的最远位置

    for (int i = 0; i < n - 1; i++) {
        farthest = max(farthest, i + A[i]);  // 更新最远可达
        if (i == curEnd) {                   // 到达当前跳跃范围的末尾
            jumps++;
            curEnd = farthest;               // 跳到最远位置
            if (curEnd >= n - 1) break;      // 已经可以到达末尾
        }
    }

    cout << jumps << "\n";
    return 0;
}
// 时间复杂度:O(N)

经典题:买卖股票最大利润(贪心版)

题目: 给定每日股价,可以无限次买卖(但每次只能持有一股),最大化总利润。

贪心思路: 只要明天价格高于今天,就「今天买明天卖」。等价于累加所有正日差。

📄 C++ 完整代码
#include <bits/stdc++.h>
using namespace std;

int main() {
    int n;
    cin >> n;
    vector<int> prices(n);
    for (int &x : prices) cin >> x;

    int profit = 0;
    for (int i = 1; i < n; i++) {
        if (prices[i] > prices[i - 1]) {
            profit += prices[i] - prices[i - 1];  // 捕获每次上涨
        }
    }

    cout << profit << "\n";
    return 0;
}
// 时间复杂度:O(N)

⚠️ 注意: 这是「无限次交易」版本。「最多一次交易」→ 追踪最低买入价的单次扫描。「最多两次/K 次交易」→ 需要 DP。


4.1.11 后悔贪心

后悔贪心是最强大也最容易被忽视的贪心技术。核心思路:

做出贪心决策,但同时保留「撤销」它的能力——如果该决策后来不是最优的,用堆(优先队列)以 O(log N) 时间撤销它。

经典题:K 次操作获得最大利润(支持撤销)

贪心 + 后悔做法:

  1. 维护最大堆
  2. 每步:取堆顶 x(最大收益),然后插入 -x(「后悔节点」——撤销此操作的代价)
  3. 若之后从堆中取出 -x,相当于「取消」之前的操作
📄 3. 若之后从堆中取出 `-x`,相当于「取消」之前的操作
#include <bits/stdc++.h>
using namespace std;

int main() {
    ios_base::sync_with_stdio(false);
    cin.tie(NULL);

    int n, k;
    cin >> n >> k;

    priority_queue<long long> pq;  // 最大堆
    for (int i = 0; i < n; i++) {
        long long x; cin >> x;
        pq.push(x);
    }

    long long total = 0;
    for (int i = 0; i < k; i++) {
        long long top = pq.top(); pq.pop();
        if (top <= 0) break;  // 取该元素会损失——停止
        total += top;
        pq.push(-top);        // 插入后悔节点:撤销此操作的代价
    }

    cout << total << "\n";
    return 0;
}

后悔的魔力: 取出 x 并插入 -x 后,若堆顶变为 y,我们可以选择:

  • 取 y(正常贪心)
  • -x(相当于「取消 x,用下一个可用元素替换」)

这自动找到了 K 次操作的最优序列。


最小化 K 台机器的最大完工时间(LPT)

贪心:最长处理时间优先(LPT)

降序排列处理时间,将每个作业分配给完成时间最早的机器(用最小堆维护)。

📄 按**降序**排列处理时间,将每个作业分配给完成时间最早的机器(用最小堆维护)。
#include <bits/stdc++.h>
using namespace std;

int main() {
    ios_base::sync_with_stdio(false);
    cin.tie(NULL);

    int n, k;
    cin >> n >> k;

    vector<int> t(n);
    for (int &x : t) cin >> x;

    sort(t.begin(), t.end(), greater<int>());  // 降序:最长作业优先

    // 最小堆:存储每台机器的当前完成时间(初始全为 0)
    priority_queue<long long, vector<long long>, greater<long long>> machines;
    for (int i = 0; i < k; i++) machines.push(0);

    for (int i = 0; i < n; i++) {
        long long earliest = machines.top(); machines.pop();
        machines.push(earliest + t[i]);  // 将作业分配给最早空闲的机器
    }

    long long makespan = 0;
    while (!machines.empty()) {
        makespan = max(makespan, machines.top());
        machines.pop();
    }

    cout << makespan << "\n";
    return 0;
}
// 时间复杂度:O(N log N + N log K)

4.1.12 对抗匹配(田忌赛马)

经典的**「田忌赛马」**问题是对抗贪心匹配的原型:双方各有 N 匹马;你可以自由选择出场顺序,对手顺序已知。最大化你赢得的比赛数。

策略(双指针,O(N)):

对你的马 A(升序)和对手的马 B(升序)排序:

  • 若你最强的马 A[hi] > 对手最强的马 B[bhi] → 用自己最强的打对手最强的(赢一场)
  • 若你最强的 A[hi] ≤ 对手最强的 B[bhi] → 用自己最弱的消耗对手最强的(策略性认输,保留更强的马)

Adversarial Matching — Tian Ji's Horse Racing

📄 C++ 完整代码
#include <bits/stdc++.h>
using namespace std;

int main() {
    ios_base::sync_with_stdio(false);
    cin.tie(NULL);

    int n;
    cin >> n;

    vector<int> A(n), B(n);
    for (int &x : A) cin >> x;
    for (int &x : B) cin >> x;

    sort(A.begin(), A.end());
    sort(B.begin(), B.end());

    int wins = 0;
    int lo = 0, hi = n - 1;    // A 的双端指针(弱端和强端)
    int blo = 0, bhi = n - 1;  // B 的双端指针

    while (lo <= hi) {
        if (A[hi] > B[bhi]) {
            // A 最强打赢 B 最强——赢一场
            wins++;
            hi--;
            bhi--;
        } else {
            // A 最强打不赢 B 最强——用 A 最弱消耗 B 最强
            lo++;
            bhi--;
        }
    }

    cout << wins << "\n";
    return 0;
}
// 时间复杂度:O(N log N)

4.1.13 前缀/后缀贪心与位运算贪心

前缀/后缀贪心

许多问题可以通过**一次从左扫描(前缀)和一次从右扫描(后缀)**来解决,然后合并结果。

经典应用:分发糖果(双遍扫描)

📄 C++ 完整代码
#include <bits/stdc++.h>
using namespace std;

int main() {
    int n;
    cin >> n;
    vector<int> rating(n);
    for (int &x : rating) cin >> x;

    vector<int> candy(n, 1);  // 每人至少 1 颗糖

    // 第一遍:满足「比左邻居评分高则多拿」
    for (int i = 1; i < n; i++) {
        if (rating[i] > rating[i - 1])
            candy[i] = candy[i - 1] + 1;
    }

    // 第二遍:满足「比右邻居评分高则多拿」
    for (int i = n - 2; i >= 0; i--) {
        if (rating[i] > rating[i + 1])
            candy[i] = max(candy[i], candy[i + 1] + 1);
    }

    cout << accumulate(candy.begin(), candy.end(), 0) << "\n";
    return 0;
}
// 时间复杂度:O(N)

位运算贪心

经典题:最大化数组中两个元素的异或值

贪心(字典树): 将所有数字插入二进制字典树。对每个数字 x,在字典树中每层贪心地走「对立」分支(从高位到低位),逐位最大化 XOR 值。

📄 C++ 完整代码
#include <bits/stdc++.h>
using namespace std;

const int MAXBIT = 30;

struct Trie {
    int ch[2];
    Trie() { ch[0] = ch[1] = -1; }
};

vector<Trie> trie(1);

void insert(int x) {
    int node = 0;
    for (int i = MAXBIT; i >= 0; i--) {
        int bit = (x >> i) & 1;
        if (trie[node].ch[bit] == -1) {
            trie[node].ch[bit] = trie.size();
            trie.push_back(Trie());
        }
        node = trie[node].ch[bit];
    }
}

int maxXOR(int x) {
    int node = 0, res = 0;
    for (int i = MAXBIT; i >= 0; i--) {
        int bit = (x >> i) & 1;
        int want = 1 - bit;  // 贪心:尝试对立位使 XOR = 1
        if (trie[node].ch[want] != -1) {
            res |= (1 << i);
            node = trie[node].ch[want];
        } else {
            node = trie[node].ch[bit];
        }
    }
    return res;
}

int main() {
    int n;
    cin >> n;
    vector<int> nums(n);
    for (int &x : nums) cin >> x;

    for (int x : nums) insert(x);

    int ans = 0;
    for (int x : nums) ans = max(ans, maxXOR(x));

    cout << ans << "\n";
    return 0;
}
// 时间复杂度:O(N × MAXBIT) = O(32N)

💡 通用位运算贪心模式: 从高位到低位处理。在每一位,贪心地选择使结果该位等于 1(或 0,取决于目标)的分支。字典树支持每次查询 O(MAXBIT) 时间。


⚠️ 第 4.1 章常见错误

  1. 把贪心用在 DP 问题上: 贪心更简单不代表它正确。始终用小反例测试。任意面额的硬币找零是经典陷阱。

  2. 排序标准用错: 活动选择时按开始时间而非结束时间排序是经典 bug。为什么这样排序的论证(交换论证)才告诉你正确的标准。

  3. 重叠判断差一: s >= lastEnd(允许相邻活动)vs s > lastEnd(要求有间隔)。检查题目要求哪种。

  4. 不证明就假设贪心有效: 始终用小例子验证,或简短地交换论证一下。若找不到反例且能草拟贪心选择「安全」的理由,大概率是正确的。

  5. 忘记排序: 贪心算法几乎总是从排序开始。忘记排序意味着贪心「顺序」不存在。

  6. 比较器中整数溢出: 按比率 w/t 排序时,避免浮点比较。用交叉乘法:w_A * t_B > w_B * t_A。乘法前始终强制转换为 long long

  7. 在错误的子问题上贪心: 有些问题看起来像「每次选最优元素」,但「最优」取决于未来上下文。若你在第 i 步的贪心选择改变了第 i+1 步的最优性,很可能需要 DP。


本章总结

📌 核心要点

题目类型贪心策略排序依据时间识别信号
最多不重叠区间选结束最早的区间右端点升序O(N log N)「最多活动/会议」
最少点刺穿所有区间在每个未覆盖区间的右端放点右端点升序O(N log N)「最少箭/传感器覆盖所有」
最少区间覆盖范围每步选最远延伸的左端点升序O(N log N)「最少线段覆盖 [L,R]」
区间合并按左端点排序,扫描合并左端点升序O(N log N)「合并重叠范围」
最小化最大延迟(EDF)最早截止日期优先截止时间升序O(N log N)「最小化最大延迟」
Huffman 编码合并两个最小频率最小堆O(N log N)「最小代价合并 N 堆」
最小化总完成时间(SJF)最短作业优先处理时间升序O(N log N)「最小化加权完成时间总和」
最大数(拼接)比较器:a+b vs b+a自定义比较器O(N log N·L)「排列数字/字符串得最大数」
排列不等式同向最大化,反向最小化两数组都排序O(N log N)「最大/最小化两数组的点积」
双序列匹配两数组排序后双指针匹配两数组都排序O(N log N)「匹配 A[i] 和 B[j] 最大化满足对数」
删除 K 个数字(最小结果)单调栈——栈顶大于当前时弹出不需要排序O(N)「删 K 个数字得最小数」
股票交易(无限次)累加每个正日差不需要排序O(N)「无限买卖,最大利润」
后悔贪心贪心选取 + 在堆中插入后悔节点最大/最小堆O(N log N)「K 次操作,可隐式撤销」
多机调度(LPT)最长作业优先 + 最小堆分配处理时间降序O(N log K)「N 个作业,K 台机器,最小完工时间」
对抗匹配(田忌赛马)最强打最强;否则最弱消耗最强双端指针O(N log N)「双方各自最优分配,最大赢场数」
前缀/后缀双遍扫描从两侧分别扫描,取最大合并无/自定义O(N)「每个元素依赖左侧最小和右侧最大」
位运算贪心(字典树+逐位)在每层贪心选对立位O(N·MAXBIT)「数组中两个元素的最大异或值」

❓ 常见问题

Q1:怎么判断一个问题能不能用贪心解?

A:三个信号:① 排序后有清晰的处理顺序;② 可以用交换论证证明贪心选择永远不比其他选择差;③ 找不到反例。若找到了(如硬币找零 {1,5,6,9}),贪心失败——改用 DP。

Q2:贪心和 DP 的真正区别是什么?

A:贪心在每步做出局部最优选择且从不回头。DP 考虑所有可能的选择,从子问题解构建全局最优。贪心是 DP 的特例——当局部最优恰好等于全局最优时可以用贪心。

Q3:「二分答案 + 贪心检查」模式是什么?

A:当题目问「最小化最大值」或「最大化最小值」时,对答案 X 二分查找,用贪心的 check(X) 验证可行性。参见第 4.2 章的 Convention 题目。

Q4:活动选择为什么按结束时间而非开始时间排序?

A:按结束时间排序确保我们总是选择最早「释放资源」的活动,为后续活动留下最大空间。按开始时间排序可能选到开始很早但结束很晚的活动,阻挡后续所有活动。

Q5:什么时候用后悔贪心而不是普通贪心?

A:当:① 题目允许最多 K 次操作且 K 较小;② 每次操作可以用反向操作「撤销」;③ 普通贪心给出次优答案,因为早期选择阻碍了后来更好的选择。关键思路是在堆中插入 -x 后悔节点,让你以 O(log N) 隐式撤销任何之前的选择。


练习题

题目关键技术难度
4.1.1 会议室 II区间调度 + 最小堆🟡 中等
4.1.2 加油站环形贪心 + 前缀和🔴 困难
4.1.3 最少站台数事件扫描🟡 中等
4.1.4 分数背包比率贪心🟢 简单
4.1.5 跳跃游戏可达性贪心🟡 中等
🏆 挑战题区间刺穿(USACO Silver)🔴 困难

题目 4.1.1 — 会议室 II 🟡 中等

题目: N 场会议,各有开始时间 start[i] 和结束时间 end[i]。找最少需要多少间会议室使所有会议都能无重叠进行。

输入:

3
0 30
5 10
15 20

输出: 2

💡 提示

最少会议室数 = 任意时刻最多重叠的会议数。用最小堆追踪结束时间(每间会议室何时空闲)。对每场新会议,检查最早空闲的会议室能否复用。

✅ 完整题解

核心思路:

开始时间排序会议,最小堆存储每间会议室的结束时间。对每场新会议:

  • 若堆顶(最早结束的会议室)≤ 新会议开始时间 → 复用该会议室
  • 否则 → 开新会议室

堆的最终大小就是答案。

#include <bits/stdc++.h>
using namespace std;

int main() {
    ios_base::sync_with_stdio(false);
    cin.tie(NULL);

    int n;
    cin >> n;

    vector<pair<int,int>> meetings(n);
    for (int i = 0; i < n; i++)
        cin >> meetings[i].first >> meetings[i].second;

    sort(meetings.begin(), meetings.end());  // 按开始时间排序

    // 最小堆:存储每间使用中会议室的结束时间
    priority_queue<int, vector<int>, greater<int>> pq;

    for (auto [start, end] : meetings) {
        if (!pq.empty() && pq.top() <= start) {
            // 复用最早空闲的会议室
            pq.pop();
        }
        pq.push(end);  // 该会议室被占用到 'end' 时刻
    }

    cout << pq.size() << "\n";
    return 0;
}
// 时间复杂度:O(N log N)

题目 4.1.2 — 加油站 🔴 困难

题目: N 个加油站排成圆圈。加油站 i 有 gas[i] 升油;从 i 到 i+1 消耗 cost[i] 升。油箱初始为空、容量无限。能否完成整圈?如果能,输出起始站下标(答案唯一)。

示例:

gas  = [1, 2, 3, 4, 5]
cost = [3, 4, 5, 1, 2]

输出:3(从第 3 站出发可以完成整圈)

💡 提示

核心思路:若总油量 ≥ 总耗油量,则恰好存在一个有效起始站。贪心扫描:每当累计油箱降至零以下,将起始站重置为下一站。

✅ 完整题解

两个关键定理:

  1. 可行性:sum(gas) < sum(cost),无解。
  2. 唯一起始站定理: 若有解,每当从站 s 出发的运行油箱变为负数,s 到故障点之间的任何站都不是有效起始点。因此下一个候选站必须紧接在故障点之后。
#include <bits/stdc++.h>
using namespace std;

int main() {
    ios_base::sync_with_stdio(false);
    cin.tie(NULL);

    int n;
    cin >> n;

    vector<int> gas(n), cost(n);
    for (int &x : gas) cin >> x;
    for (int &x : cost) cin >> x;

    int totalTank = 0;  // 总净油量(决定可行性)
    int tank = 0;       // 从 'start' 出发的当前油量
    int start = 0;      // 当前候选起始站

    for (int i = 0; i < n; i++) {
        int gain = gas[i] - cost[i];
        tank += gain;
        totalTank += gain;

        if (tank < 0) {
            // 从 'start' 无法到达 i+1 — 重置
            start = i + 1;
            tank = 0;
        }
    }

    if (totalTank < 0) {
        cout << -1 << "\n";  // 无解
    } else {
        cout << start << "\n";
    }
    return 0;
}
// 时间复杂度:O(N)——单次扫描

题目 4.1.3 — 最少站台数 🟡 中等

题目: N 辆火车,各有到达时间 arr[i] 和离开时间 dep[i]。若火车到达时所有站台都被占用,它必须等待。找最少需要多少个站台使没有火车需要等待。

示例:

arr = [9:00, 9:40, 9:50, 11:00, 15:00, 18:00]
dep = [9:10, 12:00, 11:20, 11:30, 19:00, 20:00]

输出:3

💡 提示

双指针/事件扫描:将所有到达(+1)和出发(-1)事件合并为一个排序列表,扫描时维护当前站台数。峰值就是答案。注意:相同时间时,出发事件先于到达事件处理(离开的火车先让出站台再占用)。

✅ 完整题解

方法一:事件扫描(推荐)

#include <bits/stdc++.h>
using namespace std;

int main() {
    ios_base::sync_with_stdio(false);
    cin.tie(NULL);

    int n;
    cin >> n;

    vector<pair<int,int>> events;  // {时间, 类型}:类型=0 出发,类型=1 到达
    for (int i = 0; i < n; i++) {
        int a, d;
        cin >> a >> d;
        events.push_back({a, 1});   // 到达
        events.push_back({d, 0});   // 出发(类型=0 < 1,相同时间出发先处理)
    }

    sort(events.begin(), events.end());

    int platforms = 0, maxPlatforms = 0;
    for (auto [time, type] : events) {
        if (type == 1) platforms++;   // 火车到达
        else platforms--;             // 火车出发
        maxPlatforms = max(maxPlatforms, platforms);
    }

    cout << maxPlatforms << "\n";
    return 0;
}

方法二:双指针(经典)

#include <bits/stdc++.h>
using namespace std;

int main() {
    int n;
    cin >> n;

    vector<int> arr(n), dep(n);
    for (int &x : arr) cin >> x;
    for (int &x : dep) cin >> x;

    sort(arr.begin(), arr.end());
    sort(dep.begin(), dep.end());

    int platforms = 1, maxPlatforms = 1;
    int i = 1, j = 0;

    while (i < n && j < n) {
        if (arr[i] <= dep[j]) {
            platforms++;
            i++;
        } else {
            platforms--;
            j++;
        }
        maxPlatforms = max(maxPlatforms, platforms);
    }

    cout << maxPlatforms << "\n";
    return 0;
}
// 时间复杂度:O(N log N)

题目 4.1.4 — 分数背包 🟢 简单

题目: N 件物品,物品 i 重量 w[i]、价值 v[i],背包容量 W。可以取任意分量。最大化总价值。

示例:

N=3, W=50
物品:(w=10, v=60), (w=20, v=100), (w=30, v=120)

输出:240.0

💡 提示

贪心有效是因为允许取分量。按价值/重量比(单位价值)降序排序,尽可能多地取比率最高的物品直到背包装满。

✅ 完整题解
#include <bits/stdc++.h>
using namespace std;

int main() {
    ios_base::sync_with_stdio(false);
    cin.tie(NULL);

    int n;
    double W;
    cin >> n >> W;

    vector<pair<double,double>> items(n);  // {价值, 重量}
    for (int i = 0; i < n; i++)
        cin >> items[i].second >> items[i].first;

    // 按单位价值(v/w)降序排序
    sort(items.begin(), items.end(), [](const auto &a, const auto &b) {
        return a.first / a.second > b.first / b.second;
    });

    double totalValue = 0.0;
    double remaining = W;

    for (auto [v, w] : items) {
        if (remaining <= 0) break;
        if (w <= remaining) {
            totalValue += v;
            remaining -= w;
        } else {
            totalValue += v * (remaining / w);
            remaining = 0;
        }
    }

    cout << fixed << setprecision(2) << totalValue << "\n";
    return 0;
}
// 时间复杂度:O(N log N)

题目 4.1.5 — 跳跃游戏 🟡 中等

题目: 给定非负整数数组 A,从索引 0 出发,在位置 i 可以向前跳最多 A[i] 步。判断是否能到达最后一个索引(n-1)。

示例:

A = [2, 3, 1, 1, 4] → true  (0→1→4)
A = [3, 2, 1, 0, 4] → false (过不了位置 3)
💡 提示

维护 farthest = 目前可到达的最远下标。扫描每个可达位置,更新 farthest。若某时刻 i > farthest,位置 i 不可达——返回 false。

✅ 完整题解
#include <bits/stdc++.h>
using namespace std;

bool canJump(vector<int>& A) {
    int n = A.size();
    int farthest = 0;

    for (int i = 0; i < n; i++) {
        if (i > farthest) return false;
        farthest = max(farthest, i + A[i]);
        if (farthest >= n - 1) return true;
    }
    return true;
}

int main() {
    int n;
    cin >> n;
    vector<int> A(n);
    for (int &x : A) cin >> x;
    cout << (canJump(A) ? "true" : "false") << "\n";
}

为什么贪心正确: 每步不选具体跳跃——只是追踪从当前所有可达位置出发,所有可能跳跃的并集。这等价于同时考虑所有可能的跳跃路径。


🏆 挑战题:USACO 2016 February Silver——区间刺穿

题目: FJ 有 N 段围栏,各定义为数轴上的 [L_i, R_i]。找最少的「锚点」数,使每段围栏都至少包含一个锚点。

✅ 完整题解

贪心策略:右端点升序排序。维护 lastPoint(最后一个锚点的位置,初始 −∞)。对每段:若 lastPoint 不在 [L_i, R_i] 内(即 lastPoint < L_i),在 R_i 放一个新锚点(尽量靠右以覆盖更多后续区间)。

#include <bits/stdc++.h>
using namespace std;

int main() {
    ios_base::sync_with_stdio(false);
    cin.tie(NULL);

    int n;
    cin >> n;

    vector<pair<int,int>> segs(n);  // {右端点, 左端点}
    for (int i = 0; i < n; i++) {
        int l, r;
        cin >> l >> r;
        segs[i] = {r, l};
    }

    sort(segs.begin(), segs.end());  // 按右端点排序

    int count = 0;
    long long lastPoint = LLONG_MIN;

    for (auto [r, l] : segs) {
        if (lastPoint < l) {
            // 当前锚点不覆盖该段——放新锚点
            lastPoint = r;   // 放在右端以覆盖尽量多的后续区间
            count++;
        }
    }

    cout << count << "\n";
    return 0;
}
// 时间复杂度:O(N log N)

与活动选择的联系: 区间刺穿和最多不重叠区间是对偶问题:最少刺穿点数 = 最多不重叠区间数(区间调度的 König 定理)。两者都按右端点排序,代码结构几乎相同。

📖 第 4.2 章 ⏱️ 约 60 分钟 🎯 进阶

第 4.2 章:USACO 中的贪心

能用贪心解决的 USACO 题目是最令人满足的——一旦看到那个洞察,代码几乎自己就写出来了。本章通过几道以贪心为关键的 USACO 风格题目来实战演练。


4.2.1 模式识别:这是贪心题吗?

识别贪心问题是最难的部分——它看起来像 DP,闻起来像 DP,但有特殊结构让你能做出局部决策。以下是实用框架。

三问检验法

编码前问自己:

  1. 能用某种聪明的方式对输入排序吗? 大多数贪心算法从排序开始。若能找到一个自然顺序(按截止时间、结束时间、比率、自定义比较器),你很可能在贪心的正确轨道上。

  2. 每一步有「自然的」贪心选择吗? 总能找到一个「当下显然最好」的元素/决策,并能论证选它不会关闭更好的未来选项吗?

  3. 能构造交换论证吗? 若任意两个相邻选择「不符合贪心顺序」,交换它们不会使解变差吗?如果是,通过冒泡排序推理,贪心顺序是最优的。

三个都是 → 尝试贪心。若找到反例 → 改用 DP


USACO 贪心模式分类

理解一道题属于哪个模式通常是关键洞察:

模式触发词/结构排序依据例子
活动选择「最多不重叠区间」右端点升序USACO Bronze 调度
EDF 调度「最小化最大延迟/截止时间」截止时间升序Convention II(变体)
SJF / 完成时间「最小化总等待/完成时间」处理时间升序奶牛排序(相邻交换)
贪心 + 二分「最小化最大值」或「最大化最小值」二分答案USACO Convention、Haybales
双指针匹配两个有序数组,最大化匹配对数两数组都排序Paired Up、分发饼干
扫描线/模拟带时间戳的事件、容量约束事件时间奶牛信号、会议室
后悔贪心「选 K 个元素且决策可取消」最大堆 + 后悔节点USACO Gold 进阶题
自定义比较器「按最优顺序排列 N 个元素」a+b vs b+a,w/t 比率最大数,SJF

红色警报:贪心失败的信号

警惕这些表明贪心不奏效的迹象:

  • 物品/选择有权重: 若选一个物品会排除多个有不同合并价值的其他物品,贪心往往失败。用 DP。(0/1 背包,加权区间调度)
  • 决策非局部交互: 若现在选元素 i 会以非平凡的方式影响两步之后哪些元素可用。(最长递增子序列——贪心给出错误答案)
  • 你能构造 3 元素反例: 始终在小输入上测试:N=2 和 N=3。若 N=3 破坏了你的贪心,它就是错的。

⚠️ USACO 竞赛提示: Silver/Gold 级别的贪心题几乎总是需要正确性证明草稿——无论是交换论证还是二分搜索的单调性论证。若你无法用 2 句话草拟贪心的有效性,要更加谨慎。


4.2.2 USACO Bronze:奶牛排序

题目: N 头奶牛站成一排,奶牛 i 的「暴躁值」为 g[i]。想排序这一排使暴躁值严格递增。唯一允许的操作是交换两头相邻的奶牛。交换第 i 和 j 位置的奶牛(相邻)时,代价为 g[i] + g[j]。求排序所需的最小总代价

样例输入:

3
3 1 2

样例输出:

9

逆序对方案(O(N²)):

📄 C++ 完整代码
#include <bits/stdc++.h>
using namespace std;

int main() {
    ios_base::sync_with_stdio(false);
    cin.tie(NULL);

    int n;
    cin >> n;
    vector<long long> g(n);
    for (long long &x : g) cin >> x;

    // 总代价 = 每对逆序对 (i,j)(i<j,g[i]>g[j])的 g[i]+g[j] 之和
    long long totalCost = 0;
    for (int i = 0; i < n; i++) {
        for (int j = i + 1; j < n; j++) {
            if (g[i] > g[j]) {
                totalCost += g[i] + g[j];  // 这个逆序对的代价
            }
        }
    }

    cout << totalCost << "\n";
    return 0;
}
// 时间:O(N²) — N ≤ 10^5 时用归并排序逆序对计数(O(N log N))

验证: 对 [3,1,2] 的冒泡排序:

  • 交换(3,1) = 代价 4 → [1,3,2]
  • 交换(3,2) = 代价 5 → [1,2,3]
  • 总计 = 9

4.2.3 USACO Bronze:奶牛信号(贪心模拟)

许多 USACO Bronze 题是带贪心技巧的纯模拟:按时间顺序处理事件,在每步贪心维护最优状态。关键是确定模拟什么以及按什么顺序

题目: N 头奶牛各想从牛棚出发到达牧场。奶牛 i 准备在时间 t[i] 离开。牛棚和牧场之间的路同时最多容纳 C 头奶牛,过路恰好需要 1 个时间单位。路满时奶牛在牛棚等待。假设尽早送奶牛出发,最后一头奶牛何时到达

📄 C++ 完整代码
#include <bits/stdc++.h>
using namespace std;

int main() {
    ios_base::sync_with_stdio(false);
    cin.tie(NULL);

    int n, c;
    cin >> n >> c;

    vector<int> t(n);
    for (int &x : t) cin >> x;
    sort(t.begin(), t.end());  // 按出发时间顺序处理

    int ans = 0;
    // 每 c 头奶牛一组处理
    for (int i = 0; i < n; i += c) {
        // 这批奶牛中最早的是 t[i]
        ans = max(ans, t[i]);  // 这批出发时间至少要等最早的奶牛准备好
        ans++;  // 过路需要 1 个时间单位
    }

    cout << ans << "\n";
    return 0;
}

4.2.4 USACO Silver:配对

题目: 你有 A 组 N 头奶牛和 B 组 N 头奶牛。必须将 A 中每头奶牛与 B 中恰好一头配对(一对一)。奶牛 a 与奶牛 b 配对的利润min(a, b)。最大化 N 对的总利润

样例输入:

3
1 3 5
2 4 6

样例输出:

9

追踪(两数组升序排序后按下标配对):

A 排序后:[1, 3, 5]
B 排序后:[2, 4, 6]

配对 (1,2):min=1
配对 (3,4):min=3
配对 (5,6):min=5
总计 = 1+3+5 = 9 ✓

为什么同向排序? 交换论证:若某个配对中 a₁ < a₂b₁ > b₂ 配对(A 升序但 B 不是),交换到同向配对总是不差的(见第 4.1.7 节排列不等式)。

📄 C++ 完整代码
#include <bits/stdc++.h>
using namespace std;

int main() {
    int n;
    cin >> n;

    vector<int> A(n), B(n);
    for (int &x : A) cin >> x;
    for (int &x : B) cin >> x;

    sort(A.begin(), A.end());
    sort(B.begin(), B.end());

    long long total = 0;
    for (int i = 0; i < n; i++) {
        total += min(A[i], B[i]);  // 第 i 小的与第 i 小的配对
    }

    cout << total << "\n";
    return 0;
}

4.2.5 USACO Silver:Convention(二分 + 贪心)

题目(USACO 2018 February Silver): N 头奶牛在时间 t[1..N] 到达公共汽车站。有 M 辆公共汽车,每辆最多容纳 C 头奶牛。将奶牛分配给公共汽车,最小化任意奶牛的最大等待时间。

做法:二分答案 + 贪心检查。

Convention — Binary Search + Greedy Check

📄 C++ 完整代码
#include <bits/stdc++.h>
using namespace std;

int n, m, c;
vector<long long> cows;   // 按到达时间排序

// 最大等待时间 <= maxWait 时能否调度所有奶牛?
bool canDo(long long maxWait) {
    int busesUsed = 0;
    int i = 0;  // 当前奶牛下标

    while (i < n) {
        busesUsed++;
        if (busesUsed > m) return false;  // 公共汽车不够用了

        // 该车从奶牛 i 开始服务
        // 车必须在 cows[i] + maxWait 之前出发
        long long depart = cows[i] + maxWait;

        // 尽量多地装奶牛(容量 c,到达时间 ≤ 出发时间)
        int count = 0;
        while (i < n && count < c && cows[i] <= depart) {
            i++;
            count++;
        }
    }

    return true;
}

int main() {
    ios_base::sync_with_stdio(false);
    cin.tie(NULL);

    cin >> n >> m >> c;
    cows.resize(n);
    for (long long &x : cows) cin >> x;
    sort(cows.begin(), cows.end());

    // 对最大等待时间二分
    long long lo = 0, hi = 1e14;
    while (lo < hi) {
        long long mid = lo + (hi - lo) / 2;
        if (canDo(mid)) hi = mid;
        else lo = mid + 1;
    }

    cout << lo << "\n";
    return 0;
}

4.2.6 USACO Bronze:放牧(贪心观察)

题目: 三头奶牛站在数轴上不同的整数位置 abc。每次移动可以选任意一头奶牛,将它传送到任意空的整数位置。找让三头奶牛处于三个连续整数位置(如 {k, k+1, k+2})所需的最少移动次数

样例输入: 4 7 9输出: 1(将 9 移到 5 或 6,或将 4 移到 8:{7,8,9})

样例输入 2: 1 10 100输出: 2

贪心洞察: 排序后 a ≤ b ≤ c

  • 0 次移动当且仅当 c - a == 2(已是连续)
  • 2 次移动始终够用(上界)
  • 1 次移动可行当以下任一成立:
    • c - b == 1c - b == 2(b 和 c 相邻或差 1)
    • b - a == 1b - a == 2(a 和 b 相邻或差 1)
    • c - a == 3(移动 b 到 a+1 或 c-1 使三者连续)
📄 C++ 完整代码
#include <bits/stdc++.h>
using namespace std;

int main() {
    long long a, b, c;
    cin >> a >> b >> c;

    long long pos[3] = {a, b, c};
    sort(pos, pos + 3);
    a = pos[0]; b = pos[1]; c = pos[2];

    // 0 次移动:已是连续
    if (c - a == 2) { cout << 0; return 0; }

    // 1 次移动:检查各情况
    bool one_move = false;

    // (b,c) 对保留:移动 a
    if (c - b == 1 || c - b == 2) one_move = true;
    // (a,b) 对保留:移动 c
    if (b - a == 1 || b - a == 2) one_move = true;
    // (a,c) 对保留:移动 b(需 c-a==3)
    if (c - a == 3) one_move = true;

    if (one_move) { cout << 1; return 0; }

    cout << 2;
    return 0;
}

4.2.7 USACO 中常见的贪心模式

模式描述排序依据
活动选择最多不重叠区间结束时间
调度最小化完成时间/延迟截止时间或比率
贪心 + 二分检查可行性,用二分找最优各种
配对最优匹配两个有序列表两个数组
模拟按时间顺序处理事件事件时间
扫描线扫描时维护活跃集合开始/结束事件

本章总结

📌 核心要点

USACO 中的贪心算法通常涉及:

  1. 排序输入(以某种聪明的方式)
  2. 用简单的更新规则扫描一次(或两次)
  3. 偶尔与二分答案结合使用

❓ 常见问题

Q1:「二分答案 + 贪心检查」的模板是什么?

A:外层:对答案 X 二分(lo=最小可能,hi=最大可能)。内层:编写 check(X) 函数,用贪心策略验证 X 是否可行,根据结果调整 lo/hi。关键要求是 check 必须是单调的(若 X 可行,X+1 也可行,或反过来)。

Q2:USACO 贪心题和 LeetCode 贪心题有什么不同?

A:USACO 贪心题通常需要正确性证明(交换论证),且常与二分搜索和排序结合。LeetCode 倾向于更简单的「总是选最大/最小」贪心。USACO Silver 贪心题明显比 LeetCode Medium 更难。

Q3:什么时候用 priority_queue 辅助贪心?

A:当需要反复提取「当前最优」元素时(如 Huffman 编码、最小会议室数、反复取最大/最小值)。priority_queue 将「找最优」从 O(N) 降到 O(log N)。

🔗 与其他章节的联系

  • 第 4.1 章涵盖了贪心的理论和交换论证;本章将它们应用到真实的 USACO 题目
  • 第 3.3 章(二分搜索)介绍了直接在 Convention 题中用到的「二分答案」模式
  • 第 7.1 章(理解 USACO)和第 7.2 章(解题策略)将进一步讨论如何在竞赛中识别贪心 vs DP
  • 第 3.1 章(STL)介绍了 priority_queue,在本章的贪心模拟中频繁出现

练习题


题目 4.2.1 — USACO 2016 December Bronze:统计干草堆

题目: N 捆干草放在数轴上(位置可重复)。Q 次查询:范围 [L, R] 内有多少捆干草?

示例:

N=7, Q=4
位置:6 3 2 7 5 1 4
查询:2 5 / 1 1 / 4 8 / 10 15

输出:

4
1
4
0
💡 提示

排序位置,然后用 lower_bound / upper_bound 二分搜索统计 [L, R] 内的元素。这道题练习「排序 + 二分搜索」思维——大多数贪心算法的第一步。

✅ 完整题解

排序后,[L, R] 内的数量 = upper_bound(R) - lower_bound(L)

#include <bits/stdc++.h>
using namespace std;

int main() {
    ios_base::sync_with_stdio(false);
    cin.tie(NULL);

    int n, q;
    cin >> n >> q;

    vector<int> pos(n);
    for (int &x : pos) cin >> x;
    sort(pos.begin(), pos.end());  // 关键:先排序以支持二分搜索

    while (q--) {
        int l, r;
        cin >> l >> r;

        auto lo = lower_bound(pos.begin(), pos.end(), l);
        auto hi = upper_bound(pos.begin(), pos.end(), r);

        cout << (hi - lo) << "\n";
    }

    return 0;
}
// 时间复杂度:O(N log N + Q log N)

题目 4.2.2 — USACO 2019 February Bronze:睡觉奶牛排序

题目: N 头奶牛编号 1~N 以随机顺序站成一排。每次操作:取出排尾的奶牛,将其插入任意位置。最少几次操作能让队伍变为 1, 2, ..., N 的顺序?

示例:

N=5
顺序:1 4 2 5 3

输出:4

💡 提示

核心思路: 找队尾已经有序的最长后缀(连续的 k, k+1, ..., N)。这些奶牛不需要移动。答案 = N − 后缀长度。

✅ 完整题解
#include <bits/stdc++.h>
using namespace std;

int main() {
    ios_base::sync_with_stdio(false);
    cin.tie(NULL);

    int n;
    cin >> n;

    vector<int> cows(n);
    for (int &x : cows) cin >> x;

    // 找最长已排序后缀:cows[i], cows[i+1], ..., cows[n-1]
    // 条件:cows[i] + 1 == cows[i+1](连续递增)
    int keep = 1;  // 至少最后一头奶牛留下
    for (int i = n - 2; i >= 0; i--) {
        if (cows[i] + 1 == cows[i + 1]) {
            keep++;
        } else {
            break;  // 后缀必须连续——遇到第一个断点就停
        }
    }

    cout << n - keep << "\n";
    return 0;
}
// 时间复杂度:O(N)

题目 4.2.3 — 任务调度器 🟡 中等

题目: N 个标有 A~Z 的任务,每个需要 1 个时间单位。执行标有 X 的任务后,CPU 必须等待至少 k 个时间单位才能再次执行 X(期间可运行其他任务或空闲)。找完成所有任务的最短总时间。

示例:

tasks = [A, A, A, B, B, B], k = 2

输出:8(A→B→空闲→A→B→空闲→A→B)

💡 提示

关键公式:ans = max(任务总数, (maxCount-1)*(k+1) + countMax)

其中 maxCount = 出现次数最多的任务的频率。

贪心策略:每个「帧」(每 k+1 个时间单位)先填最频繁的剩余任务。

✅ 完整题解
#include <bits/stdc++.h>
using namespace std;

int main() {
    ios_base::sync_with_stdio(false);
    cin.tie(NULL);

    int n, k;
    cin >> n >> k;

    vector<int> freq(26, 0);
    for (int i = 0; i < n; i++) {
        char c; cin >> c;
        freq[c - 'A']++;
    }

    int maxCount = *max_element(freq.begin(), freq.end());
    int countMax = count(freq.begin(), freq.end(), maxCount);

    int ans = max(n, (maxCount - 1) * (k + 1) + countMax);

    cout << ans << "\n";
    return 0;
}
// 时间复杂度:O(N)

题目 4.2.4 — USACO 2018 February Silver:Convention II 🔴 困难

题目: N 头奶牛按资历排序(下标越小资历越高)。奶牛 i 在时间 a[i] 到达饮水处,喝水需要 t[i] 个时间单位(设备每次服务一头奶牛)。设备空闲时,等待的奶牛中资历最高(下标最小)的先喝水。求所有奶牛中最大等待时间。

💡 提示

用优先队列(按资历下标最小堆)模拟。维护已到达但未喝水的奶牛的「等待队列」。每次设备空闲时,从等待队列中取资历最高(下标最小)的奶牛。

✅ 完整题解
#include <bits/stdc++.h>
using namespace std;

int main() {
    ios_base::sync_with_stdio(false);
    cin.tie(NULL);

    int n;
    cin >> n;

    // {到达时间, 原始下标(资历), 喝水时长}
    vector<tuple<int,int,int>> cows(n);
    for (int i = 0; i < n; i++) {
        int a, t;
        cin >> a >> t;
        cows[i] = {a, i, t};  // 原始下标 i = 资历(0 = 最高资历)
    }

    sort(cows.begin(), cows.end());  // 按到达时间排序

    // 最小堆:{资历下标, 到达时间, 喝水时长}——下标最小优先级最高
    priority_queue<tuple<int,int,int>, vector<tuple<int,int,int>>, greater<>> waiting;

    int curTime = 0;  // 设备下次空闲的时间
    int maxWait = 0;
    int idx = 0;

    while (idx < n || !waiting.empty()) {
        // 将所有在 curTime 之前到达的奶牛加入等待队列
        while (idx < n && get<0>(cows[idx]) <= curTime) {
            auto [a, seniority, t] = cows[idx];
            waiting.push({seniority, a, t});
            idx++;
        }

        if (waiting.empty()) {
            curTime = get<0>(cows[idx]);
            continue;
        }

        // 服务资历最高(下标最小)的等待奶牛
        auto [seniority, arrTime, drinkTime] = waiting.top();
        waiting.pop();

        int waitTime = curTime - arrTime;
        maxWait = max(maxWait, waitTime);
        curTime += drinkTime;
    }

    cout << maxWait << "\n";
    return 0;
}
// 时间复杂度:O(N log N)

题目 4.2.5 — 加权工作调度 🔴 困难(贪心失败→用 DP)

题目: N 个工作,各有开始时间 s[i]、结束时间 e[i] 和利润 p[i]。选择一组不重叠的工作以最大化总利润。

示例:

N=4
工作:(s=1,e=3,p=50), (s=2,e=5,p=10), (s=4,e=6,p=40), (s=6,e=7,p=70)

输出:160(工作 1 + 3 + 4)

✅ 完整题解(包含贪心失败分析)

为什么贪心失败?

  • 按利润排序取最大值?不行。反例:(s=1,e=10,p=100) vs (s=1,e=3,p=50)+(s=4,e=7,p=60)——后者总计 110。
  • 按最早结束时间?不行。贪心可能选利润为 1 的短工作,错过利润 100 的长工作。
  • 贪心无法同时优化「早结束」和「最大利润」。这是加权区间调度——DP 问题。

DP 做法:

#include <bits/stdc++.h>
using namespace std;

int main() {
    ios_base::sync_with_stdio(false);
    cin.tie(NULL);

    int n;
    cin >> n;

    vector<tuple<int,int,int>> jobs(n);  // {结束时间, 开始时间, 利润}
    for (int i = 0; i < n; i++) {
        int s, e, p;
        cin >> s >> e >> p;
        jobs[i] = {e, s, p};
    }

    sort(jobs.begin(), jobs.end());  // 按结束时间排序

    vector<int> ends;
    for (auto [e, s, p] : jobs) ends.push_back(e);

    vector<long long> dp(n + 1, 0);  // dp[i]:前 i 个工作的最大利润(1-indexed)

    for (int i = 1; i <= n; i++) {
        auto [e, s, p] = jobs[i - 1];

        // 二分搜索:找最后一个结束时间 <= s[i] 的工作(不重叠)
        int lo = 0, hi = i - 1;
        while (lo < hi) {
            int mid = (lo + hi + 1) / 2;
            if (ends[mid - 1] <= s) lo = mid;
            else hi = mid - 1;
        }

        dp[i] = max(dp[i - 1], dp[lo] + p);  // 跳过 vs 选取
    }

    cout << dp[n] << "\n";
    return 0;
}
// 时间复杂度:O(N log N)

教训: 选择不重叠区间时,若区间有权重(利润),贪心不起作用——用 DP。只有当所有权重相等(最大化数量)时,才能简化为贪心。

🕸️ 第五部分:图论算法

学会在题目中看见图并高效解决它。BFS、DFS、树、并查集和 Kruskal 最小生成树——USACO Silver 的核心。

📚 4 章 · ⏱️ 预计 2-3 周 · 🎯 目标:达到 USACO Silver 水平

第五部分:图论算法

预计用时:2-3 周

图在竞赛编程中无处不在:迷宫、网络、家族树、城市地图。第五部分教你看见题目中的图并高效解决它们。


涵盖的主题

章节主题核心思想
第 5.1 章图的基础表示图;邻接表;图的类型
第 5.2 章BFS 与 DFS遍历、最短路径、洪水填充、连通分量、10 种变种
第 5.3 章最短路径Dijkstra、Bellman-Ford、Floyd-Warshall、SPFA、Johnson
第 5.4 章二叉树与树算法BST、遍历、LCA(朴素+倍增)、欧拉序、树的直径
第 5.5 章并查集路径压缩+按秩合并、Kruskal MST、带权并查集、种类并查集
第 5.6 章线段树区间查询/更新、懒惰传播、动态开点
第 5.7 章树状数组(BIT)前缀和、区间查询、权值BIT

学完本部分后能解决什么问题

完成第五部分后,你将能够挑战:

  • USACO Bronze:

    • 洪水填充(统计网格中连通区域数量)
    • 可达性问题(奶牛 A 能到达奶牛 B 吗?)
    • 网格/图中的简单 BFS 最短路径
  • USACO Silver:

    • 隐式图上的 BFS/DFS(状态节点而非显式节点)
    • 多源 BFS(到最近障碍物/火焰的距离)
    • 动态连通性的并查集
    • 边添加下的图连通性
    • 树的问题(子树求和、深度、LCA)

引入的关键算法

技术章节时间复杂度USACO 相关度
DFS(递归和迭代)5.2O(V + E)连通性、环检测
BFS5.2O(V + E)最短路径(无权)
网格 BFS5.2O(R × C)迷宫问题、洪水填充
多源 BFS5.2O(V + E)到最近源点的距离
连通分量5.2O(V + E)统计不连通区域数量
树的遍历(前序/后序)5.3O(N)子树聚合
并查集(DSU)5.3O(α(N)) ≈ O(1)动态连通性
Kruskal 最小生成树5.3O(E log E)最小生成树
Dijkstra 算法5.4O((V + E) log V)非负权图的单源最短路
Bellman-Ford5.4O(V × E)含负边的单源最短路;负环检测
Floyd-Warshall5.4O(V³)小图的全对最短路
SPFA5.4O(V × E) 最坏有队列优化的实用 Bellman-Ford

前置条件

开始第五部分前,请确认你能做到:

  • 使用 vector<vector<int>> 存储邻接表(第 2.3–3.1 章)
  • 使用 STL 中的 queuestack(第 3.1、3.5 章)
  • 处理二维数组和网格遍历(第 2.3 章)
  • 理解基本的嵌套循环(第 2.2 章)
  • 使用 priority_queue(第 3.1 章)——第 5.3 章(Dijkstra)需要

本部分学习建议

  1. 第 5.1 章主要是准备工作——阅读以了解图的表示,但真正的算法从第 5.2 章开始。
  2. 第 5.2 章(BFS) 是 USACO Silver 最重要的章节之一。约 1/3 的 Silver 题目涉及网格 BFS。
  3. BFS 中 dist[v] == -1 表示未访问的模式是关键。永远不要在弹出时标记访问——要在压入时标记。
  4. 第 5.5 章的并查集对连通性问题比 BFS 更快编码。记住那个 15 行的模板——你会经常用到它。
  5. 第 5.3 章(Dijkstra) 对加权最短路径问题至关重要。用带 priority_queue<pair<int,int>> 的标准模板——这是 Silver/Gold 最常见的图算法。

💡 核心思路: 大多数 USACO 图论题实际上是伪装成网格题。网格单元 (r,c) 变成图节点;相邻单元变成边。对这个隐式图做 BFS 就能找到最短路径。

🏆 USACO 技巧: 每当在题目中看到「最短路径」「最少步数」或「最少移动次数」,立刻想到 BFS。每当看到「这两个连通吗?」或「有多少组?」,想到 DSU。

📖 第 5.1 章 ⏱️ 约 75 分钟 🎯 中级

第 5.1 章:图的基础

📝 前置条件: 熟悉数组、向量和基础 C++(第 2–4 章)。了解 struct(第 2.4 章)和 vectorpair 等 STL 容器(第 3.1 章)有帮助。

把图想象成一张地图:城市是节点,城市间的道路是。图是竞赛编程中最通用的数据结构——它可以模拟人与人之间的友谊、任务间的依赖关系、迷宫中的单元格等等。在 USACO 中,Silver 级别及以上的几乎每道题都以某种形式涉及图。

本章教你如何用图的视角思考,更重要的是用代码存储图。学完后,你能读取任何 USACO 图的输入,毫不犹豫地选择正确的表示方式。


5.1.1 什么是图?

G = (V, E) 由两个集合组成:

  • 顶点 V(也称节点):「东西」——城市、奶牛、单元格、状态
  • E:它们之间的连接——道路、友谊、转换

|V| = N 表示顶点数,|E| = M 表示边数。

图如何存储?

在深入术语之前,先快速预览图在代码中如何存储。有两种主要方式(详见 §5.1.2):

邻接表 —— 对每个顶点,存储其邻居列表。这是最常见的表示方式:

adj[0] = {1, 2}        ← 节点 0 连接到 1 和 2
adj[1] = {0, 2, 3}     ← 节点 1 连接到 0、2 和 3
adj[2] = {0, 1, 4}     ← 节点 2 连接到 0、1 和 4
adj[3] = {1, 4}         ← 节点 3 连接到 1 和 4
adj[4] = {2, 3}         ← 节点 4 连接到 2 和 3

邻接矩阵 —— 用 V×V 的网格,adj[u][v] = 1 表示「u 和 v 之间有边」:

adj:   0  1  2  3  4
  0  [ 0  1  1  0  0 ]     ← 节点 0 连接到 1 和 2
  1  [ 1  0  1  1  0 ]     ← 节点 1 连接到 0、2 和 3
  2  [ 1  1  0  0  1 ]     ← 节点 2 连接到 0、1 和 4
  3  [ 0  1  0  0  1 ]     ← 节点 3 连接到 1 和 4
  4  [ 0  0  1  1  0 ]     ← 节点 4 连接到 2 和 3

💡 快速对比: 邻接表内存更少、遍历更快——95% 的情况用它。邻接矩阵支持 O(1) 边查询——V 较小(≤ 1500)或用于 Floyd-Warshall 等算法时有用。

以下两张图展示了同一个 5 节点图用邻接表和邻接矩阵存储的方式:

Graph Basics — Adjacency List

Graph Adjacency List Detail

Graph Basics — Adjacency Matrix

矩阵中,绿色 1 = 边存在,灰色 0 = 无边。对角线上的格子始终为 0(无自环)。注意矩阵是对称的——因为这是无向图。

关键术语

现在不必全部记住——快速浏览,需要时回头查看。

术语定义示例
度(Degree)连接到一个顶点的边数节点 2 的度为 3
路径(Path)由边连接的顶点序列1 → 2 → 4 → 6
环(Cycle)起点和终点相同的路径1 → 2 → 3 → 1
连通(Connected)每个顶点都能到达其他所有顶点一个连通分量
分量(Component)最大连通子图节点的「簇」
稀疏(Sparse)边少:M = O(V)道路网络
稠密(Dense)边多:M = O(V²)完全图

握手引理

所有顶点度数之和等于 2M(边数的两倍)。

证明:每条边 (u, v) 对 deg(u) 和 deg(v) 各贡献 +1。

推论: 任何图中奇数度顶点的数量始终是偶数。这可以立即排除题目中的不可能情况。

图的类型

图有多种形式,以下是最常见的:

类型描述USACO 频率
无向图A–B 边意味着 B–A 也存在;道路、牧场连接⭐⭐⭐ 非常常见
有向图(Digraph)A→B 不意味着 B→A;依赖关系、流⭐⭐ 常见(Gold+)
加权图每条边有数值代价;道路距离⭐⭐⭐ 常见(Silver+)
连通、无环、恰好 N−1 条边⭐⭐⭐ 各级别非常常见
DAG有向无环图;存在拓扑序⭐⭐ DP 状态常见
网格图单元格为节点,4 方向边;迷宫⭐⭐⭐ Bronze/Silver 最常见
完全图 K_n每对顶点都连接;N(N−1)/2 条边⭐ 罕见;通常是理论题
二部图两色顶点,边只在组间⭐⭐ 匹配、2-染色

USACO 实际情况: Bronze/Silver 主要用无权无向图和网格图。加权图出现在 Silver 最短路中。树出现在所有级别。Gold 引入 DAG、有向图和稠密图。

有向图 vs 无向图对比

DAG 有向无环图与拓扑排序

拓扑排序:Kahn 算法(BFS 入度法)

二部图 2-染色验证


5.1.2 图的表示

现在你知道图是什么了,接下来的问题是:如何用代码存储它? 这是图问题中最关键的编码决策。有三种主要表示方式,各有不同的权衡。选错会导致 TLE 或 MLE。


表示方式一:邻接表——默认选择

对每个顶点,存储其邻居列表。这是 95% 的 USACO 题目的首选

📄 对每个顶点,存储其邻居列表。这是 **95% 的 USACO 题目的首选**。
#include <bits/stdc++.h>
using namespace std;

int main() {
    ios_base::sync_with_stdio(false);
    cin.tie(NULL);

    int n, m;          // n 个顶点(1..n),m 条边
    cin >> n >> m;

    // adj[u] = 与 u 直接相连的顶点列表
    vector<vector<int>> adj(n + 1);  // 大小 n+1 以使用 1-indexed 顶点

    for (int i = 0; i < m; i++) {
        int u, v;
        cin >> u >> v;
        adj[u].push_back(v);  // 无向图:两个方向都要加
        adj[v].push_back(u);
    }

    // 遍历顶点 u 的所有邻居:O(deg(u))——最优
    for (int u = 1; u <= n; u++) {
        cout << u << " -> ";
        for (int v : adj[u]) cout << v << " ";
        cout << "\n";
    }

    return 0;
}

属性:

  • 空间: O(V + E) —— 最优。对 V = 10^5、E = 2×10^5 轻松放进 256 MB。
  • 遍历邻居: O(deg(u)) —— 只访问实际邻居,没有浪费工作。
  • 检查边 (u, v): O(deg(u)) —— 必须扫描邻居列表。(这是弱点。)
  • 缓存性能: vector 使用连续内存 → 比链表快 5–10 倍。

表示方式二:邻接矩阵

邻接矩阵将图表示为二维数组,条目 adj[u][v] 回答:「从 u 到 v 的边存在吗?」

📄 C++ 完整代码
#include <bits/stdc++.h>
using namespace std;

const int MAXV = 1001;

// 关键:声明为全局变量!
// 局部的 bool[1001][1001] 在栈上占 ~1 MB → 栈溢出崩溃。
// 全局变量存储在 BSS 段,自动初始化为零。
bool adj[MAXV][MAXV];

int main() {
    ios_base::sync_with_stdio(false);
    cin.tie(NULL);

    int n, m;
    cin >> n >> m;

    for (int i = 0; i < m; i++) {
        int u, v;
        cin >> u >> v;
        adj[u][v] = true;   // 无向图:两个方向都要设置
        adj[v][u] = true;
    }

    // O(1) 边查询——邻接矩阵的杀手特性
    if (adj[2][3]) {
        cout << "边 2-3 存在\n";
    }

    return 0;
}

⚠️ 关键:始终使用全局数组。 局部的 bool adj[1001][1001] 消耗约 1 MB 栈空间——通常会崩溃。全局数组在 BSS/数据段中,没有栈大小限制,自动初始化为零。

空间:什么时候可以用邻接矩阵?

V =   100  →  bool[100][100]   =   10 KB   ✅ 没问题
V =   500  →  bool[500][500]   =  250 KB   ✅ 没问题
V =  1000  →  bool[1000][1000] =    1 MB   ✅ 没问题
V =  1500  →  bool[1500][1500] = 2.25 MB   ✅ 没问题
V =  3000  →  bool[3000][3000] =    9 MB   ⚠️ 临界(256 MB 限制)
V = 10000  →  bool[10k][10k]   =  100 MB   ❌ 超出典型限制
V = 10^5   →  bool[10^5][10^5] =   10 GB   ❌ 不可能

经验法则: V ≤ ~1500 时安全。V > 2000 时切换到邻接表。

完整对比

操作邻接矩阵邻接表
空间O(V²)O(V + E)
检查边 (u, v)O(1)O(deg(u))
遍历 u 的所有邻居O(V) 扫整行O(deg(u))
添加边O(1)O(1) 均摊
V ≤ 1000 时最佳✅ 若需 O(1) 边查询✅ 始终可用
V = 10^5 时最佳❌ 内存太大✅ 必须用
Floyd-Warshall✅ 自然格式❌ 不能用
Kruskal / BFS / DFS❌ 邻居遍历慢✅ 必须用

表示方式三:边列表

将图存储为 (u, v)(u, v, w) 元组的普通数组。

📄 将图存储为 `(u, v)` 或 `(u, v, w)` 元组的普通数组。
// 加权图的边结构体
struct Edge {
    int u, v, w;
    // 按权重升序排序:
    bool operator<(const Edge& other) const {
        return w < other.w;
    }
};

vector<Edge> edges;

// 读取输入:
for (int i = 0; i < m; i++) {
    int u, v, w;
    cin >> u >> v >> w;
    edges.push_back({u, v, w});
}

// 按权重排序——Kruskal MST 需要:
sort(edges.begin(), edges.end());

什么时候用边列表:

算法原因
Kruskal MST需要按权重排序的边;贪心处理
Bellman-Ford对所有 M 条边迭代 N 次
全局处理所有边不需要顶点结构时

什么时候不用:

  • BFS/DFS:无法按顶点查询邻居
  • 检查「边 (u, v) 是否存在」:O(M) 扫描

如何选择表示方式

V ≤ 1500?
├── 是 → 需要 O(1) 边查询或 Floyd-Warshall?
│         ├── 是 → 邻接矩阵
│         └── 否 → 邻接表
└── 否 → 邻接表(始终)
            └── 还需要 Kruskal?→ 同时维护边列表

默认规则: 从邻接表开始。只在 V 明确较小(≤ 1500)或需要 Floyd-Warshall 时才切换到邻接矩阵。


5.1.3 读取图的输入

你已经了解了数据结构——现在把它们与真实的 USACO 输入联系起来。立即识别输入格式可以节省宝贵的竞赛时间。以下是你会遇到的五种格式:

格式一:标准边列表(最常见)

5 4        ← n 个顶点,m 条边(第一行)
1 2        ← 之后每行:一条无向边
2 3
3 4
4 5
int n, m;
cin >> n >> m;
vector<vector<int>> adj(n + 1);
for (int i = 0; i < m; i++) {
    int u, v;
    cin >> u >> v;
    adj[u].push_back(v);
    adj[v].push_back(u);   // 有向图省略此行
}

格式二:加权边列表

4 5        ← n 个顶点,m 条边
1 2 10     ← 边 1-2,权重 10
1 3 5      ← 边 1-3,权重 5
2 3 3
2 4 8
3 4 2
📄 C++ 完整代码
int n, m;
cin >> n >> m;
vector<vector<pair<int,int>>> adj(n + 1);
// adj[u] = {邻居, 权重} 对的列表
for (int i = 0; i < m; i++) {
    int u, v, w;
    cin >> u >> v >> w;
    adj[u].push_back({v, w});
    adj[v].push_back({u, w});  // 无向加权图
}

// C++17 结构化绑定遍历:
for (auto& [v, w] : adj[1]) {
    cout << "1 -> " << v << "(权重 " << w << ")\n";
}

格式三:通过父节点数组表示树

5          ← n 个节点;根始终是节点 1
2 3 1 1    ← 节点 2、3、4、5 的父节点
int n;
cin >> n;
vector<vector<int>> children(n + 1);
vector<int> par(n + 1, 0);
for (int i = 2; i <= n; i++) {
    cin >> par[i];
    children[par[i]].push_back(i);   // 有向:父节点 -> 子节点
}
// par[1] = 0(根节点无父节点)

格式四:网格图(USACO Bronze/Silver 非常常见)

4 5        ← R 行,C 列
.....      ← '.' = 可通行,'#' = 墙/障碍
.##..
.....
.....

单元格是节点;相邻的可通行单元格共享一条边。不需要显式邻接表——使用方向 delta 数组:

📄 单元格是节点;相邻的可通行单元格共享一条边。不需要显式邻接表——使用方向 delta 数组:
int R, C;
cin >> R >> C;
vector<string> grid(R);
for (int r = 0; r < R; r++) cin >> grid[r];

// 4 方向:上、下、左、右
const int dr[] = {-1,  1,  0, 0};
const int dc[] = { 0,  0, -1, 1};

// 在单元格 (r, c) 处,遍历有效的可通行邻居:
auto neighbors = [&](int r, int c) {
    vector<pair<int,int>> result;
    for (int d = 0; d < 4; d++) {
        int nr = r + dr[d];
        int nc = c + dc[d];
        if (nr >= 0 && nr < R && nc >= 0 && nc < C && grid[nr][nc] != '#') {
            result.push_back({nr, nc});
        }
    }
    return result;
};

专业技巧: 8 方向移动(包括对角线):

const int dr[] = {-1,-1,-1, 0, 0, 1, 1, 1};
const int dc[] = {-1, 0, 1,-1, 1,-1, 0, 1};

将单元格压缩为一个整数(适合 visited 数组):

// 单元格 (r, c) → 整数 ID:r * C + c(0-indexed)
int cellId(int r, int c, int C) { return r * C + c; }
// 反向:id → (id / C, id % C)

格式五:含自环和重边的边列表

📄 查看代码:格式五:含自环和重边的边列表
// 跳过自环(若题目中无意义):
for (int i = 0; i < m; i++) {
    int u, v;
    cin >> u >> v;
    if (u == v) continue;    // 自环:跳过
    adj[u].push_back(v);
    adj[v].push_back(u);
}

// 去除重边(只构建简单图):
set<pair<int,int>> seen;
for (int i = 0; i < m; i++) {
    int u, v;
    cin >> u >> v;
    if (u > v) swap(u, v);         // 规范化:始终 u < v
    if (seen.insert({u, v}).second) {
        // .second = true 意味着是新插入的(非重复)
        adj[u].push_back(v);
        adj[v].push_back(u);
    }
}

5.1.4 树——图的特殊类型

树是图最重要的特殊情况。它出现在 USACO 的每个级别——Bronze 到 Platinum。掌握树等于掌握了图论的一半。

是满足以下所有条件的图(这些条件互相等价):

  1. 连通且恰好有 N − 1 条边
  2. 连通且无环
  3. 任意两个顶点之间恰好有一条简单路径
  4. 最小连通:去掉任意一条边就会断开

Tree Structure

         1          ← 根节点(深度 0)
        / \
       2   3        ← 深度 1
      / \   \
     4   5   6      ← 深度 2(4、5、6 是叶节点)

树的术语

术语定义示例(上图)
指定的顶部节点(深度 0)节点 1
父节点直接在上方的唯一节点parent(4) = 2
子节点直接在下方的节点children(2) = {4, 5}
叶节点没有子节点的节点节点 4、5、6
深度到根的距离depth(6) = 2
节点 u 的高度从 u 到叶节点的最长路径height(2) = 1
树的高度任意节点的最大深度2
子树(u)节点 u 及其所有后代subtree(2) = {2,4,5}
u 的祖先从 u 到根路径上的任意节点ancestors(6) = {3, 1}
LCA(u, v)最近公共祖先LCA(4, 6) = 1

给树确定根(标准 DFS 模板)

几乎所有树问题都需要选定一个根并计算父子关系。标准做法:将树作为无向图读取,然后用 DFS 确定根:

📄 几乎所有树问题都需要选定一个根并计算父子关系。标准做法:将树作为无向图读取,然后用 DFS 确定根:
int n;
cin >> n;
vector<vector<int>> adj(n + 1);
for (int i = 0; i < n - 1; i++) {
    int u, v;
    cin >> u >> v;
    adj[u].push_back(v);
    adj[v].push_back(u);   // 无向树边
}

vector<int> parent(n + 1, 0);
vector<int> depth(n + 1, 0);
vector<vector<int>> children(n + 1);

// 以节点 1 为根,用迭代 DFS(N 最大 10^5 时安全)
// 递归 DFS 对链(N = 10^5 深的路径)可能栈溢出
function<void(int, int)> rootTree = [&](int u, int par) {
    parent[u] = par;
    for (int v : adj[u]) {
        if (v != par) {                // v != par 避免向上回溯
            children[u].push_back(v);
            depth[v] = depth[u] + 1;
            rootTree(v, u);
        }
    }
};
rootTree(1, 0);   // 根 = 1,哨兵父节点 = 0

rootTree(1, 0) 完成后:

  • parent[u] = 节点 u 的父节点(若 u 是根则为 0)
  • children[u] = 有根树中 u 的子节点列表
  • depth[u] = u 从根开始的深度

⚠️ 深树的栈溢出警告: 对 10^5 长度的链图做递归 DFS 会溢出默认栈(通常 1–8 MB)。对于大型树,使用带显式栈的迭代 DFS 或增大栈大小。

迭代(栈安全)版本:

📄 C++ 完整代码
// 迭代 rootTree:BFS 顺序,任何树形状都安全
vector<int> order;
queue<int> bfsQ;
vector<bool> visited(n + 1, false);
bfsQ.push(1);
visited[1] = true;
parent[1] = 0;
depth[1] = 0;

while (!bfsQ.empty()) {
    int u = bfsQ.front(); bfsQ.pop();
    order.push_back(u);
    for (int v : adj[u]) {
        if (!visited[v]) {
            visited[v] = true;
            parent[v] = u;
            depth[v] = depth[u] + 1;
            children[u].push_back(v);
            bfsQ.push(v);
        }
    }
}
// order[] = BFS 遍历顺序(适用于自底向上 DP)

5.1.5 加权图——存储边的代价

目前为止,我们的边是「裸」的——只是说「A 连接到 B」。但许多题目给每条边赋予一个代价(距离、行程时间、容量)。以下是如何存储这些额外信息:

📄 C++ 完整代码
// 方式一:pair<int,int>——紧凑、常用
vector<vector<pair<int,int>>> adj(n + 1);
// adj[u] 存储 {v, w} 对

// 添加无向加权边 u–v,权重 w:
adj[u].push_back({v, w});
adj[v].push_back({u, w});

// 用 C++17 结构化绑定遍历:
for (auto& [v, w] : adj[u]) {
    cout << u << " -> " << v << "(代价 " << w << ")\n";
}
📄 C++ 完整代码
// 方式二:struct Edge——对复杂图更清晰
struct Edge {
    int to;       // 目标顶点
    int weight;   // 边代价
};
vector<vector<Edge>> adj(n + 1);

// 添加边:
adj[u].push_back({v, w});

// 遍历:
for (const Edge& e : adj[u]) {
    relax(u, e.to, e.weight);
}

什么时候用 long long 权重

若边权最大 10^9 且路径可能包含多条边,累积和可能溢出 32 位 int(最大约 2.1×10^9):

最坏情况:N = 10^5 个节点,所有边权 = 10^9
最长路径和 ≈ 10^5 × 10^9 = 10^14 → int 溢出!

Dijkstra / Bellman-Ford 的安全模板:

const long long INF = 2e18;   // long long 距离的安全哨兵
vector<vector<pair<int, long long>>> adj(n + 1);
//                         ^^^^^^^^^^^ long long 权重
vector<long long> dist(n + 1, INF);
dist[src] = 0;

规则: 若边权超过 10^4 且路径可能超过几百条边,用 long long。拿不准时用 long long——性能差异可以忽略不计。


5.1.6 常见错误

这些是初学者最常犯的 bug。记住它们——能节省大量调试时间。

⚠️ Bug #1——无向图缺少反向边最常见!

// 错误:只加一个方向
adj[u].push_back(v);    // 忘了 adj[v].push_back(u) !

// 正确:无向图 = 两个方向
adj[u].push_back(v);
adj[v].push_back(u);

症状: BFS/DFS 只访问半个图;某些顶点看起来无法到达。

⚠️ Bug #2——邻接表大小差一

// 错误:大小 n,但顶点下标是 1..n → adj[n] 越界!
vector<vector<int>> adj(n);

// 正确:大小 n+1,用于 1-indexed 顶点
vector<vector<int>> adj(n + 1);

⚠️ Bug #3——局部邻接矩阵崩溃(栈溢出)

// 错误:~1 MB 在栈上 → 栈溢出
int main() {
    bool adj[1001][1001];   // 局部变量在栈上!
}

// 正确:全局数组(BSS 段,自动初始化为零)
bool adj[1001][1001];   // 在 main() 之外
int main() { ... }

⚠️ Bug #4——V 很大时用邻接矩阵(MLE)

// 错误:V = 100,000 → 10 GB 内存!
bool adj[100001][100001];

// 正确:V > 1500 时用邻接表
vector<vector<int>> adj(n + 1);

⚠️ Bug #5——访问网格前未检查边界

// 错误:可能访问 grid[-1][c] → 未定义行为!
if (grid[nr][nc] != '#') { ... }

// 正确:先检查边界
if (nr >= 0 && nr < R && nc >= 0 && nc < C && grid[nr][nc] != '#') { ... }

⚠️ Bug #6——加权图中距离整数溢出

// 错误:边权 = 10^9,路径有 10^5 条边 → 溢出
int dist[MAXN];

// 正确:距离数组用 long long
long long dist[MAXN];
const long long INF = 2e18;

本章总结

核心要点

概念核心规则为什么重要
无向边同时加 adj[u]←vadj[v]←u忘一个方向 = Bug #1
有向边只加 adj[u]←v与无向图不同!
邻接表vector<vector<int>> adj(n+1)默认;O(V+E) 空间
邻接矩阵全局 bool adj[MAXV][MAXV]只在 V ≤ 1500 时;O(1) 边查询
加权邻接表vector<pair<int,int>> 或结构体Dijkstra、Bellman-Ford
加权矩阵int dist[MAXV][MAXV],INF 哨兵Floyd-Warshall
边列表vector<{u,v,w}> 按权重排序Kruskal MST 算法
网格图dr[]/dc[] 方向数组无需显式邻接表
连通 + N−1 条边 + 无环支持高效子树 DP
long long 权重当和可能超过 2×10^9防止路径和溢出

与后续章节的联系

章节使用本章什么内容
5.2 BFS & DFS本章构建的邻接表;本章是硬前置条件
5.3 树与 DSU树的表示 + 添加并查集数据结构
5.4 最短路径Dijkstra 用加权邻接表;Floyd 用加权矩阵
6.x 动态规划网格图支持网格 DP;DAG 支持 DAG 上的 DP
8.1 MSTKruskal 用边列表;Prim 用邻接表
8.3 树形 DP§5.1.4 的有根树结构;children[] 数组模式
8.4 欧拉序/LCA在 §5.1.4 的 depth[] 和 parent[] 上构建倍增

常见问题

Q:应该用 0-indexed 还是 1-indexed 顶点?

USACO 输入几乎总是 1-indexed(顶点标记为 1 到 N)。用 vector<vector<int>> adj(n + 1),槽位 0 留空不用。这直接与输入对应,避免差一错误。

Q:网格图需要显式邻接表吗?

不需要。网格邻居通过 dr[]/dc[] 数组即时计算——内存效率更高,代码通常也更简洁。

Q:什么时候对边权用 long long

当权重可达 10^9 且可能要对多条边求和时(最短路径、总代价)。10^9 × 路径长度很容易超过 2^31 − 1 ≈ 2.1×10^9。拿不准就用 long long


练习题


题目 5.1.1 — 度数统计 🟢 简单

题目: 读取一个有 N 个顶点和 M 条边的无向图,打印每个顶点的度数(与它相连的边数)。

样例输入 1:

4 4
1 2
2 3
3 4
4 1

样例输出 1: 2 2 2 2

样例输入 2:

5 3
1 2
1 3
1 4

样例输出 2: 3 1 1 1 0

💡 提示

维护 degree[] 数组。对每条边 (u, v),做 degree[u]++degree[v]++

✅ 完整题解
#include <bits/stdc++.h>
using namespace std;

int main() {
    ios_base::sync_with_stdio(false);
    cin.tie(NULL);

    int n, m;
    cin >> n >> m;

    vector<int> degree(n + 1, 0);

    for (int i = 0; i < m; i++) {
        int u, v;
        cin >> u >> v;
        degree[u]++;
        degree[v]++;   // 无向图:两个端点各加 +1
    }

    for (int u = 1; u <= n; u++) {
        cout << degree[u];
        if (u < n) cout << " ";
    }
    cout << "\n";

    return 0;
}
// 时间:O(N + M),空间:O(N)

题目 5.1.2 — 是树吗? 🟢 简单

题目: 给定一个有 N 个顶点和 M 条边的连通无向图,判断它是否是一棵树。

样例输入 1:

5 4
1 2
1 3
3 4
3 5

样例输出 1: YES

样例输入 2:

4 4
1 2
2 3
3 4
4 1

样例输出 2: NO

💡 提示

连通图:当且仅当 M = N − 1 时是树。不需要环检测——对连通图,边数本身就够了。

✅ 完整题解
#include <bits/stdc++.h>
using namespace std;

int main() {
    ios_base::sync_with_stdio(false);
    cin.tie(NULL);

    int n, m;
    cin >> n >> m;

    for (int i = 0; i < m; i++) {
        int u, v;
        cin >> u >> v;   // 读边(此题不需要用它们)
    }

    cout << (m == n - 1 ? "YES" : "NO") << "\n";
    return 0;
}
// 时间:O(M),空间:O(1)

⚠️ 注意: 这只在图被保证连通时有效。对可能不连通的图,还需用 BFS/DFS 验证连通性(第 5.2 章)。有 N−1 条边的不连通图是森林(多棵树),不是单棵树。


题目 5.1.3 — 有向图可达性 🟡 中等

题目: 给定有 N 个顶点、M 条有向边和两个顶点 S、T 的有向图,沿有向边走,T 从 S 可达吗?

样例输入 1:

5 4 1 4
1 2
2 3
3 4
1 5

样例输出 1: YES(路径:1 → 2 → 3 → 4)

样例输入 2:

4 3 4 1
1 2
2 3
3 4

样例输出 2: NO(边只向前 1→2→3→4,不能反向)

💡 提示

构建有向邻接表(只加 adj[u].push_back(v),不加反向边)。从 S 运行 BFS,若 T 被访问则输出 YES。

✅ 完整题解
#include <bits/stdc++.h>
using namespace std;

int main() {
    ios_base::sync_with_stdio(false);
    cin.tie(NULL);

    int n, m, S, T;
    cin >> n >> m >> S >> T;

    vector<vector<int>> adj(n + 1);
    for (int i = 0; i < m; i++) {
        int u, v;
        cin >> u >> v;
        adj[u].push_back(v);   // 有向:只加一个方向
    }

    vector<bool> visited(n + 1, false);
    queue<int> q;
    visited[S] = true;
    q.push(S);

    while (!q.empty()) {
        int u = q.front(); q.pop();
        for (int v : adj[u]) {
            if (!visited[v]) {
                visited[v] = true;
                q.push(v);
            }
        }
    }

    cout << (visited[T] ? "YES" : "NO") << "\n";
    return 0;
}
// 时间:O(V + E),空间:O(V + E)

题目 5.1.4 — 叶节点计数 🟢 简单

题目: 有根树共 N 个节点,根 = 节点 1,通过父节点数组给出。统计叶节点(无子节点的节点)数量。

样例输入:

5
1 1 2 2

样例输出: 3(树:1→{2,3},2→{4,5}。叶节点:3、4、5)

💡 提示

若一个节点从未作为父节点出现,它就是叶节点。追踪 hasChild[u]hasChild[u] = false 的节点就是叶节点。

✅ 完整题解
#include <bits/stdc++.h>
using namespace std;

int main() {
    ios_base::sync_with_stdio(false);
    cin.tie(NULL);

    int n;
    cin >> n;

    vector<bool> hasChild(n + 1, false);

    for (int i = 2; i <= n; i++) {
        int parent;
        cin >> parent;
        hasChild[parent] = true;   // 父节点至少有一个子节点
    }

    int leaves = 0;
    for (int u = 1; u <= n; u++) {
        if (!hasChild[u]) leaves++;
    }

    cout << leaves << "\n";
    return 0;
}
// 时间:O(N),空间:O(N)

题目 5.1.5 — 网格边数统计 🟡 中等

题目: 读取一个 N×M 的网格,. = 可通行,# = 墙。统计隐式无向图中边的数量(两个相邻可通行单元格共享一条边)。

样例输入 1:

3 3
...
.#.
...

样例输出 1: 8

💡 提示

对每个可通行单元格,只检查其邻居,避免重复计数。若两个单元格都可通行,计一条边。

✅ 完整题解
#include <bits/stdc++.h>
using namespace std;

int main() {
    ios_base::sync_with_stdio(false);
    cin.tie(NULL);

    int n, m;
    cin >> n >> m;

    vector<string> grid(n);
    for (int r = 0; r < n; r++) cin >> grid[r];

    int edges = 0;
    for (int r = 0; r < n; r++) {
        for (int c = 0; c < m; c++) {
            if (grid[r][c] == '#') continue;
            // 检查右邻居
            if (c + 1 < m && grid[r][c+1] == '.') edges++;
            // 检查下邻居
            if (r + 1 < n && grid[r+1][c] == '.') edges++;
        }
    }

    cout << edges << "\n";
    return 0;
}
// 时间:O(N*M),空间:O(N*M)

题目 5.1.6 — 构建邻接矩阵并查询 🟢 简单

题目: 给定有 N 个顶点(N ≤ 500)和 M 条边的无向无权图,构建邻接矩阵,回答 Q 次查询:对每次查询 (u, v),若边 u–v 存在打印 1,否则打印 0

样例输入:

4 4
1 2
1 3
2 4
3 4

3
1 2
2 3
1 4

样例输出:

1
0
0
💡 提示

O(M) 构建邻接矩阵,每次查询 O(1)。展示了当 Q 大而 N 小时邻接矩阵优于邻接表(每次查询 O(deg))的场景。

✅ 完整题解
#include <bits/stdc++.h>
using namespace std;

const int MAXV = 501;
bool adj[MAXV][MAXV];   // 全局:自动初始化为零,不会栈溢出

int main() {
    ios_base::sync_with_stdio(false);
    cin.tie(NULL);

    int n, m;
    cin >> n >> m;

    for (int i = 0; i < m; i++) {
        int u, v;
        cin >> u >> v;
        adj[u][v] = true;
        adj[v][u] = true;   // 无向图
    }

    int q;
    cin >> q;
    while (q--) {
        int a, b;
        cin >> a >> b;
        cout << (adj[a][b] ? 1 : 0) << "\n";   // 每次查询 O(1)!
    }

    return 0;
}
// 构建:O(M),每次查询:O(1),总计:O(M + Q)
📖 第 5.2 章 ⏱️ 约 120 分钟 🎯 中级

第 5.2 章:BFS 与 DFS

📝 前置条件: 确保理解图的表示(第 5.1 章)、队列和栈(第 3.6 章)以及基本的二维数组遍历(第 2.3 章)。

图遍历算法探索从起点可到达的每个节点,是数十种图算法的基础。DFS(深度优先搜索)在回溯前尽量深入;BFS(广度优先搜索)逐层探索。知道何时用哪种是你在竞赛编程生涯中不断积累的技能。


5.2.1 深度优先搜索(DFS)

DFS 就像探索迷宫:一直向前走直到遇到死路,然后回溯尝试另一条路。它是最自然的图遍历——递归完成了大部分工作。

核心思想

想象你站在迷宫中的十字路口。DFS 说:选一条路,尽可能走到底。遇到死路(所有邻居都已访问),回溯到上一个十字路口,尝试下一条路。重复直到访问了所有可达节点。

这种「深入后回溯」的行为与递归完美对应:每次递归调用深入一步,从调用中返回就是回溯。

从节点 1 开始的 DFS:

    1 ──── 2 ──── 4
    |      |
    3      5 ──── 6

访问顺序:1 → 2 → 4(死路,回溯)→ 5 → 6(死路,回溯×2)→ 3

图示:DFS 遍历顺序

DFS Traversal

DFS 在回溯前尽量深入。带编号的圆圈展示访问顺序,红色虚线箭头展示回溯路径。右侧的调用栈说明了递归如何自然地实现 DFS 所需的 LIFO 行为。

递归 DFS

📄 查看代码:递归 DFS
#include <bits/stdc++.h>
using namespace std;

const int MAXN = 100001;
vector<int> adj[MAXN];
bool visited[MAXN];

void dfs(int u) {
    visited[u] = true;           // 标记当前节点为已访问
    cout << u << " ";            // 处理 u(本例中打印它)

    for (int v : adj[u]) {       // 对每个邻居 v
        if (!visited[v]) {       // 若尚未访问
            dfs(v);              // 递归探索 v
        }
    }
}

int main() {
    ios_base::sync_with_stdio(false);
    cin.tie(NULL);

    int n, m;
    cin >> n >> m;

    for (int i = 0; i < m; i++) {
        int u, v;
        cin >> u >> v;
        adj[u].push_back(v);
        adj[v].push_back(u);
    }

    // 从节点 1 开始 DFS
    dfs(1);
    cout << "\n";

    return 0;
}

逐步追踪:递归 DFS 如何工作

给定图(节点:1,2,3,4,5,6;边:1-2,1-3,2-4,2-5,5-6):

📄 给定图(节点:1,2,3,4,5,6;边:1-2,1-3,2-4,2-5,5-6):
DFS 从节点 1 开始——带调用栈的完整追踪:

调用:dfs(1)
  visited[1] = true.  打印:1
  节点 1 的邻居:[2, 3]
  ├── v=2:未访问 → 调用 dfs(2)
  │   调用:dfs(2)
  │     visited[2] = true.  打印:2
  │     节点 2 的邻居:[1, 4, 5]
  │     ├── v=1:已访问 → 跳过
  │     ├── v=4:未访问 → 调用 dfs(4)
  │     │   调用:dfs(4)
  │     │     visited[4] = true.  打印:4
  │     │     节点 4 的邻居:[2]
  │     │     └── v=2:已访问 → 跳过
  │     │   从 dfs(4) 返回  ← 回溯!
  │     ├── v=5:未访问 → 调用 dfs(5)
  │     │   调用:dfs(5)
  │     │     visited[5] = true.  打印:5
  │     │     节点 5 的邻居:[2, 6]
  │     │     ├── v=2:已访问 → 跳过
  │     │     └── v=6:未访问 → 调用 dfs(6)
  │     │         dfs(6):打印 6
  │     │         从 dfs(6) 返回  ← 回溯!
  │     │   从 dfs(5) 返回  ← 回溯!
  │   从 dfs(2) 返回  ← 回溯!
  ├── v=3:未访问 → 调用 dfs(3)
  │   dfs(3):打印 3,无未访问邻居
  │   从 dfs(3) 返回  ← 回溯!
从 dfs(1) 返回

输出:1 2 4 5 6 3

💡 关键观察: 递归深度等于 DFS 树中从起始节点出发的最长路径。对于路径图 1→2→3→...→N,深度为 N——这就是栈溢出风险出现的时候。

重要: 始终在递归前而非之后标记节点为已访问!这能防止在环上无限循环。

复杂度分析

  • 时间:O(V + E) —— 每个顶点恰好访问一次(visited[] 检查),每条边恰好检查两次(无向图中从两端各一次),总计 O(V + E)。
  • 空间:O(V) —— visited[] 数组使用 O(V),递归调用栈最坏情况深度 O(V)。

迭代 DFS(使用栈)

对于非常大的图,递归 DFS 可能导致栈溢出(递归太深)。默认栈大小通常为 1–8 MB,每个递归层次使用约 100–200 字节。当图深度超过约 10^4–10^5 时会崩溃。

迭代版本用显式的 stack<int> 替换系统调用栈:

📄 迭代版本用显式的 `stack` 替换系统调用栈:
void dfs_iterative(int start, int n) {
    vector<bool> visited(n + 1, false);
    stack<int> st;

    st.push(start);

    while (!st.empty()) {
        int u = st.top();
        st.pop();

        if (visited[u]) continue;  // 可能被多次压入
        visited[u] = true;
        cout << u << " ";

        for (int v : adj[u]) {
            if (!visited[v]) {
                st.push(v);
            }
        }
    }
}

⚠️ 注意: 迭代 DFS 访问节点的顺序可能与递归 DFS 不同。对大多数题目这无所谓——两者都访问所有可达节点。

何时用迭代 DFS:

  • 图深度可能超过约 10^4(如路径图、链)
  • N×M ≥ 10^6 格子的网格问题
  • 任何担心栈溢出的时候

5.2.2 连通分量

连通分量是顶点的最大集合,其中每个顶点都能通过边到达其他所有顶点。把它想象成连接节点的「岛屿」——从分量中任意节点开始 DFS,会访问同一分量中的所有其他节点,但不会访问外面的节点。

DFS 连通分量:找出所有连通块

算法:用 DFS 给每个分量标记

策略很简单:

  1. 从 1 到 N 扫描所有节点
  2. 找到未访问节点时,这是一个新分量的起点
  3. 从该节点运行 DFS,给所有可达节点打上相同的分量 ID
  4. 重复直到所有节点都被标记
📄 4. 重复直到所有节点都被标记
#include <bits/stdc++.h>
using namespace std;

const int MAXN = 100001;
vector<int> adj[MAXN];
int comp[MAXN];   // comp[v] = 顶点 v 的分量 ID(0 = 未访问)

void dfs(int u, int id) {
    comp[u] = id;
    for (int v : adj[u]) {
        if (comp[v] == 0) {   // 0 表示未访问
            dfs(v, id);
        }
    }
}

int main() {
    ios_base::sync_with_stdio(false);
    cin.tie(NULL);

    int n, m;
    cin >> n >> m;

    for (int i = 0; i < m; i++) {
        int u, v;
        cin >> u >> v;
        adj[u].push_back(v);
        adj[v].push_back(u);
    }

    int numComponents = 0;
    for (int u = 1; u <= n; u++) {
        if (comp[u] == 0) {
            numComponents++;
            dfs(u, numComponents);  // 分配分量 ID
        }
    }

    cout << "分量数量:" << numComponents << "\n";

    // 打印各分量大小
    vector<int> sz(numComponents + 1, 0);
    for (int u = 1; u <= n; u++) sz[comp[u]]++;
    for (int i = 1; i <= numComponents; i++) {
        cout << "分量 " << i << ":" << sz[i] << " 个节点\n";
    }

    return 0;
}

常见 USACO 应用

连通分量以多种形式出现:

  • 「有多少组?」 —— 统计分量数
  • 「A 和 B 连通吗?」 —— 检查 comp[A] == comp[B]
  • 「最大组的大小?」 —— 找节点最多的分量
  • 「加 K 条边能使图连通吗?」 —— 需要恰好 numComponents - 1 条边

💡 替代方案:并查集(DSU) 也可以找连通分量,并支持动态添加边。我们将在第 5.5 章介绍 DSU。


5.2.3 广度优先搜索(BFS)

BFS 先探索所有距离为 1 的节点,再探索距离为 2 的,然后 3,以此类推。这使它非常适合在无权图中寻找最短路径。DFS 深入,BFS 扩张——就像池塘里的涟漪。

核心思想

BFS 使用队列(FIFO:先进先出)按距离源点的顺序处理节点:

  1. 从源节点(距离 0)开始
  2. 访问其所有邻居(距离 1)
  3. 访问未访问的邻居(距离 2)
  4. 继续直到访问了所有可达节点

队列确保距离 d 的所有节点在距离 d+1 的任何节点之前被处理。这种逐层扩展保证了最短路径。

图示:BFS 逐层遍历

BFS Traversal

BFS 像池塘里的涟漪向外扩散。每「层」节点颜色不同,展示了距源点距离 d 的所有节点在距离 d+1 的任何节点之前被发现。底部的队列展示了处理顺序。

BFS 模板

以下 BFS 模板是本章最重要的代码模式,你会在数十道题中用到它(或它的变体)。

📄 以下 BFS 模板是本章**最重要的代码模式**,你会在数十道题中用到它(或它的变体)。
// BFS 最短路径 — O(V + E)
#include <bits/stdc++.h>
using namespace std;

const int MAXN = 100001;
vector<int> adj[MAXN];

// 返回从源点到所有顶点的最短距离数组
// dist[v] = -1 表示不可达
vector<int> bfs(int source, int n) {
    vector<int> dist(n + 1, -1);   // -1 = 未访问(也作为「已访问」标记)
    queue<int> q;

    dist[source] = 0;     // 到源点的距离为 0
    q.push(source);       // 用源点初始化队列

    while (!q.empty()) {
        int u = q.front();        // 取出最早发现的节点
        q.pop();

        for (int v : adj[u]) {           // 对 u 的每个邻居
            if (dist[v] == -1) {          // 若 v 尚未访问
                dist[v] = dist[u] + 1;   // ← 关键行:v 比 u 多一跳
                q.push(v);                // 将 v 加入队列以供未来处理
            }
        }
    }

    return dist;
}

关键部分逐行分析:

作用为什么重要
dist(n+1, -1)将所有距离初始化为 -1-1 表示「尚未到达」——同时作为已访问标记
dist[source] = 0源点到自身的距离为 0BFS 的起始点
q.push(source)初始化队列BFS 需要至少一个节点才能开始
u = q.front(); q.pop()处理最早发现的节点FIFO 顺序保证逐层处理
if (dist[v] == -1)只访问未访问的节点防止重复访问和无限循环
dist[v] = dist[u] + 1关键行 —— 距离加 1无权图中每条边权重为 1
q.push(v)将 v 排队等待处理v 的邻居将在后续探索

BFS 为什么找到最短路径

BFS 按距离源点的顺序处理节点。第一次访问一个节点时,一定是通过最短路径。这是因为 BFS 在访问距离 d+1 的任何节点之前,会访问所有距离 d 的节点。

💡 核心思路: 把 BFS 想象成在水中投石——涟漪一层层向外扩散。距离 d 的所有单元格在距离 d+1 的任何单元格之前被处理。这种逐层处理保证了第一次到达任何节点都是通过最短路径。

BFS vs DFS 最短路径:

  • BFS:保证无权图的最短路径 ✓
  • DFS:不保证最短路径 ✗

5.2.4 网格 BFS——最常见的 USACO 模式

许多 USACO 题目给你一个有可通行(.)和阻塞(#)格子的网格。BFS 找从一个格子到另一个格子的最短路径。

图示:网格 BFS 距离洪水填充

Grid BFS

从中心格子(距离 0)开始,BFS 扩展到所有可达格子,记录到达每个格子所需的最少步数。颜色越蓝表示越远。这正是 USACO 洪水填充和网格最短路径题目的工作方式。

USACO 风格的网格 BFS 题目:迷宫最短路

题目: 给定一个有墙(#)和开放格子(.)的 5×5 迷宫,找从左上角 (0,0) 到右下角 (4,4) 的最短路径。打印长度,若无路径输出 -1。

📄 C++ 完整代码
// 网格 BFS 最短路径 — O(R × C)
#include <bits/stdc++.h>
using namespace std;

int main() {
    ios_base::sync_with_stdio(false);
    cin.tie(NULL);

    int R, C;
    cin >> R >> C;
    vector<string> grid(R);
    for (int r = 0; r < R; r++) cin >> grid[r];

    // 找起点 (S) 和终点 (E),或使用固定角落
    int sr = 0, sc = 0, er = R-1, ec = C-1;

    // BFS 距离数组:-1 = 未访问
    vector<vector<int>> dist(R, vector<int>(C, -1));
    queue<pair<int,int>> q;

    // 第一步:从源点初始化 BFS
    dist[sr][sc] = 0;
    q.push({sr, sc});

    // 第二步:方向数组(上、下、左、右)
    int dr[] = {-1, 1, 0, 0};
    int dc[] = {0, 0, -1, 1};

    // 第三步:BFS 扩展
    while (!q.empty()) {
        auto [r, c] = q.front();
        q.pop();

        for (int d = 0; d < 4; d++) {
            int nr = r + dr[d];
            int nc = c + dc[d];

            if (nr >= 0 && nr < R           // 行在界内
                && nc >= 0 && nc < C        // 列在界内
                && grid[nr][nc] != '#'       // 不是墙
                && dist[nr][nc] == -1) {     // ← 关键行:尚未访问

                dist[nr][nc] = dist[r][c] + 1;
                q.push({nr, nc});
            }
        }
    }

    // 第四步:输出结果
    if (dist[er][ec] == -1) {
        cout << -1 << "\n";   // 无路径
    } else {
        cout << dist[er][ec] << "\n";
    }

    return 0;
}

⚠️ 常见错误: 在迷宫最短路中用 DFS 代替 BFS。DFS 可能找到一条路,但不是最短路。始终用 BFS 求无权网格的最短距离。


5.2.5 USACO 示例:洪水填充

USACO 喜欢「洪水填充」题目:找所有连通的同类型格子,或统计连通区域数。洪水填充本质上是网格上的 DFS/BFS——从种子格子开始「绘制」所有可达的同类型格子。

题目:统计连通区域

题目: 统计网格中「.」格子的不同连通区域数量。

. . # # .
. . # . .
# # # . .
. . . # #
. . . # .

「.」格子的区域:

区域 1:   区域 2:   区域 3:
. .       . .       
. .       . .       . .
          . .       . .

答案:3 个区域。

完整代码(带详细注释)

📄 查看代码:完整代码(带详细注释)
#include <bits/stdc++.h>
using namespace std;

int R, C;
vector<string> grid;
vector<vector<bool>> visited;

void floodFill(int r, int c) {
    // 基础情况:越界时停止递归
    if (r < 0 || r >= R || c < 0 || c >= C) return;  // 越界
    if (visited[r][c]) return;                          // 已访问
    if (grid[r][c] == '#') return;                      // 墙(不是目标类型)

    // 标记此格子为已访问(当前区域的一部分)
    visited[r][c] = true;

    // 向 4 个方向递归
    floodFill(r - 1, c);  // 上
    floodFill(r + 1, c);  // 下
    floodFill(r, c - 1);  // 左
    floodFill(r, c + 1);  // 右
}

int main() {
    ios_base::sync_with_stdio(false);
    cin.tie(NULL);

    cin >> R >> C;
    grid.resize(R);
    visited.assign(R, vector<bool>(C, false));

    for (int r = 0; r < R; r++) cin >> grid[r];

    int regions = 0;
    for (int r = 0; r < R; r++) {
        for (int c = 0; c < C; c++) {
            if (!visited[r][c] && grid[r][c] == '.') {
                regions++;           // 找到新的未访问「.」格子 → 新区域!
                floodFill(r, c);     // 将此区域的所有格子标记为已访问
            }
        }
    }

    cout << regions << "\n";
    return 0;
}

变体:BFS 洪水填充(避免栈溢出)

对于大网格(R × C ≥ 10^6),递归洪水填充可能栈溢出。改用 BFS:

📄 对于大网格(R × C ≥ 10^6),递归洪水填充可能栈溢出。改用 BFS:
void floodFillBFS(int sr, int sc) {
    queue<pair<int,int>> q;
    visited[sr][sc] = true;
    q.push({sr, sc});

    int dr[] = {-1, 1, 0, 0};
    int dc[] = {0, 0, -1, 1};

    while (!q.empty()) {
        auto [r, c] = q.front(); q.pop();
        for (int d = 0; d < 4; d++) {
            int nr = r + dr[d], nc = c + dc[d];
            if (nr >= 0 && nr < R && nc >= 0 && nc < C
                && !visited[nr][nc] && grid[nr][nc] == '.') {
                visited[nr][nc] = true;
                q.push({nr, nc});
            }
        }
    }
}

💡 USACO 技巧: 洪水填充在 Bronze 和 Silver 级别非常常见。常见变体包括:统计区域数、找最大区域、检查两个格子是否在同一区域以及计算区域的周长。


5.2.6 多源 BFS

有时需要计算每个格子到最近特殊格子的距离——例如「每个空格子到最近火焰有多远?」从每个火焰格子分别启动 BFS 是 O(K × R × C)(K 是火焰数量)——太慢了。多源 BFS 一次 O(R × C) 的遍历就能解决。

多源 BFS:同时从多个起点出发

核心思想

不是从一个源点运行 BFS,而是在开始 BFS 之前将所有源点格子压入队列,距离为 0。然后正常运行 BFS。每个格子都被分配到其最近源点的距离——由 BFS 的逐层性质保证。

为什么有效? 想象一个虚拟「超级源点」S* 通过代价为 0 的边连接到所有真实源点。从 S* 做 BFS 会先访问所有真实源点(距离 0),然后是其邻居(距离 1),以此类推。多源 BFS 正是如此——不需要真正创建虚拟节点。

代码模板

📄 查看代码:代码模板
// 多源 BFS:同时从所有火焰格子出发
queue<pair<int,int>> q;
vector<vector<int>> dist(R, vector<int>(C, -1));

int dr[] = {-1, 1, 0, 0};
int dc[] = {0, 0, -1, 1};

// 第一步:在开始 BFS 之前将所有源点以距离 0 压入队列
for (int r = 0; r < R; r++) {
    for (int c = 0; c < C; c++) {
        if (grid[r][c] == 'F') {  // 火焰格子 = 源点
            dist[r][c] = 0;
            q.push({r, c});
        }
    }
}

// 第二步:从所有源点同时运行 BFS
while (!q.empty()) {
    auto [r, c] = q.front();
    q.pop();
    for (int d = 0; d < 4; d++) {
        int nr = r + dr[d], nc = c + dc[d];
        if (nr >= 0 && nr < R && nc >= 0 && nc < C
            && grid[nr][nc] != '#' && dist[nr][nc] == -1) {
            dist[nr][nc] = dist[r][c] + 1;
            q.push({nr, nc});
        }
    }
}
// BFS 后:dist[r][c] = (r,c) 到最近火焰格子的最小距离

💡 核心思路: 队列中源点的顺序无关紧要。BFS 先处理所有距离 0 的格子,再处理距离 1 的格子,以此类推。每个格子保证被最近的源点首先到达。


5.2.7 DFS vs BFS——何时用哪种

这是图问题中最重要的决策之一。以下是综合指南:

快速参考表

任务原因
最短路径(无权)BFS ✓逐层保证最短
连通性/连通分量任意两者都行;DFS 递归更简单
环检测(有向)DFS ✓三色方案追踪当前路径
环检测(无向)任意DFS 加父节点检查,或 DSU
拓扑排序DFS ✓后序给出逆拓扑顺序
洪水填充任意(DFS 更简单)DFS 递归简洁
二部图检查BFS 或 DFS用任意方法 2-染色
到所有节点的距离BFS ✓BFS 自然计算所有距离
树遍历(前/中/后序)DFS ✓递归自然映射到树结构
路径是否存在(是/否)任意两者都找所有可达节点
最近源点(多源)BFS ✓多源 BFS 是标准做法

决策流程图

需要最短路径/最少步数吗?
├── 是 → 用 BFS(始终!)
└── 否 → 需要探索路径/检测回边吗?
          ├── 是 → 用 DFS(递归追踪当前路径)
          └── 否 → 任意都行,DFS 代码通常更短

💡 核心思路: 需要「最少步数」时用 BFS。只需要访问所有节点或检查路径属性(环、拓扑顺序、子树性质)时用 DFS。

USACO 经验法则:

  • Bronze/Silver 网格题: BFS 最短路,DFS 洪水填充
  • Silver 图论题: BFS 求距离,DFS 求分量
  • Gold: DFS 拓扑排序、环检测;BFS 多源距离

⚠️ 第 5.2 章常见错误

错误一:用 DFS 求最短路径

DFS 深入探索一条路径,不保证最少步数。无权图的最短路径始终用 BFS。

错误二:网格 BFS 忘记边界检查

nr >= 0 && nr < R && nc >= 0 && nc < C —— 缺少其中任意一个条件都会导致越界崩溃。

// ✅ 正确:先检查边界,再检查网格
if (nr >= 0 && nr < R && nc >= 0 && nc < C && grid[nr][nc] != '#')

错误三:弹出时而非压入时标记已访问

如果弹出时才标记已访问,同一个节点可能被多次压入,导致 O(V²) 的时间而非 O(V+E)。

为什么会这样——场景: 考虑一个节点 X,它有三个邻居 ABC,且这三个邻居此时都已经在队列中(同一 BFS 层)。当我们依次把它们出队时,每一个都会查看 X 并问:"X 被访问过了吗?"

BFS 弹出时 vs 压入时标记对比

// ❌ 错误:弹出时才标记 → 同一节点会被多次压入
while (!q.empty()) {
    auto [r, c] = q.front(); q.pop();
    if (visited[r][c]) continue;   // 浪费:队列中已经有很多份了
    visited[r][c] = true;
    for (...) {
        if (!visited[nr][nc]) {
            q.push({nr, nc});      // 可能被多个邻居重复压入!
        }
    }
}

// ✅ 正确:压入时立即标记 → 每个节点恰好压入一次
while (!q.empty()) {
    auto [r, c] = q.front(); q.pop();
    for (...) {
        if (dist[nr][nc] == -1) {
            dist[nr][nc] = dist[r][c] + 1;  // 立即标记
            q.push({nr, nc});                // 恰好压入一次
        }
    }
}

错误版本的执行追踪(A、B、C 依次出队,X 是它们共同的邻居):

步骤出队visited[X]对 X 的操作本步后队列
1Afalse压入 X[B, C, X]
2B仍是 false!再次压入 X[C, X, X]
3C仍是 false!再次压入 X[X, X, X]
4Xfalse → 置为 true[X, X]
5Xtrue → 跳过[X]
6Xtrue → 跳过[]

X 被入队 3 次、出队 3 次;只有第 4 步真正处理了它,第 5、6 步都是无用功。

影响: 在 1000×1000 的网格上(每格约 4 个邻居),错误版本可能将 多达 4 倍 的条目压入队列(400 万而非 100 万)——导致 TLE 或 MLE。最坏情况下(V 个节点、E = O(V²) 边的稠密图),错误版本的队列操作达 O(V + E) = O(V²);正确版本保证恰好 V 次压入。稀疏图(E = O(V))下两者都是 O(V),但错误版本常数更大。

错误四:递归 DFS 栈溢出

对 N×M = 10^6 的网格,递归 DFS 可能超出默认栈大小(通常 1–8 MB)。改用迭代 BFS 或带显式栈的迭代 DFS。

错误五:0-indexed vs 1-indexed 起点用错

确保从正确的格子开始 BFS。USACO 题目有时用 1-indexed 网格,有时 0-indexed。


本章总结

📌 核心要点

算法数据结构时间空间最适合
DFS(递归)调用栈O(V+E)O(V)连通性、环检测、树问题
DFS(迭代)显式栈O(V+E)O(V)同上,避免栈溢出
BFS队列O(V+E)O(V)最短路径、逐层遍历
多源 BFS队列(多源预填充)O(V+E)O(V)到最近源点的距离
三色 DFS颜色数组O(V+E)O(V)有向图环检测
拓扑排序DFS/BFS(Kahn)O(V+E)O(V)DAG 上的排序/DP

❓ 常见问题

Q1:BFS 和 DFS 的时间复杂度都是 O(V+E),为什么 BFS 能找最短路径而 DFS 不能?

A:关键在访问顺序。BFS 用队列保证「先处理距离 d 的节点,再处理距离 d+1 的节点」,所以第一次到达一个节点始终是通过最短路径。DFS 用栈(或递归),可能经过长路到达节点,错过更短的路。

Q2:什么时候递归 DFS 会栈溢出?怎么修复?

A:默认栈大小约 1-8 MB,每次递归层次约用 100-200 字节。图深度超过约 10^4–10^5 时可能溢出。解决方案:① 切换到迭代 DFS(显式栈);② 编译时增加栈大小。

Q3:网格 BFS 中为什么用 dist == -1 表示未访问而不是 visited 数组?

A:用 dist[r][c] == -1 一举两得:同时记录「访问了没」和「到达的距离」。少一个数组,代码更简洁。

Q4:DFS 拓扑排序和 Kahn's BFS 拓扑排序,什么时候用哪个?

A:DFS 拓扑排序代码更短(直接反转后序),但 Kahn's 更直观,能检测环(若最终排序长度 < N,有环)。竞赛中两者都常见,选用你更熟悉的。

🔗 与后续章节的联系

  • 第 5.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==Mb==M 的任意状态的最少操作。

✅ 完整题解
#include <bits/stdc++.h>
using namespace std;

int main() {
    ios_base::sync_with_stdio(false);
    cin.tie(NULL);

    int X, Y, M;
    cin >> X >> Y >> M;

    vector<vector<int>> dist(X + 1, vector<int>(Y + 1, -1));
    queue<pair<int,int>> q;

    dist[0][0] = 0;
    q.push({0, 0});

    while (!q.empty()) {
        auto [a, b] = q.front(); q.pop();

        // 生成所有 6 种可能操作
        vector<pair<int,int>> next = {
            {X, b},             // 装满桶 A
            {a, Y},             // 装满桶 B
            {0, b},             // 倒空桶 A
            {a, 0},             // 倒空桶 B
            {max(0, a+b-Y), min(Y, a+b)},  // 将 A 倒入 B
            {min(X, a+b), max(0, a+b-X)}   // 将 B 倒入 A
        };

        for (auto [na, nb] : next) {
            if (dist[na][nb] == -1) {
                dist[na][nb] = dist[a][b] + 1;
                q.push({na, nb});
            }
        }
    }

    int ans = INT_MAX;
    for (int a = 0; a <= X; a++)
        if (dist[a][M] != -1) ans = min(ans, dist[a][M]);
    for (int b = 0; b <= Y; b++)
        if (dist[M][b] != -1) ans = min(ans, dist[M][b]);

    cout << (ans == INT_MAX ? -1 : ans) << "\n";
    return 0;
}
// 时间:O(X×Y × 6) = O(X×Y),空间:O(X×Y)

5.2.8 DFS 环检测——白/灰/黑染色

有向图环检测:三色 DFS

对于有向图,使用三色方案追踪 DFS 过程中每个节点的状态:

  • 白色(0): 尚未访问
  • 灰色(1): 当前在 DFS 调用栈中——已开始处理但尚未完成
  • 黑色(2): 完全处理完——所有后代都已探索

核心思路: 当且仅当 DFS 遇到回边(当前节点到灰色节点的边)时,存在环。灰色节点在当前 DFS 路径上,所以指回它的边创建了环。

📄 C++ 完整代码
// 有向图环检测 — O(V+E)
vector<int> color;   // 0=白, 1=灰, 2=黑
bool hasCycle = false;

void dfs(int u) {
    color[u] = 1;  // 标记为「处理中」(灰色)

    for (int v : adj[u]) {
        if (color[v] == 0) {
            dfs(v);              // 未访问:递归
        } else if (color[v] == 1) {
            hasCycle = true;     // ← 回边:v 是 u 的祖先 → 有环!
        }
        // color[v] == 2:已完全处理,安全跳过
    }

    color[u] = 2;  // 标记为「完成」(黑色)
}

无向图环检测(更简单!)

对于无向图,不需要三色。规则更简单:DFS 过程中,若遇到已访问的节点且不是当前节点的父节点,就有环。

📄 对于**无向图**,不需要三色。规则更简单:DFS 过程中,若遇到已访问的节点且**不是当前节点的父节点**,就有环。
// 无向图环检测 — O(V+E)
void dfs(int u, int parent) {
    visited[u] = true;
    for (int v : adj[u]) {
        if (!visited[v]) {
            dfs(v, u);                    // v 的父节点是 u
        } else if (v != parent) {
            hasCycle = true;              // 已访问且不是父节点 → 有环!
        }
    }
}
// 调用:dfs(1, -1);  // 从节点 1 开始,无父节点(用 -1 作哨兵)

5.2.9 拓扑排序

拓扑排序对**有向无环图(DAG)**的节点排序,使对每条边 u → v,u 在排序中排在 v 之前。

方法一:基于 DFS 的拓扑排序

📄 查看代码:方法一:基于 DFS 的拓扑排序
// 拓扑排序(DFS)— O(V+E)
vector<int> topoOrder;

void dfs(int u) {
    visited[u] = true;
    for (int v : adj[u]) {
        if (!visited[v]) dfs(v);
    }
    topoOrder.push_back(u);  // ← 所有子节点处理完后加入(后序)
}

// 使用:
for (int u = 1; u <= n; u++)
    if (!visited[u]) dfs(u);

reverse(topoOrder.begin(), topoOrder.end());  // 逆后序 = 拓扑顺序

方法二:Kahn 算法(基于 BFS 的拓扑排序)

📄 查看代码:方法二:Kahn 算法(基于 BFS 的拓扑排序)
// Kahn 算法:先处理入度为 0 的节点 — O(V+E)
vector<int> inDeg(n + 1, 0);
for (int u = 1; u <= n; u++)
    for (int v : adj[u])
        inDeg[v]++;

queue<int> q;
for (int u = 1; u <= n; u++)
    if (inDeg[u] == 0) q.push(u);  // 从无前置条件的节点开始

vector<int> order;
while (!q.empty()) {
    int u = q.front(); q.pop();
    order.push_back(u);
    for (int v : adj[u]) {
        inDeg[v]--;
        if (inDeg[v] == 0) q.push(v);
    }
}

// 若 order.size() != n,有环(不是 DAG)
if ((int)order.size() != n) cout << "有环\n";
else for (int u : order) cout << u << " ";

💡 关键应用: 拓扑排序是 DAG 上 DP 的基础。若依赖关系图是 DAG,按拓扑顺序处理节点——每个节点的 DP 状态只依赖之前处理过的节点。


5.2.10 变种一:0-1 BFS(权重只有 0 和 1)

问题引入

普通 BFS 只能处理无权图(每步代价相同)。当图的边权只有 01 时,用 Dijkstra 太重(O(E log V)),普通 BFS 又不对——但我们有更简单的方案:0-1 BFS,仍然 O(V+E)。

核心思想

双端队列(deque) 代替普通队列:

  • 经过权重为 0 的边 → 新节点从队首插入(代价不增,优先处理)
  • 经过权重为 1 的边 → 新节点从队尾插入(代价 +1)

这样队列始终保持「距离单调不减」,与 BFS 的层序性质等价。

普通 BFS 队列(所有边权=1):
  队首 [d=0, d=1, d=1, d=2, d=2] 队尾

0-1 BFS 双端队列(边权0从队首,边权1从队尾):
  队首 [d=0, d=0, d=1, d=1, d=2] 队尾
         ↑ 权重0的邻居插到前面

完整实现

📄 查看代码:完整实现
#include <bits/stdc++.h>
using namespace std;
typedef pair<int,int> pii;

// 0-1 BFS — O(V+E)
// adj[u] = {v, w},w 只有 0 或 1
vector<pii> adj[MAXN];

vector<int> bfs_01(int src, int n) {
    vector<int> dist(n + 1, INT_MAX);
    deque<int> dq;
    
    dist[src] = 0;
    dq.push_front(src);
    
    while (!dq.empty()) {
        int u = dq.front(); dq.pop_front();
        
        for (auto [v, w] : adj[u]) {
            if (dist[u] + w < dist[v]) {
                dist[v] = dist[u] + w;
                if (w == 0)
                    dq.push_front(v);   // 代价不增,放队首
                else
                    dq.push_back(v);    // 代价 +1,放队尾
            }
        }
    }
    return dist;
}

典型应用场景

场景0-1 BFS 怎么建模
网格中某些格子可以免费穿越免费格子边权 = 0,普通格子 = 1
换一次道路方向不花费用反向走 = 0,顺向 = 1
可以携带有限数量的道具扩展状态,使用道具时边权 = 0

5.2.11 变种二:双向 BFS(Bidirectional BFS)

问题引入

普通 BFS 从起点向外扩展,当图很大时,搜索空间是 O(b^d)(b = 分支因子,d = 距离)。
若从起点和终点同时扩展,总搜索空间变为 O(2 × b^(d/2)) = O(b^(d/2)),指数级加速!

核心思路

维护两个 BFS 前沿(frontier):

  • 正向前沿:从起点向外扩展
  • 反向前沿:从终点向外扩展

每次扩展较小的前沿(减少总节点数)。当某个节点同时出现在两个前沿中,找到了最短路径。

📄 每次扩展**较小的前沿**(减少总节点数)。当某个节点同时出现在两个前沿中,找到了最短路径。
#include <bits/stdc++.h>
using namespace std;

// 双向 BFS — 适用于无权无向图,O(b^(d/2)) 而非 O(b^d)
// 返回 src 到 dst 的最短路径长度,-1 表示不可达
int bidir_bfs(int src, int dst, int n, vector<vector<int>>& adj) {
    if (src == dst) return 0;
    
    // 两个方向的距离数组,-1 = 未访问
    vector<int> dist_f(n + 1, -1), dist_b(n + 1, -1);
    queue<int> qf, qb;
    
    dist_f[src] = 0; qf.push(src);
    dist_b[dst] = 0; qb.push(dst);
    
    while (!qf.empty() || !qb.empty()) {
        // 每次扩展较小的前沿
        if (qf.size() <= qb.size()) {
            // 扩展正向一层
            int sz = qf.size();
            while (sz--) {
                int u = qf.front(); qf.pop();
                for (int v : adj[u]) {
                    if (dist_f[v] == -1) {
                        dist_f[v] = dist_f[u] + 1;
                        qf.push(v);
                    }
                    // 若 v 已被反向 BFS 访问 → 找到!
                    if (dist_b[v] != -1)
                        return dist_f[v] + dist_b[v];
                }
            }
        } else {
            // 扩展反向一层
            int sz = qb.size();
            while (sz--) {
                int u = qb.front(); qb.pop();
                for (int v : adj[u]) {
                    if (dist_b[v] == -1) {
                        dist_b[v] = dist_b[u] + 1;
                        qb.push(v);
                    }
                    if (dist_f[v] != -1)
                        return dist_f[v] + dist_b[v];
                }
            }
        }
    }
    return -1;  // 不可达
}

速度对比

图类型BFS 节点数双向 BFS 节点数
分支因子 b=10,距离 d=610^6 = 1,000,0002 × 10^3 = 2,000
分支因子 b=4,距离 d=204^20 ≈ 10^122 × 4^10 ≈ 2×10^6

5.2.12 变种三:DFS 回溯与剪枝

什么是回溯?

回溯是 DFS 的一种应用模式:系统地枚举所有可能的选择,发现不合法时撤销(回溯)

三要素:

  1. 选择:在当前状态下做一个决定
  2. 递归:进入下一层状态
  3. 撤销:从递归返回后,撤销这个决定(恢复现场)

模板框架

📄 查看代码:模板框架
void backtrack(状态) {
    if (达到终止条件) {
        记录结果;
        return;
    }
    
    for (每个可能的选择) {
        if (选择不合法) continue;    // 剪枝:提前排除无效分支
        
        做选择;                        // 修改状态
        backtrack(新状态);            // 递归
        撤销选择;                      // 恢复状态(回溯!)
    }
}

经典例题一:全排列

📄 查看代码:经典例题一:全排列
// 生成 [1..n] 的所有排列
#include <bits/stdc++.h>
using namespace std;

vector<vector<int>> result;
vector<int> perm;
vector<bool> used;

void backtrack(int n) {
    if ((int)perm.size() == n) {
        result.push_back(perm);
        return;
    }
    
    for (int i = 1; i <= n; i++) {
        if (used[i]) continue;  // 剪枝:已使用过,跳过
        
        used[i] = true;
        perm.push_back(i);      // 做选择
        
        backtrack(n);           // 递归
        
        perm.pop_back();        // 撤销选择
        used[i] = false;
    }
}

int main() {
    int n = 3;
    used.assign(n + 1, false);
    backtrack(n);
    for (auto& p : result) {
        for (int x : p) cout << x << " ";
        cout << "\n";
    }
    // 输出全部 6 种排列:1 2 3 / 1 3 2 / 2 1 3 / 2 3 1 / 3 1 2 / 3 2 1
    return 0;
}

经典例题二:N 皇后

在 N×N 棋盘上放置 N 个皇后,使任意两个皇后不在同一行、列、对角线。

📄 在 N×N 棋盘上放置 N 个皇后,使任意两个皇后不在同一行、列、对角线。
#include <bits/stdc++.h>
using namespace std;

int n;
int ans = 0;
vector<int> col;      // col[r] = 第 r 行皇后所在的列

bool is_valid(int row, int c) {
    for (int r = 0; r < row; r++) {
        if (col[r] == c) return false;               // 同列
        if (abs(col[r] - c) == abs(r - row)) return false; // 对角线
    }
    return true;
}

void solve(int row) {
    if (row == n) { ans++; return; }
    
    for (int c = 0; c < n; c++) {
        if (!is_valid(row, c)) continue;  // 剪枝
        
        col[row] = c;     // 做选择:第 row 行放在列 c
        solve(row + 1);   // 递归下一行
        // 无需显式撤销:下次赋值会覆盖 col[row]
    }
}

int main() {
    cin >> n;
    col.resize(n);
    solve(0);
    cout << ans << "\n";  // n=8 输出 92
    return 0;
}

更快的 N 皇后(位运算剪枝)

📄 查看代码:更快的 N 皇后(位运算剪枝)
// 用位运算表示列/主对角线/副对角线的占用情况,O(n!) 但常数极小
int n, ans = 0;

void solve(int row, int cols, int diag1, int diag2) {
    if (row == n) { ans++; return; }
    
    // 所有被占用的位置的掩码
    int occupied = cols | diag1 | diag2;
    // 可以放置的列:取反后取低 n 位
    int available = (~occupied) & ((1 << n) - 1);
    
    while (available) {
        int bit = available & (-available);  // 取最低的可用位
        available -= bit;
        solve(row + 1,
              cols | bit,
              (diag1 | bit) << 1,
              (diag2 | bit) >> 1);
    }
}

int main() {
    cin >> n;
    solve(0, 0, 0, 0);
    cout << ans << "\n";
}

5.2.13 变种四:割点与桥(Tarjan 算法)

问题引入

在一个通信网络中,哪个节点被摧毁会导致网络分裂?哪条线路被切断会导致网络不连通?

  • 割点(Articulation Point): 删除该节点后图的连通分量数增加
  • 桥(Bridge): 删除该边后图的连通分量数增加

核心思想:DFS 序 + low 值

DFS 时给每个节点分配一个时间戳 disc[u](进入时间)。
再定义 low[u] = 通过 u 的子树(含至多一条回边)能到达的最早时间戳。

low[u] = min(
    disc[u],                      // u 自身的时间戳
    min(disc[v] for all v: (u,v) 是回边),  // 直接回边
    min(low[v] for all v: v 是 u 的树边子节点)  // 子节点的 low 值
)

判断割点:

  • 根节点:有 ≥ 2 个子树子节点
  • 非根节点 u:存在子节点 v 使得 low[v] >= disc[u](v 的子树无法绕过 u 连到 u 的祖先)

判断桥: 边 (u, v) 是桥当且仅当 low[v] > disc[u](v 的子树严格不能绕过这条边)

📄 C++ 完整代码
#include <bits/stdc++.h>
using namespace std;

const int MAXN = 100005;
vector<int> adj[MAXN];
int disc[MAXN], low[MAXN], timer_val = 0;
bool visited[MAXN], is_cut[MAXN];
vector<pair<int,int>> bridges;

void dfs(int u, int parent) {
    visited[u] = true;
    disc[u] = low[u] = ++timer_val;
    int child_cnt = 0;  // u 作为根时的子树数量
    
    for (int v : adj[u]) {
        if (!visited[v]) {
            child_cnt++;
            dfs(v, u);
            low[u] = min(low[u], low[v]);
            
            // 判断割点(非根)
            if (parent != -1 && low[v] >= disc[u])
                is_cut[u] = true;
            
            // 判断桥
            if (low[v] > disc[u])
                bridges.push_back({u, v});
                
        } else if (v != parent) {
            // 回边(不走父节点方向)
            low[u] = min(low[u], disc[v]);
        }
    }
    
    // 判断割点(根节点:有 >= 2 个子树)
    if (parent == -1 && child_cnt >= 2)
        is_cut[u] = true;
}

int main() {
    int n, m;
    cin >> n >> m;
    for (int i = 0; i < m; i++) {
        int u, v; cin >> u >> v;
        adj[u].push_back(v);
        adj[v].push_back(u);
    }
    
    for (int i = 1; i <= n; i++)
        if (!visited[i]) dfs(i, -1);
    
    cout << "割点:";
    for (int i = 1; i <= n; i++)
        if (is_cut[i]) cout << i << " ";
    cout << "\n桥:";
    for (auto [u, v] : bridges)
        cout << u << "-" << v << " ";
    cout << "\n";
    return 0;
}

完整追踪示例

📄 查看代码:完整追踪示例
图:1-2-3-4,另有 2-4
    
DFS 从 1 开始:disc[1]=1, low[1]=1
  → 访问 2:disc[2]=2, low[2]=2
    → 访问 3:disc[3]=3, low[3]=3
      → 访问 4:disc[4]=4, low[4]=4
        → 邻居 2 已访问(回边):low[4] = min(4, disc[2]) = 2
      回退到 3:low[3] = min(3, low[4]) = 2
    回退到 2:low[2] = min(2, low[3]) = 2
      low[3]=2 >= disc[2]=2? YES → 2 是割点(删2后3-4孤立)
    注意:low[3]=2 不严格大于 disc[2]=2,所以 2-3 不是桥
    
最终:割点={2},桥={}

5.2.14 变种五:迭代加深 DFS(IDDFS)

问题引入

如果你需要找最短路径,但图太大无法用 BFS(内存不足),或者需要找有限步数内的解,可以使用 迭代加深 DFS(Iterative Deepening DFS,IDDFS)

特点:

  • 用 DFS 的空间效率(O(d),只记当前路径)
  • 用 BFS 的最优性(逐层探索)

原理: 从深度限制 limit=1 开始,不断增加限制,每次做一次深度受限的 DFS:

limit=1 → DFS 最多走 1 步
limit=2 → DFS 最多走 2 步
limit=3 → DFS 最多走 3 步
...
直到找到目标

每次重新从根出发,看起来浪费,但由于树的节点数指数增长,最深一层的节点占大部分,总代价只比 BFS 多约 35%(当 b=2 时),却省去了 BFS 的大量内存。

📄 C++ 完整代码
#include <bits/stdc++.h>
using namespace std;

// 迭代加深 DFS 框架
bool dfs_limited(int node, int depth, int limit, int parent,
                 vector<vector<int>>& adj) {
    if (depth == limit) {
        // 在最大深度检查是否是目标
        return is_goal(node);
    }
    for (int v : adj[node]) {
        if (v == parent) continue;
        if (dfs_limited(v, depth + 1, limit, node, adj))
            return true;
    }
    return false;
}

int iddfs(int src, int max_depth, vector<vector<int>>& adj) {
    for (int limit = 0; limit <= max_depth; limit++) {
        if (dfs_limited(src, 0, limit, -1, adj))
            return limit;  // 找到目标,返回最小步数
    }
    return -1;  // 未找到
}

A* 搜索简介

IDDFS 的升级版是 IDA*(Iterative Deepening A*):在深度限制的基础上加入启发函数 h(n)(对剩余距离的估计),直接剪掉 depth + h(n) > limit 的分支。

📄 C++ 完整代码
// IDA* 框架
int threshold;
int target;

int h(int node) {
    return /* 到目标的估计距离(必须是下界)*/;
}

int search(int node, int g, int parent, vector<vector<int>>& adj) {
    int f = g + h(node);
    if (f > threshold) return f;  // 超过阈值,剪枝并返回 f
    if (node == target) return -1; // 找到!-1 作为成功标志
    
    int min_t = INT_MAX;
    for (int v : adj[node]) {
        if (v == parent) continue;
        int t = search(v, g + 1, node, adj);
        if (t == -1) return -1;
        min_t = min(min_t, t);
    }
    return min_t;
}

int ida_star(int src) {
    threshold = h(src);
    while (true) {
        int t = search(src, 0, -1, adj);
        if (t == -1) return threshold;  // 找到
        if (t == INT_MAX) return -1;    // 无解
        threshold = t;                   // 更新阈值
    }
}

5.2.15 变种六:BFS 分层遍历

二叉树的层序遍历

BFS 的一个重要变种是精确追踪当前在第几层(第几步),常用于树的层序遍历、按层输出节点等。

BFS 层次遍历:逐层扩展

📄 BFS 的一个重要变种是**精确追踪当前在第几层**(第几步),常用于树的层序遍历、按层输出节点等。
// 层序遍历二叉树,每层单独输出
#include <bits/stdc++.h>
using namespace std;

struct TreeNode { int val; TreeNode* left, *right; };

vector<vector<int>> level_order(TreeNode* root) {
    if (!root) return {};
    
    vector<vector<int>> result;
    queue<TreeNode*> q;
    q.push(root);
    
    while (!q.empty()) {
        int sz = q.size();         // 当前层的节点数
        vector<int> level;
        
        for (int i = 0; i < sz; i++) {
            TreeNode* node = q.front(); q.pop();
            level.push_back(node->val);
            if (node->left)  q.push(node->left);
            if (node->right) q.push(node->right);
        }
        
        result.push_back(level);  // 一整层
    }
    return result;
}

图的 BFS 按层处理

在图中,有时需要「先处理完所有距离 d 的节点,再处理距离 d+1 的」。方法完全相同:用 sz = q.size() 快照当前层大小。

📄 C++ 完整代码
// 图 BFS 按层处理模板
void bfs_by_layer(int src, vector<vector<int>>& adj, int n) {
    vector<int> dist(n + 1, -1);
    queue<int> q;
    dist[src] = 0;
    q.push(src);
    
    int layer = 0;
    while (!q.empty()) {
        int sz = q.size();         // 当前层的节点数量
        cout << "第 " << layer << " 层:";
        
        for (int i = 0; i < sz; i++) {
            int u = q.front(); q.pop();
            cout << u << " ";
            
            for (int v : adj[u]) {
                if (dist[v] == -1) {
                    dist[v] = dist[u] + 1;
                    q.push(v);
                }
            }
        }
        cout << "\n";
        layer++;
    }
}

5.2.16 变种七:记忆化 DFS(DFS + 动态规划)

DFS 经常需要处理重叠子问题——相同状态被多次访问。用记忆化(memoization)避免重复计算,这正是 DP 的核心。

记忆化 DFS 模板

📄 查看代码:记忆化 DFS 模板
// 有向图上,从节点 u 出发能到达的最大价值(记忆化 DFS)
#include <bits/stdc++.h>
using namespace std;

int n;
vector<int> adj[MAXN];
int val[MAXN];             // 每个节点的价值
int memo[MAXN];            // memo[u] = 从 u 出发的最大价值(-1=未计算)

int dfs(int u) {
    if (memo[u] != -1) return memo[u];  // 已算过,直接返回
    
    memo[u] = val[u];  // 至少获得当前节点的价值
    for (int v : adj[u]) {
        memo[u] = max(memo[u], val[u] + dfs(v));
    }
    return memo[u];
}

int main() {
    cin >> n;
    fill(memo, memo + n + 1, -1);
    // ... 读入图和价值 ...
    
    int ans = 0;
    for (int i = 1; i <= n; i++)
        ans = max(ans, dfs(i));
    cout << ans << "\n";
    return 0;
}

记忆化 DFS vs 标准 DP

对比记忆化 DFS标准 DP(递推)
编写方式递归(自顶向下)迭代(自底向上)
何时计算用到时才算按顺序全部算
适用场景状态转移顺序复杂时状态转移顺序清晰时
栈溢出风险有(深度过大时)

5.2.17 BFS/DFS 变种速查表

变种场景数据结构时间复杂度
普通 BFS无权最短路、连通性队列O(V+E)
普通 DFS连通性、环检测、拓扑排序栈/递归O(V+E)
0-1 BFS边权 0 或 1 的最短路双端队列O(V+E)
双向 BFS大图中点对最短路两个队列O(b^(d/2))
多源 BFS到多个源点的最短距离队列(多源初始化)O(V+E)
回溯 DFS枚举所有合法方案递归+恢复现场问题相关
割点/桥找关键节点/边DFS+disc/lowO(V+E)
IDDFS内存受限的最短路递归(限深)O(b^d),空间 O(d)
BFS 分层按层处理节点队列+层快照O(V+E)
记忆化 DFSDAG 上 DP递归+备忘录O(状态数×转移数)

BFS Grid Distances


变种专项练习题(共 8 道,全部含完整解答)

题目 5.2.6 — 0-1 BFS:网格最少翻转 🟡 中等

题目: N×M 网格,每格是 0(免费通过)或 1(需花费 1 元)。从左上角到右下角,求最少花费。

示例:

输入:3 3
      0 1 0
      0 0 1
      1 0 0
输出:0
(路径 (0,0)→(1,0)→(1,1)→(2,1)→(2,2),全走 0 格,花费 0)
✅ 完整解答

思路: 经过 0 格子边权为 0,经过 1 格子边权为 1,使用 0-1 BFS(双端队列)。

#include <bits/stdc++.h>
using namespace std;

int main() {
    ios_base::sync_with_stdio(false); cin.tie(NULL);
    int R, C; cin >> R >> C;
    vector<vector<int>> grid(R, vector<int>(C));
    for (auto& row : grid) for (int& x : row) cin >> x;
    
    vector<vector<int>> dist(R, vector<int>(C, INT_MAX));
    deque<pair<int,int>> dq;
    dist[0][0] = grid[0][0];
    dq.push_back({0, 0});
    
    int dr[] = {-1, 1, 0, 0};
    int dc[] = {0, 0, -1, 1};
    
    while (!dq.empty()) {
        auto [r, c] = dq.front(); dq.pop_front();
        
        for (int d = 0; d < 4; d++) {
            int nr = r + dr[d], nc = c + dc[d];
            if (nr < 0 || nr >= R || nc < 0 || nc >= C) continue;
            
            int nd = dist[r][c] + grid[nr][nc];
            if (nd < dist[nr][nc]) {
                dist[nr][nc] = nd;
                if (grid[nr][nc] == 0)
                    dq.push_front({nr, nc});   // 免费:队首
                else
                    dq.push_back({nr, nc});    // 花费:队尾
            }
        }
    }
    cout << dist[R-1][C-1] << "\n";
    return 0;
}

追踪:

dist[0][0]=0(grid=0,放队首)
处理(0,0):扩展(1,0) grid=0→dist=0,放队首;(0,1) grid=1→dist=1,放队尾
处理(1,0):扩展(1,1) grid=0→dist=0;(2,0) grid=1→dist=1
...
最终 dist[2][2] = 0

题目 5.2.7 — 回溯:子集枚举 🟢 简单

题目: 给定长度为 N 的数组,输出所有子集(包含空集),每行一个子集,元素从小到大,子集按字典序排列。

示例:

📄 Code 完整代码
输入:3
     1 2 3
输出:
(空行,空集)
1
1 2
1 2 3
1 3
2
2 3
3
✅ 完整解答

思路: 对每个元素,递归时选择「选」或「不选」(二叉回溯树)。

#include <bits/stdc++.h>
using namespace std;

int n;
vector<int> a, cur;

void backtrack(int idx) {
    // 输出当前子集
    for (int i = 0; i < (int)cur.size(); i++) {
        if (i) cout << " ";
        cout << cur[i];
    }
    cout << "\n";
    
    for (int i = idx; i < n; i++) {
        cur.push_back(a[i]);   // 选 a[i]
        backtrack(i + 1);
        cur.pop_back();         // 不选 a[i](回溯)
    }
}

int main() {
    cin >> n;
    a.resize(n);
    for (int& x : a) cin >> x;
    sort(a.begin(), a.end());  // 保证字典序
    backtrack(0);
    return 0;
}

理解回溯树(N=2,a=[1,2]):

backtrack(0):输出[]
  选1 → backtrack(1):输出[1]
    选2 → backtrack(2):输出[1,2]
    不选2
  不选1
  选2 → backtrack(2):输出[2]
  不选2

题目 5.2.8 — 回溯+剪枝:数字组合求和 🟡 中等

题目: 给定不含重复元素的正整数数组 candidates 和目标值 target,找出所有和为 target 的组合(每个数可以重复使用,组合内数字升序排列,不同组合间无重复)。

示例:

candidates=[2,3,6,7], target=7
输出:[2,2,3], [7]
✅ 完整解答
#include <bits/stdc++.h>
using namespace std;

vector<int> cands;
vector<vector<int>> result;
vector<int> cur;

void backtrack(int idx, int remain) {
    if (remain == 0) {
        result.push_back(cur);
        return;
    }
    
    for (int i = idx; i < (int)cands.size(); i++) {
        if (cands[i] > remain) break;   // 剪枝:已排序,超过 remain 的后续都不用看
        
        cur.push_back(cands[i]);
        backtrack(i, remain - cands[i]);  // i 而非 i+1,允许重复使用
        cur.pop_back();
    }
}

int main() {
    int n, target;
    cin >> n >> target;
    cands.resize(n);
    for (int& x : cands) cin >> x;
    sort(cands.begin(), cands.end());  // 排序,为了剪枝和去重
    
    backtrack(0, target);
    
    for (auto& v : result) {
        for (int i = 0; i < (int)v.size(); i++) {
            if (i) cout << " ";
            cout << v[i];
        }
        cout << "\n";
    }
    return 0;
}

关键剪枝: 数组已排序,若 cands[i] > remain,后续更大的数也一定超过,直接 break。


题目 5.2.9 — 双向 BFS:单词接龙 🟡 中等

题目: 给定起始单词 beginWord、目标单词 endWord 和单词列表。每次只能改变一个字母,且改变后的单词必须在列表中。求从 beginWordendWord 的最短转换路径长度,若不存在返回 0。

示例:

beginWord = "hit", endWord = "cog"
wordList = ["hot","dot","dog","lot","log","cog"]
输出:5(hit→hot→dot→dog→cog)
✅ 完整解答

普通 BFS 版:

#include <bits/stdc++.h>
using namespace std;

int word_ladder(string begin, string end, vector<string>& wordList) {
    unordered_set<string> words(wordList.begin(), wordList.end());
    if (!words.count(end)) return 0;
    
    queue<string> q;
    unordered_map<string, int> dist;
    dist[begin] = 1;
    q.push(begin);
    
    while (!q.empty()) {
        string cur = q.front(); q.pop();
        
        // 枚举所有相差一个字母的单词
        for (int i = 0; i < (int)cur.size(); i++) {
            string next = cur;
            for (char c = 'a'; c <= 'z'; c++) {
                next[i] = c;
                if (next == cur) continue;
                if (next == end) return dist[cur] + 1;
                if (words.count(next) && !dist.count(next)) {
                    dist[next] = dist[cur] + 1;
                    q.push(next);
                }
            }
        }
    }
    return 0;
}

int main() {
    string begin, end; int n;
    cin >> begin >> end >> n;
    vector<string> wordList(n);
    for (auto& w : wordList) cin >> w;
    cout << word_ladder(begin, end, wordList) << "\n";
    return 0;
}

双向 BFS 优化版(大词表时更快):

int word_ladder_bidir(string begin, string end, vector<string>& wordList) {
    unordered_set<string> words(wordList.begin(), wordList.end());
    if (!words.count(end)) return 0;
    
    // 两个方向的已访问集合
    unordered_set<string> front_visited = {begin};
    unordered_set<string> back_visited  = {end};
    int step = 1;
    
    while (!front_visited.empty() && !back_visited.empty()) {
        // 每次扩展较小的前沿(提高效率)
        if (front_visited.size() > back_visited.size())
            swap(front_visited, back_visited);
        
        unordered_set<string> next_front;
        for (const string& word : front_visited) {
            string next = word;
            for (int i = 0; i < (int)next.size(); i++) {
                char orig = next[i];
                for (char c = 'a'; c <= 'z'; c++) {
                    next[i] = c;
                    if (back_visited.count(next)) return step + 1;  // 相遇!
                    if (words.count(next) && !front_visited.count(next))
                        next_front.insert(next);
                    next[i] = orig;
                }
            }
        }
        front_visited = next_front;
        step++;
    }
    return 0;
}

题目 5.2.10 — 割点与桥:关键服务器 🔴 困难

题目: 有 N 台服务器和 M 条连接。若断开某条连接后,某些服务器无法相互通信,则这条连接是关键连接(即桥)。找出所有关键连接。

示例:

输入:4 4
     1-2, 1-3, 2-3, 3-4
输出:3-4
(删去 1-2 或 1-3 或 2-3,{1,2,3} 仍然连通;只有 3-4 是桥)
✅ 完整解答

直接使用 5.2.13 节的 Tarjan 桥检测算法:

#include <bits/stdc++.h>
using namespace std;

const int MAXN = 100005;
vector<pair<int,int>> adj[MAXN];  // {邻居, 边编号}
int disc[MAXN], low[MAXN], timer_val = 0;
bool visited[MAXN];
vector<pair<int,int>> bridges;

void dfs(int u, int par_edge) {
    visited[u] = true;
    disc[u] = low[u] = ++timer_val;
    
    for (auto [v, eid] : adj[u]) {
        if (eid == par_edge) continue;  // 不走来时的边(用边ID区分,处理重边)
        
        if (!visited[v]) {
            dfs(v, eid);
            low[u] = min(low[u], low[v]);
            
            if (low[v] > disc[u])       // 桥的判断
                bridges.push_back({u, v});
        } else {
            low[u] = min(low[u], disc[v]);
        }
    }
}

int main() {
    ios_base::sync_with_stdio(false); cin.tie(NULL);
    int n, m; cin >> n >> m;
    
    for (int i = 0; i < m; i++) {
        int u, v; cin >> u >> v;
        adj[u].push_back({v, i});
        adj[v].push_back({u, i});
    }
    
    for (int i = 1; i <= n; i++)
        if (!visited[i]) dfs(i, -1);
    
    cout << "关键连接数:" << bridges.size() << "\n";
    for (auto [u, v] : bridges)
        cout << u << " - " << v << "\n";
    return 0;
}

注意用「边 ID」而非「父节点」 来避免走回来时的边,这样可以正确处理重边(两个节点之间有多条边的情况)。


题目 5.2.11 — 记忆化 DFS:最长递增路径 🟡 中等

题目: 给定 N×M 矩阵,每次可以向上下左右移动到严格更大的相邻格子。求矩阵中最长递增路径的长度。

示例:

输入:3 3
     9 9 4
     6 6 8
     2 1 1
输出:4(路径 1→2→6→9)
✅ 完整解答

思路: 因为只能往更大的格子走,不会有环,图是 DAG。用记忆化 DFS:memo[r][c] = 从 (r,c) 出发的最长路径长度。

#include <bits/stdc++.h>
using namespace std;

int R, C;
vector<vector<int>> mat;
vector<vector<int>> memo;

int dr[] = {-1, 1, 0, 0};
int dc[] = {0, 0, -1, 1};

int dfs(int r, int c) {
    if (memo[r][c] != 0) return memo[r][c];  // 已计算过
    
    memo[r][c] = 1;  // 至少长度为 1(只含自身)
    for (int d = 0; d < 4; d++) {
        int nr = r + dr[d], nc = c + dc[d];
        if (nr < 0 || nr >= R || nc < 0 || nc >= C) continue;
        if (mat[nr][nc] > mat[r][c]) {             // 严格更大才能走
            memo[r][c] = max(memo[r][c], 1 + dfs(nr, nc));
        }
    }
    return memo[r][c];
}

int main() {
    ios_base::sync_with_stdio(false); cin.tie(NULL);
    cin >> R >> C;
    mat.assign(R, vector<int>(C));
    memo.assign(R, vector<int>(C, 0));
    for (auto& row : mat) for (int& x : row) cin >> x;
    
    int ans = 0;
    for (int r = 0; r < R; r++)
        for (int c = 0; c < C; c++)
            ans = max(ans, dfs(r, c));
    
    cout << ans << "\n";
    return 0;
}

为什么不需要 visited 数组? 因为路径单调递增,不可能访问同一个格子两次,天然无环。memo[r][c]!=0 就代表已经算过了。


题目 5.2.12 — 多源 BFS:地图离源距离 🟡 中等

题目: N×M 网格,有些格子是障碍 #,有些是出口 E,其余是空格 .
对所有空格,输出到最近出口的步数(不能穿越障碍),若无法到达输出 -1。

✅ 完整解答

思路: 多源 BFS——把所有出口以距离 0 同时压入队列,BFS 向外扩散。

#include <bits/stdc++.h>
using namespace std;

int main() {
    ios_base::sync_with_stdio(false); cin.tie(NULL);
    int R, C; cin >> R >> C;
    vector<string> grid(R);
    for (auto& row : grid) cin >> row;
    
    vector<vector<int>> dist(R, vector<int>(C, -1));
    queue<pair<int,int>> q;
    
    // 所有出口以距离 0 入队
    for (int r = 0; r < R; r++)
        for (int c = 0; c < C; c++)
            if (grid[r][c] == 'E') {
                dist[r][c] = 0;
                q.push({r, c});
            }
    
    int dr[] = {-1,1,0,0}, dc[] = {0,0,-1,1};
    while (!q.empty()) {
        auto [r, c] = q.front(); q.pop();
        for (int d = 0; d < 4; d++) {
            int nr = r+dr[d], nc = c+dc[d];
            if (nr<0||nr>=R||nc<0||nc>=C) continue;
            if (grid[nr][nc]=='#') continue;
            if (dist[nr][nc] != -1) continue;
            dist[nr][nc] = dist[r][c] + 1;
            q.push({nr, nc});
        }
    }
    
    for (int r = 0; r < R; r++) {
        for (int c = 0; c < C; c++) {
            if (grid[r][c] == '#') cout << "#  ";
            else if (grid[r][c] == 'E') cout << "E  ";
            else cout << dist[r][c] << "  ";
        }
        cout << "\n";
    }
    return 0;
}

题目 5.2.13 — 综合挑战:迷宫中的钥匙 🔴 困难

题目: N×M 迷宫,包含:

  • S:起点
  • E:终点
  • #:墙
  • .:空地
  • a~f:钥匙(小写字母)
  • A~F:锁住的门(大写字母,需要对应钥匙才能进入)

从 S 走到 E,求最少步数。钥匙捡起后一直携带。

提示: 状态需要扩展为 (行, 列, 已携带的钥匙集合),用 BFS 搜索最短路。

✅ 完整解答

关键:状态 = (位置 + 钥匙集合)。钥匙只有 6 把,用 6 位二进制整数表示集合(0~63)。

#include <bits/stdc++.h>
using namespace std;

int main() {
    ios_base::sync_with_stdio(false); cin.tie(NULL);
    int R, C; cin >> R >> C;
    vector<string> grid(R);
    for (auto& row : grid) cin >> row;
    
    int sr, sc, er, ec;
    for (int r = 0; r < R; r++)
        for (int c = 0; c < C; c++) {
            if (grid[r][c] == 'S') { sr=r; sc=c; }
            if (grid[r][c] == 'E') { er=r; ec=c; }
        }
    
    // dist[r][c][keys] = 步数,keys 是 6 位位掩码
    vector<vector<vector<int>>> dist(R, vector<vector<int>>(C, vector<int>(64, -1)));
    queue<tuple<int,int,int>> q;
    
    dist[sr][sc][0] = 0;
    q.push({sr, sc, 0});
    
    int dr[] = {-1,1,0,0}, dc[] = {0,0,-1,1};
    
    while (!q.empty()) {
        auto [r, c, keys] = q.front(); q.pop();
        
        if (r == er && c == ec) {
            cout << dist[er][ec][keys] << "\n";
            return 0;
        }
        
        for (int d = 0; d < 4; d++) {
            int nr = r+dr[d], nc = c+dc[d];
            if (nr<0||nr>=R||nc<0||nc>=C) continue;
            char cell = grid[nr][nc];
            if (cell == '#') continue;
            
            // 门:需要对应钥匙
            if (isupper(cell) && !(keys & (1 << (cell-'A')))) continue;
            
            // 捡钥匙
            int nkeys = keys;
            if (islower(cell)) nkeys |= (1 << (cell-'a'));
            
            if (dist[nr][nc][nkeys] == -1) {
                dist[nr][nc][nkeys] = dist[r][c][keys] + 1;
                q.push({nr, nc, nkeys});
            }
        }
    }
    
    cout << -1 << "\n";  // 无法到达
    return 0;
}

状态空间大小: R × C × 64 = 最多 50×50×64 = 160,000 个状态,BFS 可以高效处理。

示例追踪:

起点 S(0,0),keys=0
→ 走到 a(0,1):keys = 0 | (1<<0) = 1(拿到钥匙 a)
→ 走到 A(1,1):keys=1,A 对应位 0,keys&1=1 ✓,可以通过
→ 最终到达 E

📖 第 5.2b 章:函数图(Functional Graphs)

⏱ 预计阅读时间:35 分钟 | 难度:🟡 中等(USACO Silver 重要考点)


前置条件

  • BFS 与 DFS(第 5.2 章)
  • 图的基本表示(第 5.1 章)

🎯 学习目标

学完本章后,你将能够:

  1. 识别函数图的特征结构(每节点恰好一条出边)
  2. 找到函数图中的所有环(环检测)
  3. 计算图中每个节点距离其所在环的步数
  4. 解决"从节点 x 出发走 k 步到哪里"的跳跃问题(含快速幂加速)
  5. 应用到 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) 的最近公共后继。

✅ 解题思路
  1. 先找每个节点所属的环及到环的距离 dist_to_cycle[v]
  2. 若 u, v 在同一个环:最近公共后继就在环上,用倍增在环上找
  3. 若在不同环:无公共后继(输出 -1)
  4. 若一个在环外,先让环外的走 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 章 ⏱️ 约 80 分钟 🎯 进阶

第 5.4 章:最短路径

在节点间寻找最短路径是图论中最基础的问题之一。它出现在 GPS 导航、网络路由、游戏 AI 中,对我们来说最重要的是——USACO 题目。本章涵盖四种算法(Dijkstra、Bellman-Ford、Floyd-Warshall、SPFA),并解释何时使用哪种。


5.4.1 问题定义

单源最短路径(SSSP)

给定加权图 G = (V, E) 和源节点 s,找从 s所有其他节点的最短距离。

SSSP Example Graph

从源点 A:

  • dist[A] = 0
  • dist[B] = 1
  • dist[C] = 5
  • dist[D] = 5(A→B→D = 1+4)
  • dist[E] = 8(A→B→D→E = 1+4+3)

全对最短路径(APSP)

所有节点对之间的最短距离。

为什么不直接用 BFS?

BFS 找无权图的最短路径(每条边 = 距离 1)。有了权重:

  • 有些路径有很多短权重的边
  • 另一些有少量大权重的边
  • BFS 完全忽略权重 → 答案错误

5.4.2 Dijkstra 算法

最重要的最短路径算法。 用于约 90% 涉及加权最短路径的 USACO 题目。

时间
O((V+E) log V)
空间
O(V + E)
限制
非负权重
类型
单源

核心思想:贪心 + 优先队列

Dijkstra 是一个贪心算法

  1. 维护一个「已确定」节点集合(最短距离已最终确定)
  2. 每次处理当前距离最小的未访问节点
  3. 处理节点时,尝试松弛其邻居(如果找到更短路径则更新距离)

为什么贪心有效: 若所有边权非负,当前距离最小的节点不可能通过其他节点得到改善(所有替代路径 ≥ 当前距离)。

逐步追踪

Dijkstra Trace Graph

起点: 节点 0 | 初始: dist = [0, ∞, ∞, ∞, ∞]

步骤处理节点松弛操作dist 数组队列
1节点 0(dist=0)0→1: min(∞, 0+4)=4; 0→2: min(∞, 0+2)=2; 0→3: min(∞, 0+5)=5[0, 4, 2, 5, ∞]{(2,2),(4,1),(5,3)}
2节点 2(dist=2)2→3: min(5, 2+1)=3 ← 改善![0, 4, 2, 3, ∞]{(3,3),(4,1),(5,3_旧)}
3节点 3(dist=3)3→1: min(4, 3+1)=4(无变化); 3→4: min(∞, 3+3)=6[0, 4, 2, 3, 6]{(4,1),(6,4),(5,3_旧)}
4节点 1(dist=4)无可松弛[0, 4, 2, 3, 6]{(6,4)}
5节点 4(dist=6)完成![0, 4, 2, 3, 6]{}

最终: dist = [0, 4, 2, 3, 6]

完整 Dijkstra 实现

📄 查看代码:完整 Dijkstra 实现
// Dijkstra 算法(优先队列)— O((V+E) log V)
#include <bits/stdc++.h>
using namespace std;

typedef pair<int, int> pii;   // {距离, 节点}
typedef long long ll;

const ll INF = 1e18;          // 使用 long long 避免 int 溢出!
const int MAXN = 100005;

// 邻接表:adj[u] = {权重, v} 列表
vector<pii> adj[MAXN];

vector<ll> dijkstra(int src, int n) {
    vector<ll> dist(n + 1, INF);   // dist[i] = 到节点 i 的最短距离
    dist[src] = 0;

    // 最小堆:{距离, 节点}
    priority_queue<pii, vector<pii>, greater<pii>> pq;
    pq.push({0, src});

    while (!pq.empty()) {
        auto [d, u] = pq.top(); pq.pop();  // 取距离最小的节点

        // 关键:若已找到到 u 的更好路径则跳过(过期条目)
        if (d > dist[u]) continue;

        // 松弛 u 的所有邻居
        for (auto [w, v] : adj[u]) {
            ll newDist = dist[u] + w;
            if (newDist < dist[v]) {
                dist[v] = newDist;          // 更新距离
                pq.push({newDist, v});       // 将更新后的条目加入队列
            }
        }
    }
    return dist;
}

Dijkstra 的关键要点

🚫 关键:Dijkstra 对负边权不起作用! 若存在负边权,Dijkstra 可能产生错误结果。算法正确性依赖于贪心假设:一旦节点从优先队列弹出(已确定),其距离就是最终的——负边破坏这个假设。对含负边权的图,改用 Bellman-FordSPFA

Dijkstra 负边权失败原因

  • 只对非负权重有效。 负边破坏贪心假设。
  • 当边权较大时,距离用 long longdist[u] + w 可能溢出 int
  • greater<pii>priority_queue 成为最小堆。
  • if (d > dist[u]) continue; 检查对正确性和性能至关重要。

5.4.3 Bellman-Ford 算法

当边权可以是负数时,Dijkstra 失败。Bellman-Ford 处理负边权,甚至能检测负环。

时间
O(V × E)
负边
✓ 支持
负环
✓ 可检测

核心思想:松弛 V-1 次

关键洞察:有 V 个节点的图中,任意最短路径最多使用 V-1 条边(不重复节点)。所以若松弛所有边 V-1 次,保证能找到正确的最短路径。

Bellman-Ford 含负边权的图

Bellman-Ford 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 算法

用于找所有节点对之间的最短路径。

时间
O(V³)
空间
O(V²)
负边
✓ 支持
类型
全对

核心思想:通过中间节点的 DP

dp[k][i][j] = 只使用节点 {1, 2, ..., k} 作为中间节点时,从 i 到 j 的最短距离。

Floyd-Warshall DP 状态转移

递推:

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 算法对比表

算法时间复杂度负边负环检测多源最适合
BFSO(V + E)✗ 否✗ 否✓ 是(多源 BFS)无权图
DijkstraO((V+E) log V)✗ 否✗ 否✗(每源运行一次)加权非负边图
Bellman-FordO(V × E)✓ 是✓ 是负边、检测负环
SPFA最坏 O(V × E),平均 O(E)✓ 是✓ 是稀疏图含负边
Floyd-WarshallO(V³)✓ 是✓ 是(对角线)✓ 是(全对)稠密图、全对查询

如何选择?

图有负边吗?
├── 有 → Bellman-Ford、SPFA(或全对用 Floyd-Warshall)
└── 没有 → V ≤ 500 且需要全对最短路?
          ├── 是 → Floyd-Warshall  O(V³)
          └── 否 → 无权图(所有边 = 1)?
                    ├── 是 → BFS  O(V+E)
                    └── 否 → 边权只有 0 或 1?
                              ├── 是 → 0-1 BFS  O(V+E)
                              └── 否 → Dijkstra  O((V+E) log V)

5.4.6 SPFA——带队列优化的 Bellman-Ford

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。

算法步骤

  1. 建超级源点 0,向所有节点连 0 权边
  2. 用 Bellman-Ford 求 0 到所有点的最短路 h[i](若存在负环则无解)
  3. 重新标注边权: w'(u,v) = w(u,v) + h[u] - h[v](保证非负)
  4. 以每个点为源点跑 N 次 Dijkstra
  5. 还原答案: 实际最短路 = Dijkstra 结果 - h[s] + h[t]
📄 5. **还原答案:** 实际最短路 = Dijkstra 结果 - `h[s]` + `h[t]`
// Johnson 全源最短路 — O(NM log M)
#include <bits/stdc++.h>
using namespace std;
typedef long long ll;
const ll INF = 1e18;

int n, m;
// adj[u] = {v, w},edges 存所有边用于 Bellman-Ford
vector<pair<int,ll>> adj[505];
struct Edge { int u, v; ll w; };
vector<Edge> edges;

vector<ll> bellman_ford(int s) {
    vector<ll> dist(n + 1, INF);
    dist[s] = 0;
    for (int i = 0; i < n; i++) {
        for (auto [u, v, w] : edges) {
            if (dist[u] != INF && dist[u] + w < dist[v])
                dist[v] = dist[u] + w;
        }
    }
    return dist;
}

vector<ll> dijkstra(int s, int n) {
    vector<ll> dist(n + 1, INF);
    priority_queue<pair<ll,int>, vector<pair<ll,int>>, greater<>> pq;
    dist[s] = 0; pq.push({0, s});
    while (!pq.empty()) {
        auto [d, u] = pq.top(); pq.pop();
        if (d > dist[u]) continue;
        for (auto [v, w] : adj[u]) {
            if (dist[u] + w < dist[v]) {
                dist[v] = dist[u] + w;
                pq.push({dist[v], v});
            }
        }
    }
    return dist;
}

// 返回 dist[i][j] = i 到 j 的真实最短路
// 若 i 到 j 不可达返回 INF
vector<vector<ll>> johnson() {
    // 超级源点 n+1 向所有节点连 0 权边
    for (int i = 1; i <= n; i++)
        edges.push_back({n + 1, i, 0});

    // Bellman-Ford 求势函数 h[]
    vector<ll> h = bellman_ford(n + 1);

    // 重新标注边权(保证非负)
    for (auto& e : edges) {
        if (e.u != n + 1)  // 不处理超级源点的虚拟边
            e.w += h[e.u] - h[e.v];
    }
    // 同步更新邻接表
    for (int u = 1; u <= n; u++)
        for (auto& [v, w] : adj[u])
            w += h[u] - h[v];

    // N 次 Dijkstra
    vector<vector<ll>> ans(n + 1, vector<ll>(n + 1, INF));
    for (int s = 1; s <= n; s++) {
        auto d = dijkstra(s, n);
        for (int t = 1; t <= n; t++) {
            if (d[t] != INF)
                ans[s][t] = d[t] - h[s] + h[t];  // 还原真实距离
        }
    }
    return ans;
}

💡 为什么重标后边权非负? 三角不等式保证 h[v] ≤ h[u] + w(u,v),因此 w'(u,v) = w(u,v) + h[u] - h[v] ≥ 0


5.4.8 输出最短路径方案

pre[] 数组记录前驱节点,松弛时同步更新:

📄 用 `pre[]` 数组记录前驱节点,松弛时同步更新:
// Dijkstra 带路径输出
vector<int> dijkstra_with_path(int src, int dst, int n) {
    vector<ll> dist(n + 1, INF);
    vector<int> pre(n + 1, -1);  // pre[v] = v 的前驱
    priority_queue<pair<ll,int>, vector<pair<ll,int>>, greater<>> pq;

    dist[src] = 0; pq.push({0, src});
    while (!pq.empty()) {
        auto [d, u] = pq.top(); pq.pop();
        if (d > dist[u]) continue;
        for (auto [v, w] : adj[u]) {
            if (dist[u] + w < dist[v]) {
                dist[v] = dist[u] + w;
                pre[v] = u;          // 记录前驱
                pq.push({dist[v], v});
            }
        }
    }

    // 根据 pre[] 还原路径(逆序)
    vector<int> path;
    for (int v = dst; v != -1; v = pre[v])
        path.push_back(v);
    reverse(path.begin(), path.end());

    // 检查路径是否从 src 出发
    if (path.empty() || path[0] != src) return {};  // 不可达
    return path;  // src → ... → dst
}

当所有边权为 1(无权图)时,BFS 就是用简单队列的 Dijkstra。

0-1 BFS: 当边权只有 0 或 1 时,用双端队列代替队列的强力技巧:

0-1 BFS 双端队列工作原理

双端队列:[队首 → 距离最小 ... → 队尾 → 距离最大]

松弛邻居 v(经由权重 w 的边 u→v):
  w = 0 → push_front(v)   (与 u 距离相同——放在前面)
  w = 1 → push_back(v)    (多一步——放在后面)

💡 效率: 0-1 BFS 运行 O(V+E)——比 Dijkstra 的 O((V+E) log V) 更快。当边权只有 0 和 1 时,始终优先用 0-1 BFS。

📄 C++ 完整代码
// 0-1 BFS — O(V + E),处理 {0,1} 权重边
vector<int> bfs01(int src, int n) {
    vector<int> dist(n + 1, INT_MAX);
    deque<int> dq;

    dist[src] = 0;
    dq.push_front(src);

    while (!dq.empty()) {
        int u = dq.front(); dq.pop_front();

        for (auto [w, v] : adj[u]) {
            if (dist[u] + w < dist[v]) {
                dist[v] = dist[u] + w;
                if (w == 0) dq.push_front(v);   // 0 权重:加到前面
                else        dq.push_back(v);    // 1 权重:加到后面
            }
        }
    }
    return dist;
}

5.4.8 网格上的 Dijkstra

许多 USACO 题目涉及网格最短路径,图是隐式的:

📄 许多 USACO 题目涉及网格最短路径,图是隐式的:
// 网格 Dijkstra — 找从 (0,0) 到 (R-1,C-1) 的最短路径
// 每个格子有进入代价
#include <bits/stdc++.h>
using namespace std;

typedef long long ll;
typedef tuple<ll,int,int> tli;

const ll INF = 1e18;
int dx[] = {0,0,1,-1};
int dy[] = {1,-1,0,0};

ll dijkstraGrid(vector<vector<int>>& grid) {
    int R = grid.size(), C = grid[0].size();
    vector<vector<ll>> dist(R, vector<ll>(C, INF));
    priority_queue<tli, vector<tli>, greater<tli>> pq;

    dist[0][0] = grid[0][0];
    pq.push({grid[0][0], 0, 0});

    while (!pq.empty()) {
        auto [d, r, c] = pq.top(); pq.pop();
        if (d > dist[r][c]) continue;

        for (int k = 0; k < 4; k++) {
            int nr = r + dx[k], nc = c + dy[k];
            if (nr < 0 || nr >= R || nc < 0 || nc >= C) continue;

            ll newDist = dist[r][c] + grid[nr][nc];
            if (newDist < dist[nr][nc]) {
                dist[nr][nc] = newDist;
                pq.push({newDist, nr, nc});
            }
        }
    }
    return dist[R-1][C-1];
}

⚠️ 五大经典 Dijkstra Bug

  1. int 而不是 long long —— 距离和溢出 → 静默的错误答案
  2. 最大堆而非最小堆 —— 忘记 greater<pii> → 优先处理错误的节点
  3. 缺少过期条目检查if (d > dist[u]) continue)—— 不是错误但慢约 10 倍
  4. 忘记 dist[src] = 0 —— 所有距离保持为 INF
  5. 对负边用 Dijkstra —— 未定义行为,可能无限循环或给出错误答案

本章总结

📌 核心要点

算法复杂度处理负边使用场景
BFSO(V+E)无权图
DijkstraO((V+E) log V)非负权重加权 SSSP
Bellman-FordO(VE)负边、检测负环
SPFA最坏 O(VE),平均快稀疏图含负边
Floyd-WarshallO(V³)全对、V ≤ 500
0-1 BFSO(V+E)不适用只有 0 或 1 权重的边

❓ 常见问题

Q1:为什么 Dijkstra 对负边不起作用?

A:Dijkstra 的贪心假设是「当前距离最短的节点不能通过后续路径改善」。有了负边,这个假设失败——通过负边的较长路径最终可能更短。结论:有负边必须用 Bellman-Ford(O(VE))或 SPFA(平均 O(E),最坏 O(VE))。

Q2:SPFA 和 Bellman-Ford 有什么区别?

A:SPFA 是队列优化版的 Bellman-Ford。Bellman-Ford 每轮遍历所有边;SPFA 只更新距离被改善的节点的邻居,用队列追踪哪些节点需要处理。实践中 SPFA 快得多(平均 O(E)),但理论最坏情况相同(O(VE))。

Q3:Floyd-Warshall 中为什么 k 循环必须在最外层?

A:这是最常见的 Floyd-Warshall 实现错误! DP 不变量是:处理第 k 次外层循环后,dist[i][j] 表示只用 {1, 2, ..., k} 作中间节点时从 i 到 j 的最短路径。处理中间节点 k 时,dist[i][k]dist[k][j] 必须已经基于 {1..k-1} 完整计算好。若 k 在内层,dist[i][k] 可能在同一轮刚被更新,导致错误结果。记住:k 在最外层,i 和 j 在内层——顺序很重要!

Q4:USACO 题目如何判断用 Dijkstra 还是 BFS?

A:关键问题:边是否有权重?

  • 无权图(边权=1 或求最少边数)→ BFS,O(V+E),更快更简单
  • 加权图(不同的非负权重)→ Dijkstra
  • 边权只有 0 或 1 → 0-1 BFS(比 Dijkstra 快,O(V+E))
  • 有负边 → Bellman-Ford/SPFA

Q5:什么时候用 Floyd-Warshall?

A:需要所有节点对之间的最短距离,且 V ≤ 500(O(V³) ≈ 1.25×10^8 在 V=500 时勉强可行)。典型场景:给定多个源和目标,查询任意对之间的距离。V > 500 时,对每个节点运行 Dijkstra(O(V × (V+E) log V))更快。

🔗 与其他章节的联系

  • 第 5.2 章(BFS 与 DFS):BFS 是「无权图的 Dijkstra」;本章是 BFS 的直接扩展
  • 第 5.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 章 ⏱️ 约 60 分钟 🎯 中级

第 3.11 章:二叉树

前置条件 你应该熟悉:递归(第 2.3 章)、C++ 中的指针/结构体,以及基本的图概念(邻接关系、节点、边)。本章综合了二叉树基础与进阶树算法(LCA 倍增、欧拉序),是 USACO Silver/Gold 的核心。

二叉树是竞赛编程中一些最重要数据结构的基础——从二叉搜索树(BST)到线段树再到堆。深刻理解它们将使图论算法、树上 DP 和 USACO Gold 题目变得更容易上手。


3.11.1 二叉树基础

二叉树是一种层级数据结构:

  • 每个节点最多有 2 个子节点:左子节点和右子节点
  • 恰好有一个根节点(无父节点)
  • 每个非根节点恰好有一个父节点
🌳
核心术语
根节点 — 最顶层的节点(深度 0)
叶节点 — 没有子节点的节点
内部节点 — 至少有一个子节点的节点
高度 — 从根到任意叶节点的最长路径
深度 — 从根到该节点的距离
子树 — 一个节点及其所有后代

图示

Binary Tree Structure

在这棵树中:

  • 高度 = 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

三种遍历顺序访问相同的树,但顺序完全不同——各有独特的使用场景:

Binary Tree Traversals


3.11.2 二叉搜索树(BST)

二叉搜索树是带有关键排序性质的二叉树:

BST 性质
左子树 < 节点 < 右子树
搜索
平均 O(log N)
插入
平均 O(log N)
删除
平均 O(log N)
最坏情况
O(N)

BST 性质: 对每个节点 v

  • 左子树中所有值都严格小于 v.val
  • 右子树中所有值都严格大于 v.val
       [5]          ← 有效的 BST
      /    \
    [3]    [8]
   /   \   /  \
  [1] [4] [7] [10]

  5 的左边 = {1, 3, 4} — 都 < 5  ✓
  5 的右边 = {7, 8, 10} — 都 > 5  ✓

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 种情况:

  1. 节点无子节点(叶节点):直接删除
  2. 节点有一个子节点:用子节点替换该节点
  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 性质,直到找到空位:

BST Insert

BST 插入操作逐步追踪

⚠️ 关键问题: 如果按有序顺序插入(1, 2, 3, 4, 5...),BST 会退化为链表

[1]
  \
  [2]
    \
    [3]        ← 每次操作 O(N),不是 O(log N)!
      \
      [4]
        \
        [5]

这就是平衡 BST(AVL 树、红黑树)存在的原因。C++ 中 std::setstd::map 用红黑树实现——始终保证 O(log N)

AVL 树旋转:左旋 & 右旋

🔗 关键结论:竞赛编程中,用 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)——暴力方法

有根树中两个节点 uvLCA 是它们的最深公共祖先。

📄 有根树中两个节点 `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;
}
暴力法
每次查询 O(N)
二进制倍增
每次查询 O(log N)
构建时间
O(N log N)

💡 USACO 说明: 对于 USACO Silver 题目,O(N) 暴力 LCA 并非总是够用。N ≤ 10^5 个节点且 Q ≤ 10^5 次查询时,总计 O(NQ) = O(10^10)——太慢了。只在 N, Q ≤ 5000 时使用暴力。本章 §5.5.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

  1. 忘记 nullptr 基础情况 —— 立即导致段错误
  2. 插入/删除后没有返回(可能是新的)根节点 —— 树结构损坏
  3. 栈溢出 —— N > 10^5 时用迭代遍历
  4. 内存泄漏 —— 始终 delete 删除的节点(或用智能指针)
  5. STL set 够用时却手写 BST —— 竞赛中直接用 std::set

本章总结

📌 核心要点

概念要点时间复杂度
BST 搜索根据比较结果向左/向右走平均 O(log N),最坏 O(N)
BST 插入找到正确位置,在空处插入平均 O(log N)
BST 删除3 种情况:叶节点、单子节点、双子节点平均 O(log N)
中序左 → 根 → 右O(N)
前序根 → 左 → 右O(N)
后序左 → 右 → 根O(N)
层序BFS 按层O(N)
高度max(左高, 右高) + 1O(N)
LCA(暴力)找路径再比较每次查询 O(N)
LCA(二进制倍增)预处理 2^k 祖先预处理 O(N log N),查询 O(log N)
欧拉序DFS 时间戳展平树预处理 O(N),子树查询 O(1)~O(log N)

❓ 常见问题

Q1:什么时候用 BST vs std::set?

A:竞赛编程中几乎始终用 std::setstd::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 + RMQO(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 章)

🎯 学习目标

学完本章后,你将能够:

  1. 用「路径压缩 + 按秩合并」实现 O(α(n)) 的并查集
  2. 用并查集判断图的连通性和环
  3. 实现带权并查集解决差值/关系问题
  4. 用种类并查集解决多关系分组问题
  5. 独立完成 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/DFSO(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 都是把一棵树的根指向另一棵树的根,而不是任意两个节点直接相连
  • 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)」。

你需要回答:

  1. B 比 A 高多少厘米?
  2. 某条信息是否与之前的矛盾?

朴素思路: 用图建模,但查询每次都要 BFS 遍历路径,O(N) 每次查询太慢。

带权并查集的思路: 在每个节点存储「它到根节点的高度差 dist[x]」,查询时直接用 dist 相减。

带权并查集 dist[] 示意图

核心设计

  • dist[x] = height[x] - height[find(x)](x 的身高减去根的身高)
  • 路径压缩时,累加路径上的 dist,把 x 直接连到根:
压缩前:x → p → root
  dist[x] = height[x] - height[p]
  dist[p] = height[p] - height[root]

压缩后:x → root
  新的 dist[x] 应该 = height[x] - height[root]
                    = dist[x] + dist[p]
int find(int x) {
    if (pa[x] == x) return x;
    int root = find(pa[x]);
    dist[x] += dist[pa[x]];  // 压缩同时累加路径权值
    pa[x] = root;
    return root;
}

合并时计算新边权值

「声明 height[y] - height[x] = d」:

📄 「声明 height[y] - height[x] = d」:
我们有:dist[x] = height[x] - height[rx]
        dist[y] = height[y] - height[ry]

若把 ry 接到 rx 下,则需要 dist[ry] 满足:
    height[ry] - height[rx] = ?
    
由 height[y] - height[x] = d 推导:
    (dist[y] + height[ry]) - (dist[x] + height[rx]) = d
    height[ry] - height[rx] = d + dist[x] - dist[y]

所以 dist[ry] = d + dist[x] - dist[y]
📄 C++ 完整代码
// 完整带权并查集
#include <bits/stdc++.h>
using namespace std;

const int MAXN = 100005;
int pa[MAXN], sz[MAXN];
long long dist[MAXN];  // dist[x] = height[x] - height[find(x)]

void init(int n) {
    for (int i = 1; i <= n; i++) { pa[i] = i; sz[i] = 1; dist[i] = 0; }
}

int find(int x) {
    if (pa[x] == x) return x;
    int root = find(pa[x]);
    dist[x] += dist[pa[x]];
    pa[x] = root;
    return root;
}

// 声明 height[y] - height[x] = d
// 返回 true = 无矛盾;false = 与已知矛盾
bool unite(int x, int y, long long d) {
    int rx = find(x), ry = find(y);
    long long dx = dist[x], dy = dist[y];
    
    if (rx == ry) {
        // 已在同集合,验证是否矛盾
        return (dy - dx == d);
    }
    
    // 合并:小树接大树
    if (sz[rx] < sz[ry]) {
        swap(rx, ry); swap(dx, dy); d = -d;
    }
    pa[ry] = rx;
    dist[ry] = d + dx - dy;
    sz[rx] += sz[ry];
    return true;
}

long long query(int x, int y) {
    find(x); find(y);
    return dist[y] - dist[x];  // height[y] - height[x]
}

int main() {
    int n = 5;
    init(n);
    
    unite(1, 2, 3);   // height[2] - height[1] = 3(2 比 1 高 3cm)
    unite(2, 3, 5);   // height[3] - height[2] = 5
    
    // 查询 1 和 3 的差
    cout << "3 比 1 高 " << query(1, 3) << " cm\n";  // 输出 8
    
    // 添加矛盾信息
    cout << (unite(1, 3, 10) ? "一致" : "矛盾") << "\n";  // 矛盾(应该是8)
    cout << (unite(1, 3, 8)  ? "一致" : "矛盾") << "\n";  // 一致
    
    return 0;
}

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;
}

⚠️ 常见错误

错误原因修复方案
带权并查集路径压缩后权值错误没在递归中累加 distdist[x] += dist[pa[x]] 在改 pa[x] 前执行
合并时没更新 sz只改了 pa,忘记维护大小sz[rx] += sz[ry]
种类并查集数组开小需要 3N 个节点int pa[MAXN * 3]
先 unite 再判矛盾合并后信息已融合,无法判断必须先判矛盾,再 unite
find 递归爆栈极长链时(N > 10^5)递归深度溢出用迭代版路径压缩替代

💪 练习题(共 10 道,全部含完整解答)

🟢 基础练习(1~4)

题目 1:朋友圈计数
给 N 个人(编号 1~N)和 M 对朋友关系,同一朋友圈里的人可以互相联系。
求最终共有多少个朋友圈。

输入: N M,然后 M 行每行 A B 表示 A 和 B 是朋友。
输出: 朋友圈数量。

示例:

输入:5 3
     1 2
     2 3
     4 5

输出:2
({1,2,3} 和 {4,5})
✅ 完整解答

思路: 每次合并时若两人原本不在同一集合(unite 返回 true),连通块数 -1。最终 dsu.groups 就是答案。

#include <bits/stdc++.h>
using namespace std;

struct DSU {
    vector<int> pa, sz;
    int groups;
    explicit DSU(int n) : pa(n + 1), sz(n + 1, 1), groups(n) {
        iota(pa.begin(), pa.end(), 0);
    }
    int find(int x) { return pa[x] == x ? x : pa[x] = find(pa[x]); }
    bool unite(int x, int y) {
        x = find(x); y = find(y);
        if (x == y) return false;
        if (sz[x] < sz[y]) swap(x, y);
        pa[y] = x; sz[x] += sz[y]; groups--;
        return true;
    }
};

int main() {
    ios_base::sync_with_stdio(false); cin.tie(NULL);
    int n, m;
    cin >> n >> m;
    DSU dsu(n);
    while (m--) {
        int a, b; cin >> a >> b;
        dsu.unite(a, b);
    }
    cout << dsu.groups << "\n";
    return 0;
}

追踪(N=5, M=3):

初始 groups = 5
unite(1,2) → 不同集合,groups = 4,pa[2]=1
unite(2,3) → 不同集合,groups = 3,pa[3]=1
unite(4,5) → 不同集合,groups = 2,pa[5]=4
输出:2 ✓

题目 2:判断图中是否有环
给 N 个节点和 M 条无向边,判断这个图是否包含环。

输入: N M,然后 M 行每行 U V 表示一条边。
输出: YES(有环)或 NO(无环)。

✅ 完整解答

思路: 加一条边 (u, v) 时,若 u 和 v 已经连通(find(u)==find(v)),则这条边构成环。

#include <bits/stdc++.h>
using namespace std;

struct DSU {
    vector<int> pa, sz;
    explicit DSU(int n) : pa(n + 1), sz(n + 1, 1) {
        iota(pa.begin(), pa.end(), 0);
    }
    int find(int x) { return pa[x] == x ? x : pa[x] = find(pa[x]); }
    bool unite(int x, int y) {
        x = find(x); y = find(y);
        if (x == y) return false;  // 已连通 = 加边后形成环
        if (sz[x] < sz[y]) swap(x, y);
        pa[y] = x; sz[x] += sz[y];
        return true;
    }
};

int main() {
    ios_base::sync_with_stdio(false); cin.tie(NULL);
    int n, m;
    cin >> n >> m;
    DSU dsu(n);
    bool has_cycle = false;
    while (m--) {
        int u, v; cin >> u >> v;
        if (!dsu.unite(u, v)) has_cycle = true;
    }
    cout << (has_cycle ? "YES" : "NO") << "\n";
    return 0;
}

关键点: unite 返回 false 代表两端已连通 → 这条边是多余边 → 存在环。


题目 3:最大连通块
给 N 个节点和 M 条边,输出最大连通块包含的节点数。

✅ 完整解答

思路: 用带 sz[] 的并查集,合并结束后遍历所有节点,找 sz[find(i)] 的最大值。

#include <bits/stdc++.h>
using namespace std;

struct DSU {
    vector<int> pa, sz;
    explicit DSU(int n) : pa(n + 1), sz(n + 1, 1) {
        iota(pa.begin(), pa.end(), 0);
    }
    int find(int x) { return pa[x] == x ? x : pa[x] = find(pa[x]); }
    void unite(int x, int y) {
        x = find(x); y = find(y);
        if (x == y) return;
        if (sz[x] < sz[y]) swap(x, y);
        pa[y] = x; sz[x] += sz[y];
    }
    int size(int x) { return sz[find(x)]; }
};

int main() {
    int n, m;
    cin >> n >> m;
    DSU dsu(n);
    while (m--) {
        int u, v; cin >> u >> v;
        dsu.unite(u, v);
    }
    int ans = 0;
    for (int i = 1; i <= n; i++)
        ans = max(ans, dsu.size(i));
    cout << ans << "\n";
    return 0;
}

题目 4:Kruskal 最小生成树
给 N 个节点和 M 条带权无向边,求最小生成树的总权重。若图不连通输出 -1。

✅ 完整解答

思路: Kruskal 算法:将所有边按权重从小到大排序,依次尝试加入。若两端不在同一集合(不会成环),则加入 MST。最终若 MST 边数 = N-1,则图连通。

#include <bits/stdc++.h>
using namespace std;

struct DSU {
    vector<int> pa, sz;
    explicit DSU(int n) : pa(n + 1), sz(n + 1, 1) {
        iota(pa.begin(), pa.end(), 0);
    }
    int find(int x) { return pa[x] == x ? x : pa[x] = find(pa[x]); }
    bool unite(int x, int y) {
        x = find(x); y = find(y);
        if (x == y) return false;
        if (sz[x] < sz[y]) swap(x, y);
        pa[y] = x; sz[x] += sz[y];
        return true;
    }
};

int main() {
    int n, m;
    cin >> n >> m;
    
    vector<tuple<int,int,int>> edges(m);
    for (auto& [w, u, v] : edges) cin >> u >> v >> w;
    sort(edges.begin(), edges.end());  // 按权重排序
    
    DSU dsu(n);
    long long total = 0;
    int cnt = 0;  // MST 中的边数
    
    for (auto& [w, u, v] : edges) {
        if (dsu.unite(u, v)) {
            total += w;
            cnt++;
            if (cnt == n - 1) break;  // 找够 n-1 条边
        }
    }
    
    cout << (cnt == n - 1 ? total : -1) << "\n";
    return 0;
}

追踪示例(N=4, 边:1-2权1, 2-3权2, 1-3权3, 3-4权4):

排序后:(1,1,2), (2,2,3), (3,1,3), (4,3,4)

加边(1,2)权1 → unite成功,cnt=1,total=1
加边(2,3)权2 → unite成功,cnt=2,total=3
加边(1,3)权3 → find(1)=find(3),已连通,跳过
加边(3,4)权4 → unite成功,cnt=3=n-1,total=7

输出:7

🟡 进阶练习(5~8)

题目 5:网络连通性查询
给 N 台服务器,M 次操作:

  • connect A B:在 A 和 B 之间建立链路
  • query A B:询问 A 和 B 是否可以通信
  • block A B:断开 A 和 B 之间的直连链路(注意:不是断开连通性!

输出所有 query 的结果。

提示: 普通并查集不支持「断边」。解决方案:离线倒序处理——将操作逆序执行,把「断边」变成「加边」。

✅ 完整解答

核心思路:

  1. 先记录所有操作
  2. 从后向前处理:block 变成 connect,正向的 connect 但时间上在 block 之前需要排除
  3. 并查集 + 离线逆序处理,输出时逆序输出 query 结果

更简洁的方案:预处理每条边「实际存在的时间段」,再用离线并查集。

以下给出离线逆序 + 回收操作的简化版本(假设每对服务器最多断开一次):

#include <bits/stdc++.h>
using namespace std;

struct DSU {
    vector<int> pa, sz;
    explicit DSU(int n) : pa(n + 1), sz(n + 1, 1) {
        iota(pa.begin(), pa.end(), 0);
    }
    int find(int x) { return pa[x] == x ? x : pa[x] = find(pa[x]); }
    bool unite(int x, int y) {
        x = find(x); y = find(y);
        if (x == y) return false;
        if (sz[x] < sz[y]) swap(x, y);
        pa[y] = x; sz[x] += sz[y];
        return true;
    }
    bool connected(int x, int y) { return find(x) == find(y); }
};

int main() {
    int n, m;
    cin >> n >> m;
    
    vector<tuple<int,int,int>> ops(m);  // {type, a, b},type: 0=connect,1=query,2=block
    set<pair<int,int>> blocked;         // 已断开的边
    
    for (auto& [t, a, b] : ops) {
        string op; cin >> op >> a >> b;
        if (op == "connect") t = 0;
        else if (op == "query") t = 1;
        else { t = 2; blocked.insert({min(a,b), max(a,b)}); }
    }
    
    // 逆序处理
    DSU dsu(n);
    // 先加入所有「最终状态下存在」的边(connect 且未被 block 的)
    for (auto& [t, a, b] : ops) {
        if (t == 0) {
            auto key = make_pair(min(a,b), max(a,b));
            if (!blocked.count(key)) dsu.unite(a, b);
        }
    }
    
    vector<string> answers;
    for (int i = m - 1; i >= 0; i--) {
        auto [t, a, b] = ops[i];
        if (t == 2) {
            // 逆序时 block 变 connect
            dsu.unite(a, b);
        } else if (t == 1) {
            answers.push_back(dsu.connected(a, b) ? "YES" : "NO");
        }
        // connect 在逆序中不处理(已在初始化时加入)
    }
    
    reverse(answers.begin(), answers.end());
    for (auto& s : answers) cout << s << "\n";
    return 0;
}

题目 6:身高差查询(带权并查集)
N 名学生,M 条信息。每条信息格式为 A B D 表示「B 比 A 高 D cm(可为负)」。
然后 Q 次查询,每次询问「B 比 A 高多少 cm」,若无法推断输出 unknown,若已知矛盾输出 conflict

✅ 完整解答
#include <bits/stdc++.h>
using namespace std;

const int MAXN = 100005;
int pa[MAXN], sz_arr[MAXN];
long long dist[MAXN];

void init(int n) {
    for (int i = 1; i <= n; i++) { pa[i] = i; sz_arr[i] = 1; dist[i] = 0; }
}

int find(int x) {
    if (pa[x] == x) return x;
    int root = find(pa[x]);
    dist[x] += dist[pa[x]];
    pa[x] = root;
    return root;
}

// 声明 height[b] - height[a] = d,返回 false 表示矛盾
bool add_info(int a, int b, long long d) {
    int ra = find(a), rb = find(b);
    long long da = dist[a], db = dist[b];
    if (ra == rb) return (db - da == d);
    if (sz_arr[ra] < sz_arr[rb]) { swap(ra, rb); swap(da, db); d = -d; }
    pa[rb] = ra;
    dist[rb] = d + da - db;
    sz_arr[ra] += sz_arr[rb];
    return true;
}

int main() {
    int n, m, q;
    cin >> n >> m;
    init(n);
    
    bool global_conflict = false;
    for (int i = 0; i < m; i++) {
        int a, b; long long d;
        cin >> a >> b >> d;
        if (!add_info(a, b, d)) global_conflict = true;
    }
    
    cin >> q;
    while (q--) {
        int a, b; cin >> a >> b;
        int ra = find(a), rb = find(b);
        if (ra != rb) cout << "unknown\n";
        else if (global_conflict) cout << "conflict\n";
        else cout << dist[b] - dist[a] << "\n";
    }
    return 0;
}

输入示例:

5 3
1 2 3    → height[2] - height[1] = 3
2 3 5    → height[3] - height[2] = 5
1 3 8    → height[3] - height[1] = 8(与前两条一致)

2
1 3      → 输出 8
1 4      → 输出 unknown

题目 7:方格染色(种类并查集变形)
N × N 方格,每格初始为白色。M 次操作,每次给某行或某列的所有格子染色(黑←→白 翻转)。
操作结束后,询问 Q 个格子的颜色(黑或白)。

提示: 用「行并查集」和「列并查集」分别维护,结合奇偶性(翻转次数的奇偶)跟踪颜色。

✅ 完整解答(简化版:只处理行翻转)

用带权并查集,dist[x] 记录奇偶性(0=未翻转,1=翻转过奇数次):

#include <bits/stdc++.h>
using namespace std;

const int MAXN = 200005;
int pa[MAXN];
int flip[MAXN];  // flip[x] = x 相对于根的翻转次数(mod 2)

void init(int n) {
    for (int i = 0; i <= n; i++) { pa[i] = i; flip[i] = 0; }
}

int find(int x) {
    if (pa[x] == x) return x;
    int root = find(pa[x]);
    flip[x] ^= flip[pa[x]];  // 路径上翻转次数的奇偶叠加
    pa[x] = root;
    return root;
}

// 声明「x 和 y 属于同组,且翻转关系为 d(0=同色,1=不同色)」
void unite(int x, int y, int d) {
    int rx = find(x), ry = find(y);
    int fx = flip[x], fy = flip[y];
    if (rx == ry) return;
    pa[ry] = rx;
    flip[ry] = d ^ fx ^ fy;
}

// 查询 x 的颜色相对于根的偏移
int query(int x) {
    find(x);
    return flip[x];
}

此模板适用于所有「奇偶关系」类型的种类并查集问题。


题目 8:标准并查集模板(Luogu P3367)
即标准并查集模板题:M 次操作,每次 1 X Y(合并 X 和 Y 所在的集合)或 2 X Y(查询 X 和 Y 是否在同一集合,输出 YN)。

✅ 完整解答

这是标准并查集裸题,直接套模板:

#include <bits/stdc++.h>
using namespace std;

struct DSU {
    vector<int> pa, sz;
    explicit DSU(int n) : pa(n + 1), sz(n + 1, 1) {
        iota(pa.begin(), pa.end(), 0);
    }
    int find(int x) { return pa[x] == x ? x : pa[x] = find(pa[x]); }
    void unite(int x, int y) {
        x = find(x); y = find(y);
        if (x == y) return;
        if (sz[x] < sz[y]) swap(x, y);
        pa[y] = x; sz[x] += sz[y];
    }
    bool connected(int x, int y) { return find(x) == find(y); }
};

int main() {
    ios_base::sync_with_stdio(false); cin.tie(NULL);
    int n, m;
    cin >> n >> m;
    DSU dsu(n);
    while (m--) {
        int op, x, y;
        cin >> op >> x >> y;
        if (op == 1) dsu.unite(x, y);
        else cout << (dsu.connected(x, y) ? "Y" : "N") << "\n";
    }
    return 0;
}

🔴 挑战练习(9~10)

题目 9:食物链(NOIP 2001 P2024)
N 种动物,三类关系(A 吃 B,B 吃 C,C 吃 A 循环)。K 条信息,格式同 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 章 ⏱️ 约 70 分钟 🎯 进阶

第 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]) 时蓝色高亮的访问路径:

Segment Tree Structure


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)

下图展示了哪些节点被访问以及原因——绿色节点直接返回其值,橙色节点递归进入子节点,灰色节点立即被剪枝:

Segment Tree Query Visualization


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) 个节点,其他所有分支保持不变:

Segment Tree Point Update


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) 区间更新

线段树懒惰传播(Lazy Propagation)

💡 核心思路: 不立即更新所有受影响的叶节点,而是「懒惰地」推迟更新——在适用的最高节点存储更新,只在真正需要子节点时才向下传递。

每个节点现在存储两个值

  • tree[node]:该区间的实际聚合值(区间和)
  • lazy[node]:尚未向子节点传递的待处理更新

向下传递规则: 访问有待处理懒惰更新的节点时:

  1. 将懒惰更新应用到该节点的值
  2. 将懒惰更新传递给两个子节点(向下传递)
  3. 清除该节点的懒惰值
📄 3. 清除该节点的懒惰值
// 带懒惰传播的线段树
// 支持:区间加法更新、区间求和查询 — 各 O(log N)
#include <bits/stdc++.h>
using namespace std;

typedef long long ll;
const int MAXN = 100005;

ll tree[4 * MAXN];   // tree[node] = 区间和
ll lazy[4 * MAXN];   // lazy[node] = 待处理的加法值(0 表示无待处理)

// ── 向下传递:将待处理懒惰传递给子节点 ──
void pushDown(int node, int start, int end) {
    if (lazy[node] == 0) return;  // 无待处理更新

    int mid = (start + end) / 2;
    int left = 2 * node, right = 2 * node + 1;

    // 更新左子节点的和:加 lazy * (左子节点元素数量)
    tree[left]  += lazy[node] * (mid - start + 1);
    tree[right] += lazy[node] * (end - mid);

    // 将懒惰传递给子节点
    lazy[left]  += lazy[node];
    lazy[right] += lazy[node];

    // 清除当前节点的懒惰(已向下传递)
    lazy[node] = 0;
}

// ── 构建 ──
void build(int node, int start, int end, ll arr[]) {
    lazy[node] = 0;
    if (start == end) {
        tree[node] = arr[start];
        return;
    }
    int mid = (start + end) / 2;
    build(2*node, start, mid, arr);
    build(2*node+1, mid+1, end, arr);
    tree[node] = tree[2*node] + tree[2*node+1];
}

// ── 区间更新:给 [l, r] 内所有元素加 val ──
void update(int node, int start, int end, int l, int r, ll val) {
    if (r < start || end < l) return;  // 范围外:无操作

    if (l <= start && end <= r) {
        // 当前区间完全在 [l, r] 内:应用懒惰,不递归
        tree[node] += val * (end - start + 1);  // ← 关键:乘以区间长度
        lazy[node] += val;                        // 存储给子节点的待处理值
        return;
    }

    // 部分重叠:先向下传递已有懒惰,再递归
    pushDown(node, start, end);  // ← 关键:递归前必须先 pushDown!

    int mid = (start + end) / 2;
    update(2*node,   start, mid, l, r, val);
    update(2*node+1, mid+1, end, l, r, val);

    // 从子节点更新当前节点
    tree[node] = tree[2*node] + tree[2*node+1];
}

// ── 区间查询:[l, r] 内元素之和 ──
ll query(int node, int start, int end, int l, int r) {
    if (r < start || end < l) return 0;  // 范围外

    if (l <= start && end <= r) {
        return tree[node];  // 完全在内:返回存储的和(已包含懒惰!)
    }

    // 部分重叠:先向下传递,再递归
    pushDown(node, start, end);  // ← 关键:递归前必须先 pushDown!

    int mid = (start + end) / 2;
    ll leftSum  = query(2*node,   start, mid, l, r);
    ll rightSum = query(2*node+1, mid+1, end, l, r);
    return leftSum + rightSum;
}

int main() {
    ios_base::sync_with_stdio(false);
    cin.tie(NULL);

    int n, q;
    cin >> n >> q;

    ll arr[MAXN];
    for (int i = 0; i < n; i++) cin >> arr[i];

    build(1, 0, n-1, arr);

    while (q--) {
        int type;
        cin >> type;

        if (type == 1) {
            // 区间更新:给 [l, r] 加 val
            int l, r; ll val;
            cin >> l >> r >> val;
            update(1, 0, n-1, l, r, val);
        } else {
            // 区间查询:[l, r] 之和
            int l, r;
            cin >> l >> r;
            cout << query(1, 0, n-1, l, r) << "\n";
        }
    }

    return 0;
}

⚠️ 懒惰传播常见错误

4 大懒惰传播 Bug:

  1. 递归前忘记 pushDown —— 子节点会在父节点的懒惰之上再接收子节点自己的,导致查询结果错误
  2. 大小乘数用错 —— 写 tree[node] += val 而非 tree[node] += val * (end - start + 1)。节点存的是,给 (end-start+1) 个元素各加 val 意味着和增加 val×(大小)
  3. 未将 lazy[] 初始化为 0 —— 用 memset(lazy, 0, sizeof(lazy)) 或在 build() 中初始化
  4. 混合不同操作的懒惰 —— 若同时有「区间加」和「区间乘」两种懒惰,顺序很重要,需要两个独立的懒惰数组和仔细处理的 pushDown

懒惰传播通用化

该模式适用于任何满足以下条件的操作:

  • 聚合是结合性操作(和、最小值、最大值、XOR……)
  • 更新在聚合上分配(给 n 个元素各加 k,和增加 k*n
更新查询懒惰存储pushDown 公式
区间加区间和加法增量tree[child] += lazy * size; lazy[child] += lazy
区间赋值区间和赋值tree[child] = lazy * size; lazy[child] = lazy
区间加区间最小加法增量tree[child] += lazy; lazy[child] += lazy
区间赋值区间最小赋值tree[child] = lazy; lazy[child] = lazy

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) 每版本

⚠️ 常见错误

  1. 数组大小太小: 始终分配 tree[4 * MAXN]。对非 2 的幂次方大小的数组,用 2 * MAXN 会越界。
  2. 范围外的单位元用错: 求和查询返回 0;求最小查询返回 INT_MAX;求最大查询返回 INT_MIN
  3. 忘记更新父节点: 更新子节点后,必须重新计算父节点:tree[node] = tree[2*node] + tree[2*node+1]
  4. 0-indexed vs 1-indexed 混淆: 本实现使用 0-indexed 数组但 1-indexed 树节点,保持一致性。
  5. 前缀和足够时用线段树: 若没有更新操作,前缀和(O(1) 查询)优于线段树(O(log N) 查询)。合适时用更简单的工具。

本章总结

📌 核心要点

操作时间关键代码行
构建O(N)tree[node] = tree[2*node] + tree[2*node+1]
单点更新O(log N)递归到叶节点,向上更新
区间查询O(log N)完全在内/完全在外时提前返回
空间O(4N)分配 tree[4 * MAXN]

❓ 常见问题

Q1:什么时候选线段树 vs 前缀和?

A:简单规则——若数组从不改变,前缀和更好(O(1) 查询 vs O(log N))。若数组被修改(单点更新),用线段树或 BIT。若需要区间更新(给一段区间加值),用带懒惰传播的线段树。

Q2:为什么树数组大小需要 4N?

A:线段树是完全二叉树。当 N 不是 2 的幂次方时,最后一层可能不完整但仍需空间。最坏情况下需要约 4N 个节点。用 4*MAXN 是安全上界。

Q3:树状数组(BIT)和线段树哪个更好?

A:BIT 代码更短(约 15 行 vs 30 行),常数更小,但只能处理「可前缀分解」的操作(如求和)。线段树更通用(可以处理区间最小/最大、GCD 等),支持更复杂的操作(如懒惰传播)。竞赛中:能用 BIT 就用 BIT,BIT 不够用时切换到线段树。

Q4:线段树能处理哪些类型的查询?

A:任何满足结合律的操作:求和(+)、最小值(min)、最大值(max)、GCD、XOR、乘积等。关键是有「单位元」(如求和的 0、最小值的 INT_MAX、最大值的 INT_MIN)。

Q5:什么是懒惰传播?什么时候需要?

A:当需要「给区间 [L,R] 的每个元素加 V」(区间更新)时,朴素做法从 L 到 R 逐个更新叶节点(O(N)),太慢。懒惰传播将更新「懒惰地」存储在内部节点,只在子节点实际需要被查询时才向下传递,将区间更新也优化为 O(log N)

🔗 与后续章节的联系

  • 第 3.2 章(前缀和):线段树的「简化版」——没有更新操作时用前缀和
  • 第 5.1–5.2 章(图):欧拉序 + 线段树可以高效处理树上路径查询
  • 第 6.1–6.3 章(DP):某些 DP 优化需要线段树维护 DP 值的区间最小/最大
  • 线段树是 USACO Gold 级别的核心数据结构,掌握它能解决大量 Gold 题目

练习题

题目 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 章 ⏱️ 约 60 分钟 🎯 进阶

第 3.10 章:树状数组(BIT)

📝 前置条件: 了解前缀和(第 3.2 章)和位运算。本章与线段树(第 3.9 章)互补——BIT 代码更短,常数更小,但支持的操作更少。

树状数组(又名二进制索引树 / BIT)是竞赛编程中最常用的数据结构之一:不到 15 行代码,却能在 O(log N) 时间内支持单点更新和前缀查询。


3.10.1 核心思想:什么是 lowbit

lowbit 的位运算原理

对任意正整数 xlowbit(x) = x & (-x) 返回 x 的二进制表示中最低位 1 所代表的值

x  =  6  →  二进制:0110
-x = -6  →  补码:1010(按位取反 + 1)
x & (-x) = 0010 = 2   ← 最低位 1 对应 2^1 = 2

示例:

x二进制-x(补码)x & (-x)含义
1000111110001 = 1管理 1 个元素
2001011100010 = 2管理 2 个元素
3001111010001 = 1管理 1 个元素
4010011000100 = 4管理 4 个元素
6011010100010 = 2管理 2 个元素
8100010001000 = 8管理 8 个元素

BIT 树结构直觉

BIT 的精妙之处:tree[i] 不存储单个元素,而是存储一段区间的和,长度恰好是 lowbit(i)

BIT 结构(n=8):每个 tree[i] 覆盖恰好 lowbit(i) 个以下标 i 结尾的元素。

BIT Tree Structure

查询 prefix(7) 的跳转路径(i -= lowbit(i) 向下跳):

Fenwick Query Path

💡 跳转规律: 查询时 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) 向上跳):

Fenwick Update Path

查询 prefix(7) 时,通过 i -= lowbit(i) 向下跳:

  • i=7:加 tree[7](管理 A[7]),然后 7 - lowbit(7) = 7 - 1 = 6
  • i=6:加 tree[6](管理 A[5..6]),然后 6 - lowbit(6) = 6 - 2 = 4
  • i=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 = 4
  • i=4:更新 tree[4],然后 4 + lowbit(4) = 4 + 4 = 8
  • i=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 本章总结

📋 公式速查

操作代码描述
lowbitx & (-x)x 的最低位 1 的值
单点更新for(;i<=n;i+=lowbit(i)) t[i]+=v向上传播
前缀查询for(;i>0;i-=lowbit(i)) s+=t[i]向下分解
区间查询query(r) - query(l-1)差值公式
区间更新(差分 BIT)upd(l,+v); upd(r+1,-v)差分数组
逆序对计数(i-1) - query(a[i])处理每个元素时计数
数组必须1-indexed0-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 中不常见,但偶尔会在二维坐标计数题中用到。

二维树状数组(2D BIT)


3.10.10 练习题

🟢 简单一:区间求和(单点更新) 给定长度为 N 的数组,支持两种操作:

  1. 1 i x:A[i] 加 x
  2. 2 l r:查询 A[l] + A[l+1] + ... + A[r]

提示: BIT 的直接应用。用 update(i, x)query(r) - query(l-1)


🟢 简单二:小于 K 的元素个数 给定 N 次操作,每次要么插入一个整数(范围 1..10^6),要么查询「当前已插入的整数中有多少个 ≤ K?」

提示: BIT 维护值域上的频率数组。update(v, 1) 插入值 v,query(K) 是答案。


🟡 中等一:区间加法,单点查询 给定长度为 N 的数组(初始全零),支持两种操作:

  1. 1 l r x:给 A[l..r] 的每个元素加 x
  2. 2 i:查询 A[i] 的当前值

提示: 使用差分 BIT(第 3.10.6 节)。


🟡 中等二:逆序对计数(含坐标压缩) 给定长度为 N 的数组,元素范围 1..10^9(可能有重复),统计逆序对数量。

提示: 先坐标压缩,再用 BIT 计数(第 3.10.7 节的变体)。注意相等元素:(i,j) 满足 i<j 且 A[i]>A[j](严格大于)才算逆序对。


🔴 困难:区间加法,区间求和(双 BIT) 给定长度为 N 的数组,支持两种操作:

  1. 1 l r x:给 A[l..r] 的每个元素加 x
  2. 2 l r:查询 A[l] + ... + A[r]

提示: 用双 BIT。公式:prefix_sum(r) = B1[r] * r - B2[r],其中 B1 维护差分数组,B2 维护加权差分数组。

✅ 全部 BIT 练习题完整题解

🟢 简单一:区间求和

#include <bits/stdc++.h>
using namespace std;
const int MAXN = 100005;
int n, q;
long long tree[MAXN];
int lowbit(int x) { return x & (-x); }
void update(int i, long long val) { for (; i <= n; i += lowbit(i)) tree[i] += val; }
long long query(int i) { long long s=0; for (; i > 0; i -= lowbit(i)) s += tree[i]; return s; }
int main() {
    ios_base::sync_with_stdio(false); cin.tie(NULL);
    cin >> n >> q;
    while (q--) {
        int t; cin >> t;
        if (t == 1) { int i; long long x; cin >> i >> x; update(i, x); }
        else { int l, r; cin >> l >> r; cout << query(r) - query(l-1) << "\n"; }
    }
}

🟡 中等一:区间加法,单点查询(差分 BIT) 核心思路:在 BIT 中维护差分数组。range_add(l,r,x) = update(l,x) + update(r+1,-x)。单点查询 = query(i)

void range_add(int l, int r, long long x) { update(l, x); update(r+1, -x); }
long long point_query(int i) { return query(i); }

🟡 中等二:逆序对计数

// 先坐标压缩,然后对每个元素 x:
// 逆序对 += (已插入的元素数) - query(压缩后的 x)
// 然后插入 x:update(压缩后的 x, 1)

🔴 困难:区间加法,区间求和(双 BIT)

// prefix_sum(r) = (r+1)*sum(D[1..r]) - sum(i*D[i], i=1..r)
// = (r+1)*B1.query(r) - B2.query(r)
// 其中 B1 存 D[i],B2 存 i*D[i]
struct DoubleBIT {
    long long B1[MAXN], B2[MAXN];
    int n;
    DoubleBIT(int n) : n(n) { memset(B1,0,sizeof(B1)); memset(B2,0,sizeof(B2)); }
    void add(int i, long long v) {
        for (int x=i; x<=n; x+=x&-x) { B1[x]+=v; B2[x]+=v*i; }
    }
    void range_add(int l, int r, long long v) { add(l,v); add(r+1,-v); }
    long long prefix(int i) {
        long long s=0; for(int x=i;x>0;x-=x&-x) s+=(i+1)*B1[x]-B2[x]; return s;
    }
    long long range_query(int l, int r) { return prefix(r)-prefix(l-1); }
};

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硬币找零(方法数)
二维 DP6.20/1 背包、网格路径
LIS(O(N²))6.2最长递增子序列
LIS(O(N log N))6.2用二分搜索加速 LIS
状压 DP6.3TSP、任务分配问题
区间 DP6.3矩阵链乘法
树形 DP6.3树上独立集

前置条件

开始第六部分前,请确认你能做到:

  • 编写递归函数并理解调用栈(第 2.3 章)
  • 熟练使用二维向量(第 2.3 章)
  • 理解二分搜索(第 3.3 章)——O(N log N) LIS 需要
  • 能解决基础 BFS 题目(第 5.2 章)——DP 和 BFS 共享「状态空间探索」的直觉

DP 思维方式

DP 不是死记公式——而是问对问题:

  1. 「状态」是什么? 描述一个子问题需要哪些信息?
  2. 「转移」是什么? 更大状态的答案如何依赖更小状态?
  3. 「初始条件」是什么? 最简单的子问题答案是什么?
  4. 填表的顺序是什么? 依赖关系必须在被使用之前先计算。

💡 核心思路: 如果你发现自己在递归解法中多次写相同的计算,DP 就是解药。第一次计算时缓存结果,之后每次直接复用。


本部分学习建议

  1. 仔细学第 6.1 章。 不要在真正理解斐波那契 DP 之前急着学背包。DP 的「为什么」比「是什么」更重要。
  2. 对同一道题同时写记忆化和递推两种实现。 在两者之间转换能加深理解。
  3. 第 6.2 章的 LIS 有两种实现:O(N²)(易理解)和 O(N log N)(快速,大 N 时需要)。两种都要学。
  4. 第 6.3 章是 Silver/Gold 级别。 如果目标是 Bronze,可以先跳过第 6.3 章,之后再回来。
  5. 大多数 DP bug 来自错误的初始化。 最小代价问题初始化为 INF,不是 0;计数问题把初始条件初始化为 1,不是 0。

⚠️ 警告: DP 第 1 号 bug:在最小化 DP 中使用 dp[w-c] 前忘记检查 dp[w-c] != INFINF + 1 会溢出!

DP 第 2 号 bug:0/1 背包 vs 完全背包的循环顺序搞错了。倒序迭代 = 每件物品最多用一次。正序迭代 = 无限次使用。

📖 第 6.1 章 ⏱️ 约 65 分钟 🎯 中级

第 6.1 章:动态规划入门

📝 前置条件: 确保理解递归(第 2.3 章)、数组/向量(第 2.3–3.1 章)和基本循环模式(第 2.2 章)。DP 直接建立在递归概念之上。

动态规划(DP)常被描述为「带记忆的聪明递归」。让我们从最简单的例子——斐波那契数列——从零建立这种直觉。

💡 核心思路: DP 解决具有两个性质的问题:

  1. 重叠子问题 —— 相同的子计算出现多次
  2. 最优子结构 —— 大问题的最优解可以由小问题的最优解构建

两者同时成立时,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 次唯一调用——这是动态规划背后的基本洞察。

Fibonacci Memoization

朴素递归实现:

int fib(int n) {
    if (n == 0) return 0;
    if (n == 1) return 1;
    return fib(n-1) + fib(n-2);  // 递归
}

这是正确的,但慢得可怕

📄 这是正确的,但**慢得可怕**:
fib(5)
├── fib(4)
│   ├── fib(3)
│   │   ├── fib(2)
│   │   │   ├── fib(1) = 1
│   │   │   └── fib(0) = 0
│   │   └── fib(1) = 1
│   └── fib(2)           ← 再次计算!
│       ├── fib(1) = 1
│       └── fib(0) = 0
└── fib(3)               ← 再次计算!
    ├── fib(2)            ← 再次计算!
    │   ├── fib(1) = 1
    │   └── fib(0) = 0
    └── fib(1) = 1

fib(3) 被计算了两次,fib(2) 三次。对 fib(50),调用次数超过 10^10。这是指数时间:O(2^n)

核心洞察:我们在一遍遍重复计算相同的子问题。DP 解决了这个问题。


6.1.2 记忆化(自顶向下 DP)

记忆化 = 递归 + 缓存。计算之前,检查是否已经计算过这个值。若是,返回缓存的结果;若否,计算它、缓存它、返回它。

📄 C++ 完整代码
#include <bits/stdc++.h>
using namespace std;

const int MAXN = 100;
long long memo[MAXN];

long long fib(int n) {
    if (n <= 1) return n;
    if (memo[n] != -1) return memo[n];
    return memo[n] = fib(n-1) + fib(n-2);
}

int main() {
    fill(memo, memo + MAXN, -1LL);  // 将所有值初始化为 -1(「未计算」标记)
    cout << fib(50) << "\n";        // 12586269025
    return 0;
}

现在每个值被计算恰好一次。时间复杂度:O(N)。🎉


6.1.3 递推(自底向上 DP)

递推从头开始构建答案——先计算小子问题,用它们计算更大的问题。

📄 C++ 完整代码
#include <bits/stdc++.h>
using namespace std;

int main() {
    int n = 50;
    vector<long long> dp(n + 1);

    // 初始条件
    dp[0] = 0;
    dp[1] = 1;

    // 自底向上填表
    for (int i = 2; i <= n; i++) {
        dp[i] = dp[i-1] + dp[i-2];  // 使用已计算的值
    }

    cout << dp[n] << "\n";  // 12586269025
    return 0;
}

甚至可以优化空间:由于每个斐波那契数只依赖前两个,只需 O(1) 空间:

long long a = 0, b = 1;
for (int i = 2; i <= n; i++) {
    long long c = a + b;
    a = b;
    b = c;
}
cout << b << "\n";

记忆化 vs 递推

对 fib(4) 两种方式对比:

Memoization vs Tabulation

💡 核心区别: 自顶向下按需计算(只算用到的子问题),自底向上全量填表(按顺序算所有子问题)。两者时间复杂度相同,但自底向上没有递归栈开销。

方面记忆化(自顶向下)递推(自底向上)
方式递归加缓存迭代填表
内存使用只有已计算的状态所有状态(包括未用到的)
实现通常更直观可能需要想清楚填充顺序
栈溢出风险有(深度递归)
速度稍慢(函数调用开销)稍快
USACO 偏好适合理解和思考适合最终提交

🏆 USACO 技巧: 竞赛中自底向上递推略有优势,因为它避免了潜在的栈溢出(在 N = 10^5 的题目中很关键),通常也更快。但若难以看清递推关系,先用自顶向下——这是一种很好的思考方式。


6.1.4 DP 四步法

每道 DP 题都遵循相同的做法:

DP 四步法——从状态定义到空间优化:

DP 4-Step Recipe

  1. 定义状态: 什么信息能唯一描述一个子问题?
  2. 定义递推: dp[状态] 如何依赖更小的状态?
  3. 确定初始条件: 最简单子问题的答案是什么?
  4. 确定顺序: 以什么顺序填表?

应用到斐波那契:

  1. 状态: dp[i] = 第 i 个斐波那契数
  2. 递推: dp[i] = dp[i-1] + dp[i-2]
  3. 初始条件: dp[0] = 0dp[1] = 1
  4. 顺序: i 从 2 到 n(每个依赖更小的 i)

6.1.5 硬币找零——经典 DP

题目: 有面额为 coins[] 的硬币,凑出金额 W 最少需要多少枚?每种面额可以无限次使用。

示例: coins = [1, 5, 6, 9],W = 11

先试试贪心(每次选最大的 ≤ 剩余金额):

  • 贪心:9 + 1 + 1 = 3 枚 ← 不是最优!
  • 最优:5 + 6 = 2 枚 ← DP 能找到

这就是为什么贪心在这里失败,需要 DP。

图示:硬币找零 DP 表

DP 表展示了 dp[i](凑出金额 i 的最少硬币数)从左到右的填写过程。对硬币 {1,3,4},注意 dp[3]=1(直接用硬币 3)和 dp[6]=2(用两个 3)。

Coin Change DP

DP 定义

对 coins = {1, 5, 6} 的状态转移:

Coin Change State Transitions

  • 状态: dp[w] = 凑出恰好金额 w 的最少硬币数
  • 递推: dp[w] = 1 + min(对所有 c ≤ w 的硬币 c:dp[w - c])(使用硬币 c,然后最优地解决剩余的 w-c)
  • 初始条件: dp[0] = 0(凑出金额 0 需要 0 枚)
  • 答案: dp[W]
  • 顺序: w 从 1 到 W

完整演示:coins = [1, 5, 6, 9],W = 11

📄 查看代码:完整演示:coins = [1, 5, 6, 9],W = 11
dp[0] = 0 (初始条件)

dp[1]:用硬币 1:dp[0]+1=1          → dp[1] = 1
dp[2]:用硬币 1:dp[1]+1=2          → dp[2] = 2
...
dp[5]:用硬币 1:dp[4]+1=5
        用硬币 5:dp[0]+1=1          → dp[5] = 1  ← 用 5 分硬币!
dp[6]:用硬币 1:dp[5]+1=2
        用硬币 5:dp[1]+1=2
        用硬币 6:dp[0]+1=1          → dp[6] = 1  ← 用 6 分硬币!
...
dp[11]:用硬币 5:dp[6]+1=2
         用硬币 6:dp[5]+1=2          → dp[11] = 2  ← 5+6 或 6+5!

dp 表:[0, 1, 2, 3, 4, 1, 1, 2, 3, 1, 2, 2]

答案:dp[11] = 2(硬币 5 和 6)✓

📄 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 章常见错误

  1. 最小化问题用 0 而非 INF 初始化 dp: dp[w] = 0 表示「0 枚硬币」,永远不会被改善。用 dp[w] = INF,只有 dp[0] = 0
  2. 使用 dp[w-c] 前不检查 dp[w-c] != INF INF + 1 会溢出!始终检查子问题是否可解。
  3. 背包变体的循环顺序错误: 无界背包(硬币无限),金额正向循环;0/1 背包(每个只用一次),金额反向循环。搞错这一点会给出静默的错误答案。
  4. INT_MAX 作为 INF 然后加 1: INT_MAX + 1 溢出成负数。用 1e91e18 作为 INF。
  5. 忘记初始条件: dp[0] = 0 至关重要,没有它什么都设不好。

本章总结

📌 核心要点

概念要点何时使用
重叠子问题相同计算指数级重复递归树中有重复调用
记忆化(自顶向下)缓存递归结果;易于编写递归结构清晰时
递推(自底向上)迭代填表;无栈溢出最终竞赛提交;大 N
DP 状态唯一标识子问题的信息仔细定义——决定了一切
DP 递推dp[状态] 如何依赖更小状态「转移方程」
初始条件最简单子问题的已知答案通常 dp[0] = 某个平凡值

🧩 DP 四步法速查

步骤问题斐波那契示例
1. 定义状态"dp[i] 代表什么?"dp[i] = 第 i 个斐波那契数
2. 写递推"dp[i] 依赖哪些更小的状态?"dp[i] = dp[i-1] + dp[i-2]
3. 确定初始条件"最小子问题的答案是什么?"dp[0]=0,dp[1]=1
4. 确定填充顺序"i 从小到大?从大到小?"i 从 2 到 n

❓ 常见问题

Q1:怎么判断一道题是 DP 题?

A:两个信号:① 题目问「最优值」或「方法数」(不是「输出具体方案」);② 存在重叠子问题(暴力递归中相同子问题被计算多次)。若贪心能被证明正确,通常不需要 DP;否则很可能是 DP。

Q2:应该用自顶向下还是自底向上?

A:学习时用自顶向下(更自然地表达递归思维);竞赛提交用自底向上(更快,无栈溢出)。两者都正确。若能快速写出自底向上,直接用它。

Q3:什么是「最优子结构」(无后效性)?

A:DP 的核心前提条件——一旦 dp[i] 确定,后续计算不会「回来」修改它。换句话说,dp[i] 的值只依赖于「过去」(更小的状态),而不是「未来」。若违反这个性质,不能用 DP。

Q4:INF 应该设为多少?

A:int 类型用 1e9(= 10^9),long long 类型用 1e18(= 10^18)。不要用 INT_MAX,因为 INT_MAX + 1 溢出成负数。

🔗 与后续章节的联系

  • 第 6.2 章(经典 DP):扩展到 LIS、背包、网格路径——都是本章四步 DP 法的应用
  • 第 6.3 章(进阶 DP):进入状压 DP、区间 DP、树形 DP——更复杂的状态定义,但思路相同
  • 第 3.2 章(前缀和):差分数组有时可以替代简单 DP,前缀和数组可以加速 DP 中的区间计算
  • 第 4.1 章(贪心)vs DP:贪心可解的问题是 DP 的特例(每步局部最优 = 全局最优);贪心失败时需要 DP

练习题

题目 6.1.1 — 爬楼梯 🟢 简单 每次可以爬 1 或 2 级台阶,有多少种方法爬 N 级台阶?

提示 这就是斐波那契!ways[1]=1,ways[2]=2。或从 ways[0]=1, ways[1]=1 开始,then ways[n] = ways[n-1] + ways[n-2]。
✅ 完整题解

核心思路: ways[n] = 到达台阶 n 的方法数。你从台阶 n-1(1 步)或台阶 n-2(2 步)到达。

#include <bits/stdc++.h>
using namespace std;
int main() {
    int n; cin >> n;
    if (n == 1) { cout << 1; return 0; }
    vector<long long> dp(n + 1);
    dp[1] = 1; dp[2] = 2;
    for (int i = 3; i <= n; i++)
        dp[i] = dp[i-1] + dp[i-2];
    cout << dp[n] << "\n";
}

复杂度: O(N) 时间,O(N) 空间(可用两个变量降为 O(1))。


题目 6.1.2 — 最少硬币找零 🟡 中等 给定硬币面额 [1, 3, 4] 和目标 6,找最少硬币数。(期望答案:2 枚——用 3+3)

✅ 完整题解
#include <bits/stdc++.h>
using namespace std;
int main() {
    int n, W; cin >> n >> W;
    vector<int> coins(n);
    for (int& c : coins) cin >> c;
    const int INF = 1e9;
    vector<int> dp(W + 1, INF);
    dp[0] = 0;
    for (int w = 1; w <= W; w++) {
        for (int c : coins) {
            if (c <= w && dp[w - c] != INF)
                dp[w] = min(dp[w], dp[w - c] + 1);
        }
    }
    cout << (dp[W] == INF ? -1 : dp[W]) << "\n";
}

贪心选 4 → 4+1+1 = 3 枚;DP 找 3+3 = 2 枚。复杂度: O(N × W)。


题目 6.1.3 — 瓷砖铺设 🟡 中等 用 1×2 多米诺骨牌(水平或垂直放置)铺满 2×N 的棋盘,有多少种方法?

提示 递推与斐波那契相同!关键洞察:在第 N 列放一块竖排骨牌,递归到 n-1;在第 N-1 和 N 列放两块横排骨牌,递归到 n-2。
✅ 完整题解
#include <bits/stdc++.h>
using namespace std;
const long long MOD = 1e9 + 7;
int main() {
    int n; cin >> n;
    if (n == 1) { cout << 1; return 0; }
    vector<long long> dp(n + 1);
    dp[1] = 1; dp[2] = 2;
    for (int i = 3; i <= n; i++)
        dp[i] = (dp[i-1] + dp[i-2]) % MOD;
    cout << dp[n] << "\n";
}

复杂度: O(N)。


题目 6.1.4 — 有限次使用的硬币找零 🔴 困难 与硬币找零相同,但每种硬币最多用一次(0/1 背包),找最少硬币数。

提示 0/1 背包变体,关键技巧:将 w 从 W 反向迭代到 coins[i],防止重复使用同一枚硬币。
✅ 完整题解
#include <bits/stdc++.h>
using namespace std;
int main() {
    int n, W; cin >> n >> W;
    vector<int> coins(n);
    for (int& c : coins) cin >> c;
    const int INF = 1e9;
    vector<int> dp(W + 1, INF);
    dp[0] = 0;
    for (int i = 0; i < n; i++) {
        // 反向顺序:防止硬币 i 被用超过一次
        for (int w = W; w >= coins[i]; w--) {
            if (dp[w - coins[i]] != INF)
                dp[w] = min(dp[w], dp[w - coins[i]] + 1);
        }
    }
    cout << (dp[W] == INF ? -1 : dp[W]) << "\n";
}

为什么反向? 正向时可能用更新过的 dp[w] 来更新自身——等于把同一枚硬币用了两次。复杂度: O(N × W)。


题目 6.1.5 — USACO Bronze:干草堆叠放 🔴 困难 N 次操作「给位置 L 到 R 的所有位置加 1」,求每个位置的最终值。

提示 差分数组:`diff[L]++`,`diff[R+1]--`,然后取前缀和得到最终值。
✅ 完整题解
#include <bits/stdc++.h>
using namespace std;
int main() {
    ios_base::sync_with_stdio(false); cin.tie(NULL);
    int n, q; cin >> n >> q;
    vector<long long> diff(n + 2, 0);
    while (q--) {
        int l, r; cin >> l >> r;
        diff[l]++;
        diff[r + 1]--;
    }
    long long cur = 0;
    for (int i = 1; i <= n; i++) {
        cur += diff[i];
        cout << cur << " \n"[i == n];
    }
}

复杂度: O(N + Q)。


🏆 挑战题:有障碍的唯一路径 N×M 网格有「.」格子和「#」障碍,统计从 (1,1) 到 (N,M) 只向右或向下移动的路径数,答案对 10^9+7 取模。(N, M ≤ 1000)

✅ 完整题解
#include <bits/stdc++.h>
using namespace std;
const long long MOD = 1e9 + 7;
int main() {
    int n, m; cin >> n >> m;
    vector<string> grid(n);
    for (auto& row : grid) cin >> row;
    vector<vector<long long>> dp(n, vector<long long>(m, 0));
    if (grid[0][0] == '.') dp[0][0] = 1;
    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) 时间和空间。


图示:斐波那契递归树

Fibonacci Recursion Tree

上图展示了 fib(6) 的朴素递归。红色虚线节点是重复子问题——被多次计算。绿色节点展示记忆化缓存结果的位置。不用记忆化:O(2^N);用记忆化:O(N)。这是动态规划背后的基本洞察。

📖 第 6.2 章 ⏱️ 约 110 分钟 🎯 进阶

第 6.2 章:经典 DP 问题

📝 前置条件: 确保掌握了第 6.1 章的核心 DP 概念——状态、递推和初始条件。你应该能从零实现斐波那契和基本硬币找零。

本章我们处理竞赛编程中最重要、应用最广泛的三个 DP 问题。掌握这些模式将帮助你识别并解决数十道 USACO 题目。


6.2.1 最长递增子序列(LIS)

题目: 给定 N 个整数的数组 A,找最长的严格递增子序列的长度。子序列不需要连续。

示例: A = [3, 1, 8, 2, 5]

  • LIS:[1, 2, 5] → 长度 3
  • 或:[3, 8] → 长度 2(不是最长)

💡 核心思路: 子序列可以跳过元素,但必须保持相对顺序。关键 DP 洞察:对每个下标 i,问「以 A[i] 结尾的最长递增子序列是什么?」然后对所有 i 取最大值就是答案。

LIS 状态转移——A = [3, 1, 8, 2, 5]:

LIS State Transitions

💡 转移规则: dp[i] = 1 + max(dp[j])(对所有 j < i 且 A[j] < A[i])。每条箭头表示「以 j 结尾的子序列可以延伸到包含 i」。

LIS Visualization

O(N²) DP 解法

  • 状态: dp[i] = 以下标 i 结尾的最长递增子序列长度
  • 递推: dp[i] = 1 + max(对所有 j < i 且 A[j] < A[i] 的 dp[j])
  • 初始条件: dp[i] = 1(只含 A[i] 自身的子序列)
  • 答案: max(dp[0], dp[1], ..., dp[N-1])

对 A = [3, 1, 8, 2, 5] 的逐步追踪:

dp[0] = 1  (以 3 结尾的 LIS:只有 [3])
dp[1] = 1  (以 1 结尾的 LIS:只有 [1])
dp[2] = 2  (以 8 结尾的 LIS:[3,8] 或 [1,8])
dp[3] = 2  (以 2 结尾的 LIS:[1,2])
dp[4] = 3  (以 5 结尾的 LIS:[1,2,5])

LIS 长度 = max(dp) = 3
📄 C++ 完整代码
// LIS O(N²) — 简单但 N > 5000 时太慢
#include <bits/stdc++.h>
using namespace std;

int main() {
    ios_base::sync_with_stdio(false);
    cin.tie(NULL);

    int n;
    cin >> n;
    vector<int> A(n);
    for (int &x : A) cin >> x;

    vector<int> dp(n, 1);  // 每个元素单独是长度为 1 的子序列

    for (int i = 1; i < n; i++) {
        for (int j = 0; j < i; j++) {
            if (A[j] < A[i]) {              // A[j] 可以延伸到 A[i]
                dp[i] = max(dp[i], dp[j] + 1);  // ← 关键行
            }
        }
    }

    cout << *max_element(dp.begin(), dp.end()) << "\n";
    return 0;
}

样例输入: 5 / 3 1 8 2 5输出: 3

复杂度分析:

  • 时间: O(N²) —— 双重循环
  • 空间: O(N) —— dp 数组

N ≤ 5000 时 O(N²) 够快,N 最大 10^5 时需要 O(N log N) 方案。


O(N log N) LIS(耐心排序)

关键思路:维护 tails 数组,其中 tails[k] = 迄今为止任意长度为 k+1 的递增子序列中最小可能的尾部元素

💡 核心思路(耐心排序): 想象把牌发到若干叠(像接龙游戏)。每叠是递减序列,一张牌放到顶牌 ≥ 它的最左侧那叠。若无这样的叠,开一叠新的。牌的叠数就等于 LIS 长度!tails 数组正是这些叠的顶牌。

对 A = [3, 1, 8, 2, 5] 的逐步追踪:

处理 3:tails=[],无元素 ≥ 3,追加:tails=[3]
处理 1:tails=[3],lower_bound(1) 在下标 0(3 ≥ 1),替换:tails=[1]
处理 8:tails=[1],lower_bound(8) 到末尾,追加:tails=[1,8]
处理 2:tails=[1,8],lower_bound(2) 在下标 1(8 ≥ 2),替换:tails=[1,2]
处理 5:tails=[1,2],lower_bound(5) 到末尾,追加:tails=[1,2,5]
答案 = tails.size() = 3
📄 C++ 完整代码
// LIS O(N log N) — N 最大 10^5 时够快
#include <bits/stdc++.h>
using namespace std;

int main() {
    ios_base::sync_with_stdio(false);
    cin.tie(NULL);

    int n;
    cin >> n;
    vector<int> A(n);
    for (int &x : A) cin >> x;

    vector<int> tails;  // tails[i] = 长度为 i+1 的 IS 的最小尾元素

    for (int x : A) {
        // 找第一个 tails[pos] >= x(严格递增用 lower_bound)
        auto it = lower_bound(tails.begin(), tails.end(), x);

        if (it == tails.end()) {
            tails.push_back(x);   // x 延伸了最长子序列
        } else {
            *it = x;              // ← 关键行:替换以保持最小可能尾部
        }
    }

    cout << tails.size() << "\n";
    return 0;
}

⚠️ 注意: tails 不存储实际的 LIS 元素,只存储其长度lower_bound 给出严格递增的 LIS(A[j] < A[i]);若需不减序列(A[j] ≤ A[i]),改用 upper_bound

复杂度: O(N log N) 时间,O(N) 空间。


6.2.2 0/1 背包问题

题目: 有 N 件物品,物品 i 的重量为 w[i],价值为 v[i]。背包最多承重 W,选择物品使总价值最大,每件物品最多选一次(0/1 = 拿或不拿)。

图示:背包 DP 表

二维表展示了 dp[物品][容量],每行加入一件物品,答案在右下角。

Knapsack DP Table

DP 公式

0/1 背包决策——拿或不拿物品 i:

Knapsack Decision

💡 与无界背包的关键区别: 因为每件物品只能用一次,「拿」时从行 dp[i-1] 读取,而不是当前行。这就是为什么一维优化版本要反向迭代重量。

  • 状态: dp[i][w] = 使用物品 1..i 且总重量 ≤ w 时的最大价值
  • 递推:
    • 不拿物品 i:dp[i][w] = dp[i-1][w]
    • 拿物品 i(仅在 w[i] ≤ w 时):dp[i][w] = dp[i-1][w - weight[i]] + value[i]
    • 取最大值
📄 C++ 完整代码
#include <bits/stdc++.h>
using namespace std;

int main() {
    ios_base::sync_with_stdio(false);
    cin.tie(NULL);

    int n, W;
    cin >> n >> W;

    vector<int> weight(n + 1), value(n + 1);
    for (int i = 1; i <= n; i++) cin >> weight[i] >> value[i];

    vector<vector<int>> dp(n + 1, vector<int>(W + 1, 0));

    for (int i = 1; i <= n; i++) {
        for (int w = 0; w <= W; w++) {
            dp[i][w] = dp[i-1][w];  // 选项一:不拿物品 i

            if (weight[i] <= w) {    // 选项二:拿物品 i(如果放得下)
                dp[i][w] = max(dp[i][w], dp[i-1][w - weight[i]] + value[i]);
            }
        }
    }

    cout << dp[n][W] << "\n";
    return 0;
}

空间优化的 0/1 背包——O(W) 空间

只需要上一行 dp[i-1],可以用一维数组。关键: w 从 W 倒序迭代(否则物品 i 会被使用多次):

vector<int> dp(W + 1, 0);

for (int i = 1; i <= n; i++) {
    // 倒序迭代,防止物品 i 被使用多次
    for (int w = W; w >= weight[i]; w--) {
        dp[w] = max(dp[w], dp[w - weight[i]] + value[i]);
    }
}

cout << dp[W] << "\n";

为什么倒序? 计算 dp[w] 时,需要上一件物品行dp[w - weight[i]]。倒序迭代确保 dp[w - weight[i]] 还没被当前物品 i 更新过。

无界背包(物品无限次可用)

若每件物品可以使用多次,改为正序迭代:

for (int i = 1; i <= n; i++) {
    for (int w = weight[i]; w <= W; w++) {  // 正序——允许重复使用
        dp[w] = max(dp[w], dp[w - weight[i]] + value[i]);
    }
}

6.2.3 网格路径计数

题目: 统计从网格左上角 (1,1) 到右下角 (N,M) 只向右或向下移动的路径数,部分格子被堵塞。

图示:网格路径 DP 值

Grid DP

每个格子展示了从 (0,0) 到该格子的路径数。递推 dp[i][j] = dp[i-1][j] + dp[i][j-1] 叠加了从上方和左方到达的路径。

📄 C++ 完整代码
#include <bits/stdc++.h>
using namespace std;

int main() {
    ios_base::sync_with_stdio(false);
    cin.tie(NULL);

    int n, m;
    cin >> n >> m;

    vector<string> grid(n);
    for (int r = 0; r < n; r++) cin >> grid[r];

    // dp[r][c] = 到达 (r, c) 的路径数
    vector<vector<long long>> dp(n, vector<long long>(m, 0));

    // 初始条件:起始格子(若不堵塞)
    if (grid[0][0] != '#') dp[0][0] = 1;

    // 填充第一行(只能从左来)
    for (int c = 1; c < m; c++) {
        if (grid[0][c] != '#') dp[0][c] = dp[0][c-1];
    }

    // 填充第一列(只能从上来)
    for (int r = 1; r < n; r++) {
        if (grid[r][0] != '#') dp[r][0] = dp[r-1][0];
    }

    // 填充其余格子
    for (int r = 1; r < n; r++) {
        for (int c = 1; c < m; c++) {
            if (grid[r][c] == '#') {
                dp[r][c] = 0;  // 堵塞——无路径经过此处
            } else {
                dp[r][c] = dp[r-1][c] + dp[r][c-1];  // 从上 + 从左
            }
        }
    }

    cout << dp[n-1][m-1] << "\n";
    return 0;
}

网格最大价值路径

题目: 找从 (1,1) 到 (N,M)(只向右或向下)最大化路径上值之和的路径。

📄 C++ 完整代码
// ...读取 val[r][c]...
vector<vector<long long>> dp(n, vector<long long>(m, 0));
dp[0][0] = val[0][0];

for (int c = 1; c < m; c++) dp[0][c] = dp[0][c-1] + val[0][c];
for (int r = 1; r < n; r++) dp[r][0] = dp[r-1][0] + val[r][0];

for (int r = 1; r < n; r++) {
    for (int c = 1; c < m; c++) {
        dp[r][c] = max(dp[r-1][c], dp[r][c-1]) + val[r][c];
    }
}

cout << dp[n-1][m-1] << "\n";

6.2.4 USACO DP 示例:牛蹄剪刀布

题目(USACO 2019 January Silver): Bessie 玩 N 局牛蹄剪刀布(类似石头剪刀布)。她事先知道对手的出法,可以最多换 K 次手势,最大化获胜局数。

状态: dp[j][g] = 前 i 局换了 j 次、当前出手势 g 时的最大获胜数。

📄 C++ 完整代码
#include <bits/stdc++.h>
using namespace std;

int main() {
    ios_base::sync_with_stdio(false);
    cin.tie(NULL);

    int n, k;
    cin >> n >> k;

    // 0=牛蹄, 1=纸, 2=剪刀
    vector<int> opp(n + 1);
    for (int i = 1; i <= n; i++) {
        char c; cin >> c;
        if (c == 'H') opp[i] = 0;
        else if (c == 'P') opp[i] = 1;
        else opp[i] = 2;
    }

    const int NEG_INF = -1e9;
    vector<vector<int>> dp(k + 1, vector<int>(3, NEG_INF));

    // 初始化:第 1 局前,换了 0 次,任何起始手势
    for (int g = 0; g < 3; g++) dp[0][g] = 0;

    for (int i = 1; i <= n; i++) {
        vector<vector<int>> ndp(k + 1, vector<int>(3, NEG_INF));

        for (int j = 0; j <= k; j++) {
            for (int g = 0; g < 3; g++) {
                if (dp[j][g] == NEG_INF) continue;

                int win = (g == opp[i]) ? 1 : 0;

                // 选项一:不换手势
                ndp[j][g] = max(ndp[j][g], dp[j][g] + win);

                // 选项二:换手势(消耗 1 次)
                if (j < k) {
                    for (int ng = 0; ng < 3; ng++) {
                        if (ng != g) {
                            int nwin = (ng == opp[i]) ? 1 : 0;
                            ndp[j+1][ng] = max(ndp[j+1][ng], dp[j][g] + nwin);
                        }
                    }
                }
            }
        }

        dp = ndp;
    }

    int ans = 0;
    for (int j = 0; j <= k; j++)
        for (int g = 0; g < 3; g++)
            ans = max(ans, dp[j][g]);

    cout << ans << "\n";
    return 0;
}

6.2.5 区间 DP——矩阵链乘法与气球爆破模式

区间 DP 是一种强大的 DP 技术,状态代表连续的子数组或子范围,我们将更小区间的解组合来解决更大的区间。

💡 核心思路: 当区间 [l, r] 的最优解依赖于如何在某个点 k 分割该区间,且子问题 [l, k][k+1, r] 相互独立时,适用区间 DP。

区间 DP 框架

区间 DP 填充顺序——必须按区间长度递增填充:

Interval DP Fill Order

💡 填充顺序很关键: 必须按区间长度递增填充。计算 dp[l][r] 时,所有更短的子区间 dp[l][k]dp[k+1][r] 必须已经计算好。

状态:    dp[l][r] = 区间 [l, r] 上子问题的最优解
初始条件:dp[i][i] = 单个元素的代价/价值(通常为 0 或平凡值)
顺序:    按区间长度递增填充(len = 1, 2, 3, ..., n)
          确保 dp[l][k] 和 dp[k+1][r] 在 dp[l][r] 之前计算
转移:    dp[l][r] = 对 [l, r-1] 中所有分割点 k 的 min/max:
                    dp[l][k] + dp[k+1][r] + cost(l, k, r)
答案:    dp[1][n](0-indexed 则为 dp[0][n-1])

经典示例:矩阵链乘法

题目: 给定 N 个矩阵 A₁, A₂, ..., Aₙ,矩阵 Aᵢ 维度为 dim[i-1] × dim[i],找最小化标量乘法次数的括号化方案。

状态: dp[l][r] = 计算乘积 Aₗ × Aₗ₊₁ × ... × Aᵣ 的最少乘法次数

转移: 尝试每个分割点 k ∈ [l, r-1]:

  • 左乘积 Aₗ...Aₖ 代价 dp[l][k],结果维度 dim[l-1] × dim[k]
  • 右乘积 Aₖ₊₁...Aᵣ 代价 dp[k+1][r],结果维度 dim[k] × dim[r]
  • 两结果相乘代价 dim[l-1] × dim[k] × dim[r]
📄 C++ 完整代码
// 矩阵链乘法 — O(N³) 时间,O(N²) 空间
#include <bits/stdc++.h>
using namespace std;

int main() {
    ios_base::sync_with_stdio(false);
    cin.tie(NULL);

    int n;
    cin >> n;

    vector<int> dim(n + 1);
    for (int i = 0; i <= n; i++) cin >> dim[i];

    vector<vector<long long>> dp(n + 1, vector<long long>(n + 1, 0));
    const long long INF = 1e18;

    // 按区间长度递增填充
    for (int len = 2; len <= n; len++) {
        for (int l = 1; l + len - 1 <= n; l++) {
            int r = l + len - 1;
            dp[l][r] = INF;

            for (int k = l; k < r; k++) {
                long long cost = dp[l][k]
                               + dp[k+1][r]
                               + (long long)dim[l-1] * dim[k] * dim[r];
                dp[l][r] = min(dp[l][r], cost);
            }
        }
    }

    cout << dp[1][n] << "\n";
    return 0;
}

复杂度: O(N³) 时间,O(N²) 空间。

区间 DP 通用模板

📄 查看代码:区间 DP 通用模板
// 通用区间 DP 模板
void intervalDP(int n) {
    vector<vector<int>> dp(n + 1, vector<int>(n + 1, 0));

    // 初始条件:长度为 1 的区间
    for (int i = 1; i <= n; i++) dp[i][i] = base_case(i);

    // 按长度递增填充
    for (int len = 2; len <= n; len++) {
        for (int l = 1; l + len - 1 <= n; l++) {
            int r = l + len - 1;
            dp[l][r] = INF;

            for (int k = l; k < r; k++) {
                int val = dp[l][k] + dp[k+1][r] + cost(l, k, r);
                dp[l][r] = min(dp[l][r], val);
            }
        }
    }
}

⚠️ 常见错误: 以左端点 l 为外层循环、长度为内层循环——这是错误的!计算 dp[l][r] 时,子区间 dp[l][k]dp[k+1][r] 必须已经计算好。始终以长度为外层循环。


6.2.6 分组背包

题目: N 组物品,第 i 组有 cnt[i] 件物品,每组最多选一件(或不选)。在重量 W 内最大化总价值。

💡 与 0/1 背包的关键区别: 0/1 背包逐件物品做决策;分组背包逐组做决策——每组中选哪件(如果选的话)。

📄 C++ 完整代码
#include <bits/stdc++.h>
using namespace std;

int main() {
    ios_base::sync_with_stdio(false);
    cin.tie(NULL);

    int n, W;
    cin >> n >> W;

    vector<vector<pair<int,int>>> groups(n);
    for (int i = 0; i < n; i++) {
        int cnt; cin >> cnt;
        groups[i].resize(cnt);
        for (auto& [w, v] : groups[i]) cin >> w >> v;
    }

    vector<int> dp(W + 1, 0);

    for (int i = 0; i < n; i++) {          // 对每组
        for (int w = W; w >= 0; w--) {     // 容量**降序**迭代
            for (auto [wi, vi] : groups[i]) { // 尝试组内每件物品
                if (w >= wi)
                    dp[w] = max(dp[w], dp[w - wi] + vi);
            }
        }
    }

    cout << dp[W] << "\n";
    return 0;
}

复杂度: O(N × W × 平均组大小)。

循环顺序说明

正确——物品循环在容量循环内部:
  for w = W..0:
    尝试 A:dp[w] = max(dp[w], dp[w-2]+3)
    尝试 B:dp[w] = max(dp[w], dp[w-3]+5)
  → 每个容量下只从该组选最优的一件

错误——容量循环在物品循环内部:
  尝试 A:for w = W..0: dp[w] = max(dp[w], dp[w-2]+3)
  尝试 B:for w = W..0: dp[w] = max(dp[w], dp[w-3]+5)
  → A 和 B 可能都被选中,违反「每组最多一件」

6.2.7 多重背包

题目: N 种物品,每种有 cnt[i] 件(不是无限个)。在重量 W 内最大化总价值。

方法一:二进制拆分 — O(N log C × W)

核心思路: 0 到 cnt[i] 之间的任意数 k 都可以表示为 2 的幂次之和加余数。将 cnt[i] 件拆分为大小为 1, 2, 4, 8, ..., 余数的组,每组作为一件「超级物品」。

📄 C++ 完整代码
#include <bits/stdc++.h>
using namespace std;

int main() {
    ios_base::sync_with_stdio(false);
    cin.tie(NULL);

    int n, W;
    cin >> n >> W;

    // 将每种物品按二进制拆分为「超级物品」
    vector<pair<int,int>> items;

    for (int i = 0; i < n; i++) {
        int wi, vi, ci;
        cin >> wi >> vi >> ci;

        for (int k = 1; ci > 0; k *= 2) {
            int take = min(k, ci);
            items.push_back({take * wi, take * vi});
            ci -= take;
        }
    }

    // 对扩展后的物品做标准 0/1 背包
    vector<int> dp(W + 1, 0);
    for (auto [w, v] : items) {
        for (int cap = W; cap >= w; cap--)
            dp[cap] = max(dp[cap], dp[cap - w] + v);
    }

    cout << dp[W] << "\n";
    return 0;
}

复杂度: O(Σ log(cnt[i]) × W) ≈ O(N log C × W)。

方法二:单调队列优化 — O(N × W)

对 cnt 很大(最大 10^6)的情况,二进制拆分仍然太慢。最优解使用单调队列。

核心思路:w[i] 的余数对 DP 数组分组,在每个余数类内,转移变成一个滑动窗口最大值问题。

📄 C++ 完整代码
// 多重背包(单调队列优化)— O(N * W)
void bounded_knapsack_deque(vector<int>& dp, int wi, int vi, int ci, int W) {
    vector<int> prev = dp;
    for (int r = 0; r < wi; r++) {
        deque<int> dq;
        int max_k = (W - r) / wi;
        for (int k = 0; k <= max_k; k++) {
            int idx = r + k * wi;
            int val = prev[idx] - k * vi;
            while (!dq.empty() && dq.front() < k - ci) dq.pop_front();
            if (!dq.empty()) {
                int j = dq.front();
                dp[idx] = max(dp[idx], prev[r + j * wi] + (k - j) * vi);
            }
            while (!dq.empty() && prev[r + dq.back() * wi] - dq.back() * vi <= val)
                dq.pop_back();
            dq.push_back(k);
        }
    }
}

💡 何时用哪种方法:

  • cnt ≤ 1000,W ≤ 10^5 → 二进制拆分(实现更简单)
  • cnt 最大 10^6,W 最大 10^5 → 单调队列(只有 O(NW))

6.2.8 完全背包——每种物品可选无限次

核心区别: 枚举顺序从「倒序」改为「正序」。

📄 C++ 完整代码
// 完全背包 — O(N × W)
// 正序枚举允许同一物品被多次选取
vector<int> unbounded_knapsack(int n, int W,
    vector<int>& wt, vector<int>& val) {
    vector<int> dp(W + 1, 0);
    for (int i = 0; i < n; i++) {
        for (int w = wt[i]; w <= W; w++) {   // ← 正序!
            dp[w] = max(dp[w], dp[w - wt[i]] + val[i]);
        }
    }
    return dp;
}
背包类型每件可选次数内层循环顺序
0/1 背包最多 1 次倒序(W → w[i])
完全背包无限次正序(w[i] → W)
多重背包最多 cnt[i] 次拆分后按 0/1 处理

6.2.9 二维费用背包

物品同时有两种费用(如重量 + 体积),背包有两个限制。只需多一维状态。

📄 物品同时有两种费用(如重量 + 体积),背包有两个限制。只需多一维状态。
// 二维费用 0/1 背包 — O(N × V × M)
// 物品 i 有重量 w[i]、体积 v[i]、价值 c[i]
// 背包容量 V(重量)和 M(体积)
void two_dim_knapsack(int n, int V, int M,
    vector<int>& w, vector<int>& v, vector<int>& c) {
    vector<vector<int>> dp(V + 1, vector<int>(M + 1, 0));

    for (int i = 0; i < n; i++) {
        // 两个维度都倒序!保证 0/1 约束
        for (int j = V; j >= w[i]; j--) {
            for (int k = M; k >= v[i]; k--) {
                dp[j][k] = max(dp[j][k], dp[j - w[i]][k - v[i]] + c[i]);
            }
        }
    }
    // dp[V][M] 即为答案
}

⚠️ 关键: 两个维度都必须倒序枚举,否则同一物品会被多次计入。


6.2.10 背包方案数

将求最大值改为求满足条件的方案数:把 max 替换为累加,把初始值 dp[0] = 1 作为基础情况。

📄 将求最大值改为求**满足条件的方案数**:把 `max` 替换为累加,把初始值 `dp[0] = 1` 作为基础情况。
// 方案数背包:恰好装满 W 的方案数(对 MOD 取模)
// 物品无限次可选(完全背包版)
const int MOD = 1e9 + 7;

long long count_ways(int n, int W, vector<int>& wt) {
    vector<long long> dp(W + 1, 0);
    dp[0] = 1;  // 空背包:1 种方案(不选任何物品)

    for (int i = 0; i < n; i++) {
        for (int w = wt[i]; w <= W; w++) {   // 正序 = 完全背包
            dp[w] = (dp[w] + dp[w - wt[i]]) % MOD;
        }
    }
    return dp[W];
}

// 0/1 背包版:恰好装满的方案数
long long count_ways_01(int n, int W, vector<int>& wt) {
    vector<long long> dp(W + 1, 0);
    dp[0] = 1;

    for (int i = 0; i < n; i++) {
        for (int w = W; w >= wt[i]; w--)    // 倒序 = 0/1 背包
            dp[w] = (dp[w] + dp[w - wt[i]]) % MOD;
    }
    return dp[W];
}

典型应用:

  • 「正好装满 W」的方案数 → 初始 dp[0]=1dp[1..W]=0
  • 「不超过 W」的方案数 → 初始全部为 1(任何子集都是合法方案)

6.2.11 背包问题决策对照表

需求关键变化
求最大价值(0/1)dp[w] = max(dp[w], dp[w-wi]+vi)倒序
求最大价值(完全)dp[w] = max(dp[w], dp[w-wi]+vi)正序
求方案数(0/1)dp[w] += dp[w-wi]倒序,初始 dp[0]=1
求方案数(完全)dp[w] += dp[w-wi]正序,初始 dp[0]=1
求第 k 优解每个状态存前 k 大的值,转移时用双指针合并
恰好装满dp[0]=1/0dp[1..W]=-INF/0
至多装满dp[0..W] 全初始化为 0

⚠️ 第 6.2 章常见错误

  1. LIS:严格递增用 upper_bound 严格递增用 lower_bound;不减序列用 upper_bound。搞错会使 LIS 长度差 1。
  2. 0/1 背包:正向迭代重量: 正向迭代允许物品 i 被多次使用——那是无界背包,不是 0/1。0/1 背包始终倒序迭代。
  3. 网格路径:忘记处理堵塞格子:grid[r][c] == '#',设 dp[r][c] = 0(不是 dp[r-1][c] + dp[r][c-1])。
  4. 网格路径计数中溢出: 路径数可能极大,用 long long 或模运算。
  5. LIS:以为 tails 存储实际 LIS: 不是!tails 存储各长度子序列的最小可能尾元素。实际 LIS 需要单独重建。
  6. 分组背包:物品循环在容量外层: 物品循环必须在容量循环内部。若物品在外层,每件物品被当作独立的 0/1 物品处理,允许同组多件被选中。
  7. 多重背包二进制拆分后正向迭代: 拆分后超级物品仍是 0/1 约束——倒序迭代重量。正向迭代允许重用同一超级物品,结果错误。
  8. 二维背包只有一个维度倒序: 二维 0/1 背包中,重量和体积两个约束都需要其循环倒序迭代。

本章总结

📌 核心要点

问题状态定义递推复杂度
LIS(O(N²))dp[i] = 以 A[i] 结尾的 LIS 长度dp[i] = max(dp[j]+1),j<i 且 A[j]<A[i]O(N²)
LIS(O(N log N))tails[k] = 长度 k+1 的 IS 的最小尾部二分查找 + 替换O(N log N)
0/1 背包(一维)dp[w] = 容量 ≤ w 时的最大价值倒序迭代 wO(NW)
无界背包dp[w] = 容量 ≤ w 时的最大价值正序迭代 wO(NW)
分组背包dp[w] = 最大价值,每组最多选 1 件w 降序,物品循环在 w 循环内部O(N×W×组大小)
多重背包同 0/1二进制拆分 → 0/1 背包O(N log C × W)
网格路径dp[r][c] = 到达 (r,c) 的路径数dp[r-1][c] + dp[r][c-1]O(RC)

❓ 常见问题

Q1:O(N log N) LIS 中 tails 数组存储的是实际 LIS 吗?

A:不是! tails 存储的是「各长度递增子序列的最小尾元素」。其长度等于 LIS 长度,但元素本身可能不构成合法的递增子序列。要重建实际 LIS,需要记录每个元素的「前驱」。

Q2:0/1 背包为什么需要倒序迭代 w?

A:因为 dp[w] 需要上一件物品行dp[w - weight[i]]。正向迭代时,dp[w - weight[i]] 可能已被当前行(当前物品 i)更新,等于物品 i 被使用了多次。倒序迭代确保每件物品最多被使用一次。

Q3:无界背包(物品无限次可用)和 0/1 背包的代码只有什么区别?

A:只是内层循环方向。0/1 背包:w 从 W 降到 weight[i](倒序);无界背包:w 从 weight[i] 升到 W(正序)。

Q4:如果网格路径还可以向上或向左移动呢?

A:那么简单的网格 DP 就不再适用(因为会有环)。需要 BFS/DFS 或更复杂的 DP。标准网格路径 DP 只适用于「只向右/向下」的移动。

🔗 与后续章节的联系

  • 第 3.3 章(排序与二分):二分搜索是 O(N log N) LIS 的核心——对 tails 数组用 lower_bound
  • 第 6.3 章(进阶 DP):将背包扩展到状压 DP(物品集合 → 位掩码),将网格 DP 扩展到区间 DP
  • 第 4.1 章(贪心):区间调度问题有时可以转化为 LIS(通过 Dilworth 定理)
  • LIS 在 USACO Silver 中极为常见——二维 LIS、带权 LIS、LIS 计数变体频繁出现

练习题

题目 6.2.1 — LIS 长度 🟢 简单 读取 N 个整数,找最长严格递增子序列的长度。

✅ 完整题解
#include <bits/stdc++.h>
using namespace std;
int main() {
    int n; cin >> n;
    vector<int> a(n);
    for (int& x : a) cin >> x;

    vector<int> tails;
    for (int x : a) {
        auto it = lower_bound(tails.begin(), tails.end(), x);
        if (it == tails.end()) tails.push_back(x);
        else *it = x;
    }
    cout << tails.size() << "\n";
}

复杂度: O(N log N)。


题目 6.2.2 — LIS 计数 🔴 困难 读取 N 个整数,找最长递增子序列的数量(对 10^9+7 取模)。

✅ 完整题解
#include <bits/stdc++.h>
using namespace std;
const int MOD = 1e9 + 7;
int main() {
    int n; cin >> n;
    vector<int> a(n);
    for (int& x : a) cin >> x;

    vector<int> len(n, 1);
    vector<long long> cnt(n, 1);

    for (int i = 1; i < n; i++) {
        for (int j = 0; j < i; j++) {
            if (a[j] < a[i]) {
                if (len[j] + 1 > len[i]) {
                    len[i] = len[j] + 1;
                    cnt[i] = cnt[j];
                } else if (len[j] + 1 == len[i]) {
                    cnt[i] = (cnt[i] + cnt[j]) % MOD;
                }
            }
        }
    }

    int maxLen = *max_element(len.begin(), len.end());
    long long ans = 0;
    for (int i = 0; i < n; i++)
        if (len[i] == maxLen) ans = (ans + cnt[i]) % MOD;
    cout << ans << "\n";
}

复杂度: O(N²)。


题目 6.2.3 — 0/1 背包 🟡 中等 N 件物品,各有重量和价值,容量 W,找最大价值。(N, W ≤ 1000)

✅ 完整题解
#include <bits/stdc++.h>
using namespace std;
int main() {
    int n, W; cin >> n >> W;
    vector<int> wt(n), val(n);
    for (int i = 0; i < n; i++) cin >> wt[i] >> val[i];

    vector<int> dp(W + 1, 0);
    for (int i = 0; i < n; i++) {
        for (int w = W; w >= wt[i]; w--)  // 倒序:防止重复使用
            dp[w] = max(dp[w], dp[w - wt[i]] + val[i]);
    }
    cout << dp[W] << "\n";
}

复杂度: O(N × W)。


题目 6.2.4 — 收集星星 🟡 中等 N×M 网格有星星('*')和障碍('#'),只能向右或向下从 (1,1) 移动到 (N,M),最多能收集多少星星?

✅ 完整题解
#include <bits/stdc++.h>
using namespace std;
int main() {
    int n, m; cin >> n >> m;
    vector<string> g(n);
    for (auto& row : g) cin >> row;

    const int NEG = -1e9;
    vector<vector<int>> dp(n, vector<int>(m, NEG));

    dp[0][0] = (g[0][0] == '*') ? 1 : 0;

    for (int i = 0; i < n; i++) {
        for (int j = 0; j < m; j++) {
            if (i == 0 && j == 0) continue;
            if (g[i][j] == '#') continue;
            int star = (g[i][j] == '*') ? 1 : 0;
            int best = NEG;
            if (i > 0 && dp[i-1][j] != NEG) best = max(best, dp[i-1][j]);
            if (j > 0 && dp[i][j-1] != NEG) best = max(best, dp[i][j-1]);
            if (best != NEG) dp[i][j] = best + star;
        }
    }
    cout << max(0, dp[n-1][m-1]) << "\n";
}

复杂度: O(N × M)。


题目 6.2.5 — 恰好填满背包 🔴 困难 背包变体:必须恰好用满容量 W(不是最多)。

✅ 完整题解

核心思路: 与标准 0/1 背包相同,但初始化 dp[w] = -INF(w > 0),只有 dp[0] = 0。只有从 dp[0]=0 可达的状态才有有限值。

#include <bits/stdc++.h>
using namespace std;
int main() {
    int n, W; cin >> n >> W;
    vector<int> wt(n), val(n);
    for (int i = 0; i < n; i++) cin >> wt[i] >> val[i];

    const int NEG = -1e9;
    vector<int> dp(W + 1, NEG);
    dp[0] = 0;

    for (int i = 0; i < n; i++) {
        for (int w = W; w >= wt[i]; w--) {
            if (dp[w - wt[i]] != NEG)
                dp[w] = max(dp[w], dp[w - wt[i]] + val[i]);
        }
    }
    if (dp[W] == NEG) cout << "impossible\n";
    else cout << dp[W] << "\n";
}

复杂度: O(N × W)。


图示:LIS 耐心排序

LIS Patience Sort

上图用耐心排序类比展示 LIS。每「叠」表示一个潜在的子序列终点,叠的数量等于 LIS 长度。二分搜索以 O(log N) 找到每张牌的位置,总体 O(N log N) 算法。

图示:背包 DP 表

Knapsack DP Table

0/1 背包 DP 表:行 = 已考虑的物品,列 = 容量,每格展示可实现的最大价值。蓝色格子展示单件物品的贡献,绿色格子展示组合,带星号的格子是最优答案。

📖 第 6.3 章 ⏱️ 约 55 分钟 🎯 进阶

第 6.3 章:进阶 DP 模式

📝 前置条件: 必须完成第 6.1 章(DP 入门)和第 6.2 章(经典 DP 问题)。进阶模式建立在记忆化、递推和经典 DP 问题(LIS、背包、网格路径)之上。

本章涵盖 USACO Silver 及以上出现的 DP 技术:状压 DP、区间 DP、树形 DP 和数位 DP。每种都有特征性结构,一旦识别出来,问题就变得容易处理。


6.3.1 状压 DP

使用场景: 涉及小集合(N ≤ 20)的子集问题,状态包含「已选了哪些元素」。

核心思路: 用位掩码(整数)表示已选元素的集合。第 i 位为 1 表示元素 i 已选入。

{0, 2, 3} 在 5 个元素的集合中 → 位掩码 = 0b01101 = 13
第 0 位 = 1(元素 0 ∈ 集合)
第 1 位 = 0(元素 1 ∉ 集合)
第 2 位 = 1(元素 2 ∈ 集合)
第 3 位 = 1(元素 3 ∈ 集合)
第 4 位 = 0(元素 4 ∉ 集合)

基本位操作

📄 查看代码:基本位操作
int mask = 0;
mask |= (1 << i);      // 将元素 i 加入集合
mask &= ~(1 << i);     // 从集合中移除元素 i
bool has_i = (mask >> i) & 1;  // 检查元素 i 是否在集合中

// 枚举 mask 的所有子集
for (int sub = mask; sub > 0; sub = (sub - 1) & mask) {
    // 处理子集 'sub'
}
// 若需要包含空集,在循环后再处理 sub=0

// 统计置位数(集合中的元素数)
int count = __builtin_popcount(mask);   // int 类型
int count = __builtin_popcountll(mask); // long long 类型

经典题:旅行商问题(TSP)— O(2^N × N²)

题目: N 座城市,完全加权图,找访问每座城市恰好一次的最小代价哈密顿路径。

状态: dp[mask][u] = 恰好访问了 mask 中城市、当前在城市 u 时的最小代价。

Bitmask DP State Space

转移: 扩展到 mask 中没有的城市 v

dp[mask | (1<<v)][v] = min(dp[mask|(1<<v)][v], dp[mask][u] + dist[u][v])
📄 C++ 完整代码
// TSP 状压 DP — O(2^N × N²)
// N ≤ 20 时可用(2^20×400 ≈ 4×10^8,较紧;N≤18 更安全)
#include <bits/stdc++.h>
using namespace std;

typedef long long ll;
const ll INF = 1e18;

int n;
int dist[20][20];
ll dp[1 << 20][20];

int main() {
    ios_base::sync_with_stdio(false);
    cin.tie(NULL);

    cin >> n;
    for (int i = 0; i < n; i++)
        for (int j = 0; j < n; j++)
            cin >> dist[i][j];

    // 初始化:全部为 INF
    for (int mask = 0; mask < (1 << n); mask++)
        fill(dp[mask], dp[mask] + n, INF);

    // 初始条件:从城市 0 出发,只访问了城市 0
    dp[1][0] = 0;  // mask=1(第 0 位置 1),在城市 0,代价=0

    for (int mask = 1; mask < (1 << n); mask++) {
        for (int u = 0; u < n; u++) {
            if (!(mask & (1 << u))) continue;  // u 不在当前集合中
            if (dp[mask][u] == INF) continue;

            // 尝试扩展到尚未访问的城市 v
            for (int v = 0; v < n; v++) {
                if (mask & (1 << v)) continue;  // v 已访问
                int newMask = mask | (1 << v);
                dp[newMask][v] = min(dp[newMask][v], dp[mask][u] + dist[u][v]);
            }
        }
    }

    int fullMask = (1 << n) - 1;  // 所有城市都已访问
    ll ans = INF;
    for (int u = 1; u < n; u++) {
        ans = min(ans, dp[fullMask][u] + dist[u][0]);  // 返回城市 0 形成环
    }

    cout << ans << "\n";
    return 0;
}

⚠️ 内存警告: dp[1<<20][20] 使用约 168MB。N=20 时接近典型 256MB 内存限制。若距离用 int 而非 long long,内存减半约 84MB。


6.3.2 区间 DP

使用场景: 较大区间的答案可以由较小区间的答案构建。关键词:「合并」「分割」「爆破」「矩阵链」。

核心结构:

dp[l][r] = 区间 [l, r] 上子问题的最优答案
初始条件:dp[i][i] = 平凡值(单个元素)
转移:dp[l][r] = 对 k ∈ [l, r-1] 的 min/max:
        dp[l][k] + dp[k+1][r] + cost(l, k, r)
填充顺序:按区间长度递增(len = r - l + 1)

经典题:矩阵链乘法 — O(N³)

题目: N 个矩阵依次相乘,矩阵 i 维度为 dims[i] × dims[i+1],找最小化标量乘法次数的括号化方案。

状态: dp[l][r] = 计算矩阵 l 到 r 乘积的最少乘法次数。

Interval DP Fill Order

📄 ![Interval DP Fill Order](../images/interval_dp_fill_order.svg)
// 矩阵链乘法 — O(N³),O(N²) 空间
#include <bits/stdc++.h>
using namespace std;

typedef long long ll;
const ll INF = 1e18;

int main() {
    ios_base::sync_with_stdio(false);
    cin.tie(NULL);

    int n;
    cin >> n;
    vector<int> dims(n + 1);
    for (int i = 0; i <= n; i++) cin >> dims[i];

    vector<vector<ll>> dp(n + 1, vector<ll>(n + 1, 0));

    for (int len = 2; len <= n; len++) {
        for (int l = 1; l + len - 1 <= n; l++) {
            int r = l + len - 1;
            dp[l][r] = INF;

            for (int k = l; k < r; k++) {
                ll cost = dp[l][k] + dp[k+1][r]
                        + (ll)dims[l-1] * dims[k] * dims[r];
                dp[l][r] = min(dp[l][r], cost);
            }
        }
    }

    cout << dp[1][n] << "\n";
    return 0;
}

工作示例: 3 个矩阵 A(10×30),B(30×5),C(5×60)

dp[1][2] = 10×30×5 = 1500
dp[2][3] = 30×5×60 = 9000
dp[1][3]:k=2 → dp[1][2] + dp[3][3] + 10×5×60 = 1500+0+3000 = 4500 ← 最小!
答案:4500(括号化为 (A×B)×C)

经典题:气球爆破

📄 查看代码:经典题:气球爆破
// dp[l][r] = 只爆破 (l, r) 中所有气球的最大金币
// 关键洞察:考虑 [l, r] 中**最后**爆破的气球 k
vector<int> val(n + 2);
val[0] = val[n + 1] = 1;
for (int i = 1; i <= n; i++) cin >> val[i];

vector<vector<ll>> dp(n + 2, vector<ll>(n + 2, 0));

for (int len = 1; len <= n; len++) {
    for (int l = 1; l + len - 1 <= n; l++) {
        int r = l + len - 1;
        for (int k = l; k <= r; k++) {
            // k 是 [l, r] 中最后爆破的气球
            ll cost = dp[l][k-1] + dp[k+1][r]
                    + (ll)val[l-1] * val[k] * val[r+1];
            dp[l][r] = max(dp[l][r], cost);
        }
    }
}
cout << dp[1][n] << "\n";

6.3.3 树形 DP

使用场景: 在树上做 DP,节点的状态依赖其子树(后序)或其祖先(前序)。

模式:子树 DP(后序)

树形 DP 总是自底向上运行——叶节点是基础情况,每个内部节点汇总其子节点的结果:

Tree DP Bottom-Up Flow

经典题:树上最大独立集

题目: N 个节点,各有价值 val[u],选一个子集 S 最大化总价值,约束:若 u ∈ S,则 u 的子节点都不在 S 中。

状态: dp[u][0] = u 选时 u 子树的最大价值;dp[u][1] = u 时 u 子树的最大价值。

📄 C++ 完整代码
// 树上最大独立集 — O(N)
#include <bits/stdc++.h>
using namespace std;

const int MAXN = 100005;
vector<int> children[MAXN];
int val[MAXN];
long long dp[MAXN][2];

// DFS 后序:先计算所有子节点的 dp,再计算 dp[u]
void dfs(int u) {
    dp[u][1] = val[u];  // 选 u:得到 val[u]
    dp[u][0] = 0;        // 不选 u:这个节点得 0

    for (int v : children[u]) {
        dfs(v);  // ← 先处理子节点(后序)

        // 若选 u:子节点必须不选
        dp[u][1] += dp[v][0];

        // 若不选 u:子节点可以选也可以不选
        dp[u][0] += max(dp[v][0], dp[v][1]);
    }
}

int main() {
    ios_base::sync_with_stdio(false);
    cin.tie(NULL);

    int n, root;
    cin >> n >> root;
    for (int i = 1; i <= n; i++) cin >> val[i];

    for (int i = 0; i < n - 1; i++) {
        int u, v;
        cin >> u >> v;
        children[u].push_back(v);
    }

    dfs(root);
    cout << max(dp[root][0], dp[root][1]) << "\n";
    return 0;
}

树的直径(两次 DFS)

📄 查看代码:树的直径(两次 DFS)
// 树的直径:任意两个节点间的最长路径
// 两次 DFS 法
// 1. 从任意节点 u 做 DFS → 找最远节点 v
// 2. 从 v 做 DFS → 找最远节点 w
// dist(v, w) = 直径

int farthest_node, max_dist;

void dfs_diameter(int u, int parent, int d, vector<int> adj[]) {
    if (d > max_dist) {
        max_dist = d;
        farthest_node = u;
    }
    for (int v : adj[u]) {
        if (v != parent) dfs_diameter(v, u, d + 1, adj);
    }
}

int tree_diameter(int n, vector<int> adj[]) {
    max_dist = 0; farthest_node = 1;
    dfs_diameter(1, -1, 0, adj);

    int v = farthest_node;
    max_dist = 0;
    dfs_diameter(v, -1, 0, adj);

    return max_dist;
}

6.3.4 数位 DP

使用场景: 统计 [1, N] 范围内满足某个与数字有关的性质的数。

核心思路: 从左到右逐位构建数字,维护「tight」约束(是否仍受 N 的各位限制)。

状态: dp[位置][tight][...其他状态...]

  • 位置:当前决策的是哪一位(0 = 最左位)
  • tight:是否仍受 N 约束(1 = 是,不能超过 N 对应的位;0 = 否,可以自由使用 0-9)
  • 其他状态:追踪的任何性质(各位之和、零的个数等)

经典题:统计 [1, N] 中各位数字之和能被 K 整除的数

📄 查看代码:经典题:统计 [1, N] 中各位数字之和能被 K 整除的数
// 数位 DP — O(|digits| × 10 × K) 时间,O(|digits| × K) 空间
#include <bits/stdc++.h>
using namespace std;

string num;
int K;
map<tuple<int,int,int>, long long> memo;

// pos:当前数位位置(0-indexed)
// tight:是否受 num[pos] 约束
// rem:当前各位之和 mod K
long long solve(int pos, bool tight, int rem) {
    if (pos == (int)num.size()) {
        return rem == 0 ? 1 : 0;  // 完整数字:有效当且仅当各位之和 ≡ 0(mod K)
    }

    auto key = make_tuple(pos, tight, rem);
    if (memo.count(key)) return memo[key];

    int limit = tight ? (num[pos] - '0') : 9;  // 这一位最大能放的数字
    long long result = 0;

    for (int d = 0; d <= limit; d++) {
        bool new_tight = tight && (d == limit);
        result += solve(pos + 1, new_tight, (rem + d) % K);
    }

    return memo[key] = result;
}

// 统计 [1, N] 中各位之和能被 K 整除的数
long long count_up_to(long long N) {
    num = to_string(N);
    memo.clear();
    long long ans = solve(0, true, 0);
    // 减 1 是因为 0 本身的各位之和为 0(能被 K 整除)
    // 但我们要统计 [1, N],不是 [0, N]
    return ans - 1;
}

int main() {
    ios_base::sync_with_stdio(false);
    cin.tie(NULL);

    long long L, R;
    cin >> L >> R >> K;

    cout << count_up_to(R) - count_up_to(L - 1) << "\n";
    return 0;
}

💡 核心思路: tight 标志至关重要。tight=true 时,这一位只能使用 ≤ num[pos] 的数字。一旦放了比 num[pos] 小的数字,后面所有位都自由了(tight 变为 false)。这种「剥离」上界的方式是数位 DP 正确的关键。


本章总结

📌 模式识别指南

模式题目中的线索状态转移
状压 DP「子集」,N ≤ 20,分配任务dp[mask][last]翻转位,尝试下一个元素
区间 DP「合并」「分割」「加括号」dp[l][r]在 k 处分割,组合
树形 DP「树」,子树性质dp[节点][状态]从子节点汇总
数位 DP「统计具有某性质的数」dp[位置][tight][...]尝试每个数字 d

🧩 核心框架速查

📄 查看代码:🧩 核心框架速查
// 状压 DP 框架
for (int mask = 0; mask < (1<<n); mask++)
    for (int u = 0; u < n; u++) if (mask & (1<<u))
        for (int v = 0; v < n; v++) if (!(mask & (1<<v)))
            dp[mask|(1<<v)][v] = min(dp[mask|(1<<v)][v], dp[mask][u] + cost[u][v]);

// 区间 DP 框架
for (int len = 2; len <= n; len++)           // 枚举区间长度
    for (int l = 1; l+len-1 <= n; l++) {     // 枚举左端点
        int r = l + len - 1;
        for (int k = l; k < r; k++)           // 枚举分割点
            dp[l][r] = min(dp[l][r], dp[l][k] + dp[k+1][r] + cost(l,k,r));
    }

// 树形 DP 框架(后序遍历)
void dfs(int u, int parent) {
    for (int v : adj[u]) if (v != parent) {
        dfs(v, u);
        dp[u] = update(dp[u], dp[v]);  // 用子节点信息更新当前节点
    }
}

// 数位 DP 框架
long long solve(int pos, bool tight, int state) {
    if (pos == len) return (state == target) ? 1 : 0;
    if (memo[pos][tight][state] != -1) return memo[pos][tight][state];
    int lim = tight ? (num[pos]-'0') : 9;
    long long res = 0;
    for (int d = 0; d <= lim; d++)
        res += solve(pos+1, tight && (d==lim), next_state(state, d));
    return memo[pos][tight][state] = res;
}

❓ 常见问题

Q1:区间 DP 为什么必须先按长度枚举?

A:因为 dp[l][r] 依赖 dp[l][k]dp[k+1][r],两者的长度都小于 r-l+1。所以所有更短的区间必须在 dp[l][r] 之前计算。按长度从小到大枚举满足这个要求。若直接枚举 l 和 r,可能在依赖还没准备好时就计算 dp[l][r]

Q2:树形 DP 中,如何处理无根树(给出无向边)?

A:选任意节点为根(通常是节点 1),然后用 DFS 将无向边变为有向边(父 → 子方向)。在 DFS 中传递 parent 参数以避免回到父节点。

void dfs(int u, int par) {
    for (int v : adj[u]) {
        if (v != par) {  // 只访问子节点,不访问父节点
            dfs(v, u);
            // 更新 dp[u]
        }
    }
}

Q3:数位 DP 中 tight=truetight=false 能共用同一个记忆化数组吗?

A:可以,这正是为什么 tight 是状态的一部分。dp[pos][1][rem]dp[pos][0][rem] 是不同的状态,分别记录「有上界约束时的计数」和「自由时的计数」。注意 tight=false 的状态可以在多次调用间复用(一旦 tight 变为 false,后面的位不受约束)。


练习题

题目 6.3.1 — 状压 DP:任务分配 🟡 中等 N 名工人,N 项任务,工人 i 完成任务 j 需要 time[i][j] 小时。将每项任务恰好分配给一名工人,最小化总时间。(N ≤ 15)

提示 `dp[mask]` = 分配了 `mask` 中各任务时的最少总时间。工人下标 = 分配新任务前的 popcount(mask)。
✅ 完整题解
#include <bits/stdc++.h>
using namespace std;
int main() {
    int n; cin >> n;
    vector<vector<int>> t(n, vector<int>(n));
    for (auto& row : t) for (int& x : row) cin >> x;

    vector<long long> dp(1 << n, 1e18);
    dp[0] = 0;
    for (int mask = 0; mask < (1 << n); mask++) {
        if (dp[mask] >= (long long)1e18) continue;
        int worker = __builtin_popcount(mask);
        if (worker == n) continue;
        for (int task = 0; task < n; task++) {
            if (mask & (1 << task)) continue;
            dp[mask | (1 << task)] = min(dp[mask | (1 << task)],
                                          dp[mask] + t[worker][task]);
        }
    }
    cout << dp[(1 << n) - 1] << "\n";
}

复杂度: O(2^N × N) 时间和空间,轻松处理 N ≤ 20。


题目 6.3.2 — 区间 DP:回文分割 🟡 中等 找将字符串分割成回文子串的最少切割次数。

提示 先用区间 DP 预计算 isPalin[l][r],再用 cuts[i] = s[0..i] 的最少切割次数。
✅ 完整题解
#include <bits/stdc++.h>
using namespace std;
int main() {
    string s; cin >> s;
    int n = s.size();

    // 第一阶段:回文检查
    vector<vector<bool>> pal(n, vector<bool>(n, false));
    for (int i = n-1; i >= 0; i--)
        for (int j = i; j < n; j++)
            pal[i][j] = (s[i]==s[j]) && (j-i < 2 || pal[i+1][j-1]);

    // 第二阶段:最少切割
    vector<int> cuts(n, n);
    for (int i = 0; i < n; i++) {
        if (pal[0][i]) { cuts[i] = 0; continue; }
        for (int j = 1; j <= i; j++)
            if (pal[j][i]) cuts[i] = min(cuts[i], cuts[j-1] + 1);
    }
    cout << cuts[n-1] << "\n";
}

复杂度: O(N²)。


题目 6.3.3 — 树形 DP:最大匹配 🔴 困难 在树上找最大匹配(共享顶点最少的最大边集合)。

提示 dp[u][0] = u 不匹配时 u 子树的最大匹配数;dp[u][1] = u 与某个子节点匹配时 u 子树的最大匹配数。
✅ 完整题解
#include <bits/stdc++.h>
using namespace std;
const int MAXN = 100005;
vector<int> adj[MAXN];
int dp[MAXN][2];

void dfs(int u, int par) {
    dp[u][0] = dp[u][1] = 0;
    for (int v : adj[u]) {
        if (v == par) continue;
        dfs(v, u);
        dp[u][0] += max(dp[v][0], dp[v][1]);
    }
    for (int v : adj[u]) {
        if (v == par) continue;
        int gain = 1 + dp[v][0] - max(dp[v][0], dp[v][1]);
        dp[u][1] = max(dp[u][1], dp[u][0] + gain);
    }
}

int main() {
    int n; cin >> n;
    for (int i = 0; i < n-1; i++) {
        int u, v; cin >> u >> v;
        adj[u].push_back(v); adj[v].push_back(u);
    }
    dfs(1, 0);
    cout << max(dp[1][0], dp[1][1]) << "\n";
}

复杂度: O(N)。


题目 6.3.4 — 数位 DP:统计幸运数 🟡 中等 「幸运数」只包含数字 4 和 7,统计 [1, N] 中的幸运数数量。

提示 用 BFS 枚举所有幸运数(4, 7, 44, 47, 74, 77, ...),与 N 比较。
✅ 完整题解
#include <bits/stdc++.h>
using namespace std;
int main() {
    long long n; cin >> n;
    int count = 0;
    queue<long long> q;
    q.push(4); q.push(7);
    while (!q.empty()) {
        long long x = q.front(); q.pop();
        if (x > n) continue;
        count++;
        if (x <= n / 10) {
            q.push(x * 10 + 4);
            q.push(x * 10 + 7);
        }
    }
    cout << count << "\n";
}

复杂度: O(2^digits) ≈ O(2^18) 最坏情况。


⚠️ 进阶 DP 常见错误

展开——竞赛前必读

状压 DP 陷阱:

  • mask >> i & 1 被解析为 mask >> (i & 1)——始终写 (mask >> i) & 1
  • ❌ 枚举子掩码:for (sub=mask; sub>0; sub=(sub-1)&mask) 跳过了 sub=0——若空集有效,手动添加 sub=0
  • ❌ 忘记 __builtin_popcount 统计的是置位数,不是 0..n-1 中的数

区间 DP 陷阱:

  • ❌ 按 (l, r) 顺序而非按区间长度填充——dp[l][k] 可能还没计算好
  • ❌ 分割点范围:k 应该从 lr-1,不是 lr
  • ❌ 初始化错误:dp[i][i] = 0(初始条件),不是 INF

树形 DP 陷阱:

  • ❌ 栈溢出:N > 10^5 时,将递归改为迭代 DFS
  • ❌ 忘记 if (v == parent) continue——在无向边上会无限循环
  • ❌ 换根 DP 中,换根前忘记减去子节点的贡献

数位 DP 陷阱:

  • tight 标志未传递:若 tight=true,下一位 ≤ N 对应位的数字
  • ❌ 前导零:追踪 started 标志,避免「007」和「7」被重复计数
  • tight=true 的记忆化条目不能复用——tight=false 的状态可以复用

📖 第 6.4 章:折半搜索(Meet in the Middle)

⏱ 预计阅读时间:40 分钟 | 难度:🟡 中等(USACO Gold 必备)


前置条件

  • DFS 回溯(第 5.2.12 章)
  • 排序与二分查找(第 3.3 章)
  • 位运算(第 2.6 章)

🎯 学习目标

学完本章后,你将能够:

  1. 理解折半搜索的核心思想:将 O(2^N) 降为 O(2^(N/2) × log)
  2. 解决「子集和」「N 个数中选 k 个」类的大规模枚举问题
  3. 将线性搜索空间拆成两半分别处理,再合并答案

6.4.1 问题引入:子集和

原始问题

给定 N 个整数(N ≤ 40),找是否存在一个非空子集,其和恰好等于目标值 target。

朴素暴力: 枚举所有 2^N 个子集,时间 O(2^40) ≈ 10^12,完全不可行。

关键观察: 40 个元素太多,但 20 个元素的 2^20 = 1,048,576 完全可行!


6.4.2 折半搜索的核心思想

将 N 个元素分成两半(各 N/2 个),分别枚举,然后合并。

原数组:[a1, a2, ..., a40]
         ↓ 分成两半
左半边:[a1, ..., a20]  →  枚举所有子集和 → 2^20 个值
右半边:[a21, ..., a40] →  枚举所有子集和 → 2^20 个值

查找:对于左半边的每个子集和 s,
      在右半边查找是否有子集和等于 (target - s)
      用排序 + 二分查找:O(2^(N/2) × log(2^(N/2))) = O(2^(N/2) × N/2)

时间复杂度: O(2^(N/2) × N),N=40 时约 20 × 10^6,完全可行!


6.4.3 完整实现:子集和判断

#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 不大时
二维 DPO(N^2)特殊结构

折半搜索的适用条件:

  1. 问题可以拆成两个独立的「半问题」
  2. 两个半问题的结果可以快速合并(通常用排序+二分)
  3. 总量 N ≤ 40(每半不超过 20)

⚠️ 常见错误

错误原因修复方案
左半边子集和溢出N=40 且元素值大时,子集和超 intlong long
空集未处理mask=0 对应空集,和=0,target=0 时多计若 target=0 且要求非空子集,减 1
分割不均一半 20 一半 20 vs 1 和 39 效率差很多尽量均分:half = n/2
二分边界错误lower_bound vs upper_bound 混用lower_bound 找第一个 ≥,upper_bound 找第一个 >

💪 练习题

🟢 题目 1:子集和存在性

给定 N(≤40)个整数和 target,判断是否存在非空子集和恰好等于 target。

✅ 完整解答

直接使用 6.4.3 节的代码。


🟡 题目 2:子集和计数

给定 N(≤40)个整数和 target,统计和恰好为 target 的非空子集数量(答案可能很大,对 10^9+7 取模)。

✅ 完整解答

使用 6.4.4 节的计数代码,注意模运算(统计时就取模)。

#include <bits/stdc++.h>
using namespace std;
const long long MOD = 1e9 + 7;

int main() {
    int n; long long target;
    cin >> n >> target;
    vector<long long> a(n);
    for (long long& x : a) cin >> x;
    
    int half = n / 2, left_n = half, right_n = n - half;
    
    map<long long, long long> left_cnt;  // 用 map 存和→出现次数(便于取模)
    for (int mask = 0; mask < (1 << left_n); mask++) {
        long long s = 0;
        for (int i = 0; i < left_n; i++)
            if ((mask >> i) & 1) s += a[i];
        left_cnt[s]++;
    }
    
    long long ans = 0;
    for (int mask = 0; mask < (1 << right_n); mask++) {
        long long s = 0;
        for (int i = 0; i < right_n; i++)
            if ((mask >> i) & 1) s += a[left_n + i];
        
        long long need = target - s;
        if (left_cnt.count(need))
            ans = (ans + left_cnt[need]) % MOD;
    }
    
    if (target == 0) ans = (ans - 1 + MOD) % MOD;  // 去掉全空集
    cout << ans << "\n";
}

🔴 题目 3:最接近目标的子集和

给定 N(≤40)个正整数和 target,找非空子集中和最大但不超过 target 的那个,输出这个最大和。

✅ 完整解答

直接使用 6.4.5 节的 max_subset_sum_le_target 函数。

追踪(a=[3,5,7,2], target=10):

左半边 [3,5]:子集和 = [0,3,5,8],排序后 [0,3,5,8]

右半边 [7,2]:
  mask=00 (空):s=0,need=10,在左找≤10的最大=8 → 答案候选 0+8=8
  mask=01 (7):s=7,need=3,在左找≤3的最大=3 → 答案候选 7+3=10 ✓
  mask=10 (2):s=2,need=8,在左找≤8的最大=8 → 答案候选 2+8=10 ✓
  mask=11 (9):s=9,need=1,在左找≤1的最大=0 → 答案候选 9+0=9

最大答案:10

💡 章节联系: 折半搜索是 USACO Gold 的独特技巧,每年约出现 1 道。它本质上是「暴力搜索 + 聪明合并」,将指数复杂度减半。与状压 DP(第 6.3 章)都处理 2^N 的搜索空间,但折半搜索不需要 DP 递推关系。

📖 第 6.5 章:数位 DP(Digit DP)

⏱ 预计阅读时间:50 分钟 | 难度:🟡 中等(USACO Gold 高频考点)


前置条件

  • DP 入门(第 6.1 章)
  • 经典 DP 问题(第 6.2 章)

🎯 学习目标

学完本章后,你将能够:

  1. 理解数位 DP 的核心框架:按位从高到低填数
  2. 掌握「tight(是否贴上界)」标志的作用
  3. 处理「前导零」问题
  4. 解决「区间 [L, R] 内满足某数字性质的数的个数」类问题

6.5.1 问题引入

一类常见问题

统计 1 到 N 中,满足某种「数字属性」的整数个数。

示例:

  • [1, N] 中各位数字之和等于 S 的数的个数
  • [1, N] 中不含数字 4 的数的个数
  • [1, N] 中各位数字单调不降的数的个数

为什么不能暴力? N 可能高达 10^18,逐一枚举完全不可能。

核心思路: 像「填数字」一样,从最高位到最低位逐位枚举,用 DP 记录状态,避免重复计算。


6.5.2 数位 DP 的框架

核心状态

状态量含义
pos当前填到第几位(从高位到低位)
tight当前选的数字是否恰好贴着 N 的对应位(是否受上界约束)
...其他属性...问题特定的属性(如各位之和、上一位的值等)

关键逻辑

如果 tight == true:
    当前位只能填 0 ~ digit[pos](digit[pos] 是 N 的第 pos 位)
    填 digit[pos] 时,下一位仍然 tight
    填 < digit[pos] 时,下一位不再 tight(自由了!)

如果 tight == false:
    当前位可以填 0~9(自由枚举)
    下一位也不 tight

6.5.3 完整例题一:各位数字之和

问题: 统计 1 到 N 中,各位数字之和恰好等于 S 的正整数个数。

#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 问题类型

问题类型额外状态示例
各位和 = Ssum本章 6.5.3
不含特定数字无(限制在 limit 里)无 4 的数
相邻不同last_digit本章 6.5.5
单调不降min_digit本章 6.5.6
恰好 k 个某数字count_of_digit恰好含 3 个 7
整除性余数 mod m能被 7 整除的数

⚠️ 常见错误

错误原因修复方案
忘记前导零处理把 0007 当 7 位数处理started 标志区分
记忆化键不完整缺少 tightstarted4 个维度都要包含
区间端点错误查 [L, R] 时用 f(L) 而非 f(L-1)count(R) - count(L-1)
dp 数组大小不够sum 最大可达 9×18=162dp[20][163][2][2]

💪 练习题

🟢 题目 1:不含数字 4

统计 [1, N] 中不含数字 4 的整数个数(N ≤ 10^15)。

✅ 完整解答

思路: 数位 DP,遇到 d=4 时跳过。

#include <bits/stdc++.h>
using namespace std;
typedef long long ll;
int digits[20], n_digits;
ll dp[20][2][2];
bool vis[20][2][2];

ll solve(int pos, bool tight, bool started) {
    if (pos == n_digits) return started ? 1 : 0;
    ll& ret = dp[pos][tight][started];
    if (vis[pos][tight][started]) return ret;
    vis[pos][tight][started] = true;
    int lim = tight ? digits[pos] : 9;
    ret = 0;
    for (int d = 0; d <= lim; d++) {
        if (d == 4) continue;  // 不含4
        ret += solve(pos + 1, tight && (d == lim), started || (d > 0));
    }
    return ret;
}

ll count_up_to(ll N) {
    if (N <= 0) return 0;
    n_digits = 0;
    ll tmp = N;
    while (tmp) { digits[n_digits++] = tmp % 10; tmp /= 10; }
    reverse(digits, digits + n_digits);
    memset(vis, 0, sizeof(vis));
    return solve(0, true, false);
}

int main() {
    ll L, R; cin >> L >> R;
    cout << count_up_to(R) - count_up_to(L - 1) << "\n";
}

🟡 题目 2:各位数字和为 S

统计 [L, R] 中各位数字之和恰好为 S 的整数个数(L, R ≤ 10^18,S ≤ 162)。

✅ 完整解答

直接使用 6.5.4 节的通用模板,设置目标和 S。


🔴 题目 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 或贪心
  • 测试方法: 样例、边界情况、对拍
  • 调试技巧: cerrassert、AddressSanitizer
  • Bronze → Silver 检查清单

第 7.3 章:Ad Hoc 题型

  • 什么是 ad hoc: 无标准算法;需要特定于题目的洞察
  • ad hoc 思维方式: 小例子 → 找规律 → 证明不变量 → 实现
  • 6 个类别: 观察/规律、模拟捷径、构造、不变量/不可能性、贪心观察、几何/网格
  • 核心技术: 奇偶论证、鸽巢原理、坐标压缩、对称化简、逆向思考
  • 9 道练习题(简单 → 困难 → 挑战)含提示

竞赛日检查清单

竞赛当天参考:

  • 模板已编译并测试通过
  • 在编写任何代码之前先读完全部三道
  • 手动推演样例
  • 确认约束条件和相应的算法层级
  • 先解最简单的题
  • 提交前用样例测试
  • 若卡住:为小数据范围编写暴力以获取部分分
  • 剩 30 分钟时:停止添加代码,专注于测试
  • 再检查一遍:需要 long long 的地方用了吗?数组边界正确吗?

🏆 USACO 技巧: 竞赛前一周最值得做的事是从记忆中重新解 5-10 道你以前做过的题。速度和准确性与知识积累同样重要。

📖 第 7.1 章 ⏱️ 约 40 分钟 🎯 各级别适用

第 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 小时

下图展示了完整的竞赛赛季和关键日期:

USACO Contest Timeline

竞赛在周五开放,实际竞赛时间 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 Divisions

金字塔展示了 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,有很多部分分策略。

部分分策略

若无法完全解决一道题:

  1. 解决小数据: 若 N ≤ 20,O(N!) 或 O(2^N) 的暴力通常能通过几个测试点
  2. 解决特殊情况: 若图是树,或所有值相等,先解决这些
  3. 始终输出某个答案: 若认为答案总是「YES」或某个常数,试几个测试点看看
  4. 优雅地超时: 确保部分解法不崩溃——TLE 比运行时错误好

7.1.5 竞赛时间管理

4 小时策略

前 30 分钟: 读完全部 3 道题。先别写代码,只是理解题目并思考。

  • 确认哪道题看起来最简单
  • 记下边界情况或特殊条件
  • 开始在脑中形成思路

第 1-2 小时: 解最简单的题(通常是题 1 或题 2)。

  • 实现、用样例测试、调试
  • 争取至少一道题 100%

第 2-3 小时: 攻第二简单的题。

  • 若卡住,考虑部分分做法

最后一小时: 要么完成第三道题,要么整合/调试已有的解法。

  • 剩 30 分钟时:停止加新代码,专注测试和修 bug

读题

在写任何代码之前,花 5-10 分钟读每道题:

  • 再读一遍约束条件(N、值的范围、特殊条件)
  • 在纸上手动推演样例
  • 思考:「这让我想到什么算法?」

卡住了怎么办

  1. 手动试小例子——能发现什么规律?
  2. 考虑更简单的版本:N=1 时怎么样?N=2?N=10?
  3. 想想:这是图论题吗?DP?排序/贪心题?
  4. 先写暴力——它可能够快,或帮助你理解结构

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、优先队列
10Ad Hoc/观察巧妙洞察,无标准算法仔细读题,找规律——见第 7.3 章深入讲解

7.1.9 Silver 题目分类

Silver 题目需要更复杂的算法,以下是主要类别:

类别关键算法N 约束所需时间
排序 + 贪心排序 + 扫描、区间调度N ≤ 10^5O(N log N)
二分查找二分答案、参数搜索N ≤ 10^5O(N log N) 或 O(N log² N)
BFS/DFS最短路、分量、洪水填充N ≤ 10^5O(N + M)
前缀和一维/二维区间查询、差分数组N ≤ 10^5O(N)
基础 DP一维 DP、LIS、背包、网格路径N ≤ 5000O(N²) 或 O(N log N)
DSU动态连通性、Kruskal MSTN ≤ 10^5O(N α(N))
图 + DP树上 DP、DAG 路径N ≤ 10^5O(N) 或 O(N log N)

USACO 的时间复杂度限制

这很关键:USACO 题目时间限制严格(通常 2-4 秒)。用这张表确定所需算法复杂度:

N(输入规模)所需复杂度允许的算法
N ≤ 10O(N!)排列暴力
N ≤ 20O(2^N × N)状压 DP、全搜索
N ≤ 100O(N³)Floyd-Warshall、区间 DP
N ≤ 1,000O(N²)标准 DP、逐对处理
N ≤ 10,000O(N² / 常数)有时优化后的 O(N²) 可行
N ≤ 100,000O(N log N)排序、BFS、二分查找、DSU
N ≤ 1,000,000O(N)线性算法、前缀和
N ≤ 10^9O(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 分钟至关重要

在写第一行代码之前:

  1. 读完全部 3 道题(先看标题和约束)
  2. 估计难度: 哪道最简单?(Bronze/Silver 通常是题 1)
  3. 注意关键约束: N ≤ ?时间限制,特殊条件
  4. 在脑中分类每道题(用上面的分类法)

部分分策略

即使无法完全解一道题,也要争取部分分:

📄 即使无法完全解一道题,也要争取部分分:
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 章 ⏱️ 约 45 分钟 🎯 各级别适用

第 7.2 章:解题策略

了解算法是必要条件,但还不够。你还需要知道面对从未见过的题目时如何思考。本章教给你一套系统的方法。


7.2.1 如何读竞赛编程题

USACO 题目有一致的结构,学会高效解析它。

题目结构

  1. 故事/背景 —— 一个主题(通常是奶牛 🐄)。大多是润色文字——不要分心。
  2. 任务/目标 —— 实际的问题。仔细阅读这部分。
  3. 输入格式 —— 如何读取数据。
  4. 输出格式 —— 精确地打印什么。
  5. 样例输入/输出 —— 示例。
  6. 约束条件 —— 选择算法最重要的部分。

读题纪律

第一步: 先读任务/目标,再读输入/输出格式。 第二步: 读约束条件。这些告诉你:

  • 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 识别算法类型

读完题后,按顺序问自己这些问题:

图示:解题流程图

Problem Solving Flow

上图捕捉了完整的竞赛工作流程。关键步骤是将输入约束映射到算法复杂度——用下面的复杂度表来快速做出这个决策。

图示:复杂度与输入规模

Complexity Table

这张参考表能立刻告诉你所选算法是否能通过。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

自己创造测试用例

给定的样例很简单。自己创造:

  1. 最小情况: N=1,N=0,空输入
  2. 最大情况: N 取最大约束,所有值取最大
  3. 所有值相同: N 个元素全部相等
  4. 已排序/逆序排列
  5. 特殊结构: 完全图、路径图、星形图(图论题)

对拍

为小 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:mapsetpriority_queuevectorsort

解题技能

  • 能判断一道题需要 BFS vs DFS vs DP vs 贪心
  • 能在 10 分钟内从零实现 BFS
  • 能在 5 分钟内从零实现 DSU
  • 能把网格题建模成图
  • 知道如何对答案二分
  • 熟练使用二维数组和网格遍历

竞赛技能

  • 能在 30 秒内写出带快速 I/O 的清晰模板
  • 需要时从不忘记 long long
  • 提交前始终用样例测试
  • 能快速读懂并理解约束条件
  • 至少练习过 20 道 Bronze 题
  • 至少解过 5 道 Silver 题(哪怕借助提示)

练习计划

  1. 解所有容易找到的 USACO Bronze 题(2016–2024)
  2. 每道 2 小时内解不出的题:读题解,从零实现
  3. 解 30+ 道 Bronze 后,挑战 Silver:从 2016–2018 Silver 开始
  4. 保持题目日志:题目名称、用到的技术、关键洞察

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 章 ⏱️ 约 50 分钟 🎯 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?」类题目。

应用方法:

  1. 确定每个操作对某个量 Q 做了什么
  2. 若每个操作将 Q 改变偶数,则 Q mod 2 是不变量
  3. 若 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

  1. 无法识别算法类型时不要慌
  2. 试小例子 — N=2, 3, 4 — 寻找规律
  3. 问:这道题特别在哪里? — 是什么性质让它与一般版本不同?
  4. 考虑:如果能解决更简单的版本怎么办? — 然后推广
  5. 相信你的观察 — 若在小例子中发现了规律,大概率是正确的

本章总结

📌 核心要点

概念要点
定义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 专题

USACO Gold 级别考察的算法与技术。在 Silver 基础上更进一步,深入挑战难度更高的图论问题、高级树型算法与组合数学。

5
章节
约 5 周
预计学习时长
Gold
USACO 级别

第八部分:USACO Gold 专题

📝 前置要求: 学习第八部分前,请确保已熟练掌握第 2~7 部分的内容,尤其是:

  • 图论算法: BFS/DFS、Dijkstra、Bellman-Ford、并查集(第 5.1~5.4 章)
  • 动态规划: 记忆化搜索、递推、状压 DP、区间 DP(第 6.1~6.3 章)
  • 数据结构: 线段树、树状数组、单调结构(第 3.x 章)

USACO Gold 不再有"套用算法 X 即可"的清晰模式,需要你判断适用哪种技术、组合多个思路,并在竞赛压力下高效实现。

本部分覆盖 USACO Gold 中出现频率最高的五大核心类别。


📚 章节概览

章节主题核心技术难度
第 8.1 章:最小生成树以最小总权重连接所有节点Kruskal(DSU)、Prim(优先队列)、MST 性质、Kruskal 式贪心🟡 中等
第 8.2 章:拓扑排序与 DAG DP有向无环图中的排序;DAG 上的 DP;强连通分量Kahn 算法、DFS 拓扑排序、最长路、Tarjan/Kosaraju SCC、缩点 DAG、2-SAT、差分约束🔴 困难
第 8.3 章:树形 DP 与换根树上 DP;高效处理所有根节点的情况;树背包子树 DP、换根技术(求和 + 最大值)、直径、树背包 O(NW)🔴 困难
第 8.4 章:欧拉游览与树的展开将树展开为数组以支持区间查询欧拉游览、DFS 进出时间戳、倍增 LCA、路径查询🔴 困难
第 8.5 章:组合数学与数论计数、模运算、数的性质C(n,r) mod p、快速幂、容斥原理、筛法、欧拉 φ 函数中国剩余定理🔴 困难

🗺️ 依赖关系图

第五部分(图论)──────────────────► 第 8.1 章 最小生成树
                                      │
                                      └──► 第 8.2 章 拓扑排序 & DAG DP
                                                │
第 5.3 节(树)──────────────────► 第 8.3 章 树形 DP & 换根
                                      │
                                      └──► 第 8.4 章 欧拉游览 & LCA
                                                │
第二部分(数学)+ 第 3.x 章(数据结构)── ► 第 8.5 章 组合数学 & 数论

🎯 Gold 与 Silver 的区别

Silver 级别,大多数题目对应一种明确技术:"这是 BFS 题""这是前缀和题"。

Gold 级别,挑战在于:

  1. 识别 — 判断哪种技术适用,题目叙述往往加以掩盖
  2. 组合 — 结合两种或多种技术(如 DSU + 排序构造 MST,欧拉游览 + BIT 处理树上查询)
  3. 效率 — Silver 中 O(N²) 的思路在 Gold 中需要优化到 O(N log N)
  4. 证明 — Gold 题目常需要在编码前验证贪心策略的正确性

💡 Gold 解题策略: 遇到 Gold 题目时,逐一自问:

  • 有图结构吗?→ 想到 MST、最短路、拓扑排序
  • 有树结构吗?→ 想到树形 DP、换根、欧拉游览 + 数据结构
  • 答案是计数吗?→ 想到组合数学、带计数状态的 DP
  • 能排序后贪心选取吗?→ 想到 Kruskal 式贪心

📈 USACO Gold 题目分布

根据近几届 USACO 竞赛统计,各主题出现频率大致如下:

主题频率备注
图论算法(MST、最短路、并查集)~30%几乎每场竞赛都有
DP(树形 DP、状压 DP、区间 DP)~35%最常见的单一主题
数据结构(线段树、BIT、有序集合)~20%常与其他主题结合
组合数学 / 数学~10%通常出现在一月份竞赛
Ad Hoc / 构造题~5%难以针对性备考

🔗 本部分与 Platinum 的衔接

Gold 之后,USACO Platinum 会引入:

  • 线段树势能Li Chao 树(高级数据结构)
  • 重心剖分(树算法)
  • 后缀数组(字符串算法)
  • 最大流 / 最小割(网络流)

第八部分的所有内容都是 Platinum 的先修知识。欧拉游览(第 8.4 章)尤为重要——几乎每道 Platinum 树题都会用到。

📖 第 8.1 章 ⏱️ 约 50 分钟 🎯 Gold

第 8.1 章:最小生成树

📝 前置要求: 本章需要第 5.1~5.3 章(图、BFS/DFS、并查集/DSU)的知识。阅读 Kruskal 算法前,必须理解 DSU 的 findunion 操作。

带权无向图的**最小生成树(MST)**是满足以下条件的边的子集:

  1. 连通所有 N 个顶点(生成)
  2. 不包含环(树)
  3. 边权总和尽可能小(最小)

USACO Gold 中的 MST 题目常以各种形式出现:如"以最低成本建立网络"、"求连通所有节点的最小代价",或需要考虑哪些边是必须保留的。

学习目标:

  • 理解什么是生成树,以及 MST 的用途
  • 用 DSU 实现 Kruskal 算法,时间复杂度 O(E log E)
  • 用优先队列实现 Prim 算法,时间复杂度 O(E log V)
  • 识别 USACO 中的 MST 问题,并运用割边性质与环性质

8.1.0 什么是生成树?

对于一个有 N 个顶点和 E 条边的连通图,生成树是满足以下条件的边的子集:

  • 连通所有 N 个顶点
  • 恰好使用 N−1 条边
  • 不包含环

一个图可以有许多生成树。最小生成树是边权之和最小的那棵。

图:                    某棵生成树:               MST:
  1                         1                          1
 / \                       / \                        / \
2   3   边权:             2   3                      2   3
|\ /|   1-2: 4             |                          |
| X |   1-3: 2             4                          4
|/ \|   2-3: 5       总权=4+2+3=9              总权=4+2+1=7 ← 最小
4   5   2-4: 3
        3-4: 1
        4-5: 6

💡 为什么恰好是 N−1 条边? N 个节点的树恰好有 N−1 条边。少了则不连通;多了则有环。


8.1.1 割边性质与环性质

这两个基本性质是 MST 算法正确性的理论基础:

割边性质(Cut Property): 对图的任意划分(将顶点分为两组 S 和 V−S),连接两组的最小权重边一定属于某棵 MST。

环性质(Cycle Property): 对图中任意一个环,环上最大权重的边一定不属于任何 MST(权重有并列时除外)。

这两个性质同时证明了 Kruskal 算法和 Prim 算法的正确性。


8.1.2 Kruskal 算法

核心思想: 将所有边按权重排序,贪心地加入不构成环的最便宜边。用 DSU 以 O(α(N)) ≈ O(1) 的时间检测是否成环。

算法流程:

  1. 将所有边按权重从小到大排序
  2. 用 N 个分量初始化 DSU(每个顶点自成一组)
  3. 对每条边 (u, v, w)(按排序顺序):
    • find(u) ≠ find(v):将此边加入 MST,调用 union(u, v)
    • 否则:跳过(会构成环)
  4. 当 MST 包含 N−1 条边时停止
📄 4. 当 MST 包含 N−1 条边时停止
#include <bits/stdc++.h>
using namespace std;

// ── 并查集(DSU)──────────────────────────────────
struct DSU {
    vector<int> parent, rank_;
    int components;

    DSU(int n) : parent(n), rank_(n, 0), components(n) {
        iota(parent.begin(), parent.end(), 0); // parent[i] = i
    }

    int find(int x) {
        if (parent[x] != x)
            parent[x] = find(parent[x]);  // 路径压缩
        return parent[x];
    }

    bool unite(int x, int y) {
        x = find(x); y = find(y);
        if (x == y) return false;           // 已在同一分量中
        if (rank_[x] < rank_[y]) swap(x, y);
        parent[y] = x;                      // 将秩小的挂到秩大的下面
        if (rank_[x] == rank_[y]) rank_[x]++;
        components--;
        return true;
    }

    bool connected(int x, int y) { return find(x) == find(y); }
};

// ── Kruskal MST ────────────────────────────────────
int main() {
    ios_base::sync_with_stdio(false);
    cin.tie(NULL);

    int n, m;
    cin >> n >> m;  // n 个顶点,m 条边

    // edges[i] = {权重, u, v}
    vector<tuple<int,int,int>> edges(m);
    for (auto& [w, u, v] : edges) {
        cin >> u >> v >> w;
        u--; v--;  // 0-indexed
    }

    sort(edges.begin(), edges.end());  // 按权重排序(第一个元素)

    DSU dsu(n);
    long long mst_weight = 0;
    int edges_added = 0;
    vector<pair<int,int>> mst_edges;

    for (auto& [w, u, v] : edges) {
        if (dsu.unite(u, v)) {      // 仅在不构成环时加边
            mst_weight += w;
            mst_edges.push_back({u, v});
            edges_added++;
            if (edges_added == n - 1) break;  // MST 构建完成
        }
    }

    if (edges_added < n - 1) {
        cout << "图不连通——MST 不存在\n";
    } else {
        cout << "MST 权重:" << mst_weight << "\n";
    }

    return 0;
}

复杂度: 排序 O(E log E) + DSU 操作 O(E · α(N)) ≈ O(E log E)

Kruskal 算法追踪示例

顶点:4 个(0, 1, 2, 3)
边(已排序):(0,1,1)、(1,2,2)、(2,3,3)、(0,2,5)、(1,3,6)

第 1 步:边 (0,1,w=1) → find(0)=0 ≠ find(1)=1 → 加入  DSU: {0,1},{2},{3}
第 2 步:边 (1,2,w=2) → find(1)=0 ≠ find(2)=2 → 加入  DSU: {0,1,2},{3}
第 3 步:边 (2,3,w=3) → find(2)=0 ≠ find(3)=3 → 加入  DSU: {0,1,2,3}
        edges_added = 3 = n-1 → 完成

MST 权重 = 1 + 2 + 3 = 6

8.1.3 Prim 算法

核心思想: 从一个起始顶点出发逐步扩张 MST。每一步,加入将 MST 内某顶点与 MST 外某顶点相连的最小权重边。用小根堆(优先队列)高效选取最便宜的边。

Prim vs Kruskal 的选择: 对于稠密图(E ≈ V²),Prim 算法更优——邻接表 + 堆的实现为 O(E log V),而 Kruskal 需要排序 E 条边,复杂度为 O(E log E)。在稠密图上 E log V < E log E,但在竞赛实践中两者通常都能满足约束。

📄 C++ 完整代码
#include <bits/stdc++.h>
using namespace std;

int main() {
    ios_base::sync_with_stdio(false);
    cin.tie(NULL);

    int n, m;
    cin >> n >> m;

    // 邻接表:adj[u] = {边权, 邻居} 的列表
    vector<vector<pair<int,int>>> adj(n);
    for (int i = 0; i < m; i++) {
        int u, v, w;
        cin >> u >> v >> w;
        u--; v--;
        adj[u].push_back({w, v});
        adj[v].push_back({w, u});  // 无向图
    }

    // 用小根堆实现 Prim
    vector<bool> in_mst(n, false);
    long long mst_weight = 0;
    int edges_added = 0;

    // 小根堆:{边权, 顶点}
    priority_queue<pair<int,int>, vector<pair<int,int>>, greater<>> pq;
    pq.push({0, 0});  // 从顶点 0 出发,代价 0

    while (!pq.empty() && edges_added < n) {
        auto [w, u] = pq.top(); pq.pop();

        if (in_mst[u]) continue;  // 已加入 MST,跳过(懒惰删除)
        in_mst[u] = true;
        mst_weight += w;
        edges_added++;

        for (auto [edge_w, v] : adj[u]) {
            if (!in_mst[v]) {
                pq.push({edge_w, v});  // 候选扩展边
            }
        }
    }

    if (edges_added < n) {
        cout << "图不连通\n";
    } else {
        cout << "MST 权重:" << mst_weight << "\n";
    }

    return 0;
}

复杂度: 用二叉堆时为 O(E log V)。


8.1.4 MST 性质在解题中的应用

除了直接计算 MST,以下几个性质在 USACO 中非常实用:

性质 1:唯一性

若所有边权互不相同,则 MST 唯一。若存在相同边权,可能有多棵总权重相等的 MST。

性质 2:瓶颈生成树

MST 最小化了任意两顶点之间路径上的最大边权。即:MST 上 u 到 v 的路径具有最小可能的"瓶颈"边

💡 USACO 应用: "u 到 v 路径上最大边权的最小值是多少?"→ 答案是 MST 上 u 到 v 路径中的最大边权。

性质 3:MST 作为贪心框架

很多 USACO Gold 题目可以归结为带有变形的 Kruskal 算法:

  • 按某种代价对"连接"排序
  • 贪心合并各组,只要合并合法
  • DSU 追踪哪些组已连通

非标准"边"上的 Kruskal 算法

经典 USACO 模式:边不是显式给出的——你需要自己分析排序的依据和"合并"的含义。

示例模式(USACO 2016 February Gold — Fencing the Cows):

  • 奶牛分布在各个牧场中;连接两头奶牛有代价
  • 目标:以最小总代价将所有奶牛连通
  • 解法:建图后运行 Kruskal 算法

8.1.5 Kruskal 重构树

Kruskal 重构树(Kruskal 树)是在 Kruskal 算法过程中构建的一种强大结构,它编码了连通分量的"合并历史"。

构建方法: 当 Kruskal 算法通过权重为 w 的边合并包含 u 和 v 的分量时:

  • 创建一个新节点 x,值为 w
  • 将 u 所在分量和 v 所在分量的根分别设为 x 的子节点
  • 用 x 替代这两个分量成为新的根

构建完毕后,该树具有以下结构:

  • N 个叶节点(原始顶点)
  • N-1 个内部节点(每条 MST 边对应一个,值 = 边权)
  • 共 2N-1 个节点
📄 Code 完整代码
示例 MST 边(已排序):(0,1,w=1)、(1,2,w=2)、(2,3,w=3)

合并 (0,1,w=1) 后:  节点 4(w=1)
                      / \
                     0   1

合并 (1,2,w=2) 后:  节点 5(w=2)
                      / \
                节点4    2
                (w=1)
                 / \
                0   1

合并 (2,3,w=3) 后:  节点 6(w=3)
                      / \
                  节点5   3
                  (w=2)
                   / \
                节点4  2
                (w=1)
                 / \
                0   1

关键性质:LCA 即瓶颈边

Kruskal 树中 u 和 v 的 LCA 的值 = MST 上 u 到 v 路径中最大边权 = u 和 v 之间最小可能瓶颈。

这意味着:

  • 查询"u 和 v 之间的最小瓶颈"→ 在 Kruskal 树中求 LCA
  • 查询"使 u 和 v 连通的最小边权是多少?"→ 同上
📄 C++ 完整代码
#include <bits/stdc++.h>
using namespace std;

struct DSU {
    vector<int> parent, rank_, root;  // root[i] = 以 i 为根的分量在 Kruskal 树中的根节点
    DSU(int n) : parent(n), rank_(n, 0), root(n) {
        iota(parent.begin(), parent.end(), 0);
        iota(root.begin(), root.end(), 0);
    }
    int find(int x) {
        return parent[x] == x ? x : parent[x] = find(parent[x]);
    }
    // 返回此次合并创建的新 Kruskal 树节点
    int unite(int x, int y, int new_node) {
        x = find(x); y = find(y);
        if (x == y) return -1;
        if (rank_[x] < rank_[y]) swap(x, y);
        parent[y] = x;
        if (rank_[x] == rank_[y]) rank_[x]++;
        root[x] = new_node;   // 新 Kruskal 树节点成为合并后分量的根
        return new_node;
    }
    int get_root(int x) { return root[find(x)]; }
};

// 构建 Kruskal 重构树
// 返回:Kruskal 树的邻接表和节点值
// 叶节点 0..n-1 为原始顶点;节点 n..2n-2 为内部节点(MST 边)
void build_kruskal_tree(
        int n,
        vector<tuple<int,int,int>>& edges,   // {权重, u, v}——必须已排序
        vector<vector<int>>& ktree,          // ktree[node] = Kruskal 树中的子节点
        vector<int>& node_val                // node_val[node] = 边权(内部节点)或 0(叶节点)
) {
    ktree.assign(2 * n, {});
    node_val.assign(2 * n, 0);

    DSU dsu(n);
    int next_node = n;  // 下一个内部节点的 ID

    for (auto [w, u, v] : edges) {
        int ru = dsu.find(u), rv = dsu.find(v);
        if (ru == rv) continue;  // 同一分量,跳过

        // 创建新内部节点
        int x = next_node++;
        node_val[x] = w;

        // 添加子节点:u 和 v 所在分量的 Kruskal 树根
        ktree[x].push_back(dsu.get_root(u));
        ktree[x].push_back(dsu.get_root(v));

        dsu.unite(u, v, x);
    }
}

// 构建完毕后,在 ktree 上用倍增 LCA 回答瓶颈查询
// ktree 中的 lca(u, v) 即为 MST 上 u 和 v 之间的瓶颈边权

USACO Gold 应用

题型:"对于每个查询 (u, v, k),统计从 u 出发仅使用权重 ≤ k 的边可以到达的顶点数"

在 Kruskal 树中,阈值 k 下以某个 LCA 为根的子树,恰好包含当只允许使用权重 ≤ node_val[LCA] 的边时,从 u 可以到达的所有顶点。

// 查询:仅使用权重 <= threshold 的边时,
// 与 u 在同一连通分量中的顶点数是多少?
// → 在 Kruskal 树中找 u 的最深祖先 x,满足 node_val[x] <= threshold
// → 答案 = sz[x](子树大小,计算的是叶节点数 = 原始顶点数)

💡 原因: Kruskal 树精确记录了边的合并顺序。内部节点 x 的子树包含了在权重为 node_val[x] 的边加入时同属一个连通分量的所有顶点。


8.1.6 USACO Gold 题型模式

模式 1:直接求 MST

"以最小总连接代价连通所有 N 个节点。"

直接应用 Kruskal 或 Prim 算法。

模式 2:排序 + DSU(Kruskal 式贪心)

"按某种顺序处理事件/对;合并分组;查询连通性。"

这本质是 Kruskal 算法,只是没有明确称其为 MST。核心思路:按某种标准排序,再用 DSU 合并。

// 模板:Kruskal 式贪心
sort(events.begin(), events.end(), comparator);
DSU dsu(n);
for (auto& event : events) {
    if (dsu.unite(event.u, event.v)) {
        // 处理合并操作
    }
}

模式 3:MST + 附加查询

"求 MST,然后对 MST 上的路径回答查询。"

构建 MST,再由 MST 的边构造出一棵树,最后回答路径查询(通常与第 8.4 章的欧拉游览结合使用)。


💡 思路陷阱

陷阱 1:把"最小瓶颈路"误当"最短路"

错误判断: "求 u 到 v 路径上最大边权的最小值,用 Dijkstra 最短路"
实际情况: 最短路最小化边权之和;最小瓶颈路最小化路径上的最大边 — 这是 MST 问题

图:u→A(w=1), u→B(w=5), A→v(w=10), B→v(w=6)
Dijkstra 最短路(u→v):u→A→v,总权=11,最大边=10
MST 瓶颈路(u→v):    u→B→v,总权=11,最大边=6  ← 最小瓶颈

关键:最小瓶颈路 = MST 上 u→v 的路径(由割边性质保证)

识别信号: 题目要求"最小化路径上的最大/最重边" → MST + 树上路径,而非 Dijkstra


陷阱 2:贪心合并时忘记"Kruskal 视角"

错误判断: "按某种顺序处理操作,感觉像贪心,写个模拟"
实际情况: 操作可以排序 + 用 DSU 合并 → 本质是 Kruskal 的变体

典型题:N 个集合,每次可以合并代价最小的两个集合(权重 = 两集合大小之积)
错误:模拟优先队列,每次弹出最小的两个合并 → 复杂度 O(N² log N)
正确:认识到"按代价排序 + DSU 合并"就是 Kruskal-style greedy → O(N log N)

识别信号: "处理 N 个对象,按某种代价合并,最终连通" → 先想 Kruskal 框架


⚠️ 常见错误

  1. 忘记判断连通性: 不是所有图都连通。Kruskal 结束后,验证 edges_added == n - 1;Prim 结束后,验证 edges_added == n

  2. DSU 实现有误(未路径压缩或未按秩合并): 朴素 DSU 不加优化时每次操作 O(N),导致 Kruskal 整体为 O(E·N) 而非 O(E log E)。

  3. 边数差一: MST 有 N−1 条边。若停在 N 条边,则多加了一条。

  4. 将 Kruskal 用于有向图: 两种算法都假设无向边。有向图需要不同方法(最小树形图 / 朱-刘/Edmonds 算法——USACO Gold 不考)。

  5. 整数溢出: 若边权最大 10⁹ 且 N = 10⁵,MST 总权重可达约 10¹⁴。请使用 long long


📋 章节小结

📌 核心要点

概念说明
MST 定义N−1 条边连通所有 N 个顶点,总权重最小
Kruskal 算法排序边,贪心加入不构成环的边(DSU);O(E log E)
Prim 算法从源点出发用小根堆扩张;O(E log V)
割边性质任意割的最小边一定在所有 MST 中
环性质任意环的最大边不在任何 MST 中
瓶颈路径MST 路径最小化了任意两顶点间的最大边
USACO 题型排序 + DSU 就是伪装后的 Kruskal——要认出来!

❓ 常见问题

Q:Kruskal 和 Prim 各适用什么场景?
A:竞赛中几乎总是优先选 Kruskal + DSU——实现更简洁,对稀疏图(USACO 的典型场景)效果好。只有在 E ≈ V² 的稠密图时才考虑 Prim。

Q:对所有边权加同一个常数,MST 会变吗?
A:不会——给所有边加常数不改变 MST 包含哪些边(只影响总权重)。

Q:一个图能有多棵 MST 吗?
A:可以,当存在相等边权时。但 MST 的总权重(总和)始终唯一。

Q:图不连通怎么办?
A:此时不存在生成树(无法连通所有顶点)。改为计算最小生成森林——每个连通分量各自的 MST。

Q:我的 Kruskal 在 USACO 评测机上答案错误——哪里出问题了?
A:检查:① 顶点编号是 1-indexed 还是 0-indexed 是否一致?② DSU 的路径压缩是否正确?③ 总权重是否使用了 long long

🔗 与后续章节的联系

  • 第 8.3 章(树形 DP): 构建 MST 后它就是一棵树,可以在 MST 上做树形 DP 来回答路径查询。
  • 第 8.4 章(欧拉游览): 欧拉游览可以在 MST 树结构上支持区间查询。
  • 第 5.3 节(DSU): Kruskal 算法大量依赖 DSU,请准备好第 5.3 节中带路径压缩的 DSU 模板。

🏋️ 练习题

🟢 简单

8.1-E1. 经典 MST
给定 N 个城市和 M 条有权道路,求连通所有城市的最小总权重。
(标准 Kruskal——热身题)

提示

按权重排序边,用 DSU 应用 Kruskal 算法。输出 MST 的边权之和。

解题模板
#include <bits/stdc++.h>
using namespace std;
// ...(使用 8.1.2 中的 Kruskal 模板)

8.1-E2. 是否连通?
给定 N 个节点和 M 条边,判断图是否连通。若连通,输出 MST 权重;若不连通,输出连通分量的数量。

提示

Kruskal 算法结束后,检查 edges_added == n - 1。若不满足,图不连通。连通分量的数量为 dsu.components


🟡 中等

8.1-M1. 最小瓶颈路径 (USACO 风格)
给定 N 个节点、M 条有权边以及 Q 个查询 (u, v),对每个查询求从 u 到 v 的任意路径上最大边权的最小值。

提示

核心思路:查询 (u, v) 的答案就是 MST 上 u 到 v 路径中的最大边权。

构建 MST。对每个查询,在 MST 树上找路径并返回最大边。(朴素:对每个查询做 DFS/BFS,O(N·Q)。高效:LCA + 倍增,见第 8.4 章。)

竞赛中,若 Q ≤ 1000,朴素的 O(N·Q) 方案通常能通过。


8.1-M2. Kruskal 式贪心 USACO 2016 February Gold — Fencing the Cows)
N 头奶牛,每头在某个牧场中。在牧场 i 和 j 之间移动一头奶牛的代价为 |i - j|。用最小总代价将所有牧场连成一组。

提示

这本质是在完全图上求 MST,但排序所有 O(N²) 条边太慢。核心观察:对于数轴上的点,MST 始终只使用相邻边!将奶牛按位置排序,只在相邻牧场之间添加边。


🔴 困难

8.1-H1. 动态连通性 (进阶)
给定 N 个节点,处理 Q 次操作:"添加边 (u,v,w)"或"查询:到目前为止所有已添加边的 MST 权重是多少?"

提示

维护一个有序的边集合,增量运行 Kruskal 算法。当新增边 (u,v,w) 时:若 u 和 v 在当前 MST 中已连通,只有当 w 小于 MST 路径上的最大边权时,才用新边替换。

这需要找到树上路径中的最大边——使用欧拉游览 + 线段树,或倍增。


🏆 挑战

8.1-C1. Steiner 树(近似)
给定一个图和一组"必选"顶点集合 S,求连通 S 中所有顶点的最小权重子树(Steiner 树问题)。当 |S| ≤ 15 时,用状压 DP 精确求解。

提示

这不是 MST 问题——而是状压 DP 与最短路的结合。定义 dp[mask][v] = 以顶点 v 为节点、生成 mask 中所有顶点的最小代价树。

📖 第 8.2 章 ⏱️ 约 80 分钟 🎯 Gold

第 8.2 章:拓扑排序与 DAG DP

📝 前置要求: 本章需要第 5.1 章(图的表示)、第 5.2 章(BFS/DFS)及第 6.1~6.2 章(DP 基础)。学习前应熟悉邻接表和基本记忆化搜索。

有向无环图(DAG) 是没有环的有向图。DAG 能建模依赖关系——任务、先修课程、构建系统、谜题状态——并支持一种特殊算法:拓扑排序,它将顶点排成一条线,使得每条有向边都从前面的顶点指向后面的顶点。

学习目标:

  • 用 Kahn 算法(BFS)和 DFS 实现拓扑排序
  • 检测有向图中的环
  • 在 DAG 上应用 DP:最长路径、路径计数、关键路径分析
  • 用 Tarjan 或 Kosaraju 算法求强连通分量(SCC)
  • 构建缩点 DAG,并在一般有向图上做 DAG DP
  • 将 2-SAT 问题规约为 SCC 问题求解
  • 识别"在约束下排序"的问题本质是拓扑排序

8.2.0 什么是 DAG?

有向无环图的边有方向且不含环:

DAG(合法):         不是 DAG(含环):
  A → B → D              A → B
  ↓       ↓              ↑   ↓
  C ──────►E              D ← C

DAG 在以下场景中自然出现:

  • 先修课程: 课程 A 必须在课程 B 之前修
  • 构建系统: 模块 A 必须在模块 B 之前编译
  • 状态机: 谜题状态,不能返回之前的状态
  • 任务调度: 事件 A 必须在事件 B 之前发生

DAG 的核心操作是拓扑排序:将所有顶点排成一条线,使得对于每条有向边 (u → v),u 都出现在 v 之前。

DAG:                  合法的拓扑序:
A → B → D             A, C, B, D, E
↓       ↑             A, B, C, D, E
C ──────►             (可能存在多个合法排序)

8.2.1 Kahn 算法(BFS 拓扑排序)

核心思想: 反复移除入度为 0(没有前置依赖)的顶点。移除一个顶点后,其后继顶点的入度各减 1;任何入度降至 0 的顶点加入队列。

📄 C++ 完整代码
#include <bits/stdc++.h>
using namespace std;

// 返回拓扑序,若检测到环则返回空 vector
vector<int> topoSort(int n, vector<vector<int>>& adj) {
    // 第 1 步:计算入度
    vector<int> indegree(n, 0);
    for (int u = 0; u < n; u++)
        for (int v : adj[u])
            indegree[v]++;

    // 第 2 步:将所有源点(入度为 0 的顶点)加入队列
    queue<int> q;
    for (int i = 0; i < n; i++)
        if (indegree[i] == 0)
            q.push(i);

    vector<int> order;
    while (!q.empty()) {
        int u = q.front(); q.pop();
        order.push_back(u);

        for (int v : adj[u]) {
            indegree[v]--;             // 删除边 u → v
            if (indegree[v] == 0)      // v 的最后一个前置完成
                q.push(v);
        }
    }

    // 若输出不包含所有顶点,说明有环
    if ((int)order.size() != n) return {};  // 检测到环
    return order;
}

int main() {
    ios_base::sync_with_stdio(false);
    cin.tie(NULL);

    int n, m;
    cin >> n >> m;

    vector<vector<int>> adj(n);
    for (int i = 0; i < m; i++) {
        int u, v;
        cin >> u >> v;
        u--; v--;
        adj[u].push_back(v);
    }

    vector<int> order = topoSort(n, adj);
    if (order.empty()) {
        cout << "检测到环——不存在拓扑序\n";
    } else {
        for (int v : order) cout << v + 1 << " ";
        cout << "\n";
    }

    return 0;
}

复杂度: O(V + E)

💡 环检测: 若 Kahn 算法的输出顶点数少于 N,则存在环。那些"卡住"的顶点(入度始终无法降至 0 的)就是环的组成部分。

Kahn 算法追踪示例

📄 查看代码:Kahn 算法追踪示例
图:0→1, 0→2, 1→3, 2→3, 3→4

初始入度:[0, 1, 1, 2, 1]
队列:[0]

弹出 0 → order=[0],入度 1→0, 2→0
队列:[1, 2]

弹出 1 → order=[0,1],入度 3→1
弹出 2 → order=[0,1,2],入度 3→0
队列:[3]

弹出 3 → order=[0,1,2,3],入度 4→0
队列:[4]

弹出 4 → order=[0,1,2,3,4]

5 个顶点全部处理完 → 合法的拓扑序!

8.2.2 基于 DFS 的拓扑排序

另一种方法:DFS 按完成时间的逆序输出结果。

📄 另一种方法:DFS 按**完成时间的逆序**输出结果。
#include <bits/stdc++.h>
using namespace std;

vector<int> adj_list[100001];
vector<int> topo_order;
int color[100001];  // 0=白色(未访问), 1=灰色(栈中), 2=黑色(已完成)
bool has_cycle = false;

void dfs(int u) {
    color[u] = 1;  // 标记为"处理中"
    for (int v : adj_list[u]) {
        if (color[v] == 1) {
            has_cycle = true;  // 后向边 → 有环!
            return;
        }
        if (color[v] == 0)
            dfs(v);
    }
    color[u] = 2;             // 标记为"已完成"
    topo_order.push_back(u);  // 完成子树后再加入结果
}

int main() {
    int n, m;
    cin >> n >> m;
    // ... 读取边 ...

    for (int i = 0; i < n; i++)
        if (color[i] == 0)
            dfs(i);

    reverse(topo_order.begin(), topo_order.end());  // ← 关键:反转完成顺序
    // topo_order 现在是合法的拓扑序
}

为什么要反转完成顺序? DFS 中,一个顶点完成(加入结果)的时间晚于其所有可达顶点完成的时间。因此 DFS 中较晚完成的顶点应排在最前面;取反转后即得拓扑序。

⚠️ Kahn vs DFS: 两者都有效。Kahn 更直观,且能自然地通过计数检测环。DFS 版对于递归实现有时更简洁。


8.2.3 DAG 上的 DP

一旦得到拓扑序,就可以高效地在 DAG 上运行 DP:按拓扑序处理顶点,根据前驱顶点的状态更新每个顶点的状态。

核心思路: 按拓扑序处理顶点 v 时,v 的所有前驱都已处理完毕。因此可以用 dp[前驱] 来计算 dp[v]

DAG 中的最长路径

📄 查看代码:DAG 中的最长路径
// 到达每个顶点的最长路径
vector<int> dp(n, 0);  // dp[v] = 到达 v 的最长路径

// 按拓扑序处理
for (int u : topo_order) {
    for (int v : adj[u]) {
        dp[v] = max(dp[v], dp[u] + edge_weight[u][v]);
        //           ↑ 当前最优    ↑ 通过 u→v 延伸路径
    }
}

int ans = *max_element(dp.begin(), dp.end());

从源点到各顶点的路径计数

vector<long long> cnt(n, 0);
cnt[source] = 1;  // 到达源点的方案数为 1

for (int u : topo_order) {
    for (int v : adj[u]) {
        cnt[v] += cnt[u];  // 加上到达 u 的所有路径,经 u→v 延伸
        cnt[v] %= MOD;     // 若需要取模
    }
}
// cnt[t] = 从 source 到 t 的路径数

USACO 风格示例:关键路径(最早完成时间)

任务 1..N,各有执行时长。任务 v 在所有前置任务完成后才能开始。求每个任务最早的开始时间。

📄 C++ 完整代码
// earliest_start[v] = max(earliest_start[u] + duration[u]) 对所有前驱 u 取最大
vector<int> earliest(n, 0);

for (int u : topo_order) {
    for (int v : adj[u]) {
        earliest[v] = max(earliest[v], earliest[u] + duration[u]);
    }
}

// 项目总完成时间 = max(earliest[v] + duration[v]) 对所有 v
int finish_time = 0;
for (int v = 0; v < n; v++)
    finish_time = max(finish_time, earliest[v] + duration[v]);

8.2.4 DAG DP 在 USACO Gold 中的题型模式

模式 1:将状态转移视为 DAG

很多 DP 问题可以可视化为 DAG:

  • 顶点 = DP 状态
  • = 状态之间的转移
  • DAG 性质 = 转移只向"前方"进行(无环)

认识到这一点,可以将 DP 递推式转化为显式图问题。

模式 2:排序/调度

"N 个任务有先后依赖关系(任务 A 必须在任务 B 之前完成)。求调度顺序 / 最少执行阶段数 / 关键路径。"

直接应用拓扑排序 + DAG DP。

模式 3:带约束的路径计数

"在给定约束(选择 B 不能跟在选择 A 之后)下,有多少种合法的选择序列?"

将选择建模为顶点,约束建模为有向边(A → B 表示"A 后面接 B 不合法"),然后在结果 DAG 中计路径数。

模式 4:DAG 中的最短/最长路径

DAG 的最短路可以用拓扑排序 + DP 在 O(V+E) 内解决——比 Dijkstra 的 O(E log V) 更快。若图无负边且同时是 DAG,优先选这种方法。

// DAG 中从源点 s 出发的最短路(支持负权!)
vector<int> dist(n, INT_MAX);
dist[s] = 0;

for (int u : topo_order) {
    if (dist[u] == INT_MAX) continue;
    for (auto [v, w] : adj[u]) {
        dist[v] = min(dist[v], dist[u] + w);
    }
}

8.2.5 强连通分量(SCC)

有向图的**强连通分量(SCC)**是顶点的极大子集,子集内任意两个顶点互相可达。

示例:
  0 → 1 → 2
  ↑   ↓   ↓
  └── 3   4

SCC:{0, 1, 3}、{2}、{4}
- 0→1→3→0:互相可达 → 同一个 SCC
- 2 可从 SCC{0,1,3} 到达,但无法返回 → 独立的 SCC
- 4 同理孤立

SCC 在 USACO Gold 中的意义:

  • 缩点 DAG: 将每个 SCC 收缩为单个节点后,结果必然是 DAG。这使得可以在有环图上做 DAG DP。
  • 2-SAT: 每子句含 2 个字面量的布尔可满足性问题可以规约为 SCC。
  • 可达性查询: "u 能到达 v 吗?" → 判断它们是否在同一 SCC,或 SCC(u) 是否在缩点 DAG 中可达 SCC(v)。

Tarjan SCC 算法

核心思想: 一次 DFS,维护一个栈和两个数组:

  • disc[v]:v 的 DFS 发现时间
  • low[v]:从 v 的子树出发,经过至多一条"后向边"可达的最小发现时间

low[v] == disc[v] 时,v 是某个 SCC 的根——弹出栈中直到 v 为止的所有顶点。

📄 当 `low[v] == disc[v]` 时,v 是某个 SCC 的根——弹出栈中直到 v 为止的所有顶点。
#include <bits/stdc++.h>
using namespace std;

const int MAXN = 100001;
vector<int> adj[MAXN];

int disc[MAXN], low[MAXN], timer_val = 0;
bool on_stack[MAXN];
stack<int> stk;

int scc_id[MAXN];   // 每个顶点属于哪个 SCC?
int scc_count = 0;

void dfs(int u) {
    disc[u] = low[u] = ++timer_val;
    stk.push(u);
    on_stack[u] = true;

    for (int v : adj[u]) {
        if (disc[v] == 0) {          // 树边:v 未访问
            dfs(v);
            low[u] = min(low[u], low[v]);
        } else if (on_stack[v]) {    // 后向边,属于当前 SCC
            low[u] = min(low[u], disc[v]);
        }
        // 交叉边/前向边(on_stack[v]==false 且 disc[v]!=0):忽略
    }

    // 若 u 是某个 SCC 的根
    if (low[u] == disc[u]) {
        scc_count++;
        while (true) {
            int v = stk.top(); stk.pop();
            on_stack[v] = false;
            scc_id[v] = scc_count;
            if (v == u) break;
        }
    }
}

int main() {
    ios_base::sync_with_stdio(false);
    cin.tie(NULL);

    int n, m;
    cin >> n >> m;
    for (int i = 0; i < m; i++) {
        int u, v; cin >> u >> v; u--; v--;
        adj[u].push_back(v);
    }

    for (int i = 0; i < n; i++)
        if (disc[i] == 0)
            dfs(i);

    cout << "SCC 数量:" << scc_count << "\n";
    for (int i = 0; i < n; i++)
        cout << "顶点 " << i << " → SCC " << scc_id[i] << "\n";

    return 0;
}

复杂度: O(V + E)——一次 DFS。

💡 SCC 编号说明: Tarjan 算法以缩点 DAG拓扑序的逆序给 SCC 编号。编号为 1 的 SCC 是 DAG 的汇点;编号最大的 SCC 是源点。


Kosaraju SCC 算法

核心思想: 两次 DFS。

  1. 第一遍: 在原图上运行 DFS,按完成时间将顶点压入栈。
  2. 第二遍:转置图(所有边反向)上,按完成时间的逆序(弹栈顺序)处理顶点。第二遍中每棵 DFS 树恰好是一个 SCC。
📄 C++ 完整代码
#include <bits/stdc++.h>
using namespace std;

const int MAXN = 100001;
vector<int> adj[MAXN];      // 原图
vector<int> radj[MAXN];     // 转置图(边反向)
bool visited[MAXN];
int scc_id[MAXN];
stack<int> finish_order;

// 第一遍:在原图上 DFS,记录完成顺序
void dfs1(int u) {
    visited[u] = true;
    for (int v : adj[u])
        if (!visited[v])
            dfs1(v);
    finish_order.push(u);   // 完全处理后压栈
}

// 第二遍:在转置图上 DFS,标记 SCC
void dfs2(int u, int id) {
    visited[u] = true;
    scc_id[u] = id;
    for (int v : radj[u])
        if (!visited[v])
            dfs2(v, id);
}

int main() {
    ios_base::sync_with_stdio(false);
    cin.tie(NULL);

    int n, m;
    cin >> n >> m;
    for (int i = 0; i < m; i++) {
        int u, v; cin >> u >> v; u--; v--;
        adj[u].push_back(v);
        radj[v].push_back(u);   // 反向边
    }

    // 第一遍:填充 finish_order
    fill(visited, visited + n, false);
    for (int i = 0; i < n; i++)
        if (!visited[i])
            dfs1(i);

    // 第二遍:在转置图上按逆完成顺序处理
    fill(visited, visited + n, false);
    int scc_count = 0;
    while (!finish_order.empty()) {
        int u = finish_order.top(); finish_order.pop();
        if (!visited[u])
            dfs2(u, ++scc_count);
    }

    cout << "SCC 数量:" << scc_count << "\n";
    return 0;
}

复杂度: O(V + E)——两次 DFS。

Tarjan vs Kosaraju 对比

TarjanKosaraju
DFS 次数1 次2 次
空间栈 + 数组原图 + 转置图
SCC 编号顺序拓扑逆序拓扑正序
竞赛推荐更简洁,竞赛首选更易理解和调试

💡 USACO 中: Tarjan 更简洁,是竞赛编程的首选。Kosaraju 更易于理解和调试。


缩点 DAG + DP

找到 SCC 后,可以构建缩点 DAG,并在其上运行 DP。

📄 找到 SCC 后,可以构建缩点 DAG,并在其上运行 DP。
// 用 Tarjan 算法找 SCC 后,构建缩点 DAG
// scc_id[v] = 顶点 v 所在的 SCC(1-indexed,拓扑逆序)
// scc_count = SCC 总数

vector<int> scc_adj[MAXN];   // 缩点 DAG 中的边
set<pair<int,int>> seen;      // 避免重复边

for (int u = 0; u < n; u++) {
    for (int v : adj[u]) {
        if (scc_id[u] != scc_id[v]) {
            // 不同 SCC 之间的边
            auto e = make_pair(scc_id[u], scc_id[v]);
            if (!seen.count(e)) {
                seen.insert(e);
                scc_adj[scc_id[u]].push_back(scc_id[v]);
            }
        }
    }
}

// 现在 scc_adj 是 DAG——对其做 DAG DP
// 示例:缩点 DAG 按 SCC 大小加权的最长路径
vector<int> scc_size(scc_count + 1, 0);
for (int i = 0; i < n; i++) scc_size[scc_id[i]]++;

// DAG DP:dp[v] = 以 SCC v 为结尾的路径上的最大顶点数
vector<int> dp(scc_count + 1, 0);
// 按拓扑序处理(Tarjan 给出拓扑逆序,因此从 1 到 scc_count 遍历)
for (int u = 1; u <= scc_count; u++) {
    dp[u] += scc_size[u];
    for (int v : scc_adj[u]) {
        dp[v] = max(dp[v], dp[u]);
    }
}
int ans = *max_element(dp.begin() + 1, dp.end());

8.2.6 差分约束(补充内容)

差分约束系统是一组形如以下的不等式:

x_j - x_i ≤ w_{ij}

当 USACO 题目要求"给变量赋值,使所有成对差值约束满足,并找到最小/最大赋值"时,就会出现这类问题。

核心思路: 差分约束系统等价于最短路问题!

将每个约束 x_j - x_i ≤ w 转化为有向边 i → j,权重为 w

则:

  • 若约束图无负环 → 存在可行解
  • 最紧的可行解由从虚源出发的最短路给出
📄 C++ 完整代码
// 求解差分约束系统
// 约束:x[b] - x[a] <= w  →  添加边 a→b,权重 w
// 返回最小合法赋值(x[i] = 从虚源到 i 的最短距离)
// 若不可行(含负环)则返回空 vector

vector<long long> solve_difference_constraints(
        int n,                         // n 个变量 x[0..n-1]
        vector<tuple<int,int,int>>& constraints  // {a, b, w}: x[b]-x[a]<=w
) {
    // 添加虚源 s = n,添加边 s→i 权重 0(对所有 i)
    // (使 x[i] >= 0 并提供公共参考点)
    int s = n;
    vector<tuple<int,int,int>> edges = constraints;
    for (int i = 0; i < n; i++)
        edges.push_back({s, i, 0});   // x[i] - x[s] <= 0 → x[i] <= 0

    // 从源点 s 运行 Bellman-Ford
    vector<long long> dist(n + 1, 0);  // 源点距离 = 0

    for (int iter = 0; iter < n; iter++) {
        for (auto [u, v, w] : edges) {
            if (dist[u] + w < dist[v])
                dist[v] = dist[u] + w;
        }
    }

    // 检测负环
    for (auto [u, v, w] : edges) {
        if (dist[u] + w < dist[v])
            return {};  // 含负环 → 无可行解
    }

    return vector<long long>(dist.begin(), dist.begin() + n);
}

USACO 题型: "给时间/位置赋值,使 A 在 B 之后至少 D 时间发生"的问题可以直接对应差分约束。


8.2.7 2-SAT(二元可满足性问题)

2-SAT 是 Tarjan SCC 最重要的应用之一。它解决这样的问题:给 N 个布尔变量赋值(真/假),使得一组每条包含 2 个字面量的子句的合取为真。

问题形式

有 N 个布尔变量 x₁, x₂, ..., xₙ(每个可以是真或假)。给定 M 个子句,每条形如:

(xᵢ = aᵢ) OR (xⱼ = aⱼ)

其中 aᵢ, aⱼ ∈ {true, false}。

目标: 找到满足所有子句的赋值,或报告无解。

💡 USACO 伪装: "对每组选择 A 或 B。若从第 i 组选了 A,则必须从第 j 组选 B。"这就是 2-SAT!

构建蕴含图

核心转换: OR 子句 (p OR q) 等价于两条蕴含:

¬p → q      (若 p 为假,则 q 必为真)
¬q → p      (若 q 为假,则 p 必为真)

对每个变量 xᵢ,创建两个节点:2i(xᵢ = true)和 2i+1(xᵢ = false,即 ¬xᵢ)。

变量 xᵢ → 节点 2i   (xᵢ 为 TRUE)
           节点 2i+1  (xᵢ 为 FALSE,¬xᵢ)

对子句 (xᵢ = a) OR (xⱼ = b)

  • 设 p = xᵢ = a 对应的节点,¬p = xᵢ = ¬a 对应的节点
  • 设 q = xⱼ = b 对应的节点,¬q = xⱼ = ¬b 对应的节点
  • 添加边:¬p → q 以及 ¬q → p

2-SAT 实现

📄 查看代码:2-SAT 实现
#include <bits/stdc++.h>
using namespace std;

struct TwoSat {
    int n;
    vector<vector<int>> adj, radj;
    vector<int> order, comp;
    vector<bool> visited;

    TwoSat(int n) : n(n), adj(2*n), radj(2*n), comp(2*n), visited(2*n) {}

    // 添加子句:(变量 u 取值 val_u) OR (变量 v 取值 val_v)
    // val = true  → 使用节点 2*var
    // val = false → 使用节点 2*var+1
    void add_clause(int u, bool val_u, int v, bool val_v) {
        // ¬(u=val_u) → (v=val_v)
        adj[2*u + !val_u].push_back(2*v + val_v);
        radj[2*v + val_v].push_back(2*u + !val_u);
        // ¬(v=val_v) → (u=val_u)
        adj[2*v + !val_v].push_back(2*u + val_u);
        radj[2*u + val_u].push_back(2*v + !val_v);
    }

    // 强制变量 u 取值 val(添加单元子句:u=val 被强制)
    // 等价于:add_clause(u, val, u, val)
    // 即:要么 u=val 要么 u=val → u 必须为 val
    void force(int u, bool val) {
        // ¬val → val(即若 ¬val 则 val,强制 val 为真)
        adj[2*u + !val].push_back(2*u + val);
        radj[2*u + val].push_back(2*u + !val);
    }

    void dfs1(int v) {
        visited[v] = true;
        for (int u : adj[v])
            if (!visited[u]) dfs1(u);
        order.push_back(v);
    }

    void dfs2(int v, int c) {
        comp[v] = c;
        for (int u : radj[v])
            if (comp[u] == -1) dfs2(u, c);
    }

    // 若可满足返回 true,并将解填入 result[]
    bool solve(vector<bool>& result) {
        // 在蕴含图上运行 Kosaraju SCC
        fill(visited.begin(), visited.end(), false);
        for (int v = 0; v < 2*n; v++)
            if (!visited[v]) dfs1(v);

        fill(comp.begin(), comp.end(), -1);
        int c = 0;
        for (int i = (int)order.size()-1; i >= 0; i--) {
            if (comp[order[i]] == -1)
                dfs2(order[i], c++);
        }

        result.resize(n);
        for (int i = 0; i < n; i++) {
            // 若 xᵢ 和 ¬xᵢ 在同一个 SCC → 矛盾 → 无解
            if (comp[2*i] == comp[2*i+1]) return false;
            // 选择:若 SCC(xᵢ) 的编号大于 SCC(¬xᵢ),则 xᵢ = true
            // (Kosaraju 给拓扑序较后的 SCC 分配更大的编号)
            result[i] = comp[2*i] > comp[2*i+1];
        }
        return true;
    }
};

int main() {
    ios_base::sync_with_stdio(false);
    cin.tie(NULL);

    int n, m;
    cin >> n >> m;  // n 个变量,m 个子句

    TwoSat sat(n);

    for (int i = 0; i < m; i++) {
        // 读取子句:(x[u] = a) OR (x[v] = b)
        // 其中 u,v 为 0-indexed,a,b 为 0 或 1
        int u, a, v, b;
        cin >> u >> a >> v >> b;
        sat.add_clause(u, a, v, b);
    }

    vector<bool> result;
    if (sat.solve(result)) {
        for (int i = 0; i < n; i++)
            cout << "x[" << i << "] = " << result[i] << "\n";
    } else {
        cout << "不可满足\n";
    }

    return 0;
}

复杂度: O(N + M)——在蕴含图上运行 Kosaraju SCC。

为什么这个方法有效

核心洞见:若 xᵢ¬xᵢ同一个 SCC 中,则蕴含图中存在从 xᵢ¬xᵢ 的路径,以及从 ¬xᵢxᵢ 的路径。这意味着 xᵢ 强迫 ¬xᵢ,¬xᵢ 也强迫 xᵢ——产生矛盾,不存在合法赋值。

若没有变量与其否定落在同一 SCC 中,则始终可以根据拓扑序构造合法赋值。

USACO 2-SAT 题型模式

模式 1:"每组恰好选择 A 或 B"

对第 i 组:变量 xᵢ = true 表示"选 A",false 表示"选 B"
约束"若第 i 组选 A,则第 j 组必须选 B":
  add_clause(i, false, j, false)  // ¬(i=A) OR ¬(j=A)

模式 2:"A、B、C、D 中至多有一个为真"(n 变量的至多一个约束)

// 链式编码:若 xᵢ 为真,则 x_{i+1}..x_{n-1} 都为假
// 朴素方法:对每对 (i, j)(i < j)add_clause(i, false, j, false),O(n²) 条子句
// 链式技巧 O(n):引入辅助变量 yᵢ = "x₀..xᵢ 中至少有一个为真"

模式 3:"将 N 个元素分到 L 侧或 R 侧,有约束"

xᵢ = true → 元素 i 在左侧
xᵢ = false → 元素 i 在右侧
约束"i 和 j 不能同时在左侧":add_clause(i, false, j, false)
约束"若 j 在右侧则 i 必须在左侧":add_clause(i, true, j, true)

💡 思路陷阱

陷阱 1:把含环的有向图当 DAG 处理

错误判断: "这题有拓扑顺序,用 toposort + DP 就行了"
实际情况: 图中可能有环(SCC),直接 toposort 会漏掉部分节点

反例:有向图 A→B→C→A→D
错误:toposort 输出 [D](只有 3 个节点在环外)
正确:先用 Tarjan 找 SCC,把 {A,B,C} 缩成一个超级节点,再在缩点 DAG 上做 DP

识别信号: 题目说"有向图"但没说"无环" → 先检测环或求 SCC,再决定是否能用 toposort


陷阱 2:2-SAT 误认为是普通贪心

错误判断: "每个位置选 A 或 B,有约束,贪心从左到右扫一遍"
实际情况: 约束之间有传递性,贪心无法保证全局一致

反例:5 个组,约束如下(若选 A₁ 则必须选 B₂,若选 A₂ 则必须选 B₃...)
贪心:组 1 选 A₁ → 组 2 选 B₂ → 组 3 选 A₃ → 组 4 选 B₄ → 组 5 可能无解
2-SAT:建立完整蕴含图,SCC 分析一次性得出全局一致解

识别信号: 每个位置/元素有两种选择 + 成对约束("如果...则...")→ 考虑 2-SAT


⚠️ 常见错误

  1. 混淆有向图与无向图的环: 拓扑排序只适用于有向图。无向图中,任意连通分量都有生成树——不需要"环检测"。

  2. DP 初始化差一: 对"从源点出发的路径计数",初始化 cnt[source] = 1,而非 0。对"最长路径",若计边数则初始化 dp[v] = 0;若路径可能不存在,需正确处理 -∞。

  3. 忽略不可达顶点: DAG 最短路中若 dist[u] == INT_MAX,跳过该顶点——从不可达顶点出发会得到错误值。

  4. 大图 DFS 拓扑排序栈溢出: N = 10⁵ 且有深链时,递归 DFS 可能栈溢出。对大输入优先使用 Kahn 算法(BFS)。

  5. 在有环图上使用拓扑排序: Kahn 会静默返回部分排序。务必检查 order.size() == n


📋 章节小结

📌 核心要点

概念说明
DAG无环的有向图;建模依赖/排序关系
拓扑排序所有边从左指向右的线性序;O(V+E)
Kahn 算法BFS 基于入度;自然检测环
DFS 拓扑排序DFS 完成后加入结果;最后反转
环检测Kahn:输出数 < N 有环;DFS:灰→灰边有环
DAG DP按拓扑序处理;dp[v] 仅依赖 dp[前驱]
最长路径dp[v] = max(dp[u] + 权重),对所有前驱 u
路径计数cnt[v] = sum(cnt[u]),对所有前驱 u
SCC(Tarjan)一次 DFS;disc[]/low[]/栈;O(V+E);拓扑逆序
SCC(Kosaraju)G 和 Gᵀ 各一次 DFS;O(V+E);拓扑正序
缩点 DAG每个 SCC 收缩为一个节点;结果必然是 DAG
2-SATN 个布尔变量 + 2 字面量子句;建蕴含图 → SCC;O(N+M)
差分约束x[j]-x[i]≤w → 边 i→j;可行 = 无负环(Bellman-Ford)

❓ 常见问题

Q:每棵树都是 DAG 吗?
A:有根树(边从父节点指向子节点)是 DAG。无根树没有方向,不适用此问题。若将树根化,则是 DAG。

Q:拓扑排序可以有多个合法序吗?
A:可以。若两个顶点之间没有依赖关系,顺序可互换。只有当 DAG 是一条简单路径(链)时,拓扑序才唯一。

Q:Dijkstra 与 DAG 最短路——分别在什么时候用?
A:若图是 DAG,用拓扑排序 + DP:O(V+E),支持负权,更简单。若图有环但无负边,用 Dijkstra:O(E log V)。若有环且有负边,用 Bellman-Ford:O(VE)。

Q:如何求"完成所有任务的最少轮次/阶段数"?
A:这就是 DAG 中的"最长路径"(关键路径)。最少阶段数 = 1 + 最长路径的长度。

🔗 与后续章节的联系

  • 第 8.3 章(树形 DP): 树形 DP 是在特殊 DAG(有根树)上的 DP,本章技术直接适用。
  • 第 6.3 章(进阶 DP): 状压 DP 的状态通常构成 DAG(转移只从子集到超集)。
  • 第 5.2 章(BFS/DFS): 本章的两种算法都是对第 5.2 章 BFS/DFS 的扩展。

🏋️ 练习题

🟢 简单

8.2-E1. 课程表 (等价于 LeetCode 207)
N 门课程,M 个先修要求。给定先修对 (a, b),表示"必须先修 b 才能修 a",判断能否完成所有课程(即是否无环)。

提示

运行 Kahn 算法。若输出包含所有 N 门课程,则无环 → 可以完成。否则有环 → 无法完成。


8.2-E2. DAG 中的最长路径
给定 N 个顶点、M 条有权有向边和源点 S,求从 S 出发的最长路径。

提示

拓扑排序,然后按拓扑序处理顶点。初始化 dp[S] = 0dp[其他] = -∞。对每条边 u→v:dp[v] = max(dp[v], dp[u] + w)


🟡 中等

8.2-M1. 网格路径计数 (网格 DP 作为 DAG)
在 N×M 的网格中,每步只能向右或向下走。某些格子被封锁。统计从 (1,1) 到 (N,M) 的路径总数。

提示

网格是 DAG(移动只向右/向下)。按行主序处理格子(已是拓扑序)。cnt[i][j] = cnt[i-1][j] + cnt[i][j-1],若 (i,j) 未被封锁。


8.2-M2. 带依赖的任务调度 (USACO 风格)
N 个任务各有执行时长,M 条依赖边。每个任务只有在其所有前置任务完成后才能开始。所有可并行的任务同时进行。求完成所有任务的最短总时间。

提示

这是关键路径法(CPM)。运行 Kahn 拓扑排序。对拓扑序中的每个顶点,计算 earliest_start[v] = max(earliest_start[u] + duration[u]) 对所有前驱 u 取最大。答案 = max(earliest_start[v] + duration[v])。


🔴 困难

8.2-H1. 路径计数取模 (USACO Gold 2012 — Cow Rectangles)
给定有 N 个顶点(至多 10⁵)和 M 条边的 DAG,源点 S 到目标 T。统计 S 到 T 的路径总数对 10⁹+7 取模的结果。某些顶点被"标记";只统计经过至少一个标记顶点的路径。

提示

用容斥原理:(经过 ≥1 个标记顶点的路径数)=(所有路径数)-(不经过任何标记顶点的路径数)。对"不经过任何标记顶点"的情形,将标记顶点从图中移除后重新计数。


🏆 挑战

8.2-C1. 缩点 DAG + DP (困难)
给定可能含环的有向图。在缩点 DAG(每个 SCC 收缩为单个顶点)中,求路径上经过的最大顶点数。

提示

运行 Tarjan 或 Kosaraju 求 SCC 及其大小。构建缩点 DAG。在缩点 DAG 上做最长路径 DP,每个顶点的"权值"为该 SCC 的大小。答案为 max dp[v]

📖 第 8.3 章 ⏱️ 约 60 分钟 🎯 Gold / 困难

第 8.3 章:树形 DP 与换根

📝 前置要求: 本章需要第 5.4 章(二叉树与树算法:遍历、LCA)、第 5.5 章(并查集)、第 6.1~6.2 章(DP 基础)及第 8.2 章(DAG DP)。阅读树形 DP 前,必须理解 DFS 后序遍历。

树形 DP 在有根树上运行动态规划,以每棵子树作为子问题。这是 USACO Gold 中最重要的技术之一,几乎出现在所有 Gold/Platinum 树题中。

换根技术(Rerooting)将树形 DP 扩展到能在 O(N) 时间内处理以每个节点为根的查询——无需对每个根单独运行一次 DFS。

学习目标:

  • 编写基于子树的树形 DP 模板
  • 计算树的直径、最长路径、子树和
  • 运用换根技术,以 O(N) 时间回答"以某节点为根时的结果"
  • 识别 USACO Gold 树题中的树形 DP 模式

8.3.0 为什么树适合 DP?

有根树本质上是一个 DAG(边从根指向子节点)。这意味着:

  • 无环: DP 的转移方向始终是从父到子(无回溯)
  • 自然子问题: 以 v 为根的子树构成一个完整、独立的子问题
  • 简洁递推: dp[v] 只依赖 dp[v 的子节点]

核心模式: 后序 DFS——先处理子节点,再处理父节点。

树:          DFS 后序:        DP 顺序:
    1             4, 5, 2       先计算 dp[4]、dp[5],
   / \            6, 3          再用它们计算 dp[2]
  2   3           2, 3
 / \   \           1             dp[v] 在所有子节点后才计算
4   5   6                        → 父节点可以直接使用子节点的 dp 值

8.3.1 树形 DP 模板

标准树形 DP 模板:带父节点参数的 DFS,避免回溯。

📄 标准树形 DP 模板:带父节点参数的 DFS,避免回溯。
#include <bits/stdc++.h>
using namespace std;

const int MAXN = 100001;
vector<int> adj[MAXN];
int dp[MAXN];  // 根据问题定义 dp 数组

void dfs(int u, int parent) {
    // 初始化 dp[u](基础情况:叶节点)
    dp[u] = /* 初始值 */ 0;

    for (int v : adj[u]) {
        if (v == parent) continue;  // 不回溯到父节点

        dfs(v, u);  // ← 先对子节点递归(后序)

        // 现在 dp[v] 已计算好 → 用它更新 dp[u]
        dp[u] = /* 合并 dp[u] 和 dp[v] */;
    }
}

int main() {
    int n;
    cin >> n;
    for (int i = 0; i < n - 1; i++) {
        int u, v;
        cin >> u >> v;
        u--; v--;
        adj[u].push_back(v);
        adj[v].push_back(u);
    }

    dfs(0, -1);  // 以顶点 0 为根,无父节点

    cout << /* 用 dp 值计算答案 */ "\n";
    return 0;
}

8.3.2 经典树形 DP 问题

问题 1:子树大小

sz[v] = 以 v 为根的子树中节点数。

int sz[MAXN];

void dfs(int u, int par) {
    sz[u] = 1;  // 计入 u 自身
    for (int v : adj[u]) {
        if (v == par) continue;
        dfs(v, u);
        sz[u] += sz[v];  // 加上子节点的子树大小
    }
}

问题 2:子树最大深度

depth[v] = 从 v 到其子树中任意叶节点的最大距离。

int depth[MAXN];

void dfs(int u, int par) {
    depth[u] = 0;
    for (int v : adj[u]) {
        if (v == par) continue;
        dfs(v, u);
        depth[u] = max(depth[u], depth[v] + 1);  // 经过 v 延伸路径
    }
}

问题 3:树的直径

树的直径是任意两节点之间的最长路径。核心思路:最长路径要么经过根节点,要么完全在某棵子树内。

方法一:两次 DFS(最简洁)

📄 C++ 完整代码
int farthest_node, max_dist;

void dfs_farthest(int u, int par, int dist) {
    if (dist > max_dist) {
        max_dist = dist;
        farthest_node = u;
    }
    for (int v : adj[u]) {
        if (v != par)
            dfs_farthest(v, u, dist + 1);
    }
}

int treeDiameter(int n) {
    // 第 1 步:找直径的一个端点(离节点 0 最远的节点)
    max_dist = 0;
    dfs_farthest(0, -1, 0);
    int endpoint1 = farthest_node;

    // 第 2 步:找另一个端点(离 endpoint1 最远的节点)
    max_dist = 0;
    dfs_farthest(endpoint1, -1, 0);

    return max_dist;  // 直径长度
}

方法二:DP(更通用)

📄 C++ 完整代码
int diameter = 0;
int max_down[MAXN];  // max_down[v] = 从 v 向下延伸的最长路径

void dfs(int u, int par) {
    max_down[u] = 0;
    vector<int> child_depths;

    for (int v : adj[u]) {
        if (v == par) continue;
        dfs(v, u);
        child_depths.push_back(max_down[v] + 1);
    }

    // 经过 u 的直径 = 最长的两条向下路径之和
    sort(child_depths.rbegin(), child_depths.rend());
    if (child_depths.size() >= 1) max_down[u] = child_depths[0];
    if (child_depths.size() >= 2) {
        diameter = max(diameter, child_depths[0] + child_depths[1]);
    }
    diameter = max(diameter, max_down[u]);
}

问题 4:树上最大独立集

选出尽可能多的节点,使得任意两个被选节点均不相邻(不通过一条边相连)。

📄 选出尽可能多的节点,使得任意两个被选节点均不相邻(不通过一条边相连)。
int dp[MAXN][2];
// dp[v][0] = v 未被选中时,v 子树中的最大节点数
// dp[v][1] = v 被选中时,v 子树中的最大节点数

void dfs(int u, int par) {
    dp[u][0] = 0;
    dp[u][1] = 1;  // v 自身被选中

    for (int v : adj[u]) {
        if (v == par) continue;
        dfs(v, u);

        dp[u][0] += max(dp[v][0], dp[v][1]);  // v 未选:子节点可选可不选
        dp[u][1] += dp[v][0];                  // v 已选:子节点必须不选
    }
}

// 答案 = max(dp[root][0], dp[root][1])

8.3.3 换根技术

问题: 对树中每个顶点 v,计算以 v 为根时的某个值。朴素做法需要 N 次 DFS → O(N²)。换根技术只需两次 DFS:O(N)

核心思路:

  • DFS 1(向下传): 计算 down[v] = 以原始根为根时,v 子树的答案
  • DFS 2(向上传): 计算 up[v] = 树中"v 子树以外的部分"对应的答案
  • v 为根的最终答案: 合并 down[v]up[v]

换根模板:距离之和

问题: 对每个顶点 v,求 v 到其他所有顶点的距离之和。输出 N 个值。

这是经典 Gold 题。核心递推关系:

若已知 dist_sum[root](根到所有顶点的距离之和),则对根的子节点 c:

dist_sum[c] = dist_sum[root] - sz[c] + (n - sz[c])
            = dist_sum[root] + (n - 2 * sz[c])

原因: 从根移动到 c 时,c 子树中的 sz[c] 个节点各近了 1,子树外的 (n - sz[c]) 个节点各远了 1。

📄 C++ 完整代码
#include <bits/stdc++.h>
using namespace std;

const int MAXN = 100001;
vector<int> adj[MAXN];
long long sz[MAXN];      // 子树大小
long long down[MAXN];    // v 到其子树中所有节点的距离之和
long long ans[MAXN];     // 最终答案:v 到所有节点的距离之和

int n;

// DFS 1:计算 sz[] 和 down[](向下传递距离和)
void dfs1(int u, int par) {
    sz[u] = 1;
    down[u] = 0;
    for (int v : adj[u]) {
        if (v == par) continue;
        dfs1(v, u);
        sz[u] += sz[v];
        down[u] += down[v] + sz[v];  // v 子树中的所有节点多走了一步
    }
}

// DFS 2:向下传播答案(换根)
void dfs2(int u, int par) {
    for (int v : adj[u]) {
        if (v == par) continue;
        // ans[v] = ans[u] - sz[v] + (n - sz[v])
        //        = ans[u] + (n - 2 * sz[v])
        ans[v] = ans[u] + (n - 2 * sz[v]);
        dfs2(v, u);
    }
}

int main() {
    ios_base::sync_with_stdio(false);
    cin.tie(NULL);

    cin >> n;
    for (int i = 0; i < n - 1; i++) {
        int u, v;
        cin >> u >> v;
        u--; v--;
        adj[u].push_back(v);
        adj[v].push_back(u);
    }

    dfs1(0, -1);
    ans[0] = down[0];  // 从根 0 出发到其他所有节点的距离之和
    dfs2(0, -1);

    for (int i = 0; i < n; i++)
        cout << ans[i] << "\n";

    return 0;
}

复杂度: O(N)——两次 DFS,各 O(N)。

换根的直觉理解

🤔 为什么有效?

可以理解为将"视角"从父节点转移到子节点。当我们把根从 u 移动到其子节点 v 时:

  • v 子树中的节点:每个近了 1 → 减去 sz[v]
  • 不在 v 子树中的节点:每个远了 1 → 加上 (n - sz[v])
  • 净变化:+(n - sz[v]) - sz[v] = +(n - 2·sz[v])

8.3.4 换根的一般模式

换根技术可以推广到许多问题。关键是找到:

  1. 原始根定下来后,down[v] 表示什么?
  2. 将根从父节点 u "换"到子节点 v 时,答案如何变化?
  3. ans[u] 计算 ans[v] 的公式是什么?
一般结构:

DFS 1(后序):
    down[v] = combine(down[child_1], down[child_2], ..., sz[v])

DFS 2(前序):
    ans[v] = combine(down[v], up[v])
    对 v 的每个子节点 c:
        up[c] = f(ans[v], down[c], sz[c], n)
        // "up[c]" 是 c 子树之外所有内容的贡献

8.3.4b 换根示例 2:每个节点的最大距离

问题: 对每个顶点 v,求 v 到任意其他顶点的最大距离(v 的"离心率")。输出 N 个值。

这比距离之和更难,因为 max 操作不像 sum 那样可以干净地分解。

核心思路: 对每个顶点 v,最远的顶点要么:

  1. 在 v 的子树中(由 DFS 1 中的 down[] 计算)
  2. 经过 v 的父节点可达(由 DFS 2 中向下传播的 up[] 计算)
📄 2. 经过 v 的父节点可达(由 DFS 2 中向下传播的 `up[]` 计算)
#include <bits/stdc++.h>
using namespace std;

const int MAXN = 100001;
vector<int> adj[MAXN];
int n;

int down[MAXN];   // down[v] = 向下进入 v 子树的最大深度
int up[MAXN];     // up[v]   = 经 v 的父节点向上的最大深度
int ans[MAXN];    // ans[v]  = v 的离心率(到任意节点的最大距离)

// DFS 1:计算 down[](v 子树中的最大深度)
void dfs1(int u, int par) {
    down[u] = 0;
    for (int v : adj[u]) {
        if (v == par) continue;
        dfs1(v, u);
        down[u] = max(down[u], down[v] + 1);
    }
}

// DFS 2:向下传播 up[](往"上方"走的最远距离是多少?)
// up[v] = 从 v 经过父节点能到达的最大距离
void dfs2(int u, int par) {
    ans[u] = max(down[u], up[u]);

    // 计算 up[子节点] 时,需要 u 的第一深和第二深子树
    // (若子节点恰好是最深路径上的节点,则用第二深;否则用最深)
    int best1 = -1, best2 = -1;
    int best1_child = -1;

    for (int v : adj[u]) {
        if (v == par) continue;
        int d = down[v] + 1;
        if (d > best1) { best2 = best1; best1 = d; best1_child = v; }
        else if (d > best2) { best2 = d; }
    }

    for (int v : adj[u]) {
        if (v == par) continue;
        // up[v] = 从 u 向上走或进入兄弟子树的最大距离
        int sibling_best = (v == best1_child) ? best2 : best1;
        int through_parent = up[u] + 1;  // 经 u 的父节点向上,再向下

        up[v] = max(through_parent, (sibling_best >= 0 ? sibling_best + 1 : 0));

        dfs2(v, u);
    }
}

int main() {
    ios_base::sync_with_stdio(false);
    cin.tie(NULL);

    cin >> n;
    for (int i = 0; i < n - 1; i++) {
        int u, v; cin >> u >> v; u--; v--;
        adj[u].push_back(v);
        adj[v].push_back(u);
    }

    dfs1(0, -1);
    up[0] = 0;   // 根节点没有父节点
    dfs2(0, -1);

    for (int i = 0; i < n; i++)
        cout << ans[i] << "\n";

    return 0;
}

💡 关键技巧: 分别追踪最深的两棵子树。如果我们要计算 up[] 的子节点恰好是最深的子树,就用第二深的作为兄弟贡献。


8.3.4c 树背包(子树选择 DP)

问题: 给定有根树,每个顶点 v 有重量 w[v] 和价值 b[v]。选取顶点使总重量 ≤ W,约束条件:若选取 v,则必须同时选取其父节点(从根出发的连通子集)。最大化总价值。

这是经典的树背包(群组背包在树上的变体)。

朴素 O(N²W) 方法

📄 查看代码:朴素 O(N²W) 方法
// dp[v][j] = 从 v 的子树中恰好选取 j 重量(v 包含其中)时的最大价值
// 基础:dp[v][w[v]] = b[v](只选 v,子树中无其他节点)

const int MAXN = 501, MAXW = 501;
int dp[MAXN][MAXW];
int sz[MAXN];    // 子树 DP 中已使用的"容量"
int w[MAXN], b[MAXN];

void dfs(int u, int par) {
    fill(dp[u], dp[u] + MAXW, -1);  // -1 = 不可行
    dp[u][w[u]] = b[u];
    sz[u] = w[u];

    for (int v : adj[u]) {
        if (v == par) continue;
        dfs(v, u);

        // 通过背包卷积将 dp[v] 合并到 dp[u]
        // 逆序处理,避免重复计数
        for (int j = min(sz[u] + sz[v], W); j >= w[u]; j--) {
            for (int k = w[v]; k <= min(j - w[u], sz[v]); k++) {
                if (dp[u][j - k] != -1 && dp[v][k] != -1) {
                    dp[u][j] = max(dp[u][j], dp[u][j - k] + dp[v][k]);
                }
            }
        }
        sz[u] = min(sz[u] + sz[v], W);
    }
}

// 答案 = max(dp[root][j]) 对 j = 0..W

为什么实际上是 O(NW)——合并分析

关键思路: 总工作量受到限制——每对来自不同子树的顶点 (u, v) 只在它们的 LCA 合并时被比较一次。由于每对比较代价为 O(1),且每对最多被计一次,总工作量为 min(O(N²), O(NW))。

实用说明: 对于 USACO,N ≤ 300、W ≤ 300 是典型约束,O(N²W) 或 O(NW) 都很容易通过。

"从根出发的连通子集"背包模板

📄 查看代码:"从根出发的连通子集"背包模板
#include <bits/stdc++.h>
using namespace std;

const int MAXN = 305;
vector<int> adj[MAXN];
int w[MAXN], b[MAXN];
int dp[MAXN][MAXN];   // dp[v][j] = 在 v 的子树中选 j 重量(包含 v)的最大价值
int subtree_w[MAXN];  // 子树总重量

int n, W;

void dfs(int u, int par) {
    fill(dp[u], dp[u] + W + 1, 0);
    dp[u][w[u]] = b[u];
    subtree_w[u] = w[u];

    for (int v : adj[u]) {
        if (v == par) continue;
        dfs(v, u);

        // 将 dp[v] 合并到 dp[u]
        int cap = min(subtree_w[u] + subtree_w[v], W);
        for (int j = cap; j >= w[u]; j--) {
            for (int k = w[v]; k <= min(j - w[u], subtree_w[v]); k++) {
                if (dp[v][k] > 0)
                    dp[u][j] = max(dp[u][j], dp[u][j - k] + dp[v][k]);
            }
        }
        subtree_w[u] = min(subtree_w[u] + subtree_w[v], W);
    }
}

int main() {
    ios_base::sync_with_stdio(false);
    cin.tie(NULL);

    cin >> n >> W;
    for (int i = 0; i < n; i++) cin >> w[i] >> b[i];
    for (int i = 0; i < n - 1; i++) {
        int u, v; cin >> u >> v; u--; v--;
        adj[u].push_back(v);
        adj[v].push_back(u);
    }

    dfs(0, -1);

    int ans = 0;
    for (int j = 0; j <= W; j++)
        ans = max(ans, dp[0][j]);

    cout << ans << "\n";
    return 0;
}

8.3.5 USACO Gold 树形 DP 题型模式

模式 1:选取子树节点

"选择部分顶点激活。激活顶点 v 的代价为 c[v],收益为 b[v]。约束:只有父节点激活后才能激活 v。最大化净收益。"

这是"依赖背包"树形 DP:

// dp[v][j] = 从 v 的子树中选取 j 个顶点的最大收益
// 转移:通过背包合并 dp[v] 与 dp[子节点]

模式 2:树上路径查询

"对每个顶点 v,求最远顶点,或距离之和。"

换根模板——正如 8.3.3 节所示。

模式 3:树上匹配/配对

"在树上配对顶点(每对由一条路径连接)。最大化不相交的配对数。"

dp[v][0/1] = v 未被匹配(0)或已被匹配(1)时,v 子树中的最大配对数。


💡 思路陷阱

陷阱 1:换根题用 O(N²) 暴力 DFS

错误判断: "对每个节点做一次 DFS 计算它当根时的答案,O(N²) 应该够"
实际情况: N=10⁵ 时 O(N²)=10¹⁰,稳定 TLE;此类题几乎都是换根 DP 的典型形态

题目特征(三选一即触发):
  1. "对每个节点 v,求以 v 为根时..."
  2. "输出 N 个值,每个值对应某节点当根的结果"
  3. "求使某个全局指标最小/最大的根节点"

正确:两次 DFS(dfs1 下行 + dfs2 上行)→ O(N)

识别信号: 输出 N 个值且每个值依赖"以该节点为根的某属性" → 换根 DP,而非 N 次 DFS


陷阱 2:树形 DP 中忘记 if (v == par) continue

错误判断: "树是无向图,从 u 出发访问邻居,DFS 会自动不走回头路"
实际情况: 无向图的邻接表中父节点也在邻居列表里,不加 par 检查会死循环/重复计数

📄 C++ 完整代码
// 错误:没有 parent 检查
void dfs(int u) {
    for (int v : adj[u]) {
        dfs(v);              // 若 u 的父节点是 v,这里会死循环!
        dp[u] += dp[v];
    }
}

// 正确:传入 parent 参数
void dfs(int u, int par) {
    for (int v : adj[u]) {
        if (v == par) continue;   // ← 这一行至关重要
        dfs(v, u);
        dp[u] += dp[v];
    }
}

识别信号: 树的 DFS 运行时栈溢出或结果明显偏大 → 先检查有无 parent 防回溯


陷阱 3:用单次 DFS 求"树直径",结果错误

错误判断: "直径就是最深叶子到根的距离,单次 DFS 记录最大深度即可"
实际情况: 直径的两端点不一定经过根,单次 DFS 只算了"经过根的最长路径"

📄 Code 完整代码
树:  1
     / \
    2   3
   /     \
  4       5
  |       |
  6       7

从根 1 出发最大深度 = 3(到 6 或 7),但直径 = 6→4→2→1→3→5→7 = 6 条边
单次 DFS from root 得到 3(错),正确做法:
  方法1:两次 DFS/BFS(从任意点找最远点 A,再从 A 找最远点 B)
  方法2:树 DP,每个节点维护最深和次深子树,直径 = 全局最大的 (最深+次深)

识别信号: "树上最长路径"题 → 直径,要么两次 BFS/DFS,要么树 DP 维护双最深


⚠️ 常见错误

  1. 缺少 if (v == par) continue 没有这个检查,DFS 会沿着边回到父节点,导致无限递归。每棵树的 DFS 都必须有这个保护。

  2. 叶节点基础情况初始化错误: 叶节点没有子节点,循环体不会执行。确保循环之前 dp[leaf] 已正确初始化。

  3. 换根的 dfs2 忘了用前序: dfs2 必须从父节点向子节点传播(自顶向下),所以 ans[u] 必须在 ans[u 的子节点] 之前计算。不要不小心用成后序。

  4. 子树和的整数溢出: 若 N = 10⁵ 且每个顶点贡献最多 N,总和可达 10¹⁰。使用 long long

  5. "距离之和"的差一错误: 公式 down[u] += down[v] + sz[v] 为 v 子树中的每个节点加了 sz[v](它们各多走了一步)。理解为什么是 sz[v] 而非 sz[v]-1


📋 章节小结

📌 核心要点

概念说明
树形 DP后序 DFS;dp[v] 在所有子节点之后计算;O(N)
子树大小sz[v] = 1 + sum(sz[子节点])
树的直径最长路径;两次 DFS 或追踪最深和次深子节点的 DP
最大独立集dp[v][0/1] 分别对应未选/已选;经典树形 DP
换根两次 DFS:先向下计算,再向上传播;O(N) 处理所有根
距离之和经典换根:ans[子节点] = ans[父节点] + n - 2*sz[子节点]

❓ 常见问题

Q:如何判断问题是否需要换根?
A:若问题对每个顶点(以该顶点为根时)请求相同的计算——"对每个顶点,求以它为根时的 X"——那就需要换根。若只对一个固定根请求,标准树形 DP 就够了。

Q:如果边权不为 1 怎么办?
A:调整公式即可。对于带权边,若边 (u,v) 的权重为 w,则 down[u] += down[v] + sz[v] * w[u][v]。换根公式相应调整。

Q:可以用迭代 DFS 替代递归吗?
A:可以,对于大 N 更应该这样做(避免栈溢出)。用显式栈将 DFS 改为迭代,按与入栈相反的顺序处理顶点(后序)。

🔗 与后续章节的联系

  • 第 8.4 章(欧拉游览): 当树形 DP 本身对树上区间查询的效率不够时,欧拉游览 + BIT/线段树是首选方案。
  • 第 8.1 章(MST): 从一般图构建 MST 后,MST 就是一棵树,可以对其做树形 DP。
  • 第 6.3 章(进阶 DP): 树形 DP 是 DAG 上 DP(第 8.2 章)的特例,技术可以互相推广。

🏋️ 练习题

🟢 简单

8.3-E1. 子树查询
给定有根树,对每个顶点 v,输出其子树中的节点数。

提示

标准 sz[] 计算。sz[v] = 1 + sum(sz[子节点])


8.3-E2. 树的直径
求 N 个顶点、单位边权的树的直径(最长路径)。

提示

两次 BFS/DFS:从任意顶点 BFS 找最远顶点 A;再从 A BFS 找最远顶点 B。A 到 B 的距离即为直径。


🟡 中等

8.3-M1. 最大独立集 (USACO 风格)
给定 N 个顶点的树,每个顶点有一个值。选出总价值最大的顶点子集,使得任意两个被选顶点之间没有边相连。

提示

经典 dp[v][0/1]dp[v][1] = val[v] + sum(dp[子节点][0])dp[v][0] = sum(max(dp[子节点][0], dp[子节点][1]))


8.3-M2. 距离之和 (LeetCode 834 / USACO Gold 风格)
对树中每个顶点,求它到所有其他顶点的距离之和。输出 N 个值。

提示

使用 8.3.3 节的换根模板。两次 DFS,总复杂度 O(N)。


🔴 困难

8.3-H1. 牛聚集 USACO 2019 February Gold
N 头奶牛在一棵树上。每头牛有"幸福值"。当牛的父节点离开时,该牛变得更幸福。模拟移除操作,最大化总幸福值。(简化版:找到移除奶牛的顺序,使每次移除时的累积幸福值之和最大。)

提示

建模为树形 DP:对每棵子树计算按最优顺序移除顶点的"收益"。通过换根来回答所有可能起始顶点的情况。


🏆 挑战

8.3-C1. 树背包 (困难)
给定 N 个顶点的有根树,每个顶点 v 有重量 w[v] 和价值 b[v]。选取顶点子集 S,总重量 ≤ W,且若 v ∈ S 则 parent(v) ∈ S(从根出发的连通子集)。最大化总价值。

提示

dp[v][j] = 从 v 的子树(包含 v)中恰好选取 j 重量的最大价值。通过卷积风格的背包合并子节点。用仔细的合并策略可以达到 O(N·W)——关键思路是每对顶点只被比较一次,因此总复杂度通过 DFS 顺序达到 O(N·W)。

📖 第 8.4 章 ⏱️ 约 65 分钟 🎯 Gold / 困难

第 8.4 章:欧拉游览与树的展开

📝 前置要求: 本章需要第 5.4 章(二叉树与树算法:遍历、欧拉序)、第 5.6~5.7 章(线段树和树状数组)以及第 8.3 章(树形 DP 基础)。欧拉游览将树的问题转化为数组问题——必须先熟练掌握区间查询数据结构。

欧拉游览(又称 DFS 序或重链剖分线性化)是将树展开为线性数组的技术。展开后,子树查询变为数组上的区间查询——用树状数组或线段树可在 O(log N) 内求解。

本章还介绍用于 LCA(最近公共祖先)的倍增,它能在 O(log N) 时间内解决树上路径查询。

学习目标:

  • 实现带进/出时间戳的 DFS 序欧拉游览
  • 用游览结果将子树查询转化为数组区间查询
  • 实现倍增 LCA:O(N log N) 预处理 + 每次查询 O(log N)
  • 结合欧拉游览 + LCA 高效回答路径查询

8.4.0 动机:为什么要展开树?

假设有一棵树,需要:

  • 更新 v 子树中所有顶点的值
  • 查询 v 子树中所有顶点的值之和

仅用 DFS,最坏情况下每次操作需 O(N)。但若将 v 的子树转化为连续的数组区间 [in[v], out[v]],就可以用 BIT 或线段树,每次操作 O(log N)。

欧拉游览恰好提供了这种能力: 子树与连续区间之间的一一映射。


8.4.1 DFS 进/出时间戳(欧拉游览)

为每个顶点分配两个时间戳:

  • in[v]:DFS 第一次访问 v 时的时间("进入时刻")
  • out[v]:DFS 完成 v 的子树时的时间("退出时刻")

关键性质: 顶点 u 在顶点 v 的子树中,当且仅当 in[v] ≤ in[u] ≤ out[v]

📄 C++ 完整代码
#include <bits/stdc++.h>
using namespace std;

const int MAXN = 100001;
vector<int> adj[MAXN];
int in_time[MAXN], out_time[MAXN];
int order[MAXN];       // order[i] = 第 i 个被访问的顶点
int timer_val = 0;

void dfs(int u, int par) {
    in_time[u] = ++timer_val;   // 记录进入时刻
    order[timer_val] = u;        // 记录该位置对应的顶点

    for (int v : adj[u]) {
        if (v != par)
            dfs(v, u);
    }

    out_time[u] = timer_val;    // 记录退出时刻(等于或晚于 in_time)
}

示例:

📄 Code 完整代码
树(以 1 为根):         DFS 顺序:          进/出时刻:
       1                 访问 1             in[1]=1, out[1]=7
      /|\                访问 2             in[2]=2, out[2]=4
     2  5  7             访问 4             in[4]=3, out[4]=3
    /|   \               返回 2             
   4  3   6              访问 3             in[3]=4, out[3]=4
                         返回 1             
                         访问 5             in[5]=5, out[5]=6
                         访问 6             in[6]=6, out[6]=6
                         返回 1             
                         访问 7             in[7]=7, out[7]=7

顶点 2 的子树 = {2, 4, 3} → 区间 [2, 4]  ✓ (in[2]=2, out[2]=4)
顶点 5 的子树 = {5, 6}    → 区间 [5, 6]  ✓ (in[5]=5, out[5]=6)
顶点 1 的子树 = 全部       → 区间 [1, 7]  ✓ (in[1]=1, out[1]=7)

8.4.2 用 BIT/线段树处理子树查询

得到欧拉游览结果后,可以将顶点值映射到数组位置,并用树状数组处理区间查询。

📄 得到欧拉游览结果后,可以将顶点值映射到数组位置,并用树状数组处理区间查询。
#include <bits/stdc++.h>
using namespace std;

const int MAXN = 100001;
vector<int> adj[MAXN];
int in_time[MAXN], out_time[MAXN];
int val[MAXN];       // 顶点值
int flat[MAXN];      // flat[i] = 欧拉游览第 i 个位置对应顶点的值
int timer_val = 0;

// ── 树状数组(BIT),支持区间求和 ─────────────────
long long bit[MAXN];
int n;

void bit_update(int i, long long delta) {
    for (; i <= n; i += i & (-i))
        bit[i] += delta;
}

long long bit_query(int i) {
    long long s = 0;
    for (; i > 0; i -= i & (-i))
        s += bit[i];
    return s;
}

long long bit_range(int l, int r) {
    return bit_query(r) - bit_query(l - 1);
}

// ── 欧拉游览 DFS ────────────────────────────────────
void dfs(int u, int par) {
    in_time[u] = ++timer_val;
    flat[timer_val] = val[u];        // 将顶点值放到游览对应位置

    for (int v : adj[u]) {
        if (v != par) dfs(v, u);
    }

    out_time[u] = timer_val;
}

int main() {
    ios_base::sync_with_stdio(false);
    cin.tie(NULL);

    cin >> n;
    for (int i = 1; i <= n; i++) cin >> val[i];

    for (int i = 0; i < n - 1; i++) {
        int u, v;
        cin >> u >> v;
        adj[u].push_back(v);
        adj[v].push_back(u);
    }

    dfs(1, 0);

    // 用 flat 数组初始化 BIT
    for (int i = 1; i <= n; i++)
        bit_update(i, flat[i]);

    // 查询示例:
    // 顶点 v 的子树和:
    int v;
    cin >> v;
    cout << bit_range(in_time[v], out_time[v]) << "\n";

    // 将顶点 u 的值增加 delta:
    int u; long long delta;
    cin >> u >> delta;
    bit_update(in_time[u], delta);

    return 0;
}

复杂度:

  • 预处理:O(N) DFS + O(N log N) BIT 初始化
  • 子树查询:O(log N)
  • 单点更新(顶点 v):O(log N)——在位置 in_time[v] 更新
  • 子树更新(给 v 子树中所有顶点加 delta):用差分 BIT,在 in_time[v]out_time[v]+1 处更新

8.4.3 最近公共祖先(LCA)

两个顶点 u 和 v 的 LCA 是同时为它们祖先的最深顶点。

树:           LCA(4, 6) = 2
    1          LCA(4, 7) = 1
   / \         LCA(5, 3) = 2
  2   7
 / \
3   4
   /
  5
  |
  6

LCA 有许多应用:

  • u 和 v 之间的距离: dist(u, v) = depth[u] + depth[v] - 2·depth[LCA(u,v)]
  • 路径查询: u 到 v 路径上的查询可以用 LCA + 欧拉游览来回答

倍增 LCA

预处理: 对每个顶点 v,预计算 up[v][k] = v 的第 2^k 个祖先。

up[v][0] = parent(v)         (直接父节点)
up[v][1] = parent(parent(v)) (祖父节点)
up[v][2] = 往上 2 步
...
up[v][k] = up[up[v][k-1]][k-1]
📄 C++ 完整代码
#include <bits/stdc++.h>
using namespace std;

const int MAXN = 100001;
const int LOG = 17;  // 2^17 > 10^5

vector<int> adj[MAXN];
int up[MAXN][LOG];   // up[v][k] = v 的第 2^k 个祖先
int depth[MAXN];

void dfs(int u, int par, int d) {
    depth[u] = d;
    up[u][0] = par;  // 直接父节点

    // 填充倍增表
    for (int k = 1; k < LOG; k++) {
        up[u][k] = up[up[u][k-1]][k-1];
        // 第 2^k 个祖先 = 第 2^(k-1) 个祖先的第 2^(k-1) 个祖先
    }

    for (int v : adj[u]) {
        if (v != par)
            dfs(v, u, d + 1);
    }
}

// 求 u 和 v 的 LCA
int lca(int u, int v) {
    // 第 1 步:将 u 和 v 提升到同一深度
    if (depth[u] < depth[v]) swap(u, v);
    int diff = depth[u] - depth[v];

    for (int k = 0; k < LOG; k++) {
        if ((diff >> k) & 1)   // 若 diff 的第 k 位为 1
            u = up[u][k];      // 向上跳 2^k 步
    }

    // 现在 depth[u] == depth[v]
    if (u == v) return u;      // u 在 v 的子树中(或反之)

    // 第 2 步:同时对 u 和 v 二分倍增,找 LCA
    for (int k = LOG - 1; k >= 0; k--) {
        if (up[u][k] != up[v][k]) {  // 若祖先不同则向上跳
            u = up[u][k];
            v = up[v][k];
        }
    }

    return up[u][0];  // 当前位置再往上一步 = LCA
}

int main() {
    ios_base::sync_with_stdio(false);
    cin.tie(NULL);

    int n, q;
    cin >> n >> q;

    for (int i = 0; i < n - 1; i++) {
        int u, v;
        cin >> u >> v;
        adj[u].push_back(v);
        adj[v].push_back(u);
    }

    // 初始化:根 = 1,根的父节点指向自身(哨兵)
    up[1][0] = 1;
    dfs(1, 1, 0);

    while (q--) {
        int u, v;
        cin >> u >> v;
        cout << lca(u, v) << "\n";
    }

    return 0;
}

复杂度: O(N log N) 预处理 + 每次 LCA 查询 O(log N)。

两顶点之间的距离

int dist(int u, int v) {
    return depth[u] + depth[v] - 2 * depth[lca(u, v)];
}

8.4.4 欧拉游览 + LCA 处理路径查询

问题: 每个顶点有一个值,回答 Q 次查询:"从 u 到 v 路径上所有顶点的值之和"。

方法: 定义 prefix[v] = 根到 v 路径上所有顶点的值之和,则:

path_sum(u, v) = prefix[u] + prefix[v] - prefix[LCA(u,v)] - prefix[parent(LCA(u,v))]

当值需要动态更新时,需要以 DFS 序为下标的 BIT——这正是欧拉游览的作用。

// 用前缀和 + LCA 求 path_sum(u, v)
// 定义:prefix[v] = 根到 v 的路径上所有顶点值之和(含两端)
// 则:path_sum(u,v) = prefix[u] + prefix[v] - prefix[lca] - prefix[parent[lca]]

long long path_sum(int u, int v) {
    int l = lca(u, v);
    return prefix[u] + prefix[v] - prefix[l] - prefix[up[l][0]];
    //                                                  ↑ LCA 的父节点
}

8.4.5 USACO Gold 欧拉游览题型模式

模式 1:子树更新 + 查询

"对 v 子树中的所有顶点值加 x。查询所有顶点的总和。"

欧拉游览将子树映射到区间 [in[v], out[v]]。使用区间更新的 BIT(懒惰传播或 BIT 差分数组)。

模式 2:带更新的路径和

"更新顶点 v 的值。查询 u 到 w 路径上的总和。"

LCA + 前缀和 + 以 DFS 序为下标的 BIT。

模式 3:基于 LCA 区间的欧拉游览

在欧拉游览中,当 in[u] ≤ in[v] 时,区间 [in[u], in[v]] 恰好包含 u 到 v 路径上的顶点(还有一些额外的,取决于 u 是否是 v 的祖先)。用于高级 LCA 变体。


8.4.6 预览:重链剖分(HLD)

欧拉游览 + LCA 的组合能处理大多数 Gold 级别的树题。但有一类问题需要对路径进行区间更新和查询——而不仅仅是子树。标准的欧拉游览对此不够用。

重链剖分(HLD) 是 Platinum 级别处理此类问题的技术,它直接建立在欧拉游览的概念上。

HLD 解决的问题

"给定 N 个顶点的树,处理 Q 次查询,每次为以下两种之一:

  • update(u, v, delta):给 u 到 v 路径上所有顶点加 delta
  • query(u, v):返回 u 到 v 路径上所有顶点的总和"

欧拉游览能高效处理子树更新/查询,但跨树的路径查询需要 HLD 的 O(log²N)。

核心思路

通过始终沿"重子节点"(子树最大的子节点)将树分解为重链,可以保证:

  • 任意根到叶路径最多经历 O(log N) 次链的切换
  • 每条链在 DFS 序中是连续区间
  • 路径查询 = 对各段链的 O(log N) 次区间查询 → 配合线段树为 O(log²N)
📄 Code 完整代码
重子节点:子树最大的子节点
重路径:从某顶点沿重子节点向下到叶节点的链

树:            sz[] 值:       重边(→):
    1  (sz=7)      7
   / \            / \
  2   3 (sz=4)   3   4
 / \ / \        / \ / \
4  5 6  7      1 1 2  1

重子节点:1→3 (sz=4 > sz=3), 3→6 (sz=2 > sz=1)
重链:{1, 3, 6}、{2}、{4}、{5}、{7}

HLD 实现草图(仅供参考)

📄 查看代码:HLD 实现草图(仅供参考)
// 重链剖分 — O(N log N) 预处理,O(log²N) 路径查询
// 完整实现是 Platinum 级别;以下是概念性草图

int heavy[MAXN];   // heavy[v] = v 的重子节点(叶节点为 -1)
int head[MAXN];    // head[v] = v 所在重链的顶端节点
int pos[MAXN];     // pos[v] = v 在 HLD 展开数组中的位置
int cur_pos = 0;

// 第 1 步:找重子节点(需先用树形 DP 计算 sz[])
void find_heavy(int u, int par) {
    sz[u] = 1;
    heavy[u] = -1;
    int max_sz = 0;
    for (int v : adj[u]) {
        if (v == par) continue;
        find_heavy(v, u);
        sz[u] += sz[v];
        if (sz[v] > max_sz) {
            max_sz = sz[v];
            heavy[u] = v;   // v 是重子节点
        }
    }
}

// 第 2 步:沿重链分配位置
void decompose(int u, int par, int h) {
    head[u] = h;            // 链头
    pos[u] = cur_pos++;     // 在展开数组中的位置

    if (heavy[u] != -1)
        decompose(heavy[u], u, h);      // 延续重链

    for (int v : adj[u]) {
        if (v == par || v == heavy[u]) continue;
        decompose(v, u, v);             // 从 v 开始新链
    }
}

// 第 3 步:利用 LCA + 链跳转进行路径查询
long long path_query(int u, int v) {
    long long result = 0;
    while (head[u] != head[v]) {
        if (depth[head[u]] < depth[head[v]]) swap(u, v);
        // u 的链头更深;查询 pos[head[u]]..pos[u]
        result += seg_query(pos[head[u]], pos[u]);
        u = parent[head[u]];  // 跳到链头的父节点
    }
    // 现在 u 和 v 在同一条链上
    if (depth[u] > depth[v]) swap(u, v);
    result += seg_query(pos[u], pos[v]);  // 查询链上该段
    return result;
}

复杂度:

  • 预处理:find_heavy O(N) + decompose O(N) = O(N)
  • 路径查询:O(log N) 次链切换 × O(log N) 线段树 = O(log²N)

📘 何时用 HLD vs 欧拉游览:

  • 子树查询/更新 → 欧拉游览 + BIT(O(log N))
  • 路径查询/更新,无区间更新 → LCA + 前缀和(O(log N))
  • 路径区间更新 + 区间查询 → HLD + 线段树(O(log²N)) ← Platinum 级别

关键结论:本章的所有内容(欧拉游览、LCA、倍增)都是 HLD 的先修知识。掌握第 8.4 章后,HLD 是自然而然的延伸。


💡 思路陷阱

陷阱 1:把"路径查询"误用欧拉序(应用 LCA)

错误判断: "欧拉序把树压成数组,路径查询 u→v 就是 [in[u], in[v]] 区间查询"
实际情况: 欧拉序只保证子树对应连续区间,路径不是连续区间

树:1-2-3-4(链),欧拉序:in[1]=1, in[2]=2, in[3]=3, in[4]=4
查询路径 1→4 看起来是 [1,4],但路径 2→4 是 [2,4] ——偶然正确
查询路径 3→1(往上走):in[3]=3, in[1]=1,区间 [1,3] 包含节点 2,
  而 2 不在 3→1 的路径上 ← 错误!

正确方法:path_sum(u,v) = prefix[u] + prefix[v] - prefix[LCA] - prefix[parent(LCA)]

识别信号: 查询涉及两点之间的路径(非子树) → 必须用 LCA 分解,不能直接用欧拉游览区间


陷阱 2:LCA 倍增时 LOG 取值过小

错误判断: "树最多 10⁴ 个节点,LOG=13 够了(2^13=8192 > 10⁴/2)"
实际情况: 需要 2^LOG > N,对于 N=10⁴ 需要 LOG=14(2^14=16384)

// 安全做法:LOG 永远用 ceil(log2(N)) + 1,或直接用 20 覆盖 10^6
const int LOG = 20;  // 2^20 = 1048576,覆盖 N≤10^6 的所有情形
// 不要"精确计算",用大一点的常数不会影响复杂度

识别信号: LCA 在深链上得到错误答案 → 检查 LOG 是否足够大


⚠️ 常见错误

  1. LOG 取值有误: N ≤ 10⁵ 时用 LOG = 17(2^17 = 131072 > 10⁵);N ≤ 10⁶ 时用 LOG = 20

  2. 根节点的父节点哨兵: 根节点没有父节点。将 up[root][0] = root(指向自身),避免倍增时越界。

  3. 欧拉游览计时器的差一: 若 BIT 是 1-indexed,则计时器从 1 开始(而非 0)。

  4. 路径和公式出错: 注意要减去 prefix[parent(LCA)],而非 prefix[LCA]。LCA 顶点本身在路径上,应被计入一次。

  5. LCA 算法假设树有根: 倍增是在固定根的基础上建立的。若题目没有指定根,选一个(通常是顶点 1)。


📋 章节小结

📌 核心要点

概念说明
欧拉游览DFS 时间戳 in[v], out[v];v 的子树 = 区间 [in[v], out[v]]
子树查询映射为数组区间查询;用 BIT/线段树;O(log N)
倍增up[v][k] = v 的第 2^k 个祖先;O(N log N) 预处理
LCA深度对齐后二分查找分叉点;O(log N)
距离dist(u,v) = depth[u] + depth[v] - 2·depth[LCA(u,v)]
路径和prefix[u] + prefix[v] - prefix[LCA] - prefix[parent(LCA)]

❓ 常见问题

Q:存在 O(1) 的 LCA 算法吗?
A:有——对特殊欧拉游览应用 RMQ(区间最值查询)可以在 O(N log N) 预处理后实现 O(1) 查询。但 USACO Gold 中 O(log N) 的倍增已完全够用。

Q:如果树以无向图形式给出(没有指定根)怎么办?
A:以顶点 1 为根。根的选择不影响 LCA 算法的正确性,只影响 depth[] 的值。

Q:欧拉游览可以用于边权树吗?
A:可以。将边权赋给下方端点(子节点)。路径查询方式相同,但公式改为 prefix[u] + prefix[v] - 2*prefix[LCA](不减 parent(LCA),因为 LCA 顶点承载的是它到父节点的边权)。

🔗 与后续章节的联系

  • Platinum:重链剖分(HLD): HLD 将树分解为链,再用欧拉游览 + 线段树实现 O(log²N) 的带区间更新的路径查询。
  • 第 8.3 章(树形 DP): LCA 允许"在线"回答树形 DP 查询——无需离线处理即可处理路径查询。
  • 第 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 子树中所有顶点加 delta
  • query v:返回顶点 v 的当前值
提示

欧拉游览将 v 的子树映射为区间 [in[v], out[v]]。使用差分 BIT:O(log N) 区间更新,O(log N) 单点查询。


🔴 困难

8.4-H1. 带更新的路径查询 (USACO Gold 难度)
给定带权树,处理 Q 次操作:

  • update u val:将顶点 u 的值设为 val
  • query u v:u 到 v 路径上所有顶点的值之和
提示

LCA + 欧拉游览路径和。对于动态更新,用以 DFS 序为下标的 BIT 维护前缀和。path_sum(u, v) = bit_prefix(in[u]) + bit_prefix(in[v]) - bit_prefix(in[lca]) - bit_prefix(in[parent(lca)])


🏆 挑战

8.4-C1. 统计经过某顶点的路径数 (困难)
给定一棵树。对每个顶点 v,统计满足"v 在 u 到 w 路径上"的路径 (u, w) 的数量(含 u=v 或 w=v 的情形)。

提示

对固定的 v,路径 (u, w) 经过 v 当且仅当 LCA(u, w) = v,或 LCA(u, v) = v,或 LCA(v, w) = v。使用欧拉游览 + 计数:v 的答案等于 C(sz[v], 2) 减去完全在 v 某棵子树内的路径数。这需要对 v 各子节点的子树大小进行仔细的容斥。

📖 第 8.5 章 ⏱️ 约 55 分钟 🎯 Gold / 困难

第 8.5 章:组合数学与数论

📝 前置要求: 本章需要基础代数知识以及附录 E(数学基础)中的模运算内容。第 6.1 章(DP)也很有帮助,因为许多组合问题是通过 DP 解决的。

组合数学与数论题目在 USACO Gold 中定期出现——通常作为每套题的"数学题"。它们一般涉及计数(有多少种合法方案?)或整除性(哪些数满足某个性质?),答案通常需要对一个素数取模(通常为 10⁹+7)。

学习目标:

  • 正确实现模运算(加、乘、快速幂、逆元)
  • 高效计算 C(n, k) mod p
  • 应用容斥原理
  • 使用埃氏筛分解质因数
  • 识别并解决 USACO 计数问题

8.5.0 为什么需要模运算?

组合数学的答案可能极其庞大。C(100, 50) 有 30 位数字!USACO 题目始终要求输出答案某个素数 p(通常 p = 10⁹+7 = 1,000,000,007)取模的结果。

模运算的核心规则:

  • (a + b) mod p = ((a mod p) + (b mod p)) mod p ✓
  • (a × b) mod p = ((a mod p) × (b mod p)) mod p ✓
  • (a − b) mod p = ((a mod p) − (b mod p) + p) mod p ✓(加 p 防止负数)
  • (a / b) mod p = (a mod p) × (b⁻¹ mod p) mod p ← 需要模逆元
const long long MOD = 1e9 + 7;

long long mod_add(long long a, long long b) { return (a + b) % MOD; }
long long mod_sub(long long a, long long b) { return (a - b + MOD) % MOD; }
long long mod_mul(long long a, long long b) { return (a % MOD) * (b % MOD) % MOD; }

8.5.1 快速幂(二进制取幂)

用反复平方法在 O(log n) 内计算 a^n mod p:

📄 用反复平方法在 O(log n) 内计算 a^n mod p:
// 返回 a^n mod p
long long power(long long a, long long n, long long p = MOD) {
    a %= p;
    long long result = 1;
    while (n > 0) {
        if (n & 1)             // 若 n 当前位为 1
            result = result * a % p;
        a = a * a % p;         // 底数平方
        n >>= 1;               // 移动到下一位
    }
    return result;
}

示例: 2^10 = 2^(1010₂) = 2^8 × 2^2 = 256 × 4 = 1024


8.5.2 模逆元

要计算 a/b mod p,需要 b 的模逆元:一个值 b⁻¹ 满足 b × b⁻¹ ≡ 1 (mod p)。

何时存在? 仅当 gcd(b, p) = 1 时。若 p 为素数且 0 < b < p,逆元始终存在。

方法一:费马小定理(p 必须为素数)

费马小定理:对素数 p 且 gcd(a,p)=1,有 a^(p−1) ≡ 1 (mod p)。

因此:a^(p−2) ≡ a⁻¹ (mod p)。

long long mod_inv(long long a, long long p = MOD) {
    return power(a, p - 2, p);  // O(log p)
}

// 模意义下的除法:
long long mod_div(long long a, long long b) {
    return mod_mul(a, mod_inv(b));
}

方法二:扩展欧几里得算法(适用于非素数模数)

📄 C++ 完整代码
// 返回 x 使得 a*x ≡ 1 (mod m)
// 用扩展 GCD:找 x, y 满足 a*x + m*y = gcd(a, m)
long long ext_gcd(long long a, long long b, long long& x, long long& y) {
    if (b == 0) { x = 1; y = 0; return a; }
    long long x1, y1;
    long long g = ext_gcd(b, a % b, x1, y1);
    x = y1;
    y = x1 - (a / b) * y1;
    return g;
}

long long mod_inv_general(long long a, long long m) {
    long long x, y;
    long long g = ext_gcd(a, m, x, y);
    if (g != 1) return -1;  // 逆元不存在
    return (x % m + m) % m;
}

8.5.3 C(n, k) mod p 的计算

二项式系数 C(n, k) = n! / (k! × (n−k)!) 计算从 n 个中选取 k 个的方案数。

预处理阶乘(适用于多次查询,n ≤ 10⁶)

📄 查看代码:预处理阶乘(适用于多次查询,n ≤ 10⁶)
const int MAXN = 1000001;
const long long MOD = 1e9 + 7;

long long fact[MAXN], inv_fact[MAXN];

void precompute_factorials(int n) {
    fact[0] = 1;
    for (int i = 1; i <= n; i++)
        fact[i] = fact[i-1] * i % MOD;

    inv_fact[n] = power(fact[n], MOD - 2);  // 费马小定理
    for (int i = n - 1; i >= 0; i--)
        inv_fact[i] = inv_fact[i+1] * (i+1) % MOD;
    // inv_fact[i] = 1/i! mod p,倒序计算
}

long long C(int n, int k) {
    if (k < 0 || k > n) return 0;
    return fact[n] % MOD * inv_fact[k] % MOD * inv_fact[n-k] % MOD;
}

帕斯卡三角 DP(适用于 n, k ≤ 2000 的小规模情形)

long long dp[2001][2001];  // dp[n][k] = C(n, k) mod p

void precompute_pascal(int maxn) {
    for (int i = 0; i <= maxn; i++) {
        dp[i][0] = 1;
        for (int j = 1; j <= i; j++)
            dp[i][j] = (dp[i-1][j-1] + dp[i-1][j]) % MOD;
    }
}
// C(n, k) = dp[n][k]

8.5.4 常用组合公式

公式数值含义
C(n, k)n! / (k!(n-k)!)从 n 个中无序选 k 个(不重复)
P(n, k)n! / (n-k)!从 n 个中有序选 k 个(不重复)
n^kn^k将 k 个不同物品放入 n 个不同盒子
C(n+k-1, k)(n+k-1)! / (k!(n-1)!)隔板法:k 个物品放入 n 个盒子(可重复)
n! / (a! b! c! ...)多项式:排列 a 种类型的物品
C(2n, n) / (n+1)卡特兰数二叉树、合法括号序列

卡特兰数(在 USACO 中出现频率出人意料地高):

long long catalan(int n) {
    // C_n = C(2n, n) / (n+1)
    return C(2*n, n) % MOD * mod_inv(n+1) % MOD;
}
// C_0=1, C_1=1, C_2=2, C_3=5, C_4=14, C_5=42, ...

8.5.5 容斥原理

容斥原理通过交替加减计算集合并集的大小:

|A₁ ∪ A₂ ∪ ... ∪ Aₙ| = Σ|Aᵢ| − Σ|Aᵢ ∩ Aⱼ| + Σ|Aᵢ ∩ Aⱼ ∩ Aₖ| − ...

2~3 个集合的模板:

|A ∪ B| = |A| + |B| − |A ∩ B|
|A ∪ B ∪ C| = |A| + |B| + |C| − |A∩B| − |A∩C| − |B∩C| + |A∩B∩C|

USACO 题型:统计"至少满足一个条件"的序列

"统计长度为 N 的序列(每个元素取 1..M),使得 1..K 中每个值至少出现一次的方案数。"

对"缺失值"做容斥:

总数 = Σ_{j=0}^{K} (-1)^j × C(K, j) × (M-j)^N
  • 选 j 个值排除(C(K, j) 种方案)
  • 用剩余 M-j 个值填满 N 个位置:(M-j)^N 种序列
  • 容斥的交替符号
long long count_surjective(int n, int m, int k) {
    // 统计每个 K 值都至少出现一次的 N 元序列数
    long long ans = 0;
    for (int j = 0; j <= k; j++) {
        long long term = C(k, j) * power(m - j, n) % MOD;
        if (j % 2 == 0) ans = (ans + term) % MOD;
        else             ans = (ans - term + MOD) % MOD;
    }
    return ans;
}

8.5.6 埃氏筛

O(N log log N) 时间内找出 N 以内所有质数:

📄 O(N log log N) 时间内找出 N 以内所有质数:
const int MAXN = 1000001;
bool is_prime[MAXN];
vector<int> primes;

void sieve(int n) {
    fill(is_prime, is_prime + n + 1, true);
    is_prime[0] = is_prime[1] = false;

    for (int i = 2; i <= n; i++) {
        if (is_prime[i]) {
            primes.push_back(i);
            for (long long j = (long long)i * i; j <= n; j += i)
                is_prime[j] = false;
        }
    }
}

线性筛 O(N)——用于质因数分解

📄 查看代码:线性筛 O(N)——用于质因数分解
int min_prime[MAXN];  // 每个数的最小质因子

void linear_sieve(int n) {
    for (int i = 2; i <= n; i++) {
        if (min_prime[i] == 0) {  // i 是质数
            min_prime[i] = i;
            primes.push_back(i);
        }
        for (int p : primes) {
            if (p > min_prime[i] || (long long)i * p > n) break;
            min_prime[i * p] = p;
        }
    }
}

// 用 min_prime[] 在 O(log n) 内分解质因数
vector<pair<int,int>> factorize(int n) {
    vector<pair<int,int>> factors;
    while (n > 1) {
        int p = min_prime[n], cnt = 0;
        while (n % p == 0) { n /= p; cnt++; }
        factors.push_back({p, cnt});
    }
    return factors;
}

因子个数

若 n = p₁^a₁ × p₂^a₂ × ... × pₖ^aₖ,则因子个数为 (a₁+1)(a₂+1)...(aₖ+1)。

int count_divisors(int n) {
    auto factors = factorize(n);
    int cnt = 1;
    for (auto [p, e] : factors)
        cnt *= (e + 1);
    return cnt;
}

8.5.7 欧拉 φ 函数

φ(n)(欧拉 φ 函数)统计 [1, n] 中与 n 互质(即 gcd(k, n) = 1)的整数个数。

φ(1) = 1
φ(2) = 1  (只有 1 与 2 互质)
φ(6) = 2  (1 和 5 与 6 互质)
φ(12) = 4 (1, 5, 7, 11)
φ(p) = p-1,对任意质数 p(1..p-1 均与 p 互质)

计算公式

若 n = p₁^a₁ × p₂^a₂ × ... × pₖ^aₖ,则:

φ(n) = n × (1 - 1/p₁) × (1 - 1/p₂) × ... × (1 - 1/pₖ)

单值计算

📄 查看代码:单值计算
int euler_phi(int n) {
    int result = n;
    for (int p = 2; (long long)p * p <= n; p++) {
        if (n % p == 0) {
            while (n % p == 0) n /= p;  // 去掉所有 p 因子
            result -= result / p;        // result *= (1 - 1/p)
        }
    }
    if (n > 1) result -= result / n;     // n 本身是剩余的质因子
    return result;
}

筛求 φ(1..N)——O(N log log N)

📄 查看代码:筛求 φ(1..N)——O(N log log N)
const int MAXN = 1000001;
int phi[MAXN];

void phi_sieve(int n) {
    // 初始化 phi[i] = i(乘法单位元步骤)
    iota(phi, phi + n + 1, 0);

    for (int p = 2; p <= n; p++) {
        if (phi[p] == p) {   // p 是质数(尚未被修改)
            for (int j = p; j <= n; j += p) {
                phi[j] -= phi[j] / p;  // phi[j] *= (1 - 1/p)
            }
        }
    }
}
// 调用 phi_sieve(n) 后,phi[i] = φ(i) 对所有 i ∈ [1, n]

φ 函数在 USACO/组合数学中的应用

  1. 费马小定理的推广: 对满足 gcd(a, n) = 1 的任意 a:a^φ(n) ≡ 1 (mod n)。这就是欧拉定理。

  2. 原根/乘法阶: a 的阶整除 φ(n)。

  3. 项链计数(Burnside): 公式中用到 N 的每个因子 d 对应的 φ(d)。

  4. φ 的求和: Σ_{d|n} φ(d) = n。在对因子做容斥时非常有用。

// 示例:统计满足 1<=a<=b<=n 且 gcd(a,b)=1 的对数
// 答案 = 1 + Σ_{i=2}^{n} φ(i)   ("+1" 对应 (1,1))
phi_sieve(n);
long long count = 1;
for (int i = 2; i <= n; i++)
    count += phi[i];

8.5.8 中国剩余定理(CRT)

中国剩余定理指出:若有两两互质模数的同余方程组:

x ≡ r₁ (mod m₁)
x ≡ r₂ (mod m₂)
...
x ≡ rₖ (mod mₖ)

则在模 M = m₁ × m₂ × ... × mₖ 的意义下存在唯一解 x。

两方程 CRT

x ≡ r₁ (mod m₁)x ≡ r₂ (mod m₂)(其中 gcd(m₁, m₂) = 1):

📄 对 `x ≡ r₁ (mod m₁)` 和 `x ≡ r₂ (mod m₂)`(其中 gcd(m₁, m₂) = 1):
// 返回 x 使得 x ≡ r1 (mod m1) 且 x ≡ r2 (mod m2)
// 要求 gcd(m1, m2) = 1
// 解在 mod (m1 * m2) 意义下唯一
long long crt(long long r1, long long m1, long long r2, long long m2) {
    // x = r1 + m1 * k,对某个 k
    // r1 + m1 * k ≡ r2 (mod m2)
    // m1 * k ≡ r2 - r1 (mod m2)
    // k ≡ (r2 - r1) * inv(m1) (mod m2)
    long long k = (r2 - r1 % m2 + m2) % m2 * mod_inv(m1 % m2, m2) % m2;
    return r1 + m1 * k;
    // 结果在 [0, m1*m2) 范围内,若 m1*m2 > 10^18 可能溢出
    // 必要时使用 __int128
}

广义 CRT(模数非互质)

模数非互质时,解可能不存在,用扩展 GCD 判断:

📄 模数**非互质**时,解可能不存在,用扩展 GCD 判断:
// 返回 {x, lcm(m1,m2)} 使得 x ≡ r1 (mod m1) 且 x ≡ r2 (mod m2)
// 无解时返回 {-1, -1}
// 即使 gcd(m1, m2) > 1 也适用
pair<long long, long long> crt_general(long long r1, long long m1, long long r2, long long m2) {
    long long g = __gcd(m1, m2);
    if ((r2 - r1) % g != 0) return {-1, -1};  // 无解

    long long lcm = m1 / g * m2;
    long long diff = (r2 - r1) / g;
    long long m2g = m2 / g;

    // k ≡ diff * inv(m1/g) (mod m2/g)
    long long k = diff % m2g * mod_inv(m1 / g % m2g, m2g) % m2g;
    long long x = (r1 + m1 * k) % lcm;
    if (x < 0) x += lcm;
    return {x, lcm};
}

多方程 CRT(迭代求解)

📄 查看代码:多方程 CRT(迭代求解)
// 求解方程组:x ≡ r[i] (mod m[i]),i = 0..k-1
// 返回 {x, M},其中 M = 所有模数的 lcm
// 无解时返回 {-1, -1}
pair<long long, long long> crt_multi(vector<long long>& r, vector<long long>& m) {
    long long cur_r = r[0], cur_m = m[0];
    for (int i = 1; i < (int)r.size(); i++) {
        auto [x, M] = crt_general(cur_r, cur_m, r[i], m[i]);
        if (x == -1) return {-1, -1};
        cur_r = x;
        cur_m = M;
    }
    return {cur_r, cur_m};
}

USACO CRT 题型模式

"分别每 A₁ 步、B₁ 天、C₁ 小时发生一次的事件,何时三者同时发生?"

x ≡ r₁ (mod A₁)
x ≡ r₂ (mod B₁)
x ≡ r₃ (mod C₁)
→ 用 crt_multi 迭代求解

8.5.9 USACO Gold 数学题型模式

模式 1:用 DP 计数

"统计长度为 N 的合法序列数,每个元素从 1..M 中选取并满足约束。"

建模为 DP:dp[i][状态] = 以某个状态结尾的长度为 i 的序列数。答案通常需要取模。

模式 2:整除约束

"1 到 N 中有多少数至少能被 {a₁, a₂, ..., aₖ} 之一整除?"

容斥:Σ|aᵢ 的倍数| − Σ|lcm(aᵢ, aⱼ) 的倍数| + ...

// 统计 n 以内 m 的倍数:
int count_multiples(long long n, long long m) {
    return n / m;
}

模式 3:隔板法(Stars and Bars)

"将 N 个相同小球分配到 K 个盒子,满足某些约束。"

无约束:C(N+K-1, K-1)。有"每盒至多 X 个"的约束:使用容斥。

模式 4:对称性 / Burnside 引理

"统计不同的项链/着色方案数(考虑旋转/翻转的等价性)。"

Burnside 引理:所有群元素的不动点数的平均值。在 USACO 中不常见但印象深刻。


💡 思路陷阱

陷阱 1:对非素数模数使用费马小定理求逆元

错误判断: "求 a 的逆元就是 power(a, MOD-2, MOD)"
实际情况: 费马小定理要求 MOD 是素数,且 gcd(a, MOD)=1;若 MOD 不是素数(如 MOD=10⁶)则结果错误

// 错误:MOD = 10^6(不是素数)
long long inv = power(6, 1e6 - 2, 1e6);  // 6^(10^6-2) mod 10^6 ≠ 6⁻¹

// 正确:MOD 不是素数时,用扩展欧几里得
long long inv = mod_inv_general(6, 1000000);  // ext_gcd 方法
// 大多数 USACO 题的 MOD=10⁹+7(素数),直接用 Fermat 没问题

识别信号: 题目给的模数不是 10⁹+7 或 998244353 → 先验证是否为素数再决定求逆方法


陷阱 2:容斥原理的符号方向搞反

错误判断: "偶数大小子集加,奇数大小子集减"(或者反过来)
实际情况: 容斥公式:|A₁∪...∪Aₙ| = Σ|单集合| - Σ|二元交| + Σ|三元交| - ...
奇数大小子集,偶数大小子集(包含-排除交替)

📄 奇数大小子集**加**,偶数大小子集**减**(包含-排除交替)
// 常见题:N 个元素,统计"至少满足 k 个条件之一"
// 错误:把 + - 方向搞反
long long ans = 0;
for (int mask = 1; mask < (1<<k); mask++) {
    int bits = __builtin_popcount(mask);
    long long term = compute_intersection(mask);
    if (bits % 2 == 0) ans += term;  // ← 错误!偶数应该减
    else               ans -= term;  // ← 错误!奇数应该加
}

// 正确
    if (bits % 2 == 1) ans += term;  // 奇数大小交集:加
    else               ans -= term;  // 偶数大小交集:减

识别信号: 容斥答案出现负数或明显偏大 → 检查 +/- 符号是否与 popcount % 2 对应正确


陷阱 3:C(n, k) 中 k < 0 或 k > n 时未做边界检查

错误判断: "公式直接套,fact[n] * inv_fact[k] * inv_fact[n-k]"
实际情况: 当 k < 0 或 k > n 时,inv_fact 数组越界或数学上 C(n,k) 应为 0

📄 C++ 完整代码
// 错误:没有边界检查
long long C(int n, int k) {
    return fact[n] * inv_fact[k] % MOD * inv_fact[n-k] % MOD;
    // 若 k=-1 或 k=n+1,访问 inv_fact[-1] 是野指针访问
}

// 正确:加边界保护
long long C(int n, int k) {
    if (k < 0 || k > n || n < 0) return 0;  // ← 必须有!
    return fact[n] * inv_fact[k] % MOD * inv_fact[n-k] % MOD;
}

识别信号: 组合数计算在某些特殊输入下崩溃或返回极大值 → 检查边界条件


⚠️ 常见错误

  1. a * b % MOD 中的整数溢出: 若 a, b ≈ 10⁹,则 a * b 可能溢出 int 甚至 long long。务必先转换:(long long)a * b % MOD

  2. 减法结果为负: (a - b) % MOD 在 C++ 中可能为负数。始终写成 (a - b + MOD) % MOD

  3. inv_fact[0] = 1 确保 inv_fact[0] = 1(因为 0! = 1)。precompute_factorials 中的倒序循环会处理此问题。

  4. C(n, k) 当 k > n 或 k < 0 时为 0: 始终检查这些边界情况。

  5. MOD 不是素数: 费马小定理要求 p 为素数。若题目使用非素数模数(罕见),用 ext_gcd 求模逆元。

  6. 大 n 下的 Lucas 定理: 当 n 极大(10¹²+)但素数模数 p 较小(< 10⁶)时,使用 Lucas 定理:C(n, k) mod p = C(n mod p, k mod p) × C(n/p, k/p) mod p。在 USACO Gold 中罕见,但在 Platinum 中会出现。


📋 章节小结

📌 核心要点

概念说明
模逆元a⁻¹ mod p = a^(p-2) mod p(费马,p 为素数);O(log p)
阶乘表预处理 fact[], inv_fact[] 到 10⁶;O(N) 空间
C(n, k) mod pfact[n] * inv_fact[k] * inv_fact[n-k] mod p;每次查询 O(1)
容斥原理对约束的子集交替求和
筛法O(N log log N) 找出 N 以内所有质数;O(log N) 分解质因数
卡特兰数C(2n,n)/(n+1);统计二叉树、合法括号序列数

❓ 常见问题

Q:USACO 中最常见的模数是什么?
A:10⁹+7(1,000,000,007),是素数。偶尔用 998,244,353(也是素数,用于 NTT)。

Q:怎么判断一道题需要组合数学还是 DP?
A:若问题有"漂亮的"封闭形式答案(如 C(n,k)),用组合数学。若约束有复杂依赖,可能需要 DP。通常两者结合:先用 DP 建立表格,再用组合数学求和。

Q:GCD 是什么?什么时候需要用它?
A:gcd(a, b) = 最大公因数。C++ 中用 __gcd(a, b)。用途:化简分数、检验整除性、计算 lcm = a*b/gcd(a,b)。

Q:什么时候用 Lucas 定理?
A:当 n 极大(10¹²+)但素数模数 p 较小(< 10⁶)时。在 USACO Gold 中罕见,但在 Platinum 中出现。

🔗 与后续章节的联系

  • 附录 E(数学基础): 本章是附录 E 的延伸——模运算、质数和组合数学都在那里介绍。
  • 第 6.3 章(进阶 DP): 数位 DP(统计满足数位约束的整数)结合了数论与 DP。
  • 第 8.2 章(DAG DP): DAG 中的路径计数通常需要对结果取模 p。

🏋️ 练习题

🟢 简单

8.5-E1. 模意义快速幂
给定 a, n, p(p 为素数,n ≤ 10¹⁸),计算 a^n mod p。

提示

二进制取幂(power(a, n, p))。注意溢出:若 a, p ≈ 10¹⁸,使用 __int128 或谨慎处理乘法。


8.5-E2. 网格路径计数
统计在 N×M 网格中从 (0,0) 到 (n,m) 的单调路径数(只能向右或向下)。输出 mod 10⁹+7 的结果。

提示

答案为 C(n+m, n) = (n+m)! / (n! × m!)。用预处理的阶乘和模逆元计算。


🟡 中等

8.5-M1. 序列计数 (USACO 风格)
统计长度为 N 的序列数,每个元素取自 {1, 2, ..., M},且 K 个"特殊值"都至少出现一次。输出 mod 10⁹+7 的结果。

提示

容斥:使用 8.5.5 节中的 count_surjective(N, M, K)。枚举 j 个被排除的值。


8.5-M2. 因子和
给定 N 个数 a₁, a₂, ..., aₙ,对每个 aᵢ 输出其所有因子之和对 10⁹+7 取模的结果。

提示

用线性筛预计算最小质因子并分解每个 aᵢ。若 aᵢ = p₁^e₁ × ... × pₖ^eₖ,因子和 = 各 (1 + pᵢ + pᵢ² + ... + pᵢ^eᵢ) 的乘积 = 各 (pᵢ^(eᵢ+1) - 1) / (pᵢ - 1) 的乘积。用模逆元处理除法。


🔴 困难

8.5-H1. 项链计数 (Burnside 引理)
统计由 N 颗珠子组成、每颗用 K 种颜色之一着色的不同项链数(旋转等价的视为相同)。

提示

Burnside 引理:答案 = (1/N) × Σ_{d|N} φ(N/d) × K^d,其中 φ 为欧拉 φ 函数,求和对 N 的因子 d 进行。需要 GCD、模逆元和欧拉 φ 函数的计算。


🏆 挑战

8.5-C1. 树上期望值 (接近 USACO Platinum 难度)
给定 N 个顶点的树,每个顶点初始无色。以 1/2 的概率将每个顶点染成红色,以 1/2 的概率染成蓝色。求两端颜色相同的边的期望数。将答案表示为最简分数 p/q,输出 p × q⁻¹ mod 10⁹+7。

提示

由期望的线性性:E[同色边数] = 边数 × P(两端同色)。对每条边,P(同色) = 1/4(均红)+ 1/4(均蓝)= 1/2。所以答案为 (N-1)/2。输出 (N-1) × mod_inv(2) % MOD。

(有趣的变体是给每个顶点不同的着色概率——用相同思路推广即可。)

附录 A:C++ 速查手册

本附录是你的备忘单,在练习时随时参考。这里的所有内容在书中都已涵盖,这是精简的参考形式。


A.1 竞赛模板

📄 查看代码:A.1 竞赛模板
#include <bits/stdc++.h>
using namespace std;

int main() {
    ios_base::sync_with_stdio(false);
    cin.tie(NULL);
    // freopen("problem.in", "r", stdin);   // 文件 I/O 时取消注释(使用实际题目名称)
    // freopen("problem.out", "w", stdout);  // 文件 I/O 时取消注释

    // 你的代码

    return 0;
}

A.2 常用数据类型

类型大小范围使用场景
int32 位±2.1 × 10^9默认整数
long long64 位±9.2 × 10^18大数、乘积
double64 位~15 位有效数字小数
bool1 字节true/false标志
char8 位-128 到 127单字符
string可变任意长度文本

安全最大值:

INT_MAX   = 2,147,483,647   ≈ 2.1 × 10^9
LLONG_MAX = 9,223,372,036,854,775,807 ≈ 9.2 × 10^18

A.3 STL 容器——操作速查

vector<T>

📄 查看代码:vector
vector<int> v;              // 空向量
vector<int> v(n, 0);        // n 个零
vector<int> v = {1,2,3};    // 从列表初始化

v.push_back(x);     // 尾部追加——O(1) 均摊
v.pop_back();       // 删除最后——O(1)
v[i]                // 访问下标 i——O(1)
v.front()           // 第一个元素
v.back()            // 最后一个元素
v.size()            // 元素个数
v.empty()           // 空则返回 true
v.clear()           // 删除所有元素
v.resize(k, val)    // 调整为 k 个,新元素填充 val
v.insert(v.begin()+i, x)  // 在下标 i 处插入——O(n)
v.erase(v.begin()+i)      // 删除下标 i——O(n)

pair<A,B>

pair<int,int> p = {3, 5};
p.first             // 3
p.second            // 5
make_pair(a, b)     // 创建 pair
// 比较:先比 .first,再比 .second

map<K,V>

map<string,int> m;
m[key] = val;           // 插入/更新——O(log n)
m[key]                  // 访问(不存在时创建!)——O(log n)
m.find(key)             // 迭代器;未找到返回 .end()——O(log n)
m.count(key)            // 0 或 1——O(log n)
m.erase(key)            // 删除——O(log n)
m.size()                // 条目数
for (auto &[k,v] : m)   // 按键有序遍历

set<T>

set<int> s;
s.insert(x)             // 添加——O(log n)
s.erase(x)              // 删除所有 x——O(log n)
s.count(x)              // 0 或 1——O(log n)
s.find(x)               // 迭代器——O(log n)
s.lower_bound(x)        // 第一个 >= x 的元素
s.upper_bound(x)        // 第一个 > x 的元素
*s.begin()              // 最小元素
*s.rbegin()             // 最大元素

stack<T>

stack<int> st;
st.push(x)      // 压入——O(1)
st.pop()        // 弹出(无返回值!)——O(1)
st.top()        // 查看顶部——O(1)
st.empty()      // 空则返回 true
st.size()       // 元素数

queue<T>

queue<int> q;
q.push(x)       // 入队——O(1)
q.pop()         // 出队(无返回值!)——O(1)
q.front()       // 队首元素——O(1)
q.back()        // 队尾元素——O(1)
q.empty()
q.size()

priority_queue<T>(最大堆)

priority_queue<int> pq;                               // 最大堆
priority_queue<int, vector<int>, greater<int>> pq2;   // 最小堆

pq.push(x)      // 插入——O(log n)
pq.pop()        // 删除顶部——O(log n)
pq.top()        // 查看顶部(最大值)——O(1)
pq.empty()
pq.size()

unordered_map<K,V> / unordered_set<T>

接口与 map/set 相同,但平均 O(1)(无有序迭代)。


A.4 STL 算法速查

📄 查看代码:A.4 STL 算法速查
// 全部假设 #include <bits/stdc++.h>

// 排序
sort(v.begin(), v.end());                          // 升序
sort(v.begin(), v.end(), greater<int>());          // 降序
sort(v.begin(), v.end(), [](int a, int b){...});   // 自定义

// 二分查找(需要已排序容器)
binary_search(v.begin(), v.end(), x)               // bool:存在吗?
lower_bound(v.begin(), v.end(), x)                 // 第一个 >= x 的迭代器
upper_bound(v.begin(), v.end(), x)                 // 第一个 > x 的迭代器

// 最小/最大值
min(a, b)               // 两者最小
max(a, b)               // 两者最大
min({a, b, c})          // 多个中最小(C++11)
*min_element(v.begin(), v.end())   // 容器最小
*max_element(v.begin(), v.end())   // 容器最大

// 累加
accumulate(v.begin(), v.end(), 0LL)   // 求和(long long 用 0LL)

// 填充
fill(v.begin(), v.end(), x)           // 全部填充 x
memset(arr, 0, sizeof(arr))           // 清零 C 数组(快速)

// 反转
reverse(v.begin(), v.end())           // 原地反转

// 统计
count(v.begin(), v.end(), x)          // 统计 x 的出现次数

// 去重(删除连续重复——先排序!)
auto it = unique(v.begin(), v.end());
v.erase(it, v.end());

// 交换
swap(a, b)              // 交换两个值

// 全排列(暴力时有用)
sort(v.begin(), v.end());
do {
    // 处理当前排列
} while (next_permutation(v.begin(), v.end()));

// GCD / LCM(C++17)
gcd(a, b)                           // 最大公约数——来自 <numeric> 的 std::gcd
lcm(a, b)                           // 最小公倍数——来自 <numeric> 的 std::lcm
// 旧版(C++17 之前):__gcd(a, b)  // 仍然有效,但推荐用 std::gcd

A.5 时间复杂度参考表

图示:复杂度与 N 参考

Complexity Table

上面的彩色表格能让你一眼看出可行性。读题时,找到列中的 N 和行中的算法复杂度,看它是否能在 1 秒内通过。

N最大可行复杂度算法层级
N ≤ 12O(N! × N)所有排列
N ≤ 20O(2^N × N)所有子集 + 线性操作
N ≤ 500O(N³)3 重嵌套循环、区间 DP
N ≤ 5000O(N²)2 重嵌套循环、O(N²) DP
N ≤ 10^5O(N log N)排序、BFS、二分查找
N ≤ 10^6O(N)线性扫描、前缀和
N ≤ 10^8O(N)O(N / 32)纯循环或位集

A.6 常见陷阱

整数溢出

📄 查看代码:整数溢出
// 错误
int a = 1e9, b = 1e9;
int product = a * b;  // 溢出!

// 正确
long long product = (long long)a * b;

// 错误
int n = 1e5;
int arr[n * n];  // n*n = 10^10,太大了

// 检查:若中间值可能超过 2 × 10^9,用 long long

差一错误

// 错误:访问 arr[n]
for (int i = 0; i <= n; i++) cout << arr[i];

// 正确
for (int i = 0; i < n; i++) cout << arr[i];   // 0-indexed
for (int i = 1; i <= n; i++) cout << arr[i];  // 1-indexed

// 前缀和:P[i] = 前 i 个元素之和
// 查询 [L, R] 的和(1-indexed):P[R] - P[L-1]
// 不是 P[R] - P[L]  ← 差一!

遍历时修改容器

// 错误
for (auto it = s.begin(); it != s.end(); ++it) {
    if (*it % 2 == 0) s.erase(it);  // 迭代器失效!
}

// 正确
set<int> toErase;
for (int x : s) if (x % 2 == 0) toErase.insert(x);
for (int x : toErase) s.erase(x);

map 访问时创建条目

map<string,int> m;
if (m["missing_key"])  // 创建值为 0 的 "missing_key"!

// 正确:先检查
if (m.count("missing_key") && m["missing_key"])  // 安全
// 或:
auto it = m.find("missing_key");
if (it != m.end() && it->second) { ... }

浮点数比较

double a = 0.1 + 0.2;
if (a == 0.3)  // 因浮点精度可能为 false!

// 正确:用 epsilon 比较
const double EPS = 1e-9;
if (abs(a - 0.3) < EPS) { ... }

深度递归导致栈溢出

// 大图上的 DFS 可能导致栈溢出
// 对 N = 10^5 个节点排成链的树,递归深度 = 10^5
// 修复:增大栈大小,或用迭代 DFS

// 在 Linux/Mac 上增大栈:
// ulimit -s unlimited
// 或编译时:g++ -DLOCAL ... 并手动设置栈大小

A.7 常用 #definetypedef

📄 查看代码:A.7 常用 #define 和 typedef
// 常用缩写(个人喜好——不要过度使用)
typedef long long ll;
typedef pair<int,int> pii;
typedef vector<int> vi;

#define pb push_back
#define all(v) (v).begin(), (v).end()
#define sz(v) ((int)(v).size())

// 用法示例:
ll x = 1e18;
pii p = {3, 5};
vi v = {1, 2, 3};
sort(all(v));

A.8 C++17 实用特性

📄 查看代码:A.8 C++17 实用特性
// 结构化绑定——简洁地解包 pair/tuple
auto [x, y] = make_pair(3, 5);
for (auto [key, val] : mymap) { ... }

// 带初始化器的 if
if (auto it = m.find(key); it != m.end()) {
    // 使用 it->second
}

// gcd 和 lcm
int g = gcd(12, 8);   // C++17:使用 <numeric> 中的 std::gcd
int l = lcm(4, 6);    // C++17:使用 <numeric> 中的 std::lcm

// 编译:g++ -std=c++17 -O2 -o sol sol.cpp

附录 B:USACO 题目集

本附录提供了按主题分类的 20 道 USACO 精选题目,这些题目经过精心挑选以巩固本书中涵盖的技术。所有题目都可以在 usaco.org 上免费获取。


如何使用本题目集

大致按顺序做这些题。对每道题:

  1. 仔细读题,独立尝试解题至少 1-2 小时
  2. 若卡住,看下面的提示(不是完整题解)
  3. 若再过 30 分钟仍卡住,在 USACO 网站上读题解
  4. 解完(或读完题解)后,从零自行实现解法

当你挣扎后再理解时学习最多,而不是被动地读解法。


第一节:模拟与暴力(Bronze)

题目 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 / DFS13, 14
并查集11, 12
动态规划7, 15, 18, 20
贪心16, 20
字符串 / Ad hoc19

练习建议

  1. train.usaco.org 上使用 USACO 训练门户自动评测
  2. 每道题后都读题解(在 usaco.org 上)——哪怕是你解出来的题
  3. 保持题目日志——写下每道题的关键洞察
  4. 难度进阶:从近年简单题做起,再做老年份的中等题

其他题目来源

来源网址最适合
USACO 题库usaco.orgUSACO 专项练习
USACO Guideusaco.guide带题目的结构化课程
Codeforcescodeforces.com大量练习、多样题目
AtCoder Beginneratcoder.jp高质量入门题
LeetCodeleetcode.com数据结构基础
CSEScses.fi/problemset经典算法题

CSES 题目集cses.fi/problemset)特别推荐——约 300 道精心策划的题目,涵盖所有 USACO Silver 主题,自动评测,免费。

附录 C:C++ 竞赛编程技巧

本附录收集了竞赛程序员每天使用的最有用的 C++ 技巧、宏、模板和代码片段,可以在竞赛中节省大量时间并让代码运行更快。


C.1 快速 I/O

对 I/O 密集型题目最重要的性能优化:

// 始终在 main() 开头加上这两行
ios_base::sync_with_stdio(false);  // 断开 C 和 C++ I/O 流的同步
cin.tie(NULL);                      // 解除 cin 和 cout 的绑定

// 为什么有效:
// sync_with_stdio(false):默认情况下 C++ 与 C I/O(printf/scanf)
//   同步以保持兼容性,关闭后 cin/cout 快得多。
// cin.tie(NULL):默认情况下 cin 在每次读取前清空 cout,
//   解除绑定消除这个不必要的清空。

性能差异很显著——这两行应该出现在每个解法中:

Fast I/O Speed Comparison

文件 I/O(USACO 传统题):

freopen("problem.in",  "r", stdin);   // 将 cin 重定向到文件(将 "problem" 替换为实际名称)
freopen("problem.out", "w", stdout);  // 将 cout 重定向到文件
// 这两行之后 cin/cout 正常使用但读写文件

C.2 常用宏和 typedef

📄 查看代码:C.2 常用宏和 typedef
// 较短的类型名
typedef long long ll;
typedef unsigned long long ull;
typedef long double ld;
typedef pair<int,int> pii;
typedef pair<ll,ll> pll;
typedef vector<int> vi;
typedef vector<ll> vll;

// 缩写操作
#define pb push_back
#define pf push_front
#define all(v) (v).begin(), (v).end()
#define rall(v) (v).rbegin(), (v).rend()
#define sz(v) ((int)(v).size())
#define fi first
#define se second

// 循环宏(谨慎使用——可能影响可读性)
#define FOR(i, a, b) for(int i = (a); i < (b); i++)
#define REP(i, n) FOR(i, 0, n)

// 最小/最大值缩写
#define chmin(a, b) a = min(a, b)
#define chmax(a, b) a = max(a, b)

// 使用示例:
// vi v; v.pb(5);        → v.push_back(5)
// sort(all(v));         → sort(v.begin(), v.end())
// cout << sz(v) << "\n";→ cout << (int)v.size() << "\n"
// FOR(i, 1, n+1) { ... }→ for(int i = 1; i < n+1; i++) { ... }

C.3 GCC 编译指令提速

📄 查看代码:C.3 GCC 编译指令提速
// 这些 pragma 在 GCC 编译器(USACO 评测机使用)上可提速 2-4 倍
#pragma GCC optimize("O3,unroll-loops")
#pragma GCC target("avx2,bmi,bmi2,popcnt")

// 放在 #include 之前
// 警告:O3 和 avx2 可能导致微妙的数值差异
//   (整数题通常没问题,浮点数要小心)

// 更安全的版本(只有 O2,无向量指令):
#pragma GCC optimize("O2")

// 带 pragma 的完整竞赛模板:
#pragma GCC optimize("O3,unroll-loops")
#pragma GCC target("avx2")
#include <bits/stdc++.h>
using namespace std;
// ... 你的代码

C.4 实用数学:GCD、LCM、模运算

📄 查看代码:C.4 实用数学:GCD、LCM、模运算
#include <bits/stdc++.h>
using namespace std;

// ─── GCD 和 LCM ──────────────────────────────────────────────────────────────

// C++17:来自 <numeric> 的 std::gcd 和 std::lcm
#include <numeric>
int g = gcd(12, 8);            // 4
int l = lcm(4, 6);             // 12

// C++14 及以前:来自 <algorithm> 的 __gcd
int g2 = __gcd(12, 8);         // 4

// 自定义 GCD(辗转相除法):
ll mygcd(ll a, ll b) { return b ? mygcd(b, a%b) : a; }
ll mylcm(ll a, ll b) { return a / mygcd(a,b) * b; }  // 先除防溢出!

// ─── 模运算 ──────────────────────────────────────────────────────────────────

const ll MOD = 1e9 + 7;  // 标准 USACO/Codeforces 模数

// 加法:(a + b) % MOD
ll addmod(ll a, ll b) { return (a + b) % MOD; }

// 减法:(a - b + MOD) % MOD  ← 取模前总要加 MOD 防负数
ll submod(ll a, ll b) { return (a - b + MOD) % MOD; }

// 乘法:(a * b) % MOD
ll mulmod(ll a, ll b) { return (a % MOD) * (b % MOD) % MOD; }

// 快速幂:a^b mod MOD,O(log b)
ll power(ll base, ll exp, ll mod = MOD) {
    ll result = 1;
    base %= mod;
    while (exp > 0) {
        if (exp & 1) result = result * base % mod;  // 奇数指数
        base = base * base % mod;                    // 平方
        exp >>= 1;                                   // 减半指数
    }
    return result;
}

// 模逆元(a^{-1} mod p,p 为质数):
ll modinv(ll a, ll mod = MOD) { return power(a, mod-2, mod); }
// 这使用费马小定理:a^{p-1} ≡ 1 (mod p),p 为质数
// 所以 a^{-1} ≡ a^{p-2} (mod p)

// 模除法:(a / b) mod p = (a * b^{-1}) mod p
ll divmod(ll a, ll b) { return mulmod(a, modinv(b)); }

// 预计算阶乘后的组合数 C(n, k) mod p
const int MAXN = 200001;
ll fact[MAXN], inv_fact[MAXN];

void precompute_factorials() {
    fact[0] = 1;
    for (int i = 1; i < MAXN; i++) fact[i] = fact[i-1] * i % MOD;
    inv_fact[MAXN-1] = modinv(fact[MAXN-1]);
    for (int i = MAXN-2; i >= 0; i--) inv_fact[i] = inv_fact[i+1] * (i+1) % MOD;
}

ll C(int n, int k) {
    if (k < 0 || k > n) return 0;
    return fact[n] * inv_fact[k] % MOD * inv_fact[n-k] % MOD;
}

C.5 实用代码片段

并查集(DSU)模板

📄 查看代码:并查集(DSU)模板
// DSU——带大小追踪的完整模板
struct DSU {
    vector<int> parent, sz;

    DSU(int n) : parent(n+1), sz(n+1, 1) {
        iota(parent.begin(), parent.end(), 0);
    }

    int find(int x) {
        if (parent[x] != x) parent[x] = find(parent[x]);  // 路径压缩
        return parent[x];
    }

    bool unite(int x, int y) {
        x = find(x); y = find(y);
        if (x == y) return false;
        if (sz[x] < sz[y]) swap(x, y);   // 按大小合并
        parent[y] = x;
        sz[x] += sz[y];
        return true;
    }

    bool connected(int x, int y) { return find(x) == find(y); }
    int size(int x) { return sz[find(x)]; }   // x 所在分量的大小
};

// 使用:
DSU dsu(n);
dsu.unite(1, 2);
cout << dsu.connected(1, 3) << "\n";   // 0(假)
cout << dsu.size(1) << "\n";           // 2

线段树(单点更新,区间查询)

📄 查看代码:线段树(单点更新,区间查询)
// 线段树——支持:
//   point_update(i, val):设置位置 i 为 val
//   query(l, r):[l, r] 的和
// 所有操作 O(log N)

struct SegTree {
    int n;
    vector<ll> tree;

    SegTree(int n) : n(n), tree(4*n, 0) {}

    void update(int node, int start, int end, int idx, ll val) {
        if (start == end) {
            tree[node] = val;
            return;
        }
        int mid = (start + end) / 2;
        if (idx <= mid) update(2*node, start, mid, idx, val);
        else            update(2*node+1, mid+1, end, idx, val);
        tree[node] = tree[2*node] + tree[2*node+1];
    }

    ll query(int node, int start, int end, int l, int r) {
        if (r < start || end < l) return 0;
        if (l <= start && end <= r) return tree[node];
        int mid = (start + end) / 2;
        return query(2*node, start, mid, l, r)
             + query(2*node+1, mid+1, end, l, r);
    }

    void update(int i, ll val) { update(1, 1, n, i, val); }
    ll query(int l, int r) { return query(1, 1, n, l, r); }
};

BFS 模板

📄 查看代码:BFS 模板
// 网格 BFS——无权网格中的最短路径
int bfs_grid(vector<string>& grid, int sr, int sc, int er, int ec) {
    int R = grid.size(), C = grid[0].size();
    vector<vector<int>> dist(R, vector<int>(C, -1));
    queue<pair<int,int>> q;
    int dr[] = {-1, 1, 0, 0};
    int dc[] = {0, 0, -1, 1};

    dist[sr][sc] = 0;
    q.push({sr, sc});

    while (!q.empty()) {
        auto [r, c] = q.front(); q.pop();
        for (int d = 0; d < 4; d++) {
            int nr = r + dr[d], nc = c + dc[d];
            if (nr >= 0 && nr < R && nc >= 0 && nc < C
                && grid[nr][nc] != '#' && dist[nr][nc] == -1) {
                dist[nr][nc] = dist[r][c] + 1;
                q.push({nr, nc});
            }
        }
    }
    return dist[er][ec];
}

二分答案模板

📄 查看代码:二分答案模板
// 二分答案——最大化满足 check(X) 为真的 X
// 前提:check 是单调的(false...false...true...true)
template<typename T, typename F>
T binary_search_ans(T lo, T hi, F check) {
    T ans = lo;
    while (lo <= hi) {
        T mid = lo + (hi - lo) / 2;
        if (check(mid)) { ans = mid; lo = mid + 1; }
        else { hi = mid - 1; }
    }
    return ans;
}

// 使用示例:找最大 D 使 canPlace(D) 为真
int result = binary_search_ans(1, maxDist, canPlace);

C.6 值得了解的内置函数

📄 查看代码:C.6 值得了解的内置函数
// ─── 整数操作 ─────────────────────────────────────────────────────────────────

__builtin_popcount(x)      // 统计 x 中置位数(int)
__builtin_popcountll(x)    // 统计 x 中置位数(long long)
__builtin_clz(x)           // 统计前导零数(int,x > 0)
__builtin_ctz(x)           // 统计尾零数(int,x > 0)

// 示例:
__builtin_popcount(0b1011) == 3    // 三个 1 位
__builtin_ctz(0b1000)      == 3    // 三个尾零
(31 - __builtin_clz(x))            // floor(log2(x))

// ─── 位运算技巧 ──────────────────────────────────────────────────────────────

// 检查 x 是否是 2 的幂:
bool isPow2 = (x > 0) && !(x & (x-1));

// 提取最低置位:
int lsb = x & (-x);

// 清除最低置位:
x = x & (x-1);

// 枚举位掩码的所有子集(用于状压 DP):
for (int sub = mask; sub > 0; sub = (sub-1) & mask) {
    // 处理 mask 的子集 sub
}

// ─── 实用 STL 函数 ────────────────────────────────────────────────────────────

// next_permutation:遍历所有排列
sort(v.begin(), v.end());    // 从有序开始
do {
    // v 是当前排列
} while (next_permutation(v.begin(), v.end()));

C.7 完整竞赛模板

📄 查看代码:C.7 完整竞赛模板
// ────────────────────────────────────────────────────────────────────────────
// 竞赛编程模板——C++17
// ────────────────────────────────────────────────────────────────────────────
#pragma GCC optimize("O2")
#include <bits/stdc++.h>
using namespace std;

// 类型别名
typedef long long ll;
typedef pair<int,int> pii;
typedef vector<int> vi;

// 便捷宏
#define pb push_back
#define all(v) (v).begin(), (v).end()
#define sz(v) ((int)(v).size())
#define fi first
#define se second

// 常量
const ll MOD = 1e9 + 7;
const ll INF = 1e18;
const int MAXN = 200005;

// 快速幂取模
ll power(ll base, ll exp, ll mod = MOD) {
    ll res = 1; base %= mod;
    for (; exp > 0; exp >>= 1) {
        if (exp & 1) res = res * base % mod;
        base = base * base % mod;
    }
    return res;
}

int main() {
    ios_base::sync_with_stdio(false);
    cin.tie(NULL);

    // 文件 I/O 时取消注释:
    // freopen("problem.in", "r", stdin);
    // freopen("problem.out", "w", stdout);

    // ── 你的解法 ──

    return 0;
}

C.8 常用模式和惯用法

📄 查看代码:C.8 常用模式和惯用法
// ─── 读取 N 个整数到向量 ──────────────────────────────────────────────────────
int n; cin >> n;
vi a(n);
for (int &x : a) cin >> x;

// ─── 二维向量初始化 ──────────────────────────────────────────────────────────
int R, C;
vector<vector<int>> grid(R, vector<int>(C, 0));

// ─── 自定义条件排序 ──────────────────────────────────────────────────────────
sort(all(v), [](const auto &a, const auto &b) {
    return a.weight < b.weight;  // 按重量升序排序
});

// ─── 带下标的最小/最大值 ─────────────────────────────────────────────────────
auto maxIt = max_element(all(v));
int maxVal = *maxIt;
int maxIdx = maxIt - v.begin();

// ─── 从有序向量删除重复 ──────────────────────────────────────────────────────
sort(all(v));
v.erase(unique(all(v)), v.end());

// ─── 整数平方根(精确,无浮点问题)──────────────────────────────────────────
ll isqrt(ll n) {
    ll r = sqrtl(n);
    while (r*r > n) r--;
    while ((r+1)*(r+1) <= n) r++;
    return r;
}

// ─── 检查数是否为质数 ─────────────────────────────────────────────────────────
bool isPrime(ll n) {
    if (n < 2) return false;
    if (n == 2) return true;
    if (n % 2 == 0) return false;
    for (ll i = 3; i * i <= n; i += 2) {
        if (n % i == 0) return false;
    }
    return true;
}

// ─── 埃拉托斯特尼筛法(N 以内所有质数)───────────────────────────────────────
vector<bool> sieve(int N) {
    vector<bool> is_prime(N+1, true);
    is_prime[0] = is_prime[1] = false;
    for (int i = 2; i * i <= N; i++) {
        if (is_prime[i]) {
            for (int j = i*i; j <= N; j += i)
                is_prime[j] = false;
        }
    }
    return is_prime;
}

C.9 调试技巧

📄 查看代码:C.9 调试技巧
// 用 cerr 调试输出(评测机通常忽略标准错误)
#ifdef DEBUG
    #define dbg(x) cerr << #x << " = " << x << "\n"
    #define dbgv(v) cerr << #v << ": "; for(auto x:v) cerr << x << " "; cerr << "\n"
#else
    #define dbg(x)
    #define dbgv(v)
#endif
// 调试模式编译:g++ -DDEBUG -o sol sol.cpp  (启用调试输出)
// 正常编译:    g++ -o sol sol.cpp  (移除调试输出)

// 使用:
int x = 42;
dbg(x);         // 打印:x = 42(仅调试模式)
vi v = {1,2,3};
dbgv(v);        // 打印:v: 1 2 3(仅调试模式)

// 用消毒器检测内存错误和未定义行为:
// g++ -fsanitize=address,undefined -O1 -o sol sol.cpp
// 非常适合发现:
//   - 数组越界访问
//   - 整数溢出(带 -fsanitize=signed-integer-overflow)
//   - 使用未初始化内存
//   - 空指针解引用

树状数组(BIT)——带更新的前缀和

Binary Indexed Tree

树状数组使用最低置位技巧实现 O(log N) 的前缀和查询和更新。下标 i 负责范围 [i - lowbit(i) + 1, i],其中 lowbit(i) = i & (-i)

📄 C++ 完整代码
// 树状数组 / BIT——O(log N) 更新和前缀查询
struct BIT {
    int n;
    vector<long long> tree;
    BIT(int n) : n(n), tree(n + 1, 0) {}

    // 在位置 i 添加 val(1-indexed)
    void update(int i, long long val) {
        for (; i <= n; i += i & (-i))
            tree[i] += val;
    }

    // 前缀和 [1..i]
    long long query(int i) {
        long long sum = 0;
        for (; i > 0; i -= i & (-i))
            sum += tree[i];
        return sum;
    }

    // 区间和 [l..r]
    long long query(int l, int r) { return query(r) - query(l - 1); }
};

附录 D:竞赛常用算法模板

🏆 快速参考: 这些模板经过实战验证,可直接复制粘贴使用,专为算法竞赛场景设计。每个模板均注明了时间复杂度和典型应用场景。

在深入模板之前,先用这棵决策树,根据数据规模 N 选择合适的算法:

算法选择决策树


D.1 并查集(DSU / Union-Find)

适用场景: 动态连通性、Kruskal 最小生成树、环检测、元素分组。

复杂度: 每次操作 O(α(N))O(1)(阿克曼函数的反函数,极小的常数)。

📄 C++ 完整代码
// =============================================================
// DSU(并查集):路径压缩 + 按秩合并
// =============================================================
struct DSU {
    vector<int> parent, rank_;
    int components;  // 连通分量数

    DSU(int n) : parent(n), rank_(n, 0), components(n) {
        iota(parent.begin(), parent.end(), 0);  // parent[i] = i
    }

    // 带路径压缩的 find
    int find(int x) {
        if (parent[x] != x)
            parent[x] = find(parent[x]);  // 路径压缩
        return parent[x];
    }

    // 按秩合并:若真正合并了(两者在不同集合)则返回 true
    bool unite(int x, int y) {
        x = find(x); y = find(y);
        if (x == y) return false;  // 已连通
        if (rank_[x] < rank_[y]) swap(x, y);
        parent[y] = x;
        if (rank_[x] == rank_[y]) rank_[x]++;
        components--;
        return true;
    }

    bool connected(int x, int y) { return find(x) == find(y); }
};

// 使用示例:
int main() {
    int n = 5;
    DSU dsu(n);
    dsu.unite(0, 1);
    dsu.unite(2, 3);
    cout << dsu.connected(0, 1) << "\n";  // 1(连通)
    cout << dsu.connected(0, 2) << "\n";  // 0(不连通)
    cout << dsu.components << "\n";       // 3
    return 0;
}

D.2 线段树(单点更新,区间求和)

适用场景: 支持单点更新的区间求和/最大/最小查询。

复杂度: 建树 O(N),每次查询/更新 O(log N)

📄 C++ 完整代码
// =============================================================
// 线段树:单点更新,区间求和
// =============================================================
struct SegTree {
    int n;
    vector<long long> tree;

    SegTree(int n) : n(n), tree(4 * n, 0) {}

    void build(vector<long long>& arr, int node, int start, int end) {
        if (start == end) { tree[node] = arr[start]; return; }
        int mid = (start + end) / 2;
        build(arr, 2*node, start, mid);
        build(arr, 2*node+1, mid+1, end);
        tree[node] = tree[2*node] + tree[2*node+1];
    }
    void build(vector<long long>& arr) { build(arr, 1, 0, n-1); }

    void update(int node, int start, int end, int idx, long long val) {
        if (start == end) { tree[node] = val; return; }
        int mid = (start + end) / 2;
        if (idx <= mid) update(2*node, start, mid, idx, val);
        else update(2*node+1, mid+1, end, idx, val);
        tree[node] = tree[2*node] + tree[2*node+1];
    }
    // 将 arr[idx] 更新为 val
    void update(int idx, long long val) { update(1, 0, n-1, idx, val); }

    long long query(int node, int start, int end, int l, int r) {
        if (r < start || end < l) return 0;  // 求和的单位元
        if (l <= start && end <= r) return tree[node];
        int mid = (start + end) / 2;
        return query(2*node, start, mid, l, r)
             + query(2*node+1, mid+1, end, l, r);
    }
    // 查询 arr[l..r] 的区间和
    long long query(int l, int r) { return query(1, 0, n-1, l, r); }
};

// 使用示例:
int main() {
    vector<long long> arr = {1, 3, 5, 7, 9, 11};
    SegTree st(arr.size());
    st.build(arr);
    cout << st.query(2, 4) << "\n";   // 5+7+9 = 21
    st.update(2, 10);                 // arr[2] = 10
    cout << st.query(2, 4) << "\n";   // 10+7+9 = 26
    return 0;
}

D.3 BFS 模板

适用场景: 无权图/网格中的最短路径、层序遍历、多源距离。

复杂度: O(V + E)

📄 C++ 完整代码
// =============================================================
// BFS:无权图最短路径
// =============================================================
#include <bits/stdc++.h>
using namespace std;

// 返回 dist[],其中 dist[v] = src 到 v 的最短距离
// dist[v] = -1 表示不可达
vector<int> bfs(int src, int n, vector<vector<int>>& adj) {
    vector<int> dist(n, -1);
    queue<int> q;
    dist[src] = 0;
    q.push(src);
    while (!q.empty()) {
        int u = q.front(); q.pop();
        for (int v : adj[u]) {
            if (dist[v] == -1) {
                dist[v] = dist[u] + 1;
                q.push(v);
            }
        }
    }
    return dist;
}

// 网格 BFS(四方向)
const int dr[] = {-1, 1, 0, 0};
const int dc[] = {0, 0, -1, 1};

int gridBFS(vector<string>& grid, int sr, int sc, int er, int ec) {
    int R = grid.size(), C = grid[0].size();
    vector<vector<int>> dist(R, vector<int>(C, -1));
    queue<pair<int,int>> q;
    dist[sr][sc] = 0;
    q.push({sr, sc});
    while (!q.empty()) {
        auto [r, c] = q.front(); q.pop();
        for (int d = 0; d < 4; d++) {
            int nr = r + dr[d], nc = c + dc[d];
            if (nr >= 0 && nr < R && nc >= 0 && nc < C
                && grid[nr][nc] != '#' && dist[nr][nc] == -1) {
                dist[nr][nc] = dist[r][c] + 1;
                q.push({nr, nc});
            }
        }
    }
    return dist[er][ec];  // 不可达时返回 -1
}

D.4 DFS 模板

适用场景: 连通分量、环检测、拓扑排序、洪水填充。

复杂度: O(V + E)

📄 C++ 完整代码
// =============================================================
// DFS:迭代版与递归版模板
// =============================================================

vector<vector<int>> adj;
vector<int> color;  // 0=白(未访问),1=灰(栈中),2=黑(已完成)

// 递归 DFS + 环检测(有向图)
bool hasCycle = false;
void dfs(int u) {
    color[u] = 1;  // 标记为"进行中"
    for (int v : adj[u]) {
        if (color[v] == 0) dfs(v);
        else if (color[v] == 1) hasCycle = true;  // 后向边 → 有环!
    }
    color[u] = 2;  // 标记为"已完成"
}

// 利用 DFS 后序做拓扑排序
vector<int> topoOrder;
void dfsToposort(int u) {
    color[u] = 1;
    for (int v : adj[u]) {
        if (color[v] == 0) dfsToposort(v);
    }
    color[u] = 2;
    topoOrder.push_back(u);  // 处理完所有子节点后再加入
}
// 最后将 topoOrder 反转即得拓扑序列

// 迭代 DFS(避免大图递归时栈溢出)
void dfsIterative(int src, int n) {
    vector<bool> visited(n, false);
    stack<int> st;
    st.push(src);
    while (!st.empty()) {
        int u = st.top(); st.pop();
        if (visited[u]) continue;
        visited[u] = true;
        // 在此处理 u
        for (int v : adj[u]) {
            if (!visited[v]) st.push(v);
        }
    }
}

D.5 Dijkstra 算法

适用场景: 边权非负的有权图最短路径。

复杂度: O((V + E) log V)

📄 C++ 完整代码
// =============================================================
// Dijkstra 最短路 — O((V+E) log V)
// =============================================================
#include <bits/stdc++.h>
using namespace std;

typedef pair<long long, int> pli;  // {距离, 节点}
const long long INF = 1e18;

vector<long long> dijkstra(int src, int n,
                            vector<vector<pair<int,int>>>& adj) {
    // adj[u] = { {v, 边权}, ... }
    vector<long long> dist(n, INF);
    priority_queue<pli, vector<pli>, greater<pli>> pq;  // 小根堆

    dist[src] = 0;
    pq.push({0, src});

    while (!pq.empty()) {
        auto [d, u] = pq.top(); pq.pop();

        if (d > dist[u]) continue;  // ← 关键:跳过过时条目

        for (auto [v, w] : adj[u]) {
            if (dist[u] + w < dist[v]) {
                dist[v] = dist[u] + w;
                pq.push({dist[v], v});
            }
        }
    }

    return dist;  // dist[v] = src → v 的最短距离,INF 表示不可达
}

// 使用示例:
int main() {
    int n = 5;
    vector<vector<pair<int,int>>> adj(n);
    // 添加无向边 u-v,权重 w:
    auto addEdge = [&](int u, int v, int w) {
        adj[u].push_back({v, w});
        adj[v].push_back({u, w});
    };
    addEdge(0, 1, 4);
    addEdge(0, 2, 1);
    addEdge(2, 1, 2);
    addEdge(1, 3, 1);
    addEdge(2, 3, 5);

    auto dist = dijkstra(0, n, adj);
    cout << dist[3] << "\n";  // 4(路径 0→2→1→3,代价 1+2+1=4)
    return 0;
}

D.6 二分查找模板

适用场景: 在有序数组中搜索,或"对答案二分"(参数搜索)。

复杂度: 每次搜索 O(log N),对答案二分 O(f(N) × log V)

📄 C++ 完整代码
// =============================================================
// 二分查找模板
// =============================================================

// 1. 查找精确值(返回下标或 -1)
int binarySearch(vector<int>& arr, int target) {
    int lo = 0, hi = (int)arr.size() - 1;
    while (lo <= hi) {
        int mid = lo + (hi - lo) / 2;
        if (arr[mid] == target) return mid;
        else if (arr[mid] < target) lo = mid + 1;
        else hi = mid - 1;
    }
    return -1;
}

// 2. 第一个 arr[i] >= target 的下标(lower_bound)
int lowerBound(vector<int>& arr, int target) {
    int lo = 0, hi = (int)arr.size();
    while (lo < hi) {
        int mid = lo + (hi - lo) / 2;
        if (arr[mid] < target) lo = mid + 1;
        else hi = mid;
    }
    return lo;  // 若所有元素 < target 则返回 arr.size()
}

// 3. 第一个 arr[i] > target 的下标(upper_bound)
int upperBound(vector<int>& arr, int target) {
    int lo = 0, hi = (int)arr.size();
    while (lo < hi) {
        int mid = lo + (hi - lo) / 2;
        if (arr[mid] <= target) lo = mid + 1;
        else hi = mid;
    }
    return lo;
}

// 4. 对答案二分——找到最大的 X 使得 check(X) 为 true
// 模板:根据题目调整 lo、hi 和 check() 函数
long long bsOnAnswer(long long lo, long long hi,
                     function<bool(long long)> check) {
    long long answer = lo - 1;  // 哨兵:无合法答案
    while (lo <= hi) {
        long long mid = lo + (hi - lo) / 2;
        if (check(mid)) {
            answer = mid;
            lo = mid + 1;  // 尝试更大的答案
        } else {
            hi = mid - 1;
        }
    }
    return answer;
}

// STL 封装(实际使用中优先采用):
// lower_bound(v.begin(), v.end(), x) → 指向第一个 >= x 的迭代器
// upper_bound(v.begin(), v.end(), x) → 指向第一个 >  x 的迭代器
// binary_search(v.begin(), v.end(), x) → bool,判断 x 是否存在

lower_bound / upper_bound 速查表:

目标代码
第一个 ≥ x 的下标lower_bound(v.begin(), v.end(), x) - v.begin()
第一个 > x 的下标upper_bound(v.begin(), v.end(), x) - v.begin()
x 的出现次数upper_bound(..., x) - lower_bound(..., x)
最大的 ≤ x 的值prev(upper_bound(..., x)) (需确认存在)
最小的 ≥ x 的值*lower_bound(..., x) (需确认 < end)

D.7 模运算模板

适用场景: 大数运算、组合数学、大值 DP。

复杂度: 基本运算 O(1),快速幂 O(log exp)

📄 C++ 完整代码
// =============================================================
// 模运算模板
// =============================================================
const long long MOD = 1e9 + 7;  // 或 998244353(NTT 友好素数)

long long mod(long long x) { return ((x % MOD) + MOD) % MOD; }
long long add(long long a, long long b) { return (a + b) % MOD; }
long long sub(long long a, long long b) { return mod(a - b); }
long long mul(long long a, long long b) { return a % MOD * (b % MOD) % MOD; }

// 快速幂:base^exp mod MOD — O(log exp)
long long power(long long base, long long exp, long long mod = MOD) {
    long long result = 1;
    base %= mod;
    while (exp > 0) {
        if (exp & 1) result = result * base % mod;  // 当前位为 1
        base = base * base % mod;                    // 底数平方
        exp >>= 1;                                   // 右移
    }
    return result;
}

// 模逆元(base^(MOD-2) mod MOD,仅当 MOD 为素数时成立)
long long inv(long long x) { return power(x, MOD - 2); }

// 模意义下的除法
long long divide(long long a, long long b) { return mul(a, inv(b)); }

// 预处理阶乘,用于组合数
const int MAXN = 200005;
long long fact[MAXN], inv_fact[MAXN];

void precompute_factorials() {
    fact[0] = 1;
    for (int i = 1; i < MAXN; i++) fact[i] = fact[i-1] * i % MOD;
    inv_fact[MAXN-1] = inv(fact[MAXN-1]);
    for (int i = MAXN-2; i >= 0; i--) inv_fact[i] = inv_fact[i+1] * (i+1) % MOD;
}

// C(n, k) = 组合数 mod MOD
long long C(int n, int k) {
    if (k < 0 || k > n) return 0;
    return fact[n] * inv_fact[k] % MOD * inv_fact[n-k] % MOD;
}

D.8 快速幂(二进制取幂)

适用场景: 计算大指数 a^b(独立版或模运算版)。

复杂度: O(log b)

📄 C++ 完整代码
// =============================================================
// 二进制取幂:O(log b) 计算 a^b
// =============================================================

// 整数幂(无取模)——大 a、b 时注意溢出
long long fastPow(long long a, long long b) {
    long long result = 1;
    while (b > 0) {
        if (b & 1) result *= a;  // 当前位为 1
        a *= a;                   // 底数平方
        b >>= 1;                  // 下一位
    }
    return result;
}

// 模意义快速幂:a^b mod m
long long modPow(long long a, long long b, long long m) {
    long long result = 1;
    a %= m;
    while (b > 0) {
        if (b & 1) result = result * a % m;
        a = a * a % m;
        b >>= 1;
    }
    return result;
}

// 矩阵快速幂:用于 O(log N) 计算 Fibonacci 等
typedef vector<vector<long long>> Matrix;
// 注意:使用 D.7 中定义的 MOD(const long long MOD = 1e9 + 7)

Matrix multiply(const Matrix& A, const Matrix& B) {
    int n = A.size();
    Matrix C(n, vector<long long>(n, 0));
    for (int i = 0; i < n; i++)
        for (int k = 0; k < n; k++)
            if (A[i][k])
                for (int j = 0; j < n; j++)
                    C[i][j] = (C[i][j] + A[i][k] * B[k][j]) % MOD;
    return C;
}

Matrix matPow(Matrix M, long long b) {
    int n = M.size();
    Matrix result(n, vector<long long>(n, 0));
    for (int i = 0; i < n; i++) result[i][i] = 1;  // 单位矩阵
    while (b > 0) {
        if (b & 1) result = multiply(result, M);
        M = multiply(M, M);
        b >>= 1;
    }
    return result;
}

// 示例:O(log N) 计算 Fibonacci(N)
// [F(n+1)]   [1 1]^n   [F(1)]
// [F(n)  ] = [1 0]   * [F(0)]
long long fibonacci(long long n) {
    if (n <= 1) return n;
    Matrix M = {{1, 1}, {1, 0}};
    Matrix result = matPow(M, n - 1);
    return result[0][0];  // F(n)
}

D.9 其他实用模板

前缀和(一维与二维)

📄 查看代码:前缀和(一维与二维)
// 一维前缀和
vector<long long> prefSum(n + 1, 0);
for (int i = 1; i <= n; i++) prefSum[i] = prefSum[i-1] + arr[i];
// 查询 arr[l..r] 的区间和(1-indexed):prefSum[r] - prefSum[l-1]

// 二维前缀和
long long psum[N+1][M+1] = {};
for (int i = 1; i <= N; i++)
    for (int j = 1; j <= M; j++)
        psum[i][j] = grid[i][j] + psum[i-1][j] + psum[i][j-1] - psum[i-1][j-1];
// 查询矩形 [r1,c1]..[r2,c2] 的区间和:
// psum[r2][c2] - psum[r1-1][c2] - psum[r2][c1-1] + psum[r1-1][c1-1]

竞赛编程头文件模板

📄 查看代码:竞赛编程头文件模板
// 竞赛编程标准头文件模板
#include <bits/stdc++.h>
using namespace std;

typedef long long ll;
typedef pair<int,int> pii;
typedef vector<int> vi;
typedef vector<ll> vll;

#define all(x) x.begin(), x.end()
#define sz(x) (int)(x).size()
#define pb push_back
#define mp make_pair

const int INF = 1e9;
const ll LINF = 1e18;
const int MOD = 1e9 + 7;

int main() {
    ios_base::sync_with_stdio(false);
    cin.tie(NULL);
    // 在此填写你的解答
    return 0;
}

速查卡片

算法复杂度需要包含的头文件
并查集(DSU)O(α(N)) 每次操作
线段树O(N) 建树,O(log N) 每次操作
BFSO(V+E)<queue>
DFSO(V+E)<stack>
DijkstraO((V+E) log V)<queue>
二分查找O(log N)<algorithm>
排序O(N log N)<algorithm>
模意义快速幂O(log exp)
lower/upper_boundO(log N)<algorithm>

所有示例均在 C++17(-std=c++17 -O2)下编译通过并经过验证。

📎 附录 E ⏱️ 约 50 分钟 🎯 参考资料 数学

附录 E:竞赛编程数学基础

💡 关于本附录: 算法竞赛中经常需要用到基础算术之外的数学工具。本附录涵盖 USACO Bronze、Silver、Gold 中常见的核心数学知识,并为每个主题提供可直接用于竞赛的代码模板。


E.1 模运算

为什么需要模运算?

很多题目要求输出"答案对 10⁹ + 7 取模"的结果。这并非随意为之——它是为了在答案极其庞大时防止整数溢出

举个例子:"N 个元素的排列有多少种?"答案是 N!。当 N = 20 时,N! = 2,432,902,008,176,640,000,已超过 long long 的最大值(约 9.2 × 10¹⁸)。当 N = 100 时,根本无法表示。

解决方案: 对一个素数 M(通常取 10⁹ + 7)取模,将所有运算在模意义下进行。

(a + b) mod M = ((a mod M) + (b mod M)) mod M (a × b) mod M = ((a mod M) × (b mod M)) mod M (a - b) mod M = ((a mod M) - (b mod M) + M) mod M ← 注意加上 M!

时钟类比与关键性质——记住每次算术运算后都要取模:

模运算性质

常用模数

常量数值为何选这个值?
1e9 + 71,000,000,007素数,适合 int(< 2³¹),使用最广泛
1e9 + 91,000,000,009素数,1e9+7 的备选
998244353998,244,353NTT 友好素数(用于多项式运算)

基础模运算模板

📄 查看代码:基础模运算模板
// 解答:模运算基础
#include <bits/stdc++.h>
using namespace std;

typedef long long ll;
const ll MOD = 1e9 + 7;  // 竞赛编程标准模数

// 安全加法:(a + b) % MOD
ll addMod(ll a, ll b) {
    return (a % MOD + b % MOD) % MOD;
}

// 安全减法:(a - b + MOD) % MOD(处理负数结果)
ll subMod(ll a, ll b) {
    return ((a % MOD) - (b % MOD) + MOD) % MOD;  // 加 MOD 防止负数!
}

// 安全乘法:(a * b) % MOD
// 关键:a 和 b 最大为 MOD-1 ≈ 10^9,乘积 ≈ 10^18 在 long long 范围内
ll mulMod(ll a, ll b) {
    return (a % MOD) * (b % MOD) % MOD;
}

// 示例:计算前 N 个正整数之和 mod MOD
ll sumFirstN(ll n) {
    // 公式 n*(n+1)/2,但除法需要用模逆元
    // 暂时用逐步累加:
    ll result = 0;
    for (ll i = 1; i <= n; i++) {
        result = addMod(result, i);
    }
    return result;
}

⚠️ 致命 Bug: 在 C++ 中,若 a < b(a - b) % MOD 可能为负数!请始终写成 (a - b + MOD) % MOD

E.1.1 快速幂(二进制取幂)

朴素方式计算 a^n mod M 需要 O(N) 次乘法。快速幂(反复平方法)只需 O(log N) 次。

核心思路:a^n = a^(n/2) × a^(n/2)          若 n 为偶数
              a^n = a × a^((n-1)/2) × a^((n-1)/2)  若 n 为奇数

示例:a^13 = a^(1101₂)
           = a^8 × a^4 × a^1
           = 3 次乘法,而非 12 次!
📄 C++ 完整代码
// 解答:模意义快速幂 — O(log n)
// 计算 (base^exp) % mod
ll power(ll base, ll exp, ll mod = MOD) {
    ll result = 1;
    base %= mod;                  // 先对底数取模
    
    while (exp > 0) {
        if (exp & 1) {            // 若当前位为 1
            result = result * base % mod;
        }
        base = base * base % mod; // 底数平方
        exp >>= 1;                // 移动到下一位
    }
    return result;
}

// 使用示例:
// power(2, 10) = 1024 % MOD = 1024
// power(2, 100, MOD) = 2^100 mod (10^9+7)

E.1.2 模逆元(费马小定理)

a 对模 M模逆元是一个数 a⁻¹,满足 a × a⁻¹ ≡ 1 (mod M)

这让我们可以进行模意义下的除法a / b mod M = a × b⁻¹ mod M

费马小定理: 若 M 为素数且 gcd(a, M) = 1,则:

a^(M-1) ≡ 1 (mod M) ⟹ a^(M-2) ≡ a⁻¹ (mod M)
📄
// 解答:利用费马小定理求模逆元
// 仅在 MOD 为素数且 gcd(a, MOD) = 1 时适用
ll modInverse(ll a, ll mod = MOD) {
    return power(a, mod - 2, mod);
}

// 模意义下的除法:
ll divMod(ll a, ll b) {
    return mulMod(a, modInverse(b));
}

// 示例:(n! / k!) mod MOD
// = n! × (k!)^(-1) mod MOD
// = n! × modInverse(k!) mod MOD

E.1.3 预处理阶乘与逆元

对于需要多次计算组合数 C(n, k) 的题目:

📄 对于需要多次计算组合数 `C(n, k)` 的题目:
// 解答:预处理阶乘,O(1) 查询组合数
const int MAXN = 1000005;
ll fact[MAXN], inv_fact[MAXN];

void precompute() {
    fact[0] = 1;
    for (int i = 1; i < MAXN; i++) {
        fact[i] = fact[i-1] * i % MOD;
    }
    inv_fact[MAXN-1] = modInverse(fact[MAXN-1]);
    for (int i = MAXN-2; i >= 0; i--) {
        inv_fact[i] = inv_fact[i+1] * (i+1) % MOD;
    }
}

// C(n, k) = n! / (k! * (n-k)!)
ll C(int n, int k) {
    if (k < 0 || k > n) return 0;
    return fact[n] * inv_fact[k] % MOD * inv_fact[n-k] % MOD;
}

// 用法:调用一次 precompute(),之后 C(n, k) 均为 O(1)

E.2 GCD 与 LCM

欧几里得算法

两个数的最大公因数(GCD)是能同时整除两者的最大整数。

欧几里得算法: 基于 gcd(a, b) = gcd(b, a % b)

每次递归调用都会缩小问题规模,逐步推导的过程一目了然:

GCD 欧几里得算法

📄 ![GCD 欧几里得算法](../images/gcd_euclidean.svg)
// 解答:GCD — O(log(min(a,b)))
int gcd(int a, int b) {
    while (b != 0) {
        a %= b;
        swap(a, b);
    }
    return a;
}
// 递归写法:
// int gcd(int a, int b) { return b == 0 ? a : gcd(b, a % b); }

// C++17:<numeric> 中的 std::gcd
// int g = gcd(a, b);           // std::gcd,C++17(推荐)
// int g = __gcd(a, b);         // 旧版 GCC 内建函数,仍可用

追踪示例: gcd(48, 18)

gcd(48, 18) → gcd(18, 48%18=12) → gcd(12, 18%12=6) → gcd(6, 0) = 6

LCM 与溢出陷阱

📄 查看代码:LCM 与溢出陷阱
// 解答:LCM — 注意溢出!

// 错误写法:大数相乘时溢出
long long lcmWrong(long long a, long long b) {
    return a * b / gcd(a, b);  // a*b 即使是 long long 也可能溢出!
}

// 正确写法:先除后乘
long long lcm(long long a, long long b) {
    return a / gcd(a, b) * b;  // 先除再乘
}
// a / gcd(a,b) 一定是整数,不损失精度
// 然后乘以 b:最大约 10^18,在 long long 范围内
lcm(a, b) = a × b / gcd(a, b) = (a / gcd(a, b)) × b

⚠️ 始终先除后乘,避免溢出!

扩展欧几里得算法

找整数 x, y 满足 ax + by = gcd(a, b)——在 MOD 不是素数时计算模逆元非常有用:

📄 找整数 x, y 满足 `ax + by = gcd(a, b)`——在 MOD 不是素数时计算模逆元非常有用:
// 解答:扩展欧几里得算法 — O(log(min(a,b)))
// 返回 gcd(a,b),并设置 x,y 使得 a*x + b*y = gcd(a,b)
long long extgcd(long long a, long long b, long long &x, long long &y) {
    if (b == 0) { x = 1; y = 0; return a; }
    long long x1, y1;
    long long g = extgcd(b, a % b, x1, y1);
    x = y1;
    y = x1 - (a / b) * y1;
    return g;
}

// 使用 extgcd 求模逆元(即使 MOD 不是素数也适用):
long long modInverseExtGcd(long long a, long long mod) {
    long long x, y;
    long long g = extgcd(a, mod, x, y);
    if (g != 1) return -1;  // 无逆元(gcd != 1)
    return (x % mod + mod) % mod;
}

E.3 质数与筛法

试除法

📄 查看代码:试除法
// 解答:试除法判素 — O(sqrt(N))
bool isPrime(long long n) {
    if (n < 2) return false;
    if (n == 2) return true;
    if (n % 2 == 0) return false;
    for (long long i = 3; i * i <= n; i += 2) {
        if (n % i == 0) return false;
    }
    return true;
}
// 高效原因:若 n 有大于 sqrt(n) 的因子,必然也有小于等于 sqrt(n) 的因子
// 只检查 2 之后的奇数(迭代次数减半)

埃拉托色尼筛(埃筛)

高效找出 N 以内的所有质数:

📄 高效找出 N 以内的所有质数:
// 解答:埃筛 — O(N log log N) 时间,O(N) 空间
// 运行后,isPrime[i] = true 当且仅当 i 是质数
const int MAXN = 1000005;
bool isPrime[MAXN];

void sieve(int n) {
    fill(isPrime, isPrime + n + 1, true);  // 初始假设全是质数
    isPrime[0] = isPrime[1] = false;        // 0 和 1 不是质数
    
    for (int i = 2; (long long)i * i <= n; i++) {
        if (isPrime[i]) {
            // 标记 i 的所有倍数为合数
            for (int j = i * i; j <= n; j += i) {
                isPrime[j] = false;
                // 从 i*i 开始(更小的倍数已被更小的素数标记过)
            }
        }
    }
}

// 统计 N 以内的质数个数:
void countPrimes(int n) {
    sieve(n);
    int count = 0;
    for (int i = 2; i <= n; i++) {
        if (isPrime[i]) count++;
    }
    cout << count << "\n";
}

为什么内层循环从 i² 开始? i 的所有小于 i² 的倍数(如 2i, 3i, ..., (i-1)i)已被更小的质数(2, 3, ..., i-1)标记过。从 i² 开始可以避免冗余操作。

线性筛(欧拉筛)— O(N)

欧拉筛确保每个合数只被标记一次:

📄 欧拉筛确保每个合数只被标记一次:
// 解答:线性筛(欧拉筛)— O(N) 时间
// 同时计算每个数的最小质因子(SPF)
const int MAXN = 1000005;
int spf[MAXN];      // 最小质因子
vector<int> primes;

void linearSieve(int n) {
    fill(spf, spf + n + 1, 0);
    for (int i = 2; i <= n; i++) {
        if (spf[i] == 0) {          // i 是质数
            spf[i] = i;
            primes.push_back(i);
        }
        for (int j = 0; j < (int)primes.size() && primes[j] <= spf[i] && (long long)i * primes[j] <= n; j++) {
            spf[i * primes[j]] = primes[j];  // 标记合数
        }
    }
}

// 利用 SPF 快速分解质因数:
// 每次分解 O(log N)
vector<int> factorize(int n) {
    vector<int> factors;
    while (n > 1) {
        factors.push_back(spf[n]);
        n /= spf[n];
    }
    return factors;
}

E.4 二进制表示与位运算

基本位操作

📄 查看代码:基本位操作
// 解答:常用位运算参考
int n = 42;   // 二进制:101010

// ── AND(&):两位都为 1 才为 1 ──
int a = 6 & 3;     // 110 & 011 = 010 = 2

// ── OR(|):至少一位为 1 则为 1 ──
int b = 6 | 3;     // 110 | 011 = 111 = 7

// ── XOR(^):恰好一位为 1 才为 1 ──
int c = 6 ^ 3;     // 110 ^ 011 = 101 = 5

// ── NOT(~):翻转所有位(补码) ──
int d = ~6;        // = -7(补码表示)

// ── 左移(<<):乘以 2^k ──
int e = 1 << 4;    // = 16 = 2^4

// ── 右移(>>):除以 2^k(算术右移) ──
int f = 32 >> 2;   // = 8 = 32/4

竞赛常用位运算技巧

📄 查看代码:竞赛常用位运算技巧
// 解答:竞赛编程位运算技巧

// ── 判断 n 是否为奇数 ──
bool isOdd(int n) { return n & 1; }  // 最低位为 1 当且仅当 n 为奇数

// ── 判断 n 是否为 2 的幂次 ──
bool isPow2(int n) { return n > 0 && (n & (n-1)) == 0; }
// 原因:2 的幂次:1=001, 2=010, 4=100。n-1 会翻转所有低位。
// 4 & 3 = 100 & 011 = 000。非 2 的幂次:6 & 5 = 110 & 101 = 100 ≠ 0。

// ── 取第 k 位(从右 0 开始计数) ──
bool getBit(int n, int k) { return (n >> k) & 1; }

// ── 将第 k 位置 1 ──
int setBit(int n, int k) { return n | (1 << k); }

// ── 将第 k 位清零 ──
int clearBit(int n, int k) { return n & ~(1 << k); }

// ── 翻转第 k 位 ──
int toggleBit(int n, int k) { return n ^ (1 << k); }

// ── lowbit:最低位的 1(树状数组中常用!) ──
int lowbit(int n) { return n & (-n); }
// 示例:lowbit(12) = lowbit(1100) = 0100 = 4

// ── 统计置位数(popcount) ──
int popcount(int n) { return __builtin_popcount(n); }   // 使用内建函数!
// long long 版本:__builtin_popcountll(n)

// ── 不用临时变量交换两数 ──
void swapXOR(int &a, int &b) {
    a ^= b;
    b ^= a;
    a ^= b;
}
// (通常直接用 std::swap——这主要是一个技巧性写法)

// ── 找最低位 1 的位置 ──
int lowestBitPos(int n) { return __builtin_ctz(n); }  // 统计尾部零个数
// __builtin_clz(n) = 统计前导零个数

子集枚举

一个强大的技巧:用位掩码枚举集合的所有子集。

📄 一个强大的技巧:用位掩码枚举集合的所有子集。
// 解答:位掩码子集枚举
// 枚举 N 元素集合的所有子集

void enumerateAllSubsets(int n) {
    // 共 2^n 个子集
    for (int mask = 0; mask < (1 << n); mask++) {
        // mask 表示一个子集:第 i 位为 1 表示包含元素 i
        cout << "子集: {";
        for (int i = 0; i < n; i++) {
            if (mask & (1 << i)) {
                cout << i << " ";
            }
        }
        cout << "}\n";
    }
}

// 枚举给定集合 S 的所有非空子集
void enumerateSubsetsOf(int S) {
    for (int sub = S; sub > 0; sub = (sub - 1) & S) {
        // 处理子集 sub
        // 技巧:(sub-1) & S 得到 S 的"下一个更小"子集
        // 摊还 O(1) 步枚举 S 的全部 2^|S| 个子集
    }
}

// 经典应用:状压 DP
// dp[mask] = 访问 mask 所表示的城市集合所需的最小代价
// dp[0] = 0(初始:没有访问任何城市)
// dp[mask | (1 << v)] = min(dp[mask | (1 << v)], dp[mask] + cost[last][v])

E.5 组合数学基础

计数公式

排列数:P(n, k) = n! / (n-k)! — 从 n 个中有序选 k 个 组合数:C(n, k) = n! / (k! × (n-k)!) — 从 n 个中无序选 k 个
📄
// 解答:带模运算的组合数学
// 假设已调用 E.1.3 中的 precompute()

// C(n, k) = n! / (k! * (n-k)!)
ll combination(int n, int k) {
    if (k < 0 || k > n) return 0;
    return fact[n] * inv_fact[k] % MOD * inv_fact[n-k] % MOD;
}

// P(n, k) = n! / (n-k)!
ll permutation(int n, int k) {
    if (k < 0 || k > n) return 0;
    return fact[n] * inv_fact[n-k] % MOD;
}

// 隔板法(星与条):将 n 个相同球放入 k 个不同盒的方案数
// = C(n + k - 1, k - 1)
ll starsAndBars(int n, int k) {
    return combination(n + k - 1, k - 1);
}

帕斯卡三角——无需预处理直接计算 C(n, k)

当 n 较小(n ≤ 2000)时,帕斯卡三角更简洁:

📄 当 n 较小(n ≤ 2000)时,帕斯卡三角更简洁:
// 解答:帕斯卡三角 DP — O(n^2) 预处理
const int MAXN = 2005;
ll C[MAXN][MAXN];

void buildPascal() {
    for (int i = 0; i < MAXN; i++) {
        C[i][0] = C[i][i] = 1;
        for (int j = 1; j < i; j++) {
            C[i][j] = (C[i-1][j-1] + C[i-1][j]) % MOD;
        }
    }
}
// 之后 C[n][k] 即为 0 <= k <= n < MAXN 时的组合数
// 完全避免了模逆元——当 MOD 可能不是素数时特别有用

帕斯卡恒等式: C(n, k) = C(n-1, k-1) + C(n-1, k)

含义:"从 n 个中选 k 个" = "包含第 n 个元素,从 n-1 个中选 k-1 个" + "不包含第 n 个元素,从 n-1 个中选 k 个"。

常用组合恒等式

📄 查看代码:常用组合恒等式
// 竞赛中常用的恒等式:

// 曲棍球恒等式:sum_{k=0}^{n} C(r+k, k) = C(n+r+1, n)
// 用途:二维前缀和、多项式求值

// 范德蒙德恒等式:sum_k C(m,k)*C(n,r-k) = C(m+n, r)
// 用途:涉及两组元素的计数问题

// 容斥原理:
// |A ∪ B| = |A| + |B| - |A ∩ B|
// |A ∪ B ∪ C| = |A| + |B| + |C| - |A∩B| - |A∩C| - |B∩C| + |A∩B∩C|
// 推广到 n 个集合时有 2^n 项(或用位掩码枚举)

E.6 常用数学结论(复杂度分析)

调和级数

1 + 1/2 + 1/3 + ... + 1/N ≈ ln(N) ≈ 0.693 × log₂(N)

这解释了为何埃筛运行时间为 O(N log log N)

以及为何树状数组操作是 O(log N):lowbit 操作每次前进 1、2、4...位。

关键估算值

表达式近似值含义
log₂(10⁵)≈ 1710⁵ 个元素的 BST/线段树深度
log₂(10⁹)≈ 30在 10⁹ 范围内的二分查找步数
√(10⁶)= 1000N ≤ 10⁶ 时试除法的上界
2²⁰≈ 10⁶状压 DP 上限(20 个元素)
20!≈ 2.4 × 10¹⁸勉强放进 long long
13!≈ 6 × 10⁹略超 int 上限

每秒操作数估算

时间限制安全操作数上限
1 秒~10⁸ 次简单操作
2 秒~2 × 10⁸
3 秒~3 × 10⁸

据此可以估算算法是否够快:


E.7 完整数学模板

以下是整合了本附录所有模板的单文件版本:

📄 以下是整合了本附录所有模板的单文件版本:
// 解答:竞赛编程完整数学模板
#include <bits/stdc++.h>
using namespace std;
typedef long long ll;
typedef unsigned long long ull;

// ═══════════════════════════════════════════════
// 模运算
// ═══════════════════════════════════════════════
const ll MOD = 1e9 + 7;

ll power(ll base, ll exp, ll mod = MOD) {
    ll result = 1;
    base %= mod;
    while (exp > 0) {
        if (exp & 1) result = result * base % mod;
        base = base * base % mod;
        exp >>= 1;
    }
    return result;
}

ll modInverse(ll a, ll mod = MOD) {
    return power(a, mod - 2, mod);
}

// ═══════════════════════════════════════════════
// 阶乘(预处理至 MAXN)
// ═══════════════════════════════════════════════
const int MAXN = 1000005;
ll fact[MAXN], inv_fact[MAXN];

void precomputeFactorials() {
    fact[0] = 1;
    for (int i = 1; i < MAXN; i++) fact[i] = fact[i-1] * i % MOD;
    inv_fact[MAXN-1] = modInverse(fact[MAXN-1]);
    for (int i = MAXN-2; i >= 0; i--) inv_fact[i] = inv_fact[i+1] * (i+1) % MOD;
}

ll C(int n, int k) {
    if (k < 0 || k > n) return 0;
    return fact[n] * inv_fact[k] % MOD * inv_fact[n-k] % MOD;
}

// ═══════════════════════════════════════════════
// GCD / LCM
// ═══════════════════════════════════════════════
ll gcd(ll a, ll b) { return b == 0 ? a : gcd(b, a % b); }
ll lcm(ll a, ll b)  { return a / gcd(a, b) * b; }

// ═══════════════════════════════════════════════
// 质数筛
// ═══════════════════════════════════════════════
const int MAXP = 1000005;
bool notPrime[MAXP];
vector<int> primes;

void sieve(int n = MAXP - 1) {
    notPrime[0] = notPrime[1] = true;
    for (int i = 2; i <= n; i++) {
        if (!notPrime[i]) {
            primes.push_back(i);
            for (long long j = (long long)i*i; j <= n; j += i)
                notPrime[j] = true;
        }
    }
}

bool isPrime(int n) { return n >= 2 && !notPrime[n]; }

// ═══════════════════════════════════════════════
// 位运算技巧
// ═══════════════════════════════════════════════
bool isOdd(int n)       { return n & 1; }
bool isPow2(int n)      { return n > 0 && !(n & (n-1)); }
int  lowbit(int n)      { return n & (-n); }
int  popcount(int n)    { return __builtin_popcount(n); }
int  ctz(int n)         { return __builtin_ctz(n); }  // 尾部零个数

// ═══════════════════════════════════════════════
// 扩展 GCD
// ═══════════════════════════════════════════════
ll extgcd(ll a, ll b, ll &x, ll &y) {
    if (!b) { x = 1; y = 0; return a; }
    ll x1, y1, g = extgcd(b, a%b, x1, y1);
    x = y1; y = x1 - a/b * y1;
    return g;
}

int main() {
    ios_base::sync_with_stdio(false);
    cin.tie(NULL);
    
    precomputeFactorials();
    sieve();
    
    // 测试:C(10, 3) = 120
    cout << C(10, 3) << "\n";
    
    // 测试:2^100 mod (10^9+7)
    cout << power(2, 100) << "\n";
    
    // 测试:输出前 10 个质数
    for (int i = 0; i < 10; i++) cout << primes[i] << " ";
    cout << "\n";
    
    return 0;
}

E.8 数论快速参考

整除规则(手工验算用)

除数规则
2末位为偶数
3各位数字之和能被 3 整除
4末两位组成的数能被 4 整除
5末位为 0 或 5
9各位数字之和能被 9 整除
10末位为 0
11各位数字的交替和能被 11 整除

整数平方根

// 安全的整数平方根(避免浮点误差)
ll isqrt(ll n) {
    ll x = sqrtl(n);              // 浮点近似值
    while (x * x > n) x--;        // 若偏大则向下修正
    while ((x+1) * (x+1) <= n) x++; // 若偏小则向上修正
    return x;
}

向上取整除法

// 正整数的向上取整除法:ceil(a/b)
ll ceilDiv(ll a, ll b) {
    return (a + b - 1) / b;
    // 等价写法:(a - 1) / b + 1(a > 0 时相同)
}

❓ 常见问题

Q1:什么时候应该用 long long

A:当数值可能超过 2 × 10⁹(大约是 int 的上限)时。典型场景:① 两个大 int 相乘(10⁹ × 10⁹ = 10¹⁸);② 累加路径权重(N 条边,每条权重最大 10⁶,合计最大 10¹¹);③ 阶乘/组合数(即使取模,中间计算也用 long long)。经验法则:竞赛代码中只要有乘法,就用 long long

Q2:为什么用 10⁹ + 7 而非 10⁹

A:10⁹ 不是素数(= 2⁹ × 5⁹),无法用费马小定理求模逆元。10⁹ + 7 = 1,000,000,007 是素数,且 (10⁹ + 7)² < 2⁶³long long 的上限),因此取模后的两数相乘不会溢出 long long

Q3:快速幂中的位运算技巧是怎么工作的?

A:将指数 n 写成二进制:n = b_k × 2^k + ... + b_1 × 2 + b_0。那么 a^n = a^(b_k × 2^k) × ... × a^(b_1 × 2) × a^b_0。每次循环将底数平方(代表 a 的 2^k 次幂),当前位为 1 时乘入结果。只需 log₂(n) 次乘法。

Q4:埃筛的内层循环为什么从 i×i 开始?

A:i 的倍数 2i, 3i, ..., (i-1)i 已被更小的质数 2, 3, ..., i-1 标记过。例如,6 = 2×3 已被 2 标记;7×5=35 已被 5 标记。从 i×i 开始可以避免冗余,优化常数因子。

Q5:为什么 n & (n-1) 能检测 n 是否为 2 的幂次?

A:2 的幂次在二进制中只有一个 1 位(如 8 = 1000)。减 1 会把最低的 1 位变为 0,并把其下所有 0 位翻转为 1(如 7 = 0111)。因此 n & (n-1) 清除了最低 1 位。若 n 是 2 的幂次(只有一个 1 位),结果为 0;否则不为 0。


附录 E 结束 — 另请参阅:算法模板库 | 竞赛编程技巧

📖 附录 G:数学算法基础

⏱ 预计阅读时间:50 分钟 | 难度:🟡 中等


前置条件

在学习本附录之前,请确保你已掌握:


🎯 学习目标

学完本附录后,你将能够:

  1. 用埃氏筛和欧拉线性筛高效枚举质数
  2. 用快速幂在 O(log N) 内计算大幂次取模
  3. 用扩展欧几里得求逆元
  4. 理解区间 DP 的核心框架并解决石子合并类问题
  5. 运用矩阵快速幂加速线性递推

G.1 质数筛法

为什么需要筛法?

判断一个数是否是质数,朴素做法是枚举 [2, √N],时间 O(√N)。
但若要一次性求出 [2, N] 内所有质数,朴素做法是 O(N√N),当 N = 10^7 时太慢。

筛法 利用「质数的倍数是合数」这一事实,批量标记合数。


G.1.1 埃拉托斯特尼筛(埃氏筛)

核心思想:
从 2 开始,每发现一个质数,就把它的所有倍数标记为合数。

📄 从 2 开始,每发现一个质数,就把它的所有倍数标记为合数。
筛选 N = 20 的过程:

初始:2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20

标记 2 的倍数(从 2*2=4 开始):
  删除 4, 6, 8, 10, 12, 14, 16, 18, 20

标记 3 的倍数(从 3*3=9 开始):
  删除 9, 15  (12, 18 已经被 2 删除)

4 已被删除,跳过

标记 5 的倍数(从 5*5=25 > 20,停止)

最终质数:2, 3, 5, 7, 11, 13, 17, 19

从 ii 而不是 2i 开始的原因:
2×i, 3×i, ..., (i-1)×i 这些倍数,已经被更小的质数筛过了(因为它们有更小的质因子)。

📄 2×i, 3×i, ..., (i-1)×i 这些倍数,已经被更小的质数筛过了(因为它们有更小的质因子)。
#include <bits/stdc++.h>
using namespace std;

const int MAXN = 1e7 + 5;
bool is_prime[MAXN];   // is_prime[i] = true 表示 i 是质数
vector<int> primes;    // 所有质数列表

// 埃氏筛:筛出 [2, n] 内的所有质数
// 时间复杂度:O(N log log N)
void sieve_eratosthenes(int n) {
    fill(is_prime + 2, is_prime + n + 1, true);  // 初始全是质数
    
    for (int i = 2; (long long)i * i <= n; i++) {
        if (is_prime[i]) {
            // 从 i*i 开始标记(更小的倍数已经被筛过)
            for (int j = i * i; j <= n; j += i)
                is_prime[j] = false;
        }
    }
    
    // 收集质数
    for (int i = 2; i <= n; i++)
        if (is_prime[i]) primes.push_back(i);
}

int main() {
    sieve_eratosthenes(100);
    
    cout << "100 以内的质数:";
    for (int p : primes) cout << p << " ";
    cout << endl;
    cout << "质数个数:" << primes.size() << endl;
    // 输出:2 3 5 7 11 13 17 19 23 29 31 37 41 43 47 53 59 61 67 71 73 79 83 89 97
    // 个数:25
    return 0;
}

G.1.2 欧拉线性筛(最优筛法)

埃氏筛的问题:一个合数可能被多个质数重复标记(如 12 被 2 和 3 各标记一次)。

欧拉线性筛 保证每个合数只被其最小质因子筛一次,时间复杂度严格 O(N)。

核心规则: 对于每个 i,只用「i 乘以不超过 i 的最小质因子的那些质数」来筛合数。

📄 C++ 完整代码
const int MAXN = 1e7 + 5;
int min_prime[MAXN];   // min_prime[i] = i 的最小质因子
vector<int> primes;

// 欧拉线性筛:O(N)
void sieve_linear(int n) {
    fill(min_prime, min_prime + n + 1, 0);  // 0 表示还没被筛
    
    for (int i = 2; i <= n; i++) {
        if (!min_prime[i]) {
            // i 没有被任何更小的数筛过 → i 是质数
            min_prime[i] = i;   // 质数的最小质因子是自身
            primes.push_back(i);
        }
        
        // 用 i 去筛 i 的倍数
        for (int p : primes) {
            if ((long long)i * p > n) break;
            min_prime[i * p] = p;   // p 是 i*p 的最小质因子
            if (i % p == 0) {
                // p 是 i 的最小质因子
                // 若继续用更大的质数 q 筛 i*q,
                // i*q 的最小质因子仍是 p(因为 p | i | i*q)
                // 所以 i*q 会被 (i/p)*p*q 中较小的部分筛到
                // 此处终止,保证每个合数只被筛一次
                break;
            }
        }
    }
}

int main() {
    sieve_linear(30);
    
    cout << "30 以内的质数:";
    for (int p : primes) cout << p << " ";
    cout << endl;
    
    // 利用最小质因子做质因数分解
    auto factorize = [&](int x) {
        cout << x << " = ";
        while (x > 1) {
            int p = min_prime[x];
            int cnt = 0;
            while (x % p == 0) { x /= p; cnt++; }
            cout << p << "^" << cnt << " ";
        }
        cout << endl;
    };
    factorize(360);  // 360 = 2^3 3^2 5^1
    return 0;
}

线性筛的额外能力: 同时计算积性函数(欧拉函数 φ、莫比乌斯函数 μ、约数个数等),这在数论题中非常有用。


G.1.3 筛法对比

方法时间复杂度空间优势
朴素判质数O(√N) 每个O(1)单个数判断
埃氏筛O(N log log N)O(N)代码简单,实际快
欧拉线性筛O(N)O(N)同时记录最小质因子
Bitset 埃氏筛O(N log log N / 64)O(N/8)超大 N(≥ 10^8)时更快

G.2 快速幂

问题

计算 $a^n \mod p$,其中 n 可能高达 $10^{18}$。

朴素方法(循环乘 n 次)需要 O(N) 次运算,根本不可行。

核心思想:二进制分解指数

将 n 写成二进制形式,利用递推:

$$a^n = \begin{cases} (a^{n/2})^2 & n \text{ 为偶数} \ (a^{(n-1)/2})^2 \times a & n \text{ 为奇数} \end{cases}$$

示例: 计算 $3^{13}$

📄 Code 完整代码
13 = 1101₂ = 8 + 4 + 1

3^13 = 3^8 × 3^4 × 3^1

计算过程(从低位到高位):
  base = 3, exp = 13, result = 1

  exp & 1 = 1 → result = 1 × 3 = 3
  base = 3 × 3 = 9,exp = 6

  exp & 1 = 0 → result 不变(= 3)
  base = 9 × 9 = 81,exp = 3

  exp & 1 = 1 → result = 3 × 81 = 243
  base = 81 × 81 = 6561,exp = 1

  exp & 1 = 1 → result = 243 × 6561 = 1594323
  base = ..., exp = 0(结束)

验证:3^13 = 1594323 ✓

完整实现

📄 查看代码:完整实现
// 快速幂:计算 base^exp % mod
// 时间复杂度:O(log exp)
// 空间复杂度:O(1)
long long fast_pow(long long base, long long exp, long long mod) {
    long long result = 1;
    base %= mod;          // 先对底数取模
    
    while (exp > 0) {
        // 若当前位(最低位)为 1,乘入结果
        if (exp & 1)
            result = result * base % mod;
        
        // 底数平方,指数右移一位
        base = base * base % mod;
        exp >>= 1;
    }
    return result;
}

// 使用示例
int main() {
    long long MOD = 1e9 + 7;
    
    cout << fast_pow(2, 10, MOD)  << endl;  // 1024
    cout << fast_pow(3, 100, MOD) << endl;  // 981350898
    cout << fast_pow(2, 1e18, MOD) << endl; // 通过快速幂计算,不会超时
    
    return 0;
}

模逆元(求 a 关于 mod 的逆元)

当 mod 是质数时,由费马小定理 $a^{p-1} \equiv 1 \pmod{p}$,得 $a^{-1} \equiv a^{p-2} \pmod{p}$。

📄 C++ 完整代码
// 求 a 关于质数 mod 的模逆元
long long mod_inv(long long a, long long mod) {
    return fast_pow(a, mod - 2, mod);
}

// 应用:计算组合数 C(n, k) mod p
long long C(int n, int k, long long mod) {
    if (k > n || k < 0) return 0;
    
    // 预处理阶乘和逆阶乘
    vector<long long> fact(n + 1), inv_fact(n + 1);
    fact[0] = 1;
    for (int i = 1; i <= n; i++) fact[i] = fact[i-1] * i % mod;
    inv_fact[n] = mod_inv(fact[n], mod);
    for (int i = n - 1; i >= 0; i--) inv_fact[i] = inv_fact[i+1] * (i+1) % mod;
    
    return fact[n] % mod * inv_fact[k] % mod * inv_fact[n-k] % mod;
}

G.3 区间动态规划(区间 DP)

什么是区间 DP?

区间 DP 是一类特殊的 DP,状态定义在区间上

$$dp[i][j] = \text{将区间 } [i, j] \text{ 处理后的最优值}$$

关键特征:

状态转移框架

dp[i][j] = min/max over all k in [i, j-1]:
              dp[i][k] + dp[k+1][j] + cost(i, j)

遍历顺序:按区间长度

📄 查看代码:遍历顺序:按区间长度
// 区间 DP 标准模板
int n;
int dp[305][305];  // dp[i][j] = 区间 [i,j] 的最优值

// 初始化:单个元素
for (int i = 1; i <= n; i++)
    dp[i][i] = 基础情况;

// 按区间长度从小到大
for (int len = 2; len <= n; len++) {          // 区间长度
    for (int i = 1; i + len - 1 <= n; i++) {  // 左端点
        int j = i + len - 1;                   // 右端点
        dp[i][j] = INF;  // 初始为极值
        
        // 枚举分割点 k
        for (int k = i; k < j; k++) {
            dp[i][j] = min(dp[i][j], dp[i][k] + dp[k+1][j] + cost(i, j));
        }
    }
}

// 答案
return dp[1][n];

G.3.1 经典例题:石子合并

N 堆石子排成一列,每次合并相邻两堆,代价为合并后石子总数,求合并所有石子的最小(或最大)代价。

状态定义: dp[i][j] = 合并 [i, j] 堆石子的最优代价
转移: 枚举最后一次合并的分割点 k,即 [i,k] 先合并完,[k+1,j] 先合并完,再把这两堆合并

$$dp[i][j] = \min_{i \le k < j} {dp[i][k] + dp[k+1][j] + sum[j] - sum[i-1]}$$

其中 sum[j] - sum[i-1] 是区间 [i,j] 的石子总数(也是最后合并这步的代价)。

📄 其中 `sum[j] - sum[i-1]` 是区间 [i,j] 的石子总数(也是最后合并这步的代价)。
#include <bits/stdc++.h>
using namespace std;

int n;
int a[305];
int sum[305];      // 前缀和
int dp[305][305];  // dp[i][j] = 合并 [i,j] 的最小代价

int main() {
    cin >> n;
    for (int i = 1; i <= n; i++) {
        cin >> a[i];
        sum[i] = sum[i-1] + a[i];
    }
    
    // 单堆:代价 = 0(已经"合并完了")
    // dp[i][i] = 0(默认初始化)
    
    // 区间 DP
    for (int len = 2; len <= n; len++) {
        for (int i = 1; i + len - 1 <= n; i++) {
            int j = i + len - 1;
            dp[i][j] = INT_MAX;
            
            for (int k = i; k < j; k++) {
                int cost = sum[j] - sum[i-1];          // 最后合并这步的代价
                dp[i][j] = min(dp[i][j], dp[i][k] + dp[k+1][j] + cost);
            }
        }
    }
    
    cout << dp[1][n] << endl;
    return 0;
}

追踪示例(a = [3, 5, 2, 1]):

📄 Code 完整代码
前缀和:sum = [0, 3, 8, 10, 11]

初始:dp[1][1]=0, dp[2][2]=0, dp[3][3]=0, dp[4][4]=0

len=2:
  dp[1][2] = dp[1][1] + dp[2][2] + sum[2]-sum[0] = 0+0+8 = 8
  dp[2][3] = 0+0+7 = 7
  dp[3][4] = 0+0+3 = 3

len=3:
  dp[1][3]:
    k=1: dp[1][1]+dp[2][3]+(sum[3]-sum[0]) = 0+7+10 = 17
    k=2: dp[1][2]+dp[3][3]+(sum[3]-sum[0]) = 8+0+10 = 18
    dp[1][3] = min(17,18) = 17
  dp[2][4]:
    k=2: dp[2][2]+dp[3][4]+(sum[4]-sum[1]) = 0+3+8 = 11
    k=3: dp[2][3]+dp[4][4]+(sum[4]-sum[1]) = 7+0+8 = 15
    dp[2][4] = 11

len=4:
  dp[1][4]:
    k=1: dp[1][1]+dp[2][4]+11 = 0+11+11 = 22
    k=2: dp[1][2]+dp[3][4]+11 = 8+3+11 = 22
    k=3: dp[1][3]+dp[4][4]+11 = 17+0+11 = 28
    dp[1][4] = 22

答案:22

G.3.2 变形:括号匹配

给定一个括号序列,求添加最少多少个括号使其合法。

状态定义: dp[i][j] = 使 s[i..j] 合法需要添加的最少括号数

转移:

  1. 若 s[i] 和 s[j] 匹配(一对括号):dp[i][j] = dp[i+1][j-1]
  2. 枚举分割点:dp[i][j] = min(dp[i][k] + dp[k+1][j])
  3. 单个字符:dp[i][i] = 1(必须添加一个配对括号)
📄 3. 单个字符:`dp[i][i] = 1`(必须添加一个配对括号)
#include <bits/stdc++.h>
using namespace std;

bool match(char a, char b) {
    return (a == '(' && b == ')') ||
           (a == '[' && b == ']') ||
           (a == '{' && b == '}');
}

int main() {
    string s;
    cin >> s;
    int n = s.size();
    
    vector<vector<int>> dp(n, vector<int>(n, 0));
    
    // 单个字符需要 1 个括号配对
    for (int i = 0; i < n; i++) dp[i][i] = 1;
    
    for (int len = 2; len <= n; len++) {
        for (int i = 0; i + len - 1 < n; i++) {
            int j = i + len - 1;
            dp[i][j] = INT_MAX;
            
            // 两端匹配
            if (len >= 2 && match(s[i], s[j]))
                dp[i][j] = min(dp[i][j], (len == 2 ? 0 : dp[i+1][j-1]));
            
            // 枚举分割点
            for (int k = i; k < j; k++)
                dp[i][j] = min(dp[i][j], dp[i][k] + dp[k+1][j]);
        }
    }
    
    cout << dp[0][n-1] << endl;
    return 0;
}

G.4 矩阵快速幂(加速线性递推)

适用场景

如果一个递推关系的形式是:

$$\begin{pmatrix} f(n) \ f(n-1) \end{pmatrix} = M \times \begin{pmatrix} f(n-1) \ f(n-2) \end{pmatrix}$$

则可以用矩阵快速幂,在 O(K³ log N) 内计算第 N 项(K 为状态向量大小)。

以 Fibonacci 数列为例

$$f(n) = f(n-1) + f(n-2)$$

转化为矩阵形式:

$$\begin{pmatrix} f(n) \ f(n-1) \end{pmatrix} = \begin{pmatrix} 1 & 1 \ 1 & 0 \end{pmatrix} \times \begin{pmatrix} f(n-1) \ f(n-2) \end{pmatrix}$$

因此:

$$\begin{pmatrix} f(n) \ f(n-1) \end{pmatrix} = \begin{pmatrix} 1 & 1 \ 1 & 0 \end{pmatrix}^{n-1} \times \begin{pmatrix} f(1) \ f(0) \end{pmatrix}$$

📄 C++ 完整代码
#include <bits/stdc++.h>
using namespace std;
typedef long long ll;
typedef vector<vector<ll>> Matrix;

const ll MOD = 1e9 + 7;
const int K = 2;  // 状态维度

// 矩阵乘法
Matrix mat_mul(const Matrix& A, const Matrix& B) {
    int n = A.size();
    Matrix C(n, vector<ll>(n, 0));
    for (int i = 0; i < n; i++)
        for (int k = 0; k < n; k++)
            for (int j = 0; j < n; j++)
                C[i][j] = (C[i][j] + A[i][k] * B[k][j]) % MOD;
    return C;
}

// 矩阵快速幂:M^n
Matrix mat_pow(Matrix M, ll n) {
    int sz = M.size();
    // 初始化为单位矩阵
    Matrix result(sz, vector<ll>(sz, 0));
    for (int i = 0; i < sz; i++) result[i][i] = 1;
    
    while (n > 0) {
        if (n & 1) result = mat_mul(result, M);
        M = mat_mul(M, M);
        n >>= 1;
    }
    return result;
}

// 计算第 n 个 Fibonacci 数 mod MOD
ll fibonacci(ll n) {
    if (n <= 1) return n;
    
    // 转移矩阵
    Matrix trans = {{1, 1}, {1, 0}};
    Matrix result = mat_pow(trans, n - 1);
    
    // 初始状态 [f(1), f(0)] = [1, 0]
    // 结果 = result * [1, 0]^T 的第一个元素
    return result[0][0];  // result[0][0]*f(1) + result[0][1]*f(0) = result[0][0]
}

int main() {
    for (int i = 0; i <= 10; i++)
        cout << "f(" << i << ") = " << fibonacci(i) << endl;
    
    // 大数:f(10^18) mod (10^9+7)
    cout << "f(10^18) = " << fibonacci(1e18) << endl;
    return 0;
}

⚠️ 常见错误

错误原因修复方案
埃氏筛 i*i 溢出i 较大时 i*i 超 int 范围(long long)i*i <= ni <= n/i
快速幂底数未取模第一行漏了 base %= mod总是先 base %= mod
区间 DP 初始化错误忘记 dp[i][i] = 基础情况单元素的基础情况必须手动设置
区间 DP 遍历顺序错误没有按区间长度从小到大最外层循环必须是 len
矩阵乘法模运算位置累加时溢出再取模每次乘加后立即 % MOD

💪 练习题(共 8 道,全部含完整解答)

🟢 基础练习(1~3)

题目 1:质因数分解
利用欧拉线性筛的 min_prime 数组,在 O(log N) 时间内对任意正整数做质因数分解,输出格式为 2^3 * 3^2 * 5

✅ 完整解答
#include <bits/stdc++.h>
using namespace std;

const int MAXN = 1e6 + 5;
int min_prime[MAXN];
vector<int> primes;

void sieve(int n) {
    for (int i = 2; i <= n; i++) {
        if (!min_prime[i]) { min_prime[i] = i; primes.push_back(i); }
        for (int p : primes) {
            if ((long long)i * p > n) break;
            min_prime[i * p] = p;
            if (i % p == 0) break;
        }
    }
}

void factorize(int x) {
    cout << x << " = ";
    bool first = true;
    while (x > 1) {
        int p = min_prime[x], cnt = 0;
        while (x % p == 0) { x /= p; cnt++; }
        if (!first) cout << " * ";
        cout << p << "^" << cnt;
        first = false;
    }
    cout << "\n";
}

int main() {
    sieve(1e6);
    factorize(360);      // 2^3 * 3^2 * 5^1
    factorize(1000000);  // 2^6 * 5^6
    factorize(97);       // 97^1(质数)
    return 0;
}

关键: min_prime[x] 是 x 的最小质因子,每次除尽后继续处理 x/p^k,循环 O(log x) 次。


题目 2:组合数取模
计算 C(N, K) mod (10^9+7),N 和 K 可高达 10^6。
要求预处理阶乘和逆元,查询 O(1)。

✅ 完整解答
#include <bits/stdc++.h>
using namespace std;
typedef long long ll;

const ll MOD = 1e9 + 7;
const int MAXN = 1e6 + 5;
ll fact[MAXN], inv_fact[MAXN];

ll fast_pow(ll base, ll exp, ll mod) {
    ll result = 1; base %= mod;
    while (exp > 0) {
        if (exp & 1) result = result * base % mod;
        base = base * base % mod;
        exp >>= 1;
    }
    return result;
}

void preprocess(int n) {
    fact[0] = 1;
    for (int i = 1; i <= n; i++) fact[i] = fact[i-1] * i % MOD;
    inv_fact[n] = fast_pow(fact[n], MOD - 2, MOD);  // 费马小定理
    for (int i = n - 1; i >= 0; i--) inv_fact[i] = inv_fact[i+1] * (i+1) % MOD;
}

ll C(int n, int k) {
    if (k < 0 || k > n) return 0;
    return fact[n] % MOD * inv_fact[k] % MOD * inv_fact[n-k] % MOD;
}

int main() {
    preprocess(1e6);
    cout << C(10, 3)    << "\n";   // 120
    cout << C(1000000, 500000) << "\n";  // 大数取模
    cout << C(5, 0)     << "\n";   // 1
    return 0;
}

预处理流程:

1. 正向递推 fact[0..N]:O(N)
2. 用快速幂求 fact[N] 的逆元:O(log MOD)
3. 反向递推所有逆阶乘:inv_fact[i] = inv_fact[i+1] * (i+1),O(N)
4. 查询 C(n,k):O(1)

题目 3:石子合并(区间 DP 基础)
N 堆石子排成一排,每次合并相邻两堆,代价为合并后的总石子数。求总代价最小值。

✅ 完整解答
#include <bits/stdc++.h>
using namespace std;

int main() {
    int n; cin >> n;
    vector<int> a(n + 1);
    vector<int> sum(n + 1, 0);
    for (int i = 1; i <= n; i++) { cin >> a[i]; sum[i] = sum[i-1] + a[i]; }
    
    vector<vector<int>> dp(n + 1, vector<int>(n + 1, 0));
    
    // len = 区间长度,从 2 开始(长度 1 代价为 0)
    for (int len = 2; len <= n; len++) {
        for (int i = 1; i + len - 1 <= n; i++) {
            int j = i + len - 1;
            dp[i][j] = INT_MAX;
            for (int k = i; k < j; k++) {
                int cost = sum[j] - sum[i-1];  // 合并 [i..j] 的代价
                dp[i][j] = min(dp[i][j], dp[i][k] + dp[k+1][j] + cost);
            }
        }
    }
    
    cout << dp[1][n] << "\n";
    return 0;
}

输入示例:

4
3 5 2 1
输出:22

追踪: 见 G.3.1 节的详细分步追踪。


🟡 进阶练习(4~6)

题目 4:矩阵链乘(经典区间 DP)
给 N 个矩阵,第 i 个的维度为 p[i-1] × p[i](共 N+1 个维度值)。
求计算连乘 M1 × M2 × ... × MN 的最少乘法次数(通过改变括号顺序)。

提示: dp[i][j] = 计算第 i 到第 j 个矩阵乘积的最少乘法次数。
转移:枚举最后一次分割点 k,代价为 dp[i][k] + dp[k+1][j] + p[i-1]*p[k]*p[j]

✅ 完整解答
#include <bits/stdc++.h>
using namespace std;

int main() {
    int n; cin >> n;
    vector<int> p(n + 1);
    for (int& x : p) cin >> x;
    
    // dp[i][j] = 计算矩阵 i..j 的最少乘法次数(1-indexed)
    vector<vector<long long>> dp(n + 1, vector<long long>(n + 1, 0));
    
    for (int len = 2; len <= n; len++) {
        for (int i = 1; i + len - 1 <= n; i++) {
            int j = i + len - 1;
            dp[i][j] = LLONG_MAX;
            for (int k = i; k < j; k++) {
                long long cost = (long long)p[i-1] * p[k] * p[j];
                dp[i][j] = min(dp[i][j], dp[i][k] + dp[k+1][j] + cost);
            }
        }
    }
    
    cout << dp[1][n] << "\n";
    return 0;
}

示例(N=3,矩阵维度 10×30,30×5,5×60):

p = [10, 30, 5, 60]

dp[1][2] = 10×30×5 = 1500
dp[2][3] = 30×5×60 = 9000
dp[1][3]:
  k=1: dp[1][1] + dp[2][3] + 10×30×60 = 0 + 9000 + 18000 = 27000
  k=2: dp[1][2] + dp[3][3] + 10×5×60 = 1500 + 0 + 3000 = 4500
  dp[1][3] = 4500

输出:4500(先算 M2×M3,再算 M1×(M2M3))

题目 5:Fibonacci 第 N 项(矩阵快速幂)
求斐波那契数列第 N 项 f(N) mod (10^9+7),N 可高达 10^18。

✅ 完整解答
#include <bits/stdc++.h>
using namespace std;
typedef long long ll;
typedef vector<vector<ll>> Matrix;

const ll MOD = 1e9 + 7;

Matrix mat_mul(const Matrix& A, const Matrix& B) {
    int n = A.size();
    Matrix C(n, vector<ll>(n, 0));
    for (int i = 0; i < n; i++)
        for (int k = 0; k < n; k++)
            for (int j = 0; j < n; j++)
                C[i][j] = (C[i][j] + A[i][k] * B[k][j]) % MOD;
    return C;
}

Matrix mat_pow(Matrix M, ll n) {
    int sz = M.size();
    Matrix result(sz, vector<ll>(sz, 0));
    for (int i = 0; i < sz; i++) result[i][i] = 1;  // 单位矩阵
    while (n > 0) {
        if (n & 1) result = mat_mul(result, M);
        M = mat_mul(M, M);
        n >>= 1;
    }
    return result;
}

ll fibonacci(ll n) {
    if (n <= 1) return n;
    // [f(n), f(n-1)] = [[1,1],[1,0]]^(n-1) * [f(1), f(0)]
    Matrix trans = {{1, 1}, {1, 0}};
    auto R = mat_pow(trans, n - 1);
    // R * [1, 0]^T = R[0][0]*1 + R[0][1]*0 = R[0][0]
    return R[0][0];
}

int main() {
    cout << fibonacci(1)  << "\n";   // 1
    cout << fibonacci(10) << "\n";   // 55
    cout << fibonacci(50) << "\n";   // 586268941
    
    ll n = (ll)1e18;
    cout << fibonacci(n)  << "\n";   // 某个大数 mod 10^9+7
    return 0;
}

为什么递推不行? f(10^18) 需要 10^18 步,即便每步只是加法也需要 ~32 年(按 10^9 步/秒)。矩阵快速幂只需 ~120 次矩阵乘法。


题目 6:牛买卖(区间 DP 变形)
N 个人依次报价(整数),你可以买入卖出,每次操作只涉及相邻时刻。
合并相邻两个操作(买-卖一对),代价为两个价格之差的绝对值。求将所有操作合并成一笔交易的最小总代价。
(即:括号匹配最少插入次数的数值版本)

提示: 这与石子合并类似,用区间 DP,dp[i][j] = 将第 i 到第 j 笔操作合并完毕的最小代价。

✅ 完整解答

本质是:对区间 [i,j] 枚举分割点 k,左右子区间各自合并后,再将两段合并,追加代价 |price[i] - price[j]|(合并时左端与右端的价差)。

#include <bits/stdc++.h>
using namespace std;

int main() {
    int n; cin >> n;
    vector<int> p(n + 1);
    for (int i = 1; i <= n; i++) cin >> p[i];
    
    vector<vector<int>> dp(n + 1, vector<int>(n + 1, 0));
    
    for (int len = 2; len <= n; len++) {
        for (int i = 1; i + len - 1 <= n; i++) {
            int j = i + len - 1;
            dp[i][j] = INT_MAX;
            for (int k = i; k < j; k++) {
                // 左边合并后最终价格是 p[k](向左看),右边是 p[k+1](向右看)
                // 合并两段的额外代价:两段之间的「接口代价」= |p[i] - p[j]|
                // (用石子合并的模型,cost = 区间端点差值)
                dp[i][j] = min(dp[i][j],
                    dp[i][k] + dp[k+1][j] + abs(p[i] - p[j]));
            }
        }
    }
    
    cout << dp[1][n] << "\n";
    return 0;
}

此题展示了区间 DP 的灵活性:只需修改「合并代价」的计算方式,即可处理不同的区间合并问题。


🔴 挑战练习(7~8)

题目 7:1000 以内所有质数之和
用埃氏筛求出 1000 以内的所有质数,输出它们的个数和总和。
再用线性筛改写,比较结果是否一致。

✅ 完整解答
#include <bits/stdc++.h>
using namespace std;

// 埃氏筛版本
void eratosthenes(int n) {
    vector<bool> is_prime(n + 1, true);
    is_prime[0] = is_prime[1] = false;
    for (int i = 2; (long long)i * i <= n; i++)
        if (is_prime[i])
            for (int j = i * i; j <= n; j += i)
                is_prime[j] = false;
    
    int cnt = 0; long long sum = 0;
    for (int i = 2; i <= n; i++)
        if (is_prime[i]) { cnt++; sum += i; }
    cout << "埃氏筛:" << cnt << " 个质数,总和 = " << sum << "\n";
}

// 欧拉线性筛版本
void euler(int n) {
    vector<int> min_p(n + 1, 0);
    vector<int> primes;
    for (int i = 2; i <= n; i++) {
        if (!min_p[i]) { min_p[i] = i; primes.push_back(i); }
        for (int p : primes) {
            if ((long long)i * p > n) break;
            min_p[i * p] = p;
            if (i % p == 0) break;
        }
    }
    long long sum = 0;
    for (int p : primes) sum += p;
    cout << "线性筛:" << primes.size() << " 个质数,总和 = " << sum << "\n";
}

int main() {
    eratosthenes(1000);
    euler(1000);
    // 两者输出应完全一致:168 个质数,总和 = 76127
    return 0;
}

题目 8:字符串加密(快速幂综合应用)
RSA 加密中,加密公式为:密文 = 明文^e mod n。
给定明文 M(一个整数)、指数 e、模数 n,计算密文。
其中 M, e, n 可高达 10^18。

✅ 完整解答

直接套用快速幂模板。注意当 M 和 n 都接近 10^18 时,M * M 会溢出 long long,需要用 __int128 或「龟速乘(二进制分解乘法)」。

#include <bits/stdc++.h>
using namespace std;
typedef long long ll;
typedef unsigned long long ull;
typedef __int128 lll;

// 快速幂(支持 base 和 mod 都高达 10^18)
ll fast_pow(ll base, ll exp, ll mod) {
    ll result = 1;
    base %= mod;
    while (exp > 0) {
        if (exp & 1)
            result = (lll)result * base % mod;  // __int128 避免溢出
        base = (lll)base * base % mod;
        exp >>= 1;
    }
    return result;
}

// 龟速乘(不用 __int128 的替代方案)
ll safe_mul(ll a, ll b, ll mod) {
    ll result = 0;
    a %= mod;
    while (b > 0) {
        if (b & 1) result = (result + a) % mod;
        a = (a + a) % mod;
        b >>= 1;
    }
    return result;
}

ll fast_pow_safe(ll base, ll exp, ll mod) {
    ll result = 1; base %= mod;
    while (exp > 0) {
        if (exp & 1) result = safe_mul(result, base, mod);
        base = safe_mul(base, base, mod);
        exp >>= 1;
    }
    return result;
}

int main() {
    ll M, e, n;
    cin >> M >> e >> n;
    cout << fast_pow(M, e, n) << "\n";  // 密文
    
    // 测试:M=2, e=10, n=1000 → 2^10=1024 mod 1000 = 24
    cout << fast_pow(2, 10, 1000) << "\n";  // 24
    
    return 0;
}

为什么用 __int128
long long 最大约 9.2×10^18,但 base * base 可达 (10^18)^2 = 10^36,远超范围。
__int128 支持到约 1.7×10^38,足以处理中间计算。


💡 章节联系: 质数筛是数论题的基础工具;快速幂是「取模运算」场景(组合数、大幂次、RSA)的标配;区间 DP 是 USACO Gold 的高频题型,掌握了石子合并后还可以扩展到括号匹配、矩阵链乘、凸包剖分等。

📖 附录 F ⏱️ 约 30 分钟 🎯 全部级别

附录 F:调试指南——常见 Bug 及修复方法

💡 为什么要有这份附录? 即便算法思路完全正确,只要一个 Bug 溜进代码,结果就会出错。本指南系统地整理了竞赛 C++ 代码中最常见的 Bug,按类别分类,方便快速定位。当你的解答出现 WA(Wrong Answer)、TLE(Time Limit Exceeded)、RE(Runtime Error)或 MLE(Memory Limit Exceeded)时,请先来这里查找原因。

用下面这张分类图快速判断你的 Bug 属于哪个类别:

竞赛编程 Bug 分类

拿到错误判决后,按照这个系统化的调试流程来排查:

调试流程


F.1 整数溢出

C++ 中 Wrong Answer 最常见的根源。

问题:int 太小了

int 的最大值约为 2.1 × 10⁹(≈ 2 × 10⁹)。很多题目的中间值会超出这个范围。

// ❌ 错误:n=10^5 时,n*n 会溢出
int n = 100000;
int result = n * n;  // = 10^10 → 超出 int 范围(最大 ~2×10^9)!

// ✅ 正确:乘之前先转成 long long
long long result = (long long)n * n;  // = 10^10,在 long long 范围内
// 或:
long long n_ll = n;
long long result2 = n_ll * n_ll;

什么时候该用 long long

场景需要 long long 吗?
数组元素最大 10⁹,需要区间求和✅ 需要(和最大 10⁹ × 10⁵ = 10¹⁴)
最多 10⁵ 个元素的前缀和✅ 需要(安全默认选择)
矩阵元素、DP 中间值✅ 需要
Dijkstra 中的距离✅ 需要(dist[u] + w 可能溢出 int
简单计数器(0 到 10⁶ 以内的 N)int 足够
下标和循环变量int 足够

危险操作举例

📄 查看代码:危险操作举例
// ❌ 溢出示例:
int a = 1e9, b = 1e9;
cout << a + b;     // 溢出(结果 > INT_MAX)
cout << a * 2;     // 溢出
cout << a * a;     // 灾难性溢出

// ❌ 比较时溢出:
if (a * b > 1e18) ...  // a*b 本身可能已经溢出了!

// ✅ 安全版本:
cout << (long long)a + b;
cout << (long long)a * 2;
cout << (long long)a * a;
if ((long long)a * b > (long long)1e18) ...  // 用 long long 比较

INF 值的选择

// ❌ 错误:在 Dijkstra 中用 INT_MAX 作为无穷大
const int INF = INT_MAX;
if (dist[u] + w < dist[v]) ...  // dist[u] + w 在 dist[u]=INT_MAX 时溢出!

// ✅ 正确:使用安全的哨兵值
const long long INF = 1e18;   // 用于 long long 距离
const int INF_INT = 1e9;       // 用于 int 距离(留有加法空间)

F.2 差一错误(Off-By-One)

WA 第二常见的根源。

数组下标

// ❌ 错误:访问 A[n] 越界
int A[n];
for (int i = 0; i <= n; i++) cout << A[i];  // A[n] 未定义!

// ✅ 正确
for (int i = 0; i < n; i++) cout << A[i];   // 下标 0..n-1
// 或(1-indexed):
for (int i = 1; i <= n; i++) cout << A[i];  // 下标 1..n

前缀和公式

// ❌ 错误:区间和差一
// sum(L, R) 应该是 P[R] - P[L-1],而不是 P[R] - P[L]
cout << P[R] - P[L];    // 少了 A[L]!

// ✅ 正确
cout << P[R] - P[L-1];  // P[0]=0 正确处理 L=1 的边界情况

二分查找边界

📄 查看代码:二分查找边界
// 查找第一个 A[i] >= target 的下标(lower_bound 行为):

// ❌ 错误:常见二分查找写法错误
int lo = 0, hi = n - 1;
while (lo < hi) {
    int mid = (lo + hi) / 2;
    if (A[mid] < target) lo = mid;      // BUG:应为 lo = mid + 1
    else hi = mid - 1;                   // BUG:应为 hi = mid
}

// ✅ 正确:标准 lower_bound 模板
int lo = 0, hi = n;  // hi = n(而非 n-1),以允许"未找到"的情况
while (lo < hi) {
    int mid = (lo + hi) / 2;
    if (A[mid] < target) lo = mid + 1;  // target 在 [mid+1, hi]
    else hi = mid;                       // target 在 [lo, mid]
}
// lo = hi = 第一个 A[i] >= target 的下标;lo=n 表示未找到

循环边界

📄 查看代码:循环边界
// ❌ 常见错误:循环多跑或少跑一次
for (int i = 1; i < n; i++) ...    // 若本意是 i=0 到 n-1,则漏了 i=0
for (int i = 0; i <= n-1; i++) ... // 正确但写法混乱;推荐 i < n

// DP 表格填充:注意递推是否访问了 i-1
// ❌ 如果 dp[i] 用到 dp[i-1],而 i 从 0 开始,则 dp[-1] 未定义!
for (int i = 0; i <= n; i++) {
    dp[i] = dp[i-1] + ...;  // BUG:i=0 时访问 dp[-1]!
}

// ✅ 从 i=1 开始,或单独初始化 dp[0] 作为边界条件
dp[0] = BASE_CASE;
for (int i = 1; i <= n; i++) {
    dp[i] = dp[i-1] + ...;  // 安全:dp[i-1] 始终有效
}

F.3 未初始化的变量

📄 查看代码:F.3 未初始化的变量
// ❌ 错误:dp 数组未初始化
int dp[1005][1005];  // 在 C++ 中包含垃圾值!
// dp[i][j] 可能因上一个测试用例或操作系统内存而非零

// ✅ 正确方式:
// 方式一:memset(按字节填充,用 0 或 0x3f 模拟正无穷)
memset(dp, 0, sizeof(dp));          // 全部置 0
memset(dp, 0x3f, sizeof(dp));       // 置为 ~1.06e9(适合用作 int 的"无穷大")

// 方式二:vector 显式初始化
vector<vector<int>> dp(n+1, vector<int>(m+1, 0));

// 方式三:fill
fill(dp, dp + n, 0);

// ⚠️ 警告:memset(dp, -1, sizeof(dp)) 将每个字节置为 0xFF
// 对 int:0xFFFFFFFF = -1(可用于"未访问"标记)
// 对 long long:0xFFFFFFFFFFFFFFFF = -1(同样有效)
// 但 memset(dp, 1, sizeof(dp)) 得到 0x01010101 = 16843009,而不是 1!

全局变量 vs 局部变量

📄 查看代码:全局变量 vs 局部变量
// C++ 中全局数组默认初始化为零
// 局部(栈)数组则不会初始化

int globalArr[100005];     // ✅ 初始化为 0
int globalDP[1005][1005];  // ✅ 初始化为 0

int main() {
    int localArr[1000];    // ❌ 未初始化(含垃圾值)
    int localDP[100][100]; // ❌ 未初始化
    
    // 建议:将大数组声明为全局变量,既避免栈溢出又保证初始化
}

F.4 栈溢出(递归过深)

📄 查看代码:F.4 栈溢出(递归过深)
// C++ 默认栈大小通常为 1~8 MB
// 递归层数过深会超出限制 → 运行时错误(段错误)

// ❌ 危险:在深度为 10^5 的树上递归 DFS
void dfs(int u) { dfs(children[u]); }  // 长链场景下会栈溢出!

// ✅ 解法一:用显式栈改写为迭代
void dfs_iterative(int start) {
    stack<int> st;
    st.push(start);
    while (!st.empty()) {
        int u = st.top(); st.pop();
        for (int v : children[u]) st.push(v);
    }
}

// ✅ 解法二:增大栈大小(平台相关,竞赛评测机通常允许)
// Linux 下编译并运行:ulimit -s unlimited && ./sol

// 经验法则:
// 递归深度 ≤ ~10^4:通常安全
// 递归深度 ~10^5:有风险,考虑迭代
// 递归深度 ~10^6:几乎必定栈溢出 → 必须用迭代

F.5 模运算 Bug

📄 查看代码:F.5 模运算 Bug
// 当题目要求输出 mod 10^9+7 时:
const int MOD = 1e9 + 7;

// ❌ 错误:忘记取模,long long 溢出
long long dp = 1;
for (int i = 0; i < n; i++) dp *= A[i];  // ~18 次大数乘法后溢出!

// ❌ 错误:减法下溢(结果为负的模)
long long ans = (a - b) % MOD;  // a < b 时,C++ 返回负数!

// ✅ 正确:减法取模前加上 MOD
long long ans = ((a - b) % MOD + MOD) % MOD;  // 保证非负

// ❌ 错误:DP 中忘记对中间值取模
dp[i][j] = dp[i-1][j] + dp[i][j-1];  // 迭代次数多时可能溢出

// ✅ 正确:每次加法后立即取模
dp[i][j] = (dp[i-1][j] + dp[i][j-1]) % MOD;

// ✅ 正确的模意义快速幂:
long long modpow(long long base, long long exp, long long mod) {
    long long result = 1;
    base %= mod;
    while (exp > 0) {
        if (exp & 1) result = result * base % mod;  // ← 每次乘法后取模!
        base = base * base % mod;
        exp >>= 1;
    }
    return result;
}

F.6 图/BFS/DFS Bug

📄 查看代码:F.6 图/BFS/DFS Bug
// ❌ BFS:忘记在入队之前标记已访问
// 这会导致同一节点被处理多次!
queue<int> q;
q.push(src);
while (!q.empty()) {
    int u = q.front(); q.pop();
    visited[u] = true;  // ❌ 出队后才标记 → 同一节点可能多次入队
    for (int v : adj[u]) if (!visited[v]) q.push(v);
}

// ✅ 正确:入队时立即标记已访问
visited[src] = true;
queue<int> q;
q.push(src);
while (!q.empty()) {
    int u = q.front(); q.pop();
    for (int v : adj[u]) {
        if (!visited[v]) {
            visited[v] = true;  // ✅ 入队前标记
            q.push(v);
        }
    }
}

// ❌ DFS:多个测试用例之间忘记重置 visited
// 多测问题中,每次测试用例开始前必须重新初始化 visited[]!
memset(visited, false, sizeof(visited));

// ❌ Dijkstra:距离数组用 int 而非 long long
int dist[MAXN];  // ❌ 边权最大 10^9 时,累加后溢出!
long long dist[MAXN];  // ✅

F.7 I/O Bug

📄 查看代码:F.7 I/O Bug
// ❌ 错误:大量输入时未加 ios_base::sync_with_stdio(false)
// 不加此行,cin/cout 与 C 标准 I/O 同步 → 速度极慢!
// N = 10^6 的输入量下,可能是 AC 与 TLE 的差距。

// ✅ 每道竞赛题的 main() 开头都应加上:
ios_base::sync_with_stdio(false);
cin.tie(NULL);

// ❌ 错误:使用 endl(每行都刷新缓冲区 → 很慢)
for (int i = 0; i < n; i++) cout << ans[i] << endl;  // 慢!

// ✅ 正确:用 "\n" 代替
for (int i = 0; i < n; i++) cout << ans[i] << "\n";  // 快

// ❌ 错误:关闭同步后混用 cin 与 scanf/printf
ios_base::sync_with_stdio(false);
scanf("%d", &n);  // BUG:解除同步后混用 C 和 C++ I/O!

// ✅ 正确:选一种方式并坚持使用
// 要么只用 cin/cout,要么只用 scanf/printf

// USACO 文件 I/O(有时题目要求):
freopen("problem.in", "r", stdin);
freopen("problem.out", "w", stdout);
// 加上这两行后,cin/cout 会自动读写文件

F.8 二维数组越界与方向

📄 查看代码:F.8 二维数组越界与方向
// 网格 BFS:边界检查差一错误
int dx[] = {0, 0, 1, -1};
int dy[] = {1, -1, 0, 0};

// ❌ 错误(实际上下面这种写法是正确的——只是确保四个条件都写到)
for (int d = 0; d < 4; d++) {
    int nx = x + dx[d], ny = y + dy[d];
    if (nx >= 0 && ny >= 0 && nx < n && ny < m) // ✅ 这个边界检查是正确的!
    // 确保四个条件都写全
}

// ❌ 错误:弄混行列(转置了行和列)
// 若网格是 N 行 × M 列:
// A[row][col]:row 取值 0..N-1,col 取值 0..M-1
// 边界条件:row < N,col < M(不能写成 row < M!)

// ❌ 错误:多源 BFS 计算距离时,同一格子被多次访问(忘记距离检查)
if (!visited[nx][ny]) {  // ✅ 只访问未访问的格子
    visited[nx][ny] = true;
    dist[nx][ny] = dist[x][y] + 1;
    q.push({nx, ny});
}

F.9 DP 专项 Bug

📄 查看代码:F.9 DP 专项 Bug
// ❌ 错误:0/1 背包内层循环方向错了
// 必须从高到低遍历容量,防止物品被重复使用!
for (int i = 0; i < n; i++) {
    for (int j = W; j >= weight[i]; j--) {  // ✅ 从高到低
        dp[j] = max(dp[j], dp[j - weight[i]] + value[i]);
    }
}
// 若从低到高遍历:
for (int j = weight[i]; j <= W; j++) {  // ❌ 从低到高 = 完全背包!
    dp[j] = max(dp[j], dp[j - weight[i]] + value[i]);
}

// ❌ 错误:LIS 二分查找时混淆 upper_bound 与 lower_bound
// 严格递增 LIS:用 lower_bound(找第一个 >= x 的位置,替换)
// 非严格递减 LIS:用 upper_bound(找第一个 > x 的位置,替换)
auto it = lower_bound(tails.begin(), tails.end(), x);  // 严格递增
auto it = upper_bound(tails.begin(), tails.end(), x);  // 非严格递减

// ❌ 错误:忘记初始化边界条件
// dp[0] 或 dp[i][0] 或 dp[0][j] 必须在主循环之前显式赋值
dp[0][0] = 0;  // 始终初始化边界条件!

F.10 内存超限(MLE)

📄 查看代码:F.10 内存超限(MLE)
// 常见的 MLE 原因:

// ❌ 数组过大
int dp[10005][10005];  // = 10^8 个 int = 400MB → 超出典型的 256MB 限制!

// 计算方式:N × M × sizeof(类型) 字节
// int:4 字节,long long:8 字节
// 256MB = 256 × 10^6 字节
// int 数组最多约 6400 万个元素
// long long 数组最多约 3200 万个元素

// ✅ 一维 DP 的空间优化:
// 若 dp[i] 只依赖 dp[i-1],使用滚动数组:
vector<long long> dp(2, 0);  // dp[cur] 和 dp[prev]
for (int i = 0; i < n; i++) {
    dp[1 - cur] = f(dp[cur]);  // 在 0 和 1 之间交替
    cur = 1 - cur;
}

// ✅ 二维 DP 的空间优化(背包类):
// 若 dp[i][j] 只依赖 dp[i-1][...],只保留两行
vector<int> prev_row(W+1, 0), curr_row(W+1, 0);

快速诊断清单

拿到 WA/RE/TLE 时,按这个清单逐项检查:

Wrong Answer(WA):

Runtime Error(RE):

Time Limit Exceeded(TLE):

Memory Limit Exceeded(MLE):


💡 专业建议: 打印中间值!cerr << "DEBUG: dp[3] = " << dp[3] << "\n"; cerr 输出到标准错误流(而非标准输出),不会影响竞赛评测机上的输出。提交前记得删除所有 cerr 调试行。

竞赛编程术语词汇表

本词汇表定义了贯穿全书及竞赛编程领域常用的 35+ 个核心术语。遇到不熟悉的术语时,请先在这里查阅。


A

算法(Algorithm) 解决问题的分步骤过程。算法必须:正确(给出正确答案)、有限(最终终止)、确定(每步无歧义)。示例:二分查找、BFS、归并排序。

邻接表(Adjacency List) 图的一种表示方式:每个顶点存储其邻居列表。空间:O(V + E)。竞赛编程中的标准表示方式。

邻接矩阵(Adjacency Matrix) 二维数组,matrix[u][v] = 1 表示 u 到 v 有边。空间:O(V²)。仅在 V ≤ 1000 的稠密图中使用。

摊还时间(Amortized Time) 一系列操作的每次操作平均时间。示例:vector::push_back 的摊还时间为 O(1),即使偶尔的扩容需要 O(N)。


B

边界条件(Base Case) 递归和 DP 中,具有已知答案的最简子问题(无需进一步递归)。示例:fib(0) = 0fib(1) = 1

BFS(广度优先搜索,Breadth-First Search) 逐层探索节点的图遍历(先遍历所有距离为 1 的节点,再遍历距离为 2 的节点……)。使用队列。在无权图中保证最短路径。时间复杂度:O(V + E)。

大 O 表示法(Big-O Notation) 描述算法时间或空间增长上界的数学表示。"O(N log N)"表示"对某常数 c,操作次数至多为 c × N × log(N)"。用于比较算法效率。

二分查找(Binary Search) 有序数组上的 O(log N) 搜索算法。每步与中点比较,将候选范围减半。最重要的应用:"对答案二分"用于优化问题。

暴力枚举(Brute Force) 尝试所有可能情况的朴素解法。通常为 O(N²) 或 O(2^N)。正确但对大规模输入太慢。用途:部分得分、验证优化解、小测试用例。


C

比较器(Comparator) 定义排序顺序的函数。接收两个元素,若第一个应排在第二个前面则返回 true。与 std::sort 配合使用。

算法竞赛(Competitive Programming) 在时间限制内解决算法问题的编程竞赛。USACO、Codeforces、LeetCode 和 IOI 是热门平台。

连通分量(Connected Component) 任意两顶点之间都有路径相连的极大子图。可用 DFS/BFS 或并查集找连通分量。

坐标压缩(Coordinate Compression) 将大范围的值(如最大 10^9)映射为小的连续下标(0, 1, 2, ...)而不改变相对顺序。使得可以用数组代替哈希表。


D

DAG(有向无环图,Directed Acyclic Graph) 没有环的有向图。关键性质:存在拓扑排序。示例:依赖关系图、任务调度。

DFS(深度优先搜索,Depth-First Search) 在回溯前尽可能深地探索的图遍历。使用栈(或递归)。适用于:连通性判断、环检测、拓扑排序。时间复杂度:O(V + E)。

差分数组(Difference Array) O(1) 区间更新的技术。存储相邻元素之差;区间加 [L,R] 变为 diff[L]++ 和 diff[R+1]--。用前缀和还原原数组。

DP(动态规划,Dynamic Programming) 通过将问题分解为有重叠的子问题并缓存结果来优化求解的技术。需要两个性质:最优子结构 + 重叠子问题。另见:记忆化、递推。

DSU(并查集,Disjoint Set Union) 见"并查集(Union-Find)"。


E

边(Edge) 图中两顶点之间的连接。可以是有向的(单向)或无向的(双向),可以有权重。

交换论证(Exchange Argument) 贪心算法的证明技术。证明将贪心选择与其他选择交换后,解不会变得更差。


F

洪水填充(Flood Fill) 一种算法(通常用 DFS 或 BFS),标记网格中所有相同"颜色"的连通格子。用于统计连通区域数。


G

图(Graph) 由顶点(节点)和边(连接)组成的数据结构。建模关系、网络、地图等。

贪心算法(Greedy Algorithm) 在每步做出局部最优选择,期望得到全局最优结果的算法。当"贪心选择性质"成立时有效。示例:活动选择、哈夫曼编码、Kruskal MST。


H

哈希表(Hash Map / unordered_map) 以 O(1) 平均时间存储和查找键值对的数据结构。用哈希表实现。没有顺序保证。当需要快速查找但不需要有序键时使用。


I

区间 DP(Interval DP) 以子数组 [l, r] 为状态,尝试所有分割点的 DP 模式。经典示例:矩阵链乘法、回文划分。时间复杂度:O(N³)。


K

背包问题(Knapsack Problem) DP 问题:给定有重量和价值的物品,在重量限制内最大化价值。"0/1 背包"指每件物品最多使用一次,"完全背包"指可以无限使用。


L

LIS(最长递增子序列,Longest Increasing Subsequence) 数组中每个元素都严格大于前一个元素的最长子序列。O(N²) DP 或带二分查找的 O(N log N)。

LCA(最近公共祖先,Lowest Common Ancestor) 有根树中同时是 u 和 v 的祖先的最深节点。朴素做法:每次查询 O(深度)。倍增:O(log N)。


M

记忆化(Memoization) 缓存递归函数调用结果以避免重复计算。"自顶向下 DP"。备忘录表存储已计算的值;计算前先检查答案是否已知。

MST(最小生成树,Minimum Spanning Tree) 带权图中总边权最小的生成树。Kruskal 算法:排序边 + 并查集。Prim 算法:优先队列 + 已访问集合。两者都是 O(E log E)。

单调(Monotone / Monotonic) 持续递增或递减。函数单调意味着方向从不反转。对答案二分的关键:可行性函数必须是单调的。


O

差一错误(Off-By-One Error) 下标或计数恰好差 1 的 Bug。在循环(< n vs <= n)、二分查找、前缀和(P[L-1] vs P[L])中非常常见。

最优子结构(Optimal Substructure) 一种性质:问题的最优解可以由其子问题的最优解构建。DP 正常工作的必要条件。

溢出(Overflow) 值超过类型所能表示的最大值。int 最大值约为 2×10^9;long long 最大值约为 9.2×10^18。两个 10^9 的 int 相乘会溢出 int——先强制转换为 long long


P

前缀和(Prefix Sum) P[i] = 从下标 0(或 1)到 i 所有元素之和的数组。支持 O(1) 区间查询:sum(L,R) = P[R] - P[L-1]


R

递推关系(Recurrence Relation) 将 DP 值表示为更小 DP 值的公式。示例:fib(n) = fib(n-1) + fib(n-2)。定义了 DP 的状态转移。


S

线段树(Segment Tree) 支持 O(log N) 区间查询和更新的数据结构。比前缀和更强大(支持更新)。Gold/Platinum 级别话题。

稀疏图(Sparse Graph) 边数相对于 V² 较少的图。实践中:E = O(V)。使用邻接表。

状态(DP State) 唯一标识 DP 子问题的信息集合。背包中的示例:(物品下标, 剩余容量)。选择合适的状态是 DP 的核心技能。

子树(Subtree) 树中某节点的所有后代节点(含该节点本身)。树形 DP 通常计算子树上的聚合值。


T

递推(Tabulation) 从边界条件到更大子问题迭代构建 DP 表格。"自底向上 DP"。无递归,无栈溢出风险。

超时(Time Limit Exceeded / TLE) 评测结果之一,表示解法正确但速度太慢。USACO 中大多数题目的时间限制为 2~4 秒。遇到 TLE,优化算法——而不只是优化常数因子。

拓扑排序(Topological Sort) DAG 中顶点的一种排序方式,使得对每条有向边 u→v,u 都排在 v 前面。可用 DFS(逆后序)或 Kahn 算法(基于 BFS)计算。

双指针(Two Pointers) 使用两个下标遍历数组(通常同向移动)的技术。将 O(N²) 的配对搜索转化为 O(N)。适用于有序数组或条件单调的情形。


U

并查集(Union-Find / DSU) 支持两种操作的数据结构:find(x)(x 属于哪个集合?)和 union(x,y)(合并 x 和 y 所在的集合)。带路径压缩 + 按秩合并:每次操作 O(α(N)) ≈ O(1)。用于动态连通性、Kruskal MST、环检测。


V

顶点(Vertex / Node) 图的基本单元。顶点有编号(USACO 中通常从 1 开始)。


W

答案错误(Wrong Answer / WA) 评测结果之一,表示程序运行了但输出了错误结果。检查边界情况、差一错误和整数溢出。

📊 知识依赖关系图

本交互式地图展示所有章节之间的先修关系。点击任意节点即可高亮显示其前置章节(红色)和依赖章节(绿色)。


基础
数据结构
图论算法
动态规划
贪心
← 前置章节(红色)
→ 解锁章节(绿色)
点击章节节点查看依赖关系


如何阅读本图

颜色含义
🔵 蓝色节点C++ 基础章节(第2.1~3.1章)
🟢 绿色节点核心数据结构章节
🟠 橙色节点图论算法章节
🟣 紫色节点动态规划章节
🔴 红色节点贪心算法章节
红色高亮边所选章节的前置依赖
绿色高亮边所选章节解锁的后续章节

提示: 点击任意节点查看完整的依赖链。再次点击(或按"↺ 清除选中")可以重置。