START YOUR ALGORITHM JOURNEY

让每一次思考,
都更接近最优解。

服务于算法竞赛学习者的成长平台。从第一行 C++ 代码开始,用清晰的知识结构、细致的示例和正确的学习方法,陪你一步一步建立解题能力。

14C++ 入门章节
21+代码示例
0零基础门槛
你的学习起点

从这里,走向更好的自己。

知识不是堆得越多越好,重要的是按照正确的顺序真正掌握。

KNOW THE CONTESTS

算法竞赛介绍

了解 ICPC、CCPC、天梯赛、蓝桥杯与百度之星的特点,找到适合自己的参赛方向。

认识常见竞赛
STEP 01

C++ 语法学习

从代码框架、输入输出到数组、结构体和函数,每个知识点都有逐行解释、运行结果与练习。

进入 14 章教程
STEP 02

算法专题学习

基础算法、搜索、数据结构、动态规划与图论,按难度逐步建立完整知识网络。

查看算法目录
PRACTICE

刷题训练

按照 C++ 教程的学习顺序完成洛谷入门题,把刚学会的语法真正变成解题能力。

打开语法题单
ONLINE JUDGES

常用算法学习与刷题平台

教程帮助你理解知识,题目帮助你真正掌握知识。可以根据当前阶段选择一个主平台持续练习。

OJ 是什么?

Online Judge,在线评测系统。提交代码后,系统会自动编译、运行并判断答案是否正确。

学习建议

初学阶段可以先在洛谷或牛客完成基础题;熟悉常用算法后,再定期参加 Codeforces Div. 3/4 或 AtCoder Beginner Contest。不要同时追逐太多平台,持续练习比平台数量更重要。

COURSE 00 · 从这里开始

C++ 竞赛语法入门

写给第一次接触编程的你。我们会从一份完整的代码框架开始,把每个符号、每行代码和运行结果讲清楚。

零基础友好14 个学习章节竞赛场景优先
01

先看懂

从输入、输出和完整代码框架开始,不要求提前掌握任何编程知识。

02

再动手

每学完一个例子,都自己输入、运行并修改数据,观察程序结果。

03

最后刷题

学完对应语法后进入刷题训练,用协会 OJ 和练习题巩固知识。

READY TO START

从配置编程环境开始

第一次学习建议按左侧编号依次完成,已经有基础也可以直接选择章节。

开始第 01 章
CHAPTER 01 · 开始之前

准备编程环境

编程环境就是我们写代码、检查错误、把代码翻译成程序并运行它的一套工具。Windows 初学者可以在下面两种方案中任选一种。

扩展性更强

VS Code + MinGW-w64

VS Code 本身是代码编辑器,不自带 C++ 编译器,需要额外安装编译工具。

  1. 安装 VS Code,并在扩展商店安装微软的 C/C++ 扩展。
  2. 安装 MinGW-w64 工具链,并确认终端能执行 g++ --version
  3. 新建文件夹并用 VS Code 打开,再创建 hello.cpp
  4. 点击编辑器右上角播放按钮,选择检测到的 g++.exe
查看 VS Code 官方配置教程 ↗
i
到底选哪个?

如果你现在只想学习语法,选小熊猫 C++ 最省心;如果你已经熟悉文件路径、终端和扩展,选 VS Code。两者写出的 C++ 代码没有区别。

确认环境是否成功

新建 hello.cpp,复制下面的代码并运行。看到黑色运行窗口中出现 Hello, World! 就说明环境可用。

hello.cpp
#include <iostream>
using namespace std;

int main() {
    cout << "Hello, World!";
    return 0;
}
运行结果
Hello, World!
运行失败怎么办?

先检查文件名是否以 .cpp 结尾、代码中的标点是否为英文符号、每条语句末尾是否有分号。VS Code 用户还要确认安装的是 C++ 编译器,而不只是 C/C++ 扩展。

CHAPTER 02 · 程序从哪里开始

第一份代码框架

一份竞赛程序就像一张固定格式的答题纸。刚开始不必背下来,先理解每一部分负责什么。

main.cpp
1#include <bits/stdc++.h>
2using namespace std;
3
4int main() {
5    // 解题代码写在这里
6    return 0;
7}
01

#include 引入工具bits/stdc++.h 会一次性引入算法竞赛常用的标准库,让我们能够使用输入输出、数组容器和排序等工具。

02

using namespace std;让我们可以直接写 cout,不用每次都写完整的 std::cout

03

int main() 是程序入口程序运行时会先找到 main 函数,再从左花括号开始逐行执行。

04

return 0; 正常结束告诉操作系统程序顺利执行完毕。在竞赛代码中通常保留这一行。

先认识三种括号() 圆括号通常放条件或参数;{} 花括号包住一段代码;<> 尖括号在这里包住头文件名。

用注释解释“为什么”

注释是写给人看的说明,编译器会忽略它。单行注释从 // 开始;多行注释放在 /**/ 之间。好的注释说明思路、边界或特殊处理,不必逐字翻译代码。

comments.cpp
int answer = 0; // 保存目前找到的最大值

/* 枚举所有候选答案,
   只保留满足条件的最大值 */
for (int x = 1; x <= n; x++) {
    if (valid(x)) answer = max(answer, x);
}
i
注释不能嵌套

不要在一个 /* ... */ 中再放另一组多行注释。临时屏蔽代码时优先使用编辑器的单行注释快捷键,调试完成后及时清理。

全角标点是新手高频错误

代码必须使用英文输入法下的 ;(){} 和双引号。中文的 ( ) 无法通过编译。

CHAPTER 03 · 让程序说话

使用 cout 输出

cout 用来把文字或计算结果输出到屏幕。符号 << 可以理解为“把右边的内容送到屏幕”。

输出 Hello World

hello.cpp
cout << "Hello World" << '\n';
运行结果
Hello World

双引号中的内容叫做字符串,会原样输出。'\n' 表示换到下一行,它虽然由两个可见字符组成,但在 C++ 中代表一个换行符。

连续输出多个内容

output.cpp
cout << "答案是:" << 3 + 5 << '\n';
cout << "A" << " " << "B";
运行结果
答案是:8
A B
"A"字符串,用双引号,可以包含多个字符。
'A'单个字符,用单引号,只能表示一个字符。
endl也能换行,但竞赛大量输出时通常使用更快的 '\n'
TRY IT

现在轮到你

请输出两行文字:第一行是你的名字,第二行是 I love C++!。注意两行之间需要换行。

CHAPTER 04 · 接收题目数据

使用 cin 输入

算法题的数据会通过标准输入交给程序。cin 负责读取数据,>> 可以理解为“把输入送进右边的变量”。

读入两个整数并求和

sum.cpp
int a, b;       // 准备两个整数变量
cin >> a >> b;  // 依次读入 a 和 b
cout << a + b << '\n';
输入
12 8
输出
20

空格和换行都可以分隔输入数据。因此输入写成第一行 12、第二行 8,程序仍能正确读取。

输入 12 8a 保存 12b 保存 8输出 20
变量必须先声明再使用

不能直接写 cin >> a; 而没有提前告诉 C++ 变量 a 的类型。这里的 int a; 就是在声明一个整数变量。

TRY IT

计算长方形面积

输入长和宽两个整数,输出它们的乘积。例如输入 4 6,应该输出 24

CHAPTER 05 · 给数据一个名字

变量与数据类型

变量可以想象成带名字的盒子:盒子中保存数据,类型决定这个盒子能装什么、能装多大。

variable.cpp
int age = 15;             // 整数
long long score = 10000000000LL; // 大整数
double pi = 3.14159;     // 小数
char grade = 'A';        // 单个字符
bool passed = true;    // 真或假
string name = "Alice";  // 字符串
类型适合保存竞赛中如何选择
int普通整数绝对值不超过约 21 亿
long long很大的整数乘法、总和或答案可能很大时优先考虑
double带小数的数据平均数、几何计算等
char / string单字符 / 一段文字字符题、字符串题

固定小数位数

题目要求“保留若干位小数”时,可以使用 fixedsetprecision 控制输出格式。fixed 表示按普通小数形式输出,setprecision(2) 表示小数点后固定保留 2 位。

precision.cpp
#include <iomanip>

double value = 10.0 / 3.0;
cout << fixed << setprecision(2) << value;
运行结果
3.33
i
setprecision 中写的是小数位数

配合 fixed 时,setprecision(3) 会固定保留 3 位小数,例如 2.5 会输出为 2.500。如果省略 fixed,它控制的是有效数字位数,含义不同。

输出格式不会修复整数除法

如果 a 和 b 都是整数,a / b 会先完成整数除法。需要小数结果时,应写成 1.0 * a / b,再使用 fixedsetprecision 控制显示位数。

赋值与修改

change.cpp
int x = 5;
x = 8;      // 把盒子里的 5 换成 8
x = x + 2;  // 读取原来的 8,加 2,再存回 x
cout << x;
运行结果
10

用 sizeof 查看占用空间

sizeof 可以查看一种类型或一个变量占用多少字节(Byte)。它的结果是整数,写类型时通常要加括号,写变量时括号可以省略。

sizeof.cpp
int x = 10;
double price = 3.5;

cout << sizeof(int) << ' ';
cout << sizeof x << ' ';
            cout << sizeof(price);
常见运行结果
4 4 8
i
结果可能因环境而不同

多数竞赛环境中 int 占 4 字节、long longdouble 占 8 字节、char 占 1 字节。真正需要确认时,以当前程序的 sizeof 结果为准。

变量怎样命名?

score / maxScore名字表达含义变量名由字母、数字和下划线组成,不能以数字开头。竞赛代码可以简洁,但仍要让自己看得懂。
Score != score区分大小写C++ 严格区分大小写;sumSumSUM 是三个不同的名字。
int / for / return关键字不能作为名字语言已经使用的关键字不能再拿来命名变量、函数或结构体。
total_score保持一种风格camelCasesnake_case 都可以,重要的是在同一份代码里保持一致。
不要使用双下划线或“下划线 + 大写字母”开头的名字

例如 __value_Count 通常保留给编译器和标准库。自己写代码时使用 valuecount 等普通名称最安全。

不会改变的数据:const 与 constexpr

如果一个值初始化后不应该再被修改,就把它声明为常量。这样既能表达意图,也能让编译器帮你阻止误改。编译期就能确定的常量优先使用 constexpr

constants.cpp
constexpr int MAX_N = 100000; // 编译期常量
int input;
cin >> input;
const int n = input;              // 运行时读到,但之后不修改

int a[MAX_N + 5];
// n = 20;  // 错误:不能修改 const 变量
i
常量优先于宏

现代 C++ 中通常使用 constconstexpr,而不是 #define MAX_N 100000。常量有明确类型,也更容易被编译器检查。

小心整数溢出

100000 * 100000 已超出 int 范围。写成 1LL * 100000 * 100000,或把变量声明为 long long

CHAPTER 06 · 计算与比较

运算符

运算符让程序进行数学计算、比较大小并组合条件。它们是后面判断和循环的基础。

+   -   *   /四则运算整数除法会舍去小数部分:7 / 2 得到 3。
%取余数7 % 2 得到 1,常用来判断奇偶。
==   !=相等 / 不相等比较结果是 true 或 false。
>   <   >=   <=大小比较注意“大于等于”写作 >=
&&   ||   !并且 / 或者 / 取反用来组合多个判断条件。
++   --增加 1 / 减少 1i++ 等价于 i = i + 1

算术运算与整数除法

arithmetic.cpp
int a = 7, b = 2;
cout << a + b << ' ';       // 加法:9
cout << a - b << ' ';       // 减法:5
cout << a * b << ' ';       // 乘法:14
cout << a / b << ' ';       // 整数除法:3
cout << a * 1.0 / b;        // 小数除法:3.5
运行结果
9 5 14 3 3.5

简写赋值与自增自减

update.cpp
int x = 10;
x += 3;  // x = x + 3,现在是 13
x *= 2;  // x = x * 2,现在是 26
x--;     // x = x - 1,现在是 25
cout << x;
运行结果
25

+=-=*=/=%= 都是在原值上计算后再存回变量。单独使用时,++xx++ 都会让 x 加 1;初学阶段不要把它们塞进复杂表达式。

判断一个数是否为偶数

even.cpp
int n;
cin >> n;
cout << (n % 2 == 0);

如果输入 8,表达式 8 % 2 == 0 成立,输出 1;输入 7 时条件不成立,输出 0。

比较与逻辑运算

ticket.cpp
int age;
cin >> age;

bool isTeen = age >= 13 && age <= 18;
bool isFree = age < 6 || age >= 65;
cout << isTeen << ' ' << isFree << ' ';
cout << !isFree;
输入
15
输出
1 0 1

&& 要求两边都成立,|| 只要一边成立,! 会把真假反过来。C++ 默认用 1 表示 true、0 表示 false。

三目运算符:在两个值中选择

条件 ? 条件成立时的值 : 条件不成立时的值 会产生一个结果,适合写简单的二选一。逻辑稍复杂时仍应使用更清晰的 if

ternary.cpp
int a = 7, b = 12;
int larger = (a > b) ? a : b;
cout << larger;
运行结果
12

位运算:直接处理二进制位

位运算会把整数看成一串二进制位。入门阶段先认识含义即可;学习状态压缩、集合表示或快速幂时会再次用到。

a & b按位与某一位只有在 a、b 对应位置都为 1 时才得到 1,常用于检查某个二进制位。
a | b按位或对应位置只要有一个 1 就得到 1,常用于把某个状态位设为 1。
a ^ b按位异或对应位置不同得到 1、相同得到 0;同一个数异或两次会抵消。
1 << k左移把二进制的 1 向左移动 k 位;在不溢出的非负整数范围内等于 2 的 k 次方。
bits.cpp
int mask = 10;              // 二进制 1010
int k = 1;

bool hasBit = (mask & (1 << k)) != 0;
mask = mask | (1 << 2); // 把第 2 位设为 1
cout << hasBit << ' ' << mask;
位运算不是逻辑运算

&| 逐位处理整数;&&|| 判断整个条件的真假。移位只应在位数合法且不会溢出的情况下使用,不要把它机械理解成任何整数都能安全乘除 2。

()
不确定优先级就加括号

* / % 通常先于 + - 计算,例如 2 + 3 * 4 得到 14。培训和比赛中都建议用括号主动表达意图:(2 + 3) * 4 得到 20。

=== 完全不同

x = 5 是把 5 存入 x;x == 5 才是在询问“x 是否等于 5”。这是条件判断中最常见的笔误。

CHAPTER 07 · 让程序做选择

if 条件判断

当条件成立时执行一段代码,不成立时执行另一段代码,这就是分支结构。

score.cpp
int score;
cin >> score;

if (score >= 90) {
    cout << "优秀";
} else if (score >= 60) {
    cout << "及格";
} else {
    cout << "继续努力";
}
输入 95score ≥ 90 ✓输出“优秀”
输入 75第一个条件 ×,score ≥ 60 ✓输出“及格”
输入 40两个条件都不成立进入 else

else if 会按从上到下的顺序检查。一旦某个条件成立并执行,对应的整组判断就结束了,因此更严格的条件通常写在前面。

组合条件

range.cpp
if (age >= 13 && age <= 18) {
    cout << "青少年";
}

多个固定选项:switch

当变量只需要和几个固定值比较时,可以使用 switch。每个 case 表示一种情况,default 处理其他情况。

weekday.cpp
int day;
cin >> day;

switch (day) {
    case 1:
        cout << "Monday";
        break;
    case 2:
        cout << "Tuesday";
        break;
    default:
        cout << "Other day";
}
输入 2 后输出
Tuesday
不要漏掉 break

执行某个 case 后,break 会离开整个 switch。如果漏写,程序会继续执行后面的 case,这种现象称为“贯穿”。

TRY IT

判断正负

输入一个整数。大于 0 输出 positive,等于 0 输出 zero,小于 0 输出 negative

CHAPTER 08 · 重复固定次数

for 循环

当你知道一段代码需要执行多少次,for 循环通常最合适。它把“从哪里开始、何时继续、每次怎样变化”写在同一行。

for (int i = 1; i <= 5; i++)
只在开始时执行一次每轮开始前检查每轮结束后执行
count.cpp
for (int i = 1; i <= 5; i++) {
    cout << i << ' ';
}
运行结果
1 2 3 4 5

循环是怎样运行的?

轮次i 的值i ≤ 5?执行结果
11输出 1,i++
22输出 2,i++
继续重复
66循环结束

经典例子:求 1 到 n 的和

sum_n.cpp
int n, sum = 0;
cin >> n;

for (int i = 1; i <= n; i++) {
    sum += i; // 等价于 sum = sum + i
}
cout << sum;
输入
5
输出
15

提前结束或跳过本轮:break 与 continue

break 会立刻结束整个循环;continue 只跳过当前这一轮,随后进入下一轮。它们既可以用在 for 中,也可以用在 while 中。

loop_control.cpp
for (int i = 1; i <= 10; i++) {
    if (i == 8) break;       // 到 8 时结束循环
    if (i % 2 == 0) continue; // 偶数跳过输出
    cout << i << ' ';
}
运行结果
1 3 5 7

i 为 2、4、6执行 continue,本轮后面的输出语句不再执行。

i 为 8执行 break,整个循环结束,9 和 10 也不会再处理。

嵌套循环:循环里面再写循环

外层循环每执行一轮,内层循环都会从头完整执行。常用于打印图形、枚举行列、处理二维数组。

rectangle.cpp
for (int row = 1; row <= 3; row++) {
    for (int col = 1; col <= 4; col++) {
        cout << '*';
    }
    cout << '\n';
}
运行结果
****
****
****

row 控制行外层共执行 3 轮,因此输出 3 行。

col 控制列每一行中内层执行 4 轮,因此每行输出 4 个星号。

循环体共执行 3 × 4 次若两层分别循环 n 次和 m 次,总次数就是 n × m。

break 只结束它所在的那一层循环

在内层循环执行 break,外层循环仍会继续下一轮。如果需要同时结束两层,通常使用布尔标记,或把这段逻辑封装进函数后用 return

边界差一位(Off-by-one)

i <= n 会包含 n,循环 n 次;i < n 不包含 n,只到 n - 1。写循环前先在纸上明确第一个值和最后一个值。

TRY IT

输出所有偶数

输入 n,输出 1 到 n 之间的所有偶数。你可以让 i 每次加 1 后判断,也可以思考怎样让 i 每次直接加 2。

CHAPTER 09 · 条件成立就继续

while 循环

当循环次数不确定,但“继续执行的条件”很清楚时,使用 while。每一轮开始前,程序都会先检查括号中的条件。

digits.cpp
int n;
cin >> n;

while (n > 0) {
    cout << n % 10 << ' ';
    n /= 10;
}
输入 1234 后输出
4 3 2 1

n % 10取得十进制个位数。

n /= 10删掉个位数,让 n 逐步变为 123、12、1、0。

n > 0当 n 变成 0 时条件不成立,循环结束。

在 while 中使用 break 与 continue

