ACM 一个月学习计划 · DAY 25

Day 25 · 线性 DP:四步法与三大模型

第一次正式学 DP —— 先把「状态定义 → 转移方程 → 边界 → 答案」这四步练成肌肉记忆,再上手四道题

今日目标:理解 DP 四要素,能独立推导数字三角形、过河卒、覆盖墙壁、合唱队形
预计用时:约 4 小时(含做题)  难度:普及- ~ 普及/提高-
题目来源:洛谷 P1216 / P1002 / P1990 / P1091
← Day 24 · 二分与数据结构综合

⏱今日时间安排

0 - 30 min搞懂 DP 四步法(第 1 节)—— 今天最重要的地基
30 - 60 min线性 DP 的三大模型(第 2 节)+ 手推数字三角形(第 3 节)
60 - 95 min路径计数 DP(第 4 节)与 LIS / 合唱队形(第 5 节)
95 - 120 min分类讨论递推:拿 P1990 覆盖墙壁当例子(第 6 节)
120 - 240 min做 4 道题,每题限时 30 分钟
240 - 270 min对照本文题解把没做出来的补掉 + 做自测题
💡 和前几天内容的衔接

其实你早就写过 DP 了,只是没这么叫它:

· Day 16 的 P1028 数的计算、Day 17 的 P1044 栈,本质都是「把答案存在数组里、用更小的答案推出更大的答案」;
· Day 17 的 记忆化搜索(P1464)就是「递归 + 把算过的结果存起来」,它和 DP 是同一件事的两种写法;
· Day 24 的 P1115 最大子段和 更是标准的线性 DP,只是当时我们用「递推」的说法糊过去了。

今天要做的是:把这套东西命名、公式化,变成一套每道题都能照着走的流程。

1动态规划到底是什么(四步法)

先看一个你一定会的问题

有一段楼梯,共 n 级台阶。你每次可以跨 1 级或 2 级,问走到第 n 级有多少种走法?

小学生都会的推法:想到达第 n 级,最后一步只有两种可能——

· 最后一步跨了 1 级,那之前站在第 n-1 级;
· 最后一步跨了 2 级,那之前站在第 n-2 级。

所以 走法数(n) = 走法数(n-1) + 走法数(n-2)。这就是斐波那契。而你刚才做的事,就是动态规划。

🎯 一句话说清 DP

把一个大问题拆成一堆「小一号的同类问题」,先把小问题的答案算好存起来,再用它们拼出大问题的答案。

「存起来」这一步是关键 —— 它保证每个小问题只算一次,这就是 DP 比暴力搜索快的原因。

关键:DP 和递归的区别在哪

做法怎么算 fib(5)复杂度
朴素递归fib(5) 要 fib(4) 和 fib(3),fib(4) 又要 fib(3) 和 fib(2)……fib(3) 被重复算了两次,越往下重复越多O(2ⁿ) 爆炸
记忆化搜索递归写,但算完 f[x] 就存进数组,下次直接取O(n)
动态规划干脆不递归:从 f[1]、f[2] 开始,一行一行往前推,推的时候用到的都已经算好了O(n)

记忆化和 DP 是同一件事:记忆化是「自顶向下 + 查表」,DP 是「自底向上 + 填表」。本文只讲后者,因为它写起来更短、更快,也不会有递归爆栈的风险。

★ DP 四步法(每道 DP 题都按这个顺序想)

下面这张表是今天的核心。以后拿到任何一道 DP 题,先在草稿纸上把这四个空填出来,代码自然就出来了。

步骤要回答的问题爬楼梯这一题填什么
①
状态定义
dp[...] 表示什么?
下标是「位置」「容量」还是「已经考虑了几个」?
dp[i] = 走到第 i 级台阶的走法数
②
转移方程
这个状态能从哪些更小的状态来?把它们怎么组合起来? dp[i] = dp[i-1] + dp[i-2]
③
边界(初始化)
最小号的状态是多少?手算出来直接写死。 dp[0] = 1(站着不动算 1 种)
dp[1] = 1
④
答案
最后要输出哪个状态?注意不一定是 dp[n]! dp[n]
// 爬楼梯:四步法直接翻译成代码
int dp[1005];
dp[0] = 1; dp[1] = 1;              // ③ 边界:手算出来写死
for(int i = 2; i <= n; i++)
    dp[i] = dp[i-1] + dp[i-2];      // ② 转移方程
cout << dp[n] << endl;            // ④ 答案
⚠️ ④ 号步骤最容易翻车

很多新手写完转移方程就 cout << dp[n],但答案不一定是 dp[n]。反例就是 Day 24 的 P1115 最大子段和:答案要在所有 dp[i] 里取 max。
每次写完代码都要回头问自己一句:「题目问的那个东西,对应的是哪个状态?」

DP 能用的两个前提

前提说人话
最优子结构大问题的最优解,一定是由「某个小问题的最优解」接上一步得到的。
所以你可以放心地只用小问题的最优值,不用管它内部长什么样。
无后效性状态算出来之后,它不受「我是怎么走到这里的」影响。
数字三角形里,只要你走到了格子 (i,j),后面能拿多少只跟 (i,j) 有关,跟前面走的路线无关。
💡 判断「状态定义合不合格」的土办法

问自己:「只给我这个状态的值,我能不能决定下一步怎么走?」
能 → 这个状态定义是无后效的,可以用;不能 → 说明状态里缺信息(比如缺了「上一个是谁」),要么加维度,要么换定义。

2线性 DP 的三大主力模型

「线性 DP」指的是状态沿着一条线(数组下标、格子坐标)往前推的 DP。今天四道题全是这个套路,而且主要是三种形态。

模型 A:以 i 结尾 —— 一维状态

状态长这样:dp[i] = 「必须选第 i 个元素」时能拿到的最大/最优值。

转移就是:枚举上一个选了谁,从 dp[j](j < i)接过来。

// 模板:以 i 结尾的线性 DP
for(int i = 1; i <= n; i++){
    dp[i] = 只选 a[i] 时的值;              // ★ 先假设"从 i 重新开始"
    for(int j = 1; j < i; j++)
        if(可以接在 j 后面)
            dp[i] = max(dp[i], dp[j] + 代价);   // 从 j 接过来
}

