DP 不是一种算法,是一种把“重复计算”换成“查表”的写法。数字三角形 n = 1000 层,从顶到底的路径有 2⁹⁹⁹ 条,暴搜到宇宙热寂也跑不完;但互不相同的子问题只有 n(n+1)/2 ≈ 5×10⁵ 个。同一个子问题被重复算了天文数字次,DP 的全部工作就是让每个子问题只算一次、把结果存进数组。
无后效性是 DP 能不能用的唯一门槛:一个状态一旦确定,之后怎么走与“怎么走到这里”无关。数字三角形里 dp[i][j] 只记“走到 (i,j) 的最大和”,不需要知道具体走了哪条路——满足。如果题目改成“路径上不许出现两个相邻的相同数字”,dp[i][j] 立刻不够用,因为还得记住上一格是谁。
判断方法是一句话:要算出下一个状态,我还需要知道历史里的什么?答案是“什么也不需要”,直接 DP;答案是“需要知道某段历史”,就在下标里加一维把那点历史记下来。下一节背包多出来的那一维(选没选过)、后面状压 DP 多出来的那一维(哪些点走过),都是这个思路。
| 组成 | 含义 / 格式要求 |
|---|---|
dp | 数组名。规模大(10⁶ 以上)或二维的,一律开在 main 外面,走静态区。 |
第一维:阶段 | 处理到第几行、前几个元素、走了几步、容量用了多少。挑一个只会增大的量当阶段,遍历顺序就天然合法。 |
第二维:位置 | 当前停在哪一列、下标多少、还剩多少容量。转移时在这一维上左右移动。 |
值 | 该子问题的答案:最大和 / 最小代价 / 方案数。值的类型决定会不会溢出,最值题一般 int 够,求和与计数先估上界。 |
初值 | 最省事的做法:整表填 0 或整表填 −INF,再单独写边界。绝不能让“没算过”的格子参与 max。 |
注释 | 每写一个 dp 数组,先写一行注释把含义写全。注释写不出来,说明状态还没想好,这时候敲代码只是在延长错误。 |
// 反例:状态里没有“以 i 结尾”这个信息
int dp[1005]; // dp[i]:前 i 个数的 LIS 长度(错)
// a = {5, 1, 2, 3}
// 前 2 个数 {5,1} 的 LIS 是 1,可 a[2]=1 后面还能接上 2、3
// dp[i] 与 dp[i-1] 之间根本没有可用的转移式:a[i] 可能不在最优解里
// 正例:把“最后一个元素是谁”钉进下标
int f[1005]; // f[i]:以 a[i] 结尾的 LIS 长度(对)
// f[i] = 1 + max{ f[j] : j < i 且 a[j] < a[i] }状态里必须装下“转移需要的那点信息”。LIS 需要知道“能接在谁后面”,所以下标里必须有 i。
同一道题可以有好几种状态定义,选那个让转移式最短的(本节末的三大模型就是三种常见选法)。
| 组成 | 含义 / 格式要求 |
|---|---|
fill(begin, end, v) | 把左闭右开区间 [begin, end) 逐元素赋成 v。能填任意 int / long long 值,最通用。 |
memset(p, c, n) | 把从 p 开始的 n 个字节都填成 c。只有“每个字节相同”的值才有效:0、−1、0x3f。 |
memset(dp, 0x3f, sizeof dp) | 每字节 0x3f,拼成 int 就是 0x3f3f3f3f = 1061109567,常当正无穷。 |
sizeof dp | 只有当 dp 还是数组时才是整张表的字节数。dp 当参数传进函数后退化成指针,sizeof 只剩 8。 |
为什么不用 INT_MAX | INT_MAX 加上任何正数都会溢出(未定义行为)。0x3f3f3f3f 加几百万也安全。 |
const int INF = 0x3f3f3f3f; // 1061109567
const int NEG = -0x3f3f3f3f; // -1061109567
int dp[1005];
memset(dp, 0, sizeof dp); // 全 0:求最大值且不要求“恰好”时用
fill(dp, dp + n + 1, NEG); // 全 −INF:求最大值且要求“恰好凑出”时用
fill(dp, dp + n + 1, 0); // 只清前 n+1 个:多测时用它,省掉清整张 10⁶ 的表多测题每个测试点都要重新刷表,漏刷一次后面全错,且错得莫名其妙。
long long 数组不要用 memset(dp, 0x3f, ...):得到的 0x3f3f3f3f3f3f3f3f ≈ 4.6×10¹⁸ 本身能用,但两个这样的数相加就溢出;老实用 fill 填 1e18 或 4e18。
给出一个 n 层的数字三角形,第 i 层有 i 个整数。从顶层唯一的那个数出发,每一步可以走到下一层的正下方或右下方(第 i 层第 j 列可以走到第 i+1 层第 j 列或第 j+1 列)。求一条从顶到底的路径,使路径上所有数字之和最大,输出这个最大和。
输入:第一行一个整数 n(1 ≤ n ≤ 1000);接下来 n 行,第 i 行 i 个非负整数(每个不超过 100)。
输出:一行一个整数,即最大路径和。
暴力:从顶走到底,每层 2 个选择,总共 2ⁿ⁻¹ 条路径。n = 1000 时是 2⁹⁹⁹,死了。
① 状态:dp[i][j] = 从顶走到第 i 层第 j 列的所有路径中,能取得的最大路径和。
② 转移:走到 (i,j) 的最后一步只可能来自正上方 (i−1,j) 或左上方 (i−1,j−1),两步各自取最优再比一次:dp[i][j] = a[i][j] + max(dp[i-1][j-1], dp[i-1][j])。
③ 边界:dp[1][1] = a[1][1]。另外第 i 层只有 1..i 列存在:j = 1 时没有左上格子,j = i 时没有正上格子。这两类情况必须单独写,否则会读到根本不存在的格子。
④ 答案:底行的最大值 max(dp[n][1..n])。不是 dp[n][n]——走到最右下角不一定是最大和。
无后效性:dp[i][j] 只引用第 i−1 行。第 i 行以后怎么走完全不影响它,所以按行推进一定正确。
复杂度:状态数 n(n+1)/2 ≈ 5×10⁵,每个状态两次比较,O(n²)。n = 1000 时约 10⁶ 次基本操作,1 毫秒量级。
#include <bits/stdc++.h>
using namespace std;
const int MAXN = 1005; // n ≤ 1000,开 1005 让下标 1000 安全
int a[MAXN][MAXN]; // 原始数字三角形
int dp[MAXN][MAXN]; // dp[i][j]:从顶走到 (i,j) 的最大路径和
int main() {
ios::sync_with_stdio(false);
cin.tie(nullptr);
int n;
cin >> n;
for (int i = 1; i <= n; ++i)
for (int j = 1; j <= i; ++j)
cin >> a[i][j];
// ③ 边界:起点没有“上面”,它的路径和就是它自己
dp[1][1] = a[1][1];
// ② 转移:按行推进。第 i 行只依赖第 i-1 行,所以“按行的顺序”天然就是合法的计算顺序
for (int i = 2; i <= n; ++i)
for (int j = 1; j <= i; ++j) {
if (j == 1) // 左边缘:没有左上格子,只能从正上方来
dp[i][j] = a[i][j] + dp[i - 1][j];
else if (j == i) // 右边缘:没有正上格子,只能从左上方来
dp[i][j] = a[i][j] + dp[i - 1][j - 1];
else // 中间:两个前驱取优
dp[i][j] = a[i][j] + max(dp[i - 1][j - 1], dp[i - 1][j]);
}
// ④ 答案:底行最大值,不是 dp[n][n]
int ans = dp[n][1];
for (int j = 2; j <= n; ++j) ans = max(ans, dp[n][j]);
cout << ans << "\n";
return 0;
}| 代码片段 | 含义与理由 |
|---|---|
const int MAXN = 1005; | n 最大 1000,数组要开到 1000 以上。开成 1000 时 a[1000] 越界,本地可能不报错、评测机 RE。 |
int a[MAXN][MAXN]; int dp[MAXN][MAXN]; | 两个 1005×1005 的 int 约 8 MB,必须放全局。写进 main 里是栈内存,通常只有 1~8 MB,直接段错误。 |
dp[1][1] = a[1][1]; | 边界,整题的地基。不写这行,dp[1][1] 是 0,后面全部错,而且答案看起来还挺“合理”,最难查。 |
for (int i = 2; i <= n; ++i) | 阶段推进。i 从 2 开始,因为第 1 行已被边界处理完。 |
j == 1 → dp[i-1][j] | 分类讨论第一支:最左列不存在左上格子 a[i-1][0]。如果统一写 max(dp[i-1][j-1], dp[i-1][j]),就会用到 dp[*][0] 这个不存在的格子。 |
j == i → dp[i-1][j-1] | 分类讨论第二支:最右列不存在正上格子 a[i-1][i]。dp[i-1][i] 是 0(没赋过值),一旦把它放进 max,答案会被悄悄改小。 |
max(dp[i-1][j-1], dp[i-1][j]) | 这就是“决策”:两条来路,选收益大的那条。所有 DP 的转移式都是这个形状——把可达的来源列全,取最优。 |
int ans = dp[n][1]; | 答案从底行第一个格子起步,再往右扫。用 0 起步在本题能过(数字非负),但换一题允许负数时就是错的,养成用合法格子起步的习惯。 |
cout << ans << "\n"; | 只输出一个整数加换行。 |
| 组成 | 含义 / 格式要求 |
|---|---|
为什么要压 | 第 i 行只用到第 i−1 行,再往前的行永远不再被访问。二维表里 dp[0..i-2][*] 全是死数据。 |
压成什么 | dp[j] 只保留“上一行第 j 列”的结果。算第 i 行时,它表示第 i−1 行;算完后它变成第 i 行。 |
j 的顺序 | 必须倒序(j 从 i 递减到 1)。倒序时 dp[j-1] 还没被这一轮改写,读到的是上一行的值,正确。 |
正序会怎样 | 正序时 dp[j-1] 已经是本行的新值,相当于“从同一行左边走过来”,路径非法,答案偏大。 |
内存变化 | 8 MB → 4 KB。空间换时间不一定赚,但时间换空间在这里是白赚。 |
int a[1005], dp[1005]; // dp 只需要 1005 个 int
for (int i = 1; i <= n; ++i) {
for (int j = 1; j <= i; ++j) cin >> a[j]; // 先把这一行读进来
for (int j = i; j >= 1; --j) // 再倒序更新
dp[j] = a[j] + max(dp[j - 1], dp[j]);
}
int ans = dp[1];
for (int j = 2; j <= n; ++j) ans = max(ans, dp[j]);倒序的理由和下一节 0/1 背包完全一样:一维数组里“上一层”和“本层”共用同一块空间,只有让被依赖的格子还没被写,读到的才是旧值。
这一节先用例题体会一次,下一节会把它变成背包题的生死线。
第二个模型:以 i 结尾。问题:给定 a[1..n](n ≤ 1000),求最长严格上升子序列的长度。子序列可以不连续,但要保持原顺序。
int a[MAXN], f[MAXN]; // f[i]:以 a[i] 结尾的最长严格上升子序列长度
int ans = 1;
for (int i = 1; i <= n; ++i) {
f[i] = 1; // 边界:只有 a[i] 自己,长度 1
for (int j = 1; j < i; ++j) // 枚举“紧挨在 i 前面的那个是谁”
if (a[j] < a[i]) f[i] = max(f[i], f[j] + 1);
ans = max(ans, f[i]);
}为什么状态必须写成“以 i 结尾”?因为转移时需要知道“新元素能接在谁后面”,这一点只有 i 能提供。如果把状态定义成“前 i 个数的 LIS 长度”,dp[i] 和 dp[i−1] 之间就没有可用的转移式——a[i] 可能根本不在最优解里,dp[i] 甚至可能等于 dp[i−1],推不下去。
| 代码片段 | 含义与理由 |
|---|---|
f[i] = 1; | 边界。任何单个元素自成一串,长度 1。不写这句 f 全是 0,答案会变成 1。 |
for (int j = 1; j < i; ++j) | 枚举前驱。每个状态扫一遍历史,这是 O(n²) 的全部来源。 |
if (a[j] < a[i]) | “严格上升”就写 <。换成 <= 是最长不下降,换成 > 是最长下降。一个字决定答案。 |
f[i] = max(f[i], f[j] + 1); | 把 j 接到 i 前面,长度加 1;和“不接”比一次,取优。 |
ans = max(ans, f[i]); | 答案不是 f[n]。n 号元素可能很小,最优串也许在第 3 个位置就结束了。 |
| 模型 | 状态长什么样 | 转移从哪来 | 典型题 | 答案在哪 |
|---|---|---|---|---|
| 以 i 结尾 | dp[i],把“最后一个元素”钉进下标 | 所有满足条件的 j < i | LIS、最大子段和、合唱队形 | max(dp[1..n]) |
| 多阶段推进 | dp[i][j],一层一层往前走 | 上一层相邻的若干格 | 数字三角形、方格取数、过河卒 | 底行的最值 |
| 分类讨论 | dp[i][0/1/2],把“当前处于哪种状态”写进下标 | 同层不同状态之间互转 | 股票买卖、打家劫舍、粉刷栅栏 | 末状态的某种组合 |
WA:LIS 把答案写成 f[n]。测试点里只要有一个“最后一个元素很小”的数据就挂。
WA:边界没写。dp 初值全 0,LIS 输出 1 或 0,数字三角形输出 0。症状是“样例都对、提交全 WA”。
WA:数字三角形只从 (i−1,j−1) 转移,漏掉正上方;或者忘了处理 j = 1、j = i 两个边缘,把 0 当成了合法前驱。
RE:MAXN 写成 1000 而 n = 1000,访问 a[1000] 越界;二维数组写在 main 里,1005×1005×4 B ≈ 4 MB 直接爆栈。
RE:一维滚动写成正序,虽然多数情况只错答案不崩,但只要 n 稍大、数组只开到 n,就会读到 dp[-1]。
TLE:把 O(n²) 的 LIS 交到 n = 10⁵ 的题上(例如 P1020 导弹拦截)。10¹⁰ 次比较,必挂;需要 O(n log n),本节不展开。
关掉页面,从空文件把 P1216 敲一遍并提交。再输入 1 / 5,确认输出 5 而不是 0。
改一改 ①:把 max 换成 min,求最小路径和。此时 int ans = dp[n][1] 还能写成 0 吗?为什么不能?
改一改 ②:照 FN 里的滚动数组版改一遍,只留 int dp[1005],用样例数据对比两次输出是否都是 30。再把内层 j 改成从 1 到 i 的正序,看答案变成多少——那就是“同一行内横着走”的后果。
改一改 ③:把 LIS 的 a[j] < a[i] 改成 a[j] <= a[i],输入 4 / 1 2 2 3,答案从 3 变成 4。想清楚“严格”和“不下降”差在哪。
正确性:对“状态规模”做数学归纳。① 基例:边界状态直接赋值,显然正确。② 归纳假设:所有规模小于当前的状态都已算对。③ 归纳步:当前状态引用的来源全都规模更小(数字三角形第 i 行只引用第 i−1 行),由假设它们都对,而转移式把所有可能的来源分类列尽、并在每一类里取了最优,所以当前状态也一定对。
无后效性 = 状态图是一个 DAG。把每个状态画成一个点,每条转移画成一条有向边。上面第 ③ 步要求“边只从规模小的点指向规模大的点”,这保证了图上无环。有向无环图必有拓扑序,按拓扑序递推就是合法的计算顺序——这就是“循环怎么写”的全部依据。
反过来:如果状态之间出现环(dp[i] 依赖 dp[i+1],而 dp[i+1] 又依赖 dp[i]),就没有天然的递推序,只能改用记忆化搜索,或者把环上的方程解出来。所以“能不能按某个顺序把表填完”是 DP 可解性的判据。
| 题目 | 状态数 | 单次转移 | 总复杂度 | 暴搜复杂度 |
|---|---|---|---|---|
| 数字三角形 n = 1000 | n(n+1)/2 ≈ 5×10⁵ | O(1) | O(n²) | 2ⁿ⁻¹ |
| LIS n = 1000 | n | O(n) | O(n²) | 2ⁿ |
| 0/1 背包(下一节) | M×T = 10⁵ | O(1) | O(MT) | 2^M |
记住这个乘法:总复杂度 = 状态数 × 单次转移代价。设计状态时先把两个因子估出来。n = 1000 的 O(n²) 是 10⁶,随便过;n = 10⁵ 的 O(n²) 是 10¹⁰,必挂——这时要么压状态数,要么把转移降到 O(1) 或 O(log n)。
还有一条不写在书上的经验:状态数通常控制在 10⁷ 以内。因为每个状态至少要读写一次,10⁸ 次内存访问已经是 1 秒的极限,而 DP 的循环体往往不止一次访问。
int ans = dp[n][1] 还能用,但写成 0 起步就错了。为什么边界和答案初值要分开考虑?dp[i] = max(a[i], dp[i-1] + a[i]) 就够了,答案为什么是 max(dp[i])?n 位同学站成一排,音乐老师要请其中若干位出列,使剩下的同学排成“合唱队形”。
合唱队形指:剩下的 k 位同学从左到右编号 1..k,身高为 T₁…T_k,存在一个位置 i 使得 T₁ < T₂ < … < T_i 且 T_i > T_{i+1} > … > T_k。也就是先严格上升、再严格下降。
输入:第一行一个整数 n(2 ≤ n ≤ 100);第二行 n 个整数,表示从左到右每位同学的身高(130 ≤ T ≤ 230)。输出:一行一个整数,表示最少需要几位同学出列。
不要枚举“谁出列”,那是指数级;反过来想“最多能留下几位”。
留下的队形有一个最高点,一旦这个最高点定下来,左边只能一路升,右边只能一路降,两半互相独立。
左边那段“以最高点结尾、严格上升”,右边那段“以最高点开头、严格下降”,各算一次 LIS 就行。
枚举最高点 i(1 ≤ i ≤ n)。
左半边:以 i 结尾的最长严格上升子序列长度 f[i],用标准 LIS 正着算一遍。
右半边:以 i 开头的最长严格下降子序列长度 g[i]。做法是把 i 从 n 往 1 倒着扫,对 j > i 且 T[j] < T[i] 的做 g[i] = max(g[i], g[j] + 1)——本质就是在反方向上求一次 LIS。
以 i 为峰能留下的最多人数是 f[i] + g[i] − 1(i 被两段各数了一次,减掉重复)。
答案 = n − max(f[i] + g[i] − 1)。复杂度 O(n²),n ≤ 100 时约 10⁴ 次,瞬间出解。
为什么对:任何一个合法队形都有唯一的最高点 i,它左半边是 i 的上升前缀、右半边是 i 的下降后缀,长度不会超过 f[i] 与 g[i];反过来取到 f[i]、g[i] 的两段拼起来一定合法。所以枚举 i 不重不漏。
#include <bits/stdc++.h>
using namespace std;
const int MAXN = 105;
int t[MAXN]; // 身高
int f[MAXN]; // f[i]:以 i 结尾的最长严格上升子序列长度
int g[MAXN]; // g[i]:以 i 开头的最长严格下降子序列长度
int main() {
ios::sync_with_stdio(false);
cin.tie(nullptr);
int n;
cin >> n;
for (int i = 1; i <= n; ++i) cin >> t[i];
// 正着推:f[i] = 1 + max{ f[j] : j < i 且 t[j] < t[i] }
for (int i = 1; i <= n; ++i) {
f[i] = 1; // 边界:自成一串
for (int j = 1; j < i; ++j)
if (t[j] < t[i]) f[i] = max(f[i], f[j] + 1);
}
// 倒着推:g[i] = 1 + max{ g[j] : j > i 且 t[j] < t[i] }
// 必须 i 从 n 递减,才能保证用到 g[j] 时它已经算好
for (int i = n; i >= 1; --i) {
g[i] = 1;
for (int j = n; j > i; --j)
if (t[j] < t[i]) g[i] = max(g[i], g[j] + 1);
}
int keep = 0;
for (int i = 1; i <= n; ++i)
keep = max(keep, f[i] + g[i] - 1); // i 被两段都数了一次,减 1
cout << n - keep << "\n";
return 0;
}| 片段 | 作用 |
|---|---|
for (int i = 1; i <= n; ++i) | 正推阶段。i 从小到大,f[j] (j < i) 一定已经算好——遍历顺序就是拓扑序。 |
f[i] = 1; | 边界:任何一个人自己都能当一段上升序列,长度 1。漏写这句 f 全 0。 |
for (int i = n; i >= 1; --i) | 倒推阶段。g 的定义是“以 i 开头往下走”,依赖 j > i,所以 i 必须从 n 往 1。写成从 1 到 n,g[j] 还没值,全错。 |
if (t[j] < t[i]) | 严格下降:站在 i 看后面,只有比 i 矮的人才能接在 i 后面。等号不能加,题目要求严格。 |
f[i] + g[i] - 1 | 减 1 是因为最高点 i 在 f 和 g 里各出现一次。忘了减,留下的人数虚高 1,输出答案偏小 1。 |
n - keep | 题目问“最少出列几位”,等于总人数减最多留下的人数。别直接输出 keep。 |
5 / 1 2 3 2 1 时正确输出 0,它输出 2。for (int i = 2; i < n; ++i)、跳过端点:输入 5 / 5 4 3 2 1 正确输出 0,跳端点后输出 1。标准解法是 1..n 全枚举。