下面的程序不断读入整数:遇到负数就跳过,遇到 0 就结束,其他数累加。循环次数由输入内容决定,所以很适合使用 while

read_until_zero.cpp
int sum = 0;

while (true) {
    int x;
    cin >> x;
    if (x == 0) break;    // 结束整个 while
    if (x < 0) continue; // 跳过负数,继续读下一个
    sum += x;
}
cout << sum;
输入
5 -2 7 0
输出
12
i
while (true) 为什么能结束?

它本身是无限循环,但读到 0 时会执行 break。这种“先循环、满足条件再退出”的写法在处理未知数量的输入时很常见。

至少执行一次:do while

do_while.cpp
int x;
do {
    cin >> x;
} while (x < 0); // 别漏掉最后的分号

while 是先判断再执行,可能一次也不执行;do while 是先执行再判断,因此循环体至少执行一次。

警惕死循环

循环体必须让条件逐渐接近“不成立”。如果漏掉 n /= 10,n 永远不变,程序就会一直运行。遇到这种情况可以手动停止程序。

continue 前要保证状态发生变化

在手动维护循环变量的 while 中,如果先执行 continue,后面的 i++ 就会被跳过,程序可能永远停在同一个值。可以把更新语句放到判断之前,或改用 for

for 还是 while?

for遍历 1 到 n、重复固定次数、遍历数组。
while不断读入直到遇到 0、数字拆位、次数事先未知。
CHAPTER 10 · 保存一组数据

数组

如果要保存 100 个整数,没必要创建 100 个不同名字的变量。数组用一个名字管理一组类型相同的数据。

下标01234
数值83619
!
下标从 0 开始

长度为 5 的数组,下标是 0、1、2、3、4。最后一个元素是 a[4],访问 a[5] 已经越界。

读入 n 个数并求最大值

maximum.cpp
int n;
cin >> n;
int a[1005];

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

int answer = a[0];
for (int i = 1; i < n; i++) {
    answer = max(answer, a[i]);
}
cout << answer;
输入
5
8 3 6 1 9
输出
9

int a[1005] 提前准备 1005 个位置。实际使用前 n 个位置,也就是 a[0]a[n-1]

数组的初始化

initialize.cpp
int a[5] = {8, 3, 6, 1, 9};
int zero[100] = {}; // 所有元素初始化为 0

for (int i = 0; i < 5; i++) {
    cout << a[i] << ' ';
}

局部数组如果没有初始化,里面的值是不确定的,不能直接拿来累加或比较。计数数组常用 {} 将所有位置清零。

二维数组:按行和列保存数据

int a[3][4] 可以看成 3 行 4 列的表格。访问时先写行下标,再写列下标,例如 a[1][2] 表示第 2 行第 3 列。

matrix.cpp
int n, m;
cin >> n >> m;
int a[105][105];

for (int i = 0; i < n; i++) {
    for (int j = 0; j < m; j++) {
        cin >> a[i][j];
    }
}

int sum = 0;
for (int j = 0; j < m; j++) {
    sum += a[0][j]; // 累加第 1 行
}
cout << sum;
输入
2 3
1 2 3
4 5 6
输出
6

i = 0 ... n-1外层循环枚举每一行。

j = 0 ... m-1内层循环枚举这一行的每一列。

a[i][j]表示第 i + 1 行、第 j + 1 列的元素。

TRY IT

寻找二维数组最大值

输入 n 行 m 列的整数矩阵,使用两层循环找出最大值,并输出它所在的行号和列号。

不要访问数组边界之外

数组越界不一定立即报错,但会读写不属于它的内存,导致答案错误甚至程序崩溃。竞赛中应始终检查循环边界。

CHAPTER 11 · 处理文字

字符串 string

string 用来保存一串字符。它很像一个字符数组,同样可以通过从 0 开始的下标访问每个字符。

string.cpp
string s;
cin >> s;

cout << s.size() << '\n';
cout << s[0] << '\n';

for (char c : s) {
    cout << c << ' ';
}
输入 code 后输出
4
c
c o d e
cin >> s读取到空格就停止,适合单个单词。
getline(cin, s)读取完整一行,能够包含空格。
s.size()取得字符串长度,结果是字符数量。

读入含空格的一整行

如果前面刚使用 cin >> 读过数字,输入缓冲区里通常还留着一个换行符。直接调用 getline 可能读到空行。可以使用 getline(cin >> ws, line),先跳过开头残留的空白再读取。

read_line.cpp
int id;
string name;

cin >> id;
getline(cin >> ws, name);

cout << id << ' ' << name;
输入
7
Alice Wang
输出
7 Alice Wang
ws 会跳过所有开头空白

如果题目明确要求保留行首空格,就不要使用这种写法,应先用 cin.ignore() 精确丢弃上一行末尾的换行符,再调用 getline(cin, line)

常用 string 操作

a + b拼接把两个字符串连接起来;也可以使用 s += t 把内容追加到 s 末尾。
s.find(t)查找返回子串 t 第一次出现的位置;找不到时返回 string::npos
s.substr(pos, len)截取从下标 pos 开始取最多 len 个字符;省略 len 会一直取到末尾。
s.insert(pos, t)插入在下标 pos 前插入字符串 t,原有内容自动向后移动。
s.erase(pos, len)删除从下标 pos 开始删除 len 个字符;省略 len 会删除到末尾。
s.replace(pos, len, t)替换把指定范围替换为字符串 t,替换前后的长度可以不同。
string_tools.cpp
string s = "algorithm";

cout << s.substr(0, 4) << '\n'; // algo

size_t pos = s.find("rith");
if (pos != string::npos) {
    cout << pos << '\n';
}

s.insert(4, "-");       // algo-rithm
s.replace(5, 4, "code"); // algo-codem
s.erase(9, 1);            // algo-code
cout << s;
运行结果
algo
4
algo-code
先检查位置,再操作字符串

find 找不到时返回的 string::npos 不是合法下标。不要直接把它传给 eraseinsert 或作为数组下标使用;应先判断 pos != string::npos

判断和转换字符

<cctype> 提供字符分类和大小写转换工具。isalpha 判断字母,isdigit 判断数字,islowerisupper 判断大小写,tolowertoupper 完成转换。

characters.cpp
string s = "A1go";
int digits = 0;

for (char &c : s) {
    unsigned char value = static_cast<unsigned char>(c);
    if (isdigit(value)) digits++;
    c = static_cast<char>(toupper(value));
}

cout << s << ' ' << digits;
i
为什么先转成 unsigned char?

<cctype> 的函数要求参数能安全表示为无符号字符。这个写法兼容性更稳;处理普通 ASCII 字母和数字时,最终效果与直接传入 char 相同。

字符串与数字互相转换

convert.cpp
string text = "2026";
int year = stoi(text);

long long score = 123456789LL;
string result = to_string(score);

cout << year + 1 << ' ' << result.size();
运行结果
2027 9
stoi(s)把字符串转换为 int;输入必须包含合法数字。
stoll(s)把字符串转换为 long long,适合更大的整数。
to_string(x)把数值转换成 string,便于拼接或逐位处理。

判断回文串

palindrome.cpp
bool ok = true;
for (int i = 0; i < s.size(); i++) {
    if (s[i] != s[s.size() - 1 - i]) {
        ok = false;
    }
            }

第 i 个字符与倒数第 i 个字符比较。注意最后一个字符下标是 s.size() - 1

TRY IT

实现一个迷你文字处理器

读入一个初始字符串和若干操作,分别完成插入、截取与查找。查找不到时输出 -1;使用前面的 string::npos 判断,不要把它直接转换成普通下标。

CHAPTER 12 · 把相关数据放在一起

结构体 struct

结构体可以把多个不同类型、但彼此相关的数据组合成一个整体。竞赛中常用它表示学生、坐标、边、区间或题目记录。

定义一种新的数据结构

student.cpp
struct Student {
    string name;
    int score;
    int age;
}; // 结构体定义结束后需要分号

Student我们创建的新类型名称,以后可以像 int 一样用它声明变量。

成员变量name、score 和 age 描述一名学生的不同信息。

末尾分号结构体右花括号后必须写分号,这是非常常见的编译错误。

创建变量并访问成员

use_struct.cpp
Student a;
a.name = "Alice";
a.score = 95;
a.age = 16;

cout << a.name << ' ' << a.score;
运行结果
Alice 95

点号 . 表示访问结构体中的某个成员。例如 a.score 就是“学生 a 的分数”。

结构体数组:保存多名学生

students.cpp
int n;
cin >> n;
Student students[105];

for (int i = 0; i < n; i++) {
    cin >> students[i].name >> students[i].score;
}

int best = 0;
for (int i = 1; i < n; i++) {
    if (students[i].score > students[best].score) {
        best = i;
    }
}
cout << students[best].name;
输入
3
Alice 95
Bob 88
Carol 97
输出
Carol

初始化与结构体嵌套

成员值可以按照结构体中声明的顺序一次写出。结构体成员也可以是另一个结构体或容器,用来表示更完整的数据关系。

nested_struct.cpp
Student alice = {"Alice", 95, 16};

struct Team {
    string name;
    Student members[2];
};

Team team = {
    "AlgoSpark",
    {{"Alice", 95, 16}, {"Bob", 88, 17}}
};

cout << team.members[0].name;

把结构体传给函数

结构体可能包含字符串和容器,值传递会复制全部成员。只读时通常使用 const Student &:既避免复制,也保证函数不会修改原对象。

print_student.cpp
void printStudent(const Student &s) {
    cout << s.name << ' ' << s.score;
}

Student alice = {"Alice", 95, 16};
printStudent(alice);
运行结果
Alice 95
TRY IT

表示二维坐标

定义结构体 Point,包含整数成员 x 和 y。输入两个点,输出它们横坐标之和与纵坐标之和。

CHAPTER 13 · 给一段逻辑命名

函数

函数把一段可以重复使用的逻辑封装起来。它可以接收参数,完成计算,再把结果返回给调用者。

int返回值类型
maximum函数名称
(int a, int b)参数列表
function.cpp
int maximum(int a, int b) {
    if (a > b) {
        return a;
    }
    return b;
}

int main() {
    int answer = maximum(7, 12);
    cout << answer; // 输出 12
    return 0;
}
调用 maximum(7, 12)a=7,b=12返回 12answer=12
int f()函数会返回一个整数,需要写 return。
bool f()函数返回 true 或 false,常用于判断。
void f()函数不返回结果,只执行某些操作。

参数与返回值

调用函数时,括号中传入的是实参;函数定义中接收数据的 a、b 是形参。普通参数会复制一份数据,修改形参不会影响外面的变量。

parameter.cpp
void addOne(int x) {
    x++;
}

int main() {
    int n = 5;
    addOne(n);
    cout << n;
}
运行结果
5

引用传参:修改调用者的变量

在参数类型后加 & 表示引用。此时参数是外部变量的别名,对它的修改会保留下来。

reference.cpp
void swapValue(int &a, int &b) {
    int temp = a;
    a = b;
    b = temp;
}

int x = 3, y = 8;
swapValue(x, y);
cout << x << ' ' << y;
运行结果
8 3
int x值传递:复制一份,函数内修改不影响原变量。
int &x引用传递:直接操作原变量,可以把修改带出函数。
const string &s只读引用:避免复制较大的数据,同时禁止函数修改它。

void 函数与提前 return

print_positive.cpp
void printPositive(int x) {
    if (x <= 0) {
        return; // 立即结束函数,不返回具体数值
    }
    cout << x;
}

void 函数不返回计算结果,但仍可以用单独的 return; 提前结束。一个有返回值的函数则要保证每条可能执行的路径都能返回正确类型的值。

声明顺序与函数声明

C++ 需要先知道函数长什么样,才能调用它。可以把完整函数写在 main 前,也可以先写函数声明,再把实现放到 main 后。

declaration.cpp
int square(int x); // 函数声明:末尾有分号

int main() {
    cout << square(6);
}

int square(int x) { // 函数实现
    return x * x;
}
局部变量只在函数内部有效

在函数的花括号中声明的变量,离开函数后就不能再访问。不同函数可以拥有同名的局部变量,它们互不影响。

TRY IT

封装一个判断函数

编写 bool isPrime(int n),判断 n 是否为质数。在 main 中读入一个整数,根据函数返回值输出 YesNo

CHAPTER 14 · 竞赛常用工具

STL 常用工具

STL 是 C++ 标准库提供的一套现成工具。入门阶段先掌握 vectorsort,以及 maxminswap 等常用函数。

vector:长度可变化的数组

vector.cpp
vector<int> a;
a.push_back(8); // 在末尾加入 8
a.push_back(3); // 在末尾加入 3
a.push_back(6); // 在末尾加入 6

cout << a.size(); // 输出 3
a.push_back(x)末尾添加把 x 放到 vector 最后,长度自动增加 1。
a.pop_back()删除末尾删除最后一个元素;调用前必须确认 vector 不为空。
a.front() / a.back()访问两端分别取得第一个和最后一个元素,同样要求容器非空。
a.empty() / a.clear()判空 / 清空empty() 判断是否没有元素,clear() 删除全部元素。
a.insert(a.begin() + p, x)指定位置插入在下标 p 前插入 x,p 必须位于 0 到 size 之间。
a.erase(a.begin() + p)指定位置删除删除下标 p 的元素,p 必须是现有元素的合法下标。
vector_operations.cpp
vector<int> a = {8, 3, 6};

a.push_back(9);
cout << a.front() << ' ' << a.back() << '\n';

a.pop_back();
cout << a.size();
运行结果
8 9
3
不要对空 vector 调用 frontbackpop_back

空容器不存在首尾元素,这些操作会产生未定义行为。数据可能为空时,应先写 if (!a.empty())

sort:从小到大排序

sort.cpp
int n;
cin >> n;
vector<int> a(n);

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

sort(a.begin(), a.end());

for (int x : a) cout << x << ' ';
输入
5
8 3 6 1 9
输出
1 3 6 8 9

vector<int> a(n)创建长度为 n 的整数 vector。

a.begin()指向第一个元素的位置。

a.end()指向最后一个元素后面的位置,sort 排序范围左闭右开。

降序排序与普通数组排序

sort_more.cpp
vector<int> a = {8, 3, 6, 1};
sort(a.begin(), a.end(), greater<int>());

int b[] = {7, 2, 9, 4};
int n = 4;
sort(b, b + n);
排序结果
a: 8 6 3 1
b: 2 4 7 9
i
只排序一部分也使用左闭右开范围

sort(a.begin() + l, a.begin() + r) 排序下标 [l, r),包含 l、不包含 r。普通数组的 sort(b + l, b + r) 也是同一规则。

按结构体成员排序

sort 提供比较函数,就能规定结构体的先后顺序。下面先按分数从高到低;分数相同时,再按姓名字典序从小到大。

sort_students.cpp
struct Student {
    string name;
    int score;
};

bool better(const Student &a, const Student &b) {
    if (a.score != b.score) return a.score > b.score;
    return a.name < b.name;
}

vector<Student> students = {
    {"Bob", 88}, {"Carol", 97}, {"Alice", 95}
};

sort(students.begin(), students.end(), better);
for (const Student &s : students) {
    cout << s.name << ' ' << s.score << '\n';
}
比较函数不要使用 >=<=

当两个对象完全相等时,比较结果必须为 false,否则排序规则会自相矛盾。需要并列规则时,继续比较另一个成员,最后仍使用严格的 <>

max、min 与 swap

max 取较大值,min 取较小值,swap 交换两个变量的值。它们能让常见操作写得更直接。

basic_tools.cpp
int a = 8, b = 3;

cout << max(a, b) << ' '; // 较大值
cout << min(a, b) << '\n'; // 较小值

swap(a, b);
cout << a << ' ' << b;
运行结果
8 3
3 8
i
它们是标准库函数,不是 C++ 关键字

使用标准头文件时,maxmin 位于 <algorithm>swap 可由 <utility> 提供。竞赛中常用的 #include <bits/stdc++.h> 已经包含这些头文件。

max 和 min 的两个参数通常要类型一致

例如 max(3, 4LL) 的一个参数是 int、另一个是 long long,可能无法编译。可以写成 max(3LL, 4LL),让两边类型保持一致。

处理数组和 vector 的常用函数

reverse翻转顺序reverse(a.begin(), a.end()) 将整个 vector 前后颠倒。
count统计出现次数count(a.begin(), a.end(), 3) 统计 3 出现了几次。
find寻找元素找不到时返回 a.end(),使用前要先判断。
max_element寻找最大元素返回元素所在位置,前面加 * 取得数值。
min_element寻找最小元素用法与 max_element 相同。
fill批量赋值fill(a.begin(), a.end(), 0) 把所有元素改成 0。
vector_tools.cpp
vector<int> a = {8, 3, 6, 3, 9};

cout << count(a.begin(), a.end(), 3) << '\n';
cout << *max_element(a.begin(), a.end()) << '\n';

reverse(a.begin(), a.end());
for (int x : a) cout << x << ' ';
原始数据
8 3 6 3 9
输出
2
9
9 3 6 3 8
不要对空容器解引用

如果 vector 为空,max_element 会返回 a.end(),此时不能在前面加 *。应先用 a.empty() 判断容器中是否有元素。

数值处理中也很常用

abs(x)取得绝对值,例如 abs(-7) 得到 7。
sqrt(x)计算平方根,返回小数,例如 sqrt(16) 得到 4。
pow(a, b)计算 a 的 b 次方;整数幂在竞赛中常用循环计算,避免浮点误差。
TRY IT

使用标准库处理一组数

读入 n 个整数,输出其中的最大值、最小值和某个指定数字出现的次数,然后将整个序列逆序输出。

你已经具备写基础竞赛程序的语法能力

下一步不要急着学习更多语法。先用这些知识完成求和、最大值、统计、模拟等基础题目,让输入—计算—输出的过程变得熟练。

PRACTICE MAKES PROGRESS

刷题训练

教程负责把知识讲明白,题目负责让知识真正属于你。语法题按课程顺序练习,算法题按模拟、贪心、二分等专题逐步进阶。

44 道洛谷训练题3—8 分 手动完成积分协会 OJ AC 自动积分
我的刷题进度0 / 0

洛谷题可手动标记完成,协会 OJ 题需判题通过。当前积分:0

推荐做题方式

先独立思考,再运行样例,最后提交。

  1. 01读清输入和输出
  2. 02在纸上写出计算过程
  3. 03自己输入代码并调试
STAGE 01 · 对应教程 02—04

输入、输出与第一份程序

熟悉完整代码框架、coutcin 和最基本的计算。

5 题
01
洛谷 · B2002输出

Hello,World!

写出第一份完整程序,使用 cout 输出指定文字。

去做题 ↗
02
洛谷 · P1000多行输出

超级玛丽游戏

练习按题目要求准确输出多行字符图案。

去做题 ↗
03
洛谷 · P1001输入输出

A+B Problem

读入两个整数,计算并输出它们的和。

去做题 ↗
04
洛谷 · P5703乘法

苹果采购