这个模板最典型的两个例子,一个是已学过的最大子段和(dp[i] = max(a[i], dp[i-1] + a[i])),一个是今天的 P1091 合唱队形。

🎯 「以 i 结尾」是线性 DP 最常见的状态定义

为什么要「强制以 i 结尾」?因为如果不强制,状态就变成「前 i 个里随便挑」——那你没法知道最后一个是谁,也就没法判断新的元素能不能接上(无后效性被破坏了)。
强制结尾 = 把「上一个是谁」这个信息塞进状态里。这是 DP 里最常用的一个技巧。

模型 B:多阶段推进 —— 二维状态

状态长这样:dp[i][j] = 「处理到第 i 阶段、处在第 j 个位置」时的答案。转移只看上一阶段的邻居。

// 模板:多阶段推进(数字三角形、过河卒都是这个形状)
for(int i = 2; i <= n; i++)
    for(int j = 1; j <= m; j++)
        dp[i][j] = 上一行相邻位置的组合;    // 例如 max(...) 或加法;
模型状态含义转移来源今天的题
A · 以 i 结尾dp[i]:必须选 i所有更小的 dp[j](j < i)P1091 合唱队形
B · 多阶段推进dp[i][j]:走到第 i 行第 j 列上一行的邻居P1216 数字三角形
P1002 过河卒
C · 分类讨论递推f[n]:长度为 n 的整块问题的方案数按「第一步怎么开始」分成几类,各类对应更短的前缀P1990 覆盖墙壁

第 3 行的 C 和前两行不太一样:它不枚举「上一个位置」,而是枚举「第一步有哪几种不同的走法」—— 竖着放、横着放、还是用 L 形起头。每种走法都会把问题缩小成一个更短的同类问题,把各类的方案数加起来就是答案。 「分类讨论」是 DP 里最常用的一招,本文第 6 节会用 P1990 把它讲透。

3数字三角形:为什么自底向上更省事

把四步法填一遍

步骤填什么
① 状态定义dp[i][j] = 从第 i 行第 j 列这个格子出发、一路走到最底部能拿到的最大数字和
② 转移方程从 (i,j) 下一步只能去左下的 (i+1,j) 或右下的 (i+1,j+1),取大的那个:
dp[i][j] = max(dp[i+1][j], dp[i+1][j+1]) + a[i][j]
③ 边界最后一行没有下一步了:dp[r][j] = a[r][j]
④ 答案起点就是塔尖:dp[1][1]

自顶向下 vs 自底向上,到底差在哪

同一道题,两种方向都能做,但工作量差很多:

自顶向下(从塔尖往下)自底向上(从底部往上)
状态含义dp[i][j] = 从塔尖走到 (i,j) 的最大和dp[i][j] = 从 (i,j) 走到塔底的最大和
转移dp[i][j] = max(dp[i-1][j-1], dp[i-1][j]) + a[i][j]dp[i][j] = max(dp[i+1][j], dp[i+1][j+1]) + a[i][j]
麻烦点左边界和右边界要特判:第 1 列上面没有格子、每行最后一列上面也没有格子,不特判就会读到下标 0 或越界不用特判:每个格子下面都正好有 2 个格子(最后一行除外,而它本来就是边界)
答案max(dp[r][1..r]),还要扫一遍最后一行dp[1][1],直接输出
✅ 结论:这题无脑选自底向上

自顶向下要处理两条斜边的边界,自底向上一个边界都不用管,而且答案天然就在 dp[1][1]。
以后遇到「塔形 / 三角 / 逐层推进」的题,先想想能不能倒着推。

拿官方样例手推一遍(这一步千万别跳)

输入是:

5
7
3 8
8 1 0
2 7 4 4
4 5 2 6 5

自底向上填表,最后一行原样抄下来,然后从下往上逐格填:

行填的过程填完的结果
第 5 行
(边界)
没有下一步,原样写4 5 2 6 5
第 4 行2+max(4,5)=7 7+max(5,2)=12 4+max(2,6)=10 4+max(6,5)=107 12 10 10
第 3 行8+max(7,12)=20 1+max(12,10)=13 0+max(10,10)=1020 13 10
第 2 行3+max(20,13)=23 8+max(13,10)=2123 21
第 1 行7+max(23,21)=3030

答案 30,和官方样例输出一致。这条路径就是 7 → 3 → 8 → 7 → 5,也正是题面里说的那条。

💡 为什么可以先手推再写代码

DP 题最怕的是「式子写对了但边界错」,手推一遍能让边界错误当场暴露(比如上面第 5 行如果忘了原样写,后面全错)。
DP 题写完务必手算一遍样例,逐格对照——这是学习计划原文里的原话,也是今天最重要的一条纪律。

⚠️ 数据范围与数组大小

r ≤ 1000,每个数在 [0,100] 之间。
最大和 = 1000 × 100 = 100000,稳稳在 int 里,不用 long long。
数组要开到 [1005][1005]:下标从 1 开始用,所以至少 1001;同时转移时要访问第 i+1 行,多留几格余量最保险。全局数组会自带清零,别写成局部数组。

4路径计数 DP:过河卒的马与 long long

没有马的时候,这题简单得离谱

卒只能往下或往右走。那么走到格子 (i,j) 的最后一步只有两种:

· 从上面 (i-1,j) 下来;
· 从左边 (i,j-1) 过来。

所以「到 (i,j) 的路径条数」= 「到 (i-1,j) 的条数」+ 「到 (i,j-1) 的条数」,又是一次加法型 DP。

步骤填什么
① 状态定义dp[i][j] = 从起点 (0,0) 走到 (i,j) 的路径条数
② 转移方程dp[i][j] = dp[i-1][j] + dp[i][j-1](加法!因为是数方案不是求最大)
③ 边界dp[0][0] = 1(起点自己算 1 条路)
④ 答案dp[n][m]

马的 9 个控制点

马的位置 (hx,hy) 本身、加上它一步能跳到的 8 个点,一共 9 个点都是控制点,卒不能进入。

