COURSE 00 · 建立算法知识体系
算法学习
语法告诉我们代码怎样写,算法告诉我们问题应该怎样解决。这里会从读懂题目开始,逐步建立复杂度意识,再进入数据结构、动态规划、图论和数论。
01 基础算法 读题、复杂度、模拟、贪心、二分、前缀和、差分以及概率与期望。
9 节已开放 02 数据结构 学习线性结构、栈与队列、树等组织和处理数据的方法。
路线已规划 03 动态规划 学习状态设计、转移方程、递推顺序与空间优化。
线性、背包、树形、换根、区间 DP 已开放 04 图论 从图的存储与遍历开始,进入最短路和生成树。
路线已规划 05 数论 掌握整除、质数、快速幂和同余等竞赛工具。
路线已规划
RECOMMENDED START 先完成基础算法 先建立读题和复杂度意识,再学习具体算法会更稳。
进入基础算法 →
TRACK 01 · FOUNDATION
基础算法路线 这一阶段先建立正确的解题流程和复杂度意识,再学习模拟、贪心、二分、前缀和、差分以及概率与期望。
读懂题目 → 分析复杂度 → 掌握算法思想 → 独立完成题目
FOUNDATION 01 从读懂题目格式开始 先把题目描述翻译成清晰的输入、计算和输出任务。
开始第一节 →
FOUNDATION 04 · 按题意还原过程
模拟
模拟就是让程序按照题目规定的顺序,把一个过程一步一步执行出来。它通常没有难记的公式,真正考查的是:能否把较长的文字规则翻译成明确的状态、操作顺序和边界判断。
识别信号题目反复出现“依次”“每次”“执行操作”“经过若干轮”“最终状态”等描述。
核心问题当前需要保存什么?读到一次操作后,哪些状态会怎样变化?
常见复杂度有 q 次操作,每次只做常数次计算,通常就是 O(q)。
01 删掉故事背景 先用一句话概括任务,例如“机器人按指令移动,越界时原地不动”。
02 列出状态变量 只保存会影响后续过程的数据,例如位置、方向、时间、余额、队列或棋盘。
03 写出单步规则 明确一次操作的读取、计算、合法性检查和状态更新顺序。
04 手算后再编码 用表格走完一个小样例,确认每一步的状态都和题意一致。
示例:网格中的机器人
给定一张 n 行 m 列的网格、一台机器人的起点 (x, y) 和一串移动指令 s,求机器人执行完所有指令后的坐标。坐标的第一个数是行号,第二个数是列号。左上角为 (1, 1),行号向下增大,列号向右增大。
机器人从左到右读取 s,每个字符尝试移动一格。U 向上,行号减 1。D 向下,行号加 1。L 向左,列号减 1。R 向右,列号加 1。只有目标格在第 1 到 n 行、第 1 到 m 列之内才移动。越界时留在原格,继续读下一条指令。
输入格式 第一行输入四个整数 n m x y,依次表示行数、列数、起点行号和起点列号。第二行输入指令字符串 s,其中没有空格。
输出格式 输出一行两个整数,以空格分隔,依次为最终的行号和列号。
数据范围 1 ≤ n, m ≤ 10⁹,1 ≤ x ≤ n,1 ≤ y ≤ m。指令数为 1 到 2 × 10⁵,每个字符都是 U、D、L 或 R。
样例中的起点 3 4 2 2 表示网格有 3 行 4 列,机器人从第 2 行第 2 列出发。
样例
先在纸上走一遍
第 2、7、8 条指令尝试走出网格,执行这三条指令时位置保持原样。最后一条 U 从 (3, 4) 移到 (2, 4),因此输出 2 4。
指令 执行前 尝试到达 执行后
1 · U (2, 2) (1, 2) (1, 2)
2 · U (1, 2) (0, 2),越界 (1, 2)
3 · R (1, 2) (1, 3) (1, 3)
4 · R (1, 3) (1, 4) (1, 4)
5 · D (1, 4) (2, 4) (2, 4)
6 · D (2, 4) (3, 4) (3, 4)
7 · D (3, 4) (4, 4),越界 (3, 4)
8 · R (3, 4) (3, 5),越界 (3, 4)
9 · U (3, 4) (2, 4) (2, 4)
把单步规则翻译成代码
robot.cpp 复制代码
#include <bits/stdc++.h>
using namespace std;
int main() {
int n, m, x, y;
string s;
cin >> n >> m >> x >> y >> s;
// 先试走一步,确认没有越界后再更新位置
for (char c : s) {
int nx = x, ny = y;
if (c == 'U') nx--;
else if (c == 'D') nx++;
else if (c == 'L') ny--;
else if (c == 'R') ny++;
bool ok = nx >= 1 && nx <= n
&& ny >= 1 && ny <= m;
if (ok) {
x = nx;
y = ny;
}
}
cout << x << ' ' << y << '\n';
}
i
为什么先计算 nx、ny? 候选状态把“尝试移动”和“正式更新”分开。只有候选位置合法时才同时更新 x、y,越界时自然保留旧状态。规则较多时,这比边判断边修改更不容易出错。
复杂模拟先做“统一表示”
统一单位时间统一换成秒,方向统一编号为 0—3,坐标统一采用同一种下标规则,减少分类讨论。
拆出单步函数把“一次操作”写成独立函数;主流程只负责按顺序调用,更容易单独测试。
维护不变量写下每步结束后始终成立的条件,例如位置一定在网格内、余额不能为负,并在调试时检查。
模拟题最常见的四类错误
① 状态漏记 只记录当前位置,却忘记方向、剩余次数等会影响下一步的数据。
② 顺序写反 题目要求“先扣费再判断”,代码却先判断后扣费,结果会完全不同。
③ 边界差一 混淆下标从 0 还是从 1 开始,或把 < 写成 <=。
④ 输出时机错误 题目要最终状态,却在每一步输出;或者需要记录过程,却只保存最终答案。
能模拟,不代表逐步模拟一定来得及 如果操作次数只有 10^5,逐步执行通常没有问题;如果题目要求执行 10^18 次,就要寻找周期、公式或批量处理方法。先看数据范围,再决定是否真的一步一步做。
写模拟题前检查 □ 我能否用一句话说清整个过程?
□ 哪些变量组成完整状态?
□ 一次操作的更新顺序是否固定?
□ 第一步、最后一步和越界情况是否手算过?
TRY IT 日期推进一天 输入一个合法日期,输出它的下一天。先列出 year、month、day 三个状态,再分别处理普通日期、月末、年末和闰年二月。不要一开始就写一长串 if。
FOUNDATION 05 · 做出当前最优选择
贪心
贪心算法会按照某条规则不断做出当前选择,并且不回头修改。代码往往只有“排序 + 遍历”,但真正的难点是找到正确规则,并说明这个局部选择为什么一定能组成全局最优答案。
贪心不是“每次挑最大的” “最大、最小、最早、最短”都只是候选策略。只有能够通过证明,并且找不到反例的策略,才能称为正确的贪心算法。
01 明确优化目标 到底是让数量最多、总代价最小、等待时间最短,还是让剩余空间最大?
02 提出局部规则 思考当前选谁会给未来留下更有利的局面,而不是只看眼前数值大小。
03 先攻击自己的策略 构造 3—5 个元素的小数据,专门寻找能让策略失败的反例。
04 给出正确性理由 常用交换论证:把某个最优解的第一步换成贪心选择,答案不会变差。
经典例子:选择最多不重叠区间
学校只有一间教室,有 n 个活动申请使用。第 i 个活动的开始和结束时间分别是 lᵢ、rᵢ。选中一个活动,就必须按原定时间完整举行。请从这些活动中选出尽可能多的活动,使教室在任意时刻最多被一个活动占用。
活动占用半开区间 [lᵢ, rᵢ),即从开始时刻占用教室,在结束时刻腾出教室。按实际举行顺序看,只要后一个活动的开始时间大于或等于 前一个活动的结束时间,两者就能连续安排。例如 [2, 3) 和 [3, 5) 可以都选,[2, 3) 和 [1, 4) 会冲突。
输入格式 第一行输入整数 n。接下来 n 行,每行输入两个整数 lᵢ rᵢ,表示一个活动的开始时间和结束时间。活动可以按任意顺序给出。
数据范围 1 ≤ n ≤ 2 × 10⁵,0 ≤ lᵢ < rᵢ ≤ 10⁹。
样例中的一组安排 依次选择 [2, 3)、[3, 5)、[5, 6) 和 [6, 8),能安排 4 个活动。
样例
先选哪个活动?
最早开始?可能很早开始、很晚结束,一次占满几乎所有时间。
持续最短?短活动可能卡在中间,同时挡住前后两个活动。
最早结束 ✓结束越早,为所有尚未选择的活动留下的时间越多。
手算选择过程
把样例中的 6 个活动按结束时间从小到大排序。每次只比较当前活动的开始时间与上一个已选活动 的结束时间。跳过冲突的活动时,已选活动的结束时间保持原样。
当前区间 上次结束 是否选择 原因
[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 复制代码
#include <bits/stdc++.h>
using namespace std;
struct Activity {
long long l, r;
};
bool cmp(const Activity &a, const Activity &b) {
if (a.r != b.r) return a.r < b.r;
return a.l < b.l;
}
int main() {
ios::sync_with_stdio(false);
cin.tie(nullptr);
int n;
cin >> n;
vector<Activity> a(n);
for (Activity &e : a) {
cin >> e.l >> e.r;
}
// 优先选择结束最早的区间,给后面留出更多空间
sort(a.begin(), a.end(), cmp);
int ans = 0;
long long ed = LLONG_MIN;
for (const Activity &e : a) {
// 开始时间不早于上一个已选活动的结束时间
if (e.l >= ed) {
ans++;
ed = e.r;
}
}
cout << ans << '\n';
}
② ed记录最后一个已选活动的结束时间,用于判断冲突。
③ 总复杂度 排序是 O(n log n),之后遍历一次是 O(n)。
为什么“最早结束”一定正确?
设贪心选择的第一个活动是 A,任意一个最优方案选择的第一个活动是 B。因为 A 是所有活动中结束最早的,所以 A 的结束时间不会晚于 B。
最优方案先选 B → 用 A 替换 B → A 结束得更早 → 后续活动仍然都能安排
替换之后,活动数量没有减少,而且方案仍然合法。因此一定存在一个“第一步选择 A”的最优方案。对剩余活动重复同样的推理,贪心选择就能得到全局最优答案。这就是一个完整的交换论证 。
交换论证把任意最优解中的某一步换成贪心选择,证明可行性不变且答案不会更差。
归纳 / 始终领先证明完成前 k 步后,贪心方案至少不劣于任何其他方案,再推进到第 k + 1 步。
无法证明怎么办把选择后剩余的问题写出来;若未来还需要比较多种历史状态,通常应考虑 DP,而不是强行贪心。
用反例排除看似合理的策略
贪心结果 选择最短的 [4,6) 后,另外两个都不能选,答案为 1。
真正最优 选择 [0,5) 和 [5,10),答案为 2。
一种题能贪心,不代表相似题也能照搬 例如硬币面值为 1、3、4,凑出 6。每次拿最大硬币会得到 4 + 1 + 1,共 3 枚;最优方案是 3 + 3,只需 2 枚。局部最优没有自动保证全局最优。
使用贪心前检查 □ 优化目标到底是什么?
□ 我的局部选择为未来保留了什么优势?
□ 能否构造小数据推翻它?
□ 能否用交换或替换说明答案不会变差?
TRY IT 安排最多活动 给出若干活动的开始与结束时间。先不用写代码:分别尝试“最早开始、持续最短、最早结束”三种策略,并为前两种寻找反例;确认理由后,再独立实现按结束时间排序的做法。
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 字节。估算数组内存时,可以使用:
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 · 每次排除一半答案
二分
二分利用单调性,每次检查中间位置并排除一半范围。本节分为两部分:二分查找 在有序数组中寻找数值或边界;二分答案 在可能的答案范围中寻找最优可行值。
使用二分前必须确认 搜索范围具有单调性 例如数组已经从小到大排序;或者某个条件在一段范围内为 false,之后全部为 true。
false false false 边界 true true true
01 · 二分查找
当数组已经有序时,可以通过比较 a[mid] 与目标值,排除不可能包含目标的一半元素。每次查询的时间复杂度为 O(log n);如果需要先排序,还要另计 O(n log n) 的预处理时间。
示例:在有序数组中寻找 13
下标 1 2 3 4 5 6 7
数值 2 5 8 13 17 21 30
第 1 次: l = 0, r = 8, m = 4
检查 a[4] = 13;模板继续收缩边界,以统计小于和小于等于 13 的元素数。
模板写法:统计小于、等于和大于目标值的个数
数组使用 1…n 下标。沿用模板的 calc(x) 与 calc2(x):分别找到最后一个小于 x、最后一个小于等于 x 的位置。记为 y、z 后,三类元素个数依次是 y、z-y、n-z;判断 x 是否存在只需检查 z-y > 0。
输入 n、查询数 m、n 个数组元素,再输入 m 个目标值;每次输出“小于、等于、大于”的三个计数。数组先排序,每次查询做两次二分。
binary_search.cpp 复制代码
#include <bits/stdc++.h>
using namespace std;
int n;
vector<int> a;
// 返回最后一个小于 x 的位置,也就是小于 x 的元素个数
int calc(int x) {
int l = 0, r = n + 1;
while (l + 1 < r) {
int m = l + (r - l) / 2;
if (a[m] < x) l = m;
else r = m;
}
return l;
}
// 返回最后一个小于等于 x 的位置
int calc2(int x) {
int l = 0, r = n + 1;
while (l + 1 < r) {
int m = l + (r - l) / 2;
if (a[m] <= x) l = m;
else r = m;
}
return l;
}
int main() {
ios::sync_with_stdio(false);
cin.tie(nullptr);
int m;
cin >> n >> m;
a.resize(n + 1);
for (int i = 1; i <= n; i++) cin >> a[i];
sort(a.begin() + 1, a.end());
while (m--) {
int x;
cin >> x;
int y = calc(x), z = calc2(x);
cout << y << ' ' << z - y << ' ' << n - z << '\n';
}
return 0;
}
① l = 0, r = n + 1两个位置是虚拟哨兵,不读取 a[0] 或 a[n+1];真实元素始终在开区间 (l, r) 中接受检查。
② l + 1 < r两端之间还有元素时继续;使用 l + (r-l)/2 避免 l+r 相加溢出。
③ 满足条件更新 l,否则更新 r 结束时 l 是最后一个满足条件的位置;没有满足元素时 l=0,全部满足时 l=n。
竞赛更常用:寻找第一个满足条件的位置
同样使用模板的开区间写法:左端 l 是不满足条件的虚拟位置,右端 r 是满足条件的虚拟位置,只检查中间的真实位置。函数返回第一个 true;数组搜索时传入 l=0, r=n+1,返回 n+1 表示不存在,不能直接访问该位置。
first_true.cpp 复制代码
template<class Check>
int firstTrue(int l, int r, Check check) { // 搜索开区间 (l, r)
while (l + 1 < r) {
int m = l + (r - l) / 2;
if (check(m)) r = m; // r 维护满足条件的一侧
else l = m; // l 维护不满足条件的一侧
}
return r;
}
直接使用标准库边界函数
binary_search只返回 bool,判断 x 是否存在,不返回下标。
lower_bound返回第一个 大于等于 x 的位置;若不存在,返回 end()。
upper_bound返回第一个 严格大于 x 的位置;两者之差就是 x 的出现次数。
前提搜索区间必须按相同规则排好序;在 vector 上查询是 O(log n)。
binary_boundaries.cpp 复制代码
// a[1...n] 已经升序排序,不把 a[0] 作为搜索元素
bool found = binary_search(a.begin() + 1, a.end(), x);
auto it = lower_bound(a.begin() + 1, a.end(), x);
auto jt = upper_bound(a.begin() + 1, a.end(), x);
int pos = it == a.end() ? -1 : (int)(it - a.begin());
int cnt = (int)(jt - it); // 与 calc2(x) - calc(x) 相同
02 · 二分答案:把最优化变成判定
有些题没有一个已经排好序的数组,而是要求“最小的可行上限”或“最大的可行下限”。这时可以二分答案的数值 :先猜一个 mid,再写 check(mid) 判断这个答案是否可行。
猜一个答案 mid → 用 check(mid) 判断能否做到 → 利用单调性保留一半范围 → 找到可行边界
完整例题:最小化最大分组和
给定 n 个非负整数,在保持原顺序 的前提下,把它们分成至多 k 个非空连续段,使所有段中最大的段和尽可能小。设 1 ≤ k ≤ n ≤ 2 × 10⁵,0 ≤ a[i] ≤ 10⁹。
二分什么?二分每段和允许达到的上限 x。下界是最大元素,上界是整个数组的总和;关键是保证搜索区间包含答案。
怎样判定?从左到右贪心装入当前段,只有再放一个数会超过 x 时才新开一段。最后所需段数不超过 k,就说明可行。
为什么单调?上限 x 可行时,原来的分组在更大上限下仍然可行,因此 check(x) 呈 false → true,我们寻找第一个 true。
为什么这个 check 正确?
固定上限后,因为元素非负,每段尽量向右延伸,只会给后面留下更短的后缀。任何合法分组的第一段都不能比贪心第一段更靠右;对剩下的后缀重复这个理由,就能说明贪心使用的段数最少。因此贪心都需要超过 k 段时,其余分法也做不到。存在负数时,这个贪心判定不再适用。
例如 a = [7, 2, 5, 10, 8],k = 2:上限为 17 时需要 [7, 2, 5] | [10] | [8] 三段,不可行;上限为 18 时可以分成 [7, 2, 5] | [10, 8] 两段,所以最优答案为 18。
binary_answer.cpp 复制代码
#include <bits/stdc++.h>
using namespace std;
int main() {
ios::sync_with_stdio(false);
cin.tie(nullptr);
int n, k;
cin >> n >> k;
vector<long long> a(n);
long long l = 0, r = 0;
for (auto& x : a) {
cin >> x;
l = max(l, x); // 每个元素都必须放入某一段
r += x; // 整个数组作为一段一定可行
}
auto check = [&](long long mid) {
int cnt = 1;
long long sum = 0;
for (long long x : a) {
if (x > mid) return false;
if (sum + x > mid) {
++cnt; // 当前段放不下,新开一段
sum = x;
if (cnt > k) return false;
} else {
sum += x;
}
}
return true;
};
// 答案始终在闭区间 [l, r] 中,r 始终可行
while (l < r) {
long long mid = l + (r - l) / 2;
if (check(mid)) r = mid; // mid 可行,继续尝试更小的上限
else l = mid + 1; // mid 不可行,更小的上限也不可行
}
cout << l << '\n';
return 0;
}
① 输入与输出 第一行输入 n 和 k,第二行输入 n 个非负整数;输出最小的最大段和。样例第一行输入 5 2,第二行输入 7 2 5 10 8,输出 18。
② 循环为什么结束? l < r 时,向下取整的 mid 严格小于 r;无论更新 r = mid 还是 l = mid + 1,区间都会缩小,最终 l = r 就是第一个可行值。
③ 复杂度与整数范围 设 S 为数组总和,一次 check 扫描 O(n),总时间 O(n log(S + 1)),空间 O(n)。总和可能达到 2 × 10¹⁴,边界、mid 和累加器都要使用 long long。
最小可行值与最大可行值要分清 本例寻找 false → true 中的第一个 true。如果是“砍树高度尽量大”等题,check 往往呈 true → false,需要寻找最后一个 true,更新规则也要随之调整,不能原样照搬本例。
二分查找与二分答案的区别
二分查找搜索有序数组的下标,比较 a[mid] 与目标值;常见任务是查找元素、lower_bound 和 upper_bound。
二分答案搜索答案的取值范围,通过 check(mid) 判断可行性;总复杂度还需要乘上一次 check 的开销。
共同点先证明单调性,再确定搜索范围、区间含义、边界更新方式和无解时的处理。
二分最常见的错误 区间定义混乱。写代码前先决定使用哪种区间。本节查找模板用开区间 (l, r) 和 l+1<r,二分答案示例用闭区间 [l, r] 和 l<r,循环条件和更新方式必须始终与各自定义一致。
写二分前检查 □ 搜索的是具体数值、边界位置,还是答案?
□ 有序性或 check 的单调性已经证明了吗?
□ 区间定义、循环条件和更新规则一致吗?
□ 不存在答案时返回什么?
FOUNDATION 07 · 快速回答区间求和
前缀和
如果需要反复询问数组某个区间的元素总和,每次从左到右重新累加会很慢。前缀和先进行一次 O(n) 预处理,之后每次区间查询只需 O(1)。
前缀和数组表示什么?
定义 s[i] 表示原数组前 i 个元素之和。为了让公式更整齐,令 s[0] = 0。
原数组 a 3 1 4 1 5
逐项累加 ↓
s 0 3 4 8 9 14
为什么区间公式成立?
求数组第 2 到第 4 个元素之和:
3 + 1 + 4 + 1 − 3 = 1 + 4 + 1 = 6
s[4] - s[1] = 9 - 3 = 6
prefix_sum.cpp 复制代码
#include <bits/stdc++.h>
using namespace std;
int main() {
ios::sync_with_stdio(false);
cin.tie(nullptr);
int n, q;
cin >> n >> q;
vector<long long> a(n + 1), s(n + 1);
for (int i = 1; i <= n; i++) {
cin >> a[i];
s[i] = s[i - 1] + a[i];
}
while (q--) {
int l, r;
cin >> l >> r;
// 前 r 项减去 l 之前的所有项
cout << s[r] - s[l - 1] << '\n';
}
return 0;
}
预处理时间 O(n)
单次查询 O(1)
q 次总时间 O(n + q)
额外空间 O(n)
i
前缀思想不只用于求和 令 s[i] 记录前 i 个元素中“满足条件的数量”,就能 O(1) 查询区间计数;把加法换成异或,还能得到区间异或值。能否相减消去前段,取决于所用运算是否具有对应的逆运算。
注意 long long 即使数组中的每个元素都能放进 int,许多元素相加后的前缀和也可能超过 int 范围。只要总和可能很大,就使用 long long。
EXTENSION · 从一条线推广到一个矩形
二维前缀和
二维前缀和用于快速计算矩阵中的矩形区域和。它是一维前缀和的自然推广:s[i][j] 表示从左上角 (1, 1) 到右下角 (i, j) 的整个矩形元素之和。
适用场景 多次询问矩阵中的矩形和 例如地图区域统计、二维棋盘计数、图片像素区域求和等。
1 2 3 4 5
2 3 4 5 6
3 4 5 6 7
4 5 6 7 8
怎样构造二维前缀和?
① 当前位置的值 a[i][j]
② 加上上方区域 s[i-1][j]
③ 加上左方区域 s[i][j-1]
④ 减去重复区域 s[i-1][j-1]
怎样查询任意矩形?
设矩形左上角为 (x1, y1),右下角为 (x2, y2),包含边界。先取右下角的大前缀矩形,再减去上方和左方多余区域,最后把被重复减去的左上区域加回来。
矩形查询公式
s[x2][y2] - s[x1-1][y2] - s[x2][y1-1] + s[x1-1][y1-1]
取全部 − 上方 − 左方 + 重叠
下面沿用模板中的 a、s 和 query。输入 n、m、q,再输入 n 行 m 列矩阵,每次询问输入 x1、y1、x2、y2。固定数组版本适用于 1 ≤ n,m ≤ 1000;更大范围需要同时调整 N 并检查内存。
prefix_sum_2d.cpp 复制代码
#include <bits/stdc++.h>
using namespace std;
using ll = long long;
const int N = 1005;
ll a[N][N], s[N][N];
int n, m, q;
// 查询矩形 (x1, y1) ~ (x2, y2),包含边界
ll query(int x1, int y1, int x2, int y2) {
return s[x2][y2] - s[x1 - 1][y2]
- s[x2][y1 - 1] + s[x1 - 1][y1 - 1];
}
int main() {
ios::sync_with_stdio(false);
cin.tie(nullptr);
cin >> n >> m >> q;
for (int i = 1; i <= n; i++) {
for (int j = 1; j <= m; j++) {
cin >> a[i][j];
// 上方加左方,减掉重叠,再加当前位置
s[i][j] = s[i - 1][j] + s[i][j - 1]
- s[i - 1][j - 1] + a[i][j];
}
}
while (q--) {
int x1, y1, x2, y2;
cin >> x1 >> y1 >> x2 >> y2;
cout << query(x1, y1, x2, y2) << '\n';
}
return 0;
}
预处理时间 O(nm)
单次查询 O(1)
q 次总时间 O(nm + q)
额外空间 O(nm)
二维前缀和最容易错在下标 建议让矩阵下标从 1 开始,并额外保留第 0 行和第 0 列为 0。这样查询贴着上边界或左边界的矩形时,不需要额外分类讨论。
FOUNDATION 08 · 快速完成区间修改
差分
如果要对数组的许多区间整体加上一个数,逐个修改区间中的每个元素会很慢。差分只修改区间的两个边界,每次操作是 O(1),最后再用一次前缀和还原整个数组。
差分数组表示什么?
令 d[i] = a[i] - a[i - 1],并规定 a[0] = 0。差分记录的是“当前位置相对前一个位置变化了多少”。
原数组 a 3 1 4 1 5
相邻元素作差 ↓
d 3 -2 3 -3 4 0
最后一个 0 是额外预留的 d[n + 1],用于处理右端点恰好为 n 的区间修改。它不属于原数组。
区间 [l, r] 全部加上 k
原数组为 3 1 4 1 5,将区间 [2, 4] 全部加 2:
d[2] += 2 d[5] -= 2 还原后:3 3 6 3 5
区间内部不需要逐个修改,只改变开始位置和结束位置的下一格。
完整模板:多次区间加
沿用模板“读入 a → 构造 d → 修改两个端点 → 还原 a”的顺序。原模板每次区间加 1;这里把增量读为 k,以覆盖已有例题中的正数和负数修改。第一行输入 n、q,每次操作输入 l、r、k。
difference.cpp 复制代码
#include <bits/stdc++.h>
using namespace std;
int main() {
ios::sync_with_stdio(false);
cin.tie(nullptr);
int n, q;
cin >> n >> q;
// 多留一格给 d[r+1];long long 保存多次累加的结果
vector<long long> a(n + 2), d(n + 2);
for (int i = 1; i <= n; i++) cin >> a[i];
for (int i = 1; i <= n; i++) d[i] = a[i] - a[i - 1];
for (int i = 1; i <= q; i++) {
int l, r;
long long k;
cin >> l >> r >> k;
d[l] += k;
d[r + 1] -= k;
}
// 对差分求前缀和,按模板还原到原数组 a
for (int i = 1; i <= n; i++) a[i] = a[i - 1] + d[i];
for (int i = 1; i <= n; i++) cout << a[i] << ' ';
cout << '\n';
return 0;
}
构造差分 O(n)
单次区间修改 O(1)
q 次修改并还原 O(n + q)
额外空间 O(n)
前缀和擅长“数组不变,多次查询区间和”。
差分擅长“多次修改区间,最后得到整个数组”。
两者关系差分的前缀和是原数组,原数组的相邻差是差分。
记得为 r + 1 多开一个位置 如果数组使用 1 到 n 的下标,差分数组至少开到 n + 1。代码中常写 vector<long long> d(n + 2),避免访问 d[r + 1] 时越界。
普通差分适合离线得到最终结果 如果每次修改后都要立刻查询当前区间和,不能每次重新还原数组;这类在线问题通常需要树状数组或线段树。
EXTENSION · 从区间修改推广到矩形修改
二维差分
二维差分可以把矩形 (c1, d1) 到 (c2, d2) 内的所有元素同时加上 k。沿用模板,原矩阵记为 a,二维差分记为 b;一次修改只改变四个角,最后用二维前缀和还原 a。
完整程序先输入 n、m、q 和 n 行 m 列初始矩阵;每次修改输入 c1、d1、c2、d2、k,最后输出修改后的矩阵。两个矩阵均额外预留一行一列,以处理右边界和下边界。
difference_2d_update.cpp 复制代码
#include <bits/stdc++.h>
using namespace std;
int main() {
ios::sync_with_stdio(false);
cin.tie(nullptr);
int n, m, q;
cin >> n >> m >> q;
vector<vector<long long>> a(n + 2, vector<long long>(m + 2));
vector<vector<long long>> b(n + 2, vector<long long>(m + 2));
for (int i = 1; i <= n; i++) {
for (int j = 1; j <= m; j++) cin >> a[i][j];
}
// 用原矩阵构造二维差分
for (int i = 1; i <= n; i++) {
for (int j = 1; j <= m; j++) {
b[i][j] = a[i][j] - a[i][j - 1]
- a[i - 1][j] + a[i - 1][j - 1];
}
}
while (q--) {
int c1, d1, c2, d2;
long long k;
cin >> c1 >> d1 >> c2 >> d2 >> k;
b[c1][d1] += k;
b[c1][d2 + 1] -= k;
b[c2 + 1][d1] -= k;
b[c2 + 1][d2 + 1] += k;
}
// 按从上到下、从左到右的顺序还原 a
for (int i = 1; i <= n; i++) {
for (int j = 1; j <= m; j++) {
a[i][j] = b[i][j] + a[i][j - 1]
+ a[i - 1][j] - a[i - 1][j - 1];
}
}
for (int i = 1; i <= n; i++) {
for (int j = 1; j <= m; j++) cout << a[i][j] << ' ';
cout << '\n';
}
return 0;
}
i
二维差分的还原 所有矩形修改结束后,按照从上到下、从左到右的顺序计算:a[i][j] = b[i][j] + a[i][j-1] + a[i-1][j] - a[i-1][j-1]。此时 a[i][j] 是最终矩阵中的值。构造与还原均为 O(nm),q 次修改总时间 O(nm+q),空间 O(nm)。
TRY IT 给数组做批量区间修改 输入长度为 n 的数组和 m 次操作,每次把区间 [l, r] 加上 k。使用差分输出所有操作完成后的数组,并尝试与逐个修改元素的做法比较运行次数。
✦ 基础算法第一阶段完成 现在你已经能够根据数据范围判断复杂度,使用二分缩小查找范围,用前缀和优化区间查询,并用差分优化批量区间修改。
FOUNDATION 09 · 把随机过程变成可计算的平均结果
概率与期望
竞赛中的概率题通常不要求背完整本概率论。真正高频的是两种建模:统计“会出现多少个”时,把总贡献拆成许多 0-1 变量;询问“到结束还要多久”时,先分析下一步会走到哪里。
THE TWO QUESTIONS
先判断题目到底在问哪一种期望
题目背景可能是抽球、排列、骰子或随机游走,但拆掉故事后,大多数入门题都会落到右边两句话中的一条。
这一章学完要会做什么?
01 算基本概率 分清样本空间、事件和条件概率,尤其注意不放回后总数会减少。
02 拆分总贡献 用期望线性性与 0-1 指示变量处理次数、位置和元素对。
03 写首步方程 从当前状态先走一步,列出每个下一状态及其发生概率。
04 识别方程结构 知道 DAG 可以倒序递推,有自环要移项,互相依赖可能要解方程。
PART 01 · 概率是在数哪些可能结果
先建立样本空间,再谈事件概率
一次随机过程所有可能出现的基本结果组成样本空间。如果这些结果等概率,事件 A 的概率就是“满足 A 的结果数 ÷ 所有结果数”。例如公平骰子出现偶数,对应结果是 2、4、6,因此概率为 3 / 6 = 1 / 2。
1奇 2偶 3奇 4偶 5奇 6偶
不放回抽取:第二步的分母已经变了
袋中有 2 个黑球和 3 个白球,求“先黑后白”的概率。第一次抽黑球后不放回,袋里只剩 4 个球,其中 3 个是白球。
第一步 抽到黑球 2 / 5 × 已知第一步是黑球 第二步抽到白球 3 / 4 = 联合事件 先黑后白 3 / 10
条件概率的乘法公式 P(A ∩ B) = P(A) · P(B | A) P(B | A) 表示在 A 已经发生的条件下,B 再发生的概率。不要把两个阶段都机械地写成原来的分母。
独立事件:条件概率可以简化
若 A、B 独立,A 是否发生不改变 B 的概率,因此 P(B | A) = P(B),乘法公式化为 P(A ∩ B) = P(A) · P(B)。前面的不放回抽球会改变袋中组成,不能直接套用独立事件公式。
PART 02 · 期望不是“一次会出现的答案”
期望表示大量重复试验后的长期平均
离散随机变量 X 可能取值 x₁, x₂, …,对应概率为 p₁, p₂, …,那么每个结果按出现概率加权:
离散随机变量的期望 E[X] = Σ xᵢ · pᵢ 公平骰子的期望是 (1+2+3+4+5+6) / 6 = 3.5。骰子不可能掷出 3.5,它描述的是重复许多次后的平均点数。
期望值不一定是随机变量能取到的值 “期望为 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 对恰好是 01 时 Iᵢ = 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 · 公式代码也要看清含义
expected_01.cpp 复制代码
#include <bits/stdc++.h>
using namespace std;
int main() {
ios::sync_with_stdio(false);
cin.tie(nullptr);
// n 是 0 的个数,m 是 1 的个数
long long n, m;
cin >> n >> m;
// 把每对相邻位置的贡献相加,期望为 n*m/(n+m)
long double ans = (long double)n * m / (n + m);
// 按题目要求保留 6 位小数
cout << fixed << setprecision(6) << ans << '\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:
期望 DP / 首步分析 f[u] = cost[u] + Σ P(u→v) · f[v] 如果一次操作的代价就是 1,最前面一定有一个 1。终止状态不再需要操作,所以通常令 f[target] = 0。
先区分两个链上随机游走模型
若 0 与 n 都是吸收端点,从 i 等概率向左右走,令 p[i] 为先到 0 的概率,则 p[0]=1、p[n]=0,中间满足 p[i]=(p[i-1]+p[i+1])/2,所以 p[i]=(n-i)/n;先到 n 的概率是 i/n。下面的期望模型把 0 改为只能向右的反射端点,两个模型的边界条件不同。
例题:一维随机游走为什么得到 n²?
位置为 0,1,…,n,从 0 出发;中间位置等概率向左或向右一步,到达 n 后停止;在 0 不能向左,只能走到 1。设 f[i] 为从 i 到 n 的期望步数。
0只能向右 ↔ 1各 1/2 ↔ 2各 1/2 … nf[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 得到连续奇数。
模板中的一般解 f[i] = n² − i² 从 i 开始,累加 d[i+1] 到 d[n],得到 f[i]=(2i+1)+(2i+3)+…+(2n-1)=n²-i²。代入 i=0 得 n²,代入 i=n 得 0,与两个边界一致。
i
这不是普通的“从左往右填表” f[i] 同时依赖 f[i-1] 和 f[i+1],依赖关系有环,本质是一组线性方程。这里利用一维结构作差化简;更一般的题可能需要移项、树形递推或高斯消元。
DAG 上的期望可以直接倒序计算
如果状态编号保证所有转移都从较小编号走向较大编号,就没有环。下面的模型中,从每个非终点状态会等概率选择一条出边,求从 1 走到终点 n 的期望步数。
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>> g(n + 1); // 保存每个状态的后继
while (m--) {
int u, v;
cin >> u >> v; // 题目保证 u < v,因此没有环
g[u].push_back(v);
}
// f[u] 表示从 u 出发的期望步数,终点 f[n] = 0
vector<long double> f(n + 1, 0);
for (int u = n - 1; u >= 1; u--) {
int d = (int)g[u].size(); // 出边数量
if (d == 0) continue; // 没有出边就已经结束
// 先走一步,再加上各后继状态的期望
f[u] = 1;
for (int v : g[u]) {
f[u] += f[v] / d; // 每条出边的概率都是 1/d
}
}
cout << fixed << setprecision(6) << f[1] << '\n';
return 0;
}
把代码翻译成方程 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,题目要求对质数 p 取模,且 b 不被 p 整除,就把除以 b 改写成乘以 b 的模逆元:a / b ≡ a · b^(p-2) (mod p)。沿用模板的 quickpow(a,n),固定 p=1000000007,并先把底数规范到 [0,p)。若模数不是质数,应使用扩展欧几里得等方法,不能直接套 p-2。
mod_inverse.cpp 复制代码
#include <bits/stdc++.h>
using namespace std;
const int p = 1000000007;
long long quickpow(long long a, long long n) {
a = (a % p + p) % p;
long long ans = 1;
for (; n; n >>= 1) {
if (n & 1) ans = ans * a % p;
a = a * a % p;
}
return ans;
}
int main() {
long long a, b;
cin >> a >> b;
a = (a % p + p) % p;
b = (b % p + p) % p;
// p 是质数;b 能被 p 整除时没有逆元
if (b == 0) {
cout << "No inverse\n";
return 0;
}
cout << a * quickpow(b, p - 2) % p << '\n';
return 0;
}
输入分子 a、分母 b,输出 a/b 在模 p 下的值。例:输入 1 2,输出 500000004;若分母是 p 的倍数,程序输出 No inverse。
输出小数使用 double / long double,按误差或小数位要求输出。
对质数取模分母乘模逆元,所有加减乘都在模意义下进行。
先看题目不要看到概率就默认用浮点数,也不要看到分数就擅自取模。
PART 06 · 建模时按这张路线检查
先翻译题目问法,再选择工具
题目问法 第一反应 需要确认
某种结构出现多少次 指示变量 + 线性期望 候选对象有没有漏?
随机排列中的相对顺序 对称性 两个方向是否等概率?
到终点期望几步 首步分析 当前一步的代价是否写上?
有概率留在原状态 把自环项移到左边 移项后的系数是否为 0?
状态之间互相依赖 方程、差分或高斯消元 是否存在可利用的 DAG / 链 / 树结构?
答案为分数并要求取模 模逆元 模数是否为质数,分母是否可逆?
六个最常见的错误
① 把期望当成一次结果 期望可以是 3.5,即使一次试验永远取不到 3.5。
② 误以为必须独立 线性期望拆的是和,不要求各个贡献相互独立。
③ 不放回还用旧分母 已经取走一个对象后,下一次选择的总数必须减一。
④ 漏掉当前一步 求期望步数时经常忘记首步方程最前面的 +1。
⑤ 方向多算一倍 01 只是一种方向,异色相邻才同时包含 01 与 10。
⑥ 有环仍硬填表 互相依赖的期望状态是一组方程,必须先化简或求解。
写概率期望题前检查 □ 随机变量表示什么,最终答案是哪一个期望?
□ 如果统计次数,能否对每个候选贡献定义 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 个 第 i - 1 个不能选,答案为 dp[i - 2] + a[i]。
转移方程 dp[i] = max(dp[i-1], dp[i-2] + a[i])。
先把样例递推出来
数组为 [2, 7, 9, 3, 1]。设 dp[0] = 0,逐个比较“选当前”和“不选当前”。
i / a[i] 不选:dp[i-1] 选择:dp[i-2]+a[i] dp[i]
1 / 2 0 0 + 2 = 2 2
2 / 7 2 0 + 7 = 7 7
3 / 9 7 2 + 9 = 11 11
4 / 3 11 7 + 3 = 10 11
5 / 1 11 11 + 1 = 12 12
完整代码
linear_dp.cpp 复制代码
#include <bits/stdc++.h>
using namespace std;
int main() {
ios::sync_with_stdio(false);
cin.tie(nullptr);
int n;
cin >> n;
// a[i] 是第 i 个位置的收益
vector<long long> a(n + 1);
for (int i = 1; i <= n; i++) {
cin >> a[i];
}
// dp[i] 表示前 i 个位置中,不选相邻位置的最大收益
vector<long long> dp(n + 1, 0);
dp[1] = max(0LL, a[1]);
for (int i = 2; i <= n; i++) {
// 不选 i,或选 i 并跳过 i-1
dp[i] = max(dp[i - 1], dp[i - 2] + a[i]);
}
cout << dp[n] << '\n';
return 0;
}
状态数量 n + 1
每个状态转移 O(1)
总时间复杂度 O(n)
空间复杂度 O(n)
空间为什么可以优化到 O(1)?
计算 dp[i] 时只使用 dp[i-1] 和 dp[i-2],更早的状态不会再次使用,因此只需保存最近两个值。
rolling_dp.cpp 复制代码
long long x = 0; // dp[i - 2]
long long y = max(0LL, a[1]); // dp[i - 1]
for (int i = 2; i <= n; i++) {
// 先算新状态,再依次移动前两个状态
long long z = max(y, x + a[i]);
x = y;
y = z;
}
i
先写对,再优化空间 初学时建议先写含义清晰的 dp 数组,确认状态和转移正确后再改成滚动变量。需要还原具体选择方案时,通常仍要保留完整状态或前驱记录。
记忆化搜索从目标状态递归求解,写法接近原始递推关系;要设置“未计算”标记并留意递归深度。
递推填表从初始状态按依赖顺序计算,没有递归栈;需要明确哪些状态可达以及循环顺序。
还原方案除最优值外保存 parent 或当前决策,最后从答案状态反向追踪。
两种常见的线性状态定义
前 i 个的答案dp[i] 包含前 i 个元素的整体最优值,答案常在 dp[n],本节例题属于这一类。
必须以 i 结尾dp[i] 表示必须选择第 i 个元素的答案,例如最长上升子序列;最终答案通常是所有 dp[i] 的最大值。
多状态如果当前位置有“选 / 不选”“持有 / 不持有”等不同状态,可增加一维或分别维护多个数组。
“前 i 个最优”与“以 i 结尾最优”不能混用 前者允许第 i 个元素不参与答案,后者要求答案必须落在 i。状态定义只差几个字,转移和最终答案位置却会完全不同。
XCPC 模板中的最长上升子序列
沿用XCPC 模板命名:a[i] 保存第 i 个数,f[i] 表示必须以 a[i] 结尾 的最长严格上升子序列长度。先令 f[i] = 1,再枚举所有 j < i,只在 a[i] > a[j] 时接到 j 后面。答案是所有 f[i] 的最大值,不能直接输出 f[n];相等元素不能延长严格上升序列。
lis.cpp 复制代码
#include <bits/stdc++.h>
using namespace std;
int main() {
ios::sync_with_stdio(false);
cin.tie(nullptr);
int n;
cin >> n;
vector<int> a(n + 1), f(n + 1);
for (int i = 1; i <= n; i++) cin >> a[i];
for (int i = 1; i <= n; i++) {
f[i] = 1; // 只选 a[i],长度至少为 1
for (int j = 1; j < i; j++) {
if (a[i] > a[j]) {
f[i] = max(f[i], f[j] + 1);
}
}
}
int ans = 0;
for (int i = 1; i <= n; i++) ans = max(f[i], ans);
cout << ans << '\n';
return 0;
}
时间复杂度 O(n²),空间复杂度 O(n);与模板相同采用二重循环,先结合 n 的范围判断能否通过。
XCPC 模板中的最长公共子序列
输入两列整数,长度分别为 n、m。f[i][j] 表示 a 的前 i 个数与 b 的前 j 个数的最长公共子序列长度。先考虑跳过任意一列末尾的数;若 a[i] == b[j],还可以把这两个数接在 f[i-1][j-1] 后。空前缀所在的第 0 行与第 0 列均为 0。
lcs.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<int> a(n + 1), b(m + 1);
vector<vector<int>> f(n + 1, vector<int>(m + 1));
for (int i = 1; i <= n; i++) cin >> a[i];
for (int j = 1; j <= m; j++) cin >> b[j];
for (int i = 1; i <= n; i++) {
for (int j = 1; j <= m; j++) {
f[i][j] = max(f[i - 1][j], f[i][j - 1]);
if (a[i] == b[j]) {
f[i][j] = max(f[i][j], f[i - 1][j - 1] + 1);
}
}
}
cout << f[n][m] << '\n';
return 0;
}
时间与空间复杂度均为 O(nm)。这两个模板补充了“以 i 结尾”与“两个前缀”的状态;前面的不相邻元素例题仍用于讲解 DP 入门与滚动优化。
线性 DP 常见错误
① 状态含义不完整 只写“dp[i] 是最大值”,没有说明考虑范围以及是否必须选择 i。
② 转移来源漏掉 只考虑选择当前元素,忘记“不选当前”同样是一种合法决策。
③ 初始化与题意冲突 不允许空方案时却把所有状态初始化为 0,会把不存在的方案当成合法答案。
④ 答案取错位置 “必须以 i 结尾”的状态通常需要在所有 i 中取最优,而不一定直接输出 dp[n]。
写线性 DP 前检查 □ 状态定义能否完整读成一句中文?
□ 当前阶段有哪些互不遗漏的最后决策?
□ 起点、空方案和不可达状态怎样初始化?
□ 遍历顺序是否保证依赖先算完?
□ 最终答案究竟位于哪个状态?
TRY IT 最小花费爬楼梯 第 i 级台阶有一个花费,每次可以走 1 级或 2 级。请定义“到达第 i 级的最小花费”,分别写出转移、初值和答案位置,再尝试用两个变量优化空间。
DYNAMIC PROGRAMMING 02 · 在容量限制下做选择
背包 DP
背包问题研究“若干物品 + 一个容量限制 + 如何选择物品”。真正需要掌握的不是模板本身,而是状态表示、物品使用次数以及容量为什么要按特定方向遍历。
0/1 背包每件物品最多选择一次;一维优化后,容量必须从大到小遍历。
完全背包每件物品可以选择无限次;一维写法通常让容量从小到大遍历。
多重背包每件物品有指定数量,需要拆分、二进制优化或使用单调队列等方法。
0/1 背包模型
输入顺序按XCPC 模板保留:0/1 背包先读 m n,完全背包先读 n m;每件物品都读 v[i] w[i]。下面分别注明,复制时按对应格式输入。
有 n 件物品,第 i 件物品体积为 v[i]、价值为 w[i],背包容量为 m。沿用XCPC 模板,v 表示占用容量,w 表示价值。每件物品只能选择 0 次或 1 次,求总体积不超过 m 时的最大总价值。
阶段 依次考虑前 1 件、前 2 件……前 n 件物品。
状态 f[i][j] 表示只考虑前 i 件物品、容量上限为 j 时的最大价值。
决策 第 i 件物品只有“不选”和“选择”两种情况。
从两种决策推导转移
不选第 i 件 → f[i-1][j] 或 选择第 i 件 → f[i-1][j-v[i]] + w[i]
当 j < v[i] 时装不下当前物品,只能不选;否则在两种决策中取最大值:
手算一张 DP 表
容量 m = 8,四件物品分别为 (体积, 价值) = (2,3)、(3,4)、(4,5)、(5,8)。每处理一件物品,就用上一行更新当前行。
处理阶段 c=0..2 c=3..5 c=6..8
0 件物品 0 0 0 0 0 0 0 0 0
(2, 3) 0 0 3 3 3 3 3 3 3
(3, 4) 0 0 3 4 4 7 7 7 7
(4, 5) 0 0 3 4 5 7 8 9 9
(5, 8) 0 0 3 4 5 8 8 11 12
最终 f[4][8] = 12,对应选择体积 3、价值 4 的物品和体积 5、价值 8 的物品。
二维写法:最容易理解
knapsack_2d.cpp 复制代码
// f[i][j]:前 i 件物品、容量不超过 j 时的最大价值
vector<vector<long long>> f(
n + 1, vector<long long>(m + 1, 0)
);
for (int i = 1; i <= n; i++) {
for (int j = 0; j <= m; j++) {
f[i][j] = f[i - 1][j]; // 不选第 i 件物品
// 容量足够时,再尝试选择第 i 件物品
if (j >= v[i]) {
f[i][j] = max(
f[i][j],
f[i - 1][j - v[i]] + w[i]
);
}
}
}
状态数量 O(nm)
单次转移 O(1)
时间复杂度 O(nm)
空间复杂度 O(nm)
压缩成一维:容量必须倒序
第 i 行只依赖第 i - 1 行,可以复用同一个 f[j] 数组。为了让右侧的 f[j-v[i]] 仍然代表“没有处理当前物品”的旧值,容量必须从大到小更新。
knapsack_01.cpp 复制代码
#include <bits/stdc++.h>
using namespace std;
int main() {
ios::sync_with_stdio(false);
cin.tie(nullptr);
int n, m;
cin >> m >> n; // 与模板一致:先容量,再物品数
vector<int> v(n + 1);
vector<long long> w(n + 1), f(m + 1, 0);
for (int i = 1; i <= n; i++) cin >> v[i] >> w[i];
for (int i = 1; i <= n; i++) {
// 倒序枚举容量,保证每件物品只用一次
for (int j = m; j >= v[i]; j--) {
f[j] = max(f[j], f[j - v[i]] + w[i]);
}
}
cout << f[m] << '\n';
return 0;
}
物品循环 n 次
容量循环 最多 m 次
时间复杂度 O(nm)
空间复杂度 O(m)
为什么正序会错?
假设只有一件体积 2、价值 3 的物品,容量为 6。如果容量从小到大更新:
更新位置 读取的旧状态 得到结果 发生了什么
j = 2 f[0] = 0 f[2] = 3 第一次选择物品
j = 4 f[2] = 3 f[4] = 6 同一轮再次使用当前物品
j = 6 f[4] = 6 f[6] = 9 同一件物品被选择三次
0/1 背包倒序,完全背包正序 倒序读取的是上一轮保留下来的旧状态,保证当前物品最多使用一次;正序会读取本轮刚更新的新状态,因此允许继续使用当前物品。
完全背包:同一件物品可以重复使用
完全背包仍可使用一维 f[j],但容量从小到大更新。这样 f[j-v[i]] 可能已经在本轮加入当前物品,继续转移正好表示再次选择它。
complete_knapsack.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<int> v(n + 1);
vector<long long> w(n + 1), f(m + 1, 0);
for (int i = 1; i <= n; i++) cin >> v[i] >> w[i];
for (int i = 1; i <= n; i++) {
// 正序枚举容量,允许当前物品重复使用
for (int j = v[i]; j <= m; j++) {
f[j] = max(f[j], f[j - v[i]] + w[i]);
}
}
cout << f[m] << '\n';
return 0;
}
0/1:容量倒序右侧读取上一轮状态,阻止同一件物品在本轮重复进入答案。
完全:容量正序右侧允许读取本轮新状态,从而继续使用当前物品。
循环不能随便交换求最值时物品在外、容量在内最清晰;求方案数时交换循环还可能改变“组合 / 排列”的含义。
“不超过容量”与“恰好装满”初始化不同
不超过容量允许什么都不选,通常把所有 f[j] 初始化为 0,最后读取 f[m]。
恰好装满只有 f[0] = 0 合法,其他容量初始化为负无穷,表示暂时不可达。
方案计数若求方案数,初始通常是 f[0] = 1,转移使用加法而不是取最大值。
exact_fill_init.cpp 复制代码
// NEG 表示不可达,只有容量 0 的初始状态可达
const long long NEG = -(1LL << 60);
vector<long long> f(m + 1, NEG);
f[0] = 0;
// 转移时只有来源可达,才能继续选择物品。
if (j >= v[i] && f[j - v[i]] != NEG) {
f[j] = max(f[j], f[j - v[i]] + w[i]);
}
数据范围决定能不能使用背包 DP
n × m ≤ 10⁷ 左右普通 O(nm) 通常可行,仍需结合语言、时限和常数判断。
m 非常大容量不能直接作为数组维度,考虑按价值 DP、稀疏状态或其他问题结构。
价值可能溢出 int总价值可能超过 2 × 10⁹ 时,f 数组应使用 long long。
写背包 DP 前检查 □ 每件物品最多能使用几次?
□ f 表示容量上限还是恰好容量?
□ 转移来源是否仍属于正确的上一阶段?
□ 一维优化后容量应该正序还是倒序?
□ n × m 的复杂度能否通过数据范围?
TRY IT 用循环方向区分两类背包 给出 n 件物品和容量 m,分别按“每件最多一次”和“每件不限次数”求最大价值。先用二维 0/1 背包打印小数据的整张 f 表,再压缩成一维,并用“一件体积 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 核心明确 f[u] 描述以 u 为根的子树中的什么信息,以及怎样合并每个儿子。
一句话模板先算儿子,再用儿子的答案计算父亲;DFS 决定顺序,状态转移决定答案。
PART 01 · 先弄清计算顺序
第一步:把树“有根化”
如果输入直接说明“父亲是谁”,可以只保存父亲到儿子的有向边;如果输入只是 n - 1 条无向边,就要双向加边,并在 DFS 中跳过父亲。无根树在题目没有特殊要求时通常可任选 1 号节点为根。
父子输入 fa child 已经确定方向,只需 q[fa].push_back(child)。
无向边输入 同时加入 u → v 和 v → u,遍历时通过 fa 防止走回头路。
为什么选根 根只是在无向树上人为确定计算方向,不会改变树的边和连通关系。
完成顺序 一次 DFS 的完成顺序是叶子到根,恰好满足子状态先于父状态。
tree_dp_template.cpp 复制代码
vector<vector<int>> q; // 在读入 n 后初始化为 n + 1 个邻接表
void solve(int u, int fa) {
// 1. 初始化 f[u]
for (int v : q[u]) {
if (v == fa) continue;
solve(v, u);
// 2. 使用已经算好的 f[v] 更新 f[u]
}
}
程序向下走,答案向上长
这是初学树形 DP 最容易混淆的一点。调用 solve(1, 0) 时,程序先进入 1,再进入 2、4;但 4 没有儿子,会最先完成状态。回到 2 后继续完成 5,等 4、5 都算完,2 才能合并它们。
进入节点的顺序 1 → 2 → 4 ↩ 5 ↩ 3
状态完成的顺序 4 → 5 → 2 → 3 → 1
此刻在哪 发生的动作 哪些状态已完成 能否计算当前点
刚进入 2 发现儿子 4、5 都没有 不能,先递归
从 4 返回 拿到 f[4] 4 还不能,5 未完成
从 5 返回 拿到 f[5] 4、5 可以合并出 f[2]
回到 1 2、3 均已返回 2 的子树、3 可以合并出 f[1]
不要把 DFS 本身误认为动态规划 solve(v, u) 只是保证计算顺序;真正的 DP 是回溯后的 f[u] += f[v]、取最大值或背包合并。没有清晰的状态定义与转移,只有搜索。
PART 02 · 从一个状态开始
入门状态:求每个节点的子树大小
定义 f[u] 为“以 u 为根的子树节点数”。初始化时 u 自己贡献 1;每完成一个儿子 v,就把 f[v] 加到 u。于是:
为什么这里可以直接相加?
以 u 的不同儿子为根的子树互不重叠:一个节点不可能同时属于两个儿子的子树。因此,u 的子树可以被完整拆成“u 自己 + 每个儿子的子树”,既没有遗漏,也不会重复计算。
节点 2 自己 1 先初始化
+ 节点 4 的子树 1 第一个儿子
+ 节点 5 的子树 1 第二个儿子
= 节点 2 的子树 3 合并完成
?
先检查叶子,公式就不容易写错 叶子没有儿子,求和部分为空,只剩自己这 1 个节点,所以 f[leaf] = 1。这同时解释了为什么初始化必须放在遍历儿子之前。
subtree_size.cpp 复制代码
#include <bits/stdc++.h>
using namespace std;
int n;
vector<int> f;
vector<vector<int>> q;
void solve(int i, int fa) {
f[i] = 1; // 节点自己占 1 个位置
for (auto j : q[i]) {
if (j == fa) continue; // 无向边会通回父亲,必须跳过
solve(j, i);
f[i] += f[j]; // 儿子的子树算完后再合并
}
}
int main() {
ios::sync_with_stdio(false);
cin.tie(nullptr);
cin >> n;
f.assign(n + 1, 0);
q.resize(n + 1);
for (int i = 1; i <= n - 1; i++) {
int x, y;
cin >> x >> y;
q[x].push_back(y);
q[y].push_back(x);
}
solve(1, 0);
for (int i = 1; i <= n; i++) cout << f[i] << (i == n ? '\n' : ' ');
return 0;
}
PART 03 · 状态不够时就拆开
经典模型:没有上司的舞会
每位员工 u 有快乐值 v[u]。如果 u 参加舞会,u 的直接下属不能参加;求整棵公司树能获得的最大快乐值。父亲能否选择某个儿子,取决于父亲自己是否参加,因此一个状态不够。
为什么不能只定义“u 子树的最大快乐值”? 假设儿子 v 的最大值恰好来自“选择 v”。当父亲 u 也被选择时,这个最大值就不合法;但单独一个 best[v] 已经丢失了“v 是否参加”的信息。父亲未来还要询问的条件,必须保留在状态中。
父亲 u 将来会问 → “儿子 v 参加了吗?” → 增加一维 0 / 1
f[u][0] u 不参加时,u 的整棵子树能够获得的最大快乐值。
f[u][1] u 参加时,u 的整棵子树能够获得的最大快乐值。
初始化 f[u][0] = 0,f[u][1] = v[u]。
最终答案 根可参加也可不参加,取 max(f[root][0], f[root][1])。
只讨论一个儿子,转移就会自己出现
先暂时忘掉整棵树,只看相邻的父亲 u 和儿子 v。父亲不参加时没有给 v 施加限制,v 可以在自己的两种状态里选较大的;父亲参加时,为避免相邻两人同时出现,v 只能不参加。
u 不参加 → 每个 v 可选可不选 → 加 max(f[v][0], f[v][1])
u 参加 → 每个 v 必须不参加 → 加 f[v][0]
手算一次“儿子 → 父亲”
继续使用开头那棵树,节点 1—5 的快乐值分别是 5、4、3、6、2。把状态写成二元组 (不参加, 参加),严格按照 4、5、2、3、1 的顺序计算:
1 快乐 5 2 快乐 4 3 快乐 3 4 快乐 6 5 快乐 2
当前节点 f[u][0]:u 不参加 f[u][1]:u 参加 完成状态
叶子 4 0 v[4] = 6 (0, 6)
叶子 5 0 v[5] = 2 (0, 2)
节点 2 max(0,6)+max(0,2)=8 4+0+0=4 (8, 4)
叶子 3 0 v[3] = 3 (0, 3)
根 1 max(8,4)+max(0,3)=11 5+8+0=13 (11, 13)
✓
答案为什么是 13? 根 1 参加,所以直接儿子 2、3 都不参加;但限制只作用于直接相邻节点,孙子 4、5 仍然可以参加。最终选择 1、4、5,快乐值为 5 + 6 + 2 = 13。
完整代码:输入给出下属与上司
下面约定每行 u v 表示 v 是 u 的直接上司。建边时使用 q[v].push_back(u),并用 fa 沿上司链找到真正的根,不能默认根一定是 1。
party_tree_dp.cpp 复制代码
#include <bits/stdc++.h>
using namespace std;
int n;
vector<long long> v;
vector<int> fa;
vector<array<long long, 2>> f;
vector<vector<int>> q;
void solve(int u) {
f[u][0] = 0;
f[u][1] = v[u];
for (auto son : q[u]) {
solve(son);
f[u][0] += max(f[son][0], f[son][1]);
f[u][1] += f[son][0];
}
}
int main() {
ios::sync_with_stdio(false);
cin.tie(nullptr);
cin >> n;
v.resize(n + 1);
fa.assign(n + 1, 0);
f.assign(n + 1, {0, 0});
q.resize(n + 1);
for (int i = 1; i <= n; i++) cin >> v[i];
for (int i = 1; i <= n - 1; i++) {
int x, y;
cin >> x >> y; // y 是 x 的上司
q[y].push_back(x);
fa[x] = y;
}
int root = 1;
while (fa[root]) root = fa[root];
solve(root);
cout << max(f[root][0], f[root][1]) << '\n';
return 0;
}
把完整代码拆回四个问题
01 边应该指向谁? u v 表示 v 管理 u,所以保存 v → u。
02 根在哪里? 记录 fa[x] = y;从任意员工(代码从 1 开始)沿上司链向上走,直到 fa[root] = 0。
03 什么时候转移? 必须放在 solve(v) 之后,因为此时儿子的两个状态才已经完整。
04 为什么用 long long? 单人的快乐值也许不大,但许多节点相加后可能超过 32 位整数范围。
05 负快乐值怎么办? u 不参加的初值是 0,转移会自然跳过负收益的儿子;无需强迫任何人参加。
06 答案为什么看根? 根的子树就是整棵树,它的 0 / 1 两种状态已经覆盖所有合法方案。
节点状态 2n
每条边合并 O(1)
时间复杂度 O(n)
空间复杂度 O(n)
PART 04 · 子树之间也可以做背包
当题目再增加人数限制:树形背包
普通舞会只关心“选不选 i”。如果再要求最多选择 m 个人,父亲合并儿子时还必须知道每棵子树分到几个名额。这里按XCPC 模板,把人数这一维定义为容量上限 。
普通选 / 不选 f[i][0 / 1] 只记录 i 是否参加
+ 人数限制 树形背包 f[i][j][0 / 1] 再记录最多 j 人的名额
f[i][j][0] i 的子树最多 j 人参加,i 不参加时的最大快乐值。
f[i][j][1] i 的子树最多 j 人参加,i 参加时的最大快乐值;j = 0 不合法。
初始化 先只合并儿子的贡献,两列均从 0 开始;i 自己最后才加入。
最终答案 找到实际根 root,读取 max(f[root][m][0], f[root][m][1])。
先把 j、k、j-k 翻译成人话
j 合并后允许使用的总名额
= j-k 此前已经合并部分的名额
+ k 当前儿子 son 子树的名额
j、k 是人数上限,可以有空余,不要求必须用满。合并一个儿子时,i 不参加便可取儿子的两种状态的最大值;i 参加则只能取儿子不参加的状态。k = 0 的情况就是保留旧值,因此代码从 k = 1 开始。
READ THE CODE · 树形背包只盯住三个量
tree_knapsack_merge.cpp 复制代码
#include <bits/stdc++.h>
using namespace std;
int n, m;
const long long NEG = -(1LL << 60);
vector<long long> v;
vector<int> fa;
vector<vector<int>> q;
vector<vector<array<long long, 2>>> f;
void solve(int i) {
// 此时两列都只合并儿子的贡献,i 自己最后才占名额。
for (auto son : q[i]) {
solve(son);
for (int j = m; j >= 0; j--) {
for (int k = 1; k <= j; k++) {
f[i][j][0] = max(f[i][j][0],
f[i][j - k][0] + max(f[son][k][0], f[son][k][1]));
f[i][j][1] = max(f[i][j][1],
f[i][j - k][1] + f[son][k][0]);
}
}
}
// i 参加:预留一个名额给自己,其他名额只允许儿子不参加。
for (int j = m; j >= 1; j--) f[i][j][1] = f[i][j - 1][1] + v[i];
f[i][0][1] = NEG; // 0 个名额不能让 i 参加
}
int main() {
ios::sync_with_stdio(false);
cin.tie(nullptr);
cin >> n >> m;
v.resize(n + 1);
q.resize(n + 1);
fa.assign(n + 1, 0);
f.assign(n + 1, vector<array<long long, 2>>(m + 1, {0, 0}));
// 沿用人数限制模板的输入顺序,同时记录父亲找实际根。
for (int i = 2; i <= n; i++) {
int x, y;
cin >> x >> y; // y 是 x 的上司
q[y].push_back(x);
fa[x] = y;
}
for (int i = 1; i <= n; i++) cin >> v[i];
int root = 1;
while (fa[root]) root = fa[root];
solve(root);
cout << max(f[root][m][0], f[root][m][1]) << '\n';
return 0;
}
输入格式沿用人数限制模板:第一行 n m,接着 n - 1 行 x y 表示 y 是 x 的上司;代码通过 fa 找根,最后输入 n 个快乐值。与上面的普通舞会程序相比,快乐值与边的读取顺序不同。
示例输入 5 2 2 1 3 1 4 2 5 2 5 4 3 6 2
示例输出 11
例如最多选 2 人时,选 1 与 4 可得 11;最多选 3 人时,选 1、4、5 可得 13。因为允许少选,负快乐值的员工可以不选。直接三重合并上界为 O(nm²),空间为 O(nm)。
i
初学时先用 ndp 数组 新数组能把合并前后分开,原理与模板的倒序 j 相同:同一个儿子只能合并一次。模板采用原地更新,所以 j 从 m 倒序到 0;把它改成正序会重复使用当前儿子的贡献。
“最多选 j 人”和“恰好选 j 人”不要混用 本模板采用“最多”,预留给儿子的容量可以不用满;若改成“恰好”,应把不可达状态设为负无穷,并只初始化 f[u][0][0] = 0、f[u][1][1] = v[u],合并时过滤不可达来源。不能保留本模板的全 0 容量初值。这里把 f[i][0][1] 明确设为负无穷,以保证“0 人且 i 参加”不会被误读为合法状态。
PART 05 · 把方法变成自己的
树形 DP 的固定思考流程
① 确定根与建边方式 输入是否已经给出父子关系?无向边是否正确双向加入并跳过父亲?
② 写完整状态定义 说明范围是 u 的子树,是否选择 u,以及容量维度表示“恰好”还是“至多”。
③ 推导单个儿子的贡献 假设 f[v] 已知,逐种讨论 u 的状态,写出 v 可以提供哪些合法选择。
④ 设计初始化和合并顺序 先初始化叶子也成立的状态,再逐个儿子合并;不可达状态使用负无穷。
⑤ 确认答案与复杂度 根是否固定、答案取哪个状态、递归深度和 O(nm²) 是否能通过。
提交前检查 □ 无向树是否跳过 fa,避免父子之间无限递归?
□ 输入给定方向时,是否找到了真正的根?
□ 每个 f 状态是否明确限定在 u 的子树内?
□ 儿子状态是否在合并前已经计算完成?
□ n 很大且树退化成链时,递归栈是否安全?
□ 树形背包是否限制了 j、k 的有效范围?
i
留意退化成链的树 树高最坏可达 n。若 n 很大,递归 DFS 可能栈溢出;可以改用显式栈记录父亲与遍历顺序,再按顺序逆序做同样的“儿子 → 父亲”转移。算法仍是树形 DP,只是遍历实现不同。
看到什么题意,应该想到哪种状态?
只问每个子树尝试定义 f[u],让它完整描述 u 子树的大小、和、最大值或最优解。
相邻节点互相限制尝试增加“u 选 / 不选”“u 是什么颜色”等状态,保留父亲做决策时仍需知道的信息。
限制选择数量增加人数或容量维度,并把不同儿子的状态当成多个背包分组逐一合并。
根固定且只求一次优先沿“儿子 → 父亲”的方向后序计算;先保证每个儿子的状态完整,再处理当前节点。
按难度完成三次练习
LEVEL 01 先写出子树信息 求每个节点的子树权值和。先用中文定义 sum[u],再说明为什么初始化为 value[u]。
目标:熟悉后序合并 LEVEL 02 独立完成选 / 不选 树上每个节点有权值,任意相邻两点不能同时选择。不要看模板,先从父亲的两种情况推出转移。
目标:从限制设计状态 LEVEL 03 加入人数限制 把“最多 m 人”改为“恰好 m 人”,先改状态与初值,再用 ndp 数组逐棵合并子树并过滤不可达状态。
目标:掌握树形背包
SELF CHECK 合上代码后,你能回答这四句话吗? ① f[u] 的范围为什么通常是 u 的子树?② 为什么转移写在 solve(v) 之后?③ 父亲参加时为什么只能读取 f[v][0]?④ 树形背包为什么要逐个儿子合并,并区分“恰好”与“至多”?能用自己的话说清楚,再开始刷题。
✦ 你已经建立树形 DP 的主框架 普通树形 DP 沿“儿子 → 父亲”完成子树状态,树形背包在此基础上增加容量并逐棵合并。下一节将面对一个新要求:当题目要每个节点都成为一次根,怎样避免重复计算整棵树。
NEXT LESSON 下一节:换根 DP 从“固定一个根”走向“每个点都做根”,学习两次 DFS 如何复用答案。
开始学习 →
DYNAMIC PROGRAMMING 04 · 让每个节点都成为一次根
换根 DP
树形 DP 通常先选定一个根,只计算这个根对应的答案;但有些题会追问“如果 1、2、3……每个节点分别作为根,答案是多少”。换根 DP 不会从每个节点重新跑一遍,而是先求出一个根的答案,再沿着每条边把已经算好的信息传给相邻节点。
ONE TREE · EVERY ROOT 根移动一步,答案只改两部分 把根从父亲 x 移到儿子 y 后,y 子树内的节点全部近 1,其余节点全部远 1。只要知道 y 的子树大小,就能 O(1) 得到新答案。
第一次 DFS:向上收集 第二次 DFS:向下换根 总复杂度 O(n)
1 2 3 4 5
ans[1] = 6 根沿边 1 → 2 ans[2] = 5
本节要学会什么
学会 01 识别换根问题 看到“每个节点作为根”或“求每个节点的整树答案”,知道暴力为什么会重复。
学会 02 完成第一次 DFS 用子树大小与子树距离和,得到一个初始根的完整答案。
学会 03 推导换根公式 不背公式,亲自数清根移动后哪些节点变近、哪些节点变远。
学会 04 写出两遍遍历 正确处理无向边、父亲节点、long long,以及深链上的递归风险。
PART 01 · 先看暴力重复了什么
经典问题:每个点到所有节点的距离和
给定一棵 n 个节点的无权树,定义 ans[x] 为节点 x 到所有节点的距离之和。我们需要输出 ans[1..n]。例如下面这棵树,边为 1-2、1-3、2-4、2-5:
1 2 3 4 5
以 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) 。
?
为什么可以复用? 相邻节点 x 与 y 的视角非常接近。根只沿一条边移动,所有节点的距离只可能增加 1 或减少 1;我们不需要重新寻找每条路径,只需要数出两类节点各有多少个。
PART 02 · 第一次 DFS:先把子树信息收上来
先固定 1 为根,定义两个量
变量沿用XCPC 模板:q 保存邻接表,s 是子树大小,f 是子树内距离和,v 是子树外距离和,b 是访问标记;up 自底向上收集,down 自顶向下转移。下文用 x、y 表示父子节点,避免与数组 v 混淆。
子树大小 s[x]以 1 为根时,x 的子树一共有多少个节点,包含 x 自己。
子树内距离和 f[x]从 x 出发,到 x 子树中所有节点的距离之和。
这里的“子树”依赖我们临时选定的根 1 原树的边仍是无向的。模板用 b[i] 标记是否访问过;遍历邻接点时只进入未访问的点,避免沿无向边返回父亲。
一个儿子 y 怎样贡献给父亲 x?
f[y] 已经统计了 y 到它子树中所有节点的距离。现在起点从 y 移到父亲 x,每条路径都多经过边 x-y,而 y 子树共有 s[y] 个节点,所以统一增加 s[y]。
y 子树原距离和 f[y] 从 y 出发
+ 每个点多走一条边 s[y] 共 s[y] 个点
= 交给父亲 x f[y] + s[y] 从 x 出发
在示例树上从叶子往上算
节点 x 1 2 3 4 5
s[x] 53111
f[x] 62000
节点 2 收到叶子 4、5 的贡献:f[2] = (0 + 1) + (0 + 1) = 2。节点 1 再收到 2 和 3:f[1] = (2 + 3) + (0 + 1) = 6。因为 1 的子树就是整棵树,所以 ans[1] = f[1]。
first_dfs.cpp 复制代码
void up(int i) {
s[i] = 1;
b[i] = true;
for (auto j : q[i]) {
if (!b[j]) {
up(j);
s[i] += s[j];
f[i] += f[j];
}
}
// 每个后代到 i 都比到其所属儿子多走 1 条边。
f[i] += s[i] - 1;
}
模板把各儿子的 f[j] 先相加,最后统一增加 s[i] - 1。因为 s[i] - 1 = Σ s[j],这与逐个儿子累加 f[j] + s[j] 完全等价。
PART 03 · 第二次 DFS:让根沿每条边移动
从父亲 x 换到儿子 y,所有点恰好分成两组
y 子树内 共有 s[y] 个点 新根离它们全部近 1
根从 x 移到 y → y 子树外 共有 n - s[y] 个点 新根离它们全部远 1
手算根从 1 移到 2
原答案 6 ans[1]
− 变近的节点 3 2、4、5
+ 变远的节点 2 1、3
= 新答案 5 ans[2]
继续把根从 2 移到叶子 4:ans[4] = 5 + 5 - 2 × 1 = 8。同理可以得到所有节点的答案:
节点 1 6 节点 2 5 节点 3 9 节点 4 8 节点 5 8
second_dfs.cpp 复制代码
void down(int i) {
b[i] = true;
for (auto j : q[i]) {
if (!b[j]) {
// 父亲整树贡献 - 儿子子树旧贡献 + 子树外每点远 1。
v[j] = f[i] + v[i] - s[j] - f[j] + n - s[j];
down(j);
}
}
}
i
为什么第二次 DFS 不需要恢复状态? 这里没有真的修改树,也没有把一个全局根来回搬动;我们只是由父亲的 f[i] + v[i] 计算儿子的 v[j],最后得到 f[j] + v[j]。第二遍开始前要清空 b,每个节点只从它的父亲接收一次贡献。
PART 04 · 另一种推导:把答案拆成子树内与子树外两部分
如果直接公式显得跳跃,可以先定义 v[x]:从 x 到其子树之外所有节点的距离和。于是 ans[x] = f[x] + v[x]。对于 x 的儿子 y:
直接换根数学上可化简为只保存 s、f、ans 的直接公式;本节代码沿用XCPC 模板,分别保存 f 与 v。
f / v拆解更直观,也更容易迁移到不能直接化简的题目。
真正要掌握的不是死记一个式子,而是找出“移根后哪些贡献改变、改变多少”。
PART 05 · 完整 C++17 程序
把两次 DFS 连起来
示例输入 5 1 2 1 3 2 4 2 5
示例输出 6 5 9 8 8
READ THE CODE · 两次 DFS 各负责一件事
reroot_distance_sum.cpp 复制代码
#include <bits/stdc++.h>
using namespace std;
typedef long long ll;
int n;
vector<ll> f, v;
vector<int> s;
vector<bool> b;
vector<vector<int>> q;
void makelist(int x, int y) {
q[x].push_back(y);
q[y].push_back(x);
}
void up(int i) {
s[i] = 1;
b[i] = true;
for (auto j : q[i]) {
if (!b[j]) {
up(j);
s[i] += s[j];
f[i] += f[j];
}
}
// 每个后代到 i 都比到其所属儿子多走 1 条边。
f[i] += s[i] - 1;
}
void down(int i) {
b[i] = true;
for (auto j : q[i]) {
if (!b[j]) {
// 父亲整树贡献 - 儿子子树旧贡献 + 子树外每点远 1。
v[j] = f[i] + v[i] - s[j] - f[j] + n - s[j];
down(j);
}
}
}
int main() {
ios::sync_with_stdio(false);
cin.tie(nullptr);
cin >> n;
q.resize(n + 1);
s.assign(n + 1, 0);
f.assign(n + 1, 0);
v.assign(n + 1, 0);
b.assign(n + 1, false);
for (int i = 1; i <= n - 1; i++) {
int x, y;
cin >> x >> y;
makelist(x, y);
}
up(1);
fill(b.begin(), b.end(), false); // 第二遍遍历前清空访问标记
down(1); // v[1] = 0,根没有子树外节点
for (int i = 1; i <= n; i++) cout << f[i] + v[i] << '\n';
return 0;
}
读到这里暂停一下 up 只使用儿子的状态,方向是“叶子 → 根”;down 只使用父亲的整树答案,方向是“根 → 叶子”,计算子树外贡献 v,最后输出 f + v。两遍 DFS 的分工不同,不能交换执行顺序。
第一次 DFS O(n)
第二次 DFS O(n)
总时间复杂度 O(n)
空间复杂度 O(n)
① 为什么用 long long? 链形树的距离和可达到 n(n-1)/2,n 较大时会超过 int。
② 为什么每条边要加入两次? 输入给的是无向树;第二次 DFS 也需要沿父亲到儿子的方向遍历所有邻接点。
③ 为什么 v 要使用 long long? v 与 f 都是距离和,单条转移中 f[i] + v[i] 需要按 long long 计算。
PART 06 · 从模板走向真正会做
换根 DP 的固定思考流程
① 先任选一个根 通常选 1,让无向树临时拥有父子关系。
② 设计向上收集的状态 问清一个子树要交给父亲哪些信息,才能算出初始根的答案。
③ 观察根移动一条边 把所有节点按影响相同的方式分组,数出每组大小。
④ 写出父到子的转移 由父亲的整树答案 O(1) 推出儿子的整树答案。
⑤ 两次遍历覆盖整棵树 第一次儿子到父亲,第二次父亲到儿子,最后每个点都有答案。
提交前检查 □ 两次 DFS 是否都只进入未访问的节点?
□ 第一次 DFS 是否在访问儿子之后才合并?
□ 是否在 up(1) 后清空 b,再执行 down(1),并保持 v[1] = 0?
□ 距离和与乘法是否使用 long long?
□ n = 1 时是否能输出 0?
□ 树退化成超长链时,递归深度是否安全?
带权边只是把“变化 1”换成“变化 w”
若边 x-y 的权值为 w,根移过这条边后,y 子树内每个节点的距离减少 w,子树外每个节点的距离增加 w:
公式不能脱离状态直接套 这个公式依赖“每个节点权重都为 1、目标是距离总和”。如果节点自带权值,就要把 s[y] 换成 y 子树的权值和;如果题目统计的不是距离和,也要重新分析移根后各组贡献。
按难度完成三次练习
LEVEL 01 只手算,不写代码 在五节点示例树上,把根从 1 依次移到 2、4,标出每次变近与变远的节点。
目标:真正理解公式 LEVEL 02 独立写出模板题 合上完整代码,只保留状态定义,自己完成两次 DFS 并用 n = 1、链和星形树测试。
目标:掌握实现细节 LEVEL 03 推广到带权树 每条边有正权,输出每个节点到所有节点的带权距离和;同时修改第一次与第二次 DFS。
目标:从变化量推导转移
SELF CHECK 合上页面后,你能回答这四句话吗? ① 为什么从每个点重新 DFS 是 O(n²)?② f[x] += f[y] + s[y] 中为什么要加 s[y]?③ 根从 x 移到 y 后,哪两组节点的距离怎样变化?④ 为什么两次 DFS 就能得到全部答案?都能用自己的话说清楚,才算真正掌握。
✦ 你已经完成一次完整的换根推导 第一遍把子树信息收向根,第二遍把整树答案传向叶子。以后遇到“每个节点都要求答案”的树上问题,先研究根只移动一条边时答案怎样变化,而不是先背模板。
NEXT LESSON 下一节:区间 DP 把两个下标同时放进状态,从短区间开始组合出长区间答案。
开始学习 →
DYNAMIC PROGRAMMING 05 · 从短区间走向长区间
区间 DP
线性 DP 常用一个下标表示“处理到哪里”,区间 DP 则同时记录左右端点,用 f[l][r] 描述连续区间 [l, r]。它最重要的不是三层循环,而是弄清:一个长区间的最后一步,怎样由更短的区间组成。
SHORT FIRST · LONG LATER 先填对角线,再一层层向右上方扩展 长度为 1 的区间是最小问题;长度为 2 的状态依赖长度 1,长度为 3 再依赖更短状态。只要按长度递增,转移需要的答案就已经准备好。
状态:f[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 写完整状态定义 说清 f[l][r] 对哪个连续区间求什么答案,以及是否必须处理区间内全部元素。
学会 02 确定递推顺序 理解为什么必须从短区间到长区间,并写对 len、l、r 的边界。
学会 03 枚举分界点 把 [l,r] 拆成 [l,k] 与 [k+1,r],覆盖最后一步的所有可能。
学会 04 处理左右端点 根据端点选不选、能否配对,转移到 [l+1,r-1] 等更短区间。
PART 01 · 先建立区间状态的坐标感
f[l][r] 到底比 f[i] 多记录了什么?
线性 DPf[i] 常表示前 i 个元素的答案,左边界通常固定在开头。
区间 DPf[l][r] 同时记录左右端点,能够回答任意连续片段的问题。
识别信号题目反复出现“合并相邻部分”“删除两端”“区间配对”“最后切成两段”。
长度为 len、左端点为 l 时,右端点不是另一个独立循环变量,而是由区间长度唯一确定:
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 也从小到大? 这种顺序不能直观看出依赖是否已经完成。例如 f[1][4] 可能依赖 f[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 与 2 1 + 2 = 3 3 + 10 = 13 总代价 16
对比 先合并 2 与 10 2 + 10 = 12 1 + 12 = 13 总代价 25
状态定义 f[l][r]把第 l 堆到第 r 堆全部合并成一堆的最小代价。
最小区间 f[i][i] = 0一堆石子本来就是一堆,不需要发生合并。
不要从“第一步合并什么”想,要从“最后一步剩什么”想
当 [l,r] 最终合成一堆时,最后一次合并之前一定恰好剩下两堆连续石子。它们可以写成 [l,k] 和 [k+1,r],其中 k 从 l 枚举到 r-1。
[ l ... r ] 最后一次合并前 [ l ... k ]+ [ k+1 ... r ]
最后再付出整段石子总数
用前缀和 O(1) 求区间石子总数
按XCPC 模板,状态数组统一写成 f;公式仍用 l、r 讲解区间,代码中用 i 表示长度、j 表示左端点,所以 l = j、r = j + i - 1,k 仍是分界点。
s[i] = a[1] + … + a[i] → sum(l,r) = s[r] - s[l-1]
如果每次转移都重新循环求 sum(l,r),会在三层枚举外又增加一层计算。前缀和让区间代价保持 O(1)。
四堆石子 1、2、10、4 的完整填表
区间长度 状态 最优分界 最小代价
1 f[i][i]无需合并 0
2 f[1][2] / f[2][3] / f[3][4]只有一个分界 3 / 12 / 14
3 f[1][3] / f[2][4]k = 2 / k = 3 16 / 28
4 f[1][4]k = 3 33
计算 f[1][4] 时,区间总和固定为 17,但最后分成哪两段并不固定。把三个候选方案真正代入:
k = 1 0 + 28 + 1745 [1,1] 与 [2,4] k = 2 3 + 14 + 1734 [1,2] 与 [3,4] k = 3 16 + 0 + 1733 [1,3] 与 [4,4],最优
✓
为什么只枚举 k 就不会漏? 无论前面怎样合并,最后一刻一定只剩左右相邻的两大堆。它们之间必有且只有一条分界线 k。枚举 l ≤ k < r 就枚举了所有可能的最后一步;左右两段内部的最佳合并方式已经分别保存在两个较短状态中。
stone_merge.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> a(n + 1), s(n + 1, 0);
for (int i = 1; i <= n; i++) cin >> a[i];
for (int i = 1; i <= n; i++) s[i] = s[i - 1] + a[i];
const long long INF = (1LL << 60);
vector<vector<long long>> f(n + 1, vector<long long>(n + 1, INF));
for (int i = 1; i <= n; i++) f[i][i] = 0;
// i 枚举区间长度,j 枚举左端点。
for (int i = 2; i <= n; i++) {
for (int j = 1; j + i - 1 <= n; j++) {
// 枚举最后一次合并的分界 k。
for (int k = j; k <= j + i - 2; k++) {
f[j][j + i - 1] = min(f[j][j + i - 1],
f[j][k] + f[k + 1][j + i - 1] + s[j + i - 1] - s[j - 1]);
}
}
}
cout << f[1][n] << '\n';
return 0;
}
把代码翻译回四句话 s 快速算本次合并代价;f[i][i] = 0 给出最小问题;i(长度) 决定合法计算顺序;k 穷举最后一次合并。其余代码只是输入、存储与输出。
状态数量 O(n²)
每个状态枚举 k O(n)
总时间复杂度 O(n³)
空间复杂度 O(n²)
PART 03 · 括号子序列:端点配对与区间拼接
这次状态求的是最大值
括号模板也沿用 i、j、k 的枚举方式。字符串没有补空格,区间 [j,j+i-1] 的字符下标要分别减 1;f 多开两格,使长度为 2 时访问空区间仍合法。
给定只含 ( ) [ ] 的字符串,求最长合法括号子序列 长度。子序列允许跳过字符,但不能改变相对顺序。例如 ([)] 不是合法括号串,却能选择 () 或 [],答案为 2。
子序列不等于子串 子串必须连续,子序列可以跳过若干字符。本题的 f[l][r] 表示在整个区间里“挑选”字符,因此不要求最终选出的字符在原串中连续。
状态定义 f[l][r]字符串区间 [l,r] 中能选出的最长合法括号子序列长度。
最小区间 f[i][i] = 0单个括号无法组成一对,空区间的答案也视为 0。
情况一:左右端点恰好能配成一对
(区间 [l+1, r-1] 的最优子序列 )+ 2
如果区间的左右端字符是 () 或 [],可以把它们包在中间最优解两侧。DP 区间用 1-based 下标,代码里的字符串保留 0-based,因此 [l,r] 对应 s[l-1] 与 s[r-1]:
情况二:答案由两个合法子序列拼接
合法序列还可能像 (())[] 一样由两段组成,因此仍要枚举分界点 k:
区间 可用转移 结果
[1,2] = "(["端点不配对,切分后仍为 0 0
[1,3] = "([)"外层 () 配对,中间单字符贡献 0 2
[2,4] = "[)]"外层 [] 配对,中间单字符贡献 0 2
[1,4] = "([)]"切分后继承 [1,3] 或 [2,4] 的答案 2
READ THE CODE · 括号题只分两种来源
bracket_subsequence.cpp 复制代码
#include <bits/stdc++.h>
using namespace std;
int main() {
ios::sync_with_stdio(false);
cin.tie(nullptr);
int n;
string s;
cin >> n >> s;
vector<vector<int>> f(n + 2, vector<int>(n + 2, 0));
// DP 用 1-based 区间,字符串 s 保留 0-based 下标。
for (int i = 2; i <= n; i++) {
for (int j = 1; j + i - 1 <= n; j++) {
// 区间 [j,j+i-1] 的两个字符是 s[j-1]、s[j+i-2]。
if ((s[j - 1] == '[' && s[j + i - 2] == ']') ||
(s[j - 1] == '(' && s[j + i - 2] == ')')) {
f[j][j + i - 1] = f[j + 1][j + i - 2] + 2;
}
// 即使端点可配对,也要继续考虑分割。
for (int k = j; k <= j + i - 2; k++) {
f[j][j + i - 1] = max(f[j][j + i - 1],
f[j][k] + f[k + 1][j + i - 1]);
}
}
}
cout << f[1][n] << '\n';
return 0;
}
这里最容易忽略的一点 端点能够配对时也不能跳过 k 循环,因为最优答案可能由两段合法序列拼起来;端点不能配对时,k 循环又会自然保留只出现在某个子区间中的答案。
状态数量 O(n²)
每个状态枚举 k O(n)
总时间复杂度 O(n³)
空间复杂度 O(n²)
PART 04 · 把两道题放在一起比较
石子合并 最后一次切成两堆 min(左段 + 右段 + 整段代价)必须处理区间内全部石子;目标是最小值,所以非基础状态先初始化为正无穷。
括号子序列 端点配对或切成两段 max(中间 + 2, 左段 + 右段)可以跳过字符;目标是最大长度,所有状态从 0 开始就是合法的下界。
共同状态两个问题都用 f[l][r] 描述一个连续区间,并依赖更短区间。
共同顺序都按照 len 从小到大,再枚举 l 并计算 r。
不同决策转移必须来自题目的“最后一步”,不能只看见区间就机械套模板。
?
怎样判断用“分界点”还是“左右端点”? 如果最终操作会把两段连续结果合在一起,尝试枚举 k;如果题目强调两端的选择、删除、配对或回文关系,尝试向 [l+1,r]、[l,r-1]、[l+1,r-1] 转移。有些题像括号序列一样,两类转移都需要。
PART 05 · 初始化、顺序与边界
初始化不是选择一种 memset,而是代入状态定义
求最小值 f[l][r] = INF先设为不可能的大值,再用每个合法候选不断取 min;基础状态单独赋值。
求最大值 f[l][r] = 0只有当“不选任何元素”是合法方案时,0 才能作为所有状态的初始下界。
单点区间 f[i][i] = ?把“只剩一个元素”代入定义:石子无需合并为 0,单括号无法配对也为 0。
提交前检查 □ 是否用一句完整的话定义了 f[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 变成自己的方法
动笔前固定回答六个问题
① 状态 f[l][r] 表示什么?区间必须全部处理,还是允许选择子序列?
② 最小问题 空区间、单点区间或长度为 2 时,答案能否直接确定?
③ 最后一步 最后是把两段合并、让两端配对,还是删除一个端点?
④ 完整性 枚举所有 k 或所有端点选择,是否覆盖每一种合法方案?
⑤ 计算顺序 当前状态依赖哪些更短区间?len 应该从几开始?
⑥ 答案与复杂度 最终读 f[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 合上代码后,你能回答这五句话吗? ① f[l][r] 的两个下标各表示什么?② 为什么必须从短区间到长区间?③ 石子合并为什么枚举的是最后一次分界?④ 括号题为什么既要看端点又要枚举 k?⑤ 求最小值和求最大值的初始化为什么不同?能独立说明,再开始刷题。
✦ 你已经建立区间 DP 的主框架 先定义连续区间状态,再从最小区间出发,用分界点或左右端点组合更短答案。以后先推导“最后一步”,再写 len、l、r、k 四个变量,代码就会从结论自然长出来。
04 · GRAPH THEORY 图论学习路线 将从图的存储和遍历开始,再逐步进入最短路、最小生成树、拓扑排序与连通性问题。
图的存储 DFS / BFS 最短路 最小生成树
05 · NUMBER THEORY 数论学习路线 将介绍整除、最大公约数、质数筛法、快速幂、同余与组合计数等竞赛常用知识。
GCD 质数与筛法 快速幂 同余