根据人数和每人分到的数量,计算需要采购的苹果总数。

去做题 ↗
05
洛谷 · P5704字符

字母转换

读入一个小写字母,理解字符处理与输出。

去做题 ↗
STAGE 02 · 对应教程 05—06

变量、数据类型与运算符

练习整数、小数、字符以及常用算术运算。

5 题
06
洛谷 · P5705小数

数字反转

读取一个小数并按相反顺序输出,熟悉数据表现形式。

去做题 ↗
07
洛谷 · P5706除法

再分肥宅水

练习浮点数除法与整数数量计算。

去做题 ↗
08
洛谷 · P5708公式

三角形面积

把数学公式翻译成 C++ 表达式,注意小数精度。

去做题 ↗
09
洛谷 · P1425时间计算

小鱼的游泳时间

使用整除和取余处理小时与分钟。

去做题 ↗
10
洛谷 · P3954加权计算

成绩

按照比例计算综合成绩,巩固表达式和类型转换。

去做题 ↗
STAGE 03 · 对应教程 07

if 与条件判断

根据不同输入选择不同执行路径,练习逻辑表达式。

5 题
11
洛谷 · P5710逻辑运算

数的性质

综合使用与、或、非,判断一个整数具有哪些性质。

去做题 ↗
12
洛谷 · P5711复合条件

闰年判断

把闰年规则写成准确的布尔表达式。

去做题 ↗
13
洛谷 · P5713方案比较

洛谷团队系统

计算两种方案的代价,再根据结果做出选择。

去做题 ↗
14
洛谷 · P5716多分支

月份天数

综合年份和月份,判断当月具有多少天。

去做题 ↗
15
洛谷 · P5717分类讨论

三角形分类

对边长排序并进行多种条件判断,训练分类顺序。

去做题 ↗
STAGE 04 · 对应教程 08—09

for 与 while 循环

让程序重复工作,完成统计、累加和过程模拟。

5 题
16
洛谷 · P5718遍历

找最小值

循环读入一组数字,在遍历过程中维护最小值。

去做题 ↗
17
洛谷 · P5720while

一尺之棰

不断让长度减半,统计需要多少天。

去做题 ↗
18
洛谷 · P5721嵌套循环

数字直角三角形

使用两层循环控制行、列和连续编号。

去做题 ↗
19
洛谷 · P5722累加

数列求和

使用循环累加 1 到 n,理解累加器变量。

去做题 ↗
20
洛谷 · P5723循环判断

质数口袋

在预算范围内寻找质数,综合循环、条件与累计。

去做题 ↗
STAGE 05 · 对应教程 10—11

数组与字符串

保存一批数据,并按下标访问、统计或修改它们。

6 题
21
洛谷 · P1427数组

小鱼的数字游戏

把输入保存到数组,再按照相反顺序输出。

去做题 ↗
22
洛谷 · P5727数组 + while

冰雹猜想

模拟数值变化过程并保存结果,最后倒序输出。

去做题 ↗
23
洛谷 · P5728二维数据

旗鼓相当的对手

保存多名同学的成绩,并比较每一对同学。

去做题 ↗
24
洛谷 · P5730字符图形

显示屏

使用数组保存数字图案,按行组合并输出。

去做题 ↗
25
洛谷 · P5733字符转换

自动修正

遍历字符串,把其中的小写字母转换为大写。

去做题 ↗
26
洛谷 · P5734string 操作

文字处理软件

练习字符串插入、截取、查找等常用操作。

去做题 ↗
STAGE 06 · 对应教程 12—14

函数、结构体与排序

拆分重复逻辑、组织复合数据,并使用标准库完成排序。

6 题
27
洛谷 · P5735函数

距离函数

把两点距离封装成函数,重复调用并求和。

去做题 ↗
28
洛谷 · P5736布尔函数

质数筛

编写判断质数的函数,过滤并输出符合条件的数字。

去做题 ↗
29
洛谷 · P5737函数复用

闰年展示

复用闰年判断函数,输出区间中的所有闰年。

去做题 ↗
30
洛谷 · P5738数据处理

歌唱比赛

封装评分计算,去掉最高分和最低分后求平均值。

去做题 ↗
31
洛谷 · P5740结构体

最厉害的学生

用结构体保存姓名和三科成绩,寻找总分最高者。

去做题 ↗
32
洛谷 · P5715sort

三位数排序

把三个数放入容器并从小到大输出,练习 sort。

去做题 ↗
题目来源说明

本页题目主要选自洛谷《深入浅出程序设计竞赛》官方入门题单。完成后可手动标记并获得 3—5 积分;协会 OJ 题仍按 Hydro 实际判题结果自动计分。

查看洛谷官方题单 ↗
ASSOCIATION OJ · GRAMMAR

协会 OJ · 语法训练题

这些题目由协会 Hydro 自动判题,AC 后本站会自动点亮并计入积分。

正在加载协会语法题…
COURSE 00 · 建立算法知识体系

算法学习

语法告诉我们代码怎样写,算法告诉我们问题应该怎样解决。这里会从读懂题目开始,逐步建立复杂度意识,再进入数据结构、动态规划、图论和数论。

01

基础算法

读题、复杂度、模拟、贪心、二分、前缀和、差分以及概率与期望。

9 节已开放
02

数据结构

学习线性结构、栈与队列、树等组织和处理数据的方法。

路线已规划
03

动态规划

学习状态设计、转移方程、递推顺序与空间优化。

线性、背包、树形、换根、区间 DP 已开放
04

图论

从图的存储与遍历开始,进入最短路和生成树。

路线已规划
05

数论

掌握整除、质数、快速幂和同余等竞赛工具。

路线已规划
RECOMMENDED START

先完成基础算法

先建立读题和复杂度意识,再学习具体算法会更稳。

参考与延伸阅读

课程结合竞赛常用模型重新组织,并提供可追溯的权威资料:OI Wiki · 复杂度二分前缀和与差分动态规划基础背包 DP树形 DP。先学本站的结构化讲解,需要证明、拓展和更多例题时再继续阅读原资料。

TRACK 01 · FOUNDATION

基础算法路线

这一阶段先建立正确的解题流程和复杂度意识,再学习模拟、贪心、二分、前缀和、差分以及概率与期望。

读懂题目分析复杂度掌握算法思想独立完成题目
FOUNDATION 01

从读懂题目格式开始

先把题目描述翻译成清晰的输入、计算和输出任务。

开始第一节
FOUNDATION 04 · 按题意还原过程

模拟

模拟就是让程序按照题目规定的顺序,把一个过程一步一步执行出来。它通常没有难记的公式,真正考查的是:能否把较长的文字规则翻译成明确的状态、操作顺序和边界判断。

识别信号题目反复出现“依次”“每次”“执行操作”“经过若干轮”“最终状态”等描述。
核心问题当前需要保存什么?读到一次操作后,哪些状态会怎样变化?
常见复杂度有 q 次操作,每次只做常数次计算,通常就是 O(q)
01

删掉故事背景

先用一句话概括任务,例如“机器人按指令移动,越界时原地不动”。

02

列出状态变量

只保存会影响后续过程的数据,例如位置、方向、时间、余额、队列或棋盘。

03

写出单步规则

明确一次操作的读取、计算、合法性检查和状态更新顺序。

04

手算后再编码

用表格走完一个小样例,确认每一步的状态都和题意一致。

示例:网格中的机器人

机器人位于 n 行 m 列的网格中,初始位置为 (x, y)。依次执行字符串中的指令:UDLR 分别尝试向四个方向移动一格;如果新位置越界,本次移动无效,机器人留在原地。

状态

当前行 x、当前列 y

一次操作

根据当前字符计算候选位置 (nextX, nextY)

边界

1 ≤ nextX ≤ n1 ≤ nextY ≤ m 时才能移动。

答案

全部指令执行完后的 x y

先在纸上走一遍

网格大小为 3 × 4,起点为 (2, 2),指令是 UURRDDDRU。第二次 U、第三次 D 和 R 都会越界,因此状态不变。

指令执行前候选位置执行后
U(2, 2)(1, 2)(1, 2)
U(1, 2)(0, 2),越界(1, 2)
R、R(1, 2)(1, 3) → (1, 4)(1, 4)
D、D、D(1, 4)(2, 4) → (3, 4) → 越界(3, 4)
R、U(3, 4)越界 → (2, 4)(2, 4)

把单步规则翻译成代码

robot.cpp
int n, m, x, y;
string commands;
cin >> n >> m >> x >> y >> commands;

for (char command : commands) {
    int nextX = x, nextY = y;

    if (command == 'U') nextX--;
    else if (command == 'D') nextX++;
    else if (command == 'L') nextY--;
    else if (command == 'R') nextY++;

    bool inside = nextX >= 1 && nextX <= n
               && nextY >= 1 && nextY <= m;
    if (inside) {
        x = nextX;
        y = nextY;
    }
}
cout << x << ' ' << y << '\n';
输入
3 4 2 2
UURRDDDRU
输出
2 4
i
为什么先计算 nextX、nextY?

候选状态把“尝试移动”和“正式更新”分开。只有候选位置合法时才同时更新 x、y,越界时自然保留旧状态。规则较多时,这比边判断边修改更不容易出错。

复杂模拟先做“统一表示”

统一单位时间统一换成秒,方向统一编号为 0—3,坐标统一采用同一种下标规则,减少分类讨论。
拆出单步函数把“一次操作”写成独立函数;主流程只负责按顺序调用,更容易单独测试。
维护不变量写下每步结束后始终成立的条件,例如位置一定在网格内、余额不能为负,并在调试时检查。

模拟题最常见的四类错误

状态漏记

只记录当前位置,却忘记方向、剩余次数等会影响下一步的数据。

顺序写反

题目要求“先扣费再判断”,代码却先判断后扣费,结果会完全不同。

边界差一

混淆下标从 0 还是从 1 开始,或把 < 写成 <=

输出时机错误

题目要最终状态,却在每一步输出;或者需要记录过程,却只保存最终答案。

能模拟,不代表逐步模拟一定来得及

如果操作次数只有 10^5,逐步执行通常没有问题;如果题目要求执行 10^18 次,就要寻找周期、公式或批量处理方法。先看数据范围,再决定是否真的一步一步做。

写模拟题前检查
我能否用一句话说清整个过程?
哪些变量组成完整状态?
一次操作的更新顺序是否固定?
第一步、最后一步和越界情况是否手算过?
TRY IT

日期推进一天

输入一个合法日期,输出它的下一天。先列出 year、month、day 三个状态,再分别处理普通日期、月末、年末和闰年二月。不要一开始就写一长串 if。

FOUNDATION 05 · 做出当前最优选择

贪心

贪心算法会按照某条规则不断做出当前选择,并且不回头修改。代码往往只有“排序 + 遍历”,但真正的难点是找到正确规则,并说明这个局部选择为什么一定能组成全局最优答案。

贪心不是“每次挑最大的”

“最大、最小、最早、最短”都只是候选策略。只有能够通过证明,并且找不到反例的策略,才能称为正确的贪心算法。

01

明确优化目标

到底是让数量最多、总代价最小、等待时间最短,还是让剩余空间最大?

02

提出局部规则

思考当前选谁会给未来留下更有利的局面,而不是只看眼前数值大小。

03

先攻击自己的策略

构造 3—5 个元素的小数据,专门寻找能让策略失败的反例。

04

给出正确性理由

常用交换论证:把某个最优解的第一步换成贪心选择,答案不会变差。

经典例子:选择最多不重叠区间

每个活动占用一个时间区间 [left, right),目标是在同一间教室中安排尽可能多的活动。两个活动满足“后一个活动的开始时间 ≥ 前一个活动的结束时间”时不冲突。

最早开始?可能很早开始、很晚结束,一次占满几乎所有时间。
持续最短?短活动可能卡在中间,同时挡住前后两个活动。
最早结束 ✓结束越早,为所有尚未选择的活动留下的时间越多。

手算选择过程

把活动按结束时间从小到大排序后,依次检查。以下区间最终会选择 [2,3)[3,5)[5,6)[6,8),答案为 4。

当前区间上次结束是否选择原因
[2, 3)选择第一个结束的活动
[1, 4)3跳过1 < 3,发生冲突
[3, 5)3选择3 ≥ 3,可以衔接
[5, 6)5选择5 ≥ 5,可以衔接
[4, 7)6跳过4 < 6,发生冲突
[6, 8)6选择6 ≥ 6,可以衔接

完整代码

interval_greedy.cpp
struct Activity {
    long long left, right;
};

bool byEnd(const Activity &a, const Activity &b) {
    if (a.right != b.right) return a.right < b.right;
    return a.left < b.left;
}

int n;
cin >> n;
vector<Activity> activities(n);
for (Activity &activity : activities) {
    cin >> activity.left >> activity.right;
}

sort(activities.begin(), activities.end(), byEnd);

int answer = 0;
long long lastEnd = LLONG_MIN;
for (Activity activity : activities) {
    if (activity.left >= lastEnd) {
        answer++;
        lastEnd = activity.right;
    }
}
cout << answer << '\n';

按结束时间排序让最早结束的可选活动最先被考虑。

lastEnd记录最后一个已选活动的结束时间,用于判断冲突。

总复杂度排序是 O(n log n),之后遍历一次是 O(n)

为什么“最早结束”一定正确?

设贪心选择的第一个活动是 A,任意一个最优方案选择的第一个活动是 B。因为 A 是所有活动中结束最早的,所以 A 的结束时间不会晚于 B。

最优方案先选 B用 A 替换 BA 结束得更早后续活动仍然都能安排

替换之后,活动数量没有减少,而且方案仍然合法。因此一定存在一个“第一步选择 A”的最优方案。对剩余活动重复同样的推理,贪心选择就能得到全局最优答案。这就是一个完整的交换论证

交换论证把任意最优解中的某一步换成贪心选择,证明可行性不变且答案不会更差。
归纳 / 始终领先证明完成前 k 步后,贪心方案至少不劣于任何其他方案,再推进到第 k + 1 步。
无法证明怎么办把选择后剩余的问题写出来;若未来还需要比较多种历史状态,通常应考虑 DP,而不是强行贪心。

用反例排除看似合理的策略

错误策略

每次选择持续时间最短的活动。

反例

[0,5)[4,6)[5,10)

贪心结果

选择最短的 [4,6) 后,另外两个都不能选,答案为 1。

真正最优

选择 [0,5)[5,10),答案为 2。

一种题能贪心,不代表相似题也能照搬

例如硬币面值为 1、3、4,凑出 6。每次拿最大硬币会得到 4 + 1 + 1,共 3 枚;最优方案是 3 + 3,只需 2 枚。局部最优没有自动保证全局最优。

使用贪心前检查
优化目标到底是什么?
我的局部选择为未来保留了什么优势?
能否构造小数据推翻它?
能否用交换或替换说明答案不会变差?
TRY IT

安排最多活动

给出若干活动的开始与结束时间。先不用写代码:分别尝试“最早开始、持续最短、最早结束”三种策略,并为前两种寻找反例;确认理由后,再独立实现按结束时间排序的做法。

FOUNDATION 01 · 解题的第一步

读懂题目格式

一道算法题通常由题目描述、输入格式、输出格式、数据范围和样例组成。很多错误不是算法不会,而是没有把输入、输出或数据范围读准确。

01

题目描述

说明需要解决什么问题。先把故事背景翻译成一句明确任务,例如“求区间和”或“寻找第一个满足条件的位置”。

02

输入格式

说明程序会读到哪些数据、每个数据的含义以及排列顺序。变量声明和循环次数都来自这里。

03

输出格式

说明最终要输出什么。空格、换行、保留小数位数以及输出顺序都可能影响评测结果。

04

数据范围

决定数据类型和算法复杂度。看到 n ≤ 10^5,通常就不能使用 O(n²)

示例:读入两个整数并求和

题目描述

给定两个整数 ab,输出它们的和。

输入格式

一行两个整数 a, b,以空格分隔。

输出格式

输出一个整数,表示 a + b

数据范围

|a|, |b| ≤ 10⁹

把数据范围翻译成设计约束

题面信息需要推导什么常见决定
n、m、q总共会处理多少数据和询问估算 n × qn × m 是否可行
|a[i]|、答案上界中间结果最大可能是多少选择 intlong long 或取模
时间 / 内存限制允许的操作量与状态数量选择算法,并决定能否开二维数组
有序、连续、可重复输入隐含的结构和合法情况决定能否二分、怎样去重、边界是否包含
样例是证据,不是完整规格

样例只展示少数情况,不能替代题面。写代码前主动补测:最小规模、最大值、全相等、负数或零、答案在首尾,以及“无解”是否可能。若题目保证某条件成立,就按保证实现;不要从样例自行猜规则。

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

int main() {
    long long a, b;
    cin >> a >> b;
    cout << a + b << '\n';
    return 0;
}
输入
12 8
输出
20
提交前检查
是否读入了所有数据?
输出内容和顺序是否完全正确?
数据类型能否容纳最大答案?
是否考虑最小值、最大值和特殊情况?
FOUNDATION 02 · 程序能否按时完成

时间复杂度

时间复杂度描述输入规模 n 增大时,程序操作次数增长得有多快。它不直接等于运行秒数,而是帮助我们在写代码之前判断算法是否可能超时。

O(1)常数直接计算
O(log n)对数二分查找
O(n)线性遍历数组
O(n log n)线性对数高效排序
O(n²)平方两层枚举

怎样从代码看出复杂度?

O(1)执行次数与 n 无关
int answer = a[0] + a[n - 1];
O(n)一层循环执行 n 次
for (int i = 0; i < n; i++)
    sum += a[i];
O(n²)两层循环各执行约 n 次
for (int i = 0; i < n; i++)
    for (int j = 0; j < n; j++)
        check(i, j);

四条推导规则

顺序执行:相加O(n) + O(m) = O(n + m),不要在有两个输入规模时强行写成 O(n)
嵌套执行:相乘外层 n 次、每次内层 m 次,总计 O(nm)
分支:看最坏情况竞赛题通常分析最坏时间;if 的两个分支取增长更快的一边。
规模反复减半n → n/2 → n/4,直到 1,次数为 O(log n)
i
大 O 保留增长速度

3n² + 5n + 20 写作 O(n²):忽略常数和低阶项,是为了描述 n 增大后的主导趋势。但两种同为 O(n) 的程序,常数、缓存访问和实现方式仍会影响真实速度。

根据数据范围估计算法

输入规模 n通常可以考虑直观理解
n ≤ 20指数级、状态枚举可以尝试枚举许多组合
n ≤ 500O(n³)三层循环需要谨慎
n ≤ 5,000O(n²)大约数千万次操作
n ≤ 10⁵O(n log n)O(n)排序、二分、线性遍历
n ≤ 10⁷O(n)通常只能做少量遍历
估算不是绝对规则

实际速度还受到常数、语言、内存访问和评测机影响。竞赛中常用“约一秒执行一亿次简单操作”进行粗略判断,但不能把它当成精确保证。

FOUNDATION 03 · 程序需要多少内存

空间复杂度