处理办法很暴力也很好用:把这 9 个格子的 dp 值直接锁死为 0。因为条数是 0,后面谁从它转移过来也自动是 0,等于「此路不通」。

// 马自己 + 8 个跳跃点,一共 9 个控制点
int dx[9] = { 0, -2, -1,  1,  2,  2,  1, -1, -2};
int dy[9] = { 0,  1,  2,  2,  1, -1, -2, -2, -1};
for(int k = 0; k < 9; k++){
    int x = hx + dx[k], y = hy + dy[k];
    if(x >= 0 && y >= 0) blocked[x][y] = true;   // 在棋盘内才标记
}
🚫 经典坑 1:只标了 8 个跳跃点,忘了马本身

上面两个数组的第一个元素是 (0,0),对应的就是马自己所在的位置。很多人写 8 个偏移量写下习惯,顺手就漏掉了马自己——这样马脚下的格子还能走,答案直接错。
数一下:马的控制点一共 9 个,不是 8 个。

🚫 经典坑 2:坐标从 0 开始,dp[i-1][j] 会用到下标 -1

题目的坐标是 (0,0) 到 (n,m),从 0 开始。但转移时要读 dp[i-1][j],当 i = 0 时就是 dp[-1][j] —— 越界,轻则 WA 重则 RE(还可能读到别的变量,出玄学错误)。

标准解法:把所有坐标整体平移 +2,起点变成 (2,2),那么 dp[1][*]、dp[*][1] 这些"棋盘外面"的格子天然是 0,正好符合"从外面走不进来"的物理意义,一行特判都不用写。

🚫 经典坑 3:用 int 存答案

这题的路径条数是组合数级别的大。20×20 的棋盘,从 (0,0) 走到 (20,20) 一共要走 40 步,其中 20 步向下、20 步向右,条数就是 C(40,20) = 137846528820,约 1.38×10¹¹。

而 int 的上限只有 2147483647(约 2.1×10⁹)——差了将近 64 倍,用 int 必爆。必须用 long long。

实测:n=m=20、马在 (0,1) 时答案 8597496600,也已经超过 int 上限了。

💡 什么时候该警惕「答案超过 int」

看到下面任何一种情况,先把 long long 写上再说:

· 题目在数方案数 / 路径条数 / 子集个数(组合数增长极快);
· 题目里出现阶乘、乘方(比如 Day 16 的 n! 那类);
· 数据范围里的 n 有二三十,而答案是计数型(C(40,20) 这个量级);
· 题目没给答案范围,你又算不出上界 —— 那就用 long long,代价只是多几 MB 内存。

5LIS:合唱队形 = 正反两遍 LIS

什么是最长上升子序列(LIS)

从一个序列里挑出若干个元素,不改变它们在原序列里的先后顺序,让它们严格递增,问最多能挑几个。

比如 3 7 1 4 9,可以挑 3 7 9(3 个),也可以挑 1 4 9(3 个),最长就是 3。

🎯 O(n²) 的 LIS:正是「以 i 结尾」模型

up[i] = 以第 i 个数结尾的最长上升子序列长度。
想让它以 i 结尾,那前一个数可以是任意一个 j < i 且 a[j] < a[i]:
up[i] = max(up[i], up[j] + 1)

这跟第 2 节的「模型 A」一模一样。O(n²) 在 n ≤ 100 时完全够用,今天不学 O(n log n) 的贪心+二分写法。

合唱队形长什么样

合唱队形的要求是:存在一个「最高点」i,使得最高点左边严格递增、右边严格递减。整队人从左边走到最高点是一直变高的,过了最高点就一直变矮。

那么对于固定的最高点 i:

· 左边最多留 up[i] 个人(以 i 结尾的最长上升子序列);
· 右边最多留 down[i] 个人(以 i 开头的最长下降子序列);
· 但 i 自己两边都数了一遍,被重复计算了,要减 1。

🎯 合唱队形这一题的全部内容就是这一个式子

留下的最多人数 = maxi=1..n ( up[i] + down[i] − 1 )
需要出列的人数 = n − 留下的最多人数

题目问「最少需要几位同学出列」,也就是「让留下的人尽量多」,所以是 n - best,不是 best。这一步别看反了。

怎么求 down[i]

down[i] 的定义是「以 i 开头、往右走的最长下降子序列」。求法有两种,效果一样:

写法怎么想
倒着扫一遍(本文代码用这个)从 n 往 1 扫,找 j > i 且 a[j] < a[i]:down[i] = max(down[i], down[j] + 1)
把序列反转,求 LIS反转之后原来的"下降"就变成"上升"了,直接套 LIS 模板,再按原下标对应回去

两种写法本质相同。倒着扫一遍少一次数组反转,本文用这种。

⚠️ 「严格」两个字是本题的关键

题目要求身高满足 严格递增、严格递减(不能有相等的人站在一起变高或变矮)。
所以判断条件写 a[j] < a[i],不能写成 a[j] <= a[i]。

实测边界:两个人身高都是 130 时,答案必须是 1(得请出去一个),因为两个一样高的人没法构成严格递增或严格递减。

复杂度够不够

n ≤ 100,两遍 O(n²) 各约 100×100/2 = 5000 次比较,枚举最高点再 100 次 —— 总共一万多次操作,随便怎么写都不会超时。

6分类讨论递推:从「覆盖墙壁」学会枚举第一步

先把题意翻译成人话

有一面 2 行 N 列 的墙(题面写作「长为 N 宽为 2」)。手里有两种砖:

砖形状怎么用
2×1 骨牌竖放占一列,旋转 90° 就占同一行的相邻两列竖放时一次铺满一列;横放时一次铺满一行的两格
L 型砖占 2×2 方块里的 3 格(挖掉一个角)可以旋转,一共 4 种朝向
// L 型砖的四种朝向(■ 表示被覆盖)
■■    ■·    ·■    ■■
■·    ■■    ■■    ·■

两种砖都可以无限提供。问把整面墙不重不漏地铺满,一共有多少种铺法(只输出末 4 位)。

四步法填一遍

