← 目录 第 31 / 60 节 全部 60 节

31 · 动态规划入门:线性 DP 四步法

把 DP 拆成四个必须回答的问题:状态、转移、边界、答案。用数字三角形和 LIS 建立“多阶段推进”与“以 i 结尾”两种模型,并第一次用滚动数组把二维表压成一维,为背包的倒序埋下伏笔。
普及/提高-预计 70 分钟DP 四步法状态设计线性 DP
本节出现的新函数 / 新语法(先看懂再用)

DP 不是一种算法,是一种把“重复计算”换成“查表”的写法。数字三角形 n = 1000 层,从顶到底的路径有 2⁹⁹⁹ 条,暴搜到宇宙热寂也跑不完;但互不相同的子问题只有 n(n+1)/2 ≈ 5×10⁵ 个。同一个子问题被重复算了天文数字次,DP 的全部工作就是让每个子问题只算一次、把结果存进数组。

写 DP 之前先在草稿纸上回答四个问题:状态是什么、怎么转移、边界在哪、答案是哪个格子。四个问题答完,代码不到 10 行;答不出来,抄模板必错。

无后效性是 DP 能不能用的唯一门槛:一个状态一旦确定,之后怎么走与“怎么走到这里”无关。数字三角形里 dp[i][j] 只记“走到 (i,j) 的最大和”,不需要知道具体走了哪条路——满足。如果题目改成“路径上不许出现两个相邻的相同数字”,dp[i][j] 立刻不够用,因为还得记住上一格是谁。

判断方法是一句话:要算出下一个状态,我还需要知道历史里的什么?答案是“什么也不需要”,直接 DP;答案是“需要知道某段历史”,就在下标里加一维把那点历史记下来。下一节背包多出来的那一维(选没选过)、后面状压 DP 多出来的那一维(哪些点走过),都是这个思路。

新函数拆解 · dp 数组:状态定义模板
int dp[MAXN][MAXN]; // 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 / memset:把 dp 表刷成同一种初值
fill(dp, dp + n + 1, NEG); memset(dp, 0, sizeof dp);
组成含义 / 格式要求
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_MAXINT_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。

例题 · 数字三角形洛谷 P1216普及-去洛谷做这题 ↗

给出一个 n 层的数字三角形,第 i 层有 i 个整数。从顶层唯一的那个数出发,每一步可以走到下一层的正下方或右下方(第 i 层第 j 列可以走到第 i+1 层第 j 列或第 j+1 列)。求一条从顶到底的路径,使路径上所有数字之和最大,输出这个最大和。

输入:第一行一个整数 n(1 ≤ n ≤ 1000);接下来 n 行,第 i 行 i 个非负整数(每个不超过 100)。

输出:一行一个整数,即最大路径和。

输入: 5 7 3 8 8 1 0 2 7 4 4 4 5 2 6 5 输出: 30
例题怎么想:先在草稿纸上把四步填完

暴力:从顶走到底,每层 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";只输出一个整数加换行。
新函数拆解 · 滚动数组:把二维 dp 压成一维
dp[j] = a[j] + max(dp[j-1], dp[j]); // j 从 i 递减到 1
组成含义 / 格式要求
为什么要压第 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),求最长严格上升子序列的长度。子序列可以不连续,但要保持原顺序。

LIS:以 i 结尾
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],推不下去。

LIS 逐行拆解
代码片段含义与理由
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 < iLIS、最大子段和、合唱队形max(dp[1..n])
多阶段推进dp[i][j],一层一层往前走上一层相邻的若干格数字三角形、方格取数、过河卒底行的最值
分类讨论dp[i][0/1/2],把“当前处于哪种状态”写进下标同层不同状态之间互转股票买卖、打家劫舍、粉刷栅栏末状态的某种组合
线性 DP 的 6 种典型死法(含症状)

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。想清楚“严格”和“不下降”差在哪。

数学补给 · DP 为什么一定对,复杂度怎么估

正确性:对“状态规模”做数学归纳。① 基例:边界状态直接赋值,显然正确。② 归纳假设:所有规模小于当前的状态都已算对。③ 归纳步:当前状态引用的来源全都规模更小(数字三角形第 i 行只引用第 i−1 行),由假设它们都对,而转移式把所有可能的来源分类列尽、并在每一类里取了最优,所以当前状态也一定对。

无后效性 = 状态图是一个 DAG。把每个状态画成一个点,每条转移画成一条有向边。上面第 ③ 步要求“边只从规模小的点指向规模大的点”,这保证了图上无环。有向无环图必有拓扑序,按拓扑序递推就是合法的计算顺序——这就是“循环怎么写”的全部依据。

反过来:如果状态之间出现环(dp[i] 依赖 dp[i+1],而 dp[i+1] 又依赖 dp[i]),就没有天然的递推序,只能改用记忆化搜索,或者把环上的方程解出来。所以“能不能按某个顺序把表填完”是 DP 可解性的判据。

题目状态数单次转移总复杂度暴搜复杂度
数字三角形 n = 1000n(n+1)/2 ≈ 5×10⁵O(1)O(n²)2ⁿ⁻¹
LIS n = 1000nO(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 的循环体往往不止一次访问。

举一反三 · 想清楚这些再往下走
  1. 数字三角形如果允许负数,int ans = dp[n][1] 还能用,但写成 0 起步就错了。为什么边界和答案初值要分开考虑?
  2. 最大子段和(P1115):状态怎么设?为什么 dp[i] = max(a[i], dp[i-1] + a[i]) 就够了,答案为什么是 max(dp[i])?
  3. 把 LIS 条件里的 < 换成 <=、>、>=,分别求的是什么?如果要求“最长上升子序列的个数”,状态要加什么?
  4. 如果题目要求输出这条最大和路径本身而不只是和,需要多存什么?为什么存“从哪来”比存“整条路径”省空间?
  5. 数字三角形的滚动数组为什么必须倒序 j?这个倒序和下一节 0/1 背包的倒序,是同一个原因吗?
  6. 如果题目改成“可以从底边任意位置出发、走到顶”,状态定义和答案位置要怎么改?代码要改几行?
自测:能全部回答再进下一节
挑战题 · 合唱队形
洛谷 P1091普及/提高-去洛谷做这题 ↗

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。
这题最容易错的地方
  • g 数组用 i 从 1 到 n 推:用到的 g[j] 还没算出来,全是 1,下降段全被算成 1。样例恰好还能输出 4(因为 max f[i] 就是 4),但输入 5 / 1 2 3 2 1 时正确输出 0,它输出 2。
  • 忘记 f[i] + g[i] − 1 里的 −1,输出比正确答案小 1;有的数据能过,纯属运气。
  • 把 f 和 g 都算成“整个序列的 LIS 长度”,两段长度对不上同一个峰,拼出来的队形不合法。
  • 最高点写成 for (int i = 2; i < n; ++i)、跳过端点:输入 5 / 5 4 3 2 1 正确输出 0,跳端点后输出 1。标准解法是 1..n 全枚举。
  • 输出 keep 而不是 n − keep。读题时把最后一句话圈出来再写输出。
延伸阅读 / 参考