空间复杂度描述算法额外使用的存储空间怎样随输入规模增长。数组、容器、递归调用栈都会占用内存;超过题目的内存限制会得到 MLE。

单个变量O(1)

无论 n 多大,只使用固定数量的变量。

int sum, maximum;
一维数组O(n)

保存 n 个同类型元素,空间随 n 线性增长。

vector<int> a(n);
二维数组O(n²)

保存 n × n 个元素,n 增大时内存增长很快。

int grid[n][n];

把元素数量换算成内存

常见的 int 通常占 4 字节,long long 通常占 8 字节。估算数组内存时,可以使用:

数组内存元素个数 × 每个元素的字节数

int a[1'000'000] 大约占用 1,000,000 × 4 B ≈ 3.8 MB

256 MB 大约能保存6,700 万个 int实际程序还需要其他内存,不能把限制全部用满
10000 × 10000 的 int 数组约 381 MB在 256 MB 限制下会超出内存
i
原地算法

如果算法直接在输入数组上修改数据,只使用少量额外变量,它的额外空间可能是 O(1)。分析时要区分“输入本身占用的空间”和“算法额外申请的空间”。

别漏算这些空间

递归调用栈递归深度为 n 时,即使没有显式数组,也可能使用 O(n) 栈空间并触发栈溢出。
容器与副本vector 除元素外还有少量管理开销;按值传递大容器还会产生一份完整副本。
滚动数组若当前状态只依赖前一层,可以复用存储,把 O(nW) 空间降到 O(W)
大数组放在哪里也重要

巨大的局部数组通常位于调用栈,可能在远未达到题目内存限制前就崩溃。竞赛中常用 vector 动态分配,或在确有固定上界时使用全局数组;无论哪种方式都要先计算字节数。

FOUNDATION 06 · 每次排除一半答案

二分查找

二分查找利用数据的有序性或答案的单调性,每次检查中间位置并排除一半范围,把线性查找的 O(n) 降低为 O(log n)

使用二分前必须确认

搜索范围具有单调性

例如数组已经从小到大排序;或者某个条件在一段范围内为 false,之后全部为 true。

falsefalsefalse边界truetruetrue

示例:在有序数组中寻找 13

下标0123456
数值25813172130

第 1 次:left = 0, right = 6, mid = 3

发现 a[3] = 13,目标找到。

查找某个值是否存在

binary_search.cpp
bool exists(const vector<int>& a, int target) {
    int left = 0;
    int right = (int)a.size() - 1;

    while (left <= right) {
        int mid = left + (right - left) / 2;
        if (a[mid] == target) return true;
        if (a[mid] < target) left = mid + 1;
        else right = mid - 1;
    }
    return false;
}

left <= right闭区间 [left, right] 还有元素时继续查找。

left + (right-left)/2(left+right)/2 含义相同,但能够避免两数相加溢出。

更新时越过 mid已经检查过 mid,所以使用 mid+1mid-1,否则可能死循环。

竞赛更常用:寻找第一个满足条件的位置

对于形如 false false false true true 的单调序列,可以在左闭右开区间 [left, right) 中寻找第一个 true。循环始终维护:答案一定还在当前区间内。

first_true.cpp
int firstTrue(int left, int right) { // 搜索 [left, right)
    while (left < right) {
        int mid = left + (right - left) / 2;
        if (check(mid)) right = mid; // mid 可能就是答案
        else left = mid + 1;      // mid 一定不是答案
    }
    return left;
}

直接使用标准库边界函数

lower_bound返回第一个 大于等于 x 的位置;若不存在,返回 end()
upper_bound返回第一个 严格大于 x 的位置;两者之差就是 x 的出现次数。
前提搜索区间必须按相同规则排好序;在 vector 上查询是 O(log n)
binary_boundaries.cpp
auto first = lower_bound(a.begin(), a.end(), target);
auto afterLast = upper_bound(a.begin(), a.end(), target);

int firstIndex = first == a.end() ? -1 : (int)(first - a.begin());
int count = (int)(afterLast - first);

二分答案:把最优化变成判定

猜一个答案 mid用 check(mid) 判断能否做到利用单调性保留一半范围找到可行边界

例如“最小化最大分组和”:若上限为 x 时可以分组,那么更大的上限也一定可以,形成 false → true 的边界。关键不在套模板,而在先证明 check(x) 单调,并保证搜索区间包含答案。

二分最常见的错误

区间定义混乱。写代码前先决定使用闭区间 [left, right] 还是左闭右开区间 [left, right),循环条件和更新方式必须始终与它保持一致。

写二分前检查
搜索的是具体数值、边界位置,还是答案?
有序性或 check 的单调性已经证明了吗?
区间定义、循环条件和更新规则一致吗?
不存在答案时返回什么?
FOUNDATION 07 · 快速回答区间求和

前缀和

如果需要反复询问数组某个区间的元素总和,每次从左到右重新累加会很慢。前缀和先进行一次 O(n) 预处理,之后每次区间查询只需 O(1)

前缀和数组表示什么?

定义 prefix[i] 表示原数组前 i 个元素之和。为了让公式更整齐,令 prefix[0] = 0

原数组 a31415
逐项累加 ↓
prefix0348914
预处理公式prefix[i] = prefix[i - 1] + a[i]

把第 i 个元素加入前 i-1 个元素的总和。

区间 [l, r] 的和prefix[r] - prefix[l - 1]

前 r 个元素之和,减去 l 之前的所有元素。

为什么区间公式成立?

求数组第 2 到第 4 个元素之和:

3 + 1 + 4 + 1− 3= 1 + 4 + 1 = 6
prefix[4] - prefix[1] = 9 - 3 = 6
prefix_sum.cpp
int n, q;
cin >> n >> q;

vector<long long> prefix(n + 1, 0);
for (int i = 1; i <= n; i++) {
    long long x;
    cin >> x;
    prefix[i] = prefix[i - 1] + x;
}

while (q--) {
    int left, right;
    cin >> left >> right;
    cout << prefix[right] - prefix[left - 1] << '\n';
}
预处理时间O(n)
单次查询O(1)
q 次总时间O(n + q)
额外空间O(n)
i
前缀思想不只用于求和

prefix[i] 记录前 i 个元素中“满足条件的数量”,就能 O(1) 查询区间计数;把加法换成异或,还能得到区间异或值。能否相减消去前段,取决于所用运算是否具有对应的逆运算。

注意 long long

即使数组中的每个元素都能放进 int,许多元素相加后的前缀和也可能超过 int 范围。只要总和可能很大,就使用 long long

EXTENSION · 从一条线推广到一个矩形

二维前缀和

二维前缀和用于快速计算矩阵中的矩形区域和。它是一维前缀和的自然推广:prefix[i][j] 表示从左上角 (1, 1) 到右下角 (i, j) 的整个矩形元素之和。

适用场景

多次询问矩阵中的矩形和

例如地图区域统计、二维棋盘计数、图片像素区域求和等。

12345 23456 34567 45678

怎样构造二维前缀和?

构造公式 prefix[i][j] = a[i][j] + prefix[i-1][j] + prefix[i][j-1] - prefix[i-1][j-1]

上方矩形与左方矩形都包含左上角重叠区域,因此相加后必须把 prefix[i-1][j-1] 减去一次。

当前位置的值a[i][j]
加上上方区域prefix[i-1][j]
加上左方区域prefix[i][j-1]
减去重复区域prefix[i-1][j-1]

怎样查询任意矩形?

设矩形左上角为 (x1, y1),右下角为 (x2, y2),包含边界。先取右下角的大前缀矩形,再减去上方和左方多余区域,最后把被重复减去的左上区域加回来。

矩形查询公式 prefix[x2][y2] - prefix[x1-1][y2] - prefix[x2][y1-1] + prefix[x1-1][y1-1]
取全部− 上方− 左方+ 重叠
prefix_sum_2d.cpp
int n, m, q;
cin >> n >> m >> q;

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

for (int i = 1; i <= n; i++) {
    for (int j = 1; j <= m; j++) {
        long long value;
        cin >> value;
        prefix[i][j] = value
            + prefix[i - 1][j]
            + prefix[i][j - 1]
            - prefix[i - 1][j - 1];
    }
}

while (q--) {
    int x1, y1, x2, y2;
    cin >> x1 >> y1 >> x2 >> y2;
    long long answer = prefix[x2][y2]
        - prefix[x1 - 1][y2]
        - prefix[x2][y1 - 1]
        + prefix[x1 - 1][y1 - 1];
    cout << answer << '\n';
}
预处理时间O(nm)
单次查询O(1)
q 次总时间O(nm + q)
额外空间O(nm)
二维前缀和最容易错在下标

建议让矩阵下标从 1 开始,并额外保留第 0 行和第 0 列为 0。这样查询贴着上边界或左边界的矩形时,不需要额外分类讨论。

FOUNDATION 08 · 快速完成区间修改

差分

如果要对数组的许多区间整体加上一个数,逐个修改区间中的每个元素会很慢。差分只修改区间的两个边界,每次操作是 O(1),最后再用一次前缀和还原整个数组。

差分数组表示什么?

diff[i] = a[i] - a[i - 1],并规定 a[0] = 0。差分记录的是“当前位置相对前一个位置变化了多少”。

原数组 a31415
相邻元素作差 ↓
diff3-23-340

最后一个 0 是额外预留的 diff[n + 1],用于处理右端点恰好为 n 的区间修改。它不属于原数组。

构造差分diff[i] = a[i] - a[i - 1]

记录相邻元素之间的变化量。

还原原数组a[i] = a[i - 1] + diff[i]

对 diff 求前缀和,就能重新得到 a。

区间 [l, r] 全部加上 k

从 l 开始增加 kdiff[l] += k

表示从位置 l 起,后面的元素都多出 k。

从 r + 1 取消影响diff[r + 1] -= k

让这次增加只影响到 r,不继续传到后面。

原数组为 3 1 4 1 5,将区间 [2, 4] 全部加 2:

diff[2] += 2diff[5] -= 2还原后:3 3 6 3 5
区间内部不需要逐个修改,只改变开始位置和结束位置的下一格。

完整模板:多次区间加

difference.cpp
int n, m;
cin >> n >> m;

vector<long long> diff(n + 2, 0);
long long previous = 0;

for (int i = 1; i <= n; i++) {
    long long value;
    cin >> value;
    diff[i] = value - previous;
    previous = value;
}

while (m--) {
    int left, right;
    long long value;
    cin >> left >> right >> value;
    diff[left] += value;
    diff[right + 1] -= value;
}

for (int i = 1; i <= n; i++) {
    diff[i] += diff[i - 1];
    cout << diff[i] << ' ';
}
输入
5 2
3 1 4 1 5
2 4 2
1 3 -1
输出
2 2 5 3 5
构造差分O(n)
单次区间修改O(1)
m 次修改并还原O(n + m)
额外空间O(n)
前缀和擅长“数组不变,多次查询区间和”。
差分擅长“多次修改区间,最后得到整个数组”。
两者关系差分的前缀和是原数组,原数组的相邻差是差分。
记得为 r + 1 多开一个位置

如果数组使用 1 到 n 的下标,差分数组至少开到 n + 1。代码中常写 vector<long long> diff(n + 2),避免访问 diff[right + 1] 时越界。

普通差分适合离线得到最终结果

如果每次修改后都要立刻查询当前区间和,不能每次重新还原数组;这类在线问题通常需要树状数组或线段树。

EXTENSION · 从区间修改推广到矩形修改

二维差分

二维差分可以把矩形 (x1, y1)(x2, y2) 内的所有元素同时加上 value。一次修改只需要改变四个角,最后对差分矩阵求二维前缀和即可还原。

矩形修改公式 diff[x1][y1] += v  diff[x2+1][y1] -= v
diff[x1][y2+1] -= v diff[x2+1][y2+1] += v

左上角开始产生影响,下方和右方分别取消影响,右下角因为被减了两次,需要再加回来一次。

difference_2d_update.cpp
void addRectangle(int x1, int y1,
                  int x2, int y2, long long value) {
    diff[x1][y1] += value;
    diff[x2 + 1][y1] -= value;
    diff[x1][y2 + 1] -= value;
    diff[x2 + 1][y2 + 1] += value;
}
i
二维差分的还原

所有矩形修改结束后,按照从上到下、从左到右的顺序计算:diff[i][j] += diff[i-1][j] + diff[i][j-1] - diff[i-1][j-1]。此时 diff[i][j] 就是最终矩阵中的值。

TRY IT

给数组做批量区间修改

输入长度为 n 的数组和 m 次操作,每次把区间 [l, r] 加上 k。使用差分输出所有操作完成后的数组,并尝试与逐个修改元素的做法比较运行次数。

基础算法第一阶段完成

现在你已经能够根据数据范围判断复杂度,使用二分缩小查找范围,用前缀和优化区间查询,并用差分优化批量区间修改。

FOUNDATION 09 · 把随机过程变成可计算的平均结果

概率与期望

竞赛中的概率题通常不要求背完整本概率论。真正高频的是两种建模:统计“会出现多少个”时,把总贡献拆成许多 0-1 变量;询问“到结束还要多久”时,先分析下一步会走到哪里。

THE TWO QUESTIONS

先判断题目到底在问哪一种期望

题目背景可能是抽球、排列、骰子或随机游走,但拆掉故事后,大多数入门题都会落到右边两句话中的一条。

统计多少个E[Σ Iᵢ] = Σ P(Iᵢ = 1)

枚举每一个可能贡献 1 的对象。

还要走多久f[u] = cost + Σ p · f[v]

支付当前一步,再加上下一状态的期望。

这一章学完要会做什么?

01

算基本概率

分清样本空间、事件和条件概率,尤其注意不放回后总数会减少。

02

拆分总贡献

用期望线性性与 0-1 指示变量处理次数、位置和元素对。

03

写首步方程

从当前状态先走一步,列出每个下一状态及其发生概率。

04

识别方程结构

知道 DAG 可以倒序递推,有自环要移项,互相依赖可能要解方程。

PART 01 · 概率是在数哪些可能结果

先建立样本空间,再谈事件概率

一次随机过程所有可能出现的基本结果组成样本空间。如果这些结果等概率,事件 A 的概率就是“满足 A 的结果数 ÷ 所有结果数”。例如公平骰子出现偶数,对应结果是 2、4、6,因此概率为 3 / 6 = 1 / 2

123456

不放回抽取:第二步的分母已经变了

袋中有 2 个黑球和 3 个白球,求“先黑后白”的概率。第一次抽黑球后不放回,袋里只剩 4 个球,其中 3 个是白球。

第一步抽到黑球2 / 5
×
已知第一步是黑球第二步抽到白球3 / 4
=
联合事件先黑后白3 / 10
条件概率的乘法公式P(A ∩ B) = P(A) · P(B | A)

P(B | A) 表示在 A 已经发生的条件下,B 再发生的概率。不要把两个阶段都机械地写成原来的分母。

PART 02 · 期望不是“一次会出现的答案”

期望表示大量重复试验后的长期平均

离散随机变量 X 可能取值 x₁, x₂, …,对应概率为 p₁, p₂, …,那么每个结果按出现概率加权:

期望值不一定是随机变量能取到的值

“期望为 1.2 次”不代表某一次会发生 1.2 次。一次结果仍然是整数,1.2 只描述很多次试验的平均水平。

PART 03 · 线性期望:先拆贡献,再分别计算

最重要的性质不要求随机变量彼此独立

期望的线性性E[X + Y] = E[X] + E[Y]

X、Y 即使互相影响,这个等式仍然成立。

0-1 指示变量I ∈ {0,1} ⇒ E[I] = P(I=1)

事件发生贡献 1,不发生贡献 0,期望正好等于发生概率。

!
“不独立”不等于“不能拆期望”

独立性会影响乘积或联合概率的计算,但 E[X₁ + X₂ + …] = E[X₁] + E[X₂] + … 始终成立。随机排列中的不同逆序对并不独立,仍然可以逐对计算贡献。

例题:随机 01 串中,模式 01 出现多少次?

把 n 个 0 和 m 个 1 随机排列。总长度记为 N = n + m,我们统计相邻位置中“左边是 0、右边是 1”的位置数量。

确定候选对象相邻位置对共有 N - 1 个:(1,2)、(2,3)、…、(N-1,N)

为每一对设指示变量第 i 对恰好是 01Iᵢ = 1,否则为 0。

算单个候选的概率左边取到 0 的概率为 n/N;用掉一个 0 后,右边取到 1 的概率为 m/(N-1)

把所有贡献相加E[X] = (N-1) · n/N · m/(N-1) = nm/N

候选数量N - 1
×
每对成为 01 的概率n/N · m/(N-1)
=
总次数的期望nm/(n+m)
01 与“相邻两位不同”不是同一道题

异色相邻还包括 10。它与 01 对称,所以异色相邻的期望是 2nm/(n+m);看到方向要求时不要顺手多乘或少乘 2。

READ THE CODE · 公式代码也要看清含义

使用 long double 保留计算精度

总长度是 n + m

直接代入 nm/(n+m)

按题目要求输出小数

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

int main() {
    ios::sync_with_stdio(false);
    cin.tie(nullptr);

    long long zeroCount, oneCount;
    cin >> zeroCount >> oneCount;

    // ① 所有 N-1 个相邻位置的贡献相加后,公式约成 nm/N。
    long double totalLength = zeroCount + oneCount;
    long double expected =
        (long double)zeroCount * oneCount / totalLength;

    // ② 普通实数答案按题目要求保留小数位。
    cout << fixed << setprecision(6)
         << expected << '\n';
    return 0;
}
样例 2 个 0、3 个 1

候选相邻位置有 4 个,每一个成为 01 的概率是 2/5 × 3/4 = 3/10,所以期望为 4 × 3/10 = 1.2

再看一次拆贡献:随机排列的逆序对

随机打乱 1…n。对于每一对数 a < b,只关心它们的相对顺序:a 在 b 前和 b 在 a 前完全对称,所以这一对形成逆序的概率为 1/2

一共有 C(n,2) 对数E[逆序对数] = C(n,2) · 1/2 = n(n-1)/4

不同元素对会共享元素,彼此并不独立;这里仍然能相加,正是线性期望最有力量的地方。

PART 04 · 首步分析:先支付这一步,再看下一状态

“到终点还要多少期望步数”通常是一组状态方程

f[u] 表示从状态 u 出发到结束的期望代价。站在 u 时先发生一次操作,付出当前代价;之后以不同概率进入下一状态 v:

例题:一维随机游走为什么得到

位置为 0,1,…,n,从 0 出发;中间位置等概率向左或向右一步,到达 n 后停止;在 0 不能向左,只能走到 1。设 f[i] 为从 i 到 n 的期望步数。

0只能向右1各 1/22各 1/2nf[n]=0
中间位置f[i] = 1 + ½f[i-1] + ½f[i+1]

先走一步,再按两种方向的概率加权。

左端点f[0] = 1 + f[1]

在 0 没有随机选择,只能走到 1。

作差d[i] = f[i-1] - f[i]