步骤填什么
① 状态定义f[n] = 把 2×n 的墙完整铺满的方案数
② 转移方程f[n] = f[n−1] + f[n−2] + 2·S[n−3]
其中 S[m] = f[0] + f[1] + … + f[m](前缀和)
③ 边界f[0] = 1(空墙只有「什么都不铺」1 种)、f[1] = 1、f[2] = 2
④ 答案f[N] % 10000(题目只要末 4 位)

★ 转移方程是怎么来的:看「左上角那一格」被谁盖住

这是本题的全部难点,也是分类讨论型 DP 的标准推导方式:盯着整面墙最左上角的那一格,枚举「盖住它的砖」有哪几种可能。

情况左上角被谁盖住接下来会发生什么这一类有多少种
A 一块竖放的骨牌 第 1 列两格一起被铺满,右边剩下一面干干净净的 2×(n−1) 墙 f[n−1]
B 一块横放的骨牌 左上角被横砖占掉之后,左下角那一格就没得选了:竖砖放不下(上面那格已被占)、L 砖也放不下(要覆盖的三格凑不齐),只能再横放一块。于是前 2 列一起铺满,右边剩 2×(n−2) f[n−2]
C 一块 L 型砖 L 有 2 种能盖住左上角的朝向(互为镜像)。盖完之后第 1 列满了,但第 2 列会留下一个缺口 —— 下面详细说 2·S[n−3]
🎯 情况 C 里那个「缺口」怎么办:它只能一列一列往右推

L 起头之后,第 2 列会剩下一格空着(缺口)。这一格能怎么盖?

· 往右推:用一块横放的骨牌盖住它,于是缺口被推到第 3 列(而且上下翻转了),第 2 列随之铺满;
· 就地收尾:用另一块 L 盖住这个缺口,同时把下一列的两格一起补满,于是从这里往右又是一面干净的墙。

关键在于:每一步都只有一种走法 —— 缺口在第几列、哪一行是确定的,能盖住它的砖也就唯一确定了。所以整段「阶梯」的形状是被完全决定的,唯一的选择只剩它在哪里收尾。

于是:

· 阶梯可以在第 j 列收尾(j ≥ 3),对每一个收尾位置 j,都恰好有 2 种铺法(对应 L 的两种镜像朝向);
· 收尾之后,右边剩下 2×(n−j) 的干净墙,有 f[n−j] 种铺法。

把 j 从 3 到 n 全加起来:2·f[n−3] + 2·f[n−4] + … + 2·f[0] = 2·(f[0]+f[1]+…+f[n−3]) = 2·S[n−3]。

✅ 三种情况合起来就是转移方程

f[n] = f[n−1] + f[n−2] + 2·S[n−3]

而这三类之所以能直接相加,是因为它们互不重叠、又盖住了全部铺法:任何一个铺法里,左上角那一格总得被某一块砖盖住,而能盖住它的砖只有竖骨牌、横骨牌、L 型这三种。

写完分类讨论的转移方程,一定要回头检查这一句「不重不漏」。这是分类讨论题最容易翻车的地方。

为什么要用前缀和 S

如果老老实实每次现算那个和,算 f[n] 就要往回扫 n 项,总复杂度 O(N²)。N 最大 10⁶ 时是 10¹² 次操作 —— 必超时。

但那个和是连续前缀(f[0] 一直加到 f[n−3]),所以可以开个数组 S[m] = f[0]+…+f[m] 把它记下来:

S[0] = f[0];
for(int i = 1; i <= N; i++)
    S[i] = (S[i - 1] + f[i]) % MOD;      // 前缀和递推,O(1) 一项

这样每一项转移都是 O(1),总复杂度 O(N)。「看到连续区间的求和,就用前缀和」——Day 15 学过的套路,今天在 DP 里又见面了。

⚠️ S 也要取模,别忘了

f 每次都被 % 10000 压住,但 S 是累加量:如果不取模,它会一路涨到 10⁶ × 10⁴ = 10¹⁰ 的量级 —— 用 int 会溢出成负数,用 long long 也是白涨。
凡是累加型的数组(前缀和、方案数),累加那一刻就要取模。

手推一遍前几项,和题面对一对

n计算过程f[n]S[n]
0边界:空墙,什么都不铺11
1边界:只能竖放一块12
2边界:两块竖放 或 两块横放24
3f[2] + f[1] + 2×S[0] = 2 + 1 + 2×159
4f[3] + f[2] + 2×S[1] = 5 + 2 + 2×21120
5f[4] + f[3] + 2×S[2] = 11 + 5 + 2×42444
6f[5] + f[4] + 2×S[3] = 24 + 11 + 2×95397

n = 3 得到 5,正好对上题面里那句「一个 2×3 的墙可以有 5 种覆盖方法」;继续往下推到 n = 13 会得到 13465,取末 4 位就是官方样例的 3465。

💡 「看最左边」还是「看最右边」?

计划原文的提醒是「分情况讨论最后一块砖怎么放(竖放 / 两块横放 / L 形)」——按最右边分类和按最左边分类是一回事,因为整面墙左右对称,得到的方程完全一样。

本文选「看左上角」,只是因为这样剩下的部分永远是一个规规矩矩的前缀,可以直接写成 f[更小的下标],不用再操心「右边那块 L 是不是缺了个角」。两种写法你都该会,考场上挑顺手的用。

7四道例题详解

下面每道题的代码都用 g++ -std=c++14 -O2 -Wall 实际编译运行过(0 warning), 并且跑过官方样例和额外的边界数据,输出是你看到的真实结果。

P1216 [IOI 1994 / USACO1.5] 数字三角形 Number Triangles 普及- · DP 入门模板

题意:给一个数字金字塔,从塔尖出发往下走,每一步可以到左下方或右下方的格子,一直走到最底部(在哪一格停下都行)。问走过的数字之和最大是多少。

⚠️ 数据范围 / 关键约束

行数 1 ≤ r ≤ 1000,金字塔里的数字都在 [0, 100] 范围内。

所以最大和 = 1000 × 100 = 100000,int 完全够用。
但要读入的数字个数最多有 1+2+…+1000 = 500500 个,建议关掉 cin/cout 与 stdio 的同步以加快读入。

思路

把「从塔尖走到 (i,j) 的最大和」翻过来想,改成「从 (i,j) 走到最底部的最大和」,然后自底向上递推:

dp[i][j] = max(dp[i+1][j], dp[i+1][j+1]) + a[i][j]

最后一行没有下一步,直接等于自己的数字(边界);答案就是塔尖 dp[1][1]。

为什么不用自顶向下?因为自顶向下时每一行的左右两条斜边都只有一个"父亲",得特判;自底向上时每个格子下面都正好有两个格子,一个特判都不用写(详见本文第 3 节的对比表)。

样例输入
5
7
3 8
8 1 0
2 7 4 4
4 5 2 6 5
样例输出
30

完整代码

#include <iostream>
#include <algorithm>
using namespace std;

// r <= 1000,全局数组自动清零
int dp[1005][1005];

int main(){
    ios::sync_with_stdio(false);   // 最多要读 50 万个数,关掉同步更快
    cin.tie(NULL);

    int r;
    cin >> r;

    // 直接读进 dp 里,省一个数组
    for(int i = 1; i <= r; i++)
        for(int j = 1; j <= i; j++)
            cin >> dp[i][j];

    // ★ 自底向上:先把最后一行当成「从该点走到最底部的最大和」
    for(int i = r - 1; i >= 1; i--)
        for(int j = 1; j <= i; j++)
            dp[i][j] += max(dp[i + 1][j], dp[i + 1][j + 1]);

    cout << dp[1][1] << endl;      // ④ 答案就是塔尖
    return 0;
}
✅ 实测结果

官方样例 5 / 7 / 3 8 / 8 1 0 / 2 7 4 4 / 4 5 2 6 5 → 输出 30 完全一致

最大数据 r = 1000,每一格都填 100 → 输出 100000,正确(100 × 1000 级台阶)
耗时约 166 ms(含进程启动与读入 50 万个数)

编译:g++ -std=c++14 -O2 -Wall → 0 warning

易错点

  • 数组开小:dp[1005][1005] 是安全值。下标从 1 开始用,而且转移要读 i+1 行,别只开 1001。
  • 写成局部数组:局部数组不初始化是随机值。要么开全局,要么写 = {0}。
  • 手推一遍样例再交:这题的错误九成出在边界上,手推能当场发现。
  • 数字虽然都是非负的,但这题不涉及"和可能为负"的问题;等到做 P1115 最大子段和时才要注意「全为负」的情况。
P1002 [NOIP 2002 普及组] 过河卒 普及- · 路径计数 DP

题意:棋盘上卒在 (0,0),要走到 (n,m),只能向下或向右走一步。棋盘上有一匹不动的马,马自己所在的点以及它一步能跳到的 8 个点都是禁区,卒不能经过。问从起点到终点有多少条合法路径。

⚠️ 数据范围 / 关键约束

1 ≤ n, m ≤ 20,马的坐标也在 0 ~ 20 之间。保证起点不是马的控制点。

棋盘很小,但答案很大:无障碍时条数是 C(40,20) = 137846528820 ≈ 1.38×10¹¹,远超 int 上限 2147483647。
必须用 long long,dp 数组也必须是 long long。

思路

四步法:

① dp[i][j] = 从起点走到 (i,j) 的路径条数;
② dp[i][j] = dp[i-1][j] + dp[i][j-1](因为是数方案,所以用加法);
③ 起点 dp[起点] = 1,马的控制点全部置 0;
④ 答案 dp[n][m]。

两个实现要点:

(1) 坐标整体平移 +2。所有坐标加上 2(起点变成 (2,2)),这样 dp[i-1][j] 在 i=2 时读到的是 dp[1][j] = 0,天然表示"棋盘外面走不进来",不用写任何边界特判。
(2) 9 个控制点。马自己的位置也算一个,漏了就错。

样例输入
6 6 3 3
样例输出
6

完整代码

#include <iostream>
using namespace std;

// ★ 路径条数会超过 int(20x20 棋盘最大约 1.4e11),必须 long long
long long dp[30][30];
bool blocked[30][30];

int main(){
    int n, m, hx, hy;
    cin >> n >> m >> hx >> hy;

    // 整体平移 2 格,这样 dp[i-1][j]、dp[i][j-1] 不会用到负下标
    n += 2; m += 2; hx += 2; hy += 2;

    // 马自己 + 8 个跳跃点,一共 9 个控制点(第一个 (0,0) 就是马自己!)
    int dx[9] = { 0, -2, -1,  1,  2,  2,  1, -1, -2};
    int dy[9] = { 0,  1,  2,  2,  1, -1, -2, -2, -1};
    for(int k = 0; k < 9; k++){
        int x = hx + dx[k], y = hy + dy[k];
        if(x >= 0 && y >= 0) blocked[x][y] = true;
    }

    dp[2][2] = 1;                       // 起点(平移后是 (2,2))
    for(int i = 2; i <= n; i++){
        for(int j = 2; j <= m; j++){
            if(blocked[i][j]){ dp[i][j] = 0; continue; }   // 控制点走不到
            if(i == 2 && j == 2) continue;                 // 起点不参与转移
            dp[i][j] = dp[i - 1][j] + dp[i][j - 1];
        }
    }

    cout << dp[n][m] << endl;
    return 0;
}
✅ 实测结果

官方样例 6 6 3 3 → 输出 6 完全一致

最大数据 20 20 0 1(马在 (0,1))→ 输出 8597496600
这个数已经超过 int 上限 2147483647 三倍多 —— 用 int 存必爆,这就是本题必须 long long 的实证。

边界 1 1 0 1(马挡住 (0,1))→ 输出 1 正确
从 (0,0) 到 (1,1) 本来有两条路,走上面那条会被马挡住,只能先向下再向右。

对拍验证:n、m 取 1~5,马的坐标穷举 0~5,共 825 组,本解法与暴力 DFS 枚举所有路径的结果全部一致。

编译:g++ -std=c++14 -O2 -Wall → 0 warning