方程可化为 d[i+1] = d[i] + 2

累加f[0] = 1+3+…+(2n-1) = n²

d[1]=1 得到连续奇数。

i
这不是普通的“从左往右填表”

f[i] 同时依赖 f[i-1]f[i+1],依赖关系有环,本质是一组线性方程。这里利用一维结构作差化简;更一般的题可能需要移项、树形递推或高斯消元。

DAG 上的期望可以直接倒序计算

如果状态编号保证所有转移都从较小编号走向较大编号,就没有环。下面的模型中,从每个非终点状态会等概率选择一条出边,求从 1 走到终点 n 的期望步数。

READ THE CODE · 先看状态依赖方向

expected[u] 表示从 u 到终点的期望步数

终点 expected[n] 默认为 0

倒序保证后继状态已经算好

每条出边概率都是 1/degree

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

int main() {
    ios::sync_with_stdio(false);
    cin.tie(nullptr);

    int n, m;
    cin >> n >> m;
    vector<vector<int>> nextState(n + 1);
    while (m--) {
        int u, v;
        cin >> u >> v;  // 题目保证 u < v,因此状态图是 DAG
        nextState[u].push_back(v);
    }

    // ① expected[n] = 0;从 n-1 倒推到 1。
    vector<long double> expected(n + 1, 0);
    for (int u = n - 1; u >= 1; u--) {
        int degree = (int)nextState[u].size();
        if (degree == 0) continue;  // 没有出边的状态也视为终止状态

        // ② 当前一步贡献 1,下一状态按 1/degree 加权。
        expected[u] = 1;
        for (int v : nextState[u]) {
            expected[u] += expected[v] / degree;
        }
    }

    cout << fixed << setprecision(6)
         << expected[1] << '\n';
    return 0;
}
输入
4 4
1 2
1 3
2 4
3 4
输出
2.000000
把代码翻译成方程

f[2]=f[3]=1,因为走一步就到 4;因此 f[1]=1+½f[2]+½f[3]=2。代码只是按照依赖顺序把这组方程算出来。

有概率留在原地:把自己的期望移到左边

某状态 u 每次操作后,有 1/3 概率仍留在 u,有 2/3 概率进入 v。右边出现 f[u] 并不代表方程写错:

f[u] = 1 + ⅓f[u] + ⅔f[v]移项⅔f[u] = 1 + ⅔f[v]化简f[u] = 3/2 + f[v]
看到环,先判断能不能代数消掉

单个自环通常可以直接移项;多个状态互相依赖时,可能需要联立方程、高斯消元,或者利用题目的链、树等特殊结构化简。不要硬套普通 DP 的循环顺序。

PART 05 · 小数答案与取模分数不是一回事

题目要求模质数时,用逆元表示除法

如果答案是分数 a/b,题目却要求对质数 MOD 取模,通常把除以 b 改写成乘以 b 的模逆元:a / b ≡ a · b^(MOD-2) (mod MOD)。这表示“模意义下的分数”,不是小数近似。

READ THE CODE · 模逆元模板

快速幂把指数复杂度降到 O(log MOD)

指数为奇数时把当前底数乘入答案

底数每轮平方,指数每轮减半

质数模数下 x^(MOD-2) 是 x 的逆元

mod_inverse.cpp
long long power(long long base, long long exponent, long long mod) {
    long long answer = 1;
    // 二进制快速幂:每轮处理 exponent 的最低位。
    while (exponent > 0) {
        if (exponent & 1) answer = answer * base % mod;
        base = base * base % mod;
        exponent >>= 1;
    }
    return answer;
}

long long inverse(long long value, long long primeMod) {
    // 要求 primeMod 是质数,且 value 不能被 primeMod 整除。
    return power(value, primeMod - 2, primeMod);
}
输出小数使用 double / long double,按误差或小数位要求输出。
对质数取模分母乘模逆元,所有加减乘都在模意义下进行。
先看题目不要看到概率就默认用浮点数,也不要看到分数就擅自取模。
PART 06 · 建模时按这张路线检查

先翻译题目问法,再选择工具

题目问法第一反应需要确认
某种结构出现多少次指示变量 + 线性期望候选对象有没有漏?
随机排列中的相对顺序对称性两个方向是否等概率?
到终点期望几步首步分析当前一步的代价是否写上?
有概率留在原状态把自环项移到左边移项后的系数是否为 0?
状态之间互相依赖方程、差分或高斯消元是否存在可利用的 DAG / 链 / 树结构?
答案为分数并要求取模模逆元模数是否为质数,分母是否可逆?

六个最常见的错误

把期望当成一次结果

期望可以是 3.5,即使一次试验永远取不到 3.5。

误以为必须独立

线性期望拆的是和,不要求各个贡献相互独立。

不放回还用旧分母

已经取走一个对象后,下一次选择的总数必须减一。

漏掉当前一步

求期望步数时经常忘记首步方程最前面的 +1

方向多算一倍

01 只是一种方向,异色相邻才同时包含 0110

有环仍硬填表

互相依赖的期望状态是一组方程,必须先化简或求解。

写概率期望题前检查
随机变量表示什么,最终答案是哪一个期望?
如果统计次数,能否对每个候选贡献定义 0-1 变量?
条件概率的分子、分母是否随已发生事件改变?
首步分析有没有写当前代价和全部下一状态?
状态依赖是 DAG、自环,还是一般线性方程组?
输出要求是小数、分数还是模意义下的答案?

按难度完成三次练习

LEVEL 01

只练拆贡献

随机排列 1…n,不用枚举排列,独立推导逆序对数量的期望。

目标:线性期望
LEVEL 02

只练首步分析

掷公平骰子沿格子前进,超过终点时停在终点,写出每个位置的期望方程。

目标:状态与边界
LEVEL 03

处理一个自环

每轮有概率成功结束,否则留在原状态,先列方程,再通过移项求期望轮数。

目标:识别方程

记住这句竞赛翻译

统计“多少个”就拆贡献,问“还要多久”就看下一步。先把随机过程翻译成随机变量和状态方程,再决定是直接求和、倒序 DP,还是解线性方程。

02 · DATA STRUCTURE

数据结构学习路线

将从数据怎样组织和访问开始,逐步学习线性结构、栈与队列以及树结构,为后续算法打好基础。

线性结构栈与队列树结构并查集
TRACK 03 · DYNAMIC PROGRAMMING

动态规划学习路线

动态规划不是背公式,而是把一个大问题拆成会重复出现的小问题,保存小问题答案,再按照依赖顺序组合出最终答案。先掌握线性递推和背包合并,再把状态依赖放到树结构上。

定义状态寻找转移设置初值确定顺序读出答案
01

线性 DP

状态沿数组、时间或位置依次推进,当前状态通常依赖前面的若干状态。

02

背包 DP

在容量限制下做选择,重点区分物品能选几次以及容量的遍历方向。

03

树形 DP

先计算儿子,再把各个子树的信息合并到父亲;进一步认识树形背包。

04

换根 DP

用两次 DFS 同时得到每个节点作为根时的整树答案。

05

区间 DP

从短区间到长区间,按分界点或左右端点组合出答案。

看到“最优、方案数、可行性”不等于一定使用 DP

DP 适合具有重复子问题和无后效性的结构。先明确状态能否完整描述过去对未来的影响,再决定是否使用动态规划。

i
把状态和转移看成一张有向无环图

每个状态是一个节点,依赖关系是一条边。记忆化搜索从答案出发,只计算真正访问到的状态;递推则按拓扑顺序主动填表。两者复用的是同一套状态与转移,选择更自然、更不易越界的实现即可。

DP 01

从一维状态开始

先学会把一句状态定义写完整,再推导每一种可能的决策。

开始线性 DP
DYNAMIC PROGRAMMING 01 · 状态沿顺序递推

线性 DP

线性 DP 的状态按照数组下标、位置或时间从前向后排列。计算第 i 个状态时,只依赖已经算出的较小下标,因此可以按固定顺序递推。

状态用一句完整的话说明 dp[i] 表示什么,尤其要说清“处理到哪里”和“是否必须选择当前位置”。
转移枚举到达当前状态的最后一个决策,从已经正确的小状态转移过来。
顺序必须保证计算 dp[i] 时,它依赖的状态已经计算完成。

什么时候可以考虑动态规划?

01

问题可以分阶段

例如依次处理前 1 个、前 2 个……前 n 个元素,或依次到达每个位置。

02

子问题会重复

不同决策可能继续询问同一个“小规模问题”,保存答案能避免重复计算。

03

具有最优子结构

大问题的最优答案,可以由一个或多个小问题的最优答案组合得到。

04

状态具有无后效性

只要状态值和必要维度相同,未来不需要知道此前具体怎样走到这里。

DP 五步法

定义状态先写中文:dp[i] 表示考虑前 i 个元素时的最大收益。

枚举最后决策第 i 个元素选还是不选?最后一步从哪个位置走来?

写出转移把所有合法来源列全,再取最大、最小、求和或逻辑或。

初始化与边界明确空集合、起点和不可达状态分别应该是什么值。

确定答案位置答案是 dp[n]、所有状态最大值,还是某个指定终点?

例题:不相邻元素最大和

给定长度为 n(n ≥ 1)的整数数组,可以选择若干元素,但不能选择两个相邻元素。允许一个元素都不选,求所选元素之和的最大值。

状态定义

dp[i] 表示只考虑前 i 个元素时,可以获得的最大和。

不选第 i 个

答案保持为 dp[i - 1]

选择第 i 个

第 i - 1 个不能选,答案为 dp[i - 2] + value[i]

转移方程

dp[i] = max(dp[i-1], dp[i-2] + value[i])

先把样例递推出来

数组为 [2, 7, 9, 3, 1]。设 dp[0] = 0,逐个比较“选当前”和“不选当前”。

i / value[i]不选:dp[i-1]选择:dp[i-2]+value[i]dp[i]
1 / 200 + 2 = 22
2 / 720 + 7 = 77
3 / 972 + 9 = 1111
4 / 3117 + 3 = 1011
5 / 11111 + 1 = 1212

完整代码

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

int main() {
    ios::sync_with_stdio(false);
    cin.tie(nullptr);

    int n;
    cin >> n;

    vector<long long> value(n + 1);
    for (int i = 1; i <= n; i++) {
        cin >> value[i];
    }

    vector<long long> dp(n + 1, 0);
    dp[1] = max(0LL, value[1]);

    for (int i = 2; i <= n; i++) {
        dp[i] = max(dp[i - 1], dp[i - 2] + value[i]);
    }

    cout << dp[n] << '\n';
    return 0;
}
输入
5
2 7 9 3 1
输出
12
状态数量n + 1
每个状态转移O(1)
总时间复杂度O(n)
空间复杂度O(n)

空间为什么可以优化到 O(1)?

计算 dp[i] 时只使用 dp[i-1]dp[i-2],更早的状态不会再次使用,因此只需保存最近两个值。

rolling_dp.cpp
long long twoBack = 0;                 // dp[i - 2]
long long oneBack = max(0LL, value[1]); // dp[i - 1]

for (int i = 2; i <= n; i++) {
    long long current = max(oneBack, twoBack + value[i]);
    twoBack = oneBack;
    oneBack = current;
}
i
先写对,再优化空间

初学时建议先写含义清晰的 dp 数组,确认状态和转移正确后再改成滚动变量。需要还原具体选择方案时,通常仍要保留完整状态或前驱记录。

记忆化搜索从目标状态递归求解,写法接近原始递推关系;要设置“未计算”标记并留意递归深度。
递推填表从初始状态按依赖顺序计算,没有递归栈;需要明确哪些状态可达以及循环顺序。
还原方案除最优值外保存 parent 或当前决策,最后从答案状态反向追踪。

两种常见的线性状态定义

前 i 个的答案dp[i] 包含前 i 个元素的整体最优值,答案常在 dp[n],本节例题属于这一类。
必须以 i 结尾dp[i] 表示必须选择第 i 个元素的答案,例如最长上升子序列;最终答案通常是所有 dp[i] 的最大值。
多状态如果当前位置有“选 / 不选”“持有 / 不持有”等不同状态,可增加一维或分别维护多个数组。
“前 i 个最优”与“以 i 结尾最优”不能混用

前者允许第 i 个元素不参与答案,后者要求答案必须落在 i。状态定义只差几个字,转移和最终答案位置却会完全不同。

线性 DP 常见错误

状态含义不完整

只写“dp[i] 是最大值”,没有说明考虑范围以及是否必须选择 i。

转移来源漏掉

只考虑选择当前元素,忘记“不选当前”同样是一种合法决策。

初始化与题意冲突

不允许空方案时却把所有状态初始化为 0,会把不存在的方案当成合法答案。

答案取错位置

“必须以 i 结尾”的状态通常需要在所有 i 中取最优,而不一定直接输出 dp[n]。

写线性 DP 前检查
状态定义能否完整读成一句中文?
当前阶段有哪些互不遗漏的最后决策?
起点、空方案和不可达状态怎样初始化?
遍历顺序是否保证依赖先算完?
最终答案究竟位于哪个状态?
TRY IT

最小花费爬楼梯

第 i 级台阶有一个花费,每次可以走 1 级或 2 级。请定义“到达第 i 级的最小花费”,分别写出转移、初值和答案位置,再尝试用两个变量优化空间。

DYNAMIC PROGRAMMING 02 · 在容量限制下做选择

背包 DP

背包问题研究“若干物品 + 一个容量限制 + 如何选择物品”。真正需要掌握的不是模板本身,而是状态表示、物品使用次数以及容量为什么要按特定方向遍历。

0/1 背包每件物品最多选择一次;一维优化后,容量必须从大到小遍历。
完全背包每件物品可以选择无限次;一维写法通常让容量从小到大遍历。
多重背包每件物品有指定数量,需要拆分、二进制优化或使用单调队列等方法。

0/1 背包模型

有 n 件物品,第 i 件物品重量为 weight[i]、价值为 value[i],背包容量为 W。每件物品只能选择 0 次或 1 次,求总重量不超过 W 时的最大总价值。

阶段

依次考虑前 1 件、前 2 件……前 n 件物品。

状态

dp[i][capacity] 表示只考虑前 i 件物品、容量上限为 capacity 时的最大价值。

决策

第 i 件物品只有“不选”和“选择”两种情况。

答案

处理完全部物品后,答案是 dp[n][W]

从两种决策推导转移

不选第 i 件dp[i-1][capacity] 或 选择第 i 件dp[i-1][capacity-weight[i]] + value[i]

capacity < weight[i] 时装不下当前物品,只能不选;否则在两种决策中取最大值:

0/1 背包转移dp[i][c] = dp[i-1][c]            (c < weight[i])
dp[i][c] = max(dp[i-1][c], dp[i-1][c-weight[i]] + value[i]) (c ≥ weight[i])

两种来源都在第 i - 1 行,因此同一件物品不会被重复使用。

手算一张 DP 表

容量 W = 8,四件物品分别为 (重量, 价值) = (2,3)、(3,4)、(4,5)、(5,8)。每处理一件物品,就用上一行更新当前行。

处理阶段c=0..2c=3..5c=6..8
0 件物品0 0 00 0 00 0 0
(2, 3)0 0 33 3 33 3 3
(3, 4)0 0 34 4 77 7 7
(4, 5)0 0 34 5 78 9 9
(5, 8)0 0 34 5 88 11 12

最终 dp[4][8] = 12,对应选择重量 3、价值 4 的物品和重量 5、价值 8 的物品。

二维写法:最容易理解

knapsack_2d.cpp
vector<vector<long long>> dp(
    n + 1, vector<long long>(capacityLimit + 1, 0)
);

for (int i = 1; i <= n; i++) {
    for (int capacity = 0; capacity <= capacityLimit; capacity++) {
        dp[i][capacity] = dp[i - 1][capacity];

        if (capacity >= weight[i]) {
            dp[i][capacity] = max(
                dp[i][capacity],
                dp[i - 1][capacity - weight[i]] + value[i]
            );
        }
    }
}
状态数量O(nW)
单次转移O(1)
时间复杂度O(nW)
空间复杂度O(nW)

压缩成一维:容量必须倒序

第 i 行只依赖第 i - 1 行,可以复用同一个 dp[capacity] 数组。为了让右侧的 dp[capacity-weight] 仍然代表“没有处理当前物品”的旧值,容量必须从大到小更新。

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

int main() {
    ios::sync_with_stdio(false);
    cin.tie(nullptr);

    int n, capacityLimit;
    cin >> n >> capacityLimit;

    vector<long long> dp(capacityLimit + 1, 0);

    for (int i = 1; i <= n; i++) {
        int weight;
        long long value;
        cin >> weight >> value;

        for (int capacity = capacityLimit; capacity >= weight; capacity--) {
            dp[capacity] = max(dp[capacity], dp[capacity - weight] + value);
        }
    }

    cout << dp[capacityLimit] << '\n';
    return 0;
}
输入
4 8
2 3
3 4
4 5
5 8
输出
12
物品循环n 次
容量循环最多 W 次
时间复杂度O(nW)
空间复杂度O(W)

为什么正序会错?

假设只有一件重量 2、价值 3 的物品,容量为 6。如果容量从小到大更新:

更新位置读取的旧状态得到结果发生了什么
capacity = 2dp[0] = 0dp[2] = 3第一次选择物品
capacity = 4dp[2] = 3dp[4] = 6同一轮再次使用当前物品
capacity = 6dp[4] = 6dp[6] = 9同一件物品被选择三次
0/1 背包倒序,完全背包正序

倒序读取的是上一轮保留下来的旧状态,保证当前物品最多使用一次;正序会读取本轮刚更新的新状态,因此允许继续使用当前物品。

完全背包:同一件物品可以重复使用

完全背包仍可使用一维 dp[capacity],但容量从小到大更新。这样 dp[capacity-weight] 可能已经在本轮加入当前物品,继续转移正好表示再次选择它。

complete_knapsack.cpp
vector<long long> dp(capacityLimit + 1, 0);

for (int i = 1; i <= n; i++) {
    int weight;
    long long value;
    cin >> weight >> value;

    for (int capacity = weight; capacity <= capacityLimit; capacity++) {
        dp[capacity] = max(dp[capacity], dp[capacity - weight] + value);
    }
}
0/1:容量倒序右侧读取上一轮状态,阻止同一件物品在本轮重复进入答案。
完全:容量正序右侧允许读取本轮新状态,从而继续使用当前物品。
循环不能随便交换求最值时物品在外、容量在内最清晰;求方案数时交换循环还可能改变“组合 / 排列”的含义。

“不超过容量”与“恰好装满”初始化不同

不超过容量允许什么都不选,通常把所有 dp[capacity] 初始化为 0,最后读取 dp[W]
恰好装满只有 dp[0] = 0 合法,其他容量初始化为负无穷,表示暂时不可达。
方案计数若求方案数,初始通常是 dp[0] = 1,转移使用加法而不是取最大值。
exact_fill_init.cpp
const long long NEGATIVE_INFINITY = -(1LL << 60);
vector<long long> dp(capacityLimit + 1, NEGATIVE_INFINITY);
dp[0] = 0;