易错点

  • 用 int 存 dp 和答案 —— 本题第一大坑。答案上界约 1.38×10¹¹,必须 long long。
  • 控制点只标 8 个 —— 马自己也是禁区,一共 9 个。写 9 个元素的偏移量数组最不容易漏。
  • 忘了平移坐标 —— 直接从 (0,0) 开始递推,会读到 dp[-1][j],越界或玄学错误。
  • 起点 dp[2][2] = 1 之后,循环里要 continue 跳过它,否则会被 dp[1][2] + dp[2][1] = 0 覆盖成 0。
  • 标记控制点时判断 x >= 0 && y >= 0,防止写到数组外面去。
P1990 覆盖墙壁 普及/提高- · 线性 DP / 分类讨论递推

题意:有一面 2 行 N 列的墙,手里有 2×1 的骨牌和 L 型(2×2 挖掉一角的 3 格)砖,两种砖都能随便旋转、无限量供应。问把整面墙不重不漏铺满一共有多少种铺法;因为答案很大,只输出末 4 位(不足 4 位就直接输出,不加前导 0)。

⚠️ 数据范围 / 关键约束

1 ≤ N ≤ 10⁶。这个范围是本题最重要的信息:它意味着必须做到 O(N)。任何 O(N²) 的写法在 N = 10⁶ 时都是 10¹² 次操作,稳稳超时。

只要输出末 4 位,所以每一步都 % 10000。注意 N = 3 时答案是 5,要输出 5 而不是 0005 —— 用整数取模再输出天然满足这一点,别手动补零。

模数只有 10000,int 就够;但前缀和 S 是累加量,累加时也要取模,否则量级会到 10¹⁰,溢出。

思路

四步法:

① f[n] = 把 2×n 的墙铺满的方案数;
② f[n] = f[n−1] + f[n−2] + 2·S[n−3](S[m] = f[0]+…+f[m] 是前缀和);
③ 边界 f[0] = 1(空墙 = 什么都不铺,也算 1 种)、f[1] = 1、f[2] = 2;
④ 答案 f[N] % 10000。

转移方程的三个来源,就是「左上角那一格被哪块砖盖住」的三种情况(详细推导见本文第 6 节):

左上角被谁盖住接着发生什么贡献
竖放的骨牌第 1 列整列铺满,右边是干净的 2×(n−1)f[n−1]
横放的骨牌左下那格只剩「再横放一块」这一条路,于是前 2 列一起铺满f[n−2]
L 型砖2 种镜像朝向;之后缺口只能一列列往右推(每步无分支),直到用另一块 L 收尾。收尾位置 j 从 3 到 n 各 1 种,右边剩 f[n−j] 种2·S[n−3]

其中 L 那一类展开就是 Σj=3..n 2·f[n−j] = 2·(f[0]+f[1]+…+f[n−3])。用前缀和把它压成 O(1),整个算法就是一遍 O(N) 递推。

样例输入
13
样例输出
3465

题面明确说了 2×13 的铺法数是 13465,取末 4 位才是 3465。也就是说,只有真的算出 13465 再取模才会得到 3465 —— 这个样例顺便能验证我们的取模位置没写错。

完整代码

#include <iostream>
using namespace std;

const int MOD = 10000;

// f[n]:2 x n 墙壁的覆盖方案数
// S[n]:f[0] + f[1] + ... + f[n],用前缀和把转移压成 O(1)
int f[1000005];
int S[1000005];

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

    // 边界:空墙 1 种;2x1 只能竖放一块 1 种;2x2 可以两块竖放、也可以两块横放,2 种
    f[0] = 1;
    f[1] = 1;
    if(N >= 2) f[2] = 2;

    S[0] = f[0] % MOD;
    if(N >= 1) S[1] = (S[0] + f[1]) % MOD;
    if(N >= 2) S[2] = (S[1] + f[2]) % MOD;

    // f[n] = f[n-1] + f[n-2] + 2 * S[n-3]
    for(int i = 3; i <= N; i++){
        f[i] = (f[i - 1] + f[i - 2] + 2 * S[i - 3]) % MOD;
        S[i] = (S[i - 1] + f[i]) % MOD;      // S 会越滚越大,必须每步取模
    }

    cout << f[N] << endl;
    return 0;
}
✅ 实测结果

官方样例 13 → 输出 3465 完全一致

小程序实测 N = 1 … 13 → 1 2 5 11 24 53 117 258 569 1255 2768 6105 3465
其中 N = 3 的 5 与题面里「2×3 的墙有 5 种覆盖方法」一致。

最大数据 N = 1000000 → 输出 3427,耗时约 32 ms(时限 1 秒)

对拍验证:写了一个暴力程序,枚举 2×N 墙的所有精确覆盖(竖骨牌 / 横骨牌 / 4 种 L 朝向),N = 1…10 的结果是 1 2 5 11 24 53 117 258 569 1255,与递推全部一致;
另外单独验证了「左上角被 L 盖住」这一类铺法数恰好等于 2·S[n−3](N = 1…10 全部吻合),也就是第 6 节里那个分类推导是对的。

编译:g++ -std=c++14 -O2 -Wall → 0 warning

易错点

  • 转移里现场扫一遍前缀求和:那是最直观但最慢的写法,O(N²),N = 10⁶ 必超时。要用前缀和数组 S 把它变成 O(1)。
  • S 忘记取模:S 是累加量,不取模会涨到 10¹⁰,int 直接溢出成负数。
  • 漏掉 f[0] = 1:它对应「L 阶梯正好铺满整面墙、右边一列不剩」这种情况。少了它 N = 3 会算成 3 而不是 5。
  • 手动补前导零:N = 3 要输出 5,不是 0005。用整数取模输出即可。
  • 分类分漏了:一定要检查「竖 / 横 / L」三类是不是既互不重叠、又覆盖了全部情况(就是第 6 节那句「不重不漏」)。
  • 数组要开到 10⁶ 级别。两个 int 数组约 8 MB,没问题;但别开成 long long 的多维数组。
P1091 [NOIP 2004 提高组] 合唱队形 普及/提高- · 正反两遍 LIS

题意:n 个同学站成一排,每人有身高。请走最少的人,使剩下的人排成「合唱队形」:存在一个最高点,最高点左边严格递增、右边严格递减。问最少请出去几个人。

⚠️ 数据范围 / 关键约束

2 ≤ n ≤ 100,身高 130 ≤ ti ≤ 230。

n 只有 100,所以 O(n²) 的 LIS 完全够用,不需要写 O(n log n) 的优化版。
注意题目要求是严格递增 / 严格递减,身高相等的人不能构成队形。

思路

枚举「最高点」是哪个同学(设为 i),那么:

· 左边最多留 up[i] 个人 —— 以 i 结尾的最长严格上升子序列;
· 右边最多留 down[i] 个人 —— 以 i 开头的最长严格下降子序列。

于是以 i 为最高点能留下 up[i] + down[i] - 1 个人(i 被两边各数了一次,要减 1)。

把所有 i 试一遍取最大,答案 = n − 最多能留下的人数。

求 up[] 是正着扫一遍 LIS;求 down[] 是倒着扫一遍,找 j > i 且 t[j] < t[i] 的转移。

样例输入
8
186 186 150 200 160 130 197 220
样例输出
4

样例最优方案:留下 150 200 160 130(先升到 200,再一路降到 130),共 4 人,所以请出去 8 − 4 = 4 人。

完整代码

#include <iostream>
#include <algorithm>
using namespace std;

int t[105];
int up[105];      // up[i]:以 i 结尾的最长「严格上升」子序列长度
int down[105];    // down[i]:以 i 开头的最长「严格下降」子序列长度

int main(){
    int n;
    cin >> n;
    for(int i = 1; i <= n; i++) cin >> t[i];

    // 正着扫一遍:上升的 LIS
    for(int i = 1; i <= n; i++){
        up[i] = 1;
        for(int j = 1; j < i; j++)
            if(t[j] < t[i]) up[i] = max(up[i], up[j] + 1);   // 严格上升
    }

    // 倒着扫一遍:等价于「以 i 为最高点的下降部分」
    for(int i = n; i >= 1; i--){
        down[i] = 1;
        for(int j = i + 1; j <= n; j++)
            if(t[j] < t[i]) down[i] = max(down[i], down[j] + 1);
    }

    int best = 0;
    for(int i = 1; i <= n; i++)
        best = max(best, up[i] + down[i] - 1);   // i 这个最高点被数了两次,减 1

    cout << n - best << endl;                    // 出列人数 = 总人数 - 留下的
    return 0;
}
✅ 实测结果

官方样例 → 输出 4 完全一致

边界 2 / 130 130(两人一样高)→ 输出 1 正确
身高相等不满足「严格」递增,必须请出去一个。如果把 < 写成 <=,这里会错答成 0。

边界 2 / 130 230 → 0;2 / 230 130 → 0 均正确(两人本身就构成合法队形,最高点可以在端点)

对拍验证:随机生成 3000 组数据(n = 2~10,身高故意限制在很小的范围以制造大量相等身高),与「暴力枚举所有子集、逐一检查是否先严格递增后严格递减」的结果全部一致。

编译:g++ -std=c++14 -O2 -Wall → 0 warning

易错点

  • 答案写成 best 而不是 n - best:题目问「最少请出去几个人」,不是「最多留几个人」。看清问的是哪个。
  • 忘了减 1:up[i] + down[i] 把最高点 i 数了两次,正确是 up[i] + down[i] - 1。
  • 判断条件写成 <=:题目要严格递增/递减,必须写 t[j] < t[i]。
  • down 的方向搞反:down[i] 是「以 i 开头往右走」,所以倒着扫、从 j = i+1 开始,条件也是 t[j] < t[i](比 i 矮的才能接在后面)。
  • 最高点可以在最左端或最右端(此时一边为空),代码天然支持,不用特判。

✗今日常见错误汇总

错误写法后果正确写法
先写代码,不先用四步法填表 式子想到一半就卡住,或者状态定义有后效性,改到崩溃 先在草稿上写清状态 / 转移 / 边界 / 答案四行,再动手敲
DP 写完直接输出 dp[n] 答案根本不一定是它(最大子段和要取 max,合唱队形要取 n−best) 回头问一遍:题目问的对应哪个状态
P1216 自顶向下写,左右边界不特判 读到下标 0 或越界,样例就过不去 自底向上,一个边界都不用管
P1002 用 int dp[30][30] 答案最大约 1.38×10¹¹,直接溢出成负数,WA long long dp[30][30]
P1002 只标 8 个马的控制点 马脚下的格子还能走,方案数偏多 共 9 个控制点,偏移量数组第一个是 (0,0)
P1002 坐标不平移,直接用 (0,0) 起推 dp[-1][j] 越界,RE 或玄学 WA 所有坐标 +2,起点变 (2,2)
P1091 比较写 <= 身高相等的人也被算进队形,答案偏小 严格递增/递减用 t[j] < t[i]
P1091 忘了 up[i] + down[i] - 1 里的 −1 最高点被数两次,留下人数偏多,出列人数偏少 减 1,再取 max
P1990 转移里现场扫一遍前缀求和 算 f[n] 要往回扫 n 项 → O(N²),N = 10⁶ 时 10¹² 次操作,超时 用前缀和 S[n] = (S[n-1] + f[n]) % MOD;,转移变 O(1)
P1990 的 S 数组忘记取模 S 是累加量,涨得比 f 快,会溢出成负数 累加那一刻就取模:S[i] = (S[i-1] + f[i]) % MOD;
P1990 漏了 f[0] = 1 「L 阶梯正好铺满整面墙」那一类跟着少算,N = 3 会得到 3 而不是 5 空墙也算 1 种铺法(什么都不铺)
输出用 printf("%lld", ...) 本机 MinGW(GCC 6.3.0)的 printf 不认 %lld,编译时就报 warning: unknown conversion type character 'l' in format,输出行为不可靠 一律用 cout(本题用 int,本来也不需要 long long)
局部数组忘记 = {0} 初值是随机垃圾,越算越错 开全局数组,或写 int dp[1005] = {0};

✓自测:你真的会了吗

第 1 题:默写 DP 四步法