// 转移时只有来源可达,才能继续选择物品。
if (dp[capacity - weight] != NEGATIVE_INFINITY) {
    dp[capacity] = max(dp[capacity], dp[capacity - weight] + value);
}

数据范围决定能不能使用背包 DP

n × W ≤ 10⁷ 左右普通 O(nW) 通常可行,仍需结合语言、时限和常数判断。
W 非常大容量不能直接作为数组维度,考虑按价值 DP、稀疏状态或其他问题结构。
价值可能溢出 int总价值可能超过 2 × 10⁹ 时,dp 数组应使用 long long
写背包 DP 前检查
每件物品最多能使用几次?
dp 表示容量上限还是恰好容量?
转移来源是否仍属于正确的上一阶段?
一维优化后容量应该正序还是倒序?
n × W 的复杂度能否通过数据范围?
TRY IT

用循环方向区分两类背包

给出 n 件物品和容量 W,分别按“每件最多一次”和“每件不限次数”求最大价值。先用二维 0/1 背包打印小数据的整张 dp 表,再压缩成一维,并用“一件重量 2、价值 3、容量 6”的数据验证倒序得到 3、正序得到 9。

动态规划基础模型完成

现在你已经掌握状态、转移、初始化、遍历顺序和答案位置,并能解释 0/1 背包与完全背包循环方向不同的原因。下一步把这些方法带到树结构中。

DYNAMIC PROGRAMMING 03 · 先算儿子,再合并到父亲

树形 DP

在线性 DP 中,我们沿着数组从左到右计算;到了树上,节点会同时分出多个方向,应该先算谁就不再显然。树形 DP 做的第一件事,就是把无向的树整理成“父亲依赖儿子”的计算结构,再把每棵小子树的答案一层层向上合并。

贯穿本节的例子

把一棵树看成许多可以复用的小问题

选 1 为根后,节点 2 管理子树 {2, 4, 5},节点 3 管理子树 {3}。只要先算出这两棵小子树,根 1 就不必重新进入它们逐个检查。

根:1后序:4 → 5 → 2 → 3 → 1合并方向:向上
1 2 3 4 5
学会 01

确定计算方向

分清有向父子输入与无向边输入,知道根、父亲、儿子和子树分别是什么。

学会 02

读懂后序 DFS

程序虽然从根向下进入,DP 状态却在回溯时从叶子向上完成。

学会 03

设计树上状态

从一个状态的子树大小,过渡到选 / 不选两个状态,并推导完整转移。

学会 04

掌握树形背包

理解加入人数限制后,为什么每个儿子会变成一个需要逐组合并的背包。

遍历工具DFS 负责给出后序计算顺序:先进入子树,回溯时再处理当前节点。
DP 核心明确 dp[u] 描述以 u 为根的子树中的什么信息,以及怎样合并每个儿子。
一句话模板先算儿子,再用儿子的答案计算父亲;DFS 决定顺序,状态转移决定答案。
PART 01 · 先弄清计算顺序

第一步:把树“有根化”

如果输入直接说明“父亲是谁”,可以只保存父亲到儿子的有向边;如果输入只是 n - 1 条无向边,就要双向加边,并在 DFS 中跳过父亲。无根树在题目没有特殊要求时通常可任选 1 号节点为根。

父子输入

parent child 已经确定方向,只需 children[parent].push_back(child)

无向边输入

同时加入 u → vv → u,遍历时通过 parent 防止走回头路。

为什么选根

根只是在无向树上人为确定计算方向,不会改变树的边和连通关系。

完成顺序

一次 DFS 的完成顺序是叶子到根,恰好满足子状态先于父状态。

tree_dp_template.cpp
vector<vector<int>> graph(n + 1);

void dfs(int u, int parent) {
    // 1. 初始化 dp[u]
    for (int v : graph[u]) {
        if (v == parent) continue;
        dfs(v, u);
        // 2. 使用已经算好的 dp[v] 更新 dp[u]
    }
}

程序向下走,答案向上长

这是初学树形 DP 最容易混淆的一点。调用 dfs(1, 0) 时,程序先进入 1,再进入 2、4;但 4 没有儿子,会最先完成状态。回到 2 后继续完成 5,等 4、5 都算完,2 才能合并它们。

进入节点的顺序12453
状态完成的顺序45231
此刻在哪发生的动作哪些状态已完成能否计算当前点
刚进入 2发现儿子 4、5都没有不能,先递归
从 4 返回拿到 dp[4]4还不能,5 未完成
从 5 返回拿到 dp[5]4、5可以合并出 dp[2]
回到 12、3 均已返回2 的子树、3可以合并出 dp[1]
不要把 DFS 本身误认为动态规划

dfs(v, u) 只是保证计算顺序;真正的 DP 是回溯后的 dp[u] += dp[v]、取最大值或背包合并。没有清晰的状态定义与转移,只有搜索。

PART 02 · 从一个状态开始

入门状态:求每个节点的子树大小

定义 subtreeSize[u] 为“以 u 为根的子树节点数”。初始化时 u 自己贡献 1;每完成一个儿子 v,就把 subtreeSize[v] 加到 u。于是:

子树大小转移subtreeSize[u] = 1 + Σ subtreeSize[v] (v 是 u 的儿子)

每个节点访问一次,每条边至多检查两次,时间复杂度 O(n),邻接表与状态数组占 O(n) 空间。

为什么这里可以直接相加?

以 u 的不同儿子为根的子树互不重叠:一个节点不可能同时属于两个儿子的子树。因此,u 的子树可以被完整拆成“u 自己 + 每个儿子的子树”,既没有遗漏,也不会重复计算。

节点 2 自己1先初始化
+
节点 4 的子树1第一个儿子
+
节点 5 的子树1第二个儿子
=
节点 2 的子树3合并完成
?
先检查叶子,公式就不容易写错

叶子没有儿子,求和部分为空,只剩自己这 1 个节点,所以 subtreeSize[leaf] = 1。这同时解释了为什么初始化必须放在遍历儿子之前。

READ THE CODE · 先抓住四个动作

当前节点先算自己

跳过来时的父亲

先递归算完儿子

回溯时合并答案

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

// 保存无向树,以及每个节点的子树大小。
vector<vector<int>> graph;
vector<int> subtreeSize;

void dfs(int u, int parent) {
    subtreeSize[u] = 1;  // ① 先把节点 u 自己算进去

    for (int v : graph[u]) {
        if (v == parent) continue;  // ② 无向边会通回父亲,必须跳过
        dfs(v, u);                  // ③ 先得到儿子 v 的完整答案
        subtreeSize[u] += subtreeSize[v];  // ④ 回溯时合并 v 的子树
    }
}

int main() {
    ios::sync_with_stdio(false);
    cin.tie(nullptr);

    int n;
    cin >> n;
    graph.assign(n + 1, {});
    subtreeSize.assign(n + 1, 0);
    // 输入 n-1 条无向边,每条边必须双向加入邻接表。
    for (int i = 1; i < n; i++) {
        int u, v;
        cin >> u >> v;
        graph[u].push_back(v);
        graph[v].push_back(u);
    }
    dfs(1, 0);  // 任取 1 为根,0 表示 1 没有父亲
    for (int u = 1; u <= n; u++) cout << subtreeSize[u] << " \n"[u == n];
}
输入
5
1 2
1 3
2 4
2 5
输出
5 3 1 1 1
PART 03 · 状态不够时就拆开

经典模型:没有上司的舞会

每位员工 u 有快乐值 happiness[u]。如果 u 参加舞会,u 的直接下属不能参加;求整棵公司树能获得的最大快乐值。父亲能否选择某个儿子,取决于父亲自己是否参加,因此一个状态不够。

为什么不能只定义“u 子树的最大快乐值”?

假设儿子 v 的最大值恰好来自“选择 v”。当父亲 u 也被选择时,这个最大值就不合法;但单独一个 best[v] 已经丢失了“v 是否参加”的信息。父亲未来还要询问的条件,必须保留在状态中。

父亲 u 将来会问“儿子 v 参加了吗?”增加一维 0 / 1
dp[u][0]

u 不参加时,u 的整棵子树能够获得的最大快乐值。

dp[u][1]

u 参加时,u 的整棵子树能够获得的最大快乐值。

初始化

dp[u][0] = 0dp[u][1] = happiness[u]

最终答案

根可参加也可不参加,取 max(dp[root][0], dp[root][1])

只讨论一个儿子,转移就会自己出现

先暂时忘掉整棵树,只看相邻的父亲 u 和儿子 v。父亲不参加时没有给 v 施加限制,v 可以在自己的两种状态里选较大的;父亲参加时,为避免相邻两人同时出现,v 只能不参加。

u 不参加每个 v 可选可不选加 max(dp[v][0], dp[v][1])
u 参加每个 v 必须不参加加 dp[v][0]

手算一次“儿子 → 父亲”

继续使用开头那棵树,节点 1—5 的快乐值分别是 5、4、3、6、2。把状态写成二元组 (不参加, 参加),严格按照 4、5、2、3、1 的顺序计算:

1快乐 52快乐 43快乐 34快乐 65快乐 2
当前节点dp[u][0]:u 不参加dp[u][1]:u 参加完成状态
叶子 40happiness[4] = 6(0, 6)
叶子 50happiness[5] = 2(0, 2)
节点 2max(0,6)+max(0,2)=84+0+0=4(8, 4)
叶子 30happiness[3] = 3(0, 3)
根 1max(8,4)+max(0,3)=115+8+0=13(11, 13)
答案为什么是 13?

根 1 参加,所以直接儿子 2、3 都不参加;但限制只作用于直接相邻节点,孙子 4、5 仍然可以参加。最终选择 1、4、5,快乐值为 5 + 6 + 2 = 13

完整代码:输入给出下属与上司

下面约定每行 employee boss 表示 boss 是 employee 的直接上司。建边时使用 children[boss].push_back(employee),并用 hasParent 找到唯一没有上司的根,不能默认根一定是 1。

READ THE CODE · 舞会模型阅读顺序

先定义选与不选

叶子也满足初始化

按父亲状态合并儿子

找到真正的根后取答案

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

vector<vector<int>> children;
// dp[u][0]:u 不参加;dp[u][1]:u 参加。
vector<array<long long, 2>> dp;
vector<long long> happiness;

void dfs(int u) {
    dp[u][0] = 0;             // ① u 不参加,自己的贡献为 0
    dp[u][1] = happiness[u];  // ② u 参加,先加入自己的快乐值

    for (int v : children[u]) {
        dfs(v);  // ③ 儿子的两个状态必须先算完
        dp[u][0] += max(dp[v][0], dp[v][1]);  // u 不参加:v 可选可不选
        dp[u][1] += dp[v][0];                 // u 参加:v 必须不参加
    }
}

int main() {
    ios::sync_with_stdio(false);
    cin.tie(nullptr);

    int n;
    cin >> n;
    children.assign(n + 1, {});
    dp.assign(n + 1, {0, 0});
    happiness.resize(n + 1);
    vector<bool> hasParent(n + 1, false);

    for (int u = 1; u <= n; u++) cin >> happiness[u];
    // 输入 employee boss:只保存 boss → employee,并标记谁有父亲。
    for (int i = 1; i < n; i++) {
        int employee, boss;
        cin >> employee >> boss;
        children[boss].push_back(employee);
        hasParent[employee] = true;
    }

    int root = 1;
    while (hasParent[root]) root++;  // ④ 唯一没有上司的人才是根
    dfs(root);
    cout << max(dp[root][0], dp[root][1]) << '\n';  // 根可选可不选
}
输入
5
5 4 3 6 2
2 1
3 1
4 2
5 2
输出
13

把完整代码拆回四个问题

01

边应该指向谁?employee boss 表示 boss 管理 employee,所以保存 boss → employee

02

根在哪里?每个员工出现为下属时标记 hasParent;唯一没被标记的人就是最高上司。

03

什么时候转移?必须放在 dfs(v) 之后,因为此时儿子的两个状态才已经完整。

04

为什么用 long long?单人的快乐值也许不大,但许多节点相加后可能超过 32 位整数范围。

05

负快乐值怎么办?u 不参加的初值是 0,转移会自然跳过负收益的儿子;无需强迫任何人参加。

06

答案为什么看根?根的子树就是整棵树,它的 0 / 1 两种状态已经覆盖所有合法方案。

节点状态2n
每条边合并O(1)
时间复杂度O(n)
空间复杂度O(n)
PART 04 · 子树之间也可以做背包

当题目再增加人数限制:树形背包

普通舞会只关心“选不选 u”。如果再要求最多选择 m 个人,父亲合并儿子时还必须知道每棵子树用了几个名额。这个新增条件会影响后续决策,所以人数成为新的状态维度。

普通选 / 不选dp[u][0 / 1]只记录 u 是否参加
+ 人数限制
树形背包dp[u][j][0 / 1]再记录恰好选择 j 人

若要求“恰好选择 j 人”,状态可扩展为 dp[u][j][0/1]:在 u 的子树中恰好选择 j 人,且 u 不选 / 选择时的最大快乐值。处理一个儿子 v 时,枚举 k 表示从 v 的子树中选择多少人:

树形背包合并dp[u][j][0] = max(dp[u][j-k][0] + max(dp[v][k][0], dp[v][k][1]))
dp[u][j][1] = max(dp[u][j-k][1] + dp[v][k][0])

j 是合并后的总人数,k 分给当前儿子,j-k 属于 u 与此前已处理的儿子。父亲被选时,儿子只能取“不选”状态。

先把 j、k、j-k 翻译成人话

j合并完成后,一共选择的人数
=
j-ku 与此前儿子已经使用的人数
+
k当前儿子 v 的子树使用的人数

例如当前总人数 j = 4,正在合并儿子 v。k 可以是 0、1、2……;当 k = 2 时,就是把 2 个名额交给 v 的子树,把剩余 2 个名额留给此前已经合并的部分。枚举所有合法拆分并取最大值,才不会漏掉最优分配。

总人数 j分给当前儿子 k此前部分 j-k候选收益
404旧状态 + 儿子选 0 人
413旧状态 + 儿子选 1 人
422旧状态 + 儿子选 2 人
4......在所有合法拆分中取最大
准确初始化dp[u][0][0] = 0dp[u][1][1] = happiness[u],其余状态为负无穷,防止不存在的方案参与转移。
限制枚举范围k 不应超过 v 的子树大小,j 不应超过当前已经合并的节点数与人数上限 m。
倒序枚举 j原地合并时从大到小枚举总人数,避免本轮刚加入的同一个儿子被重复使用;也可使用新数组合并。
READ THE CODE · 树形背包只盯住三个量
used

此前部分选了几人

take

当前儿子选了几人

next

合并后的新状态

NEG

过滤不存在的方案

tree_knapsack_merge.cpp
// ① dp 的第二维表示“恰好选择的人数”,NEG 表示不可达。
fill(dp[u].begin(), dp[u].end(), array<long long, 2>{NEG, NEG});
dp[u][0][0] = 0;              // 不选 u,已选 0 人
dp[u][1][1] = happiness[u];   // 选择 u,已选 1 人
subtreeSize[u] = 1;

for (int v : children[u]) {
    dfs(v);

    // ② 用 next 接收结果,保证当前儿子 v 只会被合并一次。
    vector<array<long long, 2>> next(
        limit + 1, array<long long, 2>{NEG, NEG}
    );

    // ③ 枚举名额拆分:used 给此前部分,take 给 v 的子树。
    for (int used = 0; used <= min(limit, subtreeSize[u]); used++) {
        for (int take = 0;
             take <= min(limit - used, subtreeSize[v]);
             take++) {
            long long childBest = max(dp[v][take][0], dp[v][take][1]);

            // ④ u 不选时,v 可选可不选;u 选时,v 只能不选。
            if (dp[u][used][0] != NEG && childBest != NEG) {
                next[used + take][0] = max(
                    next[used + take][0],
                    dp[u][used][0] + childBest
                );
            }
            if (dp[u][used][1] != NEG && dp[v][take][0] != NEG) {
                next[used + take][1] = max(
                    next[used + take][1],
                    dp[u][used][1] + dp[v][take][0]
                );
            }
        }
    }
    dp[u] = move(next);
    subtreeSize[u] += subtreeSize[v];
}
i
初学时先用 next 数组

新数组把“合并前”和“合并后”彻底分开,更容易证明同一个儿子只使用一次。熟练后再改成原地更新,并让总人数 j 倒序遍历;两种写法的状态与转移完全相同。

“最多选 j 人”和“恰好选 j 人”不要混用

上面的合并式使用“恰好选择 j 人”,所以人数可以拆成明确的 k 与 j-k;若题目要求最多 m 人,最后在 0..m 中取最大值。直接把状态定义成“最多”却按“恰好”初始化,容易让不可达方案悄悄混入答案。

PART 05 · 把方法变成自己的

树形 DP 的固定思考流程

确定根与建边方式输入是否已经给出父子关系?无向边是否正确双向加入并跳过父亲?

写完整状态定义说明范围是 u 的子树,是否选择 u,以及容量维度表示“恰好”还是“至多”。

推导单个儿子的贡献假设 dp[v] 已知,逐种讨论 u 的状态,写出 v 可以提供哪些合法选择。

设计初始化和合并顺序先初始化叶子也成立的状态,再逐个儿子合并;不可达状态使用负无穷。

确认答案与复杂度根是否固定、答案取哪个状态、递归深度和 O(nm²) 是否能通过。

提交前检查
无向树是否跳过 parent,避免父子之间无限递归?
输入给定方向时,是否找到了真正的根?
每个 dp 状态是否明确限定在 u 的子树内?
儿子状态是否在合并前已经计算完成?
n 很大且树退化成链时,递归栈是否安全?
树形背包是否限制了 j、k 的有效范围?
i
留意退化成链的树

树高最坏可达 n。若 n 很大,递归 DFS 可能栈溢出;可以改用显式栈记录父亲与遍历顺序,再按顺序逆序做同样的“儿子 → 父亲”转移。算法仍是树形 DP,只是遍历实现不同。

看到什么题意,应该想到哪种状态?

只问每个子树尝试定义 dp[u],让它完整描述 u 子树的大小、和、最大值或最优解。
相邻节点互相限制尝试增加“u 选 / 不选”“u 是什么颜色”等状态,保留父亲做决策时仍需知道的信息。
限制选择数量增加人数或容量维度,并把不同儿子的状态当成多个背包分组逐一合并。
根固定且只求一次优先沿“儿子 → 父亲”的方向后序计算;先保证每个儿子的状态完整,再处理当前节点。

按难度完成三次练习

LEVEL 01

先写出子树信息

求每个节点的子树权值和。先用中文定义 sum[u],再说明为什么初始化为 value[u]

目标:熟悉后序合并
LEVEL 02

独立完成选 / 不选

树上每个节点有权值,任意相邻两点不能同时选择。不要看模板,先从父亲的两种情况推出转移。

目标:从限制设计状态
LEVEL 03

加入人数限制

恰好选择 m 个节点且相邻节点不能同时选。先写出三维状态,再用 next 数组逐棵合并子树。

目标:掌握树形背包
SELF CHECK

合上代码后,你能回答这四句话吗?

dp[u] 的范围为什么通常是 u 的子树?② 为什么转移写在 dfs(v) 之后?③ 父亲参加时为什么只能读取 dp[v][0]?④ 树形背包为什么要逐个儿子合并,并区分“恰好”与“至多”?能用自己的话说清楚,再开始刷题。

你已经建立树形 DP 的主框架

普通树形 DP 沿“儿子 → 父亲”完成子树状态,树形背包在此基础上增加容量并逐棵合并。下一节将面对一个新要求:当题目要每个节点都成为一次根,怎样避免重复计算整棵树。

NEXT LESSON

下一节:换根 DP

从“固定一个根”走向“每个点都做根”,学习两次 DFS 如何复用答案。

开始学习
DYNAMIC PROGRAMMING 04 · 让每个节点都成为一次根

换根 DP

树形 DP 通常先选定一个根,只计算这个根对应的答案;但有些题会追问“如果 1、2、3……每个节点分别作为根,答案是多少”。换根 DP 不会从每个节点重新跑一遍,而是先求出一个根的答案,再沿着每条边把已经算好的信息传给相邻节点。

ONE TREE · EVERY ROOT

根移动一步,答案只改两部分

把根从父亲 u 移到儿子 v 后,v 子树内的节点全部近 1,其余节点全部远 1。只要知道 v 的子树大小,就能 O(1) 得到新答案。

第一次 DFS:向上收集第二次 DFS:向下换根总复杂度 O(n)
12345
answer[1] = 6根沿边 1 → 2answer[2] = 5
本节要学会什么
学会 01

识别换根问题

看到“每个节点作为根”或“求每个节点的整树答案”,知道暴力为什么会重复。

学会 02

完成第一次 DFS

用子树大小与子树距离和,得到一个初始根的完整答案。

学会 03

推导换根公式

不背公式,亲自数清根移动后哪些节点变近、哪些节点变远。

学会 04

写出两遍遍历

正确处理无向边、父亲节点、long long,以及深链上的递归风险。

PART 01 · 先看暴力重复了什么

经典问题:每个点到所有节点的距离和

给定一棵 n 个节点的无权树,定义 answer[u] 为节点 u 到所有节点的距离之和。我们需要输出 answer[1..n]。例如下面这棵树,边为 1-2、1-3、2-4、2-5

12345
以 1 为起点0 + 1 + 1 + 2 + 2 = 6以 2 为起点1 + 0 + 2 + 1 + 1 = 5以 3 为起点1 + 2 + 0 + 3 + 3 = 9
直接做法从每个节点各跑一次 DFS

一次遍历 O(n),一共 n 次,最坏需要 O(n²)

换根思路先完整算一个根,再把答案传下去

每条边只在两次 DFS 中经过,合计 O(n)

?
为什么可以复用?

相邻节点 u 与 v 的视角非常接近。根只沿一条边移动,所有节点的距离只可能增加 1 或减少 1;我们不需要重新寻找每条路径,只需要数出两类节点各有多少个。

PART 02 · 第一次 DFS:先把子树信息收上来

先固定 1 为根,定义两个量

子树大小

size[u]

以 1 为根时,u 的子树一共有多少个节点,包含 u 自己。

子树内距离和

inside[u]

从 u 出发,到 u 子树中所有节点的距离之和。

这里的“子树”依赖我们临时选定的根 1

原树的边仍是无向的。传入 parent 只是避免 DFS 又沿原路返回,也让我们能够区分当前节点的儿子。

一个儿子 v 怎样贡献给父亲 u?

inside[v] 已经统计了 v 到它子树中所有节点的距离。现在起点从 v 移到父亲 u,每条路径都多经过边 u-v,而 v 子树共有 size[v] 个节点,所以统一增加 size[v]

第一次 DFS 的合并size[u] += size[v]inside[u] += inside[v] + size[v]

叶子初始化为 size[u] = 1inside[u] = 0。等所有儿子都合并完,u 的两个状态才完整。

v 子树原距离和inside[v]从 v 出发
+
每个点多走一条边size[v]共 size[v] 个点
=
交给父亲 uinside[v] + size[v]从 u 出发

在示例树上从叶子往上算

节点 u12345
size[u]53111
inside[u]62000

节点 2 收到叶子 4、5 的贡献:inside[2] = (0 + 1) + (0 + 1) = 2。节点 1 再收到 2 和 3:inside[1] = (2 + 3) + (0 + 1) = 6。因为 1 的子树就是整棵树,所以 answer[1] = inside[1]

first_dfs.cpp
void collect(int u, int parent) {
    size[u] = 1;  // ① 子树先包含 u 自己
    inside[u] = 0;  // u 到自己的距离为 0

    for (int v : graph[u]) {
        if (v == parent) continue;  // ② 不沿无向边走回父亲
        collect(v, u);  // ③ 先把儿子 v 的状态算完整

        size[u] += size[v];  // ④ 合并 v 子树的节点数
        // 从 v 换到 u 后,v 子树每个点的距离都多 1。
        inside[u] += inside[v] + size[v];
    }
}
PART 03 · 第二次 DFS:让根沿每条边移动

从父亲 u 换到儿子 v,所有点恰好分成两组

v 子树内共有 size[v] 个点
新根离它们全部近 1
根从 u 移到 v →
v 子树外共有 n - size[v] 个点
新根离它们全部远 1
换根转移:先理解,再记住answer[v] = answer[u] - size[v] + (n - size[v])answer[v] = answer[u] + n - 2 × size[v]

减去的是“变近”的总量,加上的是“变远”的总量。公式中的 size[v] 始终来自第一次 DFS 以 1 为根时得到的子树大小。

手算根从 1 移到 2

原答案6answer[1]
变近的节点32、4、5
+
变远的节点21、3
=
新答案5answer[2]

继续把根从 2 移到叶子 4:answer[4] = 5 + 5 - 2 × 1 = 8。同理可以得到所有节点的答案:

节点 16节点 25节点 39节点 48节点 58
second_dfs.cpp
void reroot(int u, int parent) {
    for (int v : graph[u]) {
        if (v == parent) continue;  // ① 每个节点只从父亲接收一次答案

        // ② v 子树的 size[v] 个点近 1,其余 n-size[v] 个点远 1。
        answer[v] = answer[u] + n - 2LL * size[v];
        reroot(v, u);  // ③ v 的答案已知,继续向它的儿子传递
    }
}
i
为什么第二次 DFS 不需要恢复状态?

这里没有真的修改树,也没有把一个全局根来回搬动;我们只是由 answer[u] 计算并保存 answer[v]。每个节点只从它在第一次 DFS 中确定的父亲接收一次答案。

PART 04 · 另一种推导:把答案拆成 inside 与 outside

如果直接公式显得跳跃,可以先定义 outside[u]:从 u 到其子树之外所有节点的距离和。于是 answer[u] = inside[u] + outside[u]。对于 u 的儿子 v:

① 从父亲的总答案出发answer[u]
② 删去 u 到 v 子树的距离inside[v] + size[v]
+
③ 子树外每个点再远 1n - size[v]
先得到子树外的贡献outside[v] = answer[u] - (inside[v] + size[v]) + (n - size[v])再与 v 的子树内贡献相加answer[v] = inside[v] + outside[v]

把两式合并,inside[v] 会抵消,正好得到 answer[v] = answer[u] + n - 2 × size[v]。这说明两种写法本质相同。

直接换根代码更短,只保存 sizeinsideanswer,适合距离和模板题。
inside / outside拆解更直观,也更容易迁移到不能直接化简的题目。
真正要掌握的不是死记一个式子,而是找出“移根后哪些贡献改变、改变多少”。
PART 05 · 完整 C++17 程序

把两次 DFS 连起来

示例输入5
1 2
1 3
2 4
2 5
示例输出6 5 9 8 8
READ THE CODE · 两次 DFS 各负责一件事

读入无向树

向上收集 size 与 inside

确定初始根答案

向下推出所有 answer

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

int n;
vector<vector<int>> graph;
vector<int> subtreeSize;
vector<long long> inside, answer;

// ② 第一遍 DFS:后序计算子树大小和子树内距离和。
void collect(int u, int parent) {
    subtreeSize[u] = 1;  // 叶子也包含自己
    inside[u] = 0;       // u 到自己的距离为 0

    for (int v : graph[u]) {
        if (v == parent) continue;
        collect(v, u);  // 儿子算完之后,父亲才能合并
        subtreeSize[u] += subtreeSize[v];
        // u 到 v 子树的每个点,都比 v 多走一条边。
        inside[u] += inside[v] + subtreeSize[v];
    }
}

// ④ 第二遍 DFS:由父亲的整树答案推出儿子的整树答案。
void reroot(int u, int parent) {
    for (int v : graph[u]) {
        if (v == parent) continue;
        // v 子树内近 1,子树外远 1:-size[v] + (n-size[v])。
        answer[v] = answer[u] + n - 2LL * subtreeSize[v];
        reroot(v, u);
    }
}

int main() {
    ios::sync_with_stdio(false);
    cin.tie(nullptr);

    cin >> n;
    graph.assign(n + 1, {});
    subtreeSize.assign(n + 1, 0);
    inside.assign(n + 1, 0);
    answer.assign(n + 1, 0);

    // ① 输入 n-1 条无向边,每条边双向保存。
    for (int i = 1; i < n; i++) {
        int u, v;
        cin >> u >> v;
        graph[u].push_back(v);
        graph[v].push_back(u);
    }

    collect(1, 0);
    answer[1] = inside[1];  // ③ 1 的子树就是整棵树
    reroot(1, 0);           // 再把 answer[1] 传遍整棵树

    for (int u = 1; u <= n; u++) {
        cout << answer[u] << (u == n ? '\n' : ' ');
    }
    return 0;
}
读到这里暂停一下

collect 只使用儿子的状态,方向是“叶子 → 根”;reroot 只使用父亲的整树答案,方向是“根 → 叶子”。两遍 DFS 的分工不同,不能交换执行顺序。

第一次 DFSO(n)
第二次 DFSO(n)
总时间复杂度O(n)
空间复杂度O(n)

为什么用 long long?链形树的距离和可达到 n(n-1)/2,n 较大时会超过 int。

为什么每条边要加入两次?输入给的是无向树;第二次 DFS 也需要沿父亲到儿子的方向遍历所有邻接点。

为什么有 2LL?让乘法按 long long 计算,避免乘法先以 int 溢出后再赋值。

PART 06 · 从模板走向真正会做

换根 DP 的固定思考流程

先任选一个根通常选 1,让无向树临时拥有父子关系。

设计向上收集的状态问清一个子树要交给父亲哪些信息,才能算出初始根的答案。

观察根移动一条边把所有节点按影响相同的方式分组,数出每组大小。

写出父到子的转移由父亲的整树答案 O(1) 推出儿子的整树答案。

两次遍历覆盖整棵树第一次儿子到父亲,第二次父亲到儿子,最后每个点都有答案。

提交前检查
两次 DFS 是否都跳过了 parent?
第一次 DFS 是否在访问儿子之后才合并?
是否先令 answer[1] = inside[1],再开始第二次 DFS?
距离和与乘法是否使用 long long?
n = 1 时是否能输出 0?
树退化成超长链时,递归深度是否安全?

带权边只是把“变化 1”换成“变化 w”

若边 u-v 的权值为 w,根移过这条边后,v 子树内每个节点的距离减少 w,子树外每个节点的距离增加 w:

带权树换根answer[v] = answer[u] + (n - 2 × size[v]) × w

第一次 DFS 中,儿子 v 给父亲 u 的贡献也相应变为 inside[v] + size[v] × w

公式不能脱离状态直接套

这个公式依赖“每个节点权重都为 1、目标是距离总和”。如果节点自带权值,就要把 size[v] 换成 v 子树的权值和;如果题目统计的不是距离和,也要重新分析移根后各组贡献。

按难度完成三次练习

LEVEL 01

只手算,不写代码

在五节点示例树上,把根从 1 依次移到 2、4,标出每次变近与变远的节点。

目标:真正理解公式
LEVEL 02

独立写出模板题

合上完整代码,只保留状态定义,自己完成两次 DFS 并用 n = 1、链和星形树测试。

目标:掌握实现细节
LEVEL 03

推广到带权树

每条边有正权,输出每个节点到所有节点的带权距离和;同时修改第一次与第二次 DFS。

目标:从变化量推导转移
SELF CHECK

合上页面后,你能回答这四句话吗?

① 为什么从每个点重新 DFS 是 O(n²)?② inside[u] += inside[v] + size[v] 中为什么要加 size[v]?③ 根从 u 移到 v 后,哪两组节点的距离怎样变化?④ 为什么两次 DFS 就能得到全部答案?都能用自己的话说清楚,才算真正掌握。

你已经完成一次完整的换根推导

第一遍把子树信息收向根,第二遍把整树答案传向叶子。以后遇到“每个节点都要求答案”的树上问题,先研究根只移动一条边时答案怎样变化,而不是先背模板。

NEXT LESSON

下一节:区间 DP

把两个下标同时放进状态,从短区间开始组合出长区间答案。

开始学习
DYNAMIC PROGRAMMING 05 · 从短区间走向长区间

区间 DP

线性 DP 常用一个下标表示“处理到哪里”,区间 DP 则同时记录左右端点,用 dp[l][r] 描述连续区间 [l, r]。它最重要的不是三层循环,而是弄清:一个长区间的最后一步,怎样由更短的区间组成。

SHORT FIRST · LONG LATER

先填对角线,再一层层向右上方扩展

长度为 1 的区间是最小问题;长度为 2 的状态依赖长度 1,长度为 3 再依赖更短状态。只要按长度递增,转移需要的答案就已经准备好。

状态:dp[l][r]顺序:len → l → k常见复杂度:O(n³)
len = 1[1,1][2,2][3,3][4,4]
len = 2[1,2][2,3][3,4]
len = 3[1,3][2,4]
len = 4[1,4]
本节要学会什么
学会 01

写完整状态定义

说清 dp[l][r] 对哪个连续区间求什么答案,以及是否必须处理区间内全部元素。

学会 02

确定递推顺序

理解为什么必须从短区间到长区间,并写对 len、l、r 的边界。

学会 03

枚举分界点

[l,r] 拆成 [l,k][k+1,r],覆盖最后一步的所有可能。

学会 04

处理左右端点

根据端点选不选、能否配对,转移到 [l+1,r-1] 等更短区间。

PART 01 · 先建立区间状态的坐标感

dp[l][r] 到底比 dp[i] 多记录了什么?

线性 DPdp[i] 常表示前 i 个元素的答案,左边界通常固定在开头。
区间 DPdp[l][r] 同时记录左右端点,能够回答任意连续片段的问题。
识别信号题目反复出现“合并相邻部分”“删除两端”“区间配对”“最后切成两段”。

长度为 len、左端点为 l 时,右端点不是另一个独立循环变量,而是由区间长度唯一确定:

右端点计算r = l + len - 1

例如 n = 5、len = 3,合法区间依次是 [1,3]、[2,4]、[3,5]。必须满足 r ≤ n

interval_order.cpp
for (int len = 1; len <= n; len++) {
    for (int l = 1; l + len - 1 <= n; l++) {
        int r = l + len - 1;

        // 此时计算区间 [l, r]
        // 它依赖的更短区间已经算完
    }
}
i
为什么不直接让 l 从小到大、r 也从小到大?

这种顺序不能直观看出依赖是否已经完成。例如 dp[1][4] 可能依赖 dp[2][4],它的左端点反而更大。按长度递增才同时保证 [l,k][k+1,r][l+1,r-1] 都先被计算。

区间 DP 的两条主路线

ROUTE A枚举分界点 k[ l ... k ] [ k+1 ... r ]

思考最后一次合并或最后一次切分发生在哪里。

ROUTE B观察左右端点l [ ............ ] r

思考两端能否配对,或者删除、选择哪一个端点。

PART 02 · 石子合并:枚举最后一次分界

题目模型

n 堆石子排成一行,每次只能把相邻两堆合并,代价等于两堆石子的总数。要求把所有石子合成一堆的最小总代价。对于 1、2、10

先合并 1 与 21 + 2 = 33 + 10 = 13总代价 16
对比
先合并 2 与 102 + 10 = 121 + 12 = 13总代价 25
状态定义

dp[l][r]

把第 l 堆到第 r 堆全部合并成一堆的最小代价。

最小区间

dp[i][i] = 0

一堆石子本来就是一堆,不需要发生合并。

不要从“第一步合并什么”想,要从“最后一步剩什么”想

[l,r] 最终合成一堆时,最后一次合并之前一定恰好剩下两堆连续石子。它们可以写成 [l,k][k+1,r],其中 k 从 l 枚举到 r-1。

[ l ... r ]最后一次合并前
[ l ... k ]+[ k+1 ... r ]
最后再付出整段石子总数
石子合并转移dp[l][r] = min(dp[l][k] + dp[k+1][r] + sum(l,r))

前两项分别是把左右两段各自合成一堆的最小代价;最后一项是把这两大堆合起来时必付的代价。

用前缀和 O(1) 求区间石子总数

prefix[i] = a[1] + … + a[i]sum(l,r) = prefix[r] - prefix[l-1]

如果每次转移都重新循环求 sum(l,r),会在三层枚举外又增加一层计算。前缀和让区间代价保持 O(1)。

四堆石子 1、2、10、4 的完整填表

区间长度状态最优分界最小代价
1dp[i][i]无需合并0
2dp[1][2] / dp[2][3] / dp[3][4]只有一个分界3 / 12 / 14
3dp[1][3] / dp[2][4]k = 2 / k = 316 / 28
4dp[1][4]k = 333

计算 dp[1][4] 时,区间总和固定为 17,但最后分成哪两段并不固定。把三个候选方案真正代入:

k = 10 + 28 + 1745[1,1] 与 [2,4]
k = 23 + 14 + 1734[1,2] 与 [3,4]
k = 316 + 0 + 1733[1,3] 与 [4,4],最优
为什么只枚举 k 就不会漏?

无论前面怎样合并,最后一刻一定只剩左右相邻的两大堆。它们之间必有且只有一条分界线 k。枚举 l ≤ k < r 就枚举了所有可能的最后一步;左右两段内部的最佳合并方式已经分别保存在两个较短状态中。

示例输入4
1 2 10 4
示例输出33
READ THE CODE · 不要从第一行硬啃

前缀和负责区间代价

INF 与对角线负责初值

len 保证先短后长

k 枚举最后一次分界

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