答:① 状态定义(dp[...] 表示什么)→ ② 转移方程(从哪些更小的状态来、怎么组合)→ ③ 边界 / 初始化(最小号状态的值,手算写死)→ ④ 答案(输出哪个状态,不一定是 dp[n])。

再加一句判断标准:状态必须满足无后效性——只给这个状态的值,就能决定下一步怎么走。

第 2 题:数字三角形为什么推荐自底向上?

答:因为自顶向下时,每一行的最左边和最右边只有一个"父亲",转移时会读到不存在的格子,必须特判两条斜边;而自底向上时每个格子下面都正好有两个格子(最后一行除外,它本身就是边界),一次特判都不用写。而且答案天然就是 dp[1][1],不用再扫最后一行取 max。

第 3 题:过河卒为什么要用 long long?

答:因为这题在数方案数,增长极快。20×20 的棋盘从 (0,0) 到 (20,20) 要走 40 步,其中 20 步向右、20 步向下,条数是 C(40,20) = 137846528820,约 1.38×10¹¹;而 int 上限只有 2147483647(约 2.1×10⁹),装不下。

实测也印证了这一点:n=m=20、马在 (0,1) 时输出 8597496600,已经超过 int 上限。

第 4 题:合唱队形里 down[i] 是什么意思?怎么求?

答:down[i] = 以第 i 个同学为最高点、往右走的最长严格下降子序列长度(也就是"右边最多能留几个人,且 i 自己算在内")。

求法:倒着扫,i 从 n 到 1,枚举 j = i+1 .. n,若 t[j] < t[i] 则 down[i] = max(down[i], down[j] + 1)。

最后 答案 = n − max(up[i] + down[i] − 1)。

第 5 题:P1990 的转移方程 f[n] = f[n−1] + f[n−2] + 2·S[n−3],三项分别是什么情况?

答:按「左上角那一格被哪块砖盖住」分类:

· f[n−1]:竖放一块骨牌,第 1 列整列铺满,右边剩 2×(n−1);
· f[n−2]:横放一块骨牌盖住左上角,此时左下那格只剩「也横放一块」这一条路,前 2 列一起铺满,右边剩 2×(n−2);
· 2·S[n−3]:用 L 型砖起头(2 种镜像朝向)。之后第 2 列留一个缺口,缺口只能一列列往右推(每一步都没有第二种选择),最后用另一块 L 收尾;收尾位置 j 可以从 3 到 n,每个 j 恰好 2 种铺法,右边剩 f[n−j] 种,全部加起来就是 2·(f[0]+…+f[n−3]) = 2·S[n−3]。

三项互不重叠、又覆盖了全部铺法,所以可以直接相加。

再补一句:那个 Σ 是连续前缀,所以要用 S 数组把它压成 O(1);否则每项都要往回扫,总复杂度 O(N²),N = 10⁶ 时必超时。

第 6 题(手写代码):在 P1002 的代码里,如果把 if(i == 2 && j == 2) continue; 删掉会怎样?

答:起点会被转移式覆盖成 dp[1][2] + dp[2][1] = 0 + 0 = 0,于是从起点出发的路径条数变成 0,整个答案变成 0(除非起点就是终点)。

这就是为什么必须在设完 dp[2][2] = 1 之后,把起点从循环里跳过。

▤速查卡(截图存手机)

【DP 四步法】
  ① 状态定义    dp[...] 表示什么
  ② 转移方程    从哪些更小的状态来、怎么组合
  ③ 边界        最小号状态的值(手算写死)
  ④ 答案        输出哪个状态(不一定是 dp[n]!)

【线性 DP 三大模型】
  A 以 i 结尾      dp[i] = max(dp[i], dp[j] + 代价),j < i
  B 多阶段推进     dp[i][j] = f(上一行的邻居)
  C 分类讨论递推   f[n] = Σ(枚举「第一步」的各种情况)

【数字三角形(自底向上)】
  dp[i][j] = max(dp[i+1][j], dp[i+1][j+1]) + a[i][j]
  边界:最后一行 = 自己;答案:dp[1][1]

【过河卒(路径计数)】
  坐标全部 +2(起点变 (2,2))
  马的控制点 9 个:马自己 + 8 个跳跃点,全部置 0
  dp[i][j] = dp[i-1][j] + dp[i][j-1];   dp[起点] = 1
  答案会超 int → 必须 long long

【LIS / 合唱队形】
  up[i]   = 以 i 结尾的最长严格上升子序列
  down[i] = 以 i 开头的最长严格下降子序列(倒着扫)
  留下最多 = max(up[i] + down[i] - 1)
  答案 = n - 留下最多        // 问的是"出列几人"

【P1990 覆盖墙壁(分类讨论递推)】
  看【左上角那一格】被谁盖住 -> 竖放 / 横放 / L 起头
  f[0] = 1, f[1] = 1, f[2] = 2
  f[n] = f[n-1] + f[n-2] + 2 * S[n-3]
  S[n] = (S[n-1] + f[n]) % 10000   // 前缀和把 Σ 压成 O(1)
  答案 = f[N] % 10000
  O(N);N 到 1e6,写成 O(N^2) 必超时

【判断该不该用 long long】
  数方案 / 数路径 / 阶乘乘方 / 题目没给答案上界 → 用 long long

→明天预告 · Day 26

0/1 背包

今天你已经练熟了两种最常用的 DP 思路:「多阶段推进」(数字三角形、过河卒)和「分类讨论、枚举第一步」(P1990 覆盖墙壁)。 明天的 0/1 背包要把这两种思路合起来用:一件一件地处理物品(多阶段),每件物品做一个「选 / 不选」的决策(分类讨论)。

明天会重点讲清一件事:为什么一维写法里容量必须倒序枚举。这是背包最核心、最容易考的一点,也是区分"背过模板"和"真懂背包"的分水岭。

今天 P1990 的「看左上角那一格被谁盖住」、以及 P1216 / P1002 的逐格推进,已经把「转移方程 = 枚举最后一步的所有可能」这件事演示清楚了。明天你会看到又一个例子:最后一步是「放第 i 件物品」还是「不放」。