int main() {
    ios::sync_with_stdio(false);
    cin.tie(nullptr);

    int n;
    cin >> n;
    // ① 前缀和:prefix[r] - prefix[l-1] 可 O(1) 求 [l,r] 总石子数。
    vector<long long> a(n + 1), prefix(n + 1, 0);
    for (int i = 1; i <= n; i++) {
        cin >> a[i];
        prefix[i] = prefix[i - 1] + a[i];
    }

    // ② 求最小值:先设为 INF;单堆无需合并,所以 dp[i][i] = 0。
    const long long INF = 4'000'000'000'000'000'000LL;
    vector<vector<long long>> dp(
        n + 1, vector<long long>(n + 1, INF)
    );
    for (int i = 1; i <= n; i++) dp[i][i] = 0;

    // ③ 先枚举区间长度:计算长区间时,所有短区间已经完成。
    for (int len = 2; len <= n; len++) {
        for (int l = 1; l + len - 1 <= n; l++) {
            int r = l + len - 1;  // 闭区间长度为 r-l+1
            long long intervalSum = prefix[r] - prefix[l - 1];

            // ④ 枚举最后一次合并的分界:[l,k] + [k+1,r]。
            for (int k = l; k < r; k++) {
                dp[l][r] = min(
                    dp[l][r],
                    dp[l][k] + dp[k + 1][r] + intervalSum
                );
            }
        }
    }

    cout << dp[1][n] << '\n';  // 整段 [1,n] 的最小代价
    return 0;
}
把代码翻译回四句话

prefix 快速算本次合并代价;dp[i][i] = 0 给出最小问题;len 决定合法计算顺序;k 穷举最后一次合并。其余代码只是输入、存储与输出。

状态数量O(n²)
每个状态枚举 kO(n)
总时间复杂度O(n³)
空间复杂度O(n²)
PART 03 · 括号子序列:端点配对与区间拼接

这次状态求的是最大值

给定只含 ( ) [ ] 的字符串,求最长合法括号子序列长度。子序列允许跳过字符,但不能改变相对顺序。例如 ([)] 不是合法括号串,却能选择 ()[],答案为 2。

子序列不等于子串

子串必须连续,子序列可以跳过若干字符。本题的 dp[l][r] 表示在整个区间里“挑选”字符,因此不要求最终选出的字符在原串中连续。

状态定义

dp[l][r]

字符串区间 [l,r] 中能选出的最长合法括号子序列长度。

最小区间

dp[i][i] = 0

单个括号无法组成一对,空区间的答案也视为 0。

情况一:左右端点恰好能配成一对

(区间 [l+1, r-1] 的最优子序列)+ 2

如果 s[l]s[r]()[],可以把它们包在中间最优解两侧:

端点配对dp[l][r] = max(dp[l][r], dp[l+1][r-1] + 2)

当区间长度为 2 时,中间是空区间,贡献为 0,所以一对匹配括号得到长度 2。

情况二:答案由两个合法子序列拼接

合法序列还可能像 (())[] 一样由两段组成,因此仍要枚举分界点 k:

区间拼接dp[l][r] = max(dp[l][r], dp[l][k] + dp[k+1][r])

k 从 l 到 r-1。这个转移也自然覆盖了“不使用左端点”或“不使用右端点”的情况,因为单字符区间贡献为 0。

区间可用转移结果
[1,2] = "(["端点不配对,切分后仍为 00
[1,3] = "([)"外层 () 配对,中间单字符贡献 02
[2,4] = "[)]"外层 [] 配对,中间单字符贡献 02
[1,4] = "([)]"切分后继承 [1,3] 或 [2,4] 的答案2
示例输入4
([)]
示例输出2
READ THE CODE · 括号题只分两种来源

先判断左右端点

能配对就包住中间

再枚举所有切分

始终保留最大长度

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

// 只有同类型的左括号与右括号才能组成一对。
bool match(char left, char right) {
    return (left == '(' && right == ')') ||
           (left == '[' && right == ']');
}

int main() {
    ios::sync_with_stdio(false);
    cin.tie(nullptr);

    int n;
    string s;
    cin >> n >> s;
    s = " " + s;  // 补一个空格,让字符串下标与 dp 的 1-based 下标一致

    // 单字符和空区间都无法形成括号对,初值 0 正好符合定义。
    vector<vector<int>> dp(n + 2, vector<int>(n + 2, 0));

    // ① 按长度递增,保证 [l+1,r-1] 和切分后的区间都已算好。
    for (int len = 2; len <= n; len++) {
        for (int l = 1; l + len - 1 <= n; l++) {
            int r = l + len - 1;

            // ② 来源一:左右端点配对,包住中间的最优子序列。
            if (match(s[l], s[r])) {
                dp[l][r] = dp[l + 1][r - 1] + 2;
            }
            // ③ 来源二:枚举 k,把两个合法子序列首尾拼接。
            for (int k = l; k < r; k++) {
                dp[l][r] = max(
                    dp[l][r],
                    dp[l][k] + dp[k + 1][r]
                );
            }
        }
    }

    cout << dp[1][n] << '\n';  // 整个字符串中的最大合法长度
    return 0;
}
这里最容易忽略的一点

端点能够配对时也不能跳过 k 循环,因为最优答案可能由两段合法序列拼起来;端点不能配对时,k 循环又会自然保留只出现在某个子区间中的答案。

状态数量O(n²)
每个状态枚举 kO(n)
总时间复杂度O(n³)
空间复杂度O(n²)
PART 04 · 把两道题放在一起比较
石子合并

最后一次切成两堆

min(左段 + 右段 + 整段代价)

必须处理区间内全部石子;目标是最小值,所以非基础状态先初始化为正无穷。

括号子序列

端点配对或切成两段

max(中间 + 2, 左段 + 右段)

可以跳过字符;目标是最大长度,所有状态从 0 开始就是合法的下界。

共同状态两个问题都用 dp[l][r] 描述一个连续区间,并依赖更短区间。
共同顺序都按照 len 从小到大,再枚举 l 并计算 r。
不同决策转移必须来自题目的“最后一步”,不能只看见区间就机械套模板。
?
怎样判断用“分界点”还是“左右端点”?

如果最终操作会把两段连续结果合在一起,尝试枚举 k;如果题目强调两端的选择、删除、配对或回文关系,尝试向 [l+1,r][l,r-1][l+1,r-1] 转移。有些题像括号序列一样,两类转移都需要。

PART 05 · 初始化、顺序与边界

初始化不是选择一种 memset,而是代入状态定义

求最小值dp[l][r] = INF

先设为不可能的大值,再用每个合法候选不断取 min;基础状态单独赋值。

求最大值dp[l][r] = 0

只有当“不选任何元素”是合法方案时,0 才能作为所有状态的初始下界。

单点区间dp[i][i] = ?

把“只剩一个元素”代入定义:石子无需合并为 0,单括号无法配对也为 0。

提交前检查
是否用一句完整的话定义了 dp[l][r]
len 是否从最短依赖开始递增?
是否写成 r = l + len - 1
左端点循环是否保证 r ≤ n
分界点是否满足 l ≤ k < r
空区间与单点区间是否有明确含义?
求最小值时,INF 与加法是否会溢出?
O(n³) 是否能通过题目的 n?
最常见的 off-by-one:把区间长度写成 r - l

闭区间 [l,r] 的长度是 r - l + 1,所以 r = l + len - 1。分界成 [l,k][k+1,r] 时,k 必须严格小于 r。

PART 06 · 把区间 DP 变成自己的方法

动笔前固定回答六个问题

状态dp[l][r] 表示什么?区间必须全部处理,还是允许选择子序列?

最小问题空区间、单点区间或长度为 2 时,答案能否直接确定?

最后一步最后是把两段合并、让两端配对,还是删除一个端点?

完整性枚举所有 k 或所有端点选择,是否覆盖每一种合法方案?

计算顺序当前状态依赖哪些更短区间?len 应该从几开始?

答案与复杂度最终读 dp[1][n] 还是其他区间?O(n³) 与 O(n²) 是否可接受?

看到区间不代表一定使用区间 DP

如果每个询问彼此独立,可能更适合前缀和、线段树或双指针。只有当长区间答案会反复依赖较短区间,并且这些子问题可以复用时,区间 DP 才自然。

按难度完成三次练习

LEVEL 01

只画计算顺序

令 n = 5,按 len 从 1 到 5 写出全部区间,确认每个状态依赖的区间都更短。

目标:掌握边界
LEVEL 02

独立写石子合并

不看完整代码,先写状态、初值和转移,再用 1 2 10 4 对照填表结果 33。

目标:掌握分界转移
LEVEL 03

区分子串与子序列

分别思考最长回文子序列与最长回文子串的状态,说明为什么转移和答案读取不同。

目标:避免机械套模板
SELF CHECK

合上代码后,你能回答这五句话吗?

dp[l][r] 的两个下标各表示什么?② 为什么必须从短区间到长区间?③ 石子合并为什么枚举的是最后一次分界?④ 括号题为什么既要看端点又要枚举 k?⑤ 求最小值和求最大值的初始化为什么不同?能独立说明,再开始刷题。

你已经建立区间 DP 的主框架

先定义连续区间状态,再从最小区间出发,用分界点或左右端点组合更短答案。以后先推导“最后一步”,再写 len、l、r、k 四个变量,代码就会从结论自然长出来。

04 · GRAPH THEORY

图论学习路线

将从图的存储和遍历开始,再逐步进入最短路、最小生成树、拓扑排序与连通性问题。

图的存储DFS / BFS最短路最小生成树
05 · NUMBER THEORY

数论学习路线

将介绍整除、最大公约数、质数筛法、快速幂、同余与组合计数等竞赛常用知识。

GCD质数与筛法快速幂同余
COMPETITIVE PROGRAMMING
先认识赛场,再确定方向

常见算法竞赛介绍

不同竞赛在参赛对象、比赛方式和题目难度上各有特点。了解它们不是为了盲目追逐奖项,而是为了给自己的学习找到一个清晰目标。

比赛的本质

在有限时间里,将问题转化为算法,再写成正确、高效的程序。

分析问题设计算法编写代码提交评测
5 类常见赛事

找到适合你的第一场比赛

赛事规则每年可能调整,正式报名和参赛资格请始终以当届官方通知为准。

CHINA COLLEGIATE

中国大学生程序设计竞赛

团队赛

面向中国高校学生的高水平程序设计竞赛,强调算法设计、逻辑推理、编程实现和团队合作。

3 人团队协作实时评测排名系列年度赛事
适合谁已经具备一定算法基础,准备参加高校集训队或团队赛事的同学。
查看 CCPC 官方介绍 ↗
GROUP PROGRAMMING

团体程序设计天梯赛

个人作答

重点考查基础程序设计能力以及数据结构与算法应用能力。选手独立作答,同时通过团体成绩体现学校整体水平。

个人独立答题团体汇总成绩梯度题目分层
适合谁刚开始参加大学算法竞赛,希望检验基础编程能力的同学。
访问天梯赛官方网站 ↗
LAN QIAO CUP

蓝桥杯大赛

个人赛

覆盖软件与电子等多个类别。软件赛中常见 C/C++ 程序设计方向,通常按照不同组别和阶段进行选拔。

个人独立参赛分组对应水平进阶逐级选拔
适合谁希望从个人赛开始积累比赛经验、建立学习目标的高校学生。
访问蓝桥杯官方网站 ↗
BAIDU ASTAR

百度之星程序设计大赛

个人赛

由百度发起的程序设计赛事,重视基础算法、数据结构、编程实现以及分析和解决问题的能力。历史赛事多采用在线评测与逐轮晋级形式。

在线程序评测算法综合挑战个人独立完成
适合谁喜欢挑战算法题、希望通过企业赛事接触不同命题风格的学习者。具体举办信息需关注官方当年公告。
查看百度之星赛事介绍 ↗
深入了解

ICPC 与 CCPC 到底怎样比赛?

这两项赛事是大学算法竞赛中最具代表性的团队赛。下面用一场比赛从开始到结束的过程,把赛制讲清楚。

INTERNATIONAL COLLEGIATE PROGRAMMING CONTEST

国际大学生程序设计竞赛

面向全球高校的多层级团队程序设计竞赛,强调算法能力、临场决策和三人协作。

查看官方规则 ↗
3名队员共同组成一支队伍
1台计算机需要合理安排编码时间
5h经典时长持续分析、编码和调试
20m常见罚时通过题目前的错误提交
01 · 比赛现场

三个人如何使用一台电脑?

三名队员会同时阅读题目,但只有一人能够操作电脑。有人负责推导算法,有人检查边界和样例,有人把已经确认的思路写成代码。角色不是固定职业,而会根据题目和队员特长不断切换。

  • 开场快速读题,判断每题所属算法和预估难度
  • 优先解决把握大的题目,尽快建立提交与排名优势
  • 纸上完成推导和伪代码,减少占用电脑的时间
  • 发现错误后由未编码的队员帮助构造反例
02 · 排名规则

先比解题数,再比总用时

队伍首先按照通过题目数量排名;解题数相同时,总用时更少的队伍靠前。每道通过题的用时从开赛时刻计算到首次通过,之前未通过的提交通常会增加罚时。

示例

A 题在第 40 分钟通过,之前有 2 次错误提交:

40 + 2 × 20 = 80 分钟未解决的题目通常不计入总用时
03 · 晋级与成长

从校内选拔走向区域赛

常见路径是先加入学校集训队,经过校内选拔后代表学校参加相应区域赛事,优秀队伍继续向更高阶段晋级。具体赛区划分、资格和晋级办法应查看当赛季官方规则。

  • 入门:掌握 C++、基础数据结构和常用算法
  • 训练:参加个人赛积累速度,再进行三人组队训练
  • 实战:进行完整 5 小时模拟赛并赛后补题
  • 协作:建立共享模板、读题记录和交叉检查习惯
CHINA COLLEGIATE PROGRAMMING CONTEST

中国大学生程序设计竞赛

面向中国高校学生的年度性高水平赛事,赛题风格和现场形式与 ICPC 团队赛高度相近。

查看官方介绍 ↗
01网络选拔争取全国赛参赛名额
02全国分站赛不同城市巡回举办
03专项赛事女生专场、高职专场等
04年度总决赛优秀高校队伍晋级
01 · 典型赛制

三人一队,实时评测

CCPC 现场赛的典型规则是三名正式队员组成一队,由一名高校教师担任教练。比赛采用上机编程、机器实时评测和实时排名,队伍共同使用一台比赛机器。

总决赛规则示例中,比赛时长为 5 小时,题目通常为英文描述;通过一道题后,赛场会升起对应颜色的气球,这也是现场赛非常有辨识度的传统。

02 · 成绩如何计算

解题数量决定第一顺位

排名首先比较解题数量;数量相同时再比较总用时。每道已通过题目的用时,从比赛开始计算到首次正确提交,之前的错误提交会带来额外罚时。

第一顺位通过题目数量更多
第二顺位总用时与罚时更少
最终结果按当届规则确定奖项
03 · 与 ICPC 的关系

相似赛制,不同赛事体系

两者都非常重视算法、数据结构、代码正确性和团队配合,也都常采用三人一机的现场赛形式。主要区别在于赛事组织体系和覆盖范围:ICPC 是国际赛事体系,CCPC 则重点服务中国高校程序设计竞赛。

备赛知识高度互通,因此高校集训队通常会用同一套训练体系准备两项赛事。

天梯赛

个人完成题目,团队汇总成绩

与三人共用一台电脑不同,选手独立操作和提交。题目通常具有明显梯度,既考查基础语法和读题速度,也逐步覆盖数据结构与算法。它适合用来检验一所学校不同水平选手的整体程序设计能力。

备赛重点:基础题正确率、分段得分、时间分配
蓝桥杯

适合作为个人竞赛的起点

软件类竞赛包含 C/C++ 程序设计等方向,并根据参赛对象设置相应组别。相比团队现场赛,个人赛更直接地检验独立读题、实现和调试能力,常被初学者用作阶段性目标。

备赛重点:语法熟练度、模拟枚举、常用算法
百度之星

接触企业算法赛事的命题风格

历史上的程序设计大赛采用在线评测,重点考查基础算法、数据结构和程序实现能力。它为学习者提供了不同于高校系列赛的题目风格;是否举办、参赛资格和晋级方式需要关注当年官方公告。

备赛重点:综合算法能力、代码速度、线上赛经验
快速对比

我应该先参加哪一个?

赛事主要形式入门友好度建议准备
ICPC / CCPC三人团队赛进阶挑战C++、常用算法、团队配合
天梯赛个人答题、团体计分较友好基础语法、数据结构、读题速度
蓝桥杯个人赛、分组别较友好C++ 基础、模拟、枚举与常用算法
百度之星个人在线赛视赛题而定综合算法能力与代码实现速度
如果你还是零基础

先不用急着选择比赛,从第一行 C++ 代码开始。

进入 C++ 语法学习
MY LEARNING SPACE

你好,学习者

保持好奇,继续向前。你今天完成的每一个小目标,都会成为赛场上的底气。

0%总进度
当前学习阶段C++ 基础入门

完成第一章,开启你的算法之旅

LEARNING OVERVIEW

学习概览

今天也要保持思考
C++
已完成章节0/ 14 章
AC
已完成题目0/ 32 题
DAY
连续学习1

每一次回来,都算进步

PTS
刷题积分0

完成更多题目,冲击排行榜

OJ ACTIVITY

刷题热力图

仅统计协会 OJ 中每道题的首次 AC

0OJ 已通过
0近一年活跃
0最长连续
0本周 AC
首次 AC 后,这里会点亮你的刷题记录。
ACHIEVEMENTS & STYLE

荣誉与装扮

冲击历史总榜与周榜,稀有徽章还会解锁专属外观。

AVATAR FRAME

头像边框

积分 / 徽章解锁
PROFILE THEME

主页主题装饰

沉浸式头图

01
为你推荐的下一步

从第一章开始学习

循序渐进地掌握 C++,为算法学习打好基础。

开始学习
YOUR ROADMAP

成长路径

01
C++ 语法基础正在进行
02
基础算法下一阶段
03
专题训练持续解锁
04
参加竞赛最终目标
ALGOSPARK RANKINGALL-TIME HONOR

学习排行榜

山河寄志 · 自有凌云意
今日长缨在手,何时缚住苍龙?
毛泽东《清平乐·六盘山》
01/12

心有山海,步履不停。

洛谷训练题完成得 3—5 分 · 协会 OJ 首次 AC 计分同题不重复计分 · 同分时完成题目更多者优先

历史总榜累计全部刷题积分,并每日记录前三名与连续占榜荣誉。

ALGOSPARK RESOURCE LIBRARY

学习资源

协会成员共享的模板、题解与学习笔记。

0 份资源
RESOURCE COLLECTION

全部资源

点击卡片前往原文阅读,新的分享会按发布时间排列。

正在读取学习资源…
ACCESS CHECK

正在验证访问权限

请稍候,系统正在确认你的管理员身份。

返回首页
PROBLEM STATEMENT

在线作答

正在读取协会 OJ 题面…
CODE & JUDGE

编写并提交代码

代码将使用你绑定的 Hydro 身份进行评测

Tab 缩进 · Ctrl + Enter 提交
提交前请先阅读题目并检查输入输出格式。
已复制到剪贴板